package app import ( "context" "encoding/json" "errors" "log/slog" "net" "sync" "time" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" healthgrpc "google.golang.org/grpc/health/grpc_health_v1" activityv1 "hyapp.local/api/proto/activity/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/grpchealth" "hyapp/pkg/grpcshutdown" "hyapp/pkg/healthhttp" "hyapp/pkg/logx" "hyapp/pkg/rocketmqx" "hyapp/pkg/roommq" "hyapp/pkg/tencentim" "hyapp/pkg/walletmq" "hyapp/services/activity-service/internal/client" "hyapp/services/activity-service/internal/config" broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast" firstrechargedomain "hyapp/services/activity-service/internal/domain/firstrecharge" achievementservice "hyapp/services/activity-service/internal/service/achievement" activityservice "hyapp/services/activity-service/internal/service/activity" broadcastservice "hyapp/services/activity-service/internal/service/broadcast" firstrechargeservice "hyapp/services/activity-service/internal/service/firstrecharge" growthservice "hyapp/services/activity-service/internal/service/growth" luckygiftservice "hyapp/services/activity-service/internal/service/luckygift" messageservice "hyapp/services/activity-service/internal/service/message" registrationrewardservice "hyapp/services/activity-service/internal/service/registrationreward" sevendaycheckinservice "hyapp/services/activity-service/internal/service/sevendaycheckin" taskservice "hyapp/services/activity-service/internal/service/task" mysqlstorage "hyapp/services/activity-service/internal/storage/mysql" grpcserver "hyapp/services/activity-service/internal/transport/grpc" ) // App 装配 activity-service gRPC 入口和底座依赖。 type App struct { server *grpc.Server listener net.Listener health *grpchealth.ServingChecker healthHTTP *healthhttp.Server mysqlRepo *mysqlstorage.Repository broadcast *broadcastservice.Service firstRechargeReward *firstrechargeservice.Service broadcastWorkerEnabled bool workerNodeID string mqConsumers []*rocketmqx.Consumer userConn *grpc.ClientConn walletConn *grpc.ClientConn workerCtx context.Context workerStop context.CancelFunc workerWG sync.WaitGroup closeOnce sync.Once } // New 初始化 activity-service 应用。 func New(cfg config.Config) (*App, error) { startupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // activity-service 的活动状态、消费幂等和 outbox 都必须使用 MySQL。 repository, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN) if err != nil { return nil, err } if cfg.MySQLAutoMigrate { if err := repository.Migrate(startupCtx); err != nil { _ = repository.Close() return nil, err } } listener, err := net.Listen("tcp", cfg.GRPCAddr) if err != nil { _ = repository.Close() return nil, err } userConn, err := grpc.Dial(cfg.UserServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { _ = listener.Close() _ = repository.Close() return nil, err } walletConn, err := grpc.Dial(cfg.WalletServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { _ = userConn.Close() _ = listener.Close() _ = repository.Close() return nil, err } server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("activity-service"))) svc := activityservice.New(activityservice.Config{NodeID: cfg.NodeID}, repository) messageSvc := messageservice.New(messageservice.Config{NodeID: cfg.NodeID}, repository, messageservice.WithTargetSource(client.NewGRPCUserTargetSource(userConn))) taskSvc := taskservice.New(repository, walletv1.NewWalletServiceClient(walletConn)) registrationRewardSvc := registrationrewardservice.New(repository, walletv1.NewWalletServiceClient(walletConn)) sevenDayCheckInSvc := sevendaycheckinservice.New(repository, walletv1.NewWalletServiceClient(walletConn)) growthSvc := growthservice.New(repository, walletv1.NewWalletServiceClient(walletConn)) achievementSvc := achievementservice.New(repository, walletv1.NewWalletServiceClient(walletConn)) luckyGiftSvc := luckygiftservice.New(repository) var broadcastPublisher broadcastservice.Publisher if cfg.TencentIM.Enabled { tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig()) if err != nil { _ = walletConn.Close() _ = userConn.Close() _ = listener.Close() _ = repository.Close() return nil, err } broadcastPublisher = tencentClient } if cfg.Broadcast.Enabled && broadcastPublisher == nil { _ = walletConn.Close() _ = userConn.Close() _ = listener.Close() _ = repository.Close() return nil, errors.New("broadcast worker requires tencent_im.enabled") } broadcastSvc := broadcastservice.New(broadcastservice.Config{ NodeID: cfg.NodeID, SuperGiftMinValue: cfg.Broadcast.SuperGiftMinValue, WorkerBatchSize: cfg.Broadcast.WorkerBatchSize, WorkerLockTTL: cfg.Broadcast.WorkerLockTTL, WorkerMaxRetry: cfg.Broadcast.WorkerMaxRetry, WorkerPollInterval: cfg.Broadcast.WorkerPollInterval, EnsureGroupsOnStartup: cfg.Broadcast.EnsureGroupsOnStartup, }, repository, broadcastPublisher, client.NewGRPCRegionSource(userConn)) activityv1.RegisterActivityServiceServer(server, grpcserver.NewServer(svc)) activityv1.RegisterMessageInboxServiceServer(server, grpcserver.NewMessageServer(messageSvc)) activityv1.RegisterActivityCronServiceServer(server, grpcserver.NewCronServer(messageSvc, growthSvc, achievementSvc)) activityv1.RegisterTaskServiceServer(server, grpcserver.NewTaskServer(taskSvc)) activityv1.RegisterAdminTaskServiceServer(server, grpcserver.NewAdminTaskServer(taskSvc)) activityv1.RegisterGrowthLevelServiceServer(server, grpcserver.NewGrowthLevelServer(growthSvc)) activityv1.RegisterAdminGrowthLevelServiceServer(server, grpcserver.NewAdminGrowthLevelServer(growthSvc)) activityv1.RegisterAchievementServiceServer(server, grpcserver.NewAchievementServer(achievementSvc)) activityv1.RegisterAdminAchievementServiceServer(server, grpcserver.NewAdminAchievementServer(achievementSvc)) activityv1.RegisterLuckyGiftServiceServer(server, grpcserver.NewLuckyGiftServer(luckyGiftSvc)) activityv1.RegisterAdminLuckyGiftServiceServer(server, grpcserver.NewAdminLuckyGiftServer(luckyGiftSvc)) activityv1.RegisterRegistrationRewardServiceServer(server, grpcserver.NewRegistrationRewardServer(registrationRewardSvc)) activityv1.RegisterAdminRegistrationRewardServiceServer(server, grpcserver.NewAdminRegistrationRewardServer(registrationRewardSvc)) activityv1.RegisterSevenDayCheckInServiceServer(server, grpcserver.NewSevenDayCheckInServer(sevenDayCheckInSvc)) activityv1.RegisterAdminSevenDayCheckInServiceServer(server, grpcserver.NewAdminSevenDayCheckInServer(sevenDayCheckInSvc)) broadcastServer := grpcserver.NewBroadcastServer(broadcastSvc, growthSvc) activityv1.RegisterBroadcastServiceServer(server, broadcastServer) activityv1.RegisterRoomEventConsumerServiceServer(server, broadcastServer) firstRechargeRewardSvc := firstrechargeservice.New(repository, walletv1.NewWalletServiceClient(walletConn)) activityv1.RegisterFirstRechargeRewardServiceServer(server, grpcserver.NewFirstRechargeRewardServer(firstRechargeRewardSvc)) activityv1.RegisterAdminFirstRechargeRewardServiceServer(server, grpcserver.NewAdminFirstRechargeRewardServer(firstRechargeRewardSvc)) mqConsumers := make([]*rocketmqx.Consumer, 0, 3) if cfg.RocketMQ.RoomOutbox.Enabled { consumer, err := rocketmqx.NewConsumer(roomOutboxConsumerConfig(cfg.RocketMQ)) if err != nil { _ = walletConn.Close() _ = userConn.Close() _ = listener.Close() _ = repository.Close() 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 } eventCtx := appcode.WithContext(ctx, envelope.GetAppCode()) if _, err := broadcastSvc.HandleRoomEvent(eventCtx, envelope); err != nil { return err } _, err = growthSvc.HandleRoomEvent(eventCtx, envelope) return err }); err != nil { _ = walletConn.Close() _ = userConn.Close() _ = listener.Close() _ = repository.Close() return nil, err } mqConsumers = append(mqConsumers, consumer) } if cfg.FirstRechargeRewardWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled { consumer, err := rocketmqx.NewConsumer(walletOutboxConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.FirstRechargeConsumerGroup)) if err != nil { shutdownConsumers(mqConsumers) _ = walletConn.Close() _ = userConn.Close() _ = listener.Close() _ = repository.Close() 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 firstRechargeRewardSvc.ConsumeWalletRechargeEvent(appcode.WithContext(ctx, event.AppCode), event) }); err != nil { _ = consumer.Shutdown() shutdownConsumers(mqConsumers) _ = walletConn.Close() _ = userConn.Close() _ = listener.Close() _ = repository.Close() return nil, err } mqConsumers = append(mqConsumers, consumer) } if cfg.RedPacketBroadcastWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled { consumer, err := rocketmqx.NewConsumer(walletOutboxConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.RedPacketBroadcastConsumerGroup)) if err != nil { shutdownConsumers(mqConsumers) _ = walletConn.Close() _ = userConn.Close() _ = listener.Close() _ = repository.Close() return nil, err } if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { event, ok, err := redPacketEventFromWalletMessage(message.Body) if err != nil || !ok { return err } return broadcastSvc.ConsumeRedPacketCreatedEvent(appcode.WithContext(ctx, event.AppCode), event) }); err != nil { _ = consumer.Shutdown() shutdownConsumers(mqConsumers) _ = walletConn.Close() _ = userConn.Close() _ = listener.Close() _ = repository.Close() return nil, err } mqConsumers = append(mqConsumers, consumer) } healthDependencies := []grpchealth.Dependency{{ Name: "mysql", Check: repository.Ping, }} health := grpchealth.NewServingChecker("activity-service", healthDependencies...) healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health)) healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health) if err != nil { shutdownConsumers(mqConsumers) _ = walletConn.Close() _ = userConn.Close() _ = listener.Close() _ = repository.Close() return nil, err } workerCtx, workerStop := context.WithCancel(context.Background()) return &App{ server: server, listener: listener, health: health, healthHTTP: healthHTTP, mysqlRepo: repository, broadcast: broadcastSvc, firstRechargeReward: firstRechargeRewardSvc, broadcastWorkerEnabled: cfg.Broadcast.Enabled, workerNodeID: cfg.NodeID, mqConsumers: mqConsumers, userConn: userConn, walletConn: walletConn, workerCtx: workerCtx, workerStop: workerStop, }, nil } // Run 启动 gRPC 服务。 func (a *App) Run() error { a.runHealthHTTP() a.health.MarkServing() if err := a.startMQ(); err != nil { a.health.MarkStopped() return err } if a.broadcastWorkerEnabled && a.broadcast != nil && a.workerCtx != nil { a.workerWG.Go(func() { a.broadcast.RunWorker(a.workerCtx, broadcastservice.WorkerOptions{}) }) } err := a.server.Serve(a.listener) if a.workerStop != nil { a.workerStop() } a.workerWG.Wait() a.shutdownMQ() a.health.MarkStopped() if errors.Is(err, grpc.ErrServerStopped) { return nil } return err } // Close 关闭 gRPC 服务。 func (a *App) Close() { a.closeOnce.Do(func() { a.health.MarkDraining() if a.workerStop != nil { a.workerStop() } a.workerWG.Wait() a.shutdownMQ() grpcshutdown.GracefulStop(a.server, 15*time.Second) a.closeHealthHTTP() if a.userConn != nil { _ = a.userConn.Close() } if a.walletConn != nil { _ = a.walletConn.Close() } if a.mysqlRepo != nil { // MySQL 连接池最后关闭,保证 drain 中的查询或消费提交能完成。 _ = a.mysqlRepo.Close() } }) } func (a *App) runHealthHTTP() { if a.healthHTTP == nil { return } go func() { if err := a.healthHTTP.Run(); err != nil { logx.Error(context.Background(), "health_http_run_failed", err) } }() } func (a *App) closeHealthHTTP() { if a.healthHTTP == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = a.healthHTTP.Close(ctx) } func (a *App) startMQ() error { for _, consumer := range a.mqConsumers { if err := consumer.Start(); err != nil { a.shutdownMQ() return err } } return nil } func (a *App) shutdownMQ() { for _, consumer := range a.mqConsumers { if err := consumer.Shutdown(); err != nil { logx.Warn(context.Background(), "rocketmq_consumer_shutdown_failed", slog.String("error", err.Error())) } } } func roomOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig { return rocketMQConsumerConfig(cfg, cfg.RoomOutbox.ConsumerGroup, cfg.RoomOutbox.ConsumerMaxReconsumeTimes) } func walletOutboxConsumerConfig(cfg config.RocketMQConfig, group string) rocketmqx.ConsumerConfig { return rocketMQConsumerConfig(cfg, group, cfg.WalletOutbox.ConsumerMaxReconsumeTimes) } 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) { for _, consumer := range consumers { _ = consumer.Shutdown() } } func firstRechargeEventFromWalletMessage(body []byte) (firstrechargedomain.RechargeEvent, bool, error) { message, err := walletmq.DecodeWalletOutboxMessage(body) if err != nil { return firstrechargedomain.RechargeEvent{}, false, err } if message.EventType != firstrechargedomain.RechargeEventWalletRecorded { return firstrechargedomain.RechargeEvent{}, false, nil } sequence, rechargeType := rechargeFactFromWalletPayload(message.PayloadJSON) return firstrechargedomain.RechargeEvent{ AppCode: appcode.Normalize(message.AppCode), EventID: message.EventID, EventType: message.EventType, TransactionID: message.TransactionID, CommandID: message.CommandID, UserID: message.UserID, RechargeCoinAmount: message.AvailableDelta, RechargeSequence: sequence, RechargeType: rechargeType, PayloadJSON: message.PayloadJSON, OccurredAtMS: message.OccurredAtMS, }, true, nil } func redPacketEventFromWalletMessage(body []byte) (broadcastdomain.RedPacketCreatedEvent, bool, error) { message, err := walletmq.DecodeWalletOutboxMessage(body) if err != nil { return broadcastdomain.RedPacketCreatedEvent{}, false, err } if message.EventType != "WalletRedPacketCreated" { return broadcastdomain.RedPacketCreatedEvent{}, false, nil } regionID := redPacketRegionIDFromWalletPayload(message.PayloadJSON) if regionID <= 0 { return broadcastdomain.RedPacketCreatedEvent{}, false, nil } return broadcastdomain.RedPacketCreatedEvent{ AppCode: appcode.Normalize(message.AppCode), EventID: message.EventID, TransactionID: message.TransactionID, CommandID: message.CommandID, PayloadJSON: message.PayloadJSON, RegionID: regionID, CreatedAtMS: message.OccurredAtMS, }, true, nil } func rechargeFactFromWalletPayload(payload string) (int64, string) { var decoded map[string]any if err := json.Unmarshal([]byte(payload), &decoded); err != nil { return 0, firstrechargedomain.RechargeTypeCoinSeller } var sequence int64 switch value := decoded["recharge_sequence"].(type) { case float64: sequence = int64(value) case int64: sequence = value case json.Number: sequence, _ = value.Int64() } rechargeType := firstrechargedomain.RechargeTypeCoinSeller if value, ok := decoded["recharge_type"].(string); ok && value != "" { rechargeType = value } return sequence, rechargeType } func redPacketRegionIDFromWalletPayload(payload string) int64 { var decoded map[string]any if err := json.Unmarshal([]byte(payload), &decoded); err != nil { return 0 } switch value := decoded["region_id"].(type) { case float64: return int64(value) case int64: return value case json.Number: regionID, _ := value.Int64() return regionID default: return 0 } }