[开发]碎语时光插件开发方案(全功能 · 自成一体)v2

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

本文由 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):

  1. ⚠️⚠️ 缩略图不能手拼 'thumb_' . $rel → 会 404 裂图
  2. ⚠️⚠️ plugin.json 必须声明 system:settings → 否则保存设置静默失效
  3. ⚠️ nav_plugin_links 的图标不能用 $view->icon() → 带内联 style,违反零内联

→ 用字面量 <i class="fa mn-fs-16">&#xf075;</i>

  1. ⚠️⚠️ nav_plugin_links 文件禁止声明顶层符号 → 否则移动端「应用」下拉里没有入口

(PC 有、移动端静默丢失)

  1. ⚠️ nav_plugin_links 高亮必须守卫 __nav_uri → 顶栏那次没有这个参数,不守卫会全站高亮
  2. ⚠️ sy_reports 建议加 UNIQUE(post_id,user_id) → 防刷举报
  3. ⚠️ 不要挂 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我的 / 用户主页 / 单条详情✅走自有表,无依赖
7htmx 翻页✅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)→ 门禁白送
185 个钩子(入口收敛为「应用」一处)✅全部实测存在(见 §六);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_' . $relthumb_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">&#xf075;</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/listhtmx 片段(viewRaw,⚠️ 不带 <link>/<script>)
POST/suiyu/postPRG($_SESSION['suiyu_flash_*'] + redirect())
POST/suiyu/post/{id}/deletePRG
POST/suiyu/repostPRG
POST/suiyu/likeXHR → JSON
POST/suiyu/commentXHR → JSON
POST/suiyu/followXHR → JSON
POST/suiyu/reportXHR → 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 左栏 / 移动端下拉」的分工天然吻合 ✓

#钩子作用⚠️ 注意
1init_after惰性建表兜底范式 plugins/qa/hook/init_after.php(protected/.suiyu_table_cache 门控)
2route_register前台路由写全路径
3admin_route_register后台路由不写 /admin;需 route:admin 权限
4layout_head_end注入 assets/style.css + assets/script.js按 $params['template'] 条件加载;?v= 用 md5_file 内容哈希
5nav_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
''

图标选型:&#xf075;(核心 icon() 表里的 comment / chat,单个对话气泡)

  • 核心 icon() 表共 489 条(实测),其中 'comment' => '&#xf075;'、'chat' => '&#xf075;'
  • ⚠️ 不撞车: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 条(逐条遵守):

  1. 内容渲染顺序:htmlspecialchars() → Emoji::parse()
  2. 图片走 Upload::file(),用 data-fullurl 触发灯箱
  3. viewRaw 片段不带 <link> / <script>
  4. 渲染钩子闭包内 $this 不可用 → 用 htmlspecialchars() + Csrf::token()
  5. 计数列与明细同事务
  6. Plugin::db() 是原生 PDO → 无 begin(),用 inTransaction() 判断
  7. 新增 Settings 键后清一次设置缓存
  8. 插件 CSS 一律带前缀提权(.suiyu-*)
  9. 只用 --mn-* 变量,零硬编码色
  10. 零内联:CSS/JS 全外链
  11. class_exists() ≠ 插件已启用 → 集成点自加 Plugin::isActivated()
  12. Csrf::verifyOrDie($token) 参数必填
  13. 三语键集合零差异
  14. 上线后必清页面缓存

校核补充 6 条:

  1. ⚠️⚠️ 缩略图必须 Upload::thumbUrl($rel,'thumb_') —— 禁止手拼 'thumb_' . $rel(§三·B-1)
  2. ⚠️⚠️ plugin.json 必须声明 system:settings —— 否则保存设置静默失效(§三·B-2)
  3. ⚠️ 图标手写 <i class="fa fa-x mn-fs-N"> 或自实现剥内联助手 —— 核心没有 Plugin::_icon()(§三·B-3)
  4. ⚠️ 双触发钩子文件禁顶层符号 —— 否则第二次触发静默丢失(§三·B-4)
  5. ⚠️ 表情图 src 必须走 EMOJI_MAP —— ok → OK.png、xiaoku → daku.png;

照抄 $code.'.png' 在 Windows 能跑、Linux 404(静默裂图)

  1. ⚠️ {$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 字 → 被拒;⑤ 输入 huaji → 页面渲染成 <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_' . $rel404 裂图一律 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">&#xf075;</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:settingsapp/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_onceapp/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 全站只有一份 DOMgrep -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' => '&#xf075;')app/Core/TemplateCompiler.php icon() 方法体计数
f075 未被其他插件的应用入口占用13 个 nav_plugin_links 插件的图标清单实测
零内联口径 = 任意 style 属性plugin_hygiene.py:34
⚠️ 静态扫不到运行期注入的内联同上($view->icon() 源码里无 style=)

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 7 次 | 回复 0 次

全部回复 (0)

暂无回复

请 登录