From 74bde18880e442adacf58e23b892b0306739dd7c Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 15 Jul 2026 10:17:27 +0800 Subject: [PATCH 1/2] feat(admin): native paid-post sales report page Add aksrt_pay_products under payment center for content-pay posts (resource orders), with order aggregation and deep-link filters. --- functions.php | 3 + inc/bootstrap.php | 1 + .../native/class-aksrt-admin-pay-menus.php | 11 + .../native/class-aksrt-admin-pay-orders.php | 33 +- .../native/class-aksrt-admin-pay-products.php | 366 ++++++++++++++++++ inc/classes/class-aksrt-order-repository.php | 218 +++++++++-- 6 files changed, 598 insertions(+), 34 deletions(-) create mode 100644 inc/classes/admin/native/class-aksrt-admin-pay-products.php diff --git a/functions.php b/functions.php index e12f4b2f..4c793d43 100644 --- a/functions.php +++ b/functions.php @@ -371,6 +371,9 @@ if (is_admin() && class_exists('AKSRT_Admin_Pay_After_Sale')) { if (is_admin() && class_exists('AKSRT_Admin_Pay_Orders')) { AKSRT_Admin_Pay_Orders::get_instance()->init(); } +if (is_admin() && class_exists('AKSRT_Admin_Pay_Products')) { + AKSRT_Admin_Pay_Products::get_instance()->init(); +} if (is_admin() && class_exists('AKSRT_Admin_Pay_Withdrawals')) { AKSRT_Admin_Pay_Withdrawals::get_instance()->init(); } diff --git a/inc/bootstrap.php b/inc/bootstrap.php index 675d3751..db167446 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -178,6 +178,7 @@ require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-charge require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-shipping.php'; require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-after-sale.php'; require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-orders.php'; +require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-products.php'; require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-withdrawals.php'; require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-income.php'; require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-rebate.php'; diff --git a/inc/classes/admin/native/class-aksrt-admin-pay-menus.php b/inc/classes/admin/native/class-aksrt-admin-pay-menus.php index f9a076d7..f3189845 100644 --- a/inc/classes/admin/native/class-aksrt-admin-pay-menus.php +++ b/inc/classes/admin/native/class-aksrt-admin-pay-menus.php @@ -54,6 +54,17 @@ class AKSRT_Admin_Pay_Menus { wp_die(esc_html__('订单模块未初始化。', 'aksrt')); }, ], + 'products' => [ + 'slug' => 'aksrt_pay_products', + 'title' => __('付费文章', 'aksrt'), + 'callback' => static function (): void { + if (class_exists('AKSRT_Admin_Pay_Products')) { + AKSRT_Admin_Pay_Products::get_instance()->render_products_page(); + return; + } + wp_die(esc_html__('付费文章模块未初始化。', 'aksrt')); + }, + ], 'shipping' => [ 'slug' => 'aksrt_shipping', 'title' => __('发货及物流', 'aksrt'), diff --git a/inc/classes/admin/native/class-aksrt-admin-pay-orders.php b/inc/classes/admin/native/class-aksrt-admin-pay-orders.php index 6311e75b..615fff0b 100644 --- a/inc/classes/admin/native/class-aksrt-admin-pay-orders.php +++ b/inc/classes/admin/native/class-aksrt-admin-pay-orders.php @@ -48,10 +48,12 @@ class AKSRT_Admin_Pay_Orders { return; } - $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : ''; - $search = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : ''; - $page_num = max(1, (int) ($_GET['paged'] ?? 1)); - $per_page = 20; + $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : ''; + $search = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : ''; + $product_type = isset($_GET['product_type']) ? sanitize_key((string) wp_unslash($_GET['product_type'])) : ''; + $product_id = isset($_GET['product_id']) ? max(0, (int) $_GET['product_id']) : 0; + $page_num = max(1, (int) ($_GET['paged'] ?? 1)); + $per_page = 20; $repo = AKSRT_Order_Repository::get_instance(); $args = [ @@ -61,6 +63,12 @@ class AKSRT_Admin_Pay_Orders { if ($status !== '' && $status !== 'all') { $args['status'] = $status; } + if ($product_type !== '') { + $args['product_type'] = $product_type; + } + if ($product_id > 0) { + $args['product_id'] = $product_id; + } $items = $repo->get_orders_admin($args, $search); $total = $repo->get_orders_count($args, $search); @@ -71,6 +79,12 @@ class AKSRT_Admin_Pay_Orders { echo '
'; echo ''; + if ($product_type !== '') { + echo ''; + } + if ($product_id > 0) { + echo ''; + } echo ' '; echo ' '; submit_button(__('筛选', 'aksrt'), 'secondary', '', false); + if ($product_id > 0 || $product_type !== '') { + $filter_bits = []; + if ($product_type !== '') { + $filter_bits[] = sprintf(__('类型=%s', 'aksrt'), $product_type); + } + if ($product_id > 0) { + $filter_bits[] = sprintf(__('商品ID=%d', 'aksrt'), $product_id); + } + echo ' ' . esc_html(implode(' · ', $filter_bits)) . ''; + echo ' ' . esc_html__('清除商品过滤', 'aksrt') . ''; + } echo '
'; echo ''; diff --git a/inc/classes/admin/native/class-aksrt-admin-pay-products.php b/inc/classes/admin/native/class-aksrt-admin-pay-products.php new file mode 100644 index 00000000..b3c4420a --- /dev/null +++ b/inc/classes/admin/native/class-aksrt-admin-pay-products.php @@ -0,0 +1,366 @@ +ensure_cap(); + $this->render_header(__('付费文章', 'aksrt')); + + if (!class_exists('AKSRT_Order_Repository') || !class_exists('AKSRT_Domain_Model')) { + echo '

' . esc_html__('订单服务不可用。', 'aksrt') . '

'; + $this->render_footer(); + return; + } + + $pay_type = isset($_GET['pay_type']) ? sanitize_key((string) wp_unslash($_GET['pay_type'])) : ''; + $search = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : ''; + $page_num = max(1, (int) ($_GET['paged'] ?? 1)); + $per_page = 20; + + $type_labels = AKSRT_Domain_Model::pay_type_labels(); + unset($type_labels[AKSRT_Domain_Model::PAY_TYPE_NO]); + + $query = $this->query_paid_posts([ + 'pay_type' => $pay_type, + 'search' => $search, + 'page' => $page_num, + 'per_page' => $per_page, + ]); + + $posts = $query['posts']; + $total = $query['total']; + $pages = max(1, (int) ceil($total / $per_page)); + + $sales_map = []; + if ($posts && class_exists('AKSRT_Order_Repository')) { + $ids = array_map(static function ($p) { + return (int) $p->ID; + }, $posts); + $sales_map = AKSRT_Order_Repository::get_instance()->get_content_sales_map( + $ids, + AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE + ); + } + + $this->render_filters($pay_type, $search, $type_labels); + + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + if (!$posts) { + echo ''; + } + + foreach ($posts as $post) { + $post_id = (int) $post->ID; + $meta = function_exists('aksrt_get_pay_meta') ? aksrt_get_pay_meta($post) : []; + $pt = AKSRT_Domain_Model::normalize_pay_type((string) ($meta['pay_type'] ?? AKSRT_Domain_Model::PAY_TYPE_NO)); + $pt_label = $type_labels[$pt] ?? $pt; + $modo = (($meta['pay_modo'] ?? '0') === 'points') ? 'points' : '0'; + $modo_label = $modo === 'points' ? __('积分', 'aksrt') : __('现金', 'aksrt'); + + if ($modo === 'points') { + $list_price = (int) ceil((float) ($meta['points_price'] ?? $meta['price'] ?? 0)); + $price_text = sprintf(__('%d 积分', 'aksrt'), $list_price); + } else { + $list_price = round((float) ($meta['price'] ?? 0), 2); + $price_text = number_format($list_price, 2); + } + + $sales = $sales_map[$post_id] ?? ['sales_count' => 0, 'avg_amount' => 0.0, 'revenue' => 0.0]; + $db_sales = (int) ($sales['sales_count'] ?? 0); + $avg = (float) ($sales['avg_amount'] ?? 0); + $revenue = (float) ($sales['revenue'] ?? 0); + $meta_sales = (int) get_post_meta($post_id, AKSRT_Domain_Model::RESOURCE_SALES_VOLUME_KEY, true); + + $edit_url = get_edit_post_link($post_id, 'raw'); + $title = get_the_title($post_id); + if ($title === '') { + $title = sprintf(__('(无标题 #%d)', 'aksrt'), $post_id); + } + + $orders_url = $this->page_url('aksrt_orders', [ + 'product_type' => AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE, + 'product_id' => $post_id, + ]); + + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + + echo '
' . esc_html__('商品', 'aksrt') . '' . esc_html__('ID', 'aksrt') . '' . esc_html__('付费类型', 'aksrt') . '' . esc_html__('模式', 'aksrt') . '' . esc_html__('标价', 'aksrt') . '' . esc_html__('销售均价', 'aksrt') . '' . esc_html__('订单销量', 'aksrt') . '' . esc_html__('Meta 销量', 'aksrt') . '' . esc_html__('销售总额', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '
' . esc_html__('暂无付费文章。', 'aksrt') . '
'; + if ($edit_url) { + echo '' . esc_html($title) . ''; + } else { + echo '' . esc_html($title) . ''; + } + echo '' . esc_html((string) $post_id) . '' . esc_html((string) $pt_label) . '' . esc_html($modo_label) . '' . esc_html($price_text) . '' . esc_html(number_format($avg, 2)) . '' . esc_html((string) $db_sales) . '' . esc_html((string) max(0, $meta_sales)) . '' . esc_html(number_format($revenue, 2)) . ''; + if ($edit_url) { + echo '' . esc_html__('编辑', 'aksrt') . ''; + } + echo '' . esc_html__('订单', 'aksrt') . ''; + $permalink = get_permalink($post_id); + if ($permalink) { + echo '' . esc_html__('查看', 'aksrt') . ''; + } + echo '
'; + $this->render_pagination($pages, $page_num); + $this->render_footer(); + } + + /** + * @param array{pay_type?:string,search?:string,page?:int,per_page?:int} $args + * @return array{posts: WP_Post[], total: int} + */ + private function query_paid_posts(array $args): array { + $pay_type = sanitize_key((string) ($args['pay_type'] ?? '')); + $search = (string) ($args['search'] ?? ''); + $page = max(1, (int) ($args['page'] ?? 1)); + $per_page = max(1, min(100, (int) ($args['per_page'] ?? 20))); + + $meta_query = [ + 'relation' => 'AND', + [ + 'key' => AKSRT_Domain_Model::PAY_META_KEY, + 'compare' => 'EXISTS', + ], + ]; + + // Prefer denormalized type key when present; still exclude "no". + if ($pay_type !== '' && $pay_type !== AKSRT_Domain_Model::PAY_TYPE_NO) { + $meta_query[] = [ + 'relation' => 'OR', + [ + 'key' => AKSRT_Domain_Model::PAY_TYPE_KEY, + 'value' => $pay_type, + 'compare' => '=', + ], + [ + 'key' => AKSRT_Domain_Model::PAY_META_KEY, + 'value' => '"pay_type";s:' . strlen($pay_type) . ':"' . $pay_type . '"', + 'compare' => 'LIKE', + ], + ]; + } else { + $meta_query[] = [ + 'relation' => 'OR', + [ + 'key' => AKSRT_Domain_Model::PAY_TYPE_KEY, + 'value' => AKSRT_Domain_Model::PAY_TYPE_NO, + 'compare' => '!=', + ], + [ + 'key' => AKSRT_Domain_Model::PAY_TYPE_KEY, + 'compare' => 'NOT EXISTS', + ], + ]; + } + + $q_args = [ + 'post_type' => 'post', + 'post_status' => ['publish', 'draft', 'pending', 'private', 'future'], + 'posts_per_page' => $per_page, + 'paged' => $page, + 'orderby' => 'date', + 'order' => 'DESC', + 'meta_query' => $meta_query, + 'fields' => 'all', + 'no_found_rows' => false, + 'update_post_meta_cache' => true, + 'update_post_term_cache' => false, + ]; + + if ($search !== '') { + if (ctype_digit($search)) { + $q_args['post__in'] = [(int) $search]; + } else { + $q_args['s'] = $search; + } + } + + $q = new WP_Query($q_args); + $posts = []; + foreach ($q->posts as $post) { + if (!($post instanceof WP_Post)) { + continue; + } + // Drop posts whose pay bag is effectively free/no. + if (function_exists('aksrt_is_paid_post') && !aksrt_is_paid_post($post)) { + continue; + } + if ($pay_type !== '' && function_exists('aksrt_get_pay_meta')) { + $meta = aksrt_get_pay_meta($post); + $pt = AKSRT_Domain_Model::normalize_pay_type((string) ($meta['pay_type'] ?? '')); + if ($pt !== $pay_type) { + continue; + } + } + $posts[] = $post; + } + + // When post-filtering drops rows, fall back to order-driven list if empty page + // so operators still see historical sold posts without pay meta. + if (empty($posts) && $search === '' && $pay_type === '' && $page === 1) { + $order_posts = $this->posts_from_order_summary($per_page, 0); + if (!empty($order_posts['posts'])) { + return $order_posts; + } + } + + return [ + 'posts' => $posts, + 'total' => (int) $q->found_posts, + ]; + } + + /** + * Fallback: posts that already have paid resource orders. + * + * @return array{posts: WP_Post[], total: int} + */ + private function posts_from_order_summary(int $limit, int $offset): array { + if (!class_exists('AKSRT_Order_Repository')) { + return ['posts' => [], 'total' => 0]; + } + + $summary = AKSRT_Order_Repository::get_instance()->get_content_sales_summary([ + 'product_type' => AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE, + 'limit' => $limit, + 'offset' => $offset, + 'orderby' => 'sales_count', + 'order' => 'DESC', + ]); + + $posts = []; + foreach ($summary['items'] as $item) { + $pid = (int) ($item['product_id'] ?? 0); + $post = $pid > 0 ? get_post($pid) : null; + if ($post instanceof WP_Post && $post->post_type === 'post') { + $posts[] = $post; + } + } + + return [ + 'posts' => $posts, + 'total' => (int) ($summary['total'] ?? count($posts)), + ]; + } + + /** + * @param array $type_labels + */ + private function render_filters(string $pay_type, string $search, array $type_labels): void { + echo '
'; + echo ''; + echo ' '; + echo ' '; + submit_button(__('筛选', 'aksrt'), 'secondary', '', false); + echo '
'; + + echo '
'; + echo '

' . esc_html__('本页统计内容付费文章(product_type=resource),不含商城实体商品。订单销量来自已支付/已发货/已完成订单;Meta 销量为缓存计数。', 'aksrt') . '

'; + } + + private function ensure_cap(): void { + if (!current_user_can(self::CAPABILITY)) { + wp_die(esc_html__('您没有权限访问此页面。', 'aksrt')); + } + } + + private function render_header(string $title): void { + echo '
'; + echo '

' . esc_html($title) . '

'; + } + + private function render_footer(): void { + echo '
'; + } + + private function page_url(string $slug, array $args = []): string { + return add_query_arg(array_merge(['page' => $slug], $args), admin_url('admin.php')); + } + + private function render_pagination(int $pages, int $page_num): void { + if ($pages <= 1) { + return; + } + echo '
'; + echo paginate_links([ + 'base' => add_query_arg('paged', '%#%'), + 'format' => '', + 'prev_text' => '«', + 'next_text' => '»', + 'total' => $pages, + 'current' => $page_num, + ]); + echo '
'; + } +} diff --git a/inc/classes/class-aksrt-order-repository.php b/inc/classes/class-aksrt-order-repository.php index f6ee9f99..40f4cbb5 100644 --- a/inc/classes/class-aksrt-order-repository.php +++ b/inc/classes/class-aksrt-order-repository.php @@ -408,21 +408,7 @@ class AKSRT_Order_Repository { public function get_orders_admin(array $args = [], string $search = ''): array { global $wpdb; - $where = []; - $params = []; - - $status = sanitize_key((string) ($args['status'] ?? '')); - if ($status !== '' && $status !== 'all') { - $where[] = 'status = %s'; - $params[] = $status; - } - - if ($search !== '') { - $where[] = '(order_number LIKE %s OR product_type LIKE %s)'; - $like = '%' . $wpdb->esc_like($search) . '%'; - $params[] = $like; - $params[] = $like; - } + [$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)); @@ -442,21 +428,7 @@ class AKSRT_Order_Repository { public function get_orders_count(array $args = [], string $search = ''): int { global $wpdb; - $where = []; - $params = []; - - $status = sanitize_key((string) ($args['status'] ?? '')); - if ($status !== '' && $status !== 'all') { - $where[] = 'status = %s'; - $params[] = $status; - } - - if ($search !== '') { - $where[] = '(order_number LIKE %s OR product_type LIKE %s)'; - $like = '%' . $wpdb->esc_like($search) . '%'; - $params[] = $like; - $params[] = $like; - } + [$where, $params] = $this->build_admin_order_filters($args, $search); $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; $sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; @@ -467,6 +439,192 @@ class AKSRT_Order_Repository { 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_Domain_Model::ORDER_PRODUCT_RESOURCE)); + if ($product_type === '') { + $product_type = AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE; + } + + $status_in = $args['status_in'] ?? AKSRT_Domain_Model::paid_order_statuses(); + if (!is_array($status_in) || empty($status_in)) { + $status_in = AKSRT_Domain_Model::paid_order_statuses(); + } + $status_in = array_values(array_filter(array_map('sanitize_key', $status_in))); + if (empty($status_in)) { + return ['items' => [], 'total' => 0]; + } + + $where = ['product_type = %s', 'product_id > 0']; + $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, + product_type, + COUNT(*) AS sales_count, + AVG(amount) AS avg_amount, + SUM(amount) AS revenue + FROM {$table} + {$where_sql} + GROUP BY product_id, product_type + 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 = []; + foreach ((array) $rows as $row) { + $items[] = [ + 'product_id' => (int) ($row['product_id'] ?? 0), + 'product_type' => (string) ($row['product_type'] ?? $product_type), + 'sales_count' => (int) ($row['sales_count'] ?? 0), + 'avg_amount' => round((float) ($row['avg_amount'] ?? 0), 2), + 'revenue' => round((float) ($row['revenue'] ?? 0), 2), + ]; + } + + 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_Domain_Model::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' => (float) ($item['avg_amount'] ?? 0), + 'revenue' => (float) ($item['revenue'] ?? 0), + ]; + } + 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'] ?? '')); + if ($status !== '' && $status !== 'all') { + $where[] = 'status = %s'; + $params[] = $status; + } + + $product_type = sanitize_key((string) ($args['product_type'] ?? '')); + if ($product_type !== '') { + $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; + } + + if ($search !== '') { + $where[] = '(order_number LIKE %s OR product_type LIKE %s)'; + $like = '%' . $wpdb->esc_like($search) . '%'; + $params[] = $like; + $params[] = $like; + } + + return [$where, $params]; + } + /** * 获取订单统计 */ From bccb14a48746472d856deb0a73cfe5f4336259cf Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 15 Jul 2026 10:19:46 +0800 Subject: [PATCH 2/2] feat(forum): topic pay-hide and points view gates Align native topic meta box with runtime visibility contract (_aksrt_credits_cost / pay / VIP / hide-part), blank denied content payloads, and mark gap-matrix product + topic pay items done. --- docs/zibll-gap-matrix.md | 6 +- .../forum/class-aksrt-topic-meta-box.php | 204 ++++++++++++++++-- ...trait-aksrt-forum-topic-service-part-3.php | 21 +- inc/core-hooks/part-1.php | 4 +- 4 files changed, 207 insertions(+), 28 deletions(-) diff --git a/docs/zibll-gap-matrix.md b/docs/zibll-gap-matrix.md index 9f042571..48496f4e 100644 --- a/docs/zibll-gap-matrix.md +++ b/docs/zibll-gap-matrix.md @@ -48,7 +48,7 @@ Focus: admin **entry & page shape**. Runtime field parity is Phase 5+. | `zibpay_order_page` | Orders (legacy) | SPA | same native list | | | `zibpay_page#/shipping` | Shipping | SPA | `aksrt_shipping` | `AKSRT_Shipping` | | `zibpay_page#/after-sale` | After-sale | SPA | `aksrt_after_sale` | `AKSRT_After_Sale` | -| `zibpay_product_page` | Paid-post sales report | partial | optional later `aksrt_pay_products` | content pay | +| `zibpay_product_page` | Paid-post sales report | done | `aksrt_pay_products` | content pay (`AKSRT_Admin_Pay_Products` + order aggregation) | | `zibpay_income_page` | Creator income | `PaymentRevenue` | `aksrt_income` | `AKSRT_Revenue` | | `zibpay_rebate_page` | Rebate | `PaymentRebate` | `aksrt_rebate` | `AKSRT_Rebate` | | `zibpay_withdraw` | Withdraw | `ShopWithdrawals` | `aksrt_withdraw` | `AKSRT_Withdrawal` | @@ -99,8 +99,8 @@ Note: zibll「商品管理」`zibpay_product_page` is **paid article sales**, no | 2b Shipping/after-sale/coupons/income/rebate | done | | 3 users.php submenus | done | | 4 SPA strip | done (ops routes redirect to native) | -| 5+ behavior parity | done — native ops shell modularized (orders/shipping/after-sale/coupons/charge-cards/income/rebate/withdraw) + plate moderator roles/advanced limits/icon/sort + coupon product_scope create/edit/batch | -| 5 remaining | zibll pay-hide/points plate options out of scope | +| 5+ behavior parity | done — native ops shell modularized (orders/shipping/after-sale/coupons/charge-cards/income/rebate/withdraw) + plate moderator roles/advanced limits/icon/sort + coupon product_scope create/edit/batch + paid-post sales report (`aksrt_pay_products`) + topic pay/points visibility admin/runtime alignment | +| 5 remaining | plate follow-pay (zibll paid follow) still optional; topic-level pay/points/hide-part done | ## Dual-UI rule diff --git a/inc/classes/forum/class-aksrt-topic-meta-box.php b/inc/classes/forum/class-aksrt-topic-meta-box.php index 800088bc..f82a9ef7 100644 --- a/inc/classes/forum/class-aksrt-topic-meta-box.php +++ b/inc/classes/forum/class-aksrt-topic-meta-box.php @@ -2,6 +2,16 @@ /** * Native topic governance meta box for aksrt_forum_topic. * + * Visibility / pay / credits contract (runtime source of truth): + * - `_aksrt_visibility` public|login|comment|role|credits|pay|moderator + * - `_aksrt_credits_cost` points price when visibility=credits + * - `_aksrt_pay_price` cash price when visibility=pay + * - `_aksrt_pay_vip_price` optional VIP cash (0 = free) + * - `_aksrt_visibility_roles` roles when visibility=role + * - `_aksrt_pay_hide_part` partial hide flag (full hide still default) + * + * Legacy `_aksrt_pay_credits` / topic `_aksrt_allow_view` are rewritten on save. + * * @package AKSRT */ @@ -9,6 +19,16 @@ defined('ABSPATH') || exit; class AKSRT_Topic_Meta_Box { + private const VISIBILITY = [ + 'public', + 'login', + 'comment', + 'role', + 'credits', + 'pay', + 'moderator', + ]; + private static $instance = null; public static function get_instance(): self { @@ -49,10 +69,27 @@ class AKSRT_Topic_Meta_Box { $review = 'approved'; } $bbs_type = (string) (get_post_meta($post->ID, '_aksrt_bbs_type', true) ?: 'standard'); - $visibility = (string) (get_post_meta($post->ID, '_aksrt_visibility', true) ?: 'public'); - $allow_view = (string) (get_post_meta($post->ID, '_aksrt_allow_view', true) ?: 'public'); - $pay_price = (float) get_post_meta($post->ID, '_aksrt_pay_price', true); - $pay_credits= (int) get_post_meta($post->ID, '_aksrt_pay_credits', true); + $visibility = sanitize_key((string) (get_post_meta($post->ID, '_aksrt_visibility', true) ?: 'public')); + if (!in_array($visibility, self::VISIBILITY, true)) { + $visibility = 'public'; + } + + $credits_cost = (int) get_post_meta($post->ID, '_aksrt_credits_cost', true); + if ($credits_cost <= 0) { + // Legacy dual key. + $credits_cost = max(0, (int) get_post_meta($post->ID, '_aksrt_pay_credits', true)); + } + $pay_price = (float) get_post_meta($post->ID, '_aksrt_pay_price', true); + $pay_vip_price = get_post_meta($post->ID, '_aksrt_pay_vip_price', true); + $vip_set = ($pay_vip_price !== '' && $pay_vip_price !== null && $pay_vip_price !== false); + $vip_price_val = $vip_set ? (float) $pay_vip_price : ''; + $pay_hide_part = (int) get_post_meta($post->ID, '_aksrt_pay_hide_part', true) === 1; + $view_roles = get_post_meta($post->ID, '_aksrt_visibility_roles', true); + if (!is_array($view_roles)) { + $view_roles = []; + } + $view_roles = array_values(array_filter(array_map('sanitize_key', $view_roles))); + $wp_roles = $this->wp_role_options(); $plates = get_posts([ 'post_type' => 'aksrt_forum_plate', @@ -61,6 +98,16 @@ class AKSRT_Topic_Meta_Box { 'orderby' => 'title', 'order' => 'ASC', ]); + + $visibility_labels = [ + 'public' => __('公开', 'aksrt'), + 'login' => __('登录可见', 'aksrt'), + 'comment' => __('评论可见', 'aksrt'), + 'role' => __('指定角色', 'aksrt'), + 'credits' => __('支付积分后可见', 'aksrt'), + 'pay' => __('付费后可见', 'aksrt'), + 'moderator' => __('仅版主', 'aksrt'), + ]; ?>

