'全部', 'sort' => 'date'], ['label' => '推荐', 'sort' => 'featured'], ['label' => '最新回复', 'sort' => 'last_reply'], ['label' => '热门', 'sort' => 'hot'], ['label' => '精华', 'sort' => 'essence'], ]; $allowed_sorts = []; foreach ($tabs as $tab) { $sort = sanitize_key((string) ($tab['sort'] ?? '')); if ($sort !== '') { $allowed_sorts[] = $sort; } } if ($orderby === '' || !in_array($orderby, $allowed_sorts, true)) { $active_index = max(0, (int) ($board_page['tab_active_index'] ?? 0)); $orderby = sanitize_key((string) ($tabs[$active_index]['sort'] ?? 'date')); if ($orderby === '') { $orderby = 'date'; } } $compose_url = function_exists('aksrt_get_forum_compose_url') ? aksrt_get_forum_compose_url(['board' => $board_id]) : home_url('/'); $top_info = !isset($board_page['top_info']) || !empty($board_page['top_info']); if ($this->forum->is_plate_cpt_active()) { $view = $this->get_plate_view_compat($board_id, $user_id, $page, $orderby); $view['tabs'] = $tabs; $view['active_sort'] = $orderby; $view['top_info'] = $top_info; $view['compose_url'] = $compose_url; return $view; } $board = get_term($board_id, 'aksrt_forum_board'); if (!$board || is_wp_error($board)) { return [ 'board' => null, 'topics' => [], 'sticky_topics' => [], 'moderators' => [], 'pagination' => ['page' => 1, 'max_pages' => 1, 'total' => 0], 'permissions' => [], 'application' => null, 'board_options' => [], 'tabs' => $tabs, 'active_sort' => $orderby, 'top_info' => $top_info, 'compose_url' => function_exists('aksrt_get_forum_compose_url') ? aksrt_get_forum_compose_url() : home_url('/'), ]; } $topics = $this->forum->get_topic_list([ 'board_id' => $board_id, 'page' => $page, 'limit' => 20, 'status' => 'publish', 'viewer_user_id' => $user_id, 'orderby' => $orderby, ]); $sticky_topics = $this->forum->get_sticky_topics_for_board($board_id); $board_link = get_term_link($board); return [ 'board' => [ 'id' => $board_id, 'name' => (string) $board->name, 'description' => (string) $board->description, 'content' => '', 'icon' => (string) get_term_meta($board_id, '_aksrt_icon', true), 'topic_count' => (int) $board->count, 'reply_count' => 0, 'today_reply_count' => 0, 'views' => 0, 'follow_count' => 0, 'last_reply' => '', 'last_post' => '', 'url' => !is_wp_error($board_link) ? (string) $board_link : '', 'thumbnail_url' => '', 'author_id' => 0, 'allow_view' => 'public', ], 'topics' => $topics['items'], 'sticky_topics' => array_slice($sticky_topics, 0, 5), 'moderators' => $this->forum->get_board_moderator_profiles($board_id), 'pagination' => [ 'page' => $topics['page'], 'max_pages' => $topics['max_pages'], 'total' => $topics['total'], ], 'permissions' => [ 'can_compose' => $user_id > 0, 'can_apply_moderator' => $user_id > 0 && !$this->forum->is_board_moderator($user_id, $board_id), 'can_moderate' => $this->forum->is_board_moderator($user_id, $board_id), ], 'application' => $user_id > 0 ? $this->forum->get_board_application_for_user($user_id, $board_id) : null, 'board_options' => $this->get_boards(), 'tabs' => $tabs, 'active_sort' => $orderby, 'top_info' => $top_info, 'compose_url' => $compose_url, ]; } /** * Plate CPT board view (backwards-compatible with get_board_view return shape). */ private function get_plate_view_compat(int $plate_id, int $user_id, int $page, string $orderby = 'date'): array { $plate = $this->get_plate($plate_id); if (!$plate) { return [ 'board' => null, 'topics' => [], 'sticky_topics' => [], 'moderators' => [], 'pagination' => ['page' => 1, 'max_pages' => 1, 'total' => 0], 'permissions' => [], 'application' => null, 'board_options' => [], ]; } // Check access permission $allow_view = $plate['allow_view'] ?? 'public'; if (!$this->can_view_plate($user_id, $plate_id, $allow_view)) { return [ 'board' => $plate, 'topics' => [], 'sticky_topics' => [], 'moderators' => $this->get_plate_moderator_profiles($plate_id), 'pagination' => ['page' => 1, 'max_pages' => 1, 'total' => 0], 'permissions' => [ 'can_compose' => false, 'can_apply_moderator' => false, 'can_moderate' => false, 'access_denied' => true, 'access_reason' => $allow_view, ], 'application' => null, 'board_options' => $this->get_boards(), ]; } $topics = $this->forum->get_topic_list([ 'plate_id' => $plate_id, 'page' => $page, 'limit' => 20, 'status' => 'publish', 'viewer_user_id' => $user_id, 'orderby' => $orderby, ]); $sticky_topics = $this->get_sticky_topics_for_plate($plate_id); return [ 'board' => $plate, 'topics' => $topics['items'], 'sticky_topics' => array_slice($sticky_topics, 0, 5), 'moderators' => $this->get_plate_moderator_profiles($plate_id), 'pagination' => [ 'page' => $topics['page'], 'max_pages' => $topics['max_pages'], 'total' => $topics['total'], ], 'permissions' => [ 'can_compose' => $user_id > 0 && $this->can_compose_in_plate($user_id, $plate_id), 'can_apply_moderator' => $user_id > 0 && $this->forum->forum_user_can($user_id, 'moderator_apply'), 'can_moderate' => $this->forum->forum_user_can($user_id, 'topic_moderate', $plate_id), 'can_manage_plate' => $this->forum->forum_user_can($user_id, 'plate_manage', $plate_id), 'can_manage_mods' => $this->forum->forum_user_can($user_id, 'moderator_manage', $plate_id), 'user_tier' => $this->forum->get_user_plate_tier($user_id, $plate_id), ], 'application' => $user_id > 0 ? $this->forum->get_board_application_for_user($user_id, $plate_id) : null, 'board_options' => $this->get_boards(), ]; } /** * Check if a user can view a plate (§7.2 板块阅读权限). */ public function can_view_plate(int $user_id, int $plate_id, string $allow_view = ''): bool { if ($allow_view === '') { $allow_view = (string) (get_post_meta($plate_id, '_aksrt_allow_view', true) ?: 'public'); } if ($allow_view === 'public') { return true; } if ($user_id <= 0) { // Guest cash follow unlock via signed cookie (money follow pay only). if ($allow_view === 'follow' && function_exists('aksrt_guest_has_plate_follow_access') && aksrt_guest_has_plate_follow_access($plate_id) ) { return true; } return false; } if ($this->is_plate_moderator($user_id, $plate_id)) { return true; } if ($allow_view === 'login') { return true; } if ($allow_view === 'follow') { return $this->is_user_following_plate($user_id, $plate_id); } if ($allow_view === 'role') { $allowed_roles = get_post_meta($plate_id, '_aksrt_allow_view_roles', true); if (!is_array($allowed_roles) || empty($allowed_roles)) { return true; } $user = get_userdata($user_id); if (!$user) { return false; } return !empty(array_intersect($user->roles, $allowed_roles)); } return true; } /** * Check if a user can compose in a plate (§7.2 板块发帖限制). */ public function can_compose_in_plate(int $user_id, int $plate_id): bool { if ($user_id <= 0) { return false; } if ($this->is_plate_moderator($user_id, $plate_id)) { return true; } $add_limit = (string) (get_post_meta($plate_id, '_aksrt_add_limit', true) ?: 'all'); if ($add_limit === 'all' || $add_limit === 'login') { return true; } // Moderators already returned true above. if ($add_limit === 'moderator') { return false; } $user = get_userdata($user_id); if (!$user) { return false; } if ($add_limit === 'role') { $allowed_roles = get_post_meta($plate_id, '_aksrt_add_limit_roles', true); if (!is_array($allowed_roles) || empty($allowed_roles)) { return true; } return !empty(array_intersect($user->roles, $allowed_roles)); } // Unknown contract → deny (no legacy fallthrough). return false; } /** * Check if a user is following a plate (§7.2 关注). */ public function is_user_following_plate(int $user_id, int $plate_id): bool { if ($plate_id <= 0) { return false; } if ($user_id <= 0) { return function_exists('aksrt_guest_has_plate_follow_access') && aksrt_guest_has_plate_follow_access($plate_id); } $follow_list = get_user_meta($user_id, 'aksrt_follow_plate', true); if (!is_array($follow_list)) { return false; } return in_array($plate_id, $follow_list, true); } /** * Toggle user follow on a plate (§7.2 关注/取消关注). * * When plate has paid follow enabled and user is not following, returns * require_pay payload instead of free-following. * * @return array{followed?:bool,follow_count?:int,require_pay?:bool,pay?:array,message?:string,success?:bool} */ public function toggle_follow_plate(int $user_id, int $plate_id): array { if ($user_id <= 0) { return ['followed' => false, 'follow_count' => 0, 'success' => false, 'message' => __('请先登录', 'aksrt')]; } $follow_list = get_user_meta($user_id, 'aksrt_follow_plate', true); if (!is_array($follow_list)) { $follow_list = []; } $followed = in_array($plate_id, $follow_list, true); if ($followed) { // Unfollow is always free (zibll warns but still allows cancel). $follow_list = array_values(array_diff($follow_list, [$plate_id])); update_user_meta($user_id, 'aksrt_follow_plate', array_values(array_unique(array_map('intval', $follow_list)))); $count = $this->count_plate_followers($plate_id); update_post_meta($plate_id, '_aksrt_follow_count', $count); return [ 'followed' => false, 'follow_count' => $count, 'success' => true, ]; } // Follow path — paid gate. if ($this->is_plate_follow_pay_required($plate_id, $user_id)) { $price = $this->resolve_plate_follow_pay_price($plate_id, $user_id); return [ 'followed' => false, 'follow_count' => (int) get_post_meta($plate_id, '_aksrt_follow_count', true), 'require_pay' => true, 'success' => false, 'message' => __('关注该版块需要付费', 'aksrt'), 'pay' => $price, ]; } $follow_list[] = $plate_id; update_user_meta($user_id, 'aksrt_follow_plate', array_values(array_unique(array_map('intval', $follow_list)))); $count = $this->count_plate_followers($plate_id); update_post_meta($plate_id, '_aksrt_follow_count', $count); return [ 'followed' => true, 'follow_count' => $count, 'success' => true, ]; } /** * Whether global+plate paid follow is on and user still needs to pay. */ public function is_plate_follow_pay_required(int $plate_id, int $user_id): bool { if ($user_id <= 0 || $plate_id <= 0) { return false; } if ($this->is_user_following_plate($user_id, $plate_id)) { return false; } if ($this->is_plate_moderator($user_id, $plate_id) || user_can($user_id, 'manage_options')) { return false; } if (!$this->is_plate_follow_pay_enabled($plate_id)) { return false; } // Already purchased plate_follow order. if (class_exists('\AKSRT\Payment\OrderRepository') && class_exists('\AKSRT\Core\DomainModel')) { if (\AKSRT\Payment\OrderRepository::get_instance()->has_paid_access( $user_id, \AKSRT\Core\DomainModel::ORDER_PRODUCT_PLATE_FOLLOW, $plate_id )) { return false; } } return true; } public function is_plate_follow_pay_enabled(int $plate_id): bool { if ($plate_id <= 0) { return false; } // Global switch from forum settings (SPA). $global_on = true; if (class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_forum_settings')) { $cfg = \AKSRT\Settings\Settings::get_forum_settings(); // Nested permissions.plate_follow_pay or flat. if (isset($cfg['permissions']['plate_follow_pay'])) { $global_on = !empty($cfg['permissions']['plate_follow_pay']); } elseif (isset($cfg['plate_follow_pay'])) { $global_on = !empty($cfg['plate_follow_pay']); } } if (!$global_on) { return false; } if ((int) get_post_meta($plate_id, '_aksrt_follow_pay', true) !== 1) { return false; } $modo = sanitize_key((string) (get_post_meta($plate_id, '_aksrt_follow_pay_modo', true) ?: 'money')); if ($modo === 'points') { return (int) get_post_meta($plate_id, '_aksrt_follow_pay_points', true) > 0; } return (float) get_post_meta($plate_id, '_aksrt_follow_pay_price', true) > 0; } /** * @return array{amount:float|int,modo:string,is_vip_price:bool,is_free:bool,label:string,base:float|int,currency:string,vip_level:int,vip_set:bool} */ public function resolve_plate_follow_pay_price(int $plate_id, int $user_id = 0): array { $modo = sanitize_key((string) (get_post_meta($plate_id, '_aksrt_follow_pay_modo', true) ?: 'money')); if (!in_array($modo, ['money', 'points'], true)) { $modo = 'money'; } $is_points = $modo === 'points'; $base = $is_points ? max(0, (int) get_post_meta($plate_id, '_aksrt_follow_pay_points', true)) : max(0.0, (float) get_post_meta($plate_id, '_aksrt_follow_pay_price', 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; } } $pick = static function ($raw) use ($is_points) { if ($raw === '' || $raw === null || $raw === false) { return null; } if (!is_numeric($raw)) { return null; } return $is_points ? max(0, (int) $raw) : max(0.0, (float) $raw); }; $vip2 = $pick(get_post_meta($plate_id, '_aksrt_follow_pay_vip2_price', true)); $vip1 = $pick(get_post_meta($plate_id, '_aksrt_follow_pay_vip1_price', true)); if ($vip1 === null) { // Legacy single VIP key → vip1. $vip1 = $pick(get_post_meta($plate_id, '_aksrt_follow_pay_vip_price', true)); } $amount = $base; $is_vip_price = false; $vip_set = ($vip1 !== null || $vip2 !== null); if ($vip_level >= 2 && $vip2 !== null) { $amount = $vip2; $is_vip_price = true; } elseif ($vip_level >= 1 && $vip1 !== null) { $amount = $vip1; $is_vip_price = true; } elseif ($vip_level >= 2 && $vip1 !== null) { // VIP2 user falls back to VIP1 when VIP2 unset. $amount = $vip1; $is_vip_price = true; } if ($is_vip_price) { return [ 'amount' => $amount, 'modo' => $modo, 'is_vip_price' => true, 'is_free' => $amount <= 0, 'label' => $amount <= 0 ? __('VIP 免费关注', 'aksrt') : ($is_points ? sprintf(__('VIP %d 积分', 'aksrt'), (int) $amount) : sprintf(__('VIP ¥%s', 'aksrt'), number_format((float) $amount, 2, '.', ''))), 'base' => $base, 'currency' => $is_points ? 'points' : 'CNY', 'vip_level' => $vip_level, 'vip_set' => true, ]; } return [ 'amount' => $base, 'modo' => $modo, 'is_vip_price' => false, 'is_free' => false, 'label' => $is_points ? sprintf(__('%d 积分', 'aksrt'), (int) $base) : sprintf(__('¥%s', 'aksrt'), number_format((float) $base, 2, '.', '')), 'base' => $base, 'currency' => $is_points ? 'points' : 'CNY', 'vip_level' => $vip_level, 'vip_set' => $vip_set, ]; } /** * Force-add plate to user follow list (after paid success). */ public function mark_plate_followed(int $user_id, int $plate_id): void { if ($user_id <= 0 || $plate_id <= 0) { return; } $follow_list = get_user_meta($user_id, 'aksrt_follow_plate', true); if (!is_array($follow_list)) { $follow_list = []; } $follow_list[] = $plate_id; update_user_meta($user_id, 'aksrt_follow_plate', array_values(array_unique(array_map('intval', $follow_list)))); $count = $this->count_plate_followers($plate_id); update_post_meta($plate_id, '_aksrt_follow_count', $count); } /** * Purchase plate follow with points or balance (creates plate_follow order when cash). * * @return array{success:bool,message:string,followed?:bool,follow_count?:int,order_number?:string} */ public function purchase_plate_follow(int $user_id, int $plate_id): array { if ($user_id <= 0) { return $this->forum->error(__('请先登录', 'aksrt')); } $post = get_post($plate_id); if (!$post || $post->post_type !== 'aksrt_forum_plate') { return $this->forum->error(__('版块不存在', 'aksrt')); } if ($this->is_user_following_plate($user_id, $plate_id)) { return $this->forum->success(__('已关注', 'aksrt'), [ 'followed' => true, 'follow_count' => (int) get_post_meta($plate_id, '_aksrt_follow_count', true), ]); } if (!$this->is_plate_follow_pay_enabled($plate_id)) { // Free follow fallback. $this->mark_plate_followed($user_id, $plate_id); return $this->forum->success(__('关注成功', 'aksrt'), [ 'followed' => true, 'follow_count' => (int) get_post_meta($plate_id, '_aksrt_follow_count', true), ]); } $price = $this->resolve_plate_follow_pay_price($plate_id, $user_id); if (!empty($price['is_free'])) { $this->mark_plate_followed($user_id, $plate_id); return $this->forum->success(__('VIP 免费关注成功', 'aksrt'), [ 'followed' => true, 'follow_count' => (int) get_post_meta($plate_id, '_aksrt_follow_count', true), 'free' => true, ]); } if (!class_exists('\AKSRT\Credits\Credits')) { return $this->forum->error(__('积分系统不可用', 'aksrt')); } $modo = (string) ($price['modo'] ?? 'money'); $amount = $price['amount'] ?? 0; if ($modo === 'points') { $cost = max(0, (int) $amount); if ($cost <= 0) { return $this->forum->error(__('积分价格未设置', 'aksrt')); } $balance = \AKSRT\Credits\Credits::get_balance($user_id); if ($balance < $cost) { return $this->forum->error(sprintf(__('积分不足,需要 %d 积分', 'aksrt'), $cost)); } $deducted = \AKSRT\Credits\Credits::deduct($user_id, $cost, 'forum_plate_follow', [ 'plate_id' => $plate_id, ], sprintf(__('付费关注版块「%s」', 'aksrt'), get_the_title($plate_id))); if (is_wp_error($deducted) || $deducted === false) { $msg = is_wp_error($deducted) ? $deducted->get_error_message() : __('积分扣除失败', 'aksrt'); return $this->forum->error($msg ?: __('积分扣除失败', 'aksrt')); } $this->mark_plate_followed($user_id, $plate_id); $this->grant_plate_follow_author_share($user_id, $plate_id, $cost, true); return $this->forum->success(__('关注成功', 'aksrt'), [ 'followed' => true, 'follow_count' => (int) get_post_meta($plate_id, '_aksrt_follow_count', true), 'credits_cost' => $cost, ]); } // Cash: multi-gateway cashier only (PlateFollowPay REST). No Credits×100 bypass. return $this->forum->error(__('请通过收银台支付关注费用。', 'aksrt'), [ 'require_cashier' => true, 'pay' => $price, 'modo' => 'money', 'plate_id' => $plate_id, ]); } private function grant_plate_follow_author_share(int $buyer_id, int $plate_id, int $cost, bool $is_points): void { if (!$is_points || $cost <= 0 || !class_exists('\AKSRT\Credits\Credits')) { return; } $post = get_post($plate_id); if (!$post) { return; } $author_id = (int) $post->post_author; if ($author_id <= 0 || $author_id === $buyer_id) { return; } $author_share = max(0, (int) ($this->forum->get_forum_config()['credits']['author_share_percent'] ?? 70)); $author_amount = (int) floor($cost * $author_share / 100); if ($author_amount <= 0) { return; } \AKSRT\Credits\Credits::add($author_id, $author_amount, 'forum_plate_follow_income', [ 'plate_id' => $plate_id, 'buyer_id' => $buyer_id, ], sprintf(__('版块「%s」被付费关注', 'aksrt'), get_the_title($plate_id))); } /** * Count followers for a plate. */ private function count_plate_followers(int $plate_id): int { global $wpdb; return (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE meta_key = 'aksrt_follow_plate' AND meta_value LIKE %s", '%i:' . $plate_id . ';%' )); } /** * Get plates a user follows. */ public function get_user_followed_plates(int $user_id): array { $follow_list = get_user_meta($user_id, 'aksrt_follow_plate', true); if (!is_array($follow_list) || empty($follow_list)) { return []; } $plates = get_posts([ 'post_type' => 'aksrt_forum_plate', 'post__in' => array_map('intval', $follow_list), 'post_status' => 'publish', 'posts_per_page' => 100, 'orderby' => 'post__in', ]); $result = []; foreach ($plates as $plate) { $result[] = $this->build_plate_payload($plate); } return $result; } /** * Get a user's managed plates (plates where user is moderator/author). * * @return array{owned:array,moderating:array} */ public function get_user_managed_plates(int $user_id): array { $owned = []; $moderating = []; if (!$this->forum->is_plate_cpt_active()) { return ['owned' => $owned, 'moderating' => $moderating]; } // Plates where user is author (超级版主) $own_plates = get_posts([ 'post_type' => 'aksrt_forum_plate', 'author' => $user_id, 'post_status' => 'publish', 'posts_per_page' => 50, 'orderby' => 'title', 'order' => 'ASC', ]); foreach ($own_plates as $plate) { $owned[] = $this->build_plate_payload($plate); } // Plates where user is a moderator (in _aksrt_moderators list) global $wpdb; $mod_plate_ids = $wpdb->get_col($wpdb->prepare( "SELECT pm.post_id FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id AND p.post_type = 'aksrt_forum_plate' AND p.post_status = 'publish' WHERE pm.meta_key = %s", '_aksrt_moderators' )); foreach ($mod_plate_ids as $plate_id) { $plate_id = (int) $plate_id; // Skip plates already in owned if (in_array($plate_id, wp_list_pluck($own_plates, 'ID'), true)) { continue; } $mods = get_post_meta($plate_id, '_aksrt_moderators', true); if (is_array($mods) && in_array($user_id, $mods, true)) { $plate = get_post($plate_id); if ($plate) { $moderating[] = $this->build_plate_payload($plate); } } } return ['owned' => $owned, 'moderating' => $moderating]; } // ── Plate utilities (extracted from facade) ── public function increment_plate_views(int $plate_id): void { if ($plate_id <= 0 || !$this->forum->is_plate_cpt_active()) { return; } $views = (int) get_post_meta($plate_id, '_aksrt_views', true); update_post_meta($plate_id, '_aksrt_views', $views + 1); } /** * Check if a plate qualifies as "hot" (§7.2 热门版块). */ public function is_hot_plate(int $plate_id): bool { $config = $this->forum->get_config(); $threshold = (int) ($config['other']['hot_plate_threshold'] ?? 100); $posts_count = (int) get_post_meta($plate_id, '_aksrt_posts_count', true); $follow_count = (int) get_post_meta($plate_id, '_aksrt_follow_count', true); return ($posts_count + $follow_count * 2) >= $threshold; } /** * Get plate categories (分区) with moderators. */ public function get_plate_categories(): array { $terms = get_terms([ 'taxonomy' => 'aksrt_plate_cat', 'hide_empty' => false, 'orderby' => 'name', 'order' => 'ASC', ]); if (is_wp_error($terms) || empty($terms)) { return []; } $categories = []; foreach ($terms as $term) { $term_id = (int) $term->term_id; $moderator_ids = get_term_meta($term_id, '_aksrt_cat_moderator', true); if (!is_array($moderator_ids)) { $moderator_ids = []; } $categories[] = [ 'id' => $term_id, 'name' => (string) $term->name, 'description' => (string) $term->description, 'slug' => (string) $term->slug, 'count' => (int) $term->count, 'parent_id' => (int) $term->parent, 'moderators' => array_values(array_unique(array_filter(array_map('intval', $moderator_ids)))), 'add_limit' => (string) (get_term_meta($term_id, '_aksrt_add_limit', true) ?: 'all'), 'url' => (string) get_term_link($term), ]; } return $categories; } }