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.
This commit is contained in:
2026-07-15 10:19:46 +08:00
parent 74bde18880
commit bccb14a487
4 changed files with 207 additions and 28 deletions
+3 -3
View File
@@ -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
+181 -23
View File
@@ -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'),
];
?>
<p>
<label for="aksrt_plate_id"><strong><?php esc_html_e('所属版块', 'aksrt'); ?></strong></label>
@@ -104,26 +151,42 @@ class AKSRT_Topic_Meta_Box {
<p>
<label for="aksrt_visibility"><strong><?php esc_html_e('可见性', 'aksrt'); ?></strong></label>
<select name="aksrt_visibility" id="aksrt_visibility" class="widefat">
<?php foreach (['public' => __('公开', 'aksrt'), 'login' => __('登录可见', 'aksrt'), 'comment' => __('评论可见', 'aksrt'), 'role' => __('角色', 'aksrt'), 'credits' => __('积分', 'aksrt'), 'pay' => __('付费', 'aksrt'), 'moderator' => __('版主', 'aksrt')] as $k => $label) : ?>
<?php foreach ($visibility_labels as $k => $label) : ?>
<option value="<?php echo esc_attr($k); ?>" <?php selected($visibility, $k); ?>><?php echo esc_html($label); ?></option>
<?php endforeach; ?>
</select>
</p>
<p>
<label for="aksrt_allow_view"><strong><?php esc_html_e('允许查看', 'aksrt'); ?></strong></label>
<select name="aksrt_allow_view" id="aksrt_allow_view" class="widefat">
<?php foreach (['public' => __('公开', 'aksrt'), 'login' => __('登录', 'aksrt'), 'pay' => __('付费', 'aksrt'), 'moderator' => __('版主', 'aksrt')] as $k => $label) : ?>
<option value="<?php echo esc_attr($k); ?>" <?php selected($allow_view, $k); ?>><?php echo esc_html($label); ?></option>
<p class="aksrt-topic-meta-role-fields">
<label for="aksrt_visibility_roles"><strong><?php esc_html_e('可查看角色', 'aksrt'); ?></strong></label>
<select id="aksrt_visibility_roles" name="aksrt_visibility_roles[]" class="widefat" multiple size="4">
<?php foreach ($wp_roles as $role_key => $role_label) : ?>
<option value="<?php echo esc_attr($role_key); ?>" <?php selected(in_array($role_key, $view_roles, true)); ?>>
<?php echo esc_html($role_label); ?>
</option>
<?php endforeach; ?>
</select>
<span class="description"><?php esc_html_e('仅「指定角色」时生效。', 'aksrt'); ?></span>
</p>
<p>
<label for="aksrt_pay_price"><strong><?php esc_html_e('付费价格', 'aksrt'); ?></strong></label>
<p class="aksrt-topic-meta-credits-fields">
<label for="aksrt_credits_cost"><strong><?php esc_html_e('积分价格', 'aksrt'); ?></strong></label>
<input type="number" step="1" min="0" class="widefat" name="aksrt_credits_cost" id="aksrt_credits_cost" value="<?php echo esc_attr((string) $credits_cost); ?>">
<span class="description"><?php esc_html_e('可见性为「支付积分后可见」时使用。', 'aksrt'); ?></span>
</p>
<p class="aksrt-topic-meta-pay-fields">
<label for="aksrt_pay_price"><strong><?php esc_html_e('付费价格(元)', 'aksrt'); ?></strong></label>
<input type="number" step="0.01" min="0" class="widefat" name="aksrt_pay_price" id="aksrt_pay_price" value="<?php echo esc_attr((string) $pay_price); ?>">
</p>
<p>
<label for="aksrt_pay_credits"><strong><?php esc_html_e('付费积分', 'aksrt'); ?></strong></label>
<input type="number" step="1" min="0" class="widefat" name="aksrt_pay_credits" id="aksrt_pay_credits" value="<?php echo esc_attr((string) $pay_credits); ?>">
<p class="aksrt-topic-meta-pay-fields">
<label for="aksrt_pay_vip_price"><strong><?php esc_html_e('VIP 价格(元)', 'aksrt'); ?></strong></label>
<input type="number" step="0.01" min="0" class="widefat" name="aksrt_pay_vip_price" id="aksrt_pay_vip_price" value="<?php echo esc_attr($vip_set ? (string) $vip_price_val : ''); ?>" placeholder="<?php esc_attr_e('留空=不设 VIP 价', 'aksrt'); ?>">
<span class="description"><?php esc_html_e('填 0 表示 VIP 免费;留空则 VIP 与普通同价。', 'aksrt'); ?></span>
</p>
<p class="aksrt-topic-meta-pay-hide-fields">
<label>
<input type="checkbox" name="aksrt_pay_hide_part" value="1" <?php checked($pay_hide_part); ?>>
<?php esc_html_e('仅隐藏部分内容', 'aksrt'); ?>
</label>
<span class="description" style="display:block;"><?php esc_html_e('开启后仍以全文门控为默认;部分隐藏依赖正文隐藏块。未解锁用户正文不会下发。', 'aksrt'); ?></span>
</p>
<?php
}
@@ -159,14 +222,7 @@ class AKSRT_Topic_Meta_Box {
}
update_post_meta($post_id, '_aksrt_bbs_type', $bbs_type);
$visibility = isset($_POST['aksrt_visibility']) ? sanitize_key((string) wp_unslash($_POST['aksrt_visibility'])) : 'public';
update_post_meta($post_id, '_aksrt_visibility', $visibility);
$allow_view = isset($_POST['aksrt_allow_view']) ? sanitize_key((string) wp_unslash($_POST['aksrt_allow_view'])) : 'public';
update_post_meta($post_id, '_aksrt_allow_view', $allow_view);
update_post_meta($post_id, '_aksrt_pay_price', isset($_POST['aksrt_pay_price']) ? max(0, (float) wp_unslash($_POST['aksrt_pay_price'])) : 0);
update_post_meta($post_id, '_aksrt_pay_credits', isset($_POST['aksrt_pay_credits']) ? max(0, (int) wp_unslash($_POST['aksrt_pay_credits'])) : 0);
$this->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<string, string>
*/
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;
}
}
@@ -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),
];
}
+3 -1
View File
@@ -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],