Merge pull request 'docs: 更新架构设计和令牌桶限流相关文档' (#159) from develop into v2
All checks were successful
Deploy / deploy (push) Successful in 13s

Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/159
This commit was merged in pull request #159.
This commit is contained in:
2026-06-20 20:54:51 +08:00
8 changed files with 1205 additions and 281 deletions

View File

@@ -73,7 +73,7 @@ go vet ./... # 静态分析
- `POST /api/auth/logout` — 登出 - `POST /api/auth/logout` — 登出
- `GET /api/conversations` — 对话列表 - `GET /api/conversations` — 对话列表
- `POST /api/conversations` — 创建对话 - `POST /api/conversations` — 创建对话
- `GET/PUT/PATCH/DELETE /api/conversations/:id` — 对话 CRUD - `GET/PATCH/DELETE /api/conversations/:id` — 对话详情/改标题/删除
- `GET /api/conversations/:id/messages` — 获取对话消息 - `GET /api/conversations/:id/messages` — 获取对话消息
## 错误码 ## 错误码
@@ -84,18 +84,19 @@ go vet ./... # 静态分析
| 组件 | 职责 | | 组件 | 职责 |
|------|------| |------|------|
| `AuthPage` | 登录/注册表单 | | `LandingPage` | 未登录时的着陆页,内嵌 LoginModal 登录/注册弹窗 |
| `AuthPage` | 登录/注册表单(备用) |
| `CameraManager` | 摄像头流采集 | | `CameraManager` | 摄像头流采集 |
| `MicManager` | 麦克风音频采集 | | `MicManager` | 麦克风音频采集 |
| `EdgeProcessor` | VAD + 关键帧检测Canvas 像素比较) | | `EdgeProcessor` | VAD + 关键帧检测Canvas 像素比较) |
| `WebSocketManager` | WebSocket 连接生命周期管理 | | `WebSocketManager` | WebSocket 连接生命周期管理 |
| `ChatPanel` | 消息展示、流式回复、文本输入、场景选择 | | `ChatPanel` | 消息展示、流式回复、文本输入、场景选择 |
| `VideoPreview` | 摄像头画面预览 | | `VideoPreview` | 摄像头画面预览 |
| `SessionSidebar` | 左侧抽屉式对话列表 | | `SessionSidebar` | 左侧抽屉式对话列表(搜索、重命名、删除、时间分组) |
| `ConfigPanel` | 右侧抽屉式配置面板 | | `ConfigPanel` | 右侧抽屉式配置面板主题、TTS、语言、场景、登出 |
| `Toast` | 轻量通知提示 | | `Toast` | 轻量通知提示 |
核心 Hook`useVisionSession()` 封装一次完整的视觉对话会话。 核心 Hook`useVisionSession()` 封装一次完整的视觉对话会话。`useSessionList()` 管理对话列表 CRUD通过 REST API
## 后端模块结构 ## 后端模块结构

View File

