同步平台业务模块能力

This commit is contained in:
baiyanyun
2026-07-13 09:13:30 +08:00
parent e612f5e434
commit a1a38f62d8
224 changed files with 22331 additions and 1224 deletions

View File

@@ -6,10 +6,10 @@ import com.xspaceagi.pay.application.support.PayOrderBillNotifySupport;
import com.xspaceagi.pay.application.support.PayOrderBizNotifySupport;
import com.xspaceagi.pay.application.support.PayOrderGatewayQueryMerger;
import com.xspaceagi.pay.application.support.PayOrderGatewaySyncFailureSupport;
import com.xspaceagi.pay.domain.gateway.PaymentScanGateway;
import com.xspaceagi.pay.application.support.PayOrderStatusQuerySupport;
import com.xspaceagi.pay.domain.model.PayOrderModel;
import com.xspaceagi.pay.domain.repository.PayOrderRepository;
import com.xspaceagi.pay.sdk.dto.ScanOrderStatusQueryResponse;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryResponse;
import com.xspaceagi.pay.sdk.enums.PaymentStatus;
import com.xspaceagi.pay.spec.enums.PayBizNotifyStatus;
import com.xspaceagi.pay.spec.exception.PayGatewayClientException;
@@ -44,7 +44,7 @@ public class PayOrderGatewayReconcileRunner {
public static final int LOOKBACK_DAYS = 7;
private final PayOrderRepository payOrderRepository;
private final PaymentScanGateway paymentScanGateway;
private final PayOrderStatusQuerySupport payOrderStatusQuerySupport;
private final PayGatewayOutboundExecutor payGatewayOutboundExecutor;
private final IBillRpcService billRpcService;
@@ -116,9 +116,9 @@ public class PayOrderGatewayReconcileRunner {
e.getMessage());
return false;
}
ScanOrderStatusQueryResponse last;
PaymentStatusQueryResponse last;
try {
last = paymentScanGateway.queryOrderStatus(outbound, row.getGatewayPaymentOrderNo(), true);
last = payOrderStatusQuerySupport.queryOrderStatus(outbound, row, true);
} catch (PayGatewayClientException e) {
log.warn(
"[pay-reconcile] query failed payOrderId={} tenantId={} gatewayNo={} gatewayCode={} gatewayMessage={}",
@@ -152,7 +152,7 @@ public class PayOrderGatewayReconcileRunner {
}
private boolean tryNotifyTerminalAndMarkNotified(
PayOrderModel row, ScanOrderStatusQueryResponse last, PayBizNotifyStatus expectedCurrent) {
PayOrderModel row, PaymentStatusQueryResponse last, PayBizNotifyStatus expectedCurrent) {
if (!PayOrderBillNotifySupport.notifyBillPaymentCallback(billRpcService, row, last, false)) {
log.warn("[pay-reconcile] bill notify failed payOrderId={}, will retry next tick", row.getId());
return false;

View File

@@ -3,10 +3,10 @@ package com.xspaceagi.pay.application.schedule;
import com.xspaceagi.bill.sdk.rpc.IBillRpcService;
import com.xspaceagi.pay.application.support.PayOrderBillNotifySupport;
import com.xspaceagi.pay.application.support.PayOrderGatewayQueryMerger;
import com.xspaceagi.pay.domain.gateway.PaymentScanGateway;
import com.xspaceagi.pay.application.support.PayOrderStatusQuerySupport;
import com.xspaceagi.pay.domain.model.PayOrderModel;
import com.xspaceagi.pay.domain.repository.PayOrderRepository;
import com.xspaceagi.pay.sdk.dto.ScanOrderStatusQueryResponse;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryResponse;
import com.xspaceagi.pay.spec.enums.PayBizNotifyStatus;
import com.xspaceagi.pay.sdk.enums.PaymentStatus;
import com.xspaceagi.pay.spec.enums.PayOrderGatewaySyncStatus;
@@ -32,19 +32,19 @@ public class PayOrderPollRunner {
public static final String TASK_BEAN_ID = "payOrderPollTaskExecuteService";
private final PayOrderRepository payOrderRepository;
private final PaymentScanGateway paymentScanGateway;
private final PayOrderStatusQuerySupport payOrderStatusQuerySupport;
private final PayGatewayOutboundExecutor payGatewayOutboundExecutor;
private final IBillRpcService billRpcService;
private final ScheduleTaskApiService scheduleTaskApiService;
public PayOrderPollRunner(
PayOrderRepository payOrderRepository,
PaymentScanGateway paymentScanGateway,
PayOrderStatusQuerySupport payOrderStatusQuerySupport,
PayGatewayOutboundExecutor payGatewayOutboundExecutor,
IBillRpcService billRpcService,
ScheduleTaskApiService scheduleTaskApiService) {
this.payOrderRepository = payOrderRepository;
this.paymentScanGateway = paymentScanGateway;
this.payOrderStatusQuerySupport = payOrderStatusQuerySupport;
this.payGatewayOutboundExecutor = payGatewayOutboundExecutor;
this.billRpcService = billRpcService;
this.scheduleTaskApiService = scheduleTaskApiService;
@@ -98,7 +98,7 @@ public class PayOrderPollRunner {
return true;
}
ScanOrderStatusQueryResponse last = null;
PaymentStatusQueryResponse last = null;
PayGatewayOutbound outbound;
try {
outbound = payGatewayOutboundExecutor.resolveCached(row.getTenantId());
@@ -107,7 +107,7 @@ public class PayOrderPollRunner {
return false;
}
try {
last = paymentScanGateway.queryOrderStatus(outbound, row.getGatewayPaymentOrderNo(), true);
last = payOrderStatusQuerySupport.queryOrderStatus(outbound, row, true);
PayOrderGatewayQueryMerger.mergeIntoRow(row, last);
payOrderRepository.save(row);
@@ -136,7 +136,7 @@ public class PayOrderPollRunner {
/**
* 先通知业务,成功后再 CAS 为 NOTIFIED避免 NOTIFIED 但业务未收到回调。
*/
private boolean tryNotifyAndMarkNotified(PayOrderModel row, ScanOrderStatusQueryResponse last, Long payOrderId) {
private boolean tryNotifyAndMarkNotified(PayOrderModel row, PaymentStatusQueryResponse last, Long payOrderId) {
if (!PayOrderBillNotifySupport.notifyBillPaymentCallback(billRpcService, row, last, false)) {
log.warn("[pay-poll] bill notify failed orderId={}, will retry", payOrderId);
return false;
@@ -153,7 +153,7 @@ public class PayOrderPollRunner {
return false;
}
private boolean tryNotifyTimeoutAndMark(PayOrderModel row, ScanOrderStatusQueryResponse last, Long payOrderId) {
private boolean tryNotifyTimeoutAndMark(PayOrderModel row, PaymentStatusQueryResponse last, Long payOrderId) {
if (!PayOrderBillNotifySupport.notifyBillPaymentCallback(billRpcService, row, last, true)) {
log.warn("[pay-poll] bill notify timeout failed orderId={}, will retry", payOrderId);
return false;

View File

@@ -0,0 +1,9 @@
package com.xspaceagi.pay.application.service;
import com.xspaceagi.pay.sdk.dto.FileEntryUploadResponse;
import org.springframework.web.multipart.MultipartFile;
public interface FileEntryApplicationService {
FileEntryUploadResponse uploadMerchantOnboardingImage(MultipartFile file, String replaceFileKey);
}

View File

@@ -0,0 +1,52 @@
package com.xspaceagi.pay.application.service.impl;
import com.xspaceagi.pay.application.service.FileEntryApplicationService;
import com.xspaceagi.pay.application.support.PayGatewayOutboundExecutor;
import com.xspaceagi.pay.domain.gateway.FileEntryGateway;
import com.xspaceagi.pay.sdk.dto.FileEntryUploadResponse;
import com.xspaceagi.system.spec.common.RequestContext;
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
import com.xspaceagi.system.spec.exception.BizException;
import jakarta.annotation.Resource;
import java.io.IOException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@Slf4j
@Service
public class FileEntryApplicationServiceImpl implements FileEntryApplicationService {
@Resource
private FileEntryGateway payFileEntryGateway;
@Resource
private PayGatewayOutboundExecutor payGatewayOutboundExecutor;
@Override
public FileEntryUploadResponse uploadMerchantOnboardingImage(MultipartFile file, String replaceFileKey) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("请选择文件");
}
final byte[] fileBytes;
try {
fileBytes = file.getBytes();
} catch (IOException e) {
throw new IllegalStateException("read upload file failed", e);
}
String filename = file.getOriginalFilename();
String contentType = file.getContentType();
return payGatewayOutboundExecutor.invoke(
resolveTenantId(),
outbound -> payFileEntryGateway.uploadMerchantOnboardingImage(
outbound, fileBytes, filename, contentType, replaceFileKey));
}
private static long resolveTenantId() {
RequestContext<?> ctx = RequestContext.get();
if (ctx != null && ctx.getTenantId() != null) {
return ctx.getTenantId();
}
throw new BizException(ErrorCodeEnum.INVALID_PARAM.getCode(), "无法确定租户:请登录后重试");
}
}

View File

@@ -3,22 +3,37 @@ package com.xspaceagi.pay.application.service.impl;
import com.xspaceagi.bill.sdk.rpc.IBillRpcService;
import com.xspaceagi.pay.application.schedule.PayOrderPollRunner;
import com.xspaceagi.pay.application.support.PayGatewayOutboundExecutor;
import com.xspaceagi.pay.application.support.PayOrderExtSupport;
import com.xspaceagi.pay.application.support.PayOrderGatewayQueryMerger;
import com.xspaceagi.pay.application.support.PayOrderGatewaySyncFailureSupport;
import com.xspaceagi.pay.application.support.PayOrderSettlementSyncService;
import com.xspaceagi.pay.application.support.PayOrderStatusQuerySupport;
import com.xspaceagi.pay.spec.PayOrderScheduleConstants;
import com.xspaceagi.pay.domain.gateway.PaymentAppGateway;
import com.xspaceagi.pay.domain.gateway.PaymentH5Gateway;
import com.xspaceagi.pay.domain.gateway.PaymentMiniPayGateway;
import com.xspaceagi.pay.domain.gateway.PaymentScanGateway;
import com.xspaceagi.pay.domain.model.PayOrderModel;
import com.xspaceagi.pay.domain.repository.PayOrderRepository;
import com.xspaceagi.pay.sdk.dto.AppOrderRpcCreateRequest;
import com.xspaceagi.pay.sdk.dto.AppTransactionRpcCreateRequest;
import com.xspaceagi.pay.sdk.dto.CashierSessionCreateRequest;
import com.xspaceagi.pay.sdk.dto.CashierSessionCreateResponse;
import com.xspaceagi.pay.sdk.dto.PaymentOrderCreateResponse;
import com.xspaceagi.pay.sdk.dto.H5OrderRpcCreateRequest;
import com.xspaceagi.pay.sdk.dto.H5TransactionRpcCreateRequest;
import com.xspaceagi.pay.sdk.dto.MiniPayOrderRpcCreateRequest;
import com.xspaceagi.pay.sdk.dto.MiniPayTransactionRpcCreateRequest;
import com.xspaceagi.pay.sdk.dto.OrderAndTransactionCreateResponse;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryRequest;
import com.xspaceagi.pay.sdk.dto.ScanOrderCreateRequest;
import com.xspaceagi.pay.sdk.dto.ScanOrderStatusQueryResponse;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryResponse;
import com.xspaceagi.pay.sdk.service.IPaymentRpcService;
import com.xspaceagi.pay.spec.enums.PayBizNotifyStatus;
import com.xspaceagi.pay.sdk.dto.OrderCreateResponse;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import com.xspaceagi.pay.sdk.enums.PayClientScene;
import com.xspaceagi.pay.sdk.enums.PayMode;
import com.xspaceagi.pay.sdk.enums.PaymentStatus;
import com.xspaceagi.pay.spec.enums.PayOrderGatewaySyncStatus;
import com.xspaceagi.pay.spec.exception.PayGatewayClientException;
import com.xspaceagi.system.spec.common.RequestContext;
@@ -30,7 +45,9 @@ import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import java.net.URI;
import java.time.Instant;
import java.util.Date;
@@ -43,6 +60,18 @@ public class PaymentRpcServiceImpl implements IPaymentRpcService {
@Resource
private PaymentScanGateway paymentScanGateway;
@Resource
private PaymentMiniPayGateway paymentMiniPayGateway;
@Resource
private PaymentH5Gateway paymentH5Gateway;
@Resource
private PaymentAppGateway paymentAppGateway;
@Resource
private PayOrderStatusQuerySupport payOrderStatusQuerySupport;
@Resource
private PayGatewayOutboundExecutor payGatewayOutboundExecutor;
@@ -55,52 +84,189 @@ public class PaymentRpcServiceImpl implements IPaymentRpcService {
@Resource
private PayOrderSettlementSyncService payOrderSettlementSyncService;
public PaymentOrderCreateResponse createOrderForScan(ScanOrderCreateRequest request) {
Assert.hasText(request.getBizOrderNo(), "bizOrderNo cannot be left blank");
Assert.notNull(request.getOrderAmount(), "orderAmount cannot be left blank");
Assert.isTrue(request.getOrderAmount() > 0, "orderAmount must be greater than 0");
Assert.hasText(request.getSubject(), "subject cannot be left blank");
long tenantId = resolveTenantId();
PayOrderModel existing =
payOrderRepository.findByTenantIdAndBizOrderNo(tenantId, request.getBizOrderNo()).orElse(null);
if (existing != null) {
return resumeExistingOrder(existing);
}
PayOrderModel row = PayOrderModel.builder()
.tenantId(tenantId)
.bizOrderNo(request.getBizOrderNo())
.bizScene(null)
.orderAmount(request.getOrderAmount())
.subject(request.getSubject())
.ext(request.getExt())
.payMode(PayMode.scan)
.platformFee(0L)
.providerFee(0L)
.netAmount(0L)
.gatewaySyncStatus(PayOrderGatewaySyncStatus.PENDING)
.build();
try {
row = payOrderRepository.save(row);
} catch (DuplicateKeyException e) {
PayOrderModel race = payOrderRepository.findByTenantIdAndBizOrderNo(tenantId, request.getBizOrderNo()).orElse(null);
if (race == null) {
throw new BizException(ErrorCodeEnum.SYS_ERROR.getCode(), "创建订单冲突,请重试");
}
return reconcileAfterDuplicate(race);
}
return callGatewayAndFinish(row);
@Override
public OrderCreateResponse createOrderForScan(ScanOrderCreateRequest request) {
return createPayOrder(
PayMode.scan,
request.getBizOrderNo(),
request.getOrderAmount(),
request.getSubject(),
request.getExt());
}
@Override
public OrderCreateResponse createOrderForMiniPay(MiniPayOrderRpcCreateRequest request) {
return createPayOrder(
PayMode.minipay,
request.getBizOrderNo(),
request.getOrderAmount(),
request.getSubject(),
request.getExt());
}
@Override
public OrderCreateResponse createOrderForH5(H5OrderRpcCreateRequest request) {
return createPayOrder(
PayMode.h5,
request.getBizOrderNo(),
request.getOrderAmount(),
request.getSubject(),
request.getExt());
}
@Override
public OrderCreateResponse createOrderForApp(AppOrderRpcCreateRequest request) {
return createPayOrder(
PayMode.app,
request.getBizOrderNo(),
request.getOrderAmount(),
request.getSubject(),
request.getExt());
}
@Override
public OrderAndTransactionCreateResponse createMiniPayTransaction(MiniPayTransactionRpcCreateRequest request) {
Assert.hasText(request.getBizOrderNo(), "bizOrderNo cannot be left blank");
Assert.notNull(request.getPayChannel(), "payChannel cannot be null");
validateMiniPayChannelParams(request);
long tenantId = resolveTenantId();
PayOrderModel row = payOrderRepository
.findByTenantIdAndBizOrderNo(tenantId, request.getBizOrderNo().trim())
.orElse(null);
if (row == null) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.pay_order_not_found);
}
if (row.getPayMode() != PayMode.minipay) {
throw new BizException(ErrorCodeEnum.INVALID_PARAM.getCode(), "非小程序支付单");
}
if (row.getGatewaySyncStatus() != PayOrderGatewaySyncStatus.SUCCESS) {
throw new BizException(ErrorCodeEnum.INVALID_PARAM.getCode(), "支付单尚未同步网关,无法调起支付");
}
try {
OrderAndTransactionCreateResponse resp = payGatewayOutboundExecutor.invoke(
tenantId,
outbound -> paymentMiniPayGateway.createTransaction(
outbound,
row.getGatewayPaymentOrderNo(),
request.getPayChannel(),
request.getSubAppid(),
request.getOpenId(),
request.getBuyerId(),
request.getClientIp(),
request.getPayClientScene()));
mergeTransactionResponseIntoRow(row, resp);
if (request.getPayClientScene() != null) {
row.setExt(PayOrderExtSupport.mergePayClientScene(row.getExt(), request.getPayClientScene()));
}
if (row.getBizNotifyStatus() != PayBizNotifyStatus.POLLING) {
row.setBizNotifyStatus(PayBizNotifyStatus.POLLING);
}
payOrderRepository.save(row);
// 重试调起时若调度任务已结束,同 taskId 的 start 会将其拉回 CREATE 并继续轮询
payOrderPayPollRunner.startPoll(row.getId(), row.getTenantId());
return resp;
} catch (PayGatewayClientException e) {
throw PayGatewayExceptionMapper.toBizException(e);
}
}
@Override
public OrderAndTransactionCreateResponse createH5Transaction(H5TransactionRpcCreateRequest request) {
Assert.hasText(request.getBizOrderNo(), "bizOrderNo cannot be left blank");
Assert.notNull(request.getPayChannel(), "payChannel cannot be null");
Assert.hasText(request.getFrontNotifyUrl(), "frontNotifyUrl cannot be left blank");
String frontNotifyUrl = validateFrontNotifyUrl(request.getFrontNotifyUrl().trim());
long tenantId = resolveTenantId();
PayOrderModel row = payOrderRepository
.findByTenantIdAndBizOrderNo(tenantId, request.getBizOrderNo().trim())
.orElse(null);
if (row == null) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.pay_order_not_found);
}
if (row.getPayMode() != PayMode.h5) {
throw new BizException(ErrorCodeEnum.INVALID_PARAM.getCode(), "非H5支付单");
}
if (row.getGatewaySyncStatus() != PayOrderGatewaySyncStatus.SUCCESS) {
throw new BizException(ErrorCodeEnum.INVALID_PARAM.getCode(), "支付单尚未同步网关,无法调起支付");
}
try {
OrderAndTransactionCreateResponse resp = payGatewayOutboundExecutor.invoke(
tenantId,
outbound -> paymentH5Gateway.createTransaction(
outbound,
row.getGatewayPaymentOrderNo(),
request.getPayChannel(),
request.getClientIp(),
frontNotifyUrl));
mergeTransactionResponseIntoRow(row, resp);
row.setExt(PayOrderExtSupport.mergePayClientScene(row.getExt(), PayClientScene.H5_WEB));
if (row.getBizNotifyStatus() != PayBizNotifyStatus.POLLING) {
row.setBizNotifyStatus(PayBizNotifyStatus.POLLING);
}
payOrderRepository.save(row);
payOrderPayPollRunner.startPoll(row.getId(), row.getTenantId());
return resp;
} catch (PayGatewayClientException e) {
throw PayGatewayExceptionMapper.toBizException(e);
}
}
@Override
public OrderAndTransactionCreateResponse createAppTransaction(AppTransactionRpcCreateRequest request) {
Assert.hasText(request.getBizOrderNo(), "bizOrderNo cannot be left blank");
Assert.notNull(request.getPayChannel(), "payChannel cannot be null");
validateAppPayChannel(request.getPayChannel());
long tenantId = resolveTenantId();
PayOrderModel row = payOrderRepository
.findByTenantIdAndBizOrderNo(tenantId, request.getBizOrderNo().trim())
.orElse(null);
if (row == null) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.pay_order_not_found);
}
if (row.getPayMode() != PayMode.app) {
throw new BizException(ErrorCodeEnum.INVALID_PARAM.getCode(), "非 App 原生支付单");
}
if (row.getGatewaySyncStatus() != PayOrderGatewaySyncStatus.SUCCESS) {
throw new BizException(ErrorCodeEnum.INVALID_PARAM.getCode(), "支付单尚未同步网关,无法调起支付");
}
try {
OrderAndTransactionCreateResponse resp = payGatewayOutboundExecutor.invoke(
tenantId,
outbound -> paymentAppGateway.createTransaction(
outbound,
row.getGatewayPaymentOrderNo(),
request.getPayChannel(),
request.getClientIp(),
request.getPayClientScene() != null
? request.getPayClientScene()
: PayClientScene.NATIVE_APP));
mergeTransactionResponseIntoRow(row, resp);
row.setExt(PayOrderExtSupport.mergePayClientScene(row.getExt(), PayClientScene.NATIVE_APP));
if (row.getBizNotifyStatus() != PayBizNotifyStatus.POLLING) {
row.setBizNotifyStatus(PayBizNotifyStatus.POLLING);
}
payOrderRepository.save(row);
payOrderPayPollRunner.startPoll(row.getId(), row.getTenantId());
return resp;
} catch (PayGatewayClientException e) {
throw PayGatewayExceptionMapper.toBizException(e);
}
}
@Override
public CashierSessionCreateResponse createCashierSession(CashierSessionCreateRequest request) {
Assert.notNull(request, "request cannot be null");
Assert.hasText(request.getGatewayPaymentOrderNo(), "gatewayPaymentOrderNo cannot be left blank");
long tenantId = resolveTenantId();
PayOrderModel row;
String gatewayNo = request.getGatewayPaymentOrderNo().trim();
row = payOrderRepository.findByTenantIdAndGatewayPaymentOrderNo(tenantId, gatewayNo).orElse(null);
PayOrderModel row = payOrderRepository.findByTenantIdAndGatewayPaymentOrderNo(tenantId, gatewayNo).orElse(null);
if (row == null) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.pay_order_not_found);
}
@@ -117,7 +283,8 @@ public class PaymentRpcServiceImpl implements IPaymentRpcService {
request.getBizRedirectUrl()));
}
public ScanOrderStatusQueryResponse queryStatus(PaymentStatusQueryRequest request) {
@Override
public PaymentStatusQueryResponse queryStatus(PaymentStatusQueryRequest request) {
Assert.hasText(request.getGatewayPaymentOrderNo(), "gatewayPaymentOrderNo cannot be left blank");
long tenantId = resolveTenantId();
@@ -127,9 +294,9 @@ public class PaymentRpcServiceImpl implements IPaymentRpcService {
if (row == null) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.pay_order_not_found);
}
ScanOrderStatusQueryResponse resp = payGatewayOutboundExecutor.invoke(
PaymentStatusQueryResponse resp = payGatewayOutboundExecutor.invoke(
tenantId,
outbound -> paymentScanGateway.queryOrderStatus(outbound, request.getGatewayPaymentOrderNo(), true));
outbound -> payOrderStatusQuerySupport.queryOrderStatus(outbound, row, true));
PayOrderGatewayQueryMerger.mergeIntoRow(row, resp);
payOrderRepository.save(row);
return resp;
@@ -141,36 +308,101 @@ public class PaymentRpcServiceImpl implements IPaymentRpcService {
return payOrderSettlementSyncService.syncSettlementForBizOrderNo(tenantId, bizOrderNo);
}
private PaymentOrderCreateResponse reconcileAfterDuplicate(PayOrderModel race) {
return resumeExistingOrder(race);
private OrderCreateResponse createPayOrder(
PayMode payMode, String bizOrderNo, Long orderAmount, String subject, String ext) {
Assert.hasText(bizOrderNo, "bizOrderNo cannot be left blank");
Assert.notNull(orderAmount, "orderAmount cannot be left blank");
Assert.isTrue(orderAmount > 0, "orderAmount must be greater than 0");
Assert.hasText(subject, "subject cannot be left blank");
long tenantId = resolveTenantId();
PayOrderModel existing =
payOrderRepository.findByTenantIdAndBizOrderNo(tenantId, bizOrderNo).orElse(null);
if (existing != null) {
if (existing.getPayMode() != null && existing.getPayMode() != payMode) {
return recreateForSwitchedPayMode(existing, payMode, orderAmount, subject, ext);
}
return resumeExistingOrder(existing, payMode);
}
PayOrderModel row = PayOrderModel.builder()
.tenantId(tenantId)
.bizOrderNo(bizOrderNo)
.bizScene(null)
.orderAmount(orderAmount)
.subject(subject)
.ext(ext)
.payMode(payMode)
.platformFee(0L)
.providerFee(0L)
.netAmount(0L)
.gatewaySyncStatus(PayOrderGatewaySyncStatus.PENDING)
.build();
try {
row = payOrderRepository.save(row);
} catch (DuplicateKeyException e) {
PayOrderModel race = payOrderRepository.findByTenantIdAndBizOrderNo(tenantId, bizOrderNo).orElse(null);
if (race == null) {
throw new BizException(ErrorCodeEnum.SYS_ERROR.getCode(), "创建订单冲突,请重试");
}
assertPayMode(race, payMode);
return resumeExistingOrder(race, payMode);
}
return callGatewayAndFinish(row, payMode);
}
private static void assertPayMode(PayOrderModel row, PayMode expectedPayMode) {
if (row.getPayMode() == null || row.getPayMode() != expectedPayMode) {
throw new BizException(ErrorCodeEnum.INVALID_PARAM.getCode(), "订单支付方式不匹配");
}
}
private OrderCreateResponse recreateForSwitchedPayMode(
PayOrderModel row, PayMode targetPayMode, long orderAmount, String subject, String ext) {
row.setPayMode(targetPayMode);
row.setOrderAmount(orderAmount);
row.setSubject(subject);
row.setExt(ext);
row.setPayChannel(null);
row.setPlatformFee(0L);
row.setProviderFee(0L);
row.setNetAmount(0L);
row.setGatewayPaymentOrderNo(null);
row.setGatewayOrderStatus(null);
row.setGatewayLastError(null);
row.setPaidAt(null);
row.setGatewaySyncStatus(PayOrderGatewaySyncStatus.PENDING);
row.setBizNotifyStatus(null);
payOrderRepository.save(row);
return callGatewayAndFinish(row, targetPayMode);
}
/**
* 已存在行的续跑逻辑。FAILED→PENDING 使用 CAS仅一行匹配 FAILED 才更新),降低多实例同时重试时对网关的重复调用。
* 网关已 SUCCESS 时幂等返回已有单号minipay 可能尚未调起渠道)。
*/
private PaymentOrderCreateResponse resumeExistingOrder(PayOrderModel row) {
private OrderCreateResponse resumeExistingOrder(PayOrderModel row, PayMode payMode) {
if (PayOrderGatewaySyncStatus.SUCCESS == row.getGatewaySyncStatus()) {
throw new BizException(ErrorCodeEnum.INVALID_PARAM.getCode(), "订单已存在");
return toOrderCreateResponse(row);
}
if (PayOrderGatewaySyncStatus.PENDING == row.getGatewaySyncStatus()) {
if (!isStalePending(row)) {
throw new BizException(ErrorCodeEnum.INVALID_PARAM.getCode(), "订单处理中,请稍后再试");
}
// 长时间pending,可能是因为即将调用网关时发生了重启,没有调下游,允许重新调用,依赖下游幂等
return callGatewayAndFinish(row);
return callGatewayAndFinish(row, payMode);
}
if (PayOrderGatewaySyncStatus.FAILED == row.getGatewaySyncStatus()) {
return casRetryFailedToPendingThenCallGateway(row);
return casRetryFailedToPendingThenCallGateway(row, payMode);
}
throw new BizException(ErrorCodeEnum.INVALID_PARAM.getCode(), "订单状态异常,无法创建");
}
private PaymentOrderCreateResponse casRetryFailedToPendingThenCallGateway(PayOrderModel row) {
private OrderCreateResponse casRetryFailedToPendingThenCallGateway(PayOrderModel row, PayMode payMode) {
int n = payOrderRepository.casUpdateGatewaySyncFromFailedToPending(row.getId(), row.getTenantId(), row.getBizOrderNo());
if (n == 1) {
row.setGatewaySyncStatus(PayOrderGatewaySyncStatus.PENDING);
row.setGatewayLastError(null);
return callGatewayAndFinish(row);
return callGatewayAndFinish(row, payMode);
}
PayOrderModel fresh = payOrderRepository
.findByTenantIdAndBizOrderNo(row.getTenantId(), row.getBizOrderNo())
@@ -178,7 +410,25 @@ public class PaymentRpcServiceImpl implements IPaymentRpcService {
if (fresh == null) {
throw new BizException(ErrorCodeEnum.SYS_ERROR.getCode(), "订单冲突,请重试");
}
return resumeExistingOrder(fresh);
assertPayMode(fresh, payMode);
return resumeExistingOrder(fresh, payMode);
}
private static OrderCreateResponse toOrderCreateResponse(PayOrderModel row) {
PaymentStatus status = null;
if (StringUtils.hasText(row.getGatewayOrderStatus())) {
try {
status = PaymentStatus.valueOf(row.getGatewayOrderStatus());
} catch (IllegalArgumentException ignored) {
// ignore unmapped gateway status string
}
}
return OrderCreateResponse.builder()
.gatewayPaymentOrderNo(row.getGatewayPaymentOrderNo())
.bizOrderNo(row.getBizOrderNo())
.status(status)
.payMode(row.getPayMode())
.build();
}
private static boolean isStalePending(PayOrderModel row) {
@@ -189,22 +439,48 @@ public class PaymentRpcServiceImpl implements IPaymentRpcService {
return anchor.toInstant().plus(PayOrderScheduleConstants.STALE_GATEWAY_SYNC_PENDING).isBefore(Instant.now());
}
private PaymentOrderCreateResponse callGatewayAndFinish(PayOrderModel row) {
private OrderCreateResponse callGatewayAndFinish(PayOrderModel row, PayMode payMode) {
try {
PaymentOrderCreateResponse resp =
paymentScanGateway.createOrder(
payGatewayOutboundExecutor.resolve(row.getTenantId()),
row.getBizOrderNo(),
row.getOrderAmount(),
row.getSubject(),
row.getExt());
OrderCreateResponse resp;
if (payMode == PayMode.minipay) {
resp = paymentMiniPayGateway.createOrder(
payGatewayOutboundExecutor.resolve(row.getTenantId()),
row.getBizOrderNo(),
row.getOrderAmount(),
row.getSubject(),
row.getExt());
} else if (payMode == PayMode.h5) {
resp = paymentH5Gateway.createOrder(
payGatewayOutboundExecutor.resolve(row.getTenantId()),
row.getBizOrderNo(),
row.getOrderAmount(),
row.getSubject(),
row.getExt());
} else if (payMode == PayMode.app) {
resp = paymentAppGateway.createOrder(
payGatewayOutboundExecutor.resolve(row.getTenantId()),
row.getBizOrderNo(),
row.getOrderAmount(),
row.getSubject(),
row.getExt());
} else {
resp = paymentScanGateway.createOrder(
payGatewayOutboundExecutor.resolve(row.getTenantId()),
row.getBizOrderNo(),
row.getOrderAmount(),
row.getSubject(),
row.getExt());
}
row.setGatewayPaymentOrderNo(resp.getGatewayPaymentOrderNo());
row.setGatewayOrderStatus(resp.getStatus() != null ? resp.getStatus().name() : null);
row.setGatewaySyncStatus(PayOrderGatewaySyncStatus.SUCCESS);
row.setGatewayLastError(null);
row.setBizNotifyStatus(PayBizNotifyStatus.POLLING);
payOrderRepository.save(row);
payOrderPayPollRunner.startPoll(row.getId(), row.getTenantId());
if (payMode == PayMode.scan) {
row.setBizNotifyStatus(PayBizNotifyStatus.POLLING);
payOrderRepository.save(row);
payOrderPayPollRunner.startPoll(row.getId(), row.getTenantId());
}
return resp;
} catch (PayGatewayClientException e) {
PayOrderGatewaySyncFailureSupport.finishGatewayCreateFailed(
@@ -213,6 +489,43 @@ public class PaymentRpcServiceImpl implements IPaymentRpcService {
}
}
private static void validateMiniPayChannelParams(MiniPayTransactionRpcCreateRequest request) {
if (request.getPayChannel() == PayChannel.WxPay) {
Assert.hasText(request.getSubAppid(), "subAppid cannot be left blank for WxPay");
Assert.hasText(request.getOpenId(), "openId cannot be left blank for WxPay");
return;
}
if (request.getPayChannel() == PayChannel.AliPay) {
Assert.hasText(request.getBuyerId(), "buyerId cannot be left blank for AliPay");
return;
}
throw new BizException(ErrorCodeEnum.INVALID_PARAM.getCode(), "小程序支付仅支持 WxPay 与 AliPay");
}
private static void validateAppPayChannel(PayChannel payChannel) {
if (payChannel != PayChannel.WxPay && payChannel != PayChannel.AliPay) {
throw new BizException(ErrorCodeEnum.INVALID_PARAM.getCode(), "App 原生支付仅支持 WxPay 与 AliPay");
}
}
private static void mergeTransactionResponseIntoRow(PayOrderModel row, OrderAndTransactionCreateResponse resp) {
if (resp.getPlatformFee() != null) {
row.setPlatformFee(resp.getPlatformFee());
}
if (resp.getProviderFee() != null) {
row.setProviderFee(resp.getProviderFee());
}
if (resp.getNetAmount() != null) {
row.setNetAmount(resp.getNetAmount());
}
if (resp.getPayChannel() != null) {
row.setPayChannel(resp.getPayChannel());
}
if (resp.getStatus() != null) {
row.setGatewayOrderStatus(resp.getStatus().name());
}
}
private static long resolveTenantId() {
Long tenantId = RequestContext.get() != null ? RequestContext.get().getTenantId() : null;
if (tenantId == null) {
@@ -227,4 +540,21 @@ public class PaymentRpcServiceImpl implements IPaymentRpcService {
}
return s.length() > max ? s.substring(0, max) : s;
}
private static String validateFrontNotifyUrl(String url) {
try {
URI uri = URI.create(url);
String scheme = uri.getScheme();
if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
throw new BizException(ErrorCodeEnum.INVALID_PARAM.getCode(), "frontNotifyUrl must start with http or https");
}
String host = uri.getHost();
if (host == null || host.isBlank()) {
throw new BizException(ErrorCodeEnum.INVALID_PARAM.getCode(), "Invalid frontNotifyUrl");
}
return url;
} catch (IllegalArgumentException e) {
throw new BizException(ErrorCodeEnum.INVALID_PARAM.getCode(), "Invalid frontNotifyUrl");
}
}
}

View File

@@ -1,12 +1,21 @@
package com.xspaceagi.pay.application.support;
import com.xspaceagi.bill.sdk.rpc.IBillRpcService;
import com.xspaceagi.bill.sdk.dto.AddRevenueRequest;
import com.xspaceagi.bill.spec.enums.PayStatusEnum;
import com.xspaceagi.bill.spec.enums.RevenueTargetTypeEnum;
import com.xspaceagi.bill.spec.enums.RevenueTypeEnum;
import com.xspaceagi.pay.domain.model.PayOrderModel;
import com.xspaceagi.pay.sdk.dto.ScanOrderStatusQueryResponse;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryResponse;
import com.xspaceagi.pay.sdk.enums.PaymentStatus;
import com.xspaceagi.pay.sdk.support.PayOrderExtKeys;
import lombok.extern.slf4j.Slf4j;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.HashMap;
import java.util.Map;
/** 支付结果通知 Bill进程内 RPC。 */
@Slf4j
public final class PayOrderBillNotifySupport {
@@ -44,9 +53,13 @@ public final class PayOrderBillNotifySupport {
* @return true 表示业务通知已成功(或无 Bill 回调目标)
*/
public static boolean notifyBillPaymentCallback(
IBillRpcService billRpcService, PayOrderModel row, ScanOrderStatusQueryResponse gatewayResp, boolean pollTimeout) {
IBillRpcService billRpcService, PayOrderModel row, PaymentStatusQueryResponse gatewayResp, boolean pollTimeout) {
Long orderId = parseNumericBizOrderNo(row.getBizOrderNo());
if (orderId == null) {
// 通用项目订单GP1_/GP2_— 无 Bill 回调目标;若已支付则计入开发者收益
if (gatewayResp != null && gatewayResp.getStatus() == PaymentStatus.PAID) {
return addGeneralProjectRevenue(billRpcService, row);
}
return true;
}
PaymentStatus st = gatewayResp != null ? gatewayResp.getStatus() : null;
@@ -66,6 +79,87 @@ public final class PayOrderBillNotifySupport {
}
}
/**
* 通用项目支付成功 → 计入开发者(项目创建者)收益。
* 从 ext 解析 generalProjectUserId 和 generalProjectId。
* userId 为 null 时跳过不阻断通知流程addRevenue 幂等,异常返回 false 触发重试。
*
* @return true 表示收益已记录或无需记录false 表示记录失败应重试
*/
private static boolean addGeneralProjectRevenue(IBillRpcService billRpcService, PayOrderModel row) {
Map<String, Object> extMap = PayOrderExtSupport.parseExt(row.getExt());
Long userId = parseLongExt(extMap.get(PayOrderExtKeys.GENERAL_PROJECT_USER_ID));
Long projectId = parseLongExt(extMap.get(PayOrderExtKeys.GENERAL_PROJECT_ID));
if (userId == null) {
log.warn(
"[pay-notify] general-project revenue skipped (no userId) payOrderId={} tenantId={}",
row.getId(),
row.getTenantId());
return true;
}
AddRevenueRequest req = new AddRevenueRequest();
req.setTenantId(row.getTenantId());
req.setUserId(userId);
req.setBizNo(row.getBizOrderNo());
req.setType(RevenueTypeEnum.PROJECT_PAY);
req.setTargetType(RevenueTargetTypeEnum.PROJECT);
if (projectId != null) {
req.setTargetId(projectId);
}
req.setRemark(row.getSubject());
Map<String, Object> extra = new HashMap<>();
extra.put("payOrderId", row.getId());
if (row.getBizOrderNo() != null) {
extra.put("bizOrderNo", row.getBizOrderNo());
}
if (row.getGatewayPaymentOrderNo() != null) {
extra.put("gatewayPaymentOrderNo", row.getGatewayPaymentOrderNo());
}
req.setExtra(extra);
try {
if (row.getOrderAmount() == null) {
log.warn(
"[pay-notify] general-project revenue skipped (null orderAmount) payOrderId={} tenantId={}",
row.getId(),
row.getTenantId());
return true;
}
req.setAmount(new BigDecimal(row.getOrderAmount()).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP));
billRpcService.addRevenue(req);
log.info(
"[pay-notify] general-project revenue added payOrderId={} tenantId={} userId={} projectId={} amount={}",
row.getId(),
row.getTenantId(),
userId,
projectId,
req.getAmount());
return true;
} catch (Exception e) {
log.warn(
"[pay-notify] general-project revenue failed payOrderId={} tenantId={} userId={} msg={}",
row.getId(),
row.getTenantId(),
userId,
e.getMessage());
return false;
}
}
/** 安全解析 ext 中的数字字段Integer / Long / String → Long。 */
private static Long parseLongExt(Object val) {
if (val == null) {
return null;
}
if (val instanceof Number) {
return ((Number) val).longValue();
}
try {
return Long.parseLong(val.toString().trim());
} catch (NumberFormatException e) {
return null;
}
}
public static Long parseNumericBizOrderNo(String bizOrderNo) {
if (bizOrderNo == null || bizOrderNo.isBlank()) {
return null;

View File

@@ -0,0 +1,33 @@
package com.xspaceagi.pay.application.support;
import com.alibaba.fastjson2.JSON;
import com.xspaceagi.pay.sdk.support.PayOrderExtKeys;
import com.xspaceagi.pay.sdk.enums.PayClientScene;
import java.util.HashMap;
import java.util.Map;
public final class PayOrderExtSupport {
private PayOrderExtSupport() {}
public static String mergePayClientScene(String extJson, PayClientScene scene) {
if (scene == null) {
return extJson;
}
Map<String, Object> map = parseExt(extJson);
map.put(PayOrderExtKeys.PAY_CLIENT_SCENE, scene.name());
return JSON.toJSONString(map);
}
public static Map<String, Object> parseExt(String extJson) {
if (extJson == null || extJson.isBlank()) {
return new HashMap<>();
}
try {
Map<String, Object> parsed = JSON.parseObject(extJson);
return parsed != null ? parsed : new HashMap<>();
} catch (Exception e) {
return new HashMap<>();
}
}
}

View File

@@ -1,7 +1,7 @@
package com.xspaceagi.pay.application.support;
import com.xspaceagi.pay.domain.model.PayOrderModel;
import com.xspaceagi.pay.sdk.dto.ScanOrderStatusQueryResponse;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryResponse;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
@@ -20,7 +20,7 @@ public final class PayOrderGatewayQueryMerger {
private PayOrderGatewayQueryMerger() {}
public static void mergeIntoRow(PayOrderModel row, ScanOrderStatusQueryResponse r) {
public static void mergeIntoRow(PayOrderModel row, PaymentStatusQueryResponse r) {
if (r == null) {
return;
}

View File

@@ -1,10 +1,9 @@
package com.xspaceagi.pay.application.support;
import com.xspaceagi.bill.sdk.rpc.IBillRpcService;
import com.xspaceagi.pay.domain.gateway.PaymentScanGateway;
import com.xspaceagi.pay.domain.model.PayOrderModel;
import com.xspaceagi.pay.domain.repository.PayOrderRepository;
import com.xspaceagi.pay.sdk.dto.ScanOrderStatusQueryResponse;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryResponse;
import com.xspaceagi.pay.sdk.enums.PaymentStatus;
import com.xspaceagi.pay.spec.enums.PayBizNotifyStatus;
import com.xspaceagi.pay.spec.enums.PayOrderGatewaySyncStatus;
@@ -25,7 +24,7 @@ import org.springframework.util.StringUtils;
public class PayOrderSettlementSyncService {
private final PayOrderRepository payOrderRepository;
private final PaymentScanGateway paymentScanGateway;
private final PayOrderStatusQuerySupport payOrderStatusQuerySupport;
private final PayGatewayOutboundExecutor payGatewayOutboundExecutor;
private final IBillRpcService billRpcService;
@@ -50,10 +49,10 @@ public class PayOrderSettlementSyncService {
return false;
}
ScanOrderStatusQueryResponse last;
PaymentStatusQueryResponse last;
try {
PayGatewayOutbound outbound = payGatewayOutboundExecutor.resolveCached(tenantId);
last = paymentScanGateway.queryOrderStatus(outbound, row.getGatewayPaymentOrderNo(), true);
last = payOrderStatusQuerySupport.queryOrderStatus(outbound, row, true);
PayOrderGatewayQueryMerger.mergeIntoRow(row, last);
payOrderRepository.save(row);
} catch (PayGatewayClientException e) {
@@ -78,7 +77,7 @@ public class PayOrderSettlementSyncService {
}
private boolean notifyBillAndMark(
PayOrderModel row, ScanOrderStatusQueryResponse last, PayBizNotifyStatus expectedCurrent) {
PayOrderModel row, PaymentStatusQueryResponse last, PayBizNotifyStatus expectedCurrent) {
if (!PayOrderBillNotifySupport.notifyBillPaymentCallback(billRpcService, row, last, false)) {
log.warn("[pay-settlement-sync] bill notify failed bizOrderNo={}", row.getBizOrderNo());
return false;

View File

@@ -0,0 +1,37 @@
package com.xspaceagi.pay.application.support;
import com.xspaceagi.pay.domain.gateway.PaymentAppGateway;
import com.xspaceagi.pay.domain.gateway.PaymentH5Gateway;
import com.xspaceagi.pay.domain.gateway.PaymentMiniPayGateway;
import com.xspaceagi.pay.domain.gateway.PaymentScanGateway;
import com.xspaceagi.pay.domain.model.PayOrderModel;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryResponse;
import com.xspaceagi.pay.sdk.enums.PayMode;
import com.xspaceagi.pay.spec.gateway.PayGatewayOutbound;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/** 查询支付状态。 */
@Component
@RequiredArgsConstructor
public class PayOrderStatusQuerySupport {
private final PaymentScanGateway paymentScanGateway;
private final PaymentMiniPayGateway paymentMiniPayGateway;
private final PaymentH5Gateway paymentH5Gateway;
private final PaymentAppGateway paymentAppGateway;
public PaymentStatusQueryResponse queryOrderStatus(
PayGatewayOutbound outbound, PayOrderModel row, boolean syncFromChannel) {
if (row.getPayMode() == PayMode.minipay) {
return paymentMiniPayGateway.queryOrderStatus(outbound, row.getGatewayPaymentOrderNo(), syncFromChannel);
}
if (row.getPayMode() == PayMode.h5) {
return paymentH5Gateway.queryOrderStatus(outbound, row.getGatewayPaymentOrderNo(), syncFromChannel);
}
if (row.getPayMode() == PayMode.app) {
return paymentAppGateway.queryOrderStatus(outbound, row.getGatewayPaymentOrderNo(), syncFromChannel);
}
return paymentScanGateway.queryOrderStatus(outbound, row.getGatewayPaymentOrderNo(), syncFromChannel);
}
}