751 lines
21 KiB
Markdown
751 lines
21 KiB
Markdown
|
|
# 持久化与用户系统设计
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
本文档定义用户注册/登录、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 表结构
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- 用户表
|
|||
|
|
CREATE TABLE users (
|
|||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|||
|
|
username VARCHAR(64) NOT NULL UNIQUE,
|
|||
|
|
password_hash VARCHAR(256) NOT NULL, -- bcrypt hash
|
|||
|
|
created_at TIMESTAMPTZ DEFAULT now(),
|
|||
|
|
updated_at TIMESTAMPTZ DEFAULT now()
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
CREATE INDEX idx_users_username ON users(username);
|
|||
|
|
|
|||
|
|
-- 会话(对话)表
|
|||
|
|
CREATE TABLE sessions (
|
|||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|||
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|||
|
|
title VARCHAR(128) DEFAULT '新对话',
|
|||
|
|
created_at TIMESTAMPTZ DEFAULT now(),
|
|||
|
|
updated_at TIMESTAMPTZ DEFAULT now()
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
CREATE INDEX idx_sessions_user_id ON sessions(user_id, updated_at DESC);
|
|||
|
|
|
|||
|
|
-- 消息表
|
|||
|
|
CREATE TABLE messages (
|
|||
|
|
id BIGSERIAL PRIMARY KEY,
|
|||
|
|
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|||
|
|
role VARCHAR(16) NOT NULL, -- "user" | "assistant"
|
|||
|
|
content TEXT NOT NULL,
|
|||
|
|
tokens_used INTEGER DEFAULT 0,
|
|||
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
CREATE INDEX idx_messages_session_id ON messages(session_id, id);
|
|||
|
|
|
|||
|
|
-- 刷新令牌表
|
|||
|
|
CREATE TABLE refresh_tokens (
|
|||
|
|
id BIGSERIAL PRIMARY KEY,
|
|||
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|||
|
|
token_hash VARCHAR(256) NOT NULL UNIQUE, -- SHA256(refresh_token)
|
|||
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|||
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
|
|||
|
|
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.3 与现有设计的差异
|
|||
|
|
|
|||
|
|
| 变更 | 原设计(`02-系统架构.md`) | 新设计 | 理由 |
|
|||
|
|
|------|--------------------------|--------|------|
|
|||
|
|
| `sessions.user_id` | `NOT NULL` 无外键 | `REFERENCES users(id) ON DELETE CASCADE` | 关联用户,级联删除 |
|
|||
|
|
| `sessions.title` | 无 | `VARCHAR(128) DEFAULT '新对话'` | 对话列表展示 |
|
|||
|
|
| `messages.image_url` | 有 | 移除 | 不存储图像 |
|
|||
|
|
| `usage_daily` | 有 | MVP 暂不实现 | 按需后加 |
|
|||
|
|
| 新增 `users` | 无 | 新增 | 用户系统核心 |
|
|||
|
|
| 新增 `refresh_tokens` | 无 | 新增 | JWT refresh 机制 |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 二、JWT 认证设计
|
|||
|
|
|
|||
|
|
### 2.1 Token 结构
|
|||
|
|
|
|||
|
|
**access_token**:
|
|||
|
|
- payload: `{user_id, username, exp (15min), iat, iss: "camtalk"}`
|
|||
|
|
- 签名算法: HS256(对称密钥,从配置读取)
|
|||
|
|
- 存储位置: 前端 localStorage
|
|||
|
|
|
|||
|
|
**refresh_token**:
|
|||
|
|
- payload: `{user_id, token_id (UUID), exp (7day), iat, iss: "camtalk"}`
|
|||
|
|
- 存储位置: 前端 localStorage + 数据库 `refresh_tokens` 表(存 SHA256 hash)
|
|||
|
|
|
|||
|
|
### 2.2 认证流程
|
|||
|
|
|
|||
|
|
#### 注册
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
用户 ──POST /api/auth/register──> 检查 username 唯一性
|
|||
|
|
bcrypt hash 密码
|
|||
|
|
INSERT users
|
|||
|
|
↓
|
|||
|
|
生成 access_token + refresh_token
|
|||
|
|
存 SHA256(refresh_token) 到 DB
|
|||
|
|
↓
|
|||
|
|
返回 {user, access_token, refresh_token}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 登录
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
用户 ──POST /api/auth/login──> 查 users 表 by username
|
|||
|
|
bcrypt.CompareHashAndPassword
|
|||
|
|
↓
|
|||
|
|
生成 access_token + refresh_token
|
|||
|
|
存 SHA256(refresh_token) 到 DB
|
|||
|
|
↓
|
|||
|
|
返回 {user, access_token, refresh_token}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 刷新
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
用户 ──POST /api/auth/refresh──> 校验 refresh_token 签名和过期
|
|||
|
|
查 DB 验证 hash 存在
|
|||
|
|
↓
|
|||
|
|
撤销旧 refresh_token(DELETE)
|
|||
|
|
生成新的 access + refresh
|
|||
|
|
存新 refresh_token hash
|
|||
|
|
↓
|
|||
|
|
返回 {access_token, refresh_token}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 登出
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
用户 ──POST /api/auth/logout──> 撤销 refresh_token (DELETE from DB)
|
|||
|
|
前端清除 localStorage
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.3 Go 实现接口
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/auth/jwt.go
|
|||
|
|
|
|||
|
|
type Claims struct {
|
|||
|
|
UserID string `json:"user_id"`
|
|||
|
|
Username string `json:"username"`
|
|||
|
|
jwt.RegisteredClaims
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type TokenManager struct {
|
|||
|
|
secret []byte
|
|||
|
|
accessTTL time.Duration // 15min
|
|||
|
|
refreshTTL time.Duration // 7day
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GeneratePair 生成 access + refresh token 对。
|
|||
|
|
func (tm *TokenManager) GeneratePair(userID, username string) (access, refresh string, err error)
|
|||
|
|
|
|||
|
|
// ValidateAccess 校验 access_token,返回 Claims。
|
|||
|
|
func (tm *TokenManager) ValidateAccess(tokenStr string) (*Claims, error)
|
|||
|
|
|
|||
|
|
// ValidateRefresh 校验 refresh_token 签名和过期(不查 DB,DB 校验由 service 层负责)。
|
|||
|
|
func (tm *TokenManager) ValidateRefresh(tokenStr string) (*Claims, error)
|
|||
|
|
|
|||
|
|
// HashToken 计算 token 的 SHA256 hash(用于 DB 存储)。
|
|||
|
|
func HashToken(token string) string
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/auth/middleware.go
|
|||
|
|
|
|||
|
|
// AuthMiddleware Gin 中间件:从 Authorization: Bearer <token> 提取并校验。
|
|||
|
|
// 校验通过后将 Claims 写入 gin.Context。
|
|||
|
|
func AuthMiddleware(tm *TokenManager) gin.HandlerFunc {
|
|||
|
|
return func(c *gin.Context) {
|
|||
|
|
auth := c.GetHeader("Authorization")
|
|||
|
|
if !strings.HasPrefix(auth, "Bearer ") {
|
|||
|
|
c.AbortWithStatusJSON(401, gin.H{"error": "missing token"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
claims, err := tm.ValidateAccess(strings.TrimPrefix(auth, "Bearer "))
|
|||
|
|
if err != nil {
|
|||
|
|
c.AbortWithStatusJSON(401, gin.H{"error": "invalid token"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.Set("claims", claims)
|
|||
|
|
c.Set("user_id", claims.UserID)
|
|||
|
|
c.Next()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 三、REST API 设计
|
|||
|
|
|
|||
|
|
### 3.1 认证 API(新增)
|
|||
|
|
|
|||
|
|
#### 注册
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
POST /api/auth/register
|
|||
|
|
Content-Type: application/json
|
|||
|
|
|
|||
|
|
{"username": "alice", "password": "s3cret123"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
响应:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
// 201 Created
|
|||
|
|
{
|
|||
|
|
"user": {"id": "uuid", "username": "alice", "created_at": "2026-06-14T10:00:00Z"},
|
|||
|
|
"access_token": "eyJ...",
|
|||
|
|
"refresh_token": "eyJ..."
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
错误码:`USERNAME_TAKEN`(409)、`INVALID_INPUT`(400,用户名/密码格式不合规)
|
|||
|
|
|
|||
|
|
#### 登录
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
POST /api/auth/login
|
|||
|
|
Content-Type: application/json
|
|||
|
|
|
|||
|
|
{"username": "alice", "password": "s3cret123"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
响应:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
// 200 OK
|
|||
|
|
{
|
|||
|
|
"user": {"id": "uuid", "username": "alice"},
|
|||
|
|
"access_token": "eyJ...",
|
|||
|
|
"refresh_token": "eyJ..."
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
错误码:`INVALID_CREDENTIALS`(401)
|
|||
|
|
|
|||
|
|
#### 刷新 Token
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
POST /api/auth/refresh
|
|||
|
|
Content-Type: application/json
|
|||
|
|
|
|||
|
|
{"refresh_token": "eyJ..."}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
响应:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
// 200 OK
|
|||
|
|
{
|
|||
|
|
"access_token": "eyJ...",
|
|||
|
|
"refresh_token": "eyJ..."
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
错误码:`INVALID_TOKEN`(401)
|
|||
|
|
|
|||
|
|
#### 登出
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
POST /api/auth/logout
|
|||
|
|
Authorization: Bearer <access_token>
|
|||
|
|
Content-Type: application/json
|
|||
|
|
|
|||
|
|
{"refresh_token": "eyJ..."}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
响应:`204 No Content`
|
|||
|
|
|
|||
|
|
### 3.2 对话管理 API(新增)
|
|||
|
|
|
|||
|
|
所有端点需要 `Authorization: Bearer <access_token>` header。
|
|||
|
|
|
|||
|
|
#### 获取对话列表
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
GET /api/conversations?page=1&size=20
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
响应:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
// 200 OK
|
|||
|
|
{
|
|||
|
|
"conversations": [
|
|||
|
|
{
|
|||
|
|
"id": "uuid",
|
|||
|
|
"title": "这是一朵红色的玫瑰花",
|
|||
|
|
"last_message": "它看起来很美丽。",
|
|||
|
|
"message_count": 6,
|
|||
|
|
"updated_at": "2026-06-14T10:30:00Z"
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
"total": 42,
|
|||
|
|
"page": 1,
|
|||
|
|
"size": 20
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 创建新对话
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
POST /api/conversations
|
|||
|
|
Content-Type: application/json
|
|||
|
|
|
|||
|
|
{}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
响应:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
// 201 Created
|
|||
|
|
{
|
|||
|
|
"id": "uuid",
|
|||
|
|
"title": "新对话",
|
|||
|
|
"created_at": "2026-06-14T10:00:00Z"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 获取对话详情
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
GET /api/conversations/:id
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
响应:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
// 200 OK
|
|||
|
|
{
|
|||
|
|
"id": "uuid",
|
|||
|
|
"title": "这是一朵红色的玫瑰花",
|
|||
|
|
"created_at": "2026-06-14T10:00:00Z",
|
|||
|
|
"updated_at": "2026-06-14T10:30:00Z",
|
|||
|
|
"config": {"tts_enabled": true, "detail_level": "low", "language": "zh-CN"}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 更新对话标题
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
PATCH /api/conversations/:id
|
|||
|
|
Content-Type: application/json
|
|||
|
|
|
|||
|
|
{"title": "新的标题"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
响应:`200 OK` + 更新后的对话详情
|
|||
|
|
|
|||
|
|
#### 删除对话
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
DELETE /api/conversations/:id
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
响应:`204 No Content`(级联删除 messages)
|
|||
|
|
|
|||
|
|
#### 获取对话历史消息
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
GET /api/conversations/:id/messages?limit=50&before=<message_id>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
响应:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
// 200 OK
|
|||
|
|
{
|
|||
|
|
"messages": [
|
|||
|
|
{"id": 1, "role": "user", "content": "这是什么花?", "created_at": "..."},
|
|||
|
|
{"id": 2, "role": "assistant", "content": "这是一朵红色的玫瑰。", "tokens_used": 42, "created_at": "..."}
|
|||
|
|
],
|
|||
|
|
"has_more": false
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.3 现有 API 变更
|
|||
|
|
|
|||
|
|
| 端点 | 变更 |
|
|||
|
|
|------|------|
|
|||
|
|
| `GET /api/health` | 不变 |
|
|||
|
|
| `POST /api/sessions` | **废弃**,使用 `POST /api/conversations` 替代 |
|
|||
|
|
| `DELETE /api/sessions/{id}` | **废弃**,使用 `DELETE /api/conversations/:id` 替代 |
|
|||
|
|
|
|||
|
|
### 3.4 新增错误码
|
|||
|
|
|
|||
|
|
| 错误码 | HTTP 状态 | 含义 |
|
|||
|
|
|--------|-----------|------|
|
|||
|
|
| `USERNAME_TAKEN` | 409 | 用户名已被注册 |
|
|||
|
|
| `INVALID_CREDENTIALS` | 401 | 用户名或密码错误 |
|
|||
|
|
| `INVALID_TOKEN` | 401 | JWT 无效或已过期 |
|
|||
|
|
| `INVALID_INPUT` | 400 | 请求参数不合规(用户名/密码长度等) |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 四、Session Manager 改造
|
|||
|
|
|
|||
|
|
### 4.1 接口扩展
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/session/manager.go
|
|||
|
|
|
|||
|
|
type Manager interface {
|
|||
|
|
// ===== 原有方法(签名变更) =====
|
|||
|
|
|
|||
|
|
// Create 创建新会话,关联 user_id。
|
|||
|
|
Create(ctx context.Context, userID string, config models.SessionConfig) (string, error)
|
|||
|
|
|
|||
|
|
Get(ctx context.Context, sessionID string) (*models.Session, error)
|
|||
|
|
UpdateConfig(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error
|
|||
|
|
GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error)
|
|||
|
|
AppendMessage(ctx context.Context, sessionID string, msg models.Message) error
|
|||
|
|
SetActiveRequest(ctx context.Context, sessionID string, requestID string) error
|
|||
|
|
GetActiveRequestID(ctx context.Context, sessionID string) (string, error)
|
|||
|
|
ClearActiveRequest(ctx context.Context, sessionID string) error
|
|||
|
|
Touch(ctx context.Context, sessionID string) error
|
|||
|
|
Destroy(ctx context.Context, sessionID string) error
|
|||
|
|
ActiveCount() int
|
|||
|
|
|
|||
|
|
// ===== 新增方法 =====
|
|||
|
|
|
|||
|
|
// ListByUser 获取用户的对话列表(分页)。
|
|||
|
|
ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error)
|
|||
|
|
|
|||
|
|
// UpdateTitle 更新对话标题。
|
|||
|
|
UpdateTitle(ctx context.Context, sessionID string, title string) error
|
|||
|
|
|
|||
|
|
// LoadFromDB 从 PostgreSQL 加载历史消息到热存储(Redis/内存)。
|
|||
|
|
// 用户选择历史对话继续交谈时调用。
|
|||
|
|
LoadFromDB(ctx context.Context, sessionID string) error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ConversationSummary 对话列表项。
|
|||
|
|
type ConversationSummary struct {
|
|||
|
|
ID string `json:"id"`
|
|||
|
|
Title string `json:"title"`
|
|||
|
|
LastMessage string `json:"last_message"`
|
|||
|
|
MessageCount int `json:"message_count"`
|
|||
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.2 Model 变更
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/models/models.go
|
|||
|
|
|
|||
|
|
type Session struct {
|
|||
|
|
ID string `json:"session_id"`
|
|||
|
|
UserID string `json:"user_id"` // 新增
|
|||
|
|
Title string `json:"title"` // 新增
|
|||
|
|
CreatedAt time.Time `json:"created_at"`
|
|||
|
|
UpdatedAt time.Time `json:"updated_at"` // 新增
|
|||
|
|
Config SessionConfig `json:"config"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type User struct {
|
|||
|
|
ID string `json:"id"`
|
|||
|
|
Username string `json:"username"`
|
|||
|
|
PasswordHash string `json:"-"` // 不序列化到 JSON
|
|||
|
|
CreatedAt time.Time `json:"created_at"`
|
|||
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.3 冷热数据策略
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
当前活跃会话: Redis/内存(热) ←→ PostgreSQL(冷,write-through)
|
|||
|
|
历史会话加载: PostgreSQL → Redis/内存(按需恢复)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Write-through 保证持久化**:每次 `AppendMessage` 同时写入 PostgreSQL,确保服务重启不丢数据。
|
|||
|
|
|
|||
|
|
**历史对话恢复流程**:
|
|||
|
|
1. 用户从对话列表选择一个历史对话
|
|||
|
|
2. 前端带 `conversation_id` 建立 WebSocket 连接
|
|||
|
|
3. 后端调用 `sessionManager.LoadFromDB(conversationID)` 将历史消息从 PostgreSQL 加载到 Redis/内存
|
|||
|
|
4. 后续对话正常走热存储路径
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 五、WebSocket 认证集成
|
|||
|
|
|
|||
|
|
### 5.1 连接流程
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
前端 后端
|
|||
|
|
| |
|
|||
|
|
|-- WS /ws?token=<access> ---->|
|
|||
|
|
| &conversation_id=<uuid> |
|
|||
|
|
| |-- 校验 access_token
|
|||
|
|
| |-- 校验 conversation_id 归属
|
|||
|
|
| |-- LoadFromDB(如果是历史对话)
|
|||
|
|
| |-- 创建新 session(如果 conversation_id 为空)
|
|||
|
|
|<-- connected {session_id} ---|
|
|||
|
|
| |
|
|||
|
|
|-- query {image, audio} ----->| (正常对话流程)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.2 Go 实现
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/ws/handler.go
|
|||
|
|
|
|||
|
|
func (h *Handler) HandleWS(c *gin.Context) {
|
|||
|
|
// 1. 提取并校验 access_token
|
|||
|
|
tokenStr := c.Query("token")
|
|||
|
|
if tokenStr == "" {
|
|||
|
|
c.JSON(401, gin.H{"error": "missing token"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
claims, err := h.tokenManager.ValidateAccess(tokenStr)
|
|||
|
|
if err != nil {
|
|||
|
|
c.JSON(401, gin.H{"error": "invalid token"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 提取 conversation_id(可选)
|
|||
|
|
conversationID := c.Query("conversation_id")
|
|||
|
|
|
|||
|
|
// 3. 升级 WebSocket
|
|||
|
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
|||
|
|
if err != nil {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. 获取或创建 session
|
|||
|
|
var sessionID string
|
|||
|
|
if conversationID != "" {
|
|||
|
|
// 验证该对话属于当前用户
|
|||
|
|
sess, err := h.sessionMgr.Get(c, conversationID)
|
|||
|
|
if err != nil || sess.UserID != claims.UserID {
|
|||
|
|
conn.WriteJSON(models.WsError{Type: "error", Code: "SESSION_NOT_FOUND"})
|
|||
|
|
conn.Close()
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
// 加载历史到热存储
|
|||
|
|
h.sessionMgr.LoadFromDB(c, conversationID)
|
|||
|
|
sessionID = conversationID
|
|||
|
|
} else {
|
|||
|
|
// 创建新对话
|
|||
|
|
sessionID, _ = h.sessionMgr.Create(c, claims.UserID, models.DefaultConfig())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 5. 进入正常 WS 处理循环
|
|||
|
|
h.handleSession(conn, sessionID, claims.UserID)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.3 前端连接方式
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// WebSocket 连接
|
|||
|
|
const ws = new WebSocket(
|
|||
|
|
`wss://${window.location.host}/ws?token=${accessToken}&conversation_id=${selectedConvId || ''}`
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 六、前端设计概要
|
|||
|
|
|
|||
|
|
### 6.1 页面路由
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
/ → 未登录重定向到 /login
|
|||
|
|
/login → AuthPage(登录/注册表单)
|
|||
|
|
/chat → 主界面(需登录)
|
|||
|
|
/chat/:id → 主界面,自动加载指定对话
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.2 组件结构
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
App
|
|||
|
|
├── AuthPage ← 新增:登录/注册
|
|||
|
|
└── ChatLayout(需登录)
|
|||
|
|
├── ConversationList ← 新增:侧边栏对话列表
|
|||
|
|
│ ├── 对话项(标题、最后消息、时间)
|
|||
|
|
│ ├── 新建对话按钮
|
|||
|
|
│ └── 删除对话按钮
|
|||
|
|
├── ChatPanel ← 现有,需适配多对话
|
|||
|
|
├── VideoPreview ← 现有
|
|||
|
|
├── MicManager ← 现有
|
|||
|
|
└── ConfigPanel ← 现有
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.3 新增 Hook
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// useAuth — 认证状态管理
|
|||
|
|
function useAuth() {
|
|||
|
|
const [user, setUser] = useState<User | null>(null);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
|
|||
|
|
const login = async (username: string, password: string) => { ... };
|
|||
|
|
const register = async (username: string, password: string) => { ... };
|
|||
|
|
const logout = async () => { ... };
|
|||
|
|
const refreshToken = async () => { ... };
|
|||
|
|
|
|||
|
|
// 请求拦截器:自动附加 Authorization header
|
|||
|
|
// 401 时自动尝试 refresh,失败则跳转登录
|
|||
|
|
|
|||
|
|
return { user, loading, login, register, logout };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// useConversations — 对话列表管理
|
|||
|
|
function useConversations() {
|
|||
|
|
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
|||
|
|
const [currentId, setCurrentId] = useState<string | null>(null);
|
|||
|
|
|
|||
|
|
const fetchList = async (page?: number) => { ... };
|
|||
|
|
const createNew = async () => { ... };
|
|||
|
|
const deleteConv = async (id: string) => { ... };
|
|||
|
|
const renameConv = async (id: string, title: string) => { ... };
|
|||
|
|
const selectConv = (id: string) => { setCurrentId(id); };
|
|||
|
|
|
|||
|
|
return { conversations, currentId, fetchList, createNew, deleteConv, renameConv, selectConv };
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.4 对话标题自动生成
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 内部逻辑:首条 user 消息的前 20 个字符作为 title
|
|||
|
|
func generateTitle(firstMessage string) string {
|
|||
|
|
runes := []rune(firstMessage)
|
|||
|
|
if len(runes) > 20 {
|
|||
|
|
return string(runes[:20]) + "…"
|
|||
|
|
}
|
|||
|
|
return firstMessage
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
在 `AppendMessage` 时,如果 session 的 title 仍为 "新对话",自动更新为 `generateTitle(msg.Content)`。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 七、配置扩展
|
|||
|
|
|
|||
|
|
### 7.1 Go 配置结构体
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
type Config struct {
|
|||
|
|
App AppConfig `mapstructure:"app"`
|
|||
|
|
Server ServerConfig `mapstructure:"server"`
|
|||
|
|
Auth AuthConfig `mapstructure:"auth"` // 新增
|
|||
|
|
Redis RedisConfig `mapstructure:"redis"`
|
|||
|
|
AI AIConfig `mapstructure:"ai"`
|
|||
|
|
Storage StorageConfig `mapstructure:"storage"`
|
|||
|
|
Log LogConfig `mapstructure:"log"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type AuthConfig struct {
|
|||
|
|
JWTSecret string `mapstructure:"jwt_secret"` // 必须通过环境变量设置
|
|||
|
|
AccessTTL int `mapstructure:"access_ttl"` // 分钟,默认 15
|
|||
|
|
RefreshTTL int `mapstructure:"refresh_ttl"` // 分钟,默认 10080 (7天)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 7.2 配置文件示例
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
# config.yaml
|
|||
|
|
auth:
|
|||
|
|
access_ttl: 15 # 分钟
|
|||
|
|
refresh_ttl: 10080 # 7天
|
|||
|
|
|
|||
|
|
storage:
|
|||
|
|
driver: "memory" # "memory" | "postgres"
|
|||
|
|
dsn: ""
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 7.3 环境变量
|
|||
|
|
|
|||
|
|
| 配置项 | 环境变量 | 说明 |
|
|||
|
|
|--------|---------|------|
|
|||
|
|
| `auth.jwt_secret` | `CAMTALK_AUTH_JWT_SECRET` | **必须设置**,JWT 签名密钥 |
|
|||
|
|
| `auth.access_ttl` | `CAMTALK_AUTH_ACCESS_TTL` | access_token 有效期(分钟) |
|
|||
|
|
| `auth.refresh_ttl` | `CAMTALK_AUTH_REFRESH_TTL` | refresh_token 有效期(分钟) |
|
|||
|
|
| `storage.driver` | `CAMTALK_STORAGE_DRIVER` | `"memory"` 或 `"postgres"` |
|
|||
|
|
| `storage.dsn` | `CAMTALK_STORAGE_DSN` | PostgreSQL 连接串 |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 八、实施阶段
|
|||
|
|
|
|||
|
|
### Phase 1:用户认证系统
|
|||
|
|
|
|||
|
|
- [ ] 数据库 schema 迁移脚本(users, refresh_tokens 表)
|
|||
|
|
- [ ] `internal/auth/` 包:TokenManager, bcrypt 工具, JWT 中间件
|
|||
|
|
- [ ] `internal/store/user.go`:UserRepository 接口 + PostgreSQL 实现
|
|||
|
|
- [ ] REST API:`/api/auth/register`, `/api/auth/login`, `/api/auth/refresh`, `/api/auth/logout`
|
|||
|
|
- [ ] 单元测试
|
|||
|
|
|
|||
|
|
### Phase 2:对话 CRUD + 消息持久化
|
|||
|
|
|
|||
|
|
- [ ] 数据库 schema 迁移脚本(sessions, messages 表改造)
|
|||
|
|
- [ ] `internal/store/conversation.go`:ConversationRepository 接口 + PostgreSQL 实现
|
|||
|
|
- [ ] Session Manager 扩展:Create 绑定 user_id, ListByUser, UpdateTitle
|
|||
|
|
- [ ] REST API:`/api/conversations` CRUD + `/api/conversations/:id/messages`
|
|||
|
|
- [ ] Write-through:AppendMessage 同时写 PostgreSQL
|
|||
|
|
|
|||
|
|
### Phase 3:对话历史恢复
|
|||
|
|
|
|||
|
|
- [ ] `sessionManager.LoadFromDB()` 实现
|
|||
|
|
- [ ] 对话标题自动生成逻辑
|
|||
|
|
- [ ] REST API:对话详情、历史消息查询(分页)
|
|||
|
|
|
|||
|
|
### Phase 4:前端集成
|
|||
|
|
|
|||
|
|
- [ ] `useAuth` hook + 请求拦截器(自动附加 token、自动 refresh)
|
|||
|
|
- [ ] `AuthPage` 组件(登录/注册表单)
|
|||
|
|
- [ ] `ConversationList` 组件
|
|||
|
|
- [ ] `useConversations` hook
|
|||
|
|
- [ ] 路由守卫:未登录重定向到 `/login`
|
|||
|
|
- [ ] WebSocket 连接带 token + conversation_id
|
|||
|
|
- [ ] `useVisionSession` 适配多对话切换
|
|||
|
|
|
|||
|
|
### Phase 5:配置与收尾
|
|||
|
|
|
|||
|
|
- [ ] 配置结构体扩展(AuthConfig)
|
|||
|
|
- [ ] config.yaml 更新
|
|||
|
|
- [ ] docker-compose 添加 PostgreSQL
|
|||
|
|
- [ ] 集成测试
|
|||
|
|
- [ ] 更新 `02-系统架构.md` 和 `03-接口文档.md`
|