Procházet zdrojové kódy

test(乐收赢): 退款回调修复的两支探针(直连库读数 + HTTP 端到端)

修复有没有生效,之前只有单测说得了话。补两支探针,一支量库、一支量接口。

**两支探针的量具不同,读数不能互相替代**(这是它俩分开写的理由):

1. `logs/_lsy-order-refunds-probe.py` —— psycopg2 直连库,跑与新端点**同一条**
   WHERE / ORDER BY。它证明的是「按 out_trade_no 能查到这几行、字段长这样」,
   **证明不了端点**:不过 Spring Security、不过 @PreAuthorize、不过 Jackson、
   不过租户拦截器。它自己的 docstring 里写着这句话。
   ★ 这一版顺带订正了它上一版的**越权表述**:原来逐行打印
   「前端要读的键 X 在**响应**里存在」,可它量的是**表里的列** —— 两回事。
   现在改成量 information_schema 并按实话说,最后一跳(实体 → JSON 键名)
   标注为「未由本脚本验证」。(键名那一跳另由读 `LsyRefundEntity` 与父类
   `PaymentBaseEntity` 源码确认:`status` 是**继承**来的,子类里根本没有这一行。)

2. `logs/_lsy-refunds-e2e.py` —— 走 HTTP 拿真令牌打两个端点,
   收到的是**页面真正会收到的那份 JSON**。这是「抽屉里有没有东西」的证据。

2026-09-29 在订单 BC1790649777 上的读数(该单已全额退款,totals 1/1):

- `/refunds` → HTTP 200,**1 行**,五列全有真值:
  `out_refund_no=RF1104113b4da0`、`refund_fee=1`、`status=SUCCESS`、
  `refund_no=200001070X4026092911041110001`、
  `finish_time=2026-09-29T11:04:13.007374+08:00`(微秒 6 位 ⇒ 前端那列宽度据此定的)。
- `/notify-logs` → HTTP 200,**6 行**(改前只按订单 trace 查是 4 行),
  多出的两行正是退款回调:`11:04:12.903 RECEIVED` 与 `11:04:12.937
  ORDER_NOT_FOUND`,两条 `trade_type=0`、trace 都是
  `4e8ceec2b83b44a78efaacd73499b5a9`(= 我方的 refund_trace_no)。
  这两行就是用户问「回调轨迹还是支付那四条」看到的缺的那两行;
  `ORDER_NOT_FOUND` 是**修复前**那次误判留下的历史行,重部署后不会再产生。

顺带修 `logs/_lsy-refund-live.py` 第 4 步的**列名错**:它 SELECT 了
`pay_lsy_refund.refund_reason`,而那一列本表**没有**(它在汇安融那张表上),
一跑就 UndefinedColumn。已换成 `refund_trace_no` 并注明列名逐个取自实体。
★ 该脚本**没有**重新端到端跑过 —— 它一跑就发第二笔真退款,不做。

两处都只读(SELECT / GET),不发写请求。
alphaH před 2 týdny
rodič
revize
f2002b6a46

+ 104 - 0
logs/_lsy-order-refunds-probe.py

