Selaa lähdekoodia

feat(乐收赢): 订单加「退款在途」标记,退款途中列表与抽屉都不再亮着退款按钮

用户 2026-09-30 报的:「我点了退款,ly 回调送达前,页面上还有退款按钮,难道还能重复点吗?」
根因是退款在途的那段窗口里订单行一动都不动 —— 状态还是 SUCCESS(钱确实收过了),
要等退款成功才转成 PARTIAL_REFUNDED/REFUNDED ⇒「可退状态」这个判据在整段在途期恒为真。
后端本来有三道闸(服务层在途闸门 + 预插入 + 部分唯一索引 uk_lsy_refund_one_pending),
重复点不会重复出金,但界面不该引诱人去点。

LsyOrderEntity.refunding 是派生量(@TableField(exist = false)),
判据是 pay_lsy_refund 里存在 status ∈ LsyRefundStatus.PENDING 的行 ——
与后端闸门**同一集合**,不另立一套口径(两处各写各的,界面就会在闸门仍锁着时显示「可以退」)。
不落成数据库列:真值时刻在退款表里,存两份迟早不一致(回调、调度器、关单三条腿
都得记得同步它)。

由 LeshouyingOrderController#markRefunding 在列表与详情两处出口填充:
列表一页 20 行,一次 order_id IN (…) 查完,查询次数与页大小无关;
detail 也补这一处,否则抽屉会把列表刚藏掉的按钮又亮出来。
ids 全空时不发查询(IN () 不是合法 SQL),标记留 null —— 前端对 null 的判据是
「不知道」,与「确定没有」不同。

测试:LeshouyingOrderControllerTest 新增 4 条 —— 只有退款在途那一行被标记、
判据与后端闸门同一集合(PENDING = CREATED/REFUNDING;SUCCESS/FAIL 这两格不标记)、
detail 同样带上标记、空页一次退款表都不查(17 条全绿)。
alphaH 2 viikkoa sitten
vanhempi
sitoutus
41b0ed8992

+ 54 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOrderController.java

@@ -12,6 +12,7 @@ import com.payment.platform.module.payment.leshouying.dto.LsyOrderRefundDTO;
 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.enums.LsyRefundStatus;
 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;
