- G5:pending_full 增 clientMsgId/messageId(仅在可给出时出现、绝不新铸 id —— 队列满的拒绝发生在幂等键认领之前,代铸会让客户端重试被判重复而永远进不来);另定向回发送者 {type:error,data:{code:chat_busy,...}}(与 REST 409 chat_busy 同码),广播帧照旧带同样的键供其它端对齐。
- C1:语音回合改走与文本同一条路径(编排器 Submit → 落库 → 流式 → 跨端同步),deviceId 走既有 withDeviceID、platform 走存储默认 web;未接编排器时保留旧直连路径。音频在回合结算处(persistAssistantReply → persistDocumentStatus → dispatchVoiceReply)合成并定向回原连接,回调各自 goroutine(TTS 延迟不拖住 pending_drain/session_idle);续跑 FIFO 与转向注入两条路径都带走回调 ⇒ 忙时排队/被注入的语音也不丢音频。
- C3:voice_output 增 sessionId/deviceId;三处主动加码(按最优方向):① voice_input 增可选 clientMsgId(语音也有幂等)② 不再拿 stopReason 顶替空正文当语音内容(空正文走 provider 降级信号)③ TTS 失败照旧回帧 + provider:silent(不静默)。
- 拆 chat_documents_test.go(510 行越界)→ 373 + 160,只搬不改(拆前后同 -run 过滤 PASS 集合逐条一致)。
- 主会话独立验收:隔离 worktree(HEAD + 本批次 11 文件)build/vet 绿、gateway -race 绿、全树 59 包(唯一红是既有负载 flake orchestration/TestSpawnTimeoutFromToolDefault)、linux+darwin 交叉编译绿;变异独立复现(pending_full 摘掉 clientMsgId ⇒ 3 条红)。
- 待办(已登记):Docs/api-client.md 与 api-reference.md 的帧/字段登记随 v0.59 一并落笔(本批次刻意未动 Docs,避免与契约批次打架)。
179 lines
4.6 KiB
Go
179 lines
4.6 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gorilla/websocket"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
)
|
||
|
||
// wsConn 包装一个 WebSocket 连接。写入经缓冲的 send
|
||
// 通道串行化,由单个 writePump 排空,因此 gorilla conn
|
||
// 在任一时刻只被一个 goroutine 触碰。
|
||
type wsConn struct {
|
||
conn *websocket.Conn
|
||
send chan []byte
|
||
done chan struct{}
|
||
h *hub
|
||
claims *auth.Claims
|
||
once sync.Once
|
||
}
|
||
|
||
// handleWS 在校验来自查询 token 或 Authorization 头的 JWT 之后
|
||
// 升级 /ws/chat。升级失败会在握手前写出 401 {error:...},
|
||
// 使浏览器/IM 客户端看到干净的拒绝。
|
||
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "websocket requires GET"))
|
||
return
|
||
}
|
||
claims, err := s.auth.Verify(wsToken(r))
|
||
if err != nil {
|
||
s.log.Debug("ws auth", "err", err)
|
||
writeError(w, apperr.ErrUnauthorized)
|
||
return
|
||
}
|
||
conn, err := s.hub.upgrader.Upgrade(w, r, nil)
|
||
if err != nil {
|
||
s.log.Debug("ws upgrade", "err", err)
|
||
return
|
||
}
|
||
c := &wsConn{
|
||
conn: conn,
|
||
send: make(chan []byte, wsSendBuffer),
|
||
done: make(chan struct{}),
|
||
h: s.hub,
|
||
claims: claims,
|
||
}
|
||
s.hub.register(c)
|
||
s.hub.replayHistory(c, history.MainSessionID)
|
||
go c.writePump()
|
||
go c.readPump()
|
||
}
|
||
|
||
// readPump 读取客户端帧直到连接关闭,按类型路由每个帧。
|
||
func (c *wsConn) readPump() {
|
||
defer func() {
|
||
c.h.unregister(c)
|
||
c.shutdown()
|
||
}()
|
||
c.conn.SetReadLimit(wsReadLimit)
|
||
_ = c.conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||
c.conn.SetPongHandler(func(string) error {
|
||
return c.conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||
})
|
||
for {
|
||
_, msg, err := c.conn.ReadMessage()
|
||
if err != nil {
|
||
if websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||
c.h.log.Debug("ws read", "err", err)
|
||
}
|
||
return
|
||
}
|
||
var in wsInbound
|
||
if err := json.Unmarshal(msg, &in); err != nil {
|
||
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "invalid json"}})
|
||
continue
|
||
}
|
||
c.route(in)
|
||
}
|
||
}
|
||
|
||
// writePump 排空 send 通道(并发送 keepalive ping)直到
|
||
// 连接关闭。它是 gorilla conn 上唯一的写入者。
|
||
func (c *wsConn) writePump() {
|
||
ticker := time.NewTicker(wsPingPeriod)
|
||
defer func() {
|
||
ticker.Stop()
|
||
_ = c.conn.Close()
|
||
}()
|
||
for {
|
||
select {
|
||
case <-c.done:
|
||
return
|
||
case msg := <-c.send:
|
||
_ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||
return
|
||
}
|
||
case <-ticker.C:
|
||
_ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// publishPublic 发布一个 bus 事件,且不做任何按连接的回复。
|
||
func (c *wsConn) publishPublic(subject string, payload any) {
|
||
if c.h.bus == nil {
|
||
return
|
||
}
|
||
_ = c.h.bus.Publish(context.Background(), subject, payload)
|
||
}
|
||
|
||
// shutdown 恰好关闭连接一次。它容忍 nil 的 *websocket.Conn,
|
||
// 因此构建假连接的测试可以依赖安全的清理。
|
||
func (c *wsConn) shutdown() {
|
||
c.once.Do(func() {
|
||
close(c.done)
|
||
if c.conn != nil {
|
||
_ = c.conn.Close()
|
||
}
|
||
})
|
||
}
|
||
|
||
// closed 报告连接是否已被关闭(readPump 退出时 shutdown 关闭 done)。异步收尾
|
||
// 路径(如语音回合结算后的定向回帧)用它判断「这条回帧还送得到吗」—— send 通道
|
||
// 不会在关闭时被关上,入队后只会静默丢失,所以必须显式问一次,才能给出一条带
|
||
// 来源的 WARN 而不是无声无息。nil 字段(零值 wsConn)视为未关闭。
|
||
func (c *wsConn) closed() bool {
|
||
select {
|
||
case <-c.done:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// enqueue 把序列化后的帧入队,慢客户端缓冲满时丢弃它,
|
||
// 使生产者绝不阻塞。
|
||
func (c *wsConn) enqueue(b []byte) {
|
||
select {
|
||
case c.send <- b:
|
||
default:
|
||
}
|
||
}
|
||
|
||
// enqueueJSON 序列化 ev 并把它入队。
|
||
func (c *wsConn) enqueueJSON(ev wsEvent) {
|
||
b, err := json.Marshal(ev)
|
||
if err != nil {
|
||
c.h.log.Warn("marshal ws event", "err", err)
|
||
return
|
||
}
|
||
c.enqueue(b)
|
||
}
|
||
|
||
// wsToken 从 ?token= 查询或 Authorization 头中提取 JWT。
|
||
func wsToken(r *http.Request) string {
|
||
if t := r.URL.Query().Get("token"); t != "" {
|
||
return t
|
||
}
|
||
const prefix = "Bearer "
|
||
h := r.Header.Get("Authorization")
|
||
if strings.HasPrefix(h, prefix) {
|
||
return strings.TrimSpace(h[len(prefix):])
|
||
}
|
||
return ""
|
||
}
|