_lsy-query-probe.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. # -*- coding: utf-8 -*-
  2. """用刚下的那笔主扫单,走一遍**对账那条腿**的查询。
  3. 为什么单列一发:
  4. `LsyQueryService.queryOrder` 有两条腿 —— 有 trade_no 用 trade_no,没有则用
  5. pay_trace_no + pay_time。而 /v3/qrpay 的应答**根本不含 trade_no**(文档的返回表没有这个字段,
  6. 实测也没有),所以「主扫单被对账捞起时」走的一定是 **pay_trace_no + pay_time 那条分支**。
  7. 这条分支此前从没被真上游读过一次。
  8. pay_trace_no / pay_time **直接从上一发的原始应答字节里读**(logs/_lsy-qrpay-resp.bin),
  9. 不手抄 —— 手抄的常数是失真源。
  10. 预期:一笔记账中(没人扫过码)的聚合码单 ⇒ result_code 应为 PAY_IN_PROCESS,
  11. 而不是「订单不存在」。若回「订单不存在」⇒ 下单与查询两条腿对同一笔单的键不一致。
  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-qrpay-resp.bin"
  25. OUT = "logs/_lsy-query-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-qrpay-probe.py" % SRC_BIN)
  65. prev = json.loads(open(SRC_BIN, "rb").read().decode("utf-8"))
  66. trace = prev.get("pay_trace_no")
  67. ptime = prev.get("pay_time")
  68. if not trace or not ptime:
  69. raise SystemExit("上一发应答里没有 pay_trace_no/pay_time,无法查:%s" % sorted(prev))
  70. say("── 被查的那笔单(读自上一发的原始应答,非手抄)──")
  71. say(" pay_trace_no = %s" % trace)
  72. say(" pay_time = %s" % ptime)
  73. say(" 上一发是否给了 trade_no : %s (qrpay 返回表里本就没有这个字段)" % ("trade_no" in prev))
  74. say()
  75. payload = {
  76. "inst_no": inst_no,
  77. "mch_no": prev.get("mch_no") or "Y52211090004",
  78. "query_trace_no": uuid.uuid4().hex, # 上游要求每次查询唯一
  79. # ↓ 走 LsyQueryService 的**无 trade_no 分支**
  80. "pay_trace_no": trace,
  81. "pay_time": ptime,
  82. }
  83. A = "&".join("%s=%s" % (k, payload[k]) for k in sorted(payload))
  84. sign = md5((A + "&key=" + key).encode("utf-8")).hexdigest().lower()
  85. payload["sign"] = sign
  86. say("── 将发送(不含密钥;sign 打码)──")
  87. shown = dict(payload)
  88. shown["sign"] = "<len=%d>" % len(sign)
  89. say(json.dumps(shown, ensure_ascii=False, indent=2))
  90. say()
  91. url = base_url.rstrip("/") + "/v3/queryorder"
  92. body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
  93. say("── 出站 ──")
  94. say(" POST %s" % url)
  95. say(" bytes = %d" % len(body))
  96. say()
  97. req = urllib.request.Request(
  98. url, data=body,
  99. headers={"Content-Type": "application/json;charset=utf-8"},
  100. method="POST",
  101. )
  102. try:
  103. with urllib.request.urlopen(req, timeout=30) as r:
  104. code, raw = r.status, r.read()
  105. except urllib.error.HTTPError as e:
  106. code, raw = e.code, e.read()
  107. except Exception as e:
  108. say("!! 请求未完成: %r" % (e,))
  109. open(OUT, "w", encoding="utf-8").write("\n".join(buf))
  110. sys.exit(1)
  111. open("logs/_lsy-query-resp.bin", "wb").write(raw)
  112. resp = json.loads(raw.decode("utf-8"))
  113. say("── 上游应答 ──")
  114. say(" http_code = %s, bytes = %d" % (code, len(raw)))
  115. say(json.dumps(resp, ensure_ascii=False, indent=2))
  116. say()
  117. # 入站验签:null 不参与(已两次实证的读法)
  118. usign = resp.get("sign")
  119. if usign:
  120. nonnull = {k: v for k, v in resp.items() if k != "sign" and v is not None}
  121. A2 = "&".join("%s=%s" % (k, nonnull[k]) for k in sorted(nonnull))
  122. mine = md5((A2 + "&key=" + key).encode("utf-8")).hexdigest().lower()
  123. say("── 入站验签(null 不参与)──")
  124. say(" 参与签名字段数 = %d" % len(nonnull))
  125. say(" 重算 == 上游 sign : %s" % (mine == usign))
  126. say()
  127. open(OUT, "w", encoding="utf-8").write("\n".join(buf))