Merge pull request 'fix(orders): enforce configured expiry' (#3) from worktree/fix-order-time-limit into main

Reviewed-on: #3
This commit is contained in:
2026-08-12 09:50:44 +00:00
8 changed files with 92 additions and 12 deletions
+3 -3
View File
@@ -216,7 +216,7 @@ trait PayOrders {
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
'payment_method' => 'points',
'note' => is_string($note) ? $note : '',
'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS),
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
]);
if ($order_number === '') {
@@ -362,7 +362,7 @@ trait PayOrders {
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
'payment_method' => 'free',
'note' => is_string($note) ? $note : '',
'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS),
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
]);
if ($order_number === '') {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败。', 'aksrt'), ['status' => 500]);
@@ -448,7 +448,7 @@ trait PayOrders {
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
'payment_method' => $payment_method,
'note' => is_string($note) ? $note : '',
'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS),
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
]);
if ($order_number === '') {
+2 -2
View File
@@ -369,7 +369,7 @@ trait VipServicePurchase {
'discount_tag' => $discount_tag,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$expires_at = gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS);
$expires_at = \AKSRT\Settings\PaymentRuntime::get_order_expiry_at();
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
'user_id' => $user_id,
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP,
@@ -750,7 +750,7 @@ trait VipServicePurchase {
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
'payment_method' => 'points',
'note' => is_string($note) ? $note : '',
'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS),
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
]);
if ($order_number === '') {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
+1 -1
View File
@@ -326,7 +326,7 @@ trait BalanceServiceCharge {
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
'payment_method' => $payment_method,
'note' => is_string($note) ? $note : '',
'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS),
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
]);
if ($order_number === '') {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
+1 -1
View File
@@ -180,7 +180,7 @@ trait BalanceServicePoints {
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
'payment_method' => $payment_method,
'note' => is_string($note) ? $note : '',
'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS),
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
]);
if ($order_number === '') {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
+1 -1
View File
@@ -554,7 +554,7 @@ class ChargeCard {
'status' => class_exists('AKSRT\\Payment\\Order') ? Order::STATUS_PENDING : 'pending',
'payment_method' => 'card_pass',
'note' => is_string($note) ? $note : '',
'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS),
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
]);
}
+2
View File
@@ -125,6 +125,7 @@ trait OrderRepositoryAdmin {
public function get_orders_admin(array $args = [], string $search = ''): array {
global $wpdb;
$this->expire_pending_orders();
[$where, $params] = $this->build_admin_order_filters($args, $search);
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
@@ -147,6 +148,7 @@ trait OrderRepositoryAdmin {
public function get_orders_count(array $args = [], string $search = ''): int {
global $wpdb;
$this->expire_pending_orders();
[$where, $params] = $this->build_admin_order_filters($args, $search);
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
+68 -4
View File
@@ -57,10 +57,14 @@ trait OrderRepositoryCore {
];
$formats = ['%s', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s'];
// Honour configured expiry when callers pass expires_at (shop checkout etc.).
// Pending orders always receive an expiry. Explicit caller values remain
// supported for compatibility, while new flows use the shared runtime policy.
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)) {
$row['expires_at'] = \AKSRT\Settings\PaymentRuntime::get_order_expiry_at();
$formats[] = '%s';
}
$wpdb->insert($this->table(), $row, $formats);
@@ -86,7 +90,7 @@ trait OrderRepositoryCore {
), ARRAY_A);
if ($row) {
return $this->normalize_row($row);
return $this->expire_order_if_due($this->normalize_row($row));
}
return null;
@@ -100,6 +104,7 @@ trait OrderRepositoryCore {
public function get_user_orders(int $user_id, array $args = []): array {
global $wpdb;
$this->expire_pending_orders($user_id);
$parts = $this->get_user_order_query_parts($user_id, $args);
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
@@ -143,6 +148,8 @@ trait OrderRepositoryCore {
return 0;
}
$this->expire_pending_orders($user_id);
$parts = $this->get_user_order_query_parts($user_id, $args);
return (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table()} {$parts['where']}",
@@ -169,6 +176,8 @@ trait OrderRepositoryCore {
];
}
$this->expire_pending_orders($user_id);
$parts = $this->get_user_order_query_parts($user_id, $args);
$pending_statuses = [Order::STATUS_CREATED, Order::STATUS_PENDING];
$paid_status = Order::STATUS_PAID;
@@ -199,6 +208,59 @@ trait OrderRepositoryCore {
];
}
/**
* Close every expired, unpaid order for one user (or all users).
*
* The status condition makes this idempotent and prevents an expiry sweep
* from overwriting a payment completed concurrently by a gateway callback.
*/
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";
$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}";
array_unshift($params, gmdate('Y-m-d H:i:s'));
$result = $wpdb->query($wpdb->prepare($sql, $params));
return is_numeric($result) ? (int) $result : 0;
}
/** @param array<string,mixed> $order @return array<string,mixed> */
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 === '') {
return $order;
}
$expires = strtotime($expires_at . ' UTC');
if ($expires === false || $expires > time()) {
return $order;
}
global $wpdb;
$closed_at = gmdate('Y-m-d H:i:s');
$updated = $wpdb->update(
$this->table(),
['status' => Order::STATUS_EXPIRED, 'cancelled_at' => $closed_at],
['order_number' => (string) $order['order_number'], 'status' => $status],
['%s', '%s'],
['%s', '%s']
);
if ($updated) {
$order['status'] = Order::STATUS_EXPIRED;
$order['cancelled_at'] = $closed_at;
}
return $order;
}
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
@@ -394,6 +456,8 @@ trait OrderRepositoryCore {
public function get_stats(int $user_id = 0): array {
global $wpdb;
$this->expire_pending_orders($user_id > 0 ? $user_id : null);
$where = '';
$params = [];
if ($user_id > 0) {
@@ -703,7 +767,7 @@ trait OrderRepositoryCore {
ARRAY_A
);
if (is_array($row)) {
return $this->normalize_row($row);
return $this->expire_order_if_due($this->normalize_row($row));
}
// Superseded attempts stay in the history array.
@@ -719,7 +783,7 @@ trait OrderRepositoryCore {
ARRAY_A
);
if (is_array($row)) {
return $this->normalize_row($row);
return $this->expire_order_if_due($this->normalize_row($row));
}
// Last resort: {order_number}P{n} prefix heuristic.
+14
View File
@@ -392,6 +392,20 @@ class PaymentRuntime {
];
}
/**
* Return the UTC expiry time for a newly created payable order.
*
* Keeping this in the payment runtime prevents individual checkout
* flows from drifting away from the admin "payment_order_expiry" setting.
*/
public static function get_order_expiry_at(?int $now = null): string {
$settings = self::get_order_settings();
$timestamp = $now ?? time();
$minutes = max(5, min(1440, (int) ($settings['expiry'] ?? 30)));
return gmdate('Y-m-d H:i:s', $timestamp + $minutes * MINUTE_IN_SECONDS);
}
/**
* @return array<string,mixed>
*/