diff --git a/assets/admin/src/schema/sections/users/oauth.js b/assets/admin/src/schema/sections/users/oauth.js index 88bf94f9..a8abd3c3 100644 --- a/assets/admin/src/schema/sections/users/oauth.js +++ b/assets/admin/src/schema/sections/users/oauth.js @@ -203,7 +203,7 @@ export const oauthSchema = { icon: 'fab fa-alipay', fields: [ { 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: '' }, ], }, diff --git a/composer.json b/composer.json index 4d863951..d7d507c3 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "AKSRT WordPress Theme", "type": "wordpress-theme", "require": { - "php": ">=7.4", + "php": ">=8.1", "stripe/stripe-php": "^16.1", "yansongda/pay": "^3.7", "yurunsoft/yurun-oauth-login": "~3.0" diff --git a/composer.lock b/composer.lock index 25d124b6..cf7baa7e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fe1258c91a142e623f464f5ddaef58f9", + "content-hash": "7b529608bb9672db0327c4448e99220a", "packages": [ { "name": "guzzlehttp/psr7", @@ -1002,7 +1002,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=7.4" + "php": ">=8.1" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/functions.php b/functions.php index a0feab3e..2d387449 100644 --- a/functions.php +++ b/functions.php @@ -111,7 +111,7 @@ add_filter('query_vars', function ($vars) { // 主题切换离开时:清理本主题注册的 cron 任务 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) { $next = wp_next_scheduled($hook); while ($next) { @@ -321,6 +321,10 @@ if (class_exists('\AKSRT\Payment\Order')) { \AKSRT\Payment\Order::get_instance()->init(); } +if (class_exists('\AKSRT\Payment\AfterSale')) { + \AKSRT\Payment\AfterSale::get_instance()->init(); +} + // Register built-in payment gateways if (class_exists('\AKSRT\Payment\Gateway')) { $gw = \AKSRT\Payment\Gateway::get_instance(); diff --git a/src/AKSRT/Admin/Native/PayAfterSale.php b/src/AKSRT/Admin/Native/PayAfterSale.php index 91c3bb65..5e65e751 100644 --- a/src/AKSRT/Admin/Native/PayAfterSale.php +++ b/src/AKSRT/Admin/Native/PayAfterSale.php @@ -41,20 +41,14 @@ class PayAfterSale { $ok = false; 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([ 'order_number' => $order_number, - 'user_id' => $user_id, 'type' => sanitize_key((string) wp_unslash($_POST['type'] ?? 'refund')), 'reason' => sanitize_textarea_field((string) wp_unslash($_POST['reason'] ?? '')), 'amount' => (float) ($_POST['amount'] ?? 0), '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'; diff --git a/src/AKSRT/Admin/Schema/SchemaPayment.php b/src/AKSRT/Admin/Schema/SchemaPayment.php index 3a23fc44..a6de91b9 100644 --- a/src/AKSRT/Admin/Schema/SchemaPayment.php +++ b/src/AKSRT/Admin/Schema/SchemaPayment.php @@ -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_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' => [ ['value' => 'page', 'label' => '电脑网站支付(web)'], ['value' => 'wap', 'label' => '手机网站支付(wap)'], diff --git a/src/AKSRT/Admin/Schema/SchemaUsers.php b/src/AKSRT/Admin/Schema/SchemaUsers.php index 3948a5eb..3cadf69d 100644 --- a/src/AKSRT/Admin/Schema/SchemaUsers.php +++ b/src/AKSRT/Admin/Schema/SchemaUsers.php @@ -318,7 +318,7 @@ class SchemaUsers { ]], ['key' => 'alipay', 'title' => '支付宝配置', 'fields' => [ ['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' => ''], ]], ], diff --git a/src/AKSRT/Admin/ShopControllerFulfillmentOperations.php b/src/AKSRT/Admin/ShopControllerFulfillmentOperations.php index 50b05e3c..6cdb1c20 100644 --- a/src/AKSRT/Admin/ShopControllerFulfillmentOperations.php +++ b/src/AKSRT/Admin/ShopControllerFulfillmentOperations.php @@ -82,13 +82,12 @@ trait ShopControllerFulfillmentOperations { return $this->error(__('订单不存在', 'aksrt'), 404); } - $params['user_id'] = (int) ($params['user_id'] ?? ($order['user_id'] ?? 0)); - if ($params['user_id'] <= 0) { - return $this->error(__('订单未绑定有效用户', 'aksrt')); - } - + unset($params['user_id'], $params['applicant_user_id']); $id = \AKSRT\Payment\AfterSale::get_instance()->create($params); + if (is_wp_error($id)) { + return $this->error($id->get_error_message(), 422); + } if ($id > 0) { return $this->success([ 'message' => __('售后工单已创建', 'aksrt'), diff --git a/src/AKSRT/Core/DBMigrator.php b/src/AKSRT/Core/DBMigrator.php index 916c7f7f..79048be9 100644 --- a/src/AKSRT/Core/DBMigrator.php +++ b/src/AKSRT/Core/DBMigrator.php @@ -12,7 +12,7 @@ class DBMigrator { use DBMigratorPart3; /** 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'; diff --git a/src/AKSRT/Core/DBMigratorPart2.php b/src/AKSRT/Core/DBMigratorPart2.php index 6ed4b74a..f388e5c0 100644 --- a/src/AKSRT/Core/DBMigratorPart2.php +++ b/src/AKSRT/Core/DBMigratorPart2.php @@ -337,18 +337,32 @@ trait DBMigratorPart2 { type VARCHAR(20) NOT NULL DEFAULT 'refund', reason TEXT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', - amount DECIMAL(10,2) NOT NULL DEFAULT 0, - note TEXT DEFAULT NULL, - operator_id BIGINT UNSIGNED DEFAULT NULL, - operator_note TEXT DEFAULT NULL, - created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', - updated_at DATETIME DEFAULT NULL, - resolved_at DATETIME DEFAULT NULL, + amount DECIMAL(10,2) NOT NULL DEFAULT 0, + note TEXT DEFAULT NULL, + operator_id BIGINT UNSIGNED DEFAULT NULL, + operator_note TEXT DEFAULT NULL, + refund_request_id VARCHAR(64) DEFAULT NULL, + provider VARCHAR(32) NOT NULL DEFAULT '', + 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), + UNIQUE KEY uniq_refund_request (refund_request_id), KEY idx_order (order_number), KEY idx_user (user_id), KEY idx_status (status), KEY idx_type (type), + KEY idx_provider_status (provider_status), KEY idx_created (created_at) ) {$charset_collate};"; dbDelta($sql_after_sale); diff --git a/src/AKSRT/Payment/AfterSale.php b/src/AKSRT/Payment/AfterSale.php index 57f5abb2..73bc764c 100644 --- a/src/AKSRT/Payment/AfterSale.php +++ b/src/AKSRT/Payment/AfterSale.php @@ -31,6 +31,17 @@ class AfterSale { const PROGRESS_WAITING_USER_RECEIVE = 3; 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 { if (null === self::$instance) { 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; $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)); $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'] ?? '')); - if ($order_number === '' || $user_id <= 0 || $reason === '') { - return 0; + if ($order_number === '' || $reason === '') { + 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]; - if (!in_array($type, $valid_types, true)) { - $type = self::TYPE_REFUND; + $order = OrderRepository::get_instance()->get($order_number); + if (!$order) { + 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(), [ @@ -70,13 +146,15 @@ class AfterSale { 'type' => $type, 'reason' => $reason, 'status' => self::STATUS_PENDING, - 'amount' => $amount, - 'note' => $note, - 'created_at' => current_time('mysql'), + 'amount' => $amount, + 'note' => $note, + '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) { - return 0; + return new \WP_Error('aksrt_after_sale_db', __('售后申请创建失败。', 'aksrt')); } $ticket_id = (int) $wpdb->insert_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) { - return; + return new \WP_Error('aksrt_refund_order', __('退款订单或金额无效。', 'aksrt')); } $user_id = (int) ($order['user_id'] ?? 0); if ($user_id <= 0) { - return; + return new \WP_Error('aksrt_refund_user', __('退款用户无效。', 'aksrt')); } $payment_method = (string) ($order['payment_method'] ?? 'balance'); $order_number = (string) ($order['order_number'] ?? ''); - // Balance refund: prefer cash wallet, fall back to points. if ($payment_method === 'balance') { - if (class_exists('\AKSRT\Credits\Balance') && \AKSRT\Credits\Balance::enabled()) { - \AKSRT\Credits\Balance::add( - $user_id, - (float) $refund_amount, - 'order_refund', - ['type' => 'order', 'order' => $order_number], - sprintf(__('售后退款:订单 %s', 'aksrt'), $order_number) - ); - } elseif (class_exists('\AKSRT\Credits\Credits')) { - \AKSRT\Credits\Credits::add( - $user_id, - (int) round($refund_amount), - 'order_refund', - ['order_number' => $order_number, 'type' => 'refund'], - sprintf(__('售后退款金额 ¥%s', 'aksrt'), number_format($refund_amount, 2)) - ); + if (!class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) { + return new \WP_Error('aksrt_refund_balance', __('余额退款服务不可用。', 'aksrt')); + } + $credited = \AKSRT\Credits\Balance::add( + $user_id, + $refund_amount, + 'order_refund', + ['type' => 'order', 'order' => $order_number], + sprintf(__('售后退款:订单 %s', 'aksrt'), $order_number) + ); + if (is_wp_error($credited)) { + return $credited; + } + } elseif ($payment_method === 'points') { + 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 - $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)); - } + return true; + } - /** - * Fires after refund execution. - * - * @param string $order_number - * @param int $user_id - * @param float $refund_amount - * @param string $payment_method - */ + private function publish_refund_event(array $order, float $refund_amount): void { + $order_number = (string) ($order['order_number'] ?? ''); + $user_id = (int) ($order['user_id'] ?? 0); + $payment_method = (string) ($order['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 { + global $wpdb; + $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; } - if (!class_exists('AKSRT\\Payment\\OrderRepository')) { return false; } + $ticket_id = (int) ($record['id'] ?? 0); $order_number = sanitize_text_field((string) ($record['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; } - $payment_method = (string) ($order['payment_method'] ?? 'balance'); - if (!in_array($payment_method, ['wechatpay', 'alipay'], true)) { + $latest = $this->get_by_id($ticket_id) ?: $record; + if (($latest['refund_effect_status'] ?? '') === 'succeeded') { return true; } - - $refund_amount = (float) ($record['amount'] ?? 0); - if ($refund_amount <= 0) { + if (($latest['provider_status'] ?? '') === 'succeeded' + && ($latest['refund_effect_status'] ?? '') === 'processing') { return false; } - $request_id = sprintf( - 'AS_%d_%s', - (int) ($record['id'] ?? 0), - substr(hash('sha256', $order_number), 0, 16) - ); - return $this->refund_via_gateway($payment_method, $order_number, $refund_amount, $request_id); + $payment_method = sanitize_key((string) ($order['payment_method'] ?? 'balance')); + $request_id = (string) ($latest['refund_request_id'] ?? ''); + if ($request_id === '') { + $request_id = sprintf('as_%d_%s', $ticket_id, substr(hash('sha256', $order_number), 0, 16)); + } + + $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); - if (!$gateway || !method_exists($gateway, 'refund')) { + if (!$gateway) { $this->log_refund_error($order_number, $payment_method, 'gateway unavailable'); - return false; + return ['success' => false, 'status' => 'failed', 'message' => 'gateway unavailable']; } try { $result = $gateway->refund($order_number, $refund_amount, $request_id); } catch (\Throwable $e) { $this->log_refund_error($order_number, $payment_method, $e->getMessage()); - return false; + return ['success' => false, 'status' => 'unknown', 'message' => $e->getMessage()]; } if (empty($result['success'])) { $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 { @@ -656,6 +849,15 @@ class AfterSale { $repository = OrderRepository::get_instance(); 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, [ 'note' => $note, ]); @@ -664,17 +866,49 @@ class AfterSale { if ($new_status === self::STATUS_COMPLETED) { $order = $repository->get($order_number); + if (!$order) { + return; + } if (($order['status'] ?? '') !== Order::STATUS_REFUNDING) { $repository->update_status($order_number, Order::STATUS_REFUNDING, [ 'note' => $note, ]); + $order = $repository->get($order_number) ?: $order; } - $this->execute_refund($order, (float) ($record['amount'] ?? 0)); - - $repository->update_status($order_number, Order::STATUS_REFUNDED, [ - 'note' => $note, - ]); + $refund_amount = (float) ($record['amount'] ?? 0); + global $wpdb; + $completed_other = (float) $wpdb->get_var($wpdb->prepare( + "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, 'updated_at' => current_time('mysql'), ], ['id' => $ticket_id]); + $this->restore_order_after_cancel($record); $msg = $reason !== '' ? sprintf(__('用户取消售后:%s', 'aksrt'), sanitize_text_field($reason)) @@ -903,6 +1138,33 @@ class AfterSale { * 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 { $status = (string) ($order['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) * ============================================================ */ @@ -988,6 +1367,7 @@ class AfterSale { self::TYPE_REFUND => __('仅退款', 'aksrt'), self::TYPE_REFUND_RETURN => __('退货退款', 'aksrt'), self::TYPE_EXCHANGE => __('换货', 'aksrt'), + self::TYPE_REPAIR => __('维修', 'aksrt'), self::TYPE_REPLACEMENT => __('补发', 'aksrt'), self::TYPE_WARRANTY => __('保修', 'aksrt'), self::TYPE_INSURED_PRICE => __('保价', 'aksrt'), diff --git a/src/AKSRT/Payment/BalanceGateway.php b/src/AKSRT/Payment/BalanceGateway.php index 3630049c..93663911 100644 --- a/src/AKSRT/Payment/BalanceGateway.php +++ b/src/AKSRT/Payment/BalanceGateway.php @@ -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 = $order_repo->get($order_number); diff --git a/src/AKSRT/Payment/Gateway.php b/src/AKSRT/Payment/Gateway.php index fa1ca02e..a4cf5299 100644 --- a/src/AKSRT/Payment/Gateway.php +++ b/src/AKSRT/Payment/Gateway.php @@ -162,13 +162,8 @@ class Gateway { // Alipay synchronous returns generally omit trade_status; query after signature verification. $query = $gateway->query_status($order_number); if (!empty($query['success']) && ($query['status'] ?? '') === 'paid') { - $done = $this->complete_order($gateway_id, [ - 'order_number' => $order_number, - 'amount' => 0, - 'transaction_id' => $query['transaction_id'] ?? '', - 'status' => 'paid', - ]); - if ($done) { + $query['order_number'] = $order_number; + if ($this->complete_order($gateway_id, $query)) { $status = 'success'; } } @@ -180,8 +175,13 @@ class Gateway { if ($paypal_order_id !== '') { $cap = $gateway->capture_order($paypal_order_id); if (!empty($cap['success']) && ($cap['status'] ?? '') === 'paid') { - $order_number = $order_number ?: sanitize_text_field((string) ($cap['order_number'] ?? '')); - $cap['order_number'] = $order_number; + $captured_order = sanitize_text_field((string) ($cap['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)) { $status = 'success'; } @@ -191,13 +191,10 @@ class Gateway { // 其余网关:主动查询订单状态兜底 $q = $gateway->query_status($order_number); if (!empty($q['success']) && ($q['status'] ?? '') === 'paid') { - $done = $this->complete_order($gateway_id, [ - 'order_number' => $order_number, - 'amount' => 0, // 由网关 API 已确认到账,金额校验交给 notify 路径 - 'transaction_id' => $q['transaction_id'] ?? '', - 'status' => 'paid', - ]); - if ($done) { + if (empty($q['order_number'])) { + $q['order_number'] = $order_number; + } + if ($this->complete_order($gateway_id, $q)) { $status = 'success'; } } @@ -238,6 +235,32 @@ class Gateway { 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'] ?? ''); // 幂等:已支付/已交付/已完成视为成功,不重复处理(回调可能多次到达) @@ -249,19 +272,9 @@ class Gateway { 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, [ 'payment_method' => $gateway_id, - 'payment_transaction_id' => sanitize_text_field((string) ($verify['transaction_id'] ?? '')), + 'payment_transaction_id' => $transaction_id, 'note' => __('支付网关回调确认到账。', 'aksrt'), ]); } diff --git a/src/AKSRT/Payment/GatewayInterface.php b/src/AKSRT/Payment/GatewayInterface.php index 3aa0d627..bec55990 100644 --- a/src/AKSRT/Payment/GatewayInterface.php +++ b/src/AKSRT/Payment/GatewayInterface.php @@ -40,9 +40,10 @@ interface GatewayInterface { * * @param string $order_number 订单号 * @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; /** * 获取网关显示名称 diff --git a/src/AKSRT/Payment/Gateways/AlipayGateway.php b/src/AKSRT/Payment/Gateways/AlipayGateway.php index 47ac87cf..c920203d 100644 --- a/src/AKSRT/Payment/Gateways/AlipayGateway.php +++ b/src/AKSRT/Payment/Gateways/AlipayGateway.php @@ -52,34 +52,25 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface { YansongdaPayFactory::boot_alipay(); $payload = [ + '_method' => 'GET', 'out_trade_no' => $order_number, 'total_amount' => number_format($amount, 2, '.', ''), 'subject' => mb_substr($subject, 0, 256), ]; $result = $payment_type === 'wap' - ? Pay::alipay()->wap($payload) + ? Pay::alipay()->h5($payload) : Pay::alipay()->web($payload); $payment_url = YansongdaPayFactory::extract_payment_url($result); - $html = YansongdaPayFactory::extract_html_body($result); - - // 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 === '') { + if ($payment_url === '') { return ['success' => false, 'message' => __('支付宝支付链接生成失败', 'aksrt')]; } return [ 'success' => true, 'payment_url' => $payment_url, - 'payment_data' => array_filter([ - 'order_number' => $order_number, - 'html' => $html !== '' ? $html : null, - ]), + 'payment_data' => ['order_number' => $order_number], 'message' => __('支付宝支付链接已生成', 'aksrt'), ]; } catch (\Throwable $e) { @@ -103,14 +94,23 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface { $data_obj = Pay::alipay()->callback($callback_input); $data = YansongdaPayFactory::to_array($data_obj); + $config = $this->get_config(); $trade_status = (string) ($data['trade_status'] ?? ''); $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 [ 'success' => true, - 'order_number' => (string) ($data['out_trade_no'] ?? ''), + 'order_number' => $order_number, 'amount' => (float) ($data['total_amount'] ?? 0), - 'transaction_id' => (string) ($data['trade_no'] ?? ''), + 'transaction_id' => $trade_no, 'status' => $paid ? 'paid' : 'pending', ]; } catch (\Throwable $e) { @@ -157,8 +157,10 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface { return [ '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'] ?? ''), + 'status' => $status, ]; } catch (\Throwable $e) { 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 { + if ($amount <= 0) { + return ['success' => false, 'status' => 'failed', 'message' => __('退款金额无效', 'aksrt')]; + } if (!$this->is_configured()) { return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')]; } try { 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([ 'out_trade_no' => $order_number, 'refund_amount' => number_format($amount, 2, '.', ''), @@ -181,16 +190,51 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface { ]); $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 [ - 'success' => true, - 'refund_id' => (string) ($data['trade_no'] ?? $data['out_trade_no'] ?? ''), - 'message' => __('退款成功', 'aksrt'), + 'success' => true, + 'status' => $status, + 'refund_id' => (string) ($data['trade_no'] ?? $data['out_trade_no'] ?? ''), + 'request_id' => $refund_request_id, + 'message' => $status === 'succeeded' ? __('退款成功', 'aksrt') : __('退款结果待确认', 'aksrt'), ]; } catch (\Throwable $e) { 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} */ diff --git a/src/AKSRT/Payment/Gateways/PayPalGateway.php b/src/AKSRT/Payment/Gateways/PayPalGateway.php index 0919ad1c..46daf641 100644 --- a/src/AKSRT/Payment/Gateways/PayPalGateway.php +++ b/src/AKSRT/Payment/Gateways/PayPalGateway.php @@ -84,34 +84,13 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface { } public function verify_callback(\WP_REST_Request $request): array { - try { - $body = json_decode($request->get_body(), true); - $eventType = $body['event_type'] ?? ''; - - if ($eventType !== 'CHECKOUT.ORDER.APPROVED') { - return ['success' => false, 'message' => __('非支付完成事件', '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()]; - } + // PayPal orders are finalized only by the authenticated capture call in handle_return(). + // Never treat an unsigned CHECKOUT.ORDER.APPROVED webhook as payment completion. + return [ + 'success' => false, + 'status' => 'pending', + 'message' => __('PayPal 支付需在返回流程中完成捕获确认', 'aksrt'), + ]; } 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 [ 'success' => false, 'message' => __('PayPal 退款需要通过后台手动处理', 'aksrt'), diff --git a/src/AKSRT/Payment/Gateways/StripeGateway.php b/src/AKSRT/Payment/Gateways/StripeGateway.php index 1f81365f..3208153a 100644 --- a/src/AKSRT/Payment/Gateways/StripeGateway.php +++ b/src/AKSRT/Payment/Gateways/StripeGateway.php @@ -110,7 +110,8 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface { $session = $event->data->object; $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 [ 'success' => true, @@ -160,10 +161,19 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface { $status = 'cancelled'; } + $transaction_id = is_string($found->payment_intent) + ? $found->payment_intent + : (string) ($found->payment_intent->id ?? ''); + return [ '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, - '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()) { return ['success' => false, 'message' => __('Stripe 未配置', 'aksrt')]; } @@ -208,16 +218,26 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface { $currency = strtolower($config['currency'] ?? 'usd'); + $options = ['api_key' => $config['secret_key']]; + if ($request_id !== '') { + $options['idempotency_key'] = $request_id; + } $refund = \Stripe\Refund::create([ 'payment_intent' => $paymentIntentId, 'amount' => $this->to_smallest_unit($amount, $currency), 'reason' => 'requested_by_customer', - ], ['api_key' => $config['secret_key']]); + 'metadata' => [ + 'aksrt_request_id' => $request_id, + 'order_number' => $order_number, + ], + ], $options); return [ - 'success' => true, - 'refund_id' => $refund->id, - 'message' => __('退款成功', 'aksrt'), + 'success' => true, + 'status' => ($refund->status ?? '') === 'succeeded' ? 'succeeded' : 'pending', + 'refund_id' => $refund->id, + 'request_id' => $request_id, + 'message' => __('退款请求已提交', 'aksrt'), ]; } catch (\Stripe\Exception\ApiErrorException $e) { 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(前端使用)。 */ @@ -271,6 +319,15 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface { 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)。 */ diff --git a/src/AKSRT/Payment/Gateways/WechatPayGateway.php b/src/AKSRT/Payment/Gateways/WechatPayGateway.php index ac414e5f..95b2af22 100644 --- a/src/AKSRT/Payment/Gateways/WechatPayGateway.php +++ b/src/AKSRT/Payment/Gateways/WechatPayGateway.php @@ -142,6 +142,29 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface { $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 [ 'success' => true, 'order_number' => $order_number, @@ -204,8 +227,10 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface { return [ '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'] ?? ''), + 'status' => $status, ]; } catch (\Throwable $e) { 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); $refund_fen = (int) round($amount * 100); if ($total_fen <= 0) { - $total_fen = $refund_fen; + return ['success' => false, 'status' => 'failed', 'message' => __('无法确认原订单金额', 'aksrt')]; } - if ($refund_fen <= 0) { - return ['success' => false, 'message' => __('退款金额无效', 'aksrt')]; + if ($refund_fen <= 0 || $refund_fen > $total_fen) { + 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([ '_action' => 'refund_' . $action, 'out_trade_no' => $order_number, @@ -249,16 +278,49 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface { ]); $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, - 'refund_id' => (string) ($data['refund_id'] ?? $data['out_refund_no'] ?? ''), - 'message' => __('退款成功', 'aksrt'), + 'success' => true, + 'status' => $normalized, + 'refund_id' => (string) ($data['refund_id'] ?? $data['out_refund_no'] ?? ''), + 'request_id' => $refund_request_id, + 'message' => $normalized === 'succeeded' ? __('退款成功', 'aksrt') : __('退款请求已受理', 'aksrt'), ]; } catch (\Throwable $e) { 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} */ diff --git a/src/AKSRT/Payment/Gateways/YansongdaPayFactory.php b/src/AKSRT/Payment/Gateways/YansongdaPayFactory.php index 2e03c46b..e3be23ce 100644 --- a/src/AKSRT/Payment/Gateways/YansongdaPayFactory.php +++ b/src/AKSRT/Payment/Gateways/YansongdaPayFactory.php @@ -52,7 +52,7 @@ class YansongdaPayFactory { */ public static function wechat_from_settings(): array { $all = self::settings(); - $notify = home_url('/wp-json/aksrt/v1/payment/wechatpay/notify'); + $notify = rest_url('aksrt/v1/payment/wechatpay/notify'); return [ 'mch_id' => (string) ($all['wechat_merchantid'] ?? ''), @@ -75,8 +75,8 @@ class YansongdaPayFactory { */ public static function alipay_from_settings(): array { $all = self::settings(); - $notify = home_url('/wp-json/aksrt/v1/payment/alipay/notify'); - $return = home_url('/wp-json/aksrt/v1/payment/alipay/return'); + $notify = rest_url('aksrt/v1/payment/alipay/notify'); + $return = rest_url('aksrt/v1/payment/alipay/return'); return [ 'app_id' => (string) ($all['alipay_appid'] ?? ''), @@ -159,13 +159,9 @@ class YansongdaPayFactory { return $loc; } $body = (string) $result->getBody(); - if (preg_match('#action=[\'"]([^\'"]+)[\'"]#i', $body, $m)) { - return html_entity_decode($m[1], ENT_QUOTES); - } - if (preg_match('#https?://[^\s"\'<>]+#', $body, $m)) { + if (preg_match('#^https?://[^\s"\'<>]+$#', trim($body), $m)) { return $m[0]; } - // Auto-submit HTML form: callers may open as document; store empty payment_url and put HTML in payment_data. return ''; } if (is_object($result)) { diff --git a/src/AKSRT/Payment/OrderRepositoryCore.php b/src/AKSRT/Payment/OrderRepositoryCore.php index ebb37522..eb68cddc 100644 --- a/src/AKSRT/Payment/OrderRepositoryCore.php +++ b/src/AKSRT/Payment/OrderRepositoryCore.php @@ -236,9 +236,29 @@ trait OrderRepositoryCore { $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/商品履约等挂接(单一履约入口)。 // 仅在「首次」转为 paid 时触发,避免回调多次到达重复履约。 if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID && $current_status !== \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) { diff --git a/src/AKSRT/Payment/PaymentAttemptLock.php b/src/AKSRT/Payment/PaymentAttemptLock.php new file mode 100644 index 00000000..ba7d2f83 --- /dev/null +++ b/src/AKSRT/Payment/PaymentAttemptLock.php @@ -0,0 +1,76 @@ + $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); + } +} diff --git a/src/AKSRT/Payment/UserOrders.php b/src/AKSRT/Payment/UserOrders.php index f439781b..22cd7a48 100644 --- a/src/AKSRT/Payment/UserOrders.php +++ b/src/AKSRT/Payment/UserOrders.php @@ -308,7 +308,17 @@ class UserOrders { 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 = $gateway_manager ? $gateway_manager->get_gateway($payment_method) : null; if (!$gateway) { @@ -318,39 +328,68 @@ class UserOrders { return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')]; } - $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')]; + $lock = class_exists(PaymentAttemptLock::class) + ? PaymentAttemptLock::acquire($order_number) + : false; + if ($lock === false) { + return ['success' => false, 'message' => __('该订单正在发起支付,请稍后重试。', 'aksrt')]; } - $code_url = $payment_result['payment_data']['code_url'] ?? ''; - if (!empty($payment_result['payment_url']) || $code_url !== '') { + try { + $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 [ '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'), + 'order' => $this->get_order_detail_view($user_id, $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 { diff --git a/src/AKSRT/Shop/CheckoutService.php b/src/AKSRT/Shop/CheckoutService.php index 6f77dda2..6677d7e9 100644 --- a/src/AKSRT/Shop/CheckoutService.php +++ b/src/AKSRT/Shop/CheckoutService.php @@ -184,8 +184,21 @@ class CheckoutService { return ['success' => false, 'message' => __('订单不存在。', 'aksrt')]; } - // 未显式指定时,使用订单创建时选定的支付方式。 - $payment_method = sanitize_key($payment_method ?: (string) ($order['payment_method'] ?? 'balance')); + $status = (string) ($order['status'] ?? ''); + 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 = $gateway_manager ? $gateway_manager->get_gateway($payment_method) : null; @@ -197,8 +210,16 @@ class CheckoutService { return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')]; } - // 携带订单号与摘要,供网关生成支付单。 - $order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number); + $lock = class_exists('\AKSRT\Payment\PaymentAttemptLock') + ? \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 if ($payment_method === 'points') { @@ -227,11 +248,29 @@ class CheckoutService { } // 同步型网关(站内余额):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_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 [ 'success' => true, @@ -240,6 +279,9 @@ class CheckoutService { '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 { @@ -269,11 +311,30 @@ class CheckoutService { } $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_transaction_id' => 'points_' . $order_number, '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 [ 'success' => true, diff --git a/src/AKSRT/Shop/Part1.php b/src/AKSRT/Shop/Part1.php index a0cb346c..1d93eb12 100644 --- a/src/AKSRT/Shop/Part1.php +++ b/src/AKSRT/Shop/Part1.php @@ -28,14 +28,17 @@ trait Part1 { } $ticket_id = \AKSRT\Payment\AfterSale::get_instance()->create([ - 'order_number' => $order_number, - 'user_id' => $user_id, - 'type' => $type, - 'reason' => $reason, - 'amount' => (float) ($order['amount'] ?? 0), - 'note' => sanitize_textarea_field((string) ($payload['note'] ?? '')), + 'order_number' => $order_number, + 'applicant_user_id' => $user_id, + 'type' => $type, + 'reason' => $reason, + 'amount' => (float) ($payload['amount'] ?? $order['amount'] ?? 0), + '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) { return ['success' => false, 'message' => __('售后申请提交失败。', 'aksrt')]; }