feat(forum,admin): plate follow-pay MVP, sales column, order type tabs

Wire paid plate follow (admin meta + points/balance purchase + AJAX),
re-check plate gate on topics, post list sales column, and order type
subtabs; update gap matrix and admin IA map.
This commit is contained in:
kate522 committed 2026-07-15 11:00:36 +08:00
1 parent bccb14a487
commit cf6ad0e972
12 files changed
+672 -12

No files matched your search

@@ -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.
*/
@@ -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);
}
}