# 应用型插件开发指南(B 型)
> 应用型插件是一段跑在内核里的 JavaScript:用 `clv.*` 注册路由、数据表、后台菜单、
> 定时任务、UI 注入点、事件与过滤器。**能力不受钩子白名单限制**,可以承载一个完整功能。
>
> 通用部分(打包、安装、配置项、权限模型、调试)见 [PLUGIN.md](PLUGIN.md)。
> 轻量注入类需求用声明式插件更省事 → [PLUGIN-DECLARATIVE.md](PLUGIN-DECLARATIVE.md)。
---
## 1. 设计原则
| 原则 | 含义 |
|---|---|
| **清单决定"能碰什么"** | 权限(`permissions`)必须写在 `plugin.json` 里,加载脚本**之前**就已确定;脚本无法在运行时申请权限 |
| **脚本决定"要做什么"** | 路由、表、菜单、任务的注册写在 `setup()` 里,可以按配置条件注册、循环注册 |
| **故障不拖垮站点** | 插件异常只影响自身:超时中断、失败熔断、过滤器失败放行原值、事件失败仅记日志 |
| **无并发心智负担** | 每个插件独享一条串行执行队列,插件作者**不需要考虑并发**(没有锁、没有竞态) |
---
## 2. 最小可用插件
```
hello/
├── plugin.json
└── main.js
```
**plugin.json**
```json
{
"kind": "app",
"name": "你好世界",
"slug": "hello",
"version": "1.0.0",
"author": "you",
"description": "最小示例:一个前台页面",
"requires": "2.1.0",
"runtime": { "engine": "js", "entry": "main.js" },
"permissions": []
}
```
**main.js**
```js
function setup() {
clv.route("GET", "/", "pageHello");
}
function pageHello(ctx) {
return { body: "
你好," + (ctx.userId ? "用户 " + ctx.userId : "访客") + "
" };
}
```
安装并启用后,访问 `/x/hello/` 即可看到页面。
---
## 3. 清单字段
```json
{
"kind": "app",
"name": "每日签到",
"slug": "signin",
"version": "1.0.0",
"author": "ClearLove",
"description": "……",
"requires": "2.1.0",
"runtime": { "engine": "js", "entry": "main.js", "timeout_ms": 1000 },
"permissions": ["db", "settings.read", "site.read"],
"config": [ { "key": "points", "label": "每次签到积分", "type": "number", "default": "5" } ]
}
```
| 字段 | 必填 | 说明 |
|---|---|---|
| `kind` | ✅ | 固定 `"app"`(缺省或为其它值会被当作声明式插件处理) |
| `name` | ✅ | 插件名(同时作为安装目录名) |
| `slug` | 建议 | URL 与表名标识,仅允许 `[a-z0-9_-]`;不写则由 `name` 归一化(中文名会退化为 `plugin-`) |
| `version` / `author` / `description` / `requires` | | 展示与提示用 |
| `runtime.engine` | ✅ | 目前仅支持 `"js"` |
| `runtime.entry` | ✅ | 入口脚本相对路径(不允许绝对路径与 `..`) |
| `runtime.timeout_ms` | | 单次脚本调用超时,默认 `1500`,硬上限 `30000` |
| `permissions` | | 能力白名单,见 [PLUGIN.md](PLUGIN.md) 第四节 |
| `config` | | 配置项声明(与 A 型完全一致,后台自动渲染表单) |
| `hooks` | | 可选:声明式钩子,可与脚本混用 |
---
## 4. 生命周期与执行模型
### 4.1 加载流程(启用插件时)
```
读取 plugin.json
↓ 校验 engine=js、entry 合法
执行入口脚本(顶层代码) ← 只做定义,不要有副作用
↓
onInstall() ← 可选,仅首次安装后调用
↓
setup() ← 必须定义:注册全部扩展点
↓
生成注册表快照(路由生效)
↓
启动定时任务
```
- **顶层代码**在脚本载入时执行一次:定义函数、常量、打开数据库表句柄(`var T = clv.db.table("log")`)
- **`setup()` 必须存在**,否则加载失败(后台状态显示「未运行」,`plugin_logs` 有 `load_error`)
- **`onInstall()`** 可选:适合写初始化数据(注意它在 `setup()` 之前执行)
- **`onDisable()`** 可选:插件停用 / 卸载前调用,适合清理
- 重新启用或调用重载时,会**先卸载旧实例再重新加载**,脚本内变量随之重置
### 4.2 执行模型
| 事项 | 说明 |
|---|---|
| 串行队列 | 每个插件的所有回调(路由、事件、任务、slot)在**同一条队列**上排队执行,天然无并发 |
| 超时中断 | 单次调用超过 `timeout_ms` 会被强制中断(死循环也能中断),之后插件仍可继续服务 |
| setup 预算 | `setup()` 单独限时 3~5 秒(初始化只做注册,网络请求应放在路由或任务里) |
| 队列容量 | 64 个待处理调用;队列满时新调用等待,超时即失败 |
| 失败熔断 | 连续失败 20 次 → 自动禁用并卸载插件(后台可看失败计数) |
| 请求体上限 | 插件路由读取请求体上限 1MB |
### 4.3 JavaScript 环境
| 项 | 说明 |
|---|---|
| 引擎 | goja(纯 Go 实现的 ES5.1 + 大部分 ES6 语法) |
| 支持 | `let` / `const`、箭头函数、模板字符串、解构、`class`、`JSON`、正则、`Date` |
| 不支持 | **`async` / `await`、`Promise` 回调链、DOM、`fetch`、`XMLHttpRequest`、Node.js API** |
| 重要约定 | 所有 `clv.*` API 都是**同步**调用,直接拿返回值,不需要回调或 `await` |
| 推荐风格 | 参考 `plugins/signin/main.js`:`var` + `function`,兼容性最好 |
---
## 5. 注册 API(在 `setup()` 中调用)
### `clv.table(name, columns, opts)` → 实际表名
幂等建表(已存在则跳过)。表名自动加前缀:`pl__`。
```js
clv.table("log", {
user_id: "int",
day: "string",
points: "int",
created_at: "time"
}, {
unique: [["user_id", "day"]], // 唯一索引(多列)
indexes: [["day"]] // 普通索引
});
```
列类型(自动适配 SQLite / MySQL):
| 声明 | SQLite | MySQL | 建议用途 |
|---|---|---|---|
| `int` | INTEGER | INT | 计数、ID 引用 |
| `bigint` | INTEGER | BIGINT | 大整数 |
| `bool` | INTEGER | TINYINT | 0/1 |
| `number` | REAL | DOUBLE | 小数(如金额、评分) |
| `string` | TEXT | VARCHAR(255) | 短文本 |
| `text` | TEXT | MEDIUMTEXT | 长文本、JSON 字符串 |
| `json` | TEXT | MEDIUMTEXT | 同 `text` |
| `time` | TEXT | VARCHAR(40) | ISO 时间字符串 |
> 主键 `id` 自动创建,无需声明。时间统一存 ISO 字符串(`new Date().toISOString()`),字符串比较即时间顺序。
### `clv.route(method, path, handler, opts?)`
注册前台路由,实际路径 `/x/`。
```js
clv.route("GET", "/", "pageSignin", { auth: "user" });
clv.route("POST", "/do", "doSignin", { auth: "user" });
clv.route("GET", "/rank", "apiRank", { auth: "none", json: true });
clv.route("GET", "/books/*", "bookDetail"); // 支持 /* 后缀通配
```
| opts | 说明 |
|---|---|
| `auth` | `none`(默认)/ `user`(未登录跳转 `/login?next=...`)/ `admin`(非管理员 403) |
| `json` | `true` 时错误以 JSON 返回(前端 `fetch` 接口建议开启) |
- 第 3 个参数可写**函数名字符串**或**直接传函数**
- 路径匹配不含参数提取:需要 ID 请用查询串(`/x/signin/rank?day=2026-09-21`),在 `ctx.req.query` 中读取
- 静态资源放插件目录 `assets/` 下,经 `/x//assets/<文件>` 访问(只读、防穿越)
### `clv.adminPage(path, handler, opts?)`
注册后台页面,实际路径 `/admin/plugins/`。
```js
clv.adminPage("/", "adminPage", { perm: "plugins" });
```
`perm` 为内核权限组(`posts` / `reports` / `users` / `settings` / `notices` / `plugins` / `api` / `admins`),缺省为 `plugins`。参数中的 `ctx.params.admin === "1"` 表示来自后台。
### `clv.adminMenu({ label, path, perm, order })`
在后台侧边栏注册菜单项(`path` 与 `adminPage` 对应)。
```js
clv.adminMenu({ label: "签到管理", path: "/", perm: "plugins", order: 50 });
```
`order` 升序排列,`perm` 为空则所有管理员可见,`label` 超长会被截断。
### `clv.slot(name, handler)`
在页面注入点输出 HTML。`handler(data)` 返回字符串(返回空串 / `null` 则不输出)。
```js
clv.slot("post.detail.after", "detailBadge");
clv.slot("body.end", "floatingEntry");
function detailBadge(data) {
var post = data && data.post;
if (!post) return "";
return '帖子 #' + post.id + '
';
}
```
可用 Slot 与传入数据见第 9.1 节。
### `clv.on(event, handler)` / `clv.emit(event, payload)`
订阅站点事件(异步执行,参数为载荷对象,自动带 `event` 与 `time` 字段):
```js
clv.on("post_created", function (e) {
clv.log.info("新帖 #" + e.post_id);
});
clv.on("like_toggled", function (e) {
if (e.liked) clv.cache.set("liked:" + e.post_id, 1, 3600);
});
```
广播自定义事件(插件间通信,也可被其它插件订阅):
```js
clv.emit("signin.done", { user_id: ctx.userId, points: gain });
```
事件名与载荷见第 9.2 节。
### `clv.filter(name, handler)` 或 `clv.filter(name, priority, handler)`
注册过滤器,链式处理内核数据(`priority` 小的先执行,默认 100):
```js
clv.filter("filter.content", 10, function (content) {
return content.replace(/加群/g, "***");
});
clv.filter("filter.post.visible", function (visible, extra) {
return visible; // 返回 false 即隐藏该帖
});
```
返回值约定:返回 `undefined` / `null` 表示"不改",其余值作为下一环的输入。过滤器抛错只记日志并放行原值。
### `clv.job(name, every, handler)`
注册定时任务(`every` 为时长字符串,最小 `10s`):
```js
clv.job("daily-clean", "24h", "onDaily");
clv.job("heartbeat", "30s", function () { clv.log.info("tick"); });
```
任务在插件启用时启动、停用时停止;单次执行同样受超时限制。
### `clv.middleware(phase, handler)`
注册 HTTP 中间件,`phase` 为 `"http.before"` 或 `"http.after"`。
```js
// 前置:可短路请求(返回 {abort:true, ...})
clv.middleware("http.before", function (req) {
if (clv.cache.get("banned:" + req.ip)) {
return { abort: true, status: 403, body: "您的访问已被限制" };
}
});
// 后置:只观察(状态码、耗时)
clv.middleware("http.after", function (info) {
if (info.status >= 500) clv.log.warn(info.path + " 异常 " + info.status);
});
```
| phase | 入参 | 返回值 |
|---|---|---|
| `http.before` | `{ method, path, query, ip, fingerprint }` | `{ abort:true, status?, headers?, body?, json?, redirect? }` 短路;不返回则继续 |
| `http.after` | `{ method, path, status, ip, fingerprint, duration_ms }` | 忽略 |
> `http.before` 在 CSRF 校验之前执行,且对 `/static/`、`/uploads/` 不生效;`http.after` 只能观察,不能改写响应。
---
## 6. 请求处理器:上下文与返回值
### 6.1 上下文 `ctx`
```js
function myHandler(ctx) {
ctx.req.method; // "GET" / "POST" / ...
ctx.req.path; // "/x/demo/do"
ctx.req.query; // { day: "2026-09-21" } 查询串(取第一个值)
ctx.req.form; // { _csrf: "...", ... } 表单字段
ctx.req.json; // 请求体(Content-Type: application/json)
ctx.req.ip; // 访客 IP
ctx.req.fingerprint; // 浏览器指纹
ctx.params; // 后台页面含 { admin: "1" };前台路由为空
ctx.userId; // 登录用户 ID(未登录为 0)
ctx.adminId; // 登录管理员 ID(未登录为 0)
ctx.isAdmin; // 是否管理员
ctx.csrf; // 当前请求的 CSRF 令牌(渲染表单用)
}
```
### 6.2 返回值约定
处理器返回一个对象,按以下优先级写出响应(只命中第一个):
| 返回字段 | 效果 |
|---|---|
| `{ redirect: "/x/demo/" }` | 303 跳转 |
| `{ json: {...}, status: 200 }` | JSON 响应(`json` 可为任意可序列化值) |
| `{ template: "page.html", data: {...} }` | 渲染插件目录下的模板文件,输出 HTML |
| `{ body: "hi
", status: 200 }` | 原样输出(默认 `text/html`) |
| `{ status: 204 }` | 仅状态码 |
| `{ headers: { "Cache-Control": "no-store" }, ... }` | 设置响应头(可与上述任一组合) |
```js
function doSignin(ctx) {
if (alreadyDone) return { status: 400, json: { ok: false, msg: "今天已经签到过了" } };
return { json: { ok: true, msg: "签到成功" } };
}
```
> **POST 请求同样受 CSRF 保护**:插件表单需带 `{{.csrf}}`,JS 请带请求头 `X-CSRF-Token`
> (可从 `ctx.csrf` 渲染到页面,或读 ``,参考 `plugins/ai-polish/polish.html`)。
---
## 7. Host API 参考
所有 API 均为**同步调用**,出错时抛出 JS 异常(可用 `try/catch` 捕获),错误信息会说明缺失的权限。
### 7.1 基础信息
| API | 说明 |
|---|---|
| `clv.version` | 内核版本号字符串 |
| `clv.plugin.name` / `.slug` / `.dir` / `.version` | 插件自身信息 |
| `clv.plugin.config(key)` | 读取配置项(未设置时回落清单 `default`) |
| `clv.plugin.configs()` | 读取全部配置(对象) |
| `clv.log.info(msg)` / `.warn(msg)` / `.error(msg)` | 写运行日志(同时进系统日志与 `plugin_logs` 表) |
| `clv.json.encode(v)` / `clv.json.decode(s)` | JSON 互转(JS 原生 `JSON` 也可用) |
### 7.2 数据层(权限 `db`)
| API | 说明 |
|---|---|
| `clv.db.table(name)` | 逻辑名 → 实际表名(`pl__`) |
| `clv.db.dialect()` | 返回 `"sqlite"` 或 `"mysql"`(写兼容 SQL 用) |
| `clv.db.query(sql, ...args)` | 查询,返回对象数组(最多 1000 行,超出报错) |
| `clv.db.get(sql, ...args)` | 查询单行,无结果返回 `null` |
| `clv.db.exec(sql, ...args)` | 执行写操作,返回 `{ lastId, rowsAffected }` |
| `clv.db.tx(fn)` | 事务:`fn(tx)` 内可 `tx.exec / tx.get / tx.query`,抛异常自动回滚 |
```js
var T = clv.db.table("log"); // → pl_signin_log
var row = clv.db.get("SELECT COUNT(1) AS n FROM " + T + " WHERE user_id=?", uid);
clv.db.exec("INSERT INTO " + T + "(user_id,day,points,created_at) VALUES(?,?,?,?)",
uid, day, 5, new Date().toISOString());
```
**安全边界(内核强制)**:
- 写操作(INSERT/UPDATE/DELETE/ALTER/DROP…)只能落在本插件的 `pl__*` 表
- 内核表**可读不可写**(除非声明 `db.admin` 权限)
- 查询结果上限 1000 行 —— 大表请加 `LIMIT` 与索引
- 始终使用 `?` 占位符传参,不要拼接用户输入
### 7.3 站点设置(`settings.read` / `settings.write`)
| API | 权限 | 说明 |
|---|---|---|
| `clv.setting.get(key)` | `settings.read` | 读取站点设置(如 `site_name`) |
| `clv.setting.all()` | `settings.read` | 读取全部设置(对象) |
| `clv.setting.set(key, value)` | `settings.write` | 写入设置 |
常用设置键(值为字符串,开关用 `"1"` / `"0"`;未列出的键读取返回空串):
| 分类 | 键 | 说明 |
|---|---|---|
| 基础 | `site_name` | 站点名称 |
| 外观 | `theme` / `theme_bg` / `donate_img` | 主题名 / 背景图地址 / 捐赠二维码地址 |
| 注册 | `allow_register` / `require_login_post` | 是否允许注册 / 发帖是否必须登录 |
| 内容 | `community_rules` | 社区守则文本 |
| AI | `ai_enabled` / `ai_base` / `ai_key` / `ai_model` / `ai_precheck` / `ai_autopatrol` | AI 接入与审核开关 |
| SMTP | `smtp_host` / `smtp_port` / `smtp_user` / `smtp_pass` / `smtp_from` | 邮件发送配置 |
| 其它 | `admin_nicknames` / `cloud_api` / `update_url` / `enabled_plugins` | 管理员专享昵称 / 插件商店地址 / 检查更新地址 / 启用中的插件列表 |
> 插件自己的配置也存在这张表里,键为 `plugin.<插件名>.<字段名>`,读取请用 `clv.plugin.config()`。
> 写入站点设置会影响内核行为,请谨慎并优先使用插件自己的配置项。
### 7.4 站点数据(`site.read` / `site.write`)
```js
clv.post.list({ limit: 10, before: 0, topic: "", include_hidden: false });
clv.post.get(id);
clv.post.create({ content: "内容", nickname: "匿名", topic: "话题名" }); // → { id }
clv.post.update(id, { content: "新内容" });
clv.post.remove(id); // 级联删除评论 / 媒体 / 点赞 / 举报
clv.comment.list(postId);
clv.comment.create({ post_id: id, content: "评论", nickname: "匿名" });
clv.comment.remove(id);
clv.user.get(id);
clv.user.byName("username");
clv.user.current(); // 当前请求的登录用户(无则 null)
clv.stats.overview(); // { users, posts, comments, reports_pending }
clv.stats.today(); // { posts, comments, users }
```
| API | 权限 |
|---|---|
| `clv.post.list` / `clv.post.get` / `clv.comment.list` / `clv.user.*` / `clv.stats.*` | `site.read` |
| `clv.post.create` / `clv.post.update` / `clv.post.remove` / `clv.comment.create` / `clv.comment.remove` | `site.write` |
- 内容统一经 `StripHTML` 清洗,长度限制与前台一致(帖子 3000 字、评论 500 字)
- `clv.post.create` 会触发 `post_created` Webhook(A 型钩子)
- 用户对象只含公开字段(`id / username / avatar / status / created_at`)
### 7.5 视图(`clv.view`)
| API | 说明 |
|---|---|
| `clv.view.template(name, data)` | 渲染插件目录下的模板文件,返回 HTML 字符串(可用于 slot 或路由响应) |
| `clv.view.escape(s)` | HTML 转义(输出用户数据时务必使用) |
| `clv.view.nl2br(s)` | 转义后将换行转成 `
` |
```js
clv.slot("body.end", function () {
return clv.view.template("widget.html", { site: clv.setting.get("site_name") });
});
```
模板文件位于插件目录下(如 `page.html` / `widget.html`),语法为 Go `html/template`,
可用函数:`nl2br` / `datefmt` / `snippet` / `substr0` / `plus1` / `minus1`。
### 7.6 网络(权限 `net`)
```js
var resp = clv.http.request({
url: "https://api.example.com/data",
method: "POST", // 默认 GET
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ q: "x" }),
timeout_ms: 10000, // 默认 10000,上限 30000
max_bytes: 262144 // 默认 1MB,上限 2MB
});
resp.status; // 状态码
resp.headers; // 响应头
resp.body; // 响应文本
```
- 仅支持 `http` / `https`
- 默认**拒绝内网 / 环回 / 链路本地地址**(SSRF 防护);确需访问本机或内网服务(如 Ollama)请声明 `net.local` 权限
- 这是**同步阻塞**调用:耗时算在脚本超时内,记得给 `runtime.timeout_ms` 留足时间
### 7.7 缓存(权限 `cache`)
```js
clv.cache.set("key", value, 3600); // ttl 秒,上限 24 小时;省略则永不过期
clv.cache.get("key"); // 不存在返回 null
clv.cache.del("key");
```
内存级缓存(进程内、重启丢失),按插件隔离命名空间,适合限流计数、去重、临时状态。
### 7.8 加密与随机(无需权限)
| API | 说明 |
|---|---|
| `clv.crypto.bcrypt(pwd)` | 生成密码哈希 |
| `clv.crypto.verify(hash, pwd)` | 校验密码(→ bool) |
| `clv.crypto.hmacSha256(key, msg)` | HMAC-SHA256,返回十六进制 |
| `clv.crypto.randomHex(n)` | 随机十六进制串(n ≤ 64,默认 16 字节) |
| `clv.crypto.uuid()` | UUID v4 |
| `clv.crypto.base64Encode(s)` / `.base64Decode(s)` | Base64 互转 |
> `bcrypt` 是刻意设计的慢操作(百毫秒级),请计入超时预算。
### 7.9 邮件(权限 `mail`)
```js
clv.mail.send("to@example.com", "主题", "正文"); // 需要站点已配置 SMTP
```
### 7.10 媒体(权限 `media.write`)
```js
var url = clv.media.saveImage({ name: "a.png", data: base64str }); // → "/uploads/xxx.webp"
var u2 = clv.media.saveVideo({ name: "a.mp4", data: base64str });
```
图片经内核统一处理(自动转 WebP),单图上限 10MB。
---
## 8. 权限清单
| 权限 | 授予的能力 | 典型场景 |
|---|---|---|
| `db` | `clv.db.*`、`clv.table`(写仅限本插件表) | 存取插件自己的数据 |
| `db.admin` | 放开全库写(含内核表) | 数据修复类工具(谨慎) |
| `settings.read` | `clv.setting.get/all` | 读取站名、AI 配置 |
| `settings.write` | `clv.setting.set` | 修改站点设置 |
| `site.read` | 帖子 / 评论 / 用户 / 统计读取 | 排行榜、报表 |
| `site.write` | 发帖 / 改帖 / 删帖 / 评论 | 定时公告、内容机器人 |
| `net` | `clv.http.request` | 调用外部接口 |
| `net.local` | 允许访问内网 / 本机地址 | 本机 Ollama、内网服务 |
| `cache` | `clv.cache.*` | 限流、去重 |
| `mail` | `clv.mail.send` | 通知邮件 |
| `media.write` | `clv.media.*` | 生成/保存图片视频 |
---
## 9. 扩展点全集
### 9.1 UI Slot
`handler(data)` 返回 HTML 字符串;与 A 型的同名 `html` 钩子输出会拼接(A 型在前)。
| Slot 名 | 注入位置 | 生效页面 |
|---|---|---|
| `header_html` | `` 起始处 | 全站前台 |
| `footer_html` | 页脚(`app.js` 之前) | 全站前台 |
| `head.end` | `` 之前 | 全站前台 |
| `body.end` | `
每日签到
累计签到 {{.days}} 天 · 累计积分 {{.points}}
{{if .done}}
今天已经签到过了,明天再来~
{{else}}
{{end}}
查看签到榜 ·
返回首页
` 之前 | 全站前台 |
| `admin.head.end` | 后台 `` 之前 | 全站后台 |
| `admin.body.end` | 后台 `` 之前 | 全站后台 |
| `admin.sidebar.after` | 后台侧边栏之后 | 全站后台 |
| `admin.settings.after` | 设置页表单之后 | 后台设置页 |
| `admin.dashboard.after` | 仪表盘内容之后 | 后台仪表盘 |
| `post.detail.actions.after` | 详情页操作栏之后(卡片内) | 帖子详情页 |
| `post.detail.after` | 详情页帖子卡片之后(评论区之前) | 帖子详情页 |
| `comments.after` | 评论区之后 | 帖子详情页 |
| `compose.after` | 发帖表单之后 | 发帖页 |
| `profile.after` | 个人主页内容之后 | 个人主页 |
`data` 字段(轻量数据,避免整页开销):
| 字段 | 说明 |
|---|---|
| `page` | 当前页面模板名,如 `pg_detail`、`pg_index` |
| `path` | 当前 URL 路径 |
| `site` | 站点名称 |
| `topic` / `notice` | 话题名 / 公告(视页面而定) |
| `user` | `{ id, username }`(未登录则不存在) |
| `admin` | `{ id, username, role }`(后台页面) |
| `post` | `{ id, user_id, nickname }`(详情页 / 个人主页) |
### 9.2 事件
`handler(e)`,`e` 自动包含 `event` 与 `time`。事件异步执行,失败只记日志。
| 事件 | 触发时机 | 载荷 |
|---|---|---|
| `post_created` | 发帖成功(表单 / API) | `post_id`、`nickname`、`content` |
| `post_updated` | 帖子编辑保存 | `post_id`、`content` |
| `post_deleted` | 帖子删除 | `post_id` |
| `comment_created` | 评论成功 | `post_id`、`content` |
| `like_toggled` | 点赞 / 取消 | `post_id`、`liked`、`count` |
| `report_created` | 提交举报 | `post_id`、`reason` |
| `user_login` | 用户登录成功 | `user_id`、`username` |
| `user_registered` | 用户注册成功 | `user_id`、`username` |
自定义事件:`clv.emit(name, payload)` 广播,其他插件用 `clv.on(name, fn)` 订阅。
### 9.3 过滤器
`handler(value, extra?)`:返回 `undefined` / `null` 表示不改;抛错则放行原值。
| 过滤器 | 值类型 | 触发点 | extra |
|---|---|---|---|
| `filter.content` | string | 发帖 / 评论内容(A 型正则之后) | — |
| `filter.card` | map | 首页卡片数据(可改写 `nickname` / `content` / `topic` / `images` / `videos` / `likes` / `comment_count` / `badges`,`id` 与 `user_id` 不可改) | — |
| `filter.api.response` | map | 内核 JSON 接口成功响应 | — |
| `filter.compose.guard` | map | 发帖守卫:可改 `require` / `nickname` / `labels` / `message` | — |
| `filter.post.visible` | bool | 帖子是否可见 | `{ post_id }` |
| `filter.auth.login` | bool | 登录是否放行(返回 false 拒绝登录) | `{ user_id, username }` |
| `filter.theme.css` | string | 主题 CSS(可追加全站样式) | — |
| `filter.admin.stats` | map | 后台仪表盘数据 | — |
| `filter.settings.save` | map | 后台保存设置的键值表 | — |
```js
// 首页卡片:给指定用户加标识
clv.filter("filter.card", function (card) {
if (card.user_id === 1) card.badges = (card.badges || []).concat(["元老"]);
return card;
});
```
### 9.4 HTTP 中间件
见 5.9 节 `clv.middleware`。
### 9.5 后台菜单与页面
见 5.3 / 5.4 节。菜单渲染在后台侧边栏,按 `order` 排序,无权限的管理员看不到该项。
### 9.6 模板覆盖(最强的前端改造能力)
在插件目录下新建 `templates/`,放入**与内核模板同名**的文件,用 `{{define "..."}}`
重写对应片段。插件模板在启用 / 停用后自动重新解析,**后解析的覆盖先前的**。
```
templates/
└── front.html # 只写你要覆盖的 define,其余不受影响
```
```html
{{define "pg_detail"}}
自定义的详情页布局
{{nl2br .Content}}
{{.detail_after}}
{{end}}
```
可覆盖的内核片段(define 名):
| 文件 | 片段 |
|---|---|
| `layout.html` | `layout.html`(前台布局整体) |
| `admin_layout.html` | `admin_layout.html`(后台布局整体) |
| `front.html` | `pg_index`、`pg_detail`、`pg_compose`、`pg_edit`、`pg_login`、`pg_register`、`pg_profile` |
| `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` |
| `install.html` | `pg_install` |
可用模板函数:`nl2br`、`datefmt`、`snippet`、`substr0`、`plus1`、`minus1`;
页面数据(`.site`、`.user`、`.csrf` 及各 slot 变量如 `.body_end`)同样可用。
> 覆盖布局或页面会**整体替换**该片段,请以内核模板为蓝本复制修改,避免遗漏必要结构。
> 多个插件覆盖同一片段时,启用顺序决定最终生效者(后解析者胜)。
### 9.7 前端 API(`window.CLV`)
插件注入到页面的脚本(`clv.slot` 输出、模板覆盖、A 型 `html` 钩子)可以复用内核的前端能力:
| API | 说明 |
|---|---|
| `CLV.version` | 内核版本号字符串 |
| `CLV.csrf` | 当前请求的 CSRF 令牌(自己发请求时用) |
| `CLV.api(url, opts)` | `fetch` 封装:自动带 CSRF / 指纹 / XHR 头,返回 Promise(解析为 JSON) |
| `CLV.fingerprint()` | 浏览器指纹字符串 |
| `CLV.el(tag, className, text)` | 创建元素 |
| `CLV.toast(msg)` | 弹出提示 |
| `CLV.hooks.cardHtml` | 数组,`fn(html, post) => string`:返回内容插入首页卡片底部 |
| `CLV.hooks.cardEl` | 数组,`fn(cardEl, post)`:直接操作卡片元素 |
| `CLV.hooks.afterRender` | 数组,`fn(cardEl, post)`:卡片渲染完成后回调 |
```html
```
- `post` 字段与 `GET /api/v1/posts` 的返回一致(`id` / `nickname` / `content` / `topic` / `likes` / `comment_count` / `badges` …)
- 首页卡片由前端 JS 渲染且支持无限滚动,**`CLV.hooks.*` 是介入卡片的唯一途径**(服务端 Slot 只作用于首屏之外的服务端渲染部分)
- `window.CLV` 在页面 `
每日签到
` 中已预初始化,插件脚本放在任意位置都能安全注册
- 所有 hook 回调都被 `try/catch` 包裹,插件脚本报错不会影响页面其它功能
---
## 10. 完整示例:每日签到插件(全部源码内嵌,可直接复制)
下面是官方示例插件「每日签到」的**完整源码**:共 4 个文件,全部复制保存、打包上传即可运行,
**不需要访问任何源码仓库**。它覆盖了 B 型插件的核心能力:数据表、前台页面与接口、
后台管理页与菜单、UI 注入、定时任务、事件广播。
### 10.1 目录结构
```
signin/
├── plugin.json # 清单:kind=app、权限、配置项
├── main.js # 脚本入口:setup() 注册全部扩展点
├── page.html # 前台签到页(完整 HTML 页面)
└── admin.html # 后台管理页(HTML 片段)
```
### 10.2 plugin.json
```json
{
"kind": "app",
"name": "每日签到",
"slug": "signin",
"version": "1.0.0",
"author": "ClearLove",
"description": "应用型插件示例:自定义数据表 + 前台页面 + 后台管理页 + 定时任务 + UI 注入",
"requires": "2.1.0",
"runtime": {
"engine": "js",
"entry": "main.js",
"timeout_ms": 1000
},
"permissions": ["db", "settings.read", "site.read"],
"config": [
{
"key": "points",
"label": "每次签到积分",
"type": "number",
"default": "5",
"help": "签到一次获得的积分数量"
}
]
}
```
### 10.3 main.js
```js
// 每日签到 —— 应用型插件(kind=app)完整示例
// 能力:clv.table / clv.route / clv.view.template / clv.adminPage
// clv.adminMenu / clv.slot / clv.job / clv.emit
var T = clv.db.table("log"); // 实际表名 pl_signin_log(顶层只做定义)
function setup() {
// 1) 建表(幂等,启用时执行)
clv.table("log", {
user_id: "int",
day: "string",
points: "int",
created_at: "time"
}, { unique: [["user_id", "day"]], indexes: [["day"]] });
// 2) 前台路由(实际路径:/x/signin/、/x/signin/do、/x/signin/rank)
clv.route("GET", "/", "pageSignin", { auth: "user" });
clv.route("POST", "/do", "doSignin", { auth: "user" });
clv.route("GET", "/rank", "apiRank", { auth: "none", json: true });
// 3) 后台页面与菜单(实际路径:/admin/plugins/signin/)
clv.adminPage("/", "adminPage", { perm: "plugins" });
clv.adminMenu({ label: "签到管理", path: "/", perm: "plugins", order: 50 });
// 4) UI 注入:详情页显示作者签到天数 + 前台悬浮签到按钮
clv.slot("post.detail.after", "detailBadge");
clv.slot("body.end", "floatingEntry");
// 5) 定时任务:每天清理 90 天前的记录
clv.job("daily-clean", "24h", "onDaily");
}
function today() {
var d = new Date();
return d.getFullYear() + "-" +
("0" + (d.getMonth() + 1)).slice(-2) + "-" +
("0" + d.getDate()).slice(-2);
}
/* ---------------- 前台 ---------------- */
function pageSignin(ctx) {
var day = today();
var done = clv.db.get("SELECT id FROM " + T + " WHERE user_id=? AND day=?", ctx.userId, day);
var total = clv.db.get("SELECT IFNULL(SUM(points),0) AS points, COUNT(1) AS days FROM " + T + " WHERE user_id=?", ctx.userId);
return {
template: "page.html",
data: {
done: !!done,
points: total ? total.points : 0,
days: total ? total.days : 0,
gain: clv.plugin.config("points"),
csrf: ctx.csrf
}
};
}
function doSignin(ctx) {
var day = today();
if (clv.db.get("SELECT id FROM " + T + " WHERE user_id=? AND day=?", ctx.userId, day)) {
return { status: 400, json: { ok: false, msg: "今天已经签到过了" } };
}
var gain = Number(clv.plugin.config("points")) || 1;
clv.db.exec("INSERT INTO " + T + "(user_id,day,points,created_at) VALUES(?,?,?,?)",
ctx.userId, day, gain, new Date().toISOString());
clv.emit("signin.done", { user_id: ctx.userId, day: day, points: gain });
clv.log.info("用户 " + ctx.userId + " 签到成功,积分 +" + gain);
return { json: { ok: true, msg: "签到成功,积分 +" + gain } };
}
function apiRank(ctx) {
var rows = clv.db.query(
"SELECT user_id, SUM(points) AS points, COUNT(1) AS days FROM " + T +
" GROUP BY user_id ORDER BY points DESC LIMIT 20");
return { json: { ok: true, rank: rows } };
}
/* ---------------- UI 注入 ---------------- */
// 详情页:显示帖子作者的累计签到天数(服务端渲染,可直接查库)
function detailBadge(data) {
var post = data && data.post;
if (!post || !post.user_id) return "";
var row = clv.db.get("SELECT COUNT(1) AS n FROM " + T + " WHERE user_id=?", post.user_id);
var n = row ? row.n : 0;
if (!n) return "";
return '
' +
clv.view.escape(post.nickname || "该用户") + " 已累计签到 " + n + " 天
";
}
// 前台悬浮入口
function floatingEntry() {
return '' +
'' +
'';
}
/* ---------------- 后台 ---------------- */
function adminPage(ctx) {
var days = clv.db.query("SELECT day, COUNT(1) AS n FROM " + T + " GROUP BY day ORDER BY day DESC LIMIT 30");
var total = clv.db.get("SELECT COUNT(1) AS n, IFNULL(SUM(points),0) AS p FROM " + T);
return { template: "admin.html", data: { days: days, total: total } };
}
/* ---------------- 定时任务 ---------------- */
function onDaily(ctx) {
var before = new Date(Date.now() - 90 * 86400000).toISOString();
var r = clv.db.exec("DELETE FROM " + T + " WHERE created_at < ?", before);
clv.log.info("清理 90 天前的签到记录:" + (r ? r.rowsAffected : 0) + " 条");
}
```
要点解读:
- `unique: [["user_id","day"]]` 唯一索引保证同一用户同一天只有一条记录,业务层再判重一次,双保险
- 路由第 3 个参数传**函数名字符串**;`{ auth: "user" }` 未登录自动跳转登录页,`{ json: true }` 让错误以 JSON 返回
- `{ template: "page.html", data: {...} }` 用插件目录下的模板渲染 HTML;POST 表单必须带 `{{.csrf}}`,否则被 CSRF 拦截
- slot 处理器返回 HTML 字符串即注入页面;输出用户数据时用 `clv.view.escape` 转义
- `clv.emit` 广播自定义事件,其它插件可用 `clv.on("signin.done", fn)` 订阅
### 10.4 page.html(前台页面模板)
前台页面是**完整 HTML**:自带页面骨架并引用内核样式(`/static/app.css` 与主题 `/theme.css`),
表单提交到插件自己的路由,带 CSRF 隐藏域。
```html