Files
CamTalk/docs/PLAN_USER_MODULE.md
hhs b57adf153b docs: 补充用户模块 REST API 接口契约
- PLAN_USER_MODULE.md 新增「前端 API 接口参考」章节
- 03-接口文档.md 同步认证接口、对话接口、WebSocket 认证变更
- 新增错误码 USERNAME_TAKEN / INVALID_CREDENTIALS / INVALID_TOKEN / INVALID_INPUT
- 数据模型补充 User / ConversationSummary / StoredMessage 及对应 TypeScript 类型
- 配置结构体补充 AuthConfig(JWTSecret / AccessTTL / RefreshTTL)
2026-06-14 18:05:50 +08:00

40 KiB
Raw Blame History

CamTalk 后端用户模块构建计划

Context

后端 AI 管道STT → LLM → TTS已完成现在需要实现用户系统和对话持久化。目标用户注册登录后,可在对话列表中选择历史对话继续交谈

设计文档:docs/11-持久化与用户系统设计.md(最高依据) 技术选型:docs/04-技术选型.md 第四章 现有后端计划:docs/PLAN_BACKEND.mdAI 管道部分已完成)

当前后端状态

模块 状态
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 新增 AuthConfigJWTSecret, AccessTTL, RefreshTTLStorageConfig 已有 Driver/DSN 字段
1.2 添加配置默认值 internal/config/config.go auth.access_ttl 默认 15auth.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否则跳过纯内存模式

配置扩展示例

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

数据库连接

// 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 接口

// 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 3JWT + 认证服务

目标:实现 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 <token> 提取 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 核心实现

// 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 接口

// 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 结构

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

错误响应格式(统一现有风格):

{
  "code": "USERNAME_TAKEN",
  "message": "username already taken"
}

新增错误码到 internal/errors/codes.go

const (
    CodeUsernameTaken      = "USERNAME_TAKEN"
    CodeInvalidCredentials = "INVALID_CREDENTIALS"
    CodeInvalidToken       = "INVALID_TOKEN"
    CodeInvalidInput       = "INVALID_INPUT"
)

Phase 5Session 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_idtitle 字段;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先用空字符串兼容

接口变更

// 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 实现思路

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 结构

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

权限校验模式(每个端点复用):

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=<message_id> 分页
  • 内存实现中history 是全量存储的,直接按索引切片即可

Phase 7WebSocket 认证集成

目标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=<access>&conversation_id=<uuid>

服务端处理:
  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 消息

对话标题自动生成

// 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.SaveMessagewrite-through
8.4 LoadFromDB 实现 internal/session/memory.go 从 PostgreSQL 读取消息加载到内存 history
8.5 ConversationSummary 查询优化 internal/store/message_pg.go 对话列表的 last_message 和 message_count 通过 SQL 聚合查询

MessageRepository 接口

// 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 模式

// 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_SECRETCAMTALK_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 依赖注入全貌

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

未认证或 token 过期时返回 401 Unauthorized

错误响应格式

所有错误响应统一结构:

interface ApiError {
  code: string;     // 机器可读错误码
  message: string;  // 人类可读描述
}

示例:

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

请求体

interface RegisterRequest {
  username: string;  // 3-64 字符
  password: string;  // 8-72 字符
}

成功响应 201 Created

interface AuthResponse {
  user: {
    id: string;          // UUID
    username: string;
    created_at: string;  // ISO 8601
  };
  access_token: string;   // JWT15 分钟有效
  refresh_token: string;  // JWT7 天有效
}
{
  "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

请求体

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

请求体

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

请求体

interface LogoutRequest {
  refresh_token: string;  // 要废弃的 refresh_token
}

成功响应 204 No Content(无响应体)。

错误响应

状态码 code 场景
401 INVALID_TOKEN access_token 无效或已过期

二、对话接口(/api/conversations

以下所有接口均需认证(Authorization: Bearer <access_token>),省略不重复标注。

2.1 对话列表

GET /api/conversations?page=1&size=20

查询参数

参数 类型 默认值 说明
page int 1 页码,从 1 开始
size int 20 每页条数,最大 50

成功响应 200 OK

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最后活跃时间
}
{
  "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

请求体(可选,全部有默认值):

interface CreateConversationRequest {
  config?: {
    tts_enabled?: boolean;         // 默认 true
    detail_level?: "low" | "high"; // 默认 "low"
    language?: string;             // 默认 "zh-CN"
  };
}

成功响应 201 Created

interface ConversationDetail {
  id: string;
  title: string;
  config: {
    tts_enabled: boolean;
    detail_level: "low" | "high";
    language: string;
  };
  created_at: string;  // ISO 8601
}
{
  "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

请求体

interface UpdateTitleRequest {
  title: string;  // 1-100 字符
}

成功响应 200 OK

{
  "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=<message_id>

查询参数

参数 类型 默认值 说明
limit int 50 返回条数,最大 100
before int64 游标分页:返回此 message_id 之前的消息(不含),用于加载更多

成功响应 200 OK

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
}
{
  "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"}(与不存在相同,避免信息泄露)

连接成功后connected 消息不变,新增 conversation_id 字段标识当前对话:

interface ConnectedMessage {
  type: "connected";
  session_id: string;       // 对话 ID
  conversation_id: string;  // 同 session_id便于前端统一使用
  server_version: string;
}

四、前端调用示例

认证状态管理

// 存储 token建议 localStorage 或内存,视安全需求)
interface AuthTokens {
  accessToken: string;
  refreshToken: string;
}

// 请求拦截器:自动附加 Authorization 头
async function authFetch(url: string, options: RequestInit = {}): Promise<Response> {
  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;
}

注册 + 登录

async function register(username: string, password: string): Promise<AuthResponse> {
  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();
}

获取对话列表

async function getConversations(page = 1, size = 20): Promise<ConversationListResponse> {
  const resp = await authFetch(
    `/api/conversations?page=${page}&size=${size}`
  );
  if (!resp.ok) throw new Error("Failed to load conversations");
  return resp.json();
}

加载对话历史消息

async function getMessages(
  conversationId: string,
  limit = 50,
  before?: number
): Promise<MessagesResponse> {
  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 连接(带认证)

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 5Session 改造)可并行开发
  • Phase 6对话 API和 Phase 7WS 认证)可并行开发

验证方案

层级 方法 覆盖范围
单元测试 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 ./... 全量通过