Merge pull request 'Merge pull request 'feat: 添加 PostgreSQL 服务并挂载数据库迁移脚本'' (#101) from develop into main
Some checks failed
Deploy / deploy (push) Failing after 1m32s
Some checks failed
Deploy / deploy (push) Failing after 1m32s
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/101
This commit was merged in pull request #101.
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/hhs/camtalk/internal/api"
|
||||
"github.com/hhs/camtalk/internal/auth"
|
||||
"github.com/hhs/camtalk/internal/ai/llm"
|
||||
"github.com/hhs/camtalk/internal/ai/stt"
|
||||
"github.com/hhs/camtalk/internal/ai/tts"
|
||||
@@ -19,7 +20,9 @@ import (
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/orchestrator"
|
||||
"github.com/hhs/camtalk/internal/session"
|
||||
"github.com/hhs/camtalk/internal/store"
|
||||
"github.com/hhs/camtalk/internal/ws"
|
||||
migrations "github.com/hhs/camtalk/migrations"
|
||||
)
|
||||
|
||||
// Version 通过构建时 -ldflags 注入,如:
|
||||
@@ -44,12 +47,48 @@ func main() {
|
||||
"addr", cfg.Server.Addr(),
|
||||
)
|
||||
|
||||
// 初始化 Session Manager(MVP 默认内存实现)
|
||||
// 初始化存储层(条件初始化 PostgreSQL)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var userRepo store.UserRepository
|
||||
var msgRepo store.MessageRepository
|
||||
var sessRepo store.SessionRepository
|
||||
|
||||
if cfg.Storage.Driver == "postgres" {
|
||||
pool, err := store.NewPostgresPool(ctx, cfg.Storage.DSN)
|
||||
if err != nil {
|
||||
logger.Log.Fatalw("failed to connect to postgres", "error", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
// 执行数据库迁移
|
||||
if err := store.RunMigrations(ctx, pool, migrations.FS); err != nil {
|
||||
logger.Log.Fatalw("failed to run migrations", "error", err)
|
||||
}
|
||||
|
||||
userRepo = store.NewPgUserRepository(pool)
|
||||
msgRepo = store.NewPgMessageRepository(pool)
|
||||
sessRepo = store.NewPgSessionRepository(pool)
|
||||
logger.Log.Infow("postgres storage initialized", "driver", cfg.Storage.Driver)
|
||||
} else {
|
||||
userRepo = store.NewMemUserRepository()
|
||||
logger.Log.Info("using in-memory storage")
|
||||
}
|
||||
|
||||
// 初始化 Session Manager
|
||||
var sessionMgr session.Manager
|
||||
// TODO: 当 Redis 配置非空时切换为 RedisManager
|
||||
var sessionOpts []session.Option
|
||||
if msgRepo != nil {
|
||||
sessionOpts = append(sessionOpts, session.WithMessageRepository(msgRepo))
|
||||
}
|
||||
if sessRepo != nil {
|
||||
sessionOpts = append(sessionOpts, session.WithSessionRepository(sessRepo))
|
||||
}
|
||||
sessionMgr = session.NewMemoryManager(
|
||||
time.Duration(cfg.Session.TTL)*time.Minute,
|
||||
cfg.Session.MaxHistory,
|
||||
sessionOpts...,
|
||||
)
|
||||
defer sessionMgr.(*session.MemoryManager).Stop()
|
||||
|
||||
@@ -89,6 +128,14 @@ func main() {
|
||||
// 初始化 Orchestrator
|
||||
orch := orchestrator.New(sttService, llmService, ttsService, sessionMgr, cfg)
|
||||
|
||||
// 初始化认证服务
|
||||
tokenMgr := auth.NewTokenManager(
|
||||
cfg.Auth.JWTSecret,
|
||||
time.Duration(cfg.Auth.AccessTTL)*time.Minute,
|
||||
time.Duration(cfg.Auth.RefreshTTL)*time.Minute,
|
||||
)
|
||||
authService := auth.NewAuthService(tokenMgr, userRepo)
|
||||
|
||||
// Gin 模式
|
||||
if cfg.App.Env == "prod" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
@@ -107,8 +154,16 @@ func main() {
|
||||
sessionHandler := api.NewSessionHandler(sessionMgr)
|
||||
sessionHandler.RegisterRoutes(apiGroup)
|
||||
|
||||
// Auth REST 端点
|
||||
authHandler := api.NewAuthHandler(authService, tokenMgr)
|
||||
authHandler.RegisterRoutes(apiGroup)
|
||||
|
||||
// Conversation REST 端点
|
||||
convHandler := api.NewConversationHandler(sessionMgr, tokenMgr, msgRepo)
|
||||
convHandler.RegisterRoutes(apiGroup)
|
||||
|
||||
// WebSocket
|
||||
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg))
|
||||
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr))
|
||||
|
||||
// HTTP Server
|
||||
srv := &http.Server{
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
module github.com/hhs/camtalk
|
||||
|
||||
go 1.24
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/redis/go-redis/v9 v9.20.1
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
@@ -27,6 +28,10 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
@@ -50,8 +55,9 @@ require (
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -37,6 +37,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -44,6 +46,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
@@ -120,16 +130,18 @@ golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
194
backend/internal/api/auth.go
Normal file
194
backend/internal/api/auth.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/hhs/camtalk/internal/auth"
|
||||
apperr "github.com/hhs/camtalk/internal/errors"
|
||||
)
|
||||
|
||||
// AuthHandler 提供认证相关的 REST 端点。
|
||||
type AuthHandler struct {
|
||||
authService auth.Service
|
||||
tokenMgr *auth.TokenManager
|
||||
}
|
||||
|
||||
// NewAuthHandler 创建 AuthHandler。
|
||||
func NewAuthHandler(authService auth.Service, tokenMgr *auth.TokenManager) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
tokenMgr: tokenMgr,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册认证相关路由到给定的路由组。
|
||||
func (h *AuthHandler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
authGroup := rg.Group("/auth")
|
||||
{
|
||||
authGroup.POST("/register", h.Register)
|
||||
authGroup.POST("/login", h.Login)
|
||||
authGroup.POST("/refresh", h.Refresh)
|
||||
authGroup.POST("/logout", auth.AuthMiddleware(h.tokenMgr), h.Logout)
|
||||
}
|
||||
}
|
||||
|
||||
// Register POST /api/auth/register — 用户注册。
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req auth.RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": apperr.CodeInvalidInput,
|
||||
"message": "invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if msg := validateCredentials(req.Username, req.Password); msg != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": apperr.CodeInvalidInput,
|
||||
"message": msg,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.authService.Register(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
handleAuthError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// Login POST /api/auth/login — 用户登录。
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req auth.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": apperr.CodeInvalidInput,
|
||||
"message": "invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if msg := validateCredentials(req.Username, req.Password); msg != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": apperr.CodeInvalidInput,
|
||||
"message": msg,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.authService.Login(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
handleAuthError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// Refresh POST /api/auth/refresh — 刷新令牌。
|
||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
var req auth.RefreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": apperr.CodeInvalidInput,
|
||||
"message": "invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.RefreshToken == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": apperr.CodeInvalidInput,
|
||||
"message": "refresh_token is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.authService.Refresh(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
handleAuthError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// Logout POST /api/auth/logout — 登出(需要认证)。
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
userID := c.GetString(auth.ContextKeyUserID)
|
||||
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": apperr.CodeInvalidInput,
|
||||
"message": "invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.RefreshToken == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": apperr.CodeInvalidInput,
|
||||
"message": "refresh_token is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.authService.Logout(c.Request.Context(), userID, req.RefreshToken); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperr.CodeInternalError,
|
||||
"message": "failed to logout",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "logged out successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// validateCredentials 校验用户名和密码格式。
|
||||
// 返回空字符串表示校验通过,否则返回错误描述。
|
||||
func validateCredentials(username, password string) string {
|
||||
if len(username) < 3 || len(username) > 64 {
|
||||
return "username must be 3-64 characters"
|
||||
}
|
||||
if len(password) < 8 || len(password) > 72 {
|
||||
return "password must be 8-72 characters"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// handleAuthError 将 auth 层错误映射为 HTTP 响应。
|
||||
func handleAuthError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrUsernameTaken):
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"code": apperr.CodeUsernameTaken,
|
||||
"message": "username already taken",
|
||||
})
|
||||
case errors.Is(err, auth.ErrInvalidCredentials):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"code": apperr.CodeInvalidCredentials,
|
||||
"message": "invalid username or password",
|
||||
})
|
||||
case errors.Is(err, auth.ErrRefreshTokenUsed):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"code": apperr.CodeInvalidToken,
|
||||
"message": "refresh token has been used or expired",
|
||||
})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperr.CodeInternalError,
|
||||
"message": "internal server error",
|
||||
})
|
||||
}
|
||||
}
|
||||
324
backend/internal/api/auth_test.go
Normal file
324
backend/internal/api/auth_test.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/hhs/camtalk/internal/api"
|
||||
"github.com/hhs/camtalk/internal/auth"
|
||||
)
|
||||
|
||||
// mockAuthService 实现 auth.Service 接口,用于 API 测试。
|
||||
type mockAuthService struct {
|
||||
RegisterFunc func(ctx context.Context, req auth.RegisterRequest) (*auth.AuthResponse, error)
|
||||
LoginFunc func(ctx context.Context, req auth.LoginRequest) (*auth.AuthResponse, error)
|
||||
RefreshFunc func(ctx context.Context, req auth.RefreshRequest) (*auth.AuthResponse, error)
|
||||
LogoutFunc func(ctx context.Context, userID, refreshToken string) error
|
||||
}
|
||||
|
||||
func (m *mockAuthService) Register(ctx context.Context, req auth.RegisterRequest) (*auth.AuthResponse, error) {
|
||||
return m.RegisterFunc(ctx, req)
|
||||
}
|
||||
|
||||
func (m *mockAuthService) Login(ctx context.Context, req auth.LoginRequest) (*auth.AuthResponse, error) {
|
||||
return m.LoginFunc(ctx, req)
|
||||
}
|
||||
|
||||
func (m *mockAuthService) Refresh(ctx context.Context, req auth.RefreshRequest) (*auth.AuthResponse, error) {
|
||||
return m.RefreshFunc(ctx, req)
|
||||
}
|
||||
|
||||
func (m *mockAuthService) Logout(ctx context.Context, userID, refreshToken string) error {
|
||||
return m.LogoutFunc(ctx, userID, refreshToken)
|
||||
}
|
||||
|
||||
// newTestRouter 创建带 AuthHandler 路由的测试 Gin 引擎。
|
||||
func newTestRouter(svc auth.Service) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
tm := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour)
|
||||
h := api.NewAuthHandler(svc, tm)
|
||||
h.RegisterRoutes(r.Group("/api"))
|
||||
return r
|
||||
}
|
||||
|
||||
// newTestRouterWithToken 创建带 AuthHandler 路由的测试引擎,同时返回 TokenManager 以便生成测试 token。
|
||||
func newTestRouterWithToken(svc auth.Service) (*gin.Engine, *auth.TokenManager) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
tm := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour)
|
||||
h := api.NewAuthHandler(svc, tm)
|
||||
h.RegisterRoutes(r.Group("/api"))
|
||||
return r, tm
|
||||
}
|
||||
|
||||
func sampleAuthResponse() *auth.AuthResponse {
|
||||
return &auth.AuthResponse{
|
||||
User: auth.UserResponse{
|
||||
ID: "user-123",
|
||||
Username: "alice",
|
||||
},
|
||||
AccessToken: "access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
}
|
||||
}
|
||||
|
||||
// --- Register ---
|
||||
|
||||
func TestRegister_Success(t *testing.T) {
|
||||
svc := &mockAuthService{
|
||||
RegisterFunc: func(_ context.Context, req auth.RegisterRequest) (*auth.AuthResponse, error) {
|
||||
assert.Equal(t, "alice", req.Username)
|
||||
assert.Equal(t, "password123", req.Password)
|
||||
return sampleAuthResponse(), nil
|
||||
},
|
||||
}
|
||||
r := newTestRouter(svc)
|
||||
|
||||
body, _ := json.Marshal(auth.RegisterRequest{Username: "alice", Password: "password123"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
var resp auth.AuthResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "alice", resp.User.Username)
|
||||
assert.NotEmpty(t, resp.AccessToken)
|
||||
}
|
||||
|
||||
func TestRegister_InvalidInput_EmptyBody(t *testing.T) {
|
||||
svc := &mockAuthService{}
|
||||
r := newTestRouter(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "INVALID_INPUT")
|
||||
}
|
||||
|
||||
func TestRegister_InvalidInput_UsernameTooShort(t *testing.T) {
|
||||
svc := &mockAuthService{}
|
||||
r := newTestRouter(svc)
|
||||
|
||||
body, _ := json.Marshal(auth.RegisterRequest{Username: "ab", Password: "password123"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "username must be 3-64 characters")
|
||||
}
|
||||
|
||||
func TestRegister_InvalidInput_PasswordTooShort(t *testing.T) {
|
||||
svc := &mockAuthService{}
|
||||
r := newTestRouter(svc)
|
||||
|
||||
body, _ := json.Marshal(auth.RegisterRequest{Username: "alice", Password: "short"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "password must be 8-72 characters")
|
||||
}
|
||||
|
||||
func TestRegister_UsernameTaken(t *testing.T) {
|
||||
svc := &mockAuthService{
|
||||
RegisterFunc: func(_ context.Context, _ auth.RegisterRequest) (*auth.AuthResponse, error) {
|
||||
return nil, auth.ErrUsernameTaken
|
||||
},
|
||||
}
|
||||
r := newTestRouter(svc)
|
||||
|
||||
body, _ := json.Marshal(auth.RegisterRequest{Username: "alice", Password: "password123"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusConflict, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "USERNAME_TAKEN")
|
||||
}
|
||||
|
||||
// --- Login ---
|
||||
|
||||
func TestLogin_Success(t *testing.T) {
|
||||
svc := &mockAuthService{
|
||||
LoginFunc: func(_ context.Context, req auth.LoginRequest) (*auth.AuthResponse, error) {
|
||||
assert.Equal(t, "alice", req.Username)
|
||||
assert.Equal(t, "password123", req.Password)
|
||||
return sampleAuthResponse(), nil
|
||||
},
|
||||
}
|
||||
r := newTestRouter(svc)
|
||||
|
||||
body, _ := json.Marshal(auth.LoginRequest{Username: "alice", Password: "password123"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp auth.AuthResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "alice", resp.User.Username)
|
||||
}
|
||||
|
||||
func TestLogin_InvalidCredentials(t *testing.T) {
|
||||
svc := &mockAuthService{
|
||||
LoginFunc: func(_ context.Context, _ auth.LoginRequest) (*auth.AuthResponse, error) {
|
||||
return nil, auth.ErrInvalidCredentials
|
||||
},
|
||||
}
|
||||
r := newTestRouter(svc)
|
||||
|
||||
body, _ := json.Marshal(auth.LoginRequest{Username: "alice", Password: "wrong-password"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "INVALID_CREDENTIALS")
|
||||
}
|
||||
|
||||
// --- Refresh ---
|
||||
|
||||
func TestRefresh_Success(t *testing.T) {
|
||||
svc := &mockAuthService{
|
||||
RefreshFunc: func(_ context.Context, req auth.RefreshRequest) (*auth.AuthResponse, error) {
|
||||
assert.Equal(t, "some-refresh-token", req.RefreshToken)
|
||||
return sampleAuthResponse(), nil
|
||||
},
|
||||
}
|
||||
r := newTestRouter(svc)
|
||||
|
||||
body, _ := json.Marshal(auth.RefreshRequest{RefreshToken: "some-refresh-token"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/refresh", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestRefresh_MissingToken(t *testing.T) {
|
||||
svc := &mockAuthService{}
|
||||
r := newTestRouter(svc)
|
||||
|
||||
body, _ := json.Marshal(auth.RefreshRequest{RefreshToken: ""})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/refresh", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "refresh_token is required")
|
||||
}
|
||||
|
||||
func TestRefresh_UsedToken(t *testing.T) {
|
||||
svc := &mockAuthService{
|
||||
RefreshFunc: func(_ context.Context, _ auth.RefreshRequest) (*auth.AuthResponse, error) {
|
||||
return nil, auth.ErrRefreshTokenUsed
|
||||
},
|
||||
}
|
||||
r := newTestRouter(svc)
|
||||
|
||||
body, _ := json.Marshal(auth.RefreshRequest{RefreshToken: "used-token"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/refresh", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "INVALID_TOKEN")
|
||||
}
|
||||
|
||||
// --- Logout ---
|
||||
|
||||
func TestLogout_Success(t *testing.T) {
|
||||
logoutCalled := false
|
||||
svc := &mockAuthService{
|
||||
LogoutFunc: func(_ context.Context, userID, refreshToken string) error {
|
||||
assert.Equal(t, "user-123", userID)
|
||||
assert.Equal(t, "refresh-token-to-revoke", refreshToken)
|
||||
logoutCalled = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
r, tm := newTestRouterWithToken(svc)
|
||||
|
||||
// 生成有效 token
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"refresh_token": "refresh-token-to-revoke"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.True(t, logoutCalled)
|
||||
assert.Contains(t, w.Body.String(), "logged out successfully")
|
||||
}
|
||||
|
||||
func TestLogout_MissingAuth(t *testing.T) {
|
||||
svc := &mockAuthService{}
|
||||
r := newTestRouter(svc)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"refresh_token": "some-token"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
func TestLogout_MissingRefreshToken(t *testing.T) {
|
||||
svc := &mockAuthService{}
|
||||
r, tm := newTestRouterWithToken(svc)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"refresh_token": ""})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "refresh_token is required")
|
||||
}
|
||||
322
backend/internal/api/conversation.go
Normal file
322
backend/internal/api/conversation.go
Normal file
@@ -0,0 +1,322 @@
|
||||
// Package api 提供 REST API 处理函数。
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/hhs/camtalk/internal/auth"
|
||||
apperr "github.com/hhs/camtalk/internal/errors"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/session"
|
||||
"github.com/hhs/camtalk/internal/store"
|
||||
)
|
||||
|
||||
// ConversationHandler 提供对话相关的 REST 端点。
|
||||
type ConversationHandler struct {
|
||||
sessionMgr session.Manager
|
||||
tokenMgr *auth.TokenManager
|
||||
msgRepo store.MessageRepository // 可选,为 nil 时 fallback 到内存查询
|
||||
}
|
||||
|
||||
// NewConversationHandler 创建 ConversationHandler。
|
||||
// msgRepo 可选,为 nil 时消息查询走内存。
|
||||
func NewConversationHandler(sessionMgr session.Manager, tokenMgr *auth.TokenManager, msgRepo store.MessageRepository) *ConversationHandler {
|
||||
return &ConversationHandler{
|
||||
sessionMgr: sessionMgr,
|
||||
tokenMgr: tokenMgr,
|
||||
msgRepo: msgRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册对话相关路由到给定的路由组。所有端点需要认证。
|
||||
func (h *ConversationHandler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
conv := rg.Group("/conversations", auth.AuthMiddleware(h.tokenMgr))
|
||||
{
|
||||
conv.GET("", h.List)
|
||||
conv.POST("", h.Create)
|
||||
conv.GET("/:id", h.Get)
|
||||
conv.PATCH("/:id", h.UpdateTitle)
|
||||
conv.DELETE("/:id", h.Delete)
|
||||
conv.GET("/:id/messages", h.GetMessages)
|
||||
}
|
||||
}
|
||||
|
||||
// List GET /api/conversations — 获取当前用户的对话列表。
|
||||
func (h *ConversationHandler) List(c *gin.Context) {
|
||||
userID := c.GetString(auth.ContextKeyUserID)
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
|
||||
summaries, total, err := h.sessionMgr.ListByUser(c.Request.Context(), userID, page, size)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperr.CodeInternalError,
|
||||
"message": "failed to list conversations",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"conversations": summaries,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateConversationRequest POST /api/conversations 请求体。
|
||||
type CreateConversationRequest struct {
|
||||
Config *models.SessionConfig `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
// Create POST /api/conversations — 创建新对话。
|
||||
func (h *ConversationHandler) Create(c *gin.Context) {
|
||||
userID := c.GetString(auth.ContextKeyUserID)
|
||||
|
||||
var req CreateConversationRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
cfg := models.DefaultConfig()
|
||||
if req.Config != nil {
|
||||
cfg = *req.Config
|
||||
}
|
||||
|
||||
sessionID, err := h.sessionMgr.Create(c.Request.Context(), userID, cfg)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperr.CodeInternalError,
|
||||
"message": "failed to create conversation",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := h.sessionMgr.Get(c.Request.Context(), sessionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperr.CodeInternalError,
|
||||
"message": "failed to retrieve created conversation",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": sess.ID,
|
||||
"title": sess.Title,
|
||||
"created_at": sess.CreatedAt,
|
||||
"updated_at": sess.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// Get GET /api/conversations/:id — 获取对话详情。
|
||||
func (h *ConversationHandler) Get(c *gin.Context) {
|
||||
sessionID := c.Param("id")
|
||||
|
||||
sess, err := h.getSessionForUser(c, sessionID)
|
||||
if err != nil {
|
||||
return // getSessionForUser 已写入响应
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": sess.ID,
|
||||
"title": sess.Title,
|
||||
"created_at": sess.CreatedAt,
|
||||
"updated_at": sess.UpdatedAt,
|
||||
"config": sess.Config,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateTitleRequest PATCH /api/conversations/:id 请求体。
|
||||
type UpdateTitleRequest struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// UpdateTitle PATCH /api/conversations/:id — 更新对话标题。
|
||||
func (h *ConversationHandler) UpdateTitle(c *gin.Context) {
|
||||
sessionID := c.Param("id")
|
||||
|
||||
// 先校验归属
|
||||
if _, err := h.getSessionForUser(c, sessionID); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateTitleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Title == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": apperr.CodeInvalidInput,
|
||||
"message": "title is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len([]rune(req.Title)) > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": apperr.CodeInvalidInput,
|
||||
"message": "title must be 100 characters or less",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.sessionMgr.UpdateTitle(c.Request.Context(), sessionID, req.Title); err != nil {
|
||||
if errors.Is(err, session.ErrSessionNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": apperr.CodeSessionNotFound,
|
||||
"message": "conversation not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperr.CodeInternalError,
|
||||
"message": "failed to update title",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "title updated",
|
||||
})
|
||||
}
|
||||
|
||||
// Delete DELETE /api/conversations/:id — 删除对话。
|
||||
func (h *ConversationHandler) Delete(c *gin.Context) {
|
||||
sessionID := c.Param("id")
|
||||
|
||||
// 先校验归属
|
||||
if _, err := h.getSessionForUser(c, sessionID); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.sessionMgr.Destroy(c.Request.Context(), sessionID); err != nil {
|
||||
if errors.Is(err, session.ErrSessionNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": apperr.CodeSessionNotFound,
|
||||
"message": "conversation not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperr.CodeInternalError,
|
||||
"message": "failed to delete conversation",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetMessages GET /api/conversations/:id/messages — 获取对话消息列表。
|
||||
//
|
||||
// 查询参数:
|
||||
// - limit: 返回消息数量上限,默认 50
|
||||
// - before: 消息 ID 游标(用于分页),返回此 ID 之前的消息
|
||||
func (h *ConversationHandler) GetMessages(c *gin.Context) {
|
||||
sessionID := c.Param("id")
|
||||
|
||||
// 先校验归属
|
||||
if _, err := h.getSessionForUser(c, sessionID); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
beforeID, _ := strconv.ParseInt(c.DefaultQuery("before", "0"), 10, 64)
|
||||
|
||||
// 优先从 PostgreSQL 查询(支持持久化后的全量历史)
|
||||
if h.msgRepo != nil {
|
||||
messages, err := h.msgRepo.GetMessages(c.Request.Context(), sessionID, limit, beforeID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperr.CodeInternalError,
|
||||
"message": "failed to get messages",
|
||||
})
|
||||
return
|
||||
}
|
||||
count, _ := h.msgRepo.GetMessageCount(c.Request.Context(), sessionID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"messages": messages,
|
||||
"total": count,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// fallback:从内存查询
|
||||
allMessages, err := h.sessionMgr.GetHistory(c.Request.Context(), sessionID, 0)
|
||||
if err != nil {
|
||||
if errors.Is(err, session.ErrSessionNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": apperr.CodeSessionNotFound,
|
||||
"message": "conversation not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperr.CodeInternalError,
|
||||
"message": "failed to get messages",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
total := len(allMessages)
|
||||
|
||||
// beforeID > 0 时表示偏移量(兼容旧接口语义)
|
||||
if beforeID > 0 && int(beforeID) <= total {
|
||||
allMessages = allMessages[:beforeID]
|
||||
}
|
||||
|
||||
// 取最后 limit 条
|
||||
start := len(allMessages) - limit
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
messages := allMessages[start:]
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"messages": messages,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// getSessionForUser 获取会话并校验当前用户是否有权限访问。
|
||||
// 返回 404(而非 403)以避免信息泄露。
|
||||
func (h *ConversationHandler) getSessionForUser(c *gin.Context, sessionID string) (*models.Session, error) {
|
||||
sess, err := h.sessionMgr.Get(c.Request.Context(), sessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, session.ErrSessionNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": apperr.CodeSessionNotFound,
|
||||
"message": "conversation not found",
|
||||
})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": apperr.CodeInternalError,
|
||||
"message": "internal server error",
|
||||
})
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userID := c.GetString(auth.ContextKeyUserID)
|
||||
if sess.UserID != userID {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": apperr.CodeSessionNotFound,
|
||||
"message": "conversation not found",
|
||||
})
|
||||
return nil, errors.New("forbidden")
|
||||
}
|
||||
|
||||
return sess, nil
|
||||
}
|
||||
575
backend/internal/api/conversation_test.go
Normal file
575
backend/internal/api/conversation_test.go
Normal file
@@ -0,0 +1,575 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/hhs/camtalk/internal/api"
|
||||
"github.com/hhs/camtalk/internal/auth"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/session"
|
||||
)
|
||||
|
||||
// mockSessionManager 实现 session.Manager 接口,用于 ConversationHandler 测试。
|
||||
type mockSessionManager struct {
|
||||
CreateFunc func(ctx context.Context, userID string, config models.SessionConfig) (string, error)
|
||||
GetFunc func(ctx context.Context, sessionID string) (*models.Session, error)
|
||||
UpdateConfigFunc func(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error
|
||||
UpdateTitleFunc func(ctx context.Context, sessionID string, title string) error
|
||||
ListByUserFunc func(ctx context.Context, userID string, page, size int) ([]session.ConversationSummary, int, error)
|
||||
GetHistoryFunc func(ctx context.Context, sessionID string, limit int) ([]models.Message, error)
|
||||
AppendMessageFunc func(ctx context.Context, sessionID string, msg models.Message) error
|
||||
SetActiveRequestFunc func(ctx context.Context, sessionID string, requestID string) error
|
||||
GetActiveRequestIDFunc func(ctx context.Context, sessionID string) (string, error)
|
||||
ClearActiveRequestFunc func(ctx context.Context, sessionID string) error
|
||||
TouchFunc func(ctx context.Context, sessionID string) error
|
||||
DestroyFunc func(ctx context.Context, sessionID string) error
|
||||
ActiveCountFunc func() int
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) Create(ctx context.Context, userID string, config models.SessionConfig) (string, error) {
|
||||
return m.CreateFunc(ctx, userID, config)
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) Get(ctx context.Context, sessionID string) (*models.Session, error) {
|
||||
return m.GetFunc(ctx, sessionID)
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) UpdateConfig(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error {
|
||||
return m.UpdateConfigFunc(ctx, sessionID, patch)
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) UpdateTitle(ctx context.Context, sessionID string, title string) error {
|
||||
return m.UpdateTitleFunc(ctx, sessionID, title)
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) ListByUser(ctx context.Context, userID string, page, size int) ([]session.ConversationSummary, int, error) {
|
||||
return m.ListByUserFunc(ctx, userID, page, size)
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error) {
|
||||
return m.GetHistoryFunc(ctx, sessionID, limit)
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) AppendMessage(ctx context.Context, sessionID string, msg models.Message) error {
|
||||
return m.AppendMessageFunc(ctx, sessionID, msg)
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) SetActiveRequest(ctx context.Context, sessionID string, requestID string) error {
|
||||
return m.SetActiveRequestFunc(ctx, sessionID, requestID)
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) GetActiveRequestID(ctx context.Context, sessionID string) (string, error) {
|
||||
return m.GetActiveRequestIDFunc(ctx, sessionID)
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) ClearActiveRequest(ctx context.Context, sessionID string) error {
|
||||
return m.ClearActiveRequestFunc(ctx, sessionID)
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) Touch(ctx context.Context, sessionID string) error {
|
||||
return m.TouchFunc(ctx, sessionID)
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) Destroy(ctx context.Context, sessionID string) error {
|
||||
return m.DestroyFunc(ctx, sessionID)
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) ActiveCount() int {
|
||||
return m.ActiveCountFunc()
|
||||
}
|
||||
|
||||
// newConvTestRouter 创建带 ConversationHandler 路由的测试引擎,同时返回 TokenManager。
|
||||
func newConvTestRouter(mgr session.Manager) (*gin.Engine, *auth.TokenManager) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
tm := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour)
|
||||
h := api.NewConversationHandler(mgr, tm, nil)
|
||||
h.RegisterRoutes(r.Group("/api"))
|
||||
return r, tm
|
||||
}
|
||||
|
||||
// --- List ---
|
||||
|
||||
func TestConversationList_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
mgr := &mockSessionManager{
|
||||
ListByUserFunc: func(_ context.Context, userID string, page, size int) ([]session.ConversationSummary, int, error) {
|
||||
assert.Equal(t, "user-123", userID)
|
||||
assert.Equal(t, 1, page)
|
||||
assert.Equal(t, 20, size)
|
||||
return []session.ConversationSummary{
|
||||
{ID: "sess-1", Title: "对话一", MessageCount: 3, UpdatedAt: now},
|
||||
{ID: "sess-2", Title: "对话二", MessageCount: 1, UpdatedAt: now.Add(-time.Hour)},
|
||||
}, 2, nil
|
||||
},
|
||||
}
|
||||
r, tm := newConvTestRouter(mgr)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/conversations", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, float64(2), resp["total"])
|
||||
convs := resp["conversations"].([]interface{})
|
||||
assert.Len(t, convs, 2)
|
||||
}
|
||||
|
||||
func TestConversationList_WithPagination(t *testing.T) {
|
||||
mgr := &mockSessionManager{
|
||||
ListByUserFunc: func(_ context.Context, _ string, page, size int) ([]session.ConversationSummary, int, error) {
|
||||
assert.Equal(t, 2, page)
|
||||
assert.Equal(t, 10, size)
|
||||
return []session.ConversationSummary{}, 0, nil
|
||||
},
|
||||
}
|
||||
r, tm := newConvTestRouter(mgr)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/conversations?page=2&size=10", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestConversationList_MissingAuth(t *testing.T) {
|
||||
mgr := &mockSessionManager{}
|
||||
r, _ := newConvTestRouter(mgr)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/conversations", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
// --- Create ---
|
||||
|
||||
func TestConversationCreate_Success(t *testing.T) {
|
||||
createdID := "new-session-id"
|
||||
now := time.Now()
|
||||
mgr := &mockSessionManager{
|
||||
CreateFunc: func(_ context.Context, userID string, cfg models.SessionConfig) (string, error) {
|
||||
assert.Equal(t, "user-123", userID)
|
||||
return createdID, nil
|
||||
},
|
||||
GetFunc: func(_ context.Context, sessionID string) (*models.Session, error) {
|
||||
assert.Equal(t, createdID, sessionID)
|
||||
return &models.Session{
|
||||
ID: createdID,
|
||||
UserID: "user-123",
|
||||
Title: models.DefaultSessionTitle,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Config: models.DefaultConfig(),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
r, tm := newConvTestRouter(mgr)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/conversations", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, createdID, resp["id"])
|
||||
assert.Equal(t, models.DefaultSessionTitle, resp["title"])
|
||||
}
|
||||
|
||||
func TestConversationCreate_WithConfig(t *testing.T) {
|
||||
mgr := &mockSessionManager{
|
||||
CreateFunc: func(_ context.Context, _ string, cfg models.SessionConfig) (string, error) {
|
||||
assert.False(t, cfg.TTSEnabled)
|
||||
assert.Equal(t, "high", cfg.DetailLevel)
|
||||
return "sess-1", nil
|
||||
},
|
||||
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
|
||||
return &models.Session{
|
||||
ID: "sess-1",
|
||||
UserID: "user-123",
|
||||
Title: models.DefaultSessionTitle,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
r, tm := newConvTestRouter(mgr)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
body, _ := json.Marshal(api.CreateConversationRequest{
|
||||
Config: &models.SessionConfig{TTSEnabled: false, DetailLevel: "high", Language: "zh-CN"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/conversations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
}
|
||||
|
||||
// --- Get ---
|
||||
|
||||
func TestConversationGet_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
mgr := &mockSessionManager{
|
||||
GetFunc: func(_ context.Context, sessionID string) (*models.Session, error) {
|
||||
assert.Equal(t, "sess-1", sessionID)
|
||||
return &models.Session{
|
||||
ID: "sess-1",
|
||||
UserID: "user-123",
|
||||
Title: "我的对话",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Config: models.DefaultConfig(),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
r, tm := newConvTestRouter(mgr)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/conversations/sess-1", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "我的对话", resp["title"])
|
||||
}
|
||||
|
||||
func TestConversationGet_NotFound(t *testing.T) {
|
||||
mgr := &mockSessionManager{
|
||||
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
|
||||
return nil, session.ErrSessionNotFound
|
||||
},
|
||||
}
|
||||
r, tm := newConvTestRouter(mgr)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/conversations/nonexistent", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "SESSION_NOT_FOUND")
|
||||
}
|
||||
|
||||
func TestConversationGet_Forbidden(t *testing.T) {
|
||||
mgr := &mockSessionManager{
|
||||
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
|
||||
// 会话属于另一个用户
|
||||
return &models.Session{
|
||||
ID: "sess-1",
|
||||
UserID: "other-user",
|
||||
Title: "他人对话",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
r, tm := newConvTestRouter(mgr)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/conversations/sess-1", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// 返回 404 而非 403,避免信息泄露
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "SESSION_NOT_FOUND")
|
||||
}
|
||||
|
||||
// --- UpdateTitle ---
|
||||
|
||||
func TestConversationUpdateTitle_Success(t *testing.T) {
|
||||
mgr := &mockSessionManager{
|
||||
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
|
||||
return &models.Session{ID: "sess-1", UserID: "user-123"}, nil
|
||||
},
|
||||
UpdateTitleFunc: func(_ context.Context, sessionID, title string) error {
|
||||
assert.Equal(t, "sess-1", sessionID)
|
||||
assert.Equal(t, "新标题", title)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
r, tm := newConvTestRouter(mgr)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
body, _ := json.Marshal(api.UpdateTitleRequest{Title: "新标题"})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/conversations/sess-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "title updated")
|
||||
}
|
||||
|
||||
func TestConversationUpdateTitle_EmptyTitle(t *testing.T) {
|
||||
mgr := &mockSessionManager{
|
||||
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
|
||||
return &models.Session{ID: "sess-1", UserID: "user-123"}, nil
|
||||
},
|
||||
}
|
||||
r, tm := newConvTestRouter(mgr)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
body, _ := json.Marshal(api.UpdateTitleRequest{Title: ""})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/conversations/sess-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "title is required")
|
||||
}
|
||||
|
||||
func TestConversationUpdateTitle_TooLong(t *testing.T) {
|
||||
mgr := &mockSessionManager{
|
||||
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
|
||||
return &models.Session{ID: "sess-1", UserID: "user-123"}, nil
|
||||
},
|
||||
}
|
||||
r, tm := newConvTestRouter(mgr)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
longTitle := ""
|
||||
for i := 0; i < 101; i++ {
|
||||
longTitle += "测"
|
||||
}
|
||||
body, _ := json.Marshal(api.UpdateTitleRequest{Title: longTitle})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/conversations/sess-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "title must be 100 characters or less")
|
||||
}
|
||||
|
||||
// --- Delete ---
|
||||
|
||||
func TestConversationDelete_Success(t *testing.T) {
|
||||
destroyCalled := false
|
||||
mgr := &mockSessionManager{
|
||||
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
|
||||
return &models.Session{ID: "sess-1", UserID: "user-123"}, nil
|
||||
},
|
||||
DestroyFunc: func(_ context.Context, sessionID string) error {
|
||||
assert.Equal(t, "sess-1", sessionID)
|
||||
destroyCalled = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
r, tm := newConvTestRouter(mgr)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/conversations/sess-1", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, w.Code)
|
||||
assert.True(t, destroyCalled)
|
||||
}
|
||||
|
||||
func TestConversationDelete_Forbidden(t *testing.T) {
|
||||
mgr := &mockSessionManager{
|
||||
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
|
||||
return &models.Session{ID: "sess-1", UserID: "other-user"}, nil
|
||||
},
|
||||
}
|
||||
r, tm := newConvTestRouter(mgr)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/conversations/sess-1", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
// --- GetMessages ---
|
||||
|
||||
func TestConversationGetMessages_Success(t *testing.T) {
|
||||
mgr := &mockSessionManager{
|
||||
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
|
||||
return &models.Session{ID: "sess-1", UserID: "user-123"}, nil
|
||||
},
|
||||
GetHistoryFunc: func(_ context.Context, sessionID string, limit int) ([]models.Message, error) {
|
||||
assert.Equal(t, "sess-1", sessionID)
|
||||
assert.Equal(t, 0, limit) // 获取全量
|
||||
return []models.Message{
|
||||
{Role: "user", Content: "你好"},
|
||||
{Role: "assistant", Content: "你好!有什么可以帮助你的吗?"},
|
||||
{Role: "user", Content: "今天天气怎么样?"},
|
||||
{Role: "assistant", Content: "今天天气不错!"},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
r, tm := newConvTestRouter(mgr)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/conversations/sess-1/messages", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, float64(4), resp["total"])
|
||||
msgs := resp["messages"].([]interface{})
|
||||
assert.Len(t, msgs, 4)
|
||||
}
|
||||
|
||||
func TestConversationGetMessages_WithLimit(t *testing.T) {
|
||||
mgr := &mockSessionManager{
|
||||
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
|
||||
return &models.Session{ID: "sess-1", UserID: "user-123"}, nil
|
||||
},
|
||||
GetHistoryFunc: func(_ context.Context, _ string, _ int) ([]models.Message, error) {
|
||||
return []models.Message{
|
||||
{Role: "user", Content: "消息1"},
|
||||
{Role: "assistant", Content: "回复1"},
|
||||
{Role: "user", Content: "消息2"},
|
||||
{Role: "assistant", Content: "回复2"},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
r, tm := newConvTestRouter(mgr)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/conversations/sess-1/messages?limit=2", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
msgs := resp["messages"].([]interface{})
|
||||
assert.Len(t, msgs, 2)
|
||||
}
|
||||
|
||||
func TestConversationGetMessages_WithBefore(t *testing.T) {
|
||||
mgr := &mockSessionManager{
|
||||
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
|
||||
return &models.Session{ID: "sess-1", UserID: "user-123"}, nil
|
||||
},
|
||||
GetHistoryFunc: func(_ context.Context, _ string, _ int) ([]models.Message, error) {
|
||||
return []models.Message{
|
||||
{Role: "user", Content: "消息1"},
|
||||
{Role: "assistant", Content: "回复1"},
|
||||
{Role: "user", Content: "消息2"},
|
||||
{Role: "assistant", Content: "回复2"},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
r, tm := newConvTestRouter(mgr)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/conversations/sess-1/messages?before=2&limit=10", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
// before=2 表示取 index 0..1,共 2 条
|
||||
msgs := resp["messages"].([]interface{})
|
||||
assert.Len(t, msgs, 2)
|
||||
}
|
||||
|
||||
func TestConversationGetMessages_Forbidden(t *testing.T) {
|
||||
mgr := &mockSessionManager{
|
||||
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
|
||||
return &models.Session{ID: "sess-1", UserID: "other-user"}, nil
|
||||
},
|
||||
}
|
||||
r, tm := newConvTestRouter(mgr)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/conversations/sess-1/messages", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
@@ -36,7 +36,7 @@ func (h *SessionHandler) CreateSession(c *gin.Context) {
|
||||
cfg = *req.Config
|
||||
}
|
||||
|
||||
sessionID, err := h.sessionMgr.Create(c.Request.Context(), cfg)
|
||||
sessionID, err := h.sessionMgr.Create(c.Request.Context(), "", cfg)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": "INTERNAL_ERROR",
|
||||
|
||||
111
backend/internal/auth/jwt.go
Normal file
111
backend/internal/auth/jwt.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 自定义错误。
|
||||
var (
|
||||
ErrInvalidToken = errors.New("invalid or expired token")
|
||||
)
|
||||
|
||||
// Claims JWT 声明。
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// TokenManager JWT 令牌管理器。
|
||||
type TokenManager struct {
|
||||
secret []byte
|
||||
accessTTL time.Duration
|
||||
refreshTTL time.Duration
|
||||
}
|
||||
|
||||
// NewTokenManager 创建 TokenManager。
|
||||
// secret: JWT 签名密钥;accessTTL/refreshTTL: 令牌有效期。
|
||||
func NewTokenManager(secret string, accessTTL, refreshTTL time.Duration) *TokenManager {
|
||||
return &TokenManager{
|
||||
secret: []byte(secret),
|
||||
accessTTL: accessTTL,
|
||||
refreshTTL: refreshTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// GeneratePair 生成 access + refresh 令牌对。
|
||||
func (tm *TokenManager) GeneratePair(userID, username string) (access, refresh string, err error) {
|
||||
now := time.Now()
|
||||
|
||||
// access token
|
||||
accessClaims := &Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(tm.accessTTL)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
Issuer: "camtalk",
|
||||
},
|
||||
}
|
||||
accessTkn := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||
access, err = accessTkn.SignedString(tm.secret)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// refresh token(含唯一 token_id 用于 DB 关联)
|
||||
tokenID := uuid.New().String()
|
||||
refreshClaims := &Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: tokenID,
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(tm.refreshTTL)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
Issuer: "camtalk",
|
||||
},
|
||||
}
|
||||
refreshTkn := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
|
||||
refresh, err = refreshTkn.SignedString(tm.secret)
|
||||
return
|
||||
}
|
||||
|
||||
// ValidateAccess 校验 access token 并返回 Claims。
|
||||
func (tm *TokenManager) ValidateAccess(tokenStr string) (*Claims, error) {
|
||||
return tm.validate(tokenStr)
|
||||
}
|
||||
|
||||
// ValidateRefresh 校验 refresh token 并返回 Claims。
|
||||
func (tm *TokenManager) ValidateRefresh(tokenStr string) (*Claims, error) {
|
||||
return tm.validate(tokenStr)
|
||||
}
|
||||
|
||||
// validate 解析并校验 JWT。
|
||||
func (tm *TokenManager) validate(tokenStr string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return tm.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// HashToken 对 token 做 SHA256 哈希,用于 DB 存储。
|
||||
func HashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
127
backend/internal/auth/jwt_test.go
Normal file
127
backend/internal/auth/jwt_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGeneratePair_ReturnsNonEmptyTokens(t *testing.T) {
|
||||
tm := NewTokenManager("test-secret-key", 15*time.Minute, 7*24*time.Hour)
|
||||
|
||||
access, refresh, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, access)
|
||||
assert.NotEmpty(t, refresh)
|
||||
assert.NotEqual(t, access, refresh)
|
||||
}
|
||||
|
||||
func TestValidateAccess_ValidToken(t *testing.T) {
|
||||
tm := NewTokenManager("test-secret-key", 15*time.Minute, 7*24*time.Hour)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
claims, err := tm.ValidateAccess(access)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user-123", claims.UserID)
|
||||
assert.Equal(t, "alice", claims.Username)
|
||||
assert.Equal(t, "camtalk", claims.Issuer)
|
||||
}
|
||||
|
||||
func TestValidateRefresh_ValidToken(t *testing.T) {
|
||||
tm := NewTokenManager("test-secret-key", 15*time.Minute, 7*24*time.Hour)
|
||||
|
||||
_, refresh, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
claims, err := tm.ValidateRefresh(refresh)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user-123", claims.UserID)
|
||||
assert.Equal(t, "alice", claims.Username)
|
||||
assert.NotEmpty(t, claims.ID) // refresh token 应含唯一 ID
|
||||
}
|
||||
|
||||
func TestValidateAccess_ExpiredToken(t *testing.T) {
|
||||
// 使用极短的 TTL
|
||||
tm := NewTokenManager("test-secret-key", -1*time.Second, -1*time.Second)
|
||||
|
||||
access, _, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tm.ValidateAccess(access)
|
||||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||||
}
|
||||
|
||||
func TestValidateAccess_WrongSecret(t *testing.T) {
|
||||
tm1 := NewTokenManager("secret-1", 15*time.Minute, 7*24*time.Hour)
|
||||
tm2 := NewTokenManager("secret-2", 15*time.Minute, 7*24*time.Hour)
|
||||
|
||||
access, _, err := tm1.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tm2.ValidateAccess(access)
|
||||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||||
}
|
||||
|
||||
func TestValidateAccess_InvalidFormat(t *testing.T) {
|
||||
tm := NewTokenManager("test-secret-key", 15*time.Minute, 7*24*time.Hour)
|
||||
|
||||
_, err := tm.ValidateAccess("not-a-valid-token")
|
||||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||||
}
|
||||
|
||||
func TestValidateAccess_EmptyString(t *testing.T) {
|
||||
tm := NewTokenManager("test-secret-key", 15*time.Minute, 7*24*time.Hour)
|
||||
|
||||
_, err := tm.ValidateAccess("")
|
||||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||||
}
|
||||
|
||||
func TestHashToken_Deterministic(t *testing.T) {
|
||||
hash1 := HashToken("some-token-value")
|
||||
hash2 := HashToken("some-token-value")
|
||||
assert.Equal(t, hash1, hash2)
|
||||
assert.Len(t, hash1, 64) // SHA256 hex = 64 chars
|
||||
}
|
||||
|
||||
func TestHashToken_DifferentInputsDifferentHashes(t *testing.T) {
|
||||
hash1 := HashToken("token-a")
|
||||
hash2 := HashToken("token-b")
|
||||
assert.NotEqual(t, hash1, hash2)
|
||||
}
|
||||
|
||||
func TestValidateRefresh_ExpiredToken(t *testing.T) {
|
||||
tm := NewTokenManager("test-secret-key", -1*time.Minute, -1*time.Minute)
|
||||
|
||||
_, refresh, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tm.ValidateRefresh(refresh)
|
||||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||||
}
|
||||
|
||||
func TestGeneratePair_TokenClaimsContainCorrectExpiry(t *testing.T) {
|
||||
accessTTL := 15 * time.Minute
|
||||
refreshTTL := 7 * 24 * time.Hour
|
||||
tm := NewTokenManager("test-secret-key", accessTTL, refreshTTL)
|
||||
|
||||
before := time.Now()
|
||||
access, refresh, err := tm.GeneratePair("user-123", "alice")
|
||||
require.NoError(t, err)
|
||||
after := time.Now()
|
||||
|
||||
// 校验 access token 有效期范围
|
||||
accessClaims, err := tm.ValidateAccess(access)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, accessClaims.ExpiresAt.Time.After(before.Add(accessTTL).Add(-1*time.Second)))
|
||||
assert.True(t, accessClaims.ExpiresAt.Time.Before(after.Add(accessTTL).Add(1*time.Second)))
|
||||
|
||||
// 校验 refresh token 有效期范围
|
||||
refreshClaims, err := tm.ValidateRefresh(refresh)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, refreshClaims.ExpiresAt.Time.After(before.Add(refreshTTL).Add(-1*time.Second)))
|
||||
assert.True(t, refreshClaims.ExpiresAt.Time.Before(after.Add(refreshTTL).Add(1*time.Second)))
|
||||
}
|
||||
54
backend/internal/auth/middleware.go
Normal file
54
backend/internal/auth/middleware.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// contextKey 用于在 Gin context 中存储 Claims 的 key。
|
||||
const (
|
||||
ContextKeyUserID = "user_id"
|
||||
ContextKeyUsername = "username"
|
||||
)
|
||||
|
||||
// AuthMiddleware 返回 Gin 中间件,从 Authorization: Bearer <token> 提取并校验 JWT。
|
||||
// 校验成功后将 user_id 和 username 写入 Gin Context。
|
||||
func AuthMiddleware(tokenMgr *TokenManager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"code": "INVALID_TOKEN",
|
||||
"message": "missing authorization header",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 提取 Bearer token
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"code": "INVALID_TOKEN",
|
||||
"message": "invalid authorization format",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := tokenMgr.ValidateAccess(parts[1])
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"code": "INVALID_TOKEN",
|
||||
"message": "invalid or expired token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户信息写入 context
|
||||
c.Set(ContextKeyUserID, claims.UserID)
|
||||
c.Set(ContextKeyUsername, claims.Username)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
19
backend/internal/auth/password.go
Normal file
19
backend/internal/auth/password.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package auth
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
const bcryptCost = 10
|
||||
|
||||
// HashPassword 使用 bcrypt 对密码进行哈希。
|
||||
func HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// CheckPassword 校验密码与哈希是否匹配。
|
||||
func CheckPassword(hashedPassword, password string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
||||
}
|
||||
221
backend/internal/auth/service.go
Normal file
221
backend/internal/auth/service.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/hhs/camtalk/internal/store"
|
||||
)
|
||||
|
||||
// 自定义业务错误。
|
||||
var (
|
||||
ErrUsernameTaken = errors.New("username already taken")
|
||||
ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
ErrRefreshTokenUsed = errors.New("refresh token has been used or expired")
|
||||
)
|
||||
|
||||
// RegisterRequest 注册请求。
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// LoginRequest 登录请求。
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// RefreshRequest 刷新令牌请求。
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// AuthResponse 认证响应。
|
||||
type AuthResponse struct {
|
||||
User UserResponse `json:"user"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// UserResponse 用户信息响应。
|
||||
type UserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Service 认证业务接口。
|
||||
type Service interface {
|
||||
Register(ctx context.Context, req RegisterRequest) (*AuthResponse, error)
|
||||
Login(ctx context.Context, req LoginRequest) (*AuthResponse, error)
|
||||
Refresh(ctx context.Context, req RefreshRequest) (*AuthResponse, error)
|
||||
Logout(ctx context.Context, userID, refreshToken string) error
|
||||
}
|
||||
|
||||
// authService 认证服务实现。
|
||||
type authService struct {
|
||||
tokenMgr *TokenManager
|
||||
userRepo store.UserRepository
|
||||
}
|
||||
|
||||
// NewAuthService 创建认证服务。
|
||||
func NewAuthService(tokenMgr *TokenManager, userRepo store.UserRepository) Service {
|
||||
return &authService{
|
||||
tokenMgr: tokenMgr,
|
||||
userRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// Register 用户注册。
|
||||
func (s *authService) Register(ctx context.Context, req RegisterRequest) (*AuthResponse, error) {
|
||||
// 检查用户名是否已存在
|
||||
_, err := s.userRepo.FindByUsername(ctx, req.Username)
|
||||
if err == nil {
|
||||
return nil, ErrUsernameTaken
|
||||
}
|
||||
if !errors.Is(err, store.ErrUserNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 哈希密码
|
||||
hash, err := HashPassword(req.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
userID, err := s.userRepo.Create(ctx, req.Username, hash)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrUsernameTaken) {
|
||||
return nil, ErrUsernameTaken
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 生成令牌对
|
||||
access, refresh, err := s.tokenMgr.GeneratePair(userID, req.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 保存 refresh token hash 到 DB
|
||||
if err := s.saveRefreshToken(ctx, userID, refresh); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AuthResponse{
|
||||
User: UserResponse{
|
||||
ID: userID,
|
||||
Username: req.Username,
|
||||
},
|
||||
AccessToken: access,
|
||||
RefreshToken: refresh,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Login 用户登录。
|
||||
func (s *authService) Login(ctx context.Context, req LoginRequest) (*AuthResponse, error) {
|
||||
user, err := s.userRepo.FindByUsername(ctx, req.Username)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrUserNotFound) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 校验密码
|
||||
if err := CheckPassword(user.PasswordHash, req.Password); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// 生成令牌对
|
||||
access, refresh, err := s.tokenMgr.GeneratePair(user.ID, user.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 保存 refresh token hash
|
||||
if err := s.saveRefreshToken(ctx, user.ID, refresh); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AuthResponse{
|
||||
User: UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
CreatedAt: user.CreatedAt,
|
||||
},
|
||||
AccessToken: access,
|
||||
RefreshToken: refresh,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Refresh 刷新令牌(Refresh Token Rotation)。
|
||||
func (s *authService) Refresh(ctx context.Context, req RefreshRequest) (*AuthResponse, error) {
|
||||
// 校验 refresh token
|
||||
claims, err := s.tokenMgr.ValidateRefresh(req.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, ErrRefreshTokenUsed
|
||||
}
|
||||
|
||||
tokenHash := HashToken(req.RefreshToken)
|
||||
|
||||
// 查找 DB 中的 token hash,确认未被使用
|
||||
userID, err := s.userRepo.FindRefreshToken(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrRefreshTokenNotFound) {
|
||||
return nil, ErrRefreshTokenUsed
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 确认 token 归属的用户与 claims 一致
|
||||
if userID != claims.UserID {
|
||||
return nil, ErrRefreshTokenUsed
|
||||
}
|
||||
|
||||
// 删除旧 refresh token(rotation)
|
||||
_ = s.userRepo.DeleteRefreshToken(ctx, tokenHash)
|
||||
|
||||
// 生成新的令牌对
|
||||
access, refresh, err := s.tokenMgr.GeneratePair(claims.UserID, claims.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 保存新 refresh token
|
||||
if err := s.saveRefreshToken(ctx, claims.UserID, refresh); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 查用户信息
|
||||
user, err := s.userRepo.FindByID(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AuthResponse{
|
||||
User: UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
CreatedAt: user.CreatedAt,
|
||||
},
|
||||
AccessToken: access,
|
||||
RefreshToken: refresh,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Logout 登出,删除 refresh token。
|
||||
func (s *authService) Logout(ctx context.Context, userID, refreshToken string) error {
|
||||
tokenHash := HashToken(refreshToken)
|
||||
return s.userRepo.DeleteRefreshToken(ctx, tokenHash)
|
||||
}
|
||||
|
||||
// saveRefreshToken 将 refresh token 的 hash 保存到 DB。
|
||||
func (s *authService) saveRefreshToken(ctx context.Context, userID, refreshToken string) error {
|
||||
tokenHash := HashToken(refreshToken)
|
||||
expiresAt := time.Now().Add(s.tokenMgr.refreshTTL)
|
||||
return s.userRepo.SaveRefreshToken(ctx, userID, tokenHash, expiresAt)
|
||||
}
|
||||
190
backend/internal/auth/service_test.go
Normal file
190
backend/internal/auth/service_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/hhs/camtalk/internal/auth"
|
||||
"github.com/hhs/camtalk/internal/store"
|
||||
)
|
||||
|
||||
// newTestService 创建测试用的 AuthService + MemUserRepository。
|
||||
func newTestService(t *testing.T) (auth.Service, *store.MemUserRepository) {
|
||||
t.Helper()
|
||||
tm := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour)
|
||||
repo := store.NewMemUserRepository()
|
||||
svc := auth.NewAuthService(tm, repo)
|
||||
return svc, repo
|
||||
}
|
||||
|
||||
// --- Register ---
|
||||
|
||||
func TestRegister_Success(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
resp, err := svc.Register(ctx, auth.RegisterRequest{
|
||||
Username: "alice",
|
||||
Password: "password123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, resp.User.ID)
|
||||
assert.Equal(t, "alice", resp.User.Username)
|
||||
assert.NotEmpty(t, resp.AccessToken)
|
||||
assert.NotEmpty(t, resp.RefreshToken)
|
||||
}
|
||||
|
||||
func TestRegister_DuplicateUsername(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Register(ctx, auth.RegisterRequest{
|
||||
Username: "alice",
|
||||
Password: "password123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 同名再次注册
|
||||
_, err = svc.Register(ctx, auth.RegisterRequest{
|
||||
Username: "alice",
|
||||
Password: "another-password",
|
||||
})
|
||||
assert.ErrorIs(t, err, auth.ErrUsernameTaken)
|
||||
}
|
||||
|
||||
// --- Login ---
|
||||
|
||||
func TestLogin_Success(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 先注册
|
||||
_, err := svc.Register(ctx, auth.RegisterRequest{
|
||||
Username: "bob",
|
||||
Password: "password123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 登录
|
||||
resp, err := svc.Login(ctx, auth.LoginRequest{
|
||||
Username: "bob",
|
||||
Password: "password123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "bob", resp.User.Username)
|
||||
assert.NotEmpty(t, resp.AccessToken)
|
||||
assert.NotEmpty(t, resp.RefreshToken)
|
||||
}
|
||||
|
||||
func TestLogin_WrongPassword(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Register(ctx, auth.RegisterRequest{
|
||||
Username: "bob",
|
||||
Password: "password123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.Login(ctx, auth.LoginRequest{
|
||||
Username: "bob",
|
||||
Password: "wrong-password",
|
||||
})
|
||||
assert.ErrorIs(t, err, auth.ErrInvalidCredentials)
|
||||
}
|
||||
|
||||
func TestLogin_UserNotFound(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Login(ctx, auth.LoginRequest{
|
||||
Username: "nonexistent",
|
||||
Password: "password123",
|
||||
})
|
||||
assert.ErrorIs(t, err, auth.ErrInvalidCredentials)
|
||||
}
|
||||
|
||||
// --- Refresh ---
|
||||
|
||||
func TestRefresh_Success(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 注册
|
||||
regResp, err := svc.Register(ctx, auth.RegisterRequest{
|
||||
Username: "charlie",
|
||||
Password: "password123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 刷新
|
||||
refreshResp, err := svc.Refresh(ctx, auth.RefreshRequest{
|
||||
RefreshToken: regResp.RefreshToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "charlie", refreshResp.User.Username)
|
||||
assert.NotEmpty(t, refreshResp.AccessToken)
|
||||
assert.NotEmpty(t, refreshResp.RefreshToken)
|
||||
// 新旧 refresh token 应不同(rotation)
|
||||
assert.NotEqual(t, regResp.RefreshToken, refreshResp.RefreshToken)
|
||||
}
|
||||
|
||||
func TestRefresh_UsedTokenFails(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
regResp, err := svc.Register(ctx, auth.RegisterRequest{
|
||||
Username: "charlie",
|
||||
Password: "password123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 第一次刷新
|
||||
_, err = svc.Refresh(ctx, auth.RefreshRequest{
|
||||
RefreshToken: regResp.RefreshToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 用旧 token 再次刷新 → 应失败
|
||||
_, err = svc.Refresh(ctx, auth.RefreshRequest{
|
||||
RefreshToken: regResp.RefreshToken,
|
||||
})
|
||||
assert.ErrorIs(t, err, auth.ErrRefreshTokenUsed)
|
||||
}
|
||||
|
||||
func TestRefresh_InvalidToken(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Refresh(ctx, auth.RefreshRequest{
|
||||
RefreshToken: "completely-invalid-token",
|
||||
})
|
||||
assert.ErrorIs(t, err, auth.ErrRefreshTokenUsed)
|
||||
}
|
||||
|
||||
// --- Logout ---
|
||||
|
||||
func TestLogout_Success(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
regResp, err := svc.Register(ctx, auth.RegisterRequest{
|
||||
Username: "dave",
|
||||
Password: "password123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 登出
|
||||
err = svc.Logout(ctx, regResp.User.ID, regResp.RefreshToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 登出后 refresh token 应失效
|
||||
_, err = svc.Refresh(ctx, auth.RefreshRequest{
|
||||
RefreshToken: regResp.RefreshToken,
|
||||
})
|
||||
assert.ErrorIs(t, err, auth.ErrRefreshTokenUsed)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type Config struct {
|
||||
AI AIConfig `mapstructure:"ai"`
|
||||
Storage StorageConfig `mapstructure:"storage"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
Auth AuthConfig `mapstructure:"auth"`
|
||||
}
|
||||
|
||||
// SessionConfig 会话管理配置。
|
||||
@@ -99,6 +100,13 @@ type LogConfig struct {
|
||||
Format string `mapstructure:"format"`
|
||||
}
|
||||
|
||||
// AuthConfig 认证配置。
|
||||
type AuthConfig struct {
|
||||
JWTSecret string `mapstructure:"jwt_secret"` // JWT 签名密钥,必须通过环境变量 CAMTALK_AUTH_JWT_SECRET 设置
|
||||
AccessTTL int `mapstructure:"access_ttl"` // Access Token 过期时间(分钟),默认 15
|
||||
RefreshTTL int `mapstructure:"refresh_ttl"` // Refresh Token 过期时间(分钟),默认 10080(7天)
|
||||
}
|
||||
|
||||
// Load 加载配置。优先级:环境变量 > config.{env}.yaml > config.yaml。
|
||||
func Load() (*Config, error) {
|
||||
v := viper.New()
|
||||
@@ -146,6 +154,8 @@ func Load() (*Config, error) {
|
||||
v.SetDefault("storage.driver", "memory")
|
||||
v.SetDefault("log.level", "info")
|
||||
v.SetDefault("log.format", "console")
|
||||
v.SetDefault("auth.access_ttl", 15)
|
||||
v.SetDefault("auth.refresh_ttl", 10080)
|
||||
|
||||
// 读取基础配置文件
|
||||
_ = v.ReadInConfig() // 文件不存在不报错
|
||||
|
||||
@@ -14,6 +14,12 @@ const (
|
||||
CodeSTTError = "STT_ERROR"
|
||||
CodeTTSError = "TTS_ERROR"
|
||||
CodeInternalError = "INTERNAL_ERROR"
|
||||
|
||||
// 认证相关错误码
|
||||
CodeUsernameTaken = "USERNAME_TAKEN"
|
||||
CodeInvalidCredentials = "INVALID_CREDENTIALS"
|
||||
CodeInvalidToken = "INVALID_TOKEN"
|
||||
CodeInvalidInput = "INVALID_INPUT"
|
||||
)
|
||||
|
||||
// Sender 定义发送 WS 错误消息的接口,便于测试 mock。
|
||||
|
||||
@@ -5,7 +5,10 @@ import "time"
|
||||
// Session 会话。
|
||||
type Session struct {
|
||||
ID string `json:"session_id"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Config SessionConfig `json:"config"`
|
||||
}
|
||||
|
||||
@@ -16,6 +19,9 @@ type SessionConfig struct {
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
// DefaultSessionTitle 默认会话标题。
|
||||
const DefaultSessionTitle = "新对话"
|
||||
|
||||
// DefaultConfig 默认会话配置。
|
||||
func DefaultConfig() SessionConfig {
|
||||
return SessionConfig{TTSEnabled: true, DetailLevel: "low", Language: "zh-CN"}
|
||||
@@ -41,6 +47,15 @@ func (p SessionConfigPatch) Apply(cfg *SessionConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// User 用户。
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Message 对话消息。
|
||||
type Message struct {
|
||||
Role string `json:"role"` // "user" | "assistant"
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/hhs/camtalk/internal/config"
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/session"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -63,11 +64,21 @@ type MockSessionManager struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockSessionManager) Create(ctx context.Context, config models.SessionConfig) (string, error) {
|
||||
args := m.Called(ctx, config)
|
||||
func (m *MockSessionManager) Create(ctx context.Context, userID string, config models.SessionConfig) (string, error) {
|
||||
args := m.Called(ctx, userID, config)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockSessionManager) UpdateTitle(ctx context.Context, sessionID string, title string) error {
|
||||
args := m.Called(ctx, sessionID, title)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockSessionManager) ListByUser(ctx context.Context, userID string, page, size int) ([]session.ConversationSummary, int, error) {
|
||||
args := m.Called(ctx, userID, page, size)
|
||||
return args.Get(0).([]session.ConversationSummary), args.Int(1), args.Error(2)
|
||||
}
|
||||
|
||||
func (m *MockSessionManager) Get(ctx context.Context, sessionID string) (*models.Session, error) {
|
||||
args := m.Called(ctx, sessionID)
|
||||
if args.Get(0) == nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ package session
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
)
|
||||
@@ -11,11 +12,20 @@ import (
|
||||
// ErrSessionNotFound 会话不存在或已过期。
|
||||
var ErrSessionNotFound = errors.New("session not found")
|
||||
|
||||
// ConversationSummary 对话摘要(列表展示用)。
|
||||
type ConversationSummary struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
LastMessage string `json:"last_message"`
|
||||
MessageCount int `json:"message_count"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Manager 会话管理器接口。
|
||||
// WebSocket Handler 通过此接口操作会话,不直接接触存储层。
|
||||
type Manager interface {
|
||||
// Create 创建新会话,返回 session ID。
|
||||
Create(ctx context.Context, config models.SessionConfig) (string, error)
|
||||
// Create 创建新会话,返回 session ID。userID 为空表示匿名会话。
|
||||
Create(ctx context.Context, userID string, config models.SessionConfig) (string, error)
|
||||
|
||||
// Get 获取会话(含 config)。不存在返回 ErrSessionNotFound。
|
||||
Get(ctx context.Context, sessionID string) (*models.Session, error)
|
||||
@@ -23,6 +33,12 @@ type Manager interface {
|
||||
// UpdateConfig 更新会话配置(config 消息触发)。
|
||||
UpdateConfig(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error
|
||||
|
||||
// UpdateTitle 更新会话标题。
|
||||
UpdateTitle(ctx context.Context, sessionID string, title string) error
|
||||
|
||||
// ListByUser 获取用户的对话列表(分页,按 UpdatedAt 降序)。
|
||||
ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error)
|
||||
|
||||
// GetHistory 获取最近 N 轮对话历史(供 Orchestrator 构建 LLM 上下文)。
|
||||
GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error)
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -9,6 +11,7 @@ import (
|
||||
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -32,11 +35,31 @@ type MemoryManager struct {
|
||||
ttl time.Duration
|
||||
maxHistory int
|
||||
stopCleaner chan struct{}
|
||||
msgRepo store.MessageRepository // 可选,消息持久化(Write-Through)
|
||||
sessRepo store.SessionRepository // 可选,会话持久化(Write-Through)
|
||||
}
|
||||
|
||||
// Option MemoryManager 的函数式选项。
|
||||
type Option func(*MemoryManager)
|
||||
|
||||
// WithMessageRepository 注入消息持久化仓库,启用 Write-Through 模式。
|
||||
func WithMessageRepository(repo store.MessageRepository) Option {
|
||||
return func(m *MemoryManager) {
|
||||
m.msgRepo = repo
|
||||
}
|
||||
}
|
||||
|
||||
// WithSessionRepository 注入会话持久化仓库,启用会话元数据 Write-Through 模式。
|
||||
func WithSessionRepository(repo store.SessionRepository) Option {
|
||||
return func(m *MemoryManager) {
|
||||
m.sessRepo = repo
|
||||
}
|
||||
}
|
||||
|
||||
// NewMemoryManager 创建内存版 SessionManager。
|
||||
// ttl 为会话过期时间,maxHistory 为对话历史上限(0 表示使用默认值 20)。
|
||||
func NewMemoryManager(ttl time.Duration, maxHistory int) *MemoryManager {
|
||||
// opts 为可选配置,如 WithMessageRepository 启用消息持久化。
|
||||
func NewMemoryManager(ttl time.Duration, maxHistory int, opts ...Option) *MemoryManager {
|
||||
if ttl <= 0 {
|
||||
ttl = defaultTTL
|
||||
}
|
||||
@@ -51,6 +74,10 @@ func NewMemoryManager(ttl time.Duration, maxHistory int) *MemoryManager {
|
||||
stopCleaner: make(chan struct{}),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(m)
|
||||
}
|
||||
|
||||
// 启动后台清理 goroutine,每分钟清除过期会话。
|
||||
go m.cleanLoop()
|
||||
|
||||
@@ -95,58 +122,244 @@ func (m *MemoryManager) isExpired(entry *sessionEntry) bool {
|
||||
return time.Since(entry.lastActive) > m.ttl
|
||||
}
|
||||
|
||||
// Create 创建新会话。
|
||||
func (m *MemoryManager) Create(_ context.Context, config models.SessionConfig) (string, error) {
|
||||
// Create 创建新会话。userID 为空表示匿名会话。
|
||||
func (m *MemoryManager) Create(ctx context.Context, userID string, config models.SessionConfig) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
id := uuid.New().String()
|
||||
now := time.Now()
|
||||
m.sessions[id] = &sessionEntry{
|
||||
session: models.Session{
|
||||
ID: id,
|
||||
UserID: userID,
|
||||
Title: models.DefaultSessionTitle,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Config: config,
|
||||
},
|
||||
history: make([]models.Message, 0),
|
||||
lastActive: now,
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
logger.Log.Debugw("session created", "session", id)
|
||||
// Write-Through:异步写 PG
|
||||
if m.sessRepo != nil {
|
||||
go func() {
|
||||
cfgJSON, _ := json.Marshal(config)
|
||||
if err := m.sessRepo.Save(ctx, store.SessionRecord{
|
||||
ID: id, UserID: userID, Title: models.DefaultSessionTitle,
|
||||
Config: cfgJSON, CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
logger.Log.Warnw("persist session failed", "session", id, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
logger.Log.Debugw("session created", "session", id, "user_id", userID)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Get 获取会话。
|
||||
func (m *MemoryManager) Get(_ context.Context, sessionID string) (*models.Session, error) {
|
||||
// Get 获取会话。内存中不存在时,尝试从 PG 加载(透明恢复)。
|
||||
func (m *MemoryManager) Get(ctx context.Context, sessionID string) (*models.Session, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
entry, ok := m.sessions[sessionID]
|
||||
if !ok || m.isExpired(entry) {
|
||||
return nil, ErrSessionNotFound
|
||||
if ok && !m.isExpired(entry) {
|
||||
sess := entry.session
|
||||
m.mu.RUnlock()
|
||||
return &sess, nil
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
// 内存未命中,尝试从 PG 加载
|
||||
if m.sessRepo != nil {
|
||||
rec, err := m.sessRepo.FindByID(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
sess := m.recordToSession(rec)
|
||||
// 加载到内存(含消息历史)
|
||||
if m.msgRepo != nil {
|
||||
_ = m.LoadSessionFromRepo(ctx, sess)
|
||||
} else {
|
||||
_ = m.LoadSession(sess, nil)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
sess := entry.session // 复制一份返回
|
||||
return &sess, nil
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
|
||||
// UpdateConfig 更新会话配置。
|
||||
func (m *MemoryManager) UpdateConfig(_ context.Context, sessionID string, patch models.SessionConfigPatch) error {
|
||||
func (m *MemoryManager) UpdateConfig(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
entry, ok := m.sessions[sessionID]
|
||||
if !ok || m.isExpired(entry) {
|
||||
m.mu.Unlock()
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
patch.Apply(&entry.session.Config)
|
||||
entry.lastActive = time.Now()
|
||||
cfg := entry.session.Config
|
||||
m.mu.Unlock()
|
||||
|
||||
// Write-Through:异步更新 PG
|
||||
if m.sessRepo != nil {
|
||||
go func() {
|
||||
cfgJSON, _ := json.Marshal(cfg)
|
||||
if err := m.sessRepo.UpdateConfig(ctx, sessionID, cfgJSON); err != nil {
|
||||
logger.Log.Warnw("update session config in DB failed", "session", sessionID, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
logger.Log.Debugw("session config updated", "session", sessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateTitle 更新会话标题。
|
||||
func (m *MemoryManager) UpdateTitle(ctx context.Context, sessionID string, title string) error {
|
||||
m.mu.Lock()
|
||||
|
||||
entry, ok := m.sessions[sessionID]
|
||||
if !ok || m.isExpired(entry) {
|
||||
m.mu.Unlock()
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
entry.session.Title = title
|
||||
entry.session.UpdatedAt = time.Now()
|
||||
entry.lastActive = time.Now()
|
||||
m.mu.Unlock()
|
||||
|
||||
// Write-Through:异步更新 PG
|
||||
if m.sessRepo != nil {
|
||||
go func() {
|
||||
if err := m.sessRepo.UpdateTitle(ctx, sessionID, title); err != nil {
|
||||
logger.Log.Warnw("update session title in DB failed", "session", sessionID, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
logger.Log.Debugw("session title updated", "session", sessionID, "title", title)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByUser 获取用户的对话列表(分页,按 UpdatedAt 降序)。
|
||||
// 若配置了 SessionRepository,从 PG 查询(包含内存中已过期的会话)。
|
||||
// 若配置了 MessageRepository,消息统计从 PostgreSQL 聚合查询(更准确)。
|
||||
func (m *MemoryManager) ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 {
|
||||
size = 20
|
||||
}
|
||||
|
||||
// 优先从 PG 查询会话列表(包含已过期的会话)
|
||||
if m.sessRepo != nil {
|
||||
recs, total, err := m.sessRepo.FindByUser(ctx, userID, page, size)
|
||||
if err != nil {
|
||||
logger.Log.Warnw("list sessions from DB failed, falling back to in-memory", "error", err)
|
||||
return m.listByUserFromMemory(ctx, userID, page, size)
|
||||
}
|
||||
|
||||
list := make([]ConversationSummary, 0, len(recs))
|
||||
var sessionIDs []string
|
||||
for _, rec := range recs {
|
||||
list = append(list, ConversationSummary{
|
||||
ID: rec.ID,
|
||||
Title: rec.Title,
|
||||
UpdatedAt: rec.UpdatedAt,
|
||||
})
|
||||
sessionIDs = append(sessionIDs, rec.ID)
|
||||
}
|
||||
|
||||
// 用内存中的消息数填充
|
||||
m.mu.RLock()
|
||||
for i := range list {
|
||||
if entry, ok := m.sessions[list[i].ID]; ok {
|
||||
list[i].MessageCount = len(entry.history)
|
||||
if len(entry.history) > 0 {
|
||||
list[i].LastMessage = entry.history[len(entry.history)-1].Content
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
// 从 PG 获取更准确的消息统计
|
||||
if m.msgRepo != nil && len(sessionIDs) > 0 {
|
||||
if stats, err := m.msgRepo.GetSessionMessageStats(ctx, sessionIDs); err == nil {
|
||||
for i := range list {
|
||||
if s, ok := stats[list[i].ID]; ok {
|
||||
list[i].LastMessage = s.LastMessage
|
||||
list[i].MessageCount = s.MessageCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// fallback:纯内存查询
|
||||
return m.listByUserFromMemory(ctx, userID, page, size)
|
||||
}
|
||||
|
||||
// listByUserFromMemory 从内存中获取用户的对话列表(无 PG 时的 fallback)。
|
||||
func (m *MemoryManager) listByUserFromMemory(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error) {
|
||||
m.mu.RLock()
|
||||
|
||||
var list []ConversationSummary
|
||||
var sessionIDs []string
|
||||
for _, entry := range m.sessions {
|
||||
if entry.session.UserID != userID || m.isExpired(entry) {
|
||||
continue
|
||||
}
|
||||
summary := ConversationSummary{
|
||||
ID: entry.session.ID,
|
||||
Title: entry.session.Title,
|
||||
UpdatedAt: entry.lastActive,
|
||||
}
|
||||
summary.MessageCount = len(entry.history)
|
||||
if len(entry.history) > 0 {
|
||||
summary.LastMessage = entry.history[len(entry.history)-1].Content
|
||||
}
|
||||
list = append(list, summary)
|
||||
sessionIDs = append(sessionIDs, entry.session.ID)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
// 从 PG 获取更准确的消息统计
|
||||
if m.msgRepo != nil && len(sessionIDs) > 0 {
|
||||
if stats, err := m.msgRepo.GetSessionMessageStats(ctx, sessionIDs); err == nil {
|
||||
for i := range list {
|
||||
if s, ok := stats[list[i].ID]; ok {
|
||||
list[i].LastMessage = s.LastMessage
|
||||
list[i].MessageCount = s.MessageCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
return list[i].UpdatedAt.After(list[j].UpdatedAt)
|
||||
})
|
||||
|
||||
total := len(list)
|
||||
start := (page - 1) * size
|
||||
if start >= total {
|
||||
return []ConversationSummary{}, total, nil
|
||||
}
|
||||
end := start + size
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
|
||||
return list[start:end], total, nil
|
||||
}
|
||||
|
||||
// GetHistory 获取最近 N 轮对话历史。
|
||||
func (m *MemoryManager) GetHistory(_ context.Context, sessionID string, limit int) ([]models.Message, error) {
|
||||
m.mu.RLock()
|
||||
@@ -168,26 +381,96 @@ func (m *MemoryManager) GetHistory(_ context.Context, sessionID string, limit in
|
||||
}
|
||||
|
||||
// AppendMessage 追加一条对话消息,同时刷新 TTL。
|
||||
// 若配置了 MessageRepository,消息会异步写入 PostgreSQL(Write-Through)。
|
||||
func (m *MemoryManager) AppendMessage(_ context.Context, sessionID string, msg models.Message) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
entry, ok := m.sessions[sessionID]
|
||||
if !ok || m.isExpired(entry) {
|
||||
m.mu.Unlock()
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
entry.history = append(entry.history, msg)
|
||||
|
||||
// 自动更新标题:首条 user 消息时,如果标题为默认值,自动更新为消息前 20 字符
|
||||
if msg.Role == "user" && entry.session.Title == models.DefaultSessionTitle {
|
||||
entry.session.Title = generateTitle(msg.Content)
|
||||
}
|
||||
|
||||
// 超过上限时裁剪,保留最新的 maxHistory 条
|
||||
if len(entry.history) > m.maxHistory {
|
||||
entry.history = entry.history[len(entry.history)-m.maxHistory:]
|
||||
}
|
||||
|
||||
entry.lastActive = time.Now()
|
||||
now := time.Now()
|
||||
entry.lastActive = now
|
||||
entry.session.UpdatedAt = now
|
||||
m.mu.Unlock()
|
||||
|
||||
// Write-Through:异步写冷存储,不阻塞调用方
|
||||
if m.msgRepo != nil {
|
||||
go func() {
|
||||
if err := m.msgRepo.SaveMessage(context.Background(), sessionID, msg, 0); err != nil {
|
||||
logger.Log.Warnw("persist message failed", "session", sessionID, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateTitle 从首条消息生成对话标题(取前 20 个字符)。
|
||||
func generateTitle(firstMessage string) string {
|
||||
runes := []rune(firstMessage)
|
||||
if len(runes) > 20 {
|
||||
return string(runes[:20]) + "…"
|
||||
}
|
||||
return firstMessage
|
||||
}
|
||||
|
||||
// LoadSession 从外部存储加载会话到内存热存储。
|
||||
// 用于 conversation_id 恢复场景:WS 连接时会话不在内存中,从 PostgreSQL 加载。
|
||||
// 若会话已在内存中,返回 nil(幂等)。
|
||||
func (m *MemoryManager) LoadSession(sess *models.Session, messages []models.Message) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, ok := m.sessions[sess.ID]; ok {
|
||||
return nil // 已在内存中,无需重复加载
|
||||
}
|
||||
|
||||
m.sessions[sess.ID] = &sessionEntry{
|
||||
session: *sess,
|
||||
history: messages,
|
||||
lastActive: time.Now(),
|
||||
}
|
||||
|
||||
logger.Log.Debugw("session loaded from DB", "session", sess.ID, "messages", len(messages))
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadSessionFromRepo 从 MessageRepository 加载会话消息并注册到内存。
|
||||
// 适用于已注入 MessageRepository 的场景,调用方只需传入 session 元数据。
|
||||
func (m *MemoryManager) LoadSessionFromRepo(ctx context.Context, sess *models.Session) error {
|
||||
if m.msgRepo == nil {
|
||||
return m.LoadSession(sess, nil)
|
||||
}
|
||||
|
||||
// 从冷存储加载全部消息(limit=0 表示全量)
|
||||
stored, err := m.msgRepo.GetMessages(ctx, sess.ID, 0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
messages := make([]models.Message, len(stored))
|
||||
for i, s := range stored {
|
||||
messages[i] = models.Message{Role: s.Role, Content: s.Content}
|
||||
}
|
||||
|
||||
return m.LoadSession(sess, messages)
|
||||
}
|
||||
|
||||
// SetActiveRequest 标记当前正在处理的请求 ID。
|
||||
func (m *MemoryManager) SetActiveRequest(_ context.Context, sessionID string, requestID string) error {
|
||||
m.mu.Lock()
|
||||
@@ -246,15 +529,26 @@ func (m *MemoryManager) Touch(_ context.Context, sessionID string) error {
|
||||
}
|
||||
|
||||
// Destroy 显式销毁会话。
|
||||
func (m *MemoryManager) Destroy(_ context.Context, sessionID string) error {
|
||||
func (m *MemoryManager) Destroy(ctx context.Context, sessionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, ok := m.sessions[sessionID]; !ok {
|
||||
m.mu.Unlock()
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
delete(m.sessions, sessionID)
|
||||
m.mu.Unlock()
|
||||
|
||||
// Write-Through:异步删除 PG
|
||||
if m.sessRepo != nil {
|
||||
go func() {
|
||||
if err := m.sessRepo.Delete(ctx, sessionID); err != nil {
|
||||
logger.Log.Warnw("delete session from DB failed", "session", sessionID, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
logger.Log.Debugw("session destroyed", "session", sessionID)
|
||||
return nil
|
||||
}
|
||||
@@ -273,3 +567,19 @@ func (m *MemoryManager) ActiveCount() int {
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// recordToSession 将 store.SessionRecord 转换为 models.Session。
|
||||
func (m *MemoryManager) recordToSession(rec *store.SessionRecord) *models.Session {
|
||||
cfg := models.DefaultConfig()
|
||||
if len(rec.Config) > 0 {
|
||||
_ = json.Unmarshal(rec.Config, &cfg)
|
||||
}
|
||||
return &models.Session{
|
||||
ID: rec.ID,
|
||||
UserID: rec.UserID,
|
||||
Title: rec.Title,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
UpdatedAt: rec.UpdatedAt,
|
||||
Config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestCreateAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
config := models.DefaultConfig()
|
||||
id, err := m.Create(ctx, config)
|
||||
id, err := m.Create(ctx, "", config)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func TestExpire(t *testing.T) {
|
||||
defer m.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
id, _ := m.Create(ctx, models.DefaultConfig())
|
||||
id, _ := m.Create(ctx, "", models.DefaultConfig())
|
||||
|
||||
// 未过期时应能获取
|
||||
_, err := m.Get(ctx, id)
|
||||
@@ -78,7 +78,7 @@ func TestDestroy(t *testing.T) {
|
||||
defer m.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
id, _ := m.Create(ctx, models.DefaultConfig())
|
||||
id, _ := m.Create(ctx, "", models.DefaultConfig())
|
||||
|
||||
if err := m.Destroy(ctx, id); err != nil {
|
||||
t.Fatalf("Destroy: %v", err)
|
||||
@@ -106,7 +106,7 @@ func TestAppendMessageAndGetHistory(t *testing.T) {
|
||||
defer m.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
id, _ := m.Create(ctx, models.DefaultConfig())
|
||||
id, _ := m.Create(ctx, "", models.DefaultConfig())
|
||||
|
||||
msgs := []models.Message{
|
||||
{Role: "user", Content: "你好"},
|
||||
@@ -138,7 +138,7 @@ func TestGetHistoryLimit(t *testing.T) {
|
||||
defer m.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
id, _ := m.Create(ctx, models.DefaultConfig())
|
||||
id, _ := m.Create(ctx, "", models.DefaultConfig())
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
m.AppendMessage(ctx, id, models.Message{Role: "user", Content: "msg"})
|
||||
@@ -159,7 +159,7 @@ func TestHistoryLimit(t *testing.T) {
|
||||
defer m.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
id, _ := m.Create(ctx, models.DefaultConfig())
|
||||
id, _ := m.Create(ctx, "", models.DefaultConfig())
|
||||
|
||||
// 插入超过上限的消息
|
||||
for i := 0; i < 10; i++ {
|
||||
@@ -180,7 +180,7 @@ func TestUpdateConfig(t *testing.T) {
|
||||
defer m.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
id, _ := m.Create(ctx, models.DefaultConfig())
|
||||
id, _ := m.Create(ctx, "", models.DefaultConfig())
|
||||
|
||||
ttsEnabled := false
|
||||
detailLevel := "high"
|
||||
@@ -211,7 +211,7 @@ func TestActiveRequest(t *testing.T) {
|
||||
defer m.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
id, _ := m.Create(ctx, models.DefaultConfig())
|
||||
id, _ := m.Create(ctx, "", models.DefaultConfig())
|
||||
|
||||
// 初始应为空
|
||||
reqID, err := m.GetActiveRequestID(ctx, id)
|
||||
@@ -246,7 +246,7 @@ func TestTouchRefreshesTTL(t *testing.T) {
|
||||
defer m.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
id, _ := m.Create(ctx, models.DefaultConfig())
|
||||
id, _ := m.Create(ctx, "", models.DefaultConfig())
|
||||
|
||||
// 50ms 后 Touch,应重置 TTL
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
@@ -278,9 +278,204 @@ func TestActiveCount(t *testing.T) {
|
||||
t.Errorf("initial ActiveCount = %d, want 0", m.ActiveCount())
|
||||
}
|
||||
|
||||
m.Create(ctx, models.DefaultConfig())
|
||||
m.Create(ctx, models.DefaultConfig())
|
||||
m.Create(ctx, "", models.DefaultConfig())
|
||||
m.Create(ctx, "", models.DefaultConfig())
|
||||
if m.ActiveCount() != 2 {
|
||||
t.Errorf("ActiveCount = %d, want 2", m.ActiveCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWithUserID(t *testing.T) {
|
||||
m := NewMemoryManager(30*time.Minute, 20)
|
||||
defer m.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := m.Create(ctx, "user-123", models.DefaultConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
sess, err := m.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if sess.UserID != "user-123" {
|
||||
t.Errorf("UserID = %q, want %q", sess.UserID, "user-123")
|
||||
}
|
||||
if sess.Title != models.DefaultSessionTitle {
|
||||
t.Errorf("Title = %q, want %q", sess.Title, models.DefaultSessionTitle)
|
||||
}
|
||||
if sess.UpdatedAt.IsZero() {
|
||||
t.Error("UpdatedAt should not be zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTitle(t *testing.T) {
|
||||
m := NewMemoryManager(30*time.Minute, 20)
|
||||
defer m.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
id, _ := m.Create(ctx, "user-1", models.DefaultConfig())
|
||||
|
||||
if err := m.UpdateTitle(ctx, id, "自定义标题"); err != nil {
|
||||
t.Fatalf("UpdateTitle: %v", err)
|
||||
}
|
||||
|
||||
sess, _ := m.Get(ctx, id)
|
||||
if sess.Title != "自定义标题" {
|
||||
t.Errorf("Title = %q, want %q", sess.Title, "自定义标题")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTitleNotFound(t *testing.T) {
|
||||
m := NewMemoryManager(30*time.Minute, 20)
|
||||
defer m.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
err := m.UpdateTitle(ctx, "nonexistent", "标题")
|
||||
if err != ErrSessionNotFound {
|
||||
t.Errorf("UpdateTitle nonexistent: err = %v, want ErrSessionNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoTitleOnFirstMessage(t *testing.T) {
|
||||
m := NewMemoryManager(30*time.Minute, 20)
|
||||
defer m.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
id, _ := m.Create(ctx, "user-1", models.DefaultConfig())
|
||||
|
||||
// 首条 user 消息应自动更新标题
|
||||
m.AppendMessage(ctx, id, models.Message{Role: "user", Content: "你好世界"})
|
||||
|
||||
sess, _ := m.Get(ctx, id)
|
||||
if sess.Title != "你好世界" {
|
||||
t.Errorf("Title = %q, want %q", sess.Title, "你好世界")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoTitleLongMessage(t *testing.T) {
|
||||
m := NewMemoryManager(30*time.Minute, 20)
|
||||
defer m.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
id, _ := m.Create(ctx, "user-1", models.DefaultConfig())
|
||||
|
||||
// 超过 20 字符的消息应截断
|
||||
longMsg := "这是一条很长很长很长很长很长很长很长很长的消息"
|
||||
m.AppendMessage(ctx, id, models.Message{Role: "user", Content: longMsg})
|
||||
|
||||
sess, _ := m.Get(ctx, id)
|
||||
expected := string([]rune(longMsg)[:20]) + "…"
|
||||
if sess.Title != expected {
|
||||
t.Errorf("Title = %q, want %q", sess.Title, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoTitleNotOverwritten(t *testing.T) {
|
||||
m := NewMemoryManager(30*time.Minute, 20)
|
||||
defer m.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
id, _ := m.Create(ctx, "user-1", models.DefaultConfig())
|
||||
|
||||
// 首条消息设置标题
|
||||
m.AppendMessage(ctx, id, models.Message{Role: "user", Content: "第一条消息"})
|
||||
// 第二条消息不应覆盖已有的标题
|
||||
m.AppendMessage(ctx, id, models.Message{Role: "user", Content: "第二条消息"})
|
||||
|
||||
sess, _ := m.Get(ctx, id)
|
||||
if sess.Title != "第一条消息" {
|
||||
t.Errorf("Title = %q, want %q", sess.Title, "第一条消息")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListByUser(t *testing.T) {
|
||||
m := NewMemoryManager(30*time.Minute, 20)
|
||||
defer m.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
// 创建两个用户的不同会话
|
||||
id1, _ := m.Create(ctx, "user-1", models.DefaultConfig())
|
||||
m.AppendMessage(ctx, id1, models.Message{Role: "user", Content: "会话1"})
|
||||
id2, _ := m.Create(ctx, "user-1", models.DefaultConfig())
|
||||
m.AppendMessage(ctx, id2, models.Message{Role: "user", Content: "会话2"})
|
||||
m.Create(ctx, "user-2", models.DefaultConfig()) // 其他用户的会话
|
||||
|
||||
list, total, err := m.ListByUser(ctx, "user-1", 1, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByUser: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Errorf("total = %d, want 2", total)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(list))
|
||||
}
|
||||
// 按 UpdatedAt 降序,id2 应在前
|
||||
if list[0].ID != id2 {
|
||||
t.Errorf("list[0].ID = %q, want %q", list[0].ID, id2)
|
||||
}
|
||||
if list[0].Title != "会话2" {
|
||||
t.Errorf("list[0].Title = %q, want %q", list[0].Title, "会话2")
|
||||
}
|
||||
if list[0].LastMessage != "会话2" {
|
||||
t.Errorf("list[0].LastMessage = %q, want %q", list[0].LastMessage, "会话2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListByUserPagination(t *testing.T) {
|
||||
m := NewMemoryManager(30*time.Minute, 20)
|
||||
defer m.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
// 创建 5 个会话
|
||||
for i := 0; i < 5; i++ {
|
||||
id, _ := m.Create(ctx, "user-1", models.DefaultConfig())
|
||||
m.AppendMessage(ctx, id, models.Message{Role: "user", Content: "msg"})
|
||||
}
|
||||
|
||||
// 第 1 页,每页 2 条
|
||||
list, total, _ := m.ListByUser(ctx, "user-1", 1, 2)
|
||||
if total != 5 {
|
||||
t.Errorf("total = %d, want 5", total)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Errorf("page 1 len = %d, want 2", len(list))
|
||||
}
|
||||
|
||||
// 第 2 页
|
||||
list, _, _ = m.ListByUser(ctx, "user-1", 2, 2)
|
||||
if len(list) != 2 {
|
||||
t.Errorf("page 2 len = %d, want 2", len(list))
|
||||
}
|
||||
|
||||
// 第 3 页(最后一页)
|
||||
list, _, _ = m.ListByUser(ctx, "user-1", 3, 2)
|
||||
if len(list) != 1 {
|
||||
t.Errorf("page 3 len = %d, want 1", len(list))
|
||||
}
|
||||
|
||||
// 超出范围的页
|
||||
list, _, _ = m.ListByUser(ctx, "user-1", 10, 2)
|
||||
if len(list) != 0 {
|
||||
t.Errorf("out of range page len = %d, want 0", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListByUserEmpty(t *testing.T) {
|
||||
m := NewMemoryManager(30*time.Minute, 20)
|
||||
defer m.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
list, total, err := m.ListByUser(ctx, "no-such-user", 1, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByUser: %v", err)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Errorf("total = %d, want 0", total)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Errorf("len = %d, want 0", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
// 数据结构:
|
||||
// - session:{id}:meta → Hash(会话元数据)
|
||||
// - session:{id}:history → List(对话历史)
|
||||
// - user:{id}:sessions → Set(用户会话索引)
|
||||
type RedisManager struct {
|
||||
rdb *redis.Client
|
||||
ttl time.Duration
|
||||
@@ -35,37 +36,48 @@ func NewRedisManager(rdb *redis.Client, ttl time.Duration, maxHistory int) *Redi
|
||||
return &RedisManager{rdb: rdb, ttl: ttl, maxHistory: maxHistory}
|
||||
}
|
||||
|
||||
func metaKey(id string) string { return fmt.Sprintf("session:%s:meta", id) }
|
||||
func histKey(id string) string { return fmt.Sprintf("session:%s:history", id) }
|
||||
func metaKey(id string) string { return fmt.Sprintf("session:%s:meta", id) }
|
||||
func histKey(id string) string { return fmt.Sprintf("session:%s:history", id) }
|
||||
func userSessKey(id string) string { return fmt.Sprintf("user:%s:sessions", id) }
|
||||
|
||||
// Create 创建新会话。
|
||||
func (m *RedisManager) Create(ctx context.Context, config models.SessionConfig) (string, error) {
|
||||
// Create 创建新会话。userID 为空表示匿名会话。
|
||||
func (m *RedisManager) Create(ctx context.Context, userID string, config models.SessionConfig) (string, error) {
|
||||
id := uuidNew()
|
||||
now := time.Now().UTC()
|
||||
|
||||
pipe := m.rdb.Pipeline()
|
||||
|
||||
// 写入 meta Hash
|
||||
pipe.HSet(ctx, metaKey(id), map[string]interface{}{
|
||||
"session_id": id,
|
||||
"config.tts_enabled": strconv.FormatBool(config.TTSEnabled),
|
||||
meta := map[string]interface{}{
|
||||
"session_id": id,
|
||||
"user_id": userID,
|
||||
"title": models.DefaultSessionTitle,
|
||||
"config.tts_enabled": strconv.FormatBool(config.TTSEnabled),
|
||||
"config.detail_level": config.DetailLevel,
|
||||
"config.language": config.Language,
|
||||
"created_at": now.Format(time.RFC3339),
|
||||
"last_active": now.Format(time.RFC3339),
|
||||
"active_request_id": "",
|
||||
})
|
||||
"config.language": config.Language,
|
||||
"created_at": now.Format(time.RFC3339),
|
||||
"updated_at": now.Format(time.RFC3339),
|
||||
"last_active": now.Format(time.RFC3339),
|
||||
"active_request_id": "",
|
||||
}
|
||||
pipe.HSet(ctx, metaKey(id), meta)
|
||||
pipe.Expire(ctx, metaKey(id), m.ttl)
|
||||
|
||||
// 初始化空 history List
|
||||
pipe.RPush(ctx, histKey(id), placeholderHistoryMark)
|
||||
pipe.Expire(ctx, histKey(id), m.ttl)
|
||||
|
||||
// 如果有 userID,添加到用户会话索引
|
||||
if userID != "" {
|
||||
pipe.SAdd(ctx, userSessKey(userID), id)
|
||||
pipe.Expire(ctx, userSessKey(userID), m.ttl)
|
||||
}
|
||||
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return "", fmt.Errorf("redis create session: %w", err)
|
||||
}
|
||||
|
||||
logger.Log.Debugw("redis session created", "session", id)
|
||||
logger.Log.Debugw("redis session created", "session", id, "user_id", userID)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -83,9 +95,12 @@ func (m *RedisManager) Get(ctx context.Context, sessionID string) (*models.Sessi
|
||||
}
|
||||
|
||||
sess := &models.Session{
|
||||
ID: vals["session_id"],
|
||||
ID: vals["session_id"],
|
||||
UserID: vals["user_id"],
|
||||
Title: vals["title"],
|
||||
}
|
||||
sess.CreatedAt, _ = time.Parse(time.RFC3339, vals["created_at"])
|
||||
sess.UpdatedAt, _ = time.Parse(time.RFC3339, vals["updated_at"])
|
||||
sess.Config.TTSEnabled, _ = strconv.ParseBool(vals["config.tts_enabled"])
|
||||
sess.Config.DetailLevel = vals["config.detail_level"]
|
||||
sess.Config.Language = vals["config.language"]
|
||||
@@ -104,8 +119,10 @@ func (m *RedisManager) UpdateConfig(ctx context.Context, sessionID string, patch
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
fields := map[string]interface{}{
|
||||
"last_active": time.Now().UTC().Format(time.RFC3339),
|
||||
"last_active": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
if patch.TTSEnabled != nil {
|
||||
fields["config.tts_enabled"] = strconv.FormatBool(*patch.TTSEnabled)
|
||||
@@ -127,6 +144,109 @@ func (m *RedisManager) UpdateConfig(ctx context.Context, sessionID string, patch
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateTitle 更新会话标题。
|
||||
func (m *RedisManager) UpdateTitle(ctx context.Context, sessionID string, title string) error {
|
||||
exists, err := m.rdb.Exists(ctx, metaKey(sessionID)).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("redis check session: %w", err)
|
||||
}
|
||||
if exists == 0 {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if err := m.rdb.HSet(ctx, metaKey(sessionID), "title", title, "updated_at", now, "last_active", now).Err(); err != nil {
|
||||
return fmt.Errorf("redis update title: %w", err)
|
||||
}
|
||||
|
||||
m.rdb.Expire(ctx, metaKey(sessionID), m.ttl)
|
||||
logger.Log.Debugw("redis session title updated", "session", sessionID, "title", title)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByUser 获取用户的对话列表(分页,按 UpdatedAt 降序)。
|
||||
func (m *RedisManager) ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 {
|
||||
size = 20
|
||||
}
|
||||
|
||||
// 从用户会话索引获取所有 session ID
|
||||
sessionIDs, err := m.rdb.SMembers(ctx, userSessKey(userID)).Result()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("redis list user sessions: %w", err)
|
||||
}
|
||||
|
||||
// 收集有效的会话摘要
|
||||
var list []ConversationSummary
|
||||
for _, sid := range sessionIDs {
|
||||
vals, err := m.rdb.HGetAll(ctx, metaKey(sid)).Result()
|
||||
if err != nil || len(vals) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
updatedAt, _ := time.Parse(time.RFC3339, vals["updated_at"])
|
||||
lastActive, _ := time.Parse(time.RFC3339, vals["last_active"])
|
||||
|
||||
// 检查是否过期
|
||||
if time.Since(lastActive) > m.ttl {
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取最后一条消息
|
||||
lastMsg := ""
|
||||
msgCount := 0
|
||||
raws, err := m.rdb.LRange(ctx, histKey(sid), 0, 0).Result()
|
||||
if err == nil && len(raws) > 0 && raws[0] != placeholderHistoryMark {
|
||||
var msg models.Message
|
||||
if json.Unmarshal([]byte(raws[0]), &msg) == nil {
|
||||
lastMsg = msg.Content
|
||||
}
|
||||
}
|
||||
// 获取消息总数(减去占位符)
|
||||
totalLen, err := m.rdb.LLen(ctx, histKey(sid)).Result()
|
||||
if err == nil {
|
||||
msgCount = int(totalLen)
|
||||
if msgCount > 0 {
|
||||
msgCount-- // 减去占位符
|
||||
}
|
||||
}
|
||||
|
||||
list = append(list, ConversationSummary{
|
||||
ID: vals["session_id"],
|
||||
Title: vals["title"],
|
||||
LastMessage: lastMsg,
|
||||
MessageCount: msgCount,
|
||||
UpdatedAt: updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// 按 UpdatedAt 降序排序
|
||||
for i := 0; i < len(list); i++ {
|
||||
for j := i + 1; j < len(list); j++ {
|
||||
if list[j].UpdatedAt.After(list[i].UpdatedAt) {
|
||||
list[i], list[j] = list[j], list[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total := len(list)
|
||||
|
||||
// 分页
|
||||
start := (page - 1) * size
|
||||
if start >= total {
|
||||
return []ConversationSummary{}, total, nil
|
||||
}
|
||||
end := start + size
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
|
||||
return list[start:end], total, nil
|
||||
}
|
||||
|
||||
// GetHistory 获取最近 N 轮对话历史。
|
||||
func (m *RedisManager) GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error) {
|
||||
// 检查会话是否存在
|
||||
@@ -193,8 +313,18 @@ func (m *RedisManager) AppendMessage(ctx context.Context, sessionID string, msg
|
||||
// 刷新 TTL
|
||||
pipe.Expire(ctx, histKey(sessionID), m.ttl)
|
||||
pipe.Expire(ctx, metaKey(sessionID), m.ttl)
|
||||
// 更新 last_active
|
||||
pipe.HSet(ctx, metaKey(sessionID), "last_active", time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
// 更新 last_active 和 updated_at
|
||||
pipe.HSet(ctx, metaKey(sessionID), "last_active", now, "updated_at", now)
|
||||
|
||||
// 自动更新标题:首条 user 消息时,如果标题为默认值
|
||||
if msg.Role == "user" {
|
||||
title, _ := m.rdb.HGet(ctx, metaKey(sessionID), "title").Result()
|
||||
if title == models.DefaultSessionTitle {
|
||||
pipe.HSet(ctx, metaKey(sessionID), "title", generateTitle(msg.Content))
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return fmt.Errorf("redis append message: %w", err)
|
||||
@@ -280,6 +410,9 @@ func (m *RedisManager) Touch(ctx context.Context, sessionID string) error {
|
||||
|
||||
// Destroy 显式销毁会话。
|
||||
func (m *RedisManager) Destroy(ctx context.Context, sessionID string) error {
|
||||
// 先获取 user_id 以便清理索引
|
||||
userID, _ := m.rdb.HGet(ctx, metaKey(sessionID), "user_id").Result()
|
||||
|
||||
deleted, err := m.rdb.Del(ctx, metaKey(sessionID), histKey(sessionID)).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("redis destroy session: %w", err)
|
||||
@@ -288,6 +421,11 @@ func (m *RedisManager) Destroy(ctx context.Context, sessionID string) error {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
// 清理用户会话索引
|
||||
if userID != "" {
|
||||
m.rdb.SRem(ctx, userSessKey(userID), sessionID)
|
||||
}
|
||||
|
||||
logger.Log.Debugw("redis session destroyed", "session", sessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
17
backend/internal/store/db.go
Normal file
17
backend/internal/store/db.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// NewPostgresPool 创建 PostgreSQL 连接池。
|
||||
func NewPostgresPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.MaxConns = 10
|
||||
return pgxpool.NewWithConfig(ctx, cfg)
|
||||
}
|
||||
50
backend/internal/store/message.go
Normal file
50
backend/internal/store/message.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrMessageNotFound 消息不存在。
|
||||
ErrMessageNotFound = errors.New("message not found")
|
||||
)
|
||||
|
||||
// MessageRepository 消息持久化接口。
|
||||
type MessageRepository interface {
|
||||
// SaveMessage 保存一条消息。
|
||||
SaveMessage(ctx context.Context, sessionID string, msg models.Message, tokensUsed int) error
|
||||
|
||||
// GetMessages 获取会话的消息列表(分页,按 created_at 升序)。
|
||||
// beforeID 为 0 时从最新开始查询。
|
||||
GetMessages(ctx context.Context, sessionID string, limit int, beforeID int64) ([]StoredMessage, error)
|
||||
|
||||
// GetLastMessage 获取会话的最后一条消息。
|
||||
GetLastMessage(ctx context.Context, sessionID string) (*StoredMessage, error)
|
||||
|
||||
// GetMessageCount 获取会话的消息总数。
|
||||
GetMessageCount(ctx context.Context, sessionID string) (int, error)
|
||||
|
||||
// GetSessionMessageStats 批量查询多个会话的消息统计(last_message + message_count)。
|
||||
// 返回的 map key 为 sessionID,仅包含有消息的会话。
|
||||
GetSessionMessageStats(ctx context.Context, sessionIDs []string) (map[string]SessionMessageStats, error)
|
||||
}
|
||||
|
||||
// SessionMessageStats 单个会话的消息统计(SQL 聚合查询结果)。
|
||||
type SessionMessageStats struct {
|
||||
LastMessage string
|
||||
MessageCount int
|
||||
}
|
||||
|
||||
// StoredMessage 持久化消息模型(store 层)。
|
||||
type StoredMessage struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID string `json:"-"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
TokensUsed int `json:"tokens_used"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
163
backend/internal/store/message_pg.go
Normal file
163
backend/internal/store/message_pg.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
)
|
||||
|
||||
// PgMessageRepository 基于 PostgreSQL 的 MessageRepository 实现。
|
||||
type PgMessageRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPgMessageRepository 创建 PgMessageRepository。
|
||||
func NewPgMessageRepository(pool *pgxpool.Pool) *PgMessageRepository {
|
||||
return &PgMessageRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *PgMessageRepository) SaveMessage(ctx context.Context, sessionID string, msg models.Message, tokensUsed int) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO messages (session_id, role, content, tokens_used) VALUES ($1, $2, $3, $4)`,
|
||||
sessionID, msg.Role, msg.Content, tokensUsed,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PgMessageRepository) GetMessages(ctx context.Context, sessionID string, limit int, beforeID int64) ([]StoredMessage, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
var rows []StoredMessage
|
||||
var err error
|
||||
|
||||
if beforeID > 0 {
|
||||
rows, err = r.queryMessages(ctx,
|
||||
`SELECT id, session_id, role, content, tokens_used, created_at
|
||||
FROM messages
|
||||
WHERE session_id = $1 AND id < $2
|
||||
ORDER BY id DESC
|
||||
LIMIT $3`,
|
||||
sessionID, beforeID, limit,
|
||||
)
|
||||
} else {
|
||||
rows, err = r.queryMessages(ctx,
|
||||
`SELECT id, session_id, role, content, tokens_used, created_at
|
||||
FROM messages
|
||||
WHERE session_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT $2`,
|
||||
sessionID, limit,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 反转为升序
|
||||
for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 {
|
||||
rows[i], rows[j] = rows[j], rows[i]
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *PgMessageRepository) queryMessages(ctx context.Context, query string, args ...any) ([]StoredMessage, error) {
|
||||
pgxRows, err := r.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer pgxRows.Close()
|
||||
|
||||
var messages []StoredMessage
|
||||
for pgxRows.Next() {
|
||||
var m StoredMessage
|
||||
if err := pgxRows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.TokensUsed, &m.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages = append(messages, m)
|
||||
}
|
||||
if err := pgxRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (r *PgMessageRepository) GetLastMessage(ctx context.Context, sessionID string) (*StoredMessage, error) {
|
||||
var m StoredMessage
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, session_id, role, content, tokens_used, created_at
|
||||
FROM messages
|
||||
WHERE session_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT 1`,
|
||||
sessionID,
|
||||
).Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.TokensUsed, &m.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrMessageNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (r *PgMessageRepository) GetMessageCount(ctx context.Context, sessionID string) (int, error) {
|
||||
var count int
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE session_id = $1`,
|
||||
sessionID,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *PgMessageRepository) GetSessionMessageStats(ctx context.Context, sessionIDs []string) (map[string]SessionMessageStats, error) {
|
||||
if len(sessionIDs) == 0 {
|
||||
return map[string]SessionMessageStats{}, nil
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`WITH stats AS (
|
||||
SELECT session_id, COUNT(*) AS cnt
|
||||
FROM messages
|
||||
WHERE session_id = ANY($1)
|
||||
GROUP BY session_id
|
||||
),
|
||||
last_msg AS (
|
||||
SELECT DISTINCT ON (session_id) session_id, content
|
||||
FROM messages
|
||||
WHERE session_id = ANY($1)
|
||||
ORDER BY session_id, id DESC
|
||||
)
|
||||
SELECT s.session_id, s.cnt, COALESCE(lm.content, '')
|
||||
FROM stats s
|
||||
LEFT JOIN last_msg lm ON lm.session_id = s.session_id`,
|
||||
sessionIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]SessionMessageStats)
|
||||
for rows.Next() {
|
||||
var sid string
|
||||
var stats SessionMessageStats
|
||||
if err := rows.Scan(&sid, &stats.MessageCount, &stats.LastMessage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[sid] = stats
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
80
backend/internal/store/migrate.go
Normal file
80
backend/internal/store/migrate.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
)
|
||||
|
||||
// RunMigrations 从给定的 fs.FS 中读取 *.up.sql 文件并按版本号顺序执行。
|
||||
// 已执行过的版本会跳过(通过 schema_migrations 表记录)。
|
||||
func RunMigrations(ctx context.Context, pool *pgxpool.Pool, fsys fs.FS) error {
|
||||
// 确保 schema_migrations 表存在
|
||||
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create schema_migrations table: %w", err)
|
||||
}
|
||||
|
||||
// 收集所有 *.up.sql 文件
|
||||
entries, err := fs.ReadDir(fsys, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
|
||||
var files []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
|
||||
files = append(files, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
for _, name := range files {
|
||||
// 从文件名提取版本号,如 "001_users.up.sql" → 1
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(name, "%d_", &version); err != nil {
|
||||
return fmt.Errorf("parse version from %s: %w", name, err)
|
||||
}
|
||||
|
||||
// 检查是否已执行
|
||||
var exists bool
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, version,
|
||||
).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("check migration version %d: %w", version, err)
|
||||
}
|
||||
if exists {
|
||||
logger.Log.Debugw("migration already applied", "version", version, "file", name)
|
||||
continue
|
||||
}
|
||||
|
||||
// 读取并执行
|
||||
content, err := fs.ReadFile(fsys, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
if _, err := pool.Exec(ctx, string(content)); err != nil {
|
||||
return fmt.Errorf("execute migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
// 记录已执行
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO schema_migrations (version) VALUES ($1)`, version,
|
||||
); err != nil {
|
||||
return fmt.Errorf("record migration %d: %w", version, err)
|
||||
}
|
||||
|
||||
logger.Log.Infow("migration applied", "version", version, "file", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
47
backend/internal/store/session.go
Normal file
47
backend/internal/store/session.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrSessionNotFound 会话不存在。
|
||||
ErrSessionNotFound = errors.New("session not found")
|
||||
)
|
||||
|
||||
// SessionRepository 会话持久化接口。
|
||||
type SessionRepository interface {
|
||||
// Save 创建或更新会话(UPSERT)。
|
||||
Save(ctx context.Context, s SessionRecord) error
|
||||
|
||||
// FindByID 根据 ID 查询会话。
|
||||
FindByID(ctx context.Context, id string) (*SessionRecord, error)
|
||||
|
||||
// FindByUser 查询用户的会话列表(分页,按 updated_at 降序)。
|
||||
// 返回 (列表, 总数, error)。
|
||||
FindByUser(ctx context.Context, userID string, page, size int) ([]SessionRecord, int, error)
|
||||
|
||||
// UpdateTitle 更新会话标题。
|
||||
UpdateTitle(ctx context.Context, id string, title string) error
|
||||
|
||||
// UpdateConfig 更新会话配置。
|
||||
UpdateConfig(ctx context.Context, id string, configJSON []byte) error
|
||||
|
||||
// Touch 刷新 updated_at。
|
||||
Touch(ctx context.Context, id string) error
|
||||
|
||||
// Delete 删除会话。
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// SessionRecord 持久化会话模型(store 层)。
|
||||
type SessionRecord struct {
|
||||
ID string
|
||||
UserID string
|
||||
Title string
|
||||
Config []byte // JSON 编码的 SessionConfig
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
146
backend/internal/store/session_pg.go
Normal file
146
backend/internal/store/session_pg.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// PgSessionRepository 基于 PostgreSQL 的 SessionRepository 实现。
|
||||
type PgSessionRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPgSessionRepository 创建 PgSessionRepository。
|
||||
func NewPgSessionRepository(pool *pgxpool.Pool) *PgSessionRepository {
|
||||
return &PgSessionRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *PgSessionRepository) Save(ctx context.Context, s SessionRecord) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO sessions (id, user_id, title, config, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
config = EXCLUDED.config,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
s.ID, s.UserID, s.Title, s.Config, s.CreatedAt, s.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PgSessionRepository) FindByID(ctx context.Context, id string) (*SessionRecord, error) {
|
||||
var s SessionRecord
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, user_id, title, config, created_at, updated_at
|
||||
FROM sessions WHERE id = $1`, id,
|
||||
).Scan(&s.ID, &s.UserID, &s.Title, &s.Config, &s.CreatedAt, &s.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *PgSessionRepository) FindByUser(ctx context.Context, userID string, page, size int) ([]SessionRecord, int, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 {
|
||||
size = 20
|
||||
}
|
||||
offset := (page - 1) * size
|
||||
|
||||
// 查询总数
|
||||
var total int
|
||||
if err := r.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM sessions WHERE user_id = $1`, userID,
|
||||
).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 查询列表
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT id, user_id, title, config, created_at, updated_at
|
||||
FROM sessions
|
||||
WHERE user_id = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
userID, size, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []SessionRecord
|
||||
for rows.Next() {
|
||||
var s SessionRecord
|
||||
if err := rows.Scan(&s.ID, &s.UserID, &s.Title, &s.Config, &s.CreatedAt, &s.UpdatedAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
list = append(list, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
func (r *PgSessionRepository) UpdateTitle(ctx context.Context, id string, title string) error {
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`UPDATE sessions SET title = $2, updated_at = NOW() WHERE id = $1`,
|
||||
id, title,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PgSessionRepository) UpdateConfig(ctx context.Context, id string, configJSON []byte) error {
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`UPDATE sessions SET config = $2, updated_at = NOW() WHERE id = $1`,
|
||||
id, configJSON,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PgSessionRepository) Touch(ctx context.Context, id string) error {
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`UPDATE sessions SET updated_at = NOW() WHERE id = $1`, id,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PgSessionRepository) Delete(ctx context.Context, id string) error {
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM sessions WHERE id = $1`, id,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
46
backend/internal/store/user.go
Normal file
46
backend/internal/store/user.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUsernameTaken = errors.New("username already taken")
|
||||
ErrRefreshTokenNotFound = errors.New("refresh token not found")
|
||||
)
|
||||
|
||||
// UserRepository 用户持久化接口。
|
||||
type UserRepository interface {
|
||||
// Create 创建用户,返回生成的 ID。
|
||||
Create(ctx context.Context, username, passwordHash string) (string, error)
|
||||
|
||||
// FindByUsername 按用户名查找,不存在返回 ErrUserNotFound。
|
||||
FindByUsername(ctx context.Context, username string) (*User, error)
|
||||
|
||||
// FindByID 按 ID 查找,不存在返回 ErrUserNotFound。
|
||||
FindByID(ctx context.Context, id string) (*User, error)
|
||||
|
||||
// SaveRefreshToken 保存 refresh token hash。
|
||||
SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error
|
||||
|
||||
// FindRefreshToken 按 token hash 查找,返回 user_id。不存在返回 ErrRefreshTokenNotFound。
|
||||
FindRefreshToken(ctx context.Context, tokenHash string) (string, error)
|
||||
|
||||
// DeleteRefreshToken 按 token hash 删除。
|
||||
DeleteRefreshToken(ctx context.Context, tokenHash string) error
|
||||
|
||||
// DeleteUserRefreshTokens 删除用户的所有 refresh token(登出所有设备)。
|
||||
DeleteUserRefreshTokens(ctx context.Context, userID string) error
|
||||
}
|
||||
|
||||
// User 用户数据模型(store 层)。
|
||||
type User struct {
|
||||
ID string
|
||||
Username string
|
||||
PasswordHash string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
120
backend/internal/store/user_mem.go
Normal file
120
backend/internal/store/user_mem.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// MemUserRepository 基于内存的 UserRepository 实现(测试用)。
|
||||
type MemUserRepository struct {
|
||||
mu sync.RWMutex
|
||||
users map[string]*User // id -> user
|
||||
byUsername map[string]string // username -> id
|
||||
refreshTokens map[string]string // tokenHash -> userID
|
||||
tokenExpiry map[string]time.Time // tokenHash -> expiresAt
|
||||
}
|
||||
|
||||
// NewMemUserRepository 创建 MemUserRepository。
|
||||
func NewMemUserRepository() *MemUserRepository {
|
||||
return &MemUserRepository{
|
||||
users: make(map[string]*User),
|
||||
byUsername: make(map[string]string),
|
||||
refreshTokens: make(map[string]string),
|
||||
tokenExpiry: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MemUserRepository) Create(_ context.Context, username, passwordHash string) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, exists := r.byUsername[username]; exists {
|
||||
return "", ErrUsernameTaken
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
now := time.Now()
|
||||
user := &User{
|
||||
ID: id,
|
||||
Username: username,
|
||||
PasswordHash: passwordHash,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
r.users[id] = user
|
||||
r.byUsername[username] = id
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *MemUserRepository) FindByUsername(_ context.Context, username string) (*User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
id, ok := r.byUsername[username]
|
||||
if !ok {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
u := r.users[id]
|
||||
copy := *u
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (r *MemUserRepository) FindByID(_ context.Context, id string) (*User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
u, ok := r.users[id]
|
||||
if !ok {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
copy := *u
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (r *MemUserRepository) SaveRefreshToken(_ context.Context, userID, tokenHash string, expiresAt time.Time) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.refreshTokens[tokenHash] = userID
|
||||
r.tokenExpiry[tokenHash] = expiresAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemUserRepository) FindRefreshToken(_ context.Context, tokenHash string) (string, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
userID, ok := r.refreshTokens[tokenHash]
|
||||
if !ok {
|
||||
return "", ErrRefreshTokenNotFound
|
||||
}
|
||||
if time.Now().After(r.tokenExpiry[tokenHash]) {
|
||||
return "", ErrRefreshTokenNotFound
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func (r *MemUserRepository) DeleteRefreshToken(_ context.Context, tokenHash string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
delete(r.refreshTokens, tokenHash)
|
||||
delete(r.tokenExpiry, tokenHash)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemUserRepository) DeleteUserRefreshTokens(_ context.Context, userID string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
for hash, uid := range r.refreshTokens {
|
||||
if uid == userID {
|
||||
delete(r.refreshTokens, hash)
|
||||
delete(r.tokenExpiry, hash)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
101
backend/internal/store/user_pg.go
Normal file
101
backend/internal/store/user_pg.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// PgUserRepository 基于 PostgreSQL 的 UserRepository 实现。
|
||||
type PgUserRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPgUserRepository 创建 PgUserRepository。
|
||||
func NewPgUserRepository(pool *pgxpool.Pool) *PgUserRepository {
|
||||
return &PgUserRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *PgUserRepository) Create(ctx context.Context, username, passwordHash string) (string, error) {
|
||||
var id string
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id`,
|
||||
username, passwordHash,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *PgUserRepository) FindByUsername(ctx context.Context, username string) (*User, error) {
|
||||
var u User
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = $1`,
|
||||
username,
|
||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt, &u.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *PgUserRepository) FindByID(ctx context.Context, id string) (*User, error) {
|
||||
var u User
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, username, password_hash, created_at, updated_at FROM users WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt, &u.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *PgUserRepository) SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
|
||||
userID, tokenHash, expiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PgUserRepository) FindRefreshToken(ctx context.Context, tokenHash string) (string, error) {
|
||||
var userID string
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND expires_at > NOW()`,
|
||||
tokenHash,
|
||||
).Scan(&userID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrRefreshTokenNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func (r *PgUserRepository) DeleteRefreshToken(ctx context.Context, tokenHash string) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM refresh_tokens WHERE token_hash = $1`,
|
||||
tokenHash,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PgUserRepository) DeleteUserRefreshTokens(ctx context.Context, userID string) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM refresh_tokens WHERE user_id = $1`,
|
||||
userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
185
backend/internal/store/user_test.go
Normal file
185
backend/internal/store/user_test.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newUserRepo 返回一个可测试的 UserRepository 实现。
|
||||
// 如需测试 Pg 实现,可在此替换为连接真实 DB 的版本。
|
||||
func newUserRepo() UserRepository {
|
||||
return NewMemUserRepository()
|
||||
}
|
||||
|
||||
func TestUserRepository_Create(t *testing.T) {
|
||||
repo := newUserRepo()
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := repo.Create(ctx, "alice", "hash123")
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatal("expected non-empty ID")
|
||||
}
|
||||
|
||||
// 重复用户名应返回 ErrUsernameTaken
|
||||
_, err = repo.Create(ctx, "alice", "hash456")
|
||||
if !errors.Is(err, ErrUsernameTaken) {
|
||||
t.Fatalf("expected ErrUsernameTaken, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepository_FindByUsername(t *testing.T) {
|
||||
repo := newUserRepo()
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := repo.Create(ctx, "bob", "hash_bob")
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
user, err := repo.FindByUsername(ctx, "bob")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUsername failed: %v", err)
|
||||
}
|
||||
if user.Username != "bob" {
|
||||
t.Fatalf("expected username bob, got %s", user.Username)
|
||||
}
|
||||
if user.PasswordHash != "hash_bob" {
|
||||
t.Fatalf("expected password hash hash_bob, got %s", user.PasswordHash)
|
||||
}
|
||||
|
||||
// 不存在的用户
|
||||
_, err = repo.FindByUsername(ctx, "nobody")
|
||||
if !errors.Is(err, ErrUserNotFound) {
|
||||
t.Fatalf("expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepository_FindByID(t *testing.T) {
|
||||
repo := newUserRepo()
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := repo.Create(ctx, "charlie", "hash_charlie")
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
user, err := repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByID failed: %v", err)
|
||||
}
|
||||
if user.ID != id {
|
||||
t.Fatalf("expected ID %s, got %s", id, user.ID)
|
||||
}
|
||||
if user.Username != "charlie" {
|
||||
t.Fatalf("expected username charlie, got %s", user.Username)
|
||||
}
|
||||
|
||||
// 不存在的 ID
|
||||
_, err = repo.FindByID(ctx, "nonexistent-uuid")
|
||||
if !errors.Is(err, ErrUserNotFound) {
|
||||
t.Fatalf("expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepository_RefreshToken(t *testing.T) {
|
||||
repo := newUserRepo()
|
||||
ctx := context.Background()
|
||||
|
||||
userID, err := repo.Create(ctx, "dave", "hash_dave")
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
tokenHash := "abc123hash"
|
||||
expiresAt := time.Now().Add(7 * 24 * time.Hour)
|
||||
|
||||
// 保存 token
|
||||
if err := repo.SaveRefreshToken(ctx, userID, tokenHash, expiresAt); err != nil {
|
||||
t.Fatalf("SaveRefreshToken failed: %v", err)
|
||||
}
|
||||
|
||||
// 查找 token
|
||||
foundUserID, err := repo.FindRefreshToken(ctx, tokenHash)
|
||||
if err != nil {
|
||||
t.Fatalf("FindRefreshToken failed: %v", err)
|
||||
}
|
||||
if foundUserID != userID {
|
||||
t.Fatalf("expected userID %s, got %s", userID, foundUserID)
|
||||
}
|
||||
|
||||
// 不存在的 token
|
||||
_, err = repo.FindRefreshToken(ctx, "nonexistent")
|
||||
if !errors.Is(err, ErrRefreshTokenNotFound) {
|
||||
t.Fatalf("expected ErrRefreshTokenNotFound, got %v", err)
|
||||
}
|
||||
|
||||
// 删除 token
|
||||
if err := repo.DeleteRefreshToken(ctx, tokenHash); err != nil {
|
||||
t.Fatalf("DeleteRefreshToken failed: %v", err)
|
||||
}
|
||||
_, err = repo.FindRefreshToken(ctx, tokenHash)
|
||||
if !errors.Is(err, ErrRefreshTokenNotFound) {
|
||||
t.Fatalf("expected ErrRefreshTokenNotFound after delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepository_DeleteUserRefreshTokens(t *testing.T) {
|
||||
repo := newUserRepo()
|
||||
ctx := context.Background()
|
||||
|
||||
userID, err := repo.Create(ctx, "eve", "hash_eve")
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
// 保存多个 token
|
||||
for i := 0; i < 3; i++ {
|
||||
tokenHash := "token_" + string(rune('a'+i))
|
||||
expiresAt := time.Now().Add(7 * 24 * time.Hour)
|
||||
if err := repo.SaveRefreshToken(ctx, userID, tokenHash, expiresAt); err != nil {
|
||||
t.Fatalf("SaveRefreshToken failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除用户所有 token
|
||||
if err := repo.DeleteUserRefreshTokens(ctx, userID); err != nil {
|
||||
t.Fatalf("DeleteUserRefreshTokens failed: %v", err)
|
||||
}
|
||||
|
||||
// 验证全部删除
|
||||
for i := 0; i < 3; i++ {
|
||||
tokenHash := "token_" + string(rune('a'+i))
|
||||
_, err := repo.FindRefreshToken(ctx, tokenHash)
|
||||
if !errors.Is(err, ErrRefreshTokenNotFound) {
|
||||
t.Fatalf("expected ErrRefreshTokenNotFound for token_%c, got %v", 'a'+i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepository_ExpiredRefreshToken(t *testing.T) {
|
||||
repo := newUserRepo()
|
||||
ctx := context.Background()
|
||||
|
||||
userID, err := repo.Create(ctx, "frank", "hash_frank")
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
tokenHash := "expired_token"
|
||||
expiresAt := time.Now().Add(-1 * time.Hour) // 已过期
|
||||
|
||||
if err := repo.SaveRefreshToken(ctx, userID, tokenHash, expiresAt); err != nil {
|
||||
t.Fatalf("SaveRefreshToken failed: %v", err)
|
||||
}
|
||||
|
||||
// 过期 token 应返回 ErrRefreshTokenNotFound
|
||||
_, err = repo.FindRefreshToken(ctx, tokenHash)
|
||||
if !errors.Is(err, ErrRefreshTokenNotFound) {
|
||||
t.Fatalf("expected ErrRefreshTokenNotFound for expired token, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/hhs/camtalk/internal/auth"
|
||||
"github.com/hhs/camtalk/internal/config"
|
||||
"github.com/hhs/camtalk/internal/errors"
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
@@ -91,7 +92,7 @@ func (w *WSClient) SendError(err models.WsError) error {
|
||||
}
|
||||
|
||||
// ServeWS 处理 WebSocket 升级请求。
|
||||
func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *config.Config) gin.HandlerFunc {
|
||||
func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *config.Config, tokenMgr *auth.TokenManager) gin.HandlerFunc {
|
||||
upgrader := newUpgrader(cfg)
|
||||
heartbeatInterval := time.Duration(cfg.Server.HeartbeatInterval) * time.Second
|
||||
heartbeatTimeout := time.Duration(cfg.Server.HeartbeatTimeout) * time.Second
|
||||
@@ -100,12 +101,37 @@ func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *co
|
||||
maxHistory := cfg.Session.MaxHistory
|
||||
|
||||
return func(c *gin.Context) {
|
||||
serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, maxHistory)
|
||||
serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, maxHistory, tokenMgr)
|
||||
}
|
||||
}
|
||||
|
||||
func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orchestrator,
|
||||
upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, maxHistory int) {
|
||||
upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, maxHistory int, tokenMgr *auth.TokenManager) {
|
||||
|
||||
// --- 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
|
||||
|
||||
// --- 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
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
logger.Log.Errorw("websocket upgrade failed", "error", err)
|
||||
@@ -113,11 +139,17 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 创建会话
|
||||
sessionID, err := sessionMgr.Create(context.Background(), models.DefaultConfig())
|
||||
if err != nil {
|
||||
logger.Log.Errorw("create session failed", "error", err)
|
||||
return
|
||||
// 创建或复用会话
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
@@ -134,7 +166,7 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
||||
SessionID: sessionID,
|
||||
ServerVersion: version,
|
||||
})
|
||||
logger.Log.Infow("client connected", "session", sessionID)
|
||||
logger.Log.Infow("client connected", "session", sessionID, "user_id", userID, "username", username)
|
||||
|
||||
// 心跳检测
|
||||
lastPong := time.Now()
|
||||
|
||||
@@ -2,6 +2,7 @@ package ws
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"context"
|
||||
|
||||
"github.com/hhs/camtalk/internal/auth"
|
||||
"github.com/hhs/camtalk/internal/config"
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
@@ -132,24 +134,29 @@ func (m *MockOrchestrator) ProcessQuery(
|
||||
// --- 测试辅助函数 ---
|
||||
|
||||
// setupTestServer 创建测试用 Gin 服务器和 WebSocket URL。
|
||||
// 返回的 wsURL 已包含有效 token,可直接连接。
|
||||
func setupTestServer(t *testing.T, orch orchestrator.Orchestrator) (*httptest.Server, string) {
|
||||
t.Helper()
|
||||
|
||||
sessionMgr := session.NewMemoryManager(5*time.Minute, 20)
|
||||
t.Cleanup(func() { sessionMgr.Stop() })
|
||||
|
||||
tokenMgr := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour)
|
||||
|
||||
r := gin.New()
|
||||
cfg := &config.Config{
|
||||
App: config.AppConfig{Version: "test"},
|
||||
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
||||
Session: config.SessionConfig{MaxHistory: 20},
|
||||
}
|
||||
r.GET("/ws", ServeWS(sessionMgr, orch, cfg))
|
||||
r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr))
|
||||
|
||||
srv := httptest.NewServer(r)
|
||||
|
||||
// 构造 WebSocket URL
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
|
||||
// 生成有效 token 并构造 WebSocket URL
|
||||
token, _, err := tokenMgr.GeneratePair("test-user", "testuser")
|
||||
require.NoError(t, err)
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws?token=" + token
|
||||
|
||||
return srv, wsURL
|
||||
}
|
||||
@@ -567,3 +574,156 @@ func TestWS_QueryWithTTSDisabled(t *testing.T) {
|
||||
err = conn.ReadJSON(&extra)
|
||||
assert.Error(t, err, "不应有额外消息")
|
||||
}
|
||||
|
||||
// --- 认证测试辅助 ---
|
||||
|
||||
// setupTestServerEx 创建测试服务器,返回 tokenMgr 和 sessionMgr 以便测试控制。
|
||||
func setupTestServerEx(t *testing.T, orch orchestrator.Orchestrator) (*httptest.Server, *auth.TokenManager, *session.MemoryManager) {
|
||||
t.Helper()
|
||||
|
||||
sessionMgr := session.NewMemoryManager(5*time.Minute, 20)
|
||||
t.Cleanup(func() { sessionMgr.Stop() })
|
||||
|
||||
tokenMgr := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour)
|
||||
|
||||
r := gin.New()
|
||||
cfg := &config.Config{
|
||||
App: config.AppConfig{Version: "test"},
|
||||
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
||||
Session: config.SessionConfig{MaxHistory: 20},
|
||||
}
|
||||
r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr))
|
||||
|
||||
srv := httptest.NewServer(r)
|
||||
return srv, tokenMgr, sessionMgr
|
||||
}
|
||||
|
||||
// httpGet 发送 HTTP GET 并返回状态码。
|
||||
func httpGet(t *testing.T, url string) int {
|
||||
t.Helper()
|
||||
resp, err := http.Get(url)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
// --- 认证测试用例 ---
|
||||
|
||||
// TestWS_AuthMissingToken 验证无 token 时返回 401。
|
||||
func TestWS_AuthMissingToken(t *testing.T) {
|
||||
srv, _, _ := setupTestServerEx(t, &MockOrchestrator{})
|
||||
defer srv.Close()
|
||||
|
||||
httpURL := srv.URL + "/ws"
|
||||
status := httpGet(t, httpURL)
|
||||
assert.Equal(t, http.StatusUnauthorized, status)
|
||||
}
|
||||
|
||||
// TestWS_AuthInvalidToken 验证无效 token 时返回 401。
|
||||
func TestWS_AuthInvalidToken(t *testing.T) {
|
||||
srv, _, _ := setupTestServerEx(t, &MockOrchestrator{})
|
||||
defer srv.Close()
|
||||
|
||||
httpURL := srv.URL + "/ws?token=invalid-token"
|
||||
status := httpGet(t, httpURL)
|
||||
assert.Equal(t, http.StatusUnauthorized, status)
|
||||
}
|
||||
|
||||
// TestWS_AuthExpiredToken 验证过期 token 时返回 401。
|
||||
func TestWS_AuthExpiredToken(t *testing.T) {
|
||||
// 创建一个 access TTL 极短的 tokenMgr
|
||||
sessionMgr := session.NewMemoryManager(5*time.Minute, 20)
|
||||
defer sessionMgr.Stop()
|
||||
|
||||
tokenMgr := auth.NewTokenManager("test-secret", -1*time.Minute, 7*24*time.Hour) // 已过期
|
||||
|
||||
r := gin.New()
|
||||
cfg := &config.Config{
|
||||
App: config.AppConfig{Version: "test"},
|
||||
Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60},
|
||||
Session: config.SessionConfig{MaxHistory: 20},
|
||||
}
|
||||
r.GET("/ws", ServeWS(sessionMgr, &MockOrchestrator{}, cfg, tokenMgr))
|
||||
srv := httptest.NewServer(r)
|
||||
defer srv.Close()
|
||||
|
||||
token, _, err := tokenMgr.GeneratePair("test-user", "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
httpURL := srv.URL + "/ws?token=" + token
|
||||
status := httpGet(t, httpURL)
|
||||
assert.Equal(t, http.StatusUnauthorized, status)
|
||||
}
|
||||
|
||||
// TestWS_AuthValidToken 验证有效 token 能成功建立 WS 连接。
|
||||
func TestWS_AuthValidToken(t *testing.T) {
|
||||
srv, tokenMgr, _ := setupTestServerEx(t, &MockOrchestrator{})
|
||||
defer srv.Close()
|
||||
|
||||
token, _, err := tokenMgr.GeneratePair("user-1", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws?token=" + token
|
||||
conn := connectWS(t, wsURL)
|
||||
|
||||
msg := readJSON(t, conn)
|
||||
assert.Equal(t, "connected", msg["type"])
|
||||
assert.NotEmpty(t, msg["session_id"])
|
||||
}
|
||||
|
||||
// TestWS_AuthConversationIDResume 验证通过 conversation_id 恢复已有对话。
|
||||
func TestWS_AuthConversationIDResume(t *testing.T) {
|
||||
srv, tokenMgr, sessionMgr := setupTestServerEx(t, &MockOrchestrator{})
|
||||
defer srv.Close()
|
||||
|
||||
userID := "user-1"
|
||||
|
||||
// 先创建一个属于该用户的 session
|
||||
ctx := context.Background()
|
||||
sessionID, err := sessionMgr.Create(ctx, userID, models.DefaultConfig())
|
||||
require.NoError(t, err)
|
||||
|
||||
token, _, err := tokenMgr.GeneratePair(userID, "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 带 conversation_id 连接
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") +
|
||||
"/ws?token=" + token + "&conversation_id=" + sessionID
|
||||
conn := connectWS(t, wsURL)
|
||||
|
||||
msg := readJSON(t, conn)
|
||||
assert.Equal(t, "connected", msg["type"])
|
||||
assert.Equal(t, sessionID, msg["session_id"], "应复用已有 session")
|
||||
}
|
||||
|
||||
// TestWS_AuthConversationIDNotFound 验证 conversation_id 不存在时返回 401。
|
||||
func TestWS_AuthConversationIDNotFound(t *testing.T) {
|
||||
srv, tokenMgr, _ := setupTestServerEx(t, &MockOrchestrator{})
|
||||
defer srv.Close()
|
||||
|
||||
token, _, err := tokenMgr.GeneratePair("user-1", "alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
httpURL := srv.URL + "/ws?token=" + token + "&conversation_id=nonexistent-id"
|
||||
status := httpGet(t, httpURL)
|
||||
assert.Equal(t, http.StatusUnauthorized, status)
|
||||
}
|
||||
|
||||
// TestWS_AuthConversationIDOwnership 验证 conversation_id 不属于当前用户时返回 401。
|
||||
func TestWS_AuthConversationIDOwnership(t *testing.T) {
|
||||
srv, tokenMgr, sessionMgr := setupTestServerEx(t, &MockOrchestrator{})
|
||||
defer srv.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
// user-A 创建 session
|
||||
sessionID, err := sessionMgr.Create(ctx, "user-A", models.DefaultConfig())
|
||||
require.NoError(t, err)
|
||||
|
||||
// user-B 尝试连接该 session
|
||||
token, _, err := tokenMgr.GeneratePair("user-B", "bob")
|
||||
require.NoError(t, err)
|
||||
|
||||
httpURL := srv.URL + "/ws?token=" + token + "&conversation_id=" + sessionID
|
||||
status := httpGet(t, httpURL)
|
||||
assert.Equal(t, http.StatusUnauthorized, status, "非 owner 访问应返回 401")
|
||||
}
|
||||
|
||||
5
backend/migrations/001_users.down.sql
Normal file
5
backend/migrations/001_users.down.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- 删除 Refresh Token 表(自动删除相关索引)
|
||||
DROP TABLE IF EXISTS refresh_tokens;
|
||||
|
||||
-- 删除用户表(自动删除相关索引)
|
||||
DROP TABLE IF EXISTS users;
|
||||
26
backend/migrations/001_users.up.sql
Normal file
26
backend/migrations/001_users.up.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- 用户表
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 用户名索引(用于登录查询)
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
|
||||
-- Refresh Token 表
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Token hash 索引(用于刷新验证)
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||
|
||||
-- 用户 ID 索引(用于登出所有设备)
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||
1
backend/migrations/002_messages.down.sql
Normal file
1
backend/migrations/002_messages.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS messages;
|
||||
17
backend/migrations/002_messages.up.sql
Normal file
17
backend/migrations/002_messages.up.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- 消息表
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id UUID NOT NULL,
|
||||
role VARCHAR(16) NOT NULL, -- "user" | "assistant" | "system"
|
||||
content TEXT NOT NULL,
|
||||
tokens_used INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 按会话查询消息(分页核心索引)
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session_id_created_at
|
||||
ON messages(session_id, created_at);
|
||||
|
||||
-- 按会话查询最后一条消息
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session_id_id_desc
|
||||
ON messages(session_id, id DESC);
|
||||
1
backend/migrations/003_sessions.down.sql
Normal file
1
backend/migrations/003_sessions.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
11
backend/migrations/003_sessions.up.sql
Normal file
11
backend/migrations/003_sessions.up.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL,
|
||||
title VARCHAR(256) NOT NULL DEFAULT '新对话',
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_updated ON sessions (user_id, updated_at DESC);
|
||||
9
backend/migrations/embed.go
Normal file
9
backend/migrations/embed.go
Normal file
@@ -0,0 +1,9 @@
|
||||
// Package migrations 提供数据库迁移 SQL 文件的嵌入式访问。
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
// FS 包含所有迁移 SQL 文件。
|
||||
//
|
||||
//go:embed *.sql
|
||||
var FS embed.FS
|
||||
@@ -19,10 +19,33 @@ services:
|
||||
container_name: camtalk-backend
|
||||
environment:
|
||||
- APP_ENV=production
|
||||
- CAMTALK_STORAGE_DRIVER=postgres
|
||||
- CAMTALK_STORAGE_DSN=postgres://camtalk:camtalk123@postgres:5432/camtalk?sslmode=disable
|
||||
- CAMTALK_AUTH_JWT_SECRET=78uWBBAF8XEQEotKDlrnlnd4y8i4WN3E4zXmNmC8BYQ=
|
||||
depends_on:
|
||||
- postgres
|
||||
networks:
|
||||
- camtalk-net
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
# 阿里云镜像,避免从 Docker Hub 拉取超时
|
||||
image: registry.cn-hangzhou.aliyuncs.com/library/postgres:15-alpine
|
||||
container_name: camtalk-postgres
|
||||
environment:
|
||||
POSTGRES_USER: camtalk
|
||||
POSTGRES_PASSWORD: camtalk123
|
||||
POSTGRES_DB: camtalk
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./backend/migrations:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- camtalk-net
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
networks:
|
||||
camtalk-net:
|
||||
driver: bridge
|
||||
|
||||
623
docs/03-接口文档.md
623
docs/03-接口文档.md
@@ -13,16 +13,28 @@
|
||||
|
||||
```
|
||||
浏览器 Go Gateway :8080
|
||||
WebSocket Client <--> /ws (实时对话)
|
||||
HTTP Client --> GET /api/health
|
||||
HTTP Client <--> POST/DELETE /api/sessions
|
||||
WebSocket Client <--> /ws?token=<jwt> (实时对话,需 JWT 认证)
|
||||
HTTP Client --> GET /api/health (健康检查)
|
||||
HTTP Client <--> POST /api/auth/* (注册/登录/刷新/登出)
|
||||
HTTP Client <--> GET/POST/PATCH/DELETE (对话 CRUD)
|
||||
/api/conversations/*
|
||||
HTTP Client <--> GET /api/conversations/:id (历史消息)
|
||||
/messages
|
||||
HTTP Client ~~> POST/DELETE /api/sessions (已废弃,保留兼容)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 一、WebSocket 协议
|
||||
|
||||
连接地址:`ws://localhost:8080/ws`
|
||||
连接地址:`ws://localhost:8080/ws?token=<access_token>&conversation_id=<uuid>`
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `token` | 是 | JWT access_token,缺失或无效时返回 401 |
|
||||
| `conversation_id` | 否 | 恢复已有对话;省略则创建新对话 |
|
||||
|
||||
> 详见"REST API → WebSocket 认证变更"章节。
|
||||
|
||||
### 消息格式约定
|
||||
|
||||
@@ -95,8 +107,9 @@ interface PingMessage {
|
||||
```typescript
|
||||
interface ConnectedMessage {
|
||||
type: "connected";
|
||||
session_id: string; // 服务端生成的会话 ID
|
||||
server_version: string; // 服务端版本号,如 "0.1.0"
|
||||
session_id: string; // 服务端生成的会话 ID
|
||||
conversation_id: string; // 同 session_id,便于前端统一使用
|
||||
server_version: string; // 服务端版本号,如 "0.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -265,13 +278,446 @@ Client Server
|
||||
|
||||
## 二、REST API
|
||||
|
||||
### 通用约定
|
||||
|
||||
#### 认证方式
|
||||
|
||||
需要认证的接口在请求头携带 JWT access token:
|
||||
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
未认证或 token 过期时返回 `401 Unauthorized`。
|
||||
|
||||
#### 错误响应格式
|
||||
|
||||
所有错误响应统一结构:
|
||||
|
||||
```typescript
|
||||
interface ApiError {
|
||||
code: string; // 机器可读错误码
|
||||
message: string; // 人类可读描述
|
||||
}
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "USERNAME_TAKEN",
|
||||
"message": "username already taken"
|
||||
}
|
||||
```
|
||||
|
||||
#### 输入校验规则
|
||||
|
||||
| 字段 | 规则 |
|
||||
|------|------|
|
||||
| `username` | 3-64 字符,仅允许字母、数字、下划线 |
|
||||
| `password` | 8-72 字符 |
|
||||
|
||||
---
|
||||
|
||||
### 认证接口(`/api/auth`)
|
||||
|
||||
#### 注册
|
||||
|
||||
```
|
||||
POST /api/auth/register
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求体**:
|
||||
|
||||
```typescript
|
||||
interface RegisterRequest {
|
||||
username: string; // 3-64 字符
|
||||
password: string; // 8-72 字符
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应** `201 Created`:
|
||||
|
||||
```typescript
|
||||
interface AuthResponse {
|
||||
user: {
|
||||
id: string; // UUID
|
||||
username: string;
|
||||
created_at: string; // ISO 8601
|
||||
};
|
||||
access_token: string; // JWT,15 分钟有效
|
||||
refresh_token: string; // JWT,7 天有效
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"username": "alice",
|
||||
"created_at": "2026-06-14T10:00:00Z"
|
||||
},
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
|
||||
| 状态码 | code | 场景 |
|
||||
|--------|------|------|
|
||||
| 400 | `INVALID_INPUT` | 用户名/密码不符合校验规则 |
|
||||
| 409 | `USERNAME_TAKEN` | 用户名已存在 |
|
||||
|
||||
#### 登录
|
||||
|
||||
```
|
||||
POST /api/auth/login
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求体**:
|
||||
|
||||
```typescript
|
||||
interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应** `200 OK`:同 `AuthResponse` 结构。
|
||||
|
||||
**错误响应**:
|
||||
|
||||
| 状态码 | code | 场景 |
|
||||
|--------|------|------|
|
||||
| 400 | `INVALID_INPUT` | 请求参数缺失或格式错误 |
|
||||
| 401 | `INVALID_CREDENTIALS` | 用户名或密码错误 |
|
||||
|
||||
#### 刷新 Token
|
||||
|
||||
```
|
||||
POST /api/auth/refresh
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求体**:
|
||||
|
||||
```typescript
|
||||
interface RefreshRequest {
|
||||
refresh_token: string; // 之前签发的 refresh_token
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应** `200 OK`:同 `AuthResponse` 结构(返回新的 access_token + refresh_token,旧 refresh_token 失效——Token 轮转)。
|
||||
|
||||
**错误响应**:
|
||||
|
||||
| 状态码 | code | 场景 |
|
||||
|--------|------|------|
|
||||
| 401 | `INVALID_TOKEN` | refresh_token 无效或已过期 |
|
||||
|
||||
#### 登出
|
||||
|
||||
```
|
||||
POST /api/auth/logout
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
**请求体**:
|
||||
|
||||
```typescript
|
||||
interface LogoutRequest {
|
||||
refresh_token: string; // 要废弃的 refresh_token
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应** `204 No Content`(无响应体)。
|
||||
|
||||
**错误响应**:
|
||||
|
||||
| 状态码 | code | 场景 |
|
||||
|--------|------|------|
|
||||
| 401 | `INVALID_TOKEN` | access_token 无效或已过期 |
|
||||
|
||||
---
|
||||
|
||||
### 对话接口(`/api/conversations`)
|
||||
|
||||
> 以下所有接口均需认证(`Authorization: Bearer <access_token>`),省略不重复标注。
|
||||
|
||||
#### 对话列表
|
||||
|
||||
```
|
||||
GET /api/conversations?page=1&size=20
|
||||
```
|
||||
|
||||
**查询参数**:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `page` | int | 1 | 页码,从 1 开始 |
|
||||
| `size` | int | 20 | 每页条数,最大 50 |
|
||||
|
||||
**成功响应** `200 OK`:
|
||||
|
||||
```typescript
|
||||
interface ConversationListResponse {
|
||||
conversations: ConversationSummary[];
|
||||
total: number; // 总条数
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface ConversationSummary {
|
||||
id: string; // 对话 ID(即 session_id)
|
||||
title: string; // 对话标题(首条消息前 20 字)
|
||||
last_message: string; // 最后一条消息内容预览
|
||||
message_count: number; // 消息总数
|
||||
updated_at: string; // ISO 8601,最后活跃时间
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"conversations": [
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"title": "这是一朵红色的玫瑰…",
|
||||
"last_message": "它看起来很美丽。",
|
||||
"message_count": 4,
|
||||
"updated_at": "2026-06-14T10:05:30Z"
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"size": 20
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
|
||||
| 状态码 | code | 场景 |
|
||||
|--------|------|------|
|
||||
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
|
||||
|
||||
#### 创建对话
|
||||
|
||||
```
|
||||
POST /api/conversations
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求体**(可选,全部有默认值):
|
||||
|
||||
```typescript
|
||||
interface CreateConversationRequest {
|
||||
config?: {
|
||||
tts_enabled?: boolean; // 默认 true
|
||||
detail_level?: "low" | "high"; // 默认 "low"
|
||||
language?: string; // 默认 "zh-CN"
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应** `201 Created`:
|
||||
|
||||
```typescript
|
||||
interface ConversationDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
config: {
|
||||
tts_enabled: boolean;
|
||||
detail_level: "low" | "high";
|
||||
language: string;
|
||||
};
|
||||
created_at: string; // ISO 8601
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "660e8400-e29b-41d4-a716-446655440001",
|
||||
"title": "新对话",
|
||||
"config": {
|
||||
"tts_enabled": true,
|
||||
"detail_level": "low",
|
||||
"language": "zh-CN"
|
||||
},
|
||||
"created_at": "2026-06-14T11:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
|
||||
| 状态码 | code | 场景 |
|
||||
|--------|------|------|
|
||||
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
|
||||
|
||||
#### 获取对话详情
|
||||
|
||||
```
|
||||
GET /api/conversations/:id
|
||||
```
|
||||
|
||||
**成功响应** `200 OK`:同 `ConversationDetail` 结构。
|
||||
|
||||
**错误响应**:
|
||||
|
||||
| 状态码 | code | 场景 |
|
||||
|--------|------|------|
|
||||
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
|
||||
| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 |
|
||||
|
||||
#### 更新对话标题
|
||||
|
||||
```
|
||||
PATCH /api/conversations/:id
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求体**:
|
||||
|
||||
```typescript
|
||||
interface UpdateTitleRequest {
|
||||
title: string; // 1-100 字符
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应** `200 OK`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"title": "新的自定义标题"
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
|
||||
| 状态码 | code | 场景 |
|
||||
|--------|------|------|
|
||||
| 400 | `INVALID_INPUT` | title 为空或超长 |
|
||||
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
|
||||
| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 |
|
||||
|
||||
#### 删除对话
|
||||
|
||||
```
|
||||
DELETE /api/conversations/:id
|
||||
```
|
||||
|
||||
**成功响应** `204 No Content`(无响应体)。
|
||||
|
||||
**错误响应**:
|
||||
|
||||
| 状态码 | code | 场景 |
|
||||
|--------|------|------|
|
||||
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
|
||||
| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 |
|
||||
|
||||
#### 获取对话消息
|
||||
|
||||
```
|
||||
GET /api/conversations/:id/messages?limit=50&before=<message_id>
|
||||
```
|
||||
|
||||
**查询参数**:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `limit` | int | 50 | 返回条数,最大 100 |
|
||||
| `before` | int64 | — | 游标分页:返回此 message_id 之前的消息(不含),用于加载更多 |
|
||||
|
||||
**成功响应** `200 OK`:
|
||||
|
||||
```typescript
|
||||
interface MessagesResponse {
|
||||
messages: StoredMessage[];
|
||||
has_more: boolean; // 是否还有更早的消息
|
||||
}
|
||||
|
||||
interface StoredMessage {
|
||||
id: number; // 自增 ID,用于游标分页
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
tokens_used: number; // 该条消息消耗的 token 数
|
||||
created_at: string; // ISO 8601
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"id": 1001,
|
||||
"role": "user",
|
||||
"content": "这是什么花?",
|
||||
"tokens_used": 0,
|
||||
"created_at": "2026-06-14T10:01:00Z"
|
||||
},
|
||||
{
|
||||
"id": 1002,
|
||||
"role": "assistant",
|
||||
"content": "这是一朵红色的玫瑰。",
|
||||
"tokens_used": 42,
|
||||
"created_at": "2026-06-14T10:01:02Z"
|
||||
}
|
||||
],
|
||||
"has_more": false
|
||||
}
|
||||
```
|
||||
|
||||
**分页用法**:首次请求不带 `before`,获取最新消息。滚动到顶部时,取当前列表最小的 `id` 作为 `before` 参数请求更早的消息。
|
||||
|
||||
**错误响应**:
|
||||
|
||||
| 状态码 | code | 场景 |
|
||||
|--------|------|------|
|
||||
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
|
||||
| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 |
|
||||
|
||||
---
|
||||
|
||||
### WebSocket 认证变更
|
||||
|
||||
连接地址变更为带 token 的查询参数:
|
||||
|
||||
```
|
||||
ws://localhost:8080/ws?token=<access_token>&conversation_id=<uuid>
|
||||
```
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `token` | 是 | JWT access_token |
|
||||
| `conversation_id` | 否 | 恢复已有对话;省略则创建新对话 |
|
||||
|
||||
**认证失败响应**(HTTP 升级前返回):
|
||||
|
||||
| 状态码 | 场景 |
|
||||
|--------|------|
|
||||
| 401 | token 缺失、无效或已过期 |
|
||||
|
||||
**conversation_id 校验失败**:
|
||||
|
||||
| 场景 | 处理 |
|
||||
|------|------|
|
||||
| 对话不存在 | 返回 401,`{"error": "SESSION_NOT_FOUND"}` |
|
||||
| 对话不属于当前用户 | 返回 401,`{"error": "SESSION_NOT_FOUND"}`(与不存在相同,避免信息泄露) |
|
||||
|
||||
---
|
||||
|
||||
### 健康检查
|
||||
|
||||
```
|
||||
GET /api/health
|
||||
```
|
||||
|
||||
响应:
|
||||
无需认证。
|
||||
|
||||
**成功响应** `200 OK`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -282,43 +728,21 @@ GET /api/health
|
||||
}
|
||||
```
|
||||
|
||||
### 创建会话(可选,MVP 自动创建)
|
||||
---
|
||||
|
||||
### ~~旧会话接口~~(已废弃)
|
||||
|
||||
> 以下端点已废弃,保留仅为向后兼容。新代码应使用 `/api/conversations` 系列接口。
|
||||
|
||||
```
|
||||
POST /api/sessions
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"config": {
|
||||
"tts_enabled": true,
|
||||
"detail_level": "low",
|
||||
"language": "zh-CN"
|
||||
}
|
||||
}
|
||||
POST /api/sessions → 改用 POST /api/conversations
|
||||
DELETE /api/sessions/{id} → 改用 DELETE /api/conversations/{id}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"created_at": "2026-06-12T15:41:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 销毁会话
|
||||
|
||||
```
|
||||
DELETE /api/sessions/{session_id}
|
||||
```
|
||||
|
||||
响应:`204 No Content`
|
||||
|
||||
### 预留端点(暂不实现)
|
||||
|
||||
| 端点 | 方法 | 用途 |
|
||||
|------|------|------|
|
||||
| `/api/sessions/{id}/messages` | GET | 查询对话历史 |
|
||||
| `/api/usage` | GET | 查询用量统计 |
|
||||
| `/api/users/{id}/preferences` | GET/PUT | 用户偏好管理 |
|
||||
|
||||
@@ -689,6 +1113,7 @@ type Config struct {
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
AI AIConfig `mapstructure:"ai"`
|
||||
Storage StorageConfig `mapstructure:"storage"`
|
||||
Auth AuthConfig `mapstructure:"auth"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
}
|
||||
|
||||
@@ -746,6 +1171,12 @@ type StorageConfig struct {
|
||||
DSN string `mapstructure:"dsn"` // PostgreSQL 连接串,driver=postgres 时必填
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
JWTSecret string `mapstructure:"jwt_secret"` // 必须通过 CAMTALK_AUTH_JWT_SECRET 设置
|
||||
AccessTTL int `mapstructure:"access_ttl"` // 分钟,默认 15
|
||||
RefreshTTL int `mapstructure:"refresh_ttl"` // 分钟,默认 10080(7 天)
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level string `mapstructure:"level"` // "debug" | "info" | "warn" | "error",默认 "info"
|
||||
Format string `mapstructure:"format"` // "json" | "console",生产用 json
|
||||
@@ -791,6 +1222,10 @@ ai:
|
||||
storage:
|
||||
driver: memory
|
||||
|
||||
auth:
|
||||
access_ttl: 15 # access token 有效期(分钟)
|
||||
refresh_ttl: 10080 # refresh token 有效期(分钟,7 天)
|
||||
|
||||
log:
|
||||
level: info
|
||||
format: console
|
||||
@@ -811,6 +1246,9 @@ Viper 自动将配置项映射为环境变量,规则:**前缀 `CAMTALK_` +
|
||||
| `ai.llm.model` | `CAMTALK_AI_LLM_MODEL` | `gpt-4o` |
|
||||
| `storage.driver` | `CAMTALK_STORAGE_DRIVER` | `postgres` |
|
||||
| `storage.dsn` | `CAMTALK_STORAGE_DSN` | — |
|
||||
| `auth.jwt_secret` | `CAMTALK_AUTH_JWT_SECRET` | —(必填,仅环境变量) |
|
||||
| `auth.access_ttl` | `CAMTALK_AUTH_ACCESS_TTL` | `15` |
|
||||
| `auth.refresh_ttl` | `CAMTALK_AUTH_REFRESH_TTL` | `10080` |
|
||||
| `app.env` | `CAMTALK_APP_ENV` | `prod` |
|
||||
| `log.level` | `CAMTALK_LOG_LEVEL` | `warn` |
|
||||
| `log.format` | `CAMTALK_LOG_FORMAT` | `json` |
|
||||
@@ -892,6 +1330,7 @@ CAMTALK_AI_STT_API_KEY=xxx \
|
||||
CAMTALK_AI_TTS_API_KEY=xxx \
|
||||
CAMTALK_STORAGE_DRIVER=postgres \
|
||||
CAMTALK_STORAGE_DSN="postgres://user:pass@db:5432/camtalk?sslmode=disable" \
|
||||
CAMTALK_AUTH_JWT_SECRET="$(openssl rand -hex 32)" \
|
||||
CAMTALK_LOG_LEVEL=warn \
|
||||
CAMTALK_LOG_FORMAT=json \
|
||||
./bin/camtalk
|
||||
@@ -910,7 +1349,10 @@ CAMTALK_LOG_FORMAT=json \
|
||||
|
||||
type Session struct {
|
||||
ID string `json:"session_id"`
|
||||
UserID string `json:"user_id"` // 关联用户,空串表示匿名
|
||||
Title string `json:"title"` // 对话标题
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Config SessionConfig `json:"config"`
|
||||
}
|
||||
|
||||
@@ -932,6 +1374,33 @@ type Message struct {
|
||||
Role string `json:"role"` // "user" | "assistant"
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// ---- 用户模块 ----
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ConversationSummary struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
LastMessage string `json:"last_message"`
|
||||
MessageCount int `json:"message_count"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type StoredMessage struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID string `json:"-"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
TokensUsed int `json:"tokens_used"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
```
|
||||
|
||||
### TypeScript 前端模型
|
||||
@@ -957,6 +1426,60 @@ interface ChatMessage {
|
||||
tokensUsed?: number;
|
||||
}
|
||||
|
||||
// ---- 用户模块 ----
|
||||
|
||||
interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string; // UUID
|
||||
username: string;
|
||||
created_at: string; // ISO 8601
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
user: User;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
interface ConversationSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
last_message: string;
|
||||
message_count: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ConversationListResponse {
|
||||
conversations: ConversationSummary[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface ConversationDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
config: SessionConfig;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface StoredMessage {
|
||||
id: number;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
tokens_used: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface MessagesResponse {
|
||||
messages: StoredMessage[];
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
// WebSocket 消息联合类型
|
||||
type ServerMessage =
|
||||
| ConnectedMessage
|
||||
@@ -1054,18 +1577,22 @@ func NewApp(cfg *Config) *App {
|
||||
|
||||
## 九、错误码
|
||||
|
||||
| 错误码 | 含义 | 客户端处理建议 |
|
||||
|--------|------|--------------|
|
||||
| `INVALID_MESSAGE` | 消息格式不合法 | 检查 JSON 结构,不重试 |
|
||||
| `SESSION_NOT_FOUND` | 会话不存在或已过期 | 重新建立 WebSocket 连接 |
|
||||
| `RATE_LIMITED` | 请求频率超限 | 延迟后重试,提示用户稍等 |
|
||||
| `IMAGE_TOO_LARGE` | 图像超过 4MB 限制 | 降低分辨率或压缩质量 |
|
||||
| `AUDIO_TOO_SHORT` | 音频片段 < 250ms | 忽略,等待下次语音输入 |
|
||||
| `LLM_TIMEOUT` | LLM 推理超时(>10s) | 提示用户重试 |
|
||||
| `LLM_ERROR` | LLM 服务异常 | 提示用户重试,服务端记录日志 |
|
||||
| `STT_ERROR` | 语音识别失败 | 回退到纯文本输入模式 |
|
||||
| `TTS_ERROR` | 语音合成失败 | 静默回退到纯文本回复 |
|
||||
| `INTERNAL_ERROR` | 服务端内部错误 | 提示用户重试 |
|
||||
| 错误码 | HTTP 状态码 | 含义 | 客户端处理建议 |
|
||||
|--------|-----------|------|--------------|
|
||||
| `INVALID_MESSAGE` | — | 消息格式不合法(WS) | 检查 JSON 结构,不重试 |
|
||||
| `SESSION_NOT_FOUND` | 404 | 会话/对话不存在或已过期 | 重新建立连接或刷新列表 |
|
||||
| `RATE_LIMITED` | 429 | 请求频率超限 | 延迟后重试,提示用户稍等 |
|
||||
| `IMAGE_TOO_LARGE` | — | 图像超过 4MB 限制(WS) | 降低分辨率或压缩质量 |
|
||||
| `AUDIO_TOO_SHORT` | — | 音频片段 < 250ms(WS) | 忽略,等待下次语音输入 |
|
||||
| `LLM_TIMEOUT` | — | LLM 推理超时 >10s(WS) | 提示用户重试 |
|
||||
| `LLM_ERROR` | — | LLM 服务异常(WS) | 提示用户重试,服务端记录日志 |
|
||||
| `STT_ERROR` | — | 语音识别失败(WS) | 回退到纯文本输入模式 |
|
||||
| `TTS_ERROR` | — | 语音合成失败(WS) | 静默回退到纯文本回复 |
|
||||
| `INTERNAL_ERROR` | 500 | 服务端内部错误 | 提示用户重试 |
|
||||
| `USERNAME_TAKEN` | 409 | 用户名已被注册 | 提示换一个用户名 |
|
||||
| `INVALID_CREDENTIALS` | 401 | 用户名或密码错误 | 提示检查输入 |
|
||||
| `INVALID_TOKEN` | 401 | JWT 无效或已过期 | 尝试 refresh,失败则重新登录 |
|
||||
| `INVALID_INPUT` | 400 | 请求参数校验失败 | 检查字段规则后重试 |
|
||||
|
||||
## 十、连接管理
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
│ ├── LLM: GPT-4o(默认) / 通义千问等 OpenAI 兼容模型
|
||||
│ └── TTS: OpenAI TTS(默认) / MiMo TTS
|
||||
├── 持久化层 → 数据库选型: PostgreSQL(规划中,MVP 阶段使用内存存储)
|
||||
├── 认证与用户系统
|
||||
│ ├── 认证方案: JWT (HS256), access 15min + refresh 7day
|
||||
│ ├── JWT 库: golang-jwt/jwt/v5
|
||||
│ ├── 密码哈希: bcrypt
|
||||
│ ├── 数据库驱动: pgx/v5(手写 SQL,不用 ORM)
|
||||
│ └── 前端 Token 存储: localStorage
|
||||
└── 前端边缘处理层
|
||||
├── 边缘推理: ONNX Runtime Web(规划中,MVP 使用 Canvas 像素比较)
|
||||
├── 语音检测: @ricky0123/vad-web
|
||||
@@ -214,3 +220,80 @@ vad-web 是"够用且最轻"的平衡点——直接包装浏览器原生 WebRTC
|
||||
| MediaDevices API | 直接用浏览器原生接口,不加封装层 |
|
||||
|
||||
与"前端做轻量预处理"原则一致:前端层只需采集和判断"有没有值得发给后端的数据"。
|
||||
|
||||
---
|
||||
|
||||
## 四、认证与用户系统选型
|
||||
|
||||
### 总览
|
||||
|
||||
| 能力 | 选型 | 选择理由 |
|
||||
|------|------|---------|
|
||||
| 认证方案 | JWT (HS256) | 无状态,分布式友好,实现简单 |
|
||||
| JWT 库 | golang-jwt/jwt/v5 | 社区主流,v5 活跃维护 |
|
||||
| 密码哈希 | bcrypt | Go 标准库直接可用,安全性足够 |
|
||||
| 数据库驱动 | pgx/v5 | Go 生态性能最优的 PostgreSQL 驱动 |
|
||||
| 数据库迁移 | 手写 SQL | MVP 阶段足够,后续可引入 golang-migrate |
|
||||
|
||||
### 认证方案:JWT
|
||||
|
||||
| 方案 | 特点 | 适用场景 |
|
||||
|------|------|---------|
|
||||
| **JWT (HS256)** | 无状态 token,服务端不存 session,水平扩展友好 | 分布式部署、前后端分离 |
|
||||
| Session + Cookie | 有状态,服务端存 session(通常 Redis) | 传统 Web 应用、需要服务端控制会话 |
|
||||
| OAuth2 | 第三方登录授权 | 需要接入微信/GitHub 等第三方登录 |
|
||||
|
||||
选择 JWT 的核心理由:项目架构是前后端分离 + WebSocket 长连接,JWT 无需服务端维护 session 状态,天然适配。HS256 对称签名足以满足安全需求,实现比 RS256 简单。
|
||||
|
||||
Token 策略采用 **access (15min) + refresh (7day) 双 token**:access_token 短生命周期降低泄露风险,refresh_token 支持无感续期。
|
||||
|
||||
### JWT 库:golang-jwt/jwt/v5
|
||||
|
||||
| 方案 | 状态 | 特点 |
|
||||
|------|------|------|
|
||||
| **golang-jwt/jwt/v5** | 活跃维护 | dgrijalva/jwt-go 的官方继任,社区主流 |
|
||||
| dgrijalva/jwt-go | 已停维护 | 原始库,不再更新 |
|
||||
| lestrrat-go/jwx | 活跃 | 功能更全(JWE/JWS),但项目只需签名,过度引入 |
|
||||
|
||||
v5 是 Go 生态中 JWT 的事实标准,API 简洁,文档完善。
|
||||
|
||||
### 密码哈希:bcrypt
|
||||
|
||||
| 方案 | 特点 | 选择理由 |
|
||||
|------|------|---------|
|
||||
| **bcrypt** | 自适应 cost factor,抗暴力破解 | Go 标准库 `golang.org/x/crypto/bcrypt` 直接可用 |
|
||||
| argon2 | 2015 年密码哈希竞赛冠军,抗 GPU/ASIC | 安全性更高,但 Go 生态库不如 bcrypt 成熟 |
|
||||
| scrypt | 内存硬哈希 | 参数调优复杂,bcrypt 已足够 |
|
||||
|
||||
bcrypt 的 `cost` 参数可随硬件升级调大,当前默认 cost=10 足够安全。
|
||||
|
||||
### 数据库驱动:pgx/v5
|
||||
|
||||
| 方案 | 特点 | 适用场景 |
|
||||
|------|------|---------|
|
||||
| **pgx/v5** | 原生 PostgreSQL 协议实现,连接池 pgxpool,性能最优 | 需要高性能、直接写 SQL |
|
||||
| GORM | 全功能 ORM,自动迁移、关联预加载 | 快速开发、不想写 SQL |
|
||||
| Ent | Facebook 出品,类型安全的 ORM | 大型项目、强类型需求 |
|
||||
| database/sql + lib/pq | 标准接口,但 lib/pq 已停维护 | 简单场景 |
|
||||
|
||||
项目规模不大(4 张表),手写 SQL 更可控,避免 ORM 的抽象泄漏和性能黑盒。pgx 原生支持 `pgxpool` 连接池,无需额外引入。
|
||||
|
||||
### 数据库迁移:手写 SQL
|
||||
|
||||
| 方案 | 特点 | 适用场景 |
|
||||
|------|------|---------|
|
||||
| **手写 SQL** | 零依赖,完全可控 | 表少(<10 张)、团队小 |
|
||||
| golang-migrate | CLI + 库双模式,支持版本回滚 | 表多、需要严格版本管理 |
|
||||
| Atlas | 声明式迁移,HCL 定义 schema | 大型项目、多环境管理 |
|
||||
|
||||
MVP 阶段 4 张表,手写 `schema.sql` 即可。后续表结构复杂后可引入 golang-migrate。
|
||||
|
||||
### 前端 Token 存储
|
||||
|
||||
| 方案 | 特点 | 选择理由 |
|
||||
|------|------|---------|
|
||||
| **localStorage** | 持久化存储,刷新不丢失,JS 可直接读写 | 简单直接,SPA 应用标准做法 |
|
||||
| httpOnly Cookie | 防 XSS 读取,但需防 CSRF | 传统 Web 应用,需额外 CSRF 防护 |
|
||||
| sessionStorage | 仅当前标签页有效 | 关闭标签页需重新登录,体验差 |
|
||||
|
||||
JWT 存 localStorage,配合请求拦截器统一附加 `Authorization: Bearer <token>` header。refresh_token 同样存 localStorage,401 时自动触发刷新流程。
|
||||
|
||||
750
docs/11-持久化与用户系统设计.md
Normal file
750
docs/11-持久化与用户系统设计.md
Normal file
@@ -0,0 +1,750 @@
|
||||
# 持久化与用户系统设计
|
||||
|
||||
## 概述
|
||||
|
||||
本文档定义用户注册/登录、JWT 认证、对话历史持久化的完整设计方案。核心目标:**用户登录后可在对话列表中选择历史对话继续交谈**。
|
||||
|
||||
### 设计决策
|
||||
|
||||
| 决策项 | 选择 | 理由 |
|
||||
|--------|------|------|
|
||||
| 认证方式 | JWT(access + refresh 双 token) | 无状态,适合分布式部署 |
|
||||
| 注册方式 | 用户名 + 密码 | MVP 最简方案 |
|
||||
| 密码存储 | bcrypt hash | 行业标准,抗彩虹表 |
|
||||
| 对话恢复 | 对话列表选择 | 用户可见所有历史对话,自主选择继续或新建 |
|
||||
| 对话标题 | 自动取首条用户消息前 20 字符 | 零成本,自然可读 |
|
||||
| 图像持久化 | 不存储 | 节省空间,文字历史已足够 |
|
||||
| 登录后行为 | 先选对话,再进聊天 | 明确的入口,避免困惑 |
|
||||
| WS 认证 | URL query 参数 `?token=xxx` | HTTP Upgrade 无法带 Authorization header |
|
||||
| Token 策略 | access 15min + refresh 7day | 安全性与体验平衡 |
|
||||
|
||||
---
|
||||
|
||||
## 一、数据库设计
|
||||
|
||||
### 1.1 ER 关系
|
||||
|
||||
```
|
||||
users 1──N sessions 1──N messages
|
||||
│
|
||||
└── refresh_tokens (1──N, token 轮转管理)
|
||||
```
|
||||
|
||||
### 1.2 表结构
|
||||
|
||||
```sql
|
||||
-- 用户表
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(256) NOT NULL, -- bcrypt hash
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
|
||||
-- 会话(对话)表
|
||||
CREATE TABLE sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(128) DEFAULT '新对话',
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_user_id ON sessions(user_id, updated_at DESC);
|
||||
|
||||
-- 消息表
|
||||
CREATE TABLE messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
role VARCHAR(16) NOT NULL, -- "user" | "assistant"
|
||||
content TEXT NOT NULL,
|
||||
tokens_used INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_messages_session_id ON messages(session_id, id);
|
||||
|
||||
-- 刷新令牌表
|
||||
CREATE TABLE refresh_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(256) NOT NULL UNIQUE, -- SHA256(refresh_token)
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);
|
||||
```
|
||||
|
||||
### 1.3 与现有设计的差异
|
||||
|
||||
| 变更 | 原设计(`02-系统架构.md`) | 新设计 | 理由 |
|
||||
|------|--------------------------|--------|------|
|
||||
| `sessions.user_id` | `NOT NULL` 无外键 | `REFERENCES users(id) ON DELETE CASCADE` | 关联用户,级联删除 |
|
||||
| `sessions.title` | 无 | `VARCHAR(128) DEFAULT '新对话'` | 对话列表展示 |
|
||||
| `messages.image_url` | 有 | 移除 | 不存储图像 |
|
||||
| `usage_daily` | 有 | MVP 暂不实现 | 按需后加 |
|
||||
| 新增 `users` | 无 | 新增 | 用户系统核心 |
|
||||
| 新增 `refresh_tokens` | 无 | 新增 | JWT refresh 机制 |
|
||||
|
||||
---
|
||||
|
||||
## 二、JWT 认证设计
|
||||
|
||||
### 2.1 Token 结构
|
||||
|
||||
**access_token**:
|
||||
- payload: `{user_id, username, exp (15min), iat, iss: "camtalk"}`
|
||||
- 签名算法: HS256(对称密钥,从配置读取)
|
||||
- 存储位置: 前端 localStorage
|
||||
|
||||
**refresh_token**:
|
||||
- payload: `{user_id, token_id (UUID), exp (7day), iat, iss: "camtalk"}`
|
||||
- 存储位置: 前端 localStorage + 数据库 `refresh_tokens` 表(存 SHA256 hash)
|
||||
|
||||
### 2.2 认证流程
|
||||
|
||||
#### 注册
|
||||
|
||||
```
|
||||
用户 ──POST /api/auth/register──> 检查 username 唯一性
|
||||
bcrypt hash 密码
|
||||
INSERT users
|
||||
↓
|
||||
生成 access_token + refresh_token
|
||||
存 SHA256(refresh_token) 到 DB
|
||||
↓
|
||||
返回 {user, access_token, refresh_token}
|
||||
```
|
||||
|
||||
#### 登录
|
||||
|
||||
```
|
||||
用户 ──POST /api/auth/login──> 查 users 表 by username
|
||||
bcrypt.CompareHashAndPassword
|
||||
↓
|
||||
生成 access_token + refresh_token
|
||||
存 SHA256(refresh_token) 到 DB
|
||||
↓
|
||||
返回 {user, access_token, refresh_token}
|
||||
```
|
||||
|
||||
#### 刷新
|
||||
|
||||
```
|
||||
用户 ──POST /api/auth/refresh──> 校验 refresh_token 签名和过期
|
||||
查 DB 验证 hash 存在
|
||||
↓
|
||||
撤销旧 refresh_token(DELETE)
|
||||
生成新的 access + refresh
|
||||
存新 refresh_token hash
|
||||
↓
|
||||
返回 {access_token, refresh_token}
|
||||
```
|
||||
|
||||
#### 登出
|
||||
|
||||
```
|
||||
用户 ──POST /api/auth/logout──> 撤销 refresh_token (DELETE from DB)
|
||||
前端清除 localStorage
|
||||
```
|
||||
|
||||
### 2.3 Go 实现接口
|
||||
|
||||
```go
|
||||
// internal/auth/jwt.go
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type TokenManager struct {
|
||||
secret []byte
|
||||
accessTTL time.Duration // 15min
|
||||
refreshTTL time.Duration // 7day
|
||||
}
|
||||
|
||||
// GeneratePair 生成 access + refresh token 对。
|
||||
func (tm *TokenManager) GeneratePair(userID, username string) (access, refresh string, err error)
|
||||
|
||||
// ValidateAccess 校验 access_token,返回 Claims。
|
||||
func (tm *TokenManager) ValidateAccess(tokenStr string) (*Claims, error)
|
||||
|
||||
// ValidateRefresh 校验 refresh_token 签名和过期(不查 DB,DB 校验由 service 层负责)。
|
||||
func (tm *TokenManager) ValidateRefresh(tokenStr string) (*Claims, error)
|
||||
|
||||
// HashToken 计算 token 的 SHA256 hash(用于 DB 存储)。
|
||||
func HashToken(token string) string
|
||||
```
|
||||
|
||||
```go
|
||||
// internal/auth/middleware.go
|
||||
|
||||
// AuthMiddleware Gin 中间件:从 Authorization: Bearer <token> 提取并校验。
|
||||
// 校验通过后将 Claims 写入 gin.Context。
|
||||
func AuthMiddleware(tm *TokenManager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") {
|
||||
c.AbortWithStatusJSON(401, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims, err := tm.ValidateAccess(strings.TrimPrefix(auth, "Bearer "))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(401, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
c.Set("claims", claims)
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、REST API 设计
|
||||
|
||||
### 3.1 认证 API(新增)
|
||||
|
||||
#### 注册
|
||||
|
||||
```
|
||||
POST /api/auth/register
|
||||
Content-Type: application/json
|
||||
|
||||
{"username": "alice", "password": "s3cret123"}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
// 201 Created
|
||||
{
|
||||
"user": {"id": "uuid", "username": "alice", "created_at": "2026-06-14T10:00:00Z"},
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "eyJ..."
|
||||
}
|
||||
```
|
||||
|
||||
错误码:`USERNAME_TAKEN`(409)、`INVALID_INPUT`(400,用户名/密码格式不合规)
|
||||
|
||||
#### 登录
|
||||
|
||||
```
|
||||
POST /api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{"username": "alice", "password": "s3cret123"}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
// 200 OK
|
||||
{
|
||||
"user": {"id": "uuid", "username": "alice"},
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "eyJ..."
|
||||
}
|
||||
```
|
||||
|
||||
错误码:`INVALID_CREDENTIALS`(401)
|
||||
|
||||
#### 刷新 Token
|
||||
|
||||
```
|
||||
POST /api/auth/refresh
|
||||
Content-Type: application/json
|
||||
|
||||
{"refresh_token": "eyJ..."}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
// 200 OK
|
||||
{
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "eyJ..."
|
||||
}
|
||||
```
|
||||
|
||||
错误码:`INVALID_TOKEN`(401)
|
||||
|
||||
#### 登出
|
||||
|
||||
```
|
||||
POST /api/auth/logout
|
||||
Authorization: Bearer <access_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{"refresh_token": "eyJ..."}
|
||||
```
|
||||
|
||||
响应:`204 No Content`
|
||||
|
||||
### 3.2 对话管理 API(新增)
|
||||
|
||||
所有端点需要 `Authorization: Bearer <access_token>` header。
|
||||
|
||||
#### 获取对话列表
|
||||
|
||||
```
|
||||
GET /api/conversations?page=1&size=20
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
// 200 OK
|
||||
{
|
||||
"conversations": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "这是一朵红色的玫瑰花",
|
||||
"last_message": "它看起来很美丽。",
|
||||
"message_count": 6,
|
||||
"updated_at": "2026-06-14T10:30:00Z"
|
||||
}
|
||||
],
|
||||
"total": 42,
|
||||
"page": 1,
|
||||
"size": 20
|
||||
}
|
||||
```
|
||||
|
||||
#### 创建新对话
|
||||
|
||||
```
|
||||
POST /api/conversations
|
||||
Content-Type: application/json
|
||||
|
||||
{}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
// 201 Created
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "新对话",
|
||||
"created_at": "2026-06-14T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### 获取对话详情
|
||||
|
||||
```
|
||||
GET /api/conversations/:id
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
// 200 OK
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "这是一朵红色的玫瑰花",
|
||||
"created_at": "2026-06-14T10:00:00Z",
|
||||
"updated_at": "2026-06-14T10:30:00Z",
|
||||
"config": {"tts_enabled": true, "detail_level": "low", "language": "zh-CN"}
|
||||
}
|
||||
```
|
||||
|
||||
#### 更新对话标题
|
||||
|
||||
```
|
||||
PATCH /api/conversations/:id
|
||||
Content-Type: application/json
|
||||
|
||||
{"title": "新的标题"}
|
||||
```
|
||||
|
||||
响应:`200 OK` + 更新后的对话详情
|
||||
|
||||
#### 删除对话
|
||||
|
||||
```
|
||||
DELETE /api/conversations/:id
|
||||
```
|
||||
|
||||
响应:`204 No Content`(级联删除 messages)
|
||||
|
||||
#### 获取对话历史消息
|
||||
|
||||
```
|
||||
GET /api/conversations/:id/messages?limit=50&before=<message_id>
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
// 200 OK
|
||||
{
|
||||
"messages": [
|
||||
{"id": 1, "role": "user", "content": "这是什么花?", "created_at": "..."},
|
||||
{"id": 2, "role": "assistant", "content": "这是一朵红色的玫瑰。", "tokens_used": 42, "created_at": "..."}
|
||||
],
|
||||
"has_more": false
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 现有 API 变更
|
||||
|
||||
| 端点 | 变更 |
|
||||
|------|------|
|
||||
| `GET /api/health` | 不变 |
|
||||
| `POST /api/sessions` | **废弃**,使用 `POST /api/conversations` 替代 |
|
||||
| `DELETE /api/sessions/{id}` | **废弃**,使用 `DELETE /api/conversations/:id` 替代 |
|
||||
|
||||
### 3.4 新增错误码
|
||||
|
||||
| 错误码 | HTTP 状态 | 含义 |
|
||||
|--------|-----------|------|
|
||||
| `USERNAME_TAKEN` | 409 | 用户名已被注册 |
|
||||
| `INVALID_CREDENTIALS` | 401 | 用户名或密码错误 |
|
||||
| `INVALID_TOKEN` | 401 | JWT 无效或已过期 |
|
||||
| `INVALID_INPUT` | 400 | 请求参数不合规(用户名/密码长度等) |
|
||||
|
||||
---
|
||||
|
||||
## 四、Session Manager 改造
|
||||
|
||||
### 4.1 接口扩展
|
||||
|
||||
```go
|
||||
// internal/session/manager.go
|
||||
|
||||
type Manager interface {
|
||||
// ===== 原有方法(签名变更) =====
|
||||
|
||||
// Create 创建新会话,关联 user_id。
|
||||
Create(ctx context.Context, userID string, config models.SessionConfig) (string, error)
|
||||
|
||||
Get(ctx context.Context, sessionID string) (*models.Session, error)
|
||||
UpdateConfig(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error
|
||||
GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error)
|
||||
AppendMessage(ctx context.Context, sessionID string, msg models.Message) error
|
||||
SetActiveRequest(ctx context.Context, sessionID string, requestID string) error
|
||||
GetActiveRequestID(ctx context.Context, sessionID string) (string, error)
|
||||
ClearActiveRequest(ctx context.Context, sessionID string) error
|
||||
Touch(ctx context.Context, sessionID string) error
|
||||
Destroy(ctx context.Context, sessionID string) error
|
||||
ActiveCount() int
|
||||
|
||||
// ===== 新增方法 =====
|
||||
|
||||
// ListByUser 获取用户的对话列表(分页)。
|
||||
ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error)
|
||||
|
||||
// UpdateTitle 更新对话标题。
|
||||
UpdateTitle(ctx context.Context, sessionID string, title string) error
|
||||
|
||||
// LoadFromDB 从 PostgreSQL 加载历史消息到热存储(Redis/内存)。
|
||||
// 用户选择历史对话继续交谈时调用。
|
||||
LoadFromDB(ctx context.Context, sessionID string) error
|
||||
}
|
||||
|
||||
// ConversationSummary 对话列表项。
|
||||
type ConversationSummary struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
LastMessage string `json:"last_message"`
|
||||
MessageCount int `json:"message_count"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Model 变更
|
||||
|
||||
```go
|
||||
// internal/models/models.go
|
||||
|
||||
type Session struct {
|
||||
ID string `json:"session_id"`
|
||||
UserID string `json:"user_id"` // 新增
|
||||
Title string `json:"title"` // 新增
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"` // 新增
|
||||
Config SessionConfig `json:"config"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"-"` // 不序列化到 JSON
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 冷热数据策略
|
||||
|
||||
```
|
||||
当前活跃会话: Redis/内存(热) ←→ PostgreSQL(冷,write-through)
|
||||
历史会话加载: PostgreSQL → Redis/内存(按需恢复)
|
||||
```
|
||||
|
||||
**Write-through 保证持久化**:每次 `AppendMessage` 同时写入 PostgreSQL,确保服务重启不丢数据。
|
||||
|
||||
**历史对话恢复流程**:
|
||||
1. 用户从对话列表选择一个历史对话
|
||||
2. 前端带 `conversation_id` 建立 WebSocket 连接
|
||||
3. 后端调用 `sessionManager.LoadFromDB(conversationID)` 将历史消息从 PostgreSQL 加载到 Redis/内存
|
||||
4. 后续对话正常走热存储路径
|
||||
|
||||
---
|
||||
|
||||
## 五、WebSocket 认证集成
|
||||
|
||||
### 5.1 连接流程
|
||||
|
||||
```
|
||||
前端 后端
|
||||
| |
|
||||
|-- WS /ws?token=<access> ---->|
|
||||
| &conversation_id=<uuid> |
|
||||
| |-- 校验 access_token
|
||||
| |-- 校验 conversation_id 归属
|
||||
| |-- LoadFromDB(如果是历史对话)
|
||||
| |-- 创建新 session(如果 conversation_id 为空)
|
||||
|<-- connected {session_id} ---|
|
||||
| |
|
||||
|-- query {image, audio} ----->| (正常对话流程)
|
||||
```
|
||||
|
||||
### 5.2 Go 实现
|
||||
|
||||
```go
|
||||
// internal/ws/handler.go
|
||||
|
||||
func (h *Handler) HandleWS(c *gin.Context) {
|
||||
// 1. 提取并校验 access_token
|
||||
tokenStr := c.Query("token")
|
||||
if tokenStr == "" {
|
||||
c.JSON(401, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims, err := h.tokenManager.ValidateAccess(tokenStr)
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 提取 conversation_id(可选)
|
||||
conversationID := c.Query("conversation_id")
|
||||
|
||||
// 3. 升级 WebSocket
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 获取或创建 session
|
||||
var sessionID string
|
||||
if conversationID != "" {
|
||||
// 验证该对话属于当前用户
|
||||
sess, err := h.sessionMgr.Get(c, conversationID)
|
||||
if err != nil || sess.UserID != claims.UserID {
|
||||
conn.WriteJSON(models.WsError{Type: "error", Code: "SESSION_NOT_FOUND"})
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
// 加载历史到热存储
|
||||
h.sessionMgr.LoadFromDB(c, conversationID)
|
||||
sessionID = conversationID
|
||||
} else {
|
||||
// 创建新对话
|
||||
sessionID, _ = h.sessionMgr.Create(c, claims.UserID, models.DefaultConfig())
|
||||
}
|
||||
|
||||
// 5. 进入正常 WS 处理循环
|
||||
h.handleSession(conn, sessionID, claims.UserID)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 前端连接方式
|
||||
|
||||
```typescript
|
||||
// WebSocket 连接
|
||||
const ws = new WebSocket(
|
||||
`wss://${window.location.host}/ws?token=${accessToken}&conversation_id=${selectedConvId || ''}`
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、前端设计概要
|
||||
|
||||
### 6.1 页面路由
|
||||
|
||||
```
|
||||
/ → 未登录重定向到 /login
|
||||
/login → AuthPage(登录/注册表单)
|
||||
/chat → 主界面(需登录)
|
||||
/chat/:id → 主界面,自动加载指定对话
|
||||
```
|
||||
|
||||
### 6.2 组件结构
|
||||
|
||||
```
|
||||
App
|
||||
├── AuthPage ← 新增:登录/注册
|
||||
└── ChatLayout(需登录)
|
||||
├── ConversationList ← 新增:侧边栏对话列表
|
||||
│ ├── 对话项(标题、最后消息、时间)
|
||||
│ ├── 新建对话按钮
|
||||
│ └── 删除对话按钮
|
||||
├── ChatPanel ← 现有,需适配多对话
|
||||
├── VideoPreview ← 现有
|
||||
├── MicManager ← 现有
|
||||
└── ConfigPanel ← 现有
|
||||
```
|
||||
|
||||
### 6.3 新增 Hook
|
||||
|
||||
```typescript
|
||||
// useAuth — 认证状态管理
|
||||
function useAuth() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const login = async (username: string, password: string) => { ... };
|
||||
const register = async (username: string, password: string) => { ... };
|
||||
const logout = async () => { ... };
|
||||
const refreshToken = async () => { ... };
|
||||
|
||||
// 请求拦截器:自动附加 Authorization header
|
||||
// 401 时自动尝试 refresh,失败则跳转登录
|
||||
|
||||
return { user, loading, login, register, logout };
|
||||
}
|
||||
|
||||
// useConversations — 对话列表管理
|
||||
function useConversations() {
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||
const [currentId, setCurrentId] = useState<string | null>(null);
|
||||
|
||||
const fetchList = async (page?: number) => { ... };
|
||||
const createNew = async () => { ... };
|
||||
const deleteConv = async (id: string) => { ... };
|
||||
const renameConv = async (id: string, title: string) => { ... };
|
||||
const selectConv = (id: string) => { setCurrentId(id); };
|
||||
|
||||
return { conversations, currentId, fetchList, createNew, deleteConv, renameConv, selectConv };
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 对话标题自动生成
|
||||
|
||||
```go
|
||||
// 内部逻辑:首条 user 消息的前 20 个字符作为 title
|
||||
func generateTitle(firstMessage string) string {
|
||||
runes := []rune(firstMessage)
|
||||
if len(runes) > 20 {
|
||||
return string(runes[:20]) + "…"
|
||||
}
|
||||
return firstMessage
|
||||
}
|
||||
```
|
||||
|
||||
在 `AppendMessage` 时,如果 session 的 title 仍为 "新对话",自动更新为 `generateTitle(msg.Content)`。
|
||||
|
||||
---
|
||||
|
||||
## 七、配置扩展
|
||||
|
||||
### 7.1 Go 配置结构体
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
App AppConfig `mapstructure:"app"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Auth AuthConfig `mapstructure:"auth"` // 新增
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
AI AIConfig `mapstructure:"ai"`
|
||||
Storage StorageConfig `mapstructure:"storage"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
JWTSecret string `mapstructure:"jwt_secret"` // 必须通过环境变量设置
|
||||
AccessTTL int `mapstructure:"access_ttl"` // 分钟,默认 15
|
||||
RefreshTTL int `mapstructure:"refresh_ttl"` // 分钟,默认 10080 (7天)
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 配置文件示例
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
auth:
|
||||
access_ttl: 15 # 分钟
|
||||
refresh_ttl: 10080 # 7天
|
||||
|
||||
storage:
|
||||
driver: "memory" # "memory" | "postgres"
|
||||
dsn: ""
|
||||
```
|
||||
|
||||
### 7.3 环境变量
|
||||
|
||||
| 配置项 | 环境变量 | 说明 |
|
||||
|--------|---------|------|
|
||||
| `auth.jwt_secret` | `CAMTALK_AUTH_JWT_SECRET` | **必须设置**,JWT 签名密钥 |
|
||||
| `auth.access_ttl` | `CAMTALK_AUTH_ACCESS_TTL` | access_token 有效期(分钟) |
|
||||
| `auth.refresh_ttl` | `CAMTALK_AUTH_REFRESH_TTL` | refresh_token 有效期(分钟) |
|
||||
| `storage.driver` | `CAMTALK_STORAGE_DRIVER` | `"memory"` 或 `"postgres"` |
|
||||
| `storage.dsn` | `CAMTALK_STORAGE_DSN` | PostgreSQL 连接串 |
|
||||
|
||||
---
|
||||
|
||||
## 八、实施阶段
|
||||
|
||||
### Phase 1:用户认证系统
|
||||
|
||||
- [ ] 数据库 schema 迁移脚本(users, refresh_tokens 表)
|
||||
- [ ] `internal/auth/` 包:TokenManager, bcrypt 工具, JWT 中间件
|
||||
- [ ] `internal/store/user.go`:UserRepository 接口 + PostgreSQL 实现
|
||||
- [ ] REST API:`/api/auth/register`, `/api/auth/login`, `/api/auth/refresh`, `/api/auth/logout`
|
||||
- [ ] 单元测试
|
||||
|
||||
### Phase 2:对话 CRUD + 消息持久化
|
||||
|
||||
- [ ] 数据库 schema 迁移脚本(sessions, messages 表改造)
|
||||
- [ ] `internal/store/conversation.go`:ConversationRepository 接口 + PostgreSQL 实现
|
||||
- [ ] Session Manager 扩展:Create 绑定 user_id, ListByUser, UpdateTitle
|
||||
- [ ] REST API:`/api/conversations` CRUD + `/api/conversations/:id/messages`
|
||||
- [ ] Write-through:AppendMessage 同时写 PostgreSQL
|
||||
|
||||
### Phase 3:对话历史恢复
|
||||
|
||||
- [ ] `sessionManager.LoadFromDB()` 实现
|
||||
- [ ] 对话标题自动生成逻辑
|
||||
- [ ] REST API:对话详情、历史消息查询(分页)
|
||||
|
||||
### Phase 4:前端集成
|
||||
|
||||
- [ ] `useAuth` hook + 请求拦截器(自动附加 token、自动 refresh)
|
||||
- [ ] `AuthPage` 组件(登录/注册表单)
|
||||
- [ ] `ConversationList` 组件
|
||||
- [ ] `useConversations` hook
|
||||
- [ ] 路由守卫:未登录重定向到 `/login`
|
||||
- [ ] WebSocket 连接带 token + conversation_id
|
||||
- [ ] `useVisionSession` 适配多对话切换
|
||||
|
||||
### Phase 5:配置与收尾
|
||||
|
||||
- [ ] 配置结构体扩展(AuthConfig)
|
||||
- [ ] config.yaml 更新
|
||||
- [ ] docker-compose 添加 PostgreSQL
|
||||
- [ ] 集成测试
|
||||
- [ ] 更新 `02-系统架构.md` 和 `03-接口文档.md`
|
||||
1379
docs/PLAN_USER_MODULE.md
Normal file
1379
docs/PLAN_USER_MODULE.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -200,7 +200,7 @@ body {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ---- Workspace (双栏) ---- */
|
||||
/* ---- Workspace (双栏,侧边栏为 overlay 不在 flex 流中) ---- */
|
||||
|
||||
.workspace {
|
||||
flex: 1;
|
||||
@@ -208,6 +208,285 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- 会话历史侧边栏(Overlay 抽屉式) ---- */
|
||||
|
||||
.sidebar-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: rgba(10, 10, 15, 0.45);
|
||||
backdrop-filter: blur(3px);
|
||||
-webkit-backdrop-filter: blur(3px);
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
z-index: 51;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-surface);
|
||||
border-right: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
animation: slideInLeft 0.3s var(--transition-smooth);
|
||||
box-shadow: 8px 0 40px rgba(10, 10, 15, 0.4);
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.sidebar--collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__toggle:hover {
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.sidebar__new-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition-fast);
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sidebar__new-btn:hover {
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
|
||||
.sidebar__new-btn--full {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Sidebar Search */
|
||||
.sidebar__search {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__search-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
font-size: 0.78rem;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar__search-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.sidebar__search-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Sidebar List */
|
||||
.sidebar__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.sidebar__empty {
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* Time group header */
|
||||
.sidebar__group {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
padding: 12px 12px 6px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sidebar__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar__item:hover {
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
|
||||
.sidebar__item:hover .sidebar__item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar__item--active {
|
||||
background: var(--color-surface-2);
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.sidebar__item-icon {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar__item-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.sidebar__item-meta {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar__item-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar__action-btn:hover {
|
||||
background: var(--color-surface-3);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.sidebar__action-btn--danger:hover {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.sidebar__edit {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar__edit-input {
|
||||
flex: 1;
|
||||
font-size: 0.82rem;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar__edit-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.sidebar__edit-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.sidebar__edit-btn:hover {
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Sidebar footer */
|
||||
.sidebar__footer {
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__clear-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar__clear-btn:hover {
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* ---- 左侧视频面板 ---- */
|
||||
|
||||
.video-panel {
|
||||
@@ -215,8 +494,8 @@ body {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
gap: 14px;
|
||||
padding: 16px 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
@@ -346,6 +625,32 @@ body {
|
||||
animation: pulse 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
|
||||
}
|
||||
|
||||
/* AI Vision Indicator (eye icon in top-right of video) */
|
||||
.ai-vision-indicator {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: rgba(10, 10, 15, 0.75);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
color: var(--color-text-muted);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.ai-vision-indicator--active {
|
||||
color: var(--color-success);
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
animation: pulse 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
|
||||
}
|
||||
|
||||
.video-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -371,8 +676,10 @@ body {
|
||||
.video-controls {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 6px 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.video-controls__row {
|
||||
@@ -458,6 +765,12 @@ body {
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.chat-panel-header__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-panel-header__mode {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
@@ -468,6 +781,17 @@ body {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.chat-panel-header__stats {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
padding: 3px 10px;
|
||||
background: var(--color-surface-2);
|
||||
border-radius: 12px;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-panel-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
@@ -991,3 +1315,418 @@ body {
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ---- Enhanced Video Controls (3-layer) ---- */
|
||||
|
||||
.video-controls__toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn--recognize {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid rgba(59, 130, 246, 0.25);
|
||||
padding: 7px 14px;
|
||||
font-size: 0.78rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn--recognize:hover {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.btn--recognize:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* Device selectors */
|
||||
.video-controls__devices {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.device-select-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.device-select-label {
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.device-select {
|
||||
padding: 5px 10px;
|
||||
font-size: 0.72rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.device-select:hover {
|
||||
border-color: var(--color-surface-3);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.device-select:focus {
|
||||
outline: 2px solid rgba(59, 130, 246, 0.3);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Mode switcher */
|
||||
.video-controls__mode {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--color-surface-2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
padding: 5px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mode-btn:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mode-btn--active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ---- Scene Cards (empty state) ---- */
|
||||
|
||||
.scene-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 24px;
|
||||
max-width: 380px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scene-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.scene-card:hover {
|
||||
background: var(--color-surface-3);
|
||||
border-color: var(--color-surface-3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.scene-card:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.scene-card__icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.scene-card__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.scene-card__title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.scene-card__desc {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ---- Voice Input Button ---- */
|
||||
|
||||
.chat-input__voice {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-input__voice:hover {
|
||||
background: var(--color-surface-3);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.chat-input__voice--active {
|
||||
background: rgba(52, 211, 153, 0.12);
|
||||
color: var(--color-success);
|
||||
border-color: rgba(52, 211, 153, 0.3);
|
||||
animation: micPulse 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
|
||||
}
|
||||
|
||||
/* ---- Header sidebar toggle ---- */
|
||||
|
||||
.header__menu-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.header__menu-btn:hover {
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.header__menu-btn:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
/* ---- Auth Page ---- */
|
||||
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 32px;
|
||||
box-shadow: 0 16px 64px rgba(10, 10, 15, 0.4);
|
||||
}
|
||||
|
||||
.auth-card__header {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.auth-card__logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.auth-card__subtitle {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Auth form tabs */
|
||||
.auth-form__tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--color-surface-2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.auth-tab {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.auth-tab:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.auth-tab--active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Auth form fields */
|
||||
.auth-form__fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.auth-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.auth-field__label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.auth-field__input {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.auth-field__input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.auth-field__input:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Error message */
|
||||
.auth-form__error {
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
color: var(--color-error);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid rgba(248, 113, 113, 0.2);
|
||||
}
|
||||
|
||||
/* Submit button */
|
||||
.auth-form__submit {
|
||||
width: 100%;
|
||||
padding: 11px 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.auth-form__submit:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.auth-form__submit:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.auth-form__submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Footer link */
|
||||
.auth-card__footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.auth-card__link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-left: 4px;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.auth-card__link:hover {
|
||||
color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
/* ---- Logout button in config panel ---- */
|
||||
|
||||
.config-logout-btn {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
padding: 9px 0;
|
||||
border: 1px solid rgba(248, 113, 113, 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
color: var(--color-error);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.config-logout-btn:hover {
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
border-color: rgba(248, 113, 113, 0.4);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
// ============================================================
|
||||
// CamTalk — 主应用组件(Web 端双栏布局)
|
||||
// CamTalk — 主应用组件(Web 端两栏布局:视频 + 聊天)
|
||||
// 侧边栏为 overlay 抽屉式,不挤压主界面空间
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useVisionSession } from "./hooks/useVisionSession";
|
||||
import { useSessionList } from "./hooks/useSessionList";
|
||||
import { VideoPreview } from "./components/VideoPreview";
|
||||
import { ChatPanel } from "./components/ChatPanel";
|
||||
import { ConfigPanel } from "./components/ConfigPanel";
|
||||
import { SessionSidebar } from "./components/SessionSidebar";
|
||||
import { ToastContainer } from "./components/Toast";
|
||||
import { loadTheme, saveTheme } from "./lib/storage";
|
||||
import { AuthPage } from "./components/AuthPage";
|
||||
import { AuthProvider, useAuth } from "./lib/auth";
|
||||
import { loadConfig, loadTheme, saveTheme } from "./lib/storage";
|
||||
import { I18nContext, parseLocale, t } from "./lib/i18n";
|
||||
import type { Locale } from "./lib/i18n";
|
||||
import type { Theme } from "./types";
|
||||
import "./App.css";
|
||||
|
||||
function App() {
|
||||
/** AI 视觉模式 */
|
||||
type VisionMode = "realtime" | "ondemand" | "chat";
|
||||
|
||||
/** 内部组件,确保在 I18nContext.Provider 内部使用 hooks */
|
||||
function AppContent() {
|
||||
const { isAuthenticated, isLoading, user, logout, accessToken } = useAuth();
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [theme, setTheme] = useState<Theme>(loadTheme);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [visionMode, setVisionMode] = useState<VisionMode>("ondemand");
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// 切换主题时更新 <html> 的 data-theme 属性
|
||||
@@ -34,8 +48,21 @@ function App() {
|
||||
return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// ---- 会话列表 ----
|
||||
const {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
createSession,
|
||||
deleteSession,
|
||||
renameSession,
|
||||
persistSession,
|
||||
selectSession,
|
||||
} = useSessionList();
|
||||
|
||||
// ---- 视觉会话 ----
|
||||
const {
|
||||
messages,
|
||||
setMessages,
|
||||
currentReply,
|
||||
isProcessing,
|
||||
isAudioPlaying,
|
||||
@@ -50,7 +77,6 @@ function App() {
|
||||
stats,
|
||||
mode,
|
||||
isObserving,
|
||||
toggleMode,
|
||||
startSession,
|
||||
stopSession,
|
||||
interrupt,
|
||||
@@ -59,7 +85,7 @@ function App() {
|
||||
toggleCamera,
|
||||
toggleMic,
|
||||
sendTextMessage,
|
||||
} = useVisionSession();
|
||||
} = useVisionSession(accessToken);
|
||||
|
||||
const isConnected = connectionStatus === "connected";
|
||||
|
||||
@@ -88,28 +114,134 @@ function App() {
|
||||
return () => clearInterval(id);
|
||||
}, [isConnected]);
|
||||
|
||||
const { t: tr } = useMemo(() => ({ t: (key: string) => t(key, parseLocale(config.language)) }), [config.language]);
|
||||
|
||||
// ---- 初始化:如果没有会话,创建一个 ----
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initializedRef.current) {
|
||||
initializedRef.current = true;
|
||||
if (sessions.length === 0) {
|
||||
createSession();
|
||||
}
|
||||
}
|
||||
}, [sessions.length, createSession]);
|
||||
|
||||
// ---- 自动保存:messages 变化时持久化到当前会话 ----
|
||||
const messagesRef = useRef(messages);
|
||||
useEffect(() => { messagesRef.current = messages; }, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSessionId && messages.length > 0) {
|
||||
persistSession(activeSessionId, messages);
|
||||
}
|
||||
}, [messages, activeSessionId, persistSession]);
|
||||
|
||||
// ---- 侧边栏操作 ----
|
||||
const handleNewSession = useCallback(() => {
|
||||
createSession();
|
||||
setMessages([]);
|
||||
// 如果已连接,断开
|
||||
if (connectionStatus === "connected") {
|
||||
stopSession();
|
||||
}
|
||||
setSidebarOpen(false);
|
||||
}, [createSession, setMessages, connectionStatus, stopSession]);
|
||||
|
||||
const handleSelectSession = useCallback((id: string) => {
|
||||
// 保存当前会话
|
||||
if (activeSessionId && messagesRef.current.length > 0) {
|
||||
persistSession(activeSessionId, messagesRef.current);
|
||||
}
|
||||
// 如果已连接,断开
|
||||
if (connectionStatus === "connected") {
|
||||
stopSession();
|
||||
}
|
||||
// 加载目标会话
|
||||
const loaded = selectSession(id);
|
||||
setMessages(loaded);
|
||||
}, [activeSessionId, persistSession, connectionStatus, stopSession, selectSession, setMessages]);
|
||||
|
||||
const handleDeleteSession = useCallback((id: string) => {
|
||||
deleteSession(id);
|
||||
if (id === activeSessionId) {
|
||||
setMessages([]);
|
||||
}
|
||||
}, [deleteSession, activeSessionId, setMessages]);
|
||||
|
||||
// ---- 识别画面 ----
|
||||
const handleRecognize = useCallback(() => {
|
||||
if (isProcessing) return;
|
||||
sendTextMessage(tr("controls.recognize"));
|
||||
}, [isProcessing, sendTextMessage, tr]);
|
||||
|
||||
// ---- 场景卡片点击 ----
|
||||
const handleSceneCard = useCallback((prompt: string) => {
|
||||
sendTextMessage(prompt);
|
||||
}, [sendTextMessage]);
|
||||
|
||||
// ---- 键盘快捷键 ----
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
// Cmd/Ctrl+H: 打开/关闭历史侧边栏
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "h") {
|
||||
e.preventDefault();
|
||||
setSidebarOpen((v) => !v);
|
||||
}
|
||||
// Escape: 关闭侧边栏
|
||||
if (e.key === "Escape" && sidebarOpen) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [sidebarOpen]);
|
||||
|
||||
// Auth guard:未登录时显示登录页(放在所有 hooks 之后以遵守 Rules of Hooks)
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card" style={{ textAlign: "center", border: "none", background: "transparent", boxShadow: "none" }}>
|
||||
<p style={{ color: "var(--color-text-muted)", fontSize: "0.88rem" }}>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <AuthPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{/* ---- 顶部导航栏 ---- */}
|
||||
<header className="header">
|
||||
<div className="header__left">
|
||||
{/* 侧边栏开关 */}
|
||||
<button
|
||||
className="header__menu-btn"
|
||||
onClick={() => setSidebarOpen((v) => !v)}
|
||||
title={tr("sidebar.expand")}
|
||||
aria-label={tr("sidebar.expand")}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="header__title">CamTalk</h1>
|
||||
<span className="header__subtitle">AI 视觉对话助手</span>
|
||||
<span className="header__subtitle">{tr("app.title")}</span>
|
||||
</div>
|
||||
<div className="header__right">
|
||||
{isConnected && (stats.queryCount > 0 || stats.totalTokens > 0) && (
|
||||
<span className="header__stats">
|
||||
{stats.queryCount} 次请求 · {stats.totalTokens} tokens
|
||||
</span>
|
||||
)}
|
||||
<span className={`badge badge--${connectionStatus}`}>
|
||||
{isConnected ? "已连接" : connectionStatus === "connecting" ? "连接中..." : "未连接"}
|
||||
{isConnected ? tr("status.connected") : connectionStatus === "connecting" ? tr("status.connecting") : tr("status.disconnected")}
|
||||
</span>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setShowConfig((v) => !v)}
|
||||
title="设置"
|
||||
aria-label="设置"
|
||||
title={tr("settings.title")}
|
||||
aria-label={tr("settings.title")}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
@@ -119,17 +251,31 @@ function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ---- 会话历史侧边栏(Overlay 抽屉式) ---- */}
|
||||
<SessionSidebar
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
open={sidebarOpen}
|
||||
onToggle={() => setSidebarOpen((v) => !v)}
|
||||
onNewSession={handleNewSession}
|
||||
onSelectSession={handleSelectSession}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onRenameSession={renameSession}
|
||||
/>
|
||||
|
||||
{showConfig && (
|
||||
<ConfigPanel
|
||||
config={config}
|
||||
theme={theme}
|
||||
username={user?.username}
|
||||
onUpdate={updateConfig}
|
||||
onThemeChange={handleThemeChange}
|
||||
onLogout={logout}
|
||||
onClose={() => setShowConfig(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ---- 主体:左侧视频 + 右侧聊天 ---- */}
|
||||
{/* ---- 主体:视频 + 聊天(两栏) ---- */}
|
||||
<div className="workspace">
|
||||
{/* 左侧:视频预览 + 控制栏 */}
|
||||
<div className="video-panel">
|
||||
@@ -144,17 +290,24 @@ function App() {
|
||||
{config.detailLevel === "high" && (
|
||||
<div className="detail-badge">HD</div>
|
||||
)}
|
||||
{isObserving && (
|
||||
<div className="observation-badge">👁️ 观察中</div>
|
||||
{/* AI 视觉状态指示 */}
|
||||
{isConnected && visionMode !== "chat" && (
|
||||
<div className={`ai-vision-indicator ${isObserving ? "ai-vision-indicator--active" : ""}`}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
<span>{isObserving ? tr("video.observing") : "AI"}</span>
|
||||
</div>
|
||||
)}
|
||||
{isSpeaking && (
|
||||
<div className="video-indicator">🎤 正在聆听...</div>
|
||||
<div className="video-indicator">{tr("video.listening")}</div>
|
||||
)}
|
||||
{isAudioPlaying && config.ttsEnabled && (
|
||||
<div className="video-indicator video-indicator--audio">🔊 正在播放...</div>
|
||||
<div className="video-indicator video-indicator--audio">{tr("video.playing")}</div>
|
||||
)}
|
||||
{isConnected && !isVADReady && !vadError && (
|
||||
<div className="video-indicator video-indicator--loading">正在初始化语音检测...</div>
|
||||
<div className="video-indicator video-indicator--loading">{tr("video.initVad")}</div>
|
||||
)}
|
||||
{vadError && (
|
||||
<div className="video-indicator video-indicator--error">⚠️ {vadError}</div>
|
||||
@@ -170,7 +323,7 @@ function App() {
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||
<circle cx="12" cy="13" r="4" />
|
||||
</svg>
|
||||
<span>点击下方按钮开始对话</span>
|
||||
<span>{tr("video.placeholder")}</span>
|
||||
</div>
|
||||
)}
|
||||
{isConnected && !isCameraOn && (
|
||||
@@ -179,49 +332,116 @@ function App() {
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||
<circle cx="12" cy="13" r="4" />
|
||||
</svg>
|
||||
<span>摄像头未开启</span>
|
||||
<span className="video-placeholder__hint">可在右侧聊天框打字对话</span>
|
||||
<span>{tr("video.cameraOff")}</span>
|
||||
<span className="video-placeholder__hint">{tr("video.cameraOff.hint")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 视频下方控制栏 */}
|
||||
{/* 视频下方控制区(三层结构) */}
|
||||
<div className="video-controls">
|
||||
{!isConnected ? (
|
||||
<button className="btn btn--primary btn--lg" onClick={startSession}>
|
||||
{connectionStatus === "connecting" ? "连接中..." : "🎙️ 开始对话"}
|
||||
</button>
|
||||
) : (
|
||||
<div className="video-controls__row">
|
||||
<button
|
||||
className={`btn btn--ctrl ${isCameraOn ? "btn--ctrl-on" : "btn--ctrl-off"}`}
|
||||
onClick={toggleCamera}
|
||||
title={isCameraOn ? "关闭摄像头" : "开启摄像头"}
|
||||
>
|
||||
📷
|
||||
<>
|
||||
{/* 未连接态:主 CTA */}
|
||||
<button className="btn btn--primary btn--lg" onClick={startSession}>
|
||||
{connectionStatus === "connecting" ? tr("controls.connecting") : tr("controls.startVideo")}
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn--ctrl ${isMicOn ? "btn--ctrl-on" : "btn--ctrl-off"} ${isSpeaking ? "btn--speaking" : ""}`}
|
||||
onClick={toggleMic}
|
||||
title={isMicOn ? "关闭麦克风" : "开启麦克风"}
|
||||
>
|
||||
🎤
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${mode === "observation" ? "btn--active" : "btn--secondary"}`}
|
||||
onClick={toggleMode}
|
||||
>
|
||||
{mode === "observation" ? "👁️ 观察中" : "👁️ 观察模式"}
|
||||
</button>
|
||||
{isProcessing && (
|
||||
<button className="btn btn--warning" onClick={interrupt}>
|
||||
⏹ 打断
|
||||
{/* 设备选择器 */}
|
||||
<div className="video-controls__devices">
|
||||
<div className="device-select-wrapper">
|
||||
<label className="device-select-label">📷 {tr("controls.device.camera")}</label>
|
||||
<select className="device-select" defaultValue="default">
|
||||
<option value="default">{tr("controls.device.default")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="device-select-wrapper">
|
||||
<label className="device-select-label">🎤 {tr("controls.device.mic")}</label>
|
||||
<select className="device-select" defaultValue="default">
|
||||
<option value="default">{tr("controls.device.default")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* 模式切换器 */}
|
||||
<div className="video-controls__mode">
|
||||
<button
|
||||
className={`mode-btn ${visionMode === "realtime" ? "mode-btn--active" : ""}`}
|
||||
onClick={() => setVisionMode("realtime")}
|
||||
>
|
||||
{tr("controls.mode.realtime")}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn--danger" onClick={stopSession}>
|
||||
结束对话
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className={`mode-btn ${visionMode === "ondemand" ? "mode-btn--active" : ""}`}
|
||||
onClick={() => setVisionMode("ondemand")}
|
||||
>
|
||||
{tr("controls.mode.ondemand")}
|
||||
</button>
|
||||
<button
|
||||
className={`mode-btn ${visionMode === "chat" ? "mode-btn--active" : ""}`}
|
||||
onClick={() => setVisionMode("chat")}
|
||||
>
|
||||
{tr("controls.mode.chat")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 通话态:核心控制工具栏 */}
|
||||
<div className="video-controls__toolbar">
|
||||
<button
|
||||
className={`btn btn--ctrl ${isCameraOn ? "btn--ctrl-on" : "btn--ctrl-off"}`}
|
||||
onClick={toggleCamera}
|
||||
title={isCameraOn ? tr("controls.cameraOff") : tr("controls.cameraOn")}
|
||||
>
|
||||
📷
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn--ctrl ${isMicOn ? "btn--ctrl-on" : "btn--ctrl-off"} ${isSpeaking ? "btn--speaking" : ""}`}
|
||||
onClick={toggleMic}
|
||||
title={isMicOn ? tr("controls.micOff") : tr("controls.micOn")}
|
||||
>
|
||||
🎤
|
||||
</button>
|
||||
{/* 识别画面按钮(按需模式下显示) */}
|
||||
{visionMode === "ondemand" && (
|
||||
<button
|
||||
className="btn--recognize"
|
||||
onClick={handleRecognize}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
🔍 {tr("controls.recognize")}
|
||||
</button>
|
||||
)}
|
||||
{isProcessing && (
|
||||
<button className="btn btn--warning" onClick={interrupt}>
|
||||
{tr("controls.interrupt")}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn--danger" onClick={stopSession}>
|
||||
{tr("controls.stop")}
|
||||
</button>
|
||||
</div>
|
||||
{/* 通话态模式切换 */}
|
||||
<div className="video-controls__mode">
|
||||
<button
|
||||
className={`mode-btn ${visionMode === "realtime" ? "mode-btn--active" : ""}`}
|
||||
onClick={() => setVisionMode("realtime")}
|
||||
>
|
||||
{tr("controls.mode.realtime")}
|
||||
</button>
|
||||
<button
|
||||
className={`mode-btn ${visionMode === "ondemand" ? "mode-btn--active" : ""}`}
|
||||
onClick={() => setVisionMode("ondemand")}
|
||||
>
|
||||
{tr("controls.mode.ondemand")}
|
||||
</button>
|
||||
<button
|
||||
className={`mode-btn ${visionMode === "chat" ? "mode-btn--active" : ""}`}
|
||||
onClick={() => setVisionMode("chat")}
|
||||
>
|
||||
{tr("controls.mode.chat")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,22 +449,35 @@ function App() {
|
||||
{/* 右侧:聊天面板 */}
|
||||
<div className="chat-panel-wrapper">
|
||||
<div className="chat-panel-header">
|
||||
<span>对话</span>
|
||||
{isConnected && mode === "observation" && (
|
||||
<span className="chat-panel-header__mode">观察模式</span>
|
||||
)}
|
||||
<span>{tr("chat.title")}</span>
|
||||
<div className="chat-panel-header__right">
|
||||
{isConnected && mode === "observation" && (
|
||||
<span className="chat-panel-header__mode">{tr("chat.mode.observation")}</span>
|
||||
)}
|
||||
{isConnected && stats.queryCount > 0 && (
|
||||
<span className="chat-panel-header__stats">
|
||||
{stats.queryCount} {tr("statusbar.recognitions")}
|
||||
{stats.totalTokens > 0 && ` · ${stats.totalTokens.toLocaleString()} ${tr("statusbar.tokens")}`}
|
||||
{` · ${formatTime(elapsed)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chat-panel-body">
|
||||
{connectionStatus === "disconnected" && messages.length > 0 && (
|
||||
<div className="system-message system-message--warning">
|
||||
连接已断开,正在重连...
|
||||
{tr("chat.reconnecting")}
|
||||
</div>
|
||||
)}
|
||||
<ChatPanel
|
||||
messages={messages}
|
||||
currentReply={currentReply}
|
||||
connectionStatus={connectionStatus}
|
||||
isMicOn={isMicOn}
|
||||
isSpeaking={isSpeaking}
|
||||
onSendText={sendTextMessage}
|
||||
onToggleMic={toggleMic}
|
||||
onSceneCard={handleSceneCard}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,4 +488,32 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [locale, setLocale] = useState<Locale>(() => parseLocale(loadConfig().language));
|
||||
|
||||
const i18nValue = useMemo(() => ({
|
||||
locale,
|
||||
t: (key: string) => t(key, locale),
|
||||
}), [locale]);
|
||||
|
||||
// 监听配置变化以更新 locale
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const cfg = loadConfig();
|
||||
setLocale(parseLocale(cfg.language));
|
||||
};
|
||||
// 自定义事件,由 saveConfig 触发
|
||||
window.addEventListener("camtalk-config-changed", handler);
|
||||
return () => window.removeEventListener("camtalk-config-changed", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={i18nValue}>
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
130
frontend/src/components/AuthPage/index.tsx
Normal file
130
frontend/src/components/AuthPage/index.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
// ============================================================
|
||||
// AuthPage — 登录 / 注册页面
|
||||
// 职责:用户认证表单,支持登录和注册模式切换
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useAuth } from "../../lib/auth";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
type AuthMode = "login" | "register";
|
||||
|
||||
export function AuthPage() {
|
||||
const { login, register } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [mode, setMode] = useState<AuthMode>("login");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// 前端校验
|
||||
if (username.length < 3 || username.length > 64) {
|
||||
setError(t("auth.error.usernameLength"));
|
||||
return;
|
||||
}
|
||||
if (password.length < 8 || password.length > 72) {
|
||||
setError(t("auth.error.passwordLength"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
const fn = mode === "login" ? login : register;
|
||||
const result = await fn(username, password);
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
}
|
||||
},
|
||||
[username, password, mode, login, register, t]
|
||||
);
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
setMode((m) => (m === "login" ? "register" : "login"));
|
||||
setError("");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<div className="auth-card__header">
|
||||
<h1 className="auth-card__logo">CamTalk</h1>
|
||||
<p className="auth-card__subtitle">{t("auth.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<div className="auth-form__tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`auth-tab ${mode === "login" ? "auth-tab--active" : ""}`}
|
||||
onClick={() => { setMode("login"); setError(""); }}
|
||||
>
|
||||
{t("auth.login")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`auth-tab ${mode === "register" ? "auth-tab--active" : ""}`}
|
||||
onClick={() => { setMode("register"); setError(""); }}
|
||||
>
|
||||
{t("auth.register")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="auth-form__fields">
|
||||
<label className="auth-field">
|
||||
<span className="auth-field__label">{t("auth.username")}</span>
|
||||
<input
|
||||
type="text"
|
||||
className="auth-field__input"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("auth.username.placeholder")}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="auth-field">
|
||||
<span className="auth-field__label">{t("auth.password")}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="auth-field__input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("auth.password.placeholder")}
|
||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-form__error">{error}</div>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="auth-form__submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? t("auth.submitting")
|
||||
: mode === "login"
|
||||
? t("auth.login")
|
||||
: t("auth.register")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="auth-card__footer">
|
||||
{mode === "login" ? t("auth.noAccount") : t("auth.hasAccount")}
|
||||
<button className="auth-card__link" onClick={toggleMode}>
|
||||
{mode === "login" ? t("auth.register") : t("auth.login")}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
export interface CameraManagerHandle {
|
||||
/** 获取当前视频轨道 */
|
||||
@@ -13,6 +14,7 @@ export interface CameraManagerHandle {
|
||||
}
|
||||
|
||||
export function useCamera() {
|
||||
const { t } = useI18n();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -29,7 +31,7 @@ export function useCamera() {
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "无法访问摄像头";
|
||||
const message = err instanceof Error ? err.message : t("error.cameraAccess");
|
||||
setError(message);
|
||||
console.error("[Camera] 获取摄像头失败:", err);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// ============================================================
|
||||
// ChatPanel — 消息展示面板
|
||||
// 职责:渲染对话消息列表、流式光标、元数据、自动滚动、文本输入
|
||||
// 增强:空状态场景卡片、语音输入按钮
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import type { ChatMessage } from "../../types";
|
||||
import type { ConnectionStatus } from "../../lib/websocket";
|
||||
|
||||
@@ -11,16 +13,42 @@ interface ChatPanelProps {
|
||||
messages: ChatMessage[];
|
||||
currentReply?: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
isMicOn?: boolean;
|
||||
isSpeaking?: boolean;
|
||||
onSendText?: (text: string) => void;
|
||||
onToggleMic?: () => void;
|
||||
onSceneCard?: (prompt: string) => void;
|
||||
}
|
||||
|
||||
export function ChatPanel({ messages, currentReply, connectionStatus, onSendText }: ChatPanelProps) {
|
||||
/** 场景卡片数据 */
|
||||
function getSceneCards(t: (key: string) => string) {
|
||||
return [
|
||||
{ icon: "👁", titleKey: "scene.describe", descKey: "scene.describe.desc", prompt: t("scene.describe") },
|
||||
{ icon: "🔤", titleKey: "scene.text", descKey: "scene.text.desc", prompt: t("scene.text") },
|
||||
{ icon: "📦", titleKey: "scene.object", descKey: "scene.object.desc", prompt: t("scene.object") },
|
||||
{ icon: "💡", titleKey: "scene.suggest", descKey: "scene.suggest.desc", prompt: t("scene.suggest") },
|
||||
];
|
||||
}
|
||||
|
||||
export function ChatPanel({
|
||||
messages,
|
||||
currentReply,
|
||||
connectionStatus,
|
||||
isMicOn,
|
||||
isSpeaking,
|
||||
onSendText,
|
||||
onToggleMic,
|
||||
onSceneCard,
|
||||
}: ChatPanelProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isAutoScroll = useRef(true);
|
||||
const [inputText, setInputText] = useState("");
|
||||
const { t } = useI18n();
|
||||
|
||||
const isConnected = connectionStatus === "connected";
|
||||
const isEmpty = messages.length === 0 && !currentReply;
|
||||
const sceneCards = getSceneCards(t);
|
||||
|
||||
// 用户上滚时暂停自动滚动,滚到底部时恢复
|
||||
useEffect(() => {
|
||||
@@ -51,33 +79,48 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
|
||||
setInputText("");
|
||||
};
|
||||
|
||||
// 未连接时的空状态
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<div className="chat-panel chat-panel--empty">
|
||||
<span className="chat-panel--empty-icon">💬</span>
|
||||
<p>点击下方按钮开始对话</p>
|
||||
<span className="chat-panel--empty-hint">连接后可打字或语音与 AI 交互</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 点击场景卡片
|
||||
const handleSceneCard = (prompt: string) => {
|
||||
if (onSceneCard) {
|
||||
onSceneCard(prompt);
|
||||
} else if (onSendText) {
|
||||
onSendText(prompt);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-panel__messages" ref={containerRef}>
|
||||
{/* 空状态提示 */}
|
||||
{messages.length === 0 && !currentReply && (
|
||||
{/* 空状态:场景卡片 */}
|
||||
{isEmpty && (
|
||||
<div className="chat-panel__welcome">
|
||||
<span className="chat-panel__welcome-icon">💬</span>
|
||||
<p>在下方输入文字开始对话</p>
|
||||
<span className="chat-panel__welcome-hint">也可以开启麦克风用语音对话</span>
|
||||
<p>{isConnected ? t("chat.welcome.prompt") : t("chat.empty.prompt")}</p>
|
||||
<span className="chat-panel__welcome-hint">{isConnected ? t("chat.welcome.hint") : t("chat.empty.hint")}</span>
|
||||
|
||||
{/* 场景快捷卡片 */}
|
||||
<div className="scene-cards">
|
||||
{sceneCards.map((card) => (
|
||||
<button
|
||||
key={card.titleKey}
|
||||
className="scene-card"
|
||||
onClick={() => handleSceneCard(card.prompt)}
|
||||
>
|
||||
<span className="scene-card__icon">{card.icon}</span>
|
||||
<div className="scene-card__text">
|
||||
<span className="scene-card__title">{t(card.titleKey)}</span>
|
||||
<span className="scene-card__desc">{t(card.descKey)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, index) => (
|
||||
<div key={index} className={`chat-message chat-message--${msg.role}`}>
|
||||
<div className="chat-message__role">
|
||||
{msg.role === "user" ? "你" : "AI"}
|
||||
{msg.role === "user" ? t("chat.userLabel") : "AI"}
|
||||
</div>
|
||||
<div className="chat-message__content">{msg.content}</div>
|
||||
{msg.role === "assistant" && msg.tokensUsed !== undefined && (
|
||||
@@ -104,21 +147,33 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* 文本输入框 - 连接后始终显示 */}
|
||||
{/* 文本输入框 - 始终显示 */}
|
||||
{onSendText && (
|
||||
<form className="chat-input" onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
className="chat-input__field"
|
||||
placeholder="输入文字对话..."
|
||||
placeholder={t("chat.input.placeholder")}
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
disabled={connectionStatus === "connecting"}
|
||||
/>
|
||||
{/* 语音输入按钮 */}
|
||||
{onToggleMic && (
|
||||
<button
|
||||
type="button"
|
||||
className={`chat-input__voice ${isMicOn ? "chat-input__voice--active" : ""} ${isSpeaking ? "btn--speaking" : ""}`}
|
||||
onClick={onToggleMic}
|
||||
title={t("chat.input.voice")}
|
||||
>
|
||||
🎤
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="chat-input__send"
|
||||
disabled={!inputText.trim()}
|
||||
title="发送"
|
||||
disabled={!inputText.trim() || connectionStatus === "connecting"}
|
||||
title={t("chat.send")}
|
||||
>
|
||||
➤
|
||||
</button>
|
||||
|
||||
@@ -3,51 +3,56 @@
|
||||
// 职责:主题切换、TTS 开关、detail level 切换、语言选择
|
||||
// ============================================================
|
||||
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import type { SessionConfig, Theme } from "../../types";
|
||||
|
||||
interface ConfigPanelProps {
|
||||
config: SessionConfig;
|
||||
theme: Theme;
|
||||
username?: string;
|
||||
onUpdate: (partial: Partial<SessionConfig>) => void;
|
||||
onThemeChange: (theme: Theme) => void;
|
||||
onLogout?: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConfigPanel({ config, theme, onUpdate, onThemeChange, onClose }: ConfigPanelProps) {
|
||||
export function ConfigPanel({ config, theme, username, onUpdate, onThemeChange, onLogout, onClose }: ConfigPanelProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="drawer-overlay" onClick={onClose}>
|
||||
<div className="drawer" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="drawer__header">
|
||||
<span className="drawer__title">设置</span>
|
||||
<span className="drawer__title">{t("settings.title")}</span>
|
||||
<button className="drawer__close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="drawer__body">
|
||||
<div className="config-group">
|
||||
<div className="config-group__title">外观</div>
|
||||
<div className="config-group__title">{t("settings.appearance")}</div>
|
||||
|
||||
<label className="config-row">
|
||||
<div className="config-row__info">
|
||||
<span className="config-row__label">主题</span>
|
||||
<span className="config-row__desc">切换明暗主题</span>
|
||||
<span className="config-row__label">{t("settings.theme")}</span>
|
||||
<span className="config-row__desc">{t("settings.theme.desc")}</span>
|
||||
</div>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => onThemeChange(e.target.value as Theme)}
|
||||
>
|
||||
<option value="dark">深色</option>
|
||||
<option value="light">浅色</option>
|
||||
<option value="dark">{t("settings.theme.dark")}</option>
|
||||
<option value="light">{t("settings.theme.light")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="config-group">
|
||||
<div className="config-group__title">会话</div>
|
||||
<div className="config-group__title">{t("settings.session")}</div>
|
||||
|
||||
<label className="config-row">
|
||||
<div className="config-row__info">
|
||||
<span className="config-row__label">语音回答</span>
|
||||
<span className="config-row__desc">AI 回答时同步播放语音</span>
|
||||
<span className="config-row__label">{t("settings.tts")}</span>
|
||||
<span className="config-row__desc">{t("settings.tts.desc")}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -58,22 +63,22 @@ export function ConfigPanel({ config, theme, onUpdate, onThemeChange, onClose }:
|
||||
|
||||
<label className="config-row">
|
||||
<div className="config-row__info">
|
||||
<span className="config-row__label">图像精度</span>
|
||||
<span className="config-row__desc">高精度适合识别文字,低精度省流量</span>
|
||||
<span className="config-row__label">{t("settings.detail")}</span>
|
||||
<span className="config-row__desc">{t("settings.detail.desc")}</span>
|
||||
</div>
|
||||
<select
|
||||
value={config.detailLevel}
|
||||
onChange={(e) => onUpdate({ detailLevel: e.target.value as "low" | "high" })}
|
||||
>
|
||||
<option value="low">低</option>
|
||||
<option value="high">高</option>
|
||||
<option value="low">{t("settings.detail.low")}</option>
|
||||
<option value="high">{t("settings.detail.high")}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="config-row">
|
||||
<div className="config-row__info">
|
||||
<span className="config-row__label">语言</span>
|
||||
<span className="config-row__desc">交互语言偏好</span>
|
||||
<span className="config-row__label">{t("settings.language")}</span>
|
||||
<span className="config-row__desc">{t("settings.language.desc")}</span>
|
||||
</div>
|
||||
<select
|
||||
value={config.language}
|
||||
@@ -85,6 +90,24 @@ export function ConfigPanel({ config, theme, onUpdate, onThemeChange, onClose }:
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{username && onLogout && (
|
||||
<div className="config-group">
|
||||
<div className="config-group__title">{t("settings.account")}</div>
|
||||
<div className="config-row" style={{ cursor: "default" }}>
|
||||
<div className="config-row__info">
|
||||
<span className="config-row__label">{t("settings.account.user")}</span>
|
||||
<span className="config-row__desc">{username}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="config-logout-btn"
|
||||
onClick={onLogout}
|
||||
>
|
||||
{t("auth.logout")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { MicVAD } from "@ricky0123/vad-web";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
export interface VADOptions {
|
||||
/** 语音结束回调,携带录音 Float32Array(16kHz) */
|
||||
@@ -21,6 +22,7 @@ export interface VADOptions {
|
||||
* 基于 @ricky0123/vad-web 的 MicVAD,检测用户说话并回调
|
||||
*/
|
||||
export function useVAD(options?: VADOptions) {
|
||||
const { t } = useI18n();
|
||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -79,7 +81,7 @@ export function useVAD(options?: VADOptions) {
|
||||
setIsReady(true);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "VAD 初始化失败";
|
||||
const message = err instanceof Error ? err.message : t("error.vadInit");
|
||||
setError(message);
|
||||
console.error("[VAD] 初始化失败:", err);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
export function useMicrophone() {
|
||||
const { t } = useI18n();
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
@@ -25,7 +27,7 @@ export function useMicrophone() {
|
||||
setError(null);
|
||||
return mediaStream;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "无法访问麦克风";
|
||||
const message = err instanceof Error ? err.message : t("error.micAccess");
|
||||
setError(message);
|
||||
console.error("[Mic] 获取麦克风失败:", err);
|
||||
return null;
|
||||
|
||||
235
frontend/src/components/SessionSidebar/index.tsx
Normal file
235
frontend/src/components/SessionSidebar/index.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
// ============================================================
|
||||
// SessionSidebar — 会话历史侧边栏(Overlay 抽屉式)
|
||||
// 职责:展示会话列表、搜索、新建/切换/删除/重命名会话
|
||||
// 设计:overlay 覆盖在主界面之上,不挤压主界面空间
|
||||
// ============================================================
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import type { SessionSummary } from "../../types";
|
||||
|
||||
interface SessionSidebarProps {
|
||||
sessions: SessionSummary[];
|
||||
activeSessionId: string | null;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onNewSession: () => void;
|
||||
onSelectSession: (id: string) => void;
|
||||
onDeleteSession: (id: string) => void;
|
||||
onRenameSession: (id: string, title: string) => void;
|
||||
}
|
||||
|
||||
/** 格式化相对时间 */
|
||||
function formatRelativeTime(ts: number): string {
|
||||
const now = Date.now();
|
||||
const diff = now - ts;
|
||||
const min = 60 * 1000;
|
||||
const hour = 60 * min;
|
||||
const day = 24 * hour;
|
||||
|
||||
if (diff < min) return "刚刚";
|
||||
if (diff < hour) return `${Math.floor(diff / min)}分钟前`;
|
||||
if (diff < day) return `${Math.floor(diff / hour)}小时前`;
|
||||
if (diff < 2 * day) return "昨天";
|
||||
if (diff < 7 * day) return `${Math.floor(diff / day)}天前`;
|
||||
const d = new Date(ts);
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
|
||||
/** 判断两个时间戳是否为同一天 */
|
||||
function isSameDay(ts: number, ref: Date): boolean {
|
||||
const d = new Date(ts);
|
||||
return d.getFullYear() === ref.getFullYear() &&
|
||||
d.getMonth() === ref.getMonth() &&
|
||||
d.getDate() === ref.getDate();
|
||||
}
|
||||
|
||||
type TimeGroup = "today" | "yesterday" | "earlier";
|
||||
|
||||
function getTimeGroup(ts: number, today: Date, yesterday: Date): TimeGroup {
|
||||
if (isSameDay(ts, today)) return "today";
|
||||
if (isSameDay(ts, yesterday)) return "yesterday";
|
||||
return "earlier";
|
||||
}
|
||||
|
||||
export function SessionSidebar({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
open,
|
||||
onToggle,
|
||||
onNewSession,
|
||||
onSelectSession,
|
||||
onDeleteSession,
|
||||
onRenameSession,
|
||||
}: SessionSidebarProps) {
|
||||
const { t } = useI18n();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const handleStartRename = (id: string, currentTitle: string) => {
|
||||
setEditingId(id);
|
||||
setEditTitle(currentTitle);
|
||||
};
|
||||
|
||||
const handleConfirmRename = () => {
|
||||
if (editingId && editTitle.trim()) {
|
||||
onRenameSession(editingId, editTitle.trim());
|
||||
}
|
||||
setEditingId(null);
|
||||
setEditTitle("");
|
||||
};
|
||||
|
||||
const handleCancelRename = () => {
|
||||
setEditingId(null);
|
||||
setEditTitle("");
|
||||
};
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
onSelectSession(id);
|
||||
// 选择后自动收起侧边栏
|
||||
onToggle();
|
||||
};
|
||||
|
||||
// 过滤 + 分组
|
||||
const grouped = useMemo(() => {
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const filtered = searchQuery.trim()
|
||||
? sessions.filter((s) =>
|
||||
s.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.preview.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: sessions;
|
||||
|
||||
const groups: Record<TimeGroup, SessionSummary[]> = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
earlier: [],
|
||||
};
|
||||
|
||||
for (const session of filtered) {
|
||||
const group = getTimeGroup(session.lastActiveAt, today, yesterday);
|
||||
groups[group].push(session);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [sessions, searchQuery]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const groupLabels: Record<TimeGroup, string> = {
|
||||
today: t("sidebar.today"),
|
||||
yesterday: t("sidebar.yesterday"),
|
||||
earlier: t("sidebar.earlier"),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sidebar-backdrop" onClick={onToggle} />
|
||||
<div className="sidebar">
|
||||
{/* 头部:新建 + 收起 */}
|
||||
<div className="sidebar__header">
|
||||
<button className="sidebar__new-btn" onClick={onNewSession}>
|
||||
✚ {t("sidebar.new")}
|
||||
</button>
|
||||
<button className="sidebar__toggle" onClick={onToggle} title={t("sidebar.collapse")}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="sidebar__search">
|
||||
<input
|
||||
className="sidebar__search-input"
|
||||
type="text"
|
||||
placeholder={t("sidebar.search")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 会话列表(按时间分组) */}
|
||||
<div className="sidebar__list">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="sidebar__empty">{t("sidebar.empty")}</div>
|
||||
) : (
|
||||
(["today", "yesterday", "earlier"] as TimeGroup[]).map((groupKey) => {
|
||||
const items = grouped[groupKey];
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={groupKey}>
|
||||
<div className="sidebar__group">{groupLabels[groupKey]}</div>
|
||||
{items.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`sidebar__item ${session.id === activeSessionId ? "sidebar__item--active" : ""}`}
|
||||
onClick={() => handleSelect(session.id)}
|
||||
>
|
||||
{editingId === session.id ? (
|
||||
<div className="sidebar__edit" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
className="sidebar__edit-input"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleConfirmRename();
|
||||
if (e.key === "Escape") handleCancelRename();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="sidebar__edit-btn" onClick={handleConfirmRename}>✓</button>
|
||||
<button className="sidebar__edit-btn" onClick={handleCancelRename}>✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 视频标记图标 */}
|
||||
{session.messageCount > 5 && (
|
||||
<span className="sidebar__item-icon" title={t("sidebar.video")}>📹</span>
|
||||
)}
|
||||
<div className="sidebar__item-content">
|
||||
<div className="sidebar__item-title">{session.title}</div>
|
||||
<div className="sidebar__item-meta">
|
||||
{session.messageCount > 0 && (
|
||||
<span>{session.messageCount}{t("sidebar.messages")}</span>
|
||||
)}
|
||||
<span>{formatRelativeTime(session.lastActiveAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sidebar__item-actions">
|
||||
<button
|
||||
className="sidebar__action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartRename(session.id, session.title);
|
||||
}}
|
||||
title={t("sidebar.rename")}
|
||||
>
|
||||
✏
|
||||
</button>
|
||||
<button
|
||||
className="sidebar__action-btn sidebar__action-btn--danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteSession(session.id);
|
||||
}}
|
||||
title={t("sidebar.delete")}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
// ============================================================
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
interface VideoPreviewProps {
|
||||
isStreaming: boolean;
|
||||
@@ -11,6 +12,8 @@ interface VideoPreviewProps {
|
||||
|
||||
export const VideoPreview = forwardRef<HTMLVideoElement, VideoPreviewProps>(
|
||||
function VideoPreview({ isStreaming }, ref) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="video-preview">
|
||||
<video
|
||||
@@ -22,7 +25,7 @@ export const VideoPreview = forwardRef<HTMLVideoElement, VideoPreviewProps>(
|
||||
/>
|
||||
{!isStreaming && (
|
||||
<div className="video-preview__placeholder">
|
||||
摄像头未开启
|
||||
{t("video.cameraOff")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function useWebSocketManager() {
|
||||
return {
|
||||
status,
|
||||
lastMessage,
|
||||
connect: () => wsClient.connect(),
|
||||
connect: (token?: string) => wsClient.connect(token),
|
||||
disconnect: () => wsClient.disconnect(),
|
||||
send: wsClient.send.bind(wsClient),
|
||||
};
|
||||
|
||||
117
frontend/src/hooks/useSessionList.ts
Normal file
117
frontend/src/hooks/useSessionList.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
// ============================================================
|
||||
// useSessionList — 会话历史列表管理
|
||||
// 职责:会话 CRUD、消息持久化、切换会话
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
loadSessionSummaries,
|
||||
saveSessionSummaries,
|
||||
loadSessionMessages,
|
||||
saveSessionMessages,
|
||||
deleteSessionMessages,
|
||||
} from "../lib/storage";
|
||||
import type { ChatMessage, SessionSummary } from "../types";
|
||||
|
||||
/** 截取预览文本 */
|
||||
function getPreview(text: string, maxLen = 50): string {
|
||||
const clean = text.replace(/[\n\r]/g, " ").trim();
|
||||
return clean.length > maxLen ? clean.slice(0, maxLen) + "…" : clean;
|
||||
}
|
||||
|
||||
export function useSessionList() {
|
||||
const [sessions, setSessions] = useState<SessionSummary[]>(() => loadSessionSummaries());
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const sessionsRef = useRef(sessions);
|
||||
useEffect(() => { sessionsRef.current = sessions; }, [sessions]);
|
||||
|
||||
/** 创建新会话 */
|
||||
const createSession = useCallback((): string => {
|
||||
const id = uuidv4();
|
||||
const now = Date.now();
|
||||
const summary: SessionSummary = {
|
||||
id,
|
||||
title: "新对话",
|
||||
createdAt: now,
|
||||
lastActiveAt: now,
|
||||
messageCount: 0,
|
||||
preview: "",
|
||||
};
|
||||
setSessions((prev) => [summary, ...prev]);
|
||||
setActiveSessionId(id);
|
||||
// 持久化
|
||||
const all = [summary, ...sessionsRef.current];
|
||||
saveSessionSummaries(all);
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
/** 删除会话 */
|
||||
const deleteSession = useCallback((id: string) => {
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
deleteSessionMessages(id);
|
||||
const remaining = sessionsRef.current.filter((s) => s.id !== id);
|
||||
saveSessionSummaries(remaining);
|
||||
// 如果删除的是当前会话,清空 active
|
||||
setActiveSessionId((prev) => (prev === id ? null : prev));
|
||||
}, []);
|
||||
|
||||
/** 重命名会话 */
|
||||
const renameSession = useCallback((id: string, title: string) => {
|
||||
setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, title } : s)));
|
||||
const updated = sessionsRef.current.map((s) => (s.id === id ? { ...s, title } : s));
|
||||
saveSessionSummaries(updated);
|
||||
}, []);
|
||||
|
||||
/** 保存当前会话的消息历史 */
|
||||
const saveCurrentSession = useCallback((messages: ChatMessage[]) => {
|
||||
const id = sessionsRef.current.length > 0 ? sessionsRef.current[0].id : null;
|
||||
// 找到 activeSessionId 对应的会话
|
||||
// 这里不依赖 activeSessionId state,而是通过参数传入
|
||||
return messages;
|
||||
}, []);
|
||||
|
||||
/** 保存指定会话的消息并更新摘要 */
|
||||
const persistSession = useCallback((sessionId: string, messages: ChatMessage[]) => {
|
||||
if (!sessionId) return;
|
||||
saveSessionMessages(sessionId, messages);
|
||||
// 更新摘要
|
||||
const firstUserMsg = messages.find((m) => m.role === "user");
|
||||
const title = firstUserMsg ? getPreview(firstUserMsg.content, 20) : "新对话";
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
const summary: Partial<SessionSummary> = {
|
||||
title,
|
||||
messageCount: messages.length,
|
||||
lastActiveAt: lastMsg?.timestamp || Date.now(),
|
||||
preview: lastMsg ? getPreview(lastMsg.content) : "",
|
||||
};
|
||||
setSessions((prev) => {
|
||||
const updated = prev.map((s) => (s.id === sessionId ? { ...s, ...summary } : s));
|
||||
saveSessionSummaries(updated);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** 加载指定会话的消息历史 */
|
||||
const loadMessages = useCallback((sessionId: string): ChatMessage[] => {
|
||||
return loadSessionMessages(sessionId);
|
||||
}, []);
|
||||
|
||||
/** 选择会话(返回需要加载的消息) */
|
||||
const selectSession = useCallback((id: string): ChatMessage[] => {
|
||||
setActiveSessionId(id);
|
||||
return loadSessionMessages(id);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
createSession,
|
||||
deleteSession,
|
||||
renameSession,
|
||||
persistSession,
|
||||
loadMessages,
|
||||
selectSession,
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { getErrorMessage } from "../lib/errors";
|
||||
import { TTSPlayer } from "../lib/ttsPlayer";
|
||||
import { showToast } from "../lib/toast";
|
||||
import { loadConfig, saveConfig } from "../lib/storage";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { useCamera } from "../components/CameraManager";
|
||||
import { useMicrophone } from "../components/MicManager";
|
||||
import { useVAD, sampleFrame, compareFrames } from "../components/EdgeProcessor";
|
||||
@@ -28,7 +29,8 @@ export interface SessionStats {
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export function useVisionSession() {
|
||||
export function useVisionSession(accessToken?: string | null) {
|
||||
const { t } = useI18n();
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [currentReply, setCurrentReply] = useState<string>("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
@@ -45,6 +47,9 @@ export function useVisionSession() {
|
||||
// 对话历史(role + content),用于多轮上下文
|
||||
const historyRef = useRef<Array<{ role: string; content: string }>>([]);
|
||||
|
||||
// 待发消息队列(未连接时暂存,连接后自动发送)
|
||||
const pendingMessagesRef = useRef<Array<{ text: string; requestId: string }>>([]);
|
||||
|
||||
// TTS 播放器
|
||||
const ttsPlayerRef = useRef<TTSPlayer | null>(null);
|
||||
const getTTSPlayer = useCallback(() => {
|
||||
@@ -66,6 +71,12 @@ export function useVisionSession() {
|
||||
isProcessingRef.current = isProcessing;
|
||||
}, [isProcessing]);
|
||||
|
||||
// 用 ref 跟踪连接状态,避免回调闭包问题
|
||||
const statusRef = useRef(status);
|
||||
useEffect(() => {
|
||||
statusRef.current = status;
|
||||
}, [status]);
|
||||
|
||||
// 观察模式:画面变化时自动发送 query
|
||||
const { isObserving, startObserving, stopObserving } = useObservationMode({
|
||||
onChange: useCallback(
|
||||
@@ -84,7 +95,7 @@ export function useVisionSession() {
|
||||
...prev,
|
||||
{
|
||||
role: "user",
|
||||
content: "👁️ 画面变化检测",
|
||||
content: t("session.changeDetected"),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
@@ -108,7 +119,7 @@ export function useVisionSession() {
|
||||
});
|
||||
}, [videoRef, captureFrame, startObserving, stopObserving]);
|
||||
|
||||
// WebSocket 连接成功后发送 config
|
||||
// WebSocket 连接成功后发送 config + flush 待发消息
|
||||
useEffect(() => {
|
||||
if (status === "connected") {
|
||||
send({
|
||||
@@ -119,6 +130,27 @@ export function useVisionSession() {
|
||||
language: config.language,
|
||||
},
|
||||
});
|
||||
|
||||
// flush 待发消息队列
|
||||
const pending = pendingMessagesRef.current;
|
||||
pendingMessagesRef.current = [];
|
||||
for (const msg of pending) {
|
||||
const frame = captureFrame();
|
||||
send({
|
||||
type: "query",
|
||||
request_id: msg.requestId,
|
||||
image: frame ? dataUrlToBase64(frame) : "",
|
||||
audio: "",
|
||||
text: msg.text,
|
||||
});
|
||||
setStats((prev) => ({ ...prev, queryCount: prev.queryCount + 1 }));
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "user", content: msg.text, timestamp: Date.now() },
|
||||
]);
|
||||
historyRef.current.push({ role: "user", content: msg.text });
|
||||
setIsProcessing(true);
|
||||
}
|
||||
}
|
||||
}, [status]); // eslint-disable-line react-hooks/exhaustive-deps -- 仅在连接状态变化时发送
|
||||
|
||||
@@ -199,7 +231,7 @@ export function useVisionSession() {
|
||||
// 添加用户消息(STT 流式结果会逐步更新文本)
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "user", content: "(语音识别中...)", timestamp: Date.now() },
|
||||
{ role: "user", content: t("session.recognizing"), timestamp: Date.now() },
|
||||
]);
|
||||
setIsProcessing(true);
|
||||
},
|
||||
@@ -219,7 +251,7 @@ export function useVisionSession() {
|
||||
if (lastUserIdx >= 0) {
|
||||
updated[lastUserIdx] = {
|
||||
...updated[lastUserIdx],
|
||||
content: msg.text || "(未识别到语音)",
|
||||
content: msg.text || t("session.noSpeech"),
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
@@ -271,7 +303,7 @@ export function useVisionSession() {
|
||||
|
||||
case "error":
|
||||
console.error("[Session] 服务端错误:", msg.code, msg.message);
|
||||
showToast(getErrorMessage(msg.code), "error");
|
||||
showToast(getErrorMessage(msg.code, t), "error");
|
||||
setIsProcessing(false);
|
||||
break;
|
||||
}
|
||||
@@ -280,10 +312,13 @@ export function useVisionSession() {
|
||||
return unsub;
|
||||
}, [getTTSPlayer]);
|
||||
|
||||
/** 启动会话 */
|
||||
/** 启动视频通话(摄像头 + 麦克风 + VAD) */
|
||||
const startSession = useCallback(async () => {
|
||||
// 1. 连接 WebSocket(必须)
|
||||
connect();
|
||||
// 1. 确保 WebSocket 已连接
|
||||
if (statusRef.current !== "connected") {
|
||||
connect(accessToken || undefined);
|
||||
// 等待连接完成(通过 status 变化触发后续流程,这里直接继续)
|
||||
}
|
||||
|
||||
// 2. 尝试获取摄像头(可选)
|
||||
try {
|
||||
@@ -302,7 +337,7 @@ export function useVisionSession() {
|
||||
} else {
|
||||
console.warn("[Session] 无法获取麦克风权限,将以文本输入模式运行");
|
||||
}
|
||||
}, [startCamera, startMic, connect, startVAD]);
|
||||
}, [startCamera, startMic, connect, startVAD, accessToken]);
|
||||
|
||||
/** 结束会话 */
|
||||
const stopSession = useCallback(async () => {
|
||||
@@ -359,7 +394,7 @@ export function useVisionSession() {
|
||||
setIsAudioPlaying(false);
|
||||
// 将未完成的流式内容保存为最终消息
|
||||
if (currentReply) {
|
||||
const interrupted = currentReply + "(已打断)";
|
||||
const interrupted = currentReply + t("session.interrupted");
|
||||
historyRef.current.push({ role: "assistant", content: interrupted });
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
@@ -379,10 +414,23 @@ export function useVisionSession() {
|
||||
ttsPlayerRef.current?.stop();
|
||||
setIsAudioPlaying(false);
|
||||
|
||||
// 捕获当前摄像头画面
|
||||
const frame = captureFrame();
|
||||
|
||||
const requestId = uuidv4();
|
||||
|
||||
// 未连接时:自动连接,消息加入待发队列
|
||||
if (statusRef.current !== "connected") {
|
||||
pendingMessagesRef.current.push({ text: text.trim(), requestId });
|
||||
// 添加用户消息到 UI(立即反馈)
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "user", content: text.trim(), timestamp: Date.now() }],
|
||||
);
|
||||
// 自动连接 WebSocket
|
||||
connect(accessToken || undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// 已连接:直接发送
|
||||
const frame = captureFrame();
|
||||
send({
|
||||
type: "query",
|
||||
request_id: requestId,
|
||||
@@ -405,11 +453,12 @@ export function useVisionSession() {
|
||||
|
||||
setIsProcessing(true);
|
||||
},
|
||||
[captureFrame, send],
|
||||
[captureFrame, send, connect, accessToken],
|
||||
);
|
||||
|
||||
return {
|
||||
messages,
|
||||
setMessages,
|
||||
currentReply,
|
||||
isProcessing,
|
||||
isAudioPlaying,
|
||||
@@ -433,5 +482,6 @@ export function useVisionSession() {
|
||||
toggleCamera,
|
||||
toggleMic,
|
||||
sendTextMessage,
|
||||
captureFrame,
|
||||
};
|
||||
}
|
||||
|
||||
106
frontend/src/lib/api.ts
Normal file
106
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// ============================================================
|
||||
// HTTP API 客户端
|
||||
// 职责:封装 REST API 请求(auth、conversations 等)
|
||||
// ============================================================
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: { code: string; message: string };
|
||||
status: number;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const url = `${API_BASE}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
const status = res.status;
|
||||
|
||||
if (res.status === 204) {
|
||||
return { status };
|
||||
}
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
error: { code: body.error || "UNKNOWN", message: body.message || "请求失败" },
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
return { data: body as T, status };
|
||||
} catch (err) {
|
||||
return {
|
||||
error: { code: "NETWORK_ERROR", message: "网络连接失败,请检查网络" },
|
||||
status: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders(accessToken: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${accessToken}` };
|
||||
}
|
||||
|
||||
// ---- Auth API ----
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: AuthUser;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export async function register(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<ApiResponse<AuthResponse>> {
|
||||
return request<AuthResponse>("/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<ApiResponse<AuthResponse>> {
|
||||
return request<AuthResponse>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshToken(
|
||||
refresh_token: string
|
||||
): Promise<ApiResponse<AuthResponse>> {
|
||||
return request<AuthResponse>("/auth/refresh", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ refresh_token }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function logout(
|
||||
accessToken: string,
|
||||
refreshToken: string
|
||||
): Promise<ApiResponse<{ message: string }>> {
|
||||
return request<{ message: string }>("/auth/logout", {
|
||||
method: "POST",
|
||||
headers: authHeaders(accessToken),
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
}
|
||||
209
frontend/src/lib/auth.tsx
Normal file
209
frontend/src/lib/auth.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
// ============================================================
|
||||
// Auth Context — 认证状态管理
|
||||
// 职责:登录/注册/登出/token 刷新,为子组件提供 auth 状态
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import * as api from "./api";
|
||||
import type { AuthUser } from "./api";
|
||||
import {
|
||||
clearAuth,
|
||||
loadAccessToken,
|
||||
loadRefreshToken,
|
||||
loadUser,
|
||||
saveAccessToken,
|
||||
saveRefreshToken,
|
||||
saveUser,
|
||||
} from "./storage";
|
||||
|
||||
interface AuthState {
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextValue extends AuthState {
|
||||
login: (username: string, password: string) => Promise<{ error?: string }>;
|
||||
register: (username: string, password: string) => Promise<{ error?: string }>;
|
||||
logout: () => Promise<void>;
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
/** Access token 提前刷新的缓冲时间(秒) */
|
||||
const REFRESH_BUFFER_SEC = 60;
|
||||
|
||||
/** 解析 JWT payload(不做签名验证) */
|
||||
function parseJwtPayload(token: string): { exp?: number } | null {
|
||||
try {
|
||||
const base64 = token.split(".")[1];
|
||||
const json = atob(base64.replace(/-/g, "+").replace(/_/g, "/"));
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(loadUser);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(loadAccessToken);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 清除定时器
|
||||
const clearRefreshTimer = useCallback(() => {
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 持久化 token + user
|
||||
const persistAuth = useCallback(
|
||||
(authUser: AuthUser, access: string, refresh: string) => {
|
||||
setUser(authUser);
|
||||
setAccessToken(access);
|
||||
saveAccessToken(access);
|
||||
saveRefreshToken(refresh);
|
||||
saveUser(authUser);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 安排自动刷新
|
||||
const scheduleRefresh = useCallback(
|
||||
(access: string) => {
|
||||
clearRefreshTimer();
|
||||
const payload = parseJwtPayload(access);
|
||||
if (!payload?.exp) return;
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const delayMs = Math.max((payload.exp - nowSec - REFRESH_BUFFER_SEC) * 1000, 5000);
|
||||
|
||||
refreshTimerRef.current = setTimeout(async () => {
|
||||
const rt = loadRefreshToken();
|
||||
if (!rt) return;
|
||||
const res = await api.refreshToken(rt);
|
||||
if (res.data) {
|
||||
persistAuth(res.data.user, res.data.access_token, res.data.refresh_token);
|
||||
scheduleRefresh(res.data.access_token);
|
||||
} else {
|
||||
// 刷新失败,清除 auth
|
||||
clearAuth();
|
||||
setUser(null);
|
||||
setAccessToken(null);
|
||||
}
|
||||
}, delayMs);
|
||||
},
|
||||
[clearRefreshTimer, persistAuth]
|
||||
);
|
||||
|
||||
// 初始化:检查已有 token 并尝试刷新
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const storedAccess = loadAccessToken();
|
||||
const storedRefresh = loadRefreshToken();
|
||||
const storedUser = loadUser();
|
||||
|
||||
if (!storedAccess || !storedRefresh || !storedUser) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 access token 是否过期
|
||||
const payload = parseJwtPayload(storedAccess);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (payload?.exp && payload.exp > nowSec) {
|
||||
// access token 仍然有效
|
||||
setUser(storedUser);
|
||||
setAccessToken(storedAccess);
|
||||
scheduleRefresh(storedAccess);
|
||||
} else {
|
||||
// access token 过期,尝试 refresh
|
||||
const res = await api.refreshToken(storedRefresh);
|
||||
if (res.data) {
|
||||
persistAuth(res.data.user, res.data.access_token, res.data.refresh_token);
|
||||
scheduleRefresh(res.data.access_token);
|
||||
} else {
|
||||
clearAuth();
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
init();
|
||||
return () => clearRefreshTimer();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const login = useCallback(
|
||||
async (username: string, password: string): Promise<{ error?: string }> => {
|
||||
const res = await api.login(username, password);
|
||||
if (res.data) {
|
||||
persistAuth(res.data.user, res.data.access_token, res.data.refresh_token);
|
||||
scheduleRefresh(res.data.access_token);
|
||||
return {};
|
||||
}
|
||||
return { error: res.error?.message || "登录失败" };
|
||||
},
|
||||
[persistAuth, scheduleRefresh]
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
async (username: string, password: string): Promise<{ error?: string }> => {
|
||||
const res = await api.register(username, password);
|
||||
if (res.data) {
|
||||
persistAuth(res.data.user, res.data.access_token, res.data.refresh_token);
|
||||
scheduleRefresh(res.data.access_token);
|
||||
return {};
|
||||
}
|
||||
return { error: res.error?.message || "注册失败" };
|
||||
},
|
||||
[persistAuth, scheduleRefresh]
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
const at = accessToken;
|
||||
const rt = loadRefreshToken();
|
||||
if (at && rt) {
|
||||
await api.logout(at, rt);
|
||||
}
|
||||
clearRefreshTimer();
|
||||
clearAuth();
|
||||
setUser(null);
|
||||
setAccessToken(null);
|
||||
}, [accessToken, clearRefreshTimer]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
isAuthenticated: !!user && !!accessToken,
|
||||
isLoading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
accessToken,
|
||||
}),
|
||||
[user, accessToken, isLoading, login, register, logout]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -5,20 +5,13 @@
|
||||
|
||||
import type { ErrorCode } from "../types";
|
||||
|
||||
const ERROR_MESSAGES: Record<ErrorCode, string> = {
|
||||
INVALID_MESSAGE: "消息格式异常,请重试",
|
||||
SESSION_NOT_FOUND: "会话已过期,请重新连接",
|
||||
RATE_LIMITED: "请求太频繁,请稍后再试",
|
||||
IMAGE_TOO_LARGE: "图像过大,请降低分辨率",
|
||||
AUDIO_TOO_SHORT: "语音太短,请再说一句",
|
||||
LLM_TIMEOUT: "AI 响应超时,请重试",
|
||||
LLM_ERROR: "AI 服务异常,请稍后重试",
|
||||
STT_ERROR: "语音识别失败,请重试",
|
||||
TTS_ERROR: "语音合成失败",
|
||||
INTERNAL_ERROR: "服务内部错误,请重试",
|
||||
};
|
||||
|
||||
/** 将错误码转为用户友好文案 */
|
||||
export function getErrorMessage(code: string): string {
|
||||
return ERROR_MESSAGES[code as ErrorCode] ?? `未知错误: ${code}`;
|
||||
/** 将错误码转为用户友好文案(需要传入翻译函数) */
|
||||
export function getErrorMessage(code: string, translate: (key: string) => string): string {
|
||||
const key = `error.${code}`;
|
||||
const translated = translate(key);
|
||||
// 如果翻译函数返回 key 本身(未找到翻译),用 fallback
|
||||
if (translated === key) {
|
||||
return translate("error.unknown") + `: ${code}`;
|
||||
}
|
||||
return translated;
|
||||
}
|
||||
|
||||
152
frontend/src/lib/i18n/en-US.ts
Normal file
152
frontend/src/lib/i18n/en-US.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { TranslationMap } from "./index";
|
||||
|
||||
export const enUS: TranslationMap = {
|
||||
// App header
|
||||
"app.title": "AI Vision Assistant",
|
||||
"app.stats.requests": "requests",
|
||||
|
||||
// Connection status
|
||||
"status.connected": "Connected",
|
||||
"status.connecting": "Connecting...",
|
||||
"status.disconnected": "Disconnected",
|
||||
|
||||
// Settings
|
||||
"settings.title": "Settings",
|
||||
"settings.appearance": "Appearance",
|
||||
"settings.theme": "Theme",
|
||||
"settings.theme.desc": "Switch between light and dark theme",
|
||||
"settings.theme.dark": "Dark",
|
||||
"settings.theme.light": "Light",
|
||||
"settings.session": "Session",
|
||||
"settings.tts": "Voice Response",
|
||||
"settings.tts.desc": "Play voice along with AI response",
|
||||
"settings.detail": "Image Quality",
|
||||
"settings.detail.desc": "High quality for text recognition, low saves bandwidth",
|
||||
"settings.detail.low": "Low",
|
||||
"settings.detail.high": "High",
|
||||
"settings.language": "Language",
|
||||
"settings.language.desc": "Interaction language preference",
|
||||
|
||||
// Video indicators
|
||||
"video.observing": "👁️ Observing",
|
||||
"video.listening": "🎤 Listening...",
|
||||
"video.playing": "🔊 Playing...",
|
||||
"video.initVad": "Initializing voice detection...",
|
||||
"video.clickToStart": "Click the button below to start",
|
||||
"video.placeholder": "Type in the chat panel to start",
|
||||
"video.cameraOff": "Camera is off",
|
||||
"video.cameraOff.hint": "You can type in the chat panel",
|
||||
|
||||
// Controls
|
||||
"controls.connecting": "Connecting...",
|
||||
"controls.start": "🎙️ Start Session",
|
||||
"controls.startVideo": "🎙️ Start Video Call",
|
||||
"controls.cameraOff": "Turn off camera",
|
||||
"controls.cameraOn": "Turn on camera",
|
||||
"controls.micOff": "Turn off microphone",
|
||||
"controls.micOn": "Turn on microphone",
|
||||
"controls.observing": "👁️ Observing",
|
||||
"controls.observation": "👁️ Observe",
|
||||
"controls.interrupt": "⏹ Interrupt",
|
||||
"controls.stop": "End Session",
|
||||
|
||||
// Chat panel
|
||||
"chat.title": "Chat",
|
||||
"chat.mode.observation": "Observing",
|
||||
"chat.reconnecting": "Connection lost, reconnecting...",
|
||||
"chat.empty.prompt": "Type below to start chatting",
|
||||
"chat.empty.hint": "Type to chat with AI, or click the button on the left to start video",
|
||||
"chat.welcome.prompt": "Type below to start chatting",
|
||||
"chat.welcome.hint": "Or enable microphone for voice chat",
|
||||
"chat.userLabel": "You",
|
||||
"chat.input.placeholder": "Type a message...",
|
||||
"chat.send": "Send",
|
||||
|
||||
// Session messages
|
||||
"session.changeDetected": "👁️ Scene change detected",
|
||||
"session.recognizing": "(Recognizing speech...)",
|
||||
"session.noSpeech": "(No speech detected)",
|
||||
"session.interrupted": "(Interrupted)",
|
||||
|
||||
// Errors
|
||||
"error.INVALID_MESSAGE": "Invalid message format, please retry",
|
||||
"error.SESSION_NOT_FOUND": "Session expired, please reconnect",
|
||||
"error.RATE_LIMITED": "Too many requests, please try again later",
|
||||
"error.IMAGE_TOO_LARGE": "Image too large, please reduce resolution",
|
||||
"error.AUDIO_TOO_SHORT": "Audio too short, please try again",
|
||||
"error.LLM_TIMEOUT": "AI response timed out, please retry",
|
||||
"error.LLM_ERROR": "AI service error, please try again later",
|
||||
"error.STT_ERROR": "Speech recognition failed, please retry",
|
||||
"error.TTS_ERROR": "Voice synthesis failed",
|
||||
"error.INTERNAL_ERROR": "Internal server error, please retry",
|
||||
"error.unknown": "Unknown error",
|
||||
|
||||
// Sidebar
|
||||
"sidebar.new": "New Chat",
|
||||
"sidebar.expand": "Expand sidebar",
|
||||
"sidebar.collapse": "Collapse sidebar",
|
||||
"sidebar.empty": "No chat history",
|
||||
"sidebar.messages": " messages",
|
||||
"sidebar.rename": "Rename",
|
||||
"sidebar.delete": "Delete",
|
||||
"sidebar.search": "Search conversations...",
|
||||
"sidebar.today": "Today",
|
||||
"sidebar.yesterday": "Yesterday",
|
||||
"sidebar.earlier": "Earlier",
|
||||
"sidebar.pinned": "Pinned",
|
||||
"sidebar.video": "Video",
|
||||
"sidebar.clearHistory": "Clear History",
|
||||
|
||||
// Scene cards (empty state)
|
||||
"scene.describe": "Describe my surroundings",
|
||||
"scene.describe.desc": "Let AI observe and describe the current scene",
|
||||
"scene.text": "Recognize text in view",
|
||||
"scene.text.desc": "Extract and translate text from the scene",
|
||||
"scene.object": "Analyze this object",
|
||||
"scene.object.desc": "Identify objects and provide information",
|
||||
"scene.suggest": "Give me suggestions",
|
||||
"scene.suggest.desc": "Practical suggestions based on the current scene",
|
||||
|
||||
// Video controls (enhanced)
|
||||
"controls.recognize": "Analyze Scene",
|
||||
"controls.device.camera": "Camera",
|
||||
"controls.device.mic": "Microphone",
|
||||
"controls.device.default": "Default",
|
||||
"controls.mode.realtime": "Realtime",
|
||||
"controls.mode.ondemand": "On-demand",
|
||||
"controls.mode.chat": "Chat only",
|
||||
|
||||
// Status bar
|
||||
"statusbar.ready": "Ready · Select devices to start",
|
||||
"statusbar.calling": "In call",
|
||||
"statusbar.duration": "Duration",
|
||||
"statusbar.recognitions": "recognitions",
|
||||
"statusbar.tokens": "Tokens",
|
||||
|
||||
// Input enhanced
|
||||
"chat.input.voice": "Voice input",
|
||||
|
||||
// Device errors
|
||||
"error.vadInit": "VAD initialization failed",
|
||||
"error.cameraAccess": "Cannot access camera",
|
||||
"error.micAccess": "Cannot access microphone",
|
||||
|
||||
// Auth
|
||||
"auth.subtitle": "AI Vision Assistant",
|
||||
"auth.login": "Sign In",
|
||||
"auth.register": "Sign Up",
|
||||
"auth.username": "Username",
|
||||
"auth.username.placeholder": "3-64 characters",
|
||||
"auth.password": "Password",
|
||||
"auth.password.placeholder": "8-72 characters",
|
||||
"auth.submitting": "Please wait...",
|
||||
"auth.noAccount": "Don't have an account?",
|
||||
"auth.hasAccount": "Already have an account?",
|
||||
"auth.error.usernameLength": "Username must be 3-64 characters",
|
||||
"auth.error.passwordLength": "Password must be 8-72 characters",
|
||||
"auth.logout": "Sign Out",
|
||||
|
||||
// Account (in settings)
|
||||
"settings.account": "Account",
|
||||
"settings.account.user": "Current user",
|
||||
};
|
||||
47
frontend/src/lib/i18n/index.ts
Normal file
47
frontend/src/lib/i18n/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// ============================================================
|
||||
// i18n — 轻量国际化模块
|
||||
// 职责:提供翻译函数和 React Context,根据 locale 返回对应语言文本
|
||||
// ============================================================
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import { zhCN } from "./zh-CN";
|
||||
import { enUS } from "./en-US";
|
||||
import { jaJP } from "./ja-JP";
|
||||
|
||||
export type Locale = "zh-CN" | "en-US" | "ja-JP";
|
||||
|
||||
export type TranslationMap = Record<string, string>;
|
||||
|
||||
const translations: Record<Locale, TranslationMap> = {
|
||||
"zh-CN": zhCN,
|
||||
"en-US": enUS,
|
||||
"ja-JP": jaJP,
|
||||
};
|
||||
|
||||
/** 翻译函数:根据 key 和 locale 返回翻译文本 */
|
||||
export function t(key: string, locale: Locale): string {
|
||||
return translations[locale]?.[key] ?? translations["zh-CN"][key] ?? key;
|
||||
}
|
||||
|
||||
/** 从 config.language 值解析为合法 Locale */
|
||||
export function parseLocale(lang: string): Locale {
|
||||
if (lang === "en-US" || lang === "ja-JP") return lang;
|
||||
return "zh-CN";
|
||||
}
|
||||
|
||||
// ---- React Context ----
|
||||
|
||||
export interface I18nContextValue {
|
||||
locale: Locale;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export const I18nContext = createContext<I18nContextValue>({
|
||||
locale: "zh-CN",
|
||||
t: (key) => t(key, "zh-CN"),
|
||||
});
|
||||
|
||||
/** 组件内获取 i18n 的便捷 Hook */
|
||||
export function useI18n() {
|
||||
return useContext(I18nContext);
|
||||
}
|
||||
152
frontend/src/lib/i18n/ja-JP.ts
Normal file
152
frontend/src/lib/i18n/ja-JP.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { TranslationMap } from "./index";
|
||||
|
||||
export const jaJP: TranslationMap = {
|
||||
// App header
|
||||
"app.title": "AI ビジョンアシスタント",
|
||||
"app.stats.requests": "リクエスト",
|
||||
|
||||
// Connection status
|
||||
"status.connected": "接続済み",
|
||||
"status.connecting": "接続中...",
|
||||
"status.disconnected": "未接続",
|
||||
|
||||
// Settings
|
||||
"settings.title": "設定",
|
||||
"settings.appearance": "外観",
|
||||
"settings.theme": "テーマ",
|
||||
"settings.theme.desc": "ライト/ダークテーマを切り替え",
|
||||
"settings.theme.dark": "ダーク",
|
||||
"settings.theme.light": "ライト",
|
||||
"settings.session": "セッション",
|
||||
"settings.tts": "音声応答",
|
||||
"settings.tts.desc": "AI応答と同時に音声を再生",
|
||||
"settings.detail": "画像品質",
|
||||
"settings.detail.desc": "高品質は文字認識に最適、低品質は帯域節約",
|
||||
"settings.detail.low": "低",
|
||||
"settings.detail.high": "高",
|
||||
"settings.language": "言語",
|
||||
"settings.language.desc": "インタラクション言語の設定",
|
||||
|
||||
// Video indicators
|
||||
"video.observing": "👁️ 観察中",
|
||||
"video.listening": "🎤 聞き取り中...",
|
||||
"video.playing": "🔊 再生中...",
|
||||
"video.initVad": "音声検出を初期化中...",
|
||||
"video.clickToStart": "下のボタンをクリックして開始",
|
||||
"video.placeholder": "右側のチャットに入力して開始",
|
||||
"video.cameraOff": "カメラがオフです",
|
||||
"video.cameraOff.hint": "右側のチャットでテキスト対話ができます",
|
||||
|
||||
// Controls
|
||||
"controls.connecting": "接続中...",
|
||||
"controls.start": "🎙️ 対話を開始",
|
||||
"controls.startVideo": "🎙️ ビデオ通話を開始",
|
||||
"controls.cameraOff": "カメラをオフ",
|
||||
"controls.cameraOn": "カメラをオン",
|
||||
"controls.micOff": "マイクをオフ",
|
||||
"controls.micOn": "マイクをオン",
|
||||
"controls.observing": "👁️ 観察中",
|
||||
"controls.observation": "👁️ 観察モード",
|
||||
"controls.interrupt": "⏹ 中断",
|
||||
"controls.stop": "対話を終了",
|
||||
|
||||
// Chat panel
|
||||
"chat.title": "チャット",
|
||||
"chat.mode.observation": "観察モード",
|
||||
"chat.reconnecting": "接続が切断されました。再接続中...",
|
||||
"chat.empty.prompt": "下にテキストを入力して対話を開始",
|
||||
"chat.empty.hint": "テキストでAIと対話、または左のボタンでビデオ通話を開始",
|
||||
"chat.welcome.prompt": "下にテキストを入力して対話を開始",
|
||||
"chat.welcome.hint": "マイクを有効にして音声対話もできます",
|
||||
"chat.userLabel": "あなた",
|
||||
"chat.input.placeholder": "メッセージを入力...",
|
||||
"chat.send": "送信",
|
||||
|
||||
// Session messages
|
||||
"session.changeDetected": "👁️ シーン変化を検出",
|
||||
"session.recognizing": "(音声認識中...)",
|
||||
"session.noSpeech": "(音声が検出されませんでした)",
|
||||
"session.interrupted": "(中断済み)",
|
||||
|
||||
// Errors
|
||||
"error.INVALID_MESSAGE": "メッセージ形式が無効です。再試行してください",
|
||||
"error.SESSION_NOT_FOUND": "セッションが期限切れです。再接続してください",
|
||||
"error.RATE_LIMITED": "リクエストが多すぎます。しばらく待ってから再試行してください",
|
||||
"error.IMAGE_TOO_LARGE": "画像が大きすぎます。解像度を下げてください",
|
||||
"error.AUDIO_TOO_SHORT": "音声が短すぎます。もう一度お試しください",
|
||||
"error.LLM_TIMEOUT": "AI応答がタイムアウトしました。再試行してください",
|
||||
"error.LLM_ERROR": "AIサービスエラー。しばらく待ってから再試行してください",
|
||||
"error.STT_ERROR": "音声認識に失敗しました。再試行してください",
|
||||
"error.TTS_ERROR": "音声合成に失敗しました",
|
||||
"error.INTERNAL_ERROR": "サーバー内部エラー。再試行してください",
|
||||
"error.unknown": "不明なエラー",
|
||||
|
||||
// Sidebar
|
||||
"sidebar.new": "新しいチャット",
|
||||
"sidebar.expand": "サイドバーを展開",
|
||||
"sidebar.collapse": "サイドバーを折りたたむ",
|
||||
"sidebar.empty": "会話履歴がありません",
|
||||
"sidebar.messages": "件のメッセージ",
|
||||
"sidebar.rename": "名前を変更",
|
||||
"sidebar.delete": "削除",
|
||||
"sidebar.search": "会話を検索...",
|
||||
"sidebar.today": "今日",
|
||||
"sidebar.yesterday": "昨日",
|
||||
"sidebar.earlier": "それ以前",
|
||||
"sidebar.pinned": "ピン留め",
|
||||
"sidebar.video": "ビデオ",
|
||||
"sidebar.clearHistory": "履歴をクリア",
|
||||
|
||||
// Scene cards (empty state)
|
||||
"scene.describe": "周囲のシーンを説明して",
|
||||
"scene.describe.desc": "AIが現在のシーンを観察して説明します",
|
||||
"scene.text": "画面のテキストを認識",
|
||||
"scene.text.desc": "シーン内のテキストを抽出・翻訳します",
|
||||
"scene.object": "この物を分析して",
|
||||
"scene.object.desc": "物体を識別して関連情報を提供します",
|
||||
"scene.suggest": "アドバイスをください",
|
||||
"scene.suggest.desc": "現在のシーンに基づいた実用的な提案",
|
||||
|
||||
// Video controls (enhanced)
|
||||
"controls.recognize": "シーンを分析",
|
||||
"controls.device.camera": "カメラ",
|
||||
"controls.device.mic": "マイク",
|
||||
"controls.device.default": "デフォルト",
|
||||
"controls.mode.realtime": "リアルタイム",
|
||||
"controls.mode.ondemand": "オンデマンド",
|
||||
"controls.mode.chat": "チャットのみ",
|
||||
|
||||
// Status bar
|
||||
"statusbar.ready": "準備完了 · デバイスを選択して開始",
|
||||
"statusbar.calling": "通話中",
|
||||
"statusbar.duration": "通話時間",
|
||||
"statusbar.recognitions": "回の認識",
|
||||
"statusbar.tokens": "トークン",
|
||||
|
||||
// Input enhanced
|
||||
"chat.input.voice": "音声入力",
|
||||
|
||||
// Device errors
|
||||
"error.vadInit": "VAD初期化に失敗しました",
|
||||
"error.cameraAccess": "カメラにアクセスできません",
|
||||
"error.micAccess": "マイクにアクセスできません",
|
||||
|
||||
// Auth
|
||||
"auth.subtitle": "AI ビジョンアシスタント",
|
||||
"auth.login": "ログイン",
|
||||
"auth.register": "新規登録",
|
||||
"auth.username": "ユーザー名",
|
||||
"auth.username.placeholder": "3〜64文字",
|
||||
"auth.password": "パスワード",
|
||||
"auth.password.placeholder": "8〜72文字",
|
||||
"auth.submitting": "お待ちください...",
|
||||
"auth.noAccount": "アカウントをお持ちでないですか?",
|
||||
"auth.hasAccount": "すでにアカウントをお持ちですか?",
|
||||
"auth.error.usernameLength": "ユーザー名は3〜64文字で入力してください",
|
||||
"auth.error.passwordLength": "パスワードは8〜72文字で入力してください",
|
||||
"auth.logout": "ログアウト",
|
||||
|
||||
// Account (in settings)
|
||||
"settings.account": "アカウント",
|
||||
"settings.account.user": "現在のユーザー",
|
||||
};
|
||||
152
frontend/src/lib/i18n/zh-CN.ts
Normal file
152
frontend/src/lib/i18n/zh-CN.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { TranslationMap } from "./index";
|
||||
|
||||
export const zhCN: TranslationMap = {
|
||||
// App header
|
||||
"app.title": "AI 视觉对话助手",
|
||||
"app.stats.requests": "次请求",
|
||||
|
||||
// Connection status
|
||||
"status.connected": "已连接",
|
||||
"status.connecting": "连接中...",
|
||||
"status.disconnected": "未连接",
|
||||
|
||||
// Settings
|
||||
"settings.title": "设置",
|
||||
"settings.appearance": "外观",
|
||||
"settings.theme": "主题",
|
||||
"settings.theme.desc": "切换明暗主题",
|
||||
"settings.theme.dark": "深色",
|
||||
"settings.theme.light": "浅色",
|
||||
"settings.session": "会话",
|
||||
"settings.tts": "语音回答",
|
||||
"settings.tts.desc": "AI 回答时同步播放语音",
|
||||
"settings.detail": "图像精度",
|
||||
"settings.detail.desc": "高精度适合识别文字,低精度省流量",
|
||||
"settings.detail.low": "低",
|
||||
"settings.detail.high": "高",
|
||||
"settings.language": "语言",
|
||||
"settings.language.desc": "交互语言偏好",
|
||||
|
||||
// Video indicators
|
||||
"video.observing": "👁️ 观察中",
|
||||
"video.listening": "🎤 正在聆听...",
|
||||
"video.playing": "🔊 正在播放...",
|
||||
"video.initVad": "正在初始化语音检测...",
|
||||
"video.clickToStart": "点击下方按钮开始对话",
|
||||
"video.placeholder": "在右侧聊天框输入即可开始对话",
|
||||
"video.cameraOff": "摄像头未开启",
|
||||
"video.cameraOff.hint": "可在右侧聊天框打字对话",
|
||||
|
||||
// Controls
|
||||
"controls.connecting": "连接中...",
|
||||
"controls.start": "🎙️ 开始对话",
|
||||
"controls.startVideo": "🎙️ 开始视频通话",
|
||||
"controls.cameraOff": "关闭摄像头",
|
||||
"controls.cameraOn": "开启摄像头",
|
||||
"controls.micOff": "关闭麦克风",
|
||||
"controls.micOn": "开启麦克风",
|
||||
"controls.observing": "👁️ 观察中",
|
||||
"controls.observation": "👁️ 观察模式",
|
||||
"controls.interrupt": "⏹ 打断",
|
||||
"controls.stop": "结束对话",
|
||||
|
||||
// Chat panel
|
||||
"chat.title": "对话",
|
||||
"chat.mode.observation": "观察模式",
|
||||
"chat.reconnecting": "连接已断开,正在重连...",
|
||||
"chat.empty.prompt": "在下方输入文字开始对话",
|
||||
"chat.empty.hint": "输入文字即可与 AI 交互,也可点击左侧按钮开启视频",
|
||||
"chat.welcome.prompt": "在下方输入文字开始对话",
|
||||
"chat.welcome.hint": "也可以开启麦克风用语音对话",
|
||||
"chat.userLabel": "你",
|
||||
"chat.input.placeholder": "输入文字对话...",
|
||||
"chat.send": "发送",
|
||||
|
||||
// Session messages
|
||||
"session.changeDetected": "👁️ 画面变化检测",
|
||||
"session.recognizing": "(语音识别中...)",
|
||||
"session.noSpeech": "(未识别到语音)",
|
||||
"session.interrupted": "(已打断)",
|
||||
|
||||
// Errors
|
||||
"error.INVALID_MESSAGE": "消息格式异常,请重试",
|
||||
"error.SESSION_NOT_FOUND": "会话已过期,请重新连接",
|
||||
"error.RATE_LIMITED": "请求太频繁,请稍后再试",
|
||||
"error.IMAGE_TOO_LARGE": "图像过大,请降低分辨率",
|
||||
"error.AUDIO_TOO_SHORT": "语音太短,请再说一句",
|
||||
"error.LLM_TIMEOUT": "AI 响应超时,请重试",
|
||||
"error.LLM_ERROR": "AI 服务异常,请稍后重试",
|
||||
"error.STT_ERROR": "语音识别失败,请重试",
|
||||
"error.TTS_ERROR": "语音合成失败",
|
||||
"error.INTERNAL_ERROR": "服务内部错误,请重试",
|
||||
"error.unknown": "未知错误",
|
||||
|
||||
// Sidebar
|
||||
"sidebar.new": "新对话",
|
||||
"sidebar.expand": "展开侧边栏",
|
||||
"sidebar.collapse": "收起侧边栏",
|
||||
"sidebar.empty": "暂无会话记录",
|
||||
"sidebar.messages": "条消息",
|
||||
"sidebar.rename": "重命名",
|
||||
"sidebar.delete": "删除",
|
||||
"sidebar.search": "搜索对话...",
|
||||
"sidebar.today": "今天",
|
||||
"sidebar.yesterday": "昨天",
|
||||
"sidebar.earlier": "更早",
|
||||
"sidebar.pinned": "置顶",
|
||||
"sidebar.video": "视频",
|
||||
"sidebar.clearHistory": "清除历史",
|
||||
|
||||
// Scene cards (empty state)
|
||||
"scene.describe": "描述我面前的场景",
|
||||
"scene.describe.desc": "让 AI 观察并描述当前画面",
|
||||
"scene.text": "识别画面中的文字",
|
||||
"scene.text.desc": "提取并翻译画面里的文字",
|
||||
"scene.object": "帮我分析这个物品",
|
||||
"scene.object.desc": "识别物体并给出相关信息",
|
||||
"scene.suggest": "给我一些建议",
|
||||
"scene.suggest.desc": "基于当前场景给出实用建议",
|
||||
|
||||
// Video controls (enhanced)
|
||||
"controls.recognize": "识别画面",
|
||||
"controls.device.camera": "摄像头",
|
||||
"controls.device.mic": "麦克风",
|
||||
"controls.device.default": "默认",
|
||||
"controls.mode.realtime": "实时分析",
|
||||
"controls.mode.ondemand": "按需识别",
|
||||
"controls.mode.chat": "纯聊天",
|
||||
|
||||
// Status bar
|
||||
"statusbar.ready": "就绪 · 选择设备后开始通话",
|
||||
"statusbar.calling": "通话中",
|
||||
"statusbar.duration": "通话时长",
|
||||
"statusbar.recognitions": "次识别",
|
||||
"statusbar.tokens": "Token",
|
||||
|
||||
// Input enhanced
|
||||
"chat.input.voice": "语音输入",
|
||||
|
||||
// Device errors
|
||||
"error.vadInit": "VAD 初始化失败",
|
||||
"error.cameraAccess": "无法访问摄像头",
|
||||
"error.micAccess": "无法访问麦克风",
|
||||
|
||||
// Auth
|
||||
"auth.subtitle": "AI 视觉对话助手",
|
||||
"auth.login": "登录",
|
||||
"auth.register": "注册",
|
||||
"auth.username": "用户名",
|
||||
"auth.username.placeholder": "3-64 个字符",
|
||||
"auth.password": "密码",
|
||||
"auth.password.placeholder": "8-72 个字符",
|
||||
"auth.submitting": "请稍候...",
|
||||
"auth.noAccount": "还没有账号?",
|
||||
"auth.hasAccount": "已有账号?",
|
||||
"auth.error.usernameLength": "用户名需要 3-64 个字符",
|
||||
"auth.error.passwordLength": "密码需要 8-72 个字符",
|
||||
"auth.logout": "退出登录",
|
||||
|
||||
// Account (in settings)
|
||||
"settings.account": "账号",
|
||||
"settings.account.user": "当前用户",
|
||||
};
|
||||
@@ -3,10 +3,13 @@
|
||||
// 职责:会话配置持久化
|
||||
// ============================================================
|
||||
|
||||
import type { SessionConfig, Theme } from "../types";
|
||||
import type { ChatMessage, SessionConfig, SessionSummary, Theme } from "../types";
|
||||
|
||||
const CONFIG_KEY = "camtalk:config";
|
||||
const THEME_KEY = "camtalk:theme";
|
||||
const ACCESS_TOKEN_KEY = "camtalk:access_token";
|
||||
const REFRESH_TOKEN_KEY = "camtalk:refresh_token";
|
||||
const USER_KEY = "camtalk:user";
|
||||
|
||||
const DEFAULT_CONFIG: SessionConfig = {
|
||||
ttsEnabled: true,
|
||||
@@ -30,6 +33,8 @@ export function loadConfig(): SessionConfig {
|
||||
export function saveConfig(config: SessionConfig): void {
|
||||
try {
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
||||
// 派发自定义事件,通知 App 更新 locale
|
||||
window.dispatchEvent(new CustomEvent("camtalk-config-changed"));
|
||||
} catch {
|
||||
// localStorage 不可用时静默失败
|
||||
}
|
||||
@@ -50,3 +55,118 @@ export function saveTheme(theme: Theme): void {
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ---- 会话历史存储 ----
|
||||
|
||||
const SESSIONS_KEY = "camtalk:sessions";
|
||||
const SESSION_MSG_PREFIX = "camtalk:session:";
|
||||
|
||||
/** 加载所有会话摘要(按 lastActiveAt 倒序) */
|
||||
export function loadSessionSummaries(): SessionSummary[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSIONS_KEY);
|
||||
if (!raw) return [];
|
||||
return (JSON.parse(raw) as SessionSummary[]).sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存会话摘要列表 */
|
||||
export function saveSessionSummaries(sessions: SessionSummary[]): void {
|
||||
try {
|
||||
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 加载单个会话的消息历史 */
|
||||
export function loadSessionMessages(sessionId: string): ChatMessage[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_MSG_PREFIX + sessionId);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as ChatMessage[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存单个会话的消息历史 */
|
||||
export function saveSessionMessages(sessionId: string, messages: ChatMessage[]): void {
|
||||
try {
|
||||
localStorage.setItem(SESSION_MSG_PREFIX + sessionId, JSON.stringify(messages));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 删除单个会话的消息历史 */
|
||||
export function deleteSessionMessages(sessionId: string): void {
|
||||
try {
|
||||
localStorage.removeItem(SESSION_MSG_PREFIX + sessionId);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ---- Auth Token 存储 ----
|
||||
|
||||
export interface StoredUser {
|
||||
id: string;
|
||||
username: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 保存 access token */
|
||||
export function saveAccessToken(token: string): void {
|
||||
try {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 获取 access token */
|
||||
export function loadAccessToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存 refresh token */
|
||||
export function saveRefreshToken(token: string): void {
|
||||
try {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 获取 refresh token */
|
||||
export function loadRefreshToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存用户信息 */
|
||||
export function saveUser(user: StoredUser): void {
|
||||
try {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 获取用户信息 */
|
||||
export function loadUser(): StoredUser | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as StoredUser;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除所有 auth 数据 */
|
||||
export function clearAuth(): void {
|
||||
try {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export class CamTalkWebSocket {
|
||||
private reconnectAttempt = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private shouldReconnect = true;
|
||||
private token: string | undefined;
|
||||
|
||||
private messageHandlers = new Set<MessageHandler>();
|
||||
private statusHandlers = new Set<StatusHandler>();
|
||||
@@ -45,14 +46,16 @@ export class CamTalkWebSocket {
|
||||
return () => this.statusHandlers.delete(handler);
|
||||
}
|
||||
|
||||
/** 建立连接 */
|
||||
connect(): void {
|
||||
/** 建立连接,可选传入 JWT token 用于认证 */
|
||||
connect(token?: string): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
this.token = token;
|
||||
this.shouldReconnect = true;
|
||||
this.setStatus("connecting");
|
||||
|
||||
const ws = new WebSocket(WS_URL);
|
||||
const url = token ? `${WS_URL}?token=${encodeURIComponent(token)}` : WS_URL;
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
this.reconnectAttempt = 0;
|
||||
@@ -130,7 +133,7 @@ export class CamTalkWebSocket {
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectAttempt++;
|
||||
this.connect();
|
||||
this.connect(this.token);
|
||||
}, totalDelay);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,16 @@ export interface Session {
|
||||
config: SessionConfig;
|
||||
}
|
||||
|
||||
/** 会话摘要(侧边栏列表用) */
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
lastActiveAt: number;
|
||||
messageCount: number;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
// ---- 聊天消息 ----
|
||||
|
||||
export interface ChatMessage {
|
||||
|
||||
Reference in New Issue
Block a user