Просмотр исходного кода

feat(统一支付): 退款核心与四腿状态收敛

alphaH 1 неделя назад
Родитель
Сommit
ad5f1c5590

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

@@ -0,0 +1,47 @@
+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;
+
+/**
+ * 统一退款入参(开放 API 与后台管理端共用)。
+ *
+ * <p>{@code outRefundNo} 是幂等键:同一租户下同号同参数返回已有退款单,参数不同 409。
+ * 定位订单二选一:优先 {@code orderNo}(我方订单号),否则 {@code outTradeNo}(调用方单号)。
+ */
+@Data
+public class UnifiedRefundCreateCommand {
+
+    /** 上游商户号;给了就要与订单快照一致(防跨商户误退)。 */
+    private String mchNo;
+
+    /** 我方订单号;与 {@code outTradeNo} 至少填一个。 */
+    private String orderNo;
+
+    /** 调用方下单时的商户订单号。 */
+    private String outTradeNo;
+
+    /** 退款金额,分。 */
+    @NotNull(message = "退款金额不能为空")
+    @Positive(message = "退款金额必须大于 0")
+    private Long refundAmount;
+
+    /** 退款原因;上游要求非空。 */
+    @NotBlank(message = "退款原因不能为空")
+    @Size(max = 64, message = "退款原因不能超过 64 字符")
+    private String refundReason;
+
+    /** 调用方退款单号;幂等键,必填。 */
+    @NotBlank(message = "outRefundNo 不能为空")
+    @Size(max = 64, message = "outRefundNo 不能超过 64 字符")
+    private String outRefundNo;
+
+    /** 本笔退款的下游回调地址快照;空则走默认取址链。 */
+    private String notifyUrl;
+
+    /** 透传参数。 */
+    private String extParam;
+}

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

@@ -0,0 +1,78 @@
+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.UnifiedRefundEntity;
+import lombok.Data;
+
+import java.time.OffsetDateTime;
+
+/**
+ * 退款单 VO(后台详情与开放 API 共用)。
+ *
+ * <p>{@code id} 是 19 位雪花号,序列化成字符串防 JS 精度丢失;开放 API 的
+ * {@link #open} 不暴露内部主键。
+ */
+@Data
+public class UnifiedRefundVO {
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    private Long id;
+
+    private String refundNo;
+    private String outRefundNo;
+    private String orderNo;
+    private String mchNo;
+    private Long amount;
+    private String state;
+    private String upstreamRefundOrderId;
+    private String upstreamChannelOrderNo;
+    private String errCode;
+    private String errMsg;
+    private OffsetDateTime successTime;
+    private OffsetDateTime createdTime;
+    private OffsetDateTime updatedTime;
+    private String notifyStatus;
+    private OffsetDateTime nextQueryTime;
+
+    /** 后台视图:含内部主键。 */
+    public static UnifiedRefundVO of(UnifiedRefundEntity e) {
+        if (e == null) {
+            return null;
+        }
+        UnifiedRefundVO vo = new UnifiedRefundVO();
+        vo.setId(e.getId());
+        fill(vo, e);
+        return vo;
+    }
+
+    /** 开放 API 视图:不暴露内部主键。 */
+    public static UnifiedRefundVO open(UnifiedRefundEntity e) {
+        if (e == null) {
+            return null;
+        }
+        UnifiedRefundVO vo = new UnifiedRefundVO();
+        fill(vo, e);
+        return vo;
+    }
+
+    private static void fill(UnifiedRefundVO vo, UnifiedRefundEntity e) {
+        vo.setRefundNo(e.getRefundNo());
+        vo.setOutRefundNo(e.getOutRefundNo());
+        vo.setOrderNo(e.getOrderNo());
+        vo.setMchNo(e.getMchNo());
+        vo.setAmount(e.getAmount());
+        vo.setState(e.getState());
+        vo.setUpstreamRefundOrderId(e.getUpstreamRefundOrderId());
+        vo.setUpstreamChannelOrderNo(e.getUpstreamChannelOrderNo());
+        vo.setErrCode(e.getErrCode());
+        vo.setErrMsg(e.getErrMsg());
+        vo.setSuccessTime(e.getSuccessTime());
+        vo.setCreatedTime(e.getCreatedTime());
+        vo.setUpdatedTime(e.getUpdatedTime());
+        vo.setNotifyStatus(e.getNotifyStatus());
+        vo.setNextQueryTime(e.getNextQueryTime());
+    }
+}

+ 444 - 0
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedRefundService.java

