package gameapi import ( "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" ) 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) 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) { if h.gameClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } gameID := strings.TrimSpace(request.URL.Query().Get("game_id")) if gameID == "" { gameID = "dice" } // 配置接口只返回当前 App 的可下注档位和费率,不接受客户端传 app_code。 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) { 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"` } if !httpkit.Decode(writer, request, &body) { return } if strings.TrimSpace(body.GameID) == "" { body.GameID = "dice" } if body.StakeCoin <= 0 { 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 } resp, err := h.gameClient.MatchDice(request.Context(), &gamev1.MatchDiceRequest{ Meta: gameMeta(request), UserId: userID, GameId: strings.TrimSpace(body.GameID), RoomId: strings.TrimSpace(body.RoomID), RegionId: userResp.GetUser().GetRegionId(), StakeCoin: body.StakeCoin, }) h.writeDiceMatch(writer, request, resp, err) } func (h *Handler) createDiceMatch(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 } // 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"` } if !httpkit.Decode(writer, request, &body) { return } if strings.TrimSpace(body.GameID) == "" || body.StakeCoin <= 0 { 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 } // region_id 来自 user-service 当前用户资料,不信任客户端 body,避免跨区域创建可结算局。 resp, err := h.gameClient.CreateDiceMatch(request.Context(), &gamev1.CreateDiceMatchRequest{ Meta: gameMeta(request), UserId: userID, GameId: strings.TrimSpace(body.GameID), RoomId: strings.TrimSpace(body.RoomID), RegionId: userResp.GetUser().GetRegionId(), StakeCoin: body.StakeCoin, MinPlayers: body.MinPlayers, MaxPlayers: body.MaxPlayers, }) h.writeDiceMatch(writer, request, resp, err) } func (h *Handler) joinDiceMatch(writer http.ResponseWriter, request *http.Request) { 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 } // 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, }) h.writeDiceMatch(writer, request, resp, err) } func (h *Handler) getDiceMatch(writer http.ResponseWriter, request *http.Request) { 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, }) h.writeDiceMatch(writer, request, resp, err) } func (h *Handler) rollDiceMatch(writer http.ResponseWriter, request *http.Request) { 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 } // roll 不接收骰子点数、结果或派奖金额;这些全部由 game-service 服务端随机和结算。 resp, err := h.gameClient.RollDiceMatch(request.Context(), &gamev1.RollDiceMatchRequest{ Meta: gameMeta(request), UserId: auth.UserIDFromContext(request.Context()), MatchId: matchID, }) h.writeDiceMatch(writer, request, resp, err) } func (h *Handler) cancelDiceMatch(writer http.ResponseWriter, request *http.Request) { 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, }) h.writeDiceMatch(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) 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 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 }