You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

40 KiB

语音纪要 → 拾忆 → MCP 链路审计与落地计划

审计日期 2026-09-18,基于分支 along 工作区 + 阿龙测试环境(8.133.166.29)真实数据。 状态:三批全部落地并已部署阿龙测试环境验收通过(2026-09-18),代码未提交;客户端改动(3.5/3.6 那两处)尚未发包。\n> 执行时按「第 4 节 分批」逐批做,每批做完跑对应验证再进下一批。

1. 链路全貌

录音/导入 ─→ echomeet_addrecord ─→ echomeet_starttask
   │                                   ├─ 普通录音: Submit ASR ─→ 回调 / 客户端轮询 / cron 兜底 ─→ finishTranscribe(翻译) ─→ AI 队列
   │                                   └─ VOICETRANSLAT(带转写): TranslateProcess ─→ AI 队列(跳过识别;源=目标时连翻译也跳过)
   └─ AIProcess: summary + overview 两路并行 ─→ Completed
        └─ 第三路 extractMemoryTodos ─→ memory.ExtractMeetingTodos(再跑一次 LLM)─→ memory_item(source=meeting)

拾忆读路径: memory_list / memory_today / memory_upcoming ─→ 客户端日历 + 本地系统通知
MCP 只读:   search_meeting_notes(纪要) / get_memory_items / get_memory_stats / get_memory_report
周报月报:   cron(周一/1号 00:30, 容器时区) ─→ uidsWithItemsBetween(≥3条) ─→ Redis 队列 ─→ SQL 统计 + LLM 文案
            ─→ memory_report ─→ 客户端 memory_today 带出待确认标记 ─→ 弹窗播报 ─→ memory_confirmreport

关键文件:

环节 文件
转写/总结流水线 apps/services/modules/echomeet/tasks.go(SubmitTranscribeTask / PollTranscribe / finishTranscribe / AIProcess / extractMemoryTodos)
ASR 回调 apps/services/modules/echomeet/api_backcall.go(字节)、api_alibackcall.go(阿里)
带转写直接总结 apps/services/modules/echomeet/api_starttask.go 的 Rtype == "VOICETRANSLAT" 分支
待办抽取 apps/services/modules/memory/meeting_extract.go
拾忆模型/接口 apps/services/modules/memory/{model,api_*,stats,remind,report}.go
跨模块公共 apps/services/comm/memory.go、comm/memoryquery.go、comm/module.go(IEchomeet / IMemory)
MCP 工具 apps/services/modules/mcp/tool_meeting.go、tool_memory.go
客户端 apps/client/lib/data/services/memory_service.dart、lib/modules/main_tab/views/widgets/memory_{daily,report,edit}_sheet.dart、lib/modules/home/controllers/calendar_controller.dart、lib/modules/translation/models/translation_export.dart

2. 发现清单

✅ = 测试库 / 日志里有实证;⚠️ = 读代码推出的,尚未在真机撞到。

① echomeet 按 id 操作的接口全部不校验归属(高,✅ 代码逐个核过)

getrecord / getrecords / modifyrecords / delrecords / readrecord / summary / starttask / uprecord 八个接口只按 id 查记录,没有一处比对 record.Uid == session.GetUserId()(starttask 用了 GetUserId(),但只拿来扣算力和查 VIP)。

后果:任何登录用户改 id 就能读别人纪要全文、删改别人记录、给别人的记录发起总结(扣自己算力,但覆盖对方 summary 并触发对方的拾忆抽取)。

对照:memory 模块每个接口都有 requireUID + rec.Uid != uid,写法现成。回调接口(backcall / alibackcall)是第三方来的、无 session,不在此列。

② gorm default: 标签吞零值:date_certain=false / remind_ahead=0 永远写不进库(高,✅)

mysql.Insert 就是 db.Table().Create(model)(lego/sys/mysql/mysql.go:89)。gorm 对带 default: 标签的字段,Create 时零值一律换成默认值。DBMemoryItem.date_certain 标了 default:true、remind_ahead 标了 default:5。

实证(starpivot-app.memory_item):

source    date_certain  remind_ahead  n
assistant 1             5             9
meeting   1             5             16

ExtractMeetingTodos 明明写了 DateCertain: false, RemindAhead: 0,16 条会议待办全部落成 1 / 5。后果:

  • 客户端 todo_scope_list 的「待定日期」分组永远是空的,没截止日的待办全堆在会议当天;
  • 客户端 memory_upcoming 按 remind_ahead=5 展开 → 未来日期的会议待办会在 08:55 响系统通知,与代码注释「会议待办默认不提醒」相反;
  • 用户手动建闹钟选「不提醒」(memory_edit_sheet 把 remindAhead 置 0)同样被写成 5。

api_update 走 Save(写全字段),不受影响。

