style: 统一 Ruff 代码格式
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
python3 main.py sample-session # 用迁移的 live 捕获生成示例会话态
|
||||
python3 main.py sample-order # 用迁移的 order7 生成示例订单
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -50,14 +51,31 @@ from pyvm.protocol import ( # noqa: E402
|
||||
REPLAY = ROOT / "replay"
|
||||
DEFAULT_APPID = PAY_APPID
|
||||
DEFAULT_SAVE_URL = f"https://api.unipay.qq.com/v1/r/{DEFAULT_APPID}/web_save"
|
||||
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",
|
||||
"from_h5", "webversion"]
|
||||
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",
|
||||
"from_h5",
|
||||
"webversion",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 工具
|
||||
|
||||
|
||||
def _write_private_text(path: Path, content: str) -> None:
|
||||
"""写入任务协议证据并限制为当前用户可读。"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -65,6 +83,7 @@ def _write_private_text(path: Path, content: str) -> None:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def _load_cap(path: Path) -> dict:
|
||||
"""加载 deepCap 捕获 JSON,兼容三种存档格式,返回顶层 dict(含 C 键)。
|
||||
|
||||
@@ -113,32 +132,47 @@ def parse_plaintext(path: str | Path) -> dict:
|
||||
|
||||
# ---------------------------------------------------------------- 命令
|
||||
|
||||
|
||||
def cmd_verify(args=None) -> int:
|
||||
"""校验当前受版本控制的 goods 协议材料,不依赖已删除的历史抓包。"""
|
||||
args_path = REPLAY / "live" / "default" / "args-template.json"
|
||||
args_template = json.loads(args_path.read_text(encoding="utf-8"))
|
||||
validate_goods_materials(args_template)
|
||||
required = ("bytecode.json", "constants.json", "xmidasops.json", "e2e/multi-1.json",
|
||||
"e2e/ws-args.json", "deepcaps2/cap-69667.json")
|
||||
required = (
|
||||
"bytecode.json",
|
||||
"constants.json",
|
||||
"xmidasops.json",
|
||||
"e2e/multi-1.json",
|
||||
"e2e/ws-args.json",
|
||||
"deepcaps2/cap-69667.json",
|
||||
)
|
||||
missing = [name for name in required if not (REPLAY / name).exists()]
|
||||
if missing:
|
||||
print(f"❌ goods 协议材料缺失: {', '.join(missing)}")
|
||||
return 1
|
||||
print("✅ goods 协议材料校验通过(18 槽模板、Te/S-box、VM 文件均完整)")
|
||||
print(" 历史逐字节黄金向量未随仓库保留;如需恢复,可放入 replay/golden 后由 CI 自动执行。")
|
||||
print(
|
||||
" 历史逐字节黄金向量未随仓库保留;如需恢复,可放入 replay/golden 后由 CI 自动执行。"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _gen_with_session(st: SessionState, order: dict) -> str:
|
||||
"""用会话态 + 订单参数生成 encrypt_msg(args_template 会话绑定,必须用捕获值)。"""
|
||||
from pyvm.algorithm import decode_d
|
||||
|
||||
params = {k: order.get(k, "") for k in ORDER_FIELDS}
|
||||
args_tpl = decode_d(st.args_template_d)
|
||||
return generate_encrypt_msg_offline(
|
||||
params, order.get("fk_extend", ""), order.get("ts", ""), order.get("_rand", ""),
|
||||
xmidas=st.xmidas_ops, xmidas_token=st.xmidas_token,
|
||||
params,
|
||||
order.get("fk_extend", ""),
|
||||
order.get("ts", ""),
|
||||
order.get("_rand", ""),
|
||||
xmidas=st.xmidas_ops,
|
||||
xmidas_token=st.xmidas_token,
|
||||
args_template=args_tpl,
|
||||
key16=st.key16, key1=st.key1,
|
||||
key16=st.key16,
|
||||
key1=st.key1,
|
||||
)
|
||||
|
||||
|
||||
@@ -186,7 +220,8 @@ def cmd_submit(args) -> int:
|
||||
url = args.url or DEFAULT_SAVE_URL
|
||||
cookie_str = "; ".join(f"{k}={v}" for k, v in st.cookies.items())
|
||||
req = urllib.request.Request(
|
||||
url, data=body.encode("utf-8"),
|
||||
url,
|
||||
data=body.encode("utf-8"),
|
||||
headers={
|
||||
"User-Agent": GOODS_USER_AGENT,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
@@ -221,6 +256,7 @@ def cmd_submit(args) -> int:
|
||||
def cmd_sample_session(args) -> int:
|
||||
"""用迁移的 live 捕获生成示例会话态(key16/key1/xmidas_ops)。"""
|
||||
from pyvm.algorithm import decode_d
|
||||
|
||||
web_args = decode_d(_load_cap(REPLAY / "live/caps7/cap-85091.json")["C"][2])
|
||||
cap7 = _load_cap(REPLAY / "live/caps7/cap-85091.json")
|
||||
st = SessionState(
|
||||
@@ -228,7 +264,9 @@ def cmd_sample_session(args) -> int:
|
||||
key16=list(web_args[6][0]),
|
||||
key1=list(web_args[0][0]),
|
||||
xmidas_token="DE46DBA4754D42A6B66ADD4319FF80C144D9ED22ED73384C271793D9A9AA2FFBD6C104BE8D4A7F4ED2A8688FCB6F7540",
|
||||
args_template_d=cap7["C"][2] if isinstance(cap7["C"][2], str) else json.dumps(cap7["C"][2], ensure_ascii=False),
|
||||
args_template_d=cap7["C"][2]
|
||||
if isinstance(cap7["C"][2], str)
|
||||
else json.dumps(cap7["C"][2], ensure_ascii=False),
|
||||
cookies={},
|
||||
source="live/order7 (归档示例,cookies 为空)",
|
||||
)
|
||||
@@ -252,6 +290,7 @@ def cmd_sample_order(args) -> int:
|
||||
|
||||
# ---------------------------------------------------------------- mall 命令
|
||||
|
||||
|
||||
def cmd_mall_verify(args=None) -> int:
|
||||
"""校验当前受版本控制的 mall VM 和固定槽模板。"""
|
||||
fixed_path = REPLAY / "mall" / "transform-fixed.json"
|
||||
@@ -282,7 +321,9 @@ def cmd_mall_gen(args) -> int:
|
||||
|
||||
def cmd_mall_sample(args) -> int:
|
||||
"""用归档同会话黄金对生成示例 mall 会话态(config/mall-session.json)。"""
|
||||
g = json.loads((ROOT / "config/golden/golden-final.json").read_text(encoding="utf-8"))
|
||||
g = json.loads(
|
||||
(ROOT / "config/golden/golden-final.json").read_text(encoding="utf-8")
|
||||
)
|
||||
session = MallSession(g["transform_input"], g["xmidas_ops"])
|
||||
out = Path(args.output) if args.output else ROOT / "config" / "mall-session.json"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -325,7 +366,8 @@ def cmd_mall_submit(args) -> int:
|
||||
if "midas_openkey" not in cookies and "accesstoken" in cookies:
|
||||
cookie_str += "; midas_openkey=" + cookies["accesstoken"]
|
||||
req = urllib.request.Request(
|
||||
url, data=new_body.encode("utf-8"),
|
||||
url,
|
||||
data=new_body.encode("utf-8"),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
@@ -346,7 +388,11 @@ def cmd_mall_submit(args) -> int:
|
||||
print(f"[submit] 网络错误: {e!r}")
|
||||
return 3
|
||||
print(f"[submit] 响应: {raw[:800]}")
|
||||
out = Path(args.output) if args.output else ROOT / "config" / "mall-order-response.json"
|
||||
out = (
|
||||
Path(args.output)
|
||||
if args.output
|
||||
else ROOT / "config" / "mall-order-response.json"
|
||||
)
|
||||
js = None
|
||||
ret = None
|
||||
try:
|
||||
@@ -370,7 +416,9 @@ def cmd_mall_submit(args) -> int:
|
||||
print(f" 失败响应 → {fail_out}(保留上次成功响应)")
|
||||
if ret in ("1018", 1018):
|
||||
print(" 原因: mall 登录态失效——请重新在浏览器登录并采集 mall 会话态")
|
||||
print(" (node scripts/capture-mall-data.mjs,或手动刷新 mall-session.json 的 cookies)")
|
||||
print(
|
||||
" (node scripts/capture-mall-data.mjs,或手动刷新 mall-session.json 的 cookies)"
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
@@ -383,11 +431,15 @@ def cmd_mall_capture(args) -> int:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="YYB 加密参数生成框架(goods web_save + mall PlaceOrder)")
|
||||
ap = argparse.ArgumentParser(
|
||||
description="YYB 加密参数生成框架(goods web_save + mall PlaceOrder)"
|
||||
)
|
||||
sub = ap.add_subparsers(dest="module", required=True)
|
||||
|
||||
# ---- goods 组 ----
|
||||
pg = sub.add_parser("goods", help="goods 侧(web_save / web_new_encrypt,CHAOS VM 116 opcode)")
|
||||
pg = sub.add_parser(
|
||||
"goods", help="goods 侧(web_save / web_new_encrypt,CHAOS VM 116 opcode)"
|
||||
)
|
||||
gsub = pg.add_subparsers(dest="cmd", required=True)
|
||||
gsub.add_parser("verify", help="校验当前 goods 协议材料")
|
||||
p = gsub.add_parser("gen", help="仅生成 encrypt_msg(不联网)")
|
||||
@@ -416,30 +468,64 @@ def main() -> int:
|
||||
p.add_argument("--frames", default=None, help="最新捕获的 frames.jsonl(优先)")
|
||||
p.add_argument("--output", default=None)
|
||||
p = msub.add_parser("submit", help="生成 encrypt_msg + 提交 PlaceOrder(纯 Python)")
|
||||
p.add_argument("--session", required=True, help="mall-session.json(含 transform_input/xmidas/cookies)")
|
||||
p.add_argument("--order-template", default=str(ROOT / "config" / "mall-order-template.json"))
|
||||
p.add_argument(
|
||||
"--session",
|
||||
required=True,
|
||||
help="mall-session.json(含 transform_input/xmidas/cookies)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--order-template", default=str(ROOT / "config" / "mall-order-template.json")
|
||||
)
|
||||
p.add_argument("--output", default=None)
|
||||
p = msub.add_parser("auto", help="仅凭 CK 全自动下单(纯HTTP GetPayToken + 纯Python encrypt_msg,无浏览器采集)")
|
||||
p = msub.add_parser(
|
||||
"auto",
|
||||
help="仅凭 CK 全自动下单(纯HTTP GetPayToken + 纯Python encrypt_msg,无浏览器采集)",
|
||||
)
|
||||
p.add_argument("--session", default=str(ROOT / "config" / "mall-session.json"))
|
||||
p.add_argument("--order-template", default=str(ROOT / "config" / "mall-order-template.json"))
|
||||
p.add_argument("--product-id", default=None, help="和平精英点券商品 ID;默认使用模板当前商品")
|
||||
p.add_argument(
|
||||
"--order-template", default=str(ROOT / "config" / "mall-order-template.json")
|
||||
)
|
||||
p.add_argument(
|
||||
"--product-id", default=None, help="和平精英点券商品 ID;默认使用模板当前商品"
|
||||
)
|
||||
p.add_argument("--offer-id", default=None, help="当前商品服务端 offer ID")
|
||||
p.add_argument("--quantity", type=int, default=None, help="当前点券商品购买份数(正整数)")
|
||||
p.add_argument(
|
||||
"--quantity", type=int, default=None, help="当前点券商品购买份数(正整数)"
|
||||
)
|
||||
p.add_argument("--role-id", default=None, help="游戏角色 ID;默认使用模板当前角色")
|
||||
p.add_argument("--role-name", default=None, help="游戏角色名称;默认使用模板当前角色")
|
||||
p.add_argument(
|
||||
"--role-name", default=None, help="游戏角色名称;默认使用模板当前角色"
|
||||
)
|
||||
p.add_argument("--zone-id", default=None, help="游戏区服 ID;默认使用模板当前区服")
|
||||
p.add_argument("--zone-name", default=None, help="游戏区服名称")
|
||||
p.add_argument("--area", default=None, help="游戏大区 ID(QQ 平台与 zoneid 不同,来自角色查询的 partition_info)")
|
||||
p.add_argument(
|
||||
"--area",
|
||||
default=None,
|
||||
help="游戏大区 ID(QQ 平台与 zoneid 不同,来自角色查询的 partition_info)",
|
||||
)
|
||||
p.add_argument("--partition", default=None, help="游戏分区 ID(QQ 平台可为空)")
|
||||
p.add_argument("--pf", default=None, help="支付平台标识;由角色选择器按 Android/iOS 写入")
|
||||
p.add_argument(
|
||||
"--pf", default=None, help="支付平台标识;由角色选择器按 Android/iOS 写入"
|
||||
)
|
||||
p.add_argument("--output", default=None)
|
||||
p = msub.add_parser("pay", help="mall 下单 -> goods web_save -> 微信支付二维码(纯 Python)")
|
||||
p.add_argument("--mall-response", default=str(ROOT / "config" / "mall-order-response.json"),
|
||||
help="PlaceOrder 响应(含 url_params/token)")
|
||||
p.add_argument("--goods-dir", default=str(ROOT / "replay" / "live" / "order10"),
|
||||
help="goods 会话态目录(plaintext/body/xmidasops/keys/cap/web-token)")
|
||||
p.add_argument("--session", default=str(ROOT / "config" / "mall-session.json"),
|
||||
help="mall 登录态(含 cookies)")
|
||||
p = msub.add_parser(
|
||||
"pay", help="mall 下单 -> goods web_save -> 微信支付二维码(纯 Python)"
|
||||
)
|
||||
p.add_argument(
|
||||
"--mall-response",
|
||||
default=str(ROOT / "config" / "mall-order-response.json"),
|
||||
help="PlaceOrder 响应(含 url_params/token)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--goods-dir",
|
||||
default=str(ROOT / "replay" / "live" / "order10"),
|
||||
help="goods 会话态目录(plaintext/body/xmidasops/keys/cap/web-token)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--session",
|
||||
default=str(ROOT / "config" / "mall-session.json"),
|
||||
help="mall 登录态(含 cookies)",
|
||||
)
|
||||
p.add_argument("--appid", default=DEFAULT_APPID)
|
||||
p.add_argument("--output", default=None, help="二维码 PNG 输出路径")
|
||||
msub.add_parser("capture", help="用浏览器捕获 mall 会话态(Node 脚本)")
|
||||
@@ -447,20 +533,29 @@ def main() -> int:
|
||||
args = ap.parse_args()
|
||||
try:
|
||||
if args.module == "goods":
|
||||
return {"verify": cmd_verify, "gen": cmd_gen, "submit": cmd_submit,
|
||||
"sample-session": cmd_sample_session, "sample-order": cmd_sample_order}[args.cmd](args)
|
||||
return {
|
||||
"verify": cmd_verify,
|
||||
"gen": cmd_gen,
|
||||
"submit": cmd_submit,
|
||||
"sample-session": cmd_sample_session,
|
||||
"sample-order": cmd_sample_order,
|
||||
}[args.cmd](args)
|
||||
elif args.module == "mall":
|
||||
return {"verify": cmd_mall_verify, "gen": cmd_mall_gen, "submit": cmd_mall_submit,
|
||||
"sample-session": cmd_mall_sample, "pay": cmd_mall_pay, "auto": cmd_mall_auto,
|
||||
"capture": cmd_mall_capture}[args.cmd](args)
|
||||
return {
|
||||
"verify": cmd_mall_verify,
|
||||
"gen": cmd_mall_gen,
|
||||
"submit": cmd_mall_submit,
|
||||
"sample-session": cmd_mall_sample,
|
||||
"pay": cmd_mall_pay,
|
||||
"auto": cmd_mall_auto,
|
||||
"capture": cmd_mall_capture,
|
||||
}[args.cmd](args)
|
||||
return 2
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
print(f"错误: {e}")
|
||||
return 2
|
||||
|
||||
|
||||
|
||||
|
||||
def build_mall_transform(fixed: dict) -> list:
|
||||
"""仅CK全自动:固定槽模板 + 随机 key16/槽6/明文缓冲 构造 transform_input。
|
||||
|
||||
@@ -487,6 +582,7 @@ def mall_getpaytoken(cookies: dict) -> tuple[list, str]:
|
||||
证据: evidence/getpaytoken-pure-http.json
|
||||
"""
|
||||
import urllib.request
|
||||
|
||||
login = midas_login_params(cookies)
|
||||
login["offer_id"] = "800001492"
|
||||
body = {
|
||||
@@ -494,8 +590,12 @@ def mall_getpaytoken(cookies: dict) -> tuple[list, str]:
|
||||
"call_param": {
|
||||
"call_func": "GetPayToken",
|
||||
"call_param_json": json.dumps(
|
||||
{"version": "pagedoo-v2.0.0", "app_id": "202406061128117473047424",
|
||||
"content_id": "ct1755160919_GEOCGTMN"}),
|
||||
{
|
||||
"version": "pagedoo-v2.0.0",
|
||||
"app_id": "202406061128117473047424",
|
||||
"content_id": "ct1755160919_GEOCGTMN",
|
||||
}
|
||||
),
|
||||
"call_type": "security_service",
|
||||
"login_check_param_json": json.dumps(login),
|
||||
},
|
||||
@@ -505,12 +605,19 @@ def mall_getpaytoken(cookies: dict) -> tuple[list, str]:
|
||||
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]
|
||||
req = urllib.request.Request(url, data=json.dumps(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/",
|
||||
"Cookie": cookie_str,
|
||||
"User-Agent": MALL_USER_AGENT,
|
||||
}, method="POST")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(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/",
|
||||
"Cookie": cookie_str,
|
||||
"User-Agent": MALL_USER_AGENT,
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
raw = r.read().decode("utf-8", "replace")
|
||||
@@ -549,7 +656,11 @@ def _apply_card_selection(payload: dict, args) -> None:
|
||||
if quantity is not None and quantity <= 0:
|
||||
raise ValueError("--quantity 必须是正整数")
|
||||
products = payload.get("product_list")
|
||||
if not isinstance(products, list) or not products or not isinstance(products[0], dict):
|
||||
if (
|
||||
not isinstance(products, list)
|
||||
or not products
|
||||
or not isinstance(products[0], dict)
|
||||
):
|
||||
raise ValueError("订单模板缺少 product_list[0]")
|
||||
product = products[0]
|
||||
product_id = getattr(args, "product_id", None)
|
||||
@@ -617,7 +728,9 @@ def cmd_mall_auto(args) -> int:
|
||||
return 1
|
||||
print(f"[auto] arrays {len(arrays)} | pay_token {pay_token[:16]}...")
|
||||
print("[auto] ② 构造 transform_input(固定槽 + 随机 key/明文缓冲)...")
|
||||
fixed = json.loads((ROOT / "replay/mall/transform-fixed.json").read_text(encoding="utf-8"))
|
||||
fixed = json.loads(
|
||||
(ROOT / "replay/mall/transform-fixed.json").read_text(encoding="utf-8")
|
||||
)
|
||||
validate_mall_materials(fixed)
|
||||
ti = build_mall_transform(fixed)
|
||||
print("[auto] ③ 纯 Python 生成 encrypt_msg...")
|
||||
@@ -639,18 +752,29 @@ def cmd_mall_auto(args) -> int:
|
||||
if k not in cookies and v in cookies:
|
||||
cookie_str += f"; {k}=" + cookies[v]
|
||||
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/",
|
||||
"Cookie": cookie_str,
|
||||
"User-Agent": MALL_USER_AGENT,
|
||||
}, method="POST")
|
||||
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/",
|
||||
"Cookie": cookie_str,
|
||||
"User-Agent": MALL_USER_AGENT,
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
raw = r.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode("utf-8", "replace")
|
||||
out = Path(args.output) if args.output else ROOT / "config" / "mall-order-response.json"
|
||||
out = (
|
||||
Path(args.output)
|
||||
if args.output
|
||||
else ROOT / "config" / "mall-order-response.json"
|
||||
)
|
||||
_write_private_text(out, raw)
|
||||
js = json.loads(raw)
|
||||
ret = js.get("result_code")
|
||||
@@ -669,7 +793,9 @@ 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" {describe_payment_failure(str(cookies.get('logintype', '')), 'order', f'{ret} {inner_ret} {detail}')}"
|
||||
)
|
||||
print(f" 响应已保存 → {out}")
|
||||
return 1
|
||||
|
||||
@@ -714,10 +840,13 @@ def cmd_mall_pay(args) -> int:
|
||||
cr = json.loads(resp["data"]["call_reply"])
|
||||
up = cr["data"]["url_params"]
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
q = parse_qs(urlparse(up).query)
|
||||
mall_tid = q.get("token_id", [""])[0]
|
||||
if mall_tid and mall_tid != token_id:
|
||||
print(f"⚠️ mall 响应订单({mall_tid[:16]}...)与捕获会话订单({token_id[:16]}...)不一致")
|
||||
print(
|
||||
f"⚠️ mall 响应订单({mall_tid[:16]}...)与捕获会话订单({token_id[:16]}...)不一致"
|
||||
)
|
||||
print(" goods web_token 绑定捕获页面订单,以捕获 body 订单为准继续")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"⚠️ mall 响应读取失败(忽略): {e!r}")
|
||||
@@ -727,6 +856,7 @@ def cmd_mall_pay(args) -> int:
|
||||
keys = json.loads((gd / "keys.json").read_text(encoding="utf-8"))
|
||||
xmidas = json.loads((gd / "xmidasops.json").read_text(encoding="utf-8"))
|
||||
from pyvm.algorithm import decode_d, recover_plaintext_from_buffer
|
||||
|
||||
at_file = gd / "args-template.json"
|
||||
if at_file.exists():
|
||||
# 新版采集:decoded 18参直接读取(capture-goods-session.mjs J 转储)
|
||||
@@ -740,8 +870,10 @@ def cmd_mall_pay(args) -> int:
|
||||
if not web_token:
|
||||
m = re.search(r"web_token=([0-9A-F]+)", body_tpl)
|
||||
web_token = m.group(1) if m else ""
|
||||
print(f"[pay] goods 会话态: xmidasops={len(xmidas)} key16={'有' if keys.get('key16') else '无'} "
|
||||
f"web_token={web_token[:12]}...")
|
||||
print(
|
||||
f"[pay] goods 会话态: xmidasops={len(xmidas)} key16={'有' if keys.get('key16') else '无'} "
|
||||
f"web_token={web_token[:12]}..."
|
||||
)
|
||||
|
||||
# 3. 恢复页面真实明文(ts/fk_extend/_rand),刷新 ts 为当前一致值
|
||||
try:
|
||||
@@ -761,22 +893,37 @@ def cmd_mall_pay(args) -> int:
|
||||
now_ms = int(time.time() * 1000)
|
||||
params["ts"] = str(now_ms // 1000)
|
||||
if rec_ts:
|
||||
print(f"[pay] 页面真实明文: ts={rec_ts}(捕获) -> 使用当前 ts={params['ts']} "
|
||||
f"(E3:ts 与 body t 一致即可,不必等于页面值)")
|
||||
print(
|
||||
f"[pay] 页面真实明文: ts={rec_ts}(捕获) -> 使用当前 ts={params['ts']} "
|
||||
f"(E3:ts 与 body t 一致即可,不必等于页面值)"
|
||||
)
|
||||
print("[pay] 生成 goods encrypt_msg(纯 Python)...")
|
||||
hex_msg = generate_encrypt_msg_offline(
|
||||
params, fk_extend, params["ts"], rand_val,
|
||||
xmidas=xmidas, args_template=web_args,
|
||||
key16=keys.get("key16"), key1=keys.get("key1"),
|
||||
params,
|
||||
fk_extend,
|
||||
params["ts"],
|
||||
rand_val,
|
||||
xmidas=xmidas,
|
||||
args_template=web_args,
|
||||
key16=keys.get("key16"),
|
||||
key1=keys.get("key1"),
|
||||
)
|
||||
print(f"[pay] encrypt_msg ({len(hex_msg)} hex): {hex_msg[:32]}...")
|
||||
|
||||
# 4. 构造 web_save body(会话订单 token + 当前动态值 + 纯 py encrypt_msg)
|
||||
body = body_tpl
|
||||
for k, v in [("token_id", token_id), ("transaction_id", transaction_id),
|
||||
("out_trade_no", out_trade_no), ("offer_type", offer_type)]:
|
||||
for k, v in [
|
||||
("token_id", token_id),
|
||||
("transaction_id", transaction_id),
|
||||
("out_trade_no", out_trade_no),
|
||||
("offer_type", offer_type),
|
||||
]:
|
||||
body = re.sub(rf"{k}=[^&]*", f"{k}=" + v, body, count=1)
|
||||
body = re.sub(r"pc_st=[^&]+", "pc_st=" + str(uuid.uuid4()).upper() + str(int(time.time() * 1000)), body)
|
||||
body = re.sub(
|
||||
r"pc_st=[^&]+",
|
||||
"pc_st=" + str(uuid.uuid4()).upper() + str(int(time.time() * 1000)),
|
||||
body,
|
||||
)
|
||||
body = re.sub(r"r=[0-9.]+", "r=" + str(secrets.SystemRandom().random()), body)
|
||||
body = re.sub(r"&t=[0-9]+", "&t=" + str(int(time.time() * 1000)), body)
|
||||
body = re.sub(r"encrypt_msg=[0-9a-f]+", "encrypt_msg=" + hex_msg, body)
|
||||
@@ -798,7 +945,8 @@ def cmd_mall_pay(args) -> int:
|
||||
# 6. POST web_save
|
||||
url = f"https://api.unipay.qq.com/v1/r/{args.appid}/web_save"
|
||||
req = urllib.request.Request(
|
||||
url, data=body.encode("utf-8"),
|
||||
url,
|
||||
data=body.encode("utf-8"),
|
||||
headers={
|
||||
"User-Agent": MALL_USER_AGENT,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
@@ -855,5 +1003,6 @@ def cmd_mall_pay(args) -> int:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
- mall.py: mall 侧高层 API(MallSession + generate_encrypt_msg)
|
||||
- session.py: 会话态模型
|
||||
"""
|
||||
|
||||
from .algorithm import (
|
||||
build_plaintext,
|
||||
decode_d,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
||||
→ webSave(33 块变换)
|
||||
→ encrypt_msg(1056 hex)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -16,17 +17,39 @@ from typing import Any
|
||||
from .algorithm import generate_encrypt_msg_offline
|
||||
|
||||
REPLAY = Path(__file__).resolve().parent.parent / "replay"
|
||||
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",
|
||||
"from_h5", "webversion"]
|
||||
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",
|
||||
"from_h5",
|
||||
"webversion",
|
||||
]
|
||||
|
||||
|
||||
class GoodsSession:
|
||||
"""goods 会话态:xMidasOps(59640,服务端生成)+ key16/key1 + args_template。"""
|
||||
|
||||
def __init__(self, xmidas_ops: list, key16: list, key1: list,
|
||||
args_template_d: str = "", xmidas_token: str = ""):
|
||||
def __init__(
|
||||
self,
|
||||
xmidas_ops: list,
|
||||
key16: list,
|
||||
key1: list,
|
||||
args_template_d: str = "",
|
||||
xmidas_token: str = "",
|
||||
):
|
||||
self.xmidas_ops = xmidas_ops
|
||||
self.key16 = key16
|
||||
self.key1 = key1
|
||||
@@ -44,8 +67,13 @@ class GoodsSession:
|
||||
def from_session_state(cls, path: str | Path) -> "GoodsSession":
|
||||
"""从 scripts/capture-session.mjs 生成的 session-state.json 加载。"""
|
||||
d = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
return cls(d["xmidas_ops"], d["key16"], d["key1"],
|
||||
d.get("args_template_d", ""), d.get("xmidas_token", ""))
|
||||
return cls(
|
||||
d["xmidas_ops"],
|
||||
d["key16"],
|
||||
d["key1"],
|
||||
d.get("args_template_d", ""),
|
||||
d.get("xmidas_token", ""),
|
||||
)
|
||||
|
||||
def to_json(self) -> dict:
|
||||
return {
|
||||
@@ -58,17 +86,29 @@ class GoodsSession:
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, d: dict) -> "GoodsSession":
|
||||
return cls(d["xmidas_ops"], d["key16"], d["key1"],
|
||||
d.get("args_template_d", ""), d.get("xmidas_token", ""))
|
||||
return cls(
|
||||
d["xmidas_ops"],
|
||||
d["key16"],
|
||||
d["key1"],
|
||||
d.get("args_template_d", ""),
|
||||
d.get("xmidas_token", ""),
|
||||
)
|
||||
|
||||
|
||||
def generate_encrypt_msg(session: GoodsSession, order: dict) -> str:
|
||||
"""用会话态 + 订单参数生成 goods encrypt_msg(1056 hex)。"""
|
||||
from .algorithm import decode_d
|
||||
|
||||
params = {k: order.get(k, "") for k in ORDER_FIELDS}
|
||||
args_tpl = decode_d(session.args_template_d) if session.args_template_d else None
|
||||
return generate_encrypt_msg_offline(
|
||||
params, order.get("fk_extend", ""), order.get("ts", ""), order.get("_rand", ""),
|
||||
xmidas=session.xmidas_ops, xmidas_token=session.xmidas_token,
|
||||
args_template=args_tpl, key16=session.key16, key1=session.key1,
|
||||
params,
|
||||
order.get("fk_extend", ""),
|
||||
order.get("ts", ""),
|
||||
order.get("_rand", ""),
|
||||
xmidas=session.xmidas_ops,
|
||||
xmidas_token=session.xmidas_token,
|
||||
args_template=args_tpl,
|
||||
key16=session.key16,
|
||||
key1=session.key1,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""YYB mall APIs require different session fields for WeChat and QQ OAuth."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
WECHAT_APPID = "wxd44977328b36e647"
|
||||
|
||||
@@ -12,6 +12,7 @@ mall 加密链路(E3 已验证,U-2022 闭环):
|
||||
注意:xMidasOps 是 mall 详情页页面级数据表(59620 长度,服务端生成),
|
||||
必须从浏览器捕获(与 goods 的 59640 不同)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
@@ -24,9 +25,28 @@ from .algorithm import UNDEF, JSObject, Window
|
||||
from .pagedoo_vm import PagedooVM
|
||||
|
||||
REPLAY = Path(__file__).resolve().parent.parent / "replay" / "mall"
|
||||
GLOBALS = [UNDEF, None, True, False, 4294967295, 3995986053, 2103143698, 1622111212,
|
||||
4263108271, 3162892160, 1960464030, 2867129963, 3224029870, 3514649446,
|
||||
1382846327, 1898428403, 1268470028, 1457769175, 1595352606, 1100935262]
|
||||
GLOBALS = [
|
||||
UNDEF,
|
||||
None,
|
||||
True,
|
||||
False,
|
||||
4294967295,
|
||||
3995986053,
|
||||
2103143698,
|
||||
1622111212,
|
||||
4263108271,
|
||||
3162892160,
|
||||
1960464030,
|
||||
2867129963,
|
||||
3224029870,
|
||||
3514649446,
|
||||
1382846327,
|
||||
1898428403,
|
||||
1268470028,
|
||||
1457769175,
|
||||
1595352606,
|
||||
1100935262,
|
||||
]
|
||||
|
||||
|
||||
class MallSession:
|
||||
@@ -45,9 +65,13 @@ class MallSession:
|
||||
|
||||
def validate(self) -> None:
|
||||
if len(self.transform_input) != 18:
|
||||
raise ValueError(f"transform_input 应为 18 槽,实际 {len(self.transform_input)}")
|
||||
raise ValueError(
|
||||
f"transform_input 应为 18 槽,实际 {len(self.transform_input)}"
|
||||
)
|
||||
if len(self.xmidas_ops) != 59620:
|
||||
raise ValueError(f"mall xMidasOps 应为 59620(非 goods 59640),实际 {len(self.xmidas_ops)}")
|
||||
raise ValueError(
|
||||
f"mall xMidasOps 应为 59620(非 goods 59640),实际 {len(self.xmidas_ops)}"
|
||||
)
|
||||
mid = self.transform_input[10]
|
||||
if isinstance(mid, list) and mid and isinstance(mid[0], list):
|
||||
if len(mid[0]) != 624:
|
||||
@@ -61,7 +85,11 @@ class MallSession:
|
||||
- J|...|e377650|{JSON} e377650 创建参数(transform_input)
|
||||
- XMIDAS_OPS|url|59620数组 mall 详情页 xMidasOps
|
||||
"""
|
||||
lines = Path(frames_jsonl).read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
lines = (
|
||||
Path(frames_jsonl)
|
||||
.read_text(encoding="utf-8", errors="replace")
|
||||
.splitlines()
|
||||
)
|
||||
transform_input = None
|
||||
xmidas = None
|
||||
# xMidasOps:取 mall 详情页(z.iwan / pagedoo)那条
|
||||
@@ -85,7 +113,9 @@ class MallSession:
|
||||
transform_input = json.loads(l.split("|e377650|", 1)[1])
|
||||
break
|
||||
if transform_input is None:
|
||||
raise ValueError("frames.jsonl 中未找到 e377650 J 转储(需 mall 详情页购买触发加密)")
|
||||
raise ValueError(
|
||||
"frames.jsonl 中未找到 e377650 J 转储(需 mall 详情页购买触发加密)"
|
||||
)
|
||||
if xmidas is None:
|
||||
raise ValueError("frames.jsonl 中未找到 59620 长度 xMidasOps")
|
||||
return cls(transform_input, xmidas)
|
||||
@@ -112,8 +142,18 @@ def _mk_window(xmidas: list) -> Window:
|
||||
w.set("sessionStorage", JSObject())
|
||||
w.set("screen", JSObject())
|
||||
w.set("history", JSObject())
|
||||
w.set("XMLHttpRequest", type("XHR", (), {
|
||||
"open": lambda *a: None, "send": lambda *a: None, "setRequestHeader": lambda *a: None}))
|
||||
w.set(
|
||||
"XMLHttpRequest",
|
||||
type(
|
||||
"XHR",
|
||||
(),
|
||||
{
|
||||
"open": lambda *a: None,
|
||||
"send": lambda *a: None,
|
||||
"setRequestHeader": lambda *a: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
w.set("fetch", lambda *a: None)
|
||||
w.set("xMidasOps", xmidas)
|
||||
return w
|
||||
@@ -143,5 +183,7 @@ def generate_encrypt_msg(session: MallSession, random_seed: int = 1) -> str:
|
||||
|
||||
h9 = h[9][0] if isinstance(h[9], list) and h[9] else h[9]
|
||||
if not isinstance(h9, list) or len(h9) != 624:
|
||||
raise RuntimeError(f"e377650 输出异常: {type(h9).__name__} len={len(h9) if isinstance(h9, list) else '?'}")
|
||||
raise RuntimeError(
|
||||
f"e377650 输出异常: {type(h9).__name__} len={len(h9) if isinstance(h9, list) else '?'}"
|
||||
)
|
||||
return "".join(f"{x & 255:02x}" for x in h9)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Read YYB's official order list after a payment is completed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
@@ -70,7 +71,9 @@ def order_completion_states(document: dict[str, Any]) -> dict[str, bool]:
|
||||
}
|
||||
|
||||
|
||||
def find_completed_order(document: dict[str, Any], previous_states: dict[str, bool]) -> dict[str, Any] | None:
|
||||
def find_completed_order(
|
||||
document: dict[str, Any], previous_states: dict[str, bool]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Find an order that appeared or transitioned to completed after the QR display."""
|
||||
for item in document.get("list", []):
|
||||
if not isinstance(item, dict):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
"""YYB 下单和付款失败响应的统一归类。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
这里仅收敛已由成功请求验证的固定值,不负责推断或修改协议字段。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
@@ -47,21 +48,38 @@ def file_fingerprint(path: Path) -> str:
|
||||
return _sha256_prefix_bytes(path.read_bytes())
|
||||
|
||||
|
||||
def validate_goods_materials(args_template: list, xmidas: list[int] | None = None) -> None:
|
||||
def validate_goods_materials(
|
||||
args_template: list, xmidas: list[int] | None = None
|
||||
) -> None:
|
||||
"""校验 goods webSave VM 所需静态表结构,发现升级时尽早失败。"""
|
||||
if not isinstance(args_template, list) or len(args_template) != 18:
|
||||
raise ValueError("goods args-template 必须是 18 槽数组")
|
||||
for index in (0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16):
|
||||
if not isinstance(args_template[index], list) or not args_template[index]:
|
||||
raise ValueError(f"goods args-template 槽 {index} 缺失")
|
||||
expected_lengths = {0: 16, 1: 256, 2: 256, 3: 256, 4: 256, 5: 256,
|
||||
6: 16, 10: 528, 11: 1024, 12: 1024, 13: 256,
|
||||
14: 256, 15: 256, 16: 256}
|
||||
expected_lengths = {
|
||||
0: 16,
|
||||
1: 256,
|
||||
2: 256,
|
||||
3: 256,
|
||||
4: 256,
|
||||
5: 256,
|
||||
6: 16,
|
||||
10: 528,
|
||||
11: 1024,
|
||||
12: 1024,
|
||||
13: 256,
|
||||
14: 256,
|
||||
15: 256,
|
||||
16: 256,
|
||||
}
|
||||
for index, expected in expected_lengths.items():
|
||||
value = args_template[index][0]
|
||||
if not isinstance(value, list) or len(value) != expected:
|
||||
actual = len(value) if isinstance(value, list) else "非数组"
|
||||
raise ValueError(f"goods args-template 槽 {index} 长度异常: {actual} != {expected}")
|
||||
raise ValueError(
|
||||
f"goods args-template 槽 {index} 长度异常: {actual} != {expected}"
|
||||
)
|
||||
if xmidas is not None and len(xmidas) != 59640:
|
||||
raise ValueError(f"goods xMidasOps 长度异常: {len(xmidas)} != 59640")
|
||||
|
||||
@@ -70,7 +88,9 @@ def validate_mall_materials(transform_fixed: dict[str, Any]) -> None:
|
||||
"""校验 mall 固定槽模板;动态槽仍由当前会话的 GetPayToken 填充。"""
|
||||
if not isinstance(transform_fixed, dict):
|
||||
raise ValueError("mall transform-fixed 必须是对象")
|
||||
required = {str(index) for index in (1, 2, 3, 4, 5, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17)}
|
||||
required = {
|
||||
str(index) for index in (1, 2, 3, 4, 5, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17)
|
||||
}
|
||||
missing = sorted(required - set(transform_fixed))
|
||||
if missing:
|
||||
raise ValueError(f"mall transform-fixed 缺少槽: {', '.join(missing)}")
|
||||
@@ -79,7 +99,9 @@ def validate_mall_materials(transform_fixed: dict[str, Any]) -> None:
|
||||
raise ValueError(f"mall transform-fixed 槽 {index} 不是数组")
|
||||
|
||||
|
||||
def goods_material_diagnostics(root: Path, args_template: list, xmidas: list[int]) -> dict[str, Any]:
|
||||
def goods_material_diagnostics(
|
||||
root: Path, args_template: list, xmidas: list[int]
|
||||
) -> dict[str, Any]:
|
||||
"""构造脱敏协议指纹,便于区分页面升级和服务端业务拒绝。"""
|
||||
validate_goods_materials(args_template, xmidas)
|
||||
replay = root / "replay"
|
||||
|
||||
@@ -6,6 +6,7 @@ F-2049/F-2051(E3):encrypt_msg 与当前会话绑定——
|
||||
- key1: 诱饵密钥(点击级,捕获即可)
|
||||
服务端能验证 key 派生状态(随机 key 变体 ret:1099),因此新订单必须先捕获会话态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -31,7 +32,9 @@ class SessionState:
|
||||
key16: list[int] = field(default_factory=list)
|
||||
key1: list[int] = field(default_factory=list)
|
||||
xmidas_token: str = DEFAULT_XMIDAS_TOKEN
|
||||
args_template_d: str = "" # webSave 18 参深拷贝(deepcap PC 85091 的 C[2] 原始 D 编码,会话绑定)
|
||||
args_template_d: str = (
|
||||
"" # webSave 18 参深拷贝(deepcap PC 85091 的 C[2] 原始 D 编码,会话绑定)
|
||||
)
|
||||
cookies: dict[str, str] = field(default_factory=dict)
|
||||
openid: str = ""
|
||||
openkey: str = ""
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
付款码只在服务端 ``web_save`` 返回 ``ret=0`` 后渲染。付款后通过商城官方订单
|
||||
列表确认本次新出现的完成订单;该检查不触发付款或确认操作。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -26,7 +27,11 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from pyvm.algorithm import build_plaintext, 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
|
||||
from pyvm.protocol import ( # noqa: E402
|
||||
@@ -64,6 +69,8 @@ def write_private_text(path: Path, content: str) -> None:
|
||||
|
||||
def write_private_json(path: Path, value: dict) -> None:
|
||||
write_private_text(path, json.dumps(value, ensure_ascii=False, indent=2) + "\n")
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
@@ -74,8 +81,12 @@ def parse_url_params(mall_response: dict) -> dict[str, str]:
|
||||
url_params = call_reply["data"]["url_params"]
|
||||
except (KeyError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("mall 响应缺少 data.call_reply.data.url_params") from exc
|
||||
return {key: values[-1] for key, values in urllib.parse.parse_qs(
|
||||
urllib.parse.urlparse(url_params).query, keep_blank_values=True).items()}
|
||||
return {
|
||||
key: values[-1]
|
||||
for key, values in urllib.parse.parse_qs(
|
||||
urllib.parse.urlparse(url_params).query, keep_blank_values=True
|
||||
).items()
|
||||
}
|
||||
|
||||
|
||||
def cookie_header(cookies: dict[str, str]) -> str:
|
||||
@@ -96,8 +107,9 @@ def request_bytes(url: str, cookies: dict[str, str], body: bytes | None = None)
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
headers["Origin"] = "https://pay.qq.com"
|
||||
request = urllib.request.Request(url, data=body, headers=headers,
|
||||
method="POST" if body is not None else "GET")
|
||||
request = urllib.request.Request(
|
||||
url, data=body, headers=headers, method="POST" if body is not None else "GET"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return response.read().decode("utf-8", "replace")
|
||||
@@ -105,7 +117,9 @@ def request_bytes(url: str, cookies: dict[str, str], body: bytes | None = None)
|
||||
return exc.read().decode("utf-8", "replace")
|
||||
|
||||
|
||||
def goods_page_url(cookies: dict[str, str], order: dict[str, str], zone_id: str = "1", pf: str = "") -> str:
|
||||
def goods_page_url(
|
||||
cookies: dict[str, str], order: dict[str, str], zone_id: str = "1", pf: str = ""
|
||||
) -> str:
|
||||
openid = cookies.get("openid") or cookies.get("midas_openid")
|
||||
openkey = cookies.get("accesstoken") or cookies.get("midas_openkey")
|
||||
if not openid or not openkey:
|
||||
@@ -135,7 +149,9 @@ def extract_goods_state(html: str) -> tuple[list[int], str, str]:
|
||||
token = re.search(r'id="xMidasToken"\s+value="([0-9A-Fa-f]+)"', html)
|
||||
anti = re.search(r'id="antiAutoScriptToken"\s+value="([0-9A-Fa-f]+)"', html)
|
||||
if not ops or not token or not anti:
|
||||
raise ValueError("goods 页面缺少 xMidasOps/xMidasToken/antiAutoScriptToken,登录态或订单已失效")
|
||||
raise ValueError(
|
||||
"goods 页面缺少 xMidasOps/xMidasToken/antiAutoScriptToken,登录态或订单已失效"
|
||||
)
|
||||
xmidas = [int(value) for value in ops.group(1).split(",") if value]
|
||||
if len(xmidas) != 59640:
|
||||
raise ValueError(f"goods xMidasOps 长度异常: {len(xmidas)}")
|
||||
@@ -147,16 +163,25 @@ def load_template_args() -> list:
|
||||
runtime_args = runtime_template / "args-template.json"
|
||||
if runtime_args.exists():
|
||||
return load_json(runtime_args)
|
||||
candidates = sorted((ROOT / "replay/live").glob("*/args-template.json"), reverse=True)
|
||||
candidates = sorted(
|
||||
(ROOT / "replay/live").glob("*/args-template.json"), reverse=True
|
||||
)
|
||||
for path in candidates:
|
||||
if path.exists():
|
||||
return load_json(path)
|
||||
raise FileNotFoundError("缺少归档 goods args-template.json")
|
||||
|
||||
|
||||
def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token: str,
|
||||
anti_token: str, encrypt_msg: str, zone_id: str, pf: str,
|
||||
amount_fen: int) -> dict[str, str]:
|
||||
def build_save_fields(
|
||||
order: dict[str, str],
|
||||
cookies: dict[str, str],
|
||||
web_token: str,
|
||||
anti_token: str,
|
||||
encrypt_msg: str,
|
||||
zone_id: str,
|
||||
pf: str,
|
||||
amount_fen: int,
|
||||
) -> dict[str, str]:
|
||||
"""按当前订单构造完整 web_save 表单,不能使用脱敏空模板。"""
|
||||
if amount_fen <= 0:
|
||||
raise ValueError("充值金额必须为正数")
|
||||
@@ -176,7 +201,9 @@ def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token:
|
||||
"openkey": cookies.get("accesstoken", ""),
|
||||
"session_id": login["session_id"],
|
||||
"session_type": login["session_type"],
|
||||
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode()).hexdigest().upper(),
|
||||
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode())
|
||||
.hexdigest()
|
||||
.upper(),
|
||||
"anti_auto_script_token_id": anti_token,
|
||||
"zoneid": zone_id,
|
||||
"buy_quantity": "1",
|
||||
@@ -207,17 +234,38 @@ def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token:
|
||||
return fields
|
||||
|
||||
|
||||
def build_save_body(order: dict[str, str], cookies: dict[str, str], web_token: str,
|
||||
anti_token: str, encrypt_msg: str, zone_id: str, pf: str,
|
||||
amount_fen: int) -> str:
|
||||
def build_save_body(
|
||||
order: dict[str, str],
|
||||
cookies: dict[str, str],
|
||||
web_token: str,
|
||||
anti_token: str,
|
||||
encrypt_msg: str,
|
||||
zone_id: str,
|
||||
pf: str,
|
||||
amount_fen: int,
|
||||
) -> str:
|
||||
"""编码完整支付表单,确保加密明文和实际请求使用同一上下文。"""
|
||||
return urllib.parse.urlencode(build_save_fields(
|
||||
order, cookies, web_token, anti_token, encrypt_msg, zone_id, pf, amount_fen,
|
||||
))
|
||||
return urllib.parse.urlencode(
|
||||
build_save_fields(
|
||||
order,
|
||||
cookies,
|
||||
web_token,
|
||||
anti_token,
|
||||
encrypt_msg,
|
||||
zone_id,
|
||||
pf,
|
||||
amount_fen,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_token: str,
|
||||
zone_id: str, pf: str) -> str:
|
||||
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 = {
|
||||
@@ -235,7 +283,9 @@ def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_to
|
||||
"openkey": cookies.get("accesstoken", ""),
|
||||
"session_id": login["session_id"],
|
||||
"session_type": login["session_type"],
|
||||
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode()).hexdigest().upper(),
|
||||
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode())
|
||||
.hexdigest()
|
||||
.upper(),
|
||||
"anti_auto_script_token_id": anti_token,
|
||||
"isusempaymode": "1",
|
||||
"zoneid": zone_id,
|
||||
@@ -249,10 +299,13 @@ 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,
|
||||
is_qq_login: bool) -> str:
|
||||
def make_encrypt_rand(
|
||||
params: dict[str, str], fk_extend: str, ts: str, is_qq_login: bool
|
||||
) -> str:
|
||||
"""按登录渠道生成页面已验证形态的 _rand,不能仅按长度替换控制字节。"""
|
||||
prefix = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(8))
|
||||
prefix = "".join(
|
||||
secrets.choice(string.ascii_letters + string.digits) for _ in range(8)
|
||||
)
|
||||
# 微信历史成功请求固定使用 8 个随机字符加 \x01。_rand 位于加密明文中,
|
||||
# 末控制字节是协议内容,不能为了对齐擅自替换为 QQ 使用的 \x03。
|
||||
if not is_qq_login:
|
||||
@@ -264,15 +317,27 @@ def make_encrypt_rand(params: dict[str, str], fk_extend: str, ts: str,
|
||||
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:
|
||||
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")
|
||||
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],
|
||||
"sha256_prefix": hashlib.sha256(fields.get(name, "").encode()).hexdigest()[
|
||||
:12
|
||||
],
|
||||
}
|
||||
for name in sensitive
|
||||
}
|
||||
@@ -290,7 +355,9 @@ def make_qr(sign: str, output: Path) -> None:
|
||||
try:
|
||||
import segno
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("缺少 segno;请安装后重新执行: python3 -m pip install segno") from exc
|
||||
raise RuntimeError(
|
||||
"缺少 segno;请安装后重新执行: python3 -m pip install segno"
|
||||
) from exc
|
||||
make_private_directory(output.parent)
|
||||
segno.make(sign).save(str(output), scale=6, border=2)
|
||||
os.chmod(output, 0o600)
|
||||
@@ -303,7 +370,9 @@ def node_environment() -> dict[str, str]:
|
||||
return environment
|
||||
|
||||
|
||||
def save_payment_status(path: Path, document: dict, baseline: dict[str, bool], matched: dict | None = None) -> None:
|
||||
def save_payment_status(
|
||||
path: Path, document: dict, baseline: dict[str, bool], matched: dict | None = None
|
||||
) -> None:
|
||||
"""Persist a small, non-payment-side-effect status record for this run."""
|
||||
from pyvm.order_status import completion_summary, order_ids
|
||||
|
||||
@@ -316,8 +385,13 @@ def save_payment_status(path: Path, document: dict, baseline: dict[str, bool], m
|
||||
write_private_json(path, record)
|
||||
|
||||
|
||||
def save_payment_meta(path: Path, order: dict[str, str], baseline: dict[str, bool],
|
||||
document: dict, portal_serial_no: str = "") -> None:
|
||||
def save_payment_meta(
|
||||
path: Path,
|
||||
order: dict[str, str],
|
||||
baseline: dict[str, bool],
|
||||
document: dict,
|
||||
portal_serial_no: str = "",
|
||||
) -> None:
|
||||
"""Persist the order identifiers and pre-payment baseline needed by a later check.
|
||||
|
||||
``check`` only re-reads this file and never touches web_save, so re-running a
|
||||
@@ -343,7 +417,9 @@ def save_payment_meta(path: Path, order: dict[str, str], baseline: dict[str, boo
|
||||
write_private_json(path, record)
|
||||
|
||||
|
||||
def _match_finished_by_identifiers(listed: list[dict], identifiers: dict[str, str]) -> dict | None:
|
||||
def _match_finished_by_identifiers(
|
||||
listed: list[dict], identifiers: dict[str, str]
|
||||
) -> dict | None:
|
||||
"""Match a finished order by the identifiers recorded at QR creation time.
|
||||
|
||||
The official order list may not carry token_id/out_trade_no, so no fallback
|
||||
@@ -397,23 +473,52 @@ def cmd_check_only(session_path: Path, out_dir: Path) -> int:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="YYB: jsdom DeviceFP + web_save -> 微信付款码")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="YYB: jsdom DeviceFP + web_save -> 微信付款码"
|
||||
)
|
||||
parser.add_argument("--session", default=str(ROOT / "config/mall-session.json"))
|
||||
parser.add_argument("--mall-response", default=str(ROOT / "config/mall-order-response.json"))
|
||||
parser.add_argument("--out-dir", default=None, help="运行证据目录;默认 config/jsdom-order-<timestamp>")
|
||||
parser.add_argument(
|
||||
"--mall-response", default=str(ROOT / "config/mall-order-response.json")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out-dir",
|
||||
default=None,
|
||||
help="运行证据目录;默认 config/jsdom-order-<timestamp>",
|
||||
)
|
||||
parser.add_argument("--qr", default=None, help="付款二维码 PNG 路径")
|
||||
parser.add_argument("--wait", type=int, default=5, help="jsdom 等待 DeviceFP 的秒数")
|
||||
parser.add_argument(
|
||||
"--wait", type=int, default=5, help="jsdom 等待 DeviceFP 的秒数"
|
||||
)
|
||||
parser.add_argument("--zone-id", default="1", help="所选游戏区服 ID")
|
||||
parser.add_argument("--pf", default="", help="所选 Android/iOS 支付平台标识")
|
||||
parser.add_argument("--amount-fen", type=int, default=0, help="所选点券的价格,单位分(check-only 模式不需要)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="仅拉取页面并生成 DeviceFP,不上报或创建付款码")
|
||||
parser.add_argument("--payment-timeout", type=float, default=300,
|
||||
help="付款码生成后等待订单完成的最长秒数")
|
||||
parser.add_argument("--payment-interval", type=float, default=3,
|
||||
help="订单完成状态检查间隔秒数")
|
||||
parser.add_argument("--skip-payment-check", action="store_true", help="仅生成付款码,不等待订单完成")
|
||||
parser.add_argument("--check-only", action="store_true",
|
||||
help="只读检测到账(依赖 --out-dir 下已保存的 payment-meta.json),不创建订单")
|
||||
parser.add_argument(
|
||||
"--amount-fen",
|
||||
type=int,
|
||||
default=0,
|
||||
help="所选点券的价格,单位分(check-only 模式不需要)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="仅拉取页面并生成 DeviceFP,不上报或创建付款码",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--payment-timeout",
|
||||
type=float,
|
||||
default=300,
|
||||
help="付款码生成后等待订单完成的最长秒数",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--payment-interval", type=float, default=3, help="订单完成状态检查间隔秒数"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-payment-check", action="store_true", help="仅生成付款码,不等待订单完成"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check-only",
|
||||
action="store_true",
|
||||
help="只读检测到账(依赖 --out-dir 下已保存的 payment-meta.json),不创建订单",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if args.payment_timeout <= 0 or args.payment_interval <= 0:
|
||||
raise ValueError("--payment-timeout 和 --payment-interval 必须为正数")
|
||||
@@ -421,7 +526,9 @@ def main() -> int:
|
||||
session_path = Path(args.session).resolve()
|
||||
response_path = Path(args.mall_response).resolve()
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
out_dir = Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}"
|
||||
out_dir = (
|
||||
Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}"
|
||||
)
|
||||
if args.check_only:
|
||||
make_private_directory(out_dir)
|
||||
return cmd_check_only(session_path, out_dir)
|
||||
@@ -444,7 +551,9 @@ def main() -> int:
|
||||
raise ValueError("mall 响应无法解析 url_params") from exc
|
||||
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
out_dir = Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}"
|
||||
out_dir = (
|
||||
Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}"
|
||||
)
|
||||
make_private_directory(out_dir)
|
||||
url = goods_page_url(cookies, order, args.zone_id, args.pf)
|
||||
print(f"[jsdom-pay] 订单: {order['token_id'][:20]}...")
|
||||
@@ -457,9 +566,18 @@ def main() -> int:
|
||||
|
||||
fp_path = out_dir / "device-fp.json"
|
||||
command = [
|
||||
"node", str(ROOT / "scripts/generate-devicefp-jsdom.mjs"),
|
||||
"--html", str(html_path), "--goods-url", url, "--cookies", str(session_path),
|
||||
"--output", str(fp_path), "--wait", str(args.wait * 1000),
|
||||
"node",
|
||||
str(ROOT / "scripts/generate-devicefp-jsdom.mjs"),
|
||||
"--html",
|
||||
str(html_path),
|
||||
"--goods-url",
|
||||
url,
|
||||
"--cookies",
|
||||
str(session_path),
|
||||
"--output",
|
||||
str(fp_path),
|
||||
"--wait",
|
||||
str(args.wait * 1000),
|
||||
]
|
||||
print("[jsdom-pay] 运行 jsdom DeviceFP...")
|
||||
subprocess.run(command, check=True, cwd=ROOT, env=node_environment())
|
||||
@@ -469,36 +587,53 @@ def main() -> int:
|
||||
return 0
|
||||
|
||||
print("[jsdom-pay] 上报 fp-behv...")
|
||||
fp_response = request_bytes(fp.get("fp_url", FP_URL), cookies, fp["fp_body"].encode())
|
||||
fp_response = request_bytes(
|
||||
fp.get("fp_url", FP_URL), cookies, fp["fp_body"].encode()
|
||||
)
|
||||
write_private_text(out_dir / "fp-response.json", fp_response)
|
||||
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', '')}")
|
||||
print(
|
||||
f"[jsdom-pay] fp-behv 失败: ret={fp_json.get('ret')} {fp_json.get('msg', '')}"
|
||||
)
|
||||
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())
|
||||
page_info = request_bytes(
|
||||
PAGE_INFO_URL,
|
||||
cookies,
|
||||
build_page_info_body(
|
||||
order,
|
||||
cookies,
|
||||
anti_token,
|
||||
args.zone_id,
|
||||
payment_pf,
|
||||
).encode(),
|
||||
)
|
||||
write_private_text(out_dir / "web-page-info-response.json", page_info)
|
||||
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] 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()
|
||||
validate_goods_materials(web_args, xmidas)
|
||||
write_private_json(out_dir / "protocol-materials.json", goods_material_diagnostics(ROOT, web_args, xmidas))
|
||||
write_private_json(
|
||||
out_dir / "protocol-materials.json",
|
||||
goods_material_diagnostics(ROOT, web_args, xmidas),
|
||||
)
|
||||
print("[jsdom-pay] 协议材料校验通过")
|
||||
# key 派生已破解(F-2052,2026-08-12):key16 可随机生成,key1 由反解器
|
||||
# derive_key1_from_key16 求解(key16=Sbox[Te 链(key1)]),满足服务端派生校验,
|
||||
@@ -529,18 +664,38 @@ def main() -> int:
|
||||
now_seconds = str(int(time.time()))
|
||||
fk_extend = "tdrc_session%3D" + fp["session_id"]
|
||||
random_suffix = make_encrypt_rand(
|
||||
params, fk_extend, now_seconds, bool(midas_login_params(cookies)["qq_appid"]),
|
||||
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(
|
||||
params, fk_extend, now_seconds, random_suffix,
|
||||
key16=key16, key1=key1, args_template=web_args, xmidas=xmidas, xmidas_token=web_token,
|
||||
params,
|
||||
fk_extend,
|
||||
now_seconds,
|
||||
random_suffix,
|
||||
key16=key16,
|
||||
key1=key1,
|
||||
args_template=web_args,
|
||||
xmidas=xmidas,
|
||||
xmidas_token=web_token,
|
||||
)
|
||||
fields = build_save_fields(
|
||||
order, cookies, web_token, anti_token, encrypt_msg,
|
||||
args.zone_id, payment_pf, args.amount_fen,
|
||||
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
|
||||
)
|
||||
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
|
||||
|
||||
@@ -561,10 +716,14 @@ def main() -> int:
|
||||
except json.JSONDecodeError:
|
||||
response_json = {}
|
||||
if response_json.get("ret") != 0:
|
||||
detail = f"ret={response_json.get('ret')} err_code={response_json.get('err_code', '')} " \
|
||||
f"{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] {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", "")
|
||||
@@ -588,7 +747,13 @@ def main() -> int:
|
||||
baseline_document = {"list": []}
|
||||
baseline_states = {}
|
||||
print("[jsdom-pay] 记录付款前订单基线与本次订单标识...")
|
||||
save_payment_meta(out_dir / "payment-meta.json", order, baseline_states, baseline_document, portal_serial_no)
|
||||
save_payment_meta(
|
||||
out_dir / "payment-meta.json",
|
||||
order,
|
||||
baseline_states,
|
||||
baseline_document,
|
||||
portal_serial_no,
|
||||
)
|
||||
if args.skip_payment_check:
|
||||
print("[jsdom-pay] 已保存基线;后台将单独执行只读到账检测")
|
||||
return 0
|
||||
@@ -596,17 +761,23 @@ def main() -> int:
|
||||
print("[jsdom-pay] 记录付款前订单列表,等待微信付款完成...")
|
||||
status_path = out_dir / "payment-status.json"
|
||||
save_payment_status(status_path, baseline_document, baseline_states)
|
||||
identifiers = {key: value for key, value in {
|
||||
"token_id": order.get("token_id", ""),
|
||||
"transaction_id": order.get("transaction_id", ""),
|
||||
"out_trade_no": order.get("out_trade_no", ""),
|
||||
}.items() if value}
|
||||
identifiers = {
|
||||
key: value
|
||||
for key, value in {
|
||||
"token_id": order.get("token_id", ""),
|
||||
"transaction_id": order.get("transaction_id", ""),
|
||||
"out_trade_no": order.get("out_trade_no", ""),
|
||||
}.items()
|
||||
if value
|
||||
}
|
||||
deadline = time.monotonic() + args.payment_timeout
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(args.payment_interval)
|
||||
document = get_official_orders(cookies)
|
||||
completed = _match_finished_by_identifiers(
|
||||
[item for item in document.get("list", []) if isinstance(item, dict)], identifiers)
|
||||
[item for item in document.get("list", []) if isinstance(item, dict)],
|
||||
identifiers,
|
||||
)
|
||||
save_payment_status(status_path, document, baseline_states, completed)
|
||||
if completed:
|
||||
print("[jsdom-pay] 微信付款成功,商城订单已完成。")
|
||||
@@ -620,6 +791,11 @@ def main() -> int:
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (FileNotFoundError, ValueError, RuntimeError, subprocess.CalledProcessError) as exc:
|
||||
except (
|
||||
FileNotFoundError,
|
||||
ValueError,
|
||||
RuntimeError,
|
||||
subprocess.CalledProcessError,
|
||||
) as exc:
|
||||
print(f"错误: {exc}", file=sys.stderr)
|
||||
raise SystemExit(2) from exc
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""应用宝 QQ 二维码登录(纯 Python,无浏览器自动化)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -15,7 +16,12 @@ from http.cookiejar import CookieJar
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import HTTPRedirectHandler, HTTPCookieProcessor, Request, build_opener
|
||||
from urllib.request import (
|
||||
HTTPRedirectHandler,
|
||||
HTTPCookieProcessor,
|
||||
Request,
|
||||
build_opener,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OPEN_APPID = "102033112"
|
||||
@@ -60,8 +66,15 @@ class Client:
|
||||
self.jar = CookieJar()
|
||||
self.opener = build_opener(NoRedirect, HTTPCookieProcessor(self.jar))
|
||||
|
||||
def request(self, url: str, *, method: str = "GET", body: bytes | None = None,
|
||||
referer: str = "", headers: dict[str, str] | None = None) -> Response:
|
||||
def request(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
method: str = "GET",
|
||||
body: bytes | None = None,
|
||||
referer: str = "",
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> Response:
|
||||
request_headers = {"User-Agent": USER_AGENT, "Accept": "*/*"}
|
||||
if referer:
|
||||
request_headers["Referer"] = referer
|
||||
@@ -155,7 +168,9 @@ def write_session(path: Path, cookies: dict[str, str]) -> None:
|
||||
document["login_type"] = cookies.get("logintype", "QC")
|
||||
document["login_updated_at"] = int(time.time())
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix=".mall-session-", suffix=".tmp", dir=path.parent)
|
||||
fd, temporary = tempfile.mkstemp(
|
||||
prefix=".mall-session-", suffix=".tmp", dir=path.parent
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(document, handle, ensure_ascii=False, indent=2)
|
||||
@@ -172,7 +187,9 @@ def write_session(path: Path, cookies: dict[str, str]) -> None:
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="应用宝 QQ 扫码登录(纯 Python)")
|
||||
parser.add_argument("--session", type=Path, default=ROOT / "config/mall-session.json")
|
||||
parser.add_argument(
|
||||
"--session", type=Path, default=ROOT / "config/mall-session.json"
|
||||
)
|
||||
parser.add_argument("--qr", type=Path, default=ROOT / "config/qq-login.jpg")
|
||||
parser.add_argument("--timeout", type=float, default=300, help="二维码等待秒数")
|
||||
parser.add_argument("--interval", type=float, default=3, help="轮询间隔秒数")
|
||||
@@ -183,8 +200,12 @@ def main() -> int:
|
||||
client = Client()
|
||||
state = secrets.token_urlsafe(14)
|
||||
show_query = {
|
||||
"which": "Login", "display": "pc", "response_type": "code", "client_id": OPEN_APPID,
|
||||
"redirect_uri": CALLBACK, "state": state,
|
||||
"which": "Login",
|
||||
"display": "pc",
|
||||
"response_type": "code",
|
||||
"client_id": OPEN_APPID,
|
||||
"redirect_uri": CALLBACK,
|
||||
"state": state,
|
||||
}
|
||||
show_url = f"{GRAPH_SHOW}?{urlencode(show_query)}"
|
||||
show = client.request(show_url, referer="https://m.yyb.qq.com/")
|
||||
@@ -192,11 +213,18 @@ def main() -> int:
|
||||
raise RuntimeError(f"QQ OAuth 页面请求失败: HTTP {show.status}")
|
||||
|
||||
xlogin_query = {
|
||||
"appid": PT_APPID, "daid": PT_DAID, "style": "33", "login_text": "登录",
|
||||
"hide_title_bar": "1", "hide_border": "1", "target": "self", "s_url": LOGIN_JUMP,
|
||||
"appid": PT_APPID,
|
||||
"daid": PT_DAID,
|
||||
"style": "33",
|
||||
"login_text": "登录",
|
||||
"hide_title_bar": "1",
|
||||
"hide_border": "1",
|
||||
"target": "self",
|
||||
"s_url": LOGIN_JUMP,
|
||||
"pt_3rd_aid": OPEN_APPID,
|
||||
"pt_feedback_link": f"https://support.qq.com/products/77942?customInfo=.appid{OPEN_APPID}",
|
||||
"theme": "2", "verify_theme": "",
|
||||
"theme": "2",
|
||||
"verify_theme": "",
|
||||
}
|
||||
xlogin_url = f"{XLOGIN}?{urlencode(xlogin_query)}"
|
||||
xlogin = client.request(xlogin_url, referer=show_url)
|
||||
@@ -207,9 +235,16 @@ def main() -> int:
|
||||
raise RuntimeError("QQ 登录页未写入 pt_login_sig")
|
||||
|
||||
qr_query = {
|
||||
"appid": PT_APPID, "e": "2", "l": "M", "s": "3", "d": "72", "v": "4",
|
||||
"t": str(secrets.randbelow(1_000_000) / 1_000_000), "daid": PT_DAID,
|
||||
"pt_3rd_aid": OPEN_APPID, "u1": LOGIN_JUMP,
|
||||
"appid": PT_APPID,
|
||||
"e": "2",
|
||||
"l": "M",
|
||||
"s": "3",
|
||||
"d": "72",
|
||||
"v": "4",
|
||||
"t": str(secrets.randbelow(1_000_000) / 1_000_000),
|
||||
"daid": PT_DAID,
|
||||
"pt_3rd_aid": OPEN_APPID,
|
||||
"u1": LOGIN_JUMP,
|
||||
}
|
||||
qr_url = f"{QR_SHOW}?{urlencode(qr_query)}"
|
||||
image = client.request(qr_url, referer=xlogin_url)
|
||||
@@ -228,10 +263,23 @@ def main() -> int:
|
||||
o1v_id = secrets.token_hex(16)
|
||||
while time.monotonic() < deadline:
|
||||
poll_query = {
|
||||
"u1": LOGIN_JUMP, "ptqrtoken": str(ptqr_token(qrsig)), "ptredirect": "0", "h": "1", "t": "1",
|
||||
"g": "1", "from_ui": "1", "ptlang": "2052", "action": f"0-0-{int(time.time() * 1000)}",
|
||||
"js_ver": "26071711", "js_type": "1", "login_sig": login_sig, "pt_uistyle": "40",
|
||||
"aid": PT_APPID, "daid": PT_DAID, "pt_3rd_aid": OPEN_APPID, "o1vId": o1v_id,
|
||||
"u1": LOGIN_JUMP,
|
||||
"ptqrtoken": str(ptqr_token(qrsig)),
|
||||
"ptredirect": "0",
|
||||
"h": "1",
|
||||
"t": "1",
|
||||
"g": "1",
|
||||
"from_ui": "1",
|
||||
"ptlang": "2052",
|
||||
"action": f"0-0-{int(time.time() * 1000)}",
|
||||
"js_ver": "26071711",
|
||||
"js_type": "1",
|
||||
"login_sig": login_sig,
|
||||
"pt_uistyle": "40",
|
||||
"aid": PT_APPID,
|
||||
"daid": PT_DAID,
|
||||
"pt_3rd_aid": OPEN_APPID,
|
||||
"o1vId": o1v_id,
|
||||
"pt_js_version": "c1987b96",
|
||||
}
|
||||
poll = client.request(f"{QR_POLL}?{urlencode(poll_query)}", referer=xlogin_url)
|
||||
@@ -252,7 +300,9 @@ def main() -> int:
|
||||
|
||||
check_sig = client.request(callback, referer=xlogin_url)
|
||||
login_jump = check_sig.location()
|
||||
if check_sig.status != 302 or not login_jump.startswith("https://graph.qq.com/oauth2.0/login_jump"):
|
||||
if check_sig.status != 302 or not login_jump.startswith(
|
||||
"https://graph.qq.com/oauth2.0/login_jump"
|
||||
):
|
||||
raise RuntimeError("QQ check_sig 未跳转到 OAuth login_jump")
|
||||
jump = client.request(login_jump, referer=callback)
|
||||
if jump.status != 200:
|
||||
@@ -262,8 +312,10 @@ def main() -> int:
|
||||
if not p_skey:
|
||||
raise RuntimeError("QQ check_sig 未写入 p_skey")
|
||||
authorize = client.request(
|
||||
"https://graph.qq.com/oauth2.0/authorize", method="POST",
|
||||
body=urlencode(authorize_params(state, p_skey)).encode("utf-8"), referer=login_jump,
|
||||
"https://graph.qq.com/oauth2.0/authorize",
|
||||
method="POST",
|
||||
body=urlencode(authorize_params(state, p_skey)).encode("utf-8"),
|
||||
referer=login_jump,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
oauth_callback = authorize.location()
|
||||
@@ -276,12 +328,16 @@ def main() -> int:
|
||||
if not cookies.get("openid") or not cookies.get("accesstoken"):
|
||||
raise RuntimeError("YYB QQ OAuth 回调未写入 openid/accesstoken")
|
||||
login_type = cookies.get("logintype", "QC")
|
||||
info = client.request(USER_INFO, headers={
|
||||
"Ual-Access-Login-Type": login_type_header(login_type),
|
||||
"Ual-Access-Access-Token": cookies["accesstoken"],
|
||||
"Ual-Access-Openid": cookies["openid"],
|
||||
"Origin": "https://m.yyb.qq.com", "Referer": "https://m.yyb.qq.com/",
|
||||
})
|
||||
info = client.request(
|
||||
USER_INFO,
|
||||
headers={
|
||||
"Ual-Access-Login-Type": login_type_header(login_type),
|
||||
"Ual-Access-Access-Token": cookies["accesstoken"],
|
||||
"Ual-Access-Openid": cookies["openid"],
|
||||
"Origin": "https://m.yyb.qq.com",
|
||||
"Referer": "https://m.yyb.qq.com/",
|
||||
},
|
||||
)
|
||||
if info.status != 200:
|
||||
raise RuntimeError(f"YYB QQ 登录态校验失败: HTTP {info.status}")
|
||||
try:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
二维码由微信 OAuth 生成,用户用微信扫码确认;本脚本轮询授权结果,完成
|
||||
YYB OAuth 回调后将动态 cookies 合并到 mall-session.json,供后续纯 CK 流程使用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -18,7 +19,12 @@ from http.cookiejar import CookieJar
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import HTTPRedirectHandler, HTTPCookieProcessor, Request, build_opener
|
||||
from urllib.request import (
|
||||
HTTPRedirectHandler,
|
||||
HTTPCookieProcessor,
|
||||
Request,
|
||||
build_opener,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OPEN_APPID = "wxd44977328b36e647"
|
||||
@@ -58,7 +64,9 @@ class Client:
|
||||
self.jar = CookieJar()
|
||||
self.opener = build_opener(NoRedirect, HTTPCookieProcessor(self.jar))
|
||||
|
||||
def request(self, url: str, *, referer: str = "", headers: dict[str, str] | None = None) -> Response:
|
||||
def request(
|
||||
self, url: str, *, referer: str = "", headers: dict[str, str] | None = None
|
||||
) -> Response:
|
||||
request_headers = {"User-Agent": USER_AGENT, "Accept": "*/*"}
|
||||
if referer:
|
||||
request_headers["Referer"] = referer
|
||||
@@ -116,7 +124,9 @@ def write_session(path: Path, cookies: dict[str, str]) -> None:
|
||||
document["login_type"] = cookies.get("logintype", "WX")
|
||||
document["login_updated_at"] = int(time.time())
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix=".mall-session-", suffix=".tmp", dir=path.parent)
|
||||
fd, temporary = tempfile.mkstemp(
|
||||
prefix=".mall-session-", suffix=".tmp", dir=path.parent
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(document, handle, ensure_ascii=False, indent=2)
|
||||
@@ -133,7 +143,9 @@ def write_session(path: Path, cookies: dict[str, str]) -> None:
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="应用宝微信扫码登录(纯 Python)")
|
||||
parser.add_argument("--session", type=Path, default=ROOT / "config/mall-session.json")
|
||||
parser.add_argument(
|
||||
"--session", type=Path, default=ROOT / "config/mall-session.json"
|
||||
)
|
||||
parser.add_argument("--qr", type=Path, default=ROOT / "config/wechat-login.jpg")
|
||||
parser.add_argument("--timeout", type=float, default=300, help="二维码等待秒数")
|
||||
parser.add_argument("--interval", type=float, default=2, help="轮询间隔秒数")
|
||||
@@ -158,7 +170,9 @@ def main() -> int:
|
||||
if page.status != 200:
|
||||
raise RuntimeError(f"微信授权页请求失败: HTTP {page.status}")
|
||||
uuid = extract_uuid(page.text)
|
||||
image = client.request(f"https://open.weixin.qq.com/connect/qrcode/{uuid}", referer=authorization_url)
|
||||
image = client.request(
|
||||
f"https://open.weixin.qq.com/connect/qrcode/{uuid}", referer=authorization_url
|
||||
)
|
||||
if image.status != 200 or not image.body:
|
||||
raise RuntimeError(f"微信二维码请求失败: HTTP {image.status}")
|
||||
args.qr.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -173,7 +187,9 @@ def main() -> int:
|
||||
poll_query = {"uuid": uuid}
|
||||
if last:
|
||||
poll_query["last"] = last
|
||||
poll = client.request(f"{POLL_QR}?{urlencode(poll_query)}", referer=authorization_url)
|
||||
poll = client.request(
|
||||
f"{POLL_QR}?{urlencode(poll_query)}", referer=authorization_url
|
||||
)
|
||||
errcode, code = parse_poll(poll.text)
|
||||
if errcode == 405 and code:
|
||||
break
|
||||
@@ -202,13 +218,16 @@ def main() -> int:
|
||||
login_type = cookies.get("logintype", "WX")
|
||||
if not openid or not access_token:
|
||||
raise RuntimeError("YYB OAuth 回调未写入 openid/accesstoken")
|
||||
info = client.request(USER_INFO, headers={
|
||||
"Ual-Access-Login-Type": login_type_header(login_type),
|
||||
"Ual-Access-Access-Token": access_token,
|
||||
"Ual-Access-Openid": openid,
|
||||
"Origin": "https://m.yyb.qq.com",
|
||||
"Referer": "https://m.yyb.qq.com/",
|
||||
})
|
||||
info = client.request(
|
||||
USER_INFO,
|
||||
headers={
|
||||
"Ual-Access-Login-Type": login_type_header(login_type),
|
||||
"Ual-Access-Access-Token": access_token,
|
||||
"Ual-Access-Openid": openid,
|
||||
"Origin": "https://m.yyb.qq.com",
|
||||
"Referer": "https://m.yyb.qq.com/",
|
||||
},
|
||||
)
|
||||
if info.status != 200:
|
||||
raise RuntimeError(f"YYB 登录态校验失败: HTTP {info.status}")
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""用已登录的 YYB CK 选择和平精英点券档位、区服和角色。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -45,26 +46,43 @@ UA = (
|
||||
|
||||
def load_cookies(path: Path) -> dict[str, str]:
|
||||
session = json.loads(path.read_text(encoding="utf-8"))
|
||||
cookies = {str(key): str(value) for key, value in session.get("cookies", {}).items() if value}
|
||||
cookies = {
|
||||
str(key): str(value)
|
||||
for key, value in session.get("cookies", {}).items()
|
||||
if value
|
||||
}
|
||||
if not cookies.get("openid") or not cookies.get("accesstoken"):
|
||||
raise ValueError("会话缺少 openid/accesstoken,请先执行 login-wechat.py")
|
||||
return cookies
|
||||
|
||||
|
||||
def product_options(cookies: dict[str, str], platform: str) -> list[dict[str, str | int]]:
|
||||
def product_options(
|
||||
cookies: dict[str, str], platform: str
|
||||
) -> list[dict[str, str | int]]:
|
||||
response = requests.post(
|
||||
PRODUCTS_URL,
|
||||
json={"platform": PLATFORMS[platform]["query_platform"], "source_id": SOURCE_ID,
|
||||
"yyb_app_id": YYB_APP_ID},
|
||||
headers={"Accept": "application/json, text/plain, */*", "Origin": "https://m.yyb.qq.com",
|
||||
"Referer": "https://m.yyb.qq.com/boc-mall/goods-mall/", "User-Agent": UA},
|
||||
cookies=cookies, impersonate="chrome", timeout=30,
|
||||
json={
|
||||
"platform": PLATFORMS[platform]["query_platform"],
|
||||
"source_id": SOURCE_ID,
|
||||
"yyb_app_id": YYB_APP_ID,
|
||||
},
|
||||
headers={
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Origin": "https://m.yyb.qq.com",
|
||||
"Referer": "https://m.yyb.qq.com/boc-mall/goods-mall/",
|
||||
"User-Agent": UA,
|
||||
},
|
||||
cookies=cookies,
|
||||
impersonate="chrome",
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(f"点券商品查询失败: HTTP {response.status_code}")
|
||||
document = response.json()
|
||||
if document.get("code") not in (None, 0):
|
||||
raise RuntimeError(f"点券商品查询失败: {document.get('code')} {document.get('message', '')}")
|
||||
raise RuntimeError(
|
||||
f"点券商品查询失败: {document.get('code')} {document.get('message', '')}"
|
||||
)
|
||||
products = document.get("token_mod", {}).get("products", [])
|
||||
result: list[dict[str, str | int]] = []
|
||||
for item in products:
|
||||
@@ -72,15 +90,19 @@ def product_options(cookies: dict[str, str], platform: str) -> list[dict[str, st
|
||||
match = re.fullmatch(r"(\d+)点券", str(product.get("product_name", "")))
|
||||
if not match or str(product.get("status")) != "20":
|
||||
continue
|
||||
result.append({
|
||||
"points": int(match.group(1)),
|
||||
"product_id": str(product.get("product_id", "")),
|
||||
"price_fen": int(product.get("price", 0)),
|
||||
"offer_id": str(product.get("res_offer_id", "")),
|
||||
"name": str(product.get("product_name", "")),
|
||||
})
|
||||
result.append(
|
||||
{
|
||||
"points": int(match.group(1)),
|
||||
"product_id": str(product.get("product_id", "")),
|
||||
"price_fen": int(product.get("price", 0)),
|
||||
"offer_id": str(product.get("res_offer_id", "")),
|
||||
"name": str(product.get("product_name", "")),
|
||||
}
|
||||
)
|
||||
result.sort(key=lambda item: int(item["points"]))
|
||||
if not result or any(not item["product_id"] or not item["offer_id"] for item in result):
|
||||
if not result or any(
|
||||
not item["product_id"] or not item["offer_id"] for item in result
|
||||
):
|
||||
raise RuntimeError("点券商品响应缺少 product_id 或 offer_id")
|
||||
return result
|
||||
|
||||
@@ -95,30 +117,48 @@ class Cmall:
|
||||
def query(self, cmd: str, **extra: str) -> dict:
|
||||
login = midas_login_params(self.cookies)
|
||||
params = {
|
||||
"from_h5": "1", "pf": PLATFORMS[self.platform]["cmall_pf"], "r": str(random.random()), "cmd": cmd,
|
||||
"from_h5": "1",
|
||||
"pf": PLATFORMS[self.platform]["cmall_pf"],
|
||||
"r": str(random.random()),
|
||||
"cmd": cmd,
|
||||
"session_token": self.session_token,
|
||||
"pfkey": "pfkey", "webversion": "", **extra,
|
||||
"pfkey": "pfkey",
|
||||
"webversion": "",
|
||||
**extra,
|
||||
**login,
|
||||
}
|
||||
# 当前商城页面将查询参数放在 URL 上,但请求方法为 POST 且没有 body。
|
||||
response = requests.post(
|
||||
CMALL_URL.format(offer_id=self.offer_id) + "?" + urlencode(params),
|
||||
headers={"Origin": "https://z.iwan.yyb.qq.com", "Referer": "https://z.iwan.yyb.qq.com/",
|
||||
"User-Agent": UA},
|
||||
cookies=self.cookies, impersonate="chrome", timeout=30,
|
||||
headers={
|
||||
"Origin": "https://z.iwan.yyb.qq.com",
|
||||
"Referer": "https://z.iwan.yyb.qq.com/",
|
||||
"User-Agent": UA,
|
||||
},
|
||||
cookies=self.cookies,
|
||||
impersonate="chrome",
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(f"游戏数据查询失败: HTTP {response.status_code}")
|
||||
document = response.json()
|
||||
if document.get("ret") not in (0, "0"):
|
||||
raise RuntimeError(f"游戏数据查询失败: {document.get('ret')} {document.get('msg', '')}")
|
||||
raise RuntimeError(
|
||||
f"游戏数据查询失败: {document.get('ret')} {document.get('msg', '')}"
|
||||
)
|
||||
return document
|
||||
|
||||
def zones(self) -> list[dict[str, str]]:
|
||||
response = self.query("14", use_currency_offerid="1")
|
||||
zones = response.get("zone_list", [])
|
||||
return [{"zone_id": str(item.get("zone_id", "")), "name": str(item.get("zone_name", ""))}
|
||||
for item in zones if isinstance(item, dict) and item.get("zone_id")]
|
||||
return [
|
||||
{
|
||||
"zone_id": str(item.get("zone_id", "")),
|
||||
"name": str(item.get("zone_name", "")),
|
||||
}
|
||||
for item in zones
|
||||
if isinstance(item, dict) and item.get("zone_id")
|
||||
]
|
||||
|
||||
def roles(self, zone_id: str) -> list[dict[str, str]]:
|
||||
response = self.query("15", use_currency_offerid="1", zoneid=zone_id)
|
||||
@@ -126,14 +166,18 @@ class Cmall:
|
||||
# QQ 平台和平精英的 area 与 zoneid 不同(如 area=2, zoneid=1);
|
||||
# PlaceOrder 校验角色时需要该分区信息,随角色一并返回。
|
||||
partition = response.get("partition_info") or {}
|
||||
return [{
|
||||
"role_id": str(item.get("role_id", "")),
|
||||
"name": unquote(str(item.get("role_name", ""))),
|
||||
"ban_status": str(item.get("ban_status", "")),
|
||||
"area": str(partition.get("area", "")),
|
||||
"partition": str(partition.get("partition", "")),
|
||||
"platid": str(partition.get("platid", "")),
|
||||
} for item in roles if isinstance(item, dict) and item.get("role_id")]
|
||||
return [
|
||||
{
|
||||
"role_id": str(item.get("role_id", "")),
|
||||
"name": unquote(str(item.get("role_name", ""))),
|
||||
"ban_status": str(item.get("ban_status", "")),
|
||||
"area": str(partition.get("area", "")),
|
||||
"partition": str(partition.get("partition", "")),
|
||||
"platid": str(partition.get("platid", "")),
|
||||
}
|
||||
for item in roles
|
||||
if isinstance(item, dict) and item.get("role_id")
|
||||
]
|
||||
|
||||
|
||||
def choose(label: str, options: list[dict], display) -> dict:
|
||||
@@ -151,18 +195,31 @@ def choose(label: str, options: list[dict], display) -> dict:
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="和平精英点券和角色选择(纯 CK 查询)")
|
||||
parser.add_argument("--session", type=Path, default=ROOT / "config/mall-session.json")
|
||||
parser.add_argument("--output", type=Path, default=ROOT / "config/peace-elite-selection.json")
|
||||
parser.add_argument("--platform", choices=tuple(PLATFORMS), default=None,
|
||||
help="预选 Android/iOS;不传则显示平台菜单")
|
||||
parser.add_argument("--points", type=int, default=None, help="预选点券数;不传则显示菜单")
|
||||
parser.add_argument("--list-products", action="store_true", help="仅列出当前所有点券档位")
|
||||
parser.add_argument(
|
||||
"--session", type=Path, default=ROOT / "config/mall-session.json"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", type=Path, default=ROOT / "config/peace-elite-selection.json"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--platform",
|
||||
choices=tuple(PLATFORMS),
|
||||
default=None,
|
||||
help="预选 Android/iOS;不传则显示平台菜单",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--points", type=int, default=None, help="预选点券数;不传则显示菜单"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list-products", action="store_true", help="仅列出当前所有点券档位"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
cookies = load_cookies(args.session)
|
||||
if args.platform is None:
|
||||
selected_platform = choose(
|
||||
"平台", [{"id": key, **value} for key, value in PLATFORMS.items()],
|
||||
"平台",
|
||||
[{"id": key, **value} for key, value in PLATFORMS.items()],
|
||||
lambda item: item["label"],
|
||||
)
|
||||
platform = str(selected_platform["id"])
|
||||
@@ -172,32 +229,59 @@ def main() -> int:
|
||||
products = product_options(cookies, platform)
|
||||
if args.list_products:
|
||||
for product in products:
|
||||
print(f"{product['points']}点券\t{product['price_fen'] / 100:g}元\t{product['product_id']}")
|
||||
print(
|
||||
f"{product['points']}点券\t{product['price_fen'] / 100:g}元\t{product['product_id']}"
|
||||
)
|
||||
return 0
|
||||
if args.points is None:
|
||||
product = choose("点券档位", products, lambda item: f"{item['points']}点券({item['price_fen'] / 100:g}元)")
|
||||
product = choose(
|
||||
"点券档位",
|
||||
products,
|
||||
lambda item: f"{item['points']}点券({item['price_fen'] / 100:g}元)",
|
||||
)
|
||||
else:
|
||||
product = next((item for item in products if item["points"] == args.points), None)
|
||||
product = next(
|
||||
(item for item in products if item["points"] == args.points), None
|
||||
)
|
||||
if product is None:
|
||||
available = ", ".join(str(item["points"]) for item in products)
|
||||
raise ValueError(f"不支持 {args.points} 点券;当前可选: {available}")
|
||||
print(f"已选择: {product['points']}点券({product['price_fen'] / 100:g}元)")
|
||||
|
||||
cmall = Cmall(cookies, str(product["offer_id"]), platform)
|
||||
zone = choose("区服", cmall.zones(), lambda item: f"{item['name']}(ID {item['zone_id']})")
|
||||
zone = choose(
|
||||
"区服", cmall.zones(), lambda item: f"{item['name']}(ID {item['zone_id']})"
|
||||
)
|
||||
roles = cmall.roles(zone["zone_id"])
|
||||
role = choose("角色", roles, lambda item: f"{item['name']}({'禁用' if item['ban_status'] == '1' else '可用'})")
|
||||
role = choose(
|
||||
"角色",
|
||||
roles,
|
||||
lambda item: (
|
||||
f"{item['name']}({'禁用' if item['ban_status'] == '1' else '可用'})"
|
||||
),
|
||||
)
|
||||
if role["ban_status"] == "1":
|
||||
raise RuntimeError("所选角色已被封禁,不能充值")
|
||||
|
||||
selection = {"platform": platform, "order_pf": PLATFORMS[platform]["order_pf"],
|
||||
"points": product["points"], "product_id": product["product_id"],
|
||||
"offer_id": product["offer_id"], "price_fen": product["price_fen"],
|
||||
"zone_id": zone["zone_id"], "zone_name": zone["name"],
|
||||
"role_id": role["role_id"], "role_name": role["name"]}
|
||||
selection = {
|
||||
"platform": platform,
|
||||
"order_pf": PLATFORMS[platform]["order_pf"],
|
||||
"points": product["points"],
|
||||
"product_id": product["product_id"],
|
||||
"offer_id": product["offer_id"],
|
||||
"price_fen": product["price_fen"],
|
||||
"zone_id": zone["zone_id"],
|
||||
"zone_name": zone["name"],
|
||||
"role_id": role["role_id"],
|
||||
"role_name": role["name"],
|
||||
}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(selection, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"\n已选择 {selection['points']}点券 / {selection['zone_name']} / {selection['role_name']}")
|
||||
args.output.write_text(
|
||||
json.dumps(selection, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
print(
|
||||
f"\n已选择 {selection['points']}点券 / {selection['zone_name']} / {selection['role_name']}"
|
||||
)
|
||||
print(f"选择已保存: {args.output}")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ The worker owns per-job sessions and invokes the already verified protocol
|
||||
scripts. It intentionally exposes QR images and state only; cookies and raw
|
||||
payment links never leave the worker API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -79,15 +80,23 @@ def _safe_log(job: dict, line: str) -> None:
|
||||
clean = re.sub(r"weixin://\S+", "[付款链接已隐藏]", line)
|
||||
clean = re.sub(r"HOLD_[A-Za-z0-9_-]+", "[订单已隐藏]", clean)
|
||||
clean = re.sub(r"(订单\s*[::]\s*)\S+", r"\1[订单已隐藏]", clean)
|
||||
clean = re.sub(r"(?:token|openid|openkey|cookie)=\S+", "[敏感字段已隐藏]", clean, flags=re.I)
|
||||
clean = re.sub(r"(?:pay_token|web_token|anti_token|session_id|sessionid)\s*[:= ]\s*\S+",
|
||||
"[敏感字段已隐藏]", clean, flags=re.I)
|
||||
clean = re.sub(
|
||||
r"(?:token|openid|openkey|cookie)=\S+", "[敏感字段已隐藏]", clean, flags=re.I
|
||||
)
|
||||
clean = re.sub(
|
||||
r"(?:pay_token|web_token|anti_token|session_id|sessionid)\s*[:= ]\s*\S+",
|
||||
"[敏感字段已隐藏]",
|
||||
clean,
|
||||
flags=re.I,
|
||||
)
|
||||
timestamp = time.strftime("%H:%M:%S")
|
||||
with _lock:
|
||||
job["logs"] = (job.get("logs", []) + [f"[{timestamp}] {clean.strip()}"])[-100:]
|
||||
|
||||
|
||||
def _run_process(job_id: str, command: list[str], phase: str, mark_success: bool = True) -> int:
|
||||
def _run_process(
|
||||
job_id: str, command: list[str], phase: str, mark_success: bool = True
|
||||
) -> int:
|
||||
"""Run one stage process and return its exit code.
|
||||
|
||||
When ``mark_success`` is False the caller owns the post-success state
|
||||
@@ -104,16 +113,24 @@ def _run_process(job_id: str, command: list[str], phase: str, mark_success: bool
|
||||
stage_started = time.monotonic()
|
||||
_safe_log(job, f"[{phase}] 开始执行")
|
||||
try:
|
||||
process = subprocess.Popen(command, cwd=ROOT, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True,
|
||||
bufsize=1, env=environment)
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=ROOT,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=environment,
|
||||
)
|
||||
with _lock:
|
||||
job["process_pid"] = process.pid
|
||||
assert process.stdout is not None
|
||||
for line in process.stdout:
|
||||
_safe_log(job, line)
|
||||
code = process.wait()
|
||||
_safe_log(job, f"[{phase}] 执行结束,耗时 {time.monotonic() - stage_started:.1f} 秒")
|
||||
_safe_log(
|
||||
job, f"[{phase}] 执行结束,耗时 {time.monotonic() - stage_started:.1f} 秒"
|
||||
)
|
||||
with _lock:
|
||||
job["process_pid"] = None
|
||||
if code != 0:
|
||||
@@ -134,7 +151,9 @@ def _run_process(job_id: str, command: list[str], phase: str, mark_success: bool
|
||||
job["message"] = "付款流程已完成"
|
||||
return code
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_safe_log(job, f"[{phase}] 执行异常,耗时 {time.monotonic() - stage_started:.1f} 秒")
|
||||
_safe_log(
|
||||
job, f"[{phase}] 执行异常,耗时 {time.monotonic() - stage_started:.1f} 秒"
|
||||
)
|
||||
with _lock:
|
||||
job["status"] = "failed"
|
||||
job["message"] = str(exc)
|
||||
@@ -146,42 +165,74 @@ def _start_login(job_id: str, provider: str, timeout: int) -> None:
|
||||
directory = _job_dir(job_id)
|
||||
session = directory / "mall-session.json"
|
||||
qr = directory / ("qq-login.jpg" if provider == "qq" else "wechat-login.jpg")
|
||||
command = [sys.executable, f"scripts/login-{provider}.py", "--session", str(session),
|
||||
"--qr", str(qr), "--timeout", str(timeout)]
|
||||
command = [
|
||||
sys.executable,
|
||||
f"scripts/login-{provider}.py",
|
||||
"--session",
|
||||
str(session),
|
||||
"--qr",
|
||||
str(qr),
|
||||
"--timeout",
|
||||
str(timeout),
|
||||
]
|
||||
with _lock:
|
||||
job["provider"] = provider
|
||||
job["qr_path"] = str(qr)
|
||||
job["session_path"] = str(session)
|
||||
job["status"] = "waiting_login"
|
||||
job["phase"] = "login"
|
||||
threading.Thread(target=_run_process, args=(job_id, command, "login"), daemon=True).start()
|
||||
threading.Thread(
|
||||
target=_run_process, args=(job_id, command, "login"), daemon=True
|
||||
).start()
|
||||
|
||||
|
||||
def _selection_options(job_id: str, platform: str, points: int | None, zone_id: str | None = None) -> dict:
|
||||
def _selection_options(
|
||||
job_id: str, platform: str, points: int | None, zone_id: str | None = None
|
||||
) -> dict:
|
||||
if job_id not in _jobs:
|
||||
raise ValueError("任务不存在")
|
||||
selector = _load_selector()
|
||||
session = json.loads((_job_dir(job_id) / "mall-session.json").read_text(encoding="utf-8"))
|
||||
session = json.loads(
|
||||
(_job_dir(job_id) / "mall-session.json").read_text(encoding="utf-8")
|
||||
)
|
||||
cookies = session.get("cookies", {})
|
||||
products = selector.product_options(cookies, platform)
|
||||
if points is not None and not any(int(item["points"]) == points for item in products):
|
||||
if points is not None and not any(
|
||||
int(item["points"]) == points for item in products
|
||||
):
|
||||
raise ValueError("当前登录态不支持该点券档位")
|
||||
product = next((item for item in products if int(item["points"]) == points), None) if points else None
|
||||
product = (
|
||||
next((item for item in products if int(item["points"]) == points), None)
|
||||
if points
|
||||
else None
|
||||
)
|
||||
if product is None:
|
||||
product = products[0]
|
||||
cmall = selector.Cmall(cookies, str(product["offer_id"]), platform)
|
||||
zones = cmall.zones()
|
||||
selected_zone = next((zone for zone in zones if str(zone["zone_id"]) == str(zone_id)), None)
|
||||
selected_zone = next(
|
||||
(zone for zone in zones if str(zone["zone_id"]) == str(zone_id)), None
|
||||
)
|
||||
if zone_id and selected_zone is None:
|
||||
raise ValueError("区服不存在")
|
||||
selected_zone = selected_zone or (zones[0] if zones else None)
|
||||
roles = cmall.roles(selected_zone["zone_id"]) if selected_zone else []
|
||||
return {"products": products, "zones": zones, "roles": roles,
|
||||
"default_product": product, "default_zone": selected_zone}
|
||||
return {
|
||||
"products": products,
|
||||
"zones": zones,
|
||||
"roles": roles,
|
||||
"default_product": product,
|
||||
"default_zone": selected_zone,
|
||||
}
|
||||
|
||||
|
||||
def _payment_stage(job_id: str, command: list[str], phase: str, running_message: str,
|
||||
failed_message: str) -> bool:
|
||||
def _payment_stage(
|
||||
job_id: str,
|
||||
command: list[str],
|
||||
phase: str,
|
||||
running_message: str,
|
||||
failed_message: str,
|
||||
) -> bool:
|
||||
"""Run one payment stage; return True on success without touching final state."""
|
||||
job = _jobs[job_id]
|
||||
with _lock:
|
||||
@@ -210,18 +261,32 @@ def _check_payment_once(job_id: str) -> int:
|
||||
"""Read-only completion check; returns 0=confirmed, 1=not yet, 2=check failed."""
|
||||
job = _jobs[job_id]
|
||||
directory = _job_dir(job_id)
|
||||
command = [sys.executable, "scripts/jsdom-pay.py", "--check-only",
|
||||
"--session", str(directory / "mall-session.json"),
|
||||
"--out-dir", str(directory / "jsdom-order")]
|
||||
command = [
|
||||
sys.executable,
|
||||
"scripts/jsdom-pay.py",
|
||||
"--check-only",
|
||||
"--session",
|
||||
str(directory / "mall-session.json"),
|
||||
"--out-dir",
|
||||
str(directory / "jsdom-order"),
|
||||
]
|
||||
environment = os.environ.copy()
|
||||
environment.pop("NODE_OPTIONS", None)
|
||||
check_started = time.monotonic()
|
||||
_safe_log(job, "[到账检测] 开始执行")
|
||||
try:
|
||||
result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True,
|
||||
env=environment, timeout=90)
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
timeout=90,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
_safe_log(job, f"[到账检测] 执行超时,耗时 {time.monotonic() - check_started:.1f} 秒")
|
||||
_safe_log(
|
||||
job, f"[到账检测] 执行超时,耗时 {time.monotonic() - check_started:.1f} 秒"
|
||||
)
|
||||
with _lock:
|
||||
job["payment_last_checked_at"] = int(time.time())
|
||||
return 2
|
||||
@@ -229,7 +294,9 @@ def _check_payment_once(job_id: str) -> int:
|
||||
_safe_log(job, line)
|
||||
for line in (result.stderr or "").splitlines():
|
||||
_safe_log(job, line)
|
||||
_safe_log(job, f"[到账检测] 执行结束,耗时 {time.monotonic() - check_started:.1f} 秒")
|
||||
_safe_log(
|
||||
job, f"[到账检测] 执行结束,耗时 {time.monotonic() - check_started:.1f} 秒"
|
||||
)
|
||||
with _lock:
|
||||
job["payment_last_checked_at"] = int(time.time())
|
||||
return result.returncode
|
||||
@@ -275,28 +342,62 @@ def _payment_flow(job_id: str, selection: dict) -> None:
|
||||
job["status"] = "ordering"
|
||||
job["phase"] = "payment"
|
||||
job["message"] = "正在创建商城订单"
|
||||
order_cmd = [sys.executable, "main.py", "mall", "auto", "--session", str(session),
|
||||
"--order-template", str(ROOT / "config" / "mall-order-template.json"),
|
||||
"--quantity", "1", "--product-id", str(selection["product_id"]),
|
||||
"--offer-id", str(selection["offer_id"]),
|
||||
"--role-id", str(selection["role_id"]), "--role-name", str(selection["role_name"]),
|
||||
"--zone-id", str(selection["zone_id"]), "--zone-name", str(selection["zone_name"]),
|
||||
"--output", str(response)]
|
||||
order_cmd = [
|
||||
sys.executable,
|
||||
"main.py",
|
||||
"mall",
|
||||
"auto",
|
||||
"--session",
|
||||
str(session),
|
||||
"--order-template",
|
||||
str(ROOT / "config" / "mall-order-template.json"),
|
||||
"--quantity",
|
||||
"1",
|
||||
"--product-id",
|
||||
str(selection["product_id"]),
|
||||
"--offer-id",
|
||||
str(selection["offer_id"]),
|
||||
"--role-id",
|
||||
str(selection["role_id"]),
|
||||
"--role-name",
|
||||
str(selection["role_name"]),
|
||||
"--zone-id",
|
||||
str(selection["zone_id"]),
|
||||
"--zone-name",
|
||||
str(selection["zone_name"]),
|
||||
"--output",
|
||||
str(response),
|
||||
]
|
||||
if selection.get("area"):
|
||||
order_cmd.extend(["--area", str(selection["area"])])
|
||||
if selection.get("partition"):
|
||||
order_cmd.extend(["--partition", str(selection["partition"])])
|
||||
if selection.get("order_pf"):
|
||||
order_cmd.extend(["--pf", str(selection["order_pf"])])
|
||||
if not _payment_stage(job_id, order_cmd, "order", "正在创建商城订单", "创建商城订单失败"):
|
||||
if not _payment_stage(
|
||||
job_id, order_cmd, "order", "正在创建商城订单", "创建商城订单失败"
|
||||
):
|
||||
return
|
||||
pay_cmd = [sys.executable, "scripts/jsdom-pay.py", "--session", str(session),
|
||||
"--mall-response", str(response), "--out-dir", str(output),
|
||||
"--zone-id", str(selection["zone_id"]),
|
||||
"--pf", str(selection.get("order_pf", "")),
|
||||
"--amount-fen", str(selection["price_fen"]),
|
||||
"--skip-payment-check"]
|
||||
if not _payment_stage(job_id, pay_cmd, "payment", "正在生成微信付款码", "生成付款码失败"):
|
||||
pay_cmd = [
|
||||
sys.executable,
|
||||
"scripts/jsdom-pay.py",
|
||||
"--session",
|
||||
str(session),
|
||||
"--mall-response",
|
||||
str(response),
|
||||
"--out-dir",
|
||||
str(output),
|
||||
"--zone-id",
|
||||
str(selection["zone_id"]),
|
||||
"--pf",
|
||||
str(selection.get("order_pf", "")),
|
||||
"--amount-fen",
|
||||
str(selection["price_fen"]),
|
||||
"--skip-payment-check",
|
||||
]
|
||||
if not _payment_stage(
|
||||
job_id, pay_cmd, "payment", "正在生成微信付款码", "生成付款码失败"
|
||||
):
|
||||
return
|
||||
meta_path = output / "payment-meta.json"
|
||||
try:
|
||||
@@ -318,7 +419,9 @@ def _start_payment(job_id: str, selection: dict) -> None:
|
||||
_jobs[job_id]["status"] = "ordering"
|
||||
_jobs[job_id]["phase"] = "payment"
|
||||
_jobs[job_id]["message"] = "正在创建商城订单"
|
||||
threading.Thread(target=_payment_flow, args=(job_id, selection), daemon=True).start()
|
||||
threading.Thread(
|
||||
target=_payment_flow, args=(job_id, selection), daemon=True
|
||||
).start()
|
||||
|
||||
|
||||
def _stop_job(job_id: str) -> None:
|
||||
@@ -344,13 +447,18 @@ def _stop_job(job_id: str) -> None:
|
||||
|
||||
def _public_job(job_id: str) -> dict:
|
||||
job = _jobs[job_id]
|
||||
result = {key: value for key, value in job.items()
|
||||
if key not in {"directory", "session_path", "process_pid"}}
|
||||
result = {
|
||||
key: value
|
||||
for key, value in job.items()
|
||||
if key not in {"directory", "session_path", "process_pid"}
|
||||
}
|
||||
qr_path = job.get("qr_path", "")
|
||||
if qr_path and Path(qr_path).exists():
|
||||
qr_bytes = Path(qr_path).read_bytes()
|
||||
result["qr_data"] = base64.b64encode(qr_bytes).decode("ascii")
|
||||
result["qr_mime_type"] = "image/jpeg" if qr_bytes.startswith(b"\xff\xd8\xff") else "image/png"
|
||||
result["qr_mime_type"] = (
|
||||
"image/jpeg" if qr_bytes.startswith(b"\xff\xd8\xff") else "image/png"
|
||||
)
|
||||
output = Path(job["directory"]) / "jsdom-order"
|
||||
for name in ("wechat-pay.png", "payment-status.json"):
|
||||
path = None
|
||||
@@ -359,18 +467,32 @@ def _public_job(job_id: str) -> dict:
|
||||
path = direct if direct.exists() else next(output.glob(f"*/{name}"), None)
|
||||
if path and name.endswith(".png"):
|
||||
payment_qr_bytes = path.read_bytes()
|
||||
result["payment_qr_data"] = base64.b64encode(payment_qr_bytes).decode("ascii")
|
||||
result["payment_qr_mime_type"] = "image/jpeg" if payment_qr_bytes.startswith(b"\xff\xd8\xff") else "image/png"
|
||||
result["payment_qr_data"] = base64.b64encode(payment_qr_bytes).decode(
|
||||
"ascii"
|
||||
)
|
||||
result["payment_qr_mime_type"] = (
|
||||
"image/jpeg"
|
||||
if payment_qr_bytes.startswith(b"\xff\xd8\xff")
|
||||
else "image/png"
|
||||
)
|
||||
elif path:
|
||||
try:
|
||||
status = json.loads(path.read_text(encoding="utf-8"))
|
||||
matched = status.get("matched_completion") if isinstance(status, dict) else None
|
||||
matched = (
|
||||
status.get("matched_completion")
|
||||
if isinstance(status, dict)
|
||||
else None
|
||||
)
|
||||
result["payment_status"] = {
|
||||
"checked_at": status.get("checked_at") if isinstance(status, dict) else None,
|
||||
"checked_at": status.get("checked_at")
|
||||
if isinstance(status, dict)
|
||||
else None,
|
||||
"matched_completion": {
|
||||
"is_finished": matched.get("is_finished"),
|
||||
"status": matched.get("status"),
|
||||
} if isinstance(matched, dict) else None,
|
||||
}
|
||||
if isinstance(matched, dict)
|
||||
else None,
|
||||
}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
@@ -411,8 +533,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
directory = DEFAULT_DATA / job_id
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(directory, 0o700)
|
||||
_jobs[job_id] = {"job_id": job_id, "status": "created", "phase": "login",
|
||||
"logs": [], "directory": str(directory), "created_at": int(time.time())}
|
||||
_jobs[job_id] = {
|
||||
"job_id": job_id,
|
||||
"status": "created",
|
||||
"phase": "login",
|
||||
"logs": [],
|
||||
"directory": str(directory),
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
return self._json(201, _public_job(job_id))
|
||||
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "login":
|
||||
job_id = path[2]
|
||||
@@ -421,44 +549,87 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self._json(400, {"detail": "无效任务或登录方式"})
|
||||
_start_login(job_id, body["provider"], int(body.get("timeout", 600)))
|
||||
return self._json(202, _public_job(job_id))
|
||||
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "selection-options":
|
||||
if (
|
||||
len(path) == 4
|
||||
and path[:2] == ["v1", "jobs"]
|
||||
and path[3] == "selection-options"
|
||||
):
|
||||
job_id = path[2]
|
||||
body = self._body()
|
||||
options = _selection_options(job_id, str(body.get("platform", "android")), body.get("points"), body.get("zone_id"))
|
||||
options = _selection_options(
|
||||
job_id,
|
||||
str(body.get("platform", "android")),
|
||||
body.get("points"),
|
||||
body.get("zone_id"),
|
||||
)
|
||||
_jobs[job_id]["selection_options"] = options
|
||||
return self._json(200, options)
|
||||
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "selection":
|
||||
job_id = path[2]
|
||||
body = self._body()
|
||||
required = ("platform", "points", "product_id", "role_id", "role_name", "zone_id")
|
||||
required = (
|
||||
"platform",
|
||||
"points",
|
||||
"product_id",
|
||||
"role_id",
|
||||
"role_name",
|
||||
"zone_id",
|
||||
)
|
||||
if job_id not in _jobs or any(not body.get(key) for key in required):
|
||||
return self._json(400, {"detail": "选择参数不完整"})
|
||||
selector = _load_selector()
|
||||
if body["platform"] not in selector.PLATFORMS:
|
||||
return self._json(400, {"detail": "不支持的平台"})
|
||||
session_path = _job_dir(job_id) / "mall-session.json"
|
||||
cookies = json.loads(session_path.read_text(encoding="utf-8")).get("cookies", {})
|
||||
product = next((item for item in selector.product_options(cookies, body["platform"])
|
||||
if str(item["product_id"]) == str(body["product_id"])
|
||||
and int(item["points"]) == int(body["points"])), None)
|
||||
cookies = json.loads(session_path.read_text(encoding="utf-8")).get(
|
||||
"cookies", {}
|
||||
)
|
||||
product = next(
|
||||
(
|
||||
item
|
||||
for item in selector.product_options(cookies, body["platform"])
|
||||
if str(item["product_id"]) == str(body["product_id"])
|
||||
and int(item["points"]) == int(body["points"])
|
||||
),
|
||||
None,
|
||||
)
|
||||
if product is None:
|
||||
return self._json(400, {"detail": "商品已失效,请重新选择"})
|
||||
cmall = selector.Cmall(cookies, str(product["offer_id"]), body["platform"])
|
||||
zone = next((item for item in cmall.zones()
|
||||
if str(item["zone_id"]) == str(body["zone_id"])), None)
|
||||
cmall = selector.Cmall(
|
||||
cookies, str(product["offer_id"]), body["platform"]
|
||||
)
|
||||
zone = next(
|
||||
(
|
||||
item
|
||||
for item in cmall.zones()
|
||||
if str(item["zone_id"]) == str(body["zone_id"])
|
||||
),
|
||||
None,
|
||||
)
|
||||
if zone is None:
|
||||
return self._json(400, {"detail": "区服已失效,请重新选择"})
|
||||
role = next((item for item in cmall.roles(zone["zone_id"])
|
||||
if str(item["role_id"]) == str(body["role_id"])), None)
|
||||
role = next(
|
||||
(
|
||||
item
|
||||
for item in cmall.roles(zone["zone_id"])
|
||||
if str(item["role_id"]) == str(body["role_id"])
|
||||
),
|
||||
None,
|
||||
)
|
||||
if role is None or role.get("ban_status") == "1":
|
||||
return self._json(400, {"detail": "角色不可充值,请重新选择"})
|
||||
_jobs[job_id]["selection"] = {
|
||||
"platform": body["platform"], "points": product["points"],
|
||||
"platform": body["platform"],
|
||||
"points": product["points"],
|
||||
"price_fen": product["price_fen"],
|
||||
"product_id": product["product_id"], "offer_id": product["offer_id"],
|
||||
"zone_id": zone["zone_id"], "zone_name": zone["name"],
|
||||
"role_id": role["role_id"], "role_name": role["name"],
|
||||
"area": role.get("area", ""), "partition": role.get("partition", ""),
|
||||
"product_id": product["product_id"],
|
||||
"offer_id": product["offer_id"],
|
||||
"zone_id": zone["zone_id"],
|
||||
"zone_name": zone["name"],
|
||||
"role_id": role["role_id"],
|
||||
"role_name": role["name"],
|
||||
"area": role.get("area", ""),
|
||||
"partition": role.get("partition", ""),
|
||||
"order_pf": selector.PLATFORMS[body["platform"]]["order_pf"],
|
||||
}
|
||||
_jobs[job_id]["phase"] = "payment"
|
||||
@@ -473,14 +644,23 @@ class Handler(BaseHTTPRequestHandler):
|
||||
with _lock:
|
||||
if job.get("status") == "stopped":
|
||||
return self._json(400, {"detail": "任务已停止,不能生成付款码"})
|
||||
if job.get("status") in {"ordering", "waiting_payment", "payment_timeout"} \
|
||||
or job.get("process_pid"):
|
||||
return self._json(400, {"detail": "已有进行中的支付流程,请勿重复操作"})
|
||||
if job.get("status") in {
|
||||
"ordering",
|
||||
"waiting_payment",
|
||||
"payment_timeout",
|
||||
} or job.get("process_pid"):
|
||||
return self._json(
|
||||
400, {"detail": "已有进行中的支付流程,请勿重复操作"}
|
||||
)
|
||||
if not job.get("selection"):
|
||||
return self._json(400, {"detail": "请先完成角色选择"})
|
||||
_start_payment(job_id, job["selection"])
|
||||
return self._json(202, _public_job(job_id))
|
||||
if len(path) == 5 and path[:2] == ["v1", "jobs"] and path[3:5] == ["payment", "check"]:
|
||||
if (
|
||||
len(path) == 5
|
||||
and path[:2] == ["v1", "jobs"]
|
||||
and path[3:5] == ["payment", "check"]
|
||||
):
|
||||
job_id = path[2]
|
||||
if job_id not in _jobs:
|
||||
return self._json(400, {"detail": "任务不存在"})
|
||||
@@ -541,7 +721,9 @@ def _restore_jobs(data_dir: Path) -> int:
|
||||
continue
|
||||
if not meta_path.exists():
|
||||
_jobs[job_id] = {
|
||||
"job_id": job_id, "status": "ready", "phase": "selection",
|
||||
"job_id": job_id,
|
||||
"status": "ready",
|
||||
"phase": "selection",
|
||||
"logs": ["服务重启,已从任务目录恢复登录会话"],
|
||||
"directory": str(directory),
|
||||
"created_at": int(time.time()),
|
||||
@@ -569,7 +751,9 @@ def _restore_jobs(data_dir: Path) -> int:
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
_jobs[job_id] = {
|
||||
"job_id": job_id, "status": status, "phase": phase,
|
||||
"job_id": job_id,
|
||||
"status": status,
|
||||
"phase": phase,
|
||||
"logs": ["服务重启,已从任务目录恢复本任务"],
|
||||
"directory": str(directory),
|
||||
"created_at": int(time.time()),
|
||||
@@ -587,9 +771,15 @@ def main() -> int:
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8810)
|
||||
parser.add_argument("--data-dir", type=Path, default=DEFAULT_DATA)
|
||||
parser.add_argument("--evidence-ttl-hours", type=int, default=DEFAULT_EVIDENCE_TTL_HOURS,
|
||||
help="任务原始证据保留时长;0 表示不自动清理")
|
||||
parser.add_argument("--key", default=None, help="HTTP Bearer 鉴权密钥;默认读取 YYB_WORKER_KEY")
|
||||
parser.add_argument(
|
||||
"--evidence-ttl-hours",
|
||||
type=int,
|
||||
default=DEFAULT_EVIDENCE_TTL_HOURS,
|
||||
help="任务原始证据保留时长;0 表示不自动清理",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--key", default=None, help="HTTP Bearer 鉴权密钥;默认读取 YYB_WORKER_KEY"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
# 强制绝对路径:子进程以 ROOT 为 cwd,相对路径会让任务文件写到错误位置。
|
||||
DEFAULT_DATA = Path(args.data_dir).resolve()
|
||||
@@ -603,7 +793,10 @@ def main() -> int:
|
||||
os.chmod(DEFAULT_DATA, 0o700)
|
||||
removed = _cleanup_expired_jobs(DEFAULT_DATA, args.evidence_ttl_hours)
|
||||
if removed:
|
||||
print(f"已清理 {removed} 个过期任务证据(保留期 {args.evidence_ttl_hours} 小时)", flush=True)
|
||||
print(
|
||||
f"已清理 {removed} 个过期任务证据(保留期 {args.evidence_ttl_hours} 小时)",
|
||||
flush=True,
|
||||
)
|
||||
restored = _restore_jobs(DEFAULT_DATA)
|
||||
if restored:
|
||||
print(f"已从任务目录恢复 {restored} 个支付任务", flush=True)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""YYB 付款表单的离线回归测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
@@ -23,14 +24,24 @@ from pyvm.protocol import goods_material_diagnostics, validate_goods_materials
|
||||
class TestJsdomPay(unittest.TestCase):
|
||||
def test_build_save_body_has_current_payment_context(self):
|
||||
body = _MODULE.build_save_body(
|
||||
{"token_id": "token", "transaction_id": "transaction", "out_trade_no": "trade"},
|
||||
{
|
||||
"token_id": "token",
|
||||
"transaction_id": "transaction",
|
||||
"out_trade_no": "trade",
|
||||
},
|
||||
{"openid": "openid", "accesstoken": "access-token", "logintype": "WX"},
|
||||
"web-token", "anti-token", "cipher", "2",
|
||||
"mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap", 600,
|
||||
"web-token",
|
||||
"anti-token",
|
||||
"cipher",
|
||||
"2",
|
||||
"mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap",
|
||||
600,
|
||||
)
|
||||
fields = parse_qs(body, keep_blank_values=True)
|
||||
self.assertEqual(fields["type"], ["bg"])
|
||||
self.assertEqual(fields["pf"], ["mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap"])
|
||||
self.assertEqual(
|
||||
fields["pf"], ["mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap"]
|
||||
)
|
||||
self.assertEqual(fields["zoneid"], ["2"])
|
||||
self.assertEqual(fields["pay_method"], ["wechat"])
|
||||
self.assertEqual(fields["wcp"], ["type=CNY&amt=600"])
|
||||
@@ -39,9 +50,23 @@ class TestJsdomPay(unittest.TestCase):
|
||||
|
||||
def test_qq_payment_uses_qq_oauth_session_fields(self):
|
||||
body = _MODULE.build_save_body(
|
||||
{"token_id": "token", "transaction_id": "transaction", "out_trade_no": "trade"},
|
||||
{"openid": "openid", "accesstoken": "access-token", "logintype": "QC", "appid": "102033112"},
|
||||
"web-token", "anti-token", "cipher", "1", "pf", 100,
|
||||
{
|
||||
"token_id": "token",
|
||||
"transaction_id": "transaction",
|
||||
"out_trade_no": "trade",
|
||||
},
|
||||
{
|
||||
"openid": "openid",
|
||||
"accesstoken": "access-token",
|
||||
"logintype": "QC",
|
||||
"appid": "102033112",
|
||||
},
|
||||
"web-token",
|
||||
"anti-token",
|
||||
"cipher",
|
||||
"1",
|
||||
"pf",
|
||||
100,
|
||||
)
|
||||
fields = parse_qs(body, keep_blank_values=True)
|
||||
self.assertEqual(fields["session_id"], ["openid"])
|
||||
@@ -52,18 +77,38 @@ class TestJsdomPay(unittest.TestCase):
|
||||
|
||||
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"},
|
||||
{
|
||||
"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,
|
||||
"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",
|
||||
{
|
||||
"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"])
|
||||
@@ -71,13 +116,17 @@ class TestJsdomPay(unittest.TestCase):
|
||||
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")
|
||||
message = describe_payment_failure(
|
||||
"QC", "payment", "ret=1099 err_code=1099-1007-0"
|
||||
)
|
||||
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")
|
||||
message = describe_payment_failure(
|
||||
"WX", "payment", "ret=1099 err_code=1099-1007-0"
|
||||
)
|
||||
self.assertIn("服务端拒绝", message)
|
||||
self.assertNotIn("风控", message)
|
||||
|
||||
@@ -88,17 +137,30 @@ class TestJsdomPay(unittest.TestCase):
|
||||
|
||||
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",
|
||||
"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", True)
|
||||
plaintext = _MODULE.build_plaintext(params, "tdrc_session%3Dpay-test", "1775990000", rand_value)
|
||||
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)
|
||||
rand_value = _MODULE.make_encrypt_rand(
|
||||
{}, "tdrc_session%3Dpay-test", "1775990000", False
|
||||
)
|
||||
self.assertEqual(len(rand_value), 9)
|
||||
self.assertEqual(rand_value[-1], "\x01")
|
||||
|
||||
@@ -124,12 +186,26 @@ class TestJsdomPay(unittest.TestCase):
|
||||
import json
|
||||
|
||||
template = _MODULE.load_template_args()
|
||||
xmidas = json.loads((_MODULE.ROOT / "replay" / "xmidasops.json").read_text(encoding="utf-8"))
|
||||
xmidas = json.loads(
|
||||
(_MODULE.ROOT / "replay" / "xmidasops.json").read_text(encoding="utf-8")
|
||||
)
|
||||
params = {
|
||||
"token_id": "token", "openid": "openid", "openkey": "key", "session_id": "openid",
|
||||
"session_type": "kp_accesstoken", "zoneid": "1", "pay_method": "wechat",
|
||||
"buy_quantity": "1", "mb_pwd": "", "pay_id": "", "auth_key": "", "card_value": "",
|
||||
"accounttype": "", "provide_uin": "", "extend": "", "from_h5": "1",
|
||||
"token_id": "token",
|
||||
"openid": "openid",
|
||||
"openkey": "key",
|
||||
"session_id": "openid",
|
||||
"session_type": "kp_accesstoken",
|
||||
"zoneid": "1",
|
||||
"pay_method": "wechat",
|
||||
"buy_quantity": "1",
|
||||
"mb_pwd": "",
|
||||
"pay_id": "",
|
||||
"auth_key": "",
|
||||
"card_value": "",
|
||||
"accounttype": "",
|
||||
"provide_uin": "",
|
||||
"extend": "",
|
||||
"from_h5": "1",
|
||||
"webversion": "minipayv2",
|
||||
}
|
||||
timestamp = "1775990000"
|
||||
@@ -137,16 +213,27 @@ class TestJsdomPay(unittest.TestCase):
|
||||
for length in range(512):
|
||||
params["extend"] = "x" * length
|
||||
rand_value = _MODULE.make_encrypt_rand(params, fk_extend, timestamp, True)
|
||||
plaintext = _MODULE.build_plaintext(params, fk_extend, timestamp, rand_value)
|
||||
plaintext = _MODULE.build_plaintext(
|
||||
params, fk_extend, timestamp, rand_value
|
||||
)
|
||||
if len(plaintext.encode("latin-1")) == 528:
|
||||
break
|
||||
else:
|
||||
self.fail("无法构造 528B goods 测试明文")
|
||||
key16 = list(range(16))
|
||||
key1 = derive_key1_from_key16(key16, [template[i][0] for i in (1, 2, 3, 4)], template[5][0])
|
||||
key1 = derive_key1_from_key16(
|
||||
key16, [template[i][0] for i in (1, 2, 3, 4)], template[5][0]
|
||||
)
|
||||
ciphertext = generate_encrypt_msg_offline(
|
||||
params, fk_extend, timestamp, rand_value, key16=key16, key1=key1,
|
||||
args_template=template, xmidas=xmidas, xmidas_token="A" * 96,
|
||||
params,
|
||||
fk_extend,
|
||||
timestamp,
|
||||
rand_value,
|
||||
key16=key16,
|
||||
key1=key1,
|
||||
args_template=template,
|
||||
xmidas=xmidas,
|
||||
xmidas_token="A" * 96,
|
||||
)
|
||||
self.assertEqual(len(ciphertext), 1056)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user