Explorar o código

feat(乐收赢): 管理端退款入口 —— 独立权限串,金额留空即退剩余

在此之前这条通道只有开放 API 能退款(`POST /payment/openapi/pay/refund`),
操作员在订单页上没有任何退款入口。

新增 `POST /payment/collection/order/{outTradeNo}/refund`:

- **权限串不复用 `order:list`**,另立 `module_payment:leshouying:order:refund`。
  复用不是「少一层保护」而是**放大**权限:任何能翻订单列表的人都能把任意一笔钱退出去。
  钉在 `LeshouyingOrderControllerTest#refund_有自己独立的权限串_不复用orderList`,
  菜单授权行在 `db/lsy-order-refund-menu.sql`,两处必须逐字一致。
  同页面的 `GET /refunds` 读端点复用 `order:list` 是有理由的(它返回的是本单自己的退款单,
  而订单实体本来就带 `refunded_amount`)—— 那套理由不成立在这里。

- **金额可省**:请求体整个不传 = 退剩余全额。剩余由 `LsyRefundService` 按
  `总额 − max(退款台账, 订单快照)` 算 —— 那个式子只该有一个副本,而它已经在那儿,
  所以解析留在原处,不搬到控制器(搬了就有两份,漂移的后果是「页面显示的数」与
  「真正退出去的钱」不是同一个)。

- **退款单号由服务端生成**(`RF` + 雪花)。`RF` 与收银台的 `SK`、开放 API 的调用方自带号
  三者都不共用 ⇒ 库里一眼可辨这张退款单从哪个入口发出,排查时不必再 join 操作人。

- 服务层用 `IllegalArgumentException` / `IllegalStateException` 表达业务前置校验,
  而接住它们的那两个 `@ExceptionHandler` 长在 `LeshouyingOpenController` 里 ——
  控制器局部处理器,管不到这里。⇒ 在本控制器里转成 400,否则操作员看到的是一句 500,
  真正的原因(「本单剩余 0 分」)在界面上一个字都不出现。
  不改成全局 `@RestControllerAdvice`:那会动到其它模块的异常语义,不在本批次范围
  (判断依据:本批只改乐收赢,线上其它模块正常运行中)。

`LsyRefundService#createRefund` 的 `refundFee` 由 `long` 改成 `Long`:
留空必须是「这个键不存在」而不是「金额是 0」,两条路在调用方看来一样、在日志里却分不开。
`null` 绕过了方法开头那道正数闸(它当时还不知道数额),所以在算出 remaining **之后
再判一次** —— 少了那一句,一张已退满的单会带着 `refund_fee=0` 一路打到上游。
开放 API 那条腿传的永远是具体数值,行为与改动前逐字一致。

只读核对脚本在 `db/lsy-order-refund-menu.sql`(★ 执行由用户做)。
alphaH hai 2 semanas
pai
achega
841f1b6678

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

@@ -1,17 +1,21 @@
 package com.payment.platform.module.payment.leshouying.controller;
 
+import cn.hutool.core.util.IdUtil;
 import cn.hutool.core.util.StrUtil;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.response.PageResult;
 import com.payment.platform.common.response.Result;
+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.mapper.LsyNotifyLogMapper;
 import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
 import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
