Files
AskaEth 5717735da9 feat(gateway): M44 实时帧与握手回放挂上 voice(纯增量)
修实根:`message_persisted` / `channel_persisted` 与握手 `history` 回放
此前只在 `toHistoryMessage` 之后直接扇出,于是消息 DTO 的语音面在这三条
面上恒缺席,客户端只能等下一次 REST 拉取才知道那是一条语音。

- `history_voice.go`:把语音补齐抽成存储无关的唯一收口点 `attachVoiceTo`
  (Server 与 hub 共用),并加 `voiceColumnsOf` 把任意历史视图窄化成读面,
  未实现该视图时为真 nil 接口(字段缺席,不 panic、不 500)。
- `broadcast.go`:新增 `persistedWire` —— thinking 抹除 + 可选 voice,
  `broadcastPersisted` 改用它;两类帧因此天然同口径。
- `ws_history.go`:握手回放同样补一次批量读列(最多 50 个 id 一条 SQL)。

成本实测:一帧 = 1 次 `IN (?)` 批量读列(1 个 id);回放一页 = 1 次
(去重后的全部 id);存储未提供该视图时 0 次。`history.Message` 没有
voice 列字段(`messageColumns` 也不含),故无法「零查询直接填」——补它
要改 internal/runtime/history 与 INSERT 写路径,超出本切片范围。

纯增量:voice 是可选键,非语音消息整键缺席(不是 null、不是 {});
帧壳、帧类型、REST DTO 形状一律未动。

测试(广播_voice_test.go):帧序列化输出上断言 ①②③④,并逐字比对
「帧/回放里的 voice」与「REST 读面同一行」;摘掉任一挂载即打红。
2026-10-26 18:46:06 +08:00

