topfans/backend/services/userService/mq/consumer.go

143 lines
5.1 KiB
Go

// Package mq 是 userService 的消息队列适配入口。
//
// 业务侧用法 (main.go 启动时):
//
// if err := mq.RegisterHandlers(); err != nil { ... }
// go mq.StartConsumers(ctx)
//
// 本期范围:只接入 user:accumulate-hours handler(接收 galleryService 派发的累计时长任务,
// 补回手动下架丢失的累计时长,见 backend/docs/superpowers/specs/2026-07-01-message-queue-design.md)。
package mq
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/topfans/backend/pkg/database"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/mq/adapter"
"github.com/topfans/backend/pkg/mq/tasks"
"github.com/topfans/backend/services/userService/repository"
"go.uber.org/zap"
)
// FanProfileRepository 是 AddExhibitionHours 必需的依赖,通过参数传入避免循环 import。
//
// 业务侧通常传 service 包装,这里只接受最小接口(handler 自己只调这一个方法)。
type FanProfileRepository interface {
// AddExhibitionHours 增加用户累计上架时长并触发升级(已有实现)。
// 幂等:handler 内部按 source_id 做幂等键(目前依赖业务侧保证唯一性)。
AddExhibitionHours(userID, starID int64, hours int64, sourceID string) (newLevel, levelDelta int32, crystalReward int64, err error)
}
// RegisterHandlers 注册 userService 涉及的所有 MQ handler。
func RegisterHandlers(fanProfileRepo repository.FanProfileRepository) error {
tc := adapter.Get().TaskConsumer()
if err := tc.RegisterTask(tasks.TypeUserAccumulateHours, newHandleAccumulateHours(fanProfileRepo), adapter.TaskRegisterOptions{
MaxRetry: 3,
Queue: "default",
}); err != nil {
return fmt.Errorf("register user:accumulate-hours: %w", err)
}
logger.Logger.Info("user mq handlers registered",
zap.String("types", tasks.TypeUserAccumulateHours))
return nil
}
// StartConsumers 启动 worker (阻塞直到 ctx 取消)。
//
// 业务侧应在 main.go 用 goroutine 调用:
//
// go mq.StartConsumers(ctx)
func StartConsumers(ctx context.Context) error {
return adapter.Get().TaskConsumer().Run(ctx)
}
// newHandleAccumulateHours 返回 adapter.TaskHandler,内部完成幂等后调 fanProfileRepo.AddExhibitionHours。
func newHandleAccumulateHours(repo FanProfileRepository) adapter.TaskHandler {
return func(ctx context.Context, t *adapter.Task) error {
var p tasks.UserAccumulateHoursPayload
if err := tasks.UnmarshalPayload(t.Payload, &p); err != nil {
logger.Logger.Error("handle user:accumulate-hours: unmarshal failed", zap.Error(err))
return fmt.Errorf("unmarshal: %w", err)
}
if p.UserID <= 0 || p.StarID <= 0 || p.Hours <= 0 {
logger.Logger.Warn("invalid accumulate hours payload",
zap.Int64("user_id", p.UserID),
zap.Int64("star_id", p.StarID),
zap.Int32("hours", p.Hours))
// 无效 payload 不重试,直接 drop
return nil
}
// 幂等:galleryService 派发时 source_id = "exhibition_<id>",
// 同一个 exhibition_id 多次派发只会累加一次。
if isAlreadyProcessed(ctx, p.SourceID) {
logger.Logger.Info("exhibition hours already accumulated, skip",
zap.Int64("user_id", p.UserID),
zap.String("source_id", p.SourceID))
return nil
}
if _, _, _, err := repo.AddExhibitionHours(
p.UserID, p.StarID,
int64(p.Hours),
p.SourceID,
); err != nil {
logger.Logger.Error("AddExhibitionHours failed",
zap.Int64("user_id", p.UserID),
zap.String("source_id", p.SourceID),
zap.Error(err))
return err
}
logger.Logger.Info("user exhibition hours accumulated",
zap.Int64("user_id", p.UserID),
zap.Int64("star_id", p.StarID),
zap.Int32("hours", p.Hours),
zap.String("source_id", p.SourceID),
zap.String("source_type", p.Source))
return nil
}
}
// isAlreadyProcessed 查 records 表(level_up_logs / crystal_transactions 等)看 source_id 是否已出现。
// 由于 AddExhibitionHours 内部会用 source_id 做幂等(version_id 或 idempotency key),
// 这里额外包一层轻量级只读检查,以减少数据库调用。
//
// 实现:用 source_id 在 crystal_transaction_records 里查;如果没有该表就退化到 log 警告。
func isAlreadyProcessed(ctx context.Context, sourceID string) bool {
if sourceID == "" {
return false
}
// 优先查 crystal_transaction_records(source_id 上有索引)
var count int64
err := database.GetDB().Table("public.crystal_transaction_records").
Where("source_id = ? AND change_type = ?", sourceID, "exhibition_revenue").
Count(&count).Error
if err == nil {
return count > 0
}
// 退化:用 user_exhibition_hours 配合 source 文字搜索(简化措施,生产应换 schema)
// 暂时返回 false 让 handler 继续,依赖 AddExhibitionHours 内部幂等。
logger.Logger.Debug("isAlreadyProcessed fallback (table missing?)",
zap.String("source_id", sourceID),
zap.Error(err))
return false
}
// parseUserIDFromSourceID 辅助函数 — 从 source_id 字符串 ("exhibition_<id>") 解析。
// 本期范围内未使用,保留供后续扩展。
func parseUserIDFromSourceID(sourceID string) int64 {
parts := strings.SplitN(sourceID, "_", 2)
if len(parts) < 2 {
return 0
}
id, _ := strconv.ParseInt(parts[1], 10, 64)
return id
}