Files
wordpress-theme-aksrt/inc/helpers/aksrt-vip.php
T
kate522 95ff0761b7 fix(vip): 积分兑换弹窗改服务端渲染并与 Zibll 1:1 对齐
/user/vip/ 的积分兑换会员弹窗原先是前端自行拼装的,结构与层级都与参考主题
Zibll 不一致:Zibll 走 zibpay_get_vip_points_exchange_modal() 服务端渲染静态
壳,前端只按需开合。改为同一模型,前端不再拼 HTML。

- VipServiceModal 新增 get_points_exchange_modal_html():门控(未登录/未开启
  返回空串)、vip-card 套餐选择、积分不足提示与提交表单;wp_footer 挂载输出
- pay-vip.js 移除 renderPointsModal / pointsPackage / exchangeFromModal /
  vipPointsPay,改为自包含开合(.in + 内联 display:flex + 独立遮罩 +
  body.modal-open + ESC),保留 aksrt-vip-points-toast 的 load/success/danger 契约
- 移除 /user/vip/ 页面底部独立积分兑换入口及其样式(uc-vip-points-link)
- credits.js 清理依赖全站不存在选择器的死代码 initVipPointsExchange()
- vip-modal.css 删除 71 行自实现样式,改为 .modal[data-aksrt-vip-points-modal]
  显隐契约;层级 100050 < 遮罩 100055 < 弹窗 100060 < notyf 10000000
- 新增 vip-points-exchange-modal 契约测试(7 项),toast-contract 同步

静止态必须显式 display:none,否则 .flex{display:flex} 会覆盖 .modal{display:none}
形成隐形遮罩拦截整页点击。

未写数据迁移。