437 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
// 本文件钉住 M44:语音面在**实时帧**与**握手回放**上的纯增量覆盖(契约 v0.67 §4.4)。
//
// 五条断言各对应一条取舍:
// ① `message_persisted` 的 `data.message.voice` 出现,且与 `GET /sessions/{id}/messages`
// 同一行**逐字相等** —— 这是本切片唯一的意义:客户端不必再拉一次 REST 才认出语音气泡;
// ② 非语音消息该键**整键缺席**(不是 `null`、不是 `{}`);
// ③ `channel_persisted`(隔离渠道会话)同样带,且与渠道日志读面同一行逐字相等;
// ④ 握手 `history` 回放同样带(老客户端因此重连即可拿到,不必等重新拉取);
// ⑤ 成本实测:一帧 = **一次**批量读列(`IN (?)`)、回放一页 = **一次**(id 去重)——
// 不是逐条查,也不是「每帧多打几枪」。
//
// 断言一律打在**帧的序列化输出**上(`readFakeFrame` / `ReadMessage` 都是先 marshal 再
// 回读),因此「键缺席」与「零值」的区别是真的可分辨的 —— 解码进结构体做不到这一点。
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// countingVoiceStore 在真实存储外面只给 `VoiceColumns` 记账:本切片的核心成本问题是
// 「一条消息多打几枪、一次带几个 id」,所以把调用次数与 id 数**量出来**,而不是凭印象
// 说「一次批量」。
type countingVoiceStore struct {
*history.Store
mu sync.Mutex
calls int
ids []string
}
func (c *countingVoiceStore) VoiceColumns(ctx context.Context, ids []string) (map[string]string, error) {
c.mu.Lock()
c.calls++
c.ids = append(c.ids, ids...)
c.mu.Unlock()
return c.Store.VoiceColumns(ctx, ids)
}
// reset 把计数器归零:每个断言段各自量自己的代价。
func (c *countingVoiceStore) reset() {
c.mu.Lock()
defer c.mu.Unlock()
c.calls, c.ids = 0, nil
}
// snapshot 返回 (调用次数, 各次查询的 id 并集)。
func (c *countingVoiceStore) snapshot() (int, []string) {
c.mu.Lock()
defer c.mu.Unlock()
return c.calls, append([]string(nil), c.ids...)
}
// voiceFrameEnv 打包实时帧 / 回放测试所需的依赖:真实(内存)历史存储(外套记账层)
// + httptest 网关 + JWT 服务。
type voiceFrameEnv struct {
srv *Server
ts *httptest.Server
jwt *auth.JWTService
st *history.Store
count *countingVoiceStore
}
func newVoiceFrameEnv(t *testing.T) *voiceFrameEnv {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", testLogger())
if err != nil {
t.Fatalf("打开内存库: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
st, err := history.NewStore(ctx, conn, testLogger())
if err != nil {
t.Fatalf("构造历史存储: %v", err)
}
count := &countingVoiceStore{Store: st}
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{History: count})
return &voiceFrameEnv{srv: srv, ts: ts, jwt: jwt, st: st, count: count}
}
// seed 追加一条消息,并把(可选的)语音事实写进它的 voice 列 —— 这与生产里渠道桥
// 「落库后回填同一列」的两段式同形。
func (e *voiceFrameEnv) seed(t *testing.T, m history.Message, raw string) history.Message {
t.Helper()
got, err := e.st.AppendFull(context.Background(), m)
if err != nil {
t.Fatalf("落库消息: %v", err)
}
if raw == "" {
return got
}
if err := e.st.UpdateMessageVoice(context.Background(), got.ID, raw); err != nil {
t.Fatalf("写 voice 列: %v", err)
}
return got
}
// encodeVoiceFact 编码一条语音事实;失败即测试前置条件坏掉。
func encodeVoiceFact(t *testing.T, ref voiceref.Ref) string {
t.Helper()
raw, err := voiceref.Encode(ref)
if err != nil {
t.Fatalf("编码语音事实: %v", err)
}
return raw
}
// canonicalJSON 把一段 JSON 归一成「键序由 json.Marshal 的 map 排序决定」的文本。
// JSON 对象的键**无序**,因此「逐字相等」在对象层面就该按归一后的文本来判:两侧都走
// 同一套归一,任何一处多一个键 / 少一个键 / 值不同都会让文本不同。
func canonicalJSON(t *testing.T, raw json.RawMessage) string {
t.Helper()
var v any
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatalf("解析 JSON(raw=%s): %v", raw, err)
}
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("归一 JSON(raw=%s): %v", raw, err)
}
return string(b)
}
// frameMessageVoice 从**已序列化并回读**的帧里取出 `data.message.voice`,返回归一后的
// JSON 文本与「该键是否在场」。本面的关键取舍就是「缺席」,因此先断言在场性再看内容。
func frameMessageVoice(t *testing.T, ev wsEvent) (string, bool) {
t.Helper()
data, ok := ev.Data.(map[string]any)
if !ok {
t.Fatalf("帧 data = %+v, want 一个 JSON 对象", ev.Data)
}
msg, ok := data["message"].(map[string]any)
if !ok {
t.Fatalf("帧 data.message = %+v, want 一个 JSON 对象", data["message"])
}
raw, present := msg["voice"]
if !present {
return "", false
}
b, err := json.Marshal(raw)
if err != nil {
t.Fatalf("归一帧里的 voice(%v): %v", raw, err)
}
return string(b), true
}
// replayMessageVoice 与 frameMessageVoice 同形,只是取的是握手 `history` 帧里
// `data.messages[]` 的某一条(按 id 定位)。
func replayMessageVoice(t *testing.T, ev wsEvent, id string) (string, bool) {
t.Helper()
data, ok := ev.Data.(map[string]any)
if !ok {
t.Fatalf("回放帧 data = %+v, want 一个 JSON 对象", ev.Data)
}
rows, ok := data["messages"].([]any)
if !ok {
t.Fatalf("回放帧 data.messages = %+v, want 一个数组", data["messages"])
}
for _, row := range rows {
msg, ok := row.(map[string]any)
if !ok {
t.Fatalf("回放消息 = %+v, want 一个 JSON 对象", row)
}
if msg["id"] != id {
continue
}
raw, present := msg["voice"]
if !present {
return "", false
}
b, err := json.Marshal(raw)
if err != nil {
t.Fatalf("归一回放里的 voice(%v): %v", raw, err)
}
return string(b), true
}
t.Fatalf("回放里没有 id=%q 的消息", id)
return "", false
}
// restVoiceOf 取 REST 读面里**同一行**消息的 `voice`,归一成与帧侧可比的文本。
// 键缺席时返回 ("", false)。
func restVoiceOf(t *testing.T, rows []map[string]json.RawMessage, id string) (string, bool) {
t.Helper()
for _, row := range rows {
var gotID string
if err := json.Unmarshal(row["id"], &gotID); err != nil {
t.Fatalf("解析 id: %v", err)
}
if gotID != id {
continue
}
raw, present := row["voice"]
if !present {
return "", false
}
return canonicalJSON(t, raw), true
}
t.Fatalf("REST 读面里没有 id=%q 的消息", id)
return "", false
}
// TestBroadcastPersistedCarriesVoiceMatchingRestRead 断言 ①②⑤:语音消息的
// `message_persisted` 帧带 `voice`、与 REST 读面逐字相等;非语音消息整键缺席;
// 一帧的代价是一次批量读列(一个 id)。
func TestBroadcastPersistedCarriesVoiceMatchingRestRead(t *testing.T) {
t.Parallel()
env := newVoiceFrameEnv(t)
tok := signAny(t, env.jwt, "acct", auth.RoleOwner)
name := "0123456789abcdef0123456789abcdef.ogg"
voiceMsg := env.seed(t, history.Message{
SessionID: history.MainSessionID, Role: history.RoleUser, Content: "转写文本",
}, encodeVoiceFact(t, voiceref.Ref{
Source: voiceref.SourceASR,
Status: voiceref.StatusTranscribed,
Name: name,
MIME: "audio/ogg",
Size: 4242,
DurationMs: 3200,
}))
plainMsg := env.seed(t, history.Message{
SessionID: history.MainSessionID, Role: history.RoleUser, Content: "普通文本",
}, "")
c := newOwnerConn(env.srv.hub, 8)
env.srv.hub.register(c)
env.count.reset()
env.srv.BroadcastPersisted(voiceMsg)
ev := readFakeFrame(t, c)
if ev.Type != "message_persisted" {
t.Fatalf("帧类型 = %q, want message_persisted", ev.Type)
}
frameVoice, present := frameMessageVoice(t, ev)
if !present {
t.Fatalf("语音消息的 message_persisted 帧缺 data.message.voice(帧=%+v)", ev)
}
// ⑤ 成本:一帧 = 一次批量读列,恰好一个 id(没有逐条多打一枪)。**先**取快照,
// 免得下面那次 REST 拉取的记账混进来。
calls, ids := env.count.snapshot()
// 与 REST 读面同一行**逐字相等**:这是本切片唯一的意义。
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("REST status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
restVoice, restOK := restVoiceOf(t, messagePage(t, body), voiceMsg.ID)
if !restOK {
t.Fatalf("REST 读面同一行缺 voice 键(body=%s)", body)
}
if frameVoice != restVoice {
t.Fatalf("帧里的 voice = %s, REST 读面同一行 = %s(两处各算一份就失去意义)", frameVoice, restVoice)
}
// 字段值逐项落地(给失败信息可读性;形状正确性由上面的逐字比较兜底)。
var fields historyVoice
if err := json.Unmarshal([]byte(frameVoice), &fields); err != nil {
t.Fatalf("解析帧里的 voice: %v(raw=%s)", err, frameVoice)
}
if fields.Source != "asr" || fields.Status != "transcribed" ||
fields.Name != name || fields.MIME != "audio/ogg" || fields.Size != 4242 ||
fields.DurationMs != 3200 || fields.URL != voiceref.MediaPathPrefix+name {
t.Fatalf("帧里的 voice 字段 = %+v", fields)
}
// ⑤ 成本:一帧 = 一次批量读列,恰好一个 id(没有逐条多打一枪)。
if calls != 1 || len(ids) != 1 || ids[0] != voiceMsg.ID {
t.Fatalf("一帧的读列代价 = %d 次调用 / ids=%v,want 1 次 / [%s]", calls, ids, voiceMsg.ID)
}
// ② 非语音消息:该键**整键缺席**(不是 `null`、不是 `{}`)。
env.srv.BroadcastPersisted(plainMsg)
plainFrame := readFakeFrame(t, c)
if got, present := frameMessageVoice(t, plainFrame); present {
t.Fatalf("非语音消息的帧带上了 voice = %s,want 整键缺席", got)
}
}
// TestChannelPersistedCarriesVoiceMatchingChannelLogRead 断言 ③⑤:隔离渠道会话
// (群聊 / 陌生人)走的是同一个构造函数,因此 `channel_persisted` 同样带 `voice`,
// 且与渠道日志读面同一行逐字相等 —— 「Web 帧带、IM 帧不带」的分叉不存在。
func TestChannelPersistedCarriesVoiceMatchingChannelLogRead(t *testing.T) {
t.Parallel()
env := newVoiceFrameEnv(t)
tok := signAny(t, env.jwt, "acct", auth.RoleOwner)
adapter := "qq-obv11:10001"
name := "ffffffffffffffffffffffffffffffff.mp3"
msg := env.seed(t, history.Message{
SessionID: "sess-group-1", Role: history.RoleUser, Content: "群里的语音",
Platform: "qq-obv11", AdapterID: adapter, SenderName: "某人",
}, encodeVoiceFact(t, voiceref.Ref{
Source: voiceref.SourceASR, Status: voiceref.StatusTranscribed,
Name: name, MIME: "audio/mpeg", Size: 99,
}))
c := newOwnerConn(env.srv.hub, 8)
env.srv.hub.register(c)
env.count.reset()
env.srv.BroadcastPersisted(msg)
ev := readFakeFrame(t, c)
if ev.Type != "channel_persisted" {
t.Fatalf("帧类型 = %q, want channel_persisted(隔离会话不是 Web 主会话)", ev.Type)
}
frameVoice, present := frameMessageVoice(t, ev)
if !present {
t.Fatalf("隔离会话的 channel_persisted 帧缺 data.message.voice(帧=%+v)", ev)
}
// ⑤ 成本快照**先**取,免得下面那次渠道日志拉取的记账混进来。
calls, ids := env.count.snapshot()
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/channels/messages?adapter="+adapter, nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("渠道日志 status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
restVoice, restOK := restVoiceOf(t, messagePage(t, body), msg.ID)
if !restOK {
t.Fatalf("渠道日志同一行缺 voice 键(body=%s)", body)
}
if frameVoice != restVoice {
t.Fatalf("帧里的 voice = %s, 渠道日志同一行 = %s", frameVoice, restVoice)
}
if calls != 1 || len(ids) != 1 {
t.Fatalf("一帧的读列代价 = %d 次调用 / ids=%v,want 1 次 / 1 个 id", calls, ids)
}
}
// TestWSHandshakeReplayCarriesVoice 断言 ④⑤:握手 `history` 回放同样带 `voice`
// (与 REST 读面同一行逐字相等),非语音行之键缺席,且整页只查一次列。
func TestWSHandshakeReplayCarriesVoice(t *testing.T) {
t.Parallel()
env := newVoiceFrameEnv(t)
tok := signAny(t, env.jwt, "acct", auth.RoleOwner)
name := "abcdefabcdefabcdefabcdefabcdefab.ogg"
voiceMsg := env.seed(t, history.Message{
SessionID: history.MainSessionID, Role: history.RoleUser, Content: "回放里的语音", CreatedAt: 1000,
}, encodeVoiceFact(t, voiceref.Ref{
Source: voiceref.SourceASR, Status: voiceref.StatusTranscribed,
Name: name, MIME: "audio/ogg", Size: 777, DurationMs: 1200,
}))
plainMsg := env.seed(t, history.Message{
SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "回放里的纯文本", CreatedAt: 1500,
}, "")
env.count.reset()
conn := dialWS(t, env.ts.URL, tok)
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, raw, err := conn.ReadMessage()
if err != nil {
t.Fatalf("读取握手帧: %v", err)
}
var ev wsEvent
if err := json.Unmarshal(raw, &ev); err != nil {
t.Fatalf("解析握手帧 %s: %v", raw, err)
}
if ev.Type != "history" {
t.Fatalf("首帧类型 = %q, want history(%s)", ev.Type, raw)
}
replayVoice, present := replayMessageVoice(t, ev, voiceMsg.ID)
if !present {
t.Fatalf("握手回放里语音行缺 voice(帧=%s)", raw)
}
// ⑤ 整页一次批量:恰好 1 次调用、把两行的 id 都带上。快照**先**取,免得下面那次
// REST 拉取的记账混进来。
calls, ids := env.count.snapshot()
// 与 REST 读面同一行逐字相等。
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("REST status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
restVoice, restOK := restVoiceOf(t, messagePage(t, body), voiceMsg.ID)
if !restOK {
t.Fatalf("REST 读面同一行缺 voice 键(body=%s)", body)
}
if replayVoice != restVoice {
t.Fatalf("回放里的 voice = %s, REST 读面同一行 = %s", replayVoice, restVoice)
}
if got, present := replayMessageVoice(t, ev, plainMsg.ID); present {
t.Fatalf("回放里非语音行带上了 voice = %s,want 整键缺席", got)
}
// ⑤ 整页一次批量:恰好 1 次调用,且把两行的 id 都带上了。
if calls != 1 || len(ids) != 2 {
t.Fatalf("回放一页的读列代价 = %d 次调用 / ids=%v,want 1 次 / 2 个 id", calls, ids)
}
}
// TestBroadcastPersistedVoiceAbsentWhenStoreCannotAnswer 钉住两条**诚实缺席**路径:
// 未接线历史存储、以及存储没提供语音列读面(测试替身 / 只做基本读写的实现)。
// 两者都不许 panic、不许把帧吞掉 —— 那条路径上「问不到」的结果就是字段缺席。
func TestBroadcastPersistedVoiceAbsentWhenStoreCannotAnswer(t *testing.T) {
t.Parallel()
pending := history.Message{
ID: "row-1", SessionID: history.MainSessionID, Role: history.RoleUser, Content: "hi", CreatedAt: 1000,
}
t.Run("history not wired", func(t *testing.T) {
srv, _, _ := buildEnv(t, cfgDefault(), Deps{})
c := newOwnerConn(srv.hub, 8)
srv.hub.register(c)
srv.BroadcastPersisted(pending)
ev := readFakeFrame(t, c)
if ev.Type != "message_persisted" {
t.Fatalf("帧类型 = %q, want message_persisted(问不到语音列不该吞掉帧)", ev.Type)
}
if got, present := frameMessageVoice(t, ev); present {
t.Fatalf("未接线历史存储的帧带上了 voice = %s,want 缺席", got)
}
})
t.Run("store without the voice view", func(t *testing.T) {
srv, _, _ := buildEnv(t, cfgDefault(), Deps{History: &fakeHistory{}})
c := newOwnerConn(srv.hub, 8)
srv.hub.register(c)
srv.BroadcastPersisted(pending)
ev := readFakeFrame(t, c)
if got, present := frameMessageVoice(t, ev); present {
t.Fatalf("未实现语音列读面的存储替身带上了 voice = %s,want 缺席", got)
}
})
}