同步平台业务模块能力

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);
}
}

View File

@@ -0,0 +1,15 @@
package com.xspaceagi.pay.domain.gateway;
import com.xspaceagi.pay.sdk.dto.FileEntryUploadResponse;
import com.xspaceagi.pay.spec.gateway.PayGatewayOutbound;
/** 调用支付网关 {@code /open-api/file};出站上下文由应用层解析后传入。 */
public interface FileEntryGateway {
FileEntryUploadResponse uploadMerchantOnboardingImage(
PayGatewayOutbound outbound,
byte[] fileBytes,
String filename,
String contentType,
String replaceFileKey);
}

View File

@@ -0,0 +1,25 @@
package com.xspaceagi.pay.domain.gateway;
import com.xspaceagi.pay.sdk.dto.OrderAndTransactionCreateResponse;
import com.xspaceagi.pay.sdk.dto.OrderCreateResponse;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryResponse;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import com.xspaceagi.pay.sdk.enums.PayClientScene;
import com.xspaceagi.pay.spec.gateway.PayGatewayOutbound;
/** 调用支付网关 App 原生支付 Open API。 */
public interface PaymentAppGateway {
OrderCreateResponse createOrder(
PayGatewayOutbound outbound, String bizOrderNo, long orderAmount, String subject, String ext);
OrderAndTransactionCreateResponse createTransaction(
PayGatewayOutbound outbound,
String gatewayPaymentOrderNo,
PayChannel payChannel,
String clientIp,
PayClientScene payClientScene);
PaymentStatusQueryResponse queryOrderStatus(
PayGatewayOutbound outbound, String gatewayPaymentOrderNo, boolean syncFromChannel);
}

View File

@@ -0,0 +1,24 @@
package com.xspaceagi.pay.domain.gateway;
import com.xspaceagi.pay.sdk.dto.OrderAndTransactionCreateResponse;
import com.xspaceagi.pay.sdk.dto.OrderCreateResponse;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryResponse;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import com.xspaceagi.pay.spec.gateway.PayGatewayOutbound;
/** 调用支付网关 H5 Open API用于 H5 支付。 */
public interface PaymentH5Gateway {
OrderCreateResponse createOrder(
PayGatewayOutbound outbound, String bizOrderNo, long orderAmount, String subject, String ext);
OrderAndTransactionCreateResponse createTransaction(
PayGatewayOutbound outbound,
String gatewayPaymentOrderNo,
PayChannel payChannel,
String clientIp,
String frontNotifyUrl);
PaymentStatusQueryResponse queryOrderStatus(
PayGatewayOutbound outbound, String gatewayPaymentOrderNo, boolean syncFromChannel);
}

View File

@@ -0,0 +1,31 @@
package com.xspaceagi.pay.domain.gateway;
import com.xspaceagi.pay.sdk.dto.OrderAndTransactionCreateResponse;
import com.xspaceagi.pay.sdk.dto.OrderCreateResponse;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryResponse;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import com.xspaceagi.pay.sdk.enums.PayClientScene;
import com.xspaceagi.pay.spec.gateway.PayGatewayOutbound;
/** 调用支付网关小程序支付 Open API出站上下文由应用层解析后传入。 */
public interface PaymentMiniPayGateway {
OrderCreateResponse createOrder(
PayGatewayOutbound outbound, String bizOrderNo, long orderAmount, String subject, String ext);
OrderAndTransactionCreateResponse createTransaction(
PayGatewayOutbound outbound,
String gatewayPaymentOrderNo,
PayChannel payChannel,
String subAppid,
String openId,
String buyerId,
String clientIp,
PayClientScene payClientScene);
/**
* @param syncFromChannel true 时网关会向支付渠道查询并落库
*/
PaymentStatusQueryResponse queryOrderStatus(
PayGatewayOutbound outbound, String gatewayPaymentOrderNo, boolean syncFromChannel);
}

View File

