# -*- coding: utf-8 -*- """退款实弹联调:拿**已有的一笔成功订单**走我方开放 API 退款,再查退款单。 与 logs/_lsy-lifecycle.py 的 refund 段的区别:那个直连上游、绕过我方全链路,库里不留痕; 这个走 `/payment/openapi/pay/refund`,验签 + 租户闸 + 退款单落库 + 上游退款全都要过。 设计要点(与 _lsy-barcode-live.py 同规矩) * 租户**从被退的那笔订单反查**,不按「第一个可用商户」挑 —— 挑错租户会红在 「订单不存在」上,那是夹具错不是被测对象错。 * key/secret 只打长度;单号只打长度与形态,不打印整串。 * 每条出站的**原始应答字节**落盘 logs/_lsy-rf/.bin,报告里只写结论。 * 控制台是 GBK ⇒ stdout 只打 ASCII,正文进 UTF-8 文件。 * 直连库复核,不信接口自述。 用法: python logs/_lsy-refund-live.py [退款分,默认全额] """ import io import json import os import re import sys import time import uuid import urllib.error import urllib.request from hashlib import sha256 from urllib.parse import quote import psycopg2 ROOT = r"D:\project2\payment-platform" YML = os.path.join(ROOT, "java", "src", "main", "resources", "application.yml") OUTDIR = os.path.join(ROOT, "logs", "_lsy-rf") OUT = os.path.join(ROOT, "logs", "_lsy-refund-live.txt") BASE = os.environ.get("LSY_BASE", "http://localhost:8001/api/v1") buf = [] rows = [] def say(s=""): buf.append(str(s)) print(str(s).encode("ascii", "replace").decode()) def row(tag, verdict, detail=""): rows.append((tag, verdict, detail)) def save(tag, raw): os.makedirs(OUTDIR, exist_ok=True) p = os.path.join(OUTDIR, tag + ".bin") with open(p, "wb") as f: f.write(raw) return p def db(): y = io.open(YML, encoding="utf-8", errors="replace").read() url = re.search(r"(jdbc:postgresql://[^\s\"']+)", y).group(1) tail = y[y.index(url):] user = re.search(r"username:\s*(\S+)", tail).group(1) pwd = re.search(r"password:\s*(\S+)", tail).group(1) m = re.search(r"//([^:/]+):(\d+)/([^?\s]+)", url) return psycopg2.connect(host=m.group(1), port=int(m.group(2)), dbname=m.group(3), user=user, password=pwd, connect_timeout=8) # 与 _lsy-barcode-live.py 逐字同源(文档 §3.3) def build_signature(secret, body): parts = [] for key in sorted(body): v = body[key] if v is None or v == "" or v == {} or v == [] or key == "sign": continue if isinstance(v, (dict, list)): text = json.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False) else: text = str(v) parts.append("%s=%s" % (key, quote(text, safe=""))) sign_str = "&".join(parts) return sign_str, __import__("hmac").new(secret.encode("utf-8"), sign_str.encode("utf-8"), sha256).hexdigest() def call(path, body, key, secret, tag): raw = json.dumps(body, ensure_ascii=False).encode("utf-8") req = urllib.request.Request(BASE + path, data=raw, method="POST") req.add_header("Content-Type", "application/json") req.add_header("Authorization", "ApiKey " + key) _, sig = build_signature(secret, body) req.add_header("Signature", sig) try: with urllib.request.urlopen(req, timeout=60) as r: status, data = r.status, r.read() except urllib.error.HTTPError as e: status, data = e.code, e.read() p = save(tag, data) try: parsed = json.loads(data.decode("utf-8")) except Exception: parsed = data.decode("utf-8", "replace") return status, parsed, p # ================================================================ 主流程 _pos = [a for a in sys.argv[1:] if not a.startswith("--")] if not _pos: raise SystemExit("用法: python logs/_lsy-refund-live.py [退款分]") ORDER = _pos[0] WANT_FEE = int(_pos[1]) if len(_pos) > 1 else None conn = db() cur = conn.cursor() cur.execute("""SELECT tenant_id, mch_no, status, total_amount, receipt_amount, refunded_amount, trade_no, pay_trace_no FROM pay_lsy_order WHERE out_trade_no = %s""", (ORDER,)) o = cur.fetchone() if not o: raise SystemExit("库里没有订单 %s" % ORDER) TENANT, MCH, OSTATUS, TOTAL, RECEIPT, REFUNDED = o[0], o[1], o[2], o[3], o[4], o[5] say("被退订单 %s" % ORDER) say(" tenant_id=%s mch_no=%s status=%s" % (TENANT, MCH, OSTATUS)) say(" total=%s 已退=%s 可退=%s" % (TOTAL, REFUNDED, (TOTAL or 0) - (REFUNDED or 0))) if OSTATUS not in ("SUCCESS", "PARTIAL_REFUNDED"): raise SystemExit("订单状态 %s 不可退,中止" % OSTATUS) FEE = WANT_FEE if WANT_FEE is not None else (TOTAL - REFUNDED) if FEE <= 0 or FEE > (TOTAL - REFUNDED or 0): raise SystemExit("退款金额 %s 不在可退区间内,中止" % FEE) cur.execute("SELECT api_key, api_secret FROM sys_tenant_api_key " "WHERE tenant_id = %s ORDER BY id LIMIT 1", (TENANT,)) k = cur.fetchone() if not k: raise SystemExit("租户 %s 下一把 api_key 都没有" % TENANT) API_KEY, API_SECRET = k say(" 用该租户的 key:api_key= api_secret= (值不打印)" % (len(API_KEY), len(API_SECRET))) oRefundNo = ("RF%s%s" % (time.strftime("%H%M%S"), uuid.uuid4().hex[:6]))[:32] say(" out_refund_no = prefix=RF len=%d 退款 %s 分" % (len(oRefundNo), FEE)) # ---- 1. 退款 say("") say("=" * 70) say(" 第 1 步 POST /payment/openapi/pay/refund") say("=" * 70) body = {"out_trade_no": ORDER, "out_refund_no": oRefundNo, "refund_fee": FEE} st, parsed, p = call("/payment/openapi/pay/refund", body, API_KEY, API_SECRET, "refund") say("HTTP %s 原始应答 %d 字节 → %s" % (st, os.path.getsize(p), os.path.relpath(p, ROOT))) say(json.dumps(parsed, ensure_ascii=False, indent=2)) data = parsed.get("data") if isinstance(parsed, dict) else None row("退款", "HTTP %s" % st, "status=%s" % ((data or {}).get("status"))) # ---- 2. 退款查询 say("") say("=" * 70) say(" 第 2 步 POST /payment/openapi/pay/refund/query") say("=" * 70) st2, parsed2, p2 = call("/payment/openapi/pay/refund/query", {"out_refund_no": oRefundNo}, API_KEY, API_SECRET, "refundquery") say("HTTP %s 原始应答 %d 字节 → %s" % (st2, os.path.getsize(p2), os.path.relpath(p2, ROOT))) say(json.dumps(parsed2, ensure_ascii=False, indent=2)) row("退款查询", "HTTP %s" % st2, "local_status=%s" % (((parsed2.get("data") or {}) if isinstance(parsed2, dict) else {}).get("local_status"))) # ---- 3. 查单(看订单侧是否被回写) say("") say("=" * 70) say(" 第 3 步 POST /payment/openapi/pay/order/query") say("=" * 70) st3, parsed3, p3 = call("/payment/openapi/pay/order/query", {"out_trade_no": ORDER}, API_KEY, API_SECRET, "orderquery") say("HTTP %s 原始应答 %d 字节 → %s" % (st3, os.path.getsize(p3), os.path.relpath(p3, ROOT))) say(json.dumps(parsed3, ensure_ascii=False, indent=2)) row("查单", "HTTP %s" % st3, "local_status=%s" % (((parsed3.get("data") or {}) if isinstance(parsed3, dict) else {}).get("local_status"))) # ---- 4. 直连库复核 say("") say("=" * 70) say(" 第 4 步 直连库复核(不信接口自述)") say("=" * 70) conn.rollback() # 丢掉只读事务快照,读最新 # 列名逐个取自 LsyRefundEntity(含 PaymentBaseEntity 继承来的 status)——此处原写 refund_reason, # 那一列**本表没有**(它在汇安融那张表上),一跑就 UndefinedColumn。别凭记忆改这里,去读实体。 cur.execute("""SELECT out_refund_no, refund_no, status, refund_fee, refund_trace_no, upstream_result_code, upstream_result_msg, created_time, updated_time FROM pay_lsy_refund WHERE out_refund_no = %s""", (oRefundNo,)) r = cur.fetchone() if r: cols = ["out_refund_no", "refund_no", "status", "refund_fee", "refund_trace_no", "upstream_result_code", "upstream_result_msg", "created_time", "updated_time"] for c, v in zip(cols, r): say(" %-22s = %s" % (c, v)) row("退款单落库", "status=%s" % r[2], "fee=%s refund_no=%s" % (r[3], r[1])) else: say(" !pay_lsy_refund 里没有 out_refund_no=%s 这一行" % oRefundNo) row("退款单落库", "缺行", "") cur.execute("""SELECT status, total_amount, refunded_amount FROM pay_lsy_order WHERE out_trade_no = %s""", (ORDER,)) o2 = cur.fetchone() say("") say(" 订单侧:status=%s total=%s refunded_amount=%s" % o2) row("订单回写", "status=%s" % o2[0], "refunded=%s" % o2[2]) # 退款回调(trade_type=0)—— 上送上游的 notify_url 指向公网域名,那台与本机共用同一个库, # 所以这里**可能**看得到它写的行;看到即证「退款回调确实到达并被处理」。 cur.execute("""SELECT id, to_char(created_time,'HH24:MI:SS.MS'), trade_type, refund_no, verify_status, process_status, tenant_id, error_msg FROM pay_lsy_notify_log WHERE created_time > now() - interval '15 minutes' ORDER BY id""") nl = cur.fetchall() say("") say("近 15 分钟 pay_lsy_notify_log 行数 = %d" % len(nl)) for n in nl: say(" %s" % (n,)) row("回调日志", "%d 行" % len(nl), "") conn.close() say("") say("=" * 70) say(" 汇总") say("=" * 70) for t, v, d in rows: say(" %-12s | %-22s | %s" % (t, v, d)) io.open(OUT, "w", encoding="utf-8").write("\n".join(buf) + "\n") say("") say("正文已写入 %s" % os.path.relpath(OUT, ROOT))