Files
wordpress-theme-aksrt/src/Payment/OrderRepositoryCore.php
T

1142 lines
43 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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,
];
}
}