196 lines
7.8 KiB
PHP
196 lines
7.8 KiB
PHP
<?php
|
|
/**
|
|
* Single execution path for "create a payment attempt for an existing order".
|
|
*
|
|
* Every cashier (shop / content / VIP / 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 ($stored_status !== Order::STATUS_PENDING) {
|
|
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.
|
|
}
|
|
}
|
|
}
|
|
|
|
$order['_ledger_paid_note'] = (string) ($opts['paid_note'] ?? __('用户完成支付。', 'aksrt'));
|
|
$result = $gateway->create_payment($order);
|
|
if (empty($result['success'])) {
|
|
$message = (string) ($result['message'] ?? __('支付失败。', 'aksrt'));
|
|
$code = sanitize_key((string) ($result['code'] ?? ''));
|
|
$status = (int) ($result['status'] ?? 0);
|
|
return self::fail(
|
|
$code !== '' ? $code : self::failure_code($method, $message),
|
|
$message,
|
|
$status >= 400 && $status <= 599 ? $status : 400
|
|
);
|
|
}
|
|
|
|
$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 asset gateways commit the debit and paid transition together.
|
|
$transaction_id = sanitize_text_field((string) ($result['transaction_id'] ?? ''));
|
|
if (!empty($result['order_committed'])) {
|
|
return [
|
|
'success' => true,
|
|
'redirect' => false,
|
|
'transaction_id' => $transaction_id,
|
|
'ledger_transaction_id' => (int) ($result['ledger_transaction_id'] ?? 0),
|
|
'flow_number' => (string) ($result['flow_number'] ?? ''),
|
|
'replayed' => !empty($result['replayed']),
|
|
'message' => (string) ($result['message'] ?? __('支付成功。', 'aksrt')),
|
|
];
|
|
}
|
|
$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) {
|
|
return self::fail('aksrt_order_confirm_failed', __('订单状态确认失败。', '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];
|
|
}
|
|
|
|
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, int $status = 400): array {
|
|
return ['success' => false, 'code' => $code, 'status' => $status, 'message' => $message];
|
|
}
|
|
}
|