Merge pull request 'feat: 添加 PostgreSQL 服务并挂载数据库迁移脚本' #101

Merged
huanghaosheng merged 55 commits from develop into main 2026-06-14 19:25:37 +08:00
4 changed files with 1640 additions and 0 deletions
Showing only changes of commit 4b30e67c2e - Show all commits

View File

@@ -17,6 +17,7 @@ type Config struct {
AI AIConfig `mapstructure:"ai"`
Storage StorageConfig `mapstructure:"storage"`
Log LogConfig `mapstructure:"log"`
Auth AuthConfig `mapstructure:"auth"`
}
// SessionConfig 会话管理配置。
@@ -99,6 +100,13 @@ type LogConfig struct {
Format string `mapstructure:"format"`
}
// AuthConfig 认证配置。
type AuthConfig struct {
JWTSecret string `mapstructure:"jwt_secret"` // JWT 签名密钥,必须通过环境变量 CAMTALK_AUTH_JWT_SECRET 设置
AccessTTL int `mapstructure:"access_ttl"` // Access Token 过期时间(分钟),默认 15
RefreshTTL int `mapstructure:"refresh_ttl"` // Refresh Token 过期时间(分钟),默认 100807天
}
// Load 加载配置。优先级:环境变量 > config.{env}.yaml > config.yaml。
func Load() (*Config, error) {
v := viper.New()
@@ -146,6 +154,8 @@ func Load() (*Config, error) {
v.SetDefault("storage.driver", "memory")
v.SetDefault("log.level", "info")
v.SetDefault("log.format", "console")
v.SetDefault("auth.access_ttl", 15)
v.SetDefault("auth.refresh_ttl", 10080)
// 读取基础配置文件
_ = v.ReadInConfig() // 文件不存在不报错

View File

@@ -13,6 +13,12 @@
│ ├── LLM: GPT-4o默认 / 通义千问等 OpenAI 兼容模型
│ └── TTS: OpenAI TTS默认 / MiMo TTS
├── 持久化层 → 数据库选型: PostgreSQL规划中MVP 阶段使用内存存储)
├── 认证与用户系统
│ ├── 认证方案: JWT (HS256), access 15min + refresh 7day
│ ├── JWT 库: golang-jwt/jwt/v5
│ ├── 密码哈希: bcrypt
│ ├── 数据库驱动: pgx/v5手写 SQL不用 ORM
│ └── 前端 Token 存储: localStorage
└── 前端边缘处理层
├── 边缘推理: ONNX Runtime Web规划中MVP 使用 Canvas 像素比较)
├── 语音检测: @ricky0123/vad-web
@@ -214,3 +220,80 @@ vad-web 是"够用且最轻"的平衡点——直接包装浏览器原生 WebRTC
| MediaDevices API | 直接用浏览器原生接口,不加封装层 |
与"前端做轻量预处理"原则一致:前端层只需采集和判断"有没有值得发给后端的数据"。
---
## 四、认证与用户系统选型
### 总览
| 能力 | 选型 | 选择理由 |
|------|------|---------|
| 认证方案 | JWT (HS256) | 无状态,分布式友好,实现简单 |
| JWT 库 | golang-jwt/jwt/v5 | 社区主流v5 活跃维护 |
| 密码哈希 | bcrypt | Go 标准库直接可用,安全性足够 |
| 数据库驱动 | pgx/v5 | Go 生态性能最优的 PostgreSQL 驱动 |
| 数据库迁移 | 手写 SQL | MVP 阶段足够,后续可引入 golang-migrate |
### 认证方案JWT
| 方案 | 特点 | 适用场景 |
|------|------|---------|
| **JWT (HS256)** | 无状态 token服务端不存 session水平扩展友好 | 分布式部署、前后端分离 |
| Session + Cookie | 有状态,服务端存 session通常 Redis | 传统 Web 应用、需要服务端控制会话 |
| OAuth2 | 第三方登录授权 | 需要接入微信/GitHub 等第三方登录 |
选择 JWT 的核心理由:项目架构是前后端分离 + WebSocket 长连接JWT 无需服务端维护 session 状态天然适配。HS256 对称签名足以满足安全需求,实现比 RS256 简单。
Token 策略采用 **access (15min) + refresh (7day) 双 token**access_token 短生命周期降低泄露风险refresh_token 支持无感续期。
### JWT 库golang-jwt/jwt/v5
| 方案 | 状态 | 特点 |
|------|------|------|
| **golang-jwt/jwt/v5** | 活跃维护 | dgrijalva/jwt-go 的官方继任,社区主流 |
| dgrijalva/jwt-go | 已停维护 | 原始库,不再更新 |
| lestrrat-go/jwx | 活跃 | 功能更全JWE/JWS但项目只需签名过度引入 |
v5 是 Go 生态中 JWT 的事实标准API 简洁,文档完善。
### 密码哈希bcrypt
| 方案 | 特点 | 选择理由 |
|------|------|---------|
| **bcrypt** | 自适应 cost factor抗暴力破解 | Go 标准库 `golang.org/x/crypto/bcrypt` 直接可用 |
| argon2 | 2015 年密码哈希竞赛冠军,抗 GPU/ASIC | 安全性更高,但 Go 生态库不如 bcrypt 成熟 |
| scrypt | 内存硬哈希 | 参数调优复杂bcrypt 已足够 |
bcrypt 的 `cost` 参数可随硬件升级调大,当前默认 cost=10 足够安全。
### 数据库驱动pgx/v5
| 方案 | 特点 | 适用场景 |
|------|------|---------|
| **pgx/v5** | 原生 PostgreSQL 协议实现,连接池 pgxpool性能最优 | 需要高性能、直接写 SQL |
| GORM | 全功能 ORM自动迁移、关联预加载 | 快速开发、不想写 SQL |
| Ent | Facebook 出品,类型安全的 ORM | 大型项目、强类型需求 |
| database/sql + lib/pq | 标准接口,但 lib/pq 已停维护 | 简单场景 |
项目规模不大4 张表),手写 SQL 更可控,避免 ORM 的抽象泄漏和性能黑盒。pgx 原生支持 `pgxpool` 连接池,无需额外引入。
### 数据库迁移:手写 SQL
| 方案 | 特点 | 适用场景 |
|------|------|---------|
| **手写 SQL** | 零依赖,完全可控 | 表少(<10 张)、团队小 |
| golang-migrate | CLI + 库双模式,支持版本回滚 | 表多、需要严格版本管理 |
| Atlas | 声明式迁移HCL 定义 schema | 大型项目、多环境管理 |
MVP 阶段 4 张表,手写 `schema.sql` 即可。后续表结构复杂后可引入 golang-migrate。
### 前端 Token 存储
| 方案 | 特点 | 选择理由 |
|------|------|---------|
| **localStorage** | 持久化存储刷新不丢失JS 可直接读写 | 简单直接SPA 应用标准做法 |
| httpOnly Cookie | 防 XSS 读取,但需防 CSRF | 传统 Web 应用,需额外 CSRF 防护 |
| sessionStorage | 仅当前标签页有效 | 关闭标签页需重新登录,体验差 |
JWT 存 localStorage配合请求拦截器统一附加 `Authorization: Bearer <token>` header。refresh_token 同样存 localStorage401 时自动触发刷新流程。

View File

@@ -0,0 +1,750 @@
# 持久化与用户系统设计
## 概述
本文档定义用户注册/登录、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 表结构
```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_tokenDELETE
生成新的 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 签名和过期(不查 DBDB 校验由 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/内存(热) ←→ 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 实现
```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-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-系统架构.md``03-接口文档.md`

797
docs/PLAN_USER_MODULE.md Normal file
View File

@@ -0,0 +1,797 @@
# 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 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 核心实现**
```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 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_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=<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 消息
```
**对话标题自动生成**
```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.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 接口**
```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))
// ... 启动
}
```
---
## 关键文件清单
```
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 ./...` | 全量通过 |