# -*- coding: utf-8 -*- """乐收赢测试环境 **主扫下单** 探针:向上游发一次真实的 /v3/qrpay,拿 code_url。 为什么要打这一发(前面已经验过签名了): - 之前那条只读 /v3/queryorder 证明了「签名规则 + 信封」对; - 但**下单**才走完整的业务校验(mch_no 是否有效、shop_no 要不要、pay_trace_no 唯一性、 total_amount 形态、time_expire 取值)——这些都是「签名对」证明不了的。 - 2026-09-28 用户已与上游确认:文档里的示例参数可用于测试。 ★ 与生产代码的关系(重要,别把本探针当生产量具): 报文**逐字段抄自** `LsyOrderService.createQrOrder`(主扫那条腿)在 2026-09-28 的读法: mch_no / [shop_no] / pay_trace_no / pay_time / total_amount / time_expire / [order_body] / [attach] / [notify_url] + inst_no + sign(由 client 自动补) 本脚本会把**实际发出去的 body** 原样打出来,好让人把它与上面那份字段清单逐格对 —— 转写件必须能被核对,否则它只是一段漂亮的猜测。 但本探针仍是**另一支量具**:它证明的是「这个字段集被上游接受」,不是「Java 客户端发出的字节被接受」。 ★ 不传 notify_url:上游文档说「不传取机构默认配置回调」。传了就等于让上游的测试环境 去回调我们的地址,那是一个**有副作用**的动作,本次不需要。 ★ 凭据(GC3/GC5):机构密钥只从 application.yml 就地读,只打印长度与来源,绝不落文件、不上命令行。 ★ 出站 body 里的 sign 打码(它是这一笔的鉴权件)——字段形状仍然完整可见。 """ import json import os import re import sys import time import urllib.request import urllib.error from hashlib import md5 YML = "java/src/main/resources/application.yml" VAR = re.compile(r"^\$\{([A-Za-z0-9_]+):(.*)\}$") OUT = "logs/_lsy-qrpay-probe.txt" RESP_BIN = "logs/_lsy-qrpay-resp.bin" buf = [] def say(s=""): buf.append(s) print(s.encode("ascii", "replace").decode()) def read_block(path, top): lines = open(path, encoding="utf-8", errors="replace").read().split("\n") start = None for i, ln in enumerate(lines): if re.match(r"^%s:\s*$" % re.escape(top), ln): start = i break if start is None: raise SystemExit("找不到顶层段 %s:" % top) out = {} for j in range(start + 1, len(lines)): ln = lines[j] if ln.strip() == "" or ln.lstrip().startswith("#"): continue if not ln.startswith((" ", "\t")): break m = re.match(r"^\s+([A-Za-z0-9_.\-]+):\s*(.*)$", ln) if m: v = re.split(r"\s+#", m.group(2), maxsplit=1)[0].strip().strip("'\"") out[m.group(1)] = v return out def resolve(raw): m = VAR.match(raw) if not m: return raw, "application.yml 字面量" var, default = m.group(1), m.group(2) if os.environ.get(var): return os.environ[var], "环境变量 %s" % var return default, "application.yml 默认值(键 %s)" % var cfg = read_block(YML, "leshouying") base_url, base_src = resolve(cfg["base-url"]) inst_no, inst_src = resolve(cfg["inst-no"]) key, key_src = resolve(cfg["key"]) expire_min = resolve(cfg.get("default-qr-expire-minutes", "15"))[0] say("── 取值来源(不含密钥值)──") say(" base-url = %s [%s]" % (base_url, base_src)) say(" inst-no = %s [%s]" % (inst_no, inst_src)) say(" key = [%s]" % (len(key), key_src)) say(" default-qr-expire-minutes= %s [取自 application.yml]" % expire_min) say() # ── 组报文:字段集逐格抄自 LsyOrderService.createQrOrder ───────────────── now = time.strftime("%Y%m%d%H%M%S") trace = "probe" + now + "qr" payload = { "inst_no": inst_no, # client 的 signedPayload 自动补 "mch_no": "Y52211090004", # 文档测试商户号 "pay_trace_no": trace, # 上游要求商户系统内唯一;本探针每跑一次都不同 "pay_time": now, # yyyyMMddHHmmss "total_amount": "1", # 1 分 "time_expire": str(expire_min), "order_body": "联调探针-主扫", # shop_no / attach / notify_url 生产侧都是「非 null 才发」,此处留空不传 } A = "&".join("%s=%s" % (k, payload[k]) for k in sorted(payload)) sign = md5((A + "&key=" + key).encode("utf-8")).hexdigest().lower() payload["sign"] = sign say("── 将发送(不含密钥;sign 打码)──") say(" A = %s" % A) say() body = json.dumps(payload, ensure_ascii=False).encode("utf-8") shown = json.loads(body.decode("utf-8")) shown["sign"] = "" % len(sign) say(" body = %s" % json.dumps(shown, ensure_ascii=False, indent=2)) say() say(" 字段清单(= 生产 createQrOrder 发的集合):%s" % ", ".join(sorted(payload))) say() url = base_url.rstrip("/") + "/v3/qrpay" say("── 出站 ──") say(" POST %s" % url) say(" Content-Type: application/json;charset=utf-8") say(" bytes = %d" % len(body)) say() req = urllib.request.Request( url, data=body, headers={"Content-Type": "application/json;charset=utf-8"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=30) as r: code, raw = r.status, r.read() except urllib.error.HTTPError as e: code, raw = e.code, e.read() except Exception as e: say("!! 请求未完成: %r" % (e,)) open(OUT, "w", encoding="utf-8").write("\n".join(buf)) sys.exit(1) open(RESP_BIN, "wb").write(raw) say("── 上游应答 ──") say(" http_code = %s" % code) say(" bytes = %d" % len(raw)) say(" 原始字节已落 %s" % RESP_BIN) say() try: resp = json.loads(raw.decode("utf-8")) say(" 解码 = utf-8 成功") except Exception as e: say(" utf-8 解码失败: %r" % (e,)) open(OUT, "w", encoding="utf-8").write("\n".join(buf)) sys.exit(1) say(" return_code = %s" % resp.get("return_code")) say(" return_msg = %s" % resp.get("return_msg")) say(" result_code = %s" % resp.get("result_code")) say(" code_url = %s" % resp.get("code_url")) say() # ── 入站验签:用上一条已经实证过的「null 不参与」读法 ───────────────── rkey = resp.get("sign") if rkey: nonnull = {k: v for k, v in resp.items() if k != "sign" and v is not None} A2 = "&".join("%s=%s" % (k, nonnull[k]) for k in sorted(nonnull)) mine = md5((A2 + "&key=" + key).encode("utf-8")).hexdigest().lower() say("── 入站验签(null 不参与)──") say(" 参与签名的字段数 = %d / 应答字段数 %d" % (len(nonnull), len(resp) - 1)) say(" A = %s" % A2) say(" 重算 == 上游 sign : %s" % (mine == rkey)) say() open(OUT, "w", encoding="utf-8").write("\n".join(buf))