feat(admin,forum): download logs, hidecontent, VIP tiers, users commerce

Add aksrt_download_logs workbench, zibll hidecontent alias + topic
partial-hide path, VIP1/VIP2 price matrix for topics, and users list
commerce snapshot column with deep links.
This commit is contained in:
2026-07-15 11:13:48 +08:00
parent cf6ad0e972
commit 8d31e65ccd
14 changed files with 611 additions and 32 deletions
+1
View File
@@ -28,6 +28,7 @@ Do **not** reintroduce operational workbenches into the SPA once native pages sh
├─ 概览 aksrt_pay
├─ 订单 aksrt_orders
├─ 付费文章 aksrt_pay_products
├─ 下载记录 aksrt_download_logs
├─ 发货及物流 aksrt_shipping
├─ 售后管理 aksrt_after_sale
├─ 优惠码 aksrt_coupons
+2 -2
View File
@@ -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 + paid-post sales report (`aksrt_pay_products`) + topic pay/points visibility admin/runtime alignment + plate follow-pay MVP + post list sales column + order type tabs + topic plate re-check |
| 5 remaining | download-log admin page; topic VIP1/2 full matrix; hidecontent shortcode partial-hide; users commerce columns |
| 5+ behavior parity | done — native ops shell modularized + plate follow-pay MVP + topic pay/points + sales report + post sales column + order type tabs + download logs page + hidecontent alias + topic VIP1/2 matrix + users commerce column + topic partial hide |
| 5 remaining | optional: multi-gateway for forum/topics; full zibll download modal UX; topic virtual_sales/coupon fields |
## Dual-UI rule
+3
View File
@@ -374,6 +374,9 @@ if (is_admin() && class_exists('AKSRT_Admin_Pay_Orders')) {
if (is_admin() && class_exists('AKSRT_Admin_Pay_Products')) {
AKSRT_Admin_Pay_Products::get_instance()->init();
}
if (is_admin() && class_exists('AKSRT_Admin_Pay_Download_Logs')) {
AKSRT_Admin_Pay_Download_Logs::get_instance()->init();
}
if (is_admin() && class_exists('AKSRT_Admin_Pay_Withdrawals')) {
AKSRT_Admin_Pay_Withdrawals::get_instance()->init();
}
+1
View File
@@ -179,6 +179,7 @@ require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-shippi
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-download-logs.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';
@@ -0,0 +1,202 @@
<?php
/**
* Native WP admin: resource download token/log browser (zibll paydown log parity, best-effort).
*
* Source: {prefix}aksrt_dl_token (issued tokens; used rows kept ~7 days by cleanup).
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Admin_Pay_Download_Logs {
public const CAPABILITY = 'manage_options';
public const PAGE_SLUG = 'aksrt_download_logs';
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
// Read-only.
}
public function render_page(): void {
$this->ensure_cap();
echo '<div class="wrap"><h1>' . esc_html__('下载记录', 'aksrt') . '</h1>';
if (!class_exists('AKSRT_DB_Migrator')) {
echo '<div class="notice notice-error"><p>' . esc_html__('数据库服务不可用。', 'aksrt') . '</p></div></div>';
return;
}
$table = AKSRT_DB_Migrator::table('dl_token');
if ($table === '') {
echo '<div class="notice notice-error"><p>' . esc_html__('下载令牌表未初始化。', 'aksrt') . '</p></div></div>';
return;
}
$post_id = isset($_GET['post_id']) ? max(0, (int) $_GET['post_id']) : 0;
$user_id = isset($_GET['user_id']) ? max(0, (int) $_GET['user_id']) : 0;
$only_used = !isset($_GET['used']) || (string) $_GET['used'] !== '0';
$page_num = max(1, (int) ($_GET['paged'] ?? 1));
$per_page = 30;
$result = $this->query_logs($table, [
'post_id' => $post_id,
'user_id' => $user_id,
'only_used' => $only_used,
'limit' => $per_page,
'offset' => ($page_num - 1) * $per_page,
]);
$items = $result['items'];
$total = $result['total'];
$pages = max(1, (int) ceil($total / $per_page));
echo '<p class="description">' . esc_html__('基于下载令牌表(aksrt_dl_token)。已使用记录会保留一段时间后清理;未使用且过期的令牌会被定期删除。', 'aksrt') . '</p>';
echo '<form method="get" style="margin:12px 0;">';
echo '<input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '" />';
echo '<label>' . esc_html__('文章 ID', 'aksrt') . ' <input type="number" min="0" name="post_id" value="' . esc_attr($post_id > 0 ? (string) $post_id : '') . '" class="small-text" /></label> ';
echo '<label>' . esc_html__('用户 ID', 'aksrt') . ' <input type="number" min="0" name="user_id" value="' . esc_attr($user_id > 0 ? (string) $user_id : '') . '" class="small-text" /></label> ';
echo '<label><input type="checkbox" name="used" value="1" ' . checked($only_used, true, false) . ' /> ' . esc_html__('仅已使用', 'aksrt') . '</label> ';
// When unchecked, pass used=0 explicitly.
if (!$only_used) {
// no-op; checkbox not checked
}
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
echo ' <a class="button" href="' . esc_url(admin_url('admin.php?page=' . self::PAGE_SLUG)) . '">' . esc_html__('重置', 'aksrt') . '</a>';
echo '</form>';
// Fix: unchecked checkbox means used filter off — re-read without default if form submitted with no used.
// Already handled via isset($_GET['used']).
echo '<table class="wp-list-table widefat fixed striped">';
echo '<thead><tr>';
echo '<th>' . esc_html__('时间', 'aksrt') . '</th>';
echo '<th>' . esc_html__('用户', 'aksrt') . '</th>';
echo '<th>' . esc_html__('文章', 'aksrt') . '</th>';
echo '<th>' . esc_html__('源序号', 'aksrt') . '</th>';
echo '<th>' . esc_html__('积分消耗', 'aksrt') . '</th>';
echo '<th>' . esc_html__('使用', 'aksrt') . '</th>';
echo '<th>' . esc_html__('过期', 'aksrt') . '</th>';
echo '<th>' . esc_html__('IP', 'aksrt') . '</th>';
echo '</tr></thead><tbody>';
if (!$items) {
echo '<tr><td colspan="8">' . esc_html__('暂无下载记录。', 'aksrt') . '</td></tr>';
}
foreach ($items as $row) {
$uid = (int) ($row['user_id'] ?? 0);
$pid = (int) ($row['post_id'] ?? 0);
$user = $uid > 0 ? get_userdata($uid) : null;
$title = $pid > 0 ? get_the_title($pid) : '';
if ($title === '') {
$title = $pid > 0 ? sprintf(__('(已删除 #%d)', 'aksrt'), $pid) : '—';
}
$edit = $pid > 0 ? get_edit_post_link($pid, 'raw') : '';
$used = (int) ($row['used_count'] ?? 0);
$max = (int) ($row['max_uses'] ?? 1);
echo '<tr>';
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
echo '<td>';
if ($user) {
echo esc_html($user->display_name . ' (#' . $uid . ')');
echo '<br><a href="' . esc_url(admin_url('admin.php?page=' . self::PAGE_SLUG . '&user_id=' . $uid)) . '">' . esc_html__('仅此用户', 'aksrt') . '</a>';
} else {
echo esc_html((string) $uid);
}
echo '</td>';
echo '<td>';
if ($edit) {
echo '<a href="' . esc_url($edit) . '">' . esc_html($title) . '</a>';
} else {
echo esc_html($title);
}
if ($pid > 0) {
echo '<br><a href="' . esc_url(admin_url('admin.php?page=' . self::PAGE_SLUG . '&post_id=' . $pid)) . '">' . esc_html__('仅此文章', 'aksrt') . '</a>';
}
echo '</td>';
echo '<td>' . esc_html((string) (int) ($row['source_index'] ?? 0)) . '</td>';
echo '<td>' . esc_html((string) (int) ($row['cost'] ?? 0)) . '</td>';
echo '<td>' . esc_html($used . ' / ' . $max) . ($used > 0 ? ' <span class="dashicons dashicons-yes" style="color:green;"></span>' : '') . '</td>';
echo '<td>' . esc_html((string) ($row['expires_at'] ?? '')) . '</td>';
echo '<td><code>' . esc_html((string) ($row['ip_bind'] ?? '')) . '</code></td>';
echo '</tr>';
}
echo '</tbody></table>';
if ($pages > 1) {
echo '<div class="tablenav"><div class="tablenav-pages">';
echo paginate_links([
'base' => add_query_arg('paged', '%#%'),
'format' => '',
'prev_text' => '&laquo;',
'next_text' => '&raquo;',
'total' => $pages,
'current' => $page_num,
]);
echo '</div></div>';
}
echo '</div>';
}
/**
* @param array{post_id?:int,user_id?:int,only_used?:bool,limit?:int,offset?:int} $args
* @return array{items: array<int,array<string,mixed>>, total:int}
*/
private function query_logs(string $table, array $args): array {
global $wpdb;
$where = ['1=1'];
$params = [];
$post_id = (int) ($args['post_id'] ?? 0);
if ($post_id > 0) {
$where[] = 'post_id = %d';
$params[] = $post_id;
}
$user_id = (int) ($args['user_id'] ?? 0);
if ($user_id > 0) {
$where[] = 'user_id = %d';
$params[] = $user_id;
}
if (!empty($args['only_used'])) {
$where[] = 'used_count > 0';
}
$where_sql = implode(' AND ', $where);
$limit = max(1, min(100, (int) ($args['limit'] ?? 30)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
$total = $params
? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params))
: (int) $wpdb->get_var($count_sql);
$sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
$list_params = $params;
$list_params[] = $limit;
$list_params[] = $offset;
$rows = $wpdb->get_results($wpdb->prepare($sql, $list_params), ARRAY_A);
return [
'items' => is_array($rows) ? $rows : [],
'total' => $total,
];
}
private function ensure_cap(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
}
}
}
@@ -65,6 +65,17 @@ class AKSRT_Admin_Pay_Menus {
wp_die(esc_html__('付费文章模块未初始化。', 'aksrt'));
},
],
'download_logs' => [
'slug' => 'aksrt_download_logs',
'title' => __('下载记录', 'aksrt'),
'callback' => static function (): void {
if (class_exists('AKSRT_Admin_Pay_Download_Logs')) {
AKSRT_Admin_Pay_Download_Logs::get_instance()->render_page();
return;
}
wp_die(esc_html__('下载记录模块未初始化。', 'aksrt'));
},
],
'shipping' => [
'slug' => 'aksrt_shipping',
'title' => __('发货及物流', 'aksrt'),
@@ -141,6 +141,10 @@ class AKSRT_Admin_Pay_Products {
echo '<a class="button button-small" href="' . esc_url($edit_url) . '">' . esc_html__('编辑', 'aksrt') . '</a>';
}
echo '<a class="button button-small" href="' . esc_url($orders_url) . '">' . esc_html__('订单', 'aksrt') . '</a>';
if ($pt === AKSRT_Domain_Model::PAY_TYPE_DOWNLOAD) {
$dl_url = $this->page_url('aksrt_download_logs', ['post_id' => $post_id]);
echo '<a class="button button-small" href="' . esc_url($dl_url) . '">' . esc_html__('下载记录', 'aksrt') . '</a>';
}
$permalink = get_permalink($post_id);
if ($permalink) {
echo '<a class="button button-small" target="_blank" rel="noopener noreferrer" href="' . esc_url($permalink) . '">' . esc_html__('查看', 'aksrt') . '</a>';
@@ -532,6 +532,10 @@ class AKSRT_Post_Meta_Box {
echo '<br><span class="description">' . esc_html__('暂无订单', 'aksrt') . '</span>';
}
echo '<br><a href="' . esc_url($products_url) . '">' . esc_html__('付费文章', 'aksrt') . '</a>';
if ($pay_type === AKSRT_Domain_Model::PAY_TYPE_DOWNLOAD) {
$dl_url = admin_url('admin.php?page=aksrt_download_logs&post_id=' . $post_id);
echo ' · <a href="' . esc_url($dl_url) . '">' . esc_html__('下载记录', 'aksrt') . '</a>';
}
echo '</div>';
}
@@ -44,10 +44,14 @@ class AKSRT_User_Credits_Admin {
public function add_balance_column(array $columns): array {
$currency_name = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_currency_name() : __('积分', 'aksrt');
$columns['aksrt_credits_balance'] = sprintf(__('%s余额', 'aksrt'), $currency_name);
$columns['aksrt_user_commerce'] = __('资产/分成', 'aksrt');
return $columns;
}
public function render_balance_column(string $output, string $column_name, int $user_id): string {
if ($column_name === 'aksrt_user_commerce') {
return $this->render_commerce_column($user_id);
}
if ($column_name !== 'aksrt_credits_balance') {
return $output;
}
@@ -59,6 +63,55 @@ class AKSRT_User_Credits_Admin {
return '<strong>' . esc_html((string) $balance) . '</strong>';
}
/**
* Compact commerce snapshot: VIP, revenue, rebate, download logs link.
*/
private function render_commerce_column(int $user_id): string {
$bits = [];
if (function_exists('aksrt_get_user_vip_level')) {
$lv = (int) aksrt_get_user_vip_level($user_id);
if ($lv > 0) {
$bits[] = '<span class="aksrt-vip-badge">VIP' . esc_html((string) $lv) . '</span>';
}
}
if (class_exists('AKSRT_Revenue')) {
$stats = AKSRT_Revenue::get_instance()->get_stats($user_id);
$pending = (float) ($stats['pending'] ?? 0);
$total = (float) ($stats['total'] ?? 0);
if ($total > 0 || $pending > 0) {
$bits[] = sprintf(
/* translators: 1 total 2 pending */
esc_html__('分成 ¥%1$s(待结 ¥%2$s)', 'aksrt'),
number_format($total, 2),
number_format($pending, 2)
);
$bits[] = '<a href="' . esc_url(admin_url('admin.php?page=aksrt_income')) . '">' . esc_html__('明细', 'aksrt') . '</a>';
}
}
if (class_exists('AKSRT_Rebate')) {
$list = AKSRT_Rebate::get_instance()->get_list(['user_id' => $user_id, 'limit' => 1]);
$total_r = (int) ($list['total'] ?? 0);
if ($total_r > 0) {
$sum = 0.0;
// Lightweight sum via stats if available; else show count.
if (method_exists(AKSRT_Rebate::get_instance(), 'get_stats')) {
// global stats only — show link with count
$bits[] = sprintf(esc_html__('返佣 %d 笔', 'aksrt'), $total_r);
} else {
$bits[] = sprintf(esc_html__('返佣 %d 笔', 'aksrt'), $total_r);
}
$bits[] = '<a href="' . esc_url(admin_url('admin.php?page=aksrt_rebate')) . '">' . esc_html__('返佣', 'aksrt') . '</a>';
}
}
$bits[] = '<a href="' . esc_url(admin_url('admin.php?page=aksrt_download_logs&user_id=' . $user_id)) . '">' . esc_html__('下载记录', 'aksrt') . '</a>';
$bits[] = '<a href="' . esc_url(admin_url('admin.php?page=aksrt_orders&s=' . $user_id)) . '">' . esc_html__('订单', 'aksrt') . '</a>';
if (!$bits) {
return '—';
}
return '<div style="font-size:12px;line-height:1.5;">' . implode('<br>', $bits) . '</div>';
}
public function make_balance_sortable(array $columns): array {
$columns['aksrt_credits_balance'] = 'aksrt_credits_balance';
return $columns;
@@ -91,6 +144,11 @@ class AKSRT_User_Credits_Admin {
esc_html(sprintf(__('调整%s', 'aksrt'), $currency_name))
);
}
$actions['aksrt_user_dl_logs'] = sprintf(
'<a href="%s">%s</a>',
esc_url(admin_url('admin.php?page=aksrt_download_logs&user_id=' . (int) $user->ID)),
esc_html__('下载记录', 'aksrt')
);
return $actions;
}
@@ -83,6 +83,19 @@ class AKSRT_Topic_Meta_Box {
$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_vip1_price = get_post_meta($post->ID, '_aksrt_pay_vip1_price', true);
$pay_vip2_price = get_post_meta($post->ID, '_aksrt_pay_vip2_price', true);
$vip1_set = ($pay_vip1_price !== '' && $pay_vip1_price !== null && $pay_vip1_price !== false);
$vip2_set = ($pay_vip2_price !== '' && $pay_vip2_price !== null && $pay_vip2_price !== false);
// Migrate single VIP price into vip1 display if vip1 empty.
if (!$vip1_set && $vip_set) {
$vip1_set = true;
$pay_vip1_price = $pay_vip_price;
}
$credits_vip1 = get_post_meta($post->ID, '_aksrt_credits_vip1', true);
$credits_vip2 = get_post_meta($post->ID, '_aksrt_credits_vip2', true);
$credits_vip1_set = ($credits_vip1 !== '' && $credits_vip1 !== null && $credits_vip1 !== false);
$credits_vip2_set = ($credits_vip2 !== '' && $credits_vip2 !== null && $credits_vip2 !== false);
$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)) {
@@ -172,21 +185,33 @@ class AKSRT_Topic_Meta_Box {
<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-credits-fields">
<label for="aksrt_credits_vip1"><strong><?php esc_html_e('VIP1 积分价', 'aksrt'); ?></strong></label>
<input type="number" step="1" min="0" class="widefat" name="aksrt_credits_vip1" id="aksrt_credits_vip1" value="<?php echo esc_attr($credits_vip1_set ? (string) (int) $credits_vip1 : ''); ?>" placeholder="<?php esc_attr_e('留空=不设', 'aksrt'); ?>">
</p>
<p class="aksrt-topic-meta-credits-fields">
<label for="aksrt_credits_vip2"><strong><?php esc_html_e('VIP2 积分价', 'aksrt'); ?></strong></label>
<input type="number" step="1" min="0" class="widefat" name="aksrt_credits_vip2" id="aksrt_credits_vip2" value="<?php echo esc_attr($credits_vip2_set ? (string) (int) $credits_vip2 : ''); ?>" placeholder="<?php esc_attr_e('留空=不设;0=免费', 'aksrt'); ?>">
</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 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>
<label for="aksrt_pay_vip1_price"><strong><?php esc_html_e('VIP1 价格(元)', 'aksrt'); ?></strong></label>
<input type="number" step="0.01" min="0" class="widefat" name="aksrt_pay_vip1_price" id="aksrt_pay_vip1_price" value="<?php echo esc_attr($vip1_set ? (string) (float) $pay_vip1_price : ''); ?>" placeholder="<?php esc_attr_e('留空=不设 VIP1 价', 'aksrt'); ?>">
</p>
<p class="aksrt-topic-meta-pay-fields">
<label for="aksrt_pay_vip2_price"><strong><?php esc_html_e('VIP2 价格(元)', 'aksrt'); ?></strong></label>
<input type="number" step="0.01" min="0" class="widefat" name="aksrt_pay_vip2_price" id="aksrt_pay_vip2_price" value="<?php echo esc_attr($vip2_set ? (string) (float) $pay_vip2_price : ''); ?>" placeholder="<?php esc_attr_e('留空=不设;0=VIP2 免费', 'aksrt'); ?>">
<span class="description"><?php esc_html_e('兼容旧「VIP 价格」字段:未填 VIP1 时仍可读 _aksrt_pay_vip_price。', '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>
<span class="description" style="display:block;"><?php esc_html_e('开启后正文仍输出,付费块用 [payshow] 或 [hidecontent type="payshow"] 包裹。未开启则全文门控。', 'aksrt'); ?></span>
</p>
<?php
}
@@ -281,9 +306,22 @@ class AKSRT_Topic_Meta_Box {
}
update_post_meta($post_id, '_aksrt_credits_cost', $cost);
update_post_meta($post_id, '_aksrt_pay_credits', $cost);
if (isset($_POST['aksrt_credits_vip1']) && (string) wp_unslash($_POST['aksrt_credits_vip1']) !== '') {
update_post_meta($post_id, '_aksrt_credits_vip1', max(0, (int) wp_unslash($_POST['aksrt_credits_vip1'])));
} else {
delete_post_meta($post_id, '_aksrt_credits_vip1');
}
if (isset($_POST['aksrt_credits_vip2']) && (string) wp_unslash($_POST['aksrt_credits_vip2']) !== '') {
update_post_meta($post_id, '_aksrt_credits_vip2', max(0, (int) wp_unslash($_POST['aksrt_credits_vip2'])));
} else {
delete_post_meta($post_id, '_aksrt_credits_vip2');
}
} else {
delete_post_meta($post_id, '_aksrt_credits_cost');
delete_post_meta($post_id, '_aksrt_pay_credits');
delete_post_meta($post_id, '_aksrt_credits_vip1');
delete_post_meta($post_id, '_aksrt_credits_vip2');
}
// Cash pay.
@@ -291,15 +329,34 @@ class AKSRT_Topic_Meta_Box {
$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']) !== '') {
$vip1_written = false;
if (isset($_POST['aksrt_pay_vip1_price']) && (string) wp_unslash($_POST['aksrt_pay_vip1_price']) !== '') {
$vip1 = max(0, (float) wp_unslash($_POST['aksrt_pay_vip1_price']));
update_post_meta($post_id, '_aksrt_pay_vip1_price', $vip1);
// Keep legacy single VIP key in sync with VIP1 for older readers.
update_post_meta($post_id, '_aksrt_pay_vip_price', $vip1);
$vip1_written = true;
} else {
delete_post_meta($post_id, '_aksrt_pay_vip1_price');
}
if (isset($_POST['aksrt_pay_vip2_price']) && (string) wp_unslash($_POST['aksrt_pay_vip2_price']) !== '') {
update_post_meta($post_id, '_aksrt_pay_vip2_price', max(0, (float) wp_unslash($_POST['aksrt_pay_vip2_price'])));
} else {
delete_post_meta($post_id, '_aksrt_pay_vip2_price');
}
// Legacy field if posted without vip1.
if (!$vip1_written && 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 {
update_post_meta($post_id, '_aksrt_pay_vip1_price', $vip);
} elseif (!$vip1_written) {
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');
delete_post_meta($post_id, '_aksrt_pay_vip1_price');
delete_post_meta($post_id, '_aksrt_pay_vip2_price');
}
// Partial hide only meaningful for pay/credits; otherwise clear.
@@ -68,11 +68,18 @@ trait AKSRT_Forum_Topic_Service_Part_3 {
$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 = '';
// Partial hide: keep body for shortcode processing; strip locked blocks at render.
// Full hide (default): blank content to avoid API/template leaks.
if ($pay_hide_part && in_array($visibility, [AKSRT_Forum::VISIBILITY_CREDITS, AKSRT_Forum::VISIBILITY_PAY], true)) {
// leave content; payshow/hidecontent shortcodes gate paid parts
$excerpt = wp_trim_words(wp_strip_all_tags(
preg_replace('/\[(payshow|hidecontent)[^\]]*\].*?\[\/\1\]/is', '', $content) ?? $content
), 40);
} else {
$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 = '';
}
}
}
@@ -334,6 +341,34 @@ trait AKSRT_Forum_Topic_Service_Part_3 {
}
$cost = max(0, (int) get_post_meta($topic_id, '_aksrt_credits_cost', true));
if ($cost <= 0) {
$cost = max(0, (int) get_post_meta($topic_id, '_aksrt_pay_credits', true));
}
// VIP tier discount for credits.
$vip_level = 0;
if (function_exists('aksrt_get_user_vip_level')) {
$vip_level = (int) aksrt_get_user_vip_level($user_id);
} elseif (function_exists('aksrt_is_vip2') && aksrt_is_vip2($user_id)) {
$vip_level = 2;
} elseif (function_exists('aksrt_is_vip') && aksrt_is_vip($user_id)) {
$vip_level = 1;
}
if ($vip_level >= 2) {
$v2 = get_post_meta($topic_id, '_aksrt_credits_vip2', true);
if ($v2 !== '' && $v2 !== null && $v2 !== false) {
$cost = max(0, (int) $v2);
}
} elseif ($vip_level >= 1) {
$v1 = get_post_meta($topic_id, '_aksrt_credits_vip1', true);
if ($v1 !== '' && $v1 !== null && $v1 !== false) {
$cost = max(0, (int) $v1);
}
}
if ($cost <= 0 && $vip_level > 0) {
// VIP free credits unlock.
$this->mark_topic_unlocked($user_id, $topic_id);
return $this->forum->success(__('VIP 免费解锁成功', 'aksrt'), ['free' => true]);
}
if ($cost <= 0) {
return $this->forum->error(__('积分价格未设置', 'aksrt'));
}
@@ -491,31 +526,69 @@ trait AKSRT_Forum_Topic_Service_Part_3 {
/**
* Resolve cash price for a pay-visibility topic.
*
* @return array{amount:float,is_vip_price:bool,is_free:bool,label:string,base:float,vip_set:bool}
* VIP resolution: VIP2 price → VIP1/legacy VIP price → base.
*
* @return array{amount:float,is_vip_price:bool,is_free:bool,label:string,base:float,vip_set:bool,vip_level:int}
*/
public function resolve_topic_pay_price(int $topic_id, int $user_id = 0): array {
$base_raw = get_post_meta($topic_id, '_aksrt_pay_price', true);
$base = is_numeric($base_raw) ? (float) $base_raw : 0.0;
$vip_raw = get_post_meta($topic_id, '_aksrt_pay_vip_price', true);
$vip_set = ($vip_raw !== '' && $vip_raw !== null && $vip_raw !== false);
$vip_price = $vip_set && is_numeric($vip_raw) ? (float) $vip_raw : 0.0;
$is_vip = $user_id > 0 && function_exists('aksrt_is_vip') && aksrt_is_vip($user_id);
if (!$is_vip && $user_id > 0 && function_exists('aksrt_is_vip_exempt') && aksrt_is_vip_exempt($user_id)) {
$is_vip = true;
$vip_level = 0;
if ($user_id > 0) {
if (function_exists('aksrt_get_user_vip_level')) {
$vip_level = (int) aksrt_get_user_vip_level($user_id);
} elseif (function_exists('aksrt_is_vip2') && aksrt_is_vip2($user_id)) {
$vip_level = 2;
} elseif (function_exists('aksrt_is_vip') && aksrt_is_vip($user_id)) {
$vip_level = 1;
}
if ($vip_level <= 0 && function_exists('aksrt_is_vip_exempt') && aksrt_is_vip_exempt($user_id)) {
$vip_level = 1;
}
}
if ($is_vip && $vip_set) {
$pick = static function ($raw) {
if ($raw === '' || $raw === null || $raw === false) {
return null;
}
return is_numeric($raw) ? (float) $raw : null;
};
$vip2 = $pick(get_post_meta($topic_id, '_aksrt_pay_vip2_price', true));
$vip1 = $pick(get_post_meta($topic_id, '_aksrt_pay_vip1_price', true));
if ($vip1 === null) {
// Legacy single VIP key.
$vip1 = $pick(get_post_meta($topic_id, '_aksrt_pay_vip_price', true));
}
$amount = $base;
$is_vip_price = false;
$vip_set = ($vip1 !== null || $vip2 !== null);
if ($vip_level >= 2 && $vip2 !== null) {
$amount = max(0.0, $vip2);
$is_vip_price = true;
} elseif ($vip_level >= 1 && $vip1 !== null) {
$amount = max(0.0, $vip1);
$is_vip_price = true;
} elseif ($vip_level >= 2 && $vip1 !== null) {
// VIP2 user falls back to VIP1 price when VIP2 unset.
$amount = max(0.0, $vip1);
$is_vip_price = true;
}
if ($is_vip_price) {
return [
'amount' => max(0.0, $vip_price),
'amount' => $amount,
'is_vip_price' => true,
'is_free' => $vip_price <= 0,
'label' => $vip_price <= 0
'is_free' => $amount <= 0,
'label' => $amount <= 0
? __('VIP 免费', 'aksrt')
: sprintf(__('VIP ¥%s', 'aksrt'), number_format($vip_price, 2, '.', '')),
: sprintf(__('VIP ¥%s', 'aksrt'), number_format($amount, 2, '.', '')),
'base' => $base,
'vip_set' => true,
'vip_level' => $vip_level,
];
}
@@ -528,6 +601,7 @@ trait AKSRT_Forum_Topic_Service_Part_3 {
: __('价格未设置', 'aksrt'),
'base' => $base,
'vip_set' => $vip_set,
'vip_level' => $vip_level,
];
}
+5 -1
View File
@@ -326,9 +326,13 @@ function aksrt_register_forum_topic_meta() {
// 付费内容扩展位(论坛帖子付费门槛;前台 forum-topic 渲染)
'_aksrt_allow_view' => ['type' => 'string', 'default' => 'public'],
'_aksrt_pay_price' => ['type' => 'number', 'default' => 0],
'_aksrt_pay_vip_price' => ['type' => 'number', 'default' => 0],
'_aksrt_pay_vip_price' => ['type' => 'number', 'default' => 0], // legacy single VIP
'_aksrt_pay_vip1_price' => ['type' => 'number', 'default' => 0],
'_aksrt_pay_vip2_price' => ['type' => 'number', '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_credits_vip1' => ['type' => 'integer', 'default' => 0],
'_aksrt_credits_vip2' => ['type' => 'integer', 'default' => 0],
'_aksrt_pay_hide_part' => ['type' => 'integer', 'default' => 0],
'_aksrt_visibility_roles' => ['type' => 'array', 'default' => []],
+88 -5
View File
@@ -37,6 +37,8 @@ class AKSRT_Shortcodes {
add_shortcode('reply', [$this, 'render_reply']);
add_shortcode('vip', [$this, 'render_vip']);
add_shortcode('payshow', [$this, 'render_payshow']);
// Zibll-compatible hidecontent alias (type=payshow|reply|login|vip|password|…)
add_shortcode('hidecontent', [$this, 'render_hidecontent']);
add_shortcode('content', [$this, 'render_content_gate']);
// 文章归档 / 个人资料卡 → AKSRT_Templates 模板
add_shortcode('aksrt_archive', [$this, 'render_archive']);
@@ -101,16 +103,13 @@ class AKSRT_Shortcodes {
}
/**
* [payshow]...[/payshow] — 付费后可见
* [payshow]...[/payshow] — 付费后可见(文章 content-pay 或论坛主题解锁)
*/
public function render_payshow(array $atts = [], string $content = ''): string {
$atts = shortcode_atts(['message' => ''], $atts, 'payshow');
$pid = get_the_ID();
$can_view = false;
if ($pid && class_exists('AKSRT_Content_Visibility')) {
$can_view = AKSRT_Content_Visibility::can_view_paid_content($pid);
}
$can_view = $this->viewer_can_see_paid_block($pid);
if ($can_view) {
return '<div class="aksrt-hidden-content aksrt-hidden-content--unlocked aksrt-hidden-content--payshow">'
@@ -132,6 +131,90 @@ class AKSRT_Shortcodes {
return $html;
}
/**
* Zibll [hidecontent type="payshow|reply|login|…"]…[/hidecontent]
*/
public function render_hidecontent(array $atts = [], string $content = ''): string {
$atts = shortcode_atts([
'type' => 'payshow',
'message' => '',
'pass' => '',
], $atts, 'hidecontent');
$type = sanitize_key((string) $atts['type']);
$msg = (string) $atts['message'];
switch ($type) {
case 'reply':
case 'comment':
return $this->render_reply(['message' => $msg], $content);
case 'login':
case 'signin':
return $this->render_hide([
'mode' => 'login',
'message' => $msg !== '' ? $msg : __('登录后可查看隐藏内容', 'aksrt'),
], $content);
case 'password':
case 'pass':
return $this->render_hide([
'mode' => 'password',
'message' => $msg,
'pass' => (string) $atts['pass'],
], $content);
case 'vip':
return $this->render_vip(['message' => $msg], $content);
case 'payshow':
case 'pay':
case 'points':
default:
return $this->render_payshow(['message' => $msg], $content);
}
}
/**
* Whether current viewer may see a paid hide block on this post/topic.
*/
private function viewer_can_see_paid_block(int $pid): bool {
if ($pid <= 0) {
return false;
}
$post = get_post($pid);
if (!$post instanceof WP_Post) {
return false;
}
$uid = get_current_user_id();
if ($uid > 0 && ((int) $post->post_author === $uid || user_can($uid, 'manage_options'))) {
return true;
}
// Forum topics: paid/credits unlock list or orders.
if ($post->post_type === 'aksrt_forum_topic' && class_exists('AKSRT_Forum')) {
$forum = AKSRT_Forum::get_instance();
$visibility = sanitize_key((string) get_post_meta($pid, '_aksrt_visibility', true));
if ($visibility === 'public' || $visibility === '') {
return true;
}
if (method_exists($forum, 'has_user_unlocked_topic') && $forum->has_user_unlocked_topic($uid, $pid)) {
return true;
}
if (method_exists($forum, 'has_user_paid_topic') && $forum->has_user_paid_topic($uid, $pid)) {
return true;
}
// Free VIP price counts as paid for cash topics.
if ($visibility === 'pay' && method_exists($forum, 'resolve_topic_pay_price')) {
$price = $forum->resolve_topic_pay_price($pid, $uid);
if (!empty($price['is_free'])) {
return true;
}
}
return false;
}
if (class_exists('AKSRT_Content_Visibility')) {
return AKSRT_Content_Visibility::can_view_paid_content($pid);
}
return false;
}
/**
* [content type="admin|author|vip"]...[/content] — 按角色/身份控制可见性
*/
+77
View File
@@ -59,6 +59,83 @@ if (empty($topic['can_view'])) :
$deny_vis = $topic['visibility']['key'] ?? '';
$credits_cost = (int) get_post_meta($topic_id, '_aksrt_credits_cost', true);
$pay_price = get_post_meta($topic_id, '_aksrt_pay_price', true);
$pay_hide_part = !empty($topic['pay_hide_part']) || ((int) get_post_meta($topic_id, '_aksrt_pay_hide_part', true) === 1);
$partial_ok = $pay_hide_part && in_array($deny_vis, ['pay', 'credits'], true);
// Partial hide: show title + body with payshow/hidecontent locked blocks + pay CTA.
if ($partial_ok) :
?>
<main id="main-content" class="site-main content-shell content-shell--stream content-shell--forum" tabindex="-1">
<div class="content-wrap container">
<div class="content-layout content-layout--no-sidebar">
<div class="content-layout__primary">
<div class="aksrt-forum-topic">
<header class="aksrt-forum-topic__header">
<h1 class="aksrt-forum-topic__title"><?php echo esc_html((string) ($topic['title'] ?? get_the_title($topic_id))); ?></h1>
</header>
<div class="aksrt-forum-topic__denied aksrt-forum-topic__denied--partial">
<i class="fas fa-lock"></i>
<p><?php esc_html_e('部分内容需解锁后查看。', 'aksrt'); ?></p>
<?php if ($deny_vis === 'credits' && $credits_cost > 0 && is_user_logged_in()) : ?>
<button type="button" class="aksrt-forum-topic__primary-btn js-aksrt-unlock-topic"
data-topic-id="<?php echo esc_attr((string) $topic_id); ?>"
data-cost="<?php echo esc_attr((string) $credits_cost); ?>">
<i class="fas fa-unlock"></i> <?php printf(esc_html__('使用 %d 积分解锁', 'aksrt'), $credits_cost); ?>
</button>
<span class="aksrt-forum-topic__unlock-feedback js-aksrt-action-feedback" aria-live="polite"></span>
<?php elseif ($deny_vis === 'pay' && is_user_logged_in()) : ?>
<?php
$charge_amount = (float) $pay_price;
$vip_price_raw = get_post_meta($topic_id, '_aksrt_pay_vip_price', true);
$vip_set = ($vip_price_raw !== '' && $vip_price_raw !== null && $vip_price_raw !== false);
$viewer_id = get_current_user_id();
$is_vip_user = $viewer_id > 0 && function_exists('aksrt_is_vip') && aksrt_is_vip($viewer_id);
$is_free_vip = false;
if ($is_vip_user && $vip_set) {
$charge_amount = max(0.0, (float) $vip_price_raw);
$is_free_vip = $charge_amount <= 0;
}
?>
<button type="button" class="aksrt-forum-topic__primary-btn js-aksrt-purchase-topic"
data-topic-id="<?php echo esc_attr((string) $topic_id); ?>"
data-price="<?php echo esc_attr(number_format($charge_amount, 2, '.', '')); ?>"
data-free="<?php echo $is_free_vip ? '1' : '0'; ?>">
<i class="fas fa-shopping-cart"></i>
<?php
if ($is_free_vip) {
esc_html_e('VIP 免费解锁', 'aksrt');
} else {
printf(esc_html__('余额支付 ¥%s', 'aksrt'), esc_html(number_format($charge_amount, 2, '.', '')));
}
?>
</button>
<span class="aksrt-forum-topic__unlock-feedback js-aksrt-action-feedback" aria-live="polite"></span>
<?php elseif (!is_user_logged_in()) : ?>
<a href="<?php echo esc_url(wp_login_url(get_permalink($topic_id))); ?>" class="aksrt-forum-topic__primary-btn"><?php esc_html_e('登录后解锁', 'aksrt'); ?></a>
<?php endif; ?>
</div>
<article class="aksrt-forum-topic__content aksrt-forum-topic__content--partial">
<?php
// Render WP content so payshow/hidecontent shortcodes run for locked parts.
if (have_posts()) {
while (have_posts()) {
the_post();
the_content();
}
} else {
echo apply_filters('the_content', (string) ($topic['content'] ?? '')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
?>
</article>
</div>
</div>
</div>
</div>
</main>
<?php
get_footer();
return;
endif;
?>
<main id="main-content" class="site-main content-shell content-shell--stream content-shell--forum" tabindex="-1">
<div class="content-wrap container">