@@ -104,26 +151,42 @@ class AKSRT_Topic_Meta_Box {

-

- - + $role_label) : ?> + +

-

- +

+ + + +

+

+

-

- - +

+ + + +

+

+ +

save_visibility_meta($post_id); // Keep board taxonomy bridge roughly in sync when plate selected. if ($plate_id > 0 && taxonomy_exists('aksrt_forum_board')) { @@ -188,4 +244,106 @@ class AKSRT_Topic_Meta_Box { } } } + + private function save_visibility_meta(int $post_id): void { + $visibility = isset($_POST['aksrt_visibility']) ? sanitize_key((string) wp_unslash($_POST['aksrt_visibility'])) : 'public'; + if (!in_array($visibility, self::VISIBILITY, true)) { + $visibility = 'public'; + } + update_post_meta($post_id, '_aksrt_visibility', $visibility); + + // Rewrite legacy dual allow_view so admin/runtime stay on one contract. + $allow_view_map = [ + 'public' => 'public', + 'login' => 'login', + 'comment' => 'login', + 'role' => 'public', + 'credits' => 'pay', + 'pay' => 'pay', + 'moderator' => 'moderator', + ]; + update_post_meta($post_id, '_aksrt_allow_view', $allow_view_map[$visibility] ?? 'public'); + + // Roles. + if ($visibility === 'role') { + $roles = $this->sanitize_role_list($_POST['aksrt_visibility_roles'] ?? []); + update_post_meta($post_id, '_aksrt_visibility_roles', $roles); + } else { + delete_post_meta($post_id, '_aksrt_visibility_roles'); + } + + // Credits (canonical + legacy dual-write). + if ($visibility === 'credits') { + $cost = isset($_POST['aksrt_credits_cost']) ? max(0, (int) wp_unslash($_POST['aksrt_credits_cost'])) : 0; + // Accept legacy field name if UI still posts it. + if ($cost <= 0 && isset($_POST['aksrt_pay_credits'])) { + $cost = max(0, (int) wp_unslash($_POST['aksrt_pay_credits'])); + } + update_post_meta($post_id, '_aksrt_credits_cost', $cost); + update_post_meta($post_id, '_aksrt_pay_credits', $cost); + } else { + delete_post_meta($post_id, '_aksrt_credits_cost'); + delete_post_meta($post_id, '_aksrt_pay_credits'); + } + + // Cash pay. + if ($visibility === 'pay') { + $price = isset($_POST['aksrt_pay_price']) ? max(0, (float) wp_unslash($_POST['aksrt_pay_price'])) : 0; + update_post_meta($post_id, '_aksrt_pay_price', $price); + + if (isset($_POST['aksrt_pay_vip_price']) && (string) wp_unslash($_POST['aksrt_pay_vip_price']) !== '') { + $vip = max(0, (float) wp_unslash($_POST['aksrt_pay_vip_price'])); + update_post_meta($post_id, '_aksrt_pay_vip_price', $vip); + } else { + delete_post_meta($post_id, '_aksrt_pay_vip_price'); + } + } else { + delete_post_meta($post_id, '_aksrt_pay_price'); + delete_post_meta($post_id, '_aksrt_pay_vip_price'); + } + + // Partial hide only meaningful for pay/credits; otherwise clear. + if (in_array($visibility, ['pay', 'credits'], true)) { + update_post_meta($post_id, '_aksrt_pay_hide_part', !empty($_POST['aksrt_pay_hide_part']) ? 1 : 0); + } else { + delete_post_meta($post_id, '_aksrt_pay_hide_part'); + } + } + + /** + * @param mixed $raw + * @return string[] + */ + private function sanitize_role_list($raw): array { + if (!is_array($raw)) { + return []; + } + $allowed = array_keys($this->wp_role_options()); + $out = []; + foreach ($raw as $role) { + $role = sanitize_key((string) $role); + if ($role !== '' && in_array($role, $allowed, true)) { + $out[] = $role; + } + } + return array_values(array_unique($out)); + } + + /** + * @return array + */ + private function wp_role_options(): array { + if (!function_exists('wp_roles')) { + return []; + } + $roles = wp_roles(); + if (!$roles || !is_array($roles->roles)) { + return []; + } + $out = []; + foreach ($roles->roles as $key => $data) { + $out[sanitize_key((string) $key)] = (string) ($data['name'] ?? $key); + } + return $out; + } } diff --git a/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-3.php b/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-3.php index ea1f5d9c..bbc5d880 100644 --- a/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-3.php +++ b/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-3.php @@ -65,6 +65,17 @@ trait AKSRT_Forum_Topic_Service_Part_3 { ? $post->post_excerpt : wp_trim_words(wp_strip_all_tags((string) $post->post_content), 40); + $pay_hide_part = (int) get_post_meta($topic_id, '_aksrt_pay_hide_part', true) === 1; + $content = (string) $post->post_content; + if (!$can_view) { + // Prevent content leak via payload/API when template forgets can_view check. + // Partial-hide flag only applies after unlock; denied users get empty body. + $content = ''; + if (in_array($visibility, [AKSRT_Forum::VISIBILITY_CREDITS, AKSRT_Forum::VISIBILITY_PAY, AKSRT_Forum::VISIBILITY_LOGIN, AKSRT_Forum::VISIBILITY_COMMENT, AKSRT_Forum::VISIBILITY_ROLE, AKSRT_Forum::VISIBILITY_MODERATOR], true)) { + $excerpt = ''; + } + } + $sticky_level = $this->resolve_sticky_level($topic_id); $featured = get_post_meta($topic_id, '_aksrt_featured', true) === '1'; $locked = get_post_meta($topic_id, '_aksrt_locked', true) === '1'; @@ -81,11 +92,16 @@ trait AKSRT_Forum_Topic_Service_Part_3 { $cover = $this->build_cover_payload($topic_id); + $credits_cost = max(0, (int) get_post_meta($topic_id, '_aksrt_credits_cost', true)); + if ($credits_cost <= 0) { + $credits_cost = max(0, (int) get_post_meta($topic_id, '_aksrt_pay_credits', true)); + } + return [ 'id' => $topic_id, 'title' => (string) get_the_title($topic_id), 'excerpt' => (string) $excerpt, - 'content' => (string) $post->post_content, + 'content' => $content, 'status' => (string) $post->post_status, 'author_id' => $author_id, 'author' => $this->build_user_summary($author_id), @@ -127,6 +143,9 @@ trait AKSRT_Forum_Topic_Service_Part_3 { 'can_view' => $can_view, 'can_edit' => $this->forum->can_edit_topic($viewer_user_id, $topic_id), 'can_moderate' => $this->forum->can_moderate_topic($viewer_user_id, $topic_id), + 'pay_hide_part' => $pay_hide_part, + 'credits_cost' => $credits_cost, + 'pay_price' => (float) get_post_meta($topic_id, '_aksrt_pay_price', true), ]; } diff --git a/inc/core-hooks/part-1.php b/inc/core-hooks/part-1.php index 7e0d9eed..05cfadc7 100644 --- a/inc/core-hooks/part-1.php +++ b/inc/core-hooks/part-1.php @@ -327,8 +327,10 @@ function aksrt_register_forum_topic_meta() { '_aksrt_allow_view' => ['type' => 'string', 'default' => 'public'], '_aksrt_pay_price' => ['type' => 'number', 'default' => 0], '_aksrt_pay_vip_price' => ['type' => 'number', 'default' => 0], - '_aksrt_pay_credits' => ['type' => 'integer', 'default' => 0], + '_aksrt_pay_credits' => ['type' => 'integer', 'default' => 0], // legacy alias of credits_cost + '_aksrt_credits_cost' => ['type' => 'integer', 'default' => 0], // runtime unlock price '_aksrt_pay_hide_part' => ['type' => 'integer', 'default' => 0], + '_aksrt_visibility_roles' => ['type' => 'array', 'default' => []], // 状态工作流扩展位 (Wave4) '_aksrt_closed' => ['type' => 'integer', 'default' => 0],