feat(payment): 新增网关支付(聚合扫码)领域与统一处理回写

落地 GatewayPayOrder 实体/管理端与 Uni 接口、商户聚合配置、超时 MQ/Job,并按 trade_type 回写普通/网关业务容器;补充收银台场景与网关相关枚举 i18n。
This commit is contained in:
DaxPay Dev
2026-07-11 09:27:22 +08:00
parent 1130c523a5
commit e76058d05e
49 changed files with 2112 additions and 48 deletions

View File

@@ -0,0 +1,38 @@
package cn.daxpay.open.payment.common.enums;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.daxpay.open.platform.core.i18n.I18nSupport;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
/// # 收银场景(聚合扫码环境识别)
///
/// 字典: cashier_scene
@Getter
@RequiredArgsConstructor
public enum CashierSceneEnum implements I18nSupport {
/// 微信环境
WECHAT_PAY("wechat_pay"),
/// 支付宝环境
ALIPAY("alipay"),
/// 云闪付环境
UNION_PAY("union_pay"),
;
private final String code;
@Override
public String getI18nPrefix() {
return "enum.cashier_scene";
}
public static CashierSceneEnum findByCode(String code) {
return Arrays.stream(values())
.filter(e -> e.getCode().equals(code))
.findFirst()
.orElseThrow(() -> new DataNotExistException("error.common.cashierSceneNotExist", code));
}
}

View File

@@ -0,0 +1,42 @@
package cn.daxpay.open.payment.common.enums;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.daxpay.open.platform.core.i18n.I18nSupport;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
/// # 网关支付业务单状态
///
/// 字典: gateway_order_status
@Getter
@RequiredArgsConstructor
public enum GatewayOrderStatusEnum implements I18nSupport {
/// 待支付(已预下单, 未建 Trade)
WAIT_PAY("wait_pay"),
/// 支付中(已建 Trade, 通道处理中)
PAYING("paying"),
/// 已支付
PAID("paid"),
/// 已关闭
CLOSED("closed"),
/// 已过期
EXPIRED("expired"),
;
private final String code;
@Override
public String getI18nPrefix() {
return "enum.gateway_order_status";
}
public static GatewayOrderStatusEnum findByCode(String code) {
return Arrays.stream(values())
.filter(e -> e.getCode().equals(code))
.findFirst()
.orElseThrow(() -> new DataNotExistException("error.common.gatewayOrderStatusNotExist", code));
}
}

View File

@@ -0,0 +1,36 @@
package cn.daxpay.open.payment.common.enums;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.daxpay.open.platform.core.i18n.I18nSupport;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
/// # 网关支付类型
///
/// 字典: gateway_pay_type
@Getter
@RequiredArgsConstructor
public enum GatewayPayTypeEnum implements I18nSupport {
/// 统一收银台(设备自适应, 本期仅预留下单类型)
CASHIER("cashier"),
/// 聚合扫码一码多付
AGGREGATE("aggregate"),
;
private final String code;
@Override
public String getI18nPrefix() {
return "enum.gateway_pay_type";
}
public static GatewayPayTypeEnum findByCode(String code) {
return Arrays.stream(values())
.filter(e -> e.getCode().equals(code))
.findFirst()
.orElseThrow(() -> new DataNotExistException("error.common.gatewayPayTypeNotExist", code));
}
}

View File

