Browse Source

feat(汇安融): 后台加退款入口 —— 全额、二次确认、原因落库

本通道在此之前**只有开放 API 能退款**(POST /payment/openapi/har/refund),管理端
一个退款入口都没有:订单页那三个按钮是 详情 / 同步 / 下单,计划里那行
「| 7 | 退款 | 本期未实现 |」记的就是这件事。用户在页面上找不到退款按钮 ——
那不是坏了,是从来没做过。

新增一个端点(本控制器第一个**动钱**的端点):

  POST /payment/har/order/{id}/refund   module_payment:har:order:refund

寻址与开放 API 不同,这里走**主键**:后台是从列表点进来的,手上就有 id,不需要
调用方回填单号。权限串**不复用** sync / create —— PermissionCheck.hasAny 是全等
匹配,复用等于「能查一笔单的人就能把钱退了」,由新增的 HarOrderControllerTest
逐条比对拦住。

退款的实际动作与开放 API **共用同一条路径**:把 openRefund 的身体抽成
private doRefund(order, reason),两个入口只负责寻址。不是为了整齐 —— 抢占、
trade_status 守卫、失败回退、原因落库这四件事任何一处漂移,两边的行为就不同,
而「哪一边是真的」没有读数能回答。

授权来自**查询形状**,不是这里多写的一道闸:selectById 走 pay_har_order,它在
TenantInnerInterceptor 的 CONDITIONAL_TABLES(**不是** IGNORE_TABLES)里
⇒ 已登录且带 tenant_id 的调用会被自动追加租户谓词,拿到别租户的行时返回 null
⇒「订单不存在」;超管按设计可退任意租户。这条依赖是**隐式**的(实测两个数组都在
同一个文件里、名字只差一个词),所以写进了 javadoc:谁哪天把 pay_har_order 挪进
IGNORE_TABLES,这个端点就静默变成跨租户可退款。

原因这一格:上游**要求非空**(2026-09-24 实测,规范三处都没写),留空由服务端补
默认值 —— 与开放 API 用同一个串,发出去的那句和落库的那句必须逐字相同。

读数:
- 先红后绿:4 条服务用例在 refundById 存在之前**编译失败**(找不到符号 refundById)
- 两向变异:权限串改成 :sync ⇒ HarOrderControllerTest 红在该断言;
  refundById 传 null 当原因 ⇒ HarOrderServiceTest 红在该断言(实收默认值);
  把 isBlank 换成等价写法 ⇒ 62 条仍绿(证明不是「随便改都红」)
- 全量 mvn -o test 885 绿 / BUILD SUCCESS(878 → 885,+4 服务 +3 控制器)
alphaH 2 weeks ago
parent
commit
f80035094c

+ 18 - 0
java/src/main/java/com/payment/platform/module/payment/har/controller/HarOrderController.java

@@ -4,6 +4,7 @@ import com.payment.platform.common.response.PageResult;
 import com.payment.platform.common.response.Result;
 import com.payment.platform.module.payment.har.dto.HarOrderCreateDTO;
 import com.payment.platform.module.payment.har.dto.HarOrderQueryDTO;
+import com.payment.platform.module.payment.har.dto.HarOrderRefundDTO;
 import com.payment.platform.module.payment.har.dto.HarOrderVO;
 import com.payment.platform.module.payment.har.service.HarOrderService;
 import io.swagger.v3.oas.annotations.Operation;
@@ -67,4 +68,21 @@ public class HarOrderController {
     public Result<HarOrderVO> sync(@PathVariable(name = "id") Long id) {
         return Result.ok(service.syncFromUpstream(service.getEntityById(id)));
     }
+
+    /**
+     * 退款(全额,调用上游)。
+     *
+     * <p>这是本控制器第一个**动钱**的端点,也是本通道管理端唯一的退款入口 ——
+     * 在此之前只有开放 API 能退({@code POST /payment/openapi/har/refund})。
+     *
+     * <p>请求体可省:只为了带一句原因,留空由服务端补默认值(上游要求非空)。
+     * 权限串**不复用** sync / create:能查一笔单不等于能动这笔钱。
+     */
+    @PostMapping("/{id}/refund")
+    @Operation(summary = "退款(全额,调用上游)")
+    @PreAuthorize("@perm.hasAny('module_payment:har:order:refund')")
+    public Result<HarOrderVO> refund(@PathVariable(name = "id") Long id,
+                                     @RequestBody(required = false) HarOrderRefundDTO dto) {
+        return Result.ok(service.refundById(id, dto == null ? null : dto.getReason()));
+    }
 }

+ 28 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarOrderRefundDTO.java

