$order Order row plus subject/amount overrides. * @param array $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]; } }