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 不该拿到收尾权") } }) }