Selaa lähdekoodia

feat(乐收赢): 订单详情补退款单端点,回调轨迹纳入退款行的 trace

用户 2026-09-29 报的两个症状,这是后端的另一半:
订单状态写着「已全额退款」,可详情里看不到任何退款;回调轨迹只有支付那四条。

第二个症状**是结构性的**,不是没渲染:`notifyLogs` 只按订单自己的 pay_trace_no
过滤,而退款回调行的 trace 是**退款单的** trace ⇒ 那些行在这一版查询下**永远**
取不出来,不管前端怎么画。改成 trace 集合(订单 trace ∪ 每笔退款的 refund_trace_no)。

新增 GET /payment/collection/order/{outTradeNo}/refunds:

- 权限串**复用** `module_payment:leshouying:order:list`,与详情/轨迹两个端点同一个
  ⇒ 菜单 SQL 不用改、不用重发授权(加上它是第 4 个复用同一串的端点,
  `grep -c` 该串在本控制器内 = 4)。
- 按 out_trade_no **等值**过滤并按 created_time 倒序。刻意**不用**退款列表那条
  `LIKE keyword` 腿代替:LIKE 给的是「单号包含本单号」的退款单,订单号互为前缀时
  两者会给出不同的行,且客户端收敛不回去。
- 订单不存在时走 requireOrder 的 404,与详情端点同一行为。

两个实现细节值得一提,都会**静默**出错:

- traces 为空时先返回空列表再查。`IN ()` 是非法 SQL —— 空集合直接发下去是数据库
  报错,不是空结果。(真到这一步的订单就是「没有退款也没 trace」的建单失败单。)
