diff --git a/services/game-service/configs/config.docker.yaml b/services/game-service/configs/config.docker.yaml index a43f577d..0f6689fb 100644 --- a/services/game-service/configs/config.docker.yaml +++ b/services/game-service/configs/config.docker.yaml @@ -8,6 +8,16 @@ wallet_service_addr: "wallet-service:13004" user_service_addr: "user-service:13005" activity_service_addr: "activity-service:13006" launch_session_ttl: "15m" +tencent_im: + # Docker/testbox 联调房内猜拳 PK 房间群消息;必须和 gateway/room 使用同一个 IM 应用。 + enabled: true + sdk_app_id: 20036101 + secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c" + admin_identifier: "900100100" + admin_user_sig_ttl: "24h" + endpoint: "adminapisgp.im.qcloud.com" + group_type: "ChatRoom" + request_timeout: "5s" rocketmq: enabled: true name_servers: ["rocketmq-namesrv:9876"] diff --git a/services/game-service/configs/config.tencent.example.yaml b/services/game-service/configs/config.tencent.example.yaml index 3e00bcb8..2c4093d7 100644 --- a/services/game-service/configs/config.tencent.example.yaml +++ b/services/game-service/configs/config.tencent.example.yaml @@ -8,6 +8,16 @@ wallet_service_addr: "wallet-service:13004" user_service_addr: "user-service:13005" activity_service_addr: "10.2.1.16:13006" launch_session_ttl: "15m" +tencent_im: + # 房内猜拳发起、应战、揭晓和结算事件都走腾讯 IM 房间群自定义消息。 + enabled: true + sdk_app_id: 1400000000 + secret_key: "TENCENT_IM_SECRET_KEY" + admin_identifier: "TENCENT_IM_ADMIN_IDENTIFIER" + admin_user_sig_ttl: "24h" + endpoint: "adminapisgp.im.qcloud.com" + group_type: "ChatRoom" + request_timeout: "5s" rocketmq: enabled: true name_servers: [] diff --git a/services/game-service/configs/config.yaml b/services/game-service/configs/config.yaml index 7cf66f6a..c99e1a7c 100644 --- a/services/game-service/configs/config.yaml +++ b/services/game-service/configs/config.yaml @@ -8,6 +8,16 @@ wallet_service_addr: "127.0.0.1:13004" user_service_addr: "127.0.0.1:13005" activity_service_addr: "127.0.0.1:13006" launch_session_ttl: "15m" +tencent_im: + # 本地联调房内猜拳 PK 房间群消息;必须和 gateway/room 使用同一个 IM 应用。 + enabled: true + sdk_app_id: 20036101 + secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c" + admin_identifier: "900100100" + admin_user_sig_ttl: "24h" + endpoint: "adminapisgp.im.qcloud.com" + group_type: "ChatRoom" + request_timeout: "5s" rocketmq: enabled: true name_servers: ["127.0.0.1:19876"] diff --git a/services/game-service/internal/app/app.go b/services/game-service/internal/app/app.go index 71e738fa..c41d6ba9 100644 --- a/services/game-service/internal/app/app.go +++ b/services/game-service/internal/app/app.go @@ -98,14 +98,21 @@ func New(cfg config.Config) (*App, error) { svc := gameservice.New(gameservice.Config{LaunchSessionTTL: cfg.LaunchSessionTTL}, repo, walletClient, userClient, client.NewActivityGrowthClient(activityConn)) // 骰子服务复用同一个 MySQL repository 和 wallet client;局事实走 dice 表,金币事实仍走 game_orders + wallet-service。 diceSvc := diceservice.New(diceservice.Config{}, repo, walletClient) - roomRPSPublisher, err := roomrpsservice.NewTencentIMPublisherFromEnv() - if err != nil { - _ = listener.Close() - _ = activityConn.Close() - _ = userConn.Close() - _ = walletConn.Close() - _ = repo.Close() - return nil, err + var roomRPSPublisher roomrpsservice.Publisher + if cfg.TencentIM.Enabled { + // 房内 RPS 的“发起 PK”需要同步投递腾讯 IM 房间群消息;启动阶段先创建 REST client,避免运行中才发现配置缺失。 + roomRPSPublisher, err = roomrpsservice.NewTencentIMPublisher(cfg.TencentIM.RESTConfig()) + if err != nil { + _ = listener.Close() + _ = activityConn.Close() + _ = userConn.Close() + _ = walletConn.Close() + _ = repo.Close() + return nil, err + } + } else { + // 显式关闭时保留 HTTP/RPC 功能,便于纯本地测试;测试服和线上必须在 YAML 中打开,否则用户看不到房内 PK。 + logx.Warn(context.Background(), "room_rps_im_disabled", slog.String("reason", "tencent_im.enabled=false")) } // 房内猜拳独立于骰子和独立猜拳:配置、挑战状态机和 IM 事件全部由 room-rps service 承接,transport 只做 RPC 适配。 roomRPSSvc := roomrpsservice.New(roomrpsservice.Config{}, userClient, roomRPSPublisher) diff --git a/services/game-service/internal/config/config.go b/services/game-service/internal/config/config.go index 02f23856..5596e5f5 100644 --- a/services/game-service/internal/config/config.go +++ b/services/game-service/internal/config/config.go @@ -3,11 +3,13 @@ package config import ( "errors" "fmt" + "net/http" "strings" "time" "hyapp/pkg/configx" "hyapp/pkg/logx" + "hyapp/pkg/tencentim" ) // Config 描述 game-service 启动所需的最小依赖。 @@ -17,15 +19,58 @@ type Config struct { Environment string `yaml:"environment"` GRPCAddr string `yaml:"grpc_addr"` // HealthHTTPAddr 是给 CLB/发布脚本探活使用的 HTTP 监听地址,不承载业务流量。 - HealthHTTPAddr string `yaml:"health_http_addr"` - MySQLDSN string `yaml:"mysql_dsn"` - WalletServiceAddr string `yaml:"wallet_service_addr"` - UserServiceAddr string `yaml:"user_service_addr"` - ActivityServiceAddr string `yaml:"activity_service_addr"` - LaunchSessionTTL time.Duration `yaml:"launch_session_ttl"` - RocketMQ RocketMQConfig `yaml:"rocketmq"` - OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"` - Log logx.Config `yaml:"log"` + HealthHTTPAddr string `yaml:"health_http_addr"` + MySQLDSN string `yaml:"mysql_dsn"` + WalletServiceAddr string `yaml:"wallet_service_addr"` + UserServiceAddr string `yaml:"user_service_addr"` + ActivityServiceAddr string `yaml:"activity_service_addr"` + LaunchSessionTTL time.Duration `yaml:"launch_session_ttl"` + // TencentIM 是房内猜拳向房间群投递 PK 事件的腾讯 IM REST 配置;缺配置时必须显式关闭,不能靠环境变量隐式漂移。 + TencentIM TencentIMConfig `yaml:"tencent_im"` + RocketMQ RocketMQConfig `yaml:"rocketmq"` + OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"` + Log logx.Config `yaml:"log"` +} + +// TencentIMConfig 描述 game-service 发房内 RPS 群自定义消息需要的服务端 IM 配置。 +type TencentIMConfig struct { + // Enabled 控制房内 RPS 是否向腾讯 IM 房间群投递事件;开启后缺少密钥或管理员账号应启动失败。 + Enabled bool `yaml:"enabled"` + // SDKAppID 是腾讯云 IM 应用 ID,必须和 gateway 签发 UserSig 使用同一个应用。 + SDKAppID int64 `yaml:"sdk_app_id"` + // SecretKey 只用于服务端签 admin UserSig,不能出现在响应或业务日志里。 + SecretKey string `yaml:"secret_key"` + // AdminIdentifier 是服务端 REST 发房间 PK 消息的管理员账号。 + AdminIdentifier string `yaml:"admin_identifier"` + // AdminUserSigTTL 控制 REST 调用签名有效期,默认 24h。 + AdminUserSigTTL time.Duration `yaml:"admin_user_sig_ttl"` + // Endpoint 是腾讯 IM REST 地域入口,例如 adminapisgp.im.qcloud.com。 + Endpoint string `yaml:"endpoint"` + // GroupType 是房间群类型;房内 RPS 复用房间群,默认 ChatRoom。 + GroupType string `yaml:"group_type"` + // RequestTimeout 是单次腾讯 IM REST 调用预算,避免创建 PK 时无限等待外部服务。 + RequestTimeout time.Duration `yaml:"request_timeout"` +} + +// RESTConfig 收敛为共享腾讯 IM client 配置,调用方不用重复处理默认超时和群类型。 +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: c.GroupType, + HTTPClient: &http.Client{Timeout: timeout}, + } } type RocketMQConfig struct { @@ -68,7 +113,14 @@ func Default() Config { UserServiceAddr: "127.0.0.1:13005", ActivityServiceAddr: "127.0.0.1:13006", LaunchSessionTTL: 15 * time.Minute, - RocketMQ: defaultRocketMQConfig(), + TencentIM: TencentIMConfig{ + AdminIdentifier: "administrator", + AdminUserSigTTL: 24 * time.Hour, + Endpoint: tencentim.DefaultEndpoint, + GroupType: tencentim.DefaultGroupType, + RequestTimeout: 5 * time.Second, + }, + RocketMQ: defaultRocketMQConfig(), OutboxWorker: OutboxWorkerConfig{ Enabled: false, PollInterval: time.Second, @@ -138,6 +190,10 @@ func (cfg *Config) Normalize() error { if cfg.LaunchSessionTTL <= 0 { cfg.LaunchSessionTTL = 15 * time.Minute } + cfg.normalizeTencentIM() + if cfg.TencentIM.Enabled && (cfg.TencentIM.SDKAppID <= 0 || cfg.TencentIM.SecretKey == "" || cfg.TencentIM.AdminIdentifier == "") { + return errors.New("tencent_im enabled requires sdk_app_id, secret_key and admin_identifier") + } cfg.RocketMQ = normalizeRocketMQConfig(cfg.RocketMQ) if cfg.OutboxWorker.PollInterval <= 0 { cfg.OutboxWorker.PollInterval = time.Second @@ -162,6 +218,28 @@ func (cfg *Config) Normalize() error { return nil } +func (cfg *Config) normalizeTencentIM() { + cfg.TencentIM.SecretKey = strings.TrimSpace(cfg.TencentIM.SecretKey) + 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 + } + cfg.TencentIM.GroupType = strings.TrimSpace(cfg.TencentIM.GroupType) + if cfg.TencentIM.GroupType == "" { + cfg.TencentIM.GroupType = tencentim.DefaultGroupType + } + if cfg.TencentIM.AdminUserSigTTL <= 0 { + cfg.TencentIM.AdminUserSigTTL = 24 * time.Hour + } + if cfg.TencentIM.RequestTimeout <= 0 { + cfg.TencentIM.RequestTimeout = 5 * time.Second + } +} + func defaultRocketMQConfig() RocketMQConfig { return RocketMQConfig{ Enabled: false, diff --git a/services/game-service/internal/config/config_test.go b/services/game-service/internal/config/config_test.go index acddd405..9b0b498e 100644 --- a/services/game-service/internal/config/config_test.go +++ b/services/game-service/internal/config/config_test.go @@ -25,3 +25,44 @@ func TestLoadConfigsKeepActivityServiceAddr(t *testing.T) { }) } } + +func TestLoadConfigsEnableRoomRPSIM(t *testing.T) { + tests := []struct { + name string + path string + }{ + {name: "local", path: "../../configs/config.yaml"}, + {name: "docker", path: "../../configs/config.docker.yaml"}, + {name: "tencent", path: "../../configs/config.tencent.example.yaml"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := Load(tt.path) + if err != nil { + t.Fatalf("Load %s config failed: %v", tt.name, err) + } + if !cfg.TencentIM.Enabled { + t.Fatalf("room-rps tencent_im must be enabled in %s config", tt.name) + } + if cfg.TencentIM.SDKAppID <= 0 || cfg.TencentIM.SecretKey == "" || cfg.TencentIM.AdminIdentifier == "" { + t.Fatalf("room-rps tencent_im config is incomplete: sdk_app_id_set=%t secret_key_set=%t admin_identifier_set=%t", cfg.TencentIM.SDKAppID > 0, cfg.TencentIM.SecretKey != "", cfg.TencentIM.AdminIdentifier != "") + } + if cfg.TencentIM.Endpoint != "adminapisgp.im.qcloud.com" { + t.Fatalf("room-rps tencent_im endpoint mismatch: %q", cfg.TencentIM.Endpoint) + } + }) + } +} + +func TestNormalizeRejectsEnabledIncompleteTencentIM(t *testing.T) { + cfg := Default() + cfg.TencentIM.Enabled = true + cfg.TencentIM.SDKAppID = 0 + cfg.TencentIM.SecretKey = "" + cfg.TencentIM.AdminIdentifier = "" + + if err := cfg.Normalize(); err == nil { + t.Fatal("Normalize should reject enabled incomplete tencent_im config") + } +} diff --git a/services/game-service/internal/service/roomrps/service.go b/services/game-service/internal/service/roomrps/service.go index d17a047c..43d8c75b 100644 --- a/services/game-service/internal/service/roomrps/service.go +++ b/services/game-service/internal/service/roomrps/service.go @@ -6,8 +6,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "net/http" - "os" "sort" "strconv" "strings" @@ -101,33 +99,10 @@ func New(config Config, user UserClient, publisher Publisher) *Service { } } -// NewTencentIMPublisherFromEnv 按环境变量创建真实腾讯 IM publisher。 -// 密钥、账号和地域入口都属于敏感或部署差异配置,因此这里只读取环境,不在 game-service 默认配置里伪造。 -func NewTencentIMPublisherFromEnv() (Publisher, error) { - sdkAppID, _ := strconv.ParseInt(strings.TrimSpace(os.Getenv("TENCENT_IM_SDK_APP_ID")), 10, 64) - secretKey := strings.TrimSpace(os.Getenv("TENCENT_IM_SECRET_KEY")) - adminIdentifier := strings.TrimSpace(os.Getenv("TENCENT_IM_ADMIN_IDENTIFIER")) - if sdkAppID <= 0 || secretKey == "" || adminIdentifier == "" { - // 未配置 IM 时不阻断本地服务启动;真实 IM smoke 会显式带环境变量,并要求事件投递成功。 - return nil, nil - } - ttl := time.Hour - if raw := strings.TrimSpace(os.Getenv("TENCENT_IM_ADMIN_USERSIG_TTL")); raw != "" { - parsed, err := time.ParseDuration(raw) - if err != nil || parsed <= 0 { - return nil, fmt.Errorf("TENCENT_IM_ADMIN_USERSIG_TTL is invalid") - } - ttl = parsed - } - return tencentim.NewRESTClient(tencentim.RESTConfig{ - SDKAppID: sdkAppID, - SecretKey: secretKey, - AdminIdentifier: adminIdentifier, - AdminUserSigTTL: ttl, - Endpoint: envOr("TENCENT_IM_ENDPOINT", tencentim.DefaultEndpoint), - GroupType: tencentim.DefaultGroupType, - HTTPClient: &http.Client{Timeout: 10 * time.Second}, - }) +// NewTencentIMPublisher 创建真实腾讯 IM publisher。 +// game-service 的房内 PK 发起接口是同步返回给客户端的入口,因此 IM 配置必须来自已校验的 YAML,而不是进程环境变量的隐式副作用。 +func NewTencentIMPublisher(config tencentim.RESTConfig) (Publisher, error) { + return tencentim.NewRESTClient(config) } func (s *Service) GetConfig(ctx context.Context, app string) (*gamev1.RoomRPSConfig, int64, error) { @@ -768,11 +743,3 @@ func newChallengeID(app string) string { } return fmt.Sprintf("rps_%s_%d_%s", appcode.Normalize(app), time.Now().UnixMilli(), hex.EncodeToString(entropy[:])) } - -func envOr(key string, fallback string) string { - value := strings.TrimSpace(os.Getenv(key)) - if value == "" { - return fallback - } - return value -}