③ 没有内容门槛,短录音 / 单人自述照样进拾忆和 MCP(高,✅)

  • memory_item id=26:6 秒测试通话(call_20260918_112659.wav)总结成「继续进行进一步的功能测试」,随即抽成一条待办。
  • memory_item id=23/24(记录 530,20 秒单人语音):抽出「将本次语音录音准确转写为规范文字稿」「按'会议记录版本'格式整理输出」。第一遍审计误判为模板 outline 泄漏,拉出完整 summary 看,是说话人自己说的需求("提出需求:将测试语音导出为文字版或会议记录版本"),总结模板强制有「四、待办事项」一节,LLM 没有真待办就把这句愿望改写成行动项,抽取器再照单全收。根因与 id=26 同源:内容太薄 + 模板强制出待办节。
  • MCP search_meeting_notes 只挡 CHAR_LENGTH(summary) >= 20,这类「内容零散、按兜底规则输出」的废话总结有 600 多字,照样进检索结果、挤掉真会议。
  • 周报阈值 report_min_items=3:一次会议抽出 3 条废话就够触发一份周报(又一次 LLM 调用)。测试库 W37 那份周报正是靠 9 月 7 日那批会议待办凑够条数生成的。

AIProcess 与 ExtractMeetingTodos 都没有最短时长 / 最少字数判断。

④ 说话人标签三种格式并存(中,✅)

写入点 格式
api_alibackcall.go / api_backcall.go / PollTranscribe Speaker_0
tasks.go finishSyncTranscribe(字节 flash 同步通道) Speaker 0(空格)
client translation_export.dart(通话/同传/面对面归档,2026-09-18 新加) 0 / 1

LLM 拿到 [0]:、[Speaker 2]、[Speaker_1] 混着,owner 就写成 Speaker2、[Speaker_0] 各种样(memory_item id=25 owner=Speaker2、id=12 owner=[Speaker_0])。MCP meaningfulPersonnel 只认 Speaker_ 前缀,其余格式会被当成「用户命名过的真人」塞给模型。

⑤ 客户端上报的 tz 是缩写不是 IANA 名(中,✅)

MemoryService._tz() 用 DateTime.now().timeZoneName → "CST";服务端 comm.LoadMemoryLocation("CST") 解析失败静默退回容器时区。库里 tz 列只有空串和 CST 两种值。国内用户碰巧没事,海外用户的提醒时刻会按北京时间算,且不报任何错。flutter_timezone 已在 pubspec.yaml 里但一处都没用。

⑥ 删纪要不删待办(中,⚠️)

echomeet_delrecords 不碰 memory_item,source_id 指向的记录没了,待办还挂在日历上,点进去找不到来源。

⑦ 周报/月报的卡单补偿是死的、cron 错过不补、失败不重试(高,⚠️)

三个问题一个根:

  • requeueIfStale 只由 memory_getreport 触发;客户端只调无参版本 → 服务端走 latestUnconfirmedReport,它的 SQL 只查 state=Done → pending / processing / failed 的报告永远不会被查出来,也就永远不会被重新入队。注释里写的「容器 00:30 重启一次就靠它救」实际救不了。
  • 周一 00:30 容器不在线,那一周连 memory_report 行都不会建;下周 tick 算的是下一个 period_key,不回头补。
  • state=Failed 后 GetReport 直接回错,下一次 tick 是新周期,旧的永远 Failed。

⑧ 重复项在周期统计里恒为 1 次(中,⚠️)

computeStats 与 MCP get_memory_stats 按 happen_date 落在周期内计数,一条 daily 闹钟只有一行、锚点在几个月前 → 本周 alarm_total 里它是 0。

⑨ ASR 回调与轮询可能撞车,回调不加锁(中,⚠️ 潜在)

AliBackCall / BackCall 直接同步 TranslateProcess + SubmitAITask,不检查 finishing 锁、不判 State;PollTranscribe 同一时刻若也拿到 Success,两边各翻译一遍、各 LPush 一次 → AIProcess 跑两遍(两次 LLM 计费,第二次覆盖第一次,extractMemoryTodos 也跑两轮)。SubmitAITask 入队前没有 inQueue 判重(sweepStuckSummarize 有,它没有)。72 小时日志里回调命中 1 次、未观察到重复完成;转写文件越长(回调越晚、轮询越多次)越容易撞。

没问题、不用动的

  • VOICETRANSLAT 分支(带转写直接总结):跳识别、源=目标跳翻译、AIProcess 用 Translate 退回 Original、extractMemoryTodos 照常触发,2026-09-18 真机 id=555 已验证。
  • MCP 三个工具:uid 从 JWT 解、只读、按字符截断、全文索引失败退 LIKE。
  • 周/月边界、ISO 周键、闰月范围、周日归属、容器时区口径:有单测且正确。upsertReport 已确认不重算、ConfirmReport 有归属校验、空报告自动确认。
  • 会议归属日期用 creationtime(上传时刻):补录旧录音会偏,代码里已注明是已知偏差,本次不动。

3. 修改方案

每条按「改哪 / 怎么改 / 边界 / 验证」写。原则:不改 proto、不重生成 pb、不改表结构、存量修正幂等、用户可见行为不变。

3.1 归属校验(对应 ①)

改哪:apps/services/modules/echomeet/model.go + 八个 api_*.go。

怎么改:

// model.go
func (this *modelComp) getrecordforuid(uid string, id uint64) (*pb.DBEchoMeetRecord, error)   // WHERE uid=? AND id=?
func (this *modelComp) getrecordsforuidids(uid string, ids []uint64) ([]*pb.DBEchoMeetRecord, error) // WHERE uid=? AND id IN ?
func (this *modelComp) delrecordsforuid(uid string, ids []uint64) error                       // DELETE WHERE uid=? AND id IN ?

