diff --git a/scripts/zgame-flutter-container-check.py b/scripts/zgame-flutter-container-check.py new file mode 100755 index 00000000..1a326865 --- /dev/null +++ b/scripts/zgame-flutter-container-check.py @@ -0,0 +1,555 @@ +#!/usr/bin/env python3 +"""用 Chrome DevTools Protocol 模拟 Flutter WebView 打开线上 ZGame。 + +脚本复刻 Flutter 容器最关键的三个边界:先向线上网关登录并签发一次性 +launch token;在文档首行注入 launch payload/config;再执行后端下发的远程 +bridge 脚本。账号密码只从环境变量读取,避免进入代码、shell history 或截图。 +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import pathlib +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +try: + import websocket +except ImportError as exc: # pragma: no cover - 只在本机缺少运行依赖时触发。 + raise SystemExit( + "缺少 websocket-client,请先执行: python3 -m pip install --user websocket-client" + ) from exc + + +DEFAULT_API_BASE = "https://api.global-interaction.com/api/v1" +DEFAULT_CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" +LOCAL_HTTP_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="登录线上账号,并用 Flutter WebView 同等桥接顺序打开 ZGame。" + ) + parser.add_argument("--api-base", default=DEFAULT_API_BASE) + parser.add_argument("--app-code", default="lalu") + parser.add_argument("--account", default="123456") + parser.add_argument("--game-id", default="zgame_49") + parser.add_argument("--password-env", default="HYAPP_TEST_PASSWORD") + parser.add_argument("--chrome", default=DEFAULT_CHROME) + parser.add_argument("--timeout", type=int, default=60) + parser.add_argument( + "--screenshot", + default=".tmp/zgame-flutter-container-check.png", + help="验证截图输出位置;.tmp 已被仓库忽略。", + ) + return parser.parse_args() + + +def http_json( + url: str, + *, + method: str = "GET", + body: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: int = 20, +) -> dict[str, Any]: + encoded = None if body is None else json.dumps(body).encode("utf-8") + request_headers = { + "Accept": "application/json", + "Content-Type": "application/json", + **(headers or {}), + } + request = urllib.request.Request( + url, + data=encoded, + headers=request_headers, + method=method, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + payload = json.load(response) + except urllib.error.HTTPError as exc: + # 错误正文通常包含明确业务码;只输出响应,不输出请求体中的密码/token。 + detail = exc.read().decode("utf-8", errors="replace")[:1000] + raise RuntimeError(f"HTTP {exc.code} {url}: {detail}") from exc + if not isinstance(payload, dict): + raise RuntimeError(f"接口未返回 JSON object: {url}") + return payload + + +def require_envelope(payload: dict[str, Any], operation: str) -> dict[str, Any]: + code = str(payload.get("code", "")).upper() + if code not in {"OK", "0"}: + raise RuntimeError( + f"{operation}失败: code={payload.get('code')} message={payload.get('message')}" + ) + data = payload.get("data") + if not isinstance(data, dict): + raise RuntimeError(f"{operation}响应缺少 data") + return data + + +def reserve_port() -> int: + # 先由内核选择空闲端口,Chrome 随后立即占用;本机验证避免固定调试端口冲突。 + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def wait_json(url: str, timeout_seconds: int) -> dict[str, Any]: + deadline = time.monotonic() + timeout_seconds + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + # 本机 CDP 不能经过系统 HTTP(S)_PROXY,否则 127.0.0.1 会被代理吞掉并超时。 + with LOCAL_HTTP_OPENER.open(url, timeout=1) as response: + value = json.load(response) + if isinstance(value, dict): + return value + except Exception as exc: # noqa: BLE001 - Chrome 启动期间连接失败是预期状态。 + last_error = exc + time.sleep(0.1) + raise RuntimeError(f"Chrome DevTools 未就绪: {last_error}") + + +class CDPClient: + """最小 CDP 客户端,只实现本验证所需的调用、事件收集和超时边界。""" + + def __init__(self, websocket_url: str, timeout_seconds: int) -> None: + endpoint = urllib.parse.urlsplit(websocket_url) + # websocket-client 即使显式传空 proxy 也可能继续读取系统代理;预建 TCP + # socket 可确保 CDP 永远直连本机 Chrome,不把调试流量送出机器。 + raw_socket = socket.create_connection( + (endpoint.hostname or "127.0.0.1", endpoint.port or 80), + timeout=timeout_seconds, + ) + self._socket = websocket.create_connection( + websocket_url, + timeout=timeout_seconds, + origin="http://127.0.0.1", + socket=raw_socket, + ) + self._socket.settimeout(1) + self._next_id = 1 + self.events: list[dict[str, Any]] = [] + + def close(self) -> None: + self._socket.close() + + def call( + self, + method: str, + params: dict[str, Any] | None = None, + *, + timeout_seconds: int = 15, + ) -> dict[str, Any]: + request_id = self._next_id + self._next_id += 1 + self._socket.send( + json.dumps({"id": request_id, "method": method, "params": params or {}}) + ) + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + try: + message = json.loads(self._socket.recv()) + except websocket.WebSocketTimeoutException: + continue + if message.get("id") == request_id: + if "error" in message: + raise RuntimeError(f"CDP {method}失败: {message['error']}") + result = message.get("result") + return result if isinstance(result, dict) else {} + self.events.append(message) + raise RuntimeError(f"CDP {method}调用超时") + + def drain(self, duration_seconds: float = 0.1) -> None: + deadline = time.monotonic() + duration_seconds + while time.monotonic() < deadline: + try: + self.events.append(json.loads(self._socket.recv())) + except websocket.WebSocketTimeoutException: + return + + +def fetch_text(url: str, timeout: int = 20) -> str: + request = urllib.request.Request(url, headers={"Accept": "application/javascript,*/*"}) + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read().decode("utf-8") + + +def build_flutter_document_start_script( + *, game_id: str, launch_url: str, session_id: str, bridge_source: str +) -> str: + query = dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(launch_url).query)) + # Flutter 会把 URL query、后端 launch data 和目录元数据合并;ZGame 取身份字段时 + # 依赖 query 中的 openId/js_code,adapterType 则决定安装 ThirdPlatformBridge。 + config: dict[str, Any] = { + **query, + "adapterType": "zgame_v1", + "adapter_type": "zgame_v1", + "platformCode": "zgame", + "platform_code": "zgame", + } + payload = { + "sessionId": session_id, + "gameSessionId": session_id, + "gameId": game_id, + "provider": "zgame", + "vendorType": "zgame", + "launchUrl": launch_url, + "launchConfig": config, + "entry": {"entryUrl": launch_url}, + } + # 原生通道在模拟器中只记录动作;用户身份和 login 凭证由远程 bridge 同步返回, + # 不需要伪造厂商回调,也不会把 token 发送到除即构与线上 API 之外的域名。 + bootstrap = f""" +window.hyappGameLaunchPayload = {json.dumps(payload, ensure_ascii=False)}; +window.hyappGameLaunchConfig = {json.dumps(config, ensure_ascii=False)}; +window.hyappGameEntry = {json.dumps(payload['entry'], ensure_ascii=False)}; +window.addEventListener('unhandledrejection', function(event) {{ + var reason = event && event.reason; + try {{ console.error('[FlutterSimulatorUnhandledRejection]', JSON.stringify(reason)); }} + catch (_) {{ console.error('[FlutterSimulatorUnhandledRejection]', String(reason)); }} +}}); +window.addEventListener('error', function(event) {{ + console.error('[FlutterSimulatorError]', event && event.message || 'unknown error'); +}}); +window.GameBridgeChannel = {{ + postMessage: function(message) {{ console.info('[FlutterBridge]', message); }} +}}; +window.webkit = window.webkit || {{}}; +window.webkit.messageHandlers = window.webkit.messageHandlers || {{}}; +window.webkit.messageHandlers.GameBridgeChannel = window.GameBridgeChannel; +""" + return bootstrap + "\n;\n" + bridge_source + + +def evaluate_value(cdp: CDPClient, expression: str) -> Any: + result = cdp.call( + "Runtime.evaluate", + {"expression": expression, "returnByValue": True, "awaitPromise": True}, + ) + remote = result.get("result") + if not isinstance(remote, dict): + return None + return remote.get("value") + + +def browser_state(cdp: CDPClient) -> dict[str, Any]: + expression = r""" +(() => { + const visible = (element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return rect.width > 2 && rect.height > 2 && style.visibility !== 'hidden' && style.display !== 'none'; + }; + const canvases = [...document.querySelectorAll('canvas')]; + return { + readyState: document.readyState, + title: document.title, + bodyText: (document.body && document.body.innerText || '').trim().slice(0, 500), + bridgeReady: !!(window.ThirdPlatformBridge && window.ThirdPlatformBridge.__hyappZGameBridge), + canvasCount: canvases.length, + visibleCanvasCount: canvases.filter(visible).length, + largestCanvasPixels: canvases.reduce((max, canvas) => Math.max(max, canvas.width * canvas.height), 0), + imageCount: document.images.length, + href: location.href + }; +})() +""" + value = evaluate_value(cdp, expression) + return value if isinstance(value, dict) else {} + + +def relevant_browser_errors(events: list[dict[str, Any]]) -> list[str]: + errors: list[str] = [] + for event in events: + method = event.get("method") + params = event.get("params") or {} + if method == "Runtime.exceptionThrown": + detail = params.get("exceptionDetails") or {} + exception = detail.get("exception") or {} + text = str( + exception.get("description") + or detail.get("text") + or exception.get("value") + or "" + ) + if text: + errors.append(text[:500]) + elif method == "Log.entryAdded": + entry = params.get("entry") or {} + if entry.get("level") == "error": + errors.append(str(entry.get("text") or "")[:500]) + elif method == "Runtime.consoleAPICalled": + if params.get("type") not in {"error", "warning"}: + continue + values = [] + for argument in params.get("args") or []: + if not isinstance(argument, dict): + continue + values.append(str(argument.get("value") or argument.get("description") or "")) + if values: + errors.append("console: " + " ".join(values)[:2000]) + elif method == "Network.loadingFailed": + errors.append( + "network: " + + str(params.get("errorText") or "loading failed") + + " " + + str(params.get("blockedReason") or "") + ) + elif method == "Network.responseReceived": + response = params.get("response") or {} + status = int(response.get("status") or 0) + if status >= 400: + errors.append(f"http {status}: {response.get('url')}") + return list(dict.fromkeys(error for error in errors if error)) + + +def browser_network_trace(events: list[dict[str, Any]]) -> list[str]: + """保留末尾 XHR/fetch,避免把图片/CDN 噪声混进业务登录失败诊断。""" + trace: list[str] = [] + for event in events: + if event.get("method") != "Network.responseReceived": + continue + params = event.get("params") or {} + if params.get("type") not in {"XHR", "Fetch"}: + continue + response = params.get("response") or {} + trace.append(f"{int(response.get('status') or 0)} {response.get('url')}") + return trace[-20:] + + +def run_browser_check( + *, + chrome_path: str, + launch_url: str, + game_id: str, + session_id: str, + bridge_source: str, + screenshot_path: pathlib.Path, + timeout_seconds: int, +) -> tuple[dict[str, Any], list[str]]: + if not pathlib.Path(chrome_path).is_file(): + raise RuntimeError(f"找不到 Chrome: {chrome_path}") + port = reserve_port() + profile_dir = tempfile.mkdtemp(prefix="zgame-flutter-container-") + process = subprocess.Popen( + [ + chrome_path, + "--headless=new", + f"--remote-debugging-port={port}", + "--remote-allow-origins=*", + f"--user-data-dir={profile_dir}", + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + "about:blank", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + cdp: CDPClient | None = None + try: + wait_json(f"http://127.0.0.1:{port}/json/version", 10) + with LOCAL_HTTP_OPENER.open( + f"http://127.0.0.1:{port}/json/list", timeout=2 + ) as response: + targets = json.load(response) + page = next((item for item in targets if item.get("type") == "page"), None) + if not isinstance(page, dict) or not page.get("webSocketDebuggerUrl"): + raise RuntimeError("Chrome 未创建可调试页面") + cdp = CDPClient(str(page["webSocketDebuggerUrl"]), timeout_seconds) + for domain in ("Page.enable", "Runtime.enable", "Log.enable", "Network.enable"): + cdp.call(domain) + cdp.call( + "Emulation.setDeviceMetricsOverride", + { + "width": 390, + "height": 844, + "deviceScaleFactor": 2, + "mobile": True, + }, + ) + cdp.call( + "Network.setUserAgentOverride", + { + "userAgent": ( + "Mozilla/5.0 (Linux; Android 14; FlutterWebView) " + "AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 " + "Chrome/136.0.0.0 Mobile Safari/537.36" + ) + }, + ) + document_start = build_flutter_document_start_script( + game_id=game_id, + launch_url=launch_url, + session_id=session_id, + bridge_source=bridge_source, + ) + cdp.call("Page.addScriptToEvaluateOnNewDocument", {"source": document_start}) + cdp.call("Page.navigate", {"url": launch_url}) + + deadline = time.monotonic() + timeout_seconds + state: dict[str, Any] = {} + while time.monotonic() < deadline: + time.sleep(1) + cdp.drain(0.05) + state = browser_state(cdp) + # 厂商加载页也有背景素材;必须同时看到桥已安装和可见 canvas,才算 + # 通过授权阶段进入游戏渲染,而不是把 loading 背景误报为成功。 + if state.get("bridgeReady") and int(state.get("visibleCanvasCount") or 0) > 0: + break + + screenshot_path.parent.mkdir(parents=True, exist_ok=True) + screenshot = cdp.call( + "Page.captureScreenshot", + {"format": "png", "captureBeyondViewport": False}, + ) + screenshot_path.write_bytes(base64.b64decode(str(screenshot.get("data") or ""))) + cdp.drain(0.2) + errors = relevant_browser_errors(cdp.events) + network_trace = browser_network_trace(cdp.events) + if not state.get("bridgeReady"): + raise RuntimeError( + "ThirdPlatformBridge 未安装,Flutter 容器模拟失败; " + + json.dumps({"errors": errors[:10], "xhr": network_trace}, ensure_ascii=False) + ) + if int(state.get("visibleCanvasCount") or 0) <= 0: + raise RuntimeError( + f"游戏未进入 canvas 渲染,最终页面状态: {json.dumps(state, ensure_ascii=False)}; " + f"诊断: {json.dumps({'errors': errors[:10], 'xhr': network_trace}, ensure_ascii=False)}" + ) + return state, errors + finally: + if cdp is not None: + cdp.close() + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + shutil.rmtree(profile_dir, ignore_errors=True) + + +def main() -> int: + args = parse_args() + password = os.environ.get(args.password_env, "").strip() + if not password: + raise RuntimeError(f"请通过环境变量 {args.password_env} 提供线上测试密码") + api_base = args.api_base.rstrip("/") + common_headers = {"X-App-Code": args.app_code} + + login = require_envelope( + http_json( + f"{api_base}/auth/account/login", + method="POST", + body={ + "account": args.account, + "password": password, + "device_id": "zgame-flutter-container-check", + }, + headers=common_headers, + ), + "线上账号登录", + ) + access_token = str(login.get("access_token") or "").strip() + if not access_token: + raise RuntimeError("线上账号登录响应缺少 access_token") + auth_headers = {**common_headers, "Authorization": f"Bearer {access_token}"} + + games = require_envelope( + http_json(f"{api_base}/games?scene=voice_room", headers=auth_headers), + "读取游戏目录", + ) + catalog = games.get("games") + if not isinstance(catalog, list) or not any( + isinstance(item, dict) and item.get("game_id") == args.game_id + for item in catalog + ): + raise RuntimeError(f"线上 voice_room 目录找不到 {args.game_id}") + + launch = require_envelope( + http_json( + f"{api_base}/games/{urllib.parse.quote(args.game_id)}/launch", + method="POST", + body={}, + headers=auth_headers, + ), + "签发游戏启动会话", + ) + launch_url = str(launch.get("launch_url") or "").strip() + session_id = str(launch.get("session_id") or "").strip() + if not launch_url or not session_id: + raise RuntimeError("启动响应缺少 launch_url/session_id") + query = dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(launch_url).query)) + bridge_url = str(query.get("bridge_script_url") or query.get("bridgeScriptUrl") or "") + open_id = str(query.get("open_id") or query.get("openId") or "") + js_code = str(query.get("js_code") or query.get("jscode") or "") + callback_base = str(query.get("callback_base") or query.get("callbackBase") or "").rstrip("/") + if not bridge_url or not open_id or not js_code or not callback_base: + raise RuntimeError("启动链接缺少 bridge/open_id/js_code/callback_base") + + # 先直接验证服务端 login 回调,再让浏览器走一遍厂商 H5;两层都通过才能区分 + # “桥接显示正常”与“实际会话无法登录”。login 不消耗 token,页面仍可继续使用。 + callback = http_json( + f"{callback_base}/api/server/login", + method="POST", + body={"open_id": open_id, "js_code": js_code}, + headers=common_headers, + ) + callback_data = require_envelope(callback, "ZGame 服务端登录回调") + if not str(callback_data.get("session") or "").strip(): + raise RuntimeError("ZGame 服务端登录回调未返回 session") + + screenshot_path = pathlib.Path(args.screenshot).expanduser().resolve() + # 先输出本次短期链接;即使 H5 最终失败,调用方仍能复现同一厂商会话。 + print(f"expires_at_ms={launch.get('expires_at_ms')}", flush=True) + print(f"launch_url={launch_url}", flush=True) + state, browser_errors = run_browser_check( + chrome_path=args.chrome, + launch_url=launch_url, + game_id=args.game_id, + session_id=session_id, + bridge_source=fetch_text(bridge_url), + screenshot_path=screenshot_path, + timeout_seconds=args.timeout, + ) + print("ZGame Flutter 容器模拟验证通过") + print(f"account={args.account} game_id={args.game_id} session_id={session_id}") + print( + "browser_state=" + + json.dumps( + { + "bridge_ready": state.get("bridgeReady"), + "visible_canvas_count": state.get("visibleCanvasCount"), + "largest_canvas_pixels": state.get("largestCanvasPixels"), + "title": state.get("title"), + }, + ensure_ascii=False, + ) + ) + print(f"browser_error_count={len(browser_errors)}") + for error in browser_errors[:5]: + print(f"browser_error={error}") + print(f"screenshot={screenshot_path}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: # noqa: BLE001 - CLI 统一输出可执行失败原因。 + print(f"验证失败: {exc}", file=sys.stderr) + raise SystemExit(1) from exc diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 9e0978d3..3f1b3bdd 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -703,6 +703,7 @@ type fakeWalletClient struct { lastBatchEquipped *walletv1.BatchGetUserEquippedResourcesRequest batchEquippedRequests []*walletv1.BatchGetUserEquippedResourcesRequest batchEquippedResp *walletv1.BatchGetUserEquippedResourcesResponse + batchEquippedBudget time.Duration lastListRedPackets *walletv1.ListRedPacketsRequest listRedPacketsResp *walletv1.ListRedPacketsResponse lastGetRedPacket *walletv1.GetRedPacketRequest @@ -2737,9 +2738,12 @@ func (f *fakeWalletClient) UnequipUserResource(context.Context, *walletv1.Unequi return &walletv1.UnequipUserResourceResponse{}, nil } -func (f *fakeWalletClient) BatchGetUserEquippedResources(_ context.Context, req *walletv1.BatchGetUserEquippedResourcesRequest) (*walletv1.BatchGetUserEquippedResourcesResponse, error) { +func (f *fakeWalletClient) BatchGetUserEquippedResources(ctx context.Context, req *walletv1.BatchGetUserEquippedResourcesRequest) (*walletv1.BatchGetUserEquippedResourcesResponse, error) { f.lastBatchEquipped = req f.batchEquippedRequests = append(f.batchEquippedRequests, req) + if deadline, ok := ctx.Deadline(); ok { + f.batchEquippedBudget = time.Until(deadline) + } if f.err != nil { return nil, f.err } @@ -3702,7 +3706,27 @@ func TestJoinRoomLiteReturnsFirstScreenFieldsWithoutDisplayHydration(t *testing. Locked: true, }, }} - walletClient := &fakeWalletClient{batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{}} + walletClient := &fakeWalletClient{batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{ + Users: []*walletv1.UserEquippedResources{{ + UserId: 42, + Resources: []*walletv1.UserResourceEntitlement{{ + UserId: 42, + EntitlementId: "ent-lite-vehicle", + ExpiresAtMs: 1_999_999_999_999, + Equipped: true, + Resource: &walletv1.Resource{ + ResourceId: 8201, + ResourceCode: "vehicle-lite", + ResourceType: "vehicle", + Name: "Lite Vehicle", + AssetUrl: "https://cdn.example/vehicle.png", + PreviewUrl: "https://cdn.example/vehicle-preview.png", + AnimationUrl: "https://cdn.example/vehicle.svga", + MetadataJson: `{"format":"svga"}`, + }, + }}, + }}, + }} profileClient := &fakeUserProfileClient{} handler := NewHandlerWithConfig(roomClient, nil, nil, nil, TencentIMConfig{}, profileClient) handler.SetWalletClient(walletClient) @@ -3725,11 +3749,22 @@ func TestJoinRoomLiteReturnsFirstScreenFieldsWithoutDisplayHydration(t *testing. if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } - if roomClient.lastJoin == nil || roomClient.lastJoin.GetResponseMode() != "lite" || !roomClient.lastJoin.GetPresenceRecovery() || roomClient.lastJoin.GetEntryVehicle() != nil { - t.Fatalf("lite join must pass response_mode and skip entry vehicle: %+v", roomClient.lastJoin) + if roomClient.lastJoin == nil || roomClient.lastJoin.GetResponseMode() != "lite" || !roomClient.lastJoin.GetPresenceRecovery() { + t.Fatalf("lite join must preserve response_mode and recovery semantics: %+v", roomClient.lastJoin) } - if len(walletClient.batchEquippedRequests) != 0 { - t.Fatalf("lite join must not query wallet appearance or vehicle resources: %+v", walletClient.batchEquippedRequests) + vehicle := roomClient.lastJoin.GetEntryVehicle() + if vehicle.GetResourceId() != 8201 || vehicle.GetAnimationUrl() != "https://cdn.example/vehicle.svga" || vehicle.GetEntitlementId() != "ent-lite-vehicle" { + t.Fatalf("lite join must pass the current user's entry vehicle snapshot: %+v", vehicle) + } + if len(walletClient.batchEquippedRequests) != 1 { + t.Fatalf("lite join must issue exactly one minimal vehicle lookup: %+v", walletClient.batchEquippedRequests) + } + equippedRequest := walletClient.batchEquippedRequests[0] + if strings.Join(int64SliceToStrings(equippedRequest.GetUserIds()), ",") != "42" || strings.Join(equippedRequest.GetResourceTypes(), ",") != "vehicle" { + t.Fatalf("lite vehicle lookup must stay scoped to the actor and vehicle type: %+v", equippedRequest) + } + if walletClient.batchEquippedBudget <= 0 || walletClient.batchEquippedBudget > 100*time.Millisecond { + t.Fatalf("lite vehicle lookup must use the short best-effort budget: %s", walletClient.batchEquippedBudget) } if len(profileClient.batchRequests) != 0 { t.Fatalf("lite join must not call full BatchGetUsers: %+v", profileClient.batchRequests) @@ -3772,6 +3807,30 @@ func TestJoinRoomLiteReturnsFirstScreenFieldsWithoutDisplayHydration(t *testing. } } +func TestJoinRoomLiteContinuesWhenEntryVehicleLookupFails(t *testing.T) { + roomClient := &fakeRoomClient{} + walletClient := &fakeWalletClient{err: errors.New("wallet unavailable")} + handler := NewHandlerWithConfig(roomClient, nil, nil, nil, TencentIMConfig{}, &fakeUserProfileClient{}) + handler.SetWalletClient(walletClient) + router := handler.Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/join", bytes.NewReader([]byte(`{"room_id":"room_lite_degraded","command_id":"cmd-join-lite-degraded","role":"audience","response_mode":"lite"}`))) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-join-lite-degraded") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("vehicle lookup failure must not block lite join: status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if roomClient.lastJoin == nil || roomClient.lastJoin.GetEntryVehicle() != nil { + t.Fatalf("degraded lite join must submit presence without a fabricated vehicle: %+v", roomClient.lastJoin) + } + if len(walletClient.batchEquippedRequests) != 1 || walletClient.batchEquippedBudget <= 0 || walletClient.batchEquippedBudget > 100*time.Millisecond { + t.Fatalf("degraded lite join must keep one bounded vehicle lookup: requests=%+v budget=%s", walletClient.batchEquippedRequests, walletClient.batchEquippedBudget) + } +} + func TestListRoomsUsesUserRegionFromUserService(t *testing.T) { profileClient := &fakeUserProfileClient{regionID: 1001} regionClient := &fakeUserRegionClient{resp: &userv1.RegionResponse{Region: &userv1.Region{ diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_handler.go index eb5f2a32..04803831 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -46,6 +46,8 @@ const ( // JoinRoom 默认保持历史完整包;lite 只保留进房首屏必要字段,把装扮/榜单等增强展示后移。 joinRoomResponseModeFull = "full" joinRoomResponseModeLite = "lite" + // 座驾只影响进房展示,独立短预算避免 wallet 抖动把 lite 首屏拖到通用 5 秒 RPC 上限。 + joinRoomEntryVehicleLookupTimeout = 100 * time.Millisecond ) type flexibleInt64 int64 @@ -1353,10 +1355,6 @@ func joinRoomResponseMode(request *http.Request, bodyMode string) (string, bool) } func (h *Handler) joinRoomEntryVehicleSnapshot(request *http.Request, responseMode string) *roomv1.RoomEntryVehicleSnapshot { - if responseMode == joinRoomResponseModeLite { - // lite 进房只提交 presence 必需事实;座驾属于入场展示增强,后续由资料/装扮接口补齐。 - return nil - } if h.walletClient == nil { // 座驾只影响进房展示,不应该因为 wallet 暂不可用阻断 JoinRoom。 return nil @@ -1365,7 +1363,15 @@ func (h *Handler) joinRoomEntryVehicleSnapshot(request *http.Request, responseMo if userID <= 0 { return nil } - resp, err := h.walletClient.BatchGetUserEquippedResources(request.Context(), &walletv1.BatchGetUserEquippedResourcesRequest{ + lookupCtx := request.Context() + if responseMode == joinRoomResponseModeLite { + // lite 只裁剪返回给进房者的首屏数据,不改变发给房内其他用户的进房事件事实; + // 新增查询只查当前用户的一种资源,并用短预算保护 lite 延迟。full 保持原超时语义,避免扩大兼容性影响。 + var cancel context.CancelFunc + lookupCtx, cancel = context.WithTimeout(lookupCtx, joinRoomEntryVehicleLookupTimeout) + defer cancel() + } + resp, err := h.walletClient.BatchGetUserEquippedResources(lookupCtx, &walletv1.BatchGetUserEquippedResourcesRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), UserIds: []int64{userID}, diff --git a/services/room-service/internal/room/service/room_rocket_test.go b/services/room-service/internal/room/service/room_rocket_test.go index a4522994..4ac6e08c 100644 --- a/services/room-service/internal/room/service/room_rocket_test.go +++ b/services/room-service/internal/room/service/room_rocket_test.go @@ -126,6 +126,70 @@ func TestJoinRoomDirectIMCarriesDisplayProfileSnapshot(t *testing.T) { } } +func TestJoinRoomLiteCarriesEntryVehicleToDirectIMAndOutbox(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + now := &fixedRoomRocketClock{now: time.Date(2026, 7, 14, 8, 0, 0, 0, time.UTC)} + directIM := newRecordingRoomDirectIMPublisher() + svc := newRocketTestServiceWithDirectIM(t, repository, &rocketTestWallet{}, now, nil, directIM) + + roomID := "room-lite-entry-vehicle" + userID := int64(1002) + createRocketRoom(t, ctx, svc, roomID, 1001, 9001) + response, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ + Meta: roomservice.NewRequestMeta(roomID, userID), + Role: "audience", + ResponseMode: "lite", + EntryVehicle: &roomv1.RoomEntryVehicleSnapshot{ + ResourceId: 8201, + ResourceCode: "vehicle-lite", + Name: "Lite Vehicle", + AssetUrl: "https://cdn.example/vehicle.png", + PreviewUrl: "https://cdn.example/vehicle-preview.png", + AnimationUrl: "https://cdn.example/vehicle.svga", + MetadataJson: `{"format":"svga"}`, + EntitlementId: "ent-lite-vehicle", + ExpiresAtMs: 1_999_999_999_999, + }, + }) + if err != nil { + t.Fatalf("lite join with entry vehicle failed: %v", err) + } + if len(response.GetRoom().GetOnlineUsers()) != 0 { + t.Fatalf("lite response must remain trimmed while its IM event carries the vehicle: %+v", response.GetRoom()) + } + waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomUserJoined": 1}) + + directRecords := directIM.recordsByType("RoomUserJoined") + if len(directRecords) != 1 { + t.Fatalf("lite join direct IM count mismatch: %+v", directRecords) + } + assertJoinedEntryVehicle := func(source string, body []byte) { + t.Helper() + var joined roomeventsv1.RoomUserJoined + if err := proto.Unmarshal(body, &joined); err != nil { + t.Fatalf("decode %s RoomUserJoined failed: %v", source, err) + } + vehicle := joined.GetEntryVehicle() + if vehicle.GetResourceId() != 8201 || vehicle.GetAnimationUrl() != "https://cdn.example/vehicle.svga" || vehicle.GetEntitlementId() != "ent-lite-vehicle" { + t.Fatalf("%s must preserve the entry vehicle snapshot: %+v", source, vehicle) + } + } + assertJoinedEntryVehicle("direct IM", directRecords[0].GetBody()) + + pendingRecords, err := repository.ListPendingOutbox(ctx, 20) + if err != nil { + t.Fatalf("list lite join outbox failed: %v", err) + } + for _, record := range pendingRecords { + if record.EventType == "RoomUserJoined" && record.RoomID == roomID { + assertJoinedEntryVehicle("durable outbox", record.Envelope.GetBody()) + return + } + } + t.Fatalf("lite join RoomUserJoined outbox record not found: %+v", pendingRecords) +} + func TestRepeatJoinRoomPublishesEntryBroadcastDirectIMOnly(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t)