@@ -17,6 +17,8 @@ public enum PayTradeTypeEnum implements I18nSupport {
/// 普通支付
NORMAL("normal"),
/// 网关支付(聚合扫码/收银台)
GATEWAY("gateway"),
/// 预授权冻结
AUTHORIZE("authorize"),
/// 预授权捕获扣款

View File

@@ -0,0 +1,62 @@
package cn.daxpay.open.payment.core.controller.admin;
import cn.daxpay.open.payment.core.trade.param.GatewayPayOrderQuery;
import cn.daxpay.open.payment.core.trade.result.GatewayPayOrderResult;
import cn.daxpay.open.payment.core.trade.service.admin.GatewayPayOrderAdminService;
import cn.daxpay.open.payment.unipay.result.trade.pay.NormalPaySyncResult;
import cn.daxpay.open.platform.core.annotation.PermCode;
import cn.daxpay.open.platform.core.rest.Res;
import cn.daxpay.open.platform.core.rest.param.PageParam;
import cn.daxpay.open.platform.core.rest.result.PageResult;
import cn.daxpay.open.platform.core.rest.result.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
/// # 网关支付业务单(管理)
@PermCode(menuCode = "payment:gateway-order")
@Validated
@Tag(name = "网关支付业务单(管理)")
@RestController
@RequestMapping("/admin/order/gateway-pay")
@RequiredArgsConstructor
public class GatewayPayOrderAdminController {
private final GatewayPayOrderAdminService gatewayPayOrderAdminService;
@PermCode(code = "view", nameCn = "订单查看", nameEn = "Order View")
@Operation(summary = "网关支付业务单分页")
@GetMapping("/page")
public Result<PageResult<GatewayPayOrderResult>> page(PageParam pageParam, GatewayPayOrderQuery query) {
return Res.ok(gatewayPayOrderAdminService.page(pageParam, query));
}
@PermCode(code = "view", nameCn = "订单查看", nameEn = "Order View")
@Operation(summary = "根据ID查询详情")
@GetMapping("/get-by-id")
public Result<GatewayPayOrderResult> findById(
@NotNull(message = "{validation.field.id.notNull}") Long id) {
return Res.ok(gatewayPayOrderAdminService.findById(id));
}
@PermCode(code = "manage", nameCn = "订单管理", nameEn = "Order Manage")
@Operation(summary = "同步支付状态")
@PostMapping("/sync")
public Result<NormalPaySyncResult> sync(
@NotNull(message = "{validation.field.id.notNull}") Long id) {
return Res.ok(gatewayPayOrderAdminService.sync(id));
}
@PermCode(code = "manage", nameCn = "订单管理", nameEn = "Order Manage")
@Operation(summary = "关闭订单")
@PostMapping("/close")
public Result<Void> close(
@NotNull(message = "{validation.field.id.notNull}") Long id,
@RequestParam(defaultValue = "false") boolean useCancel) {
gatewayPayOrderAdminService.close(id, useCancel);
return Res.ok();
}
}

View File

@@ -0,0 +1,15 @@
package cn.daxpay.open.payment.core.trade.convert;
import cn.daxpay.open.payment.core.trade.entity.GatewayPayOrder;
import cn.daxpay.open.payment.core.trade.result.GatewayPayOrderResult;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
/// # 网关支付业务单转换
@Mapper
public interface GatewayPayOrderConvert {
GatewayPayOrderConvert CONVERT = Mappers.getMapper(GatewayPayOrderConvert.class);
GatewayPayOrderResult toResult(GatewayPayOrder entity);
}

View File

@@ -0,0 +1,66 @@
package cn.daxpay.open.payment.core.trade.dao;
import cn.daxpay.open.payment.common.enums.GatewayOrderStatusEnum;
import cn.daxpay.open.payment.core.trade.entity.GatewayPayOrder;
import cn.daxpay.open.payment.core.trade.param.GatewayPayOrderQuery;
import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager;
import cn.daxpay.open.platform.common.mybatisplus.query.generator.QueryGenerator;
import cn.daxpay.open.platform.common.mybatisplus.util.MpUtil;
import cn.daxpay.open.platform.core.annotation.IgnoreTenant;
import cn.daxpay.open.platform.core.rest.param.PageParam;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.stereotype.Repository;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
/// # 网关支付业务单管理器
@Repository
public class GatewayPayOrderManager extends BaseManager<GatewayPayOrderMapper, GatewayPayOrder> {
/// 按平台网关单号查询
public Optional<GatewayPayOrder> findByOrderNo(String orderNo) {
return lambdaQuery()
.eq(GatewayPayOrder::getOrderNo, orderNo)
.oneOpt();
}
/// 按平台网关单号查询(忽略租户, H5 落地页用)
@IgnoreTenant
public Optional<GatewayPayOrder> findByOrderNoNotTenant(String orderNo) {
return lambdaQuery()
.eq(GatewayPayOrder::getOrderNo, orderNo)
.oneOpt();
}
/// 按商户业务单号 + 应用号查询
public Optional<GatewayPayOrder> findByBizOrderNo(String bizOrderNo, String appId) {
return lambdaQuery()
.eq(GatewayPayOrder::getBizOrderNo, bizOrderNo)
.eq(GatewayPayOrder::getAppId, appId)
.oneOpt();
}
/// 分页查询
public Page<GatewayPayOrder> page(PageParam pageParam, GatewayPayOrderQuery query) {
Page<GatewayPayOrder> mpPage = MpUtil.getMpPage(pageParam);
QueryWrapper<GatewayPayOrder> wrapper = QueryGenerator.generator(query);
wrapper.orderByDesc("create_time");
return this.page(mpPage, wrapper);
}
/// 已过期仍待支付/支付中的网关单(超时兜底)
@IgnoreTenant
public List<GatewayPayOrder> findTimeoutOrders(OffsetDateTime now) {
return lambdaQuery()
.in(GatewayPayOrder::getStatus,
GatewayOrderStatusEnum.WAIT_PAY.getCode(),
GatewayOrderStatusEnum.PAYING.getCode())
.lt(GatewayPayOrder::getExpiredTime, now)
.orderByAsc(GatewayPayOrder::getExpiredTime)
.last("limit 500")
.list();
}
}

View File

@@ -0,0 +1,10 @@
package cn.daxpay.open.payment.core.trade.dao;
import cn.daxpay.open.payment.core.trade.entity.GatewayPayOrder;
import com.github.yulichang.base.MPJBaseMapper;
import org.apache.ibatis.annotations.Mapper;
/// # 网关支付业务单 Mapper
@Mapper
public interface GatewayPayOrderMapper extends MPJBaseMapper<GatewayPayOrder> {
}

View File

@@ -49,6 +49,26 @@ public class PayTradeManager extends BaseManager<PayTradeMapper, PayTrade> {
.oneOpt();
}
/// 根据容器ID + 交易形态查询
public Optional<PayTrade> findByContainerId(Long containerId, String tradeType) {
return lambdaQuery()
.eq(PayTrade::getContainerId, containerId)
.eq(PayTrade::getTradeType, tradeType)
.oneOpt();
}
/// 查询网关支付已超时但仍处理中的资金交易(兜底)
@IgnoreTenant
public List<PayTrade> findGatewayTimeoutTrades(OffsetDateTime now) {
return lambdaQuery()
.eq(PayTrade::getTradeType, PayTradeTypeEnum.GATEWAY.getCode())
.eq(PayTrade::getStatus, PayFundStatusEnum.PROCESSING.getCode())
.lt(PayTrade::getExpiredTime, now)
.orderByAsc(PayTrade::getExpiredTime)
.last("limit 500")
.list();
}
/// 分页查询(管理端), 默认按创建时间倒序
public Page<PayTrade> page(PageParam pageParam, PayTradeQuery query) {
Page<PayTrade> mpPage = MpUtil.getMpPage(pageParam);

View File

@@ -0,0 +1,112 @@
package cn.daxpay.open.payment.core.trade.entity;
import cn.daxpay.open.payment.common.entity.merchant.MchBaseEntity;
import cn.daxpay.open.payment.common.enums.GatewayOrderStatusEnum;
import cn.daxpay.open.payment.common.enums.GatewayPayTypeEnum;
import cn.daxpay.open.payment.unipay.param.trade.pay.GoodsDetail;
import cn.daxpay.open.platform.core.enums.pay.channel.CurrencyEnum;
import cn.daxpay.open.platform.core.enums.pay.channel.PayMethodEnum;
import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.time.OffsetDateTime;
import java.util.List;
/// # 网关支付业务单容器
///
/// 聚合扫码/收银台预下单场景: 创建时不知具体通道, 仅承载收款意图。
/// 用户真正支付时再创建 pay_trade(trade_type=gateway) 并回填 channel/product/method。
@Data
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
@TableName(value = "pay_gateway_order", autoResultMap = true)
public class GatewayPayOrder extends MchBaseEntity {
/// 应用号
@TableField(updateStrategy = FieldStrategy.NEVER)
private String appId;
/// 平台网关单号(URL 用)
private String orderNo;
/// 商户业务单号
private String bizOrderNo;
/// 网关类型
/// @see GatewayPayTypeEnum
private String gatewayType;
/// 标题
private String title;
/// 描述
private String description;
/// 业务状态
/// @see GatewayOrderStatusEnum
private String status;
/// 异步通知地址
private String notifyUrl;
/// 同步跳转地址
private String returnUrl;
/// 商户附加参数
private String attach;
/// 过期时间
private OffsetDateTime expiredTime;
/// 金额(最小货币单位)
private Long amount;
/// 币种
/// @see CurrencyEnum
private String currency;
/// 支付通道(支付后冗余)
private String channel;
/// 支付方式
/// @see PayMethodEnum
private String method;
/// 支付产品
/// @see ProductEnum
private String product;
/// 支付能力(路由回填)
private String capability;
/// 通道商户号(路由回填)
private String channelMchNo;
/// 收银场景 wechat_pay/alipay/union_pay
private String scene;
/// 最后发起设备 mobile/pc
private String device;
/// 支付成功时间
private OffsetDateTime payTime;
/// 关闭时间
private OffsetDateTime closeTime;
/// 客户端 IP
private String clientIp;
/// 终端设备编码
private String terminalNo;
/// 商品明细
@TableField(typeHandler = JacksonTypeHandler.class)
private List<GoodsDetail> goodsDetail;
}

View File

@@ -0,0 +1,57 @@
package cn.daxpay.open.payment.core.trade.job;
import cn.daxpay.open.payment.core.trade.dao.GatewayPayOrderManager;
import cn.daxpay.open.payment.core.trade.dao.PayTradeManager;
import cn.daxpay.open.payment.core.trade.entity.GatewayPayOrder;
import cn.daxpay.open.payment.core.trade.entity.PayTrade;
import cn.daxpay.open.payment.core.trade.service.GatewayTimeoutService;
import cn.daxpay.open.payment.core.trade.service.PayCloseService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
/// # 网关支付超时关单兜底定时任务
@Slf4j
@Component
@RequiredArgsConstructor
public class GatewayTimeoutJob {
private final GatewayPayOrderManager gatewayPayOrderManager;
private final PayTradeManager payTradeManager;
private final GatewayTimeoutService gatewayTimeoutService;
private final PayCloseService payCloseService;
@Scheduled(fixedDelay = 300_000L)
public void scanTimeoutOrders() {
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
// 1. 容器层超时(含尚未建 Trade 的 wait_pay)
List<GatewayPayOrder> timeoutOrders = gatewayPayOrderManager.findTimeoutOrders(now);
if (!timeoutOrders.isEmpty()) {
log.info("网关超时关单兜底扫描命中容器 {} 笔", timeoutOrders.size());
for (GatewayPayOrder order : timeoutOrders) {
try {
gatewayTimeoutService.closeForTimeout(order.getOrderNo());
} catch (Exception e) {
log.error("网关超时关单兜底失败, orderNo={}", order.getOrderNo(), e);
}
}
}
// 2. Trade 层超时(processing)
List<PayTrade> timeoutTrades = payTradeManager.findGatewayTimeoutTrades(now);
if (!timeoutTrades.isEmpty()) {
log.info("网关超时关单兜底扫描命中交易 {} 笔", timeoutTrades.size());
for (PayTrade trade : timeoutTrades) {
try {
payCloseService.closeForTimeout(trade.getTradeNo());
} catch (Exception e) {
log.error("网关交易超时关单兜底失败, tradeNo={}", trade.getTradeNo(), e);
}
}
}
}
}

View File

@@ -0,0 +1,34 @@
package cn.daxpay.open.payment.core.trade.mq;
import cn.daxpay.open.payment.core.trade.service.GatewayTimeoutService;
import cn.daxpay.open.platform.common.json.util.JacksonUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jms.annotation.JmsListener;
import org.springframework.stereotype.Component;
/// # 网关支付超时关单消费者
@Slf4j
@Component
@RequiredArgsConstructor
public class GatewayTimeoutConsumer {
private final GatewayTimeoutService gatewayTimeoutService;
@JmsListener(destination = PayArtemisConstants.GATEWAY_TIMEOUT_QUEUE)
public void onMessage(String json) {
GatewayTimeoutMessage message;
try {
message = JacksonUtil.toBean(json, GatewayTimeoutMessage.class);
} catch (Exception e) {
log.warn("网关超时关单消息解析失败, 丢弃: json={}, error={}", json, e.getMessage());
return;
}
try {
gatewayTimeoutService.closeForTimeout(message.getOrderNo());
} catch (Exception e) {
log.error("网关超时关单处理失败, orderNo={}, bizOrderNo={}",
message.getOrderNo(), message.getBizOrderNo(), e);
}
}
}

View File

@@ -0,0 +1,18 @@
package cn.daxpay.open.payment.core.trade.mq;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 网关支付超时关单消息体
///
/// 预下单时按过期时间投递, 消费端只信任 orderNo 重新查库做幂等。
@Data
@Accessors(chain = true)
public class GatewayTimeoutMessage {
/// 平台网关单号
private String orderNo;
/// 商户业务单号(日志追踪)
private String bizOrderNo;
}

View File

@@ -12,4 +12,8 @@ public interface PayArtemisConstants {
/// 下单时通过 `sendDelayAt(...)` 按订单过期时间定时投递,到期后由
/// [NormalPayTimeoutConsumer] 消费触发自动关单。
String NORMAL_TIMEOUT_QUEUE = "pay.normal.timeout";
/// 网关支付超时关单队列 address
/// 预下单时按容器过期时间投递,到期后由 [GatewayTimeoutConsumer] 消费。
String GATEWAY_TIMEOUT_QUEUE = "pay.gateway.timeout";
}

View File

@@ -0,0 +1,55 @@
package cn.daxpay.open.payment.core.trade.param;
import cn.daxpay.open.platform.core.annotation.QueryParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
import java.time.OffsetDateTime;
/// # 网关支付业务单查询参数(管理)
@Data
@Accessors(chain = true)
@Schema(title = "网关支付业务单查询参数")
public class GatewayPayOrderQuery {
@QueryParam(type = QueryParam.CompareTypeEnum.EQ)
@Schema(description = "商户号")
private String mchNo;
@QueryParam(type = QueryParam.CompareTypeEnum.EQ)
@Schema(description = "应用号")
private String appId;
@QueryParam(type = QueryParam.CompareTypeEnum.LIKE)
@Schema(description = "平台网关单号")
private String orderNo;
@QueryParam(type = QueryParam.CompareTypeEnum.LIKE)
@Schema(description = "商户业务单号")
private String bizOrderNo;
@QueryParam(type = QueryParam.CompareTypeEnum.EQ)
@Schema(description = "网关类型")
private String gatewayType;
@QueryParam(type = QueryParam.CompareTypeEnum.EQ)
@Schema(description = "业务状态")
private String status;
@QueryParam(type = QueryParam.CompareTypeEnum.EQ)
@Schema(description = "支付通道")
private String channel;
@QueryParam(type = QueryParam.CompareTypeEnum.EQ)
@Schema(description = "支付产品")
private String product;
@QueryParam(type = QueryParam.CompareTypeEnum.GE, fieldName = "create_time")
@Schema(description = "创建时间-开始")
private OffsetDateTime createTimeStart;
@QueryParam(type = QueryParam.CompareTypeEnum.LE, fieldName = "create_time")
@Schema(description = "创建时间-结束")
private OffsetDateTime createTimeEnd;
}

View File

@@ -0,0 +1,96 @@
package cn.daxpay.open.payment.core.trade.result;
import cn.daxpay.open.platform.core.result.BaseResult;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.time.OffsetDateTime;
/// # 网关支付业务单(管理)
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@Schema(title = "网关支付业务单")
public class GatewayPayOrderResult extends BaseResult {
@Schema(description = "商户号")
private String mchNo;
@Schema(description = "应用号")
private String appId;
@Schema(description = "平台网关单号")
private String orderNo;
@Schema(description = "商户业务单号")
private String bizOrderNo;
@Schema(description = "网关类型")
private String gatewayType;
@Schema(description = "标题")
private String title;
@Schema(description = "描述")
private String description;
@Schema(description = "业务状态")
private String status;
@Schema(description = "金额(分)")
private Long amount;
@Schema(description = "币种")
private String currency;
@Schema(description = "过期时间")
private OffsetDateTime expiredTime;
@Schema(description = "支付通道")
private String channel;
@Schema(description = "支付方式")
private String method;
@Schema(description = "支付产品")
private String product;
@Schema(description = "收银场景")
private String scene;
@Schema(description = "设备")
private String device;
@Schema(description = "支付成功时间")
private OffsetDateTime payTime;
@Schema(description = "关闭时间")
private OffsetDateTime closeTime;
@Schema(description = "异步通知地址")
private String notifyUrl;
@Schema(description = "同步跳转地址")
private String returnUrl;
@Schema(description = "商户附加参数")
private String attach;
// ===== Trade 联表 =====
@Schema(description = "资金交易号")
private String tradeNo;
@Schema(description = "通道订单号")
private String outOrderNo;
@Schema(description = "资金状态")
private String fundStatus;
@Schema(description = "可退金额")
private Long refundableBalance;
@Schema(description = "错误信息")
private String errorMsg;
}

View File

@@ -0,0 +1,64 @@
package cn.daxpay.open.payment.core.trade.service;
import cn.daxpay.open.payment.common.enums.CashierSceneEnum;
import cn.daxpay.open.payment.common.enums.GatewayPayTypeEnum;
import cn.daxpay.open.payment.core.trade.entity.GatewayPayOrder;
import cn.daxpay.open.payment.merchant.entity.gateway.GatewayAggregateConfig;
import cn.daxpay.open.payment.merchant.service.gateway.GatewayAggregateConfigService;
import cn.daxpay.open.payment.unipay.param.gateway.AggregateQrPayParam;
import cn.daxpay.open.payment.unipay.result.trade.pay.NormalPayResult;
import cn.daxpay.open.platform.common.spring.util.WebServletUtil;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Objects;
/// # 网关聚合扫码支付服务
@Slf4j
@Service
@RequiredArgsConstructor
public class GatewayAggregatePayService {
private final GatewayPayAssistService gatewayPayAssistService;
private final GatewayAggregateConfigService gatewayAggregateConfigService;
private final GatewayPayHandleService gatewayPayHandleService;
/// 聚合扫码发起支付
public NormalPayResult aggregateQrPay(AggregateQrPayParam param) {
GatewayPayOrder order = gatewayPayAssistService.getOrderAndCheck(param.getOrderNo());
if (!Objects.equals(order.getGatewayType(), GatewayPayTypeEnum.AGGREGATE.getCode())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.gateway.typeMismatch");
}
CashierSceneEnum scene = CashierSceneEnum.findByCode(param.getScene());
GatewayAggregateConfig config = gatewayAggregateConfigService.getRequiredByAppId(order.getAppId());
String product;
String method;
switch (scene) {
case WECHAT_PAY -> {
product = config.getWxProduct();
method = config.getWxMethod();
}
case ALIPAY -> {
product = config.getAlipayProduct();
method = config.getAlipayMethod();
}
case UNION_PAY -> {
product = config.getUnionProduct();
method = config.getUnionMethod();
}
default -> throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.sceneNotSupport");
}
if (StrUtil.isBlank(product) || StrUtil.isBlank(method)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.sceneNotConfigured");
}
String clientIp = StrUtil.blankToDefault(param.getClientIp(), WebServletUtil.getClientIp());
return gatewayPayHandleService.handle(order, product, method,
param.getOpenId(), scene.getCode(), param.getDevice(), clientIp);
}
}

View File

@@ -0,0 +1,78 @@
package cn.daxpay.open.payment.core.trade.service;
import cn.daxpay.open.payment.common.enums.PayTradeTypeEnum;
import cn.daxpay.open.payment.core.trade.dao.GatewayPayOrderManager;
import cn.daxpay.open.payment.core.trade.dao.PayTradeManager;
import cn.daxpay.open.payment.core.trade.entity.GatewayPayOrder;
import cn.daxpay.open.payment.core.trade.entity.PayTrade;
import cn.daxpay.open.payment.unipay.param.gateway.GatewayOrderQueryParam;
import cn.daxpay.open.payment.unipay.result.gateway.GatewayOrderResult;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/// # 网关订单查询服务
@Service
@RequiredArgsConstructor
public class GatewayOrderQueryService {
private final GatewayPayOrderManager gatewayPayOrderManager;
private final PayTradeManager payTradeManager;
public GatewayOrderResult query(GatewayOrderQueryParam param) {
if (StrUtil.isBlank(param.getOrderNo()) && StrUtil.isBlank(param.getBizOrderNo())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.orderNoRequired");
}
GatewayPayOrder order = null;
if (StrUtil.isNotBlank(param.getOrderNo())) {
order = gatewayPayOrderManager.findByOrderNo(param.getOrderNo()).orElse(null);
}
if (order == null && StrUtil.isNotBlank(param.getBizOrderNo())) {
if (StrUtil.isBlank(param.getAppId())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "validation.field.appId.notBlank");
}
order = gatewayPayOrderManager.findByBizOrderNo(param.getBizOrderNo(), param.getAppId()).orElse(null);
}
if (order == null) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.payOrderNotExist");
}
return this.toResult(order);
}
/// H5 侧按 orderNo 查(忽略租户)
public GatewayOrderResult queryByOrderNoNotTenant(String orderNo) {
GatewayPayOrder order = gatewayPayOrderManager.findByOrderNoNotTenant(orderNo)
.orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.payOrderNotExist"));
return this.toResult(order);
}
public GatewayOrderResult toResult(GatewayPayOrder order) {
GatewayOrderResult result = new GatewayOrderResult()
.setOrderNo(order.getOrderNo())
.setBizOrderNo(order.getBizOrderNo())
.setGatewayType(order.getGatewayType())
.setTitle(order.getTitle())
.setDescription(order.getDescription())
.setAmount(order.getAmount())
.setCurrency(order.getCurrency())
.setStatus(order.getStatus())
.setExpiredTime(order.getExpiredTime())
.setPayTime(order.getPayTime())
.setChannel(order.getChannel())
.setMethod(order.getMethod())
.setProduct(order.getProduct())
.setAttach(order.getAttach())
.setReturnUrl(order.getReturnUrl());
PayTrade trade = payTradeManager.findByContainerId(order.getId(), PayTradeTypeEnum.GATEWAY.getCode())
.orElse(null);
if (trade != null) {
result.setTradeNo(trade.getTradeNo())
.setOutOrderNo(trade.getOutOrderNo())
.setFundStatus(trade.getStatus());
}
return result;
}
}

