修复微信QQ支付参数分流

This commit is contained in:
yml2213
2026-08-12 21:59:56 +08:00
parent d610933f87
commit 8e4e394c4b
4 changed files with 70 additions and 24 deletions
@@ -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();
@@ -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:
@@ -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,7 +452,9 @@ 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...")
# 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())
@@ -455,7 +464,7 @@ def main() -> int:
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')} "
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
@@ -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,
@@ -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()