2026-06-11 01:02:16 +08:00

453 lines
16 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 (
"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
}