Merge pull request 'Merge pull request 'feat: 添加 PostgreSQL 服务并挂载数据库迁移脚本'' (#101) from develop into main
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:
2026-06-14 19:25:37 +08:00
69 changed files with 9718 additions and 265 deletions

View File

@@ -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 ManagerMVP 默认内存实现
// 初始化存储层(条件初始化 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{

View File

@@ -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
)

View File

@@ -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=

View 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",
})
}
}

View 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")
}

View 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
}

View 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)
}

View File

@@ -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",

View 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[:])
}

View 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)))
}

View 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()
}
}

View 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))
}

View 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 tokenrotation
_ = 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)
}

View 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)
}

View File

@@ -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 过期时间(分钟),默认 100807天
}
// 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() // 文件不存在不报错

View File

@@ -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。

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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消息会异步写入 PostgreSQLWrite-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,
}
}

View File

@@ -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))
}
}

View File

@@ -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
}

View 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)
}

View 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"`
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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 &copy, 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 &copy, 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
}

View 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
}

View 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)
}
}

View File

@@ -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()

View File

@@ -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")
}

View File

@@ -0,0 +1,5 @@
-- 删除 Refresh Token 表(自动删除相关索引)
DROP TABLE IF EXISTS refresh_tokens;
-- 删除用户表(自动删除相关索引)
DROP TABLE IF EXISTS users;

View 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);

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS messages;

View 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);

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS sessions;

View 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);

View File

@@ -0,0 +1,9 @@
// Package migrations 提供数据库迁移 SQL 文件的嵌入式访问。
package migrations
import "embed"
// FS 包含所有迁移 SQL 文件。
//
//go:embed *.sql
var FS embed.FS

View File

@@ -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

View File

@@ -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; // JWT15 分钟有效
refresh_token: string; // JWT7 天有效
}
```
```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"` // 分钟,默认 100807 天)
}
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` | — | 音频片段 < 250msWS | 忽略,等待下次语音输入 |
| `LLM_TIMEOUT` | — | LLM 推理超时 >10sWS | 提示用户重试 |
| `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 | 请求参数校验失败 | 检查字段规则后重试 |
## 十、连接管理

View File

@@ -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 同样存 localStorage401 时自动触发刷新流程。

View File

@@ -0,0 +1,750 @@
# 持久化与用户系统设计
## 概述
本文档定义用户注册/登录、JWT 认证、对话历史持久化的完整设计方案。核心目标:**用户登录后可在对话列表中选择历史对话继续交谈**。
### 设计决策
| 决策项 | 选择 | 理由 |
|--------|------|------|
| 认证方式 | JWTaccess + 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_tokenDELETE
生成新的 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 签名和过期(不查 DBDB 校验由 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/内存(热) ←→ PostgreSQLwrite-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-throughAppendMessage 同时写 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

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}

View File

@@ -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;

View 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>
);
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {
/** 语音结束回调,携带录音 Float32Array16kHz */
@@ -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);
}

View File

@@ -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;

View 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>
</>
);
}

View File

@@ -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>

View File

@@ -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),
};

View 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,
};
}

View File

@@ -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
View 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
View 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;
}

View File

@@ -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;
}

View 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",
};

View 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);
}

View 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": "現在のユーザー",
};

View 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": "当前用户",
};

View File

@@ -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 */ }
}

View File

@@ -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);
}

View File

@@ -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 {