ソースを参照

fix(统一支付): 修复 H5 页评审发现的两重要三项次要

alphaH 1 週間 前
親
コミット
681c1b57fa

+ 21 - 6
frontend/public/pay/unified-h5.html

@@ -19,6 +19,8 @@
     --radius: 14px;
   }
   * { box-sizing: border-box; }
+  /* UA 的 [hidden] 会被下面 ID 选择器里的 display:block 盖过:空「去支付」链接与坏图会露出来 */
+  [hidden] { display: none !important; }
   html, body { margin: 0; padding: 0; }
   body {
     min-height: 100vh;
@@ -365,22 +367,35 @@
     }).then(function (response) {
       return response.json().catch(function () {
         return {};
+      }).then(function (json) {
+        return { status: response.status, json: json };
       });
-    }).then(function (json) {
-      if (!json || json.code !== 0 || !json.data) {
-        throw new Error((json && json.msg) || "查询订单失败");
+    }).then(function (out) {
+      var json = out.json || {};
+      // 404 是终态(单不存在/令牌失效):继续轮询只会每 3 秒重复一次无效请求
+      if (out.status === 404) {
+        stopPolling();
+        showResultError(json.msg || "订单不存在或链接已过期");
+        return;
+      }
+      if (out.status !== 200 || json.code !== 0 || !json.data) {
+        throw new Error(json.msg || "查询订单失败");
       }
       renderOrder(json.data);
       if (STOPPED_STATUS.indexOf(String(json.data.trade_status)) >= 0) {
         stopPolling();
       }
     }).catch(function (error) {
-      var errorBox = document.getElementById("result-error");
-      errorBox.textContent = (error && error.message) || "网络异常,稍后自动重试";
-      errorBox.hidden = false;
+      showResultError((error && error.message) || "网络异常,稍后自动重试");
     });
   }
 
