# CamTalk 后端用户模块构建计划 ## Context 后端 AI 管道(STT → LLM → TTS)已完成,现在需要实现用户系统和对话持久化。目标:**用户注册登录后,可在对话列表中选择历史对话继续交谈**。 设计文档:`docs/11-持久化与用户系统设计.md`(最高依据) 技术选型:`docs/04-技术选型.md` 第四章 现有后端计划:`docs/PLAN_BACKEND.md`(AI 管道部分已完成) ### 当前后端状态 | 模块 | 状态 | |------|------| | config | ✅ 已完成,需扩展 AuthConfig | | logger | ✅ 已完成 | | errors | ✅ 已完成,需扩展用户相关错误码 | | models | ✅ 已完成,需扩展 User/Session | | session manager | ✅ 内存/Redis 已完成,需扩展 user_id 绑定 | | AI 服务层 | ✅ STT/LLM/TTS 已完成 | | orchestrator | ✅ 已完成 | | ws handler | ✅ 已完成,需接入 JWT 认证 | | REST API | ✅ sessions CRUD 已完成,需新增 auth + conversations | ### 新增依赖 | 包 | 用途 | 引入阶段 | |----|------|---------| | `github.com/golang-jwt/jwt/v5` | JWT 签发/校验 | Phase 1 | | `golang.org/x/crypto/bcrypt` | 密码哈希 | Phase 1 | | `github.com/jackc/pgx/v5` | PostgreSQL 驱动 | Phase 2 | --- ## 分阶段实施 ### Phase 1:配置扩展 + 数据库连接 **目标**:扩展配置结构体,建立 PostgreSQL 连接池。 | # | 任务 | 文件 | 说明 | |---|------|------|------| | 1.1 | 扩展 Config 结构体 | `internal/config/config.go` | 新增 `AuthConfig`(JWTSecret, AccessTTL, RefreshTTL),`StorageConfig` 已有 Driver/DSN 字段 | | 1.2 | 添加配置默认值 | `internal/config/config.go` | `auth.access_ttl` 默认 15,`auth.refresh_ttl` 默认 10080 | | 1.3 | 实现数据库连接池 | `internal/store/db.go` | `NewPostgresPool(ctx, dsn) (*pgxpool.Pool, error)`,启动时调用,注入到各 repository | | 1.4 | 编写 schema 迁移脚本 | `migrations/001_users.up.sql` | `users` 表 + `refresh_tokens` 表 | | 1.5 | 编写回滚脚本 | `migrations/001_users.down.sql` | DROP TABLE | | 1.6 | main.go 条件初始化 DB | `cmd/server/main.go` | `storage.driver == "postgres"` 时创建 pgxpool,否则跳过(纯内存模式) | **配置扩展示例**: ```go // internal/config/config.go 新增 type AuthConfig struct { JWTSecret string `mapstructure:"jwt_secret"` // 必须通过 CAMTALK_AUTH_JWT_SECRET 设置 AccessTTL int `mapstructure:"access_ttl"` // 分钟,默认 15 RefreshTTL int `mapstructure:"refresh_ttl"` // 分钟,默认 10080 } ``` **数据库连接**: ```go // internal/store/db.go package store import ( "context" "github.com/jackc/pgx/v5/pgxpool" ) 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) } ``` --- ### Phase 2:用户模型 + Repository **目标**:定义用户数据模型和持久化接口。 | # | 任务 | 文件 | 说明 | |---|------|------|------| | 2.1 | 扩展 models | `internal/models/models.go` | 新增 `User` 结构体(ID, Username, PasswordHash, CreatedAt, UpdatedAt) | | 2.2 | 定义 UserRepository 接口 | `internal/store/user.go` | `Create`, `FindByUsername`, `FindByID`, `SaveRefreshToken`, `FindRefreshToken`, `DeleteRefreshToken` | | 2.3 | 实现 PostgreSQL UserRepository | `internal/store/user_pg.go` | pgx 实现,所有方法使用 `pgxpool.Pool` | | 2.4 | 实现内存 UserRepository(测试用) | `internal/store/user_mem.go` | `sync.RWMutex` + map,单元测试时注入 | | 2.5 | 编写 UserRepository 测试 | `internal/store/user_pg_test.go` | 需要测试 DB 或 mock | **UserRepository 接口**: ```go // internal/store/user.go 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") ) 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 } ``` --- ### Phase 3:JWT + 认证服务 **目标**:实现 JWT 签发/校验、bcrypt 密码处理、认证业务逻辑。 | # | 任务 | 文件 | 说明 | |---|------|------|------| | 3.1 | 实现 TokenManager | `internal/auth/jwt.go` | `GeneratePair`, `ValidateAccess`, `ValidateRefresh`, `HashToken` | | 3.2 | 实现密码工具 | `internal/auth/password.go` | `HashPassword(password) (string, error)`, `CheckPassword(hash, password) error` | | 3.3 | 实现 AuthMiddleware | `internal/auth/middleware.go` | Gin 中间件,从 `Authorization: Bearer ` 提取 Claims 写入 Context | | 3.4 | 定义 AuthService 接口 | `internal/auth/service.go` | 业务层封装:`Register`, `Login`, `Refresh`, `Logout` | | 3.5 | 实现 AuthService | `internal/auth/service.go` | 组合 TokenManager + UserRepository | | 3.6 | 编写 TokenManager 测试 | `internal/auth/jwt_test.go` | 生成/校验/过期/hash | | 3.7 | 编写 AuthService 测试 | `internal/auth/service_test.go` | mock UserRepository,覆盖注册重复、密码错误、token 轮转 | **TokenManager 核心实现**: ```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 refreshTTL time.Duration } func NewTokenManager(secret string, accessTTL, refreshTTL time.Duration) *TokenManager { return &TokenManager{ secret: []byte(secret), accessTTL: accessTTL, refreshTTL: refreshTTL, } } func (tm *TokenManager) GeneratePair(userID, username string) (access, refresh string, err error) { // access_token: 15min accessClaims := &Claims{ UserID: userID, Username: username, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(tm.accessTTL)), IssuedAt: jwt.NewNumericDate(time.Now()), Issuer: "camtalk", }, } accessTkn := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) access, err = accessTkn.SignedString(tm.secret) if err != nil { return "", "", err } // refresh_token: 7day, 含唯一 token_id tokenID := uuid.New().String() refreshClaims := &Claims{ UserID: userID, Username: username, RegisteredClaims: jwt.RegisteredClaims{ ID: tokenID, ExpiresAt: jwt.NewNumericDate(time.Now().Add(tm.refreshTTL)), IssuedAt: jwt.NewNumericDate(time.Now()), Issuer: "camtalk", }, } refreshTkn := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) refresh, err = refreshTkn.SignedString(tm.secret) return } func (tm *TokenManager) ValidateAccess(tokenStr string) (*Claims, error) { return tm.validate(tokenStr) } func (tm *TokenManager) ValidateRefresh(tokenStr string) (*Claims, error) { return tm.validate(tokenStr) } func (tm *TokenManager) validate(tokenStr string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { return tm.secret, nil }) if err != nil { return nil, err } claims, ok := token.Claims.(*Claims) if !ok || !token.Valid { return nil, jwt.ErrTokenInvalidClaims } return claims, nil } // HashToken SHA256 hash,用于 DB 存储。 func HashToken(token string) string { h := sha256.Sum256([]byte(token)) return hex.EncodeToString(h[:]) } ``` **AuthService 接口**: ```go // internal/auth/service.go type RegisterRequest struct { Username string `json:"username"` Password string `json:"password"` } type LoginRequest struct { Username string `json:"username"` Password string `json:"password"` } type RefreshRequest struct { RefreshToken string `json:"refresh_token"` } type AuthResponse struct { User UserResponse `json:"user"` AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` } type UserResponse struct { ID string `json:"id"` Username string `json:"username"` CreatedAt time.Time `json:"created_at"` } 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 } ``` --- ### Phase 4:认证 REST API **目标**:实现注册、登录、刷新、登出四个端点。 | # | 任务 | 文件 | 说明 | |---|------|------|------| | 4.1 | 实现 AuthHandler | `internal/api/auth.go` | `Register`, `Login`, `Refresh`, `Logout` 处理函数 | | 4.2 | 输入校验 | `internal/api/auth.go` | 用户名 3-64 字符,密码 8-72 字符 | | 4.3 | 注册路由 | `internal/api/auth.go` | `RegisterRoutes(rg *gin.RouterGroup)` | | 4.4 | main.go 接入 | `cmd/server/main.go` | 创建 TokenManager + AuthService + AuthHandler,注册路由 | | 4.5 | 编写 API 测试 | `internal/api/auth_test.go` | httptest + mock AuthService | **AuthHandler 结构**: ```go // internal/api/auth.go type AuthHandler struct { authService auth.Service } func NewAuthHandler(authService auth.Service) *AuthHandler { return &AuthHandler{authService: authService} } func (h *AuthHandler) RegisterRoutes(rg *gin.RouterGroup) { auth := rg.Group("/auth") { auth.POST("/register", h.Register) auth.POST("/login", h.Login) auth.POST("/refresh", h.Refresh) auth.POST("/logout", auth.AuthMiddleware(), h.Logout) } } ``` **错误响应格式**(统一现有风格): ```json { "code": "USERNAME_TAKEN", "message": "username already taken" } ``` 新增错误码到 `internal/errors/codes.go`: ```go const ( CodeUsernameTaken = "USERNAME_TAKEN" CodeInvalidCredentials = "INVALID_CREDENTIALS" CodeInvalidToken = "INVALID_TOKEN" CodeInvalidInput = "INVALID_INPUT" ) ``` --- ### Phase 5:Session Manager 改造 **目标**:Session Manager 关联 user_id,支持对话列表查询。 | # | 任务 | 文件 | 说明 | |---|------|------|------| | 5.1 | 扩展 Session 模型 | `internal/models/models.go` | `Session` 新增 `UserID`, `Title`, `UpdatedAt` 字段 | | 5.2 | 扩展 Manager 接口 | `internal/session/manager.go` | `Create` 签名加 `userID`;新增 `ListByUser`, `UpdateTitle`;新增 `ConversationSummary` 类型 | | 5.3 | 修改 MemoryManager | `internal/session/memory.go` | `Create` 存储 userID;实现 `ListByUser`(遍历+过滤+排序);实现 `UpdateTitle` | | 5.4 | 修改 RedisManager | `internal/session/redis.go` | `session:{id}:meta` 新增 `user_id`、`title` 字段;`ListByUser` 使用 Redis Set `user:{id}:sessions` 索引 | | 5.5 | 编写新方法测试 | `internal/session/memory_test.go` | 覆盖 ListByUser 分页、UpdateTitle、Create 带 userID | | 5.6 | 更新 ws handler 调用 | `internal/ws/handler.go` | `sessionMgr.Create` 调用传入 userID(此时 Phase 7 才有真实 userID,先用空字符串兼容) | **接口变更**: ```go // internal/session/manager.go 变更 type Manager interface { // Create 签名变更:新增 userID 参数 Create(ctx context.Context, userID string, config models.SessionConfig) (string, error) // 新增方法 ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error) UpdateTitle(ctx context.Context, sessionID string, title string) error // 其余方法不变... } 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"` } ``` **MemoryManager ListByUser 实现思路**: ```go func (m *MemoryManager) ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error) { m.mu.RLock() defer m.mu.RUnlock() // 收集该用户的所有 session var list []ConversationSummary for _, entry := range m.sessions { if entry.session.UserID != userID { continue } summary := ConversationSummary{ ID: entry.session.ID, Title: entry.session.Title, MessageCount: len(entry.history), UpdatedAt: entry.lastActive, } if len(entry.history) > 0 { summary.LastMessage = entry.history[len(entry.history)-1].Content } list = append(list, summary) } // 按 UpdatedAt 降序排序 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 } ``` --- ### Phase 6:对话 REST API **目标**:实现对话 CRUD 和历史消息查询端点。 | # | 任务 | 文件 | 说明 | |---|------|------|------| | 6.1 | 实现 ConversationHandler | `internal/api/conversation.go` | `List`, `Create`, `Get`, `UpdateTitle`, `Delete`, `GetMessages` | | 6.2 | 权限校验 | `internal/api/conversation.go` | 每个端点校验 session.UserID == claims.UserID | | 6.3 | 注册路由 | `internal/api/conversation.go` | `RegisterRoutes(rg *gin.RouterGroup)`,全部走 AuthMiddleware | | 6.4 | main.go 接入 | `cmd/server/main.go` | 创建 ConversationHandler 并注册 | | 6.5 | 编写 API 测试 | `internal/api/conversation_test.go` | httptest + mock SessionManager | **ConversationHandler 结构**: ```go // internal/api/conversation.go type ConversationHandler struct { sessionMgr session.Manager } func (h *ConversationHandler) RegisterRoutes(rg *gin.RouterGroup) { conv := rg.Group("/conversations", auth.AuthMiddleware(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) } } ``` **权限校验模式**(每个端点复用): ```go func (h *ConversationHandler) getSessionForUser(c *gin.Context, sessionID string) (*models.Session, error) { sess, err := h.sessionMgr.Get(c.Request.Context(), sessionID) if err != nil { return nil, err } userID := c.GetString("user_id") // 从 AuthMiddleware 写入 if sess.UserID != userID { return nil, session.ErrSessionNotFound // 返回 404 而非 403,避免信息泄露 } return sess, nil } ``` **GetMessages 实现要点**: - 从 Session Manager 的 `GetHistory` 获取消息 - 支持 `?limit=50&before=` 分页 - 内存实现中,history 是全量存储的,直接按索引切片即可 --- ### Phase 7:WebSocket 认证集成 **目标**:WS 连接需要 JWT 认证,支持指定 conversation_id 恢复历史对话。 | # | 任务 | 文件 | 说明 | |---|------|------|------| | 7.1 | 修改 ServeWS 签名 | `internal/ws/handler.go` | 新增 `tokenMgr *auth.TokenManager` 参数 | | 7.2 | WS 连接认证 | `internal/ws/handler.go` | 从 `?token=xxx` 提取并校验 access_token,失败返回 401 | | 7.3 | conversation_id 处理 | `internal/ws/handler.go` | `?conversation_id=xxx` 存在时:校验归属 → LoadFromDB → 复用 session;否则创建新 session | | 7.4 | AppendMessage 自动标题 | `internal/session/memory.go` | 首条 user 消息时,如果 title == "新对话",自动更新为前 20 字符 | | 7.5 | main.go 更新 | `cmd/server/main.go` | 传入 tokenMgr 到 ServeWS | | 7.7 | 编写认证测试 | `internal/ws/handler_test.go` | 测试无 token / 过期 token / 有效 token / conversation_id 恢复 | **WS 连接流程变更**: ``` 客户端请求: GET /ws?token=&conversation_id= 服务端处理: 1. token 为空 → 401 {"error": "missing token"} 2. token 无效/过期 → 401 {"error": "invalid token"} 3. conversation_id 非空: a. session 不存在或 user_id 不匹配 → 401 {"error": "SESSION_NOT_FOUND"} b. sessionMgr.LoadFromDB(conversationID) → 加载历史到热存储 c. sessionID = conversationID 4. conversation_id 为空: a. sessionMgr.Create(userID, defaultConfig) → 创建新 session 5. Upgrade WebSocket → 发送 connected 消息 ``` **对话标题自动生成**: ```go // internal/session/memory.go — AppendMessage 中追加逻辑 func (m *MemoryManager) AppendMessage(ctx context.Context, sessionID string, msg models.Message) error { m.mu.Lock() defer m.mu.Unlock() entry, ok := m.sessions[sessionID] if !ok { return ErrSessionNotFound } entry.history = append(entry.history, msg) entry.lastActive = time.Now() // 自动更新标题 if msg.Role == "user" && entry.session.Title == "新对话" { entry.session.Title = generateTitle(msg.Content) } // 限制历史上限 if len(entry.history) > m.maxHistory { entry.history = entry.history[len(entry.history)-m.maxHistory:] } return nil } func generateTitle(firstMessage string) string { runes := []rune(firstMessage) if len(runes) > 20 { return string(runes[:20]) + "…" } return firstMessage } ``` --- ### Phase 8:消息持久化(Write-Through) **目标**:对话消息同时写入 PostgreSQL,保证重启不丢数据。 | # | 任务 | 文件 | 说明 | |---|------|------|------| | 8.1 | 定义 MessageRepository 接口 | `internal/store/message.go` | `SaveMessage`, `GetMessages`, `GetLastMessage` | | 8.2 | 实现 PostgreSQL MessageRepository | `internal/store/message_pg.go` | pgx 实现 | | 8.3 | Session Manager 注入 MessageRepository | `internal/session/memory.go` | AppendMessage 时同时调用 repo.SaveMessage(write-through) | | 8.4 | LoadFromDB 实现 | `internal/session/memory.go` | 从 PostgreSQL 读取消息加载到内存 history | | 8.5 | ConversationSummary 查询优化 | `internal/store/message_pg.go` | 对话列表的 last_message 和 message_count 通过 SQL 聚合查询 | **MessageRepository 接口**: ```go // internal/store/message.go type MessageRepository interface { // SaveMessage 保存一条消息。 SaveMessage(ctx context.Context, sessionID string, msg models.Message, tokensUsed int) error // GetMessages 获取会话的消息列表(分页,按 id 升序)。 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) } 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"` } ``` **Write-Through 模式**: ```go // internal/session/memory.go — AppendMessage 改造 func (m *MemoryManager) AppendMessage(ctx context.Context, sessionID string, msg models.Message) error { // 1. 写热存储(内存/Redis) m.mu.Lock() entry, ok := m.sessions[sessionID] if !ok { m.mu.Unlock() return ErrSessionNotFound } entry.history = append(entry.history, msg) entry.lastActive = time.Now() if msg.Role == "user" && entry.session.Title == "新对话" { entry.session.Title = generateTitle(msg.Content) } if len(entry.history) > m.maxHistory { entry.history = entry.history[len(entry.history)-m.maxHistory:] } m.mu.Unlock() // 2. 写冷存储(PostgreSQL,异步不阻塞) 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 } ``` --- ### Phase 9:旧端点废弃 + 集成收尾 **目标**:废弃旧的 `/api/sessions` 端点,完成全链路集成。 | # | 任务 | 文件 | 说明 | |---|------|------|------| | 9.1 | 废弃旧 session 路由 | `internal/api/session.go` | 保留代码但标记 deprecated,或直接删除 | | 9.2 | main.go 完整组装 | `cmd/server/main.go` | 按 storage.driver 选择注入 MemoryRepo 或 PgRepo | | 9.3 | .env.example 更新 | `backend/.env.example` | 新增 `CAMTALK_AUTH_JWT_SECRET`、`CAMTALK_STORAGE_*` | | 9.4 | config.yaml 更新 | `backend/config.yaml` | 新增 auth 配置块 | | 9.5 | docker-compose 添加 PG | `docker-compose.yml` | PostgreSQL 15 服务 + 环境变量 | | 9.6 | go mod tidy | `backend/` | 清理依赖 | | 9.7 | 端到端手动测试 | — | 注册 → 登录 → 创建对话 → 发送消息 → 登出 → 重新登录 → 查看对话列表 → 选择历史对话继续 | **main.go 依赖注入全貌**: ```go func main() { cfg, _ := config.Load() logger.Init(cfg.Log.Level, cfg.Log.Format) // --- 存储层 --- var ( userRepo store.UserRepository msgRepo store.MessageRepository sessionMgr session.Manager ) if cfg.Storage.Driver == "postgres" { pool, _ := store.NewPostgresPool(ctx, cfg.Storage.DSN) defer pool.Close() userRepo = store.NewPgUserRepository(pool) msgRepo = store.NewPgMessageRepository(pool) sessionMgr = session.NewMemoryManager(..., msgRepo) // 注入 msgRepo } else { userRepo = store.NewMemUserRepository() sessionMgr = session.NewMemoryManager(...) // 无 msgRepo,纯内存 } // --- 认证 --- 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) // --- AI 服务(不变)--- sttService := ... llmService := ... ttsService := ... orch := orchestrator.New(sttService, llmService, ttsService, sessionMgr, cfg) // --- 路由 --- r := gin.New() apiGroup := r.Group("/api") apiGroup.GET("/health", healthHandler(sessionMgr, cfg)) authHandler := api.NewAuthHandler(authService) authHandler.RegisterRoutes(apiGroup) convHandler := api.NewConversationHandler(sessionMgr, tokenMgr) convHandler.RegisterRoutes(apiGroup) r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr)) // ... 启动 } ``` --- ## 前端 API 接口参考 本章节为前端开发者提供完整的 REST API 契约。所有接口以 JSON 通信,基地址与 WebSocket 同源(开发环境 `http://localhost:8080`,生产环境通过 Nginx 反代)。 ### 通用约定 #### 认证方式 需要认证的接口在请求头携带 JWT access token: ``` Authorization: Bearer ``` 未认证或 token 过期时返回 `401 Unauthorized`。 #### 错误响应格式 所有错误响应统一结构: ```typescript interface ApiError { code: string; // 机器可读错误码 message: string; // 人类可读描述 } ``` 示例: ```json { "code": "USERNAME_TAKEN", "message": "username already taken" } ``` #### 新增错误码 | 错误码 | HTTP 状态码 | 含义 | |--------|-----------|------| | `USERNAME_TAKEN` | 409 | 用户名已被注册 | | `INVALID_CREDENTIALS` | 401 | 用户名或密码错误 | | `INVALID_TOKEN` | 401 | JWT 无效或已过期 | | `INVALID_INPUT` | 400 | 请求参数校验失败 | | `SESSION_NOT_FOUND` | 404 | 对话不存在或无权访问 | #### 输入校验规则 | 字段 | 规则 | |------|------| | `username` | 3-64 字符,仅允许字母、数字、下划线 | | `password` | 8-72 字符 | --- ### 一、认证接口(`/api/auth`) #### 1.1 注册 ``` POST /api/auth/register Content-Type: application/json ``` **请求体**: ```typescript interface RegisterRequest { username: string; // 3-64 字符 password: string; // 8-72 字符 } ``` **成功响应** `201 Created`: ```typescript interface AuthResponse { user: { id: string; // UUID username: string; created_at: string; // ISO 8601 }; access_token: string; // JWT,15 分钟有效 refresh_token: string; // JWT,7 天有效 } ``` ```json { "user": { "id": "550e8400-e29b-41d4-a716-446655440000", "username": "alice", "created_at": "2026-06-14T10:00:00Z" }, "access_token": "eyJhbGciOiJIUzI1NiIs...", "refresh_token": "eyJhbGciOiJIUzI1NiIs..." } ``` **错误响应**: | 状态码 | code | 场景 | |--------|------|------| | 400 | `INVALID_INPUT` | 用户名/密码不符合校验规则 | | 409 | `USERNAME_TAKEN` | 用户名已存在 | --- #### 1.2 登录 ``` 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` | 用户名或密码错误 | --- #### 1.3 刷新 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 无效或已过期 | --- #### 1.4 登出 ``` POST /api/auth/logout Content-Type: application/json Authorization: Bearer ``` **请求体**: ```typescript interface LogoutRequest { refresh_token: string; // 要废弃的 refresh_token } ``` **成功响应** `204 No Content`(无响应体)。 **错误响应**: | 状态码 | code | 场景 | |--------|------|------| | 401 | `INVALID_TOKEN` | access_token 无效或已过期 | --- ### 二、对话接口(`/api/conversations`) > 以下所有接口均需认证(`Authorization: Bearer `),省略不重复标注。 #### 2.1 对话列表 ``` 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 过期 | --- #### 2.2 创建对话 ``` 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 过期 | --- #### 2.3 获取对话详情 ``` GET /api/conversations/:id ``` **成功响应** `200 OK`:同 `ConversationDetail` 结构。 **错误响应**: | 状态码 | code | 场景 | |--------|------|------| | 401 | `INVALID_TOKEN` | 未认证或 token 过期 | | 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 | --- #### 2.4 更新对话标题 ``` 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` | 对话不存在或不属于当前用户 | --- #### 2.5 删除对话 ``` DELETE /api/conversations/:id ``` **成功响应** `204 No Content`(无响应体)。 **错误响应**: | 状态码 | code | 场景 | |--------|------|------| | 401 | `INVALID_TOKEN` | 未认证或 token 过期 | | 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 | --- #### 2.6 获取对话消息 ``` GET /api/conversations/:id/messages?limit=50&before= ``` **查询参数**: | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `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=&conversation_id= ``` | 参数 | 必填 | 说明 | |------|------|------| | `token` | 是 | JWT access_token | | `conversation_id` | 否 | 恢复已有对话;省略则创建新对话 | **认证失败响应**(HTTP 升级前返回): | 状态码 | 场景 | |--------|------| | 401 | token 缺失、无效或已过期 | **conversation_id 校验失败**: | 场景 | 处理 | |------|------| | 对话不存在 | 返回 401,`{"error": "SESSION_NOT_FOUND"}` | | 对话不属于当前用户 | 返回 401,`{"error": "SESSION_NOT_FOUND"}`(与不存在相同,避免信息泄露) | **连接成功后**:`connected` 消息不变,新增 `conversation_id` 字段标识当前对话: ```typescript interface ConnectedMessage { type: "connected"; session_id: string; // 对话 ID conversation_id: string; // 同 session_id,便于前端统一使用 server_version: string; } ``` --- ### 四、前端调用示例 #### 认证状态管理 ```typescript // 存储 token(建议 localStorage 或内存,视安全需求) interface AuthTokens { accessToken: string; refreshToken: string; } // 请求拦截器:自动附加 Authorization 头 async function authFetch(url: string, options: RequestInit = {}): Promise { const tokens = getStoredTokens(); const headers = { ...options.headers, "Authorization": `Bearer ${tokens.accessToken}`, }; let resp = await fetch(url, { ...options, headers }); // 401 时尝试刷新 token if (resp.status === 401 && tokens.refreshToken) { const refreshResp = await fetch("/api/auth/refresh", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: tokens.refreshToken }), }); if (refreshResp.ok) { const newTokens: AuthResponse = await refreshResp.json(); storeTokens({ accessToken: newTokens.access_token, refreshToken: newTokens.refresh_token, }); // 用新 token 重试原请求 headers["Authorization"] = `Bearer ${newTokens.access_token}`; resp = await fetch(url, { ...options, headers }); } else { // refresh 也失败,跳转登录 redirectToLogin(); } } return resp; } ``` #### 注册 + 登录 ```typescript async function register(username: string, password: string): Promise { const resp = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); if (!resp.ok) { const err: ApiError = await resp.json(); throw new Error(err.message); // "username already taken" 等 } return resp.json(); } ``` #### 获取对话列表 ```typescript async function getConversations(page = 1, size = 20): Promise { const resp = await authFetch( `/api/conversations?page=${page}&size=${size}` ); if (!resp.ok) throw new Error("Failed to load conversations"); return resp.json(); } ``` #### 加载对话历史消息 ```typescript async function getMessages( conversationId: string, limit = 50, before?: number ): Promise { let url = `/api/conversations/${conversationId}/messages?limit=${limit}`; if (before !== undefined) url += `&before=${before}`; const resp = await authFetch(url); if (!resp.ok) throw new Error("Failed to load messages"); return resp.json(); } ``` #### 建立 WebSocket 连接(带认证) ```typescript function connectWebSocket(accessToken: string, conversationId?: string): WebSocket { let url = `/ws?token=${encodeURIComponent(accessToken)}`; if (conversationId) { url += `&conversation_id=${encodeURIComponent(conversationId)}`; } return new WebSocket(url); } ``` --- ## 关键文件清单 ``` backend/ cmd/server/main.go ← Phase 1.6, 4.4, 7.5, 9.2 migrations/ 001_users.up.sql ← Phase 1.4(新建) 001_users.down.sql ← Phase 1.5(新建) internal/ config/config.go ← Phase 1.1, 1.2(修改) errors/codes.go ← Phase 4.2(修改,新增错误码) models/models.go ← Phase 5.1(修改) store/ db.go ← Phase 1.3(新建) user.go ← Phase 2.2(新建) user_pg.go ← Phase 2.3(新建) user_mem.go ← Phase 2.4(新建) user_pg_test.go ← Phase 2.5(新建) message.go ← Phase 8.1(新建) message_pg.go ← Phase 8.2(新建) auth/ jwt.go ← Phase 3.1(新建) password.go ← Phase 3.2(新建) middleware.go ← Phase 3.3(新建) service.go ← Phase 3.4, 3.5(新建) jwt_test.go ← Phase 3.6(新建) service_test.go ← Phase 3.7(新建) session/ manager.go ← Phase 5.2(修改) memory.go ← Phase 5.3, 7.4, 8.3, 8.4(修改) redis.go ← Phase 5.4(修改) memory_test.go ← Phase 5.5(修改) api/ auth.go ← Phase 4.1, 4.3(新建) auth_test.go ← Phase 4.5(新建) conversation.go ← Phase 6.1, 6.2, 6.3(新建) conversation_test.go ← Phase 6.5(新建) session.go ← Phase 9.1(废弃/删除) ws/ handler.go ← Phase 7.1, 7.2, 7.3(修改) handler_test.go ← Phase 7.7(修改) ``` --- ## 执行顺序与依赖关系 ``` Phase 1 (配置 + DB 连接) ↓ Phase 2 (User 模型 + Repository) ← 依赖 Phase 1 ↓ Phase 3 (JWT + AuthService) ← 依赖 Phase 2 ↓ Phase 4 (Auth REST API) ← 依赖 Phase 3 ↓ Phase 5 (Session Manager 改造) ← 依赖 Phase 1(模型扩展),可与 Phase 2-4 并行 ↓ Phase 6 (Conversation REST API) ← 依赖 Phase 4 + 5 ↓ Phase 7 (WS 认证集成) ← 依赖 Phase 3 + 5 ↓ Phase 8 (消息持久化) ← 依赖 Phase 1 + 5 ↓ Phase 9 (废弃旧端点 + 集成收尾) ← 依赖全部 ``` **可并行的路径**: - Phase 2-4(用户认证链路)和 Phase 5(Session 改造)可并行开发 - Phase 6(对话 API)和 Phase 7(WS 认证)可并行开发 --- ## 验证方案 | 层级 | 方法 | 覆盖范围 | |------|------|---------| | 单元测试 | `go test ./internal/auth/... ./internal/store/...` | JWT 生成/校验、密码 hash、Repository CRUD | | API 测试 | `httptest` + `go test ./internal/api/...` | 注册/登录/刷新/登出、对话 CRUD、权限校验 | | 集成测试 | 启动 Gin test server + WS client | WS 认证、conversation_id 恢复、消息持久化 | | 端到端 | 手动测试 | 注册 → 登录 → 对话 → 登出 → 重登 → 历史列表 → 继续对话 | | 静态检查 | `go vet ./...` + `go test ./...` | 全量通过 |