@@ -0,0 +1,444 @@
+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.fasterxml.jackson.databind.JsonNode;
+import com.payment.platform.common.exception.BusinessException;
+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.UnifiedResponseParser;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedRefundCreateCommand;
+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.entity.UnifiedRefundEntity;
+import com.payment.platform.module.payment.unified.enums.UnifiedRefundStatus;
+import com.payment.platform.module.payment.unified.enums.UnifiedSource;
+import com.payment.platform.module.payment.unified.mapper.UnifiedRefundMapper;
+import com.payment.platform.module.payment.unified.util.UnifiedPollPolicy;
+import com.payment.platform.module.payment.unified.util.UnifiedUpstreamSnapshot;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 统一支付退款:申请、四腿收敛与事件/回调编排(spec §7.4 / §7.6)。
+ *
+ * <p>顺序与口径:
+ *
+ * <ol>
+ *   <li><b>先落库再调上游</b>:上游通知可能比 HTTP 响应先到,通知必须能在库里找到这笔退款。</li>
+ *   <li><b>上游明确拒绝立即落 3</b>:额度当场释放,等查询只会把额度白锁 30 分钟;
+ *       传输/验签失败留在 0 并排 15 秒后查询 —— 那笔可能已经成功。</li>
+ *   <li><b>事件只在事务 B 真的写了行之后发</b>({@link UnifiedRefundTxService.ApplyOutcome});
+ *       第二参是租户号(R23)。</li>
+ *   <li><b>一次 apply 至多一个 ORDER 事件</b>(R25②):0/1→2→5 只推最终快照 5。</li>
+ * </ol>
+ *
+ * <p>{@code channelExtra} 与本类无关:退款报文不含它,快照只走白名单。{@code app_secret}
+ * 只存在于 {@link UnifiedMerchantEntity} 内,永不返回、永不日志。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class UnifiedRefundService {
+
+    private static final String REFUND_NO_PREFIX = "UR";
+
+    /** 轮询批量上限(防配置/入参把 SQL 撑成全表扫描)。 */
+    private static final int POLL_LIMIT_MAX = 500;
+
+    private final UnifiedRefundTxService txService;
+    private final UnifiedRefundMapper refundMapper;
+    private final UnifiedOrderService orderService;
+    private final UnifiedMerchantService merchantService;
+    private final UnifiedClient client;
+    private final UnifiedCallbackService callbackService;
+    private final OrderEventPublisher publisher;
+
+    // ────────────────────────────── 申请 ──────────────────────────────
+
+    /**
+     * 发起退款。
+     *
+     * @param cmd      入参;{@code outRefundNo} 必填并作为幂等键
+     * @param tenantId 认证上下文给出的租户;超管传 null(按订单归属落库)
+     * @param apiKey   开放 API 调用方 Key(后台为 null)
+     * @param source   OPENAPI / ADMIN
+     * @throws BusinessException 400 参数/来源/额度闸门;404 订单不存在;409 幂等冲突;
+     *                           500 上游业务拒绝或传输失败(本地退款单保留)
+     */
+    public UnifiedRefundEntity createRefund(UnifiedRefundCreateCommand cmd, Long tenantId,
+                                            String apiKey, UnifiedSource source) {
+        validateCreate(cmd, source);
+
+        UnifiedOrderEntity order = StrUtil.isNotBlank(cmd.getOrderNo())
+                ? orderService.findByOrderNo(tenantId, cmd.getOrderNo())
+                : orderService.findByOutTradeNo(tenantId, cmd.getOutTradeNo());
+        if (order == null) {
+            throw new BusinessException(404, "订单不存在");
+        }
+        if (StrUtil.isNotBlank(cmd.getMchNo()) && !cmd.getMchNo().equals(order.getMchNo())) {
+            // 不区分「不存在」与「不属于当前商户」,避免成为跨商户探针
+            throw new BusinessException(404, "订单不存在");
+        }
+
+        // 幂等键 (tenant_id, out_refund_no);超管也必须按订单归属查
+        Long effectiveTenant = order.getTenantId();
+        UnifiedRefundEntity existing = findByOutRefundNo(effectiveTenant, cmd.getOutRefundNo());
+        if (existing != null) {
+            return sameOrConflict(existing, order, cmd);
+        }
+
+        UnifiedMerchantEntity cfg = merchantService.requireById(order.getMerchantId());
+        merchantService.requireEnabled(cfg);
+
+        String refundNo = REFUND_NO_PREFIX + SnowflakeIdGenerator.nextIdStr();
+        UnifiedRefundEntity refund;
+        try {
+            refund = txService.reserve(order.getId(), tenantId, cmd.getRefundAmount(),
+                    cmd.getRefundReason(), refundNo, cmd.getOutRefundNo(), cmd.getNotifyUrl(),
+                    cmd.getExtParam(), apiKey, source);
+        } catch (DuplicateKeyException e) {
+            // 并发重试:另一请求已插入同一 (tenant_id, out_refund_no)
+            UnifiedRefundEntity raced = findByOutRefundNo(effectiveTenant, cmd.getOutRefundNo());
+            if (raced == null) {
+                throw e;
+            }
+            return sameOrConflict(raced, order, cmd);
+        }
+
+        JsonNode data;
+        try {
+            data = client.refundOrder(cfg, order.getOrderNo(), refundNo,
+                    cmd.getRefundAmount(), cmd.getRefundReason());
+        } catch (UnifiedApiException e) {
+            if (e.isBusinessRejection()) {
+                // 上游明确拒绝:立即落失败终态,释放占用额度
+                applyRefundState(refundNo, UnifiedRefundStatus.FAILED, null, null,
+                        String.valueOf(e.getCode()), e.getMessage());
+                throw new BusinessException(500, e.getMessage());
+            }
+            // 超时/网络/验签:绝不删单,那笔可能已经成功
+            scheduleNextQuery(refund, OffsetDateTime.now()
+                    .plusSeconds(UnifiedPollPolicy.CREATE_FAILURE_RETRY_SECONDS));
+            throw new BusinessException(500,
+                    "本地退款单已保留,可能仍会成功,请用 out_refund_no 查询: " + e.getMessage());
+        }
+
+        JsonNode upstream = UnifiedUpstreamSnapshot.of(data);
+        backfillFromResponse(refund, data, upstream);
+        recordQueryAttempt(refund);
+
+        String state = UnifiedResponseParser.pickState(data);
+        if (UnifiedRefundStatus.canTransition(refund.getState(), state)) {
+            applyRefundState(refundNo, state, upstream, payAmountOf(data),
+                    textOf(data, "errCode"), textOf(data, "errMsg"));
+        }
+        // 状态可能已由 applyRefundState 写在另一实例(事务层重新查出的行)上:
+        // 必须先回读,再按库内状态兜底排期,否则终态单会被重新排进轮询。
+        UnifiedRefundEntity current = reload(refund);
+        ensurePollScheduled(current);
+        return current;
+    }
+
+    private static void validateCreate(UnifiedRefundCreateCommand cmd, UnifiedSource source) {
+        if (cmd == null) {
+            throw new BusinessException(400, "请求不能为空");
+        }
+        if (source != UnifiedSource.OPENAPI && source != UnifiedSource.ADMIN) {
+            throw new BusinessException(400, "不支持的退款来源: " + source);
+        }
+        if (cmd.getRefundAmount() == null || cmd.getRefundAmount() <= 0) {
+            throw new BusinessException(400, "退款金额必须大于 0");
+        }
+        if (StrUtil.isBlank(cmd.getRefundReason())) {
+            throw new BusinessException(400, "退款原因不能为空");
+        }
+        if (StrUtil.isBlank(cmd.getOutRefundNo())) {
+            throw new BusinessException(400, "out_refund_no 不能为空");
+        }
+        if (StrUtil.isBlank(cmd.getOrderNo()) && StrUtil.isBlank(cmd.getOutTradeNo())) {
+            throw new BusinessException(400, "orderNo 与 outTradeNo 至少填一个");
+        }
+    }
+
+    private static boolean sameCreateParams(UnifiedRefundEntity existing, UnifiedOrderEntity order,
+                                            UnifiedRefundCreateCommand cmd) {
+        return Objects.equals(existing.getOrderId(), order.getId())
+                && Objects.equals(existing.getAmount(), cmd.getRefundAmount());
+    }
+
+    private static UnifiedRefundEntity sameOrConflict(UnifiedRefundEntity existing,
+                                                      UnifiedOrderEntity order,
+                                                      UnifiedRefundCreateCommand cmd) {
+        if (sameCreateParams(existing, order, cmd)) {
+            return existing;
+        }
+        throw new BusinessException(409, "out_refund_no 已存在且参数不一致");
+    }
+
+    /** 回填上游退款单号/渠道单号/快照;只写这些列,不碰业务状态。 */
+    private void backfillFromResponse(UnifiedRefundEntity refund, JsonNode data, JsonNode upstream) {
+        String upstreamRefundOrderId = textOf(data, "refundOrderId");
+        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");
+
+        refundMapper.update(null, new LambdaUpdateWrapper<UnifiedRefundEntity>()
+                .eq(UnifiedRefundEntity::getRefundNo, refund.getRefundNo())
+                .set(UnifiedRefundEntity::getUpstreamRefundOrderId, upstreamRefundOrderId)
+                .set(UnifiedRefundEntity::getUpstreamChannelOrderNo, upstreamChannelOrderNo)
+                .set(UnifiedRefundEntity::getUpstreamCreatedAt, upstreamCreatedAt)
+                .set(UnifiedRefundEntity::getUpstreamDataJson, upstreamDataJson)
+                .set(UnifiedRefundEntity::getErrCode, errCode)
+                .set(UnifiedRefundEntity::getErrMsg, errMsg));
+
+        refund.setUpstreamRefundOrderId(upstreamRefundOrderId);
+        refund.setUpstreamChannelOrderNo(upstreamChannelOrderNo);
+        refund.setUpstreamCreatedAt(upstreamCreatedAt);
+        refund.setUpstreamDataJson(upstreamDataJson);
+        refund.setErrCode(errCode);
+        refund.setErrMsg(errMsg);
+    }
+
+    private UnifiedRefundEntity reload(UnifiedRefundEntity fallback) {
+        UnifiedRefundEntity reloaded = refundMapper.selectOne(
+                new LambdaQueryWrapper<UnifiedRefundEntity>()
+                        .eq(UnifiedRefundEntity::getRefundNo, fallback.getRefundNo()));
+        return reloaded != null ? reloaded : fallback;
+    }
+
+    // ────────────────────────────── 四腿收敛入口 ──────────────────────────────
+
+    /**
+     * 退款状态收敛的唯一入口(创建响应 / 上游通知 / 退款查询 / 后台同步共用)。
+     *
+     * <p>只有事务 B 报告 {@code refundChanged} 才可能产生副作用;退款终态(2/3/4)
+     * 发 REFUND 事件并推退款回调。订单侧 {@code orderPaidChanged || orderRefundedChanged}
+     * 时**只发一次** ORDER 事件/回调(R25②)。
+     */
+    public void applyRefundState(String refundNo, String nextState, JsonNode upstream,
+                                 Long payAmount, String errCode, String errMsg) {
+        UnifiedRefundTxService.ApplyOutcome outcome =
+                txService.apply(refundNo, nextState, upstream, payAmount, errCode, errMsg);
+        if (!outcome.refundChanged()) {
+            return;
+        }
+
+        UnifiedRefundEntity refund = outcome.refund();
+        UnifiedOrderEntity order = outcome.order();
+        if (UnifiedRefundStatus.FINAL.contains(nextState)) {
+            // R23:租户号,不是自增主键
+            publisher.changed(OrderEventPublisher.CHANNEL_UNIFIED_REFUND, refund.getTenantId());
+            callbackService.notifyRefund(refund, order, upstream);
+        }
+        if ((outcome.orderPaidChanged() || outcome.orderRefundedChanged()) && order != null) {
+            publisher.changed(OrderEventPublisher.CHANNEL_UNIFIED_ORDER, order.getTenantId());
+            callbackService.notifyOrder(order, upstream);
+        }
+    }
+
+    // ────────────────────────────── 查询 ──────────────────────────────
+
+    /** 后台详情按主键;租户由拦截器钉住(超管跨租户)。 */
+    public UnifiedRefundEntity requireById(Long id) {
+        if (id == null) {
+            throw new BusinessException(400, "退款单 id 不能为空");
+        }
+        UnifiedRefundEntity row = refundMapper.selectById(id);
+        if (row == null) {
+            throw new BusinessException(404, "退款单不存在");
+        }
+        return row;
+    }
+
+    /**
+     * 租户内按我方退款号查;{@code tenantId == null}(超管/内部)按全局唯一号查。
+     * 找不到返回 null。
+     */
+    public UnifiedRefundEntity findByRefundNo(Long tenantId, String refundNo) {
+        if (StrUtil.isBlank(refundNo)) {
+            return null;
+        }
+        LambdaQueryWrapper<UnifiedRefundEntity> w = new LambdaQueryWrapper<UnifiedRefundEntity>()
+                .eq(UnifiedRefundEntity::getRefundNo, refundNo);
+        if (tenantId != null) {
+            w.eq(UnifiedRefundEntity::getTenantId, tenantId);
+        }
+        return refundMapper.selectOne(w);
+    }
+
+    /** 无认证路径按全局唯一 {@code refund_no} 点查。 */
+    public UnifiedRefundEntity findByRefundNoGlobal(String refundNo) {
+        if (StrUtil.isBlank(refundNo)) {
+            return null;
+        }
+        return refundMapper.selectOne(new LambdaQueryWrapper<UnifiedRefundEntity>()
+                .eq(UnifiedRefundEntity::getRefundNo, refundNo));
+    }
+
+    /**
+     * 租户内按调用方退款号查(唯一键是 {@code (tenant_id, out_refund_no)})。
+     *
+     * <p>{@code tenantId == null} 时返回 null:无租户条件可能匹配多行,
+     * {@code selectOne} 会直接抛异常。
+     */
+    public UnifiedRefundEntity findByOutRefundNo(Long tenantId, String outRefundNo) {
+        if (tenantId == null || StrUtil.isBlank(outRefundNo)) {
+            return null;
+        }
+        return refundMapper.selectOne(new LambdaQueryWrapper<UnifiedRefundEntity>()
+                .eq(UnifiedRefundEntity::getTenantId, tenantId)
+                .eq(UnifiedRefundEntity::getOutRefundNo, outRefundNo));
+    }
+
+    /**
+     * 上游退款通知找单:优先 {@code refundOrderId}(上游退款单号),查不到回退我方
+     * {@code mchRefundNo}(即 {@code refund_no})。
+     */
+    public UnifiedRefundEntity findForNotify(String upstreamRefundOrderId, String refundNo) {
+        if (StrUtil.isNotBlank(upstreamRefundOrderId)) {
+            UnifiedRefundEntity row = refundMapper.selectOne(
+                    new LambdaQueryWrapper<UnifiedRefundEntity>()
+                            .eq(UnifiedRefundEntity::getUpstreamRefundOrderId,
+                                    upstreamRefundOrderId));
+            if (row != null) {
+                return row;
+            }
+        }
+        if (StrUtil.isNotBlank(refundNo)) {
+            return refundMapper.selectOne(new LambdaQueryWrapper<UnifiedRefundEntity>()
+                    .eq(UnifiedRefundEntity::getRefundNo, refundNo));
+        }
+        return null;
+    }
+
+    /** 按订单号列退款(后台详情用),按主键升序保证读数稳定。 */
+    public List<UnifiedRefundEntity> findByOrderNo(String orderNo) {
+        if (StrUtil.isBlank(orderNo)) {
+            return List.of();
+        }
+        return refundMapper.selectList(new LambdaQueryWrapper<UnifiedRefundEntity>()
+                .eq(UnifiedRefundEntity::getOrderNo, orderNo)
+                .orderByAsc(UnifiedRefundEntity::getId));
+    }
+
+    /**
+     * 退款轮询扫描(Task 13 调度器消费):非终态、到期、未超 30 分钟、未满 60 次,
+     * 按到期时间升序,最多 {@code limit} 条。
+     *
+     * <p>{@code LIMIT} 用拼接而非参数:Lambda wrapper 没有 limit 参数位;
+     * 这里先夹到 [1, 500] 的整型,不存在注入面。
+     */
+    public List<UnifiedRefundEntity> findPendingForPoll(int limit) {
+        int size = Math.max(1, Math.min(limit, POLL_LIMIT_MAX));
+        OffsetDateTime now = OffsetDateTime.now();
+        return refundMapper.selectList(new LambdaQueryWrapper<UnifiedRefundEntity>()
+                .in(UnifiedRefundEntity::getState, UnifiedRefundStatus.PENDING)
+                .le(UnifiedRefundEntity::getNextQueryTime, now)
+                .gt(UnifiedRefundEntity::getCreatedTime,
+                        now.minusMinutes(UnifiedPollPolicy.MAX_AGE_MINUTES))
+                .lt(UnifiedRefundEntity::getQueryCount, UnifiedPollPolicy.MAX_QUERY_COUNT)
+                .orderByAsc(UnifiedRefundEntity::getNextQueryTime)
+                .last("LIMIT " + size));
+    }
+
+    /**
+     * 退款查单并收敛状态(Task 13 实现)。
+     *
+     * <p>Task 12 只声明契约:查单腿依赖的任务(调度器、通知解析)尚未合入,先显式失败,
+     * 避免「调用了但什么都没做」这种更难发现的问题。
+     */
+    public void syncFromUpstream(UnifiedRefundEntity refund) {
+        throw new UnsupportedOperationException("统一支付退款查单由 Task 13 实现");
+    }
+
+    /** 查询计数 +1 并记时间;内存同步。 */
+    public void recordQueryAttempt(UnifiedRefundEntity refund) {
+        if (refund == null || refund.getRefundNo() == null) {
+            return;
+        }
+        OffsetDateTime now = OffsetDateTime.now();
+        refundMapper.update(null, new LambdaUpdateWrapper<UnifiedRefundEntity>()
+                .eq(UnifiedRefundEntity::getRefundNo, refund.getRefundNo())
+                .setSql("query_count = query_count + 1")
+                .set(UnifiedRefundEntity::getLastQueryTime, now));
+
+        refund.setQueryCount(refund.getQueryCount() == null ? 1 : refund.getQueryCount() + 1);
+        refund.setLastQueryTime(now);
+    }
+
+    /** 安排下一次查询(创建失败重试 / 无状态变化的退避)。 */
+    public void scheduleNextQuery(UnifiedRefundEntity refund, OffsetDateTime next) {
+        if (refund == null || refund.getRefundNo() == null) {
+            return;
+        }
+        refundMapper.update(null, new LambdaUpdateWrapper<UnifiedRefundEntity>()
+                .eq(UnifiedRefundEntity::getRefundNo, refund.getRefundNo())
+                .set(UnifiedRefundEntity::getNextQueryTime, next));
+        refund.setNextQueryTime(next);
+    }
+
+    /** 仍为非终态且尚未排期的退款,按当前查询次数兜底安排下一次。 */
+    private void ensurePollScheduled(UnifiedRefundEntity refund) {
+        if (refund == null
+                || !UnifiedRefundStatus.PENDING.contains(refund.getState())
+                || refund.getNextQueryTime() != null) {
+            return;
+        }
+        int queryCount = refund.getQueryCount() == null ? 0 : refund.getQueryCount();
+        scheduleNextQuery(refund, nextQueryTimeWithinWindow(
+                refund, OffsetDateTime.now(), queryCount));
+    }
+
+    /**
+     * 下一次自动查询时间(退避 + 30 分钟停止边界),与事务层同口径。
+     */
+    private static OffsetDateTime nextQueryTimeWithinWindow(UnifiedRefundEntity refund,
+                                                            OffsetDateTime now, int queryCount) {
+        OffsetDateTime next = now.plusSeconds(
+                UnifiedPollPolicy.nextDelaySeconds(queryCount - 1));
+        OffsetDateTime createdAt = refund.getCreatedTime();
+        if (createdAt != null
+                && !next.isBefore(createdAt.plusMinutes(UnifiedPollPolicy.MAX_AGE_MINUTES))) {
+            return null;
+        }
+        return next;
+    }
+
+    /** 上游 {@code payAmount}:只有数字才认,缺失/非法一律 null(未知不等于相等)。 */
+    private static Long payAmountOf(JsonNode data) {
+        if (data == null || !data.isObject()) {
+            return null;
+        }
+        JsonNode node = data.get("payAmount");
+        return node != null && node.isNumber() ? node.asLong() : null;
+    }
+
+    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;
+    }
+}

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

