2026-06-08 19:00:23 +08:00

481 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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 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 broadcastClient client.BroadcastClient = client.NewGRPCBroadcastClient(activityConn)
var gameClient client.GameClient = client.NewGRPCGameClient(gameConn)
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.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.SetSevenDayCheckInClient(sevenDayCheckInClient)
handler.SetLuckyGiftClient(luckyGiftClient)
handler.SetRoomTurnoverRewardClient(roomTurnoverRewardClient)
handler.SetWeeklyStarClient(weeklyStarClient)
handler.SetBroadcastClient(broadcastClient)
handler.SetGameClient(gameClient)
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
}
return appconfig.OpenMySQLReader(cfg.MySQLDSN)
}
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-openRedis 不可用不能阻断 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
}