diff --git a/services/yyb-worker/runtime/probe-jsdom-pay.mjs b/services/yyb-worker/runtime/probe-jsdom-pay.mjs index c4d0deb..14d00ad 100644 --- a/services/yyb-worker/runtime/probe-jsdom-pay.mjs +++ b/services/yyb-worker/runtime/probe-jsdom-pay.mjs @@ -3,11 +3,17 @@ import { JSDOM, ResourceLoader } from 'jsdom'; import { patchEnvironment } from './scripts/jsdom-patch-env.mjs'; const ROOT = '/Users/yml/codes/douyu_login_py'; -const out = ROOT + '/data/yyb-worker-dev/49684f85543d4fd1/jsdom-order'; +const taskId = process.argv[2] || '142265ef3d874ce6'; +const out = `${ROOT}/data/yyb-worker-dev/${taskId}/jsdom-order`; const goodsUrl = JSON.parse(fs.readFileSync(`${out}/device-fp.json`, 'utf8')).goods_url; +const pageInfoPath = process.argv[3] || `${out}/web-page-info-response.json`; +const pageInfo = fs.existsSync(pageInfoPath) + ? fs.readFileSync(pageInfoPath, 'utf8') + : '{"ret":0,"msg":"","info":{}}'; const captured = []; const requests = []; +const pageErrors = []; const dom = new JSDOM(fs.readFileSync(`${out}/goods.html`, 'utf8'), { url: goodsUrl, referrer: 'https://z.iwan.yyb.qq.com/', @@ -26,11 +32,15 @@ const dom = new JSDOM(fs.readFileSync(`${out}/goods.html`, 'utf8'), { requests.push({ name, url: this.__u.slice(0, 120), bodyLen: String(body || '').length }); if (name === 'web_save') captured.push(String(body || '')); this.readyState = 4; this.status = 200; - this.responseText = '{"ret":0,"msg":"","info":{}}'; + this.responseText = name === 'web_page_info' ? pageInfo : '{"ret":0,"msg":"","info":{}}'; + this.response = this.responseText; if (typeof this.onreadystatechange === 'function') this.onreadystatechange(); if (typeof this.onload === 'function') this.onload(); + this.dispatchEvent(new window.Event('readystatechange')); + this.dispatchEvent(new window.Event('load')); } }; + window.addEventListener('error', event => pageErrors.push(String(event.error || event.message || 'unknown error'))); }, }); @@ -38,9 +48,24 @@ await new Promise(r => setTimeout(r, 15000)); console.log('=== 页面发出的 XHR ==='); for (const r of requests) console.log(' ', r.name, '|', r.url, '| bodyLen:', r.bodyLen); console.log('=== web_save body 捕获:', captured.length ? 'YES len=' + captured[0].length : 'NO'); -console.log('=== 页面按钮 ==='); +console.log('=== 页面可见控件 ==='); const btns = dom.window.document.querySelectorAll('[class*="btn"],[class*="confirm"],[class*="submit"],button'); for (const b of btns) { const t = (b.textContent || '').trim().replace(/\s+/g, ' '); - if (t && t.length < 40 && b.offsetParent !== null) console.log(' btn:', t.slice(0, 30)); + if (t && t.length < 40) console.log(' control:', b.tagName, b.className, '|', t.slice(0, 30)); } +console.log('=== Avalon 控制器 ==='); +const models = dom.window.avalon?.vmodels || {}; +for (const [id, model] of Object.entries(models)) { + const keys = Object.keys(model).filter(key => !key.startsWith('$')).slice(0, 30); + console.log(' model:', id, '|', keys.join(',')); +} +console.log('=== 订单状态 ==='); +console.log(JSON.stringify({ + channelListLength: String(models.order?.channelList || '').length, + channelData: models.order?.channelData, + goodsName: models.order?.goodsName, + amount: models.order?.amount, + pageErrors: pageErrors.slice(0, 10), +}, null, 2)); +dom.window.close(); diff --git a/services/yyb-worker/runtime/pyvm/payment_errors.py b/services/yyb-worker/runtime/pyvm/payment_errors.py index c7ef357..a6b549b 100644 --- a/services/yyb-worker/runtime/pyvm/payment_errors.py +++ b/services/yyb-worker/runtime/pyvm/payment_errors.py @@ -13,7 +13,7 @@ def describe_payment_failure(provider: str, phase: str, response: object) -> str "QQ 支付请求被风控/限流暂时拦截(1099-1007-0),未生成微信付款码;" "请停止连续重试,等待 30-60 分钟后再使用新订单尝试。" ) - return "支付请求被暂时拦截(1099-1007-0),未生成微信付款码;请稍后使用新订单重试。" + return "微信支付请求被服务端拒绝(1099-1007-0),未生成微信付款码;请查看本次请求证据。" if "10007" in text: if is_qq: diff --git a/services/yyb-worker/runtime/scripts/jsdom-pay.py b/services/yyb-worker/runtime/scripts/jsdom-pay.py index f682477..1ec78b4 100644 --- a/services/yyb-worker/runtime/scripts/jsdom-pay.py +++ b/services/yyb-worker/runtime/scripts/jsdom-pay.py @@ -221,9 +221,16 @@ def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_to return urllib.parse.urlencode(fields) -def make_encrypt_rand(params: dict[str, str], fk_extend: str, ts: str) -> str: - """生成与当前订单明文长度匹配的 _rand,确保 goods VM 不截断尾块。""" +def make_encrypt_rand(params: dict[str, str], fk_extend: str, ts: str, + is_qq_login: bool) -> str: + """按登录渠道生成页面已验证形态的 _rand,不能仅按长度替换控制字节。""" prefix = "".join(random.choices(string.ascii_letters + string.digits, k=8)) + # 微信历史成功请求固定使用 8 个随机字符加 \x01。_rand 位于加密明文中, + # 末控制字节是协议内容,不能为了对齐擅自替换为 QQ 使用的 \x03。 + if not is_qq_login: + return prefix + "\x01" + + # QQ 当前订单的明文长度会随 OAuth 字段变化;补齐后才能避免 VM 丢弃尾块。 base_length = len(build_plaintext(params, fk_extend, ts, "").encode("latin-1")) padding_length = (-(base_length + len(prefix))) % 16 return prefix + ("\x03" * padding_length) @@ -445,20 +452,22 @@ def main() -> int: print(f"[jsdom-pay] fp-behv 失败: ret={fp_json.get('ret')} {fp_json.get('msg', '')}") return 1 - print("[jsdom-pay] 拉取 web_page_info...") - page_info = request_bytes(PAGE_INFO_URL, cookies, build_page_info_body( - order, cookies, anti_token, args.zone_id, payment_pf, - ).encode()) - (out_dir / "web-page-info-response.json").write_text(page_info, encoding="utf-8") - try: - page_info_json = json.loads(page_info) - except json.JSONDecodeError: - page_info_json = {} - if page_info_json.get("ret") != 0: - print(f"[jsdom-pay] web_page_info 失败: ret={page_info_json.get('ret')} " - f"{page_info_json.get('msg', '')}") - print(f"[jsdom-pay] 证据目录: {out_dir}") - return 1 + # QQ 成功 HAR 包含该前置;微信历史成功链路没有,不能将 QQ 状态机混入微信请求。 + if midas_login_params(cookies)["qq_appid"]: + print("[jsdom-pay] 拉取 QQ web_page_info...") + page_info = request_bytes(PAGE_INFO_URL, cookies, build_page_info_body( + order, cookies, anti_token, args.zone_id, payment_pf, + ).encode()) + (out_dir / "web-page-info-response.json").write_text(page_info, encoding="utf-8") + try: + page_info_json = json.loads(page_info) + except json.JSONDecodeError: + page_info_json = {} + if page_info_json.get("ret") != 0: + print(f"[jsdom-pay] QQ web_page_info 失败: ret={page_info_json.get('ret')} " + f"{page_info_json.get('msg', '')}") + print(f"[jsdom-pay] 证据目录: {out_dir}") + return 1 web_args = load_template_args() # key 派生已破解(F-2052,2026-08-12):key16 可随机生成,key1 由反解器 @@ -489,7 +498,9 @@ def main() -> int: params.update(midas_login_params(cookies)) now_seconds = str(int(time.time())) fk_extend = "tdrc_session%3D" + fp["session_id"] - random_suffix = make_encrypt_rand(params, fk_extend, now_seconds) + random_suffix = make_encrypt_rand( + params, fk_extend, now_seconds, bool(midas_login_params(cookies)["qq_appid"]), + ) plaintext_length = len(build_plaintext(params, fk_extend, now_seconds, random_suffix).encode("latin-1")) encrypt_msg = generate_encrypt_msg_offline( params, fk_extend, now_seconds, random_suffix, diff --git a/services/yyb-worker/runtime/tests/test_jsdom_pay.py b/services/yyb-worker/runtime/tests/test_jsdom_pay.py index 9193b6b..f34fb19 100644 --- a/services/yyb-worker/runtime/tests/test_jsdom_pay.py +++ b/services/yyb-worker/runtime/tests/test_jsdom_pay.py @@ -74,22 +74,32 @@ class TestJsdomPay(unittest.TestCase): self.assertIn("未生成微信付款码", message) self.assertIn("停止连续重试", message) + def test_wechat_rejection_is_not_labeled_as_risk_control(self): + message = describe_payment_failure("WX", "payment", "ret=1099 err_code=1099-1007-0") + self.assertIn("服务端拒绝", message) + self.assertNotIn("风控", message) + def test_game_role_error_is_classified_separately(self): message = describe_payment_failure("QC", "order", "inner=10014 gamerole err") self.assertIn("角色或区服校验失败", message) self.assertIn("重新选择角色和区服", message) - def test_encrypt_rand_aligns_plaintext_to_aes_block(self): + def test_qq_encrypt_rand_aligns_plaintext_to_aes_block(self): params = { "token_id": "t", "openid": "o", "openkey": "k", "session_id": "openid", "session_type": "kp_accesstoken", "zoneid": "1", "pay_method": "wechat", "buy_quantity": "1", "from_h5": "1", "webversion": "minipayv2", } - rand_value = _MODULE.make_encrypt_rand(params, "tdrc_session%3Dpay-test", "1775990000") + rand_value = _MODULE.make_encrypt_rand(params, "tdrc_session%3Dpay-test", "1775990000", True) plaintext = _MODULE.build_plaintext(params, "tdrc_session%3Dpay-test", "1775990000", rand_value) self.assertEqual(len(plaintext.encode("latin-1")) % 16, 0) self.assertGreaterEqual(len(rand_value), 8) + def test_wechat_encrypt_rand_preserves_historical_control_byte(self): + rand_value = _MODULE.make_encrypt_rand({}, "tdrc_session%3Dpay-test", "1775990000", False) + self.assertEqual(len(rand_value), 9) + self.assertEqual(rand_value[-1], "\x01") + if __name__ == "__main__": unittest.main()