feat: 配置扩展与 PostgreSQL 连接池 #93
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/hhs/camtalk/internal/logger"
|
"github.com/hhs/camtalk/internal/logger"
|
||||||
"github.com/hhs/camtalk/internal/orchestrator"
|
"github.com/hhs/camtalk/internal/orchestrator"
|
||||||
"github.com/hhs/camtalk/internal/session"
|
"github.com/hhs/camtalk/internal/session"
|
||||||
|
"github.com/hhs/camtalk/internal/store"
|
||||||
"github.com/hhs/camtalk/internal/ws"
|
"github.com/hhs/camtalk/internal/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,6 +45,21 @@ func main() {
|
|||||||
"addr", cfg.Server.Addr(),
|
"addr", cfg.Server.Addr(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 初始化存储层(条件初始化 PostgreSQL)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if cfg.Storage.Driver == "postgres" {
|
||||||
|
pool, err := store.NewPostgresPool(ctx, cfg.Storage.DSN)
|
||||||
|
if err != nil {
|
||||||
|
logger.Log.Fatalw("failed to connect to postgres", "error", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
logger.Log.Infow("postgres connected", "driver", cfg.Storage.Driver)
|
||||||
|
// TODO: Phase 2 - 初始化 UserRepository 和 MessageRepository
|
||||||
|
_ = pool
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化 Session Manager(MVP 默认内存实现)
|
// 初始化 Session Manager(MVP 默认内存实现)
|
||||||
var sessionMgr session.Manager
|
var sessionMgr session.Manager
|
||||||
// TODO: 当 Redis 配置非空时切换为 RedisManager
|
// TODO: 当 Redis 配置非空时切换为 RedisManager
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
module github.com/hhs/camtalk
|
module github.com/hhs/camtalk
|
||||||
|
|
||||||
go 1.24
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/redis/go-redis/v9 v9.20.1
|
github.com/redis/go-redis/v9 v9.20.1
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
@@ -27,6 +28,9 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
@@ -50,8 +54,9 @@ require (
|
|||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/crypto v0.23.0 // indirect
|
golang.org/x/crypto v0.23.0 // indirect
|
||||||
golang.org/x/net v0.25.0 // indirect
|
golang.org/x/net v0.25.0 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/text v0.29.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
@@ -120,16 +128,18 @@ golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
|||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type Config struct {
|
|||||||
AI AIConfig `mapstructure:"ai"`
|
AI AIConfig `mapstructure:"ai"`
|
||||||
Storage StorageConfig `mapstructure:"storage"`
|
Storage StorageConfig `mapstructure:"storage"`
|
||||||
Log LogConfig `mapstructure:"log"`
|
Log LogConfig `mapstructure:"log"`
|
||||||
|
Auth AuthConfig `mapstructure:"auth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionConfig 会话管理配置。
|
// SessionConfig 会话管理配置。
|
||||||
@@ -99,6 +100,13 @@ type LogConfig struct {
|
|||||||
Format string `mapstructure:"format"`
|
Format string `mapstructure:"format"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthConfig 认证配置。
|
||||||
|
type AuthConfig struct {
|
||||||
|
JWTSecret string `mapstructure:"jwt_secret"` // JWT 签名密钥,必须通过环境变量 CAMTALK_AUTH_JWT_SECRET 设置
|
||||||
|
AccessTTL int `mapstructure:"access_ttl"` // Access Token 过期时间(分钟),默认 15
|
||||||
|
RefreshTTL int `mapstructure:"refresh_ttl"` // Refresh Token 过期时间(分钟),默认 10080(7天)
|
||||||
|
}
|
||||||
|
|
||||||
// Load 加载配置。优先级:环境变量 > config.{env}.yaml > config.yaml。
|
// Load 加载配置。优先级:环境变量 > config.{env}.yaml > config.yaml。
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
v := viper.New()
|
v := viper.New()
|
||||||
@@ -146,6 +154,8 @@ func Load() (*Config, error) {
|
|||||||
v.SetDefault("storage.driver", "memory")
|
v.SetDefault("storage.driver", "memory")
|
||||||
v.SetDefault("log.level", "info")
|
v.SetDefault("log.level", "info")
|
||||||
v.SetDefault("log.format", "console")
|
v.SetDefault("log.format", "console")
|
||||||
|
v.SetDefault("auth.access_ttl", 15)
|
||||||
|
v.SetDefault("auth.refresh_ttl", 10080)
|
||||||
|
|
||||||
// 读取基础配置文件
|
// 读取基础配置文件
|
||||||
_ = v.ReadInConfig() // 文件不存在不报错
|
_ = v.ReadInConfig() // 文件不存在不报错
|
||||||
|
|||||||
17
backend/internal/store/db.go
Normal file
17
backend/internal/store/db.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewPostgresPool 创建 PostgreSQL 连接池。
|
||||||
|
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)
|
||||||
|
}
|
||||||
5
backend/migrations/001_users.down.sql
Normal file
5
backend/migrations/001_users.down.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- 删除 Refresh Token 表(自动删除相关索引)
|
||||||
|
DROP TABLE IF EXISTS refresh_tokens;
|
||||||
|
|
||||||
|
-- 删除用户表(自动删除相关索引)
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
26
backend/migrations/001_users.up.sql
Normal file
26
backend/migrations/001_users.up.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- 用户表
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
username VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 用户名索引(用于登录查询)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
|
||||||
|
-- Refresh Token 表
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Token hash 索引(用于刷新验证)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||||
|
|
||||||
|
-- 用户 ID 索引(用于登出所有设备)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||||
@@ -13,6 +13,12 @@
|
|||||||
│ ├── LLM: GPT-4o(默认) / 通义千问等 OpenAI 兼容模型
|
│ ├── LLM: GPT-4o(默认) / 通义千问等 OpenAI 兼容模型
|
||||||
│ └── TTS: OpenAI TTS(默认) / MiMo TTS
|
│ └── TTS: OpenAI TTS(默认) / MiMo TTS
|
||||||
├── 持久化层 → 数据库选型: PostgreSQL(规划中,MVP 阶段使用内存存储)
|
├── 持久化层 → 数据库选型: PostgreSQL(规划中,MVP 阶段使用内存存储)
|
||||||
|
├── 认证与用户系统
|
||||||
|
│ ├── 认证方案: JWT (HS256), access 15min + refresh 7day
|
||||||
|
│ ├── JWT 库: golang-jwt/jwt/v5
|
||||||
|
│ ├── 密码哈希: bcrypt
|
||||||
|
│ ├── 数据库驱动: pgx/v5(手写 SQL,不用 ORM)
|
||||||
|
│ └── 前端 Token 存储: localStorage
|
||||||
└── 前端边缘处理层
|
└── 前端边缘处理层
|
||||||
├── 边缘推理: ONNX Runtime Web(规划中,MVP 使用 Canvas 像素比较)
|
├── 边缘推理: ONNX Runtime Web(规划中,MVP 使用 Canvas 像素比较)
|
||||||
├── 语音检测: @ricky0123/vad-web
|
├── 语音检测: @ricky0123/vad-web
|
||||||
@@ -214,3 +220,80 @@ vad-web 是"够用且最轻"的平衡点——直接包装浏览器原生 WebRTC
|
|||||||
| MediaDevices API | 直接用浏览器原生接口,不加封装层 |
|
| MediaDevices API | 直接用浏览器原生接口,不加封装层 |
|
||||||
|
|
||||||
与"前端做轻量预处理"原则一致:前端层只需采集和判断"有没有值得发给后端的数据"。
|
与"前端做轻量预处理"原则一致:前端层只需采集和判断"有没有值得发给后端的数据"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、认证与用户系统选型
|
||||||
|
|
||||||
|
### 总览
|
||||||
|
|
||||||
|
| 能力 | 选型 | 选择理由 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 认证方案 | JWT (HS256) | 无状态,分布式友好,实现简单 |
|
||||||
|
| JWT 库 | golang-jwt/jwt/v5 | 社区主流,v5 活跃维护 |
|
||||||
|
| 密码哈希 | bcrypt | Go 标准库直接可用,安全性足够 |
|
||||||
|
| 数据库驱动 | pgx/v5 | Go 生态性能最优的 PostgreSQL 驱动 |
|
||||||
|
| 数据库迁移 | 手写 SQL | MVP 阶段足够,后续可引入 golang-migrate |
|
||||||
|
|
||||||
|
### 认证方案:JWT
|
||||||
|
|
||||||
|
| 方案 | 特点 | 适用场景 |
|
||||||
|
|------|------|---------|
|
||||||
|
| **JWT (HS256)** | 无状态 token,服务端不存 session,水平扩展友好 | 分布式部署、前后端分离 |
|
||||||
|
| Session + Cookie | 有状态,服务端存 session(通常 Redis) | 传统 Web 应用、需要服务端控制会话 |
|
||||||
|
| OAuth2 | 第三方登录授权 | 需要接入微信/GitHub 等第三方登录 |
|
||||||
|
|
||||||
|
选择 JWT 的核心理由:项目架构是前后端分离 + WebSocket 长连接,JWT 无需服务端维护 session 状态,天然适配。HS256 对称签名足以满足安全需求,实现比 RS256 简单。
|
||||||
|
|
||||||
|
Token 策略采用 **access (15min) + refresh (7day) 双 token**:access_token 短生命周期降低泄露风险,refresh_token 支持无感续期。
|
||||||
|
|
||||||
|
### JWT 库:golang-jwt/jwt/v5
|
||||||
|
|
||||||
|
| 方案 | 状态 | 特点 |
|
||||||
|
|------|------|------|
|
||||||
|
| **golang-jwt/jwt/v5** | 活跃维护 | dgrijalva/jwt-go 的官方继任,社区主流 |
|
||||||
|
| dgrijalva/jwt-go | 已停维护 | 原始库,不再更新 |
|
||||||
|
| lestrrat-go/jwx | 活跃 | 功能更全(JWE/JWS),但项目只需签名,过度引入 |
|
||||||
|
|
||||||
|
v5 是 Go 生态中 JWT 的事实标准,API 简洁,文档完善。
|
||||||
|
|
||||||
|
### 密码哈希:bcrypt
|
||||||
|
|
||||||
|
| 方案 | 特点 | 选择理由 |
|
||||||
|
|------|------|---------|
|
||||||
|
| **bcrypt** | 自适应 cost factor,抗暴力破解 | Go 标准库 `golang.org/x/crypto/bcrypt` 直接可用 |
|
||||||
|
| argon2 | 2015 年密码哈希竞赛冠军,抗 GPU/ASIC | 安全性更高,但 Go 生态库不如 bcrypt 成熟 |
|
||||||
|
| scrypt | 内存硬哈希 | 参数调优复杂,bcrypt 已足够 |
|
||||||
|
|
||||||
|
bcrypt 的 `cost` 参数可随硬件升级调大,当前默认 cost=10 足够安全。
|
||||||
|
|
||||||
|
### 数据库驱动:pgx/v5
|
||||||
|
|
||||||
|
| 方案 | 特点 | 适用场景 |
|
||||||
|
|------|------|---------|
|
||||||
|
| **pgx/v5** | 原生 PostgreSQL 协议实现,连接池 pgxpool,性能最优 | 需要高性能、直接写 SQL |
|
||||||
|
| GORM | 全功能 ORM,自动迁移、关联预加载 | 快速开发、不想写 SQL |
|
||||||
|
| Ent | Facebook 出品,类型安全的 ORM | 大型项目、强类型需求 |
|
||||||
|
| database/sql + lib/pq | 标准接口,但 lib/pq 已停维护 | 简单场景 |
|
||||||
|
|
||||||
|
项目规模不大(4 张表),手写 SQL 更可控,避免 ORM 的抽象泄漏和性能黑盒。pgx 原生支持 `pgxpool` 连接池,无需额外引入。
|
||||||
|
|
||||||
|
### 数据库迁移:手写 SQL
|
||||||
|
|
||||||
|
| 方案 | 特点 | 适用场景 |
|
||||||
|
|------|------|---------|
|
||||||
|
| **手写 SQL** | 零依赖,完全可控 | 表少(<10 张)、团队小 |
|
||||||
|
| golang-migrate | CLI + 库双模式,支持版本回滚 | 表多、需要严格版本管理 |
|
||||||
|
| Atlas | 声明式迁移,HCL 定义 schema | 大型项目、多环境管理 |
|
||||||
|
|
||||||
|
MVP 阶段 4 张表,手写 `schema.sql` 即可。后续表结构复杂后可引入 golang-migrate。
|
||||||
|
|
||||||
|
### 前端 Token 存储
|
||||||
|
|
||||||
|
| 方案 | 特点 | 选择理由 |
|
||||||
|
|------|------|---------|
|
||||||
|
| **localStorage** | 持久化存储,刷新不丢失,JS 可直接读写 | 简单直接,SPA 应用标准做法 |
|
||||||
|
| httpOnly Cookie | 防 XSS 读取,但需防 CSRF | 传统 Web 应用,需额外 CSRF 防护 |
|
||||||
|
| sessionStorage | 仅当前标签页有效 | 关闭标签页需重新登录,体验差 |
|
||||||
|
|
||||||
|
JWT 存 localStorage,配合请求拦截器统一附加 `Authorization: Bearer <token>` header。refresh_token 同样存 localStorage,401 时自动触发刷新流程。
|
||||||
|
|||||||
750
docs/11-持久化与用户系统设计.md
Normal file
750
docs/11-持久化与用户系统设计.md
Normal file
@@ -0,0 +1,750 @@
|
|||||||
|
# 持久化与用户系统设计
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本文档定义用户注册/登录、JWT 认证、对话历史持久化的完整设计方案。核心目标:**用户登录后可在对话列表中选择历史对话继续交谈**。
|
||||||
|
|
||||||
|
### 设计决策
|
||||||
|
|
||||||
|
| 决策项 | 选择 | 理由 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 认证方式 | JWT(access + refresh 双 token) | 无状态,适合分布式部署 |
|
||||||
|
| 注册方式 | 用户名 + 密码 | MVP 最简方案 |
|
||||||
|
| 密码存储 | bcrypt hash | 行业标准,抗彩虹表 |
|
||||||
|
| 对话恢复 | 对话列表选择 | 用户可见所有历史对话,自主选择继续或新建 |
|
||||||
|
| 对话标题 | 自动取首条用户消息前 20 字符 | 零成本,自然可读 |
|
||||||
|
| 图像持久化 | 不存储 | 节省空间,文字历史已足够 |
|
||||||
|
| 登录后行为 | 先选对话,再进聊天 | 明确的入口,避免困惑 |
|
||||||
|
| WS 认证 | URL query 参数 `?token=xxx` | HTTP Upgrade 无法带 Authorization header |
|
||||||
|
| Token 策略 | access 15min + refresh 7day | 安全性与体验平衡 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、数据库设计
|
||||||
|
|
||||||
|
### 1.1 ER 关系
|
||||||
|
|
||||||
|
```
|
||||||
|
users 1──N sessions 1──N messages
|
||||||
|
│
|
||||||
|
└── refresh_tokens (1──N, token 轮转管理)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 表结构
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 用户表
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
username VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(256) NOT NULL, -- bcrypt hash
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_users_username ON users(username);
|
||||||
|
|
||||||
|
-- 会话(对话)表
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(128) DEFAULT '新对话',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sessions_user_id ON sessions(user_id, updated_at DESC);
|
||||||
|
|
||||||
|
-- 消息表
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(16) NOT NULL, -- "user" | "assistant"
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
tokens_used INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_messages_session_id ON messages(session_id, id);
|
||||||
|
|
||||||
|
-- 刷新令牌表
|
||||||
|
CREATE TABLE refresh_tokens (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash VARCHAR(256) NOT NULL UNIQUE, -- SHA256(refresh_token)
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||||
|
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 与现有设计的差异
|
||||||
|
|
||||||
|
| 变更 | 原设计(`02-系统架构.md`) | 新设计 | 理由 |
|
||||||
|
|------|--------------------------|--------|------|
|
||||||
|
| `sessions.user_id` | `NOT NULL` 无外键 | `REFERENCES users(id) ON DELETE CASCADE` | 关联用户,级联删除 |
|
||||||
|
| `sessions.title` | 无 | `VARCHAR(128) DEFAULT '新对话'` | 对话列表展示 |
|
||||||
|
| `messages.image_url` | 有 | 移除 | 不存储图像 |
|
||||||
|
| `usage_daily` | 有 | MVP 暂不实现 | 按需后加 |
|
||||||
|
| 新增 `users` | 无 | 新增 | 用户系统核心 |
|
||||||
|
| 新增 `refresh_tokens` | 无 | 新增 | JWT refresh 机制 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、JWT 认证设计
|
||||||
|
|
||||||
|
### 2.1 Token 结构
|
||||||
|
|
||||||
|
**access_token**:
|
||||||
|
- payload: `{user_id, username, exp (15min), iat, iss: "camtalk"}`
|
||||||
|
- 签名算法: HS256(对称密钥,从配置读取)
|
||||||
|
- 存储位置: 前端 localStorage
|
||||||
|
|
||||||
|
**refresh_token**:
|
||||||
|
- payload: `{user_id, token_id (UUID), exp (7day), iat, iss: "camtalk"}`
|
||||||
|
- 存储位置: 前端 localStorage + 数据库 `refresh_tokens` 表(存 SHA256 hash)
|
||||||
|
|
||||||
|
### 2.2 认证流程
|
||||||
|
|
||||||
|
#### 注册
|
||||||
|
|
||||||
|
```
|
||||||
|
用户 ──POST /api/auth/register──> 检查 username 唯一性
|
||||||
|
bcrypt hash 密码
|
||||||
|
INSERT users
|
||||||
|
↓
|
||||||
|
生成 access_token + refresh_token
|
||||||
|
存 SHA256(refresh_token) 到 DB
|
||||||
|
↓
|
||||||
|
返回 {user, access_token, refresh_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 登录
|
||||||
|
|
||||||
|
```
|
||||||
|
用户 ──POST /api/auth/login──> 查 users 表 by username
|
||||||
|
bcrypt.CompareHashAndPassword
|
||||||
|
↓
|
||||||
|
生成 access_token + refresh_token
|
||||||
|
存 SHA256(refresh_token) 到 DB
|
||||||
|
↓
|
||||||
|
返回 {user, access_token, refresh_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 刷新
|
||||||
|
|
||||||
|
```
|
||||||
|
用户 ──POST /api/auth/refresh──> 校验 refresh_token 签名和过期
|
||||||
|
查 DB 验证 hash 存在
|
||||||
|
↓
|
||||||
|
撤销旧 refresh_token(DELETE)
|
||||||
|
生成新的 access + refresh
|
||||||
|
存新 refresh_token hash
|
||||||
|
↓
|
||||||
|
返回 {access_token, refresh_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 登出
|
||||||
|
|
||||||
|
```
|
||||||
|
用户 ──POST /api/auth/logout──> 撤销 refresh_token (DELETE from DB)
|
||||||
|
前端清除 localStorage
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Go 实现接口
|
||||||
|
|
||||||
|
```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 // 15min
|
||||||
|
refreshTTL time.Duration // 7day
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePair 生成 access + refresh token 对。
|
||||||
|
func (tm *TokenManager) GeneratePair(userID, username string) (access, refresh string, err error)
|
||||||
|
|
||||||
|
// ValidateAccess 校验 access_token,返回 Claims。
|
||||||
|
func (tm *TokenManager) ValidateAccess(tokenStr string) (*Claims, error)
|
||||||
|
|
||||||
|
// ValidateRefresh 校验 refresh_token 签名和过期(不查 DB,DB 校验由 service 层负责)。
|
||||||
|
func (tm *TokenManager) ValidateRefresh(tokenStr string) (*Claims, error)
|
||||||
|
|
||||||
|
// HashToken 计算 token 的 SHA256 hash(用于 DB 存储)。
|
||||||
|
func HashToken(token string) string
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/auth/middleware.go
|
||||||
|
|
||||||
|
// AuthMiddleware Gin 中间件:从 Authorization: Bearer <token> 提取并校验。
|
||||||
|
// 校验通过后将 Claims 写入 gin.Context。
|
||||||
|
func AuthMiddleware(tm *TokenManager) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
auth := c.GetHeader("Authorization")
|
||||||
|
if !strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
c.AbortWithStatusJSON(401, gin.H{"error": "missing token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, err := tm.ValidateAccess(strings.TrimPrefix(auth, "Bearer "))
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(401, gin.H{"error": "invalid token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("claims", claims)
|
||||||
|
c.Set("user_id", claims.UserID)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、REST API 设计
|
||||||
|
|
||||||
|
### 3.1 认证 API(新增)
|
||||||
|
|
||||||
|
#### 注册
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/register
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"username": "alice", "password": "s3cret123"}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 201 Created
|
||||||
|
{
|
||||||
|
"user": {"id": "uuid", "username": "alice", "created_at": "2026-06-14T10:00:00Z"},
|
||||||
|
"access_token": "eyJ...",
|
||||||
|
"refresh_token": "eyJ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
错误码:`USERNAME_TAKEN`(409)、`INVALID_INPUT`(400,用户名/密码格式不合规)
|
||||||
|
|
||||||
|
#### 登录
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"username": "alice", "password": "s3cret123"}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 200 OK
|
||||||
|
{
|
||||||
|
"user": {"id": "uuid", "username": "alice"},
|
||||||
|
"access_token": "eyJ...",
|
||||||
|
"refresh_token": "eyJ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
错误码:`INVALID_CREDENTIALS`(401)
|
||||||
|
|
||||||
|
#### 刷新 Token
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/refresh
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"refresh_token": "eyJ..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 200 OK
|
||||||
|
{
|
||||||
|
"access_token": "eyJ...",
|
||||||
|
"refresh_token": "eyJ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
错误码:`INVALID_TOKEN`(401)
|
||||||
|
|
||||||
|
#### 登出
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/logout
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"refresh_token": "eyJ..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:`204 No Content`
|
||||||
|
|
||||||
|
### 3.2 对话管理 API(新增)
|
||||||
|
|
||||||
|
所有端点需要 `Authorization: Bearer <access_token>` header。
|
||||||
|
|
||||||
|
#### 获取对话列表
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/conversations?page=1&size=20
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 200 OK
|
||||||
|
{
|
||||||
|
"conversations": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"title": "这是一朵红色的玫瑰花",
|
||||||
|
"last_message": "它看起来很美丽。",
|
||||||
|
"message_count": 6,
|
||||||
|
"updated_at": "2026-06-14T10:30:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 42,
|
||||||
|
"page": 1,
|
||||||
|
"size": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 创建新对话
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/conversations
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 201 Created
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"title": "新对话",
|
||||||
|
"created_at": "2026-06-14T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 获取对话详情
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/conversations/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 200 OK
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"title": "这是一朵红色的玫瑰花",
|
||||||
|
"created_at": "2026-06-14T10:00:00Z",
|
||||||
|
"updated_at": "2026-06-14T10:30:00Z",
|
||||||
|
"config": {"tts_enabled": true, "detail_level": "low", "language": "zh-CN"}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 更新对话标题
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /api/conversations/:id
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"title": "新的标题"}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:`200 OK` + 更新后的对话详情
|
||||||
|
|
||||||
|
#### 删除对话
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/conversations/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:`204 No Content`(级联删除 messages)
|
||||||
|
|
||||||
|
#### 获取对话历史消息
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/conversations/:id/messages?limit=50&before=<message_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 200 OK
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{"id": 1, "role": "user", "content": "这是什么花?", "created_at": "..."},
|
||||||
|
{"id": 2, "role": "assistant", "content": "这是一朵红色的玫瑰。", "tokens_used": 42, "created_at": "..."}
|
||||||
|
],
|
||||||
|
"has_more": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 现有 API 变更
|
||||||
|
|
||||||
|
| 端点 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| `GET /api/health` | 不变 |
|
||||||
|
| `POST /api/sessions` | **废弃**,使用 `POST /api/conversations` 替代 |
|
||||||
|
| `DELETE /api/sessions/{id}` | **废弃**,使用 `DELETE /api/conversations/:id` 替代 |
|
||||||
|
|
||||||
|
### 3.4 新增错误码
|
||||||
|
|
||||||
|
| 错误码 | HTTP 状态 | 含义 |
|
||||||
|
|--------|-----------|------|
|
||||||
|
| `USERNAME_TAKEN` | 409 | 用户名已被注册 |
|
||||||
|
| `INVALID_CREDENTIALS` | 401 | 用户名或密码错误 |
|
||||||
|
| `INVALID_TOKEN` | 401 | JWT 无效或已过期 |
|
||||||
|
| `INVALID_INPUT` | 400 | 请求参数不合规(用户名/密码长度等) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、Session Manager 改造
|
||||||
|
|
||||||
|
### 4.1 接口扩展
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/session/manager.go
|
||||||
|
|
||||||
|
type Manager interface {
|
||||||
|
// ===== 原有方法(签名变更) =====
|
||||||
|
|
||||||
|
// Create 创建新会话,关联 user_id。
|
||||||
|
Create(ctx context.Context, userID string, config models.SessionConfig) (string, error)
|
||||||
|
|
||||||
|
Get(ctx context.Context, sessionID string) (*models.Session, error)
|
||||||
|
UpdateConfig(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error
|
||||||
|
GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error)
|
||||||
|
AppendMessage(ctx context.Context, sessionID string, msg models.Message) error
|
||||||
|
SetActiveRequest(ctx context.Context, sessionID string, requestID string) error
|
||||||
|
GetActiveRequestID(ctx context.Context, sessionID string) (string, error)
|
||||||
|
ClearActiveRequest(ctx context.Context, sessionID string) error
|
||||||
|
Touch(ctx context.Context, sessionID string) error
|
||||||
|
Destroy(ctx context.Context, sessionID string) error
|
||||||
|
ActiveCount() int
|
||||||
|
|
||||||
|
// ===== 新增方法 =====
|
||||||
|
|
||||||
|
// ListByUser 获取用户的对话列表(分页)。
|
||||||
|
ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error)
|
||||||
|
|
||||||
|
// UpdateTitle 更新对话标题。
|
||||||
|
UpdateTitle(ctx context.Context, sessionID string, title string) error
|
||||||
|
|
||||||
|
// LoadFromDB 从 PostgreSQL 加载历史消息到热存储(Redis/内存)。
|
||||||
|
// 用户选择历史对话继续交谈时调用。
|
||||||
|
LoadFromDB(ctx context.Context, sessionID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConversationSummary 对话列表项。
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Model 变更
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/models/models.go
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
ID string `json:"session_id"`
|
||||||
|
UserID string `json:"user_id"` // 新增
|
||||||
|
Title string `json:"title"` // 新增
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"` // 新增
|
||||||
|
Config SessionConfig `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
PasswordHash string `json:"-"` // 不序列化到 JSON
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 冷热数据策略
|
||||||
|
|
||||||
|
```
|
||||||
|
当前活跃会话: Redis/内存(热) ←→ PostgreSQL(冷,write-through)
|
||||||
|
历史会话加载: PostgreSQL → Redis/内存(按需恢复)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Write-through 保证持久化**:每次 `AppendMessage` 同时写入 PostgreSQL,确保服务重启不丢数据。
|
||||||
|
|
||||||
|
**历史对话恢复流程**:
|
||||||
|
1. 用户从对话列表选择一个历史对话
|
||||||
|
2. 前端带 `conversation_id` 建立 WebSocket 连接
|
||||||
|
3. 后端调用 `sessionManager.LoadFromDB(conversationID)` 将历史消息从 PostgreSQL 加载到 Redis/内存
|
||||||
|
4. 后续对话正常走热存储路径
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、WebSocket 认证集成
|
||||||
|
|
||||||
|
### 5.1 连接流程
|
||||||
|
|
||||||
|
```
|
||||||
|
前端 后端
|
||||||
|
| |
|
||||||
|
|-- WS /ws?token=<access> ---->|
|
||||||
|
| &conversation_id=<uuid> |
|
||||||
|
| |-- 校验 access_token
|
||||||
|
| |-- 校验 conversation_id 归属
|
||||||
|
| |-- LoadFromDB(如果是历史对话)
|
||||||
|
| |-- 创建新 session(如果 conversation_id 为空)
|
||||||
|
|<-- connected {session_id} ---|
|
||||||
|
| |
|
||||||
|
|-- query {image, audio} ----->| (正常对话流程)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Go 实现
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/ws/handler.go
|
||||||
|
|
||||||
|
func (h *Handler) HandleWS(c *gin.Context) {
|
||||||
|
// 1. 提取并校验 access_token
|
||||||
|
tokenStr := c.Query("token")
|
||||||
|
if tokenStr == "" {
|
||||||
|
c.JSON(401, gin.H{"error": "missing token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, err := h.tokenManager.ValidateAccess(tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(401, gin.H{"error": "invalid token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 提取 conversation_id(可选)
|
||||||
|
conversationID := c.Query("conversation_id")
|
||||||
|
|
||||||
|
// 3. 升级 WebSocket
|
||||||
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 获取或创建 session
|
||||||
|
var sessionID string
|
||||||
|
if conversationID != "" {
|
||||||
|
// 验证该对话属于当前用户
|
||||||
|
sess, err := h.sessionMgr.Get(c, conversationID)
|
||||||
|
if err != nil || sess.UserID != claims.UserID {
|
||||||
|
conn.WriteJSON(models.WsError{Type: "error", Code: "SESSION_NOT_FOUND"})
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 加载历史到热存储
|
||||||
|
h.sessionMgr.LoadFromDB(c, conversationID)
|
||||||
|
sessionID = conversationID
|
||||||
|
} else {
|
||||||
|
// 创建新对话
|
||||||
|
sessionID, _ = h.sessionMgr.Create(c, claims.UserID, models.DefaultConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 进入正常 WS 处理循环
|
||||||
|
h.handleSession(conn, sessionID, claims.UserID)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 前端连接方式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// WebSocket 连接
|
||||||
|
const ws = new WebSocket(
|
||||||
|
`wss://${window.location.host}/ws?token=${accessToken}&conversation_id=${selectedConvId || ''}`
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、前端设计概要
|
||||||
|
|
||||||
|
### 6.1 页面路由
|
||||||
|
|
||||||
|
```
|
||||||
|
/ → 未登录重定向到 /login
|
||||||
|
/login → AuthPage(登录/注册表单)
|
||||||
|
/chat → 主界面(需登录)
|
||||||
|
/chat/:id → 主界面,自动加载指定对话
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 组件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
App
|
||||||
|
├── AuthPage ← 新增:登录/注册
|
||||||
|
└── ChatLayout(需登录)
|
||||||
|
├── ConversationList ← 新增:侧边栏对话列表
|
||||||
|
│ ├── 对话项(标题、最后消息、时间)
|
||||||
|
│ ├── 新建对话按钮
|
||||||
|
│ └── 删除对话按钮
|
||||||
|
├── ChatPanel ← 现有,需适配多对话
|
||||||
|
├── VideoPreview ← 现有
|
||||||
|
├── MicManager ← 现有
|
||||||
|
└── ConfigPanel ← 现有
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 新增 Hook
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useAuth — 认证状态管理
|
||||||
|
function useAuth() {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const login = async (username: string, password: string) => { ... };
|
||||||
|
const register = async (username: string, password: string) => { ... };
|
||||||
|
const logout = async () => { ... };
|
||||||
|
const refreshToken = async () => { ... };
|
||||||
|
|
||||||
|
// 请求拦截器:自动附加 Authorization header
|
||||||
|
// 401 时自动尝试 refresh,失败则跳转登录
|
||||||
|
|
||||||
|
return { user, loading, login, register, logout };
|
||||||
|
}
|
||||||
|
|
||||||
|
// useConversations — 对话列表管理
|
||||||
|
function useConversations() {
|
||||||
|
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||||
|
const [currentId, setCurrentId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchList = async (page?: number) => { ... };
|
||||||
|
const createNew = async () => { ... };
|
||||||
|
const deleteConv = async (id: string) => { ... };
|
||||||
|
const renameConv = async (id: string, title: string) => { ... };
|
||||||
|
const selectConv = (id: string) => { setCurrentId(id); };
|
||||||
|
|
||||||
|
return { conversations, currentId, fetchList, createNew, deleteConv, renameConv, selectConv };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 对话标题自动生成
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 内部逻辑:首条 user 消息的前 20 个字符作为 title
|
||||||
|
func generateTitle(firstMessage string) string {
|
||||||
|
runes := []rune(firstMessage)
|
||||||
|
if len(runes) > 20 {
|
||||||
|
return string(runes[:20]) + "…"
|
||||||
|
}
|
||||||
|
return firstMessage
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `AppendMessage` 时,如果 session 的 title 仍为 "新对话",自动更新为 `generateTitle(msg.Content)`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、配置扩展
|
||||||
|
|
||||||
|
### 7.1 Go 配置结构体
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Config struct {
|
||||||
|
App AppConfig `mapstructure:"app"`
|
||||||
|
Server ServerConfig `mapstructure:"server"`
|
||||||
|
Auth AuthConfig `mapstructure:"auth"` // 新增
|
||||||
|
Redis RedisConfig `mapstructure:"redis"`
|
||||||
|
AI AIConfig `mapstructure:"ai"`
|
||||||
|
Storage StorageConfig `mapstructure:"storage"`
|
||||||
|
Log LogConfig `mapstructure:"log"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthConfig struct {
|
||||||
|
JWTSecret string `mapstructure:"jwt_secret"` // 必须通过环境变量设置
|
||||||
|
AccessTTL int `mapstructure:"access_ttl"` // 分钟,默认 15
|
||||||
|
RefreshTTL int `mapstructure:"refresh_ttl"` // 分钟,默认 10080 (7天)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 配置文件示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config.yaml
|
||||||
|
auth:
|
||||||
|
access_ttl: 15 # 分钟
|
||||||
|
refresh_ttl: 10080 # 7天
|
||||||
|
|
||||||
|
storage:
|
||||||
|
driver: "memory" # "memory" | "postgres"
|
||||||
|
dsn: ""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 环境变量
|
||||||
|
|
||||||
|
| 配置项 | 环境变量 | 说明 |
|
||||||
|
|--------|---------|------|
|
||||||
|
| `auth.jwt_secret` | `CAMTALK_AUTH_JWT_SECRET` | **必须设置**,JWT 签名密钥 |
|
||||||
|
| `auth.access_ttl` | `CAMTALK_AUTH_ACCESS_TTL` | access_token 有效期(分钟) |
|
||||||
|
| `auth.refresh_ttl` | `CAMTALK_AUTH_REFRESH_TTL` | refresh_token 有效期(分钟) |
|
||||||
|
| `storage.driver` | `CAMTALK_STORAGE_DRIVER` | `"memory"` 或 `"postgres"` |
|
||||||
|
| `storage.dsn` | `CAMTALK_STORAGE_DSN` | PostgreSQL 连接串 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、实施阶段
|
||||||
|
|
||||||
|
### Phase 1:用户认证系统
|
||||||
|
|
||||||
|
- [ ] 数据库 schema 迁移脚本(users, refresh_tokens 表)
|
||||||
|
- [ ] `internal/auth/` 包:TokenManager, bcrypt 工具, JWT 中间件
|
||||||
|
- [ ] `internal/store/user.go`:UserRepository 接口 + PostgreSQL 实现
|
||||||
|
- [ ] REST API:`/api/auth/register`, `/api/auth/login`, `/api/auth/refresh`, `/api/auth/logout`
|
||||||
|
- [ ] 单元测试
|
||||||
|
|
||||||
|
### Phase 2:对话 CRUD + 消息持久化
|
||||||
|
|
||||||
|
- [ ] 数据库 schema 迁移脚本(sessions, messages 表改造)
|
||||||
|
- [ ] `internal/store/conversation.go`:ConversationRepository 接口 + PostgreSQL 实现
|
||||||
|
- [ ] Session Manager 扩展:Create 绑定 user_id, ListByUser, UpdateTitle
|
||||||
|
- [ ] REST API:`/api/conversations` CRUD + `/api/conversations/:id/messages`
|
||||||
|
- [ ] Write-through:AppendMessage 同时写 PostgreSQL
|
||||||
|
|
||||||
|
### Phase 3:对话历史恢复
|
||||||
|
|
||||||
|
- [ ] `sessionManager.LoadFromDB()` 实现
|
||||||
|
- [ ] 对话标题自动生成逻辑
|
||||||
|
- [ ] REST API:对话详情、历史消息查询(分页)
|
||||||
|
|
||||||
|
### Phase 4:前端集成
|
||||||
|
|
||||||
|
- [ ] `useAuth` hook + 请求拦截器(自动附加 token、自动 refresh)
|
||||||
|
- [ ] `AuthPage` 组件(登录/注册表单)
|
||||||
|
- [ ] `ConversationList` 组件
|
||||||
|
- [ ] `useConversations` hook
|
||||||
|
- [ ] 路由守卫:未登录重定向到 `/login`
|
||||||
|
- [ ] WebSocket 连接带 token + conversation_id
|
||||||
|
- [ ] `useVisionSession` 适配多对话切换
|
||||||
|
|
||||||
|
### Phase 5:配置与收尾
|
||||||
|
|
||||||
|
- [ ] 配置结构体扩展(AuthConfig)
|
||||||
|
- [ ] config.yaml 更新
|
||||||
|
- [ ] docker-compose 添加 PostgreSQL
|
||||||
|
- [ ] 集成测试
|
||||||
|
- [ ] 更新 `02-系统架构.md` 和 `03-接口文档.md`
|
||||||
797
docs/PLAN_USER_MODULE.md
Normal file
797
docs/PLAN_USER_MODULE.md
Normal file
@@ -0,0 +1,797 @@
|
|||||||
|
# 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))
|
||||||
|
|
||||||
|
// ... 启动
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键文件清单
|
||||||
|
|
||||||
|
```
|
||||||
|
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 ./...` | 全量通过 |
|
||||||
Reference in New Issue
Block a user