Files
CamTalk/docs/PLAN_USER_MODULE.md
hhs b57adf153b docs: 补充用户模块 REST API 接口契约
- PLAN_USER_MODULE.md 新增「前端 API 接口参考」章节
- 03-接口文档.md 同步认证接口、对话接口、WebSocket 认证变更
- 新增错误码 USERNAME_TAKEN / INVALID_CREDENTIALS / INVALID_TOKEN / INVALID_INPUT
- 数据模型补充 User / ConversationSummary / StoredMessage 及对应 TypeScript 类型
- 配置结构体补充 AuthConfig(JWTSecret / AccessTTL / RefreshTTL)
2026-06-14 18:05:50 +08:00

1380 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CamTalk 后端用户模块构建计划
## Context
后端 AI 管道STT → LLM → TTS已完成现在需要实现用户系统和对话持久化。目标**用户注册登录后,可在对话列表中选择历史对话继续交谈**。
设计文档:`docs/11-持久化与用户系统设计.md`(最高依据)
技术选型:`docs/04-技术选型.md` 第四章
现有后端计划:`docs/PLAN_BACKEND.md`AI 管道部分已完成)
### 当前后端状态
| 模块 | 状态 |
|------|------|
| config | ✅ 已完成,需扩展 AuthConfig |
| logger | ✅ 已完成 |
| errors | ✅ 已完成,需扩展用户相关错误码 |
| models | ✅ 已完成,需扩展 User/Session |
| session manager | ✅ 内存/Redis 已完成,需扩展 user_id 绑定 |
| AI 服务层 | ✅ STT/LLM/TTS 已完成 |
| orchestrator | ✅ 已完成 |
| ws handler | ✅ 已完成,需接入 JWT 认证 |
| REST API | ✅ sessions CRUD 已完成,需新增 auth + conversations |
### 新增依赖
| 包 | 用途 | 引入阶段 |
|----|------|---------|
| `github.com/golang-jwt/jwt/v5` | JWT 签发/校验 | Phase 1 |
| `golang.org/x/crypto/bcrypt` | 密码哈希 | Phase 1 |
| `github.com/jackc/pgx/v5` | PostgreSQL 驱动 | Phase 2 |
---
## 分阶段实施
### Phase 1配置扩展 + 数据库连接
**目标**:扩展配置结构体,建立 PostgreSQL 连接池。
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 1.1 | 扩展 Config 结构体 | `internal/config/config.go` | 新增 `AuthConfig`JWTSecret, AccessTTL, RefreshTTL`StorageConfig` 已有 Driver/DSN 字段 |
| 1.2 | 添加配置默认值 | `internal/config/config.go` | `auth.access_ttl` 默认 15`auth.refresh_ttl` 默认 10080 |
| 1.3 | 实现数据库连接池 | `internal/store/db.go` | `NewPostgresPool(ctx, dsn) (*pgxpool.Pool, error)`,启动时调用,注入到各 repository |
| 1.4 | 编写 schema 迁移脚本 | `migrations/001_users.up.sql` | `users` 表 + `refresh_tokens` 表 |
| 1.5 | 编写回滚脚本 | `migrations/001_users.down.sql` | DROP TABLE |
| 1.6 | main.go 条件初始化 DB | `cmd/server/main.go` | `storage.driver == "postgres"` 时创建 pgxpool否则跳过纯内存模式 |
**配置扩展示例**
```go
// internal/config/config.go 新增
type AuthConfig struct {
JWTSecret string `mapstructure:"jwt_secret"` // 必须通过 CAMTALK_AUTH_JWT_SECRET 设置
AccessTTL int `mapstructure:"access_ttl"` // 分钟,默认 15
RefreshTTL int `mapstructure:"refresh_ttl"` // 分钟,默认 10080
}
```
**数据库连接**
```go
// internal/store/db.go
package store
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
func NewPostgresPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, err
}
cfg.MaxConns = 10
return pgxpool.NewWithConfig(ctx, cfg)
}
```
---
### Phase 2用户模型 + Repository
**目标**:定义用户数据模型和持久化接口。
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 2.1 | 扩展 models | `internal/models/models.go` | 新增 `User` 结构体ID, Username, PasswordHash, CreatedAt, UpdatedAt |
| 2.2 | 定义 UserRepository 接口 | `internal/store/user.go` | `Create`, `FindByUsername`, `FindByID`, `SaveRefreshToken`, `FindRefreshToken`, `DeleteRefreshToken` |
| 2.3 | 实现 PostgreSQL UserRepository | `internal/store/user_pg.go` | pgx 实现,所有方法使用 `pgxpool.Pool` |
| 2.4 | 实现内存 UserRepository测试用 | `internal/store/user_mem.go` | `sync.RWMutex` + map单元测试时注入 |
| 2.5 | 编写 UserRepository 测试 | `internal/store/user_pg_test.go` | 需要测试 DB 或 mock |
**UserRepository 接口**
```go
// internal/store/user.go
package store
import (
"context"
"errors"
"time"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrUsernameTaken = errors.New("username already taken")
ErrRefreshTokenNotFound = errors.New("refresh token not found")
)
type UserRepository interface {
// Create 创建用户,返回生成的 ID。
Create(ctx context.Context, username, passwordHash string) (string, error)
// FindByUsername 按用户名查找,不存在返回 ErrUserNotFound。
FindByUsername(ctx context.Context, username string) (*User, error)
// FindByID 按 ID 查找,不存在返回 ErrUserNotFound。
FindByID(ctx context.Context, id string) (*User, error)
// SaveRefreshToken 保存 refresh token hash。
SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error
// FindRefreshToken 按 token hash 查找,返回 user_id。不存在返回 ErrRefreshTokenNotFound。
FindRefreshToken(ctx context.Context, tokenHash string) (string, error)
// DeleteRefreshToken 按 token hash 删除。
DeleteRefreshToken(ctx context.Context, tokenHash string) error
// DeleteUserRefreshTokens 删除用户的所有 refresh token登出所有设备
DeleteUserRefreshTokens(ctx context.Context, userID string) error
}
// User 用户数据模型store 层)。
type User struct {
ID string
Username string
PasswordHash string
CreatedAt time.Time
UpdatedAt time.Time
}
```
---
### Phase 3JWT + 认证服务
**目标**:实现 JWT 签发/校验、bcrypt 密码处理、认证业务逻辑。
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 3.1 | 实现 TokenManager | `internal/auth/jwt.go` | `GeneratePair`, `ValidateAccess`, `ValidateRefresh`, `HashToken` |
| 3.2 | 实现密码工具 | `internal/auth/password.go` | `HashPassword(password) (string, error)`, `CheckPassword(hash, password) error` |
| 3.3 | 实现 AuthMiddleware | `internal/auth/middleware.go` | Gin 中间件,从 `Authorization: Bearer <token>` 提取 Claims 写入 Context |
| 3.4 | 定义 AuthService 接口 | `internal/auth/service.go` | 业务层封装:`Register`, `Login`, `Refresh`, `Logout` |
| 3.5 | 实现 AuthService | `internal/auth/service.go` | 组合 TokenManager + UserRepository |
| 3.6 | 编写 TokenManager 测试 | `internal/auth/jwt_test.go` | 生成/校验/过期/hash |
| 3.7 | 编写 AuthService 测试 | `internal/auth/service_test.go` | mock UserRepository覆盖注册重复、密码错误、token 轮转 |
**TokenManager 核心实现**
```go
// internal/auth/jwt.go
type Claims struct {
UserID string `json:"user_id"`
Username string `json:"username"`
jwt.RegisteredClaims
}
type TokenManager struct {
secret []byte
accessTTL time.Duration
refreshTTL time.Duration
}
func NewTokenManager(secret string, accessTTL, refreshTTL time.Duration) *TokenManager {
return &TokenManager{
secret: []byte(secret),
accessTTL: accessTTL,
refreshTTL: refreshTTL,
}
}
func (tm *TokenManager) GeneratePair(userID, username string) (access, refresh string, err error) {
// access_token: 15min
accessClaims := &Claims{
UserID: userID,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(tm.accessTTL)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "camtalk",
},
}
accessTkn := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
access, err = accessTkn.SignedString(tm.secret)
if err != nil {
return "", "", err
}
// refresh_token: 7day, 含唯一 token_id
tokenID := uuid.New().String()
refreshClaims := &Claims{
UserID: userID,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ID: tokenID,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(tm.refreshTTL)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "camtalk",
},
}
refreshTkn := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
refresh, err = refreshTkn.SignedString(tm.secret)
return
}
func (tm *TokenManager) ValidateAccess(tokenStr string) (*Claims, error) {
return tm.validate(tokenStr)
}
func (tm *TokenManager) ValidateRefresh(tokenStr string) (*Claims, error) {
return tm.validate(tokenStr)
}
func (tm *TokenManager) validate(tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
return tm.secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, jwt.ErrTokenInvalidClaims
}
return claims, nil
}
// HashToken SHA256 hash用于 DB 存储。
func HashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
```
**AuthService 接口**
```go
// internal/auth/service.go
type RegisterRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type RefreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
type AuthResponse struct {
User UserResponse `json:"user"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
type UserResponse struct {
ID string `json:"id"`
Username string `json:"username"`
CreatedAt time.Time `json:"created_at"`
}
type Service interface {
Register(ctx context.Context, req RegisterRequest) (*AuthResponse, error)
Login(ctx context.Context, req LoginRequest) (*AuthResponse, error)
Refresh(ctx context.Context, req RefreshRequest) (*AuthResponse, error)
Logout(ctx context.Context, userID, refreshToken string) error
}
```
---
### Phase 4认证 REST API
**目标**:实现注册、登录、刷新、登出四个端点。
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 4.1 | 实现 AuthHandler | `internal/api/auth.go` | `Register`, `Login`, `Refresh`, `Logout` 处理函数 |
| 4.2 | 输入校验 | `internal/api/auth.go` | 用户名 3-64 字符,密码 8-72 字符 |
| 4.3 | 注册路由 | `internal/api/auth.go` | `RegisterRoutes(rg *gin.RouterGroup)` |
| 4.4 | main.go 接入 | `cmd/server/main.go` | 创建 TokenManager + AuthService + AuthHandler注册路由 |
| 4.5 | 编写 API 测试 | `internal/api/auth_test.go` | httptest + mock AuthService |
**AuthHandler 结构**
```go
// internal/api/auth.go
type AuthHandler struct {
authService auth.Service
}
func NewAuthHandler(authService auth.Service) *AuthHandler {
return &AuthHandler{authService: authService}
}
func (h *AuthHandler) RegisterRoutes(rg *gin.RouterGroup) {
auth := rg.Group("/auth")
{
auth.POST("/register", h.Register)
auth.POST("/login", h.Login)
auth.POST("/refresh", h.Refresh)
auth.POST("/logout", auth.AuthMiddleware(), h.Logout)
}
}
```
**错误响应格式**(统一现有风格):
```json
{
"code": "USERNAME_TAKEN",
"message": "username already taken"
}
```
新增错误码到 `internal/errors/codes.go`
```go
const (
CodeUsernameTaken = "USERNAME_TAKEN"
CodeInvalidCredentials = "INVALID_CREDENTIALS"
CodeInvalidToken = "INVALID_TOKEN"
CodeInvalidInput = "INVALID_INPUT"
)
```
---
### Phase 5Session Manager 改造
**目标**Session Manager 关联 user_id支持对话列表查询。
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 5.1 | 扩展 Session 模型 | `internal/models/models.go` | `Session` 新增 `UserID`, `Title`, `UpdatedAt` 字段 |
| 5.2 | 扩展 Manager 接口 | `internal/session/manager.go` | `Create` 签名加 `userID`;新增 `ListByUser`, `UpdateTitle`;新增 `ConversationSummary` 类型 |
| 5.3 | 修改 MemoryManager | `internal/session/memory.go` | `Create` 存储 userID实现 `ListByUser`(遍历+过滤+排序);实现 `UpdateTitle` |
| 5.4 | 修改 RedisManager | `internal/session/redis.go` | `session:{id}:meta` 新增 `user_id``title` 字段;`ListByUser` 使用 Redis Set `user:{id}:sessions` 索引 |
| 5.5 | 编写新方法测试 | `internal/session/memory_test.go` | 覆盖 ListByUser 分页、UpdateTitle、Create 带 userID |
| 5.6 | 更新 ws handler 调用 | `internal/ws/handler.go` | `sessionMgr.Create` 调用传入 userID此时 Phase 7 才有真实 userID先用空字符串兼容 |
**接口变更**
```go
// internal/session/manager.go 变更
type Manager interface {
// Create 签名变更:新增 userID 参数
Create(ctx context.Context, userID string, config models.SessionConfig) (string, error)
// 新增方法
ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error)
UpdateTitle(ctx context.Context, sessionID string, title string) error
// 其余方法不变...
}
type ConversationSummary struct {
ID string `json:"id"`
Title string `json:"title"`
LastMessage string `json:"last_message"`
MessageCount int `json:"message_count"`
UpdatedAt time.Time `json:"updated_at"`
}
```
**MemoryManager ListByUser 实现思路**
```go
func (m *MemoryManager) ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error) {
m.mu.RLock()
defer m.mu.RUnlock()
// 收集该用户的所有 session
var list []ConversationSummary
for _, entry := range m.sessions {
if entry.session.UserID != userID {
continue
}
summary := ConversationSummary{
ID: entry.session.ID,
Title: entry.session.Title,
MessageCount: len(entry.history),
UpdatedAt: entry.lastActive,
}
if len(entry.history) > 0 {
summary.LastMessage = entry.history[len(entry.history)-1].Content
}
list = append(list, summary)
}
// 按 UpdatedAt 降序排序
sort.Slice(list, func(i, j int) bool {
return list[i].UpdatedAt.After(list[j].UpdatedAt)
})
total := len(list)
// 分页
start := (page - 1) * size
if start >= total {
return []ConversationSummary{}, total, nil
}
end := start + size
if end > total {
end = total
}
return list[start:end], total, nil
}
```
---
### Phase 6对话 REST API
**目标**:实现对话 CRUD 和历史消息查询端点。
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 6.1 | 实现 ConversationHandler | `internal/api/conversation.go` | `List`, `Create`, `Get`, `UpdateTitle`, `Delete`, `GetMessages` |
| 6.2 | 权限校验 | `internal/api/conversation.go` | 每个端点校验 session.UserID == claims.UserID |
| 6.3 | 注册路由 | `internal/api/conversation.go` | `RegisterRoutes(rg *gin.RouterGroup)`,全部走 AuthMiddleware |
| 6.4 | main.go 接入 | `cmd/server/main.go` | 创建 ConversationHandler 并注册 |
| 6.5 | 编写 API 测试 | `internal/api/conversation_test.go` | httptest + mock SessionManager |
**ConversationHandler 结构**
```go
// internal/api/conversation.go
type ConversationHandler struct {
sessionMgr session.Manager
}
func (h *ConversationHandler) RegisterRoutes(rg *gin.RouterGroup) {
conv := rg.Group("/conversations", auth.AuthMiddleware(tokenMgr))
{
conv.GET("", h.List)
conv.POST("", h.Create)
conv.GET("/:id", h.Get)
conv.PATCH("/:id", h.UpdateTitle)
conv.DELETE("/:id", h.Delete)
conv.GET("/:id/messages", h.GetMessages)
}
}
```
**权限校验模式**(每个端点复用):
```go
func (h *ConversationHandler) getSessionForUser(c *gin.Context, sessionID string) (*models.Session, error) {
sess, err := h.sessionMgr.Get(c.Request.Context(), sessionID)
if err != nil {
return nil, err
}
userID := c.GetString("user_id") // 从 AuthMiddleware 写入
if sess.UserID != userID {
return nil, session.ErrSessionNotFound // 返回 404 而非 403避免信息泄露
}
return sess, nil
}
```
**GetMessages 实现要点**
- 从 Session Manager 的 `GetHistory` 获取消息
- 支持 `?limit=50&before=<message_id>` 分页
- 内存实现中history 是全量存储的,直接按索引切片即可
---
### Phase 7WebSocket 认证集成
**目标**WS 连接需要 JWT 认证,支持指定 conversation_id 恢复历史对话。
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 7.1 | 修改 ServeWS 签名 | `internal/ws/handler.go` | 新增 `tokenMgr *auth.TokenManager` 参数 |
| 7.2 | WS 连接认证 | `internal/ws/handler.go` | 从 `?token=xxx` 提取并校验 access_token失败返回 401 |
| 7.3 | conversation_id 处理 | `internal/ws/handler.go` | `?conversation_id=xxx` 存在时:校验归属 → LoadFromDB → 复用 session否则创建新 session |
| 7.4 | AppendMessage 自动标题 | `internal/session/memory.go` | 首条 user 消息时,如果 title == "新对话",自动更新为前 20 字符 |
| 7.5 | main.go 更新 | `cmd/server/main.go` | 传入 tokenMgr 到 ServeWS |
| 7.7 | 编写认证测试 | `internal/ws/handler_test.go` | 测试无 token / 过期 token / 有效 token / conversation_id 恢复 |
**WS 连接流程变更**
```
客户端请求: GET /ws?token=<access>&conversation_id=<uuid>
服务端处理:
1. token 为空 → 401 {"error": "missing token"}
2. token 无效/过期 → 401 {"error": "invalid token"}
3. conversation_id 非空:
a. session 不存在或 user_id 不匹配 → 401 {"error": "SESSION_NOT_FOUND"}
b. sessionMgr.LoadFromDB(conversationID) → 加载历史到热存储
c. sessionID = conversationID
4. conversation_id 为空:
a. sessionMgr.Create(userID, defaultConfig) → 创建新 session
5. Upgrade WebSocket → 发送 connected 消息
```
**对话标题自动生成**
```go
// internal/session/memory.go — AppendMessage 中追加逻辑
func (m *MemoryManager) AppendMessage(ctx context.Context, sessionID string, msg models.Message) error {
m.mu.Lock()
defer m.mu.Unlock()
entry, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
entry.history = append(entry.history, msg)
entry.lastActive = time.Now()
// 自动更新标题
if msg.Role == "user" && entry.session.Title == "新对话" {
entry.session.Title = generateTitle(msg.Content)
}
// 限制历史上限
if len(entry.history) > m.maxHistory {
entry.history = entry.history[len(entry.history)-m.maxHistory:]
}
return nil
}
func generateTitle(firstMessage string) string {
runes := []rune(firstMessage)
if len(runes) > 20 {
return string(runes[:20]) + "…"
}
return firstMessage
}
```
---
### Phase 8消息持久化Write-Through
**目标**:对话消息同时写入 PostgreSQL保证重启不丢数据。
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 8.1 | 定义 MessageRepository 接口 | `internal/store/message.go` | `SaveMessage`, `GetMessages`, `GetLastMessage` |
| 8.2 | 实现 PostgreSQL MessageRepository | `internal/store/message_pg.go` | pgx 实现 |
| 8.3 | Session Manager 注入 MessageRepository | `internal/session/memory.go` | AppendMessage 时同时调用 repo.SaveMessagewrite-through |
| 8.4 | LoadFromDB 实现 | `internal/session/memory.go` | 从 PostgreSQL 读取消息加载到内存 history |
| 8.5 | ConversationSummary 查询优化 | `internal/store/message_pg.go` | 对话列表的 last_message 和 message_count 通过 SQL 聚合查询 |
**MessageRepository 接口**
```go
// internal/store/message.go
type MessageRepository interface {
// SaveMessage 保存一条消息。
SaveMessage(ctx context.Context, sessionID string, msg models.Message, tokensUsed int) error
// GetMessages 获取会话的消息列表(分页,按 id 升序)。
GetMessages(ctx context.Context, sessionID string, limit int, beforeID int64) ([]StoredMessage, error)
// GetLastMessage 获取会话的最后一条消息。
GetLastMessage(ctx context.Context, sessionID string) (*StoredMessage, error)
// GetMessageCount 获取会话的消息总数。
GetMessageCount(ctx context.Context, sessionID string) (int, error)
}
type StoredMessage struct {
ID int64 `json:"id"`
SessionID string `json:"-"`
Role string `json:"role"`
Content string `json:"content"`
TokensUsed int `json:"tokens_used"`
CreatedAt time.Time `json:"created_at"`
}
```
**Write-Through 模式**
```go
// internal/session/memory.go — AppendMessage 改造
func (m *MemoryManager) AppendMessage(ctx context.Context, sessionID string, msg models.Message) error {
// 1. 写热存储(内存/Redis
m.mu.Lock()
entry, ok := m.sessions[sessionID]
if !ok {
m.mu.Unlock()
return ErrSessionNotFound
}
entry.history = append(entry.history, msg)
entry.lastActive = time.Now()
if msg.Role == "user" && entry.session.Title == "新对话" {
entry.session.Title = generateTitle(msg.Content)
}
if len(entry.history) > m.maxHistory {
entry.history = entry.history[len(entry.history)-m.maxHistory:]
}
m.mu.Unlock()
// 2. 写冷存储PostgreSQL异步不阻塞
if m.msgRepo != nil {
go func() {
if err := m.msgRepo.SaveMessage(context.Background(), sessionID, msg, 0); err != nil {
logger.Log.Warnw("persist message failed", "session", sessionID, "error", err)
}
}()
}
return nil
}
```
---
### Phase 9旧端点废弃 + 集成收尾
**目标**:废弃旧的 `/api/sessions` 端点,完成全链路集成。
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 9.1 | 废弃旧 session 路由 | `internal/api/session.go` | 保留代码但标记 deprecated或直接删除 |
| 9.2 | main.go 完整组装 | `cmd/server/main.go` | 按 storage.driver 选择注入 MemoryRepo 或 PgRepo |
| 9.3 | .env.example 更新 | `backend/.env.example` | 新增 `CAMTALK_AUTH_JWT_SECRET``CAMTALK_STORAGE_*` |
| 9.4 | config.yaml 更新 | `backend/config.yaml` | 新增 auth 配置块 |
| 9.5 | docker-compose 添加 PG | `docker-compose.yml` | PostgreSQL 15 服务 + 环境变量 |
| 9.6 | go mod tidy | `backend/` | 清理依赖 |
| 9.7 | 端到端手动测试 | — | 注册 → 登录 → 创建对话 → 发送消息 → 登出 → 重新登录 → 查看对话列表 → 选择历史对话继续 |
**main.go 依赖注入全貌**
```go
func main() {
cfg, _ := config.Load()
logger.Init(cfg.Log.Level, cfg.Log.Format)
// --- 存储层 ---
var (
userRepo store.UserRepository
msgRepo store.MessageRepository
sessionMgr session.Manager
)
if cfg.Storage.Driver == "postgres" {
pool, _ := store.NewPostgresPool(ctx, cfg.Storage.DSN)
defer pool.Close()
userRepo = store.NewPgUserRepository(pool)
msgRepo = store.NewPgMessageRepository(pool)
sessionMgr = session.NewMemoryManager(..., msgRepo) // 注入 msgRepo
} else {
userRepo = store.NewMemUserRepository()
sessionMgr = session.NewMemoryManager(...) // 无 msgRepo纯内存
}
// --- 认证 ---
tokenMgr := auth.NewTokenManager(cfg.Auth.JWTSecret,
time.Duration(cfg.Auth.AccessTTL)*time.Minute,
time.Duration(cfg.Auth.RefreshTTL)*time.Minute)
authService := auth.NewAuthService(tokenMgr, userRepo)
// --- AI 服务(不变)---
sttService := ...
llmService := ...
ttsService := ...
orch := orchestrator.New(sttService, llmService, ttsService, sessionMgr, cfg)
// --- 路由 ---
r := gin.New()
apiGroup := r.Group("/api")
apiGroup.GET("/health", healthHandler(sessionMgr, cfg))
authHandler := api.NewAuthHandler(authService)
authHandler.RegisterRoutes(apiGroup)
convHandler := api.NewConversationHandler(sessionMgr, tokenMgr)
convHandler.RegisterRoutes(apiGroup)
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr))
// ... 启动
}
```
---
## 前端 API 接口参考
本章节为前端开发者提供完整的 REST API 契约。所有接口以 JSON 通信,基地址与 WebSocket 同源(开发环境 `http://localhost:8080`,生产环境通过 Nginx 反代)。
### 通用约定
#### 认证方式
需要认证的接口在请求头携带 JWT access token
```
Authorization: Bearer <access_token>
```
未认证或 token 过期时返回 `401 Unauthorized`
#### 错误响应格式
所有错误响应统一结构:
```typescript
interface ApiError {
code: string; // 机器可读错误码
message: string; // 人类可读描述
}
```
示例:
```json
{
"code": "USERNAME_TAKEN",
"message": "username already taken"
}
```
#### 新增错误码
| 错误码 | HTTP 状态码 | 含义 |
|--------|-----------|------|
| `USERNAME_TAKEN` | 409 | 用户名已被注册 |
| `INVALID_CREDENTIALS` | 401 | 用户名或密码错误 |
| `INVALID_TOKEN` | 401 | JWT 无效或已过期 |
| `INVALID_INPUT` | 400 | 请求参数校验失败 |
| `SESSION_NOT_FOUND` | 404 | 对话不存在或无权访问 |
#### 输入校验规则
| 字段 | 规则 |
|------|------|
| `username` | 3-64 字符,仅允许字母、数字、下划线 |
| `password` | 8-72 字符 |
---
### 一、认证接口(`/api/auth`
#### 1.1 注册
```
POST /api/auth/register
Content-Type: application/json
```
**请求体**
```typescript
interface RegisterRequest {
username: string; // 3-64 字符
password: string; // 8-72 字符
}
```
**成功响应** `201 Created`
```typescript
interface AuthResponse {
user: {
id: string; // UUID
username: string;
created_at: string; // ISO 8601
};
access_token: string; // JWT15 分钟有效
refresh_token: string; // JWT7 天有效
}
```
```json
{
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"username": "alice",
"created_at": "2026-06-14T10:00:00Z"
},
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
}
```
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 400 | `INVALID_INPUT` | 用户名/密码不符合校验规则 |
| 409 | `USERNAME_TAKEN` | 用户名已存在 |
---
#### 1.2 登录
```
POST /api/auth/login
Content-Type: application/json
```
**请求体**
```typescript
interface LoginRequest {
username: string;
password: string;
}
```
**成功响应** `200 OK`:同 `AuthResponse` 结构。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 400 | `INVALID_INPUT` | 请求参数缺失或格式错误 |
| 401 | `INVALID_CREDENTIALS` | 用户名或密码错误 |
---
#### 1.3 刷新 Token
```
POST /api/auth/refresh
Content-Type: application/json
```
**请求体**
```typescript
interface RefreshRequest {
refresh_token: string; // 之前签发的 refresh_token
}
```
**成功响应** `200 OK`:同 `AuthResponse` 结构(返回新的 access_token + refresh_token旧 refresh_token 失效——Token 轮转)。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | refresh_token 无效或已过期 |
---
#### 1.4 登出
```
POST /api/auth/logout
Content-Type: application/json
Authorization: Bearer <access_token>
```
**请求体**
```typescript
interface LogoutRequest {
refresh_token: string; // 要废弃的 refresh_token
}
```
**成功响应** `204 No Content`(无响应体)。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | access_token 无效或已过期 |
---
### 二、对话接口(`/api/conversations`
> 以下所有接口均需认证(`Authorization: Bearer <access_token>`),省略不重复标注。
#### 2.1 对话列表
```
GET /api/conversations?page=1&size=20
```
**查询参数**
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `page` | int | 1 | 页码,从 1 开始 |
| `size` | int | 20 | 每页条数,最大 50 |
**成功响应** `200 OK`
```typescript
interface ConversationListResponse {
conversations: ConversationSummary[];
total: number; // 总条数
page: number;
size: number;
}
interface ConversationSummary {
id: string; // 对话 ID即 session_id
title: string; // 对话标题(首条消息前 20 字)
last_message: string; // 最后一条消息内容预览
message_count: number; // 消息总数
updated_at: string; // ISO 8601最后活跃时间
}
```
```json
{
"conversations": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "这是一朵红色的玫瑰…",
"last_message": "它看起来很美丽。",
"message_count": 4,
"updated_at": "2026-06-14T10:05:30Z"
}
],
"total": 1,
"page": 1,
"size": 20
}
```
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
---
#### 2.2 创建对话
```
POST /api/conversations
Content-Type: application/json
```
**请求体**(可选,全部有默认值):
```typescript
interface CreateConversationRequest {
config?: {
tts_enabled?: boolean; // 默认 true
detail_level?: "low" | "high"; // 默认 "low"
language?: string; // 默认 "zh-CN"
};
}
```
**成功响应** `201 Created`
```typescript
interface ConversationDetail {
id: string;
title: string;
config: {
tts_enabled: boolean;
detail_level: "low" | "high";
language: string;
};
created_at: string; // ISO 8601
}
```
```json
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"title": "新对话",
"config": {
"tts_enabled": true,
"detail_level": "low",
"language": "zh-CN"
},
"created_at": "2026-06-14T11:00:00Z"
}
```
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
---
#### 2.3 获取对话详情
```
GET /api/conversations/:id
```
**成功响应** `200 OK`:同 `ConversationDetail` 结构。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 |
---
#### 2.4 更新对话标题
```
PATCH /api/conversations/:id
Content-Type: application/json
```
**请求体**
```typescript
interface UpdateTitleRequest {
title: string; // 1-100 字符
}
```
**成功响应** `200 OK`
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "新的自定义标题"
}
```
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 400 | `INVALID_INPUT` | title 为空或超长 |
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 |
---
#### 2.5 删除对话
```
DELETE /api/conversations/:id
```
**成功响应** `204 No Content`(无响应体)。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 |
---
#### 2.6 获取对话消息
```
GET /api/conversations/:id/messages?limit=50&before=<message_id>
```
**查询参数**
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `limit` | int | 50 | 返回条数,最大 100 |
| `before` | int64 | — | 游标分页:返回此 message_id 之前的消息(不含),用于加载更多 |
**成功响应** `200 OK`
```typescript
interface MessagesResponse {
messages: StoredMessage[];
has_more: boolean; // 是否还有更早的消息
}
interface StoredMessage {
id: number; // 自增 ID用于游标分页
role: "user" | "assistant";
content: string;
tokens_used: number; // 该条消息消耗的 token 数
created_at: string; // ISO 8601
}
```
```json
{
"messages": [
{
"id": 1001,
"role": "user",
"content": "这是什么花?",
"tokens_used": 0,
"created_at": "2026-06-14T10:01:00Z"
},
{
"id": 1002,
"role": "assistant",
"content": "这是一朵红色的玫瑰。",
"tokens_used": 42,
"created_at": "2026-06-14T10:01:02Z"
}
],
"has_more": false
}
```
**分页用法**:首次请求不带 `before`,获取最新消息。滚动到顶部时,取当前列表最小的 `id` 作为 `before` 参数请求更早的消息。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | 未认证或 token 过期 |
| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 |
---
### 三、WebSocket 认证变更
连接地址变更为带 token 的查询参数:
```
ws://localhost:8080/ws?token=<access_token>&conversation_id=<uuid>
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `token` | 是 | JWT access_token |
| `conversation_id` | 否 | 恢复已有对话;省略则创建新对话 |
**认证失败响应**HTTP 升级前返回):
| 状态码 | 场景 |
|--------|------|
| 401 | token 缺失、无效或已过期 |
**conversation_id 校验失败**
| 场景 | 处理 |
|------|------|
| 对话不存在 | 返回 401`{"error": "SESSION_NOT_FOUND"}` |
| 对话不属于当前用户 | 返回 401`{"error": "SESSION_NOT_FOUND"}`(与不存在相同,避免信息泄露) |
**连接成功后**`connected` 消息不变,新增 `conversation_id` 字段标识当前对话:
```typescript
interface ConnectedMessage {
type: "connected";
session_id: string; // 对话 ID
conversation_id: string; // 同 session_id便于前端统一使用
server_version: string;
}
```
---
### 四、前端调用示例
#### 认证状态管理
```typescript
// 存储 token建议 localStorage 或内存,视安全需求)
interface AuthTokens {
accessToken: string;
refreshToken: string;
}
// 请求拦截器:自动附加 Authorization 头
async function authFetch(url: string, options: RequestInit = {}): Promise<Response> {
const tokens = getStoredTokens();
const headers = {
...options.headers,
"Authorization": `Bearer ${tokens.accessToken}`,
};
let resp = await fetch(url, { ...options, headers });
// 401 时尝试刷新 token
if (resp.status === 401 && tokens.refreshToken) {
const refreshResp = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: tokens.refreshToken }),
});
if (refreshResp.ok) {
const newTokens: AuthResponse = await refreshResp.json();
storeTokens({
accessToken: newTokens.access_token,
refreshToken: newTokens.refresh_token,
});
// 用新 token 重试原请求
headers["Authorization"] = `Bearer ${newTokens.access_token}`;
resp = await fetch(url, { ...options, headers });
} else {
// refresh 也失败,跳转登录
redirectToLogin();
}
}
return resp;
}
```
#### 注册 + 登录
```typescript
async function register(username: string, password: string): Promise<AuthResponse> {
const resp = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!resp.ok) {
const err: ApiError = await resp.json();
throw new Error(err.message); // "username already taken" 等
}
return resp.json();
}
```
#### 获取对话列表
```typescript
async function getConversations(page = 1, size = 20): Promise<ConversationListResponse> {
const resp = await authFetch(
`/api/conversations?page=${page}&size=${size}`
);
if (!resp.ok) throw new Error("Failed to load conversations");
return resp.json();
}
```
#### 加载对话历史消息
```typescript
async function getMessages(
conversationId: string,
limit = 50,
before?: number
): Promise<MessagesResponse> {
let url = `/api/conversations/${conversationId}/messages?limit=${limit}`;
if (before !== undefined) url += `&before=${before}`;
const resp = await authFetch(url);
if (!resp.ok) throw new Error("Failed to load messages");
return resp.json();
}
```
#### 建立 WebSocket 连接(带认证)
```typescript
function connectWebSocket(accessToken: string, conversationId?: string): WebSocket {
let url = `/ws?token=${encodeURIComponent(accessToken)}`;
if (conversationId) {
url += `&conversation_id=${encodeURIComponent(conversationId)}`;
}
return new WebSocket(url);
}
```
---
## 关键文件清单
```
backend/
cmd/server/main.go ← Phase 1.6, 4.4, 7.5, 9.2
migrations/
001_users.up.sql ← Phase 1.4(新建)
001_users.down.sql ← Phase 1.5(新建)
internal/
config/config.go ← Phase 1.1, 1.2(修改)
errors/codes.go ← Phase 4.2(修改,新增错误码)
models/models.go ← Phase 5.1(修改)
store/
db.go ← Phase 1.3(新建)
user.go ← Phase 2.2(新建)
user_pg.go ← Phase 2.3(新建)
user_mem.go ← Phase 2.4(新建)
user_pg_test.go ← Phase 2.5(新建)
message.go ← Phase 8.1(新建)
message_pg.go ← Phase 8.2(新建)
auth/
jwt.go ← Phase 3.1(新建)
password.go ← Phase 3.2(新建)
middleware.go ← Phase 3.3(新建)
service.go ← Phase 3.4, 3.5(新建)
jwt_test.go ← Phase 3.6(新建)
service_test.go ← Phase 3.7(新建)
session/
manager.go ← Phase 5.2(修改)
memory.go ← Phase 5.3, 7.4, 8.3, 8.4(修改)
redis.go ← Phase 5.4(修改)
memory_test.go ← Phase 5.5(修改)
api/
auth.go ← Phase 4.1, 4.3(新建)
auth_test.go ← Phase 4.5(新建)
conversation.go ← Phase 6.1, 6.2, 6.3(新建)
conversation_test.go ← Phase 6.5(新建)
session.go ← Phase 9.1(废弃/删除)
ws/
handler.go ← Phase 7.1, 7.2, 7.3(修改)
handler_test.go ← Phase 7.7(修改)
```
---
## 执行顺序与依赖关系
```
Phase 1 (配置 + DB 连接)
Phase 2 (User 模型 + Repository) ← 依赖 Phase 1
Phase 3 (JWT + AuthService) ← 依赖 Phase 2
Phase 4 (Auth REST API) ← 依赖 Phase 3
Phase 5 (Session Manager 改造) ← 依赖 Phase 1模型扩展可与 Phase 2-4 并行
Phase 6 (Conversation REST API) ← 依赖 Phase 4 + 5
Phase 7 (WS 认证集成) ← 依赖 Phase 3 + 5
Phase 8 (消息持久化) ← 依赖 Phase 1 + 5
Phase 9 (废弃旧端点 + 集成收尾) ← 依赖全部
```
**可并行的路径**
- Phase 2-4用户认证链路和 Phase 5Session 改造)可并行开发
- Phase 6对话 API和 Phase 7WS 认证)可并行开发
---
## 验证方案
| 层级 | 方法 | 覆盖范围 |
|------|------|---------|
| 单元测试 | `go test ./internal/auth/... ./internal/store/...` | JWT 生成/校验、密码 hash、Repository CRUD |
| API 测试 | `httptest` + `go test ./internal/api/...` | 注册/登录/刷新/登出、对话 CRUD、权限校验 |
| 集成测试 | 启动 Gin test server + WS client | WS 认证、conversation_id 恢复、消息持久化 |
| 端到端 | 手动测试 | 注册 → 登录 → 对话 → 登出 → 重登 → 历史列表 → 继续对话 |
| 静态检查 | `go vet ./...` + `go test ./...` | 全量通过 |