2026-06-11 18:14:04 +08:00

823 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package gameapi
import (
"bytes"
"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"
)
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_codegame-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),
})
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_coderps 路径固定映射 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(),
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(),
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_codeApp 不能上传 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 int64 `json:"gift_id"`
Gesture string `json:"gesture"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
gesture, ok := normalizeRoomRPSGesture(body.Gesture)
if strings.TrimSpace(body.RoomID) == "" || body.GiftID <= 0 || !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: body.GiftID,
Gesture: gesture,
})
h.writeRoomRPSChallenge(writer, request, resp, err)
}
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
}
h.handleGameCallbackRaw(writer, request, raw, strings.TrimSpace(request.PathValue("platform_code")), strings.TrimSpace(request.PathValue("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
}
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 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
}