迁移 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。
407 lines
20 KiB
Go
407 lines
20 KiB
Go
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
|
||
}
|