@@ -34,6 +34,8 @@ graph TB
B2[Session Manager] B2[Session Manager]
B3[AI Orchestrator] B3[AI Orchestrator]
B4[REST API] B4[REST API]
B5[Auth 模块]
B6[Store 层]
end end
subgraph cloud[云端 AI 服务] subgraph cloud[云端 AI 服务]
@@ -54,9 +56,9 @@ graph TB
|------|------| |------|------|
| 前端 | React 18, TypeScript, Vite, @ricky0123/vad-web | | 前端 | React 18, TypeScript, Vite, @ricky0123/vad-web |
| 后端 | Go, Gin, gorilla/websocket, Viper, Zap | | 后端 | Go, Gin, gorilla/websocket, Viper, Zap |
| STT | Deepgram默认 / MiMo ASR | | STT | MiMo ASR默认 / Deepgram |
| LLM | GPT-4o默认通过 OpenAI 兼容接口可切换 | | LLM | DashScope qwen3-vl-plus默认通过 eino-ext OpenAI ChatModel 接入 |
| TTS | OpenAI TTS默认 / MiMo TTS | | TTS | MiMo TTS默认 / OpenAI TTS |
## 项目结构 ## 项目结构
@@ -65,38 +67,49 @@ CamTalk/
├── frontend/ # 浏览器客户端 ├── frontend/ # 浏览器客户端
│ └── src/ │ └── src/
│ ├── components/ # UI 组件 │ ├── components/ # UI 组件
│ │ ├── LandingPage/ # 登录着陆页 + LoginModal
│ │ ├── AuthPage/ # 登录/注册表单
│ │ ├── CameraManager/ # 摄像头流采集 │ │ ├── CameraManager/ # 摄像头流采集
│ │ ├── MicManager/ # 麦克风音频采集 │ │ ├── MicManager/ # 麦克风音频采集
│ │ ├── EdgeProcessor/ # VAD + 关键帧检测 │ │ ├── EdgeProcessor/ # VAD + 关键帧检测
│ │ ├── WebSocketManager/ # WS 连接管理 │ │ ├── WebSocketManager/ # WS 连接管理
│ │ ├── ChatPanel/ # 消息展示 │ │ ├── ChatPanel/ # 消息展示
│ │ ├── VideoPreview/ # 摄像头画面预览 │ │ ├── VideoPreview/ # 摄像头画面预览
│ │ ├── SessionSidebar/ # 对话历史侧边栏
│ │ ├── ConfigPanel/ # 配置面板 │ │ ├── ConfigPanel/ # 配置面板
│ │ └── Toast/ # 通知提示 │ │ └── Toast/ # 通知提示
│ ├── hooks/ # 自定义 Hooks │ ├── hooks/ # 自定义 Hooks
│ │ ├── useVisionSession.ts # 核心会话 Hook │ │ ├── useVisionSession.ts # 核心会话 Hook
│ │ ├── useSessionList.ts # 对话列表管理
│ │ └── useObservationMode.ts # 观察模式 │ │ └── useObservationMode.ts # 观察模式
│ ├── lib/ # 工具库 │ ├── lib/ # 工具库
│ │ ├── websocket.ts # WebSocket 连接管理 │ │ ├── websocket.ts # WebSocket 连接管理
│ │ ├── api.ts # REST API 客户端
│ │ ├── auth.tsx # 认证上下文JWT 管理)
│ │ ├── audio.ts # 音频编码 │ │ ├── audio.ts # 音频编码
│ │ ├── ttsPlayer.ts # TTS 播放器 │ │ ├── ttsPlayer.ts # TTS 播放器
│ │ ├── i18n/ # 国际化zh-CN/en-US/ja-JP
│ │ └── sampling.ts # 采样策略 │ │ └── sampling.ts # 采样策略
│ └── types/ # TypeScript 类型定义 │ └── types/ # TypeScript 类型定义
├── backend/ # Go 网关 ├── backend/ # Go 网关
│ ├── cmd/server/ # 入口 │ ├── cmd/server/ # 入口
│ └── internal/ │ └── internal/
│ ├── ai/ # AI 服务抽象层 │ ├── ai/ # AI 服务抽象层
│ │ ├── llm/ # LLM 服务OpenAI 兼容) │ │ ├── llm/ # LLM 提示词与场景
│ │ ├── stt/ # STT 服务Deepgram/MiMo │ │ ├── stt/ # STT 服务(MiMo/Deepgram
│ │ └── tts/ # TTS 服务OpenAI/MiMo │ │ └── tts/ # TTS 服务(MiMo/OpenAI
│ ├── orchestrator/ # AI 编排器STT→LLM→TTS 管道 │ ├── eino/ # Eino Graph 编排层7 节点 DAG
│ ├── session/ # 会话管理Memory/Redis │ ├── orchestrator/ # Orchestrator 接口
│ ├── session/ # 会话管理三级存储Memory/Redis/PG
│ ├── store/ # 持久化层Repository 接口 + PG/内存实现)
│ ├── auth/ # 认证JWT、bcrypt、中间件
│ ├── ws/ # WebSocket Handler │ ├── ws/ # WebSocket Handler
│ ├── api/ # REST API │ ├── api/ # REST APIAuth/Conversation
│ ├── config/ # 配置管理 │ ├── config/ # 配置管理
│ ├── models/ # 数据模型 │ ├── models/ # 数据模型
│ ├── errors/ # 错误码 │ ├── errors/ # 错误码
│ └── logger/ # 日志 │ └── logger/ # 日志
├── migrations/ # 数据库迁移(嵌入式 SQL
├── docs/ # 设计文档 ├── docs/ # 设计文档
└── CLAUDE.md # Claude Code 指引 └── CLAUDE.md # Claude Code 指引
``` ```
@@ -106,7 +119,7 @@ CamTalk/
### 前置条件 ### 前置条件
- Node.js >= 18 - Node.js >= 18
- Go >= 1.24 - Go >= 1.25
### 前端 ### 前端
@@ -147,20 +160,21 @@ go run ./cmd/server
**客户端 → 服务端**`query``config``interrupt``ping` **客户端 → 服务端**`query``config``interrupt``ping`
**服务端 → 客户端**`connected``stt_result``llm_chunk``llm_done``tts_audio``error``pong` **服务端 → 客户端**`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) | 项目目标与核心挑战 | | [01-架构设计](docs/01-架构设计.md) | 三层架构、技术栈、数据库设计、部署方案 |
| [02-系统架构](docs/02-系统架构.md) | 三层架构、技术栈、部署方案 | | [02-接口文档](docs/02-接口文档.md) | WebSocket 协议、REST API、AI 服务层、编排器、配置管理 |
| [03-接口文档](docs/03-接口文档.md) | WebSocket 协议、REST API、配置管理 | | [03-技术选型](docs/03-技术选型.md) | AI 服务栈、持久化层、前端边缘处理选型 |
| [04-技术选型](docs/04-技术选型.md) | AI 服务栈、持久化层、前端边缘处理选型 | | [04-用户故事](docs/04-用户故事.md) | 用户场景与优先级 |
| [05-用户故事](docs/05-用户故事.md) | 用户场景与优先级 | | [05-语音交互](docs/05-语音交互.md) | VAD → STT → LLM → TTS 全链路 |
| [06-语音交互](docs/06-语音交互.md) | VAD → STT → LLM → TTS 全链路 | | [06-视觉理解](docs/06-视觉理解.md) | 帧采样、关键帧检测、多模态输入 |
| [07-视觉理解](docs/07-视觉理解.md) | 采样、关键帧检测、多模态输入 | | [07-成本控制](docs/07-成本控制.md) | 采样策略、端云协同、模型分级 |
| [08-成本控制](docs/08-成本控制.md) | 采样策略、端云协同、模型分级 | | [08-功能创意](docs/08-功能创意.md) | 功能创意与规划 |
| [对话历史技术设计](docs/conversation-history-technical-design.md) | 对话历史功能的前端技术方案 |
## License ## License

View File

@@ -199,7 +199,7 @@ graph LR
| 模块 | 职责 | | 模块 | 职责 |
|------|------| |------|------|
| WebSocket Handler | 管理客户端连接生命周期JWT 认证conversation_id 恢复,单播消息推送 | | WebSocket Handler | 管理客户端连接生命周期JWT 认证conversation_id 恢复,单播消息推送 |
| Session Manager | 维护用户会话状态、对话历史。Memory(默认)/ Redis(可切换30 分钟 TTLWrite-Through 到 PG | | Session Manager | 维护用户会话状态、对话历史。三级存储(Memory Redis → PostgreSQL30 分钟 TTLWrite-Through 到 PG |
| Eino 编排层 | 基于 CloudWeGo Eino Graph 的声明式 AI 编排。7 节点 DAGSTT→History→ChatModel→Msg2Str→Splitter→TTS→DoneStream 模式调用Callback 实现 LLM token 实时推送 | | Eino 编排层 | 基于 CloudWeGo Eino Graph 的声明式 AI 编排。7 节点 DAGSTT→History→ChatModel→Msg2Str→Splitter→TTS→DoneStream 模式调用Callback 实现 LLM token 实时推送 |
| AI Orchestrator | `EinoOrchestrator` 适配器,包装 Eino Graph 实现 `Orchestrator` 接口。context 取消 + 超时控制 | | AI Orchestrator | `EinoOrchestrator` 适配器,包装 Eino Graph 实现 `Orchestrator` 接口。context 取消 + 超时控制 |
| AI Service Layer | AI 服务抽象层,多 provider 支持Deepgram/MiMo/OpenAI 等) | | AI Service Layer | AI 服务抽象层,多 provider 支持Deepgram/MiMo/OpenAI 等) |
@@ -210,24 +210,25 @@ graph LR
| Models | 数据模型定义 | | Models | 数据模型定义 |
| Migrations | 数据库版本化迁移,嵌入式 SQL 文件自动执行 | | Migrations | 数据库版本化迁移,嵌入式 SQL 文件自动执行 |
| Model Router | 根据请求类型选择 AI 模型(待实现) | | Model Router | 根据请求类型选择 AI 模型(待实现) |
| Rate Limiter | 令牌桶限流(待实现) | | Rate Limiter | 令牌桶限流。详细设计见 [令牌桶限流设计](./13-令牌桶限流设计.md) |
## 前端组件 ## 前端组件
| 组件 | 职责 | | 组件 | 职责 |
|------|------| |------|------|
| AuthPage | 登录/注册表单 | | LandingPage | 未登录时的着陆页(营销展示),内嵌 LoginModal 登录/注册弹窗 |
| AuthPage | 登录/注册表单(备用,已被 LandingPage + LoginModal 替代) |
| CameraManager | 摄像头流采集 | | CameraManager | 摄像头流采集 |
| MicManager | 麦克风音频采集 | | MicManager | 麦克风音频采集 |
| EdgeProcessor | VAD + 关键帧检测Canvas 像素比较) | | EdgeProcessor | VAD + 关键帧检测Canvas 像素比较) |
| WebSocketManager | WS 连接生命周期管理 | | WebSocketManager | WS 连接生命周期管理 |
| ChatPanel | 消息展示、流式回复、文本输入、场景选择 | | ChatPanel | 消息展示、流式回复、文本输入、场景选择 |
| VideoPreview | 摄像头画面预览 | | VideoPreview | 摄像头画面预览 |
| SessionSidebar | 左侧抽屉式对话列表(搜索、重命名、删除) | | SessionSidebar | 左侧抽屉式对话列表(搜索、重命名、删除、时间分组 |
| ConfigPanel | 右侧抽屉式配置面板主题、TTS 开关、detail level、语言、场景、账户 | | ConfigPanel | 右侧抽屉式配置面板主题、TTS 开关、detail level、语言、场景、登出 |
| Toast | 轻量通知提示3 秒自动消失) | | 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 ```mermaid
sequenceDiagram sequenceDiagram
participant C as 客户端 participant C as 客户端
@@ -408,9 +422,45 @@ sequenceDiagram
G-->>C: {access_token, refresh_token} G-->>C: {access_token, refresh_token}
``` ```
**Token 策略**access_token 15 分钟有效refresh_token 7 天有效。refresh 时旧 token 失效(轮转),防止重放攻击。 ### Token 策略
**WebSocket 认证**:连接地址 `ws://host/ws?token=<access_token>&conversation_id=<uuid>`。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=<access_token>&conversation_id=<uuid>`
- 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` 生成随机密钥。
## 部署架构 ## 部署架构

