Browse Source
审计与落地计划见 docs/语音纪要-拾忆-MCP链路审计与落地计划.md,已在阿龙测试环境 逐条验收通过(记录见该文档 §5.1)。 ## 安全 echomeet 八个按 id 操作的接口**一处都没有比对 record.Uid 与会话 uid**,任何登录用户 改一个 id 就能读别人纪要全文、删改别人的记录、给别人的记录发起总结(扣自己算力, 但覆盖对方 summary 并触发对方的拾忆抽取)。统一改走带 uid 的查询: - 「不存在」与「不是你的」回同一个错误码同一句话——分开回等于给出一个探测他人 id 的接口;用的还是改动前记录真不存在时的 DBError,合法用户行为无任何变化; - 批量接口按交集处理、**不报错**:客户端轮询队列里本就可能留着已被别的设备删掉的 id, 报错会把整个轮询循环掀掉;delrecords 响应回实际删掉的那批; - starttask 的校验排在扣算力之前,否则拒绝了还先把钱扣了; - 回调接口是第三方来的、没有 session,不在此列。 - TestApisUseUidScopedQueries 扫源码守着:最容易复发的不是有人改回去, 而是新加接口时照旧写法抄一遍,那样既不报错也看不出来。 ## gorm 的 default: 标签吞零值 mysql.Insert = db.Table().Create(model),gorm 对带 default: 的字段**一律把零值换成 默认值**。DBMemoryItem 的 date_certain(default:true) / remind_ahead(default:5) 因此 永远写不进 false/0:测试库 25 条记录全部落成 1/5,「待定日期」分组恒为空, 会议待办还会在 08:55 响提醒——与「会议待办默认不提醒」正好相反。 ⚠️ 加 Select("*") **救不了**:替换在 ConvertToCreateValues 的 reflect.Struct 分支里 无条件做,只看值是不是零值,与 Select/Omit 无关(DryRun 实测两种写法生成的 VALUES 一模一样)。改成插入后把这两列按结构体现值补写回去,并把真值还给调用方—— gorm 连结构体上的字段一起改了,不还原的话 memory_add 回给客户端的也是错的。 存量由 migrate_defaults.go 幂等修正(只碰 source=meeting 且 user_edited=0)。 ## 内容门槛 6 秒的测试通话、20 秒的单人自述照样被抽成「继续进行进一步的功能测试」这类废话待办, 再靠条数凑够周报阈值又触发一次 LLM 调用。门槛卡**转写正文**不卡纪要——内容太薄时 模型按模板兜底规则照样能写出六百多字。只拦抽取与 MCP 检索,**纪要照出**: 短语音备忘是合法用法,也不判成 SummarizFail(客户端把那个码显示成「请稍后重试」, 用户会一直点)。数据缺失时按通过处理,不因上游漏传一个字段就静默关掉抽取。 ## 其余 - 报告补偿:原先的 requeueIfStale 由 memory_getreport 驱动,而客户端调无参版本 → 走 latestUnconfirmedReport,那条 SQL 只查 state=Done,pending/processing/failed 永远查不出来也就永远补不了。新增 ensureRecent 挂在 memory_today:漏建的补建 (过与 cron 同一道条数闸门)、卡住的复用 requeueIfStale、失败的至多重试 3 次 (计数寄在 error_msg 的 retry=N; 前缀,fail() 必须保住它,否则上限形同虚设)。 加 enqueueOnce 判重与按报告 id 的 SETNX 生成锁,cron 与 ensure 撞上只跑一次 LLM。 - 说话人标签:finishSyncTranscribe 拼的是「Speaker 0」(空格)、其余是「Speaker_0」, 抽出来的负责人就成了 Speaker2、[Speaker_0] 各种样。四条写入路径统一走 normalizeSpeakers, 顺带修掉没开说话人分离时拼出「Speaker_」空尾巴。 - 时区:LoadMemoryLocation 现在认 IANA / 固定偏移 / CST(按 Asia/Shanghai 解释, 兜住已落库那批),认不出来仍退服务器本地时区但**打 warn**——原来是全静默的。 - 删纪要连带删自动待办(只删 user_edited=0),查询带 uid:入参来自删除请求, 光信 source_id 等于把归属校验的成果又丢一次。 - 回调幂等:两个回调既不看状态也不抢锁,与轮询撞车就各翻译一遍、各入队一次。 失败分支同样要过闸门——一条已 Completed 的记录被迟到的失败回调打回 TranscribeFail, 用户看到的就是「纪要好端端地变成了转写失败」。挡下来仍回 SUCCESS,不让第三方重投。 SubmitAITask 保持不判重(用户点的「重新生成」悄悄跳过等于按钮失灵), 自动路径改走 submitAITaskOnce。 - 重复项统计:只展开闹钟。花销展开等于虚构金额,待办的 done/undone 按次拆不开。 ## 顺带修的两个既有 bug(部署验收时挖出来的) happen_date / period_start 都是 type:date 列 + DSN parseTime=True,读进 Go 的 string 是「2026-09-07T00:00:00+08:00」。SQL 比较靠 MySQL 转换还是对的,所以这个问题 **在任何日志任何报错里都看不见**,只在 Go 侧解析它的地方发作: 1. remind.go 的 expandItem 解析不了 → **memory_upcoming 恒返回 0 个提醒时刻, 整个拾忆提醒在服务端一直空转**(真机实测:建一条每天 08:00 的闹钟,slots 为 []); 2. 下发给客户端的 happen_date 一直是 RFC3339 而非协议约定的 YYYY-MM-DD, 客户端 _localToday() 的字符串相等比较恒不匹配、_mergeRange 在区间端点会重复。 这也是审计里「会议待办会在 08:55 响通知」只能是推演的原因:实际一条都不会响, 两个 bug 互相遮蔽。修法两层:ParseMemoryDate 容忍带时间的形式(安全网), 读路径出口统一归一(管对外格式),缺任一层都会留坑。 ## 列表骨架化(同批) echomeet_getallrecords 只回列表要用的骨架字段,original/translate/summary 占每条 97% 的字节(实测 59 条 ≈ 500KB,列表用得上的 15KB)。 ⚠️ 骨架记录**绝不能写回库**:mysql.Save 是整行 UPDATE,三列会被清空。 StartTask 允许对已完成/已阅/失败的记录「重新转写」,那期间 state=Transcribing 而旧 纪要还在库里,用户切一下列表页旧转写和旧总结就没了。轮询前重新取整条再操作, TestListEndpointNeverSavesBriefRecord 守着不许回退。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>main
36 changed files with 2505 additions and 149 deletions
@ -0,0 +1,40 @@ |
|||
package echomeet |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
/* |
|||
按 id 操作会议记录的接口,统一在这里取会话 uid 并做归属校验。 |
|||
|
|||
在此之前 getrecord / getrecords / modifyrecords / delrecords / readrecord / |
|||
summary / starttask / uprecord 八个接口**全部只按 id 查库**,没有一处比对 |
|||
record.Uid 与会话 uid(starttask 用了 GetUserId(),但只拿去扣算力和查 VIP)。 |
|||
后果是任何登录用户改一个 id 就能读别人纪要全文、删改别人的记录、 |
|||
给别人的记录发起总结(扣自己的算力,但覆盖对方 summary 并触发对方的拾忆抽取)。 |
|||
|
|||
写法照 memory 模块(每个接口 requireUID + rec.Uid != uid)。 |
|||
回调接口 BackCall / AliBackCall 是第三方来的、没有 session,不在此列。 |
|||
*/ |
|||
|
|||
// requireUID 取会话 uid。所有按 id 操作记录的接口第一步。
|
|||
func requireUID(session comm.IUserSession) (string, *pb.ErrorData) { |
|||
uid := session.GetUserId() |
|||
if uid == "" { |
|||
return "", &pb.ErrorData{Code: pb.ErrorCode_NoLogin, Message: pb.ErrorCode_NoLogin.String()} |
|||
} |
|||
return uid, nil |
|||
} |
|||
|
|||
// recordErr 把 model 层的查询错误转成对外错误。
|
|||
//
|
|||
// ⚠️ 「不存在」与「不是你的」回同一个错误码同一句话:分开回等于给出一个
|
|||
// 探测他人记录 id 的接口。DBError 也正是改动前记录真的不存在时客户端拿到的码,
|
|||
// 这样合法用户看到的行为没有任何变化。
|
|||
func recordErr(err error) *pb.ErrorData { |
|||
if err == ErrRecordNotFound { |
|||
return &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: ErrRecordNotFound.Error()} |
|||
} |
|||
return &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()} |
|||
} |
|||
@ -0,0 +1,238 @@ |
|||
package echomeet |
|||
|
|||
import ( |
|||
"os" |
|||
"path/filepath" |
|||
"regexp" |
|||
"strings" |
|||
"testing" |
|||
|
|||
"yunyan/pb" |
|||
) |
|||
|
|||
// TestApisUseUidScopedQueries 守住归属校验:接口层不许再调不带 uid 的记录查询。
|
|||
//
|
|||
// 2026-09-18 之前八个按 id 操作的接口全部只按 id 查库,任何登录用户改一个 id
|
|||
// 就能读改删别人的纪要。修完之后最容易复发的方式不是有人改回去,而是**新加一个
|
|||
// api_xxx.go 时照着旧写法抄一遍** —— 那样既不报错也没人看得出来,所以这里按源码扫。
|
|||
//
|
|||
// 不带 uid 的版本仍然合法,但只能由回调(第三方来的,没有 session)和
|
|||
// 后台兜底任务(按 state/taskid 扫全表)调用,它们都不在 api_*.go 里。
|
|||
func TestApisUseUidScopedQueries(t *testing.T) { |
|||
// 回调没有会话,天然不在此列
|
|||
exempt := map[string]bool{ |
|||
"api_backcall.go": true, |
|||
"api_alibackcall.go": true, |
|||
} |
|||
banned := regexp.MustCompile(`model\.(getrecord|getrecords|delrecords)\(`) |
|||
|
|||
files, err := filepath.Glob("api_*.go") |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
if len(files) < 8 { |
|||
t.Fatalf("只扫到 %d 个 api 文件,glob 大概写错了", len(files)) |
|||
} |
|||
for _, f := range files { |
|||
if exempt[filepath.Base(f)] || strings.HasSuffix(f, "_test.go") { |
|||
continue |
|||
} |
|||
src, err := os.ReadFile(f) |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
for i, line := range strings.Split(string(src), "\n") { |
|||
// 跳过注释行:api_putrecords.go 整个函数都是注释掉的死代码
|
|||
// (客户端那个调用点 2026-09-18 已删),它不是活的调用。
|
|||
if strings.HasPrefix(strings.TrimSpace(line), "//") { |
|||
continue |
|||
} |
|||
if banned.MatchString(line) { |
|||
t.Errorf("%s:%d 调了不带 uid 的记录查询,别人的记录也查得到:%s\n"+ |
|||
" → 改用 getrecordforuid / getrecordsforuidids / delrecordsforuid(见 api_scope.go)", |
|||
f, i+1, strings.TrimSpace(line)) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// TestTranscriptRunes 内容门槛数的是「说的话」,不是转写 JSON 的长度,
|
|||
// 更不能把 [Speaker_x] 标签算进去——两人对话光标签就有几十字,
|
|||
// 算进去的话再薄的内容也能轻松过门槛。
|
|||
func TestTranscriptRunes(t *testing.T) { |
|||
cases := []struct { |
|||
name string |
|||
rec *pb.DBEchoMeetRecord |
|||
want int |
|||
}{ |
|||
{ |
|||
name: "优先用译文", |
|||
rec: &pb.DBEchoMeetRecord{ |
|||
Original: `[{"speaker":"Speaker_0","content":"一二三四五"}]`, |
|||
Translate: `[{"speaker":"Speaker_0","content":"一二三"}]`, |
|||
}, |
|||
want: 3, |
|||
}, |
|||
{ |
|||
name: "译文为空退回原文(与 AIProcess 同口径)", |
|||
rec: &pb.DBEchoMeetRecord{ |
|||
Original: `[{"speaker":"Speaker_0","content":"一二三四五"}]`, |
|||
Translate: " ", |
|||
}, |
|||
want: 5, |
|||
}, |
|||
{ |
|||
name: "多段累加,说话人标签不计入", |
|||
rec: &pb.DBEchoMeetRecord{ |
|||
Translate: `[{"speaker":"Speaker_0","content":"你好"},{"speaker":"Speaker_1","content":"在的"}]`, |
|||
}, |
|||
want: 4, |
|||
}, |
|||
{ |
|||
name: "provider 直接给纯文本时按整串算", |
|||
rec: &pb.DBEchoMeetRecord{Translate: "就这么一句话"}, |
|||
want: 6, |
|||
}, |
|||
{ |
|||
name: "两边都空 → 0(调用方按「数据缺失」处理,不拦)", |
|||
rec: &pb.DBEchoMeetRecord{}, |
|||
want: 0, |
|||
}, |
|||
} |
|||
for _, c := range cases { |
|||
t.Run(c.name, func(t *testing.T) { |
|||
if got := transcriptRunes(c.rec); got != c.want { |
|||
t.Errorf("期望 %d 字,得到 %d", c.want, got) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// 说话人标签四条写入路径必须同一种格式 `Speaker_N`。
|
|||
//
|
|||
// 此前 finishSyncTranscribe 拼的是 `Speaker 0`(空格),后果不在纪要本身而在下游:
|
|||
// LLM 抽出来的负责人会变成 `Speaker2`、`[Speaker_0]` 各种样,
|
|||
// MCP 的 meaningfulPersonnel 只认 `Speaker_` 前缀,别的会被当成真人名塞给模型。
|
|||
func TestNormalizeSpeakers(t *testing.T) { |
|||
t.Run("裸编号补上 Speaker_ 前缀,Personnel 跟着拼", func(t *testing.T) { |
|||
rec := &pb.DBEchoMeetRecord{Id: 7} |
|||
ctxs := []*pb.ContextStruct{ |
|||
{Speaker: "0", Content: "你好"}, |
|||
{Speaker: "1", Content: "在的"}, |
|||
{Speaker: "0", Content: "那就这样"}, |
|||
} |
|||
normalizeSpeakers(rec, ctxs) |
|||
for _, c := range ctxs { |
|||
if !strings.HasPrefix(c.Speaker, "Speaker_") { |
|||
t.Errorf("说话人 %q 没有统一成 Speaker_N", c.Speaker) |
|||
} |
|||
if c.Meetingid != 7 { |
|||
t.Errorf("meetingid 没回填,得到 %d", c.Meetingid) |
|||
} |
|||
} |
|||
// map 迭代无序,只能按包含判断
|
|||
for _, want := range []string{"[Speaker_0]", "[Speaker_1]"} { |
|||
if !strings.Contains(rec.Personnel, want) { |
|||
t.Errorf("Personnel %q 里缺 %s", rec.Personnel, want) |
|||
} |
|||
} |
|||
}) |
|||
|
|||
t.Run("已带 Speaker 前缀的不动(含历史的空格写法)", func(t *testing.T) { |
|||
rec := &pb.DBEchoMeetRecord{Id: 1} |
|||
ctxs := []*pb.ContextStruct{ |
|||
{Speaker: "Speaker_0"}, |
|||
{Speaker: "Speaker 3"}, // 存量数据,重跑时原样保留,免得同一条会议里新旧对不上
|
|||
} |
|||
normalizeSpeakers(rec, ctxs) |
|||
if ctxs[0].Speaker != "Speaker_0" || ctxs[1].Speaker != "Speaker 3" { |
|||
t.Errorf("已带前缀的被改写了:%q / %q", ctxs[0].Speaker, ctxs[1].Speaker) |
|||
} |
|||
}) |
|||
|
|||
t.Run("没开说话人分离时 Speaker 是空串,不该拼成 Speaker_", func(t *testing.T) { |
|||
rec := &pb.DBEchoMeetRecord{Id: 1} |
|||
ctxs := []*pb.ContextStruct{{Speaker: ""}} |
|||
normalizeSpeakers(rec, ctxs) |
|||
if ctxs[0].Speaker != "" { |
|||
t.Errorf("空说话人被改成了 %q", ctxs[0].Speaker) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// 列表接口拿到的是 Omit 掉 original/translate/summary 的**骨架**记录,
|
|||
// 它绝不能被写回库:mysql.Save 是整行 UPDATE,写回去就是把这三列清空。
|
|||
//
|
|||
// 真实触发路径:StartTask 允许对已完成/已阅/失败的记录「重新转写」,
|
|||
// 那期间 state=Transcribing 而旧纪要还在库里,用户切一下列表页就没了。
|
|||
// 要轮询转写必须先 getrecord 取整条(见 api_getallrecords.go)。
|
|||
func TestListEndpointNeverSavesBriefRecord(t *testing.T) { |
|||
src, err := os.ReadFile("api_getallrecords.go") |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
for _, bad := range []string{"saverecord(brief", "PollTranscribe(brief"} { |
|||
if strings.Contains(string(src), bad) { |
|||
t.Errorf("api_getallrecords.go 里出现 %q:骨架记录被写回库会清空 "+ |
|||
"original/translate/summary,必须先 getrecord 取整条", bad) |
|||
} |
|||
} |
|||
// 反过来也确认一下整条那步还在,别哪天连轮询一起删了
|
|||
if !strings.Contains(string(src), "this.module.model.getrecordforuid(uid, brief.Id)") { |
|||
t.Error("api_getallrecords.go 不再按 id 取整条记录,转写轮询要么没了、要么又拿骨架在跑") |
|||
} |
|||
} |
|||
|
|||
// 转写收尾的抢锁:回调重放、与轮询撞车、迟到的失败回调,三种都必须被挡下来。
|
|||
//
|
|||
// 挡不住的后果不是报错而是「安静地多跑一轮」:整篇重译一遍、AI 队列里多一个 id、
|
|||
// AIProcess 跑两次(两次 LLM 计费,后一轮覆盖前一轮,拾忆的待办也抽两轮)。
|
|||
func TestBeginTranscribeFinish(t *testing.T) { |
|||
newTasks := func() *tasksComp { return &tasksComp{} } |
|||
|
|||
t.Run("转写中:第一次拿得到,第二次拿不到", func(t *testing.T) { |
|||
tc := newTasks() |
|||
rec := &pb.DBEchoMeetRecord{Id: 1, State: pb.DBEchoMeetRecordState_Transcribing} |
|||
if !tc.beginTranscribeFinish(rec) { |
|||
t.Fatal("第一次应该拿得到收尾权") |
|||
} |
|||
if tc.beginTranscribeFinish(rec) { |
|||
t.Error("收尾进行中还能再拿到,回调与轮询会各收尾一次") |
|||
} |
|||
tc.endTranscribeFinish(rec.Id) |
|||
if !tc.beginTranscribeFinish(rec) { |
|||
t.Error("收尾结束后应该能再次进入(兜底扫描要用)") |
|||
} |
|||
}) |
|||
|
|||
t.Run("等待转写:放行(提交成功到状态落库之间有窗口)", func(t *testing.T) { |
|||
tc := newTasks() |
|||
rec := &pb.DBEchoMeetRecord{Id: 2, State: pb.DBEchoMeetRecordState_AwaitTranscribing} |
|||
if !tc.beginTranscribeFinish(rec) { |
|||
t.Error("快的 provider 可能在状态写成 Transcribing 之前就回调,必须放行") |
|||
} |
|||
}) |
|||
|
|||
for _, st := range []pb.DBEchoMeetRecordState{ |
|||
pb.DBEchoMeetRecordState_AwaitSummarizing, |
|||
pb.DBEchoMeetRecordState_Summarizing, |
|||
pb.DBEchoMeetRecordState_Completed, |
|||
pb.DBEchoMeetRecordState_Readed, |
|||
pb.DBEchoMeetRecordState_TranscribeFail, |
|||
pb.DBEchoMeetRecordState_SummarizFail, |
|||
} { |
|||
t.Run("已经不在转写阶段就不再收尾:"+st.String(), func(t *testing.T) { |
|||
tc := newTasks() |
|||
rec := &pb.DBEchoMeetRecord{Id: 3, State: st} |
|||
if tc.beginTranscribeFinish(rec) { |
|||
t.Errorf("state=%s 仍被允许收尾:一条已完成的纪要会被迟到的失败回调打回转写失败", st) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
t.Run("nil 不 panic", func(t *testing.T) { |
|||
if newTasks().beginTranscribeFinish(nil) { |
|||
t.Error("nil 不该拿到收尾权") |
|||
} |
|||
}) |
|||
} |
|||
@ -0,0 +1,53 @@ |
|||
package memory |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/mysql" |
|||
) |
|||
|
|||
/* |
|||
存量修正:把被 gorm 的 `default:` 标签吞掉的会议待办标记改回来。 |
|||
|
|||
起因见 model.go 的 addItem —— 2026-09-18 之前 ExtractMeetingTodos 写的 |
|||
DateCertain:false / RemindAhead:0 一条都没落进库,测试库里 16 条会议待办 |
|||
全是 date_certain=1 / remind_ahead=5。代码修好之后**新数据**才对, |
|||
已经写歪的这些得单独扶正,否则用户的日历里那批待办照旧在 08:55 响提醒。 |
|||
|
|||
三条刻意的边界: |
|||
|
|||
- **只碰 source='meeting' 且 user_edited=0 的行**。用户改过的项已经是他自己的东西, |
|||
哪怕当初的值来自 bug,也不该在他背后再改一次。 |
|||
- **助手闹钟那批 remind_ahead=5 不动**。当初用户到底有没有选「不提醒」现在已无从知道, |
|||
而 5 分钟正是产品默认值,改了反而可能把用户设过的提醒抹掉。 |
|||
- **date_certain 只回退「有 due_raw 却仍落在会议当天」的那些**。当初真从原文推算出 |
|||
截止日的项本来就该是 1,无法与被吞掉的那批区分,所以只挑这个能确定判错的子集。 |
|||
|
|||
幂等:条件里带了「当前值不等于目标值」,跑第二遍影响 0 行。 |
|||
不写 next_remind_at:全项目没有任何地方读它(只有 recalcRemind 在写), |
|||
动它只会扩大这次修正的影响面。 |
|||
*/ |
|||
|
|||
// fixSwallowedDefaults 修正存量会议待办被默认值覆盖的两列,返回改动行数。
|
|||
func (this *modelComp) fixSwallowedDefaults() (int64, error) { |
|||
var total int64 |
|||
|
|||
// 会议待办一律不提醒
|
|||
tx := mysql.Table(comm.TableMemoryItem). |
|||
Where("source = ? AND user_edited = ? AND remind_ahead <> ?", comm.MemorySrcMeeting, false, 0). |
|||
Update("remind_ahead", 0) |
|||
if tx.Error != nil { |
|||
return total, tx.Error |
|||
} |
|||
total += tx.RowsAffected |
|||
|
|||
// 有截止原文、却没推算出具体日期(= 仍落在会议当天)→ 该进「待定日期」分组
|
|||
tx = mysql.Table(comm.TableMemoryItem). |
|||
Where("source = ? AND user_edited = ? AND due_raw <> ? AND date_certain = ?", |
|||
comm.MemorySrcMeeting, false, "", true). |
|||
Update("date_certain", false) |
|||
if tx.Error != nil { |
|||
return total, tx.Error |
|||
} |
|||
total += tx.RowsAffected |
|||
return total, nil |
|||
} |
|||
@ -0,0 +1,188 @@ |
|||
package memory |
|||
|
|||
import ( |
|||
"database/sql" |
|||
"database/sql/driver" |
|||
"reflect" |
|||
"strconv" |
|||
"strings" |
|||
"testing" |
|||
|
|||
"yunyan/pb" |
|||
|
|||
"gorm.io/driver/mysql" |
|||
"gorm.io/gorm" |
|||
) |
|||
|
|||
/* |
|||
守住 addItem 那条补写逻辑的两个前提。 |
|||
|
|||
背景:gorm 对带 `default:` 标签的字段,Create 时把零值换成标签里的默认值再写库。 |
|||
DBMemoryItem 的 date_certain(default:true) / remind_ahead(default:5) 因此永远写不进 |
|||
false / 0 —— 2026-09-18 测试库里 25 条记录全部落成 1 / 5。 |
|||
|
|||
⚠️ 一度以为加 `Select("*")` 能绕开,实测不行:替换发生在 ConvertToCreateValues 的 |
|||
reflect.Struct 分支,只看字段值是不是零值,与 Select / Omit 无关。 |
|||
TestGormCreateSwallowsZeroValues 就是把这个事实钉住,免得下次又有人去试。 |
|||
*/ |
|||
|
|||
// ── DryRun 用的假驱动:只要能让 gorm 把 SQL 拼出来,不需要真连库 ──
|
|||
|
|||
type nullDriver struct{} |
|||
|
|||
func (nullDriver) Open(string) (driver.Conn, error) { return nullConn{}, nil } |
|||
|
|||
type nullConn struct{} |
|||
|
|||
func (nullConn) Prepare(string) (driver.Stmt, error) { return nil, driver.ErrSkip } |
|||
func (nullConn) Close() error { return nil } |
|||
func (nullConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip } |
|||
|
|||
func init() { sql.Register("memory_model_test_null", nullDriver{}) } |
|||
|
|||
func dryRunDB(t *testing.T) *gorm.DB { |
|||
t.Helper() |
|||
conn, _ := sql.Open("memory_model_test_null", "") |
|||
db, err := gorm.Open( |
|||
mysql.New(mysql.Config{Conn: conn, SkipInitializeWithVersion: true}), |
|||
// SkipDefaultTransaction:写操作默认包事务,假驱动的 Begin 会报错,SQL 就拼不出来了
|
|||
&gorm.Config{DryRun: true, DisableAutomaticPing: true, SkipDefaultTransaction: true}, |
|||
) |
|||
if err != nil { |
|||
t.Fatalf("起 DryRun 连接失败: %v", err) |
|||
} |
|||
return db |
|||
} |
|||
|
|||
// insertValueOf 取一条 INSERT 里某列的绑定值。
|
|||
func insertValueOf(t *testing.T, st *gorm.Statement, column string) interface{} { |
|||
t.Helper() |
|||
sql := st.SQL.String() |
|||
head := sql[strings.Index(sql, "(")+1 : strings.Index(sql, ")")] |
|||
for i, c := range strings.Split(head, ",") { |
|||
if strings.Trim(strings.TrimSpace(c), "`") == column { |
|||
if i >= len(st.Vars) { |
|||
t.Fatalf("列 %s 下标 %d 超出绑定值个数 %d", column, i, len(st.Vars)) |
|||
} |
|||
return st.Vars[i] |
|||
} |
|||
} |
|||
t.Fatalf("INSERT 里没有列 %s:%s", column, sql) |
|||
return nil |
|||
} |
|||
|
|||
// TestGormCreateSwallowsZeroValues 记录 bug 本身:Create 会把 false/0 换成 true/5,
|
|||
// 加不加 Select("*") 都一样。哪天 gorm 改了行为这条会红,那时 addItem 的补写就可以删了。
|
|||
func TestGormCreateSwallowsZeroValues(t *testing.T) { |
|||
for _, tc := range []struct { |
|||
name string |
|||
make func(*gorm.DB, *pb.DBMemoryItem) *gorm.DB |
|||
}{ |
|||
{"裸 Create", func(db *gorm.DB, it *pb.DBMemoryItem) *gorm.DB { |
|||
return db.Table("memory_item").Create(it) |
|||
}}, |
|||
{"Select(*) 也救不了", func(db *gorm.DB, it *pb.DBMemoryItem) *gorm.DB { |
|||
return db.Table("memory_item").Select("*").Create(it) |
|||
}}, |
|||
} { |
|||
t.Run(tc.name, func(t *testing.T) { |
|||
item := &pb.DBMemoryItem{ |
|||
Uid: "u1", Title: "跟进合同", HappenDate: "2026-09-18", |
|||
DateCertain: false, RemindAhead: 0, |
|||
} |
|||
st := tc.make(dryRunDB(t), item).Statement |
|||
if got := insertValueOf(t, st, "date_certain"); got != true { |
|||
t.Errorf("date_certain 期望被换成 true(即 bug 仍在),实际 %v", got) |
|||
} |
|||
// ⚠️ 比数值不比类型:gorm 的默认值来自 strconv.ParseInt,塞回去是 int64 不是字段的 int32
|
|||
if got := insertValueOf(t, st, "remind_ahead"); toInt64(t, got) != 5 { |
|||
t.Errorf("remind_ahead 期望被换成 5(即 bug 仍在),实际 %v", got) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// TestGormNonZeroDefaultsAreHandled 反查 pb 结构体上所有「默认值非零」的列,
|
|||
// 必须与 gormZeroDefaultColumns 一字不差。
|
|||
//
|
|||
// proto 里给某个字段加了 `default:1` 之类而 addItem 没跟着补写,这条会红 ——
|
|||
// 否则那个字段会重演同一个 bug,而且同样不报任何错。
|
|||
func TestGormNonZeroDefaultsAreHandled(t *testing.T) { |
|||
got := nonZeroDefaultColumns(reflect.TypeOf(pb.DBMemoryItem{})) |
|||
want := append([]string(nil), gormZeroDefaultColumns...) |
|||
if !reflect.DeepEqual(got, sortedCopy(want)) { |
|||
t.Fatalf("DBMemoryItem 上非零默认值的列是 %v,而 addItem 只补写 %v;"+ |
|||
"请同步 gormZeroDefaultColumns 与 addItem 里的补写分支", got, want) |
|||
} |
|||
} |
|||
|
|||
// TestReportHasNoNonZeroDefaults 周期报告表走同一个 mysql.Insert,
|
|||
// 目前它没有非零默认值的列,所以不需要补写。加了就要按 addItem 的办法处理。
|
|||
func TestReportHasNoNonZeroDefaults(t *testing.T) { |
|||
if got := nonZeroDefaultColumns(reflect.TypeOf(pb.DBMemoryReport{})); len(got) > 0 { |
|||
t.Fatalf("DBMemoryReport 新增了非零默认值的列 %v,upsertReport 的 Insert 会把它们的零值吞掉", got) |
|||
} |
|||
} |
|||
|
|||
// nonZeroDefaultColumns 扫结构体 tag,挑出 `gorm:"default:X"` 且 X 不是零值的列,
|
|||
// 列名取 json tag(pb 生成的 json 名与建表列名一致)。返回值已排序。
|
|||
func nonZeroDefaultColumns(t reflect.Type) []string { |
|||
out := make([]string, 0, 4) |
|||
for i := 0; i < t.NumField(); i++ { |
|||
f := t.Field(i) |
|||
tag := f.Tag.Get("gorm") |
|||
if tag == "" || tag == "-" { |
|||
continue |
|||
} |
|||
def, ok := "", false |
|||
for _, seg := range strings.Split(tag, ";") { |
|||
if v, found := strings.CutPrefix(strings.TrimSpace(seg), "default:"); found { |
|||
def, ok = strings.TrimSpace(v), true |
|||
} |
|||
} |
|||
if !ok || isZeroLiteral(def) { |
|||
continue |
|||
} |
|||
name := strings.Split(f.Tag.Get("json"), ",")[0] |
|||
if name == "" || name == "-" { |
|||
continue |
|||
} |
|||
out = append(out, name) |
|||
} |
|||
return sortedCopy(out) |
|||
} |
|||
|
|||
// toInt64 把绑定值统一成 int64 再比,绕开 int32/int64 的类型差异。
|
|||
func toInt64(t *testing.T, v interface{}) int64 { |
|||
t.Helper() |
|||
rv := reflect.ValueOf(v) |
|||
switch rv.Kind() { |
|||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: |
|||
return rv.Int() |
|||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: |
|||
return int64(rv.Uint()) |
|||
} |
|||
t.Fatalf("不是整数: %#v", v) |
|||
return 0 |
|||
} |
|||
|
|||
func isZeroLiteral(s string) bool { |
|||
switch strings.ToLower(strings.TrimSpace(s)) { |
|||
case "", "0", "false", "null", "''", `""`: |
|||
return true |
|||
} |
|||
if f, err := strconv.ParseFloat(s, 64); err == nil { |
|||
return f == 0 |
|||
} |
|||
return false |
|||
} |
|||
|
|||
func sortedCopy(in []string) []string { |
|||
out := append([]string(nil), in...) |
|||
for i := 1; i < len(out); i++ { |
|||
for j := i; j > 0 && out[j] < out[j-1]; j-- { |
|||
out[j], out[j-1] = out[j-1], out[j] |
|||
} |
|||
} |
|||
return out |
|||
} |
|||
@ -0,0 +1,120 @@ |
|||
package memory |
|||
|
|||
import ( |
|||
"testing" |
|||
"time" |
|||
|
|||
"yunyan/comm" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
// 补偿要找的是「最近一个**已结束**的周期」,口径必须与 cron 的 tick 完全一致:
|
|||
// 差一周就会去补一个 cron 已经建过的周期(重复生成)或者永远差一格(永远补不上)。
|
|||
func TestLastFinishedPeriods(t *testing.T) { |
|||
loc := time.FixedZone("CST", 8*3600) |
|||
cases := []struct { |
|||
name string |
|||
now time.Time |
|||
wantWeek string // 周一~周日
|
|||
wantMonth string |
|||
}{ |
|||
{ |
|||
name: "周中", |
|||
now: time.Date(2026, 9, 18, 10, 0, 0, 0, loc), // 周五
|
|||
wantWeek: "2026-09-07~2026-09-13", |
|||
wantMonth: "2026-08-01~2026-08-31", |
|||
}, |
|||
{ |
|||
name: "周一 cron 触发那一刻", |
|||
now: time.Date(2026, 9, 14, 0, 30, 0, 0, loc), |
|||
wantWeek: "2026-09-07~2026-09-13", |
|||
wantMonth: "2026-08-01~2026-08-31", |
|||
}, |
|||
{ |
|||
name: "周日(本周还没结束,仍看上一周)", |
|||
now: time.Date(2026, 9, 20, 23, 59, 0, 0, loc), |
|||
wantWeek: "2026-09-07~2026-09-13", |
|||
wantMonth: "2026-08-01~2026-08-31", |
|||
}, |
|||
{ |
|||
name: "跨年:1 月 1 日的上个月是去年 12 月", |
|||
now: time.Date(2026, 1, 1, 0, 30, 0, 0, loc), |
|||
wantMonth: "2025-12-01~2025-12-31", |
|||
}, |
|||
} |
|||
for _, c := range cases { |
|||
t.Run(c.name, func(t *testing.T) { |
|||
if c.wantWeek != "" { |
|||
w := lastFinishedWeek(c.now) |
|||
if got := w.start + "~" + w.end; got != c.wantWeek { |
|||
t.Errorf("上周范围应为 %s,得到 %s", c.wantWeek, got) |
|||
} |
|||
if w.ptype != comm.MemoryPeriodWeek { |
|||
t.Errorf("周期类型应为 week,得到 %s", w.ptype) |
|||
} |
|||
} |
|||
m := lastFinishedMonth(c.now) |
|||
if got := m.start + "~" + m.end; got != c.wantMonth { |
|||
t.Errorf("上月范围应为 %s,得到 %s", c.wantMonth, got) |
|||
} |
|||
if m.ptype != comm.MemoryPeriodMonth { |
|||
t.Errorf("周期类型应为 month,得到 %s", m.ptype) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// 周一 00:30 的 cron 与 ensureRecent 必须算出同一个周期键,
|
|||
// 否则一个建单、另一个再建一次,同一周会有两份报告在跑。
|
|||
func TestLastFinishedWeekMatchesCronKey(t *testing.T) { |
|||
loc := time.FixedZone("CST", 8*3600) |
|||
now := time.Date(2026, 9, 14, 0, 30, 0, 0, loc) // 周一,cron 触发时刻
|
|||
cronKey := comm.MemoryWeekKey(now.AddDate(0, 0, -1)) |
|||
if got := lastFinishedWeek(now).key; got != cronKey { |
|||
t.Errorf("ensureRecent 算出 %s,cron 算出 %s,两者必须一致", got, cronKey) |
|||
} |
|||
} |
|||
|
|||
// 重试次数寄存在 error_msg 的 "retry=N;" 前缀里(不加列)。
|
|||
// 计数丢了的后果是一份永远失败的报告可以无限重试,每次都烧一次 LLM。
|
|||
func TestRetryCountRoundTrip(t *testing.T) { |
|||
cases := []struct { |
|||
msg string |
|||
want int |
|||
}{ |
|||
{"", 0}, |
|||
{"统计失败: connection refused", 0}, |
|||
{"retry=1;统计失败", 1}, |
|||
{"retry=3;统计失败", 3}, |
|||
{"retry=;坏格式当没重试过", 0}, |
|||
{"retry=abc;坏格式当没重试过", 0}, |
|||
{"retry=2没有分号也当没重试过", 0}, |
|||
} |
|||
for _, c := range cases { |
|||
if got := retryCount(c.msg); got != c.want { |
|||
t.Errorf("retryCount(%q) = %d,期望 %d", c.msg, got, c.want) |
|||
} |
|||
} |
|||
|
|||
// 加计数要保住原来的错误正文,换计数不能越叠越长
|
|||
got := withRetryCount("统计失败: x", 1) |
|||
if got != "retry=1;统计失败: x" { |
|||
t.Errorf("首次加计数得到 %q", got) |
|||
} |
|||
if got = withRetryCount(got, 2); got != "retry=2;统计失败: x" { |
|||
t.Errorf("再次加计数得到 %q(正文应原样保留、前缀不叠加)", got) |
|||
} |
|||
} |
|||
|
|||
// fail 覆盖 error_msg 时必须保住已有计数——不然重试上限形同虚设。
|
|||
func TestFailKeepsRetryCount(t *testing.T) { |
|||
rec := &pb.DBMemoryReport{ErrorMsg: "retry=2;上一次的原因"} |
|||
// 只验计数的保留逻辑本身,不走 fail(它要写库)
|
|||
msg := "这次的原因" |
|||
if n := retryCount(rec.ErrorMsg); n > 0 { |
|||
msg = withRetryCount(msg, n) |
|||
} |
|||
if retryCount(msg) != 2 { |
|||
t.Errorf("覆盖错误正文后计数应仍为 2,得到 %q", msg) |
|||
} |
|||
} |
|||
@ -0,0 +1,582 @@ |
|||
# 语音纪要 → 拾忆 → 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:<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`: |
|||
|
|||
```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`。 |
|||
|
|||
```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` 注释里已记为待办,与本链路无关。 |
|||
Loading…
Reference in new issue