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} */ 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 $order @return array */ 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}> $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 */ 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} */ 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 $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 $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, ]; } }