feat: 初始化 Go 后端项目骨架
- 使用 Gin 框架替代原始 net/http,方便调试 - 实现 WebSocket 端点 (/ws),支持 ping/query/config/interrupt 消息 - 实现健康检查 REST 接口 (/api/health) - 定义协议数据结构 (models) - 心跳检测(60 秒超时断开)
This commit is contained in:
108
backend/internal/models/models.go
Normal file
108
backend/internal/models/models.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Session 会话。
|
||||
type Session struct {
|
||||
ID string `json:"session_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Config SessionConfig `json:"config"`
|
||||
}
|
||||
|
||||
// SessionConfig 会话配置。
|
||||
type SessionConfig struct {
|
||||
TTSEnabled bool `json:"tts_enabled"`
|
||||
DetailLevel string `json:"detail_level"` // "low" | "high"
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
// DefaultConfig 默认会话配置。
|
||||
func DefaultConfig() SessionConfig {
|
||||
return SessionConfig{TTSEnabled: true, DetailLevel: "low", Language: "zh-CN"}
|
||||
}
|
||||
|
||||
// Message 对话消息。
|
||||
type Message struct {
|
||||
Role string `json:"role"` // "user" | "assistant"
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// --- WebSocket 消息 ---
|
||||
|
||||
// WsQuery 客户端 query 消息。
|
||||
type WsQuery struct {
|
||||
Type string `json:"type"`
|
||||
RequestID string `json:"request_id"`
|
||||
Image string `json:"image"` // base64
|
||||
Audio string `json:"audio"` // base64
|
||||
MimeType string `json:"mime_type"` // 默认 "audio/pcm"
|
||||
}
|
||||
|
||||
// WsConfig 客户端 config 消息。
|
||||
type WsConfig struct {
|
||||
Type string `json:"type"`
|
||||
Payload struct {
|
||||
TTSEnabled *bool `json:"tts_enabled,omitempty"`
|
||||
DetailLevel *string `json:"detail_level,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
|
||||
// WsConnected 服务端 connected 消息。
|
||||
type WsConnected struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"session_id"`
|
||||
ServerVersion string `json:"server_version"`
|
||||
}
|
||||
|
||||
// WsSTTResult 服务端 stt_result 消息。
|
||||
type WsSTTResult struct {
|
||||
Type string `json:"type"`
|
||||
RequestID string `json:"request_id"`
|
||||
Text string `json:"text"`
|
||||
IsFinal bool `json:"is_final"`
|
||||
}
|
||||
|
||||
// WsLLMChunk 服务端 llm_chunk 消息。
|
||||
type WsLLMChunk struct {
|
||||
Type string `json:"type"`
|
||||
RequestID string `json:"request_id"`
|
||||
Delta string `json:"delta"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// WsLLMDone 服务端 llm_done 消息。
|
||||
type WsLLMDone struct {
|
||||
Type string `json:"type"`
|
||||
RequestID string `json:"request_id"`
|
||||
FullText string `json:"full_text"`
|
||||
TokensUsed struct {
|
||||
Prompt int `json:"prompt"`
|
||||
Completion int `json:"completion"`
|
||||
Total int `json:"total"`
|
||||
} `json:"tokens_used"`
|
||||
Model string `json:"model"`
|
||||
LatencyMs int64 `json:"latency_ms"`
|
||||
}
|
||||
|
||||
// WsTTSAudio 服务端 tts_audio 消息。
|
||||
type WsTTSAudio struct {
|
||||
Type string `json:"type"`
|
||||
RequestID string `json:"request_id"`
|
||||
Audio string `json:"audio"` // base64
|
||||
MimeType string `json:"mime_type"` // "audio/mp3" 或 "audio/pcm"
|
||||
IsLast bool `json:"is_last"`
|
||||
}
|
||||
|
||||
// WsError 服务端 error 消息。
|
||||
type WsError struct {
|
||||
Type string `json:"type"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// WsPong 服务端 pong 消息。
|
||||
type WsPong struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
149
backend/internal/ws/handler.go
Normal file
149
backend/internal/ws/handler.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true }, // 开发阶段允许所有来源
|
||||
}
|
||||
|
||||
// Client 代表一个 WebSocket 客户端连接。
|
||||
type Client struct {
|
||||
conn *websocket.Conn
|
||||
sessionID string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (c *Client) sendJSON(v any) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.conn.WriteJSON(v)
|
||||
}
|
||||
|
||||
// ServeWS 处理 WebSocket 升级请求。
|
||||
func ServeWS(c *gin.Context) {
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Printf("websocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
sessionID := uuid.New().String()
|
||||
client := &Client{conn: conn, sessionID: sessionID}
|
||||
|
||||
// 发送 connected 消息
|
||||
_ = client.sendJSON(models.WsConnected{
|
||||
Type: "connected",
|
||||
SessionID: sessionID,
|
||||
ServerVersion: "0.1.0",
|
||||
})
|
||||
log.Printf("client connected: session=%s", sessionID)
|
||||
|
||||
// 心跳检测
|
||||
lastPong := time.Now()
|
||||
conn.SetPongHandler(func(string) error {
|
||||
lastPong = time.Now()
|
||||
return nil
|
||||
})
|
||||
|
||||
// 启动心跳检查 goroutine
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if time.Since(lastPong) > 60*time.Second {
|
||||
log.Printf("heartbeat timeout: session=%s", sessionID)
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 消息读取循环
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||
log.Printf("ws read error: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 解析消息类型
|
||||
var envelope struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(message, &envelope); err != nil {
|
||||
_ = client.sendJSON(models.WsError{
|
||||
Type: "error",
|
||||
Code: "INVALID_MESSAGE",
|
||||
Message: "invalid JSON",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
switch envelope.Type {
|
||||
case "ping":
|
||||
_ = client.sendJSON(models.WsPong{Type: "pong"})
|
||||
|
||||
case "query":
|
||||
var msg models.WsQuery
|
||||
if err := json.Unmarshal(message, &msg); err != nil {
|
||||
_ = client.sendJSON(models.WsError{
|
||||
Type: "error",
|
||||
Code: "INVALID_MESSAGE",
|
||||
Message: "invalid query message",
|
||||
RequestID: msg.RequestID,
|
||||
})
|
||||
continue
|
||||
}
|
||||
log.Printf("query received: session=%s request=%s", sessionID, msg.RequestID)
|
||||
// TODO: 调用 AI 编排流程(STT → LLM → TTS)
|
||||
|
||||
case "config":
|
||||
var msg models.WsConfig
|
||||
if err := json.Unmarshal(message, &msg); err != nil {
|
||||
_ = client.sendJSON(models.WsError{
|
||||
Type: "error",
|
||||
Code: "INVALID_MESSAGE",
|
||||
Message: "invalid config message",
|
||||
})
|
||||
continue
|
||||
}
|
||||
log.Printf("config update: session=%s", sessionID)
|
||||
// TODO: 更新会话配置
|
||||
|
||||
case "interrupt":
|
||||
log.Printf("interrupt received: session=%s", sessionID)
|
||||
// TODO: 中断当前 AI 响应
|
||||
|
||||
default:
|
||||
_ = client.sendJSON(models.WsError{
|
||||
Type: "error",
|
||||
Code: "INVALID_MESSAGE",
|
||||
Message: "unknown message type: " + envelope.Type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
close(done)
|
||||
log.Printf("client disconnected: session=%s", sessionID)
|
||||
}
|
||||
Reference in New Issue
Block a user