Browse Source

fix(统一支付): 评审 12 项后端修复(I1-I3、M2-M9)

- I1:接收并忽略 division_mode,开放 API 不再 500
- I2:订单/退款落库即写 15s 兜底 next_query_time,识别到状态后回 5s 首档
- I3:已有交易时禁止修改 mch_no/app_id,与删除共用同一对查询
- M2:开放/收银台视图不再返回 next_query_time
- M3:reqTime 固定按东八区格式化
- M4:三处日志可控字符串先 flatten
- M5:开放/收银台坏请求体返回 400 固定文案
- M6:按 DDL 列宽补 @Size,expiredTime 只加 @Positive
- M7:H5 建单 4xx 原样透出,仅 5xx 折叠
- M8:订单腿转 5 时 refunded_amount 写订单金额
- M9:退款查单腿 payAmount 存在且不等时拦截
- 新增 UnifiedValidationHttpTest 并补齐各服务/控制器单测
alphaH 1 week ago
parent
commit
3c02681b7c
24 changed files with 835 additions and 23 deletions
  1. 5 2
      java/src/main/java/com/payment/platform/module/payment/unified/client/UnifiedClient.java
  2. 15 0
      java/src/main/java/com/payment/platform/module/payment/unified/controller/UnifiedCashierController.java
  3. 15 0
      java/src/main/java/com/payment/platform/module/payment/unified/controller/open/UnifiedOpenController.java
  4. 11 1
      java/src/main/java/com/payment/platform/module/payment/unified/dto/req/UnifiedMerchantSaveDTO.java
  5. 9 0
      java/src/main/java/com/payment/platform/module/payment/unified/dto/req/UnifiedOrderCreateCommand.java
  6. 2 0
      java/src/main/java/com/payment/platform/module/payment/unified/dto/req/UnifiedRefundCreateCommand.java
  7. 5 0
      java/src/main/java/com/payment/platform/module/payment/unified/dto/vo/UnifiedOrderVO.java
  8. 5 0
      java/src/main/java/com/payment/platform/module/payment/unified/dto/vo/UnifiedRefundVO.java
  9. 9 4
      java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedH5Service.java
  10. 29 11
      java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedMerchantService.java
  11. 21 0
      java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedOrderService.java
  12. 19 1
      java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedRefundService.java
  13. 4 0
      java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedRefundTxService.java
  14. 3 1
      java/src/main/java/com/payment/platform/module/payment/unified/util/UnifiedChannelExtraAssembler.java
  15. 20 0
      java/src/test/java/com/payment/platform/module/payment/unified/client/UnifiedClientTest.java
  16. 35 0
      java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedCashierControllerTest.java
  17. 249 0
      java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedValidationHttpTest.java
  18. 65 0
      java/src/test/java/com/payment/platform/module/payment/unified/controller/open/UnifiedOpenControllerTest.java
  19. 20 3
      java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedH5ServiceTest.java
  20. 80 0
      java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedMerchantServiceTest.java
  21. 106 0
      java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedOrderServiceTest.java
  22. 30 0
      java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedRefundServiceTest.java
  23. 41 0
      java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedRefundSyncTest.java
  24. 37 0
      java/src/test/java/com/payment/platform/module/payment/unified/util/UnifiedChannelExtraAssemblerTest.java

+ 5 - 2
java/src/main/java/com/payment/platform/module/payment/unified/client/UnifiedClient.java

@@ -6,12 +6,14 @@ import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.payment.platform.module.payment.unified.config.UnifiedPayProperties;
 import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
+import com.payment.platform.module.payment.unified.util.UnifiedLogSanitizer;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
 
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.time.OffsetDateTime;
+import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -180,7 +182,8 @@ public class UnifiedClient {
         return switch (format) {
             case "epoch13" -> String.valueOf(now.toInstant().toEpochMilli());
             case "epoch10" -> String.valueOf(now.toInstant().getEpochSecond());
-            default -> now.format(COMPACT_14);
+            // 合约时区固定东八区:不跟随部署 JVM 的默认时区(与订单日期边界同口径)
+            default -> now.atZoneSameInstant(ZoneId.of("Asia/Shanghai")).format(COMPACT_14);
         };
     }
 
@@ -224,7 +227,7 @@ public class UnifiedClient {
         if (code != 0 && code != 200) {
             String msg = textOrNull(root.get("msg"));
             log.error("[统一支付] {} 业务失败 mchOrderNo={} mchRefundNo={} code={} msg={}",
-                    api, mchOrderNo, mchRefundNo, code, msg);
+                    api, mchOrderNo, mchRefundNo, code, UnifiedLogSanitizer.flatten(msg));
             throw new UnifiedApiException(code, msg);
         }
         return data;

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

@@ -15,12 +15,16 @@ import com.payment.platform.module.payment.unified.util.UnifiedTenantGuard;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageNotReadableException;
 import org.springframework.security.access.prepost.PreAuthorize;
+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.ResponseStatus;
 import org.springframework.web.bind.annotation.RestController;
 
 /**
@@ -83,4 +87,15 @@ public class UnifiedCashierController {
         LoginUser user = guard.require();
         return Result.ok(new UnifiedH5LinkVO(h5Service.linkForOrder(orderNo, user.getTenantId())));
     }
+
+    /**
+     * 请求体无法反序列化(语法错误/未知字段)时 Jackson 在进入方法前就抛
+     * {@link HttpMessageNotReadableException};全局兜底会把它变成 500。
+     * 与 H5 控制器同形:400 + 固定文案,且<b>不记录任何参数值</b>。
+     */
+    @ExceptionHandler(HttpMessageNotReadableException.class)
+    @ResponseStatus(HttpStatus.BAD_REQUEST)
+    public Result<Void> handleUnreadableBody() {
+        return Result.of(400, "请求体格式错误");
+    }
 }

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

