feat(chat): chat_messages.turn_id 列 + DTO(写入接线留待下一步)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

迁移 0018:ALTER TABLE chat_messages ADD COLUMN turn_id TEXT NOT NULL DEFAULT ''
(与 persona_id / client_msg_id / document 同族:不用 NULL,空串 = 不属于任何已知回合 ⇒ 旧行零迁移、键省略)。
存储层全链路带 turn_id(列清单唯一一份 ⇒ List/ListAfter/Recent/Search/FindByClientMsgID 自动同源);
新增 history/turn_id.go:ensureTurnIDColumn(既有库幂等前滚)+ SetMessagesTurnID
(第二段补写:单事务参数化、先到先得 WHERE turn_id = ''、幂等、未知 id 忽略)。
DTO:historyMessage.TurnID(json turnId,omitempty)⇒ REST 列表 / admin 渠道日志 / WS 握手回放 /
message_persisted / channel_persisted / message_edited 一次全覆盖。
写入面:persistUserMessages 读 ctx 回合标识;persistAssistantReply 增可选尾参(既有调用点零改动);
withTurnID / turnIDFrom / assignTurnID / turnIDOfLeg。
⚠️ 编排器把 turnId 交给落库的 4 处(新起一路 / 续跑路 / 被 steer 吃进 / 助手回复)尚未接线
(那些文件当时被另一路占用);当前生产行为 turn_id 仍为 ''。清单已在切片报告里精确到文件:行。
13 新用例 + 5 条变异(INSERT 不带列 / DTO 不回填 / 去掉 omitempty / 去掉先到先得 / 不回滚前滚)。
为守 session_persist.go ≤500 行,turnId 一节拆到新文件 session_turn_id.go。
This commit is contained in:
AskaEth committed 2026-10-05 09:46:10 +08:00
1 parent 354b08a45f
commit d64b26d499
12 files changed
+950 -16

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
package db
import (
"context"
"testing"
)
// turnIDMigrationID 是回合标识列的迁移 id(design/15 §2.5.9)。
const turnIDMigrationID = "0018_chat_message_turn_id"
// TestMigrateTurnIDColumnReversible 验证 0018(chat_messages.turn_id,回合标识列)
// 可上下翻滚:up 建列、重复 up 是空操作、down 删列、再 up 恢复。它用自己的内存库,
// 绝不碰 data/cyrene.db。
func TestMigrateTurnIDColumnReversible(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn := openMemory(t)
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp: %v", err)
}
if !columnExists(t, ctx, conn, "chat_messages", "turn_id") {
t.Fatal("chat_messages.turn_id should exist after MigrateUp")
}
// 重复 up:已记录的迁移被跳过,列仍在且不报错(幂等)。
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp second call: %v", err)
}
if !columnExists(t, ctx, conn, "chat_messages", "turn_id") {
t.Fatal("chat_messages.turn_id should still exist after idempotent re-run")
}
// 恰好回滚到 0018(含它之后新加的迁移),因此日后再加迁移也不会让本测试失效。
ids := upMigrationIDs(t)
idx := -1
for i, id := range ids {
if id == turnIDMigrationID {
idx = i
}
}
if idx < 0 {
t.Fatalf("%s not found in %v", turnIDMigrationID, ids)
}
if err := MigrateDown(ctx, conn, len(ids)-idx, testLogger()); err != nil {
t.Fatalf("MigrateDown(%d): %v", len(ids)-idx, err)
}
if columnExists(t, ctx, conn, "chat_messages", "turn_id") {
t.Fatal("chat_messages.turn_id should be dropped by the 0018 down")
}
// 重新 up 恢复端到端可用:默认空串意味着「这一行不属于任何已知回合」。
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("re-MigrateUp: %v", err)
}
if !columnExists(t, ctx, conn, "chat_messages", "turn_id") {
t.Fatal("chat_messages.turn_id should exist again after re-MigrateUp")
}
if _, err := conn.ExecContext(ctx,
`INSERT INTO chat_messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
"m1", "web", "user", "hi", 1000); err != nil {
t.Fatalf("insert without turn id: %v", err)
}
var turnID string
if err := conn.QueryRowContext(ctx, `SELECT turn_id FROM chat_messages WHERE id = ?`, "m1").Scan(&turnID); err != nil {
t.Fatalf("select turn_id: %v", err)
}
if turnID != "" {
t.Fatalf("turn_id = %q, want the empty default", turnID)
}
}
@@ -0,0 +1,9 @@
-- 0018_chat_message_turn_id down: 回滚回合标识列。
--
-- turn_id 只被 chat_messages 自己引用(无索引、无视图、无约束牵涉),因此直接删列
-- 即可。回滚后的状态是「服务端不再记得哪条回复在答哪句提问」:客户端退回按顺序
-- 渲染(本列引入之前的行为),行里的分组信息随列一起丢弃 —— 它只是**派生的展示
-- 元数据**,真实回合身份仍由流帧(帧壳 turnId)携带,删列不会让任何在跑的回合
-- 失去自己的身份。
ALTER TABLE chat_messages DROP COLUMN turn_id;
@@ -0,0 +1,27 @@
-- 0018_chat_message_turn_id: 给聊天消息挂上「它属于哪一路回合」(design/15 §2.5.9)。
--
-- 并行回合(design/15 §2.5)落地后,一个会话可以同时有两条在飞的**路**,同一个
-- 会话的行因此不再能靠「顺序」唯一回答「这条回复在答哪一句提问」。turn_id 就是
-- 编排器生成、该路全部流帧与 `turn_begin` 帧壳同值的那一份权威回合标识
-- (internal/services/gateway 的 turnLeg.turnID,经 loop.WithTurnID 盖在帧上)。
--
-- 用途(客户端依赖):① 「回复 ↔ 提问」精确配对 —— 同一 turn_id 的 user 行与
-- assistant 行互为配对;② 重连 / 回放时从历史行恢复「回复侧分组小标」。
--
-- 它**可选**:'' 意味着「这一行不属于任何已知的回合」—— 加入本列之前的全部历史行、
-- 以及未经编排器的写入路径(IM 镜像等)都是这种情形,读起来因此与加入本列之前
-- 逐字相同(DTO 里该键缺席)。列口径与同族可选列(persona_id / client_msg_id /
-- document)完全一致:NOT NULL DEFAULT '',**绝不用 NULL** —— 于是「旧行」与
-- 「没写」是同一个值,读取侧只有一种空情形要处理。这一列因此**不需要索引**:
-- 它只被随行读出,没有任何按它过滤/排序的查询。
--
-- 写入是**两段式**(与 document 列同一手法):消息在它所属的那一路已经存在时
-- (新起的一路、以及助手回复)就把 turn_id 写进 INSERT;排队 / 被转向吃进的消息
-- 在落库那一刻还没有自己的路,由它**实际被处理的那一路**在起跑/消费时补写同一
-- 列(先到先得,见 internal/runtime/history/turn_id.go 的 SetMessagesTurnID)。
--
-- 0003 的 chat_messages 表在此已存在,故 ALTER ADD COLUMN 是非破坏性的正确一步;
-- 全新库由 internal/runtime/history.NewStore 幂等地前滚(含该列)。表名沿用复数
-- 口径(援引 AGENTS §10 已登记的例外)。
ALTER TABLE chat_messages ADD COLUMN turn_id TEXT NOT NULL DEFAULT '';