Pārlūkot izejas kodu

feat(汇安融): 退款端点开放 —— POST /payment/openapi/har/refund

控制器照 order/query 同形:第一行 requireMerchant(enterprise_id, merchant_code),
再过 orderService.openRefund。开放 API 是新入口,管理端一侧不动。

merchant_code 必须一路带进闸里:本租户有多条商户配置而不传它时,requireByTenant
会报 400「请指定 merchant_code」,而退款请求体里根本没有这个字段可填 ——
调用点写死 null 就把那句提示变成死路(order/query 犯过同一个错)。

闸必须在 openRefund **之前**:闸在后等于拿着一个还没证明属于本租户的单号去发起
退款,404 只是发晚了。这条由 verifyNoInteractions(orderService) 钉住 ——
顺序反了时 404 照样抛得出来,只有这条断言会红(见下面的变异读数)。

TenantApiKeyAuthFilter.OPENAPI_ACCOUNT_PATHS 补一行。★ 这一行**不守鉴权**:
认证判定读的是 OPENAPI_PREFIXES(已含 /payment/openapi/har),退款端点从来就是
fail-closed 的;这个数组的 javadoc 自己写着「供文档与测试核对」。乐收赢侧有
LeshouyingWiringTest 在核对同一件事,汇安融侧此前一条都没有,所以补了一致性用例
everyEndpoint_isListedInTheAuthFilterInventory —— 它盯的是那份清单与实际映射不漂移。

同一提交补两条路径断言:退款映射在 /refund、控制器前缀是 /payment/openapi/har。
路径写错在别处完全不可见(service 层直调 service、本类直调方法),全绿而线上 404。

读数:HarOpenControllerTest 11 条全绿(原 6 + 新增 5)。
变异(脚本 logs/_har-ctl-mutate.py,逐次还原并复核 sha256 与基线逐字节相同):
- m1 清单里删掉退款那行 ⇒ 红 1 条(everyEndpoint_isListedInTheAuthFilterInventory,
  失败消息打出实际路径 /payment/openapi/har/refund)
- m2 退款完全不设闸 ⇒ 红 3 条(401 / 404 / merchant_code 转发)
- m3 闸留着但挪到 openRefund 之后 ⇒ 红 2 条(401 与 404 那两条的 verifyNoInteractions)
- 还原 ⇒ 11 条全绿;HarOpenController.java e8797bc96feb2ac5、TenantApiKeyAuthFilter.java
  49e08ba4e0c3a651,与注入前一致
alphaH 3 nedēļas atpakaļ
vecāks
revīzija
2f54088762

+ 3 - 1
java/src/main/java/com/payment/platform/core/security/TenantApiKeyAuthFilter.java

