439 lines
14 KiB
Go
439 lines
14 KiB
Go
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/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 userHostClient client.UserHostClient = client.NewGRPCUserHostClient(userConn)
|
||
var userSocialClient client.UserSocialClient = client.NewGRPCUserSocialClient(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 sevenDayCheckInClient client.SevenDayCheckInClient = client.NewGRPCSevenDayCheckInClient(activityConn)
|
||
var luckyGiftClient client.LuckyGiftClient = client.NewGRPCLuckyGiftClient(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,
|
||
}, userProfileClient)
|
||
handler.SetRoomQueryClient(roomQueryClient)
|
||
handler.SetUserDeviceClient(userDeviceClient)
|
||
handler.SetUserCountryQueryClient(userCountryQueryClient)
|
||
handler.SetUserHostClient(userHostClient)
|
||
handler.SetUserSocialClient(userSocialClient)
|
||
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.SetSevenDayCheckInClient(sevenDayCheckInClient)
|
||
handler.SetLuckyGiftClient(luckyGiftClient)
|
||
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-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())
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|