topfans/docs/superpowers/plans/2026-07-21-service-stability.md
2026-07-21 21:14:22 +08:00

64 KiB
Raw Blame History

服务稳定性 (批次 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-fastpkg/jwt.MustInit 在启动时一次性注入 + 校验 + 拒绝弱默认;pkg/mq/streams 退化为 stub停止实例化删除生产路径。
  • 可靠性层pkg/statistic/client.TrackEvent 改为「goroutine + bounded channel」buffer1024 槽),溢出落本地 SQLite 兜底;新增 pkg/queue/consts.QueueGallery / QueueDefault 单一来源。
  • 网关分层:抽出 pkg/gateway/starcache 缓存 star 目录TTL 60s替代 6 处 GetFanIdentities 链式调用;DeleteAccount 改为调 userService.DeleteAccount RPCuserService 才是 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.goEventProducer.PublishEventConsumer.Subscribe 0 业务调用方。asynq 是事实唯一的任务队列;统计事件走 pkg/statistic 自己的 Dubbo RPCstatistic.Get().TrackEvent),与 streams 无关。

决策:Task 3.3 选「停用 streams adapter」分支

依据 MVP 先行原则(CLAUDE.md §MVP 先行原则):

  • 现网 0 调用方 → 补 DLQ + XAUTOCLAIM + 稳定 consumer name 是为未来 100 业务准备的架构YAGNI
  • 半吊子(已声明的事件可靠性 + 实际 0 调用方)会让新人误以为已可用,未来接入时再翻工成本更高。
  • 停用路径 = 删除 streams adapter + 保留 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.godatabase.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.RWMutexGetStar(starID int64) *pb.StarInvalidate()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 改为 internalMustInit 调用,外部包只能 MustInit
  • backend/services/userService/service/auth_service.goRegister/ResetPasswordrepository.HashPassword 移到事务前;Login 统一 ErrInvalidCredential + 调限流;ValidateToken 同步新签名。
  • backend/services/userService/main.go — 调 jwt.MustInit(jwtSecret) 替代 jwt.SetSecretenv 缺失/弱默认 → fatal。
  • backend/services/userService/service/user_service.goResetPassword 同样把 HashPassword 移到事务前L670→L683fireCrystalChangeEvent 加 trace。
  • backend/services/aiChatService/provider/ai_chat_provider.go — Redis 错误升级 WARN + 降级;err.Error() 不再回传 clientpersonaIDpersona.ID
  • backend/services/aiChatService/main.go — Dify/LLM 二选一改为尝试 Dify 失败时 WARN 降级到 LLM保留原行为当两者都给定时 Dify 优先(业务 0 感知)。
  • backend/services/galleryService/main.goQueues: map[string]int{pkg/queue/consts.QueueGallery: 1}
  • backend/services/galleryService/mq/consumer.goQueue: pkg/queue/consts.QueueGallery
  • backend/services/galleryService/mq/producer.go — 5 处 Queue: "gallery"/"default" → 常量。
  • backend/services/taskService/main.goQueues: map[string]int{pkg/queue/consts.QueueDefault: 1}
  • backend/services/taskService/mq/consumer.goQueue: pkg/queue/consts.QueueDefault
  • backend/gateway/main.gojwt.SetSecretjwt.MustInit;启动 starcache.Start(ctx)
  • backend/gateway/controller/auth_controller.goRegister/Loginstarcache.GetStar(resp.FanProfile.StarId) 替代链式 GetFanIdentities
  • backend/gateway/controller/user_controller.goGetMe/GetMyProfile/UpdateProfile 同上;DeleteAccount 改为调 userService.DeleteAccount RPCuserService 才是 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 Redis5 次失败后命中限流mobile+ip 双维度。
  • backend/services/userService/service/auth_service_test.go — 新建 TestRegister_HashPassword_OutsideTxmock 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-233Register 事务块)
  • Modify: backend/services/userService/service/user_service.go:670-693ResetPassword 事务块)
  • 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 位于 L161s.db.Transaction(func(tx *gorm.DB) error { 之后)。

auth_service.goRegister 函数中,把 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 一处。

  • 调用方回归:RegisterRegisterResponse 仍带 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-489Login 全函数)
  • 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 900s15 分钟)
    • 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 路径上应为 0UpdatePassword 等仍保留。

  • git grep "login:fail:"只在 login_ratelimit.go

  • go test ./pkg/errors/... ./services/userService/service/... -count=1 PASS。

  • 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-60jwt.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

  • 全局 jwtSecretatomic.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.goSetSecret("test-secret-key")20 字节,长度 < 32→ 会 fail。同步把测试改为 SetSecret("test-secret-key-must-be-at-least-32-bytes") 或调用 setSecretInternal

  • backend/scripts/loadgen/seed/tokens.go:23jwt.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 warninggo 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/streams 0 业务调用方)。

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-83buildRedisAdapter 删 streams 装配)

