Files
Cyrene_v2/internal/services/gateway/chat_idempotency_test.go
T
AskaEth 7512d593c2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,history): REST 流式不再静默失败(G2)+ clientMsgId 幂等(G4)(协同 §G,均标「中高/中」)
- G2:`stream:true` 原经**有损总线**且**先回 200** ⇒ 总线饱和时客户端拿到 200 却什么都没发生。
  改为**直达编排器**(与 ws chat 帧既有先例同一原则):新文件 chat_stream_dispatch.go;200 变成有意义的
  承诺(同步穿过「编排器接收 + 用户消息落库」才回);顺带对齐 `202 {queued,queueDepth}` / `409 chat_busy`;
  兜底路径(未接编排器才走总线)现在**返回错误**、ctx 与请求脱钩(WithoutCancel + 30min 上界)→ `503 chat_dispatch_failed`;
  未接 loop 由 200 改为 `500 chat_unavailable`(与同步路径同口径,已同步改既有用例并新增钉住用例)
- G4:`chat`(WS 帧 + REST 体)新增**可选** `clientMsgId` ⇒ 幂等。
  原子性两层,均非「先查再插」:① 同进程在 `o.mu` 同一临界区内完成「查认领表→查存储→预分配 id→认领」;
  ② 跨重启靠 `(session_id, client_msg_id)` **部分**唯一索引 + `INSERT … ON CONFLICT DO NOTHING`(命中则读回既有行)。
  重复语义:REST `200 {duplicate:true, messageId}`;WS 新帧 `chat_duplicate`(owner/admin 档);重复**不落库、不起回合**。
  `clientMsgId` 回填进 DTO ⇒ `message_persisted` 与读接口都可见(客户端可据此对齐本地气泡,不再靠文本相等)
- 迁移 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列),
  已在 `data/cyrene.db` **副本**上验 up → 判重 → down → 再 up
- 拆分:store.go 647→498 行(`log_reads.go` 抽走四个读路径);七个 SELECT 清单抽成 `messageColumns` 一处
- 复核:**全树 `go test ./... -count=1` 0 FAIL** + build/vet 绿 + linux/darwin 交叉编译绿;
  四条真断言(总线饱和仍落库出帧且 `ws.chat` 零发布 / 同键两次仅一回合+并发两条也仅一回合 / 省略键逐字不变 /
  迁移副本 up-down-up);**3 条变异反证**均已还原
- 越界说明(均已报备):`history.go` DTO 字段(否则「读接口可见」不可能)、`policy_store_test.go`
  (0013 使「回滚最后一步 == 0012」前提失效,改为按 schema_migrations 现算)、`extra_test.go`(补 runner)
- 顺带补交 P0-A 遗漏的新测试文件 `loop/memory_writeback_test.go`
2026-10-04 14:37:37 +08:00

