64 KiB
服务稳定性 (批次 3) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 修复后端 P1 稳定性问题——bcrypt 阻塞事务、Login 用户枚举+无限流、JWT 全局可变密钥+弱默认、MQ streams adapter 半吊子、aiChat Redis 吞错+err 泄露+personaID 漂移、TrackEvent 静默丢失+队列名硬编码、网关链式 GetFanIdentities+铸造双写+DeleteAccount 直连 DB。8 项修复全部纳入同一 commit 序列以便回滚。
Architecture:
- 错误处理统一:新增
ErrInvalidCredential(替代ErrUserNotFound/ErrInvalidPassword在 Login 出口的分裂)、ErrTooManyLoginAttempts复用 SMS 限流 Redis key。 - 配置 fail-fast:
pkg/jwt.MustInit在启动时一次性注入 + 校验 + 拒绝弱默认;pkg/mq/streams退化为 stub(停止实例化),删除生产路径。 - 可靠性层:
pkg/statistic/client.TrackEvent改为「goroutine + bounded channel」buffer(1024 槽),溢出落本地 SQLite 兜底;新增pkg/queue/consts.QueueGallery/QueueDefault单一来源。 - 网关分层:抽出
pkg/gateway/starcache缓存 star 目录(TTL 60s)替代 6 处GetFanIdentities链式调用;DeleteAccount改为调userService.DeleteAccountRPC(userService 才是 fans/users 的 Owner);铸造双写收口为MintSuccess单一事件→消费方解耦。 - aiChat 健壮性:Redis 错误升级为
WARN+降级到空集合而非静默吞;错误响应不再err.Error()原样回传;personaID一律使用persona.ID。
Tech Stack: Go 1.25 (go.work 多模块), GORM, Redis Streams + asynq, Dubbo triple, PostgreSQL, 现有 pkg/statistic + pkg/jwt + pkg/mq 三库原地修改,不引新依赖。
Global Constraints
- 沿用现有分层(handler/service/repository)+ DTO 入出参 + GORM 序列
setval同步规则(CLAUDE.md§数据库操作规范)。 - 时间戳统一毫秒 (
time.Now().UnixMilli())。 - 不自动
git commit(仓库规矩:需用户明确指示)。步骤里的 commit 命令仅在用户批准后执行。 - 每个任务结束
go build ./...(在对应模块目录)通过;go vet ./...0 warning。 - 全程遵守
CLAUDE.md§全局自审规则:每次修复必须做回归检查(直接调用方 + 相邻模块 + 共享函数副作用),禁止"只读改的部分"。 - 现有 asynq 队列(
gallery/default)运行中不可改名,只在pkg/queue/consts加常量、替换硬编码字面量,asynq queue 名不变。
前置决策(影响 3.3 整 Task)
streams adapter 用方调查结果(证据,非设计)
$ grep -rn "EventProducer\|EventConsumer\|EventHandler" backend --include="*.go" | grep -v "_test.go" | grep -v "pkg/mq"
$ # 输出: 0 行
pkg/mq/streams/adapter.go 的 EventProducer.Publish 与 EventConsumer.Subscribe 0 业务调用方。asynq 是事实唯一的任务队列;统计事件走 pkg/statistic 自己的 Dubbo RPC(statistic.Get().TrackEvent),与 streams 无关。
决策:Task 3.3 选「停用 streams adapter」分支
依据 MVP 先行原则(CLAUDE.md §MVP 先行原则):
- 现网 0 调用方 → 补 DLQ + XAUTOCLAIM + 稳定 consumer name 是为未来 100 业务准备的架构(YAGNI)。
- 半吊子(已声明的事件可靠性 + 实际 0 调用方)会让新人误以为已可用,未来接入时再翻工成本更高。
- 停用路径 = 删除
streamsadapter + 保留 asynq = 50 行代码减量 + 0 业务影响。
完整架构(streams/dlq + streams/claim)留作 docs/specs/2026-07-21-mq-v2-design.md 路线图,本 plan 不落地。
File Structure
新增
backend/pkg/errors/errors.go— 增ErrInvalidCredential/ErrTooManyLoginAttempts,加ToGRPCCode分支(ErrTooManyLoginAttempts → ResourceExhausted)。backend/services/userService/service/login_ratelimit.go— 新建。CheckLoginRateLimit/IncrLoginFailure/ClearLoginAttempts,复用sms_redis.go的database.GetRedis(),key 前缀login:fail:{mobile}/login:fail:ip:{ip}。backend/pkg/queue/consts/consts.go— 新建。QueueGallery = "gallery"、QueueDefault = "default"常量;目前 asynq queue 名不变。backend/gateway/pkg/starcache/starcache.go— 新建。内存缓存 + TTL 60s +sync.RWMutex;GetStar(starID int64) *pb.Star、Invalidate()。GetFanIdentities只在 cache miss 时调下层。backend/pkg/mq/streams/adapter_stub.go— 新建。保留Adapter类型但New()直接返回错误,所有方法 panic 或返回errors.New("streams: deprecated");compositeAdapter.EventProducer/Consumer改返 stub。backend/pkg/mq/mq.go— 删 streams 装配分支。backend/pkg/statistic/client.go— 加BufferedTrackEvent(channel 1024 + overflow → zap.Warn 落丢弃并 metrics 计数);保留 fire-and-forget 接口签名。backend/pkg/jwt/jwt.go— 加MustInit(secret string) error:长度 ≥ 32 字节、非默认值;SetSecret改为internal仅MustInit调用,外部包只能MustInit。backend/services/userService/service/auth_service.go—Register/ResetPassword把repository.HashPassword移到事务前;Login统一ErrInvalidCredential+ 调限流;ValidateToken同步新签名。backend/services/userService/main.go— 调jwt.MustInit(jwtSecret)替代jwt.SetSecret;env 缺失/弱默认 → fatal。backend/services/userService/service/user_service.go—ResetPassword同样把HashPassword移到事务前(L670→L683);fireCrystalChangeEvent加 trace。backend/services/aiChatService/provider/ai_chat_provider.go— Redis 错误升级 WARN + 降级;err.Error()不再回传 client;personaID用persona.ID。backend/services/aiChatService/main.go— Dify/LLM 二选一改为尝试 Dify 失败时 WARN 降级到 LLM;保留原行为当两者都给定时 Dify 优先(业务 0 感知)。backend/services/galleryService/main.go—Queues: map[string]int{pkg/queue/consts.QueueGallery: 1}。backend/services/galleryService/mq/consumer.go—Queue: pkg/queue/consts.QueueGallery。backend/services/galleryService/mq/producer.go— 5 处Queue: "gallery"/"default"→ 常量。backend/services/taskService/main.go—Queues: map[string]int{pkg/queue/consts.QueueDefault: 1}。backend/services/taskService/mq/consumer.go—Queue: pkg/queue/consts.QueueDefault。backend/gateway/main.go—jwt.SetSecret→jwt.MustInit;启动starcache.Start(ctx)。backend/gateway/controller/auth_controller.go—Register/Login用starcache.GetStar(resp.FanProfile.StarId)替代链式GetFanIdentities。backend/gateway/controller/user_controller.go—GetMe/GetMyProfile/UpdateProfile同上;DeleteAccount改为调userService.DeleteAccountRPC(userService 才是 owner),不再database.GetDB()。backend/gateway/controller/asset_controller.go— 铸造成功后改发events.MintSuccess事件到 in-process channel,由单独 goroutine 调LaserRepo.SetMintResult+ 写 operation_log(解耦双写失败→阻塞主链路)。backend/services/assetService/repository/laser_card_repository.go— 增MintSuccessConsumer(ctx, ch chan *MintSuccessEvent)接收事件并落库。backend/services/assetService/main.go— 启动 consumer goroutine。backend/pkg/mq/streams/adapter.go— 删(被 stub 取代)。backend/pkg/mq/streams/adapter_test.go— 删(与 stub 不兼容)。
测试
backend/pkg/jwt/jwt_test.go— 加TestMustInit_Empty/TestMustInit_Default/TestMustInit_OK/TestMustInit_Short/ 并发读写 race(-race)。backend/services/userService/service/login_ratelimit_test.go— 新建。Mock Redis,5 次失败后命中限流;mobile+ip 双维度。backend/services/userService/service/auth_service_test.go— 新建TestRegister_HashPassword_OutsideTx(mock repo 验证 HashPassword 调用顺序在 Transaction 之前);TestLogin_UnifiedError。backend/pkg/statistic/client_test.go— 新建。TestTrackEvent_OverflowDropsAndLogs。backend/services/aiChatService/provider/ai_chat_provider_test.go— 新建。TestSendMessage_RedisErrorLogsAndContinues/TestSendMessage_PersonaIDUsesResolved。backend/pkg/mq/mq_test.go— 新建。TestInit_RedisOnly_NoStreams。
Task 1: 3.1 bcrypt 移出事务
Files:
- Modify:
backend/services/userService/service/auth_service.go:139-233(Register事务块) - Modify:
backend/services/userService/service/user_service.go:670-693(ResetPassword事务块) - Test:
backend/services/userService/service/auth_service_test.go(新建)
Interfaces:
-
公共行为不变;
bcrypt.GenerateFromPassword调用必须在s.db.Transaction(...)进入之前完成。 -
Step 1: 写测试验证 HashPassword 在事务前
// backend/services/userService/service/auth_service_test.go
package service
import (
"context"
"errors"
"sync/atomic"
"testing"
"github.com/topfans/backend/pkg/errors"
"github.com/topfans/backend/pkg/models"
pb "github.com/topfans/backend/pkg/proto/user"
"github.com/topfans/backend/services/userService/repository"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// fakeUserRepo 实现 repository.UserRepository(仅注册路径用到的方法)
type fakeUserRepo struct {
hashCalledBeforeTx atomic.Bool
createCalled atomic.Int32
}
func (f *fakeUserRepo) GetByMobile(mobile string) (*models.User, error) {
return nil, errors.ErrUserNotFound
}
func (f *fakeUserRepo) Create(u *models.User) error { f.createCalled.Add(1); u.ID = 1; return nil }
func (f *fakeUserRepo) GetByID(id int64) (*models.User, error) { return nil, errors.ErrUserNotFound }
func (f *fakeUserRepo) VerifyPassword(u *models.User, pw string) bool { return false }
func (f *fakeUserRepo) UpdateToken(id int64, tok string, exp int64) error { return nil }
func (f *fakeUserRepo) ClearToken(id int64) error { return nil }
func (f *fakeUserRepo) UpdatePassword(id int64, hash string) error { return nil }
func (f *fakeUserRepo) GetAccountStatus(id int64) (*models.UserAccountStatus, error) { return nil, nil }
func (f *fakeUserRepo) UpdateAvatar(id int64, url string) error { return nil }
// spyDb 包装 *gorm.DB 记录事务开始/HashPassword 调用顺序
type spyDb struct{ *gorm.DB; txEntered atomic.Bool }
func (s *spyDb) Transaction(fn func(tx *gorm.DB) error) error {
s.txEntered.Store(true)
return s.DB.Transaction(fn)
}
func TestRegister_HashPassword_OutsideTransaction(t *testing.T) {
db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
spy := &spyDb{DB: db}
starRepo := &fakeStarRepo{}
fanRepo := &fakeFanRepo{}
userRepo := &fakeUserRepo{}
// 用一个 wrapper 让 HashPassword 调用时记录"此刻事务是否已进入"
wrapped := &hashOrderRecorder{userRepo: userRepo, spy: spy}
svc := NewAuthService(wrapped, fanRepo, starRepo, spy.DB)
_, err := svc.Register(context.Background(), &pb.RegisterRequest{
Mobile: "13800000000", Password: "Pass1234", Nickname: "n", StarId: 1,
})
if err != nil {
t.Fatalf("Register: %v", err)
}
if !wrapped.hashedBeforeTx.Load() {
t.Fatalf("HashPassword was called AFTER tx began (currently txEntered=%v)", spy.txEntered.Load())
}
}
(配套 hashOrderRecorder + fakeStarRepo + fakeFanRepo 在同文件实现,思路:repo 在 Create(user) 时读 spy.txEntered,应得 false。)
- Step 2: Register L161 移出事务
Run git diff backend/services/userService/service/auth_service.go 确认改前 HashPassword 位于 L161(在 s.db.Transaction(func(tx *gorm.DB) error { 之后)。
在 auth_service.go 的 Register 函数中,把 L161-165 的 HashPassword 整段移到 L142(事务前):
// 在 s.db.Transaction(...) 之前(紧跟现有 L141 注释后)
hashedPassword, err := repository.HashPassword(req.Password)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
// 移除事务内的 L161-165;事务内仅 Create(user) + Create(fanProfile) + UpdateColumns(token)
err = s.db.Transaction(func(tx *gorm.DB) error {
user = &models.User{ /* 同前, PasswordHash: hashedPassword */ }
// ...
if err := tx.Create(user).Error; err != nil { ... }
// 不再调 HashPassword
// ...
})
预期:go test ./services/userService/service/... -run TestRegister_HashPassword_OutsideTransaction -v PASS。
- Step 3: ResetPassword L670 同步
backend/services/userService/service/user_service.go:683-693 同样把 repository.HashPassword(req.NewPassword) 移到 s.db.Transaction(...) 之前:
// 在 s.db.Transaction(...) 之前
newPasswordHash, err := repository.HashPassword(req.NewPassword)
if err != nil {
return nil, fmt.Errorf("failed to hash new password: %w", err)
}
err = s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(user).Updates(map[string]interface{}{
"password_hash": newPasswordHash,
// ...
}).Error; err != nil { ... }
return nil
})
- Step 4: 全模块回归(CLAUDE.md 全局自审)
按 CLAUDE.md §全局自审规则执行:
-
git grep "HashPassword"全仓,确认 0 个新调用方在事务内。 -
git grep "bcrypt.GenerateFromPassword"应只在services/userService/repository/user_repository.go:202一处。 -
调用方回归:
Register(RegisterResponse 仍带 token)/ResetPassword(同)。 -
go vet ./...0 warning。 -
Step 5: commit(需用户批准)
git add backend/services/userService/service/auth_service.go \
backend/services/userService/service/user_service.go \
backend/services/userService/service/auth_service_test.go
git commit -m "fix(auth): move bcrypt HashPassword outside DB transaction (batch 3.1)" \
-m "P1: auth_service.go:161 + user_service.go:670 bcrypt inside tx blocks pool under registration spikes. Move HashPassword call before s.db.Transaction()." \
-m "Co-Authored-By: Claude <noreply@anthropic.com>"
Task 2: 3.2 Login 限流 + 消除用户枚举
Files:
- Modify:
backend/services/userService/service/auth_service.go:276-489(Login全函数) - New:
backend/services/userService/service/login_ratelimit.go - Modify:
backend/pkg/errors/errors.go:13-95, 108-172 - Test:
backend/services/userService/service/login_ratelimit_test.go(新建)+ 扩auth_service_test.go
Interfaces:
-
新
ErrInvalidCredential = errors.New("账号或密码错误")—— Login 出口统一返回此(覆盖ErrUserNotFound+ErrInvalidPassword在 Login 路径上的使用)。 -
新
ErrTooManyLoginAttempts = errors.New("登录尝试过多,请稍后再试")→ToGRPCCode → ResourceExhausted(8)。 -
新函数
service.CheckLoginRateLimit(ctx, mobile, ip) (ok bool, retryAfter time.Duration, err error)、service.IncrLoginFailure(ctx, mobile, ip)、service.ClearLoginAttempts(ctx, mobile, ip)。 -
Redis key(复用
database.GetRedis()):login:fail:mobile:{mobile}→ INCR + EXPIRE 900s(15 分钟)login:fail:ip:{ip}→ INCR + EXPIRE 900s- 阈值:mobile 5 次/15min、ip 20 次/15min(参考 SMS IP 限制量级)。
-
Step 1: 增错误类型 + ToGRPCCode 分支
在 backend/pkg/errors/errors.go 紧跟 ErrPasswordTooShort 后增:
// 鉴权相关
ErrInvalidCredential = errors.New("账号或密码错误")
ErrTooManyLoginAttempts = errors.New("登录尝试过多,请稍后再试")
在 ToGRPCCode switch 内 ErrInvalidPassword 同一 case 加 errors.Is(err, ErrInvalidCredential):
case errors.Is(err, ErrInvalidPassword), errors.Is(err, ErrInvalidToken),
errors.Is(err, ErrTokenExpired), errors.Is(err, ErrTokenMismatch),
errors.Is(err, ErrInvalidCredential): // ← 新增
return codes.Unauthenticated
并在 ErrRequestInCooldown case 后增:
case errors.Is(err, ErrTooManyLoginAttempts):
return codes.ResourceExhausted
Run go test ./pkg/errors/... -v 确认原有 7 个 case 不破。
- Step 2: 新建
login_ratelimit.go
// backend/services/userService/service/login_ratelimit.go
package service
import (
"context"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"github.com/topfans/backend/pkg/database"
appErrors "github.com/topfans/backend/pkg/errors"
)
const (
loginFailKeyPrefix = "login:fail:"
loginFailWindow = 15 * time.Minute
MaxMobileFailures = 5
MaxIPFailures = 20
)
func mobileLoginKey(mobile string) string { return loginFailKeyPrefix + "mobile:" + mobile }
func ipLoginKey(ip string) string { return loginFailKeyPrefix + "ip:" + ip }
// CheckLoginRateLimit 命中任一阈值即返回 ErrTooManyLoginAttempts
func CheckLoginRateLimit(ctx context.Context, mobile, ip string) error {
cli := database.GetRedis()
if cli == nil {
return nil // 限流依赖 Redis;不可用时不阻断(降级)
}
mobCount, err := cli.Get(ctx, mobileLoginKey(mobile)).Int()
if err != nil && !errors.Is(err, redis.Nil) {
return nil // Redis 抖动不阻断登录
}
if mobCount >= MaxMobileFailures {
return appErrors.ErrTooManyLoginAttempts
}
ipCount, err := cli.Get(ctx, ipLoginKey(ip)).Int()
if err != nil && !errors.Is(err, redis.Nil) {
return nil
}
if ipCount >= MaxIPFailures {
return appErrors.ErrTooManyLoginAttempts
}
return nil
}
// IncrLoginFailure 失败 +1,带 window TTL。返回当前 mobile 计数。
func IncrLoginFailure(ctx context.Context, mobile, ip string) (int, error) {
cli := database.GetRedis()
if cli == nil { return 0, nil }
pipe := cli.Pipeline()
mobIncr := pipe.Incr(ctx, mobileLoginKey(mobile))
pipe.Expire(ctx, mobileLoginKey(mobile), loginFailWindow)
pipe.Incr(ctx, ipLoginKey(ip))
pipe.Expire(ctx, ipLoginKey(ip), loginFailWindow)
if _, err := pipe.Exec(ctx); err != nil {
return 0, fmt.Errorf("incr login fail: %w", err)
}
return int(mobIncr.Val()), nil
}
// ClearLoginAttempts 登录成功清零
func ClearLoginAttempts(ctx context.Context, mobile, ip string) {
cli := database.GetRedis()
if cli == nil { return }
cli.Del(ctx, mobileLoginKey(mobile), ipLoginKey(ip))
}
- Step 3: Login 接入限流 + 统一错误码
auth_service.go:276-489 Login 改动:
func (s *authService) Login(req *pb.LoginRequest) (*pb.LoginResponse, error) {
// 1. 参数验证(原样)
// ...
// 2. 新增:限流检查(在 GetByMobile 之前,避免泄露"用户是否存在"时的计时差异)
// 注意:Login 是 gRPC 服务,需从 metadata 取 client IP;若 dubbo 已透传则优先取,否则 fallback "0.0.0.0"
clientIP := extractClientIP(req) // 实现见 Step 3a
if err := CheckLoginRateLimit(ctx, clientIP, req.Mobile); err != nil {
return nil, err
}
ctx := contextFromRequest(req) // 见 Step 3a
// 3. GetByMobile(原样 L294)
user, err := s.userRepo.GetByMobile(req.Mobile)
if err != nil {
if errors.Is(err, appErrors.ErrUserNotFound) {
// 不再返回 ErrUserNotFound;统一 ErrInvalidCredential + 计数失败
_, _ = IncrLoginFailure(ctx, req.Mobile, clientIP)
return nil, appErrors.ErrInvalidCredential
}
// ... 同前
}
// 4. 密码错(原 L310-316)改为:
if !s.userRepo.VerifyPassword(user, req.Password) {
_, _ = IncrLoginFailure(ctx, req.Mobile, clientIP)
return nil, appErrors.ErrInvalidCredential
}
// 5. 验证用户是否激活(原 L319-325 不变)
// ... 中间不变 ...
// 6. 生成 token 后(UpdateToken 成功后),成功清零
if err := s.userRepo.UpdateToken(...); err != nil { ... }
ClearLoginAttempts(ctx, req.Mobile, clientIP)
// ... build response 不变
}
Step 3a: Login(req *pb.LoginRequest) 当前签名无 ctx & IP。在 proto 不动的前提下:
- 暂用
context.Background()(grpc handler 由调用方控制,本 plan 不改 proto)。 - IP 暂定
"0.0.0.0"(仅 mobile 维限流生效,IP 维度等待批次 5 把 clientIP 透传后再启用;本 plan 不引入新 proto 字段)。 - Step 4 回归测试用
ctx入参单元化。
⚠️ 全局自审:未传 IP 时 ipLoginKey("0.0.0.0") 会全用户共享——必须保证 CheckLoginRateLimit 在 ip == "" 或 "0.0.0.0" 时跳过 IP 维度。补丁:
func CheckLoginRateLimit(ctx context.Context, mobile, ip string) error {
// ... mobile 维度同前 ...
if ip != "" && ip != "0.0.0.0" {
ipCount, err := cli.Get(ctx, ipLoginKey(ip)).Int()
// ...
}
return nil
}
- Step 4: 测试
// backend/services/userService/service/login_ratelimit_test.go
package service
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/topfans/backend/pkg/database"
"github.com/topfans/backend/pkg/errors"
)
func newTestRedis(t *testing.T) (*miniredis.Miniredis, func()) {
mr := miniredis.RunT(t)
cli := redis.NewClient(&redis.Options{Addr: mr.Addr()})
database.SetRedisForTest(cli) // 假定已存在测试钩子;若无则临时实现
return mr, func() { cli.Close(); mr.Close() }
}
func TestCheckLoginRateLimit_MobileThreshold(t *testing.T) {
mr, cleanup := newTestRedis(t); defer cleanup()
ctx := context.Background()
for i := 0; i < MaxMobileFailures-1; i++ {
IncrLoginFailure(ctx, "13800000000", "")
}
if err := CheckLoginRateLimit(ctx, "13800000000", ""); err != nil {
t.Fatalf("under threshold should pass, got %v", err)
}
IncrLoginFailure(ctx, "13800000000", "")
if err := CheckLoginRateLimit(ctx, "13800000000", ""); err != appErrors.ErrTooManyLoginAttempts {
t.Fatalf("over threshold should return ErrTooManyLoginAttempts, got %v", err)
}
}
func TestCheckLoginRateLimit_SkipEmptyIP(t *testing.T) {
// 验证全局自审补丁:ip="" 不参与
mr, cleanup := newTestRedis(t); defer cleanup()
ctx := context.Background()
if err := CheckLoginRateLimit(ctx, "13800000001", ""); err != nil {
t.Fatalf("empty ip should skip ip dim, got %v", err)
}
}
func TestClearLoginAttempts(t *testing.T) {
mr, cleanup := newTestRedis(t); defer cleanup()
ctx := context.Background()
IncrLoginFailure(ctx, "13800000002", "")
ClearLoginAttempts(ctx, "13800000002", "")
if err := CheckLoginRateLimit(ctx, "13800000002", ""); err != nil {
t.Fatalf("after clear should pass, got %v", err)
}
}
注:miniredis 已是仓库常用测试依赖;若未引入则在 go.mod 加 github.com/alicebob/miniredis/v2 并确认 go mod tidy 通过。
- Step 5: 全模块回归
CLAUDE.md §全局自审:
-
git grep "ErrUserNotFound"在Login路径上应为 0;其它路径(GetByID等)保留。 -
git grep "ErrInvalidPassword"在Login路径上应为 0;UpdatePassword等仍保留。 -
git grep "login:fail:"应只在login_ratelimit.go。 -
go test ./pkg/errors/... ./services/userService/service/... -count=1PASS。 -
Step 6: commit(需用户批准)
git add backend/pkg/errors/errors.go \
backend/services/userService/service/login_ratelimit.go \
backend/services/userService/service/auth_service.go \
backend/services/userService/service/auth_service_test.go \
backend/services/userService/service/login_ratelimit_test.go
git commit -m "fix(auth): unify Login error code + add Redis rate limit (batch 3.2)" \
-m "P1: Login discriminated 'user not found' vs 'wrong password' (user enum); no rate limit. Unify to ErrInvalidCredential + mobile 5/15min + ip 20/15min throttling (skipped when ip empty)." \
-m "Co-Authored-By: Claude <noreply@anthropic.com>"
Task 3: 3.5 JWT 密钥治理(fail-fast + 无 race)
Files:
- Modify:
backend/pkg/jwt/jwt.go:16-25 - Modify:
backend/services/userService/main.go:85-95 - Modify:
backend/gateway/main.go:55-60(jwt.SetSecret调用点) - Test:
backend/pkg/jwt/jwt_test.go(扩)+backend/pkg/jwt/race_test.go(新建)
Interfaces:
-
新
MustInit(secret string) error:长度 ≥ 32 字节、非默认值;成功→赋值全局;失败→panic-equivalent (返回 error)。运行期不可再设。 -
SetSecret改为func setSecretInternal(s string)(小写包内);外部入口只允许MustInit。 -
全局
jwtSecret用atomic.Value存储,消除读写 race。 -
Step 1: 写 race + 失败用例测试
// backend/pkg/jwt/jwt_test.go(追加)
package jwt
import (
"errors"
"sync"
"testing"
)
const goodSecret = "this-is-a-32-byte-test-secret-32!"
func TestMustInit_OK(t *testing.T) {
if err := MustInit(goodSecret); err != nil {
t.Fatalf("MustInit good: %v", err)
}
}
func TestMustInit_Empty(t *testing.T) {
if err := MustInit(""); !errors.Is(err, ErrSecretEmpty) {
t.Fatalf("empty should reject, got %v", err)
}
}
func TestMustInit_TooShort(t *testing.T) {
if err := MustInit("short"); !errors.Is(err, ErrSecretTooShort) {
t.Fatalf("<32B should reject, got %v", err)
}
}
func TestMustInit_DefaultSecret(t *testing.T) {
if err := MustInit("your-secret-key-change-in-production"); !errors.Is(err, ErrSecretIsDefault) {
t.Fatalf("default should reject, got %v", err)
}
}
// backend/pkg/jwt/race_test.go(新建)
package jwt
import (
"sync"
"testing"
"time"
)
func TestMustInit_NoRace(t *testing.T) {
_ = MustInit("this-is-a-32-byte-test-secret-32!")
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(2)
go func() { defer wg.Done(); _, _ = GenerateToken(1, 1, time.Now().UnixMilli()) }()
go func() { defer wg.Done(); _, _ = ValidateToken("invalid") }()
}
wg.Wait()
}
Run go test -race ./pkg/jwt/... -count=1 应 0 race。
- Step 2: 重写 jwt.go
backend/pkg/jwt/jwt.go 改写头部(L11-25):
package jwt
import (
"errors"
"fmt"
"sync/atomic"
"time"
"github.com/golang-jwt/jwt/v5"
)
const (
TokenExpiration = 7 * 24 * time.Hour
MinSecretLen = 32
DefaultSecretValue = "your-secret-key-change-in-production"
)
var (
ErrSecretEmpty = errors.New("jwt: secret is empty")
ErrSecretTooShort = errors.New("jwt: secret shorter than 32 bytes")
ErrSecretIsDefault = errors.New("jwt: secret is the well-known default; refuse to run")
// jwtSecretOnce 保证 MustInit 单次;后续调用 SetSecret 路径被移除
jwtSecret atomic.Value // []byte
initDone atomic.Bool
)
// MustInit 启动时调用一次。失败返回 error(由 main.go Fatal)。
func MustInit(secret string) error {
if initDone.Load() {
return errors.New("jwt: already initialized")
}
if secret == "" {
return ErrSecretEmpty
}
if len(secret) < MinSecretLen {
return ErrSecretTooShort
}
if secret == DefaultSecretValue {
return ErrSecretIsDefault
}
jwtSecret.Store([]byte(secret))
initDone.Store(true)
return nil
}
// setSecretInternal 仅供 jwt_test.go 使用;运行期禁止调用。
func setSecretInternal(s []byte) { jwtSecret.Store(s); initDone.Store(true) }
func mustSecret() []byte {
v := jwtSecret.Load()
if v == nil {
panic("jwt: MustInit not called")
}
return v.([]byte)
}
// Claims 保持不变
// GenerateToken / ParseToken / ValidateToken 把 jwtSecret 替换为 mustSecret()
⚠️ 全局自审:
-
现有
backend/pkg/jwt/jwt_test.go调SetSecret("test-secret-key")(20 字节,长度 < 32)→ 会 fail。同步把测试改为SetSecret("test-secret-key-must-be-at-least-32-bytes")或调用setSecretInternal。 -
backend/scripts/loadgen/seed/tokens.go:23调jwt.SetSecret(cfg.JWTSecret)→ 改jwt.MustInit(cfg.JWTSecret)。 -
backend/cmd/laser_test/main.go不调 SetSecret(直接用本地变量签名),不受影响。 -
Step 3: 启动点接入 MustInit
backend/services/userService/main.go:87-95:
// 原 L87-95
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
logger.Sugar.Warn("⚠️ JWT_SECRET is empty, using insecure default (DO NOT use in prod)")
jwtSecret = "your-secret-key-change-in-production"
} else {
logger.Sugar.Infof("JWT secret loaded (%d bytes)", len(jwtSecret))
}
jwt.SetSecret(jwtSecret)
// 改为
jwtSecret := os.Getenv("JWT_SECRET")
if err := jwt.MustInit(jwtSecret); err != nil {
logger.Sugar.Fatalf("FATAL: JWT secret init failed: %v", err)
}
logger.Sugar.Infof("JWT secret loaded (%d bytes)", len(jwtSecret))
backend/gateway/main.go:55 同步改为 jwt.MustInit(cfg) 并 Fatal 失败。
- Step 4: 验证(同步 .env.example + 部署说明文档是后续 Task,本 plan 不动)
# 1. 不设 env
unset JWT_SECRET
go test ./pkg/jwt/... -count=1 -v
# expected: TestMustInit_Empty FAIL(已断言)
# 2. 设短 secret
JWT_SECRET=short go test ./pkg/jwt/... -count=1 -v
# expected: TestMustInit_TooShort FAIL(已断言)
# 3. 设默认
JWT_SECRET="your-secret-key-change-in-production" go test ./pkg/jwt/... -count=1 -v
# expected: TestMustInit_DefaultSecret FAIL(已断言)
# 4. 正常 secret
JWT_SECRET="this-is-a-32-byte-test-secret-32!" go test -race ./pkg/jwt/... -count=1 -v
# expected: 全部 PASS,0 race
- Step 5: 全模块回归
git grep "jwt.SetSecret\|jwt\\.Secret" 全仓:
- 应 0 外部调用(除
scripts/loadgen/seed/tokens.go已改)。 MustInit调用点 =services/userService/main.go+gateway/main.go+scripts/loadgen/seed/tokens.go+ 测试。
go vet ./... 0 warning;go test -race ./pkg/jwt/... ./services/userService/... ./gateway/... -count=1 通过。
- Step 6: commit(需用户批准)
git add backend/pkg/jwt/jwt.go backend/pkg/jwt/jwt_test.go backend/pkg/jwt/race_test.go \
backend/services/userService/main.go backend/gateway/main.go \
backend/scripts/loadgen/seed/tokens.go
git commit -m "fix(jwt): fail-fast on weak/empty/default secret + atomic.Value (batch 3.5)" \
-m "P1: pkg/jwt/jwt.go:18 package-level mutable jwtSecret with weak default; SetSecret has no lock = data race. Add MustInit(secret): ≥32 bytes, not default, atomic.Value-backed; remove external SetSecret; gateway + userService main.go adopt MustInit+fatal on failure." \
-m "Co-Authored-By: Claude <noreply@anthropic.com>"
Task 4: 3.3 MQ 可靠性(停用 streams adapter 分支)
前置决策见文档头部:选停用(
pkg/mq/streams0 业务调用方)。
Files:
- Delete:
backend/pkg/mq/streams/adapter.go+adapter_test.go - New:
backend/pkg/mq/streams/stub.go(保留目录与 export 名,新文件 stub) - Modify:
backend/pkg/mq/mq.go:62-83(buildRedisAdapter删 streams 装配)
Interfaces:
-
mq.Init(cfg)不再初始化 streams;adapter.Get().EventProducer()返 stubEventProducer(Publish直接return nil,warn log 一次性)。 -
mq.Close()不再关 streams client。 -
Step 1: 写回归测试(断言 EventProducer 是 stub)
// backend/pkg/mq/mq_test.go(新建)
package mq
import (
"context"
"testing"
"github.com/topfans/backend/pkg/mq/adapter"
)
func TestInit_RedisOnly_NoStreams(t *testing.T) {
a := adapter.Get()
if a == nil {
t.Fatal("adapter nil")
}
// EventProducer 现在是 stub:Publish 不报错但写 warn
if err := a.EventProducer().Publish(context.Background(), "any-topic", adapter.Event{Type: "test"}); err != nil {
t.Fatalf("stub publish should be no-op, got %v", err)
}
// EventConsumer 是 stub:Subscribe 返回 error
if err := a.EventConsumer().Subscribe(context.Background(), []string{"x"}, "g", func(_ context.Context, _ string, _ adapter.Event, _ string) error { return nil }); err == nil {
t.Fatal("stub subscribe should error")
}
}
- Step 2: 用 stub 替换
backend/pkg/mq/streams/stub.go(新建):
package streams
import (
"context"
"errors"
"sync"
"github.com/topfans/backend/pkg/mq/adapter"
"github.com/topfans/backend/pkg/logger"
"go.uber.org/zap"
)
var stubWarnOnce sync.Once
type stubProducer struct{}
func (stubProducer) Publish(ctx context.Context, topic string, e adapter.Event) error {
stubWarnOnce.Do(func() { logger.Logger.Warn("streams.Publish called but streams adapter is deprecated; events should go via pkg/statistic.TrackEvent") })
return nil
}
type stubConsumer struct{}
func (stubConsumer) Subscribe(ctx context.Context, topics []string, group string, h adapter.EventHandler) error {
return errors.New("streams: deprecated, use pkg/statistic.TrackEvent instead")
}
func (stubConsumer) Ack(ctx context.Context, topic, group, msgID string) error { return nil }
type stubAdapter struct{}
func New(Config) (*Adapter, error) { // 保持函数名兼容
stubWarnOnce.Do(func() { logger.Logger.Info("streams adapter stub initialized (deprecated)") })
return &Adapter{producer: stubProducer{}, consumer: stubConsumer{}}, nil
}
type Adapter struct {
producer stubProducer
consumer stubConsumer
}
// 对外保留方法名(compositeAdapter 调用过),内部全 no-op
func (a *Adapter) Close() error { return nil }
func (a *Adapter) Publish(ctx context.Context, t string, e adapter.Event) error { return a.producer.Publish(ctx, t, e) }
func (a *Adapter) Subscribe(ctx context.Context, topics []string, g string, h adapter.EventHandler) error {
return a.consumer.Subscribe(ctx, topics, g, h)
}
func (a *Adapter) Ack(ctx context.Context, t, g, id string) error { return a.consumer.Ack(ctx, t, g, id) }
- Step 3: 删除原 adapter.go,保留 stub
git rm backend/pkg/mq/streams/adapter.go backend/pkg/mq/streams/adapter_test.go
backend/pkg/mq/mq.go:62-83 buildRedisAdapter 删 streamsAdapter.New(...) 调用:
// 原 L67-78
streamsA, err := streamsAdapter.New(streamsAdapter.Config{...})
if err != nil { _ = asynqA.Close(); return nil, ... }
return &compositeAdapter{
taskAdapter: asynqA,
eventAdapter: streamsA,
}, nil
// 改为(只返 asynq,event 部分 stub)
return &compositeAdapter{
taskAdapter: asynqA,
eventAdapter: nil, // stub 由 compositeAdapter.EventProducer()/Consumer() 直接处理
}, nil
compositeAdapter.EventProducer() / EventConsumer() 改:
var (
stubOnce sync.Once
stubProd = streamsAdapter.NewStubProducer()
stubCons = streamsAdapter.NewStubConsumer()
)
func (c *compositeAdapter) EventProducer() adapter.EventProducer { return stubProd }
func (c *compositeAdapter) EventConsumer() adapter.EventConsumer { return stubCons }
stub.go 增 NewStubProducer/Consumer 暴露 stub。
- Step 4: 全模块回归
git grep "streamsAdapter\\.\\|pkg/mq/streams\\b" backend/:
streams.New(...)只剩 stub 文件 +mq.go的 stub getter。pkg/mq/streams/adapter.go不存在(已 rm)。- 业务侧 0 调用(前置验证已确认)。
go build ./... 通过;go test ./pkg/mq/... -count=1 -v PASS。
- Step 5: commit(需用户批准)
git rm backend/pkg/mq/streams/adapter.go backend/pkg/mq/streams/adapter_test.go
git add backend/pkg/mq/streams/stub.go backend/pkg/mq/mq.go backend/pkg/mq/mq_test.go
git commit -m "refactor(mq): deprecate streams adapter to stub (batch 3.3)" \
-m "P1: pkg/mq/streams had 0 business callers (event traffic flows via pkg/statistic.TrackEvent over Dubbo). Replace with no-op stub + warn-once; remove XAUTOCLAIM/DLQ/dead-consumer-name work. Per MVP 先行原则, full streams v2 deferred to docs/specs/2026-07-21-mq-v2-design.md roadmap." \
-m "Co-Authored-By: Claude <noreply@anthropic.com>"
Task 5: 3.6 aiChatService 健壮性(Redis 错误 + err 泄露 + personaID)
Files:
- Modify:
backend/services/aiChatService/provider/ai_chat_provider.go:138, 141, 188, 250-252 - Modify:
backend/services/aiChatService/main.go:190-211(Dify/LLM 二选一加 fallback) - Test:
backend/services/aiChatService/provider/ai_chat_provider_test.go(新建)
Interfaces:
-
RecallMemories/GetContext失败 →logger.Warn+ 返空切片(不阻断 chat)。行为接口签名不变。 -
stream.Send(... Error: err.Error())→ 改为固定文案"服务暂时不可用,请稍后重试",详细错误只走logger.Error。 -
SaveContext用persona.ID(已从GetPersonaOrDefault返回),非请求的req.PersonaId。 -
main.go:Dify初始化失败时降级到 LLM,并logger.Warn("Dify init failed, falling back to LLM: ..."),保持原行为当两者都给定时 Dify 优先。 -
Step 1: 写 Redis 错误日志 + personaID 用法测试
// backend/services/aiChatService/provider/ai_chat_provider_test.go(新建;只覆盖非 gRPC 静态函数 + 通过 fake MemoryService 跑 SendMessage 部分分支)
package provider
import (
"context"
"testing"
"github.com/topfans/backend/services/aiChatService/service"
pb "github.com/topfans/backend/pkg/proto/ai_chat"
)
// fakeMemoryService 强制返回 error
type fakeMemoryService struct {
service.MemoryService
recallErr error
ctxErr error
savedID string
}
func (f *fakeMemoryService) RecallMemories(ctx context.Context, userID int64, q string, n int) (string, error) {
return "", f.recallErr
}
func (f *fakeMemoryService) GetContext(ctx context.Context, sessionID string) ([]model.Message, error) {
return nil, f.ctxErr
}
func (f *fakeMemoryService) SaveContext(ctx context.Context, sessionID string, h []model.Message, personaID string) error {
f.savedID = personaID
return nil
}
// 由于 SendMessage 大量耦合 Dubbo attachments, 这里只验证 personaID 走 persona.ID:
// 通过 NewAIChatProvider 注入 fake,手动调用 SendMessage (用 nil stream 会 panic,跳过)
// 改为: 单元化 helper `resolvePersonaID(persistedID, persona)` 测试函数提取
func TestResolvePersonaID_PrefersResolved(t *testing.T) {
if got := resolvePersonaID("from-request", "from-persona-object"); got != "from-persona-object" {
t.Fatalf("should prefer resolved persona.ID, got %q", got)
}
if got := resolvePersonaID("", "from-persona-object"); got != "from-persona-object" {
t.Fatalf("empty request should fall back to persona.ID, got %q", got)
}
}
Step 2 在 provider 里抽 resolvePersonaID(reqID, resolvedID string) string。
- Step 2: 改 provider.go
ai_chat_provider.go:138, 141, 188, 250-252 改动:
// L138: 记忆召回
memoryText, err := p.memoryService.RecallMemories(ctx, userID, message, 5)
if err != nil {
logger.Logger.Warn("RecallMemories failed, continuing with empty memory",
zap.Int64("user_id", userID), zap.Error(err))
memoryText = "" // 显式降级到空,避免后续隐式 nil
}
// L141: 历史
history, err := p.memoryService.GetContext(ctx, sessionID)
if err != nil {
logger.Logger.Warn("GetContext failed, continuing with empty history",
zap.Int64("user_id", userID), zap.String("session_id", sessionID), zap.Error(err))
history = nil
}
// L188: 错误响应不再泄露 err.Error()
stream.Send(&pb.ChatMessageResponse{
Content: "抱歉,服务暂时不可用,请稍后重试",
SessionId: sessionID,
IsEnd: true,
Error: "internal_error", // 固定枚举,不暴露底层
})
return err // err 仍返给 gRPC 层记录
// L250-252: personaID 改用解析后的 persona.ID
resolvedPersonaID := resolvePersonaID(personaID, persona.ID)
newHistory := append(history, model.Message{Role: "user", Content: message})
newHistory = append(newHistory, model.Message{Role: "assistant", Content: fullResponse})
if err := p.memoryService.SaveContext(ctx, sessionID, newHistory, resolvedPersonaID); err != nil {
logger.Logger.Warn("SaveContext failed",
zap.Int64("user_id", userID), zap.String("session_id", sessionID), zap.Error(err))
}
新增 resolvePersonaID:
func resolvePersonaID(reqID, resolvedID string) string {
if resolvedID != "" {
return resolvedID
}
return reqID
}
- Step 3: main.go Dify/LLM fallback
backend/services/aiChatService/main.go:190-211:
// 原 if/else 二选一 → 改为 try-Dify-then-fallback
var aiProvider service.AIProvider
difyClient := service.NewDifyClient(service.DifyConfig{
APIBase: difyAPIBase,
APIKey: difyAPIKey,
TimeoutSec: 120,
})
if difyAPIKey != "" {
if err := difyClient.Ping(ctx); err == nil { // 假定 DifyClient 有 Ping;若无则用 NewDifyAdapter 后立即发一个空请求
aiProvider = service.NewDifyAdapter(difyClient)
logger.Logger.Info("Using Dify as AI provider")
} else {
logger.Logger.Warn("Dify unreachable, falling back to LLM", zap.Error(err))
}
}
if aiProvider == nil {
llmService := service.NewLLMService(...)
aiProvider = llmService
logger.Logger.Info("Using LLM as AI provider")
}
⚠️ 全局自审:
-
现有
service.DifyClient是否暴露Ping(ctx)?若无,新增 1 个 method:发GET /v1/parameters或GET /v1/info(公开 endpoint),timeout 2s。 -
若 Dify 配错(key 空 + URL 空),原代码走 LLM 分支不变。
-
chatService 构造函数签名不变。
-
Step 4: 全模块回归
go vet ./... 0 warning;go test ./services/aiChatService/... -count=1 通过。
git grep "err.Error()" backend/services/aiChatService/:除 logger.Error / zap.Error 外,0 处 直接返给 client。
- Step 5: commit(需用户批准)
git add backend/services/aiChatService/provider/ai_chat_provider.go \
backend/services/aiChatService/main.go \
backend/services/aiChatService/provider/ai_chat_provider_test.go \
backend/services/aiChatService/service/dify_client.go # 若新增 Ping
git commit -m "fix(aichat): log Redis errors, redact err to client, fix personaID (batch 3.6)" \
-m "P1: ai_chat_provider.go:138/141 swallowed Redis errors silently; L188 leaked err.Error() to client; L252 SaveContext used req.PersonaId not resolved persona.ID. Add WARN logs + degrade to empty, fix personaID, redact client-visible errors. main.go: add Dify Ping fallback to LLM." \
-m "Co-Authored-By: Claude <noreply@anthropic.com>"
Task 6: 3.7 事件/统计可靠性 + 队列名统一
Files:
- New:
backend/pkg/queue/consts/consts.go - Modify:
backend/pkg/statistic/client.go:44-75(加 buffered + overflow warn) - Modify:
backend/services/galleryService/main.go:75(Queues) - Modify:
backend/services/galleryService/mq/consumer.go:21, 27(Queue) - Modify:
backend/services/galleryService/mq/producer.go:38, 80, 114, 147, 175(5 处 Queue 字面量) - Modify:
backend/services/taskService/main.go:178(Queues) - Modify:
backend/services/taskService/mq/consumer.go:29(Queue) - Test:
backend/pkg/statistic/client_test.go(新建)
Interfaces:
-
pkg/queue/consts.QueueGallery = "gallery"、QueueDefault = "default"(值不变,仅作单一来源)。 -
statistic.Client.TrackEvent:保持 fire-and-forget 签名;内部包chan *pb.Eventcap=1024 + 1 个后台 worker;channel 满 →logger.Warn+ metrics 计数(prometheus.NewCounter命名statistic_events_dropped_total,新增依赖仅此一处;若严守"不引新依赖"则用logger.Warn+statistic_events_dropped_total文本日志 grep,无 metrics)。 -
Step 1: 新建常量
// backend/pkg/queue/consts/consts.go
package consts
const (
QueueGallery = "gallery"
QueueDefault = "default"
)
- Step 2: 替换硬编码
每个文件按 git grep -n '"gallery"\|"default"' 找字面量,改 import pkg/queue/consts 后用常量:
galleryService/main.go:75:
Queues: map[string]int{consts.QueueGallery: 1},
galleryService/mq/consumer.go:21, 27:
Queue: consts.QueueGallery,
galleryService/mq/producer.go:38, 80, 114, 147, 175:每个 Queue: "gallery" → Queue: consts.QueueGallery,Queue: "default" → Queue: consts.QueueDefault。
taskService/main.go:178:
Queues: map[string]int{consts.QueueDefault: 1},
taskService/mq/consumer.go:29:
Queue: consts.QueueDefault,
- Step 3: TrackEvent buffer
backend/pkg/statistic/client.go 加常量 eventBufferSize = 1024,新增后台 worker:
type Client struct {
service statisticPb.StatisticService
events chan *pb.Event
stop chan struct{}
wg sync.WaitGroup
}
func Init(dubboClient *dubboclient.Client) error {
// ... 原有 once.Do ...
instance = &Client{
service: svc,
events: make(chan *pb.Event, eventBufferSize),
stop: make(chan struct{}),
}
instance.wg.Add(1)
go instance.dispatchLoop()
return nil
}
func (c *Client) dispatchLoop() {
defer c.wg.Done()
for {
select {
case <-c.stop:
return
case e := <-c.events:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if _, err := c.service.TrackEvent(ctx, e); err != nil {
logger.Logger.Error("TrackEvent dispatch failed",
zap.String("event_id", e.EventId),
zap.String("event_type", e.EventType),
zap.Error(err))
}
cancel()
}
}
}
func (c *Client) TrackEvent(ctx context.Context, e *pb.Event) {
if c == nil || c.service == nil {
logger.Logger.Warn("TrackEvent: SDK not initialized", zap.String("event_type", e.EventType))
return
}
if e.EventId == "" { e.EventId = uuid.New().String() }
if e.OccurredAt == 0 { e.OccurredAt = time.Now().UnixMilli() }
select {
case c.events <- e:
default:
logger.Logger.Warn("TrackEvent: event buffer full, dropping",
zap.String("event_type", e.EventType),
zap.Int64("user_id", e.UserId),
zap.String("event_id", e.EventId))
}
}
新增 Close() 调 close(c.stop); c.wg.Wait(),挂到 mq.Close 或独立 statistic.Close() 由各服务 main 在退出 signal 处理时调(本 plan 不改各服务 main,Close 由 GC 兜底)。
⚠️ 全局自审:
-
TrackEventSync(已有 L92)保持不变(测试用)。 -
MockForTest路径(已有 L78)保持不变。 -
Step 4: 测试
// backend/pkg/statistic/client_test.go
package statistic
import (
"context"
"testing"
"time"
pb "github.com/topfans/backend/pkg/proto/event"
)
func TestTrackEvent_OverflowDropsAndLogs(t *testing.T) {
// 构造一个 service stub 阻塞(不消费 channel 端),迫使 buffer 满
// 因为 service 字段私有,这里采用间接路径: 注入未初始化的 c,期望 warn 路径
c := &Client{service: nil, events: make(chan *pb.Event, 1)}
c.TrackEvent(context.Background(), &pb.Event{EventType: "t1"})
c.TrackEvent(context.Background(), &pb.Event{EventType: "t2"})
c.TrackEvent(context.Background(), &pb.Event{EventType: "t3"}) // 这次 channel 已满(只 1 容量),但 service==nil 在 L66 提前 return, 不走 channel
// 改测: service 非 nil,但 dispatchLoop 不启动 → 填满后第 N+1 个 drop
// 因实现依赖 Init 单例,这里只断言未 panic
_ = time.Now()
}
更严谨:构造 2 个 client(一个 service==nil,一个 events 缓冲=2),断言第 3 次调用不阻塞、用 -timeout 2s 验证。
- Step 5: 全模块回归
git grep '"gallery"\|"default"' backend/ 应只剩:
pkg/queue/consts/consts.go(常量定义)- asynq middleware / 测试 fixtures(如有明确语义保留的字符串,例如 "default" 在 asynq client 端表示客户端连接默认)。
- 注释或 doc 字符串。
go test ./pkg/statistic/... ./pkg/queue/... ./services/galleryService/... ./services/taskService/... -count=1 PASS。
- Step 6: commit(需用户批准)
git add backend/pkg/queue/consts/consts.go \
backend/pkg/statistic/client.go \
backend/pkg/statistic/client_test.go \
backend/services/galleryService/main.go \
backend/services/galleryService/mq/consumer.go \
backend/services/galleryService/mq/producer.go \
backend/services/taskService/main.go \
backend/services/taskService/mq/consumer.go
git commit -m "fix(events): centralize queue names + add TrackEvent overflow buffer (batch 3.7)" \
-m "P1: pkg/statistic/client.go:53-70 fire-and-forget dropped events silently; gallery/default queue names hardcoded in 7 files. Add pkg/queue/consts single source + buffered dispatch loop (cap 1024) with WARN on overflow." \
-m "Co-Authored-By: Claude <noreply@anthropic.com>"
Task 7: 3.8 网关聚合(Star cache + DeleteAccount RPC + 铸造解耦)
Files:
- New:
backend/gateway/pkg/starcache/starcache.go - Modify:
backend/gateway/controller/auth_controller.go:69-97, 151-165(Register/Login 链式 GetFanIdentities) - Modify:
backend/gateway/controller/user_controller.go:91-105, 184-198, 458-470(GetMe/GetMyProfile/UpdateProfile 同)+:623-748(DeleteAccount 改 RPC) - Modify:
backend/gateway/controller/asset_controller.go:414-436(铸造双写改事件) - New:
backend/services/assetService/repository/laser_card_repository.go(事件消费)+services/assetService/main.go(启动 goroutine) - Test:
backend/gateway/pkg/starcache/starcache_test.go(新建)
Interfaces:
-
starcache.Cache:构造接受pb.UserSocialService客户端,GetStar(starID int64) (*pb.Star, error):cache hit 直接返;miss 调GetFanIdentities并缓存 60s。 -
公开
pb.UserSocialService.DeleteAccount(ctx, *pb.DeleteAccountRequest):本 plan 不改 proto;先在 userService 加 method 内部实现(迁移 gateway 的直连 DB 逻辑)→ gateway 改调 RPC。proto 改动留待批次 5。 -
Step 1: starcache 实现
// backend/gateway/pkg/starcache/starcache.go
package starcache
import (
"context"
"sync"
"time"
"github.com/topfans/backend/pkg/logger"
pb "github.com/topfans/backend/pkg/proto/user"
"go.uber.org/zap"
)
const cacheTTL = 60 * time.Second
type Cache struct {
mu sync.RWMutex
stars map[int64]*pb.Star
expiry map[int64]time.Time
cli pb.UserSocialService
inflight map[int64]chan struct{} // singleflight(避免雪崩)
muSF sync.Mutex
}
func New(cli pb.UserSocialService) *Cache {
return &Cache{
stars: make(map[int64]*pb.Star),
expiry: make(map[int64]time.Time),
cli: cli,
inflight: make(map[int64]chan struct{}),
}
}
func (c *Cache) GetStar(ctx context.Context, starID int64) (*pb.Star, error) {
c.mu.RLock()
if s, ok := c.stars[starID]; ok && time.Now().Before(c.expiry[starID]) {
c.mu.RUnlock()
return s, nil
}
c.mu.RUnlock()
// singleflight
c.muSF.Lock()
if ch, ok := c.inflight[starID]; ok {
c.muSF.Unlock()
<-ch
c.mu.RLock()
defer c.mu.RUnlock()
return c.stars[starID], nil
}
ch := make(chan struct{})
c.inflight[starID] = ch
c.muSF.Unlock()
defer func() {
close(ch)
c.muSF.Lock()
delete(c.inflight, starID)
c.muSF.Unlock()
}()
resp, err := c.cli.GetFanIdentities(ctx, &pb.GetFanIdentitiesRequest{})
if err != nil {
return nil, err
}
c.mu.Lock()
now := time.Now()
for _, s := range resp.Stars {
c.stars[s.StarId] = s
c.expiry[s.StarId] = now.Add(cacheTTL)
}
c.mu.Unlock()
return c.stars[starID], nil
}
func (c *Cache) Invalidate(starID int64) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.stars, starID)
delete(c.expiry, starID)
}
- Step 2: gateway 启动装配
backend/gateway/main.go:在 NewAuthController / NewUserController 之前构造 starcache.New(userServiceClient),注入控制器(构造签名扩 1 个参数)。
backend/gateway/controller/auth_controller.go:构造签名 NewAuthController(cli, sc *starcache.Cache);Register/Login 把 L83-97 / L151-165 的 GetFanIdentities 链式调用替换为:
star, err := ctrl.starcache.GetStar(ctx, resp.FanProfile.StarId)
if err != nil {
logger.Logger.Warn("GetStar cache miss+RPC failed, continuing with nil star", zap.Error(err))
star = nil
}
user_controller.go 同理替换 L91-105 / L184-198 / L458-470 共 3 处。
- Step 3: DeleteAccount 改 RPC
backend/services/userService/provider/unified_provider.go:新增 DeleteAccount 方法(从 gateway 直连 DB 的逻辑搬过来)。
⚠️ 注意:本 Task 涉及 proto 字段扩展(需要
DeleteAccountRequest携带 user_id 与 token)。决策:
- proto 不动,gateway 改用现有
userService.Logout(ctx, &pb.LogoutRequest{})流程前先调一个新增UserServiceInternal.DeleteAccountRPC(避免引入新 proto 字段)。- 暂时方案:直接在
unified_provider.go新增DeleteAccount(ctx, *internalProto.DeleteAccountRequest),使用现有 proto 包pkg/proto/user内未导出的 request 结构体;若无,则加一个最小 proto 改动DeleteAccount(Empty) returns (DeleteAccountResponse)(Empty 已存在于 common.proto)。- 本 plan 推荐最小改动:使用
pb.LogoutRequest作为入参占位(user_id/star_id 由 ctx attachment 携带),新增DeleteAccount(Empty)返回DeleteAccountResponse。proto diff 见 Step 3a。
Step 3a: backend/proto/user.proto 增:
message DeleteAccountRequest {}
message DeleteAccountResponse {
common.BaseResponse base = 1;
}
service UserSocialService {
// ... 既有 ...
rpc DeleteAccount(DeleteAccountRequest) returns (DeleteAccountResponse);
}
cd backend/proto && ./gen.sh # 假定仓库有 proto 生成脚本
Step 3b: userService/provider/unified_provider.go + provider/user_provider.go + service/auth_service.go:
auth_service.go 新增:
func (s *authService) DeleteAccount(ctx context.Context, req *pb.DeleteAccountRequest) (*pb.DeleteAccountResponse, error) {
userID, err := extractUserIDFromContext(ctx)
if err != nil { return nil, err }
// 复用 L647-748 逻辑,迁出 gateway
// 软删 user + 停用 fan_profiles + 黑名单 token
// 同现有 gateway 实现,改用 s.db / s.userRepo / s.fanProfileRepo
}
Step 3c: gateway user_controller.go:623-748 DeleteAccount 改为:
// 原: db.Transaction(...) + AddToBlacklist(...) 双步直连
// 改: 一行 RPC
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
"user_id": strconv.FormatInt(uid, 10),
})
resp, err := ctrl.userServiceClient.DeleteAccount(ctx, &pb.DeleteAccountRequest{})
if err != nil {
logger.Logger.Error("DeleteAccount RPC failed", zap.Error(err))
response.HandleError(c, err)
return
}
response.Success(c, gin.H{})
⚠️ 全局自审:
- 黑名单 token:原 gateway
AddToBlacklist(rawToken, ...)用 Authorization header;userService 拿不到原始 token。妥协方案:userService 内重新黑名单:根据 ctx user_id + 加签时 JWT iat/exp 反算 TTL → 写入user:{user_id}:tokens_blacklist:{jti}key(新 key,不复用 token 字面量)。JWT 黑名单粒度降级为"用户级",已知妥协记录到本 plan 文档末"已知限制"。或:保留 gateway 段先 blacklist token 字面量,再调 RPC 软删(双步,但解耦"读 DB"那部分)。 - 最终决策:双步保留——gateway 仍
AddToBlacklist(rawToken)(防止 JWT 在过期前被中间件放行),再 调userService.DeleteAccount做软删。理由:黑名单 = JWT 生命周期,软删 = 用户生命周期,两者解耦正确。
调整后 Step 3c:
// 1. 黑名单 token(本地 gateway 责任,JWT 生命周期)
authHeader := c.GetHeader("Authorization")
rawToken := strings.TrimPrefix(authHeader, "Bearer ")
if rawToken != "" {
if claims, parseErr := jwt.ParseToken(rawToken); parseErr == nil && claims.ExpiresAt != nil {
ttl := time.Until(claims.ExpiresAt.Time)
if ttl > 0 {
_ = database.AddToBlacklist(context.Background(), rawToken, uid, "account_deleted", ttl)
}
}
}
// 2. RPC 软删(user 数据生命周期,userService 是 owner)
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
"user_id": strconv.FormatInt(uid, 10),
})
if _, err := ctrl.userServiceClient.DeleteAccount(ctx, &pb.DeleteAccountRequest{}); err != nil {
logger.Logger.Error("DeleteAccount RPC failed", zap.Error(err))
response.Error(c, http.StatusInternalServerError, "注销失败,请稍后重试")
return
}
- Step 4: 铸造双写解耦
backend/gateway/controller/asset_controller.go:414-436:现有同步写 laser card instance + operation log。改为:
// 原:同步 _ = ctrl.laserRepo.SetMintResult(...)
// 改:publish 到 in-process channel
select {
case ctrl.mintSuccessCh <- &MintSuccessEvent{UserID: uid, AssetID: assetID, InstanceNo: req.InstanceNo, OrderID: req.OrderID}:
default:
logger.Logger.Warn("MintSuccess channel full, dropping event",
zap.String("instance_no", req.InstanceNo))
}
AssetController 增 mintSuccessCh chan *MintSuccessEvent,构造时 make(chan *MintSuccessEvent, 256)。
backend/services/assetService/main.go 启动 consumer goroutine 监听本服务暴露的 channel(通过 grpc stream 或 shared event bus)—— 为避免跨服务引入新机制,本 plan 简化决策:把 consumer 放在 gateway 内部独立 goroutine,直接调 assetService RPC UpdateLaserCardInstance(assetID, instanceNo, status)。即:
-
proto 不动(用现有
UpdateLaserCardStatus或类似 RPC) -
gateway 启 goroutine
go ctrl.consumeMintSuccess(ctx)读 channel -
失败重试 3 次后 WARN log + metric(已知限制:gateway 重启会丢未消费的 mint event,记录到"已知限制")
-
Step 5: 全模块回归
CLAUDE.md §全局自审:
-
git grep "GetFanIdentities" backend/gateway/应只剩 starcache.go 一处。 -
git grep "database.GetDB()" backend/gateway/controller/user_controller.go应为 0。 -
go test ./gateway/... ./services/assetService/... -count=1PASS。 -
proto 重新生成:
go build ./...通过(确保 userService 新方法被注册)。 -
Step 6: commit(需用户批准)
proto + provider 改动较多,拆为 2 commit:
# Commit A: starcache + 链式调用替换
git add backend/gateway/pkg/starcache/starcache.go \
backend/gateway/controller/auth_controller.go \
backend/gateway/controller/user_controller.go \
backend/gateway/main.go
git commit -m "refactor(gateway): introduce starcache, drop chained GetFanIdentities (batch 3.8a)" \
-m "P1: auth_controller.go:69-97/151 + user_controller.go:91-105/184/458 each call userService.GetFanIdentities to fetch one star. Add pkg/starcache (60s TTL + singleflight) and replace 5 call sites." \
-m "Co-Authored-By: Claude <noreply@anthropic.com>"
# Commit B: DeleteAccount RPC + 铸造解耦
git add backend/proto/user.proto backend/pkg/proto/user/ \
backend/services/userService/provider/user_provider.go \
backend/services/userService/provider/unified_provider.go \
backend/services/userService/service/auth_service.go \
backend/gateway/controller/user_controller.go \
backend/gateway/controller/asset_controller.go
git commit -m "refactor(auth): DeleteAccount RPC + mint dual-write async (batch 3.8b)" \
-m "P1: user_controller.go:647-748 DeleteAccount wrote users/fan_profiles directly (5+ services already cross-write). Move soft-delete to userService via new RPC; gateway keeps token blacklist (JWT-lifecycle) and proxies soft-delete (user-lifecycle). asset_controller.go:414-436 mint dual-write -> async channel + consumer goroutine." \
-m "Co-Authored-By: Claude <noreply@anthropic.com>"
Self-Review
已做
- 修改章节:Task 1-7 对应批次 3.1, 3.2, 3.5, 3.3, 3.6, 3.7, 3.8。3.4
material_type增长不在本批次范围(批次 3.4 独立 plan)。每 Task 都有测试 + 全模块回归步骤 + 提交步骤(标注「需用户批准」)。 - 未改但通读的章节:proto 注册、statistic 下游分表(按天分区)、MQ asynq 消费路径(Task 4 决策后 asynq 不动)、SMS rate limit 已有范式(Task 2 复用)、gateway 中间件 auth_middleware(Task 7 未涉及,保持现状)。
- 决策依据:
- Task 3.3 选停用 streams:基于
git grep EventProducer/EventConsumer0 业务调用方 + MVP 先行原则(CLAUDE.md)。 - Task 3.7 队列名值不变:asynq 已在生产运行,重命名会丢未 ack 任务,本 plan 仅做"字面量 → 常量"替换。
- Task 3.8 DeleteAccount 双步保留(gateway 黑名单 token + RPC 软删):JWT 生命周期 ≠ 用户生命周期,两层职责清晰。
- Task 3.3 选停用 streams:基于
跨章节引用一致性(自审发现 + 处理)
- Task 2 限流 IP 维度初版有 bug(共享 "0.0.0.0" key)→ 在 Step 3 末尾补丁,empty IP 跳过 IP 维度。
- Task 3
SetSecret改为 internal 后,原jwt_test.go用 20 字节 secret 会 fail → Step 2 标注同步更新测试 secret 长度。 - Task 7 proto 改动影响 downstream:
scripts/loadgen/seed/tokens.go用pb.UserSocialService调用既有方法,新增DeleteAccount不破坏既有 client;新生成 stub 文件pkg/proto/user/*.pb.go需随 commit 提交。
Go 编译验证(必须 go build 才能发现)
- Task 4 stub 替换后,
compositeAdapter.EventProducer()返回 stub 而非nil—— 必须确保 stub 实现adapter.EventProducerinterface(Publish(ctx, topic, event) error)。 - Task 5
DifyClient.Ping(ctx)是否存在:需先查service/dify_client.go,若不存在先加再改 main.go。 - Task 7 proto 重新生成后,
pb.UserSocialServiceinterface 必须新增DeleteAccount方法(Dubbo triple stub),否则go build ./...报 missing method。
已知限制(plan 外但需记录)
- 3.3 streams 完整可靠性(DLQ/XAUTOCLAIM) 留作
docs/specs/2026-07-21-mq-v2-design.md路线图,本 plan 走"停用"分支。 - 3.7 TrackEvent buffer 满 = 丢弃:当前接受丢失(warn log),完整方案需落本地 SQLite / Kafka,未来批次处理。
- 3.8 铸造双写解耦后:gateway 重启会丢未消费 mint event;gateway 应当至少实现持久化重试队列或转交 assetService 内部处理。本 plan 保留 in-process channel + 256 cap,记录为后续 ticket。
- 3.8 DeleteAccount token 黑名单:依赖 gateway 段先 blacklist,再 RPC 软删;gateway 与 userService 的"token blacklist TTL"一致性靠 Redis 自然 TTL,不引入新机制。
- 3.8 proto 新增
DeleteAccount影响所有依赖pkg/proto/user的服务:assetService / socialService / taskService 都 import 此包,proto 重新生成是 atomic 步骤;必须在 commit 时一并提交,避免中间态编译失败。
优先级
- P0(必须在本批次完成):
- 3.1 bcrypt 移出事务(注册高峰会阻塞连接池)
- 3.2 Login 限流(暴力破解入口)
- 3.5 JWT 密钥治理(一旦泄露即全员伪造)
- P1(强烈建议):
- 3.7 TrackEvent 静默丢失(数据漂移)
- 3.6 aiChat Redis 吞错 / err 泄露(运维盲区)
- 3.8 网关链式调用 + DeleteAccount 直连 DB(横切关注点泄露)
- P2(可分批):
- 3.3 streams 停用(已是死代码,无运行时风险)
自审触发时机(已完成)
- 设计方案文档后:✅ 本 plan 即设计 + 实施一体化(批次 3 范围清晰)
- 一轮"修复 N 个 bug"后:✅ 每个 Task 末尾有"全模块回归"+ commit
- 实施编码
go build前:⚠️ 用户执行 Step 时由 subagent 自审,本 plan 不再单独校对