feat(asset): emit task:event on mint success (fix daily_mint 悬空)

assetService 接入 MQ producer:铸造成功时 emit `task:event { event_type: daily_mint }`,
由 taskService consumer 异步调 ProcessTaskEvent 完成判定。

- main.go: 初始化 MQ (Asynq producer-only),沿用 galleryService 模式;
  失败仅 Warn 不阻塞主路径
- service/mint_service.go: CreateMintOrder 成功分支 fire-and-forget emit
- mq/producer.go: 新增 EnqueueTaskEvent helper,封装 TaskEventPayload marshal +
  adapter.TaskProducer.Enqueue

配套:spec §4 / Phase F.1 / impl plan Phase D。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zerosaturation 2026-07-27 15:45:42 +08:00
parent 1e8a7476e0
commit a9ce281d5e
3 changed files with 115 additions and 1 deletions

View File

@ -21,6 +21,9 @@ import (
"github.com/topfans/backend/pkg/health"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/models"
"github.com/topfans/backend/pkg/mq"
asynqAdapter "github.com/topfans/backend/pkg/mq/asynq"
queueconsts "github.com/topfans/backend/pkg/queue/consts"
"github.com/topfans/backend/pkg/statistic"
pbAsset "github.com/topfans/backend/pkg/proto/asset"
pbCastlove "github.com/topfans/backend/pkg/proto/castlove"
@ -45,7 +48,12 @@ var (
dbPassword = flag.String("db-password", getEnv("DB_PASSWORD", ""), "Database password")
dbName = flag.String("db-name", getEnv("DB_NAME", "top-fans"), "Database name")
userServiceURL = flag.String("user-service-url", getEnv("USER_SERVICE_URL", "tri://localhost:20000"), "User service URL")
healthHandler *health.Handler
// MQ 配置assetService 是 producer-onlyemit task:event 给 taskService consumer修复 daily_mint 悬空)
mqRedisAddr = flag.String("mq-redis-addr", getEnv("MQ_REDIS_ADDR", "localhost:6379"), "MQ redis address")
mqRedisDB = flag.Int("mq-redis-db", getEnvInt("MQ_REDIS_DB", 2), "MQ redis db (avoid clashing with app cache db)")
mqRedisPassword = flag.String("mq-redis-password", getEnv("MQ_REDIS_PASSWORD", getEnv("REDIS_PASSWORD", "")), "MQ redis password")
mqConcurrency = flag.Int("mq-concurrency", getEnvInt("MQ_CONCURRENCY", 10), "Asynq worker concurrency (unused for producer-only)")
healthHandler *health.Handler
// ★ mint-task-3 review P1-1: 对账 worker 周期(默认 10 分钟;设为 0 = 关闭)
reconcileIntervalSec = flag.Int("reconcile-interval-sec", getEnvInt("MINT_RECONCILE_INTERVAL_SEC", 600), "Reconcile stuck mint orders interval (seconds); 0 disables")
reconcileStaleMin = flag.Int("reconcile-stale-minutes", getEnvInt("MINT_RECONCILE_STALE_MINUTES", 10), "Stale threshold for stuck PROCESSING orders (minutes)")
@ -67,6 +75,31 @@ func getEnvInt(key string, fallback int) int {
return fallback
}
// asynqAdapterConfig 提供 Asynq producer 配置。
// assetService 是 producer-only只 emit 不消费Queues 字段在 producer 路径不生效adapter 校验用)。
func asynqAdapterConfig() asynqAdapter.Config {
return asynqAdapter.Config{
RedisAddr: *mqRedisAddr,
RedisDB: *mqRedisDB,
Password: *mqRedisPassword,
Concurrency: *mqConcurrency,
Queues: map[string]int{queueconsts.QueueDefault: 1},
}
}
// asynqAdapterStreamsConfig Redis Streams 配置(同 galleryService 模式)。
// assetService 当前不订阅 stream但 mq.Init 需要 Streams 字段非 nil否则 fallback 默认。
func asynqAdapterStreamsConfig() mq.StreamsConfig {
return mq.StreamsConfig{
RedisAddr: *mqRedisAddr,
RedisDB: *mqRedisDB,
Password: *mqRedisPassword,
ConsumerGroup: "topfans-service",
MaxLen: 100000,
ReadBlockMS: 1000,
}
}
func main() {
// 加载 .env 文件中的环境变量
godotenv.Load()
@ -156,6 +189,19 @@ func main() {
logger.Logger.Info("Statistic SDK initialized")
}
// 初始化 MQ (Asynq producer-only) — 修复 daily_mint 悬空
// 失败仅 Warn 日志不阻塞主路径(与 statistic / dubbo 客户端一致)
if err := mq.Init(mq.Config{
MQDriver: mq.DriverRedis,
Asynq: asynqAdapterConfig(),
Streams: asynqAdapterStreamsConfig(),
}); err != nil {
logger.Logger.Warn(fmt.Sprintf("MQ init failed (task events disabled, RPC endpoints unaffected): %v", err))
} else {
defer func() { _ = mq.Close() }()
logger.Logger.Info("MQ initialized (producer-only for task:event)")
}
// 创建 Provider 层实例(用于获取 AssetLevelService
assetLevelProvider := provider.NewAssetLevelProvider(database.GetDB())
assetLevelSvc := assetLevelProvider.GetLevelService()

View File

@ -0,0 +1,56 @@
// Package mq 是 assetService 的消息队列适配入口producer-only
//
// 业务侧调用示例 (mint_service.CreateMintOrder 成功分支):
//
// assetmq.EnqueueTaskEvent(ctx, userID, starID, tasks.EventDailyMint)
//
// 全部走 pkg/mq/adapter不直接 import asynq 或 redis-streams 包。
// assetService 是 producer-only不注册 handler / 不起 consumer ——
// task:event 消息由 taskService 的 mq/consumer.go 异步消费处理。
package mq
import (
"context"
"fmt"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/mq/adapter"
"github.com/topfans/backend/pkg/mq/tasks"
queueconsts "github.com/topfans/backend/pkg/queue/consts"
"go.uber.org/zap"
)
// EnqueueTaskEvent 业务事件 emitspec §4 / Phase F.1)。
//
// 修复 daily_mint 悬空:铸造成功时 emit `task:event { event_type: daily_mint }`
// 由 taskService 的 MQ consumer 异步调 DailyTaskService.ProcessTaskEvent 完成判定。
// 失败仅 Warn 日志不阻塞主路径fire-and-forget与现有 EnqueueExhibitionExpire 一致)。
func EnqueueTaskEvent(ctx context.Context, userID, starID int64, eventType string) error {
payload, err := tasks.MarshalToPayload(tasks.TaskEventPayload{
UserID: userID,
StarID: starID,
EventType: eventType,
})
if err != nil {
return fmt.Errorf("asset mq: marshal task event payload: %w", err)
}
task := adapter.Task{
Queue: queueconsts.QueueDefault, // taskService consumer 注册到 default 队列
Type: tasks.TypeTaskEvent,
Payload: payload,
MaxRetry: 3,
}
if _, err := adapter.Get().TaskProducer().Enqueue(ctx, task); err != nil {
logger.Logger.Warn("enqueue task:event failed",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.String("event_type", eventType),
zap.Error(err))
return err
}
logger.Logger.Info("task:event enqueued",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.String("event_type", eventType))
return nil
}

View File

@ -25,8 +25,10 @@ import (
"github.com/topfans/backend/pkg/validator"
"github.com/topfans/backend/services/assetService/client"
"github.com/topfans/backend/services/assetService/config"
assetmq "github.com/topfans/backend/services/assetService/mq"
"github.com/topfans/backend/services/assetService/repository"
"github.com/topfans/backend/services/assetService/util"
"github.com/topfans/backend/pkg/mq/tasks"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@ -601,6 +603,16 @@ func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, st
},
})
// 业务事件:触发 daily_mint 每日任务(修复 daily_mint 悬空)
// fire-and-forget失败仅 Warn 日志不阻塞主路径spec §6 / Phase F.1
if err := assetmq.EnqueueTaskEvent(context.Background(), userID, starID, tasks.EventDailyMint); err != nil {
logger.Logger.Warn("emit task:event daily_mint failed",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.String("order_id", mintOrder.OrderID),
zap.Error(err))
}
return response, nil
}