Interfaces:

  • mq.Init(cfg) 不再初始化 streamsadapter.Get().EventProducer() 返 stub EventProducerPublish 直接 return nilwarn 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 buildRedisAdapterstreamsAdapter.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.goNewStubProducer/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-211Dify/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

  • SaveContextpersona.ID(已从 GetPersonaOrDefault 返回),非请求的 req.PersonaId

  • main.goDify 初始化失败时降级到 LLMlogger.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 个 methodGET /v1/parametersGET /v1/info(公开 endpointtimeout 2s。

  • 若 Dify 配错key 空 + URL 空),原代码走 LLM 分支不变。

  • chatService 构造函数签名不变。

  • Step 4: 全模块回归

go vet ./... 0 warninggo 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:75Queues
  • Modify: backend/services/galleryService/mq/consumer.go:21, 27Queue
  • Modify: backend/services/galleryService/mq/producer.go:38, 80, 114, 147, 1755 处 Queue 字面量)
  • Modify: backend/services/taskService/main.go:178Queues
  • Modify: backend/services/taskService/mq/consumer.go:29Queue
  • Test: backend/pkg/statistic/client_test.go(新建)

Interfaces:

  • pkg/queue/consts.QueueGallery = "gallery"QueueDefault = "default"(值不变,仅作单一来源)。

  • statistic.Client.TrackEvent:保持 fire-and-forget 签名;内部包 chan *pb.Event cap=1024 + 1 个后台 workerchannel 满 → 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.QueueGalleryQueue: "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 不改各服务 mainClose 由 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-165Register/Login 链式 GetFanIdentities
  • Modify: backend/gateway/controller/user_controller.go:91-105, 184-198, 458-470GetMe/GetMyProfile/UpdateProfile 同)+ :623-748DeleteAccount 改 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.DeleteAccount RPC避免引入新 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 headeruserService 拿不到原始 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))
}

AssetControllermintSuccessCh 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=1 PASS。

  • 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_middlewareTask 7 未涉及,保持现状)。
  • 决策依据
    • Task 3.3 选停用 streams基于 git grep EventProducer/EventConsumer 0 业务调用方 + MVP 先行原则(CLAUDE.md)。
    • Task 3.7 队列名值不变asynq 已在生产运行,重命名会丢未 ack 任务,本 plan 仅做"字面量 → 常量"替换。
    • Task 3.8 DeleteAccount 双步保留gateway 黑名单 token + RPC 软删JWT 生命周期 ≠ 用户生命周期,两层职责清晰。

跨章节引用一致性(自审发现 + 处理)

  1. Task 2 限流 IP 维度初版有 bug(共享 "0.0.0.0" key→ 在 Step 3 末尾补丁empty IP 跳过 IP 维度。
  2. Task 3 SetSecret 改为 internal 后,原 jwt_test.go 用 20 字节 secret 会 fail → Step 2 标注同步更新测试 secret 长度。
  3. Task 7 proto 改动影响 downstreamscripts/loadgen/seed/tokens.gopb.UserSocialService 调用既有方法,新增 DeleteAccount 不破坏既有 client新生成 stub 文件 pkg/proto/user/*.pb.go 需随 commit 提交。

Go 编译验证(必须 go build 才能发现)

  • Task 4 stub 替换后,compositeAdapter.EventProducer() 返回 stub 而非 nil —— 必须确保 stub 实现 adapter.EventProducer interfacePublish(ctx, topic, event) error)。
  • Task 5 DifyClient.Ping(ctx) 是否存在:需先查 service/dify_client.go,若不存在先加再改 main.go。
  • Task 7 proto 重新生成后,pb.UserSocialService interface 必须新增 DeleteAccount 方法Dubbo triple stub否则 go build ./... 报 missing method。

已知限制plan 外但需记录)

  1. 3.3 streams 完整可靠性DLQ/XAUTOCLAIM 留作 docs/specs/2026-07-21-mq-v2-design.md 路线图,本 plan 走"停用"分支。
  2. 3.7 TrackEvent buffer 满 = 丢弃当前接受丢失warn log完整方案需落本地 SQLite / Kafka未来批次处理。
  3. 3.8 铸造双写解耦后gateway 重启会丢未消费 mint eventgateway 应当至少实现持久化重试队列或转交 assetService 内部处理。本 plan 保留 in-process channel + 256 cap记录为后续 ticket。
  4. 3.8 DeleteAccount token 黑名单:依赖 gateway 段先 blacklist再 RPC 软删gateway 与 userService 的"token blacklist TTL"一致性靠 Redis 自然 TTL不引入新机制。
  5. 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 不再单独校对