refactor: flatten src/AKSRT → src (drop theme-identifier dir)
Move all domain folders from src/AKSRT/{Domain} to src/{Domain}; remap
Composer PSR-4 "AKSRT\\": "src/"; update audit script + README + ADR
paths; remove dead tools/build-psr4-map.php (scanned deleted inc/classes).
This commit is contained in:
1 parent
56f1a8175f
commit
8c5b1555c9
285 files changed
+26
-3600
No files matched your search
@@ -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];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user