Files
CamTalk/docs/11-持久化与用户系统设计.md
hhs 4b30e67c2e feat: 扩展 Config 结构体,新增 AuthConfig 配置
- 新增 AuthConfig 结构体(JWTSecret, AccessTTL, RefreshTTL)
- 在 Config 中添加 Auth 字段
- 设置默认值:access_ttl=15分钟,refresh_ttl=10080分钟(7天)
- JWTSecret 必须通过环境变量 CAMTALK_AUTH_JWT_SECRET 设置
2026-06-14 16:40:06 +08:00

21 KiB
Raw Blame History

持久化与用户系统设计

概述

本文档定义用户注册/登录、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 表结构

-- 用户表
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 实现接口

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

响应:

// 201 Created
{
  "user": {"id": "uuid", "username": "alice", "created_at": "2026-06-14T10:00:00Z"},
  "access_token": "eyJ...",
  "refresh_token": "eyJ..."
}

错误码:USERNAME_TAKEN409INVALID_INPUT400用户名/密码格式不合规)

登录

POST /api/auth/login
Content-Type: application/json

{"username": "alice", "password": "s3cret123"}

响应:

// 200 OK
{
  "user": {"id": "uuid", "username": "alice"},
  "access_token": "eyJ...",
  "refresh_token": "eyJ..."
}

错误码:INVALID_CREDENTIALS401

刷新 Token

POST /api/auth/refresh
Content-Type: application/json

{"refresh_token": "eyJ..."}

响应:

// 200 OK
{
  "access_token": "eyJ...",
  "refresh_token": "eyJ..."
}

错误码:INVALID_TOKEN401

登出

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

响应:

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

{}

响应:

// 201 Created
{
  "id": "uuid",
  "title": "新对话",
  "created_at": "2026-06-14T10:00:00Z"
}

获取对话详情

GET /api/conversations/:id

响应:

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

响应:

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

// 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 变更

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

// 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 前端连接方式

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

// 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 对话标题自动生成

// 内部逻辑:首条 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 配置结构体

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 配置文件示例

# 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.goUserRepository 接口 + PostgreSQL 实现
  • REST API/api/auth/register, /api/auth/login, /api/auth/refresh, /api/auth/logout
  • 单元测试

Phase 2对话 CRUD + 消息持久化

  • 数据库 schema 迁移脚本sessions, messages 表改造)
  • internal/store/conversation.goConversationRepository 接口 + 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-系统架构.md03-接口文档.md