View File

@@ -0,0 +1,182 @@
package cn.daxpay.open.payment.core.trade.service;
import cn.daxpay.open.payment.common.enums.GatewayOrderStatusEnum;
import cn.daxpay.open.payment.common.enums.GatewayPayTypeEnum;
import cn.daxpay.open.payment.common.service.MerchantContextLoader;
import cn.daxpay.open.payment.core.trade.dao.GatewayPayOrderManager;
import cn.daxpay.open.payment.core.trade.entity.GatewayPayOrder;
import cn.daxpay.open.payment.core.trade.mq.GatewayTimeoutMessage;
import cn.daxpay.open.payment.core.trade.mq.PayArtemisConstants;
import cn.daxpay.open.payment.unipay.param.gateway.GatewayPrePayParam;
import cn.daxpay.open.payment.unipay.result.gateway.GatewayPrePayResult;
import cn.daxpay.open.platform.common.artemis.service.ArtemisTemplateService;
import cn.daxpay.open.platform.common.json.util.JacksonUtil;
import cn.daxpay.open.platform.common.spring.util.WebServletUtil;
import cn.daxpay.open.platform.core.code.CommonCode;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.enums.pay.channel.CurrencyEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.exception.operation.OperationFailException;
import cn.daxpay.open.platform.core.util.DateTimeUtil;
import cn.daxpay.open.platform.core.util.TradeNoGenerateUtil;
import cn.daxpay.open.platform.system.entity.config.platform.infra.PlatformUrlConfig;
import cn.daxpay.open.platform.system.service.config.infra.PlatformUrlConfigService;
import cn.hutool.core.util.StrUtil;
import com.baomidou.lock.LockInfo;
import com.baomidou.lock.LockTemplate;
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.time.ZoneOffset;
import java.util.List;
import java.util.Objects;
/// # 网关支付支撑服务
///
/// 预下单建容器、URL 生成、状态校验、超时消息注册。
@Slf4j
@Service
@RequiredArgsConstructor
public class GatewayPayAssistService {
private final GatewayPayOrderManager gatewayPayOrderManager;
private final MerchantContextLoader merchantContextLoader;
private final PlatformUrlConfigService platformUrlConfigService;
private final ArtemisTemplateService artemisTemplateService;
private final LockTemplate lockTemplate;
private final PayAssistService payAssistService;
/// 预下单: 仅创建容器, 返回落地页 URL
public GatewayPrePayResult prePay(GatewayPrePayParam param) {
if (StrUtil.isBlank(param.getClientIp())) {
param.setClientIp(WebServletUtil.getClientIp());
}
payAssistService.validationExpiredTime(param.getExpiredTime());
GatewayPayTypeEnum typeEnum = GatewayPayTypeEnum.findByCode(param.getGatewayPayType());
// 本期仅实现聚合扫码, 收银台类型允许预留但提示
if (typeEnum == GatewayPayTypeEnum.CASHIER) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.gateway.cashierNotReady");
}
var mchApp = merchantContextLoader.resolveApp(param.getMchNo(), param.getAppId());
param.setAppId(mchApp.getAppId());
merchantContextLoader.initMch(param.getMchNo());
LockInfo lock = lockTemplate.lock(
"payment:gateway:pre:" + param.getAppId() + ":" + param.getBizOrderNo(), 10000, 200);
if (Objects.isNull(lock)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.pay.processing");
}
try {
return cn.hutool.extra.spring.SpringUtil.getBean(this.getClass()).doPrePay(param, typeEnum);
} finally {
lockTemplate.releaseLock(lock);
}
}
@Transactional(rollbackFor = Exception.class)
public GatewayPrePayResult doPrePay(GatewayPrePayParam param, GatewayPayTypeEnum typeEnum) {
// 幂等: 已有未终态单则返回原 URL
var existing = gatewayPayOrderManager.findByBizOrderNo(param.getBizOrderNo(), param.getAppId());
if (existing.isPresent()) {
GatewayPayOrder order = existing.get();
String status = order.getStatus();
if (Objects.equals(status, GatewayOrderStatusEnum.PAID.getCode())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.pay.alreadySuccess");
}
if (List.of(GatewayOrderStatusEnum.CLOSED.getCode(), GatewayOrderStatusEnum.EXPIRED.getCode())
.contains(status)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.pay.failedOrClosed");
}
return this.buildPrePayResult(order);
}
OffsetDateTime expiredTime = payAssistService.getExpiredTime(param.getExpiredTime());
String terminalNo = param.getTerminal() != null ? param.getTerminal().getTerminalNo() : null;
GatewayPayOrder order = new GatewayPayOrder();
order.setAppId(param.getAppId());
order.setOrderNo(TradeNoGenerateUtil.pay());
order.setBizOrderNo(param.getBizOrderNo());
order.setGatewayType(typeEnum.getCode());
order.setTitle(param.getTitle());
order.setDescription(param.getDescription());
order.setStatus(GatewayOrderStatusEnum.WAIT_PAY.getCode());
order.setNotifyUrl(param.getNotifyUrl());
order.setReturnUrl(param.getReturnUrl());
order.setAttach(param.getAttach());
order.setExpiredTime(expiredTime);
order.setAmount(param.getAmount());
order.setCurrency(CurrencyEnum.CNY.getCode());
order.setClientIp(param.getClientIp());
order.setTerminalNo(terminalNo);
order.setGoodsDetail(param.getGoodsDetail());
gatewayPayOrderManager.save(order);
this.registerTimeout(order.getOrderNo(), order.getBizOrderNo(), expiredTime);
return this.buildPrePayResult(order);
}
/// 按 orderNo 加载并校验可支付
public GatewayPayOrder getOrderAndCheck(String orderNo) {
GatewayPayOrder order = gatewayPayOrderManager.findByOrderNoNotTenant(orderNo)
.orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.payOrderNotExist"));
this.checkPayable(order);
return order;
}
/// 校验容器可继续支付
public void checkPayable(GatewayPayOrder order) {
String status = order.getStatus();
if (Objects.equals(status, GatewayOrderStatusEnum.PAID.getCode())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.pay.alreadySuccess");
}
if (List.of(GatewayOrderStatusEnum.CLOSED.getCode(), GatewayOrderStatusEnum.EXPIRED.getCode())
.contains(status)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.pay.failedOrClosed");
}
if (Objects.nonNull(order.getExpiredTime())
&& DateTimeUtil.ge(OffsetDateTime.now(ZoneOffset.UTC), order.getExpiredTime())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.pay.timeoutRetry");
}
}
/// 生成落地页 URL
public String buildGatewayUrl(GatewayPayOrder order) {
PlatformUrlConfig urlConfig = platformUrlConfigService.getUrlConfig();
String gatewayBase = urlConfig.getPaymentGatewayBaseUrl();
if (StrUtil.isBlank(gatewayBase)) {
throw new OperationFailException(CommonCode.FAIL_CODE, "error.common.gatewayUrlNotConfigured");
}
gatewayBase = StrUtil.removeSuffix(gatewayBase, "/");
if (Objects.equals(order.getGatewayType(), GatewayPayTypeEnum.AGGREGATE.getCode())) {
return gatewayBase + "/aggregate/" + order.getOrderNo();
}
return gatewayBase + "/cashier/" + order.getOrderNo();
}
public GatewayPrePayResult buildPrePayResult(GatewayPayOrder order) {
return new GatewayPrePayResult()
.setOrderNo(order.getOrderNo())
.setBizOrderNo(order.getBizOrderNo())
.setStatus(order.getStatus())
.setUrl(this.buildGatewayUrl(order))
.setExpiredTime(order.getExpiredTime());
}
private void registerTimeout(String orderNo, String bizOrderNo, OffsetDateTime expiredTime) {
GatewayTimeoutMessage message = new GatewayTimeoutMessage()
.setOrderNo(orderNo)
.setBizOrderNo(bizOrderNo);
String json = JacksonUtil.toJson(message);
try {
artemisTemplateService.sendDelayAt(PayArtemisConstants.GATEWAY_TIMEOUT_QUEUE, json, expiredTime);
} catch (Exception e) {
log.warn("注册网关超时关单延时消息失败, 由定时任务兜底, orderNo={}", orderNo, e);
}
}
}

