Переглянути джерело

feat(统一支付): 开放 API 认证面接线

alphaH 1 тиждень тому
батько
коміт
33fa837fb0

+ 6 - 0
java/src/main/java/com/payment/platform/core/security/SecurityConfig.java

@@ -50,6 +50,10 @@ public class SecurityConfig {
             //   必须与 TenantApiKeyAuthFilter.OPENAPI_PREFIXES 里那条**同名**改,
             //   只改一处就是「被放行 + 无人认证」的零鉴权(2026-09-24 踩过)。
             "/payment/openapi/alipay/**",
+            // 统一支付开放接口:同上,由 TenantApiKeyAuthFilter 独立认证(API Key + 签名)。
+            // ★ 必须与 TenantApiKeyAuthFilter.OPENAPI_PREFIXES 里那条**同名**登记,
+            //   只改一处就是「被放行 + 无人认证」的零鉴权。
+            "/payment/openapi/unified/**",
             "/system/auth/login",
             "/system/auth/login/mini",
             "/system/auth/login/sms",
@@ -69,6 +73,8 @@ public class SecurityConfig {
             "/payment/notify/alipay",
             // 汇安融异步通知:免登录,真实性由 RSA 验签保证(HarNotifyService)
             "/payment/notify/har",
+            // 统一支付异步通知:免登录,真实性由通道验签保证(不走 ApiKey filter)。
+            "/payment/notify/unified/**",
             // 支付宝授权回调白名单地址(当面付 app_auth_code + openid auth_code 共用,按参数分流;
             // 无登录态,租户隔离由 TenantInnerInterceptor 条件表放行)
             "/payment/aplipay/auth",

+ 11 - 2
java/src/main/java/com/payment/platform/core/security/TenantApiKeyAuthFilter.java

@@ -81,8 +81,8 @@ import java.util.UUID;
  *
  * ———————————————————————————————————————
  * 作用范围:
- *   拦截 /payment/openapi/account/**、/payment/openapi/pay/** 与 /payment/openapi/alipay/**
- *   (前缀判定, 见 OPENAPI_PREFIXES)
+ *   拦截 /payment/openapi/account/**、/payment/openapi/pay/**、/payment/openapi/alipay/**
+ *   与 /payment/openapi/unified/**(前缀判定, 见 OPENAPI_PREFIXES)
  *   /payment/openapi/conf 等管理接口走 JWT 认证, 不经过此 filter
  */
 @Slf4j
@@ -118,6 +118,12 @@ public class TenantApiKeyAuthFilter extends OncePerRequestFilter {
             "/payment/openapi/alipay/order/create",
             "/payment/openapi/alipay/order/query",
             "/payment/openapi/alipay/refund",
+            // 统一支付通道的 5 条开放端点。同上:本数组只供核对,认证判定读 OPENAPI_PREFIXES。
+            "/payment/openapi/unified/order/create",
+            "/payment/openapi/unified/order/query",
+            "/payment/openapi/unified/refund",
+            "/payment/openapi/unified/refund/query",
+            "/payment/openapi/unified/channel-user/jump-url",
     };
 
     /**
@@ -150,6 +156,9 @@ public class TenantApiKeyAuthFilter extends OncePerRequestFilter {
             //   都不被本前缀覆盖;这条边界由 LeshouyingAdminWiringTest 的
             //   adminPaths_mustNotBeCoveredByApiKeyFilterPrefixes 动态守住。
             "/payment/openapi/alipay",
+            // 统一支付通道:与白名单 `/payment/openapi/unified/**` 必须**同名**登记。
+            //   只改一边 = 该前缀下「被 permitAll 放行 + 无人认证」= 零鉴权(2026-09-24 事故形状)。
+            "/payment/openapi/unified",
     };
 
     private static final String AUTH_PREFIX = "ApiKey ";

+ 128 - 0
java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedAuthWiringTest.java

@@ -0,0 +1,128 @@
+package com.payment.platform.module.payment.unified.controller;
+
+import com.payment.platform.core.security.SecurityConfig;
+import com.payment.platform.core.security.TenantApiKeyAuthFilter;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 统一支付通道的**开放 API 认证面**接线守卫。
+ *
+ * <p>这条通道要接入开放 API,必须同时动两处:
+ * <ul>
+ *   <li>{@code SecurityConfig.WHITE_LIST} 放行 {@code /payment/openapi/unified/**}
+ *       与回调 {@code /payment/notify/unified/**};</li>
+ *   <li>{@code TenantApiKeyAuthFilter.OPENAPI_PREFIXES} 认领 {@code /payment/openapi/unified}
+ *       —— 白名单放行而 filter 不认,就是「被 permitAll + 无人认证」的**零鉴权**。</li>
+ * </ul>
+ * 2026-09-24 的零鉴权事故正是这两份清单只改了一边;{@code OpenapiAuthWiringTest}
+ * 负责通用一致性,本类负责把统一支付这 5 条端点**逐条**钉死。
+ *
+ * <p>全部读数走反射读**真实数组**,不在测试里复制实现常量 —— 复制一份等于测自己。
+ */
+class UnifiedAuthWiringTest {
+
+    private static final String PREFIX = "/payment/openapi/unified";
+
+    /**
+     * 被测 filter。{@code shouldNotFilter} 是 protected,本测试不在 core.security 包内,
+     * 故用同形子类把判定暴露成 public 方法(不改生产可见性);三个依赖字段在该路径上不被触碰,可传 null。
+     */
+    private final AuthProbe filter = new AuthProbe();
+
+    private static final class AuthProbe extends TenantApiKeyAuthFilter {
+        AuthProbe() {
+            super(null, null, null);
+        }
+
+        boolean requiresAuth(MockHttpServletRequest request) {
+            return !shouldNotFilter(request);
+        }
+    }
+
+    @Test
+    void openapiPrefixes_containsTheUnifiedPrefix() throws Exception {
+        List<String> prefixes = Arrays.asList(readStringArray(TenantApiKeyAuthFilter.class, "OPENAPI_PREFIXES"));
+
+        assertTrue(prefixes.contains(PREFIX),
+                "OPENAPI_PREFIXES 未认领 " + PREFIX + " ⇒ 该前缀被白名单放行却无人认证 = 零鉴权。读数: " + prefixes);
+        // 正对照:既有通道必须仍在,证明读到的是真数组而不是空壳。
+        assertTrue(prefixes.contains("/payment/openapi/alipay"),
+                "既有 alipay 前缀缺失,说明本用例的量具读错了数组。读数: " + prefixes);
+    }
+
+    @Test
+    void openapiAccountPaths_unifiedSubsetIsExactlyTheFiveEndpoints() throws Exception {
+        List<String> allPaths = Arrays.asList(
+                readStringArray(TenantApiKeyAuthFilter.class, "OPENAPI_ACCOUNT_PATHS"));
+
+        // 该数组是跨通道全局清单,这里只按 unified 前缀取子集核对(见任务简报 R15)。
+        Set<String> unified = allPaths.stream()
+                .filter(p -> p.startsWith(PREFIX + "/"))
+                .collect(Collectors.toSet());
+
+        assertEquals(Set.of(
+                        PREFIX + "/order/create",
+                        PREFIX + "/order/query",
+                        PREFIX + "/refund",
+                        PREFIX + "/refund/query",
+                        PREFIX + "/channel-user/jump-url"),
+                unified,
+                "OPENAPI_ACCOUNT_PATHS 中 unified 端点清单不符。读数: " + unified);
+
+        // 正对照:既有 alipay 端点必须仍在数组中,证明读到的是真数组。
+        assertTrue(allPaths.contains("/payment/openapi/alipay/order/create"),
+                "既有 alipay 端点缺失,说明本用例的量具读错了数组。读数: " + allPaths);
+    }
+
+    @Test
+    void whiteList_permitsUnifiedOpenapiAndNotify() throws Exception {
+        List<String> whiteList = Arrays.asList(readStringArray(SecurityConfig.class, "WHITE_LIST"));
+
+        assertTrue(whiteList.contains(PREFIX + "/**"),
+                "WHITE_LIST 未放行 " + PREFIX + "/**,开放 API 会在授权层被拦成空 403。读数: " + whiteList);
+        assertTrue(whiteList.contains("/payment/notify/unified/**"),
+                "WHITE_LIST 未放行 /payment/notify/unified/**,上游回调进不来。读数: " + whiteList);
+    }
+
+    @Test
+    void shouldNotFilter_unifiedOpenapiRequiresAuth_butUnifiedNotifyDoesNot() {
+        assertTrue(filter.requiresAuth(realRequest(PREFIX + "/order/create")),
+                "开放端点必须经过 ApiKey 认证(shouldNotFilter=false)");
+        assertTrue(filter.requiresAuth(realRequest(PREFIX)),
+                "bare 前缀经 AntPathMatcher 也匹配 " + PREFIX + "/**,必须同样认证");
+        // 正对照:回调入口不走 ApiKey 认证(真实性由通道验签保证)。
+        assertFalse(filter.requiresAuth(realRequest("/payment/notify/unified/order")),
+                "回调路径不应被 ApiKey filter 拦截(shouldNotFilter=true)");
+    }
+
+    // ────────────────────────────── 工具 ──────────────────────────────
+
+    private static String[] readStringArray(Class<?> owner, String fieldName) throws Exception {
+        Field f = owner.getDeclaredField(fieldName);
+        f.setAccessible(true);
+        return (String[]) f.get(null);
+    }
+
+    /**
+     * 构造**真实容器形状**的请求:servletPath 不含 context-path,requestURI 含。
+     * 同 {@code OpenapiAuthWiringTest#realRequest} —— 两者相等就测不出访问器用错。
+     */
+    private static MockHttpServletRequest realRequest(String path) {
+        MockHttpServletRequest req = new MockHttpServletRequest("POST", path);
+        req.setContextPath("/api/v1");
+        req.setServletPath(path);
+        req.setRequestURI("/api/v1" + path);
+        assertNotEquals(req.getServletPath(), req.getRequestURI(),
+                "mock 必须是真实容器形状:servletPath ≠ requestURI,否则测不出访问器用错");
+        return req;
+    }
+}