package app import ( "context" "encoding/json" "errors" "fmt" "time" "hyapp/pkg/appcode" "hyapp/pkg/rocketmqx" "hyapp/pkg/roommq" servicemq "hyapp/pkg/servicekit/mq" "hyapp/pkg/usermq" "hyapp/pkg/walletmq" "hyapp/services/activity-service/internal/config" broadcastservice "hyapp/services/activity-service/internal/service/broadcast" ) func buildMQConsumers(cfg config.Config, services *serviceBundle) ([]*rocketmqx.Consumer, error) { mqConsumers := make([]*rocketmqx.Consumer, 0, 4) if cfg.RocketMQ.RoomOutbox.Enabled { consumer, err := newRoomOutboxConsumer(cfg, services) if err != nil { shutdownConsumers(mqConsumers) return nil, err } mqConsumers = append(mqConsumers, consumer) } if cfg.RocketMQ.UserOutbox.Enabled && !services.broadcastPublisherAvailable { if !cfg.TaskEventWorker.Enabled { shutdownConsumers(mqConsumers) return nil, errors.New("rocketmq user_outbox consumer requires tencent_im.enabled") } } if cfg.RocketMQ.UserOutbox.Enabled && services.broadcastPublisherAvailable { consumer, err := newUserOutboxConsumer(cfg, services.broadcast) if err != nil { shutdownConsumers(mqConsumers) return nil, err } mqConsumers = append(mqConsumers, consumer) } if cfg.TaskEventWorker.Enabled && cfg.RocketMQ.UserOutbox.Enabled { consumer, err := newTaskUserOutboxConsumer(cfg, services) if err != nil { shutdownConsumers(mqConsumers) return nil, err } mqConsumers = append(mqConsumers, consumer) } if cfg.FirstRechargeRewardWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled { consumer, err := newFirstRechargeWalletConsumer(cfg, services) if err != nil { shutdownConsumers(mqConsumers) return nil, err } mqConsumers = append(mqConsumers, consumer) } if cfg.CumulativeRechargeRewardWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled { consumer, err := newCumulativeRechargeWalletConsumer(cfg, services) if err != nil { shutdownConsumers(mqConsumers) return nil, err } mqConsumers = append(mqConsumers, consumer) } if cfg.InviteActivityRewardWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled { consumer, err := newInviteActivityWalletConsumer(cfg, services) if err != nil { shutdownConsumers(mqConsumers) return nil, err } mqConsumers = append(mqConsumers, consumer) } if cfg.InviteActivityRewardWorker.Enabled && cfg.RocketMQ.UserOutbox.Enabled { consumer, err := newInviteActivityUserOutboxConsumer(cfg, services) if err != nil { shutdownConsumers(mqConsumers) return nil, err } mqConsumers = append(mqConsumers, consumer) } if cfg.MessageActionConfirmWorker.Enabled && cfg.RocketMQ.UserOutbox.Enabled { consumer, err := newMessageActionUserOutboxConsumer(cfg, services) if err != nil { shutdownConsumers(mqConsumers) return nil, err } mqConsumers = append(mqConsumers, consumer) } if cfg.RedPacketBroadcastWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled { consumer, err := newRedPacketWalletConsumer(cfg, services) if err != nil { shutdownConsumers(mqConsumers) return nil, err } mqConsumers = append(mqConsumers, consumer) } if cfg.UserLeaderboardWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled { consumer, err := newUserLeaderboardWalletConsumer(cfg, services) if err != nil { shutdownConsumers(mqConsumers) return nil, err } mqConsumers = append(mqConsumers, consumer) } if cfg.TaskEventWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled { consumer, err := newTaskWalletConsumer(cfg, services) if err != nil { shutdownConsumers(mqConsumers) return nil, err } mqConsumers = append(mqConsumers, consumer) } return mqConsumers, nil } func newRoomOutboxConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) { consumer, err := rocketmqx.NewConsumer(roomOutboxConsumerConfig(cfg.RocketMQ)) if err != nil { return nil, err } if err := consumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { envelope, _, err := roommq.DecodeRoomOutboxMessage(message.Body) if err != nil { return err } // 一个 room outbox envelope 代表房间状态已经提交;下游模块各自写幂等消费表。 // 任何模块失败都返回错误给 MQ,让同一个 envelope 重试,避免“播报成功但活动漏计分”的半完成状态。 eventCtx := appcode.WithContext(ctx, envelope.GetAppCode()) if _, err := services.broadcast.HandleRoomEvent(eventCtx, envelope); err != nil { return fmt.Errorf("room outbox broadcast projection: %w", err) } if _, err := services.growth.HandleRoomEvent(eventCtx, envelope); err != nil { return fmt.Errorf("room outbox growth projection: %w", err) } if _, err := services.weeklyStar.HandleRoomEvent(eventCtx, envelope); err != nil { return fmt.Errorf("room outbox weekly star projection: %w", err) } if _, err := services.agencyOpening.HandleRoomEvent(eventCtx, envelope); err != nil { return fmt.Errorf("room outbox agency opening projection: %w", err) } if _, err := services.task.HandleRoomEvent(eventCtx, envelope); err != nil { return fmt.Errorf("room outbox task projection: %w", err) } if _, err := services.roomTurnoverReward.HandleRoomEvent(eventCtx, envelope); err != nil { return fmt.Errorf("room outbox turnover reward projection: %w", err) } return nil }); err != nil { _ = consumer.Shutdown() return nil, err } return consumer, nil } func newUserOutboxConsumer(cfg config.Config, broadcastSvc *broadcastservice.Service) (*rocketmqx.Consumer, error) { consumer, err := rocketmqx.NewConsumer(userOutboxConsumerConfig(cfg.RocketMQ)) if err != nil { return nil, err } if err := consumer.Subscribe(cfg.RocketMQ.UserOutbox.Topic, usermq.TagUserOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { outboxMessage, err := usermq.DecodeUserOutboxMessage(message.Body) if err != nil { return err } return consumeUserRegionChangedForBroadcast(ctx, broadcastSvc, outboxMessage) }); err != nil { _ = consumer.Shutdown() return nil, err } return consumer, nil } func newFirstRechargeWalletConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) { consumer, err := rocketmqx.NewConsumer(walletOutboxConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.FirstRechargeConsumerGroup)) if err != nil { return nil, err } if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { event, ok, err := firstRechargeEventFromWalletMessage(message.Body) if err != nil || !ok { return err } return services.firstRechargeReward.ConsumeWalletRechargeEvent(appcode.WithContext(ctx, event.AppCode), event) }); err != nil { _ = consumer.Shutdown() return nil, err } return consumer, nil } func newCumulativeRechargeWalletConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) { // 累充奖励使用独立 consumer group,避免和首充奖励共享消费位点后互相影响重放和排查。 consumer, err := rocketmqx.NewConsumer(walletOutboxConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.CumulativeRechargeConsumerGroup)) if err != nil { return nil, err } if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { event, ok, err := cumulativeRechargeEventFromWalletMessage(message.Body) if err != nil || !ok { return err } return services.cumulativeRecharge.ConsumeWalletRechargeEvent(appcode.WithContext(ctx, event.AppCode), event) }); err != nil { _ = consumer.Shutdown() return nil, err } return consumer, nil } func newInviteActivityWalletConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) { // 邀请活动单独使用 wallet_outbox consumer group;重放累计充值时不影响首充、累充和任务的消费进度。 consumer, err := rocketmqx.NewConsumer(walletOutboxConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.InviteActivityConsumerGroup)) if err != nil { return nil, err } if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { event, ok, err := inviteActivityRechargeEventFromWalletMessage(message.Body) if err != nil || !ok { return err } return services.inviteActivityReward.ConsumeWalletRechargeEvent(appcode.WithContext(ctx, event.AppCode), event) }); err != nil { _ = consumer.Shutdown() return nil, err } return consumer, nil } func newInviteActivityUserOutboxConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) { // 邀请绑定和有效邀请都来自 user-service 已提交事实;消费失败必须让同一 outbox 重试,仓储和钱包命令共同保证幂等。 consumer, err := rocketmqx.NewConsumer(userOutboxInviteActivityConsumerConfig(cfg.RocketMQ)) if err != nil { return nil, err } if err := consumer.Subscribe(cfg.RocketMQ.UserOutbox.Topic, usermq.TagUserOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { inviteEvent, inviteOK, err := inviteActivityInviteEventFromUserMessage(message.Body) if err != nil { return err } if inviteOK { return services.inviteActivityReward.ConsumeInviteEvent(appcode.WithContext(ctx, inviteEvent.AppCode), inviteEvent) } event, ok, err := inviteActivityValidInviteEventFromUserMessage(message.Body) if err != nil || !ok { return err } return services.inviteActivityReward.ConsumeValidInviteEvent(appcode.WithContext(ctx, event.AppCode), event) }); err != nil { _ = consumer.Shutdown() return nil, err } return consumer, nil } func newRedPacketWalletConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) { consumer, err := rocketmqx.NewConsumer(walletOutboxConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.RedPacketBroadcastConsumerGroup)) if err != nil { return nil, err } if err := consumer.Subscribe(redPacketWalletOutboxTopic(cfg.RocketMQ.WalletOutbox), walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { event, ok, err := redPacketEventFromWalletMessage(message.Body) if err != nil || !ok { return err } return services.broadcast.ConsumeRedPacketWalletEvent(appcode.WithContext(ctx, event.AppCode), event) }); err != nil { _ = consumer.Shutdown() return nil, err } return consumer, nil } func newUserLeaderboardWalletConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) { if services == nil || services.userLeaderboard == nil { return nil, errors.New("user leaderboard worker requires redis store") } // 用户送礼榜使用独立 consumer group;重放榜单读模型不能推进首充、任务、红包等业务位点。 consumer, err := rocketmqx.NewConsumer(walletOutboxUserLeaderboardConsumerConfig(cfg.RocketMQ)) if err != nil { return nil, err } if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { event, ok, err := userLeaderboardGiftEventFromWalletMessage(message.Body) if err != nil || !ok { return err } // Redis 聚合自身用 wallet event_id 做幂等;MQ 重投只会重新确认同一事实,不会重复加分。 _, err = services.userLeaderboard.ApplyGiftEvent(appcode.WithContext(ctx, event.AppCode), event) return err }); err != nil { _ = consumer.Shutdown() return nil, err } return consumer, nil } func newTaskWalletConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) { consumer, err := rocketmqx.NewConsumer(walletOutboxConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.TaskConsumerGroup)) if err != nil { return nil, err } if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { // 钱包任务消费者使用独立 consumer group,不能复用红包或其他钱包消费者进度;否则充值任务会被其他业务确认掉。 event, appCode, ok, err := taskEventFromWalletMessage(message.Body) if err != nil || !ok { return err } // ConsumeTaskEvent 内部按 event_id 幂等;这里返回错误给 MQ 才会重试,正常 skipped/duplicate 都视为成功确认。 _, err = services.task.ConsumeTaskEvent(appcode.WithContext(ctx, appCode), event) return err }); err != nil { _ = consumer.Shutdown() return nil, err } return consumer, nil } func newTaskUserOutboxConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) { consumer, err := rocketmqx.NewConsumer(userOutboxTaskConsumerConfig(cfg.RocketMQ)) if err != nil { return nil, err } if err := consumer.Subscribe(cfg.RocketMQ.UserOutbox.Topic, usermq.TagUserOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { // user outbox 一条消息可能投影成多条任务事件,例如 CP 双方各一条;只有全部写入成功才确认 MQ。 events, appCode, err := taskEventsFromUserMessage(message.Body) if err != nil || len(events) == 0 { return err } eventCtx := appcode.WithContext(ctx, appCode) for _, event := range events { // 单条失败时让整条 outbox 重试,已写入成功的事件靠 task_event_consumption 幂等表跳过。 if _, err := services.task.ConsumeTaskEvent(eventCtx, event); err != nil { return err } } return nil }); err != nil { _ = consumer.Shutdown() return nil, err } return consumer, nil } func roomOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig { consumerConfig := rocketMQConsumerConfig(cfg, cfg.RoomOutbox.ConsumerGroup, cfg.RoomOutbox.ConsumerMaxReconsumeTimes) // 一条房间事实会串行投影到多组 MySQL 幂等表和聚合表。SDK 默认开 20 个消费协程, // 积压恢复时同一用户的多条送礼事实会并发争抢任务进度和活动聚合锁,把可恢复积压放大成死锁重试风暴。 // 单实例固定单协程;生产的同 consumer group 仍由多实例分担 queue,不改变消费位点和重试语义。 consumerConfig.ConsumeGoroutines = 1 return consumerConfig } func userOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig { return rocketMQConsumerConfig(cfg, cfg.UserOutbox.ConsumerGroup, cfg.UserOutbox.ConsumerMaxReconsumeTimes) } func userOutboxTaskConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig { return rocketMQConsumerConfig(cfg, cfg.UserOutbox.TaskConsumerGroup, cfg.UserOutbox.ConsumerMaxReconsumeTimes) } func userOutboxInviteActivityConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig { return rocketMQConsumerConfig(cfg, cfg.UserOutbox.InviteActivityConsumerGroup, cfg.UserOutbox.ConsumerMaxReconsumeTimes) } func userOutboxMessageActionConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig { return rocketMQConsumerConfig(cfg, cfg.UserOutbox.MessageActionConsumerGroup, cfg.UserOutbox.ConsumerMaxReconsumeTimes) } func walletOutboxConsumerConfig(cfg config.RocketMQConfig, group string) rocketmqx.ConsumerConfig { return rocketMQConsumerConfig(cfg, group, cfg.WalletOutbox.ConsumerMaxReconsumeTimes) } func walletOutboxUserLeaderboardConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig { consumerConfig := walletOutboxConsumerConfig(cfg, cfg.WalletOutbox.UserLeaderboardConsumerGroup) // 用户榜是 Redis 可重建读模型;新 consumer group 首次上线需要尽量从 broker 保留的最早消息补齐当前周期。 consumerConfig.ConsumeFromFirst = true return consumerConfig } func messageActionProducerConfig(cfg config.RocketMQConfig) rocketmqx.ProducerConfig { return rocketmqx.ProducerConfig{ EndpointConfig: rocketmqx.EndpointConfig{ NameServers: cfg.NameServers, NameServerDomain: cfg.NameServerDomain, AccessKey: cfg.AccessKey, SecretKey: cfg.SecretKey, SecurityToken: cfg.SecurityToken, Namespace: cfg.Namespace, VIPChannel: cfg.VIPChannel, }, GroupName: cfg.MessageActionOutbox.ProducerGroup, SendTimeout: cfg.MessageActionOutbox.SendTimeout, Retry: cfg.MessageActionOutbox.Retry, } } func redPacketWalletOutboxTopic(cfg config.WalletOutboxMQConfig) string { // 钱包实时 topic 显式配置后,红包 worker 只消费实时通道,避免继续被普通账务 topic 的高频消息拖慢。 if cfg.RealtimeTopic != "" { return cfg.RealtimeTopic } return cfg.Topic } func rocketMQConsumerConfig(cfg config.RocketMQConfig, group string, maxReconsume int32) rocketmqx.ConsumerConfig { return rocketmqx.ConsumerConfig{ EndpointConfig: rocketmqx.EndpointConfig{ NameServers: cfg.NameServers, NameServerDomain: cfg.NameServerDomain, AccessKey: cfg.AccessKey, SecretKey: cfg.SecretKey, SecurityToken: cfg.SecurityToken, Namespace: cfg.Namespace, VIPChannel: cfg.VIPChannel, }, GroupName: group, MaxReconsumeTimes: maxReconsume, ConsumeRetryDelay: time.Second, ConsumePullBatch: 32, ConsumePullTimeout: 15 * time.Minute, } } func shutdownConsumers(consumers []*rocketmqx.Consumer) { servicemq.ShutdownConsumers(consumers) } type userRegionChangedPayload struct { UserID int64 `json:"user_id"` OldRegionID int64 `json:"old_region_id"` NewRegionID int64 `json:"new_region_id"` } func consumeUserRegionChangedForBroadcast(ctx context.Context, service *broadcastservice.Service, message usermq.UserOutboxMessage) error { if message.EventType != "UserRegionChanged" { return nil } var payload userRegionChangedPayload if err := json.Unmarshal([]byte(message.PayloadJSON), &payload); err != nil { return err } if payload.UserID <= 0 || payload.OldRegionID <= 0 || payload.OldRegionID == payload.NewRegionID { return nil } // 区域广播群成员关系是当前读模型;用户切区后只移除旧群,下一次 UserSig 会按新 users.region_id 返回新群。 _, _, err := service.RemoveRegionBroadcastMember(appcode.WithContext(ctx, message.AppCode), payload.UserID, payload.OldRegionID) return err }