823 lines
32 KiB
Go
823 lines
32 KiB
Go
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_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),
|
||
})
|
||
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(),
|
||
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_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 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
|
||
}
|