@@ -0,0 +1,357 @@
+package com.payment.platform.module.payment.unified.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.module.payment.unified.client.UnifiedResponseParser;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedRefundEntity;
+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.UnifiedRefundStatus;
+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.UnifiedPollPolicy;
+import com.payment.platform.module.payment.unified.util.UnifiedUpstreamSnapshot;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.OffsetDateTime;
+import java.util.Objects;
+
+/**
+ * 退款的两个事务边界(spec §7.4)。
+ *
+ * <p><b>事务 A {@link #reserve}</b>:锁订单行 → 校验支付状态与可退额度 → 插入退款单。
+ * 锁的意义是把「读占用额度 + 插入新退款」变成一个串行区间;否则两笔并发退款都读到
+ * 旧额度,合计退超。占用的口径是 {@link UnifiedRefundStatus#OCCUPIED}(含在途与成功),
+ * 因此这里**不再**减 {@code refunded_amount}(R21:那会重复扣减)。
+ *
+ * <p><b>事务 B {@link #apply}</b>:只有退款行条件 UPDATE 真的写了行才算变化;订单侧
+ * 同样只走条件 UPDATE。三条刻意的规则:
+ *
+ * <ol>
+ *   <li><b>订单 3/4/6/5 早退</b>(R3):终止态订单不写 {@code refunded_amount}、
+ *       不发 ORDER 事件 —— 退款行自己照常收敛并推 REFUND。</li>
+ *   <li><b>订单 0/1 且支付金额未知</b>(R25①):{@code payAmount} 缺失或与订单金额不等时
+ *       只记 ERROR,不把订单标成已支付;未知不等于相等。</li>
+ *   <li><b>一次 apply 至多一个 ORDER 事件</b>(R25②):0/1→2→5 的全额退款在同一个事务里
+ *       完成后只带最终快照(5)返回一次,绝不先发 2 再发 5。</li>
+ * </ol>
+ *
+ * <p>本类不发事件、不推回调 —— 编排在 {@link UnifiedRefundService#applyRefundState},
+ * 只有事务提交后的可见写入才会走到那里。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class UnifiedRefundTxService {
+
+    private static final String CURRENCY_CNY = "cny";
+
+    private final UnifiedOrderMapper orderMapper;
+    private final UnifiedRefundMapper refundMapper;
+
+    /**
+     * 事务结果。{@code refundChanged} 为 false 时后续事件/回调一律不得发生;
+     * 两个 order 标志同真时调用方也只允许发**一次** ORDER(R25②)。
+     */
+    public record ApplyOutcome(boolean refundChanged, boolean orderPaidChanged,
+                               boolean orderRefundedChanged, UnifiedRefundEntity refund,
+                               UnifiedOrderEntity order) {
+
+        static ApplyOutcome none(UnifiedRefundEntity refund, UnifiedOrderEntity order) {
+            return new ApplyOutcome(false, false, false, refund, order);
+        }
+    }
+
+    /**
+     * 事务 A:锁单、校验可退额度、落退款单(state=0)。
+     *
+     * <p>{@code tenantId} 只用于锁单时的租户条件(超管传 null 表示全局视角);
+     * 退款行的归属**一律取订单行**(R13),不取调用方身份。
+     *
+     * @throws BusinessException 400 未支付/已无额度/超额;404 订单不存在
+     */
+    @Transactional
+    public UnifiedRefundEntity reserve(Long orderId, Long tenantId, long amount,
+                                       String refundReason, String refundNo, String outRefundNo,
+                                       String callbackUrl, String extParam, String apiKey,
+                                       UnifiedSource source) {
+        if (orderId == null) {
+            throw new BusinessException(400, "订单 id 不能为空");
+        }
+        UnifiedOrderEntity order = orderMapper.selectForUpdate(orderId, tenantId);
+        if (order == null) {
+            throw new BusinessException(404, "订单不存在");
+        }
+
+        String tradeStatus = order.getTradeStatus();
+        // 已退款终态:额度口径与「没有可退金额」一致;其余非成功态一律按未支付拒绝
+        if (UnifiedOrderStatus.REFUNDED.equals(tradeStatus)) {
+            throw new BusinessException(400, "已无可退金额");
+        }
+        if (!UnifiedOrderStatus.SUCCESS.equals(tradeStatus)) {
+            throw new BusinessException(400, "订单未支付成功,不能退款");
+        }
+
+        long paid = order.getAmount() == null ? 0L : order.getAmount();
+        // R21:占用口径已含 state=2,不再减 refunded_amount
+        long occupied = refundMapper.sumOccupiedAmount(order.getId());
+        long available = paid - occupied;
+        if (available <= 0) {
+            throw new BusinessException(400, "已无可退金额");
+        }
+        if (amount <= 0) {
+            throw new BusinessException(400, "退款金额必须大于 0");
+        }
+        if (amount > available) {
+            throw new BusinessException(400, "退款金额超过可退金额,当前可退 " + available + " 分");
+        }
+
+        UnifiedRefundEntity entity = new UnifiedRefundEntity();
+        entity.setTenantId(order.getTenantId());
+        entity.setOrderId(order.getId());
+        entity.setOrderNo(order.getOrderNo());
+        entity.setRefundNo(refundNo);
+        entity.setOutRefundNo(outRefundNo);
+        entity.setMchNo(order.getMchNo());
+        entity.setAppId(order.getAppId());
+        entity.setApiKey(apiKey != null ? apiKey : order.getApiKey());
+        entity.setSource(source == null ? null : source.name());
+        entity.setAmount(amount);
+        entity.setCurrency(CURRENCY_CNY);
+        entity.setRefundReason(refundReason);
+        entity.setCallbackUrl(callbackUrl);
+        entity.setExtParam(extParam);
+        entity.setState(UnifiedRefundStatus.GENERATED);
+        entity.setNotifyStatus(UnifiedNotifyStatus.PENDING);
+        entity.setQueryCount(0);
+        refundMapper.insert(entity);
+        return entity;
+    }
+
+    /**
+     * 事务 B:条件推进退款状态,并在退款成功时联动订单。
+     *
+     * <p>非法/同态/条件 UPDATE 落空都返回 {@code refundChanged=false},调用方据此
+     * 不发事件、不推回调。订单侧只在退款成功(state=2)时处理。
+     */
+    @Transactional
+    public ApplyOutcome apply(String refundNo, String nextState, JsonNode upstream,
+                              Long payAmount, String errCode, String errMsg) {
+        if (refundNo == null || refundNo.isBlank()
+                || !UnifiedRefundStatus.isValid(nextState)) {
+            return ApplyOutcome.none(null, null);
+        }
+        UnifiedRefundEntity refund = refundMapper.selectOne(
+                new LambdaQueryWrapper<UnifiedRefundEntity>()
+                        .eq(UnifiedRefundEntity::getRefundNo, refundNo));
+        if (refund == null
+                || !UnifiedRefundStatus.canTransition(refund.getState(), nextState)) {
+            return ApplyOutcome.none(refund, null);
+        }
+
+        OffsetDateTime now = OffsetDateTime.now();
+        int queryCount = refund.getQueryCount() == null ? 0 : refund.getQueryCount();
+        OffsetDateTime nextQueryTime = UnifiedRefundStatus.PENDING.contains(nextState)
+                ? nextQueryTimeWithinWindow(refund, now, queryCount) : null;
+        String upstreamDataJson = upstream == null
+                ? null : UnifiedUpstreamSnapshot.toJson(upstream);
+        String upstreamRefundOrderId = textOf(upstream, "refundOrderId");
+        String upstreamChannelOrderNo = textOf(upstream, "channelOrderNo");
+        OffsetDateTime successTime = UnifiedRefundStatus.SUCCESS.equals(nextState)
+                ? UnifiedResponseParser.toShanghai(
+                        UnifiedResponseParser.epochMillis(upstream, "successTime"))
+                : null;
+
+        LambdaUpdateWrapper<UnifiedRefundEntity> update = new LambdaUpdateWrapper<UnifiedRefundEntity>()
+                .eq(UnifiedRefundEntity::getRefundNo, refundNo)
+                .in(UnifiedRefundEntity::getState,
+                        UnifiedRefundStatus.previousStatesFor(nextState))
+                .set(UnifiedRefundEntity::getState, nextState)
+                .set(UnifiedRefundEntity::getErrCode, errCode)
+                .set(UnifiedRefundEntity::getErrMsg, errMsg)
+                .set(UnifiedRefundEntity::getLastQueryTime, now)
+                .set(UnifiedRefundEntity::getUpdatedTime, now)
+                .set(UnifiedRefundEntity::getNextQueryTime, nextQueryTime);
+        if (upstreamDataJson != null) {
+            update.set(UnifiedRefundEntity::getUpstreamDataJson, upstreamDataJson);
+        }
+        if (upstreamRefundOrderId != null) {
+            update.set(UnifiedRefundEntity::getUpstreamRefundOrderId, upstreamRefundOrderId);
+        }
+        if (upstreamChannelOrderNo != null) {
+            update.set(UnifiedRefundEntity::getUpstreamChannelOrderNo, upstreamChannelOrderNo);
+        }
+        if (successTime != null) {
+            update.set(UnifiedRefundEntity::getSuccessTime, successTime);
+        }
+
+        if (refundMapper.update(null, update) == 0) {
+            // 并发的另一条腿已经写过:不覆盖、不发事件
+            return ApplyOutcome.none(refund, null);
+        }
+
+        refund.setState(nextState);
+        refund.setErrCode(errCode);
+        refund.setErrMsg(errMsg);
+        refund.setLastQueryTime(now);
+        refund.setUpdatedTime(now);
+        refund.setNextQueryTime(nextQueryTime);
+        if (upstreamDataJson != null) {
+            refund.setUpstreamDataJson(upstreamDataJson);
+        }
+        if (upstreamRefundOrderId != null) {
+            refund.setUpstreamRefundOrderId(upstreamRefundOrderId);
+        }
+        if (upstreamChannelOrderNo != null) {
+            refund.setUpstreamChannelOrderNo(upstreamChannelOrderNo);
+        }
+        if (successTime != null) {
+            refund.setSuccessTime(successTime);
+        }
+
+        if (!UnifiedRefundStatus.SUCCESS.equals(nextState)) {
+            // 非成功终态的退款回调需要订单快照,但绝不改订单
+            return new ApplyOutcome(true, false, false, refund, orderOf(refund));
+        }
+        return applyOrderSide(refund, payAmount);
+    }
+
+    /** 退款成功后的订单联动;任何一条不满足都只返回退款侧变化。 */
+    private ApplyOutcome applyOrderSide(UnifiedRefundEntity refund, Long payAmount) {
+        if (refund.getOrderId() == null) {
+            log.error("[统一支付] 退款成功但缺少订单关联: refund_no={}", refund.getRefundNo());
+            return new ApplyOutcome(true, false, false, refund, null);
+        }
+        // 事务 B 已先锁退款行;这里再锁订单行,把退款金额汇总与订单状态写串行化
+        UnifiedOrderEntity order = orderMapper.selectForUpdate(
+                refund.getOrderId(), refund.getTenantId());
+        if (order == null) {
+            log.error("[统一支付] 退款成功但订单不存在: refund_no={}, order_id={}",
+                    refund.getRefundNo(), refund.getOrderId());
+            return new ApplyOutcome(true, false, false, refund, null);
+        }
+
+        String tradeStatus = order.getTradeStatus();
+        boolean orderPaidChanged = false;
+        if (UnifiedOrderStatus.PENDING.contains(tradeStatus)) {
+            // R25①:金额缺失 = 未知,未知不等于相等 —— 不得把订单标成已支付
+            if (payAmount == null) {
+                log.error("[统一支付] 退款成功但订单支付金额未知,订单状态不推进: "
+                                + "order_no={}, refund_no={}, trade_status={}",
+                        order.getOrderNo(), refund.getRefundNo(), tradeStatus);
+                return new ApplyOutcome(true, false, false, refund, order);
+            }
+            if (order.getAmount() == null || !Objects.equals(order.getAmount(), payAmount)) {
+                log.error("[统一支付] 退款成功但支付金额与订单金额不符,订单状态不推进: "
+                                + "order_no={}, refund_no={}, payAmount={}, amount={}",
+                        order.getOrderNo(), refund.getRefundNo(), payAmount, order.getAmount());
+                return new ApplyOutcome(true, false, false, refund, order);
+            }
+            OffsetDateTime paidAt = OffsetDateTime.now();
+            int paidRows = orderMapper.update(null,
+                    new LambdaUpdateWrapper<UnifiedOrderEntity>()
+                            .eq(UnifiedOrderEntity::getId, order.getId())
+                            .in(UnifiedOrderEntity::getTradeStatus, UnifiedOrderStatus.PENDING)
+                            .set(UnifiedOrderEntity::getTradeStatus, UnifiedOrderStatus.SUCCESS)
+                            .set(UnifiedOrderEntity::getUpdatedTime, paidAt));
+            if (paidRows == 0) {
+                log.error("[统一支付] 订单支付状态已被并发修改,退款金额未计入订单: "
+                                + "order_no={}, refund_no={}",
+                        order.getOrderNo(), refund.getRefundNo());
+                return new ApplyOutcome(true, false, false, refund, order);
+            }
+            order.setTradeStatus(UnifiedOrderStatus.SUCCESS);
+            order.setUpdatedTime(paidAt);
+            orderPaidChanged = true;
+        } else if (UnifiedOrderStatus.FINAL.contains(tradeStatus)) {
+            // R3:终止态订单早退 —— 不写 refunded_amount、不发 ORDER
+            log.error("[统一支付] 退款成功但订单已处于终止态,订单不做联动: "
+                            + "order_no={}, refund_no={}, trade_status={}",
+                    order.getOrderNo(), refund.getRefundNo(), tradeStatus);
+            return new ApplyOutcome(true, orderPaidChanged, false, refund, order);
+        }
+
+        if (!UnifiedOrderStatus.SUCCESS.equals(order.getTradeStatus())) {
+            return new ApplyOutcome(true, orderPaidChanged, false, refund, order);
+        }
+
+        long succeeded = refundMapper.sumSucceededAmount(order.getId());
+        boolean orderRefundedChanged = false;
+        if (!Objects.equals(order.getRefundedAmount(), succeeded)) {
+            OffsetDateTime updatedAt = OffsetDateTime.now();
+            orderMapper.update(null, new LambdaUpdateWrapper<UnifiedOrderEntity>()
+                    .eq(UnifiedOrderEntity::getId, order.getId())
+                    .set(UnifiedOrderEntity::getRefundedAmount, succeeded)
+                    .set(UnifiedOrderEntity::getUpdatedTime, updatedAt));
+            order.setRefundedAmount(succeeded);
+            order.setUpdatedTime(updatedAt);
+        }
+
+        long paidAmount = order.getAmount() == null ? 0L : order.getAmount();
+        if (succeeded >= paidAmount) {
+            OffsetDateTime refundedAt = OffsetDateTime.now();
+            int refundedRows = orderMapper.update(null,
+                    new LambdaUpdateWrapper<UnifiedOrderEntity>()
+                            .eq(UnifiedOrderEntity::getId, order.getId())
+                            .eq(UnifiedOrderEntity::getTradeStatus, UnifiedOrderStatus.SUCCESS)
+                            .set(UnifiedOrderEntity::getTradeStatus,
+                                    UnifiedOrderStatus.REFUNDED)
+                            .set(UnifiedOrderEntity::getUpdatedTime, refundedAt));
+            if (refundedRows > 0) {
+                order.setTradeStatus(UnifiedOrderStatus.REFUNDED);
+                order.setUpdatedTime(refundedAt);
+                orderRefundedChanged = true;
+            }
+        }
+        return new ApplyOutcome(true, orderPaidChanged, orderRefundedChanged, refund, order);
+    }
+
+    private UnifiedOrderEntity orderOf(UnifiedRefundEntity refund) {
+        if (refund.getOrderId() == null) {
+            return null;
+        }
+        return orderMapper.selectById(refund.getOrderId());
+    }
+
+    /**
+     * 下一次自动查询时间(退避 + 30 分钟停止边界)。
+     *
+     * <p>退避索引取 {@code queryCount - 1}:调用方在记录本次查询之后才排期,
+     * 第 1 次查询对应 5 秒首档。排到 30 分钟边界或更晚意味着永远不会被扫描,
+     * 必须写 null(与订单同口径)。
+     */
+    private static OffsetDateTime nextQueryTimeWithinWindow(UnifiedRefundEntity refund,
+                                                            OffsetDateTime now, int queryCount) {
+        OffsetDateTime next = now.plusSeconds(
+                UnifiedPollPolicy.nextDelaySeconds(queryCount - 1));
+        OffsetDateTime createdAt = refund.getCreatedTime();
+        if (createdAt != null
+                && !next.isBefore(createdAt.plusMinutes(UnifiedPollPolicy.MAX_AGE_MINUTES))) {
+            return null;
+        }
+        return next;
+    }
+
+    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;
+    }
+}

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