八个接口改调上面三个;查不到统一回 ErrorCode_DBError「记录不存在」——不要区分「不存在」和「不是你的」,避免被用来探测 id。

边界:

  • getrecords 批量里混了别人的 id → 只返回自己的那部分,不报错。客户端轮询队列里可能有已删记录,报错会掀掉整个轮询(MeetingTaskService._executeTask 那个坑)。
  • delrecords 同理按交集删,响应里 Ids 回实际删掉的。
  • starttask / summary 在归属校验之后再扣算力,否则拒绝了还扣钱。
  • 回调接口不动。

验证:

  • 源码扫描单测 TestApisUseUidScopedQueries:api_*.go(回调两个除外)里不许出现 model.getrecord( / getrecords( / delrecords(。比模型级单测更值:最容易复发的方式 不是有人改回去,而是新加一个 api 文件时照旧写法抄一遍,那样既不报错也看不出来。 模型级单测要连真库,本仓库没有测试库,跳过。
  • 真机:用 iPhone 账号(2095078768153460736)的 token 请求安卓账号(2095032531807109120)的记录 559,改前 200,改后回错。

3.2 插入后补写 + 存量修正(对应 ②)

改哪:apps/services/modules/memory/model.go 的 addItem,加 migrate_defaults.go。

⚠️ 本节方案在执行时推翻重写过一次。 原计划写的是给 Create 加 Select("*") 强制写全字段——那是错的,实测无效。gorm 的零值→默认值替换发生在 callbacks.ConvertToCreateValues 的 reflect.Struct 分支里:

if values.Values[0][idx], isZero = field.ValueOf(ctx, stmt.ReflectValue); isZero {
    if field.DefaultValueInterface != nil {
        values.Values[0][idx] = field.DefaultValueInterface   // ← 无条件替换

判据只有「字段值是不是零值」,完全不看 Select / Omit。DryRun 实测两种写法 生成的 VALUES 一模一样(date_certain=true, remind_ahead=5),这条结论已写成 测试 TestGormCreateSwallowsZeroValues 钉住,免得下次又有人去试 Select("*")。

实际怎么改:插完把那两列按结构体的现值补写回去。

func (this *modelComp) addItem(item *pb.DBMemoryItem) error {
	...
	// gorm 替换零值时连结构体上的字段一起改(field.Set),所以先留一份真值:
	// 不还原的话 memory_add 回给客户端的 item 也是被改过的
	dateCertain, remindAhead := item.DateCertain, item.RemindAhead
	if err := mysql.Insert(comm.TableMemoryItem, item); err != nil { return err }
	fix := map[string]interface{}{}
	if !dateCertain { fix["date_certain"] = false; item.DateCertain = false }
	if remindAhead == 0 { fix["remind_ahead"] = int32(0); item.RemindAhead = 0 }
	if len(fix) == 0 { return nil }
	// 补写失败只告警不回错:行已经插进去了,回错会让 memory_add 对客户端报
	// 「新增失败」,而那条记忆项其实在库里
	if err := mysql.Table(...).Where("id = ?", item.Id).Updates(fix).Error; err != nil {
		this.module.Warnf(...)
	}
	return nil
}

一处改完,ExtractMeetingTodos、api_add、migrate_task 三个写入方一起修好。

为什么不去掉 default: 标签:要改 proto → 重生成 memory_db.pb.go(生成器版本 v1.36.6 的坑) → 且 MySQL 列上已建出来的 DEFAULT 不会跟着变,收益为零。

为什么不改成 map 形式的 Create(ConvertMapToValuesForCreate 确实不做替换): 那要手抄 27 个列名,proto 加字段时静默漏写,风险比这一条 UPDATE 大得多。

怎么防漏:受影响的只有「默认值非零」的列(default:0 / default:false 替换了还是零值,无害)。 TestGormNonZeroDefaultsAreHandled 用反射扫 pb 结构体的 tag,与 gormZeroDefaultColumns 逐字比对;proto 里再加一个非零 default 而 addItem 没跟上,这条测试会红。 DBMemoryReport 另有一条测试确认它目前没有这类列(它也走 mysql.Insert)。

存量修正(migrate_defaults.go,memory 模块 Start() 里 go 跑,幂等,失败只记日志):

UPDATE memory_item SET remind_ahead = 0
 WHERE source = 'meeting' AND user_edited = 0 AND remind_ahead <> 0;
UPDATE memory_item SET date_certain = 0
 WHERE source = 'meeting' AND user_edited = 0 AND due_raw <> '' AND date_certain = 1;

date_certain 无法百分百还原(当初真推算出日期的那些本来就该是 1),只把 「有 due_raw 却仍落在会议当天」这个能确定判错的子集回退成待定。只碰 user_edited = 0。 助手闹钟那 9 条 remind_ahead=5 不动——当初用户是不是选了「不提醒」已无从知道, 且 5 分钟是产品默认值,保守。

不写 next_remind_at:全项目没有任何地方读它(只有 recalcRemind 在写, grep 过一遍),动它只会扩大修正的影响面。

边界:normalizeItem 里 HappenDate == "" 时置 DateCertain = false 这条现在才真正生效; 客户端 todo_scope_list.dart:248 已有 !dateCertain → 待定 分组,不用改。

验证:

  • 单测(已加):TestGormCreateSwallowsZeroValues / TestGormNonZeroDefaultsAreHandled。
  • 真机:跑一次会议总结后查库;手动建闹钟选「不提醒」后查库 remind_ahead=0; memory_upcoming 不再返回会议待办的 08:55 槽位。

3.3 内容门槛(对应 ③)

分两层,刻意不改用户可见的总结行为:短语音备忘("记一下明天买菜")是合法用法,总结照出,只是不该进拾忆和 MCP。也不把短录音判 SummarizFail——客户端把 10001 显示成「总结失败,请稍后重试」,用户会反复点。

3.3.1 抽取门槛(apps/services/modules/echomeet/tasks.go extractMemoryTodos)

// 有效字符:去掉 "[Speaker_x]:" 标签后的 rune 数
if rec.Seconds < opts.MeetingExtractMinSeconds || effectiveRunes(rec) < opts.MeetingExtractMinChars {
	this.module.Infof("extractMemoryTodos id:%d 内容太薄(%ds/%d字),跳过抽取", ...)
	return
}

阈值放 memory 的 Options:meeting_extract_min_seconds(默认 60)、meeting_extract_min_chars(默认 120),deploy/app/confs/home.yaml.example 同步加注释。两项都允许配 0(=这一道不限),所以回默认值的判据是「配置里没有这个键」而不是「值 <= 0」。

门槛判断放在 memory.ExtractMeetingTodos 入口(阈值在它自己的 Options 里),判定本身抽成纯函数 meetingContentEnough(seconds, runes, minSec, minChars) (bool, string) 方便单测,日志由调用方打。

⚠️ 字数必须数转写正文,不能数纪要:内容极薄时模型按模板兜底规则照样写出六百多字(id=23/24/26 就是),卡纪要字数一条也拦不住。正文字数由 echomeet 侧的 transcriptRunes(rec) 算(口径与 AIProcess 拼 originalText 一致:优先译文、空则退回原文,只数 Content 不数 [Speaker_x] 标签)。

⚠️ IMemory.ExtractMeetingTodos 改成收一个 comm.MeetingExtractInput 结构体,不是原计划的「再加一个 seconds 位置参数」——那样会变成 ctx + 5 个字符串 + 2 个数字,把 summary 和 meetingDate 写反编译器一句话都不会说。

⚠️ 数据缺失(seconds / runes 为 0)按通过处理:宁可多抽几条用户能自己删的待办,也不要因为上游漏传一个字段就把所有会议的抽取静默关掉。

3.3.2 抽取 prompt 加两条(meeting_extract.go meetingExtractPrompt)

8. 纪要若注明「非真实会议」「单人测试」「无待办」「不构成会议场景」之类,直接输出 []。
9. 说话人对自己的愿望、需求、期望的描述(如「希望导出成文字」「想评估一下效果」)不是待办;
   只收录会议中明确指派给某人 / 某方去执行的事。

3.3.3 MCP(tool_meeting.go):加 Where("seconds >= ?", comm.MeetingMinSeconds)。seconds 列现成,不用新索引。

⚠️ 阈值提成 comm.MeetingMinSeconds 常量:mcp 是独立进程,读不到 memory 的配置,两边各写一个 60 迟早会漂。

⚠️ 只改 recentQuery 不够:Handl 里另拼了一份一模一样的过滤条件给「无关键词」那条路径用,加在 recentQuery 上的门槛对它不生效。已把那份重复条件删掉,四条检索路径(无关键词 / 全文 / LIKE / 回退)统一从 recentQuery 起手。

3.3.4 周报阈值:不动。垃圾待办不再产生,report_min_items=3 自然凑不够。

验证:

  • meeting_extract_test.go 已加 TestMeetingContentEnough(8 个用例,含两条「数据缺失不误伤」)与 TestOptionsExtractThresholdDefaults(默认值 vs 显式配 0);echomeet 侧加 TestTranscriptRunes。prompt 第 8/9 条的效果只能真机看,没有 fake ChatLLM。
  • 真机:拿 559(6 秒)点「重新生成」,memory_item 不应新增;60 秒以上正常会议照旧抽取;MCP 问「最近的会议」不再返回 559。

3.4 报告补偿(对应 ⑦)

改哪:apps/services/modules/memory/report.go 新增 ensureRecent(uid),api_today.go 调它。

怎么改:

// ensureRecent 用户回来时(memory_today)补两件事:最近一个已结束的周 + 最近一个已结束的月。
// 只看这两个周期,不追更早的——与「只弹最近一份」口径一致,也避免半年没开 App 的用户一回来
// 就建二十份报告。
func (this *reportComp) ensureRecent(uid string) {
	now := time.Now()
	for _, p := range []period{lastWeek(now), lastMonth(now)} {
		rec, err := this.module.model.getReportByPeriod(uid, p.ptype, p.key)
		if err != nil { continue }
		switch {
		case rec == nil:
			// cron 错过了(容器当时不在线):按同一道闸门补建
			if n := this.module.model.countItemsBetween(uid, p.start, p.end); n < this.options.ReportMinItems { continue }
			rec = &pb.DBMemoryReport{Uid: uid, PeriodType: p.ptype, PeriodKey: p.key, PeriodStart: p.start, PeriodEnd: p.end}
			if this.module.model.upsertReport(rec) == nil { this.enqueueOnce(rec.Id) }
		case rec.State == Pending || rec.State == Processing:
			if now.Unix()-rec.UpdateTime > this.options.ReportStaleSec { this.enqueueOnce(rec.Id) }
		case rec.State == Failed && !rec.Confirmed:
			if now.Unix()-rec.UpdateTime > this.options.ReportStaleSec && retryCount(rec) < 3 {
				rec.State = Pending; bumpRetry(rec); _ = this.module.model.saveReport(rec); this.enqueueOnce(rec.Id)
			}
		}
	}
}

落地时相对上面这版草稿的调整:

  • 拆成 ensureRecent / ensureBuilt / retryFailed 三个小函数,pending/processing 那支直接复用 requeueIfStale,不另写一遍超时判定(两份阈值迟早会漂)。
  • 周期计算抽成 lastFinishedWeek / lastFinishedMonth,TestLastFinishedWeekMatchesCronKey 钉住它与 cron 的 tick 算出同一个 period_key——差一格就会重复建单或永远补不上。
  • enqueueOnce = LPos 判重 + LPush(echomeet sweepStuckSummarize 的做法)。
  • process() 开头加 SETNX memory:report:proc:<id> EX 600(经 redissys.RKey 加应用前缀),cron 与 ensure 撞上也只跑一次 LLM;拿不到锁直接返回。
  • Failed 重试上限 3 次:error_msg 前缀记 retry=N;,retryCount/withRetryCount 解析它,不加列。 ⚠️ fail() 也必须保住这个前缀——它原本直接覆盖 error_msg,计数会被清零, 于是一份永远失败的报告可以无限重试,上限形同虚设。TestFailKeepsRetryCount 守着。
  • api_today.go:go this.module.report.ensureRecent(uid),整个包在 goroutine 里、带 recover,不阻塞 today 响应。
  • requeueIfStale 保留(对带 period_key 的查询仍有用),latestUnconfirmedReport 不改。

边界:memory_today 每次切前台都调,ensureRecent 只多两次主键查询 + 至多一次 count;UpdateTime 节流保证同一份报告至多每 ReportStaleSec(900s) 重试一次。countItemsBetween 复用 uidsWithItemsBetween 的条件(happen_date 范围),单 uid 版。

验证:

  • 把测试库一条报告改成 state=0, update_time=0,切一次前台,900s 内变 Done。
  • 把 W37 那条改成 state=3, confirmed=0, update_time=0,切前台后重新生成;连改三次后第四次不再入队。
  • 手动删掉上周的报告行,切前台后(该用户上周 ≥3 条记忆项时)重新出现。

3.5 说话人标签统一(对应 ④)

改哪:

文件 改动
apps/services/modules/echomeet/tasks.go finishSyncTranscribe fmt.Sprintf("Speaker %s", ...) → "Speaker_%s"
apps/client/lib/modules/translation/models/translation_export.dart _speakerOf '0'/'1' → 'Speaker_0'/'Speaker_1';test/translation_export_test.dart 两条断言同步改

Personnel 拼接随之一致。存量数据不动(客户端说话人重命名功能会把它们改掉)。

落地时的两点调整:

  • 服务端两处(finishSyncTranscribe 与 PollTranscribe)原本各写了一遍同样的循环、 只有前缀不同,已抽成 normalizeSpeakers(rec, contexts) 一个函数,从根上不会再漂。
  • 客户端那两个取值提成常量 kSpeakerSelf / kSpeakerPeer。 ⚠️ 这条有用户可见变化:转写页(speech_tab.dart:76)直接把 speaker 字段当文案显示, 翻译归档来的记录以前显示成裸 0 / 1,现在与其它纪要一样显示 Speaker_0。 这是把它改对,不是引入不一致。

验证:meeting_extract_test.go 加一条:输入含 [Speaker_0]: 的纪要,owner 输出不带方括号;tool_meeting.go 的 meaningfulPersonnel("[Speaker_0] [Speaker_1] ") 返回空。

3.6 时区(对应 ⑤)

客户端 memory_service.dart:

static String? _ianaTz;              // 启动时取一次,进程内缓存
static Future<void> warmTz() async { _ianaTz = await FlutterTimezone.getLocalTimezone(); }
String _tz() => _ianaTz ?? DateTime.now().timeZoneName;   // 拿不到再退缩写

warmTz() 挂在 MemoryService.onInit(unawaited)。

服务端 comm/memory.go LoadMemoryLocation:

  • 解析失败打一条 warn(现在静默);
  • 顺手认固定偏移:UTC+8 / GMT+08:00 / +08:00 → time.FixedZone;CST 歧义(中国/美国中部都叫 CST),按 Asia/Shanghai 解释并注明——把已落库的 9 条兜住。

⚠️ flutter_timezone 3.0.1 的 getLocalTimezone() 返回的是 Future<String>, 不是带 .identifier 的对象——照新版 API 写会编译不过。

验证:新建一条闹钟查库 tz='Asia/Shanghai';服务端 TestLoadMemoryLocation 11 个用例(IANA / CST / UTC+8 / GMT+08:00 / +08:00 / -0530 / 认不出来退本地而非 UTC)。

3.7 删纪要连带删待办(对应 ⑥)

改哪:comm/module.go IMemory 加 DeleteMeetingItems(uid string, recordIDs []string) (int64, error);memory/meeting_extract.go 实现;echomeet/api_delrecords.go 在归属校验后 go 调它(失败只记日志)。

只删 user_edited = 0 的——用户改过的待办已经是他自己的东西,来源没了也该留着。

⚠️ model 层那条 delMeetingItemsBySource 带 uid 一起查:入参来自 echomeet 的删除请求, 光信 source_id 等于把归属校验的成果又丢掉一次。

验证:删一条带自动待办的纪要,memory_item 里对应 source_id 且 user_edited=0 的行消失,user_edited=1 的保留。

3.8 回调幂等 + 入队判重(对应 ⑨)

改哪:api_backcall.go / api_alibackcall.go 开头;tasks.go SubmitAITask。

// 回调重放 / 与轮询撞车:状态已经不是 Transcribing 说明别的路径已经收尾,直接 ack
if model.State != pb.DBEchoMeetRecordState_Transcribing { resp = ...; return }
if _, busy := this.module.tasks.finishing.LoadOrStore(model.Id, struct{}{}); busy { resp = ...; return }
defer this.module.tasks.finishing.Delete(model.Id)

SubmitAITask:LPush 前 inQueue(awaitKey) / inQueue(procKey) 判重,已在队列里直接返回。

落地时相对草稿的三点调整:

  • 抢锁逻辑抽成 beginTranscribeFinish / endTranscribeFinish 一对函数, 状态判定与内存锁合在一处,两个回调各一行。放行的状态是 Transcribing 与 AwaitTranscribing——提交成功到把状态写成 Transcribing 之间有个窗口,快的 provider 能在这中间就把回调打回来,只认 Transcribing 会把 合法回调丢掉。
  • ⚠️ 失败回调也必须过这一关(草稿里漏了):状态检查原本只挡成功分支的话, 一条已经 Completed 的记录被一个迟到的失败回调打回 TranscribeFail, 用户看到的就是「纪要好端端地变成了转写失败」。现在两个分支都在闸门之后。
  • 挡下来时仍回 SUCCESS:对第三方来说这次通知已经正确接收,回错只会让它 按失败重投,重投的还是同一条处理过的结果。
  • SubmitAITask 不改签名、不判重,另加一个 submitAITaskOnce 给自动路径 (两个回调 + 两处转写收尾)用。用户主动发起的「重新生成」(Summary / StartTask) 继续走不判重的那个:悄悄跳过等于按钮失灵,比多跑一次糟得多。

顺带把两个回调里各自手拼的 speaker / Personnel 也收进 normalizeSpeakers (3.5 那个函数),修掉一个边角:没开说话人分离时编号是空串,原来会拼出 Speaker_ 这种空尾巴。

验证:TestBeginTranscribeFinish 覆盖重入、AwaitTranscribing 放行、 六种已推进状态一律拒绝、nil 不 panic。

3.9 重复项统计展开(对应 ⑧,可后做)

computeStats 与 tool_memory.go get_memory_stats:对 repeat_rule != once 的项,用 comm.MemoryRepeatHits 在周期内逐日展开计数。

落地时收窄了范围,只展开闹钟:

  • 花销展开等于虚构金额。「每月房租」展开进「你这周花了多少」,报出去的是用户 根本没记过的数字,而这份统计的全部价值就在数字是准的(本文档开头就写着「金额与 完成数走 SQL,LLM 只组织文案」)。
  • 待办的 done/undone 按次拆不开:一行只有一个 state,把一条「每周复盘」的重复待办 按 7 次都算成已完成,完成率就成了假的。
  • 灵感本来就不该重复。

实现放 comm(MemoryExtraOccurrences + ApplyRepeatCandidates),home 与 mcp 共用—— 各写一份会漂成「App 里的周报说 7 次、EMAI 说 1 次」,而且两边都不报错。

⚠️ 返回的是增量(除库里那一行之外还发生了几次)不是总次数:调用方的 group by 已经把锚点落在周期内的那一行数过一次,返回总数就会重复计。同时守住不返回负数—— 规则在本周期一次都没命中、而那一行恰好落在周期内时,扣锚点会把调用方本来数对的那行抹掉。

验证:TestMemoryExtraOccurrences 12 个用例(一次性/每天/每周/工作日/周末/每月、 锚点在周期前/中/后、以及那条不能返回 -1 的边界)。

4. 落地顺序与分批

批 内容 特点 部署
1 3.1 归属校验、3.2 Select("*") + 存量修正、3.3 内容门槛 有实证、改动小、不改协议不改表 只部服务端(dev-deploy.sh along),客户端不用发包
2 3.4 报告补偿、3.5 标签统一、3.6 时区、3.7 删纪要连带 涉及 IMemory 签名与客户端两处 服务端 + 客户端同批
3 3.8 回调幂等、3.9 重复项统计 潜在问题,独立验证 只部服务端

每批做完:go build ./... && go vet ./... && go test ./modules/echomeet/ ./modules/memory/ ./comm/;客户端改动跑 flutter test。

4.1 与「语音纪要列表骨架化」改动的交叉检查(2026-09-18 当天)

同一天上午把列表接口改成了骨架查询(getrecordbriefforuid,Omit 掉 original/translate/summary)+ 客户端本地优先缓存。逐条核过与本计划的交叉点:

交叉点 结论
归属校验 vs 骨架列表 GetAllRecords 本来就按 uid 查;顺手补了 requireUID
delrecords 改回「实际删掉的 id」 客户端只有 MeetingTaskService.removeTask 一个调用方,且不消费响应
GetRecords 静默过滤非本人 id 客户端按响应里的记录逐条更新,缺的那条留在轮询队列里;已被 Synchrodata 的孤儿清理 + removeLocalTask 覆盖
MCP 的 seconds >= 60 翻译归档走 RecordingArchive 时带真实 seconds,不会被误伤
说话人标签统一 同步写的是骨架字段,不碰 translate;详情页单条拉全字段,不受影响

查出一个真问题,已修:

GetAllRecords 对转写中的记录会 saverecord(骨架记录),而 mysql.Save 是整行 UPDATE —— 骨架里 original/translate/summary 是空串,写回去就把这三列清空了。触发路径不罕见: StartTask 的闸门是 state>0 && state<5,已完成(5)/已阅(6)/失败(10001,10002) 都允许 「重新转写」,那期间 state=Transcribing 而旧纪要还在库里,用户这时切一下列表页, 旧转写和旧总结就没了;这次转写若又失败,那是永久丢失。列表改成骨架查询之前这里拿的是 全字段,不会有这个问题。

修法:轮询前用 getrecordforuid(uid, brief.Id) 重新取整条,拿整条去 save / PollTranscribe, 只把 lastquerytime 同步回骨架供本次响应序列化。TestListEndpointNeverSavesBriefRecord 守着不许回退。

顺带一条与第 3 批有关的观察:列表页现在也成了转写轮询的驱动方(回调 / 详情页轮询 / 列表页 / cron 兜底,四条),回调与轮询撞车的概率比审计时更高,3.8 的价值随之上升。

4.2 部署验证时挖出来的两个既有 bug(2026-09-18 真机)

三批部到阿龙测试机后逐条验收,3.9 死活不生效,顺藤摸出一条影响面大得多的既有问题。

根因:type:date 列读进 Go 的 string 是 RFC3339,不是 YYYY-MM-DD

memory_item.happen_date、memory_report.period_start/period_end 都是 MySQL 的 type:date 列,而 DSN 带 parseTime=True —— gorm 把它们扫进 pb 结构体的 string 字段时,给的是 2026-09-07T00:00:00+08:00。

按 happen_date 比较的那几句 SQL 靠 MySQL 自己做类型转换还是对的,所以这个问题 在任何报错、任何日志里都看不见,只在「Go 侧要解析这个值」的地方发作, 而且一律表现为「什么都没发生」:

受害者 表现 实证
remind.go 的 expandItem memory_upcoming 恒返回 0 个提醒时刻,整个拾忆提醒在服务端空转 真机建了一条每天 08:00 的闹钟,slots 返回 []
stats.go 的重复项展开(3.9) 重复闹钟永远只算 1 次 一条 daily 闹钟在 7 天周期里 alarm_total=1
下发给客户端的 happen_date 协议说好是 YYYY-MM-DD,实际是 RFC3339 memory_list 响应逐条都是

第三行还有客户端后果:MemoryService._localToday() 用 e.happenDate == today 做字符串相等 → 「今天」的离线兜底恒为空; _mergeRange 用 compareTo 判区间 → 落在区间端点那天的项会被判成「段外」而重复。 (happenDateTime 走 DateTime.tryParse,两种格式都认,所以日历渲染看不出问题—— 这也是它一直没被发现的原因。)

⚠️ 这解释了审计报告 §2 ② 里那句「会议待办会在 08:55 响系统通知」为什么只是推演: 实际一条都不会响,因为 upcoming 恒为空。两个 bug 互相遮蔽。

怎么修的

两层,缺一不可:

  1. comm.ParseMemoryDate 容忍带时间的形式(取前 10 位,丢掉时间与时区—— 这个字段的语义是「用户本地的那一天」不是一个时刻,按时区换算反而会把日期挪一天)。 这是安全网:所有 Go 侧解析点一次性都对了。
  2. 读路径出口归一(comm.NormalizeMemoryDate):memory/model.go 的 7 个读函数、 stats.go 的明细、mcp/tool_memory.go 交给模型的 date。这管的是回给客户端 与模型的格式,让它回到协议约定的 YYYY-MM-DD。

只做 1 不做 2,客户端继续收到 RFC3339;只做 2 不做 1,漏掉任何一个读路径就又静默失效。

验证:TestNormalizeMemoryDate、TestExpandItem_AcceptsDbDateFormat(同一条闹钟 两种日期格式必须产出相同的提醒时刻)。真机复验见下节。

5. 验收清单

  • 跨账号读 / 删 / 改 / 总结别人的纪要 → 全部拒绝
  • 新抽取的会议待办 date_certain=0(无截止日时)、remind_ahead=0,memory_upcoming 不再出 08:55
  • 手动建闹钟选「不提醒」→ 库里 remind_ahead=0
  • 6 秒录音重新生成 → 不产生待办、不进 MCP 检索
  • 报告行被删 / 卡 pending / Failed → 切前台后自动补建 / 重跑,Failed 至多 3 次
  • 新写入的说话人标签全是 Speaker_N
  • 新建记忆项 tz 为 IANA 名
  • 删纪要后自动待办消失、用户改过的保留
  • 迟到的失败回调不会把已完成的纪要打回「转写失败」;同一条转写只跑一轮总结
  • 重新转写一条已完成的纪要期间刷新列表页,旧纪要内容不丢
  • 周报里「闹钟次数」把重复闹钟按实际响的次数算
  • memory_upcoming 能排出提醒时刻(不是空数组)
  • memory_list / MCP 回的 happen_date 是 YYYY-MM-DD 不是 RFC3339

5.1 真机验收记录(2026-09-18,阿龙测试环境)

验收手法:用游客登录拿一个全新账号(user_sgin stype=6),既能测跨账号越权, 又能在自己名下造数据跑完整链路,不碰真实用户的数据。跑完全部清理。

验收项 结果 证据
跨账号读/改/删/总结/发起任务 ✅ 全部拒绝 8 个接口逐个打过:getrecord/readrecord/uprecord/summary/starttask/modifyrecords 回 code:21 记录不存在;getrecords 回空数组不报错;delrecords 回 ids:[] 且目标行逐字未变;未登录回 code:18
存量会议待办标记修正 ✅ 与预测一字不差 16 条 remind_ahead 全部归 0;其中有 due_raw 的 6 条 date_certain 退回 0;助手那 9 条纹丝不动
新写入不再被默认值吞掉 ✅ 新抽取的会议待办 remind_ahead=0;memory_add 显式传 0 也落 0
内容门槛 ✅ 对照实验 22 秒那条一条待办都没抽;同时建的 900 秒正常会议抽出 3 条
MCP 时长门槛 ✅ search_meeting_notes 只回 900s/600s 两条,22 秒那条(有 101 字总结)被挡在外面
报告补偿:cron 漏建 ✅ 造 5 条上周记忆项 → 调 memory_today → 自动补建 W37 周报并生成完成;月报没建(上月 0 条,没过闸门)
报告补偿:卡 pending ✅ 报告置 state=0,update_time=0 → 切前台后重新入队并跑完
报告补偿:失败重试上限 ✅ retry=2 → 重试且计数变 3;retry=3 → 保持 Failed 不再重试
回调幂等 ✅ 两种都撞到了 ① 兜底扫描先判 TranscribeFail,6 秒后回调到达 → 「状态已是 TranscribeFail…忽略」(改前会把已判失败的记录推进总结);② 同一回调连打两次 → 第 2 次「状态已是 AwaitSummarizing…忽略」,只跑了一轮 AIProcess
说话人标签统一 ✅ 回调路径产出 speaker:"Speaker_1"、personnel:"[Speaker_2] [Speaker_1] "
删纪要连带删待办 ✅ 删掉带 3 条待办的纪要 → 自动抽的 2 条消失,标了 user_edited=1 的那条保留
重复闹钟统计展开 ✅(修完根因后) 一条 daily 闹钟在 7 天周期里 alarm_total=7
memory_upcoming 能排出提醒 ✅(既有 bug 修复) 改前恒为 [],改后正确排出 6 天的 07:55
下发的 happen_date 格式 ✅(既有 bug 修复) memory_list 与 MCP 都回 2026-09-07,不再是 RFC3339

⚠️ 部署过程发现的运维问题(未处理):服务器 confs/home.yaml 里没有 memory: 段, 启动日志一行 注册模块【memory】 没有对应的配置信息,导致 memory 模块自己打的 Info/Warn 在 stdout 与 log/home.log 里都看不到(「内容太薄跳过抽取」「存量修正完成 N 行」这些全是盲的)。 配置项本身有代码内置默认值,功能不受影响,但排障时等于没有日志。 修法是照 deploy/app/confs/home.yaml.example 给服务器的 home.yaml 补一段 memory:; 本次没动服务器配置(dev-deploy.sh 按设计不下发真实配置)。

6. 回滚

  • 3.1 / 3.3 / 3.8:纯代码,回滚镜像即可。
  • 3.2:Select("*") 回滚镜像即可;存量修正 SQL 是幂等 UPDATE,回滚不需要反向操作(改回来的值本来就是错的)。
  • 3.4:新增行为,回滚镜像即可;已补建的报告行留着无害。
  • 3.6 客户端:_tz() 回退逻辑仍在,服务端能同时认两种格式,两端可各自独立回滚。

7. 未纳入本计划的已知项

  • 会议归属日期用 creationtime(补录旧录音会偏)——要做准得加 meet_date + tz 两列由客户端上报,是表结构改动,另立项。
  • 周报 / 月报 cron 与统计一律容器时区(Asia/Shanghai),海外用户「上周」边界会偏——设计文档 §5.3 已记录,另立项。
  • 转写失败不退还 Meetintegral 的异步路径——tasks.go sweepStuckTranscribe 注释里已记为待办,与本链路无关。