package app import ( "context" "database/sql" "errors" "fmt" "net" "net/http" "os" "strings" "sync" "time" _ "github.com/go-sql-driver/mysql" "google.golang.org/grpc" "hyapp/pkg/grpcclient" "hyapp/pkg/tencentcos" "hyapp/pkg/tencentim" "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/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 gameConn *grpc.ClientConn appConfig *appconfig.MySQLReader leaderboard *sql.DB 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 } gameConn, err := grpcclient.Dial(cfg.GameServiceAddr, grpcConfig) if err != nil { _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() _ = activityConn.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 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 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(activityConn) 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() _ = gameConn.Close() return nil, err } leaderboardWalletDB, err := openLeaderboardWalletDB(cfg.Leaderboard) if err != nil { if appConfigReader != nil { _ = appConfigReader.Close() } _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() _ = activityConn.Close() _ = gameConn.Close() return nil, err } 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 { if appConfigReader != nil { _ = appConfigReader.Close() } if leaderboardWalletDB != nil { _ = leaderboardWalletDB.Close() } _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() _ = activityConn.Close() _ = gameConn.Close() return nil, err } handler.SetTencentIMAccountImporter(gatewayTencentIMAccountImporter{client: imClient}) } handler.SetRoomQueryClient(roomQueryClient) handler.SetUserDeviceClient(userDeviceClient) 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.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.SetLeaderboardWalletDB(leaderboardWalletDB) 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 { if appConfigReader != nil { _ = appConfigReader.Close() } if leaderboardWalletDB != nil { _ = leaderboardWalletDB.Close() } _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() _ = activityConn.Close() _ = gameConn.Close() 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) redisClose, err := configureAuthRateLimit(handler, cfg.AuthRateLimit, rateLimitConfig) if err != nil { if appConfigReader != nil { _ = appConfigReader.Close() } if leaderboardWalletDB != nil { _ = leaderboardWalletDB.Close() } _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() _ = activityConn.Close() _ = gameConn.Close() return nil, err } loginRiskRedisClose, err := configureLoginRisk(handler, cfg.LoginRisk, loginRiskConfig) if err != nil { if redisClose != nil { _ = redisClose() } if appConfigReader != nil { _ = appConfigReader.Close() } if leaderboardWalletDB != nil { _ = leaderboardWalletDB.Close() } _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() _ = activityConn.Close() _ = gameConn.Close() 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: "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 { if redisClose != nil { _ = redisClose() } if appConfigReader != nil { _ = appConfigReader.Close() } if leaderboardWalletDB != nil { _ = leaderboardWalletDB.Close() } _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() _ = activityConn.Close() _ = gameConn.Close() 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, gameConn: gameConn, appConfig: appConfigReader, leaderboard: leaderboardWalletDB, 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 openLeaderboardWalletDB(cfg config.LeaderboardConfig) (*sql.DB, error) { if strings.TrimSpace(cfg.WalletMySQLDSN) == "" { return nil, nil } return sql.Open("mysql", strings.TrimSpace(cfg.WalletMySQLDSN)) } 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 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 := a.server.Serve(a.listener) a.health.MarkHTTPStopped() if errors.Is(err, http.ErrServerClosed) { return nil } return err } // Close 关闭 HTTP 服务。 func (a *App) Close() error { var err error a.closeOnce.Do(func() { a.health.MarkDraining() shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() // HTTP Shutdown drains accepted requests before client connections close, // so in-flight handlers can finish their internal gRPC calls. if shutdownErr := a.server.Shutdown(shutdownCtx); shutdownErr != nil { // 超过 drain 预算后强制关闭,避免发布脚本被异常长连接卡住。 err = errors.Join(err, shutdownErr, a.server.Close()) } 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.gameConn != nil { err = errors.Join(err, a.gameConn.Close()) } if a.appConfig != nil { err = errors.Join(err, a.appConfig.Close()) } if a.leaderboard != nil { err = errors.Join(err, a.leaderboard.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 }