@@ -0,0 +1,28 @@
+package com.payment.platform.module.payment.har.dto;
+
+import lombok.Data;
+
+/**
+ * 管理端退款入参。
+ *
+ * <p>只有原因一个格子:**退哪一笔由路径上的 id 决定**({@code /payment/har/order/{id}/refund}),
+ * 不像开放 API 那样还要 order_no / out_biz_no 二选一地寻址 —— 后台是从列表点进来的,
+ * 手上就有 id。
+ *
+ * <p>没有金额:本通道**只有全额退款**(上游 5.4 的 biz 只有 order_no / third_order_no)。
+ *
+ * <p>与 {@link HarOpenRefundDTO} 是两个 DTO 而不是共用一个:那一个是给调用方的契约,
+ * 带 enterprise_id / merchant_code 两个寻址维度;这一个的寻址在路径上,
+ * 两个契约会各自独立演进(比如后台哪天要加「操作员备注」)。
+ */
+@Data
+public class HarOrderRefundDTO {
+
+    /**
+     * 退款原因。**选填**,留空由平台补一个默认值。
+     *
+     * <p>上游实际要求它非空(规范漏写),但长度上限未知 ⇒ 不设 @Size:
+     * 编一个长度上限出来会把本来能成的退款挡在门外。
+     */
+    private String reason;
+}

+ 30 - 1
java/src/main/java/com/payment/platform/module/payment/har/service/HarOrderService.java

