_lsy-close-control.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. # -*- coding: utf-8 -*-
  2. """差分对照:/v3/closeorder 到底有没有真的改变 H5 单的状态?
  3. 背景:LsyOrderService.java:334 的注释断言
  4. 「上游『H5支付』目录下无独立关单接口,本类不为 H5 提供关单路径(见 spec §2.3)」
  5. 但第三轮实测:对一笔 H5 单发 /v3/closeorder(trade_no 寻址)→ 回「关单成功」,
  6. 随后 queryorder 由 PAY_IN_PROCESS「用户支付中」变成 PAY_FAIL「支付失败」。
  7. 「关单成功」四个字**不足以**证明状态被改了 —— 也可能它只是应了一声。
  8. 所以做差分:**同一时刻建两笔同参 H5 单,只关其中一笔**,再各查一次。
  9. A 被关 → 若 A=PAY_FAIL 且 B=PAY_IN_PROCESS ⇒ 差分成立,closeorder 真的改了状态。
  10. A 被关 → 若 A 与 B 同为 PAY_IN_PROCESS ⇒ 那个「关单成功」是空话(或尚未生效)。
  11. 两笔都 FAIL ⇒ 与关单无关(例如该商户的 H5 单本来就迅速过期),此对照组作废。
  12. 聚合码侧同法再做一组(/v3/qrclose 的对照),因为那是我方**已在用**的关单腿。
  13. """
  14. import io
  15. import json
  16. import os
  17. import re
  18. import time
  19. import uuid
  20. import urllib.error
  21. import urllib.request
  22. from hashlib import md5
  23. YML = "java/src/main/resources/application.yml"
  24. VAR = re.compile(r"^\$\{([A-Za-z0-9_]+):(.*)\}$")
  25. OUTDIR = "logs/_lsy-ctl"
  26. OUT = "logs/_lsy-close-control.txt"
  27. buf = []
  28. def say(s=""):
  29. buf.append(s)
  30. print(s.encode("ascii", "replace").decode())
  31. def read_block(path, top):
  32. lines = io.open(path, encoding="utf-8", errors="replace").read().split("\n")
  33. start = None
  34. for i, ln in enumerate(lines):
  35. if re.match(r"^%s:\s*$" % re.escape(top), ln):
  36. start = i
  37. break
  38. if start is None:
  39. raise SystemExit("找不到顶层段 %s:" % top)
  40. out = {}
  41. for j in range(start + 1, len(lines)):
  42. ln = lines[j]
  43. if ln.strip() == "" or ln.lstrip().startswith("#"):
  44. continue
  45. if not ln.startswith((" ", "\t")):
  46. break
  47. m = re.match(r"^\s+([A-Za-z0-9_.\-]+):\s*(.*)$", ln)
  48. if m:
  49. out[m.group(1)] = re.split(r"\s+#", m.group(2), maxsplit=1)[0].strip().strip("'\"")
  50. return out
  51. def resolve(raw):
  52. m = VAR.match(raw)
  53. if not m:
  54. return raw
  55. return os.environ.get(m.group(1)) or m.group(2)
  56. cfg = read_block(YML, "leshouying")
  57. BASE = resolve(cfg["base-url"]).rstrip("/")
  58. INST = resolve(cfg["inst-no"])
  59. KEY = resolve(cfg["key"])
  60. MCH = "Y52211090004"
  61. if not os.path.isdir(OUTDIR):
  62. os.makedirs(OUTDIR)
  63. def call(path, params, tag, timeout=60):
  64. p = dict(params)
  65. p.setdefault("inst_no", INST)
  66. p.setdefault("mch_no", MCH)
  67. A = "&".join("%s=%s" % (k, p[k]) for k in sorted(p) if p[k] is not None)
  68. p["sign"] = md5((A + "&key=" + KEY).encode("utf-8")).hexdigest().lower()
  69. body = json.dumps(p, ensure_ascii=False).encode("utf-8")
  70. req = urllib.request.Request(BASE + path, data=body,
  71. headers={"Content-Type": "application/json;charset=utf-8"},
  72. method="POST")
  73. try:
  74. with urllib.request.urlopen(req, timeout=timeout) as r:
  75. raw = r.read()
  76. except urllib.error.HTTPError as e:
  77. raw = e.read()
  78. except Exception as e: # noqa: BLE001
  79. say(" !! 量具失效 %s: %r" % (tag, e))
  80. return None
  81. io.open("%s/%s.bin" % (OUTDIR, tag), "wb").write(raw)
  82. resp = json.loads(raw.decode("utf-8"))
  83. usign = resp.get("sign")
  84. vs = "sign=null"
  85. if usign:
  86. nn = {k: v for k, v in resp.items() if k != "sign" and v is not None}
  87. A2 = "&".join("%s=%s" % (k, nn[k]) for k in sorted(nn))
  88. vs = ("验签OK(%d)" % len(nn)
  89. if md5((A2 + "&key=" + KEY).encode("utf-8")).hexdigest().lower() == usign
  90. else "验签FAIL")
  91. say(" %-16s %-18s rc=%-8s ec=%-14s %s | %s"
  92. % (tag, path, resp.get("return_code"), resp.get("result_code"),
  93. resp.get("return_msg"), vs))
  94. return resp
  95. def stamp():
  96. return time.strftime("%Y%m%d%H%M%S")
  97. def uid(p):
  98. return "%s%s%s" % (p, time.strftime("%H%M%S"), uuid.uuid4().hex[:6])
  99. def q(tag, **kw):
  100. r = call("/v3/queryorder", dict(query_trace_no=uuid.uuid4().hex, **kw), tag)
  101. return r
  102. # ══════════════════════ 对照一:H5 + /v3/closeorder ══════════════════════
  103. say("=" * 78)
  104. say("对照一:两笔同参 H5,只关 A —— 判 /v3/closeorder 是否真改状态")
  105. say("=" * 78)
  106. ta, pa = uid("hA"), stamp()
  107. ra = call("/v3/h5pay", {"pay_trace_no": ta, "pay_time": pa, "total_amount": "1",
  108. "pay_type": "300", "order_body": "对照-H5A-待关"}, "h5-A-create")
  109. tb, pb = uid("hB"), stamp()
  110. rb = call("/v3/h5pay", {"pay_trace_no": tb, "pay_time": pb, "total_amount": "1",
  111. "pay_type": "300", "order_body": "对照-H5B-不动"}, "h5-B-create")
  112. A = ra.get("trade_no") if ra else None
  113. B = rb.get("trade_no") if rb else None
  114. say(" A.trade_no = %s" % A)
  115. say(" B.trade_no = %s" % B)
  116. say()
  117. say(" ── 关单前的基线(两笔都必须是 PAY_IN_PROCESS,否则对照作废)──")
  118. qa0 = q("A-baseline", trade_no=A)
  119. qb0 = q("B-baseline", trade_no=B)
  120. say()
  121. say(" ── 只关 A ──")
  122. call("/v3/closeorder", {"close_trace_no": uid("kA"), "close_time": stamp(),
  123. "trade_no": A}, "close-A")
  124. say()
  125. say(" ── 关后各查一次 ──")
  126. qa1 = q("A-after", trade_no=A)
  127. qb1 = q("B-after", trade_no=B)
  128. say()
  129. say(" 判决:")
  130. say(" A: %s -> %s (%s)"
  131. % (qa0.get("result_code") if qa0 else "?",
  132. qa1.get("result_code") if qa1 else "?", qa1.get("return_msg") if qa1 else "?"))
  133. say(" B: %s -> %s (%s)"
  134. % (qb0.get("result_code") if qb0 else "?",
  135. qb1.get("result_code") if qb1 else "?", qb1.get("return_msg") if qb1 else "?"))
  136. if qa1 and qb1 and qb1.get("result_code") == "PAY_IN_PROCESS" and qa1.get("result_code") == "PAY_FAIL":
  137. say(" ⇒ **差分成立**:closeorder 真把 A 从「用户支付中」改成了「支付失败」,")
  138. say(" 而同一刻同参的 B 仍是「用户支付中」。closeorder 对 H5 有效。")
  139. elif qa1 and qb1 and qa1.get("result_code") == qb1.get("result_code"):
  140. say(" ⇒ 两笔同态 ⇒ closeorder 对 H5 **没有改变状态**(或那个「关单成功」是空话)。")
  141. else:
  142. say(" ⇒ 读数不可判,见上表。")
  143. say()
  144. # ══════════════════════ 对照二:聚合码 + /v3/qrclose ══════════════════════
  145. say("=" * 78)
  146. say("对照二:两笔同参聚合码,只关 A —— 复核我方已在用的 /v3/qrclose")
  147. say("=" * 78)
  148. ta2, pa2 = uid("qA"), stamp()
  149. ra2 = call("/v3/qrpay", {"pay_trace_no": ta2, "pay_time": pa2, "total_amount": "1",
  150. "time_expire": "30", "order_body": "对照-码A-待关"}, "qr-A-create")
  151. tb2, pb2 = uid("qB"), stamp()
  152. rb2 = call("/v3/qrpay", {"pay_trace_no": tb2, "pay_time": pb2, "total_amount": "1",
  153. "time_expire": "30", "order_body": "对照-码B-不动"}, "qr-B-create")
  154. UA = ra2.get("code_url") if ra2 else None
  155. say(" A.code_url = %s" % UA)
  156. say(" B.code_url = %s" % (rb2.get("code_url") if rb2 else None))
  157. say()
  158. say(" ── 关单前的基线(已知:未扫的聚合码单查不到,此处复核)──")
  159. qa2 = q("A-qr-baseline", pay_trace_no=ta2, pay_time=pa2)
  160. qb2 = q("B-qr-baseline", pay_trace_no=tb2, pay_time=pb2)
  161. say()
  162. say(" ── 只关 A ──")
  163. call("/v3/qrclose", {"code_url": UA}, "qrclose-A")
  164. say()
  165. say(" ── 关后各查一次 ──")
  166. qa3 = q("A-qr-after", pay_trace_no=ta2, pay_time=pa2)
  167. qb3 = q("B-qr-after", pay_trace_no=tb2, pay_time=pb2)
  168. say(" 判决:A 关前/关后 = %s/%s ;B 关前/关后 = %s/%s"
  169. % (qa2.get("return_code") if qa2 else "?", qa3.get("return_code") if qa3 else "?",
  170. qb2.get("return_code") if qb2 else "?", qb3.get("return_code") if qb3 else "?"))
  171. say(" (聚合码单在关前关后都查不到 ⇒「查不到」是这个单**种类**的性质,")
  172. say(" 与关单动作无关 —— 正好反证第一轮那条负读数不是关单造的。)")
  173. say()
  174. say("句柄留档:")
  175. say(" h5 A/B = %s / %s (A 已关,B 未动,可用于后续比对)" % (A, B))
  176. say(" qr A/B = %s / %s" % (ta2, tb2))
  177. io.open(OUT, "w", encoding="utf-8").write("\n".join(buf) + "\n")
  178. print("report -> %s" % OUT)