From 089421cd3e0c00cb8893050647770baf5e0ef5e0 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 27 Jul 2026 11:29:57 +0800 Subject: [PATCH 1/2] fix(payment): unify and harden payment attempts Co-Authored-By: Claude Fable 5 --- src/AKSRT/Content/PayOrders.php | 121 +++++------ src/AKSRT/Content/VipServicePackages.php | 2 +- src/AKSRT/Content/VipServicePurchase.php | 115 +++++----- src/AKSRT/Credits/BalanceServiceCharge.php | 48 ++--- src/AKSRT/Credits/BalanceServicePoints.php | 43 ++-- src/AKSRT/Forum/PlateFollowPay.php | 62 ++---- src/AKSRT/Forum/TopicPay.php | 63 ++---- src/AKSRT/Payment/BalanceGateway.php | 51 ++++- src/AKSRT/Payment/Gateway.php | 50 +++-- src/AKSRT/Payment/Gateways/AlipayGateway.php | 7 +- src/AKSRT/Payment/Gateways/EpayGateway.php | 6 +- src/AKSRT/Payment/Gateways/PayPalGateway.php | 16 +- src/AKSRT/Payment/Gateways/StripeGateway.php | 139 ++++++++---- .../Payment/Gateways/WechatPayGateway.php | 6 +- src/AKSRT/Payment/OrderRepositoryCore.php | 40 +++- src/AKSRT/Payment/PaymentOrchestrator.php | 199 ++++++++++++++++++ src/AKSRT/Payment/UserOrders.php | 96 ++------- src/AKSRT/Shop/CheckoutService.php | 92 +++----- 18 files changed, 680 insertions(+), 476 deletions(-) create mode 100644 src/AKSRT/Payment/PaymentOrchestrator.php diff --git a/src/AKSRT/Content/PayOrders.php b/src/AKSRT/Content/PayOrders.php index c673cae0..84886113 100644 --- a/src/AKSRT/Content/PayOrders.php +++ b/src/AKSRT/Content/PayOrders.php @@ -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, ], ]; diff --git a/src/AKSRT/Content/VipServicePackages.php b/src/AKSRT/Content/VipServicePackages.php index 012f9000..3894ed55 100644 --- a/src/AKSRT/Content/VipServicePackages.php +++ b/src/AKSRT/Content/VipServicePackages.php @@ -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; diff --git a/src/AKSRT/Content/VipServicePurchase.php b/src/AKSRT/Content/VipServicePurchase.php index 6de268ff..fb097758 100644 --- a/src/AKSRT/Content/VipServicePurchase.php +++ b/src/AKSRT/Content/VipServicePurchase.php @@ -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 [ diff --git a/src/AKSRT/Credits/BalanceServiceCharge.php b/src/AKSRT/Credits/BalanceServiceCharge.php index 33d5930f..3eaf123b 100644 --- a/src/AKSRT/Credits/BalanceServiceCharge.php +++ b/src/AKSRT/Credits/BalanceServiceCharge.php @@ -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, diff --git a/src/AKSRT/Credits/BalanceServicePoints.php b/src/AKSRT/Credits/BalanceServicePoints.php index f0438c1b..35f70d1f 100644 --- a/src/AKSRT/Credits/BalanceServicePoints.php +++ b/src/AKSRT/Credits/BalanceServicePoints.php @@ -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, diff --git a/src/AKSRT/Forum/PlateFollowPay.php b/src/AKSRT/Forum/PlateFollowPay.php index 0066d0a0..05724a0e 100644 --- a/src/AKSRT/Forum/PlateFollowPay.php +++ b/src/AKSRT/Forum/PlateFollowPay.php @@ -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, ], ]; diff --git a/src/AKSRT/Forum/TopicPay.php b/src/AKSRT/Forum/TopicPay.php index bcc12416..60c68879 100644 --- a/src/AKSRT/Forum/TopicPay.php +++ b/src/AKSRT/Forum/TopicPay.php @@ -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, ], ]; diff --git a/src/AKSRT/Payment/BalanceGateway.php b/src/AKSRT/Payment/BalanceGateway.php index 93663911..01c09067 100644 --- a/src/AKSRT/Payment/BalanceGateway.php +++ b/src/AKSRT/Payment/BalanceGateway.php @@ -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')]; } } diff --git a/src/AKSRT/Payment/Gateway.php b/src/AKSRT/Payment/Gateway.php index dd741ec5..bba3cb0a 100644 --- a/src/AKSRT/Payment/Gateway.php +++ b/src/AKSRT/Payment/Gateway.php @@ -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)); + } + /** * 幂等、带金额校验的订单完成。仅由验签通过的回调调用。 * diff --git a/src/AKSRT/Payment/Gateways/AlipayGateway.php b/src/AKSRT/Payment/Gateways/AlipayGateway.php index 1246e15d..d5d092bf 100644 --- a/src/AKSRT/Payment/Gateways/AlipayGateway.php +++ b/src/AKSRT/Payment/Gateways/AlipayGateway.php @@ -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); } diff --git a/src/AKSRT/Payment/Gateways/EpayGateway.php b/src/AKSRT/Payment/Gateways/EpayGateway.php index 4e40cedf..84b437ad 100644 --- a/src/AKSRT/Payment/Gateways/EpayGateway.php +++ b/src/AKSRT/Payment/Gateways/EpayGateway.php @@ -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); } diff --git a/src/AKSRT/Payment/Gateways/PayPalGateway.php b/src/AKSRT/Payment/Gateways/PayPalGateway.php index 46daf641..2d7814ed 100644 --- a/src/AKSRT/Payment/Gateways/PayPalGateway.php +++ b/src/AKSRT/Payment/Gateways/PayPalGateway.php @@ -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'), diff --git a/src/AKSRT/Payment/Gateways/StripeGateway.php b/src/AKSRT/Payment/Gateways/StripeGateway.php index 3208153a..d84c30e2 100644 --- a/src/AKSRT/Payment/Gateways/StripeGateway.php +++ b/src/AKSRT/Payment/Gateways/StripeGateway.php @@ -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 $order + * @return array + */ + 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 []; diff --git a/src/AKSRT/Payment/Gateways/WechatPayGateway.php b/src/AKSRT/Payment/Gateways/WechatPayGateway.php index 0a2ef90d..94903a81 100644 --- a/src/AKSRT/Payment/Gateways/WechatPayGateway.php +++ b/src/AKSRT/Payment/Gateways/WechatPayGateway.php @@ -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); } diff --git a/src/AKSRT/Payment/OrderRepositoryCore.php b/src/AKSRT/Payment/OrderRepositoryCore.php index c5493805..74c93d61 100644 --- a/src/AKSRT/Payment/OrderRepositoryCore.php +++ b/src/AKSRT/Payment/OrderRepositoryCore.php @@ -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; diff --git a/src/AKSRT/Payment/PaymentOrchestrator.php b/src/AKSRT/Payment/PaymentOrchestrator.php new file mode 100644 index 00000000..f5eb9bb4 --- /dev/null +++ b/src/AKSRT/Payment/PaymentOrchestrator.php @@ -0,0 +1,199 @@ + $order Order row plus subject/amount overrides. + * @param array $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]; + } +} diff --git a/src/AKSRT/Payment/UserOrders.php b/src/AKSRT/Payment/UserOrders.php index 83f4911b..9191dba9 100644 --- a/src/AKSRT/Payment/UserOrders.php +++ b/src/AKSRT/Payment/UserOrders.php @@ -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 { diff --git a/src/AKSRT/Shop/CheckoutService.php b/src/AKSRT/Shop/CheckoutService.php index 13837b9f..ced3fe94 100644 --- a/src/AKSRT/Shop/CheckoutService.php +++ b/src/AKSRT/Shop/CheckoutService.php @@ -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 { From abc3f1565fe18d2b5c18bd9a257f7b292584d983 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 27 Jul 2026 11:31:39 +0800 Subject: [PATCH 2/2] fix(payment): reject duplicate provider transactions Co-Authored-By: Claude Fable 5 --- src/AKSRT/Payment/Gateway.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/AKSRT/Payment/Gateway.php b/src/AKSRT/Payment/Gateway.php index bba3cb0a..721d2ca3 100644 --- a/src/AKSRT/Payment/Gateway.php +++ b/src/AKSRT/Payment/Gateway.php @@ -296,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)) {