356 lines
13 KiB
Go
356 lines
13 KiB
Go
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
|
||
broadcastClient client.BroadcastClient
|
||
gameClient client.GameClient
|
||
appConfigReader appapi.ConfigReader
|
||
leaderboardWalletDB *sql.DB
|
||
tencentIM TencentIMConfig
|
||
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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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 {
|
||
// 缺少 SDKAppID 或 SecretKey 属于服务端配置错误,不能返回假票据给客户端。
|
||
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
|
||
}
|