修复微信QQ支付参数分流
This commit is contained in:
@@ -3,11 +3,17 @@ import { JSDOM, ResourceLoader } from 'jsdom';
|
|||||||
import { patchEnvironment } from './scripts/jsdom-patch-env.mjs';
|
import { patchEnvironment } from './scripts/jsdom-patch-env.mjs';
|
||||||
|
|
||||||
const ROOT = '/Users/yml/codes/douyu_login_py';
|
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 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 captured = [];
|
||||||
const requests = [];
|
const requests = [];
|
||||||
|
const pageErrors = [];
|
||||||
const dom = new JSDOM(fs.readFileSync(`${out}/goods.html`, 'utf8'), {
|
const dom = new JSDOM(fs.readFileSync(`${out}/goods.html`, 'utf8'), {
|
||||||
url: goodsUrl,
|
url: goodsUrl,
|
||||||
referrer: 'https://z.iwan.yyb.qq.com/',
|
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 });
|
requests.push({ name, url: this.__u.slice(0, 120), bodyLen: String(body || '').length });
|
||||||
if (name === 'web_save') captured.push(String(body || ''));
|
if (name === 'web_save') captured.push(String(body || ''));
|
||||||
this.readyState = 4; this.status = 200;
|
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.onreadystatechange === 'function') this.onreadystatechange();
|
||||||
if (typeof this.onload === 'function') this.onload();
|
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 ===');
|
console.log('=== 页面发出的 XHR ===');
|
||||||
for (const r of requests) console.log(' ', r.name, '|', r.url, '| bodyLen:', r.bodyLen);
|
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('=== 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');
|
const btns = dom.window.document.querySelectorAll('[class*="btn"],[class*="confirm"],[class*="submit"],button');
|
||||||
for (const b of btns) {
|
for (const b of btns) {
|
||||||
const t = (b.textContent || '').trim().replace(/\s+/g, ' ');
|
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),未生成微信付款码;"
|
"QQ 支付请求被风控/限流暂时拦截(1099-1007-0),未生成微信付款码;"
|
||||||
"请停止连续重试,等待 30-60 分钟后再使用新订单尝试。"
|
"请停止连续重试,等待 30-60 分钟后再使用新订单尝试。"
|
||||||
)
|
)
|
||||||
return "支付请求被暂时拦截(1099-1007-0),未生成微信付款码;请稍后使用新订单重试。"
|
return "微信支付请求被服务端拒绝(1099-1007-0),未生成微信付款码;请查看本次请求证据。"
|
||||||
|
|
||||||
if "10007" in text:
|
if "10007" in text:
|
||||||
if is_qq:
|
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)
|
return urllib.parse.urlencode(fields)
|
||||||
|
|
||||||
|
|
||||||
def make_encrypt_rand(params: dict[str, str], fk_extend: str, ts: str) -> str:
|
def make_encrypt_rand(params: dict[str, str], fk_extend: str, ts: str,
|
||||||
"""生成与当前订单明文长度匹配的 _rand,确保 goods VM 不截断尾块。"""
|
is_qq_login: bool) -> str:
|
||||||
|
"""按登录渠道生成页面已验证形态的 _rand,不能仅按长度替换控制字节。"""
|
||||||
prefix = "".join(random.choices(string.ascii_letters + string.digits, k=8))
|
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"))
|
base_length = len(build_plaintext(params, fk_extend, ts, "").encode("latin-1"))
|
||||||
padding_length = (-(base_length + len(prefix))) % 16
|
padding_length = (-(base_length + len(prefix))) % 16
|
||||||
return prefix + ("\x03" * padding_length)
|
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', '')}")
|
print(f"[jsdom-pay] fp-behv 失败: ret={fp_json.get('ret')} {fp_json.get('msg', '')}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
print("[jsdom-pay] 拉取 web_page_info...")
|
# QQ 成功 HAR 包含该前置;微信历史成功链路没有,不能将 QQ 状态机混入微信请求。
|
||||||
page_info = request_bytes(PAGE_INFO_URL, cookies, build_page_info_body(
|
if midas_login_params(cookies)["qq_appid"]:
|
||||||
order, cookies, anti_token, args.zone_id, payment_pf,
|
print("[jsdom-pay] 拉取 QQ web_page_info...")
|
||||||
).encode())
|
page_info = request_bytes(PAGE_INFO_URL, cookies, build_page_info_body(
|
||||||
(out_dir / "web-page-info-response.json").write_text(page_info, encoding="utf-8")
|
order, cookies, anti_token, args.zone_id, payment_pf,
|
||||||
try:
|
).encode())
|
||||||
page_info_json = json.loads(page_info)
|
(out_dir / "web-page-info-response.json").write_text(page_info, encoding="utf-8")
|
||||||
except json.JSONDecodeError:
|
try:
|
||||||
page_info_json = {}
|
page_info_json = json.loads(page_info)
|
||||||
if page_info_json.get("ret") != 0:
|
except json.JSONDecodeError:
|
||||||
print(f"[jsdom-pay] web_page_info 失败: ret={page_info_json.get('ret')} "
|
page_info_json = {}
|
||||||
f"{page_info_json.get('msg', '')}")
|
if page_info_json.get("ret") != 0:
|
||||||
print(f"[jsdom-pay] 证据目录: {out_dir}")
|
print(f"[jsdom-pay] QQ web_page_info 失败: ret={page_info_json.get('ret')} "
|
||||||
return 1
|
f"{page_info_json.get('msg', '')}")
|
||||||
|
print(f"[jsdom-pay] 证据目录: {out_dir}")
|
||||||
|
return 1
|
||||||
|
|
||||||
web_args = load_template_args()
|
web_args = load_template_args()
|
||||||
# key 派生已破解(F-2052,2026-08-12):key16 可随机生成,key1 由反解器
|
# key 派生已破解(F-2052,2026-08-12):key16 可随机生成,key1 由反解器
|
||||||
@@ -489,7 +498,9 @@ def main() -> int:
|
|||||||
params.update(midas_login_params(cookies))
|
params.update(midas_login_params(cookies))
|
||||||
now_seconds = str(int(time.time()))
|
now_seconds = str(int(time.time()))
|
||||||
fk_extend = "tdrc_session%3D" + fp["session_id"]
|
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"))
|
plaintext_length = len(build_plaintext(params, fk_extend, now_seconds, random_suffix).encode("latin-1"))
|
||||||
encrypt_msg = generate_encrypt_msg_offline(
|
encrypt_msg = generate_encrypt_msg_offline(
|
||||||
params, fk_extend, now_seconds, random_suffix,
|
params, fk_extend, now_seconds, random_suffix,
|
||||||
|
|||||||
@@ -74,22 +74,32 @@ class TestJsdomPay(unittest.TestCase):
|
|||||||
self.assertIn("未生成微信付款码", message)
|
self.assertIn("未生成微信付款码", message)
|
||||||
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):
|
def test_game_role_error_is_classified_separately(self):
|
||||||
message = describe_payment_failure("QC", "order", "inner=10014 gamerole err")
|
message = describe_payment_failure("QC", "order", "inner=10014 gamerole err")
|
||||||
self.assertIn("角色或区服校验失败", message)
|
self.assertIn("角色或区服校验失败", message)
|
||||||
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 = {
|
params = {
|
||||||
"token_id": "t", "openid": "o", "openkey": "k", "session_id": "openid",
|
"token_id": "t", "openid": "o", "openkey": "k", "session_id": "openid",
|
||||||
"session_type": "kp_accesstoken", "zoneid": "1", "pay_method": "wechat",
|
"session_type": "kp_accesstoken", "zoneid": "1", "pay_method": "wechat",
|
||||||
"buy_quantity": "1", "from_h5": "1", "webversion": "minipayv2",
|
"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)
|
plaintext = _MODULE.build_plaintext(params, "tdrc_session%3Dpay-test", "1775990000", rand_value)
|
||||||
self.assertEqual(len(plaintext.encode("latin-1")) % 16, 0)
|
self.assertEqual(len(plaintext.encode("latin-1")) % 16, 0)
|
||||||
self.assertGreaterEqual(len(rand_value), 8)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user