_lsy-h5pay-probe.py 5.8 KB

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