@@ -0,0 +1,104 @@
+# -*- coding: utf-8 -*-
+"""只读探针:订单抽屉那条新查询,在**真库**上到底会不会返回行。
+
+它跑的是与新端点**同一条 SQL**(同一个 WHERE / ORDER BY),但**量具不是线上那支** ——
+真正的端点是 MyBatis 发出去的、还要过租户拦截器与 @PreAuthorize。本脚本只能证明
+「按 out_trade_no 能查到这几行、字段名与形态长这样」,证明不了端点本身。
+(这条区分的来历见 memory `probe-instrument-is-not-the-production-instrument`。)
+
+用法: python logs/_lsy-order-refunds-probe.py <out_trade_no> [...]
+只读:只发 SELECT。控制台 GBK ⇒ stdout 只打 ASCII,正文写 UTF-8 文件。
+"""
+import io
+import os
+import re
+import sys
+
+import psycopg2
+
+ROOT = r"D:\project2\payment-platform"
+YML = os.path.join(ROOT, "java", "src", "main", "resources", "application.yml")
+OUT = os.path.join(ROOT, "logs", "_lsy-order-refunds-probe.txt")
+
+buf = []
+
+
+def say(s=""):
+    buf.append(str(s))
+    print(str(s).encode("ascii", "replace").decode())
+
+
+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)
+
+
+orders = [a for a in sys.argv[1:] if not a.startswith("--")]
+if not orders:
+    raise SystemExit("用法: python logs/_lsy-order-refunds-probe.py <out_trade_no> [...]")
+
+conn = db()
+cur = conn.cursor()
+
+# 后端实体字段 → 全局 SNAKE_CASE 之后前端要读的键(这是本探针要复核的那件事)
+COLS = ("out_refund_no", "refund_fee", "status", "refund_no", "finish_time")
+
+for o in orders:
+    say("=" * 72)
+    say("out_trade_no = %s" % o)
+    cur.execute("""SELECT status, total_amount, refunded_amount
+                   FROM pay_lsy_order WHERE out_trade_no = %s""", (o,))
+    row = cur.fetchone()
+    say("  pay_lsy_order: %s" % (row,))
+    if not row:
+        say("  ! 库里没有这笔订单")
+        continue
+
+    # ↓↓↓ 与新端点 refundsOf() 同一条查询 ↓↓↓
+    cur.execute("""SELECT out_refund_no, refund_fee, status, refund_no, finish_time,
+                          refund_trace_no, created_time
+                   FROM pay_lsy_refund WHERE out_trade_no = %s
+                   ORDER BY created_time DESC""", (o,))
+    rows = cur.fetchall()
+    say("  新端点那条查询返回 %d 行" % len(rows))
+    for r in rows:
+        say("    " + " | ".join("%s=%s" % (c, v) for c, v in zip(
+            ("out_refund_no", "refund_fee", "status", "refund_no", "finish_time",
+             "refund_trace_no", "created_time"), r)))
+    # ★ 下面这几行量的是**表里的列**,不是**响应里的键** —— 两回事,别把标题写成后者。
+    #   列存在 ⇒ 该字段在实体上有映射(`LsyRefundEntity` 逐字段读过;`status` 来自父类
+    #   `PaymentBaseEntity`),SNAKE_CASE 之后键名与列名一致,故键也在。但这条推理链的
+    #   最后一跳**本脚本没跑**(它不发 HTTP、不过 Jackson),真正的端到端读数得由接口给。
+    cur.execute("""SELECT column_name FROM information_schema.columns
+                   WHERE table_name = 'pay_lsy_refund'""")
+    have = set(x[0] for x in cur.fetchall())
+    for c in COLS:
+        say("  表里的列 `%s` %s(响应键名同一套 SNAKE_CASE;端到端未由本脚本验证)"
+            % (c, "存在" if c in have else "**不存在**"))
+
+    # 回调轨迹那条:IN (订单 trace ∪ 每笔退款的 trace)。只数行,证明退款回调行确实存在。
+    cur.execute("""SELECT pay_trace_no FROM pay_lsy_order WHERE out_trade_no = %s""", (o,))
+    pt = cur.fetchone()[0]
+    cur.execute("""SELECT refund_trace_no FROM pay_lsy_refund WHERE out_trade_no = %s""", (o,))
+    traces = [pt] + [t[0] for t in cur.fetchall() if t[0]]
+    say("  回调轨迹的 trace 集合 = %d 个" % len(traces))
+    cur.execute("""SELECT to_char(created_time,'HH24:MI:SS.MS'), trade_type, verify_status,
+                          process_status, pay_trace_no
+                   FROM pay_lsy_notify_log WHERE pay_trace_no = ANY(%s)
+                   ORDER BY created_time DESC""", (traces,))
+    logs = cur.fetchall()
+    say("  回调轨迹命中 %d 行(改前只按订单 trace 查,命中 %d 行)"
+        % (len(logs), sum(1 for l in logs if l[4] == pt)))
+    for l in logs:
+        say("    %s" % (l,))
+
+conn.close()
+io.open(OUT, "w", encoding="utf-8").write("\n".join(buf) + "\n")
+say("")
+say("正文已写入 %s" % os.path.relpath(OUT, ROOT))