View File

@@ -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 <access_token> Authorization: Bearer <access_token>
``` ```
未认证或 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=<access_token>&conversation_id=<uuid>`
- HTTP Upgrade 前校验 token
- 校验失败返回 401 Unauthorized
#### 错误响应格式 #### 错误响应格式
@@ -380,7 +394,7 @@ interface LoginRequest {
| 400 | `INVALID_INPUT` | 请求参数缺失或格式错误 | | 400 | `INVALID_INPUT` | 请求参数缺失或格式错误 |
| 401 | `INVALID_CREDENTIALS` | 用户名或密码错误 | | 401 | `INVALID_CREDENTIALS` | 用户名或密码错误 |
#### 刷新 Token #### 刷新 TokenRefresh Token Rotation
``` ```
POST /api/auth/refresh POST /api/auth/refresh
@@ -397,12 +411,56 @@ interface RefreshRequest {
**成功响应** `200 OK`:同 `AuthResponse` 结构(返回新的 access_token + refresh_token旧 refresh_token 失效——Token 轮转)。 **成功响应** `200 OK`:同 `AuthResponse` 结构(返回新的 access_token + refresh_token旧 refresh_token 失效——Token 轮转)。
**安全机制**
- **Token 轮转**:每次 refresh 都会生成新的 token pair旧 refresh_token 立即失效
- **复用检测**:如果检测到已删除的 refresh_token 被复用,立即吊销该用户的所有 refresh_token
- **强制重新登录**:吊销后,该用户所有设备都需要重新登录
**错误响应** **错误响应**
| 状态码 | code | 场景 | | 状态码 | code | 场景 |
|--------|------|------| |--------|------|------|
| 401 | `INVALID_TOKEN` | refresh_token 无效或已过期 | | 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);
}
);
```
#### 登出 #### 登出
``` ```

