2026-06-12 17:34:43 +08:00
|
|
|
|
package ws
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-06-13 15:31:15 +08:00
|
|
|
|
"context"
|
2026-06-12 17:34:43 +08:00
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"sync"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
|
"github.com/gorilla/websocket"
|
|
|
|
|
|
|
2026-06-14 17:46:11 +08:00
|
|
|
|
"github.com/hhs/camtalk/internal/auth"
|
2026-06-14 11:55:15 +08:00
|
|
|
|
"github.com/hhs/camtalk/internal/config"
|
2026-06-13 15:31:15 +08:00
|
|
|
|
"github.com/hhs/camtalk/internal/errors"
|
2026-06-13 15:18:03 +08:00
|
|
|
|
"github.com/hhs/camtalk/internal/logger"
|
2026-06-12 17:34:43 +08:00
|
|
|
|
"github.com/hhs/camtalk/internal/models"
|
2026-06-13 16:13:22 +08:00
|
|
|
|
"github.com/hhs/camtalk/internal/orchestrator"
|
2026-06-13 15:31:15 +08:00
|
|
|
|
"github.com/hhs/camtalk/internal/session"
|
2026-06-12 17:34:43 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-14 11:55:15 +08:00
|
|
|
|
// newUpgrader 根据配置创建 WebSocket upgrader。
|
|
|
|
|
|
func newUpgrader(cfg *config.Config) websocket.Upgrader {
|
|
|
|
|
|
allowedOrigins := cfg.Server.AllowedOrigins
|
|
|
|
|
|
return websocket.Upgrader{
|
|
|
|
|
|
CheckOrigin: func(r *http.Request) bool {
|
|
|
|
|
|
if len(allowedOrigins) == 0 {
|
|
|
|
|
|
return true // 未配置则允许所有来源(开发模式)
|
|
|
|
|
|
}
|
|
|
|
|
|
origin := r.Header.Get("Origin")
|
|
|
|
|
|
for _, o := range allowedOrigins {
|
|
|
|
|
|
if o == origin || o == "*" {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
2026-06-12 17:34:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Client 代表一个 WebSocket 客户端连接。
|
|
|
|
|
|
type Client struct {
|
2026-06-13 16:13:22 +08:00
|
|
|
|
conn *websocket.Conn
|
|
|
|
|
|
sessionID string
|
|
|
|
|
|
sessionMgr session.Manager
|
|
|
|
|
|
orchestrator orchestrator.Orchestrator
|
|
|
|
|
|
cancelFuncs map[string]context.CancelFunc // requestID → cancel func
|
|
|
|
|
|
mu sync.Mutex
|
2026-06-12 17:34:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 15:31:15 +08:00
|
|
|
|
// SendJSON 向客户端发送 JSON 消息(公开以便 errors 包调用)。
|
|
|
|
|
|
func (c *Client) SendJSON(v any) error {
|
2026-06-12 17:34:43 +08:00
|
|
|
|
c.mu.Lock()
|
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
return c.conn.WriteJSON(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 16:13:22 +08:00
|
|
|
|
// WSClient 实现 orchestrator.Sender 接口,将消息推送到 WebSocket 连接。
|
|
|
|
|
|
type WSClient struct {
|
|
|
|
|
|
client *Client
|
|
|
|
|
|
requestID string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SendSTTResult 发送语音识别结果。
|
|
|
|
|
|
func (w *WSClient) SendSTTResult(result models.WsSTTResult) error {
|
|
|
|
|
|
result.RequestID = w.requestID
|
|
|
|
|
|
return w.client.SendJSON(result)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SendLLMChunk 发送 LLM 流式文本增量。
|
|
|
|
|
|
func (w *WSClient) SendLLMChunk(chunk models.WsLLMChunk) error {
|
|
|
|
|
|
chunk.RequestID = w.requestID
|
|
|
|
|
|
return w.client.SendJSON(chunk)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SendLLMDone 发送 LLM 流结束信号。
|
|
|
|
|
|
func (w *WSClient) SendLLMDone(done models.WsLLMDone) error {
|
|
|
|
|
|
done.RequestID = w.requestID
|
|
|
|
|
|
return w.client.SendJSON(done)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SendTTSAudio 发送 TTS 音频数据。
|
|
|
|
|
|
func (w *WSClient) SendTTSAudio(audio models.WsTTSAudio) error {
|
|
|
|
|
|
audio.RequestID = w.requestID
|
|
|
|
|
|
return w.client.SendJSON(audio)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SendError 发送错误消息。
|
|
|
|
|
|
func (w *WSClient) SendError(err models.WsError) error {
|
|
|
|
|
|
err.RequestID = w.requestID
|
|
|
|
|
|
return w.client.SendJSON(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 17:34:43 +08:00
|
|
|
|
// ServeWS 处理 WebSocket 升级请求。
|
2026-06-14 17:46:11 +08:00
|
|
|
|
func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *config.Config, tokenMgr *auth.TokenManager) gin.HandlerFunc {
|
2026-06-14 11:55:15 +08:00
|
|
|
|
upgrader := newUpgrader(cfg)
|
|
|
|
|
|
heartbeatInterval := time.Duration(cfg.Server.HeartbeatInterval) * time.Second
|
|
|
|
|
|
heartbeatTimeout := time.Duration(cfg.Server.HeartbeatTimeout) * time.Second
|
|
|
|
|
|
version := cfg.App.Version
|
|
|
|
|
|
|
2026-06-13 15:31:15 +08:00
|
|
|
|
return func(c *gin.Context) {
|
2026-06-20 19:57:36 +08:00
|
|
|
|
serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, tokenMgr)
|
2026-06-13 15:31:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 11:55:15 +08:00
|
|
|
|
func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orchestrator,
|
2026-06-20 19:57:36 +08:00
|
|
|
|
upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, tokenMgr *auth.TokenManager) {
|
2026-06-14 17:47:05 +08:00
|
|
|
|
|
|
|
|
|
|
// --- JWT 认证(upgrade 前完成,失败直接返回 HTTP 错误) ---
|
|
|
|
|
|
token := c.Query("token")
|
|
|
|
|
|
if token == "" {
|
|
|
|
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
claims, err := tokenMgr.ValidateAccess(token)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
userID := claims.UserID
|
|
|
|
|
|
username := claims.Username
|
|
|
|
|
|
|
2026-06-14 17:47:34 +08:00
|
|
|
|
// --- conversation_id 处理(upgrade 前校验归属) ---
|
|
|
|
|
|
conversationID := c.Query("conversation_id")
|
|
|
|
|
|
if conversationID != "" {
|
|
|
|
|
|
sess, err := sessionMgr.Get(c.Request.Context(), conversationID)
|
|
|
|
|
|
if err != nil || sess.UserID != userID {
|
|
|
|
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "SESSION_NOT_FOUND"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 17:34:43 +08:00
|
|
|
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
|
|
|
|
if err != nil {
|
2026-06-13 15:18:03 +08:00
|
|
|
|
logger.Log.Errorw("websocket upgrade failed", "error", err)
|
2026-06-12 17:34:43 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer conn.Close()
|
|
|
|
|
|
|
2026-06-14 17:47:34 +08:00
|
|
|
|
// 创建或复用会话
|
|
|
|
|
|
var sessionID string
|
|
|
|
|
|
if conversationID != "" {
|
|
|
|
|
|
sessionID = conversationID
|
|
|
|
|
|
logger.Log.Infow("resuming conversation", "session", sessionID, "user_id", userID)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sessionID, err = sessionMgr.Create(context.Background(), userID, models.DefaultConfig())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
logger.Log.Errorw("create session failed", "error", err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-06-13 15:31:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 16:13:22 +08:00
|
|
|
|
client := &Client{
|
|
|
|
|
|
conn: conn,
|
|
|
|
|
|
sessionID: sessionID,
|
|
|
|
|
|
sessionMgr: sessionMgr,
|
|
|
|
|
|
orchestrator: orch,
|
|
|
|
|
|
cancelFuncs: make(map[string]context.CancelFunc),
|
|
|
|
|
|
}
|
2026-06-12 17:34:43 +08:00
|
|
|
|
|
|
|
|
|
|
// 发送 connected 消息
|
2026-06-13 15:31:15 +08:00
|
|
|
|
_ = client.SendJSON(models.WsConnected{
|
2026-06-12 17:34:43 +08:00
|
|
|
|
Type: "connected",
|
|
|
|
|
|
SessionID: sessionID,
|
2026-06-14 11:55:15 +08:00
|
|
|
|
ServerVersion: version,
|
2026-06-12 17:34:43 +08:00
|
|
|
|
})
|
2026-06-14 17:47:05 +08:00
|
|
|
|
logger.Log.Infow("client connected", "session", sessionID, "user_id", userID, "username", username)
|
2026-06-12 17:34:43 +08:00
|
|
|
|
|
|
|
|
|
|
// 心跳检测
|
|
|
|
|
|
lastPong := time.Now()
|
|
|
|
|
|
conn.SetPongHandler(func(string) error {
|
|
|
|
|
|
lastPong = time.Now()
|
|
|
|
|
|
return nil
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 启动心跳检查 goroutine
|
|
|
|
|
|
done := make(chan struct{})
|
|
|
|
|
|
go func() {
|
2026-06-14 11:55:15 +08:00
|
|
|
|
ticker := time.NewTicker(heartbeatInterval)
|
2026-06-12 17:34:43 +08:00
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
|
for {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case <-ticker.C:
|
2026-06-14 11:55:15 +08:00
|
|
|
|
if time.Since(lastPong) > heartbeatTimeout {
|
2026-06-13 15:18:03 +08:00
|
|
|
|
logger.Log.Warnw("heartbeat timeout", "session", sessionID)
|
2026-06-12 17:34:43 +08:00
|
|
|
|
conn.Close()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
case <-done:
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
|
|
// 消息读取循环
|
|
|
|
|
|
for {
|
|
|
|
|
|
_, message, err := conn.ReadMessage()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
2026-06-13 15:18:03 +08:00
|
|
|
|
logger.Log.Warnw("ws read error", "error", err)
|
2026-06-12 17:34:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 解析消息类型
|
|
|
|
|
|
var envelope struct {
|
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := json.Unmarshal(message, &envelope); err != nil {
|
2026-06-13 15:31:15 +08:00
|
|
|
|
errors.SendWSError(client, errors.CodeInvalidMessage, "", err)
|
2026-06-12 17:34:43 +08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch envelope.Type {
|
|
|
|
|
|
case "ping":
|
2026-06-14 08:52:36 +08:00
|
|
|
|
lastPong = time.Now() // 刷新心跳计时器
|
2026-06-13 15:31:15 +08:00
|
|
|
|
_ = client.SendJSON(models.WsPong{Type: "pong"})
|
2026-06-12 17:34:43 +08:00
|
|
|
|
|
|
|
|
|
|
case "query":
|
|
|
|
|
|
var msg models.WsQuery
|
|
|
|
|
|
if err := json.Unmarshal(message, &msg); err != nil {
|
2026-06-13 15:31:15 +08:00
|
|
|
|
errors.SendWSError(client, errors.CodeInvalidMessage, msg.RequestID, err)
|
2026-06-12 17:34:43 +08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-06-13 15:18:03 +08:00
|
|
|
|
logger.Log.Infow("query received", "session", sessionID, "request", msg.RequestID)
|
2026-06-13 15:31:15 +08:00
|
|
|
|
|
|
|
|
|
|
// 刷新会话 TTL
|
2026-06-13 16:14:20 +08:00
|
|
|
|
if err := client.sessionMgr.Touch(context.Background(), sessionID); err != nil {
|
2026-06-13 15:31:15 +08:00
|
|
|
|
logger.Log.Warnw("touch session failed", "session", sessionID, "error", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 标记活跃请求
|
2026-06-13 16:14:20 +08:00
|
|
|
|
if err := client.sessionMgr.SetActiveRequest(context.Background(), sessionID, msg.RequestID); err != nil {
|
2026-06-13 15:31:15 +08:00
|
|
|
|
logger.Log.Warnw("set active request failed", "session", sessionID, "error", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 16:14:20 +08:00
|
|
|
|
// 创建可取消的 context
|
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
|
client.mu.Lock()
|
|
|
|
|
|
client.cancelFuncs[msg.RequestID] = cancel
|
|
|
|
|
|
client.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
|
|
// 创建 sender
|
|
|
|
|
|
sender := &WSClient{client: client, requestID: msg.RequestID}
|
|
|
|
|
|
|
|
|
|
|
|
// 启动 orchestrator 处理 goroutine
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
defer func() {
|
|
|
|
|
|
// 清理 cancel func
|
|
|
|
|
|
client.mu.Lock()
|
|
|
|
|
|
delete(client.cancelFuncs, msg.RequestID)
|
|
|
|
|
|
client.mu.Unlock()
|
|
|
|
|
|
cancel()
|
|
|
|
|
|
// 清除活跃请求
|
|
|
|
|
|
_ = client.sessionMgr.ClearActiveRequest(context.Background(), sessionID)
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
2026-06-20 19:57:36 +08:00
|
|
|
|
if err := client.orchestrator.ProcessQuery(ctx, sessionID, msg, sender); err != nil {
|
2026-06-13 16:14:20 +08:00
|
|
|
|
logger.Log.Errorw("process query failed", "session", sessionID, "request", msg.RequestID, "error", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
2026-06-12 17:34:43 +08:00
|
|
|
|
|
|
|
|
|
|
case "config":
|
|
|
|
|
|
var msg models.WsConfig
|
|
|
|
|
|
if err := json.Unmarshal(message, &msg); err != nil {
|
2026-06-13 15:31:15 +08:00
|
|
|
|
errors.SendWSError(client, errors.CodeInvalidMessage, "", err)
|
2026-06-12 17:34:43 +08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-06-13 15:31:15 +08:00
|
|
|
|
|
|
|
|
|
|
patch := models.SessionConfigPatch{
|
|
|
|
|
|
TTSEnabled: msg.Payload.TTSEnabled,
|
|
|
|
|
|
DetailLevel: msg.Payload.DetailLevel,
|
|
|
|
|
|
Language: msg.Payload.Language,
|
2026-06-14 20:32:30 +08:00
|
|
|
|
Scenario: msg.Payload.Scenario,
|
2026-06-13 15:31:15 +08:00
|
|
|
|
}
|
2026-06-13 16:15:17 +08:00
|
|
|
|
if err := client.sessionMgr.UpdateConfig(context.Background(), sessionID, patch); err != nil {
|
2026-06-13 15:31:15 +08:00
|
|
|
|
errors.SendWSError(client, errors.CodeInternalError, "", err)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
logger.Log.Infow("config updated", "session", sessionID)
|
2026-06-12 17:34:43 +08:00
|
|
|
|
|
|
|
|
|
|
case "interrupt":
|
2026-06-13 15:18:03 +08:00
|
|
|
|
logger.Log.Infow("interrupt received", "session", sessionID)
|
2026-06-13 15:31:15 +08:00
|
|
|
|
|
2026-06-13 16:15:17 +08:00
|
|
|
|
// 获取活跃请求 ID 并取消
|
|
|
|
|
|
reqID, _ := client.sessionMgr.GetActiveRequestID(context.Background(), sessionID)
|
2026-06-13 15:31:15 +08:00
|
|
|
|
if reqID != "" {
|
2026-06-13 16:15:17 +08:00
|
|
|
|
client.mu.Lock()
|
|
|
|
|
|
if cancel, ok := client.cancelFuncs[reqID]; ok {
|
|
|
|
|
|
cancel()
|
|
|
|
|
|
delete(client.cancelFuncs, reqID)
|
|
|
|
|
|
}
|
|
|
|
|
|
client.mu.Unlock()
|
|
|
|
|
|
_ = client.sessionMgr.ClearActiveRequest(context.Background(), sessionID)
|
2026-06-13 15:31:15 +08:00
|
|
|
|
}
|
2026-06-12 17:34:43 +08:00
|
|
|
|
|
|
|
|
|
|
default:
|
2026-06-13 15:31:15 +08:00
|
|
|
|
_ = client.SendJSON(models.WsError{
|
2026-06-12 17:34:43 +08:00
|
|
|
|
Type: "error",
|
|
|
|
|
|
Code: "INVALID_MESSAGE",
|
|
|
|
|
|
Message: "unknown message type: " + envelope.Type,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
close(done)
|
2026-06-13 15:31:15 +08:00
|
|
|
|
|
2026-06-13 16:15:17 +08:00
|
|
|
|
// 取消所有活跃请求
|
|
|
|
|
|
client.mu.Lock()
|
|
|
|
|
|
for reqID, cancel := range client.cancelFuncs {
|
|
|
|
|
|
logger.Log.Infow("canceling active request on disconnect", "session", sessionID, "request", reqID)
|
|
|
|
|
|
cancel()
|
|
|
|
|
|
}
|
|
|
|
|
|
client.cancelFuncs = make(map[string]context.CancelFunc)
|
|
|
|
|
|
client.mu.Unlock()
|
|
|
|
|
|
|
2026-06-13 15:31:15 +08:00
|
|
|
|
// 断开连接时不销毁会话,让其自然过期(支持重连恢复)
|
2026-06-13 15:18:03 +08:00
|
|
|
|
logger.Log.Infow("client disconnected", "session", sessionID)
|
2026-06-12 17:34:43 +08:00
|
|
|
|
}
|