feat: add hotgame callback compatibility proxy

This commit is contained in:
zhx 2026-06-10 18:36:47 +08:00
parent 27ab1317d2
commit 919364d4d0
5 changed files with 376 additions and 19 deletions

View File

@ -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

View File

@ -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),

View File

@ -12,6 +12,8 @@ import (
type Handler struct {
gameClient client.GameClient
userProfileClient client.UserProfileClient
// 热游固定回调地址先进入 hyapp gatewaychatapp3 老 token 需要原样转给 chatapp3hyapp 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)
}

View File

@ -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
}
}

View File

@ -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)
}