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

+5 -2
View File
@@ -10,7 +10,7 @@ import (
)
// 本文件归属聊天消息携带的**文档引用**(design/24 §2.4),以及 chat_messages
// 上「建表之后才引入的列」的前滚聚合(client_msg_id 与 document)。
// 上「建表之后才引入的列」的前滚聚合(client_msg_id、document 与 turn_id)。
//
// document 列只存「引用 + 本回合注入统计」——存储名、嗅探所得 MIME、字节数、注入
// 字符数、注入是否被截断、抽取状态;**绝不存抽取正文**。G3(base64 原样落库 →
@@ -202,7 +202,10 @@ func ensureMessageColumns(ctx context.Context, db *sql.DB) error {
if err := ensureClientMsgIDColumn(ctx, db); err != nil {
return err
}
return ensureDocumentColumn(ctx, db)
if err := ensureDocumentColumn(ctx, db); err != nil {
return err
}
return ensureTurnIDColumn(ctx, db)
}
// ensureDocumentColumn 在 chat_messages 早于本列(0015 迁移)建表时把它前滚:
+9 -8
View File
@@ -167,9 +167,9 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger, opts ...StoreOp
}
log = logger.WithSource(discardLogger(log), "history")
// 顺序不能反:createMessagesTable 对全新库已含 client_msg_id,随后
// ensureMessageColumns 把早于这些列(client_msg_id 与 document)建出的既有表
// 前滚(幂等),最后才建索引 —— 部分唯一索引引用 client_msg_id,列还不存在时
// 建它必然失败。
// ensureMessageColumns 把早于这些列(client_msg_id / document / turn_id)建出的
// 既有表前滚(幂等),最后才建索引 —— 部分唯一索引引用 client_msg_id,列还不
// 存在时建它必然失败。
for _, ddl := range []string{
createSessionsTable,
createMessagesTable,
@@ -244,8 +244,8 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
s.ensureTitle(ctx, msg.SessionID, msg.Content)
}
query := `INSERT INTO chat_messages
(id, session_id, role, content, mode, mood, image, document, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
(id, session_id, role, content, mode, mood, image, document, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, turn_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
if msg.ClientMsgID != "" {
query += ` ON CONFLICT DO NOTHING`
}
@@ -257,7 +257,7 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
res, err := tx.ExecContext(ctx, query,
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood, msg.Image,
msg.Document, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID,
msg.PersonaID, msg.ClientMsgID, msg.CreatedAt)
msg.PersonaID, msg.ClientMsgID, msg.TurnID, msg.CreatedAt)
if err != nil {
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
}
@@ -347,13 +347,13 @@ func (s *Store) ensureMainSession(ctx context.Context) error {
// **唯一**一份列清单(位置顺序必须与 scanMessage 的 Scan 完全一致)。
// 它刻意只写一次:此前七个查询各抄一份,加一列就得改七处,漏一处就只会在
// 运行时以「扫描到的列数与列不清单不符」炸掉;共用常量让漏改成为不可能。
const messageColumns = `id, session_id, role, content, mode, mood, image, document, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, created_at`
const messageColumns = `id, session_id, role, content, mode, mood, image, document, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, turn_id, created_at`
// scanMessage 读取一行 chat_messages。消息各列按位置扫描,顺序见 messageColumns。
func scanMessage(sc rowScanner) (Message, error) {
var m Message
var mode, mood, platform, deviceID, senderName, adapterID string
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &m.Image, &m.Document, &m.Thinking, &platform, &deviceID, &senderName, &adapterID, &m.PersonaID, &m.ClientMsgID, &m.CreatedAt); err != nil {
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &m.Image, &m.Document, &m.Thinking, &platform, &deviceID, &senderName, &adapterID, &m.PersonaID, &m.ClientMsgID, &m.TurnID, &m.CreatedAt); err != nil {
return Message{}, err
}
m.Mode, m.Mood, m.Platform, m.DeviceID, m.SenderName, m.AdapterID = mode, mood, platform, deviceID, senderName, adapterID
@@ -420,6 +420,7 @@ CREATE TABLE IF NOT EXISTS chat_messages (
adapter_id TEXT NOT NULL DEFAULT '',
persona_id TEXT NOT NULL DEFAULT '',
client_msg_id TEXT NOT NULL DEFAULT '',
turn_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)`
+80
View File
@@ -0,0 +1,80 @@
package history
import (
"context"
"database/sql"
"fmt"
"strings"
)
// 本文件归属聊天消息的**回合标识**列(design/15 §2.5.9):一条消息属于哪一路回合。
//
// 它与会话编排器分摊同一件事:编排器是那个**生成**权威 turnId 的地方(每个在飞的
// 路一份,挂进 loop.WithTurnID 并盖在该路全部流帧上),存储只是把它落进行里,使
// 「回复 ↔ 提问」的配对在**重连 / 回放**时依然成立 —— 流帧是易失的,历史行不是。
//
// 写入是**两段式**,与 document 列完全同一手法:
// - 消息落库时它所属的路已经存在(编排器新起的一路、以及该路的助手回复)⇒ 随
// INSERT 一起写入(Message.TurnID),因此 `message_persisted` 那一帧也带着它;
// - 排队(忙时入队)或**被转向(steer)吃进**的消息在落库那一刻**还没有自己的
// 路** —— 用户消息在入队时就落库,而它由哪一路回答要等那一轮才定。这种行的
// turn_id 只能在它**实际被处理的那一路**起跑 / 消费时补写,即本文件的
// SetMessagesTurnID。
//
// 空串(列的默认值)意味着「这一行不属于任何已知的回合」:加入该列之前的历史行、
// 未经编排器的写入路径(IM 镜像)都是这种情形,读起来与以往逐字相同。
// SetMessagesTurnID 把 ids 所指的消息行的 turn_id 落定为 turnID —— 两段式写入的
// 第二段(起跑 / 被 steer 消费时补写)。
//
// 三条纪律:
// - **先到先得**:只写给仍然是空串的行(`AND turn_id = ”`)。一行只属于一路,
// 后来者绝不改写已经带标识的行 —— 否则「被哪一路回答」会随结算顺序漂移;
// - **幂等**:重复补写同一条(重启、重试、同一批消息被两处补写)是空操作;
// - **参数化**:id 列表按占位符展开,绝不把值拼进 SQL。
//
// 空 ids 或空 turnID 是空操作:调用方在「这一路没有落库任何用户消息」或「这一路
// 没有回合标识」(未接编排器的旧路径)时不必先判断。未知 id 被忽略而不是报错 ——
// 第二段的常态是「刚落的行」,一行找不到只可能是它已被删除,那不是本次写入的失败。
func (s *Store) SetMessagesTurnID(ctx context.Context, ids []string, turnID string) error {
unique := make([]string, 0, len(ids))
seen := make(map[string]bool, len(ids))
for _, id := range ids {
if id == "" || seen[id] {
continue
}
seen[id] = true
unique = append(unique, id)
}
if len(unique) == 0 || turnID == "" {
return nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(unique)), ", ")
args := make([]any, 0, len(unique)+1)
args = append(args, turnID)
for _, id := range unique {
args = append(args, id)
}
query := `UPDATE chat_messages SET turn_id = ? WHERE turn_id = '' AND id IN (` + placeholders + `)`
if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
return fmt.Errorf("history: set turn id for %d messages: %w", len(unique), err)
}
return nil
}
// ensureTurnIDColumn 在 chat_messages 早于本列(0018 迁移)建表时把它前滚:缺列则
// 补加,已有则忽略重复列错误。它是幂等的,因此 NewStore 可在全新 schema 或既有
// schema 上调用(与 ensureClientMsgIDColumn / ensureDocumentColumn 同一手法)。
func ensureTurnIDColumn(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `ALTER TABLE chat_messages
ADD COLUMN turn_id TEXT NOT NULL DEFAULT ''`)
if err == nil {
return nil
}
// 列已存在时(由 createMessagesTable 建出,或上一次运行补过)SQLite 报
// "duplicate column name: turn_id"。
if strings.Contains(err.Error(), "duplicate column name") {
return nil
}
return fmt.Errorf("history: roll forward turn_id column: %w", err)
}
+242
View File
@@ -0,0 +1,242 @@
package history
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// 本文件锁定聊天消息的 **turn_id** 列(design/15 §2.5.9):它回答「这一行属于哪
// 一路回合」,使客户端在重连 / 回放时仍能精确配对「回复 ↔ 提问」。
//
// 三条语义被逐条钉住:
// - 落库时已经知道所属路的消息(新起的一路 + 它的助手回复)随 INSERT 写入,
// 同一路两条**同值**、两路**不同值**;
// - 落库时还不知道所属路的消息(忙时入队 / 被 steer 吃进)留空串,由它实际被
// 处理的那一路**补写**(SetMessagesTurnID),且**先到先得**(已带标识的行不被
// 改写 —— 一行只属于一路);
// - 省略标识与加入本列之前逐字相同:空串默认值、读路径照常工作。
func TestAppendFullRoundTripsTurnID(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newStore(t)
user, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "先问你一句", TurnID: "turn-leg1", CreatedAt: 1000})
if err != nil {
t.Fatalf("append user: %v", err)
}
reply, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "答你一句", TurnID: "turn-leg1", CreatedAt: 2000})
if err != nil {
t.Fatalf("append assistant: %v", err)
}
// 第二路(并行)带自己的标识与自己的两条消息。
if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "另一路的问题", TurnID: "turn-leg2", CreatedAt: 3000}); err != nil {
t.Fatalf("append parallel user: %v", err)
}
if user.TurnID != "turn-leg1" || reply.TurnID != "turn-leg1" {
t.Fatalf("写入返回值 = %q / %q, want turn-leg1(同一路两条同值)", user.TurnID, reply.TurnID)
}
// 读路径(共用 messageColumns/scanMessage)必须把标识带出来。
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
got := make(map[string]string, len(msgs))
for _, m := range msgs {
got[m.Content] = m.TurnID
}
if got["先问你一句"] != "turn-leg1" || got["答你一句"] != "turn-leg1" {
t.Fatalf("同一路两条 = %q / %q, want 都是 turn-leg1", got["先问你一句"], got["答你一句"])
}
if got["另一路的问题"] != "turn-leg2" {
t.Fatalf("另一路 = %q, want turn-leg2(两路绝不同值)", got["另一路的问题"])
}
// 其余读面同源(它们共用同一份列清单,这里抽验两条最容易漏改的)。
recent, err := st.Recent(ctx, MainSessionID, 1)
if err != nil {
t.Fatalf("recent: %v", err)
}
if len(recent) != 1 || recent[0].TurnID != "turn-leg2" {
t.Fatalf("recent = %+v, want 最新那条带 turn-leg2", recent)
}
hits, err := st.Search(ctx, MainSessionID, "先问你一句", 0, 10)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(hits) != 1 || hits[0].TurnID != "turn-leg1" {
t.Fatalf("search = %+v, want 命中那条带 turn-leg1", hits)
}
found, err := st.FindByClientMsgID(ctx, MainSessionID, "")
if err == nil {
t.Fatalf("FindByClientMsgID(空键) = %+v, want ErrMessageNotFound", found)
}
}
func TestAppendWithoutTurnIDLeavesItEmpty(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newStore(t)
// 省略标识(老路径 / IM 镜像 / 排队入队)与加入本列之前逐字相同:空串。
if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "没有回合标识", CreatedAt: 1000}); err != nil {
t.Fatalf("append: %v", err)
}
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 1 || msgs[0].TurnID != "" {
t.Fatalf("msgs = %+v, want 空 turn_id(省略 ⇒ 不属于任何已知回合)", msgs)
}
}
func TestSetMessagesTurnIDAssignsUnassignedOnly(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newStore(t)
// 两行在落库时都还没有所属的路(忙时入队 / 被 steer 吃进的那种行)。
queued, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "排队的一句", CreatedAt: 1000})
if err != nil {
t.Fatalf("append queued: %v", err)
}
steered, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "被转向吃进的一句", CreatedAt: 1500})
if err != nil {
t.Fatalf("append steered: %v", err)
}
// 第二段:由**实际处理它们的那一路**补写。
if err := st.SetMessagesTurnID(ctx, []string{queued.ID, steered.ID}, "turn-actual"); err != nil {
t.Fatalf("set turn id: %v", err)
}
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
for _, m := range msgs {
if m.TurnID != "turn-actual" {
t.Fatalf("消息 %q 的 turn_id = %q, want turn-actual(按实际处理它的那一路写)", m.Content, m.TurnID)
}
}
// 先到先得:已经带上标识的行绝不被后来者改写(一行只属于一路)。
if err := st.SetMessagesTurnID(ctx, []string{queued.ID}, "turn-later"); err != nil {
t.Fatalf("set turn id again: %v", err)
}
msgs, err = st.List(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list after re-set: %v", err)
}
for _, m := range msgs {
if m.TurnID != "turn-actual" {
t.Fatalf("消息 %q 的 turn_id 被改写成 %q, want 仍是 turn-actual", m.Content, m.TurnID)
}
}
// 未知 id 被忽略而不是报错:第二段的常态是「刚落的行」,找不到只可能是它已被删除。
if err := st.SetMessagesTurnID(ctx, []string{"不存在的 id"}, "turn-actual"); err != nil {
t.Fatalf("unknown id: %v", err)
}
}
func TestSetMessagesTurnIDNoopInputs(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newStore(t)
row, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "保持空", CreatedAt: 1000})
if err != nil {
t.Fatalf("append: %v", err)
}
// 空 id 列表、空标识、只含空串的列表都是空操作(调用方不必先判断)。
cases := []struct {
name string
ids []string
turnID string
}{
{name: "no ids", ids: nil, turnID: "turn-x"},
{name: "no turn id", ids: []string{row.ID}, turnID: ""},
{name: "blank ids", ids: []string{"", ""}, turnID: "turn-x"},
}
for _, tc := range cases {
if err := st.SetMessagesTurnID(ctx, tc.ids, tc.turnID); err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
}
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 1 || msgs[0].TurnID != "" {
t.Fatalf("msgs = %+v, want 空 turn_id(空输入不写任何东西)", msgs)
}
// 重复 id 只算一次,且整批落到同一个标识上。
if err := st.SetMessagesTurnID(ctx, []string{row.ID, row.ID, "", row.ID}, "turn-dup"); err != nil {
t.Fatalf("duplicate ids: %v", err)
}
msgs, err = st.List(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list after dup: %v", err)
}
if msgs[0].TurnID != "turn-dup" {
t.Fatalf("turn_id = %q, want turn-dup", msgs[0].TurnID)
}
}
// TestNewStoreRollsForwardTurnIDColumn 验证早于 0018 建出的 chat_messages 表会被
// NewStore 幂等前滚出 turn_id 列(与 client_msg_id / document 同一手法):全新库由
// 建表 DDL 一次建全,既有库靠补列,两条路都必须能写能读带标识的消息。
func TestNewStoreRollsForwardTurnIDColumn(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", testLogger())
if err != nil {
t.Fatalf("open memory db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
// 0018 之前的形状:有 document(0015)但没有 turn_id。
const legacyTable = `
CREATE TABLE chat_messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
mode TEXT,
mood TEXT,
image TEXT,
document TEXT NOT NULL DEFAULT '',
thinking TEXT,
platform TEXT NOT NULL DEFAULT 'web',
device_id TEXT NOT NULL DEFAULT '',
sender_name TEXT NOT NULL DEFAULT '',
adapter_id TEXT NOT NULL DEFAULT '',
persona_id TEXT NOT NULL DEFAULT '',
client_msg_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)`
if _, err := conn.ExecContext(ctx, legacyTable); err != nil {
t.Fatalf("create legacy table: %v", err)
}
st, err := NewStore(ctx, conn, testLogger())
if err != nil {
t.Fatalf("new store: %v", err)
}
if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "前滚之后", TurnID: "turn-rolled", CreatedAt: 2000}); err != nil {
t.Fatalf("append after roll forward: %v", err)
}
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 1 || msgs[0].TurnID != "turn-rolled" {
t.Fatalf("msgs = %+v, want turn-rolled 往返", msgs)
}
}
+15 -1
View File
@@ -62,7 +62,21 @@ type Message struct {
// 「这条消息没有幂等键」(老客户端、IM 镜像、历史行),行为与加入该列之前
// 逐字相同。
ClientMsgID string
CreatedAt int64
// TurnID 是这条消息所属的那一**路**回合的权威标识(design/15 §2.5.9):
// 编排器生成,与该路全部流帧 / `turn_begin` 帧壳上的 turnId **同值**。因此
// 同一路的 user 行与 assistant 行带同一个值 —— 客户端据此精确配对「回复 ↔
// 提问」,并在重连 / 回放时从历史行恢复回复侧的分组小标(真并行下同一会话
// 有两个交错的回答,只按顺序渲染会配错)。
//
// 它可选:空串意味着「这一行不属于任何已知的回合」—— 加入该列之前的行、以及
// 未经编排器的写入路径(IM 镜像等)都是这种情形。
//
// 两个写入时点(与 Document 同一手法,见 turn_id.go):① 消息落库时它所属的
// 路已经存在(新起的一路、以及助手回复)⇒ 随 INSERT 写入;② 排队 / 被转向
// 吃进的消息在落库那一刻还没有自己的路 ⇒ 由**它实际被处理的那一路**补写同一
// 列(先到先得)。
TurnID string
CreatedAt int64
}
// SessionMeta 是一个持久化会话面向线上的元数据。