+ 18 - 0
logs/_lsy-order-refunds-probe.txt

@@ -0,0 +1,18 @@
+========================================================================
+out_trade_no = BC1790649777
+  pay_lsy_order: ('REFUNDED', 1, 1)
+  新端点那条查询返回 1 行
+    out_refund_no=RF1104113b4da0 | refund_fee=1 | status=SUCCESS | refund_no=200001070X4026092911041110001 | finish_time=2026-09-29 11:04:13.007374+08:00 | refund_trace_no=4e8ceec2b83b44a78efaacd73499b5a9 | created_time=2026-09-29 11:04:11.331934+08:00
+  表里的列 `out_refund_no` 存在(响应键名同一套 SNAKE_CASE;端到端未由本脚本验证)
+  表里的列 `refund_fee` 存在(响应键名同一套 SNAKE_CASE;端到端未由本脚本验证)
+  表里的列 `status` 存在(响应键名同一套 SNAKE_CASE;端到端未由本脚本验证)
+  表里的列 `refund_no` 存在(响应键名同一套 SNAKE_CASE;端到端未由本脚本验证)
+  表里的列 `finish_time` 存在(响应键名同一套 SNAKE_CASE;端到端未由本脚本验证)
+  回调轨迹的 trace 集合 = 2 个
+  回调轨迹命中 6 行(改前只按订单 trace 查,命中 4 行)
+    ('11:04:12.937', '0', 'SUCCESS', 'ORDER_NOT_FOUND', '4e8ceec2b83b44a78efaacd73499b5a9')
+    ('11:04:12.903', '0', 'SUCCESS', 'RECEIVED', '4e8ceec2b83b44a78efaacd73499b5a9')
+    ('10:43:35.995', '1', 'SUCCESS', 'DUPLICATE', '9df774f58716468e91addc1eb9ac400f')
+    ('10:43:35.954', '1', 'SUCCESS', 'RECEIVED', '9df774f58716468e91addc1eb9ac400f')
+    ('10:43:35.790', '1', 'SUCCESS', 'DUPLICATE', '9df774f58716468e91addc1eb9ac400f')
+    ('10:43:35.688', '1', 'SUCCESS', 'RECEIVED', '9df774f58716468e91addc1eb9ac400f')

+ 236 - 0
logs/_lsy-refund-live.py

@@ -0,0 +1,236 @@
+# -*- coding: utf-8 -*-
+"""退款实弹联调:拿**已有的一笔成功订单**走我方开放 API 退款,再查退款单。
+
+与 logs/_lsy-lifecycle.py 的 refund 段的区别:那个直连上游、绕过我方全链路,库里不留痕;
+这个走 `/payment/openapi/pay/refund`,验签 + 租户闸 + 退款单落库 + 上游退款全都要过。
+
+设计要点(与 _lsy-barcode-live.py 同规矩)
+  * 租户**从被退的那笔订单反查**,不按「第一个可用商户」挑 —— 挑错租户会红在
+    「订单不存在」上,那是夹具错不是被测对象错。
+  * key/secret 只打长度;单号只打长度与形态,不打印整串。
+  * 每条出站的**原始应答字节**落盘 logs/_lsy-rf/<tag>.bin,报告里只写结论。
+  * 控制台是 GBK ⇒ stdout 只打 ASCII,正文进 UTF-8 文件。
+  * 直连库复核,不信接口自述。
+
+用法: python logs/_lsy-refund-live.py <out_trade_no> [退款分,默认全额]
+"""
+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 <out_trade_no> [退款分]")
+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=<len %d>  api_secret=<len %d>   (值不打印)"
+    % (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))

+ 125 - 0
logs/_lsy-refunds-e2e.py

