fix(payment): harden payment and refund chains

This commit is contained in:
2026-07-26 02:29:46 +08:00
parent c70fbe9835
commit 53ff99f343
24 changed files with 993 additions and 251 deletions
@@ -203,7 +203,7 @@ export const oauthSchema = {
icon: 'fab fa-alipay', icon: 'fab fa-alipay',
fields: [ fields: [
{ key: 'alipay_appid', label: 'AppID', type: 'input', default: '' }, { key: 'alipay_appid', label: 'AppID', type: 'input', default: '' },
{ key: 'alipay_privatekey', label: '应用私钥', type: 'input', default: '' }, { key: 'alipay_privatekey', label: '应用私钥', type: 'textarea', rows: 3, default: '' },
{ key: 'alipay_publickey', label: '支付宝公钥', type: 'input', default: '' }, { key: 'alipay_publickey', label: '支付宝公钥', type: 'input', default: '' },
], ],
}, },
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "AKSRT WordPress Theme", "description": "AKSRT WordPress Theme",
"type": "wordpress-theme", "type": "wordpress-theme",
"require": { "require": {
"php": ">=7.4", "php": ">=8.1",
"stripe/stripe-php": "^16.1", "stripe/stripe-php": "^16.1",
"yansongda/pay": "^3.7", "yansongda/pay": "^3.7",
"yurunsoft/yurun-oauth-login": "~3.0" "yurunsoft/yurun-oauth-login": "~3.0"
Generated
+2 -2
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "fe1258c91a142e623f464f5ddaef58f9", "content-hash": "7b529608bb9672db0327c4448e99220a",
"packages": [ "packages": [
{ {
"name": "guzzlehttp/psr7", "name": "guzzlehttp/psr7",
@@ -1002,7 +1002,7 @@
"prefer-stable": false, "prefer-stable": false,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": ">=7.4" "php": ">=8.1"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.9.0" "plugin-api-version": "2.9.0"
+5 -1
View File
@@ -111,7 +111,7 @@ add_filter('query_vars', function ($vars) {
// 主题切换离开时:清理本主题注册的 cron 任务 // 主题切换离开时:清理本主题注册的 cron 任务
add_action('switch_theme', function () { add_action('switch_theme', function () {
$hooks = ['aksrt_dl_token_cleanup', 'aksrt_email_verification_backfill_batch', 'aksrt_notification_cleanup']; $hooks = ['aksrt_dl_token_cleanup', 'aksrt_email_verification_backfill_batch', 'aksrt_notification_cleanup', 'aksrt_reconcile_refunds', 'aksrt_after_sale_auto_cancel'];
foreach ($hooks as $hook) { foreach ($hooks as $hook) {
$next = wp_next_scheduled($hook); $next = wp_next_scheduled($hook);
while ($next) { while ($next) {
@@ -321,6 +321,10 @@ if (class_exists('\AKSRT\Payment\Order')) {
\AKSRT\Payment\Order::get_instance()->init(); \AKSRT\Payment\Order::get_instance()->init();
} }
if (class_exists('\AKSRT\Payment\AfterSale')) {
\AKSRT\Payment\AfterSale::get_instance()->init();
}
// Register built-in payment gateways // Register built-in payment gateways
if (class_exists('\AKSRT\Payment\Gateway')) { if (class_exists('\AKSRT\Payment\Gateway')) {
$gw = \AKSRT\Payment\Gateway::get_instance(); $gw = \AKSRT\Payment\Gateway::get_instance();
+1 -7
View File
@@ -41,20 +41,14 @@ class PayAfterSale {
$ok = false; $ok = false;
if ($order_number !== '' && class_exists('\AKSRT\Payment\AfterSale')) { if ($order_number !== '' && class_exists('\AKSRT\Payment\AfterSale')) {
$user_id = isset($_POST['user_id']) ? (int) $_POST['user_id'] : 0;
if ($user_id <= 0 && class_exists('\AKSRT\Payment\OrderRepository')) {
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
$user_id = (int) ($order['user_id'] ?? 0);
}
$id = \AKSRT\Payment\AfterSale::get_instance()->create([ $id = \AKSRT\Payment\AfterSale::get_instance()->create([
'order_number' => $order_number, 'order_number' => $order_number,
'user_id' => $user_id,
'type' => sanitize_key((string) wp_unslash($_POST['type'] ?? 'refund')), 'type' => sanitize_key((string) wp_unslash($_POST['type'] ?? 'refund')),
'reason' => sanitize_textarea_field((string) wp_unslash($_POST['reason'] ?? '')), 'reason' => sanitize_textarea_field((string) wp_unslash($_POST['reason'] ?? '')),
'amount' => (float) ($_POST['amount'] ?? 0), 'amount' => (float) ($_POST['amount'] ?? 0),
'note' => sanitize_textarea_field((string) wp_unslash($_POST['note'] ?? '')), 'note' => sanitize_textarea_field((string) wp_unslash($_POST['note'] ?? '')),
]); ]);
$ok = $id > 0; $ok = !is_wp_error($id) && $id > 0;
} }
$msg = $ok ? 'after_sale_created' : 'after_sale_create_failed'; $msg = $ok ? 'after_sale_created' : 'after_sale_create_failed';
+1 -1
View File
@@ -258,7 +258,7 @@ class SchemaPayment {
]], ]],
['key' => 'alipay', 'title' => '支付宝配置', 'collapsible' => true, 'expandWhen' => 'payment_alipay_enabled', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true], 'fields' => [ ['key' => 'alipay', 'title' => '支付宝配置', 'collapsible' => true, 'expandWhen' => 'payment_alipay_enabled', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true], 'fields' => [
['key' => 'alipay_appid', 'label' => 'AppID', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]], ['key' => 'alipay_appid', 'label' => 'AppID', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
['key' => 'alipay_privatekey', 'label' => '应用私钥', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]], ['key' => 'alipay_privatekey', 'label' => '应用私钥', 'type' => 'textarea', 'sanitize_type' => 'code', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
['key' => 'alipay_payment_type', 'label' => '支付方式', 'type' => 'select', 'default' => 'page', 'options' => [ ['key' => 'alipay_payment_type', 'label' => '支付方式', 'type' => 'select', 'default' => 'page', 'options' => [
['value' => 'page', 'label' => '电脑网站支付(web)'], ['value' => 'page', 'label' => '电脑网站支付(web)'],
['value' => 'wap', 'label' => '手机网站支付(wap)'], ['value' => 'wap', 'label' => '手机网站支付(wap)'],
+1 -1
View File
@@ -318,7 +318,7 @@ class SchemaUsers {
]], ]],
['key' => 'alipay', 'title' => '支付宝配置', 'fields' => [ ['key' => 'alipay', 'title' => '支付宝配置', 'fields' => [
['key' => 'alipay_appid', 'label' => 'AppID', 'type' => 'input', 'default' => ''], ['key' => 'alipay_appid', 'label' => 'AppID', 'type' => 'input', 'default' => ''],
['key' => 'alipay_privatekey', 'label' => '应用私钥', 'type' => 'input', 'default' => ''], ['key' => 'alipay_privatekey', 'label' => '应用私钥', 'type' => 'textarea', 'sanitize_type' => 'code', 'default' => ''],
['key' => 'alipay_publickey', 'label' => '支付宝公钥', 'type' => 'input', 'default' => ''], ['key' => 'alipay_publickey', 'label' => '支付宝公钥', 'type' => 'input', 'default' => ''],
]], ]],
], ],
@@ -82,13 +82,12 @@ trait ShopControllerFulfillmentOperations {
return $this->error(__('订单不存在', 'aksrt'), 404); return $this->error(__('订单不存在', 'aksrt'), 404);
} }
$params['user_id'] = (int) ($params['user_id'] ?? ($order['user_id'] ?? 0)); unset($params['user_id'], $params['applicant_user_id']);
if ($params['user_id'] <= 0) {
return $this->error(__('订单未绑定有效用户', 'aksrt'));
}
$id = \AKSRT\Payment\AfterSale::get_instance()->create($params); $id = \AKSRT\Payment\AfterSale::get_instance()->create($params);
if (is_wp_error($id)) {
return $this->error($id->get_error_message(), 422);
}
if ($id > 0) { if ($id > 0) {
return $this->success([ return $this->success([
'message' => __('售后工单已创建', 'aksrt'), 'message' => __('售后工单已创建', 'aksrt'),
+1 -1
View File
@@ -12,7 +12,7 @@ class DBMigrator {
use DBMigratorPart3; use DBMigratorPart3;
/** Current schema version; changing it re-runs dbDelta. */ /** Current schema version; changing it re-runs dbDelta. */
const SCHEMA_VERSION = '1.15.0'; const SCHEMA_VERSION = '1.16.0';
const OPT_KEY = 'aksrt_db_schema_version'; const OPT_KEY = 'aksrt_db_schema_version';
+21 -7
View File
@@ -337,18 +337,32 @@ trait DBMigratorPart2 {
type VARCHAR(20) NOT NULL DEFAULT 'refund', type VARCHAR(20) NOT NULL DEFAULT 'refund',
reason TEXT NOT NULL, reason TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending', status VARCHAR(20) NOT NULL DEFAULT 'pending',
amount DECIMAL(10,2) NOT NULL DEFAULT 0, amount DECIMAL(10,2) NOT NULL DEFAULT 0,
note TEXT DEFAULT NULL, note TEXT DEFAULT NULL,
operator_id BIGINT UNSIGNED DEFAULT NULL, operator_id BIGINT UNSIGNED DEFAULT NULL,
operator_note TEXT DEFAULT NULL, operator_note TEXT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', refund_request_id VARCHAR(64) DEFAULT NULL,
updated_at DATETIME DEFAULT NULL, provider VARCHAR(32) NOT NULL DEFAULT '',
resolved_at DATETIME DEFAULT NULL, provider_refund_id VARCHAR(128) NOT NULL DEFAULT '',
provider_status VARCHAR(24) NOT NULL DEFAULT 'not_started',
refund_effect_status VARCHAR(24) NOT NULL DEFAULT 'not_started',
refund_error TEXT DEFAULT NULL,
refund_attempts INT UNSIGNED NOT NULL DEFAULT 0,
refund_requested_at DATETIME DEFAULT NULL,
provider_updated_at DATETIME DEFAULT NULL,
refund_succeeded_at DATETIME DEFAULT NULL,
refund_effected_at DATETIME DEFAULT NULL,
order_status_before VARCHAR(20) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
updated_at DATETIME DEFAULT NULL,
resolved_at DATETIME DEFAULT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uniq_refund_request (refund_request_id),
KEY idx_order (order_number), KEY idx_order (order_number),
KEY idx_user (user_id), KEY idx_user (user_id),
KEY idx_status (status), KEY idx_status (status),
KEY idx_type (type), KEY idx_type (type),
KEY idx_provider_status (provider_status),
KEY idx_created (created_at) KEY idx_created (created_at)
) {$charset_collate};"; ) {$charset_collate};";
dbDelta($sql_after_sale); dbDelta($sql_after_sale);
+462 -82
View File
@@ -31,6 +31,17 @@ class AfterSale {
const PROGRESS_WAITING_USER_RECEIVE = 3; const PROGRESS_WAITING_USER_RECEIVE = 3;
const PROGRESS_DONE = 4; const PROGRESS_DONE = 4;
public function init(): void {
add_action('aksrt_reconcile_refunds', [$this, 'reconcile_pending_refunds']);
add_action('aksrt_after_sale_auto_cancel', [$this, 'run_auto_cancel_cron']);
if (!wp_next_scheduled('aksrt_reconcile_refunds')) {
wp_schedule_event(time() + HOUR_IN_SECONDS, 'hourly', 'aksrt_reconcile_refunds');
}
if (!wp_next_scheduled('aksrt_after_sale_auto_cancel')) {
wp_schedule_event(time() + DAY_IN_SECONDS, 'daily', 'aksrt_after_sale_auto_cancel');
}
}
public static function get_instance(): self { public static function get_instance(): self {
if (null === self::$instance) { if (null === self::$instance) {
self::$instance = new self(); self::$instance = new self();
@@ -44,24 +55,89 @@ class AfterSale {
/** /**
* 创建售后工单 * 创建售后工单
*
* @return int|\WP_Error
*/ */
public function create(array $data): int { public function create(array $data) {
global $wpdb; global $wpdb;
$order_number = sanitize_text_field((string) ($data['order_number'] ?? '')); $order_number = sanitize_text_field((string) ($data['order_number'] ?? ''));
$user_id = (int) ($data['user_id'] ?? 0); $applicant_id = (int) ($data['applicant_user_id'] ?? $data['user_id'] ?? 0);
$type = sanitize_key((string) ($data['type'] ?? self::TYPE_REFUND)); $type = sanitize_key((string) ($data['type'] ?? self::TYPE_REFUND));
$reason = sanitize_textarea_field((string) ($data['reason'] ?? '')); $reason = sanitize_textarea_field((string) ($data['reason'] ?? ''));
$amount = (float) ($data['amount'] ?? 0); $amount = round((float) ($data['amount'] ?? 0), 2);
$note = sanitize_textarea_field((string) ($data['note'] ?? '')); $note = sanitize_textarea_field((string) ($data['note'] ?? ''));
if ($order_number === '' || $user_id <= 0 || $reason === '') { if ($order_number === '' || $reason === '') {
return 0; return new \WP_Error('aksrt_after_sale_invalid', __('售后申请参数不完整。', 'aksrt'));
}
if (!array_key_exists($type, self::get_valid_types())) {
return new \WP_Error('aksrt_after_sale_type', __('售后类型无效。', 'aksrt'));
}
if (!class_exists('AKSRT\\Payment\\OrderRepository')) {
return new \WP_Error('aksrt_after_sale_order_service', __('订单服务不可用。', 'aksrt'));
} }
$valid_types = [self::TYPE_REFUND, self::TYPE_REFUND_RETURN, self::TYPE_EXCHANGE, self::TYPE_REPAIR, self::TYPE_WARRANTY, self::TYPE_INSURED_PRICE, self::TYPE_REPLACEMENT]; $order = OrderRepository::get_instance()->get($order_number);
if (!in_array($type, $valid_types, true)) { if (!$order) {
$type = self::TYPE_REFUND; return new \WP_Error('aksrt_after_sale_order', __('订单不存在。', 'aksrt'));
}
$user_id = (int) ($order['user_id'] ?? 0);
if ($user_id <= 0 || ($applicant_id > 0 && $applicant_id !== $user_id)) {
return new \WP_Error('aksrt_after_sale_owner', __('无权为该订单申请售后。', 'aksrt'));
}
if (!$this->can_apply_after_sale($order, $type)) {
return new \WP_Error('aksrt_after_sale_state', __('当前订单状态不支持该售后类型。', 'aksrt'));
}
if (!$this->within_application_window($order, $type)) {
return new \WP_Error('aksrt_after_sale_expired', __('该订单已超过售后申请期限。', 'aksrt'));
}
$refund_type = $this->is_refund_type($type);
if ($refund_type && $amount <= 0) {
return new \WP_Error('aksrt_after_sale_amount', __('退款金额必须大于零。', 'aksrt'));
}
if (!$refund_type) {
$amount = 0.0;
}
$creation_lock = 'aksrt_as_' . substr(hash('sha256', $order_number), 0, 48);
$lock_acquired = (int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $creation_lock));
if ($lock_acquired !== 1) {
return new \WP_Error('aksrt_after_sale_busy', __('该订单正在处理售后申请,请稍后重试。', 'aksrt'));
}
$active_statuses = [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_APPROVED];
$placeholders = implode(', ', array_fill(0, count($active_statuses), '%s'));
$active_sql = "SELECT COUNT(*) FROM {$this->table()} WHERE order_number = %s AND status IN ({$placeholders})";
$active = (int) $wpdb->get_var($wpdb->prepare($active_sql, array_merge([$order_number], $active_statuses)));
if ($active > 0) {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $creation_lock));
return new \WP_Error('aksrt_after_sale_active', __('该订单已有处理中的售后申请。', 'aksrt'));
}
if ($refund_type) {
$reserved_sql = "SELECT COALESCE(SUM(amount), 0) FROM {$this->table()}
WHERE order_number = %s
AND type IN (%s, %s, %s)
AND status IN (%s, %s, %s, %s)";
$reserved = (float) $wpdb->get_var($wpdb->prepare(
$reserved_sql,
$order_number,
self::TYPE_REFUND,
self::TYPE_REFUND_RETURN,
self::TYPE_INSURED_PRICE,
self::STATUS_PENDING,
self::STATUS_PROCESSING,
self::STATUS_APPROVED,
self::STATUS_COMPLETED
));
$remaining = max(0.0, round((float) ($order['amount'] ?? 0) - $reserved, 2));
if ($amount > $remaining) {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $creation_lock));
return new \WP_Error('aksrt_after_sale_excess', __('退款金额超过订单剩余可退金额。', 'aksrt'));
}
} }
$result = $wpdb->insert($this->table(), [ $result = $wpdb->insert($this->table(), [
@@ -70,13 +146,15 @@ class AfterSale {
'type' => $type, 'type' => $type,
'reason' => $reason, 'reason' => $reason,
'status' => self::STATUS_PENDING, 'status' => self::STATUS_PENDING,
'amount' => $amount, 'amount' => $amount,
'note' => $note, 'note' => $note,
'created_at' => current_time('mysql'), 'order_status_before' => sanitize_key((string) ($order['status'] ?? '')),
'created_at' => current_time('mysql'),
]); ]);
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $creation_lock));
if (!$result) { if (!$result) {
return 0; return new \WP_Error('aksrt_after_sale_db', __('售后申请创建失败。', 'aksrt'));
} }
$ticket_id = (int) $wpdb->insert_id; $ticket_id = (int) $wpdb->insert_id;
$this->append_event($ticket_id, 'created', __('用户提交售后申请', 'aksrt'), $user_id); $this->append_event($ticket_id, 'created', __('用户提交售后申请', 'aksrt'), $user_id);
@@ -506,126 +584,241 @@ class AfterSale {
} }
/** /**
* R69: Execute actual refund — credit back user balance / points. * R69: Execute local refund effects and sales rollback once.
*
* @return true|\WP_Error
*/ */
private function execute_refund(?array $order, float $refund_amount): void { private function execute_refund(?array $order, float $refund_amount) {
if (!$order || $refund_amount <= 0) { if (!$order || $refund_amount <= 0) {
return; return new \WP_Error('aksrt_refund_order', __('退款订单或金额无效。', 'aksrt'));
} }
$user_id = (int) ($order['user_id'] ?? 0); $user_id = (int) ($order['user_id'] ?? 0);
if ($user_id <= 0) { if ($user_id <= 0) {
return; return new \WP_Error('aksrt_refund_user', __('退款用户无效。', 'aksrt'));
} }
$payment_method = (string) ($order['payment_method'] ?? 'balance'); $payment_method = (string) ($order['payment_method'] ?? 'balance');
$order_number = (string) ($order['order_number'] ?? ''); $order_number = (string) ($order['order_number'] ?? '');
// Balance refund: prefer cash wallet, fall back to points.
if ($payment_method === 'balance') { if ($payment_method === 'balance') {
if (class_exists('\AKSRT\Credits\Balance') && \AKSRT\Credits\Balance::enabled()) { if (!class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
\AKSRT\Credits\Balance::add( return new \WP_Error('aksrt_refund_balance', __('余额退款服务不可用。', 'aksrt'));
$user_id, }
(float) $refund_amount, $credited = \AKSRT\Credits\Balance::add(
'order_refund', $user_id,
['type' => 'order', 'order' => $order_number], $refund_amount,
sprintf(__('售后退款:订单 %s', 'aksrt'), $order_number) 'order_refund',
); ['type' => 'order', 'order' => $order_number],
} elseif (class_exists('\AKSRT\Credits\Credits')) { sprintf(__('售后退款:订单 %s', 'aksrt'), $order_number)
\AKSRT\Credits\Credits::add( );
$user_id, if (is_wp_error($credited)) {
(int) round($refund_amount), return $credited;
'order_refund', }
['order_number' => $order_number, 'type' => 'refund'], } elseif ($payment_method === 'points') {
sprintf(__('售后退款金额 ¥%s', 'aksrt'), number_format($refund_amount, 2)) if (!class_exists('\AKSRT\Credits\Credits') || floor($refund_amount) !== $refund_amount) {
); return new \WP_Error('aksrt_refund_points', __('积分退款金额无效或积分服务不可用。', 'aksrt'));
}
$credited = \AKSRT\Credits\Credits::add(
$user_id,
(int) $refund_amount,
'order_refund',
['order_number' => $order_number, 'type' => 'refund'],
sprintf(__('售后退回积分:订单 %s', 'aksrt'), $order_number)
);
if (is_wp_error($credited)) {
return $credited;
}
} elseif (!in_array($payment_method, ['wechatpay', 'alipay', 'stripe'], true)) {
return new \WP_Error('aksrt_refund_gateway', __('当前支付方式不支持自动退款。', 'aksrt'));
}
// Full-order refunds roll back sales once after the monetary refund succeeds.
if (abs((float) ($order['amount'] ?? 0) - $refund_amount) <= 0.01) {
$meta = json_decode((string) ($order['note'] ?? ''), true);
$items = is_array($meta) ? ($meta['items'] ?? []) : [];
if (empty($items) && !empty($order['product_id'])) {
$items = [['product_id' => (int) $order['product_id'], 'quantity' => 1]];
}
foreach ($items as $item) {
$pid = (int) ($item['product_id'] ?? 0);
$qty = max(1, (int) ($item['quantity'] ?? 1));
if ($pid <= 0) {
continue;
}
$sales = (int) get_post_meta($pid, '_aksrt_sales_volume', true);
update_post_meta($pid, '_aksrt_sales_volume', max(0, $sales - $qty));
} }
} }
// Deduct sales_volume for refunded items return true;
$meta = json_decode((string) ($order['note'] ?? ''), true); }
$items = is_array($meta) ? ($meta['items'] ?? []) : [];
if (empty($items) && !empty($order['product_id'])) {
$items = [['product_id' => (int) $order['product_id'], 'quantity' => 1]];
}
foreach ($items as $item) {
$pid = (int) ($item['product_id'] ?? 0);
$qty = max(1, (int) ($item['quantity'] ?? 1));
if ($pid <= 0) {
continue;
}
$sales = (int) get_post_meta($pid, '_aksrt_sales_volume', true);
update_post_meta($pid, '_aksrt_sales_volume', max(0, $sales - $qty));
}
/** private function publish_refund_event(array $order, float $refund_amount): void {
* Fires after refund execution. $order_number = (string) ($order['order_number'] ?? '');
* $user_id = (int) ($order['user_id'] ?? 0);
* @param string $order_number $payment_method = (string) ($order['payment_method'] ?? '');
* @param int $user_id
* @param float $refund_amount
* @param string $payment_method
*/
do_action('aksrt_shop_refund_executed', $order_number, $user_id, $refund_amount, $payment_method); do_action('aksrt_shop_refund_executed', $order_number, $user_id, $refund_amount, $payment_method);
} }
/** /**
* Execute an online-channel refund before completing the after-sale ticket. * Execute and persist a refund before completing the after-sale ticket.
*/ */
private function complete_refund(array $record): bool { private function complete_refund(array $record): bool {
global $wpdb;
$type = (string) ($record['type'] ?? ''); $type = (string) ($record['type'] ?? '');
if (!in_array($type, [self::TYPE_REFUND, self::TYPE_REFUND_RETURN, self::TYPE_INSURED_PRICE], true)) { if (!$this->is_refund_type($type)) {
return true; return true;
} }
if (!class_exists('AKSRT\\Payment\\OrderRepository')) { if (!class_exists('AKSRT\\Payment\\OrderRepository')) {
return false; return false;
} }
$ticket_id = (int) ($record['id'] ?? 0);
$order_number = sanitize_text_field((string) ($record['order_number'] ?? '')); $order_number = sanitize_text_field((string) ($record['order_number'] ?? ''));
$order = OrderRepository::get_instance()->get($order_number); $order = OrderRepository::get_instance()->get($order_number);
if (!$order) { $refund_amount = (float) ($record['amount'] ?? 0);
if ($ticket_id <= 0 || !$order || $refund_amount <= 0) {
return false; return false;
} }
$payment_method = (string) ($order['payment_method'] ?? 'balance'); $latest = $this->get_by_id($ticket_id) ?: $record;
if (!in_array($payment_method, ['wechatpay', 'alipay'], true)) { if (($latest['refund_effect_status'] ?? '') === 'succeeded') {
return true; return true;
} }
if (($latest['provider_status'] ?? '') === 'succeeded'
$refund_amount = (float) ($record['amount'] ?? 0); && ($latest['refund_effect_status'] ?? '') === 'processing') {
if ($refund_amount <= 0) {
return false; return false;
} }
$request_id = sprintf( $payment_method = sanitize_key((string) ($order['payment_method'] ?? 'balance'));
'AS_%d_%s', $request_id = (string) ($latest['refund_request_id'] ?? '');
(int) ($record['id'] ?? 0), if ($request_id === '') {
substr(hash('sha256', $order_number), 0, 16) $request_id = sprintf('as_%d_%s', $ticket_id, substr(hash('sha256', $order_number), 0, 16));
); }
return $this->refund_via_gateway($payment_method, $order_number, $refund_amount, $request_id);
$provider_status = (string) ($latest['provider_status'] ?? 'not_started');
if ($provider_status !== 'succeeded') {
$claimed = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()}
SET refund_request_id = %s, provider = %s, provider_status = 'submitting',
refund_attempts = refund_attempts + 1, refund_requested_at = COALESCE(refund_requested_at, %s),
provider_updated_at = %s, refund_error = NULL, updated_at = %s
WHERE id = %d AND provider_status IN ('not_started', 'failed', 'pending', 'unknown')",
$request_id,
$payment_method,
current_time('mysql'),
current_time('mysql'),
current_time('mysql'),
$ticket_id
));
if ($claimed !== 1) {
return false;
}
if (in_array($payment_method, ['wechatpay', 'alipay', 'stripe'], true)) {
$result = $this->refund_via_gateway($payment_method, $order_number, $refund_amount, $request_id);
$status = sanitize_key((string) ($result['status'] ?? (!empty($result['success']) ? 'unknown' : 'failed')));
if (!in_array($status, ['pending', 'succeeded', 'failed', 'unknown'], true)) {
$status = 'unknown';
}
$wpdb->update($this->table(), [
'refund_request_id' => sanitize_text_field((string) ($result['request_id'] ?? $request_id)),
'provider_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? '')),
'provider_status' => $status,
'refund_error' => empty($result['success']) ? sanitize_textarea_field((string) ($result['message'] ?? '')) : null,
'provider_updated_at' => current_time('mysql'),
'refund_succeeded_at' => $status === 'succeeded' ? current_time('mysql') : null,
'updated_at' => current_time('mysql'),
], ['id' => $ticket_id]);
if ($status !== 'succeeded') {
return false;
}
} elseif (in_array($payment_method, ['balance', 'points'], true)) {
$wpdb->update($this->table(), [
'provider_status' => 'succeeded',
'provider_updated_at' => current_time('mysql'),
'refund_succeeded_at' => current_time('mysql'),
'updated_at' => current_time('mysql'),
], ['id' => $ticket_id]);
} else {
$wpdb->update($this->table(), [
'provider_status' => 'failed',
'refund_error' => __('当前支付方式不支持自动退款。', 'aksrt'),
'provider_updated_at' => current_time('mysql'),
'updated_at' => current_time('mysql'),
], ['id' => $ticket_id]);
return false;
}
}
$wpdb->query('START TRANSACTION');
$effect_claimed = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()}
SET refund_effect_status = 'processing', updated_at = %s
WHERE id = %d AND refund_effect_status IN ('not_started', 'failed')",
current_time('mysql'),
$ticket_id
));
if ($effect_claimed !== 1) {
$wpdb->query('ROLLBACK');
return false;
}
$effect = $this->execute_refund($order, $refund_amount);
if (is_wp_error($effect)) {
$wpdb->query('ROLLBACK');
$wpdb->update($this->table(), [
'refund_effect_status' => 'failed',
'refund_error' => $effect->get_error_message(),
'updated_at' => current_time('mysql'),
], ['id' => $ticket_id]);
$this->log_refund_error($order_number, $payment_method, $effect->get_error_message());
return false;
}
$effect_saved = $wpdb->update($this->table(), [
'refund_effect_status' => 'succeeded',
'refund_effected_at' => current_time('mysql'),
'refund_error' => null,
'updated_at' => current_time('mysql'),
], ['id' => $ticket_id]);
if ($effect_saved === false) {
$wpdb->query('ROLLBACK');
return false;
}
$committed = $wpdb->query('COMMIT');
if ($committed === false) {
return false;
}
$this->publish_refund_event($order, $refund_amount);
return true;
} }
private function refund_via_gateway(string $payment_method, string $order_number, float $refund_amount, string $request_id): bool { /**
* @return array{success:bool,status:string,refund_id?:string,message?:string}
*/
private function refund_via_gateway(string $payment_method, string $order_number, float $refund_amount, string $request_id): array {
$gateway = Gateway::get_instance()->get_gateway($payment_method); $gateway = Gateway::get_instance()->get_gateway($payment_method);
if (!$gateway || !method_exists($gateway, 'refund')) { if (!$gateway) {
$this->log_refund_error($order_number, $payment_method, 'gateway unavailable'); $this->log_refund_error($order_number, $payment_method, 'gateway unavailable');
return false; return ['success' => false, 'status' => 'failed', 'message' => 'gateway unavailable'];
} }
try { try {
$result = $gateway->refund($order_number, $refund_amount, $request_id); $result = $gateway->refund($order_number, $refund_amount, $request_id);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->log_refund_error($order_number, $payment_method, $e->getMessage()); $this->log_refund_error($order_number, $payment_method, $e->getMessage());
return false; return ['success' => false, 'status' => 'unknown', 'message' => $e->getMessage()];
} }
if (empty($result['success'])) { if (empty($result['success'])) {
$this->log_refund_error($order_number, $payment_method, (string) ($result['message'] ?? 'unknown error')); $this->log_refund_error($order_number, $payment_method, (string) ($result['message'] ?? 'unknown error'));
return false; $result['status'] = $result['status'] ?? 'unknown';
} }
return true; return $result;
} }
private function log_refund_error(string $order_number, string $payment_method, string $message): void { private function log_refund_error(string $order_number, string $payment_method, string $message): void {
@@ -656,6 +849,15 @@ class AfterSale {
$repository = OrderRepository::get_instance(); $repository = OrderRepository::get_instance();
if (in_array($new_status, [self::STATUS_PROCESSING, self::STATUS_APPROVED], true)) { if (in_array($new_status, [self::STATUS_PROCESSING, self::STATUS_APPROVED], true)) {
$order = $repository->get($order_number);
$current_status = sanitize_key((string) ($order['status'] ?? ''));
if ($current_status !== '' && $current_status !== Order::STATUS_REFUNDING) {
global $wpdb;
$wpdb->update($this->table(), [
'order_status_before' => $current_status,
'updated_at' => current_time('mysql'),
], ['id' => (int) ($record['id'] ?? 0)]);
}
$repository->update_status($order_number, Order::STATUS_REFUNDING, [ $repository->update_status($order_number, Order::STATUS_REFUNDING, [
'note' => $note, 'note' => $note,
]); ]);
@@ -664,17 +866,49 @@ class AfterSale {
if ($new_status === self::STATUS_COMPLETED) { if ($new_status === self::STATUS_COMPLETED) {
$order = $repository->get($order_number); $order = $repository->get($order_number);
if (!$order) {
return;
}
if (($order['status'] ?? '') !== Order::STATUS_REFUNDING) { if (($order['status'] ?? '') !== Order::STATUS_REFUNDING) {
$repository->update_status($order_number, Order::STATUS_REFUNDING, [ $repository->update_status($order_number, Order::STATUS_REFUNDING, [
'note' => $note, 'note' => $note,
]); ]);
$order = $repository->get($order_number) ?: $order;
} }
$this->execute_refund($order, (float) ($record['amount'] ?? 0)); $refund_amount = (float) ($record['amount'] ?? 0);
global $wpdb;
$repository->update_status($order_number, Order::STATUS_REFUNDED, [ $completed_other = (float) $wpdb->get_var($wpdb->prepare(
'note' => $note, "SELECT COALESCE(SUM(amount), 0) FROM {$this->table()}
]); WHERE order_number = %s AND id <> %d
AND type IN (%s, %s, %s) AND status = %s",
$order_number,
(int) ($record['id'] ?? 0),
self::TYPE_REFUND,
self::TYPE_REFUND_RETURN,
self::TYPE_INSURED_PRICE,
self::STATUS_COMPLETED
));
$total_refunded = round($completed_other + $refund_amount, 2);
if ($total_refunded + 0.01 >= (float) ($order['amount'] ?? 0)) {
$repository->update_status($order_number, Order::STATUS_REFUNDED, [
'note' => $note,
]);
} else {
$previous_status = sanitize_key((string) ($record['order_status_before'] ?? ''));
if (!in_array($previous_status, [
Order::STATUS_PAID,
Order::STATUS_DELIVERED,
Order::STATUS_COMPLETED,
Order::STATUS_PENDING_REVIEW,
], true)) {
$previous_status = Order::STATUS_PAID;
}
$repository->update_status($order_number, $previous_status, [
'force' => true,
'note' => __('部分退款已完成。', 'aksrt'),
]);
}
} }
} }
@@ -782,6 +1016,7 @@ class AfterSale {
'status' => self::STATUS_CANCELLED, 'status' => self::STATUS_CANCELLED,
'updated_at' => current_time('mysql'), 'updated_at' => current_time('mysql'),
], ['id' => $ticket_id]); ], ['id' => $ticket_id]);
$this->restore_order_after_cancel($record);
$msg = $reason !== '' $msg = $reason !== ''
? sprintf(__('用户取消售后:%s', 'aksrt'), sanitize_text_field($reason)) ? sprintf(__('用户取消售后:%s', 'aksrt'), sanitize_text_field($reason))
@@ -903,6 +1138,33 @@ class AfterSale {
* Shipping status window + type constraints (§6.8) * Shipping status window + type constraints (§6.8)
* ============================================================ */ * ============================================================ */
private function is_refund_type(string $type): bool {
return in_array($type, [
self::TYPE_REFUND,
self::TYPE_REFUND_RETURN,
self::TYPE_INSURED_PRICE,
], true);
}
private function within_application_window(array $order, string $type): bool {
$days = (int) ($this->get_type_timeouts()[$type] ?? 0);
if ($days <= 0) {
return true;
}
$base = (string) ($order['completed_at']
?? $order['delivered_at']
?? $order['paid_at']
?? $order['created_at']
?? '');
$timestamp = $base !== '' ? strtotime($base) : false;
if ($timestamp === false) {
return true;
}
return $timestamp >= current_time('timestamp') - ($days * DAY_IN_SECONDS);
}
public function can_apply_after_sale(array $order, string $type): bool { public function can_apply_after_sale(array $order, string $type): bool {
$status = (string) ($order['status'] ?? ''); $status = (string) ($order['status'] ?? '');
if (!in_array($status, [ if (!in_array($status, [
@@ -944,6 +1206,123 @@ class AfterSale {
]; ];
} }
private function restore_order_after_cancel(array $record): void {
if (!$this->is_refund_type((string) ($record['type'] ?? ''))
|| !class_exists('AKSRT\\Payment\\OrderRepository')) {
return;
}
$order_number = sanitize_text_field((string) ($record['order_number'] ?? ''));
$order = OrderRepository::get_instance()->get($order_number);
if (!$order || ($order['status'] ?? '') !== Order::STATUS_REFUNDING) {
return;
}
$previous = sanitize_key((string) ($record['order_status_before'] ?? ''));
if (!in_array($previous, [
Order::STATUS_PAID,
Order::STATUS_DELIVERED,
Order::STATUS_COMPLETED,
Order::STATUS_PENDING_REVIEW,
], true)) {
$previous = Order::STATUS_PAID;
}
OrderRepository::get_instance()->update_status($order_number, $previous, [
'force' => true,
'note' => __('售后申请已取消,恢复订单状态。', 'aksrt'),
]);
}
public function reconcile_pending_refunds(): void {
global $wpdb;
$submitting_cutoff = date('Y-m-d H:i:s', current_time('timestamp') - (15 * MINUTE_IN_SECONDS));
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$this->table()}
WHERE (
provider_status IN ('pending', 'unknown')
OR (provider_status = 'submitting' AND provider_updated_at < %s)
OR (provider_status = 'succeeded' AND refund_effect_status = 'processing' AND updated_at < %s)
)
AND status IN ('processing', 'approved')
ORDER BY provider_updated_at ASC
LIMIT 50",
$submitting_cutoff,
$submitting_cutoff
),
ARRAY_A
);
foreach ((array) $rows as $record) {
$provider_status = (string) ($record['provider_status'] ?? '');
$effect_status = (string) ($record['refund_effect_status'] ?? '');
if ($provider_status === 'succeeded' && $effect_status === 'processing') {
$wpdb->update($this->table(), [
'refund_effect_status' => 'failed',
'refund_error' => __('本地退款效果状态不确定,请人工核对余额或积分流水后重试。', 'aksrt'),
'updated_at' => current_time('mysql'),
], ['id' => (int) $record['id']]);
continue;
}
$provider = sanitize_key((string) ($record['provider'] ?? ''));
$request_id = (string) ($record['refund_request_id'] ?? '');
$gateway = Gateway::get_instance()->get_gateway($provider);
if (!$gateway || $request_id === '' || !method_exists($gateway, 'query_refund')) {
continue;
}
try {
$result = $gateway->query_refund(
$request_id,
(string) ($record['order_number'] ?? '')
);
} catch (\Throwable $e) {
continue;
}
$status = sanitize_key((string) ($result['status'] ?? 'unknown'));
if (!in_array($status, ['pending', 'succeeded', 'failed', 'unknown'], true)) {
$status = 'unknown';
}
$wpdb->update($this->table(), [
'provider_status' => $status,
'provider_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? $record['provider_refund_id'] ?? '')),
'refund_error' => $status === 'failed' ? sanitize_textarea_field((string) ($result['message'] ?? '')) : null,
'provider_updated_at' => current_time('mysql'),
'refund_succeeded_at' => $status === 'succeeded' ? current_time('mysql') : null,
'updated_at' => current_time('mysql'),
], ['id' => (int) $record['id']]);
if ($status === 'unknown'
&& (int) ($record['refund_attempts'] ?? 0) < 3
&& in_array($provider_status, ['submitting', 'unknown'], true)) {
$wpdb->update($this->table(), [
'provider_status' => 'failed',
'provider_updated_at' => current_time('mysql'),
'refund_error' => __('未查询到退款记录,将使用原请求号幂等重试。', 'aksrt'),
'updated_at' => current_time('mysql'),
], ['id' => (int) $record['id']]);
$fresh = $this->get_by_id((int) $record['id']);
if ($fresh) {
$this->complete_refund($fresh);
}
continue;
}
if ($status === 'succeeded') {
$fresh = $this->get_by_id((int) $record['id']);
if ($fresh && $this->complete_refund($fresh)) {
$this->update_status(
(int) $record['id'],
self::STATUS_COMPLETED,
0,
__('系统对账确认退款成功。', 'aksrt')
);
}
}
}
}
/* ============================================================ /* ============================================================
* Auto-cancel Cron (§6.8) * Auto-cancel Cron (§6.8)
* ============================================================ */ * ============================================================ */
@@ -988,6 +1367,7 @@ class AfterSale {
self::TYPE_REFUND => __('仅退款', 'aksrt'), self::TYPE_REFUND => __('仅退款', 'aksrt'),
self::TYPE_REFUND_RETURN => __('退货退款', 'aksrt'), self::TYPE_REFUND_RETURN => __('退货退款', 'aksrt'),
self::TYPE_EXCHANGE => __('换货', 'aksrt'), self::TYPE_EXCHANGE => __('换货', 'aksrt'),
self::TYPE_REPAIR => __('维修', 'aksrt'),
self::TYPE_REPLACEMENT => __('补发', 'aksrt'), self::TYPE_REPLACEMENT => __('补发', 'aksrt'),
self::TYPE_WARRANTY => __('保修', 'aksrt'), self::TYPE_WARRANTY => __('保修', 'aksrt'),
self::TYPE_INSURED_PRICE => __('保价', 'aksrt'), self::TYPE_INSURED_PRICE => __('保价', 'aksrt'),
+1 -1
View File
@@ -86,7 +86,7 @@ class BalanceGateway implements GatewayInterface {
]; ];
} }
public function refund(string $order_number, float $amount): array { public function refund(string $order_number, float $amount, string $request_id = ''): array {
$order_repo = OrderRepository::get_instance(); $order_repo = OrderRepository::get_instance();
$order = $order_repo->get($order_number); $order = $order_repo->get($order_number);
+40 -27
View File
@@ -162,13 +162,8 @@ class Gateway {
// Alipay synchronous returns generally omit trade_status; query after signature verification. // Alipay synchronous returns generally omit trade_status; query after signature verification.
$query = $gateway->query_status($order_number); $query = $gateway->query_status($order_number);
if (!empty($query['success']) && ($query['status'] ?? '') === 'paid') { if (!empty($query['success']) && ($query['status'] ?? '') === 'paid') {
$done = $this->complete_order($gateway_id, [ $query['order_number'] = $order_number;
'order_number' => $order_number, if ($this->complete_order($gateway_id, $query)) {
'amount' => 0,
'transaction_id' => $query['transaction_id'] ?? '',
'status' => 'paid',
]);
if ($done) {
$status = 'success'; $status = 'success';
} }
} }
@@ -180,8 +175,13 @@ class Gateway {
if ($paypal_order_id !== '') { if ($paypal_order_id !== '') {
$cap = $gateway->capture_order($paypal_order_id); $cap = $gateway->capture_order($paypal_order_id);
if (!empty($cap['success']) && ($cap['status'] ?? '') === 'paid') { if (!empty($cap['success']) && ($cap['status'] ?? '') === 'paid') {
$order_number = $order_number ?: sanitize_text_field((string) ($cap['order_number'] ?? '')); $captured_order = sanitize_text_field((string) ($cap['order_number'] ?? ''));
$cap['order_number'] = $order_number; 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)) { if ($this->complete_order($gateway_id, $cap)) {
$status = 'success'; $status = 'success';
} }
@@ -191,13 +191,10 @@ class Gateway {
// 其余网关:主动查询订单状态兜底 // 其余网关:主动查询订单状态兜底
$q = $gateway->query_status($order_number); $q = $gateway->query_status($order_number);
if (!empty($q['success']) && ($q['status'] ?? '') === 'paid') { if (!empty($q['success']) && ($q['status'] ?? '') === 'paid') {
$done = $this->complete_order($gateway_id, [ if (empty($q['order_number'])) {
'order_number' => $order_number, $q['order_number'] = $order_number;
'amount' => 0, // 由网关 API 已确认到账,金额校验交给 notify 路径 }
'transaction_id' => $q['transaction_id'] ?? '', if ($this->complete_order($gateway_id, $q)) {
'status' => 'paid',
]);
if ($done) {
$status = 'success'; $status = 'success';
} }
} }
@@ -238,6 +235,32 @@ class Gateway {
return false; 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 = (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 === ''
|| abs($expected - $paid_amount) > 0.01) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log(sprintf(
'[AKSRT Payment] invalid completion order=%s expected=%.2f paid=%.2f transaction=%s gateway=%s',
$order_number,
$expected,
$paid_amount,
$transaction_id !== '' ? 'present' : 'missing',
$gateway_id
));
}
return false;
}
$current = (string) ($order['status'] ?? ''); $current = (string) ($order['status'] ?? '');
// 幂等:已支付/已交付/已完成视为成功,不重复处理(回调可能多次到达) // 幂等:已支付/已交付/已完成视为成功,不重复处理(回调可能多次到达)
@@ -249,19 +272,9 @@ class Gateway {
return false; return false;
} }
// 金额校验,防止前端/回调金额被篡改
$expected = (float) ($order['amount'] ?? 0);
$paid_amount = (float) ($verify['amount'] ?? 0);
if ($expected > 0 && $paid_amount > 0 && abs($expected - $paid_amount) > 0.01) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log(sprintf('[AKSRT Payment] amount mismatch order=%s expected=%.2f paid=%.2f gateway=%s', $order_number, $expected, $paid_amount, $gateway_id));
}
return false;
}
return $repo->update_status($order_number, Order::STATUS_PAID, [ return $repo->update_status($order_number, Order::STATUS_PAID, [
'payment_method' => $gateway_id, 'payment_method' => $gateway_id,
'payment_transaction_id' => sanitize_text_field((string) ($verify['transaction_id'] ?? '')), 'payment_transaction_id' => $transaction_id,
'note' => __('支付网关回调确认到账。', 'aksrt'), 'note' => __('支付网关回调确认到账。', 'aksrt'),
]); ]);
} }
+3 -2
View File
@@ -40,9 +40,10 @@ interface GatewayInterface {
* *
* @param string $order_number 订单号 * @param string $order_number 订单号
* @param float $amount 退款金额 * @param float $amount 退款金额
* @return array{success: bool, refund_id?: string, message?: string} * @param string $request_id 幂等退款请求号
* @return array{success: bool, status?: string, refund_id?: string, request_id?: string, message?: string}
*/ */
public function refund(string $order_number, float $amount): array; public function refund(string $order_number, float $amount, string $request_id = ''): array;
/** /**
* 获取网关显示名称 * 获取网关显示名称
+64 -20
View File
@@ -52,34 +52,25 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
YansongdaPayFactory::boot_alipay(); YansongdaPayFactory::boot_alipay();
$payload = [ $payload = [
'_method' => 'GET',
'out_trade_no' => $order_number, 'out_trade_no' => $order_number,
'total_amount' => number_format($amount, 2, '.', ''), 'total_amount' => number_format($amount, 2, '.', ''),
'subject' => mb_substr($subject, 0, 256), 'subject' => mb_substr($subject, 0, 256),
]; ];
$result = $payment_type === 'wap' $result = $payment_type === 'wap'
? Pay::alipay()->wap($payload) ? Pay::alipay()->h5($payload)
: Pay::alipay()->web($payload); : Pay::alipay()->web($payload);
$payment_url = YansongdaPayFactory::extract_payment_url($result); $payment_url = YansongdaPayFactory::extract_payment_url($result);
$html = YansongdaPayFactory::extract_html_body($result); if ($payment_url === '') {
// Prefer Location / action URL; if only auto-submit HTML, expose as payment_data for frontend.
if ($payment_url === '' && $html !== '' && preg_match('#action=[\'"]([^\'"]+)[\'"]#i', $html, $m)) {
$payment_url = html_entity_decode($m[1], ENT_QUOTES);
}
if ($payment_url === '' && $html === '') {
return ['success' => false, 'message' => __('支付宝支付链接生成失败', 'aksrt')]; return ['success' => false, 'message' => __('支付宝支付链接生成失败', 'aksrt')];
} }
return [ return [
'success' => true, 'success' => true,
'payment_url' => $payment_url, 'payment_url' => $payment_url,
'payment_data' => array_filter([ 'payment_data' => ['order_number' => $order_number],
'order_number' => $order_number,
'html' => $html !== '' ? $html : null,
]),
'message' => __('支付宝支付链接已生成', 'aksrt'), 'message' => __('支付宝支付链接已生成', 'aksrt'),
]; ];
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -103,14 +94,23 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
$data_obj = Pay::alipay()->callback($callback_input); $data_obj = Pay::alipay()->callback($callback_input);
$data = YansongdaPayFactory::to_array($data_obj); $data = YansongdaPayFactory::to_array($data_obj);
$config = $this->get_config();
$trade_status = (string) ($data['trade_status'] ?? ''); $trade_status = (string) ($data['trade_status'] ?? '');
$paid = in_array($trade_status, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true); $paid = in_array($trade_status, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true);
$order_number = (string) ($data['out_trade_no'] ?? '');
$trade_no = (string) ($data['trade_no'] ?? '');
if (($data['app_id'] ?? '') !== $config['app_id']
|| $order_number === ''
|| ($paid && ($trade_no === '' || (float) ($data['total_amount'] ?? 0) <= 0))) {
return ['success' => false, 'message' => __('支付宝回调身份或订单数据无效', 'aksrt')];
}
return [ return [
'success' => true, 'success' => true,
'order_number' => (string) ($data['out_trade_no'] ?? ''), 'order_number' => $order_number,
'amount' => (float) ($data['total_amount'] ?? 0), 'amount' => (float) ($data['total_amount'] ?? 0),
'transaction_id' => (string) ($data['trade_no'] ?? ''), 'transaction_id' => $trade_no,
'status' => $paid ? 'paid' : 'pending', 'status' => $paid ? 'paid' : 'pending',
]; ];
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -157,8 +157,10 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
return [ return [
'success' => true, 'success' => true,
'status' => $status, 'order_number' => (string) ($data['out_trade_no'] ?? $order_number),
'amount' => (float) ($data['total_amount'] ?? 0),
'transaction_id' => (string) ($data['trade_no'] ?? ''), 'transaction_id' => (string) ($data['trade_no'] ?? ''),
'status' => $status,
]; ];
} catch (\Throwable $e) { } catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()]; return ['success' => false, 'message' => $e->getMessage()];
@@ -166,13 +168,20 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
} }
public function refund(string $order_number, float $amount, string $request_id = ''): array { public function refund(string $order_number, float $amount, string $request_id = ''): array {
if ($amount <= 0) {
return ['success' => false, 'status' => 'failed', 'message' => __('退款金额无效', 'aksrt')];
}
if (!$this->is_configured()) { if (!$this->is_configured()) {
return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')]; return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')];
} }
try { try {
YansongdaPayFactory::boot_alipay(); YansongdaPayFactory::boot_alipay();
$refund_request_id = sanitize_key($request_id !== '' ? $request_id : $order_number . '_refund'); $refund_request_id = (string) preg_replace(
'/[^A-Za-z0-9_-]/',
'',
$request_id !== '' ? $request_id : $order_number . '_refund'
);
$result = Pay::alipay()->refund([ $result = Pay::alipay()->refund([
'out_trade_no' => $order_number, 'out_trade_no' => $order_number,
'refund_amount' => number_format($amount, 2, '.', ''), 'refund_amount' => number_format($amount, 2, '.', ''),
@@ -181,16 +190,51 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
]); ]);
$data = YansongdaPayFactory::to_array($result); $data = YansongdaPayFactory::to_array($result);
$fund_change = strtoupper((string) ($data['fund_change'] ?? ''));
$status = ($fund_change === 'Y' || (float) ($data['refund_fee'] ?? 0) > 0)
? 'succeeded'
: 'unknown';
return [ return [
'success' => true, 'success' => true,
'refund_id' => (string) ($data['trade_no'] ?? $data['out_trade_no'] ?? ''), 'status' => $status,
'message' => __('退款成功', 'aksrt'), 'refund_id' => (string) ($data['trade_no'] ?? $data['out_trade_no'] ?? ''),
'request_id' => $refund_request_id,
'message' => $status === 'succeeded' ? __('退款成功', 'aksrt') : __('退款结果待确认', 'aksrt'),
]; ];
} catch (\Throwable $e) { } catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()]; return ['success' => false, 'message' => $e->getMessage()];
} }
} }
public function query_refund(string $request_id, string $order_number = ''): array {
if (!$this->is_configured() || $request_id === '') {
return ['success' => false, 'status' => 'failed', 'message' => __('支付宝退款查询参数无效', 'aksrt')];
}
try {
YansongdaPayFactory::boot_alipay();
$params = ['out_request_no' => $request_id, '_action' => 'refund'];
if ($order_number !== '') {
$params['out_trade_no'] = $order_number;
}
$result = Pay::alipay()->query($params);
$data = YansongdaPayFactory::to_array($result);
$refund_status = strtoupper((string) ($data['refund_status'] ?? ''));
$refund_amount = (float) ($data['refund_amount'] ?? 0);
$status = ($refund_status === 'REFUND_SUCCESS' || $refund_amount > 0)
? 'succeeded'
: ($refund_status === 'REFUND_FAIL' ? 'failed' : 'pending');
return [
'success' => true,
'status' => $status,
'refund_id' => (string) ($data['trade_no'] ?? $data['out_request_no'] ?? ''),
];
} catch (\Throwable $e) {
return ['success' => false, 'status' => 'unknown', 'message' => $e->getMessage()];
}
}
/** /**
* @return array{app_id:string,private_key:string,app_public_cert:string,alipay_public_cert:string,alipay_root_cert:string,payment_type:string} * @return array{app_id:string,private_key:string,app_public_cert:string,alipay_public_cert:string,alipay_root_cert:string,payment_type:string}
*/ */
+8 -29
View File
@@ -84,34 +84,13 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
} }
public function verify_callback(\WP_REST_Request $request): array { public function verify_callback(\WP_REST_Request $request): array {
try { // PayPal orders are finalized only by the authenticated capture call in handle_return().
$body = json_decode($request->get_body(), true); // Never treat an unsigned CHECKOUT.ORDER.APPROVED webhook as payment completion.
$eventType = $body['event_type'] ?? ''; return [
'success' => false,
if ($eventType !== 'CHECKOUT.ORDER.APPROVED') { 'status' => 'pending',
return ['success' => false, 'message' => __('非支付完成事件', 'aksrt')]; 'message' => __('PayPal 支付需在返回流程中完成捕获确认', 'aksrt'),
} ];
$resource = $body['resource'] ?? [];
$orderNumber = '';
$amount = 0;
foreach ($resource['purchase_units'] ?? [] as $unit) {
$orderNumber = $unit['reference_id'] ?? '';
$amount = (float) ($unit['amount']['value'] ?? 0);
break;
}
return [
'success' => true,
'order_number' => $orderNumber,
'amount' => $amount,
'transaction_id' => $resource['id'] ?? '',
'status' => 'paid',
];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
} }
public function query_status(string $order_number): array { public function query_status(string $order_number): array {
@@ -122,7 +101,7 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
]; ];
} }
public function refund(string $order_number, float $amount): array { public function refund(string $order_number, float $amount, string $request_id = ''): array {
return [ return [
'success' => false, 'success' => false,
'message' => __('PayPal 退款需要通过后台手动处理', 'aksrt'), 'message' => __('PayPal 退款需要通过后台手动处理', 'aksrt'),
+64 -7
View File
@@ -110,7 +110,8 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
$session = $event->data->object; $session = $event->data->object;
$orderNumber = $session->metadata->order_number ?? ''; $orderNumber = $session->metadata->order_number ?? '';
$amount = ($session->amount_total ?? 0) / 100; $currency = strtolower((string) ($session->currency ?? $config['currency'] ?? 'usd'));
$amount = $this->from_smallest_unit((int) ($session->amount_total ?? 0), $currency);
return [ return [
'success' => true, 'success' => true,
@@ -160,10 +161,19 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
$status = 'cancelled'; $status = 'cancelled';
} }
$transaction_id = is_string($found->payment_intent)
? $found->payment_intent
: (string) ($found->payment_intent->id ?? '');
return [ return [
'success' => true, 'success' => true,
'order_number' => (string) ($found->metadata->order_number ?? $order_number),
'amount' => $this->from_smallest_unit(
(int) ($found->amount_total ?? 0),
strtolower((string) ($found->currency ?? $config['currency'] ?? 'usd'))
),
'status' => $status, 'status' => $status,
'transaction_id' => $found->payment_intent ?? '', 'transaction_id' => $transaction_id,
]; ];
} }
@@ -173,7 +183,7 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
} }
} }
public function refund(string $order_number, float $amount): array { public function refund(string $order_number, float $amount, string $request_id = ''): array {
if (!$this->is_configured()) { if (!$this->is_configured()) {
return ['success' => false, 'message' => __('Stripe 未配置', 'aksrt')]; return ['success' => false, 'message' => __('Stripe 未配置', 'aksrt')];
} }
@@ -208,16 +218,26 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
$currency = strtolower($config['currency'] ?? 'usd'); $currency = strtolower($config['currency'] ?? 'usd');
$options = ['api_key' => $config['secret_key']];
if ($request_id !== '') {
$options['idempotency_key'] = $request_id;
}
$refund = \Stripe\Refund::create([ $refund = \Stripe\Refund::create([
'payment_intent' => $paymentIntentId, 'payment_intent' => $paymentIntentId,
'amount' => $this->to_smallest_unit($amount, $currency), 'amount' => $this->to_smallest_unit($amount, $currency),
'reason' => 'requested_by_customer', 'reason' => 'requested_by_customer',
], ['api_key' => $config['secret_key']]); 'metadata' => [
'aksrt_request_id' => $request_id,
'order_number' => $order_number,
],
], $options);
return [ return [
'success' => true, 'success' => true,
'refund_id' => $refund->id, 'status' => ($refund->status ?? '') === 'succeeded' ? 'succeeded' : 'pending',
'message' => __('退款成功', 'aksrt'), 'refund_id' => $refund->id,
'request_id' => $request_id,
'message' => __('退款请求已提交', 'aksrt'),
]; ];
} catch (\Stripe\Exception\ApiErrorException $e) { } catch (\Stripe\Exception\ApiErrorException $e) {
return ['success' => false, 'message' => $e->getMessage()]; return ['success' => false, 'message' => $e->getMessage()];
@@ -226,6 +246,34 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
} }
} }
public function query_refund(string $request_id, string $order_number = ''): array {
if (!$this->is_configured() || $request_id === '') {
return ['success' => false, 'status' => 'failed', 'message' => __('Stripe 退款查询参数无效', 'aksrt')];
}
try {
$config = $this->get_config();
$this->create_client($config);
$refunds = \Stripe\Refund::all(['limit' => 100], ['api_key' => $config['secret_key']]);
foreach ($refunds->data as $refund) {
if (($refund->metadata->aksrt_request_id ?? '') !== $request_id) {
continue;
}
$status = ($refund->status ?? '') === 'succeeded'
? 'succeeded'
: (($refund->status ?? '') === 'failed' ? 'failed' : 'pending');
return [
'success' => true,
'status' => $status,
'refund_id' => (string) ($refund->id ?? ''),
];
}
return ['success' => true, 'status' => 'unknown', 'message' => __('未找到 Stripe 退款记录', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'status' => 'unknown', 'message' => $e->getMessage()];
}
}
/** /**
* 获取 Stripe Publishable Key(前端使用)。 * 获取 Stripe Publishable Key(前端使用)。
*/ */
@@ -271,6 +319,15 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
return array_values(array_intersect($methods, $valid)) ?: ['card']; return array_values(array_intersect($methods, $valid)) ?: ['card'];
} }
private function from_smallest_unit(int $amount, string $currency): float {
$zero_decimal = ['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf'];
if (in_array(strtolower($currency), $zero_decimal, true)) {
return (float) $amount;
}
return $amount / 100;
}
/** /**
* 将金额转换为最小单位(分/cent)。 * 将金额转换为最小单位(分/cent)。
*/ */
@@ -142,6 +142,29 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
$paid = false; $paid = false;
} }
$config = $this->get_config();
$mch_id = (string) ($resource['mchid'] ?? $data['mchid'] ?? '');
$app_id = (string) ($resource['appid'] ?? $data['appid'] ?? '');
$currency = (string) YansongdaPayFactory::dig($resource, 'amount.currency', '');
if ($order_number === ''
|| ($paid && (
$mch_id === ''
|| $app_id === ''
|| $currency === ''
|| !hash_equals($config['mch_id'], $mch_id)
|| !hash_equals($config['appid'], $app_id)
|| $currency !== 'CNY'
|| $transaction_id === ''
|| $total_fen <= 0
))
|| (!$paid && (
($mch_id !== '' && !hash_equals($config['mch_id'], $mch_id))
|| ($app_id !== '' && !hash_equals($config['appid'], $app_id))
|| ($currency !== '' && $currency !== 'CNY')
))) {
return ['success' => false, 'message' => __('微信支付回调身份或订单数据无效', 'aksrt')];
}
return [ return [
'success' => true, 'success' => true,
'order_number' => $order_number, 'order_number' => $order_number,
@@ -204,8 +227,10 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
return [ return [
'success' => true, 'success' => true,
'status' => $status, 'order_number' => (string) ($data['out_trade_no'] ?? $order_number),
'amount' => ((int) YansongdaPayFactory::dig($data, 'amount.total', 0)) / 100,
'transaction_id' => (string) ($data['transaction_id'] ?? ''), 'transaction_id' => (string) ($data['transaction_id'] ?? ''),
'status' => $status,
]; ];
} catch (\Throwable $e) { } catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()]; return ['success' => false, 'message' => $e->getMessage()];
@@ -229,13 +254,17 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
$total_fen = (int) YansongdaPayFactory::dig($q, 'amount.total', 0); $total_fen = (int) YansongdaPayFactory::dig($q, 'amount.total', 0);
$refund_fen = (int) round($amount * 100); $refund_fen = (int) round($amount * 100);
if ($total_fen <= 0) { if ($total_fen <= 0) {
$total_fen = $refund_fen; return ['success' => false, 'status' => 'failed', 'message' => __('无法确认原订单金额', 'aksrt')];
} }
if ($refund_fen <= 0) { if ($refund_fen <= 0 || $refund_fen > $total_fen) {
return ['success' => false, 'message' => __('退款金额无效', 'aksrt')]; return ['success' => false, 'status' => 'failed', 'message' => __('退款金额无效', 'aksrt')];
} }
$refund_request_id = sanitize_key($request_id !== '' ? $request_id : 'REF_' . $order_number); $refund_request_id = (string) preg_replace(
'/[^A-Za-z0-9_-]/',
'',
$request_id !== '' ? $request_id : 'REF_' . $order_number
);
$result = Pay::wechat()->refund([ $result = Pay::wechat()->refund([
'_action' => 'refund_' . $action, '_action' => 'refund_' . $action,
'out_trade_no' => $order_number, 'out_trade_no' => $order_number,
@@ -249,16 +278,49 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
]); ]);
$data = YansongdaPayFactory::to_array($result); $data = YansongdaPayFactory::to_array($result);
$status = strtoupper((string) ($data['status'] ?? ''));
$normalized = $status === 'SUCCESS'
? 'succeeded'
: (in_array($status, ['CLOSED', 'ABNORMAL'], true) ? 'failed' : 'pending');
return [ return [
'success' => true, 'success' => true,
'refund_id' => (string) ($data['refund_id'] ?? $data['out_refund_no'] ?? ''), 'status' => $normalized,
'message' => __('退款成功', 'aksrt'), 'refund_id' => (string) ($data['refund_id'] ?? $data['out_refund_no'] ?? ''),
'request_id' => $refund_request_id,
'message' => $normalized === 'succeeded' ? __('退款成功', 'aksrt') : __('退款请求已受理', 'aksrt'),
]; ];
} catch (\Throwable $e) { } catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()]; return ['success' => false, 'message' => $e->getMessage()];
} }
} }
public function query_refund(string $request_id, string $order_number = ''): array {
if (!$this->is_configured() || $request_id === '') {
return ['success' => false, 'status' => 'failed', 'message' => __('微信退款查询参数无效', 'aksrt')];
}
try {
YansongdaPayFactory::boot_wechat();
$result = Pay::wechat()->query([
'out_refund_no' => $request_id,
'_action' => 'refund_' . $this->query_action(),
]);
$data = YansongdaPayFactory::to_array($result);
$status = strtoupper((string) ($data['status'] ?? ''));
$normalized = $status === 'SUCCESS'
? 'succeeded'
: (in_array($status, ['CLOSED', 'ABNORMAL'], true) ? 'failed' : 'pending');
return [
'success' => true,
'status' => $normalized,
'refund_id' => (string) ($data['refund_id'] ?? $data['out_refund_no'] ?? ''),
];
} catch (\Throwable $e) {
return ['success' => false, 'status' => 'unknown', 'message' => $e->getMessage()];
}
}
/** /**
* @return array{mch_id:string,appid:string,api_v3_key:string,private_key:string,cert_path:string,payment_type:string} * @return array{mch_id:string,appid:string,api_v3_key:string,private_key:string,cert_path:string,payment_type:string}
*/ */
@@ -52,7 +52,7 @@ class YansongdaPayFactory {
*/ */
public static function wechat_from_settings(): array { public static function wechat_from_settings(): array {
$all = self::settings(); $all = self::settings();
$notify = home_url('/wp-json/aksrt/v1/payment/wechatpay/notify'); $notify = rest_url('aksrt/v1/payment/wechatpay/notify');
return [ return [
'mch_id' => (string) ($all['wechat_merchantid'] ?? ''), 'mch_id' => (string) ($all['wechat_merchantid'] ?? ''),
@@ -75,8 +75,8 @@ class YansongdaPayFactory {
*/ */
public static function alipay_from_settings(): array { public static function alipay_from_settings(): array {
$all = self::settings(); $all = self::settings();
$notify = home_url('/wp-json/aksrt/v1/payment/alipay/notify'); $notify = rest_url('aksrt/v1/payment/alipay/notify');
$return = home_url('/wp-json/aksrt/v1/payment/alipay/return'); $return = rest_url('aksrt/v1/payment/alipay/return');
return [ return [
'app_id' => (string) ($all['alipay_appid'] ?? ''), 'app_id' => (string) ($all['alipay_appid'] ?? ''),
@@ -159,13 +159,9 @@ class YansongdaPayFactory {
return $loc; return $loc;
} }
$body = (string) $result->getBody(); $body = (string) $result->getBody();
if (preg_match('#action=[\'"]([^\'"]+)[\'"]#i', $body, $m)) { if (preg_match('#^https?://[^\s"\'<>]+$#', trim($body), $m)) {
return html_entity_decode($m[1], ENT_QUOTES);
}
if (preg_match('#https?://[^\s"\'<>]+#', $body, $m)) {
return $m[0]; return $m[0];
} }
// Auto-submit HTML form: callers may open as document; store empty payment_url and put HTML in payment_data.
return ''; return '';
} }
if (is_object($result)) { if (is_object($result)) {
+22 -2
View File
@@ -236,9 +236,29 @@ trait OrderRepositoryCore {
$format[] = '%s'; $format[] = '%s';
} }
$result = $wpdb->update($this->table(), $update_data, ['order_number' => $order_number], $format, ['%s']); // Compare-and-swap the status so concurrent callbacks cannot both win the
// first paid transition and trigger fulfillment twice.
$result = $wpdb->update(
$this->table(),
$update_data,
['order_number' => $order_number, 'status' => $current_status],
$format,
['%s', '%s']
);
if ($result !== false) { if ($result === false) {
return false;
}
if ($result === 0) {
if (!empty($extra['require_transition'])) {
return false;
}
$latest = $this->get($order_number);
return is_array($latest) && ($latest['status'] ?? '') === $new_status;
}
if ($result > 0) {
// 到账事件:让充值/VIP/商品履约等挂接(单一履约入口)。 // 到账事件:让充值/VIP/商品履约等挂接(单一履约入口)。
// 仅在「首次」转为 paid 时触发,避免回调多次到达重复履约。 // 仅在「首次」转为 paid 时触发,避免回调多次到达重复履约。
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID && $current_status !== \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) { if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID && $current_status !== \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
+76
View File
@@ -0,0 +1,76 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
/**
* Cross-request lock for creating a payment attempt for one order.
*/
class PaymentAttemptLock {
private const TTL = 120;
/**
* @return string|false Lock token, or false when another request owns it.
*/
public static function acquire(string $order_number) {
$key = self::key($order_number);
$token = wp_generate_uuid4();
$value = [
'token' => $token,
'expires' => time() + self::TTL,
];
if (add_option($key, $value, '', false)) {
return $token;
}
$current = get_option($key, []);
if (is_array($current) && (int) ($current['expires'] ?? 0) < time()) {
global $wpdb;
$serialized_current = maybe_serialize($current);
$serialized_value = maybe_serialize($value);
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$wpdb->options}
SET option_value = %s
WHERE option_name = %s AND option_value = %s",
$serialized_value,
$key,
$serialized_current
));
if ($updated === 1) {
wp_cache_delete($key, 'options');
return $token;
}
}
return false;
}
public static function release(string $order_number, string $token): void {
global $wpdb;
$key = self::key($order_number);
$current = get_option($key, []);
if (!is_array($current) || !hash_equals((string) ($current['token'] ?? ''), $token)) {
return;
}
$deleted = $wpdb->query($wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
$key,
maybe_serialize($current)
));
if ($deleted === 1) {
wp_cache_delete($key, 'options');
}
}
private static function key(string $order_number): string {
return 'aksrt_pay_lock_' . hash('sha256', $order_number);
}
}
+65 -26
View File
@@ -308,7 +308,17 @@ class UserOrders {
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')]; return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
} }
$payment_method = sanitize_key($payment_method ?: (string) ($order['payment_method'] ?? 'balance')); $status = (string) ($order['status'] ?? '');
if (!in_array($status, [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) {
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
}
$stored_method = sanitize_key((string) ($order['payment_method'] ?? ''));
$payment_method = sanitize_key($payment_method ?: ($stored_method ?: 'balance'));
if ($stored_method !== '' && $payment_method !== $stored_method) {
return ['success' => false, 'message' => __('支付方式与订单不一致,请重新下单。', 'aksrt')];
}
$gateway_manager = class_exists(Gateway::class) ? Gateway::get_instance() : null; $gateway_manager = class_exists(Gateway::class) ? Gateway::get_instance() : null;
$gateway = $gateway_manager ? $gateway_manager->get_gateway($payment_method) : null; $gateway = $gateway_manager ? $gateway_manager->get_gateway($payment_method) : null;
if (!$gateway) { if (!$gateway) {
@@ -318,39 +328,68 @@ class UserOrders {
return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')]; return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
} }
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number); $lock = class_exists(PaymentAttemptLock::class)
$payment_result = $gateway->create_payment($order); ? PaymentAttemptLock::acquire($order_number)
if (empty($payment_result['success'])) { : false;
return ['success' => false, 'message' => $payment_result['message'] ?? __('支付失败。', 'aksrt')]; if ($lock === false) {
return ['success' => false, 'message' => __('该订单正在发起支付,请稍后重试。', 'aksrt')];
} }
$code_url = $payment_result['payment_data']['code_url'] ?? ''; try {
if (!empty($payment_result['payment_url']) || $code_url !== '') { $order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
$payment_result = $gateway->create_payment($order);
if (empty($payment_result['success'])) {
return ['success' => false, 'message' => $payment_result['message'] ?? __('支付失败。', 'aksrt')];
}
$code_url = $payment_result['payment_data']['code_url'] ?? '';
if (!empty($payment_result['payment_url']) || $code_url !== '') {
return [
'success' => true,
'data' => [
'redirect' => true,
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) $code_url,
'order_number' => $order_number,
'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'),
],
];
}
$paid = $repo->update_status($order_number, Order::STATUS_PAID, [
'payment_method' => $payment_method,
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
'note' => __('用户在前台完成支付。', 'aksrt'),
'require_transition' => true,
]);
if (!$paid) {
$compensation = $gateway->refund(
$order_number,
(float) ($order['amount'] ?? 0),
'PAY_ROLLBACK_' . substr(hash('sha256', $order_number), 0, 24)
);
if (empty($compensation['success'])) {
error_log(sprintf(
'[AKSRT Payment] rollback failed order=%s gateway=%s: %s',
$order_number,
$payment_method,
(string) ($compensation['message'] ?? 'unknown error')
));
return ['success' => false, 'message' => __('订单状态已变化,扣款补偿失败,请联系管理员处理。', 'aksrt')];
}
return ['success' => false, 'message' => __('订单状态已变化,扣款已退回。', 'aksrt')];
}
return [ return [
'success' => true, 'success' => true,
'data' => [ 'data' => [
'redirect' => true, 'order' => $this->get_order_detail_view($user_id, $order_number),
'payment_url' => (string) ($payment_result['payment_url'] ?? ''), 'message' => $payment_result['message'] ?? __('支付成功。', 'aksrt'),
'qr_content' => (string) $code_url,
'order_number' => $order_number,
'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'),
], ],
]; ];
} finally {
PaymentAttemptLock::release($order_number, $lock);
} }
$repo->update_status($order_number, Order::STATUS_PAID, [
'payment_method' => $payment_method,
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
'note' => __('用户在前台完成支付。', 'aksrt'),
]);
return [
'success' => true,
'data' => [
'order' => $this->get_order_detail_view($user_id, $order_number),
'message' => $payment_result['message'] ?? __('支付成功。', 'aksrt'),
],
];
} }
private function empty_stats(): array { private function empty_stats(): array {
+67 -6
View File
@@ -184,8 +184,21 @@ class CheckoutService {
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')]; return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
} }
// 未显式指定时,使用订单创建时选定的支付方式。 $status = (string) ($order['status'] ?? '');
$payment_method = sanitize_key($payment_method ?: (string) ($order['payment_method'] ?? 'balance')); if (!in_array($status, [
\AKSRT\Payment\Order::STATUS_CREATED,
\AKSRT\Payment\Order::STATUS_PENDING,
], true)) {
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
}
// Payment attempts are bound to the method selected when the order was created.
$stored_method = sanitize_key((string) ($order['payment_method'] ?? ''));
$payment_method = sanitize_key($payment_method ?: ($stored_method ?: 'balance'));
if ($stored_method !== '' && $payment_method !== $stored_method) {
return ['success' => false, 'message' => __('支付方式与订单不一致,请重新下单。', 'aksrt')];
}
$gateway_manager = class_exists('\AKSRT\Payment\Gateway') ? \AKSRT\Payment\Gateway::get_instance() : null; $gateway_manager = class_exists('\AKSRT\Payment\Gateway') ? \AKSRT\Payment\Gateway::get_instance() : null;
$gateway = $gateway_manager ? $gateway_manager->get_gateway($payment_method) : null; $gateway = $gateway_manager ? $gateway_manager->get_gateway($payment_method) : null;
@@ -197,8 +210,16 @@ class CheckoutService {
return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')]; return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
} }
// 携带订单号与摘要,供网关生成支付单。 $lock = class_exists('\AKSRT\Payment\PaymentAttemptLock')
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number); ? \AKSRT\Payment\PaymentAttemptLock::acquire($order_number)
: false;
if ($lock === false) {
return ['success' => false, 'message' => __('该订单正在发起支付,请稍后重试。', 'aksrt')];
}
try {
// 携带订单号与摘要,供网关生成支付单。
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
// Points payment: synchronous deduction via \AKSRT\Credits\Credits // Points payment: synchronous deduction via \AKSRT\Credits\Credits
if ($payment_method === 'points') { if ($payment_method === 'points') {
@@ -227,11 +248,29 @@ class CheckoutService {
} }
// 同步型网关(站内余额):create_payment 已实际扣款,标记已支付(触发 aksrt_order_paid 履约)。 // 同步型网关(站内余额):create_payment 已实际扣款,标记已支付(触发 aksrt_order_paid 履约)。
$repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [ $paid = $repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
'payment_method' => $payment_method, 'payment_method' => $payment_method,
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')), 'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
'note' => __('用户在前台完成支付。', 'aksrt'), 'note' => __('用户在前台完成支付。', 'aksrt'),
'require_transition' => true,
]); ]);
if (!$paid) {
$compensation = $gateway->refund(
$order_number,
(float) ($order['amount'] ?? 0),
'PAY_ROLLBACK_' . substr(hash('sha256', $order_number), 0, 24)
);
if (empty($compensation['success'])) {
error_log(sprintf(
'[AKSRT Payment] rollback failed order=%s gateway=%s: %s',
$order_number,
$payment_method,
(string) ($compensation['message'] ?? 'unknown error')
));
return ['success' => false, 'message' => __('订单状态已变化,扣款补偿失败,请联系管理员处理。', 'aksrt')];
}
return ['success' => false, 'message' => __('订单状态已变化,扣款已退回。', 'aksrt')];
}
return [ return [
'success' => true, 'success' => true,
@@ -240,6 +279,9 @@ class CheckoutService {
'message' => $payment_result['message'] ?? __('支付成功。', 'aksrt'), 'message' => $payment_result['message'] ?? __('支付成功。', 'aksrt'),
], ],
]; ];
} finally {
\AKSRT\Payment\PaymentAttemptLock::release($order_number, $lock);
}
} }
private function pay_with_points(int $user_id, string $order_number, array $order): array { private function pay_with_points(int $user_id, string $order_number, array $order): array {
@@ -269,11 +311,30 @@ class CheckoutService {
} }
$repo = \AKSRT\Payment\OrderRepository::get_instance(); $repo = \AKSRT\Payment\OrderRepository::get_instance();
$repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [ $paid = $repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
'payment_method' => 'points', 'payment_method' => 'points',
'payment_transaction_id' => 'points_' . $order_number, 'payment_transaction_id' => 'points_' . $order_number,
'note' => sprintf(__('积分支付 %d 积分。', 'aksrt'), $amount), 'note' => sprintf(__('积分支付 %d 积分。', 'aksrt'), $amount),
'require_transition' => true,
]); ]);
if (!$paid) {
$compensation = \AKSRT\Credits\Credits::add(
$user_id,
$amount,
'shop_points_pay_rollback',
['order' => $order_number],
sprintf(__('订单 %s 支付失败退回积分', 'aksrt'), $order_number)
);
if (is_wp_error($compensation)) {
error_log(sprintf(
'[AKSRT Payment] points rollback failed order=%s: %s',
$order_number,
$compensation->get_error_message()
));
return ['success' => false, 'message' => __('订单状态已变化,积分补偿失败,请联系管理员处理。', 'aksrt')];
}
return ['success' => false, 'message' => __('订单状态已变化,积分已退回。', 'aksrt')];
}
return [ return [
'success' => true, 'success' => true,
+9 -6
View File
@@ -28,14 +28,17 @@ trait Part1 {
} }
$ticket_id = \AKSRT\Payment\AfterSale::get_instance()->create([ $ticket_id = \AKSRT\Payment\AfterSale::get_instance()->create([
'order_number' => $order_number, 'order_number' => $order_number,
'user_id' => $user_id, 'applicant_user_id' => $user_id,
'type' => $type, 'type' => $type,
'reason' => $reason, 'reason' => $reason,
'amount' => (float) ($order['amount'] ?? 0), 'amount' => (float) ($payload['amount'] ?? $order['amount'] ?? 0),
'note' => sanitize_textarea_field((string) ($payload['note'] ?? '')), 'note' => sanitize_textarea_field((string) ($payload['note'] ?? '')),
]); ]);
if (is_wp_error($ticket_id)) {
return ['success' => false, 'message' => $ticket_id->get_error_message()];
}
if ($ticket_id <= 0) { if ($ticket_id <= 0) {
return ['success' => false, 'message' => __('售后申请提交失败。', 'aksrt')]; return ['success' => false, 'message' => __('售后申请提交失败。', 'aksrt')];
} }