@@ -192,7 +192,36 @@ public class HarOrderService {
     public HarOrderVO openRefund(HarOpenRefundDTO dto) {
         HarOrderEntity order = requireOpenOrder(
                 dto.getOrderNo(), dto.getOutBizNo(), dto.getEnterpriseId(), dto.getMerchantCode());
+        return doRefund(order, dto.getReason());
+    }
 
+    /**
+     * 管理端退款(按主键)。后台是从列表点进来的,手上就有 id,不需要调用方回填单号。
+     *
+     * <p><b>授权来自查询形状,不是这里多写的一道闸</b>:{@code selectById} 走的是
+     * {@code pay_har_order},它在 {@code TenantInnerInterceptor} 的
+     * <b>CONDITIONAL_TABLES</b>(不是 IGNORE_TABLES)里 ⇒ 已登录且带 tenant_id 的调用
+     * 会被自动追加租户谓词,拿到别租户的行时返回 null ⇒ 「订单不存在」。超管按设计可以
+     * 退任意租户的单。**这条依赖是隐式的** —— 谁哪天把 {@code pay_har_order} 挪进
+     * IGNORE_TABLES,这个端点就静默变成跨租户可退款,所以写在这里。
+     *
+     * <p>开放 API 那条路另有一层企业/商户归属比对({@link #requireOpenOrder}):
+     * 那边是按**业务单号**寻址,同一租户下多个企业都能撞上;这边按**主键**寻址,
+     * 主键已经在租户范围内取到,再加企业比对没有增量。
+     *
+     * @param reason 退款原因,**选填**:留空由平台补默认值(上游要求非空,见 {@link #doRefund})
+     */
+    public HarOrderVO refundById(Long id, String reason) {
+        return doRefund(getEntityById(id), reason);
+    }
+
+    /**
+     * 退款的实际动作 —— 开放 API 与后台**共用这一条**。
+     *
+     * 拆出来的理由不是「看着整齐」:抢占、`trade_status` 守卫、失败回退、原因落库这四件事
+     * 任何一处漂移,两边的行为就不同,而「哪一边是真的」没有读数能回答。入口只负责寻址。
+     */
+    private HarOrderVO doRefund(HarOrderEntity order, String callerReason) {
         if (!HarOrderStatus.PAID.equals(order.getTradeStatus())) {
             // 尤其「2 退款成功」:再退一次就是重复退款
             throw new BusinessException("订单当前状态不可退款: " + order.getTradeStatus());
@@ -204,7 +233,7 @@ public class HarOrderService {
         // 退款原因先定下来:同一句话既要发给上游,也要落本地这一行。
         // 上游对空 reason 是**整笔直接拒**(2026-09-24 实测),默认值由我们补;
         // 调用方填了就按他填的走 —— 这一格会进商户的账务记录,不该被默认值盖掉。
-        String reason = StrUtil.isBlank(dto.getReason()) ? DEFAULT_REFUND_REASON : dto.getReason();
+        String reason = StrUtil.isBlank(callerReason) ? DEFAULT_REFUND_REASON : callerReason;
 
         order.setTradeStatus(HarOrderStatus.REFUNDING);
         order.setQueryCount(0);

+ 101 - 0
java/src/test/java/com/payment/platform/module/payment/har/controller/HarOrderControllerTest.java

@@ -0,0 +1,101 @@
+package com.payment.platform.module.payment.har.controller;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.lang.reflect.Method;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 管理端订单控制器的**接线**断言。
+ *
+ * 只看注解,不碰 Spring,也不调服务:这里钉的是「这个端点存在、挂在哪个 URL、认哪个权限串」。
+ * 服务行为由 {@code HarOrderServiceTest} 覆盖,两者不重叠。
+ *
+ * 为什么值得单独一层:退款是本控制器第一个**动钱**的端点,而权限串是
+ * {@code PermissionCheck.hasAny} 的**全等匹配**(见 LeshouyingAdminWiringTest 的实测记录)
+ * ⇒ 写错的后果是 403(fail-closed,安全但功能全废),而**复用**一个已有的串
+ * (比如图省事写 :sync)后果相反:能查一笔单的人就能把钱退了。这两种都要有测试盯着。
+ * 权限串同时是交付给用户执行的菜单 SQL 里的那一格 —— 两边对不上就是「菜单给了、点进去 403」。
+ */
+class HarOrderControllerTest {
+
+    /** 前端与菜单 SQL 都按这个 URL 走,地址错了两边都白搭 */
+    private static final String BASE = "/payment/har/order";
+
+    private static Method m(String name, Class<?>... args) throws NoSuchMethodException {
+        return HarOrderController.class.getDeclaredMethod(name, args);
+    }
+
+    private static String preAuthorize(Method method) {
+        PreAuthorize a = method.getAnnotation(PreAuthorize.class);
+        assertNotNull(a, method.getName() + " 没有 @PreAuthorize —— 该端点不受权限管控");
+        return a.value();
+    }
+
+    /** 控制器上每个端点的权限串,按方法名索引 */
+    private static Map<String, String> permissionByMethod() throws Exception {
+        Map<String, String> out = new LinkedHashMap<>();
+        for (Method method : HarOrderController.class.getDeclaredMethods()) {
+            // 只收端点:有 @GetMapping/@PostMapping 这些组合注解的方法
+            if (method.getAnnotation(PostMapping.class) != null
+                    || method.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class) != null) {
+                out.put(method.getName(), preAuthorize(method));
+            }
+        }
+        return out;
+    }
+
+    @Test
+    void refund_isPostOnIdRefundPathUnderTheOrderBase() throws Exception {
+        assertEquals(BASE, HarOrderController.class.getAnnotation(RequestMapping.class).value()[0],
+                "类上的基路径变了的话,前端与菜单 SQL 都要跟着改");
+
+        PostMapping mapping = m("refund", Long.class,
+                com.payment.platform.module.payment.har.dto.HarOrderRefundDTO.class)
+                .getAnnotation(PostMapping.class);
+
+        assertNotNull(mapping, "退款必须是 POST");
+        assertArrayEquals(new String[]{"/{id}/refund"}, mapping.value(),
+                "实测前端与交付 SQL 都按 /payment/har/order/{id}/refund 写");
+    }
+
+    /**
+     * 退款**必须有自己的权限串**,不能复用任何一个已有端点的。
+     *
+     * 复用是这里唯一会放大权限的写法:{@code hasAny} 全等匹配,所以「能看列表」
+     * 直接就变成「能退款」。这条断言就是拦它。
+     */
+    @Test
+    void refund_hasItsOwnPermission_notReusedFromAnyOtherEndpoint() throws Exception {
+        Map<String, String> perms = permissionByMethod();
+        String refund = perms.get("refund");
+
+        assertNotNull(refund, "refund 端点没被识别到 —— 注解形状变了?实测已收端点: " + perms.keySet());
+        assertEquals("@perm.hasAny('module_payment:har:order:refund')", refund,
+                "权限串要和交付给用户的菜单 SQL 逐字相同");
+
+        perms.forEach((name, value) -> {
+            if (!"refund".equals(name)) {
+                assertNotEquals(refund, value,
+                        "退款复用了 " + name + " 的权限串 ⇒ 拿到那个权限的人就能把钱退了。实测: " + value);
+            }
+        });
+    }
+
+    /** 一个端点都不许漏掉权限注解(将来新增端点时这条会替我们拦一次) */
+    @Test
+    void everyEndpoint_carriesAPermission() throws Exception {
+        Map<String, String> perms = permissionByMethod();
+
+        assertEquals(5, perms.size(), "端点数量变了(新增端点请同时确认它的权限串)实测: " + perms.keySet());
+        perms.forEach((name, value) -> assertTrue(
+                value.startsWith("@perm.hasAny('module_payment:har:order:"),
+                name + " 的权限串不在本模块命名空间下,实测: " + value));
+    }
+}

+ 83 - 0
java/src/test/java/com/payment/platform/module/payment/har/service/HarOrderServiceTest.java

@@ -951,4 +951,87 @@ class HarOrderServiceTest {
         assertTrue(e.getMessage().contains("至少填一个"), "实测: " + e.getMessage());
         verify(orderMapper, never()).selectOne(any());
     }
+
+    // ==================== 管理端退款(按 id) ====================
+    //
+    // 后台是从列表点进来的,手上就有 id ⇒ 寻址走主键,不让调用方回填单号。
+    // 退款的实质动作与开放 API **共用同一条路径**(HarOrderService#doRefund):
+    // 抢占、守卫、回退、原因落库只写一份,两处不会漂移。
+
+    /** 按 id 取到的那一行就是被退的那一笔;调用方填的原因原样到上游 */
+    @Test
+    void refundById_refundsTheRowTheIdPointsAt_withCallerReason() {
+        when(orderMapper.selectById(ORDER_ID)).thenReturn(paidOrder());
+        when(orderMapper.update(any(), any())).thenReturn(1);
+        when(harClient.refund(any())).thenReturn(response("10000", null));
+
+        service.refundById(ORDER_ID, "用户申请退款");
+
+        ArgumentCaptor<HarRefundRequest> req = ArgumentCaptor.forClass(HarRefundRequest.class);
+        verify(harClient).refund(req.capture());
+        assertEquals("用户申请退款", req.getValue().getReason());
+        assertEquals("HAR1", req.getValue().getThirdOrderNo(),
+                "退的必须是 id 指的那一笔。实测: " + req.getValue().getThirdOrderNo());
+        // 拿到的行已经在手上,不该再按单号查一次 —— 两次查询之间隔着一个「查到的还是不是这一行」的疑问
+        verify(orderMapper, never()).selectOne(any());
+    }
+
+    /**
+     * id 不存在 ⇒ 与开放 API 同一句话「订单不存在」,且**一个上游字节都不发**。
+     *
+     * 这里用 verifyNoInteractions 而不是只 verify(never()).refund:后者会漏掉
+     * 将来在这个方法里新加的任何上游调用(查询、回调…)。
+     */
+    @Test
+    void refundById_unknownId_throwsAndNeverTouchesUpstream() {
+        when(orderMapper.selectById(ORDER_ID)).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.refundById(ORDER_ID, "用户申请退款"));
+
+        assertEquals("订单不存在", e.getMessage());
+        verify(orderMapper, never()).update(any(), any());
+        verifyNoInteractions(harClient);
+    }
+
+    /** 状态闸不能被「按 id 进来」这条新路绕过去:非「1 支付成功」一律不受理 */
+    @Test
+    void refundById_nonPaidOrder_rejectedWithoutTouchingUpstream() {
+        HarOrderEntity o = paidOrder();
+        o.setTradeStatus(HarOrderStatus.REFUNDED);
+        when(orderMapper.selectById(ORDER_ID)).thenReturn(o);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.refundById(ORDER_ID, null));
+
+        assertTrue(e.getMessage().contains("不可退款"), "实测: " + e.getMessage());
+        verify(orderMapper, never()).update(any(), any());
+        verifyNoInteractions(harClient);
+    }
+
+    /**
+     * 留空 ⇒ 与开放 API **同一套**默认值,且发出去的那句与落库的那句是同一个串。
+     *
+     * 上游对空 reason 是整笔直接拒(2026-09-24 实测),所以「非空」只是及格线;
+     * 这一条真正钉的是「页面上看到的原因 == 上游账务里收到的那句」。
+     */
+    @Test
+    void refundById_blankReason_defaultsToTheSameStringUpstreamAndInDb() {
+        when(orderMapper.selectById(ORDER_ID)).thenReturn(paidOrder());
+        when(orderMapper.update(any(), any())).thenReturn(1);
+        when(harClient.refund(any())).thenReturn(response("10000", null));
+
+        service.refundById(ORDER_ID, "   ");
+
+        ArgumentCaptor<HarRefundRequest> req = ArgumentCaptor.forClass(HarRefundRequest.class);
+        verify(harClient).refund(req.capture());
+        ArgumentCaptor<HarOrderEntity> patch = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).update(patch.capture(), any());
+
+        assertTrue(StrUtil.isNotBlank(req.getValue().getReason()),
+                "上游要求非空,留空必须补默认值。实测: " + req.getValue().getReason());
+        assertEquals(req.getValue().getReason(), patch.getValue().getRefundReason(),
+                "发出去的与落库的必须是同一个串。上游收到: " + req.getValue().getReason()
+                        + ",落库: " + patch.getValue().getRefundReason());
+    }
 }