座驾问题解决
This commit is contained in:
parent
d6c2948ab0
commit
4e0c9f7cb2
555
scripts/zgame-flutter-container-check.py
Executable file
555
scripts/zgame-flutter-container-check.py
Executable file
@ -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
|
||||
@ -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{
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user