_lsy-query-by-tradeno-probe.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. # -*- coding: utf-8 -*-
  2. """按 trade_no 查一次 /v3/queryorder —— 判 H5 对账走哪条分支。
  3. 背景(实测,非推断):
  4. `LsyQueryService.queryOrder` 的逻辑是「order 上有 trade_no 就用 trade_no,
  5. 否则用 pay_trace_no + pay_time」。
  6. 主扫(/v3/qrpay)的应答**没有 trade_no** ⇒ 主扫单走第二条分支(已实证)。
  7. 但 H5(/v3/h5pay)的 PAY_SUCCESS 应答**带 trade_no**(logs/_lsy-h5pay-resp.bin 里实测有)
  8. ⇒ H5 单落库时 trade_no 非空 ⇒ 对账时走的是**第一条分支**。
  9. 而代码注释里写的是「qrpay 与 h5pay 那两个路径传的 patch 本来就没有 trade_no」——
  10. 与实测不符(h5 有)。本发就是要判:**按 trade_no 到底查不查得到这笔单**。
  11. ★ 只读请求。trade_no 从上一发的原始应答字节里读,不手抄。
  12. ★ 凭据同前:密钥只从 application.yml 读、只打长度。
  13. """
  14. import json
  15. import os
  16. import re
  17. import sys
  18. import uuid
  19. import urllib.request
  20. import urllib.error
  21. from hashlib import md5
  22. YML = "java/src/main/resources/application.yml"
  23. VAR = re.compile(r"^\$\{([A-Za-z0-9_]+):(.*)\}$")
  24. SRC_BIN = "logs/_lsy-h5pay-resp.bin"
  25. OUT = "logs/_lsy-query-by-tradeno-probe.txt"
  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 = resolve(cfg["base-url"])[0]
  61. inst_no = resolve(cfg["inst-no"])[0]
  62. key = resolve(cfg["key"])[0]
  63. if not os.path.exists(SRC_BIN):
  64. raise SystemExit("缺少 %s —— 先跑 _lsy-h5pay-probe.py" % SRC_BIN)
  65. prev = json.loads(open(SRC_BIN, "rb").read().decode("utf-8"))
  66. trade_no = prev.get("trade_no")
  67. if not trade_no:
  68. raise SystemExit("H5 那一发的应答里没有 trade_no ⇒ 本探针要判的前提不成立:%s" % sorted(prev))
  69. say("── 被查的那笔单(读自上一发的原始应答,非手抄)──")
  70. say(" trade_no = %s" % trade_no)
  71. say(" pay_trace_no = %s" % prev.get("pay_trace_no"))
  72. say(" result_code = %s (下单受理,尚未被扫/未支付)" % prev.get("result_code"))
  73. say()
  74. payload = {
  75. "inst_no": inst_no,
  76. "mch_no": prev.get("mch_no"),
  77. "query_trace_no": uuid.uuid4().hex, # 上游要求每次查询唯一
  78. # ↓ 走 LsyQueryService 的**有 trade_no 分支**
  79. "trade_no": trade_no,
  80. }
  81. A = "&".join("%s=%s" % (k, payload[k]) for k in sorted(payload))
  82. sign = md5((A + "&key=" + key).encode("utf-8")).hexdigest().lower()
  83. payload["sign"] = sign
  84. say("── 将发送(不含密钥;sign 打码)──")
  85. shown = dict(payload)
  86. shown["sign"] = "<len=%d>" % len(sign)
  87. say(json.dumps(shown, ensure_ascii=False, indent=2))
  88. say()
  89. url = base_url.rstrip("/") + "/v3/queryorder"
  90. body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
  91. say("── 出站 ──")
  92. say(" POST %s" % url)
  93. say(" bytes = %d" % len(body))
  94. say()
  95. req = urllib.request.Request(
  96. url, data=body,
  97. headers={"Content-Type": "application/json;charset=utf-8"},
  98. method="POST",
  99. )
  100. try:
  101. with urllib.request.urlopen(req, timeout=30) as r:
  102. code, raw = r.status, r.read()
  103. except urllib.error.HTTPError as e:
  104. code, raw = e.code, e.read()
  105. except Exception as e:
  106. say("!! 请求未完成: %r" % (e,))
  107. open(OUT, "w", encoding="utf-8").write("\n".join(buf))
  108. sys.exit(1)
  109. open("logs/_lsy-query-by-tradeno-resp.bin", "wb").write(raw)
  110. resp = json.loads(raw.decode("utf-8"))
  111. say("── 上游应答 ──")
  112. say(" http_code = %s, bytes = %d" % (code, len(raw)))
  113. for k in sorted(resp):
  114. v = resp[k]
  115. say(" %-16s = %s" % (k, "null" if v is None else repr(v)))
  116. say()
  117. usign = resp.get("sign")
  118. if usign:
  119. nonnull = {k: v for k, v in resp.items() if k != "sign" and v is not None}
  120. A2 = "&".join("%s=%s" % (k, nonnull[k]) for k in sorted(nonnull))
  121. mine = md5((A2 + "&key=" + key).encode("utf-8")).hexdigest().lower()
  122. say("── 入站验签(null 不参与)──")
  123. say(" 参与签名字段数 = %d" % len(nonnull))
  124. say(" 重算 == 上游 sign : %s" % (mine == usign))
  125. else:
  126. say("── 应答里没有 sign 字段 ──")
  127. say()
  128. open(OUT, "w", encoding="utf-8").write("\n".join(buf))