Files
CamTalk/docs/PLAN_USER_MODULE.md
hhs 4b30e67c2e feat: 扩展 Config 结构体,新增 AuthConfig 配置
- 新增 AuthConfig 结构体(JWTSecret, AccessTTL, RefreshTTL)
- 在 Config 中添加 Auth 字段
- 设置默认值:access_ttl=15分钟,refresh_ttl=10080分钟(7天)
- JWTSecret 必须通过环境变量 CAMTALK_AUTH_JWT_SECRET 设置
2026-06-14 16:40:06 +08:00

798 lines
28 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))
// ... 启动
}
```
---
## 关键文件清单
```
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 ./...` | 全量通过 |