[审计]soundplayer 插件审计与悬浮窗改造报告

👑Lv.11 元老 🌞 骨干会员
2026-10-25 00:41:46

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/soundplayer插件审计与悬浮窗改造报告_2026-09-30.md。

四、审计结论

  1. 不能以现状启用 —— P0-1 越权是硬伤,普通用户可改播放列表 + 上传文件到服务器。
  2. 功能实现本身质量中上 —— 上传安全、CSRF、事务、i18n、URL 校验都规范,不需要推倒重来。
  3. plugin_case_lint.py 有工具盲区 —— 它验不出 P0-2(class_alias),建议在规范中补记:

> 「目录名与命名空间首字母大小写不一致」这类缺陷,必须用 class_exists('\Plugin\{目录名}\Plugin') 实测断言,

> 静态路径检查工具覆盖不到。

  1. 插件当前未启用 = 最佳修复窗口 —— 修完 P0/P1 再启用,风险可控。

第二部分 悬浮窗改造报告

五、需求拆解

用户原话:

「我想把这个插件改成全局显示的悬浮窗,这样在播放时就不会因为切换页面而卡顿,

钩子位置跟公告弹窗那里一样即可。悬浮窗放在 PC 端的右侧栏的位置即可,可以拖动,可以关闭。」

拆成 5 条可验收的需求:

#需求验收标准
R1全局显示所有前台页面都能看到(不只渲染右栏的页面)
R2钩子同公告弹窗使用 layout_body_end(与 notice_center 一致)
R3PC 端右侧栏位置position: fixed,贴右侧栏视觉位置(right: 16px 附近)
R4可拖动鼠标拖动移动位置,刷新后位置保留
R5可关闭有明确关闭按钮,关闭后本次会话不再出现

隐含需求 R6:播放不中断(用户的核心动机)→ 见第八章,这一条无法通过悬浮窗达成。


六、钩子方案

6.1 钩子变更对照

钩子改造前改造后说明
init_after✅ 每请求✅ 保留CSP media-src 注入必需,不能动
route_register✅✅ 保留前台独立页 /soundplayer
admin_route_register✅✅ 保留后台路由
layout_head_end✅ 无条件输出 CSS + 条件输出 JS✅ 降级为「仅资源」+ 短路无悬浮窗时零输出(对齐 notice_center 范式)
layout_body_end❌ 未注册✅ 新增悬浮窗 DOM 直出
right_sidebar_after✅ hook/sidebar_card.php⚠️ 待定(见 6.3)与悬浮窗职责重叠
~~sidebar_above_friend_links~~孤儿🗑️ 删除P1-3

6.2 layout_body_end 的关键约束(逐条对照 notice_center 的踩坑记录)

约束说明应对
无参数规范 §9.1 #43 明确「注入参数:无」判后台只能靠请求路径(/admin 前缀),不能靠 template
全站触发(含后台)同上必须做 isBackend() 短路,否则后台页也会冒悬浮窗
闭包作用域规范 §11.24:渲染钩子里 $this 不可用所有转义 / i18n 走 Plugin:: 静态方法 + htmlspecialchars() + I18n::get()
禁顶层符号规范 §9.0hook 文件内不声明 function / class
游客整页缓存PageCache 仅缓存列表页 + 仅游客悬浮窗 DOM 里只能有公共内容;任何用户态(csrf / 已读 / uid)必须只对登录用户输出

6.3 right_sidebar_after 的处置 —— 需要用户拍板

现状:hook/sidebar_card.php 在 PC 右栏输出 cardHtml()。

问题:若同时注册 layout_body_end,页面里会出现两个 .sound-player-card,而:

// assets/script.js:25-26
var root = document.querySelector('.sound-player-card');   // ← 单元素查询
if (!root) return;

querySelector 只取第一个 → 第二个卡片是死 DOM(无事件、无状态),视觉上却存在。

三个选项:

选项做法优点缺点
A(推荐)移除 right_sidebar_after,只保留悬浮窗干净、无重复、querySelector 单元素假设成立PC 右栏不再有卡片(用户需求本就是「改成悬浮窗」)
B保留右栏卡片 + 悬浮窗,改 JS 支持多实例双入口JS 改造量大(984 行里 root 被引用 40+ 次),状态同步复杂
C保留右栏卡片,悬浮窗仅在无右栏的页面显示看似两全判定「本页是否有右栏」在 layout_body_end 里做不到(钩子执行时右栏已渲染完,但拿不到标记)→ 不可行

