_lsy-refund-unlock.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. # -*- coding: utf-8 -*-
  2. """第二轮:把「退款」这条腿打通 —— 换渠道补录 + 换寻址键退款。
  3. 第一轮的读数(logs/_lsy-lifecycle.txt):
  4. * 补录(channel_type=37 建行)→ queryorder 查到 PAY_SUCCESS ✔
  5. * 但 /v3/refundorder → PAY_FAIL「退款失败:没有查询到符合条件的记录。」
  6. * /v3/queryrefund 把那条退款记录查回来了,return_msg = 「查询失败:商户号[105000370940]已注销或待审核」
  7. —— 那个商户号不是我们的(我们的是 Y52211090004),是**渠道商户号**。
  8. ⇒ 假设 R1:退款失败是因为 channel_type=37 那条渠道在建行侧没开通/已注销。
  9. 若成立,换一个渠道(50 平安 / 6 官方直连 / 其它)补录后**退款应当能往下走**。
  10. ⇒ 假设 R2:退款失败是**寻址键**的问题(pay_trace_no+pay_trace_time 这条腿在退款接口上不通)。
  11. 若成立,用 trade_no 寻址应当能往下走。
  12. 两个假设**各改一个变量**,互不干扰。
  13. 另测:真实被扫失败单的 /v3/cancelorder(文档:「如果此订单用户支付失败,会将此订单关闭」)。
  14. """
  15. import io
  16. import json
  17. import os
  18. import re
  19. import sys
  20. import time
  21. import uuid
  22. import urllib.error
  23. import urllib.request
  24. from hashlib import md5
  25. YML = "java/src/main/resources/application.yml"
  26. VAR = re.compile(r"^\$\{([A-Za-z0-9_]+):(.*)\}$")
  27. OUTDIR = "logs/_lsy-lc2"
  28. OUT = "logs/_lsy-refund-unlock.txt"
  29. buf = []
  30. rows = []
  31. def say(s=""):
  32. buf.append(s)
  33. print(s.encode("ascii", "replace").decode())
  34. def read_block(path, top):
  35. lines = io.open(path, encoding="utf-8", errors="replace").read().split("\n")
  36. start = None
  37. for i, ln in enumerate(lines):
  38. if re.match(r"^%s:\s*$" % re.escape(top), ln):
  39. start = i
  40. break
  41. if start is None:
  42. raise SystemExit("找不到顶层段 %s:" % top)
  43. out = {}
  44. for j in range(start + 1, len(lines)):
  45. ln = lines[j]
  46. if ln.strip() == "" or ln.lstrip().startswith("#"):
  47. continue
  48. if not ln.startswith((" ", "\t")):
  49. break
  50. m = re.match(r"^\s+([A-Za-z0-9_.\-]+):\s*(.*)$", ln)
  51. if m:
  52. out[m.group(1)] = re.split(r"\s+#", m.group(2), maxsplit=1)[0].strip().strip("'\"")
  53. return out
  54. def resolve(raw):
  55. m = VAR.match(raw)
  56. if not m:
  57. return raw
  58. return os.environ.get(m.group(1)) or m.group(2)
  59. cfg = read_block(YML, "leshouying")
  60. BASE = resolve(cfg["base-url"]).rstrip("/")
  61. INST = resolve(cfg["inst-no"])
  62. KEY = resolve(cfg["key"])
  63. MCH = "Y52211090004"
  64. if not os.path.isdir(OUTDIR):
  65. os.makedirs(OUTDIR)
  66. # 第一轮遗留的真实句柄(读自原始应答字节,不手抄)
  67. BARCODE_TRADE_NO = "200001070X3126092816241710003" # create-pay-barcode,PAY_FAIL 的真被扫单
  68. SUP_TRACE = None
  69. SUP_TIME = None
  70. SUP_NO = None
  71. for ln in io.open("logs/_lsy-lifecycle.txt", encoding="utf-8").read().split("\n"):
  72. if "data(乐收赢订单号)" in ln:
  73. SUP_NO = ln.split("=")[-1].strip()
  74. say("── 前置:读第一轮的补录句柄 ──")
  75. say(" 补录乐收赢订单号 = %s" % SUP_NO)
  76. say(" 真实被扫失败单 = %s" % BARCODE_TRADE_NO)
  77. say()
  78. def call(path, params, tag, envelope="v3"):
  79. p = dict(params)
  80. p.setdefault("inst_no", INST)
  81. p.setdefault("mch_no", MCH)
  82. A = "&".join("%s=%s" % (k, p[k]) for k in sorted(p) if p[k] is not None)
  83. p["sign"] = md5((A + "&key=" + KEY).encode("utf-8")).hexdigest().lower()
  84. body = json.dumps(p, ensure_ascii=False).encode("utf-8")
  85. req = urllib.request.Request(BASE + path, data=body,
  86. headers={"Content-Type": "application/json;charset=utf-8"},
  87. method="POST")
  88. try:
  89. with urllib.request.urlopen(req, timeout=30) as r:
  90. code, raw = r.status, r.read()
  91. except urllib.error.HTTPError as e:
  92. code, raw = e.code, e.read()
  93. except Exception as e: # noqa: BLE001
  94. say(" !! 未完成 %s: %r" % (tag, e))
  95. rows.append((tag, path, "TRANSPORT_ERR", repr(e)[:60]))
  96. return None
  97. io.open("%s/%s.bin" % (OUTDIR, tag), "wb").write(raw)
  98. try:
  99. resp = json.loads(raw.decode("utf-8"))
  100. except Exception: # noqa: BLE001
  101. rows.append((tag, path, "NON_JSON", "%d B" % len(raw)))
  102. return None
  103. usign = resp.get("sign")
  104. vs = "sign=null"
  105. if usign:
  106. nn = {k: v for k, v in resp.items() if k != "sign" and v is not None}
  107. A2 = "&".join("%s=%s" % (k, nn[k]) for k in sorted(nn))
  108. ok = md5((A2 + "&key=" + KEY).encode("utf-8")).hexdigest().lower() == usign
  109. vs = "验签OK(%d)" % len(nn) if ok else "验签FAIL(%d)" % len(nn)
  110. elif "sign" not in resp:
  111. vs = "无sign键"
  112. rc = resp.get("return_code")
  113. ec = resp.get("result_code")
  114. if envelope == "legacy":
  115. rc, ec = resp.get("code"), resp.get("data")
  116. say(" POST %-20s -> %4d B rc=%s ec=%s %s" % (path, len(raw), rc, ec, vs))
  117. say(" msg = %s" % (resp.get("return_msg") or resp.get("message")))
  118. rows.append((tag, path, "%s/%s" % (rc, ec), "%s; %s" % (str(resp.get("return_msg") or resp.get("message"))[:44], vs)))
  119. return resp
  120. def stamp():
  121. return time.strftime("%Y%m%d%H%M%S")
  122. def uid(p):
  123. return "%s%s%s" % (p, time.strftime("%H%M%S"), uuid.uuid4().hex[:6])
  124. # ────────────────────────────────────────────── 0) 验签:FAIL 应答的 sign 覆盖了什么
  125. say("=" * 78)
  126. say("【0】先钉一件事:queryorder 的 FAIL 应答 sign 覆盖哪几个字段")
  127. say("=" * 78)
  128. raw = io.open("logs/_lsy-lc/query-qrpay-unscanned.bin", "rb").read()
  129. resp = json.loads(raw.decode("utf-8"))
  130. nn = {k: v for k, v in resp.items() if k != "sign" and v is not None}
  131. say(" 非 null 字段 = %s" % sorted(nn))
  132. A2 = "&".join("%s=%s" % (k, nn[k]) for k in sorted(nn))
  133. mine = md5((A2 + "&key=" + KEY).encode("utf-8")).hexdigest().lower()
  134. say(" 重算 == 上游 : %s" % (mine == resp["sign"]))
  135. say(" ⇒ 这两条 FAIL 的 sign 是**常量**(换单号、换 query_trace_no 都不变)")
  136. say()
  137. # ────────────────────────────────────────────── 1) 换渠道补录
  138. say("=" * 78)
  139. say("【1】假设 R1:换 channel_type 补录(各一条),再试退款")
  140. say("=" * 78)
  141. CHANNELS = [("6", "官方直连"), ("1", "网商"), ("2", "富友"), ("50", "平安"), ("4", "银联")]
  142. made = []
  143. for ch, name in CHANNELS:
  144. trace = uid("s%d" % len(made))
  145. now = stamp()
  146. settle = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - 60))
  147. r = call("/v3/createSupply", {
  148. "channel_type": ch, "pay_mode": "2", "type": "300", "function_flag": "1",
  149. "pay_time": now, "settle_time": settle, "pay_trace_no": trace,
  150. "total_fee": "1", "order_body": "二轮补录ch%s" % ch,
  151. }, "supply-ch%s" % ch, envelope="legacy")
  152. if r and r.get("code") == 200 and r.get("data"):
  153. made.append({"ch": ch, "name": name, "trace": trace, "time": now, "no": r["data"]})
  154. say(" ch=%s(%s) 订单号 = %s" % (ch, name, r["data"]))
  155. say()
  156. # ────────────────────────────────────────────── 2) 每笔补录:查得到吗
  157. say("=" * 78)
  158. say("【2】每笔补录用 queryorder 复核(必须 PAY_SUCCESS 才值得往下退)")
  159. say("=" * 78)
  160. good = []
  161. for m in made:
  162. q = call("/v3/queryorder", {"query_trace_no": uuid.uuid4().hex,
  163. "pay_trace_no": m["trace"], "pay_time": m["time"]},
  164. "q-supply-ch%s" % m["ch"])
  165. if q and q.get("result_code") == "PAY_SUCCESS":
  166. good.append(m)
  167. say()
  168. say(" 查到 PAY_SUCCESS 的:%s" % [m["ch"] for m in good])
  169. say()
  170. # ────────────────────────────────────────────── 3) 假设 R1:换渠道退款(键不变)
  171. say("=" * 78)
  172. say("【3】假设 R1:同一套寻址键(pay_trace_no+pay_trace_time),只换渠道")
  173. say("=" * 78)
  174. for m in made:
  175. say(" ── ch=%s(%s) ──" % (m["ch"], m["name"]))
  176. call("/v3/refundorder", {
  177. "refund_trace_no": uid("r%d" % len(m["ch"])), "refund_fee": "1",
  178. "pay_time": stamp(), "pay_trace_no": m["trace"], "pay_trace_time": m["time"],
  179. }, "refund-ch%s-bytrace" % m["ch"])
  180. say()
  181. # ────────────────────────────────────────────── 4) 假设 R2:换寻址键(trade_no)
  182. say("=" * 78)
  183. say("【4】假设 R2:换寻址键为 trade_no(含第一轮那笔建行补录)")
  184. say("=" * 78)
  185. targets = [{"ch": "37(一轮建行)", "no": SUP_NO}] + [{"ch": m["ch"], "no": m["no"]} for m in good]
  186. for t in targets:
  187. if not t["no"]:
  188. continue
  189. say(" ── trade_no = %s ch=%s ──" % (t["no"], t["ch"]))
  190. call("/v3/refundorder", {
  191. "refund_trace_no": uid("rt"), "refund_fee": "1",
  192. "pay_time": stamp(), "trade_no": t["no"],
  193. }, "refund-tradeno-%s" % t["no"][-6:])
  194. say()
  195. # ────────────────────────────────────────────── 5) 退款查询:把 refund_time 用对
  196. say("=" * 78)
  197. say("【5】/v3/queryrefund 两条寻址路(第一轮 bytrace 挂在我把 refund_time 填错)")
  198. say("=" * 78)
  199. first = json.loads(io.open("logs/_lsy-lc/refund-supply-bytrace.bin", "rb").read().decode("utf-8"))
  200. say(" 第一轮那条退款:refund_no=%s refund_trace_no=%s" % (first.get("refund_no"), first.get("refund_trace_no")))
  201. say(" 它回显的 pay_time(=发起退款的时间)= %s ← 这才是 refund_time 该填的值" % first.get("pay_time"))
  202. call("/v3/queryrefund", {"query_trace_no": uuid.uuid4().hex,
  203. "refund_trace_no": first["refund_trace_no"],
  204. "refund_time": first["pay_time"]}, "qrefund-bytrace-fixed")
  205. call("/v3/queryrefund", {"query_trace_no": uuid.uuid4().hex,
  206. "refund_no": first["refund_no"]}, "qrefund-byno-again")
  207. say()
  208. # ────────────────────────────────────────────── 6) 撤销:真实被扫失败单
  209. say("=" * 78)
  210. say("【6】/v3/cancelorder —— 拿一笔**真实的被扫 PAY_FAIL 单**(文档:支付失败会关单)")
  211. say("=" * 78)
  212. call("/v3/cancelorder", {"cancel_trace_no": uid("cx"), "cancel_time": stamp(),
  213. "trade_no": BARCODE_TRADE_NO}, "cancel-barcode-bytradeno")
  214. call("/v3/queryorder", {"query_trace_no": uuid.uuid4().hex,
  215. "trade_no": BARCODE_TRADE_NO}, "q-barcode-aftercancel")
  216. say()
  217. # ────────────────────────────────────────────── 7) 退款一笔「已被关单」的 H5 单
  218. say("=" * 78)
  219. say("【7】对**已关单**的 H5 单退款(第一轮 closeorder 已把它关了)")
  220. say("=" * 78)
  221. h5 = json.loads(io.open("logs/_lsy-lc/create-h5pay.bin", "rb").read().decode("utf-8"))
  222. say(" H5 单 trade_no = %s(已被 /v3/closeorder 关闭)" % h5.get("trade_no"))
  223. call("/v3/refundorder", {"refund_trace_no": uid("rc"), "refund_fee": "1",
  224. "pay_time": stamp(), "trade_no": h5.get("trade_no")},
  225. "refund-closed-h5")
  226. say()
  227. # ────────────────────────────────────────────── 8) 补录退款(function_flag=2)
  228. say("=" * 78)
  229. say("【8】/v3/createSupply function_flag=2(补录退款订单)—— 直接造一条退款记录")
  230. say("=" * 78)
  231. if SUP_NO:
  232. call("/v3/createSupply", {
  233. "channel_type": "37", "pay_mode": "2", "type": "300", "function_flag": "2",
  234. "pay_time": stamp(), "settle_time": time.strftime("%Y-%m-%d %H:%M:%S"),
  235. "pay_trace_no": uid("sup2"), "total_fee": "1", "out_refund_no": SUP_NO,
  236. "order_body": "二轮补录退款",
  237. }, "supply-flag2", envelope="legacy")
  238. say()
  239. say("=" * 78)
  240. say("汇总(%d 条)" % len(rows))
  241. say("=" * 78)
  242. say("%-30s %-22s %-20s %s" % ("tag", "path", "rc/ec", "note"))
  243. for t, p, v, d in rows:
  244. say("%-30s %-22s %-20s %s" % (t, p, v, d))
  245. io.open(OUT, "w", encoding="utf-8").write("\n".join(buf) + "\n")
  246. print("report -> %s" % OUT)