2026-06-14 16:40:06 +08:00
|
|
|
|
# CamTalk 后端用户模块构建计划
|
|
|
|
|
|
|
|
|
|
|
|
## Context
|
|
|
|
|
|
|
|
|
|
|
|
后端 AI 管道(STT → LLM → TTS)已完成,现在需要实现用户系统和对话持久化。目标:**用户注册登录后,可在对话列表中选择历史对话继续交谈**。
|
|
|
|
|
|
|
|
|
|
|
|
设计文档:`docs/11-持久化与用户系统设计.md`(最高依据)
|
|
|
|
|
|
技术选型:`docs/04-技术选型.md` 第四章
|
|
|
|
|
|
现有后端计划:`docs/PLAN_BACKEND.md`(AI 管道部分已完成)
|
|
|
|
|
|
|
|
|
|
|
|
### 当前后端状态
|
|
|
|
|
|
|
|
|
|
|
|
| 模块 | 状态 |
|
|
|
|
|
|
|------|------|
|
|
|
|
|
|
| config | ✅ 已完成,需扩展 AuthConfig |
|
|
|
|
|
|
| logger | ✅ 已完成 |
|
|
|
|
|
|
| errors | ✅ 已完成,需扩展用户相关错误码 |
|
|
|
|
|
|
| models | ✅ 已完成,需扩展 User/Session |
|
|
|
|
|
|
| session manager | ✅ 内存/Redis 已完成,需扩展 user_id 绑定 |
|
|
|
|
|
|
| AI 服务层 | ✅ STT/LLM/TTS 已完成 |
|
|
|
|
|
|
| orchestrator | ✅ 已完成 |
|
|
|
|
|
|
| ws handler | ✅ 已完成,需接入 JWT 认证 |
|
|
|
|
|
|
| REST API | ✅ sessions CRUD 已完成,需新增 auth + conversations |
|
|
|
|
|
|
|
|
|
|
|
|
### 新增依赖
|
|
|
|
|
|
|
|
|
|
|
|
| 包 | 用途 | 引入阶段 |
|
|
|
|
|
|
|----|------|---------|
|
|
|
|
|
|
| `github.com/golang-jwt/jwt/v5` | JWT 签发/校验 | Phase 1 |
|
|
|
|
|
|
| `golang.org/x/crypto/bcrypt` | 密码哈希 | Phase 1 |
|
|
|
|
|
|
| `github.com/jackc/pgx/v5` | PostgreSQL 驱动 | Phase 2 |
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 分阶段实施
|
|
|
|
|
|
|
|
|
|
|
|
### Phase 1:配置扩展 + 数据库连接
|
|
|
|
|
|
|
|
|
|
|
|
**目标**:扩展配置结构体,建立 PostgreSQL 连接池。
|
|
|
|
|
|
|
|
|
|
|
|
| # | 任务 | 文件 | 说明 |
|
|
|
|
|
|
|---|------|------|------|
|
|
|
|
|
|
| 1.1 | 扩展 Config 结构体 | `internal/config/config.go` | 新增 `AuthConfig`(JWTSecret, AccessTTL, RefreshTTL),`StorageConfig` 已有 Driver/DSN 字段 |
|
|
|
|
|
|
| 1.2 | 添加配置默认值 | `internal/config/config.go` | `auth.access_ttl` 默认 15,`auth.refresh_ttl` 默认 10080 |
|
|
|
|
|
|
| 1.3 | 实现数据库连接池 | `internal/store/db.go` | `NewPostgresPool(ctx, dsn) (*pgxpool.Pool, error)`,启动时调用,注入到各 repository |
|
|
|
|
|
|
| 1.4 | 编写 schema 迁移脚本 | `migrations/001_users.up.sql` | `users` 表 + `refresh_tokens` 表 |
|
|
|
|
|
|
| 1.5 | 编写回滚脚本 | `migrations/001_users.down.sql` | DROP TABLE |
|
|
|
|
|
|
| 1.6 | main.go 条件初始化 DB | `cmd/server/main.go` | `storage.driver == "postgres"` 时创建 pgxpool,否则跳过(纯内存模式) |
|
|
|
|
|
|
|
|
|
|
|
|
**配置扩展示例**:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
// internal/config/config.go 新增
|
|
|
|
|
|
|
|
|
|
|
|
type AuthConfig struct {
|
|
|
|
|
|
JWTSecret string `mapstructure:"jwt_secret"` // 必须通过 CAMTALK_AUTH_JWT_SECRET 设置
|
|
|
|
|
|
AccessTTL int `mapstructure:"access_ttl"` // 分钟,默认 15
|
|
|
|
|
|
RefreshTTL int `mapstructure:"refresh_ttl"` // 分钟,默认 10080
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**数据库连接**:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
// internal/store/db.go
|
|
|
|
|
|
|
|
|
|
|
|
package store
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
func NewPostgresPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
|
|
|
|
|
cfg, err := pgxpool.ParseConfig(dsn)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
cfg.MaxConns = 10
|
|
|
|
|
|
return pgxpool.NewWithConfig(ctx, cfg)
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### Phase 2:用户模型 + Repository
|
|
|
|
|
|
|
|
|
|
|
|
**目标**:定义用户数据模型和持久化接口。
|
|
|
|
|
|
|
|
|
|
|
|
| # | 任务 | 文件 | 说明 |
|
|
|
|
|
|
|---|------|------|------|
|
|
|
|
|
|
| 2.1 | 扩展 models | `internal/models/models.go` | 新增 `User` 结构体(ID, Username, PasswordHash, CreatedAt, UpdatedAt) |
|
|
|
|
|
|
| 2.2 | 定义 UserRepository 接口 | `internal/store/user.go` | `Create`, `FindByUsername`, `FindByID`, `SaveRefreshToken`, `FindRefreshToken`, `DeleteRefreshToken` |
|
|
|
|
|
|
| 2.3 | 实现 PostgreSQL UserRepository | `internal/store/user_pg.go` | pgx 实现,所有方法使用 `pgxpool.Pool` |
|
|
|
|
|
|
| 2.4 | 实现内存 UserRepository(测试用) | `internal/store/user_mem.go` | `sync.RWMutex` + map,单元测试时注入 |
|
|
|
|
|
|
| 2.5 | 编写 UserRepository 测试 | `internal/store/user_pg_test.go` | 需要测试 DB 或 mock |
|
|
|
|
|
|
|
|
|
|
|
|
**UserRepository 接口**:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
// internal/store/user.go
|
|
|
|
|
|
|
|
|
|
|
|
package store
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
|
ErrUserNotFound = errors.New("user not found")
|
|
|
|
|
|
ErrUsernameTaken = errors.New("username already taken")
|
|
|
|
|
|
ErrRefreshTokenNotFound = errors.New("refresh token not found")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type UserRepository interface {
|
|
|
|
|
|
// Create 创建用户,返回生成的 ID。
|
|
|
|
|
|
Create(ctx context.Context, username, passwordHash string) (string, error)
|
|
|
|
|
|
|
|
|
|
|
|
// FindByUsername 按用户名查找,不存在返回 ErrUserNotFound。
|
|
|
|
|
|
FindByUsername(ctx context.Context, username string) (*User, error)
|
|
|
|
|
|
|
|
|
|
|
|
// FindByID 按 ID 查找,不存在返回 ErrUserNotFound。
|
|
|
|
|
|
FindByID(ctx context.Context, id string) (*User, error)
|
|
|
|
|
|
|
|
|
|
|
|
// SaveRefreshToken 保存 refresh token hash。
|
|
|
|
|
|
SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error
|
|
|
|
|
|
|
|
|
|
|
|
// FindRefreshToken 按 token hash 查找,返回 user_id。不存在返回 ErrRefreshTokenNotFound。
|
|
|
|
|
|
FindRefreshToken(ctx context.Context, tokenHash string) (string, error)
|
|
|
|
|
|
|
|
|
|
|
|
// DeleteRefreshToken 按 token hash 删除。
|
|
|
|
|
|
DeleteRefreshToken(ctx context.Context, tokenHash string) error
|
|
|
|
|
|
|
|
|
|
|
|
// DeleteUserRefreshTokens 删除用户的所有 refresh token(登出所有设备)。
|
|
|
|
|
|
DeleteUserRefreshTokens(ctx context.Context, userID string) error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// User 用户数据模型(store 层)。
|
|
|
|
|
|
type User struct {
|
|
|
|
|
|
ID string
|
|
|
|
|
|
Username string
|
|
|
|
|
|
PasswordHash string
|
|
|
|
|
|
CreatedAt time.Time
|
|
|
|
|
|
UpdatedAt time.Time
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### Phase 3:JWT + 认证服务
|
|
|
|
|
|
|
|
|
|
|
|
**目标**:实现 JWT 签发/校验、bcrypt 密码处理、认证业务逻辑。
|
|
|
|
|
|
|
|
|
|
|
|
| # | 任务 | 文件 | 说明 |
|
|
|
|
|
|
|---|------|------|------|
|
|
|
|
|
|
| 3.1 | 实现 TokenManager | `internal/auth/jwt.go` | `GeneratePair`, `ValidateAccess`, `ValidateRefresh`, `HashToken` |
|
|
|
|
|
|
| 3.2 | 实现密码工具 | `internal/auth/password.go` | `HashPassword(password) (string, error)`, `CheckPassword(hash, password) error` |
|
|
|
|
|
|
| 3.3 | 实现 AuthMiddleware | `internal/auth/middleware.go` | Gin 中间件,从 `Authorization: Bearer <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 5:Session Manager 改造
|
|
|
|
|
|
|
|
|
|
|
|
**目标**:Session Manager 关联 user_id,支持对话列表查询。
|
|
|
|
|
|
|
|
|
|
|
|
| # | 任务 | 文件 | 说明 |
|
|
|
|
|
|
|---|------|------|------|
|
|
|
|
|
|
| 5.1 | 扩展 Session 模型 | `internal/models/models.go` | `Session` 新增 `UserID`, `Title`, `UpdatedAt` 字段 |
|
|
|
|
|
|
| 5.2 | 扩展 Manager 接口 | `internal/session/manager.go` | `Create` 签名加 `userID`;新增 `ListByUser`, `UpdateTitle`;新增 `ConversationSummary` 类型 |
|
|
|
|
|
|
| 5.3 | 修改 MemoryManager | `internal/session/memory.go` | `Create` 存储 userID;实现 `ListByUser`(遍历+过滤+排序);实现 `UpdateTitle` |
|
|
|
|
|
|
| 5.4 | 修改 RedisManager | `internal/session/redis.go` | `session:{id}:meta` 新增 `user_id`、`title` 字段;`ListByUser` 使用 Redis Set `user:{id}:sessions` 索引 |
|
|
|
|
|
|
| 5.5 | 编写新方法测试 | `internal/session/memory_test.go` | 覆盖 ListByUser 分页、UpdateTitle、Create 带 userID |
|
|
|
|
|
|
| 5.6 | 更新 ws handler 调用 | `internal/ws/handler.go` | `sessionMgr.Create` 调用传入 userID(此时 Phase 7 才有真实 userID,先用空字符串兼容) |
|
|
|
|
|
|
|
|
|
|
|
|
**接口变更**:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
// internal/session/manager.go 变更
|
|
|
|
|
|
|
|
|
|
|
|
type Manager interface {
|
|
|
|
|
|
// Create 签名变更:新增 userID 参数
|
|
|
|
|
|
Create(ctx context.Context, userID string, config models.SessionConfig) (string, error)
|
|
|
|
|
|
|
|
|
|
|
|
// 新增方法
|
|
|
|
|
|
ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error)
|
|
|
|
|
|
UpdateTitle(ctx context.Context, sessionID string, title string) error
|
|
|
|
|
|
|
|
|
|
|
|
// 其余方法不变...
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ConversationSummary struct {
|
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
|
Title string `json:"title"`
|
|
|
|
|
|
LastMessage string `json:"last_message"`
|
|
|
|
|
|
MessageCount int `json:"message_count"`
|
|
|
|
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**MemoryManager ListByUser 实现思路**:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
func (m *MemoryManager) ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error) {
|
|
|
|
|
|
m.mu.RLock()
|
|
|
|
|
|
defer m.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
|
|
// 收集该用户的所有 session
|
|
|
|
|
|
var list []ConversationSummary
|
|
|
|
|
|
for _, entry := range m.sessions {
|
|
|
|
|
|
if entry.session.UserID != userID {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
summary := ConversationSummary{
|
|
|
|
|
|
ID: entry.session.ID,
|
|
|
|
|
|
Title: entry.session.Title,
|
|
|
|
|
|
MessageCount: len(entry.history),
|
|
|
|
|
|
UpdatedAt: entry.lastActive,
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(entry.history) > 0 {
|
|
|
|
|
|
summary.LastMessage = entry.history[len(entry.history)-1].Content
|
|
|
|
|
|
}
|
|
|
|
|
|
list = append(list, summary)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 按 UpdatedAt 降序排序
|
|
|
|
|
|
sort.Slice(list, func(i, j int) bool {
|
|
|
|
|
|
return list[i].UpdatedAt.After(list[j].UpdatedAt)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
total := len(list)
|
|
|
|
|
|
|
|
|
|
|
|
// 分页
|
|
|
|
|
|
start := (page - 1) * size
|
|
|
|
|
|
if start >= total {
|
|
|
|
|
|
return []ConversationSummary{}, total, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
end := start + size
|
|
|
|
|
|
if end > total {
|
|
|
|
|
|
end = total
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return list[start:end], total, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### Phase 6:对话 REST API
|
|
|
|
|
|
|
|
|
|
|
|
**目标**:实现对话 CRUD 和历史消息查询端点。
|
|
|
|
|
|
|
|
|
|
|
|
| # | 任务 | 文件 | 说明 |
|
|
|
|
|
|
|---|------|------|------|
|
|
|
|
|
|
| 6.1 | 实现 ConversationHandler | `internal/api/conversation.go` | `List`, `Create`, `Get`, `UpdateTitle`, `Delete`, `GetMessages` |
|
|
|
|
|
|
| 6.2 | 权限校验 | `internal/api/conversation.go` | 每个端点校验 session.UserID == claims.UserID |
|
|
|
|
|
|
| 6.3 | 注册路由 | `internal/api/conversation.go` | `RegisterRoutes(rg *gin.RouterGroup)`,全部走 AuthMiddleware |
|
|
|
|
|
|
| 6.4 | main.go 接入 | `cmd/server/main.go` | 创建 ConversationHandler 并注册 |
|
|
|
|
|
|
| 6.5 | 编写 API 测试 | `internal/api/conversation_test.go` | httptest + mock SessionManager |
|
|
|
|
|
|
|
|
|
|
|
|
**ConversationHandler 结构**:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
// internal/api/conversation.go
|
|
|
|
|
|
|
|
|
|
|
|
type ConversationHandler struct {
|
|
|
|
|
|
sessionMgr session.Manager
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (h *ConversationHandler) RegisterRoutes(rg *gin.RouterGroup) {
|
|
|
|
|
|
conv := rg.Group("/conversations", auth.AuthMiddleware(tokenMgr))
|
|
|
|
|
|
{
|
|
|
|
|
|
conv.GET("", h.List)
|
|
|
|
|
|
conv.POST("", h.Create)
|
|
|
|
|
|
conv.GET("/:id", h.Get)
|
|
|
|
|
|
conv.PATCH("/:id", h.UpdateTitle)
|
|
|
|
|
|
conv.DELETE("/:id", h.Delete)
|
|
|
|
|
|
conv.GET("/:id/messages", h.GetMessages)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**权限校验模式**(每个端点复用):
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
func (h *ConversationHandler) getSessionForUser(c *gin.Context, sessionID string) (*models.Session, error) {
|
|
|
|
|
|
sess, err := h.sessionMgr.Get(c.Request.Context(), sessionID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
userID := c.GetString("user_id") // 从 AuthMiddleware 写入
|
|
|
|
|
|
if sess.UserID != userID {
|
|
|
|
|
|
return nil, session.ErrSessionNotFound // 返回 404 而非 403,避免信息泄露
|
|
|
|
|
|
}
|
|
|
|
|
|
return sess, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**GetMessages 实现要点**:
|
|
|
|
|
|
- 从 Session Manager 的 `GetHistory` 获取消息
|
|
|
|
|
|
- 支持 `?limit=50&before=<message_id>` 分页
|
|
|
|
|
|
- 内存实现中,history 是全量存储的,直接按索引切片即可
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### Phase 7:WebSocket 认证集成
|
|
|
|
|
|
|
|
|
|
|
|
**目标**:WS 连接需要 JWT 认证,支持指定 conversation_id 恢复历史对话。
|
|
|
|
|
|
|
|
|
|
|
|
| # | 任务 | 文件 | 说明 |
|
|
|
|
|
|
|---|------|------|------|
|
|
|
|
|
|
| 7.1 | 修改 ServeWS 签名 | `internal/ws/handler.go` | 新增 `tokenMgr *auth.TokenManager` 参数 |
|
|
|
|
|
|
| 7.2 | WS 连接认证 | `internal/ws/handler.go` | 从 `?token=xxx` 提取并校验 access_token,失败返回 401 |
|
|
|
|
|
|
| 7.3 | conversation_id 处理 | `internal/ws/handler.go` | `?conversation_id=xxx` 存在时:校验归属 → LoadFromDB → 复用 session;否则创建新 session |
|
|
|
|
|
|
| 7.4 | AppendMessage 自动标题 | `internal/session/memory.go` | 首条 user 消息时,如果 title == "新对话",自动更新为前 20 字符 |
|
|
|
|
|
|
| 7.5 | main.go 更新 | `cmd/server/main.go` | 传入 tokenMgr 到 ServeWS |
|
|
|
|
|
|
| 7.7 | 编写认证测试 | `internal/ws/handler_test.go` | 测试无 token / 过期 token / 有效 token / conversation_id 恢复 |
|
|
|
|
|
|
|
|
|
|
|
|
**WS 连接流程变更**:
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
客户端请求: GET /ws?token=<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.SaveMessage(write-through) |
|
|
|
|
|
|
| 8.4 | LoadFromDB 实现 | `internal/session/memory.go` | 从 PostgreSQL 读取消息加载到内存 history |
|
|
|
|
|
|
| 8.5 | ConversationSummary 查询优化 | `internal/store/message_pg.go` | 对话列表的 last_message 和 message_count 通过 SQL 聚合查询 |
|
|
|
|
|
|
|
|
|
|
|
|
**MessageRepository 接口**:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
// internal/store/message.go
|
|
|
|
|
|
|
|
|
|
|
|
type MessageRepository interface {
|
|
|
|
|
|
// SaveMessage 保存一条消息。
|
|
|
|
|
|
SaveMessage(ctx context.Context, sessionID string, msg models.Message, tokensUsed int) error
|
|
|
|
|
|
|
|
|
|
|
|
// GetMessages 获取会话的消息列表(分页,按 id 升序)。
|
|
|
|
|
|
GetMessages(ctx context.Context, sessionID string, limit int, beforeID int64) ([]StoredMessage, error)
|
|
|
|
|
|
|
|
|
|
|
|
// GetLastMessage 获取会话的最后一条消息。
|
|
|
|
|
|
GetLastMessage(ctx context.Context, sessionID string) (*StoredMessage, error)
|
|
|
|
|
|
|
|
|
|
|
|
// GetMessageCount 获取会话的消息总数。
|
|
|
|
|
|
GetMessageCount(ctx context.Context, sessionID string) (int, error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type StoredMessage struct {
|
|
|
|
|
|
ID int64 `json:"id"`
|
|
|
|
|
|
SessionID string `json:"-"`
|
|
|
|
|
|
Role string `json:"role"`
|
|
|
|
|
|
Content string `json:"content"`
|
|
|
|
|
|
TokensUsed int `json:"tokens_used"`
|
|
|
|
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Write-Through 模式**:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
// internal/session/memory.go — AppendMessage 改造
|
|
|
|
|
|
|
|
|
|
|
|
func (m *MemoryManager) AppendMessage(ctx context.Context, sessionID string, msg models.Message) error {
|
|
|
|
|
|
// 1. 写热存储(内存/Redis)
|
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
|
entry, ok := m.sessions[sessionID]
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
m.mu.Unlock()
|
|
|
|
|
|
return ErrSessionNotFound
|
|
|
|
|
|
}
|
|
|
|
|
|
entry.history = append(entry.history, msg)
|
|
|
|
|
|
entry.lastActive = time.Now()
|
|
|
|
|
|
if msg.Role == "user" && entry.session.Title == "新对话" {
|
|
|
|
|
|
entry.session.Title = generateTitle(msg.Content)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(entry.history) > m.maxHistory {
|
|
|
|
|
|
entry.history = entry.history[len(entry.history)-m.maxHistory:]
|
|
|
|
|
|
}
|
|
|
|
|
|
m.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 写冷存储(PostgreSQL,异步不阻塞)
|
|
|
|
|
|
if m.msgRepo != nil {
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
if err := m.msgRepo.SaveMessage(context.Background(), sessionID, msg, 0); err != nil {
|
|
|
|
|
|
logger.Log.Warnw("persist message failed", "session", sessionID, "error", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### Phase 9:旧端点废弃 + 集成收尾
|
|
|
|
|
|
|
|
|
|
|
|
**目标**:废弃旧的 `/api/sessions` 端点,完成全链路集成。
|
|
|
|
|
|
|
|
|
|
|
|
| # | 任务 | 文件 | 说明 |
|
|
|
|
|
|
|---|------|------|------|
|
|
|
|
|
|
| 9.1 | 废弃旧 session 路由 | `internal/api/session.go` | 保留代码但标记 deprecated,或直接删除 |
|
|
|
|
|
|
| 9.2 | main.go 完整组装 | `cmd/server/main.go` | 按 storage.driver 选择注入 MemoryRepo 或 PgRepo |
|
|
|
|
|
|
| 9.3 | .env.example 更新 | `backend/.env.example` | 新增 `CAMTALK_AUTH_JWT_SECRET`、`CAMTALK_STORAGE_*` |
|
|
|
|
|
|
| 9.4 | config.yaml 更新 | `backend/config.yaml` | 新增 auth 配置块 |
|
|
|
|
|
|
| 9.5 | docker-compose 添加 PG | `docker-compose.yml` | PostgreSQL 15 服务 + 环境变量 |
|
|
|
|
|
|
| 9.6 | go mod tidy | `backend/` | 清理依赖 |
|
|
|
|
|
|
| 9.7 | 端到端手动测试 | — | 注册 → 登录 → 创建对话 → 发送消息 → 登出 → 重新登录 → 查看对话列表 → 选择历史对话继续 |
|
|
|
|
|
|
|
|
|
|
|
|
**main.go 依赖注入全貌**:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
func main() {
|
|
|
|
|
|
cfg, _ := config.Load()
|
|
|
|
|
|
logger.Init(cfg.Log.Level, cfg.Log.Format)
|
|
|
|
|
|
|
|
|
|
|
|
// --- 存储层 ---
|
|
|
|
|
|
var (
|
|
|
|
|
|
userRepo store.UserRepository
|
|
|
|
|
|
msgRepo store.MessageRepository
|
|
|
|
|
|
sessionMgr session.Manager
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if cfg.Storage.Driver == "postgres" {
|
|
|
|
|
|
pool, _ := store.NewPostgresPool(ctx, cfg.Storage.DSN)
|
|
|
|
|
|
defer pool.Close()
|
|
|
|
|
|
userRepo = store.NewPgUserRepository(pool)
|
|
|
|
|
|
msgRepo = store.NewPgMessageRepository(pool)
|
|
|
|
|
|
sessionMgr = session.NewMemoryManager(..., msgRepo) // 注入 msgRepo
|
|
|
|
|
|
} else {
|
|
|
|
|
|
userRepo = store.NewMemUserRepository()
|
|
|
|
|
|
sessionMgr = session.NewMemoryManager(...) // 无 msgRepo,纯内存
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- 认证 ---
|
|
|
|
|
|
tokenMgr := auth.NewTokenManager(cfg.Auth.JWTSecret,
|
|
|
|
|
|
time.Duration(cfg.Auth.AccessTTL)*time.Minute,
|
|
|
|
|
|
time.Duration(cfg.Auth.RefreshTTL)*time.Minute)
|
|
|
|
|
|
authService := auth.NewAuthService(tokenMgr, userRepo)
|
|
|
|
|
|
|
|
|
|
|
|
// --- AI 服务(不变)---
|
|
|
|
|
|
sttService := ...
|
|
|
|
|
|
llmService := ...
|
|
|
|
|
|
ttsService := ...
|
|
|
|
|
|
orch := orchestrator.New(sttService, llmService, ttsService, sessionMgr, cfg)
|
|
|
|
|
|
|
|
|
|
|
|
// --- 路由 ---
|
|
|
|
|
|
r := gin.New()
|
|
|
|
|
|
apiGroup := r.Group("/api")
|
|
|
|
|
|
apiGroup.GET("/health", healthHandler(sessionMgr, cfg))
|
|
|
|
|
|
|
|
|
|
|
|
authHandler := api.NewAuthHandler(authService)
|
|
|
|
|
|
authHandler.RegisterRoutes(apiGroup)
|
|
|
|
|
|
|
|
|
|
|
|
convHandler := api.NewConversationHandler(sessionMgr, tokenMgr)
|
|
|
|
|
|
convHandler.RegisterRoutes(apiGroup)
|
|
|
|
|
|
|
|
|
|
|
|
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr))
|
|
|
|
|
|
|
|
|
|
|
|
// ... 启动
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-14 18:05:50 +08:00
|
|
|
|
## 前端 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; // JWT,15 分钟有效
|
|
|
|
|
|
refresh_token: string; // JWT,7 天有效
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
```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);
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-14 16:40:06 +08:00
|
|
|
|
## 关键文件清单
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
backend/
|
|
|
|
|
|
cmd/server/main.go ← Phase 1.6, 4.4, 7.5, 9.2
|
|
|
|
|
|
migrations/
|
|
|
|
|
|
001_users.up.sql ← Phase 1.4(新建)
|
|
|
|
|
|
001_users.down.sql ← Phase 1.5(新建)
|
|
|
|
|
|
internal/
|
|
|
|
|
|
config/config.go ← Phase 1.1, 1.2(修改)
|
|
|
|
|
|
errors/codes.go ← Phase 4.2(修改,新增错误码)
|
|
|
|
|
|
models/models.go ← Phase 5.1(修改)
|
|
|
|
|
|
store/
|
|
|
|
|
|
db.go ← Phase 1.3(新建)
|
|
|
|
|
|
user.go ← Phase 2.2(新建)
|
|
|
|
|
|
user_pg.go ← Phase 2.3(新建)
|
|
|
|
|
|
user_mem.go ← Phase 2.4(新建)
|
|
|
|
|
|
user_pg_test.go ← Phase 2.5(新建)
|
|
|
|
|
|
message.go ← Phase 8.1(新建)
|
|
|
|
|
|
message_pg.go ← Phase 8.2(新建)
|
|
|
|
|
|
auth/
|
|
|
|
|
|
jwt.go ← Phase 3.1(新建)
|
|
|
|
|
|
password.go ← Phase 3.2(新建)
|
|
|
|
|
|
middleware.go ← Phase 3.3(新建)
|
|
|
|
|
|
service.go ← Phase 3.4, 3.5(新建)
|
|
|
|
|
|
jwt_test.go ← Phase 3.6(新建)
|
|
|
|
|
|
service_test.go ← Phase 3.7(新建)
|
|
|
|
|
|
session/
|
|
|
|
|
|
manager.go ← Phase 5.2(修改)
|
|
|
|
|
|
memory.go ← Phase 5.3, 7.4, 8.3, 8.4(修改)
|
|
|
|
|
|
redis.go ← Phase 5.4(修改)
|
|
|
|
|
|
memory_test.go ← Phase 5.5(修改)
|
|
|
|
|
|
api/
|
|
|
|
|
|
auth.go ← Phase 4.1, 4.3(新建)
|
|
|
|
|
|
auth_test.go ← Phase 4.5(新建)
|
|
|
|
|
|
conversation.go ← Phase 6.1, 6.2, 6.3(新建)
|
|
|
|
|
|
conversation_test.go ← Phase 6.5(新建)
|
|
|
|
|
|
session.go ← Phase 9.1(废弃/删除)
|
|
|
|
|
|
ws/
|
|
|
|
|
|
handler.go ← Phase 7.1, 7.2, 7.3(修改)
|
|
|
|
|
|
handler_test.go ← Phase 7.7(修改)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 执行顺序与依赖关系
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
Phase 1 (配置 + DB 连接)
|
|
|
|
|
|
↓
|
|
|
|
|
|
Phase 2 (User 模型 + Repository) ← 依赖 Phase 1
|
|
|
|
|
|
↓
|
|
|
|
|
|
Phase 3 (JWT + AuthService) ← 依赖 Phase 2
|
|
|
|
|
|
↓
|
|
|
|
|
|
Phase 4 (Auth REST API) ← 依赖 Phase 3
|
|
|
|
|
|
↓
|
|
|
|
|
|
Phase 5 (Session Manager 改造) ← 依赖 Phase 1(模型扩展),可与 Phase 2-4 并行
|
|
|
|
|
|
↓
|
|
|
|
|
|
Phase 6 (Conversation REST API) ← 依赖 Phase 4 + 5
|
|
|
|
|
|
↓
|
|
|
|
|
|
Phase 7 (WS 认证集成) ← 依赖 Phase 3 + 5
|
|
|
|
|
|
↓
|
|
|
|
|
|
Phase 8 (消息持久化) ← 依赖 Phase 1 + 5
|
|
|
|
|
|
↓
|
|
|
|
|
|
Phase 9 (废弃旧端点 + 集成收尾) ← 依赖全部
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**可并行的路径**:
|
|
|
|
|
|
- Phase 2-4(用户认证链路)和 Phase 5(Session 改造)可并行开发
|
|
|
|
|
|
- Phase 6(对话 API)和 Phase 7(WS 认证)可并行开发
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 验证方案
|
|
|
|
|
|
|
|
|
|
|
|
| 层级 | 方法 | 覆盖范围 |
|
|
|
|
|
|
|------|------|---------|
|
|
|
|
|
|
| 单元测试 | `go test ./internal/auth/... ./internal/store/...` | JWT 生成/校验、密码 hash、Repository CRUD |
|
|
|
|
|
|
| API 测试 | `httptest` + `go test ./internal/api/...` | 注册/登录/刷新/登出、对话 CRUD、权限校验 |
|
|
|
|
|
|
| 集成测试 | 启动 Gin test server + WS client | WS 认证、conversation_id 恢复、消息持久化 |
|
|
|
|
|
|
| 端到端 | 手动测试 | 注册 → 登录 → 对话 → 登出 → 重登 → 历史列表 → 继续对话 |
|
|
|
|
|
|
| 静态检查 | `go vet ./...` + `go test ./...` | 全量通过 |
|