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],