# 语音纪要 → 拾忆 → 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`。 **怎么改**: ```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` 分支里: ```go 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("*")`。 **实际怎么改**:插完把那两列按结构体的现值补写回去。 ```go 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` 跑,幂等,失败只记日志): ```sql 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`) ```go // 有效字符:去掉 "[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` 调它。 **怎么改**: ```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: 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`: ```dart static String? _ianaTz; // 启动时取一次,进程内缓存 static Future 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`, 不是带 `.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`。 ```go // 回调重放 / 与轮询撞车:状态已经不是 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` 注释里已记为待办,与本链路无关。