package config import ( "fmt" "net/http" "strings" "time" "hyapp/pkg/configx" "hyapp/pkg/grpcclient" "hyapp/pkg/logx" "hyapp/pkg/tencentim" "hyapp/pkg/tencentrtc" ) // Config 描述 gateway-service 启动所需的最小配置。 type Config struct { ServiceName string `yaml:"service_name"` NodeID string `yaml:"node_id"` Environment string `yaml:"environment"` HTTPAddr string `yaml:"http_addr"` CORS CORSConfig `yaml:"cors"` JWTSecret string `yaml:"jwt_secret"` RoomServiceAddr string `yaml:"room_service_addr"` UserServiceAddr string `yaml:"user_service_addr"` WalletServiceAddr string `yaml:"wallet_service_addr"` ActivityServiceAddr string `yaml:"activity_service_addr"` GameServiceAddr string `yaml:"game_service_addr"` GRPCClient GRPCClientConfig `yaml:"grpc_client"` AuthRateLimit AuthRateLimitConfig `yaml:"auth_rate_limit"` LoginRisk LoginRiskConfig `yaml:"login_risk"` AppConfig AppConfigConfig `yaml:"app_config"` Leaderboard LeaderboardConfig `yaml:"leaderboard"` TencentIM TencentIMConfig `yaml:"tencent_im"` TencentRTC TencentRTCConfig `yaml:"tencent_rtc"` TencentCOS TencentCOSConfig `yaml:"tencent-cos"` Log logx.Config `yaml:"log"` } // CORSConfig 控制浏览器/H5 访问 gateway API 的跨域入口策略。 type CORSConfig struct { // Enabled 为 true 时,gateway 在最外层处理浏览器 preflight,避免 OPTIONS 进入业务 handler。 Enabled bool `yaml:"enabled"` // AllowedOrigins 是允许携带凭据访问 API 的 Origin 白名单;生产不要配置裸星号。 AllowedOrigins []string `yaml:"allowed_origins"` // AllowedMethods 是 preflight 允许的方法集合。 AllowedMethods []string `yaml:"allowed_methods"` // AllowedHeaders 是 preflight 允许的请求头集合。 AllowedHeaders []string `yaml:"allowed_headers"` // ExposeHeaders 是实际响应暴露给浏览器读取的响应头集合。 ExposeHeaders []string `yaml:"expose_headers"` // AllowCredentials 控制浏览器是否允许带 cookie/Authorization 等凭据。 AllowCredentials bool `yaml:"allow_credentials"` // MaxAgeSec 是浏览器缓存 preflight 结果的秒数。 MaxAgeSec int64 `yaml:"max_age_sec"` } // GRPCClientConfig 控制 gateway 访问内部服务的 gRPC transport 策略。 type GRPCClientConfig struct { // DefaultTimeout 是没有显式 request deadline 时的 RPC 总预算,包含重试耗时。 DefaultTimeout time.Duration `yaml:"default_timeout"` // ConnectTimeout 是单次建连最小预算,避免内网目标不可达时长期卡住。 ConnectTimeout time.Duration `yaml:"connect_timeout"` // KeepaliveTime 是空闲连接探活周期,帮助 CLB/NAT 后的半开连接尽早发现。 KeepaliveTime time.Duration `yaml:"keepalive_time"` // KeepaliveTimeout 是 keepalive ack 等待时间。 KeepaliveTimeout time.Duration `yaml:"keepalive_timeout"` // RetryMaxAttempts 是包含首次调用在内的最大尝试次数;小于 2 时关闭显式重试。 RetryMaxAttempts int `yaml:"retry_max_attempts"` // RetryInitialBackoff 是第一次重试前等待时间。 RetryInitialBackoff time.Duration `yaml:"retry_initial_backoff"` // RetryMaxBackoff 是重试退避上限。 RetryMaxBackoff time.Duration `yaml:"retry_max_backoff"` // RetryBackoffMultiplier 控制退避增长倍率。 RetryBackoffMultiplier float64 `yaml:"retry_backoff_multiplier"` // RetryableStatusCodes 限定可重试状态,生产默认只允许 UNAVAILABLE。 RetryableStatusCodes []string `yaml:"retryable_status_codes"` } // Runtime 转换为共享 gRPC client 配置,避免 app 层重复维护默认值。 func (c GRPCClientConfig) Runtime() grpcclient.Config { return grpcclient.Normalize(grpcclient.Config{ DefaultTimeout: c.DefaultTimeout, ConnectTimeout: c.ConnectTimeout, KeepaliveTime: c.KeepaliveTime, KeepaliveTimeout: c.KeepaliveTimeout, RetryMaxAttempts: c.RetryMaxAttempts, RetryInitialBackoff: c.RetryInitialBackoff, RetryMaxBackoff: c.RetryMaxBackoff, RetryBackoffMultiplier: c.RetryBackoffMultiplier, RetryableStatusCodes: c.RetryableStatusCodes, }) } // LoginRiskConfig 控制 gateway 登录入口快拦和 Redis 风控缓存读取。 type LoginRiskConfig struct { Enabled bool `yaml:"enabled"` RedisAddr string `yaml:"redis_addr"` RedisPassword string `yaml:"redis_password"` RedisDB int `yaml:"redis_db"` KeyPrefix string `yaml:"key_prefix"` FastGuard LoginRiskFastGuardConfig `yaml:"fast_guard"` } // LoginRiskFastGuardConfig 保存语言和时区弱信号阻断配置。 type LoginRiskFastGuardConfig struct { BlockedLanguages []string `yaml:"blocked_languages"` BlockedLanguagePrimaryTags []string `yaml:"blocked_language_primary_tags"` BlockedTimezones []string `yaml:"blocked_timezones"` BlockedTimezonePrefixes []string `yaml:"blocked_timezone_prefixes"` } // AppConfigConfig 描述 gateway 读取后台 App 运行时配置的只读数据源。 type AppConfigConfig struct { // MySQLDSN 指向后台管理库 hyapp_admin;gateway 只读 App 运行时配置表。 MySQLDSN string `yaml:"mysql_dsn"` } // LeaderboardConfig 描述 App 活动榜单读取钱包送礼事实的只读数据源。 type LeaderboardConfig struct { // WalletMySQLDSN 指向钱包库 hyapp_wallet;只读取 wallet_transactions 的 gift_debit 事实。 WalletMySQLDSN string `yaml:"wallet_mysql_dsn"` } // AuthRateLimitConfig 控制 gateway public auth 入口的 Redis 频控。 type AuthRateLimitConfig struct { // Enabled 为 false 时关闭 gateway auth 入口频控。 Enabled bool `yaml:"enabled"` // RedisAddr 是 public auth 频控 Redis 地址。 RedisAddr string `yaml:"redis_addr"` // RedisPassword 是 Redis 鉴权密码,本地默认留空。 RedisPassword string `yaml:"redis_password"` // RedisDB 是 Redis logical DB。 RedisDB int `yaml:"redis_db"` // KeyPrefix 是频控 key 前缀,用于区分环境和服务。 KeyPrefix string `yaml:"key_prefix"` // WindowSec 是固定窗口秒数。 WindowSec int64 `yaml:"window_sec"` // IPLimit 限制同一入口 IP 对所有 public auth API 的请求数。 IPLimit int `yaml:"ip_limit"` // DeviceIDLimit 限制同一 device_id 对登录、注册和 refresh 的请求数。 DeviceIDLimit int `yaml:"device_id_limit"` // ProviderIPLimit 限制同一 IP 对同一三方 provider 的注册/登录请求数。 ProviderIPLimit int `yaml:"provider_ip_limit"` // DisplayUserIDIPLimit 限制同一 IP 对同一 display_user_id 的密码尝试。 DisplayUserIDIPLimit int `yaml:"display_user_id_ip_limit"` // DisplayUserIDLimit 限制同一 display_user_id 被跨 IP 喷射尝试。 DisplayUserIDLimit int `yaml:"display_user_id_limit"` // SessionIDIPLimit 限制同一 IP 对同一 session_id 的 logout 请求数。 SessionIDIPLimit int `yaml:"session_id_ip_limit"` } // TencentIMConfig 描述 gateway 给客户端签发腾讯云 IM UserSig 的配置。 type TencentIMConfig struct { // SDKAppID 是腾讯云 IM 控制台分配的应用 ID。 SDKAppID int64 `yaml:"sdk_app_id"` // SecretKey 只允许出现在服务端配置中,不能下发给客户端。 SecretKey string `yaml:"secret_key"` // UserSigTTL 是客户端 IM 登录票据有效期。 UserSigTTL time.Duration `yaml:"user_sig_ttl"` // AdminIdentifier 是服务端 REST 发播报和系统消息的管理员账号,回调用它识别服务端发送者。 AdminIdentifier string `yaml:"admin_identifier"` // AdminUserSigTTL 控制 gateway 补偿导入 IM 账号时的 admin UserSig 有效期。 AdminUserSigTTL time.Duration `yaml:"admin_user_sig_ttl"` // Endpoint 是腾讯 IM REST 地域入口,例如 adminapisgp.im.qcloud.com。 Endpoint string `yaml:"endpoint"` // RequestTimeout 是 gateway 调腾讯 IM REST 的请求超时。 RequestTimeout time.Duration `yaml:"request_timeout"` // GroupIDPrefix 给测试服等隔离环境使用,正式服为空以保持既有腾讯 IM 群不变。 GroupIDPrefix string `yaml:"group_id_prefix"` // CallbackURL 是在腾讯云 IM 控制台配置的服务端回调地址。 CallbackURL string `yaml:"callback_url"` // CallbackAuthToken 是腾讯云 IM 回调鉴权 token,只用于校验回调来源。 CallbackAuthToken string `yaml:"callback_auth_token"` } // RESTConfig 转换成腾讯 IM REST client 配置,用于 /im/usersig 入口补偿导入账号。 func (c TencentIMConfig) RESTConfig() tencentim.RESTConfig { timeout := c.RequestTimeout if timeout <= 0 { timeout = 5 * time.Second } ttl := c.AdminUserSigTTL if ttl <= 0 { ttl = 24 * time.Hour } return tencentim.RESTConfig{ SDKAppID: c.SDKAppID, SecretKey: c.SecretKey, AdminIdentifier: c.AdminIdentifier, AdminUserSigTTL: ttl, Endpoint: c.Endpoint, GroupType: tencentim.DefaultGroupType, HTTPClient: &http.Client{Timeout: timeout}, } } // TencentRTCConfig 描述 gateway 给客户端签发腾讯 RTC 进房 UserSig 的配置。 type TencentRTCConfig struct { // Enabled 控制 RTC token endpoint 是否可以对外签发,配置缺失时必须 fail-closed。 Enabled bool `yaml:"enabled"` // SDKAppID 是腾讯 RTC 控制台分配的应用 ID。 SDKAppID int64 `yaml:"sdk_app_id"` // SecretKey 只允许出现在服务端配置中,不能下发给客户端。 SecretKey string `yaml:"secret_key"` // UserSigTTL 是客户端 RTC 进房票据有效期。 UserSigTTL time.Duration `yaml:"user_sig_ttl"` // RoomIDType 首版固定 string,确保客户端只使用 TRTC strRoomId。 RoomIDType string `yaml:"room_id_type"` // AppScene 首版固定 voice_chat_room,所有端必须一致。 AppScene string `yaml:"app_scene"` // CallbackURL 是在腾讯 RTC 控制台配置的事件回调地址。 CallbackURL string `yaml:"callback_url"` // CallbackSignKey 是腾讯 RTC 事件回调签名 key,只用于校验回调来源。 CallbackSignKey string `yaml:"callback_sign_key"` } // TencentCOSConfig 描述 gateway 代 App 上传文件到腾讯云 COS 所需的服务端配置。 type TencentCOSConfig struct { // Enabled 控制上传接口是否真正写 COS;关闭时接口会 fail-closed。 Enabled bool `yaml:"enabled"` // SecretID/SecretKey 只允许保存在服务端配置中,不能下发给客户端。 SecretID string `yaml:"secret-id"` SecretKey string `yaml:"secret-key"` // BucketName 是完整 bucket 名,例如 yumi-assets-1420526837。 BucketName string `yaml:"bucket-name"` // Region 是 COS 地域,例如 me-saudi-arabia。 Region string `yaml:"region"` // AccessURL 是对象对外访问域名,通常是 CDN 或绑定的自定义域名。 AccessURL string `yaml:"access-url"` } // Default 返回本地开发默认配置。 func Default() Config { return Config{ ServiceName: "gateway-service", NodeID: "gateway-local", Environment: "local", HTTPAddr: ":13000", CORS: CORSConfig{ Enabled: true, AllowedOrigins: []string{ "http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:5173", "http://127.0.0.1:5173", "https://global-interaction.com", "https://www.global-interaction.com", "https://h5.global-interaction.com", "https://api.global-interaction.com", }, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowedHeaders: []string{ "Authorization", "Content-Type", "X-Request-ID", "X-App-Code", "X-HY-App-Code", "X-App-Package", "X-Package-Name", "X-App-Bundle-ID", "X-Bundle-ID", "X-App-Platform", "X-Platform", }, ExposeHeaders: []string{"X-Request-ID"}, AllowCredentials: true, MaxAgeSec: 600, }, JWTSecret: "gateway-dev-secret", RoomServiceAddr: "127.0.0.1:13001", UserServiceAddr: "127.0.0.1:13005", WalletServiceAddr: "127.0.0.1:13004", ActivityServiceAddr: "127.0.0.1:13006", GameServiceAddr: "127.0.0.1:13008", GRPCClient: GRPCClientConfig{ DefaultTimeout: 5 * time.Second, ConnectTimeout: 2 * time.Second, KeepaliveTime: 30 * time.Second, KeepaliveTimeout: 10 * time.Second, RetryMaxAttempts: 2, RetryInitialBackoff: 100 * time.Millisecond, RetryMaxBackoff: 500 * time.Millisecond, RetryBackoffMultiplier: 2, RetryableStatusCodes: []string{"UNAVAILABLE"}, }, AppConfig: AppConfigConfig{ MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC", }, Leaderboard: LeaderboardConfig{ WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC", }, AuthRateLimit: AuthRateLimitConfig{ Enabled: true, RedisAddr: "127.0.0.1:13379", KeyPrefix: "gateway:auth:rate:", WindowSec: 60, IPLimit: 120, DeviceIDLimit: 30, ProviderIPLimit: 20, DisplayUserIDIPLimit: 10, DisplayUserIDLimit: 50, SessionIDIPLimit: 30, }, LoginRisk: LoginRiskConfig{ Enabled: true, RedisAddr: "127.0.0.1:13379", KeyPrefix: "login_risk:ip:", }, TencentIM: TencentIMConfig{ UserSigTTL: 24 * time.Hour, AdminUserSigTTL: 24 * time.Hour, AdminIdentifier: "administrator", RequestTimeout: 5 * time.Second, Endpoint: tencentim.DefaultEndpoint, }, TencentRTC: TencentRTCConfig{ UserSigTTL: 2 * time.Hour, RoomIDType: "string", AppScene: "voice_chat_room", }, Log: logx.Config{ Level: "info", Format: "json", MaxPayloadBytes: 2048, }, } } // Load 从独立 config.yaml 读取 gateway-service 配置。 func Load(path string) (Config, error) { cfg := Default() if path == "" { if err := cfg.Normalize(); err != nil { return Config{}, err } return cfg, nil } if err := configx.LoadYAML(path, &cfg); err != nil { return Config{}, err } if err := cfg.Normalize(); err != nil { return Config{}, err } return cfg, nil } // Normalize fills policy defaults and rejects unsupported RTC room/app modes at startup. func (cfg *Config) Normalize() error { cfg.ServiceName = strings.TrimSpace(cfg.ServiceName) if cfg.ServiceName == "" { cfg.ServiceName = "gateway-service" } cfg.NodeID = strings.TrimSpace(cfg.NodeID) if cfg.NodeID == "" { cfg.NodeID = cfg.ServiceName } cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment)) if cfg.Environment == "" { cfg.Environment = "local" } cfg.CORS.AllowedOrigins = trimNonEmpty(cfg.CORS.AllowedOrigins) cfg.CORS.AllowedMethods = upperNonEmpty(cfg.CORS.AllowedMethods) cfg.CORS.AllowedHeaders = trimNonEmpty(cfg.CORS.AllowedHeaders) cfg.CORS.ExposeHeaders = trimNonEmpty(cfg.CORS.ExposeHeaders) if len(cfg.CORS.AllowedMethods) == 0 { cfg.CORS.AllowedMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"} } if len(cfg.CORS.AllowedHeaders) == 0 { cfg.CORS.AllowedHeaders = []string{"Authorization", "Content-Type", "X-Request-ID", "X-App-Code", "X-HY-App-Code", "X-App-Package", "X-Package-Name", "X-App-Bundle-ID", "X-Bundle-ID", "X-App-Platform", "X-Platform"} } if len(cfg.CORS.ExposeHeaders) == 0 { cfg.CORS.ExposeHeaders = []string{"X-Request-ID"} } if cfg.CORS.MaxAgeSec <= 0 { cfg.CORS.MaxAgeSec = 600 } cfg.RoomServiceAddr = strings.TrimSpace(cfg.RoomServiceAddr) cfg.UserServiceAddr = strings.TrimSpace(cfg.UserServiceAddr) cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr) cfg.ActivityServiceAddr = strings.TrimSpace(cfg.ActivityServiceAddr) cfg.GameServiceAddr = strings.TrimSpace(cfg.GameServiceAddr) if cfg.WalletServiceAddr == "" { cfg.WalletServiceAddr = "127.0.0.1:13004" } if cfg.ActivityServiceAddr == "" { cfg.ActivityServiceAddr = "127.0.0.1:13006" } if cfg.GameServiceAddr == "" { cfg.GameServiceAddr = "127.0.0.1:13008" } runtimeGRPC := cfg.GRPCClient.Runtime() cfg.GRPCClient = GRPCClientConfig{ DefaultTimeout: runtimeGRPC.DefaultTimeout, ConnectTimeout: runtimeGRPC.ConnectTimeout, KeepaliveTime: runtimeGRPC.KeepaliveTime, KeepaliveTimeout: runtimeGRPC.KeepaliveTimeout, RetryMaxAttempts: runtimeGRPC.RetryMaxAttempts, RetryInitialBackoff: runtimeGRPC.RetryInitialBackoff, RetryMaxBackoff: runtimeGRPC.RetryMaxBackoff, RetryBackoffMultiplier: runtimeGRPC.RetryBackoffMultiplier, RetryableStatusCodes: runtimeGRPC.RetryableStatusCodes, } cfg.AppConfig.MySQLDSN = strings.TrimSpace(cfg.AppConfig.MySQLDSN) cfg.Leaderboard.WalletMySQLDSN = strings.TrimSpace(cfg.Leaderboard.WalletMySQLDSN) cfg.LoginRisk.RedisAddr = strings.TrimSpace(cfg.LoginRisk.RedisAddr) cfg.LoginRisk.KeyPrefix = strings.TrimSpace(cfg.LoginRisk.KeyPrefix) if cfg.LoginRisk.Enabled && cfg.LoginRisk.RedisAddr == "" { cfg.LoginRisk.RedisAddr = strings.TrimSpace(cfg.AuthRateLimit.RedisAddr) cfg.LoginRisk.RedisPassword = cfg.AuthRateLimit.RedisPassword cfg.LoginRisk.RedisDB = cfg.AuthRateLimit.RedisDB } if cfg.LoginRisk.KeyPrefix == "" { cfg.LoginRisk.KeyPrefix = "login_risk:ip:" } cfg.TencentCOS.SecretID = strings.TrimSpace(cfg.TencentCOS.SecretID) cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey) cfg.TencentCOS.BucketName = strings.TrimSpace(cfg.TencentCOS.BucketName) cfg.TencentCOS.Region = strings.TrimSpace(cfg.TencentCOS.Region) cfg.TencentCOS.AccessURL = strings.TrimRight(strings.TrimSpace(cfg.TencentCOS.AccessURL), "/") if cfg.TencentCOS.Enabled { if cfg.TencentCOS.SecretID == "" || cfg.TencentCOS.SecretKey == "" || cfg.TencentCOS.BucketName == "" || cfg.TencentCOS.Region == "" || cfg.TencentCOS.AccessURL == "" { return fmt.Errorf("tencent-cos config is incomplete") } if !strings.HasPrefix(cfg.TencentCOS.AccessURL, "http://") && !strings.HasPrefix(cfg.TencentCOS.AccessURL, "https://") { return fmt.Errorf("tencent-cos.access-url must be an absolute url") } } cfg.TencentRTC.RoomIDType = strings.TrimSpace(cfg.TencentRTC.RoomIDType) if cfg.TencentRTC.RoomIDType == "" { cfg.TencentRTC.RoomIDType = tencentrtc.RoomIDTypeString } if cfg.TencentRTC.RoomIDType != tencentrtc.RoomIDTypeString { // Numeric TRTC roomId would fork users into a different room than internal string room_id. return fmt.Errorf("tencent_rtc.room_id_type must be %q", tencentrtc.RoomIDTypeString) } cfg.TencentRTC.AppScene = strings.TrimSpace(cfg.TencentRTC.AppScene) if cfg.TencentRTC.AppScene == "" { cfg.TencentRTC.AppScene = tencentrtc.AppSceneVoiceChatRoom } if cfg.TencentRTC.AppScene != tencentrtc.AppSceneVoiceChatRoom { // v1 has no gateway policy surface for multiple RTC scenes, so reject drift early. return fmt.Errorf("tencent_rtc.app_scene must be %q", tencentrtc.AppSceneVoiceChatRoom) } cfg.TencentRTC.CallbackURL = strings.TrimSpace(cfg.TencentRTC.CallbackURL) cfg.TencentRTC.CallbackSignKey = strings.TrimSpace(cfg.TencentRTC.CallbackSignKey) cfg.TencentIM.AdminIdentifier = strings.TrimSpace(cfg.TencentIM.AdminIdentifier) if cfg.TencentIM.AdminIdentifier == "" { cfg.TencentIM.AdminIdentifier = "administrator" } cfg.TencentIM.Endpoint = strings.TrimSpace(cfg.TencentIM.Endpoint) if cfg.TencentIM.Endpoint == "" { cfg.TencentIM.Endpoint = tencentim.DefaultEndpoint } if cfg.TencentIM.AdminUserSigTTL <= 0 { cfg.TencentIM.AdminUserSigTTL = 24 * time.Hour } if cfg.TencentIM.RequestTimeout <= 0 { cfg.TencentIM.RequestTimeout = 5 * time.Second } cfg.TencentIM.GroupIDPrefix = strings.TrimSpace(cfg.TencentIM.GroupIDPrefix) cfg.TencentIM.CallbackURL = strings.TrimSpace(cfg.TencentIM.CallbackURL) cfg.TencentIM.CallbackAuthToken = strings.TrimSpace(cfg.TencentIM.CallbackAuthToken) return nil } func trimNonEmpty(values []string) []string { out := make([]string, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value != "" { out = append(out, value) } } return out } func upperNonEmpty(values []string) []string { out := make([]string, 0, len(values)) for _, value := range values { value = strings.ToUpper(strings.TrimSpace(value)) if value != "" { out = append(out, value) } } return out }