| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176 |
- # -*- 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 = <len=%d> [%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=%d>" % 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))
|