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`
This commit is contained in:
1 parent
da3a386daf
commit
7512d593c2
18 files changed
+1573
-216
No files matched your search
@@ -78,7 +78,12 @@ type historyMessage struct {
|
||||
// 气泡。为空时省略,而空意味着「全局默认角色」——
|
||||
// 多角色之前的行读起来与以往完全一致。
|
||||
PersonaID string `json:"personaId,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
// ClientMsgID 是客户端自带的幂等键(Android 协同 §G4),原样回填,
|
||||
// 因此客户端能把本地乐观气泡与服务端真相对齐(而不再靠「文本相等」
|
||||
// 这类脆弱匹配),也能在重连后从读接口收敛。它为空时省略,而空意味着
|
||||
// 「这条消息没有幂等键」(老客户端、IM 镜像、历史行)。
|
||||
ClientMsgID string `json:"clientMsgId,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// handleHistorySessions 先返回主会话,再返回每个子会话(最旧
|
||||
@@ -120,7 +125,7 @@ func toHistoryLastMessage(m *history.MessageSummary) *historyLastMessage {
|
||||
// 新会话可选绑定的角色(design/22 §4.5)。它是设计的
|
||||
// 优先级中「为新会话播种角色」的那一半:此处显式给出的角色
|
||||
// 成为会话级绑定(唯一事实来源),而省略它则
|
||||
// 保持 persona_id = '',使会话继承全局默认。
|
||||
// 保持 persona_id 为空串,使会话继承全局默认。
|
||||
type createSubSessionRequest struct {
|
||||
PersonaID string `json:"personaId"`
|
||||
}
|
||||
@@ -312,7 +317,7 @@ func (s *Server) handleChannelPlatformLog(w http.ResponseWriter, r *http.Request
|
||||
// handleChannelMessages 为管理适配器日志页返回至多 limit 条消息,
|
||||
// 最新优先,覆盖三种粒度之一(design/16 §4.6 §4):单个
|
||||
// 适配器配置(?adapter=kook:3bef...)、整个平台(?platform=kook),或
|
||||
// 全部 IM 适配器总览(二者都不给,即 adapter_id != '')。before/limit 复用
|
||||
// 全部 IM 适配器总览(二者都不给,即 adapter_id 非空)。before/limit 复用
|
||||
// 标准游标分页。主 Web 会话上的 owner 私有镜像在此
|
||||
// 不被排除,因为它携带 adapter_id;每种粒度直接按
|
||||
// adapter_id / platform 过滤。
|
||||
@@ -403,7 +408,11 @@ func toHistoryMessage(m history.Message) historyMessage {
|
||||
SenderName: m.SenderName,
|
||||
AdapterID: m.AdapterID,
|
||||
PersonaID: m.PersonaID,
|
||||
CreatedAt: m.CreatedAt,
|
||||
// ClientMsgID 一并回填:它正是客户端拿来做乐观气泡对齐的键
|
||||
// (协同 G4),读接口(GET /sessions/{id}/messages)与
|
||||
// `message_persisted` 帧因此给出同一个答案。
|
||||
ClientMsgID: m.ClientMsgID,
|
||||
CreatedAt: m.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user