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:
@@ -0,0 +1,406 @@
|
||||
<?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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
if (!$order || !in_array((string) ($order['status'] ?? ''), [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) {
|
||||
return $order;
|
||||
}
|
||||
|
||||
$gateway_id = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||
if ($gateway_id === '' || in_array($gateway_id, ['balance', 'points', 'manual_review'], true)) {
|
||||
return $order;
|
||||
}
|
||||
$gateway = $this->get_gateway($gateway_id);
|
||||
if (!$gateway || !$gateway->is_configured()) {
|
||||
return $order;
|
||||
}
|
||||
|
||||
$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 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 {
|
||||
$pages = get_posts([
|
||||
'post_type' => 'page',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => 1,
|
||||
'meta_key' => '_wp_page_template',
|
||||
'meta_value' => 'templates/pages/page-orders.php',
|
||||
'fields' => 'ids',
|
||||
'no_found_rows' => true,
|
||||
]);
|
||||
|
||||
if (!empty($pages)) {
|
||||
return (string) get_permalink((int) $pages[0]);
|
||||
}
|
||||
|
||||
return home_url('/orders/');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user