Merge pull request 'Worktree tighten payment gateways' (#308) from worktree-tighten-payment-gateways into main

Reviewed-on: CYL/wordpress-theme-aksrt#308
This commit is contained in:
2026-07-27 11:44:55 +08:00
18 changed files with 684 additions and 478 deletions
+57 -64
View File
@@ -209,37 +209,49 @@ trait PayOrders {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败。', 'aksrt'), ['status' => 500]);
}
$deducted = \AKSRT\Credits\Credits::deduct(
$user_id,
$amount,
'content_points_pay',
['type' => 'post', 'id' => $post_id, 'order' => $order_number],
sprintf(__('积分购买《%s》', 'aksrt'), get_the_title($post_id))
);
if (is_wp_error($deducted) || $deducted === false) {
\AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_CANCELLED, [
'note' => __('积分扣除失败,自动取消', 'aksrt'),
]);
$msg = is_wp_error($deducted) ? $deducted->get_error_message() : __('积分扣除失败。', 'aksrt');
return new \WP_Error('aksrt_credits_deduct_failed', $msg ?: __('积分扣除失败。', 'aksrt'), ['status' => 400]);
$lock = class_exists('\AKSRT\Payment\PaymentAttemptLock')
? \AKSRT\Payment\PaymentAttemptLock::acquire($order_number)
: false;
if ($lock === false) {
return new \WP_Error('aksrt_pay_locked', __('该订单正在发起支付,请稍后重试。', 'aksrt'), ['status' => 409]);
}
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
'payment_method' => 'points',
'payment_transaction_id' => 'points_' . $order_number,
'note' => sprintf(__('积分支付 %d 积分。', 'aksrt'), $amount),
]);
if (!$paid) {
\AKSRT\Credits\Credits::add(
try {
$deducted = \AKSRT\Credits\Credits::deduct(
$user_id,
$amount,
'content_points_pay_refund',
'content_points_pay',
['type' => 'post', 'id' => $post_id, 'order' => $order_number],
sprintf(__('购买失败退款:%s', 'aksrt'), get_the_title($post_id))
sprintf(__('积分购买《%s》', 'aksrt'), get_the_title($post_id))
);
return new \WP_Error('aksrt_order_confirm_failed', __('订单确认失败,已尝试退回积分。', 'aksrt'), ['status' => 500]);
if (is_wp_error($deducted) || $deducted === false) {
\AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_CANCELLED, [
'note' => __('积分扣除失败,自动取消', 'aksrt'),
]);
$msg = is_wp_error($deducted) ? $deducted->get_error_message() : __('积分扣除失败。', 'aksrt');
return new \WP_Error('aksrt_credits_deduct_failed', $msg ?: __('积分扣除失败。', 'aksrt'), ['status' => 400]);
}
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
'payment_method' => 'points',
'payment_transaction_id' => 'points_' . $order_number,
'note' => sprintf(__('积分支付 %d 积分。', 'aksrt'), $amount),
'require_transition' => true,
]);
if (!$paid) {
\AKSRT\Credits\Credits::add(
$user_id,
$amount,
'content_points_pay_refund',
['type' => 'post', 'id' => $post_id, 'order' => $order_number],
sprintf(__('购买失败退款:%s', 'aksrt'), get_the_title($post_id))
);
return new \WP_Error('aksrt_order_confirm_failed', __('订单确认失败,已尝试退回积分。', 'aksrt'), ['status' => 500]);
}
} finally {
\AKSRT\Payment\PaymentAttemptLock::release($order_number, $lock);
}
return [
@@ -453,69 +465,50 @@ trait PayOrders {
return new \WP_Error('aksrt_pay_unavailable', __('支付网关不可用。', 'aksrt'), ['status' => 500]);
}
$gateway_manager = \AKSRT\Payment\Gateway::get_instance();
$gateway = $gateway_manager->get_gateway($payment_method);
if (!$gateway) {
return new \WP_Error('aksrt_gateway_unavailable', __('当前支付方式不可用。', 'aksrt'), [
'status' => 400,
'order_number' => $order_number,
]);
}
if (method_exists($gateway, 'is_configured') && !$gateway->is_configured()) {
return new \WP_Error('aksrt_gateway_unconfigured', __('该支付方式尚未配置,请联系站长。', 'aksrt'), [
'status' => 400,
'order_number' => $order_number,
]);
$resolved = \AKSRT\Payment\PaymentOrchestrator::resolve_gateway($payment_method);
if (empty($resolved['gateway'])) {
return new \WP_Error(
(string) ($resolved['error_code'] ?? 'aksrt_gateway_unavailable'),
(string) ($resolved['message'] ?? __('当前支付方式不可用。', 'aksrt')),
['status' => 400, 'order_number' => $order_number]
);
}
$payment_result = $gateway->create_payment($order);
$payment_result = \AKSRT\Payment\PaymentOrchestrator::pay($resolved['gateway'], $order, [
'paid_note' => __('用户完成内容购买支付。', 'aksrt'),
]);
if (empty($payment_result['success'])) {
$msg = (string) ($payment_result['message'] ?? __('支付失败。', 'aksrt'));
$code = ($payment_method === 'balance' && (str_contains($msg, '余额') || str_contains($msg, '不足')))
? 'aksrt_balance_insufficient'
: 'aksrt_pay_failed';
return new \WP_Error($code, $msg, [
'status' => 402,
'order_number' => $order_number,
]);
return new \WP_Error(
(string) ($payment_result['code'] ?? 'aksrt_pay_failed'),
(string) ($payment_result['message'] ?? __('支付失败。', 'aksrt')),
['status' => 402, 'order_number' => $order_number]
);
}
$code_url = (string) ($payment_result['payment_data']['code_url'] ?? '');
$payment_url = (string) ($payment_result['payment_url'] ?? '');
if ($payment_url !== '' || $code_url !== '') {
if (!empty($payment_result['redirect'])) {
return [
'success' => true,
'data' => [
'redirect' => true,
'payment_url' => $payment_url,
'qr_content' => $code_url,
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
'order_number' => $order_number,
'amount' => $amount,
'pay_modo' => '0',
'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'),
'message' => $payment_result['message'],
'reload' => false,
'poll' => true,
],
];
}
// Sync gateway (balance): mark paid.
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
'payment_method' => $payment_method,
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
'note' => __('用户完成内容购买支付。', 'aksrt'),
]);
if (!$paid) {
return new \WP_Error('aksrt_order_confirm_failed', __('订单确认失败。', 'aksrt'), ['status' => 500]);
}
return [
'success' => true,
'data' => [
'order_number' => $order_number,
'amount' => $amount,
'pay_modo' => '0',
'message' => $payment_result['message'] ?? __('支付成功', 'aksrt'),
'message' => $payment_result['message'],
'reload' => true,
],
];
+1 -1
View File
@@ -514,7 +514,7 @@ trait VipServicePackages {
return $methods;
}
$gw = \AKSRT\Payment\Gateway::get_instance();
foreach (['balance', 'alipay', 'wechatpay', 'paypal', 'stripe'] as $id) {
foreach (['balance', 'alipay', 'wechatpay', 'paypal', 'stripe', 'epay_alipay', 'epay_wxpay'] as $id) {
$g = $gw->get_gateway($id);
if (!$g) {
continue;
+55 -60
View File
@@ -344,59 +344,42 @@ trait VipServicePurchase {
if (!class_exists('\AKSRT\Payment\Gateway')) {
return new \WP_Error('aksrt_pay_unavailable', __('支付网关不可用', 'aksrt'), ['status' => 500]);
}
$gateway = \AKSRT\Payment\Gateway::get_instance()->get_gateway($payment_method);
if (!$gateway) {
return new \WP_Error('aksrt_gateway_unavailable', __('当前支付方式不可用', 'aksrt'), [
'status' => 400,
'order_number' => $order_number,
]);
}
if (method_exists($gateway, 'is_configured') && !$gateway->is_configured()) {
return new \WP_Error('aksrt_gateway_unconfigured', __('该支付方式尚未配置,请联系站长。', 'aksrt'), [
'status' => 400,
'order_number' => $order_number,
]);
$resolved = \AKSRT\Payment\PaymentOrchestrator::resolve_gateway($payment_method);
if (empty($resolved['gateway'])) {
return new \WP_Error(
(string) ($resolved['error_code'] ?? 'aksrt_gateway_unavailable'),
(string) ($resolved['message'] ?? __('当前支付方式不可用', 'aksrt')),
['status' => 400, 'order_number' => $order_number]
);
}
$payment_result = $gateway->create_payment($order);
$payment_result = \AKSRT\Payment\PaymentOrchestrator::pay($resolved['gateway'], $order, [
'paid_note' => __('用户完成会员开通支付。', 'aksrt'),
]);
if (empty($payment_result['success'])) {
$msg = (string) ($payment_result['message'] ?? __('支付失败', 'aksrt'));
$code = ($payment_method === 'balance' && (str_contains($msg, '余额') || str_contains($msg, '不足')))
? 'aksrt_balance_insufficient'
: 'aksrt_pay_failed';
return new \WP_Error($code, $msg, [
'status' => 402,
'order_number' => $order_number,
]);
return new \WP_Error(
(string) ($payment_result['code'] ?? 'aksrt_pay_failed'),
(string) ($payment_result['message'] ?? __('支付失败', 'aksrt')),
['status' => 402, 'order_number' => $order_number]
);
}
$code_url = (string) ($payment_result['payment_data']['code_url'] ?? '');
$payment_url = (string) ($payment_result['payment_url'] ?? '');
if ($payment_url !== '' || $code_url !== '') {
if (!empty($payment_result['redirect'])) {
return [
'success' => true,
'data' => [
'redirect' => true,
'payment_url' => $payment_url,
'qr_content' => $code_url,
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
'order_number' => $order_number,
'amount' => $amount,
'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'),
'message' => $payment_result['message'],
'reload' => false,
'poll' => true,
],
];
}
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
'payment_method' => $payment_method,
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
'note' => __('用户完成会员开通支付。', 'aksrt'),
]);
if (!$paid) {
return new \WP_Error('aksrt_order_confirm_failed', __('订单确认失败', 'aksrt'), ['status' => 500]);
}
return [
'success' => true,
'data' => [
@@ -637,35 +620,47 @@ trait VipServicePurchase {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
}
$deducted = \AKSRT\Credits\Credits::deduct(
$user_id,
$cost,
'vip_points_exchange',
['order' => $order_number, 'level' => $level, 'days' => $days],
sprintf(__('积分兑换会员 · %s', 'aksrt'), (string) ($match['title'] ?? ''))
);
if (is_wp_error($deducted) || $deducted === false) {
\AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_CANCELLED, [
'note' => __('积分扣除失败,自动取消', 'aksrt'),
]);
$msg = is_wp_error($deducted) ? $deducted->get_error_message() : __('积分扣除失败', 'aksrt');
return new \WP_Error('aksrt_credits_deduct_failed', $msg ?: __('积分扣除失败', 'aksrt'), ['status' => 400]);
$lock = class_exists('\AKSRT\Payment\PaymentAttemptLock')
? \AKSRT\Payment\PaymentAttemptLock::acquire($order_number)
: false;
if ($lock === false) {
return new \WP_Error('aksrt_pay_locked', __('该订单正在发起支付,请稍后重试。', 'aksrt'), ['status' => 409]);
}
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
'payment_method' => 'points',
'payment_transaction_id' => 'points_' . $order_number,
'note' => sprintf(__('积分兑换会员 %d 积分。', 'aksrt'), $cost),
]);
if (!$paid) {
\AKSRT\Credits\Credits::add(
try {
$deducted = \AKSRT\Credits\Credits::deduct(
$user_id,
$cost,
'vip_points_exchange_refund',
['order' => $order_number],
__('会员兑换失败退款', 'aksrt')
'vip_points_exchange',
['order' => $order_number, 'level' => $level, 'days' => $days],
sprintf(__('积分兑换会员 · %s', 'aksrt'), (string) ($match['title'] ?? ''))
);
return new \WP_Error('aksrt_order_confirm_failed', __('兑换确认失败,已尝试退回积分', 'aksrt'), ['status' => 500]);
if (is_wp_error($deducted) || $deducted === false) {
\AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_CANCELLED, [
'note' => __('积分扣除失败,自动取消', 'aksrt'),
]);
$msg = is_wp_error($deducted) ? $deducted->get_error_message() : __('积分扣除失败', 'aksrt');
return new \WP_Error('aksrt_credits_deduct_failed', $msg ?: __('积分扣除失败', 'aksrt'), ['status' => 400]);
}
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
'payment_method' => 'points',
'payment_transaction_id' => 'points_' . $order_number,
'note' => sprintf(__('积分兑换会员 %d 积分。', 'aksrt'), $cost),
'require_transition' => true,
]);
if (!$paid) {
\AKSRT\Credits\Credits::add(
$user_id,
$cost,
'vip_points_exchange_refund',
['order' => $order_number],
__('会员兑换失败退款', 'aksrt')
);
return new \WP_Error('aksrt_order_confirm_failed', __('兑换确认失败,已尝试退回积分', 'aksrt'), ['status' => 500]);
}
} finally {
\AKSRT\Payment\PaymentAttemptLock::release($order_number, $lock);
}
return [
+17 -31
View File
@@ -296,56 +296,42 @@ trait BalanceServiceCharge {
if (!class_exists('\AKSRT\Payment\Gateway')) {
return new \WP_Error('aksrt_pay_unavailable', __('支付网关不可用', 'aksrt'), ['status' => 500]);
}
$gateway = \AKSRT\Payment\Gateway::get_instance()->get_gateway($payment_method);
if (!$gateway) {
return new \WP_Error('aksrt_gateway_unavailable', __('当前支付方式不可用', 'aksrt'), [
'status' => 400,
'order_number' => $order_number,
]);
}
if (method_exists($gateway, 'is_configured') && !$gateway->is_configured()) {
return new \WP_Error('aksrt_gateway_unconfigured', __('该支付方式尚未配置,请联系站长。', 'aksrt'), [
'status' => 400,
'order_number' => $order_number,
]);
$resolved = \AKSRT\Payment\PaymentOrchestrator::resolve_gateway($payment_method);
if (empty($resolved['gateway'])) {
return new \WP_Error(
(string) ($resolved['error_code'] ?? 'aksrt_gateway_unavailable'),
(string) ($resolved['message'] ?? __('当前支付方式不可用', 'aksrt')),
['status' => 400, 'order_number' => $order_number]
);
}
$payment_result = $gateway->create_payment($order);
$payment_result = \AKSRT\Payment\PaymentOrchestrator::pay($resolved['gateway'], $order, [
'paid_note' => __('余额充值支付完成', 'aksrt'),
]);
if (empty($payment_result['success'])) {
return new \WP_Error(
'aksrt_pay_failed',
(string) ($payment_result['code'] ?? 'aksrt_pay_failed'),
(string) ($payment_result['message'] ?? __('支付失败', 'aksrt')),
['status' => 402, 'order_number' => $order_number]
);
}
$code_url = (string) ($payment_result['payment_data']['code_url'] ?? '');
$payment_url = (string) ($payment_result['payment_url'] ?? '');
if ($payment_url !== '' || $code_url !== '') {
if (!empty($payment_result['redirect'])) {
return [
'redirect' => true,
'payment_url' => $payment_url,
'qr_content' => $code_url,
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
'order_number' => $order_number,
'amount' => $pay_amount,
'credit_amount' => $credit_amount,
'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'),
'message' => $payment_result['message'],
'reload' => false,
'poll' => true,
];
}
// Synchronous gateway (rare for recharge): mark paid → on_order_paid credits wallet.
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
'payment_method' => $payment_method,
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
'note' => __('余额充值支付完成', 'aksrt'),
]);
if (!$paid) {
return new \WP_Error('aksrt_order_confirm_failed', __('支付确认失败', 'aksrt'), ['status' => 500]);
}
// Ensure credit even if status hook shape differs.
$this->on_order_paid($order_number, \AKSRT\Payment\OrderRepository::get_instance()->get($order_number) ?: $order);
// Synchronous gateway: PaymentOrchestrator's paid transition already fired
// aksrt_order_paid, so fulfillment has run through the single hook path.
return [
'redirect' => false,
+20 -23
View File
@@ -195,41 +195,38 @@ trait BalanceServicePoints {
if (!class_exists('\AKSRT\Payment\Gateway')) {
return new \WP_Error('aksrt_pay_unavailable', __('支付网关不可用', 'aksrt'), ['status' => 500]);
}
$gateway = \AKSRT\Payment\Gateway::get_instance()->get_gateway($payment_method);
if (!$gateway) {
return new \WP_Error('aksrt_gateway_unavailable', __('当前支付方式不可用', 'aksrt'), ['status' => 400, 'order_number' => $order_number]);
$resolved = \AKSRT\Payment\PaymentOrchestrator::resolve_gateway($payment_method);
if (empty($resolved['gateway'])) {
return new \WP_Error(
(string) ($resolved['error_code'] ?? 'aksrt_gateway_unavailable'),
(string) ($resolved['message'] ?? __('当前支付方式不可用', 'aksrt')),
['status' => 400, 'order_number' => $order_number]
);
}
if (method_exists($gateway, 'is_configured') && !$gateway->is_configured()) {
return new \WP_Error('aksrt_gateway_unconfigured', __('该支付方式尚未配置,请联系站长。', 'aksrt'), ['status' => 400, 'order_number' => $order_number]);
}
$payment_result = $gateway->create_payment($order);
$payment_result = \AKSRT\Payment\PaymentOrchestrator::pay($resolved['gateway'], $order, [
'paid_note' => __('购买积分支付完成', 'aksrt'),
]);
if (empty($payment_result['success'])) {
return new \WP_Error('aksrt_pay_failed', (string) ($payment_result['message'] ?? __('支付失败', 'aksrt')), ['status' => 402, 'order_number' => $order_number]);
return new \WP_Error(
(string) ($payment_result['code'] ?? 'aksrt_pay_failed'),
(string) ($payment_result['message'] ?? __('支付失败', 'aksrt')),
['status' => 402, 'order_number' => $order_number]
);
}
$code_url = (string) ($payment_result['payment_data']['code_url'] ?? '');
$payment_url = (string) ($payment_result['payment_url'] ?? '');
if ($payment_url !== '' || $code_url !== '') {
if (!empty($payment_result['redirect'])) {
return [
'redirect' => true,
'payment_url' => $payment_url,
'qr_content' => $code_url,
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
'order_number' => $order_number,
'amount' => $pay_amount,
'points_amount' => $points_amount,
'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'),
'message' => $payment_result['message'],
'reload' => false,
'poll' => true,
];
}
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
'payment_method' => $payment_method,
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
'note' => __('购买积分支付完成', 'aksrt'),
]);
if (!$paid) {
return new \WP_Error('aksrt_order_confirm_failed', __('支付确认失败', 'aksrt'), ['status' => 500]);
}
$this->on_order_paid($order_number, \AKSRT\Payment\OrderRepository::get_instance()->get($order_number) ?: $order);
// PaymentOrchestrator's paid transition already dispatched the fulfillment hook.
return [
'redirect' => false,
'order_number' => $order_number,
+20 -42
View File
@@ -441,72 +441,50 @@ class PlateFollowPay {
return new \WP_Error('aksrt_pay_unavailable', __('支付网关不可用。', 'aksrt'), ['status' => 500]);
}
$gateway_manager = \AKSRT\Payment\Gateway::get_instance();
$gateway = $gateway_manager->get_gateway($payment_method);
if (!$gateway) {
return new \WP_Error('aksrt_gateway_unavailable', __('当前支付方式不可用。', 'aksrt'), [
'status' => 400,
'order_number' => $order_number,
]);
}
if (method_exists($gateway, 'is_configured') && !$gateway->is_configured()) {
return new \WP_Error('aksrt_gateway_unconfigured', __('该支付方式尚未配置,请联系站长。', 'aksrt'), [
'status' => 400,
'order_number' => $order_number,
]);
$resolved = \AKSRT\Payment\PaymentOrchestrator::resolve_gateway($payment_method);
if (empty($resolved['gateway'])) {
return new \WP_Error(
(string) ($resolved['error_code'] ?? 'aksrt_gateway_unavailable'),
(string) ($resolved['message'] ?? __('当前支付方式不可用。', 'aksrt')),
['status' => 400, 'order_number' => $order_number]
);
}
$payment_result = $gateway->create_payment($order);
$payment_result = \AKSRT\Payment\PaymentOrchestrator::pay($resolved['gateway'], $order, [
'paid_note' => __('用户完成版块付费关注支付。', 'aksrt'),
]);
if (empty($payment_result['success'])) {
$msg = (string) ($payment_result['message'] ?? __('支付失败。', 'aksrt'));
$code = ($payment_method === 'balance' && (str_contains($msg, '余额') || str_contains($msg, '不足')))
? 'aksrt_balance_insufficient'
: 'aksrt_pay_failed';
return new \WP_Error($code, $msg, [
'status' => 402,
'order_number' => $order_number,
]);
return new \WP_Error(
(string) ($payment_result['code'] ?? 'aksrt_pay_failed'),
(string) ($payment_result['message'] ?? __('支付失败。', 'aksrt')),
['status' => 402, 'order_number' => $order_number]
);
}
$code_url = (string) ($payment_result['payment_data']['code_url'] ?? '');
$payment_url = (string) ($payment_result['payment_url'] ?? '');
if ($payment_url !== '' || $code_url !== '') {
if (!empty($payment_result['redirect'])) {
return [
'success' => true,
'data' => [
'redirect' => true,
'payment_url' => $payment_url,
'qr_content' => $code_url,
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
'order_number' => $order_number,
'amount' => $amount,
'pay_modo' => '0',
'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'),
'message' => $payment_result['message'],
'reload' => false,
'poll' => true,
],
];
}
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
'payment_method' => $payment_method,
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
'note' => __('用户完成版块付费关注支付。', 'aksrt'),
]);
if (!$paid) {
return new \WP_Error('aksrt_order_confirm_failed', __('订单确认失败。', 'aksrt'), ['status' => 500]);
}
if ($user_id > 0) {
$this->mark_followed($user_id, $plate_id);
}
return [
'success' => true,
'data' => [
'order_number' => $order_number,
'amount' => $amount,
'pay_modo' => '0',
'message' => $payment_result['message'] ?? __('关注成功', 'aksrt'),
'message' => $payment_result['message'],
'reload' => true,
],
];
+20 -43
View File
@@ -441,73 +441,50 @@ class TopicPay {
return new \WP_Error('aksrt_pay_unavailable', __('支付网关不可用。', 'aksrt'), ['status' => 500]);
}
$gateway_manager = \AKSRT\Payment\Gateway::get_instance();
$gateway = $gateway_manager->get_gateway($payment_method);
if (!$gateway) {
return new \WP_Error('aksrt_gateway_unavailable', __('当前支付方式不可用。', 'aksrt'), [
'status' => 400,
'order_number' => $order_number,
]);
}
if (method_exists($gateway, 'is_configured') && !$gateway->is_configured()) {
return new \WP_Error('aksrt_gateway_unconfigured', __('该支付方式尚未配置,请联系站长。', 'aksrt'), [
'status' => 400,
'order_number' => $order_number,
]);
$resolved = \AKSRT\Payment\PaymentOrchestrator::resolve_gateway($payment_method);
if (empty($resolved['gateway'])) {
return new \WP_Error(
(string) ($resolved['error_code'] ?? 'aksrt_gateway_unavailable'),
(string) ($resolved['message'] ?? __('当前支付方式不可用。', 'aksrt')),
['status' => 400, 'order_number' => $order_number]
);
}
$payment_result = $gateway->create_payment($order);
$payment_result = \AKSRT\Payment\PaymentOrchestrator::pay($resolved['gateway'], $order, [
'paid_note' => __('用户完成论坛主题购买支付。', 'aksrt'),
]);
if (empty($payment_result['success'])) {
$msg = (string) ($payment_result['message'] ?? __('支付失败。', 'aksrt'));
$code = ($payment_method === 'balance' && (str_contains($msg, '余额') || str_contains($msg, '不足')))
? 'aksrt_balance_insufficient'
: 'aksrt_pay_failed';
return new \WP_Error($code, $msg, [
'status' => 402,
'order_number' => $order_number,
]);
return new \WP_Error(
(string) ($payment_result['code'] ?? 'aksrt_pay_failed'),
(string) ($payment_result['message'] ?? __('支付失败。', 'aksrt')),
['status' => 402, 'order_number' => $order_number]
);
}
$code_url = (string) ($payment_result['payment_data']['code_url'] ?? '');
$payment_url = (string) ($payment_result['payment_url'] ?? '');
if ($payment_url !== '' || $code_url !== '') {
if (!empty($payment_result['redirect'])) {
return [
'success' => true,
'data' => [
'redirect' => true,
'payment_url' => $payment_url,
'qr_content' => $code_url,
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
'order_number' => $order_number,
'amount' => $amount,
'pay_modo' => '0',
'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'),
'message' => $payment_result['message'],
'reload' => false,
'poll' => true,
],
];
}
// Sync gateway (balance): mark paid.
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
'payment_method' => $payment_method,
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
'note' => __('用户完成论坛主题购买支付。', 'aksrt'),
]);
if (!$paid) {
return new \WP_Error('aksrt_order_confirm_failed', __('订单确认失败。', 'aksrt'), ['status' => 500]);
}
if ($user_id > 0) {
$this->mark_unlocked($user_id, $topic_id);
}
return [
'success' => true,
'data' => [
'order_number' => $order_number,
'amount' => $amount,
'pay_modo' => '0',
'message' => $payment_result['message'] ?? __('支付成功', 'aksrt'),
'message' => $payment_result['message'],
'reload' => true,
],
];
+41 -10
View File
@@ -101,16 +101,47 @@ class BalanceGateway implements GatewayInterface {
return ['success' => false, 'message' => __('余额功能未启用', 'aksrt')];
}
$result = \AKSRT\Credits\Balance::add(
$user_id,
$amount,
'order_refund',
['type' => 'order', 'order' => $order_number],
sprintf(__('订单 %s 退款', 'aksrt'), $order_number)
);
if (is_wp_error($result)) {
return ['success' => false, 'message' => $result->get_error_message() ?: __('退款失败', 'aksrt')];
$request_id = sanitize_text_field($request_id);
$refund_key = $request_id !== '' ? $request_id : 'order:' . $order_number;
$lock_key = 'aksrt_balance_refund_' . hash('sha256', $refund_key);
$existing = get_option($lock_key, []);
if (is_array($existing) && ($existing['status'] ?? '') === 'succeeded') {
return [
'success' => true,
'refund_id' => 'BAL_REF_' . $order_number,
'message' => __('退款已处理(幂等)', 'aksrt'),
];
}
if (is_array($existing) && ($existing['status'] ?? '') === 'pending') {
return ['success' => false, 'status' => 'pending', 'message' => __('退款正在处理中,请稍后查询', 'aksrt')];
}
$marker = ['status' => 'pending', 'token' => wp_generate_uuid4(), 'created_at' => time()];
if (!add_option($lock_key, $marker, '', false)) {
return ['success' => false, 'status' => 'pending', 'message' => __('退款正在处理中,请稍后查询', 'aksrt')];
}
try {
$result = \AKSRT\Credits\Balance::add(
$user_id,
$amount,
'order_refund',
['type' => 'order', 'order' => $order_number, 'request_id' => $request_id],
sprintf(__('订单 %s 退款', 'aksrt'), $order_number)
);
if (is_wp_error($result)) {
delete_option($lock_key);
return ['success' => false, 'message' => $result->get_error_message() ?: __('退款失败', 'aksrt')];
}
update_option($lock_key, [
'status' => 'succeeded',
'refund_id' => 'BAL_REF_' . $order_number,
'completed_at' => time(),
], false);
return ['success' => true, 'refund_id' => 'BAL_REF_' . $order_number, 'message' => __('退款成功', 'aksrt')];
} catch (\Throwable $e) {
delete_option($lock_key);
return ['success' => false, 'message' => $e->getMessage()];
}
return ['success' => true, 'refund_id' => 'BAL_REF_' . $order_number, 'message' => __('退款成功', 'aksrt')];
}
}
+41 -15
View File
@@ -146,26 +146,35 @@ class Gateway {
* 用户浏览器同步返回。尽力确认到账(兜底异步通知延迟),再跳回订单中心。
*/
public function handle_return(\WP_REST_Request $request) {
$gateway_id = sanitize_key((string) $request->get_param('gateway'));
$order_number = sanitize_text_field((string) ($request->get_param('order')
$gateway_id = sanitize_key((string) $request->get_param('gateway'));
// Providers echo back whatever number we submitted, which for a re-pay is an
// attempt-suffixed out_trade_no. Map it to the local order for query + redirect.
$provider_ref = sanitize_text_field((string) ($request->get_param('order')
?: $request->get_param('out_trade_no')
?: ''));
$order_number = $this->local_order_number($provider_ref);
$gateway = $this->get_gateway($gateway_id);
$status = 'pending';
if ($gateway) {
if ($gateway_id === 'alipay') {
// 支付宝同步返回带签名参数,可直接验签作为到账兜底
if ($gateway_id === 'alipay' || str_starts_with($gateway_id, 'epay')) {
// 支付宝 / 易支付同步返回带签名参数,可直接验签作为到账兜底
$verify = $gateway->verify_callback($request);
if (!empty($verify['success'])) {
$order_number = $order_number ?: sanitize_text_field((string) ($verify['order_number'] ?? ''));
if ($order_number === '') {
$order_number = $this->local_order_number(
sanitize_text_field((string) ($verify['order_number'] ?? ''))
);
}
if (($verify['status'] ?? '') === 'paid' && $this->complete_order($gateway_id, $verify)) {
$status = 'success';
} elseif ($order_number !== '') {
// Alipay synchronous returns generally omit trade_status; query after signature verification.
$query = $gateway->query_status($order_number);
// Synchronous returns generally omit trade_status; query after signature verification.
$query = $gateway->query_status($provider_ref !== '' ? $provider_ref : $order_number);
if (!empty($query['success']) && ($query['status'] ?? '') === 'paid') {
$query['order_number'] = $order_number;
if (empty($query['order_number'])) {
$query['order_number'] = $order_number;
}
if ($this->complete_order($gateway_id, $query)) {
$status = 'success';
}
@@ -178,7 +187,8 @@ class Gateway {
if ($paypal_order_id !== '') {
$cap = $gateway->capture_order($paypal_order_id);
if (!empty($cap['success']) && ($cap['status'] ?? '') === 'paid') {
$captured_order = sanitize_text_field((string) ($cap['order_number'] ?? ''));
$captured_ref = sanitize_text_field((string) ($cap['order_number'] ?? ''));
$captured_order = $this->local_order_number($captured_ref);
if ($captured_order === ''
|| ($order_number !== '' && !hash_equals($order_number, $captured_order))) {
$this->redirect_to_orders($order_number, 'pending');
@@ -191,8 +201,8 @@ class Gateway {
}
}
} elseif ($order_number !== '') {
// 其余网关:主动查询订单状态兜底
$q = $gateway->query_status($order_number);
// 其余网关:用返回时的 provider ref 查询该次尝试;local number 仅用于跳转。
$q = $gateway->query_status($provider_ref !== '' ? $provider_ref : $order_number);
if (!empty($q['success']) && ($q['status'] ?? '') === 'paid') {
if (empty($q['order_number'])) {
$q['order_number'] = $order_number;
@@ -212,13 +222,27 @@ class Gateway {
* 用户取消支付,跳回订单中心。
*/
public function handle_cancel(\WP_REST_Request $request) {
$order_number = sanitize_text_field((string) ($request->get_param('order')
$provider_ref = sanitize_text_field((string) ($request->get_param('order')
?: $request->get_param('out_trade_no')
?: ''));
$this->redirect_to_orders($order_number, 'cancelled');
$this->redirect_to_orders($this->local_order_number($provider_ref), 'cancelled');
return null;
}
/**
* Map a provider out_trade_no back to the local order number (identity when unknown).
*/
private function local_order_number(string $provider_ref): string {
if ($provider_ref === '' || !class_exists('AKSRT\\Payment\\OrderRepository')) {
return $provider_ref;
}
$order = OrderRepository::get_instance()->resolve_by_out_trade_no($provider_ref);
if (!$order) {
return $provider_ref;
}
return sanitize_text_field((string) ($order['order_number'] ?? $provider_ref));
}
/**
* 幂等、带金额校验的订单完成。仅由验签通过的回调调用。
*
@@ -272,9 +296,11 @@ class Gateway {
$current = (string) ($order['status'] ?? '');
// 幂等:已支付/已交付/已完成视为成功,不重复处理(回调可能多次到达)
// 幂等:仅同一交易号的重复回调可视为成功。不同交易号意味着
// 用户可能重复付款,不能静默 ACK 为已正确同步。
if (in_array($current, ['paid', 'delivered', 'completed'], true)) {
return true;
$stored_transaction = sanitize_text_field((string) ($order['payment_transaction_id'] ?? ''));
return $stored_transaction !== '' && hash_equals($stored_transaction, $transaction_id);
}
// 只允许从未支付态完成
if (!in_array($current, ['created', 'pending'], true)) {
+6 -1
View File
@@ -294,7 +294,12 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
*/
private function resolve_out_trade_no(string $order_number): string {
if (class_exists('\AKSRT\Payment\OrderRepository')) {
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
$repo = \AKSRT\Payment\OrderRepository::get_instance();
// A provider attempt id must be queried verbatim, including superseded Pn.
if ($repo->resolve_by_out_trade_no($order_number) && !$repo->get($order_number)) {
return $order_number;
}
$order = $repo->get($order_number);
if ($order) {
return \AKSRT\Payment\OrderRepository::provider_out_trade_no($order);
}
+5 -1
View File
@@ -328,7 +328,11 @@ class EpayGateway implements \AKSRT\Payment\GatewayInterface {
*/
private function resolve_out_trade_no(string $order_number): string {
if (class_exists('\AKSRT\Payment\OrderRepository')) {
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
$repo = \AKSRT\Payment\OrderRepository::get_instance();
if ($repo->resolve_by_out_trade_no($order_number) && !$repo->get($order_number)) {
return $order_number;
}
$order = $repo->get($order_number);
if ($order) {
return \AKSRT\Payment\OrderRepository::provider_out_trade_no($order);
}
+12 -4
View File
@@ -32,7 +32,14 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
}
$config = $this->get_config();
$order_number = $order['order_number'] ?? '';
$order_number = (string) ($order['order_number'] ?? '');
// Re-pay attempts carry a fresh provider number; the callback resolves it back.
$out_trade_no = (string) ($order['out_trade_no'] ?? '');
if ($out_trade_no === '') {
$out_trade_no = class_exists('\AKSRT\Payment\OrderRepository')
? \AKSRT\Payment\OrderRepository::provider_out_trade_no($order)
: $order_number;
}
$amount = (float) ($order['amount'] ?? 0);
$subject = $order['subject'] ?? __('订单支付', 'aksrt');
$currency = $config['currency'] ?? 'USD';
@@ -41,7 +48,7 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
$response = $this->api_request('POST', '/v2/checkout/orders', [
'intent' => 'CAPTURE',
'purchase_units' => [[
'reference_id' => $order_number,
'reference_id' => $out_trade_no,
'description' => $subject,
'amount' => [
'currency_code' => $currency,
@@ -49,8 +56,8 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
],
]],
'application_context' => [
'return_url' => home_url('/wp-json/aksrt/v1/payment/paypal/return?order=' . $order_number),
'cancel_url' => home_url('/wp-json/aksrt/v1/payment/paypal/cancel?order=' . $order_number),
'return_url' => home_url('/wp-json/aksrt/v1/payment/paypal/return?order=' . rawurlencode($out_trade_no)),
'cancel_url' => home_url('/wp-json/aksrt/v1/payment/paypal/cancel?order=' . rawurlencode($out_trade_no)),
'brand_name' => get_bloginfo('name'),
'user_action' => 'PAY_NOW',
],
@@ -71,6 +78,7 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
'payment_url' => $approveUrl,
'payment_data' => [
'order_number' => $order_number,
'out_trade_no' => $out_trade_no,
'paypal_id' => $data['id'] ?? '',
],
'message' => __('PayPal 订单已创建', 'aksrt'),
+101 -38
View File
@@ -32,7 +32,14 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
}
$config = $this->get_config();
$order_number = $order['order_number'] ?? '';
$order_number = (string) ($order['order_number'] ?? '');
// Re-pay attempts carry a fresh provider number; the callback resolves it back.
$out_trade_no = (string) ($order['out_trade_no'] ?? '');
if ($out_trade_no === '') {
$out_trade_no = class_exists('\AKSRT\Payment\OrderRepository')
? \AKSRT\Payment\OrderRepository::provider_out_trade_no($order)
: $order_number;
}
$amount = (float) ($order['amount'] ?? 0);
$subject = $order['subject'] ?? __('订单支付', 'aksrt');
$currency = strtolower($config['currency'] ?? 'usd');
@@ -54,10 +61,11 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
'quantity' => 1,
]],
'mode' => 'payment',
'success_url' => home_url('/wp-json/aksrt/v1/payment/stripe/return?session_id={CHECKOUT_SESSION_ID}&order=' . $order_number),
'cancel_url' => home_url('/wp-json/aksrt/v1/payment/stripe/cancel?order=' . $order_number),
'client_reference_id' => $out_trade_no,
'success_url' => home_url('/wp-json/aksrt/v1/payment/stripe/return?session_id={CHECKOUT_SESSION_ID}&order=' . rawurlencode($out_trade_no)),
'cancel_url' => home_url('/wp-json/aksrt/v1/payment/stripe/cancel?order=' . rawurlencode($out_trade_no)),
'metadata' => [
'order_number' => $order_number,
'order_number' => $out_trade_no,
],
];
@@ -72,11 +80,31 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
'api_key' => $config['secret_key'],
]);
// Persist the session id before returning the redirect. Without this,
// status/refund lookup may miss older sessions after traffic exceeds 100.
if ($order_number !== '' && class_exists('\AKSRT\Payment\OrderRepository')) {
$persisted = \AKSRT\Payment\OrderRepository::get_instance()->merge_meta($order_number, [
'stripe_session_id' => (string) $session->id,
'stripe_sessions' => $this->append_session_mapping($order, $out_trade_no, (string) $session->id),
]);
if (!$persisted) {
try {
\Stripe\Checkout\Session::expire((string) $session->id, [
'api_key' => $config['secret_key'],
]);
} catch (\Throwable $e) {
// Best-effort cleanup; do not return an untraceable session.
}
return ['success' => false, 'message' => __('Stripe 支付会话保存失败,请重试', 'aksrt')];
}
}
return [
'success' => true,
'payment_url' => $session->url,
'payment_data' => [
'order_number' => $order_number,
'out_trade_no' => $out_trade_no,
'session_id' => $session->id,
],
'message' => __('Stripe 支付页面已创建', 'aksrt'),
@@ -135,23 +163,8 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
$config = $this->get_config();
try {
$stripe = $this->create_client($config);
// 注意:Stripe API 的 Session::all() 不支持按 metadata 过滤。
// 此处通过创建时间倒序获取最近的 session,再匹配 metadata。
// 生产环境建议在 create_payment 时保存 session_id 并用 Session::retrieve() 查询。
$sessions = \Stripe\Checkout\Session::all([
'limit' => 100,
], ['api_key' => $config['secret_key']]);
// 手动按 metadata 匹配
$found = null;
foreach ($sessions->data as $session) {
if (($session->metadata->order_number ?? '') === $order_number) {
$found = $session;
break;
}
}
$this->create_client($config);
$found = $this->find_session($order_number, $config, true);
if ($found) {
$status = 'pending';
@@ -191,32 +204,22 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
$config = $this->get_config();
try {
$stripe = $this->create_client($config);
// 先查询 payment_intent(手动按 metadata 匹配)
$sessions = \Stripe\Checkout\Session::all([
'limit' => 100,
], ['api_key' => $config['secret_key']]);
$found = null;
foreach ($sessions->data as $session) {
if (($session->metadata->order_number ?? '') === $order_number) {
$found = $session;
break;
}
}
$this->create_client($config);
$found = $this->find_session($order_number, $config, true);
if (!$found) {
return ['success' => false, 'message' => __('未找到支付记录', 'aksrt')];
}
$paymentIntentId = $found->payment_intent;
$paymentIntentId = is_string($found->payment_intent)
? $found->payment_intent
: (string) ($found->payment_intent->id ?? '');
if (empty($paymentIntentId)) {
if ($paymentIntentId === '') {
return ['success' => false, 'message' => __('未找到支付意图', 'aksrt')];
}
$currency = strtolower($config['currency'] ?? 'usd');
$currency = strtolower((string) ($found->currency ?? $config['currency'] ?? 'usd'));
$options = ['api_key' => $config['secret_key']];
if ($request_id !== '') {
@@ -282,6 +285,66 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
return $config['publishable_key'] ?? '';
}
/**
* @param array<string,mixed> $order
* @return array<string,string>
*/
private function append_session_mapping(array $order, string $out_trade_no, string $session_id): array {
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$map = is_array($meta['stripe_sessions'] ?? null) ? $meta['stripe_sessions'] : [];
$map[$out_trade_no] = $session_id;
if (count($map) > 20) {
$map = array_slice($map, -20, null, true);
}
return $map;
}
/**
* Locate the Checkout Session for an order. Prefers the session id persisted
* at create time; falls back to scanning recent sessions for legacy orders.
*
* @return \Stripe\Checkout\Session|null
*/
private function find_session(string $order_number, array $config, bool $allow_legacy_scan = false) {
$session_id = '';
if ($order_number !== '' && class_exists('\AKSRT\Payment\OrderRepository')) {
$repo = \AKSRT\Payment\OrderRepository::get_instance();
$order = $repo->resolve_by_out_trade_no($order_number) ?: $repo->get($order_number);
if ($order) {
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$sessions = is_array($meta['stripe_sessions'] ?? null) ? $meta['stripe_sessions'] : [];
$session_id = sanitize_text_field((string) ($sessions[$order_number] ?? ''));
if ($session_id === '' && $repo->get($order_number)) {
$current_ref = \AKSRT\Payment\OrderRepository::provider_out_trade_no($order);
$session_id = sanitize_text_field((string) ($sessions[$current_ref] ?? $meta['stripe_session_id'] ?? ''));
}
}
}
if ($session_id !== '') {
try {
return \Stripe\Checkout\Session::retrieve($session_id, ['api_key' => $config['secret_key']]);
} catch (\Exception $e) {
// Fall through to the scan below.
}
}
if (!$allow_legacy_scan) {
return null;
}
// Legacy orders created before session-id persistence can only be found
// through Stripe's recent-session window.
$sessions = \Stripe\Checkout\Session::all(['limit' => 100], ['api_key' => $config['secret_key']]);
foreach ($sessions->data as $session) {
if (($session->metadata->order_number ?? '') === $order_number) {
return $session;
}
}
return null;
}
private function get_config(): array {
if (!class_exists('\AKSRT\Settings\Settings')) {
return [];
@@ -390,7 +390,11 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
*/
private function resolve_out_trade_no(string $order_number): string {
if (class_exists('\AKSRT\Payment\OrderRepository')) {
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
$repo = \AKSRT\Payment\OrderRepository::get_instance();
if ($repo->resolve_by_out_trade_no($order_number) && !$repo->get($order_number)) {
return $order_number;
}
$order = $repo->get($order_number);
if ($order) {
return \AKSRT\Payment\OrderRepository::provider_out_trade_no($order);
}
+38 -2
View File
@@ -546,8 +546,15 @@ trait OrderRepositoryCore {
/**
* Resolve a local order from a provider out_trade_no (base or attempt-suffixed).
*
* The authoritative mapping is the order's own meta (provider_out_trade_no /
* provider_out_trade_nos); the prefix heuristic is only a fallback and must
* never be the sole resolver, otherwise a truncated attempt number would
* strand a real payment.
*/
public function resolve_by_out_trade_no(string $out_trade_no): ?array {
global $wpdb;
$out_trade_no = sanitize_text_field($out_trade_no);
if ($out_trade_no === '') {
return null;
@@ -558,8 +565,37 @@ trait OrderRepositoryCore {
return $direct;
}
// Resume attempts: {order_number}P{n}
if (preg_match('/^(AKS[0-9]{10,})P([0-9]{1,3})$/', $out_trade_no, $m)) {
// Authoritative: the attempt number was recorded in the order note JSON.
$needle = '"provider_out_trade_no":"' . $out_trade_no . '"';
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE note LIKE %s ORDER BY id DESC LIMIT 1",
'%' . $wpdb->esc_like($needle) . '%'
),
ARRAY_A
);
if (is_array($row)) {
return $this->normalize_row($row);
}
// Superseded attempts stay in the history array.
$history_needle = '"' . $out_trade_no . '"';
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$this->table()}
WHERE note LIKE %s AND note LIKE %s
ORDER BY id DESC LIMIT 1",
'%' . $wpdb->esc_like('"provider_out_trade_nos"') . '%',
'%' . $wpdb->esc_like($history_needle) . '%'
),
ARRAY_A
);
if (is_array($row)) {
return $this->normalize_row($row);
}
// Last resort: {order_number}P{n} prefix heuristic.
if (preg_match('/^(.+)P([0-9]{1,3})$/', $out_trade_no, $m)) {
$base = $this->get($m[1]);
if ($base) {
return $base;
+199
View File
@@ -0,0 +1,199 @@
<?php
/**
* Single execution path for "create a payment attempt for an existing order".
*
* Every cashier (shop / content / VIP / forum / recharge / points) must route
* through here so that all of them get:
* 1. a cross-request attempt lock (no double deduction on double-click)
* 2. a fresh provider out_trade_no on re-pay (providers reject reused numbers)
* 3. best-effort close of the previous provider trade
* 4. strict CAS on the paid transition + rollback of synchronous deductions
*
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
class PaymentOrchestrator {
/** Methods settled synchronously in-process (no provider round trip). */
private const SYNC_METHODS = ['balance', 'points'];
/**
* @param array<string,mixed> $order Order row plus subject/amount overrides.
* @param array<string,mixed> $opts paid_note: string
*
* @return array{
* success:bool,
* code?:string,
* message:string,
* redirect?:bool,
* payment_url?:string,
* qr_content?:string,
* transaction_id?:string,
* out_trade_no?:string
* }
*/
public static function pay(GatewayInterface $gateway, array $order, array $opts = []): array {
$order_number = sanitize_text_field((string) ($order['order_number'] ?? ''));
if ($order_number === '' || !class_exists('AKSRT\\Payment\\OrderRepository')) {
return self::fail('aksrt_pay_failed', __('订单号无效。', 'aksrt'));
}
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
if ($method === '') {
return self::fail('aksrt_pay_failed', __('支付方式无效。', 'aksrt'));
}
$lock = PaymentAttemptLock::acquire($order_number);
if ($lock === false) {
return self::fail('aksrt_pay_locked', __('该订单正在发起支付,请稍后重试。', 'aksrt'));
}
try {
$repo = OrderRepository::get_instance();
// A pending order remains bound to the gateway selected at creation.
// Switching it would make a late callback from the previous gateway look
// invalid after that provider may already have collected the money.
$stored = $repo->get($order_number);
$stored_status = sanitize_key((string) ($stored['status'] ?? ''));
if (!in_array($stored_status, [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) {
return self::fail('aksrt_order_not_payable', __('当前订单状态不可支付。', 'aksrt'));
}
$stored_method = sanitize_key((string) ($stored['payment_method'] ?? ''));
if ($stored_method !== '' && !hash_equals($stored_method, $method)) {
return self::fail('aksrt_pay_method_mismatch', __('支付方式与订单不一致,请重新下单。', 'aksrt'));
}
// External providers refuse a second submission that reuses the same
// merchant order number with changed params — mint a new one per attempt.
if (!in_array($method, self::SYNC_METHODS, true)) {
$attempt = $repo->allocate_payment_attempt($order_number);
if ($attempt === null) {
return self::fail('aksrt_pay_failed', __('无法生成支付单号,请稍后重试。', 'aksrt'));
}
$order['out_trade_no'] = (string) $attempt['out_trade_no'];
$previous = (string) ($attempt['previous'] ?? '');
if ($previous !== '' && $previous !== $order['out_trade_no'] && method_exists($gateway, 'close_trade')) {
try {
$gateway->close_trade($previous);
} catch (\Throwable $e) {
// Best-effort: the new attempt already uses a fresh number.
}
}
}
$result = $gateway->create_payment($order);
if (empty($result['success'])) {
$message = (string) ($result['message'] ?? __('支付失败。', 'aksrt'));
return self::fail(self::failure_code($method, $message), $message);
}
$payment_url = (string) ($result['payment_url'] ?? '');
$qr_content = (string) ($result['payment_data']['code_url'] ?? '');
if ($payment_url !== '' || $qr_content !== '') {
// Redirect/scan gateways: the order stays pending until the signed
// notify/return callback confirms it. Never pre-mark as paid here.
return [
'success' => true,
'redirect' => true,
'payment_url' => $payment_url,
'qr_content' => $qr_content,
'out_trade_no' => (string) ($order['out_trade_no'] ?? $order_number),
'message' => (string) ($result['message'] ?? __('请在新页面完成支付。', 'aksrt')),
];
}
// Synchronous gateway (balance): funds already moved inside create_payment().
$transaction_id = sanitize_text_field((string) ($result['transaction_id'] ?? ''));
$paid = $repo->update_status($order_number, Order::STATUS_PAID, [
'payment_method' => $method,
'payment_transaction_id' => $transaction_id,
'note' => (string) ($opts['paid_note'] ?? __('用户完成支付。', 'aksrt')),
// Require a real status transition: if a callback already moved the
// order, this attempt must roll its deduction back instead of
// silently reporting success.
'require_transition' => true,
]);
if (!$paid) {
$rolled_back = self::rollback($gateway, $order_number, (float) ($order['amount'] ?? 0), $method);
return self::fail(
'aksrt_order_confirm_failed',
$rolled_back
? __('订单状态已变化,扣款已退回。', 'aksrt')
: __('订单状态已变化,扣款补偿失败,请联系管理员处理。', 'aksrt')
);
}
return [
'success' => true,
'redirect' => false,
'transaction_id' => $transaction_id,
'message' => (string) ($result['message'] ?? __('支付成功。', 'aksrt')),
];
} finally {
PaymentAttemptLock::release($order_number, $lock);
}
}
/**
* Resolve + validate the gateway for a cashier request.
*
* @return array{gateway:GatewayInterface}|array{error_code:string,message:string}
*/
public static function resolve_gateway(string $payment_method): array {
if (!class_exists('AKSRT\\Payment\\Gateway')) {
return ['error_code' => 'aksrt_pay_unavailable', 'message' => __('支付网关不可用。', 'aksrt')];
}
$gateway = Gateway::get_instance()->get_gateway(sanitize_key($payment_method));
if (!$gateway) {
return ['error_code' => 'aksrt_gateway_unavailable', 'message' => __('当前支付方式不可用。', 'aksrt')];
}
if (!$gateway->is_configured()) {
return ['error_code' => 'aksrt_gateway_unconfigured', 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
}
return ['gateway' => $gateway];
}
/**
* Compensate a synchronous deduction that could not be committed.
*/
private static function rollback(GatewayInterface $gateway, string $order_number, float $amount, string $method): bool {
if ($amount <= 0) {
return true;
}
$request_id = 'PAY_ROLLBACK_' . substr(hash('sha256', $order_number), 0, 24);
try {
$compensation = $gateway->refund($order_number, $amount, $request_id);
} catch (\Throwable $e) {
$compensation = ['success' => false, 'message' => $e->getMessage()];
}
if (!empty($compensation['success'])) {
return true;
}
error_log(sprintf(
'[AKSRT Payment] rollback failed order=%s gateway=%s: %s',
$order_number,
$method,
(string) ($compensation['message'] ?? 'unknown error')
));
return false;
}
private static function failure_code(string $method, string $message): string {
if ($method === 'balance' && (str_contains($message, '余额') || str_contains($message, '不足'))) {
return 'aksrt_balance_insufficient';
}
return 'aksrt_pay_failed';
}
/**
* @return array{success:false,code:string,message:string}
*/
private static function fail(string $code, string $message): array {
return ['success' => false, 'code' => $code, 'message' => $message];
}
}
+22 -74
View File
@@ -328,88 +328,36 @@ class UserOrders {
return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
}
$lock = class_exists(PaymentAttemptLock::class)
? PaymentAttemptLock::acquire($order_number)
: false;
if ($lock === false) {
return ['success' => false, 'message' => __('该订单正在发起支付,请稍后重试。', 'aksrt')];
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
$order['payment_method'] = $payment_method;
$payment_result = PaymentOrchestrator::pay($gateway, $order, [
'paid_note' => __('用户在前台完成支付。', 'aksrt'),
]);
if (empty($payment_result['success'])) {
return ['success' => false, 'message' => $payment_result['message']];
}
try {
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
$needs_attempt = !in_array($payment_method, ['balance', 'points'], true);
if ($needs_attempt) {
$attempt = $repo->allocate_payment_attempt($order_number);
if ($attempt === null) {
return ['success' => false, 'message' => __('无法生成支付单号,请稍后重试。', 'aksrt')];
}
$order = $repo->get_user_order($user_id, $order_number) ?: $order;
$order['out_trade_no'] = (string) $attempt['out_trade_no'];
if (!empty($attempt['previous'])
&& $attempt['previous'] !== $attempt['out_trade_no']
&& method_exists($gateway, 'close_trade')) {
try {
$gateway->close_trade((string) $attempt['previous']);
} catch (\Throwable $e) {
// best-effort
}
}
}
$payment_result = $gateway->create_payment($order);
if (empty($payment_result['success'])) {
return ['success' => false, 'message' => $payment_result['message'] ?? __('支付失败。', 'aksrt')];
}
$code_url = $payment_result['payment_data']['code_url'] ?? '';
if (!empty($payment_result['payment_url']) || $code_url !== '') {
return [
'success' => true,
'data' => [
'redirect' => true,
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) $code_url,
'order_number' => $order_number,
'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'),
],
];
}
$paid = $repo->update_status($order_number, Order::STATUS_PAID, [
'payment_method' => $payment_method,
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
'note' => __('用户在前台完成支付。', 'aksrt'),
'require_transition' => true,
]);
if (!$paid) {
$compensation = $gateway->refund(
$order_number,
(float) ($order['amount'] ?? 0),
'PAY_ROLLBACK_' . substr(hash('sha256', $order_number), 0, 24)
);
if (empty($compensation['success'])) {
error_log(sprintf(
'[AKSRT Payment] rollback failed order=%s gateway=%s: %s',
$order_number,
$payment_method,
(string) ($compensation['message'] ?? 'unknown error')
));
return ['success' => false, 'message' => __('订单状态已变化,扣款补偿失败,请联系管理员处理。', 'aksrt')];
}
return ['success' => false, 'message' => __('订单状态已变化,扣款已退回。', 'aksrt')];
}
if (!empty($payment_result['redirect'])) {
return [
'success' => true,
'data' => [
'order' => $this->get_order_detail_view($user_id, $order_number),
'message' => $payment_result['message'] ?? __('支付成功。', 'aksrt'),
'redirect' => true,
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
'order_number' => $order_number,
'message' => $payment_result['message'],
],
];
} finally {
PaymentAttemptLock::release($order_number, $lock);
}
return [
'success' => true,
'data' => [
'order' => $this->get_order_detail_view($user_id, $order_number),
'message' => $payment_result['message'],
],
];
}
private function empty_stats(): array {
+24 -68
View File
@@ -210,100 +210,56 @@ class CheckoutService {
return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
}
$lock = class_exists('\AKSRT\Payment\PaymentAttemptLock')
? \AKSRT\Payment\PaymentAttemptLock::acquire($order_number)
: false;
if ($lock === false) {
return ['success' => false, 'message' => __('该订单正在发起支付,请稍后重试。', 'aksrt')];
}
try {
// 携带订单号与摘要,供网关生成支付单。
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
// Points payment: synchronous deduction via \AKSRT\Credits\Credits
// Points payment is a purely internal settlement (no provider attempt),
// but still needs the attempt lock against double-click double deduction.
if ($payment_method === 'points') {
return $this->pay_with_points($user_id, $order_number, $order);
}
// External gateways: allocate a fresh out_trade_no so re-pay works when
// the provider rejects "same order number with changed params".
$needs_attempt = !in_array($payment_method, ['balance', 'points'], true);
if ($needs_attempt && class_exists('\AKSRT\Payment\OrderRepository')) {
$attempt = $repo->allocate_payment_attempt($order_number);
if ($attempt === null) {
return ['success' => false, 'message' => __('无法生成支付单号,请稍后重试。', 'aksrt')];
$lock = class_exists('\AKSRT\Payment\PaymentAttemptLock')
? \AKSRT\Payment\PaymentAttemptLock::acquire($order_number)
: false;
if ($lock === false) {
return ['success' => false, 'message' => __('该订单正在发起支付,请稍后重试。', 'aksrt')];
}
$order = $repo->get_user_order($user_id, $order_number) ?: $order;
$order['out_trade_no'] = (string) $attempt['out_trade_no'];
// Close previous provider trade when possible (WeChat/Alipay) so the new attempt is clean.
if (!empty($attempt['previous'])
&& $attempt['previous'] !== $attempt['out_trade_no']
&& method_exists($gateway, 'close_trade')) {
try {
$gateway->close_trade((string) $attempt['previous']);
} catch (\Throwable $e) {
// best-effort
}
try {
return $this->pay_with_points($user_id, $order_number, $order);
} finally {
\AKSRT\Payment\PaymentAttemptLock::release($order_number, $lock);
}
}
$payment_result = $gateway->create_payment($order);
// 携带订单号与摘要,供网关生成支付单。
// PaymentOrchestrator 内部持有 attempt 锁并分配 provider out_trade_no。
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
$order['payment_method'] = $payment_method;
$payment_result = \AKSRT\Payment\PaymentOrchestrator::pay($gateway, $order, [
'paid_note' => __('用户在前台完成支付。', 'aksrt'),
]);
if (empty($payment_result['success'])) {
return ['success' => false, 'message' => $payment_result['message'] ?? __('支付失败。', 'aksrt')];
return ['success' => false, 'message' => $payment_result['message']];
}
// 跳转/扫码型网关(支付宝/微信/PayPal/Stripe):返回支付凭据,订单保持待支付,
// 由 /payment/{gateway}/notify|return 回调确认到账;此处绝不预先标记已支付。
$code_url = $payment_result['payment_data']['code_url'] ?? '';
if (!empty($payment_result['payment_url']) || $code_url !== '') {
if (!empty($payment_result['redirect'])) {
return [
'success' => true,
'data' => [
'redirect' => true,
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) $code_url,
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
'order_number' => $order_number,
'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'),
'message' => $payment_result['message'],
],
];
}
// 同步型网关(站内余额):create_payment 已实际扣款,标记已支付(触发 aksrt_order_paid 履约)。
$paid = $repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
'payment_method' => $payment_method,
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
'note' => __('用户在前台完成支付。', 'aksrt'),
'require_transition' => true,
]);
if (!$paid) {
$compensation = $gateway->refund(
$order_number,
(float) ($order['amount'] ?? 0),
'PAY_ROLLBACK_' . substr(hash('sha256', $order_number), 0, 24)
);
if (empty($compensation['success'])) {
error_log(sprintf(
'[AKSRT Payment] rollback failed order=%s gateway=%s: %s',
$order_number,
$payment_method,
(string) ($compensation['message'] ?? 'unknown error')
));
return ['success' => false, 'message' => __('订单状态已变化,扣款补偿失败,请联系管理员处理。', 'aksrt')];
}
return ['success' => false, 'message' => __('订单状态已变化,扣款已退回。', 'aksrt')];
}
return [
'success' => true,
'data' => [
'order' => $this->shop->get_order_detail_view($user_id, $order_number),
'message' => $payment_result['message'] ?? __('支付成功。', 'aksrt'),
'message' => $payment_result['message'],
],
];
} finally {
\AKSRT\Payment\PaymentAttemptLock::release($order_number, $lock);
}
}
private function pay_with_points(int $user_id, string $order_number, array $order): array {