验证:node --test tests/widget-system/*.test.cjs 308 项全部通过;php -l 全部
改动文件通过;隔离渲染桩确认积分充足 / 积分不足 / 功能关闭 / 未登录四种情形
的 HTML 产物正确。
2026-09-18 20:05:05 +08:00

530 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* AKSRT VIP Helper — VIP 会员等级与展示辅助函数
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
/**
* Resolve the complete VIP membership state for a user.
*
* Explicit membership meta is authoritative, including an explicit level 0.
* Order fallback is only for users without a canonical membership level.
*
* @return array{level:int,expires:int,state:string,expired_level:int,source:string,has_explicit_state:bool,is_permanent:bool,current_level:int,current_expires:int,current_state:string,fallback_level:int,fallback_expires:int,fallback_state:string,has_fallback:bool,is_layered_upgrade:bool}
*/
function aksrt_get_user_vip_state(int $user_id = 0): array {
$empty = [
'level' => 0,
'expires' => 0,
'state' => 'unopened',
'expired_level' => 0,
'source' => 'none',
'has_explicit_state' => false,
'is_permanent' => false,
'current_level' => 0,
'current_expires' => 0,
'current_state' => 'unopened',
'fallback_level' => 0,
'fallback_expires' => 0,
'fallback_state' => 'none',
'has_fallback' => false,
'is_layered_upgrade' => false,
];
if ($user_id <= 0) {
$user_id = get_current_user_id();
}
if ($user_id <= 0 || !class_exists('\AKSRT\Settings\Settings')) {
return $empty;
}
$vip = \AKSRT\Settings\Settings::get_payment_vip_settings();
if (empty($vip['enabled'])) {
return $empty;
}
$levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : [];
$vip1_enabled = !empty($levels[1]['enabled']);
$vip2_enabled = !empty($levels[2]['enabled']);
$has_explicit = metadata_exists('user', $user_id, 'aksrt_vip_level');
$level = min(2, max(0, (int) get_user_meta($user_id, 'aksrt_vip_level', true)));
$expires = max(0, (int) get_user_meta($user_id, 'aksrt_vip_expires', true));
$expired_level = min(2, max(0, (int) get_user_meta($user_id, 'aksrt_vip_level_expired', true)));
if ($has_explicit) {
if ($level > 0 && $expires > 0 && $expires <= time()) {
if (class_exists('\\AKSRT\\Content\\VipMembershipExpiry')) {
\AKSRT\Content\VipMembershipExpiry::process_user($user_id, 'resolver');
$level = min(2, max(0, (int) get_user_meta($user_id, 'aksrt_vip_level', true)));
$expires = max(0, (int) get_user_meta($user_id, 'aksrt_vip_expires', true));
} else {
$base_level = min(2, max(0, (int) get_user_meta($user_id, 'aksrt_vip_base_level', true)));
$base_expires = max(0, (int) get_user_meta($user_id, 'aksrt_vip_base_expires', true));
if ($base_level > 0 && ($base_expires === 0 || $base_expires > time())) {
update_user_meta($user_id, 'aksrt_vip_level', $base_level);
update_user_meta($user_id, 'aksrt_vip_expires', $base_expires);
delete_user_meta($user_id, 'aksrt_vip_base_level');
delete_user_meta($user_id, 'aksrt_vip_base_expires');
$level = $base_level;
$expires = $base_expires;
$expired_level = 0;
} else {
$expired_level = max($expired_level, $level);
update_user_meta($user_id, 'aksrt_vip_level_expired', $expired_level);
update_user_meta($user_id, 'aksrt_vip_level', 0);
update_user_meta($user_id, 'aksrt_vip_expires', 0);
delete_user_meta($user_id, 'aksrt_vip_base_level');
delete_user_meta($user_id, 'aksrt_vip_base_expires');
$level = 0;
$expires = 0;
}
}
}
$fallback_level = min(2, max(0, (int) get_user_meta($user_id, 'aksrt_vip_base_level', true)));
$fallback_expires = max(0, (int) get_user_meta($user_id, 'aksrt_vip_base_expires', true));
if ($level > 0) {
$level = $level >= 2 && $vip2_enabled ? 2 : ($vip1_enabled ? 1 : 0);
$current_state = $expires > 0 ? 'active' : 'permanent';
$fallback_state = $fallback_level > 0 ? ($fallback_expires > 0 ? 'active' : 'permanent') : 'none';
return [
'level' => $level,
'expires' => $expires,
'state' => $current_state,
'expired_level' => 0,
'source' => 'meta',
'has_explicit_state' => true,
'is_permanent' => $expires <= 0,
'current_level' => $level,
'current_expires' => $expires,
'current_state' => $current_state,
'fallback_level' => $fallback_level,
'fallback_expires' => $fallback_expires,
'fallback_state' => $fallback_state,
'has_fallback' => $fallback_level > 0,
'is_layered_upgrade' => $fallback_level > 0 && $fallback_level < $level,
];
}
return array_merge($empty, [
'state' => $expired_level > 0 ? 'expired' : 'unopened',
'expired_level' => $expired_level,
'source' => 'meta',
'has_explicit_state' => true,
'current_state' => $expired_level > 0 ? 'expired' : 'unopened',
]);
}
$paid = \AKSRT\Settings\Settings::get_paid_content_settings();
$user = get_userdata($user_id);
if ($user && array_intersect((array) $user->roles, (array) ($paid['vip_exempt_roles'] ?? []))) {
$level = $vip2_enabled ? 2 : ($vip1_enabled ? 1 : 0);
return array_merge($empty, [
'level' => $level,
'state' => $level > 0 ? 'permanent' : 'unopened',
'source' => 'exempt',
'is_permanent' => $level > 0,
'current_level' => $level,
'current_state' => $level > 0 ? 'permanent' : 'unopened',
]);
}
if (class_exists('\AKSRT\Payment\Order')) {
$orders = \AKSRT\Payment\Order::get_instance();
$order = method_exists($orders, 'get_valid_vip_order') ? $orders->get_valid_vip_order($user_id) : null;
if (is_array($order) && $order !== []) {
$order_meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
if ($order_meta === [] && is_string($order['note'] ?? null)) {
$decoded = json_decode($order['note'], true);
$order_meta = is_array($decoded) ? $decoded : [];
}
$level = (int) ($order_meta['level'] ?? 0);
$product_id = (int) ($order['product_id'] ?? 0);
if ($level <= 0 && $product_id > 0) {
$level = $product_id >= 1000 ? (int) floor($product_id / 1000) : 1;
}
$level = min(2, max(1, $level));
$level = $level >= 2 && $vip2_enabled ? 2 : ($vip1_enabled ? 1 : 0);
$order_expires = max(0, (int) ($order_meta['expires'] ?? $order_meta['vip_expires'] ?? 0));
return array_merge($empty, [
'level' => $level,
'expires' => $order_expires,
'state' => $level > 0 ? 'active' : 'unopened',
'source' => 'order',
'current_level' => $level,
'current_expires' => $order_expires,
'current_state' => $level > 0 ? 'active' : 'unopened',
]);
}
}
return $empty;
}
/**
* @param int $user_id User ID, or current user when zero.
* @return int 0|1|2
*/
function aksrt_get_user_vip_level(int $user_id = 0): int {
return (int) aksrt_get_user_vip_state($user_id)['level'];
}
/**
* 检查用户是否为 VIP(level >= 1)。
*
* @param int $user_id
* @return bool
*/
function aksrt_is_vip(int $user_id = 0): bool {
return aksrt_get_user_vip_level($user_id) >= 1;
}
/**
* 检查用户是否为 VIP2(level >= 2)。
*
* @param int $user_id
* @return bool
*/
function aksrt_is_vip2(int $user_id = 0): bool {
return aksrt_get_user_vip_level($user_id) >= 2;
}
/**
* 获取 VIP 徽标文本
*
* @return string
*/
function aksrt_get_vip_badge_text(int $user_id = 0): string {
if (!class_exists('\AKSRT\Settings\Settings')) {
return 'VIP';
}
$vip_settings = \AKSRT\Settings\Settings::get_payment_vip_settings();
$level = $user_id > 0 ? aksrt_get_user_vip_level($user_id) : 0;
if ($level >= 1 && !empty($vip_settings['levels'][$level]['name'])) {
return sanitize_text_field((string) $vip_settings['levels'][$level]['name']);
}
return sanitize_text_field($vip_settings['badge_text'] ?? 'VIP');
}
/**
* Daily free-download limit for a user (0 = unlimited).
* Aligns with legacy pay_download_limit / pay_download_limit_vip_{n}.
*/
if (!function_exists('aksrt_get_user_free_download_limit')) {
function aksrt_get_user_free_download_limit(int $user_id = 0): int {
if ($user_id <= 0) {
$user_id = get_current_user_id();
}
if ($user_id <= 0 || !class_exists('\AKSRT\Settings\Settings')) {
return 0;
}
$vip = \AKSRT\Settings\Settings::get_payment_vip_settings();
$level = function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($user_id) : 0;
$levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : [];
$benefit = is_array($vip['benefit'] ?? null) ? $vip['benefit'] : [];
if ($level >= 1 && !empty($levels[$level]['enabled'])) {
return max(0, (int) ($levels[$level]['download_limit'] ?? 0));
}
return max(0, (int) ($benefit['download_limit'] ?? 0));
}
}
/**
* Count free downloads used today for a user (legacy pay_down_number).
*/
if (!function_exists('aksrt_get_user_free_downloaded_number')) {
function aksrt_get_user_free_downloaded_number(int $user_id = 0): int {
if ($user_id <= 0) {
$user_id = get_current_user_id();
}
if ($user_id <= 0) {
return 0;
}
$day = current_time('Y-m-d');
$mate = get_user_meta($user_id, 'aksrt_pay_down_number', true);
if (!is_array($mate) || empty($mate[$day]) || !is_array($mate[$day])) {
return 0;
}
return count($mate[$day]);
}
}
/**
* Record a free download for daily quota (legacy pay_down_number).
*/
if (!function_exists('aksrt_record_user_free_download')) {
function aksrt_record_user_free_download(int $user_id, int $post_id): void {
if ($user_id <= 0 || $post_id <= 0) {
return;
}
$day = current_time('Y-m-d');
$mate = get_user_meta($user_id, 'aksrt_pay_down_number', true);
if (!is_array($mate)) {
$mate = [];
}
// Drop old days to keep meta small.
foreach (array_keys($mate) as $k) {
if ($k !== $day) {
unset($mate[$k]);
}
}
if (!isset($mate[$day]) || !is_array($mate[$day])) {
$mate[$day] = [];
}
$mate[$day][(string) $post_id] = 1;
update_user_meta($user_id, 'aksrt_pay_down_number', $mate);
}
}
/**
* Whether a free download is allowed under daily quota.
* Paid downloads (cost > 0) always pass.
*
* @return true|WP_Error
*/
if (!function_exists('aksrt_check_free_download_quota')) {
function aksrt_check_free_download_quota(int $user_id, int $cost = 0) {
if ($cost > 0) {
return true;
}
if ($user_id <= 0) {
return true; // login gate handled elsewhere
}
$limit = aksrt_get_user_free_download_limit($user_id);
if ($limit <= 0) {
return true; // unlimited
}
$used = aksrt_get_user_free_downloaded_number($user_id);
if ($used >= $limit) {
$data = [
'status' => 429,
'limit' => $limit,
'used' => $used,
];
// Optional overbuy price for free-quota exhausted (legacy download_limit_over_price).
// Callers may pass post context via filter.
$over = apply_filters('aksrt_download_limit_over_price', 0, $user_id);
if ($over > 0) {
$data['download_limit_over_price'] = $over;
$data['allow_limit_over'] = true;
}
return new WP_Error(
'aksrt_download_limit',
__('您今日下载免费资源个数已超限,请明日再下载。', 'aksrt'),
$data
);
}
return true;
}
}
/**
* Remaining free downloads today (null = unlimited).
*/
if (!function_exists('aksrt_get_free_download_surplus')) {
function aksrt_get_free_download_surplus(int $user_id = 0): ?int {
if ($user_id <= 0) {
$user_id = get_current_user_id();
}
$limit = aksrt_get_user_free_download_limit($user_id);
if ($limit <= 0) {
return null;
}
$used = aksrt_get_user_free_downloaded_number($user_id);
return max(0, $limit - $used);
}
}
/**
* 渲染 VIP 徽标 HTML
*
* @param int $user_id
* @param string $size sm | md | lg
* @return string
*/
function aksrt_render_vip_badge(int $user_id = 0, string $size = 'sm'): string {
if (!aksrt_is_vip($user_id)) {
return '';
}
$level = aksrt_get_user_vip_level($user_id);
$badge_text = aksrt_get_vip_badge_text($user_id);
$size_class = 'aksrt-vip-badge--' . $size;
$level_class = $level >= 2 ? 'aksrt-vip-badge--vip2' : 'aksrt-vip-badge--vip1';
$icon_url = '';
if (class_exists('\AKSRT\Settings\Settings')) {
$vip_settings = \AKSRT\Settings\Settings::get_payment_vip_settings();
$icon_url = esc_url((string) ($vip_settings['levels'][$level]['img_icon'] ?? ''));
}
$icon = $icon_url !== ''
? '<img' . aksrt_get_lazy_attr('lazy_other', $icon_url, 'aksrt-vip-badge__icon') . ' alt="" decoding="async">'
: '<i class="fa fas fa-certificate" aria-hidden="true"></i>';
return '<span class="aksrt-vip-badge aksrt-vip-badge--gold ' . esc_attr($size_class . ' ' . $level_class) . '" title="' . esc_attr($badge_text) . '">' . $icon . '</span>';
}
/**
* 获取用户 VIP 等级信息(用于展示)
*
* @param int $user_id
* @return array{is_vip: bool, level: int, badge_text: string, badge_html: string, name: string, expires: int, expires_label: string}
*/
function aksrt_get_vip_info(int $user_id = 0): array {
$state = function_exists('aksrt_get_user_vip_state') ? aksrt_get_user_vip_state($user_id) : [];
$level = (int) ($state['level'] ?? 0);
$is_vip = $level >= 1;
$badge_text = aksrt_get_vip_badge_text($user_id);
$expires = max(0, (int) ($state['expires'] ?? 0));
$expires_label = __('未开通', 'aksrt');
if (($state['state'] ?? '') === 'expired') {
$expires_label = __('已过期', 'aksrt');
} elseif ($is_vip && $expires <= 0 && ($state['source'] ?? 'none') !== 'order') {
$expires_label = __('永久有效', 'aksrt');
} elseif ($is_vip) {
$expires_label = sprintf(__('到期时间:%s', 'aksrt'), wp_date('Y-m-d H:i', $expires));
}
$name = __('普通用户', 'aksrt');
if ($is_vip && class_exists('\AKSRT\Settings\Settings')) {
$vip = \AKSRT\Settings\Settings::get_payment_vip_settings();
$name = (string) ($vip['levels'][$level]['name'] ?? ($badge_text . __('会员', 'aksrt')));
} elseif ($is_vip) {
$name = $badge_text . __('会员', 'aksrt');
}
return [
'is_vip' => $is_vip,
'level' => $level,
'badge_text' => $badge_text,
'badge_html' => $is_vip ? aksrt_render_vip_badge($user_id) : '',
'name' => $name,
'expires' => $expires,
'expires_label' => $expires_label,
'state' => (string) ($state['state'] ?? 'unopened'),
'source' => (string) ($state['source'] ?? 'none'),
];
}
/**
* VIP 开通页入口(用户中心 credits 面板)。
*/
if (!function_exists('aksrt_get_vip_purchase_url')) {
function aksrt_get_vip_purchase_url(): string {
if (function_exists('aksrt_get_user_center_tab_url')) {
return aksrt_get_user_center_tab_url('vip');
}
if (function_exists('aksrt_get_user_center_url')) {
return aksrt_get_user_center_url('vip');
}
return home_url('/');
}
}
/**
* Format VIP equity HTML for front-end display.
*
* Admin field is free-form HTML (typically one or more <li>). Do NOT
* re-parse / re-split the content into synthetic list items by punctuation
* or Chinese keyword heuristics — that mangles intentional HTML.
*
* Rules:
* - Sanitize with a small allowlist.
* - If markup already has <li>, wrap the items in <ul class="v-equity …">.
* - Otherwise output the sanitized HTML inside <div class="v-equity …">.
* - Plain multi-line text: each non-empty line becomes one <li> (only \n split).
* - Empty input → empty string.
*
* @param string $raw Equity HTML/plain from settings.
* @param string $class Extra classes on the wrapper (e.g. "mb10 relative").
*/
if (!function_exists('aksrt_format_vip_equity_html')) {
function aksrt_format_vip_equity_html(string $raw, string $class = ''): string {
$raw = trim($raw);
if ($raw === '') {
return '';
}
$allowed = [
'li' => ['class' => true],
'ul' => ['class' => true],
'ol' => ['class' => true],
'strong' => [],
'b' => [],
'em' => [],
'i' => ['class' => true],
'span' => ['class' => true],
'br' => [],
'p' => ['class' => true],
'div' => ['class' => true],
'a' => ['href' => true, 'target' => true, 'rel' => true, 'class' => true],
];
$clean = trim(wp_kses($raw, $allowed));
if ($clean === '') {
return '';
}
$class = trim('v-equity ' . $class);
$class_attr = esc_attr($class);
// Already list items — preserve them, only re-wrap.
if (preg_match('/<li[\s>]/i', $clean)) {
$inner = preg_replace('#</?(ul|ol)[^>]*>#i', '', $clean) ?? $clean;
$inner = trim($inner);
if ($inner === '') {
return '';
}
return '<ul class="' . $class_attr . '">' . $inner . '</ul>';
}
// Other HTML (p/br/a/…) — keep as-is, no list fabrication.
if (preg_match('/<[^>]+>/', $clean)) {
return '<div class="' . $class_attr . '">' . $clean . '</div>';
}
// Plain text only: split by newlines (author-authored line breaks), never
// by commas / Chinese keywords.
$lines = preg_split('/\R+/u', $clean) ?: [];
$lines = array_values(array_filter(array_map('trim', $lines), static fn($s) => $s !== ''));
if ($lines === []) {
return '';
}
$html = '<ul class="' . $class_attr . '">';
foreach ($lines as $line) {
$html .= '<li>' . esc_html($line) . '</li>';
}
$html .= '</ul>';
return $html;
}
}
/**
* 全局积分兑换会员弹窗(参考主题 refresh_modal 同构静态壳)。
*
* 入口位于会员购买弹窗(.pay-vip → assets/js/pay-vip.js)内,而 .pay-vip
* 触发点全站可见(页头、浮动按钮、用户中心、付费卡),因此弹窗壳在满足
* 条件时随 wp_footer 全局输出一次;显隐由 pay-vip.js 自包含开关驱动,
* 静止态由 vip-modal.css 的 .modal[data-aksrt-vip-points-modal] 显式隐藏。
*/
if (!function_exists('aksrt_render_vip_points_exchange_modal')) :
function aksrt_render_vip_points_exchange_modal(): void {
if (!is_user_logged_in() || !class_exists('\AKSRT\Content\VipService')) {
return;
}
$html = \AKSRT\Content\VipService::get_instance()->get_points_exchange_modal_html(get_current_user_id());
if ($html === '') {
return;
}
echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- 各字段已在生成时转义。
}
endif;
add_action('wp_footer', 'aksrt_render_vip_points_exchange_modal');