(cashfeatier): 添加收银台支付功能

- 新增收银台类型枚举和相关参数类
- 实现收银台订单创建和支付逻辑
- 添加收银台配置管理和查询功能- 优化支付辅助服务,增加订单状态检查和限额验证
This commit is contained in:
DaxPay
2024-11-26 20:26:54 +08:00
parent eaa936c9c7
commit c842447d2f
16 changed files with 605 additions and 26 deletions

View File

@@ -0,0 +1,34 @@
package org.dromara.daxpay.core.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.dromara.daxpay.core.exception.ConfigNotExistException;
import java.util.Arrays;
import java.util.Objects;
/**
* 收银台类型
* @author xxm
* @since 2024/11/26
*/
@Getter
@AllArgsConstructor
public enum CheckoutTypeEnum {
H5("h5", "H5"),
PC("pc", "PC"),
MINI_APP("app", "APP"),
;
private final String code;
private final String name;
public static CheckoutTypeEnum findBuyCode(String code){
return Arrays.stream(CheckoutTypeEnum.values())
.filter(value -> Objects.equals(value.getCode(), code))
.findFirst()
.orElseThrow(() -> new ConfigNotExistException("不支持的收银台类型"));
}
}

View File

@@ -1,23 +0,0 @@
package org.dromara.daxpay.core.param.assist;
import org.dromara.daxpay.core.param.PaymentCommonParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/**
* 查询OpenId参数
* @author xxm
* @since 2024/9/19
*/
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@Schema(title = "查询OpenId参数")
public class QueryAuthParam extends PaymentCommonParam {
@Schema(description = "标识码")
private String queryCode;
}

View File

@@ -0,0 +1,92 @@
package org.dromara.daxpay.core.param.cashier;
import cn.hutool.core.date.DatePattern;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.dromara.daxpay.core.enums.CheckoutTypeEnum;
import org.dromara.daxpay.core.param.PaymentCommonParam;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 收银台创建参数
* @author xxm
* @since 2024/11/26
*/
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@Schema(title = "收银台创建参数")
public class CheckoutParam extends PaymentCommonParam {
/**
* 收银台类型
* @see CheckoutTypeEnum
*/
@NotBlank(message = "收银台类型不可为空")
private String checkoutType;
/** 商户订单号 */
@Schema(description = "商户订单号")
@NotBlank(message = "商户订单号不可为空")
@Size(max = 100, message = "商户订单号不可超过100位")
private String bizOrderNo;
/** 支付标题 */
@Schema(description = "支付标题")
@NotBlank(message = "支付标题不可为空")
@Size(max = 100, message = "支付标题不可超过100位")
private String title;
/** 支付描述 */
@Schema(description = "支付描述")
@Size(max = 500, message = "支付描述不可超过500位")
private String description;
/** 是否开启分账 */
@Schema(description = "是否开启分账")
private Boolean allocation;
/** 自动分账 */
@Schema(description = "自动分账")
private Boolean autoAllocation;
/** 过期时间 */
@Schema(description = "过期时间")
@JsonFormat(pattern = DatePattern.NORM_DATETIME_PATTERN)
private LocalDateTime expiredTime;
/** 支付金额 */
@Schema(description = "支付金额")
@NotNull(message = "支付金额不可为空")
@DecimalMin(value = "0.01", message = "支付金额不可小于0.01元")
@Digits(integer = 8, fraction = 2, message = "支付金额精度到分, 且要小于一亿元")
private BigDecimal amount;
/**
* 支付扩展参数
*/
@Schema(description = "支付扩展参数")
@Size(max = 2048, message = "支付扩展参数不可超过2048位")
private String extraParam;
/** 商户扩展参数,回调时会原样返回 */
@Schema(description = "商户扩展参数")
@Size(max = 500, message = "商户扩展参数不可超过500位")
private String attach;
/** 同步跳转地址, 支付完毕后用户浏览器返回到该地址, 不传输跳转到默认地址 */
@Schema(description = "同步通知URL")
@Size(max = 200, message = "同步通知URL不可超过200位")
private String returnUrl;
/** 异步通知地址 */
@Schema(description = "异步通知地址")
@Size(max = 200, message = "异步通知地址不可超过200位")
private String notifyUrl;
}

View File

