_lsy-refund-live.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. # -*- coding: utf-8 -*-
  2. """退款实弹联调:拿**已有的一笔成功订单**走我方开放 API 退款,再查退款单。
  3. 与 logs/_lsy-lifecycle.py 的 refund 段的区别:那个直连上游、绕过我方全链路,库里不留痕;
  4. 这个走 `/payment/openapi/pay/refund`,验签 + 租户闸 + 退款单落库 + 上游退款全都要过。
  5. 设计要点(与 _lsy-barcode-live.py 同规矩)
  6. * 租户**从被退的那笔订单反查**,不按「第一个可用商户」挑 —— 挑错租户会红在
  7. 「订单不存在」上,那是夹具错不是被测对象错。
  8. * key/secret 只打长度;单号只打长度与形态,不打印整串。
  9. * 每条出站的**原始应答字节**落盘 logs/_lsy-rf/<tag>.bin,报告里只写结论。
  10. * 控制台是 GBK ⇒ stdout 只打 ASCII,正文进 UTF-8 文件。
  11. * 直连库复核,不信接口自述。
  12. 用法: python logs/_lsy-refund-live.py <out_trade_no> [退款分,默认全额]
  13. """
  14. import io
  15. import json
  16. import os
  17. import re
  18. import sys
  19. import time
  20. import uuid
  21. import urllib.error
  22. import urllib.request
  23. from hashlib import sha256
  24. from urllib.parse import quote
  25. import psycopg2
  26. ROOT = r"D:\project2\payment-platform"
  27. YML = os.path.join(ROOT, "java", "src", "main", "resources", "application.yml")
  28. OUTDIR = os.path.join(ROOT, "logs", "_lsy-rf")
  29. OUT = os.path.join(ROOT, "logs", "_lsy-refund-live.txt")
  30. BASE = os.environ.get("LSY_BASE", "http://localhost:8001/api/v1")
  31. buf = []
  32. rows = []
  33. def say(s=""):
  34. buf.append(str(s))
  35. print(str(s).encode("ascii", "replace").decode())
  36. def row(tag, verdict, detail=""):
  37. rows.append((tag, verdict, detail))
  38. def save(tag, raw):
  39. os.makedirs(OUTDIR, exist_ok=True)
  40. p = os.path.join(OUTDIR, tag + ".bin")
  41. with open(p, "wb") as f:
  42. f.write(raw)
  43. return p
  44. def db():
  45. y = io.open(YML, encoding="utf-8", errors="replace").read()
  46. url = re.search(r"(jdbc:postgresql://[^\s\"']+)", y).group(1)
  47. tail = y[y.index(url):]
  48. user = re.search(r"username:\s*(\S+)", tail).group(1)
  49. pwd = re.search(r"password:\s*(\S+)", tail).group(1)
  50. m = re.search(r"//([^:/]+):(\d+)/([^?\s]+)", url)
  51. return psycopg2.connect(host=m.group(1), port=int(m.group(2)), dbname=m.group(3),
  52. user=user, password=pwd, connect_timeout=8)
  53. # 与 _lsy-barcode-live.py 逐字同源(文档 §3.3)
  54. def build_signature(secret, body):
  55. parts = []
  56. for key in sorted(body):
  57. v = body[key]
  58. if v is None or v == "" or v == {} or v == [] or key == "sign":
  59. continue
  60. if isinstance(v, (dict, list)):
  61. text = json.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
  62. else:
  63. text = str(v)
  64. parts.append("%s=%s" % (key, quote(text, safe="")))
  65. sign_str = "&".join(parts)
  66. return sign_str, __import__("hmac").new(secret.encode("utf-8"),
  67. sign_str.encode("utf-8"),
  68. sha256).hexdigest()
  69. def call(path, body, key, secret, tag):
  70. raw = json.dumps(body, ensure_ascii=False).encode("utf-8")
  71. req = urllib.request.Request(BASE + path, data=raw, method="POST")
  72. req.add_header("Content-Type", "application/json")
  73. req.add_header("Authorization", "ApiKey " + key)
  74. _, sig = build_signature(secret, body)
  75. req.add_header("Signature", sig)
  76. try:
  77. with urllib.request.urlopen(req, timeout=60) as r:
  78. status, data = r.status, r.read()
  79. except urllib.error.HTTPError as e:
  80. status, data = e.code, e.read()
  81. p = save(tag, data)
  82. try:
  83. parsed = json.loads(data.decode("utf-8"))
  84. except Exception:
  85. parsed = data.decode("utf-8", "replace")
  86. return status, parsed, p
  87. # ================================================================ 主流程
  88. _pos = [a for a in sys.argv[1:] if not a.startswith("--")]
  89. if not _pos:
  90. raise SystemExit("用法: python logs/_lsy-refund-live.py <out_trade_no> [退款分]")
  91. ORDER = _pos[0]
  92. WANT_FEE = int(_pos[1]) if len(_pos) > 1 else None
  93. conn = db()
  94. cur = conn.cursor()
  95. cur.execute("""SELECT tenant_id, mch_no, status, total_amount, receipt_amount, refunded_amount,
  96. trade_no, pay_trace_no
  97. FROM pay_lsy_order WHERE out_trade_no = %s""", (ORDER,))
  98. o = cur.fetchone()
  99. if not o:
  100. raise SystemExit("库里没有订单 %s" % ORDER)
  101. TENANT, MCH, OSTATUS, TOTAL, RECEIPT, REFUNDED = o[0], o[1], o[2], o[3], o[4], o[5]
  102. say("被退订单 %s" % ORDER)
  103. say(" tenant_id=%s mch_no=%s status=%s" % (TENANT, MCH, OSTATUS))
  104. say(" total=%s 已退=%s 可退=%s" % (TOTAL, REFUNDED, (TOTAL or 0) - (REFUNDED or 0)))
  105. if OSTATUS not in ("SUCCESS", "PARTIAL_REFUNDED"):
  106. raise SystemExit("订单状态 %s 不可退,中止" % OSTATUS)
  107. FEE = WANT_FEE if WANT_FEE is not None else (TOTAL - REFUNDED)
  108. if FEE <= 0 or FEE > (TOTAL - REFUNDED or 0):
  109. raise SystemExit("退款金额 %s 不在可退区间内,中止" % FEE)
  110. cur.execute("SELECT api_key, api_secret FROM sys_tenant_api_key "
  111. "WHERE tenant_id = %s ORDER BY id LIMIT 1", (TENANT,))
  112. k = cur.fetchone()
  113. if not k:
  114. raise SystemExit("租户 %s 下一把 api_key 都没有" % TENANT)
  115. API_KEY, API_SECRET = k
  116. say(" 用该租户的 key:api_key=<len %d> api_secret=<len %d> (值不打印)"
  117. % (len(API_KEY), len(API_SECRET)))
  118. oRefundNo = ("RF%s%s" % (time.strftime("%H%M%S"), uuid.uuid4().hex[:6]))[:32]
  119. say(" out_refund_no = prefix=RF len=%d 退款 %s 分" % (len(oRefundNo), FEE))
  120. # ---- 1. 退款
  121. say("")
  122. say("=" * 70)
  123. say(" 第 1 步 POST /payment/openapi/pay/refund")
  124. say("=" * 70)
  125. body = {"out_trade_no": ORDER, "out_refund_no": oRefundNo, "refund_fee": FEE}
  126. st, parsed, p = call("/payment/openapi/pay/refund", body, API_KEY, API_SECRET, "refund")
  127. say("HTTP %s 原始应答 %d 字节 → %s" % (st, os.path.getsize(p), os.path.relpath(p, ROOT)))
  128. say(json.dumps(parsed, ensure_ascii=False, indent=2))
  129. data = parsed.get("data") if isinstance(parsed, dict) else None
  130. row("退款", "HTTP %s" % st, "status=%s" % ((data or {}).get("status")))
  131. # ---- 2. 退款查询
  132. say("")
  133. say("=" * 70)
  134. say(" 第 2 步 POST /payment/openapi/pay/refund/query")
  135. say("=" * 70)
  136. st2, parsed2, p2 = call("/payment/openapi/pay/refund/query", {"out_refund_no": oRefundNo},
  137. API_KEY, API_SECRET, "refundquery")
  138. say("HTTP %s 原始应答 %d 字节 → %s" % (st2, os.path.getsize(p2), os.path.relpath(p2, ROOT)))
  139. say(json.dumps(parsed2, ensure_ascii=False, indent=2))
  140. row("退款查询", "HTTP %s" % st2,
  141. "local_status=%s" % (((parsed2.get("data") or {}) if isinstance(parsed2, dict) else {}).get("local_status")))
  142. # ---- 3. 查单(看订单侧是否被回写)
  143. say("")
  144. say("=" * 70)
  145. say(" 第 3 步 POST /payment/openapi/pay/order/query")
  146. say("=" * 70)
  147. st3, parsed3, p3 = call("/payment/openapi/pay/order/query", {"out_trade_no": ORDER},
  148. API_KEY, API_SECRET, "orderquery")
  149. say("HTTP %s 原始应答 %d 字节 → %s" % (st3, os.path.getsize(p3), os.path.relpath(p3, ROOT)))
  150. say(json.dumps(parsed3, ensure_ascii=False, indent=2))
  151. row("查单", "HTTP %s" % st3,
  152. "local_status=%s" % (((parsed3.get("data") or {}) if isinstance(parsed3, dict) else {}).get("local_status")))
  153. # ---- 4. 直连库复核
  154. say("")
  155. say("=" * 70)
  156. say(" 第 4 步 直连库复核(不信接口自述)")
  157. say("=" * 70)
  158. conn.rollback() # 丢掉只读事务快照,读最新
  159. # 列名逐个取自 LsyRefundEntity(含 PaymentBaseEntity 继承来的 status)——此处原写 refund_reason,
  160. # 那一列**本表没有**(它在汇安融那张表上),一跑就 UndefinedColumn。别凭记忆改这里,去读实体。
  161. cur.execute("""SELECT out_refund_no, refund_no, status, refund_fee, refund_trace_no,
  162. upstream_result_code, upstream_result_msg, created_time, updated_time
  163. FROM pay_lsy_refund WHERE out_refund_no = %s""", (oRefundNo,))
  164. r = cur.fetchone()
  165. if r:
  166. cols = ["out_refund_no", "refund_no", "status", "refund_fee", "refund_trace_no",
  167. "upstream_result_code", "upstream_result_msg", "created_time", "updated_time"]
  168. for c, v in zip(cols, r):
  169. say(" %-22s = %s" % (c, v))
  170. row("退款单落库", "status=%s" % r[2], "fee=%s refund_no=%s" % (r[3], r[1]))
  171. else:
  172. say(" !pay_lsy_refund 里没有 out_refund_no=%s 这一行" % oRefundNo)
  173. row("退款单落库", "缺行", "")
  174. cur.execute("""SELECT status, total_amount, refunded_amount
  175. FROM pay_lsy_order WHERE out_trade_no = %s""", (ORDER,))
  176. o2 = cur.fetchone()
  177. say("")
  178. say(" 订单侧:status=%s total=%s refunded_amount=%s" % o2)
  179. row("订单回写", "status=%s" % o2[0], "refunded=%s" % o2[2])
  180. # 退款回调(trade_type=0)—— 上送上游的 notify_url 指向公网域名,那台与本机共用同一个库,
  181. # 所以这里**可能**看得到它写的行;看到即证「退款回调确实到达并被处理」。
  182. cur.execute("""SELECT id, to_char(created_time,'HH24:MI:SS.MS'), trade_type, refund_no,
  183. verify_status, process_status, tenant_id, error_msg
  184. FROM pay_lsy_notify_log
  185. WHERE created_time > now() - interval '15 minutes'
  186. ORDER BY id""")
  187. nl = cur.fetchall()
  188. say("")
  189. say("近 15 分钟 pay_lsy_notify_log 行数 = %d" % len(nl))
  190. for n in nl:
  191. say(" %s" % (n,))
  192. row("回调日志", "%d 行" % len(nl), "")
  193. conn.close()
  194. say("")
  195. say("=" * 70)
  196. say(" 汇总")
  197. say("=" * 70)
  198. for t, v, d in rows:
  199. say(" %-12s | %-22s | %s" % (t, v, d))
  200. io.open(OUT, "w", encoding="utf-8").write("\n".join(buf) + "\n")
  201. say("")
  202. say("正文已写入 %s" % os.path.relpath(OUT, ROOT))