From 4b30e67c2ef7999d41d38caa4726e18540798213 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 16:40:06 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=20Config=20?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E4=BD=93=EF=BC=8C=E6=96=B0=E5=A2=9E=20AuthCo?= =?UTF-8?q?nfig=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AuthConfig 结构体(JWTSecret, AccessTTL, RefreshTTL) - 在 Config 中添加 Auth 字段 - 设置默认值:access_ttl=15分钟,refresh_ttl=10080分钟(7天) - JWTSecret 必须通过环境变量 CAMTALK_AUTH_JWT_SECRET 设置 --- backend/internal/config/config.go | 10 + docs/04-技术选型.md | 83 ++++ docs/11-持久化与用户系统设计.md | 750 ++++++++++++++++++++++++++++ docs/PLAN_USER_MODULE.md | 797 ++++++++++++++++++++++++++++++ 4 files changed, 1640 insertions(+) create mode 100644 docs/11-持久化与用户系统设计.md create mode 100644 docs/PLAN_USER_MODULE.md diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e0937a8..1949f0d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 过期时间(分钟),默认 10080(7天) +} + // 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() // 文件不存在不报错 diff --git a/docs/04-技术选型.md b/docs/04-技术选型.md index 742e59a..8875fac 100644 --- a/docs/04-技术选型.md +++ b/docs/04-技术选型.md @@ -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 ` header。refresh_token 同样存 localStorage,401 时自动触发刷新流程。 diff --git a/docs/11-持久化与用户系统设计.md b/docs/11-持久化与用户系统设计.md new file mode 100644 index 0000000..0cce137 --- /dev/null +++ b/docs/11-持久化与用户系统设计.md @@ -0,0 +1,750 @@ +# 持久化与用户系统设计 + +## 概述 + +本文档定义用户注册/登录、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 提取并校验。 +// 校验通过后将 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 +Content-Type: application/json + +{"refresh_token": "eyJ..."} +``` + +响应:`204 No Content` + +### 3.2 对话管理 API(新增) + +所有端点需要 `Authorization: Bearer ` 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= +``` + +响应: + +```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= ---->| + | &conversation_id= | + | |-- 校验 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(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([]); + const [currentId, setCurrentId] = useState(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` diff --git a/docs/PLAN_USER_MODULE.md b/docs/PLAN_USER_MODULE.md new file mode 100644 index 0000000..2bc764e --- /dev/null +++ b/docs/PLAN_USER_MODULE.md @@ -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 3:JWT + 认证服务 + +**目标**:实现 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 ` 提取 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 5:Session 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=` 分页 +- 内存实现中,history 是全量存储的,直接按索引切片即可 + +--- + +### Phase 7:WebSocket 认证集成 + +**目标**: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=&conversation_id= + +服务端处理: + 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.SaveMessage(write-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 5(Session 改造)可并行开发 +- Phase 6(对话 API)和 Phase 7(WS 认证)可并行开发 + +--- + +## 验证方案 + +| 层级 | 方法 | 覆盖范围 | +|------|------|---------| +| 单元测试 | `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 ./...` | 全量通过 | -- 2.49.1 From 0edafbbf8a4105ff5b5ddb82a5024b5a06e345a9 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 16:41:50 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20PostgreSQL=20?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E6=B1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 internal/store/db.go - 实现 NewPostgresPool(ctx, dsn) 创建连接池 - 添加 pgx/v5 依赖 - 最大连接数设置为 10 --- backend/go.mod | 9 +++++++-- backend/go.sum | 18 ++++++++++++++---- backend/internal/store/db.go | 17 +++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 backend/internal/store/db.go diff --git a/backend/go.mod b/backend/go.mod index 8e60b15..069496b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,11 +1,12 @@ module github.com/hhs/camtalk -go 1.24 +go 1.25.0 require ( github.com/gin-gonic/gin v1.10.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/jackc/pgx/v5 v5.10.0 github.com/redis/go-redis/v9 v9.20.1 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 @@ -27,6 +28,9 @@ require ( github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -50,8 +54,9 @@ require ( golang.org/x/arch v0.8.0 // indirect golang.org/x/crypto v0.23.0 // indirect golang.org/x/net v0.25.0 // indirect + golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/text v0.29.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 01eeeb8..d4c2b90 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -44,6 +44,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -120,16 +128,18 @@ golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/store/db.go b/backend/internal/store/db.go new file mode 100644 index 0000000..bc0d6c8 --- /dev/null +++ b/backend/internal/store/db.go @@ -0,0 +1,17 @@ +package store + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// NewPostgresPool 创建 PostgreSQL 连接池。 +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) +} -- 2.49.1 From bf1e24453c757d3acf3faad95d011583ef7a3969 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 16:42:12 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=E7=BC=96=E5=86=99=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=A1=A8=20schema=20=E8=BF=81=E7=A7=BB=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 migrations/001_users.up.sql:创建 users 表和 refresh_tokens 表 - 新增 migrations/001_users.down.sql:回滚脚本 - users 表包含 id, username, password_hash, created_at, updated_at - refresh_tokens 表包含 id, user_id, token_hash, expires_at, created_at - 添加必要的索引优化查询性能 --- backend/migrations/001_users.down.sql | 5 +++++ backend/migrations/001_users.up.sql | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 backend/migrations/001_users.down.sql create mode 100644 backend/migrations/001_users.up.sql diff --git a/backend/migrations/001_users.down.sql b/backend/migrations/001_users.down.sql new file mode 100644 index 0000000..4d30d76 --- /dev/null +++ b/backend/migrations/001_users.down.sql @@ -0,0 +1,5 @@ +-- 删除 Refresh Token 表(自动删除相关索引) +DROP TABLE IF EXISTS refresh_tokens; + +-- 删除用户表(自动删除相关索引) +DROP TABLE IF EXISTS users; diff --git a/backend/migrations/001_users.up.sql b/backend/migrations/001_users.up.sql new file mode 100644 index 0000000..76ff405 --- /dev/null +++ b/backend/migrations/001_users.up.sql @@ -0,0 +1,26 @@ +-- 用户表 +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(64) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 用户名索引(用于登录查询) +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); + +-- Refresh Token 表 +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Token hash 索引(用于刷新验证) +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash); + +-- 用户 ID 索引(用于登出所有设备) +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id); -- 2.49.1 From 0ff810a2970689c72fc59828fe18a4a1761c0178 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 16:43:07 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20main.go=20=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=20PostgreSQL=20=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E6=B1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 store 包导入 - 当 storage.driver 为 postgres 时创建 pgxpool - 使用 defer 确保连接池正确关闭 - 添加日志记录连接状态 --- backend/cmd/server/main.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 829c415..8091719 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -19,6 +19,7 @@ import ( "github.com/hhs/camtalk/internal/logger" "github.com/hhs/camtalk/internal/orchestrator" "github.com/hhs/camtalk/internal/session" + "github.com/hhs/camtalk/internal/store" "github.com/hhs/camtalk/internal/ws" ) @@ -44,6 +45,21 @@ func main() { "addr", cfg.Server.Addr(), ) + // 初始化存储层(条件初始化 PostgreSQL) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if cfg.Storage.Driver == "postgres" { + pool, err := store.NewPostgresPool(ctx, cfg.Storage.DSN) + if err != nil { + logger.Log.Fatalw("failed to connect to postgres", "error", err) + } + defer pool.Close() + logger.Log.Infow("postgres connected", "driver", cfg.Storage.Driver) + // TODO: Phase 2 - 初始化 UserRepository 和 MessageRepository + _ = pool + } + // 初始化 Session Manager(MVP 默认内存实现) var sessionMgr session.Manager // TODO: 当 Redis 配置非空时切换为 RedisManager -- 2.49.1