- `in(...)` 在 MyBatis-Plus 里渲染成**多个独立占位符**(IN (#{...1},#{...2})),
  不是把一个集合塞成一个参数。测试里按集合去找绑定值会找不到 —— 本类第一版
  5 条挂了 3 条,就是这个。辅助方法先调 getSqlSegment()(该调用才填充
  paramNameValuePairs)再解析占位符名。

两向变异(还原后 grep -rn MUTANT 无残留):
- notifyLogs 里 in(...) 改 eq(...) ⇒ 两条轨迹用例红
- refundsOf 里去掉 out_trade_no 等值条件 ⇒ 作用域用例红

LeshouyingOrderControllerTest 5/5 绿(本控制器此前**没有任何**测试类);
LeshouyingAdminWiringTest 6/6 绿;全量 916 绿。
alphaH 2 viikkoa sitten
vanhempi
sitoutus
851d622ada

+ 59 - 1
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOrderController.java

@@ -8,13 +8,16 @@ import com.payment.platform.common.response.PageResult;
 import com.payment.platform.common.response.Result;
 import com.payment.platform.module.payment.leshouying.entity.LsyNotifyLogEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
+import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
 import com.payment.platform.module.payment.leshouying.mapper.LsyNotifyLogMapper;
 import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
+import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
 import lombok.RequiredArgsConstructor;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
+import java.util.ArrayList;
 import java.util.List;
 
 /**
@@ -39,6 +42,7 @@ public class LeshouyingOrderController {
 
     private final LsyOrderMapper orderMapper;
     private final LsyNotifyLogMapper notifyLogMapper;
+    private final LsyRefundMapper refundMapper;
 
     @PreAuthorize("@perm.hasAny('module_payment:leshouying:order:list')")
     @GetMapping("/page")
@@ -85,18 +89,72 @@ public class LeshouyingOrderController {
      * ★ 这里也修掉一个静默失效:原写法把 `outTradeNo` 这个 `@PathVariable` **完全没用上**
      *   (只用了可选的 `payTraceNo`),于是 `/notify-logs` 对任何订单都返回同样的
      *   「本租户最近 200 条」—— 路径参数被无声忽略,前端却会以为拿到的是这笔单的轨迹。
+     *
+     * ★★ 2026-09-29:查的是 trace 的**集合**,不再只是订单自己的那一个。
+     *   退款回调的审计行绑的是**那笔退款的 trace**(上游退款报文里的 `pay_trace_no`
+     *   就是我们发出去的 `refund_trace_no`,见 {@code LsyNotifyService#handleRefundNotify}),
+     *   所以只按订单 trace 查时,退款回调行**结构上**不可能出现 —— 用户实测到的
+     *   「已全额退款,回调轨迹还是支付的那四条」就是这个原因,不是数据没落库。
      */
     @PreAuthorize("@perm.hasAny('module_payment:leshouying:order:list')")
     @GetMapping("/{outTradeNo}/notify-logs")
     public Result<List<LsyNotifyLogEntity>> notifyLogs(@PathVariable String outTradeNo) {
         LsyOrderEntity order = requireOrder(outTradeNo);
 
+        List<String> traces = new ArrayList<>();
+        if (order.getPayTraceNo() != null) {
+            traces.add(order.getPayTraceNo());
+        }
+        for (LsyRefundEntity r : refundsOf(order)) {
+            // 退款行的 refund_trace_no 建表即 NOT NULL 且唯一(V1.8),但这里是防御性过滤:
+            // 一个 null 进 IN 列表会变成 `IN (..., NULL)` —— 它不会报错,只会静默地永不匹配,
+            // 而「静默不匹配」正是本条要修的那类缺陷。
+            if (r.getRefundTraceNo() != null) {
+                traces.add(r.getRefundTraceNo());
+            }
+        }
+        if (traces.isEmpty()) {
+            // 无键可查时**不发查询**,直接空集:`IN ()` 不是合法 SQL,
+            // 而「发一个恒空的谓词」只会让调用方以为查过。
+            return Result.ok(List.of());
+        }
+
         LambdaQueryWrapper<LsyNotifyLogEntity> w = new LambdaQueryWrapper<>();
-        w.eq(LsyNotifyLogEntity::getPayTraceNo, order.getPayTraceNo());
+        w.in(LsyNotifyLogEntity::getPayTraceNo, traces);
         w.orderByDesc(LsyNotifyLogEntity::getCreatedTime).last("LIMIT 200");
         return Result.ok(notifyLogMapper.selectList(w));
     }
 
+    /**
+     * 本单的退款单列表 —— 订单抽屉的「退款」一节用它。
+     *
+     * <p>【为什么不是复用 {@code /payment/collection/refund/page?keyword=}}
+     * 那条腿是 `LIKE out_trade_no`:拿到的是「订单号**包含**本单号」的退款单,
+     * 而抽屉要的是「**属于**本单的」退款单。两者在订单号互为前缀时会给出不同的行,
+     * 且调用方无法在客户端把它收敛回去(page 接口没有等值过滤)。
+     *
+     * <p>【权限串为什么复用 `order:list` 而不新开一个】返回的是**本订单自己的**退款单,
+     * 而订单实体里本来就带着 `refunded_amount` —— 能看到这个订单的人已经看得到
+     * 「它被退过多少钱」。新开权限串反而会让抽屉对一部分人半残(余额看得见、明细看不见),
+     * 且要多一份菜单 SQL。
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:leshouying:order:list')")
+    @GetMapping("/{outTradeNo}/refunds")
+    public Result<List<LsyRefundEntity>> refunds(@PathVariable String outTradeNo) {
+        LsyOrderEntity order = requireOrder(outTradeNo);
+        return Result.ok(refundsOf(order));
+    }
+
+    /**
+     * 取本单的退款单。**先经 {@link #requireOrder}** 再过这里 —— 租户边界建在订单上,
+     * 与 {@code notifyLogs} 同一理由(见那边的 javadoc)。
+     */
+    private List<LsyRefundEntity> refundsOf(LsyOrderEntity order) {
+        return refundMapper.selectList(new LambdaQueryWrapper<LsyRefundEntity>()
+                .eq(LsyRefundEntity::getOutTradeNo, order.getOutTradeNo())
+                .orderByDesc(LsyRefundEntity::getCreatedTime));
+    }
+
     /**
      * 取本租户可见的订单,取不到一律 404。
      * ★ 与 Task 15 的 OpenAPI 侧同一条纪律:**绝不把「不存在」和「不属于你」区分开** ——

+ 211 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOrderControllerTest.java

@@ -0,0 +1,211 @@
+package com.payment.platform.module.payment.leshouying.controller;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.module.payment.leshouying.entity.LsyNotifyLogEntity;
+import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
+import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
+import com.payment.platform.module.payment.leshouying.mapper.LsyNotifyLogMapper;
+import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
+import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * 订单抽屉要用的两条只读查询:回调轨迹、本单的退款单。
+ *
+ * <p>【为什么这个类的断言落在 wrapper 上,而不是落在返回值上】
+ * `selectList` 是 mock,返回什么由用例自己 stub —— 断言返回值等于自己刚设的值,恒真。
+ * 唯一能证明「查的是哪些 key」的读数是**去看 wrapper 里绑的值**(同款手法见
+ * {@code LsyNotifyServiceTest#capturedRefundLookup})。
+ *
+ * <p>【缺口是什么】`notifyLogs` 原先只按订单自己的 `pay_trace_no` 查,
+ * 而**退款回调的审计行绑的是那笔退款的 trace**(上游报文里的 `pay_trace_no` 就是我们的
+ * `refund_trace_no`,见 `LsyNotifyService#handleRefundNotify`)⇒ 退款回调行**结构上**
+ * 永远不可能出现在订单的回调轨迹里。用户 2026-09-29 实测到的正是这个:
+ * 订单已「全额退款」,回调轨迹却仍然是支付的那几条。
+ */
+@ExtendWith(MockitoExtension.class)
+class LeshouyingOrderControllerTest {
+
+    @Mock private LsyOrderMapper orderMapper;
+    @Mock private LsyNotifyLogMapper notifyLogMapper;
+    @Mock private LsyRefundMapper refundMapper;
+
+    private LeshouyingOrderController controller;
+
+    @BeforeEach
+    void setUp() {
+        controller = new LeshouyingOrderController(orderMapper, notifyLogMapper, refundMapper);
+        // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在
+        // 无 Spring 上下文的单测中工作(否则抛 can not find lambda cache for this entity)。
+        // 本类**每一条**断言都读 wrapper,所以这一句是必需的,不是保险起见。
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), LsyOrderEntity.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), LsyRefundEntity.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), LsyNotifyLogEntity.class);
+    }
+
+    // ────────────────────────────── 工具 ──────────────────────────────
+
+    private LsyOrderEntity order() {
+        LsyOrderEntity o = new LsyOrderEntity();
+        o.setId(1L);
+        o.setTenantId(1L);
+        o.setOutTradeNo("OUT001");
+        o.setPayTraceNo("PT001");
+        o.setStatus("REFUNDED");
+        o.setTotalAmount(100L);
+        o.setRefundedAmount(100L);
+        return o;
+    }
+
+    private LsyRefundEntity refund(long id, String trace) {
+        LsyRefundEntity r = new LsyRefundEntity();
+        r.setId(id);
+        r.setTenantId(1L);
+        r.setOrderId(1L);
+        r.setOutTradeNo("OUT001");
+        r.setOutRefundNo("RF00" + id);
+        r.setRefundTraceNo(trace);
+        r.setRefundFee(30L);
+        r.setStatus("SUCCESS");
+        return r;
+    }
+
+    /** 取出审计表的 `selectList` 实际用掉的 wrapper。 */
+    @SuppressWarnings("unchecked")
+    private AbstractWrapper<?, ?, ?> capturedLogQuery() {
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<LsyNotifyLogEntity>> c =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
+        verify(notifyLogMapper).selectList(c.capture());
+        return (AbstractWrapper<?, ?, ?>) c.getValue();
+    }
+
+    @SuppressWarnings("unchecked")
+    private AbstractWrapper<?, ?, ?> capturedRefundQuery() {
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<LsyRefundEntity>> c =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
+        verify(refundMapper).selectList(c.capture());
+        return (AbstractWrapper<?, ?, ?>) c.getValue();
+    }
+
+    /**
+     * WHERE 里 `pay_trace_no IN (...)` 绑的**每一个**值。
+     *
+     * <p>★ 两个坑,都是实测踩出来的,不是保险起见:
+     * <ol>
+     *   <li><b>必须先把 `getSqlSegment()` 求出来</b> —— `getParamNameValuePairs()` 是在渲染 SQL 段
+     *       那一步才被填的。先读 map 会读到**空 map**,于是「一个值都没有」与「断言写错了」
+     *       两种情形长得一模一样(第一版就是这么红的)。</li>
+     *   <li><b>`in(...)` 的元素是逐个占位符,不是一个 Collection</b> ——
+     *       渲染出来是 `IN (#{...MPGENVAL1},#{...MPGENVAL2},#{...MPGENVAL3})`。
+     *       按「找 map 里那个 Collection」去取会一个都找不到。</li>
+     * </ol>
+     */
+    private static List<Object> inValues(AbstractWrapper<?, ?, ?> w) {
+        String sql = w.getSqlSegment();                 // ← 必须先求值,见上
+        int i = sql.indexOf("pay_trace_no IN (");
+        assertTrue(i >= 0, "WHERE 里应有 pay_trace_no IN (...);实测: " + sql);
+        String tail = sql.substring(i, sql.indexOf(')', i));
+        Matcher m = Pattern.compile("paramNameValuePairs\\.([A-Za-z0-9_]+)").matcher(tail);
+        List<Object> out = new ArrayList<>();
+        while (m.find()) {
+            out.add(w.getParamNameValuePairs().get(m.group(1)));
+        }
+        assertFalse(out.isEmpty(), "IN 列表不该是空的;实测: " + sql);
+        return out;
+    }
+
+    /** WHERE 里绑定的**所有**值(同样必须在 `getSqlSegment()` 之后读)。 */
+    private static List<Object> boundValues(AbstractWrapper<?, ?, ?> w) {
+        w.getSqlSegment();
+        return new ArrayList<>(w.getParamNameValuePairs().values());
+    }
+
+    // ───────────────────────── 回调轨迹 ─────────────────────────
+
+    @Test
+    void notifyLogs_coversTheOrderTraceAndEveryRefundTrace() {
+        when(orderMapper.selectOne(any())).thenReturn(order());
+        when(refundMapper.selectList(any())).thenReturn(List.of(refund(9L, "RT001"), refund(10L, "RT002")));
+        when(notifyLogMapper.selectList(any())).thenReturn(List.of());
+
+        controller.notifyLogs("OUT001");
+
+        AbstractWrapper<?, ?, ?> w = capturedLogQuery();
+        assertTrue(w.getSqlSegment().contains("pay_trace_no IN"),
+                "必须按 pay_trace_no 的**集合**查;实测: " + w.getSqlSegment());
+        assertEquals(Set.of("PT001", "RT001", "RT002"), new HashSet<>(inValues(w)),
+                "订单自身的 trace + 每一笔退款的 trace 缺一不可 —— 少任何一个,"
+                        + "对应的回调行就结构性地看不见(这正是用户看到的现象)");
+    }
+
+    @Test
+    void notifyLogs_withoutAnyRefund_stillQueriesTheOrderTraceAlone() {
+        when(orderMapper.selectOne(any())).thenReturn(order());
+        when(refundMapper.selectList(any())).thenReturn(List.of());
+        when(notifyLogMapper.selectList(any())).thenReturn(List.of());
+
+        controller.notifyLogs("OUT001");
+
+        assertEquals(Set.of("PT001"), new HashSet<>(inValues(capturedLogQuery())),
+                "没有退款时行为必须与改动前一致(IN 一个元素的语义等于等值)");
+    }
+
+    @Test
+    void notifyLogs_unknownOrder_is404_andQueriesNothingElse() {
+        when(orderMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.notifyLogs("NOPE"));
+
+        // 与 requireOrder 的既有纪律一致:不区分「不存在」与「不属于你」
+        assertEquals(404, e.getCode());
+        verify(refundMapper, never()).selectList(any());
+        verify(notifyLogMapper, never()).selectList(any());
+    }
+
+    // ───────────────────────── 本单的退款单 ─────────────────────────
+
+    @Test
+    void orderRefunds_areScopedToThisOrder() {
+        when(orderMapper.selectOne(any())).thenReturn(order());
+        when(refundMapper.selectList(any())).thenReturn(List.of(refund(9L, "RT001")));
+
+        controller.refunds("OUT001");
+
+        AbstractWrapper<?, ?, ?> w = capturedRefundQuery();
+        assertTrue(w.getSqlSegment().contains("out_trade_no ="),
+                "退款单必须按本单过滤;实测: " + w.getSqlSegment());
+        assertTrue(boundValues(w).contains("OUT001"),
+                "过滤条件绑的必须是**本单**的订单号;实测绑定值=" + boundValues(w));
+    }
+
+    @Test
+    void orderRefunds_unknownOrder_is404() {
+        when(orderMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.refunds("NOPE"));
+
+        assertEquals(404, e.getCode());
+        verify(refundMapper, never()).selectList(any());
+    }
+}