_lsy-qrpay-probe.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. # -*- coding: utf-8 -*-
  2. """乐收赢测试环境 **主扫下单** 探针:向上游发一次真实的 /v3/qrpay,拿 code_url。
  3. 为什么要打这一发(前面已经验过签名了):
  4. - 之前那条只读 /v3/queryorder 证明了「签名规则 + 信封」对;
  5. - 但**下单**才走完整的业务校验(mch_no 是否有效、shop_no 要不要、pay_trace_no 唯一性、
  6. total_amount 形态、time_expire 取值)——这些都是「签名对」证明不了的。
  7. - 2026-09-28 用户已与上游确认:文档里的示例参数可用于测试。
  8. ★ 与生产代码的关系(重要,别把本探针当生产量具):
  9. 报文**逐字段抄自** `LsyOrderService.createQrOrder`(主扫那条腿)在 2026-09-28 的读法:
  10. mch_no / [shop_no] / pay_trace_no / pay_time / total_amount / time_expire
  11. / [order_body] / [attach] / [notify_url] + inst_no + sign(由 client 自动补)
  12. 本脚本会把**实际发出去的 body** 原样打出来,好让人把它与上面那份字段清单逐格对
  13. —— 转写件必须能被核对,否则它只是一段漂亮的猜测。
  14. 但本探针仍是**另一支量具**:它证明的是「这个字段集被上游接受」,不是「Java 客户端发出的字节被接受」。
  15. ★ 不传 notify_url:上游文档说「不传取机构默认配置回调」。传了就等于让上游的测试环境
  16. 去回调我们的地址,那是一个**有副作用**的动作,本次不需要。
  17. ★ 凭据(GC3/GC5):机构密钥只从 application.yml 就地读,只打印长度与来源,绝不落文件、不上命令行。
  18. ★ 出站 body 里的 sign 打码(它是这一笔的鉴权件)——字段形状仍然完整可见。
  19. """
  20. import json
  21. import os
  22. import re
  23. import sys
  24. import time
  25. import urllib.request
  26. import urllib.error
  27. from hashlib import md5
  28. YML = "java/src/main/resources/application.yml"
  29. VAR = re.compile(r"^\$\{([A-Za-z0-9_]+):(.*)\}$")
  30. OUT = "logs/_lsy-qrpay-probe.txt"
  31. RESP_BIN = "logs/_lsy-qrpay-resp.bin"
  32. buf = []
  33. def say(s=""):
  34. buf.append(s)
  35. print(s.encode("ascii", "replace").decode())
  36. def read_block(path, top):
  37. lines = open(path, encoding="utf-8", errors="replace").read().split("\n")
  38. start = None
  39. for i, ln in enumerate(lines):
  40. if re.match(r"^%s:\s*$" % re.escape(top), ln):
  41. start = i
  42. break
  43. if start is None:
  44. raise SystemExit("找不到顶层段 %s:" % top)
  45. out = {}
  46. for j in range(start + 1, len(lines)):
  47. ln = lines[j]
  48. if ln.strip() == "" or ln.lstrip().startswith("#"):
  49. continue
  50. if not ln.startswith((" ", "\t")):
  51. break
  52. m = re.match(r"^\s+([A-Za-z0-9_.\-]+):\s*(.*)$", ln)
  53. if m:
  54. v = re.split(r"\s+#", m.group(2), maxsplit=1)[0].strip().strip("'\"")
  55. out[m.group(1)] = v
  56. return out
  57. def resolve(raw):
  58. m = VAR.match(raw)
  59. if not m:
  60. return raw, "application.yml 字面量"
  61. var, default = m.group(1), m.group(2)
  62. if os.environ.get(var):
  63. return os.environ[var], "环境变量 %s" % var
  64. return default, "application.yml 默认值(键 %s)" % var
  65. cfg = read_block(YML, "leshouying")
  66. base_url, base_src = resolve(cfg["base-url"])
  67. inst_no, inst_src = resolve(cfg["inst-no"])
  68. key, key_src = resolve(cfg["key"])
  69. expire_min = resolve(cfg.get("default-qr-expire-minutes", "15"))[0]
  70. say("── 取值来源(不含密钥值)──")
  71. say(" base-url = %s [%s]" % (base_url, base_src))
  72. say(" inst-no = %s [%s]" % (inst_no, inst_src))
  73. say(" key = <len=%d> [%s]" % (len(key), key_src))
  74. say(" default-qr-expire-minutes= %s [取自 application.yml]" % expire_min)
  75. say()
  76. # ── 组报文:字段集逐格抄自 LsyOrderService.createQrOrder ─────────────────
  77. now = time.strftime("%Y%m%d%H%M%S")
  78. trace = "probe" + now + "qr"
  79. payload = {
  80. "inst_no": inst_no, # client 的 signedPayload 自动补
  81. "mch_no": "Y52211090004", # 文档测试商户号
  82. "pay_trace_no": trace, # 上游要求商户系统内唯一;本探针每跑一次都不同
  83. "pay_time": now, # yyyyMMddHHmmss
  84. "total_amount": "1", # 1 分
  85. "time_expire": str(expire_min),
  86. "order_body": "联调探针-主扫",
  87. # shop_no / attach / notify_url 生产侧都是「非 null 才发」,此处留空不传
  88. }
  89. A = "&".join("%s=%s" % (k, payload[k]) for k in sorted(payload))
  90. sign = md5((A + "&key=" + key).encode("utf-8")).hexdigest().lower()
  91. payload["sign"] = sign
  92. say("── 将发送(不含密钥;sign 打码)──")
  93. say(" A = %s" % A)
  94. say()
  95. body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
  96. shown = json.loads(body.decode("utf-8"))
  97. shown["sign"] = "<len=%d>" % len(sign)
  98. say(" body = %s" % json.dumps(shown, ensure_ascii=False, indent=2))
  99. say()
  100. say(" 字段清单(= 生产 createQrOrder 发的集合):%s" % ", ".join(sorted(payload)))
  101. say()
  102. url = base_url.rstrip("/") + "/v3/qrpay"
  103. say("── 出站 ──")
  104. say(" POST %s" % url)
  105. say(" Content-Type: application/json;charset=utf-8")
  106. say(" bytes = %d" % len(body))
  107. say()
  108. req = urllib.request.Request(
  109. url, data=body,
  110. headers={"Content-Type": "application/json;charset=utf-8"},
  111. method="POST",
  112. )
  113. try:
  114. with urllib.request.urlopen(req, timeout=30) as r:
  115. code, raw = r.status, r.read()
  116. except urllib.error.HTTPError as e:
  117. code, raw = e.code, e.read()
  118. except Exception as e:
  119. say("!! 请求未完成: %r" % (e,))
  120. open(OUT, "w", encoding="utf-8").write("\n".join(buf))
  121. sys.exit(1)
  122. open(RESP_BIN, "wb").write(raw)
  123. say("── 上游应答 ──")
  124. say(" http_code = %s" % code)
  125. say(" bytes = %d" % len(raw))
  126. say(" 原始字节已落 %s" % RESP_BIN)
  127. say()
  128. try:
  129. resp = json.loads(raw.decode("utf-8"))
  130. say(" 解码 = utf-8 成功")
  131. except Exception as e:
  132. say(" utf-8 解码失败: %r" % (e,))
  133. open(OUT, "w", encoding="utf-8").write("\n".join(buf))
  134. sys.exit(1)
  135. say(" return_code = %s" % resp.get("return_code"))
  136. say(" return_msg = %s" % resp.get("return_msg"))
  137. say(" result_code = %s" % resp.get("result_code"))
  138. say(" code_url = %s" % resp.get("code_url"))
  139. say()
  140. # ── 入站验签:用上一条已经实证过的「null 不参与」读法 ─────────────────
  141. rkey = resp.get("sign")
  142. if rkey:
  143. nonnull = {k: v for k, v in resp.items() if k != "sign" and v is not None}
  144. A2 = "&".join("%s=%s" % (k, nonnull[k]) for k in sorted(nonnull))
  145. mine = md5((A2 + "&key=" + key).encode("utf-8")).hexdigest().lower()
  146. say("── 入站验签(null 不参与)──")
  147. say(" 参与签名的字段数 = %d / 应答字段数 %d" % (len(nonnull), len(resp) - 1))
  148. say(" A = %s" % A2)
  149. say(" 重算 == 上游 sign : %s" % (mine == rkey))
  150. say()
  151. open(OUT, "w", encoding="utf-8").write("\n".join(buf))