فهرست منبع

feat(统一支付): 下单/订单状态机/事件通道

alphaH 1 هفته پیش
والد
کامیت
3ca6e7ed78

+ 4 - 4
frontend/scripts/verify-order-stream.mjs

@@ -271,17 +271,17 @@ check("最后一个订阅者退订 → 连接关闭", () => {
   assert.equal(built[0].closed, true);
 });
 
-check("三个通道名的线上契约(与后端 OrderEventPublisher 逐字一致)", () => {
-  const CHANNELS = ["lsy_order", "lsy_refund", "har_order"];
+check("五个通道名的线上契约(与后端 OrderEventPublisher 逐字一致)", () => {
+  const CHANNELS = ["lsy_order", "lsy_refund", "har_order", "unified_order", "unified_refund"];
   const { built, stream } = harness();
-  const hits = { lsy_order: 0, lsy_refund: 0, har_order: 0 };
+  const hits = { lsy_order: 0, lsy_refund: 0, har_order: 0, unified_order: 0, unified_refund: 0 };
   const offs = CHANNELS.map(c => stream.subscribe(c, () => { hits[c] += 1; }));
 
   for (const c of CHANNELS) {
     built[0].fire("changed", { data: JSON.stringify({ channel: c }) });
   }
 
-  assert.deepEqual(hits, { lsy_order: 1, lsy_refund: 1, har_order: 1 });
+  assert.deepEqual(hits, { lsy_order: 1, lsy_refund: 1, har_order: 1, unified_order: 1, unified_refund: 1 });
   offs.forEach(off => off());
 });
 

+ 4 - 0
java/src/main/java/com/payment/platform/module/payment/stream/OrderEventPublisher.java

@@ -21,6 +21,10 @@ public class OrderEventPublisher {
     public static final String CHANNEL_LSY_REFUND = "lsy_refund";
     /** 汇安融订单表 {@code har_order}。 */
     public static final String CHANNEL_HAR_ORDER = "har_order";
+    /** 统一支付订单表 {@code pay_unified_order}。 */
+    public static final String CHANNEL_UNIFIED_ORDER = "unified_order";
+    /** 统一支付退款表 {@code pay_unified_refund}。 */
+    public static final String CHANNEL_UNIFIED_REFUND = "unified_refund";
 
     private final ApplicationEventPublisher events;
 

+ 2 - 0
java/src/main/java/com/payment/platform/module/payment/unified/client/JdkUnifiedTransport.java

@@ -1,6 +1,7 @@
 package com.payment.platform.module.payment.unified.client;
 
 import com.payment.platform.module.payment.unified.config.UnifiedPayProperties;
+import org.springframework.stereotype.Component;
 
 import java.io.IOException;
 import java.net.URI;
@@ -17,6 +18,7 @@ import java.time.Duration;
  * ({@code WebMvcConfig} 那个故意没有超时)。非 2xx 与 IO 异常统一包成
  * {@link UnifiedApiException#CODE_TRANSPORT}。
  */
+@Component
 public class JdkUnifiedTransport implements UnifiedTransport {
 
     private final HttpClient client;

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

@@ -7,6 +7,7 @@ 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 lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
 
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
@@ -25,6 +26,7 @@ import java.util.Map;
  * 错误日志只含接口名、{@code mchOrderNo}/{@code mchRefundNo}、上游 {@code code}/{@code msg}。
  */
 @Slf4j
+@Component
 public class UnifiedClient {
 
     private static final String VERSION = "1.0";

+ 28 - 0
java/src/main/java/com/payment/platform/module/payment/unified/dto/query/UnifiedOrderQueryDTO.java

@@ -0,0 +1,28 @@
+package com.payment.platform.module.payment.unified.dto.query;
+
+import lombok.Data;
+
+/**
+ * 订单分页查询条件(spec §9.2)。
+ *
+ * <p>租户侧由 {@code TenantInnerInterceptor} 钉死在自己的 {@code tenant_id} 上,
+ * 这里没有 {@code tenantId} 栏:后台页面不按租户筛订单(超管看全量)。
+ */
+@Data
+public class UnifiedOrderQueryDTO {
+
+    private int pageNo = 1;
+    private int pageSize = 20;
+
+    private String orderNo;
+    private String outTradeNo;
+    private String mchNo;
+    private String wayCode;
+    private String tradeStatus;
+
+    /** yyyy-MM-dd(含当天)。 */
+    private String startDate;
+
+    /** yyyy-MM-dd(含当天)。 */
+    private String endDate;
+}

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

@@ -0,0 +1,70 @@
+package com.payment.platform.module.payment.unified.dto.req;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.util.Map;
+
+/**
+ * 统一下单入参(开放 API 与收银台共用)。
+ *
+ * <p>{@code channelExtra} 的值(付款码 / openid / buyerUserId)只在内存里活一次请求:
+ * 不落库、不进日志、不转发下游以外的任何地方;库里只写键名(见订单服务)。
+ */
+@Data
+public class UnifiedOrderCreateCommand {
+
+    /** 上游商户号;租户只有一条配置时可空(服务层解析)。 */
+    private String mchNo;
+
+    /** 金额,分。 */
+    @NotNull(message = "金额不能为空")
+    @Positive(message = "金额必须大于 0")
+    private Long amount;
+
+    /** 支付方式,见 spec §4.6 的 16 个值(大小写敏感)。 */
+    @NotBlank(message = "wayCode 不能为空")
+    private String wayCode;
+
+    /** 商品标题。 */
+    @NotBlank(message = "subject 不能为空")
+    @Size(max = 128, message = "subject 不能超过 128 字符")
+    private String subject;
+
+    /** 商品描述;空时服务层用 {@code subject} 顶上(上游要求非空)。 */
+    @Size(max = 512, message = "body 不能超过 512 字符")
+    private String body;
+
+    /** 调用方单号;开放 API 的幂等键,必填。 */
+    @NotBlank(message = "outTradeNo 不能为空")
+    @Size(max = 64, message = "outTradeNo 不能超过 64 字符")
+    private String outTradeNo;
+
+    /** 发起支付的客户端 IP。 */
+    private String clientIp;
+
+    /**
+     * 下游回调地址快照。
+     *
+     * <p>★ 只存 {@code callback_url},**绝不**透传给上游 —— 上游 notifyUrl 恒为我们自己的地址。
+     */
+    @Size(max = 512, message = "notifyUrl 不能超过 512 字符")
+    private String notifyUrl;
+
+    /** 上游同步跳转地址(浏览器),透传。 */
+    @Size(max = 512, message = "returnUrl 不能超过 512 字符")
+    private String returnUrl;
+
+    /** 过期间隔(秒);空则 7200。 */
+    private Integer expiredTime;
+
+    /** 按 wayCode 收敛的额外参数(对象形态;服务层序列化成紧凑 JSON 字符串)。 */
+    private Map<String, Object> channelExtra;
+
+    /** 上游原样返回的透传参数。 */
+    @Size(max = 512, message = "extParam 不能超过 512 字符")
+    private String extParam;
+}

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

@@ -0,0 +1,94 @@
+package com.payment.platform.module.payment.unified.dto.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import lombok.Data;
+
+import java.time.OffsetDateTime;
+
+/**
+ * 订单 VO(后台详情与开放 API 共用)。
+ *
+ * <p>{@code id}/{@code tenantId} 只给后台:开放 API 的 {@link #open} 不填这两个字段
+ * (内部主键不返回给调用方)。{@code id} 是 19 位雪花号,序列化成字符串防 JS 精度丢失。
+ */
+@Data
+public class UnifiedOrderVO {
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    private Long id;
+
+    /** 仅后台;租户号是自增小整数,不需要转字符串。 */
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    private Long tenantId;
+
+    private String orderNo;
+    private String outTradeNo;
+    private String mchNo;
+    private String wayCode;
+    private Long amount;
+    private Long refundedAmount;
+
+    /** 可退金额 = amount - 占用中的退款;列表不算(null),详情传值。 */
+    private Long refundableAmount;
+
+    private String tradeStatus;
+    private String upstreamPayOrderId;
+    private String upstreamChannelOrderNo;
+    private String payDataType;
+    private String payData;
+    private String errCode;
+    private String errMsg;
+    private OffsetDateTime payTime;
+    private OffsetDateTime createdTime;
+    private OffsetDateTime updatedTime;
+    private String notifyStatus;
+    private OffsetDateTime nextQueryTime;
+
+    /** 后台视图(含 id / tenantId);{@code refundable} 为空表示列表不算可退金额。 */
+    public static UnifiedOrderVO of(UnifiedOrderEntity e, Long refundable) {
+        if (e == null) {
+            return null;
+        }
+        UnifiedOrderVO vo = new UnifiedOrderVO();
+        vo.setId(e.getId());
+        vo.setTenantId(e.getTenantId());
+        fill(vo, e, refundable);
+        return vo;
+    }
+
+    /** 开放 API 视图:不暴露内部主键与租户归属。 */
+    public static UnifiedOrderVO open(UnifiedOrderEntity e, long refundable) {
+        if (e == null) {
+            return null;
+        }
+        UnifiedOrderVO vo = new UnifiedOrderVO();
+        fill(vo, e, refundable);
+        return vo;
+    }
+
+    private static void fill(UnifiedOrderVO vo, UnifiedOrderEntity e, Long refundable) {
+        vo.setOrderNo(e.getOrderNo());
+        vo.setOutTradeNo(e.getOutTradeNo());
+        vo.setMchNo(e.getMchNo());
+        vo.setWayCode(e.getWayCode());
+        vo.setAmount(e.getAmount());
+        vo.setRefundedAmount(e.getRefundedAmount());
+        vo.setRefundableAmount(refundable);
+        vo.setTradeStatus(e.getTradeStatus());
+        vo.setUpstreamPayOrderId(e.getUpstreamPayOrderId());
+        vo.setUpstreamChannelOrderNo(e.getUpstreamChannelOrderNo());
+        vo.setPayDataType(e.getPayDataType());
+        vo.setPayData(e.getPayData());
+        vo.setErrCode(e.getErrCode());
+        vo.setErrMsg(e.getErrMsg());
+        vo.setPayTime(e.getPayTime());
+        vo.setCreatedTime(e.getCreatedTime());
+        vo.setUpdatedTime(e.getUpdatedTime());
+        vo.setNotifyStatus(e.getNotifyStatus());
+        vo.setNextQueryTime(e.getNextQueryTime());
+    }
+}

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

@@ -0,0 +1,637 @@
+package com.payment.platform.module.payment.unified.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.common.utils.SnowflakeIdGenerator;
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
+import com.payment.platform.module.payment.unified.client.UnifiedApiException;
+import com.payment.platform.module.payment.unified.client.UnifiedClient;
+import com.payment.platform.module.payment.unified.client.UnifiedOrderPayload;
+import com.payment.platform.module.payment.unified.client.UnifiedResponseParser;
+import com.payment.platform.module.payment.unified.config.UnifiedPayProperties;
+import com.payment.platform.module.payment.unified.dto.query.UnifiedOrderQueryDTO;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedOrderCreateCommand;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedOrderVO;
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.enums.UnifiedNotifyStatus;
+import com.payment.platform.module.payment.unified.enums.UnifiedOrderStatus;
+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.mapper.UnifiedRefundMapper;
+import com.payment.platform.module.payment.unified.util.UnifiedChannelExtraAssembler;
+import com.payment.platform.module.payment.unified.util.UnifiedPollPolicy;
+import com.payment.platform.module.payment.unified.util.UnifiedUpstreamSnapshot;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeParseException;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * 统一支付订单:下单、查询、状态机与事件通道(spec §7.2 / §7.5)。
+ *
+ * <p>四条刻意不动的顺序/口径:
+ *
+ * <ol>
+ *   <li><b>先落库再调上游</b>({@code insert} 在 {@code client.unifiedOrder} 之前):
+ *       上游通知可能比 HTTP 响应先到,通知必须能在库里找到这一单。</li>
+ *   <li><b>上游 notifyUrl 恒为我们的地址</b>;调用方的 {@code notify_url} 只写
+ *       {@code callback_url}(R22),由 Task 9 的回调取址链消费。</li>
+ *   <li><b>状态只走条件 UPDATE</b>({@code WHERE order_no = ? AND trade_status IN (前态...)}),
+ *       影响行数为 0 就当没发生:不回退终态、不重复发事件、不覆盖并发写入。</li>
+ *   <li><b>事件带 tenantId</b>(R23),不是自增主键 —— 前端按租户订阅,带错号等于把别家的
+ *       页面刷新一次。</li>
+ * </ol>
+ *
+ * <p>{@code channelExtra} 的值只在 {@link #buildPayload} 里活一次请求;落库只有键名
+ * ({@code channel_extra_keys})。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class UnifiedOrderService {
+
+    private static final String ORDER_NO_PREFIX = "UP";
+
+    /** 开放 API 默认过期时间(秒);收银台由上层显式传 300。 */
+    private static final int DEFAULT_EXPIRED_SECONDS = 7200;
+
+    /** 轮询批量上限(防配置/入参把 SQL 撑成全表扫描)。 */
+    private static final int POLL_LIMIT_MAX = 500;
+
+    private static final int LIST_PAGE_SIZE_MAX = 200;
+
+    /** 状态真的变化且对下游有意义时要发事件/推回调的状态集合。 */
+    private static final Set<String> NOTIFY_STATES = Set.of(
+            UnifiedOrderStatus.SUCCESS,
+            UnifiedOrderStatus.FAILED,
+            UnifiedOrderStatus.REVOKED,
+            UnifiedOrderStatus.REFUNDED,
+            UnifiedOrderStatus.CLOSED);
+
+    private static final String CURRENCY_CNY = "cny";
+
+    private final UnifiedOrderMapper orderMapper;
+    private final UnifiedRefundMapper refundMapper;
+    private final UnifiedClient client;
+    private final UnifiedMerchantService merchantService;
+    private final UnifiedCallbackService callbackService;
+    private final OrderEventPublisher publisher;
+    private final UnifiedPayProperties properties;
+
+    // ────────────────────────────── 下单 ──────────────────────────────
+
+    /**
+     * 下单主流程(顺序不得调换,见类注释)。
+     *
+     * @param cmd      入参;{@code channelExtra} 的值只在此方法的调用栈内使用
+     * @param tenantId 认证上下文/API Key 给出的租户;必须非空
+     * @param apiKey   开放 API 调用方 Key(收银台为 null)
+     * @param source   OPENAPI / CASHIER;ADMIN 不允许下单
+     * @throws BusinessException 400 参数/来源;409 幂等冲突;404 商户未配置;
+     *                           500 上游失败(本地单保留)或金额不符
+     */
+    public UnifiedOrderEntity create(UnifiedOrderCreateCommand cmd, Long tenantId,
+                                     String apiKey, UnifiedSource source) {
+        validateCreate(cmd, source);
+
+        // 幂等:同 out_trade_no 同参数返回已有单,不同 409(spec §8.5)
+        UnifiedOrderEntity existing = findByOutTradeNo(tenantId, cmd.getOutTradeNo());
+        if (existing != null) {
+            if (sameCreateParams(existing, cmd)) {
+                return existing;
+            }
+            throw new BusinessException(409, "out_trade_no 已存在且参数不一致");
+        }
+
+        UnifiedMerchantEntity cfg = merchantService.resolveForTenant(tenantId, cmd.getMchNo());
+        // resolveForTenant 内部已校验启停;这里显式再钉一次,防止未来兑换实现分叉时静默放过停用商户
+        merchantService.requireEnabled(cfg);
+
+        UnifiedChannelExtraAssembler.Result extra = UnifiedChannelExtraAssembler.assemble(
+                cmd.getWayCode(), cmd.getChannelExtra(), properties.getAuthCodeKey());
+
+        UnifiedOrderEntity entity = buildEntity(cmd, tenantId, apiKey, source, cfg, extra);
+
+        // 先落库(自动提交)——上游通知可能在 HTTP 响应返回前就到
+        try {
+            orderMapper.insert(entity);
+        } catch (DuplicateKeyException e) {
+            // 并发重试:另一请求已插入同一 (tenant_id, out_trade_no)
+            UnifiedOrderEntity raced = findByOutTradeNo(tenantId, cmd.getOutTradeNo());
+            if (raced == null) {
+                throw e;
+            }
+            if (sameCreateParams(raced, cmd)) {
+                return raced;
+            }
+            throw new BusinessException(409, "out_trade_no 已存在且参数不一致");
+        }
+
+        JsonNode data;
+        try {
+            data = client.unifiedOrder(cfg, buildPayload(entity, cmd, extra));
+        } catch (UnifiedApiException e) {
+            // 绝不删单:超时的那笔可能已经成功。留 15 秒后重试查询。
+            scheduleNextQuery(entity, OffsetDateTime.now()
+                    .plusSeconds(UnifiedPollPolicy.CREATE_FAILURE_RETRY_SECONDS));
+            throw new BusinessException(500,
+                    "本地订单已保留,可能仍会成功,请用 out_trade_no 查询: " + e.getMessage());
+        }
+
+        JsonNode upstream = UnifiedUpstreamSnapshot.of(data);
+        backfillFromResponse(entity, data, upstream);
+        recordQueryAttempt(entity);
+
+        String state = UnifiedResponseParser.pickState(data);
+        if (UnifiedOrderStatus.canTransition(entity.getTradeStatus(), state)) {
+            if (UnifiedOrderStatus.SUCCESS.equals(state)
+                    || UnifiedOrderStatus.REFUNDED.equals(state)) {
+                if (!amountMatches(entity, data)) {
+                    markAmountMismatch(entity, upstreamAmountDetail(entity, data));
+                    throw new BusinessException(500, "AMOUNT_MISMATCH");
+                }
+            }
+            applyOrderState(entity, state, upstream, null, null);
+        }
+
+        return reload(entity);
+    }
+
+    private static void validateCreate(UnifiedOrderCreateCommand cmd, UnifiedSource source) {
+        if (cmd == null) {
+            throw new BusinessException(400, "请求不能为空");
+        }
+        // 后台管理端没有下单入口;放进来会绕过收银台/开放 API 的租户语义
+        if (source == null || source == UnifiedSource.ADMIN) {
+            throw new BusinessException(400, "不支持的下单来源: " + source);
+        }
+        if (cmd.getAmount() == null || cmd.getAmount() <= 0) {
+            throw new BusinessException(400, "金额必须大于 0");
+        }
+        if (StrUtil.isBlank(cmd.getWayCode())) {
+            throw new BusinessException(400, "wayCode 不能为空");
+        }
+        // 先于落库/上游校验:未知 wayCode 不许建单,更不许打上游
+        if (!UnifiedChannelExtraAssembler.SUPPORTED.contains(cmd.getWayCode())) {
+            throw new BusinessException(400, "不支持的 wayCode: " + cmd.getWayCode());
+        }
+        if (StrUtil.isBlank(cmd.getSubject())) {
+            throw new BusinessException(400, "subject 不能为空");
+        }
+        if (StrUtil.isBlank(cmd.getOutTradeNo())) {
+            throw new BusinessException(400, "out_trade_no 不能为空");
+        }
+    }
+
+    /** 幂等比较只看关键字段:金额、方式、标题(spec §7.2)。 */
+    private static boolean sameCreateParams(UnifiedOrderEntity existing, UnifiedOrderCreateCommand cmd) {
+        return Objects.equals(existing.getAmount(), cmd.getAmount())
+                && Objects.equals(existing.getWayCode(), cmd.getWayCode())
+                && Objects.equals(existing.getSubject(), cmd.getSubject());
+    }
+
+    private static UnifiedOrderEntity buildEntity(UnifiedOrderCreateCommand cmd, Long tenantId,
+                                                  String apiKey, UnifiedSource source,
+                                                  UnifiedMerchantEntity cfg,
+                                                  UnifiedChannelExtraAssembler.Result extra) {
+        UnifiedOrderEntity entity = new UnifiedOrderEntity();
+        entity.setTenantId(tenantId);
+        entity.setMerchantId(cfg.getId());
+        entity.setMchNo(cfg.getMchNo());
+        entity.setAppId(cfg.getAppId());
+        entity.setOrderNo(ORDER_NO_PREFIX + SnowflakeIdGenerator.nextIdStr());
+        entity.setOutTradeNo(cmd.getOutTradeNo());
+        entity.setApiKey(apiKey);
+        entity.setSource(source.name());
+        entity.setWayCode(cmd.getWayCode());
+        entity.setAmount(cmd.getAmount());
+        entity.setRefundedAmount(0L);
+        entity.setCurrency(CURRENCY_CNY);
+        entity.setClientIp(cmd.getClientIp());
+        entity.setSubject(cmd.getSubject());
+        entity.setBody(cmd.getBody());
+        // R22:调用方的 notify_url 只存快照;上游 notifyUrl 见 buildPayload
+        entity.setCallbackUrl(cmd.getNotifyUrl());
+        entity.setExtParam(cmd.getExtParam());
+        // 只落键名,值(付款码/openid/buyerUserId)绝不落库
+        entity.setChannelExtraKeys(extra == null ? null : extra.keysCsv());
+        entity.setTradeStatus(UnifiedOrderStatus.GENERATED);
+        entity.setNotifyStatus(UnifiedNotifyStatus.PENDING);
+        entity.setQueryCount(0);
+        return entity;
+    }
+
+    private UnifiedOrderPayload buildPayload(UnifiedOrderEntity entity, UnifiedOrderCreateCommand cmd,
+                                             UnifiedChannelExtraAssembler.Result extra) {
+        return new UnifiedOrderPayload(
+                entity.getOrderNo(),
+                entity.getAmount(),
+                entity.getSubject(),
+                StrUtil.isBlank(entity.getBody()) ? entity.getSubject() : entity.getBody(),
+                entity.getWayCode(),
+                extra == null ? null : extra.json(),
+                // 上游通知必须回我们:调用方地址只用于下游回调
+                properties.notifyOrderUrl(),
+                cmd.getReturnUrl(),
+                String.valueOf(cmd.getExpiredTime() == null
+                        ? DEFAULT_EXPIRED_SECONDS : cmd.getExpiredTime()),
+                cmd.getClientIp(),
+                cmd.getExtParam());
+    }
+
+    /** 按响应回填上游单号/支付数据/脱敏快照;只更新这些列,不碰业务状态。 */
+    private void backfillFromResponse(UnifiedOrderEntity order, JsonNode data, JsonNode upstream) {
+        String upstreamPayOrderId = textOf(data, "payOrderId");
+        String payDataType = UnifiedResponseParser.pickPayDataType(data);
+        String payData = textOf(data, "payData");
+        String upstreamChannelOrderNo = textOf(data, "channelOrderNo");
+        OffsetDateTime upstreamCreatedAt = UnifiedResponseParser.toShanghai(
+                UnifiedResponseParser.epochMillis(data, "createdAt"));
+        String upstreamDataJson = UnifiedUpstreamSnapshot.toJson(upstream);
+        String errCode = textOf(data, "errCode");
+        String errMsg = textOf(data, "errMsg");
+
+        orderMapper.update(null, new LambdaUpdateWrapper<UnifiedOrderEntity>()
+                .eq(UnifiedOrderEntity::getOrderNo, order.getOrderNo())
+                .set(UnifiedOrderEntity::getUpstreamPayOrderId, upstreamPayOrderId)
+                .set(UnifiedOrderEntity::getPayDataType, payDataType)
+                .set(UnifiedOrderEntity::getPayData, payData)
+                .set(UnifiedOrderEntity::getUpstreamChannelOrderNo, upstreamChannelOrderNo)
+                .set(UnifiedOrderEntity::getUpstreamCreatedAt, upstreamCreatedAt)
+                .set(UnifiedOrderEntity::getUpstreamDataJson, upstreamDataJson)
+                .set(UnifiedOrderEntity::getErrCode, errCode)
+                .set(UnifiedOrderEntity::getErrMsg, errMsg));
+
+        order.setUpstreamPayOrderId(upstreamPayOrderId);
+        order.setPayDataType(payDataType);
+        order.setPayData(payData);
+        order.setUpstreamChannelOrderNo(upstreamChannelOrderNo);
+        order.setUpstreamCreatedAt(upstreamCreatedAt);
+        order.setUpstreamDataJson(upstreamDataJson);
+        order.setErrCode(errCode);
+        order.setErrMsg(errMsg);
+    }
+
+    private UnifiedOrderEntity reload(UnifiedOrderEntity fallback) {
+        UnifiedOrderEntity reloaded = orderMapper.selectOne(
+                new LambdaQueryWrapper<UnifiedOrderEntity>()
+                        .eq(UnifiedOrderEntity::getOrderNo, fallback.getOrderNo()));
+        return reloaded != null ? reloaded : fallback;
+    }
+
+    // ────────────────────────────── 状态机 ──────────────────────────────
+
+    /**
+     * 条件推进订单状态(唯一写 {@code trade_status} 的入口)。
+     *
+     * <p>非法/同态/不可迁移直接 false;条件 UPDATE 落空也 false —— 两种情况都**不发事件**。
+     * 真的写了行且目标状态在 {@code NOTIFY_STATES} 里才发事件并推下游回调。
+     */
+    public boolean applyOrderState(UnifiedOrderEntity order, String nextState, JsonNode upstream,
+                                   String errCode, String errMsg) {
+        if (order == null || order.getOrderNo() == null) {
+            return false;
+        }
+        String current = order.getTradeStatus();
+        if (!UnifiedOrderStatus.isValid(nextState)
+                || !UnifiedOrderStatus.canTransition(current, nextState)) {
+            return false;
+        }
+
+        OffsetDateTime now = OffsetDateTime.now();
+        int queryCount = order.getQueryCount() == null ? 0 : order.getQueryCount();
+        OffsetDateTime nextQueryTime = UnifiedOrderStatus.PENDING.contains(nextState)
+                ? now.plusSeconds(UnifiedPollPolicy.nextDelaySeconds(queryCount))
+                : null;
+        OffsetDateTime payTime = UnifiedOrderStatus.SUCCESS.equals(nextState)
+                ? UnifiedResponseParser.toShanghai(
+                        UnifiedResponseParser.epochMillis(upstream, "successTime"))
+                : null;
+
+        LambdaUpdateWrapper<UnifiedOrderEntity> update = new LambdaUpdateWrapper<UnifiedOrderEntity>()
+                .eq(UnifiedOrderEntity::getOrderNo, order.getOrderNo())
+                .in(UnifiedOrderEntity::getTradeStatus, UnifiedOrderStatus.previousStatesFor(nextState))
+                .set(UnifiedOrderEntity::getTradeStatus, nextState)
+                .set(UnifiedOrderEntity::getErrCode, errCode)
+                .set(UnifiedOrderEntity::getErrMsg, errMsg)
+                .set(UnifiedOrderEntity::getLastQueryTime, now)
+                .set(UnifiedOrderEntity::getUpdatedTime, now)
+                .set(UnifiedOrderEntity::getNextQueryTime, nextQueryTime);
+        if (payTime != null) {
+            update.set(UnifiedOrderEntity::getPayTime, payTime);
+        }
+
+        if (orderMapper.update(null, update) == 0) {
+            // 并发下另一条腿已经写过:不覆盖、不发事件
+            return false;
+        }
+
+        order.setTradeStatus(nextState);
+        order.setErrCode(errCode);
+        order.setErrMsg(errMsg);
+        order.setLastQueryTime(now);
+        order.setUpdatedTime(now);
+        order.setNextQueryTime(nextQueryTime);
+        if (payTime != null) {
+            order.setPayTime(payTime);
+        }
+
+        if (NOTIFY_STATES.contains(nextState)) {
+            // R23:租户号,不是自增主键
+            publisher.changed(OrderEventPublisher.CHANNEL_UNIFIED_ORDER, order.getTenantId());
+            callbackService.notifyOrder(order, upstream);
+        }
+        return true;
+    }
+
+    /**
+     * 金额核对失败:只写错误码,不碰 {@code trade_status}(不会报错但会错钱)。
+     *
+     * <p>{@code detail} 含上游金额与本地金额;ERROR 日志含单号 —— 两者都不进请求体。
+     */
+    public void markAmountMismatch(UnifiedOrderEntity order, String detail) {
+        if (order == null || order.getOrderNo() == null) {
+            return;
+        }
+        OffsetDateTime now = OffsetDateTime.now();
+        orderMapper.update(null, new LambdaUpdateWrapper<UnifiedOrderEntity>()
+                .eq(UnifiedOrderEntity::getOrderNo, order.getOrderNo())
+                .set(UnifiedOrderEntity::getErrCode, "AMOUNT_MISMATCH")
+                .set(UnifiedOrderEntity::getErrMsg, detail)
+                .set(UnifiedOrderEntity::getUpdatedTime, now));
+
+        order.setErrCode("AMOUNT_MISMATCH");
+        order.setErrMsg(detail);
+        order.setUpdatedTime(now);
+        log.error("[统一支付] 金额核对失败,订单状态不推进: order_no={}, {}", order.getOrderNo(), detail);
+    }
+
+    /** 查询计数 +1 并记时间;内存同步(条件更新不经过实体回填)。 */
+    public void recordQueryAttempt(UnifiedOrderEntity order) {
+        if (order == null || order.getOrderNo() == null) {
+            return;
+        }
+        OffsetDateTime now = OffsetDateTime.now();
+        orderMapper.update(null, new LambdaUpdateWrapper<UnifiedOrderEntity>()
+                .eq(UnifiedOrderEntity::getOrderNo, order.getOrderNo())
+                .setSql("query_count = query_count + 1")
+                .set(UnifiedOrderEntity::getLastQueryTime, now));
+
+        order.setQueryCount(order.getQueryCount() == null ? 1 : order.getQueryCount() + 1);
+        order.setLastQueryTime(now);
+    }
+
+    /** 安排下一次查询(创建失败重试 / 无状态变化的常规退避)。 */
+    public void scheduleNextQuery(UnifiedOrderEntity order, OffsetDateTime next) {
+        if (order == null || order.getOrderNo() == null) {
+            return;
+        }
+        orderMapper.update(null, new LambdaUpdateWrapper<UnifiedOrderEntity>()
+                .eq(UnifiedOrderEntity::getOrderNo, order.getOrderNo())
+                .set(UnifiedOrderEntity::getNextQueryTime, next));
+        order.setNextQueryTime(next);
+    }
+
+    // ────────────────────────────── 金额核对 ──────────────────────────────
+
+    /**
+     * 上游 {@code amount} 必须存在、&gt;0、等于本地金额;{@code currency} 缺省视为 cny,
+     * 大小写不敏感且必须等于本地币种(spec §7.5)。
+     *
+     * <p>Task 11 的查单腿复用本方法 —— 通知和查单两条腿的钱安全规则必须一致。
+     */
+    private static boolean amountMatches(UnifiedOrderEntity order, JsonNode data) {
+        if (order == null || data == null || !data.isObject()) {
+            return false;
+        }
+        JsonNode amountNode = data.get("amount");
+        if (amountNode == null || amountNode.isNull() || amountNode.isContainerNode()) {
+            return false;
+        }
+        BigDecimal amount;
+        try {
+            amount = new BigDecimal(amountNode.asText().trim());
+        } catch (NumberFormatException e) {
+            return false;
+        }
+        if (amount.signum() <= 0) {
+            return false;
+        }
+        if (order.getAmount() == null
+                || amount.compareTo(BigDecimal.valueOf(order.getAmount())) != 0) {
+            return false;
+        }
+
+        String localCurrency = order.getCurrency() == null ? CURRENCY_CNY : order.getCurrency();
+        JsonNode currencyNode = data.get("currency");
+        if (currencyNode == null || currencyNode.isNull() || currencyNode.isContainerNode()
+                || currencyNode.asText().isBlank()) {
+            // 缺省视为 cny(spec §7.5)
+            return CURRENCY_CNY.equalsIgnoreCase(localCurrency);
+        }
+        return currencyNode.asText().trim().equalsIgnoreCase(localCurrency);
+    }
+
+    private static String upstreamAmountDetail(UnifiedOrderEntity order, JsonNode data) {
+        JsonNode amount = data == null ? null : data.get("amount");
+        return "上游=" + (amount == null ? null : amount.asText()) + ", 本地=" + order.getAmount();
+    }
+
+    private static String textOf(JsonNode data, String field) {
+        if (data == null || !data.isObject()) {
+            return null;
+        }
+        JsonNode node = data.get(field);
+        if (node == null || node.isNull() || node.isContainerNode()) {
+            return null;
+        }
+        String value = node.asText();
+        if (value == null) {
+            return null;
+        }
+        value = value.trim();
+        return value.isEmpty() ? null : value;
+    }
+
+    // ────────────────────────────── 查询 ──────────────────────────────
+
+    /**
+     * 后台分页列表。
+     *
+     * <p>租户侧由 {@code TenantInnerInterceptor} 钉住;列表不算可退金额(null),
+     * 详情由控制器调 {@link #refundableAmount}。
+     */
+    public PageResult<UnifiedOrderVO> list(UnifiedOrderQueryDTO query) {
+        UnifiedOrderQueryDTO q = query == null ? new UnifiedOrderQueryDTO() : query;
+        int pageNo = Math.max(1, q.getPageNo());
+        int pageSize = Math.max(1, Math.min(q.getPageSize(), LIST_PAGE_SIZE_MAX));
+
+        LambdaQueryWrapper<UnifiedOrderEntity> w = new LambdaQueryWrapper<>();
+        if (StrUtil.isNotBlank(q.getOrderNo())) {
+            w.eq(UnifiedOrderEntity::getOrderNo, q.getOrderNo());
+        }
+        if (StrUtil.isNotBlank(q.getOutTradeNo())) {
+            w.eq(UnifiedOrderEntity::getOutTradeNo, q.getOutTradeNo());
+        }
+        if (StrUtil.isNotBlank(q.getMchNo())) {
+            w.eq(UnifiedOrderEntity::getMchNo, q.getMchNo());
+        }
+        if (StrUtil.isNotBlank(q.getWayCode())) {
+            w.eq(UnifiedOrderEntity::getWayCode, q.getWayCode());
+        }
+        if (StrUtil.isNotBlank(q.getTradeStatus())) {
+            w.eq(UnifiedOrderEntity::getTradeStatus, q.getTradeStatus());
+        }
+        if (StrUtil.isNotBlank(q.getStartDate())) {
+            w.ge(UnifiedOrderEntity::getCreatedTime, startOfDay(q.getStartDate()));
+        }
+        if (StrUtil.isNotBlank(q.getEndDate())) {
+            // 含结束当天:下半开区间 [endDate 00:00, endDate+1 00:00)
+            w.lt(UnifiedOrderEntity::getCreatedTime, startOfDay(q.getEndDate()).plusDays(1));
+        }
+        w.orderByDesc(UnifiedOrderEntity::getCreatedTime);
+        w.orderByDesc(UnifiedOrderEntity::getId);
+
+        Page<UnifiedOrderEntity> page =
+                orderMapper.selectPage(new Page<>(pageNo, pageSize), w);
+        List<UnifiedOrderVO> rows = page.getRecords().stream()
+                .map(entity -> UnifiedOrderVO.of(entity, null))
+                .toList();
+        return PageResult.of(pageNo, pageSize, page.getTotal(), rows);
+    }
+
+    private static OffsetDateTime startOfDay(String value) {
+        try {
+            return LocalDate.parse(value.trim()).atStartOfDay(ZoneId.systemDefault())
+                    .toOffsetDateTime();
+        } catch (DateTimeParseException e) {
+            throw new BusinessException(400, "日期格式应为 yyyy-MM-dd: " + value);
+        }
+    }
+
+    /** 后台详情按主键;租户由拦截器钉住(超管跨租户)。 */
+    public UnifiedOrderEntity requireById(Long id) {
+        if (id == null) {
+            throw new BusinessException(400, "订单 id 不能为空");
+        }
+        UnifiedOrderEntity row = orderMapper.selectById(id);
+        if (row == null) {
+            throw new BusinessException(404, "订单不存在");
+        }
+        return row;
+    }
+
+    /**
+     * 无认证/内部路径按全局唯一 {@code order_no} 点查。
+     *
+     * @throws BusinessException 400 单号为空;404 不存在
+     */
+    public UnifiedOrderEntity requireByOrderNo(String orderNo) {
+        if (StrUtil.isBlank(orderNo)) {
+            throw new BusinessException(400, "订单号不能为空");
+        }
+        UnifiedOrderEntity row = orderMapper.selectOne(new LambdaQueryWrapper<UnifiedOrderEntity>()
+                .eq(UnifiedOrderEntity::getOrderNo, orderNo));
+        if (row == null) {
+            throw new BusinessException(404, "订单不存在");
+        }
+        return row;
+    }
+
+    /**
+     * 租户内按订单号查;{@code tenantId == null}(超管/内部)按全局唯一单号查。
+     * 找不到返回 null。
+     */
+    public UnifiedOrderEntity findByOrderNo(Long tenantId, String orderNo) {
+        if (StrUtil.isBlank(orderNo)) {
+            return null;
+        }
+        LambdaQueryWrapper<UnifiedOrderEntity> w = new LambdaQueryWrapper<UnifiedOrderEntity>()
+                .eq(UnifiedOrderEntity::getOrderNo, orderNo);
+        if (tenantId != null) {
+            w.eq(UnifiedOrderEntity::getTenantId, tenantId);
+        }
+        return orderMapper.selectOne(w);
+    }
+
+    /**
+     * 租户内按调用方单号查(幂等键是 {@code (tenant_id, out_trade_no)})。
+     *
+     * <p>{@code tenantId == null} 时返回 null:开放 API/收银台都必有租户,
+     * 无租户条件会让同一 out_trade_no 在不同租户下多行,{@code selectOne} 直接炸。
+     */
+    public UnifiedOrderEntity findByOutTradeNo(Long tenantId, String outTradeNo) {
+        if (tenantId == null || StrUtil.isBlank(outTradeNo)) {
+            return null;
+        }
+        return orderMapper.selectOne(new LambdaQueryWrapper<UnifiedOrderEntity>()
+                .eq(UnifiedOrderEntity::getTenantId, tenantId)
+                .eq(UnifiedOrderEntity::getOutTradeNo, outTradeNo));
+    }
+
+    /**
+     * 上游通知找单:优先 {@code payOrderId}(上游单号),查不到回退我方
+     * {@code mchOrderNo}(即 {@code order_no})。
+     */
+    public UnifiedOrderEntity findForNotify(String upstreamPayOrderId, String orderNo) {
+        if (StrUtil.isNotBlank(upstreamPayOrderId)) {
+            UnifiedOrderEntity row = orderMapper.selectOne(new LambdaQueryWrapper<UnifiedOrderEntity>()
+                    .eq(UnifiedOrderEntity::getUpstreamPayOrderId, upstreamPayOrderId));
+            if (row != null) {
+                return row;
+            }
+        }
+        if (StrUtil.isNotBlank(orderNo)) {
+            return orderMapper.selectOne(new LambdaQueryWrapper<UnifiedOrderEntity>()
+                    .eq(UnifiedOrderEntity::getOrderNo, orderNo));
+        }
+        return null;
+    }
+
+    /**
+     * 轮询扫描(Task 11 调度器消费):非终态、到期、未超 30 分钟、未满 60 次,
+     * 按到期时间升序,最多 {@code limit} 条。
+     *
+     * <p>{@code LIMIT} 用拼接而非参数:MyBatis-Plus 的 Lambda wrapper 没有 limit 参数位;
+     * 这里先夹到 [1, 500] 的整型,不存在注入面。
+     */
+    public List<UnifiedOrderEntity> findPendingForPoll(int limit) {
+        int size = Math.max(1, Math.min(limit, POLL_LIMIT_MAX));
+        OffsetDateTime now = OffsetDateTime.now();
+        return orderMapper.selectList(new LambdaQueryWrapper<UnifiedOrderEntity>()
+                .in(UnifiedOrderEntity::getTradeStatus, UnifiedOrderStatus.PENDING)
+                .le(UnifiedOrderEntity::getNextQueryTime, now)
+                .gt(UnifiedOrderEntity::getCreatedTime,
+                        now.minusMinutes(UnifiedPollPolicy.MAX_AGE_MINUTES))
+                .lt(UnifiedOrderEntity::getQueryCount, UnifiedPollPolicy.MAX_QUERY_COUNT)
+                .orderByAsc(UnifiedOrderEntity::getNextQueryTime)
+                .last("LIMIT " + size));
+    }
+
+    /** 可退金额 = 订单金额 - 占用中的退款(创建/处理中/成功都算,spec §7.4)。 */
+    public long refundableAmount(UnifiedOrderEntity order) {
+        if (order == null) {
+            return 0L;
+        }
+        long amount = order.getAmount() == null ? 0L : order.getAmount();
+        if (order.getId() == null) {
+            return amount;
+        }
+        return amount - refundMapper.sumOccupiedAmount(order.getId());
+    }
+}

+ 144 - 0
java/src/main/java/com/payment/platform/module/payment/unified/util/UnifiedUpstreamSnapshot.java

@@ -0,0 +1,144 @@
+package com.payment.platform.module.payment.unified.util;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.TextNode;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 上游 {@code data} / 通知参数的脱敏快照(spec §7.5,裁定 R35)。
+ *
+ * <p>两条硬约束:
+ *
+ * <ol>
+ *   <li><b>只存白名单字段</b> —— 上游通知是「我们上送的内容 + 查单返回参数」的混合体,
+ *       会带 {@code channelExtra}(付款码 / openid / buyerUserId)、{@code sign}。
+ *       整包落库等于把付款凭据写进数据库。白名单逐字取 spec §7.5,不得放宽。</li>
+ *   <li><b>最长 8KB</b> —— 异常大报文不能把行撑爆。截断保持 JSON 可解析:
+ *       只截断白名单字段的文本值(按码点边界),回调重试路径仍能 {@code readTree}。</li>
+ * </ol>
+ *
+ * <p>纯函数、无状态;不记录任何参数值。
+ */
+public final class UnifiedUpstreamSnapshot {
+
+    /** spec §7.5 / R35 的白名单,逐字。顺序即快照字段顺序(便于排障时稳定比对)。 */
+    private static final List<String> WHITELIST = List.of(
+            "payOrderId", "mchOrderNo", "state", "orderState", "amount", "currency",
+            "wayCode", "ifCode", "channelOrderNo", "successTime", "createdAt",
+            "refundOrderId", "mchRefundNo", "refundAmount", "payAmount",
+            "extParam", "errCode", "errMsg");
+
+    /** 与 spec §7.5「截断到 8KB」一致;按 UTF-8 字节计。 */
+    public static final int MAX_LENGTH = 8192;
+
+    private static final String EMPTY = "{}";
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private UnifiedUpstreamSnapshot() {
+    }
+
+    /**
+     * 从上游 {@code data} 节点拷贝白名单字段。
+     *
+     * <p>null / 非对象 / 空对象都返回空 {@link ObjectNode}({@link #toJson} 输出 {@code "{}"})。
+     */
+    public static JsonNode of(JsonNode data) {
+        ObjectNode snapshot = MAPPER.createObjectNode();
+        if (data == null || !data.isObject()) {
+            return snapshot;
+        }
+        for (String field : WHITELIST) {
+            JsonNode value = data.get(field);
+            if (value != null && !value.isNull()) {
+                snapshot.set(field, value.deepCopy());
+            }
+        }
+        return snapshot;
+    }
+
+    /**
+     * 从 form 参数表(上游通知)拷贝白名单字段。
+     *
+     * <p>空白值不写;全部是字符串 —— 通知参数本身就是字符串。
+     */
+    public static JsonNode ofParams(Map<String, String> params) {
+        ObjectNode snapshot = MAPPER.createObjectNode();
+        if (params == null || params.isEmpty()) {
+            return snapshot;
+        }
+        for (String field : WHITELIST) {
+            String value = params.get(field);
+            if (value != null && !value.isBlank()) {
+                snapshot.put(field, value);
+            }
+        }
+        return snapshot;
+    }
+
+    /**
+     * 序列化为落库字符串;空快照输出 {@code "{}"},超限时截断文本值。
+     *
+     * <p>截断策略:先按「每字段最多 {@code cap} 个码点」缩一遍,仍超限就 {@code cap} 减半,
+     * 直到不超过 {@link #MAX_LENGTH} 字节。白名单只有 18 个字段,最多几轮就收敛。
+     * 极端情况(字段名本身超 8KB)返回空对象 —— 长度上限优先于排障信息。
+     */
+    public static String toJson(JsonNode sanitized) {
+        if (sanitized == null || !sanitized.isObject() || sanitized.isEmpty()) {
+            return EMPTY;
+        }
+        String json = write(sanitized);
+        if (byteLength(json) <= MAX_LENGTH) {
+            return json;
+        }
+
+        int cap = MAX_LENGTH / 2;
+        while (cap > 0) {
+            ObjectNode truncated = ((ObjectNode) sanitized).deepCopy();
+            truncateTexts(truncated, cap);
+            json = write(truncated);
+            if (byteLength(json) <= MAX_LENGTH) {
+                return json;
+            }
+            cap /= 2;
+        }
+        return EMPTY;
+    }
+
+    private static String write(JsonNode node) {
+        try {
+            return MAPPER.writeValueAsString(node);
+        } catch (JsonProcessingException e) {
+            // ObjectNode 序列化不会失败;真失败也不能把内容带进异常消息(可能含凭据)
+            throw new IllegalStateException("统一支付快照序列化失败", e);
+        }
+    }
+
+    private static int byteLength(String json) {
+        return json.getBytes(StandardCharsets.UTF_8).length;
+    }
+
+    /** 只缩文本值,数字/布尔保持原样;按码点截断,避免切出半个代理对。 */
+    private static void truncateTexts(ObjectNode node, int cap) {
+        List<String> names = new ArrayList<>();
+        node.fieldNames().forEachRemaining(names::add);
+        for (String name : names) {
+            JsonNode value = node.get(name);
+            if (value == null || !value.isTextual()) {
+                continue;
+            }
+            String text = value.asText();
+            if (text.codePointCount(0, text.length()) > cap) {
+                int end = text.offsetByCodePoints(0, cap);
+                node.set(name, TextNode.valueOf(text.substring(0, end)));
+            }
+        }
+    }
+}

+ 3 - 1
java/src/test/java/com/payment/platform/module/payment/stream/OrderEventPublisherTest.java

@@ -26,10 +26,12 @@ class OrderEventPublisherTest {
 
     @Test
     void channelNames_areTheWireContractWithFrontend() {
-        // 与前端 scripts/verify-order-stream.mjs 里钉的三个串必须逐字一致:改名 = 断链
+        // 与前端 scripts/verify-order-stream.mjs 里钉的五个串必须逐字一致:改名 = 断链
         assertEquals("lsy_order", OrderEventPublisher.CHANNEL_LSY_ORDER);
         assertEquals("lsy_refund", OrderEventPublisher.CHANNEL_LSY_REFUND);
         assertEquals("har_order", OrderEventPublisher.CHANNEL_HAR_ORDER);
+        assertEquals("unified_order", OrderEventPublisher.CHANNEL_UNIFIED_ORDER);
+        assertEquals("unified_refund", OrderEventPublisher.CHANNEL_UNIFIED_REFUND);
     }
 
     @Test

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

@@ -0,0 +1,624 @@
+package com.payment.platform.module.payment.unified.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+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.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
+import com.payment.platform.module.payment.unified.client.UnifiedApiException;
+import com.payment.platform.module.payment.unified.client.UnifiedClient;
+import com.payment.platform.module.payment.unified.client.UnifiedOrderPayload;
+import com.payment.platform.module.payment.unified.config.UnifiedPayProperties;
+import com.payment.platform.module.payment.unified.dto.query.UnifiedOrderQueryDTO;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedOrderCreateCommand;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedOrderVO;
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.enums.UnifiedOrderStatus;
+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.mapper.UnifiedRefundMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DuplicateKeyException;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Map;
+
+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.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 下单主流程与订单状态机(spec §7.2 / §7.5)。
+ *
+ * <p>本类盯的是「不会报错但会错钱」的地方:
+ * <ol>
+ *   <li>先调上游再落库 —— 上游通知可能比 HTTP 响应先到,通知会找不到单;</li>
+ *   <li>未知状态直接落库 —— 一个 {@code state=99} 就能把订单推到不存在的状态;</li>
+ *   <li>上游金额与本地不符仍落成功 —— 少付的单被标成已支付;</li>
+ *   <li>上游失败就删单 —— 超时的那笔可能已经成功;</li>
+ *   <li>并发重复 out_trade_no 撞唯一索引后 500 —— 幂等重试应当成功。</li>
+ * </ol>
+ */
+@ExtendWith(MockitoExtension.class)
+class UnifiedOrderServiceTest {
+
+    private static final ObjectMapper JSON = new ObjectMapper();
+    private static final Long TENANT = 2L;
+
+    @Mock private UnifiedOrderMapper orderMapper;
+    @Mock private UnifiedRefundMapper refundMapper;
+    @Mock private UnifiedClient client;
+    @Mock private UnifiedMerchantService merchantService;
+    @Mock private UnifiedCallbackService callbackService;
+    @Mock private OrderEventPublisher publisher;
+
+    private UnifiedPayProperties props;
+    private UnifiedOrderService service;
+
+    @BeforeEach
+    void setUp() {
+        props = new UnifiedPayProperties();
+        service = new UnifiedOrderService(orderMapper, refundMapper, client,
+                merchantService, callbackService, publisher, props);
+        // 初始化 MyBatis-Plus lambda 元数据缓存,使 getSqlSegment()/getSqlSet() 在无 Spring 上下文的单测中工作
+        // (同款引导见 UnifiedMerchantServiceTest。)
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
+        TableInfoHelper.initTableInfo(assistant, UnifiedOrderEntity.class);
+    }
+
+    // ────────────────────────────── 工具 ──────────────────────────────
+
+    private static JsonNode json(String text) {
+        try {
+            return JSON.readTree(text);
+        } catch (JsonProcessingException e) {
+            throw new IllegalStateException("测试 JSON 写错了", e);
+        }
+    }
+
+    private static UnifiedOrderCreateCommand command() {
+        UnifiedOrderCreateCommand cmd = new UnifiedOrderCreateCommand();
+        cmd.setAmount(100L);
+        cmd.setWayCode("QR_CASHIER");
+        cmd.setSubject("测试商品");
+        cmd.setOutTradeNo("T001");
+        cmd.setNotifyUrl("https://merchant.example/callback");
+        return cmd;
+    }
+
+    private static UnifiedMerchantEntity merchant() {
+        UnifiedMerchantEntity cfg = new UnifiedMerchantEntity();
+        cfg.setId(11L);
+        cfg.setTenantId(TENANT);
+        cfg.setMchNo("M001");
+        cfg.setAppId("app-1");
+        cfg.setAppSecret("SECRET");
+        cfg.setStatus(UnifiedMerchantService.STATUS_ENABLED);
+        return cfg;
+    }
+
+    private UnifiedMerchantEntity stubMerchant() {
+        UnifiedMerchantEntity cfg = merchant();
+        when(merchantService.resolveForTenant(eq(TENANT), any())).thenReturn(cfg);
+        return cfg;
+    }
+
+    private static UnifiedOrderEntity existing(String tradeStatus) {
+        UnifiedOrderEntity o = new UnifiedOrderEntity();
+        o.setId(5L);
+        o.setTenantId(TENANT);
+        o.setMerchantId(11L);
+        o.setMchNo("M001");
+        o.setAppId("app-1");
+        o.setOrderNo("UP5");
+        o.setOutTradeNo("T001");
+        o.setApiKey("KEY-1");
+        o.setSource("OPENAPI");
+        o.setWayCode("QR_CASHIER");
+        o.setAmount(100L);
+        o.setRefundedAmount(0L);
+        o.setCurrency("cny");
+        o.setSubject("测试商品");
+        o.setTradeStatus(tradeStatus);
+        o.setQueryCount(0);
+        return o;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> queryCaptor() {
+        return ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+    }
+
+    @SuppressWarnings("unchecked")
+    private static ArgumentCaptor<LambdaUpdateWrapper<UnifiedOrderEntity>> updateCaptor() {
+        return ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
+    }
+
+    // ────────────────────────────── 创建前置闸门 ──────────────────────────────
+
+    @Test
+    void unknownWayCode_rejectedBeforeInsertAndUpstream() {
+        UnifiedOrderCreateCommand cmd = command();
+        cmd.setWayCode("XX");
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.create(cmd, TENANT, "KEY-1", UnifiedSource.OPENAPI));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        verify(orderMapper, never()).insert(any());
+        verifyNoInteractions(client);
+    }
+
+    @Test
+    void missingOutTradeNo_rejected() {
+        UnifiedOrderCreateCommand cmd = command();
+        cmd.setOutTradeNo(" ");
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.create(cmd, TENANT, "KEY-1", UnifiedSource.OPENAPI));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        verify(orderMapper, never()).insert(any());
+        verifyNoInteractions(client);
+    }
+
+    @Test
+    void amountZeroOrNegative_rejected() {
+        for (long amount : new long[]{0L, -1L}) {
+            UnifiedOrderCreateCommand cmd = command();
+            cmd.setAmount(amount);
+
+            BusinessException e = assertThrows(BusinessException.class,
+                    () -> service.create(cmd, TENANT, "KEY-1", UnifiedSource.OPENAPI));
+
+            assertEquals(400, e.getCode(), "amount=" + amount + " 实测消息: " + e.getMessage());
+        }
+        verify(orderMapper, never()).insert(any());
+        verifyNoInteractions(client);
+    }
+
+    @Test
+    void sourceAdmin_rejectedForOrderCreate() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.create(command(), TENANT, "KEY-1", UnifiedSource.ADMIN));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        verify(orderMapper, never()).insert(any());
+        verifyNoInteractions(client);
+    }
+
+    // ────────────────────────────── 幂等 ──────────────────────────────
+
+    @Test
+    void idempotentSameParams_returnsExistingWithoutUpstream() {
+        UnifiedOrderEntity existing = existing("0");
+        when(orderMapper.selectOne(any())).thenReturn(existing);
+
+        UnifiedOrderEntity result = service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI);
+
+        assertSame(existing, result);
+        verify(orderMapper, never()).insert(any());
+        verifyNoInteractions(client);
+
+        ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> w = queryCaptor();
+        verify(orderMapper).selectOne(w.capture());
+        String sql = w.getValue().getSqlSegment();
+        assertTrue(sql.contains("tenant_id"), "幂等键必须含租户: " + sql);
+        assertTrue(sql.contains("out_trade_no"), "幂等键必须含 out_trade_no: " + sql);
+    }
+
+    @Test
+    void idempotentDifferentParams_409() {
+        UnifiedOrderEntity existing = existing("0");
+        existing.setAmount(99L);
+        when(orderMapper.selectOne(any())).thenReturn(existing);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI));
+
+        assertEquals(409, e.getCode(), "实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("out_trade_no"));
+        verifyNoInteractions(client);
+    }
+
+    @Test
+    void duplicateInsertSameParams_returnsExistingInsteadOf500() {
+        UnifiedOrderEntity existing = existing("0");
+        when(orderMapper.selectOne(any())).thenReturn(null, existing);
+        stubMerchant();
+        when(orderMapper.insert(any())).thenThrow(new DuplicateKeyException("uk_unified_order_tenant_out_trade_no"));
+
+        UnifiedOrderEntity result = service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI);
+
+        assertSame(existing, result);
+        verifyNoInteractions(client);
+    }
+
+    @Test
+    void duplicateInsertDifferentParams_409() {
+        UnifiedOrderEntity existing = existing("0");
+        existing.setAmount(99L);
+        when(orderMapper.selectOne(any())).thenReturn(null, existing);
+        stubMerchant();
+        when(orderMapper.insert(any())).thenThrow(new DuplicateKeyException("uk_unified_order_tenant_out_trade_no"));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI));
+
+        assertEquals(409, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(client);
+    }
+
+    // ────────────────────────────── 创建主流程 ──────────────────────────────
+
+    @Test
+    void insertCommitsBeforeUpstreamCall_andStoresCallbackSnapshot() {
+        stubMerchant();
+        when(client.unifiedOrder(any(), any())).thenReturn(json("{\"payOrderId\":\"P1\",\"orderState\":0}"));
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+        // 实体是活引用:记录 insert 那一刻的 queryCount(之后 recordQueryAttempt 会把它 ++)
+        java.util.concurrent.atomic.AtomicInteger queryCountAtInsert =
+                new java.util.concurrent.atomic.AtomicInteger(-1);
+        when(orderMapper.insert(any())).thenAnswer(inv -> {
+            queryCountAtInsert.set(inv.<UnifiedOrderEntity>getArgument(0).getQueryCount());
+            return 1;
+        });
+
+        UnifiedOrderCreateCommand cmd = command();
+        cmd.setChannelExtra(Map.of("payDataType", "codeUrl"));
+
+        service.create(cmd, TENANT, "KEY-1", UnifiedSource.OPENAPI);
+
+        InOrder inOrder = inOrder(orderMapper, client);
+        inOrder.verify(orderMapper).insert(any());
+        inOrder.verify(client).unifiedOrder(any(), any());
+
+        ArgumentCaptor<UnifiedOrderEntity> insertedCap = ArgumentCaptor.forClass(UnifiedOrderEntity.class);
+        verify(orderMapper).insert(insertedCap.capture());
+        UnifiedOrderEntity inserted = insertedCap.getValue();
+
+        assertEquals("0", inserted.getTradeStatus());
+        assertTrue(inserted.getOrderNo().startsWith("UP"), "order_no 实测: " + inserted.getOrderNo());
+        assertEquals(TENANT, inserted.getTenantId());
+        assertEquals("OPENAPI", inserted.getSource());
+        assertEquals("KEY-1", inserted.getApiKey());
+        assertEquals(11L, inserted.getMerchantId());
+        assertEquals("M001", inserted.getMchNo());
+        assertEquals("app-1", inserted.getAppId());
+        assertEquals(100L, inserted.getAmount());
+        assertEquals(0L, inserted.getRefundedAmount());
+        assertEquals("cny", inserted.getCurrency());
+        assertEquals("测试商品", inserted.getSubject());
+        assertEquals("payDataType", inserted.getChannelExtraKeys());
+        assertEquals("PENDING", inserted.getNotifyStatus());
+        assertEquals(0, queryCountAtInsert.get(), "落库那一刻 query_count 必须是 0");
+        // R22:调用方的 notifyUrl 只存 callback_url,绝不当上游 notifyUrl
+        assertEquals("https://merchant.example/callback", inserted.getCallbackUrl());
+
+        ArgumentCaptor<UnifiedOrderPayload> payloadCap = ArgumentCaptor.forClass(UnifiedOrderPayload.class);
+        verify(client).unifiedOrder(any(), payloadCap.capture());
+        UnifiedOrderPayload payload = payloadCap.getValue();
+        assertEquals(inserted.getOrderNo(), payload.mchOrderNo());
+        assertEquals(props.notifyOrderUrl(), payload.notifyUrl(),
+                "上游 notifyUrl 必须是我们自己的地址");
+        assertFalse(props.notifyOrderUrl().equals(cmd.getNotifyUrl()));
+        assertEquals("7200", payload.expiredTime());
+        assertEquals("测试商品", payload.body(), "body 空时用 subject 顶上");
+        assertTrue(payload.channelExtraJson().contains("payDataType"));
+        assertTrue(payload.channelExtraJson().contains("codeUrl"));
+    }
+
+    @Test
+    void successResponse_state2_appliesAndPublishes() {
+        stubMerchant();
+        JsonNode data = json("{\"payOrderId\":\"P1\",\"orderState\":2,\"amount\":100,"
+                + "\"currency\":\"cny\",\"successTime\":1760000000000}");
+        when(client.unifiedOrder(any(), any())).thenReturn(data);
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        UnifiedOrderEntity result = service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI);
+
+        assertEquals("2", result.getTradeStatus());
+        assertNotNull(result.getPayTime());
+        assertEquals("P1", result.getUpstreamPayOrderId());
+        assertEquals(1, result.getQueryCount(), "创建响应那次查询也要计数");
+        assertNotNull(result.getUpstreamDataJson());
+
+        // R23:事件带的是租户号(2),不是自增主键(5)
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_ORDER, TENANT);
+
+        ArgumentCaptor<UnifiedOrderEntity> callbackOrder = ArgumentCaptor.forClass(UnifiedOrderEntity.class);
+        ArgumentCaptor<JsonNode> callbackUpstream = ArgumentCaptor.forClass(JsonNode.class);
+        verify(callbackService).notifyOrder(callbackOrder.capture(), callbackUpstream.capture());
+        assertEquals("2", callbackOrder.getValue().getTradeStatus());
+        assertEquals("P1", callbackUpstream.getValue().get("payOrderId").asText());
+    }
+
+    @Test
+    void unknownState_doesNotAdvance() {
+        stubMerchant();
+        when(client.unifiedOrder(any(), any())).thenReturn(json("{\"payOrderId\":\"P1\",\"orderState\":99}"));
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        UnifiedOrderEntity result = service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI);
+
+        assertEquals("0", result.getTradeStatus());
+        assertEquals("P1", result.getUpstreamPayOrderId(), "回填仍应发生,只是状态不推进");
+        verifyNoInteractions(publisher, callbackService);
+    }
+
+    @Test
+    void successWithMissingOrInvalidAmount_isNotApplied() {
+        stubMerchant();
+        when(client.unifiedOrder(any(), any())).thenReturn(
+                json("{\"payOrderId\":\"P1\",\"orderState\":2}"),
+                json("{\"payOrderId\":\"P1\",\"orderState\":2,\"amount\":0,\"currency\":\"cny\"}"),
+                json("{\"payOrderId\":\"P1\",\"orderState\":2,\"amount\":-1,\"currency\":\"cny\"}"),
+                json("{\"payOrderId\":\"P1\",\"orderState\":2,\"amount\":99,\"currency\":\"cny\"}"),
+                json("{\"payOrderId\":\"P1\",\"orderState\":2,\"amount\":100,\"currency\":\"usd\"}"));
+
+        ArgumentCaptor<UnifiedOrderEntity> insertedCap = ArgumentCaptor.forClass(UnifiedOrderEntity.class);
+        for (int i = 0; i < 5; i++) {
+            BusinessException e = assertThrows(BusinessException.class,
+                    () -> service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI));
+            assertEquals(500, e.getCode(), "第 " + (i + 1) + " 例实测消息: " + e.getMessage());
+            assertTrue(e.getMessage().contains("AMOUNT_MISMATCH"),
+                    "第 " + (i + 1) + " 例必须报 AMOUNT_MISMATCH,实测: " + e.getMessage());
+        }
+
+        verify(orderMapper, times(5)).insert(insertedCap.capture());
+        for (UnifiedOrderEntity order : insertedCap.getAllValues()) {
+            assertEquals("0", order.getTradeStatus(), "金额不符时状态不能推进");
+            assertEquals("AMOUNT_MISMATCH", order.getErrCode());
+            assertNotNull(order.getErrMsg());
+        }
+        verifyNoInteractions(publisher, callbackService);
+    }
+
+    @Test
+    void upstreamFailure_keepsLocalOrderAndSchedulesRetry() {
+        stubMerchant();
+        when(client.unifiedOrder(any(), any())).thenThrow(
+                new UnifiedApiException(UnifiedApiException.CODE_TRANSPORT, "connect timeout"));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI));
+
+        assertEquals(500, e.getCode(), "实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("out_trade_no"), "实测消息: " + e.getMessage());
+
+        ArgumentCaptor<UnifiedOrderEntity> insertedCap = ArgumentCaptor.forClass(UnifiedOrderEntity.class);
+        verify(orderMapper).insert(insertedCap.capture());
+        UnifiedOrderEntity order = insertedCap.getValue();
+        assertEquals("0", order.getTradeStatus());
+        assertNotNull(order.getNextQueryTime(), "失败单必须安排 15 秒后的重试查询");
+        assertTrue(order.getNextQueryTime().isAfter(OffsetDateTime.now().plusSeconds(5)));
+        verifyNoInteractions(publisher, callbackService);
+    }
+
+    // ────────────────────────────── 状态机 ──────────────────────────────
+
+    @Test
+    void applyOrderState_zeroToOne_doesNotPublish() {
+        UnifiedOrderEntity order = existing("0");
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        assertTrue(service.applyOrderState(order, "1", null, null, null));
+
+        assertEquals("1", order.getTradeStatus());
+        assertNotNull(order.getNextQueryTime(), "非终态要安排下一次查询");
+        assertNull(order.getPayTime());
+        verifyNoInteractions(publisher, callbackService);
+
+        ArgumentCaptor<LambdaUpdateWrapper<UnifiedOrderEntity>> w = updateCaptor();
+        verify(orderMapper).update(isNull(), w.capture());
+        String where = w.getValue().getSqlSegment();
+        assertTrue(where.contains("order_no"), "条件更新必须按 order_no: " + where);
+        assertTrue(where.contains("trade_status IN"), "条件更新必须带前态集合: " + where);
+        String set = w.getValue().getSqlSet();
+        for (String column : List.of("trade_status", "err_code", "err_msg",
+                "last_query_time", "updated_time", "next_query_time")) {
+            assertTrue(set.contains(column), "SET 缺列 " + column + ",实测: " + set);
+        }
+        assertFalse(set.contains("pay_time"), "没有 successTime 不该写 pay_time: " + set);
+    }
+
+    @Test
+    void applyOrderState_toSuccess_publishesAndNotifies() {
+        UnifiedOrderEntity order = existing("0");
+        JsonNode upstream = json("{\"successTime\":1760000000000,\"payOrderId\":\"P1\"}");
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        assertTrue(service.applyOrderState(order, "2", upstream, null, null));
+
+        assertEquals("2", order.getTradeStatus());
+        assertNotNull(order.getPayTime());
+        assertNull(order.getNextQueryTime(), "终态不再轮询");
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_ORDER, TENANT);
+        verify(callbackService).notifyOrder(same(order), same(upstream));
+    }
+
+    @Test
+    void applyOrderState_twoToFive_publishes() {
+        UnifiedOrderEntity order = existing("2");
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        assertTrue(service.applyOrderState(order, UnifiedOrderStatus.REFUNDED, null, null, null));
+
+        assertEquals("5", order.getTradeStatus());
+        assertNull(order.getNextQueryTime());
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_ORDER, TENANT);
+        verify(callbackService).notifyOrder(same(order), isNull());
+    }
+
+    @Test
+    void applyOrderState_terminalToSuccess_rejected() {
+        UnifiedOrderEntity order = existing("3");
+
+        assertFalse(service.applyOrderState(order, "2", null, null, null));
+
+        assertEquals("3", order.getTradeStatus(), "终态不能回退");
+        verify(orderMapper, never()).update(isNull(), any());
+        verifyNoInteractions(publisher, callbackService);
+    }
+
+    @Test
+    void applyOrderState_sameState_notChanged() {
+        UnifiedOrderEntity order = existing("2");
+
+        assertFalse(service.applyOrderState(order, "2", null, null, null));
+
+        verify(orderMapper, never()).update(isNull(), any());
+        verifyNoInteractions(publisher, callbackService);
+    }
+
+    @Test
+    void applyOrderState_invalidState_rejected() {
+        UnifiedOrderEntity order = existing("0");
+
+        assertFalse(service.applyOrderState(order, "99", null, null, null));
+
+        verify(orderMapper, never()).update(isNull(), any());
+        verifyNoInteractions(publisher, callbackService);
+    }
+
+    @Test
+    void applyOrderState_updateZeroRows_doesNotPublish() {
+        UnifiedOrderEntity order = existing("0");
+        when(orderMapper.update(isNull(), any())).thenReturn(0);
+
+        assertFalse(service.applyOrderState(order, "2", null, null, null));
+
+        assertEquals("0", order.getTradeStatus(), "条件更新落空时内存也不能动");
+        verifyNoInteractions(publisher, callbackService);
+    }
+
+    // ────────────────────────────── 查询与列表 ──────────────────────────────
+
+    @Test
+    void refundableAmount_usesOccupiedSum() {
+        UnifiedOrderEntity order = existing("2");
+        when(refundMapper.sumOccupiedAmount(5L)).thenReturn(30L);
+
+        assertEquals(70L, service.refundableAmount(order));
+    }
+
+    @Test
+    void list_mapsRowsToVoWithNullRefundable() {
+        UnifiedOrderEntity row = existing("2");
+        Page<UnifiedOrderEntity> page = new Page<>(2, 10);
+        page.setRecords(List.of(row));
+        page.setTotal(1);
+        doReturn(page).when(orderMapper).selectPage(any(), any());
+
+        UnifiedOrderQueryDTO q = new UnifiedOrderQueryDTO();
+        q.setPageNo(2);
+        q.setPageSize(10);
+        q.setOrderNo("UP5");
+        q.setOutTradeNo("T001");
+        q.setMchNo("M001");
+        q.setWayCode("QR_CASHIER");
+        q.setTradeStatus("2");
+        q.setStartDate("2026-10-01");
+        q.setEndDate("2026-10-20");
+
+        PageResult<UnifiedOrderVO> result = service.list(q);
+
+        assertEquals(2, result.getPageNo());
+        assertEquals(10, result.getPageSize());
+        assertEquals(1, result.getTotal());
+        assertEquals(1, result.getList().size());
+        UnifiedOrderVO vo = result.getList().get(0);
+        assertEquals(5L, vo.getId());
+        assertEquals(TENANT, vo.getTenantId());
+        assertEquals("UP5", vo.getOrderNo());
+        assertEquals("T001", vo.getOutTradeNo());
+        assertEquals("M001", vo.getMchNo());
+        assertEquals("QR_CASHIER", vo.getWayCode());
+        assertEquals(100L, vo.getAmount());
+        assertEquals("2", vo.getTradeStatus());
+        assertNull(vo.getRefundableAmount(), "列表不算可退金额(详情才算)");
+
+        ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> w = queryCaptor();
+        verify(orderMapper).selectPage(any(), w.capture());
+        String sql = w.getValue().getSqlSegment();
+        for (String fragment : List.of("order_no", "out_trade_no", "mch_no",
+                "way_code", "trade_status", "created_time", "ORDER BY created_time DESC")) {
+            assertTrue(sql.contains(fragment), "查询缺片段 " + fragment + ",实测: " + sql);
+        }
+    }
+
+    @Test
+    void findPendingForPoll_filtersAndClampsLimit() {
+        when(orderMapper.selectList(any())).thenReturn(List.of(existing("0")));
+
+        List<UnifiedOrderEntity> rows = service.findPendingForPoll(5000);
+
+        assertEquals(1, rows.size());
+        ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> w = queryCaptor();
+        verify(orderMapper).selectList(w.capture());
+        String sql = w.getValue().getSqlSegment();
+        for (String fragment : List.of("trade_status IN", "next_query_time <=", "created_time >",
+                "query_count <", "ORDER BY next_query_time ASC", "LIMIT 500")) {
+            assertTrue(sql.contains(fragment), "轮询查询缺片段 " + fragment + ",实测: " + sql);
+        }
+    }
+
+    @Test
+    void findForNotify_prefersUpstreamPayOrderId() {
+        UnifiedOrderEntity byUpstream = existing("2");
+        when(orderMapper.selectOne(any())).thenReturn(byUpstream);
+
+        assertSame(byUpstream, service.findForNotify("P1", "UP5"));
+
+        ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> w = queryCaptor();
+        verify(orderMapper).selectOne(w.capture());
+        assertTrue(w.getValue().getSqlSegment().contains("upstream_pay_order_id"),
+                "有上游单号时必须优先按它查: " + w.getValue().getSqlSegment());
+    }
+
+    @Test
+    void findForNotify_fallsBackToOrderNo() {
+        UnifiedOrderEntity byOrderNo = existing("2");
+        when(orderMapper.selectOne(any())).thenReturn(null, byOrderNo);
+
+        assertSame(byOrderNo, service.findForNotify("P1", "UP5"));
+
+        ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> w = queryCaptor();
+        verify(orderMapper, times(2)).selectOne(w.capture());
+        List<LambdaQueryWrapper<UnifiedOrderEntity>> wrappers = w.getAllValues();
+        assertTrue(wrappers.get(1).getSqlSegment().contains("order_no"),
+                "上游单号查不到时必须回退到 order_no: " + wrappers.get(1).getSqlSegment());
+    }
+}

