Files
wordpress-theme-aksrt/src/Payment/PaymentOrchestrator.php
T
2026-08-05 02:04:55 +08:00

209 lines
8.1 KiB
PHP

<?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, order_context: array
*
* @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.
// The caller may pass request-local create context so metadata such as
// subject is not reconstructed or read a second time. The persisted row
// remains authoritative for status, ownership, and payment method.
$stored = $repo->get($order_number);
if (is_array($opts['order_context'] ?? null)) {
$context = $opts['order_context'];
if ((string) ($context['order_number'] ?? '') === $order_number && is_array($stored)) {
$stored = array_merge($context, $stored);
}
}
$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];
}
}