diff --git a/services/gateway-service/internal/transport/http/game_handler_test.go b/services/gateway-service/internal/transport/http/game_handler_test.go index a32d42f9..4bfe5bac 100644 --- a/services/gateway-service/internal/transport/http/game_handler_test.go +++ b/services/gateway-service/internal/transport/http/game_handler_test.go @@ -3,9 +3,13 @@ package http import ( "bytes" "context" + "encoding/base64" "encoding/json" + "io" "net/http" "net/http/httptest" + "net/url" + "strconv" "testing" gamev1 "hyapp.local/api/proto/game/v1" @@ -173,6 +177,86 @@ func TestGameCallbackIsRawPublicResponse(t *testing.T) { } } +func TestHotgameCompatFallbacksToHyappReyouCallback(t *testing.T) { + body := `{"gameId":"101","uid":"420001","token":"hyapp-token","sign":"s"}` + gameClient := &fakeGatewayGameClient{callbackResp: &gamev1.CallbackResponse{RawBody: []byte(`{"errorCode":0}`), ContentType: "application/json"}} + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) + handler.SetGameClient(gameClient) + router := handler.Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodPost, "/getUserInfo?nonce=1", bytes.NewReader([]byte(body))) + request.Header.Set("X-App-Code", "lalu") + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK || recorder.Body.String() != `{"errorCode":0}` { + t.Fatalf("hotgame hyapp callback response mismatch: status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if gameClient.lastCallback == nil || gameClient.lastCallback.GetPlatformCode() != "reyou" || gameClient.lastCallback.GetOperation() != "getUserInfo" || string(gameClient.lastCallback.GetRawBody()) != body || gameClient.lastCallback.GetQuery()["nonce"] != "1" { + t.Fatalf("hotgame hyapp callback request mismatch: %+v", gameClient.lastCallback) + } +} + +func TestHotgameCompatProxiesChatapp3TokenWithoutGameCallback(t *testing.T) { + token := chatapp3HotgameToken("v1", 1001, "LIKEI", 4102444800000, 1700000000000) + body := `{"orderId":"o1","gameId":"13","roundId":"r1","uid":"1001","coin":12,"type":1,"token":"` + token + `","sign":"s"}` + var gotHost string + var gotPath string + var gotQuery string + var gotBody string + var gotContentType string + oldTransport := http.DefaultTransport + http.DefaultTransport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + gotHost = request.URL.Host + gotPath = request.URL.Path + gotQuery = request.URL.RawQuery + gotContentType = request.Header.Get("Content-Type") + raw, _ := io.ReadAll(request.Body) + gotBody = string(raw) + header := http.Header{} + header.Set("Content-Type", "application/json") + return &http.Response{ + StatusCode: http.StatusOK, + Header: header, + Body: io.NopCloser(bytes.NewReader([]byte(`{"errorCode":0,"data":{"source":"chatapp3"}}`))), + Request: request, + }, nil + }) + defer func() { http.DefaultTransport = oldTransport }() + gameClient := &fakeGatewayGameClient{callbackResp: &gamev1.CallbackResponse{RawBody: []byte(`{"errorCode":0}`), ContentType: "application/json"}} + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) + handler.SetGameClient(gameClient) + router := handler.Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodPost, "/updateBalance?nonce=1", bytes.NewReader([]byte(body))) + request.Header.Set("X-App-Code", "lalu") + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK || recorder.Body.String() != `{"errorCode":0,"data":{"source":"chatapp3"}}` { + t.Fatalf("hotgame chatapp3 proxy response mismatch: status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if gotHost != "jvapi.haiyihy.com" || gotPath != "/go/updateBalance" || gotQuery != "nonce=1" || gotBody != body || gotContentType != "application/json" { + t.Fatalf("hotgame chatapp3 proxy request mismatch: host=%q path=%q query=%q content_type=%q body=%s", gotHost, gotPath, gotQuery, gotContentType, gotBody) + } + if gameClient.lastCallback != nil { + t.Fatalf("chatapp3 token must not enter hyapp game callback: %+v", gameClient.lastCallback) + } +} + +func chatapp3HotgameToken(version string, userID int64, sysOrigin string, expireMs int64, releaseMs int64) string { + payload := url.QueryEscape(version + ":" + strconv.FormatInt(userID, 10) + ":" + sysOrigin + ":" + strconv.FormatInt(expireMs, 10) + ":" + strconv.FormatInt(releaseMs, 10)) + return "sign." + base64.StdEncoding.EncodeToString([]byte(payload)) +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + type fakeGatewayGameClient struct { listResp *gamev1.ListGamesResponse recentResp *gamev1.ListGamesResponse diff --git a/services/gateway-service/internal/transport/http/gameapi/game_handler.go b/services/gateway-service/internal/transport/http/gameapi/game_handler.go index c2937151..8f690e54 100644 --- a/services/gateway-service/internal/transport/http/gameapi/game_handler.go +++ b/services/gateway-service/internal/transport/http/gameapi/game_handler.go @@ -125,19 +125,23 @@ func (h *Handler) launchGame(writer http.ResponseWriter, request *http.Request) } func (h *Handler) handleGameCallback(writer http.ResponseWriter, request *http.Request) { - if h.gameClient == nil { - http.Error(writer, "upstream service error", http.StatusBadGateway) - return - } 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(request.PathValue("platform_code")), - Operation: strings.TrimSpace(request.PathValue("operation")), + PlatformCode: strings.TrimSpace(platformCode), + Operation: strings.TrimSpace(operation), RawBody: raw, Headers: flattenedHeaders(request), Query: flattenedQuery(request), diff --git a/services/gateway-service/internal/transport/http/gameapi/handler.go b/services/gateway-service/internal/transport/http/gameapi/handler.go index 05af7d43..ee8b01ad 100644 --- a/services/gateway-service/internal/transport/http/gameapi/handler.go +++ b/services/gateway-service/internal/transport/http/gameapi/handler.go @@ -12,6 +12,8 @@ import ( type Handler struct { gameClient client.GameClient userProfileClient client.UserProfileClient + // 热游固定回调地址先进入 hyapp gateway;chatapp3 老 token 需要原样转给 chatapp3,hyapp token 继续落到 game-service。 + hotgameCompatHTTPClient *http.Client } type Config struct { @@ -21,18 +23,21 @@ type Config struct { func New(config Config) *Handler { return &Handler{ - gameClient: config.GameClient, - userProfileClient: config.UserProfileClient, + gameClient: config.GameClient, + userProfileClient: config.UserProfileClient, + hotgameCompatHTTPClient: hotgameCompatHTTPClient(), } } func (h *Handler) Handlers() httproutes.GameHandlers { return httproutes.GameHandlers{ - ListGames: h.listGames, - ListRecentGames: h.listRecentGames, - GetBridgeScript: h.getBridgeScript, - LaunchGame: h.launchGame, - HandleCallback: h.handleGameCallback, + ListGames: h.listGames, + ListRecentGames: h.listRecentGames, + GetBridgeScript: h.getBridgeScript, + LaunchGame: h.launchGame, + HandleCallback: h.handleGameCallback, + HandleHotgameGetUserInfo: h.handleHotgameGetUserInfo, + HandleHotgameUpdateBalance: h.handleHotgameUpdateBalance, } } @@ -55,3 +60,11 @@ func (h *Handler) LaunchGame(writer http.ResponseWriter, request *http.Request) func (h *Handler) HandleCallback(writer http.ResponseWriter, request *http.Request) { h.handleGameCallback(writer, request) } + +func (h *Handler) HandleHotgameGetUserInfo(writer http.ResponseWriter, request *http.Request) { + h.handleHotgameGetUserInfo(writer, request) +} + +func (h *Handler) HandleHotgameUpdateBalance(writer http.ResponseWriter, request *http.Request) { + h.handleHotgameUpdateBalance(writer, request) +} diff --git a/services/gateway-service/internal/transport/http/gameapi/hotgame_compat_handler.go b/services/gateway-service/internal/transport/http/gameapi/hotgame_compat_handler.go new file mode 100644 index 00000000..830833ca --- /dev/null +++ b/services/gateway-service/internal/transport/http/gameapi/hotgame_compat_handler.go @@ -0,0 +1,243 @@ +package gameapi + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + hotgameCompatPlatformCode = "reyou" + hotgameCompatProxyDefaultBaseURL = "https://jvapi.haiyihy.com/go" + hotgameCompatProxyDefaultTimeout = 10 * time.Second + hotgameCompatGetUserInfoOperation = "getUserInfo" + hotgameCompatUpdateBalanceOperation = "updateBalance" +) + +func (h *Handler) handleHotgameGetUserInfo(writer http.ResponseWriter, request *http.Request) { + h.handleHotgameCompatCallback(writer, request, hotgameCompatGetUserInfoOperation) +} + +func (h *Handler) handleHotgameUpdateBalance(writer http.ResponseWriter, request *http.Request) { + h.handleHotgameCompatCallback(writer, request, hotgameCompatUpdateBalanceOperation) +} + +func (h *Handler) handleHotgameCompatCallback(writer http.ResponseWriter, request *http.Request, operation string) { + raw, err := io.ReadAll(request.Body) + if err != nil { + http.Error(writer, "invalid request body", http.StatusBadRequest) + return + } + // 热游固定回调地址无法按域名区分 hyapp 和 chatapp3;这里用 token 结构做最小路由键。 + // chatapp3 的用户 token 是 sign.base64(version:userId:sysOrigin:expire:release),hyapp 当前热游 token 是 JWT 或本服务 session。 + if isChatapp3HotgameToken(hotgameCompatCallbackToken(raw)) { + h.proxyChatapp3HotgameCallback(writer, request, raw, operation) + return + } + // 非 chatapp3 token 全部继续走 hyapp 自己的 reyou 平台配置,避免 hyapp 自有热游游戏误转到 chatapp3。 + h.handleGameCallbackRaw(writer, request, raw, hotgameCompatPlatformCode, operation) +} + +func (h *Handler) proxyChatapp3HotgameCallback(writer http.ResponseWriter, request *http.Request, raw []byte, operation string) { + target, err := hotgameCompatProxyURL(operation, request.URL.RawQuery) + if err != nil { + http.Error(writer, "proxy config error", http.StatusBadGateway) + return + } + proxyReq, err := http.NewRequestWithContext(request.Context(), http.MethodPost, target, bytes.NewReader(raw)) + if err != nil { + http.Error(writer, "proxy request error", http.StatusBadGateway) + return + } + copyHotgameProxyRequestHeaders(proxyReq, request) + appendHotgameForwardedHeaders(proxyReq, request) + + resp, err := h.hotgameCompatHTTPClient.Do(proxyReq) + if err != nil { + http.Error(writer, "proxy upstream error", http.StatusBadGateway) + return + } + defer resp.Body.Close() + copyHotgameProxyResponseHeaders(writer.Header(), resp.Header) + writer.WriteHeader(resp.StatusCode) + _, _ = io.Copy(writer, resp.Body) +} + +func hotgameCompatHTTPClient() *http.Client { + return &http.Client{Timeout: hotgameCompatProxyDefaultTimeout} +} + +func hotgameCompatCallbackToken(raw []byte) string { + var payload map[string]json.RawMessage + if err := json.Unmarshal(raw, &payload); err != nil { + return "" + } + return hotgameCompatJSONText(payload["token"]) +} + +func hotgameCompatJSONText(raw json.RawMessage) string { + text := strings.TrimSpace(string(raw)) + if text == "" || text == "null" { + return "" + } + var decoded string + if err := json.Unmarshal(raw, &decoded); err == nil { + return strings.TrimSpace(decoded) + } + return text +} + +func isChatapp3HotgameToken(token string) bool { + token = strings.TrimSpace(token) + if chatapp3HotgameTokenCandidateMatches(token) { + return true + } + if decoded, err := url.QueryUnescape(token); err == nil && strings.TrimSpace(decoded) != token { + return chatapp3HotgameTokenCandidateMatches(decoded) + } + return false +} + +func chatapp3HotgameTokenCandidateMatches(token string) bool { + token = normalizeChatapp3HotgameTokenCandidate(token) + parts := strings.SplitN(token, ".", 2) + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" { + return false + } + decoded, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + return false + } + payload := strings.TrimSpace(string(decoded)) + if unescaped, err := url.QueryUnescape(payload); err == nil { + payload = strings.TrimSpace(unescaped) + } + fields := strings.Split(payload, ":") + if len(fields) != 5 || strings.TrimSpace(fields[0]) == "" || strings.TrimSpace(fields[2]) == "" { + return false + } + userID, userErr := strconv.ParseInt(strings.TrimSpace(fields[1]), 10, 64) + _, expireErr := strconv.ParseInt(strings.TrimSpace(fields[3]), 10, 64) + _, releaseErr := strconv.ParseInt(strings.TrimSpace(fields[4]), 10, 64) + // 这里只判定归属,不判定过期;过期 token 仍必须转给 chatapp3,让原项目按自己的错误码回复热游。 + return userID > 0 && userErr == nil && expireErr == nil && releaseErr == nil +} + +func normalizeChatapp3HotgameTokenCandidate(token string) string { + token = strings.TrimSpace(token) + if strings.HasPrefix(strings.ToLower(token), "bearer ") { + token = strings.TrimSpace(token[len("bearer "):]) + } + parts := strings.SplitN(token, ".", 2) + if len(parts) != 2 || parts[1] == "" { + return token + } + switch len(parts[1]) % 4 { + case 2: + parts[1] += "==" + case 3: + parts[1] += "=" + } + return strings.TrimSpace(parts[0]) + "." + strings.TrimSpace(parts[1]) +} + +func hotgameCompatProxyURL(operation string, rawQuery string) (string, error) { + parsed, err := url.Parse(hotgameCompatProxyDefaultBaseURL) + if err != nil { + return "", err + } + parsed.Path = singleJoiningSlash(parsed.Path, operation) + if strings.TrimSpace(rawQuery) != "" { + if parsed.RawQuery == "" { + parsed.RawQuery = rawQuery + } else { + parsed.RawQuery += "&" + rawQuery + } + } + return parsed.String(), nil +} + +func singleJoiningSlash(left string, right string) string { + leftSlash := strings.HasSuffix(left, "/") + rightSlash := strings.HasPrefix(right, "/") + switch { + case leftSlash && rightSlash: + return left + strings.TrimLeft(right, "/") + case !leftSlash && !rightSlash: + return left + "/" + right + default: + return left + right + } +} + +func copyHotgameProxyRequestHeaders(dst *http.Request, src *http.Request) { + for key, values := range src.Header { + if isHopByHopHeader(key) { + continue + } + for _, value := range values { + dst.Header.Add(key, value) + } + } + if dst.Header.Get("Content-Type") == "" { + dst.Header.Set("Content-Type", "application/json") + } +} + +func appendHotgameForwardedHeaders(dst *http.Request, src *http.Request) { + if host := strings.TrimSpace(src.Host); host != "" { + dst.Header.Set("X-Forwarded-Host", host) + } + if proto := strings.TrimSpace(src.Header.Get("X-Forwarded-Proto")); proto != "" { + dst.Header.Set("X-Forwarded-Proto", proto) + } else if src.TLS != nil { + dst.Header.Set("X-Forwarded-Proto", "https") + } else { + dst.Header.Set("X-Forwarded-Proto", "http") + } + if ip := requestClientIP(src); ip != "" { + if prior := strings.TrimSpace(src.Header.Get("X-Forwarded-For")); prior != "" { + dst.Header.Set("X-Forwarded-For", prior+", "+ip) + } else { + dst.Header.Set("X-Forwarded-For", ip) + } + } +} + +func requestClientIP(request *http.Request) string { + host, _, err := net.SplitHostPort(strings.TrimSpace(request.RemoteAddr)) + if err == nil { + return host + } + return strings.TrimSpace(request.RemoteAddr) +} + +func copyHotgameProxyResponseHeaders(dst http.Header, src http.Header) { + for key, values := range src { + if isHopByHopHeader(key) { + continue + } + for _, value := range values { + dst.Add(key, value) + } + } + if dst.Get("Content-Type") == "" { + dst.Set("Content-Type", "application/json") + } +} + +func isHopByHopHeader(key string) bool { + switch strings.ToLower(strings.TrimSpace(key)) { + case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length": + return true + default: + return false + } +} diff --git a/services/gateway-service/internal/transport/http/httproutes/router.go b/services/gateway-service/internal/transport/http/httproutes/router.go index 1b3f9382..06e34edd 100644 --- a/services/gateway-service/internal/transport/http/httproutes/router.go +++ b/services/gateway-service/internal/transport/http/httproutes/router.go @@ -236,11 +236,13 @@ type VIPHandlers struct { } type GameHandlers struct { - ListGames http.HandlerFunc - ListRecentGames http.HandlerFunc - GetBridgeScript http.HandlerFunc - LaunchGame http.HandlerFunc - HandleCallback http.HandlerFunc + ListGames http.HandlerFunc + ListRecentGames http.HandlerFunc + GetBridgeScript http.HandlerFunc + LaunchGame http.HandlerFunc + HandleCallback http.HandlerFunc + HandleHotgameGetUserInfo http.HandlerFunc + HandleHotgameUpdateBalance http.HandlerFunc } type routes struct { @@ -274,6 +276,10 @@ func (r routes) public(path string, method string, next http.HandlerFunc) { r.register(path, method, r.config.PublicWrap, next) } +func (r routes) rootPublic(path string, method string, next http.HandlerFunc) { + r.registerRaw(path, method, r.config.PublicWrap, next) +} + func (r routes) auth(path string, method string, next http.HandlerFunc) { r.register(path, method, r.config.AuthWrap, next) } @@ -283,11 +289,15 @@ func (r routes) profile(path string, method string, next http.HandlerFunc) { } func (r routes) register(path string, method string, wrap Wrapper, next http.HandlerFunc) { + r.registerRaw(APIV1Prefix+path, method, wrap, next) +} + +func (r routes) registerRaw(path string, method string, wrap Wrapper, next http.HandlerFunc) { if method != "" { // method 继续走 gateway envelope,而不是使用 ServeMux 自动 405。 next = httpkit.RequireMethod(method, next) } - r.mux.Handle(APIV1Prefix+path, wrap(next)) + r.mux.Handle(path, wrap(next)) } func (r routes) registerAuthRoutes() { @@ -514,5 +524,8 @@ func (r routes) registerGameRoutes() { r.profile("/games/recent", http.MethodGet, h.ListRecentGames) r.profile("/games/bridge-script", http.MethodGet, h.GetBridgeScript) r.profile("/games/{game_id}/launch", http.MethodPost, h.LaunchGame) + // 热游后台只能配置根路径回调;这里仍复用 public wrapper,让请求日志、request_id 和 app_code 解析保持一致。 + r.rootPublic("/getUserInfo", http.MethodPost, h.HandleHotgameGetUserInfo) + r.rootPublic("/updateBalance", http.MethodPost, h.HandleHotgameUpdateBalance) r.public("/game-callbacks/{platform_code}/{operation}", http.MethodPost, h.HandleCallback) }