package http import ( "context" "database/sql" "hyapp/services/gateway-service/internal/transport/http/httpkit" "io" "net/http" "strings" "time" "hyapp/pkg/appcode" "hyapp/pkg/imgroup" "hyapp/pkg/tencentim" "hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/client" "hyapp/services/gateway-service/internal/transport/http/appapi" userv1 "hyapp.local/api/proto/user/v1" ) // Handler 是 gateway 的 HTTP transport adapter。 // 它只依赖跨服务 client,不持有房间状态,也不直接实现房间命令语义。 type Handler struct { roomClient client.RoomClient roomGuardClient client.RoomGuardClient roomQueryClient client.RoomQueryClient userClient client.UserAuthClient userIdentityClient client.UserIdentityClient userProfileClient client.UserProfileClient userSocialClient client.UserSocialClient userDeviceClient client.UserDeviceClient userCountryClient client.UserCountryQueryClient userHostClient client.UserHostClient appRegistryClient client.AppRegistryClient walletClient client.WalletClient messageClient client.MessageInboxClient taskClient client.TaskClient growthLevelClient client.GrowthLevelClient achievementClient client.AchievementClient registrationReward client.RegistrationRewardClient firstRechargeReward client.FirstRechargeRewardClient sevenDayCheckIn client.SevenDayCheckInClient luckyGift client.LuckyGiftClient broadcastClient client.BroadcastClient gameClient client.GameClient appConfigReader appapi.ConfigReader leaderboardWalletDB *sql.DB tencentIM TencentIMConfig tencentIMImporter TencentIMAccountImporter tencentRTC TencentRTCConfig objectUploader ObjectUploader authRateLimitConfig AuthRateLimitConfig authRateLimiter publicAuthRateLimiter loginRiskConfig LoginRiskConfig loginRiskCache loginRiskCache } // TencentIMConfig 是 gateway transport 层签发客户端腾讯云 IM 登录票据所需的配置。 type TencentIMConfig struct { SDKAppID int64 SecretKey string UserSigTTL time.Duration AdminIdentifier string CallbackAuthToken string } // TencentIMAccountImporter 是 gateway 在签发 UserSig 前补偿导入账号的最小边界。 type TencentIMAccountImporter interface { ImportAccount(ctx context.Context, userID int64, nickname string, faceURL string) error } // TencentRTCConfig 是 gateway transport 层签发客户端腾讯 RTC 进房票据所需的配置。 type TencentRTCConfig struct { Enabled bool SDKAppID int64 SecretKey string UserSigTTL time.Duration RoomIDType string AppScene string CallbackSignKey string } // ObjectUploader 是 gateway 上传接口依赖的对象存储最小能力。 type ObjectUploader interface { PutObject(ctx context.Context, key string, reader io.Reader, sizeBytes int64, contentType string) (string, error) } // NewHandler 装配 gateway HTTP handler 的内部依赖。 func NewHandler(roomClient client.RoomClient, userClient ...client.UserAuthClient) *Handler { config := defaultAuthRateLimitConfig() handler := &Handler{roomClient: roomClient, authRateLimitConfig: config, authRateLimiter: newMemoryAuthRateLimiter(config), loginRiskConfig: defaultLoginRiskConfig()} if len(userClient) > 0 { handler.userClient = userClient[0] } return handler } // NewHandlerWithClients 装配需要用户资料接口的 gateway HTTP handler。 func NewHandlerWithClients(roomClient client.RoomClient, userClient client.UserAuthClient, userIdentityClient client.UserIdentityClient, userProfileClient ...client.UserProfileClient) *Handler { handler := &Handler{ roomClient: roomClient, userClient: userClient, userIdentityClient: userIdentityClient, loginRiskConfig: defaultLoginRiskConfig(), } handler.SetAuthRateLimit(defaultAuthRateLimitConfig()) if len(userProfileClient) > 0 { handler.userProfileClient = userProfileClient[0] } return handler } // NewHandlerWithConfig 装配完整 gateway HTTP handler 和外部云服务配置。 func NewHandlerWithConfig(roomClient client.RoomClient, roomGuardClient client.RoomGuardClient, userClient client.UserAuthClient, userIdentityClient client.UserIdentityClient, tencentIM TencentIMConfig, userProfileClient ...client.UserProfileClient) *Handler { handler := &Handler{ roomClient: roomClient, roomGuardClient: roomGuardClient, userClient: userClient, userIdentityClient: userIdentityClient, tencentIM: tencentIM, loginRiskConfig: defaultLoginRiskConfig(), } handler.SetAuthRateLimit(defaultAuthRateLimitConfig()) if len(userProfileClient) > 0 { handler.userProfileClient = userProfileClient[0] } return handler } // SetTencentRTC 注入 RTC 签发配置;缺失或 disabled 时 token endpoint 会 fail-closed。 func (h *Handler) SetTencentRTC(config TencentRTCConfig) { h.tencentRTC = config } // SetTencentIMAccountImporter 注入腾讯 IM 账号导入边界;nil 表示仅签发 UserSig,不做 REST 补偿。 func (h *Handler) SetTencentIMAccountImporter(importer TencentIMAccountImporter) { h.tencentIMImporter = importer } // SetObjectUploader 注入对象存储上传实现;生产环境使用腾讯云 COS。 func (h *Handler) SetObjectUploader(uploader ObjectUploader) { h.objectUploader = uploader } // SetRoomQueryClient 注入 room-service 读模型查询 client。 func (h *Handler) SetRoomQueryClient(roomQueryClient client.RoomQueryClient) { h.roomQueryClient = roomQueryClient } // SetUserCountryQueryClient 注入注册页国家列表查询 client。 func (h *Handler) SetUserCountryQueryClient(userCountryClient client.UserCountryQueryClient) { h.userCountryClient = userCountryClient } // SetUserDeviceClient 注入 user-service 设备推送 token client。 func (h *Handler) SetUserDeviceClient(userDeviceClient client.UserDeviceClient) { h.userDeviceClient = userDeviceClient } // SetUserHostClient 注入 user-service host domain 身份查询 client。 func (h *Handler) SetUserHostClient(userHostClient client.UserHostClient) { h.userHostClient = userHostClient } // SetUserSocialClient 注入 user-service 访问、关注和好友关系 client。 func (h *Handler) SetUserSocialClient(userSocialClient client.UserSocialClient) { h.userSocialClient = userSocialClient } // SetAppRegistryClient 注入 App 注册表解析 client;gateway 用它把包名转换成内部 app_code。 func (h *Handler) SetAppRegistryClient(appRegistryClient client.AppRegistryClient) { h.appRegistryClient = appRegistryClient } // SetWalletClient 注入 wallet-service 余额查询 client。 func (h *Handler) SetWalletClient(walletClient client.WalletClient) { h.walletClient = walletClient } // SetMessageInboxClient 注入 activity-service message inbox client。 func (h *Handler) SetMessageInboxClient(messageClient client.MessageInboxClient) { h.messageClient = messageClient } // SetTaskClient 注入 activity-service task client。 func (h *Handler) SetTaskClient(taskClient client.TaskClient) { h.taskClient = taskClient } // SetGrowthLevelClient 注入 activity-service 等级查询 client。 func (h *Handler) SetGrowthLevelClient(growthLevelClient client.GrowthLevelClient) { h.growthLevelClient = growthLevelClient } // SetAchievementClient 注入 activity-service 成就和徽章 client。 func (h *Handler) SetAchievementClient(achievementClient client.AchievementClient) { h.achievementClient = achievementClient } // SetRegistrationRewardClient 注入 activity-service 注册奖励查询 client。 func (h *Handler) SetRegistrationRewardClient(registrationReward client.RegistrationRewardClient) { h.registrationReward = registrationReward } // SetFirstRechargeRewardClient 注入 activity-service 首冲奖励查询 client。 func (h *Handler) SetFirstRechargeRewardClient(firstRechargeReward client.FirstRechargeRewardClient) { h.firstRechargeReward = firstRechargeReward } // SetSevenDayCheckInClient 注入 activity-service 七日签到 client。 func (h *Handler) SetSevenDayCheckInClient(sevenDayCheckIn client.SevenDayCheckInClient) { h.sevenDayCheckIn = sevenDayCheckIn } // SetLuckyGiftClient 注入 activity-service 幸运礼物资格检查 client。 func (h *Handler) SetLuckyGiftClient(luckyGift client.LuckyGiftClient) { h.luckyGift = luckyGift } // SetBroadcastClient 注入 activity-service 播报群成员关系 client。 func (h *Handler) SetBroadcastClient(broadcastClient client.BroadcastClient) { h.broadcastClient = broadcastClient } // SetGameClient 注入 game-service 游戏列表、启动和回调 client。 func (h *Handler) SetGameClient(gameClient client.GameClient) { h.gameClient = gameClient } // SetLeaderboardWalletDB 注入 App 榜单只读钱包库连接。 func (h *Handler) SetLeaderboardWalletDB(db *sql.DB) { h.leaderboardWalletDB = db } // SetAppConfigReader 注入后台 App 配置读取依赖。 func (h *Handler) SetAppConfigReader(reader appapi.ConfigReader) { h.appConfigReader = reader } func normalizeCommandID(commandID string) string { // command_id 是客户端按用户动作生成的写命令幂等键;gateway 只裁剪空白,不自动补值。 return strings.TrimSpace(commandID) } // decode 统一处理 HTTP JSON 入参解析失败分支。 // gateway 在这里拒绝非法 JSON,不做房间业务校验,避免把 Room Cell 规则复制到入口层。 func decode(writer http.ResponseWriter, request *http.Request, out any) bool { if err := httpkit.DecodeJSON(request.Body, out); err != nil { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidJSON, "invalid request body") return false } return true } // write 统一把内部 gRPC 调用结果转换为 HTTP JSON 响应。 // 首版不在 gateway 细分 gRPC 错误码,避免入口层提前耦合 room-service 的业务错误模型。 func write(writer http.ResponseWriter, request *http.Request, body any, err error) { if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, body) } func (h *Handler) issueTencentIMUserSig(writer http.ResponseWriter, request *http.Request) { userID := auth.UserIDFromContext(request.Context()) if userID <= 0 { // 正常路径已由 auth middleware 拦截;这里兜底避免签发匿名 IM 票据。 httpkit.WriteError(writer, request, http.StatusUnauthorized, httpkit.CodeUnauthorized, "unauthorized") return } if h.userProfileClient == nil { // 播报群 join 列表必须来自 user-service 当前资料,不能靠 token 快照猜区域。 httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error") return } userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ Meta: authRequestMeta(request, ""), UserId: userID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } user := userResp.GetUser() if user == nil { httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error") return } if !user.GetProfileCompleted() { httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodeProfileRequired, "profile required") return } app := appcode.FromContext(request.Context()) if user.GetAppCode() != "" && appcode.Normalize(user.GetAppCode()) != app { // UserSig 是 IM 登录凭证,不能给跨 app_code 用户签发,否则会绕过播报群租户隔离。 httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied") return } result, err := tencentim.GenerateUserSig(tencentim.UserSigConfig{ SDKAppID: h.tencentIM.SDKAppID, SecretKey: h.tencentIM.SecretKey, TTL: h.tencentIM.UserSigTTL, }, tencentim.FormatUserID(userID), time.Now().UTC()) if err != nil { if h.tencentIMImporter != nil { _ = h.tencentIMImporter.ImportAccount(request.Context(), userID, user.GetUsername(), user.GetAvatar()) result, err = tencentim.GenerateUserSig(tencentim.UserSigConfig{ SDKAppID: h.tencentIM.SDKAppID, SecretKey: h.tencentIM.SecretKey, TTL: h.tencentIM.UserSigTTL, }, tencentim.FormatUserID(userID), time.Now().UTC()) } } if err != nil { // 缺少 SDKAppID 或 SecretKey 属于服务端配置错误,不能返回假票据给客户端。 httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error") return } if h.tencentIMImporter != nil { if err := h.tencentIMImporter.ImportAccount(request.Context(), userID, user.GetUsername(), user.GetAvatar()); err != nil { // UserSig 只证明签名有效;账号未导入时 SDK C2C 仍会失败,因此这里必须 fail-closed。 httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error") return } } joinGroups, ok := imJoinGroupsForUser(app, user.GetRegionId()) if !ok { // 生成 join_groups 失败代表服务端 app_code/region_id 配置异常,不能返回缺失群列表让客户端自行猜。 httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } httpkit.WriteOK(writer, request, tencentIMUserSigResponse{ SDKAppID: result.SDKAppID, UserID: result.UserID, UserSig: result.UserSig, ExpireAtMS: result.ExpireAtMS, JoinGroups: joinGroups, }) } type tencentIMUserSigResponse struct { SDKAppID int64 `json:"sdk_app_id"` UserID string `json:"user_id"` UserSig string `json:"user_sig"` ExpireAtMS int64 `json:"expire_at_ms"` JoinGroups []tencentIMJoinGroup `json:"join_groups"` } type tencentIMJoinGroup struct { GroupID string `json:"group_id"` Type string `json:"type"` RegionID int64 `json:"region_id,omitempty"` } func imJoinGroupsForUser(app string, regionID int64) ([]tencentIMJoinGroup, bool) { globalGroupID, err := imgroup.GlobalBroadcastGroupID(app) if err != nil { return nil, false } // 全局群始终返回;实际消息量应很低,主要用于全服级运营通知或极少数全局事件。 groups := []tencentIMJoinGroup{{ GroupID: globalGroupID, Type: string(imgroup.KindGlobalBroadcast), }} if regionID > 0 { // 区域群来自 user-service 当前 region_id,客户端不能自选区域群;区域变更后下一次 UserSig 返回新群。 regionGroupID, err := imgroup.RegionBroadcastGroupID(app, regionID) if err != nil { return nil, false } groups = append(groups, tencentIMJoinGroup{ GroupID: regionGroupID, Type: string(imgroup.KindRegionBroadcast), RegionID: regionID, }) } return groups, true }