get_admin_order_context_map([$order_number]); return $contexts[$order_number] ?? $this->get_empty_admin_order_context($order_number); } /** * Build admin-facing order context payloads in bulk to avoid per-row order queries. * * @param array $order_numbers * @return array> */ public function get_admin_order_context_map(array $order_numbers): array { global $wpdb; $order_numbers = array_values(array_unique(array_filter(array_map( static function ($order_number): string { return sanitize_text_field((string) $order_number); }, $order_numbers )))); if (empty($order_numbers)) { return []; } $contexts = []; foreach ($order_numbers as $order_number) { $contexts[$order_number] = $this->get_empty_admin_order_context($order_number); } $placeholders = implode(', ', array_fill(0, count($order_numbers), '%s')); $sql = "SELECT * FROM {$this->table()} WHERE order_number IN ({$placeholders})"; $rows = $wpdb->get_results($wpdb->prepare($sql, $order_numbers), ARRAY_A); if (empty($rows)) { return $contexts; } $orders = []; foreach ((array) $rows as $row) { $order = $this->normalize_row($row); $orders[(string) ($order['order_number'] ?? '')] = $order; } $resolved_order_numbers = array_keys($orders); foreach ($orders as $order_number => $order) { $contexts[$order_number] = $this->build_admin_order_context($order, []); } return $contexts; } /** * 更新订单状态(带状态机验证) */ /** * Merge operator admin note into order note meta without destroying JSON payload. */ public function update_admin_note(string $order_number, string $admin_note): bool { global $wpdb; $order = $this->get($order_number); if (!$order) { return false; } $raw = isset($order['note']) && is_string($order['note']) ? $order['note'] : ''; $decoded = $raw !== '' ? json_decode($raw, true) : null; $admin_note = sanitize_textarea_field($admin_note); if (is_array($decoded)) { $decoded['admin_note'] = $admin_note; $new_note = wp_json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } elseif ($raw !== '') { $new_note = wp_json_encode([ 'customer_note' => $raw, 'admin_note' => $admin_note, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } else { $new_note = wp_json_encode([ 'admin_note' => $admin_note, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } $result = $wpdb->update( $this->table(), ['note' => $new_note], ['order_number' => $order_number], ['%s'], ['%s'] ); return $result !== false; } /** * 管理后台订单列表 — 支持 search(order_number / product_type 模糊匹配) */ public function get_orders_admin(array $args = [], string $search = ''): array { global $wpdb; $this->expire_pending_orders(); [$where, $params] = $this->build_admin_order_filters($args, $search); $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); $offset = max(0, (int) ($args['offset'] ?? 0)); $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; $sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; $params[] = $limit; $params[] = $offset; $rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); return array_map([$this, 'normalize_row'], (array) $rows); } /** * 管理后台订单计数 — 与 get_orders_admin 共用过滤条件 */ public function get_orders_count(array $args = [], string $search = ''): int { global $wpdb; $this->expire_pending_orders(); [$where, $params] = $this->build_admin_order_filters($args, $search); $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; $sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; if (!empty($params)) { return (int) $wpdb->get_var($wpdb->prepare($sql, $params)); } return (int) $wpdb->get_var($sql); } /** * Aggregate paid content (resource) sales by product_id (post ID). * * @param array{ * product_type?: string, * product_ids?: int[], * status_in?: string[], * limit?: int, * offset?: int, * orderby?: string, * order?: string * } $args * @return array{items: list, total: int} */ public function get_content_sales_summary(array $args = []): array { global $wpdb; $product_type = sanitize_key((string) ($args['product_type'] ?? \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE)); if ($product_type === '') { $product_type = \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE; } $status_in = $args['status_in'] ?? \AKSRT\Core\DomainModel::paid_order_statuses(); if (!is_array($status_in) || empty($status_in)) { $status_in = \AKSRT\Core\DomainModel::paid_order_statuses(); } $status_in = array_values(array_filter(array_map('sanitize_key', $status_in))); if (empty($status_in)) { return ['items' => [], 'total' => 0]; } $content_types = [ \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, \AKSRT\Core\DomainModel::ORDER_PRODUCT_READING, \AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD, \AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE, \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO, ]; $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, ]; $where = ['product_id > 0']; $params = []; if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) { $where[] = 'product_type IN (' . implode(', ', array_fill(0, count($content_types), '%s')) . ')'; array_push($params, ...$content_types); } elseif (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; } $status_placeholders = implode(', ', array_fill(0, count($status_in), '%s')); $where[] = "status IN ({$status_placeholders})"; foreach ($status_in as $st) { $params[] = $st; } $product_ids = $args['product_ids'] ?? []; if (is_array($product_ids) && !empty($product_ids)) { $ids = array_values(array_unique(array_filter(array_map('intval', $product_ids), static function ($id) { return $id > 0; }))); if (!empty($ids)) { $id_ph = implode(', ', array_fill(0, count($ids), '%d')); $where[] = "product_id IN ({$id_ph})"; foreach ($ids as $id) { $params[] = $id; } } } $where_sql = 'WHERE ' . implode(' AND ', $where); $table = $this->table(); $total_sql = "SELECT COUNT(DISTINCT product_id) FROM {$table} {$where_sql}"; $total = (int) $wpdb->get_var($wpdb->prepare($total_sql, $params)); $orderby_map = [ 'sales_count' => 'sales_count', 'revenue' => 'revenue', 'avg_amount' => 'avg_amount', 'product_id' => 'product_id', ]; $orderby_key = sanitize_key((string) ($args['orderby'] ?? 'sales_count')); $orderby_col = $orderby_map[$orderby_key] ?? 'sales_count'; $order_dir = strtoupper((string) ($args['order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC'; $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); $offset = max(0, (int) ($args['offset'] ?? 0)); $list_sql = "SELECT product_id, MIN(product_type) AS product_type, currency, COUNT(*) AS sales_count, AVG(amount) AS avg_amount, SUM(amount) AS revenue FROM {$table} {$where_sql} GROUP BY product_id, currency ORDER BY {$orderby_col} {$order_dir} LIMIT %d OFFSET %d"; $list_params = $params; $list_params[] = $limit; $list_params[] = $offset; $rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A); $items = []; $aggregate = []; foreach ((array) $rows as $row) { $sales_count = max(1, (int) ($row['sales_count'] ?? 0)); $is_points = (string) ($row['currency'] ?? '') === 'points'; if ($is_points) { $revenue = max(0, (int) ($row['revenue'] ?? 0)); $avg_amount = intdiv($revenue + intdiv($sales_count, 2), $sales_count); } else { try { $revenue_minor = \AKSRT\Ledger\AssetAmount::balance_minor((string) ($row['revenue'] ?? '0')); } catch (\InvalidArgumentException $e) { $revenue_minor = 0; } $revenue = \AKSRT\Ledger\AssetAmount::decimal_from_minor($revenue_minor); $avg_amount = \AKSRT\Ledger\AssetAmount::decimal_from_minor( intdiv($revenue_minor + intdiv($sales_count, 2), $sales_count) ); } $pid = (int) ($row['product_id'] ?? 0); if (!isset($aggregate[$pid])) { $aggregate[$pid] = [ 'product_id' => (int) ($row['product_id'] ?? 0), 'product_type' => $product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE ? $product_type : (string) ($row['product_type'] ?? $product_type), 'sales_count' => 0, 'avg_amount' => 0, 'revenue_minor' => 0, 'currency' => (string) ($row['currency'] ?? 'CNY'), ]; } $aggregate[$pid]['sales_count'] += (int) ($row['sales_count'] ?? 0); if ((string) ($row['currency'] ?? '') === 'points') { $aggregate[$pid]['revenue_minor'] += (int) $revenue; } else { $aggregate[$pid]['revenue_minor'] += \AKSRT\Ledger\AssetAmount::balance_minor((string) $revenue); } } foreach ($aggregate as $item) { $count = max(1, (int) $item['sales_count']); $is_points = $item['currency'] === 'points'; $revenue = $is_points ? (int) $item['revenue_minor'] : \AKSRT\Ledger\AssetAmount::decimal_from_minor((int) $item['revenue_minor']); $average_minor = intdiv((int) $item['revenue_minor'] + intdiv($count, 2), $count); $items[] = [ 'product_id' => (int) $item['product_id'], 'product_type' => (string) $item['product_type'], 'sales_count' => (int) $item['sales_count'], 'avg_amount' => $is_points ? $average_minor : \AKSRT\Ledger\AssetAmount::decimal_from_minor($average_minor), 'revenue' => $revenue, ]; } return [ 'items' => $items, 'total' => $total, ]; } /** * Paid order totals for a set of product IDs (resource content). * * @param int[] $product_ids * @return array */ public function get_content_sales_map(array $product_ids, string $product_type = ''): array { $ids = array_values(array_unique(array_filter(array_map('intval', $product_ids), static function ($id) { return $id > 0; }))); if (empty($ids)) { return []; } if ($product_type === '') { $product_type = \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE; } $summary = $this->get_content_sales_summary([ 'product_type' => $product_type, 'product_ids' => $ids, 'limit' => max(count($ids), 1), 'offset' => 0, 'orderby' => 'product_id', 'order' => 'ASC', ]); $map = []; foreach ($summary['items'] as $item) { $pid = (int) ($item['product_id'] ?? 0); if ($pid <= 0) { continue; } $map[$pid] = [ 'sales_count' => (int) ($item['sales_count'] ?? 0), 'avg_amount' => $item['avg_amount'] ?? '0.00', 'revenue' => $item['revenue'] ?? '0.00', ]; } return $map; } /** * Shared WHERE builder for admin order list/count. * * @return array{0: string[], 1: array} */ private function build_admin_order_filters(array $args, string $search = ''): array { global $wpdb; $where = []; $params = []; $status = sanitize_key((string) ($args['status'] ?? '')); $valid_statuses = array_keys(\AKSRT\Core\DomainModel::order_status_labels()); if ($status !== '' && $status !== 'all' && in_array($status, $valid_statuses, true)) { if ($status === 'paid') { $where[] = "status IN (%s, 'refunding')"; $params[] = $status; } else { $where[] = 'status = %s'; $params[] = $status; } } else { $where[] = "status IN (" . implode(', ', array_fill(0, count($valid_statuses), '%s')) . ", 'refunding')"; array_push($params, ...$valid_statuses); } $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; } } } $product_id = (int) ($args['product_id'] ?? 0); if ($product_id > 0) { $where[] = 'product_id = %d'; $params[] = $product_id; } $user_id = (int) ($args['user_id'] ?? 0); if ($user_id > 0) { $where[] = 'user_id = %d'; $params[] = $user_id; } if (array_key_exists('payment_methods', $args)) { $payment_methods = array_values(array_unique(array_filter(array_map( static fn($method): string => sanitize_key((string) $method), (array) $args['payment_methods'] )))); if ($payment_methods === []) { $where[] = '1 = 0'; } else { $where[] = 'payment_method IN (' . implode(', ', array_fill(0, count($payment_methods), '%s')) . ')'; array_push($params, ...$payment_methods); } } if ($search !== '') { $user_table = $wpdb->users; $user_match = "EXISTS (SELECT 1 FROM {$user_table} u WHERE u.ID = " . $this->table() . '.user_id AND (u.user_login LIKE %s OR u.user_email LIKE %s OR u.display_name LIKE %s))'; $post_table = $wpdb->posts; $product_match = "EXISTS (SELECT 1 FROM {$post_table} p WHERE p.ID = " . $this->table() . '.product_id AND p.post_title LIKE %s)'; $meta_match = "note LIKE %s"; // Search matches the public order number, account, product title, and stored product label. if (ctype_digit($search)) { $where[] = "(order_number LIKE %s OR product_type LIKE %s OR user_id = %d OR product_id = %d OR {$user_match} OR {$product_match} OR {$meta_match})"; $like = '%' . $wpdb->esc_like($search) . '%'; $params[] = $like; $params[] = $like; $params[] = (int) $search; $params[] = (int) $search; array_push($params, $like, $like, $like, $like, $like); } else { $where[] = "(order_number LIKE %s OR product_type LIKE %s OR payment_method LIKE %s OR {$user_match} OR {$product_match} OR {$meta_match})"; $like = '%' . $wpdb->esc_like($search) . '%'; $params[] = $like; $params[] = $like; $params[] = $like; array_push($params, $like, $like, $like, $like, $like); } } 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'; } } return [$where, $params]; } private function get_empty_admin_order_context(string $order_number): array { return [ 'exists' => false, 'summary' => [ 'order_number' => $order_number, 'status' => '', 'status_label' => '', 'amount' => '0.00', 'currency_symbol' => '¥', 'created_at' => '', 'paid_at' => '', ], 'buyer' => [], 'address' => [], 'payment_method' => [], 'shipping_method' => [], 'shipping' => [ 'count' => 0, 'latest' => [], ], 'after_sale' => [ 'count' => 0, 'latest' => [], ], 'coupon' => [], 'pricing' => [ 'subtotal' => '0.00', 'shipping_fee' => '0.00', 'discount' => '0.00', 'amount' => '0.00', ], 'items' => [], 'customer_note' => '', 'raw_order' => null, ]; } /** * @param array $order * @param array $related * @return array */ private function build_admin_order_context(array $order, array $related = []): array { $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; $user = get_userdata((int) ($order['user_id'] ?? 0)); $status = (string) ($order['status'] ?? ''); $address = is_array($meta['address'] ?? null) ? $meta['address'] : []; $payment_method = is_array($meta['payment_method'] ?? null) ? $meta['payment_method'] : []; $shipping_method = is_array($meta['shipping_method'] ?? null) ? $meta['shipping_method'] : []; $coupon = is_array($meta['coupon'] ?? null) ? $meta['coupon'] : []; $items = is_array($meta['items'] ?? null) ? $meta['items'] : []; $currency = (string) ($order['currency'] ?? 'CNY'); $is_points = $currency === 'points'; $shipping_minor = $is_points ? max(0, (int) ($meta['shipping_fee'] ?? 0)) : $this->cash_minor((string) ($meta['shipping_fee'] ?? '0')); $discount_minor = $is_points ? max(0, (int) ($meta['discount'] ?? 0)) : $this->cash_minor((string) ($meta['discount'] ?? '0')); $amount_minor = $is_points ? max(0, (int) ($order['amount'] ?? 0)) : $this->cash_minor((string) ($order['amount'] ?? '0')); $fallback_minor = max(0, $amount_minor + $discount_minor - $shipping_minor); $subtotal_minor = $this->calculate_order_subtotal_minor($items, $fallback_minor, $is_points); $amount = $is_points ? $amount_minor : \AKSRT\Ledger\AssetAmount::decimal_from_minor($amount_minor); $shipping_fee = $is_points ? $shipping_minor : \AKSRT\Ledger\AssetAmount::decimal_from_minor($shipping_minor); $discount = $is_points ? $discount_minor : \AKSRT\Ledger\AssetAmount::decimal_from_minor($discount_minor); $subtotal = $is_points ? $subtotal_minor : \AKSRT\Ledger\AssetAmount::decimal_from_minor($subtotal_minor); $address['full_text'] = $this->build_address_text($address); return [ 'exists' => true, 'summary' => [ 'order_number' => (string) ($order['order_number'] ?? ''), 'status' => $status, 'status_label' => class_exists('AKSRT\\Payment\\Order') ? (Order::get_valid_statuses()[$status] ?? $status) : $status, 'amount' => $amount, 'currency_symbol' => $is_points ? __('积分', 'aksrt') : '¥', 'created_at' => (string) ($order['created_at'] ?? ''), 'paid_at' => (string) ($order['paid_at'] ?? ''), 'product_type' => (string) ($order['product_type'] ?? ''), 'product_id' => (int) ($order['product_id'] ?? 0), ], 'buyer' => [ 'id' => (int) ($order['user_id'] ?? 0), 'display_name' => $user ? (string) $user->display_name : '', 'user_login' => $user ? (string) $user->user_login : '', 'user_email' => $user ? (string) $user->user_email : '', ], 'address' => $address, 'payment_method' => [ 'value' => (string) ($payment_method['value'] ?? $order['payment_method'] ?? ''), 'label' => (string) ($payment_method['label'] ?? $order['payment_method'] ?? ''), ], 'shipping_method' => [ 'value' => (string) ($shipping_method['value'] ?? ''), 'label' => (string) ($shipping_method['label'] ?? ''), 'fee' => $shipping_fee, ], 'shipping' => [ 'count' => 0, 'latest' => [], ], 'after_sale' => [ 'count' => 0, 'latest' => [], ], 'coupon' => $coupon, 'pricing' => [ 'subtotal' => $subtotal, 'shipping_fee' => $shipping_fee, 'discount' => $discount, 'amount' => $amount, ], 'items' => $items, 'customer_note' => (string) ($meta['customer_note'] ?? $order['note_text'] ?? ''), 'raw_order' => $order, ]; } /** * @param array> $items */ private function calculate_order_subtotal_minor(array $items, int $fallback, bool $is_points): int { if (empty($items)) { return max(0, $fallback); } $subtotal = 0; foreach ($items as $item) { if (isset($item['line_total'])) { $line_minor = $is_points ? max(0, (int) $item['line_total']) : $this->cash_minor((string) $item['line_total']); if ($subtotal > PHP_INT_MAX - $line_minor) { return max(0, $fallback); } $subtotal += $line_minor; continue; } $price_minor = $is_points ? max(0, (int) ($item['price'] ?? 0)) : $this->cash_minor((string) ($item['price'] ?? '0')); $quantity = max(1, (int) ($item['quantity'] ?? 1)); if ($price_minor > intdiv(PHP_INT_MAX, $quantity) || $subtotal > PHP_INT_MAX - ($price_minor * $quantity) ) { return max(0, $fallback); } $subtotal += $price_minor * $quantity; } if ($subtotal <= 0) { return max(0, $fallback); } return $subtotal; } private function cash_minor(string $value): int { try { return \AKSRT\Ledger\AssetAmount::balance_minor($value); } catch (\InvalidArgumentException $e) { return 0; } } private function build_address_text(array $address): string { $segments = []; foreach (['name', 'phone', 'province', 'city', 'district', 'address'] as $key) { $value = trim((string) ($address[$key] ?? '')); if ($value !== '') { $segments[] = $value; } } if (!empty($address['full_text']) && is_string($address['full_text'])) { return (string) $address['full_text']; } return implode(' / ', $segments); } }