Files
AskaEth 3a01b4e236
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 语音回合进编排+落库、G5 队列满带回身份、voice_output 归属(安卓 G5/C1/C3)
- 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,避免与契约批次打架)。
2026-10-04 17:59:28 +08:00

179 lines
4.6 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"
"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 ""
}