qq 也可以获取支付二维码 成功微信支付了
This commit is contained in:
@@ -36,10 +36,12 @@ from pyvm.algorithm import build_plaintext, generate_encrypt_msg, generate_encry
|
||||
from pyvm.mall import MallSession, generate_encrypt_msg as mall_generate # noqa: E402
|
||||
from pyvm.session import SessionState, load_session # noqa: E402
|
||||
from pyvm.login_profile import midas_login_params # noqa: E402
|
||||
from pyvm.payment_errors import describe_payment_failure # noqa: E402
|
||||
|
||||
REPLAY = ROOT / "replay"
|
||||
DEFAULT_APPID = "1450243039"
|
||||
DEFAULT_SAVE_URL = f"https://api.unipay.qq.com/v1/r/{DEFAULT_APPID}/web_save"
|
||||
MALL_API_URL = "https://storeapi.pay.qq.com/api/CommonCallMpgo"
|
||||
ORDER_FIELDS = ["token_id", "openid", "openkey", "session_id", "session_type", "zoneid",
|
||||
"pay_method", "buy_quantity", "mb_pwd", "pay_id", "auth_key",
|
||||
"card_value", "accounttype", "provide_uin", "extend", "ts",
|
||||
@@ -367,7 +369,7 @@ def cmd_mall_submit(args) -> int:
|
||||
body_obj["call_param"]["call_param_json"] = json.dumps(cpj, ensure_ascii=False)
|
||||
new_body = json.dumps(body_obj, ensure_ascii=False)
|
||||
|
||||
url = "https://pagedooapi.pay.qq.com/api/CommonCallMpgo?t=" + str(int(time.time() * 1000))
|
||||
url = MALL_API_URL + "?t=" + str(int(time.time() * 1000))
|
||||
cookie_str = "; ".join(f"{k}={v}" for k, v in cookies.items())
|
||||
# midas 域 cookie 兜底(profile 导出常缺 midas_openid/midas_openkey,值为 openid/accesstoken)
|
||||
if "midas_openid" not in cookies and "openid" in cookies:
|
||||
@@ -552,7 +554,7 @@ def mall_getpaytoken(cookies: dict) -> tuple[list, str]:
|
||||
"login_check_param_json": json.dumps(login),
|
||||
},
|
||||
}
|
||||
url = "https://pagedooapi.pay.qq.com/api/CommonCallMpgo?t=" + str(int(time.time() * 1000))
|
||||
url = MALL_API_URL + "?t=" + str(int(time.time() * 1000))
|
||||
cookie_str = "; ".join(f"{k}={v}" for k, v in cookies.items())
|
||||
for k, v in [("midas_openid", "openid"), ("midas_openkey", "accesstoken")]:
|
||||
if k not in cookies and v in cookies:
|
||||
@@ -690,7 +692,7 @@ def cmd_mall_auto(args) -> int:
|
||||
for k, v in [("midas_openid", "openid"), ("midas_openkey", "accesstoken")]:
|
||||
if k not in cookies and v in cookies:
|
||||
cookie_str += f"; {k}=" + cookies[v]
|
||||
url = "https://pagedooapi.pay.qq.com/api/CommonCallMpgo?t=" + str(int(time.time() * 1000))
|
||||
url = MALL_API_URL + "?t=" + str(int(time.time() * 1000))
|
||||
req = urllib.request.Request(url, data=new_body.encode("utf-8"), headers={
|
||||
"Content-Type": "application/json", "Accept": "application/json, text/plain, */*",
|
||||
"Origin": "https://z.iwan.yyb.qq.com", "Referer": "https://z.iwan.yyb.qq.com/",
|
||||
@@ -722,6 +724,7 @@ def cmd_mall_auto(args) -> int:
|
||||
return 0
|
||||
detail = js.get("result_info", "") or call_reply.get("result_info", "") or ""
|
||||
print(f"❌ 下单失败 ret={ret} inner={inner_ret} ({detail})")
|
||||
print(f" {describe_payment_failure(str(cookies.get('logintype', '')), 'order', f'{ret} {inner_ret} {detail}')}")
|
||||
print(f" 响应已保存 → {out}")
|
||||
return 1
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import fs from 'node:fs';
|
||||
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 goodsUrl = JSON.parse(fs.readFileSync(`${out}/device-fp.json`, 'utf8')).goods_url;
|
||||
|
||||
const captured = [];
|
||||
const requests = [];
|
||||
const dom = new JSDOM(fs.readFileSync(`${out}/goods.html`, 'utf8'), {
|
||||
url: goodsUrl,
|
||||
referrer: 'https://z.iwan.yyb.qq.com/',
|
||||
pretendToBeVisual: true,
|
||||
runScripts: 'dangerously',
|
||||
resources: new ResourceLoader(),
|
||||
beforeParse(window) {
|
||||
patchEnvironment(window);
|
||||
const NativeXHR = window.XMLHttpRequest;
|
||||
window.XMLHttpRequest = class extends NativeXHR {
|
||||
open(method, url, async = true) { this.__m = method; this.__u = String(url); return super.open(method, url, async); }
|
||||
send(body) {
|
||||
const name = this.__u.includes('web_save') ? 'web_save'
|
||||
: this.__u.includes('web_page_info') ? 'web_page_info'
|
||||
: this.__u.includes('fp-behv') ? 'fp_behv' : 'other';
|
||||
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":{}}';
|
||||
if (typeof this.onreadystatechange === 'function') this.onreadystatechange();
|
||||
if (typeof this.onload === 'function') this.onload();
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
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('=== 页面按钮 ===');
|
||||
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));
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
"""encrypt_msg (web_new_encrypt) 生成器 — goodsBiz CHAOS VM 的纯 Python 移植
|
||||
|
||||
算法背景(见 FINDINGS.md F-2038..F-2043):
|
||||
算法背景(见 FINDINGS.md F-2038..F-2043,破解见 F-2052/2026-08-12):
|
||||
- goodsBiz.js 是腾讯 __TENCENT_CHAOS_VM 解释器(116 opcode,0..115)+ 178093 元素字节码。
|
||||
- 加密核心 = webSave h@85091(18 参):表生成 → 检查 window.xMidasOps → 33 次块变换
|
||||
(T 表 AES 类)→ 回调 h@69667(4 参)→ randGen h@85173(16 次重随机 key2)。
|
||||
- 输入:18 参(key1 诱饵 / key2 真实 / args[10]=528B 明文缓冲 / 常量表 / 回调)。
|
||||
- 输入:18 参(key1 诱饵 / key2 真实 / args[10]=16 字节对齐的明文缓冲 / 常量表 / 回调)。
|
||||
- key 派生已破解:key16 = Sbox[Te 链(key1)];服务端校验两者派生一致性,
|
||||
离线用随机 key16 + derive_key1_from_key16 反解 key1 即可通过(无需页面捕获 key)。
|
||||
- 输出:C[3476] 528 字节密文(hex 1056 字符,头部 5574bea9... 固定前缀)。
|
||||
|
||||
本文件复刻解释器语义,可在无浏览器环境运行;两组独立真实向量均 1056/1056 逐字节一致。
|
||||
@@ -660,6 +662,14 @@ class VM:
|
||||
except Exception:
|
||||
parts.append("??")
|
||||
self.out_hex = "".join(parts)
|
||||
# 仅供离线排查 webSave 输出缓冲何时变化,生产路径默认不启用。
|
||||
if getattr(self, "capture_output_lengths", False):
|
||||
v = js_index(C, 3476)
|
||||
if isinstance(v, list):
|
||||
length = len(v)
|
||||
if length != getattr(self, "_last_output_length", None):
|
||||
self._last_output_length = length
|
||||
self.output_length_trace.append((u, op, length))
|
||||
# ---- opcode dispatch ----
|
||||
if op == 0:
|
||||
a, b, c = o[u+1], o[u+2], o[u+3]; u += 3
|
||||
@@ -1461,15 +1471,18 @@ def derive_key1_from_key16(key16, te_tables=None, sbox=None):
|
||||
def generate_encrypt_msg_offline(params, fk_extend, ts, rand_val,
|
||||
key16=None, key1=None,
|
||||
args_template=None, cb_json=None,
|
||||
random_seed=None, xmidas=None, xmidas_token=None):
|
||||
random_seed=None, xmidas=None, xmidas_token=None,
|
||||
diagnostics=None):
|
||||
"""纯离线生成 encrypt_msg(A2 路线,2026-08-10 双向量 E3 闭环)。
|
||||
|
||||
params: 订单字段字典(18 字段,见 build_plaintext);fk_extend: tdrc_session 串
|
||||
(最终编码形态 tdrc_session%3Dpay-...,不再二次编码);ts: 秒级时间戳;
|
||||
rand_val: _rand 值(页面实测 8 字母数字 + 3×\\x03(run A)或 7 字母数字 +
|
||||
4×\\x04(run B),调用方自定即可);
|
||||
key16/key1: 16 字节密钥,**不影响最终输出**(E3:双向量随机密钥 1056/1056
|
||||
复现;webSave 回调内部重派生密钥,机制见 U-2030);
|
||||
key16/key1: 16 字节密钥,**不影响最终输出密文字节**(E3:双向量随机密钥 1056/1056
|
||||
复现;webSave 回调内部重派生密钥),但服务端会校验 key16 与 key1 的派生一致性
|
||||
(key16=Sbox[Te 链(key1)],F-2052),key1 必须由 derive_key1_from_key16 从 key16
|
||||
反解,不能随意给定;
|
||||
args_template: 常量表模板(跨次恒定,缺省用 work/replay/e2e/ws-args.json)。
|
||||
"""
|
||||
import random as _r
|
||||
@@ -1523,6 +1536,13 @@ def generate_encrypt_msg_offline(params, fk_extend, ts, rand_val,
|
||||
# 参数全部以 a:1 包装传入;key16 按全局块索引轮转:out[j] = word_byte[j] ^ key16[(4*blk+j)%16]
|
||||
pt = build_plaintext(params, fk_extend, ts, rand_val)
|
||||
pb = pt.encode("latin-1")
|
||||
# goods VM 只逐个处理完整的 16 字节块,不会为尾块自动填充。
|
||||
# 未对齐时继续执行会静默丢弃尾部字段,必须在请求前终止。
|
||||
if len(pb) % 16:
|
||||
raise ValueError(
|
||||
f"webSave 明文必须为 16 字节对齐,当前 {len(pb)} 字节;"
|
||||
"请用 _rand 补齐后再生成 encrypt_msg"
|
||||
)
|
||||
def _cont(*a):
|
||||
return None
|
||||
te_cap = json.loads(json.loads((replay / "e2e" / "multi-1.json").read_text().strip()))
|
||||
@@ -1562,7 +1582,18 @@ def generate_encrypt_msg_offline(params, fk_extend, ts, rand_val,
|
||||
vm.init_c = init_c
|
||||
vm.capture_at_return = True
|
||||
vm.out_hex = None
|
||||
if diagnostics is not None:
|
||||
vm.capture_output_lengths = True
|
||||
vm.output_length_trace = []
|
||||
vm._last_output_length = None
|
||||
vm.run(web, [])
|
||||
if vm.out_hex is None:
|
||||
raise RuntimeError("webSave capture failed")
|
||||
if diagnostics is not None:
|
||||
diagnostics.update({
|
||||
"plaintext_length": len(pb),
|
||||
"transform_output_length": len(outbuf),
|
||||
"ciphertext_length": len(vm.out_arr),
|
||||
"output_length_trace": vm.output_length_trace,
|
||||
})
|
||||
return vm.out_hex
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""YYB 下单和付款失败响应的统一归类。"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def describe_payment_failure(provider: str, phase: str, response: object) -> str:
|
||||
"""根据已保存的响应内容返回不会诱导连续重试的失败说明。"""
|
||||
text = str(response)
|
||||
is_qq = provider.lower() in {"qq", "qc"}
|
||||
|
||||
if "1099-1007-0" in text or "ret=1099" in text:
|
||||
if is_qq:
|
||||
return (
|
||||
"QQ 支付请求被风控/限流暂时拦截(1099-1007-0),未生成微信付款码;"
|
||||
"请停止连续重试,等待 30-60 分钟后再使用新订单尝试。"
|
||||
)
|
||||
return "支付请求被暂时拦截(1099-1007-0),未生成微信付款码;请稍后使用新订单重试。"
|
||||
|
||||
if "10007" in text:
|
||||
if is_qq:
|
||||
return (
|
||||
"QQ 商城下单被支付侧暂时拒绝(10007 系统繁忙),未创建可付款订单;"
|
||||
"请停止连续重试,等待 30-60 分钟后再尝试。"
|
||||
)
|
||||
return "商城下单被暂时拒绝(10007 系统繁忙),未创建可付款订单;请稍后重试。"
|
||||
|
||||
if "10014" in text or "gamerole err" in text:
|
||||
return "角色或区服校验失败(10014),未创建可付款订单;请重新选择角色和区服。"
|
||||
|
||||
if phase == "order":
|
||||
return "商城订单创建失败,未创建可付款订单;请查看任务日志中的业务码。"
|
||||
return "微信付款码生成失败;请查看任务日志中的业务码。"
|
||||
@@ -26,12 +26,14 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from pyvm.algorithm import derive_key1_from_key16, generate_encrypt_msg_offline # noqa: E402
|
||||
from pyvm.algorithm import build_plaintext, derive_key1_from_key16, generate_encrypt_msg_offline # noqa: E402
|
||||
from pyvm.login_profile import midas_login_params # noqa: E402
|
||||
from pyvm.payment_errors import describe_payment_failure # noqa: E402
|
||||
|
||||
APPID = "1450243039"
|
||||
GOODS_URL = "https://pay.qq.com/midas/minipay_v2/views/cpay/goods.shtml"
|
||||
SAVE_URL = f"https://api.unipay.qq.com/v1/r/{APPID}/web_save"
|
||||
PAGE_INFO_URL = f"https://api.unipay.qq.com/v1/r/{APPID}/web_page_info"
|
||||
FP_URL = "https://api.unipay.qq.com/cgi-bin/fp-behv.fcg"
|
||||
def load_json(path: Path) -> dict:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
@@ -131,7 +133,7 @@ def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token:
|
||||
if amount_fen <= 0:
|
||||
raise ValueError("充值金额必须为正数")
|
||||
login = midas_login_params(cookies)
|
||||
return {
|
||||
fields = {
|
||||
"token_id": order.get("token_id", ""),
|
||||
"transaction_id": order.get("transaction_id", ""),
|
||||
"out_trade_no": order.get("out_trade_no", ""),
|
||||
@@ -163,7 +165,6 @@ def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token:
|
||||
"pushtype": "NodeJS",
|
||||
"wx_order_interface": "1",
|
||||
"encrypt_msg": encrypt_msg,
|
||||
"qq_appid": login["qq_appid"],
|
||||
"base_key_version": "web_1.0.6",
|
||||
"encrypt_way": "web_new_encrypt",
|
||||
"web_token": web_token,
|
||||
@@ -172,6 +173,10 @@ def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token:
|
||||
"t": str(int(time.time() * 1000)),
|
||||
"__refer": "https://z.iwan.yyb.qq.com/",
|
||||
}
|
||||
# QQ 网页端会携带该字段;微信 OAuth 不携带,不能使用固定 appid 混入两条链路。
|
||||
if login["qq_appid"]:
|
||||
fields["offerid_for_qq_appid"] = login["qq_appid"]
|
||||
return fields
|
||||
|
||||
|
||||
def build_save_body(order: dict[str, str], cookies: dict[str, str], web_token: str,
|
||||
@@ -183,6 +188,71 @@ def build_save_body(order: dict[str, str], cookies: dict[str, str], web_token: s
|
||||
))
|
||||
|
||||
|
||||
def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_token: str,
|
||||
zone_id: str, pf: str) -> str:
|
||||
"""构造网页端 fp-behv 后的 web_page_info 表单。"""
|
||||
login = midas_login_params(cookies)
|
||||
fields = {
|
||||
"token_id": order.get("token_id", ""),
|
||||
"transaction_id": order.get("transaction_id", ""),
|
||||
"out_trade_no": order.get("out_trade_no", ""),
|
||||
"offer_type": order.get("offer_type", "0"),
|
||||
"type": "bg",
|
||||
"pf": pf,
|
||||
"pfkey": "pfkey",
|
||||
"from_h5": "1",
|
||||
"pc_st": str(uuid.uuid4()).upper() + str(int(time.time() * 1000)),
|
||||
"r": str(random.random()),
|
||||
"openid": cookies.get("openid", ""),
|
||||
"openkey": cookies.get("accesstoken", ""),
|
||||
"session_id": login["session_id"],
|
||||
"session_type": login["session_type"],
|
||||
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode()).hexdigest().upper(),
|
||||
"anti_auto_script_token_id": anti_token,
|
||||
"isusempaymode": "1",
|
||||
"zoneid": zone_id,
|
||||
"webversion": "minipayv2",
|
||||
"from_https": "1",
|
||||
"t": str(int(time.time() * 1000)),
|
||||
"__refer": "https://z.iwan.yyb.qq.com/",
|
||||
}
|
||||
if login["qq_appid"]:
|
||||
fields["offerid_for_qq_appid"] = login["qq_appid"]
|
||||
return urllib.parse.urlencode(fields)
|
||||
|
||||
|
||||
def make_encrypt_rand(params: dict[str, str], fk_extend: str, ts: str) -> str:
|
||||
"""生成与当前订单明文长度匹配的 _rand,确保 goods VM 不截断尾块。"""
|
||||
prefix = "".join(random.choices(string.ascii_letters + string.digits, k=8))
|
||||
base_length = len(build_plaintext(params, fk_extend, ts, "").encode("latin-1"))
|
||||
padding_length = (-(base_length + len(prefix))) % 16
|
||||
return prefix + ("\x03" * padding_length)
|
||||
|
||||
|
||||
def save_web_save_request_meta(out_dir: Path, fields: dict[str, str], device_fp_length: int,
|
||||
plaintext_length: int) -> None:
|
||||
"""保存可比对的脱敏请求形态,避免在证据目录落盘支付凭据。"""
|
||||
sensitive = ("token_id", "transaction_id", "out_trade_no", "openid", "openkey",
|
||||
"sck", "anti_auto_script_token_id", "web_token", "encrypt_msg")
|
||||
fingerprints = {
|
||||
name: {
|
||||
"length": len(fields.get(name, "")),
|
||||
"sha256_prefix": hashlib.sha256(fields.get(name, "").encode()).hexdigest()[:12],
|
||||
}
|
||||
for name in sensitive
|
||||
}
|
||||
meta = {
|
||||
"field_names": sorted(fields),
|
||||
"plaintext_length": plaintext_length,
|
||||
"encrypt_msg_length": len(fields.get("encrypt_msg", "")),
|
||||
"device_fp_length": device_fp_length,
|
||||
"sensitive_field_fingerprints": fingerprints,
|
||||
}
|
||||
(out_dir / "web-save-request-meta.json").write_text(
|
||||
json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def make_qr(sign: str, output: Path) -> None:
|
||||
try:
|
||||
import segno
|
||||
@@ -323,6 +393,7 @@ def main() -> int:
|
||||
return cmd_check_only(session_path, out_dir)
|
||||
if args.amount_fen <= 0:
|
||||
raise ValueError("充值金额必须为正数")
|
||||
payment_pf = args.pf or "mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-android"
|
||||
|
||||
session = load_json(session_path)
|
||||
cookies = dict(session.get("cookies", {}))
|
||||
@@ -366,13 +437,36 @@ def main() -> int:
|
||||
print("[jsdom-pay] 上报 fp-behv...")
|
||||
fp_response = request_bytes(fp.get("fp_url", FP_URL), cookies, fp["fp_body"].encode())
|
||||
(out_dir / "fp-response.json").write_text(fp_response, encoding="utf-8")
|
||||
try:
|
||||
fp_json = json.loads(fp_response)
|
||||
except json.JSONDecodeError:
|
||||
fp_json = {}
|
||||
if fp_json.get("ret") != 0:
|
||||
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
|
||||
|
||||
web_args = load_template_args()
|
||||
payment_pf = args.pf or "mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-android"
|
||||
# key 派生已破解(F-2052,2026-08-12):key16 可随机生成,key1 由反解器
|
||||
# derive_key1_from_key16 求解(key16=Sbox[Te 链(key1)]),满足服务端派生校验,
|
||||
# 无需浏览器捕获 key(微信渠道纯 HTTP 已 ret:0)。
|
||||
key16 = [random.randrange(256) for _ in range(16)]
|
||||
tables = [web_args[index][0] for index in (1, 2, 3, 4)]
|
||||
key1 = derive_key1_from_key16(key16, te_tables=tables, sbox=web_args[5][0])
|
||||
random_suffix = "".join(random.choices(string.ascii_letters + string.digits, k=8)) + "\x01"
|
||||
params = {
|
||||
"token_id": order.get("token_id", ""),
|
||||
"openid": cookies.get("openid", ""),
|
||||
@@ -394,14 +488,19 @@ 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)
|
||||
plaintext_length = len(build_plaintext(params, fk_extend, now_seconds, random_suffix).encode("latin-1"))
|
||||
encrypt_msg = generate_encrypt_msg_offline(
|
||||
params, "tdrc_session%3D" + fp["session_id"], now_seconds, random_suffix,
|
||||
params, fk_extend, now_seconds, random_suffix,
|
||||
key16=key16, key1=key1, args_template=web_args, xmidas=xmidas, xmidas_token=web_token,
|
||||
)
|
||||
body = build_save_body(
|
||||
fields = build_save_fields(
|
||||
order, cookies, web_token, anti_token, encrypt_msg,
|
||||
args.zone_id, payment_pf, args.amount_fen,
|
||||
)
|
||||
save_web_save_request_meta(out_dir, fields, int(fp.get("device_fp_length", 0)), plaintext_length)
|
||||
body = urllib.parse.urlencode(fields)
|
||||
from pyvm.order_status import order_completion_states, get_official_orders
|
||||
|
||||
# 尽量在发起支付前建立基线,缩小极快支付造成的检测窗口;失败不阻塞支付,付款码生成后重试。
|
||||
@@ -421,7 +520,10 @@ def main() -> int:
|
||||
except json.JSONDecodeError:
|
||||
response_json = {}
|
||||
if response_json.get("ret") != 0:
|
||||
print(f"[jsdom-pay] web_save 失败: ret={response_json.get('ret')} {response_json.get('msg', '')}")
|
||||
detail = f"ret={response_json.get('ret')} err_code={response_json.get('err_code', '')} " \
|
||||
f"{response_json.get('msg', '')}"
|
||||
print(f"[jsdom-pay] web_save 失败: {detail}")
|
||||
print(f"[jsdom-pay] {describe_payment_failure(cookies.get('logintype', ''), 'payment', detail)}")
|
||||
print(f"[jsdom-pay] 证据目录: {out_dir}")
|
||||
return 1
|
||||
sign = response_json.get("info", {}).get("channel_info", {}).get("sign", "")
|
||||
|
||||
@@ -23,11 +23,21 @@ from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
DEFAULT_DATA = ROOT / "config" / "worker-jobs"
|
||||
WORKER_KEY = os.environ.get("YYB_WORKER_KEY", "")
|
||||
_jobs: dict[str, dict] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
from pyvm.payment_errors import describe_payment_failure # noqa: E402
|
||||
|
||||
|
||||
def _payment_failure_message(job: dict, phase: str, fallback: str) -> str:
|
||||
"""从阶段日志提取业务码,向管理端返回准确且可操作的失败信息。"""
|
||||
detail = "\n".join(job.get("logs", []))
|
||||
message = describe_payment_failure(str(job.get("provider", "")), phase, detail)
|
||||
return message if message else fallback
|
||||
|
||||
|
||||
def _load_selector():
|
||||
path = ROOT / "scripts" / "select-peace-elite.py"
|
||||
@@ -161,7 +171,7 @@ def _payment_stage(job_id: str, command: list[str], phase: str, running_message:
|
||||
return False
|
||||
job["status"] = "failed"
|
||||
job["phase"] = phase
|
||||
job["message"] = f"{failed_message}(退出码 {code})"
|
||||
job["message"] = _payment_failure_message(job, phase, failed_message)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ _MODULE = importlib.util.module_from_spec(_SPEC)
|
||||
sys.modules[_SPEC.name] = _MODULE
|
||||
_SPEC.loader.exec_module(_MODULE)
|
||||
|
||||
from pyvm.payment_errors import describe_payment_failure
|
||||
|
||||
|
||||
class TestJsdomPay(unittest.TestCase):
|
||||
def test_build_save_body_has_current_payment_context(self):
|
||||
@@ -42,8 +44,51 @@ class TestJsdomPay(unittest.TestCase):
|
||||
fields = parse_qs(body, keep_blank_values=True)
|
||||
self.assertEqual(fields["session_id"], ["openid"])
|
||||
self.assertEqual(fields["session_type"], ["kp_accesstoken"])
|
||||
self.assertEqual(fields["qq_appid"], ["102033112"])
|
||||
self.assertEqual(fields["wx_appid"], [""])
|
||||
self.assertEqual(fields["offerid_for_qq_appid"], ["102033112"])
|
||||
self.assertNotIn("qq_appid", fields)
|
||||
|
||||
def test_wechat_payment_does_not_include_qq_offer_field(self):
|
||||
body = _MODULE.build_save_body(
|
||||
{"token_id": "token", "transaction_id": "transaction", "out_trade_no": "trade"},
|
||||
{"openid": "openid", "accesstoken": "access-token", "logintype": "WX"},
|
||||
"web-token", "anti-token", "cipher", "1", "pf", 100,
|
||||
)
|
||||
fields = parse_qs(body, keep_blank_values=True)
|
||||
self.assertNotIn("offerid_for_qq_appid", fields)
|
||||
|
||||
def test_qq_page_info_matches_the_web_flow(self):
|
||||
body = _MODULE.build_page_info_body(
|
||||
{"token_id": "token", "transaction_id": "transaction", "out_trade_no": "trade"},
|
||||
{"openid": "openid", "accesstoken": "access-token", "logintype": "QC", "appid": "102033112"},
|
||||
"anti-token", "1", "pf",
|
||||
)
|
||||
fields = parse_qs(body, keep_blank_values=True)
|
||||
self.assertEqual(fields["isusempaymode"], ["1"])
|
||||
self.assertEqual(fields["offerid_for_qq_appid"], ["102033112"])
|
||||
self.assertNotIn("pay_method", fields)
|
||||
|
||||
def test_qq_risk_rejection_is_not_reported_as_qr_error(self):
|
||||
message = describe_payment_failure("QC", "payment", "ret=1099 err_code=1099-1007-0")
|
||||
self.assertIn("风控/限流", message)
|
||||
self.assertIn("未生成微信付款码", message)
|
||||
self.assertIn("停止连续重试", 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):
|
||||
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")
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user