+import com.payment.platform.module.payment.leshouying.service.LsyRefundService;
+import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.security.access.prepost.PreAuthorize;
@@ -40,9 +44,20 @@ import java.util.List;
 @RequiredArgsConstructor
 public class LeshouyingOrderController {
 
+    /**
+     * 退款单号前缀。与收银台(`SK`)、开放 API 那条腿(调用方自带的号)都**不共用** ——
+     * 库里一眼可辨这张退款单是从哪个入口发的,排查时不必再去 join 一次操作人。
+     */
+    private static final String REFUND_NO_PREFIX = "RF";
+
     private final LsyOrderMapper orderMapper;
     private final LsyNotifyLogMapper notifyLogMapper;
     private final LsyRefundMapper refundMapper;
+    /**
+     * 本控制器**唯一动钱**的依赖 —— 只有 {@link #refund} 用它。
+     * 退款的全部前置校验(可退状态、在途闸门、余额)都在那个服务里,这里不重写任何一条。
+     */
+    private final LsyRefundService refundService;
 
     @PreAuthorize("@perm.hasAny('module_payment:leshouying:order:list')")
     @GetMapping("/page")
@@ -145,6 +160,50 @@ public class LeshouyingOrderController {
         return Result.ok(refundsOf(order));
     }
 
+    /**
+     * 发起退款(动钱)。
+     *
+     * <p>这是本控制器第一个**动钱**的端点,也是本通道管理端唯一的退款入口 ——
+     * 在此之前只有开放 API 能退({@code POST /payment/openapi/pay/refund})。
+     * 同 {@code HarOrderController#refund} 的定位(那边也是该通道的第一个动钱端点)。
+     *
+     * <p>【权限串为什么**不复用** {@code order:list}】能查一笔单不等于能动这笔钱。
+     * 复用不是「少一层保护」而是**放大权限**:任何能翻订单列表的人都能把任意一笔钱退出去。
+     * 上面那个 {@code /refunds} 读端点复用 {@code order:list} 是有理由的(它返回的是本单自己的
+     * 退款单,而订单实体本来就带着 {@code refunded_amount})—— 那套理由**不成立**在这里。
+     * 后端这一侧由 {@code LeshouyingOrderControllerTest#refund_有自己独立的权限串_不复用orderList}
+     * 钉住,菜单那一侧的授权行在 {@code db/lsy-order-refund-menu.sql},两边必须逐字一致。
+     *
+     * <p>【请求体可省】只为了带金额;整个不传 = 退剩余全额。
+     * 退款单号由服务端生成({@code RF} + 雪花),**不接受**调用方指定:
+     * 操作员手打单号只会造出重复号,而重复号在这条腿上是钱的事。
+     *
+     * <p>【为什么把两个运行时异常在这里转成 400】{@code LsyRefundService.createRefund} 用
+     * {@code IllegalArgumentException}(金额非法 / 超过可退余额)与
+     * {@code IllegalStateException}(状态不可退 / 有退款在途)表达业务前置校验,
+     * 而**接住它们的那两个 {@code @ExceptionHandler} 长在 {@code LeshouyingOpenController} 里**
+     * —— 那是控制器局部处理器,管不到这里。不转的话操作员看到的是一句 500,
+     * 而真正的原因(「可退 0」)在界面上一个字都不出现。
+     * 不改成全局 {@code @RestControllerAdvice}:那会动到**其它模块**的异常语义,
+     * 而它们的线上行为不在本批次范围内。
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:leshouying:order:refund')")
+    @PostMapping("/{outTradeNo}/refund")
+    public Result<LsyRefundEntity> refund(@PathVariable String outTradeNo,
+                                          @Valid @RequestBody(required = false) LsyOrderRefundDTO dto) {
+        LsyOrderEntity order = requireOrder(outTradeNo);
+        String outRefundNo = REFUND_NO_PREFIX + IdUtil.getSnowflakeNextIdStr();
+        try {
+            // dto 整个不传、或传了 {} 但没填金额 —— 两种都交给服务层去算剩余(见那边 :140 附近的注释)
+            return Result.ok(refundService.createRefund(
+                    order, outRefundNo, dto == null ? null : dto.getRefundFee()));
+        } catch (IllegalArgumentException | IllegalStateException e) {
+            // 这两句 message 是**我们自己写的**(不是上游原文),可以原样给操作员。
+            // 与开放 API 那条腿同一档语义:业务前置校验不通过 = 400,不是 500。
+            throw new BusinessException(400, e.getMessage());
+        }
+    }
+
     /**
      * 取本单的退款单。**先经 {@link #requireOrder}** 再过这里 —— 租户边界建在订单上,
      * 与 {@code notifyLogs} 同一理由(见那边的 javadoc)。

+ 36 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyOrderRefundDTO.java

@@ -0,0 +1,36 @@
+package com.payment.platform.module.payment.leshouying.dto;
+
+import jakarta.validation.constraints.Positive;
+import lombok.Data;
+
+/**
+ * 管理端「退款」按钮的请求体 —— **只有一个字段,而且可以整个不传**。
+ *
+ * <p>与开放 API 那条腿的 {@link LsyRefundDTO} 差在两个地方,两处都是**故意**的:
+ * <ol>
+ *   <li><b>没有 {@code outTradeNo}</b> —— 它走路径参数({@code /{outTradeNo}/refund}),
+ *       而不是请求体。同一个值出现在两个地方就必然有一天不一致,且判不出该信哪个。</li>
+ *   <li><b>没有 {@code outRefundNo},金额也可以不填</b> —— 开放 API 的调用方是自己系统
+ *       对接的程序,让它带幂等键是合理的;管理端按钮前面坐着的是操作员,
+ *       让他手打退款单号只会造出重复单号,让他算「还可退多少」只会算错。
+ *       退款单号由服务端生成,金额留空即「把剩余全额退掉」。</li>
+ * </ol>
+ *
+ * <p>★ 金额这一格**不挂 1 元下限**(对照下单侧四个入口的 {@code LsyAmountFloor}):
+ * 部分退款天然可以低于 1 元 —— 一笔 1.5 元的单退 0.5 元是合法业务。
+ * 下限那一批的 javadoc 里已经写明「退款金额不在内」,此处是那句话的落点。
+ * 但 {@code @Positive} 必须在:0 与负数没有任何一种读法说得通。
+ */
+@Data
+public class LsyOrderRefundDTO {
+
+    /**
+     * 退款金额(分)。**留空 = 退剩余全额**(剩余由服务端按台账与订单快照算出)。
+     *
+     * <p>用包装类型 {@code Long} 而不是 {@code long}:留空必须是「这个键不存在」,
+     * 而不是「金额是 0」—— 后者会被 {@code @Positive} 拦下,两条路在调用方看来一样、
+     * 在日志里却分不开。
+     */
+    @Positive(message = "refund_fee 必须大于 0")
+    private Long refundFee;
+}

+ 26 - 3
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java

@@ -75,11 +75,20 @@ public class LsyRefundService {
 
     /**
      * 申请退款。支持部分退款。
-     * @throws IllegalArgumentException 金额非法
+     *
+     * @param refundFee 退款金额(分)。**{@code null} = 退剩余全额**,由本方法自己算
+     *                  (见下面 remaining 那一段)。允许 null 是给管理端按钮用的:
+     *                  让操作员算「还可退多少」只会算错,而这个地方是钱。
+     *                  开放 API 那条腿传的永远是具体数值,行为与改动前逐字一致。
+     * @throws IllegalArgumentException 金额非法(含「留空但已退满」这种算出来是 0 的)
      * @throws IllegalStateException    订单状态不允许退款
      */
-    public LsyRefundEntity createRefund(LsyOrderEntity order, String outRefundNo, long refundFee) {
-        if (refundFee <= 0) {
+    public LsyRefundEntity createRefund(LsyOrderEntity order, String outRefundNo, Long refundFee) {
+        // ★ 只在**显式给了金额**时当场拒。null 在这一步无法求值 ——
+        //   它的合法性取决于下面才算得出来的 remaining,所以那一侧在下面补判,
+        //   两道闸不能合并(合并就得把这道挪到读库之后,而「金额是 -1」这种错误
+        //   没有任何理由要多读一次库才知道)。
+        if (refundFee != null && refundFee <= 0) {
             throw new IllegalArgumentException("退款金额必须大于 0");
         }
         if (!REFUNDABLE.contains(order.getStatus())) {
@@ -138,6 +147,20 @@ public class LsyRefundService {
         long snapshotSettled = order.getRefundedAmount() == null ? 0L : order.getRefundedAmount();
         // total 仍取快照:总额不随时间变,没有陈旧问题。
         long remaining = order.getTotalAmount() - Math.max(settled, snapshotSettled);
+        // ★ 留空 = 退剩余:**在这里**解析,不搬到控制器去。
+        //   理由就是上面那句 remaining 的定义 —— 它只该有一个副本,而这里已经是它。
+        if (refundFee == null) {
+            refundFee = remaining;
+        }
+        // ★ 必须**再判一次正数**:null 绕过了方法开头那道闸(它当时还不知道数额),
+        //   而 0 是在这里才出现的。少了这一句,一张**已退满**的单会带着
+        //   `refund_fee=0` 一路打到上游。
+        //   提示语与开头那句不同是**有意的**:操作员看到「已退满,剩余 0」才知道
+        //   不是自己填错了,而是这单确实没得退了。
+        if (refundFee <= 0) {
+            throw new IllegalArgumentException(
+                    "退款金额必须大于 0(未指定金额时表示退剩余,本单剩余 " + remaining + " 分)");
+        }
         if (refundFee > remaining) {
             throw new IllegalArgumentException(
                     "退款金额超过可退余额: 申请 " + refundFee + ", 可退 " + remaining);

+ 63 - 0
java/src/main/resources/db/lsy-order-refund-menu.sql

@@ -0,0 +1,63 @@
+-- 乐收赢订单管理:新增「退款」权限点(2026-09-30)
+--
+-- 执行人:用户。本平台没有直连该库的通道,所以这份脚本**由我编写、只由你执行**。
+-- 本脚本是幂等的:重复执行不会产生第二行(两处 INSERT 都带 NOT EXISTS)。
+--
+-- 为什么是新增一个权限点,而不是复用 order:list:
+--   PermissionCheck.hasAny 是按 authorities 集合做**精确匹配**(全等)。
+--   复用 list 的后果不是「少一层保护」而是**放大权限** —— 能翻订单列表的人就能把钱退了。
+--   后端那一侧由 LeshouyingOrderControllerTest#refund_有自己独立的权限串_不复用orderList
+--   钉住,这里这一行必须与它逐字一致:module_payment:leshouying:order:refund
+--
+-- 谁拿到这个权限:
+--   role 1 = ADMIN、role 2 = tenant 都授予 —— 与汇安融那一批同一档理由:
+--   租户本来就能通过开放 API(POST /payment/openapi/pay/refund)退自己商户的单,
+--   后台按钮只是同一能力的界面。若要**只给 ADMIN、不给租户**:
+--   执行完把最后那段 role 2 的 INSERT 换成它下面注释里的 DELETE。
+--
+-- ★ 这份脚本同时是「权限串契约」的一部分:LeshouyingAdminWiringTest 会读它,
+--   把注解里出现的每个权限串跟它比对(注解里有、契约里没有 ⇒ 红)。
+--   所以新增权限点时**不要另建一个名字不同的文件**而不登记 —— 那会让那条腿假红。
+
+-- ---------- 权限点:挂在「支付订单」页面下 ----------
+-- type=3 = 按钮级权限点。parent 必须指向已存在的那一页,否则 UserService.buildMenuTree
+-- 会把父节点不在集合里的子节点**直接丢弃**(静默消失,不报错)。
+INSERT INTO sys_menu (parent_id, name, title, permission, type, "order",
+                      hidden, keep_alive, always_show, affix, status, created_time, updated_time)
+SELECT (SELECT id FROM sys_menu WHERE route_path = '/payment/collection/order' AND type = 2),
+       '订单退款', '订单退款', 'module_payment:leshouying:order:refund', 3,
+       (SELECT COALESCE(MAX("order"), 0) + 1 FROM sys_menu
+        WHERE parent_id = (SELECT id FROM sys_menu WHERE route_path = '/payment/collection/order' AND type = 2)),
+       false, false, false, false, '0', now(), now()
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE permission = 'module_payment:leshouying:order:refund');
+
+-- ---------- 角色授权 ----------
+-- 只授权**这一个**权限点(不像 lsy-menu.sql 那样按页面批量插),
+-- 免得将来加权限点时这份脚本被重跑一次就顺带全授出去。
+INSERT INTO sys_role_menus (role_id, menu_id)
+SELECT 1, id FROM sys_menu WHERE permission = 'module_payment:leshouying:order:refund'
+   AND NOT EXISTS (SELECT 1 FROM sys_role_menus rm
+                   WHERE rm.role_id = 1
+                     AND rm.menu_id = (SELECT id FROM sys_menu WHERE permission = 'module_payment:leshouying:order:refund'));
+
+INSERT INTO sys_role_menus (role_id, menu_id)
+SELECT 2, id FROM sys_menu WHERE permission = 'module_payment:leshouying:order:refund'
+   AND NOT EXISTS (SELECT 1 FROM sys_role_menus rm
+                   WHERE rm.role_id = 2
+                     AND rm.menu_id = (SELECT id FROM sys_menu WHERE permission = 'module_payment:leshouying:order:refund'));
+
+-- 若不想给租户(role 2),改用下面这句代替上面那段 role 2 的 INSERT:
+--   DELETE FROM sys_role_menus
+--   WHERE role_id = 2
+--     AND menu_id = (SELECT id FROM sys_menu WHERE permission = 'module_payment:leshouying:order:refund');
+
+-- ---------- 只读核对(可随时复量,不写任何数据)----------
+-- 期望:一行,type=3,parent 指向 /payment/collection/order 那一页,两个角色各有一条授权。
+SELECT m.id, m.parent_id, m.title, m.permission, m.type, m."order",
+       (SELECT string_agg(rm.role_id::text, ',' ORDER BY rm.role_id)
+        FROM sys_role_menus rm WHERE rm.menu_id = m.id) AS granted_roles
+FROM sys_menu m
+WHERE m.permission = 'module_payment:leshouying:order:refund';
+
+-- ★ 执行后必须**重新登录**才生效:权限集合在登录时进 token / 权限 store,
+--   不重登的话菜单里不会有这一项,后端也会照旧 403。★ 还要**重新部署**(新端点要上新 jar)。

+ 82 - 1
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOrderControllerTest.java

@@ -6,11 +6,15 @@ 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.dto.LsyOrderRefundDTO;
 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 com.payment.platform.module.payment.leshouying.service.LsyRefundService;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.mockito.ArgumentMatchers;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -49,12 +53,13 @@ class LeshouyingOrderControllerTest {
     @Mock private LsyOrderMapper orderMapper;
     @Mock private LsyNotifyLogMapper notifyLogMapper;
     @Mock private LsyRefundMapper refundMapper;
+    @Mock private LsyRefundService refundService;
 
     private LeshouyingOrderController controller;
 
     @BeforeEach
     void setUp() {
-        controller = new LeshouyingOrderController(orderMapper, notifyLogMapper, refundMapper);
+        controller = new LeshouyingOrderController(orderMapper, notifyLogMapper, refundMapper, refundService);
         // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在
         // 无 Spring 上下文的单测中工作(否则抛 can not find lambda cache for this entity)。
         // 本类**每一条**断言都读 wrapper,所以这一句是必需的,不是保险起见。
@@ -208,4 +213,80 @@ class LeshouyingOrderControllerTest {
         assertEquals(404, e.getCode());
         verify(refundMapper, never()).selectList(any());
     }
+
+    // ───────────────────────── 发起退款(动钱) ─────────────────────────
+
+    /**
+     * 不传金额 ⇒ 不自己算,把「算剩余」留给服务层。
+     *
+     * <p>★ 这条是本端点与开放 API 那条腿的**唯一**差别:那边 {@code out_refund_no}
+     * 与金额都由调用方给,管理端两者都不该让操作员填(退款单号重复/金额算错都是钱的事)。
+     * 所以这里断言两件事:第三参**是 null**(不是 0、也不是事先算好的余额),
+     * 以及第二个参是服务端新生成的号。
+     *
+     * <p>★ 「让服务层算」不是洁癖:剩余 = 总额 - max(台账, 快照) 这个式子
+     * 已经在 {@code LsyRefundService} 里有一份,控制器再算一次就是第二个副本 ——
+     * 而两处漂移的后果是「页面预填的数」与「真正发出去的数」不是同一个。
+     */
+    @Test
+    void refund_不传金额_把算剩余留给服务层_且退款单号由服务端生成() {
+        LsyOrderEntity o = order();
+        when(orderMapper.selectOne(any())).thenReturn(o);
+        when(refundService.createRefund(any(), anyString(), any())).thenReturn(new LsyRefundEntity());
+
+        controller.refund("OUT001", null);
+
+        ArgumentCaptor<String> no = ArgumentCaptor.forClass(String.class);
+        verify(refundService).createRefund(same(o), no.capture(), ArgumentMatchers.<Long>isNull());
+        assertTrue(no.getValue().matches("RF\\d+"),
+                "退款单号必须是服务端生成的 RF + 雪花数字(操作员不该有机会填这个);实测=" + no.getValue());
+    }
+
+    @Test
+    void refund_带金额_原样透传() {
+        LsyOrderEntity o = order();
+        when(orderMapper.selectOne(any())).thenReturn(o);
+        when(refundService.createRefund(any(), anyString(), any())).thenReturn(new LsyRefundEntity());
+        LsyOrderRefundDTO dto = new LsyOrderRefundDTO();
+        dto.setRefundFee(30L);
+
+        controller.refund("OUT001", dto);
+
+        ArgumentCaptor<Long> fee = ArgumentCaptor.forClass(Long.class);
+        verify(refundService).createRefund(same(o), anyString(), fee.capture());
+        assertEquals(30L, fee.getValue(),
+                "部分退款时控制器**不得**改写调用方给的金额(改写就等于页面上的数不是退的数)");
+    }
+
+    @Test
+    void refund_订单不存在_is404_且一步都不碰上游() {
+        when(orderMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> controller.refund("NOPE", null));
+
+        assertEquals(404, e.getCode(),
+                "与其它方法同一条纪律:不区分「不存在」与「不属于你」");
+        verifyNoInteractions(refundService);
+    }
+
+    /**
+     * 本端点**不复用** {@code order:list} —— 能查一笔单不等于能动这笔钱。
+     *
+     * <p>★ 为什么不能只靠 {@code LeshouyingAdminWiringTest}:那个类明确写着它
+     * 「不校验哪个权限串配哪个控制器」—— 把 refund 的串抄成 list 的串不会让它变红
+     * (两条串都在菜单契约里)。所以「这一个端点的串是不是它自己的」必须在本类里钉。
+     * 同 {@code HarOrderController#refund} 的取舍(那边有同款用例)。
+     */
+    @Test
+    void refund_有自己独立的权限串_不复用orderList() throws Exception {
+        PreAuthorize pa = LeshouyingOrderController.class
+                .getMethod("refund", String.class, LsyOrderRefundDTO.class)
+                .getAnnotation(PreAuthorize.class);
+
+        assertNotNull(pa, "退款端点必须有 @PreAuthorize");
+        assertEquals("@perm.hasAny('module_payment:leshouying:order:refund')", pa.value(),
+                "退款必须有自己的权限串。复用 order:list 的后果不是「少一层保护」而是**放大权限**:"
+                        + "任何能查一笔单的人都能把这笔钱退出去");
+    }
 }

+ 63 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyRefundServiceTest.java

@@ -113,6 +113,69 @@ class LsyRefundServiceTest {
                 () -> service.createRefund(order(), "RF001", 0L));
     }
 
+    // ─────────────── 金额留空 = 退**剩余**(管理端退款按钮的默认行为)───────────────
+    //
+    // ★ 为什么「留空」这件事落在服务层而不是控制器:剩余 = 总额 - max(台账, 快照)
+    //   这个式子在本类**已经写了一遍**(:140 那一行)。控制器再算一次就是第二个副本,
+    //   而两处漂移的后果是「页面预填的数」与「真正发出去的数」不是同一个 ——
+    //   调用方无从察觉。
+
+    /**
+     * 留空 ⇒ 退**剩余**,不是退总额。
+     *
+     * <p>夹具刻意先塞一笔 30 的 SUCCESS 行:剩余 70 ≠ 总额 100 ⇒
+     * 把「剩余」实现成 {@code order.getTotalAmount()} 的写法会得到 100,本用例当场红。
+     * 换句话说这条用例同时证明「取的是剩余」与「台账真的被读了」。
+     */
+    @Test
+    void refund_feeOmitted_meansRefundTheWholeRemaining() {
+        LsyOrderEntity o = order();
+        LsyRefundEntity prior = new LsyRefundEntity();
+        prior.setId(9L);
+        prior.setStatus("SUCCESS");
+        prior.setRefundFee(30L);
+        when(refundMapper.selectList(any())).thenReturn(List.of(prior));
+        when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
+        when(refundMapper.insert(any())).thenReturn(1);
+        when(refundMapper.update(any(), any())).thenReturn(1);
+        when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true);
+
+        LsyRefundEntity e = service.createRefund(o, "RF004", null);
+
+        assertEquals(70L, e.getRefundFee(),
+                "留空时落库的必须是**剩余**(100-30),不是总额");
+        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
+        verify(client).post(eq("/v3/refundorder"), captor.capture());
+        @SuppressWarnings("unchecked")
+        Map<String, Object> sent = captor.getValue();
+        assertEquals("70", sent.get("refund_fee"),
+                "发给上游的 refund_fee 必须与落库的是同一个数 —— 两个数不一致时,"
+                        + "库里记的与真正出金的不是一回事");
+    }
+
+    /**
+     * 已经退满的单留空 ⇒ 剩余 0 ⇒ 必须拒,**不能**把 {@code refund_fee=0} 发给上游。
+     *
+     * <p>这条钉的是「留空」分支里最容易被漏掉的一步:{@code null} 绕过了方法开头那道
+     * {@code refundFee <= 0} 的闸(它无法对一个还不知道的数额求值),
+     * 而 0 是在**后面**算出 remaining 时才出现的 ⇒ 必须在那之后**再判一次**。
+     * 少了这一次,一张已退满的单会带着 0 元退款请求打到上游。
+     */
+    @Test
+    void refund_feeOmitted_onFullyRefundedOrder_isRejectedBeforeTouchingUpstream() {
+        LsyOrderEntity o = order();
+        LsyRefundEntity prior = new LsyRefundEntity();
+        prior.setId(9L);
+        prior.setStatus("SUCCESS");
+        prior.setRefundFee(100L);
+        when(refundMapper.selectList(any())).thenReturn(List.of(prior));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> service.createRefund(o, "RF005", null));
+        verify(client, never()).post(anyString(), anyMap());
+        verify(refundMapper, never()).insert(any());
+    }
+
     @Test
     void refund_rejectsOrderNotInSuccessState() {
         LsyOrderEntity o = order();