@@ -1,14 +1,14 @@
package com.xspaceagi.pay.domain.gateway;
import com.xspaceagi.pay.sdk.dto.CashierSessionCreateResponse;
import com.xspaceagi.pay.sdk.dto.PaymentOrderCreateResponse;
import com.xspaceagi.pay.sdk.dto.ScanOrderStatusQueryResponse;
import com.xspaceagi.pay.sdk.dto.OrderCreateResponse;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryResponse;
import com.xspaceagi.pay.spec.gateway.PayGatewayOutbound;
/** 调用 qiming-pay 扫码支付 Open API出站上下文由应用层解析后传入。 */
public interface PaymentScanGateway {
PaymentOrderCreateResponse createOrder(
OrderCreateResponse createOrder(
PayGatewayOutbound outbound, String bizOrderNo, long orderAmount, String subject, String ext);
CashierSessionCreateResponse createCashierSession(
@@ -21,6 +21,6 @@ public interface PaymentScanGateway {
/**
* @param syncFromChannel true 时网关会向支付渠道查询并落库
*/
ScanOrderStatusQueryResponse queryOrderStatus(
PaymentStatusQueryResponse queryOrderStatus(
PayGatewayOutbound outbound, String gatewayPaymentOrderNo, boolean syncFromChannel);
}

View File

@@ -0,0 +1,93 @@
package com.xspaceagi.pay.infra.gateway.client;
import com.xspaceagi.pay.domain.gateway.FileEntryGateway;
import com.xspaceagi.pay.infra.gateway.utils.PayGatewayClientUtils;
import com.xspaceagi.pay.sdk.dto.ApiResponse;
import com.xspaceagi.pay.sdk.dto.FileEntryOpenApiUploadRequest;
import com.xspaceagi.pay.sdk.dto.FileEntryUploadResponse;
import com.xspaceagi.pay.sdk.sign.OpenApiFileEntryMultipartSupport;
import com.xspaceagi.pay.sdk.sign.OpenApiFileEntryMultipartSupport.SignedUploadFields;
import com.xspaceagi.pay.sdk.sign.OpenApiFileEntrySign;
import com.xspaceagi.pay.spec.gateway.PayGatewayOutbound;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
@Slf4j
@Component
public class FileEntryGatewayClient implements FileEntryGateway {
private static final String BIZ_MERCHANT_ONBOARDING_IMAGE = "merchant_onboarding_image";
private static final ParameterizedTypeReference<ApiResponse<FileEntryUploadResponse>> UPLOAD_RESPONSE_TYPE =
new ParameterizedTypeReference<ApiResponse<FileEntryUploadResponse>>() {};
private final RestTemplate payGatewayRestTemplate;
public FileEntryGatewayClient(@Qualifier("payGatewayRestTemplate") RestTemplate payGatewayRestTemplate) {
this.payGatewayRestTemplate = payGatewayRestTemplate;
}
@Override
public FileEntryUploadResponse uploadMerchantOnboardingImage(
PayGatewayOutbound outbound,
byte[] fileBytes,
String filename,
String contentType,
String replaceFileKey) {
if (fileBytes == null || fileBytes.length == 0) {
throw new IllegalArgumentException("请选择文件");
}
FileEntryOpenApiUploadRequest meta = new FileEntryOpenApiUploadRequest();
meta.setClientId(outbound.clientId());
meta.setBizType(BIZ_MERCHANT_ONBOARDING_IMAGE);
if (StringUtils.hasText(replaceFileKey)) {
meta.setReplaceFileKey(replaceFileKey.trim());
}
byte[] signedJson = OpenApiFileEntrySign.signUpload(meta, outbound.clientSecret());
SignedUploadFields fields = OpenApiFileEntryMultipartSupport.parseSignedMetadata(signedJson);
String url = outbound.baseUrl() + OpenApiFileEntrySign.PATH_UPLOAD;
log.info("[pay-gateway] POST {} clientId={} bizType={}", url, fields.getClientId(), fields.getBizType());
return PayGatewayClientUtils.postMultipartSigned(
payGatewayRestTemplate,
url,
toMultipartBody(fields, fileBytes, filename, contentType),
UPLOAD_RESPONSE_TYPE,
true);
}
private static MultiValueMap<String, Object> toMultipartBody(
SignedUploadFields fields, byte[] fileBytes, String filename, String contentType) {
MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
parts.add("clientId", fields.getClientId());
parts.add("bizType", fields.getBizType());
if (StringUtils.hasText(fields.getReplaceFileKey())) {
parts.add("replaceFileKey", fields.getReplaceFileKey());
}
parts.add("timestamp", String.valueOf(fields.getTimestamp()));
parts.add("nonce", fields.getNonce());
parts.add("signature", fields.getSignature());
String safeName = StringUtils.hasText(filename) ? filename : "upload";
ByteArrayResource resource = new ByteArrayResource(fileBytes) {
@Override
public String getFilename() {
return safeName;
}
};
HttpHeaders fileHeaders = new HttpHeaders();
if (StringUtils.hasText(contentType)) {
fileHeaders.setContentType(MediaType.parseMediaType(contentType));
}
parts.add("file", new HttpEntity<>(resource, fileHeaders));
return parts;
}
}

View File

@@ -0,0 +1,87 @@
package com.xspaceagi.pay.infra.gateway.client;
import com.xspaceagi.pay.domain.gateway.PaymentAppGateway;
import com.xspaceagi.pay.infra.gateway.utils.PayGatewayClientUtils;
import com.xspaceagi.pay.sdk.dto.ApiResponse;
import com.xspaceagi.pay.sdk.dto.AppOrderCreateRequest;
import com.xspaceagi.pay.sdk.dto.AppTransactionCreateRequest;
import com.xspaceagi.pay.sdk.dto.OrderAndTransactionCreateResponse;
import com.xspaceagi.pay.sdk.dto.OrderCreateResponse;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryRequest;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryResponse;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import com.xspaceagi.pay.sdk.enums.PayClientScene;
import com.xspaceagi.pay.sdk.sign.OpenApiPaymentAppSign;
import com.xspaceagi.pay.spec.gateway.PayGatewayOutbound;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
@Slf4j
@Component
public class PaymentAppGatewayClient implements PaymentAppGateway {
private static final ParameterizedTypeReference<ApiResponse<OrderCreateResponse>> CREATE_TYPE =
new ParameterizedTypeReference<ApiResponse<OrderCreateResponse>>() {};
private static final ParameterizedTypeReference<ApiResponse<OrderAndTransactionCreateResponse>> TX_TYPE =
new ParameterizedTypeReference<ApiResponse<OrderAndTransactionCreateResponse>>() {};
private static final ParameterizedTypeReference<ApiResponse<PaymentStatusQueryResponse>> STATUS_TYPE =
new ParameterizedTypeReference<ApiResponse<PaymentStatusQueryResponse>>() {};
private final RestTemplate payGatewayRestTemplate;
public PaymentAppGatewayClient(@Qualifier("payGatewayRestTemplate") RestTemplate payGatewayRestTemplate) {
this.payGatewayRestTemplate = payGatewayRestTemplate;
}
@Override
public OrderCreateResponse createOrder(
PayGatewayOutbound outbound, String bizOrderNo, long orderAmount, String subject, String ext) {
AppOrderCreateRequest request = new AppOrderCreateRequest();
request.setClientId(outbound.clientId());
request.setBizOrderNo(bizOrderNo);
request.setOrderAmount(orderAmount);
request.setSubject(subject);
request.setExt(ext);
byte[] body = OpenApiPaymentAppSign.signCreateOrder(request, outbound.clientSecret());
String url = outbound.baseUrl() + OpenApiPaymentAppSign.PATH_CREATE_ORDER;
log.info("[pay-gateway] POST {}", url);
return PayGatewayClientUtils.postSigned(payGatewayRestTemplate, url, body, CREATE_TYPE, true);
}
@Override
public OrderAndTransactionCreateResponse createTransaction(
PayGatewayOutbound outbound,
String gatewayPaymentOrderNo,
PayChannel payChannel,
String clientIp,
PayClientScene payClientScene) {
AppTransactionCreateRequest request = new AppTransactionCreateRequest();
request.setClientId(outbound.clientId());
request.setGatewayPaymentOrderNo(gatewayPaymentOrderNo);
request.setPayChannel(payChannel);
request.setClientIp(clientIp);
request.setPayClientScene(payClientScene);
byte[] body = OpenApiPaymentAppSign.signCreateTransaction(request, outbound.clientSecret());
String url = outbound.baseUrl() + OpenApiPaymentAppSign.PATH_CREATE_TRANSACTION;
log.info("[pay-gateway] POST {} payChannel={}", url, payChannel);
return PayGatewayClientUtils.postSigned(payGatewayRestTemplate, url, body, TX_TYPE, true);
}
@Override
public PaymentStatusQueryResponse queryOrderStatus(
PayGatewayOutbound outbound, String gatewayPaymentOrderNo, boolean syncFromChannel) {
PaymentStatusQueryRequest request = new PaymentStatusQueryRequest();
request.setClientId(outbound.clientId());
request.setGatewayPaymentOrderNo(gatewayPaymentOrderNo);
request.setSyncFromChannel(syncFromChannel);
byte[] body = OpenApiPaymentAppSign.signQueryStatus(request, outbound.clientSecret());
String url = outbound.baseUrl() + OpenApiPaymentAppSign.PATH_STATUS;
log.info("[pay-gateway] POST {} syncFromChannel={}", url, syncFromChannel);
return PayGatewayClientUtils.postSigned(payGatewayRestTemplate, url, body, STATUS_TYPE, true);
}
}

View File

@@ -0,0 +1,86 @@
package com.xspaceagi.pay.infra.gateway.client;
import com.xspaceagi.pay.domain.gateway.PaymentH5Gateway;
import com.xspaceagi.pay.infra.gateway.utils.PayGatewayClientUtils;
import com.xspaceagi.pay.sdk.dto.ApiResponse;
import com.xspaceagi.pay.sdk.dto.H5OrderCreateRequest;
import com.xspaceagi.pay.sdk.dto.H5TransactionCreateRequest;
import com.xspaceagi.pay.sdk.dto.OrderAndTransactionCreateResponse;
import com.xspaceagi.pay.sdk.dto.OrderCreateResponse;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryRequest;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryResponse;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import com.xspaceagi.pay.sdk.sign.OpenApiPaymentH5Sign;
import com.xspaceagi.pay.spec.gateway.PayGatewayOutbound;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
@Slf4j
@Component
public class PaymentH5GatewayClient implements PaymentH5Gateway {
private static final ParameterizedTypeReference<ApiResponse<OrderCreateResponse>> CREATE_TYPE =
new ParameterizedTypeReference<ApiResponse<OrderCreateResponse>>() {};
private static final ParameterizedTypeReference<ApiResponse<OrderAndTransactionCreateResponse>> TX_TYPE =
new ParameterizedTypeReference<ApiResponse<OrderAndTransactionCreateResponse>>() {};
private static final ParameterizedTypeReference<ApiResponse<PaymentStatusQueryResponse>> STATUS_TYPE =
new ParameterizedTypeReference<ApiResponse<PaymentStatusQueryResponse>>() {};
private final RestTemplate payGatewayRestTemplate;
public PaymentH5GatewayClient(@Qualifier("payGatewayRestTemplate") RestTemplate payGatewayRestTemplate) {
this.payGatewayRestTemplate = payGatewayRestTemplate;
}
@Override
public OrderCreateResponse createOrder(
PayGatewayOutbound outbound, String bizOrderNo, long orderAmount, String subject, String ext) {
H5OrderCreateRequest request = new H5OrderCreateRequest();
request.setClientId(outbound.clientId());
request.setBizOrderNo(bizOrderNo);
request.setOrderAmount(orderAmount);
request.setSubject(subject);
request.setExt(ext);
byte[] body = OpenApiPaymentH5Sign.signCreateOrder(request, outbound.clientSecret());
String url = outbound.baseUrl() + OpenApiPaymentH5Sign.PATH_CREATE_ORDER;
log.info("[pay-gateway] POST {}", url);
return PayGatewayClientUtils.postSigned(payGatewayRestTemplate, url, body, CREATE_TYPE, true);
}
@Override
public OrderAndTransactionCreateResponse createTransaction(
PayGatewayOutbound outbound,
String gatewayPaymentOrderNo,
PayChannel payChannel,
String clientIp,
String frontNotifyUrl) {
H5TransactionCreateRequest request = new H5TransactionCreateRequest();
request.setClientId(outbound.clientId());
request.setGatewayPaymentOrderNo(gatewayPaymentOrderNo);
request.setPayChannel(payChannel);
request.setClientIp(clientIp);
request.setFrontNotifyUrl(frontNotifyUrl);
byte[] body = OpenApiPaymentH5Sign.signCreateTransaction(request, outbound.clientSecret());
String url = outbound.baseUrl() + OpenApiPaymentH5Sign.PATH_CREATE_TRANSACTION;
log.info("[pay-gateway] POST {} payChannel={}", url, payChannel);
return PayGatewayClientUtils.postSigned(payGatewayRestTemplate, url, body, TX_TYPE, true);
}
@Override
public PaymentStatusQueryResponse queryOrderStatus(
PayGatewayOutbound outbound, String gatewayPaymentOrderNo, boolean syncFromChannel) {
PaymentStatusQueryRequest request = new PaymentStatusQueryRequest();
request.setClientId(outbound.clientId());
request.setGatewayPaymentOrderNo(gatewayPaymentOrderNo);
request.setSyncFromChannel(syncFromChannel);
byte[] body = OpenApiPaymentH5Sign.signQueryStatus(request, outbound.clientSecret());
String url = outbound.baseUrl() + OpenApiPaymentH5Sign.PATH_STATUS;
log.info("[pay-gateway] POST {} syncFromChannel={}", url, syncFromChannel);
return PayGatewayClientUtils.postSigned(payGatewayRestTemplate, url, body, STATUS_TYPE, true);
}
}

View File

@@ -0,0 +1,92 @@
package com.xspaceagi.pay.infra.gateway.client;
import com.xspaceagi.pay.domain.gateway.PaymentMiniPayGateway;
import com.xspaceagi.pay.infra.gateway.utils.PayGatewayClientUtils;
import com.xspaceagi.pay.sdk.dto.ApiResponse;
import com.xspaceagi.pay.sdk.dto.MiniPayOrderCreateRequest;
import com.xspaceagi.pay.sdk.dto.MiniPayTransactionCreateRequest;
import com.xspaceagi.pay.sdk.dto.OrderAndTransactionCreateResponse;
import com.xspaceagi.pay.sdk.dto.OrderCreateResponse;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryRequest;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryResponse;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import com.xspaceagi.pay.sdk.enums.PayClientScene;
import com.xspaceagi.pay.sdk.sign.OpenApiPaymentMiniPaySign;
import com.xspaceagi.pay.spec.gateway.PayGatewayOutbound;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
@Slf4j
@Component
public class PaymentMiniPayGatewayClient implements PaymentMiniPayGateway {
private static final ParameterizedTypeReference<ApiResponse<OrderCreateResponse>> CREATE_TYPE =
new ParameterizedTypeReference<ApiResponse<OrderCreateResponse>>() {};
private static final ParameterizedTypeReference<ApiResponse<OrderAndTransactionCreateResponse>> TX_TYPE =
new ParameterizedTypeReference<ApiResponse<OrderAndTransactionCreateResponse>>() {};
private static final ParameterizedTypeReference<ApiResponse<PaymentStatusQueryResponse>> STATUS_TYPE =
new ParameterizedTypeReference<ApiResponse<PaymentStatusQueryResponse>>() {};
private final RestTemplate payGatewayRestTemplate;
public PaymentMiniPayGatewayClient(@Qualifier("payGatewayRestTemplate") RestTemplate payGatewayRestTemplate) {
this.payGatewayRestTemplate = payGatewayRestTemplate;
}
@Override
public OrderCreateResponse createOrder(
PayGatewayOutbound outbound, String bizOrderNo, long orderAmount, String subject, String ext) {
MiniPayOrderCreateRequest request = new MiniPayOrderCreateRequest();
request.setClientId(outbound.clientId());
request.setBizOrderNo(bizOrderNo);
request.setOrderAmount(orderAmount);
request.setSubject(subject);
byte[] body = OpenApiPaymentMiniPaySign.signCreateOrder(request, outbound.clientSecret());
String url = outbound.baseUrl() + OpenApiPaymentMiniPaySign.PATH_CREATE_ORDER;
log.info("[pay-gateway] POST {}", url);
return PayGatewayClientUtils.postSigned(payGatewayRestTemplate, url, body, CREATE_TYPE, true);
}
@Override
public OrderAndTransactionCreateResponse createTransaction(
PayGatewayOutbound outbound,
String gatewayPaymentOrderNo,
PayChannel payChannel,
String subAppid,
String openId,
String buyerId,
String clientIp,
PayClientScene payClientScene) {
MiniPayTransactionCreateRequest request = new MiniPayTransactionCreateRequest();
request.setClientId(outbound.clientId());
request.setGatewayPaymentOrderNo(gatewayPaymentOrderNo);
request.setPayChannel(payChannel);
request.setSubAppid(subAppid);
request.setOpenId(openId);
request.setBuyerId(buyerId);
request.setClientIp(clientIp);
request.setPayClientScene(payClientScene);
byte[] body = OpenApiPaymentMiniPaySign.signCreateTransaction(request, outbound.clientSecret());
String url = outbound.baseUrl() + OpenApiPaymentMiniPaySign.PATH_CREATE_TRANSACTION;
log.info("[pay-gateway] POST {} payChannel={}", url, payChannel);
return PayGatewayClientUtils.postSigned(payGatewayRestTemplate, url, body, TX_TYPE, true);
}
@Override
public PaymentStatusQueryResponse queryOrderStatus(
PayGatewayOutbound outbound, String gatewayPaymentOrderNo, boolean syncFromChannel) {
PaymentStatusQueryRequest request = new PaymentStatusQueryRequest();
request.setClientId(outbound.clientId());
request.setGatewayPaymentOrderNo(gatewayPaymentOrderNo);
request.setSyncFromChannel(syncFromChannel);
byte[] body = OpenApiPaymentMiniPaySign.signQueryStatus(request, outbound.clientSecret());
String url = outbound.baseUrl() + OpenApiPaymentMiniPaySign.PATH_STATUS;
log.info("[pay-gateway] POST {} syncFromChannel={}", url, syncFromChannel);
return PayGatewayClientUtils.postSigned(payGatewayRestTemplate, url, body, STATUS_TYPE, true);
}
}

View File

@@ -5,10 +5,10 @@ import com.xspaceagi.pay.infra.gateway.utils.PayGatewayClientUtils;
import com.xspaceagi.pay.sdk.dto.ApiResponse;
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.OrderCreateResponse;
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.sign.OpenApiCashierSign;
import com.xspaceagi.pay.sdk.sign.OpenApiPaymentScanSign;
import com.xspaceagi.pay.spec.gateway.PayGatewayOutbound;
@@ -22,11 +22,11 @@ import org.springframework.web.client.RestTemplate;
@Component
public class PaymentScanGatewayClient implements PaymentScanGateway {
private static final ParameterizedTypeReference<ApiResponse<PaymentOrderCreateResponse>> CREATE_TYPE =
new ParameterizedTypeReference<ApiResponse<PaymentOrderCreateResponse>>() {};
private static final ParameterizedTypeReference<ApiResponse<OrderCreateResponse>> CREATE_TYPE =
new ParameterizedTypeReference<ApiResponse<OrderCreateResponse>>() {};
private static final ParameterizedTypeReference<ApiResponse<ScanOrderStatusQueryResponse>> STATUS_TYPE =
new ParameterizedTypeReference<ApiResponse<ScanOrderStatusQueryResponse>>() {};
private static final ParameterizedTypeReference<ApiResponse<PaymentStatusQueryResponse>> STATUS_TYPE =
new ParameterizedTypeReference<ApiResponse<PaymentStatusQueryResponse>>() {};
private static final ParameterizedTypeReference<ApiResponse<CashierSessionCreateResponse>> CASHIER_SESSION_TYPE =
new ParameterizedTypeReference<ApiResponse<CashierSessionCreateResponse>>() {};
@@ -38,7 +38,7 @@ public class PaymentScanGatewayClient implements PaymentScanGateway {
}
@Override
public PaymentOrderCreateResponse createOrder(
public OrderCreateResponse createOrder(
PayGatewayOutbound outbound, String bizOrderNo, long orderAmount, String subject, String ext) {
ScanOrderCreateRequest request = new ScanOrderCreateRequest();
request.setClientId(outbound.clientId());
@@ -53,7 +53,7 @@ public class PaymentScanGatewayClient implements PaymentScanGateway {
}
@Override
public ScanOrderStatusQueryResponse queryOrderStatus(
public PaymentStatusQueryResponse queryOrderStatus(
PayGatewayOutbound outbound, String gatewayPaymentOrderNo, boolean syncFromChannel) {
PaymentStatusQueryRequest request = new PaymentStatusQueryRequest();
request.setClientId(outbound.clientId());

View File

@@ -13,6 +13,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
@@ -67,6 +68,31 @@ public final class PayGatewayClientUtils {
}
}
public static <T> T postMultipartSigned(
RestTemplate restTemplate,
String url,
MultiValueMap<String, Object> parts,
ParameterizedTypeReference<ApiResponse<T>> typeRef,
boolean requireNonNullData) {
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(parts, headers);
ResponseEntity<ApiResponse<T>> response = restTemplate.exchange(url, HttpMethod.POST, entity, typeRef);
return unwrap(response.getBody(), url, requireNonNullData);
} catch (HttpStatusCodeException e) {
log.warn(
"[pay-gateway] POST multipart failed url={} status={} body={}",
url,
e.getStatusCode(),
e.getResponseBodyAsString());
throw toHttpErrorException(e);
} catch (ResourceAccessException e) {
log.warn("[pay-gateway] POST multipart network error url={} msg={}", url, e.getMessage(), e);
throw new PayGatewayClientException();
}
}
private static <T> T unwrap(ApiResponse<T> resp, String url, boolean requireNonNullData) {
if (resp == null) {
log.warn("[pay-gateway] empty response url={}", url);

View File

@@ -0,0 +1,20 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import com.xspaceagi.pay.sdk.enums.PayClientScene;
import lombok.Data;
@Data
public class AppOrderAndTransactionCreateRequest {
private String clientId;
private Long timestamp;
private String nonce;
private String signature;
private String bizOrderNo;
private PayChannel payChannel;
private Long orderAmount;
private String subject;
private String ext;
private String clientIp;
private PayClientScene payClientScene;
}

View File

@@ -0,0 +1,15 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.Data;
@Data
public class AppOrderCreateRequest {
private String clientId;
private Long timestamp;
private String nonce;
private String signature;
private String bizOrderNo;
private Long orderAmount;
private String subject;
private String ext;
}

View File

@@ -0,0 +1,12 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.Data;
/** 创建 App 原生支付单(仅落网关支付单,不调渠道)。 */
@Data
public class AppOrderRpcCreateRequest {
private String bizOrderNo;
private Long orderAmount;
private String subject;
private String ext;
}

View File

@@ -0,0 +1,17 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import com.xspaceagi.pay.sdk.enums.PayClientScene;
import lombok.Data;
@Data
public class AppTransactionCreateRequest {
private String clientId;
private Long timestamp;
private String nonce;
private String signature;
private String gatewayPaymentOrderNo;
private PayChannel payChannel;
private String clientIp;
private PayClientScene payClientScene;
}

View File

@@ -0,0 +1,14 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import com.xspaceagi.pay.sdk.enums.PayClientScene;
import lombok.Data;
/** 对已有 App 支付单调渠道下单,返回原生 SDK 调起参数。 */
@Data
public class AppTransactionRpcCreateRequest {
private String bizOrderNo;
private PayChannel payChannel;
private String clientIp;
private PayClientScene payClientScene;
}

View File

@@ -0,0 +1,14 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.Data;
/** Open API 文件上传元数据(不含文件二进制;与 multipart 表单字段一致)。 */
@Data
public class FileEntryOpenApiUploadRequest {
private String clientId;
private String bizType;
private String replaceFileKey;
private Long timestamp;
private String nonce;
private String signature;
}

View File

@@ -0,0 +1,19 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FileEntryUploadResponse {
/** 32 位小写 hex无中划线 */
private String fileKey;
/** 对外可访问 URL含 site.base-url */
private String publicUrl;
private String contentType;
private int sizeBytes;
}

View File

@@ -0,0 +1,15 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.Data;
@Data
public class H5OrderCreateRequest {
private String clientId;
private Long timestamp;
private String nonce;
private String signature;
private String bizOrderNo;
private Long orderAmount;
private String subject;
private String ext;
}

View File

@@ -0,0 +1,12 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.Data;
/** 创建 H5支付单仅落网关支付单不调渠道。 */
@Data
public class H5OrderRpcCreateRequest {
private String bizOrderNo;
private Long orderAmount;
private String subject;
private String ext;
}

View File

@@ -0,0 +1,16 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import lombok.Data;
@Data
public class H5TransactionCreateRequest {
private String clientId;
private Long timestamp;
private String nonce;
private String signature;
private String gatewayPaymentOrderNo;
private PayChannel payChannel;
private String clientIp;
private String frontNotifyUrl;
}

View File

@@ -0,0 +1,13 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import lombok.Data;
/** 对已有 h5 支付单调渠道下单,返回前端调起参数。 */
@Data
public class H5TransactionRpcCreateRequest {
private String bizOrderNo;
private PayChannel payChannel;
private String clientIp;
private String frontNotifyUrl;
}

View File

@@ -54,18 +54,25 @@ public class MerchantOnboardingResponse {
/** 营业执照 */
private String orgCertificateUrl;
private String orgCertificateFileKey;
/** 法人身份证正面 */
private String legalPersonIdCardFrontUrl;
private String legalPersonIdCardFrontFileKey;
/** 法人身份证背面 */
private String legalPersonIdCardBackUrl;
private String legalPersonIdCardBackFileKey;
/** 财务室照片 */
private String photoFinanceRoomUrl;
private String photoFinanceRoomFileKey;
/** 门头照片 */
private String photoGateUrl;
private String photoGateFileKey;
/** 地标照片 */
private String photoLandmarkUrl;
private String photoLandmarkFileKey;
/** 银行开户证明 */
private String bankAccountProofUrl;
private String bankAccountProofFileKey;
private LocalDateTime created;
private LocalDateTime modified;

View File

@@ -5,7 +5,8 @@ import com.xspaceagi.pay.sdk.enums.MerchantOnboardingType;
import lombok.Data;
/**
* 创建/全量更新进件资料(图片字段均为 URL
* 创建/全量更新进件资料。
* 影像优先传 {@code *FileKey}(本系统上传);{@code *Url} 保留兼容外部链接。
*/
@Data
public class MerchantOnboardingUpsertRequest {
@@ -56,4 +57,16 @@ public class MerchantOnboardingUpsertRequest {
private String photoGateUrl;
private String photoLandmarkUrl;
private String bankAccountProofUrl;
/** 营业执照;本系统上传时为 file_entry.file_key */
private String orgCertificateFileKey;
/** 法人身份证人像面/正面 file_key */
private String legalPersonIdCardFrontFileKey;
/** 法人身份证国徽面/反面 file_key */
private String legalPersonIdCardBackFileKey;
private String photoFinanceRoomFileKey;
private String photoGateFileKey;
private String photoLandmarkFileKey;
private String bankAccountProofFileKey;
}

View File

@@ -0,0 +1,23 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import lombok.Data;
@Data
public class MiniPayOrderAndTransactionCreateRequest {
private String clientId;
private Long timestamp;
private String nonce;
private String signature;
private String bizOrderNo;
private PayChannel payChannel;
private Long orderAmount;
private String subject;
/** 微信小程序 AppID安心付 channel_params.sub_appidWxPay 必填) */
private String subAppid;
/** 微信:用户 OpenID安心付 channel_params.open_id */
private String openId;
/** 支付宝小程序:用户 buyer_id安心付 channel_params.buyer_id */
private String buyerId;
private String ext;
}

View File

@@ -0,0 +1,14 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.Data;
@Data
public class MiniPayOrderCreateRequest {
private String clientId;
private Long timestamp;
private String nonce;
private String signature;
private String bizOrderNo;
private Long orderAmount;
private String subject;
}

View File

@@ -0,0 +1,12 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.Data;
/** 创建小程序支付单(仅落网关支付单,不调渠道)。 */
@Data
public class MiniPayOrderRpcCreateRequest {
private String bizOrderNo;
private Long orderAmount;
private String subject;
private String ext;
}

View File

@@ -0,0 +1,24 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import com.xspaceagi.pay.sdk.enums.PayClientScene;
import lombok.Data;
@Data
public class MiniPayTransactionCreateRequest {
private String clientId;
private Long timestamp;
private String nonce;
private String signature;
private String gatewayPaymentOrderNo;
private PayChannel payChannel;
/** WxPay minipaychannel_params.sub_appid */
private String subAppid;
/** WxPay minipaychannel_params.open_id */
private String openId;
/** AliPay minipaychannel_params.buyer_id */
private String buyerId;
private String clientIp;
/** 调起场景 */
private PayClientScene payClientScene;
}

View File

@@ -0,0 +1,21 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import com.xspaceagi.pay.sdk.enums.PayClientScene;
import lombok.Data;
/** 对已有 minipay 支付单调渠道下单,返回调起支付参数。 */
@Data
public class MiniPayTransactionRpcCreateRequest {
private String bizOrderNo;
private PayChannel payChannel;
/** WxPay minipay小程序或公众号 AppIDsub_appid */
private String subAppid;
/** WxPay minipay用户 OpenID */
private String openId;
/** AliPay minipay用户 buyer_id */
private String buyerId;
private String clientIp;
/** minipay 调起场景:小程序 / 微信JSAPI写入 pay_order.ext */
private PayClientScene payClientScene;
}

View File

@@ -1,14 +1,16 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import com.xspaceagi.pay.sdk.enums.PayInvokeType;
import com.xspaceagi.pay.sdk.enums.PayMode;
import com.xspaceagi.pay.sdk.enums.PaymentStatus;
import lombok.Builder;
import lombok.Data;
/** 创建支付单/渠道下单后的统一响应 */
@Data
@Builder
public class ScanOrderCreateResponse {
public class OrderAndTransactionCreateResponse {
private String gatewayPaymentOrderNo;
private String gatewayPaymentTxNo;
/** 渠道服务费,单位分 */
@@ -20,6 +22,14 @@ public class ScanOrderCreateResponse {
private PaymentStatus status;
private PayChannel payChannel;
private PayMode payMode;
private PayInvokeType invokeType;
/** 主扫 scan渠道返回的二维码/链接原文payparam */
private String qrCodeContent;
private String formHtml;
private String redirectUrl;
/** minipay + WxPaywx.requestPayment 参数 */
private WxPayInvokeParams wxPayParams;
/** minipay + AliPaytradeNO用于 my.tradePay */
private String alipayTradeNo;
private String expiredAt;
}

View File

@@ -7,7 +7,7 @@ import lombok.Data;
@Data
@Builder
public class PaymentOrderCreateResponse {
public class OrderCreateResponse {
private String gatewayPaymentOrderNo;
private String bizOrderNo;
private PaymentStatus status;

View File

@@ -1,6 +1,7 @@
package com.xspaceagi.pay.sdk.dto;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import com.xspaceagi.pay.sdk.enums.PayInvokeType;
import com.xspaceagi.pay.sdk.enums.PayMode;
import com.xspaceagi.pay.sdk.enums.PaymentStatus;
import lombok.Builder;
@@ -8,7 +9,7 @@ import lombok.Data;
@Data
@Builder
public class ScanOrderStatusQueryResponse {
public class PaymentStatusQueryResponse {
private String gatewayPaymentOrderNo;
private String gatewayPaymentTxNo;
private PaymentStatus status;
@@ -19,6 +20,9 @@ public class ScanOrderStatusQueryResponse {
private Long providerFee;
private Long platformFee;
private Long netAmount;
private PayInvokeType invokeType;
private String qrCodeContent;
private String formHtml;
private String redirectUrl;
private String paidAt;
}

View File

@@ -0,0 +1,17 @@
package com.xspaceagi.pay.sdk.dto;
import lombok.Builder;
import lombok.Data;
/** 微信小程序 {@code wx.requestPayment} 所需字段(来自渠道 payparam JSON。 */
@Data
@Builder
public class WxPayInvokeParams {
private String appId;
private String timeStamp;
private String nonceStr;
/** 对应微信字段 package值为 prepay_id=... */
private String packageValue;
private String signType;
private String paySign;
}

View File

@@ -0,0 +1,19 @@
package com.xspaceagi.pay.sdk.enums;
/** 调起场景 */
public enum PayClientScene {
MINI_PROGRAM("微信小程序"), //渠道 pay_mode 均为 minipay
WECHAT_JSAPI("微信JSAPI"), //渠道 pay_mode 均为 minipay
H5_WEB("系统浏览器H5"),
NATIVE_APP("App原生SDK"); // gateway_back微信 common / 支付宝 scan
private final String name;
PayClientScene(String name) {
this.name = name;
}
public String getName() {
return name;
}
}

View File

@@ -0,0 +1,7 @@
package com.xspaceagi.pay.sdk.enums;
public enum PayInvokeType {
FORM_HTML,
REDIRECT_URL,
QRCODE_FALLBACK
}

View File

@@ -1,7 +1,10 @@
package com.xspaceagi.pay.sdk.enums;
public enum PayMode {
scan("主扫");
scan("主扫"),
minipay("小程序支付"),
h5("H5支付"),
app("App原生支付");
private final String name;

View File

@@ -1,17 +1,42 @@
package com.xspaceagi.pay.sdk.service;
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.OrderCreateResponse;
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;
/** 平台内支付 RPC 契约(由 pay-application 实现,供 bill 等外部模块依赖调用)。 */
public interface IPaymentRpcService {
/** 创建扫码支付订单 */
PaymentOrderCreateResponse createOrderForScan(ScanOrderCreateRequest request);
OrderCreateResponse createOrderForScan(ScanOrderCreateRequest request);
/** 创建小程序支付单(仅落网关支付单,不调渠道;调起支付见 {@link #createMiniPayTransaction} */
OrderCreateResponse createOrderForMiniPay(MiniPayOrderRpcCreateRequest request);
/** 创建 H5支付单仅落网关支付单不调渠道调起支付见 {@link #createH5Transaction} */
OrderCreateResponse createOrderForH5(H5OrderRpcCreateRequest request);
/** 创建 App 原生支付单(仅落网关支付单,不调渠道;调起支付见 {@link #createAppTransaction} */
OrderCreateResponse createOrderForApp(AppOrderRpcCreateRequest request);
/** 对已有小程序支付单调渠道下单,返回 wx.requestPayment / my.tradePay 参数 */
OrderAndTransactionCreateResponse createMiniPayTransaction(MiniPayTransactionRpcCreateRequest request);
/** 对已有 h5 支付单调渠道下单,返回 formHtml / redirectUrl / qrcode 兜底 */
OrderAndTransactionCreateResponse createH5Transaction(H5TransactionRpcCreateRequest request);
/** 对已有 App 支付单调渠道下单,返回 wxPayParams / redirectUrl */
OrderAndTransactionCreateResponse createAppTransaction(AppTransactionRpcCreateRequest request);
/**
* 获取收银台地址;会话有过期时间,过期后需重新调用。
@@ -19,7 +44,7 @@ public interface IPaymentRpcService {
CashierSessionCreateResponse createCashierSession(CashierSessionCreateRequest request);
/** 查询支付状态(仅更新 pay_order不通知 Bill */
ScanOrderStatusQueryResponse queryStatus(PaymentStatusQueryRequest request);
PaymentStatusQueryResponse queryStatus(PaymentStatusQueryRequest request);
/**
* 结算页主动同步:按业务单号查网关终态并通知 Bill与支付轮询任务单 tick 一致,幂等)。

View File

@@ -0,0 +1,65 @@
package com.xspaceagi.pay.sdk.sign;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Builder;
import lombok.Value;
/** 将已签名的 Open API JSON 元数据解析为 multipart 表单字段。 */
public final class OpenApiFileEntryMultipartSupport {
private OpenApiFileEntryMultipartSupport() {}
@Value
@Builder
public static class SignedUploadFields {
String clientId;
String bizType;
String replaceFileKey;
long timestamp;
String nonce;
String signature;
}
public static SignedUploadFields parseSignedMetadata(byte[] signedJson) {
if (signedJson == null || signedJson.length == 0) {
throw new IllegalArgumentException("signed metadata must not be empty");
}
try {
JsonNode root = OpenApiSignJson.mapper().readTree(signedJson);
JsonNode clientId = root.get("clientId");
JsonNode bizType = root.get("bizType");
JsonNode timestamp = root.get("timestamp");
JsonNode nonce = root.get("nonce");
JsonNode signature = root.get("signature");
if (clientId == null
|| !clientId.isTextual()
|| bizType == null
|| !bizType.isTextual()
|| timestamp == null
|| !timestamp.isNumber()
|| nonce == null
|| !nonce.isTextual()
|| signature == null
|| !signature.isTextual()) {
throw new IllegalArgumentException("invalid signed file upload metadata");
}
String replaceFileKey = null;
JsonNode replaceNode = root.get("replaceFileKey");
if (replaceNode != null && replaceNode.isTextual() && !replaceNode.asText().isBlank()) {
replaceFileKey = replaceNode.asText().trim();
}
return SignedUploadFields.builder()
.clientId(clientId.asText().trim())
.bizType(bizType.asText().trim())
.replaceFileKey(replaceFileKey)
.timestamp(timestamp.longValue())
.nonce(nonce.asText().trim())
.signature(signature.asText().trim())
.build();
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
throw new IllegalArgumentException("invalid signed file upload metadata", e);
}
}
}

View File

@@ -0,0 +1,15 @@
package com.xspaceagi.pay.sdk.sign;
import com.xspaceagi.pay.sdk.dto.FileEntryOpenApiUploadRequest;
/** 文件 Open API 路径与签名辅助 */
public final class OpenApiFileEntrySign {
public static final String PATH_UPLOAD = "/open-api/file/upload";
private OpenApiFileEntrySign() {}
public static byte[] signUpload(FileEntryOpenApiUploadRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_UPLOAD, request, clientSecret);
}
}

View File

@@ -0,0 +1,35 @@
package com.xspaceagi.pay.sdk.sign;
import com.xspaceagi.pay.sdk.dto.AppOrderAndTransactionCreateRequest;
import com.xspaceagi.pay.sdk.dto.AppOrderCreateRequest;
import com.xspaceagi.pay.sdk.dto.AppTransactionCreateRequest;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryRequest;
/** App 原生支付 Open API 路径与签名辅助。 */
public final class OpenApiPaymentAppSign {
public static final String BASE = "/open-api/payment/app";
public static final String PATH_CREATE_ORDER_AND_TRANSACTION = BASE + "/create-order-and-transaction";
public static final String PATH_CREATE_ORDER = BASE + "/create-order";
public static final String PATH_CREATE_TRANSACTION = BASE + "/create-transaction";
public static final String PATH_STATUS = BASE + "/status";
private OpenApiPaymentAppSign() {}
public static byte[] signCreateOrderAndTransaction(
AppOrderAndTransactionCreateRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_CREATE_ORDER_AND_TRANSACTION, request, clientSecret);
}
public static byte[] signCreateOrder(AppOrderCreateRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_CREATE_ORDER, request, clientSecret);
}
public static byte[] signCreateTransaction(AppTransactionCreateRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_CREATE_TRANSACTION, request, clientSecret);
}
public static byte[] signQueryStatus(PaymentStatusQueryRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_STATUS, request, clientSecret);
}
}

View File

@@ -0,0 +1,27 @@
package com.xspaceagi.pay.sdk.sign;
import com.xspaceagi.pay.sdk.dto.H5OrderCreateRequest;
import com.xspaceagi.pay.sdk.dto.H5TransactionCreateRequest;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryRequest;
public final class OpenApiPaymentH5Sign {
public static final String BASE = "/open-api/payment/h5";
public static final String PATH_CREATE_ORDER = BASE + "/create-order";
public static final String PATH_CREATE_TRANSACTION = BASE + "/create-transaction";
public static final String PATH_STATUS = BASE + "/status";
private OpenApiPaymentH5Sign() {}
public static byte[] signCreateOrder(H5OrderCreateRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_CREATE_ORDER, request, clientSecret);
}
public static byte[] signCreateTransaction(H5TransactionCreateRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_CREATE_TRANSACTION, request, clientSecret);
}
public static byte[] signQueryStatus(PaymentStatusQueryRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_STATUS, request, clientSecret);
}
}

View File

@@ -0,0 +1,35 @@
package com.xspaceagi.pay.sdk.sign;
import com.xspaceagi.pay.sdk.dto.MiniPayOrderAndTransactionCreateRequest;
import com.xspaceagi.pay.sdk.dto.MiniPayOrderCreateRequest;
import com.xspaceagi.pay.sdk.dto.MiniPayTransactionCreateRequest;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryRequest;
/** 微信/支付宝小程序支付minipayOpen API 路径与签名辅助 */
public final class OpenApiPaymentMiniPaySign {
public static final String BASE = "/open-api/payment/minipay";
public static final String PATH_CREATE_ORDER_AND_TRANSACTION = BASE + "/create-order-and-transaction";
public static final String PATH_CREATE_ORDER = BASE + "/create-order";
public static final String PATH_CREATE_TRANSACTION = BASE + "/create-transaction";
public static final String PATH_STATUS = BASE + "/status";
private OpenApiPaymentMiniPaySign() {}
public static byte[] signCreateOrderAndTransaction(
MiniPayOrderAndTransactionCreateRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_CREATE_ORDER_AND_TRANSACTION, request, clientSecret);
}
public static byte[] signCreateOrder(MiniPayOrderCreateRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_CREATE_ORDER, request, clientSecret);
}
public static byte[] signCreateTransaction(MiniPayTransactionCreateRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_CREATE_TRANSACTION, request, clientSecret);
}
public static byte[] signQueryStatus(PaymentStatusQueryRequest request, String clientSecret) {
return OpenApiSignSupport.sign(PATH_STATUS, request, clientSecret);
}
}

View File

@@ -4,8 +4,7 @@ import java.util.List;
import java.util.Map;
/**
* 各 Open API 路径参与 body 哈希的字段(不含 {@code timestamp}/{@code nonce}/{@code signature})。
* <p>path 与各 {@code OpenApi*Sign#PATH_*} 保持一致,为单一来源。</p>
* 各 Open API 路径参与 body 哈希的字段
*/
public final class OpenApiSignPolicies {
@@ -24,6 +23,39 @@ public final class OpenApiSignPolicies {
Map.entry(
OpenApiPaymentScanSign.PATH_STATUS,
List.of("clientId", "gatewayPaymentOrderNo", "syncFromChannel")),
Map.entry(
OpenApiPaymentMiniPaySign.PATH_CREATE_ORDER_AND_TRANSACTION,
List.of("bizOrderNo", "buyerId", "clientId", "openId", "orderAmount", "payChannel", "subAppid")),
Map.entry(
OpenApiPaymentMiniPaySign.PATH_CREATE_ORDER,
List.of("bizOrderNo", "clientId", "orderAmount")),
Map.entry(
OpenApiPaymentMiniPaySign.PATH_CREATE_TRANSACTION,
List.of("buyerId", "clientId", "gatewayPaymentOrderNo", "openId", "payChannel", "subAppid")),
Map.entry(
OpenApiPaymentMiniPaySign.PATH_STATUS,
List.of("clientId", "gatewayPaymentOrderNo", "syncFromChannel")),
Map.entry(
OpenApiPaymentH5Sign.PATH_CREATE_ORDER,
List.of("bizOrderNo", "clientId", "orderAmount")),
Map.entry(
OpenApiPaymentH5Sign.PATH_CREATE_TRANSACTION,
List.of("clientId", "frontNotifyUrl", "gatewayPaymentOrderNo", "payChannel")),
Map.entry(
OpenApiPaymentH5Sign.PATH_STATUS,
List.of("clientId", "gatewayPaymentOrderNo", "syncFromChannel")),
Map.entry(
OpenApiPaymentAppSign.PATH_CREATE_ORDER_AND_TRANSACTION,
List.of("bizOrderNo", "clientId", "orderAmount", "payChannel")),
Map.entry(
OpenApiPaymentAppSign.PATH_CREATE_ORDER,
List.of("bizOrderNo", "clientId", "orderAmount")),
Map.entry(
OpenApiPaymentAppSign.PATH_CREATE_TRANSACTION,
List.of("clientId", "gatewayPaymentOrderNo", "payChannel")),
Map.entry(
OpenApiPaymentAppSign.PATH_STATUS,
List.of("clientId", "gatewayPaymentOrderNo", "syncFromChannel")),
Map.entry(OpenApiPaymentConfigSign.PATH_QUERY, List.of("clientId")),
Map.entry(
OpenApiMerchantOnboardingSign.PATH_ADD,
@@ -38,7 +70,10 @@ public final class OpenApiSignPolicies {
List.of("clientId", "onboardingType", "page", "pageSize", "status")),
Map.entry(
OpenApiCashierSign.PATH_SESSION,
List.of("clientId", "gatewayPaymentOrderNo", "orderAmount")));
List.of("clientId", "gatewayPaymentOrderNo", "orderAmount")),
Map.entry(
OpenApiFileEntrySign.PATH_UPLOAD,
List.of("clientId", "bizType", "replaceFileKey")));
public static List<String> signFieldsForPath(String path) {
String normalized = normalizePath(path);

View File

@@ -0,0 +1,63 @@
package com.xspaceagi.pay.sdk.support;
import java.util.Locale;
import java.util.Set;
/**
* 识别 App 壳 / WebView 内发起的 HTTP 请求(非系统浏览器、非微信内置浏览器)。
* 平台 App 请求会携带 {@value #HEADER_CLIENT_TYPE};兜底解析 User-Agent 中的 App 标记。
*/
public final class PayAppWebViewDetector {
/** App 客户端标识,与 AuthInterceptor 续期逻辑一致:非空即视为 App 请求。 */
public static final String HEADER_CLIENT_TYPE = "X-Client-Type";
private static final Set<String> APP_CLIENT_TYPE_VALUES = Set.of(
"app",
"native",
"ios",
"android",
"mobile",
"mobile-app",
"qiming-app",
"nuwax-app");
private static final Set<String> WEB_CLIENT_TYPE_VALUES = Set.of("web", "h5", "browser", "wap");
private PayAppWebViewDetector() {}
/**
* 是否为 App WebView 内请求。H5 支付在此环境下会被渠道判定违规,应改用 App 原生 SDK 支付。
*/
public static boolean isAppWebView(String clientTypeHeader, String userAgent) {
if (clientTypeHeader != null && !clientTypeHeader.isBlank()) {
String normalized = clientTypeHeader.trim().toLowerCase(Locale.ROOT);
if (WEB_CLIENT_TYPE_VALUES.contains(normalized)) {
return false;
}
if (APP_CLIENT_TYPE_VALUES.contains(normalized) || normalized.startsWith("app")) {
return true;
}
// 平台 App 统一注入 X-Client-Type非 web 类取值视为 App 壳(与鉴权续期策略一致)
return true;
}
return matchesAppWebViewUserAgent(userAgent);
}
private static boolean matchesAppWebViewUserAgent(String userAgent) {
if (userAgent == null || userAgent.isBlank()) {
return false;
}
String ua = userAgent;
if (ua.contains("QimingApp") || ua.contains("QIMING_APP") || ua.contains("qiming-app")
|| ua.contains("NuwaxApp") || ua.contains("NUWAX_APP") || ua.contains("nuwax-app")) {
return true;
}
String lower = ua.toLowerCase(Locale.ROOT);
if ((lower.contains("qiming") || lower.contains("nuwax"))
&& (lower.contains("webview") || lower.contains("; wv)"))) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,12 @@
package com.xspaceagi.pay.sdk.support;
/** pay_order.ext / Bill order extra 中与支付相关的扩展键。 */
public final class PayOrderExtKeys {
public static final String PAY_CLIENT_SCENE = "payClientScene";
public static final String GENERAL_PROJECT_ID = "generalProjectId";
public static final String GENERAL_PROJECT_USER_ID = "generalProjectUserId";
private PayOrderExtKeys() {}
}

View File

@@ -0,0 +1,28 @@
package com.xspaceagi.pay.spec.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 通用项目支付 - App 原生第一步:创建订单请求。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GeneralProjectAppOrderCreateRequest {
/** 项目 ID必传前端从 DEV_PROJECT_ID 环境变量获取 */
private String projectId;
/** 业务订单号,可空;传了用 GP1_ 前缀(幂等),不传用 GP2_ 前缀自动生成 */
private String bizOrderNo;
/** 订单金额(单位:分) */
private Long orderAmount;
/** 订单标题/商品描述 */
private String subject;
}

View File

@@ -0,0 +1,20 @@
package com.xspaceagi.pay.spec.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 通用项目支付 - App 原生第一步:创建订单响应。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GeneralProjectAppOrderCreateResponse {
private String orderNo;
private String gatewayOrderNo;
}

View File

@@ -0,0 +1,22 @@
package com.xspaceagi.pay.spec.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 通用项目支付 - App 原生第二步:调起渠道支付请求。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GeneralProjectAppPayRequest {
/** 业务订单号(第一步返回的 orderNo */
private String orderNo;
/** 支付渠道WxPay / AliPay */
private String payChannel;
}

View File

@@ -0,0 +1,44 @@
package com.xspaceagi.pay.spec.dto;
import com.xspaceagi.pay.sdk.dto.WxPayInvokeParams;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 通用项目支付 - App 原生第二步:调起渠道支付响应。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GeneralProjectAppPayResponse {
private String orderNo;
private String gatewayOrderNo;
/** WxPay | AliPay */
private String payChannel;
/** 微信:其他渠道 prepay JSON当前安心付渠道为 null支付宝不使用 */
private WxPayInvokeParams wxPayParams;
/**
* 微信weixin://dl/business/...
* 支付宝https://qr.alipay.com/... 或 alipays://
*/
private String redirectUrl;
/** 支付宝 tradeNO部分 SDK 使用) */
private String alipayTradeNo;
/** REDIRECT_URL | QRCODE_FALLBACK */
private String invokeType;
/** 支付宝 invokeType=QRCODE_FALLBACK 时的二维码内容 */
private String qrCodeContent;
private String status;
}

View File

@@ -0,0 +1,31 @@
package com.xspaceagi.pay.spec.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 通用项目支付 - 收银台模式请求。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GeneralProjectCashierRequest {
/** 项目 ID必传前端从 DEV_PROJECT_ID 环境变量获取 */
private String projectId;
/** 业务订单号,可空;传了用 GP1_ 前缀(幂等),不传用 GP2_ 前缀自动生成 */
private String bizOrderNo;
/** 订单金额(单位:分) */
private Long orderAmount;
/** 订单标题/商品描述 */
private String subject;
/** 支付完成后前端回跳地址,可空 */
private String frontNotifyUrl;
}

View File

@@ -0,0 +1,25 @@
package com.xspaceagi.pay.spec.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 通用项目支付 - 收银台模式响应。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GeneralProjectCashierResponse {
/** 业务订单号GP1_ 或 GP2_ 前缀) */
private String orderNo;
/** 网关支付订单号 */
private String gatewayOrderNo;
/** 收银台跳转地址(浏览器直接跳转) */
private String cashierUrl;
}

View File

@@ -0,0 +1,28 @@
package com.xspaceagi.pay.spec.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 通用项目支付 - H5 第一步:创建订单请求。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GeneralProjectH5OrderCreateRequest {
/** 项目 ID必传前端从 DEV_PROJECT_ID 环境变量获取 */
private String projectId;
/** 业务订单号,可空;传了用 GP1_ 前缀(幂等),不传用 GP2_ 前缀自动生成 */
private String bizOrderNo;
/** 订单金额(单位:分) */
private Long orderAmount;
/** 订单标题/商品描述 */
private String subject;
}

View File

@@ -0,0 +1,22 @@
package com.xspaceagi.pay.spec.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 通用项目支付 - H5 第一步:创建订单响应。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GeneralProjectH5OrderCreateResponse {
/** 业务订单号GP1_ 或 GP2_ 前缀) */
private String orderNo;
/** 网关支付订单号 */
private String gatewayOrderNo;
}

View File

@@ -0,0 +1,25 @@
package com.xspaceagi.pay.spec.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 通用项目支付 - H5 第二步:调起渠道支付请求。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GeneralProjectH5PayRequest {
/** 业务订单号(第一步返回的 orderNo */
private String orderNo;
/** 支付渠道WxPay / AliPay */
private String payChannel;
/** 支付完成后前端回跳地址 */
private String frontNotifyUrl;
}

View File

@@ -0,0 +1,34 @@
package com.xspaceagi.pay.spec.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 通用项目支付 - H5 第二步:调起渠道支付响应。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GeneralProjectH5PayResponse {
/** 业务订单号 */
private String orderNo;
/** 网关支付订单号 */
private String gatewayOrderNo;
/** 表单 HTMLinvokeType=FORM_HTML 时,直接写入页面) */
private String formHtml;
/** 跳转地址invokeType=REDIRECT_URL 时,浏览器跳转) */
private String redirectUrl;
/** 调起方式FORM_HTML / REDIRECT_URL / QRCODE_FALLBACK */
private String invokeType;
/** 当前支付单状态 */
private String status;
}

View File

@@ -0,0 +1,19 @@
package com.xspaceagi.pay.spec.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 通用项目支付 - 查询支付状态请求。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GeneralProjectPaymentStatusRequest {
/** 网关支付订单号 */
private String gatewayOrderNo;
}

View File

@@ -0,0 +1,31 @@
package com.xspaceagi.pay.spec.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 通用项目支付 - 查询支付状态响应。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GeneralProjectPaymentStatusResponse {
/** 支付状态INIT / PENDING / PAID / FAILED / CLOSED */
private String status;
/** 支付渠道WxPay / AliPay / UnionPay */
private String payChannel;
/** 支付模式scan / h5 / minipay */
private String payMode;
/** 订单金额(单位:分) */
private Long orderAmount;
/** 支付完成时间 */
private String paidAt;
}

View File

@@ -0,0 +1,20 @@
package com.xspaceagi.pay.spec.support;
import com.xspaceagi.pay.sdk.support.PayAppWebViewDetector;
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
import com.xspaceagi.system.spec.exception.BizException;
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
/**
* App 原生支付仅允许在 App WebView 内调起;系统/手机浏览器应使用 H5 接口。
*/
public final class PayAppNativeInAppGuard {
private PayAppNativeInAppGuard() {}
public static void assertAppNativePayRequired(String clientTypeHeader, String userAgent) {
if (!PayAppWebViewDetector.isAppWebView(clientTypeHeader, userAgent)) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.pay_app_native_requires_app);
}
}
}

View File

@@ -0,0 +1,20 @@
package com.xspaceagi.pay.spec.support;
import com.xspaceagi.pay.sdk.support.PayAppWebViewDetector;
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
import com.xspaceagi.system.spec.exception.BizException;
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
/**
* App WebView 内禁止调起 H5 支付(渠道违规);应使用 App 原生 SDK 接口。
*/
public final class PayH5InAppGuard {
private PayH5InAppGuard() {}
public static void assertH5PayAllowed(String clientTypeHeader, String userAgent) {
if (PayAppWebViewDetector.isAppWebView(clientTypeHeader, userAgent)) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.pay_h5_not_allowed_in_app);
}
}
}

View File

@@ -24,5 +24,9 @@
<groupId>com.xspaceagi</groupId>
<artifactId>system-spec</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-custom-page-sdk</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -1,6 +1,8 @@
package com.xspaceagi.pay.web.controller.admin;
import com.xspaceagi.pay.application.service.FileEntryApplicationService;
import com.xspaceagi.pay.application.service.MerchantOnboardingApplicationService;
import com.xspaceagi.pay.sdk.dto.FileEntryUploadResponse;
import com.xspaceagi.pay.sdk.dto.MerchantOnboardingResponse;
import com.xspaceagi.pay.sdk.dto.MerchantOnboardingUpdateRequest;
import com.xspaceagi.pay.sdk.dto.MerchantOnboardingUpsertRequest;
@@ -14,10 +16,14 @@ import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.MediaType;
import static com.xspaceagi.system.spec.enums.ResourceEnum.PAY_EARNINGS_MODIFY;
import static com.xspaceagi.system.spec.enums.ResourceEnum.PAY_EARNINGS_QUERY;
@@ -31,6 +37,25 @@ public class MerchantOnboardingAdminController {
@Resource
private MerchantOnboardingApplicationService merchantOnboardingAppService;
@Resource
private FileEntryApplicationService fileEntryAppService;
@RequireResource(PAY_EARNINGS_MODIFY)
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "上传进件影像", description = "上传文件,返回 fileKey 与 publicUrl提交进件请传 *FileKey重传同字段请带 replaceFileKey")
public ReqResult<FileEntryUploadResponse> upload(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "replaceFileKey", required = false) String replaceFileKey) {
Long tenantId = RequestContext.get() == null ? null : RequestContext.get().getTenantId();
Assert.notNull(tenantId, "tenantId must be non-null");
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("请选择文件");
}
String replaceKey = StringUtils.hasText(replaceFileKey) ? replaceFileKey.trim() : null;
log.info("api /pay/merchant-onboarding/upload-image tenantId={}, size={}", tenantId, file.getSize());
return ReqResult.success(fileEntryAppService.uploadMerchantOnboardingImage(file, replaceKey));
}
@RequireResource(PAY_EARNINGS_MODIFY)
@PostMapping("/add")
@Operation(summary = "新增进件", description = "仅支持租户进件、用户进件不支持平台进件。clientId 由服务端按登录租户凭证写入,请求体勿传")

View File

@@ -0,0 +1,353 @@
package com.xspaceagi.pay.web.controller.user;
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.H5OrderRpcCreateRequest;
import com.xspaceagi.pay.sdk.dto.H5TransactionRpcCreateRequest;
import com.xspaceagi.pay.sdk.dto.OrderAndTransactionCreateResponse;
import com.xspaceagi.pay.sdk.dto.OrderCreateResponse;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryRequest;
import com.xspaceagi.pay.sdk.dto.PaymentStatusQueryResponse;
import com.xspaceagi.pay.sdk.dto.ScanOrderCreateRequest;
import com.xspaceagi.pay.sdk.enums.PayChannel;
import com.xspaceagi.pay.sdk.enums.PayClientScene;
import com.xspaceagi.pay.sdk.service.IPaymentRpcService;
import com.xspaceagi.pay.sdk.support.PayOrderExtKeys;
import com.xspaceagi.pay.spec.dto.GeneralProjectAppOrderCreateRequest;
import com.xspaceagi.pay.spec.dto.GeneralProjectAppOrderCreateResponse;
import com.xspaceagi.pay.spec.dto.GeneralProjectAppPayRequest;
import com.xspaceagi.pay.spec.dto.GeneralProjectAppPayResponse;
import com.xspaceagi.pay.spec.dto.GeneralProjectCashierRequest;
import com.xspaceagi.pay.spec.dto.GeneralProjectCashierResponse;
import com.xspaceagi.pay.spec.dto.GeneralProjectH5OrderCreateRequest;
import com.xspaceagi.pay.spec.dto.GeneralProjectH5OrderCreateResponse;
import com.xspaceagi.pay.spec.dto.GeneralProjectH5PayRequest;
import com.xspaceagi.pay.spec.dto.GeneralProjectH5PayResponse;
import com.xspaceagi.pay.spec.dto.GeneralProjectPaymentStatusRequest;
import com.xspaceagi.pay.spec.dto.GeneralProjectPaymentStatusResponse;
import com.xspaceagi.pay.spec.support.PayAppNativeInAppGuard;
import com.xspaceagi.pay.spec.support.PayH5InAppGuard;
import com.xspaceagi.pay.sdk.support.PayAppWebViewDetector;
import com.xspaceagi.custompage.sdk.dto.CustomPageDto;
import com.xspaceagi.custompage.sdk.ICustomPageRpcService;
import com.xspaceagi.system.spec.dto.ReqResult;
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
import com.xspaceagi.system.spec.exception.BizException;
import com.alibaba.fastjson2.JSON;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
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.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
/**
* 通用项目支付接入(用户端)。
* 面向项目沙箱/线上前端无需登录态projectId 必传(从 DEV_PROJECT_ID 获取)。
*/
@Slf4j
@RestController
@RequestMapping("/api/pay/general")
@Tag(name = "支付-通用项目(用户端)")
public class PayGeneralProjectController {
@Resource
private IPaymentRpcService paymentRpcService;
@Resource
private ICustomPageRpcService iCustomPageRpcService;
@PostMapping("/cashier")
@Operation(summary = "收银台模式", description = "创建扫码支付订单并生成收银台跳转地址")
public ReqResult<GeneralProjectCashierResponse> cashier(@RequestBody GeneralProjectCashierRequest request) {
Assert.notNull(request, "request must not be null");
Assert.hasText(request.getProjectId(), "projectId must not be blank");
Assert.notNull(request.getOrderAmount(), "orderAmount must not be null");
Assert.hasText(request.getSubject(), "subject must not be blank");
String bizOrderNo = resolveBizOrderNo(request.getBizOrderNo(), request.getProjectId());
String ext = buildGeneralProjectExt(request.getProjectId());
ScanOrderCreateRequest scanReq = new ScanOrderCreateRequest();
scanReq.setBizOrderNo(bizOrderNo);
scanReq.setOrderAmount(request.getOrderAmount());
scanReq.setSubject(request.getSubject());
scanReq.setExt(ext);
OrderCreateResponse orderResp = paymentRpcService.createOrderForScan(scanReq);
CashierSessionCreateRequest cashierReq = new CashierSessionCreateRequest();
cashierReq.setGatewayPaymentOrderNo(orderResp.getGatewayPaymentOrderNo());
cashierReq.setOrderAmount(request.getOrderAmount());
cashierReq.setSubject(request.getSubject());
cashierReq.setBizRedirectUrl(request.getFrontNotifyUrl());
CashierSessionCreateResponse cashierResp = paymentRpcService.createCashierSession(cashierReq);
GeneralProjectCashierResponse resp = GeneralProjectCashierResponse.builder()
.orderNo(bizOrderNo)
.gatewayOrderNo(orderResp.getGatewayPaymentOrderNo())
.cashierUrl(cashierResp.getCashierUrl())
.build();
return ReqResult.success(resp);
}
@PostMapping("/app/create-order")
@Operation(summary = "App原生-创建订单", description = "创建 App 原生支付订单(不调渠道)。仅限 App WebView 内调用")
public ReqResult<GeneralProjectAppOrderCreateResponse> createAppOrder(@RequestBody GeneralProjectAppOrderCreateRequest request,
HttpServletRequest httpRequest) {
assertAppNativePayRequired(httpRequest);
Assert.notNull(request, "request must not be null");
Assert.hasText(request.getProjectId(), "projectId must not be blank");
Assert.notNull(request.getOrderAmount(), "orderAmount must not be null");
Assert.hasText(request.getSubject(), "subject must not be blank");
String bizOrderNo = resolveBizOrderNo(request.getBizOrderNo(), request.getProjectId());
String ext = buildGeneralProjectExt(request.getProjectId());
AppOrderRpcCreateRequest appReq = new AppOrderRpcCreateRequest();
appReq.setBizOrderNo(bizOrderNo);
appReq.setOrderAmount(request.getOrderAmount());
appReq.setSubject(request.getSubject());
appReq.setExt(ext);
OrderCreateResponse orderResp = paymentRpcService.createOrderForApp(appReq);
GeneralProjectAppOrderCreateResponse resp = GeneralProjectAppOrderCreateResponse.builder()
.orderNo(bizOrderNo)
.gatewayOrderNo(orderResp.getGatewayPaymentOrderNo())
.build();
return ReqResult.success(resp);
}
@PostMapping("/app/pay")
@Operation(summary = "App原生-调起渠道支付", description = "仅限 App WebView。微信当前渠道invokeType=REDIRECT_URL + redirectUrl(weixin://...),前端解析 query 后 wx.miniapp.launchMiniProgram"
+ "支付宝invokeType=REDIRECT_URL + redirectUrl(https/alipays) 或 QRCODE_FALLBACK + qrCodeContent")
public ReqResult<GeneralProjectAppPayResponse> appPay(@RequestBody GeneralProjectAppPayRequest request,
HttpServletRequest httpRequest) {
assertAppNativePayRequired(httpRequest);
Assert.notNull(request, "request must not be null");
Assert.hasText(request.getOrderNo(), "orderNo must not be blank");
Assert.hasText(request.getPayChannel(), "payChannel must not be blank");
PayChannel payChannel = PayChannel.parse(request.getPayChannel());
String clientIp = resolveClientIp(httpRequest);
AppTransactionRpcCreateRequest txReq = new AppTransactionRpcCreateRequest();
txReq.setBizOrderNo(request.getOrderNo());
txReq.setPayChannel(payChannel);
txReq.setClientIp(clientIp);
txReq.setPayClientScene(PayClientScene.NATIVE_APP);
OrderAndTransactionCreateResponse txResp = paymentRpcService.createAppTransaction(txReq);
GeneralProjectAppPayResponse resp = GeneralProjectAppPayResponse.builder()
.orderNo(request.getOrderNo())
.gatewayOrderNo(txResp.getGatewayPaymentOrderNo())
.payChannel(txResp.getPayChannel() == null ? null : txResp.getPayChannel().name())
.wxPayParams(txResp.getWxPayParams())
.redirectUrl(txResp.getRedirectUrl())
.alipayTradeNo(txResp.getAlipayTradeNo())
.invokeType(txResp.getInvokeType() == null ? null : txResp.getInvokeType().name())
.qrCodeContent(txResp.getQrCodeContent())
.status(txResp.getStatus() == null ? null : txResp.getStatus().name())
.build();
return ReqResult.success(resp);
}
@PostMapping("/h5/create-order")
@Operation(summary = "H5支付-创建订单", description = "创建 H5 支付订单不调渠道返回订单号。App WebView 内禁止,请使用收银台或 App 原生支付")
public ReqResult<GeneralProjectH5OrderCreateResponse> createH5Order(@RequestBody GeneralProjectH5OrderCreateRequest request,
HttpServletRequest httpRequest) {
assertH5PayAllowed(httpRequest);
Assert.notNull(request, "request must not be null");
Assert.hasText(request.getProjectId(), "projectId must not be blank");
Assert.notNull(request.getOrderAmount(), "orderAmount must not be null");
Assert.hasText(request.getSubject(), "subject must not be blank");
String bizOrderNo = resolveBizOrderNo(request.getBizOrderNo(), request.getProjectId());
String ext = buildGeneralProjectExt(request.getProjectId());
H5OrderRpcCreateRequest h5Req = new H5OrderRpcCreateRequest();
h5Req.setBizOrderNo(bizOrderNo);
h5Req.setOrderAmount(request.getOrderAmount());
h5Req.setSubject(request.getSubject());
h5Req.setExt(ext);
OrderCreateResponse orderResp = paymentRpcService.createOrderForH5(h5Req);
GeneralProjectH5OrderCreateResponse resp = GeneralProjectH5OrderCreateResponse.builder()
.orderNo(bizOrderNo)
.gatewayOrderNo(orderResp.getGatewayPaymentOrderNo())
.build();
return ReqResult.success(resp);
}
@PostMapping("/h5/pay")
@Operation(summary = "H5支付-调起渠道支付", description = "对已有 H5 订单调起微信/支付宝 H5 支付。App WebView 内禁止")
public ReqResult<GeneralProjectH5PayResponse> h5Pay(@RequestBody GeneralProjectH5PayRequest request,
HttpServletRequest httpRequest) {
assertH5PayAllowed(httpRequest);
Assert.notNull(request, "request must not be null");
Assert.hasText(request.getOrderNo(), "orderNo must not be blank");
Assert.hasText(request.getPayChannel(), "payChannel must not be blank");
Assert.hasText(request.getFrontNotifyUrl(), "frontNotifyUrl must not be blank");
PayChannel payChannel = PayChannel.parse(request.getPayChannel());
String clientIp = resolveClientIp(httpRequest);
String frontNotifyUrl = validateFrontNotifyUrl(request.getFrontNotifyUrl().trim());
H5TransactionRpcCreateRequest txReq = new H5TransactionRpcCreateRequest();
txReq.setBizOrderNo(request.getOrderNo());
txReq.setPayChannel(payChannel);
txReq.setClientIp(clientIp);
txReq.setFrontNotifyUrl(buildChannelFrontNotifyUrl(frontNotifyUrl));
OrderAndTransactionCreateResponse txResp = paymentRpcService.createH5Transaction(txReq);
GeneralProjectH5PayResponse resp = GeneralProjectH5PayResponse.builder()
.orderNo(request.getOrderNo())
.gatewayOrderNo(txResp.getGatewayPaymentOrderNo())
.formHtml(txResp.getFormHtml())
.redirectUrl(txResp.getRedirectUrl())
.invokeType(txResp.getInvokeType() == null ? null : txResp.getInvokeType().name())
.status(txResp.getStatus() == null ? null : txResp.getStatus().name())
.build();
return ReqResult.success(resp);
}
@RequestMapping(value = "/h5/front-notify", method = {RequestMethod.GET, RequestMethod.POST})
@Operation(
summary = "H5 渠道前台回跳中转",
description = "接收渠道前台回调GET/POST统一 302 到前端回跳地址")
public void handleH5FrontNotify(@RequestParam String returnUrl, HttpServletResponse response) {
String target = validateFrontNotifyUrl(returnUrl.trim());
response.setStatus(HttpServletResponse.SC_FOUND);
response.setHeader("Location", target);
}
@PostMapping("/status")
@Operation(summary = "查询支付状态", description = "按网关支付订单号查询支付结果,会同步渠道")
public ReqResult<GeneralProjectPaymentStatusResponse> queryStatus(@RequestBody GeneralProjectPaymentStatusRequest request) {
Assert.notNull(request, "request must not be null");
Assert.hasText(request.getGatewayOrderNo(), "gatewayOrderNo must not be blank");
PaymentStatusQueryRequest statusReq = new PaymentStatusQueryRequest();
statusReq.setGatewayPaymentOrderNo(request.getGatewayOrderNo());
statusReq.setSyncFromChannel(Boolean.TRUE);
PaymentStatusQueryResponse statusResp = paymentRpcService.queryStatus(statusReq);
GeneralProjectPaymentStatusResponse resp = GeneralProjectPaymentStatusResponse.builder()
.status(statusResp.getStatus() == null ? null : statusResp.getStatus().name())
.payChannel(statusResp.getPayChannel() == null ? null : statusResp.getPayChannel().name())
.payMode(statusResp.getPayMode() == null ? null : statusResp.getPayMode().name())
.orderAmount(statusResp.getOrderAmount())
.paidAt(statusResp.getPaidAt())
.build();
return ReqResult.success(resp);
}
/**
* 构建通用项目 ext JSON包含 generalProjectId 和 generalProjectUserId项目创建者
* 项目查不到或 creatorId 为 null 时不阻断支付ext 中只有 projectId、无 userId
*/
private String buildGeneralProjectExt(String projectId) {
Long creatorId = null;
try {
CustomPageDto projectDto = iCustomPageRpcService.queryDetail(Long.parseLong(projectId.trim()));
if (projectDto != null) {
creatorId = projectDto.getCreatorId();
}
} catch (Exception e) {
log.warn("[pay-general] queryDetail failed projectId={} msg={}", projectId, e.getMessage());
}
Map<String, Object> ext = new HashMap<>();
ext.put(PayOrderExtKeys.GENERAL_PROJECT_ID, projectId);
if (creatorId != null) {
ext.put(PayOrderExtKeys.GENERAL_PROJECT_USER_ID, creatorId);
}
return JSON.toJSONString(ext);
}
/**
* 解析业务订单号projectId 必传:
* <ul>
* <li>传了 bizOrderNo → GP1_{projectId}_{bizOrderNo}(幂等,同 projectId + bizOrderNo 复用)</li>
* <li>没传 bizOrderNo → GP2_{projectId}_{timestamp}_{random6}(自动生成,每次唯一)</li>
* </ul>
*/
private String resolveBizOrderNo(String bizOrderNo, String projectId) {
String pid = projectId.trim();
if (StringUtils.hasText(bizOrderNo)) {
return "GP1_" + pid + "_" + bizOrderNo.trim();
}
long ts = System.currentTimeMillis();
int rand = ThreadLocalRandom.current().nextInt(1_000_000);
return "GP2_" + pid + "_" + ts + "_" + String.format("%06d", rand);
}
/**
* 将前端回跳地址包装为渠道前台回调地址:支付完成后先回到平台后端,再 302 到前端目标页。
* 与 Bill H5 一致,避免渠道直接把用户带回 SPA 并携带不可控 query 参数。
*/
private static String buildChannelFrontNotifyUrl(String frontNotifyUrl) {
URI frontendUri = URI.create(frontNotifyUrl);
String origin = frontendUri.getScheme() + "://" + frontendUri.getAuthority();
String encodedReturnUrl = URLEncoder.encode(frontNotifyUrl, StandardCharsets.UTF_8);
return origin + "/api/pay/general/h5/front-notify?returnUrl=" + encodedReturnUrl;
}
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");
}
if (!StringUtils.hasText(uri.getHost())) {
throw new BizException(ErrorCodeEnum.INVALID_PARAM.getCode(), "Invalid frontNotifyUrl");
}
return url;
} catch (IllegalArgumentException e) {
throw new BizException(ErrorCodeEnum.INVALID_PARAM.getCode(), "Invalid frontNotifyUrl");
}
}
/** App WebView 内禁止 H5 支付(渠道违规),应使用 App 原生 SDK。 */
private static void assertH5PayAllowed(HttpServletRequest httpRequest) {
PayH5InAppGuard.assertH5PayAllowed(
httpRequest.getHeader(PayAppWebViewDetector.HEADER_CLIENT_TYPE),
httpRequest.getHeader("User-Agent"));
}
/** App 原生支付仅限 App WebView手机浏览器请用 H5。 */
private static void assertAppNativePayRequired(HttpServletRequest httpRequest) {
PayAppNativeInAppGuard.assertAppNativePayRequired(
httpRequest.getHeader(PayAppWebViewDetector.HEADER_CLIENT_TYPE),
httpRequest.getHeader("User-Agent"));
}
/** 提取客户端 IP优先 X-Forwarded-For 首段,兜底 getRemoteAddr */
private String resolveClientIp(HttpServletRequest httpRequest) {
String xff = httpRequest.getHeader("X-Forwarded-For");
if (StringUtils.hasText(xff)) {
String first = xff.split(",")[0].trim();
if (StringUtils.hasText(first)) {
return first;
}
}
return httpRequest.getRemoteAddr();
}
}