feat(payment): 统一支付新增退款/退款查询/退款同步接口

- UniTradeController 增 POST /refund 退款发起
- UniQueryController 增 POST /refund-order 退款订单查询
- UniSyncController 增 POST /refund 退款订单同步
- 新增 RefundParam/RefundOrderQueryParam/RefundSyncParam 与对应 Result
- 新增 RefundOrderService/RefundOrderQueryService/RefundOrderSyncService + UnipayRefundOrderConvert
- RefundOrderManager 增 findByBizRefundNo(单参/含appId) 支持统一接口主路径查询
This commit is contained in:
daxpay
2026-08-02 10:43:38 +08:00
parent 417d0e832e
commit e27748840f
14 changed files with 437 additions and 1 deletions

View File

@@ -23,6 +23,21 @@ public class RefundOrderManager extends BaseManager<RefundOrderMapper, RefundOrd
return findByField(RefundOrder::getRefundNo, refundNo);
}
/// 根据商户退款号查询(按商户号自动租户隔离)
public Optional<RefundOrder> findByBizRefundNo(String bizRefundNo) {
return lambdaQuery()
.eq(RefundOrder::getBizRefundNo, bizRefundNo)
.oneOpt();
}
/// 根据商户退款号和应用号查询(统一接口主路径, 避免同商户多应用串单)
public Optional<RefundOrder> findByBizRefundNo(String bizRefundNo, String appId) {
return lambdaQuery()
.eq(RefundOrder::getBizRefundNo, bizRefundNo)
.eq(RefundOrder::getAppId, appId)
.oneOpt();
}
/// 根据实际上送串查询(回调容错: 特殊通道仅回传变形号)
public Optional<RefundOrder> findByRelationOrderNo(String relationOrderNo) {
return findByField(RefundOrder::getRelationOrderNo, relationOrderNo);

View File

@@ -0,0 +1,28 @@
package cn.daxpay.open.payment.unipay.param.trade.refund;
import cn.daxpay.open.payment.unipay.param.MerchantPaymentCommonParam;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
/// # 退款单查询参数
///
/// 平台退款号与商户退款号至少传一个, 优先使用平台退款号。
/// 仅查询本地退款单, 不调用通道; 需要实时通道状态请走退款同步接口。
@EqualsAndHashCode(callSuper = true)
@Data
@Schema(title = "退款单查询参数")
public class RefundOrderQueryParam extends MerchantPaymentCommonParam {
/// 平台退款号
@Schema(description = "平台退款号")
@Size(max = 100, message = "{validation.field.refundNo.size}")
private String refundNo;
/// 商户退款号
@Schema(description = "商户退款号")
@Size(max = 100, message = "{validation.field.bizRefundNo.size}")
private String bizRefundNo;
}

View File

@@ -0,0 +1,50 @@
package cn.daxpay.open.payment.unipay.param.trade.refund;
import cn.daxpay.open.payment.unipay.param.MerchantPaymentCommonParam;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
/// # 统一退款发起参数
///
/// 从已支付订单发起退款, 支持部分退款。
/// 原支付单定位: tradeNo(资金交易号) 与 bizOrderNo(商户业务单号) 至少传一个, 优先使用 tradeNo。
/// 与内部编排参数 [cn.daxpay.open.payment.trade.runtime.param.RefundParam] 同名但职责不同:
/// 本类是对外签名 DTO(含 mchNo/appId/sign), Controller 层负责转换。
@EqualsAndHashCode(callSuper = true)
@Data
@Schema(title = "统一退款参数")
public class RefundParam extends MerchantPaymentCommonParam {
/// 原支付资金交易号(平台 tradeNo)
@Schema(description = "原支付资金交易号")
@Size(max = 100, message = "{validation.field.tradeNo.size}")
private String tradeNo;
/// 原支付商户业务订单号
@Schema(description = "原支付商户业务订单号")
@Size(max = 100, message = "{validation.field.bizOrderNo.size}")
private String bizOrderNo;
/// 退款金额(单位: 分, 最小货币单位)
@Schema(description = "退款金额(分)")
@NotNull(message = "{validation.field.amount.notNull}")
@Positive(message = "{validation.field.amount.positive}")
@Min(value = 1, message = "{validation.field.amount.min}")
private Long amount;
/// 退款原因
@Schema(description = "退款原因")
@Size(max = 50, message = "{validation.field.reason.size}")
private String reason;
/// 商户退款号(可选, 不传则由系统生成)
@Schema(description = "商户退款号")
@Size(max = 100, message = "{validation.field.bizRefundNo.size}")
private String bizRefundNo;
}

View File

@@ -0,0 +1,28 @@
package cn.daxpay.open.payment.unipay.param.trade.refund;
import cn.daxpay.open.payment.unipay.param.MerchantPaymentCommonParam;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
/// # 退款状态同步参数
///
/// 主动查询通道网关方退款终态并回写本地退款单。
/// 平台退款号与商户退款号至少传一个, 优先使用平台退款号。
@EqualsAndHashCode(callSuper = true)
@Data
@Schema(title = "退款状态同步参数")
public class RefundSyncParam extends MerchantPaymentCommonParam {
/// 平台退款号
@Schema(description = "平台退款号")
@Size(max = 100, message = "{validation.field.refundNo.size}")
private String refundNo;
/// 商户退款号
@Schema(description = "商户退款号")
@Size(max = 100, message = "{validation.field.bizRefundNo.size}")
private String bizRefundNo;
}

View File

@@ -0,0 +1,64 @@
package cn.daxpay.open.payment.unipay.result.trade.refund;
import cn.daxpay.open.payment.trade.enums.RefundOrderStatusEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
import java.time.OffsetDateTime;
/// # 退款订单(统一查询)
///
/// 对外查询结果(精简), 不复用管理端 [cn.daxpay.open.payment.trade.order.result.RefundOrderResult]
/// (后者含 mchName 翻译、channelMchNo 等内部字段, 不宜对商户暴露)。
@Data
@Accessors(chain = true)
@Schema(title = "退款订单")
public class RefundOrderResult {
/// 平台退款号
@Schema(description = "平台退款号")
private String refundNo;
/// 商户退款号
@Schema(description = "商户退款号")
private String bizRefundNo;
/// 原支付资金交易号
@Schema(description = "原支付资金交易号")
private String tradeNo;
/// 原支付商户业务订单号
@Schema(description = "原支付商户业务订单号")
private String bizOrderNo;
/// 通道退款流水号
@Schema(description = "通道退款流水号")
private String outRefundNo;
/// 退款金额(分)
@Schema(description = "退款金额(分)")
private Long amount;
/// 订单总金额(分)
@Schema(description = "订单总金额(分)")
private Long orderAmount;
/// 退款状态
/// @see RefundOrderStatusEnum
@Schema(description = "退款状态")
private String status;
/// 退款原因
@Schema(description = "退款原因")
private String reason;
/// 退款完成时间(UTC)
@Schema(description = "退款完成时间(UTC)")
private OffsetDateTime finishTime;
/// 错误信息
@Schema(description = "错误信息")
private String errorMsg;
}

View File

@@ -0,0 +1,32 @@
package cn.daxpay.open.payment.unipay.result.trade.refund;
import cn.daxpay.open.payment.trade.enums.RefundOrderStatusEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 统一退款响应参数
///
@Data
@Accessors(chain = true)
@Schema(title = "统一退款响应参数")
public class RefundResult {
/// 平台退款号
@Schema(description = "平台退款号")
private String refundNo;
/// 商户退款号
@Schema(description = "商户退款号")
private String bizRefundNo;
/// 退款状态
/// @see RefundOrderStatusEnum
@Schema(description = "退款状态")
private String status;
/// 错误信息(失败时返回)
@Schema(description = "错误信息")
private String errorMsg;
}

View File

@@ -0,0 +1,24 @@
package cn.daxpay.open.payment.unipay.result.trade.refund;
import cn.daxpay.open.payment.trade.enums.RefundOrderStatusEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 退款同步结果
///
@Data
@Accessors(chain = true)
@Schema(title = "退款同步结果")
public class RefundSyncResult {
/// 同步后的退款订单状态
/// @see RefundOrderStatusEnum
@Schema(description = "同步后退款状态")
private String orderStatus;
/// 是否触发了调整(本地状态因本次同步发生了变更)
@Schema(description = "是否触发了调整")
private boolean adjust;
}

View File

@@ -2,12 +2,15 @@ package cn.daxpay.open.payment.unipay.trade.controller;
import cn.daxpay.open.platform.core.annotation.IgnoreAuth;
import cn.daxpay.open.payment.unipay.param.trade.pay.NormalPayQueryParam;
import cn.daxpay.open.payment.unipay.param.trade.refund.RefundOrderQueryParam;
import cn.daxpay.open.payment.common.result.DaxResult;
import cn.daxpay.open.payment.unipay.result.trade.pay.NormalPayOrderResult;
import cn.daxpay.open.payment.unipay.result.trade.refund.RefundOrderResult;
import cn.daxpay.open.payment.common.util.DaxRes;
import cn.daxpay.open.payment.unipay.aop.PaymentVerify;
import cn.daxpay.open.payment.unipay.trade.service.NormalPayOrderQueryService;
import cn.daxpay.open.payment.unipay.trade.service.RefundOrderQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
@@ -27,6 +30,7 @@ import org.springframework.web.bind.annotation.RestController;
public class UniQueryController {
private final NormalPayOrderQueryService normalPayOrderQueryService;
private final RefundOrderQueryService refundOrderQueryService;
@Operation(summary = "支付订单查询接口")
@PostMapping("/pay-order")
@@ -34,4 +38,10 @@ public class UniQueryController {
return DaxRes.ok(normalPayOrderQueryService.queryPayOrder(param));
}
@Operation(summary = "退款订单查询接口")
@PostMapping("/refund-order")
public DaxResult<RefundOrderResult> queryRefundOrder(@RequestBody RefundOrderQueryParam param){
return DaxRes.ok(refundOrderQueryService.queryRefundOrder(param));
}
}

View File

@@ -2,12 +2,15 @@ package cn.daxpay.open.payment.unipay.trade.controller;
import cn.daxpay.open.platform.core.annotation.IgnoreAuth;
import cn.daxpay.open.payment.unipay.param.trade.pay.NormalPaySyncParam;
import cn.daxpay.open.payment.unipay.param.trade.refund.RefundSyncParam;
import cn.daxpay.open.payment.common.result.DaxResult;
import cn.daxpay.open.payment.unipay.result.trade.pay.NormalPaySyncResult;
import cn.daxpay.open.payment.unipay.result.trade.refund.RefundSyncResult;
import cn.daxpay.open.payment.common.util.DaxRes;
import cn.daxpay.open.payment.unipay.aop.PaymentVerify;
import cn.daxpay.open.payment.trade.runtime.service.sync.PaySyncService;
import cn.daxpay.open.payment.unipay.trade.service.RefundOrderSyncService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -28,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController;
public class UniSyncController {
private final PaySyncService paySyncService;
private final RefundOrderSyncService refundOrderSyncService;
@Operation(summary = "支付订单同步接口")
@PostMapping("/pay")
@@ -35,4 +39,10 @@ public class UniSyncController {
return DaxRes.ok(paySyncService.sync(param));
}
@Operation(summary = "退款订单同步接口")
@PostMapping("/refund")
public DaxResult<RefundSyncResult> refund(@RequestBody RefundSyncParam param){
return DaxRes.ok(refundOrderSyncService.sync(param));
}
}

View File

@@ -3,12 +3,15 @@ package cn.daxpay.open.payment.unipay.trade.controller;
import cn.daxpay.open.platform.core.annotation.IgnoreAuth;
import cn.daxpay.open.payment.common.result.DaxResult;
import cn.daxpay.open.payment.common.util.DaxRes;
import cn.daxpay.open.payment.unipay.aop.PaymentVerify;
import cn.daxpay.open.payment.trade.runtime.service.close.PayCloseService;
import cn.daxpay.open.payment.trade.runtime.service.pay.normal.NormalPayService;
import cn.daxpay.open.payment.unipay.aop.PaymentVerify;
import cn.daxpay.open.payment.unipay.param.trade.pay.NormalPayCloseParam;
import cn.daxpay.open.payment.unipay.param.trade.pay.NormalPayParam;
import cn.daxpay.open.payment.unipay.param.trade.refund.RefundParam;
import cn.daxpay.open.payment.unipay.result.trade.pay.NormalPayResult;
import cn.daxpay.open.payment.unipay.result.trade.refund.RefundResult;
import cn.daxpay.open.payment.unipay.trade.service.RefundOrderService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
@@ -28,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController;
public class UniTradeController {
private final NormalPayService normalPayService;
private final PayCloseService payCloseService;
private final RefundOrderService refundOrderService;
@Operation(summary = "支付接口")
@PostMapping("/pay")
@@ -42,4 +46,10 @@ public class UniTradeController {
return DaxRes.ok();
}
@Operation(summary = "退款接口")
@PostMapping("/refund")
public DaxResult<RefundResult> refund(@RequestBody RefundParam param){
return DaxRes.ok(refundOrderService.refund(param));
}
}

View File

@@ -0,0 +1,29 @@
package cn.daxpay.open.payment.unipay.trade.convert;
import cn.daxpay.open.payment.trade.order.entity.RefundOrder;
import cn.daxpay.open.payment.unipay.param.trade.refund.RefundParam;
import cn.daxpay.open.payment.unipay.result.trade.refund.RefundOrderResult;
import cn.daxpay.open.payment.unipay.result.trade.refund.RefundResult;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
/// # 退款转换器(对外)
///
/// 汇聚退款场景的对外映射:
/// - 退款单实体 → 对外查询结果 [RefundOrderResult] (内部字段 mchNo/channelMchNo 等自动忽略)
/// - 退款单实体 → 发起响应 [RefundResult] (仅退款号/状态等对外字段)
/// - 对外签名入参 [RefundParam] → 内部编排参数 (丢弃 mchNo/appId/sign 等签名字段)
@Mapper
public interface UnipayRefundOrderConvert {
UnipayRefundOrderConvert CONVERT = Mappers.getMapper(UnipayRefundOrderConvert.class);
/// 退款单实体 → 对外查询结果(同名映射)
RefundOrderResult toResult(RefundOrder order);
/// 退款单实体 → 发起响应(同名映射)
RefundResult toRefundResult(RefundOrder order);
/// 对外签名入参 → 内部编排参数(同名字段映射, 签名/商户字段无目标自动丢弃)
cn.daxpay.open.payment.trade.runtime.param.RefundParam toRuntime(RefundParam param);
}

View File

@@ -0,0 +1,49 @@
package cn.daxpay.open.payment.unipay.trade.service;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.daxpay.open.payment.trade.order.dao.RefundOrderManager;
import cn.daxpay.open.payment.trade.order.entity.RefundOrder;
import cn.daxpay.open.payment.unipay.param.trade.refund.RefundOrderQueryParam;
import cn.daxpay.open.payment.unipay.result.trade.refund.RefundOrderResult;
import cn.daxpay.open.payment.unipay.trade.convert.UnipayRefundOrderConvert;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/// # 退款订单查询服务(对外)
///
/// 纯查本地退款单, 不调用通道; 需要实时通道状态请走退款同步接口。
/// 支持按平台退款号(refundNo)或商户退款号(bizRefundNo)查询, 优先使用平台退款号。
/// 按商户退款号查询时绑定 appId, 避免同商户多应用串单。
@Slf4j
@Service
@RequiredArgsConstructor
public class RefundOrderQueryService {
private final RefundOrderManager refundOrderManager;
/// 查询退款订单
public RefundOrderResult queryRefundOrder(RefundOrderQueryParam param) {
// 校验参数, 平台退款号和商户退款号不能都为空
if (StrUtil.isBlank(param.getRefundNo()) && StrUtil.isBlank(param.getBizRefundNo())) {
// 退款: 退款号不能都为空(复用统一接口层通用单号校验 key)
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.orderNoRequired");
}
RefundOrder order;
// 优先按平台退款号查询
if (StrUtil.isNotBlank(param.getRefundNo())) {
order = refundOrderManager.findByRefundNo(param.getRefundNo())
// 退款: 退款订单不存在
.orElseThrow(() -> new DataNotExistException("pay.error.refund.orderNotFound"));
} else {
// 按商户退款号 + 应用号查询(避免串单)
order = refundOrderManager.findByBizRefundNo(param.getBizRefundNo(), param.getAppId())
.orElseThrow(() -> new DataNotExistException("pay.error.refund.orderNotFound"));
}
return UnipayRefundOrderConvert.CONVERT.toResult(order);
}
}

View File

@@ -0,0 +1,28 @@
package cn.daxpay.open.payment.unipay.trade.service;
import cn.daxpay.open.payment.trade.order.entity.RefundOrder;
import cn.daxpay.open.payment.trade.runtime.service.refund.RefundService;
import cn.daxpay.open.payment.unipay.param.trade.refund.RefundParam;
import cn.daxpay.open.payment.unipay.result.trade.refund.RefundResult;
import cn.daxpay.open.payment.unipay.trade.convert.UnipayRefundOrderConvert;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/// # 退款发起服务(对外)
///
/// 对外统一退款的编排入口: 对外签名入参 → 内部编排参数, 委托核心 [RefundService] 建单调通道,
/// 再将退款单实体映射为对外响应 [RefundResult]。
/// 核心层只认内部 [cn.daxpay.open.payment.trade.runtime.param.RefundParam], 对外签名字段(mchNo/appId/sign)在此剥离。
/// 与退款查询/同步的 [RefundOrderQueryService] / [RefundOrderSyncService] 并列。
@Service
@RequiredArgsConstructor
public class RefundOrderService {
private final RefundService refundService;
/// 发起退款
public RefundResult refund(RefundParam param) {
RefundOrder refundOrder = refundService.refund(UnipayRefundOrderConvert.CONVERT.toRuntime(param));
return UnipayRefundOrderConvert.CONVERT.toRefundResult(refundOrder);
}
}

View File

@@ -0,0 +1,59 @@
package cn.daxpay.open.payment.unipay.trade.service;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.daxpay.open.payment.trade.order.dao.RefundOrderManager;
import cn.daxpay.open.payment.trade.order.entity.RefundOrder;
import cn.daxpay.open.payment.trade.runtime.service.refund.RefundSyncService;
import cn.daxpay.open.payment.unipay.param.trade.refund.RefundSyncParam;
import cn.daxpay.open.payment.unipay.result.trade.refund.RefundSyncResult;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Objects;
/// # 退款订单同步服务(对外)
///
/// 对外统一入口: 按平台退款号或商户退款号定位退款单, 委托 [RefundSyncService] 查询通道终态回写。
/// adjust 标记: 同步前后本地状态发生变化即为 true, 让商户感知本次同步是否触发了状态调整。
/// 与支付同步 [cn.daxpay.open.payment.trade.runtime.service.sync.PaySyncService] 的 adjust 语义对齐。
@Slf4j
@Service
@RequiredArgsConstructor
public class RefundOrderSyncService {
private final RefundOrderManager refundOrderManager;
private final RefundSyncService refundSyncService;
/// 退款同步
public RefundSyncResult sync(RefundSyncParam param) {
// 校验参数, 平台退款号和商户退款号不能都为空
if (StrUtil.isBlank(param.getRefundNo()) && StrUtil.isBlank(param.getBizRefundNo())) {
// 退款: 退款号不能都为空(复用统一接口层通用单号校验 key)
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.orderNoRequired");
}
// 按号定位退款单(优先平台退款号)
RefundOrder refundOrder;
if (StrUtil.isNotBlank(param.getRefundNo())) {
refundOrder = refundOrderManager.findByRefundNo(param.getRefundNo())
.orElseThrow(() -> new DataNotExistException("pay.error.refund.orderNotFound"));
} else {
refundOrder = refundOrderManager.findByBizRefundNo(param.getBizRefundNo(), param.getAppId())
.orElseThrow(() -> new DataNotExistException("pay.error.refund.orderNotFound"));
}
// 记录同步前状态, 同步后比较得出是否调整
String statusBefore = refundOrder.getStatus();
RefundOrder synced = refundSyncService.sync(refundOrder);
String statusAfter = synced.getStatus();
boolean adjust = !Objects.equals(statusBefore, statusAfter);
return new RefundSyncResult()
.setOrderStatus(statusAfter)
.setAdjust(adjust);
}
}