clearlove2.1
1# ClearLove 插件系统设计与实现说明
2
3> 定位:**内核开发者文档**(架构决策、扩展点接入方式与实现细节)。
4> 插件作者请阅读:[PLUGIN.md](PLUGIN.md)(总览)、[PLUGIN-DECLARATIVE.md](PLUGIN-DECLARATIVE.md)(声明式)、
5> [PLUGIN-APP.md](PLUGIN-APP.md)(应用型)。
6>
7> 状态:**P0 + P1 已实现并通过测试**(声明式 + 应用型双形态均已落地)
8> 目标:在现有声明式插件之外,**新增一种"应用型插件"形态**,让新功能可以完全通过插件实现,不必改内核源码、不必重新编译整套系统
9> 硬约束:**现有声明式插件 100% 向后兼容**;保持 `CGO_ENABLED=0` 单二进制部署
10> 实际落地情况与差异见文末「[15. 实现状态](#15-实现状态本仓库当前进度)」
11
12---
13
14## 1. 现状与问题(升级动因)
15
16当前插件系统是"声明式"的:`plugin.json` + 5 类钩子(`html` / `filter` / `http` / `guard` / `badge`),由内核解释执行。它能覆盖"注入一段 HTML / 正则改写 / 回调 Webhook / 昵称守卫"这类简单需求,但**无法承载一个新功能**。
17
18经全仓库排查,具体缺口如下:
19
20| 缺口 | 事实依据 |
21|---|---|
22| 不能注册页面/路由 | `main.go` 的 `buildMux()` 硬编码全部路由,插件无法挂载任何 URL |
23| 不能建表存数据 | 插件配置只能塞进 `settings` 表(键名 `plugin.<name>.<key>`),无法拥有独立数据结构 |
24| 不能注册后台菜单 | `admin_layout.html:18-45` 侧边栏为硬编码 HTML,插件只能往"插件管理页"顶部注入一块 |
25| 不能定时执行 | 只有内核自带的 AI 巡查定时器(`ai.StartPatrol`),插件无调度能力 |
26| 前台 UI 几乎无法介入 | 首页卡片由前端 JS 拼接(`app.js:119-201` `cardEl`),服务端模板只有 3 个有效注入点 |
27| 只能"加"不能"拦" | `html` 钩子只能追加内容,无法拦截/改写请求;`filter` 只能正则替换字符串 |
28| 后台完全没有注入点 | `admin_layout.html` 不引用 `header_html`/`footer_html`;唯一后台注入点是 `admin_plugins_top` |
29| 死钩子与不对称 | 注释里的 `card_extra` 无渲染位置;`content_filter` 在表单发帖 `POST /compose`(`front.go:395`)路径缺失 |
30| 前端无扩展接口 | `app.js` 是 IIFE,插件无法复用 `api()`/`cardEl()`/`fingerprint()` |
31
32**结论**:声明式钩子做不成"万物插件化"。需要引入可执行的插件运行时,并且以**独立的第二种插件形态**存在,而不是继续往 `hooks` 里加类型。
33
34---
35
36## 2. 目标与边界
37
38### 2.1 验收标准
39
40以下 5 个功能**全部只用应用型插件实现**(不改一行内核源码、不重新编译),即视为达标:
41
421. **签到/积分系统**:自定义数据表 + 前台页面 + 前台展示位 + 后台管理菜单 + 每日重置任务
432. **活动抽奖页**:独立路由 + 自有数据 + 后台配置 + 前端交互
443. **第三方登录/外部同步**:拦截请求 + 调用外部 HTTP + 读写站点数据
454. **内容增强**:改写帖子正文、往卡片/详情页追加 UI、往后台设置页追加配置卡片
465. **数据报表**:读取站点统计 + 生成后台图表页
47
48### 2.2 边界(诚实的说明)
49
50- 不能修改内核已有页面的布局与流程(P3 的"模板覆盖"能力可逼近,但仍有边界)
51- 不能 patch 内核函数、不能替换内核 SQL(脚本运行在能力白名单内)
52- 不允许插件无限制访问 OS/文件系统/内网(安全底线,见第 8 节)
53- 不引入 CGO,不破坏"单二进制 + 交叉编译"
54
55---
56
57## 3. 两种插件形态
58
59这是本方案的核心:**两种形态并列存在,互不干扰,各司其职。**
60
61```
62 ┌──────────────────────────────┐
63 │ data/plugins/<name>/ │
64 │ plugin.json │
65 └──────────────┬───────────────┘
66 │ 读取 kind 字段判定
67 ┌─────────────────┴─────────────────┐
68 │ │
69 ┌────────▼─────────┐ ┌──────────▼──────────┐
70 │ 形态 A 声明式 │ │ 形态 B 应用型 │
71 │ (现有,保留) │ │ (新增,本次重点) │
72 ├──────────────────┤ ├─────────────────────┤
73 │ 无代码 │ │ main.js 脚本 │
74 │ hooks 白名单 │ │ 运行时注册一切 │
75 │ 内核解释执行 │ │ 内核调用脚本 │
76 │ 5 类固定能力 │ │ 能力不受白名单限制 │
77 └──────────────────┘ └─────────────────────┘
78 │ │
79 └─────────────────┬─────────────────┘
80 │
81 ┌──────────────▼───────────────┐
82 │ Extension Registry 注册表 │
83 │ Event / Filter / Middleware │
84 │ Slot / Route / Job / Table │
85 └──────────────┬───────────────┘
86 │
87 ┌──────────────▼───────────────┐
88 │ 内核:handlers / templates │
89 │ middleware / web │
90 └──────────────────────────────┘
91```
92
93### 3.1 形态 A:声明式插件(现有,原样保留)
94
95- 清单:`plugin.json`(**无 `kind` 字段**,即现在的格式)
96- 能力:`hooks` 声明的 5 类钩子(html / filter / http / guard / badge)
97- 特点:零代码、可审计、最适合市场分发;改配置即生效
98- 定位:**轻量增强**(注入脚本、敏感词替换、Webhook 通知、身份守卫)
99- 行为:**与今天完全一致**,不做任何改动
100
101### 3.2 形态 B:应用型插件(新增)
102
103- 清单:`plugin.json` + `"kind": "app"` + `runtime.entry`(如 `main.js`)
104- 入口:脚本中的 `setup()` 函数,在其中**调用注册 API 注册一切能力**
105- 能力:路由、后台页面与菜单、UI 注入、事件订阅、过滤器、请求中间件、定时任务、自定义数据表、HTTP 出网、媒体、邮件、缓存……
106- **不使用声明式钩子**:`hooks` 字段可以完全不写;B 型的能力来源是运行时注册,不受 5 类钩子白名单约束
107- 定位:**承载完整功能**(签到、抽奖、积分、报表、第三方对接)
108
109### 3.3 对比与选型
110
111| 维度 | A 型 声明式 | B 型 应用型 |
112|---|---|---|
113| 清单 | `plugin.json` | `plugin.json` + `"kind":"app"` |
114| 是否需要代码 | 否 | 是(`main.js`) |
115| 能力来源 | `hooks` 白名单 | 运行时注册 API |
116| 新增路由/页面 | 不支持 | 支持 |
117| 自定义数据表 | 不支持 | 支持 |
118| 后台菜单 | 不支持 | 支持 |
119| 定时任务 | 不支持 | 支持 |
120| 请求拦截/改写 | 不支持 | 支持(中间件) |
121| UI 注入点 | 3 个(header/footer/插件页顶部) | 15+ 个 slot,可动态渲染 |
122| 分发安全等级 | 最高(无代码) | 需权限声明 + 沙箱 + 审计 |
123| 典型场景 | 加一段 JS、敏感词、Webhook、昵称守卫 | 签到、抽奖、积分商城、数据看板 |
124
125**选型建议**:能用 A 型表达的继续用 A 型(更安全、更简单);一旦需要存数据、开页面、做后台,直接用 B 型,不要在 A 型的 `hooks` 上继续打补丁。
126
127### 3.4 关键设计原则
128
1291. **兼容优先**:A 型的清单格式、目录结构、设置键、公开函数签名、行为全部不变。
1302. **职责分离**:A 型能力来自"清单声明",B 型能力来自"脚本注册",两套机制不混用。
1313. **权限必须声明式**:`permissions` 只能在清单里声明(详见 5.3)。
1324. **能力白名单**:B 型插件默认最小权限,安装时向管理员展示。
1335. **热路径零开销**:没有插件注册扩展点时,调用链开销为一次长度判断。
1346. **故障隔离**:任何插件出错不得影响站点,可熔断、可一键停用。
135
136---
137
138## 4. 清单规范
139
140### 4.1 A 型清单(= 现有 v1,不变)
141
142```json
143{
144 "name": "bgm",
145 "version": "1.0.0",
146 "author": "ClearLove",
147 "description": "背景音乐",
148 "requires": "2.0.0",
149 "config": [ { "key": "music", "label": "音乐文件", "type": "audio" } ],
150 "hooks": [ { "hook": "footer_html", "type": "html", "html": "@player.html" } ]
151}
152```
153
154现有 3 个插件(bgm / 官方身份守卫 / rules-gate)继续按此格式工作。
155
156### 4.2 B 型清单
157
158```json
159{
160 "kind": "app",
161 "name": "每日签到",
162 "slug": "signin",
163 "version": "1.0.0",
164 "author": "yourname",
165 "description": "签到、积分与签到榜",
166 "requires": "2.1.0",
167
168 "runtime": {
169 "engine": "js",
170 "entry": "main.js",
171 "timeout_ms": 1000
172 },
173
174 "permissions": ["db", "settings.read"],
175
176 "config": [
177 { "key": "points", "label": "每次签到积分", "type": "number", "default": "5" }
178 ]
179}
180```
181
182B 型清单**只有元信息、权限、运行时、配置表单**四类字段。路由、表、菜单、任务、钩子全部在脚本里注册——这是与 A 型最本质的区别。
183
184字段说明:
185
186| 字段 | 必填 | 说明 |
187|---|---|---|
188| `kind` | 是 | 固定 `"app"`,用于与 A 型区分 |
189| `name` / `slug` / `version` / `author` / `description` / `requires` | 同 A 型 | `slug` 规则见 4.4 |
190| `runtime.engine` | 是 | 引擎标识,当前 `"js"` |
191| `runtime.entry` | 是 | 入口脚本(相对插件目录,禁止路径穿越) |
192| `runtime.timeout_ms` | 否 | 单次调用超时,默认 1500,硬上限 5000 |
193| `permissions` | 是 | 能力白名单(至少 `[]`),见第 8 节 |
194| `config` | 否 | 配置项声明,后台自动渲染表单(复用 A 型的现有实现) |
195
196### 4.3 形态识别与兼容规则
197
198| 清单情况 | 判定 | 内核行为 |
199|---|---|---|
200| 无 `kind` 字段 | A 型 | 走现有全部逻辑,**行为不变** |
201| `"kind": "declarative"` | A 型 | 同上(显式写法) |
202| `"kind": "app"` | B 型 | 加载脚本运行时 + 执行 `setup()` 注册 |
203| `kind: "app"` 但缺少 `runtime.entry` 或脚本加载失败 | 加载失败 | 插件标记为"错误",不影响其它插件与站点 |
204| B 型同时写了 `hooks` | — | 兼容执行(便于从 A 型平滑迁移),但**文档不推荐**:B 型应使用 `clv.slot()` 等效实现 |
205| 未知 `kind` 值 | 拒绝加载 | 列表页提示"不支持的插件类型" |
206| `permissions` 缺失但脚本调用了受限 API | 运行时拒绝 | 报错并写审计日志 |
207
208### 4.4 slug 规则
209
210现有插件名允许中文(如"官方身份守卫"),但 URL 与表名需要安全标识,因此引入 `slug`:
211
212- 显式声明优先;缺省时由 `name` 归一化(小写、非 `[a-z0-9_-]` 字符转 `-`、去除首尾 `-`)
213- 归一化后为空则回退为 `plugin-<sha1(name)[:8]>`
214- slug 在站点内唯一(冲突时安装失败并提示)
215- 派生规则:前台路由前缀 `/x/<slug>/`、后台 `/admin/plugins/<slug>/`、数据表前缀 `pl_<slug>_`
216
217---
218
219## 5. B 型插件:运行时注册体系
220
221### 5.1 加载与执行模型
222
223```
224启用插件
225 │
226 ├─ 1. 读取 plugin.json → 校验 kind / permissions / slug
227 ├─ 2. 创建脚本 Runtime + 串行 worker(每插件一个)
228 ├─ 3. 注入 Host API(受 permissions 约束)
229 ├─ 4. 执行 main.js 顶层代码(只做定义,不注册)
230 ├─ 5. 调用 setup() ← 所有扩展点在此注册
231 ├─ 6. 生成注册表快照(此后不可变,直到禁用/重载)
232 └─ 7. 站点开始按注册表分发请求与事件
233
234禁用 / 重载
235 └─ 丢弃快照 + 关闭 worker(数据表保留)
236```
237
238关键约束:
239
240- `setup()` 必须**幂等**(重载/重新启用会重复执行)
241- `setup()` 内不得做耗时操作(内核对其施加更短的超时,如 3s)
242- 注册的 handler 用**函数名**引用(便于调试与热重载),不用闭包引用
243- `setup()` 未注册的能力,内核不调用
244- worker 串行执行:同一插件的所有回调(路由、事件、任务)排队执行,**脚本内无需考虑并发**
245
246### 5.2 注册 API 全集
247
248```js
249function setup() {
250 // ── 数据表(运行时建表,幂等)──────────────────────
251 clv.table("log", { // 实际表名 pl_<slug>_log
252 user_id: "int", day: "string", created_at: "time"
253 }, { unique: [["user_id", "day"]], indexes: [["day"]] });
254
255 // ── 路由(path 相对 /x/<slug>)─────────────────────
256 clv.route("GET", "/", "pageSignin", { auth: "user" });
257 clv.route("POST", "/do", "doSignin", { auth: "user" });
258 clv.route("GET", "/rank","apiRank", { auth: "none", json: true });
259
260 // ── 后台页面与菜单(path 相对 /admin/plugins/<slug>)─
261 clv.adminPage("/", "adminPage", { perm: "plugins" });
262 clv.adminMenu({ label: "签到管理", path: "/", perm: "plugins", order: 50 });
263
264 // ── UI 注入(slot 详见 6.4)───────────────────────
265 clv.slot("post.card.after", "cardBadge"); // 也可直接传函数
266 clv.slot("admin.settings.after", function (d) { return "<div class='card'>...</div>"; });
267
268 // ── 事件与过滤器 ──────────────────────────────────
269 clv.on("post.created", "onPostCreated");
270 clv.filter("filter.content", 10, "censor"); // 优先级升序
271
272 // ── 请求中间件 ────────────────────────────────────
273 clv.middleware("http.before", function (req) {
274 if (req.path === "/x/signin") { /* 可改写 req 或短路返回响应 */ }
275 });
276
277 // ── 定时任务 ──────────────────────────────────────
278 clv.job("daily-reset", "24h", "onDaily");
279}
280```
281
282`clv.*` 注册项与 A 型 `hooks` 的对应关系(迁移参考):
283
284| A 型写法 | B 型等价写法 |
285|---|---|
286| `{"hook":"footer_html","type":"html","html":"..."}` | `clv.slot("footer_html", function(d){ return "..."; })` |
287| `{"hook":"content_filter","type":"filter","match":"..","replace":".."}` | `clv.filter("filter.content", 100, function(s){ return s.replace(/../g,"..") })` |
288| `{"hook":"post_created","type":"http","url":"..."}` | `clv.on("post.created", function(e){ clv.http.request({...}) })` |
289| `{"hook":"compose_guard","type":"guard",...}` | `clv.filter("filter.compose.guard", 50, function(g){ ... })` |
290| `{"hook":"post_badge","type":"badge",...}` | `clv.slot("post.card.badge", ...)` 或 `clv.filter("filter.card", ...)` |
291
292### 5.3 为什么"注册是命令式、权限是声明式"
293
294这是一个必须坚持的安全边界:
295
296- **权限只能声明式**:如果允许脚本运行时申请权限(`clv.requestPermission("db")`),那么插件在获得权限前就已经执行了任意代码,权限控制形同虚设。因此 `permissions` 必须在清单中声明,内核在**加载脚本之前**完成校验,并展示给管理员。
297- **能力注册必须命令式**:注册逻辑本身是代码(可条件判断、可循环、可按配置裁剪),写在清单里会重新落回"白名单枚举"的老路,永远追不上需求。
298
299一句话:**清单决定"能碰什么",脚本决定"要做什么"。**
300
301### 5.4 生命周期回调
302
303B 型插件可选实现以下入口,内核在对应时机调用:
304
305| 回调 | 时机 | 典型用途 |
306|---|---|---|
307| `setup()` | 启用/加载时,**必选** | 注册全部扩展点 |
308| `onInstall(ctx)` | 安装完成后、首次 setup 前 | 初始化默认数据 |
309| `onEnable(ctx)` / `onDisable(ctx)` | 启用 / 禁用时 | 资源准备与清理 |
310| `onUninstall(ctx)` | 卸载前(管理员选择删除数据时) | 清理数据 |
311| `onUpgrade(ctx, { from, to })` | 检测到版本变更 | 数据迁移、补列 |
312
313### 5.5 handler 上下文与返回值约定
314
315所有 handler(路由、slot、事件、过滤器、任务)统一接收 `ctx` 参数:
316
317```js
318function pageSignin(ctx) {
319 // ctx = {
320 // req: { method, path, query, form, json, headers, ip, fingerprint, files },
321 // params:{ id: "12" }, // 路由路径参数
322 // userId, adminId, isAdmin, // 已解析的身份(未登录为 0 / false)
323 // csrf, // 当前 CSRF 令牌
324 // plugin:{ name, slug, dir, config(k) }
325 // }
326
327 return { template: "page.html", data: { days: 3 } };
328}
329```
330
331返回值约定(内核统一处理):
332
333| 返回 | 行为 |
334|---|---|
335| `{ json: {...} }` | JSON 响应(可带 `status`、`headers`) |
336| `{ body: "<h1>x</h1>" }` | HTML 响应 |
337| `{ template: "page.html", data: {...} }` | 用插件目录下 `html/template` 模板渲染(可选 `layout: "layout.html"` 套用站点布局) |
338| `{ redirect: "/x/signin/rank" }` | 302 跳转 |
339| `{ file: "download.csv" }` | 插件目录下文件下载 |
340| `undefined` / `null` | 该 slot 无输出 / 该事件处理完成 |
341| 抛异常 | 记录日志;路由返回 500,事件/过滤器按"放行原值"处理 |
342
343---
344
345## 6. 扩展点全集(两种形态共用)
346
347同一个注册表支撑 A、B 两型:A 型由清单转译成注册项,B 型由 `setup()` 注册。
348
349### 6.1 Event(事件:只观察,不可改数据)
350
351| 事件名 | 触发点 | A 型 | B 型 |
352|---|---|---|---|
353| `post.created` / `post.updated` / `post.deleted` | `front.go` ComposeSubmit / PostEditSave / PostDelete | `http` 钩子 | `clv.on` |
354| `post.status_changed` | `admin.go` AdminReportHandle、`ai.go` applyVerdict | 同上 | `clv.on` |
355| `comment.created` / `comment.deleted` | `front.go` addComment、deletePostCascade | 同上 | `clv.on` |
356| `like.toggled` | `front.go` toggleLike | 同上 | `clv.on` |
357| `report.created` / `report.handled` | `api.go` APIReport / `admin.go` AdminReportHandle | 同上 | `clv.on` |
358| `user.registered` / `user.login` / `user.logout` | `auth.go` | 同上 | `clv.on` |
359| `admin.login` | `admin.go` AdminLoginSubmit | 同上 | `clv.on` |
360| `media.uploaded` | `util.SaveImage/SaveVideo` 调用方 | 同上 | `clv.on` |
361| `plugin.installed` / `enabled` / `disabled` / `uninstalled` | 生命周期 | 同上 | `clv.on` |
362| `ai.verdict` | `ai.go` applyVerdict | 同上 | `clv.on` |
363
364事件 payload 沿用现有 Webhook 风格(含 `event` 与 `time`),A 型 `http` 钩子与 B 型 `clv.on` 收到同一份数据。
365
366### 6.2 Filter(过滤器:链式改写,带优先级)
367
368| 过滤器 | 位置 | 签名 |
369|---|---|---|
370| `filter.content` | 发帖/评论入库前(**含补齐 `front.go:395` 缺失调用**) | `string → string` |
371| `filter.nickname` | 发帖/评论前 | `string → string` |
372| `filter.card` | `fetchCards` 每张 Card 组装后 | `Card → Card` |
373| `filter.post.visible` | 详情页可见性判定 | `bool → bool`(任一插件 veto 即不可见) |
374| `filter.api.response` | `okJSON` 前 | `map → map` |
375| `filter.theme.css` | `ThemeCSS` 输出前 | `string → string` |
376| `filter.admin.stats` | `AdminDashboard` 统计 | `map → map` |
377| `filter.settings.save` | `AdminSettingsSave` 写入前 | `map → map` |
378| `filter.auth.login` | `LoginSubmit` 校验通过后 | `bool → bool`(可拒绝登录) |
379| `filter.compose.guard` | `guardCompose` 内 | `guardResult → guardResult` |
380
381约定:按 `priority` 升序串联(A 型固定 100);任一环节抛错 → 记日志并**放行原值**,不因插件故障阻断站点。
382
383### 6.3 Middleware(中间件:可拦截 HTTP)
384
385| 钩子 | 位置 | 能力 |
386|---|---|---|
387| `http.before` | `middleware.Use` 中、CSRF 校验**之前** | 观察/改写请求、设置响应头、**短路返回**(自定义鉴权、维护模式) |
388| `http.after` | 响应写出后 | 观察状态码/耗时 |
389| `http.route` | 路由未命中时 | 自定义路由匹配(兜底给插件路由) |
390
391### 6.4 Slot(UI 注入点)
392
393| Slot | 位置 | A 型 | B 型 |
394|---|---|---|---|
395| `head.end` | `layout.html` `</head>` 前 | 新增 | `clv.slot` |
396| `body.start` | `layout.html` `<body>` 后 | 新增 | `clv.slot` |
397| `body.end` | `layout.html` `</body>` 前 | 新增 | `clv.slot` |
398| `header_html` / `footer_html` | 现有位置 | 支持 | `clv.slot` |
399| `post.card.after` | 首页卡片(前端渲染,见 6.5) | 新增 | `clv.slot` |
400| `post.detail.after` | 详情页正文后 | 新增 | `clv.slot` |
401| `post.detail.actions.after` | 详情页操作区后 | 新增 | `clv.slot` |
402| `comment.item.after` | 每条评论后 | 新增 | `clv.slot` |
403| `compose.form.after` | 发帖表单后 | 新增 | `clv.slot` |
404| `profile.after` | 个人主页 | 新增 | `clv.slot` |
405| `admin.head.end` / `admin.body.end` | 后台布局 | 新增 | `clv.slot` |
406| `admin.sidebar.after` | 后台侧边栏底部 | 新增 | `clv.slot` |
407| `admin.dashboard.after` | 仪表盘 | 新增 | `clv.slot` |
408| `admin.settings.after` | 设置页 | 新增 | `clv.slot` |
409| `admin_plugins_top` | 现有位置 | 支持 | `clv.slot` |
410
411与 A 型的关键区别:B 型的 slot handler **接收数据、可动态生成 HTML**(例如按当前帖子内容、当前用户权限决定输出),而 A 型只是静态片段。
412
413### 6.5 前端扩展(`window.CLV`)
414
415首页卡片由 JS 渲染,服务端 slot 覆盖不到,需前端 hook:
416
417```js
418// 内核暴露(app.js 末尾)
419window.CLV = {
420 version, csrf,
421 api, // fetch 封装(自动带 CSRF / 指纹 / X-Requested-With)
422 el, toast, fingerprint,
423 hooks: {
424 cardHtml: [], // (html, post) => html
425 cardEl: [], // (el, post) => el | void
426 afterRender: [] // (root) => void
427 }
428};
429```
430
431`cardEl()` 返回前依次调用 `hooks.cardHtml`,渲染后触发 `afterRender`。B 型插件在 `body.end` slot 注入脚本注册:
432
433```js
434CLV.hooks.cardHtml.push(function (html, p) {
435 if (p.likes > 10) html += '<span class="hot">热帖</span>';
436 return html;
437});
438```
439
440### 6.6 Route(路由)
441
442| 类型 | 实际挂载 | 鉴权 |
443|---|---|---|
444| 前台页面 | `/x/<slug>/<path>` | `auth`:`none` / `user` / `admin` |
445| 前台 API | 同上,`json: true` | 同上 |
446| 后台页面 | `/admin/plugins/<slug>/<path>` | 强制 `requireAdmin` + `perm` 声明 |
447| 静态资源 | `/x/<slug>/assets/*` | 自动映射插件目录下 `assets/`(无鉴权,只读) |
448
449`path` 是相对路径,内核统一加前缀,从根本上避免插件间路由冲突。
450
451### 6.7 Job(定时任务)
452
453`clv.job(name, every, handler)`,`every` 支持 `30s` / `10m` / `1h` / `24h`。
454
455内核调度器(新增 `internal/plugin/scheduler.go`,复用 `ai.StartPatrol` 模式):
456- 启动时注册,`time.Ticker` 驱动,单任务互斥
457- 触发时同样走沙箱(超时、权限、审计)
458- 执行结果写入 `plugin_logs`,后台可查
459
460### 6.8 Data(自定义数据表)
461
462`clv.table(name, columns, opts)`(也可在 A 型清单中声明,供"无脚本但需建表"的场景):
463
464类型映射由方言层翻译:
465
466| 声明类型 | SQLite | MySQL |
467|---|---|---|
468| `int` | `INTEGER` | `INT` |
469| `bigint` | `INTEGER` | `BIGINT` |
470| `string` | `TEXT` | `VARCHAR(255)` |
471| `text` | `TEXT` | `MEDIUMTEXT` |
472| `bool` | `INTEGER` | `TINYINT` |
473| `time` | `TEXT`(RFC3339) | `VARCHAR(40)`(与内核时间字段一致) |
474| `json` | `TEXT` | `MEDIUMTEXT` |
475
476- 表名统一 `pl_<slug>_` 前缀,插件无需关心实际名称(用 `clv.db.table("log")` 取)
477- 卸载时**默认保留数据**,后台询问"是否同时删除插件数据表"
478- 写操作默认只允许 `pl_` 前缀表;内核表只读(`db.admin` 权限可放开,需管理员显式授权)
479
480---
481
482## 7. Host API 清单
483
484B 型插件可用的全部能力(按 `permissions` 逐项授权):
485
486```js
487// ── 元信息与配置 ────────────────────────────────
488clv.plugin.name / slug / dir / version
489clv.plugin.config(key) // 读配置(等价于现有 plugin.<name>.<key>)
490clv.plugin.configs()
491clv.version // 内核版本
492
493// ── 数据 ───────────────────────────────────────
494clv.db.query(sql, ...args) // -> [{...}](行数受配额约束)
495clv.db.get(sql, ...args) // -> {...} | null
496clv.db.exec(sql, ...args) // -> { rowsAffected, lastId }
497clv.db.tx(fn) // 事务;fn 抛错自动回滚
498clv.db.dialect() // "sqlite" | "mysql"
499clv.db.table("log") // -> "pl_signin_log"
500
501// ── 站点高层数据(推荐,替代手写 SQL) ─────────────
502clv.post.list({ before, limit, topic })
503clv.post.get(id) / create({...}) / update(id, {...}) / remove(id)
504clv.comment.list(postId) / create({...}) / remove(id)
505clv.user.get(id) / byName(name) / current(ctx)
506clv.setting.get(k) / set(k, v) / all()
507clv.stats.overview() / today()
508
509// ── 视图 ───────────────────────────────────────
510clv.view.template("page.html", data) // 渲染插件目录下模板
511clv.view.escape(s) / nl2br(s)
512
513// ── 其它能力 ───────────────────────────────────
514clv.http.request({ method, url, headers, body, timeout_ms, max_bytes })
515clv.media.saveImage(file) / saveVideo(file)
516clv.cache.get(k) / set(k, v, ttlSec) / del(k)
517clv.mail.send(to, subject, body)
518clv.crypto.bcrypt(pwd) / verify(hash, pwd) / hmacSha256(key, msg) / randomHex(n) / uuid()
519clv.log.info(...) / warn(...) / error(...) // 写 plugin_logs
520clv.json.encode(v) / decode(s)
521```
522
523> 同步风格:goja 不支持 `async/await` 语义,所有 Host API 均为同步调用(HTTP 请求由内核阻塞执行并受超时保护)。
524>
525> 引擎选型:默认 **goja**(纯 Go、无 CGO,插件作者最熟悉 JS,现有 `rules-gate` 已是 JS);抽象出 `Runtime` 接口,后续可加 gopher-lua / wazero。goja 的 `Runtime` 非 goroutine-safe,因此每个插件配一条串行 worker 队列,天然串行 + 可超时中断(`vm.Interrupt`)。
526
527---
528
529## 8. 沙箱、权限与治理
530
531| 维度 | 措施 |
532|---|---|
533| 执行时间 | 每次调用 `context` 超时,默认 1500ms,清单可调,**硬上限 30s**(支持 AI 调用等耗时操作);`setup()` 另设 5s 上限 |
534| 资源配额 | 中断计数(防死循环);单次 HTTP 响应 ≤ 2MB;`db.query` 行数 ≤ 1000 |
535| 权限白名单 | 每个 Host API 入口校验清单 `permissions`,未声明直接抛错(错误提示"如何声明") |
536| 数据隔离 | 默认仅插件表可写;内核表只读;`db.admin` 需管理员在后台显式授权 |
537| SSRF 防护 | `clv.http` 需 `net` 权限;默认拒绝私有/环回/链路本地 IP;声明 `net.local` 后放行(用于本机 Ollama 等内网 AI 服务) |
538| 审计 | 新增 `plugin_logs` 表(`slug`/`action`/`detail`/`duration_ms`/`created_at`),后台可查 |
539| 故障隔离 | 所有回调 `recover`;单插件异常不影响其它插件与主流程;连续 20 次异常自动禁用(熔断) |
540| 总开关 | 网站设置新增 `plugins_runtime_enabled`(默认开),可一键停用全部 B 型插件用于故障处置 |
541| 分发安全 | 市场下载保留 SHA256 校验;P3 增加"官方签名"标记(展示用途,非强制) |
542
543权限清单(`permissions` 取值):
544
545| 权限 | 覆盖能力 |
546|---|---|
547| `db` | `clv.db.*`(仅插件表可写) |
548| `db.admin` | 可写内核表(危险,需管理员二次确认) |
549| `settings.read` / `settings.write` | `clv.setting.get` / `set` |
550| `site.read` | `clv.post.*` / `clv.comment.*` / `clv.user.*`(只读) |
551| `site.write` | 同上 + 写操作 |
552| `net` | `clv.http.request`(默认拒绝内网地址) |
553| `net.local` | 额外允许访问内网/本机地址(如 `http://127.0.0.1:11434` 的 Ollama) |
554| `media.write` | `clv.media.saveImage/saveVideo` |
555| `mail` | `clv.mail.send` |
556| `cache` | `clv.cache.*` |
557| `log` | `clv.log.*` |
558
559---
560
561## 9. 内核改造清单(文件级)
562
563| 文件 | 改动 |
564|---|---|
565| `main.go` | 挂载插件路由捕获器(`/x/`、`/admin/plugins/`);启动 `plugin.Boot()`(加载 A/B 两型)与 `plugin.StartScheduler()` |
566| `internal/database/database.go` | 新增 `plugin_logs` 表;暴露 `Dialect()` 供插件层使用 |
567| `internal/plugin/plugin.go` | 保留全部现有函数签名(变为兼容 shim,内部转调新注册表) |
568| `internal/plugin/manifest.go`(新) | A/B 型识别、slug 归一化、权限校验、兼容性检查 |
569| `internal/plugin/registry.go`(新) | 扩展点注册表(Event/Filter/Middleware/Slot/Route/Job/Table) |
570| `internal/plugin/hub.go`(新) | 事件总线 + 过滤器链(优先级、panic 隔离、空链零开销) |
571| `internal/plugin/runtime.go` + `runtime_goja.go`(新) | 引擎抽象 + goja 实现 + 每插件串行 worker |
572| `internal/plugin/api_*.go`(新) | 注册 API + 能力 API 分组实现 |
573| `internal/plugin/sandbox.go`(新) | 超时、配额、权限、审计、熔断 |
574| `internal/plugin/lifecycle.go`(新) | 安装/启用/禁用/卸载/升级 + 建表 |
575| `internal/plugin/scheduler.go`(新) | 定时任务调度 |
576| `internal/plugin/adminui.go`(新) | 后台菜单注册表 + 插件页面分发 |
577| `internal/plugin/view.go`(新) | `ViewProvider` 反向注入接口,解决包循环依赖(见下) |
578| `internal/handlers/handlers.go` | `Render` 注入 slot 数据;初始化时向 plugin 注册 `ViewProvider` |
579| `internal/handlers/front.go` | 插入第 6.1/6.2 节的事件与过滤器调用点(约 12 处),并补齐 `ComposeSubmit` 的 `filter.content` |
580| `internal/handlers/api.go` | 同上(约 8 处) |
581| `internal/handlers/admin.go` | 侧边栏菜单数据化;插件管理页增加插件类型徽章、「运行日志」「插件页面」入口 |
582| `internal/middleware/middleware.go` | CSRF 之前插入 `http.before` 链;响应后触发 `http.after` |
583| `web/templates/layout.html` | 新增 `head.end` / `body.start` / `body.end` 三个 slot |
584| `web/templates/admin_layout.html` | 侧边栏改为 `{{range .pluginMenus}}`;新增 `admin.head.end` / `admin.sidebar.after` slot |
585| `web/templates/front.html` / `admin.html` | 新增各内容级 slot(详见 6.4) |
586| `web/static/app.js` | 末尾暴露 `window.CLV`;`cardEl` 接入 `hooks.cardHtml` / `afterRender` |
587| `docs/PLUGIN.md` | 重构为**插件开发总览**;A 型细节拆至 `PLUGIN-DECLARATIVE.md`,B 型细节拆至 `PLUGIN-APP.md` |
588
589**关键架构约束:包循环依赖**
590
591`handlers` → `plugin`(现有依赖方向)。插件页面渲染需要模板能力(在 `handlers`),但 `plugin` 不能 import `handlers`。解决方式:**反向注入接口**。
592
593```go
594// internal/plugin/view.go
595type ViewProvider interface {
596 RenderPage(w http.ResponseWriter, r *http.Request, layout, page string, data map[string]any)
597 RenderTemplate(fsys fs.FS, name string, data any) (string, error)
598 CSRF(r *http.Request) string
599 CurrentUserID(r *http.Request) int64
600 CurrentAdminID(r *http.Request) int64
601}
602
603var viewProvider ViewProvider
604func SetViewProvider(p ViewProvider) { viewProvider = p }
605```
606
607`handlers` 在 `SetupTemplates()` 之后调用 `plugin.SetViewProvider(...)` 完成装配。
608
609---
610
611## 10. 兼容性策略与回归清单
612
613### 10.1 兼容保证
614
615| 项 | 保证 |
616|---|---|
617| 现有 3 个插件包(bgm / 官方身份守卫 / rules-gate) | 不改动即可安装、启用、生效 |
618| A 型 `plugin.json` 格式 | 完全支持,行为不变(无 `kind` 字段即 A 型) |
619| `data/plugins/<name>/` 目录结构 | 不变(slug 只影响路由/表名,不影响目录) |
620| `enabled_plugins` 设置键 | 不变 |
621| `plugin.<name>.<key>` 配置键 | 不变 |
622| 现有 HTTP 路由与模板输出 | 不变(slot 为空时输出空字符串) |
623| 现有 Go 函数签名(`CallHTML`/`CallFilter`/`Notify`/`CheckComposeGuard`/`NicknameBadges`/`GuardNicknames`/`List`/`Install`/`Remove`/`SetEnabled`/`GetConfig`/`SaveConfig`/`ConfigOf`) | 全部保留 |
624| 编译 | `CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build` 必须通过 |
625| 二进制体积 | 记录基线;goja 预计 +3~6MB,可留 build tag(`-tags no_plugin_runtime`)裁剪 |
626
627### 10.2 回归测试清单
628
6291. 三只现有 A 型插件:安装 → 启用 → 功能验证(背景音乐、守卫拦截、守则弹窗)
6302. 插件配置保存、素材上传/替换/清理
6313. 插件商店安装(SHA256 校验路径)
6324. 前台发帖 / 评论 / 点赞 / 举报 / 编辑 / 删除全流程
6335. 后台全部页面可打开、菜单完整(含 B 型插件注册的菜单)
6346. SQLite 与 MySQL 双库各跑一遍
6357. 无插件时(`data/plugins` 为空)站点行为与性能不变
6368. B 型插件死循环 / 抛异常:站点不阻塞、自动熔断
6379. A 型与 B 型**同时启用**:互不干扰,注册表合并正确
638
639---
640
641## 11. 分阶段实施计划
642
643> 每个阶段结束都必须满足:可编译、可部署、现有功能无回归。
644
645### P0 —— 运行时地基(不影响任何现有行为)
646
6471. `manifest.go`:A/B 型识别 + slug 归一化 + 权限字段解析
6482. `runtime.go` + `runtime_goja.go`:引擎抽象 + goja + 每插件串行 worker
6493. `sandbox.go`:超时、权限校验、panic 隔离、`plugin_logs`
6504. 注册 API 第一批:`clv.table` / `clv.on` / `clv.filter` / `clv.log` / `clv.plugin.config` / `clv.setting.*` / `clv.db.*` / `clv.json`
6515. `lifecycle.go`:`setup()` 调用 + 建表 + 生命周期回调
6526. 示例插件 `plugins/demo-app/`(B 型:建表 + 事件 + 过滤器)
6537. 引入依赖:`github.com/dop251/goja`(纯 Go,无 CGO)
654
655**验收**:A 型插件行为零变化;B 型示例插件能建表、订阅 `post.created`、过滤 `filter.content`。
656
657### P1 —— 页面与路由扩展
658
6591. `registry.go` + `view.go`(ViewProvider 反向注入)
6602. `clv.route` / `clv.adminPage` / `clv.adminMenu`:前台 `/x/<slug>/`、后台 `/admin/plugins/<slug>/`
6613. 后台侧边栏数据化 + 插件类型徽章
6624. Slot 全量落地(前台 + 后台,约 15 个)+ `clv.slot`
6635. `window.CLV` 前端 API + 卡片 hook
6646. 示例插件 `plugins/signin/`(签到:表 + 前台页 + 后台菜单 + 卡片展示)
665
666**验收**:第 2.1 节验收功能 1、2 达成。
667
668### P2 —— 深度集成
669
6701. 事件/过滤器全清单接入(第 6.1 / 6.2 节全部)
6712. `clv.middleware`:`http.before` / `http.after` / `http.route`
6723. `clv.job`:定时任务调度 + 后台可视化
6734. `clv.http` / `clv.media` / `clv.cache` / `clv.mail` / `clv.crypto`
6745. `clv.view.template`:插件模板文件渲染
6756. 示例插件:内容摘要(filter)、活动抽奖页(route + table)
676
677**验收**:第 2.1 节验收功能 3、4、5 达成。
678
679### P3 —— 治理与生态
680
6811. 权限管理 UI(安装时向管理员展示并授权)+ 后台"脚本插件总开关"
6822. `plugin_logs` 审计页 + 熔断策略可配置
6833. 插件市场支持 B 型包(权限/引擎/体积字段展示 + 审核流程)
6844. **模板覆盖**:插件目录 `templates/` 覆盖内核同名片段(最接近"任意改前端"的能力)
6855. 内置功能插件化试点(如"公告增强"用 B 型插件重写)
6866. 开发工具:`clearlove plugin new/pack/check` 命令行 + B 型开发文档
687
688---
689
690## 12. 示例:签到应用插件(B 型,完全不用声明式钩子)
691
692目录结构:
693
694```
695plugins/signin/
696├── plugin.json
697├── main.js
698└── page.html
699```
700
701`plugin.json`:
702
703```json
704{
705 "kind": "app",
706 "name": "每日签到",
707 "slug": "signin",
708 "version": "1.0.0",
709 "author": "ClearLove",
710 "description": "签到、积分与签到榜",
711 "requires": "2.1.0",
712 "runtime": { "engine": "js", "entry": "main.js", "timeout_ms": 1000 },
713 "permissions": ["db", "settings.read"],
714 "config": [
715 { "key": "points", "label": "每次签到积分", "type": "number", "default": "5" }
716 ]
717}
718```
719
720`main.js`:
721
722```js
723var T = clv.db.table("log"); // pl_signin_log
724
725function setup() {
726 clv.table("log", {
727 user_id: "int", day: "string", points: "int", created_at: "time"
728 }, { unique: [["user_id", "day"]] });
729
730 clv.route("GET", "/", "pageSignin", { auth: "user" });
731 clv.route("POST", "/do", "doSignin", { auth: "user" });
732 clv.route("GET", "/rank", "apiRank", { auth: "none", json: true });
733
734 clv.adminPage("/", "adminPage", { perm: "plugins" });
735 clv.adminMenu({ label: "签到管理", path: "/", perm: "plugins", order: 50 });
736
737 clv.slot("post.card.after", "cardBadge");
738 clv.job("monthly-reset", "24h", "onDaily");
739}
740
741function pageSignin(ctx) {
742 var today = new Date().toISOString().slice(0, 10);
743 var done = clv.db.get("SELECT id FROM " + T + " WHERE user_id=? AND day=?", ctx.userId, today);
744 var total = clv.db.get("SELECT IFNULL(SUM(points),0) AS n FROM " + T + " WHERE user_id=?", ctx.userId);
745 return { template: "page.html", data: { done: !!done, total: total.n, points: clv.plugin.config("points") } };
746}
747
748function doSignin(ctx) {
749 var today = new Date().toISOString().slice(0, 10);
750 if (clv.db.get("SELECT id FROM " + T + " WHERE user_id=? AND day=?", ctx.userId, today)) {
751 return { status: 400, json: { ok: false, msg: "今天已经签到过了" } };
752 }
753 clv.db.exec("INSERT INTO " + T + "(user_id,day,points,created_at) VALUES(?,?,?,?)",
754 ctx.userId, today, Number(clv.plugin.config("points")), new Date().toISOString());
755 return { json: { ok: true, msg: "签到成功" } };
756}
757
758function apiRank(ctx) {
759 return { json: clv.db.query(
760 "SELECT user_id, SUM(points) AS n FROM " + T + " GROUP BY user_id ORDER BY n DESC LIMIT 20") };
761}
762
763function cardBadge(post) {
764 return post.user_id ? "<span class='signin-dot' title='活跃用户'></span>" : "";
765}
766
767function adminPage(ctx) {
768 var rows = clv.db.query("SELECT day, COUNT(1) AS n FROM " + T + " GROUP BY day ORDER BY day DESC LIMIT 30");
769 return { template: "admin.html", data: { rows: rows } };
770}
771
772function onDaily(ctx) {
773 clv.log.info("签到插件每日任务执行");
774}
775```
776
777对比 A 型:该插件**没有 `hooks` 字段**,全部能力(表、路由、后台菜单、slot、任务)均通过 `setup()` 中的注册 API 获得——这正是新增的第二种插件形式。
778
779---
780
781## 13. 风险与取舍
782
783| 风险 | 影响 | 对策 |
784|---|---|---|
785| 二进制体积增大(goja) | 单文件约 +3~6MB | 可接受;预留 build tag 裁剪 |
786| 脚本 = 代码执行 | 安全面扩大 | 权限声明(清单)+ 配额 + 审计 + 熔断 + 总开关 + 市场审核 |
787| 双形态并存 | 概念与维护成本上升 | 职责边界清晰(A 型=声明、B 型=脚本),管理页用类型徽章区分;两型共用同一注册表,内核代码不翻倍 |
788| 热路径性能 | 每张卡片/每请求都过插件链 | 注册表快照 + 空链零开销 + 每请求只取一次快照 |
789| MySQL/SQLite 方言 | 插件 SQL 可能只兼容一种 | `clv.db.dialect()` + 高层 `clv.post.*` API + 建表由内核翻译 |
790| goja 不支持 async/await | 插件作者写法受限 | 全部同步 API + 文档明确说明 |
791| 插件质量参差 | 拖垮站点 | 熔断 + 后台一键停用 + 运行日志可见 |
792| "万物"预期过高 | 内核布局/流程无法替换 | 文档明确边界;P3 模板覆盖补齐前端自定义能力 |
793
794---
795
796## 14. 附录:工作量估算
797
798| 阶段 | 主要产出 | 预估 |
799|---|---|---|
800| P0 | B 型运行时 + 沙箱 + 注册 API 第一批 + 示例 | 5~8 人日 |
801| P1 | 路由 + 后台菜单 + slot + 前端 API + 签到样例 | 5~8 人日 |
802| P2 | 全量事件/过滤器接入 + 中间件 + jobs + 能力 API | 6~10 人日 |
803| P3 | 治理 + 市场 + 模板覆盖 + 工具链 | 5~8 人日 |
804
805> 建议:**P0 + P1 为一个交付批次**(约 2 周),完成后即可支撑绝大多数"新功能类插件";P2 补齐深度集成,P3 面向生态与治理。
806
807---
808
809## 15. 实现状态(本仓库当前进度)
810
811> 本节记录实际落地情况;与上方设计稿如有差异,以本节为准。
812
813### 15.1 已实现
814
815运行时内核 `internal/plugin/`:
816
817| 文件 | 职责 |
818|---|---|
819| `manifest.go` | A/B 型识别、slug 归一化、权限声明解析、超时策略 |
820| `registry.go` | 扩展点注册表 + 不可变快照(热路径无锁读取) |
821| `runtime.go` | 每插件串行 Worker(goja 非线程安全,全部调用排队执行) |
822| `runtime_goja.go` | goja 装配、函数调用、值转换、日志 / JSON / 配置 API |
823| `api_db.go` | `clv.db.*` + `clv.table`(写表前缀白名单、行数配额、双库方言翻译) |
824| `api_site.go` | `clv.setting` / `clv.post` / `clv.comment` / `clv.user` / `clv.stats` |
825| `api_view.go` | `clv.route` / `adminPage` / `adminMenu` / `slot` / `on` / `emit` / `filter` / `job` / `middleware` / `view` |
826| `api_net.go` | `clv.http` / `cache` / `crypto` / `mail` / `media`(SSRF 防护、配额) |
827| `hub.go` | 事件总线、过滤器链、Slot 渲染、中间件执行 |
828| `router.go` | `/x/<slug>/...` 与 `/admin/plugins/<slug>/...` 分发、鉴权、返回值约定 |
829| `lifecycle.go` | `Boot` / `LoadApp` / `UnloadApp` / `ReloadApp`、setup 调用、生命周期回调 |
830| `scheduler.go` | 定时任务调度(最小周期 10s,运行留痕) |
831| `sandbox.go` | 权限校验、`plugin_logs` 审计、失败熔断(连续 20 次自动禁用) |
832| `view.go` | `ViewProvider` 反向注入接口(解决包循环依赖) |
833
834内核接入:
835
836- `main.go`:`plugin.Boot()` + `/x/` 与 `/admin/plugins/` 路由挂载
837- `middleware`:`http.before` 中间件链(可短路响应,绕过 CSRF)
838- `handlers`:Slot 注入、后台插件菜单数据化、HTML 渲染器接入
839- 事件触发点已接入:`post.created` / `comment.created` / `report.created` / `like.toggled` / `post.updated` / `post.deleted` / `user.login` / `user.registered`
840- `handlers/plugin_bridge.go`:模板与会话能力的反向注入
841- `database`:新增 `plugin_logs` 表
842- 前端:`window.CLV`(`api` / `el` / `toast` / `fingerprint` + `cardHtml` / `cardEl` / `afterRender` 钩子)
843- 模板 Slot:`head.end` / `body.end` / `admin.head.end` / `admin.body.end` / `admin.sidebar.after` / `admin.settings.after` / `admin.dashboard.after`,并保留原有 `header_html` / `footer_html` / `admin_plugins_top`
844- 后台「插件管理」显示「声明式 / 应用型」类型与运行状态、插件路由前缀
845
846示例与测试:
847
848- `plugins/signin/`:应用型插件示例(数据表 + 前台页 + 后台页 + 菜单 + Slot + 定时任务,**完全不使用声明式钩子**)
849- `internal/plugin/app_test.go`:生命周期 / 权限拒绝 / 超时中断 / 声明式回归
850- `internal/plugin/router_test.go`:鉴权与返回值约定
851- `main_test.go`:走完整中间件与路由链的端到端集成测试
852
853### 15.2 已实现 API
854
855```js
856// 元信息与配置
857clv.version · clv.plugin.{name,slug,dir,version,config,configs}
858// 日志与序列化
859clv.log.{info,warn,error} · clv.json.{encode,decode}
860// 数据
861clv.table(name, columns, opts) · clv.db.{query,get,exec,tx,dialect,table}
862// 站点数据
863clv.setting.{get,set,all} · clv.post.{list,get,create,update,remove}
864clv.comment.{list,create,remove} · clv.user.{get,byName,current} · clv.stats.{overview,today}
865// 注册
866clv.route · clv.adminPage · clv.adminMenu · clv.slot · clv.on · clv.emit
867clv.filter(name, priority, handler) · clv.job(name, every, handler) · clv.middleware(phase, handler)
868// 视图
869clv.view.{template,escape,nl2br}
870// 其它
871clv.http.request · clv.cache.{get,set,del} · clv.crypto.{bcrypt,verify,hmacSha256,randomHex,uuid,base64Encode,base64Decode}
872clv.mail.send · clv.media.{saveImage,saveVideo}
873```
874
875权限取值:`db` / `db.admin` / `settings.read` / `settings.write` / `site.read` / `site.write` / `net` / `net.local` / `media.write` / `mail` / `cache` / `log`。
876
877**内置应用型插件示例**
878
879| 插件 | 说明 | 用到的能力 |
880|---|---|---|
881| `plugins/signin/` | 每日签到:数据表 + 前台页 + 后台页 + 菜单 + 定时任务 | `db` / `cache` / 路由 / slot / job |
882| `plugins/ai-polish/` | AI 语句美化:发帖页按钮,调用站点已接入的 AI 模型润色内容 | 声明式 hook 静态注入 + 路由 + `settings.read` / `net` / `net.local` / `cache` + 限流 |
883
884> `ai-polish` 演示了一个实用组合:**UI 用声明式 hook 静态注入**(页面渲染零开销),
885> **功能走应用型路由**(只在用户点击时才进入脚本运行时)——避免把耗时逻辑放在 slot 渲染里阻塞页面。
886
887### 15.3 已实现(第二轮补齐)
888
889| 能力 | 说明 |
890|---|---|
891| `http.after` 中间件 | 包装 ResponseWriter 记录状态码,请求结束后回调插件(含耗时、IP、指纹) |
892| 过滤器点(全量) | `filter.content`、`filter.card`、`filter.post.visible`、`filter.api.response`、`filter.theme.css`、`filter.auth.login`、`filter.compose.guard`、`filter.admin.stats`、`filter.settings.save` |
893| 模板覆盖 | 启用插件的 `templates/*.html` 覆盖内核同名片段(Go 模板后解析优先);插件启停后自动重建模板集 |
894| 后台运行日志页 | `/admin/plugin-logs`,支持按插件筛选、一键清空;侧边栏「插件日志」入口 |
895| 定时任务可视化 | 插件管理页展示各插件的任务名与周期 |
896| 权限审阅 | 插件卡片展示已声明权限,启用前 `data-confirm` 提示;插件商店展示形态与权限 |
897| 运行时重载 | 插件管理页「重载」按钮(改脚本后无需禁用再启用) |
898| `clv.media.saveVideo` | 与 `saveImage` 对称,支持 base64 写入(扩展名白名单 + 70MB 上限) |
899| 前端 Slot 补齐 | `post.detail.actions.after`、`comments.after`、`compose.after`、`profile.after`,加上此前的 `head.end` / `body.end` / 后台各 Slot |
900
901### 15.4 云端(插件社区)双形态支持
902
903云端服务(`cloud/`,独立 Go 项目,部署于 clearlove.kazx.top)已完成升级,与客户端打通"上传 → 审核 → 市场 → 安装"全链路:
904
905**数据层**
906- `plugins` / `plugin_versions` 新增 `kind`、`permissions`、`runtime`、`requires`、`has_templates`、`hooks_count` 列(启动时自动 `ALTER TABLE` 迁移,幂等)
907- 历史数据自动回退为 `declarative`;筛选使用 `NULLIF` 兼容空串,新旧数据一视同仁
908
909**上传与校验**(开发者后台上传 zip 时)
910- 自动识别形态:无 `kind` 字段 → 声明式;`kind=app` → 应用型
911- 应用型强制校验:`runtime.engine`(当前仅 js)、`runtime.entry`(禁路径穿越)、`permissions` 白名单
912- 声明式强制校验:至少一个 `hooks` 项
913- 自动统计:`hooks_count`、是否含 `templates/` 模板覆盖
914- 新版本上传会同步刷新插件主记录的形态快照
915
916**市场与审核**
917- 市场列表/详情展示「声明式 / 应用型」徽章、权限清单、运行时引擎、版本要求、模板覆盖标记
918- 市场支持 `?kind=app|declarative` 按形态筛选(官网页面与开放 API 均支持)
919- 开发者后台:插件管理页展示形态与权限;发布页提示两种形态要求
920- 管理后台审核列表:展示形态与权限(应用型重点核对),支持按形态筛选
921- 开发文档页新增「两种插件形态」章节,含 `plugin.json` 与 `main.js` 示例
922
923**开放 API 新增字段**(老客户端忽略未知字段,完全兼容)
924```json
925{
926 "kind": "app", "kind_label": "应用型",
927 "permissions": ["db", "settings.read"],
928 "requires": "2.1.0", "engine": "js",
929 "has_templates": false, "hooks_count": 0
930}
931```
932
933**客户端配合**
934- 插件商店页支持按形态筛选(`/admin/store?kind=app`)
935- 商店卡片与安装确认展示权限清单,安装应用型插件后需在插件管理页启用
936
937### 15.5 暂未提供(可选的后续演进)
938
939- WASM / Lua 引擎(`Runtime` 接口已预留,当前仅 goja)
940- 插件签名与官方认证标记(当前依赖市场 SHA256 校验)
941- 插件之间的依赖声明与加载顺序编排
942- 后台可视化调试台(REPL / 单步)
943- 脚本文件变更的自动热重载(当前是后台「重载」按钮或保存配置触发)
944
945### 15.4 安装使用
946
9471. 将 `plugins/signin/` 整个目录复制到站点的 `data/plugins/signin/`
9482. 后台「插件管理」→ 点击**启用**(应用型插件启用后立即加载运行时并执行 `setup()`)
9493. 前台访问 `/x/signin/`,后台左侧出现「签到管理」菜单
950
951> 打包分发:把插件目录(`plugin.json` + `main.js` + 模板文件)压成 zip,后台「插件管理 → 安装插件」上传即可;
952> `plugin.json` 必须在压缩包内(建议直接放根目录),且为 UTF-8 无 BOM。