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 互相遮蔽。
怎么修的
两层,缺一不可:
comm.ParseMemoryDate容忍带时间的形式(取前 10 位,丢掉时间与时区—— 这个字段的语义是「用户本地的那一天」不是一个时刻,按时区换算反而会把日期挪一天)。 这是安全网:所有 Go 侧解析点一次性都对了。- 读路径出口归一(
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注释里已记为待办,与本链路无关。