建议 A。用户说的是「改成悬浮窗」,语义就是替换。


七、关键技术澄清:为什么悬浮窗 ≠ 不卡顿

这一节是本次改造最重要的部分。如果不先对齐预期,改造完用户会发现「还是卡」。

7.1 事实链

① 核心没有全局 htmx boost —— 导航是整页跳转

app/Views/layouts/main.php 全文只有 2 处 htmx 痕迹:

  • :90 注释 <!-- FhAjax (htmx) AJAX 请求 -->
  • :266 一处 hx-get="/api/favorites-count"(收藏数懒加载)

没有 hx-boost、没有 pjax、没有 Turbo。所有 <a href> 都是浏览器整页导航。

② 音频元素是游离的,不在 DOM 里

// assets/script.js:75-81
/* audio 元素:与原插件一致,只设 preload。 */
var audio = document.createElement('audio');
audio.preload = 'metadata';

createElement 之后从未 appendChild 到任何容器 —— 它是纯 JS 对象。

③ 整页跳转 = JS 上下文销毁

浏览器整页导航 → 旧 document 完全卸载 → 所有 JS 变量、事件监听、游离 DOM 节点一并销毁 →

audio 元素随之消失 → 声音中断。

④ 现有「续播」是兜底,不是无缝

// assets/script.js:432-443
function readLive() {
    try { return JSON.parse(sessionStorage.getItem(LIVE_KEY) || 'null'); } catch (e) { return null; }
}
function saveLiveNow() {
    ...
    sessionStorage.setItem(LIVE_KEY, JSON.stringify({
        url: ...,
        position: isFinite(audio.currentTime) ? Math.floor(audio.currentTime) : 0,
        ...

新页面加载后从 sessionStorage 读回 {url, position} → 重建 audio → audio.src = url; audio.load()(:380-381)

→ 等 loadedmetadata 才能 seek(:469-473)→ 重新建立连接 + 重新缓冲。

用户感受到的「卡顿」,就是这段「重建 + 重连 + 重缓冲」的空白期。

7.2 结论

目标悬浮窗能否达成
UI 位置固定(不随右栏消失)✅ 能
交互状态不被跳转重置(音量 / 模式 / 展开态)✅ 能(且 localStorage 已在做)
切页不断音❌ 不能

7.3 真要「不断音」,只有两条路

方案做法代价
htmx boost 全站给 <body> 加 hx-boost="true",导航变局部刷新⚠️ 极高风险:影响全站所有链接 / 表单 / CSRF / 插件钩子 / PageCache / 缓存 / SEO。核心 main.php:266 的 htmx 用法是局部的,全站 boost 是另一个量级
iframe 承载播放器播放器放独立 iframe⚠️ 在本场景不成立 —— 父页面整页跳转时 iframe 同样被销毁,除非配合 window.name 之类的 hack,脆弱且难维护

我的建议:先做 UI 改造(低风险、用户明确要的),把「切页不断音」单独立项评估。

如果用户的实际症状只是「切页后要等 1-2 秒才续上」,那属于可接受的续播代价,不值得为它冒全站 htmx 改造的风险。

⚠️ 需要向用户确认:您说的「卡顿」,是①声音完全断掉重来,还是②断 1-2 秒后续上,还是③UI 闪一下/位置跳变?

三者的解法完全不同:①③ 悬浮窗能改善,② 需要 htmx。


八、悬浮窗实现设计

8.1 DOM 结构


音乐播放器 ×

设计要点

  1. <div class="sp-float"> 是新增外壳,卡片本身(.sound-player-card)保持原样 →

script.js:25 的 querySelector('.sound-player-card') 继续可用,JS 改造量最小化。

  1. hidden 起步? —— ❌ 不能。notice_center 的弹窗用 hidden 起步是因为有「已读」判定要等 JS;

播放器没有这个需求,服务端直出即可见 → 避免「JS 挂了就永远不显示」。

关闭态由 CSS class + JS 控制。

  1. data-sp-float-state 由 JS 读写(open / closed),初始值服务端给 open。

8.2 CSS 设计(追加到 style.css,不改动 .sound-player-card 原有规则)

/* ===== 悬浮窗外壳(PC 端右侧栏位置) ===== */
.sp-float {
    position: fixed;
    right: var(--sp-float-x, 16px);      /* 拖动改写 */
    bottom: var(--sp-float-y, 24px);
    width: 300px;
    z-index: 100010;                     /* 核心天花板 100005(与 notice_center 同层) */
    /* 视觉:与卡片一致的面板感 */
}
.sp-float[data-sp-float-state="closed"] { display: none; }

/* 拖动把手 */
.sp-float-head { cursor: grab; user-select: none; }
.sp-float-head:active { cursor: grabbing; }

/* 移动端:默认不显示(PC 端专属,见 8.5) */
@media (max-width: 1024px) {
    .sp-float { display: none; }
}

/* 无障碍:尊重减弱动效偏好 */
@media (prefers-reduced-motion: reduce) {
    .sp-float { transition: none !important; }
}

为什么用 right + bottom 而不是 left + top?

  • right / bottom 天然贴合「右侧栏位置」需求,且不受窗口宽度变化影响(右侧栏宽度固定)。
  • 拖动时 JS 改写 --sp-float-x / --sp-float-y 两个 CSS 变量(不直接改 style.left,避免与媒体查询打架)。

⚠️ 记忆红线:**CSS 只用既有 --mn-* 变量**。上面的 --sp-float-* 是插件自有变量(--sp- 前缀,不与核心冲突),

不违反该红线。但不要在 sp-float 里引用那 11 个幻影 --mn-* 变量(见 P2-2)。

8.3 拖动实现(**必须用 pointerdown,禁止 on* 内联**)

// 追加到 assets/script.js 的 soundplayer_main() 内
(function initFloat() {
    var wrap = document.querySelector('[data-sp-float]');
    if (!wrap) return;
    var handle = wrap.querySelector('[data-sp-drag-handle]');

    var DRAG_KEY = 'soundplayer_float_pos_v1';
    var CLOSE_KEY = 'soundplayer_float_closed_v1';

    // ---- 恢复上次位置(localStorage) ----
    try {
        var pos = JSON.parse(localStorage.getItem(DRAG_KEY) || 'null');
        if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
            wrap.style.setProperty('--sp-float-x', pos.x + 'px');
            wrap.style.setProperty('--sp-float-y', pos.y + 'px');
        }
    } catch (e) {}

    // ---- 拖动:pointerdown / move / up ----
    if (handle) {
        var sx = 0, sy = 0, ox = 0, oy = 0, dragging = false;

        handle.addEventListener('pointerdown', function (e) {
            dragging = true;
            sx = e.clientX; sy = e.clientY;
            var cs = getComputedStyle(wrap);
            ox = parseFloat(cs.right) || 16;
            oy = parseFloat(cs.bottom) || 24;
            handle.setPointerCapture(e.pointerId);   // ← 关键:防止拖出窗口丢事件
            e.preventDefault();
        });

        handle.addEventListener('pointermove', function (e) {
            if (!dragging) return;
            // 右/下为正方向:鼠标右移 → right 减小
            var nx = Math.max(0, Math.min(window.innerWidth  - 40, ox - (e.clientX - sx)));
            var ny = Math.max(0, Math.min(window.innerHeight - 40, oy - (e.clientY - sy)));
            wrap.style.setProperty('--sp-float-x', nx + 'px');
            wrap.style.setProperty('--sp-float-y', ny + 'px');
        });

        function endDrag(e) {
            if (!dragging) return;
            dragging = false;
            try {
                handle.releasePointerCapture(e.pointerId);
            } catch (err) {}
            var cs = getComputedStyle(wrap);
            try {
                localStorage.setItem(DRAG_KEY, JSON.stringify({
                    x: parseFloat(cs.right)  || 16,
                    y: parseFloat(cs.bottom) || 24
                }));
            } catch (err) {}
        }
        handle.addEventListener('pointerup', endDrag);
        handle.addEventListener('pointercancel', endDrag);
    }

    // ---- 关闭 ----
    var closeBtn = wrap.querySelector('[data-sp-float-close]');
    if (closeBtn) {
        closeBtn.addEventListener('click', function () {
            wrap.setAttribute('data-sp-float-state', 'closed');
            try { sessionStorage.setItem(CLOSE_KEY, '1'); } catch (e) {}
            // ⚠️ 关闭时**不暂停播放** —— 用户意图是「收起 UI」,不是「停止音乐」
        });
    }
    try {
        if (sessionStorage.getItem(CLOSE_KEY) === '1') {
            wrap.setAttribute('data-sp-float-state', 'closed');
        }
    } catch (e) {}
})();

要点说明

要点理由
pointerdown/move/up 而非 mousedown/mousemove同时支持鼠标 + 触屏 + 触控笔;pointercancel 兜住系统手势打断
setPointerCapture防止鼠标移出把手后 pointermove 丢失(不加这个,快速拖动会「掉」)
写 CSS 变量而非 style.left与 right/bottom 定位策略一致,不产生 left+right 双约束冲突
边界钳制 Math.max/min防止拖出视口后找不回来
位置存 localStorage跨会话保留(用户期望「拖过一次就一直在那」)
关闭态存 sessionStorage本次会话不再打扰,下次访问恢复(比 localStorage 更符合「关闭」语义)
**零 on* 内联**规范 §十二:views/ + hook/ 零内联;本段在 .js 文件里,用 addEventListener
关闭不暂停播放关闭的是 UI,不是播放器 —— 需向用户确认这一行为(见第十三章 D3)

8.4 服务端渲染(hook/layout_body_end.php)

<?php
/**
 * hook/layout_body_end.php —— 输出前台悬浮窗 DOM
 *
 * 触发点:`app/Views/layouts/main.php:534`(`</body>` 前)。**本钩子无参数**
 * (规范 §9.1 #43)。
 *
 * ⚠️ 无 `template` 参数 → 「是否后台页」只能靠请求路径判定(`/admin` 前缀)。
 * ⚠️ 渲染钩子在闭包作用域执行,`$this` 不可用(规范 §11.24)。
 * ⚠️ 本文件禁止声明顶层 function / class / interface / trait(规范 §9.0)。
 *
 * @file plugins/soundplayer/hook/layout_body_end.php
 */

echo \Plugin\Soundplayer\Plugin::renderFloat();

Plugin::renderFloat() 关键逻辑(与 notice_center::renderPopup() 同构):

public static function renderFloat(): string
{
    // ① 后台页不输出(layout_body_end 全站触发,含后台)
    if (self::isBackend()) {
        return '';
    }
    // ② 无曲目/电台 → 不输出(与 cardHtml 同判据)
    $cfg = self::config();
    if (!$cfg['enabled']) {
        return '';
    }
    $tracks   = self::tracks();
    $stations = self::stations();
    if (!$tracks && !$stations) {
        return '';
    }
    // ③ 渲染
    \ob_start();
    include __DIR__ . '/views/float.php';
    return (string)\ob_get_clean();
}

isBackend() 实现(照抄 notice_center/Plugin.php:614-621):

public static function isBackend(): bool
{
    $path = (string)\parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
    return $path === '/admin' || \strpos($path, '/admin/') === 0;
}

⚠️ notice_center 的 isBackend() 还接受 $template 参数(它有 layout_head_end 带参可复用)。

soundplayer 的 layout_body_end 无参数 → 简化为纯路径判定即可。

两处判定口径必须一致(layout_head_end 短路 与 layout_body_end 输出),否则会出现「CSS 加载了但 DOM 没有」或反之。

8.5 移动端处置 —— 需要用户拍板

用户明确说「PC 端的右侧栏」。移动端有三条路:

选项做法说明
A移动端完全不显示(@media (max-width: 1024px) { display: none })最简单,但移动端失去播放器入口(原 show_on_mobile 的右栏卡片被移除后)
B移动端显示为底部固定条(bottom: 0; left: 0; right: 0,不可拖动)保持移动端可用;额外 CSS
C移动端沿用原移动侧栏卡片(保留 sidebar_above_friend_links 但改为条件输出)与 P1-3「删孤儿文件」冲突

建议 A(贴合用户原话)。若用户希望移动端也能播,选 B。

⚠️ 若选 A,show_on_mobile 配置项将失去意义 → 建议一并下线(或改名为 show_float)。


九、风险清单

#风险级别应对
1layout_body_end 全站触发含后台 → 后台页冒出悬浮窗高isBackend() 短路(8.4)
2双卡片:right_sidebar_after + 悬浮窗同时输出 → querySelector 取到死 DOM高移除 right_sidebar_after(6.3 选项 A)
3layout_head_end 与 layout_body_end 判定口径不一致中两处都调同一个 shouldRender(),单次解析共用
4后台页加载 JS 但无悬浮窗 → root 为 null中现有 if (!root) return;(script.js:26)已兜住;但需确认后台 JS 仍按需加载(layout_head_end:20 的 $needJs 逻辑保留)
5游客整页缓存串号中悬浮窗 DOM 内不得含用户态(csrf / uid / 已读)。位置与关闭态走 localStorage / sessionStorage(客户端),不烘焙进缓存
6z-index 冲突中用 100010(核心天花板 100005,notice_center 同层)。⚠️ 若同页既有公告弹窗又有悬浮窗,两者会重叠 → 需确认遮挡关系(悬浮窗在下、弹窗在上)
7拖动与页面滚动冲突低handle.setPointerCapture + e.preventDefault()(8.3)
8localStorage 被禁用(隐私模式)低全部包 try/catch,失败则退化为「不记忆位置」
9plugin.json 改 hooks 后缓存滞后高必须 Plugin::refresh()(app/Helpers/Plugin.php:819)或后台「插件」页刷新,否则 layout_body_end 不生效且无任何报错
10说明.md 需同步大改低钩子清单 + §11.4 + 附录 A + 新增悬浮窗章节
11**--sp-float-* 变量与主题冲突**低用 --sp- 前缀(插件自有),不碰 --mn-*


十、验证清单

改造完成后需跑(逻辑由我跑,界面由您看 —— 按既有分工):

10.1 静态门禁

检查命令 / 方法期望
PHP 语法双版本php -l × 14 文件 × (8.2 / 8.0)全通过
JS 语法node --check assets/script.js通过
零内联plugin_hygiene.pyPASS
无顶层符号plugin_hygiene.pyPASS
全角标点 + $var.workbuddy-ai/tools/php_var_cjk_check.php plugins/soundplayer0 命中
CRLFplugin_hygiene.pyPASS(含 说明.md)
i18n三语键集合比对零差异 + 键序一致
缓存一致plugin_hygiene.py 第 9 项PASS

10.2 逻辑验证(探针)

#用例断言
T1class_exists('\Plugin\soundplayer\Plugin')true(P0-2 修复验证)
T2非管理员访问 /admin/soundplayer302 → /login(或 /),且 sp_tracks 行数零变化(增量守卫)
T3管理员访问 /admin/soundplayer200,含 admin-container / admin-content
T4GET / 前台含 data-sp-float,且 z-index:100010 在 CSS 中
T5GET /admin 后台不含 data-sp-float(后台短路)
T6GET / 连续 3 次 → DDL 计数首请求 5 条,后续 0 条(P1-1 验证)
T7游客页面缓存data-sp-* 中无 csrf / uid 残留
T8禁用插件(enabled=false)layout_body_end 输出空串,layout_head_end 无 <link>
T9无曲目 + 无电台悬浮窗不输出
T10移除 right_sidebar_after 后页面内 .sound-player-card 仅 1 个

10.3 交互验证(需真实浏览器 / 用户自测)

#用例说明
U1拖动悬浮窗位置跟手,松手后保持
U2刷新页面位置保留(localStorage)
U3点关闭悬浮窗消失,音乐继续播(若确认此行为)
U4关闭后切换页面本次会话不再出现(sessionStorage)
U5新开标签页位置保留、关闭态重置为显示
U6窄窗口(< 1024px)悬浮窗隐藏(若选移动端选项 A)
U7与公告弹窗同页层级不冲突,都能操作
U8切页后观察音频中断时长(这是第七章的关键观测点)

⚠️ CSP 注意:核心 CSP(app/Core/Controller.php:80)为 img-src 'self' data: https:,不含 blob:。

本改造不涉及图片预览,无影响。但若后续给悬浮窗加「封面预览」,必须用 FileReader.readAsDataURL()。


十一、回滚路径

改造全部集中在插件目录内,不触碰核心:

改动回滚方式
plugin.json hooks还原 5 个钩子 + Plugin::refresh()
hook/layout_body_end.php删除文件
hook/sidebar_card.php恢复(若移除过)
Plugin.php删除 renderFloat() / isBackend() / ensureSchemaOnce()
views/float.php删除文件
assets/script.js / style.css追加的代码块删除

关键:Plugin.php 的 class_alias(P0-2)与 ensureSchemaOnce()(P1-1)属修复项,不回滚。

数据无风险:改造不涉及任何表结构变更、不迁移数据。sp_* 三表原样。


十二、待确认决策点

改造前需要您拍板 4 件事:

#决策点选项我的建议
D1「卡顿」的实际症状是什么?(第七章)①声音完全断掉重来 ②断 1-2 秒后续上 ③UI 闪一下/位置跳变请先描述症状 —— 这决定要不要评估 htmx boost。若只是③,悬浮窗就够了
D2right_sidebar_after 如何处置?(6.3)A 移除,只留悬浮窗 / B 双入口(改 JS 支持多实例)A(用户说「改成悬浮窗」= 替换)
D3点「关闭」后音乐是否继续播?A 继续播(只收 UI) / B 一并暂停A —— 「关闭悬浮窗」语义上是收 UI,不是停止播放
D4移动端怎么办?(8.5)A 不显示 / B 底部固定条 / C 沿用移动侧栏A(贴合「PC 端」原话);若需移动端可播则 B

另有一个前置建议:先修 P0/P1 再改造,还是一次性做完?

  • 若「先修后改」:可以先让插件安全启用,验证播放功能正常,再做悬浮窗 —— 风险更低、可分阶段验收
  • 若「一次性」:改动集中在同几个文件,一次验收 —— 更快,但问题定位会变难

按您既有的「分阶段推进 + 阶段末暂停确认」习惯,我建议 先修后改。


附录 证据索引

结论证据位置
requireLogin() 只判登录app/Core/Controller.php:134-142
真门禁在 Admin\BaseController 构造函数app/Controllers/Admin/BaseController.php:16-36
Auth::isAdmin() AND 双条件app/Helpers/Auth.php:20-27
核心按目录名硬拼类名app/Helpers/Plugin.php:699
class_exists 三个调用点app/Helpers/Plugin.php:700 / :751 / :847
ensureSchema = exec($sql)app/Helpers/Plugin.php:86-89
Plugin::refresh() 存在app/Helpers/Plugin.php:819
layout_body_end 定义与用途docs/插件开发规范.md §9.1 #43;app/Views/layouts/main.php:532-534
right_sidebar_after 触发点app/Views/_components/right_sidebar.php:211
sidebar_above_friend_links 双触发right_sidebar.php:263 + mobile_sidebar.php:37
后台 CSS 作用域assets/css/admin.css:4
后台视图标准容器写法notice_center/views/admin.php:49-51、netdisk_help/views/admin.php:51-53
class_alias 范式plugins/notice_center/Plugin.php:892-894
layout_body_end 消费者范式plugins/notice_center/hook/layout_body_end.php
shouldInject / isBackend / renderPopupplugins/notice_center/Plugin.php:837 / :614 / :856
核心 CSP(无 media-src)app/Core/Controller.php:80
无全局 htmx boostapp/Views/layouts/main.php 全文仅 :90 注释 + :266 一处 hx-get
audio 元素游离创建plugins/soundplayer/assets/script.js:75-81
续播兜底(sessionStorage)assets/script.js:432-473
root 单元素查询assets/script.js:25-26
幻影 CSS 变量 11 个assets/style.css × assets/css/modern.css(108 定义)交叉比对
门禁 FAIL = 1(CRLF)plugin_hygiene.py plugins/soundplayer

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 4 | Replies 0

All Replies (0)

No replies yet

Please Log in