Files
wordpress-theme-aksrt/src/Payment/Gateway.php
T
kate522 d2447f8aa6 fix(pay): 修复后台收款接口显示顺序不可用并接入收银台排序
后台「收款接口 → 收款接口显示顺序」有三处缺陷叠加:
1) sortable 渲染组件读取 item.label,而 schema 写的是 title,导致 6 行
   只剩上下箭头;
2) SchemaProvider 的 sortable 分支遍历 items 定义重建数组,而该字段的
   键顺序本身就是配置值,用户排序被静默改回 schema 静态顺序;
3) 真正构建收银台支付方式列表的 aksrt_list_cash_payment_methods() 使用
   硬编码顺序,Gateway::get_available()/apply_sdk_order() 那套排序逻辑
   无任何调用方,配置对前台毫无影响。

改为:schema 项统一使用 label(JS 与 PHP 两处对齐);sortable 持久化按
提交键顺序重建,items 定义仅作白名单;新增唯一排序实现
aksrt_sort_payment_methods()(语义对齐参考主题 zibpay_get_payment_methods
+ pay_sdk_order 键顺序),并接入内容收银台、余额充值、购买积分与会员
购买四个入口。卡密输入盒改为按渠道存在与否判断,不再假定卡密固定在末位。

清理死代码:无调用方的 Gateway::get_available/apply_sdk_order/
get_status_summary、VipServicePackages 中不可达的支付方式列表回退分支、
零引用的 settings-defaults 模块及 schema/index.js 未使用的导出、支付
sortable 的 fields 死元数据、无拖拽实现却声明的 cursor:move。

未配置 pay_sdk_order 时顺序保持不变;未写数据迁移。

验证:php -l 8 个改动文件全部通过;新增 pay-sdk-order-contract 契约测试
(6 项);node --test tests/widget-system/*.test.cjs 301 项全部通过;
后台 SPA 已重新构建。
2026-09-18 19:01:19 +08:00

401 lines
16 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;
}
/**
* 绑定支付回调路由。必须在 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), Order::STATUS_CLOSED);
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) || (string) ($order['status'] ?? '') !== Order::STATUS_PENDING) {
return null;
}
$gateway_id = sanitize_key((string) ($order['payment_method'] ?? ''));
if ($gateway_id === '' || in_array($gateway_id, ['balance', 'points'], 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'] ?? '')));
try {
$expected = \AKSRT\Ledger\AssetAmount::balance(is_string($order['amount'] ?? null) ? $order['amount'] : '');
$paid_amount = \AKSRT\Ledger\AssetAmount::balance(is_string($verify['amount'] ?? null) ? $verify['amount'] : '');
} catch (\InvalidArgumentException $e) {
return false;
}
$transaction_id = sanitize_text_field((string) ($verify['transaction_id'] ?? ''));
if ($expected->minor() <= 0
|| $paid_amount->minor() <= 0
|| $transaction_id === ''
|| $expected_currency === ''
|| $paid_currency === ''
|| !hash_equals($expected_currency, $paid_currency)
|| $expected->minor() !== $paid_amount->minor()) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log(sprintf(
'[AKSRT Payment] invalid completion order=%s out_trade_no=%s expected=%s %s paid=%s %s transaction=%s gateway=%s',
$order_number,
$provider_out_trade_no,
$expected->decimal(),
$expected_currency,
$paid_amount->decimal(),
$paid_currency,
$transaction_id !== '' ? 'present' : 'missing',
$gateway_id
));
}
return false;
}
$current = (string) ($order['status'] ?? '');
// 幂等:仅同一交易号的重复回调可视为成功。不同交易号意味着
// 用户可能重复付款,不能静默 ACK 为已正确同步。
if ($current === Order::STATUS_PAID) {
$stored_transaction = sanitize_text_field((string) ($order['payment_transaction_id'] ?? ''));
if (sanitize_key((string) ($order['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
if ($stored_transaction !== '' && !hash_equals($stored_transaction, $transaction_id)) {
return false;
}
return $repo->update_status($order_number, Order::STATUS_PAID, [
'payment_method' => $gateway_id,
'payment_transaction_id' => $transaction_id,
'note' => __('支付网关回调补充确认到账。', 'aksrt'),
]);
}
return $stored_transaction !== '' && hash_equals($stored_transaction, $transaction_id);
}
// 只允许从未支付态完成
if ($current !== Order::STATUS_PENDING) {
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/');
}
}