package gateway import "context" // 本文件归属协同项 G5 的「队列满的拒绝要说清是哪一条」:pending_full 状态帧的 // 载荷回填,以及发往**发送者连接**的那条定向拒绝帧。 // // 背景:pending_full 此前只带 {sessionId, mode, max},于是客户端收到「队列满」 // 时无法判定该收掉哪一条本地乐观气泡 —— 那条气泡会永远挂在「发送中…」(REST // 路径有 409 chat_busy 可以回滚,WS 路径没有等价物)。这里给出确定的键,并让 // 发送者收到一条能精确处理的定向帧。 // // 它与 ws 边界的关系:编排器不认识连接,只认识 ctx 上由传输层给出的「怎么把 // 拒绝送回发送者」回调(与 deviceID / mode / 幂等键同一种机制)。REST 路径不接 // 回调 —— 它的 409 chat_busy 承担同一职责。 // PendingFullNotifier 把「本次提交因待处理队列已满被拒」定向通知给发送者。 // 它只作用于发起这条消息的那根连接:广播的 pending_full 是状态信号,各端自行 // 按 clientMsgId 对齐,其它连接忽略即可。clientMsgID 与 messageID 都可能为 "" // (该键省略,绝不伪造)。 type PendingFullNotifier func(sessionID, clientMsgID, messageID string) // pendingFullNotifierKey 是携带定向拒绝回调的 context 键。它从 WS 帧边界 // (ws_route.go 的 submitChat / voice.go 的 runVoiceTurn)引入编排器。 type pendingFullNotifierKey struct{} // withPendingFullNotifier 返回携带定向拒绝回调的 context。nil 回调是空操作: // 未接线传输层(REST / 单元测试)的提交在 ctx 上不留任何痕迹。 func withPendingFullNotifier(ctx context.Context, n PendingFullNotifier) context.Context { if n == nil { return ctx } return context.WithValue(ctx, pendingFullNotifierKey{}, n) } // pendingFullNotifierFrom 返回 ctx 携带的定向拒绝回调,未设置时为 nil。 func pendingFullNotifierFrom(ctx context.Context) PendingFullNotifier { n, _ := ctx.Value(pendingFullNotifierKey{}).(PendingFullNotifier) return n } // emitPendingFull 广播 pending_full 状态帧,并在接线了发送者回调时定向回一条 // 拒绝帧。调用方必须已从状态锁退出(状态锁内不做广播,与既有纪律一致)。 func (o *Orchestrator) emitPendingFull(ctx context.Context, sessionID string, mode Mode, rejected clientMsgHint) { o.emitState(sessionID, EventPendingFull, pendingFullPayload(sessionID, mode, o.maxPending, rejected)) if n := pendingFullNotifierFrom(ctx); n != nil { n(sessionID, rejected.ClientMsgID, rejected.MessageID) } } // rejectedIdentityLocked 为队列满被拒的那条消息取「服务端能给出的身份」(G5): // clientMsgId 原样来自 ctx(省略时为空 ⇒ 该字段被省略);messageId 只在它**此前 // 已经落库或已认领**时给出。 // // 它绝不新铸 id:队列满的拒绝发生在认领之前,一旦在这里认领一个新 id,客户端用 // 同一个键重试就会被判成「重复到达」而永远进不来(见 admitLocked 的顺序说明)。 // 因此未落库时只报 clientMsgId。必须在 o.mu 下调用。 func (o *Orchestrator) rejectedIdentityLocked(ctx context.Context, st *sessionState, sessionID string) clientMsgHint { id := clientMsgIDFrom(ctx) if id == "" { return clientMsgHint{} } if msgID, ok := st.clientMsgs[id]; ok { return clientMsgHint{ClientMsgID: id, MessageID: msgID} } if msgID, ok := o.findPersistedClientMsg(ctx, sessionID, id); ok { o.rememberClientMsgLocked(st, id, msgID) return clientMsgHint{ClientMsgID: id, MessageID: msgID} } return clientMsgHint{ClientMsgID: id} } // pendingFullPayload 组装 pending_full 状态帧的载荷。既有三个键 // (sessionId / mode / max)**一个不改**,新增键是纯增量: // - clientMsgId:被拒那条消息的客户端幂等键(未携带时该键不出现); // - messageId:该键此前已落库/已认领时的消息 id(未落库时该键不出现)。 func pendingFullPayload(sessionID string, mode Mode, max int, rejected clientMsgHint) map[string]any { data := map[string]any{"sessionId": sessionID, "mode": mode, "max": max} if rejected.ClientMsgID != "" { data["clientMsgId"] = rejected.ClientMsgID } if rejected.MessageID != "" { data["messageId"] = rejected.MessageID } return data }