+  function showResultError(message) {
+    var errorBox = document.getElementById("result-error");
+    errorBox.textContent = message;
+    errorBox.hidden = false;
+  }
+
   function renderOrder(data) {
     if (!data) {
       return;

+ 15 - 0
java/src/main/java/com/payment/platform/module/payment/unified/controller/UnifiedH5Controller.java

@@ -7,12 +7,16 @@ import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
 import com.payment.platform.module.payment.unified.service.UnifiedH5Service;
 import jakarta.servlet.http.HttpServletRequest;
 import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
 import org.springframework.web.bind.annotation.RestController;
 
 import java.util.Map;
@@ -55,6 +59,17 @@ public class UnifiedH5Controller {
         return Result.ok(UnifiedH5OrderVO.open(order, null));
     }
 
+    /**
+     * 请求体无法反序列化成「字符串键值对」时(例如对象值的未知键),Jackson 在进入
+     * {@link #create} 之前就抛 {@link HttpMessageNotReadableException};全局兜底会把它
+     * 变成 500。公开端点必须与其它参数错误同一应答:400 + 同一句话,且**不记录任何参数值**。
+     */
+    @ExceptionHandler(HttpMessageNotReadableException.class)
+    @ResponseStatus(HttpStatus.BAD_REQUEST)
+    public Result<Void> handleUnreadableBody() {
+        return Result.of(400, UnifiedH5Service.MSG_INCOMPLETE);
+    }
+
     /**
      * 顾客的真实 IP。与 {@code HarCheckoutController#clientIp} /
      * {@code TenantApiKeyAuthFilter} 同形:四个请求头(取第一跳)+ {@code unknown} 兜底。

+ 2 - 1
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedH5Service.java

@@ -61,7 +61,8 @@ public class UnifiedH5Service {
     /** 同一次页面轮询的节流窗(秒),与收银台快照同口径。 */
     private static final long SNAPSHOT_THROTTLE_SECONDS = 3;
 
-    private static final String MSG_INCOMPLETE = "支付链接参数不完整";
+    /** 公开端点统一的参数错误文案;控制器处理坏请求体时也复用它(单一来源)。 */
+    public static final String MSG_INCOMPLETE = "支付链接参数不完整";
     private static final String MSG_INVALID_LINK = "支付链接无效或已过期";
     private static final String MSG_ORDER_NOT_FOUND = "订单不存在";
     private static final String MSG_CREATE_FAILED = "支付创建失败,请稍后重试";

+ 44 - 3
java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedH5ControllerTest.java

@@ -1,13 +1,20 @@
 package com.payment.platform.module.payment.unified.controller;
 
+import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.payment.platform.common.config.JacksonConfig;
 import com.payment.platform.core.security.JwtAuthFilter;
 import com.payment.platform.core.security.SecurityConfig;
 import com.payment.platform.core.security.TenantApiKeyAuthFilter;
 import com.payment.platform.module.payment.unified.dto.vo.UnifiedH5OrderVO;
 import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.service.UnifiedH5Service;
 import org.junit.jupiter.api.Test;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockHttpServletRequest;
 import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
@@ -15,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
 import java.util.Arrays;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -25,6 +33,10 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
 /**
  * 公开 H5 端点(Task 24)的契约与认证面。
@@ -120,7 +132,7 @@ class UnifiedH5ControllerTest {
     }
 
     @Test
-    void orderVo_serialization_leaksNoInternalFields() throws Exception {
+    void orderVo_serialization_hasExactlyAllowedSnakeCaseKeys() throws Exception {
         UnifiedOrderEntity entity = new UnifiedOrderEntity();
         entity.setId(1234567890123456789L);
         entity.setTenantId(2L);
@@ -132,8 +144,18 @@ class UnifiedH5ControllerTest {
         entity.setSubject("测试订单");
         entity.setTradeStatus("0");
 
-        String json = new ObjectMapper()
-                .writeValueAsString(UnifiedH5OrderVO.open(entity, "tok"));
+        // 生产 mapper(全局 SNAKE_CASE + JavaTimeModule),不是裸 ObjectMapper
+        ObjectMapper mapper = new JacksonConfig().objectMapper();
+        String json = mapper.writeValueAsString(UnifiedH5OrderVO.open(entity, "tok"));
+        JsonNode node = mapper.readTree(json);
+
+        Set<String> actual = new HashSet<>();
+        node.fieldNames().forEachRemaining(actual::add);
+        assertEquals(Set.of(
+                        "order_no", "token", "out_trade_no", "mch_no", "way_code", "amount",
+                        "subject", "trade_status", "pay_data_type", "pay_data",
+                        "err_code", "err_msg", "created_time"),
+                actual, "H5 视图序列化键必须恰好是 13 个 snake_case 契约键: " + json);
 
         assertFalse(json.contains("\"id\""), "不得暴露内部主键: " + json);
         assertFalse(json.contains("tenant_id"), "不得暴露租户归属: " + json);
@@ -142,6 +164,25 @@ class UnifiedH5ControllerTest {
         assertFalse(json.contains("appSecret"), "不得暴露密钥: " + json);
     }
 
+    /**
+     * 对象值的未知键(例如 {@code {"channel_extra":{"a":1}}})在 Jackson 反序列化
+     * {@code Map<String,String>} 时就失败,根本走不到服务层的 ALLOWED_KEYS。
+     * 控制器必须把它翻成与参数错误同一应答(400 + 同一句话),而不是全局兜底的 500。
+     */
+    @Test
+    void objectValuedKey_is400_withIncompleteMessage_not500() throws Exception {
+        UnifiedH5Controller controller = new UnifiedH5Controller(
+                mock(UnifiedH5Service.class), new MockHttpServletRequest());
+        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+
+        mockMvc.perform(post(PREFIX + "/order")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"mch_no\":\"M001\",\"channel_extra\":{\"a\":1}}"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.msg").value("支付链接参数不完整"));
+    }
+
     private static String[] readStringArray(Class<?> owner, String fieldName) throws Exception {
         Field f = owner.getDeclaredField(fieldName);
         f.setAccessible(true);

+ 16 - 0
java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedH5PageContractTest.java

@@ -75,6 +75,22 @@ class UnifiedH5PageContractTest {
                 "fetch 必须显式不带凭据");
     }
 
+    @Test
+    void page_hiddenRuleWins_and404StopsPolling() throws IOException {
+        String html = html();
+
+        Matcher hiddenRule = Pattern.compile(
+                "\\[hidden\\]\\s*\\{\\s*display:\\s*none\\s*!important;?\\s*}").matcher(html);
+        assertTrue(hiddenRule.find(),
+                "缺 [hidden] { display: none !important; }:ID 选择器上的 display 规则会盖过 UA 的 [hidden],"
+                        + "空「去支付」链接与坏图会显示出来");
+
+        Matcher stopOn404 = Pattern.compile(
+                "if \\(out\\.status === 404\\) \\{\\s*stopPolling\\(\\);").matcher(html);
+        assertTrue(stopOn404.find(),
+                "404 是终态:轮询必须在该分支 stopPolling(),否则每 3 秒重复一次无效请求");
+    }
+
     @Test
     void H5_WAY_CODES_equalsBackendSet() throws IOException {
         String html = html();

+ 55 - 0
java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedH5ServiceTest.java

@@ -182,6 +182,47 @@ class UnifiedH5ServiceTest {
         verifyNoInteractions(orderService);
     }
 
+    @Test
+    void create_orderService400_isRethrownVerbatim() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg);
+        BusinessException paramError = new BusinessException(400, "参数类错误原样透出");
+        when(orderService.create(any(), eq(2L), isNull(), eq(UnifiedSource.H5)))
+                .thenThrow(paramError);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.create(signed(unsignedParams()), "203.0.113.9"));
+
+        assertSame(paramError, e, "400 必须原样透出,不能换成 500");
+        assertEquals(400, e.getCode());
+        assertEquals("参数类错误原样透出", e.getMessage());
+    }
+
+    @Test
+    void create_orderService409_mapsTo500_withoutLeakingConflict() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg);
+        when(orderService.create(any(), eq(2L), isNull(), eq(UnifiedSource.H5)))
+                .thenThrow(new BusinessException(409, "幂等冲突"));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.create(signed(unsignedParams()), "203.0.113.9"));
+
+        assertEquals(500, e.getCode(), "实测消息: " + e.getMessage());
+        assertEquals("支付创建失败,请稍后重试", e.getMessage(), "不向公开调用方泄露内部冲突详情");
+    }
+
+    @Test
+    void create_orderServiceRuntimeFailure_mapsTo500() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg);
+        when(orderService.create(any(), eq(2L), isNull(), eq(UnifiedSource.H5)))
+                .thenThrow(new RuntimeException("数据库连接断了"));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.create(signed(unsignedParams()), "203.0.113.9"));
+
+        assertEquals(500, e.getCode(), "实测消息: " + e.getMessage());
+        assertEquals("支付创建失败,请稍后重试", e.getMessage());
+    }
+
     @Test
     void create_wayCodeOutsideH5Supported_is400_beforeAnyMerchantCall() {
         Map<String, String> params = unsignedParams();
@@ -260,6 +301,20 @@ class UnifiedH5ServiceTest {
         verify(orderService, never()).syncFromUpstream(any());
     }
 
+    @Test
+    void snapshot_merchantLookupFailure_is404_withoutSync() {
+        when(orderService.requireByOrderNo("UP1")).thenReturn(order("0"));
+        when(merchantService.requireById(MERCHANT_ID))
+                .thenThrow(new BusinessException(404, "商户配置不存在"));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.snapshot("UP1", UnifiedH5Token.issue(SECRET, "UP1")));
+
+        assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
+        assertEquals("订单不存在", e.getMessage(), "不暴露商户配置缺失");
+        verify(orderService, never()).syncFromUpstream(any());
+    }
+
     @Test
     void snapshot_nonPendingStatuses_returnRowWithoutUpstreamCall() {
         for (String status : List.of("2", "3", "4", "5", "6")) {