From cb76b95b016e83c2bfb10abf35a7bae6428f1bed Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 18 Oct 2026 13:44:01 +0800 Subject: [PATCH] feat(vip): skeleton loading state and faster modal/site startup The VIP purchase/renew modal opened with a single "loading..." line, which reads as a hang while /pay/vip/modal round-trips. Every frontend pageview also paid an option write inside wp_head, and the header user-card CSS was enqueued even for guests who never see the card. pay-vip.js now builds a full-layout skeleton (user box, level tabs, the two-column package panel) from vipSkeleton(), reusing the global aksrt-modal-runtime__skeleton line/block classes; the VIP-specific shapes live in the conditionally loaded vip-modal.css, and the 640px two-column threshold matches .pay-vip-form-row. The modal cache TTL goes 15s -> 60s (purchase, card-pass redeem and points exchange already invalidate the vip tag), and hover/focus on a .pay-vip trigger now prefetches the payload through the same cacheKey and flight openRemote uses, so a warm click opens instantly. The bottom note and the whole help_text pipeline behind it are removed; the admin-editable pay_user_vip_desc still renders at the top of the modal, and the recharge/gateway help_text keys are untouched. get_catalog() reads points and wallet balance through one new LedgerService::points_and_balance() call. The StatsTracker counter write moves to shutdown so wp_head only does the visitor check and setcookie, and header-userbox.css is enqueued for logged-in users only. Verification: node --test across the suite (628 tests, including the 7 new contract assertions in tests/widget-system/vip-modal-skeleton.test.cjs) and php -l on every changed PHP file. No visual acceptance performed, per request. --- assets/css/frontend/vip-modal.css | 83 +++++++++++ assets/js/pay-vip.js | 94 +++++++++++- src/Content/VipServiceModal.php | 1 - src/Content/VipServicePurchase.php | 8 +- src/Core/ScriptsPart1b.php | 5 +- src/Core/StatsTracker.php | 23 ++- src/Ledger/LedgerService.php | 40 ++++++ src/Settings/PaymentRuntime.php | 1 - .../widget-system/vip-modal-skeleton.test.cjs | 134 ++++++++++++++++++ 9 files changed, 376 insertions(+), 13 deletions(-) create mode 100644 tests/widget-system/vip-modal-skeleton.test.cjs diff --git a/assets/css/frontend/vip-modal.css b/assets/css/frontend/vip-modal.css index ef3c8395..a90c5480 100644 --- a/assets/css/frontend/vip-modal.css +++ b/assets/css/frontend/vip-modal.css @@ -101,3 +101,86 @@ opacity: 0.7; cursor: wait; } + +/* 会员弹窗骨架屏(pay-vip.js vipSkeleton()):仿真实弹窗布局—— + 用户框 + 等级卡行 + 双列套餐面板。线条/色块与 shimmer 动画复用全局 + .aksrt-modal-runtime__skeleton-line/-block 与 @keyframes + aksrt-modal-skeleton(build/public.min.css 已全局入队); + 这里只定义 VIP 专属形状(头像圆、tab 卡、双列 grid、按钮条), + 与 .pay-vip-form-row 的 640px 双列阈值保持一致。 */ +.aksrt-vip-modal-skeleton { + display: flex; + flex-direction: column; + gap: 14px; + padding: 15px; +} + +.aksrt-vip-modal-skeleton__user { + display: flex; + align-items: center; + gap: 10px; +} + +.aksrt-vip-modal-skeleton__avatar { + width: 42px; + height: 42px; + border-radius: 50%; + flex: none; +} + +.aksrt-vip-modal-skeleton__lines { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + min-width: 0; +} + +.aksrt-vip-modal-skeleton__tabs { + display: flex; + gap: 10px; +} + +.aksrt-vip-modal-skeleton__tab { + flex: 1; + height: 56px; +} + +.aksrt-vip-modal-skeleton__pane { + display: grid; + gap: 12px; +} + +.aksrt-vip-modal-skeleton__info { + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 4px; +} + +.aksrt-vip-modal-skeleton__products { + display: flex; + flex-direction: column; + gap: 10px; +} + +.aksrt-vip-modal-skeleton__button { + height: 38px; +} + +/* 专属形状共用骨架底色与 shimmer(值与 modal-runtime.css 的线条/块一致)。 */ +.aksrt-vip-modal-skeleton__avatar, +.aksrt-vip-modal-skeleton__tab, +.aksrt-vip-modal-skeleton__button { + display: block; + border-radius: 8px; + background: linear-gradient(90deg, rgba(148, 163, 184, 0.16), rgba(148, 163, 184, 0.3), rgba(148, 163, 184, 0.16)); + background-size: 200% 100%; + animation: aksrt-modal-skeleton 1.2s ease-in-out infinite; +} + +@media (min-width: 640px) { + .aksrt-vip-modal-skeleton__pane { + grid-template-columns: 1fr 1.2fr; + } +} diff --git a/assets/js/pay-vip.js b/assets/js/pay-vip.js index 656dbb4c..894f917e 100644 --- a/assets/js/pay-vip.js +++ b/assets/js/pay-vip.js @@ -519,8 +519,6 @@ append(modal, tabs); append(modal, content); - if (catalog.help_text) append(modal, node("div", "muted-2-color em09 mb6", catalog.help_text)); - // 卡密渠道初始显隐(选中 card_pass 时隐藏套餐、显示卡密输入盒)。 modal.querySelectorAll(".aksrt-vip-pane").forEach(function (pane) { applyVipMethodVisibility(pane); @@ -698,6 +696,46 @@ }); } + // 骨架屏:仿弹窗真实布局(用户框 + 等级卡 + 双列套餐面板)。 + // 线条/色块复用全局骨架类(build/public.min.css),VIP 专属形状见 vip-modal.css。 + function vipSkeleton() { + var skeleton = node("div", "aksrt-vip-modal-skeleton"); + skeleton.setAttribute("aria-live", "polite"); + skeleton.setAttribute("aria-busy", "true"); + + // 用户框:头像圆 + 两行文字。 + var user = node("div", "aksrt-vip-modal-skeleton__user"); + append(user, node("span", "aksrt-vip-modal-skeleton__avatar")); + var userLines = node("span", "aksrt-vip-modal-skeleton__lines"); + append(userLines, node("span", "aksrt-modal-runtime__skeleton-line is-wide")); + append(userLines, node("span", "aksrt-modal-runtime__skeleton-line is-short")); + append(user, userLines); + append(skeleton, user); + + // 等级卡行(vip-cardminis):两张 tab 占位。 + var tabs = node("div", "aksrt-vip-modal-skeleton__tabs"); + append(tabs, node("span", "aksrt-vip-modal-skeleton__tab")); + append(tabs, node("span", "aksrt-vip-modal-skeleton__tab")); + append(skeleton, tabs); + + // 双列面板:左权益信息 + 右套餐区(套餐卡、支付方式行、提交按钮条)。 + var pane = node("div", "aksrt-vip-modal-skeleton__pane"); + var info = node("div", "aksrt-vip-modal-skeleton__info"); + append(info, node("span", "aksrt-modal-runtime__skeleton-line is-wide")); + append(info, node("span", "aksrt-modal-runtime__skeleton-line")); + append(info, node("span", "aksrt-modal-runtime__skeleton-line is-short")); + append(pane, info); + var products = node("div", "aksrt-vip-modal-skeleton__products"); + append(products, node("span", "aksrt-modal-runtime__skeleton-block")); + append(products, node("span", "aksrt-modal-runtime__skeleton-block")); + append(products, node("span", "aksrt-modal-runtime__skeleton-line is-short")); + append(products, node("span", "aksrt-vip-modal-skeleton__button")); + append(pane, products); + append(skeleton, pane); + + return skeleton.outerHTML; + } + function vipPay(trigger) { var level = trigger ? parseInt(trigger.getAttribute("vip-level") || trigger.getAttribute("data-vip-level") || "1", 10) || 1 @@ -706,11 +744,11 @@ url: "/pay/vip/modal", query: { vip_level: level }, className: "aksrt-vip-modal-shell", - skeleton: '
加载中…
', + skeleton: vipSkeleton(), telemetry: "vip", kind: "vip", responseType: "json", - cacheTtl: 15000, + cacheTtl: 60000, cacheTags: ["vip"], renderData: function (data, session) { renderModalView(session.content, data); @@ -728,6 +766,53 @@ }); } + var prefetchedLevels = Object.create(null); + + // 与 openRemote 同一 cacheKey(method + AksrtRequest.url),命中同一份缓存/flight。 + function vipModalCacheKey(level) { + return "GET:" + window.AksrtRequest.url("/pay/vip/modal", { vip_level: level }); + } + + // 意图预热:悬停/聚焦开通入口时按等级预热弹窗数据(不打开弹窗,失败静默)。 + // 点击打开时若预热请求在途,cache.acquire 的 flight 去重会自动合并。 + function prefetchVipModal(trigger) { + if (!trigger || !window.AksrtModalCache || !window.AksrtRequest) return; + var level = parseInt(trigger.getAttribute("vip-level") || trigger.getAttribute("data-vip-level") || "1", 10) || 1; + if (level < 1 || level > 2) level = 1; + if (prefetchedLevels[level]) return; + prefetchedLevels[level] = true; + var cache = window.AksrtModalCache; + if (cache.get && cache.get(vipModalCacheKey(level)).hit) return; + try { + cache.acquire(vipModalCacheKey(level), function () { + return window.AksrtRequest.request("/pay/vip/modal", { + method: "GET", + query: { vip_level: level }, + }); + }, { + ttl: 60000, + tags: ["vip"], + }); + } catch (error) { + // 预热失败不影响点击后的正常打开路径。 + } + } + + function vipTriggerFrom(event) { + var target = event.target; + if (!target || !target.closest) return null; + return target.closest(".pay-vip, a.pay-vip, .float-btn.pay-vip, [data-aksrt-pay-vip]"); + } + + function bindPrefetch() { + // pointerenter 不冒泡但走捕获阶段;focusin 冒泡,两者互补覆盖鼠标与键盘。 + ["pointerenter", "focusin"].forEach(function (type) { + document.addEventListener(type, function (event) { + prefetchVipModal(vipTriggerFrom(event)); + }, true); + }); + } + function onDocClick(event) { var trigger = event.target.closest(".pay-vip, a.pay-vip, .float-btn.pay-vip, [data-aksrt-pay-vip]"); if (!trigger) return; @@ -768,6 +853,7 @@ if (window.__aksrtPayVipBound) return; window.__aksrtPayVipBound = true; document.addEventListener("click", onDocClick, true); + bindPrefetch(); bindPointsModal(); bindPointsEntry(); } diff --git a/src/Content/VipServiceModal.php b/src/Content/VipServiceModal.php index 0b90e94e..df38e60d 100644 --- a/src/Content/VipServiceModal.php +++ b/src/Content/VipServiceModal.php @@ -115,7 +115,6 @@ trait VipServiceModal { 'prefer_level' => $prefer_level, 'catalog' => [ 'desc' => (string) ($catalog['desc'] ?? ''), - 'help_text' => (string) ($catalog['help_text'] ?? ''), 'more' => (string) ($vip['more'] ?? ''), 'currency_name' => (string) ($catalog['currency_name'] ?? ''), 'level_packages' => $groups, diff --git a/src/Content/VipServicePurchase.php b/src/Content/VipServicePurchase.php index 89ca6ce2..7bba7cc0 100644 --- a/src/Content/VipServicePurchase.php +++ b/src/Content/VipServicePurchase.php @@ -232,21 +232,19 @@ trait VipServicePurchase { } } - $points_balance = $user_id > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS) : 0; - $wallet_balance = $user_id > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : '0.00'; + $balances = \AKSRT\Ledger\LedgerService::get_instance()->points_and_balance($user_id); $methods = $this->available_payment_methods(); $currency_name = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt'); $catalog = [ 'enabled' => !empty($vip['enabled']), 'desc' => (string) ($vip['desc'] ?? ''), - 'help_text' => (string) ($vip['help_text'] ?? ''), 'user_level' => $level, 'user_expires' => $expires, 'user_is_permanent' => $is_permanent, 'user_expires_label' => (string) ($member['expires_label'] ?? ''), - 'user_points' => $points_balance, - 'wallet_balance' => $wallet_balance, + 'user_points' => $balances['points'], + 'wallet_balance' => $balances['balance'], 'currency_name' => $currency_name, 'renew_enabled' => $renew_enabled, 'upgrade_enabled' => $upgrade_enabled, diff --git a/src/Core/ScriptsPart1b.php b/src/Core/ScriptsPart1b.php index 4150930c..a216c2da 100644 --- a/src/Core/ScriptsPart1b.php +++ b/src/Core/ScriptsPart1b.php @@ -63,7 +63,10 @@ trait ScriptsPart1b { }; // 顶部导航用户卡片(legacy header-userbox 模块样式)。 - $enqueue_module('header-userbox'); + // 仅登录用户渲染用户卡(aksrt-header-userbox.php 的渲染条件),访客页面不加载。 + if (is_user_logged_in()) { + $enqueue_module('header-userbox'); + } if (!is_user_logged_in() || (bool) get_query_var('aksrt_sign')) { $auth_css = get_template_directory() . '/assets/css/routes/sign.css'; diff --git a/src/Core/StatsTracker.php b/src/Core/StatsTracker.php index 0830f758..0e235f0e 100644 --- a/src/Core/StatsTracker.php +++ b/src/Core/StatsTracker.php @@ -30,6 +30,9 @@ class StatsTracker { /** * 追踪访问 + * + * 访客判定与 setcookie 必须留在 wp_head(响应头尚未发送); + * 计数读写改挂 shutdown,避免每次前台请求都在渲染关键路径上写库。 */ public function track_visit(): void { // 排除管理员、爬虫、AJAX请求 @@ -41,6 +44,12 @@ class StatsTracker { return; } + static $scheduled = false; + if ($scheduled) { + return; + } + $scheduled = true; + $today = current_time('Y-m-d'); $option_key = 'aksrt_daily_stats_' . $today; @@ -48,6 +57,19 @@ class StatsTracker { $visitor_cookie = 'aksrt_visitor_' . $today; $is_unique = empty($_COOKIE[$visitor_cookie]); + if ($is_unique) { + setcookie($visitor_cookie, '1', time() + DAY_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN); + } + + add_action('shutdown', static function () use ($option_key, $is_unique): void { + self::get_instance()->record_stats($option_key, $is_unique); + }); + } + + /** + * 计数落库(shutdown 阶段执行;请求中断时本次统计丢弃)。 + */ + public function record_stats(string $option_key, bool $is_unique): void { // 获取或初始化今日统计 $stats = get_option($option_key, ['visitors' => 0, 'pageviews' => 0]); @@ -59,7 +81,6 @@ class StatsTracker { $stats['pageviews']++; if ($is_unique) { $stats['visitors']++; - setcookie($visitor_cookie, '1', time() + DAY_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN); } update_option($option_key, $stats, false); diff --git a/src/Ledger/LedgerService.php b/src/Ledger/LedgerService.php index 3c894116..f552b3c7 100644 --- a/src/Ledger/LedgerService.php +++ b/src/Ledger/LedgerService.php @@ -63,6 +63,46 @@ final class LedgerService { return AssetAmount::from_minor(AssetType::BALANCE, $this->balance($user_id, AssetType::BALANCE))->decimal(); } + /** + * Read points and cash balance together (VIP modal catalog). + * + * The meta adapter serves both assets from user meta; the asset-account + * fallback answers both rows with a single query. + * + * @return array{points:int,balance:string} + */ + public function points_and_balance(int $user_id): array { + if ($user_id <= 0) { + return ['points' => 0, 'balance' => '0.00']; + } + if (class_exists('\AKSRT\Credits\MetaAssetService')) { + $meta = \AKSRT\Credits\MetaAssetService::get_instance(); + return [ + 'points' => $meta->balance_points($user_id), + 'balance' => $meta->balance_decimal($user_id), + ]; + } + global $wpdb; + $table = $this->table('asset_account'); + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT asset, balance_minor FROM {$table} WHERE user_id = %d AND asset IN (%s, %s)", + $user_id, + AssetType::POINTS->value, + AssetType::BALANCE->value + ), ARRAY_A); + $minor = ['points' => 0, 'balance' => 0]; + foreach ((array) $rows as $row) { + $asset = (string) ($row['asset'] ?? ''); + if (isset($minor[$asset])) { + $minor[$asset] = max(0, (int) ($row['balance_minor'] ?? 0)); + } + } + return [ + 'points' => $minor['points'], + 'balance' => AssetAmount::from_minor(AssetType::BALANCE, $minor['balance'])->decimal(), + ]; + } + public function format_balance(int $minor): string { return AssetAmount::from_minor(AssetType::BALANCE, max(0, $minor))->formatted(); } diff --git a/src/Settings/PaymentRuntime.php b/src/Settings/PaymentRuntime.php index e7c02a90..aa955a89 100644 --- a/src/Settings/PaymentRuntime.php +++ b/src/Settings/PaymentRuntime.php @@ -219,7 +219,6 @@ class PaymentRuntime { 'badge_text' => $badge, 'desc' => $desc, 'more' => wp_kses($more, $allowed_equity + ['div' => ['class' => true], 'p' => ['class' => true]]), - 'help_text' => $desc !== '' ? $desc : __('会员权益以免扣下载和身份展示为主,开通后立即生效。', 'aksrt'), 'levels' => [ 1 => [ 'enabled' => $vip1_enabled, diff --git a/tests/widget-system/vip-modal-skeleton.test.cjs b/tests/widget-system/vip-modal-skeleton.test.cjs new file mode 100644 index 00000000..68503376 --- /dev/null +++ b/tests/widget-system/vip-modal-skeleton.test.cjs @@ -0,0 +1,134 @@ +/** + * 会员弹窗骨架屏 / 打开提速 / help_text 清理 / 启动优化契约。 + * + * 硬约束: + * 1) 会员弹窗加载占位为完整布局仿形骨架(vipSkeleton()),复用全局 + * aksrt-modal-runtime__skeleton-line/-block 与 shimmer 动画, + * VIP 专属形状样式落在 assets/css/frontend/vip-modal.css; + * 2) 弹窗数据缓存 TTL 60s,悬停/聚焦入口按等级意图预热,预热与 + * openRemote 共用 cacheKey 与 flight; + * 3) 弹窗底部提示句与整条 help_text 管线已移除,顶部说明 desc 保留; + * 4) 积分/钱包余额合并为一次 LedgerService::points_and_balance(); + * 5) 访问统计写库移到 shutdown,wp_head 只做访客判定与 setcookie; + * 6) header-userbox 样式仅登录用户入队。 + */ +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.join(__dirname, '..', '..'); +const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8'); + +// 取某个方法体(到下一个同类方法声明为止),避免断言误伤其它功能的同名键。 +function sliceMethod(source, signature, nextSignature) { + const start = source.indexOf(signature); + assert.notStrictEqual(start, -1, `missing ${signature}`); + const end = source.indexOf(nextSignature, start + signature.length); + return source.slice(start, end === -1 ? undefined : end); +} + +const vipJs = read('assets', 'js', 'pay-vip.js'); +const vipCss = read('assets', 'css', 'frontend', 'vip-modal.css'); +const paymentRuntime = read('src', 'Settings', 'PaymentRuntime.php'); +const modal = read('src', 'Content', 'VipServiceModal.php'); +const catalogSource = read('src', 'Content', 'VipServicePurchase.php'); +const ledger = read('src', 'Ledger', 'LedgerService.php'); +const stats = read('src', 'Core', 'StatsTracker.php'); +const scripts = read('src', 'Core', 'ScriptsPart1b.php'); + +test('会员弹窗使用完整布局仿形骨架屏', () => { + assert.match(vipJs, /function vipSkeleton\(\) \{/); + assert.match(vipJs, /node\("div", "aksrt-vip-modal-skeleton"\)/); + assert.match(vipJs, /skeleton\.setAttribute\("aria-busy", "true"\)/); + // 结构:用户框(头像+两行)、等级卡行、双列面板(权益+套餐区)。 + assert.match(vipJs, /aksrt-vip-modal-skeleton__user/); + assert.match(vipJs, /aksrt-vip-modal-skeleton__avatar/); + assert.match(vipJs, /aksrt-vip-modal-skeleton__lines/); + assert.match(vipJs, /aksrt-vip-modal-skeleton__tabs/); + assert.match(vipJs, /aksrt-vip-modal-skeleton__tab/); + assert.match(vipJs, /aksrt-vip-modal-skeleton__pane/); + assert.match(vipJs, /aksrt-vip-modal-skeleton__info/); + assert.match(vipJs, /aksrt-vip-modal-skeleton__products/); + assert.match(vipJs, /aksrt-vip-modal-skeleton__button/); + // 线条/色块复用全局骨架类。 + assert.match(vipJs, /aksrt-modal-runtime__skeleton-line is-wide/); + assert.match(vipJs, /aksrt-modal-runtime__skeleton-line is-short/); + assert.match(vipJs, /aksrt-modal-runtime__skeleton-block/); + // openRemote 传入自定义骨架,不再使用纯文本占位。 + assert.match(vipJs, /skeleton: vipSkeleton\(\),/); + assert.doesNotMatch(vipJs, /加载中/); +}); + +test('骨架样式落在 vip-modal.css 并对齐 640px 双列阈值', () => { + assert.match(vipCss, /\.aksrt-vip-modal-skeleton \{/); + assert.match(vipCss, /\.aksrt-vip-modal-skeleton__avatar \{[\s\S]*?border-radius: 50%/); + assert.match(vipCss, /\.aksrt-vip-modal-skeleton__tab \{[\s\S]*?flex: 1/); + assert.match(vipCss, /\.aksrt-vip-modal-skeleton__button \{[\s\S]*?height: 38px/); + assert.match(vipCss, /animation: aksrt-modal-skeleton/); + assert.match(vipCss, /@media \(min-width: 640px\)[\s\S]*?\.aksrt-vip-modal-skeleton__pane \{[\s\S]*?grid-template-columns: 1fr 1\.2fr/); +}); + +test('弹窗打开提速:缓存 TTL 提升与意图预热', () => { + assert.match(vipJs, /cacheTtl: 60000,/); + // 预热与 openRemote 同一 cacheKey(method + AksrtRequest.url)。 + assert.match(vipJs, /function vipModalCacheKey\(level\) \{/); + assert.match(vipJs, /"GET:" \+ window\.AksrtRequest\.url\("\/pay\/vip\/modal", \{ vip_level: level \}\)/); + assert.match(vipJs, /cache\.acquire\(vipModalCacheKey\(level\)/); + assert.match(vipJs, /ttl: 60000,/); + assert.match(vipJs, /tags: \["vip"\]/); + // 悬停/聚焦触发,绑定进 bindOnce。 + assert.match(vipJs, /\["pointerenter", "focusin"\]/); + assert.match(vipJs, /function bindPrefetch\(\) \{/); + assert.match(vipJs, /document\.addEventListener\("click", onDocClick, true\);\s*bindPrefetch\(\);/); + // 每等级每页只预热一次。 + assert.match(vipJs, /prefetchedLevels\[level\] = true;/); +}); + +test('弹窗底部提示与 help_text 管线已彻底移除', () => { + assert.doesNotMatch(vipJs, /help_text/); + // 仅收敛 VIP 设置函数:充值/网关设置的 help_text 与本文无关,保持原样。 + const vipSettings = sliceMethod( + paymentRuntime, + 'public static function get_payment_vip_settings', + 'public static function get_payment_revenue_settings' + ); + assert.doesNotMatch(vipSettings, /help_text/); + assert.doesNotMatch(vipSettings, /开通后立即生效/); + assert.doesNotMatch(modal, /help_text/); + const viewModel = sliceMethod(modal, 'public function get_modal_view_model', 'public function get_points_exchange_modal_html'); + assert.doesNotMatch(viewModel, /help_text/); + assert.doesNotMatch(catalogSource, /help_text/); + // 顶部说明 desc 仍保留(后台 pay_user_vip_desc 继续生效)。 + assert.match(vipJs, /catalog\.desc/); + assert.match(paymentRuntime, /'desc'\s+=> \$desc,/); +}); + +test('积分与钱包余额合并为一次 LedgerService 读取', () => { + assert.match(ledger, /public function points_and_balance\(int \$user_id\): array/); + // 仅收敛 get_catalog:购买/履约路径的余额读取与本文无关,保持原样。 + const getCatalog = sliceMethod(catalogSource, 'public function get_catalog', 'private function find_catalog_package'); + assert.match(getCatalog, /points_and_balance\(\$user_id\)/); + assert.match(getCatalog, /'user_points'\s+=> \$balances\['points'\]/); + assert.match(getCatalog, /'wallet_balance'\s+=> \$balances\['balance'\]/); + assert.doesNotMatch(getCatalog, /LedgerService::get_instance\(\)->balance\(/); + assert.doesNotMatch(getCatalog, /balance_decimal/); +}); + +test('访问统计写库移出渲染关键路径', () => { + assert.match(stats, /add_action\('wp_head', \[self::get_instance\(\), 'track_visit'\], 1\)/); + assert.match(stats, /add_action\('shutdown'/); + assert.match(stats, /public function record_stats\(string \$option_key, bool \$is_unique\): void/); + // wp_head 阶段只做判定与 setcookie,不再读写 option。 + const trackVisit = stats.slice(stats.indexOf('public function track_visit'), stats.indexOf('public function record_stats')); + assert.doesNotMatch(trackVisit, /update_option|get_option/); + assert.match(trackVisit, /setcookie\(\$visitor_cookie/); + // 落库与月初清理收敛到 record_stats。 + const recordStats = stats.slice(stats.indexOf('public function record_stats')); + assert.match(recordStats, /update_option\(\$option_key, \$stats, false\)/); + assert.match(recordStats, /cleanup_old_stats\(\)/); +}); + +test('header-userbox 样式仅登录用户入队', () => { + assert.match(scripts, /if \(is_user_logged_in\(\)\) \{\s*\$enqueue_module\('header-userbox'\);/); +});