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