@@ -106,10 +106,12 @@ public class TenantApiKeyAuthFilter extends OncePerRequestFilter {
             "/payment/openapi/pay/order/close",
             "/payment/openapi/pay/refund",
             "/payment/openapi/pay/refund/query",
-            // 汇安融开放接口(门店/订单)。同上:本数组只供核对,认证判定读 OPENAPI_PREFIXES。
+            // 汇安融开放接口(门店/订单/退款)。同上:本数组只供核对,认证判定读 OPENAPI_PREFIXES。
+            // 本通道这份清单由 HarOpenControllerTest#everyEndpoint_isListedInTheAuthFilterInventory 核对。
             "/payment/openapi/har/shop/create",
             "/payment/openapi/har/order/create",
             "/payment/openapi/har/order/query",
+            "/payment/openapi/har/refund",
     };
 
     /**

+ 17 - 0
java/src/main/java/com/payment/platform/module/payment/har/controller/open/HarOpenController.java

@@ -4,6 +4,7 @@ import cn.hutool.core.util.StrUtil;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.response.Result;
 import com.payment.platform.module.payment.har.dto.HarOpenOrderQueryDTO;
+import com.payment.platform.module.payment.har.dto.HarOpenRefundDTO;
 import com.payment.platform.module.payment.har.dto.HarOrderCreateDTO;
 import com.payment.platform.module.payment.har.dto.HarOrderVO;
 import com.payment.platform.module.payment.har.dto.HarShopCreateDTO;
@@ -75,6 +76,22 @@ public class HarOpenController {
         return Result.ok(orderService.openQuery(query));
     }
 
+    /**
+     * 全额退款。本通道上游只支持全额(规范 5.4 的 biz 没有金额字段),故入参没有金额。
+     *
+     * <p>不幂等:同一笔订单重复调用,第二次会因订单已不在「1 支付成功」而报错,
+     * 不会重复发起。同步返回成功只代表上游**受理**了,到账以通知(交易状态 2)或
+     * 查单为准。
+     */
+    @PostMapping("/refund")
+    @Operation(summary = "订单退款(API Key 认证)")
+    public Result<HarOrderVO> refund(@Valid @RequestBody HarOpenRefundDTO dto) {
+        // 同 orderQuery:merchant_code 必须带到闸这里,否则多商户租户会收到
+        // 一句照办不了的 400(请求体里没有这个字段可填)。
+        requireMerchant(dto.getEnterpriseId(), dto.getMerchantCode());
+        return Result.ok(orderService.openRefund(dto));
+    }
+
     // ---------- helpers ----------
 
     /**

+ 100 - 0
java/src/test/java/com/payment/platform/module/payment/har/controller/open/HarOpenControllerTest.java

@@ -2,6 +2,7 @@ package com.payment.platform.module.payment.har.controller.open;
 
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.module.payment.har.dto.HarOpenOrderQueryDTO;
+import com.payment.platform.module.payment.har.dto.HarOpenRefundDTO;
 import com.payment.platform.module.payment.har.dto.HarOrderCreateDTO;
 import com.payment.platform.module.payment.har.dto.HarShopCreateDTO;
 import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
@@ -14,6 +15,8 @@ import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
 
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
@@ -148,4 +151,101 @@ class HarOpenControllerTest {
         assertEquals(403, e.getCode(), "实测消息: " + e.getMessage());
         verifyNoInteractions(orderService);
     }
+
+    // ==================== 退款端点 ====================
+
+    @Test
+    void refund_withoutApiKeyAuth_is401() {
+        request.removeAttribute("openapi.tenantId");
+        HarOpenRefundDTO dto = new HarOpenRefundDTO();
+        dto.setOrderNo("HAR1");
+
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.refund(dto));
+
+        assertEquals(401, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(orderService);
+    }
+
+    /**
+     * 闸里的 merchant_code 分支必须**被调用点真的传进来**(同 orderQuery 那条教训:
+     * 调用点写死 null,闸里那条分支在生产里就是死的,而 service 层用例全绿)。
+     *
+     * 少传它,多商户租户的退款会被 400「请指定 merchant_code」挡下,而请求体里
+     * 根本没有这个字段可填 —— 那是一条**照办不了**的提示。
+     */
+    @Test
+    void refund_forwardsMerchantCode_soMultiMerchantTenantsCanRefund() {
+        when(merchantService.requireByTenantAndCode(TENANT, "M700")).thenReturn(merchant("M700"));
+        HarOpenRefundDTO dto = new HarOpenRefundDTO();
+        dto.setMerchantCode("M700");
+        dto.setOrderNo("HAR1");
+
+        controller.refund(dto);
+
+        verify(merchantService).requireByTenantAndCode(TENANT, "M700");
+        verify(orderService).openRefund(dto);
+    }
+
+    /** 别租户的商户号必须在**发起退款之前**被挡下 —— 闸在后就等于拿着别人的单号去退款了 */
+    @Test
+    void refund_foreignMerchant_rejectedBeforeRefunding() {
+        when(merchantService.requireByTenantAndCode(TENANT, "M999"))
+                .thenThrow(new BusinessException(404, "商户编号不存在或不属于当前租户: M999"));
+        HarOpenRefundDTO dto = new HarOpenRefundDTO();
+        dto.setMerchantCode("M999");
+        dto.setOrderNo("HAR1");
+
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.refund(dto));
+
+        assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(orderService);
+    }
+
+    /**
+     * 退款的**真实路径**。
+     *
+     * 路径写错在别处完全不可见:service 层用例直调 service,本类别的用例直调方法,
+     * 把 {@code @PostMapping("/refund")} 改成别的一行,那些用例照样全绿而线上 404。
+     */
+    @Test
+    void refund_isMappedAtTheDocumentedPath() throws Exception {
+        assertEquals("/refund", HarOpenController.class
+                        .getDeclaredMethod("refund", HarOpenRefundDTO.class)
+                        .getAnnotation(PostMapping.class).value()[0],
+                "租户文档 10.4 承诺的路径是 /payment/openapi/har/refund");
+        assertEquals("/payment/openapi/har",
+                HarOpenController.class.getAnnotation(RequestMapping.class).value()[0]);
+    }
+
+    /**
+     * 本控制器每一个 {@code @PostMapping} 都要出现在
+     * {@code TenantApiKeyAuthFilter.OPENAPI_ACCOUNT_PATHS} 那份清单里。
+     *
+     * ★ 这条**不守鉴权边界**:认证判定读的是前缀数组,漏列**不会**造成无鉴权
+     *   (该 filter 的 javadoc 明写了这一点)。它守的是那份自称「供文档与测试核对」的
+     *   清单与实际映射一致 —— 乐收赢有 LeshouyingWiringTest 在核对同一件事,
+     *   汇安融侧此前一条都没有,所以这条清单在 HAR 这边是**没人核对**的。
+     */
+    @Test
+    void everyEndpoint_isListedInTheAuthFilterInventory() throws Exception {
+        String base = HarOpenController.class.getAnnotation(RequestMapping.class).value()[0];
+        java.lang.reflect.Field f = com.payment.platform.core.security.TenantApiKeyAuthFilter.class
+                .getDeclaredField("OPENAPI_ACCOUNT_PATHS");
+        f.setAccessible(true);
+        java.util.List<String> listed =
+                java.util.Arrays.asList((String[]) f.get(null));
+
+        java.util.List<String> actual = new java.util.ArrayList<>();
+        for (var m : HarOpenController.class.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm != null) {
+                actual.add(base + pm.value()[0]);
+            }
+        }
+
+        assertTrue(actual.size() >= 4, "一个端点都没扫到,说明这条用例的量具坏了: " + actual);
+        for (String path : actual) {
+            assertTrue(listed.contains(path), "未列入 OPENAPI_ACCOUNT_PATHS: " + path);
+        }
+    }
 }