View File

@@ -0,0 +1,232 @@
package cn.daxpay.open.payment.core.trade.service;
import cn.daxpay.open.payment.common.enums.GatewayOrderStatusEnum;
import cn.daxpay.open.payment.common.enums.PayFundStatusEnum;
import cn.daxpay.open.payment.common.enums.PayTradeTypeEnum;
import cn.daxpay.open.payment.common.service.MerchantContextLoader;
import cn.daxpay.open.payment.core.route.PayRouteFacade;
import cn.daxpay.open.payment.core.strategy.PaymentStrategyFactory;
import cn.daxpay.open.payment.core.strategy.pay.AbsNormalPayStrategy;
import cn.daxpay.open.payment.core.strategy.pay.PayStrategyContext;
import cn.daxpay.open.payment.core.trade.bo.PayTradeResultBo;
import cn.daxpay.open.payment.core.trade.dao.GatewayPayOrderManager;
import cn.daxpay.open.payment.core.trade.dao.PayTradeManager;
import cn.daxpay.open.payment.core.trade.entity.GatewayPayOrder;
import cn.daxpay.open.payment.core.trade.entity.PayTrade;
import cn.daxpay.open.payment.unipay.param.trade.pay.NormalPayParam;
import cn.daxpay.open.payment.unipay.result.trade.pay.NormalPayResult;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.enums.pay.channel.CurrencyEnum;
import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum;
import cn.daxpay.open.platform.core.enums.pay.trade.TradeSourceEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.exception.PayFailureException;
import cn.daxpay.open.platform.core.util.TradeNoGenerateUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil;
import com.baomidou.lock.LockInfo;
import com.baomidou.lock.LockTemplate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Objects;
/// # 网关支付统一内核
///
/// 在 product/method 已解析后: 懒创建 Trade → 调通道策略 → 回写容器。
@Slf4j
@Service
@RequiredArgsConstructor
public class GatewayPayHandleService {
private final GatewayPayOrderManager gatewayPayOrderManager;
private final PayTradeManager payTradeManager;
private final PayRouteFacade payRouteFacade;
private final MerchantContextLoader merchantContextLoader;
private final PayUniHandleService payUniHandleService;
private final GatewayPayAssistService gatewayPayAssistService;
private final LockTemplate lockTemplate;
/// 发起网关支付
public NormalPayResult handle(GatewayPayOrder order, String product, String method,
String openId, String scene, String device, String clientIp) {
LockInfo lock = lockTemplate.lock("payment:gateway:pay:" + order.getOrderNo(), 10000, 200);
if (Objects.isNull(lock)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.pay.processing");
}
try {
// 重新加载最新状态
order = gatewayPayOrderManager.findById(order.getId())
.orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.payOrderNotExist"));
gatewayPayAssistService.checkPayable(order);
merchantContextLoader.initMch(order.getMchNo());
// 已有 Trade: 幂等 / 锁定规则
PayTrade existing = payTradeManager.findByContainerId(order.getId(), PayTradeTypeEnum.GATEWAY.getCode())
.orElse(null);
if (existing != null) {
if (Objects.equals(existing.getStatus(), PayFundStatusEnum.SUCCESS.getCode())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.pay.alreadySuccess");
}
if (Objects.equals(existing.getStatus(), PayFundStatusEnum.PROCESSING.getCode())
|| Objects.equals(existing.getStatus(), PayFundStatusEnum.INIT.getCode())) {
// 通道/方式不一致则拒绝换端
if (!Objects.equals(existing.getProduct(), product)
|| !Objects.equals(existing.getMethod(), method)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.channelLocked");
}
if (StrUtil.isNotBlank(existing.getPayBody())) {
return this.buildResult(order, existing);
}
} else {
// fail/close 等终态: 首期不允许换方式重试
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.pay.failedOrClosed");
}
}
// 组装路由用参数
NormalPayParam payParam = this.buildPayParam(order, product, method, openId, clientIp);
payRouteFacade.resolve(payParam);
var payStrategy = PaymentStrategyFactory.createByProduct(payParam.getProduct(), AbsNormalPayStrategy.class);
var context = new PayStrategyContext().setPayParam(payParam);
payStrategy.doBeforePay(context);
// 建 Trade(若无) + 回填容器
if (existing == null) {
existing = SpringUtil.getBean(this.getClass())
.createTrade(order, payParam, scene, device);
} else {
// 回填路由结果到容器
this.fillRouteOnOrder(order, payParam, scene, device);
gatewayPayOrderManager.updateById(order);
}
context.setTrade(existing);
PayTradeResultBo result;
try {
result = payStrategy.doPay(context);
} catch (Exception e) {
log.error("网关支付出现异常", e);
existing.setStatus(PayFundStatusEnum.FAIL.getCode());
if (e instanceof PayFailureException) {
existing.setErrorMsg(e.getMessage());
} else {
existing.setErrorMsg("支付出现异常: " + e.getMessage());
}
payTradeManager.updateById(existing);
throw e;
}
return SpringUtil.getBean(this.getClass()).paySuccess(order, existing, result);
} finally {
lockTemplate.releaseLock(lock);
}
}
/// 创建资金凭证并更新容器为 paying
@Transactional(rollbackFor = Exception.class)
public PayTrade createTrade(GatewayPayOrder order, NormalPayParam payParam, String scene, String device) {
var productEnum = ProductEnum.findByCode(payParam.getProduct());
String channel = productEnum.getChannel();
PayTrade trade = new PayTrade();
trade.setAppId(order.getAppId());
trade.setTradeNo(TradeNoGenerateUtil.pay());
trade.setTradeType(PayTradeTypeEnum.GATEWAY.getCode());
trade.setContainerId(order.getId());
trade.setBizOrderNo(order.getBizOrderNo());
trade.setProduct(payParam.getProduct());
trade.setChannel(channel);
trade.setMethod(payParam.getMethod());
trade.setChannelMchNo(payParam.getChannelMchNo());
trade.setCapability(payParam.getCapability());
trade.setClientIp(order.getClientIp());
trade.setAmount(order.getAmount());
trade.setCurrency(CurrencyEnum.CNY.getCode());
trade.setRefundableBalance(order.getAmount());
trade.setStatus(PayFundStatusEnum.PROCESSING.getCode());
trade.setExpiredTime(order.getExpiredTime());
trade.setSource(TradeSourceEnum.AGGRESS_PAY.getCode());
trade.setOpenid(payParam.getOpenId());
payTradeManager.save(trade);
this.fillRouteOnOrder(order, payParam, scene, device);
order.setStatus(GatewayOrderStatusEnum.PAYING.getCode());
order.setChannel(channel);
order.setMethod(payParam.getMethod());
order.setProduct(payParam.getProduct());
gatewayPayOrderManager.updateById(order);
return trade;
}
@Transactional(rollbackFor = Exception.class)
public NormalPayResult paySuccess(GatewayPayOrder order, PayTrade trade, PayTradeResultBo result) {
if (result.isComplete()) {
trade.setStatus(PayFundStatusEnum.SUCCESS.getCode());
trade.setPayTime(result.getFinishTime());
}
trade.setOutOrderNo(result.getOutOrderNo());
trade.setTransOrderNo(result.getTransOrderNo());
trade.setRelationOrderNo(result.getRelationOrderNo());
trade.setBuyerId(result.getBuyerId());
trade.setBuyerLogonId(result.getBuyerLogonId());
trade.setTradeProduct(result.getTradeProduct());
trade.setTradeWay(result.getTradeWay());
trade.setBankType(result.getBankType());
trade.setPromotionType(result.getPromotionType());
trade.setPayBody(result.getPayBody());
trade.setPayBodyType(Objects.nonNull(result.getPayBodyType())
? result.getPayBodyType().getCode() : null);
trade.setErrorMsg(null);
payUniHandleService.payAfterHandel(trade);
return this.buildResult(order, trade);
}
private void fillRouteOnOrder(GatewayPayOrder order, NormalPayParam payParam, String scene, String device) {
order.setChannelMchNo(payParam.getChannelMchNo());
order.setCapability(payParam.getCapability());
if (StrUtil.isNotBlank(scene)) {
order.setScene(scene);
}
if (StrUtil.isNotBlank(device)) {
order.setDevice(device);
}
if (StrUtil.isNotBlank(payParam.getClientIp())) {
order.setClientIp(payParam.getClientIp());
}
}
private NormalPayParam buildPayParam(GatewayPayOrder order, String product, String method,
String openId, String clientIp) {
NormalPayParam payParam = new NormalPayParam();
payParam.setMchNo(order.getMchNo());
payParam.setAppId(order.getAppId());
payParam.setBizOrderNo(order.getBizOrderNo());
payParam.setTitle(order.getTitle());
payParam.setDescription(order.getDescription());
payParam.setAmount(order.getAmount());
payParam.setProduct(product);
payParam.setMethod(method);
payParam.setOpenId(openId);
payParam.setNotifyUrl(order.getNotifyUrl());
payParam.setReturnUrl(order.getReturnUrl());
payParam.setAttach(order.getAttach());
payParam.setExpiredTime(order.getExpiredTime());
payParam.setClientIp(StrUtil.blankToDefault(clientIp, order.getClientIp()));
payParam.setGoodsDetail(order.getGoodsDetail());
return payParam;
}
private NormalPayResult buildResult(GatewayPayOrder order, PayTrade trade) {
return new NormalPayResult()
.setOrderId(order.getId())
.setBizOrderNo(order.getBizOrderNo())
.setOrderNo(trade.getTradeNo())
.setStatus(trade.getStatus())
.setPayBody(trade.getPayBody())
.setPayBodyType(trade.getPayBodyType());
}
}

