package app import ( "context" "errors" "log/slog" "net" "sync" "time" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" gamev1 "hyapp.local/api/proto/game/v1" "hyapp/pkg/gamemq" "hyapp/pkg/grpchealth" "hyapp/pkg/healthhttp" "hyapp/pkg/logx" "hyapp/pkg/rocketmqx" servicegrpc "hyapp/pkg/servicekit/grpcserver" servicehealth "hyapp/pkg/servicekit/health" servicemq "hyapp/pkg/servicekit/mq" "hyapp/services/game-service/internal/client" "hyapp/services/game-service/internal/config" diceservice "hyapp/services/game-service/internal/service/dice" gameservice "hyapp/services/game-service/internal/service/game" roomrpsservice "hyapp/services/game-service/internal/service/roomrps" mysqlstorage "hyapp/services/game-service/internal/storage/mysql" grpcserver "hyapp/services/game-service/internal/transport/grpc" ) // App 装配 game-service gRPC 入口和持久化依赖。 type App struct { server *grpc.Server listener net.Listener health *grpchealth.ServingChecker healthHTTP *healthhttp.Server repo *mysqlstorage.Repository walletConn *grpc.ClientConn userConn *grpc.ClientConn activityConn *grpc.ClientConn robotConn *grpc.ClientConn outboxProducer *rocketmqx.Producer outboxCtx context.Context outboxCancel context.CancelFunc outboxWG sync.WaitGroup cfg config.Config closeOnce sync.Once } func New(cfg config.Config) (*App, error) { startupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() repo, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN) if err != nil { return nil, err } walletConn, err := grpc.Dial(cfg.WalletServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { _ = repo.Close() return nil, err } userConn, err := grpc.Dial(cfg.UserServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { _ = walletConn.Close() _ = repo.Close() return nil, err } activityConn, err := grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { _ = userConn.Close() _ = walletConn.Close() _ = repo.Close() return nil, err } robotConn, err := grpc.Dial(cfg.RobotServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { _ = activityConn.Close() _ = userConn.Close() _ = walletConn.Close() _ = repo.Close() return nil, err } listener, err := net.Listen("tcp", cfg.GRPCAddr) if err != nil { _ = robotConn.Close() _ = activityConn.Close() _ = userConn.Close() _ = walletConn.Close() _ = repo.Close() return nil, err } var outboxProducer *rocketmqx.Producer if cfg.RocketMQ.GameOutbox.Enabled { outboxProducer, err = rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ)) if err != nil { _ = listener.Close() _ = robotConn.Close() _ = activityConn.Close() _ = userConn.Close() _ = walletConn.Close() _ = repo.Close() return nil, err } } server := servicegrpc.New("game-service") walletClient := client.NewWalletClient(walletConn) userClient := client.NewUserClient(userConn) svc := gameservice.New(gameservice.Config{LaunchSessionTTL: cfg.LaunchSessionTTL}, repo, walletClient, userClient, client.NewActivityGrowthClient(activityConn)) // 骰子服务仍持有局事实仓储和钱包依赖,但机器人池已经拆到 robot-service;旧后台机器人 RPC 会通过这个客户端转发,避免后台接口一次性改约。 diceSvc := diceservice.New(diceservice.Config{}, repo, walletClient, diceservice.WithRobotRegistry(client.NewGameRobotClient(robotConn))) var roomRPSPublisher roomrpsservice.Publisher if cfg.TencentIM.Enabled { // 房内 RPS 的“发起 PK”需要同步投递腾讯 IM 房间群消息;启动阶段先创建 REST client,避免运行中才发现配置缺失。 roomRPSPublisher, err = roomrpsservice.NewTencentIMPublisher(cfg.TencentIM.RESTConfig()) if err != nil { _ = listener.Close() _ = robotConn.Close() _ = activityConn.Close() _ = userConn.Close() _ = walletConn.Close() _ = repo.Close() return nil, err } } else { // 显式关闭时保留 HTTP/RPC 功能,便于纯本地测试;测试服和线上必须在 YAML 中打开,否则用户看不到房内 PK。 logx.Warn(context.Background(), "room_rps_im_disabled", slog.String("reason", "tencent_im.enabled=false")) } // 房内猜拳独立于骰子和独立猜拳:配置、挑战状态机和 IM 事件全部由 room-rps service 承接,transport 只做 RPC 适配。 roomRPSSvc := roomrpsservice.New(roomrpsservice.Config{}, userClient, roomRPSPublisher, walletClient, repo) transport := grpcserver.NewServer(svc, diceSvc, roomRPSSvc) gamev1.RegisterGameAppServiceServer(server, transport) gamev1.RegisterGameCallbackServiceServer(server, transport) gamev1.RegisterGameAdminServiceServer(server, transport) gamev1.RegisterGameCronServiceServer(server, transport) health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{ ServiceName: "game-service", HTTPAddr: cfg.HealthHTTPAddr, NodeID: cfg.NodeID, Dependencies: []grpchealth.Dependency{{ Name: "mysql", Check: repo.Ping, }}, }) if err != nil { _ = listener.Close() _ = robotConn.Close() _ = activityConn.Close() _ = userConn.Close() _ = walletConn.Close() _ = repo.Close() return nil, err } outboxCtx, outboxCancel := context.WithCancel(context.Background()) return &App{server: server, listener: listener, health: health, healthHTTP: healthHTTP, repo: repo, walletConn: walletConn, userConn: userConn, activityConn: activityConn, robotConn: robotConn, outboxProducer: outboxProducer, outboxCtx: outboxCtx, outboxCancel: outboxCancel, cfg: cfg}, nil } func (a *App) Run() error { a.runHealthHTTP() if err := a.startMQ(); err != nil { a.health.MarkStopped() return err } a.runOutboxWorker() a.health.MarkServing() err := a.server.Serve(a.listener) a.health.MarkStopped() a.stopOutboxWorker() a.shutdownMQ() if errors.Is(err, grpc.ErrServerStopped) { return nil } return err } func (a *App) Close() { a.closeOnce.Do(func() { a.health.MarkDraining() a.stopOutboxWorker() servicegrpc.GracefulStop(a.server, 15*time.Second) a.closeHealthHTTP() if a.walletConn != nil { _ = a.walletConn.Close() } if a.userConn != nil { _ = a.userConn.Close() } if a.activityConn != nil { _ = a.activityConn.Close() } if a.robotConn != nil { _ = a.robotConn.Close() } if a.repo != nil { _ = a.repo.Close() } a.shutdownMQ() }) } func (a *App) runOutboxWorker() { if !a.cfg.OutboxWorker.Enabled || a.outboxProducer == nil || a.repo == nil { return } workerID := "game-outbox-" + a.cfg.NodeID a.outboxWG.Add(1) go func() { defer a.outboxWG.Done() ticker := time.NewTicker(a.cfg.OutboxWorker.PollInterval) defer ticker.Stop() for { processed, err := a.processOutboxBatch() if err != nil && !errors.Is(err, context.Canceled) { logx.Error(a.outboxCtx, "game_outbox_publish_failed", err, slog.String("worker_id", workerID)) } if processed >= a.cfg.OutboxWorker.BatchSize { continue } select { case <-a.outboxCtx.Done(): return case <-ticker.C: } } }() } func (a *App) processOutboxBatch() (int, error) { workerID := "game-outbox-" + a.cfg.NodeID records, err := a.repo.ClaimPendingGameOutbox(a.outboxCtx, workerID, a.cfg.OutboxWorker.BatchSize) if err != nil { return 0, err } for _, record := range records { publishCtx, cancel := context.WithTimeout(context.Background(), a.cfg.OutboxWorker.PublishTimeout) err := a.publishGameOutboxRecord(publishCtx, record) cancel() if err != nil { nextRetryAtMS := time.Now().UTC().Add(a.cfg.OutboxWorker.PollInterval).UnixMilli() if markErr := a.repo.MarkGameOutboxRetryable(context.Background(), record.AppCode, record.EventID, err.Error(), nextRetryAtMS); markErr != nil { return 0, markErr } return len(records), err } if err := a.repo.MarkGameOutboxDelivered(context.Background(), record.AppCode, record.EventID); err != nil { return 0, err } } return len(records), nil } func (a *App) publishGameOutboxRecord(ctx context.Context, record mysqlstorage.GameOutboxRecord) error { body, err := gamemq.EncodeGameOutboxMessage(gamemq.GameOutboxMessage{ AppCode: record.AppCode, EventID: record.EventID, EventType: record.EventType, OrderID: record.OrderID, UserID: record.UserID, PlatformCode: record.PlatformCode, GameID: record.GameID, OpType: record.OpType, CoinAmount: record.CoinAmount, PayloadJSON: record.PayloadJSON, OccurredAtMS: record.CreatedAtMS, }) if err != nil { return err } return a.outboxProducer.SendSync(ctx, rocketmqx.Message{ Topic: a.cfg.RocketMQ.GameOutbox.Topic, Tag: gamemq.TagGameOutboxEvent, Keys: []string{record.EventID, record.OrderID, record.GameID}, Body: body, }) } func (a *App) startMQ() error { if a.outboxProducer == nil { return nil } return servicemq.StartProducers([]*rocketmqx.Producer{a.outboxProducer}) } func (a *App) shutdownMQ() { servicemq.ShutdownProducers([]*rocketmqx.Producer{a.outboxProducer}) } func (a *App) stopOutboxWorker() { if a.outboxCancel != nil { a.outboxCancel() } a.outboxWG.Wait() } func rocketMQProducerConfig(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.GameOutbox.ProducerGroup, SendTimeout: cfg.SendTimeout, Retry: cfg.Retry, } } 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) }