Forráskód Böngészése

fix(统一支付): 订单快照落库与退款通知金额错误码

alphaH 1 hete
szülő
commit
b6ff99ce46

+ 9 - 1
java/src/main/java/com/payment/platform/module/payment/unified/controller/UnifiedNotifyController.java

@@ -107,7 +107,15 @@ public class UnifiedNotifyController {
             return null;
         }
 
-        Map<String, String[]> parameterMap = request.getParameterMap();
+        Map<String, String[]> parameterMap;
+        try {
+            parameterMap = request.getParameterMap();
+        } catch (RuntimeException e) {
+            // 容器解析参数失败(如上游表单编码坏掉):按报文不可接受处理,不让异常漏成 500
+            log.warn("[统一支付通知] 读取通知参数失败: {}",
+                    UnifiedLogSanitizer.flatten(e.getMessage()));
+            return null;
+        }
         if (parameterMap != null && !parameterMap.isEmpty()) {
             return flattenParameters(parameterMap);
         }

+ 2 - 0
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedNotifyService.java

@@ -141,6 +141,8 @@ public class UnifiedNotifyService {
                 if (payAmount == null || !refundAmountMatches(refund, p)) {
                     log.error("[统一支付通知] 退款金额核对失败,状态不推进: refundNo={}, {}",
                             flatten(refund.getRefundNo()), refundAmountDetail(order, refund, p));
+                    // spec §7.5:与查单腿一样落 AMOUNT_MISMATCH,只写错误码、不碰 state
+                    refundService.markAmountMismatch(refund, refundAmountDetail(order, refund, p));
                     return UnifiedNotifyOutcome.FAIL;
                 }
             }

+ 9 - 0
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedOrderService.java