@@ -0,0 +1,125 @@
+# -*- coding: utf-8 -*-
+"""端到端:拿真令牌打**新端点**,看订单抽屉那两块(退款单 / 回调轨迹)到底收到什么。
+
+与 `_lsy-order-refunds-probe.py` 的分工(别把两者混为一谈):
+  * 那个探针直连库,跑「同一条 SQL」,**证明不了端点** —— 它自己 docstring 里写着这句。
+  * 这个脚本走 HTTP,过 Spring Security / @PreAuthorize / Jackson / 租户拦截器,
+    拿到的是**页面真正会收到的那份 JSON**。它才是「抽屉里有没有东西」的证据。
+
+只读:两个 GET,不写任何东西。
+凭据纪律:只打令牌**长度**;响应正文落 UTF-8 文件,stdout 只打 ASCII。
+控制台 GBK ⇒ 中文一律进文件。
+"""
+import io
+import json
+import os
+import sys
+import urllib.error
+import urllib.request
+
+ROOT = r"D:\project2\payment-platform"
+OUT = os.path.join(ROOT, "logs", "_lsy-refunds-e2e.txt")
+BASE = os.environ.get("LSY_ADMIN_BASE", "http://localhost:8001/api/v1")
+ORDER = sys.argv[1] if len(sys.argv) > 1 else "BC1790649777"
+
+buf = []
+
+
+def say(s=""):
+    buf.append(str(s))
+    print(str(s).encode("ascii", "replace").decode())
+
+
+def req(method, path, token=None, body=None):
+    """返回 (http_status, 解析后的 JSON 或原文)。业务错误也照样返回,由调用方判。"""
+    data = json.dumps(body).encode("utf-8") if body is not None else None
+    r = urllib.request.Request(BASE + path, data=data, method=method)
+    r.add_header("Content-Type", "application/json")
+    if token:
+        r.add_header("Authorization", "Bearer " + token)
+    try:
+        with urllib.request.urlopen(r, timeout=30) as resp:
+            raw = resp.read()
+            return resp.status, json.loads(raw.decode("utf-8"))
+    except urllib.error.HTTPError as e:
+        raw = e.read()
+        try:
+            return e.code, json.loads(raw.decode("utf-8"))
+        except Exception:
+            return e.code, raw.decode("utf-8", "replace")
+
+
+# ---- 1. 登录取令牌 -------------------------------------------------------
+say("=" * 74)
+say("  1. 取管理端令牌")
+say("=" * 74)
+st, cap = req("GET", "/system/auth/captcha/get")
+say("  captcha/get -> HTTP %s  enable=%s" % (st, (cap.get("data") or {}).get("enable")))
+
+token = None
+st, users = req("GET", "/system/auth/auto-login/users")
+say("  auto-login/users -> HTTP %s" % st)
+lst = (users.get("data") if isinstance(users, dict) else None) or []
+say("  免登录可选用户 %d 个" % len(lst))
+if lst:
+    uid = lst[0].get("id")
+    uname = lst[0].get("username")
+    say("  取第一个:username=%s id=%s" % (uname, uid))
+    st, t = req("POST", "/system/auth/auto-login/token?user_id=%s" % uid)
+    say("  auto-login/token -> HTTP %s" % st)
+    tok = ((t.get("data") or {}) if isinstance(t, dict) else {}).get("token")
+    if tok:
+        st, res = req("POST", "/system/auth/auto-login?token=%s" % tok)
+        say("  auto-login -> HTTP %s" % st)
+        token = ((res.get("data") or {}) if isinstance(res, dict) else {}).get("access_token")
+if token:
+    say("  令牌已取得:len=%d   (值不打印)" % len(token))
+else:
+    say("  !没能取得令牌,下面两个端点会 401 —— 那就是本脚本的结论,不要当成端点坏了")
+
+# ---- 2. 新端点 /refunds --------------------------------------------------
+say("")
+say("=" * 74)
+say("  2. GET /payment/collection/order/%s/refunds   (本次新加的端点)" % ORDER)
+say("=" * 74)
+st, refunds = req("GET", "/payment/collection/order/%s/refunds" % ORDER, token)
+say("  HTTP %s" % st)
+say(json.dumps(refunds, ensure_ascii=False, indent=2))
+rows = ((refunds.get("data") if isinstance(refunds, dict) else None) or [])
+say("  -> %d 行" % len(rows))
+for i, r in enumerate(rows):
+    say("    [%d] keys=%s" % (i, sorted(r.keys())))
+    # 前端「退款单」那一节的 5 列 + 状态映射要读的键,逐个点名
+    for k in ("out_refund_no", "refund_fee", "status", "refund_no", "finish_time"):
+        say("        %-14s = %r" % (k, r.get(k, "<缺失>")))
+
+# ---- 3. 回调轨迹 /notify-logs -------------------------------------------
+say("")
+say("=" * 74)
+say("  3. GET /payment/collection/order/%s/notify-logs  (trace 集合改过)" % ORDER)
+say("=" * 74)
+st, logs = req("GET", "/payment/collection/order/%s/notify-logs" % ORDER, token)
+say("  HTTP %s" % st)
+say(json.dumps(logs, ensure_ascii=False, indent=2))
+lg = ((logs.get("data") if isinstance(logs, dict) else None) or [])
+say("  -> %d 行(改前只按订单 trace 查,是 4 行)" % len(lg))
+n_refund = 0
+for x in lg:
+    tt = x.get("trade_type")
+    if tt == "0":
+        n_refund += 1
+    say("    %s tt=%s verify=%s process=%s trace=%s"
+        % (x.get("created_time"), tt, x.get("verify_status"),
+           x.get("process_status"), x.get("pay_trace_no")))
+say("  其中 trade_type=0(退款回调)的行 = %d" % n_refund)
+say("")
+say("  判定:")
+say("    退款单 %d 行(期望 1)、回调轨迹 %d 行含退款 %d 行(期望 6 / 2)"
+    % (len(rows), len(lg), n_refund))
+say("    => %s" % ("两项都变了,用户报的两个症状在 HTTP 层确认修好"
+                  if len(rows) == 1 and n_refund == 2
+                  else "与期望不符,见上面原始读数"))
+
+io.open(OUT, "w", encoding="utf-8").write("\n".join(buf) + "\n")
+say("")
+say("正文已写入 %s" % os.path.relpath(OUT, ROOT))

