仰望星辰工作室

clearlove2.1

clearlove2.1/ docs/PLUGIN-APP.md 39.4 KB · 1,001 行 原始文件
1# 应用型插件开发指南(B 型)
2
3> 应用型插件是一段跑在内核里的 JavaScript:用 `clv.*` 注册路由、数据表、后台菜单、
4> 定时任务、UI 注入点、事件与过滤器。**能力不受钩子白名单限制**,可以承载一个完整功能。
5>
6> 通用部分(打包、安装、配置项、权限模型、调试)见 [PLUGIN.md](PLUGIN.md)。
7> 轻量注入类需求用声明式插件更省事 → [PLUGIN-DECLARATIVE.md](PLUGIN-DECLARATIVE.md)。
8
9---
10
11## 1. 设计原则
12
13| 原则 | 含义 |
14|---|---|
15| **清单决定"能碰什么"** | 权限(`permissions`)必须写在 `plugin.json` 里,加载脚本**之前**就已确定;脚本无法在运行时申请权限 |
16| **脚本决定"要做什么"** | 路由、表、菜单、任务的注册写在 `setup()` 里,可以按配置条件注册、循环注册 |
17| **故障不拖垮站点** | 插件异常只影响自身:超时中断、失败熔断、过滤器失败放行原值、事件失败仅记日志 |
18| **无并发心智负担** | 每个插件独享一条串行执行队列,插件作者**不需要考虑并发**(没有锁、没有竞态) |
19
20---
21
22## 2. 最小可用插件
23
24```
25hello/
26├── plugin.json
27└── main.js
28```
29
30**plugin.json**
31
32```json
33{
34 "kind": "app",
35 "name": "你好世界",
36 "slug": "hello",
37 "version": "1.0.0",
38 "author": "you",
39 "description": "最小示例:一个前台页面",
40 "requires": "2.1.0",
41 "runtime": { "engine": "js", "entry": "main.js" },
42 "permissions": []
43}
44```
45
46**main.js**
47
48```js
49function setup() {
50 clv.route("GET", "/", "pageHello");
51}
52
53function pageHello(ctx) {
54 return { body: "<h1>你好," + (ctx.userId ? "用户 " + ctx.userId : "访客") + "</h1>" };
55}
56```
57
58安装并启用后,访问 `/x/hello/` 即可看到页面。
59
60---
61
62## 3. 清单字段
63
64```json
65{
66 "kind": "app",
67 "name": "每日签到",
68 "slug": "signin",
69 "version": "1.0.0",
70 "author": "ClearLove",
71 "description": "……",
72 "requires": "2.1.0",
73 "runtime": { "engine": "js", "entry": "main.js", "timeout_ms": 1000 },
74 "permissions": ["db", "settings.read", "site.read"],
75 "config": [ { "key": "points", "label": "每次签到积分", "type": "number", "default": "5" } ]
76}
77```
78
79| 字段 | 必填 | 说明 |
80|---|---|---|
81| `kind` | ✅ | 固定 `"app"`(缺省或为其它值会被当作声明式插件处理) |
82| `name` | ✅ | 插件名(同时作为安装目录名) |
83| `slug` | 建议 | URL 与表名标识,仅允许 `[a-z0-9_-]`;不写则由 `name` 归一化(中文名会退化为 `plugin-<hash>`) |
84| `version` / `author` / `description` / `requires` | | 展示与提示用 |
85| `runtime.engine` | ✅ | 目前仅支持 `"js"` |
86| `runtime.entry` | ✅ | 入口脚本相对路径(不允许绝对路径与 `..`) |
87| `runtime.timeout_ms` | | 单次脚本调用超时,默认 `1500`,硬上限 `30000` |
88| `permissions` | | 能力白名单,见 [PLUGIN.md](PLUGIN.md) 第四节 |
89| `config` | | 配置项声明(与 A 型完全一致,后台自动渲染表单) |
90| `hooks` | | 可选:声明式钩子,可与脚本混用 |
91
92---
93
94## 4. 生命周期与执行模型
95
96### 4.1 加载流程(启用插件时)
97
98```
99读取 plugin.json
100 ↓ 校验 engine=js、entry 合法
101执行入口脚本(顶层代码) ← 只做定义,不要有副作用
102 ↓
103onInstall() ← 可选,仅首次安装后调用
104 ↓
105setup() ← 必须定义:注册全部扩展点
106 ↓
107生成注册表快照(路由生效)
108 ↓
109启动定时任务
110```
111
112- **顶层代码**在脚本载入时执行一次:定义函数、常量、打开数据库表句柄(`var T = clv.db.table("log")`)
113- **`setup()` 必须存在**,否则加载失败(后台状态显示「未运行」,`plugin_logs` 有 `load_error`)
114- **`onInstall()`** 可选:适合写初始化数据(注意它在 `setup()` 之前执行)
115- **`onDisable()`** 可选:插件停用 / 卸载前调用,适合清理
116- 重新启用或调用重载时,会**先卸载旧实例再重新加载**,脚本内变量随之重置
117
118### 4.2 执行模型
119
120| 事项 | 说明 |
121|---|---|
122| 串行队列 | 每个插件的所有回调(路由、事件、任务、slot)在**同一条队列**上排队执行,天然无并发 |
123| 超时中断 | 单次调用超过 `timeout_ms` 会被强制中断(死循环也能中断),之后插件仍可继续服务 |
124| setup 预算 | `setup()` 单独限时 3~5 秒(初始化只做注册,网络请求应放在路由或任务里) |
125| 队列容量 | 64 个待处理调用;队列满时新调用等待,超时即失败 |
126| 失败熔断 | 连续失败 20 次 → 自动禁用并卸载插件(后台可看失败计数) |
127| 请求体上限 | 插件路由读取请求体上限 1MB |
128
129### 4.3 JavaScript 环境
130
131| 项 | 说明 |
132|---|---|
133| 引擎 | goja(纯 Go 实现的 ES5.1 + 大部分 ES6 语法) |
134| 支持 | `let` / `const`、箭头函数、模板字符串、解构、`class`、`JSON`、正则、`Date` |
135| 不支持 | **`async` / `await`、`Promise` 回调链、DOM、`fetch`、`XMLHttpRequest`、Node.js API** |
136| 重要约定 | 所有 `clv.*` API 都是**同步**调用,直接拿返回值,不需要回调或 `await` |
137| 推荐风格 | 参考 `plugins/signin/main.js`:`var` + `function`,兼容性最好 |
138
139---
140
141## 5. 注册 API(在 `setup()` 中调用)
142
143### `clv.table(name, columns, opts)` → 实际表名
144
145幂等建表(已存在则跳过)。表名自动加前缀:`pl_<slug>_<name>`。
146
147```js
148clv.table("log", {
149 user_id: "int",
150 day: "string",
151 points: "int",
152 created_at: "time"
153}, {
154 unique: [["user_id", "day"]], // 唯一索引(多列)
155 indexes: [["day"]] // 普通索引
156});
157```
158
159列类型(自动适配 SQLite / MySQL):
160
161| 声明 | SQLite | MySQL | 建议用途 |
162|---|---|---|---|
163| `int` | INTEGER | INT | 计数、ID 引用 |
164| `bigint` | INTEGER | BIGINT | 大整数 |
165| `bool` | INTEGER | TINYINT | 0/1 |
166| `number` | REAL | DOUBLE | 小数(如金额、评分) |
167| `string` | TEXT | VARCHAR(255) | 短文本 |
168| `text` | TEXT | MEDIUMTEXT | 长文本、JSON 字符串 |
169| `json` | TEXT | MEDIUMTEXT | 同 `text` |
170| `time` | TEXT | VARCHAR(40) | ISO 时间字符串 |
171
172> 主键 `id` 自动创建,无需声明。时间统一存 ISO 字符串(`new Date().toISOString()`),字符串比较即时间顺序。
173
174### `clv.route(method, path, handler, opts?)`
175
176注册前台路由,实际路径 `/x/<slug><path>`。
177
178```js
179clv.route("GET", "/", "pageSignin", { auth: "user" });
180clv.route("POST", "/do", "doSignin", { auth: "user" });
181clv.route("GET", "/rank", "apiRank", { auth: "none", json: true });
182clv.route("GET", "/books/*", "bookDetail"); // 支持 /* 后缀通配
183```
184
185| opts | 说明 |
186|---|---|
187| `auth` | `none`(默认)/ `user`(未登录跳转 `/login?next=...`)/ `admin`(非管理员 403) |
188| `json` | `true` 时错误以 JSON 返回(前端 `fetch` 接口建议开启) |
189
190- 第 3 个参数可写**函数名字符串**或**直接传函数**
191- 路径匹配不含参数提取:需要 ID 请用查询串(`/x/signin/rank?day=2026-09-21`),在 `ctx.req.query` 中读取
192- 静态资源放插件目录 `assets/` 下,经 `/x/<slug>/assets/<文件>` 访问(只读、防穿越)
193
194### `clv.adminPage(path, handler, opts?)`
195
196注册后台页面,实际路径 `/admin/plugins/<slug><path>`。
197
198```js
199clv.adminPage("/", "adminPage", { perm: "plugins" });
200```
201
202`perm` 为内核权限组(`posts` / `reports` / `users` / `settings` / `notices` / `plugins` / `api` / `admins`),缺省为 `plugins`。参数中的 `ctx.params.admin === "1"` 表示来自后台。
203
204### `clv.adminMenu({ label, path, perm, order })`
205
206在后台侧边栏注册菜单项(`path` 与 `adminPage` 对应)。
207
208```js
209clv.adminMenu({ label: "签到管理", path: "/", perm: "plugins", order: 50 });
210```
211
212`order` 升序排列,`perm` 为空则所有管理员可见,`label` 超长会被截断。
213
214### `clv.slot(name, handler)`
215
216在页面注入点输出 HTML。`handler(data)` 返回字符串(返回空串 / `null` 则不输出)。
217
218```js
219clv.slot("post.detail.after", "detailBadge");
220clv.slot("body.end", "floatingEntry");
221
222function detailBadge(data) {
223 var post = data && data.post;
224 if (!post) return "";
225 return '<div class="tip">帖子 #' + post.id + '</div>';
226}
227```
228
229可用 Slot 与传入数据见第 9.1 节。
230
231### `clv.on(event, handler)` / `clv.emit(event, payload)`
232
233订阅站点事件(异步执行,参数为载荷对象,自动带 `event` 与 `time` 字段):
234
235```js
236clv.on("post_created", function (e) {
237 clv.log.info("新帖 #" + e.post_id);
238});
239clv.on("like_toggled", function (e) {
240 if (e.liked) clv.cache.set("liked:" + e.post_id, 1, 3600);
241});
242```
243
244广播自定义事件(插件间通信,也可被其它插件订阅):
245
246```js
247clv.emit("signin.done", { user_id: ctx.userId, points: gain });
248```
249
250事件名与载荷见第 9.2 节。
251
252### `clv.filter(name, handler)` 或 `clv.filter(name, priority, handler)`
253
254注册过滤器,链式处理内核数据(`priority` 小的先执行,默认 100):
255
256```js
257clv.filter("filter.content", 10, function (content) {
258 return content.replace(/加群/g, "***");
259});
260clv.filter("filter.post.visible", function (visible, extra) {
261 return visible; // 返回 false 即隐藏该帖
262});
263```
264
265返回值约定:返回 `undefined` / `null` 表示"不改",其余值作为下一环的输入。过滤器抛错只记日志并放行原值。
266
267### `clv.job(name, every, handler)`
268
269注册定时任务(`every` 为时长字符串,最小 `10s`):
270
271```js
272clv.job("daily-clean", "24h", "onDaily");
273clv.job("heartbeat", "30s", function () { clv.log.info("tick"); });
274```
275
276任务在插件启用时启动、停用时停止;单次执行同样受超时限制。
277
278### `clv.middleware(phase, handler)`
279
280注册 HTTP 中间件,`phase` 为 `"http.before"` 或 `"http.after"`。
281
282```js
283// 前置:可短路请求(返回 {abort:true, ...})
284clv.middleware("http.before", function (req) {
285 if (clv.cache.get("banned:" + req.ip)) {
286 return { abort: true, status: 403, body: "您的访问已被限制" };
287 }
288});
289
290// 后置:只观察(状态码、耗时)
291clv.middleware("http.after", function (info) {
292 if (info.status >= 500) clv.log.warn(info.path + " 异常 " + info.status);
293});
294```
295
296| phase | 入参 | 返回值 |
297|---|---|---|
298| `http.before` | `{ method, path, query, ip, fingerprint }` | `{ abort:true, status?, headers?, body?, json?, redirect? }` 短路;不返回则继续 |
299| `http.after` | `{ method, path, status, ip, fingerprint, duration_ms }` | 忽略 |
300
301> `http.before` 在 CSRF 校验之前执行,且对 `/static/`、`/uploads/` 不生效;`http.after` 只能观察,不能改写响应。
302
303---
304
305## 6. 请求处理器:上下文与返回值
306
307### 6.1 上下文 `ctx`
308
309```js
310function myHandler(ctx) {
311 ctx.req.method; // "GET" / "POST" / ...
312 ctx.req.path; // "/x/demo/do"
313 ctx.req.query; // { day: "2026-09-21" } 查询串(取第一个值)
314 ctx.req.form; // { _csrf: "...", ... } 表单字段
315 ctx.req.json; // 请求体(Content-Type: application/json)
316 ctx.req.ip; // 访客 IP
317 ctx.req.fingerprint; // 浏览器指纹
318 ctx.params; // 后台页面含 { admin: "1" };前台路由为空
319 ctx.userId; // 登录用户 ID(未登录为 0)
320 ctx.adminId; // 登录管理员 ID(未登录为 0)
321 ctx.isAdmin; // 是否管理员
322 ctx.csrf; // 当前请求的 CSRF 令牌(渲染表单用)
323}
324```
325
326### 6.2 返回值约定
327
328处理器返回一个对象,按以下优先级写出响应(只命中第一个):
329
330| 返回字段 | 效果 |
331|---|---|
332| `{ redirect: "/x/demo/" }` | 303 跳转 |
333| `{ json: {...}, status: 200 }` | JSON 响应(`json` 可为任意可序列化值) |
334| `{ template: "page.html", data: {...} }` | 渲染插件目录下的模板文件,输出 HTML |
335| `{ body: "<h1>hi</h1>", status: 200 }` | 原样输出(默认 `text/html`) |
336| `{ status: 204 }` | 仅状态码 |
337| `{ headers: { "Cache-Control": "no-store" }, ... }` | 设置响应头(可与上述任一组合) |
338
339```js
340function doSignin(ctx) {
341 if (alreadyDone) return { status: 400, json: { ok: false, msg: "今天已经签到过了" } };
342 return { json: { ok: true, msg: "签到成功" } };
343}
344```
345
346> **POST 请求同样受 CSRF 保护**:插件表单需带 `{{.csrf}}`,JS 请带请求头 `X-CSRF-Token`
347> (可从 `ctx.csrf` 渲染到页面,或读 `<meta name="csrf">`,参考 `plugins/ai-polish/polish.html`)。
348
349---
350
351## 7. Host API 参考
352
353所有 API 均为**同步调用**,出错时抛出 JS 异常(可用 `try/catch` 捕获),错误信息会说明缺失的权限。
354
355### 7.1 基础信息
356
357| API | 说明 |
358|---|---|
359| `clv.version` | 内核版本号字符串 |
360| `clv.plugin.name` / `.slug` / `.dir` / `.version` | 插件自身信息 |
361| `clv.plugin.config(key)` | 读取配置项(未设置时回落清单 `default`) |
362| `clv.plugin.configs()` | 读取全部配置(对象) |
363| `clv.log.info(msg)` / `.warn(msg)` / `.error(msg)` | 写运行日志(同时进系统日志与 `plugin_logs` 表) |
364| `clv.json.encode(v)` / `clv.json.decode(s)` | JSON 互转(JS 原生 `JSON` 也可用) |
365
366### 7.2 数据层(权限 `db`)
367
368| API | 说明 |
369|---|---|
370| `clv.db.table(name)` | 逻辑名 → 实际表名(`pl_<slug>_<name>`) |
371| `clv.db.dialect()` | 返回 `"sqlite"` 或 `"mysql"`(写兼容 SQL 用) |
372| `clv.db.query(sql, ...args)` | 查询,返回对象数组(最多 1000 行,超出报错) |
373| `clv.db.get(sql, ...args)` | 查询单行,无结果返回 `null` |
374| `clv.db.exec(sql, ...args)` | 执行写操作,返回 `{ lastId, rowsAffected }` |
375| `clv.db.tx(fn)` | 事务:`fn(tx)` 内可 `tx.exec / tx.get / tx.query`,抛异常自动回滚 |
376
377```js
378var T = clv.db.table("log"); // → pl_signin_log
379
380var row = clv.db.get("SELECT COUNT(1) AS n FROM " + T + " WHERE user_id=?", uid);
381clv.db.exec("INSERT INTO " + T + "(user_id,day,points,created_at) VALUES(?,?,?,?)",
382 uid, day, 5, new Date().toISOString());
383```
384
385**安全边界(内核强制)**:
386
387- 写操作(INSERT/UPDATE/DELETE/ALTER/DROP…)只能落在本插件的 `pl_<slug>_*` 表
388- 内核表**可读不可写**(除非声明 `db.admin` 权限)
389- 查询结果上限 1000 行 —— 大表请加 `LIMIT` 与索引
390- 始终使用 `?` 占位符传参,不要拼接用户输入
391
392### 7.3 站点设置(`settings.read` / `settings.write`)
393
394| API | 权限 | 说明 |
395|---|---|---|
396| `clv.setting.get(key)` | `settings.read` | 读取站点设置(如 `site_name`) |
397| `clv.setting.all()` | `settings.read` | 读取全部设置(对象) |
398| `clv.setting.set(key, value)` | `settings.write` | 写入设置 |
399
400常用设置键(值为字符串,开关用 `"1"` / `"0"`;未列出的键读取返回空串):
401
402| 分类 | 键 | 说明 |
403|---|---|---|
404| 基础 | `site_name` | 站点名称 |
405| 外观 | `theme` / `theme_bg` / `donate_img` | 主题名 / 背景图地址 / 捐赠二维码地址 |
406| 注册 | `allow_register` / `require_login_post` | 是否允许注册 / 发帖是否必须登录 |
407| 内容 | `community_rules` | 社区守则文本 |
408| AI | `ai_enabled` / `ai_base` / `ai_key` / `ai_model` / `ai_precheck` / `ai_autopatrol` | AI 接入与审核开关 |
409| SMTP | `smtp_host` / `smtp_port` / `smtp_user` / `smtp_pass` / `smtp_from` | 邮件发送配置 |
410| 其它 | `admin_nicknames` / `cloud_api` / `update_url` / `enabled_plugins` | 管理员专享昵称 / 插件商店地址 / 检查更新地址 / 启用中的插件列表 |
411
412> 插件自己的配置也存在这张表里,键为 `plugin.<插件名>.<字段名>`,读取请用 `clv.plugin.config()`。
413> 写入站点设置会影响内核行为,请谨慎并优先使用插件自己的配置项。
414
415### 7.4 站点数据(`site.read` / `site.write`)
416
417```js
418clv.post.list({ limit: 10, before: 0, topic: "", include_hidden: false });
419clv.post.get(id);
420clv.post.create({ content: "内容", nickname: "匿名", topic: "话题名" }); // → { id }
421clv.post.update(id, { content: "新内容" });
422clv.post.remove(id); // 级联删除评论 / 媒体 / 点赞 / 举报
423
424clv.comment.list(postId);
425clv.comment.create({ post_id: id, content: "评论", nickname: "匿名" });
426clv.comment.remove(id);
427
428clv.user.get(id);
429clv.user.byName("username");
430clv.user.current(); // 当前请求的登录用户(无则 null)
431
432clv.stats.overview(); // { users, posts, comments, reports_pending }
433clv.stats.today(); // { posts, comments, users }
434```
435
436| API | 权限 |
437|---|---|
438| `clv.post.list` / `clv.post.get` / `clv.comment.list` / `clv.user.*` / `clv.stats.*` | `site.read` |
439| `clv.post.create` / `clv.post.update` / `clv.post.remove` / `clv.comment.create` / `clv.comment.remove` | `site.write` |
440
441- 内容统一经 `StripHTML` 清洗,长度限制与前台一致(帖子 3000 字、评论 500 字)
442- `clv.post.create` 会触发 `post_created` Webhook(A 型钩子)
443- 用户对象只含公开字段(`id / username / avatar / status / created_at`)
444
445### 7.5 视图(`clv.view`)
446
447| API | 说明 |
448|---|---|
449| `clv.view.template(name, data)` | 渲染插件目录下的模板文件,返回 HTML 字符串(可用于 slot 或路由响应) |
450| `clv.view.escape(s)` | HTML 转义(输出用户数据时务必使用) |
451| `clv.view.nl2br(s)` | 转义后将换行转成 `<br>` |
452
453```js
454clv.slot("body.end", function () {
455 return clv.view.template("widget.html", { site: clv.setting.get("site_name") });
456});
457```
458
459模板文件位于插件目录下(如 `page.html` / `widget.html`),语法为 Go `html/template`,
460可用函数:`nl2br` / `datefmt` / `snippet` / `substr0` / `plus1` / `minus1`。
461
462### 7.6 网络(权限 `net`)
463
464```js
465var resp = clv.http.request({
466 url: "https://api.example.com/data",
467 method: "POST", // 默认 GET
468 headers: { "Content-Type": "application/json" },
469 body: JSON.stringify({ q: "x" }),
470 timeout_ms: 10000, // 默认 10000,上限 30000
471 max_bytes: 262144 // 默认 1MB,上限 2MB
472});
473resp.status; // 状态码
474resp.headers; // 响应头
475resp.body; // 响应文本
476```
477
478- 仅支持 `http` / `https`
479- 默认**拒绝内网 / 环回 / 链路本地地址**(SSRF 防护);确需访问本机或内网服务(如 Ollama)请声明 `net.local` 权限
480- 这是**同步阻塞**调用:耗时算在脚本超时内,记得给 `runtime.timeout_ms` 留足时间
481
482### 7.7 缓存(权限 `cache`)
483
484```js
485clv.cache.set("key", value, 3600); // ttl 秒,上限 24 小时;省略则永不过期
486clv.cache.get("key"); // 不存在返回 null
487clv.cache.del("key");
488```
489
490内存级缓存(进程内、重启丢失),按插件隔离命名空间,适合限流计数、去重、临时状态。
491
492### 7.8 加密与随机(无需权限)
493
494| API | 说明 |
495|---|---|
496| `clv.crypto.bcrypt(pwd)` | 生成密码哈希 |
497| `clv.crypto.verify(hash, pwd)` | 校验密码(→ bool) |
498| `clv.crypto.hmacSha256(key, msg)` | HMAC-SHA256,返回十六进制 |
499| `clv.crypto.randomHex(n)` | 随机十六进制串(n ≤ 64,默认 16 字节) |
500| `clv.crypto.uuid()` | UUID v4 |
501| `clv.crypto.base64Encode(s)` / `.base64Decode(s)` | Base64 互转 |
502
503> `bcrypt` 是刻意设计的慢操作(百毫秒级),请计入超时预算。
504
505### 7.9 邮件(权限 `mail`)
506
507```js
508clv.mail.send("to@example.com", "主题", "正文"); // 需要站点已配置 SMTP
509```
510
511### 7.10 媒体(权限 `media.write`)
512
513```js
514var url = clv.media.saveImage({ name: "a.png", data: base64str }); // → "/uploads/xxx.webp"
515var u2 = clv.media.saveVideo({ name: "a.mp4", data: base64str });
516```
517
518图片经内核统一处理(自动转 WebP),单图上限 10MB。
519
520---
521
522## 8. 权限清单
523
524| 权限 | 授予的能力 | 典型场景 |
525|---|---|---|
526| `db` | `clv.db.*`、`clv.table`(写仅限本插件表) | 存取插件自己的数据 |
527| `db.admin` | 放开全库写(含内核表) | 数据修复类工具(谨慎) |
528| `settings.read` | `clv.setting.get/all` | 读取站名、AI 配置 |
529| `settings.write` | `clv.setting.set` | 修改站点设置 |
530| `site.read` | 帖子 / 评论 / 用户 / 统计读取 | 排行榜、报表 |
531| `site.write` | 发帖 / 改帖 / 删帖 / 评论 | 定时公告、内容机器人 |
532| `net` | `clv.http.request` | 调用外部接口 |
533| `net.local` | 允许访问内网 / 本机地址 | 本机 Ollama、内网服务 |
534| `cache` | `clv.cache.*` | 限流、去重 |
535| `mail` | `clv.mail.send` | 通知邮件 |
536| `media.write` | `clv.media.*` | 生成/保存图片视频 |
537
538---
539
540## 9. 扩展点全集
541
542### 9.1 UI Slot
543
544`handler(data)` 返回 HTML 字符串;与 A 型的同名 `html` 钩子输出会拼接(A 型在前)。
545
546| Slot 名 | 注入位置 | 生效页面 |
547|---|---|---|
548| `header_html` | `<body>` 起始处 | 全站前台 |
549| `footer_html` | 页脚(`app.js` 之前) | 全站前台 |
550| `head.end` | `</head>` 之前 | 全站前台 |
551| `body.end` | `</body>` 之前 | 全站前台 |
552| `admin.head.end` | 后台 `</head>` 之前 | 全站后台 |
553| `admin.body.end` | 后台 `</body>` 之前 | 全站后台 |
554| `admin.sidebar.after` | 后台侧边栏之后 | 全站后台 |
555| `admin.settings.after` | 设置页表单之后 | 后台设置页 |
556| `admin.dashboard.after` | 仪表盘内容之后 | 后台仪表盘 |
557| `post.detail.actions.after` | 详情页操作栏之后(卡片内) | 帖子详情页 |
558| `post.detail.after` | 详情页帖子卡片之后(评论区之前) | 帖子详情页 |
559| `comments.after` | 评论区之后 | 帖子详情页 |
560| `compose.after` | 发帖表单之后 | 发帖页 |
561| `profile.after` | 个人主页内容之后 | 个人主页 |
562
563`data` 字段(轻量数据,避免整页开销):
564
565| 字段 | 说明 |
566|---|---|
567| `page` | 当前页面模板名,如 `pg_detail`、`pg_index` |
568| `path` | 当前 URL 路径 |
569| `site` | 站点名称 |
570| `topic` / `notice` | 话题名 / 公告(视页面而定) |
571| `user` | `{ id, username }`(未登录则不存在) |
572| `admin` | `{ id, username, role }`(后台页面) |
573| `post` | `{ id, user_id, nickname }`(详情页 / 个人主页) |
574
575### 9.2 事件
576
577`handler(e)`,`e` 自动包含 `event` 与 `time`。事件异步执行,失败只记日志。
578
579| 事件 | 触发时机 | 载荷 |
580|---|---|---|
581| `post_created` | 发帖成功(表单 / API) | `post_id`、`nickname`、`content` |
582| `post_updated` | 帖子编辑保存 | `post_id`、`content` |
583| `post_deleted` | 帖子删除 | `post_id` |
584| `comment_created` | 评论成功 | `post_id`、`content` |
585| `like_toggled` | 点赞 / 取消 | `post_id`、`liked`、`count` |
586| `report_created` | 提交举报 | `post_id`、`reason` |
587| `user_login` | 用户登录成功 | `user_id`、`username` |
588| `user_registered` | 用户注册成功 | `user_id`、`username` |
589
590自定义事件:`clv.emit(name, payload)` 广播,其他插件用 `clv.on(name, fn)` 订阅。
591
592### 9.3 过滤器
593
594`handler(value, extra?)`:返回 `undefined` / `null` 表示不改;抛错则放行原值。
595
596| 过滤器 | 值类型 | 触发点 | extra |
597|---|---|---|---|
598| `filter.content` | string | 发帖 / 评论内容(A 型正则之后) | — |
599| `filter.card` | map | 首页卡片数据(可改写 `nickname` / `content` / `topic` / `images` / `videos` / `likes` / `comment_count` / `badges`,`id` 与 `user_id` 不可改) | — |
600| `filter.api.response` | map | 内核 JSON 接口成功响应 | — |
601| `filter.compose.guard` | map | 发帖守卫:可改 `require` / `nickname` / `labels` / `message` | — |
602| `filter.post.visible` | bool | 帖子是否可见 | `{ post_id }` |
603| `filter.auth.login` | bool | 登录是否放行(返回 false 拒绝登录) | `{ user_id, username }` |
604| `filter.theme.css` | string | 主题 CSS(可追加全站样式) | — |
605| `filter.admin.stats` | map | 后台仪表盘数据 | — |
606| `filter.settings.save` | map | 后台保存设置的键值表 | — |
607
608```js
609// 首页卡片:给指定用户加标识
610clv.filter("filter.card", function (card) {
611 if (card.user_id === 1) card.badges = (card.badges || []).concat(["元老"]);
612 return card;
613});
614```
615
616### 9.4 HTTP 中间件
617
618见 5.9 节 `clv.middleware`。
619
620### 9.5 后台菜单与页面
621
622见 5.3 / 5.4 节。菜单渲染在后台侧边栏,按 `order` 排序,无权限的管理员看不到该项。
623
624### 9.6 模板覆盖(最强的前端改造能力)
625
626在插件目录下新建 `templates/`,放入**与内核模板同名**的文件,用 `{{define "..."}}`
627重写对应片段。插件模板在启用 / 停用后自动重新解析,**后解析的覆盖先前的**。
628
629```
630templates/
631└── front.html # 只写你要覆盖的 define,其余不受影响
632```
633
634```html
635{{define "pg_detail"}}
636<article class="card detail">
637 <h1>自定义的详情页布局</h1>
638 <div class="detail-content">{{nl2br .Content}}</div>
639 {{.detail_after}}
640</article>
641{{end}}
642```
643
644可覆盖的内核片段(define 名):
645
646| 文件 | 片段 |
647|---|---|
648| `layout.html` | `layout.html`(前台布局整体) |
649| `admin_layout.html` | `admin_layout.html`(后台布局整体) |
650| `front.html` | `pg_index`、`pg_detail`、`pg_compose`、`pg_edit`、`pg_login`、`pg_register`、`pg_profile` |
651| `admin.html` | `pg_dashboard`、`pg_admin_posts`、`pg_admin_settings`、`pg_admin_reports`、`pg_admin_users`、`pg_admin_admins`、`pg_admin_notices`、`pg_admin_plugins`、`pg_admin_store`、`pg_admin_apikeys`、`pg_admin_migrate`、`pg_admin_about`、`pg_admin_update`、`pg_admin_plugin_logs`、`pg_admin_login` |
652| `install.html` | `pg_install` |
653
654可用模板函数:`nl2br`、`datefmt`、`snippet`、`substr0`、`plus1`、`minus1`;
655页面数据(`.site`、`.user`、`.csrf` 及各 slot 变量如 `.body_end`)同样可用。
656
657> 覆盖布局或页面会**整体替换**该片段,请以内核模板为蓝本复制修改,避免遗漏必要结构。
658> 多个插件覆盖同一片段时,启用顺序决定最终生效者(后解析者胜)。
659
660### 9.7 前端 API(`window.CLV`)
661
662插件注入到页面的脚本(`clv.slot` 输出、模板覆盖、A 型 `html` 钩子)可以复用内核的前端能力:
663
664| API | 说明 |
665|---|---|
666| `CLV.version` | 内核版本号字符串 |
667| `CLV.csrf` | 当前请求的 CSRF 令牌(自己发请求时用) |
668| `CLV.api(url, opts)` | `fetch` 封装:自动带 CSRF / 指纹 / XHR 头,返回 Promise(解析为 JSON) |
669| `CLV.fingerprint()` | 浏览器指纹字符串 |
670| `CLV.el(tag, className, text)` | 创建元素 |
671| `CLV.toast(msg)` | 弹出提示 |
672| `CLV.hooks.cardHtml` | 数组,`fn(html, post) => string`:返回内容插入首页卡片底部 |
673| `CLV.hooks.cardEl` | 数组,`fn(cardEl, post)`:直接操作卡片元素 |
674| `CLV.hooks.afterRender` | 数组,`fn(cardEl, post)`:卡片渲染完成后回调 |
675
676```html
677<script>
678(function () {
679 // 给热度高的卡片加一个角标
680 CLV.hooks.cardHtml.push(function (html, post) {
681 return post.likes > 10 ? '<span class="hot-tag">热门</span>' : '';
682 });
683 // 调用插件自己的接口
684 CLV.api('/x/demo/rank').then(function (res) { console.log(res); });
685})();
686</script>
687```
688
689- `post` 字段与 `GET /api/v1/posts` 的返回一致(`id` / `nickname` / `content` / `topic` / `likes` / `comment_count` / `badges` …)
690- 首页卡片由前端 JS 渲染且支持无限滚动,**`CLV.hooks.*` 是介入卡片的唯一途径**(服务端 Slot 只作用于首屏之外的服务端渲染部分)
691- `window.CLV` 在页面 `<head>` 中已预初始化,插件脚本放在任意位置都能安全注册
692- 所有 hook 回调都被 `try/catch` 包裹,插件脚本报错不会影响页面其它功能
693
694---
695
696## 10. 完整示例:每日签到插件(全部源码内嵌,可直接复制)
697
698下面是官方示例插件「每日签到」的**完整源码**:共 4 个文件,全部复制保存、打包上传即可运行,
699**不需要访问任何源码仓库**。它覆盖了 B 型插件的核心能力:数据表、前台页面与接口、
700后台管理页与菜单、UI 注入、定时任务、事件广播。
701
702### 10.1 目录结构
703
704```
705signin/
706├── plugin.json # 清单:kind=app、权限、配置项
707├── main.js # 脚本入口:setup() 注册全部扩展点
708├── page.html # 前台签到页(完整 HTML 页面)
709└── admin.html # 后台管理页(HTML 片段)
710```
711
712### 10.2 plugin.json
713
714```json
715{
716 "kind": "app",
717 "name": "每日签到",
718 "slug": "signin",
719 "version": "1.0.0",
720 "author": "ClearLove",
721 "description": "应用型插件示例:自定义数据表 + 前台页面 + 后台管理页 + 定时任务 + UI 注入",
722 "requires": "2.1.0",
723 "runtime": {
724 "engine": "js",
725 "entry": "main.js",
726 "timeout_ms": 1000
727 },
728 "permissions": ["db", "settings.read", "site.read"],
729 "config": [
730 {
731 "key": "points",
732 "label": "每次签到积分",
733 "type": "number",
734 "default": "5",
735 "help": "签到一次获得的积分数量"
736 }
737 ]
738}
739```
740
741### 10.3 main.js
742
743```js
744// 每日签到 —— 应用型插件(kind=app)完整示例
745// 能力:clv.table / clv.route / clv.view.template / clv.adminPage
746// clv.adminMenu / clv.slot / clv.job / clv.emit
747
748var T = clv.db.table("log"); // 实际表名 pl_signin_log(顶层只做定义)
749
750function setup() {
751 // 1) 建表(幂等,启用时执行)
752 clv.table("log", {
753 user_id: "int",
754 day: "string",
755 points: "int",
756 created_at: "time"
757 }, { unique: [["user_id", "day"]], indexes: [["day"]] });
758
759 // 2) 前台路由(实际路径:/x/signin/、/x/signin/do、/x/signin/rank)
760 clv.route("GET", "/", "pageSignin", { auth: "user" });
761 clv.route("POST", "/do", "doSignin", { auth: "user" });
762 clv.route("GET", "/rank", "apiRank", { auth: "none", json: true });
763
764 // 3) 后台页面与菜单(实际路径:/admin/plugins/signin/)
765 clv.adminPage("/", "adminPage", { perm: "plugins" });
766 clv.adminMenu({ label: "签到管理", path: "/", perm: "plugins", order: 50 });
767
768 // 4) UI 注入:详情页显示作者签到天数 + 前台悬浮签到按钮
769 clv.slot("post.detail.after", "detailBadge");
770 clv.slot("body.end", "floatingEntry");
771
772 // 5) 定时任务:每天清理 90 天前的记录
773 clv.job("daily-clean", "24h", "onDaily");
774}
775
776function today() {
777 var d = new Date();
778 return d.getFullYear() + "-" +
779 ("0" + (d.getMonth() + 1)).slice(-2) + "-" +
780 ("0" + d.getDate()).slice(-2);
781}
782
783/* ---------------- 前台 ---------------- */
784
785function pageSignin(ctx) {
786 var day = today();
787 var done = clv.db.get("SELECT id FROM " + T + " WHERE user_id=? AND day=?", ctx.userId, day);
788 var total = clv.db.get("SELECT IFNULL(SUM(points),0) AS points, COUNT(1) AS days FROM " + T + " WHERE user_id=?", ctx.userId);
789 return {
790 template: "page.html",
791 data: {
792 done: !!done,
793 points: total ? total.points : 0,
794 days: total ? total.days : 0,
795 gain: clv.plugin.config("points"),
796 csrf: ctx.csrf
797 }
798 };
799}
800
801function doSignin(ctx) {
802 var day = today();
803 if (clv.db.get("SELECT id FROM " + T + " WHERE user_id=? AND day=?", ctx.userId, day)) {
804 return { status: 400, json: { ok: false, msg: "今天已经签到过了" } };
805 }
806 var gain = Number(clv.plugin.config("points")) || 1;
807 clv.db.exec("INSERT INTO " + T + "(user_id,day,points,created_at) VALUES(?,?,?,?)",
808 ctx.userId, day, gain, new Date().toISOString());
809 clv.emit("signin.done", { user_id: ctx.userId, day: day, points: gain });
810 clv.log.info("用户 " + ctx.userId + " 签到成功,积分 +" + gain);
811 return { json: { ok: true, msg: "签到成功,积分 +" + gain } };
812}
813
814function apiRank(ctx) {
815 var rows = clv.db.query(
816 "SELECT user_id, SUM(points) AS points, COUNT(1) AS days FROM " + T +
817 " GROUP BY user_id ORDER BY points DESC LIMIT 20");
818 return { json: { ok: true, rank: rows } };
819}
820
821/* ---------------- UI 注入 ---------------- */
822
823// 详情页:显示帖子作者的累计签到天数(服务端渲染,可直接查库)
824function detailBadge(data) {
825 var post = data && data.post;
826 if (!post || !post.user_id) return "";
827 var row = clv.db.get("SELECT COUNT(1) AS n FROM " + T + " WHERE user_id=?", post.user_id);
828 var n = row ? row.n : 0;
829 if (!n) return "";
830 return '<div class="card clv-signin-days" style="margin-top:12px;font-size:13px;opacity:.85">' +
831 clv.view.escape(post.nickname || "该用户") + " 已累计签到 " + n + " 天</div>";
832}
833
834// 前台悬浮入口
835function floatingEntry() {
836 return '<a class="clv-signin-fab" href="/x/signin/" title="每日签到">' +
837 '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2">' +
838 '<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/><path d="M9 12l2 2 4-4"/></svg></a>' +
839 '<style>.clv-signin-fab{position:fixed;left:18px;bottom:22px;z-index:900;width:46px;height:46px;' +
840 'border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;' +
841 'background:linear-gradient(135deg,var(--clv-accent,#ff7a9c),var(--clv-accent-2,#8a6cff));' +
842 'box-shadow:0 8px 22px rgba(0,0,0,.22)}</style>';
843}
844
845/* ---------------- 后台 ---------------- */
846
847function adminPage(ctx) {
848 var days = clv.db.query("SELECT day, COUNT(1) AS n FROM " + T + " GROUP BY day ORDER BY day DESC LIMIT 30");
849 var total = clv.db.get("SELECT COUNT(1) AS n, IFNULL(SUM(points),0) AS p FROM " + T);
850 return { template: "admin.html", data: { days: days, total: total } };
851}
852
853/* ---------------- 定时任务 ---------------- */
854
855function onDaily(ctx) {
856 var before = new Date(Date.now() - 90 * 86400000).toISOString();
857 var r = clv.db.exec("DELETE FROM " + T + " WHERE created_at < ?", before);
858 clv.log.info("清理 90 天前的签到记录:" + (r ? r.rowsAffected : 0) + " 条");
859}
860```
861
862要点解读:
863
864- `unique: [["user_id","day"]]` 唯一索引保证同一用户同一天只有一条记录,业务层再判重一次,双保险
865- 路由第 3 个参数传**函数名字符串**;`{ auth: "user" }` 未登录自动跳转登录页,`{ json: true }` 让错误以 JSON 返回
866- `{ template: "page.html", data: {...} }` 用插件目录下的模板渲染 HTML;POST 表单必须带 `{{.csrf}}`,否则被 CSRF 拦截
867- slot 处理器返回 HTML 字符串即注入页面;输出用户数据时用 `clv.view.escape` 转义
868- `clv.emit` 广播自定义事件,其它插件可用 `clv.on("signin.done", fn)` 订阅
869
870### 10.4 page.html(前台页面模板)
871
872前台页面是**完整 HTML**:自带页面骨架并引用内核样式(`/static/app.css` 与主题 `/theme.css`),
873表单提交到插件自己的路由,带 CSRF 隐藏域。
874
875```html
876<!DOCTYPE html>
877<html lang="zh-CN">
878<head>
879<meta charset="utf-8">
880<meta name="viewport" content="width=device-width, initial-scale=1">
881<title>每日签到</title>
882<link rel="icon" href="/favicon.svg" type="image/svg+xml">
883<link rel="stylesheet" href="/static/app.css">
884<link rel="stylesheet" href="/theme.css">
885</head>
886<body>
887<main class="container">
888 <h1 class="page-title">每日签到</h1>
889 <div class="card">
890 <p>累计签到 <strong>{{.days}}</strong> 天 · 累计积分 <strong>{{.points}}</strong></p>
891 {{if .done}}
892 <p class="muted">今天已经签到过了,明天再来~</p>
893 {{else}}
894 <form method="post" action="/x/signin/do">
895 <input type="hidden" name="_csrf" value="{{.csrf}}">
896 <button class="btn btn-primary" type="submit">立即签到(+{{.gain}})</button>
897 </form>
898 {{end}}
899 <p style="margin-top:14px">
900 <a href="/x/signin/rank">查看签到榜</a> ·
901 <a href="/">返回首页</a>
902 </p>
903 </div>
904</main>
905</body>
906</html>
907```
908
909### 10.5 admin.html(后台管理页模板)
910
911后台页面只是 **HTML 片段**(由内核嵌进后台布局,不要写 `<html>` 骨架),
912通过 `{{range}}` / `{{else}}` 渲染 `data` 传来的查询结果:
913
914```html
915<h1 class="page-title">签到管理</h1>
916
917<div class="stat-grid">
918 <div class="stat card"><span class="stat-num">{{.total.n}}</span><span class="stat-label">累计签到次数</span></div>
919 <div class="stat card"><span class="stat-num">{{.total.p}}</span><span class="stat-label">累计发放积分</span></div>
920</div>
921
922<div class="card table-card">
923 <h3>最近 30 天</h3>
924 <div class="table-scroll">
925 <table class="table">
926 <thead><tr><th>日期</th><th>签到人数</th></tr></thead>
927 <tbody>
928 {{range .days}}
929 <tr><td>{{.day}}</td><td>{{.n}}</td></tr>
930 {{else}}
931 <tr><td colspan="2" class="empty">暂无数据</td></tr>
932 {{end}}
933 </tbody>
934 </table>
935 </div>
936</div>
937```
938
939### 10.6 打包、安装、验证
940
941打包(三平台命令详见 [PLUGIN.md](PLUGIN.md) 2.0 节):
942
943```bash
944zip -r ../plugin-signin.zip . # macOS / Linux
945tar -a -c -f ../plugin-signin.zip * # Windows 10/11 自带命令
946```
947
948安装验证清单:
949
9501. 后台「插件管理 → 上传并安装」选择 zip → 点「启用」→ 状态变为「运行中」
9512. 登录用户访问 `/x/signin/` → 签到页;签到后再次访问显示"已签到"
9523. 访问 `/x/signin/rank` → JSON 排行榜
9534. 后台侧边栏出现「签到管理」→ 可看到累计统计与近 30 天数据
9545. 任意帖子详情页 → 若作者签过到,评论区上方出现"已累计签到 N 天"
9556. 「插件设置」把"每次签到积分"改成 1 → 保存后再次签到,提示 `积分 +1`
9567. 排错查后台「插件日志」页(`load_error` / `job:daily-clean` 等记录),见第 11 节
957
958---
959
960## 11. 调试与 FAQ
961
962| 现象 | 原因与处理 |
963|---|---|
964| 后台状态「未运行」 | 看 `plugin_logs` 的 `load_error`:常见为 `setup()` 抛错、`runtime.entry` 文件缺失、JS 语法错误 |
965| 路由 404 | 确认前缀 `/x/<slug>/`、方法一致、插件处于「运行中」;带 `/*` 的通配只匹配后缀 |
966| 报"未声明权限 db" | 权限在启用时确定,补 `permissions` 后需**重新启用** |
967| 请求报 CSRF 校验失败 | 表单加 `{{.csrf}}`,JS 加 `X-CSRF-Token` 请求头 |
968| 脚本超时 | 默认 1500ms。慢操作(外部 API、bcrypt)请显式设置 `runtime.timeout_ms`(上限 30000) |
969| 改了 main.js 不生效 | 脚本驻留内存,需重新启用插件(开发时可反复点停用/启用) |
970| 定时任务没跑 | 周期最小 `10s`;插件需在「运行中」;执行记录在 `plugin_logs`(`job:<name>`) |
971| 插件自动被停用 | 触发熔断(连续 20 次失败),查日志修好后再启用 |
972| 模板覆盖不生效 | 文件需在 `templates/` 下且用 `{{define "同名片段"}}`;启用/停用插件后自动重载 |
973| slot 不输出 | 仅当页面渲染时执行;返回空串不输出;确认 slot 名称拼写与生效页面 |
974| 数据库表名写错 | 用 `clv.db.table("log")` 取实际表名,不要硬编码 `pl_xxx_log`(slug 变化时表名会变) |
975
976**开发建议**
977
9781. 先在本地装好站点,把插件目录直接放到 `data/plugins/<name>/`,改完重新启用即可调试
9792. 用 `clv.log.info()` 打点,后台「插件日志」页查看
9803. 复杂逻辑先写纯函数,再接入路由 / 事件,便于排查
9814. 涉及写操作务必用 `?` 参数占位,绝不拼接用户输入
982
983---
984
985## 12. 安全边界(诚实的说明)
986
987应用型插件运行在**进程内沙箱**中,受权限、配额、超时、熔断约束,但它是**可执行代码**——请只安装可信来源的插件。
988
989**做不到的事**:
990
991- 不能读取任意文件、不能执行系统命令、不能访问 OS
992- 不能绕过权限访问未声明的能力(`db` 之外的库、`net` 之外的网络)
993- 不能访问内网 / 本机(除非显式声明 `net.local`)
994- 不能替换内核已有页面的处理流程(模板覆盖可改外观,但改不了业务逻辑与路由)
995- 不能修改内核函数或内核 SQL
996
997**部署建议**:
998
999- 生产环境安装插件前,先在测试站点验证
1000- 关注后台插件页的**失败计数**与 `plugin_logs`
1001- 出问题时「停用」即可立刻止损(路由与任务随停用一起失效)