@@ -0,0 +1,28 @@
package org.dromara.daxpay.core.param.cashier;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 收银台支付参数
* @author xxm
* @since 2024/11/26
*/
@Data
@Accessors(chain = true)
@Schema(title = "收银台支付参数")
public class CheckoutPayParam {
@Schema(description = "要支付的订单号")
private String bizOrderNo;
@Schema(description = "支付配置项ID")
private Long itemId;
@Schema(description = "唯一标识")
private String openId;
@Schema(description = "描述")
private String description;
}

View File

@@ -0,0 +1,21 @@
package org.dromara.daxpay.core.result.cashier;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 收银台订单结算和配置信息
* @author xxm
* @since 2024/11/26
*/
@Data
@Accessors(chain = true)
@Schema(title = "收银台订单结算和配置信息")
public class CheckoutInfoResult {
/** 订单信息 */
/** 配置信息 */
}

View File

@@ -0,0 +1,20 @@
package org.dromara.daxpay.core.result.cashier;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 收银台响应参数
* @author xxm
* @since 2024/11/26
*/
@Data
@Accessors(chain = true)
@Schema(title = "收银台响应参数")
public class CheckoutUrlResult {
@Schema(description = "收银台链接")
private String url;
}

View File

@@ -0,0 +1,48 @@
package org.dromara.daxpay.service.controller.unipay;
import cn.bootx.platform.core.rest.Res;
import cn.bootx.platform.core.rest.result.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.dromara.daxpay.core.param.cashier.CheckoutParam;
import org.dromara.daxpay.core.param.cashier.CheckoutPayParam;
import org.dromara.daxpay.core.result.DaxResult;
import org.dromara.daxpay.core.result.cashier.CheckoutUrlResult;
import org.dromara.daxpay.core.util.DaxRes;
import org.dromara.daxpay.service.common.anno.PaymentVerify;
import org.dromara.daxpay.service.service.cashier.CheckoutService;
import org.springframework.web.bind.annotation.*;
/**
* 收银台服务
* @author xxm
* @since 2024/11/26
*/
@Tag(name = "收银台服务")
@RestController
@RequestMapping("/unipay/checkout")
@RequiredArgsConstructor
public class CheckoutController {
private final CheckoutService checkoutService;
@PaymentVerify
@Operation(summary = "创建一个收银台链接")
@PostMapping("/creat")
public DaxResult<CheckoutUrlResult> creat(@RequestBody CheckoutParam checkoutParam){
return DaxRes.ok(checkoutService.creat(checkoutParam));
}
@Operation(summary = "获取收银台订单信息")
@GetMapping("/info")
public Result<Void> getInfo(){
return Res.ok();
}
@Operation(summary = "发起支付")
@PostMapping("/pay")
public Result<Void> pay(@RequestBody CheckoutPayParam checkoutParam){
return Res.ok();
}
}

View File

@@ -0,0 +1,18 @@
package org.dromara.daxpay.service.dao.config.checkout;
import cn.bootx.platform.common.mybatisplus.impl.BaseManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.daxpay.service.entity.config.checkout.CheckoutItemConfig;
import org.springframework.stereotype.Repository;
/**
* 收银台配置项
* @author xxm
* @since 2024/11/26
*/
@Slf4j
@Repository
@RequiredArgsConstructor
public class CheckoutItemConfigManager extends BaseManager<CheckoutItemConfigMapper, CheckoutItemConfig> {
}

View File

@@ -0,0 +1,14 @@
package org.dromara.daxpay.service.dao.config.checkout;
import com.github.yulichang.base.MPJBaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.dromara.daxpay.service.entity.config.checkout.CheckoutItemConfig;
/**
* 收银台配置项
* @author xxm
* @since 2024/11/26
*/
@Mapper
public interface CheckoutItemConfigMapper extends MPJBaseMapper<CheckoutItemConfig> {
}

View File

@@ -1,9 +1,13 @@
package org.dromara.daxpay.service.entity.config.checkout;
import cn.bootx.platform.common.mybatisplus.function.ToResult;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.dromara.daxpay.service.common.entity.MchAppBaseEntity;
import org.dromara.daxpay.service.convert.config.CheckoutGroupConfigConvert;
import org.dromara.daxpay.service.param.config.checkout.CheckoutGroupConfigParam;
import org.dromara.daxpay.service.result.config.checkout.CheckoutGroupConfigResult;
/**
* 收银台类目配置
@@ -13,7 +17,7 @@ import org.dromara.daxpay.service.common.entity.MchAppBaseEntity;
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
public class CheckoutGroupConfig extends MchAppBaseEntity {
public class CheckoutGroupConfig extends MchAppBaseEntity implements ToResult<CheckoutGroupConfigResult> {
/** 类型 web/h5/小程序 */
private String type;
@@ -23,4 +27,16 @@ public class CheckoutGroupConfig extends MchAppBaseEntity {
/** 排序 */
private Double sort;
public static CheckoutGroupConfig init(CheckoutGroupConfigParam param){
return CheckoutGroupConfigConvert.CONVERT.toEntity(param);
}
/**
* 转换
*/
@Override
public CheckoutGroupConfigResult toResult() {
return CheckoutGroupConfigConvert.CONVERT.toResult(this);
}
}