@@ -349,6 +349,9 @@ public class UnifiedOrderService {
                 ? UnifiedResponseParser.toShanghai(
                         UnifiedResponseParser.epochMillis(upstream, "successTime"))
                 : null;
+        // 与退款事务腿同口径:null upstream 不写快照,避免把已有值覆盖成空
+        String upstreamDataJson = upstream == null
+                ? null : UnifiedUpstreamSnapshot.toJson(upstream);
 
         LambdaUpdateWrapper<UnifiedOrderEntity> update = new LambdaUpdateWrapper<UnifiedOrderEntity>()
                 .eq(UnifiedOrderEntity::getOrderNo, order.getOrderNo())
@@ -362,6 +365,9 @@ public class UnifiedOrderService {
         if (payTime != null) {
             update.set(UnifiedOrderEntity::getPayTime, payTime);
         }
+        if (upstreamDataJson != null) {
+            update.set(UnifiedOrderEntity::getUpstreamDataJson, upstreamDataJson);
+        }
 
         if (orderMapper.update(null, update) == 0) {
             // 并发下另一条腿已经写过:不覆盖、不发事件
@@ -377,6 +383,9 @@ public class UnifiedOrderService {
         if (payTime != null) {
             order.setPayTime(payTime);
         }
+        if (upstreamDataJson != null) {
+            order.setUpstreamDataJson(upstreamDataJson);
+        }
 
         if (NOTIFY_STATES.contains(nextState)) {
             // R23:租户号,不是自增主键

+ 5 - 2
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedRefundService.java

@@ -616,8 +616,11 @@ public class UnifiedRefundService {
 
     /**
      * 金额/币种核对失败:只写错误码,不碰 {@code state}(不会报错但会错钱)。
+     *
+     * <p>查单腿与通知腿共用:{@code detail} 由调用方拼好(上游/本地金额并排),
+     * 日志前先 {@link UnifiedLogSanitizer#flatten}。
      */
-    private void markAmountMismatch(UnifiedRefundEntity refund, String detail) {
+    public void markAmountMismatch(UnifiedRefundEntity refund, String detail) {
         if (refund == null || refund.getRefundNo() == null) {
             return;
         }
@@ -627,7 +630,7 @@ public class UnifiedRefundService {
                 .set(UnifiedRefundEntity::getErrMsg, detail));
         refund.setErrCode(AMOUNT_MISMATCH);
         refund.setErrMsg(detail);
-        log.error("[统一支付退款查询] 退款金额/币种核对失败,状态不推进: refund_no={}, {}",
+        log.error("[统一支付] 退款金额/币种核对失败,状态不推进: refund_no={}, {}",
                 refund.getRefundNo(), UnifiedLogSanitizer.flatten(detail));
     }
 

+ 54 - 0
java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedNotifyControllerTest.java

@@ -115,6 +115,8 @@ class UnifiedNotifyControllerTest {
 
         assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
         assertEquals("FAIL", response.getBody());
+        assertEquals("text/plain;charset=UTF-8",
+                response.getHeaders().getContentType().toString());
     }
 
     @Test
@@ -125,6 +127,8 @@ class UnifiedNotifyControllerTest {
 
         assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
         assertEquals("FAIL", response.getBody());
+        assertEquals("text/plain;charset=UTF-8",
+                response.getHeaders().getContentType().toString());
     }
 
     @Test
@@ -137,6 +141,8 @@ class UnifiedNotifyControllerTest {
 
         assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
         assertEquals("FAIL", response.getBody());
+        assertEquals("text/plain;charset=UTF-8",
+                response.getHeaders().getContentType().toString());
         verifyNoInteractions(notifyService);
     }
 
@@ -156,6 +162,8 @@ class UnifiedNotifyControllerTest {
 
         assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
         assertEquals("FAIL", response.getBody());
+        assertEquals("text/plain;charset=UTF-8",
+                response.getHeaders().getContentType().toString());
         verifyNoInteractions(notifyService);
     }
 
@@ -215,6 +223,8 @@ class UnifiedNotifyControllerTest {
 
         assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
         assertEquals("FAIL", response.getBody());
+        assertEquals("text/plain;charset=UTF-8",
+                response.getHeaders().getContentType().toString());
         verifyNoInteractions(notifyService);
     }
 
@@ -226,9 +236,53 @@ class UnifiedNotifyControllerTest {
 
         assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
         assertEquals("FAIL", response.getBody());
+        assertEquals("text/plain;charset=UTF-8",
+                response.getHeaders().getContentType().toString());
         verifyNoInteractions(notifyService);
     }
 
+    @Test
+    void parameterMapRuntimeException_returns400Fail_withoutDispatch() {
+        // 容器解析参数时可能抛 IllegalStateException(如 form 解析器状态坏掉);
+        // 不得漏成 500/空白应答 —— 上游只认 FAIL 才能重推
+        MockHttpServletRequest request = new MockHttpServletRequest("POST", ROOT + "/order") {
+            @Override
+            public Map<String, String[]> getParameterMap() {
+                throw new IllegalStateException("参数解析失败");
+            }
+        };
+        request.setContentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE);
+
+        ResponseEntity<String> response = controller.orderNotify(request);
+
+        assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+        assertEquals("FAIL", response.getBody());
+        assertEquals("text/plain;charset=UTF-8",
+                response.getHeaders().getContentType().toString());
+        verifyNoInteractions(notifyService);
+    }
+
+    @Test
+    void exactly64KJsonBody_isAccepted() {
+        when(notifyService.handleOrder(any())).thenReturn(UnifiedNotifyOutcome.SUCCESS);
+        String prefix = "{\"mchNo\":\"M1\",\"pad\":\"";
+        String suffix = "\"}";
+        int padLength = 65536 - prefix.getBytes(StandardCharsets.UTF_8).length
+                - suffix.getBytes(StandardCharsets.UTF_8).length;
+        String body = prefix + " ".repeat(padLength) + suffix;
+        assertEquals(65536, body.getBytes(StandardCharsets.UTF_8).length,
+                "测试报文必须恰好 65536 字节(上限含等号)");
+
+        ResponseEntity<String> response = controller.orderNotify(jsonRequest(body));
+
+        assertEquals(HttpStatus.OK, response.getStatusCode(),
+                "恰好 64KB 不算超限,必须受理");
+        assertEquals("SUCCESS", response.getBody());
+        Map<String, String> params = capturedOrderParams();
+        assertEquals("M1", params.get("mchNo"));
+        assertEquals(padLength, params.get("pad").length());
+    }
+
     @Test
     void emptyForm_isDispatchedWithEmptyMap_andMappedNormally() {
         when(notifyService.handleOrder(any())).thenReturn(UnifiedNotifyOutcome.UNAUTHORIZED);

+ 3 - 0
java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedNotifyServiceTest.java

@@ -33,6 +33,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNotSame;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.contains;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.ArgumentMatchers.same;
@@ -709,6 +710,8 @@ class UnifiedNotifyServiceTest {
 
         assertEquals(UnifiedNotifyOutcome.FAIL, service.handleRefund(signed(params)),
                 field + " 不符/缺失必须 FAIL");
+        // spec §7.5:核对失败必须落 AMOUNT_MISMATCH,否则页面/对账只看到「状态没动」
+        verify(refundService).markAmountMismatch(same(refund), contains(field));
         verify(refundService, never()).applyRefundState(any(), any(), any(), any(), any(), any());
     }
 

+ 59 - 0
java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedOrderSyncTest.java

@@ -15,6 +15,7 @@ import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
 import com.payment.platform.module.payment.unified.mapper.UnifiedOrderMapper;
 import com.payment.platform.module.payment.unified.mapper.UnifiedRefundMapper;
 import com.payment.platform.module.payment.unified.util.UnifiedPollPolicy;
+import com.payment.platform.module.payment.unified.util.UnifiedUpstreamSnapshot;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -32,6 +33,7 @@ import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+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;
@@ -146,6 +148,16 @@ class UnifiedOrderSyncTest {
         return ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
     }
 
+    /** 从 SET 子句里取某一列绑定的参数值(MyBatis-Plus 的参数名形如 {@code paramNameValuePairs.MPGENVAL1})。 */
+    private static Object setValue(LambdaUpdateWrapper<UnifiedOrderEntity> w, String column) {
+        String set = w.getSqlSet();
+        Matcher m = Pattern.compile(Pattern.quote(column)
+                        + "=#\\{ew\\.paramNameValuePairs\\.([A-Za-z0-9_]+)\\}")
+                .matcher(set);
+        assertTrue(m.find(), "SET 缺列 " + column + ",实测: " + set);
+        return w.getParamNameValuePairs().get(m.group(1));
+    }
+
     /** 只取 {@code trade_status IN (...)} 谓词引用到的绑定值(同款口径见 UnifiedOrderServiceTest)。 */
     private static Set<String> inValues(LambdaQueryWrapper<UnifiedOrderEntity> w) {
         Matcher inPredicate = Pattern.compile("trade_status IN \\(([^)]*)\\)")
@@ -208,6 +220,53 @@ class UnifiedOrderSyncTest {
                 "敏感字段不得进入下游回调的 upstream 投影");
     }
 
+    @Test
+    void stateTwoWithMatchingAmount_persistsWhitelistedSnapshotOnRowAndMemory() {
+        JsonNode data = json("{\"payOrderId\":\"P1\",\"state\":2,\"amount\":100,\"currency\":\"cny\","
+                + "\"successTime\":1760000000000,\"openid\":\"SECRET-OPENID\"}");
+        when(merchantService.requireById(11L)).thenReturn(merchant());
+        when(client.queryOrder(any(), eq("UP5"))).thenReturn(data);
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        UnifiedOrderEntity order = order("0");
+        service.syncFromUpstream(order);
+
+        // 回调重试读的是行上的 upstream_data_json:必须是白名单投影后的同一份 JSON
+        String expected = UnifiedUpstreamSnapshot.toJson(UnifiedUpstreamSnapshot.of(data));
+        ArgumentCaptor<LambdaUpdateWrapper<UnifiedOrderEntity>> w = updateCaptor();
+        verify(orderMapper, atLeastOnce()).update(isNull(), w.capture());
+        LambdaUpdateWrapper<UnifiedOrderEntity> apply = w.getAllValues().stream()
+                .filter(each -> each.getSqlSet() != null
+                        && each.getSqlSet().contains("upstream_data_json"))
+                .findFirst()
+                .orElseThrow(() -> new AssertionError(
+                        "状态迁移的 UPDATE 必须落 upstream_data_json,实测 SET: "
+                                + w.getAllValues().stream()
+                                .map(LambdaUpdateWrapper::getSqlSet).toList()));
+        assertEquals(expected, setValue(apply, "upstream_data_json"));
+        assertEquals(expected, order.getUpstreamDataJson(),
+                "内存行也要同步,否则同一次回调重试读到旧快照");
+        assertFalse(expected.contains("SECRET-OPENID"), "白名单快照不得含 openid: " + expected);
+    }
+
+    @Test
+    void applyOrderState_withNullUpstream_doesNotSetSnapshotOrOverwriteMemory() {
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        UnifiedOrderEntity order = order("0");
+        order.setUpstreamDataJson("{\"payOrderId\":\"OLD\"}");
+
+        assertTrue(service.applyOrderState(order, "2", null, null, null));
+
+        ArgumentCaptor<LambdaUpdateWrapper<UnifiedOrderEntity>> w = updateCaptor();
+        verify(orderMapper).update(isNull(), w.capture());
+        String set = w.getValue().getSqlSet();
+        assertFalse(set.contains("upstream_data_json"),
+                "upstream 为 null 不得写快照列(会覆盖旧值),实测: " + set);
+        assertEquals("{\"payOrderId\":\"OLD\"}", order.getUpstreamDataJson(),
+                "内存行不得被 null 清空");
+    }
+
     @Test
     void amountMismatch_doesNotAdvance() {
         JsonNode[] responses = {

+ 30 - 0
java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedRefundSyncTest.java

@@ -30,6 +30,8 @@ import org.slf4j.LoggerFactory;
 
 import java.time.Duration;
 import java.time.OffsetDateTime;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -166,6 +168,16 @@ class UnifiedRefundSyncTest {
         return ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
     }
 
+    /** 从 SET 子句里取某一列绑定的参数值(MyBatis-Plus 的参数名形如 {@code paramNameValuePairs.MPGENVAL1})。 */
+    private static Object setValue(LambdaUpdateWrapper<UnifiedRefundEntity> w, String column) {
+        String set = w.getSqlSet();
+        Matcher m = Pattern.compile(Pattern.quote(column)
+                        + "=#\\{ew\\.paramNameValuePairs\\.([A-Za-z0-9_]+)\\}")
+                .matcher(set);
+        assertTrue(m.find(), "SET 缺列 " + column + ",实测: " + set);
+        return w.getParamNameValuePairs().get(m.group(1));
+    }
+
     private static ListAppender<ILoggingEvent> attachLog(Class<?> type) {
         Logger logger = (Logger) LoggerFactory.getLogger(type);
         ListAppender<ILoggingEvent> appender = new ListAppender<>();
@@ -387,6 +399,24 @@ class UnifiedRefundSyncTest {
         assertNoDirectStateWrite();
     }
 
+    @Test
+    void markAmountMismatch_publicEntryWritesCodeAndDetailWithoutTouchingState() {
+        // 通知腿(Task 14)与查单腿共用同一条落错误码的实现:必须是 public 且不写 state
+        UnifiedRefundEntity refund = refund("1");
+        String detail = "上游 refundAmount=99, currency=cny; 本地 amount=100, currency=cny";
+
+        service.markAmountMismatch(refund, detail);
+
+        ArgumentCaptor<LambdaUpdateWrapper<UnifiedRefundEntity>> w = updateCaptor();
+        verify(refundMapper).update(isNull(), w.capture());
+        String set = w.getValue().getSqlSet();
+        assertFalse(set.contains("state"), "只写错误码,不得碰 state: " + set);
+        assertEquals("AMOUNT_MISMATCH", String.valueOf(setValue(w.getValue(), "err_code")));
+        assertEquals(detail, String.valueOf(setValue(w.getValue(), "err_msg")));
+        assertEquals("AMOUNT_MISMATCH", refund.getErrCode());
+        assertEquals(detail, refund.getErrMsg());
+    }
+
     /** 正对照:金额/币种一致必须放行。 */
     @Test
     void stateTwo_amountAndCurrencyMatch_applies() {