本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/碎语时光插件开发方案_2026-09-27.md。
碎语时光插件开发方案(全功能 · 自成一体)v2
日期:2026-09-27 | 依据:
docs/插件开发规范.md状态:待确认(本文只出方案,不动手写代码)
插件名 / 目录名:
suiyu(plugins/suiyu空闲 ✓;无下划线 → 不需要class_alias)v2 说明:按「全功能 + 自成一体」指令重写。§三 是逐条可行性校核结果 ——
结论是 10 项功能全部可行,但有 6 个「指令没写、不写就静默失败」的实现细节(§三·B)。
一、结论摘要
| 项 | 判定 |
|---|---|
| 功能可行性 | ✅ 10 项全部可行(发布 / 流 / 互动 / 关注 / 审核 / 举报 / 搜索 / 通知 / 删除 / 后台) |
| 零核心改动 | ✅ 不需要改 app/ 或 index.php 任何文件 |
| 零插件依赖 | ✅ 可达成(关注/审核/举报/搜索全部自建)—— 但有 1 个陷阱,见 §三·B-6 |
| 自成一体 | ✅ 6 张表全在 plugins/suiyu/data/suiyu.sqlite |
| 风险等级 | 低(全部走既有插件机制,无新扩展点) |
| 入口(已定案) | PC 左栏「应用」面板 + 移动端「应用」下拉 = nav_plugin_links 单钩子双触发(实测正好覆盖两端,见 §六) |
校核产出的 7 个必须补的细节(摘要,详见 §三·B):
- ⚠️⚠️ 缩略图不能手拼
'thumb_' . $rel→ 会 404 裂图 - ⚠️⚠️
plugin.json必须声明system:settings→ 否则保存设置静默失效 - ⚠️
nav_plugin_links的图标不能用$view->icon()→ 带内联 style,违反零内联
→ 用字面量 <i class="fa mn-fs-16"></i>
- ⚠️⚠️
nav_plugin_links文件禁止声明顶层符号 → 否则移动端「应用」下拉里没有入口
(PC 有、移动端静默丢失)
- ⚠️
nav_plugin_links高亮必须守卫__nav_uri→ 顶栏那次没有这个参数,不守卫会全站高亮 - ⚠️
sy_reports建议加UNIQUE(post_id,user_id)→ 防刷举报 - ⚠️ 不要挂
profile_user_card_after→ 那是user_profile触发的,会破坏「零插件依赖」
二、设计原则落地(「自成一体」怎么保证)
| 原则 | 落地手段 | 验证方式 |
|---|---|---|
| 数据全存自己的库 | 6 张表全在 plugins/suiyu/data/suiyu.sqlite(Plugin::db('suiyu')) | grep 全插件源码,不得出现 business.sqlite / main_index / 核心表名 |
| 逻辑自己实现 | 关注 / 审核 / 举报 / 搜索全部自建表 + 自实现 | 不 require 任何其他插件的文件 |
| 不碰核心库 | 只走 Upload::file() / Emoji::parse() / Notification::add() / Csrf / RateLimiter / Settings | 这 6 个是核心标准接口,不算插件交集 |
| 零插件依赖 | 不调其他插件的类/表/钩子 | grep -rn "Plugin\\\\[A-Z]" plugins/suiyu/ 必须为空 |
⚠️ 「零插件依赖」的判定标准要写死:不是「不调用其他插件的方法」,
而是「不依赖其他插件是否启用」。
→ 因此不能用
profile_user_card_after(#25)—— 它的触发点在
plugins/user_profile/views/front.php:43,一旦user_profile停用,钩子永不触发(软依赖)。关注按钮一律放我们自己的页
/suiyu/u/{id}。
三、可行性校核(逐条)
A. 指令条目 → 可行性判定
| # | 指令条目 | 判定 | 实测依据 / 备注 |
|---|---|---|---|
| 1 | 独立库 6 张表 | ✅ | Plugin::db($name)(app/Helpers/Plugin.php:49)返回原生 PDO,自动补 .htaccess/web.config;SQLite 3.39.2 |
| 2 | 发布:300 字 | ✅ | mb_strlen 校验 + RateLimiter::hit()(:336) |
| 3 | 发布:最多 9 图 | ✅ | Upload::file($file,$allowed,$opts)(:446);$opts['namespace'] 支持(:507) |
| 4 | 表情 shortcode → 图片 | ✅ | Emoji::parse($html)(app/Helpers/Emoji.php:114);⚠️ src 必须走 EMOJI_MAP,见 §七·17 |
| 5 | 全站流 tab = new/hot/follow | ✅ | 三个索引 idx_sy_posts_stream / _hot / _follows 齐备 |
| 6 | 我的 / 用户主页 / 单条详情 | ✅ | 走自有表,无依赖 |
| 7 | htmx 翻页 | ✅ | viewRaw() 片段(Controller.php:61);⚠️ 不带 <link>/<script>(§11.27) |
| 8 | 点赞(1 人 1 次可取消) | ✅ | sy_likes UNIQUE(post_id,user_id) 天然保证 |
| 9 | 评论 + 楼中楼(reply_to) | ✅ | 自有表自引用 |
| 10 | 转发(repost_of 引用原帖) | ✅ | 自有表自引用;⚠️ 原帖删除后需显示「原内容已删除」 |
| 11 | 关注自建(sy_follows + 关注 tab) | ✅ | 完全自建,不碰 user_profile 的 user_follows |
| 12 | 审核自建(sy_blocked_words) | ✅ | 纯服务端校验,无需外部 |
| 13 | 举报自建(sy_reports) | ✅ | ⚠️ 建议补 UNIQUE(post_id,user_id),见 §三·B-5 |
| 14 | 搜索自建 | ✅ | LIKE 简单可靠;FTS5 + trigram 也可用(SQLite 3.39.2 ≥ 3.34,实测 trigram: OK)→ 见 §三·C-1 |
| 15 | 通知 4 类(赞/评/转/关注) | ✅ 无 type 白名单 | Notification::add()(:69)只做 mb_substr($type,0,20) 截断;渲染通用(app/Views/notifications/index.php:86 只输出 $n['link']+$n['title'])→ 任意自定义 type 都能正常显示 |
| 16 | 删除(作者 + 管理员,软删) | ✅ | status=1 回收站 |
| 17 | 后台 10 条路由 | ✅ | ⚠️ 不写 /admin 前缀(§11.22);基类 Admin\BaseController 构造函数自带 Auth::isAdmin() + 二次密码验证(app/Controllers/Admin/BaseController.php:16-39)→ 门禁白送 |
| 18 | 5 个钩子(入口收敛为「应用」一处) | ✅ | 全部实测存在(见 §六);nav_plugin_links 双触发已实测确认恰好覆盖 PC 左栏 + 移动端下拉 |
B. ⚠️ 校核产出的 6 个「指令没写但必须做」的细节
B-1. ⚠️⚠️ 缩略图不能手拼 'thumb_' . $rel → 会 404 裂图
Upload::makeThumbnail() 把前缀加在 basename 上(app/Helpers/Upload.php:582-583):
$thumbPath = self::absPath($dir . $prefix . \basename($norm)); // $dir = dirname($rel)而 makeRelPath($ext, 'suiyu') 返回的是 suiyu/2026-09/<32hex>.png(:210,namespace 是前导目录)。
| 写法 | 结果 |
|---|---|
✅ Upload::thumbUrl($rel, 'thumb_')(:352)/视图 $this->uploadThumb($rel) | suiyu/2026-09/thumb_abc….png ✓ |
❌ 手拼 'thumb_' . $rel | thumb_suiyu/2026-09/abc….png → 文件不存在 → 裂图 |
→ 红线:缩略图一律走 Upload::thumbUrl(),禁止手拼前缀。
B-2. ⚠️⚠️ plugin.json 必须声明 system:settings → 否则保存设置静默失效
Settings::update($key,$value,$pluginName)(app/Helpers/Settings.php:255)第一件事就是:
$caller = $pluginName !== '' ? $pluginName : Plugin::detectCaller();
if ($caller !== '' && !Plugin::requirePermission($caller, 'system:settings')) {
\error_log("Settings::update 被插件 {$caller} 越权调用: {$key}");
return; // ← 静默返回,前台看不出任何异常
}而 requirePermission()(app/Helpers/Plugin.php:175)的默认是强制模式:
$enforce = Settings::get('permission_enforce', '1') === '1'; // ← 缺省 '1' = DENY→ plugin.json 的 permissions 必须是 ["route:admin", "system:settings"],一个都不能少。
⚠️ 附注:
Settings::update()末尾会self::buildCache()(:267)→ 插件设置会自动重建缓存,不需要手动清设置缓存。(MEMORY 里「新增 settings 键必须清缓存」那条针对的是核心代码新增键的场景,插件侧不适用。)
B-3. ⚠️ 图标不能直接用 $view->icon() → 带内联 style
TemplateCompiler::icon($name,$size,$extraClass)(:609)返回的 HTML 带 style="font-size:Npx"
(证据:plugins/nav_manager/Plugin.php:516 的 _icon() 专门写正则去剥
<i class="…" style="font-size:Npx;"> 这个形态)。插件零内联红线(§7.3)不允许。
两条正解:
- ① 钩子/视图里手写
<i class="fa fa-xxx mn-fs-N" aria-hidden="true"></i>(daily_checkin的做法) - ② 在
Plugin.php里自实现剥内联助手(范式 =plugins/nav_manager/Plugin.php:516的_icon($view,$name,$size))
⚠️ 更正一条项目记忆:核心没有
Plugin::_icon()(实测grep -n "_icon" app/Helpers/Plugin.php为空)。
_icon()只是nav_manager插件自己写的私有静态方法。记忆里「插件渲染图标须剥内联改挂尺寸类(Plugin::_icon())」的写法是错的 → 已在技能里更正。
B-4. ⚠️ 双触发钩子文件禁止声明顶层 function/class/trait → 否则第二次触发静默丢失
Plugin::hook()(:636-640)会静态检测钩子文件是否声明顶层符号:
self::$hookDeclaresSymbols[$hookFile] = preg_match('/^[ \t]*(?:abstract\s+|final\s+)?(?:function|class|interface|trait)\s+\w+/m', $src) === 1;
// 声明了 → include_once(第二次触发被跳过);没声明 → include(每次执行)→ 本插件唯一的双触发钩子 nav_plugin_links 文件必须无顶层符号
(所有逻辑写在文件顶层语句里,不包函数)。这是最容易犯、且完全静默的错 ——
后果是「PC 左栏「应用」里有入口、移动端「应用」下拉里没有」。
B-5. ⚠️ sy_reports 建议补 UNIQUE(post_id, user_id)
指令给的 DDL 只有 idx_sy_reports_status。没有唯一约束时,同一用户可以无限次举报同一帖
→ 后台被刷屏、且「举报数」失去参考意义。
→ 建议:加 UNIQUE(post_id, user_id)(同一人对同一帖只能举报一次;重复提交走 INSERT OR IGNORE 并回「已举报过」)。
B-6. ⚠️ 不要挂 profile_user_card_after(会破坏「零插件依赖」)
profile_user_card_after(#25)的触发点在 plugins/user_profile/views/front.php:43
—— 由 user_profile 插件触发。一旦它停用,钩子永不触发 → 关注按钮消失 = 软依赖。
→ 关注按钮一律放我们自己的页 /suiyu/u/{id}。
(若将来确实想在核心个人主页也放,那属于改核心或接受软依赖,需另行拍板。)
B-7. ⚠️ nav_plugin_links 的图标不能用 $view->icon() —— 必须用字面量
§9.3 的标准模板写 $view->icon('gift', 16),但核心 icon() 恒返回带内联 style 的 <i>
→ 违反零内联红线(plugin_hygiene.py:34:任意 style 属性都算内联)。
→ 用字面量:<i class="fa mn-fs-16" aria-hidden="true"></i>
(既有做法:plugins/icon_generator/hook/nav_plugin_links.php:14、plugins/props/hook/nav_plugin_links.php:32)
⚠️ .fa 的 font-size:inherit 已于 2026-09-24 删除(assets/css/fontawesome.css:10-16)
→ .mn-fs-16(modern.css:1862)能正常生效 ✓
C. 需要澄清的点
| # | 问题 | 我的处理 |
|---|---|---|
| C-1 | 指令写「只搜自己的碎语」 | 我理解为「只搜本插件自己的数据源(不接入核心搜索)」,与后半句「不接入核心搜索」对仗 → 设计为搜全站公开碎语(status=0),并支持 ?mine=1 只搜自己的。⚠️ 若你的原意是「搜索页只允许搜当前用户自己发的」,改 1 处 WHERE 即可,请确认。 |
| C-2 | 热门 tab 排序口径 | 设计为 ORDER BY like_count DESC, id DESC(可命中 idx_sy_posts_hot)。若要「赞+评论+转发加权」则需全扫,小数据量可接受 —— 请确认要不要加权。 |
| C-3 | 删碎语是否删图 | 设计为:软删不删图(可恢复);后台「彻底删除」才 Upload::delete($rel)。⚠️ 注意 Upload::delete() 返回删除文件数。 |
| C-4 | 搜索用 LIKE 还是 FTS5 | 设计为 v1.0.0 用 LIKE(content LIKE '%q%',简单、零维护、数据量小);FTS5+trigram 实测可用,但需在 activate() 里运行时探测(生产 PHP 8.4 的 SQLite 版本未知),留作 v1.1.0 优化。 |
| C-5 | 转发是否允许「转发自己的」 | 设计为允许(微博也允许),但禁止「转发一条转发」时无限套娃 —— repost_of 只指向原创帖 id(转发转发 → 仍指向最初原创)。 |
四、数据表(6 张,全部在 plugins/suiyu/data/suiyu.sqlite)
全部 SQLite 语法(§10.3);
ensureSchema幂等;建表放activate()+init_after惰性兜底。
-- ① 碎语主表
CREATE TABLE IF NOT EXISTS sy_posts (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
content TEXT NOT NULL DEFAULT '',
images TEXT NOT NULL DEFAULT '[]', -- JSON 数组,元素 = 相对 uploads 路径
repost_of INTEGER NOT NULL DEFAULT 0, -- 0=原创;>0=最初原创帖 id
repost_count INTEGER NOT NULL DEFAULT 0,
like_count INTEGER NOT NULL DEFAULT 0,
comment_count INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 0, -- 0=正常 1=回收站
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_sy_posts_user ON sy_posts (user_id, status, id DESC);
CREATE INDEX IF NOT EXISTS idx_sy_posts_stream ON sy_posts (status, id DESC);
CREATE INDEX IF NOT EXISTS idx_sy_posts_hot ON sy_posts (status, like_count DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_sy_posts_repost ON sy_posts (repost_of);
-- ② 评论(含楼中楼)
CREATE TABLE IF NOT EXISTS sy_comments (
id INTEGER PRIMARY KEY,
post_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
content TEXT NOT NULL DEFAULT '',
reply_to INTEGER NOT NULL DEFAULT 0, -- 0=直接评论;>0=回复的评论 id
status INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sy_comments_post ON sy_comments (post_id, status, id);
-- ③ 点赞
CREATE TABLE IF NOT EXISTS sy_likes (
id INTEGER PRIMARY KEY,
post_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(post_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_sy_likes_user ON sy_likes (user_id);
-- ④ 关注(自建,不碰 user_profile 的 user_follows)
CREATE TABLE IF NOT EXISTS sy_follows (
id INTEGER PRIMARY KEY,
follower_id INTEGER NOT NULL,
followee_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(follower_id, followee_id)
);
CREATE INDEX IF NOT EXISTS idx_sy_follows_follower ON sy_follows (follower_id);
CREATE INDEX IF NOT EXISTS idx_sy_follows_followee ON sy_follows (followee_id);
-- ⑤ 敏感词库(自建)
CREATE TABLE IF NOT EXISTS sy_blocked_words (
id INTEGER PRIMARY KEY,
word TEXT NOT NULL UNIQUE,
action TEXT NOT NULL DEFAULT 'block', -- block / replace
created_at TEXT NOT NULL
);
-- ⑥ 举报(自建)
CREATE TABLE IF NOT EXISTS sy_reports (
id INTEGER PRIMARY KEY,
post_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
reason TEXT NOT NULL DEFAULT '',
status INTEGER NOT NULL DEFAULT 0, -- 0=待处理 1=已处理 2=忽略
created_at TEXT NOT NULL,
UNIQUE(post_id, user_id) -- ★ 校核补充(§三·B-5)
);
CREATE INDEX IF NOT EXISTS idx_sy_reports_status ON sy_reports (status, id);4.1 计数一致性(红线)
like_count / comment_count / repost_count 是冗余真值列,必须与明细同一事务:
$pdb = \app\Helpers\Plugin::db('suiyu');
$pdb->beginTransaction(); // ⚠️ 原生 PDO:没有 begin()
try {
$pdb->prepare('INSERT INTO sy_likes (post_id,user_id,created_at) VALUES (?,?,?)')->execute([...]);
$pdb->prepare('UPDATE sy_posts SET like_count = like_count + 1 WHERE id = ?')->execute([$postId]);
$pdb->commit();
} catch (\Throwable $e) {
if ($pdb->inTransaction()) $pdb->rollBack(); // ⚠️ 先判断,否则二次抛异常逃逸 → 500
\error_log('suiyu like error: ' . $e->getMessage());
return ['ok' => false, 'msg' => I18n::get('plugin.suiyu.err_system')];
}⚠️ 取消点赞时,
DELETE返回rowCount() === 0说明本来就没赞过 → 不能-1(否则计数变负)。
4.2 关注流查询(自建表,无跨插件)
SELECT * FROM sy_posts
WHERE status = 0
AND user_id IN (SELECT followee_id FROM sy_follows WHERE follower_id = :me)
ORDER BY id DESC LIMIT :limit OFFSET :offset命中 idx_sy_follows_follower ✓。⚠️ 未登录时「关注」tab 显示引导登录,不查库。
五、路由
5.1 前台(route_register,⚠️ 在 group 外 → 必须写全路径)
| 方法 | 路径 | 响应 |
|---|---|---|
| GET | /suiyu | 页面(`?tab=new\ |
| GET | /suiyu/mine | 页面 |
| GET | /suiyu/u/{id} | 页面(含关注按钮) |
| GET | /suiyu/post/{id} | 页面(详情 + 评论) |
| GET | /suiyu/search | 页面(?q=&mine=) |
| GET | /suiyu/list | htmx 片段(viewRaw,⚠️ 不带 <link>/<script>) |
| POST | /suiyu/post | PRG($_SESSION['suiyu_flash_*'] + redirect()) |
| POST | /suiyu/post/{id}/delete | PRG |
| POST | /suiyu/repost | PRG |
| POST | /suiyu/like | XHR → JSON |
| POST | /suiyu/comment | XHR → JSON |
| POST | /suiyu/follow | XHR → JSON |
| POST | /suiyu/report | XHR → JSON |
⚠️ 「表单提交」与「XHR」响应不能混用(MEMORY §8):普通
<form>走 PRG,XHR 才返 JSON。改一个必须加对照断言保住另一个。
⚠️ Router 参数语法 =
preg_replace('/\{(\w+)\}/','(?P<$1>[^/]+)',$uri)(app/Core/Router.php:47)→
{id}单段参数 ✓;/suiyu/post/{id}/delete这种「多段+参数」可用(qa已验证/qa/edit/{id})。
5.2 后台(admin_route_register,⚠️ 不写 /admin 前缀,§11.22)
| 方法 | 路径 | 动作 |
|---|---|---|
| GET | /suiyu | 碎语列表(分页 + 搜索) |
| GET | /suiyu/trash | 回收站 |
| POST | /suiyu/delete/{id} | 软删 |
| POST | /suiyu/restore/{id} | 恢复 |
| POST | /suiyu/purge/{id} | 彻底删除(含删图,§三·C-3) |
| GET | /suiyu/words | 词库管理 |
| POST | /suiyu/words/add | 加词 |
| POST | /suiyu/words/edit/{id} | 改词 |
| POST | /suiyu/words/delete/{id} | 删词 |
| GET | /suiyu/reports | 举报列表 |
| POST | /suiyu/reports/{id}/handle | 处理(删碎语 / 忽略) |
| GET | /suiyu/settings | 设置页 |
| POST | /suiyu/settings | 保存设置 |
⚠️ 后台表格必须
.admin-table(核心没有.table),且admin.css:180/:198有按列序硬编码规则→ 插件列序/行内表单会被静默误压,须
.pp-table.pp-table td.pp-col-x双类提权(MEMORY §5)。分页/空状态用
.admin-empty-state;长文本列单行 + 省略号 +title。
六、钩子入口(5 个 · 导航入口已收敛为「应用」一处)
入口定案(2026-09-27 用户指定):PC 端在左侧栏「应用」里面,移动端在「应用」下拉里面。
→ 这正是
nav_plugin_links(#9) 一个钩子,它一次页面渲染触发两次、且两处容器不同:| 触发点 | 文件:行号 | 参数 | 容器 | 生效端 |
|---|---|---|---|---|
| PC 左栏「应用」面板 |
app/Views/_components/left_sidebar.php:100|['__nav_uri' => $currentUri]|<div class="mn-panel"><h3>应用</h3><ul class="mn-cat-list">| PC || 顶栏「应用」下拉 |
app/Views/layouts/main.php:247| 无参数 |<div class="mn-apps-dropdown">| 移动端 |⚠️ 移动端不是第三个触发点(实测确认):
main.php:243-250的.mn-mobile-icon-apps与.mn-apps-dropdown是同一份 DOM,
modern.css:380-384的@media (min-width:769px){ .mn-nav-right .mn-mobile-icon-apps{display:none} }决定它只在 ≤768px 显示 → 与「PC 左栏 / 移动端下拉」的分工天然吻合 ✓
| # | 钩子 | 作用 | ⚠️ 注意 |
|---|---|---|---|
| 1 | init_after | 惰性建表兜底 | 范式 plugins/qa/hook/init_after.php(protected/.suiyu_table_cache 门控) |
| 2 | route_register | 前台路由 | 写全路径 |
| 3 | admin_route_register | 后台路由 | 不写 /admin;需 route:admin 权限 |
| 4 | layout_head_end | 注入 assets/style.css + assets/script.js | 按 $params['template'] 条件加载;?v= 用 md5_file 内容哈希 |
| 5 | nav_plugin_links | 唯一导航入口:PC 左栏「应用」+ 移动端「应用」下拉 | ⚠️ 双触发、禁顶层符号(§三·B-4)、图标 16px、拼 BASE_PATH |
6.1 ⚠️ nav_plugin_links 的四条约定(照抄,别自造)
① 图标用 16px,且必须用「字面量形式」
§9.3 的标准模板写的是 $view->icon('comment', 16),但 **TemplateCompiler::icon() 恒返回
<i class="fa" style="font-size:16px">(带内联 style) → 违反零内联红线**。
而 plugin_hygiene.py:34 的零内联口径是「任意 style 属性都算」——
虽然静态扫不到运行期注入的内联(MEMORY §5 已记此坑),但红线就是红线。
→ suiyu 用字面量(这也是 icon_generator / props 两个插件的既有做法):
// ✅ 正解:零内联 + 16px
''图标选型:(核心 icon() 表里的 comment / chat,单个对话气泡)
- 核心
icon()表共 489 条(实测),其中'comment' => ''、'chat' => '' - ⚠️ 不撞车:
forum用f086(双气泡)、reply用f4ad(三点气泡)、message用f0e0(信封) - ⚠️ 已注册
nav_plugin_links的 13 个插件用的图标:code/f06b?、calendar/f073、dice/f522、palette/f53f、
users/f0c0、award/f559、gift/f06b、question/f059、swords/f05b、clipboard-list/f46d、wand-sparkles
→ f075 未被占用 ✓
- ⚠️
.fa的font-size:inherit已于 2026-09-24 删除(assets/css/fontawesome.css:10-16有注释)
→ .mn-fs-16(modern.css:1862,.mn-fs-16{font-size:16px})能正常生效 ✓
② 高亮类固定 mn-cat-active,且必须守卫 __nav_uri
顶栏那次没有 __nav_uri → 不守卫会拼出「全站高亮」:
$__active = !empty($__nav_uri) && \strpos($__nav_uri, '/suiyu') === 0 ? ' mn-cat-active' : '';③ 链接必须拼 BASE_PATH(二级目录部署)
④ 顶部禁止声明 function/class/trait → 否则 include_once,第二处入口静默消失(§三·B-4)
6.2 标准模板(照抄)
<?php
/**
* FlintHub — 碎语时光插件 · 应用入口
* PC 左栏「应用」面板 + 移动端「应用」下拉 共用(⚠️ 双触发,禁顶层符号)
* @file plugins/suiyu/hook/nav_plugin_links.php
*/
$__bp = \defined('BASE_PATH') ? BASE_PATH : '';
$__active = !empty($__nav_uri) && \strpos($__nav_uri, '/suiyu') === 0 ? ' mn-cat-active' : '';
echo ''
. ''
. \app\Helpers\I18n::get('plugin.suiyu.nav')
. '';自检方法(§9.3):模拟「连续调用两次」→ 两次都应输出 <a>,且字节完全一致。
6.3 已按你的指定移除的入口钩子
| 原方案钩子 | 移除原因 |
|---|---|
nav_main_links(顶部主导航) | 你指定入口只在「应用」里 |
nav_user_menu_items(用户下拉) | 同上 |
right_sidebar_after(右栏「碎语广场」面板) | 同上 + 右栏 ≤1024px 隐藏 + 极简取向(⚠️ 可恢复,见 §十一) |
right_sidebar_user_actions_above(右栏用户卡「发碎语」) | 同上 |
✅ 副产品:入口唯一化后,「双触发钩子禁顶层符号」这条红线只剩
nav_plugin_links一处要守,出错面更小。
6.4 钩子文件运行环境(实测)
Plugin::hook() 的执行方式(app/Helpers/Plugin.php:644-651):
(static function (string $__hookFile, array $params, bool $__once): void {
extract($params, EXTR_SKIP | EXTR_REFS); // 钩子参数成为局部变量
if ($__once) { include_once $__hookFile; } else { include $__hookFile; }
})($hookFile, $params, self::$hookDeclaresSymbols[$hookFile]);→ $this 不可用(§11.24);但 $GLOBALS['__view'] 可用(所以 $view->t() / $view->url() 能用)。
→ 闭包内 $this->e() 会抛 Using $this when not in object context,被 try/catch 吞成
「HTML 渲染一半断掉、HTTP 仍 200、无告警」(仅 protected/error.log 一行)→ 只能发真实 HTTP + 看日志。
七、技术红线(14 条 + 校核补充)
指令给的 14 条(逐条遵守):
- 内容渲染顺序:
htmlspecialchars()→Emoji::parse() - 图片走
Upload::file(),用data-fullurl触发灯箱 viewRaw片段不带<link>/<script>- 渲染钩子闭包内
$this不可用 → 用htmlspecialchars()+Csrf::token() - 计数列与明细同事务
Plugin::db()是原生 PDO → 无begin(),用inTransaction()判断- 新增
Settings键后清一次设置缓存 - 插件 CSS 一律带前缀提权(
.suiyu-*) - 只用
--mn-*变量,零硬编码色 - 零内联:CSS/JS 全外链
class_exists()≠ 插件已启用 → 集成点自加Plugin::isActivated()Csrf::verifyOrDie($token)参数必填- 三语键集合零差异
- 上线后必清页面缓存
校核补充 6 条:
- ⚠️⚠️ 缩略图必须
Upload::thumbUrl($rel,'thumb_')—— 禁止手拼'thumb_' . $rel(§三·B-1) - ⚠️⚠️
plugin.json必须声明system:settings—— 否则保存设置静默失效(§三·B-2) - ⚠️ 图标手写
<i class="fa fa-x mn-fs-N">或自实现剥内联助手 —— 核心没有Plugin::_icon()(§三·B-3) - ⚠️ 双触发钩子文件禁顶层符号 —— 否则第二次触发静默丢失(§三·B-4)
- ⚠️ 表情图 src 必须走
EMOJI_MAP——ok→OK.png、xiaoku→daku.png;
照抄 $code.'.png' 在 Windows 能跑、Linux 404(静默裂图)
- ⚠️
{$var}写中文串里的变量 —— PHP 变量名可含 ≥0x80 字节,
"共$n条(第$p页)" 里 $n条(第 会被当成一个变量名 → 静默空值(MEMORY §8)
另外三条环境红线(不属插件本身,但会踩):
- ⚠️ 插件 CSS 早于核心:
layout_head_end注入在main.php:40,核心modern.css(:51)/editor.css(:65) 在后
→ 同 (0,1,0) 权重核心后胜;复用核心组件时兜底规则取值必须与核心逐字一致(MEMORY §5)
- ⚠️ 灯箱入口是
data-fullurl:assets/js/lightbox.js:45只收集[data-fullurl], .post-text-content img;
说说图不在 .post-text-content 里 → 不写 data-fullurl 就点图毫无反应(完全静默)
- ⚠️ 探针数据禁按硬编码 id 删除 → 插入不指定 id、按唯一标记清理(MEMORY §10)
八、文件结构
plugins/suiyu/
├── plugin.json # name=suiyu, version=1.0.0, category=community,
│ # admin_url="/admin/suiyu",
│ # permissions=["route:admin","system:settings"] ← ★ 缺一不可
├── Plugin.php # db()/ddl()/activate()/deactivate()/uninstall()
│ # + 业务静态方法(createPost/toggleLike/addComment/repost/
│ # toggleFollow/checkBlockedWords/createReport/search/…)
│ # ⚠️ 无下划线 → 不需要 class_alias
├── FrontController.php # 前台:13 条路由
├── AdminController.php # 后台:extends \app\Controllers\Admin\BaseController
├── hook/
│ ├── init_after.php # 惰性建表兜底
│ ├── route_register.php # 前台路由(写全路径)
│ ├── admin_route_register.php # 后台路由(不写 /admin)
│ ├── layout_head_end.php # 注入 style.css + script.js
│ └── nav_plugin_links.php # ★ 唯一导航入口
│ # PC 左栏「应用」面板 + 移动端「应用」下拉(双触发)
│ # ⚠️ 禁顶层符号;图标字面量 16px;拼 BASE_PATH
├── views/
│ ├── stream.php # 流页(全站/我的/用户主页/关注 共用)
│ ├── _card.php # 单条卡片(流与片段共用)
│ ├── _list.php # htmx 翻页片段(viewRaw,⚠️ 不带 link/script)
│ ├── show.php # 单条详情 + 评论(含楼中楼)
│ ├── search.php # 搜索页
│ └── admin/{index,trash,words,reports,settings}.php
├── assets/
│ ├── style.css # 只用 --mn-* 变量,零内联,全部 .suiyu-* 前缀
│ └── script.js # 必须外链
├── data/ # 独立库(.htaccess / web.config 由 Plugin::db 自动补)
├── lang/{zh,zh_tw,en}.php # 三语键集合零差异
└── 说明.md九、实施阶段(P1~P10,每阶段末暂停确认)
| 阶段 | 内容 | **验收方式**(可执行,不是「感觉可以」) |
|---|---|---|
| P1 | 骨架 + 6 张表 | ① 后台插件列表能看到 suiyu 且可启用;② sqlite3 读 suiyu.sqlite 打印 .schema,6 张表 + 全部索引齐全;③ 卸载后 data/suiyu.sqlite 被删、无孤儿表 |
| P2 | 发布(文字 + 图片 + 表情) | ① 发一条纯文字 → 库里多 1 行、content 与输入一致;② 发 9 图 → images JSON 长度 9、Upload::path() 实测文件存在、缩略图 suiyu/2026-09/thumb_*.png 实测存在;③ 发 10 图 → 被拒;④ 超 300 字 → 被拒;⑤ 输入 → 页面渲染成 <img class="emoji-emotion"> 且原始 shortcode 残留 = 0;⑥ 连发超频 → RateLimiter 拦截 |
| P3 | 流与卡片(全站/我的/用户主页 + 翻页) | ① 三个流页面 HTTP 200;② 分页第 2 页与第 1 页 id 无重叠;③ htmx 片段返回的 HTML 不含 <link>/<script>;④ 图片 data-fullurl 属性存在(grep 片段源码) |
| P4 | 互动(赞 / 评 / 转) | ① 点赞后 sy_likes +1 且 like_count +1(同事务);② 重复点赞不重复计数;③ 取消后归 0 且不为负;④ 评论含楼中楼 reply_to 指向正确;⑤ 转发后 repost_of 指向原创 id;⑥ 原帖软删后转发卡片显示「原内容已删除」 |
| P5 | 关注(自建表 + 关注 tab) | ① sy_follows 插入/删除正确、UNIQUE 生效;② 关注 tab 只出现被关注者的碎语;③ 未登录访问关注 tab 不查库(SQL 日志验证);④ user_profile 停用后功能不受影响(零依赖验证) |
| P6 | 搜索(自建) | ① ?q= 命中正确;② ?mine=1 只返回自己的;③ 特殊字符 % _ 不被当通配符(需 ESCAPE);④ 空 q 不报错 |
| P7 | 审核 + 举报(自建) | ① 词库 block 命中 → 提交被拒且提示;② replace 命中 → 入库内容已替换;③ 同人重复举报被 UNIQUE 拦(走 INSERT OR IGNORE);④ 后台处理举报 → 碎语进回收站 |
| P8 | 入口(nav_plugin_links 双触发) | ① PC 视口(>768px)左栏「应用」面板内出现 /suiyu 链接;② ≤768px 时顶栏「应用」下拉内出现同一链接;③ 同一钩子连续输出 2 次且字节一致(§9.3 自检法);④ 当前页在 /suiyu* 时左栏那处带 mn-cat-active、顶栏那处不带(__nav_uri 守卫生效);⑤ 二级目录部署下 href 带 BASE_PATH;⑥ 全站零内联(plugin_hygiene.py + HTTP 扫运行期,两者不可互替) |
| P9 | 后台(列表/回收站/词库/举报/设置) | ① 5 个后台页 HTTP 200(带管理员会话);② 非管理员访问 → 302 到 /login;③ 保存设置后回读 DB settings 表值已变(验证 system:settings 权限生效);④ 回收站恢复/彻底删除正确 |
| P10 | 验证 | ① 三语键集合零差异 + 键序一致;② plugin_hygiene.py FAIL=0;③ node --check script.js OK;④ HTTP 冒烟全绿;⑤ 双主题(默认 + 暗夜星辰)走查;⑥ 数据洁净度:探针残留 = 0、无孤儿上传文件 |
十、风险与坑(汇总,按严重度)
| 级别 | 坑 | 后果 | 规避 |
|---|---|---|---|
| ⚠️⚠️ | 手拼 'thumb_' . $rel | 404 裂图 | 一律 Upload::thumbUrl() |
| ⚠️⚠️ | plugin.json 缺 system:settings | 保存设置静默失效 | 权限数组写全 |
| ⚠️⚠️ | nav_plugin_links 文件声明顶层符号 | 移动端「应用」下拉里没有入口(PC 有、移动端静默丢失) | 所有逻辑写在顶层语句里,不包函数 |
| ⚠️⚠️ | nav_plugin_links 漏了 !empty($__nav_uri) 守卫 | 顶栏那处全站高亮 | 守卫后再 strpos |
| ⚠️⚠️ | nav_plugin_links 图标写 14px | 与「应用」两套容器(16px)不齐 | 固定 mn-fs-16 |
| ⚠️⚠️ | 渲染钩子里用 $this->e() | 渲染半截、HTTP 200、无告警 | 用 htmlspecialchars();发真实 HTTP 看 protected/error.log |
| ⚠️⚠️ | 不写 data-fullurl | 点图毫无反应(静默) | 每张图都写 |
| ⚠️⚠️ | 表情 src 手拼 $code.'.png' | Linux 404 裂图 | 走 EMOJI_MAP |
| ⚠️ | 计数与明细不同事务 | 「有赞无数」 | 同事务 + inTransaction() 判断 |
| ⚠️ | $pdb->begin() | Call to undefined method | 用 beginTransaction() |
| ⚠️ | 取消点赞时 -1 | 计数变负 | 先判 rowCount() |
| ⚠️ | 搜索 %/_ 未转义 | 通配符污染 | LIKE ... ESCAPE '\' |
| ⚠️ | 后台表格非 .admin-table | 样式全丢 | 用 .admin-table + 双类提权 |
| ⚠️ | $view->icon() 带内联 style | 违反零内联(门禁静态扫不到、HTTP 才抓) | 手写 <i class="fa mn-fs-16"></i> |
| ⚠️ | 中文串里 $var 后跟全角括号 | 变量名被吞 → 静默空值 | 写 {$var} |
| ⚠️ | 用 profile_user_card_after | 破坏零依赖 | 关注按钮放自有页 |
| ⚠️ | 页面缓存未清 | 前台看不到改动 | 上线后走后台「维护→清理缓存」 |
| ⚠️ | 探针按硬编码 id 删数据 | 误删真实数据 | 插不指定 id + 按唯一标记清理 |
十一、待确认清单
- [x] ~~入口位置~~ → 已定案:PC 左栏「应用」面板 + 移动端「应用」下拉(=
nav_plugin_links单钩子双触发,§六) - [ ] 右栏「碎语广场」面板要不要(已按你的指定移除;它是内容面板不是导航入口,
挂 right_sidebar_after 即可恢复,零成本)—— 若要,我再加回来
- [ ] 右栏用户卡「发碎语」快捷按钮要不要(
right_sidebar_user_actions_above,同上,可恢复) - [ ] §三·C 的 5 个澄清点(尤其 C-1 搜索范围:全站公开 vs 仅自己)
- [ ] 插件名用
suiyu还是moments/shuoshuo - [ ] 关注按钮是否也要放核心个人主页(= 接受软依赖或改核心,与零依赖原则冲突)
- [ ] 热门 tab 是否加权排序
- [ ] 图片删除策略确认(软删不删图 / 彻底删才删图)
- [ ] 是否要「@提及」(v1 未纳入,接
Notification+ 名字映射,建议 v1.1.0) - [ ] 是否要「碎语进全站搜索」(需核心加扩展点,属 B 线,与「不接入核心搜索」冲突)
附:本方案已完成的前置侦察(可复核)
| 结论 | 证据 |
|---|---|
Notification::add() 无 type 白名单 | app/Helpers/Notification.php:69-90(只 mb_substr($type,0,20)) |
| 通知渲染通用(非按 type 硬编码) | app/Views/notifications/index.php:86 |
Admin\BaseController 自带 isAdmin + 二次密码 | app/Controllers/Admin/BaseController.php:16-39 |
thumb_ 前缀加在 basename 上 | app/Helpers/Upload.php:582-583 |
namespace 是前导目录 | app/Helpers/Upload.php:210($ns . $month . '/' . hex . '.' . $ext) |
Settings::update() 需 system:settings | app/Helpers/Settings.php:260 |
requirePermission() 缺省 = 强制模式 | app/Helpers/Plugin.php:175(Settings::get('permission_enforce','1')) |
核心无 Plugin::_icon() | grep -n "_icon" app/Helpers/Plugin.php 为空;仅 plugins/nav_manager/Plugin.php:516 有私有实现 |
| 双触发钩子靠「是否声明顶层符号」决定 include/include_once | app/Helpers/Plugin.php:636-640 |
钩子跑在 static closure + extract($params) | app/Helpers/Plugin.php:644-651 |
Router 支持 {id} 参数 | app/Core/Router.php:47 |
| SQLite 3.39.2 + FTS5 trigram 可用 | 实测 trigram: OK |
站点无同类插件、suiyu 目录空闲 | ls plugins/(43 项) |
全站无 Settings::set()(只有 update()) | grep -rn "Settings::set(" 零命中 |
入口专项侦察(2026-09-27,用户定案后补)
| 结论 | 证据 |
|---|---|
nav_plugin_links PC 触发点 | app/Views/_components/left_sidebar.php:100(带 __nav_uri,容器 <ul class="mn-cat-list">) |
nav_plugin_links 移动端触发点 | app/Views/layouts/main.php:247(无参数,容器 <div class="mn-apps-dropdown">) |
| ⚠️ 顶栏「应用」是移动端专属(不是第三个触发点) | modern.css:380-384 @media (min-width:769px){ .mn-nav-right .mn-mobile-icon-apps{display:none} } |
.mn-apps-dropdown 全站只有一份 DOM | grep -n "mn-apps-dropdown" app/Views/layouts/main.php → 仅 :245 |
| 两套容器字号/内边距不同,需同时适配 | .mn-cat-list a(modern.css:548,13px)/ .mn-apps-dropdown a(:433,14px) |
图标必须 16px(14px 是 nav_user_menu_items 的规则) | §9.3 订正条 + 13 个插件实测全用 16px |
.mn-fs-16 存在且能生效 | modern.css:1862 .mn-fs-16{font-size:16px};.fa 的 font-size:inherit 已于 2026-09-24 删除(assets/css/fontawesome.css:10-16) |
核心 icon() 表共 489 条(含 'comment' => '') | app/Core/TemplateCompiler.php icon() 方法体计数 |
f075 未被其他插件的应用入口占用 | 13 个 nav_plugin_links 插件的图标清单实测 |
零内联口径 = 任意 style 属性 | plugin_hygiene.py:34 |
| ⚠️ 静态扫不到运行期注入的内联 | 同上($view->icon() 源码里无 style=) |