View File

@@ -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<br/>Register / Login / Refresh / Logout"]
TokenMgr["TokenManager<br/>JWT 生成与验证"]
Middleware["AuthMiddleware<br/>Gin 中间件"]
Password["PasswordUtil<br/>bcrypt 哈希"]
end
subgraph Storage["存储层"]
UserRepo["UserRepository<br/>用户数据"]
TokenStore["RefreshToken 存储<br/>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
- Cost102^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 <token>`
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/)

View File

@@ -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<br/>query 消息"]
REST["REST API<br/>login / register"]
end
subgraph LimiterModule["Rate Limiter 模块"]
Interface["Limiter 接口<br/>Allow(userID) → (bool, retryAfter)"]
MemBucket["TokenBucket<br/>内存令牌桶"]
RedisBucket["RedisTokenBucket<br/>Redis 令牌桶Lua 脚本)"]
Middleware["RateLimitMiddleware<br/>Gin 中间件"]
end
subgraph Storage["存储层"]
MemSync["sync.RWMutex<br/>进程内 map"]
Redis["Redis<br/>分布式计数"]
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] = ttlkey 过期时间,秒)
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/)

View File

@@ -17,6 +17,8 @@ CamTalk 是一款多模态实时 AI 视觉对话助手。用户通过摄像头
| [09-技术名词解释](09-技术名词解释.md) | 前端/后端/AI 服务/Eino 框架技术名词简明解释 | | [09-技术名词解释](09-技术名词解释.md) | 前端/后端/AI 服务/Eino 框架技术名词简明解释 |
| [10-Eino重构方案](10-Eino重构方案.md) | Eino Graph 替换手写 goroutine 管道的设计方案 | | [10-Eino重构方案](10-Eino重构方案.md) | Eino Graph 替换手写 goroutine 管道的设计方案 |
| [11-Eino框架技术文档](11-Eino框架技术文档.md) | Eino 框架在 CamTalk 中的使用指南Graph、Lambda、Callback、State | | [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** — 各技术领域的详细设计 5. **05~07** — 各技术领域的详细设计
6. **09-技术名词解释** — 遇到不熟悉的名词时查阅 6. **09-技术名词解释** — 遇到不熟悉的名词时查阅
7. **10~12** — Eino 重构相关(方案、框架文档、实施记录) 7. **10~12** — Eino 重构相关(方案、框架文档、实施记录)
8. **12-鉴权体系设计** — 认证授权机制详细设计JWT、bcrypt、Refresh Token Rotation
9. **13-令牌桶限流设计** — 速率限制设计(令牌桶算法、成本控制、防暴力破解)

View File

@@ -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<string | null>(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<Array<{ role: string; content: string }>>([]);
```
它被 push`llm_done` 时 L258、`sendTextMessage` 时 L466、`interrupt` 时 L417但从未被读取或发送到后端。前端的 LLM 上下文完全由后端 `session.Manager.GetHistory` 独立管理。这段代码增加了维护负担却没有任何功能价值。
**Bug 6VAD 语音输入时用户消息未加入 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 8ChatPanel 使用数组 index 作为 React key**
```tsx
{messages.map((msg, index) => (
<div key={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 2WebSocket 会话切换(解决 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 是核心,完成后对话历史功能即可正常工作。