Files
wordpress-theme-aksrt/src/Payment/Gateway.php
T

416 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
/**
* 支付网关管理器(注册/回调路由/到账)。
*/
class Gateway {
private static $instance = null;
private $gateways = [];
public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self();
return self::$instance;
}
/**
* 注册网关
*/
public function register(string $id, GatewayInterface $gateway): void {
$this->gateways[$id] = $gateway;
}
/**
* 获取网关
*/
public function get_gateway(string $id): ?GatewayInterface {
return $this->gateways[$id] ?? null;
}
/**
* 获取所有已注册网关
*
* @return GatewayInterface[]
*/
public function get_all(): array {
return $this->gateways;
}
/**
* 获取可用网关列表(已配置的)
*/
public function get_available(): array {
$available = [];
foreach ($this->gateways as $id => $gateway) {
if ($gateway->is_configured()) {
$available[$id] = $gateway;
}
}
return $available;
}
/**
* 获取网关状态摘要(用于后台展示)
*/
public function get_status_summary(): array {
$summary = [];
foreach ($this->gateways as $id => $gateway) {
$summary[] = [
'id' => $id,
'name' => $gateway->get_name(),
'configured' => $gateway->is_configured(),
'available' => $gateway->is_configured(),
];
}
return $summary;
}
/**
* 绑定支付回调路由。必须在 functions.php 中初始化。
*
* 此前网关 create_payment() 把 notify_url/return_url 指向
* /wp-json/aksrt/v1/payment/{gateway}/{notify|return|cancel},但这些路由
* 从未注册,导致所有跳转型网关(支付宝/微信/PayPal/Stripe)无法完成支付。
*/
public function init(): void {
add_action('rest_api_init', [$this, 'register_routes']);
}
public function register_routes(): void {
$args = [
'methods' => ['GET', 'POST'],
'permission_callback' => '__return_true', // 真实性由各网关签名校验保证
'args' => [
'gateway' => ['sanitize_callback' => 'sanitize_key'],
],
];
register_rest_route('aksrt/v1', '/payment/(?P<gateway>[a-z0-9_]+)/notify', array_merge($args, [
'callback' => [$this, 'handle_notify'],
]));
register_rest_route('aksrt/v1', '/payment/(?P<gateway>[a-z0-9_]+)/return', array_merge($args, [
'callback' => [$this, 'handle_return'],
]));
register_rest_route('aksrt/v1', '/payment/(?P<gateway>[a-z0-9_]+)/cancel', array_merge($args, [
'callback' => [$this, 'handle_cancel'],
]));
}
/**
* 异步通知(服务器到服务器)。验签 → 到账 → 输出网关期望的 ACK。
*/
public function handle_notify(\WP_REST_Request $request) {
$gateway_id = sanitize_key((string) $request->get_param('gateway'));
$gateway = $this->get_gateway($gateway_id);
if (!$gateway) {
return new \WP_REST_Response('gateway not found', 404);
}
$verify = $gateway->verify_callback($request);
$verified = !empty($verify['success']);
$paid = $verified && ($verify['status'] ?? '') === 'paid';
$processed = $verified;
if ($paid) {
// Only ACK paid notifications after the order was actually synchronized.
$processed = $this->complete_order($gateway_id, $verify);
}
// 网关各自要求的 ACK 格式
if ($gateway_id === 'wechatpay' && method_exists($gateway, 'output_callback_response')) {
$gateway->output_callback_response(['success' => $processed]); // exit
}
if ($gateway_id === 'alipay' || str_starts_with($gateway_id, 'epay')) {
// 支付宝 / 易支付异步通知要求返回纯文本 success,否则会重复通知
if (method_exists($gateway, 'output_callback_response')) {
$gateway->output_callback_response(['success' => $processed]); // exit
}
header('Content-Type: text/plain; charset=utf-8');
echo $processed ? 'success' : 'fail';
exit;
}
// Stripe / PayPal webhook:200 表示已接收;处理失败返回 400 触发重试
return new \WP_REST_Response(['received' => $processed], $processed ? 200 : 400);
}
/**
* 用户浏览器同步返回。尽力确认到账(兜底异步通知延迟),再跳回订单中心。
*/
public function handle_return(\WP_REST_Request $request) {
$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' || str_starts_with($gateway_id, 'epay')) {
// 支付宝 / 易支付同步返回带签名参数,可直接验签作为到账兜底
$verify = $gateway->verify_callback($request);
if (!empty($verify['success'])) {
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 !== '') {
// 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') {
if (empty($query['order_number'])) {
$query['order_number'] = $order_number;
}
if ($this->complete_order($gateway_id, $query)) {
$status = 'success';
}
}
}
}
} elseif ($gateway_id === 'paypal' && method_exists($gateway, 'capture_order')) {
// PayPal v2:用户批准后在 return 时捕获(token = PayPal 订单号)
$paypal_order_id = sanitize_text_field((string) ($request->get_param('token') ?: ''));
if ($paypal_order_id !== '') {
$cap = $gateway->capture_order($paypal_order_id);
if (!empty($cap['success']) && ($cap['status'] ?? '') === 'paid') {
$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');
return null;
}
$order_number = $captured_order;
if ($this->complete_order($gateway_id, $cap)) {
$status = 'success';
}
}
}
} elseif ($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;
}
if ($this->complete_order($gateway_id, $q)) {
$status = 'success';
}
}
}
}
$this->redirect_to_orders($order_number, $status);
return null;
}
/**
* 用户取消支付,跳回订单中心。
*/
public function handle_cancel(\WP_REST_Request $request) {
$provider_ref = sanitize_text_field((string) ($request->get_param('order')
?: $request->get_param('out_trade_no')
?: ''));
$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));
}
/**
* Whether a pending order can be reconciled against an external provider.
*
* This is the single gate used by the order view and the actual reconcile
* operation, so ordinary detail reads never trigger provider I/O.
*
* @param array<string,mixed>|null $order
*/
public function needs_reconciliation(?array $order): bool {
return null !== $this->reconciliation_gateway($order);
}
/**
* Reconcile a pending external order against the configured provider.
*
* @param array<string,mixed>|null $order
*/
public function reconcile_order(string $order_number, ?array $order = null): ?array {
if ($order_number === '' || !class_exists('AKSRT\\Payment\\OrderRepository')) {
return $order;
}
$repo = OrderRepository::get_instance();
$order = is_array($order) ? $order : $repo->get($order_number);
$gateway = $this->reconciliation_gateway($order);
if (!$gateway) {
return $order;
}
$gateway_id = sanitize_key((string) ($order['payment_method'] ?? ''));
$throttle_key = 'aksrt_reconcile_' . hash('sha256', $order_number);
if (get_transient($throttle_key)) {
return $order;
}
set_transient($throttle_key, 1, 5);
$lock = PaymentAttemptLock::acquire('reconcile_' . $order_number);
if ($lock === false) {
return $repo->get($order_number) ?: $order;
}
try {
$provider_ref = OrderRepository::provider_out_trade_no($order);
$query = $gateway->query_status($provider_ref);
if (!empty($query['success']) && ($query['status'] ?? '') === 'paid') {
if (empty($query['order_number'])) {
$query['order_number'] = $provider_ref;
}
$this->complete_order($gateway_id, $query);
}
} catch (\Throwable $e) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log(sprintf('[AKSRT Payment] reconcile failed order=%s gateway=%s: %s', $order_number, $gateway_id, $e->getMessage()));
}
} finally {
PaymentAttemptLock::release('reconcile_' . $order_number, $lock);
}
return $repo->get($order_number) ?: $order;
}
/**
* @param array<string,mixed>|null $order
*/
private function reconciliation_gateway(?array $order): ?GatewayInterface {
if (!is_array($order) || !in_array((string) ($order['status'] ?? ''), [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) {
return null;
}
$gateway_id = sanitize_key((string) ($order['payment_method'] ?? ''));
if ($gateway_id === '' || in_array($gateway_id, ['balance', 'points', 'manual_review'], true)) {
return null;
}
$gateway = $this->get_gateway($gateway_id);
return $gateway && $gateway->is_configured() ? $gateway : null;
}
/**
* 幂等、带金额校验的订单完成。仅由验签通过的回调调用。
*
* @param string $gateway_id 网关 ID
* @param array $verify 网关 verify_callback / query 结果
* @return bool
*/
public function complete_order(string $gateway_id, array $verify): bool {
$provider_out_trade_no = sanitize_text_field((string) ($verify['order_number'] ?? ''));
if ($provider_out_trade_no === '' || !class_exists('AKSRT\\Payment\\OrderRepository')) {
return false;
}
$repo = OrderRepository::get_instance();
// Callbacks may carry attempt-suffixed out_trade_no (…P2); map back to local order.
$order = $repo->resolve_by_out_trade_no($provider_out_trade_no);
if (!$order) {
return false;
}
$order_number = sanitize_text_field((string) ($order['order_number'] ?? ''));
if ($order_number === '') {
return false;
}
$stored_gateway = sanitize_key((string) ($order['payment_method'] ?? ''));
if ($stored_gateway === '' || !hash_equals($stored_gateway, sanitize_key($gateway_id))) {
return false;
}
// Provider-confirmed external payments must include an exact amount and transaction ID.
$expected_currency = strtoupper((string) ($order['currency'] ?? ''));
$paid_currency = strtoupper(sanitize_key((string) ($verify['currency'] ?? '')));
$expected = (float) ($order['amount'] ?? 0);
$paid_amount = (float) ($verify['amount'] ?? 0);
$transaction_id = sanitize_text_field((string) ($verify['transaction_id'] ?? ''));
if ($expected <= 0
|| $paid_amount <= 0
|| $transaction_id === ''
|| $expected_currency === ''
|| $paid_currency === ''
|| !hash_equals($expected_currency, $paid_currency)
|| abs($expected - $paid_amount) > 0.01) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log(sprintf(
'[AKSRT Payment] invalid completion order=%s out_trade_no=%s expected=%.2f %s paid=%.2f %s transaction=%s gateway=%s',
$order_number,
$provider_out_trade_no,
$expected,
$expected_currency,
$paid_amount,
$paid_currency,
$transaction_id !== '' ? 'present' : 'missing',
$gateway_id
));
}
return false;
}
$current = (string) ($order['status'] ?? '');
// 幂等:仅同一交易号的重复回调可视为成功。不同交易号意味着
// 用户可能重复付款,不能静默 ACK 为已正确同步。
if (in_array($current, ['paid', 'delivered', 'completed'], 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)) {
return false;
}
return $repo->update_status($order_number, Order::STATUS_PAID, [
'payment_method' => $gateway_id,
'payment_transaction_id' => $transaction_id,
'note' => __('支付网关回调确认到账。', 'aksrt'),
]);
}
private function redirect_to_orders(string $order_number, string $status): void {
$url = add_query_arg(array_filter([
'payment' => sanitize_key($status),
'order' => $order_number,
]), $this->orders_page_url());
nocache_headers();
wp_safe_redirect($url);
exit;
}
private function orders_page_url(): string {
return function_exists('aksrt_get_user_center_tab_url')
? aksrt_get_user_center_tab_url('orders')
: home_url('/user/orders/');
}
}