View File

@@ -1,11 +1,15 @@
package org.dromara.daxpay.service.entity.config.checkout;
import cn.bootx.platform.common.mybatisplus.function.ToResult;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.dromara.daxpay.core.enums.ChannelEnum;
import org.dromara.daxpay.core.enums.PayMethodEnum;
import org.dromara.daxpay.service.common.entity.MchAppBaseEntity;
import org.dromara.daxpay.service.convert.config.CheckoutItemConfigConvert;
import org.dromara.daxpay.service.param.config.checkout.CheckoutItemConfigParam;
import org.dromara.daxpay.service.result.config.checkout.CheckoutItemConfigResult;
/**
* 收银台配置项
@@ -15,7 +19,7 @@ import org.dromara.daxpay.service.common.entity.MchAppBaseEntity;
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
public class CheckoutItemConfig extends MchAppBaseEntity {
public class CheckoutItemConfig extends MchAppBaseEntity implements ToResult<CheckoutItemConfigResult> {
/** 类目配置Id */
private Long classifyId;
@@ -38,4 +42,28 @@ public class CheckoutItemConfig extends MchAppBaseEntity {
/** 是否开启分账 */
private boolean allocation;
/**
* 类型
* 1. 扫码支付
* 2. 条码支付
* 3. 跳转链接
* 4. 小程序支付
* 5. 聚合支付
*/
private String type;
/**
* 构造
*/
public static CheckoutItemConfig init(CheckoutItemConfigParam param) {
return CheckoutItemConfigConvert.CONVERT.toEntity(param);
}
/**
* 转换
*/
@Override
public CheckoutItemConfigResult toResult() {
return CheckoutItemConfigConvert.CONVERT.toResult(this);
}
}

View File

@@ -14,7 +14,7 @@ import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 支付订单
* 支付订单显示对象, 不与 PayOrderResult重名
* @author xxm
* @since 2021/2/25
*/

View File