@@ -25,7 +26,9 @@ import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 /**
  * 乐收赢订单管理 —— 走 JWT 认证。
@@ -93,9 +96,58 @@ public class LeshouyingOrderController {
         }
         w.orderByDesc(LsyOrderEntity::getCreatedTime);
         Page<LsyOrderEntity> r = orderMapper.selectPage(page, w);
+        markRefunding(r.getRecords());
         return Result.ok(PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords()));
     }
 
+    /**
+     * 给这一批订单补上「退款在途」标记(字段说明见 {@code LsyOrderEntity#refunding})。
+     *
+     * <p>【为什么是批量一次查,不是每行查一次】列表一页 20 行 ⇒ 逐行查就是 20 次
+     * 数据库往返,而这是个**只读**列表页。这里一次 {@code order_id IN (…)} 查完,
+     * 查询次数与页大小无关。
+     *
+     * <p>【为什么放在出口处而不是塞进 SQL】订单表与退款表是两张表,在 SQL 里表达这个
+     * 派生量要么多一个 {@code EXISTS} 子查询、要么多一处 join,两者都会让
+     * {@code selectPage} 的计数与分页语义变复杂({@code EXISTS} 版本还得手写 SQL 片段,
+     * MyBatis-Plus 的 {@code LambdaQueryWrapper} 表达不了)。20 行的一次 IN 查询便宜得多。
+     *
+     * <p>【租户边界】这里**不写**显式租户条件,与同类查询一致:已认证路径由
+     * {@code TenantInnerInterceptor} 追加谓词。列表里的 id 本来就只属于本租户
+     * (它们是上一步按租户查出来的),所以即便退化为无谓词,也只是多扫几行、**不会**串数据。
+     */
+    private void markRefunding(List<LsyOrderEntity> rows) {
+        if (rows == null || rows.isEmpty()) {
+            return;
+        }
+        List<Long> ids = new ArrayList<>();
+        for (LsyOrderEntity o : rows) {
+            if (o.getId() != null) {
+                ids.add(o.getId());
+            }
+        }
+        // 全是「没有 id 的行」= 上游数据已经坏了;此时**不发查询**(`IN ()` 不是合法 SQL),
+        // 标记一律留空而不是谎报 false —— 前端对 null 的判据是「不知道」,与「确定没有」不同。
+        if (ids.isEmpty()) {
+            return;
+        }
+        LambdaQueryWrapper<LsyRefundEntity> rw = new LambdaQueryWrapper<>();
+        rw.select(LsyRefundEntity::getOrderId)
+                .in(LsyRefundEntity::getOrderId, ids)
+                // ★ 与 LsyRefundService 的在途闸门**同一集合**(LsyRefundStatus.PENDING),
+                //   不另立一套口径:两处若各写各的,界面会在闸门仍锁着时显示「可以退」。
+                .in(LsyRefundEntity::getStatus, LsyRefundStatus.PENDING);
+        Set<Long> pending = new HashSet<>();
+        for (LsyRefundEntity rf : refundMapper.selectList(rw)) {
+            if (rf.getOrderId() != null) {
+                pending.add(rf.getOrderId());
+            }
+        }
+        for (LsyOrderEntity o : rows) {
+            o.setRefunding(pending.contains(o.getId()));
+        }
+    }
+
     /**
      * 超管在订单页按租户筛选。{@code tenantId} 为空 ⇒ **什么都不做**,隔离照旧交给拦截器。
      *
@@ -130,6 +182,8 @@ public class LeshouyingOrderController {
     @GetMapping("/{outTradeNo}")
     public Result<LsyOrderEntity> detail(@PathVariable String outTradeNo) {
         LsyOrderEntity o = requireOrder(outTradeNo);
+        // 抽屉里的退款按钮用同一个标记判可见性 —— 不补这一处,抽屉会把列表刚藏掉的按钮又亮出来。
+        markRefunding(List.of(o));
         return Result.ok(o);
     }
 

+ 21 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/entity/LsyOrderEntity.java

@@ -1,5 +1,6 @@
 package com.payment.platform.module.payment.leshouying.entity;
 
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableName;
 import com.fasterxml.jackson.databind.annotation.JsonSerialize;
 import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
@@ -70,4 +71,24 @@ public class LsyOrderEntity extends PaymentTenantBaseEntity {
     private OffsetDateTime lastNotifyTime;
 
     private OffsetDateTime finishTime;
+
+    /**
+     * 本单**是否有退款正在途中** —— 只读标记,**不是库里的列**({@code exist = false})。
+     *
+     * <p>【为什么要有它】退款的在途窗口里,订单行本身**一动都不动**:状态还是
+     * {@code SUCCESS}(钱确实收过了),要等退款成功才转成 {@code PARTIAL_REFUNDED/REFUNDED}。
+     * 于是「可退状态」这个判据在整段在途期**恒为真** ⇒ 界面上那枚退款按钮一直亮着,
+     * 用户 2026-09-30 报的正是这个:「我点了退款,回调送达前页面上还有退款按钮,难道还能重复点吗?」
+     * 后端有三道闸挡着(服务层的在途闸门 + 预插入 + 部分唯一索引
+     * {@code uk_lsy_refund_one_pending}),重复点**不会**重复出金,但界面不该引诱人去点。
+     *
+     * <p>【判据来源】{@code pay_lsy_refund} 里存在 {@code status ∈ LsyRefundStatus.PENDING}
+     * 的行(与后端闸门**同一集合**,不另立一套口径)。由
+     * {@code LeshouyingOrderController#markRefunding} 在列表/详情出口处批量填充。
+     *
+     * <p>【为什么不做成数据库列】它是**派生量**,真值时刻在退款表里。落成一列就等于
+     * 给同一件事存两份,两份迟早不一致(回调、调度器、关单三条腿都得记得同步它)。
+     */
+    @TableField(exist = false)
+    private Boolean refunding;
 }

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

@@ -389,4 +389,96 @@ class LeshouyingOrderControllerTest {
                 "取不到主体时**不能默认放行**(写成 Boolean.FALSE.equals 就会在这里放行)");
         verify(orderMapper, never()).selectPage(any(), any());
     }
