|
|
@@ -156,6 +156,7 @@ class LsyNotifyServiceTest {
|
|
|
r.setOrderId(1L);
|
|
|
r.setRefundNo("R001");
|
|
|
r.setOutRefundNo("RF001");
|
|
|
+ r.setRefundTraceNo("RT001");
|
|
|
r.setRefundFee(30L);
|
|
|
r.setStatus(status);
|
|
|
return r;
|
|
|
@@ -164,24 +165,48 @@ class LsyNotifyServiceTest {
|
|
|
/**
|
|
|
* 退款回调报文 —— 词表与支付侧不同(TRADE_REFUND / TRADE_REFUND_FAIL),见实现类注释。
|
|
|
*
|
|
|
- * ★★ **本夹具刻意不含 `refund_no`,因为它在上游报文里不存在**(2026-09-22 实测上游
|
|
|
- * opendocs「退款异步回调」全页:完整 24 行字段表与请求示例里 `refund_no` /
|
|
|
- * `out_refund_no` / `refund_trace_no` / `qd_refund_no` **一个都没有**)。
|
|
|
- * 此前这里塞过一个 `refund_no="R001"` —— 那是**凭空字段**(出自 Task 12 brief 的测试样例,
|
|
|
- * 不是上游文档),它让报文看上去是对的,配合下面各用例的 `selectOne(any())` 打桩,
|
|
|
- * 使「定位键恒为 null」这个缺陷**在测试里完全不可观测**。
|
|
|
- * 摘掉它之后夹具才与真实报文同形;缺陷本身由
|
|
|
- * {@link #refundNotify_realPayloadHasNoRefundNo_soLookupKeyIsAlwaysNull} 正面钉住。
|
|
|
+ * ★★ **本夹具的字段集取自 2026-09-29 那笔实弹退款回调(25 个键,报文原文见
|
|
|
+ * `logs/_lsy-refund-callback-raw.json`),值一律换成测试值。** 复刻的是**结构与语义**,
|
|
|
+ * 不是那笔交易的值。三条读数才是要照抄的部分:
|
|
|
*
|
|
|
- * <p>需要 `refund_no` 的用例(审计列宽那条)自行 `put` —— 它测的是**未认证入口的截断**,
|
|
|
- * 攻击者本来就能塞任意字段,与上游发不发无关。
|
|
|
+ * - `pay_trace_no` = **本笔退款的** trace,与我们随退款请求发出去的 `refund_trace_no`
|
|
|
+ * (`LsyRefundService` 里 `IdUtil.fastSimpleUUID()` 生成)逐字符相同 ⇒ 它才是定位键;
|
|
|
+ * - `trade_type` = `"0"`(我方设计文档 §4.2 记的枚举:0退款/1交易/2云订单关单),
|
|
|
+ * 与支付侧的 `"1"` 互斥;
|
|
|
+ * - `refund_no` / `out_refund_no` / `refund_trace_no` / `qd_refund_no` **一个都没有**
|
|
|
+ * —— 上游 opendocs「退款异步回调」全页(24 行字段表 + 请求示例)早有此结论,
|
|
|
+ * 实弹报文与它一致。此前夹具里塞过一个 `refund_no="R001"`,那是**凭空字段**
|
|
|
+ * (出自 Task 12 brief 的测试样例,不是上游文档),使「定位键恒为 null」这个缺陷
|
|
|
+ * 在测试里完全不可观测。
|
|
|
+ *
|
|
|
+ * <p>需要 `refund_no` / 需要删字段的用例自行增删 —— 那几条测的正是**兜底腿**与
|
|
|
+ * **入口截断**,与上游发不发无关。
|
|
|
*/
|
|
|
private Map<String, Object> refundBody(String resultCode) {
|
|
|
Map<String, Object> m = new LinkedHashMap<>();
|
|
|
m.put("return_code", "SUCCESS");
|
|
|
+ m.put("return_msg", "退款成功");
|
|
|
m.put("result_code", resultCode);
|
|
|
+ m.put("trade_type", "0");
|
|
|
m.put("trade_no", "T001");
|
|
|
- m.put("mch_no", "Y52211090004");
|
|
|
+ m.put("td_trade_no", "");
|
|
|
+ m.put("pay_trace_no", "RT001");
|
|
|
+ m.put("o_trade_no", "P20260929001");
|
|
|
+ m.put("qd_trade_no", "Q20260929001");
|
|
|
+ m.put("mch_name", "MCH_NAME");
|
|
|
+ m.put("inst_no", "INST001");
|
|
|
+ m.put("shop_no", "");
|
|
|
+ m.put("shop_name", "");
|
|
|
+ m.put("pay_type", "400");
|
|
|
+ m.put("total_amount", "30");
|
|
|
+ m.put("pay_amount", "-1");
|
|
|
+ m.put("receipt_amount", "-1");
|
|
|
+ m.put("coupon_amount", "0");
|
|
|
+ m.put("discount_amount", "0");
|
|
|
+ m.put("user_id", "U001");
|
|
|
+ m.put("pay_time", "2026-09-29 11:04:12");
|
|
|
+ m.put("end_time", "2026-09-29 11:04:13");
|
|
|
+ m.put("attach", "");
|
|
|
return m;
|
|
|
}
|
|
|
|
|
|
@@ -303,6 +328,13 @@ class LsyNotifyServiceTest {
|
|
|
// 不写任何终态、不通知下游;但仍应答上游 SUCCESS(免无限重推)
|
|
|
verify(orderService, never()).applyPayResult(any(), any(), any());
|
|
|
verify(callbackService, never()).notifyOrder(any());
|
|
|
+
|
|
|
+ // ★ 顺带当**「入口分流」的阴性对照**:支付腿的未知码不许被分流到退款腿。
|
|
|
+ // `payBody` 的 trade_type="1"(交易)、result_code 也不是 TRADE_REFUND* ——
|
|
|
+ // 两条判据都不成立。若哪天判据被放宽成「认不出来就当退款」,本条立刻红。
|
|
|
+ // 没有这一句时它照样绿:分流走错了也满足上面两条 never()(退款腿也不写订单终态),
|
|
|
+ // 于是缺陷在测试里不可见。
|
|
|
+ verify(refundMapper, never()).selectOne(any());
|
|
|
}
|
|
|
|
|
|
@Test
|
|
|
@@ -531,72 +563,130 @@ class LsyNotifyServiceTest {
|
|
|
verify(callbackService).notifyOrder(any());
|
|
|
}
|
|
|
|
|
|
- // ================= 退款回调的定位键(2026-09-22,表征 + 阳性对照)=================
|
|
|
+ // ================= 退款回调的定位键(2026-09-29 由实弹报文裁定)=================
|
|
|
|
|
|
/**
|
|
|
- * ★★ **表征测试:钉住「退款回调定位键恒为 null」这个已知缺口。**
|
|
|
- *
|
|
|
- * <p>【缺口是什么】`handleRefundNotify` 按 `params.get("refund_no")` 查退款单,
|
|
|
- * 而上游退款回调报文里**没有这个字段**(实测上游 opendocs「退款异步回调」全页:
|
|
|
- * 完整 24 行字段表与请求示例里 `refund_no` / `out_refund_no` / `refund_trace_no` /
|
|
|
- * `qd_refund_no` 一个都没有)。⇒ 定位**每一次都落空**,走 `REFUND_NOT_FOUND` 后返回,
|
|
|
- * 该分支之后的一切(三分支词表、幂等闸门、`LATE_REFUND_AFTER_TERMINAL`)在生产上是死代码。
|
|
|
- *
|
|
|
- * <p>【为什么这里必须断言 wrapper 而不是断言"没找到"】断言结果没有信息量 ——
|
|
|
- * `selectOne` 是 mock,返回什么由用例自己 stub。**唯一能证明「键是 null」的读数,
|
|
|
- * 是去看 wrapper 里绑的值**。这也正是此前全绿的原因:`any()` 把 wrapper 吃掉了。
|
|
|
- *
|
|
|
- * <p>【为什么断言的是缺陷而不是断言"应该找到"】改定位键需要先知道上游到底用哪个字段指代
|
|
|
- * 一笔退款 —— 文档**没有定义** `o_trade_no` / `pay_trace_no` 在退款回调里指「原支付的」
|
|
|
- * 还是「本笔退款的」,两种读法都自洽且结论相反。按项目铁律**不得猜测**,故挂起。
|
|
|
- * 本条的作用是让缺口**在测试里可见**:谁改定位键,谁就必须来改本条,
|
|
|
- * 而不是让一个凭空字段继续把缺陷盖住。
|
|
|
+ * ★★ **主键:报文的 `pay_trace_no` → 表里的 `refund_trace_no`。**
|
|
|
*
|
|
|
- * <p>【影响面:为什么挂起是可以接受的】异步退款的**收敛**已由退款对账腿覆盖
|
|
|
- * (`LsyReconcileScheduler.reconcilePendingRefunds` 按 `status` + `created_time` 扫描,
|
|
|
- * **不依赖任何上游字段**),所以不存在「钱动了我们不知道」。回调从「唯一收敛通道」
|
|
|
- * 降级为一条从未通电的死腿。
|
|
|
+ * <p>【在此之前这里钉的是什么】原先是
|
|
|
+ * `refundNotify_realPayloadHasNoRefundNo_soLookupKeyIsAlwaysNull` —— 一条**表征测试**,
|
|
|
+ * 钉住「按 `refund_no` 查、而上游报文不含该字段 ⇒ 绑定值恒为 null ⇒ 恒 0 行」这个缺口,
|
|
|
+ * 并记着「重新定位键**挂起,不猜**」。挂起的理由是:文档**没有定义**
|
|
|
+ * `o_trade_no` / `pay_trace_no` 在退款回调里指「原支付的」还是「本笔退款的」,
|
|
|
+ * 两种读法都自洽且结论相反。**这个理由现在被实弹读数解除了**:
|
|
|
+ * 2026-09-29 那笔真实退款(1 分)的回调里 `pay_trace_no = 4e8ceec2b83b44a78efaacd73499b5a9`,
|
|
|
+ * 与我们随退款请求发出去的 `refund_trace_no` **逐字符相同** —— 是我们选的值、由上游原样回传,
|
|
|
+ * 因果闭合,不是同形巧合。报文原文见 `logs/_lsy-refund-callback-raw.json`。
|
|
|
*
|
|
|
- * <p>裁定与证据全文见
|
|
|
- * `docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md` §一。
|
|
|
+ * <p>【为什么断言 wrapper 而不是断言「找到了」】`selectOne` 是 mock,返回什么由用例自己 stub,
|
|
|
+ * 断言结果没有信息量。**唯一能证明「用的是哪一列、绑的哪个值」的读数,是去看 wrapper**。
|
|
|
+ * 这也正是此前全绿的原因:`any()` 把 wrapper 吃掉了。
|
|
|
*/
|
|
|
@Test
|
|
|
- void refundNotify_realPayloadHasNoRefundNo_soLookupKeyIsAlwaysNull() {
|
|
|
+ void refundNotify_realPayload_locatesRefundByItsTraceNo() {
|
|
|
when(refundMapper.selectOne(any())).thenReturn(refund("REFUNDING"));
|
|
|
|
|
|
- // refundBody 与真实报文同形 —— 刻意不含 refund_no
|
|
|
service.handleRefundNotify(signed(refundBody("TRADE_REFUND")), "{}");
|
|
|
|
|
|
AbstractWrapper<?, ?, ?> w = capturedRefundLookup();
|
|
|
- assertTrue(w.getSqlSegment().contains("refund_no ="),
|
|
|
- "定位仍走 refund_no 列(本断言的对照;改定位键时本条应红);实测: " + w.getSqlSegment());
|
|
|
- assertNull(boundValue(w, "refund_no"),
|
|
|
- "★ 已知缺口:真实报文不含 refund_no ⇒ 绑定值恒为 null ⇒ PG 里 `refund_no = NULL` "
|
|
|
- + "恒为 UNKNOWN ⇒ 0 行 ⇒ 退款回调永远定位不到自己的退款单。"
|
|
|
- + "若此断言变红,说明定位键已被改动 —— 请一并更新本类的 "
|
|
|
- + "refundNotify_payloadCarryingRefundNo_wouldBeUsedAsLookupKey 与台账 §一。");
|
|
|
+ assertTrue(w.getSqlSegment().contains("refund_trace_no ="),
|
|
|
+ "定位必须走 refund_trace_no 列;实测: " + w.getSqlSegment());
|
|
|
+ assertEquals("RT001", boundValue(w, "refund_trace_no"),
|
|
|
+ "绑的必须是报文里的 pay_trace_no —— 它就是我们发出去的那个 refund_trace_no。"
|
|
|
+ + "若本断言变红,说明定位键又被改了,请一并更新本类夹具与 "
|
|
|
+ + "docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md §一");
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * **阳性对照**:证明上面那条断言的「看得见非 null」是成立的。
|
|
|
+ * **兜底腿**:报文里没有 `pay_trace_no` 时回落到 `refund_no`,且**只在它有值时才查**。
|
|
|
*
|
|
|
- * <p>没有这一条,`refundNotify_realPayloadHasNoRefundNo_soLookupKeyIsAlwaysNull` 读到 null
|
|
|
- * 可能是**断言机制本身失灵**(正则没匹配上、取值取错括号)—— 那样它就是个假绿。
|
|
|
- * 这里塞一个上游**不会发**的 `refund_no`,若定位键确实被读出来,值就该是它。
|
|
|
+ * <p>【这条腿为什么保留】`pay_trace_no` 在退款回调里指「本笔退款」这一读法目前只有**一笔**
|
|
|
+ * 实弹样本。若上游在某些场景改发原支付的 trace,主键会落空 —— 那时兜底救不回来
|
|
|
+ * (真实报文里本就没有 `refund_no`),但**也不会更坏**:仍是 `REFUND_NOT_FOUND`。
|
|
|
*
|
|
|
- * <p>两条合起来才是完整读数:**机制有效,且真实报文落进的是 null 那一侧。**
|
|
|
+ * <p>【为什么必须 `!= null` 才查】`.eq(col, null)` 在 MP 里**照发谓词**、绑一个真正的 null,
|
|
|
+ * 而 PG 里 `refund_no = NULL` 恒为 UNKNOWN ⇒ **恒 0 行**。拿一个已知查不到的条件去查库,
|
|
|
+ * 除了把意图藏起来没有任何作用。
|
|
|
*/
|
|
|
@Test
|
|
|
- void refundNotify_payloadCarryingRefundNo_wouldBeUsedAsLookupKey() {
|
|
|
+ void refundNotify_payloadWithoutPayTraceNo_fallsBackToRefundNo() {
|
|
|
when(refundMapper.selectOne(any())).thenReturn(refund("REFUNDING"));
|
|
|
|
|
|
Map<String, Object> body = refundBody("TRADE_REFUND");
|
|
|
- body.put("refund_no", "R001"); // 上游不发此字段,纯粹为了证明断言看得见非 null
|
|
|
+ body.remove("pay_trace_no"); // 真实报文不发这种形态,纯粹为了压兜底这条腿
|
|
|
+ body.put("refund_no", "R001");
|
|
|
|
|
|
service.handleRefundNotify(signed(body), "{}");
|
|
|
|
|
|
+ // capturedRefundLookup 内含 verify(...)(等价 times(1))⇒ 主键落空后**只**发一次查询
|
|
|
assertEquals("R001", boundValue(capturedRefundLookup(), "refund_no"),
|
|
|
- "断言机制必须能读出非 null 的定位键,否则上面那条的 null 读数不可信");
|
|
|
+ "主键缺失时必须落到 refund_no 兜底,否则这条腿是死的");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * **两个键都没有 ⇒ 一次库都不查**,直接记 `REFUND_NOT_FOUND`。
|
|
|
+ *
|
|
|
+ * <p>与上一条同源:`refund_no = NULL` 恒 0 行,所以「查了」与「没查」的**结果**完全一样,
|
|
|
+ * 区别只在读数上 —— 发出去的那次查询会让日志/追踪里出现一条看上去像「查过、没找到」的记录,
|
|
|
+ * 而真相是「根本没有可查的键」。这两种情形的处置不同(前者要查上游是不是发了新字段,
|
|
|
+ * 后者要查我们自己的退款单为什么没落库),所以不能混成同一个读数。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void refundNotify_withoutAnyLocatingKey_queriesNothingAndStillAcksSuccess() {
|
|
|
+ Map<String, Object> body = refundBody("TRADE_REFUND");
|
|
|
+ body.remove("pay_trace_no"); // 既无 pay_trace_no(→ refund_trace_no)也无 refund_no
|
|
|
+
|
|
|
+ String resp = service.handleRefundNotify(signed(body), "{}");
|
|
|
+
|
|
|
+ assertEquals("{\"return_code\":\"SUCCESS\",\"return_msg\":\"成功\"}", resp);
|
|
|
+ verify(refundMapper, never()).selectOne(any());
|
|
|
+
|
|
|
+ var captor = ArgumentCaptor.forClass(LsyNotifyLogEntity.class);
|
|
|
+ verify(notifyLogMapper, atLeastOnce()).insert(captor.capture());
|
|
|
+ assertTrue(captor.getAllValues().stream()
|
|
|
+ .anyMatch(r -> "REFUND_NOT_FOUND".equals(r.getProcessStatus())),
|
|
|
+ "无键可查也必须留痕;实际=" + processStatuses(captor));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★★ **分流**:退款回调打到**支付端点**上时,必须交给退款腿处理。
|
|
|
+ *
|
|
|
+ * <p>【缺陷是什么】上游只认**一个**回调地址(下单时配的 `notify_url`),退款通知也推它;
|
|
|
+ * 而我们的两个端点(`/notify/pay`、`/notify/refund`)是分开的。2026-09-29 实弹实测:
|
|
|
+ * 那笔退款的回调打在 `/notify/pay` 上,`handlePayNotify` 拿**退款的** trace 去查订单
|
|
|
+ * (必然查不到),写一条 `ORDER_NOT_FOUND` 就结束了 —— 退款腿一次都没通电。
|
|
|
+ * 后果不是「钱动了我们不知道」(收敛有对账腿兜底),而是:
|
|
|
+ * ① 退款回调这条**快通道**整条失效;② 每次退款都误写一条误导性的审计行
|
|
|
+ * (「上游推了一笔我们不认识的支付」),把真正该报警的那条信号淹掉。
|
|
|
+ *
|
|
|
+ * <p>【判据为什么是这两个】`result_code` 前缀 `TRADE_REFUND` 是退款侧独有的词表;
|
|
|
+ * `trade_type="0"` 是文档记的退款枚举(0退款/1交易/2云订单关单),与支付侧的 `"1"` 互斥。
|
|
|
+ * 两条都是上游语义、且**都不与支付侧重叠** —— 上面那条未知码用例是本条的阴性对照。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void refundPayloadArrivingAtThePayEndpoint_isRoutedToTheRefundLeg() {
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(refund("REFUNDING"));
|
|
|
+ when(refundService.applyNotifyResult(any(), any(), any(), any())).thenReturn(true);
|
|
|
+ when(orderMapper.selectById(1L)).thenReturn(order("SUCCESS"));
|
|
|
+ when(refundService.settleOrderAfterRefund(any(), anyLong())).thenReturn(true);
|
|
|
+
|
|
|
+ String resp = service.handlePayNotify(signed(refundBody("TRADE_REFUND")), "{}");
|
|
|
+
|
|
|
+ // ① 走的是退款腿(会去收敛订单),而不是支付腿的「查订单 → 找不到」
|
|
|
+ assertEquals("{\"return_code\":\"SUCCESS\",\"return_msg\":\"成功\"}", resp);
|
|
|
+ verify(orderMapper, never()).selectOne(any());
|
|
|
+ verify(refundService).settleOrderAfterRefund(any(), eq(30L));
|
|
|
+
|
|
|
+ // ② 审计行里不许出现 ORDER_NOT_FOUND,且 RECEIVED 只许一条
|
|
|
+ // (分流必须在支付侧写 RECEIVED **之前**,否则一次回调写两条 RECEIVED)
|
|
|
+ var captor = ArgumentCaptor.forClass(LsyNotifyLogEntity.class);
|
|
|
+ verify(notifyLogMapper, atLeastOnce()).insert(captor.capture());
|
|
|
+ assertTrue(captor.getAllValues().stream()
|
|
|
+ .noneMatch(r -> "ORDER_NOT_FOUND".equals(r.getProcessStatus())),
|
|
|
+ "退款回调不得被记成「订单不存在」;实际=" + processStatuses(captor));
|
|
|
+ assertEquals(1, captor.getAllValues().stream()
|
|
|
+ .filter(r -> "RECEIVED".equals(r.getProcessStatus())).count(),
|
|
|
+ "一次回调只写一条 RECEIVED;实际=" + processStatuses(captor));
|
|
|
}
|
|
|
|
|
|
@Test
|