修复支付状态问题

This commit is contained in:
2026-08-24 17:21:06 +08:00
parent 406fb8d6a6
commit 85a124b886
21 changed files with 198 additions and 308 deletions
+45 -64
View File
@@ -46,6 +46,12 @@ trait OrderRepositoryCore {
if ($amount === null) {
return '';
}
$status = array_key_exists('status', $data)
? \AKSRT\Core\DomainModel::normalize_order_status((string) $data['status'])
: \AKSRT\Core\DomainModel::ORDER_STATUS_PENDING;
if ($status === '') {
return '';
}
$row = [
'order_number' => $order_number,
@@ -54,22 +60,21 @@ trait OrderRepositoryCore {
'product_id' => (int) ($data['product_id'] ?? 0),
'amount' => $amount,
'currency' => $currency,
'status' => \AKSRT\Core\DomainModel::normalize_order_status((string) ($data['status'] ?? \AKSRT\Core\DomainModel::ORDER_STATUS_PENDING)),
'status' => $status,
'payment_method' => sanitize_key((string) ($data['payment_method'] ?? '')),
'payment_transaction_id' => sanitize_text_field((string) ($data['payment_transaction_id'] ?? '')),
'created_at' => $data['created_at'] ?? current_time('mysql'),
'paid_at' => $data['paid_at'] ?? null,
'completed_at' => $data['completed_at'] ?? null,
'note' => $note,
];
$formats = ['%s', '%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s'];
$formats = ['%s', '%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s'];
// Pending orders always receive an expiry. Explicit caller values remain
// supported for compatibility, while new flows use the shared runtime policy.
// Pending orders always receive an expiry. Callers may provide a shorter
// expiry; otherwise the shared runtime policy supplies it.
if (array_key_exists('expires_at', $data) && $data['expires_at'] !== null && $data['expires_at'] !== '') {
$row['expires_at'] = (string) $data['expires_at'];
$formats[] = '%s';
} elseif (in_array($row['status'], [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) {
} elseif ($row['status'] === Order::STATUS_PENDING) {
$row['expires_at'] = \AKSRT\Settings\PaymentRuntime::get_order_expiry_at();
$formats[] = '%s';
}
@@ -189,7 +194,7 @@ trait OrderRepositoryCore {
* their existing user-wide semantics, so changing a filter does not change the
* summary cards. The subquery avoids four independent COUNT statements.
*
* @return array{total:int,stats:array{total:int,pending:int,completed:int}}
* @return array{total:int,stats:array{total:int,pending:int,paid:int}}
*/
public function get_user_order_center_summary(int $user_id, array $args = []): array {
global $wpdb;
@@ -197,29 +202,25 @@ trait OrderRepositoryCore {
if ($user_id <= 0) {
return [
'total' => 0,
'stats' => ['total' => 0, 'pending' => 0, 'completed' => 0],
'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0],
];
}
$this->expire_pending_orders($user_id);
$parts = $this->get_user_order_query_parts($user_id, $args);
$pending_statuses = [Order::STATUS_CREATED, Order::STATUS_PENDING];
$completed_statuses = [Order::STATUS_PAID, Order::STATUS_DELIVERED, Order::STATUS_COMPLETED, Order::STATUS_PENDING_REVIEW];
$completed_placeholders = implode(', ', array_fill(0, count($completed_statuses), '%s'));
$known_statuses = OrderCenterQuery::from_array([])->database_statuses();
$known_placeholders = implode(', ', array_fill(0, count($known_statuses), '%s'));
$sql = "SELECT
(SELECT COUNT(*) FROM {$this->table()} {$parts['where']}) AS filtered_total,
COUNT(*) AS known_total,
SUM(CASE WHEN status IN (%s, %s) THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN status IN ({$completed_placeholders}) THEN 1 ELSE 0 END) AS completed
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS paid
FROM {$this->table()}
WHERE user_id = %d AND status IN ({$known_placeholders})";
$params = array_merge(
$parts['params'],
$pending_statuses,
$completed_statuses,
[Order::STATUS_PENDING, Order::STATUS_PAID],
[$user_id],
$known_statuses
);
@@ -230,7 +231,7 @@ trait OrderRepositoryCore {
'stats' => [
'total' => (int) ($row['known_total'] ?? 0),
'pending' => (int) ($row['pending'] ?? 0),
'completed' => (int) ($row['completed'] ?? 0),
'paid' => (int) ($row['paid'] ?? 0),
],
];
}
@@ -244,14 +245,14 @@ 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 <= %s";
$where = "status = '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';
$params[] = $user_id;
}
$sql = "UPDATE {$this->table()} SET status = 'expired', cancelled_at = %s WHERE {$where}";
$sql = "UPDATE {$this->table()} SET status = 'closed', cancelled_at = %s WHERE {$where}";
array_unshift($params, gmdate('Y-m-d H:i:s'));
$result = $wpdb->query($wpdb->prepare($sql, $params));
@@ -262,7 +263,7 @@ trait OrderRepositoryCore {
private function expire_order_if_due(array $order): array {
$status = sanitize_key((string) ($order['status'] ?? ''));
$expires_at = trim((string) ($order['expires_at'] ?? ''));
if (!in_array($status, [Order::STATUS_CREATED, Order::STATUS_PENDING], true) || $expires_at === '') {
if ($status !== Order::STATUS_PENDING || $expires_at === '') {
return $order;
}
@@ -275,13 +276,13 @@ trait OrderRepositoryCore {
$closed_at = gmdate('Y-m-d H:i:s');
$updated = $wpdb->update(
$this->table(),
['status' => Order::STATUS_EXPIRED, 'cancelled_at' => $closed_at],
['status' => Order::STATUS_CLOSED, 'cancelled_at' => $closed_at],
['order_number' => (string) $order['order_number'], 'status' => $status],
['%s', '%s'],
['%s', '%s']
);
if ($updated) {
$order['status'] = Order::STATUS_EXPIRED;
$order['status'] = Order::STATUS_CLOSED;
$order['cancelled_at'] = $closed_at;
}
@@ -298,10 +299,8 @@ trait OrderRepositoryCore {
return false;
}
if (
in_array($new_status, [
\AKSRT\Core\DomainModel::ORDER_STATUS_PAID,
\AKSRT\Core\DomainModel::ORDER_STATUS_COMPLETED,
], true)
$new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID
&& (string) ($preflight['status'] ?? '') !== \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING
&& sanitize_key((string) ($preflight['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
&& class_exists('\AKSRT\Credits\BalanceService')
) {
@@ -382,30 +381,24 @@ trait OrderRepositoryCore {
$order = is_array($order_row) ? $this->normalize_row($order_row) : null;
if (!$order) return false;
$current_status = $order['status'] ?? 'created';
$current_status = (string) ($order['status'] ?? '');
if (empty($extra['force']) && !\AKSRT\Core\DomainModel::can_transition_order_status($current_status, $new_status)) {
if (!\AKSRT\Core\DomainModel::can_transition_order_status($current_status, $new_status)) {
return false;
}
$update_data = ['status' => $new_status];
$format = ['%s'];
// Stamp lifecycle timestamps. Some statuses share a column (e.g. expired → cancelled_at;
// pending_review still counts as a completion-like milestone for the timeline).
$time_fields = [
\AKSRT\Core\DomainModel::ORDER_STATUS_PAID => 'paid_at',
\AKSRT\Core\DomainModel::ORDER_STATUS_DELIVERED => 'delivered_at',
\AKSRT\Core\DomainModel::ORDER_STATUS_COMPLETED => 'completed_at',
\AKSRT\Core\DomainModel::ORDER_STATUS_PENDING_REVIEW => 'completed_at',
\AKSRT\Core\DomainModel::ORDER_STATUS_CANCELLED => 'cancelled_at',
\AKSRT\Core\DomainModel::ORDER_STATUS_EXPIRED => 'cancelled_at',
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED => 'refunded_at',
\AKSRT\Core\DomainModel::ORDER_STATUS_PAID => 'paid_at',
\AKSRT\Core\DomainModel::ORDER_STATUS_CLOSED => 'cancelled_at',
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED => 'refunded_at',
];
if (isset($time_fields[$new_status])) {
$field = $time_fields[$new_status];
// Do not overwrite an existing timestamp (idempotent callbacks / force updates).
// Do not overwrite an existing timestamp on repeated callbacks.
$existing = is_string($order[$field] ?? null) ? trim((string) $order[$field]) : '';
if ($existing === '' || str_starts_with($existing, '0000-00-00')) {
$update_data[$field] = current_time('mysql');
@@ -413,13 +406,8 @@ trait OrderRepositoryCore {
}
}
// When jumping past paid (force admin transitions), backfill paid_at so the
// progress chain is never stuck on "订单创建" alone.
if (
in_array($new_status, [
\AKSRT\Core\DomainModel::ORDER_STATUS_DELIVERED,
\AKSRT\Core\DomainModel::ORDER_STATUS_COMPLETED,
\AKSRT\Core\DomainModel::ORDER_STATUS_PENDING_REVIEW,
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING,
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED,
], true)
@@ -487,7 +475,7 @@ trait OrderRepositoryCore {
];
// 到账事件:让充值/VIP/商品履约等挂接(单一履约入口)。
// 仅在「首次」转为 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_PENDING) {
$paid_order = array_merge($order, [
'status' => $new_status,
'payment_method' => $update_data['payment_method'] ?? ($order['payment_method'] ?? ''),
@@ -568,8 +556,7 @@ trait OrderRepositoryCore {
/**
* Delete closed (cancelled/expired) orders older than $days days.
* Mirrors the legacy pay module clear_order(14).
* Delete closed orders older than $days days.
*
* @return int Number of rows deleted.
*/
@@ -579,10 +566,10 @@ trait OrderRepositoryCore {
$days = max(1, min(3650, $days));
$table = $this->table();
$cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
// Prefer cancelled_at when set; fall back to created_at for older rows.
$sql = "DELETE FROM {$table}
WHERE status IN ('cancelled', 'expired')
AND COALESCE(cancelled_at, created_at) < %s";
WHERE status = 'closed'
AND cancelled_at IS NOT NULL
AND cancelled_at < %s";
$deleted = $wpdb->query($wpdb->prepare($sql, $cutoff));
return $deleted === false ? 0 : (int) $deleted;
}
@@ -612,24 +599,21 @@ trait OrderRepositoryCore {
$this->expire_pending_orders($user_id > 0 ? $user_id : null);
$where = '';
$params = [];
if ($user_id > 0) {
$where = 'WHERE user_id = %d';
$params[] = $user_id;
}
$where = "WHERE status IN ('pending', 'paid', 'refunding', 'refunded', 'closed')";
$params = [];
if ($user_id > 0) {
$where .= ' AND user_id = %d';
$params[] = $user_id;
}
$sql = "SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid,
SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) as delivered,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'pending_review' THEN 1 ELSE 0 END) as pending_review,
SUM(CASE WHEN status = 'refunding' THEN 1 ELSE 0 END) as refunding,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled,
SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) as refunded,
SUM(CASE WHEN status IN ('paid','delivered','completed','pending_review') THEN amount ELSE 0 END) as revenue
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed,
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) as revenue
FROM {$this->table()} {$where}";
if (!empty($params)) {
@@ -642,12 +626,9 @@ trait OrderRepositoryCore {
'total' => (int) ($row['total'] ?? 0),
'pending' => (int) ($row['pending'] ?? 0),
'paid' => (int) ($row['paid'] ?? 0),
'delivered' => (int) ($row['delivered'] ?? 0),
'completed' => (int) ($row['completed'] ?? 0),
'pending_review' => (int) ($row['pending_review'] ?? 0),
'refunding' => (int) ($row['refunding'] ?? 0),
'cancelled' => (int) ($row['cancelled'] ?? 0),
'refunded' => (int) ($row['refunded'] ?? 0),
'closed' => (int) ($row['closed'] ?? 0),
'revenue' => $this->cash_decimal((string) ($row['revenue'] ?? '0')),
];
}
@@ -727,7 +708,7 @@ trait OrderRepositoryCore {
if ($row['amount'] === null) {
$row['amount'] = (string) ($row['currency'] ?? '') === 'points' ? 0 : '0.00';
}
$row['status'] = \AKSRT\Core\DomainModel::normalize_order_status((string) ($row['status'] ?? ''));
$row['status'] = sanitize_key((string) ($row['status'] ?? ''));
$row['meta_payload'] = [];
if (!empty($row['note']) && is_string($row['note'])) {