@@ -23,9 +23,13 @@ import jakarta.servlet.http.HttpServletRequest;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
 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.ResponseStatus;
 import org.springframework.web.bind.annotation.RestController;
 
 /**
@@ -118,6 +122,17 @@ public class UnifiedOpenController {
 
     // ---------- helpers ----------
 
+    /**
+     * 请求体无法反序列化(语法错误/未知字段)时 Jackson 在进入方法前就抛
+     * {@link HttpMessageNotReadableException};全局兜底会把它变成 500。
+     * 与 H5 控制器同形:400 + 固定文案,且<b>不记录任何参数值</b>。
+     */
+    @ExceptionHandler(HttpMessageNotReadableException.class)
+    @ResponseStatus(HttpStatus.BAD_REQUEST)
+    public Result<Void> handleUnreadableBody() {
+        return Result.of(400, "请求体格式错误");
+    }
+
     /** 五个端点共用的认证后置守卫:没有租户上下文即 401,先于任何服务调用。 */
     private Long requireTenantId() {
         Long tenantId = getOpenapiTenantId();

+ 11 - 1
java/src/main/java/com/payment/platform/module/payment/unified/dto/req/UnifiedMerchantSaveDTO.java

@@ -1,17 +1,20 @@
 package com.payment.platform.module.payment.unified.dto.req;
 
+import jakarta.validation.constraints.Size;
 import lombok.Data;
 
 /**
  * 新增/修改统一支付商户配置的请求体。
  *
- * <p>字段是否必填**按分支不同**,所以不挂 Bean Validation 注解:
+ * <p>字段是否必填**按分支不同**,所以不挂「必填」类 Bean Validation 注解:
  * <ul>
  *   <li>新增:{@code tenantId} / {@code mchNo} / {@code appId} / {@code appSecret} 必填;</li>
  *   <li>修改:{@code tenantId} 一律忽略(归属不可改),{@code appSecret} 留空表示不修改。</li>
  * </ul>
  * 「留空 = 不修改」与「新增必填」是互斥规则,注解表达不了,校验由
  * {@code UnifiedMerchantService#save} 按分支执行。
+ *
+ * <p>长度上限与 DDL 列宽一致,可以且应当挂注解:超长值没有「留空 = 不修改」的歧义。
  */
 @Data
 public class UnifiedMerchantSaveDTO {
@@ -23,22 +26,29 @@ public class UnifiedMerchantSaveDTO {
     private Long tenantId;
 
     /** 上游商户号,全局唯一;上游通知只带它,无认证上下文靠它定位租户 */
+    @Size(max = 64, message = "mchNo 不能超过 64 字符")
     private String mchNo;
 
     /** 上游应用 ID */
+    @Size(max = 64, message = "appId 不能超过 64 字符")
     private String appId;
 
     /** MD5 私钥:不返回前端、尽量少经手;修改时留空表示保留原值 */
+    @Size(max = 128, message = "appSecret 不能超过 128 字符")
     private String appSecret;
 
     /** 显示名 */
+    @Size(max = 128, message = "merchantName 不能超过 128 字符")
     private String merchantName;
 
     /** 渠道用户 ID 跳转的 ifCode;留空由数据库默认值兜底({@code fuioupay}) */
+    @Size(max = 16, message = "channelUserIfCode 不能超过 16 字符")
     private String channelUserIfCode;
 
     /** {@code 0}=启用、{@code 1}=停用;留空由数据库默认值兜底({@code 0}) */
+    @Size(max = 10, message = "status 不能超过 10 字符")
     private String status;
 
+    @Size(max = 512, message = "description 不能超过 512 字符")
     private String description;
 }

+ 9 - 0
java/src/main/java/com/payment/platform/module/payment/unified/dto/req/UnifiedOrderCreateCommand.java

@@ -18,6 +18,7 @@ import java.util.Map;
 public class UnifiedOrderCreateCommand {
 
     /** 上游商户号;租户只有一条配置时可空(服务层解析)。 */
+    @Size(max = 64, message = "mchNo 不能超过 64 字符")
     private String mchNo;
 
     /** 金额,分。 */
@@ -27,6 +28,7 @@ public class UnifiedOrderCreateCommand {
 
     /** 支付方式,见 spec §4.6 的 16 个值(大小写敏感)。 */
     @NotBlank(message = "wayCode 不能为空")
+    @Size(max = 16, message = "wayCode 不能超过 16 字符")
     private String wayCode;
 
     /** 商品标题。 */
@@ -44,8 +46,14 @@ public class UnifiedOrderCreateCommand {
     private String outTradeNo;
 
     /** 发起支付的客户端 IP。 */
+    @Size(max = 64, message = "clientIp 不能超过 64 字符")
     private String clientIp;
 
+    /**
+     * 分账标记。本期不实现分账;仅为兼容调用方传值,不参与校验、不转发上游。
+     */
+    private Integer divisionMode;
+
     /**
      * 下游回调地址快照。
      *
@@ -59,6 +67,7 @@ public class UnifiedOrderCreateCommand {
     private String returnUrl;
 
     /** 过期间隔(秒);空则 7200。 */
+    @Positive(message = "expiredTime 必须大于 0")
     private Integer expiredTime;
 
     /** 按 wayCode 收敛的额外参数(对象形态;服务层序列化成紧凑 JSON 字符串)。 */

+ 2 - 0
java/src/main/java/com/payment/platform/module/payment/unified/dto/req/UnifiedRefundCreateCommand.java

@@ -40,8 +40,10 @@ public class UnifiedRefundCreateCommand {
     private String outRefundNo;
 
     /** 本笔退款的下游回调地址快照;空则走默认取址链。 */
+    @Size(max = 512, message = "notifyUrl 不能超过 512 字符")
     private String notifyUrl;
 
     /** 透传参数。 */
+    @Size(max = 512, message = "extParam 不能超过 512 字符")
     private String extParam;
 }

+ 5 - 0
java/src/main/java/com/payment/platform/module/payment/unified/dto/vo/UnifiedOrderVO.java

@@ -46,6 +46,9 @@ public class UnifiedOrderVO {
     private OffsetDateTime createdTime;
     private OffsetDateTime updatedTime;
     private String notifyStatus;
+
+    /** 仅后台;开放 API/收银台视图在 {@link #open} 里显式清空(spec §8.4 无此字段)。 */
+    @JsonInclude(JsonInclude.Include.NON_NULL)
     private OffsetDateTime nextQueryTime;
 
     /** 后台视图(含 id / tenantId);{@code refundable} 为空表示列表不算可退金额。 */
@@ -67,6 +70,8 @@ public class UnifiedOrderVO {
         }
         UnifiedOrderVO vo = new UnifiedOrderVO();
         fill(vo, e, refundable);
+        // spec §8.4 的字段表没有 next_query_time:内部调度时间不外泄
+        vo.setNextQueryTime(null);
         return vo;
     }
 

+ 5 - 0
java/src/main/java/com/payment/platform/module/payment/unified/dto/vo/UnifiedRefundVO.java

@@ -35,6 +35,9 @@ public class UnifiedRefundVO {
     private OffsetDateTime createdTime;
     private OffsetDateTime updatedTime;
     private String notifyStatus;
+
+    /** 仅后台;开放 API 视图在 {@link #open} 里显式清空(spec §8.4 无此字段)。 */
+    @JsonInclude(JsonInclude.Include.NON_NULL)
     private OffsetDateTime nextQueryTime;
 
     /** 后台视图:含内部主键。 */
@@ -55,6 +58,8 @@ public class UnifiedRefundVO {
         }
         UnifiedRefundVO vo = new UnifiedRefundVO();
         fill(vo, e);
+        // spec §8.4 的字段表没有 next_query_time:内部调度时间不外泄
+        vo.setNextQueryTime(null);
         return vo;
     }
 

+ 9 - 4
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedH5Service.java

@@ -14,6 +14,7 @@ import com.payment.platform.module.payment.unified.enums.UnifiedSource;
 import com.payment.platform.module.payment.unified.mapper.UnifiedOrderMapper;
 import com.payment.platform.module.payment.unified.util.UnifiedChannelExtraAssembler;
 import com.payment.platform.module.payment.unified.util.UnifiedH5Token;
+import com.payment.platform.module.payment.unified.util.UnifiedLogSanitizer;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -150,13 +151,17 @@ public class UnifiedH5Service {
             SecurityContextHolder.setContext(scopedTo(cfg.getTenantId()));
             order = orderService.create(cmd, cfg.getTenantId(), null, UnifiedSource.H5);
         } catch (BusinessException e) {
-            if (e.getCode() == 400) {
+            // 4xx 是调用方可修正的错误(参数/幂等冲突等):原样透出,不折叠成 500。
+            // 只有 5xx/未知错误才换成人畜无害的统一文案。
+            if (e.getCode() >= 400 && e.getCode() < 500) {
                 throw e;
             }
-            log.warn("[统一支付H5] 建单失败: mch_no={}, 原因={}", mchNo, e.getMessage());
+            log.warn("[统一支付H5] 建单失败: mch_no={}, 原因={}",
+                    mchNo, UnifiedLogSanitizer.flatten(e.getMessage()));
             throw new BusinessException(500, MSG_CREATE_FAILED);
         } catch (RuntimeException e) {
-            log.warn("[统一支付H5] 建单异常: mch_no={}, 原因={}", mchNo, e.getMessage());
+            log.warn("[统一支付H5] 建单异常: mch_no={}, 原因={}",
+                    mchNo, UnifiedLogSanitizer.flatten(e.getMessage()));
             throw new BusinessException(500, MSG_CREATE_FAILED);
         } finally {
             SecurityContextHolder.setContext(previous);
@@ -207,7 +212,7 @@ public class UnifiedH5Service {
             orderService.syncFromUpstream(order);
         } catch (Exception e) {
             log.warn("[统一支付H5] 轮询上游失败,返回库内行: order_no={}, 原因={}",
-                    orderNo, e.getMessage());
+                    orderNo, UnifiedLogSanitizer.flatten(e.getMessage()));
             return order;
         } finally {
             SecurityContextHolder.setContext(previous);

+ 29 - 11
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedMerchantService.java

@@ -90,13 +90,24 @@ public class UnifiedMerchantService {
         if (dto.getId() != null) {
             entity = requireById(dto.getId());
 
+            boolean mchNoChanging = StrUtil.isNotBlank(dto.getMchNo())
+                    && !dto.getMchNo().equals(entity.getMchNo());
+            boolean appIdChanging = StrUtil.isNotBlank(dto.getAppId())
+                    && !dto.getAppId().equals(entity.getAppId());
+            // mch_no/app_id 是下单时快照(spec :239/:407):历史单必须继续按快照收敛,
+            // 换号会让已产生的订单/退款失联。与 delete 同一对查询;退款按**库内旧号**数。
+            if ((mchNoChanging || appIdChanging)
+                    && hasTransactions(entity.getId(), entity.getMchNo())) {
+                throw new BusinessException(400, "已有交易,不能修改商户号或应用 ID,请新建配置");
+            }
+
             // 归属不可改:已建订单带的是旧 tenant_id,改这里等于让同一笔业务横跨两个租户。
             // 编号换了才需要重新查重;没变时按编号查会把这一行自己数进去。
-            if (StrUtil.isNotBlank(dto.getMchNo()) && !dto.getMchNo().equals(entity.getMchNo())) {
+            if (mchNoChanging) {
                 requireMchNoAvailable(dto.getMchNo());
                 entity.setMchNo(dto.getMchNo());
             }
-            if (StrUtil.isNotBlank(dto.getAppId())) {
+            if (appIdChanging) {
                 entity.setAppId(dto.getAppId());
             }
             // 留空 = 不修改。写成空串会让该商户之后所有上游请求签名失败。
@@ -164,21 +175,28 @@ public class UnifiedMerchantService {
     public void delete(Long id) {
         UnifiedMerchantEntity cfg = requireById(id);
 
-        Long orders = orderMapper.selectCount(new LambdaQueryWrapper<UnifiedOrderEntity>()
-                .eq(UnifiedOrderEntity::getMerchantId, id));
-        if (orders != null && orders > 0) {
-            throw new BusinessException(400, "已有交易,不能删除,请停用");
-        }
-
-        Long refunds = refundMapper.selectCount(new LambdaQueryWrapper<UnifiedRefundEntity>()
-                .eq(UnifiedRefundEntity::getMchNo, cfg.getMchNo()));
-        if (refunds != null && refunds > 0) {
+        if (hasTransactions(id, cfg.getMchNo())) {
             throw new BusinessException(400, "已有交易,不能删除,请停用");
         }
 
         merchantMapper.deleteById(id);
     }
 
+    /**
+     * 该配置下是否已有交易:任一笔订单(按 {@code merchant_id})或退款(按
+     * {@code mch_no} 快照)即算。<b>delete 与 save 换号共用同一对查询</b>。
+     */
+    private boolean hasTransactions(Long merchantId, String mchNo) {
+        Long orders = orderMapper.selectCount(new LambdaQueryWrapper<UnifiedOrderEntity>()
+                .eq(UnifiedOrderEntity::getMerchantId, merchantId));
+        if (orders != null && orders > 0) {
+            return true;
+        }
+        Long refunds = refundMapper.selectCount(new LambdaQueryWrapper<UnifiedRefundEntity>()
+                .eq(UnifiedRefundEntity::getMchNo, mchNo));
+        return refunds != null && refunds > 0;
+    }
+
     /**
      * 按主键取配置。
      *

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

@@ -161,6 +161,12 @@ public class UnifiedOrderService {
         recordQueryAttempt(entity);
 
         String state = UnifiedResponseParser.pickState(data);
+        if (UnifiedOrderStatus.isValid(state)) {
+            // 已拿到可识别的上游状态:插入时的 15s 兜底让位给正常排期。否则下面的
+            // ensurePollScheduled 会因 nextQueryTime 非空而跳过,orderState=0 的单会停在
+            // 15s 而不是 5s 首档。状态无法识别时保留兜底值(保守排期)。
+            entity.setNextQueryTime(null);
+        }
         if (UnifiedOrderStatus.canTransition(entity.getTradeStatus(), state)) {
             if (UnifiedOrderStatus.SUCCESS.equals(state)
                     || UnifiedOrderStatus.REFUNDED.equals(state)) {
@@ -260,6 +266,10 @@ public class UnifiedOrderService {
         entity.setTradeStatus(UnifiedOrderStatus.GENERATED);
         entity.setNotifyStatus(UnifiedNotifyStatus.PENDING);
         entity.setQueryCount(0);
+        // 落库即带兜底排期:insert 与首次排期之间进程崩溃时,该行仍会被轮询捞起;
+        // 成功路径(含 orderState=0)由 applyOrderState/ensurePollScheduled 覆盖成 5s 首档。
+        entity.setNextQueryTime(OffsetDateTime.now()
+                .plusSeconds(UnifiedPollPolicy.CREATE_FAILURE_RETRY_SECONDS));
         return entity;
     }
 
@@ -349,6 +359,11 @@ public class UnifiedOrderService {
                 ? UnifiedResponseParser.toShanghai(
                         UnifiedResponseParser.epochMillis(upstream, "successTime"))
                 : null;
+        // 5 = 已全退(spec §7.4):订单腿先看到 5 时退款腿可能还没落成功,
+        // 此时可退金额的派生量仍是 0;必须直接用订单金额把 refunded_amount 写满,
+        // 否则下游回调会带着旧的 0 出去。
+        boolean fullyRefunded = UnifiedOrderStatus.REFUNDED.equals(nextState)
+                && order.getAmount() != null;
         // 与退款事务腿同口径:null upstream 不写快照,避免把已有值覆盖成空
         String upstreamDataJson = upstream == null
                 ? null : UnifiedUpstreamSnapshot.toJson(upstream);
@@ -362,6 +377,9 @@ public class UnifiedOrderService {
                 .set(UnifiedOrderEntity::getLastQueryTime, now)
                 .set(UnifiedOrderEntity::getUpdatedTime, now)
                 .set(UnifiedOrderEntity::getNextQueryTime, nextQueryTime);
+        if (fullyRefunded) {
+            update.set(UnifiedOrderEntity::getRefundedAmount, order.getAmount());
+        }
         if (payTime != null) {
             update.set(UnifiedOrderEntity::getPayTime, payTime);
         }
@@ -380,6 +398,9 @@ public class UnifiedOrderService {
         order.setLastQueryTime(now);
         order.setUpdatedTime(now);
         order.setNextQueryTime(nextQueryTime);
+        if (fullyRefunded) {
+            order.setRefundedAmount(order.getAmount());
+        }
         if (payTime != null) {
             order.setPayTime(payTime);
         }

+ 19 - 1
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedRefundService.java

@@ -146,6 +146,13 @@ public class UnifiedRefundService {
         recordQueryAttempt(refund);
 
         String state = UnifiedResponseParser.pickState(data);
+        if (UnifiedRefundStatus.isValid(state)
+                && !UnifiedRefundStatus.canTransition(refund.getState(), state)) {
+            // 识别到状态但没有迁移(如 state=0):插入时的 15s 兜底让位给正常排期,
+            // 否则下面的 ensurePollScheduled 会因 nextQueryTime 非空而跳过,首档变 15s。
+            // 有迁移时 applyRefundState 自己写 next_query_time,无需清空。
+            scheduleNextQuery(refund, null);
+        }
         if (UnifiedRefundStatus.canTransition(refund.getState(), state)) {
             applyRefundState(refundNo, state, upstream, payAmountOf(data),
                     textOf(data, "errCode"), textOf(data, "errMsg"));
@@ -466,9 +473,20 @@ public class UnifiedRefundService {
                 scheduleAfterQuery(refund);
                 return;
             }
+            Long payAmount = payAmountOf(data);
+            // 与通知腿的差异:通知腿里 payAmount 缺席 = 失败(未知不等于相等);
+            // 查单腿允许缺席(现状放行),只有「存在且不等于订单金额」才拦截。
+            if (UnifiedRefundStatus.SUCCESS.equals(state)
+                    && payAmount != null
+                    && (order.getAmount() == null || !payAmount.equals(order.getAmount()))) {
+                markAmountMismatch(refund,
+                        "payAmount=" + payAmount + ", orderAmount=" + order.getAmount());
+                scheduleAfterQuery(refund);
+                return;
+            }
             // 唯一收敛入口:订单联动/事件/回调都在这里(R25②)
             applyRefundState(refund.getRefundNo(), state, UnifiedUpstreamSnapshot.of(data),
-                    payAmountOf(data), textOf(data, "errCode"), textOf(data, "errMsg"));
+                    payAmount, textOf(data, "errCode"), textOf(data, "errMsg"));
         }
         if (state != null && UnifiedRefundStatus.FINAL.contains(state)) {
             // 事务层已写 next_query_time = null;条件 UPDATE 落空时另一边也已是终态

+ 4 - 0
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedRefundTxService.java

@@ -133,6 +133,10 @@ public class UnifiedRefundTxService {
         entity.setState(UnifiedRefundStatus.GENERATED);
         entity.setNotifyStatus(UnifiedNotifyStatus.PENDING);
         entity.setQueryCount(0);
+        // 落库即带兜底排期:insert 与首次排期之间进程崩溃时,该行仍会被轮询捞起;
+        // 成功路径(state=0/1)由 applyRefundState/ensurePollScheduled 覆盖成 5s 首档。
+        entity.setNextQueryTime(OffsetDateTime.now()
+                .plusSeconds(UnifiedPollPolicy.CREATE_FAILURE_RETRY_SECONDS));
         refundMapper.insert(entity);
         return entity;
     }

+ 3 - 1
java/src/main/java/com/payment/platform/module/payment/unified/util/UnifiedChannelExtraAssembler.java

@@ -253,7 +253,9 @@ public final class UnifiedChannelExtraAssembler {
             }
         }
         if (!unknown.isEmpty()) {
-            log.warn("channelExtra 未知键已丢弃: wayCode={}, keys={}", wayCode, unknown);
+            // 键名来自调用方请求体:先 flatten,防 CWE-117 日志注入
+            log.warn("channelExtra 未知键已丢弃: wayCode={}, keys={}",
+                    wayCode, UnifiedLogSanitizer.flatten(String.valueOf(unknown)));
         }
     }
 

+ 20 - 0
java/src/test/java/com/payment/platform/module/payment/unified/client/UnifiedClientTest.java

@@ -116,6 +116,26 @@ class UnifiedClientTest {
         assertEquals(String.valueOf(fixed.toInstant().getEpochSecond()), seconds);
     }
 
+    /**
+     * compact14 的合约时区固定东八区,**不跟随入参自带的 offset,也不跟随 JVM 默认时区**。
+     *
+     * <p>钉法是给一个 UTC 的 {@link OffsetDateTime}:若实现直接 {@code format} 入参,
+     * 读数会是 {@code 20260101000000};正确实现必须先换算到 Asia/Shanghai。
+     */
+    @Test
+    void reqTime_compact14_convertsToShanghaiRegardlessOfInputOffset() {
+        UnifiedPayProperties props = new UnifiedPayProperties();
+        props.setReqTimeFormat("compact14");
+
+        OffsetDateTime utc = OffsetDateTime.parse("2026-01-01T00:00:00Z");
+        assertEquals("20260101080000", UnifiedClient.reqTime(props, utc),
+                "UTC 零点必须换算成东八区 08:00");
+
+        OffsetDateTime west = OffsetDateTime.of(2026, 1, 1, 0, 0, 0, 0, ZoneOffset.ofHours(-5));
+        assertEquals("20260101130000", UnifiedClient.reqTime(props, west),
+                "西五区零点必须换算成东八区 13:00");
+    }
+
     @Test
     void successCodes0And200_bothReturnData() throws Exception {
         transport.response = signedResponse(0, "SUCCESS", "{\"state\":\"2\"}", false);

+ 35 - 0
java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedCashierControllerTest.java

@@ -3,6 +3,8 @@ package com.payment.platform.module.payment.unified.controller;
 import com.baomidou.mybatisplus.core.MybatisConfiguration;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.payment.platform.common.config.JacksonConfig;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.response.Result;
 import com.payment.platform.core.security.LoginUser;
@@ -22,8 +24,12 @@ import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
 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;
@@ -46,6 +52,9 @@ import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
+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;
 
 /**
  * 收银台控制器 + 收银台服务(Task 16)。
@@ -65,6 +74,9 @@ import static org.mockito.Mockito.when;
 @ExtendWith(MockitoExtension.class)
 class UnifiedCashierControllerTest {
 
+    /** 生产 mapper(SNAKE_CASE + 未知字段报错);用于坏请求体测试。 */
+    private static final ObjectMapper GLOBAL_MAPPER = new JacksonConfig().objectMapper();
+
     @Mock private UnifiedCashierService cashierService;
     @Mock private UnifiedOrderService orderService;
     @Mock private UnifiedH5Service h5Service;
@@ -181,6 +193,29 @@ class UnifiedCashierControllerTest {
         verifyNoInteractions(cashierService, orderService);
     }
 
+    /**
+     * 语法错误与未知字段都在反序列化阶段抛
+     * {@link org.springframework.http.converter.HttpMessageNotReadableException};
+     * 控制器自己的 handler 必须把它变成 400 + 固定文案(全局兜底会给 500)。
+     */
+    @Test
+    void unreadableRequestBody_is400WithFixedMessage_andNeverReachesServices() throws Exception {
+        MockMvc mvc = MockMvcBuilders.standaloneSetup(controller)
+                .setMessageConverters(new MappingJackson2HttpMessageConverter(GLOBAL_MAPPER))
+                .build();
+
+        for (String body : List.of("{not-json", "{\"unknown_field\":1}")) {
+            mvc.perform(post("/payment/unified/cashier/order")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(body))
+                    .andExpect(status().isBadRequest())
+                    .andExpect(jsonPath("$.code").value(400))
+                    .andExpect(jsonPath("$.msg").value("请求体格式错误"));
+        }
+
+        verifyNoInteractions(cashierService, orderService, h5Service, guard);
+    }
+
     // ────────────────────────────── 控制器:快照 ──────────────────────────────
 
     @Test

+ 249 - 0
java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedValidationHttpTest.java

@@ -0,0 +1,249 @@
+package com.payment.platform.module.payment.unified.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.payment.platform.common.config.JacksonConfig;
+import com.payment.platform.common.exception.GlobalExceptionHandler;
+import com.payment.platform.module.payment.unified.controller.open.UnifiedOpenController;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedMerchantSaveDTO;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedOrderCreateCommand;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedRefundCreateCommand;
+import com.payment.platform.module.payment.unified.service.UnifiedChannelUserService;
+import com.payment.platform.module.payment.unified.service.UnifiedMerchantService;
+import com.payment.platform.module.payment.unified.service.UnifiedOrderService;
+import com.payment.platform.module.payment.unified.service.UnifiedRefundService;
+import com.payment.platform.module.payment.unified.util.UnifiedTenantGuard;
+import jakarta.validation.Validation;
+import jakarta.validation.Validator;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+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;
+
+/**
+ * 请求字段上限的 Bean Validation 覆盖(评审 M6)。
+ *
+ * <p>两类证据各一份:
+ * <ol>
+ *   <li><b>Validator 单测</b>:逐字段钉住 DDL 列宽对应的 {@code @Size} 与
+ *       {@code expiredTime} 的 {@code @Positive} —— 包括合法边界值的正对照;</li>
+ *   <li><b>HTTP 层</b>:超长值必须被 400 拒绝,而不是穿过校验后在入库时炸成 500。
+ *       用 standalone MockMvc + 生产 {@link GlobalExceptionHandler},
+ *       断言的是线上同一个异常映射。</li>
+ * </ol>
+ */
+class UnifiedValidationHttpTest {
+
+    private static final Validator VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator();
+
+    /** 生产 mapper:SNAKE_CASE 请求体 + 未知字段报错,和线上 Web 层同一支量具。 */
+    private static final ObjectMapper GLOBAL_MAPPER = new JacksonConfig().objectMapper();
+
+    // ────────────────────────────── Validator 单测 ──────────────────────────────
+
+    @Test
+    void orderCreateCommand_documentedLimits() {
+        // 正对照:合法值零违规(否则下面的「有违规」可能只是量具恒红)
+        assertTrue(violations(validOrder()).isEmpty(),
+                "合法订单请求不应有违规,读数: " + violations(validOrder()));
+
+        UnifiedOrderCreateCommand expiredZero = validOrder();
+        expiredZero.setExpiredTime(0);
+        assertTrue(violations(expiredZero).contains("expiredTime"),
+                "expiredTime=0 必须被 @Positive 拒绝,读数: " + violations(expiredZero));
+
+        UnifiedOrderCreateCommand expiredOne = validOrder();
+        expiredOne.setExpiredTime(1);
+        assertFalse(violations(expiredOne).contains("expiredTime"),
+                "expiredTime=1 是合法下界,读数: " + violations(expiredOne));
+
+        UnifiedOrderCreateCommand longWayCode = validOrder();
+        longWayCode.setWayCode("X".repeat(17));
+        assertTrue(violations(longWayCode).contains("wayCode"),
+                "wayCode 17 字符必须被拒绝,读数: " + violations(longWayCode));
+
+        UnifiedOrderCreateCommand longMchNo = validOrder();
+        longMchNo.setMchNo("M".repeat(65));
+        assertTrue(violations(longMchNo).contains("mchNo"),
+                "mchNo 65 字符必须被拒绝,读数: " + violations(longMchNo));
+
+        UnifiedOrderCreateCommand longClientIp = validOrder();
+        longClientIp.setClientIp("1".repeat(65));
+        assertTrue(violations(longClientIp).contains("clientIp"),
+                "clientIp 65 字符必须被拒绝,读数: " + violations(longClientIp));
+    }
+
+    @Test
+    void refundCreateCommand_documentedLimits() {
+        assertTrue(violations(validRefund()).isEmpty(),
+                "合法退款请求不应有违规,读数: " + violations(validRefund()));
+
+        UnifiedRefundCreateCommand longNotifyUrl = validRefund();
+        longNotifyUrl.setNotifyUrl("h".repeat(513));
+        assertTrue(violations(longNotifyUrl).contains("notifyUrl"),
+                "notifyUrl 513 字符必须被拒绝,读数: " + violations(longNotifyUrl));
+
+        UnifiedRefundCreateCommand longExtParam = validRefund();
+        longExtParam.setExtParam("e".repeat(513));
+        assertTrue(violations(longExtParam).contains("extParam"),
+                "extParam 513 字符必须被拒绝,读数: " + violations(longExtParam));
+    }
+
+    @Test
+    void merchantSaveDTO_documentedLimits() {
+        assertTrue(violations(validMerchant()).isEmpty(),
+                "合法商户配置不应有违规,读数: " + violations(validMerchant()));
+
+        assertTrue(violations(merchantWithMchNo("M".repeat(65))).contains("mchNo"));
+        assertTrue(violations(merchantWithAppId("a".repeat(65))).contains("appId"));
+
+        UnifiedMerchantSaveDTO longSecret = validMerchant();
+        longSecret.setAppSecret("s".repeat(129));
+        assertTrue(violations(longSecret).contains("appSecret"));
+
+        UnifiedMerchantSaveDTO longName = validMerchant();
+        longName.setMerchantName("名".repeat(129));
+        assertTrue(violations(longName).contains("merchantName"));
+
+        UnifiedMerchantSaveDTO longIfCode = validMerchant();
+        longIfCode.setChannelUserIfCode("i".repeat(17));
+        assertTrue(violations(longIfCode).contains("channelUserIfCode"));
+
+        UnifiedMerchantSaveDTO longStatus = validMerchant();
+        longStatus.setStatus("0".repeat(11));
+        assertTrue(violations(longStatus).contains("status"));
+
+        UnifiedMerchantSaveDTO longDescription = validMerchant();
+        longDescription.setDescription("d".repeat(513));
+        assertTrue(violations(longDescription).contains("description"));
+    }
+
+    // ────────────────────────────── HTTP 层 ──────────────────────────────
+
+    /**
+     * 超长 {@code notify_url} 以前会穿过校验,在入库时撞列宽变成 500;
+     * 现在必须在 MVC 校验阶段以 400 返回,且服务层一次都不被调用。
+     */
+    @Test
+    void openRefund_tooLongNotifyUrl_is400Not500() throws Exception {
+        UnifiedOrderService orderService = mock(UnifiedOrderService.class);
+        UnifiedRefundService refundService = mock(UnifiedRefundService.class);
+        UnifiedChannelUserService channelUserService = mock(UnifiedChannelUserService.class);
+        MockHttpServletRequest servletRequest =
+                new MockHttpServletRequest("POST", "/payment/openapi/unified/refund");
+        servletRequest.setAttribute("openapi.tenantId", 2L);
+        servletRequest.setAttribute("openapi.apiKey", "key-2");
+
+        MockMvc mvc = MockMvcBuilders
+                .standaloneSetup(new UnifiedOpenController(
+                        orderService, refundService, channelUserService, servletRequest))
+                .setControllerAdvice(new GlobalExceptionHandler())
+                .setMessageConverters(new MappingJackson2HttpMessageConverter(GLOBAL_MAPPER))
+                .build();
+
+        String body = "{\"order_no\":\"UO1\",\"refund_amount\":100,\"refund_reason\":\"测试退款\","
+                + "\"out_refund_no\":\"RF1\",\"notify_url\":\"" + "h".repeat(600) + "\"}";
+        mvc.perform(post("/payment/openapi/unified/refund")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("notifyUrl")));
+
+        verifyNoInteractions(orderService, refundService, channelUserService);
+    }
+
+    /** 超长 {@code mchNo} 同样必须在校验层 400,而不是入库时 500。 */
+    @Test
+    void merchantSave_tooLongMchNo_is400Not500() throws Exception {
+        UnifiedMerchantService service = mock(UnifiedMerchantService.class);
+        MockMvc mvc = MockMvcBuilders
+                .standaloneSetup(new UnifiedMerchantController(service, mock(UnifiedTenantGuard.class)))
+                .setControllerAdvice(new GlobalExceptionHandler())
+                .setMessageConverters(new MappingJackson2HttpMessageConverter(GLOBAL_MAPPER))
+                .build();
+
+        String body = "{\"tenant_id\":7,\"mch_no\":\"" + "M".repeat(600) + "\","
+                + "\"app_id\":\"app-1\",\"app_secret\":\"secret\"}";
+        mvc.perform(post("/payment/unified/merchant")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("mchNo")));
+
+        verifyNoInteractions(service);
+    }
+
+    // ────────────────────────────── 工具 ──────────────────────────────
+
+    private static Set<String> violations(Object bean) {
+        return VALIDATOR.validate(bean).stream()
+                .map(v -> v.getPropertyPath().toString())
+                .collect(Collectors.toSet());
+    }
+
+    private static UnifiedOrderCreateCommand validOrder() {
+        UnifiedOrderCreateCommand cmd = new UnifiedOrderCreateCommand();
+        cmd.setMchNo("M001");
+        cmd.setAmount(100L);
+        cmd.setWayCode("QR_CASHIER");
+        cmd.setSubject("测试订单");
+        cmd.setOutTradeNo("T001");
+        cmd.setClientIp("203.0.113.9");
+        cmd.setNotifyUrl("https://merchant.example.test/notify");
+        cmd.setReturnUrl("https://merchant.example.test/return");
+        cmd.setExpiredTime(7200);
+        cmd.setExtParam("ext");
+        return cmd;
+    }
+
+    private static UnifiedRefundCreateCommand validRefund() {
+        UnifiedRefundCreateCommand cmd = new UnifiedRefundCreateCommand();
+        cmd.setMchNo("M001");
+        cmd.setOrderNo("UO1");
+        cmd.setRefundAmount(100L);
+        cmd.setRefundReason("测试退款");
+        cmd.setOutRefundNo("RF1");
+        cmd.setNotifyUrl("https://merchant.example.test/refund-notify");
+        cmd.setExtParam("ext");
+        return cmd;
+    }
+
+    private static UnifiedMerchantSaveDTO validMerchant() {
+        UnifiedMerchantSaveDTO dto = new UnifiedMerchantSaveDTO();
+        dto.setTenantId(7L);
+        dto.setMchNo("M001");
+        dto.setAppId("app-1");
+        dto.setAppSecret("secret");
+        dto.setMerchantName("测试商户");
+        dto.setChannelUserIfCode("fuioupay");
+        dto.setStatus("0");
+        dto.setDescription("备注");
+        return dto;
+    }
+
+    private static UnifiedMerchantSaveDTO merchantWithMchNo(String mchNo) {
+        UnifiedMerchantSaveDTO dto = validMerchant();
+        dto.setMchNo(mchNo);
+        return dto;
+    }
+
+    private static UnifiedMerchantSaveDTO merchantWithAppId(String appId) {
+        UnifiedMerchantSaveDTO dto = validMerchant();
+        dto.setAppId(appId);
+        return dto;
+    }
+}

+ 65 - 0
java/src/test/java/com/payment/platform/module/payment/unified/controller/open/UnifiedOpenControllerTest.java

@@ -26,12 +26,17 @@ import org.junit.jupiter.api.function.Executable;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
 import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
+import java.time.OffsetDateTime;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Set;
@@ -51,6 +56,9 @@ import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
+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;
 
 /**
  * 统一支付开放 API 控制器(Task 15)。
@@ -311,6 +319,63 @@ class UnifiedOpenControllerTest {
         assertEquals("UR-X", node.get("refund_no").asText());
     }
 
+    /** spec §8.4 的订单字段表没有 {@code next_query_time}:开放视图必须不带。 */
+    @Test
+    void openOrderView_omitsNextQueryTime_whileAdminViewKeepsIt() throws Exception {
+        UnifiedOrderEntity entity = order(99003L, 7L, "UO-Q", "T-Q");
+        entity.setNextQueryTime(OffsetDateTime.parse("2026-10-21T10:00:00+08:00"));
+
+        JsonNode open = GLOBAL_MAPPER.readTree(
+                GLOBAL_MAPPER.writeValueAsString(UnifiedOrderVO.open(entity, 500L)));
+        JsonNode admin = GLOBAL_MAPPER.readTree(
+                GLOBAL_MAPPER.writeValueAsString(UnifiedOrderVO.of(entity, 500L)));
+
+        assertFalse(open.has("next_query_time"), "开放订单视图不得含内部调度时间,读数: " + open);
+        // 正对照:NON_NULL 会让「缺字段」可能只是值为 null;后台视图必须仍带这个字段
+        assertTrue(admin.has("next_query_time"), "后台订单视图丢了 next_query_time,量具无效: " + admin);
+    }
+
+    /** spec §8.4 的退款字段表没有 {@code next_query_time}:开放视图必须不带。 */
+    @Test
+    void openRefundView_omitsNextQueryTime_whileAdminViewKeepsIt() throws Exception {
+        UnifiedRefundEntity entity = refund(99004L, "UR-Q", "UO-Q");
+        entity.setNextQueryTime(OffsetDateTime.parse("2026-10-21T10:00:00+08:00"));
+
+        JsonNode open = GLOBAL_MAPPER.readTree(
+                GLOBAL_MAPPER.writeValueAsString(UnifiedRefundVO.open(entity)));
+        JsonNode admin = GLOBAL_MAPPER.readTree(
+                GLOBAL_MAPPER.writeValueAsString(UnifiedRefundVO.of(entity)));
+
+        assertFalse(open.has("next_query_time"), "开放退款视图不得含内部调度时间,读数: " + open);
+        assertTrue(admin.has("next_query_time"), "后台退款视图丢了 next_query_time,量具无效: " + admin);
+    }
+
+    // ────────────────────────────── 坏请求体 ──────────────────────────────
+
+    /**
+     * 语法错误与未知字段都在反序列化阶段抛 {@link org.springframework.http.converter.HttpMessageNotReadableException},
+     * 全局兜底会给 500;控制器自己的 handler 必须把它变成 400 + 固定文案。
+     */
+    @Test
+    void unreadableRequestBody_is400WithFixedMessage_andNeverReachesService() throws Exception {
+        // 用生产 mapper:standalone 默认的 Jackson 会静默忽略未知字段,量不到这条缺陷
+        MockMvc mvc = MockMvcBuilders
+                .standaloneSetup(controller(request(2L, "key-2")))
+                .setMessageConverters(new MappingJackson2HttpMessageConverter(GLOBAL_MAPPER))
+                .build();
+
+        for (String body : List.of("{not-json", "{\"unknown_field\":1}")) {
+            mvc.perform(post(PREFIX + "/order/create")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(body))
+                    .andExpect(status().isBadRequest())
+                    .andExpect(jsonPath("$.code").value(400))
+                    .andExpect(jsonPath("$.msg").value("请求体格式错误"));
+        }
+
+        verifyNoInteractions(orderService, refundService, channelUserService);
+    }
+
     // ────────────────────────────── 认证清单接线 ──────────────────────────────
 
     /**

+ 20 - 3
java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedH5ServiceTest.java

@@ -197,17 +197,34 @@ class UnifiedH5ServiceTest {
         assertEquals("参数类错误原样透出", e.getMessage());
     }
 
+    /** 4xx 全是调用方可修正的错误(含幂等冲突):原样透出,不折叠成 500。 */
     @Test
-    void create_orderService409_mapsTo500_withoutLeakingConflict() {
+    void create_orderService409_isRethrownVerbatim() {
         when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg);
+        BusinessException conflict = new BusinessException(409, "幂等冲突");
         when(orderService.create(any(), eq(2L), isNull(), eq(UnifiedSource.H5)))
-                .thenThrow(new BusinessException(409, "幂等冲突"));
+                .thenThrow(conflict);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.create(signed(unsignedParams()), "203.0.113.9"));
+
+        assertSame(conflict, e, "409 必须原样透出,不能换成 500");
+        assertEquals(409, e.getCode());
+        assertEquals("幂等冲突", e.getMessage());
+    }
+
+    /** 5xx 业务异常才换成人畜无害的统一文案,不向公开调用方泄露内部细节。 */
+    @Test
+    void create_orderService500_mapsTo500_withoutLeakingDetail() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg);
+        when(orderService.create(any(), eq(2L), isNull(), eq(UnifiedSource.H5)))
+                .thenThrow(new BusinessException(500, "上游内部错误: secret detail"));
 
         BusinessException e = assertThrows(BusinessException.class,
                 () -> service.create(signed(unsignedParams()), "203.0.113.9"));
 
         assertEquals(500, e.getCode(), "实测消息: " + e.getMessage());
-        assertEquals("支付创建失败,请稍后重试", e.getMessage(), "不向公开调用方泄露内部冲突详情");
+        assertEquals("支付创建失败,请稍后重试", e.getMessage());
     }
 
     @Test

+ 80 - 0
java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedMerchantServiceTest.java

@@ -379,6 +379,86 @@ class UnifiedMerchantServiceTest {
         verify(merchantMapper, never()).insert(any());
     }
 
+    // ──────────────── 四之二、已有交易时锁定 mch_no / app_id(I3) ────────────────
+
+    /**
+     * {@code mch_no}/{@code app_id} 是下单时快照:换号会让已产生的订单/退款失联。
+     * 有订单 ⇒ 400,且连新号的唯一性检查都不该发生。
+     */
+    @Test
+    void save_update_mchNoChange_withOrders_throws400_andNeverUpdates() {
+        UnifiedMerchantEntity existing = merchant(5L, 2L, "M001", "0");
+        when(merchantMapper.selectById(5L)).thenReturn(existing);
+        when(orderMapper.selectCount(any())).thenReturn(1L);
+
+        UnifiedMerchantSaveDTO dto = createDto();
+        dto.setId(5L);
+        dto.setMchNo("M002");              // 换商户号
+        dto.setAppId(existing.getAppId()); // 应用 ID 不动,隔离变量
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.save(dto));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("已有交易,不能修改商户号或应用 ID"),
+                "实测消息: " + e.getMessage());
+        verify(merchantMapper, never()).updateById(any());
+        verify(merchantMapper, never()).selectCount(any());
+    }
+
+    /** 有退款(按**库内旧号**数)⇒ 换 app_id 同样 400。 */
+    @Test
+    void save_update_appIdChange_withRefunds_throws400_andNeverUpdates() {
+        UnifiedMerchantEntity existing = merchant(5L, 2L, "M001", "0");
+        when(merchantMapper.selectById(5L)).thenReturn(existing);
+        when(orderMapper.selectCount(any())).thenReturn(0L);
+        when(refundMapper.selectCount(any())).thenReturn(1L);
+
+        UnifiedMerchantSaveDTO dto = createDto();
+        dto.setId(5L);
+        dto.setMchNo("M001");
+        dto.setAppId("app-new");
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.save(dto));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("已有交易,不能修改商户号或应用 ID"),
+                "实测消息: " + e.getMessage());
+        verify(merchantMapper, never()).updateById(any());
+
+        @SuppressWarnings({"unchecked", "rawtypes"})
+        ArgumentCaptor<LambdaQueryWrapper<UnifiedRefundEntity>> refundCap =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(refundMapper).selectCount(refundCap.capture());
+        assertTrue(refundCap.getValue().getSqlSegment().contains("mch_no"),
+                "退款必须按 mch_no 数,实测 WHERE: " + refundCap.getValue().getSqlSegment());
+        assertTrue(refundCap.getValue().getParamNameValuePairs().containsValue("M001"),
+                "数的是库内旧号,不是请求里的新值,实测参数: "
+                        + refundCap.getValue().getParamNameValuePairs());
+    }
+
+    /** 正对照:没有任何交易时,两个字段都可以改。 */
+    @Test
+    void save_update_mchNoAndAppId_withoutTransactions_updatesBoth() {
+        UnifiedMerchantEntity existing = merchant(5L, 2L, "M001", "0");
+        when(merchantMapper.selectById(5L)).thenReturn(existing);
+        when(orderMapper.selectCount(any())).thenReturn(0L);
+        when(refundMapper.selectCount(any())).thenReturn(0L);
+        when(merchantMapper.selectCount(any())).thenReturn(0L);
+
+        UnifiedMerchantSaveDTO dto = createDto();
+        dto.setId(5L);
+        dto.setMchNo("M002");
+        dto.setAppId("app-new");
+
+        service.save(dto);
+
+        ArgumentCaptor<UnifiedMerchantEntity> captor =
+                ArgumentCaptor.forClass(UnifiedMerchantEntity.class);
+        verify(merchantMapper).updateById(captor.capture());
+        assertEquals("M002", captor.getValue().getMchNo());
+        assertEquals("app-new", captor.getValue().getAppId());
+    }
+
     // ───────────────────────────── 五、删除 ─────────────────────────────
 
     /** 有订单 ⇒ 拒删:历史单失去配置,退款/对账都定位不到上游。 */

+ 106 - 0
java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedOrderServiceTest.java

@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.payment.platform.common.config.JacksonConfig;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.response.PageResult;
 import com.payment.platform.module.payment.stream.OrderEventPublisher;
@@ -377,6 +378,47 @@ class UnifiedOrderServiceTest {
         assertTrue(payload.channelExtraJson().contains("codeUrl"));
     }
 
+    // ────────────────────────────── 分账兼容(I1) ──────────────────────────────
+
+    /** 全局 mapper 是 SNAKE_CASE;调用方按旧文档传 {@code division_mode} 不得再炸成 400/500。 */
+    @Test
+    void divisionMode_isDeserializableByGlobalMapper() throws Exception {
+        String body = "{\"amount\":100,\"way_code\":\"QR_CASHIER\",\"subject\":\"测试商品\","
+                + "\"out_trade_no\":\"T001\",\"division_mode\":0}";
+
+        UnifiedOrderCreateCommand cmd = new JacksonConfig().objectMapper()
+                .readValue(body, UnifiedOrderCreateCommand.class);
+
+        assertEquals(0, cmd.getDivisionMode(), "division_mode 必须能被反序列化,读数: " + cmd);
+    }
+
+    /** 本期不实现分账:该字段既不进上游 payload,也不落订单实体。 */
+    @Test
+    void divisionMode_isNeverForwardedOrPersisted() throws Exception {
+        stubMerchant();
+        when(client.unifiedOrder(any(), any())).thenReturn(
+                json("{\"payOrderId\":\"P1\",\"orderState\":0}"));
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        UnifiedOrderCreateCommand cmd = command();
+        cmd.setDivisionMode(0);
+
+        service.create(cmd, TENANT, "KEY-1", UnifiedSource.OPENAPI);
+
+        ArgumentCaptor<UnifiedOrderEntity> insertedCap = ArgumentCaptor.forClass(UnifiedOrderEntity.class);
+        verify(orderMapper).insert(insertedCap.capture());
+        String entityJson = new JacksonConfig().objectMapper()
+                .writeValueAsString(insertedCap.getValue());
+        assertFalse(entityJson.contains("divisionMode"), "实体不得含 camelCase 分账字段: " + entityJson);
+        assertFalse(entityJson.contains("division_mode"), "实体不得含 snake_case 分账字段: " + entityJson);
+
+        ArgumentCaptor<UnifiedOrderPayload> payloadCap = ArgumentCaptor.forClass(UnifiedOrderPayload.class);
+        verify(client).unifiedOrder(any(), payloadCap.capture());
+        String payloadJson = JSON.writeValueAsString(payloadCap.getValue());
+        assertFalse(payloadJson.contains("divisionMode"), "上游 payload 不得含 camelCase 分账字段: " + payloadJson);
+        assertFalse(payloadJson.contains("division_mode"), "上游 payload 不得含 snake_case 分账字段: " + payloadJson);
+    }
+
     @Test
     void successResponse_state2_appliesAndPublishes() {
         stubMerchant();
@@ -438,6 +480,41 @@ class UnifiedOrderServiceTest {
         verifyNoInteractions(publisher, callbackService);
     }
 
+    /**
+     * I2:insert 与首次排期之间进程崩溃时,该行仍要能被轮询捞起 —— 落库即带 15s 兜底;
+     * 但成功响应(含 orderState=0)必须把它覆盖回 5s 首档,不能停在兜底值上。
+     */
+    @Test
+    void insertCarriesCrashWindowFallback_thenSuccessPathOverridesTo5s() {
+        stubMerchant();
+        when(client.unifiedOrder(any(), any())).thenReturn(
+                json("{\"payOrderId\":\"P1\",\"orderState\":0}"));
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        java.util.concurrent.atomic.AtomicReference<OffsetDateTime> nextQueryTimeAtInsert =
+                new java.util.concurrent.atomic.AtomicReference<>();
+        when(orderMapper.insert(any())).thenAnswer(inv -> {
+            nextQueryTimeAtInsert.set(
+                    inv.<UnifiedOrderEntity>getArgument(0).getNextQueryTime());
+            return 1;
+        });
+
+        OffsetDateTime beforeCreate = OffsetDateTime.now();
+        UnifiedOrderEntity result = service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI);
+
+        assertNotNull(nextQueryTimeAtInsert.get(),
+                "insert 时必须已带兜底排期(否则崩溃窗口里的单永远不会被轮询)");
+        assertTrue(nextQueryTimeAtInsert.get().isAfter(beforeCreate.plusSeconds(13))
+                        && nextQueryTimeAtInsert.get().isBefore(beforeCreate.plusSeconds(18)),
+                "insert 兜底应是 15s,实测: " + nextQueryTimeAtInsert.get());
+
+        OffsetDateTime afterCreate = OffsetDateTime.now();
+        assertNotNull(result.getNextQueryTime());
+        assertTrue(result.getNextQueryTime().isAfter(afterCreate.plusSeconds(3))
+                        && result.getNextQueryTime().isBefore(afterCreate.plusSeconds(8)),
+                "orderState=0 成功响应必须覆盖成 5s 首档,实测: " + result.getNextQueryTime());
+    }
+
     @Test
     void missingState_schedulesFirstPoll() {
         stubMerchant();
@@ -558,6 +635,35 @@ class UnifiedOrderServiceTest {
         verify(callbackService).notifyOrder(same(order), isNull());
     }
 
+    /**
+     * M8:订单腿先看到「已退款」、退款腿还没落成功时,{@code sumSucceededAmount} 仍是 0;
+     * 此时 {@code refunded_amount} 必须按订单金额落,而不是等退款腿的总和。
+     */
+    @Test
+    void applyOrderState_twoToFive_fillsRefundedAmountFromOrderAmount() {
+        UnifiedOrderEntity order = existing("2");
+        order.setRefundedAmount(0L);
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        assertTrue(service.applyOrderState(order, UnifiedOrderStatus.REFUNDED, null, null, null));
+
+        assertEquals(100L, order.getRefundedAmount(), "2→5 时内存里的已退金额必须等于订单金额");
+
+        ArgumentCaptor<LambdaUpdateWrapper<UnifiedOrderEntity>> w = updateCaptor();
+        verify(orderMapper).update(isNull(), w.capture());
+        assertTrue(w.getValue().getSqlSet().contains("refunded_amount"),
+                "条件更新的 SET 必须写 refunded_amount,实测: " + w.getValue().getSqlSet());
+        assertTrue(w.getValue().getParamNameValuePairs().containsValue(100L),
+                "refunded_amount 的绑定值必须是 order.amount,实测: "
+                        + w.getValue().getParamNameValuePairs());
+
+        ArgumentCaptor<UnifiedOrderEntity> callbackSnapshot =
+                ArgumentCaptor.forClass(UnifiedOrderEntity.class);
+        verify(callbackService).notifyOrder(callbackSnapshot.capture(), isNull());
+        assertEquals(100L, callbackSnapshot.getValue().getRefundedAmount(),
+                "下游回调快照也必须带上已退金额,否则调用方看到 0");
+    }
+
     @Test
     void applyOrderState_terminalToSuccess_rejected() {
         UnifiedOrderEntity order = existing("3");

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

@@ -783,6 +783,36 @@ class UnifiedRefundServiceTest {
         assertEquals("OPENAPI", inserted.getValue().getSource());
     }
 
+    /**
+     * I2:退款行落库时也必须自带 15s 兜底排期,否则 insert 与首次排期之间进程崩溃,
+     * 这笔在途退款永远不会被轮询收敛(额度被永久占用)。
+     * 成功路径覆盖成 5s 首档由 {@link #createResponseState0_schedulesNextQuery()} 钉住。
+     */
+    @Test
+    void reserve_insertCarriesCrashWindowFallbackSchedule() {
+        UnifiedOrderEntity o = order("2");
+        when(orderMapper.selectForUpdate(ORDER_ID, null)).thenReturn(o);
+        when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
+
+        java.util.concurrent.atomic.AtomicReference<OffsetDateTime> nextQueryTimeAtInsert =
+                new java.util.concurrent.atomic.AtomicReference<>();
+        when(refundMapper.insert(any())).thenAnswer(inv -> {
+            nextQueryTimeAtInsert.set(
+                    inv.<UnifiedRefundEntity>getArgument(0).getNextQueryTime());
+            return 1;
+        });
+
+        OffsetDateTime before = OffsetDateTime.now();
+        realTx.reserve(ORDER_ID, null, 100L, "测试退款",
+                "UR1", "R1", null, null, "KEY-1", UnifiedSource.OPENAPI);
+
+        assertNotNull(nextQueryTimeAtInsert.get(),
+                "insert 时必须已带兜底排期(崩溃窗口里的退款单永远不会被轮询)");
+        assertTrue(nextQueryTimeAtInsert.get().isAfter(before.plusSeconds(13))
+                        && nextQueryTimeAtInsert.get().isBefore(before.plusSeconds(18)),
+                "insert 兜底应是 15s,实测: " + nextQueryTimeAtInsert.get());
+    }
+
     // ────────────────────────────── 14. 脱敏快照落库 ──────────────────────────────
 
     @Test

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

@@ -399,6 +399,47 @@ class UnifiedRefundSyncTest {
         assertNoDirectStateWrite();
     }
 
+    /**
+     * M9:查单腿的 payAmount 存在但与订单金额不等 ⇒ 不得推进。
+     * (通知腿里 payAmount 缺席也算失败;查单腿允许缺席,见下一个正对照。)
+     */
+    @Test
+    void stateTwo_payAmountMismatch_doesNotAdvance() {
+        stubOrderAndMerchant();
+        when(client.queryRefund(any(), eq("UR1"))).thenReturn(json(
+                "{\"state\":2,\"payAmount\":50,\"refundAmount\":100,\"currency\":\"cny\"}"));
+        lenient().when(tx.apply(any(), any(), any(), any(), any(), any()))
+                .thenReturn(new UnifiedRefundTxService.ApplyOutcome(
+                        false, false, false, refund("2"), null));
+
+        UnifiedRefundEntity refund = refund("1");
+        service.syncFromUpstream(refund);
+
+        assertEquals("1", refund.getState(), "payAmount 与订单金额不符不得推进状态");
+        verifyNoInteractions(tx, publisher, callbackService);
+        assertEquals(1, refund.getQueryCount(), "上游响应算一次查询");
+        assertScheduledIn(refund, UnifiedPollPolicy.nextDelaySeconds(0));
+        assertAmountMismatchMarked();
+        assertNoDirectStateWrite();
+    }
+
+    /** 正对照(M9):payAmount 缺席时放行 —— 只有「存在且不等」才拦截。 */
+    @Test
+    void stateTwo_payAmountMissing_applies() {
+        stubOrderAndMerchant();
+        when(client.queryRefund(any(), eq("UR1"))).thenReturn(json(
+                "{\"state\":2,\"refundAmount\":100,\"currency\":\"cny\"}"));
+        when(tx.apply(eq("UR1"), eq("2"), any(JsonNode.class), isNull(), isNull(), isNull()))
+                .thenReturn(new UnifiedRefundTxService.ApplyOutcome(
+                        false, false, false, refund("2"), null));
+
+        UnifiedRefundEntity refund = refund("1");
+        service.syncFromUpstream(refund);
+
+        verify(tx).apply(eq("UR1"), eq("2"), any(JsonNode.class), isNull(), isNull(), isNull());
+        assertEquals(1, refund.getQueryCount());
+    }
+
     @Test
     void markAmountMismatch_publicEntryWritesCodeAndDetailWithoutTouchingState() {
         // 通知腿(Task 14)与查单腿共用同一条落错误码的实现:必须是 public 且不写 state

+ 37 - 0
java/src/test/java/com/payment/platform/module/payment/unified/util/UnifiedChannelExtraAssemblerTest.java

@@ -1,10 +1,15 @@
 package com.payment.platform.module.payment.unified.util;
 
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
 import com.payment.platform.common.exception.BusinessException;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.function.Executable;
+import org.slf4j.LoggerFactory;
 
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -374,4 +379,36 @@ class UnifiedChannelExtraAssemblerTest {
         assertNull(qr.json(), "只给未知键时不抛错,结果为空");
         assertNull(qr.keysCsv());
     }
+
+    /**
+     * 未知键名来自调用方请求体:进日志前必须 flatten,否则换行可以把一条日志伪造成两条
+     * (CWE-117 日志注入)。
+     */
+    @Test
+    void unknownKey_withNewline_isFlattenedInWarnLog() {
+        Logger logger = (Logger) LoggerFactory.getLogger(UnifiedChannelExtraAssembler.class);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+        try {
+            UnifiedChannelExtraAssembler.Result result = UnifiedChannelExtraAssembler.assemble(
+                    "WX_JSAPI", extras("openid", "openid-1", "evil\nFORGED", "x"), null);
+
+            assertEquals("{\"openid\":\"openid-1\"}", result.json());
+            assertEquals("openid", result.keysCsv(), "含换行的未知键仍然必须被丢弃");
+        } finally {
+            logger.detachAppender(appender);
+        }
+
+        ILoggingEvent event = appender.list.stream()
+                .filter(e -> e.getFormattedMessage().contains("未知键已丢弃"))
+                .findFirst()
+                .orElseThrow(() -> new AssertionError(
+                        "没有捕获到未知键日志,实际日志行: " + appender.list));
+        String message = event.getFormattedMessage();
+        assertFalse(message.contains("\n"), "日志行不得含裸换行,读数: " + message);
+        assertFalse(message.contains("\r"), "日志行不得含裸回车,读数: " + message);
+        assertTrue(message.contains("evil FORGED"),
+                "换行应被压成空格而不是丢弃整条日志,读数: " + message);
+    }
 }