+ 129 - 0
java/src/test/java/com/payment/platform/module/payment/unified/util/UnifiedUpstreamSnapshotTest.java

@@ -0,0 +1,129 @@
+package com.payment.platform.module.payment.unified.util;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.NullNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 上游报文脱敏快照(spec §7.5 / 裁定 R35)。
+ *
+ * <p>快照落库后会被排障页面直接展示,也会被回调重试路径读取;两类硬约束:
+ * <ul>
+ *   <li>只允许白名单字段 —— 上游通知混有我们上送的 {@code channelExtra}
+ *       (付款码 / openid / buyerUserId),整包落库等于把付款凭据写进数据库;</li>
+ *   <li>长度不超过 8KB —— 异常大报文不能把行撑爆。</li>
+ * </ul>
+ */
+class UnifiedUpstreamSnapshotTest {
+
+    private static final ObjectMapper JSON = new ObjectMapper();
+
+    /** spec §7.5 / R35 白名单,逐字。 */
+    private static final List<String> WHITELIST = List.of(
+            "payOrderId", "mchOrderNo", "state", "orderState", "amount", "currency",
+            "wayCode", "ifCode", "channelOrderNo", "successTime", "createdAt",
+            "refundOrderId", "mchRefundNo", "refundAmount", "payAmount",
+            "extParam", "errCode", "errMsg");
+
+    private static final List<String> FORBIDDEN = List.of(
+            "channelExtra", "openid", "buyerUserId", "authCode", "channelUser", "sign");
+
+    @Test
+    void whitelistFieldsKept_sensitiveFieldsNeverAppear() {
+        ObjectNode data = JSON.createObjectNode();
+        for (String field : WHITELIST) {
+            data.put(field, "v-" + field);
+        }
+        data.putObject("channelExtra").put("auth_code", "CODE-SECRET");
+        data.put("openid", "OPENID-SECRET");
+        data.put("buyerUserId", "BUYER-SECRET");
+        data.put("authCode", "AUTH-SECRET");
+        data.put("channelUser", "CHANNEL-USER-SECRET");
+        data.put("sign", "SIGN-SECRET");
+
+        JsonNode snapshot = UnifiedUpstreamSnapshot.of(data);
+
+        for (String field : WHITELIST) {
+            assertTrue(snapshot.has(field), "白名单字段丢了: " + field);
+            assertEquals("v-" + field, snapshot.get(field).asText(), "白名单字段值变了: " + field);
+        }
+        for (String field : FORBIDDEN) {
+            assertFalse(snapshot.has(field), "敏感字段进了快照: " + field);
+        }
+
+        String json = UnifiedUpstreamSnapshot.toJson(snapshot);
+        for (String secret : List.of("CODE-SECRET", "OPENID-SECRET", "BUYER-SECRET",
+                "AUTH-SECRET", "CHANNEL-USER-SECRET", "SIGN-SECRET")) {
+            assertFalse(json.contains(secret), "敏感值进了快照: " + secret);
+        }
+        // 正对照:白名单字段确实写出来了,上面的「不含」不是空字符串自证
+        assertTrue(json.contains("\"payOrderId\":\"v-payOrderId\""), "实测快照: " + json);
+    }
+
+    @Test
+    void oversizedValue_isTruncatedTo8KAndStillJson() throws Exception {
+        ObjectNode data = JSON.createObjectNode();
+        data.put("payOrderId", "P1");
+        data.put("extParam", "x".repeat(20_000));
+
+        String json = UnifiedUpstreamSnapshot.toJson(UnifiedUpstreamSnapshot.of(data));
+
+        assertTrue(json.length() <= 8192, "快照未截断,长度=" + json.length());
+        int bytes = json.getBytes(StandardCharsets.UTF_8).length;
+        assertTrue(bytes <= 8192, "按 UTF-8 字节计也超过 8KB,bytes=" + bytes);
+        assertEquals("P1", JSON.readTree(json).get("payOrderId").asText(),
+                "截断后必须仍是可解析的 JSON(回调重试要读它)");
+    }
+
+    @Test
+    void emptyOrNullBecomesEmptyObjectJson() {
+        assertEquals("{}", UnifiedUpstreamSnapshot.toJson(UnifiedUpstreamSnapshot.of(null)));
+        assertEquals("{}", UnifiedUpstreamSnapshot.toJson(
+                UnifiedUpstreamSnapshot.of(NullNode.getInstance())));
+        assertEquals("{}", UnifiedUpstreamSnapshot.toJson(
+                UnifiedUpstreamSnapshot.of(JSON.createObjectNode())));
+        assertEquals("{}", UnifiedUpstreamSnapshot.toJson(null));
+        assertEquals("{}", UnifiedUpstreamSnapshot.toJson(JSON.createArrayNode()));
+        assertEquals(0, UnifiedUpstreamSnapshot.of(null).size());
+    }
+
+    @Test
+    void ofParams_takesWhitelistFromFormMapOnly() {
+        Map<String, String> params = new LinkedHashMap<>();
+        params.put("payOrderId", "P1");
+        params.put("amount", "100");
+        params.put("state", "2");
+        params.put("channelExtra", "{\"auth_code\":\"CODE-SECRET\"}");
+        params.put("openid", "OPENID-SECRET");
+        params.put("buyerUserId", "BUYER-SECRET");
+        params.put("authCode", "AUTH-SECRET");
+        params.put("channelUser", "CHANNEL-USER-SECRET");
+        params.put("sign", "SIGN-SECRET");
+        params.put("notifyUrl", "https://qcsj88888.com/notify");
+
+        JsonNode snapshot = UnifiedUpstreamSnapshot.ofParams(params);
+
+        assertEquals(3, snapshot.size(), "只应保留三个白名单字段,实测: " + snapshot);
+        assertEquals("P1", snapshot.get("payOrderId").asText());
+        assertEquals("100", snapshot.get("amount").asText());
+        assertEquals("2", snapshot.get("state").asText());
+        assertFalse(snapshot.has("notifyUrl"), "上游通知里的 notifyUrl 不落库(spec §7.5)");
+
+        String json = UnifiedUpstreamSnapshot.toJson(snapshot);
+        for (String secret : List.of("CODE-SECRET", "OPENID-SECRET", "BUYER-SECRET",
+                "AUTH-SECRET", "CHANNEL-USER-SECRET", "SIGN-SECRET")) {
+            assertFalse(json.contains(secret), "敏感值进了快照: " + secret);
+        }
+    }
+}