Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ public ResponseEntity<String> handleEpayCallback(@RequestParam Map<String, Strin
return ResponseEntity.ok(result);
}

/**
* Qiupay 回调 — POST form-urlencoded,返回纯文本 success/fail
*/
@PostMapping(value = "/qiupay", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, produces = MediaType.TEXT_PLAIN_VALUE)
public ResponseEntity<String> handleQiupayCallback(@RequestParam Map<String, String> params) {
log.info("Qiupay callback received: {}", params);
String result = webhookService.processQiupayCallback(params);
return ResponseEntity.ok(result);
}

/**
* BEpusdt USDT 支付回调 — POST JSON,返回 "ok" 表示成功
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public class PaymentChannel extends BaseEntity {
private String channelName;

/**
* 支付提供商类型:epay / native_alipay / native_wxpay / usdt
* 支付提供商类型:epay / qiupay / native_alipay / native_wxpay / usdt
* 同一 channelCode 只能有一个已启用的 providerType
*/
@Column(nullable = false, columnDefinition = "varchar(255) not null default 'epay'")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ public interface WebhookService {
*/
String processEpayCallback(Map<String, String> params);

/**
* 处理 Qiupay POST form-urlencoded 回调
*/
String processQiupayCallback(Map<String, String> params);

/**
* 处理 BEpusdt USDT 支付回调(JSON body,含非 String 类型字段如 amount/status)
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ public class PaymentServiceImpl implements PaymentService {
"wechat", "wxpay"
);

private static final String QIUPAY_ALIPAY_CHANNEL_CODE = "qiupay_alipay";

private final PaymentChannelRepository paymentChannelRepository;
private final OrderRepository orderRepository;
private final OrderItemRepository orderItemRepository;
Expand Down Expand Up @@ -72,6 +74,7 @@ public Map<String, Object> createPayment(UUID orderId, String paymentMethod, Big
String providerType = channel.getProviderType();
switch (providerType) {
case "epay" -> createEpayPayment(channel, order, paymentMethod, amount, device);
case "qiupay" -> createQiupayPayment(channel, order, paymentMethod, amount, device);
case "native_alipay" -> throw new BusinessException(ErrorCode.CHANNEL_UNAVAILABLE, "原生支付宝支付尚未实现,请使用易支付渠道");
case "native_wxpay" -> throw new BusinessException(ErrorCode.CHANNEL_UNAVAILABLE, "原生微信支付尚未实现,请使用易支付渠道");
case "usdt" -> createBepusdtPayment(channel, order, amount);
Expand Down Expand Up @@ -149,6 +152,33 @@ private void createEpayPayment(PaymentChannel channel, Order order, String payme
orderRepository.save(order);
}

/**
* Qiupay 下单流程(协议与易支付一致,复用 EpayService 实现)
*/
private void createQiupayPayment(PaymentChannel channel, Order order, String paymentMethod, BigDecimal amount, String device) {
if (!QIUPAY_ALIPAY_CHANNEL_CODE.equalsIgnoreCase(paymentMethod)) {
throw new BusinessException(ErrorCode.CHANNEL_UNAVAILABLE, "Qiupay 仅支持 qiupay_alipay 渠道");
}

ChannelConfig config = buildChannelConfig(channel);
String productName = buildProductName(order.getId());

EpayResult epayResult = epayService.createPayment(
config,
order.getId().toString(),
"alipay",
productName,
amount,
order.getClientIp(),
device
);

order.setPaymentUrl(epayResult.payUrl());
order.setQrcodeUrl(epayResult.qrcodeUrl());
order.setEpayTradeNo(epayResult.tradeNo());
orderRepository.save(order);
}

/**
* 从渠道的 config_data JSON 构建 EpayService.ChannelConfig。
* 所有必填字段均从数据库渠道配置读取,缺失则抛出异常。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ public class WebhookServiceImpl implements WebhookService {
private final ObjectMapper objectMapper;
private final PaymentServiceImpl paymentService;

/** Qiupay 允许的最大超付金额(单位:元) */
private static final BigDecimal QIUPAY_MAX_OVERPAY = new BigDecimal("0.99");

@Override
@Transactional
public String processEpayCallback(Map<String, String> params) {
Expand Down Expand Up @@ -175,6 +178,152 @@ public String processEpayCallback(Map<String, String> params) {
return "SUCCESS";
}

@Override
@Transactional
public String processQiupayCallback(Map<String, String> params) {
String tradeNo = params.get("trade_no");
String outTradeNo = params.get("out_trade_no");
String tradeStatus = params.get("trade_status");
String money = params.get("money");
String sign = params.get("sign");

log.info("Qiupay callback: out_trade_no={}, trade_status={}, money={}", outTradeNo, tradeStatus, money);

// 使用 trade_no 作为幂等事件 ID
String eventId = "qiupay_" + (tradeNo != null ? tradeNo : UUID.randomUUID().toString());
Optional<WebhookEvent> existingEvent = webhookEventRepository.findByEventId(eventId);
if (existingEvent.isPresent()) {
log.info("Qiupay callback already processed: {}", eventId);
return "success";
}

// Step 1: 解析订单 ID
UUID orderId;
try {
orderId = UUID.fromString(outTradeNo);
} catch (IllegalArgumentException e) {
log.error("Qiupay callback invalid out_trade_no: {}", outTradeNo);
return "fail";
}

// Step 2: 解析商户 key 并验签
String merchantKey = resolveMerchantKey(orderId);
if (!epayService.verifySign(merchantKey, params, sign)) {
log.error("Qiupay callback signature verification failed: out_trade_no={}, remote sign={}", outTradeNo, sign);
return "fail";
}

// Step 3: 状态校验(仅处理支付成功)
if (!"TRADE_SUCCESS".equals(tradeStatus)) {
log.info("Qiupay callback non-success status: {}, skipping (not saved to idempotency table)", tradeStatus);
return "success";
}

// Step 4: 构建事件对象
WebhookEvent event = new WebhookEvent();
event.setEventId(eventId);
event.setChannelCode("qiupay");
event.setOrderId(orderId);
event.setPayload(params.toString());

Order order = orderRepository.findById(orderId).orElse(null);
if (order == null) {
event.setProcessResult("ORDER_NOT_FOUND");
log.warn("Qiupay callback order not found: {}", orderId);
webhookEventRepository.save(event);
return "success";
}

// Step 5: 回调金额容差校验(paid >= order && paid - order <= 0.99)
if (money == null || money.isBlank()) {
log.error("Qiupay callback missing money parameter: out_trade_no={}", outTradeNo);
event.setProcessResult("MISSING_AMOUNT");
webhookEventRepository.save(event);
return "fail";
}

BigDecimal callbackAmount;
try {
callbackAmount = new BigDecimal(money);
} catch (NumberFormatException e) {
log.error("Qiupay callback invalid money format: {}, out_trade_no={}", money, outTradeNo);
event.setProcessResult("INVALID_AMOUNT_FORMAT");
webhookEventRepository.save(event);
return "fail";
}

if (!isQiupayAmountWithinTolerance(order.getActualAmount(), callbackAmount)) {
log.error("Qiupay callback amount out of tolerance: order={}, callback={}", order.getActualAmount(), callbackAmount);
event.setProcessResult("AMOUNT_OUT_OF_TOLERANCE");
webhookEventRepository.save(event);
return "fail";
}

// Step 6: 服务端主动查询网关订单状态(防止伪造回调)
EpayService.ChannelConfig channelConfig = resolveChannelConfig(order);
if (channelConfig != null) {
EpayService.OrderQueryResult queryResult = epayService.queryOrder(channelConfig, outTradeNo);
if (queryResult == null) {
// 网络/网关故障 — 不写入幂等表,返回 fail 触发网关重试
log.warn("Qiupay callback deferred: server-side order query returned null, out_trade_no={}", outTradeNo);
return "fail";
}

if (!isQueryStatusPaid(queryResult.tradeStatus())) {
log.error("Qiupay callback rejected: query status={}, expected TRADE_SUCCESS/1, out_trade_no={}",
queryResult.tradeStatus(), outTradeNo);
event.setProcessResult("QUERY_STATUS_MISMATCH");
webhookEventRepository.save(event);
return "fail";
}

if (queryResult.money() == null || queryResult.money().isBlank()) {
log.error("Qiupay callback rejected: missing query money, out_trade_no={}", outTradeNo);
event.setProcessResult("QUERY_MISSING_AMOUNT");
webhookEventRepository.save(event);
return "fail";
}

BigDecimal queryAmount;
try {
queryAmount = new BigDecimal(queryResult.money());
} catch (NumberFormatException e) {
log.error("Qiupay order query returned invalid money format: {}, out_trade_no={}", queryResult.money(), outTradeNo);
event.setProcessResult("QUERY_INVALID_AMOUNT_FORMAT");
webhookEventRepository.save(event);
return "fail";
}

if (!isQiupayAmountWithinTolerance(order.getActualAmount(), queryAmount)) {
log.error("Qiupay callback rejected: query amount out of tolerance, query={}, order={}, out_trade_no={}",
queryAmount, order.getActualAmount(), outTradeNo);
event.setProcessResult("QUERY_AMOUNT_OUT_OF_TOLERANCE");
webhookEventRepository.save(event);
return "fail";
}

log.info("Qiupay callback server-side verification passed: out_trade_no={}, queryStatus={}", outTradeNo, queryResult.tradeStatus());
} else {
// 配置不完整时降级为仅签名校验
log.warn("Qiupay callback: channel config incomplete, skipping server-side query verification for out_trade_no={}", outTradeNo);
}

// Step 7: 幂等更新订单状态
if (order.getStatus() == OrderStatus.PENDING) {
order.setStatus(OrderStatus.PAID);
order.setPaidAt(LocalDateTime.now());
orderRepository.save(order);
event.setProcessResult("SUCCESS");
log.info("Qiupay callback: order {} marked as PAID", orderId);
} else {
event.setProcessResult("SKIPPED_" + order.getStatus().name());
log.info("Qiupay callback: order {} already {}", orderId, order.getStatus());
}

webhookEventRepository.save(event);
return "success";
}

@Override
@Transactional
public String processBepusdtCallback(Map<String, Object> params) {
Expand Down Expand Up @@ -346,6 +495,22 @@ private String resolveMerchantKey(UUID orderId) {
"支付渠道配置缺少 key,请在后台「支付渠道管理」中完善配置");
}

/**
* Qiupay 金额容差校验:
* 1) paidAmount >= orderAmount
* 2) paidAmount - orderAmount <= 0.99
*/
private boolean isQiupayAmountWithinTolerance(BigDecimal orderAmount, BigDecimal paidAmount) {
if (orderAmount == null || paidAmount == null) {
return false;
}
if (paidAmount.compareTo(orderAmount) < 0) {
return false;
}
return paidAmount.subtract(orderAmount).compareTo(QIUPAY_MAX_OVERPAY) <= 0;
}


/**
* 判断查询 API 返回的 status 是否表示"已支付"。
* 不同 Epay 网关实现可能返回 "TRADE_SUCCESS"(字符串)或 "1"(数字),兼容两种格式。
Expand Down
13 changes: 13 additions & 0 deletions apps/web/app/admin/payment-channels/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ const PROVIDER_OPTIONS: ProviderOption[] = [
{ key: "return_url", label: "同步跳转地址", placeholder: "例如:https://yourdomain.com/pay" },
],
},
{
type: "qiupay",
name: "QiuPay(聚合支付)",
description: "独立 provider,复用 Epay 协议,仅支持支付宝渠道",
channels: [{ code: "qiupay_alipay", name: "支付宝(QiuPay)" }],
configFields: [
{ key: "pid", label: "商户ID (PID)", placeholder: "例如:743794" },
{ key: "key", label: "商户密钥 (Key)", placeholder: "MD5 密钥", type: "password" },
{ key: "api_url", label: "API 地址", placeholder: "例如:https://pay.example.com/" },
{ key: "notify_url", label: "异步回调地址", placeholder: "例如:https://yourdomain.com/api/payments/webhook/qiupay" },
{ key: "return_url", label: "同步跳转地址", placeholder: "例如:https://yourdomain.com/pay" },
],
},
{
type: "native_alipay",
name: "原生支付宝",
Expand Down
28 changes: 23 additions & 5 deletions apps/web/components/shared/payment-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ const BRAND_COLORS: Record<string, string> = {
}

export function getPaymentBrandColor(method: string): string | undefined {
return BRAND_COLORS[normalize(method)]
const m = normalize(method)
if (m.includes("alipay") || m === "支付宝") return BRAND_COLORS.alipay
if (m.includes("wechat") || m === "微信支付") return BRAND_COLORS.wechat
if (m.includes("usdt")) return BRAND_COLORS.usdt
return BRAND_COLORS[m]
}

/** 支付方式 code → 中文名称(不依赖 i18n,用于无 hook 的场景) */
Expand All @@ -49,19 +53,33 @@ const PAYMENT_LABELS: Record<string, string> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getPaymentLabel(method: string, t?: (key: any) => string): string {
const m = normalize(method)
const isAlipay = m.includes("alipay") || m === "支付宝"
const isWechat = m.includes("wechat") || m === "微信支付"

if (t) {
if (m === "alipay") return t("payment.alipay")
if (m === "wechat") return t("payment.wechat")
if (isAlipay) return t("payment.alipay")
if (isWechat) return t("payment.wechat")
}

if (isAlipay) return PAYMENT_LABELS.alipay
if (isWechat) return PAYMENT_LABELS.wechat

if (m.includes("usdt")) {
if (m.includes("trc20")) return PAYMENT_LABELS.usdt_trc20
if (m.includes("erc20")) return PAYMENT_LABELS.usdt_erc20
if (m.includes("bep20")) return PAYMENT_LABELS.usdt_bep20
if (m.includes("bsc")) return PAYMENT_LABELS.usdt_bsc
}

return PAYMENT_LABELS[m] || method
}

/** 获取扫码提示文案(大小写不敏感) */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getPaymentScanHint(method: string, t: (key: any) => string): string {
const m = normalize(method)
if (m === "alipay") return t("payment.scanWithAlipay")
if (m === "wechat") return t("payment.scanWithWechat")
if (m.includes("alipay") || m === "支付宝") return t("payment.scanWithAlipay")
if (m.includes("wechat") || m === "微信支付") return t("payment.scanWithWechat")
return t("payment.scanToPay")
}

Expand Down
2 changes: 1 addition & 1 deletion apps/web/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ export interface CurrencyItem {
// Payment
// ============================================================

export type ProviderType = 'epay' | 'native_alipay' | 'native_wxpay' | 'usdt'
export type ProviderType = 'epay' | 'qiupay' | 'native_alipay' | 'native_wxpay' | 'usdt'

export interface PaymentChannelConfig {
// 易支付
Expand Down