@@ -0,0 +1,812 @@
+package com.payment.platform.module.payment.unified.service;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+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.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.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.dto.req.UnifiedRefundCreateCommand;
+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.entity.UnifiedRefundEntity;
+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.UnifiedUpstreamSnapshot;
+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.slf4j.LoggerFactory;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.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.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.clearInvocations;
+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;
+
+/**
+ * 退款核心:事务 A(额度闸门 + 落单)、事务 B(状态收敛)、事件/回调编排(spec §7.4 / §7.6)。
+ *
+ * <p>本类盯的是「不会报错但会错钱」的地方:
+ * <ol>
+ *   <li>对没收到钱的单发起退款;</li>
+ *   <li>在途退款不占额度,并发两笔把总额退超;</li>
+ *   <li>上游明确拒绝却把额度锁 30 分钟(state 留 0);</li>
+ *   <li>退款成功但订单仍 0/1 时把未知金额的订单标成已支付(R25①);</li>
+ *   <li>未支付单全额退款(0/1→2→5)把同一个 ORDER 事件推两遍(R25②);</li>
+ *   <li>条件 UPDATE 落空仍发事件/回调。</li>
+ * </ol>
+ *
+ * <p>编排测试用 mock 的 {@link UnifiedRefundTxService};闸门与事务语义用真实 tx service +
+ * mock mapper。事件第二参一律是租户号(R23)。
+ */
+@ExtendWith(MockitoExtension.class)
+class UnifiedRefundServiceTest {
+
+    private static final ObjectMapper JSON = new ObjectMapper();
+    private static final Long TENANT = 2L;
+    private static final long ORDER_ID = 5L;
+    private static final long MERCHANT_ID = 11L;
+
+    @Mock private UnifiedRefundTxService tx;
+    @Mock private UnifiedRefundMapper refundMapper;
+    @Mock private UnifiedOrderMapper orderMapper;
+    @Mock private UnifiedOrderService orderService;
+    @Mock private UnifiedMerchantService merchantService;
+    @Mock private UnifiedClient client;
+    @Mock private UnifiedCallbackService callbackService;
+    @Mock private OrderEventPublisher publisher;
+
+    /** 编排层:tx 是 mock。 */
+    private UnifiedRefundService service;
+    /** 真实事务层 + mock mapper。 */
+    private UnifiedRefundTxService realTx;
+    private UnifiedRefundService realService;
+
+    @BeforeEach
+    void setUp() {
+        service = new UnifiedRefundService(tx, refundMapper, orderService, merchantService,
+                client, callbackService, publisher);
+        realTx = new UnifiedRefundTxService(orderMapper, refundMapper);
+        realService = new UnifiedRefundService(realTx, refundMapper, orderService, merchantService,
+                client, callbackService, publisher);
+
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
+        TableInfoHelper.initTableInfo(assistant, UnifiedOrderEntity.class);
+        TableInfoHelper.initTableInfo(assistant, UnifiedRefundEntity.class);
+    }
+
+    // ────────────────────────────── 工具 ──────────────────────────────
+
+    private static JsonNode json(String text) {
+        try {
+            return JSON.readTree(text);
+        } catch (JsonProcessingException e) {
+            throw new IllegalStateException("测试 JSON 写错了", e);
+        }
+    }
+
+    private static UnifiedOrderEntity order(String tradeStatus) {
+        UnifiedOrderEntity o = new UnifiedOrderEntity();
+        o.setId(ORDER_ID);
+        o.setTenantId(TENANT);
+        o.setMerchantId(MERCHANT_ID);
+        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.setTradeStatus(tradeStatus);
+        o.setQueryCount(0);
+        return o;
+    }
+
+    private static UnifiedMerchantEntity merchant() {
+        UnifiedMerchantEntity cfg = new UnifiedMerchantEntity();
+        cfg.setId(MERCHANT_ID);
+        cfg.setTenantId(TENANT);
+        cfg.setMchNo("M001");
+        cfg.setAppId("app-1");
+        cfg.setAppSecret("SECRET");
+        cfg.setStatus(UnifiedMerchantService.STATUS_ENABLED);
+        return cfg;
+    }
+
+    private static UnifiedRefundEntity refund(String state, long amount) {
+        UnifiedRefundEntity r = new UnifiedRefundEntity();
+        r.setId(9L);
+        r.setTenantId(TENANT);
+        r.setOrderId(ORDER_ID);
+        r.setOrderNo("UP5");
+        r.setRefundNo("UR1");
+        r.setOutRefundNo("R1");
+        r.setMchNo("M001");
+        r.setAppId("app-1");
+        r.setApiKey("KEY-1");
+        r.setSource("OPENAPI");
+        r.setAmount(amount);
+        r.setCurrency("cny");
+        r.setRefundReason("测试退款");
+        r.setState(state);
+        r.setQueryCount(0);
+        r.setNotifyStatus("PENDING");
+        return r;
+    }
+
+    private static UnifiedRefundCreateCommand command(long amount, String outRefundNo) {
+        UnifiedRefundCreateCommand cmd = new UnifiedRefundCreateCommand();
+        cmd.setOrderNo("UP5");
+        cmd.setRefundAmount(amount);
+        cmd.setRefundReason("测试退款");
+        cmd.setOutRefundNo(outRefundNo);
+        return cmd;
+    }
+
+    private static UnifiedRefundCreateCommand command() {
+        return command(100L, "R1");
+    }
+
+    /** 创建路径的公共桩:订单解析 + 商户配置。 */
+    private void stubCreateOrder(UnifiedOrderEntity o) {
+        when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
+        when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
+    }
+
+    /** 创建路径的公共桩:事务 A 锁单 + 额度 + 落单。 */
+    private void stubReserve(UnifiedOrderEntity o, long occupied) {
+        when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
+        when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(occupied);
+        when(refundMapper.insert(any())).thenReturn(1);
+    }
+
+    @SuppressWarnings("unchecked")
+    private static ArgumentCaptor<LambdaUpdateWrapper<UnifiedRefundEntity>> refundUpdateCaptor() {
+        return ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
+    }
+
+    @SuppressWarnings("unchecked")
+    private static ArgumentCaptor<LambdaUpdateWrapper<UnifiedOrderEntity>> orderUpdateCaptor() {
+        return ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
+    }
+
+    /** 从 SET 子句里取某一列绑定的参数值(MyBatis-Plus 的参数名形如 {@code paramNameValuePairs.MPGENVAL1})。 */
+    private static Object setValue(LambdaUpdateWrapper<?> w, String column) {
+        String set = w.getSqlSet();
+        Matcher m = Pattern.compile(Pattern.quote(column)
+                        + "=#\\{ew\\.paramNameValuePairs\\.([A-Za-z0-9_]+)\\}")
+                .matcher(set);
+        assertTrue(m.find(), "SET 缺列 " + column + ",实测: " + set);
+        return w.getParamNameValuePairs().get(m.group(1));
+    }
+
+    private static ListAppender<ILoggingEvent> attachLog(Class<?> type) {
+        Logger logger = (Logger) LoggerFactory.getLogger(type);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+        return appender;
+    }
+
+    // ────────────────────────────── 1. 未支付单 ──────────────────────────────
+
+    @Test
+    void unpaidOrder_isRejectedBeforeUpstream() {
+        UnifiedOrderEntity o = order("0");
+        when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
+        when(refundMapper.selectOne(any())).thenReturn(null);
+        when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
+        when(tx.reserve(eq(ORDER_ID), eq(TENANT), eq(100L), anyString(), anyString(), eq("R1"),
+                isNull(), isNull(), isNull(), eq(UnifiedSource.OPENAPI)))
+                .thenThrow(new BusinessException(400, "订单未支付成功,不能退款"));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.createRefund(command(), TENANT, null, UnifiedSource.OPENAPI));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertEquals("订单未支付成功,不能退款", e.getMessage());
+        verifyNoInteractions(client);
+    }
+
+    // ────────────────────────────── 2. 已全退 ──────────────────────────────
+
+    @Test
+    void fullyRefundedOrder_isRejectedWithNoRefundableAmount() {
+        UnifiedOrderEntity o = order("5");
+        when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
+        when(refundMapper.selectOne(any())).thenReturn(null);
+        when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
+        when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> realService.createRefund(command(), TENANT, null, UnifiedSource.OPENAPI));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertEquals("已无可退金额", e.getMessage());
+        verify(refundMapper, never()).insert(any());
+        verifyNoInteractions(client);
+    }
+
+    // ────────────────────────────── 3. 在途占额度(Review Focus 5) ──────────────────────────────
+
+    @Test
+    void secondRefundExceedingInFlightQuota_isRejectedWithSingleUpstreamCall() {
+        UnifiedOrderEntity o = order("2");
+        when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
+        when(refundMapper.selectOne(any())).thenReturn(null);
+        when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
+        when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
+        // 第一笔落单后占用 60 分;第二笔只剩 40 分可退
+        when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L, 60L);
+        when(refundMapper.insert(any())).thenReturn(1);
+        when(refundMapper.update(isNull(), any())).thenReturn(1);
+        when(client.refundOrder(any(), eq("UP5"), anyString(), anyLong(), anyString()))
+                .thenReturn(json("{\"state\":0}"));
+
+        UnifiedRefundEntity first = realService.createRefund(
+                command(60L, "R1"), TENANT, null, UnifiedSource.OPENAPI);
+        assertEquals("0", first.getState());
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> realService.createRefund(command(50L, "R2"), TENANT, null,
+                        UnifiedSource.OPENAPI));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("超过可退金额"), "实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("40"), "实测消息: " + e.getMessage());
+        verify(client, times(1)).refundOrder(any(), anyString(), anyString(), anyLong(), anyString());
+    }
+
+    // ────────────────────────────── 4. 幂等 ──────────────────────────────
+
+    @Test
+    void idempotentSameOutRefundNo_returnsExistingWithoutUpstream() {
+        UnifiedOrderEntity o = order("2");
+        UnifiedRefundEntity existing = refund("2", 100L);
+        when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
+        when(refundMapper.selectOne(any())).thenReturn(existing);
+
+        UnifiedRefundEntity result = service.createRefund(command(), TENANT, "KEY-1",
+                UnifiedSource.OPENAPI);
+
+        assertSame(existing, result);
+        verifyNoInteractions(client, merchantService, tx);
+    }
+
+    @Test
+    void idempotentDifferentAmount_409() {
+        UnifiedOrderEntity o = order("2");
+        UnifiedRefundEntity existing = refund("2", 60L);
+        existing.setOutRefundNo("R1");
+        when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
+        when(refundMapper.selectOne(any())).thenReturn(existing);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.createRefund(command(100L, "R1"), TENANT, null,
+                        UnifiedSource.OPENAPI));
+
+        assertEquals(409, e.getCode(), "实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("out_refund_no"), "实测消息: " + e.getMessage());
+        verifyNoInteractions(client);
+    }
+
+    @Test
+    void duplicateInsertThenNotFound_rethrows() {
+        UnifiedOrderEntity o = order("2");
+        when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
+        when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
+        when(refundMapper.selectOne(any())).thenReturn(null);
+        when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
+        when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
+        when(refundMapper.insert(any())).thenThrow(
+                new org.springframework.dao.DuplicateKeyException("uk_unified_refund_tenant_out_refund_no"));
+
+        assertThrows(org.springframework.dao.DuplicateKeyException.class,
+                () -> realService.createRefund(command(), TENANT, null, UnifiedSource.OPENAPI));
+
+        verifyNoInteractions(client);
+    }
+
+    // ────────────────────────────── 5. 上游明确拒绝 ──────────────────────────────
+
+    @Test
+    void upstreamBusinessRejection_marksRefundFailedAndPublishes() {
+        UnifiedOrderEntity o = order("2");
+        UnifiedRefundEntity r = refund("0", 100L);
+        when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
+        when(refundMapper.selectOne(any())).thenReturn(null, r);
+        when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
+        when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
+        when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
+        when(refundMapper.insert(any())).thenReturn(1);
+        when(refundMapper.update(isNull(), any())).thenReturn(1);
+        when(orderMapper.selectById(ORDER_ID)).thenReturn(o);
+        when(client.refundOrder(any(), eq("UP5"), anyString(), eq(100L), anyString()))
+                .thenThrow(new UnifiedApiException(5, "余额不足"));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> realService.createRefund(command(), TENANT, null, UnifiedSource.OPENAPI));
+
+        assertEquals(500, e.getCode(), "实测消息: " + e.getMessage());
+        assertEquals("余额不足", e.getMessage());
+        assertEquals("3", r.getState(), "上游明确拒绝必须落 3,立即释放额度");
+        assertEquals("5", r.getErrCode());
+        assertEquals("余额不足", r.getErrMsg());
+
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_REFUND, TENANT);
+        verify(publisher, never()).changed(eq(OrderEventPublisher.CHANNEL_UNIFIED_ORDER), any());
+        verify(callbackService).notifyRefund(same(r), same(o), isNull());
+        verify(callbackService, never()).notifyOrder(any(), any());
+
+        ArgumentCaptor<LambdaUpdateWrapper<UnifiedRefundEntity>> w = refundUpdateCaptor();
+        verify(refundMapper).update(isNull(), w.capture());
+        String set = w.getValue().getSqlSet();
+        assertTrue(set.contains("state"), "退款状态更新缺 state: " + set);
+        assertFalse(set.contains("upstream_data_json"),
+                "null upstream 不得把已有快照覆盖成空: " + set);
+        assertEquals("3", String.valueOf(setValue(w.getValue(), "state")));
+        assertEquals("5", String.valueOf(setValue(w.getValue(), "err_code")));
+    }
+
+    // ────────────────────────────── 6. 传输异常 ──────────────────────────────
+
+    @Test
+    void transportFailure_keepsGeneratedAndSchedulesRetry() {
+        UnifiedOrderEntity o = order("2");
+        when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
+        when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
+        when(refundMapper.selectOne(any())).thenReturn(null);
+        when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
+        when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
+        when(refundMapper.insert(any())).thenReturn(1);
+        when(refundMapper.update(isNull(), any())).thenReturn(1);
+        when(client.refundOrder(any(), eq("UP5"), anyString(), anyLong(), anyString()))
+                .thenThrow(new UnifiedApiException(UnifiedApiException.CODE_TRANSPORT,
+                        "connect timeout"));
+
+        OffsetDateTime before = OffsetDateTime.now();
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> realService.createRefund(command(), TENANT, null, UnifiedSource.OPENAPI));
+
+        assertEquals(500, e.getCode(), "实测消息: " + e.getMessage());
+        ArgumentCaptor<UnifiedRefundEntity> inserted = ArgumentCaptor.forClass(UnifiedRefundEntity.class);
+        verify(refundMapper).insert(inserted.capture());
+        UnifiedRefundEntity reserved = inserted.getValue();
+        assertEquals("0", reserved.getState(), "超时/网络异常必须留在 0 等查询收敛");
+        assertNotNull(reserved.getNextQueryTime(), "失败单必须安排 15 秒后的重试查询");
+        assertTrue(reserved.getNextQueryTime().isAfter(before.plusSeconds(13))
+                        && reserved.getNextQueryTime().isBefore(before.plusSeconds(18)),
+                "重试排期应约 15 秒后,实测: " + reserved.getNextQueryTime());
+        verifyNoInteractions(publisher, callbackService);
+    }
+
+    // ────────────────────────────── 7. 全额退款(已支付单) ──────────────────────────────
+
+    @Test
+    void fullRefund_paidOrder_convergesOrderTo5AndPublishesBoth() {
+        UnifiedOrderEntity o = order("2");
+        UnifiedRefundEntity r = refund("0", 100L);
+        when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
+        when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
+        when(refundMapper.selectOne(any())).thenReturn(null, r);
+        when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
+        when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
+        when(refundMapper.insert(any())).thenReturn(1);
+        when(refundMapper.sumSucceededAmount(ORDER_ID)).thenReturn(100L);
+        when(refundMapper.update(isNull(), any())).thenReturn(1);
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+        when(client.refundOrder(any(), eq("UP5"), anyString(), anyLong(), anyString()))
+                .thenReturn(json("{\"state\":2,\"refundOrderId\":\"RF1\","
+                        + "\"successTime\":1760000000000}"));
+
+        UnifiedRefundEntity result = realService.createRefund(command(), TENANT, null,
+                UnifiedSource.OPENAPI);
+
+        assertEquals("2", result.getState());
+        assertEquals(100L, o.getRefundedAmount());
+        assertEquals("5", o.getTradeStatus());
+
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_REFUND, TENANT);
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_ORDER, TENANT);
+        verify(callbackService).notifyRefund(same(r), same(o), any(JsonNode.class));
+        ArgumentCaptor<UnifiedOrderEntity> orderCallback =
+                ArgumentCaptor.forClass(UnifiedOrderEntity.class);
+        verify(callbackService).notifyOrder(orderCallback.capture(), any(JsonNode.class));
+        assertEquals("5", orderCallback.getValue().getTradeStatus(),
+                "全额退款的 ORDER 事件必须带最终快照(5)");
+
+        // 订单/退款行的 UPDATE 必须先于两次下游回调
+        InOrder inOrder = inOrder(refundMapper, orderMapper, callbackService);
+        inOrder.verify(refundMapper, org.mockito.Mockito.atLeastOnce())
+                .update(isNull(), any());
+        inOrder.verify(orderMapper, org.mockito.Mockito.atLeastOnce())
+                .update(isNull(), any());
+        inOrder.verify(callbackService).notifyRefund(any(), any(), any());
+        inOrder.verify(callbackService).notifyOrder(any(), any());
+    }
+
+    // ────────────────────────────── 8. 部分退款(已支付单) ──────────────────────────────
+
+    @Test
+    void partialRefund_paidOrder_publishesRefundOnly() {
+        UnifiedOrderEntity o = order("2");
+        UnifiedRefundEntity r = refund("0", 30L);
+        when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
+        when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
+        when(refundMapper.selectOne(any())).thenReturn(null, r);
+        when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
+        when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
+        when(refundMapper.insert(any())).thenReturn(1);
+        when(refundMapper.sumSucceededAmount(ORDER_ID)).thenReturn(30L);
+        when(refundMapper.update(isNull(), any())).thenReturn(1);
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+        when(client.refundOrder(any(), eq("UP5"), anyString(), anyLong(), anyString()))
+                .thenReturn(json("{\"state\":2}"));
+
+        UnifiedRefundEntity result = realService.createRefund(
+                command(30L, "R1"), TENANT, null, UnifiedSource.OPENAPI);
+
+        assertEquals("2", result.getState());
+        assertEquals("2", o.getTradeStatus(), "部分退款成功后订单仍应为 2");
+        assertEquals(30L, o.getRefundedAmount());
+
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_REFUND, TENANT);
+        verify(publisher, never()).changed(eq(OrderEventPublisher.CHANNEL_UNIFIED_ORDER), any());
+        verify(callbackService).notifyRefund(any(), any(), any());
+        verify(callbackService, never()).notifyOrder(any(), any());
+        verify(orderMapper, times(1)).update(isNull(), any());
+    }
+
+    // ────────────────────────────── 9. 退款成功但订单仍 0/1 ──────────────────────────────
+
+    @Test
+    void refundSuccess_unpaidOrder_withMatchingPayAmount_setsOrderPaid() {
+        UnifiedOrderEntity o = order("0");
+        UnifiedRefundEntity r = refund("1", 30L);
+        when(refundMapper.selectOne(any())).thenReturn(r);
+        when(refundMapper.update(isNull(), any())).thenReturn(1);
+        when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+        when(refundMapper.sumSucceededAmount(ORDER_ID)).thenReturn(30L);
+
+        realService.applyRefundState("UR1", "2", json("{\"state\":2}"),
+                100L, null, null);
+
+        assertEquals("2", r.getState());
+        assertEquals("2", o.getTradeStatus(), "payAmount 与订单金额一致时补记支付成功");
+        assertEquals(30L, o.getRefundedAmount());
+
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_REFUND, TENANT);
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_ORDER, TENANT);
+        ArgumentCaptor<UnifiedOrderEntity> orderCallback =
+                ArgumentCaptor.forClass(UnifiedOrderEntity.class);
+        verify(callbackService).notifyOrder(orderCallback.capture(), any(JsonNode.class));
+        assertEquals("2", orderCallback.getValue().getTradeStatus());
+    }
+
+    /** R25②:0/1→2→5 同一次 apply 只推一次 ORDER(最终 5),不是两次。 */
+    @Test
+    void refundSuccess_unpaidOrder_fullAmount_publishesSingleOrderEventWithFinalState5() {
+        UnifiedOrderEntity o = order("0");
+        UnifiedRefundEntity r = refund("1", 100L);
+        when(refundMapper.selectOne(any())).thenReturn(r);
+        when(refundMapper.update(isNull(), any())).thenReturn(1);
+        when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+        when(refundMapper.sumSucceededAmount(ORDER_ID)).thenReturn(100L);
+
+        realService.applyRefundState("UR1", "2", json("{\"state\":2}"),
+                100L, null, null);
+
+        assertEquals("5", o.getTradeStatus());
+        assertEquals(100L, o.getRefundedAmount());
+
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_REFUND, TENANT);
+        verify(publisher, times(1)).changed(OrderEventPublisher.CHANNEL_UNIFIED_ORDER, TENANT);
+        ArgumentCaptor<UnifiedOrderEntity> orderCallback =
+                ArgumentCaptor.forClass(UnifiedOrderEntity.class);
+        verify(callbackService, times(1)).notifyOrder(orderCallback.capture(), any(JsonNode.class));
+        assertEquals("5", orderCallback.getValue().getTradeStatus(),
+                "同一次 apply 的 ORDER 事件必须带最终快照(5)");
+    }
+
+    // ────────────────────────────── 10. 金额缺失/不符(R25①) ──────────────────────────────
+
+    @Test
+    void refundSuccess_unpaidOrder_payAmountMissing_orderUntouched() {
+        assertUnpaidOrderUntouched(null, "缺失");
+    }
+
+    @Test
+    void refundSuccess_unpaidOrder_payAmountMismatch_orderUntouched() {
+        assertUnpaidOrderUntouched(99L, "不符");
+    }
+
+    private void assertUnpaidOrderUntouched(Long payAmount, String label) {
+        UnifiedOrderEntity o = order("0");
+        UnifiedRefundEntity r = refund("1", 100L);
+        when(refundMapper.selectOne(any())).thenReturn(r);
+        when(refundMapper.update(isNull(), any())).thenReturn(1);
+        when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
+
+        ListAppender<ILoggingEvent> appender = attachLog(UnifiedRefundTxService.class);
+        try {
+            realService.applyRefundState("UR1", "2", json("{\"state\":2}"),
+                    payAmount, null, null);
+        } finally {
+            ((Logger) LoggerFactory.getLogger(UnifiedRefundTxService.class))
+                    .detachAppender(appender);
+        }
+
+        assertEquals("0", o.getTradeStatus(), label + ":金额未知/不等的单不得标成已支付");
+        assertEquals(0L, o.getRefundedAmount());
+        assertEquals("2", r.getState(), "退款行本身仍应落 2");
+
+        verify(orderMapper, never()).update(isNull(), any());
+        verify(refundMapper, never()).sumSucceededAmount(anyLong());
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_REFUND, TENANT);
+        verify(publisher, never()).changed(eq(OrderEventPublisher.CHANNEL_UNIFIED_ORDER), any());
+        verify(callbackService).notifyRefund(any(), any(), any());
+        verify(callbackService, never()).notifyOrder(any(), any());
+
+        assertTrue(appender.list.stream().anyMatch(ev ->
+                        ev.getLevel() == Level.ERROR
+                                && ev.getFormattedMessage().contains("UP5")),
+                "payAmount " + label + " 必须记 ERROR(含订单号),实测: "
+                        + appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
+    }
+
+    // ────────────────────────────── 11. 订单 3/4/6 ──────────────────────────────
+
+    @Test
+    void refundSuccess_orderFailedRevokedOrClosed_orderUntouched() {
+        for (String status : List.of("3", "4", "6")) {
+            UnifiedOrderEntity o = order(status);
+            UnifiedRefundEntity r = refund("1", 100L);
+            when(refundMapper.selectOne(any())).thenReturn(r);
+            when(refundMapper.update(isNull(), any())).thenReturn(1);
+            when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
+
+            realService.applyRefundState("UR1", "2", json("{\"state\":2}"),
+                    100L, null, null);
+
+            assertEquals(status, o.getTradeStatus(), "终态订单不得被退款回写改动");
+            assertEquals("2", r.getState());
+            verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_REFUND, TENANT);
+            verify(publisher, never()).changed(eq(OrderEventPublisher.CHANNEL_UNIFIED_ORDER), any());
+            verify(callbackService).notifyRefund(any(), any(), any());
+            verify(callbackService, never()).notifyOrder(any(), any());
+            verify(orderMapper, never()).update(isNull(), any());
+
+            clearInvocations(publisher, callbackService, orderMapper);
+        }
+    }
+
+    // ────────────────────────────── 12. 重复收敛 ──────────────────────────────
+
+    @Test
+    void duplicateSuccessApply_isNoOp() {
+        UnifiedOrderEntity o = order("2");
+        UnifiedRefundEntity r = refund("1", 30L);
+        when(refundMapper.selectOne(any())).thenReturn(r);
+        when(refundMapper.update(isNull(), any())).thenReturn(1);
+        when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+        when(refundMapper.sumSucceededAmount(ORDER_ID)).thenReturn(30L);
+
+        realService.applyRefundState("UR1", "2", json("{\"state\":2}"), 100L, null, null);
+        assertEquals("2", r.getState());
+
+        clearInvocations(publisher, callbackService);
+        realService.applyRefundState("UR1", "2", json("{\"state\":2}"), 100L, null, null);
+
+        verifyNoInteractions(publisher, callbackService);
+        verify(refundMapper, times(1)).update(isNull(), any());
+
+        UnifiedRefundTxService.ApplyOutcome outcome =
+                realTx.apply("UR1", "2", json("{\"state\":2}"), 100L, null, null);
+        assertFalse(outcome.refundChanged(), "同态不得算一次变化");
+    }
+
+    @Test
+    void refundConditionalUpdateZeroRows_doesNotPublish() {
+        UnifiedRefundEntity r = refund("1", 100L);
+        when(refundMapper.selectOne(any())).thenReturn(r);
+        when(refundMapper.update(isNull(), any())).thenReturn(0);
+
+        realService.applyRefundState("UR1", "2", json("{\"state\":2}"), 100L, null, null);
+
+        verifyNoInteractions(publisher, callbackService);
+        verify(orderMapper, never()).selectForUpdate(any(), any());
+    }
+
+    // ────────────────────────────── 13. 退款行租户取自订单 ──────────────────────────────
+
+    @Test
+    void reserve_usesOrderTenantWhenCallerTenantIsNull() {
+        UnifiedOrderEntity o = order("2");
+        when(orderMapper.selectForUpdate(ORDER_ID, null)).thenReturn(o);
+        when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
+        when(refundMapper.insert(any())).thenReturn(1);
+
+        UnifiedRefundEntity reserved = realTx.reserve(ORDER_ID, null, 100L, "测试退款",
+                "UR1", "R1", null, null, "KEY-1", UnifiedSource.OPENAPI);
+
+        assertEquals(TENANT, reserved.getTenantId(), "超管发起也必须落到订单所属租户");
+        ArgumentCaptor<UnifiedRefundEntity> inserted =
+                ArgumentCaptor.forClass(UnifiedRefundEntity.class);
+        verify(refundMapper).insert(inserted.capture());
+        assertEquals(TENANT, inserted.getValue().getTenantId());
+        assertEquals("0", inserted.getValue().getState());
+        assertEquals("cny", inserted.getValue().getCurrency());
+        assertEquals(0, inserted.getValue().getQueryCount());
+        assertEquals("PENDING", inserted.getValue().getNotifyStatus());
+        assertEquals("OPENAPI", inserted.getValue().getSource());
+    }
+
+    // ────────────────────────────── 14. 脱敏快照落库 ──────────────────────────────
+
+    @Test
+    void apply_persistsSanitizedUpstreamSnapshot() {
+        UnifiedOrderEntity o = order("2");
+        UnifiedRefundEntity r = refund("1", 30L);
+        when(refundMapper.selectOne(any())).thenReturn(r);
+        when(refundMapper.update(isNull(), any())).thenReturn(1);
+        when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+        when(refundMapper.sumSucceededAmount(ORDER_ID)).thenReturn(30L);
+
+        JsonNode raw = json("{\"state\":2,\"refundOrderId\":\"RF1\","
+                + "\"channelExtra\":{\"auth_code\":\"CODE\"},\"openid\":\"OPEN\"}");
+        JsonNode snapshot = UnifiedUpstreamSnapshot.of(raw);
+
+        realService.applyRefundState("UR1", "2", snapshot, 100L, null, null);
+
+        ArgumentCaptor<LambdaUpdateWrapper<UnifiedRefundEntity>> w = refundUpdateCaptor();
+        verify(refundMapper).update(isNull(), w.capture());
+        String stored = String.valueOf(setValue(w.getValue(), "upstream_data_json"));
+        assertTrue(stored.contains("\"refundOrderId\":\"RF1\""), "快照缺白名单字段: " + stored);
+        assertTrue(stored.contains("\"state\""), "快照缺 state: " + stored);
+        for (String forbidden : List.of("channelExtra", "auth_code", "CODE", "openid", "OPEN")) {
+            assertFalse(stored.contains(forbidden),
+                    "快照不得包含敏感值 " + forbidden + ",实测: " + stored);
+        }
+    }
+
+    // ────────────────────────────── 15/16. 创建响应状态 ──────────────────────────────
+
+    @Test
+    void createResponseState1_transitionsWithoutEvent() {
+        UnifiedOrderEntity o = order("2");
+        UnifiedRefundEntity r = refund("0", 100L);
+        when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
+        when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
+        when(refundMapper.selectOne(any())).thenReturn(null, r);
+        when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
+        when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
+        when(refundMapper.insert(any())).thenReturn(1);
+        when(refundMapper.update(isNull(), any())).thenReturn(1);
+        when(orderMapper.selectById(ORDER_ID)).thenReturn(o);
+        when(client.refundOrder(any(), eq("UP5"), anyString(), anyLong(), anyString()))
+                .thenReturn(json("{\"state\":1,\"refundOrderId\":\"RF1\"}"));
+
+        UnifiedRefundEntity result = realService.createRefund(command(), TENANT, null,
+                UnifiedSource.OPENAPI);
+
+        assertEquals("1", result.getState());
+        verifyNoInteractions(publisher, callbackService);
+    }
+
+    @Test
+    void createResponseState0_schedulesNextQuery() {
+        UnifiedOrderEntity o = order("2");
+        when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
+        when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
+        when(refundMapper.selectOne(any())).thenReturn(null);
+        when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
+        when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
+        when(refundMapper.insert(any())).thenReturn(1);
+        when(refundMapper.update(isNull(), any())).thenReturn(1);
+        when(client.refundOrder(any(), eq("UP5"), anyString(), anyLong(), anyString()))
+                .thenReturn(json("{\"state\":0}"));
+
+        OffsetDateTime before = OffsetDateTime.now();
+        UnifiedRefundEntity result = realService.createRefund(command(), TENANT, null,
+                UnifiedSource.OPENAPI);
+
+        assertEquals("0", result.getState());
+        assertNotNull(result.getNextQueryTime(), "state=0 必须安排下一次查询");
+        assertTrue(result.getNextQueryTime().isAfter(before.plusSeconds(3))
+                        && result.getNextQueryTime().isBefore(before.plusSeconds(8)),
+                "queryCount=1 应走退避首档 5s,实测: " + result.getNextQueryTime());
+        verifyNoInteractions(publisher, callbackService);
+    }
+
+    // ────────────────────────────── 来源与必填闸门 ──────────────────────────────
+
+    @Test
+    void cashierSource_isRejectedForRefund() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.createRefund(command(), TENANT, null, UnifiedSource.CASHIER));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(orderService, client, tx);
+    }
+
+    @Test
+    void missingOutRefundNo_isRejected() {
+        UnifiedRefundCreateCommand cmd = command();
+        cmd.setOutRefundNo(" ");
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.createRefund(cmd, TENANT, null, UnifiedSource.OPENAPI));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(orderService, client, tx);
+    }
+
+    // ────────────────────────────── 查询语义(Task 13 依赖) ──────────────────────────────
+
+    @Test
+    void findByOutRefundNo_nullTenant_returnsNull() {
+        // (tenant_id, out_refund_no) 只是租户内唯一;无租户时不能 selectOne 猜一行
+        assertEquals(null, realService.findByOutRefundNo(null, "R1"));
+        verifyNoInteractions(refundMapper);
+    }
+
+    @Test
+    void findPendingForPoll_filtersAndClampsLimit() {
+        when(refundMapper.selectList(any())).thenReturn(List.of(refund("0", 100L)));
+
+        List<UnifiedRefundEntity> rows = realService.findPendingForPoll(5000);
+
+        assertEquals(1, rows.size());
+        ArgumentCaptor<LambdaQueryWrapper<UnifiedRefundEntity>> w =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(refundMapper).selectList(w.capture());
+        String sql = w.getValue().getSqlSegment();
+        for (String fragment : List.of("state IN", "next_query_time <=", "created_time >",
+                "query_count <", "ORDER BY next_query_time ASC", "LIMIT 500")) {
+            assertTrue(sql.contains(fragment), "退款轮询查询缺片段 " + fragment + ",实测: " + sql);
+        }
+    }
+}