@@ -0,0 +1,151 @@
package org.dromara.daxpay.service.service.cashier;
import cn.bootx.platform.core.exception.ValidationFailedException;
import cn.bootx.platform.core.util.BigDecimalUtil;
import cn.bootx.platform.core.util.DateTimeUtil;
import cn.bootx.platform.starter.redis.delay.service.DelayJobService;
import cn.hutool.core.bean.BeanUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.daxpay.core.enums.PayAllocStatusEnum;
import org.dromara.daxpay.core.enums.PayRefundStatusEnum;
import org.dromara.daxpay.core.enums.PayStatusEnum;
import org.dromara.daxpay.core.exception.AmountExceedLimitException;
import org.dromara.daxpay.core.exception.TradeStatusErrorException;
import org.dromara.daxpay.core.param.cashier.CheckoutParam;
import org.dromara.daxpay.core.util.PayUtil;
import org.dromara.daxpay.core.util.TradeNoGenerateUtil;
import org.dromara.daxpay.service.code.DaxPayCode;
import org.dromara.daxpay.service.common.context.MchAppLocal;
import org.dromara.daxpay.service.common.local.PaymentContextLocal;
import org.dromara.daxpay.service.dao.order.pay.PayOrderManager;
import org.dromara.daxpay.service.entity.order.pay.PayOrder;
import org.dromara.daxpay.service.service.order.pay.PayOrderQueryService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
/**
* 收银台支撑服务
* @author xxm
* @since 2024/11/26
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CheckoutAssistService {
private final DelayJobService delayJobService;
private final PayOrderManager payOrderManager;
private final PayOrderQueryService payOrderQueryService;
/**
* 校验支付状态,支付成功则返回,支付失败则抛出对应的异常
*/
public PayOrder getOrderAndCheck(String orderNo) {
PayOrder payOrder = payOrderQueryService.findByOrderNo(orderNo).orElse(null);
return getOrderAndCheck(payOrder);
}
/**
* 校验支付状态,支付成功则返回,支付失败则抛出对应的异常
*/
public PayOrder getOrderAndCheck(CheckoutParam param) {
// 根据订单查询支付记录
PayOrder payOrder = payOrderQueryService.findByBizOrderNo(param.getBizOrderNo(), param.getAppId()).orElse(null);
return getOrderAndCheck(payOrder);
}/**
* 校验支付状态,支付成功则返回,支付失败则抛出对应的异常
*/
public PayOrder getOrderAndCheck(PayOrder payOrder) {
if (Objects.nonNull(payOrder)) {
// 已经支付状态
if (PayStatusEnum.SUCCESS.getCode()
.equals(payOrder.getStatus())) {
throw new TradeStatusErrorException("已经支付成功,请勿重新支付");
}
// 支付失败类型状态
List<String> tradesStatus = List.of(
PayStatusEnum.FAIL.getCode(),
PayStatusEnum.CLOSE.getCode(),
PayStatusEnum.CANCEL.getCode());
if (tradesStatus.contains(payOrder.getStatus())) {
throw new TradeStatusErrorException("支付失败或已经被关闭");
}
// 退款类型状态
if (Objects.equals(payOrder.getRefundStatus(), PayRefundStatusEnum.REFUNDING.getCode())) {
throw new TradeStatusErrorException("该订单处于退款状态");
}
// 其他状态直接抛出兜底异常
throw new TradeStatusErrorException("订单不是待支付状态,请重新确认订单状态");
}
return null;
}
/**
* 检验订单是否超过限额
*/
public void validationLimitAmount(CheckoutParam checkoutParam) {
MchAppLocal mchAppInfo = PaymentContextLocal.get()
.getMchAppInfo();
// 总额校验
if (BigDecimalUtil.isGreaterThan(checkoutParam.getAmount(),mchAppInfo.getLimitAmount())) {
throw new AmountExceedLimitException("支付金额超过限额");
}
}
/**
* 校验订单超时时间是否正常
*/
public void validationExpiredTime(CheckoutParam payParam) {
LocalDateTime expiredTime = this.getExpiredTime(payParam);
if (Objects.nonNull(expiredTime) && DateTimeUtil.lt(expiredTime,LocalDateTime.now())) {
throw new ValidationFailedException("支付超时时间设置有误, 请检查!");
}
}
/**
* 创建支付订单并保存, 返回支付订单
*/
@Transactional(rollbackFor = Exception.class)
public PayOrder createPayOrder(CheckoutParam checkoutParam) {
// 订单超时时间
LocalDateTime expiredTime = this.getExpiredTime(checkoutParam);
// 构建支付订单对象
PayOrder order = new PayOrder();
BeanUtil.copyProperties(checkoutParam, order);
order.setOrderNo(TradeNoGenerateUtil.pay())
.setStatus(PayStatusEnum.WAIT.getCode())
.setRefundStatus(PayRefundStatusEnum.NO_REFUND.getCode())
.setExpiredTime(expiredTime)
.setRefundableBalance(checkoutParam.getAmount());
// 如果支持分账, 设置分账状态为待分账
if (order.getAllocation()) {
order.setAllocStatus(PayAllocStatusEnum.WAITING.getCode());
}
payOrderManager.save(order);
// 注册支付超时任务
delayJobService.registerByTransaction(order.getId(), DaxPayCode.Event.MERCHANT_PAY_TIMEOUT, order.getExpiredTime());
return order;
}
/**
* 获取支付订单超时时间
*/
private LocalDateTime getExpiredTime(CheckoutParam payParam) {
MchAppLocal mchAppLocal = PaymentContextLocal.get().getMchAppInfo();
// 支付参数传入
if (Objects.nonNull(payParam.getExpiredTime())) {
return payParam.getExpiredTime();
}
// 根据商户应用配置计算出时间
return PayUtil.getPaymentExpiredTime(mchAppLocal.getOrderTimeout());
}
}

View File