+
+    // ─────────────── 退款在途标记:列表/详情出口处补的那个派生量 ───────────────
+    //
+    // 用户 2026-09-30 报的:「我点击退款后,lsy 回调送达前,页面上还有退款按钮,难道还能重复点吗?」
+    // 后端有三道闸(在途闸门 + 预插入 + 部分唯一索引 uk_lsy_refund_one_pending),重复点**不会**
+    // 重复出金 —— 但界面上那枚按钮在整段在途期一直亮着,因为它判的是**订单状态**,
+    // 而退款在途时订单状态一动不动(还是 SUCCESS)。下面两条用例钉的是「谁在途」这个新读数的来源。
+
+    /**
+     * 两条订单,退款表**按判据过滤之后**只剩一行在途的(orderId=2)。
+     *
+     * <p>★ 这里必须把「过滤」写进桩里,不能把两条退款行都给出去 —— mock 的
+     * {@code selectList} **不过滤**,谓词是数据库执行的。第一版就是这么写的
+     * (桩返回 [order1 的 SUCCESS, order2 的 REFUNDING]),于是 order1 也被标成在途,
+     * 断言红在 {@code expected: <false> but was: <true>}。
+     * 那次的教训是:**桩返回什么,就等于宣称数据库会返回什么** —— 桩里的两行
+     * 等于宣称「status IN (CREATED,REFUNDING) 会匹配到一行 SUCCESS」。
+     * 谓词本身对不对,由下一条用例直接读 wrapper 来钉。
+     */
+    private Page<LsyOrderEntity> pageOfTwoOrders() {
+        LsyOrderEntity first = order();                 // id=1:只有一笔**已成功**的退款
+        LsyOrderEntity second = order();                // id=2:有一笔退款在途
+        second.setId(2L);
+        second.setOutTradeNo("OUT002");
+        Page<LsyOrderEntity> p = new Page<>(1, 20);
+        p.setRecords(new ArrayList<>(List.of(first, second)));
+        when(orderMapper.selectPage(any(), any())).thenReturn(p);
+
+        LsyRefundEntity inFlight = refund(9L, "RT009");
+        inFlight.setOrderId(2L);
+        inFlight.setStatus("REFUNDING");
+        when(refundMapper.selectList(any())).thenReturn(List.of(inFlight));
+        return p;
+    }
+
+    @Test
+    void page_只有退款在途的那一行才标记为退款中() {
+        Page<LsyOrderEntity> p = pageOfTwoOrders();
+
+        controller.page(1, 20, null, null, null, null);
+
+        assertEquals(Boolean.FALSE, p.getRecords().get(0).getRefunding(),
+                "这张单的退款已结束、没有在途的 ⇒ 不在途,按钮该留着(否则退款过一次就再也退不了第二次)");
+        assertEquals(Boolean.TRUE, p.getRecords().get(1).getRefunding(),
+                "这张单有一笔 REFUNDING ⇒ 在途,界面必须据此把退款入口藏起来");
+    }
+
+    @Test
+    void 退款在途的判据_与后端闸门同一集合() {
+        pageOfTwoOrders();
+
+        controller.page(1, 20, null, null, null, null);
+
+        // ★ 这条不是为了覆盖率,是为了钉「口径只有一份」:界面藏按钮的判据必须与
+        //   LsyRefundService 的在途闸门(LsyRefundStatus.PENDING)是同一个集合。
+        //   两处各写各的,后果是闸门锁着而界面显示「可以退」—— 点下去才 400。
+        AbstractWrapper<?, ?, ?> w = capturedRefundQuery();
+        String sql = w.getSqlSegment();
+        assertTrue(sql.contains("status IN ("),
+                "必须按退款单状态过滤。**这条断言就是上一条用例里那个桩的前提** —— "
+                        + "少了它,「已结束的退款不算在途」全靠桩自己说了算。实测: " + sql);
+        List<Object> statuses = boundValues(w);
+        assertTrue(statuses.containsAll(List.of("CREATED", "REFUNDING")),
+                "在途 = CREATED/REFUNDING(与 LsyRefundStatus.PENDING 同集合);实测绑定值=" + statuses);
+        assertFalse(statuses.contains("SUCCESS"),
+                "已成功的退款**不是**在途;实测绑定值=" + statuses);
+        assertTrue(sql.contains("order_id IN ("),
+                "必须按本页那些订单号批量查(逐行查一页 20 行就是 20 次往返);实测: " + sql);
+    }
+
+    @Test
+    void detail_同样带上退款在途标记() {
+        when(orderMapper.selectOne(any())).thenReturn(order());
+        LsyRefundEntity inFlight = refund(9L, "RT009");
+        inFlight.setStatus("REFUNDING");
+        when(refundMapper.selectList(any())).thenReturn(List.of(inFlight));
+
+        LsyOrderEntity o = controller.detail("OUT001").getData();
+
+        assertEquals(Boolean.TRUE, o.getRefunding(),
+                "抽屉里的退款按钮用同一个标记判可见性 —— 不补这一处,列表刚藏掉的按钮会在抽屉里又亮出来");
+    }
+
+    @Test
+    void page_空页_一次退款表都不查() {
+        when(orderMapper.selectPage(any(), any())).thenReturn(new Page<>(1, 20));
+
+        controller.page(1, 20, null, null, null, null);
+
+        // 空页时 `IN ()` 不是合法 SQL —— 这条防的是「为了补标记而无条件发一次恒假查询」。
+        verify(refundMapper, never()).selectList(any());
+    }
 }