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

238 lines
8.5 KiB

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