413 lines
16 KiB
Go
Raw 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
import (
"encoding/json"
"net/http"
"testing"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
)
// 本文件锁定 Android 协同 §G 的两条:G2(REST `stream:true` 不得「静默成功」)
// 与 G4(chat 全链路幂等 —— clientMsgId)。
//
// G2 的核心断言:**总线饱和(或不投递)时,经 REST `stream:true` 发起的对话
// 仍然落库并产生帧**。它与 ws_route_chat_test.go 的
// TestWSChatFrameSurvivesSaturatedBus 是同一条原则在另一条入口上的落地:会话
// 消息不得依赖可丢弃的通道(design/15 §2.1)。
//
// G4 的核心断言:同一 clientMsgId 发两次 → 只有一条用户消息、只有一个回合,且
// 第二次的响应明确(既有 messageId);省略该字段时行为与今天逐字一致。
// postChat 以 owner token POST 一次 /api/v1/chat,返回响应与原始响应体。
func postChat(t *testing.T, url, token string, body map[string]any) (*http.Response, []byte) {
t.Helper()
return doReq(t, http.MethodPost, url+"/api/v1/chat", body, token)
}
// dataOf 解出 {data:...} 信封里的 data 对象。
func dataOf(t *testing.T, body []byte) map[string]any {
t.Helper()
raw, ok := decodeBody(t, body)["data"]
if !ok {
t.Fatalf("响应没有 data 信封:%s", body)
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("解码 data 失败:%v(body=%s)", err, body)
}
return out
}
// errorCodeOf 解出 {error:{code}} 里的 code。
func errorCodeOf(t *testing.T, body []byte) string {
t.Helper()
var eobj struct {
Code string `json:"code"`
}
if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil {
t.Fatalf("解码 error 信封失败:%v(body=%s)", err, body)
}
return eobj.Code
}
// frameTypes 汇总见过的帧类型,供失败信息使用(读帧超时时能一眼看出漏了什么)。
func chatFrameTypes(frames []wsEvent) []string {
out := make([]string, 0, len(frames))
for _, f := range frames {
out = append(out, f.Type)
}
return out
}
// readFramesUntil 在读到一个 want 类型的帧之前持续读帧(最多 5 秒),返回它
// 见过的**全部**帧。它带读超时,因此断言失败时是明确的报错,而不是挂死。
func readChatFramesUntil(t *testing.T, conn *websocket.Conn, want string) []wsEvent {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
seen := make([]wsEvent, 0, 8)
for time.Now().Before(deadline) {
_ = conn.SetReadDeadline(deadline)
_, msg, err := conn.ReadMessage()
if err != nil {
t.Fatalf("等待 %q 时读 WS 帧失败:%v(已见 %v)", want, err, chatFrameTypes(seen))
}
var ev wsEvent
if err := json.Unmarshal(msg, &ev); err != nil {
t.Fatalf("解码帧 %s:%v", msg, err)
}
seen = append(seen, ev)
if ev.Type == want {
return seen
}
}
t.Fatalf("等待 %q 超时(已见 %v)", want, chatFrameTypes(seen))
return nil
}
// hasFrame 报告帧列表里是否出现过某类型。
func hasFrame(frames []wsEvent, typ string) bool {
for _, f := range frames {
if f.Type == typ {
return true
}
}
return false
}
// TestChatStreamSurvivesSaturatedBus 是 G2 的主断言:总线**完全不投递**(饱和
// 丢弃)时,REST `stream:true` 依然驱动回合、落库用户消息与助手回复,并产出下行
// 帧。修复前这一轮是 publishChatStream → bus.Publish → hub 订阅:饱和时事件被
// 丢,客户端却早就拿到了 200 {"stream":true} —— 不落库、没有任何帧。
func TestChatStreamSurvivesSaturatedBus(t *testing.T) {
t.Parallel()
run := &recordingRunner{}
hist := newReplayHistoryStore()
sb := &saturatedBus{}
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Loop: run, History: hist, Bus: sb})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
conn := dialWS(t, ts.URL, tok)
defer conn.Close()
waitConnCount(t, srv, 1)
resp, body := postChat(t, ts.URL, tok, map[string]any{"sessionId": "web", "text": "总线饱和也要到", "stream": true})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200(body=%s)", resp.StatusCode, body)
}
if got := dataOf(t, body); got["stream"] != true {
t.Fatalf("data = %v, want stream:true", got)
}
// 200 回给客户端时用户消息已经落库(handler 同步穿过提交与落库),助手回复
// 随回合结算到达。
waitUntil(t, "回合被驱动", func() bool { return run.count() == 1 })
waitUntil(t, "用户+助手消息落库", func() bool { return len(hist.records()) == 2 })
recs := hist.records()
if recs[0].Role != "user" || recs[0].Content != "总线饱和也要到" {
t.Fatalf("用户消息 = %+v,want 刚发的那条", recs[0])
}
if recs[1].Role != "assistant" || recs[1].Content == "" {
t.Fatalf("助手回复 = %+v,want 非空", recs[1])
}
// 帧:用户消息的 message_persisted 必然在 stream_end 之前出现过。
frames := readChatFramesUntil(t, conn, "stream_end")
if !hasFrame(frames, "message_persisted") {
t.Fatalf("帧 = %v,want 至少一条 message_persisted(客户端要靠它追加消息)", chatFrameTypes(frames))
}
// 会话消息绝不经可丢弃的通道:ws.chat 主题上不应有任何发布。
for _, subject := range sb.subjects() {
if subject == wsChatSubject {
t.Fatalf("REST stream:true 仍把会话消息发布到可丢弃的 bus 主题 %q(design/15 §2.1 禁止)", subject)
}
}
}
// TestChatStreamWithoutRunnerIsNotSilentlyAccepted 钉住诚实的退化:没接 loop 的
// 网关(runner 为 nil)**不能**对 stream:true 回 200 —— 修复前它照样回 200,
// 而客户端永远等不到任何帧、也永远不会落库。这里与同步路径同口径回 500。
func TestChatStreamWithoutRunnerIsNotSilentlyAccepted(t *testing.T) {
t.Parallel()
env := newEnv(t, nil, false)
tok := env.token(t, auth.RoleOwner)
resp, body := postChat(t, env.ts.URL, tok, map[string]any{"sessionId": "web", "text": "hi", "stream": true})
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500(body=%s)", resp.StatusCode, body)
}
if code := errorCodeOf(t, body); code != "chat_unavailable" {
t.Fatalf("code = %q, want chat_unavailable", code)
}
}
// TestChatStreamAnswersBusyAndFull 锁定 G2 的顺带项:`stream:true` 也能给出
// 「排队 / 满」这两个明确的答案,而不是一律 200。(修复前 handler 提前 return,
// 202 queued 与 409 chat_busy 在这条路径上永远不可能出现。)
func TestChatStreamAnswersBusyAndFull(t *testing.T) {
t.Parallel()
release := make(chan struct{})
run := &blockingRunner{release: release}
hist := newReplayHistoryStore()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Loop: run, History: hist, Bus: &saturatedBus{}})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// 第一条:会话空闲 → 200 stream:true,回合在后台跑着。
resp, body := postChat(t, ts.URL, tok, map[string]any{"sessionId": "web", "text": "A", "stream": true})
if resp.StatusCode != http.StatusOK {
t.Fatalf("第一条 status = %d, want 200(body=%s)", resp.StatusCode, body)
}
waitUntil(t, "A 回合在飞", func() bool { return run.started() == 1 })
// 第二条:会话正忙 → 202 {stream:true, queued:true, queueDepth:1},不阻塞。
resp, body = postChat(t, ts.URL, tok, map[string]any{"sessionId": "web", "text": "B", "stream": true})
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("第二条 status = %d, want 202(body=%s)", resp.StatusCode, body)
}
got := dataOf(t, body)
if got["queued"] != true || got["stream"] != true || got["queueDepth"] != float64(1) {
t.Fatalf("第二条 data = %v, want {stream:true,queued:true,queueDepth:1}", got)
}
// 填满待处理队列(默认上限 5;已入队 1 条),下一条必须是 409 chat_busy。
for i := 0; i < 4; i++ {
resp, body = postChat(t, ts.URL, tok, map[string]any{"sessionId": "web", "text": "fill", "stream": true})
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("第 %d 条填充 status = %d, want 202(body=%s)", i+3, resp.StatusCode, body)
}
}
resp, body = postChat(t, ts.URL, tok, map[string]any{"sessionId": "web", "text": "溢出", "stream": true})
if resp.StatusCode != http.StatusConflict {
t.Fatalf("溢出 status = %d, want 409(body=%s)", resp.StatusCode, body)
}
if code := errorCodeOf(t, body); code != "chat_busy" {
t.Fatalf("溢出 code = %q, want chat_busy", code)
}
close(release)
waitUntil(t, "会话排空", func() bool {
return !srv.hub.orchestrator.Busy("web") && srv.hub.orchestrator.QueueDepth("web") == 0
})
}
// TestChatClientMsgIDDedupesTurn 是 G4 的主断言(REST 同步路径):同一个
// clientMsgId 发两次 → 只有一条用户消息、只有一个回合;第二次的响应明确给出既有
// messageId,客户端据此对齐本地乐观气泡,而不是靠「文本相等」猜。
func TestChatClientMsgIDDedupesTurn(t *testing.T) {
t.Parallel()
run := &countingRunner{}
hist := &fakeHistory{}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Loop: run, History: hist, Bus: &saturatedBus{}})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
body := map[string]any{"sessionId": "web", "text": "弱网重发的那条", "clientMsgId": "local-bubble-1"}
resp, first := postChat(t, ts.URL, tok, body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("第一次 status = %d, want 200(body=%s)", resp.StatusCode, first)
}
// 第二次:逐字相同的 clientMsgId → 明确判重,不落库、不起回合。
resp, second := postChat(t, ts.URL, tok, body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("第二次 status = %d, want 200(body=%s)", resp.StatusCode, second)
}
dup := dataOf(t, second)
if dup["duplicate"] != true {
t.Fatalf("第二次 data = %v, want duplicate:true", dup)
}
msgID, _ := dup["messageId"].(string)
if msgID == "" {
t.Fatalf("第二次 data = %v, want 既有 messageId(客户端要用它对齐气泡)", dup)
}
// 一个回合、一条用户消息:模型只被驱动一次,库里只有 user+assistant 两条。
if got := run.count(); got != 1 {
t.Fatalf("回合数 = %d, want 1(重复的 clientMsgId 绝不起第二个回合)", got)
}
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("落库条数 = %d, want 2(一条用户 + 一条助手)", len(recs))
}
if recs[0].Role != "user" || recs[0].ClientMsgID != "local-bubble-1" {
t.Fatalf("用户消息 = %+v,want clientMsgId=local-bubble-1 被回填", recs[0])
}
if recs[0].ID != msgID {
t.Fatalf("响应 messageId = %q,want 等于落库那条用户消息的 id %q", msgID, recs[0].ID)
}
}
// TestChatClientMsgIDDuplicateWhilePending 锁定幂等在**排队**分支上的行为:会话
// 忙时,同一个 clientMsgId 的第二条必须立刻判重,而不是又排一次队、又落一条用户
// 消息(重复到达的判定与队列分支无关,两者都在状态锁下的同一道门里)。
func TestChatClientMsgIDDuplicateWhilePending(t *testing.T) {
t.Parallel()
release := make(chan struct{})
run := &blockingRunner{release: release}
hist := &fakeHistory{}
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Loop: run, History: hist, Bus: &saturatedBus{}})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// 第一条(不带幂等键)占住会话:stream 路径立刻返回,回合在后台跑着。
resp, body := postChat(t, ts.URL, tok, map[string]any{"sessionId": "web", "text": "占住会话", "stream": true})
if resp.StatusCode != http.StatusOK {
t.Fatalf("第一条 status = %d, want 200(body=%s)", resp.StatusCode, body)
}
waitUntil(t, "会话变忙", func() bool { return run.started() == 1 })
dup := map[string]any{"sessionId": "web", "text": "排队的那条", "clientMsgId": "cm-pending"}
resp, body = postChat(t, ts.URL, tok, dup)
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("排队那条 status = %d, want 202(body=%s)", resp.StatusCode, body)
}
// 同键再来一次:判重,队列深度不再增长。
resp, body = postChat(t, ts.URL, tok, dup)
if resp.StatusCode != http.StatusOK {
t.Fatalf("重复那条 status = %d, want 200(body=%s)", resp.StatusCode, body)
}
if got := dataOf(t, body); got["duplicate"] != true {
t.Fatalf("重复那条 data = %v, want duplicate:true", got)
}
if depth := srv.hub.orchestrator.QueueDepth("web"); depth != 1 {
t.Fatalf("待处理深度 = %d, want 1(重复的那条不得再排队)", depth)
}
withKey := 0
for _, m := range hist.records() {
if m.ClientMsgID == "cm-pending" {
withKey++
}
}
if withKey != 1 {
t.Fatalf("带该 clientMsgId 的用户消息 = %d 条, want 1", withKey)
}
close(release)
waitUntil(t, "会话排空", func() bool {
return !srv.hub.orchestrator.Busy("web") && srv.hub.orchestrator.QueueDepth("web") == 0
})
}
// TestChatWithoutClientMsgIDIsUnchanged 钉住 G4 的兼容口径:省略 clientMsgId 时
// 行为与今天逐字一致 —— 同一段文本发两次就是两条消息、两个回合,响应里也没有
// 任何 duplicate/messageId 字段(老客户端零影响)。
func TestChatWithoutClientMsgIDIsUnchanged(t *testing.T) {
t.Parallel()
run := &countingRunner{}
hist := &fakeHistory{}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Loop: run, History: hist, Bus: &saturatedBus{}})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
body := map[string]any{"sessionId": "web", "text": "同一段文本"}
for i := 0; i < 2; i++ {
resp, raw := postChat(t, ts.URL, tok, body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("第 %d 次 status = %d, want 200(body=%s)", i+1, resp.StatusCode, raw)
}
got := dataOf(t, raw)
if _, ok := got["duplicate"]; ok {
t.Fatalf("第 %d 次 data = %v,未带幂等键时不该有 duplicate", i+1, got)
}
if got["reply"] == nil {
t.Fatalf("第 %d 次 data = %v, want 正常回复契约", i+1, got)
}
}
if got := run.count(); got != 2 {
t.Fatalf("回合数 = %d, want 2(无幂等键时逐字保持旧行为)", got)
}
recs := hist.records()
if len(recs) != 4 {
t.Fatalf("落库条数 = %d, want 4(两次各一条用户 + 一条助手)", len(recs))
}
for _, m := range recs {
if m.ClientMsgID != "" {
t.Fatalf("落库消息 = %+v,未带幂等键时 clientMsgId 必须为空", m)
}
}
}
// TestWSChatClientMsgIDDuplicateIsExplicit 锁定 WS 侧的重复语义:同一
// clientMsgId 的两条 chat 帧**并发到达**(两条帧各自进 goroutine)时,只有一个
// 赢家 —— 一个回合、一条用户消息;输掉的那条收到明确的 `chat_duplicate` 帧,
// 其 messageId 指向真正落库的那条用户消息。
func TestWSChatClientMsgIDDuplicateIsExplicit(t *testing.T) {
t.Parallel()
run := &recordingRunner{}
hist := newReplayHistoryStore()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Loop: run, History: hist, Bus: &saturatedBus{}})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
conn := dialWS(t, ts.URL, tok)
defer conn.Close()
waitConnCount(t, srv, 1)
payload := json.RawMessage(`{"sessionId":"web","text":"同一条","clientMsgId":"ws-bubble-1"}`)
for i := 0; i < 2; i++ {
if err := conn.WriteJSON(wsInbound{Type: "chat", Data: payload}); err != nil {
t.Fatalf("第 %d 条 chat 帧写入失败:%v", i+1, err)
}
}
waitUntil(t, "只驱动一个回合", func() bool { return run.count() == 1 })
waitUntil(t, "用户+助手消息落库", func() bool { return len(hist.records()) == 2 })
var userID string
for _, m := range hist.records() {
if m.Role == "user" {
if m.ClientMsgID != "ws-bubble-1" {
t.Fatalf("用户消息 = %+v,want clientMsgId 被回填", m)
}
userID = m.ID
}
}
if userID == "" {
t.Fatal("没有找到落库的用户消息")
}
frames := readChatFramesUntil(t, conn, EventChatDuplicate)
var dupFrame *wsEvent
for i := range frames {
if frames[i].Type == EventChatDuplicate {
dupFrame = &frames[i]
}
}
if dupFrame == nil {
t.Fatalf("帧 = %v,want 一条 %s", chatFrameTypes(frames), EventChatDuplicate)
}
data, ok := dupFrame.Data.(map[string]any)
if !ok {
t.Fatalf("chat_duplicate 载荷 = %#v,want 对象", dupFrame.Data)
}
if data["clientMsgId"] != "ws-bubble-1" {
t.Fatalf("chat_duplicate 载荷 = %v,want clientMsgId=ws-bubble-1", data)
}
if data["messageId"] != userID {
t.Fatalf("chat_duplicate messageId = %v,want 落库那条的 id %q", data["messageId"], userID)
}
// 重复的那条绝不产生第二个回合。
if got := run.count(); got != 1 {
t.Fatalf("回合数 = %d, want 1", got)
}
}