package gameapi import ( "bytes" "encoding/json" "io" "log/slog" "net/http" "strconv" "strings" "time" gamev1 "hyapp.local/api/proto/game/v1" userv1 "hyapp.local/api/proto/user/v1" "hyapp/pkg/appcode" "hyapp/pkg/logx" "hyapp/pkg/xerr" "hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/transport/http/httpkit" ) const ( diceAccessTokenRenewBefore = 10 * time.Minute hyappAccessTokenHeader = "X-Hyapp-Access-Token" hyappTokenTypeHeader = "X-Hyapp-Token-Type" hyappExpiresInHeader = "X-Hyapp-Expires-In" selfGameIDDice = "dice" selfGameIDRock = "rock" zgameCallbackPlatformCode = "zgame" ) func (h *Handler) listGames(writer http.ResponseWriter, request *http.Request) { if h.gameClient == nil || h.userProfileClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } userID := auth.UserIDFromContext(request.Context()) userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ Meta: httpkit.UserMeta(request, ""), UserId: userID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } resp, err := h.gameClient.ListGames(request.Context(), &gamev1.ListGamesRequest{ Meta: gameMeta(request), UserId: userID, Scene: strings.TrimSpace(request.URL.Query().Get("scene")), RoomId: strings.TrimSpace(request.URL.Query().Get("room_id")), RegionId: userResp.GetUser().GetRegionId(), Language: requestLanguage(request), ClientPlatform: httpkit.FirstHeader(request, "X-App-Platform", "X-Platform"), }) httpkit.Write(writer, request, gameListDataFromProto(resp), err) } func (h *Handler) listRecentGames(writer http.ResponseWriter, request *http.Request) { if h.gameClient == nil || h.userProfileClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } pageSize, ok := recentGamePageSize(request) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } userID := auth.UserIDFromContext(request.Context()) userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ Meta: httpkit.UserMeta(request, ""), UserId: userID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } // 最近常玩仍传 scene、room、region、language 和 platform,让 game-service 在真实启动记录之外, // 继续按当前房间场景和用户区域过滤,避免 App 展示用户已不能进入的游戏。 resp, err := h.gameClient.ListRecentGames(request.Context(), &gamev1.ListRecentGamesRequest{ Meta: gameMeta(request), UserId: userID, Scene: strings.TrimSpace(request.URL.Query().Get("scene")), RoomId: strings.TrimSpace(request.URL.Query().Get("room_id")), RegionId: userResp.GetUser().GetRegionId(), Language: requestLanguage(request), ClientPlatform: httpkit.FirstHeader(request, "X-App-Platform", "X-Platform"), PageSize: pageSize, }) httpkit.Write(writer, request, gameListDataFromProto(resp), err) } func (h *Handler) listExploreWinners(writer http.ResponseWriter, request *http.Request) { if h.gameClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } pageSize, ok := exploreWinnerPageSize(request) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } // Square 播报只依赖当前 App,不接受客户端传 app_code;game-service 会保证首批机器人获胜事实已落库。 resp, err := h.gameClient.ListExploreWinners(request.Context(), &gamev1.ListExploreWinnersRequest{ Meta: gameMeta(request), PageSize: pageSize, }) data := exploreWinnerListDataFromProto(resp) // 获胜事实归 game-service,头像昵称归 user-service;资料补全失败时仍返回游戏事实,Flutter 用 user_id 和本地图做兜底。 h.enrichExploreWinners(request, data.Items) httpkit.Write(writer, request, data, err) } func (h *Handler) getBridgeScript(writer http.ResponseWriter, request *http.Request) { if h.gameClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } // 桥接脚本配置是 App 级启动预热信息,不依赖房间和用户区域;保持接口轻量,避免拖慢进 App 主流程。 resp, err := h.gameClient.GetBridgeScript(request.Context(), &gamev1.GetBridgeScriptRequest{ Meta: gameMeta(request), }) httpkit.Write(writer, request, gameBridgeScriptDataFromProto(resp), err) } func (h *Handler) launchGame(writer http.ResponseWriter, request *http.Request) { if h.gameClient == nil || h.userProfileClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } gameID := strings.TrimSpace(request.PathValue("game_id")) if gameID == "" { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } var body struct { Scene string `json:"scene"` RoomID string `json:"room_id"` } if !httpkit.Decode(writer, request, &body) { return } userID := auth.UserIDFromContext(request.Context()) userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ Meta: httpkit.UserMeta(request, ""), UserId: userID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } resp, err := h.gameClient.LaunchGame(request.Context(), &gamev1.LaunchGameRequest{ Meta: gameMeta(request), UserId: userID, DisplayUserId: userResp.GetUser().GetDisplayUserId(), GameId: gameID, Scene: body.Scene, RoomId: body.RoomID, AccessToken: bearerAccessToken(request), CountryId: userResp.GetUser().GetCountryId(), }) httpkit.Write(writer, request, gameLaunchDataFromProto(resp), err) } func (h *Handler) getDiceConfig(writer http.ResponseWriter, request *http.Request) { h.getSelfGameConfig(writer, request, selfGameIDDice, false) } func (h *Handler) getRPSConfig(writer http.ResponseWriter, request *http.Request) { h.getSelfGameConfig(writer, request, selfGameIDRock, true) } func (h *Handler) getSelfGameConfig(writer http.ResponseWriter, request *http.Request, defaultGameID string, fixedGameID bool) { if h.gameClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } gameID, ok := selfGameIDFromInput(writer, request, request.URL.Query().Get("game_id"), defaultGameID, fixedGameID) if !ok { return } // 配置接口只返回当前 App 的可下注档位和费率,不接受客户端传 app_code;rps 路径固定映射 rock,避免误读 room-rps 或 dice。 resp, err := h.gameClient.GetDiceConfig(request.Context(), &gamev1.GetDiceConfigRequest{ Meta: gameMeta(request), GameId: gameID, }) if err == nil { h.renewDiceAccessTokenIfNeeded(writer, request) } httpkit.Write(writer, request, diceConfigDataFromProto(resp), err) } func (h *Handler) matchDice(writer http.ResponseWriter, request *http.Request) { h.matchSelfGame(writer, request, selfGameIDDice, false) } func (h *Handler) matchRPS(writer http.ResponseWriter, request *http.Request) { h.matchSelfGame(writer, request, selfGameIDRock, true) } func (h *Handler) matchSelfGame(writer http.ResponseWriter, request *http.Request, defaultGameID string, fixedGameID bool) { if h.gameClient == nil || h.userProfileClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } var body struct { GameID string `json:"game_id"` RoomID string `json:"room_id"` StakeCoin int64 `json:"stake_coin"` Gesture string `json:"gesture"` RPSGesture string `json:"rps_gesture"` } if !httpkit.Decode(writer, request, &body) { return } gameID, ok := selfGameIDFromInput(writer, request, body.GameID, defaultGameID, fixedGameID) if !ok { return } if body.StakeCoin <= 0 { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } rpsGesture, ok := selfGameRPSGestureFromInput(writer, request, gameID, firstRPSGestureInput(body.Gesture, body.RPSGesture), true) if !ok { return } userID := auth.UserIDFromContext(request.Context()) userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ Meta: httpkit.UserMeta(request, ""), UserId: userID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } resp, err := h.gameClient.MatchDice(request.Context(), &gamev1.MatchDiceRequest{ Meta: gameMeta(request), UserId: userID, GameId: gameID, RoomId: strings.TrimSpace(body.RoomID), RegionId: userResp.GetUser().GetRegionId(), CountryId: userResp.GetUser().GetCountryId(), StakeCoin: body.StakeCoin, RpsGesture: rpsGesture, }) h.writeDiceMatch(writer, request, resp, err) } func (h *Handler) createDiceMatch(writer http.ResponseWriter, request *http.Request) { h.createSelfGameMatch(writer, request, selfGameIDDice, false) } func (h *Handler) createRPSMatch(writer http.ResponseWriter, request *http.Request) { h.createSelfGameMatch(writer, request, selfGameIDRock, true) } func (h *Handler) createSelfGameMatch(writer http.ResponseWriter, request *http.Request, defaultGameID string, fixedGameID bool) { if h.gameClient == nil || h.userProfileClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } // gateway 只接受 H5 的展示输入;user_id、region_id、app_code 和 request_id 都由网关上下文补齐。 var body struct { GameID string `json:"game_id"` RoomID string `json:"room_id"` StakeCoin int64 `json:"stake_coin"` MinPlayers int32 `json:"min_players"` MaxPlayers int32 `json:"max_players"` Gesture string `json:"gesture"` RPSGesture string `json:"rps_gesture"` } if !httpkit.Decode(writer, request, &body) { return } gameID, ok := selfGameIDFromInput(writer, request, body.GameID, defaultGameID, fixedGameID) if !ok { return } if body.StakeCoin <= 0 { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } rpsGesture, ok := selfGameRPSGestureFromInput(writer, request, gameID, firstRPSGestureInput(body.Gesture, body.RPSGesture), true) if !ok { return } userID := auth.UserIDFromContext(request.Context()) userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ Meta: httpkit.UserMeta(request, ""), UserId: userID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } // region_id 来自 user-service 当前用户资料,不信任客户端 body,避免跨区域创建可结算局。 resp, err := h.gameClient.CreateDiceMatch(request.Context(), &gamev1.CreateDiceMatchRequest{ Meta: gameMeta(request), UserId: userID, GameId: gameID, RoomId: strings.TrimSpace(body.RoomID), RegionId: userResp.GetUser().GetRegionId(), CountryId: userResp.GetUser().GetCountryId(), StakeCoin: body.StakeCoin, MinPlayers: body.MinPlayers, MaxPlayers: body.MaxPlayers, RpsGesture: rpsGesture, }) h.writeDiceMatch(writer, request, resp, err) } func (h *Handler) joinDiceMatch(writer http.ResponseWriter, request *http.Request) { h.joinSelfGameMatch(writer, request, selfGameIDDice) } func (h *Handler) joinRPSMatch(writer http.ResponseWriter, request *http.Request) { h.joinSelfGameMatch(writer, request, selfGameIDRock) } func (h *Handler) joinSelfGameMatch(writer http.ResponseWriter, request *http.Request, expectedGameID string) { if h.gameClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } matchID := strings.TrimSpace(request.PathValue("match_id")) if matchID == "" { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } rpsGesture := "" if expectedGameID == selfGameIDRock { var body struct { Gesture string `json:"gesture"` RPSGesture string `json:"rps_gesture"` } if !httpkit.Decode(writer, request, &body) { return } var ok bool rpsGesture, ok = selfGameRPSGestureFromInput(writer, request, expectedGameID, firstRPSGestureInput(body.Gesture, body.RPSGesture), true) if !ok { return } } // join 不接受 body 里的 user_id 或 stake_coin;参与者身份来自 JWT,押注金额来自 match 当前事实。 resp, err := h.gameClient.JoinDiceMatch(request.Context(), &gamev1.JoinDiceMatchRequest{ Meta: gameMeta(request), UserId: auth.UserIDFromContext(request.Context()), MatchId: matchID, // rps 入口复用 self-game match 表,但必须把路径期望的 game_id 传给 game-service 复核,避免拿 dice match_id 串到 rock 流程。 GameId: expectedGameID, RpsGesture: rpsGesture, }) h.writeDiceMatch(writer, request, resp, err) } func (h *Handler) getDiceMatch(writer http.ResponseWriter, request *http.Request) { h.getSelfGameMatch(writer, request, selfGameIDDice) } func (h *Handler) getRPSMatch(writer http.ResponseWriter, request *http.Request) { h.getSelfGameMatch(writer, request, selfGameIDRock) } func (h *Handler) getSelfGameMatch(writer http.ResponseWriter, request *http.Request, expectedGameID string) { if h.gameClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } matchID := strings.TrimSpace(request.PathValue("match_id")) if matchID == "" { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } // 查询接口只是透传当前局事实,前端轮询或 IM 事件后的刷新都可以复用这条路径。 resp, err := h.gameClient.GetDiceMatch(request.Context(), &gamev1.GetDiceMatchRequest{ Meta: gameMeta(request), UserId: auth.UserIDFromContext(request.Context()), MatchId: matchID, // 查询同样带上路径 game_id,防止 rps 页面仅凭 match_id 读到 dice 局详情并继续轮询。 GameId: expectedGameID, }) h.writeDiceMatch(writer, request, resp, err) } func (h *Handler) rollDiceMatch(writer http.ResponseWriter, request *http.Request) { h.rollSelfGameMatch(writer, request, selfGameIDDice) } func (h *Handler) rollRPSMatch(writer http.ResponseWriter, request *http.Request) { h.rollSelfGameMatch(writer, request, selfGameIDRock) } func (h *Handler) rollSelfGameMatch(writer http.ResponseWriter, request *http.Request, expectedGameID string) { if h.gameClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } matchID := strings.TrimSpace(request.PathValue("match_id")) if matchID == "" { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } rpsGesture := "" if expectedGameID == selfGameIDRock { var body struct { Gesture string `json:"gesture"` RPSGesture string `json:"rps_gesture"` } if !decodeOptionalJSONBody(writer, request, &body) { return } var ok bool rpsGesture, ok = selfGameRPSGestureFromInput(writer, request, expectedGameID, firstRPSGestureInput(body.Gesture, body.RPSGesture), false) if !ok { return } } // roll 不接收骰子点数、结果或派奖金额;这些全部由 game-service 服务端随机和结算。 resp, err := h.gameClient.RollDiceMatch(request.Context(), &gamev1.RollDiceMatchRequest{ Meta: gameMeta(request), UserId: auth.UserIDFromContext(request.Context()), MatchId: matchID, // roll 是资金入口,必须在 game-service 抢结算锁前验证当前路径只能操作自己的自研游戏局。 GameId: expectedGameID, RpsGesture: rpsGesture, }) h.writeDiceMatch(writer, request, resp, err) } func (h *Handler) cancelDiceMatch(writer http.ResponseWriter, request *http.Request) { h.cancelSelfGameMatch(writer, request, selfGameIDDice) } func (h *Handler) cancelRPSMatch(writer http.ResponseWriter, request *http.Request) { h.cancelSelfGameMatch(writer, request, selfGameIDRock) } func (h *Handler) cancelSelfGameMatch(writer http.ResponseWriter, request *http.Request, expectedGameID string) { if h.gameClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } matchID := strings.TrimSpace(request.PathValue("match_id")) if matchID == "" { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } resp, err := h.gameClient.CancelDiceMatch(request.Context(), &gamev1.CancelDiceMatchRequest{ Meta: gameMeta(request), UserId: auth.UserIDFromContext(request.Context()), MatchId: matchID, // 取消等待局也要校验 game_id,否则用户可以从 rps 路径取消自己创建的 dice 等待局。 GameId: expectedGameID, }) h.writeDiceMatch(writer, request, resp, err) } func (h *Handler) getRoomRPSConfig(writer http.ResponseWriter, request *http.Request) { if h.gameClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } // 配置查询只取当前请求上下文里的 app_code;App 不能上传 app_code,否则同一客户端可以越权读取其它马甲包的礼物档位。 // gateway 不缓存配置,避免后台刚改完礼物档位或开关后,房间入口还拿到旧的下注金额和倒计时。 resp, err := h.gameClient.GetRoomRPSConfig(request.Context(), &gamev1.GetRoomRPSConfigRequest{ Meta: gameMeta(request), }) httpkit.Write(writer, request, roomRPSConfigDataFromProto(resp), err) } func (h *Handler) listRoomRPSChallenges(writer http.ResponseWriter, request *http.Request) { if h.gameClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } roomID := strings.TrimSpace(request.URL.Query().Get("room_id")) if roomID == "" { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } // room_id 必须来自 query,表示当前房间的公开挑战列表;用户身份只来自 JWT,不能让前端传 user_id 旁路查看其它人的私有状态。 // page_size 在 gateway 层先做正数保护,防止空值、负数或非数字继续透传到 game-service 造成分页语义不一致。 // status 只是筛选条件,最终哪些状态可见、游标如何解释、过期挑战是否需要补偿收敛,都必须由 game-service 按订单事实判断。 resp, err := h.gameClient.ListRoomRPSChallenges(request.Context(), &gamev1.ListRoomRPSChallengesRequest{ Meta: gameMeta(request), UserId: auth.UserIDFromContext(request.Context()), RoomId: roomID, Status: strings.TrimSpace(request.URL.Query().Get("status")), PageSize: pageSize, Cursor: strings.TrimSpace(request.URL.Query().Get("cursor")), }) data := roomRPSChallengeListDataFromProto(resp) // 挑战事实来自 game-service,头像昵称来自 user-service;资料补全失败时仍返回订单,保证 IM 丢失后的补拉不被展示层依赖拖垮。 h.enrichRoomRPSChallenges(request, data.Challenges) httpkit.Write(writer, request, data, err) } func (h *Handler) createRoomRPSChallenge(writer http.ResponseWriter, request *http.Request) { if h.gameClient == nil || h.userProfileClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } // 发起挑战只接受房间、礼物档位和本人手势;发起人 user_id、app_code、region_id 都由服务端上下文补齐。 // 这样做可以避免客户端伪造他人发起挑战,也能让后续钱包托管、风控和房间语区统计使用同一份可信身份。 var body struct { RoomID string `json:"room_id"` GiftID json.RawMessage `json:"gift_id"` Gesture string `json:"gesture"` } if !httpkit.Decode(writer, request, &body) { return } gesture, ok := normalizeRoomRPSGesture(body.Gesture) giftID := roomRPSGiftIDFromRaw(body.GiftID) if strings.TrimSpace(body.RoomID) == "" || giftID == "" || !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } userID := auth.UserIDFromContext(request.Context()) // 当前用户资料是 region_id 的可信来源;即使客户端已经在房间里,仍不能让客户端自己上传 region_id 参与订单归属。 userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ Meta: httpkit.UserMeta(request, ""), UserId: userID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } // gateway 只做协议适配和可信上下文拼装;礼物档位是否启用、金币托管是否成功、重复 command 如何收敛,全部在 game-service 事务内完成。 resp, err := h.gameClient.CreateRoomRPSChallenge(request.Context(), &gamev1.CreateRoomRPSChallengeRequest{ Meta: gameMeta(request), UserId: userID, RoomId: strings.TrimSpace(body.RoomID), RegionId: userResp.GetUser().GetRegionId(), GiftId: legacyRoomRPSGiftID(giftID), GiftIdText: giftID, Gesture: gesture, }) h.writeRoomRPSChallenge(writer, request, resp, err) } func roomRPSGiftIDFromRaw(raw json.RawMessage) string { raw = json.RawMessage(bytes.TrimSpace(raw)) if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { return "" } var text string if err := json.Unmarshal(raw, &text); err == nil { return strings.TrimSpace(text) } var number json.Number decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.UseNumber() if err := decoder.Decode(&number); err == nil { return strings.TrimSpace(number.String()) } return "" } func legacyRoomRPSGiftID(giftID string) int64 { value, err := strconv.ParseInt(strings.TrimSpace(giftID), 10, 64) if err != nil || value <= 0 { return 0 } return value } func (h *Handler) acceptRoomRPSChallenge(writer http.ResponseWriter, request *http.Request) { if h.gameClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } // 应战入口只需要 challenge_id 和本人手势;房间、押注礼物、发起人和超时时间都从订单事实读取,不能相信客户端 body。 challengeID := strings.TrimSpace(request.PathValue("challenge_id")) var body struct { Gesture string `json:"gesture"` } if !httpkit.Decode(writer, request, &body) { return } gesture, ok := normalizeRoomRPSGesture(body.Gesture) if challengeID == "" || !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } // game-service 必须在一个事务里判断 pending、未超时、未被本人重复应战、钱包托管成功;gateway 不做影子状态判断,避免并发窗口不一致。 resp, err := h.gameClient.AcceptRoomRPSChallenge(request.Context(), &gamev1.AcceptRoomRPSChallengeRequest{ Meta: gameMeta(request), UserId: auth.UserIDFromContext(request.Context()), ChallengeId: challengeID, Gesture: gesture, }) h.writeRoomRPSChallenge(writer, request, resp, err) } func (h *Handler) getRoomRPSChallenge(writer http.ResponseWriter, request *http.Request) { if h.gameClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } challengeID := strings.TrimSpace(request.PathValue("challenge_id")) if challengeID == "" { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } // 详情查询主要服务 IM 事件后的补拉和断线恢复;权限判断必须落在 game-service,防止只凭 challenge_id 猜测访问其它房间订单。 resp, err := h.gameClient.GetRoomRPSChallenge(request.Context(), &gamev1.GetRoomRPSChallengeRequest{ Meta: gameMeta(request), UserId: auth.UserIDFromContext(request.Context()), ChallengeId: challengeID, }) h.writeRoomRPSChallenge(writer, request, resp, err) } func (h *Handler) writeDiceMatch(writer http.ResponseWriter, request *http.Request, resp *gamev1.DiceMatchResponse, err error) { if err != nil { httpkit.Write(writer, request, diceMatchData{}, err) return } data := diceMatchDataFromProto(resp) h.enrichDiceMatchParticipants(request, &data.Match) h.renewDiceAccessTokenIfNeeded(writer, request) httpkit.Write(writer, request, data, nil) } func (h *Handler) writeRoomRPSChallenge(writer http.ResponseWriter, request *http.Request, resp *gamev1.RoomRPSChallengeResponse, err error) { if err != nil { // 失败时返回空 data 结构和统一错误 envelope;不要用半截挑战对象误导 Flutter 展示“已发起/已应战”。 httpkit.Write(writer, request, roomRPSChallengeData{}, err) return } data := roomRPSChallengeDataFromProto(resp) // 单个挑战返回也补齐双方头像昵称,保证 HTTP 响应和后续房间 IM 使用同一套展示字段。 h.enrichRoomRPSChallenges(request, []*roomRPSChallengeItemData{&data.Challenge}) httpkit.Write(writer, request, data, nil) } func selfGameIDFromInput(writer http.ResponseWriter, request *http.Request, rawGameID string, defaultGameID string, fixedGameID bool) (string, bool) { gameID := strings.TrimSpace(rawGameID) if gameID == "" { gameID = defaultGameID } if fixedGameID && gameID != defaultGameID { // rps 是独立 H5 入口,但当前产品只开放 rock 这一条自研猜拳 game_id;这里拒绝串到 dice 或 room-rps。 httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return "", false } return gameID, true } func firstRPSGestureInput(gesture string, rpsGesture string) string { if strings.TrimSpace(gesture) != "" { return gesture } return rpsGesture } func selfGameRPSGestureFromInput(writer http.ResponseWriter, request *http.Request, gameID string, rawGesture string, required bool) (string, bool) { if gameID != selfGameIDRock { return "", true } if strings.TrimSpace(rawGesture) == "" && !required { return "", true } gesture, ok := normalizeSelfRPSGesture(rawGesture) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return "", false } return gesture, true } func normalizeSelfRPSGesture(value string) (string, bool) { // self-rps 的手势在入 game-service 前统一成小写英文枚举;dice 路径不会调用这里,避免污染骰子玩法。 switch strings.ToLower(strings.TrimSpace(value)) { case "rock", "paper", "scissors": return strings.ToLower(strings.TrimSpace(value)), true default: return "", false } } func decodeOptionalJSONBody(writer http.ResponseWriter, request *http.Request, out any) bool { if request.Body == nil { return true } raw, err := io.ReadAll(request.Body) if err != nil { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidJSON, "invalid request body") return false } if strings.TrimSpace(string(raw)) == "" { return true } if err := httpkit.DecodeJSON(bytes.NewReader(raw), out); err != nil { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidJSON, "invalid request body") return false } return true } func normalizeRoomRPSGesture(value string) (string, bool) { // 手势在 gateway 层归一为小写英文枚举;game-service 只接收稳定值,避免 Flutter 大小写或本地化文案污染订单事实。 switch strings.ToLower(strings.TrimSpace(value)) { case "rock", "paper", "scissors": return strings.ToLower(strings.TrimSpace(value)), true default: return "", false } } func (h *Handler) renewDiceAccessTokenIfNeeded(writer http.ResponseWriter, request *http.Request) { if h.userAuthClient == nil { return } expiresAtMS := auth.AccessTokenExpiresAtMSFromContext(request.Context()) if expiresAtMS <= 0 || time.Until(time.UnixMilli(expiresAtMS)) > diceAccessTokenRenewBefore { // 旧测试 token 或距离过期还很久的 token 不做续签,避免每秒轮询把 user-service 和登录审计打满。 return } sessionID := auth.SessionIDFromContext(request.Context()) if strings.TrimSpace(sessionID) == "" { return } resp, err := h.userAuthClient.AppHeartbeat(request.Context(), &userv1.AppHeartbeatRequest{ Meta: httpkit.UserMeta(request, httpkit.FirstHeader(request, "X-Device-ID", "X-HY-Device-ID")), UserId: auth.UserIDFromContext(request.Context()), SessionId: sessionID, }) if err != nil { // 游戏事实响应已经成功,续签失败只记录日志;客户端下一次仍可走 refresh token 兜底。 logx.Warn(request.Context(), "dice_access_token_renew_failed", slog.String("component", "gateway_game"), slog.String("session_id", sessionID), slog.String("error", err.Error())) return } token := resp.GetToken() accessToken := strings.TrimSpace(token.GetAccessToken()) if accessToken == "" { return } writer.Header().Set(hyappAccessTokenHeader, accessToken) writer.Header().Set(hyappTokenTypeHeader, strings.TrimSpace(token.GetTokenType())) writer.Header().Set(hyappExpiresInHeader, strconv.FormatInt(token.GetExpiresInSec(), 10)) } func (h *Handler) handleGameCallback(writer http.ResponseWriter, request *http.Request) { raw, err := io.ReadAll(request.Body) if err != nil { http.Error(writer, "invalid request body", http.StatusBadRequest) return } platformCode := strings.TrimSpace(request.PathValue("platform_code")) operation := strings.TrimSpace(request.PathValue("operation")) // reyou 的线上回调现在存在两种入口:根路径兼容入口和 /game-callbacks/reyou/* 通用入口。 // 通用入口同样先按 token 归属做分流,chatapp3 token 原样回源 chatapp3,其他 token 才交给 hyapp 自己的游戏服务。 if h.tryProxyChatapp3HotgameCallback(writer, request, raw, platformCode, operation) { return } h.handleGameCallbackRaw(writer, request, raw, platformCode, operation) } func (h *Handler) handleZGameCallback(writer http.ResponseWriter, request *http.Request) { raw, err := io.ReadAll(request.Body) if err != nil { http.Error(writer, "invalid request body", http.StatusBadRequest) return } operation := strings.Trim(strings.TrimSpace(request.PathValue("operation")), "/") operation = strings.TrimPrefix(operation, "api/") if operation == "" { http.Error(writer, "invalid callback operation", http.StatusBadRequest) return } // ZGame 后台只能配置固定 callback 前缀;gateway 只把固定尾段映射为 adapter operation,具体协议和钱包语义仍归 game-service。 h.handleGameCallbackRaw(writer, request, raw, zgameCallbackPlatformCode, operation) } func (h *Handler) handleGameCallbackRaw(writer http.ResponseWriter, request *http.Request, raw []byte, platformCode string, operation string) { if h.gameClient == nil { http.Error(writer, "upstream service error", http.StatusBadGateway) return } scopedRequest, ok := h.withVerifiedCallbackToken(request, raw) if !ok { http.Error(writer, "unauthorized", http.StatusUnauthorized) return } request = scopedRequest resp, err := h.gameClient.HandleCallback(request.Context(), &gamev1.CallbackRequest{ Meta: gameMeta(request), PlatformCode: strings.TrimSpace(platformCode), Operation: strings.TrimSpace(operation), RawBody: raw, Headers: flattenedHeaders(request), Query: flattenedQuery(request), RemoteAddr: request.RemoteAddr, }) if err != nil { status := xerr.SpecOf(xerr.ReasonFromGRPC(err)).HTTPStatus http.Error(writer, xerr.SpecOf(xerr.ReasonFromGRPC(err)).PublicMessage, status) return } contentType := strings.TrimSpace(resp.GetContentType()) if contentType == "" { contentType = "application/json" } writer.Header().Set("Content-Type", contentType) writer.WriteHeader(http.StatusOK) _, _ = writer.Write(resp.GetRawBody()) } func (h *Handler) withVerifiedCallbackToken(request *http.Request, raw []byte) (*http.Request, bool) { jwtCandidates := callbackJWTTokenCandidates(callbackTokenCandidates(request, raw)) if len(jwtCandidates) == 0 { // 回调 body 里常见的 code、js_code、token 可能是三方业务码或老平台 session;不是 HyApp JWT 时不参与归因, // 避免把 code=0、chatapp3 sign token 或游戏方随机串误判为当前 App 用户。 return request, true } if h.jwtVerifier == nil { return request, false } for _, token := range jwtCandidates { claims, err := h.jwtVerifier.Verify("Bearer " + token) if err != nil { continue } // 游戏回调是公网入口,callback 自带 app_code/header 都不能作为归因事实;只有 gateway 验过签的 HyApp access token // 才能覆盖 request context,后续 gameMeta 会把 token 内 app_code/user_id 透传到 game-service RequestMeta。 return request.WithContext(auth.WithClaims(request.Context(), claims)), true } return request, false } func callbackJWTTokenCandidates(candidates []string) []string { jwtCandidates := make([]string, 0, len(candidates)) seen := make(map[string]struct{}, len(candidates)) for _, candidate := range candidates { token := normalizeCallbackTokenValue(candidate) if !callbackTokenLooksLikeJWT(token) { continue } if _, ok := seen[token]; ok { continue } seen[token] = struct{}{} jwtCandidates = append(jwtCandidates, token) } return jwtCandidates } func callbackTokenCandidates(request *http.Request, raw []byte) []string { candidates := make([]string, 0, 8) seen := make(map[string]struct{}, 8) add := func(value string) { token := normalizeCallbackTokenValue(value) if token == "" { return } if _, ok := seen[token]; ok { return } seen[token] = struct{}{} candidates = append(candidates, token) } add(request.Header.Get("Authorization")) for _, headerName := range []string{hyappAccessTokenHeader, "X-Access-Token", "X-Token", "X-Session-Token", "X-Game-Token"} { add(request.Header.Get(headerName)) } for key, values := range request.URL.Query() { if !callbackTokenFieldName(key) { continue } for _, value := range values { add(value) } } appendCallbackBodyTokenCandidates(raw, add) return candidates } func appendCallbackBodyTokenCandidates(raw []byte, add func(string)) { raw = bytes.TrimSpace(raw) if len(raw) == 0 { return } var payload any decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.UseNumber() if err := decoder.Decode(&payload); err != nil { return } walkCallbackTokenJSON("", payload, add) } func walkCallbackTokenJSON(fieldName string, value any, add func(string)) { switch typed := value.(type) { case map[string]any: for key, child := range typed { walkCallbackTokenJSON(key, child, add) } case []any: for _, child := range typed { walkCallbackTokenJSON(fieldName, child, add) } case string: if callbackTokenFieldName(fieldName) { add(typed) } } } var callbackTokenKeyReplacer = strings.NewReplacer("_", "", "-", "", " ", "") var callbackTokenFieldNames = map[string]struct{}{ "authorization": {}, "token": {}, "accesstoken": {}, "hyapptoken": {}, "hyappaccesstoken": {}, "authtoken": {}, "session": {}, "sessionid": {}, "sessiontoken": {}, "jscode": {}, "authcode": {}, "platauthcode": {}, "plattoken": {}, "sstoken": {}, "useraccesstoken": {}, "userauthorization": {}, } func callbackTokenFieldName(fieldName string) bool { normalized := callbackTokenKeyReplacer.Replace(strings.ToLower(strings.TrimSpace(fieldName))) _, ok := callbackTokenFieldNames[normalized] return ok } func normalizeCallbackTokenValue(value string) string { token := strings.TrimSpace(value) if len(token) >= len("Bearer ") && strings.EqualFold(token[:len("Bearer ")], "Bearer ") { token = strings.TrimSpace(token[len("Bearer "):]) } return strings.Trim(strings.TrimSpace(token), `"'`) } func callbackTokenLooksLikeJWT(token string) bool { parts := strings.Split(token, ".") if len(parts) != 3 { return false } for _, part := range parts { if strings.TrimSpace(part) == "" { return false } } return true } func gameMeta(request *http.Request) *gamev1.RequestMeta { return &gamev1.RequestMeta{ RequestId: httpkit.RequestIDFromContext(request.Context()), Caller: "gateway-service", GatewayNodeId: "", ActorUserId: auth.UserIDFromContext(request.Context()), SentAtMs: time.Now().UnixMilli(), AppCode: appcode.FromContext(request.Context()), } } func requestLanguage(request *http.Request) string { value := httpkit.FirstHeader(request, "X-App-Language", "X-Language") if value == "" { value = request.Header.Get("Accept-Language") } if index := strings.Index(value, ","); index >= 0 { value = value[:index] } return strings.TrimSpace(value) } func recentGamePageSize(request *http.Request) (int32, bool) { if strings.TrimSpace(request.URL.Query().Get("limit")) != "" { return httpkit.PositiveInt32Query(request, "limit", 4) } return httpkit.PositiveInt32Query(request, "page_size", 4) } func exploreWinnerPageSize(request *http.Request) (int32, bool) { pageSize, ok := httpkit.PositiveInt32Query(request, "limit", 20) if !ok { return 0, false } if pageSize > 50 { pageSize = 50 } return pageSize, true } func bearerAccessToken(request *http.Request) string { header := strings.TrimSpace(request.Header.Get("Authorization")) if !strings.HasPrefix(header, "Bearer ") { return "" } return strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) } func flattenedHeaders(request *http.Request) map[string]string { headers := make(map[string]string, len(request.Header)) for key, values := range request.Header { if len(values) > 0 { headers[key] = values[0] } } return headers } func flattenedQuery(request *http.Request) map[string]string { query := request.URL.Query() result := make(map[string]string, len(query)) for key, values := range query { if len(values) > 0 { result[key] = values[0] } } return result }