diff --git a/assets/js/forum-governance.js b/assets/js/forum-governance.js index 61216627..581617b1 100644 --- a/assets/js/forum-governance.js +++ b/assets/js/forum-governance.js @@ -718,6 +718,110 @@ }); } + function formatFollowPayLabel(pay) { + pay = pay || {}; + if (pay.is_free) { + return 'VIP 免费关注'; + } + if (pay.label) { + return String(pay.label); + } + if (pay.modo === 'points') { + return String(pay.amount || 0) + ' 积分'; + } + return '¥' + String(pay.amount || '0'); + } + + function purchasePlateFollow(plateId, button, payHint) { + var confirmMsg = '确认付费关注该版块?'; + if (payHint && payHint.is_free) { + confirmMsg = '确认使用 VIP 免费关注该版块?'; + } else if (payHint) { + confirmMsg = '确认支付 ' + formatFollowPayLabel(payHint) + ' 关注该版块?'; + } + if (!window.confirm(confirmMsg)) { + button.disabled = false; + return; + } + + var data = new FormData(); + data.append('action', 'aksrt_forum_purchase_plate_follow'); + data.append('plate_id', plateId); + data.append('_nonce', rootData.forumNonce || ''); + + fetch((rootData.ajaxUrl || '/wp-admin/admin-ajax.php'), { + method: 'POST', + body: data, + credentials: 'same-origin', + }).then(function (r) { return r.json(); }).then(function (payload) { + if (!payload.success) { + throw new Error((payload.data && payload.data.message) || '支付失败'); + } + var res = payload.data || {}; + button.setAttribute('data-followed', '1'); + button.classList.add('is-followed'); + var label = button.querySelector('span') || button; + if (button.querySelector('i') && button.childNodes.length) { + // keep icon, replace trailing text via last text-ish content + button.innerHTML = ' 已关注'; + } else { + button.textContent = '已关注'; + } + if (typeof window.aksrtToast === 'function') { + window.aksrtToast(res.message || '关注成功', 'success'); + } + setTimeout(function () { window.location.reload(); }, 600); + }).catch(function (error) { + var msg = error.message || '支付失败'; + if (typeof window.aksrtToast === 'function') { window.aksrtToast(msg, 'error'); } + else { window.alert(msg); } + button.disabled = false; + }); + } + + function bindFollowPlateButton(button) { + button.addEventListener('click', function () { + var plateId = parseInt(button.getAttribute('data-plate-id') || '0', 10); + if (!plateId || button.disabled) { + return; + } + button.disabled = true; + + var data = new FormData(); + data.append('action', 'aksrt_forum_follow_plate'); + data.append('plate_id', plateId); + data.append('_nonce', rootData.forumNonce || ''); + + fetch((rootData.ajaxUrl || '/wp-admin/admin-ajax.php'), { + method: 'POST', + body: data, + credentials: 'same-origin', + }).then(function (r) { return r.json(); }).then(function (payload) { + // Paid follow required + if (!payload.success && payload.data && payload.data.require_pay) { + purchasePlateFollow(plateId, button, payload.data.pay || {}); + return; + } + if (!payload.success) { + throw new Error((payload.data && payload.data.message) || '操作失败'); + } + var res = payload.data || {}; + var followed = !!res.followed; + button.setAttribute('data-followed', followed ? '1' : '0'); + button.classList.toggle('is-followed', followed); + button.innerHTML = followed + ? ' 已关注' + : ' 关注'; + button.disabled = false; + }).catch(function (error) { + var msg = error.message || '操作失败'; + if (typeof window.aksrtToast === 'function') { window.aksrtToast(msg, 'error'); } + else { window.alert(msg); } + button.disabled = false; + }); + }); + } + function bindComposeCoverToggle(form) { var coverTypeSelect = form.querySelector('#compose-cover-type'); var imageField = form.querySelector('#compose-cover-image-field'); @@ -1455,6 +1559,7 @@ document.querySelectorAll('.js-aksrt-adopt-answer').forEach(bindAdoptButton); document.querySelectorAll('.js-aksrt-unlock-topic').forEach(bindUnlockButton); document.querySelectorAll('.js-aksrt-purchase-topic').forEach(bindPurchaseButton); + document.querySelectorAll('.js-aksrt-follow-plate').forEach(bindFollowPlateButton); bindForumCommentEngine(); bindForumHomeTabs(); bindBoardSortTabs(); diff --git a/docs/admin-ia-map.md b/docs/admin-ia-map.md index ec40ee42..7b074bbe 100644 --- a/docs/admin-ia-map.md +++ b/docs/admin-ia-map.md @@ -27,6 +27,7 @@ Do **not** reintroduce operational workbenches into the SPA once native pages sh 支付中心 (aksrt_pay) pos ~36–38, dashicons-cart ├─ 概览 aksrt_pay ├─ 订单 aksrt_orders +├─ 付费文章 aksrt_pay_products ├─ 发货及物流 aksrt_shipping ├─ 售后管理 aksrt_after_sale ├─ 优惠码 aksrt_coupons diff --git a/docs/zibll-gap-matrix.md b/docs/zibll-gap-matrix.md index 48496f4e..4a8822e6 100644 --- a/docs/zibll-gap-matrix.md +++ b/docs/zibll-gap-matrix.md @@ -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 | -| 5 remaining | plate follow-pay (zibll paid follow) still optional; topic-level pay/points/hide-part done | +| 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 | ## Dual-UI rule 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 615fff0b..009ba899 100644 --- a/inc/classes/admin/native/class-aksrt-admin-pay-orders.php +++ b/inc/classes/admin/native/class-aksrt-admin-pay-orders.php @@ -77,6 +77,29 @@ class AKSRT_Admin_Pay_Orders { ? AKSRT_Domain_Model::order_status_labels() : []; + // Type tabs (zibll order subsubsub parity). + $type_tabs = [ + '' => __('全部类型', 'aksrt'), + 'resource' => __('内容付费', 'aksrt'), + 'vip' => __('VIP', 'aksrt'), + 'recharge' => __('充值', 'aksrt'), + 'shop_product' => __('商城商品', 'aksrt'), + 'plate_follow' => __('版块关注', 'aksrt'), + ]; + echo '
'; + echo '
'; echo ''; if ($product_type !== '') { diff --git a/inc/classes/class-aksrt-forum.php b/inc/classes/class-aksrt-forum.php index 64211289..a55173cb 100644 --- a/inc/classes/class-aksrt-forum.php +++ b/inc/classes/class-aksrt-forum.php @@ -351,6 +351,7 @@ class AKSRT_Forum { add_action('wp_ajax_aksrt_forum_topic_list', [$this, 'ajax_topic_list']); add_action('wp_ajax_aksrt_forum_create_topic', [$this, 'ajax_create_topic']); add_action('wp_ajax_aksrt_forum_follow_plate', [$this, 'ajax_follow_plate']); + add_action('wp_ajax_aksrt_forum_purchase_plate_follow', [$this, 'ajax_purchase_plate_follow']); add_action('wp_ajax_aksrt_forum_unlock_topic', [$this, 'ajax_unlock_topic']); add_action('wp_ajax_aksrt_forum_purchase_topic', [$this, 'ajax_purchase_topic']); @@ -450,6 +451,10 @@ class AKSRT_Forum { $this->board_service()->ajax_follow_plate(); } + public function ajax_purchase_plate_follow(): void { + $this->board_service()->ajax_purchase_plate_follow(); + } + public function ajax_unlock_topic(): void { $this->topic_service()->ajax_unlock_topic(); } diff --git a/inc/classes/class-aksrt-post-meta-box.php b/inc/classes/class-aksrt-post-meta-box.php index 71346002..355abd98 100644 --- a/inc/classes/class-aksrt-post-meta-box.php +++ b/inc/classes/class-aksrt-post-meta-box.php @@ -456,6 +456,7 @@ class AKSRT_Post_Meta_Box { public function add_columns(array $columns): array { $columns['aksrt_views'] = __('阅读量', 'aksrt'); $columns['aksrt_likes'] = __('点赞数', 'aksrt'); + $columns['aksrt_sales'] = __('销售数据', 'aksrt'); return $columns; } @@ -473,9 +474,67 @@ class AKSRT_Post_Meta_Box { $toc = esc_attr($meta['show_toc'] ?? ''); $mh = (int) ($meta['maxheight'] ?? 0); echo '' . esc_html($likes > 0 ? (string) $likes : '—') . ''; + } elseif ($column === 'aksrt_sales') { + $this->render_sales_column($post_id); } } + /** + * Content-pay sales summary for posts list (zibll 销售数据 column parity). + */ + private function render_sales_column(int $post_id): void { + if (!function_exists('aksrt_get_pay_meta') || !class_exists('AKSRT_Domain_Model')) { + echo '—'; + return; + } + $pay = aksrt_get_pay_meta($post_id); + $pay_type = AKSRT_Domain_Model::normalize_pay_type((string) ($pay['pay_type'] ?? AKSRT_Domain_Model::PAY_TYPE_NO)); + if ($pay_type === AKSRT_Domain_Model::PAY_TYPE_NO) { + echo '' . esc_html__('免费', 'aksrt') . ''; + return; + } + $labels = AKSRT_Domain_Model::pay_type_labels(); + $type_label = $labels[$pay_type] ?? $pay_type; + $modo = (($pay['pay_modo'] ?? '0') === 'points') ? 'points' : '0'; + if ($modo === 'points') { + $price_text = sprintf(__('%d 积分', 'aksrt'), (int) ceil((float) ($pay['points_price'] ?? $pay['price'] ?? 0))); + } else { + $price_text = '¥' . number_format((float) ($pay['price'] ?? 0), 2); + } + $meta_sales = (int) get_post_meta($post_id, AKSRT_Domain_Model::RESOURCE_SALES_VOLUME_KEY, true); + $db_sales = 0; + $revenue = 0.0; + if (class_exists('AKSRT_Order_Repository')) { + $map = AKSRT_Order_Repository::get_instance()->get_content_sales_map( + [$post_id], + AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE + ); + if (isset($map[$post_id])) { + $db_sales = (int) ($map[$post_id]['sales_count'] ?? 0); + $revenue = (float) ($map[$post_id]['revenue'] ?? 0); + } + } + $orders_url = admin_url('admin.php?page=aksrt_orders&product_type=resource&product_id=' . $post_id); + $products_url = admin_url('admin.php?page=aksrt_pay_products&s=' . $post_id); + + echo '
'; + echo '' . esc_html((string) $type_label) . ''; + echo '
' . esc_html($price_text); + echo '
' . esc_html(sprintf(__('Meta销量 %d', 'aksrt'), max(0, $meta_sales))); + if ($db_sales > 0) { + printf( + '
%s', + esc_url($orders_url), + esc_attr__('查看销售明细', 'aksrt'), + esc_html(sprintf(__('真实销量 %d · ¥%s', 'aksrt'), $db_sales, number_format($revenue, 2))) + ); + } else { + echo '
' . esc_html__('暂无订单', 'aksrt') . ''; + } + echo '
' . esc_html__('付费文章', 'aksrt') . ''; + echo '
'; + } + /** * §3.8 Render quick edit and bulk edit fields. */ diff --git a/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-2.php b/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-2.php index 4d4e2d9d..9956c9f7 100644 --- a/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-2.php +++ b/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-2.php @@ -295,11 +295,14 @@ trait AKSRT_Forum_Board_Service_Part_2 { /** * Toggle user follow on a plate (§7.2 关注/取消关注). * - * @return array{followed:bool,follow_count:int} + * 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]; + return ['followed' => false, 'follow_count' => 0, 'success' => false, 'message' => __('请先登录', 'aksrt')]; } $follow_list = get_user_meta($user_id, 'aksrt_follow_plate', true); @@ -309,23 +312,332 @@ trait AKSRT_Forum_Board_Service_Part_2 { $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])); - } else { - $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)))); - // Update plate follow_count $count = $this->count_plate_followers($plate_id); update_post_meta($plate_id, '_aksrt_follow_count', $count); return [ - 'followed' => !$followed, + '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_Order_Repository') && class_exists('AKSRT_Domain_Model')) { + if (AKSRT_Order_Repository::get_instance()->has_paid_access( + $user_id, + AKSRT_Domain_Model::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') && method_exists('AKSRT_Settings', 'get_forum_settings')) { + $cfg = AKSRT_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} + */ + 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_raw = get_post_meta($plate_id, '_aksrt_follow_pay_vip_price', true); + $vip_set = ($vip_raw !== '' && $vip_raw !== null && $vip_raw !== false); + $vip_price = $vip_set + ? ($is_points ? max(0, (int) $vip_raw) : max(0.0, (float) $vip_raw)) + : 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; + } + + if ($is_vip && $vip_set) { + $amount = $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', + ]; + } + + 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', + ]; + } + + /** + * 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')) { + 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::get_balance($user_id); + if ($balance < $cost) { + return $this->forum->error(sprintf(__('积分不足,需要 %d 积分', 'aksrt'), $cost)); + } + $deducted = AKSRT_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 via balance credits (same as topic pay). + if (!class_exists('AKSRT_Order') || !class_exists('AKSRT_Domain_Model')) { + return $this->forum->error(__('支付系统不可用', 'aksrt')); + } + $cash = max(0.0, (float) $amount); + if ($cash <= 0) { + return $this->forum->error(__('价格未设置', 'aksrt')); + } + $product_type = AKSRT_Domain_Model::ORDER_PRODUCT_PLATE_FOLLOW; + $order_number = AKSRT_Order::get_instance()->create($user_id, $product_type, $plate_id, $cash, 'CNY'); + if ($order_number === '') { + return $this->forum->error(__('创建订单失败', 'aksrt')); + } + $credits_cost = (int) ceil($cash * 100); + $balance = AKSRT_Credits::get_balance($user_id); + if ($balance < $credits_cost) { + AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_CANCELLED, [ + 'note' => __('余额不足,自动取消', 'aksrt'), + ]); + return $this->forum->error(sprintf( + __('余额不足,需要 %d 积分(约 ¥%s),当前余额 %d', 'aksrt'), + $credits_cost, + number_format($cash, 2, '.', ''), + $balance + )); + } + $deducted = AKSRT_Credits::deduct( + $user_id, + $credits_cost, + 'forum_plate_follow_pay', + ['plate_id' => $plate_id, 'order' => $order_number], + sprintf(__('付费关注版块「%s」', 'aksrt'), get_the_title($plate_id)) + ); + if (is_wp_error($deducted) || $deducted === false) { + AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_CANCELLED, [ + 'note' => __('扣款失败,自动取消', 'aksrt'), + ]); + $msg = is_wp_error($deducted) ? $deducted->get_error_message() : __('扣款失败', 'aksrt'); + return $this->forum->error($msg ?: __('扣款失败', 'aksrt')); + } + $paid = AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_PAID, [ + 'payment_method' => 'balance', + 'payment_transaction_id' => 'BAL_' . $order_number, + 'note' => sprintf(__('版块付费关注余额支付 %d 积分', 'aksrt'), $credits_cost), + ]); + if (!$paid) { + AKSRT_Credits::add( + $user_id, + $credits_cost, + 'forum_plate_follow_refund', + ['plate_id' => $plate_id, 'order' => $order_number], + sprintf(__('关注失败退款:%s', 'aksrt'), get_the_title($plate_id)) + ); + return $this->forum->error(__('订单确认失败,已尝试退回积分', 'aksrt')); + } + + $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), + 'order_number' => $order_number, + 'amount' => $cash, + 'credits_cost' => $credits_cost, + ]); + } + + 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')) { + 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::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. */ diff --git a/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-3.php b/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-3.php index 2954e3e3..1d7e50e1 100644 --- a/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-3.php +++ b/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-3.php @@ -132,6 +132,43 @@ trait AKSRT_Forum_Board_Service_Part_3 { } $result = $this->toggle_follow_plate($user_id, $plate_id); + if (!empty($result['require_pay'])) { + wp_send_json_error([ + 'code' => 'require_pay', + 'message' => (string) ($result['message'] ?? __('需要付费关注', 'aksrt')), + 'require_pay' => true, + 'pay' => $result['pay'] ?? [], + 'followed' => false, + 'follow_count' => (int) ($result['follow_count'] ?? 0), + ]); + } + if (isset($result['success']) && $result['success'] === false) { + wp_send_json_error([ + 'message' => (string) ($result['message'] ?? __('操作失败', 'aksrt')), + ]); + } + wp_send_json_success($result); + } + + public function ajax_purchase_plate_follow(): void { + check_ajax_referer('aksrt_forum_action', '_nonce'); + + $user_id = get_current_user_id(); + if ($user_id <= 0) { + wp_send_json_error(['message' => __('请先登录', 'aksrt')]); + } + + $plate_id = (int) ($_POST['plate_id'] ?? 0); + if ($plate_id <= 0) { + wp_send_json_error(['message' => __('版块不存在', 'aksrt')]); + } + + $result = $this->purchase_plate_follow($user_id, $plate_id); + if (empty($result['success'])) { + wp_send_json_error([ + 'message' => (string) ($result['message'] ?? __('支付失败', 'aksrt')), + ]); + } wp_send_json_success($result); } } diff --git a/inc/classes/forum/class-aksrt-plate-meta-box.php b/inc/classes/forum/class-aksrt-plate-meta-box.php index 336e131c..993b03c8 100644 --- a/inc/classes/forum/class-aksrt-plate-meta-box.php +++ b/inc/classes/forum/class-aksrt-plate-meta-box.php @@ -89,8 +89,10 @@ class AKSRT_Plate_Meta_Box { $wp_roles = $this->wp_role_options(); $icon = (string) get_post_meta($plate_id, '_aksrt_icon', true); $sort_order = (int) get_post_meta($plate_id, '_aksrt_sort_order', true); + $follow_pay = $this->get_follow_pay_meta($plate_id); $this->render_access_fields($allow_view, $add_limit, $view_roles, $add_roles, $wp_roles); + $this->render_follow_pay_fields($follow_pay); $this->render_display_fields($icon, $sort_order); $this->render_moderator_fields($moderator_ids, $roles_map, $author_id); $this->render_applications($applications); @@ -151,6 +153,42 @@ class AKSRT_Plate_Meta_Box { +

+ + +

+

+ + +

+

+ + +

+

+ + +

+

+ + + +

+
+

@@ -280,6 +318,7 @@ class AKSRT_Plate_Meta_Box { } $this->save_access_meta($post_id); + $this->save_follow_pay_meta($post_id); $this->save_display_meta($post_id); $this->save_moderators($post_id, (int) $post->post_author); $this->save_applications(); @@ -325,6 +364,70 @@ class AKSRT_Plate_Meta_Box { } } + private function save_follow_pay_meta(int $post_id): void { + $enabled = !empty($_POST['aksrt_plate_follow_pay_enabled']); + $modo = isset($_POST['aksrt_plate_follow_pay_modo']) + ? sanitize_key((string) wp_unslash($_POST['aksrt_plate_follow_pay_modo'])) + : 'money'; + if (!in_array($modo, ['money', 'points'], true)) { + $modo = 'money'; + } + $price = isset($_POST['aksrt_plate_follow_pay_price']) ? max(0, (float) wp_unslash($_POST['aksrt_plate_follow_pay_price'])) : 0; + $points = isset($_POST['aksrt_plate_follow_pay_points']) ? max(0, (int) wp_unslash($_POST['aksrt_plate_follow_pay_points'])) : 0; + + // Invalid price while enabled → force off (normalize, no migration). + if ($enabled) { + if ($modo === 'points' && $points <= 0) { + $enabled = false; + } + if ($modo === 'money' && $price <= 0) { + $enabled = false; + } + } + + if (!$enabled) { + delete_post_meta($post_id, '_aksrt_follow_pay'); + delete_post_meta($post_id, '_aksrt_follow_pay_modo'); + delete_post_meta($post_id, '_aksrt_follow_pay_price'); + delete_post_meta($post_id, '_aksrt_follow_pay_points'); + delete_post_meta($post_id, '_aksrt_follow_pay_vip_price'); + return; + } + + update_post_meta($post_id, '_aksrt_follow_pay', 1); + update_post_meta($post_id, '_aksrt_follow_pay_modo', $modo); + update_post_meta($post_id, '_aksrt_follow_pay_price', $price); + update_post_meta($post_id, '_aksrt_follow_pay_points', $points); + + if (isset($_POST['aksrt_plate_follow_pay_vip_price']) && (string) wp_unslash($_POST['aksrt_plate_follow_pay_vip_price']) !== '') { + $vip = max(0, (float) wp_unslash($_POST['aksrt_plate_follow_pay_vip_price'])); + update_post_meta($post_id, '_aksrt_follow_pay_vip_price', $vip); + } else { + delete_post_meta($post_id, '_aksrt_follow_pay_vip_price'); + } + } + + /** + * @return array{enabled:bool,modo:string,price:float,points:int,vip_price:string} + */ + private function get_follow_pay_meta(int $plate_id): array { + $enabled = (int) get_post_meta($plate_id, '_aksrt_follow_pay', true) === 1; + $modo = sanitize_key((string) (get_post_meta($plate_id, '_aksrt_follow_pay_modo', true) ?: 'money')); + if (!in_array($modo, ['money', 'points'], true)) { + $modo = 'money'; + } + $vip_raw = get_post_meta($plate_id, '_aksrt_follow_pay_vip_price', true); + $vip_set = ($vip_raw !== '' && $vip_raw !== null && $vip_raw !== false); + + return [ + 'enabled' => $enabled, + 'modo' => $modo, + 'price' => (float) get_post_meta($plate_id, '_aksrt_follow_pay_price', true), + 'points' => (int) get_post_meta($plate_id, '_aksrt_follow_pay_points', true), + 'vip_price' => $vip_set ? (string) (float) $vip_raw : '', + ]; + } + private function save_moderators(int $post_id, int $author_id): void { $role_posted = isset($_POST['aksrt_plate_mod_role']) && is_array($_POST['aksrt_plate_mod_role']) ? wp_unslash($_POST['aksrt_plate_mod_role']) 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 bbc5d880..3f5403b9 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 @@ -166,6 +166,14 @@ trait AKSRT_Forum_Topic_Service_Part_3 { return true; } + // Plate gate first (zibll plate → post). Moderators of plate already pass can_view_plate. + $plate_id = (int) get_post_meta((int) $post->ID, '_aksrt_plate_id', true); + if ($plate_id > 0 && method_exists($this->forum, 'can_view_plate')) { + if (!$this->forum->can_view_plate($viewer_user_id, $plate_id)) { + return false; + } + } + if ($visibility === AKSRT_Forum::VISIBILITY_PUBLIC) { return true; } diff --git a/inc/core-hooks/part-1.php b/inc/core-hooks/part-1.php index 05cfadc7..8acefd82 100644 --- a/inc/core-hooks/part-1.php +++ b/inc/core-hooks/part-1.php @@ -372,6 +372,11 @@ function aksrt_register_forum_topic_meta() { '_aksrt_last_post' => ['type' => 'string', 'default' => ''], '_aksrt_allow_view' => ['type' => 'string', 'default' => 'public'], '_aksrt_add_limit' => ['type' => 'integer', 'default' => 0], + '_aksrt_follow_pay' => ['type' => 'integer', 'default' => 0], + '_aksrt_follow_pay_modo' => ['type' => 'string', 'default' => 'money'], + '_aksrt_follow_pay_price' => ['type' => 'number', 'default' => 0], + '_aksrt_follow_pay_points' => ['type' => 'integer', 'default' => 0], + '_aksrt_follow_pay_vip_price' => ['type' => 'number', 'default' => 0], ]; foreach ($plate_meta as $key => $args) { diff --git a/inc/helpers/aksrt-domain-model.php b/inc/helpers/aksrt-domain-model.php index 523e5995..898a408e 100644 --- a/inc/helpers/aksrt-domain-model.php +++ b/inc/helpers/aksrt-domain-model.php @@ -27,10 +27,12 @@ class AKSRT_Domain_Model { public const ORDER_MIGRATED_OPTION = 'aksrt_order_migrated_to_table'; public const RESOURCE_PAY_META_MIGRATED = 'aksrt_resource_pay_meta_migrated'; - public const ORDER_PRODUCT_RESOURCE = 'resource'; - public const ORDER_PRODUCT_VIP = 'vip'; - public const ORDER_PRODUCT_RECHARGE = 'recharge'; - public const ORDER_PRODUCT_SHOP = 'shop_product'; + public const ORDER_PRODUCT_RESOURCE = 'resource'; + public const ORDER_PRODUCT_VIP = 'vip'; + public const ORDER_PRODUCT_RECHARGE = 'recharge'; + public const ORDER_PRODUCT_SHOP = 'shop_product'; + /** Paid plate follow (zibll order_type=11). product_id = plate post ID. */ + public const ORDER_PRODUCT_PLATE_FOLLOW = 'plate_follow'; public const PAY_TYPE_NO = 'no'; public const PAY_TYPE_READING = 'reading';