View File

@@ -0,0 +1,65 @@
package cn.daxpay.open.payment.core.trade.service;
import cn.daxpay.open.payment.common.enums.GatewayOrderStatusEnum;
import cn.daxpay.open.payment.common.enums.PayTradeTypeEnum;
import cn.daxpay.open.payment.core.trade.dao.GatewayPayOrderManager;
import cn.daxpay.open.payment.core.trade.dao.PayTradeManager;
import cn.daxpay.open.payment.core.trade.entity.GatewayPayOrder;
import cn.daxpay.open.payment.core.trade.entity.PayTrade;
import com.baomidou.lock.LockInfo;
import com.baomidou.lock.LockTemplate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Objects;
/// # 网关支付超时关单服务
@Slf4j
@Service
@RequiredArgsConstructor
public class GatewayTimeoutService {
private final GatewayPayOrderManager gatewayPayOrderManager;
private final PayTradeManager payTradeManager;
private final PayCloseService payCloseService;
private final PayUniHandleService payUniHandleService;
private final LockTemplate lockTemplate;
/// 按网关单号超时关闭(幂等)
public void closeForTimeout(String orderNo) {
GatewayPayOrder order = gatewayPayOrderManager.findByOrderNoNotTenant(orderNo).orElse(null);
if (order == null) {
return;
}
if (!List.of(GatewayOrderStatusEnum.WAIT_PAY.getCode(), GatewayOrderStatusEnum.PAYING.getCode())
.contains(order.getStatus())) {
return;
}
LockInfo lock = lockTemplate.lock("payment:gateway:timeout:" + order.getId(), 10000, 50);
if (lock == null) {
log.warn("网关超时关单获取锁失败, orderNo={}", orderNo);
return;
}
try {
order = gatewayPayOrderManager.findByOrderNoNotTenant(orderNo).orElse(null);
if (order == null
|| !List.of(GatewayOrderStatusEnum.WAIT_PAY.getCode(), GatewayOrderStatusEnum.PAYING.getCode())
.contains(order.getStatus())) {
return;
}
PayTrade trade = payTradeManager.findByContainerId(order.getId(), PayTradeTypeEnum.GATEWAY.getCode())
.orElse(null);
if (trade != null) {
// 有资金凭证: 走统一超时关单(含通道关闭)
payCloseService.closeForTimeout(trade.getTradeNo());
} else {
// 仅容器: 直接过期
payUniHandleService.gatewayOrderTimeout(order);
}
} finally {
lockTemplate.releaseLock(lock);
}
}
}

View File

@@ -109,7 +109,8 @@ public class PayRefundService {
// 按 bizOrderNo 查容器, 再查关联交易
NormalPayOrder normalOrder = payNormalOrderManager.findByBizOrderNo(bizOrderNo)
.orElseThrow(() -> new BizInfoException(DaxPayErrorCode.TRADE_STATUS_ERROR, "pay.error.notExists"));
return payTradeManager.findByContainerId(normalOrder.getId())
return payTradeManager.findByContainerId(normalOrder.getId(),
cn.daxpay.open.payment.common.enums.PayTradeTypeEnum.NORMAL.getCode())
.orElseThrow(() -> new BizInfoException(DaxPayErrorCode.TRADE_STATUS_ERROR, "pay.error.notExists"));
}

View File