@@ -0,0 +1,105 @@
package org.dromara.daxpay.service.service.cashier;
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.dromara.daxpay.core.enums.CheckoutTypeEnum;
import org.dromara.daxpay.core.exception.TradeProcessingException;
import org.dromara.daxpay.core.exception.UnsupportedAbilityException;
import org.dromara.daxpay.core.param.cashier.CheckoutParam;
import org.dromara.daxpay.core.result.cashier.CheckoutUrlResult;
import org.dromara.daxpay.service.entity.config.PlatformConfig;
import org.dromara.daxpay.service.entity.order.pay.PayOrder;
import org.dromara.daxpay.service.service.assist.PaymentAssistService;
import org.dromara.daxpay.service.service.config.CheckoutConfigService;
import org.dromara.daxpay.service.service.config.PlatformConfigService;
import org.springframework.stereotype.Service;
import java.util.Objects;
/**
* 收银台服务
* @author xxm
* @since 2024/11/26
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CheckoutService {
private final CheckoutAssistService payAssistService;
private final LockTemplate lockTemplate;
private final PlatformConfigService platformConfigService;
private final PaymentAssistService paymentAssistService;
private final CheckoutConfigService checkoutConfigService;
/**
* 生成收银台链接
*/
public CheckoutUrlResult creat(CheckoutParam checkoutParam){
// 校验支付限额
payAssistService.validationLimitAmount(checkoutParam);
// 校验超时时间, 不可早于当前
payAssistService.validationExpiredTime(checkoutParam);
// 获取商户订单号
String bizOrderNo = checkoutParam.getBizOrderNo();
// 加锁
LockInfo lock = lockTemplate.lock("payment:pay:" + bizOrderNo,10000,200);
if (Objects.isNull(lock)){
log.warn("正在发起调起收银台中,请勿重复操作");
throw new TradeProcessingException("正在发起调起收银台中,请勿重复操作");
}
try {
// 查询并检查订单
PayOrder payOrder = payAssistService.getOrderAndCheck(checkoutParam);
// 订单已经存在直接返回链接, 不存在创建订单后返回链接
if (Objects.isNull(payOrder)){
// 执行支付前的保存动作, 保存支付订单和扩展记录
payOrder = payAssistService.createPayOrder(checkoutParam);
String checkoutUrl = this.getCheckoutUrl(payOrder.getOrderNo(), checkoutParam.getCheckoutType());
return new CheckoutUrlResult().setUrl(checkoutUrl);
} else {
// 直接返回收银台链接
String checkoutUrl = this.getCheckoutUrl(payOrder.getOrderNo(), checkoutParam.getCheckoutType());
return new CheckoutUrlResult().setUrl(checkoutUrl);
}
} finally {
lockTemplate.releaseLock(lock);
}
}
/**
* 获取收银台链接
*/
public String getCheckoutUrl(String code, String checkoutType){
CheckoutTypeEnum checkoutTypeEnum = CheckoutTypeEnum.findBuyCode(checkoutType);
PlatformConfig config = platformConfigService.getConfig();
switch (checkoutTypeEnum) {
case H5 -> {
return StrUtil.format("{}/checkout/{}",config.getGatewayMobileUrl(), code);
}
case PC -> {
return StrUtil.format("{}/checkout/{}",config.getGatewayPcUrl(), code);
}
case MINI_APP -> {
throw new UnsupportedAbilityException("暂不支持小程序收银台");
}
default -> throw new UnsupportedAbilityException("不支持的收银台类型");
}
}
/**
* 获取收银台相关信息, 不需要签名和鉴权
*/
public void info(String orderNo){
// 订单信息
PayOrder orderAndCheck = payAssistService.getOrderAndCheck(orderNo);
// 配置信息
paymentAssistService.initMchApp(orderAndCheck.getAppId());
// 获取相关配置
}
}

View File

@@ -0,0 +1,26 @@
package org.dromara.daxpay.service.service.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.daxpay.service.dao.config.checkout.CheckoutConfigManager;
import org.dromara.daxpay.service.dao.config.checkout.CheckoutGroupConfigManager;
import org.dromara.daxpay.service.dao.config.checkout.CheckoutItemConfigManager;
import org.springframework.stereotype.Service;
/**
* 收银台配置
* @author xxm
* @since 2024/11/26
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CheckoutConfigService {
private final CheckoutConfigManager checkoutConfigManager;
private final CheckoutGroupConfigManager checkoutGroupConfigManager;
private final CheckoutItemConfigManager checkoutItemConfigManager;
}

View File

@@ -85,6 +85,7 @@ public class PayAssistService {
.setReqTime(payParam.getReqTime())
.setChannel(payParam.getChannel())
.setMethod(payParam.getMethod())
.setStatus(PayStatusEnum.PROGRESS.getCode())
.setExtraParam(payParam.getExtraParam());
if (!order.getAllocation()) {
order.setAllocStatus(null);