From 406fb8d6a648fe86ac431d4c97171b8fa05fdd6c Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 24 Aug 2026 16:52:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=A4=E4=B8=AA=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Credits/BalanceServiceCharge.php | 43 +++++++++++++++---- src/Payment/Gateway.php | 10 +++++ src/Payment/OrderRepositoryCore.php | 9 ++-- .../asset-ledger-contract.test.cjs | 12 ++++++ 4 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/Credits/BalanceServiceCharge.php b/src/Credits/BalanceServiceCharge.php index 098c2c8f..8bdc66f4 100644 --- a/src/Credits/BalanceServiceCharge.php +++ b/src/Credits/BalanceServiceCharge.php @@ -40,23 +40,50 @@ trait BalanceServiceCharge { $provider_transaction_id = sanitize_text_field($provider_transaction_id); $locked_status = ''; $locked_note = []; - $guard = function () use ($order_number, $user_id, &$locked_status, &$locked_note) { + $locked_payment_method = ''; + $locked_transaction_id = ''; + $locked_paid_at = ''; + $locked_completed_at = ''; + $guard = function () use ($order_number, $user_id, $credit, $provider_transaction_id, &$locked_status, &$locked_note, &$locked_payment_method, &$locked_transaction_id, &$locked_paid_at, &$locked_completed_at) { global $wpdb; $table = \AKSRT\Core\DBMigrator::table('order'); $current = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE order_number = %s FOR UPDATE", $order_number), ARRAY_A); if (!is_array($current) || (int) ($current['user_id'] ?? 0) !== $user_id || sanitize_key((string) ($current['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE - || !in_array((string) ($current['status'] ?? ''), [\AKSRT\Payment\Order::STATUS_CREATED, \AKSRT\Payment\Order::STATUS_PENDING], true) + || !in_array((string) ($current['status'] ?? ''), [ + \AKSRT\Payment\Order::STATUS_CREATED, + \AKSRT\Payment\Order::STATUS_PENDING, + \AKSRT\Payment\Order::STATUS_PAID, + \AKSRT\Payment\Order::STATUS_COMPLETED, + ], true) ) { return new \WP_Error('aksrt_order_confirm_failed', __('订单状态确认失败', 'aksrt'), ['status' => 409]); } $locked_status = (string) $current['status']; $decoded = json_decode((string) ($current['note'] ?? ''), true); $locked_note = is_array($decoded) ? $decoded : []; + $current_credit_raw = $locked_note['credit_amount'] ?? null; + try { + $current_credit = is_string($current_credit_raw) + ? \AKSRT\Ledger\AssetAmount::balance($current_credit_raw) + : null; + } catch (\InvalidArgumentException $e) { + $current_credit = null; + } + if (!$current_credit || $current_credit->minor() !== $credit->minor()) { + return new \WP_Error('aksrt_recharge_amount_changed', __('充值到账金额已变化', 'aksrt'), ['status' => 409]); + } + $locked_payment_method = sanitize_key((string) ($current['payment_method'] ?? '')); + $locked_transaction_id = sanitize_text_field((string) ($current['payment_transaction_id'] ?? '')); + $locked_paid_at = trim((string) ($current['paid_at'] ?? '')); + $locked_completed_at = trim((string) ($current['completed_at'] ?? '')); + if ($provider_transaction_id !== '' && $locked_transaction_id !== '' && !hash_equals($locked_transaction_id, $provider_transaction_id)) { + return new \WP_Error('aksrt_recharge_transaction_conflict', __('充值交易号不一致', 'aksrt'), ['status' => 409]); + } return true; }; - $mutation = function () use ($order_number, $payment_method, $provider_transaction_id, $status_note, $credit, &$locked_status, &$locked_note) { + $mutation = function () use ($order_number, $payment_method, $provider_transaction_id, $status_note, $credit, &$locked_status, &$locked_note, &$locked_payment_method, &$locked_transaction_id, &$locked_paid_at, &$locked_completed_at) { global $wpdb; $table = \AKSRT\Core\DBMigrator::table('order'); $locked_note['ledger_credited'] = 1; @@ -67,13 +94,13 @@ trait BalanceServiceCharge { $now = current_time('mysql'); $updated = $wpdb->update($table, [ 'status' => \AKSRT\Payment\Order::STATUS_COMPLETED, - 'payment_method' => $payment_method, - 'payment_transaction_id' => $provider_transaction_id, - 'paid_at' => $now, - 'completed_at' => $now, + 'payment_method' => $payment_method !== '' ? $payment_method : $locked_payment_method, + 'payment_transaction_id' => $provider_transaction_id !== '' ? $provider_transaction_id : $locked_transaction_id, + 'paid_at' => $locked_paid_at !== '' ? $locked_paid_at : $now, + 'completed_at' => $locked_completed_at !== '' ? $locked_completed_at : $now, 'note' => wp_json_encode($locked_note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ], ['order_number' => $order_number, 'status' => $locked_status], ['%s', '%s', '%s', '%s', '%s', '%s'], ['%s', '%s']); - return $updated === 1 ? true : new \WP_Error('aksrt_order_confirm_failed', __('订单状态确认失败', 'aksrt'), ['status' => 409]); + return $updated !== false ? true : new \WP_Error('aksrt_order_confirm_failed', __('订单状态确认失败', 'aksrt'), ['status' => 409]); }; $result = \AKSRT\Ledger\LedgerService::get_instance()->credit( $user_id, diff --git a/src/Payment/Gateway.php b/src/Payment/Gateway.php index cb809152..c5a6e3bf 100644 --- a/src/Payment/Gateway.php +++ b/src/Payment/Gateway.php @@ -386,6 +386,16 @@ class Gateway { // 用户可能重复付款,不能静默 ACK 为已正确同步。 if (in_array($current, ['paid', 'delivered', 'completed'], true)) { $stored_transaction = sanitize_text_field((string) ($order['payment_transaction_id'] ?? '')); + if (sanitize_key((string) ($order['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) { + if ($stored_transaction !== '' && !hash_equals($stored_transaction, $transaction_id)) { + return false; + } + return $repo->update_status($order_number, Order::STATUS_PAID, [ + 'payment_method' => $gateway_id, + 'payment_transaction_id' => $transaction_id, + 'note' => __('支付网关回调补充确认到账。', 'aksrt'), + ]); + } return $stored_transaction !== '' && hash_equals($stored_transaction, $transaction_id); } // 只允许从未支付态完成 diff --git a/src/Payment/OrderRepositoryCore.php b/src/Payment/OrderRepositoryCore.php index 0392b24c..a94ad38a 100644 --- a/src/Payment/OrderRepositoryCore.php +++ b/src/Payment/OrderRepositoryCore.php @@ -244,7 +244,7 @@ trait OrderRepositoryCore { public function expire_pending_orders(?int $user_id = null): int { global $wpdb; - $where = "status IN ('created', 'pending') AND expires_at IS NOT NULL AND expires_at <> '' AND expires_at <= %s"; + $where = "status IN ('created', 'pending') AND expires_at IS NOT NULL AND expires_at <= %s"; $params = [gmdate('Y-m-d H:i:s')]; if ($user_id !== null && $user_id > 0) { $where .= ' AND user_id = %d'; @@ -298,7 +298,10 @@ trait OrderRepositoryCore { return false; } if ( - $new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID + in_array($new_status, [ + \AKSRT\Core\DomainModel::ORDER_STATUS_PAID, + \AKSRT\Core\DomainModel::ORDER_STATUS_COMPLETED, + ], true) && sanitize_key((string) ($preflight['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE && class_exists('\AKSRT\Credits\BalanceService') ) { @@ -579,7 +582,7 @@ trait OrderRepositoryCore { // Prefer cancelled_at when set; fall back to created_at for older rows. $sql = "DELETE FROM {$table} WHERE status IN ('cancelled', 'expired') - AND COALESCE(NULLIF(cancelled_at, ''), created_at) < %s"; + AND COALESCE(cancelled_at, created_at) < %s"; $deleted = $wpdb->query($wpdb->prepare($sql, $cutoff)); return $deleted === false ? 0 : (int) $deleted; } diff --git a/tests/widget-system/asset-ledger-contract.test.cjs b/tests/widget-system/asset-ledger-contract.test.cjs index 7a8052ff..99d2b5dd 100644 --- a/tests/widget-system/asset-ledger-contract.test.cjs +++ b/tests/widget-system/asset-ledger-contract.test.cjs @@ -204,3 +204,15 @@ test('asset purchases share the order center and refresh every wallet surface', assert.match(header, /data-aksrt-wallet-value/) assert.match(header, /data-aksrt-points-value/) }) + +test('recharge completion always credits the ledger and expiry SQL is strict-mode safe', () => { + const repository = fs.readFileSync(path.join(root, 'src', 'Payment', 'OrderRepositoryCore.php'), 'utf8') + const charge = fs.readFileSync(path.join(root, 'src', 'Credits', 'BalanceServiceCharge.php'), 'utf8') + const gateway = fs.readFileSync(path.join(root, 'src', 'Payment', 'Gateway.php'), 'utf8') + + assert.match(repository, /in_array\(\$new_status,\s*\[\s*\\AKSRT\\Core\\DomainModel::ORDER_STATUS_PAID,\s*\\AKSRT\\Core\\DomainModel::ORDER_STATUS_COMPLETED,\s*\],\s*true\)/) + assert.match(charge, /Order::STATUS_CREATED,\s*\\AKSRT\\Payment\\Order::STATUS_PENDING,\s*\\AKSRT\\Payment\\Order::STATUS_PAID,\s*\\AKSRT\\Payment\\Order::STATUS_COMPLETED/) + assert.match(gateway, /ORDER_PRODUCT_RECHARGE[\s\S]*?update_status\(\$order_number, Order::STATUS_PAID/) + assert.doesNotMatch(repository, /expires_at\s*<>\s*''/) + assert.doesNotMatch(repository, /NULLIF\(cancelled_at,\s*''\)/) +})