本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/feidao插件审计报告_2026-09-29.md。
飞刀大乱斗(plugins/feidao)插件审计报告
- 审计日期:2026-09-29
- 审计依据:
docs/插件开发规范.md(v1.3,2026-09-20)+docs/插件审计报告.md(此前未收录 feidao,本报告为首次) - 插件版本:
plugin.jsonv1.0.0 /Plugin::CLIENT_VERSION = 1.48.2-flinthub/SCHEMA_VERSION = '1' - 报障输入:「游戏结束时点击观战,感觉整个游戏就不动了」
- 结论:报障已复现并定位到根因(P0);另有 2 处确定性缺陷、5 处规范偏差。安全红线与结构规范全部通过。
一、报障根因(P0):死者观战路径没有数据源
1.1 现象与复现路径
| 步骤 | 代码位置 |
|---|---|
1. 在公共大地图被击杀 / 本局结束 → finish() 结算 | assets/original/module.js:230-284(onDeath)、module/net.js:1582-1603(finish) |
2. 死亡卡片浮出(#fd-death) | module.js:283 |
3. 点「观战」(#fd-btn-spec)→ 只做本地镜头切换 | module.js:223-225 → module/spectate.js:37-44(start()) |
| 4. 幕布撤掉,露出一个静止的战场 | —— |
spec.start() 只设 G.watchIdx/G.watchAt 并给观战条加 .on,不发任何请求、不改任何网络态。
1.2 根因链(每一环都有 文件:行号)
死亡 → module.js:239 net.snapToServer() / module.js:258 net.finish(...)
→ net.js:1585 ended = true ← 全局「本局已结算」闸门
├─ net.js:1630 localTick(): if (!G.inArena || ended) return; ← 第 1 道门
│ └─ 逻辑帧不再前进:G.frameNo / G.frameAt / board() / syncSelfFields() 全停
└─ net.js:1538 syncRoom(): if (syncing || !api || !G.inArena || ended) return; ← 第 2 道门
└─ 而 syncRoom() 的**唯一调用点**是 net.js:1685(就在 localTick 里)ended 的复位点只有一处:net.js:610(在 resetSelf() 内),而 resetSelf() 只被
resetArena()(net.js:666,进场)与 restart()(net.js:703,复活)调用。
→ 点了观战也永远解不开这两道门。
ended 三个赋值点全量核对:net.js:50(初始化 false)、net.js:610(复位 false)、net.js:1585(置 true)。
1.3 为什么「纯观战」(首页 #fd-iwatch)是好的 —— 对照实验
两条路走的是两套机制,这正是问题所在:
| 首页「观战」`#fd-iwatch` | 死亡卡片「观战」`#fd-btn-spec` | |
|---|---|---|
| 入口 | module.js:689-690 → watchArena() → enterFlow(true) | module.js:223 → spec.start() |
| 服务端 | call('watch') → Plugin::publicArenaWatchPayload() | 不发请求 |
G.watcher | true(net.js:662 从 session.watcher 取) | 仍为 false |
| 数据通道 | watch_sync 轮询(net.js:1542),不受 ended 影响 | 无 |
| 结果 | 世界正常动 | 世界冻结 |
module.js:203-217 的注释明确写着这两条路「算同一回事」,并且为死者那条路做了
UI 层适配(watchingNow() / applyWatchMode() / 隐藏血条刀阶)——
但网络层从未跟上。module.js:326-331 更直接承认了「本地玩法结算后不再产出逻辑帧」。
1.4 证据:真码 A/B 探针(17/17 通过)
探针:.workbuddy-ai/tmp/fd_spectate_probe.js(长期保留,修复后可直接复用验收)
方法:在 vm 沙箱里加载未改动的 assets/original/module/{art.js,net.js},
复刻 module.js 的 G 结构,逐拍调用真实的 localTick()。
=== A. 活着(基线):resetArena 后跑 30 拍 ===
PASS 逻辑帧在前进 :: frameNo 0 → 30
PASS room_sync 持续轮询 :: 4 次 / 1s
=== B. 死亡后(现状代码):finish() 之后跑 30 拍 ===
PASS 死亡后逻辑帧停止前进(画面冻结) :: 5 → 5
PASS 死亡后不再发出 room_sync(无新快照) :: 1 → 1
PASS G.watcher 仍为 false —— 走观战 UI 也不会有数据源
PASS 榜单仍是死亡那一刻的旧快照 :: 11 行
=== C1. 反证:只放开 localTick 的门(net.js:1630) ===
PASS 死亡后逻辑帧恢复前进 :: 5 → 35
PASS 但 room_sync 依旧不涨 —— syncRoom 还有第二道 ended 门(net.js:1538) :: 1 → 1
=== C2. 反证:两道门一起放开 ===
PASS 死亡后逻辑帧继续前进 :: 5 → 35
PASS 死亡后 room_sync 恢复轮询(世界能更新了) :: 1 → 5
=== D. 只有「复活」能解冻:restart() ===
PASS restart() 后帧恢复 :: 0 → 10
=== E. 纯观战者(iwatch 那条路)对照 ===
PASS 纯观战者逻辑帧在前进 :: frameNo 0 → 30
PASS watch_sync 持续轮询 :: 3 次 / 1s
PASS G.watcher === true(与死者那条路的关键差异)
断言 17 条,失败 0 条C1 是本探针独立发现的:静态阅读容易只看到 localTick 那道门,实测证明
必须同时放开 net.js:1538 与 net.js:1630 两道。
探针踩坑记录(已写进
REF_审计与验证):syncRoom用syncing标志串行化,同步循环里不同步 flush 微任务会把计数 latch 住 → 误判成「没轮询」。
1.5 服务端是否支持「结算后继续同步」——支持,无需改 PHP
已逐行核对:
Plugin::roomSync()(Plugin.php:965)的快路径(:980)只要求
room_status === 'running' + 公共战场码;不校验玩家是否还活着。
Plugin::roomFinish()对公共战场把玩家状态写回'playing'(Plugin.php:937),
所以结算后玩家行仍在。
feidao_runs行结算后仍在(settled_at置位),:986的查询不会失败,
:997 的写入有 settled_at === 0 守卫。
:996$merged['alive'] = incoming.alive && stored.alive→ 死者保持alive=false;
:1051 的命中循环以 $merged['alive'] 为前提 → 死后补发的命中一律被忽略(无刷分面)。
即:客户端只要继续轮询 room_sync,就能拿回全量 roomPayload 快照,世界就会动。
1.6 修复建议(未动手,等你拍板)
两条路,改动面差别很大:
方案 B(推荐)—— 只改 net.js 两处门控 + 一个语义开关
- 新增
let spectatingAfterEnd = false; net.js:1630→if (!G.inArena || (ended && !spectatingAfterEnd)) return;net.js:1538→if (syncing || !api || !G.inArena || (ended && !spectatingAfterEnd)) return;- 由
module.js:223那条(死亡卡片观战)在spec.start()成功后置真;
spec.stop() / 退出观战 / 回死亡卡片 / restart() 时置假。
- 服务端零改动(1.5 已验)。
- 代价/风险:
G.watcher保持false→watchLink()(module.js:322-343)的「死者不算掉线」早退分支
继续生效,不会误弹「连接断开」,这点正好合适。
- 死者本地会继续跑
updateBots()(本插件的电脑玩家是各客户端本地模拟,
不进 roomPayload)→ 看到的场面与「活着时」一致;但纯观战者那条路看不到本地电脑玩家
(resetArena() 对 watcher 把 bot 全置死,net.js:684-691)→ 两条观战路径观感仍不一致。
- 死者会持续
room_sync→last_seen一直新鲜 → 在Plugin::onlineCount()/
pickArenaRoomId() 的容量口径里继续占一个名额(现状冻结 15 秒后会自动让位)。
如果在意这点,可让服务端容量口径改成只数 alive 的玩家——那是另一处改动,需单独确认。
方案 A(不推荐)—— 死亡后直接走 enterFlow(true) 变成纯观战者
- 语义最干净(复用已验证的
watch_sync通路),但要把服务端签发的
session.watcher 语义扩展到「已结算的参赛者」,牵动 roomPayload / watch_sync / 结算幂等,
影响面大得多。
二、其它确定性缺陷
P1-1 后台看板「战场」恒为 0
- 位置:
AdminController.php:118
```php
"SELECT COUNT(*) FROM feidao_rooms WHERE status IN ('waiting','playing')"
```
- 实际
feidao_rooms.status取值只有waiting/running/finished
(Plugin.php:691 插入 'running';Plugin.php:947 置 'finished')→
'playing' 是玩家表的状态值,房间表里不存在。
- 实测:插件库当前
SELECT room_code,status FROM feidao_rooms→[('PUBLIC','running')]
→ 该卡片永远显示 0。
- 修法:
IN ('waiting','running')。
P1-2 卸载残留:不清核心 settings 里的 feidao_* 键
- 位置:
Plugin.php:375-383(uninstall())只 DROP 6 张表 + 删戳文件
protected/.feidao_table_cache;未清 settings 表。
- 核心
app/Helpers/Plugin.php::uninstall()(:835-870)只负责「调插件自己的uninstall()
+ 把 activated 置 false + 重建缓存」,不会代清 settings。
- 实测:
data/meta/business.sqlite的settings表现存feidao_last_cleanup;
站长一旦保存过后台表单,还会多出 feidao_entry_fee / feidao_kill_reward /
feidao_daily_cap / feidao_bot_count / feidao_bot_names 五个键。
- 规范依据:§11.5 第 34 条(卸载残留自检三处)。
- 修法:
uninstall()里追加Settings键清理 +Settings::buildCache()
(§11.16:绕过 Settings::update() 直删后必须重建文件缓存)。
P2-1 会话过期后无回跳入口(§11.28 ①③)
FrontController.php:80-82:未登录返回{ok:0, code:401, message:'请先登录'},
未附 login_url(§11.28 ③ 要求)。
assets/original/module.js:139-141:
```js
location.href = '/login?redirect=' + encodeURIComponent(location.pathname + location.search);
```
裸 /login(不处理 BASE_PATH)+ 自己拼回跳参数(§11.28 ① 明确禁止自拼,应走 Auth::loginUrl())。
- 现状影响有限:
FrontController::index()已requireLogin(),页面加载时能正常跳登录;
只有「页面开着 → 会话过期 → 点开始」这一路会卡在 toast。
(另注:needLogin() 实际很难触达 —— module.js:661 是 me = data.user || me,
me 一旦有值就不会被 null 覆盖。)
P2-2 死功能:弹幕整条链不可达
Plugin::clientSettings()(Plugin.php:235-236)硬编码danmaku_on => false、
danmaku_cost => 0,后台没有任何入口能改;
module/danmaku.js:147-153见danmaku_on为假即return,连按钮都不建;- 即便建了,
FrontController.php:171-172的danmaku动作是return ['ok' => true];空壳。 - 结论:
danmaku.js(162 行)+spectate.js里的弹幕按钮插入点 = 死代码。
要么接上后台开关与服务端扣费,要么删掉,别留「点了没反应」的控件(符合你的极简取向)。
三、规范偏差(不影响功能)
| # | 位置 | 问题 | 规范 |
|---|---|---|---|
| 1 | views/index.php:36,38、views/admin.php:28 | $this->icon('xxx', N) 恒返回带内联 style="font-size:Npx" 的 <i> | §7.3 P1 零内联;改用核心工具类 mn-fs-14/15/16。注:code_repo/daily_checkin/dice 等既有插件同样写法,非本插件独有 |
| 2 | assets/style.css:62,111,125 | var(--mn-radius-sm, 8px) / var(--mn-radius-md, 10px) —— 这两个变量全仓 0 处定义 → 永远走 fallback | §11.7「拼错=静默失效」。仅影响圆角,无主题风险(不是颜色变量) |
| 3 | FeidaoException.php | 行尾为 CRLF | §11.19:UTF-8 无 BOM + LF(CRLF 例外清单只含 mod_system 4 个文件) |
| 4 | AdminController.php:63-66 | (int)($_POST[$k] ?? 0) + 区间夹取 —— 当前表单字段名全部对得上,属潜伏雷:任一字段改名/漏传即静默写 0(entry_fee/kill_reward/daily_cap 三项无下限保护) | §J.2(原 MEMORY §7):正解 array_key_exists($key, $_POST) 缺失即跳过 |
| 5 | module/buffs.js:39 | row.url.replace(/"/g,'%22') 未转义 ) / \;因 clientSettings() 不产出 buffs[].url,当前不可达 | §7.3 第 4 条(动态色值/URL 应走预定义 class 或 CSS 变量) |
四、通过项(逐条对照规范)
安全(第一章三条硬规则 + 一五 8 条红线)
- 规则 1 文件操作:插件内无任何
unlink/file_put_contents/move_uploaded_file吃用户输入;
唯一的文件读是 FrontController::asset(),用 realpath() + 前缀比对 + \0/../ 拦截(:208-219)。
- 规则 2 SQL:全部
prepare()+ 绑定(Plugin.php:90-112是唯一出口);
rankRows() 的 IN (...) 用 :u0..:uN 占位符绑定(Plugin.php:1332-1341),无 implode 拼 id。
- 规则 3 XSS:视图全走
$this->e();JS 侧昵称/弹幕/榜单一律textContent或esc()
(board.js:37,59、overlay.js:64,94,112-118、store.js:129、spectate.js:115)→ 零 XSS 面。
- 红线 1/2:无
unserialize/eval/assert;无$_SERVER['DOCUMENT_ROOT'](全__DIR__/BASE_PATH)。 - 红线 3:渲染钩子
nav_plugin_links.php只echo,无die/exit/header。 - 红线 5 积分:入场费/头像购买走
Points::deduct()(条件原子扣减,:653/:1250),
失败走 refundRun()/Points::award() 回滚,无 UPDATE users SET points=points-N 直写。
- 红线 6/7:见上;
Plugin::db()由PluginHelper::db()提供,未覆盖 PDO 属性。 - 红线 8 CSRF:
FrontController.php:80-85先登录后 CSRF(顺序正确,§11.28 ②);
AdminController.php:60,103 均 Csrf::verifyOrDie($_POST['csrf'] ?? '')。
- IDOR:
playerLookup()/roomSync()/roomFinish()全部带user_id条件(:494/:968/:906);
settleRunForUser() 校验 run.user_id === userId(:819);roomFinish 用 hash_equals 校验 run_token(:912)。
门禁与权限
AdminController extends app\Controllers\Admin\BaseController→ 构造函数门禁生效(含管理员二次验证)。plugin.json的permissions: ["route:admin", "system:settings"]与「注册后台路由 + 写全局设置」两个受控能力完全对应。
生命周期与结构
- 目录名
feidao无下划线 → 不需要class_alias(§4.1 不适用)。 - 建表双保险:
init_after.php走SCHEMA_VERSION内容比对门控(不是「文件存在」门控,§B.7 正确)
+ AdminController::index() 兜底 createTable()。
- 路由前缀:
route_register.php写完整/feidao;admin_route_register.php不写/admin(§11.22 正确)。 - 4 个钩子全部已在
plugins/plugins_cache.json注册(实测:feidao条目 hooks 完整)。 nav_plugin_links.php未声明任何顶层 function/class → 双触发安全(§9.3);图标 16px 正确(§9.3 实测值)。- 无
.bak/.tmp/.revert残留;data/同时有.htaccess+web.config双防护。
i18n
- 三语字典各 46 键,
zh↔en、zh↔zh_tw集合零差异;代码引用键 46 个,
零缺失、零冗余(§8 验收口径 = 键集合零差异,已满足)。
- 例外:
Plugin.php内部fail('请先登录')等为硬编码中文(未走 i18n)——移植遗留,建议后续统一。
前端资源与编码
- 所有资源 URL 拼
BASE_PATH+ 版本号(?v=filemtime或?v=CLIENT_VERSION);view.html零内联<script>/<style>/on*。 php -l全 21 个 PHP 文件通过;除FeidaoException.php外全部 LF + 无 BOM。- 依赖的
--mn-text-link/--mn-bg-nav-hover/--mn-shadow-dropdown/--mn-bg-card等均已定义(各 11 处)。 sw.jsCache-First 风险已规避:所有插件资源 URL 都带版本号(版本一变 URL 就变)。
五、建议处理顺序
- P0 观战卡死 —— 方案 B(两行门控 + 一个开关);改完直接跑
.workbuddy-ai/tmp/fd_spectate_probe.js 加一条断言「死亡后 room_sync > 0」。
- P1-1 看板 0 —— 一个字符串改动(
'playing'→'running')。 - P1-2 卸载残留 —— 补
uninstall()的 settings 清理。 - P2-2 死功能 —— 决定接上还是删掉(涉及「点了没反应的控件」,按你的取向应删)。
- 其余 P2 / 偏差项可随手一起收。
本报告未改动插件任何代码。等你确认方案后再动手。
六、修复记录 —— P0 方案 B 已落地(2026-09-29 晚)
6.1 改动集(3 个文件)
| 文件 | 改动 |
|---|---|
assets/original/module/net.js | :54 新增 let spectatingAfterEnd = false;;:615 resetSelf() 里随 ended 一起清零;:1543 syncRoom() 门控收窄;:1635 localTick() 门控收窄;:1721~1727 新增 setPostEndSpectate(on),:1728 导出 |
assets/original/module.js | :758 主循环接线 net.setPostEndSpectate(!G.watcher && wantWatch);(wantWatch = watchingNow()) |
Plugin.php | :59 CLIENT_VERSION 1.48.2-flinthub → 1.48.3-flinthub |
setPostEndSpectate() 是边沿触发(状态没变直接 return,主循环每帧调也无开销),
置真时清掉 lastSync —— 让恢复后的第一拍立刻补一次同步,不用干等一个轮询间隔。
⚠️ 版本必须 bump:FrontController::asset() 发的是
Cache-Control: public, max-age=31536000, immutable,?v= 是唯一的破缓存手段
(module.js 再按 VERSION 去拉 module/net.js?v=…)。不 bump 等于没修。
6.2 语义:ended 从「停止一切」收窄为「停止自己参战」
| 状态 | `spectatingAfterEnd` | 行为 |
|---|---|---|
| 活着 | false | 正常 |
| 死了 · 死亡卡片盖着(未点观战) | false | 仍然冻结(原行为,不空跑、省流量) |
| 死了 · 观战条拉起 | true | 逻辑帧恢复 + room_sync 继续轮询 → 世界会动 |
纯观战者(G.watcher) | false | 不受影响,仍走 watch_sync |
6.3 验证
.workbuddy-ai/tmp/fd_spectate_probe.js —— 31 条断言全 PASS(vm 沙箱加载未改动真码):
- 0 组:修复锚点守卫(防被误回滚);并从
module.js真实源码抽出watchingNow()与接线表达式求值,
五种 UI 状态交叉验证「真实表达式 = 手抄语义 = 期望值」。
- C 组(核心验收):死亡后点观战 →
frameNo 5 → 36、room_sync 1 → 5,
且恢复后的第一拍就补了一次同步(lastSync 清零生效)。
- E 组(反证):内存里把两处门控改回旧写法 → 开观战仍
frameNo 5 → 5、room_sync零增量
→ 复现「整个游戏不动了」,证明这两行是承重的。
- B / D / F 组:未开观战仍冻结;退出观战重新冻结;
restart()清掉观战态(再死一次会重新冻结)。 - G 组:纯观战者对照,
room_sync恒 0,两条路互不干扰。
探针首轮 C 组假 FAIL,真因是探针自己:只调
finish()没把自己置死,而接线表达式判的是
!G.self.alive。补die()复刻真实死亡落点(kill()先置死 →onDeath()再finish())后全绿。
HTTP 冒烟(php -S + 临时会话,用完已按精确 id 删除):
GET /feidao/asset?file=original/module/net.js&v=1.48.3-flinthub→ 200,spectatingAfterEnd×6GET /feidao/asset?file=original/module.js&v=1.48.3-flinthub→ 200,setPostEndSpectate×1GET /feidao(登录态)→ 200,data-version="1.48.3-flinthub"- 目录穿越
?file=original/../FrontController.php→ 404(防护未被这次改动破坏) node --check三个 JS 通过;php -l Plugin.php通过;全角变量检查通过
6.4 已知代价(未处理,需你定)
- 死者在观战时本地
updateBots()继续跑(本插件电脑玩家是各客户端本地模拟,不进roomPayload)
→ 与「纯观战者看不到本地电脑玩家」的观感差异依然存在(只有方案 A 能消掉)。
- 死者观战期间
room_sync持续 →last_seen一直新鲜 → 在onlineCount()/pickArenaRoomId()
的容量口径里继续占一个名额(改前是冻结 15 秒后自动让位)。
要收掉须改服务端容量口径(只数 alive)—— 另一处改动,等你确认。
6.5 本次未动
plugin.json的version仍是1.0.0(从未维护过)。- P1-1 / P1-2 / P2-1 / P2-2 与 5 条规范偏差 均未动。