Files
AskaEth d64b26d499
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(chat): chat_messages.turn_id 列 + DTO(写入接线留待下一步)
迁移 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。
2026-10-05 09:46:10 +08:00

407 lines
20 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
import (
"context"
"errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件拥有编排器的聊天历史持久化钩子(design/16
// §3.3):用户消息流在提交时写入,助手回复在
// 回合结算时写入。未接线 history 存储时二者都是静默空操作,
// 因此编排器无存储也能原样运行(测试 / 未接线的网关)。
// modeChat 是回合未携带行为模式时所持久化的模式(design/01 §3,
// design/16 §5.1)。它也是 IM 路径唯一会写入的模式,因为 IM
// 只携带聊天文本。
const modeChat = "chat"
// modeOrDefault 返回 mode,调用方未提供时返回 modeChat,使
// 持久化消息绝不携带空的行为模式(design/16 §5.1)。
func modeOrDefault(mode string) string {
if mode == "" {
return modeChat
}
return mode
}
// messageModeContextKey 是携带回合行为模式的 context 键
// (design/01 §3、design/16 §5.1),从 HTTP/WS 边界引入编排器的
// 持久化,使每条持久化消息被它发送时的模式标注。
type messageModeContextKey struct{}
// withMode 返回携带 mode 的 context,使编排器能标注
// 持久化的用户消息与助手回复。空 mode 是空操作
// (持久化层回退到 modeChat)。
func withMode(ctx context.Context, mode string) context.Context {
return context.WithValue(ctx, messageModeContextKey{}, mode)
}
// modeFrom 返回 ctx 携带的行为模式,未设置时返回 ""。
func modeFrom(ctx context.Context) string {
v, _ := ctx.Value(messageModeContextKey{}).(string)
return v
}
// persistUserMessages 把一次已提交回合中每条 RoleUser 消息写入
// 历史存储,使原始用户消息流在回合结算前就持久,随后
// 通知传输层,使同一账号的每个浏览器都能追加它
// 而无需手动刷新(design/16 §3.3、§4.2)。deviceID 用发送客户端标注
// 消息(design/16 §4.5),mode 是该回合发送时的行为模式
// (design/16 §5.1),为空时回退到 modeChat,而 personaID 是
// 该回合说话时的角色(design/22 §7-5)—— 即当时的角色,因此
// 之后切换角色的会话仍保有准确的「谁说了这个」流。未接线
// history 存储时它是空操作。被拒绝的回合(空、待处理已满)
// 绝不触达它,因此不会持久化任何空内容。
//
// ctx 上若带着客户端的幂等键(G4,clientMsgHintFrom),第一条用户消息会带上它
// 与编排器预分配的消息 id:写入因此可被 (session_id, client_msg_id) 的唯一索引
// 判重。省略幂等键时 ctx 上没有该值,写入与加入这一列之前逐字相同。
//
// ctx 上若带着本路的回合标识(design/15 §2.5.9,withTurnID),该标识随**写入**
// 落进 turn_id 列 —— 这正是「这条路已经把这条消息吃进去了」要留下的配对信息。
// 没有它(排队入队、未接线的路径)时列里留空串,由它实际被处理的那一路随后补写
// (见 assignTurnID 与 internal/runtime/history/turn_id.go)。
//
// 它返回刚落库的那些消息(含存储分配的 id 与**只有引用**的 document 列),
// 使调用方能把它们挂到本回合上:抽取结论要等回合跑起来才有,只能在回合结束后
// 回写进同一列(design/24 §6.1,见 session_document_status.go)。这就是那条
// 「回合前落引用、回合后补状态」的两段式。
func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string, messages []model.Message, deviceID, mode, personaID string) []history.Message {
if o.history == nil {
return nil
}
hint := clientMsgHintFrom(ctx)
turnID := turnIDFrom(ctx)
persisted := make([]history.Message, 0, len(messages))
for _, m := range messages {
if m.Role != model.RoleUser {
continue
}
text, image, documents, err := messageTextImageDocument(m)
if err != nil {
// 文档引用编码失败(只可能是坏数据或超限)只丢那一列并留下带来源的
// 日志,整条消息照常落库 —— 丢一条消息远比丢一个附件引用严重,而且
// 这里绝不静默。
o.log.Error("orchestrator persist document", "err", err, "session", sessionID, "device_id", deviceID)
}
if text == "" && image == "" && documents == "" {
continue
}
msg := history.Message{SessionID: sessionID, Role: history.RoleUser, Content: text, Image: image, Document: documents, DeviceID: deviceID, Mode: modeOrDefault(mode), PersonaID: personaID, TurnID: turnID}
// 幂等键只标注**第一条**用户消息:一个回合至多只有一条用户消息,而把
// 同一个键盖到多条上会让它们互相冲突(唯一索引把它们判成同一条)。
if hint.ClientMsgID != "" {
msg.ClientMsgID, msg.ID = hint.ClientMsgID, hint.MessageID
hint = clientMsgHint{}
}
full, err := o.history.AppendFull(ctx, msg)
if err != nil {
o.log.Error("orchestrator persist user", "err", err, "session", sessionID, "device_id", deviceID)
// 落库失败就撤销认领:否则客户端拿同一个 clientMsgId 重试时会被判成
// 「重复到达」而既不落库也不起回合 —— 这条消息从此再也进不来。
if msg.ClientMsgID != "" {
o.releaseClientMsg(sessionID, msg.ClientMsgID, msg.ID)
}
continue
}
persisted = append(persisted, full)
if msg.ClientMsgID != "" && full.ID != msg.ID {
// 存储侧唯一索引裁决出既有行(例如本进程重启后另一次写入已落库):
// 把权威 id 覆盖进认领表,别让后续重复请求拿到一个悬空的 messageId。
o.rememberClientMsg(sessionID, msg.ClientMsgID, full.ID)
}
o.notifyPersisted(full)
}
return persisted
}
// persistAssistantReply 把已结算回合的最终助手文本写入
// 历史存储,然后通知传输层(design/16 §3.3、§4.2)。deviceID 用
// 发送该回合的客户端标注回复(design/16 §4.5),mode 是
// 该回合的行为模式(design/16 §5.1),为空时回退到 modeChat,
// 而 personaID 是产出该回复的角色(design/22 §7-5)。Mood
// 是该回合中人格的 mood(design/01 §2 情绪状态机),取自 loop
// 结果,因此已存/DTO/`message_persisted` 的 mood 是真实数据,而不是
// 永远为空的列;不跟踪 mood 的回合存一个空 mood。它
// 在未接线 history 存储,或该回合没有产出文本
// 时是空操作(被中断/出错的回合保持 res 为 nil)。该写入在一个全新的
// 后台 context 下运行,因此被取消的回合 context 绝不可能中断它。
//
// 它是**裸写入**,判定属于 persistTurnReply(session_turn_reply.go):收尾路径
// 经后者进入,由它保证「归本代次、且不是被取消的回合一定有一条可见消息」——
// 空文本在这里仍然是空操作,因此本函数绝不自作主张编造内容。
//
// turnID 是本路的回合标识(design/15 §2.5.9),可选:给出即随**写入**落进 turn_id
// 列,与它那条用户消息同值,客户端因此能精确配对「回复 ↔ 提问」。省略(未接线的
// 调用路径)等价于加入该列之前的行为(列里留空串)。它刻意是可变参数而不是必填
// 形参:既有调用点、以及「这一路没有回合标识」的旧路径都不必先传一个空值,而
// 已给出的第一个值之外的多余值**被忽略**(一个回合只有一份标识,多给是调用方的
// 笔误,绝不据此分叉行为)。
func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result, deviceID, mode, personaID string, turnID ...string) {
if o.history == nil || res == nil || res.Text == "" {
return
}
full, err := o.history.AppendFull(context.Background(), history.Message{SessionID: sessionID, Role: history.RoleAssistant, Content: res.Text, Mood: res.Mood, DeviceID: deviceID, Mode: modeOrDefault(mode), PersonaID: personaID, TurnID: firstTurnID(turnID)})
if err != nil {
o.log.Error("orchestrator persist assistant", "err", err, "session", sessionID, "device_id", deviceID)
return
}
o.notifyPersisted(full)
}
// messageTextImageDocument 返回消息内容部分拼接后的文本、首个图片 URL 与**全部**
// 文档引用(已编码为落库用的 JSON 数组),供持久化进 chat_messages 各列。
//
// 文档那一半只落**引用与统计**(design/24 §2.4):装配期已经把抽取所得的正文
// 替换成文本部件,因此这里绝不把正文写进 document 列 —— 正文一旦落库,每翻
// 一页历史都要背着它重传(G3 的教训)。抽取统计(chars/truncated/status)属于
// 装配期与抽取器,模型层只携带引用本身,它们由回合结束后的回写补进同一列
// (见 session_document_status.go)。
//
// 一个回合可以挂多个附件(design/24 §6.1 的上限 5),因此这里按**部件顺序**收集
// 全部引用:此前「只留第一个」的写法会让其余几个从历史与后续回合的上下文里凭空
// 消失(缺口①)。编码失败(含超限)时返回错误,由调用方决定如何可见地报告,
// 而不是在这里静默当成「没有附件」。
func messageTextImageDocument(m model.Message) (text, image, documents string, err error) {
refs := make([]history.DocumentRef, 0, len(m.Content))
for _, c := range m.Content {
switch c.Type {
case model.ContentTypeText:
text += c.Text
case model.ContentTypeImage:
if image == "" {
image = c.ImageURL
}
case model.ContentTypeDocument:
refs = append(refs, documentRefOf(c))
}
}
encoded, err := history.EncodeDocuments(refs)
if err != nil {
return text, image, "", err
}
return text, image, encoded, nil
}
// documentRefOf 把文档内容片段收成落库用的引用。四个字段全部来自服务端
// (存储名、嗅探所得 MIME、本地引用、字节数),客户端的原始文件名与声明
// Content-Type 都不在其中(design/24 §2.2/§3.2)。
func documentRefOf(c model.Content) history.DocumentRef {
return history.DocumentRef{Name: c.DocumentName, MIME: c.DocumentMIME, Size: c.DocumentSize}
}
// messageTextImage 是 messageTextImageDocument 的窄视图:只要文本与首个图片
// URL。session_steer 的转写行配对只按这两项匹配,文档引用不参与;它也就
// 刻意不浮现文档编码的错误(那属于持久化路径的关切)。
func messageTextImage(m model.Message) (text, image string) {
text, image, _, _ = messageTextImageDocument(m)
return text, image
}
// --- G4 客户端幂等键(clientMsgId)-------------------------------------------
//
// 全链路此前没有幂等:客户端超时重发同一段文本就是两条用户消息、两个回合。
// 这一节给编排器加判重,与存储侧的 (session_id, client_msg_id) 部分唯一索引
// 配合:编排器决定「这一轮还要不要跑」,存储决定「这一行还要不要写」。
//
// 两层职责刻意分开:内存认领表(sessionState.clientMsgs)让**同进程并发**只有一个
// 赢家(这是唯一索引覆盖不到的窗口 —— 两次重发都还没插入),唯一索引则让判重
// 跨重启依然成立(单二进制单进程,不存在跨进程并发)。
// maxClientMsgsPerSession 限制每会话保留在内存里的认领条数。淘汰最旧条目只会
// 让一次很晚到达的重复多问一次存储,绝不会让重复被放行 —— 权威在存储。
const maxClientMsgsPerSession = 64
// clientMsgHint 是一条幂等提示(G4)。ClientMsgID 是客户端自带的去重键
// (空 = 老客户端,行为逐字不变);MessageID 是编排器为这条消息**预分配**的
// id,使并发的重复请求在第一条真正落库之前就能拿到一个确定(且终将存在)的
// messageId 去对齐本地乐观气泡。
type clientMsgHint struct {
ClientMsgID string
MessageID string
}
// clientMsgHintKey 是携带 clientMsgHint 的 context 键。它从 HTTP/WS 边界
// (REST 请求体 / ws chat 帧的 clientMsgId)引入编排器,与 deviceID、mode
// 走同一种机制。
type clientMsgHintKey struct{}
// withClientMsgHint 返回携带该提示的 context。空 ClientMsgID 是空操作:省略
// clientMsgId 的请求在 ctx 上不留任何痕迹。
func withClientMsgHint(ctx context.Context, h clientMsgHint) context.Context {
if h.ClientMsgID == "" {
return ctx
}
return context.WithValue(ctx, clientMsgHintKey{}, h)
}
// clientMsgHintFrom 返回 ctx 携带的幂等提示,未设置时返回零值。
func clientMsgHintFrom(ctx context.Context) clientMsgHint {
h, _ := ctx.Value(clientMsgHintKey{}).(clientMsgHint)
return h
}
// clientMsgIDFrom 返回 ctx 携带的客户端幂等键,未设置时为 ""。
func clientMsgIDFrom(ctx context.Context) string {
return clientMsgHintFrom(ctx).ClientMsgID
}
// clientMsgIDReader 是 HistoryStore 的一个**可选**更窄视图(与 persona_env.go
// 的 sessionPersonaReader 同一做法):按 (会话, clientMsgId) 定点读一条既有
// 消息,供进程重启后的判重(内存认领表已随进程消失)。未实现该视图的存储替身
// 或后端退回「内存认领表」判定,不因此报错,也无需为此实现新方法。
type clientMsgIDReader interface {
FindByClientMsgID(ctx context.Context, sessionID, clientMsgID string) (history.Message, error)
}
// admit 是一次提交能否进入会话状态机的判定结果(design/15 §4 的队列容量 +
// G4 的幂等判重)。两种为真的情形都意味着「不落库、不起回合」,但它们对客户端
// 的含义相反:full 是「稍后重试」(队列满),dup 是「不必重试」(消息早已在库)。
type admit struct {
// full 报告该会话的待处理队列已满。
full bool
// dup 报告这次提交的 clientMsgId 已经到达过。
dup bool
// mode 是判定时的会话忙碌通道,供 PendingFull 事件如实报告(在锁内取值,
// 解锁后再广播)。
mode Mode
// hint 是本次的幂等提示:dup 为真时指向既有/已认领的消息,否则携带本次
// 落库要用的预分配 id(省略 clientMsgId 时是零值)。
hint clientMsgHint
}
// admitLocked 在状态锁下判定一次提交能否进入状态机,并在可以进入时完成 G4 的
// 认领。它必须在 o.mu 下调用,且**不**在锁内广播任何状态事件 —— 调用方解锁后
// 再发(与既有 PendingFull 路径同一纪律)。
//
// 顺序是刻意的:**队列满的拒绝排在认领之前**。被拒的回合没有落库,若在拒绝前
// 认领了 clientMsgId,客户端用同一个键重试时会被判成「重复到达」,这条消息就
// 再也进不来了 —— 那正是 G2 那类静默丢失的翻版。
func (o *Orchestrator) admitLocked(ctx context.Context, st *sessionState, sessionID string) admit {
if st.mode != ModeIdle && len(st.pending) >= o.maxPending {
return admit{full: true, mode: st.mode}
}
hint, dup := o.claimClientMsgLocked(ctx, st, sessionID, clientMsgIDFrom(ctx))
return admit{dup: dup, mode: st.mode, hint: hint}
}
// claimClientMsgLocked 在状态锁下判重并认领一个 clientMsgId(G4)。duplicate 为
// true 表示这条消息此前已经到达(hint.MessageID 指向既有/已认领的消息),调用方
// 必须原地返回:既不落库、也不启动回合。duplicate 为 false 时 hint.MessageID 是
// 本次落库要用的预分配 id(省略 clientMsgId 时 hint 与 duplicate 都是零值)。
//
// 必须在 o.mu 下调用:认领与状态读取必须是同一个临界区,否则两个并发的重复请求
// 会双双认领成功、各起一个回合(这正是「先查再插」的经典竞态)。存储查询也是有界
// 的定点读(部分唯一索引至多命中一行),与 startTurnMessages 的转录读取同一量级,
// 因此不违反「状态锁不持有慢操作」。
func (o *Orchestrator) claimClientMsgLocked(ctx context.Context, st *sessionState, sessionID, clientMsgID string) (clientMsgHint, bool) {
if clientMsgID == "" {
return clientMsgHint{}, false
}
if id, ok := st.clientMsgs[clientMsgID]; ok {
return clientMsgHint{ClientMsgID: clientMsgID, MessageID: id}, true
}
if id, ok := o.findPersistedClientMsg(ctx, sessionID, clientMsgID); ok {
o.rememberClientMsgLocked(st, clientMsgID, id)
return clientMsgHint{ClientMsgID: clientMsgID, MessageID: id}, true
}
id, err := history.NewMessageID()
if err != nil {
// 生成失败就退回「不判重」:宁可多跑一轮重复,也不把这条消息挡在门外
// —— 挡在门外就是静默丢失,正是本次要消灭的东西。
o.log.Error("orchestrator client msg id", "err", err, "session", sessionID)
return clientMsgHint{}, false
}
o.rememberClientMsgLocked(st, clientMsgID, id)
return clientMsgHint{ClientMsgID: clientMsgID, MessageID: id}, false
}
// findPersistedClientMsg 报告该会话是否已有携带该 clientMsgId 的消息,是时返回
// 其消息 id。存储未接、未实现该可选视图、或查询失败时一律报「没有」并记日志:
// 判重失败的方向必须是**放行**(多一条重复),绝不是拒绝(丢一条消息)。
func (o *Orchestrator) findPersistedClientMsg(ctx context.Context, sessionID, clientMsgID string) (string, bool) {
reader, ok := o.history.(clientMsgIDReader)
if !ok {
return "", false
}
msg, err := reader.FindByClientMsgID(ctx, sessionID, clientMsgID)
if err != nil {
if !errors.Is(err, history.ErrMessageNotFound) {
o.log.Error("orchestrator client msg lookup", "err", err, "session", sessionID)
}
return "", false
}
return msg.ID, msg.ID != ""
}
// rememberClientMsgLocked 认领一个 clientMsgId(必须在 o.mu 下调用),并按
// maxClientMsgsPerSession 有界淘汰最旧的条目。
func (o *Orchestrator) rememberClientMsgLocked(st *sessionState, clientMsgID, msgID string) {
if st.clientMsgs == nil {
st.clientMsgs = make(map[string]string, maxClientMsgsPerSession)
}
if _, ok := st.clientMsgs[clientMsgID]; !ok {
st.clientMsgOrder = append(st.clientMsgOrder, clientMsgID)
if len(st.clientMsgOrder) > maxClientMsgsPerSession {
oldest := st.clientMsgOrder[0]
st.clientMsgOrder = st.clientMsgOrder[1:]
delete(st.clientMsgs, oldest)
}
}
st.clientMsgs[clientMsgID] = msgID
}
// rememberClientMsg 是 rememberClientMsgLocked 的加锁包装,供锁外的持久化路径
// 在拿到存储裁决出的权威 id 后修正认领(见 persistUserMessages)。会话状态不
// 存在时它是空操作(那意味着本次提交没有走过状态机,没有认领需要修正)。
func (o *Orchestrator) rememberClientMsg(sessionID, clientMsgID, msgID string) {
if clientMsgID == "" || msgID == "" {
return
}
o.mu.Lock()
defer o.mu.Unlock()
if st := o.states[sessionID]; st != nil {
o.rememberClientMsgLocked(st, clientMsgID, msgID)
}
}
// releaseClientMsg 撤销一次认领(落库失败时调用),使同一个 clientMsgId 的
// 下一次重试仍然能落库,而不是被判重挡在门外、消息从此消失。它只撤销仍然属于
// msgID 的那一条认领,不碰后来的认领者。
func (o *Orchestrator) releaseClientMsg(sessionID, clientMsgID, msgID string) {
o.mu.Lock()
defer o.mu.Unlock()
st := o.states[sessionID]
if st == nil || st.clientMsgs[clientMsgID] != msgID {
return
}
delete(st.clientMsgs, clientMsgID)
for i, id := range st.clientMsgOrder {
if id == clientMsgID {
st.clientMsgOrder = append(st.clientMsgOrder[:i], st.clientMsgOrder[i+1:]...)
return
}
}
}
// deviceIDContextKey 是携带消息客户端 deviceID 的 context 键
// (design/16 §4.5)从 HTTP/WS 边界引入编排器的持久化。
type deviceIDContextKey struct{}
// withDeviceID 返回携带 deviceID 的 context,使编排器能标注
// 持久化的用户消息。空 deviceID 是空操作(不写入标签)。
func withDeviceID(ctx context.Context, deviceID string) context.Context {
return context.WithValue(ctx, deviceIDContextKey{}, deviceID)
}
// deviceIDFrom 返回 ctx 携带的 deviceID,未设置时返回 ""。它
// 只返回格式良好的非空值;其他一律视为缺失。
func deviceIDFrom(ctx context.Context) string {
v, _ := ctx.Value(deviceIDContextKey{}).(string)
return v
}