package app import ( "context" "errors" "fmt" "net" "net/http" "os" "strings" "sync" "time" "google.golang.org/grpc" "hyapp/pkg/dingtalkrobot" "hyapp/pkg/grpcclient" serviceapp "hyapp/pkg/servicekit/app" "hyapp/pkg/tencentcos" "hyapp/pkg/tencentim" "hyapp/pkg/userleaderboard" "hyapp/services/gateway-service/internal/appconfig" "hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/client" "hyapp/services/gateway-service/internal/config" "hyapp/services/gateway-service/internal/financewithdrawal" "hyapp/services/gateway-service/internal/healthcheck" httptransport "hyapp/services/gateway-service/internal/transport/http" ) // App 装配 gateway 的 HTTP 入口。 type App struct { server *http.Server listener net.Listener roomConn *grpc.ClientConn userConn *grpc.ClientConn walletConn *grpc.ClientConn activityConn *grpc.ClientConn luckyGiftConn *grpc.ClientConn gameConn *grpc.ClientConn appConfig *appconfig.MySQLReader withdrawal *financewithdrawal.MySQLWriter redisClose func() error health *healthcheck.State closeOnce sync.Once } // New 初始化 gateway 应用。 func New(cfg config.Config) (*App, error) { if err := cfg.Normalize(); err != nil { return nil, err } grpcConfig := cfg.GRPCClient.Runtime() roomConn, err := grpcclient.Dial(cfg.RoomServiceAddr, grpcConfig) if err != nil { return nil, err } userConn, err := grpcclient.Dial(cfg.UserServiceAddr, grpcConfig) if err != nil { _ = roomConn.Close() return nil, err } walletConn, err := grpcclient.Dial(cfg.WalletServiceAddr, grpcConfig) if err != nil { _ = roomConn.Close() _ = userConn.Close() return nil, err } activityConn, err := grpcclient.Dial(cfg.ActivityServiceAddr, grpcConfig) if err != nil { _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() return nil, err } luckyGiftConn, err := grpcclient.Dial(cfg.LuckyGiftServiceAddr, grpcConfig) if err != nil { _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() _ = activityConn.Close() return nil, err } gameConn, err := grpcclient.Dial(cfg.GameServiceAddr, grpcConfig) if err != nil { _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() _ = activityConn.Close() _ = luckyGiftConn.Close() return nil, err } var roomClient client.RoomClient = client.NewGRPCRoomClient(roomConn) var roomGuardClient client.RoomGuardClient = client.NewGRPCRoomGuardClient(roomConn) var roomQueryClient client.RoomQueryClient = client.NewGRPCRoomQueryClient(roomConn) var userClient client.UserAuthClient = client.NewGRPCUserAuthClient(userConn) var userIdentityClient client.UserIdentityClient = client.NewGRPCUserIdentityClient(userConn) var userProfileClient client.UserProfileClient = client.NewGRPCUserProfileClient(userConn) var userInviteClient client.UserInviteClient = client.NewGRPCUserInviteClient(userConn) var userDeviceClient client.UserDeviceClient = client.NewGRPCUserDeviceClient(userConn) var userCountryQueryClient client.UserCountryQueryClient = client.NewGRPCUserCountryQueryClient(userConn) var userRegionClient client.UserRegionClient = client.NewGRPCUserRegionClient(userConn) var userHostClient client.UserHostClient = client.NewGRPCUserHostClient(userConn) var userHostAdminClient client.UserHostAdminClient = client.NewGRPCUserHostAdminClient(userConn) var userSocialClient client.UserSocialClient = client.NewGRPCUserSocialClient(userConn) var userCPClient client.UserCPClient = client.NewGRPCUserCPClient(userConn) var appRegistryClient client.AppRegistryClient = client.NewGRPCAppRegistryClient(userConn) var walletClient client.WalletClient = client.NewGRPCWalletClient(walletConn) var messageClient client.MessageInboxClient = client.NewGRPCMessageInboxClient(activityConn) var messageActionClient client.MessageActionConfirmClient = client.NewGRPCMessageActionConfirmClient(activityConn) var taskClient client.TaskClient = client.NewGRPCTaskClient(activityConn) var growthLevelClient client.GrowthLevelClient = client.NewGRPCGrowthLevelClient(activityConn) var achievementClient client.AchievementClient = client.NewGRPCAchievementClient(activityConn) var registrationRewardClient client.RegistrationRewardClient = client.NewGRPCRegistrationRewardClient(activityConn) var firstRechargeRewardClient client.FirstRechargeRewardClient = client.NewGRPCFirstRechargeRewardClient(activityConn) var cumulativeRechargeRewardClient client.CumulativeRechargeRewardClient = client.NewGRPCCumulativeRechargeRewardClient(activityConn) var inviteActivityRewardClient client.InviteActivityRewardClient = client.NewGRPCInviteActivityRewardClient(activityConn) var sevenDayCheckInClient client.SevenDayCheckInClient = client.NewGRPCSevenDayCheckInClient(activityConn) var luckyGiftClient client.LuckyGiftClient = client.NewGRPCLuckyGiftClient(luckyGiftConn) var roomTurnoverRewardClient client.RoomTurnoverRewardClient = client.NewGRPCRoomTurnoverRewardClient(activityConn) var weeklyStarClient client.WeeklyStarClient = client.NewGRPCWeeklyStarClient(activityConn) var cpWeeklyRankClient client.CPWeeklyRankClient = client.NewGRPCCPWeeklyRankClient(activityConn) var agencyOpeningClient client.AgencyOpeningClient = client.NewGRPCAgencyOpeningClient(activityConn) var wheelClient client.WheelClient = client.NewGRPCWheelClient(activityConn) var broadcastClient client.BroadcastClient = client.NewGRPCBroadcastClient(activityConn) var gameClient client.GameClient = client.NewGRPCGameClient(gameConn) var statisticsClient client.StatisticsClient = client.NewHTTPStatisticsClient(cfg.Statistics.BaseURL, cfg.Statistics.Timeout) appConfigReader, err := openAppConfigReader(cfg.AppConfig) if err != nil { _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() _ = activityConn.Close() _ = luckyGiftConn.Close() _ = gameConn.Close() return nil, err } withdrawalWriter, err := openWithdrawalApplicationWriter(cfg.AppConfig) if err != nil { if appConfigReader != nil { _ = appConfigReader.Close() } _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() _ = activityConn.Close() _ = luckyGiftConn.Close() _ = gameConn.Close() return nil, err } withdrawalNotifier, err := openWithdrawalApplicationNotifier(cfg.FinanceNotifications) if err != nil { if withdrawalWriter != nil { _ = withdrawalWriter.Close() } if appConfigReader != nil { _ = appConfigReader.Close() } _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() _ = activityConn.Close() _ = luckyGiftConn.Close() _ = gameConn.Close() return nil, err } closeGatewayDeps := func(redisClose func() error) { if withdrawalWriter != nil { _ = withdrawalWriter.Close() } if appConfigReader != nil { _ = appConfigReader.Close() } if redisClose != nil { _ = redisClose() } _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() _ = activityConn.Close() _ = luckyGiftConn.Close() _ = gameConn.Close() } handler := httptransport.NewHandlerWithConfig(roomClient, roomGuardClient, userClient, userIdentityClient, httptransport.TencentIMConfig{ SDKAppID: cfg.TencentIM.SDKAppID, SecretKey: cfg.TencentIM.SecretKey, UserSigTTL: cfg.TencentIM.UserSigTTL, AdminIdentifier: cfg.TencentIM.AdminIdentifier, CallbackAuthToken: cfg.TencentIM.CallbackAuthToken, GroupIDPrefix: cfg.TencentIM.GroupIDPrefix, }, userProfileClient) if cfg.TencentIM.SDKAppID > 0 && strings.TrimSpace(cfg.TencentIM.SecretKey) != "" { imClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig()) if err != nil { closeGatewayDeps(nil) return nil, err } handler.SetTencentIMAccountImporter(gatewayTencentIMAccountImporter{client: imClient}) handler.SetTencentIMUserMessagePublisher(imClient) } handler.SetRoomQueryClient(roomQueryClient) handler.SetUserDeviceClient(userDeviceClient) handler.SetUserInviteClient(userInviteClient) handler.SetUserCountryQueryClient(userCountryQueryClient) handler.SetUserRegionClient(userRegionClient) handler.SetUserHostClient(userHostClient) handler.SetUserHostAdminClient(userHostAdminClient) handler.SetUserSocialClient(userSocialClient) handler.SetUserCPClient(userCPClient) handler.SetAppRegistryClient(appRegistryClient) handler.SetWalletClient(walletClient) handler.SetMessageInboxClient(messageClient) handler.SetMessageActionConfirmClient(messageActionClient) handler.SetTaskClient(taskClient) handler.SetGrowthLevelClient(growthLevelClient) handler.SetAchievementClient(achievementClient) handler.SetRegistrationRewardClient(registrationRewardClient) handler.SetFirstRechargeRewardClient(firstRechargeRewardClient) handler.SetCumulativeRechargeRewardClient(cumulativeRechargeRewardClient) handler.SetInviteActivityRewardClient(inviteActivityRewardClient) handler.SetSevenDayCheckInClient(sevenDayCheckInClient) handler.SetLuckyGiftClient(luckyGiftClient) handler.SetRoomTurnoverRewardClient(roomTurnoverRewardClient) handler.SetWeeklyStarClient(weeklyStarClient) handler.SetCPWeeklyRankClient(cpWeeklyRankClient) handler.SetAgencyOpeningClient(agencyOpeningClient) handler.SetWheelClient(wheelClient) handler.SetBroadcastClient(broadcastClient) handler.SetGameClient(gameClient) handler.SetStatisticsClient(statisticsClient) handler.SetWithdrawalApplicationWriter(withdrawalWriter) handler.SetWithdrawalApplicationNotifier(withdrawalNotifier) leaderboardRedisClose, err := configureUserLeaderboard(handler, cfg.Leaderboard) if err != nil { closeGatewayDeps(nil) return nil, err } redisClose := leaderboardRedisClose if appConfigReader != nil { handler.SetAppConfigReader(appConfigReader) } handler.SetTencentRTC(httptransport.TencentRTCConfig{ Enabled: cfg.TencentRTC.Enabled, SDKAppID: cfg.TencentRTC.SDKAppID, SecretKey: cfg.TencentRTC.SecretKey, UserSigTTL: cfg.TencentRTC.UserSigTTL, RoomIDType: cfg.TencentRTC.RoomIDType, AppScene: cfg.TencentRTC.AppScene, CallbackSignKey: cfg.TencentRTC.CallbackSignKey, }) if cfg.TencentCOS.Enabled { uploader, err := tencentcos.NewUploader(tencentcos.Config{ SecretID: cfg.TencentCOS.SecretID, SecretKey: cfg.TencentCOS.SecretKey, Bucket: cfg.TencentCOS.BucketName, Region: cfg.TencentCOS.Region, AccessURL: cfg.TencentCOS.AccessURL, }, nil) if err != nil { closeGatewayDeps(redisClose) return nil, err } handler.SetObjectUploader(uploader) } rateLimitConfig := httptransport.AuthRateLimitConfig{ Enabled: cfg.AuthRateLimit.Enabled, KeyPrefix: cfg.AuthRateLimit.KeyPrefix, Window: time.Duration(cfg.AuthRateLimit.WindowSec) * time.Second, IPLimit: cfg.AuthRateLimit.IPLimit, DeviceIDLimit: cfg.AuthRateLimit.DeviceIDLimit, ProviderIPLimit: cfg.AuthRateLimit.ProviderIPLimit, DisplayUserIDIPLimit: cfg.AuthRateLimit.DisplayUserIDIPLimit, DisplayUserIDLimit: cfg.AuthRateLimit.DisplayUserIDLimit, SessionIDIPLimit: cfg.AuthRateLimit.SessionIDIPLimit, } loginRiskConfig := httptransport.LoginRiskConfig{ Enabled: cfg.LoginRisk.Enabled, KeyPrefix: cfg.LoginRisk.KeyPrefix, FastGuard: httptransport.LoginRiskFastGuardConfig{ BlockedLanguages: cfg.LoginRisk.FastGuard.BlockedLanguages, BlockedLanguagePrimaryTags: cfg.LoginRisk.FastGuard.BlockedLanguagePrimaryTags, BlockedTimezones: cfg.LoginRisk.FastGuard.BlockedTimezones, BlockedTimezonePrefixes: cfg.LoginRisk.FastGuard.BlockedTimezonePrefixes, }, } handler.SetLoginRisk(loginRiskConfig) authRedisClose, err := configureAuthRateLimit(handler, cfg.AuthRateLimit, rateLimitConfig) if err != nil { closeGatewayDeps(redisClose) return nil, err } redisClose = joinClose(redisClose, authRedisClose) loginRiskRedisClose, err := configureLoginRisk(handler, cfg.LoginRisk, loginRiskConfig) if err != nil { closeGatewayDeps(redisClose) return nil, err } redisClose = joinClose(redisClose, loginRiskRedisClose) verifier := auth.NewVerifier(cfg.JWTSecret) healthState := healthcheck.NewState(nodeID(), cfg.JWTSecret, roomConn, healthcheck.GRPCDependency{Name: "user_grpc", Conn: userConn}, healthcheck.GRPCDependency{Name: "wallet_grpc", Conn: walletConn}, healthcheck.GRPCDependency{Name: "activity_grpc", Conn: activityConn}, healthcheck.GRPCDependency{Name: "lucky_gift_grpc", Conn: luckyGiftConn}, healthcheck.GRPCDependency{Name: "game_grpc", Conn: gameConn}, ) mux := http.NewServeMux() healthHandler := healthcheck.NewHTTPHandler(healthState) mux.HandleFunc("/healthz/live", healthHandler.Live) mux.HandleFunc("/healthz/ready", healthHandler.Ready) mux.Handle("/", handler.Routes(verifier)) listener, err := net.Listen("tcp", cfg.HTTPAddr) if err != nil { closeGatewayDeps(redisClose) return nil, err } server := &http.Server{ Handler: httptransport.WithCORS(httptransport.CORSConfig{ Enabled: cfg.CORS.Enabled, AllowedOrigins: cfg.CORS.AllowedOrigins, AllowedMethods: cfg.CORS.AllowedMethods, AllowedHeaders: cfg.CORS.AllowedHeaders, ExposeHeaders: cfg.CORS.ExposeHeaders, AllowCredentials: cfg.CORS.AllowCredentials, MaxAgeSec: cfg.CORS.MaxAgeSec, }, mux), } return &App{ server: server, listener: listener, roomConn: roomConn, userConn: userConn, walletConn: walletConn, activityConn: activityConn, luckyGiftConn: luckyGiftConn, gameConn: gameConn, appConfig: appConfigReader, withdrawal: withdrawalWriter, redisClose: redisClose, health: healthState, }, nil } func openAppConfigReader(cfg config.AppConfigConfig) (*appconfig.MySQLReader, error) { if strings.TrimSpace(cfg.MySQLDSN) == "" { return nil, nil } reader, err := appconfig.OpenMySQLReader(cfg.MySQLDSN) if err != nil { return nil, err } if strings.TrimSpace(cfg.RedisAddr) == "" { return reader, nil } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() cacheClient, err := appconfig.OpenRedisCache(ctx, cfg.RedisAddr, cfg.RedisPassword, cfg.RedisDB) if err != nil { _ = reader.Close() return nil, fmt.Errorf("connect app config redis: %w", err) } reader.SetRedisCache(cacheClient, cfg.KeyPrefix, cfg.CacheTTL) return reader, nil } func openWithdrawalApplicationWriter(cfg config.AppConfigConfig) (*financewithdrawal.MySQLWriter, error) { // H5 提现申请进入后台 finance 审核表,表在 hyapp_admin;这里复用后台库 DSN,但只开放单表写入器。 return financewithdrawal.OpenMySQLWriter(cfg.MySQLDSN) } func openWithdrawalApplicationNotifier(cfg config.FinanceNotificationsConfig) (financewithdrawal.Notifier, error) { if !cfg.DingTalk.Enabled || strings.TrimSpace(cfg.DingTalk.WebhookURL) == "" { return nil, nil } client, err := dingtalkrobot.New(dingtalkrobot.Config{ WebhookURL: cfg.DingTalk.WebhookURL, Secret: cfg.DingTalk.Secret, AtMobiles: cfg.DingTalk.AtMobiles, AtAll: cfg.DingTalk.AtAll, RequestTimeout: cfg.DingTalk.RequestTimeout, }, nil) if err != nil { return nil, err } return financewithdrawal.NewDingTalkNotifier(client), nil } func configureAuthRateLimit(handler *httptransport.Handler, cfg config.AuthRateLimitConfig, rateLimitConfig httptransport.AuthRateLimitConfig) (func() error, error) { if !rateLimitConfig.Enabled { // 显式关闭频控时仍记录配置,避免 handler 使用默认开启策略。 handler.SetAuthRateLimit(rateLimitConfig) return nil, nil } redisAddr := strings.TrimSpace(cfg.RedisAddr) if redisAddr == "" { return nil, errors.New("auth rate limit redis_addr is required") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() redisClient, err := httptransport.NewAuthRateLimitRedisClient(ctx, redisAddr, cfg.RedisPassword, cfg.RedisDB) if err != nil { return nil, fmt.Errorf("connect auth rate limit redis: %w", err) } // 生产入口必须走 Redis 共享计数;内存 backend 只保留给单元测试和显式禁用频控。 handler.SetAuthRateLimitBackend(rateLimitConfig, httptransport.NewRedisAuthRateLimiter(redisClient, rateLimitConfig)) return redisClient.Close, nil } func configureUserLeaderboard(handler *httptransport.Handler, cfg config.LeaderboardConfig) (func() error, error) { redisAddr := strings.TrimSpace(cfg.RedisAddr) if redisAddr == "" { return nil, nil } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() redisClient, err := userleaderboard.NewRedisClient(ctx, redisAddr, cfg.RedisPassword, cfg.RedisDB) if err != nil { return nil, fmt.Errorf("connect user leaderboard redis: %w", err) } // gateway 只读取 activity-service 维护的 Redis 聚合结果,不再在 HTTP 请求中扫描 wallet_transactions。 handler.SetUserLeaderboardStore(userleaderboard.NewStore(redisClient, cfg.KeyPrefix)) return redisClient.Close, nil } func configureLoginRisk(handler *httptransport.Handler, cfg config.LoginRiskConfig, riskConfig httptransport.LoginRiskConfig) (func() error, error) { if !riskConfig.Enabled { handler.SetLoginRisk(riskConfig) return nil, nil } redisAddr := strings.TrimSpace(cfg.RedisAddr) if redisAddr == "" { handler.SetLoginRisk(riskConfig) return nil, nil } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() redisClient, err := httptransport.NewLoginRiskRedisClient(ctx, redisAddr, cfg.RedisPassword, cfg.RedisDB) if err != nil { // IP 风控缓存按文档 fail-open:Redis 不可用不能阻断 gateway 启动或登录。 handler.SetLoginRisk(riskConfig) return nil, nil } handler.SetLoginRiskCache(riskConfig, httptransport.NewRedisLoginRiskCache(redisClient, riskConfig)) return redisClient.Close, nil } func joinClose(left func() error, right func() error) func() error { if left == nil { return right } if right == nil { return left } return func() error { return errors.Join(left(), right()) } } type gatewayTencentIMAccountImporter struct { client *tencentim.RESTClient } func (i gatewayTencentIMAccountImporter) ImportAccount(ctx context.Context, userID int64, nickname string, faceURL string) error { return i.client.ImportAccount(ctx, tencentim.AccountProfile{ UserID: userID, Nick: nickname, FaceURL: faceURL, }) } // Run 启动 HTTP 服务。 func (a *App) Run() error { a.health.MarkHTTPServing() err := serviceapp.ServeHTTP(a.server, a.listener) a.health.MarkHTTPStopped() return err } // Close 关闭 HTTP 服务。 func (a *App) Close() error { var err error a.closeOnce.Do(func() { a.health.MarkDraining() // HTTP drain 要先于内部 gRPC 连接关闭,保证已进入 handler 的编排请求能自然完成。 err = errors.Join(err, serviceapp.ShutdownHTTP(a.server, 15*time.Second)) if a.roomConn != nil { err = errors.Join(err, a.roomConn.Close()) } if a.userConn != nil { err = errors.Join(err, a.userConn.Close()) } if a.walletConn != nil { err = errors.Join(err, a.walletConn.Close()) } if a.activityConn != nil { err = errors.Join(err, a.activityConn.Close()) } if a.luckyGiftConn != nil { err = errors.Join(err, a.luckyGiftConn.Close()) } if a.gameConn != nil { err = errors.Join(err, a.gameConn.Close()) } if a.appConfig != nil { err = errors.Join(err, a.appConfig.Close()) } if a.withdrawal != nil { err = errors.Join(err, a.withdrawal.Close()) } if a.redisClose != nil { err = errors.Join(err, a.redisClose()) } }) return err } func nodeID() string { hostname, err := os.Hostname() if err != nil || hostname == "" { return "gateway-service" } return hostname }