@@ -1,10 +1,14 @@
package cn.daxpay.open.payment.core.trade.service;
import cn.daxpay.open.payment.common.enums.GatewayOrderStatusEnum;
import cn.daxpay.open.payment.common.enums.NormalPayOrderStatusEnum;
import cn.daxpay.open.payment.common.enums.PayFundStatusEnum;
import cn.daxpay.open.payment.common.enums.PayTradeTypeEnum;
import cn.daxpay.open.payment.core.trade.dao.GatewayPayOrderManager;
import cn.daxpay.open.payment.core.trade.dao.NormalPayOrderManager;
import cn.daxpay.open.payment.core.trade.entity.NormalPayOrder;
import cn.daxpay.open.payment.core.trade.dao.PayTradeManager;
import cn.daxpay.open.payment.core.trade.entity.GatewayPayOrder;
import cn.daxpay.open.payment.core.trade.entity.NormalPayOrder;
import cn.daxpay.open.payment.core.trade.entity.PayTrade;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -16,7 +20,7 @@ import java.util.Objects;
/// # 交易统一处理服务
///
/// 支付成功/失败/关闭后的统一处理逻辑
/// 支付成功/失败/关闭后的统一处理逻辑, 按 trade_type 回写对应业务容器。
@Slf4j
@Service
@RequiredArgsConstructor
@@ -24,71 +28,111 @@ public class PayUniHandleService {
private final PayTradeManager payTradeManager;
private final NormalPayOrderManager payNormalOrderManager;
private final GatewayPayOrderManager gatewayPayOrderManager;
/// 支付发起后处理(参考商业版 TradeUniHandleService.payAfterHandel
/// 支付发起后处理
/// 不论是否完成都更新交易单; 仅资金状态为 SUCCESS 时同步容器为 PAID。
/// 异步支付(complete=false)时容器保持 WAIT_PAY, 交易单保持 PROCESSING。
public void payAfterHandel(PayTrade trade) {
payTradeManager.updateById(trade);
if (Objects.equals(trade.getStatus(), PayFundStatusEnum.SUCCESS.getCode())) {
NormalPayOrder normalOrder = payNormalOrderManager.findById(trade.getContainerId())
.orElse(null);
if (normalOrder != null) {
normalOrder.setStatus(NormalPayOrderStatusEnum.PAID.getCode());
normalOrder.setPayTime(trade.getPayTime());
payNormalOrderManager.updateById(normalOrder);
}
this.markContainerPaid(trade);
}
}
/// 支付成功后续处理(同步冗余时间线到容器)
/// 支付成功后续处理
public void paySuccess(PayTrade trade) {
NormalPayOrder normalOrder = payNormalOrderManager.findById(trade.getContainerId())
.orElse(null);
if (normalOrder != null) {
normalOrder.setStatus(NormalPayOrderStatusEnum.PAID.getCode());
normalOrder.setPayTime(trade.getPayTime());
payNormalOrderManager.updateById(normalOrder);
}
this.markContainerPaid(trade);
payTradeManager.updateById(trade);
}
/// 支付失败处理(同步冗余时间线到容器)
public void payFail(PayTrade trade, NormalPayOrder normalOrder, String errMsg) {
/// 支付失败处理
public void payFail(PayTrade trade, String errMsg) {
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
trade.setStatus(PayFundStatusEnum.FAIL.getCode());
trade.setErrorMsg(errMsg);
trade.setCloseTime(now);
normalOrder.setStatus(NormalPayOrderStatusEnum.CLOSED.getCode());
normalOrder.setCloseTime(now);
payTradeManager.updateById(trade);
payNormalOrderManager.updateById(normalOrder);
this.markContainerClosed(trade, now, false);
}
/// 支付关闭处理(同步冗余时间线到容器)
/// @param useCancel true=撤销(资金态置 CANCEL), false=关闭(资金态置 CLOSE); 容器统一置 CLOSED
public void payClose(PayTrade trade, NormalPayOrder normalOrder, boolean useCancel) {
/// 支付关闭处理
/// @param useCancel true=撤销(资金态置 CANCEL), false=关闭(资金态置 CLOSE)
public void payClose(PayTrade trade, boolean useCancel) {
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
trade.setStatus(useCancel
? PayFundStatusEnum.CANCEL.getCode()
: PayFundStatusEnum.CLOSE.getCode());
trade.setCloseTime(now);
normalOrder.setStatus(NormalPayOrderStatusEnum.CLOSED.getCode());
normalOrder.setCloseTime(now);
payTradeManager.updateById(trade);
payNormalOrderManager.updateById(normalOrder);
this.markContainerClosed(trade, now, false);
}
/// 支付超时关闭处理
/// 资金态置 CLOSE(与普通关闭等价), 容器态置 EXPIRED(区分业务语义),
/// 供 MQ 延时关单 / 兜底定时任务 / 同步发现超时 三条路径统一调用。
public void payTimeout(PayTrade trade, NormalPayOrder normalOrder) {
/// 支付超时关闭: 资金态 CLOSE, 容器态 EXPIRED
public void payTimeout(PayTrade trade) {
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
trade.setStatus(PayFundStatusEnum.CLOSE.getCode());
trade.setCloseTime(now);
normalOrder.setStatus(NormalPayOrderStatusEnum.EXPIRED.getCode());
normalOrder.setCloseTime(now);
payTradeManager.updateById(trade);
payNormalOrderManager.updateById(normalOrder);
this.markContainerClosed(trade, now, true);
}
/// 仅关闭网关容器(尚未创建 Trade 的预下单超时)
public void gatewayOrderTimeout(GatewayPayOrder order) {
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
order.setStatus(GatewayOrderStatusEnum.EXPIRED.getCode());
order.setCloseTime(now);
gatewayPayOrderManager.updateById(order);
}
/// 仅关闭网关容器(商户主动关单且尚无 Trade)
public void gatewayOrderClose(GatewayPayOrder order) {
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
order.setStatus(GatewayOrderStatusEnum.CLOSED.getCode());
order.setCloseTime(now);
gatewayPayOrderManager.updateById(order);
}
private void markContainerPaid(PayTrade trade) {
if (isGateway(trade)) {
GatewayPayOrder order = gatewayPayOrderManager.findById(trade.getContainerId()).orElse(null);
if (order != null) {
order.setStatus(GatewayOrderStatusEnum.PAID.getCode());
order.setPayTime(trade.getPayTime());
gatewayPayOrderManager.updateById(order);
}
return;
}
NormalPayOrder normalOrder = payNormalOrderManager.findById(trade.getContainerId()).orElse(null);
if (normalOrder != null) {
normalOrder.setStatus(NormalPayOrderStatusEnum.PAID.getCode());
normalOrder.setPayTime(trade.getPayTime());
payNormalOrderManager.updateById(normalOrder);
}
}
private void markContainerClosed(PayTrade trade, OffsetDateTime now, boolean expired) {
if (isGateway(trade)) {
GatewayPayOrder order = gatewayPayOrderManager.findById(trade.getContainerId()).orElse(null);
if (order != null) {
order.setStatus(expired
? GatewayOrderStatusEnum.EXPIRED.getCode()
: GatewayOrderStatusEnum.CLOSED.getCode());
order.setCloseTime(now);
gatewayPayOrderManager.updateById(order);
}
return;
}
NormalPayOrder normalOrder = payNormalOrderManager.findById(trade.getContainerId()).orElse(null);
if (normalOrder != null) {
normalOrder.setStatus(expired
? NormalPayOrderStatusEnum.EXPIRED.getCode()
: NormalPayOrderStatusEnum.CLOSED.getCode());
normalOrder.setCloseTime(now);
payNormalOrderManager.updateById(normalOrder);
}
}
private boolean isGateway(PayTrade trade) {
return Objects.equals(trade.getTradeType(), PayTradeTypeEnum.GATEWAY.getCode());
}
}

View File

@@ -0,0 +1,86 @@
package cn.daxpay.open.payment.core.trade.service.admin;
import cn.daxpay.open.payment.common.enums.GatewayOrderStatusEnum;
import cn.daxpay.open.payment.common.enums.PayTradeTypeEnum;
import cn.daxpay.open.payment.core.trade.convert.GatewayPayOrderConvert;
import cn.daxpay.open.payment.core.trade.dao.GatewayPayOrderManager;
import cn.daxpay.open.payment.core.trade.dao.PayTradeManager;
import cn.daxpay.open.payment.core.trade.entity.GatewayPayOrder;
import cn.daxpay.open.payment.core.trade.entity.PayTrade;
import cn.daxpay.open.payment.core.trade.param.GatewayPayOrderQuery;
import cn.daxpay.open.payment.core.trade.result.GatewayPayOrderResult;
import cn.daxpay.open.payment.core.trade.service.PayCloseService;
import cn.daxpay.open.payment.core.trade.service.PaySyncService;
import cn.daxpay.open.payment.core.trade.service.PayUniHandleService;
import cn.daxpay.open.payment.unipay.result.trade.pay.NormalPaySyncResult;
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.platform.core.rest.param.PageParam;
import cn.daxpay.open.platform.core.rest.result.PageResult;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Objects;
/// # 网关支付业务单管理服务
@Service
@RequiredArgsConstructor
public class GatewayPayOrderAdminService {
private final GatewayPayOrderManager gatewayPayOrderManager;
private final PayTradeManager payTradeManager;
private final PaySyncService paySyncService;
private final PayCloseService payCloseService;
private final PayUniHandleService payUniHandleService;
public PageResult<GatewayPayOrderResult> page(PageParam pageParam, GatewayPayOrderQuery query) {
Page<GatewayPayOrder> page = gatewayPayOrderManager.page(pageParam, query);
var records = page.getRecords().stream()
.map(GatewayPayOrderConvert.CONVERT::toResult)
.toList();
return new PageResult<GatewayPayOrderResult>()
.setRecords(records)
.setTotal(page.getTotal())
.setSize(page.getSize())
.setCurrent(page.getCurrent());
}
public GatewayPayOrderResult findById(Long id) {
GatewayPayOrder entity = gatewayPayOrderManager.findById(id)
.orElseThrow(() -> new DataNotExistException("pay.error.payOrderNotExist"));
GatewayPayOrderResult result = GatewayPayOrderConvert.CONVERT.toResult(entity);
PayTrade trade = payTradeManager.findByContainerId(id, PayTradeTypeEnum.GATEWAY.getCode()).orElse(null);
if (Objects.nonNull(trade)) {
result.setTradeNo(trade.getTradeNo());
result.setOutOrderNo(trade.getOutOrderNo());
result.setFundStatus(trade.getStatus());
result.setRefundableBalance(trade.getRefundableBalance());
result.setErrorMsg(trade.getErrorMsg());
}
return result;
}
public NormalPaySyncResult sync(Long id) {
PayTrade trade = payTradeManager.findByContainerId(id, PayTradeTypeEnum.GATEWAY.getCode())
.orElseThrow(() -> new DataNotExistException("pay.error.payOrderNotExist"));
return paySyncService.syncPayOrder(trade);
}
public void close(Long id, boolean useCancel) {
GatewayPayOrder order = gatewayPayOrderManager.findById(id)
.orElseThrow(() -> new DataNotExistException("pay.error.payOrderNotExist"));
if (!List.of(GatewayOrderStatusEnum.WAIT_PAY.getCode(), GatewayOrderStatusEnum.PAYING.getCode())
.contains(order.getStatus())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.pay.closeNotPaying");
}
PayTrade trade = payTradeManager.findByContainerId(id, PayTradeTypeEnum.GATEWAY.getCode()).orElse(null);
if (trade != null) {
payCloseService.closeOrder(trade, useCancel);
} else {
payUniHandleService.gatewayOrderClose(order);
}
}
}

View File

@@ -54,7 +54,7 @@ public class NormalPayOrderAdminService {
.orElseThrow(() -> new DataNotExistException("pay.error.payOrderNotExist"));
NormalPayOrderResult result = NormalPayOrderConvert.CONVERT.toResult(entity);
// 联表查询资金凭证, 补充交易字段
PayTrade trade = payTradeManager.findByContainerId(id).orElse(null);
PayTrade trade = payTradeManager.findByContainerId(id, cn.daxpay.open.payment.common.enums.PayTradeTypeEnum.NORMAL.getCode()).orElse(null);
if (Objects.nonNull(trade)) {
result.setTradeNo(trade.getTradeNo());
result.setOutOrderNo(trade.getOutOrderNo());
@@ -74,14 +74,14 @@ public class NormalPayOrderAdminService {
/// 同步支付状态(传入业务订单ID)
public NormalPaySyncResult sync(Long id) {
PayTrade trade = payTradeManager.findByContainerId(id)
PayTrade trade = payTradeManager.findByContainerId(id, cn.daxpay.open.payment.common.enums.PayTradeTypeEnum.NORMAL.getCode())
.orElseThrow(() -> new DataNotExistException("pay.error.payOrderNotExist"));
return paySyncService.syncPayOrder(trade);
}
/// 关闭/撤销订单(传入业务订单ID)
public void close(Long id, boolean useCancel) {
PayTrade trade = payTradeManager.findByContainerId(id)
PayTrade trade = payTradeManager.findByContainerId(id, cn.daxpay.open.payment.common.enums.PayTradeTypeEnum.NORMAL.getCode())
.orElseThrow(() -> new DataNotExistException("pay.error.payOrderNotExist"));
payCloseService.closeOrder(trade, useCancel);
}

View File

@@ -0,0 +1,42 @@
package cn.daxpay.open.payment.merchant.controller.admin.gateway;
import cn.daxpay.open.payment.merchant.param.gateway.GatewayAggregateConfigParam;
import cn.daxpay.open.payment.merchant.result.gateway.GatewayAggregateConfigResult;
import cn.daxpay.open.payment.merchant.service.gateway.GatewayAggregateConfigService;
import cn.daxpay.open.platform.core.annotation.PermCode;
import cn.daxpay.open.platform.core.rest.Res;
import cn.daxpay.open.platform.core.rest.result.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
/// # 网关聚合扫码配置(管理)
@PermCode(menuCode = "merchant:gateway-aggregate")
@Validated
@Tag(name = "网关聚合扫码配置")
@RestController
@RequestMapping("/admin/gateway/aggregate-config")
@RequiredArgsConstructor
public class GatewayAggregateConfigAdminController {
private final GatewayAggregateConfigService gatewayAggregateConfigService;
@PermCode(code = "view", nameCn = "配置查看", nameEn = "Config View")
@Operation(summary = "按应用查询聚合扫码配置")
@GetMapping("/get-by-app-id")
public Result<GatewayAggregateConfigResult> getByAppId(
@NotBlank(message = "{validation.field.appId.notBlank}") String appId) {
return Res.ok(gatewayAggregateConfigService.findByAppId(appId));
}
@PermCode(code = "update", nameCn = "配置更新", nameEn = "Config Update")
@Operation(summary = "保存或更新聚合扫码配置")
@PostMapping("/save-or-update")
public Result<Void> saveOrUpdate(@RequestBody @Validated GatewayAggregateConfigParam param) {
gatewayAggregateConfigService.saveOrUpdate(param);
return Res.ok();
}
}

View File

@@ -0,0 +1,19 @@
package cn.daxpay.open.payment.merchant.dao.gateway;
import cn.daxpay.open.payment.merchant.entity.gateway.GatewayAggregateConfig;
import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/// # 网关聚合扫码配置管理器
@Repository
public class GatewayAggregateConfigManager extends BaseManager<GatewayAggregateConfigMapper, GatewayAggregateConfig> {
/// 按应用号查询
public Optional<GatewayAggregateConfig> findByAppId(String appId) {
return lambdaQuery()
.eq(GatewayAggregateConfig::getAppId, appId)
.oneOpt();
}
}

View File

@@ -0,0 +1,10 @@
package cn.daxpay.open.payment.merchant.dao.gateway;
import cn.daxpay.open.payment.merchant.entity.gateway.GatewayAggregateConfig;
import com.github.yulichang.base.MPJBaseMapper;
import org.apache.ibatis.annotations.Mapper;
/// # 网关聚合扫码配置 Mapper
@Mapper
public interface GatewayAggregateConfigMapper extends MPJBaseMapper<GatewayAggregateConfig> {
}

View File

@@ -0,0 +1,44 @@
package cn.daxpay.open.payment.merchant.entity.gateway;
import cn.daxpay.open.payment.common.entity.merchant.MchBaseEntity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/// # 网关聚合扫码配置(应用级)
///
/// 按场景映射支付产品与支付方式, 扫码识别环境后用于建 Trade。
@Data
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
@TableName("pay_gateway_aggregate_config")
public class GatewayAggregateConfig extends MchBaseEntity {
/// 应用号
@TableField(updateStrategy = FieldStrategy.NEVER)
private String appId;
/// 微信场景支付产品
private String wxProduct;
/// 微信场景支付方式
private String wxMethod;
/// 支付宝场景支付产品
private String alipayProduct;
/// 支付宝场景支付方式
private String alipayMethod;
/// 云闪付场景支付产品
private String unionProduct;
/// 云闪付场景支付方式
private String unionMethod;
/// 是否自动拉起支付
private Boolean autoLaunch;
}

View File

@@ -0,0 +1,49 @@
package cn.daxpay.open.payment.merchant.param.gateway;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/// # 网关聚合扫码配置参数
@Data
@Schema(title = "网关聚合扫码配置参数")
public class GatewayAggregateConfigParam {
@Schema(description = "商户号")
@NotBlank(message = "{validation.field.mchNo.notBlank}")
@Size(max = 32, message = "{validation.field.mchNo.size}")
private String mchNo;
@Schema(description = "应用号")
@NotBlank(message = "{validation.field.appId.notBlank}")
@Size(max = 32, message = "{validation.field.appId.size}")
private String appId;
@Schema(description = "微信场景支付产品")
@Size(max = 32, message = "{validation.field.product.size}")
private String wxProduct;
@Schema(description = "微信场景支付方式")
@Size(max = 32, message = "{validation.field.method.size}")
private String wxMethod;
@Schema(description = "支付宝场景支付产品")
@Size(max = 32, message = "{validation.field.product.size}")
private String alipayProduct;
@Schema(description = "支付宝场景支付方式")
@Size(max = 32, message = "{validation.field.method.size}")
private String alipayMethod;
@Schema(description = "云闪付场景支付产品")
@Size(max = 32, message = "{validation.field.product.size}")
private String unionProduct;
@Schema(description = "云闪付场景支付方式")
@Size(max = 32, message = "{validation.field.method.size}")
private String unionMethod;
@Schema(description = "是否自动拉起支付")
private Boolean autoLaunch;
}

View File

@@ -0,0 +1,42 @@
package cn.daxpay.open.payment.merchant.result.gateway;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 网关聚合扫码配置结果
@Data
@Accessors(chain = true)
@Schema(title = "网关聚合扫码配置结果")
public class GatewayAggregateConfigResult {
@Schema(description = "主键")
private Long id;
@Schema(description = "商户号")
private String mchNo;
@Schema(description = "应用号")
private String appId;
@Schema(description = "微信场景支付产品")
private String wxProduct;
@Schema(description = "微信场景支付方式")
private String wxMethod;
@Schema(description = "支付宝场景支付产品")
private String alipayProduct;
@Schema(description = "支付宝场景支付方式")
private String alipayMethod;
@Schema(description = "云闪付场景支付产品")
private String unionProduct;
@Schema(description = "云闪付场景支付方式")
private String unionMethod;
@Schema(description = "是否自动拉起支付")
private Boolean autoLaunch;
}

View File

@@ -0,0 +1,58 @@
package cn.daxpay.open.payment.merchant.service.gateway;
import cn.daxpay.open.payment.merchant.dao.gateway.GatewayAggregateConfigManager;
import cn.daxpay.open.payment.merchant.entity.gateway.GatewayAggregateConfig;
import cn.daxpay.open.payment.merchant.param.gateway.GatewayAggregateConfigParam;
import cn.daxpay.open.payment.merchant.result.gateway.GatewayAggregateConfigResult;
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.hutool.core.bean.BeanUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/// # 网关聚合扫码配置服务
@Slf4j
@Service
@RequiredArgsConstructor
public class GatewayAggregateConfigService {
private final GatewayAggregateConfigManager configManager;
/// 按应用查询, 不存在返回空对象字段
public GatewayAggregateConfigResult findByAppId(String appId) {
return configManager.findByAppId(appId)
.map(this::toResult)
.orElseGet(() -> new GatewayAggregateConfigResult().setAppId(appId));
}
/// 支付时必须已配置
public GatewayAggregateConfig getRequiredByAppId(String appId) {
return configManager.findByAppId(appId)
.orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.aggregateConfigMissing"));
}
/// 保存或更新
@Transactional(rollbackFor = Exception.class)
public void saveOrUpdate(GatewayAggregateConfigParam param) {
var existing = configManager.findByAppId(param.getAppId());
if (existing.isPresent()) {
GatewayAggregateConfig entity = existing.get();
BeanUtil.copyProperties(param, entity, "id", "appId", "mchNo", "version", "deleted");
configManager.updateById(entity);
} else {
GatewayAggregateConfig entity = new GatewayAggregateConfig();
BeanUtil.copyProperties(param, entity);
configManager.save(entity);
}
}
private GatewayAggregateConfigResult toResult(GatewayAggregateConfig entity) {
GatewayAggregateConfigResult result = new GatewayAggregateConfigResult();
BeanUtil.copyProperties(entity, result);
return result;
}
}

View File

@@ -0,0 +1,36 @@
package cn.daxpay.open.payment.unipay.param.gateway;
import cn.daxpay.open.payment.common.enums.CashierSceneEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/// # 聚合扫码发起支付参数(H5 侧)
@Data
@Schema(title = "聚合扫码支付参数")
public class AggregateQrPayParam {
@Schema(description = "平台网关单号")
@NotBlank(message = "{validation.field.orderNo.notBlank}")
@Size(max = 64, message = "{validation.field.orderNo.size}")
private String orderNo;
/// @see CashierSceneEnum
@Schema(description = "收银场景 wechat_pay/alipay/union_pay")
@NotBlank(message = "{validation.field.scene.notBlank}")
@Size(max = 32, message = "{validation.field.scene.size}")
private String scene;
@Schema(description = "OpenId(微信 JSAPI 等)")
@Size(max = 128, message = "{validation.field.openId.size}")
private String openId;
@Schema(description = "设备 mobile/pc")
@Size(max = 16, message = "{validation.field.device.size}")
private String device;
@Schema(description = "客户端IP")
@Size(max = 64, message = "{validation.field.clientIp.size}")
private String clientIp;
}

View File

@@ -0,0 +1,32 @@
package cn.daxpay.open.payment.unipay.param.gateway;
import cn.daxpay.open.payment.unipay.param.PaymentCommonParam;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
/// # 网关订单查询参数
@EqualsAndHashCode(callSuper = true)
@Data
@Schema(title = "网关订单查询参数")
public class GatewayOrderQueryParam extends PaymentCommonParam {
@Schema(description = "平台网关单号")
@Size(max = 64, message = "{validation.field.orderNo.size}")
private String orderNo;
@Schema(description = "商户业务单号")
@Size(max = 100, message = "{validation.field.bizOrderNo.size}")
private String bizOrderNo;
@Schema(description = "应用号")
@Size(max = 32, message = "{validation.field.appId.size}")
private String appId;
@Schema(description = "商户号")
@NotBlank(message = "{validation.field.mchNo.notBlank}")
@Size(max = 32, message = "{validation.field.mchNo.size}")
private String mchNo;
}

View File

@@ -0,0 +1,69 @@
package cn.daxpay.open.payment.unipay.param.gateway;
import cn.daxpay.open.payment.common.enums.GatewayPayTypeEnum;
import cn.daxpay.open.payment.unipay.param.MerchantPaymentCommonParam;
import cn.daxpay.open.payment.unipay.param.trade.pay.GoodsDetail;
import cn.daxpay.open.payment.unipay.param.trade.pay.TerminalInfo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.OffsetDateTime;
import java.util.List;
/// # 网关预下单参数
@EqualsAndHashCode(callSuper = true)
@Data
@Schema(title = "网关预下单参数")
public class GatewayPrePayParam extends MerchantPaymentCommonParam {
@Schema(description = "商户订单号")
@NotBlank(message = "{validation.field.bizOrderNo.notBlank}")
@Size(max = 100, message = "{validation.field.bizOrderNo.size}")
private String bizOrderNo;
@Schema(description = "支付标题")
@NotBlank(message = "{validation.field.title.notBlank}")
@Size(max = 100, message = "{validation.field.title.size}")
private String title;
@Schema(description = "支付描述")
@Size(max = 50, message = "{validation.field.description.size}")
private String description;
@Schema(description = "支付金额(分)")
@NotNull(message = "{validation.field.amount.notNull}")
@Min(value = 1, message = "{validation.field.amount.min}")
@Max(value = 9999999999L, message = "{validation.field.amount.max}")
private Long amount;
/// @see GatewayPayTypeEnum
@Schema(description = "网关支付类型 cashier/aggregate")
@NotBlank(message = "{validation.field.gatewayPayType.notBlank}")
@Size(max = 32, message = "{validation.field.gatewayPayType.size}")
private String gatewayPayType;
@Schema(description = "异步通知地址")
@Size(max = 256, message = "{validation.field.notifyUrl.size}")
private String notifyUrl;
@Schema(description = "同步跳转地址")
@Size(max = 256, message = "{validation.field.returnUrl.size}")
private String returnUrl;
@Schema(description = "商户附加参数")
@Size(max = 512, message = "{validation.field.attach.size}")
private String attach;
@Schema(description = "过期时间")
private OffsetDateTime expiredTime;
@Valid
@Schema(description = "终端信息")
private TerminalInfo terminal;
@Schema(description = "商品明细")
private List<GoodsDetail> goodsDetail;
}

View File

@@ -0,0 +1,68 @@
package cn.daxpay.open.payment.unipay.result.gateway;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
import java.time.OffsetDateTime;
/// # 网关订单查询结果
@Data
@Accessors(chain = true)
@Schema(title = "网关订单查询结果")
public class GatewayOrderResult {
@Schema(description = "平台网关单号")
private String orderNo;
@Schema(description = "商户业务单号")
private String bizOrderNo;
@Schema(description = "网关类型")
private String gatewayType;
@Schema(description = "标题")
private String title;
@Schema(description = "描述")
private String description;
@Schema(description = "金额(分)")
private Long amount;
@Schema(description = "币种")
private String currency;
@Schema(description = "业务状态")
private String status;
@Schema(description = "过期时间")
private OffsetDateTime expiredTime;
@Schema(description = "支付成功时间")
private OffsetDateTime payTime;
@Schema(description = "支付通道")
private String channel;
@Schema(description = "支付方式")
private String method;
@Schema(description = "支付产品")
private String product;
@Schema(description = "资金交易号")
private String tradeNo;
@Schema(description = "通道订单号")
private String outOrderNo;
@Schema(description = "资金状态")
private String fundStatus;
@Schema(description = "商户附加参数")
private String attach;
@Schema(description = "同步跳转地址")
private String returnUrl;
}

View File

@@ -0,0 +1,29 @@
package cn.daxpay.open.payment.unipay.result.gateway;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
import java.time.OffsetDateTime;
/// # 网关预下单结果
@Data
@Accessors(chain = true)
@Schema(title = "网关预下单结果")
public class GatewayPrePayResult {
@Schema(description = "平台网关单号")
private String orderNo;
@Schema(description = "商户业务单号")
private String bizOrderNo;
@Schema(description = "业务状态")
private String status;
@Schema(description = "网关落地页 URL")
private String url;
@Schema(description = "过期时间")
private OffsetDateTime expiredTime;
}

View File

@@ -0,0 +1,44 @@
package cn.daxpay.open.payment.web.gateway.controller;
import cn.daxpay.open.payment.core.trade.service.GatewayAggregatePayService;
import cn.daxpay.open.payment.core.trade.service.GatewayOrderQueryService;
import cn.daxpay.open.payment.unipay.param.gateway.AggregateQrPayParam;
import cn.daxpay.open.payment.unipay.result.gateway.GatewayOrderResult;
import cn.daxpay.open.payment.unipay.result.trade.pay.NormalPayResult;
import cn.daxpay.open.platform.core.annotation.IgnoreAuth;
import cn.daxpay.open.platform.core.annotation.IgnoreTenant;
import cn.daxpay.open.platform.core.rest.Res;
import cn.daxpay.open.platform.core.rest.result.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
/// # 网关 H5 侧接口(无商户签名)
@IgnoreAuth
@IgnoreTenant
@Validated
@Tag(name = "网关支付(H5)")
@RestController
@RequestMapping("/client/gateway")
@RequiredArgsConstructor
public class GatewayClientController {
private final GatewayOrderQueryService gatewayOrderQueryService;
private final GatewayAggregatePayService gatewayAggregatePayService;
@Operation(summary = "查询网关订单摘要")
@GetMapping("/order")
public Result<GatewayOrderResult> getOrder(
@NotBlank(message = "{validation.field.orderNo.notBlank}") String orderNo) {
return Res.ok(gatewayOrderQueryService.queryByOrderNoNotTenant(orderNo));
}
@Operation(summary = "聚合扫码发起支付")
@PostMapping("/aggregate/pay")
public Result<NormalPayResult> aggregatePay(@RequestBody @Validated AggregateQrPayParam param) {
return Res.ok(gatewayAggregatePayService.aggregateQrPay(param));
}
}

View File

@@ -0,0 +1,52 @@
package cn.daxpay.open.payment.web.unipay.controller;
import cn.daxpay.open.payment.common.aop.PaymentVerify;
import cn.daxpay.open.payment.common.result.DaxResult;
import cn.daxpay.open.payment.common.util.DaxRes;
import cn.daxpay.open.payment.core.trade.service.GatewayOrderQueryService;
import cn.daxpay.open.payment.core.trade.service.GatewayPayAssistService;
import cn.daxpay.open.payment.merchant.service.permission.MerchantPermissionService;
import cn.daxpay.open.payment.unipay.param.gateway.GatewayOrderQueryParam;
import cn.daxpay.open.payment.unipay.param.gateway.GatewayPrePayParam;
import cn.daxpay.open.payment.unipay.result.gateway.GatewayOrderResult;
import cn.daxpay.open.payment.unipay.result.gateway.GatewayPrePayResult;
import cn.daxpay.open.platform.core.annotation.IgnoreAuth;
import cn.daxpay.open.platform.core.code.CommonCode;
import cn.daxpay.open.platform.core.enums.common.PaymentApiEnum;
import cn.daxpay.open.platform.core.exception.operation.OperationFailException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/// # 网关支付开放接口
@PaymentVerify
@IgnoreAuth
@Tag(name = "网关支付接口")
@RestController
@RequestMapping("/unipay/gateway")
@RequiredArgsConstructor
public class UniGatewayPayController {
private final GatewayPayAssistService gatewayPayAssistService;
private final GatewayOrderQueryService gatewayOrderQueryService;
private final MerchantPermissionService permConfigService;
@Operation(summary = "网关预下单")
@PostMapping("/pre-pay")
public DaxResult<GatewayPrePayResult> prePay(@RequestBody GatewayPrePayParam param) {
if (!permConfigService.hasApiPerm(PaymentApiEnum.PRE_PAY.getCode())) {
throw new OperationFailException(CommonCode.FAIL_CODE, "error.payment.order.merchantNoApiPermission");
}
return DaxRes.ok(gatewayPayAssistService.prePay(param));
}
@Operation(summary = "网关订单查询")
@PostMapping("/query")
public DaxResult<GatewayOrderResult> query(@RequestBody GatewayOrderQueryParam param) {
return DaxRes.ok(gatewayOrderQueryService.query(param));
}
}

View File

@@ -48,7 +48,8 @@ public class NormalPayOrderQueryService {
.orElseThrow(() -> new DataNotExistException("pay.error.payOrderNotExist"));
}
// 联表资金凭证补充交易字段
PayTrade trade = payTradeManager.findByContainerId(order.getId()).orElse(null);
PayTrade trade = payTradeManager.findByContainerId(order.getId(),
cn.daxpay.open.payment.common.enums.PayTradeTypeEnum.NORMAL.getCode()).orElse(null);
return UnipayNormalPayOrderConvert.CONVERT.toResult(order, trade);
}
}

View File

@@ -0,0 +1,5 @@
{
"wechat_pay": "WeChat",
"alipay": "Alipay",
"union_pay": "UnionPay"
}

View File

@@ -0,0 +1,7 @@
{
"wait_pay": "Wait Pay",
"paying": "Paying",
"paid": "Paid",
"closed": "Closed",
"expired": "Expired"
}

View File

@@ -0,0 +1,4 @@
{
"cashier": "Cashier",
"aggregate": "Aggregate QR"
}

View File

@@ -41,7 +41,16 @@
"closeProcessing": "Order closing in progress, do not repeat",
"closeFailed": "Close order failed",
"syncNotStarted": "Order payment not started, please confirm payment status",
"syncProcessing": "Payment sync in progress, do not repeat"
"syncProcessing": "Payment sync in progress, do not repeat",
"processing": "Payment processing, do not repeat"
},
"gateway": {
"cashierNotReady": "Cashier payment is not available yet",
"typeMismatch": "Gateway order type mismatch",
"sceneNotSupport": "Unsupported cashier scene",
"sceneNotConfigured": "Payment product/method not configured for this scene",
"aggregateConfigMissing": "Aggregate QR pay config missing for app",
"channelLocked": "Payment method locked for this order"
},
"refund": {
"processing": "Refund processing, do not repeat",

View File

@@ -0,0 +1,5 @@
{
"wechat_pay": "微信环境",
"alipay": "支付宝环境",
"union_pay": "云闪付环境"
}

View File

@@ -0,0 +1,7 @@
{
"wait_pay": "待支付",
"paying": "支付中",
"paid": "已支付",
"closed": "已关闭",
"expired": "已过期"
}

View File

@@ -0,0 +1,4 @@
{
"cashier": "统一收银台",
"aggregate": "聚合扫码"
}

View File

@@ -40,7 +40,16 @@
"closeProcessing": "支付订单已在关闭中,请勿重复发起",
"closeFailed": "关闭订单失败",
"syncNotStarted": "订单未开始支付, 请重新确认支付状态",
"syncProcessing": "支付同步处理中,请勿重复操作"
"syncProcessing": "支付同步处理中,请勿重复操作",
"processing": "支付处理中,请勿重复操作"
},
"gateway": {
"cashierNotReady": "收银台支付尚未开放",
"typeMismatch": "网关订单类型不匹配",
"sceneNotSupport": "不支持的收银场景",
"sceneNotConfigured": "该场景未配置支付产品/方式",
"aggregateConfigMissing": "应用未配置聚合扫码支付",
"channelLocked": "订单已锁定支付方式,请勿切换"
},
"refund": {
"processing": "退款处理中,请勿重复操作",

View File

@@ -32,21 +32,21 @@ public class SocialLoginConfigController {
private final SocialLoginConfigService socialLoginConfigService;
@PermCode(code = "view", nameCn = "配置查看", nameEn = "Config View")
@PermCode(code = "view", nameCn = "社交登录配置查看", nameEn = "Social Login Config View")
@Operation(summary = "全量查询平台配置(枚举驱动, 读时初始化缺失平台)")
@GetMapping("/find-all")
public Result<List<SocialLoginConfigResult>> findAll() {
return Res.ok(socialLoginConfigService.findAll());
}
@PermCode(code = "view", nameCn = "配置查看", nameEn = "Config View")
@PermCode(code = "view", nameCn = "社交登录配置查看", nameEn = "Social Login Config View")
@Operation(summary = "根据平台编码查询(不存在则初始化占位记录)")
@GetMapping("/get-by-source")
public Result<SocialLoginConfigResult> findBySource(@NotBlank(message = "{validation.field.source.notBlank}") String source) {
return Res.ok(socialLoginConfigService.findBySource(source));
}
@PermCode(code = "manage", nameCn = "配置管理", nameEn = "Config Manage")
@PermCode(code = "manage", nameCn = "社交登录配置管理", nameEn = "Social Login Config Manage")
@Operation(summary = "修改平台配置")
@PostMapping("/update")
public Result<Void> update(@RequestBody @Validated SocialLoginConfigParam param) {
@@ -54,7 +54,7 @@ public class SocialLoginConfigController {
return Res.ok();
}
@PermCode(code = "manage", nameCn = "配置管理", nameEn = "Config Manage")
@PermCode(code = "manage", nameCn = "社交登录配置管理", nameEn = "Social Login Config Manage")
@Operation(summary = "切换平台启用状态(仅已配置平台可启停)")
@PostMapping("/update-enabled")
public Result<Void> updateEnabled(