1142 lines
43 KiB
PHP
1142 lines
43 KiB
PHP
<?php
|
||
/**
|
||
* @package AKSRT
|
||
*/
|
||
|
||
namespace AKSRT\Payment;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
trait OrderRepositoryCore {
|
||
|
||
/**
|
||
* 建单时对「订单号唯一键冲突」的重试次数。
|
||
*
|
||
* 统一流水号规则为 {YmdHis}{8 位随机数},同一秒内的取值空间为 10^8,
|
||
* 高并发下单仍可能因随机后缀碰撞触发 uniq_order_number 冲突。资金流水侧
|
||
* (FinancialFlowService::record)已有等价的 5 次重试,订单侧此前缺失,
|
||
* 碰撞时会直接把错误抛给用户,故在此对齐。
|
||
*/
|
||
private const ORDER_NUMBER_MAX_ATTEMPTS = 5;
|
||
|
||
/**
|
||
* 调用方自带订单号的合法形态:全数字,14 位时间前缀 + 定长数字后缀。
|
||
*
|
||
* 号码只有一种来源(本类的 allocate_order_number()),所以自带号必须落在
|
||
* 分配器能产出的范围内:22 位是当前规则(见 BusinessNumber),20 位是扩位
|
||
* 之前的历史规则——导入旧数据、旧单重放仍可能带它进来,故一并放行。
|
||
* 带前缀、UUID、非数字、超长等一律视为不受控输入:弃用并回落到分配器,
|
||
* 避免把异常长度写进 VARCHAR(32) 列后再去污染检索与对账。
|
||
*/
|
||
private const ORDER_NUMBER_PATTERN = '/^\d{20}$|^\d{22}$/';
|
||
|
||
|
||
|
||
private function table(): string {
|
||
return \AKSRT\Core\DBMigrator::table('order');
|
||
}
|
||
|
||
|
||
|
||
/* ------------------------------------------------------------------
|
||
* CRUD
|
||
* ----------------------------------------------------------------*/
|
||
|
||
/**
|
||
* 创建订单(写入表)
|
||
*/
|
||
public function create(array $data): string {
|
||
global $wpdb;
|
||
|
||
// [防刷机制]下单限制(后台「扩展&增强 → 网站安全」),管理员不受限,未登录按 IP。
|
||
if (class_exists('\AKSRT\Extensions\SecurityHardening') && !current_user_can('manage_options')) {
|
||
$security = \AKSRT\Settings\ExtensionRuntime::get_security_settings();
|
||
$order_user_id = (int) ($data['user_id'] ?? 0);
|
||
$brush_subject = $order_user_id > 0
|
||
? (string) $order_user_id
|
||
: (function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||
$brush = \AKSRT\Extensions\SecurityHardening::brush_allow('order', $brush_subject, (int) $security['security_brush_order_10min'], (int) $security['security_brush_order_day']);
|
||
if ($brush !== true) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
// 自带订单号先过形态校验:只接受分配器能产出的「全数字、20 或 22 位」。
|
||
// 不合规的一律弃用并回落到分配器(WP_DEBUG 下留痕),既不把非法长度写进库,
|
||
// 也不因此抛错中断调用方。
|
||
$declared_number = isset($data['order_number']) && is_scalar($data['order_number'])
|
||
? trim((string) $data['order_number'])
|
||
: '';
|
||
$caller_supplied = $declared_number !== ''
|
||
&& (bool) preg_match(self::ORDER_NUMBER_PATTERN, $declared_number);
|
||
if ($declared_number !== '' && !$caller_supplied && defined('WP_DEBUG') && WP_DEBUG) {
|
||
error_log(sprintf('[AKSRT Order] 弃用不合规的自带订单号:%s', $declared_number));
|
||
}
|
||
$order_number = $caller_supplied ? $declared_number : $this->allocate_order_number();
|
||
|
||
// Preserve JSON pay meta (VIP/content/coupon). Only strip tags for plain text notes.
|
||
$note_raw = (string) ($data['note'] ?? '');
|
||
$note_trim = trim($note_raw);
|
||
if ($note_trim !== '' && ($note_trim[0] === '{' || $note_trim[0] === '[')) {
|
||
$decoded = json_decode($note_trim, true);
|
||
$note = is_array($decoded)
|
||
? (string) wp_json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||
: sanitize_textarea_field($note_raw);
|
||
} else {
|
||
$note = sanitize_textarea_field($note_raw);
|
||
}
|
||
|
||
$currency = sanitize_key((string) ($data['currency'] ?? 'CNY'));
|
||
$amount = $this->normalize_order_amount($data['amount'] ?? null, $currency);
|
||
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,
|
||
'user_id' => (int) ($data['user_id'] ?? 0),
|
||
'product_type' => sanitize_key((string) ($data['product_type'] ?? '')),
|
||
'product_id' => (int) ($data['product_id'] ?? 0),
|
||
'amount' => $amount,
|
||
'currency' => $currency,
|
||
'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,
|
||
'note' => $note,
|
||
];
|
||
$formats = ['%s', '%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s'];
|
||
|
||
// 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 ($row['status'] === Order::STATUS_PENDING) {
|
||
$row['expires_at'] = \AKSRT\Settings\PaymentRuntime::get_order_expiry_at();
|
||
$formats[] = '%s';
|
||
}
|
||
|
||
if (!empty($data['request_key'])) {
|
||
$row['request_key'] = substr(sanitize_text_field((string) $data['request_key']), 0, 191);
|
||
$formats[] = '%s';
|
||
}
|
||
|
||
// 只有本方法自己分配的号码才允许换号重试。调用方显式传入的合规订单号
|
||
// (导入、补单、幂等重放)必须原样使用,冲突就应失败,不得悄悄换号。
|
||
$max_attempts = $caller_supplied ? 1 : self::ORDER_NUMBER_MAX_ATTEMPTS;
|
||
|
||
for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
|
||
$row['order_number'] = $order_number;
|
||
$wpdb->insert($this->table(), $row, $formats);
|
||
|
||
if ($wpdb->insert_id) {
|
||
return $order_number;
|
||
}
|
||
|
||
// 只对订单号唯一键冲突换号重试;request_key 冲突等属于调用方的
|
||
// 幂等语义,换号重试会掩盖重复提交,必须原样失败。
|
||
if ($attempt >= $max_attempts
|
||
|| stripos((string) $wpdb->last_error, 'uniq_order_number') === false
|
||
) {
|
||
return '';
|
||
}
|
||
|
||
$order_number = $this->allocate_order_number();
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 获取单个订单
|
||
*/
|
||
public function get(string $order_number): ?array {
|
||
global $wpdb;
|
||
|
||
$row = $wpdb->get_row($wpdb->prepare(
|
||
"SELECT * FROM {$this->table()} WHERE order_number = %s",
|
||
$order_number
|
||
), ARRAY_A);
|
||
|
||
if ($row) {
|
||
return $this->expire_order_if_due($this->normalize_row($row));
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
public function get_by_request_key(string $request_key): ?array {
|
||
global $wpdb;
|
||
$request_key = substr(sanitize_text_field($request_key), 0, 191);
|
||
if ($request_key === '') {
|
||
return null;
|
||
}
|
||
$row = $wpdb->get_row($wpdb->prepare(
|
||
"SELECT * FROM {$this->table()} WHERE request_key = %s",
|
||
$request_key
|
||
), ARRAY_A);
|
||
return is_array($row) ? $this->normalize_row($row) : null;
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 获取用户订单列表
|
||
*/
|
||
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));
|
||
$params = $parts['params'];
|
||
$params[] = $limit;
|
||
$params[] = $offset;
|
||
|
||
$sql = "SELECT * FROM {$this->table()} {$parts['where']} ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||
$rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||
|
||
return array_map([$this, 'normalize_row'], (array) $rows);
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 获取单个用户订单。
|
||
*/
|
||
public function get_user_order(int $user_id, string $order_number): ?array {
|
||
$order = $this->get($order_number);
|
||
if (!$order) {
|
||
return null;
|
||
}
|
||
|
||
if ($user_id > 0 && (int) ($order['user_id'] ?? 0) !== $user_id) {
|
||
return null;
|
||
}
|
||
|
||
return $order;
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 获取用户订单总数。
|
||
*/
|
||
public function count_user_orders(int $user_id, array $args = []): int {
|
||
global $wpdb;
|
||
|
||
if ($user_id <= 0) {
|
||
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']}",
|
||
$parts['params']
|
||
));
|
||
}
|
||
|
||
/**
|
||
* Get the filtered list total and the user's order-center counters in one query.
|
||
*
|
||
* The filtered total mirrors the list query while the counters intentionally keep
|
||
* 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<string,int>}
|
||
*/
|
||
public function get_user_order_center_summary(int $user_id, array $args = []): array {
|
||
global $wpdb;
|
||
|
||
if ($user_id <= 0) {
|
||
return [
|
||
'total' => 0,
|
||
'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0, 'refunded' => 0, 'closed' => 0, 'after_sale_pending' => 0],
|
||
];
|
||
}
|
||
|
||
$this->expire_pending_orders($user_id);
|
||
|
||
$parts = $this->get_user_order_query_parts($user_id, $args);
|
||
$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 = %s THEN 1 ELSE 0 END) AS pending,
|
||
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS paid,
|
||
SUM(CASE WHEN status = 'refunding' THEN 1 ELSE 0 END) AS paid_legacy,
|
||
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS refunded,
|
||
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS closed
|
||
FROM {$this->table()}
|
||
WHERE user_id = %d AND status IN ({$known_placeholders})";
|
||
$params = array_merge(
|
||
$parts['params'],
|
||
[Order::STATUS_PENDING, Order::STATUS_PAID, Order::STATUS_REFUNDED, Order::STATUS_CLOSED],
|
||
[$user_id],
|
||
$known_statuses
|
||
);
|
||
$row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A);
|
||
|
||
return [
|
||
'total' => (int) ($row['filtered_total'] ?? 0),
|
||
'stats' => [
|
||
'total' => (int) ($row['known_total'] ?? 0),
|
||
'pending' => (int) ($row['pending'] ?? 0),
|
||
'paid' => (int) ($row['paid'] ?? 0) + (int) ($row['paid_legacy'] ?? 0),
|
||
'refunded' => (int) ($row['refunded'] ?? 0),
|
||
'closed' => (int) ($row['closed'] ?? 0),
|
||
'after_sale_pending' => class_exists(AfterSaleService::class) ? AfterSaleService::get_instance()->count_user_open($user_id) : 0,
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 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 = '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 = 'closed', 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 ($status !== Order::STATUS_PENDING || $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_CLOSED, 'cancelled_at' => $closed_at],
|
||
['order_number' => (string) $order['order_number'], 'status' => $status],
|
||
['%s', '%s'],
|
||
['%s', '%s']
|
||
);
|
||
if ($updated) {
|
||
$order['status'] = Order::STATUS_CLOSED;
|
||
$order['cancelled_at'] = $closed_at;
|
||
}
|
||
|
||
return $order;
|
||
}
|
||
|
||
|
||
|
||
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
|
||
global $wpdb;
|
||
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED && empty($extra['allow_refund'])) {
|
||
return false;
|
||
}
|
||
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED
|
||
&& (!class_exists(AfterSaleService::class) || !AfterSaleService::get_instance()->can_finalize_order_refund((int) ($extra['after_sale_id'] ?? 0), $order_number))
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
$preflight = $this->get($order_number);
|
||
if (!$preflight) {
|
||
return false;
|
||
}
|
||
if (
|
||
$new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID
|
||
&& (string) ($preflight['status'] ?? '') !== 'refunding'
|
||
&& sanitize_key((string) ($preflight['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|
||
&& class_exists('\AKSRT\Credits\BalanceService')
|
||
) {
|
||
return \AKSRT\Credits\BalanceService::get_instance()->complete_recharge_payment(
|
||
$preflight,
|
||
sanitize_key((string) ($extra['payment_method'] ?? $preflight['payment_method'] ?? '')),
|
||
sanitize_text_field((string) ($extra['payment_transaction_id'] ?? $preflight['payment_transaction_id'] ?? '')),
|
||
(string) ($extra['note'] ?? '')
|
||
);
|
||
}
|
||
|
||
$ledger_active = class_exists('\AKSRT\Ledger\LedgerService')
|
||
&& \AKSRT\Ledger\LedgerService::get_instance()->transaction_active();
|
||
$root_call = $this->status_transaction_depth === 0;
|
||
if ($root_call) {
|
||
$this->status_after_commit_events = [];
|
||
}
|
||
$transaction_managed = !empty($extra['transaction_managed']);
|
||
$owns_transaction = $root_call && !$ledger_active && !$transaction_managed;
|
||
if ($owns_transaction && $wpdb->query('START TRANSACTION') === false) {
|
||
return false;
|
||
}
|
||
|
||
$this->status_transaction_depth++;
|
||
try {
|
||
$result = $this->update_status_in_transaction($order_number, $new_status, $extra);
|
||
if (!$result) {
|
||
if ($owns_transaction) {
|
||
$wpdb->query('ROLLBACK');
|
||
}
|
||
if ($root_call) {
|
||
$this->status_after_commit_events = [];
|
||
}
|
||
return false;
|
||
}
|
||
if ($owns_transaction && $wpdb->query('COMMIT') === false) {
|
||
$wpdb->query('ROLLBACK');
|
||
$this->status_after_commit_events = [];
|
||
return false;
|
||
}
|
||
if ($root_call) {
|
||
$events = $this->status_after_commit_events;
|
||
$this->status_after_commit_events = [];
|
||
if ($owns_transaction) {
|
||
$this->dispatch_status_events($events);
|
||
} elseif ($ledger_active) {
|
||
\AKSRT\Ledger\LedgerService::get_instance()->after_commit(function () use ($events): void {
|
||
$this->dispatch_status_events($events);
|
||
});
|
||
} elseif ($transaction_managed) {
|
||
$this->status_after_commit_events = $events;
|
||
}
|
||
}
|
||
return true;
|
||
} catch (\Throwable $e) {
|
||
if ($owns_transaction) {
|
||
$wpdb->query('ROLLBACK');
|
||
}
|
||
if ($root_call) {
|
||
$this->status_after_commit_events = [];
|
||
}
|
||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||
error_log('[AKSRT Order] transactional status update failed: ' . $e->getMessage());
|
||
}
|
||
return false;
|
||
} finally {
|
||
$this->status_transaction_depth--;
|
||
}
|
||
}
|
||
|
||
private function update_status_in_transaction(string $order_number, string $new_status, array $extra = []): bool {
|
||
global $wpdb;
|
||
$ledger_active = class_exists('\AKSRT\Ledger\LedgerService')
|
||
&& \AKSRT\Ledger\LedgerService::get_instance()->transaction_active();
|
||
|
||
$valid = \AKSRT\Core\DomainModel::order_status_labels();
|
||
if (!isset($valid[$new_status])) return false;
|
||
|
||
$order_row = $wpdb->get_row($wpdb->prepare(
|
||
"SELECT * FROM {$this->table()} WHERE order_number = %s FOR UPDATE",
|
||
$order_number
|
||
), ARRAY_A);
|
||
$database_status = is_array($order_row) ? sanitize_key((string) ($order_row['status'] ?? '')) : '';
|
||
$order = is_array($order_row) ? $this->normalize_row($order_row) : null;
|
||
if (!$order) return false;
|
||
|
||
$current_status = (string) ($order['status'] ?? '');
|
||
if ($database_status === 'refunding' && $new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) {
|
||
$current_status = \AKSRT\Core\DomainModel::ORDER_STATUS_PAID;
|
||
}
|
||
|
||
if (!\AKSRT\Core\DomainModel::can_transition_order_status($current_status, $new_status)) {
|
||
return false;
|
||
}
|
||
|
||
$update_data = ['status' => $new_status];
|
||
$format = ['%s'];
|
||
|
||
$time_fields = [
|
||
\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 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');
|
||
$format[] = '%s';
|
||
}
|
||
}
|
||
|
||
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) {
|
||
$existing_paid = is_string($order['paid_at'] ?? null) ? trim((string) $order['paid_at']) : '';
|
||
if ($existing_paid === '' || str_starts_with($existing_paid, '0000-00-00')) {
|
||
if (!isset($update_data['paid_at'])) {
|
||
$update_data['paid_at'] = current_time('mysql');
|
||
$format[] = '%s';
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!empty($extra['payment_method'])) {
|
||
$update_data['payment_method'] = sanitize_key($extra['payment_method']);
|
||
$format[] = '%s';
|
||
}
|
||
|
||
if (!empty($extra['payment_transaction_id'])) {
|
||
$update_data['payment_transaction_id'] = sanitize_text_field($extra['payment_transaction_id']);
|
||
$format[] = '%s';
|
||
}
|
||
|
||
if (!empty($extra['note'])) {
|
||
// Preserve JSON pay meta (coupon/topic_pay/content_pay/…); stash human text as status_note.
|
||
$status_note = sanitize_textarea_field((string) $extra['note']);
|
||
$raw_note = isset($order['note']) && is_string($order['note']) ? $order['note'] : '';
|
||
$decoded_note = $raw_note !== '' ? json_decode($raw_note, true) : null;
|
||
if (is_array($decoded_note)) {
|
||
$decoded_note['status_note'] = $status_note;
|
||
$merged = wp_json_encode($decoded_note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
$update_data['note'] = is_string($merged) ? $merged : $status_note;
|
||
} else {
|
||
$update_data['note'] = $status_note;
|
||
}
|
||
$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' => $database_status],
|
||
$format,
|
||
['%s', '%s']
|
||
);
|
||
|
||
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) {
|
||
$this->status_after_commit_events[] = [
|
||
'hook' => 'aksrt_order_status_changed',
|
||
'args' => [$order_number, $new_status, array_merge($order, $update_data)],
|
||
];
|
||
// 到账事件:让充值/VIP/商品履约等挂接(单一履约入口)。
|
||
// 仅在「首次」转为 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'] ?? ''),
|
||
'payment_transaction_id' => $update_data['payment_transaction_id'] ?? ($order['payment_transaction_id'] ?? ''),
|
||
]);
|
||
if (isset($update_data['note'])) {
|
||
// Keep pre-update JSON payload for listeners (coupon burn etc.);
|
||
// only attach status_note if callers mutated note.
|
||
$pre_meta = [];
|
||
$pre_raw = isset($order['note']) && is_string($order['note']) ? $order['note'] : '';
|
||
if ($pre_raw !== '') {
|
||
$pre_decoded = json_decode($pre_raw, true);
|
||
if (is_array($pre_decoded)) {
|
||
$pre_meta = $pre_decoded;
|
||
}
|
||
}
|
||
if ($pre_meta !== []) {
|
||
$paid_order['meta_payload'] = $pre_meta;
|
||
// Prefer merged DB note (JSON + status_note) for downstream reads.
|
||
$paid_order['note'] = $update_data['note'];
|
||
}
|
||
}
|
||
|
||
$this->status_after_commit_events[] = [
|
||
'hook' => 'aksrt_order_commission',
|
||
'args' => [$order_number, $new_status, $paid_order],
|
||
];
|
||
|
||
$product_type = sanitize_key((string) ($paid_order['product_type'] ?? ''));
|
||
if (\AKSRT\Core\DomainModel::is_resource_product_type($product_type) && class_exists('\AKSRT\Content\Pay')) {
|
||
$fulfilled = \AKSRT\Content\Pay::get_instance()->fulfill_paid_order($order_number, $paid_order);
|
||
if (is_wp_error($fulfilled)) {
|
||
return false;
|
||
}
|
||
}
|
||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP && class_exists('\AKSRT\Content\VipService')) {
|
||
$fulfilled = \AKSRT\Content\VipService::get_instance()->fulfill_paid_order($order_number, $paid_order);
|
||
if (is_wp_error($fulfilled)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 订单到账。
|
||
*
|
||
* @param string $order_number 订单号
|
||
* @param array $paid_order 订单数据(status=paid)
|
||
*/
|
||
$this->status_after_commit_events[] = [
|
||
'hook' => 'aksrt_order_paid',
|
||
'args' => [$order_number, $paid_order],
|
||
];
|
||
}
|
||
|
||
}
|
||
|
||
return $result !== false;
|
||
}
|
||
|
||
/** @param array<int,array{hook:string,args:array<int,mixed>}> $events */
|
||
private function dispatch_status_events(array $events): void {
|
||
foreach ($events as $event) {
|
||
try {
|
||
do_action((string) $event['hook'], ...$event['args']);
|
||
} catch (\Throwable $e) {
|
||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||
error_log('[AKSRT Order Event] ' . $e->getMessage());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* Delete closed orders older than $days days.
|
||
*
|
||
* @return int Number of rows deleted.
|
||
*/
|
||
public function clear_closed_orders(int $days = 14): int {
|
||
global $wpdb;
|
||
|
||
$days = max(1, min(3650, $days));
|
||
$table = $this->table();
|
||
$cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
|
||
|
||
$deleted = $wpdb->query($wpdb->prepare(
|
||
"DELETE FROM {$table}
|
||
WHERE status = 'closed'
|
||
AND cancelled_at IS NOT NULL
|
||
AND cancelled_at < %s",
|
||
$cutoff
|
||
));
|
||
return $deleted === false ? 0 : (int) $deleted;
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* Hard-delete a single order by order_number (admin hygiene).
|
||
*/
|
||
public function delete_order(string $order_number): bool {
|
||
global $wpdb;
|
||
$order_number = sanitize_text_field($order_number);
|
||
if ($order_number === '') {
|
||
return false;
|
||
}
|
||
$ok = $wpdb->delete($this->table(), ['order_number' => $order_number], ['%s']);
|
||
return $ok !== false && $ok > 0;
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 获取订单统计
|
||
*/
|
||
public function get_stats(int $user_id = 0): array {
|
||
global $wpdb;
|
||
|
||
$this->expire_pending_orders($user_id > 0 ? $user_id : null);
|
||
|
||
$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 IN ('paid','refunding') THEN 1 ELSE 0 END) as paid,
|
||
SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) as refunded,
|
||
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed,
|
||
SUM(CASE WHEN status IN ('paid','refunding') THEN amount ELSE 0 END) as revenue
|
||
FROM {$this->table()} {$where}";
|
||
|
||
if (!empty($params)) {
|
||
$row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A);
|
||
} else {
|
||
$row = $wpdb->get_row($sql, ARRAY_A);
|
||
}
|
||
|
||
return [
|
||
'total' => (int) ($row['total'] ?? 0),
|
||
'pending' => (int) ($row['pending'] ?? 0),
|
||
'paid' => (int) ($row['paid'] ?? 0),
|
||
'refunded' => (int) ($row['refunded'] ?? 0),
|
||
'closed' => (int) ($row['closed'] ?? 0),
|
||
'revenue' => $this->cash_decimal((string) ($row['revenue'] ?? '0')),
|
||
];
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
public function get_admin_order_stats(array $args = []): array {
|
||
global $wpdb;
|
||
$this->expire_pending_orders();
|
||
$where = [];
|
||
$params = [];
|
||
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
|
||
$date = sanitize_text_field((string) ($args[$key] ?? ''));
|
||
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||
$where[] = 'created_at ' . $operator . ' %s';
|
||
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
|
||
}
|
||
}
|
||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||
$sql = "SELECT COUNT(*) AS total,
|
||
SUM(status = 'pending') AS pending,
|
||
SUM(status IN ('paid','refunding')) AS paid,
|
||
SUM(status = 'refunded') AS refunded,
|
||
SUM(status = 'closed') AS closed,
|
||
SUM(CASE WHEN status IN ('paid','refunding') AND currency <> 'points' THEN amount ELSE 0 END) AS revenue
|
||
FROM {$this->table()} {$where_sql}";
|
||
$row = $params ? $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A) : $wpdb->get_row($sql, ARRAY_A);
|
||
$type_sql = "SELECT product_type, note, COUNT(*) AS total FROM {$this->table()} {$where_sql} GROUP BY product_type, note";
|
||
$type_rows = $params ? $wpdb->get_results($wpdb->prepare($type_sql, $params), ARRAY_A) : $wpdb->get_results($type_sql, ARRAY_A);
|
||
$by_type = array_fill_keys(array_keys(\AKSRT\Core\DomainModel::order_product_type_labels()), 0);
|
||
foreach ((array) $type_rows as $type_row) {
|
||
$meta = json_decode((string) ($type_row['note'] ?? ''), true);
|
||
$type = \AKSRT\Core\DomainModel::resolve_order_product_type((string) ($type_row['product_type'] ?? ''), is_array($meta) ? $meta : []);
|
||
if (isset($by_type[$type])) {
|
||
$by_type[$type] += (int) ($type_row['total'] ?? 0);
|
||
}
|
||
}
|
||
return [
|
||
'total' => (int) ($row['total'] ?? 0),
|
||
'pending' => (int) ($row['pending'] ?? 0),
|
||
'paid' => (int) ($row['paid'] ?? 0),
|
||
'refunded' => (int) ($row['refunded'] ?? 0),
|
||
'closed' => (int) ($row['closed'] ?? 0),
|
||
'revenue' => $this->cash_decimal((string) ($row['revenue'] ?? '0')),
|
||
'by_type' => $by_type,
|
||
'after_sale_pending' => class_exists(AfterSaleService::class) ? AfterSaleService::get_instance()->count([
|
||
'status' => AfterSaleService::STATUS_PENDING,
|
||
'date_from' => $args['date_from'] ?? '',
|
||
'date_to' => $args['date_to'] ?? '',
|
||
]) : 0,
|
||
];
|
||
}
|
||
|
||
public function dispatch_managed_status_events(): void {
|
||
$events = $this->status_after_commit_events;
|
||
$this->status_after_commit_events = [];
|
||
$this->dispatch_status_events($events);
|
||
}
|
||
|
||
|
||
|
||
public function has_paid_access(int $user_id, string $product_type, int $product_id): bool {
|
||
if ($user_id <= 0 || $product_id <= 0) {
|
||
return false;
|
||
}
|
||
|
||
if (\AKSRT\Core\DomainModel::is_resource_product_type($product_type)) {
|
||
return null !== $this->find_latest_success_content_order($user_id, $product_id);
|
||
}
|
||
return null !== $this->find_latest_success_order($user_id, $product_type, $product_id);
|
||
}
|
||
|
||
|
||
|
||
public function get_valid_vip_order(int $user_id): ?array {
|
||
return $this->find_latest_success_order($user_id, \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP, 0, true);
|
||
}
|
||
|
||
|
||
|
||
/* ------------------------------------------------------------------
|
||
* Internal helpers
|
||
* ----------------------------------------------------------------*/
|
||
|
||
/**
|
||
* Build the shared list/count predicate without changing caller semantics.
|
||
*
|
||
* @return array{where:string,params:array<int,mixed>}
|
||
*/
|
||
private function get_user_order_query_parts(int $user_id, array $args = []): array {
|
||
global $wpdb;
|
||
|
||
$where = [];
|
||
$params = [];
|
||
if ($user_id > 0) {
|
||
$where[] = 'user_id = %d';
|
||
$params[] = $user_id;
|
||
}
|
||
|
||
$statuses = array_values(array_filter(array_map('sanitize_key', (array) ($args['status_in'] ?? []))));
|
||
if ($statuses !== []) {
|
||
$where[] = 'status IN (' . implode(', ', array_fill(0, count($statuses), '%s')) . ')';
|
||
array_push($params, ...$statuses);
|
||
} else {
|
||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||
if ($status !== '') {
|
||
$where[] = 'status = %s';
|
||
$params[] = $status;
|
||
}
|
||
}
|
||
|
||
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
|
||
if ($product_type !== '') {
|
||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
|
||
$types = \AKSRT\Core\DomainModel::resource_order_product_types();
|
||
$where[] = 'product_type IN (' . implode(', ', array_fill(0, count($types), '%s')) . ')';
|
||
array_push($params, ...$types);
|
||
} else {
|
||
$legacy_pay_types = [
|
||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING => \AKSRT\Core\DomainModel::PAY_TYPE_READING,
|
||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD => \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD,
|
||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE => \AKSRT\Core\DomainModel::PAY_TYPE_GALLERY,
|
||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO => \AKSRT\Core\DomainModel::PAY_TYPE_VIDEO,
|
||
];
|
||
if (isset($legacy_pay_types[$product_type])) {
|
||
$where[] = "(product_type = %s OR (product_type = %s AND note LIKE %s))";
|
||
array_push($params, $product_type, \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, '%"pay_type":"' . $wpdb->esc_like($legacy_pay_types[$product_type]) . '"%');
|
||
} else {
|
||
$where[] = 'product_type = %s';
|
||
$params[] = $product_type;
|
||
}
|
||
}
|
||
}
|
||
|
||
$order_number_prefix = strtoupper(preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($args['order_number_prefix'] ?? '')) ?? '');
|
||
if ($order_number_prefix !== '') {
|
||
$where[] = 'order_number LIKE %s';
|
||
$params[] = $wpdb->esc_like($order_number_prefix) . '%';
|
||
}
|
||
|
||
return [
|
||
'where' => $where ? 'WHERE ' . implode(' AND ', $where) : '',
|
||
'params' => $params,
|
||
];
|
||
}
|
||
|
||
private function normalize_row(array $row): array {
|
||
$row['id'] = (int) $row['id'];
|
||
$row['user_id'] = (int) $row['user_id'];
|
||
$row['product_id'] = (int) $row['product_id'];
|
||
$row['amount'] = $this->normalize_order_amount((string) ($row['amount'] ?? ''), (string) ($row['currency'] ?? ''), true);
|
||
if ($row['amount'] === null) {
|
||
$row['amount'] = (string) ($row['currency'] ?? '') === 'points' ? 0 : '0.00';
|
||
}
|
||
$row['status'] = sanitize_key((string) ($row['status'] ?? ''));
|
||
if ($row['status'] === 'refunding') {
|
||
$row['status'] = Order::STATUS_PAID;
|
||
}
|
||
|
||
$row['meta_payload'] = [];
|
||
if (!empty($row['note']) && is_string($row['note'])) {
|
||
$decoded = json_decode($row['note'], true);
|
||
if (is_array($decoded)) {
|
||
$row['meta_payload'] = $decoded;
|
||
if (isset($decoded['customer_note']) && is_string($decoded['customer_note'])) {
|
||
$row['note_text'] = $decoded['customer_note'];
|
||
}
|
||
} else {
|
||
$row['note_text'] = $row['note'];
|
||
}
|
||
}
|
||
$row['product_type'] = \AKSRT\Core\DomainModel::resolve_order_product_type(
|
||
sanitize_key((string) ($row['product_type'] ?? '')),
|
||
$row['meta_payload']
|
||
);
|
||
|
||
return $row;
|
||
}
|
||
|
||
private function normalize_order_amount(mixed $value, string $currency, bool $from_database = false): string|int|null {
|
||
if ($currency === 'points') {
|
||
$raw = is_int($value) ? (string) $value : (is_string($value) ? trim($value) : '');
|
||
if ($from_database && preg_match('/^(0|[1-9]\d*)\.0+$/', $raw, $matches)) {
|
||
$raw = $matches[1];
|
||
}
|
||
if (!preg_match('/^(0|[1-9]\d*)$/', $raw)) {
|
||
return null;
|
||
}
|
||
$max = (string) PHP_INT_MAX;
|
||
if (strlen($raw) > strlen($max) || (strlen($raw) === strlen($max) && strcmp($raw, $max) > 0)) {
|
||
return null;
|
||
}
|
||
return (int) $raw;
|
||
}
|
||
if (!is_string($value)) {
|
||
return null;
|
||
}
|
||
try {
|
||
return \AKSRT\Ledger\AssetAmount::balance(trim($value))->decimal();
|
||
} catch (\InvalidArgumentException $e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private function cash_decimal(string $value): string {
|
||
try {
|
||
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
|
||
} catch (\InvalidArgumentException $e) {
|
||
return '0.00';
|
||
}
|
||
}
|
||
|
||
|
||
|
||
private function find_latest_success_order(int $user_id, string $product_type, int $product_id = 0, bool $ignore_product_id = false): ?array {
|
||
global $wpdb;
|
||
|
||
if ($user_id <= 0 || $product_type === '') {
|
||
return null;
|
||
}
|
||
|
||
$status_placeholders = implode(', ', array_fill(0, count(\AKSRT\Core\DomainModel::paid_order_statuses()), '%s'));
|
||
$params = array_merge(
|
||
[$user_id, sanitize_key($product_type)],
|
||
$ignore_product_id ? [] : [$product_id],
|
||
\AKSRT\Core\DomainModel::paid_order_statuses()
|
||
);
|
||
|
||
$product_sql = $ignore_product_id ? '' : ' AND product_id = %d';
|
||
$sql = "SELECT * FROM {$this->table()}
|
||
WHERE user_id = %d
|
||
AND product_type = %s{$product_sql}
|
||
AND status IN ({$status_placeholders})
|
||
ORDER BY COALESCE(paid_at, created_at) DESC, id DESC
|
||
LIMIT 1";
|
||
|
||
$row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A);
|
||
|
||
return is_array($row) ? $this->normalize_row($row) : null;
|
||
}
|
||
|
||
private function find_latest_success_content_order(int $user_id, int $product_id): ?array {
|
||
global $wpdb;
|
||
$types = \AKSRT\Core\DomainModel::resource_order_product_types();
|
||
$placeholders = implode(', ', array_fill(0, count($types), '%s'));
|
||
$statuses = \AKSRT\Core\DomainModel::paid_order_statuses();
|
||
$status_placeholders = implode(', ', array_fill(0, count($statuses), '%s'));
|
||
$params = array_merge([$user_id, $product_id], $types, $statuses);
|
||
$row = $wpdb->get_row($wpdb->prepare(
|
||
"SELECT * FROM {$this->table()} WHERE user_id = %d AND product_id = %d AND product_type IN ({$placeholders}) AND status IN ({$status_placeholders}) ORDER BY COALESCE(paid_at, created_at) DESC, id DESC LIMIT 1",
|
||
$params
|
||
), ARRAY_A);
|
||
return is_array($row) ? $this->normalize_row($row) : null;
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 分配订单号。
|
||
*
|
||
* 订单号不再是独立编号体系:与资金流水号、提现单号共用统一流水号规则,
|
||
* 由 {@see \AKSRT\Finance\BusinessNumber::allocate()} 生成,不带任何前缀。
|
||
*/
|
||
public function allocate_order_number(): string {
|
||
return \AKSRT\Finance\BusinessNumber::allocate();
|
||
}
|
||
|
||
/**
|
||
* Merge keys into the order note JSON payload (meta_payload).
|
||
*
|
||
* @param array<string,mixed> $patch
|
||
*/
|
||
public function merge_meta(string $order_number, array $patch): bool {
|
||
global $wpdb;
|
||
|
||
$order = $this->get($order_number);
|
||
if (!$order) {
|
||
return false;
|
||
}
|
||
|
||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||
foreach ($patch as $key => $value) {
|
||
$meta[(string) $key] = $value;
|
||
}
|
||
|
||
$encoded = wp_json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
if (!is_string($encoded)) {
|
||
return false;
|
||
}
|
||
|
||
$updated = $wpdb->update(
|
||
$this->table(),
|
||
['note' => $encoded],
|
||
['order_number' => $order_number],
|
||
['%s'],
|
||
['%s']
|
||
);
|
||
|
||
return $updated !== false;
|
||
}
|
||
|
||
/**
|
||
* Resolve a local order from a provider out_trade_no (base or attempt-suffixed).
|
||
*
|
||
* The authoritative mapping is the order's own meta (provider_out_trade_no /
|
||
* provider_out_trade_nos); the prefix heuristic is only a fallback and must
|
||
* never be the sole resolver, otherwise a truncated attempt number would
|
||
* strand a real payment.
|
||
*/
|
||
public function resolve_by_out_trade_no(string $out_trade_no): ?array {
|
||
global $wpdb;
|
||
|
||
$out_trade_no = sanitize_text_field($out_trade_no);
|
||
if ($out_trade_no === '') {
|
||
return null;
|
||
}
|
||
|
||
$direct = $this->get($out_trade_no);
|
||
if ($direct) {
|
||
return $direct;
|
||
}
|
||
|
||
// Authoritative: the attempt number was recorded in the order note JSON.
|
||
$needle = '"provider_out_trade_no":"' . $out_trade_no . '"';
|
||
$row = $wpdb->get_row(
|
||
$wpdb->prepare(
|
||
"SELECT * FROM {$this->table()} WHERE note LIKE %s ORDER BY id DESC LIMIT 1",
|
||
'%' . $wpdb->esc_like($needle) . '%'
|
||
),
|
||
ARRAY_A
|
||
);
|
||
if (is_array($row)) {
|
||
return $this->expire_order_if_due($this->normalize_row($row));
|
||
}
|
||
|
||
// Superseded attempts stay in the history array.
|
||
$history_needle = '"' . $out_trade_no . '"';
|
||
$row = $wpdb->get_row(
|
||
$wpdb->prepare(
|
||
"SELECT * FROM {$this->table()}
|
||
WHERE note LIKE %s AND note LIKE %s
|
||
ORDER BY id DESC LIMIT 1",
|
||
'%' . $wpdb->esc_like('"provider_out_trade_nos"') . '%',
|
||
'%' . $wpdb->esc_like($history_needle) . '%'
|
||
),
|
||
ARRAY_A
|
||
);
|
||
if (is_array($row)) {
|
||
return $this->expire_order_if_due($this->normalize_row($row));
|
||
}
|
||
|
||
// Last resort: {order_number}P{n} prefix heuristic.
|
||
if (preg_match('/^(.+)P([0-9]{1,3})$/', $out_trade_no, $m)) {
|
||
$base = $this->get($m[1]);
|
||
if ($base) {
|
||
return $base;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Provider out_trade_no currently bound to this order (falls back to order_number).
|
||
*
|
||
* @param array<string,mixed> $order
|
||
*/
|
||
public static function provider_out_trade_no(array $order): string {
|
||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||
$out = sanitize_text_field((string) ($meta['provider_out_trade_no'] ?? ''));
|
||
if ($out !== '') {
|
||
return $out;
|
||
}
|
||
return sanitize_text_field((string) ($order['order_number'] ?? ''));
|
||
}
|
||
|
||
/**
|
||
* Allocate a new provider out_trade_no for a payment attempt.
|
||
* First attempt uses bare order_number; later attempts append Pn (WeChat limit 32).
|
||
*
|
||
* @return array{out_trade_no:string,attempt:int,previous:string}|null
|
||
*/
|
||
public function allocate_payment_attempt(string $order_number): ?array {
|
||
$order = $this->get($order_number);
|
||
if (!$order) {
|
||
return null;
|
||
}
|
||
|
||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||
$previous = sanitize_text_field((string) ($meta['provider_out_trade_no'] ?? ''));
|
||
$history = is_array($meta['provider_out_trade_nos'] ?? null) ? $meta['provider_out_trade_nos'] : [];
|
||
|
||
// First provider submission keeps the bare order number.
|
||
// Re-pay (provider_out_trade_no already set) always mints a new suffix so
|
||
// WeChat/Alipay/Epay accept the order when amount/subject/notify changed.
|
||
if ($previous === '') {
|
||
$attempt = 1;
|
||
$out = $order_number;
|
||
} else {
|
||
$attempt = max(1, (int) ($meta['pay_attempt'] ?? 1)) + 1;
|
||
$suffix = 'P' . $attempt;
|
||
$base = $order_number;
|
||
if (strlen($base) + strlen($suffix) > 32) {
|
||
$base = substr($base, 0, 32 - strlen($suffix));
|
||
}
|
||
$out = $base . $suffix;
|
||
if ($previous !== '' && !in_array($previous, $history, true)) {
|
||
$history[] = $previous;
|
||
}
|
||
}
|
||
|
||
if (!in_array($out, $history, true)) {
|
||
$history[] = $out;
|
||
}
|
||
if (count($history) > 20) {
|
||
$history = array_slice($history, -20);
|
||
}
|
||
|
||
$ok = $this->merge_meta($order_number, [
|
||
'pay_attempt' => $attempt,
|
||
'provider_out_trade_no' => $out,
|
||
'provider_out_trade_nos' => array_values($history),
|
||
]);
|
||
if (!$ok) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'out_trade_no' => $out,
|
||
'attempt' => $attempt,
|
||
'previous' => $previous,
|
||
];
|
||
}
|
||
|
||
}
|