- 新增 AuthConfig 结构体(JWTSecret, AccessTTL, RefreshTTL) - 在 Config 中添加 Auth 字段 - 设置默认值:access_ttl=15分钟,refresh_ttl=10080分钟(7天) - JWTSecret 必须通过环境变量 CAMTALK_AUTH_JWT_SECRET 设置
21 KiB
21 KiB
持久化与用户系统设计
概述
本文档定义用户注册/登录、JWT 认证、对话历史持久化的完整设计方案。核心目标:用户登录后可在对话列表中选择历史对话继续交谈。
设计决策
| 决策项 | 选择 | 理由 |
|---|---|---|
| 认证方式 | JWT(access + refresh 双 token) | 无状态,适合分布式部署 |
| 注册方式 | 用户名 + 密码 | MVP 最简方案 |
| 密码存储 | bcrypt hash | 行业标准,抗彩虹表 |
| 对话恢复 | 对话列表选择 | 用户可见所有历史对话,自主选择继续或新建 |
| 对话标题 | 自动取首条用户消息前 20 字符 | 零成本,自然可读 |
| 图像持久化 | 不存储 | 节省空间,文字历史已足够 |
| 登录后行为 | 先选对话,再进聊天 | 明确的入口,避免困惑 |
| WS 认证 | URL query 参数 ?token=xxx |
HTTP Upgrade 无法带 Authorization header |
| Token 策略 | access 15min + refresh 7day | 安全性与体验平衡 |
一、数据库设计
1.1 ER 关系
users 1──N sessions 1──N messages
│
└── refresh_tokens (1──N, token 轮转管理)
1.2 表结构
-- 用户表
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(256) NOT NULL, -- bcrypt hash
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_users_username ON users(username);
-- 会话(对话)表
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(128) DEFAULT '新对话',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id, updated_at DESC);
-- 消息表
CREATE TABLE messages (
id BIGSERIAL PRIMARY KEY,
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
role VARCHAR(16) NOT NULL, -- "user" | "assistant"
content TEXT NOT NULL,
tokens_used INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_messages_session_id ON messages(session_id, id);
-- 刷新令牌表
CREATE TABLE refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(256) NOT NULL UNIQUE, -- SHA256(refresh_token)
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);
1.3 与现有设计的差异
| 变更 | 原设计(02-系统架构.md) |
新设计 | 理由 |
|---|---|---|---|
sessions.user_id |
NOT NULL 无外键 |
REFERENCES users(id) ON DELETE CASCADE |
关联用户,级联删除 |
sessions.title |
无 | VARCHAR(128) DEFAULT '新对话' |
对话列表展示 |
messages.image_url |
有 | 移除 | 不存储图像 |
usage_daily |
有 | MVP 暂不实现 | 按需后加 |
新增 users |
无 | 新增 | 用户系统核心 |
新增 refresh_tokens |
无 | 新增 | JWT refresh 机制 |
二、JWT 认证设计
2.1 Token 结构
access_token:
- payload:
{user_id, username, exp (15min), iat, iss: "camtalk"} - 签名算法: HS256(对称密钥,从配置读取)
- 存储位置: 前端 localStorage
refresh_token:
- payload:
{user_id, token_id (UUID), exp (7day), iat, iss: "camtalk"} - 存储位置: 前端 localStorage + 数据库
refresh_tokens表(存 SHA256 hash)
2.2 认证流程
注册
用户 ──POST /api/auth/register──> 检查 username 唯一性
bcrypt hash 密码
INSERT users
↓
生成 access_token + refresh_token
存 SHA256(refresh_token) 到 DB
↓
返回 {user, access_token, refresh_token}
登录
用户 ──POST /api/auth/login──> 查 users 表 by username
bcrypt.CompareHashAndPassword
↓
生成 access_token + refresh_token
存 SHA256(refresh_token) 到 DB
↓
返回 {user, access_token, refresh_token}
刷新
用户 ──POST /api/auth/refresh──> 校验 refresh_token 签名和过期
查 DB 验证 hash 存在
↓
撤销旧 refresh_token(DELETE)
生成新的 access + refresh
存新 refresh_token hash
↓
返回 {access_token, refresh_token}
登出
用户 ──POST /api/auth/logout──> 撤销 refresh_token (DELETE from DB)
前端清除 localStorage
2.3 Go 实现接口
// internal/auth/jwt.go
type Claims struct {
UserID string `json:"user_id"`
Username string `json:"username"`
jwt.RegisteredClaims
}
type TokenManager struct {
secret []byte
accessTTL time.Duration // 15min
refreshTTL time.Duration // 7day
}
// GeneratePair 生成 access + refresh token 对。
func (tm *TokenManager) GeneratePair(userID, username string) (access, refresh string, err error)
// ValidateAccess 校验 access_token,返回 Claims。
func (tm *TokenManager) ValidateAccess(tokenStr string) (*Claims, error)
// ValidateRefresh 校验 refresh_token 签名和过期(不查 DB,DB 校验由 service 层负责)。
func (tm *TokenManager) ValidateRefresh(tokenStr string) (*Claims, error)
// HashToken 计算 token 的 SHA256 hash(用于 DB 存储)。
func HashToken(token string) string
// 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_TAKEN(409)、INVALID_INPUT(400,用户名/密码格式不合规)
登录
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_CREDENTIALS(401)
刷新 Token
POST /api/auth/refresh
Content-Type: application/json
{"refresh_token": "eyJ..."}
响应:
// 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
响应:
// 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/内存(热) ←→ PostgreSQL(冷,write-through)
历史会话加载: PostgreSQL → Redis/内存(按需恢复)
Write-through 保证持久化:每次 AppendMessage 同时写入 PostgreSQL,确保服务重启不丢数据。
历史对话恢复流程:
- 用户从对话列表选择一个历史对话
- 前端带
conversation_id建立 WebSocket 连接 - 后端调用
sessionManager.LoadFromDB(conversationID)将历史消息从 PostgreSQL 加载到 Redis/内存 - 后续对话正常走热存储路径
五、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.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/conversationsCRUD +/api/conversations/:id/messages - Write-through:AppendMessage 同时写 PostgreSQL
Phase 3:对话历史恢复
sessionManager.LoadFromDB()实现- 对话标题自动生成逻辑
- REST API:对话详情、历史消息查询(分页)
Phase 4:前端集成
useAuthhook + 请求拦截器(自动附加 token、自动 refresh)AuthPage组件(登录/注册表单)ConversationList组件useConversationshook- 路由守卫:未登录重定向到
/login - WebSocket 连接带 token + conversation_id
useVisionSession适配多对话切换
Phase 5:配置与收尾
- 配置结构体扩展(AuthConfig)
- config.yaml 更新
- docker-compose 添加 PostgreSQL
- 集成测试
- 更新
02-系统架构.md和03-接口文档.md