+ 176 - 0
logs/_lsy-refunds-e2e.txt

@@ -0,0 +1,176 @@
+==========================================================================
+  1. 取管理端令牌
+==========================================================================
+  captcha/get -> HTTP 200  enable=True
+  auto-login/users -> HTTP 200
+  免登录可选用户 33 个
+  取第一个:username=admin id=1
+  auto-login/token -> HTTP 200
+  auto-login -> HTTP 200
+  令牌已取得:len=606   (值不打印)
+
+==========================================================================
+  2. GET /payment/collection/order/BC1790649777/refunds   (本次新加的端点)
+==========================================================================
+  HTTP 200
+{
+  "code": 0,
+  "msg": "成功",
+  "data": [
+    {
+      "id": "2104769177627619330",
+      "status": "SUCCESS",
+      "description": null,
+      "created_time": "2026-09-29T11:04:11.331934+08:00",
+      "updated_time": "2026-09-29T11:19:40.289253+08:00",
+      "tenant_id": 2,
+      "order_id": 2104763835497803777,
+      "out_trade_no": "BC1790649777",
+      "out_refund_no": "RF1104113b4da0",
+      "refund_trace_no": "4e8ceec2b83b44a78efaacd73499b5a9",
+      "refund_no": "200001070X4026092911041110001",
+      "qd_refund_no": "2026092989141749",
+      "refund_fee": 1,
+      "refund_settle": null,
+      "upstream_result_code": "PAY_SUCCESS",
+      "upstream_result_msg": "退款成功",
+      "next_query_time": "2026-09-29T11:05:11.330942+08:00",
+      "refund_request_time": "20260929110411",
+      "original_pay_time": "20260929104257",
+      "notify_status": "FAIL",
+      "notify_count": 8,
+      "last_notify_time": "2026-09-29T11:19:40.288467+08:00",
+      "finish_time": "2026-09-29T11:04:13.007374+08:00"
+    }
+  ],
+  "status_code": 200,
+  "success": true
+}
+  -> 1 行
+    [0] keys=['created_time', 'description', 'finish_time', 'id', 'last_notify_time', 'next_query_time', 'notify_count', 'notify_status', 'order_id', 'original_pay_time', 'out_refund_no', 'out_trade_no', 'qd_refund_no', 'refund_fee', 'refund_no', 'refund_request_time', 'refund_settle', 'refund_trace_no', 'status', 'tenant_id', 'updated_time', 'upstream_result_code', 'upstream_result_msg']
+        out_refund_no  = 'RF1104113b4da0'
+        refund_fee     = 1
+        status         = 'SUCCESS'
+        refund_no      = '200001070X4026092911041110001'
+        finish_time    = '2026-09-29T11:04:13.007374+08:00'
+
+==========================================================================
+  3. GET /payment/collection/order/BC1790649777/notify-logs  (trace 集合改过)
+==========================================================================
+  HTTP 200
+{
+  "code": 0,
+  "msg": "成功",
+  "data": [
+    {
+      "id": "2104769184395755521",
+      "status": null,
+      "description": null,
+      "created_time": "2026-09-29T11:04:12.937943+08:00",
+      "updated_time": "2026-09-29T11:04:12.937943+08:00",
+      "tenant_id": null,
+      "trade_type": "0",
+      "trade_no": "200001070X4026092911041110001",
+      "pay_trace_no": "4e8ceec2b83b44a78efaacd73499b5a9",
+      "refund_no": null,
+      "raw_body": "{\"attach\":\"\",\"coupon_amount\":\"0\",\"discount_amount\":\"0\",\"end_time\":\"***\",\"inst_no\":\"900112\",\"mch_name\":\"建行小店基本休息修改\",\"mch_no\":\"Y52211090004\",\"o_trade_no\":\"200001070X***\",\"pay_amount\":\"-1\",\"pay_time\":\"***\",\"pay_trace_no\":\"4e8ceec2b83b44a78efaacd73499b5a9\",\"pay_type\":\"400\",\"qd_trade_no\":\"***\",\"receipt_amount\":\"-1\",\"result_code\":\"TRADE_REFUND\",\"return_code\":\"SUCCESS\",\"return_msg\":\"退款成功\",\"shop_name\":\"\",\"shop_no\":\"\",\"sign\":\"695351c2ec7129badfe726f5f6d168c3\",\"td_trade_no\":\"\",\"total_amount\":\"1\",\"trade_no\":\"200001070X***\",\"trade_type\":\"0\",\"user_id\":\"ogDZBwNxyOIt27zVTG-fXlrxOpLk\"}",
+      "verify_status": "SUCCESS",
+      "process_status": "ORDER_NOT_FOUND",
+      "error_msg": "订单不存在"
+    },
+    {
+      "id": "2104769184253149185",
+      "status": null,
+      "description": null,
+      "created_time": "2026-09-29T11:04:12.903813+08:00",
+      "updated_time": "2026-09-29T11:04:12.903813+08:00",
+      "tenant_id": null,
+      "trade_type": "0",
+      "trade_no": "200001070X4026092911041110001",
+      "pay_trace_no": "4e8ceec2b83b44a78efaacd73499b5a9",
+      "refund_no": null,
+      "raw_body": "{\"attach\":\"\",\"coupon_amount\":\"0\",\"discount_amount\":\"0\",\"end_time\":\"***\",\"inst_no\":\"900112\",\"mch_name\":\"建行小店基本休息修改\",\"mch_no\":\"Y52211090004\",\"o_trade_no\":\"200001070X***\",\"pay_amount\":\"-1\",\"pay_time\":\"***\",\"pay_trace_no\":\"4e8ceec2b83b44a78efaacd73499b5a9\",\"pay_type\":\"400\",\"qd_trade_no\":\"***\",\"receipt_amount\":\"-1\",\"result_code\":\"TRADE_REFUND\",\"return_code\":\"SUCCESS\",\"return_msg\":\"退款成功\",\"shop_name\":\"\",\"shop_no\":\"\",\"sign\":\"695351c2ec7129badfe726f5f6d168c3\",\"td_trade_no\":\"\",\"total_amount\":\"1\",\"trade_no\":\"200001070X***\",\"trade_type\":\"0\",\"user_id\":\"ogDZBwNxyOIt27zVTG-fXlrxOpLk\"}",
+      "verify_status": "SUCCESS",
+      "process_status": "RECEIVED",
+      "error_msg": null
+    },
+    {
+      "id": "2104763996284977154",
+      "status": null,
+      "description": null,
+      "created_time": "2026-09-29T10:43:35.995723+08:00",
+      "updated_time": "2026-09-29T10:43:35.995723+08:00",
+      "tenant_id": 2,
+      "trade_type": "1",
+      "trade_no": "200001070X4126092910425710001",
+      "pay_trace_no": "9df774f58716468e91addc1eb9ac400f",
+      "refund_no": null,
+      "raw_body": "{\"attach\":\"\",\"coupon_amount\":\"0\",\"discount_amount\":\"0\",\"end_time\":\"***\",\"inst_no\":\"900112\",\"mch_name\":\"建行小店基本休息修改\",\"mch_no\":\"Y52211090004\",\"pay_time\":\"***\",\"pay_trace_no\":\"9df774f58716468e91addc1eb9ac400f\",\"pay_type\":\"400\",\"qd_trade_no\":\"20260929MM7lrbnu\",\"receipt_amount\":\"1\",\"result_code\":\"PAY_SUCCESS\",\"return_code\":\"SUCCESS\",\"return_msg\":\"支付成功\",\"shop_name\":\"\",\"shop_no\":\"\",\"sign\":\"281c4e3d995fdf6d10acb193be53e421\",\"td_trade_no\":\"***\",\"total_amount\":\"1\",\"trade_no\":\"200001070X***\",\"trade_type\":\"1\",\"user_id\":\"ogDZBwNxyOIt27zVTG-fXlrxOpLk\"}",
+      "verify_status": "SUCCESS",
+      "process_status": "DUPLICATE",
+      "error_msg": null
+    },
+    {
+      "id": "2104763996108816386",
+      "status": null,
+      "description": null,
+      "created_time": "2026-09-29T10:43:35.954092+08:00",
+      "updated_time": "2026-09-29T10:43:35.954092+08:00",
+      "tenant_id": null,
+      "trade_type": "1",
+      "trade_no": "200001070X4126092910425710001",
+      "pay_trace_no": "9df774f58716468e91addc1eb9ac400f",
+      "refund_no": null,
+      "raw_body": "{\"attach\":\"\",\"coupon_amount\":\"0\",\"discount_amount\":\"0\",\"end_time\":\"***\",\"inst_no\":\"900112\",\"mch_name\":\"建行小店基本休息修改\",\"mch_no\":\"Y52211090004\",\"pay_time\":\"***\",\"pay_trace_no\":\"9df774f58716468e91addc1eb9ac400f\",\"pay_type\":\"400\",\"qd_trade_no\":\"20260929MM7lrbnu\",\"receipt_amount\":\"1\",\"result_code\":\"PAY_SUCCESS\",\"return_code\":\"SUCCESS\",\"return_msg\":\"支付成功\",\"shop_name\":\"\",\"shop_no\":\"\",\"sign\":\"281c4e3d995fdf6d10acb193be53e421\",\"td_trade_no\":\"***\",\"total_amount\":\"1\",\"trade_no\":\"200001070X***\",\"trade_type\":\"1\",\"user_id\":\"ogDZBwNxyOIt27zVTG-fXlrxOpLk\"}",
+      "verify_status": "SUCCESS",
+      "process_status": "RECEIVED",
+      "error_msg": null
+    },
+    {
+      "id": "2104763995425144834",
+      "status": null,
+      "description": null,
+      "created_time": "2026-09-29T10:43:35.790768+08:00",
+      "updated_time": "2026-09-29T10:43:35.790768+08:00",
+      "tenant_id": 2,
+      "trade_type": "1",
+      "trade_no": "200001070X4126092910425710001",
+      "pay_trace_no": "9df774f58716468e91addc1eb9ac400f",
+      "refund_no": null,
+      "raw_body": "{\"attach\":\"\",\"coupon_amount\":\"0\",\"discount_amount\":\"0\",\"end_time\":\"***\",\"inst_no\":\"900112\",\"mch_name\":\"建行小店基本休息修改\",\"mch_no\":\"Y52211090004\",\"pay_time\":\"***\",\"pay_trace_no\":\"9df774f58716468e91addc1eb9ac400f\",\"pay_type\":\"400\",\"qd_trade_no\":\"20260929MM7lrbnu\",\"receipt_amount\":\"1\",\"result_code\":\"PAY_SUCCESS\",\"return_code\":\"SUCCESS\",\"return_msg\":\"支付成功\",\"shop_name\":\"\",\"shop_no\":\"\",\"sign\":\"281c4e3d995fdf6d10acb193be53e421\",\"td_trade_no\":\"***\",\"total_amount\":\"1\",\"trade_no\":\"200001070X***\",\"trade_type\":\"1\",\"user_id\":\"ogDZBwNxyOIt27zVTG-fXlrxOpLk\"}",
+      "verify_status": "SUCCESS",
+      "process_status": "DUPLICATE",
+      "error_msg": null
+    },
+    {
+      "id": "2104763994984742913",
+      "status": null,
+      "description": null,
+      "created_time": "2026-09-29T10:43:35.68853+08:00",
+      "updated_time": "2026-09-29T10:43:35.68853+08:00",
+      "tenant_id": null,
+      "trade_type": "1",
+      "trade_no": "200001070X4126092910425710001",
+      "pay_trace_no": "9df774f58716468e91addc1eb9ac400f",
+      "refund_no": null,
+      "raw_body": "{\"attach\":\"\",\"coupon_amount\":\"0\",\"discount_amount\":\"0\",\"end_time\":\"***\",\"inst_no\":\"900112\",\"mch_name\":\"建行小店基本休息修改\",\"mch_no\":\"Y52211090004\",\"pay_time\":\"***\",\"pay_trace_no\":\"9df774f58716468e91addc1eb9ac400f\",\"pay_type\":\"400\",\"qd_trade_no\":\"20260929MM7lrbnu\",\"receipt_amount\":\"1\",\"result_code\":\"PAY_SUCCESS\",\"return_code\":\"SUCCESS\",\"return_msg\":\"支付成功\",\"shop_name\":\"\",\"shop_no\":\"\",\"sign\":\"281c4e3d995fdf6d10acb193be53e421\",\"td_trade_no\":\"***\",\"total_amount\":\"1\",\"trade_no\":\"200001070X***\",\"trade_type\":\"1\",\"user_id\":\"ogDZBwNxyOIt27zVTG-fXlrxOpLk\"}",
+      "verify_status": "SUCCESS",
+      "process_status": "RECEIVED",
+      "error_msg": null
+    }
+  ],
+  "status_code": 200,
+  "success": true
+}
+  -> 6 行(改前只按订单 trace 查,是 4 行)
+    2026-09-29T11:04:12.937943+08:00 tt=0 verify=SUCCESS process=ORDER_NOT_FOUND trace=4e8ceec2b83b44a78efaacd73499b5a9
+    2026-09-29T11:04:12.903813+08:00 tt=0 verify=SUCCESS process=RECEIVED trace=4e8ceec2b83b44a78efaacd73499b5a9
+    2026-09-29T10:43:35.995723+08:00 tt=1 verify=SUCCESS process=DUPLICATE trace=9df774f58716468e91addc1eb9ac400f
+    2026-09-29T10:43:35.954092+08:00 tt=1 verify=SUCCESS process=RECEIVED trace=9df774f58716468e91addc1eb9ac400f
+    2026-09-29T10:43:35.790768+08:00 tt=1 verify=SUCCESS process=DUPLICATE trace=9df774f58716468e91addc1eb9ac400f
+    2026-09-29T10:43:35.68853+08:00 tt=1 verify=SUCCESS process=RECEIVED trace=9df774f58716468e91addc1eb9ac400f
+  其中 trade_type=0(退款回调)的行 = 2
+
+  判定:
+    退款单 1 行(期望 1)、回调轨迹 6 行含退款 2 行(期望 6 / 2)
+    => 两项都变了,用户报的两个症状在 HTTP 层确认修好