diff --git a/CLAUDE.md b/CLAUDE.md index 9798898..f0b3da7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ go vet ./... # 静态分析 - `POST /api/auth/logout` — 登出 - `GET /api/conversations` — 对话列表 - `POST /api/conversations` — 创建对话 -- `GET/PUT/PATCH/DELETE /api/conversations/:id` — 对话 CRUD +- `GET/PATCH/DELETE /api/conversations/:id` — 对话详情/改标题/删除 - `GET /api/conversations/:id/messages` — 获取对话消息 ## 错误码 @@ -84,18 +84,19 @@ go vet ./... # 静态分析 | 组件 | 职责 | |------|------| -| `AuthPage` | 登录/注册表单 | +| `LandingPage` | 未登录时的着陆页,内嵌 LoginModal 登录/注册弹窗 | +| `AuthPage` | 登录/注册表单(备用) | | `CameraManager` | 摄像头流采集 | | `MicManager` | 麦克风音频采集 | | `EdgeProcessor` | VAD + 关键帧检测(Canvas 像素比较) | | `WebSocketManager` | WebSocket 连接生命周期管理 | | `ChatPanel` | 消息展示、流式回复、文本输入、场景选择 | | `VideoPreview` | 摄像头画面预览 | -| `SessionSidebar` | 左侧抽屉式对话列表 | -| `ConfigPanel` | 右侧抽屉式配置面板 | +| `SessionSidebar` | 左侧抽屉式对话列表(搜索、重命名、删除、时间分组) | +| `ConfigPanel` | 右侧抽屉式配置面板(主题、TTS、语言、场景、登出) | | `Toast` | 轻量通知提示 | -核心 Hook:`useVisionSession()` 封装一次完整的视觉对话会话。 +核心 Hook:`useVisionSession()` 封装一次完整的视觉对话会话。`useSessionList()` 管理对话列表 CRUD(通过 REST API)。 ## 后端模块结构 diff --git a/README.md b/README.md index dd9d270..e7d5710 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ graph TB B2[Session Manager] B3[AI Orchestrator] B4[REST API] + B5[Auth 模块] + B6[Store 层] end subgraph cloud[云端 AI 服务] @@ -54,9 +56,9 @@ graph TB |------|------| | 前端 | React 18, TypeScript, Vite, @ricky0123/vad-web | | 后端 | Go, Gin, gorilla/websocket, Viper, Zap | -| STT | Deepgram(默认) / MiMo ASR | -| LLM | GPT-4o(默认,通过 OpenAI 兼容接口可切换) | -| TTS | OpenAI TTS(默认) / MiMo TTS | +| STT | MiMo ASR(默认) / Deepgram | +| LLM | DashScope qwen3-vl-plus(默认,通过 eino-ext OpenAI ChatModel 接入) | +| TTS | MiMo TTS(默认) / OpenAI TTS | ## 项目结构 @@ -65,38 +67,49 @@ CamTalk/ ├── frontend/ # 浏览器客户端 │ └── src/ │ ├── components/ # UI 组件 +│ │ ├── LandingPage/ # 登录着陆页 + LoginModal +│ │ ├── AuthPage/ # 登录/注册表单 │ │ ├── CameraManager/ # 摄像头流采集 │ │ ├── MicManager/ # 麦克风音频采集 │ │ ├── EdgeProcessor/ # VAD + 关键帧检测 │ │ ├── WebSocketManager/ # WS 连接管理 │ │ ├── ChatPanel/ # 消息展示 │ │ ├── VideoPreview/ # 摄像头画面预览 +│ │ ├── SessionSidebar/ # 对话历史侧边栏 │ │ ├── ConfigPanel/ # 配置面板 │ │ └── Toast/ # 通知提示 │ ├── hooks/ # 自定义 Hooks │ │ ├── useVisionSession.ts # 核心会话 Hook +│ │ ├── useSessionList.ts # 对话列表管理 │ │ └── useObservationMode.ts # 观察模式 │ ├── lib/ # 工具库 │ │ ├── websocket.ts # WebSocket 连接管理 +│ │ ├── api.ts # REST API 客户端 +│ │ ├── auth.tsx # 认证上下文(JWT 管理) │ │ ├── audio.ts # 音频编码 │ │ ├── ttsPlayer.ts # TTS 播放器 +│ │ ├── i18n/ # 国际化(zh-CN/en-US/ja-JP) │ │ └── sampling.ts # 采样策略 │ └── types/ # TypeScript 类型定义 ├── backend/ # Go 网关 │ ├── cmd/server/ # 入口 │ └── internal/ │ ├── ai/ # AI 服务抽象层 -│ │ ├── llm/ # LLM 服务(OpenAI 兼容) -│ │ ├── stt/ # STT 服务(Deepgram/MiMo) -│ │ └── tts/ # TTS 服务(OpenAI/MiMo) -│ ├── orchestrator/ # AI 编排器(STT→LLM→TTS 管道) -│ ├── session/ # 会话管理(Memory/Redis) +│ │ ├── llm/ # LLM 提示词与场景 +│ │ ├── stt/ # STT 服务(MiMo/Deepgram) +│ │ └── tts/ # TTS 服务(MiMo/OpenAI) +│ ├── eino/ # Eino Graph 编排层(7 节点 DAG) +│ ├── orchestrator/ # Orchestrator 接口 +│ ├── session/ # 会话管理(三级存储:Memory/Redis/PG) +│ ├── store/ # 持久化层(Repository 接口 + PG/内存实现) +│ ├── auth/ # 认证(JWT、bcrypt、中间件) │ ├── ws/ # WebSocket Handler -│ ├── api/ # REST API +│ ├── api/ # REST API(Auth/Conversation) │ ├── config/ # 配置管理 │ ├── models/ # 数据模型 │ ├── errors/ # 错误码 │ └── logger/ # 日志 +├── migrations/ # 数据库迁移(嵌入式 SQL) ├── docs/ # 设计文档 └── CLAUDE.md # Claude Code 指引 ``` @@ -106,7 +119,7 @@ CamTalk/ ### 前置条件 - Node.js >= 18 -- Go >= 1.24 +- Go >= 1.25 ### 前端 @@ -147,20 +160,21 @@ go run ./cmd/server **客户端 → 服务端**:`query`、`config`、`interrupt`、`ping` **服务端 → 客户端**:`connected`、`stt_result`、`llm_chunk`、`llm_done`、`tts_audio`、`error`、`pong` -完整协议见 [docs/03-接口文档.md](docs/03-接口文档.md)。 +完整协议见 [docs/02-接口文档.md](docs/02-接口文档.md)。 ## 文档 | 文档 | 内容 | |------|------| -| [01-项目概述](docs/01-项目概述.md) | 项目目标与核心挑战 | -| [02-系统架构](docs/02-系统架构.md) | 三层架构、技术栈、部署方案 | -| [03-接口文档](docs/03-接口文档.md) | WebSocket 协议、REST API、配置管理 | -| [04-技术选型](docs/04-技术选型.md) | AI 服务栈、持久化层、前端边缘处理选型 | -| [05-用户故事](docs/05-用户故事.md) | 用户场景与优先级 | -| [06-语音交互](docs/06-语音交互.md) | VAD → STT → LLM → TTS 全链路 | -| [07-视觉理解](docs/07-视觉理解.md) | 帧采样、关键帧检测、多模态输入 | -| [08-成本控制](docs/08-成本控制.md) | 采样策略、端云协同、模型分级 | +| [01-架构设计](docs/01-架构设计.md) | 三层架构、技术栈、数据库设计、部署方案 | +| [02-接口文档](docs/02-接口文档.md) | WebSocket 协议、REST API、AI 服务层、编排器、配置管理 | +| [03-技术选型](docs/03-技术选型.md) | AI 服务栈、持久化层、前端边缘处理选型 | +| [04-用户故事](docs/04-用户故事.md) | 用户场景与优先级 | +| [05-语音交互](docs/05-语音交互.md) | VAD → STT → LLM → TTS 全链路 | +| [06-视觉理解](docs/06-视觉理解.md) | 帧采样、关键帧检测、多模态输入 | +| [07-成本控制](docs/07-成本控制.md) | 采样策略、端云协同、模型分级 | +| [08-功能创意](docs/08-功能创意.md) | 功能创意与规划 | +| [对话历史技术设计](docs/conversation-history-technical-design.md) | 对话历史功能的前端技术方案 | ## License diff --git a/docs/01-架构设计.md b/docs/01-架构设计.md index 02826e4..bd45dd1 100644 --- a/docs/01-架构设计.md +++ b/docs/01-架构设计.md @@ -199,7 +199,7 @@ graph LR | 模块 | 职责 | |------|------| | WebSocket Handler | 管理客户端连接生命周期,JWT 认证,conversation_id 恢复,单播消息推送 | -| Session Manager | 维护用户会话状态、对话历史。Memory(默认)/ Redis(可切换),30 分钟 TTL,Write-Through 到 PG | +| Session Manager | 维护用户会话状态、对话历史。三级存储(Memory → Redis → PostgreSQL),30 分钟 TTL,Write-Through 到 PG | | Eino 编排层 | 基于 CloudWeGo Eino Graph 的声明式 AI 编排。7 节点 DAG(STT→History→ChatModel→Msg2Str→Splitter→TTS→Done),Stream 模式调用,Callback 实现 LLM token 实时推送 | | AI Orchestrator | `EinoOrchestrator` 适配器,包装 Eino Graph 实现 `Orchestrator` 接口。context 取消 + 超时控制 | | AI Service Layer | AI 服务抽象层,多 provider 支持(Deepgram/MiMo/OpenAI 等) | @@ -210,24 +210,25 @@ graph LR | Models | 数据模型定义 | | Migrations | 数据库版本化迁移,嵌入式 SQL 文件自动执行 | | Model Router | 根据请求类型选择 AI 模型(待实现) | -| Rate Limiter | 令牌桶限流(待实现) | +| Rate Limiter | 令牌桶限流。详细设计见 [令牌桶限流设计](./13-令牌桶限流设计.md) | ## 前端组件 | 组件 | 职责 | |------|------| -| AuthPage | 登录/注册表单 | +| LandingPage | 未登录时的着陆页(营销展示),内嵌 LoginModal 登录/注册弹窗 | +| AuthPage | 登录/注册表单(备用,已被 LandingPage + LoginModal 替代) | | CameraManager | 摄像头流采集 | | MicManager | 麦克风音频采集 | | EdgeProcessor | VAD + 关键帧检测(Canvas 像素比较) | | WebSocketManager | WS 连接生命周期管理 | | ChatPanel | 消息展示、流式回复、文本输入、场景选择 | | VideoPreview | 摄像头画面预览 | -| SessionSidebar | 左侧抽屉式对话列表(搜索、重命名、删除) | -| ConfigPanel | 右侧抽屉式配置面板(主题、TTS 开关、detail level、语言、场景、账户) | +| SessionSidebar | 左侧抽屉式对话列表(搜索、重命名、删除、时间分组) | +| ConfigPanel | 右侧抽屉式配置面板(主题、TTS 开关、detail level、语言、场景、登出) | | Toast | 轻量通知提示(3 秒自动消失) | -核心 Hook:`useVisionSession()` 封装一次完整的视觉对话会话(摄像头、VAD、WebSocket、消息状态、认证、场景模式)。 +核心 Hook:`useVisionSession()` 封装一次完整的视觉对话会话(摄像头、VAD、WebSocket、消息状态、认证、场景模式)。`useSessionList()` 通过 REST API 管理对话列表 CRUD(列表、创建、删除、重命名、加载消息)。 ### 前端会话状态模型(三态) @@ -376,6 +377,19 @@ TieredManager ## 认证设计 +采用 **JWT 双 token 轮转认证机制**,结合 bcrypt 密码哈希和 Refresh Token Rotation 安全策略。详细设计见 [鉴权体系设计](./12-鉴权体系设计.md)。 + +### 核心组件 + +| 组件 | 职责 | +|------|------| +| TokenManager | JWT 生成与验证(HS256 算法) | +| AuthService | 认证业务逻辑(注册/登录/刷新/登出) | +| AuthMiddleware | Gin 中间件,校验 access_token 并注入用户信息 | +| PasswordUtil | bcrypt 密码哈希(cost=10) | + +### 认证流程 + ```mermaid sequenceDiagram participant C as 客户端 @@ -408,9 +422,45 @@ sequenceDiagram G-->>C: {access_token, refresh_token} ``` -**Token 策略**:access_token 15 分钟有效,refresh_token 7 天有效。refresh 时旧 token 失效(轮转),防止重放攻击。 +### Token 策略 -**WebSocket 认证**:连接地址 `ws://host/ws?token=&conversation_id=`。HTTP Upgrade 前校验 token,失败返回 401。 +- **access_token**:15 分钟有效,用于 API 认证和 WebSocket 连接 +- **refresh_token**:7 天有效,用于刷新 access_token +- **Refresh Token Rotation**:每次 refresh 都生成新的 token pair,旧 refresh_token 立即失效 +- **复用检测**:检测到已删除的 refresh_token 被复用时,吊销该用户的所有 refresh_token + +### 安全机制 + +1. **密码安全**:bcrypt 算法(cost=10),自动生成盐值,防彩虹表攻击 +2. **Token 安全**: + - access_token 短有效期(15 分钟),降低泄露风险 + - refresh_token 使用 SHA256 哈希存储,不存储原始 token + - Refresh Token Rotation 防重放攻击 + - 复用检测 + 自动吊销机制 +3. **传输安全**:HTTPS 强制,CORS 限制,HttpOnly Cookie 存储 refresh_token +4. **防攻击策略**: + - 防暴力破解:可选速率限制 + - 防枚举攻击:统一错误信息 + - 防 Token 泄露:复用检测 + 自动吊销 + +### WebSocket 认证 + +连接地址:`ws://host/ws?token=&conversation_id=` + +- HTTP Upgrade 前校验 token +- 校验失败返回 401 Unauthorized +- 校验成功后,user_id 和 username 注入到连接上下文 + +### 配置 + +```yaml +auth: + jwt_secret: "" # JWT 签名密钥(必须通过 CAMTALK_AUTH_JWT_SECRET 环境变量设置) + access_ttl: 15 # access_token 有效期(分钟) + refresh_ttl: 10080 # refresh_token 有效期(分钟,7天) +``` + +> **安全要求**:`JWT_SECRET` **必须**通过环境变量设置,不能写入配置文件。生产环境使用 `openssl rand -hex 32` 生成随机密钥。 ## 部署架构 diff --git a/docs/02-接口文档.md b/docs/02-接口文档.md index 820a0c4..2d06269 100644 --- a/docs/02-接口文档.md +++ b/docs/02-接口文档.md @@ -279,13 +279,27 @@ Client Server #### 认证方式 -需要认证的接口在请求头携带 JWT access token: +采用 **JWT 双 token 轮转认证机制**。详细设计见 [鉴权体系设计](./12-鉴权体系设计.md)。 +**Token 类型**: +- **access_token**:短期令牌(15 分钟),用于 API 认证和 WebSocket 连接 +- **refresh_token**:长期令牌(7 天),用于刷新 access_token + +**请求头格式**: ``` Authorization: Bearer ``` -未认证或 token 过期时返回 `401 Unauthorized`。 +**认证流程**: +1. 用户登录后获取 access_token + refresh_token +2. 请求受保护接口时携带 access_token +3. access_token 过期时,使用 refresh_token 刷新获取新的 token pair +4. refresh_token 采用轮转机制,每次刷新后旧 token 失效 + +**WebSocket 认证**: +- 连接地址:`ws://host/ws?token=&conversation_id=` +- HTTP Upgrade 前校验 token +- 校验失败返回 401 Unauthorized #### 错误响应格式 @@ -380,7 +394,7 @@ interface LoginRequest { | 400 | `INVALID_INPUT` | 请求参数缺失或格式错误 | | 401 | `INVALID_CREDENTIALS` | 用户名或密码错误 | -#### 刷新 Token +#### 刷新 Token(Refresh Token Rotation) ``` POST /api/auth/refresh @@ -397,12 +411,56 @@ interface RefreshRequest { **成功响应** `200 OK`:同 `AuthResponse` 结构(返回新的 access_token + refresh_token,旧 refresh_token 失效——Token 轮转)。 +**安全机制**: +- **Token 轮转**:每次 refresh 都会生成新的 token pair,旧 refresh_token 立即失效 +- **复用检测**:如果检测到已删除的 refresh_token 被复用,立即吊销该用户的所有 refresh_token +- **强制重新登录**:吊销后,该用户所有设备都需要重新登录 + **错误响应**: | 状态码 | code | 场景 | |--------|------|------| | 401 | `INVALID_TOKEN` | refresh_token 无效或已过期 | +**前端集成示例**: + +```typescript +// axios 响应拦截器 +api.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + // 如果是 401 且不是 refresh 请求,尝试刷新 token + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + const refreshToken = getRefreshToken(); + const response = await api.post('/api/auth/refresh', { + refresh_token: refreshToken, + }); + + const { access_token, refresh_token } = response.data; + setAccessToken(access_token); + setRefreshToken(refresh_token); + + // 重试原始请求 + originalRequest.headers.Authorization = `Bearer ${access_token}`; + return api(originalRequest); + } catch (refreshError) { + // 刷新失败,跳转登录页 + clearTokens(); + window.location.href = '/login'; + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + } +); +``` + #### 登出 ``` diff --git a/docs/12-鉴权体系设计.md b/docs/12-鉴权体系设计.md new file mode 100644 index 0000000..3c3fb74 --- /dev/null +++ b/docs/12-鉴权体系设计.md @@ -0,0 +1,544 @@ +# 鉴权体系设计 + +## 概述 + +CamTalk 采用 JWT 双 token 轮转认证机制,结合 bcrypt 密码哈希和 Refresh Token Rotation 安全策略,实现安全可靠的用户认证体系。 + +**设计原则**: +- **安全性**:access_token 短有效期(15 分钟),refresh_token 支持轮转防重放 +- **可靠性**:Refresh Token Rotation 机制,检测复用时自动吊销用户所有令牌 +- **可扩展性**:Repository 接口隔离存储层,支持内存和 PostgreSQL 双实现 + +## 整体架构 + +```mermaid +graph TB + subgraph Client["客户端"] + Browser["浏览器"] + end + + subgraph AuthModule["Auth 模块"] + Service["AuthService
Register / Login / Refresh / Logout"] + TokenMgr["TokenManager
JWT 生成与验证"] + Middleware["AuthMiddleware
Gin 中间件"] + Password["PasswordUtil
bcrypt 哈希"] + end + + subgraph Storage["存储层"] + UserRepo["UserRepository
用户数据"] + TokenStore["RefreshToken 存储
SHA256 哈希"] + end + + Browser -->|"POST /api/auth/*"| Service + Service --> TokenMgr + Service --> Password + Service --> UserRepo + Service --> TokenStore + Middleware -->|"校验 access_token"| TokenMgr + Middleware -->|"写入 user_id/username"| GinContext["Gin Context"] +``` + +## 核心组件 + +### 1. JWT 令牌管理(TokenManager) + +**文件位置**:`backend/internal/auth/jwt.go` + +#### Claims 结构 + +```go +type Claims struct { + UserID string `json:"user_id"` + Username string `json:"username"` + TokenType string `json:"token_type"` // "access" | "refresh" + jwt.RegisteredClaims +} +``` + +**字段说明**: +- `UserID`:用户唯一标识(UUID) +- `Username`:用户名 +- `TokenType`:令牌类型,用于区分 access 和 refresh token +- `RegisteredClaims`:JWT 标准声明(ExpiresAt, IssuedAt, Issuer, ID) + +#### TokenManager 配置 + +```go +type TokenManager struct { + secret []byte // JWT 签名密钥(HS256) + accessTTL time.Duration // access_token 有效期(默认 15 分钟) + refreshTTL time.Duration // refresh_token 有效期(默认 7 天) +} + +func NewTokenManager(secret string, accessTTL, refreshTTL time.Duration) *TokenManager +``` + +#### 令牌生成 + +```go +func (tm *TokenManager) GeneratePair(userID, username string) (access, refresh string, err error) +``` + +**生成逻辑**: +1. **access_token**: + - 签名算法:HS256 + - 有效期:15 分钟 + - 包含:UserID, Username, TokenType="access", ExpiresAt, IssuedAt, Issuer="camtalk" + +2. **refresh_token**: + - 签名算法:HS256 + - 有效期:7 天 + - 包含:UserID, Username, TokenType="refresh", ID=UUID(用于 DB 关联), ExpiresAt, IssuedAt, Issuer="camtalk" + +#### 令牌验证 + +```go +func (tm *TokenManager) ValidateAccess(tokenStr string) (*Claims, error) +func (tm *TokenManager) ValidateRefresh(tokenStr string) (*Claims, error) +``` + +**验证逻辑**: +1. 解析 JWT,验证签名算法为 HMAC +2. 验证签名是否有效 +3. 验证令牌是否过期 +4. 验证 TokenType 是否匹配(access 或 refresh) +5. 返回 Claims 或错误 + +#### Token 哈希 + +```go +func HashToken(token string) string +``` + +**用途**:对 refresh_token 做 SHA256 哈希后存储到数据库,避免直接存储原始 token。 + +### 2. 密码处理(PasswordUtil) + +**文件位置**:`backend/internal/auth/password.go` + +#### 密码哈希 + +```go +func HashPassword(password string) (string, error) +``` + +**实现**: +- 算法:bcrypt +- Cost:10(2^10 次迭代) +- 返回:base64 编码的哈希字符串 + +#### 密码验证 + +```go +func CheckPassword(hashedPassword, password string) error +``` + +**实现**: +- 使用 `bcrypt.CompareHashAndPassword` 验证 +- 返回 nil 表示匹配,否则返回错误 + +### 3. 认证服务(AuthService) + +**文件位置**:`backend/internal/auth/service.go` + +#### 接口定义 + +```go +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 +} +``` + +#### 注册流程(Register) + +```go +func (s *authService) Register(ctx context.Context, req RegisterRequest) (*AuthResponse, error) +``` + +**流程**: +1. 检查用户名是否已存在(`FindByUsername`) +2. 如果存在,返回 `ErrUsernameTaken` +3. 使用 bcrypt 哈希密码(`HashPassword`) +4. 创建用户记录(`Create`) +5. 生成 access_token + refresh_token(`GeneratePair`) +6. 保存 refresh_token 的 SHA256 哈希到数据库(`SaveRefreshToken`) +7. 返回 `AuthResponse` + +**错误处理**: +- `ErrUsernameTaken`:用户名已存在 +- 数据库错误:透传底层错误 + +#### 登录流程(Login) + +```go +func (s *authService) Login(ctx context.Context, req LoginRequest) (*AuthResponse, error) +``` + +**流程**: +1. 根据用户名查找用户(`FindByUsername`) +2. 如果用户不存在,返回 `ErrInvalidCredentials` +3. 验证密码(`CheckPassword`) +4. 如果密码错误,返回 `ErrInvalidCredentials` +5. 生成 access_token + refresh_token(`GeneratePair`) +6. 保存 refresh_token 的 SHA256 哈希到数据库(`SaveRefreshToken`) +7. 返回 `AuthResponse` + +**错误处理**: +- `ErrInvalidCredentials`:用户名或密码错误(统一错误信息,防止枚举攻击) + +#### 刷新令牌流程(Refresh)— Refresh Token Rotation + +```go +func (s *authService) Refresh(ctx context.Context, req RefreshRequest) (*AuthResponse, error) +``` + +**流程**: +1. 验证 refresh_token 的签名和有效期(`ValidateRefresh`) +2. 计算 refresh_token 的 SHA256 哈希(`HashToken`) +3. 在数据库中查找该哈希(`FindRefreshToken`) +4. **如果哈希不存在**: + - JWT 校验已通过但 DB 中不存在 → token 已被 rotation 删除 + - 这是 **token 复用行为**,属于安全风险 + - 吊销该用户的所有 refresh_token(`DeleteUserRefreshTokens`) + - 返回 `ErrRefreshTokenUsed` +5. 验证 token 归属的用户与 claims 一致 +6. 删除旧的 refresh_token 哈希(`DeleteRefreshToken`) +7. 生成新的 access_token + refresh_token(`GeneratePair`) +8. 保存新的 refresh_token 哈希到数据库(`SaveRefreshToken`) +9. 查询用户信息(`FindByID`) +10. 返回 `AuthResponse` + +**安全机制**: +- **Token 轮转**:每次 refresh 都会生成新的 token pair,旧 refresh_token 立即失效 +- **复用检测**:如果检测到已删除的 refresh_token 被复用,立即吊销该用户的所有 refresh_token +- **强制重新登录**:吊销后,该用户所有设备都需要重新登录 + +#### 登出流程(Logout) + +```go +func (s *authService) Logout(ctx context.Context, userID, refreshToken string) error +``` + +**流程**: +1. 计算 refresh_token 的 SHA256 哈希(`HashToken`) +2. 从数据库删除该哈希(`DeleteRefreshToken`) + +### 4. Gin 中间件(AuthMiddleware) + +**文件位置**:`backend/internal/auth/middleware.go` + +```go +func AuthMiddleware(tokenMgr *TokenManager) gin.HandlerFunc +``` + +**功能**: +1. 从请求头提取 `Authorization: Bearer ` +2. 验证 access_token(`ValidateAccess`) +3. 如果验证失败,返回 401 Unauthorized +4. 如果验证成功,将 `user_id` 和 `username` 写入 Gin Context +5. 调用 `c.Next()` 继续处理请求 + +**错误响应**: +```json +{ + "code": "INVALID_TOKEN", + "message": "missing authorization header" +} +``` + +```json +{ + "code": "INVALID_TOKEN", + "message": "invalid authorization format" +} +``` + +```json +{ + "code": "INVALID_TOKEN", + "message": "invalid or expired token" +} +``` + +**Context Key**: +- `ContextKeyUserID = "user_id"` +- `ContextKeyUsername = "username"` + +**使用示例**: +```go +// 在路由中使用中间件 +authorized := r.Group("/api") +authorized.Use(auth.AuthMiddleware(tokenMgr)) +{ + authorized.GET("/conversations", handler.ListConversations) + authorized.POST("/conversations", handler.CreateConversation) +} +``` + +## 数据模型 + +### 用户表(users) + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(64) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +``` + +### Refresh Token 表(refresh_tokens) + +```sql +CREATE TABLE refresh_tokens ( + token_hash VARCHAR(64) PRIMARY KEY, -- SHA256 哈希 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); +CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at); +``` + +## Repository 接口 + +### UserRepository + +```go +type UserRepository interface { + // Create 创建用户,返回用户 ID + Create(ctx context.Context, username, passwordHash string) (string, error) + + // FindByUsername 根据用户名查找用户 + FindByUsername(ctx context.Context, username string) (*User, error) + + // FindByID 根据 ID 查找用户 + FindByID(ctx context.Context, id string) (*User, error) + + // SaveRefreshToken 保存 refresh_token 哈希 + SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error + + // FindRefreshToken 根据 token 哈希查找用户 ID + FindRefreshToken(ctx context.Context, tokenHash string) (string, error) + + // DeleteRefreshToken 删除指定的 refresh_token + DeleteRefreshToken(ctx context.Context, tokenHash string) error + + // DeleteUserRefreshTokens 删除用户的所有 refresh_token(用于检测复用时吊销) + DeleteUserRefreshTokens(ctx context.Context, userID string) error +} +``` + +## 前端集成 + +### Token 存储 + +**推荐方案**: +- `access_token`:存储在内存中(JavaScript 变量) +- `refresh_token`:存储在 `httpOnly` Cookie 中(防止 XSS 攻击) + +**备选方案**(开发环境): +- 两者都存储在 `localStorage`(便于调试,但存在 XSS 风险) + +### 请求拦截器 + +```typescript +// axios 请求拦截器 +api.interceptors.request.use((config) => { + const accessToken = getAccessToken(); + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + return config; +}); + +// axios 响应拦截器 +api.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + // 如果是 401 且不是 refresh 请求,尝试刷新 token + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + const refreshToken = getRefreshToken(); + const response = await api.post('/api/auth/refresh', { + refresh_token: refreshToken, + }); + + const { access_token, refresh_token } = response.data; + setAccessToken(access_token); + setRefreshToken(refresh_token); + + // 重试原始请求 + originalRequest.headers.Authorization = `Bearer ${access_token}`; + return api(originalRequest); + } catch (refreshError) { + // 刷新失败,跳转登录页 + clearTokens(); + window.location.href = '/login'; + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + } +); +``` + +### WebSocket 认证 + +```typescript +// 建立 WebSocket 连接时传递 access_token +const wsUrl = `ws://${window.location.host}/ws?token=${accessToken}&conversation_id=${conversationId}`; +const ws = new WebSocket(wsUrl); + +// 连接失败时(401),触发 token 刷新 +ws.onerror = (error) => { + console.error('WebSocket connection failed'); + // 可能需要刷新 token 后重连 +}; +``` + +## 安全考虑 + +### 1. 密码安全 + +- **bcrypt 算法**:使用 bcrypt 进行密码哈希,cost factor 为 10 +- **盐值自动生成**:bcrypt 自动生成随机盐值,无需手动管理 +- **防彩虹表**:每个密码的哈希值都不同,即使密码相同 + +### 2. Token 安全 + +- **短期 access_token**:15 分钟有效期,降低泄露风险 +- **Refresh Token Rotation**:每次 refresh 都生成新 token,旧 token 立即失效 +- **复用检测**:检测到已删除的 refresh_token 被复用时,吊销该用户的所有 token +- **SHA256 哈希存储**:数据库只存储 refresh_token 的哈希值,不存储原始 token + +### 3. 传输安全 + +- **HTTPS 强制**:生产环境必须使用 HTTPS +- **CORS 限制**:配置 `AllowedOrigins` 限制允许的域名 +- **HttpOnly Cookie**:refresh_token 存储在 httpOnly Cookie 中,防止 XSS 攻击 + +### 4. 防攻击策略 + +- **防暴力破解**:可选的速率限制(`RATE_LIMITED` 错误码) +- **防枚举攻击**:登录失败时统一返回 `INVALID_CREDENTIALS`,不区分用户名不存在还是密码错误 +- **防重放攻击**:Refresh Token Rotation 确保每个 refresh_token 只能使用一次 +- **防 Token 泄露**:检测到 token 复用时,立即吊销该用户的所有 token + +## 配置说明 + +### 配置文件 + +```yaml +auth: + jwt_secret: "" # JWT 签名密钥(必须通过环境变量设置) + access_ttl: 15 # access_token 有效期(分钟) + refresh_ttl: 10080 # refresh_token 有效期(分钟,7天) +``` + +### 环境变量 + +| 环境变量 | 说明 | 示例 | +|---------|------|------| +| `CAMTALK_AUTH_JWT_SECRET` | JWT 签名密钥(必须) | `$(openssl rand -hex 32)` | +| `CAMTALK_AUTH_ACCESS_TTL` | access_token 有效期(分钟) | `15` | +| `CAMTALK_AUTH_REFRESH_TTL` | refresh_token 有效期(分钟) | `10080` | + +**安全要求**: +- `JWT_SECRET` **必须**通过环境变量设置,不能写入配置文件 +- 生产环境使用 `openssl rand -hex 32` 生成随机密钥 +- 密钥长度建议至少 32 字节(256 位) + +## 错误码 + +| 错误码 | HTTP 状态码 | 含义 | 客户端处理 | +|--------|-----------|------|-----------| +| `USERNAME_TAKEN` | 409 | 用户名已存在 | 提示换一个用户名 | +| `INVALID_CREDENTIALS` | 401 | 用户名或密码错误 | 提示检查输入 | +| `INVALID_TOKEN` | 401 | JWT 无效或已过期 | 尝试 refresh,失败则重新登录 | + +## 测试用例 + +### 单元测试 + +**文件位置**:`backend/internal/auth/jwt_test.go`, `backend/internal/auth/service_test.go` + +**测试覆盖**: +- Token 生成和验证 +- Token 过期处理 +- Refresh Token Rotation +- Token 复用检测和吊销 +- 密码哈希和验证 +- 边界条件和错误处理 + +### 集成测试 + +**测试场景**: +- 注册 → 登录 → 访问受保护资源 +- Token 刷新流程 +- Token 过期后自动刷新 +- 并发刷新 token(竞态条件) +- Token 复用检测和吊销 + +## 监控指标 + +### 关键指标 + +- **登录成功率**:登录成功次数 / 登录总次数 +- **Token 刷新率**:refresh 请求次数 / 总请求数 +- **Token 复用检测**:检测到 token 复用的次数(安全事件) +- **认证延迟**:JWT 验证的平均耗时 + +### 告警规则 + +- **Token 复用检测**:任何 token 复用事件都应触发告警 +- **异常登录失败率**:短时间内大量登录失败可能表示暴力破解攻击 +- **Token 刷新失败率**:refresh 失败率突然上升可能表示系统问题 + +## 扩展点 + +### 1. 多设备管理 + +当前实现支持同一用户在多个设备上登录(每个设备独立的 refresh_token)。可以扩展为: +- 设备列表管理 +- 单设备登录(踢出其他设备) +- 设备信任等级 + +### 2. OAuth 第三方登录 + +可以扩展 AuthService 支持 OAuth 2.0: +- Google、GitHub 等第三方登录 +- 绑定/解绑第三方账号 +- 统一的用户身份管理 + +### 3. 双因素认证(2FA) + +可以扩展为: +- TOTP(基于时间的一次性密码) +- SMS 验证码 +- 邮箱验证 + +### 4. 会话管理 + +可以扩展为: +- 活跃会话列表 +- 远程登出其他会话 +- 会话过期策略 + +## 参考资料 + +- [JWT 规范](https://tools.ietf.org/html/rfc7519) +- [bcrypt 算法](https://en.wikipedia.org/wiki/Bcrypt) +- [OWASP 认证备忘录](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html) +- [Refresh Token Rotation](https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/) diff --git a/docs/13-令牌桶限流设计.md b/docs/13-令牌桶限流设计.md new file mode 100644 index 0000000..94dca6e --- /dev/null +++ b/docs/13-令牌桶限流设计.md @@ -0,0 +1,499 @@ +# 令牌桶限流设计 + +## 概述 + +CamTalk 采用令牌桶(Token Bucket)算法实现按用户维度的速率限制,核心目标是**控制 AI 调用成本**,同时为 REST API 提供防暴力破解保护。 + +**设计原则**: +- **成本优先**:主要限流对象是 WebSocket `query` 消息(每次触发 STT + LLM + TTS 完整调用链) +- **用户隔离**:Per-user 维度限流,单用户超限不影响其他用户 +- **弹性突发**:令牌桶允许合理的突发请求,优于固定窗口的滑动限流 +- **存储适配**:内存 + Redis 双实现,单实例零依赖,多实例分布式一致 + +## 整体架构 + +```mermaid +graph TB + subgraph Entry["入口层"] + WS["WebSocket Handler
query 消息"] + REST["REST API
login / register"] + end + + subgraph LimiterModule["Rate Limiter 模块"] + Interface["Limiter 接口
Allow(userID) → (bool, retryAfter)"] + MemBucket["TokenBucket
内存令牌桶"] + RedisBucket["RedisTokenBucket
Redis 令牌桶(Lua 脚本)"] + Middleware["RateLimitMiddleware
Gin 中间件"] + end + + subgraph Storage["存储层"] + MemSync["sync.RWMutex
进程内 map"] + Redis["Redis
分布式计数"] + end + + WS -->|"限流检查"| Interface + REST -->|"中间件"| Middleware + Middleware --> Interface + Interface --> MemBucket + Interface --> RedisBucket + MemBucket --> MemSync + RedisBucket --> Redis +``` + +## 令牌桶算法 + +### 原理 + +令牌桶以固定速率向桶中添加令牌,桶有最大容量上限。每次请求消耗一个令牌,桶空时拒绝请求。 + +``` +桶容量(capacity) = 允许的突发请求数上限 +填充速率(rate) = 每秒补充的令牌数 + +时间线示例(capacity=5, rate=0.2): + t=0s 桶满 5 令牌 → 用户连续发 5 个 query 全部通过 + t=0s 桶空 → 第 6 个 query 被拒绝,retryAfter=5s + t=5s 桶补充 1 令牌 → 可再发 1 个 query + t=10s 桶补充 1 令牌 → 可再发 1 个 query +``` + +### 算法公式 + +``` +elapsed = now - lastRefill +newTokens = elapsed * rate +currentTokens = min(capacity, lastTokens + newTokens) + +if currentTokens >= 1: + currentTokens -= 1 + allowed = true +else: + allowed = false + retryAfter = (1 - currentTokens) / rate +``` + +## 核心组件 + +### 1. Limiter 接口 + +**文件位置**:`backend/internal/ratelimit/limiter.go` + +```go +// Limiter 速率限制器接口。 +type Limiter interface { + // Allow 判断 key 是否允许执行一次操作。 + // key 通常为 "userID:action" 格式。 + // 返回 (allowed, retryAfter)。retryAfter 表示需要等待的时间。 + Allow(ctx context.Context, key string) (bool, time.Duration) +} +``` + +**设计要点**: +- key 为字符串,不限定格式,由调用方决定维度(用户 ID、IP 地址等) +- 返回 `retryAfter` 供客户端/服务端设置 `Retry-After` header +- 接受 `context.Context` 支持超时和取消(Redis 实现需要) + +### 2. 内存令牌桶(TokenBucket) + +**文件位置**:`backend/internal/ratelimit/bucket.go` + +```go +// TokenBucket 内存令牌桶,适用于单实例部署。 +type TokenBucket struct { + capacity int // 桶容量 + rate float64 // 每秒填充令牌数 + tokens float64 // 当前令牌数 + lastRefill time.Time // 上次填充时间 + mu sync.Mutex +} + +// Limiter 管理多个用户的令牌桶。 +type Limiter struct { + buckets map[string]*TokenBucket + config Config + mu sync.RWMutex + stopOnce sync.Once + done chan struct{} +} +``` + +**并发安全**: +- 每个桶内部用 `sync.Mutex` 保护读写 +- 桶 map 用 `sync.RWMutex` 保护(读多写少场景) +- 用户首次请求时惰性创建桶 + +**内存回收**: +- 后台 goroutine 定期扫描,清理超过 10 分钟无活动的桶 +- 避免长期运行后内存泄漏 + +### 3. Redis 令牌桶(RedisTokenBucket) + +**文件位置**:`backend/internal/ratelimit/redis_bucket.go` + +使用 Redis Lua 脚本保证原子性,避免竞态条件: + +```lua +-- KEYS[1] = 限流 key +-- ARGV[1] = capacity(桶容量) +-- ARGV[2] = rate(每秒填充数) +-- ARGV[3] = now(当前时间戳,秒,浮点) +-- ARGV[4] = ttl(key 过期时间,秒) + +local key = KEYS[1] +local capacity = tonumber(ARGV[1]) +local rate = tonumber(ARGV[2]) +local now = tonumber(ARGV[3]) +local ttl = tonumber(ARGV[4]) + +local data = redis.call('HMGET', key, 'tokens', 'last_refill') +local tokens = tonumber(data[1]) or capacity +local last_refill = tonumber(data[2]) or now + +-- 计算新令牌 +local elapsed = math.max(0, now - last_refill) +tokens = math.min(capacity, tokens + elapsed * rate) + +local allowed = 0 +local retry_after = 0 + +if tokens >= 1 then + tokens = tokens - 1 + allowed = 1 +else + retry_after = (1 - tokens) / rate +end + +-- 回写状态 +redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now) +redis.call('EXPIRE', key, ttl) + +return {allowed, tostring(retry_after)} +``` + +**设计要点**: +- 每个用户的限流状态存储为一个 Redis Hash(`tokens` + `last_refill`) +- TTL 自动过期,无需手动清理 +- Lua 脚本保证"读取-计算-回写"原子执行 + +### 4. Gin 中间件 + +**文件位置**:`backend/internal/ratelimit/middleware.go` + +```go +// RateLimitMiddleware 返回 Gin 中间件,按 key 维度限流。 +// keyFunc 从请求中提取限流 key(如 IP、用户 ID)。 +func RateLimitMiddleware(limiter Limiter, keyFunc func(*gin.Context) string) gin.HandlerFunc +``` + +**使用方式**: + +```go +// 按 IP 限流(登录/注册,未登录用户无 userID) +loginGroup.POST("/login", + ratelimit.Middleware(limiter, func(c *gin.Context) string { + return c.ClientIP() + ":login" + }), + authHandler.Login, +) + +// 按用户 ID 限流(已认证的 API) +authorized.POST("/conversations", + ratelimit.Middleware(limiter, func(c *gin.Context) string { + return c.GetString("user_id") + ":conversation" + }), + convHandler.Create, +) +``` + +**错误响应**: + +REST API 返回 HTTP 429: + +```json +{ + "code": "RATE_LIMITED", + "message": "too many requests, retry after 5s" +} +``` + +同时设置 `Retry-After` header: + +``` +HTTP/1.1 429 Too Many Requests +Retry-After: 5 +``` + +## 限流接入点 + +### WebSocket query 消息(核心) + +在 `ws/handler.go` 的 `case "query"` 分支中,orchestrator 调用前检查: + +```go +case "query": + // ... 解析消息 ... + + // 限流检查 + if limiter != nil { + allowed, retryAfter := limiter.Allow(ctx, userID+":query") + if !allowed { + errors.SendWSError(client, errors.CodeRateLimited, msg.RequestID, + fmt.Errorf("rate limited, retry after %s", retryAfter)) + continue + } + } + + // ... 继续处理 query ... +``` + +### REST API 登录/注册 + +在 `api/auth.go` 的路由注册中添加中间件: + +```go +func (h *AuthHandler) RegisterRoutes(rg *gin.RouterGroup, limiter ratelimit.Limiter) { + auth := rg.Group("/auth") + if limiter != nil { + auth.POST("/register", + ratelimit.Middleware(limiter, ipKeyFunc("register")), + h.Register, + ) + auth.POST("/login", + ratelimit.Middleware(limiter, ipKeyFunc("login")), + h.Login, + ) + } else { + auth.POST("/register", h.Register) + auth.POST("/login", h.Login) + } + auth.POST("/refresh", h.Refresh) + auth.POST("/logout", h.Logout) +} +``` + +### 不限流的端点 + +| 端点 | 原因 | +|------|------| +| `ping` / `pong` | 心跳保活,无 AI 调用成本 | +| `config` | 配置更新,无 AI 调用成本 | +| `interrupt` | 中断请求,取消操作不应被限流 | +| `GET /api/health` | 健康检查,运维必需 | +| `POST /api/auth/refresh` | Token 刷新,限流会导致用户被迫重新登录 | +| `POST /api/auth/logout` | 登出,限流会导致用户无法正常退出 | +| `GET /api/conversations` | 查询列表,无 AI 调用成本 | + +## 配置设计 + +### 配置文件 + +```yaml +# config.yaml 新增 +ratelimit: + enabled: true + # WebSocket query 消息限流(核心,控制 AI 成本) + query: + capacity: 10 # 突发容量:允许连续发 10 个 query + rate: 0.2 # 填充速率:每 5 秒补充 1 个令牌 + # REST API 登录限流(防暴力破解) + login: + capacity: 5 # 突发容量:允许连续 5 次登录尝试 + rate: 0.1 # 填充速率:每 10 秒补充 1 次 + # REST API 注册限流 + register: + capacity: 3 # 突发容量:允许连续 3 次注册 + rate: 0.05 # 填充速率:每 20 秒补充 1 次 +``` + +### 配置结构体 + +```go +// config/config.go 新增 + +type RateLimitConfig struct { + Enabled bool `mapstructure:"enabled"` + Query BucketConfig `mapstructure:"query"` + Login BucketConfig `mapstructure:"login"` + Register BucketConfig `mapstructure:"register"` +} + +type BucketConfig struct { + Capacity int `mapstructure:"capacity"` // 桶容量(突发上限) + Rate float64 `mapstructure:"rate"` // 每秒填充令牌数 +} +``` + +### 默认值 + +```go +// setDefaults 新增 +v.SetDefault("ratelimit.enabled", false) +v.SetDefault("ratelimit.query.capacity", 10) +v.SetDefault("ratelimit.query.rate", 0.2) +v.SetDefault("ratelimit.login.capacity", 5) +v.SetDefault("ratelimit.login.rate", 0.1) +v.SetDefault("ratelimit.register.capacity", 3) +v.SetDefault("ratelimit.register.rate", 0.05) +``` + +### 参数选择建议 + +| 场景 | capacity | rate | 含义 | +|------|----------|------|------| +| WebSocket query | 10 | 0.2 | 突发 10 个,之后每 5 秒 1 个 | +| 登录 | 5 | 0.1 | 突发 5 次,之后每 10 秒 1 次 | +| 注册 | 3 | 0.05 | 突发 3 次,之后每 20 秒 1 次 | + +> **调参原则**:capacity 决定"能忍多少次突发",rate 决定"稳态下多久能再请求一次"。query 的 rate 建议根据 AI 调用成本和目标月预算反推。 + +## 依赖注入 + +### main.go 初始化 + +```go +// 初始化限流器 +var limiter ratelimit.Limiter +if cfg.RateLimit.Enabled { + if rdb != nil { + // 多实例:使用 Redis 令牌桶 + limiter = ratelimit.NewRedisLimiter(rdb, cfg.RateLimit) + logger.Log.Info("rate limiter initialized with Redis backend") + } else { + // 单实例:使用内存令牌桶 + limiter = ratelimit.NewLimiter(cfg.RateLimit) + logger.Log.Info("rate limiter initialized with in-memory backend") + } + defer limiter.Stop() +} +``` + +### 注入到各模块 + +```go +// WebSocket Handler —— 新增 limiter 参数 +r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr, limiter)) + +// Auth REST —— 新增 limiter 参数 +authHandler := api.NewAuthHandler(authService, tokenMgr) +authHandler.RegisterRoutes(apiGroup, limiter) +``` + +## 错误码 + +复用已有错误码 `RATE_LIMITED`(`backend/internal/errors/codes.go`): + +| 传输层 | HTTP 状态码 | 错误格式 | +|--------|-----------|---------| +| REST API | 429 Too Many Requests | `{code: "RATE_LIMITED", message: "too many requests, retry after Xs"}` | +| WebSocket | — | `{type: "error", code: "RATE_LIMITED", request_id: "...", message: "..."}` | + +## 文件结构 + +``` +backend/internal/ratelimit/ +├── limiter.go # Limiter 接口 + Config 类型定义 +├── bucket.go # 内存令牌桶实现 +├── bucket_test.go # 内存令牌桶单元测试 +├── redis_bucket.go # Redis 令牌桶实现(Lua 脚本) +├── redis_bucket_test.go# Redis 令牌桶单元测试 +└── middleware.go # Gin 中间件 +``` + +## 测试用例 + +### 单元测试 + +**内存令牌桶**(`bucket_test.go`): +- 首次请求通过 +- 连续消耗至桶空 +- 桶空后拒绝,返回正确 retryAfter +- 等待后令牌补充,请求通过 +- 并发安全性(多个 goroutine 同时 Allow) +- 桶容量边界(capacity=0, capacity=1) +- 填充速率边界(rate=0, rate 极大值) +- 不活跃桶的内存回收 + +**Redis 令牌桶**(`redis_bucket_test.go`): +- 与内存实现行为一致性 +- Lua 脚本原子性 +- key TTL 自动过期 +- 并发安全性(多个客户端同时请求) + +### 集成测试 + +- 限流关闭时不拦截请求 +- 限流开启后,REST API 登录超限返回 429 +- 限流开启后,WebSocket query 超限返回 `RATE_LIMITED` 错误 +- 单实例内存限流 vs 多实例 Redis 限流行为一致 +- 重启后内存限流重置,Redis 限流保持 + +## 扩展点 + +### 1. 多级限流 + +可扩展为多级限流策略: + +``` +全局限流(全用户共享) → 用户级限流(当前实现) → 端点级限流(不同 API 不同限制) +``` + +### 2. 动态调参 + +通过配置热更新或管理 API 动态调整限流参数,无需重启: + +```go +// 预留接口 +type DynamicLimiter interface { + Limiter + UpdateConfig(action string, cfg BucketConfig) error +} +``` + +### 3. 按用户等级差异化 + +不同用户等级使用不同的限流参数: + +```yaml +ratelimit: + query: + capacity: 10 # 免费用户 + rate: 0.2 + query_premium: + capacity: 30 # 付费用户 + rate: 1.0 +``` + +### 4. 滑动窗口限流 + +令牌桶适合允许突发的场景。如果需要更平滑的限流,可增加滑动窗口实现: + +```go +type SlidingWindowLimiter struct { + windowSize time.Duration + maxRequests int +} +``` + +### 5. 分布式全局限流 + +当前 Redis 实现是 Per-Instance 独立计数。如需全局精确限流,可改为 Redis 全局计数器(所有实例共享同一个 key)。 + +## 监控指标 + +### 关键指标 + +- **限流触发率**:被拒绝请求数 / 总请求数 +- **各端点限流分布**:query / login / register 各自的触发率 +- **等待时长分布**:retryAfter 的 P50/P99 +- **桶状态**:各用户桶的平均令牌数(反映使用模式) + +### 告警规则 + +- **限流触发率突增**:可能表示异常流量或攻击 +- **单用户持续被限流**:可能表示客户端 bug(死循环请求) + +## 参考资料 + +- [Token Bucket 算法](https://en.wikipedia.org/wiki/Token_bucket) +- [Redis Rate Limiting](https://redis.io/glossaries/rate-limiting/) +- [Cloudflare - How we built rate limiting capable of scaling to millions of domains](https://blog.cloudflare.com/counting-things-a-lot-of-different-things/) diff --git a/docs/README.md b/docs/README.md index a898a9a..00b707d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,8 @@ CamTalk 是一款多模态实时 AI 视觉对话助手。用户通过摄像头 | [09-技术名词解释](09-技术名词解释.md) | 前端/后端/AI 服务/Eino 框架技术名词简明解释 | | [10-Eino重构方案](10-Eino重构方案.md) | Eino Graph 替换手写 goroutine 管道的设计方案 | | [11-Eino框架技术文档](11-Eino框架技术文档.md) | Eino 框架在 CamTalk 中的使用指南(Graph、Lambda、Callback、State) | +| [12-鉴权体系设计](12-鉴权体系设计.md) | JWT 双 token 轮转认证、bcrypt 密码哈希、Refresh Token Rotation、安全机制 | +| [13-令牌桶限流设计](13-令牌桶限流设计.md) | 令牌桶限流算法、内存/Redis 双实现、Gin 中间件、WebSocket query 限流、配置设计 | ## 推荐阅读顺序 @@ -28,4 +30,6 @@ CamTalk 是一款多模态实时 AI 视觉对话助手。用户通过摄像头 5. **05~07** — 各技术领域的详细设计 6. **09-技术名词解释** — 遇到不熟悉的名词时查阅 7. **10~12** — Eino 重构相关(方案、框架文档、实施记录) +8. **12-鉴权体系设计** — 认证授权机制详细设计(JWT、bcrypt、Refresh Token Rotation) +9. **13-令牌桶限流设计** — 速率限制设计(令牌桶算法、成本控制、防暴力破解) diff --git a/docs/conversation-history-bug-analysis.md b/docs/conversation-history-bug-analysis.md deleted file mode 100644 index 965d942..0000000 --- a/docs/conversation-history-bug-analysis.md +++ /dev/null @@ -1,246 +0,0 @@ -## CamTalk 对话历史功能 — Bug 分析与修复方案 - -### 一、整体架构现状 - -当前对话历史系统存在一个**根本性的架构缺陷**:前端和后端各自维护了一套完全独立的会话管理系统,两者之间从未同步。 - -**前端**:`useSessionList` Hook + `localStorage` 管理会话列表和消息存储。会话 ID 由前端 `uuid` 生成,消息通过 `localStorage` 持久化。 - -**后端**:`MemoryManager` (内存) + `PgSessionRepository` / `PgMessageRepository` (PostgreSQL) 管理会话和消息。会话 ID 由后端 `uuid.New()` 生成。 - -前端 `api.ts` 中没有任何对话相关的 REST API 调用,后端提供的 `/api/conversations` 全套接口(List / Create / Get / Patch / Delete / GetMessages)完全未被前端使用。 - ---- - -### 二、Bug 清单 - -#### P0 — 严重级别 - -**Bug 1:前后端会话系统完全脱节** - -前端创建会话(`useSessionList.createSession`)只在 localStorage 中写入一条 `SessionSummary`,后端完全不知道这个会话的存在。后端在 WebSocket 连接时创建的会话(`ws/handler.go` L148)有独立的 ID,前端也无法感知。两套 ID 体系互不关联,导致: - -- 前端切换/删除会话无法影响后端 -- 后端消息持久化到 PG 但前端无法读取 -- 对话历史不能跨设备、跨浏览器同步 -- 清除浏览器数据后所有历史丢失 - -**Bug 2:活跃对话中创建/切换会话导致后端消息写入错误会话** - -复现步骤: -1. 用户在会话 A 中正在对话(WebSocket 已连接,后端 sessionID = A) -2. 用户点击"新建对话" -3. 前端 `handleNewSession` 调用 `createSession()` 创建前端会话 B,调用 `setMessages([])` 清空 UI -4. 由于 `connectionStatus === "connected"`,调用 `stopSession()` 断开 WebSocket -5. 用户在新 UI 中发送消息,前端显示在"新对话"下 -6. 但 WebSocket 重连后,后端创建了一个**全新的**会话 C - -结果:前端认为是会话 B,后端实际是会话 C。如果 `stopSession` 未执行(连接状态判断时序问题),消息甚至会写入旧会话 A。 - -**Bug 3:刷新页面后 activeSessionId 丢失,消息无法自动保存** - -`useSessionList` 中 `activeSessionId` 初始值为 `null`,且不会从 localStorage 恢复: - -```typescript -const [activeSessionId, setActiveSessionId] = useState(null); -``` - -初始化逻辑(`App.tsx` L122-129)仅在 `sessions.length === 0` 时调用 `createSession()`。对于回访用户(sessions 不为空),`activeSessionId` 保持 `null`。 - -自动保存的 `useEffect`(L136-140)需要 `activeSessionId` 非 null: - -```typescript -if (activeSessionId && messages.length > 0) { - persistSession(activeSessionId, messages); -} -``` - -结果:回访用户如果不点击侧边栏选择会话,所有新消息不会被持久化,刷新页面即丢失。 - -#### P1 — 重要级别 - -**Bug 4:切换会话时强制断开 WebSocket,用户体验差** - -`handleSelectSession` 和 `handleNewSession` 都调用 `stopSession()`,而 `stopSession` 会断开 WebSocket 连接。每次切换会话都需要重新建立连接(TCP 握手 + JWT 认证 + VAD 初始化),增加约 1-3 秒延迟。 - -正确做法应该是在切换会话时保持 WebSocket 连接,仅在后端切换 sessionID(通过发送 `conversation_id` 参数重连,或者在协议中增加切换会话的消息类型)。 - -**Bug 5:前端 historyRef 是无效的死代码** - -`useVisionSession` 中的 `historyRef`(L48)被维护但从未被实际使用: - -```typescript -const historyRef = useRef>([]); -``` - -它被 push(`llm_done` 时 L258、`sendTextMessage` 时 L466、`interrupt` 时 L417),但从未被读取或发送到后端。前端的 LLM 上下文完全由后端 `session.Manager.GetHistory` 独立管理。这段代码增加了维护负担却没有任何功能价值。 - -**Bug 6:VAD 语音输入时用户消息未加入 historyRef** - -`onSpeechEnd` 回调(L186-228)添加了用户消息到 `messages` state,但从未 push 到 `historyRef`。同样,`stt_result` 处理器(L236-249)更新消息文本后也未同步到 `historyRef`。 - -虽然 `historyRef` 本身是死代码(Bug 5),但如果未来要利用它,这个遗漏会造成语音消息在前端历史中缺失。 - -**Bug 7:观察模式消息未加入 historyRef** - -`useObservationMode` 的 `onChange` 回调(L82-107)添加了用户消息但未 push 到 `historyRef`。同 Bug 6。 - -#### P2 — 一般级别 - -**Bug 8:ChatPanel 使用数组 index 作为 React key** - -```tsx -{messages.map((msg, index) => ( -
-``` - -当消息列表动态变化时(如 STT 结果更新替换了占位消息),使用 index 作为 key 可能导致 React 无法正确 diff,出现闪烁或渲染异常。应使用稳定唯一的 ID(如 `timestamp` 或生成 UUID)。 - -**Bug 9:后端 AppendMessage 中 tokensUsed 始终为 0** - -`MemoryManager.AppendMessage` 异步写 PG 时硬编码 `tokensUsed` 为 0: - -```go -if err := m.msgRepo.SaveMessage(context.Background(), sessionID, msg, 0); err != nil { -``` - -`WsLLMDone` 中的 `tokens_used` 信息未被传递到持久化层,导致 PG 中所有消息的 token 统计均为 0。 - -**Bug 10:后端 WS Handler 与 Eino 编排器重复获取历史** - -`handler.go` L240 获取了 `history` 并传给 `ProcessQuery`,但 `ProcessQuery`(`adapter.go`)内部并未使用这个参数。Eino Graph 的 History 节点(`nodes_history.go`)会自己重新调用 `sessionMgr.GetHistory`。传入的 `history` 参数被浪费了一次查询。 - -**Bug 11:后端 GetMessages 内存 fallback 的 beforeID 语义不一致** - -PostgreSQL 实现中 `beforeID` 是消息 ID 游标(`WHERE id < $2`),而内存 fallback 将其当作数组索引偏移量: - -```go -if beforeID > 0 && int(beforeID) <= total { - allMessages = allMessages[:beforeID] -} -``` - -两种实现的语义完全不同,切换存储后端时分页行为会不一致。 - ---- - -### 三、修复方案 - -#### 方案核心思路 - -将前端会话管理从 localStorage 迁移到后端 API,实现单一数据源。前端变为"薄客户端",会话 CRUD 和消息持久化全部走后端 `/api/conversations` 接口。 - -#### Phase 1:前端对接后端 API(解决 P0 Bug 1/2/3) - -**1.1 在 api.ts 中增加对话 API 封装** - -```typescript -// 新增对话 API -export async function listConversations(token: string, page = 1, size = 20) { ... } -export async function createConversation(token: string, config?: SessionConfig) { ... } -export async function getConversationMessages(token: string, id: string) { ... } -export async function deleteConversation(token: string, id: string) { ... } -export async function renameConversation(token: string, id: string, title: string) { ... } -``` - -**1.2 重写 useSessionList Hook** - -将所有 CRUD 操作从 localStorage 切换到后端 API: - -- `createSession` → `POST /api/conversations` -- `deleteSession` → `DELETE /api/conversations/:id` -- `renameSession` → `PATCH /api/conversations/:id` -- `selectSession` → `GET /api/conversations/:id/messages` -- 初始化时 → `GET /api/conversations` 加载列表 -- 移除 `saveSessionMessages` / `loadSessionMessages` 等 localStorage 操作 -- 将 `activeSessionId` 持久化到 localStorage(仅用于恢复选中状态) - -**1.3 初始化逻辑修复** - -```typescript -useEffect(() => { - if (!initializedRef.current) { - initializedRef.current = true; - if (sessions.length === 0) { - createSession(); - } else { - // 恢复上次选中的会话 - const lastId = localStorage.getItem('camtalk:last_active_session'); - if (lastId && sessions.find(s => s.id === lastId)) { - setActiveSessionId(lastId); - } - } - } -}, [sessions.length, createSession]); -``` - -#### Phase 2:WebSocket 会话切换(解决 P0 Bug 2, P1 Bug 4) - -**2.1 WebSocket 连接增加 conversation_id 参数** - -后端已支持 `conversation_id` 查询参数(`handler.go` L126-133),前端需要在 `connect` 时传入当前会话 ID: - -```typescript -connect(token?: string, conversationId?: string): void { - const params = new URLSearchParams(); - if (token) params.set('token', token); - if (conversationId) params.set('conversation_id', conversationId); - const url = `${WS_URL}?${params.toString()}`; - // ... -} -``` - -**2.2 切换会话时保持连接** - -在 `handleSelectSession` 中,不再调用 `stopSession()`,而是: -1. 保存当前会话消息到后端(如果需要) -2. 断开当前 WebSocket -3. 用新会话 ID 重新连接 - -或者更优方案:在 WebSocket 协议中增加 `switch_session` 消息类型,允许在保持连接的情况下切换后端会话。 - -#### Phase 3:清理前端冗余代码(解决 P1 Bug 5/6/7, P2 Bug 8) - -**3.1 移除 historyRef** - -删除 `useVisionSession` 中的 `historyRef` 及其所有 push 操作。前端不再维护独立的 LLM 上下文历史,完全依赖后端。 - -**3.2 消息列表使用稳定 key** - -将 `ChatMessage` 类型增加 `id` 字段(UUID),在创建消息时生成,用作文本 diff 和 React key。 - -```typescript -export interface ChatMessage { - id: string; // 新增 - role: "user" | "assistant" | "system"; - content: string; - // ... -} -``` - -#### Phase 4:后端修复(解决 P2 Bug 9/10/11) - -**4.1 传递 tokensUsed 到持久化层** - -修改 `AppendMessage` 接口,增加 `tokensUsed` 参数;或在 `EinoOrchestrator.ProcessQuery` 中,在 `llm_done` 后单独调用一次 `UpdateMessageMeta` 更新 token 信息。 - -**4.2 移除 WS Handler 中多余的 GetHistory 调用** - -删除 `handler.go` L240 的 `history` 获取,同时从 `ProcessQuery` 签名中移除 `history` 参数。 - -**4.3 统一 GetMessages beforeID 语义** - -内存 fallback 中改为基于消息序号的偏移量,或直接移除内存 fallback(生产环境始终使用 PG)。 - ---- - -### 四、实施优先级 - -| 优先级 | 修复项 | 预估工作量 | -|--------|--------|-----------| -| P0 | 前端对接后端 API + 初始化修复 | 2-3 天 | -| P0 | WebSocket 会话切换 | 1-2 天 | -| P1 | 清理 historyRef 死代码 | 0.5 天 | -| P2 | React key + tokensUsed + GetMessages | 1 天 | - -总计约 5-7 天可完成全部修复。Phase 1 是核心,完成后对话历史功能即可正常工作。