feat: 发布 2.1.1 —— 插件系统完善、一键自更新、版本发布对接
55 个文件变更
+8594
-295
fangqihang1717@163.com
| •.gitignore | +6 -0 |
| •README.md | +16 -5 |
| •docs/PLUGIN-APP.md | +1001 -0 |
| •docs/PLUGIN-DECLARATIVE.md | +398 -0 |
| •docs/PLUGIN-V3.md | +952 -0 |
| •docs/PLUGIN.md | +179 -224 |
| •go.mod | +5 -0 |
| •go.sum | +8 -0 |
| •internal/config/config.go | +1 -1 |
| •internal/database/database.go | +1 -0 |
| •internal/handlers/admin.go | +100 -5 |
| •internal/handlers/api.go | +4 -4 |
| •internal/handlers/auth.go | +8 -0 |
| •internal/handlers/front.go | +32 -5 |
| •internal/handlers/handlers.go | +77 -4 |
| •internal/handlers/plugin_bridge.go | +192 -0 |
| •internal/handlers/selfupdate.go | +275 -0 |
| •internal/handlers/selfupdate_linux.go | +14 -0 |
| •internal/handlers/selfupdate_other.go | +11 -0 |
| •internal/handlers/store.go | +22 -3 |
| •internal/middleware/middleware.go | +76 -0 |
| •internal/plugin/admininfo.go | +108 -0 |
| •internal/plugin/api_db.go | +409 -0 |
| •internal/plugin/api_net.go | +370 -0 |
| •internal/plugin/api_site.go | +415 -0 |
| •internal/plugin/api_view.go | +351 -0 |
| •internal/plugin/app_test.go | +271 -0 |
| •internal/plugin/hub.go | +246 -0 |
| •internal/plugin/lifecycle.go | +176 -0 |
| •internal/plugin/manifest.go | +171 -0 |
| •internal/plugin/plugin.go | +81 -24 |
| •internal/plugin/registry.go | +341 -0 |
| •internal/plugin/router.go | +342 -0 |
| •internal/plugin/router_test.go | +85 -0 |
| •internal/plugin/runtime.go | +115 -0 |
| •internal/plugin/runtime_goja.go | +315 -0 |
| •internal/plugin/sandbox.go | +103 -0 |
| •internal/plugin/scheduler.go | +88 -0 |
| •internal/plugin/view.go | +32 -0 |
| •internal/util/util.go | +26 -0 |
| •main.go | +13 -0 |
| •main_test.go | +417 -0 |
| •plugins/ai-polish/main.js | +133 -0 |
| •plugins/ai-polish/plugin.json | +62 -0 |
| •plugins/ai-polish/polish.html | +126 -0 |
| •plugins/signin/admin.html | +22 -0 |
| •plugins/signin/main.js | +137 -0 |
| •plugins/signin/page.html | +31 -0 |
| •plugins/signin/plugin.json | +31 -0 |
| •web/static/app.css | +13 -2 |
| •web/static/app.js | +34 -0 |
| •web/templates/admin.html | +134 -18 |
| •web/templates/admin_layout.html | +9 -0 |
| •web/templates/front.html | +5 -0 |
| •web/templates/layout.html | +4 -0 |
变更内容
diff --git a/.gitignore b/.gitignore
index 20e6d7a..c8d32f7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,9 @@ cloud/
# 系统文件
.DS_Store
Thumbs.db
+
+# build artifacts
+clearlove-linux-amd64
+clearlove-cloud-linux-amd64
+plugins/*.zip
+plugins/*/*.zip
diff --git a/README.md b/README.md
index 30d73b8..a7b28fa 100644
--- a/README.md
+++ b/README.md
@@ -395,7 +395,10 @@ docker run -d --name clearlove --restart=always \
│ └── static/ # CSS / JS / 默认主题(内嵌)
└── docs/
├── THEME.md # 主题开发文档
- └── PLUGIN.md # 插件开发文档
+ ├── PLUGIN.md # 插件开发总览(两种形态、安装配置、权限与安全)
+ ├── PLUGIN-DECLARATIVE.md# 声明式插件开发指南(hooks 钩子)
+ ├── PLUGIN-APP.md # 应用型插件开发指南(JS 运行时与 API 参考)
+ └── PLUGIN-V3.md # 插件系统设计与实现说明(内核开发者)
```
运行时生成(**不在版本库中,升级时请勿删除**):
@@ -417,9 +420,15 @@ docker run -d --name clearlove --restart=always \
### 内置插件
-| 插件 | 说明 | 安装方式 |
-|---|---|---|
-| [背景音乐](plugins/bgm) | 管理员上传音乐后,访客打开站点即可听到背景音乐 | 后台「插件管理」上传 `dist/plugin-bgm.zip` |
+| 插件 | 形态 | 说明 | 安装方式 |
+|---|---|---|---|
+| [背景音乐](plugins/bgm) | 声明式 | 管理员上传音乐后,访客打开站点即可听到背景音乐 | 后台「插件管理」上传 `dist/plugin-bgm.zip` |
+| [AI 语句美化](plugins/ai-polish) | 应用型 | 发帖页一键调用站点已接入的 AI 模型润色帖子内容(含限流、撤销) | 后台「插件管理」上传 zip(`plugin.json` + `main.js` + `polish.html`) |
+| [每日签到](plugins/signin) | 应用型 | 自定义数据表 + 前台页面 + 后台菜单 + 定时任务的完整示例 | 可将 `plugins/signin/` 目录直接复制到 `data/plugins/` 后启用 |
+
+> 应用型插件(`"kind": "app"`)通过 `main.js` 注册路由、数据表、后台菜单与定时任务,
+> 能力不受声明式钩子限制;开发指南见 [docs/PLUGIN-APP.md](docs/PLUGIN-APP.md),
+> 两种形态的分工见 [docs/PLUGIN.md](docs/PLUGIN.md)。
**背景音乐**用法:上传 zip → 启用 → 在「插件设置」中上传音乐并保存。
@@ -505,7 +514,9 @@ docker run -d --name clearlove --restart=always \
## 📚 文档
- [主题开发](docs/THEME.md) —— 目录结构与 CSS 变量
-- [插件开发](docs/PLUGIN.md) —— 钩子规范与打包要求
+- [插件开发总览](docs/PLUGIN.md) —— 两种插件形态怎么选、打包安装与权限安全
+- [声明式插件指南](docs/PLUGIN-DECLARATIVE.md) —— `hooks` 钩子规范、示例与调试
+- [应用型插件指南](docs/PLUGIN-APP.md) —— JS 运行时、Host API 参考、Slot / 事件 / 过滤器清单
---
diff --git a/docs/PLUGIN-APP.md b/docs/PLUGIN-APP.md
new file mode 100644
index 0000000..a4bd099
--- /dev/null
+++ b/docs/PLUGIN-APP.md
@@ -0,0 +1,1001 @@
+# 应用型插件开发指南(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: "<h1>你好," + (ctx.userId ? "用户 " + ctx.userId : "访客") + "</h1>" };
+}
+```
+
+安装并启用后,访问 `/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-<hash>`) |
+| `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_<slug>_<name>`。
+
+```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/<slug><path>`。
+
+```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/<slug>/assets/<文件>` 访问(只读、防穿越)
+
+### `clv.adminPage(path, handler, opts?)`
+
+注册后台页面,实际路径 `/admin/plugins/<slug><path>`。
+
+```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 '<div class="tip">帖子 #' + post.id + '</div>';
+}
+```
+
+可用 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: "<h1>hi</h1>", 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` 渲染到页面,或读 `<meta name="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_<slug>_<name>`) |
+| `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_<slug>_*` 表
+- 内核表**可读不可写**(除非声明 `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)` | 转义后将换行转成 `<br>` |
+
+```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` | `<body>` 起始处 | 全站前台 |
+| `footer_html` | 页脚(`app.js` 之前) | 全站前台 |
+| `head.end` | `</head>` 之前 | 全站前台 |
+| `body.end` | `</body>` 之前 | 全站前台 |
+| `admin.head.end` | 后台 `</head>` 之前 | 全站后台 |
+| `admin.body.end` | 后台 `</body>` 之前 | 全站后台 |
+| `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"}}
+<article class="card detail">
+ <h1>自定义的详情页布局</h1>
+ <div class="detail-content">{{nl2br .Content}}</div>
+ {{.detail_after}}
+</article>
+{{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
+<script>
+(function () {
+ // 给热度高的卡片加一个角标
+ CLV.hooks.cardHtml.push(function (html, post) {
+ return post.likes > 10 ? '<span class="hot-tag">热门</span>' : '';
+ });
+ // 调用插件自己的接口
+ CLV.api('/x/demo/rank').then(function (res) { console.log(res); });
+})();
+</script>
+```
+
+- `post` 字段与 `GET /api/v1/posts` 的返回一致(`id` / `nickname` / `content` / `topic` / `likes` / `comment_count` / `badges` …)
+- 首页卡片由前端 JS 渲染且支持无限滚动,**`CLV.hooks.*` 是介入卡片的唯一途径**(服务端 Slot 只作用于首屏之外的服务端渲染部分)
+- `window.CLV` 在页面 `<head>` 中已预初始化,插件脚本放在任意位置都能安全注册
+- 所有 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 '<div class="card clv-signin-days" style="margin-top:12px;font-size:13px;opacity:.85">' +
+ clv.view.escape(post.nickname || "该用户") + " 已累计签到 " + n + " 天</div>";
+}
+
+// 前台悬浮入口
+function floatingEntry() {
+ return '<a class="clv-signin-fab" href="/x/signin/" title="每日签到">' +
+ '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2">' +
+ '<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/><path d="M9 12l2 2 4-4"/></svg></a>' +
+ '<style>.clv-signin-fab{position:fixed;left:18px;bottom:22px;z-index:900;width:46px;height:46px;' +
+ 'border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;' +
+ 'background:linear-gradient(135deg,var(--clv-accent,#ff7a9c),var(--clv-accent-2,#8a6cff));' +
+ 'box-shadow:0 8px 22px rgba(0,0,0,.22)}</style>';
+}
+
+/* ---------------- 后台 ---------------- */
+
+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
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>每日签到</title>
+<link rel="icon" href="/favicon.svg" type="image/svg+xml">
+<link rel="stylesheet" href="/static/app.css">
+<link rel="stylesheet" href="/theme.css">
+</head>
+<body>
+<main class="container">
+ <h1 class="page-title">每日签到</h1>
+ <div class="card">
+ <p>累计签到 <strong>{{.days}}</strong> 天 · 累计积分 <strong>{{.points}}</strong></p>
+ {{if .done}}
+ <p class="muted">今天已经签到过了,明天再来~</p>
+ {{else}}
+ <form method="post" action="/x/signin/do">
+ <input type="hidden" name="_csrf" value="{{.csrf}}">
+ <button class="btn btn-primary" type="submit">立即签到(+{{.gain}})</button>
+ </form>
+ {{end}}
+ <p style="margin-top:14px">
+ <a href="/x/signin/rank">查看签到榜</a> ·
+ <a href="/">返回首页</a>
+ </p>
+ </div>
+</main>
+</body>
+</html>
+```
+
+### 10.5 admin.html(后台管理页模板)
+
+后台页面只是 **HTML 片段**(由内核嵌进后台布局,不要写 `<html>` 骨架),
+通过 `{{range}}` / `{{else}}` 渲染 `data` 传来的查询结果:
+
+```html
+<h1 class="page-title">签到管理</h1>
+
+<div class="stat-grid">
+ <div class="stat card"><span class="stat-num">{{.total.n}}</span><span class="stat-label">累计签到次数</span></div>
+ <div class="stat card"><span class="stat-num">{{.total.p}}</span><span class="stat-label">累计发放积分</span></div>
+</div>
+
+<div class="card table-card">
+ <h3>最近 30 天</h3>
+ <div class="table-scroll">
+ <table class="table">
+ <thead><tr><th>日期</th><th>签到人数</th></tr></thead>
+ <tbody>
+ {{range .days}}
+ <tr><td>{{.day}}</td><td>{{.n}}</td></tr>
+ {{else}}
+ <tr><td colspan="2" class="empty">暂无数据</td></tr>
+ {{end}}
+ </tbody>
+ </table>
+ </div>
+</div>
+```
+
+### 10.6 打包、安装、验证
+
+打包(三平台命令详见 [PLUGIN.md](PLUGIN.md) 2.0 节):
+
+```bash
+zip -r ../plugin-signin.zip . # macOS / Linux
+tar -a -c -f ../plugin-signin.zip * # Windows 10/11 自带命令
+```
+
+安装验证清单:
+
+1. 后台「插件管理 → 上传并安装」选择 zip → 点「启用」→ 状态变为「运行中」
+2. 登录用户访问 `/x/signin/` → 签到页;签到后再次访问显示"已签到"
+3. 访问 `/x/signin/rank` → JSON 排行榜
+4. 后台侧边栏出现「签到管理」→ 可看到累计统计与近 30 天数据
+5. 任意帖子详情页 → 若作者签过到,评论区上方出现"已累计签到 N 天"
+6. 「插件设置」把"每次签到积分"改成 1 → 保存后再次签到,提示 `积分 +1`
+7. 排错查后台「插件日志」页(`load_error` / `job:daily-clean` 等记录),见第 11 节
+
+---
+
+## 11. 调试与 FAQ
+
+| 现象 | 原因与处理 |
+|---|---|
+| 后台状态「未运行」 | 看 `plugin_logs` 的 `load_error`:常见为 `setup()` 抛错、`runtime.entry` 文件缺失、JS 语法错误 |
+| 路由 404 | 确认前缀 `/x/<slug>/`、方法一致、插件处于「运行中」;带 `/*` 的通配只匹配后缀 |
+| 报"未声明权限 db" | 权限在启用时确定,补 `permissions` 后需**重新启用** |
+| 请求报 CSRF 校验失败 | 表单加 `{{.csrf}}`,JS 加 `X-CSRF-Token` 请求头 |
+| 脚本超时 | 默认 1500ms。慢操作(外部 API、bcrypt)请显式设置 `runtime.timeout_ms`(上限 30000) |
+| 改了 main.js 不生效 | 脚本驻留内存,需重新启用插件(开发时可反复点停用/启用) |
+| 定时任务没跑 | 周期最小 `10s`;插件需在「运行中」;执行记录在 `plugin_logs`(`job:<name>`) |
+| 插件自动被停用 | 触发熔断(连续 20 次失败),查日志修好后再启用 |
+| 模板覆盖不生效 | 文件需在 `templates/` 下且用 `{{define "同名片段"}}`;启用/停用插件后自动重载 |
+| slot 不输出 | 仅当页面渲染时执行;返回空串不输出;确认 slot 名称拼写与生效页面 |
+| 数据库表名写错 | 用 `clv.db.table("log")` 取实际表名,不要硬编码 `pl_xxx_log`(slug 变化时表名会变) |
+
+**开发建议**
+
+1. 先在本地装好站点,把插件目录直接放到 `data/plugins/<name>/`,改完重新启用即可调试
+2. 用 `clv.log.info()` 打点,后台「插件日志」页查看
+3. 复杂逻辑先写纯函数,再接入路由 / 事件,便于排查
+4. 涉及写操作务必用 `?` 参数占位,绝不拼接用户输入
+
+---
+
+## 12. 安全边界(诚实的说明)
+
+应用型插件运行在**进程内沙箱**中,受权限、配额、超时、熔断约束,但它是**可执行代码**——请只安装可信来源的插件。
+
+**做不到的事**:
+
+- 不能读取任意文件、不能执行系统命令、不能访问 OS
+- 不能绕过权限访问未声明的能力(`db` 之外的库、`net` 之外的网络)
+- 不能访问内网 / 本机(除非显式声明 `net.local`)
+- 不能替换内核已有页面的处理流程(模板覆盖可改外观,但改不了业务逻辑与路由)
+- 不能修改内核函数或内核 SQL
+
+**部署建议**:
+
+- 生产环境安装插件前,先在测试站点验证
+- 关注后台插件页的**失败计数**与 `plugin_logs`
+- 出问题时「停用」即可立刻止损(路由与任务随停用一起失效)
diff --git a/docs/PLUGIN-DECLARATIVE.md b/docs/PLUGIN-DECLARATIVE.md
new file mode 100644
index 0000000..051b13e
--- /dev/null
+++ b/docs/PLUGIN-DECLARATIVE.md
@@ -0,0 +1,398 @@
+# 声明式插件开发指南(A 型)
+
+> 本文档面向**声明式插件**:只有一份 `plugin.json`,用 `hooks` 描述"要往哪里注入什么",
+> 由内核解释执行。**无需写代码、无需编译**,适合轻量扩展。
+>
+> 需要独立页面、数据表、后台菜单、定时任务?请改用**应用型插件** → [PLUGIN-APP.md](PLUGIN-APP.md)。
+> 通用部分(打包、安装、配置项、权限、调试)见 [PLUGIN.md](PLUGIN.md)。
+
+---
+
+## 1. 插件包结构
+
+```
+your-plugin.zip
+├── plugin.json # 必需:插件清单(放在根目录或任意子目录均可)
+└── (可选)其他静态资源,如 @片段.html
+```
+
+打包要求:
+
+- 必须是 zip 格式,`plugin.json` 需为 **UTF-8 无 BOM**
+- 插件名(`name`)会被用作目录名,建议避免 `/`、`\` 等字符
+- 单包不超过 50MB
+- 各系统打包命令、开发调试循环(全程无需源码)见 [PLUGIN.md](PLUGIN.md) 2.0 节
+
+安装后在后台「插件管理」点击**启用**才生效。
+
+---
+
+## 2. plugin.json 完整字段
+
+```json
+{
+ "name": "插件名(必需,唯一)",
+ "version": "1.0.0",
+ "author": "作者",
+ "description": "一句话说明",
+ "requires": "2.0.0",
+ "config": [ ... ],
+ "hooks": [ ... ]
+}
+```
+
+| 字段 | 必填 | 说明 |
+|---|---|---|
+| `name` | ✅ | 插件名,同时作为安装目录名 |
+| `version` | | 版本号,展示用 |
+| `author` | | 作者名 |
+| `description` | | 简介,展示在插件列表与插件市场 |
+| `requires` | | 建议的最低表白墙版本(不满足时后台显示 ⚠,不阻止启用) |
+| `config` | | 配置项声明,见第 3 节 |
+| `hooks` | ✅ | 钩子列表,见第 4 节 |
+
+> 清单里**不要**写 `kind` 字段(那是应用型插件的标识);写了 `"kind": "app"` 会走另一套运行时。
+
+---
+
+## 3. 配置项(config)
+
+插件可以声明若干配置项,表白墙会**自动在后台「插件管理 → 插件设置」渲染表单**,
+管理员填写后立即生效,无需重新安装插件。
+
+```json
+"config": [
+ { "key": "nicknames", "label": "专享昵称", "type": "textarea",
+ "default": "官方,公告", "help": "多个昵称用逗号或换行分隔" },
+ { "key": "badge", "label": "帖子标识", "type": "text", "default": "官方" },
+ { "key": "enable", "label": "启用提示", "type": "switch", "default": "1" },
+ { "key": "position", "label": "显示位置", "type": "select",
+ "default": "bottom", "options": ["top", "bottom"] }
+]
+```
+
+| 字段 | 说明 |
+|---|---|
+| `key` | 字段名,供 `{config:key}` 引用 |
+| `label` | 后台表单里显示的名称 |
+| `type` | `text` / `textarea` / `switch`(值 `1`/`0`)/ `select` / `number` / `audio`(音频上传)/ `file` |
+| `default` | 未配置时的默认值 |
+| `help` | 表单下方的说明文字 |
+| `options` | 仅 `select` 使用,候选项数组 |
+
+### 上传型字段(audio / file)
+
+`type: "audio"` 会渲染成「试听 + 选择文件」的上传控件,保存后配置值即为文件的可访问地址
+(形如 `/uploads/plugins/<插件名>/audio-xxxxxxxx.mp3`),可直接用于 `{config:key}`:
+
+```json
+{ "key": "music", "label": "背景音乐文件", "type": "audio",
+ "default": "", "help": "支持 mp3 / m4a / aac / ogg / opus / wav / flac,单个文件不超过 20MB" }
+```
+
+- 文件名由服务端生成,不使用上传时的原始文件名
+- 重新上传或勾选「删除」会自动清理旧文件
+- 配置值会被清除引号与尖括号等危险字符 —— 因为它会被原样注入页面
+
+### 在钩子里引用配置
+
+任意字符串字段都支持 `{config:字段名}` 占位符,运行时替换为管理员填写的值:
+
+```json
+{ "hook": "compose_guard", "type": "guard",
+ "nicknames": "{config:nicknames}",
+ "label": "{config:badge}" }
+```
+
+```json
+{ "hook": "footer_html", "type": "html",
+ "html": "<audio src=\"{config:music}\" autoplay loop></audio>" }
+```
+
+配置值保存在 `settings` 表(键名 `plugin.<插件名>.<字段名>`),随站点一起备份。
+
+---
+
+## 4. 钩子一览
+
+`hooks` 是一个数组,每个元素至少包含 `hook` 与 `type`:
+
+| type | hook | 作用 | 关键字段 |
+|---|---|---|---|
+| `html` | `header_html` | 前台页面 `<head>` 后(body 起始处)注入 HTML | `html` |
+| `html` | `footer_html` | 前台页面页脚注入 HTML | `html` |
+| `html` | `admin_plugins_top` | 后台「插件管理」页顶部注入 HTML | `html` |
+| `filter` | `content_filter` | 用正则改写帖子 / 评论内容 | `match`、`replace` |
+| `http` | `post_created` | 发帖成功后回调 Webhook | `url` |
+| `http` | `comment_created` | 评论后回调 Webhook | `url` |
+| `http` | `report_created` | 举报后回调 Webhook | `url` |
+| `guard` | `compose_guard` | **发帖前置守卫**:命中昵称时强制验证后台账号 | `nicknames`、`require`、`label`、`message` |
+| `badge` | `post_badge` | 给指定昵称的帖子打上标识 | `nicknames`、`label` |
+
+> 内核遇到**不认识的钩子类型会跳过该钩子而不报错**,详见第 8 节"能力边界"。
+
+### 4.1 html:注入片段
+
+```json
+{ "hook": "footer_html", "type": "html",
+ "html": "<div class='daily-quote'>今天也要加油鸭</div>" }
+```
+
+注入内容会被原样输出到页面,因此**可以包含 `<style>` 与 `<script>`** —— 这也是插件实现
+自定义交互(弹窗、动效、统计)的主要方式。请勿注入恶意代码。
+
+三个注入点的位置:
+
+| hook | 渲染位置 | 适用 |
+|---|---|---|
+| `header_html` | `layout.html` 中 `<body>` 起始处 | 顶部公告条、全局横幅 |
+| `footer_html` | `layout.html` 页脚区域(脚本之前) | 悬浮按钮、播放器、统计脚本 |
+| `admin_plugins_top` | 后台插件管理页顶部 | 插件自检提示、批量操作入口 |
+
+> 需要更细的注入点(`head.end`、`body.end`、`post.detail.after`、`admin.sidebar.after` 等 14 处)?
+> 这些是**应用型插件**的 Slot(`clv.slot`),见 [PLUGIN-APP.md](PLUGIN-APP.md) 第 9.1 节。
+> 应用型插件注册同名 `footer_html` / `header_html` 时,输出会与 A 型片段拼接在一起。
+>
+> 注入到页面的脚本(不管是 A 型还是 B 型)都可以使用内核前端 API —— `CLV.api` 发请求、
+> `CLV.hooks.cardHtml` 给首页卡片追加内容等,见 [PLUGIN-APP.md](PLUGIN-APP.md) 第 9.7 节。
+
+### 4.2 片段较长时用 `@文件`
+
+在 JSON 里转义大段 HTML / JS 很痛苦。把片段放在插件目录下的独立文件里,
+`html` 字段写成 `@文件名` 即可,安装(zip)时会随插件一起落盘:
+
+```json
+{ "hook": "footer_html", "type": "html", "html": "@player.html" }
+```
+
+```
+插件 zip 根目录/
+├── plugin.json
+└── player.html ← 与 plugin.json 同级,整段 HTML/CSS/JS 原样放在这里
+```
+
+- 只读取插件目录下的**直接文件名**,`@../xxx` 这类路径会被无视
+- 片段文件缺失时该钩子被跳过(不会把 `@player.html` 原样输出到页面)
+- 官方「背景音乐」插件就是这么写的,可直接参考 `plugins/bgm/`
+
+### 4.3 filter:内容正则改写
+
+```json
+{ "hook": "content_filter", "type": "filter", "match": "广告|加群|代刷", "replace": "***" }
+```
+
+- `match` 是 Go 正则表达式;`replace` 支持 `$1` 捕获组引用
+- 对**表单发帖、API 发帖、评论**三类内容生效(均在 `StripHTML` 之后执行)
+- 规则有 30 秒缓存;插件启用后会自动热加载
+- 多个插件的规则按顺序依次应用
+- 应用型插件的 `filter.content` 会在此基础上继续链式处理
+
+### 4.4 http:事件 Webhook
+
+```json
+{ "hook": "post_created", "type": "http", "url": "https://example.com/hook" }
+```
+
+回调以 `POST` + JSON 发送,请求头带 `X-ClearLove-Event`,超时 10 秒,
+**失败只记录日志、不影响发帖**。回调体包含事件名、时间与业务字段:
+
+```json
+{ "event": "post_created", "post_id": 12, "nickname": "匿名", "content": "...", "time": "2026-09-12T15:00:00Z" }
+```
+
+| 事件 | 触发时机 | 载荷字段 |
+|---|---|---|
+| `post_created` | 发帖成功(表单 / API) | `post_id`、`nickname`、`content` |
+| `comment_created` | 评论成功 | `post_id`、`content` |
+| `report_created` | 提交举报 | `post_id`、`reason` |
+
+> 需要订阅更多事件(点赞、注册、登录、改帖、删帖…)或做进程内处理?用应用型插件的 `clv.on`。
+
+---
+
+## 5. guard:发帖守卫(需要验证后台账号)
+
+`compose_guard` 用于**防止他人冒充官方身份**:当发帖昵称命中名单时,
+表白墙会在提交前弹出「管理员身份验证」弹窗,要求输入后台管理员账号与密码,
+**服务端校验通过后才会发布**(不通过直接拒绝,无法绕过),并可给帖子打上标识。
+
+| 字段 | 说明 |
+|---|---|
+| `nicknames` | 触发的昵称名单,逗号 / 分号 / 换行分隔,支持 `{config:key}` |
+| `require` | `admin` = 必须验证后台管理员账号密码;留空表示仅打标识、不校验 |
+| `label` | 校验通过后写入帖子的标识文字(前台显示为昵称后的彩色徽章) |
+| `message` | 校验失败时返回给用户的提示语 |
+
+```json
+{
+ "hook": "compose_guard",
+ "type": "guard",
+ "nicknames": "官方,公告板",
+ "require": "admin",
+ "label": "官方",
+ "message": "该昵称为官方专享,请验证管理员账号后发布"
+}
+```
+
+行为说明:
+
+- 前台发帖页会在昵称命中时**自动弹出验证弹窗**(昵称名单由插件配置实时提供)
+- 表单发帖(`POST /compose`)与 API 发帖(`POST /api/v1/posts`)都会强制校验
+- API 发帖需在 JSON 中额外提供 `admin_user` 与 `admin_pass`
+- 同一 IP 连续失败 5 次会被锁定 10 分钟,防止暴力破解后台密码
+- 多个插件同时命中时,标识会合并、校验要求取并集
+- 通过验证的帖子会同时带上内置的「**管理员**」标识,插件自定义的 `label` 追加在后面,例如 `["管理员", "官方认证"]`
+
+### badge:帖子标识(无需验证)
+
+只想给某些昵称的帖子加个标记时使用,不涉及验证:
+
+```json
+{ "hook": "post_badge", "type": "badge", "nicknames": "小编,编辑", "label": "编辑" }
+```
+
+标识在发帖时写入帖子记录,**不随插件卸载而消失**(已发布的历史帖子仍保留标识)。
+
+---
+
+## 6. 完整示例:官方身份守卫插件
+
+`plugin.json`:
+
+```json
+{
+ "name": "官方身份守卫",
+ "version": "1.0.0",
+ "author": "yourname",
+ "description": "使用指定昵称发帖时必须验证后台管理员账号,帖子显示自定义标识,防止冒充官方身份",
+ "requires": "2.0.0",
+ "config": [
+ { "key": "nicknames", "label": "专享昵称", "type": "textarea", "default": "官方,公告",
+ "help": "使用这些昵称发帖时需验证后台账号,多个用逗号或换行分隔" },
+ { "key": "badge", "label": "帖子标识", "type": "text", "default": "官方",
+ "help": "验证通过的帖子在昵称后显示的徽章文字" },
+ { "key": "message", "label": "失败提示", "type": "text",
+ "default": "该昵称为官方专享,请验证管理员账号后发布" }
+ ],
+ "hooks": [
+ { "hook": "compose_guard", "type": "guard",
+ "nicknames": "{config:nicknames}",
+ "require": "admin",
+ "label": "{config:badge}",
+ "message": "{config:message}" },
+ { "hook": "footer_html", "type": "html",
+ "html": "<style>.post-badge{box-shadow:0 1px 6px rgba(255,123,169,.45)}</style>" }
+ ]
+}
+```
+
+打包与安装(三平台打包命令见 [PLUGIN.md](PLUGIN.md) 2.0 节):
+
+```bash
+cd your-plugin
+zip -r official-guard.zip . # macOS / Linux
+tar -a -c -f official-guard.zip * # Windows 10/11 自带命令
+```
+
+后台「插件管理 → 上传并安装」选择 zip → 点「启用」→ 在「插件设置」里填写专享昵称
+→ 用该昵称发帖,验证弹出管理员验证弹窗、通过后帖子带上标识。
+
+> 表白墙内置了「管理员专享昵称」(网站设置),覆盖最常见的官方身份保护场景;
+> 这个插件演示的是如何用插件实现同类能力,并支持自定义标识文字与多插件组合。
+
+### 6.1 另一个完整例子:每日一言(注入 + 配置 + 前端 JS)
+
+只需两个文件,演示 `footer_html` 注入、`{config:key}` 引用与注入脚本中的原生 JS:
+
+`plugin.json`:
+
+```json
+{
+ "name": "每日一言",
+ "version": "1.0.0",
+ "author": "yourname",
+ "description": "在每个前台页面底部随机展示一句语录,语录可在后台配置",
+ "config": [
+ { "key": "quotes", "label": "语录列表", "type": "textarea",
+ "default": "今天也要加油鸭\n慢慢来,比较快\n你笑起来真好看",
+ "help": "每行一句,访客每次刷新随机显示其中一条" }
+ ],
+ "hooks": [
+ { "hook": "footer_html", "type": "html", "html": "@quote.html" }
+ ]
+}
+```
+
+`quote.html`(与 plugin.json 同级,避免在 JSON 里转义大段内容,见 4.2 节):
+
+```html
+<script type="text/plain" id="clv-quotes">{config:quotes}</script>
+<div id="clv-quote" style="text-align:center;padding:10px;font-size:13px;opacity:.75"></div>
+<script>
+(function () {
+ var raw = document.getElementById("clv-quotes").textContent;
+ var list = raw.split(/\r?\n/).map(function (s) { return s.trim(); }).filter(Boolean);
+ var el = document.getElementById("clv-quote");
+ if (el && list.length) el.textContent = list[Math.floor(Math.random() * list.length)];
+})();
+</script>
+```
+
+说明:
+
+- `{config:quotes}` 在**服务端**就替换为管理员填写的文本(`@文件` 片段同样支持占位符展开)
+- 多行文本放进 `type="text/plain"` 的脚本块而不是 JS 字符串字面量——字符串里出现真实换行会导致语法错误,这是注入多行配置的推荐写法
+- 注入内容原样输出到页面,可以写 `<style>` / `<script>`;请只注入可信内容
+- 打包上传 → 启用 → 刷新前台首页,页脚出现随机语录;后台「插件设置」改语录即时生效
+
+---
+
+## 7. 能力边界:什么时候该换应用型插件
+
+声明式插件的钩子是**固定白名单**,它只能"往已有位置注入内容",不能新增功能载体:
+
+| 你想做 | A 型能做吗 | 应该用什么 |
+|---|---|---|
+| 往页面注入 HTML/JS/CSS | ✅ | `html` 钩子 |
+| 正则替换敏感词 | ✅ | `content_filter` |
+| 事件推送到外部系统 | ✅ | `http` 钩子 |
+| 给昵称加标识 / 要求验证身份 | ✅ | `guard` / `badge` |
+| 新增一个页面(如 /x/signin/) | ❌ | B 型 `clv.route` |
+| 存自己的数据(如表、记录) | ❌ | B 型 `clv.table` |
+| 后台管理菜单 / 管理页 | ❌ | B 型 `clv.adminMenu` / `clv.adminPage` |
+| 定时任务 | ❌ | B 型 `clv.job` |
+| 拦截 / 改写请求与响应 | ❌ | B 型 `clv.middleware` / `clv.filter` |
+| 读取或写入站点数据 | ❌ | B 型 `clv.post` / `clv.setting` 等 |
+
+### 从 A 型迁移到 B 型
+
+两种形态可以平滑过渡,常见对应关系:
+
+| A 型 | B 型等价做法 |
+|---|---|
+| `{ "hook": "footer_html", "html": "..." }` | `clv.slot("footer_html", fn)` 返回同一段 HTML(可动态生成) |
+| `{ "hook": "content_filter", "match": ..., "replace": ... }` | `clv.filter("filter.content", fn)` 用 JS 做更复杂的改写 |
+| `{ "hook": "post_created", "type": "http", "url": ... }` | `clv.on("post_created", fn)` 内用 `clv.http.request`(可加签名、重试、条件判断) |
+| `{ "hook": "compose_guard", ... }` | `clv.filter("filter.compose.guard", fn)` 动态决定是否需要验证 |
+| `{config:key}` 占位符 | `clv.plugin.config("key")` |
+| 配置项声明 `config` | 完全相同,照搬即可 |
+
+迁移不必推倒重来:在 `plugin.json` 里加 `"kind": "app"`、`runtime` 与 `permissions`,
+保留原有 `hooks` 数组,再补 `main.js` —— 内核会同时处理两者(参考 `plugins/ai-polish/`)。
+
+---
+
+## 8. 调试建议
+
+| 场景 | 建议 |
+|---|---|
+| **插件装了但完全没反应** | ① 是否已在「插件管理」点**启用**(安装 ≠ 启用);② 后台列表里插件名下是否出现 ⚠ —— 有提示说明正在运行的内核版本过旧,不认识插件的钩子类型,**升级二进制即可**;③ 浏览器打开页面查看源码,确认注入片段是否存在 |
+| 改了 plugin.json 不生效 | A 型清单有 5 秒短缓存;`content_filter` 规则有 30 秒缓存 |
+| 配置改了没生效 | 配置存在 `settings` 表并带内存缓存,保存后立即刷新,无需重启 |
+| 钩子没触发 | 确认插件已启用;`guard` / `badge` 依赖昵称精确匹配(忽略大小写) |
+| 前台注入的 JS 报错 | 打开浏览器控制台查看;注入内容原样输出,注意转义引号 |
+| 保存 plugin.json 后插件不出现 | 确认文件为 **UTF-8 无 BOM**,可用 `jq . plugin.json` 验证 |
+| 想看当前插件与配置 | 后台「插件管理」页可查看全部插件、状态、兼容性提示与配置表单 |
+
+> 兼容性原则:插件使用了当前内核不支持的钩子类型时,**该钩子会被跳过而不是报错**,
+> 因此请务必留意后台插件列表中的 ⚠ 兼容性提示。
diff --git a/docs/PLUGIN-V3.md b/docs/PLUGIN-V3.md
new file mode 100644
index 0000000..e8f6573
--- /dev/null
+++ b/docs/PLUGIN-V3.md
@@ -0,0 +1,952 @@
+# ClearLove 插件系统设计与实现说明
+
+> 定位:**内核开发者文档**(架构决策、扩展点接入方式与实现细节)。
+> 插件作者请阅读:[PLUGIN.md](PLUGIN.md)(总览)、[PLUGIN-DECLARATIVE.md](PLUGIN-DECLARATIVE.md)(声明式)、
+> [PLUGIN-APP.md](PLUGIN-APP.md)(应用型)。
+>
+> 状态:**P0 + P1 已实现并通过测试**(声明式 + 应用型双形态均已落地)
+> 目标:在现有声明式插件之外,**新增一种"应用型插件"形态**,让新功能可以完全通过插件实现,不必改内核源码、不必重新编译整套系统
+> 硬约束:**现有声明式插件 100% 向后兼容**;保持 `CGO_ENABLED=0` 单二进制部署
+> 实际落地情况与差异见文末「[15. 实现状态](#15-实现状态本仓库当前进度)」
+
+---
+
+## 1. 现状与问题(升级动因)
+
+当前插件系统是"声明式"的:`plugin.json` + 5 类钩子(`html` / `filter` / `http` / `guard` / `badge`),由内核解释执行。它能覆盖"注入一段 HTML / 正则改写 / 回调 Webhook / 昵称守卫"这类简单需求,但**无法承载一个新功能**。
+
+经全仓库排查,具体缺口如下:
+
+| 缺口 | 事实依据 |
+|---|---|
+| 不能注册页面/路由 | `main.go` 的 `buildMux()` 硬编码全部路由,插件无法挂载任何 URL |
+| 不能建表存数据 | 插件配置只能塞进 `settings` 表(键名 `plugin.<name>.<key>`),无法拥有独立数据结构 |
+| 不能注册后台菜单 | `admin_layout.html:18-45` 侧边栏为硬编码 HTML,插件只能往"插件管理页"顶部注入一块 |
+| 不能定时执行 | 只有内核自带的 AI 巡查定时器(`ai.StartPatrol`),插件无调度能力 |
+| 前台 UI 几乎无法介入 | 首页卡片由前端 JS 拼接(`app.js:119-201` `cardEl`),服务端模板只有 3 个有效注入点 |
+| 只能"加"不能"拦" | `html` 钩子只能追加内容,无法拦截/改写请求;`filter` 只能正则替换字符串 |
+| 后台完全没有注入点 | `admin_layout.html` 不引用 `header_html`/`footer_html`;唯一后台注入点是 `admin_plugins_top` |
+| 死钩子与不对称 | 注释里的 `card_extra` 无渲染位置;`content_filter` 在表单发帖 `POST /compose`(`front.go:395`)路径缺失 |
+| 前端无扩展接口 | `app.js` 是 IIFE,插件无法复用 `api()`/`cardEl()`/`fingerprint()` |
+
+**结论**:声明式钩子做不成"万物插件化"。需要引入可执行的插件运行时,并且以**独立的第二种插件形态**存在,而不是继续往 `hooks` 里加类型。
+
+---
+
+## 2. 目标与边界
+
+### 2.1 验收标准
+
+以下 5 个功能**全部只用应用型插件实现**(不改一行内核源码、不重新编译),即视为达标:
+
+1. **签到/积分系统**:自定义数据表 + 前台页面 + 前台展示位 + 后台管理菜单 + 每日重置任务
+2. **活动抽奖页**:独立路由 + 自有数据 + 后台配置 + 前端交互
+3. **第三方登录/外部同步**:拦截请求 + 调用外部 HTTP + 读写站点数据
+4. **内容增强**:改写帖子正文、往卡片/详情页追加 UI、往后台设置页追加配置卡片
+5. **数据报表**:读取站点统计 + 生成后台图表页
+
+### 2.2 边界(诚实的说明)
+
+- 不能修改内核已有页面的布局与流程(P3 的"模板覆盖"能力可逼近,但仍有边界)
+- 不能 patch 内核函数、不能替换内核 SQL(脚本运行在能力白名单内)
+- 不允许插件无限制访问 OS/文件系统/内网(安全底线,见第 8 节)
+- 不引入 CGO,不破坏"单二进制 + 交叉编译"
+
+---
+
+## 3. 两种插件形态
+
+这是本方案的核心:**两种形态并列存在,互不干扰,各司其职。**
+
+```
+ ┌──────────────────────────────┐
+ │ data/plugins/<name>/ │
+ │ plugin.json │
+ └──────────────┬───────────────┘
+ │ 读取 kind 字段判定
+ ┌─────────────────┴─────────────────┐
+ │ │
+ ┌────────▼─────────┐ ┌──────────▼──────────┐
+ │ 形态 A 声明式 │ │ 形态 B 应用型 │
+ │ (现有,保留) │ │ (新增,本次重点) │
+ ├──────────────────┤ ├─────────────────────┤
+ │ 无代码 │ │ main.js 脚本 │
+ │ hooks 白名单 │ │ 运行时注册一切 │
+ │ 内核解释执行 │ │ 内核调用脚本 │
+ │ 5 类固定能力 │ │ 能力不受白名单限制 │
+ └──────────────────┘ └─────────────────────┘
+ │ │
+ └─────────────────┬─────────────────┘
+ │
+ ┌──────────────▼───────────────┐
+ │ Extension Registry 注册表 │
+ │ Event / Filter / Middleware │
+ │ Slot / Route / Job / Table │
+ └──────────────┬───────────────┘
+ │
+ ┌──────────────▼───────────────┐
+ │ 内核:handlers / templates │
+ │ middleware / web │
+ └──────────────────────────────┘
+```
+
+### 3.1 形态 A:声明式插件(现有,原样保留)
+
+- 清单:`plugin.json`(**无 `kind` 字段**,即现在的格式)
+- 能力:`hooks` 声明的 5 类钩子(html / filter / http / guard / badge)
+- 特点:零代码、可审计、最适合市场分发;改配置即生效
+- 定位:**轻量增强**(注入脚本、敏感词替换、Webhook 通知、身份守卫)
+- 行为:**与今天完全一致**,不做任何改动
+
+### 3.2 形态 B:应用型插件(新增)
+
+- 清单:`plugin.json` + `"kind": "app"` + `runtime.entry`(如 `main.js`)
+- 入口:脚本中的 `setup()` 函数,在其中**调用注册 API 注册一切能力**
+- 能力:路由、后台页面与菜单、UI 注入、事件订阅、过滤器、请求中间件、定时任务、自定义数据表、HTTP 出网、媒体、邮件、缓存……
+- **不使用声明式钩子**:`hooks` 字段可以完全不写;B 型的能力来源是运行时注册,不受 5 类钩子白名单约束
+- 定位:**承载完整功能**(签到、抽奖、积分、报表、第三方对接)
+
+### 3.3 对比与选型
+
+| 维度 | A 型 声明式 | B 型 应用型 |
+|---|---|---|
+| 清单 | `plugin.json` | `plugin.json` + `"kind":"app"` |
+| 是否需要代码 | 否 | 是(`main.js`) |
+| 能力来源 | `hooks` 白名单 | 运行时注册 API |
+| 新增路由/页面 | 不支持 | 支持 |
+| 自定义数据表 | 不支持 | 支持 |
+| 后台菜单 | 不支持 | 支持 |
+| 定时任务 | 不支持 | 支持 |
+| 请求拦截/改写 | 不支持 | 支持(中间件) |
+| UI 注入点 | 3 个(header/footer/插件页顶部) | 15+ 个 slot,可动态渲染 |
+| 分发安全等级 | 最高(无代码) | 需权限声明 + 沙箱 + 审计 |
+| 典型场景 | 加一段 JS、敏感词、Webhook、昵称守卫 | 签到、抽奖、积分商城、数据看板 |
+
+**选型建议**:能用 A 型表达的继续用 A 型(更安全、更简单);一旦需要存数据、开页面、做后台,直接用 B 型,不要在 A 型的 `hooks` 上继续打补丁。
+
+### 3.4 关键设计原则
+
+1. **兼容优先**:A 型的清单格式、目录结构、设置键、公开函数签名、行为全部不变。
+2. **职责分离**:A 型能力来自"清单声明",B 型能力来自"脚本注册",两套机制不混用。
+3. **权限必须声明式**:`permissions` 只能在清单里声明(详见 5.3)。
+4. **能力白名单**:B 型插件默认最小权限,安装时向管理员展示。
+5. **热路径零开销**:没有插件注册扩展点时,调用链开销为一次长度判断。
+6. **故障隔离**:任何插件出错不得影响站点,可熔断、可一键停用。
+
+---
+
+## 4. 清单规范
+
+### 4.1 A 型清单(= 现有 v1,不变)
+
+```json
+{
+ "name": "bgm",
+ "version": "1.0.0",
+ "author": "ClearLove",
+ "description": "背景音乐",
+ "requires": "2.0.0",
+ "config": [ { "key": "music", "label": "音乐文件", "type": "audio" } ],
+ "hooks": [ { "hook": "footer_html", "type": "html", "html": "@player.html" } ]
+}
+```
+
+现有 3 个插件(bgm / 官方身份守卫 / rules-gate)继续按此格式工作。
+
+### 4.2 B 型清单
+
+```json
+{
+ "kind": "app",
+ "name": "每日签到",
+ "slug": "signin",
+ "version": "1.0.0",
+ "author": "yourname",
+ "description": "签到、积分与签到榜",
+ "requires": "2.1.0",
+
+ "runtime": {
+ "engine": "js",
+ "entry": "main.js",
+ "timeout_ms": 1000
+ },
+
+ "permissions": ["db", "settings.read"],
+
+ "config": [
+ { "key": "points", "label": "每次签到积分", "type": "number", "default": "5" }
+ ]
+}
+```
+
+B 型清单**只有元信息、权限、运行时、配置表单**四类字段。路由、表、菜单、任务、钩子全部在脚本里注册——这是与 A 型最本质的区别。
+
+字段说明:
+
+| 字段 | 必填 | 说明 |
+|---|---|---|
+| `kind` | 是 | 固定 `"app"`,用于与 A 型区分 |
+| `name` / `slug` / `version` / `author` / `description` / `requires` | 同 A 型 | `slug` 规则见 4.4 |
+| `runtime.engine` | 是 | 引擎标识,当前 `"js"` |
+| `runtime.entry` | 是 | 入口脚本(相对插件目录,禁止路径穿越) |
+| `runtime.timeout_ms` | 否 | 单次调用超时,默认 1500,硬上限 5000 |
+| `permissions` | 是 | 能力白名单(至少 `[]`),见第 8 节 |
+| `config` | 否 | 配置项声明,后台自动渲染表单(复用 A 型的现有实现) |
+
+### 4.3 形态识别与兼容规则
+
+| 清单情况 | 判定 | 内核行为 |
+|---|---|---|
+| 无 `kind` 字段 | A 型 | 走现有全部逻辑,**行为不变** |
+| `"kind": "declarative"` | A 型 | 同上(显式写法) |
+| `"kind": "app"` | B 型 | 加载脚本运行时 + 执行 `setup()` 注册 |
+| `kind: "app"` 但缺少 `runtime.entry` 或脚本加载失败 | 加载失败 | 插件标记为"错误",不影响其它插件与站点 |
+| B 型同时写了 `hooks` | — | 兼容执行(便于从 A 型平滑迁移),但**文档不推荐**:B 型应使用 `clv.slot()` 等效实现 |
+| 未知 `kind` 值 | 拒绝加载 | 列表页提示"不支持的插件类型" |
+| `permissions` 缺失但脚本调用了受限 API | 运行时拒绝 | 报错并写审计日志 |
+
+### 4.4 slug 规则
+
+现有插件名允许中文(如"官方身份守卫"),但 URL 与表名需要安全标识,因此引入 `slug`:
+
+- 显式声明优先;缺省时由 `name` 归一化(小写、非 `[a-z0-9_-]` 字符转 `-`、去除首尾 `-`)
+- 归一化后为空则回退为 `plugin-<sha1(name)[:8]>`
+- slug 在站点内唯一(冲突时安装失败并提示)
+- 派生规则:前台路由前缀 `/x/<slug>/`、后台 `/admin/plugins/<slug>/`、数据表前缀 `pl_<slug>_`
+
+---
+
+## 5. B 型插件:运行时注册体系
+
+### 5.1 加载与执行模型
+
+```
+启用插件
+ │
+ ├─ 1. 读取 plugin.json → 校验 kind / permissions / slug
+ ├─ 2. 创建脚本 Runtime + 串行 worker(每插件一个)
+ ├─ 3. 注入 Host API(受 permissions 约束)
+ ├─ 4. 执行 main.js 顶层代码(只做定义,不注册)
+ ├─ 5. 调用 setup() ← 所有扩展点在此注册
+ ├─ 6. 生成注册表快照(此后不可变,直到禁用/重载)
+ └─ 7. 站点开始按注册表分发请求与事件
+
+禁用 / 重载
+ └─ 丢弃快照 + 关闭 worker(数据表保留)
+```
+
+关键约束:
+
+- `setup()` 必须**幂等**(重载/重新启用会重复执行)
+- `setup()` 内不得做耗时操作(内核对其施加更短的超时,如 3s)
+- 注册的 handler 用**函数名**引用(便于调试与热重载),不用闭包引用
+- `setup()` 未注册的能力,内核不调用
+- worker 串行执行:同一插件的所有回调(路由、事件、任务)排队执行,**脚本内无需考虑并发**
+
+### 5.2 注册 API 全集
+
+```js
+function setup() {
+ // ── 数据表(运行时建表,幂等)──────────────────────
+ clv.table("log", { // 实际表名 pl_<slug>_log
+ user_id: "int", day: "string", created_at: "time"
+ }, { unique: [["user_id", "day"]], indexes: [["day"]] });
+
+ // ── 路由(path 相对 /x/<slug>)─────────────────────
+ clv.route("GET", "/", "pageSignin", { auth: "user" });
+ clv.route("POST", "/do", "doSignin", { auth: "user" });
+ clv.route("GET", "/rank","apiRank", { auth: "none", json: true });
+
+ // ── 后台页面与菜单(path 相对 /admin/plugins/<slug>)─
+ clv.adminPage("/", "adminPage", { perm: "plugins" });
+ clv.adminMenu({ label: "签到管理", path: "/", perm: "plugins", order: 50 });
+
+ // ── UI 注入(slot 详见 6.4)───────────────────────
+ clv.slot("post.card.after", "cardBadge"); // 也可直接传函数
+ clv.slot("admin.settings.after", function (d) { return "<div class='card'>...</div>"; });
+
+ // ── 事件与过滤器 ──────────────────────────────────
+ clv.on("post.created", "onPostCreated");
+ clv.filter("filter.content", 10, "censor"); // 优先级升序
+
+ // ── 请求中间件 ────────────────────────────────────
+ clv.middleware("http.before", function (req) {
+ if (req.path === "/x/signin") { /* 可改写 req 或短路返回响应 */ }
+ });
+
+ // ── 定时任务 ──────────────────────────────────────
+ clv.job("daily-reset", "24h", "onDaily");
+}
+```
+
+`clv.*` 注册项与 A 型 `hooks` 的对应关系(迁移参考):
+
+| A 型写法 | B 型等价写法 |
+|---|---|
+| `{"hook":"footer_html","type":"html","html":"..."}` | `clv.slot("footer_html", function(d){ return "..."; })` |
+| `{"hook":"content_filter","type":"filter","match":"..","replace":".."}` | `clv.filter("filter.content", 100, function(s){ return s.replace(/../g,"..") })` |
+| `{"hook":"post_created","type":"http","url":"..."}` | `clv.on("post.created", function(e){ clv.http.request({...}) })` |
+| `{"hook":"compose_guard","type":"guard",...}` | `clv.filter("filter.compose.guard", 50, function(g){ ... })` |
+| `{"hook":"post_badge","type":"badge",...}` | `clv.slot("post.card.badge", ...)` 或 `clv.filter("filter.card", ...)` |
+
+### 5.3 为什么"注册是命令式、权限是声明式"
+
+这是一个必须坚持的安全边界:
+
+- **权限只能声明式**:如果允许脚本运行时申请权限(`clv.requestPermission("db")`),那么插件在获得权限前就已经执行了任意代码,权限控制形同虚设。因此 `permissions` 必须在清单中声明,内核在**加载脚本之前**完成校验,并展示给管理员。
+- **能力注册必须命令式**:注册逻辑本身是代码(可条件判断、可循环、可按配置裁剪),写在清单里会重新落回"白名单枚举"的老路,永远追不上需求。
+
+一句话:**清单决定"能碰什么",脚本决定"要做什么"。**
+
+### 5.4 生命周期回调
+
+B 型插件可选实现以下入口,内核在对应时机调用:
+
+| 回调 | 时机 | 典型用途 |
+|---|---|---|
+| `setup()` | 启用/加载时,**必选** | 注册全部扩展点 |
+| `onInstall(ctx)` | 安装完成后、首次 setup 前 | 初始化默认数据 |
+| `onEnable(ctx)` / `onDisable(ctx)` | 启用 / 禁用时 | 资源准备与清理 |
+| `onUninstall(ctx)` | 卸载前(管理员选择删除数据时) | 清理数据 |
+| `onUpgrade(ctx, { from, to })` | 检测到版本变更 | 数据迁移、补列 |
+
+### 5.5 handler 上下文与返回值约定
+
+所有 handler(路由、slot、事件、过滤器、任务)统一接收 `ctx` 参数:
+
+```js
+function pageSignin(ctx) {
+ // ctx = {
+ // req: { method, path, query, form, json, headers, ip, fingerprint, files },
+ // params:{ id: "12" }, // 路由路径参数
+ // userId, adminId, isAdmin, // 已解析的身份(未登录为 0 / false)
+ // csrf, // 当前 CSRF 令牌
+ // plugin:{ name, slug, dir, config(k) }
+ // }
+
+ return { template: "page.html", data: { days: 3 } };
+}
+```
+
+返回值约定(内核统一处理):
+
+| 返回 | 行为 |
+|---|---|
+| `{ json: {...} }` | JSON 响应(可带 `status`、`headers`) |
+| `{ body: "<h1>x</h1>" }` | HTML 响应 |
+| `{ template: "page.html", data: {...} }` | 用插件目录下 `html/template` 模板渲染(可选 `layout: "layout.html"` 套用站点布局) |
+| `{ redirect: "/x/signin/rank" }` | 302 跳转 |
+| `{ file: "download.csv" }` | 插件目录下文件下载 |
+| `undefined` / `null` | 该 slot 无输出 / 该事件处理完成 |
+| 抛异常 | 记录日志;路由返回 500,事件/过滤器按"放行原值"处理 |
+
+---
+
+## 6. 扩展点全集(两种形态共用)
+
+同一个注册表支撑 A、B 两型:A 型由清单转译成注册项,B 型由 `setup()` 注册。
+
+### 6.1 Event(事件:只观察,不可改数据)
+
+| 事件名 | 触发点 | A 型 | B 型 |
+|---|---|---|---|
+| `post.created` / `post.updated` / `post.deleted` | `front.go` ComposeSubmit / PostEditSave / PostDelete | `http` 钩子 | `clv.on` |
+| `post.status_changed` | `admin.go` AdminReportHandle、`ai.go` applyVerdict | 同上 | `clv.on` |
+| `comment.created` / `comment.deleted` | `front.go` addComment、deletePostCascade | 同上 | `clv.on` |
+| `like.toggled` | `front.go` toggleLike | 同上 | `clv.on` |
+| `report.created` / `report.handled` | `api.go` APIReport / `admin.go` AdminReportHandle | 同上 | `clv.on` |
+| `user.registered` / `user.login` / `user.logout` | `auth.go` | 同上 | `clv.on` |
+| `admin.login` | `admin.go` AdminLoginSubmit | 同上 | `clv.on` |
+| `media.uploaded` | `util.SaveImage/SaveVideo` 调用方 | 同上 | `clv.on` |
+| `plugin.installed` / `enabled` / `disabled` / `uninstalled` | 生命周期 | 同上 | `clv.on` |
+| `ai.verdict` | `ai.go` applyVerdict | 同上 | `clv.on` |
+
+事件 payload 沿用现有 Webhook 风格(含 `event` 与 `time`),A 型 `http` 钩子与 B 型 `clv.on` 收到同一份数据。
+
+### 6.2 Filter(过滤器:链式改写,带优先级)
+
+| 过滤器 | 位置 | 签名 |
+|---|---|---|
+| `filter.content` | 发帖/评论入库前(**含补齐 `front.go:395` 缺失调用**) | `string → string` |
+| `filter.nickname` | 发帖/评论前 | `string → string` |
+| `filter.card` | `fetchCards` 每张 Card 组装后 | `Card → Card` |
+| `filter.post.visible` | 详情页可见性判定 | `bool → bool`(任一插件 veto 即不可见) |
+| `filter.api.response` | `okJSON` 前 | `map → map` |
+| `filter.theme.css` | `ThemeCSS` 输出前 | `string → string` |
+| `filter.admin.stats` | `AdminDashboard` 统计 | `map → map` |
+| `filter.settings.save` | `AdminSettingsSave` 写入前 | `map → map` |
+| `filter.auth.login` | `LoginSubmit` 校验通过后 | `bool → bool`(可拒绝登录) |
+| `filter.compose.guard` | `guardCompose` 内 | `guardResult → guardResult` |
+
+约定:按 `priority` 升序串联(A 型固定 100);任一环节抛错 → 记日志并**放行原值**,不因插件故障阻断站点。
+
+### 6.3 Middleware(中间件:可拦截 HTTP)
+
+| 钩子 | 位置 | 能力 |
+|---|---|---|
+| `http.before` | `middleware.Use` 中、CSRF 校验**之前** | 观察/改写请求、设置响应头、**短路返回**(自定义鉴权、维护模式) |
+| `http.after` | 响应写出后 | 观察状态码/耗时 |
+| `http.route` | 路由未命中时 | 自定义路由匹配(兜底给插件路由) |
+
+### 6.4 Slot(UI 注入点)
+
+| Slot | 位置 | A 型 | B 型 |
+|---|---|---|---|
+| `head.end` | `layout.html` `</head>` 前 | 新增 | `clv.slot` |
+| `body.start` | `layout.html` `<body>` 后 | 新增 | `clv.slot` |
+| `body.end` | `layout.html` `</body>` 前 | 新增 | `clv.slot` |
+| `header_html` / `footer_html` | 现有位置 | 支持 | `clv.slot` |
+| `post.card.after` | 首页卡片(前端渲染,见 6.5) | 新增 | `clv.slot` |
+| `post.detail.after` | 详情页正文后 | 新增 | `clv.slot` |
+| `post.detail.actions.after` | 详情页操作区后 | 新增 | `clv.slot` |
+| `comment.item.after` | 每条评论后 | 新增 | `clv.slot` |
+| `compose.form.after` | 发帖表单后 | 新增 | `clv.slot` |
+| `profile.after` | 个人主页 | 新增 | `clv.slot` |
+| `admin.head.end` / `admin.body.end` | 后台布局 | 新增 | `clv.slot` |
+| `admin.sidebar.after` | 后台侧边栏底部 | 新增 | `clv.slot` |
+| `admin.dashboard.after` | 仪表盘 | 新增 | `clv.slot` |
+| `admin.settings.after` | 设置页 | 新增 | `clv.slot` |
+| `admin_plugins_top` | 现有位置 | 支持 | `clv.slot` |
+
+与 A 型的关键区别:B 型的 slot handler **接收数据、可动态生成 HTML**(例如按当前帖子内容、当前用户权限决定输出),而 A 型只是静态片段。
+
+### 6.5 前端扩展(`window.CLV`)
+
+首页卡片由 JS 渲染,服务端 slot 覆盖不到,需前端 hook:
+
+```js
+// 内核暴露(app.js 末尾)
+window.CLV = {
+ version, csrf,
+ api, // fetch 封装(自动带 CSRF / 指纹 / X-Requested-With)
+ el, toast, fingerprint,
+ hooks: {
+ cardHtml: [], // (html, post) => html
+ cardEl: [], // (el, post) => el | void
+ afterRender: [] // (root) => void
+ }
+};
+```
+
+`cardEl()` 返回前依次调用 `hooks.cardHtml`,渲染后触发 `afterRender`。B 型插件在 `body.end` slot 注入脚本注册:
+
+```js
+CLV.hooks.cardHtml.push(function (html, p) {
+ if (p.likes > 10) html += '<span class="hot">热帖</span>';
+ return html;
+});
+```
+
+### 6.6 Route(路由)
+
+| 类型 | 实际挂载 | 鉴权 |
+|---|---|---|
+| 前台页面 | `/x/<slug>/<path>` | `auth`:`none` / `user` / `admin` |
+| 前台 API | 同上,`json: true` | 同上 |
+| 后台页面 | `/admin/plugins/<slug>/<path>` | 强制 `requireAdmin` + `perm` 声明 |
+| 静态资源 | `/x/<slug>/assets/*` | 自动映射插件目录下 `assets/`(无鉴权,只读) |
+
+`path` 是相对路径,内核统一加前缀,从根本上避免插件间路由冲突。
+
+### 6.7 Job(定时任务)
+
+`clv.job(name, every, handler)`,`every` 支持 `30s` / `10m` / `1h` / `24h`。
+
+内核调度器(新增 `internal/plugin/scheduler.go`,复用 `ai.StartPatrol` 模式):
+- 启动时注册,`time.Ticker` 驱动,单任务互斥
+- 触发时同样走沙箱(超时、权限、审计)
+- 执行结果写入 `plugin_logs`,后台可查
+
+### 6.8 Data(自定义数据表)
+
+`clv.table(name, columns, opts)`(也可在 A 型清单中声明,供"无脚本但需建表"的场景):
+
+类型映射由方言层翻译:
+
+| 声明类型 | SQLite | MySQL |
+|---|---|---|
+| `int` | `INTEGER` | `INT` |
+| `bigint` | `INTEGER` | `BIGINT` |
+| `string` | `TEXT` | `VARCHAR(255)` |
+| `text` | `TEXT` | `MEDIUMTEXT` |
+| `bool` | `INTEGER` | `TINYINT` |
+| `time` | `TEXT`(RFC3339) | `VARCHAR(40)`(与内核时间字段一致) |
+| `json` | `TEXT` | `MEDIUMTEXT` |
+
+- 表名统一 `pl_<slug>_` 前缀,插件无需关心实际名称(用 `clv.db.table("log")` 取)
+- 卸载时**默认保留数据**,后台询问"是否同时删除插件数据表"
+- 写操作默认只允许 `pl_` 前缀表;内核表只读(`db.admin` 权限可放开,需管理员显式授权)
+
+---
+
+## 7. Host API 清单
+
+B 型插件可用的全部能力(按 `permissions` 逐项授权):
+
+```js
+// ── 元信息与配置 ────────────────────────────────
+clv.plugin.name / slug / dir / version
+clv.plugin.config(key) // 读配置(等价于现有 plugin.<name>.<key>)
+clv.plugin.configs()
+clv.version // 内核版本
+
+// ── 数据 ───────────────────────────────────────
+clv.db.query(sql, ...args) // -> [{...}](行数受配额约束)
+clv.db.get(sql, ...args) // -> {...} | null
+clv.db.exec(sql, ...args) // -> { rowsAffected, lastId }
+clv.db.tx(fn) // 事务;fn 抛错自动回滚
+clv.db.dialect() // "sqlite" | "mysql"
+clv.db.table("log") // -> "pl_signin_log"
+
+// ── 站点高层数据(推荐,替代手写 SQL) ─────────────
+clv.post.list({ before, limit, topic })
+clv.post.get(id) / create({...}) / update(id, {...}) / remove(id)
+clv.comment.list(postId) / create({...}) / remove(id)
+clv.user.get(id) / byName(name) / current(ctx)
+clv.setting.get(k) / set(k, v) / all()
+clv.stats.overview() / today()
+
+// ── 视图 ───────────────────────────────────────
+clv.view.template("page.html", data) // 渲染插件目录下模板
+clv.view.escape(s) / nl2br(s)
+
+// ── 其它能力 ───────────────────────────────────
+clv.http.request({ method, url, headers, body, timeout_ms, max_bytes })
+clv.media.saveImage(file) / saveVideo(file)
+clv.cache.get(k) / set(k, v, ttlSec) / del(k)
+clv.mail.send(to, subject, body)
+clv.crypto.bcrypt(pwd) / verify(hash, pwd) / hmacSha256(key, msg) / randomHex(n) / uuid()
+clv.log.info(...) / warn(...) / error(...) // 写 plugin_logs
+clv.json.encode(v) / decode(s)
+```
+
+> 同步风格:goja 不支持 `async/await` 语义,所有 Host API 均为同步调用(HTTP 请求由内核阻塞执行并受超时保护)。
+>
+> 引擎选型:默认 **goja**(纯 Go、无 CGO,插件作者最熟悉 JS,现有 `rules-gate` 已是 JS);抽象出 `Runtime` 接口,后续可加 gopher-lua / wazero。goja 的 `Runtime` 非 goroutine-safe,因此每个插件配一条串行 worker 队列,天然串行 + 可超时中断(`vm.Interrupt`)。
+
+---
+
+## 8. 沙箱、权限与治理
+
+| 维度 | 措施 |
+|---|---|
+| 执行时间 | 每次调用 `context` 超时,默认 1500ms,清单可调,**硬上限 30s**(支持 AI 调用等耗时操作);`setup()` 另设 5s 上限 |
+| 资源配额 | 中断计数(防死循环);单次 HTTP 响应 ≤ 2MB;`db.query` 行数 ≤ 1000 |
+| 权限白名单 | 每个 Host API 入口校验清单 `permissions`,未声明直接抛错(错误提示"如何声明") |
+| 数据隔离 | 默认仅插件表可写;内核表只读;`db.admin` 需管理员在后台显式授权 |
+| SSRF 防护 | `clv.http` 需 `net` 权限;默认拒绝私有/环回/链路本地 IP;声明 `net.local` 后放行(用于本机 Ollama 等内网 AI 服务) |
+| 审计 | 新增 `plugin_logs` 表(`slug`/`action`/`detail`/`duration_ms`/`created_at`),后台可查 |
+| 故障隔离 | 所有回调 `recover`;单插件异常不影响其它插件与主流程;连续 20 次异常自动禁用(熔断) |
+| 总开关 | 网站设置新增 `plugins_runtime_enabled`(默认开),可一键停用全部 B 型插件用于故障处置 |
+| 分发安全 | 市场下载保留 SHA256 校验;P3 增加"官方签名"标记(展示用途,非强制) |
+
+权限清单(`permissions` 取值):
+
+| 权限 | 覆盖能力 |
+|---|---|
+| `db` | `clv.db.*`(仅插件表可写) |
+| `db.admin` | 可写内核表(危险,需管理员二次确认) |
+| `settings.read` / `settings.write` | `clv.setting.get` / `set` |
+| `site.read` | `clv.post.*` / `clv.comment.*` / `clv.user.*`(只读) |
+| `site.write` | 同上 + 写操作 |
+| `net` | `clv.http.request`(默认拒绝内网地址) |
+| `net.local` | 额外允许访问内网/本机地址(如 `http://127.0.0.1:11434` 的 Ollama) |
+| `media.write` | `clv.media.saveImage/saveVideo` |
+| `mail` | `clv.mail.send` |
+| `cache` | `clv.cache.*` |
+| `log` | `clv.log.*` |
+
+---
+
+## 9. 内核改造清单(文件级)
+
+| 文件 | 改动 |
+|---|---|
+| `main.go` | 挂载插件路由捕获器(`/x/`、`/admin/plugins/`);启动 `plugin.Boot()`(加载 A/B 两型)与 `plugin.StartScheduler()` |
+| `internal/database/database.go` | 新增 `plugin_logs` 表;暴露 `Dialect()` 供插件层使用 |
+| `internal/plugin/plugin.go` | 保留全部现有函数签名(变为兼容 shim,内部转调新注册表) |
+| `internal/plugin/manifest.go`(新) | A/B 型识别、slug 归一化、权限校验、兼容性检查 |
+| `internal/plugin/registry.go`(新) | 扩展点注册表(Event/Filter/Middleware/Slot/Route/Job/Table) |
+| `internal/plugin/hub.go`(新) | 事件总线 + 过滤器链(优先级、panic 隔离、空链零开销) |
+| `internal/plugin/runtime.go` + `runtime_goja.go`(新) | 引擎抽象 + goja 实现 + 每插件串行 worker |
+| `internal/plugin/api_*.go`(新) | 注册 API + 能力 API 分组实现 |
+| `internal/plugin/sandbox.go`(新) | 超时、配额、权限、审计、熔断 |
+| `internal/plugin/lifecycle.go`(新) | 安装/启用/禁用/卸载/升级 + 建表 |
+| `internal/plugin/scheduler.go`(新) | 定时任务调度 |
+| `internal/plugin/adminui.go`(新) | 后台菜单注册表 + 插件页面分发 |
+| `internal/plugin/view.go`(新) | `ViewProvider` 反向注入接口,解决包循环依赖(见下) |
+| `internal/handlers/handlers.go` | `Render` 注入 slot 数据;初始化时向 plugin 注册 `ViewProvider` |
+| `internal/handlers/front.go` | 插入第 6.1/6.2 节的事件与过滤器调用点(约 12 处),并补齐 `ComposeSubmit` 的 `filter.content` |
+| `internal/handlers/api.go` | 同上(约 8 处) |
+| `internal/handlers/admin.go` | 侧边栏菜单数据化;插件管理页增加插件类型徽章、「运行日志」「插件页面」入口 |
+| `internal/middleware/middleware.go` | CSRF 之前插入 `http.before` 链;响应后触发 `http.after` |
+| `web/templates/layout.html` | 新增 `head.end` / `body.start` / `body.end` 三个 slot |
+| `web/templates/admin_layout.html` | 侧边栏改为 `{{range .pluginMenus}}`;新增 `admin.head.end` / `admin.sidebar.after` slot |
+| `web/templates/front.html` / `admin.html` | 新增各内容级 slot(详见 6.4) |
+| `web/static/app.js` | 末尾暴露 `window.CLV`;`cardEl` 接入 `hooks.cardHtml` / `afterRender` |
+| `docs/PLUGIN.md` | 重构为**插件开发总览**;A 型细节拆至 `PLUGIN-DECLARATIVE.md`,B 型细节拆至 `PLUGIN-APP.md` |
+
+**关键架构约束:包循环依赖**
+
+`handlers` → `plugin`(现有依赖方向)。插件页面渲染需要模板能力(在 `handlers`),但 `plugin` 不能 import `handlers`。解决方式:**反向注入接口**。
+
+```go
+// internal/plugin/view.go
+type ViewProvider interface {
+ RenderPage(w http.ResponseWriter, r *http.Request, layout, page string, data map[string]any)
+ RenderTemplate(fsys fs.FS, name string, data any) (string, error)
+ CSRF(r *http.Request) string
+ CurrentUserID(r *http.Request) int64
+ CurrentAdminID(r *http.Request) int64
+}
+
+var viewProvider ViewProvider
+func SetViewProvider(p ViewProvider) { viewProvider = p }
+```
+
+`handlers` 在 `SetupTemplates()` 之后调用 `plugin.SetViewProvider(...)` 完成装配。
+
+---
+
+## 10. 兼容性策略与回归清单
+
+### 10.1 兼容保证
+
+| 项 | 保证 |
+|---|---|
+| 现有 3 个插件包(bgm / 官方身份守卫 / rules-gate) | 不改动即可安装、启用、生效 |
+| A 型 `plugin.json` 格式 | 完全支持,行为不变(无 `kind` 字段即 A 型) |
+| `data/plugins/<name>/` 目录结构 | 不变(slug 只影响路由/表名,不影响目录) |
+| `enabled_plugins` 设置键 | 不变 |
+| `plugin.<name>.<key>` 配置键 | 不变 |
+| 现有 HTTP 路由与模板输出 | 不变(slot 为空时输出空字符串) |
+| 现有 Go 函数签名(`CallHTML`/`CallFilter`/`Notify`/`CheckComposeGuard`/`NicknameBadges`/`GuardNicknames`/`List`/`Install`/`Remove`/`SetEnabled`/`GetConfig`/`SaveConfig`/`ConfigOf`) | 全部保留 |
+| 编译 | `CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build` 必须通过 |
+| 二进制体积 | 记录基线;goja 预计 +3~6MB,可留 build tag(`-tags no_plugin_runtime`)裁剪 |
+
+### 10.2 回归测试清单
+
+1. 三只现有 A 型插件:安装 → 启用 → 功能验证(背景音乐、守卫拦截、守则弹窗)
+2. 插件配置保存、素材上传/替换/清理
+3. 插件商店安装(SHA256 校验路径)
+4. 前台发帖 / 评论 / 点赞 / 举报 / 编辑 / 删除全流程
+5. 后台全部页面可打开、菜单完整(含 B 型插件注册的菜单)
+6. SQLite 与 MySQL 双库各跑一遍
+7. 无插件时(`data/plugins` 为空)站点行为与性能不变
+8. B 型插件死循环 / 抛异常:站点不阻塞、自动熔断
+9. A 型与 B 型**同时启用**:互不干扰,注册表合并正确
+
+---
+
+## 11. 分阶段实施计划
+
+> 每个阶段结束都必须满足:可编译、可部署、现有功能无回归。
+
+### P0 —— 运行时地基(不影响任何现有行为)
+
+1. `manifest.go`:A/B 型识别 + slug 归一化 + 权限字段解析
+2. `runtime.go` + `runtime_goja.go`:引擎抽象 + goja + 每插件串行 worker
+3. `sandbox.go`:超时、权限校验、panic 隔离、`plugin_logs`
+4. 注册 API 第一批:`clv.table` / `clv.on` / `clv.filter` / `clv.log` / `clv.plugin.config` / `clv.setting.*` / `clv.db.*` / `clv.json`
+5. `lifecycle.go`:`setup()` 调用 + 建表 + 生命周期回调
+6. 示例插件 `plugins/demo-app/`(B 型:建表 + 事件 + 过滤器)
+7. 引入依赖:`github.com/dop251/goja`(纯 Go,无 CGO)
+
+**验收**:A 型插件行为零变化;B 型示例插件能建表、订阅 `post.created`、过滤 `filter.content`。
+
+### P1 —— 页面与路由扩展
+
+1. `registry.go` + `view.go`(ViewProvider 反向注入)
+2. `clv.route` / `clv.adminPage` / `clv.adminMenu`:前台 `/x/<slug>/`、后台 `/admin/plugins/<slug>/`
+3. 后台侧边栏数据化 + 插件类型徽章
+4. Slot 全量落地(前台 + 后台,约 15 个)+ `clv.slot`
+5. `window.CLV` 前端 API + 卡片 hook
+6. 示例插件 `plugins/signin/`(签到:表 + 前台页 + 后台菜单 + 卡片展示)
+
+**验收**:第 2.1 节验收功能 1、2 达成。
+
+### P2 —— 深度集成
+
+1. 事件/过滤器全清单接入(第 6.1 / 6.2 节全部)
+2. `clv.middleware`:`http.before` / `http.after` / `http.route`
+3. `clv.job`:定时任务调度 + 后台可视化
+4. `clv.http` / `clv.media` / `clv.cache` / `clv.mail` / `clv.crypto`
+5. `clv.view.template`:插件模板文件渲染
+6. 示例插件:内容摘要(filter)、活动抽奖页(route + table)
+
+**验收**:第 2.1 节验收功能 3、4、5 达成。
+
+### P3 —— 治理与生态
+
+1. 权限管理 UI(安装时向管理员展示并授权)+ 后台"脚本插件总开关"
+2. `plugin_logs` 审计页 + 熔断策略可配置
+3. 插件市场支持 B 型包(权限/引擎/体积字段展示 + 审核流程)
+4. **模板覆盖**:插件目录 `templates/` 覆盖内核同名片段(最接近"任意改前端"的能力)
+5. 内置功能插件化试点(如"公告增强"用 B 型插件重写)
+6. 开发工具:`clearlove plugin new/pack/check` 命令行 + B 型开发文档
+
+---
+
+## 12. 示例:签到应用插件(B 型,完全不用声明式钩子)
+
+目录结构:
+
+```
+plugins/signin/
+├── plugin.json
+├── main.js
+└── page.html
+```
+
+`plugin.json`:
+
+```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"],
+ "config": [
+ { "key": "points", "label": "每次签到积分", "type": "number", "default": "5" }
+ ]
+}
+```
+
+`main.js`:
+
+```js
+var T = clv.db.table("log"); // pl_signin_log
+
+function setup() {
+ clv.table("log", {
+ user_id: "int", day: "string", points: "int", created_at: "time"
+ }, { unique: [["user_id", "day"]] });
+
+ clv.route("GET", "/", "pageSignin", { auth: "user" });
+ clv.route("POST", "/do", "doSignin", { auth: "user" });
+ clv.route("GET", "/rank", "apiRank", { auth: "none", json: true });
+
+ clv.adminPage("/", "adminPage", { perm: "plugins" });
+ clv.adminMenu({ label: "签到管理", path: "/", perm: "plugins", order: 50 });
+
+ clv.slot("post.card.after", "cardBadge");
+ clv.job("monthly-reset", "24h", "onDaily");
+}
+
+function pageSignin(ctx) {
+ var today = new Date().toISOString().slice(0, 10);
+ var done = clv.db.get("SELECT id FROM " + T + " WHERE user_id=? AND day=?", ctx.userId, today);
+ var total = clv.db.get("SELECT IFNULL(SUM(points),0) AS n FROM " + T + " WHERE user_id=?", ctx.userId);
+ return { template: "page.html", data: { done: !!done, total: total.n, points: clv.plugin.config("points") } };
+}
+
+function doSignin(ctx) {
+ var today = new Date().toISOString().slice(0, 10);
+ if (clv.db.get("SELECT id FROM " + T + " WHERE user_id=? AND day=?", ctx.userId, today)) {
+ return { status: 400, json: { ok: false, msg: "今天已经签到过了" } };
+ }
+ clv.db.exec("INSERT INTO " + T + "(user_id,day,points,created_at) VALUES(?,?,?,?)",
+ ctx.userId, today, Number(clv.plugin.config("points")), new Date().toISOString());
+ return { json: { ok: true, msg: "签到成功" } };
+}
+
+function apiRank(ctx) {
+ return { json: clv.db.query(
+ "SELECT user_id, SUM(points) AS n FROM " + T + " GROUP BY user_id ORDER BY n DESC LIMIT 20") };
+}
+
+function cardBadge(post) {
+ return post.user_id ? "<span class='signin-dot' title='活跃用户'></span>" : "";
+}
+
+function adminPage(ctx) {
+ var rows = clv.db.query("SELECT day, COUNT(1) AS n FROM " + T + " GROUP BY day ORDER BY day DESC LIMIT 30");
+ return { template: "admin.html", data: { rows: rows } };
+}
+
+function onDaily(ctx) {
+ clv.log.info("签到插件每日任务执行");
+}
+```
+
+对比 A 型:该插件**没有 `hooks` 字段**,全部能力(表、路由、后台菜单、slot、任务)均通过 `setup()` 中的注册 API 获得——这正是新增的第二种插件形式。
+
+---
+
+## 13. 风险与取舍
+
+| 风险 | 影响 | 对策 |
+|---|---|---|
+| 二进制体积增大(goja) | 单文件约 +3~6MB | 可接受;预留 build tag 裁剪 |
+| 脚本 = 代码执行 | 安全面扩大 | 权限声明(清单)+ 配额 + 审计 + 熔断 + 总开关 + 市场审核 |
+| 双形态并存 | 概念与维护成本上升 | 职责边界清晰(A 型=声明、B 型=脚本),管理页用类型徽章区分;两型共用同一注册表,内核代码不翻倍 |
+| 热路径性能 | 每张卡片/每请求都过插件链 | 注册表快照 + 空链零开销 + 每请求只取一次快照 |
+| MySQL/SQLite 方言 | 插件 SQL 可能只兼容一种 | `clv.db.dialect()` + 高层 `clv.post.*` API + 建表由内核翻译 |
+| goja 不支持 async/await | 插件作者写法受限 | 全部同步 API + 文档明确说明 |
+| 插件质量参差 | 拖垮站点 | 熔断 + 后台一键停用 + 运行日志可见 |
+| "万物"预期过高 | 内核布局/流程无法替换 | 文档明确边界;P3 模板覆盖补齐前端自定义能力 |
+
+---
+
+## 14. 附录:工作量估算
+
+| 阶段 | 主要产出 | 预估 |
+|---|---|---|
+| P0 | B 型运行时 + 沙箱 + 注册 API 第一批 + 示例 | 5~8 人日 |
+| P1 | 路由 + 后台菜单 + slot + 前端 API + 签到样例 | 5~8 人日 |
+| P2 | 全量事件/过滤器接入 + 中间件 + jobs + 能力 API | 6~10 人日 |
+| P3 | 治理 + 市场 + 模板覆盖 + 工具链 | 5~8 人日 |
+
+> 建议:**P0 + P1 为一个交付批次**(约 2 周),完成后即可支撑绝大多数"新功能类插件";P2 补齐深度集成,P3 面向生态与治理。
+
+---
+
+## 15. 实现状态(本仓库当前进度)
+
+> 本节记录实际落地情况;与上方设计稿如有差异,以本节为准。
+
+### 15.1 已实现
+
+运行时内核 `internal/plugin/`:
+
+| 文件 | 职责 |
+|---|---|
+| `manifest.go` | A/B 型识别、slug 归一化、权限声明解析、超时策略 |
+| `registry.go` | 扩展点注册表 + 不可变快照(热路径无锁读取) |
+| `runtime.go` | 每插件串行 Worker(goja 非线程安全,全部调用排队执行) |
+| `runtime_goja.go` | goja 装配、函数调用、值转换、日志 / JSON / 配置 API |
+| `api_db.go` | `clv.db.*` + `clv.table`(写表前缀白名单、行数配额、双库方言翻译) |
+| `api_site.go` | `clv.setting` / `clv.post` / `clv.comment` / `clv.user` / `clv.stats` |
+| `api_view.go` | `clv.route` / `adminPage` / `adminMenu` / `slot` / `on` / `emit` / `filter` / `job` / `middleware` / `view` |
+| `api_net.go` | `clv.http` / `cache` / `crypto` / `mail` / `media`(SSRF 防护、配额) |
+| `hub.go` | 事件总线、过滤器链、Slot 渲染、中间件执行 |
+| `router.go` | `/x/<slug>/...` 与 `/admin/plugins/<slug>/...` 分发、鉴权、返回值约定 |
+| `lifecycle.go` | `Boot` / `LoadApp` / `UnloadApp` / `ReloadApp`、setup 调用、生命周期回调 |
+| `scheduler.go` | 定时任务调度(最小周期 10s,运行留痕) |
+| `sandbox.go` | 权限校验、`plugin_logs` 审计、失败熔断(连续 20 次自动禁用) |
+| `view.go` | `ViewProvider` 反向注入接口(解决包循环依赖) |
+
+内核接入:
+
+- `main.go`:`plugin.Boot()` + `/x/` 与 `/admin/plugins/` 路由挂载
+- `middleware`:`http.before` 中间件链(可短路响应,绕过 CSRF)
+- `handlers`:Slot 注入、后台插件菜单数据化、HTML 渲染器接入
+- 事件触发点已接入:`post.created` / `comment.created` / `report.created` / `like.toggled` / `post.updated` / `post.deleted` / `user.login` / `user.registered`
+- `handlers/plugin_bridge.go`:模板与会话能力的反向注入
+- `database`:新增 `plugin_logs` 表
+- 前端:`window.CLV`(`api` / `el` / `toast` / `fingerprint` + `cardHtml` / `cardEl` / `afterRender` 钩子)
+- 模板 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`
+- 后台「插件管理」显示「声明式 / 应用型」类型与运行状态、插件路由前缀
+
+示例与测试:
+
+- `plugins/signin/`:应用型插件示例(数据表 + 前台页 + 后台页 + 菜单 + Slot + 定时任务,**完全不使用声明式钩子**)
+- `internal/plugin/app_test.go`:生命周期 / 权限拒绝 / 超时中断 / 声明式回归
+- `internal/plugin/router_test.go`:鉴权与返回值约定
+- `main_test.go`:走完整中间件与路由链的端到端集成测试
+
+### 15.2 已实现 API
+
+```js
+// 元信息与配置
+clv.version · clv.plugin.{name,slug,dir,version,config,configs}
+// 日志与序列化
+clv.log.{info,warn,error} · clv.json.{encode,decode}
+// 数据
+clv.table(name, columns, opts) · clv.db.{query,get,exec,tx,dialect,table}
+// 站点数据
+clv.setting.{get,set,all} · clv.post.{list,get,create,update,remove}
+clv.comment.{list,create,remove} · clv.user.{get,byName,current} · clv.stats.{overview,today}
+// 注册
+clv.route · clv.adminPage · clv.adminMenu · clv.slot · clv.on · clv.emit
+clv.filter(name, priority, handler) · clv.job(name, every, handler) · clv.middleware(phase, handler)
+// 视图
+clv.view.{template,escape,nl2br}
+// 其它
+clv.http.request · clv.cache.{get,set,del} · clv.crypto.{bcrypt,verify,hmacSha256,randomHex,uuid,base64Encode,base64Decode}
+clv.mail.send · clv.media.{saveImage,saveVideo}
+```
+
+权限取值:`db` / `db.admin` / `settings.read` / `settings.write` / `site.read` / `site.write` / `net` / `net.local` / `media.write` / `mail` / `cache` / `log`。
+
+**内置应用型插件示例**
+
+| 插件 | 说明 | 用到的能力 |
+|---|---|---|
+| `plugins/signin/` | 每日签到:数据表 + 前台页 + 后台页 + 菜单 + 定时任务 | `db` / `cache` / 路由 / slot / job |
+| `plugins/ai-polish/` | AI 语句美化:发帖页按钮,调用站点已接入的 AI 模型润色内容 | 声明式 hook 静态注入 + 路由 + `settings.read` / `net` / `net.local` / `cache` + 限流 |
+
+> `ai-polish` 演示了一个实用组合:**UI 用声明式 hook 静态注入**(页面渲染零开销),
+> **功能走应用型路由**(只在用户点击时才进入脚本运行时)——避免把耗时逻辑放在 slot 渲染里阻塞页面。
+
+### 15.3 已实现(第二轮补齐)
+
+| 能力 | 说明 |
+|---|---|
+| `http.after` 中间件 | 包装 ResponseWriter 记录状态码,请求结束后回调插件(含耗时、IP、指纹) |
+| 过滤器点(全量) | `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` |
+| 模板覆盖 | 启用插件的 `templates/*.html` 覆盖内核同名片段(Go 模板后解析优先);插件启停后自动重建模板集 |
+| 后台运行日志页 | `/admin/plugin-logs`,支持按插件筛选、一键清空;侧边栏「插件日志」入口 |
+| 定时任务可视化 | 插件管理页展示各插件的任务名与周期 |
+| 权限审阅 | 插件卡片展示已声明权限,启用前 `data-confirm` 提示;插件商店展示形态与权限 |
+| 运行时重载 | 插件管理页「重载」按钮(改脚本后无需禁用再启用) |
+| `clv.media.saveVideo` | 与 `saveImage` 对称,支持 base64 写入(扩展名白名单 + 70MB 上限) |
+| 前端 Slot 补齐 | `post.detail.actions.after`、`comments.after`、`compose.after`、`profile.after`,加上此前的 `head.end` / `body.end` / 后台各 Slot |
+
+### 15.4 云端(插件社区)双形态支持
+
+云端服务(`cloud/`,独立 Go 项目,部署于 clearlove.kazx.top)已完成升级,与客户端打通"上传 → 审核 → 市场 → 安装"全链路:
+
+**数据层**
+- `plugins` / `plugin_versions` 新增 `kind`、`permissions`、`runtime`、`requires`、`has_templates`、`hooks_count` 列(启动时自动 `ALTER TABLE` 迁移,幂等)
+- 历史数据自动回退为 `declarative`;筛选使用 `NULLIF` 兼容空串,新旧数据一视同仁
+
+**上传与校验**(开发者后台上传 zip 时)
+- 自动识别形态:无 `kind` 字段 → 声明式;`kind=app` → 应用型
+- 应用型强制校验:`runtime.engine`(当前仅 js)、`runtime.entry`(禁路径穿越)、`permissions` 白名单
+- 声明式强制校验:至少一个 `hooks` 项
+- 自动统计:`hooks_count`、是否含 `templates/` 模板覆盖
+- 新版本上传会同步刷新插件主记录的形态快照
+
+**市场与审核**
+- 市场列表/详情展示「声明式 / 应用型」徽章、权限清单、运行时引擎、版本要求、模板覆盖标记
+- 市场支持 `?kind=app|declarative` 按形态筛选(官网页面与开放 API 均支持)
+- 开发者后台:插件管理页展示形态与权限;发布页提示两种形态要求
+- 管理后台审核列表:展示形态与权限(应用型重点核对),支持按形态筛选
+- 开发文档页新增「两种插件形态」章节,含 `plugin.json` 与 `main.js` 示例
+
+**开放 API 新增字段**(老客户端忽略未知字段,完全兼容)
+```json
+{
+ "kind": "app", "kind_label": "应用型",
+ "permissions": ["db", "settings.read"],
+ "requires": "2.1.0", "engine": "js",
+ "has_templates": false, "hooks_count": 0
+}
+```
+
+**客户端配合**
+- 插件商店页支持按形态筛选(`/admin/store?kind=app`)
+- 商店卡片与安装确认展示权限清单,安装应用型插件后需在插件管理页启用
+
+### 15.5 暂未提供(可选的后续演进)
+
+- WASM / Lua 引擎(`Runtime` 接口已预留,当前仅 goja)
+- 插件签名与官方认证标记(当前依赖市场 SHA256 校验)
+- 插件之间的依赖声明与加载顺序编排
+- 后台可视化调试台(REPL / 单步)
+- 脚本文件变更的自动热重载(当前是后台「重载」按钮或保存配置触发)
+
+### 15.4 安装使用
+
+1. 将 `plugins/signin/` 整个目录复制到站点的 `data/plugins/signin/`
+2. 后台「插件管理」→ 点击**启用**(应用型插件启用后立即加载运行时并执行 `setup()`)
+3. 前台访问 `/x/signin/`,后台左侧出现「签到管理」菜单
+
+> 打包分发:把插件目录(`plugin.json` + `main.js` + 模板文件)压成 zip,后台「插件管理 → 安装插件」上传即可;
+> `plugin.json` 必须在压缩包内(建议直接放根目录),且为 UTF-8 无 BOM。
diff --git a/docs/PLUGIN.md b/docs/PLUGIN.md
index 0141d8c..1d7dfdc 100644
--- a/docs/PLUGIN.md
+++ b/docs/PLUGIN.md
@@ -1,300 +1,255 @@
-# ClearLove 插件开发文档
+# ClearLove 插件开发总览
-插件用于在不修改表白墙源码的前提下扩展功能。插件是**声明式**的:一个 zip 包 + 一份 `plugin.json`,
-无需编译、无需服务端代码,安装后由表白墙内核解释执行,因此可以安全地在插件市场分发。
+ClearLove 的插件**不需要改源码、不需要重新编译**:把 zip 传到后台即可上线,停用即回退。
----
-
-## 1. 插件包结构
-
-```
-your-plugin.zip
-├── plugin.json # 必需:插件清单(放在根目录或任意子目录均可)
-└── (可选)其他静态资源
-```
+内核提供**两种插件形态**,能力与门槛不同,但共用同一套安装、配置、权限与安全机制。
-打包要求:
+| 对比项 | 声明式插件(A 型) | 应用型插件(B 型) |
+|---|---|---|
+| 一句话 | 用清单描述"注入什么" | 用脚本注册"实现什么" |
+| 组成 | `plugin.json`(`hooks`) | `plugin.json` + `main.js` |
+| 能力来源 | 内核预置的 5 类钩子(白名单) | `clv.*` 运行时 API(路由 / 数据表 / 后台菜单 / 任务 / 事件 / 过滤器…) |
+| 能实现 | 注入 HTML、正则改写、Webhook 回调、发帖守卫、帖子标识 | 新页面、新数据表、后台管理页、定时任务、请求拦截、改写卡片与响应… |
+| 做不到 | 新增页面 / 数据表 / 后台菜单 | 替换内核页面流程(可用"模板覆盖"逼近)、访问操作系统 |
+| 门槛 | 会写 JSON | 会写 JavaScript |
+| 权限 | 无需声明(能力即白名单) | 必须在 `permissions` 中声明,内核逐项校验 |
+| 运行方式 | 无运行时,每次请求解释清单 | goja JS 引擎,每插件独立沙箱(内部串行) |
+| 适合 | 换皮肤、加提示条、敏感词替换、转发通知 | 签到积分、抽奖活动、第三方登录、数据报表 |
+
+两种形态**可以混用**:同一个插件既能写 `hooks`(静态注入,零开销),又能用脚本注册路由与任务。参考示例 `plugins/ai-polish/`。
-- 必须是 zip 格式,`plugin.json` 需为 **UTF-8** 编码
-- 插件名(`name`)会被用作目录名,建议使用中文或英文短名,避免 `/`、`\` 等字符
-- 单包不超过 50MB
+---
-安装方式:
+## 一、怎么选
-| 方式 | 操作 |
+| 需求 | 选择 |
|---|---|
-| 后台上传 | 后台「插件管理 → 安装插件」选择 zip → 上传并安装 |
-| 插件市场 | 后台「插件商店」中一键安装(自动下载并校验 SHA256) |
-| 手动部署 | 解压到 `data/plugins/<插件名>/` 后刷新后台插件列表 |
+| 往页面塞一段 HTML / CSS / JS | A 型 |
+| 正则替换敏感词、发帖时转发 Webhook | A 型 |
+| 给某些昵称加标识 / 要求验证管理员身份 | A 型 |
+| 需要独立页面、需要存自己的数据 | B 型 |
+| 需要后台管理界面、定时任务、拦截请求 | B 型 |
+| 拿不准 | **B 型**(它是 A 型的超集,还能同时写 `hooks`) |
-安装后需在「插件管理」中点击**启用**才会生效。
+- A 型完整指南 → [PLUGIN-DECLARATIVE.md](PLUGIN-DECLARATIVE.md)
+- B 型完整指南 → [PLUGIN-APP.md](PLUGIN-APP.md)
---
-## 2. plugin.json 完整字段
+## 二、插件包与安装(两种形态通用)
-```json
-{
- "name": "插件名(必需,唯一)",
- "version": "1.0.0",
- "author": "作者",
- "description": "一句话说明",
- "requires": "2.0.0",
- "config": [ ... ],
- "hooks": [ ... ]
-}
-```
+### 2.0 零基础上手:不碰源码,从想法到上线
-| 字段 | 必填 | 说明 |
-|---|---|---|
-| `name` | ✅ | 插件名,同时作为安装目录名 |
-| `version` | | 版本号,展示用 |
-| `author` | | 作者名 |
-| `description` | | 简介,展示在插件列表与插件市场 |
-| `requires` | | 建议的最低表白墙版本(仅作展示提示) |
-| `config` | | 配置项声明,见第 3 节 |
-| `hooks` | ✅ | 钩子列表,见第 4 节 |
-
----
+开发插件**不需要 Go 环境、不需要站点源码、不需要登录服务器**,只需要:
-## 3. 配置项(config)
+| 需要什么 | 说明 |
+|---|---|
+| 一个能进后台的站点 | 建议先在本机调试:从[发布页](https://git.kazx.top/fqh/Clearlove2.0/releases)下载二进制(Windows 为 `clearlove.exe`),双击运行后访问 `http://127.0.0.1:16868/install` 完成三步安装;直接在正式站点上开发也可以,但插件会即时影响访客,请先在测试站验证 |
+| 一个文本编辑器 | VS Code / Notepad++ / 记事本均可;`plugin.json` 必须保存为 **UTF-8 无 BOM**(Windows 记事本右下角确认编码为"UTF-8"而非"UTF-8 带 BOM") |
+| 后台管理员账号 | 需要「插件管理」权限(超级管理员默认拥有) |
-插件可以声明若干配置项,表白墙会**自动在后台「插件管理 → 插件设置」渲染表单**,
-管理员填写后立即生效,无需重新安装插件。
+标准开发循环(全程只用浏览器 + 编辑器):
-```json
-"config": [
- { "key": "nicknames", "label": "专享昵称", "type": "textarea",
- "default": "官方,公告", "help": "多个昵称用逗号或换行分隔" },
- { "key": "badge", "label": "帖子标识", "type": "text", "default": "官方" },
- { "key": "enable", "label": "启用提示", "type": "switch", "default": "1" },
- { "key": "position", "label": "显示位置", "type": "select",
- "default": "bottom", "options": ["top", "bottom"] }
-]
+```
+写 plugin.json(A 型到此为止;B 型再写 main.js 与页面模板)
+ ↓ 打包成 zip
+后台「插件管理 → 上传并安装」
+ ↓ 点「启用」
+前台 / 后台验证效果,出问题查「插件日志」页
+ ↓ 改代码
+重新打 zip → 再次上传(同名文件被覆盖)→ B 型需重新「停用 → 启用」才会加载新代码
```
-| 字段 | 说明 |
-|---|---|
-| `key` | 字段名,供 `{config:key}` 引用 |
-| `label` | 后台表单里显示的名称 |
-| `type` | `text`(单行)/ `textarea`(多行)/ `switch`(开关,值为 `1`/`0`)/ `select`(下拉)/ `number` / `audio`(音频上传) |
-| `default` | 未配置时的默认值 |
-| `help` | 表单下方的说明文字 |
-| `options` | 仅 `select` 使用,候选项数组 |
-
-### 上传型字段(audio)
+把插件目录打包成 zip(任选其一):
-`type: "audio"` 会渲染成「试听 + 选择文件」的上传控件,管理员保存后配置值即为文件的可访问地址
-(形如 `/uploads/plugins/<插件名>/audio-xxxxxxxx.mp3`),可直接用于 `{config:key}`:
+| 环境 | 做法 |
+|---|---|
+| Windows 10/11(自带命令) | 在插件目录打开终端执行 `tar -a -c -f 你的插件.zip *` |
+| Windows(图形界面) | 用 7-Zip / WinRAR「压缩为 zip」;右键"发送到压缩文件夹"也可以 |
+| macOS / Linux | 在插件目录执行 `zip -r ../你的插件.zip .` |
-```json
-{ "key": "music", "label": "背景音乐文件", "type": "audio",
- "default": "", "help": "支持 mp3 / m4a / aac / ogg / opus / wav / flac,单个文件不超过 20MB" }
-```
+> ⚠ 旧版 PowerShell 的 `Compress-Archive` 生成的包内路径以反斜杠分隔,在 Linux 站点上**子目录(`assets/`、`templates/`)会失效**;
+> zip 根目录平铺文件(只有 `plugin.json` / `main.js` / 页面模板)时不受影响。含子目录请改用 `tar -a` 或 7-Zip。
-- 文件名由服务端生成,不使用上传时的原始文件名
-- 限制 20MB 与扩展名白名单;重新上传或勾选「删除当前音乐」会自动清理旧文件
-- 配置值会被清除引号与尖括号等危险字符 —— 因为它会被原样注入页面
+更新插件的两个细节:
-### 在钩子里引用配置
+- 重新上传 zip 只会**覆盖同名文件**,从新版里删掉的旧文件会残留在插件目录;正式升级建议先「卸载」再上传(B 型数据表默认保留,见 2.3)
+- A 型清单有 5 秒缓存、`content_filter` 规则有 30 秒缓存,改完稍等片刻即可生效;B 型脚本载入内存运行,**必须重新启用**(或后台点「重载」)才生效
-任意字符串字段都支持 `{config:字段名}` 占位符,运行时会被替换为管理员填写的值。
-`guard` / `badge` 与 `html` 类钩子都支持:
+### 2.1 目录结构
-```json
-{ "hook": "compose_guard", "type": "guard",
- "nicknames": "{config:nicknames}",
- "label": "{config:badge}" }
```
-
-```json
-{ "hook": "footer_html", "type": "html",
- "html": "<audio src=\"{config:music}\" autoplay loop></audio>" }
+your-plugin.zip
+├── plugin.json # 必需:插件清单(可位于 zip 内任意目录)
+├── main.js # B 型必需:脚本入口
+├── assets/ # 可选:静态资源,经 /x/<slug>/assets/ 访问
+├── templates/ # 可选:模板覆盖,见 PLUGIN-APP.md 第 9.6 节
+└── (可选)其他文件,如 A 型的 @片段.html
```
-配置值保存在数据库 `settings` 表(键名 `plugin.<插件名>.<字段名>`),随站点一起备份。
-
----
-
-## 4. 钩子一览
+打包要求:
-`hooks` 是一个数组,每个元素至少包含 `hook` 与 `type`:
+- 必须是 zip 格式,`plugin.json` 必须为 **UTF-8 无 BOM**(带 BOM 会明确报错)
+- 单包不超过 50MB,`plugin.json` 不超过 1MB
+- 插件名(`name`)会作为安装目录名,避免 `/`、`\`
-| type | hook | 作用 | 关键字段 |
-|---|---|---|---|
-| `html` | `header_html` | 前台页面 `<head>` 后注入 HTML | `html` |
-| `html` | `footer_html` | 前台页面底部注入 HTML | `html` |
-| `html` | `admin_plugins_top` | 后台「插件管理」页顶部注入 HTML | `html` |
-| `filter` | `content_filter` | 用正则改写帖子 / 评论内容 | `match`、`replace` |
-| `http` | `post_created` | 发帖成功后回调 Webhook | `url` |
-| `http` | `comment_created` | 评论后回调 Webhook | `url` |
-| `http` | `report_created` | 举报后回调 Webhook | `url` |
-| `guard` | `compose_guard` | **发帖前置守卫**:命中昵称时强制验证后台账号 | `nicknames`、`require`、`label`、`message` |
-| `badge` | `post_badge` | 给指定昵称的帖子打上标识 | `nicknames`、`label` |
+### 2.2 安装方式
-> Webhook 以 `POST` + JSON 发送,请求头带 `X-ClearLove-Event`,超时 10 秒,失败只记录日志、不影响发帖。
+| 方式 | 操作 |
+|---|---|
+| 后台上传 | 后台「插件管理 → 安装插件」选择 zip |
+| 插件商店 | 后台「插件商店」一键安装(自动下载并校验 SHA256) |
+| 手动部署 | 解压到 `data/plugins/<插件名>/` 后刷新后台插件列表 |
-### html 钩子示例
+安装 ≠ 启用。安装后在「插件管理」点**启用**才生效:
-```json
-{ "hook": "footer_html", "type": "html",
- "html": "<div class='daily-quote'>今天也要加油鸭</div>" }
-```
+- **A 型**:启用后立即生效,无需加载(每次请求读取清单)
+- **B 型**:启用时执行 `setup()` 完成全部注册,后台状态显示「运行中」
-注入的 HTML 会被原样输出到页面,因此**可以包含 `<style>` 与 `<script>`** —— 这也是插件实现
-自定义交互(弹窗、动效、统计)的主要方式。请勿注入恶意代码。
+### 2.3 卸载与数据
-### 片段较长时用 `@文件`
+- 卸载(删除插件)会删除目录、从启用列表移除、**停止 B 型的任务与路由**
+- **数据表默认保留**(`pl_<slug>_*`),重新安装后数据还在;如需清除请手动删表
+- 已写入帖子的 `badges` 标识不会因卸载消失
-在 JSON 里转义大段 HTML / JS 很痛苦。把片段放在插件目录下的独立文件里,
-`html` 字段写成 `@文件名` 即可,安装(zip)时会随插件一起落盘:
+### 2.4 插件清单常见字段
```json
-{ "hook": "footer_html", "type": "html", "html": "@player.html" }
-```
-
-```
-插件 zip 根目录/
-├── plugin.json
-└── player.html ← 与 plugin.json 同级,整段 HTML/CSS/JS 原样放在这里
+{
+ "name": "插件名(必需,同时作为目录名)",
+ "version": "1.0.0",
+ "author": "作者",
+ "description": "一句话说明",
+ "requires": "2.1.0",
+ "config": [ ... ]
+}
```
-- 只读取插件目录下的**直接文件名**,`@../xxx` 这类路径会被无视
-- 片段文件缺失时该钩子被跳过(不会把 `@player.html` 原样输出到页面)
-- 官方「背景音乐」插件就是这么写的,可直接参考 `plugins/bgm/`
+A 型额外要求 `hooks`;B 型额外要求 `kind: "app"`、`runtime`、`permissions`。
-### filter 钩子示例
-
-```json
-{ "hook": "content_filter", "type": "filter", "match": "广告|加群|代刷", "replace": "***" }
-```
+> `requires` 仅作提示:版本不满足时后台列表显示 ⚠,不会阻止启用。
-`match` 是 Go 正则表达式,对所有发帖与评论内容生效(插件启用后 30 秒内自动热加载)。
-
-### http 钩子示例
+---
-```json
-{ "hook": "post_created", "type": "http", "url": "https://example.com/hook" }
-```
+## 三、配置项(两种形态通用)
-回调体包含事件名与业务字段,例如发帖回调:
+在清单里声明 `config`,后台「插件管理 → 插件设置」会**自动渲染表单**,保存后立即生效,无需重启。
```json
-{ "event": "post_created", "post_id": 12, "nickname": "匿名", "content": "...", "time": "2026-09-12T15:00:00Z" }
+"config": [
+ { "key": "enable", "label": "启用提示", "type": "switch", "default": "1" },
+ { "key": "text", "label": "提示文案", "type": "text", "default": "欢迎" },
+ { "key": "badge", "label": "标识文字", "type": "text", "help": "显示在昵称后" },
+ { "key": "pos", "label": "显示位置", "type": "select", "default": "bottom",
+ "options": ["top", "bottom"] },
+ { "key": "num", "label": "次数上限", "type": "number", "default": "3" },
+ { "key": "music", "label": "背景音乐", "type": "audio", "default": "",
+ "help": "支持 mp3 / m4a / aac / ogg / opus / wav / flac,不超过 20MB" },
+ { "key": "big", "label": "长文本", "type": "textarea", "default": "" }
+]
```
----
-
-## 5. guard:发帖守卫(需要验证后台账号)
-
-`compose_guard` 用于**防止他人冒充官方身份**:当发帖昵称命中名单时,
-表白墙会在提交前弹出「管理员身份验证」弹窗,要求输入后台管理员账号与密码,
-**服务端校验通过后才会发布**(不通过直接拒绝,无法绕过),并可给帖子打上标识。
-
| 字段 | 说明 |
|---|---|
-| `nicknames` | 触发的昵称名单,逗号 / 分号 / 换行分隔,支持 `{config:key}` |
-| `require` | `admin` = 必须验证后台管理员账号密码;留空表示仅打标识、不校验 |
-| `label` | 校验通过后写入帖子的标识文字(前台显示为昵称后的彩色徽章) |
-| `message` | 校验失败时返回给用户的提示语 |
-
-```json
-{
- "hook": "compose_guard",
- "type": "guard",
- "nicknames": "官方,公告板",
- "require": "admin",
- "label": "官方",
- "message": "该昵称为官方专享,请验证管理员账号后发布"
-}
-```
+| `key` | 字段名,A 型用 `{config:key}` 引用,B 型用 `clv.plugin.config("key")` 读取 |
+| `label` | 后台表单显示的名称 |
+| `type` | `text` / `textarea` / `switch`(值 `1`/`0`)/ `select` / `number` / `audio` / `file` |
+| `default` | 未配置时的默认值 |
+| `help` | 表单下方说明文字 |
+| `options` | 仅 `select` 使用 |
-行为说明:
+- 存储位置:`settings` 表的 `plugin.<插件名>.<字段名>`,随站点一起备份
+- `audio` / `file` 上传型:管理员上传后,配置值即为可访问地址(`/uploads/plugins/<插件名>/xxx`),文件名由服务端生成;重新上传或勾选删除会清理旧文件
+- 值会被去除 HTML 与危险字符(因为它可能被原样注入页面)
-- 前台发帖页会在昵称命中时**自动弹出验证弹窗**(昵称名单由插件配置实时提供)
-- 表单发帖(`/compose`)与 API 发帖(`POST /api/v1/posts`)都会强制校验
-- API 发帖需在 JSON 中额外提供 `admin_user` 与 `admin_pass`
-- 同一 IP 连续失败 5 次会被锁定 10 分钟,防止暴力破解后台密码
-- 多个插件同时命中时,标识会合并、校验要求取并集
-- 通过验证的帖子会同时带上内置的「**管理员**」标识(表示发布者已验证为管理员),
- 插件自定义的 `label` 会追加在后面,例如 `["管理员", "官方认证"]`
+---
-### badge:帖子标识(无需验证)
+## 四、权限与安全(B 型重点)
-只想给某些昵称的帖子加个标记时使用,不涉及验证:
+B 型插件的每一项能力都要在清单里**声明权限**,运行时逐项校验;A 型不需要(只会注入内容,能力受限)。
```json
-{ "hook": "post_badge", "type": "badge", "nicknames": "小编,编辑", "label": "编辑" }
+"permissions": ["db", "settings.read", "site.read"]
```
-标识在发帖时写入帖子记录,**不随插件卸载而消失**(已发布的历史帖子仍保留标识)。
+| 权限 | 授予的能力 |
+|---|---|
+| `db` | 数据能力:`clv.db.*`、`clv.table`(写操作仅限本插件表) |
+| `db.admin` | 放开全库写(内核表也可写,谨慎) |
+| `settings.read` | 读站点设置(`clv.setting.get/all`) |
+| `settings.write` | 写站点设置(`clv.setting.set`) |
+| `site.read` | 读帖子 / 评论 / 用户 / 统计(`clv.post.list/get`、`clv.user.*`、`clv.stats.*`) |
+| `site.write` | 写站点内容(发帖 / 改帖 / 删帖 / 评论) |
+| `net` | 出网请求(`clv.http.request`) |
+| `net.local` | 允许访问内网 / 本机地址(如本机 Ollama) |
+| `cache` | 内存缓存(`clv.cache.*`) |
+| `mail` | 发邮件(`clv.mail.send`) |
+| `media.write` | 保存图片 / 视频到上传目录(`clv.media.*`) |
+
+安全机制(内核强制,插件无法绕过):
+
+| 机制 | 说明 |
+|---|---|
+| 权限校验 | 未声明的权限调用即抛异常,插件可 `try/catch` |
+| 数据隔离 | 写 SQL 只能落在 `pl_<slug>_*` 表;查询结果上限 1000 行 |
+| 执行超时 | 单次脚本调用默认 1500ms(`runtime.timeout_ms` 可放宽,硬上限 30s);死循环会被中断,中断后插件仍可继续服务 |
+| 失败熔断 | 连续失败 20 次自动禁用并卸载插件,避免拖垮站点 |
+| 出网防护 | 默认拒绝内网 / 环回地址(SSRF),响应体默认 1MB、上限 2MB |
+| 请求限额 | 插件路由读取请求体上限 1MB |
+| 运行审计 | 关键动作写入 `plugin_logs` 表(后台可查) |
----
+> 插件脚本运行在**进程内沙箱**(goja),不是操作系统级隔离。请只安装可信来源的插件。
-## 6. 完整示例:官方身份守卫插件
+---
-`plugin.json`:
+## 五、数据与存储位置
-```json
-{
- "name": "官方身份守卫",
- "version": "1.0.0",
- "author": "yourname",
- "description": "使用指定昵称发帖时必须验证后台管理员账号,帖子显示自定义标识,防止冒充官方身份",
- "requires": "2.0.0",
- "config": [
- { "key": "nicknames", "label": "专享昵称", "type": "textarea", "default": "官方,公告",
- "help": "使用这些昵称发帖时需验证后台账号,多个用逗号或换行分隔" },
- { "key": "badge", "label": "帖子标识", "type": "text", "default": "官方",
- "help": "验证通过的帖子在昵称后显示的徽章文字" },
- { "key": "message", "label": "失败提示", "type": "text",
- "default": "该昵称为官方专享,请验证管理员账号后发布" }
- ],
- "hooks": [
- { "hook": "compose_guard", "type": "guard",
- "nicknames": "{config:nicknames}",
- "require": "admin",
- "label": "{config:badge}",
- "message": "{config:message}" },
- { "hook": "footer_html", "type": "html",
- "html": "<style>.post-badge{box-shadow:0 1px 6px rgba(255,123,169,.45)}</style>" }
- ]
-}
-```
+| 内容 | 位置 |
+|---|---|
+| 插件目录 | `data/plugins/<插件名>/` |
+| 配置值 | `settings` 表,键 `plugin.<插件名>.<字段名>` |
+| B 型数据表 | `pl_<slug>_<逻辑名>`(`slug` 中的 `-` 转 `_`) |
+| 上传素材 | `uploads/plugins/<插件名>/` |
+| 运行日志 | `plugin_logs` 表(`clv.log.*`、加载/失败/任务记录) |
+| 静态资源 URL | `/x/<slug>/assets/<文件>` |
-打包与安装:
+`slug` 是 URL 与表名的安全标识:清单里显式写 `slug` 优先,否则由 `name` 归一化(中文名会退化为 `plugin-<hash>`,**建议显式声明 `slug`**)。
-```bash
-zip -r official-guard.zip plugin.json
-```
+---
-上传到后台「插件管理 → 安装插件」→ 启用 → 在「插件设置」里填写专享昵称即可。
+## 六、调试速查
-> 表白墙内置了「管理员专享昵称」(网站设置),覆盖最常见的官方身份保护场景;
-> 这个插件演示的是如何用插件实现同类能力,并支持自定义标识文字与多插件组合。
+| 现象 | 排查方向 |
+|---|---|
+| A 型插件装了没反应 | ① 是否已**启用**;② 后台列表是否有 ⚠(钩子类型不支持 / 版本不满足);③ 浏览器查看页面源码确认注入内容是否存在 |
+| B 型插件状态「未运行」 | 看 `plugin_logs` 表中的 `load_error` 记录;常见原因:`setup()` 报错、`runtime.entry` 文件缺失、语法错误 |
+| B 型路由 404 | 实际前缀是 `/x/<slug>/`(后台页是 `/admin/plugins/<slug>/`);确认插件状态为「运行中」 |
+| 调用 API 报"未声明权限" | 在 `plugin.json` 的 `permissions` 中补上,然后重新启用(权限在加载时确定) |
+| 改了 main.js 不生效 | B 型脚本在启用时载入内存。重新点一次「停用 → 启用」(或调用重载),无需重启服务 |
+| 改了 plugin.json 不生效 | A 型清单有 5 秒短缓存,`content_filter` 有 30 秒缓存;B 型需重新启用 |
+| 配置改了没生效 | 配置读取带内存缓存,保存即刷新;B 型插件读到的是最新值 |
+| 插件被自动停用 | 触发熔断(连续 20 次失败),去 `plugin_logs` 查失败原因 |
+| 保存 plugin.json 报解析失败 | 必须 UTF-8 **无 BOM**;可用 `jq . plugin.json` 验证 |
+| 模板覆盖不生效 | 覆盖文件放在 `templates/` 下且与内核模板同名;启用/停用插件后会自动重载模板 |
+| 想看插件运行情况 | 后台「插件管理」列表(路由/菜单/slot/任务数量、失败计数、权限)与日志页 |
---
-## 7. 发布到插件市场
+## 七、发布到插件市场
1. 在插件社区注册开发者账号(邮箱验证):<https://clearlove.kazx.top/dev/register>
2. 开发者后台「发布插件」上传 zip,填写分类、说明与标签
-3. 管理员审核上架后,所有站点都能在后台「插件商店」中一键安装
+3. 管理员审核上架后,所有站点都能在后台「插件商店」一键安装
4. 之后可在开发者后台查看下载量与安装量,并上传新版本
---
-## 8. 调试建议
+## 八、深入阅读
-| 场景 | 建议 |
-|---|---|
-| **插件装了但完全没反应** | 按顺序检查:① 是否已在「插件管理」点**启用**(安装 ≠ 启用);② 后台插件列表里插件名下方是否出现 ⚠ 提示 —— 有提示说明正在运行的表白墙版本过旧,不认识插件的钩子类型,**升级二进制即可**;③ 浏览器打开 `/compose` 查看网页源码,若没有 `data-admin-nicks` 属性,同样说明服务端是旧版本 |
-| 改了 plugin.json 不生效 | 插件清单每次请求都会重新读取磁盘,但 `content_filter` 规则有 30 秒缓存 |
-| 配置改了没生效 | 配置存在 `settings` 表并带内存缓存,保存后立即刷新,无需重启 |
-| 钩子没触发 | 确认插件已在「插件管理」中**启用**(安装 ≠ 启用) |
-| 前台注入的 JS 报错 | 打开浏览器控制台查看;注入内容会在渲染时直接输出,注意转义引号 |
-| 保存 plugin.json 后插件不出现 | 确认文件为 **UTF-8 无 BOM**(带 BOM 会导致 JSON 解析失败而静默跳过),可用 `jq . plugin.json` 验证 |
-| 想看当前插件与配置 | 后台「插件管理」页可查看全部插件、状态、兼容性提示与配置表单 |
-
-> 兼容性原则:插件使用了当前内核不支持的钩子类型时,**该钩子会被跳过而不是报错**,
-> 因此请务必留意后台插件列表中的 ⚠ 兼容性提示。
+| 文档 | 面向 | 内容 |
+|---|---|---|
+| [PLUGIN-DECLARATIVE.md](PLUGIN-DECLARATIVE.md) | A 型作者 | `hooks` 五类钩子的全部字段、`@文件` 片段、守卫与标识、示例解读 |
+| [PLUGIN-APP.md](PLUGIN-APP.md) | B 型作者 | 清单与生命周期、注册 API、Host API 全集、Slot / 事件 / 过滤器 / 中间件清单、模板覆盖、完整示例 |
+| [PLUGIN-V3.md](PLUGIN-V3.md) | 内核开发者 | 插件系统设计与实现说明(两种形态的架构决策、扩展点接入方式、路线图) |
+
+两种形态都有**完整可复制的源码示例**内嵌在本套文档中,无需访问源码仓库:A 型见 [PLUGIN-DECLARATIVE.md](PLUGIN-DECLARATIVE.md) 第 6 节(官方身份守卫、每日一言),B 型见 [PLUGIN-APP.md](PLUGIN-APP.md) 第 10 节(每日签到,四个文件全文)。仓库 `plugins/` 目录(`bgm`、`official-guard`、`rules-gate`、`ai-polish`、`signin`)与插件市场中的现成 zip 仅供对照下载。
diff --git a/go.mod b/go.mod
index b6b237b..e6078cc 100644
--- a/go.mod
+++ b/go.mod
@@ -12,13 +12,18 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
+ github.com/dlclark/regexp2/v2 v2.5.2 // indirect
+ github.com/dop251/goja v0.0.0-20260917113740-793a2a65c13b // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
+ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sys v0.35.0 // indirect
+ golang.org/x/text v0.28.0 // indirect
modernc.org/libc v1.65.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
diff --git a/go.sum b/go.sum
index 2c7af0b..efa3082 100644
--- a/go.sum
+++ b/go.sum
@@ -2,8 +2,14 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/HugoSmits86/nativewebp v1.2.1 h1:dJbfulw6WRf6rTcth6TwgEVwlBeP3vdZIJUIoySmeHQ=
github.com/HugoSmits86/nativewebp v1.2.1/go.mod h1:YNQuWenlVmSUUASVNhTDwf4d7FwYQGbGhklC8p72Vr8=
+github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0=
+github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
+github.com/dop251/goja v0.0.0-20260917113740-793a2a65c13b h1:UMDLDHFR1Chu3qnsPNCrVxq0lZgG6JqHpLL5+iqfSkw=
+github.com/dop251/goja v0.0.0-20260917113740-793a2a65c13b/go.mod h1:u8yZRUavu+N4EnFFy6J5fVtjE7lEcZ2YyV2GcBXY9c8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
+github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
@@ -29,6 +35,8 @@ golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
+golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
diff --git a/internal/config/config.go b/internal/config/config.go
index 5f38323..9aa2c2a 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -9,7 +9,7 @@ import (
)
// Version 当前应用版本号,用于检查更新
-const Version = "2.0.0"
+const Version = "2.1.1"
// Cfg 全局配置单例
var Cfg Config
diff --git a/internal/database/database.go b/internal/database/database.go
index 93ba24d..81c74b0 100644
--- a/internal/database/database.go
+++ b/internal/database/database.go
@@ -101,6 +101,7 @@ CREATE TABLE IF NOT EXISTS apikeys (id {PK}, name VARCHAR(64) DEFAULT '', key VA
CREATE TABLE IF NOT EXISTS ai_logs (id {PK}, post_id {I} DEFAULT 0, action VARCHAR(16) DEFAULT '', reason {LT}, created_at VARCHAR(40));
CREATE TABLE IF NOT EXISTS bans (id {PK}, btype VARCHAR(16) DEFAULT '', bvalue VARCHAR(128) DEFAULT '', created_at VARCHAR(40));
CREATE TABLE IF NOT EXISTS verifications (id {PK}, email VARCHAR(128) NOT NULL, code VARCHAR(10) NOT NULL, expires VARCHAR(40));
+CREATE TABLE IF NOT EXISTS plugin_logs (id {PK}, plugin_slug VARCHAR(64) DEFAULT '', action VARCHAR(48) DEFAULT '', detail {LT}, duration_ms {I} DEFAULT 0, created_at VARCHAR(40));
`
// Migrate 安装时自动建表并创建索引,可重复执行(幂等)
diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go
index b75ab09..02f5e40 100644
--- a/internal/handlers/admin.go
+++ b/internal/handlers/admin.go
@@ -15,6 +15,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "runtime"
"strings"
"time"
@@ -86,6 +87,14 @@ func AdminDashboard(w http.ResponseWriter, r *http.Request) {
"comments": models.QueryInt("SELECT COUNT(1) FROM comments"),
"reports": models.QueryInt("SELECT COUNT(1) FROM reports WHERE status=0"),
}
+ // 应用型插件可改写仪表盘统计(filter.admin.stats)
+ if plugin.HasApps() {
+ if out := plugin.ApplyFilterValue("filter.admin.stats", data, nil); out != nil {
+ if m, ok := out.(map[string]any); ok {
+ data = m
+ }
+ }
+ }
Render(w, r, "admin_layout.html", "pg_dashboard", data)
}
@@ -210,11 +219,26 @@ func AdminSettingsSave(w http.ResponseWriter, r *http.Request) {
}
textKeys := []string{"site_name", "theme_bg", "smtp_host", "smtp_port", "smtp_user",
"smtp_pass", "smtp_from", "ai_base", "ai_key", "ai_model", "update_url", "donate_img", "cloud_api", "admin_nicknames"}
+ vals := map[string]string{}
for _, k := range textKeys {
if v := r.FormValue(k); v != "" || k == "theme_bg" || k == "smtp_pass" {
- _ = models.SetSetting(k, strings.TrimSpace(v))
+ vals[k] = strings.TrimSpace(v)
+ }
+ }
+ // 应用型插件可校验/改写即将写入的设置(filter.settings.save)
+ if plugin.HasApps() {
+ if out := plugin.ApplyFilterValue("filter.settings.save", vals, nil); out != nil {
+ if m, ok := out.(map[string]any); ok {
+ vals = map[string]string{}
+ for k, v := range m {
+ vals[k] = strOfAny(v)
+ }
+ }
}
}
+ for k, v := range vals {
+ _ = models.SetSetting(k, v)
+ }
for _, k := range []string{"allow_register", "require_login_post", "ai_enabled", "ai_autopatrol", "ai_precheck"} {
v := "0"
if r.FormValue(k) == "1" {
@@ -357,6 +381,8 @@ func ThemeCSS(w http.ResponseWriter, r *http.Request) {
if bg := models.GetSetting("theme_bg"); bg != "" {
css = fmt.Sprintf(":root{--clv-bg-image:url('%s');}\n", bg) + css
}
+ // 应用型插件可追加/改写全站样式(filter.theme.css)
+ css = plugin.ApplyFilterStr("filter.theme.css", css)
_, _ = w.Write([]byte(css))
}
@@ -729,20 +755,60 @@ func AdminPlugins(w http.ResponseWriter, r *http.Request) {
}
list := plugin.List()
configs := map[string]map[string]string{}
+ infos := map[string]plugin.AppInfo{}
for i := range list {
if len(list[i].Config) > 0 {
p := list[i].Plugin
configs[p.Name] = plugin.GetConfig(&p)
}
+ if list[i].App {
+ infos[list[i].Name] = plugin.AppInfoOf(list[i].Name)
+ }
}
Render(w, r, "admin_layout.html", "pg_admin_plugins", map[string]any{
"plugins": list,
"configs": configs,
+ "infos": infos,
// 插件可通过 {"hook":"admin_plugins_top","type":"html"} 往本页顶部注入内容
"adminTop": template.HTML(plugin.CallHTML("admin_plugins_top")),
})
}
+// AdminPluginReload 手动重载应用型插件运行时(修改脚本后无需重启站点)
+func AdminPluginReload(w http.ResponseWriter, r *http.Request) {
+ if a := requireAdmin(w, r, models.PermPlugin); a == nil {
+ return
+ }
+ name := filepath.Base(r.FormValue("name"))
+ if err := plugin.ReloadApp(name); err != nil {
+ util.Log("error", "插件 %s 重载失败: %v", name, err)
+ }
+ http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
+}
+
+// AdminPluginLogs 插件运行日志页
+func AdminPluginLogs(w http.ResponseWriter, r *http.Request) {
+ if a := requireAdmin(w, r, models.PermPlugin); a == nil {
+ return
+ }
+ slug := strings.TrimSpace(r.URL.Query().Get("slug"))
+ Render(w, r, "admin_layout.html", "pg_admin_plugin_logs", map[string]any{
+ "logs": plugin.PluginLogs(slug, 200),
+ "slug": slug,
+ })
+}
+
+// AdminPluginLogsClear 清空插件运行日志
+func AdminPluginLogsClear(w http.ResponseWriter, r *http.Request) {
+ if a := requireAdmin(w, r, models.PermPlugin); a == nil {
+ return
+ }
+ if err := plugin.ClearPluginLogs(); err != nil {
+ util.Log("error", "清空插件日志失败: %v", err)
+ }
+ http.Redirect(w, r, "/admin/plugin-logs", http.StatusSeeOther)
+}
+
// AdminPluginConfig 保存插件配置(仅接受该插件声明过的字段)
func AdminPluginConfig(w http.ResponseWriter, r *http.Request) {
if a := requireAdmin(w, r, models.PermPlugin); a == nil {
@@ -804,6 +870,12 @@ func AdminPluginConfig(w http.ResponseWriter, r *http.Request) {
return
}
util.Log("info", "插件 %s 配置已更新", name)
+ // 应用型插件:配置变更后重载,确保 setup() 读到新配置
+ if p, ok := plugin.Load()[name]; ok && p.IsApp() {
+ if err := plugin.ReloadApp(name); err != nil {
+ util.Log("error", "应用型插件 %s 重载失败: %v", name, err)
+ }
+ }
http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
}
@@ -893,12 +965,26 @@ func AdminPluginUpload(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
}
-// AdminPluginToggle 启用/禁用插件
+// AdminPluginToggle 启用/禁用插件。
+// 应用型插件(kind=app)在启用时加载运行时、禁用时卸载;
+// 声明式插件无需加载,按请求即时解析。
func AdminPluginToggle(w http.ResponseWriter, r *http.Request) {
if a := requireAdmin(w, r, models.PermPlugin); a == nil {
return
}
- plugin.SetEnabled(filepath.Base(r.FormValue("name")), r.FormValue("on") == "1")
+ name := filepath.Base(r.FormValue("name"))
+ on := r.FormValue("on") == "1"
+ plugin.SetEnabled(name, on)
+ if on {
+ if p, ok := plugin.Load()[name]; ok && p.IsApp() {
+ if err := plugin.LoadApp(name); err != nil {
+ util.Log("error", "应用型插件 %s 加载失败: %v", name, err)
+ }
+ }
+ } else {
+ plugin.UnloadApp(name)
+ }
+ ReloadTemplates() // 插件可能带有模板覆盖
http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
}
@@ -907,7 +993,10 @@ func AdminPluginDelete(w http.ResponseWriter, r *http.Request) {
if a := requireAdmin(w, r, models.PermPlugin); a == nil {
return
}
- _ = plugin.Remove(filepath.Base(r.FormValue("name")))
+ name := filepath.Base(r.FormValue("name"))
+ plugin.UnloadApp(name) // 应用型插件先停止运行时再删除目录
+ _ = plugin.Remove(name)
+ ReloadTemplates()
http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
}
@@ -989,11 +1078,13 @@ func AdminUpdate(w http.ResponseWriter, r *http.Request) {
Version string `json:"version"`
Notes string `json:"notes"`
Download string `json:"download"`
+ SHA256 string `json:"sha256"`
}
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&info) == nil {
result["latest"] = info.Version
result["notes"] = info.Notes
result["download"] = info.Download
+ result["sha256"] = info.SHA256
result["upToDate"] = info.Version == "" || info.Version <= config.Version
}
resp.Body.Close()
@@ -1003,5 +1094,9 @@ func AdminUpdate(w http.ResponseWriter, r *http.Request) {
} else {
result["error"] = "未配置更新接口地址"
}
- Render(w, r, "admin_layout.html", "pg_admin_update", map[string]any{"result": result})
+ Render(w, r, "admin_layout.html", "pg_admin_update", map[string]any{
+ "result": result,
+ "platform": runtime.GOOS + "/" + runtime.GOARCH,
+ "canSelfUpdate": runtime.GOOS == "linux" && runtime.GOARCH == "amd64",
+ })
}
diff --git a/internal/handlers/api.go b/internal/handlers/api.go
index b63f648..afc5184 100644
--- a/internal/handlers/api.go
+++ b/internal/handlers/api.go
@@ -107,7 +107,7 @@ func APIComment(w http.ResponseWriter, r *http.Request) {
return
}
postID := formInt64(r, "post_id")
- content := plugin.CallFilter(util.StripHTML(r.FormValue("content")))
+ content := plugin.ApplyFilterStr("filter.content", util.StripHTML(r.FormValue("content")))
cid, errMsg := addComment(postID, util.StripHTML(r.FormValue("nickname")), content, util.FingerprintOf(r), CurrentUser(r))
if errMsg != "" {
fail(w, 400, errMsg)
@@ -136,7 +136,7 @@ func APIReport(w http.ResponseWriter, r *http.Request) {
fail(w, 500, "举报提交失败")
return
}
- plugin.Notify("report_created", map[string]any{"post_id": postID, "reason": reason})
+ plugin.Emit("report_created", map[string]any{"post_id": postID, "reason": reason})
okJSON(w, map[string]any{"msg": "举报已提交,感谢您的监督"})
}
@@ -165,7 +165,7 @@ func APICreatePost(w http.ResponseWriter, r *http.Request) {
fail(w, 400, "JSON 格式错误")
return
}
- content := plugin.CallFilter(util.StripHTML(body.Content))
+ content := plugin.ApplyFilterStr("filter.content", util.StripHTML(body.Content))
if content == "" || len([]rune(content)) > 3000 {
fail(w, 400, "帖子内容需为 1-3000 字")
return
@@ -214,7 +214,7 @@ func APICreatePost(w http.ResponseWriter, r *http.Request) {
_, _ = database.DB.Exec("INSERT INTO medias(post_id,type,path,created_at) VALUES(?,?,?,?)", pid, "video", p, models.Now())
}
}
- plugin.Notify("post_created", map[string]any{"post_id": pid, "nickname": nickname, "content": content})
+ plugin.Emit("post_created", map[string]any{"post_id": pid, "nickname": nickname, "content": content})
okJSON(w, map[string]any{"id": pid})
}
diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go
index 2dc5a67..52a340d 100644
--- a/internal/handlers/auth.go
+++ b/internal/handlers/auth.go
@@ -13,6 +13,7 @@ import (
"clearlove/internal/database"
"clearlove/internal/mailer"
"clearlove/internal/models"
+ "clearlove/internal/plugin"
"clearlove/internal/util"
)
@@ -44,10 +45,16 @@ func LoginSubmit(w http.ResponseWriter, r *http.Request) {
Render(w, r, "layout.html", "pg_login", map[string]any{"err": "账号已被禁用"})
return
}
+ // 应用型插件可拦截登录(filter.auth.login,如封禁名单、黑名单校验)
+ if !plugin.ApplyFilterBool("filter.auth.login", true, map[string]any{"user_id": id, "username": username}) {
+ Render(w, r, "layout.html", "pg_login", map[string]any{"err": "登录被站点插件拦截"})
+ return
+ }
http.SetCookie(w, &http.Cookie{
Name: "clv_user", Value: util.SignSession(id, 30*24*time.Hour), Path: "/",
MaxAge: 30 * 86400, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
+ plugin.Emit("user_login", map[string]any{"user_id": id, "username": username})
next := r.FormValue("next")
if next == "" || !strings.HasPrefix(next, "/") {
next = "/"
@@ -124,6 +131,7 @@ func RegisterSubmit(w http.ResponseWriter, r *http.Request) {
return
}
uid, _ := res.LastInsertId()
+ plugin.Emit("user_registered", map[string]any{"user_id": uid, "username": username})
_, _ = database.DB.Exec("DELETE FROM verifications WHERE email=?", email)
http.SetCookie(w, &http.Cookie{
Name: "clv_user", Value: util.SignSession(uid, 30*24*time.Hour), Path: "/",
diff --git a/internal/handlers/front.go b/internal/handlers/front.go
index ad37d74..3182bdf 100644
--- a/internal/handlers/front.go
+++ b/internal/handlers/front.go
@@ -93,6 +93,24 @@ func guardCompose(nickname, adminUser, adminPass, ip string) (int, []string, int
}
}
}
+ // 应用型插件可追加/放宽守卫要求(filter.compose.guard)
+ if plugin.HasApps() {
+ out := plugin.ApplyFilterValue("filter.compose.guard", map[string]any{
+ "require": needAdmin,
+ "nickname": nickname,
+ "labels": labels,
+ "message": msg,
+ }, nil)
+ if m, ok := out.(map[string]any); ok {
+ needAdmin = boolOfAny(m["require"])
+ if v := strOfAny(m["message"]); v != "" {
+ msg = v
+ }
+ if list := anyToStrings(m["labels"]); list != nil {
+ labels = list
+ }
+ }
+ }
if !needAdmin {
return 0, models.MergeBadges(labels), 0, ""
}
@@ -226,6 +244,7 @@ func fetchCards(before int64, limit int, topic string) []Card {
for i, j := 0, len(c.Comments)-1; i < j; i, j = i+1, j-1 {
c.Comments[i], c.Comments[j] = c.Comments[j], c.Comments[i]
}
+ applyCardFilter(c)
out = append(out, *c)
}
return out
@@ -296,9 +315,13 @@ func PostDetail(w http.ResponseWriter, r *http.Request) {
Render(w, r, "layout.html", "pg_detail", data)
}
-// isVisible 帖子是否可见
+// isVisible 帖子是否可见(应用型插件可通过 filter.post.visible 追加可见性条件)
func isVisible(id int64) bool {
- return models.QueryInt("SELECT COUNT(1) FROM posts WHERE id=? AND status=1", id) > 0
+ ok := models.QueryInt("SELECT COUNT(1) FROM posts WHERE id=? AND status=1", id) > 0
+ if !ok || !plugin.HasApps() {
+ return ok
+ }
+ return plugin.ApplyFilterBool("filter.post.visible", ok, map[string]any{"post_id": id})
}
// singleCard 按 ID 构建单个卡片(详情页用,含媒体与评论)
@@ -335,6 +358,7 @@ func singleCard(id int64) *Card {
Nickname: cm["nickname"].(string), Content: cm["content"].(string), CreatedAt: cm["created_at"].(string),
})
}
+ applyCardFilter(c)
return c
}
@@ -392,7 +416,7 @@ func ComposeSubmit(w http.ResponseWriter, r *http.Request) {
fail(w, 400, "验证码错误或已过期")
return
}
- content := util.StripHTML(r.FormValue("content"))
+ content := plugin.ApplyFilterStr("filter.content", util.StripHTML(r.FormValue("content")))
if content == "" || len([]rune(content)) > 3000 {
fail(w, 400, "帖子内容需为 1-3000 字")
return
@@ -510,7 +534,7 @@ func ComposeSubmit(w http.ResponseWriter, r *http.Request) {
_, _ = database.DB.Exec(
"INSERT INTO medias(post_id,type,path,created_at) VALUES(?,?,?,?)", pid, m.typ, m.path, models.Now())
}
- plugin.Notify("post_created", map[string]any{"post_id": pid, "nickname": nickname, "content": content})
+ plugin.Emit("post_created", map[string]any{"post_id": pid, "nickname": nickname, "content": content})
if isAJAX(r) {
okJSON(w, map[string]any{"redirect": "/", "id": pid})
return
@@ -554,6 +578,7 @@ func toggleLike(postID int64, fp string) (int64, bool) {
}
n := models.QueryInt("SELECT COUNT(1) FROM likes WHERE post_id=?", postID)
_, _ = database.DB.Exec("UPDATE posts SET like_count=? WHERE id=?", n, postID)
+ plugin.Emit("like_toggled", map[string]any{"post_id": postID, "liked": exists == 0, "count": n})
return n, exists == 0
}
@@ -580,7 +605,7 @@ func addComment(postID int64, nickname, content, fp string, u *models.User) (int
}
cid, _ := res.LastInsertId()
_, _ = database.DB.Exec("UPDATE posts SET comment_count=comment_count+1 WHERE id=?", postID)
- plugin.Notify("comment_created", map[string]any{"post_id": postID, "content": content})
+ plugin.Emit("comment_created", map[string]any{"post_id": postID, "content": content})
return cid, ""
}
@@ -624,6 +649,7 @@ func PostEditSave(w http.ResponseWriter, r *http.Request) {
http.Error(w, "保存失败", http.StatusInternalServerError)
return
}
+ plugin.Emit("post_updated", map[string]any{"post_id": id, "content": content})
http.Redirect(w, r, "/post/"+r.PathValue("id"), http.StatusSeeOther)
}
@@ -654,6 +680,7 @@ func PostDelete(w http.ResponseWriter, r *http.Request) {
return
}
deletePostCascade(id)
+ plugin.Emit("post_deleted", map[string]any{"post_id": id})
if isAJAX(r) {
okJSON(w, map[string]any{"redirect": "/"})
return
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index c362b0e..9aee922 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -9,10 +9,12 @@ import (
"html/template"
"io/fs"
"net/http"
+ "os"
"strconv"
"strings"
"time"
+ "clearlove/internal/config"
"clearlove/internal/middleware"
"clearlove/internal/models"
"clearlove/internal/plugin"
@@ -22,9 +24,18 @@ import (
// Tmpl 全局模板集(layout.html / admin_layout.html 为布局,其余为页面片段)
var Tmpl *template.Template
+// tmplFuncs 模板函数表(插件模板渲染复用同一套函数)
+var tmplFuncs template.FuncMap
+
// StaticFS 嵌入式静态资源(由 main 注入)
var StaticFS fs.FS
+// webTemplatesFS 内嵌模板源(插件覆盖时用于重新解析)
+var (
+ webTemplatesFS embed.FS
+ hasWebTemplates bool
+)
+
// SetupTemplates 解析嵌入式模板并注册模板函数
func SetupTemplates(efs embed.FS) {
funcs := template.FuncMap{
@@ -71,9 +82,47 @@ func SetupTemplates(efs embed.FS) {
}
return template.HTML(buf.String())
}
- Tmpl = template.Must(template.New("clv").Funcs(funcs).ParseFS(efs, "web/templates/*.html"))
+ tmplFuncs = funcs
+ webTemplatesFS = efs
+ hasWebTemplates = true
+ RebuildTemplates()
+ // 把模板能力反向注入插件运行时(应用型插件的页面渲染依赖它)
+ plugin.SetViewProvider(pluginViewProvider{})
}
+// RebuildTemplates 重新解析内核模板,并叠加插件目录中的同名覆盖模板。
+// Go 模板的特性:后解析的同名模板会覆盖先前的,因此插件可精准替换某个片段。
+func RebuildTemplates() {
+ if !hasWebTemplates {
+ return
+ }
+ Tmpl = template.Must(template.New("clv").Funcs(tmplFuncs).ParseFS(webTemplatesFS, "web/templates/*.html"))
+ for _, dir := range plugin.EnabledTemplateDirs() {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ continue
+ }
+ hasHTML := false
+ for _, e := range entries {
+ if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".html") {
+ hasHTML = true
+ break
+ }
+ }
+ if !hasHTML {
+ continue
+ }
+ if _, err := Tmpl.ParseFS(os.DirFS(dir), "*.html"); err != nil {
+ util.Log("warn", "插件模板覆盖失败 %s: %v", dir, err)
+ continue
+ }
+ util.Log("info", "已应用插件模板覆盖: %s", dir)
+ }
+}
+
+// ReloadTemplates 插件启停后刷新模板(含覆盖),供 handlers 内部调用
+func ReloadTemplates() { RebuildTemplates() }
+
// Render 渲染页面。公共数据(页面名、CSRF、站名、用户、插件注入)自动注入
func Render(w http.ResponseWriter, r *http.Request, layout, page string, data map[string]any) {
if data == nil {
@@ -81,10 +130,9 @@ func Render(w http.ResponseWriter, r *http.Request, layout, page string, data ma
}
data["Page"] = page
data["csrf"] = middleware.CSRFToken(r)
+ data["clvVersion"] = config.Version
data["site"] = models.GetSetting("site_name")
data["bg"] = models.GetSetting("theme_bg")
- data["header_html"] = template.HTML(plugin.CallHTML("header_html"))
- data["footer_html"] = template.HTML(plugin.CallHTML("footer_html"))
data["donate"] = models.GetSetting("donate_img")
if u := CurrentUser(r); u != nil {
data["user"] = u
@@ -93,6 +141,24 @@ func Render(w http.ResponseWriter, r *http.Request, layout, page string, data ma
if a := CurrentAdmin(r); a != nil {
data["admin"] = a
}
+
+ // 插件 UI 注入点:A 型静态片段 + B 型(应用型)动态渲染
+ slotData := pluginSlotData(r, page, data)
+ data["header_html"] = template.HTML(plugin.SlotHTML("header_html", slotData))
+ data["footer_html"] = template.HTML(plugin.SlotHTML("footer_html", slotData))
+ data["head_end"] = template.HTML(plugin.SlotHTML("head.end", slotData))
+ data["body_end"] = template.HTML(plugin.SlotHTML("body.end", slotData))
+ data["admin_head_end"] = template.HTML(plugin.SlotHTML("admin.head.end", slotData))
+ data["admin_body_end"] = template.HTML(plugin.SlotHTML("admin.body.end", slotData))
+ data["admin_sidebar_after"] = template.HTML(plugin.SlotHTML("admin.sidebar.after", slotData))
+ data["admin_settings_after"] = template.HTML(plugin.SlotHTML("admin.settings.after", slotData))
+ data["admin_dashboard_after"] = template.HTML(plugin.SlotHTML("admin.dashboard.after", slotData))
+ data["comments_after"] = template.HTML(plugin.SlotHTML("comments.after", slotData))
+ data["compose_after"] = template.HTML(plugin.SlotHTML("compose.after", slotData))
+ data["profile_after"] = template.HTML(plugin.SlotHTML("profile.after", slotData))
+ data["detail_actions_after"] = template.HTML(plugin.SlotHTML("post.detail.actions.after", slotData))
+ data["detail_after"] = template.HTML(plugin.SlotHTML("post.detail.after", slotData))
+ data["plugin_menus"] = plugin.AdminMenus()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := Tmpl.ExecuteTemplate(w, layout, data); err != nil {
util.Log("error", "模板渲染失败 %s: %v", page, err)
@@ -111,12 +177,19 @@ func fail(w http.ResponseWriter, code int, msg string) {
JSON(w, code, map[string]any{"ok": false, "msg": msg})
}
-// okJSON 输出成功 JSON
+// okJSON 输出成功 JSON(应用型插件可通过 filter.api.response 改写响应体)
func okJSON(w http.ResponseWriter, extra map[string]any) {
m := map[string]any{"ok": true}
for k, v := range extra {
m[k] = v
}
+ if plugin.HasApps() {
+ if out := plugin.ApplyFilterValue("filter.api.response", m, nil); out != nil {
+ if mm, ok := out.(map[string]any); ok {
+ m = mm
+ }
+ }
+ }
JSON(w, http.StatusOK, m)
}
diff --git a/internal/handlers/plugin_bridge.go b/internal/handlers/plugin_bridge.go
new file mode 100644
index 0000000..619d0a0
--- /dev/null
+++ b/internal/handlers/plugin_bridge.go
@@ -0,0 +1,192 @@
+// 把 handlers 的模板与会话能力注入插件运行时。
+//
+// 依赖方向是 handlers -> plugin,因此由 handlers 实现 plugin.ViewProvider
+// 并反向注册,避免 plugin 包 import handlers 造成循环依赖。
+package handlers
+
+import (
+ "bytes"
+ "html/template"
+ "io/fs"
+ "net/http"
+
+ "clearlove/internal/middleware"
+ "clearlove/internal/plugin"
+)
+
+type pluginViewProvider struct{}
+
+// RenderTemplate 渲染插件目录下的模板文件
+func (pluginViewProvider) RenderTemplate(fsys fs.FS, name string, data any) (string, error) {
+ t, err := template.New(name).Funcs(tmplFuncs).ParseFS(fsys, name)
+ if err != nil {
+ return "", err
+ }
+ var buf bytes.Buffer
+ if err := t.ExecuteTemplate(&buf, name, data); err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+}
+
+func (pluginViewProvider) CSRF(r *http.Request) string { return middleware.CSRFToken(r) }
+
+func (pluginViewProvider) UserID(r *http.Request) int64 {
+ if u := CurrentUser(r); u != nil {
+ return u.ID
+ }
+ return 0
+}
+
+func (pluginViewProvider) AdminID(r *http.Request) int64 {
+ if a := CurrentAdmin(r); a != nil {
+ return a.ID
+ }
+ return 0
+}
+
+func (pluginViewProvider) AdminPerm(r *http.Request, perm string) bool {
+ a := CurrentAdmin(r)
+ return a != nil && a.HasPerm(perm)
+}
+
+// pluginSlotData 构造传给插件 Slot 处理的轻量数据(避免整页数据序列化开销)
+func pluginSlotData(r *http.Request, page string, data map[string]any) map[string]any {
+ out := map[string]any{"page": page, "path": r.URL.Path}
+ for _, k := range []string{"site", "topic", "notice"} {
+ if v, ok := data[k]; ok {
+ out[k] = v
+ }
+ }
+ if u := CurrentUser(r); u != nil {
+ out["user"] = map[string]any{"id": u.ID, "username": u.Username}
+ }
+ if a := CurrentAdmin(r); a != nil {
+ out["admin"] = map[string]any{"id": a.ID, "username": a.Username, "role": a.Role}
+ }
+ // 详情页/个人主页把当前帖子(或列表)的轻量信息交给插件 Slot
+ switch p := data["post"].(type) {
+ case Card:
+ out["post"] = map[string]any{"id": p.ID, "user_id": p.UserID, "nickname": p.Nickname}
+ case *Card:
+ if p != nil {
+ out["post"] = map[string]any{"id": p.ID, "user_id": p.UserID, "nickname": p.Nickname}
+ }
+ }
+ return out
+}
+
+var _ plugin.ViewProvider = pluginViewProvider{}
+
+// ---------- 过滤器接入辅助 ----------
+
+func strOfAny(v any) string {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ return ""
+}
+
+func boolOfAny(v any) bool {
+ switch t := v.(type) {
+ case bool:
+ return t
+ case string:
+ return t == "1" || t == "true"
+ case int64:
+ return t != 0
+ case float64:
+ return t != 0
+ default:
+ return false
+ }
+}
+
+func intOfAny(v any) int64 {
+ switch t := v.(type) {
+ case int64:
+ return t
+ case int:
+ return int64(t)
+ case float64:
+ return int64(t)
+ default:
+ return 0
+ }
+}
+
+func anyToStrings(v any) []string {
+ switch list := v.(type) {
+ case []string:
+ return list
+ case []any:
+ out := make([]string, 0, len(list))
+ for _, item := range list {
+ if s, ok := item.(string); ok {
+ out = append(out, s)
+ }
+ }
+ return out
+ default:
+ return nil
+ }
+}
+
+// cardToMap 把帖子卡片转成插件可读写的普通对象
+func cardToMap(c *Card) map[string]any {
+ return map[string]any{
+ "id": c.ID,
+ "user_id": c.UserID,
+ "nickname": c.Nickname,
+ "content": c.Content,
+ "topic": c.Topic,
+ "images": c.Images,
+ "videos": c.Videos,
+ "likes": c.Likes,
+ "comment_count": c.CommentCount,
+ "is_admin": c.IsAdmin,
+ "badges": c.Badges,
+ "created_at": c.CreatedAt,
+ }
+}
+
+// mapToCard 把插件改写后的字段写回卡片(id/user_id 不允许改)
+func mapToCard(m map[string]any, c *Card) {
+ if v, ok := m["nickname"].(string); ok && v != "" {
+ c.Nickname = v
+ }
+ if v, ok := m["content"].(string); ok {
+ c.Content = v
+ }
+ if v, ok := m["topic"].(string); ok {
+ c.Topic = v
+ }
+ if v, ok := m["likes"]; ok {
+ c.Likes = intOfAny(v)
+ }
+ if v, ok := m["comment_count"]; ok {
+ c.CommentCount = intOfAny(v)
+ }
+ if v, ok := m["images"]; ok {
+ c.Images = anyToStrings(v)
+ }
+ if v, ok := m["videos"]; ok {
+ c.Videos = anyToStrings(v)
+ }
+ if v, ok := m["badges"]; ok {
+ if list := anyToStrings(v); list != nil {
+ c.Badges = list
+ }
+ }
+}
+
+// applyCardFilter 让插件改写卡片数据(filter.card)
+func applyCardFilter(c *Card) {
+ if !plugin.HasApps() || c == nil {
+ return
+ }
+ out := plugin.ApplyFilterValue("filter.card", cardToMap(c), nil)
+ if m, ok := out.(map[string]any); ok {
+ mapToCard(m, c)
+ }
+}
diff --git a/internal/handlers/selfupdate.go b/internal/handlers/selfupdate.go
new file mode 100644
index 0000000..5ac31f5
--- /dev/null
+++ b/internal/handlers/selfupdate.go
@@ -0,0 +1,275 @@
+// 一键自更新(仅 Linux x86_64):
+// 管理员在后台「检查更新」页点击"立即更新",程序从 update_url 指向的云端 JSON
+// 获取版本信息,下载 linux-amd64 二进制,校验 SHA256 与 ELF 头后原子替换自身,
+// 最后通过 syscall.Exec 原地重启(同 PID,外部守护进程无感知)。
+//
+// 云端 JSON 契约(兼容旧版检查更新的 version/notes/download 字段):
+//
+// {
+// "version": "2.0.1",
+// "notes": "更新说明",
+// "packages": {
+// "linux-amd64": { "url": "二进制直链", "sha256": "…", "size": 21823488 }
+// }
+// }
+//
+// 未提供 packages 时回退使用顶层 download + sha256;缺少 sha256 时拒绝自动更新。
+package handlers
+
+import (
+ "crypto/sha256"
+ "encoding/binary"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "time"
+
+ "clearlove/internal/config"
+ "clearlove/internal/models"
+ "clearlove/internal/util"
+)
+
+// selfUpdateMu 防止并发触发两次更新流程
+var selfUpdateMu sync.Mutex
+
+// maxSelfUpdateBytes 二进制下载上限
+const maxSelfUpdateBytes = 256 << 20
+
+// updatePackage 单平台下载条目
+type updatePackage struct {
+ URL string `json:"url"`
+ SHA256 string `json:"sha256"`
+ Size int64 `json:"size"`
+}
+
+// updateInfo 云端更新接口(update_url)返回的 JSON
+type updateInfo struct {
+ Version string `json:"version"`
+ Notes string `json:"notes"`
+ Download string `json:"download"`
+ SHA256 string `json:"sha256"`
+ Packages map[string]updatePackage `json:"packages"`
+}
+
+// AdminUpdateApply 执行一键更新(POST /admin/update/apply)
+func AdminUpdateApply(w http.ResponseWriter, r *http.Request) {
+ a := requireAdmin(w, r, models.PermSetting)
+ if a == nil {
+ return
+ }
+ if !selfUpdateMu.TryLock() {
+ fail(w, http.StatusTooManyRequests, "已有一次更新正在进行,请稍后再试")
+ return
+ }
+ defer selfUpdateMu.Unlock()
+ applySelfUpdate(w, r, a)
+}
+
+func applySelfUpdate(w http.ResponseWriter, r *http.Request, a *models.Admin) {
+ // 1) 平台门禁:本特性仅针对 Linux x86_64
+ if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" {
+ fail(w, http.StatusBadRequest, fmt.Sprintf("一键更新仅支持 Linux x86_64(当前 %s/%s),请手动替换二进制", runtime.GOOS, runtime.GOARCH))
+ return
+ }
+
+ // 2) 拉取并校验更新信息
+ info, err := fetchUpdateInfo()
+ if err != nil {
+ fail(w, http.StatusBadGateway, err.Error())
+ return
+ }
+ if info.Version == "" {
+ fail(w, http.StatusBadGateway, "更新接口未提供版本号")
+ return
+ }
+ if info.Version <= config.Version {
+ fail(w, http.StatusBadRequest, "当前已是最新版本(云端 "+info.Version+")")
+ return
+ }
+ dlURL, wantSum := linuxAMD64Package(info)
+ if dlURL == "" {
+ fail(w, http.StatusBadGateway, "更新接口未提供 linux-amd64 下载地址")
+ return
+ }
+ wantSum = strings.ToLower(strings.TrimSpace(wantSum))
+ if wantSum == "" {
+ fail(w, http.StatusBadGateway, "更新接口未提供 SHA256 校验值,为安全起见拒绝自动更新")
+ return
+ }
+
+ // 3) 下载到可执行文件同目录的临时文件(保证同分区,rename 原子生效)
+ exe, err := os.Executable()
+ if err != nil {
+ fail(w, http.StatusInternalServerError, "无法定位当前程序:"+err.Error())
+ return
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(exe), ".clearlove-update-*.tmp")
+ if err != nil {
+ fail(w, http.StatusInternalServerError, "无法创建临时文件:"+err.Error())
+ return
+ }
+ tmpName := tmp.Name()
+ cleanup := func() { _ = tmp.Close(); _ = os.Remove(tmpName) }
+
+ client := &http.Client{Timeout: 10 * time.Minute}
+ resp, err := client.Get(dlURL)
+ if err != nil {
+ cleanup()
+ fail(w, http.StatusBadGateway, "下载失败:"+err.Error())
+ return
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ cleanup()
+ fail(w, http.StatusBadGateway, "下载失败:云端返回 "+resp.Status)
+ return
+ }
+ written, err := io.Copy(tmp, io.LimitReader(resp.Body, maxSelfUpdateBytes))
+ if cerr := tmp.Close(); err == nil {
+ err = cerr
+ }
+ if err != nil || written <= 0 {
+ cleanup()
+ fail(w, http.StatusBadGateway, "下载失败:网络中断或内容为空")
+ return
+ }
+ if written >= maxSelfUpdateBytes {
+ cleanup()
+ fail(w, http.StatusBadGateway, "下载失败:文件超过大小上限")
+ return
+ }
+
+ // 4) SHA256 校验
+ sum, err := fileSHA256(tmpName)
+ if err != nil {
+ cleanup()
+ fail(w, http.StatusInternalServerError, "读取下载文件失败:"+err.Error())
+ return
+ }
+ if sum != wantSum {
+ cleanup()
+ fail(w, http.StatusBadRequest, "SHA256 校验失败,云端文件与校验值不一致(已放弃更新)")
+ return
+ }
+
+ // 5) ELF 平台校验(防止云端误传其它平台的文件)
+ if err := validLinuxAMD64ELF(tmpName); err != nil {
+ cleanup()
+ fail(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ // 6) 补执行权限
+ if err := os.Chmod(tmpName, 0o755); err != nil {
+ cleanup()
+ fail(w, http.StatusInternalServerError, "设置执行权限失败:"+err.Error())
+ return
+ }
+
+ // 7) 原子替换:旧程序留作 .bak 便于回滚
+ bak := exe + ".bak"
+ _ = os.Remove(bak)
+ if err := os.Rename(exe, bak); err != nil {
+ cleanup()
+ fail(w, http.StatusInternalServerError, "备份当前程序失败:"+err.Error())
+ return
+ }
+ if err := os.Rename(tmpName, exe); err != nil {
+ _ = os.Rename(bak, exe)
+ fail(w, http.StatusInternalServerError, "替换程序失败(已回滚):"+err.Error())
+ return
+ }
+ util.Log("info", "管理员 %s(#%d)执行一键更新:%s -> %s", a.Username, a.ID, config.Version, info.Version)
+
+ // 8) 先应答,再原地换载新二进制(syscall.Exec,同 PID)
+ okJSON(w, map[string]any{
+ "msg": "更新完成(" + config.Version + " → " + info.Version + "),程序正在重启,页面稍后自动刷新",
+ "version": info.Version,
+ })
+ if f, ok := w.(http.Flusher); ok {
+ f.Flush()
+ }
+ time.Sleep(500 * time.Millisecond)
+ if err := restartSelf(exe); err != nil {
+ // 新二进制已就位但重启失败:保留新文件,等服务下次重启时生效
+ util.Log("error", "自动重启失败,新版本已就位,请手动重启服务:%v", err)
+ }
+}
+
+// fetchUpdateInfo 请求 update_url 获取更新信息
+func fetchUpdateInfo() (*updateInfo, error) {
+ url := strings.TrimSpace(models.GetSetting("update_url"))
+ if url == "" {
+ return nil, errors.New("未配置更新接口地址(网站设置 → 更新接口地址)")
+ }
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Get(url)
+ if err != nil {
+ return nil, errors.New("无法连接更新服务器")
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("更新服务器返回 %s", resp.Status)
+ }
+ var info updateInfo
+ if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&info); err != nil {
+ return nil, errors.New("更新接口返回内容不是有效的 JSON")
+ }
+ return &info, nil
+}
+
+// linuxAMD64Package 取 linux-amd64 的下载地址与校验值;无 packages 时回退顶层字段
+func linuxAMD64Package(info *updateInfo) (url, sha string) {
+ if info.Packages != nil {
+ if p, ok := info.Packages["linux-amd64"]; ok {
+ return p.URL, p.SHA256
+ }
+ }
+ return info.Download, info.SHA256
+}
+
+// fileSHA256 计算文件 SHA256(十六进制小写)
+func fileSHA256(path string) (string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ h := sha256.New()
+ if _, err := io.Copy(h, f); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(h.Sum(nil)), nil
+}
+
+// validLinuxAMD64ELF 校验文件是 64 位 x86-64 ELF:
+// 魔数 \x7fELF、EI_CLASS=2(64 位)、e_machine=62(EM_X86_64)。
+func validLinuxAMD64ELF(path string) error {
+ f, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ head := make([]byte, 20)
+ if _, err := io.ReadFull(f, head); err != nil {
+ return errors.New("下载文件过小,不是有效的程序文件")
+ }
+ if head[0] != 0x7f || head[1] != 'E' || head[2] != 'L' || head[3] != 'F' {
+ return errors.New("下载文件不是 ELF 程序(云端是否误传了压缩包?)")
+ }
+ if head[4] != 2 {
+ return errors.New("下载文件不是 64 位 ELF")
+ }
+ if machine := binary.LittleEndian.Uint16(head[18:20]); machine != 62 {
+ return errors.New("下载文件不是 x86_64 架构的 ELF")
+ }
+ return nil
+}
diff --git a/internal/handlers/selfupdate_linux.go b/internal/handlers/selfupdate_linux.go
new file mode 100644
index 0000000..d583987
--- /dev/null
+++ b/internal/handlers/selfupdate_linux.go
@@ -0,0 +1,14 @@
+//go:build linux
+
+// Linux 平台:通过 syscall.Exec 原地换载新二进制(同 PID,监听端口短暂重置)。
+package handlers
+
+import (
+ "os"
+ "syscall"
+)
+
+// restartSelf 以 exe 替换当前进程映像继续运行(新版本立即生效)
+func restartSelf(exe string) error {
+ return syscall.Exec(exe, os.Args, os.Environ())
+}
diff --git a/internal/handlers/selfupdate_other.go b/internal/handlers/selfupdate_other.go
new file mode 100644
index 0000000..7dac0a0
--- /dev/null
+++ b/internal/handlers/selfupdate_other.go
@@ -0,0 +1,11 @@
+//go:build !linux
+
+// 非 Linux 平台不支持原地重启(一键更新在入口处已按平台拦截,此实现仅为满足编译)。
+package handlers
+
+import "errors"
+
+// restartSelf 非 Linux 平台返回错误
+func restartSelf(_ string) error {
+ return errors.New("当前平台不支持自动重启")
+}
diff --git a/internal/handlers/store.go b/internal/handlers/store.go
index 735e769..25ebed6 100644
--- a/internal/handlers/store.go
+++ b/internal/handlers/store.go
@@ -46,14 +46,21 @@ type StorePlugin struct {
UpdatedAt string `json:"updated_at"`
PageURL string `json:"page_url"`
DownloadURL string `json:"download_url"`
+ // 应用型插件(kind=app)的形态与权限声明,供安装前审阅
+ Kind string `json:"kind"`
+ Permissions []string `json:"permissions"`
}
-// fetchStoreList 拉取云端插件列表,返回列表与错误提示
-func fetchStoreList(q string) ([]StorePlugin, string) {
+// fetchStoreList 拉取云端插件列表,返回列表与错误提示。
+// kind 为空表示全部形态,可传 app(应用型)/ declarative(声明式)。
+func fetchStoreList(q, kind string) ([]StorePlugin, string) {
u := cloudBase() + "/api/v1/store/plugins?limit=50"
if q != "" {
u += "&q=" + url.QueryEscape(q)
}
+ if kind != "" {
+ u += "&kind=" + url.QueryEscape(kind)
+ }
client := &http.Client{Timeout: 12 * time.Second}
resp, err := client.Get(u)
if err != nil {
@@ -79,16 +86,28 @@ func AdminStore(w http.ResponseWriter, r *http.Request) {
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
- list, errMsg := fetchStoreList(q)
+ kind := strings.TrimSpace(r.URL.Query().Get("kind"))
+ if kind != "app" && kind != "declarative" {
+ kind = ""
+ }
+ list, errMsg := fetchStoreList(q, kind)
// 本地已安装插件(按 plugin.json 的 name 匹配)
installed := map[string]bool{}
for _, p := range plugin.List() {
installed[p.Name] = true
}
+ appNum := 0
+ for _, sp := range list {
+ if sp.Kind == "app" {
+ appNum++
+ }
+ }
Render(w, r, "admin_layout.html", "pg_admin_store", map[string]any{
"plugins": list,
"installed": installed,
"q": q,
+ "kind": kind,
+ "appNum": appNum,
"cloud": cloudBase(),
"errMsg": errMsg,
"notice": r.URL.Query().Get("msg"),
diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go
index b54e9c8..b57d069 100644
--- a/internal/middleware/middleware.go
+++ b/internal/middleware/middleware.go
@@ -4,13 +4,16 @@ package middleware
import (
"context"
+ "encoding/json"
"net/http"
+ "net/url"
"strings"
"time"
"clearlove/internal/config"
"clearlove/internal/database"
"clearlove/internal/models"
+ "clearlove/internal/plugin"
"clearlove/internal/util"
)
@@ -51,6 +54,35 @@ func Use(next http.Handler) http.Handler {
return
}
+ // 应用型插件中间件(http.before):在 CSRF 校验之前执行,可短路响应
+ if plugin.HasApps() && !strings.HasPrefix(r.URL.Path, "/static/") && !strings.HasPrefix(r.URL.Path, "/uploads/") {
+ if res := plugin.RunMiddlewareBefore(map[string]any{
+ "method": r.Method,
+ "path": r.URL.Path,
+ "query": firstValues(r.URL.Query()),
+ "ip": util.ClientIP(r),
+ "fingerprint": util.FingerprintOf(r),
+ }); res != nil {
+ for k, v := range res.Headers {
+ w.Header().Set(k, v)
+ }
+ switch {
+ case res.Redirect != "":
+ http.Redirect(w, r, res.Redirect, http.StatusFound)
+ case res.JSON != nil:
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(res.Status)
+ _ = json.NewEncoder(w).Encode(res.JSON)
+ case res.Body != "":
+ w.WriteHeader(res.Status)
+ _, _ = w.Write([]byte(res.Body))
+ default:
+ w.WriteHeader(res.Status)
+ }
+ return
+ }
+ }
+
// CSRF 令牌:缺失则生成并写入 Cookie,同时放入上下文
token := ""
if c, err := r.Cookie("clv_csrf"); err == nil {
@@ -98,11 +130,44 @@ func Use(next http.Handler) http.Handler {
}
}
+ // 应用型插件的 http.after:需要拿到最终状态码,这里包装一层 ResponseWriter
+ var sw *statusWriter
+ if plugin.HasApps() {
+ sw = &statusWriter{ResponseWriter: w, status: http.StatusOK}
+ w = sw
+ }
next.ServeHTTP(w, r)
+ if sw != nil {
+ plugin.RunMiddlewareAfter(map[string]any{
+ "method": r.Method,
+ "path": r.URL.Path,
+ "status": sw.status,
+ "ip": util.ClientIP(r),
+ "fingerprint": util.FingerprintOf(r),
+ "duration_ms": time.Since(start).Milliseconds(),
+ })
+ }
util.Log("info", "%s %s %s %s", r.Method, r.URL.Path, r.RemoteAddr, time.Since(start).Round(time.Millisecond))
})
}
+// statusWriter 记录响应状态码(http.after 使用)
+type statusWriter struct {
+ http.ResponseWriter
+ status int
+}
+
+func (s *statusWriter) WriteHeader(code int) {
+ s.status = code
+ s.ResponseWriter.WriteHeader(code)
+}
+
+func (s *statusWriter) Flush() {
+ if f, ok := s.ResponseWriter.(http.Flusher); ok {
+ f.Flush()
+ }
+}
+
// exemptCSRF API Key 请求(第三方客户端)无需 CSRF
func exemptCSRF(r *http.Request) bool {
k := r.Header.Get("X-API-Key")
@@ -114,6 +179,17 @@ func exemptCSRF(r *http.Request) bool {
return err == nil && n > 0
}
+// firstValues 把 query 转成一维 map(供插件中间件使用)
+func firstValues(v url.Values) map[string]string {
+ out := make(map[string]string, len(v))
+ for k, list := range v {
+ if len(list) > 0 {
+ out[k] = list[0]
+ }
+ }
+ return out
+}
+
// isBanned 检查 IP 是否在封禁名单
func isBanned(ip string) bool {
var n int64
diff --git a/internal/plugin/admininfo.go b/internal/plugin/admininfo.go
new file mode 100644
index 0000000..7cf5c59
--- /dev/null
+++ b/internal/plugin/admininfo.go
@@ -0,0 +1,108 @@
+// 面向后台管理页的运行时信息查询与模板覆盖支持。
+package plugin
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+
+ "clearlove/internal/database"
+)
+
+// JobInfo 后台展示用的定时任务信息
+type JobInfo struct {
+ Name string `json:"name"`
+ Every string `json:"every"`
+}
+
+// AppInfo 应用型插件在后台展示的运行时概况
+type AppInfo struct {
+ Running bool `json:"running"`
+ Routes int `json:"routes"`
+ Pages int `json:"pages"`
+ Menus int `json:"menus"`
+ Slots int `json:"slots"`
+ Events int `json:"events"`
+ Filters int `json:"filters"`
+ Jobs []JobInfo `json:"jobs"`
+ Perms []string `json:"perms"`
+ Fails int `json:"fails"` // 连续失败次数(熔断进度)
+}
+
+// AppInfoOf 查询某插件目录的运行时概况(未加载时 Running=false)
+func AppInfoOf(dir string) AppInfo {
+ a := GetApp(filepath.Base(dir))
+ if a == nil || a.Plugin == nil {
+ return AppInfo{}
+ }
+ info := AppInfo{
+ Running: true,
+ Perms: a.Plugin.Permissions,
+ Fails: FailCount(a.Plugin.SlugOf()),
+ Routes: len(a.Routes),
+ Pages: len(a.Pages),
+ Menus: len(a.Menus),
+ }
+ for _, fns := range a.Slots {
+ info.Slots += len(fns)
+ }
+ for _, fns := range a.Events {
+ info.Events += len(fns)
+ }
+ for _, list := range a.Filters {
+ info.Filters += len(list)
+ }
+ for _, j := range a.Jobs {
+ info.Jobs = append(info.Jobs, JobInfo{Name: j.Name, Every: j.Every.String()})
+ }
+ return info
+}
+
+// EnabledTemplateDirs 返回已启用插件中带有 templates/ 目录的绝对路径。
+// 内核按顺序重新解析这些目录下的模板,实现"插件覆盖内核同名模板片段"。
+func EnabledTemplateDirs() []string {
+ enabled := enabledSet()
+ var out []string
+ for name, p := range cachedPlugins() {
+ if !enabled[p.Name] {
+ continue
+ }
+ dir := filepath.Join(dir(), filepath.Base(name), "templates")
+ if st, err := os.Stat(dir); err == nil && st.IsDir() {
+ out = append(out, dir)
+ }
+ }
+ return out
+}
+
+// PluginLogs 读取运行日志(后台日志页使用)
+func PluginLogs(slug string, limit int) []map[string]any {
+ if database.DB == nil {
+ return nil
+ }
+ if limit <= 0 || limit > 500 {
+ limit = 200
+ }
+ q := "SELECT id, plugin_slug, action, IFNULL(detail,'') AS detail, duration_ms, IFNULL(created_at,'') AS created_at FROM plugin_logs"
+ var args []any
+ if slug != "" {
+ q += " WHERE plugin_slug=?"
+ args = append(args, slug)
+ }
+ q += " ORDER BY id DESC LIMIT ?"
+ args = append(args, limit)
+ rows, err := queryMaps(database.DB, q, args)
+ if err != nil {
+ return nil
+ }
+ return rows
+}
+
+// ClearPluginLogs 清空运行日志
+func ClearPluginLogs() error {
+ if database.DB == nil {
+ return errors.New("数据库未连接")
+ }
+ _, err := database.DB.Exec("DELETE FROM plugin_logs")
+ return err
+}
diff --git a/internal/plugin/api_db.go b/internal/plugin/api_db.go
new file mode 100644
index 0000000..ea50822
--- /dev/null
+++ b/internal/plugin/api_db.go
@@ -0,0 +1,409 @@
+// Host API:数据层(clv.db.* / clv.table)。
+//
+// 安全策略:
+// - 声明式权限 db 是使用数据能力的前提
+// - 写操作默认只允许本插件前缀(pl_<slug>_)的表;内核表只读
+// - 声明 db.admin 权限可放开全库写(需管理员在后台显式授权)
+// - 查询行数设上限,防止插件拉爆内存
+package plugin
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "regexp"
+ "sort"
+ "strings"
+
+ "github.com/dop251/goja"
+
+ "clearlove/internal/database"
+)
+
+const maxQueryRows = 1000
+
+type sqlQuerier interface {
+ Query(query string, args ...any) (*sql.Rows, error)
+}
+
+type sqlExecer interface {
+ Exec(query string, args ...any) (sql.Result, error)
+}
+
+func (rt *jsRuntime) installDB(clv *goja.Object) error {
+ db := rt.vm.NewObject()
+
+ _ = db.Set("dialect", rt.jsFn(func(call goja.FunctionCall) (any, error) {
+ return database.Driver, nil
+ }))
+
+ _ = db.Set("table", rt.jsFn(func(call goja.FunctionCall) (any, error) {
+ args := argsOf(call)
+ if len(args) == 0 {
+ return nil, errors.New("clv.db.table(name) 需要一个参数")
+ }
+ return rt.plugin.TableName(strOf(args[0])), nil
+ }))
+
+ _ = db.Set("query", rt.jsFn(rt.dbQuery))
+ _ = db.Set("get", rt.jsFn(rt.dbGet))
+ _ = db.Set("exec", rt.jsFn(rt.dbExec))
+ _ = db.Set("tx", rt.jsFn(rt.dbTx))
+ _ = clv.Set("db", db)
+
+ // 建表:clv.table(name, columns, opts)
+ _ = clv.Set("table", rt.jsFn(rt.createTable))
+ return nil
+}
+
+// ---------- SQL 安全 ----------
+
+var (
+ reWriteKeyword = regexp.MustCompile(`(?i)\b(insert|update|delete|replace|alter|drop|create|truncate|rename)\b`)
+ reTableRef = regexp.MustCompile("(?i)\\b(?:into|update|from|table|join)\\s+[`\"']?([a-zA-Z0-9_]+)")
+)
+
+// sqlAllowed 写操作必须落在本插件前缀的表上
+func (rt *jsRuntime) sqlAllowed(sqlText string) bool {
+ if rt.app != nil && rt.app.Plugin.HasPermission("db.admin") {
+ return true
+ }
+ if !reWriteKeyword.MatchString(sqlText) {
+ return true // 纯读取
+ }
+ prefix := strings.ToLower(rt.plugin.TablePrefix())
+ for _, m := range reTableRef.FindAllStringSubmatch(sqlText, -1) {
+ if !strings.HasPrefix(strings.ToLower(m[1]), prefix) {
+ return false
+ }
+ }
+ return true
+}
+
+func (rt *jsRuntime) sqlDeniedErr(sqlText string) error {
+ return fmt.Errorf("插件 %s 只能写自己的数据表(%s*);如需访问其它表请声明 db.admin 权限。SQL: %.100s",
+ rt.plugin.SlugOf(), rt.plugin.TablePrefix(), strings.TrimSpace(sqlText))
+}
+
+// ---------- db.query / db.get / db.exec ----------
+
+func (rt *jsRuntime) dbQuery(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("db"); err != nil {
+ return nil, err
+ }
+ args := argsOf(call)
+ if len(args) == 0 || strOf(args[0]) == "" {
+ return nil, errors.New("clv.db.query(sql, ...args) 需要 SQL")
+ }
+ sqlText := strOf(args[0])
+ if !rt.sqlAllowed(sqlText) {
+ return nil, rt.sqlDeniedErr(sqlText)
+ }
+ if database.DB == nil {
+ return nil, errors.New("数据库未连接")
+ }
+ return queryMaps(database.DB, sqlText, args[1:])
+}
+
+func (rt *jsRuntime) dbGet(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("db"); err != nil {
+ return nil, err
+ }
+ args := argsOf(call)
+ if len(args) == 0 || strOf(args[0]) == "" {
+ return nil, errors.New("clv.db.get(sql, ...args) 需要 SQL")
+ }
+ sqlText := strOf(args[0])
+ if !rt.sqlAllowed(sqlText) {
+ return nil, rt.sqlDeniedErr(sqlText)
+ }
+ if database.DB == nil {
+ return nil, errors.New("数据库未连接")
+ }
+ rows, err := queryMaps(database.DB, sqlText, args[1:])
+ if err != nil {
+ return nil, err
+ }
+ if len(rows) == 0 {
+ return nil, nil
+ }
+ return rows[0], nil
+}
+
+func (rt *jsRuntime) dbExec(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("db"); err != nil {
+ return nil, err
+ }
+ args := argsOf(call)
+ if len(args) == 0 || strOf(args[0]) == "" {
+ return nil, errors.New("clv.db.exec(sql, ...args) 需要 SQL")
+ }
+ sqlText := strOf(args[0])
+ if !rt.sqlAllowed(sqlText) {
+ return nil, rt.sqlDeniedErr(sqlText)
+ }
+ if database.DB == nil {
+ return nil, errors.New("数据库未连接")
+ }
+ return execResult(database.DB, sqlText, args[1:])
+}
+
+// dbTx 事务:clv.db.tx(function (tx) { tx.exec(...); tx.get(...); })
+func (rt *jsRuntime) dbTx(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("db"); err != nil {
+ return nil, err
+ }
+ fn, ok := goja.AssertFunction(call.Argument(0))
+ if !ok {
+ return nil, errors.New("clv.db.tx(fn) 需要传入函数")
+ }
+ if database.DB == nil {
+ return nil, errors.New("数据库未连接")
+ }
+ tx, err := database.DB.Begin()
+ if err != nil {
+ return nil, fmt.Errorf("开启事务失败: %w", err)
+ }
+ txObj := rt.vm.NewObject()
+ _ = txObj.Set("exec", rt.jsFn(func(c goja.FunctionCall) (any, error) {
+ a := argsOf(c)
+ if len(a) == 0 {
+ return nil, errors.New("tx.exec(sql, ...args) 需要 SQL")
+ }
+ s := strOf(a[0])
+ if !rt.sqlAllowed(s) {
+ return nil, rt.sqlDeniedErr(s)
+ }
+ return execResult(tx, s, a[1:])
+ }))
+ _ = txObj.Set("get", rt.jsFn(func(c goja.FunctionCall) (any, error) {
+ a := argsOf(c)
+ if len(a) == 0 {
+ return nil, errors.New("tx.get(sql, ...args) 需要 SQL")
+ }
+ s := strOf(a[0])
+ if !rt.sqlAllowed(s) {
+ return nil, rt.sqlDeniedErr(s)
+ }
+ rows, err := queryMaps(tx, s, a[1:])
+ if err != nil {
+ return nil, err
+ }
+ if len(rows) == 0 {
+ return nil, nil
+ }
+ return rows[0], nil
+ }))
+ _ = txObj.Set("query", rt.jsFn(func(c goja.FunctionCall) (any, error) {
+ a := argsOf(c)
+ if len(a) == 0 {
+ return nil, errors.New("tx.query(sql, ...args) 需要 SQL")
+ }
+ s := strOf(a[0])
+ if !rt.sqlAllowed(s) {
+ return nil, rt.sqlDeniedErr(s)
+ }
+ return queryMaps(tx, s, a[1:])
+ }))
+
+ v, err := fn(goja.Undefined(), txObj)
+ if err != nil {
+ _ = tx.Rollback()
+ return nil, err
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, fmt.Errorf("提交事务失败: %w", err)
+ }
+ return exportJS(v), nil
+}
+
+// ---------- 建表 ----------
+
+// createTable clv.table(name, columns, opts) —— 幂等建表
+func (rt *jsRuntime) createTable(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("db"); err != nil {
+ return nil, err
+ }
+ if database.DB == nil {
+ return nil, errors.New("数据库未连接")
+ }
+ name := NormalizeSlug(strOf(call.Argument(0)))
+ if name == "" {
+ return nil, errors.New("clv.table(name, columns, opts) 需要表名(字母/数字/下划线)")
+ }
+ cols := mapOfAny(call.Argument(1))
+ if len(cols) == 0 {
+ return nil, errors.New("clv.table 需要列定义对象,例如 { user_id: 'int', day: 'string' }")
+ }
+ opts := mapOfAny(call.Argument(2))
+
+ table := rt.plugin.TableName(name)
+ ddl := buildCreateTable(database.Driver, table, cols, opts)
+ if _, err := database.DB.Exec(ddl); err != nil {
+ return nil, fmt.Errorf("建表 %s 失败: %w", table, err)
+ }
+ for _, ix := range buildIndexes(table, opts) {
+ _, _ = database.DB.Exec(ix) // MySQL 不支持 IF NOT EXISTS,重复创建的错误忽略
+ }
+ return table, nil
+}
+
+func sqlType(driver, t string) string {
+ t = strings.ToLower(strings.TrimSpace(t))
+ if driver == "mysql" {
+ switch t {
+ case "int":
+ return "INT"
+ case "bigint":
+ return "BIGINT"
+ case "float", "number":
+ return "DOUBLE"
+ case "bool":
+ return "TINYINT"
+ case "text":
+ return "MEDIUMTEXT"
+ case "json":
+ return "MEDIUMTEXT"
+ case "time":
+ return "VARCHAR(40)"
+ default:
+ return "VARCHAR(255)"
+ }
+ }
+ switch t {
+ case "int", "bigint", "bool":
+ return "INTEGER"
+ case "float", "number":
+ return "REAL"
+ case "time":
+ return "TEXT"
+ default:
+ return "TEXT"
+ }
+}
+
+func buildCreateTable(driver, table string, cols map[string]any, opts map[string]any) string {
+ var b strings.Builder
+ b.WriteString("CREATE TABLE IF NOT EXISTS " + table + " (")
+ if driver == "mysql" {
+ b.WriteString("id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT")
+ } else {
+ b.WriteString("id INTEGER PRIMARY KEY AUTOINCREMENT")
+ }
+ names := make([]string, 0, len(cols))
+ for k := range cols {
+ names = append(names, k)
+ }
+ sort.Strings(names)
+ for _, k := range names {
+ col := strings.ReplaceAll(NormalizeSlug(k), "-", "_")
+ if col == "" || col == "id" {
+ continue
+ }
+ b.WriteString(", " + col + " " + sqlType(driver, strOf(cols[k])))
+ }
+ for _, u := range listOfLists(opts["unique"]) {
+ b.WriteString(", UNIQUE(" + strings.Join(u, ",") + ")")
+ }
+ b.WriteString(")")
+ return b.String()
+}
+
+func buildIndexes(table string, opts map[string]any) []string {
+ var out []string
+ for i, cols := range listOfLists(opts["indexes"]) {
+ out = append(out, fmt.Sprintf("CREATE INDEX idx_%s_%d ON %s (%s)",
+ table, i, table, strings.Join(cols, ",")))
+ }
+ return out
+}
+
+// ---------- 通用查询辅助 ----------
+
+func queryMaps(q sqlQuerier, sqlText string, args []any) ([]map[string]any, error) {
+ rows, err := q.Query(sqlText, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ cols, err := rows.Columns()
+ if err != nil {
+ return nil, err
+ }
+ out := make([]map[string]any, 0, 16)
+ for rows.Next() {
+ if len(out) >= maxQueryRows {
+ return nil, fmt.Errorf("查询结果超过 %d 行上限,请加 LIMIT", maxQueryRows)
+ }
+ vals := make([]any, len(cols))
+ ptrs := make([]any, len(cols))
+ for i := range vals {
+ ptrs[i] = &vals[i]
+ }
+ if err := rows.Scan(ptrs...); err != nil {
+ return nil, err
+ }
+ m := make(map[string]any, len(cols))
+ for i, c := range cols {
+ m[c] = normalizeSQLValue(vals[i])
+ }
+ out = append(out, m)
+ }
+ return out, rows.Err()
+}
+
+func execResult(e sqlExecer, sqlText string, args []any) (map[string]any, error) {
+ res, err := e.Exec(sqlText, args...)
+ if err != nil {
+ return nil, err
+ }
+ lastID, _ := res.LastInsertId()
+ affected, _ := res.RowsAffected()
+ return map[string]any{"lastId": lastID, "rowsAffected": affected}, nil
+}
+
+// normalizeSQLValue 统一驱动差异([]byte -> string 等)
+func normalizeSQLValue(v any) any {
+ switch t := v.(type) {
+ case []byte:
+ return string(t)
+ default:
+ return v
+ }
+}
+
+// mapOfAny 取 goja 对象参数为 map
+func mapOfAny(v goja.Value) map[string]any {
+ if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
+ return nil
+ }
+ if m, ok := v.Export().(map[string]any); ok {
+ return m
+ }
+ return nil
+}
+
+// listOfLists 解析 [["a","b"],["c"]] 结构
+func listOfLists(v any) [][]string {
+ arr, ok := v.([]any)
+ if !ok {
+ return nil
+ }
+ out := make([][]string, 0, len(arr))
+ for _, item := range arr {
+ inner, ok := item.([]any)
+ if !ok {
+ continue
+ }
+ row := make([]string, 0, len(inner))
+ for _, c := range inner {
+ if col := strings.ReplaceAll(NormalizeSlug(strOf(c)), "-", "_"); col != "" {
+ row = append(row, col)
+ }
+ }
+ if len(row) > 0 {
+ out = append(out, row)
+ }
+ }
+ return out
+}
diff --git a/internal/plugin/api_net.go b/internal/plugin/api_net.go
new file mode 100644
index 0000000..60b54af
--- /dev/null
+++ b/internal/plugin/api_net.go
@@ -0,0 +1,370 @@
+// Host API:网络与其它能力(clv.http / clv.cache / clv.crypto / clv.mail / clv.media)。
+package plugin
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/dop251/goja"
+
+ "clearlove/internal/mailer"
+ "clearlove/internal/models"
+ "clearlove/internal/util"
+)
+
+const (
+ maxHTTPBody = 2 << 20 // 单次出网响应上限 2MB
+ maxCacheTTL = 24 * time.Hour
+)
+
+func (rt *jsRuntime) installNet(clv *goja.Object) error {
+ // ---------- clv.http ----------
+ h := rt.vm.NewObject()
+ _ = h.Set("request", rt.jsFn(rt.httpRequest))
+ _ = clv.Set("http", h)
+
+ // ---------- clv.cache ----------
+ c := rt.vm.NewObject()
+ _ = c.Set("get", rt.jsFn(rt.cacheGet))
+ _ = c.Set("set", rt.jsFn(rt.cacheSet))
+ _ = c.Set("del", rt.jsFn(rt.cacheDel))
+ _ = clv.Set("cache", c)
+
+ // ---------- clv.crypto ----------
+ cr := rt.vm.NewObject()
+ _ = cr.Set("bcrypt", rt.jsFn(func(call goja.FunctionCall) (any, error) {
+ args := argsOf(call)
+ if len(args) == 0 {
+ return nil, errors.New("clv.crypto.bcrypt(pwd) 需要一个参数")
+ }
+ return util.HashPassword(strOf(args[0])), nil
+ }))
+ _ = cr.Set("verify", rt.jsFn(func(call goja.FunctionCall) (any, error) {
+ args := argsOf(call)
+ if len(args) < 2 {
+ return nil, errors.New("clv.crypto.verify(hash, pwd) 需要两个参数")
+ }
+ return util.CheckPassword(strOf(args[0]), strOf(args[1])), nil
+ }))
+ _ = cr.Set("hmacSha256", rt.jsFn(func(call goja.FunctionCall) (any, error) {
+ args := argsOf(call)
+ if len(args) < 2 {
+ return nil, errors.New("clv.crypto.hmacSha256(key, msg) 需要两个参数")
+ }
+ m := hmac.New(sha256.New, []byte(strOf(args[0])))
+ m.Write([]byte(strOf(args[1])))
+ return hex.EncodeToString(m.Sum(nil)), nil
+ }))
+ _ = cr.Set("randomHex", rt.jsFn(func(call goja.FunctionCall) (any, error) {
+ args := argsOf(call)
+ n := 16
+ if len(args) > 0 {
+ if v := intOf(args[0]); v > 0 && v <= 64 {
+ n = v
+ }
+ }
+ return util.RandomHex(n), nil
+ }))
+ _ = cr.Set("uuid", rt.jsFn(func(call goja.FunctionCall) (any, error) {
+ return randomUUID(), nil
+ }))
+ _ = cr.Set("base64Encode", rt.jsFn(func(call goja.FunctionCall) (any, error) {
+ args := argsOf(call)
+ if len(args) == 0 {
+ return "", nil
+ }
+ return base64.StdEncoding.EncodeToString([]byte(strOf(args[0]))), nil
+ }))
+ _ = cr.Set("base64Decode", rt.jsFn(func(call goja.FunctionCall) (any, error) {
+ args := argsOf(call)
+ if len(args) == 0 {
+ return "", nil
+ }
+ b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(strOf(args[0])))
+ if err != nil {
+ return nil, errors.New("base64 解码失败")
+ }
+ return string(b), nil
+ }))
+ _ = clv.Set("crypto", cr)
+
+ // ---------- clv.mail ----------
+ m := rt.vm.NewObject()
+ _ = m.Set("send", rt.jsFn(rt.mailSend))
+ _ = clv.Set("mail", m)
+
+ // ---------- clv.media ----------
+ md := rt.vm.NewObject()
+ _ = md.Set("saveImage", rt.jsFn(rt.mediaSaveImage))
+ _ = md.Set("saveVideo", rt.jsFn(rt.mediaSaveVideo))
+ _ = clv.Set("media", md)
+
+ return nil
+}
+
+// ---------- http ----------
+
+func (rt *jsRuntime) httpRequest(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("net"); err != nil {
+ return nil, err
+ }
+ arg := mapOfAny(call.Argument(0))
+ if arg == nil {
+ return nil, errors.New("clv.http.request({url, method, headers, body}) 需要一个对象参数")
+ }
+ rawURL := strings.TrimSpace(strOf(arg["url"]))
+ if rawURL == "" {
+ return nil, errors.New("缺少 url")
+ }
+ // 声明 net.local 权限的插件允许访问内网/本机(例如部署在本机的 Ollama 等 AI 服务)
+ allowLocal := rt.app != nil && rt.app.Plugin.HasPermission("net.local")
+ if err := guardSSRF(rawURL, allowLocal); err != nil {
+ return nil, err
+ }
+ method := strings.ToUpper(strings.TrimSpace(strOf(arg["method"])))
+ if method == "" {
+ method = http.MethodGet
+ }
+ timeoutMS := intOf(arg["timeout_ms"])
+ if timeoutMS <= 0 || timeoutMS > 30000 {
+ timeoutMS = 10000
+ }
+ maxBytes := intOf(arg["max_bytes"])
+ if maxBytes <= 0 || maxBytes > maxHTTPBody {
+ maxBytes = 1 << 20
+ }
+
+ var body io.Reader
+ if b := strOf(arg["body"]); b != "" {
+ body = strings.NewReader(b)
+ }
+ req, err := http.NewRequest(method, rawURL, body)
+ if err != nil {
+ return nil, err
+ }
+ if hdrs, ok := arg["headers"].(map[string]any); ok {
+ for k, v := range hdrs {
+ req.Header.Set(k, strOf(v))
+ }
+ }
+ if req.Header.Get("User-Agent") == "" {
+ req.Header.Set("User-Agent", "ClearLove-Plugin/1.0")
+ }
+
+ client := &http.Client{Timeout: time.Duration(timeoutMS) * time.Millisecond}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ data, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxBytes)+1))
+ if err != nil {
+ return nil, err
+ }
+ if len(data) > maxBytes {
+ return nil, fmt.Errorf("响应体超过 %d 字节上限", maxBytes)
+ }
+ headers := make(map[string]string, len(resp.Header))
+ for k := range resp.Header {
+ headers[k] = resp.Header.Get(k)
+ }
+ return map[string]any{"status": resp.StatusCode, "headers": headers, "body": string(data)}, nil
+}
+
+// guardSSRF 拒绝内网/环回/链路本地地址,防止插件被用作内网探测跳板。
+// allowLocal 为 true(插件已声明 net.local 权限)时跳过内网校验,
+// 以支持访问部署在本机/内网的 AI 服务(Ollama、LM Studio、内网网关等)。
+func guardSSRF(rawURL string, allowLocal bool) error {
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ return fmt.Errorf("URL 无法解析: %w", err)
+ }
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return errors.New("仅支持 http/https 协议")
+ }
+ host := u.Hostname()
+ if host == "" {
+ return errors.New("URL 缺少主机名")
+ }
+ if allowLocal {
+ return nil
+ }
+ if ip := net.ParseIP(host); ip != nil {
+ if isPrivateIP(ip) {
+ return fmt.Errorf("禁止访问内网地址 %s", ip)
+ }
+ return nil
+ }
+ ips, err := net.LookupIP(host)
+ if err != nil {
+ return fmt.Errorf("域名解析失败: %w", err)
+ }
+ for _, ip := range ips {
+ if isPrivateIP(ip) {
+ return fmt.Errorf("禁止访问内网地址(%s 解析到 %s)", host, ip)
+ }
+ }
+ return nil
+}
+
+func isPrivateIP(ip net.IP) bool {
+ return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
+ ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
+}
+
+func randomUUID() string {
+ b := make([]byte, 16)
+ _, _ = rand.Read(b)
+ b[6] = (b[6] & 0x0f) | 0x40
+ b[8] = (b[8] & 0x3f) | 0x80
+ return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
+}
+
+// ---------- cache ----------
+
+type cacheEntry struct {
+ val any
+ exp time.Time
+}
+
+var pluginCache sync.Map
+
+func cacheKey(slug, k string) string { return slug + "\x00" + k }
+
+func (rt *jsRuntime) cacheGet(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("cache"); err != nil {
+ return nil, err
+ }
+ args := argsOf(call)
+ if len(args) == 0 {
+ return nil, errors.New("clv.cache.get(key) 需要一个参数")
+ }
+ key := cacheKey(rt.plugin.SlugOf(), strOf(args[0]))
+ v, ok := pluginCache.Load(key)
+ if !ok {
+ return nil, nil
+ }
+ e := v.(cacheEntry)
+ if !e.exp.IsZero() && time.Now().After(e.exp) {
+ pluginCache.Delete(key)
+ return nil, nil
+ }
+ return e.val, nil
+}
+
+func (rt *jsRuntime) cacheSet(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("cache"); err != nil {
+ return nil, err
+ }
+ args := argsOf(call)
+ if len(args) < 2 {
+ return nil, errors.New("clv.cache.set(key, value, ttlSec?) 至少需要两个参数")
+ }
+ ttl := time.Duration(0)
+ if len(args) > 2 {
+ if sec := intOf(args[2]); sec > 0 {
+ d := time.Duration(sec) * time.Second
+ if d > maxCacheTTL {
+ d = maxCacheTTL
+ }
+ ttl = d
+ }
+ }
+ e := cacheEntry{val: args[1]}
+ if ttl > 0 {
+ e.exp = time.Now().Add(ttl)
+ }
+ pluginCache.Store(cacheKey(rt.plugin.SlugOf(), strOf(args[0])), e)
+ return nil, nil
+}
+
+func (rt *jsRuntime) cacheDel(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("cache"); err != nil {
+ return nil, err
+ }
+ args := argsOf(call)
+ if len(args) == 0 {
+ return nil, errors.New("clv.cache.del(key) 需要一个参数")
+ }
+ pluginCache.Delete(cacheKey(rt.plugin.SlugOf(), strOf(args[0])))
+ return nil, nil
+}
+
+// ---------- mail ----------
+
+func (rt *jsRuntime) mailSend(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("mail"); err != nil {
+ return nil, err
+ }
+ args := argsOf(call)
+ if len(args) < 3 {
+ return nil, errors.New("clv.mail.send(to, subject, body) 需要三个参数")
+ }
+ host := models.GetSetting("smtp_host")
+ if host == "" {
+ return nil, errors.New("站点未配置 SMTP 邮箱服务")
+ }
+ err := mailer.Send(host, models.GetSetting("smtp_port"),
+ models.GetSetting("smtp_user"), models.GetSetting("smtp_pass"),
+ models.GetSetting("smtp_from"), strOf(args[0]), strOf(args[1]), strOf(args[2]))
+ if err != nil {
+ return nil, err
+ }
+ return true, nil
+}
+
+// ---------- media ----------
+
+func (rt *jsRuntime) mediaSaveImage(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("media.write"); err != nil {
+ return nil, err
+ }
+ arg := mapOfAny(call.Argument(0))
+ if arg == nil {
+ return nil, errors.New("clv.media.saveImage({name, data}) 需要一个对象参数(data 为 base64)")
+ }
+ raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(strOf(arg["data"])))
+ if err != nil {
+ return nil, errors.New("图片数据不是合法的 base64")
+ }
+ if len(raw) > 10<<20 {
+ return nil, errors.New("图片不能超过 10MB")
+ }
+ path, err := util.SaveImageBytes(raw)
+ if err != nil {
+ return nil, err
+ }
+ return path, nil
+}
+
+// mediaSaveVideo 保存视频:clv.media.saveVideo({name, data}),data 为 base64
+func (rt *jsRuntime) mediaSaveVideo(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("media.write"); err != nil {
+ return nil, err
+ }
+ arg := mapOfAny(call.Argument(0))
+ if arg == nil {
+ return nil, errors.New("clv.media.saveVideo({name, data}) 需要一个对象参数(data 为 base64)")
+ }
+ raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(strOf(arg["data"])))
+ if err != nil {
+ return nil, errors.New("视频数据不是合法的 base64")
+ }
+ path, err := util.SaveVideoBytes(raw, strOf(arg["name"]))
+ if err != nil {
+ return nil, err
+ }
+ return path, nil
+}
diff --git a/internal/plugin/api_site.go b/internal/plugin/api_site.go
new file mode 100644
index 0000000..3c5490e
--- /dev/null
+++ b/internal/plugin/api_site.go
@@ -0,0 +1,415 @@
+// Host API:站点数据层(clv.setting / clv.post / clv.comment / clv.user / clv.stats)。
+//
+// 提供高层语义,避免插件手写内核 SQL;写操作需 site.write 权限。
+package plugin
+
+import (
+ "errors"
+ "strings"
+
+ "github.com/dop251/goja"
+
+ "clearlove/internal/database"
+ "clearlove/internal/models"
+ "clearlove/internal/util"
+)
+
+func (rt *jsRuntime) installSite(clv *goja.Object) error {
+ // ---------- clv.setting ----------
+ st := rt.vm.NewObject()
+ _ = st.Set("get", rt.jsFn(rt.settingGet))
+ _ = st.Set("set", rt.jsFn(rt.settingSet))
+ _ = st.Set("all", rt.jsFn(rt.settingAll))
+ _ = clv.Set("setting", st)
+
+ // ---------- clv.post ----------
+ post := rt.vm.NewObject()
+ _ = post.Set("list", rt.jsFn(rt.postList))
+ _ = post.Set("get", rt.jsFn(rt.postGet))
+ _ = post.Set("create", rt.jsFn(rt.postCreate))
+ _ = post.Set("update", rt.jsFn(rt.postUpdate))
+ _ = post.Set("remove", rt.jsFn(rt.postRemove))
+ _ = clv.Set("post", post)
+
+ // ---------- clv.comment ----------
+ cm := rt.vm.NewObject()
+ _ = cm.Set("list", rt.jsFn(rt.commentList))
+ _ = cm.Set("create", rt.jsFn(rt.commentCreate))
+ _ = cm.Set("remove", rt.jsFn(rt.commentRemove))
+ _ = clv.Set("comment", cm)
+
+ // ---------- clv.user ----------
+ u := rt.vm.NewObject()
+ _ = u.Set("get", rt.jsFn(rt.userGet))
+ _ = u.Set("byName", rt.jsFn(rt.userByName))
+ _ = u.Set("current", rt.jsFn(rt.userCurrent))
+ _ = clv.Set("user", u)
+
+ // ---------- clv.stats ----------
+ stat := rt.vm.NewObject()
+ _ = stat.Set("overview", rt.jsFn(rt.statsOverview))
+ _ = stat.Set("today", rt.jsFn(rt.statsToday))
+ _ = clv.Set("stats", stat)
+
+ return nil
+}
+
+// requireAnyPerm 任一权限满足即可(用于读类 API 的宽松校验)
+func (rt *jsRuntime) requireAnyPerm(perms ...string) error {
+ if rt.app == nil {
+ return errNoApp
+ }
+ for _, p := range perms {
+ if rt.app.Plugin.HasPermission(p) {
+ return nil
+ }
+ }
+ return &permError{slug: rt.app.Plugin.SlugOf(), perm: strings.Join(perms, " / ")}
+}
+
+func (rt *jsRuntime) needDB() error {
+ if database.DB == nil {
+ return errors.New("数据库未连接")
+ }
+ return nil
+}
+
+// ---------- setting ----------
+
+func (rt *jsRuntime) settingGet(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("settings.read"); err != nil {
+ return nil, err
+ }
+ args := argsOf(call)
+ if len(args) == 0 {
+ return nil, errors.New("clv.setting.get(key) 需要一个参数")
+ }
+ return models.GetSetting(strOf(args[0])), nil
+}
+
+func (rt *jsRuntime) settingSet(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("settings.write"); err != nil {
+ return nil, err
+ }
+ args := argsOf(call)
+ if len(args) < 2 {
+ return nil, errors.New("clv.setting.set(key, value) 需要两个参数")
+ }
+ return nil, models.SetSetting(strOf(args[0]), strOf(args[1]))
+}
+
+func (rt *jsRuntime) settingAll(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("settings.read"); err != nil {
+ return nil, err
+ }
+ if err := rt.needDB(); err != nil {
+ return nil, err
+ }
+ rows, err := queryMaps(database.DB, "SELECT k, v FROM settings", nil)
+ if err != nil {
+ return nil, err
+ }
+ out := make(map[string]string, len(rows))
+ for _, r := range rows {
+ out[strOf(r["k"])] = strOf(r["v"])
+ }
+ return out, nil
+}
+
+// ---------- post ----------
+
+func (rt *jsRuntime) postList(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("site.read"); err != nil {
+ return nil, err
+ }
+ if err := rt.needDB(); err != nil {
+ return nil, err
+ }
+ arg := mapOfAny(call.Argument(0))
+ limit := intOf(arg["limit"])
+ if limit <= 0 || limit > 50 {
+ limit = 10
+ }
+ before := int64(intOf(arg["before"]))
+ topic := strOf(arg["topic"])
+ withHidden := boolOf(arg["include_hidden"])
+ q := `SELECT p.id, p.user_id, IFNULL(p.nickname,'匿名') AS nickname, IFNULL(p.content,'') AS content,
+ IFNULL(t.name,'') AS topic, p.status, p.like_count, p.comment_count, IFNULL(p.created_at,'') AS created_at
+ FROM posts p LEFT JOIN topics t ON t.id=p.topic_id
+ WHERE (? <= 0 OR p.id < ?)`
+ args := []any{before, before}
+ if !withHidden {
+ q += " AND p.status=1"
+ }
+ if topic != "" {
+ q += " AND t.name=?"
+ args = append(args, topic)
+ }
+ q += " ORDER BY p.id DESC LIMIT ?"
+ args = append(args, limit)
+ return queryMaps(database.DB, q, args)
+}
+
+func (rt *jsRuntime) postGet(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("site.read"); err != nil {
+ return nil, err
+ }
+ if err := rt.needDB(); err != nil {
+ return nil, err
+ }
+ args := argsOf(call)
+ if len(args) == 0 {
+ return nil, errors.New("clv.post.get(id) 需要一个参数")
+ }
+ rows, err := queryMaps(database.DB,
+ `SELECT p.id, p.user_id, IFNULL(p.nickname,'匿名') AS nickname, IFNULL(p.content,'') AS content,
+ p.topic_id, IFNULL(t.name,'') AS topic, p.status, p.like_count, p.comment_count,
+ IFNULL(p.is_admin,0) AS is_admin, IFNULL(p.created_at,'') AS created_at
+ FROM posts p LEFT JOIN topics t ON t.id=p.topic_id WHERE p.id=?`, []any{int64(intOf(args[0]))})
+ if err != nil {
+ return nil, err
+ }
+ if len(rows) == 0 {
+ return nil, nil
+ }
+ return rows[0], nil
+}
+
+func (rt *jsRuntime) postCreate(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("site.write"); err != nil {
+ return nil, err
+ }
+ if err := rt.needDB(); err != nil {
+ return nil, err
+ }
+ arg := mapOfAny(call.Argument(0))
+ if arg == nil {
+ return nil, errors.New("clv.post.create({content, nickname, topic}) 需要一个对象参数")
+ }
+ content := util.StripHTML(strOf(arg["content"]))
+ if content == "" || len([]rune(content)) > 3000 {
+ return nil, errors.New("帖子内容需为 1-3000 字")
+ }
+ nickname := util.StripHTML(strOf(arg["nickname"]))
+ if nickname == "" {
+ nickname = "匿名"
+ }
+ topicID := rt.ensureTopic(util.StripHTML(strOf(arg["topic"])))
+ res, err := database.DB.Exec(
+ "INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,is_admin,badges,created_at) VALUES(?,?,?,?,?,?,1,0,'',?)",
+ 0, nickname, content, topicID, "", "", models.Now())
+ if err != nil {
+ return nil, err
+ }
+ id, _ := res.LastInsertId()
+ Notify("post_created", map[string]any{"post_id": id, "nickname": nickname, "content": content})
+ return map[string]any{"id": id}, nil
+}
+
+func (rt *jsRuntime) postUpdate(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("site.write"); err != nil {
+ return nil, err
+ }
+ if err := rt.needDB(); err != nil {
+ return nil, err
+ }
+ args := argsOf(call)
+ if len(args) < 2 {
+ return nil, errors.New("clv.post.update(id, {content}) 需要两个参数")
+ }
+ id := int64(intOf(args[0]))
+ arg, _ := args[1].(map[string]any)
+ content := util.StripHTML(strOf(arg["content"]))
+ if content == "" || len([]rune(content)) > 3000 {
+ return nil, errors.New("帖子内容需为 1-3000 字")
+ }
+ _, err := database.DB.Exec("UPDATE posts SET content=? WHERE id=?", content, id)
+ return nil, err
+}
+
+func (rt *jsRuntime) postRemove(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("site.write"); err != nil {
+ return nil, err
+ }
+ if err := rt.needDB(); err != nil {
+ return nil, err
+ }
+ args := argsOf(call)
+ if len(args) == 0 {
+ return nil, errors.New("clv.post.remove(id) 需要一个参数")
+ }
+ id := int64(intOf(args[0]))
+ for _, t := range []string{"comments", "medias", "likes", "reports"} {
+ _, _ = database.DB.Exec("DELETE FROM "+t+" WHERE post_id=?", id)
+ }
+ _, err := database.DB.Exec("DELETE FROM posts WHERE id=?", id)
+ return nil, err
+}
+
+func (rt *jsRuntime) ensureTopic(name string) int64 {
+ name = strings.TrimSpace(name)
+ if name == "" || len([]rune(name)) > 30 {
+ return 0
+ }
+ if id := models.QueryInt("SELECT id FROM topics WHERE name=?", name); id > 0 {
+ return id
+ }
+ res, err := database.DB.Exec("INSERT INTO topics(name,created_at) VALUES(?,?)", name, models.Now())
+ if err != nil {
+ return 0
+ }
+ id, _ := res.LastInsertId()
+ return id
+}
+
+// ---------- comment ----------
+
+func (rt *jsRuntime) commentList(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("site.read"); err != nil {
+ return nil, err
+ }
+ if err := rt.needDB(); err != nil {
+ return nil, err
+ }
+ args := argsOf(call)
+ if len(args) == 0 {
+ return nil, errors.New("clv.comment.list(postId) 需要一个参数")
+ }
+ return queryMaps(database.DB,
+ `SELECT id, post_id, IFNULL(nickname,'匿名') AS nickname, IFNULL(content,'') AS content, IFNULL(created_at,'') AS created_at
+ FROM comments WHERE post_id=? ORDER BY id`, []any{int64(intOf(args[0]))})
+}
+
+func (rt *jsRuntime) commentCreate(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("site.write"); err != nil {
+ return nil, err
+ }
+ if err := rt.needDB(); err != nil {
+ return nil, err
+ }
+ arg := mapOfAny(call.Argument(0))
+ if arg == nil {
+ return nil, errors.New("clv.comment.create({post_id, content, nickname}) 需要一个对象参数")
+ }
+ postID := int64(intOf(arg["post_id"]))
+ if models.QueryInt("SELECT COUNT(1) FROM posts WHERE id=? AND status=1", postID) == 0 {
+ return nil, errors.New("帖子不存在或不可见")
+ }
+ content := util.StripHTML(strOf(arg["content"]))
+ if content == "" || len([]rune(content)) > 500 {
+ return nil, errors.New("评论需为 1-500 字")
+ }
+ nickname := util.StripHTML(strOf(arg["nickname"]))
+ if nickname == "" {
+ nickname = "匿名"
+ }
+ res, err := database.DB.Exec(
+ "INSERT INTO comments(post_id,user_id,nickname,content,ip,fingerprint,created_at) VALUES(?,0,?,?,'','',?)",
+ postID, nickname, content, models.Now())
+ if err != nil {
+ return nil, err
+ }
+ _, _ = database.DB.Exec("UPDATE posts SET comment_count=comment_count+1 WHERE id=?", postID)
+ id, _ := res.LastInsertId()
+ Notify("comment_created", map[string]any{"post_id": postID, "content": content})
+ return map[string]any{"id": id}, nil
+}
+
+func (rt *jsRuntime) commentRemove(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("site.write"); err != nil {
+ return nil, err
+ }
+ if err := rt.needDB(); err != nil {
+ return nil, err
+ }
+ args := argsOf(call)
+ if len(args) == 0 {
+ return nil, errors.New("clv.comment.remove(id) 需要一个参数")
+ }
+ _, err := database.DB.Exec("DELETE FROM comments WHERE id=?", int64(intOf(args[0])))
+ return nil, err
+}
+
+// ---------- user ----------
+
+func userPublicMap(u *models.User) map[string]any {
+ if u == nil {
+ return nil
+ }
+ return map[string]any{
+ "id": u.ID, "username": u.Username, "avatar": u.Avatar,
+ "status": u.Status, "created_at": u.CreatedAt,
+ }
+}
+
+func (rt *jsRuntime) userGet(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("site.read"); err != nil {
+ return nil, err
+ }
+ args := argsOf(call)
+ if len(args) == 0 {
+ return nil, errors.New("clv.user.get(id) 需要一个参数")
+ }
+ return userPublicMap(models.GetUserByID(int64(intOf(args[0])))), nil
+}
+
+func (rt *jsRuntime) userByName(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("site.read"); err != nil {
+ return nil, err
+ }
+ if err := rt.needDB(); err != nil {
+ return nil, err
+ }
+ args := argsOf(call)
+ if len(args) == 0 {
+ return nil, errors.New("clv.user.byName(name) 需要一个参数")
+ }
+ var id int64
+ if err := database.DB.QueryRow("SELECT id FROM users WHERE username=?", strOf(args[0])).Scan(&id); err != nil {
+ return nil, nil
+ }
+ return userPublicMap(models.GetUserByID(id)), nil
+}
+
+// userCurrent 返回当前请求的登录用户(由路由层在调用前注入)
+func (rt *jsRuntime) userCurrent(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("site.read"); err != nil {
+ return nil, err
+ }
+ if rt.cur == nil || rt.cur.UserID == 0 {
+ return nil, nil
+ }
+ return userPublicMap(models.GetUserByID(rt.cur.UserID)), nil
+}
+
+// ---------- stats ----------
+
+func (rt *jsRuntime) statsOverview(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("site.read"); err != nil {
+ return nil, err
+ }
+ if err := rt.needDB(); err != nil {
+ return nil, err
+ }
+ return map[string]any{
+ "users": models.QueryInt("SELECT COUNT(1) FROM users"),
+ "posts": models.QueryInt("SELECT COUNT(1) FROM posts"),
+ "comments": models.QueryInt("SELECT COUNT(1) FROM comments"),
+ "reports_pending": models.QueryInt("SELECT COUNT(1) FROM reports WHERE status=0"),
+ }, nil
+}
+
+func (rt *jsRuntime) statsToday(call goja.FunctionCall) (any, error) {
+ if err := rt.requirePerm("site.read"); err != nil {
+ return nil, err
+ }
+ if err := rt.needDB(); err != nil {
+ return nil, err
+ }
+ today := models.Now()[:10] + "T00:00:00Z"
+ return map[string]any{
+ "posts": models.QueryInt("SELECT COUNT(1) FROM posts WHERE created_at >= ?", today),
+ "comments": models.QueryInt("SELECT COUNT(1) FROM comments WHERE created_at >= ?", today),
+ "users": models.QueryInt("SELECT COUNT(1) FROM users WHERE created_at >= ?", today),
+ }, nil
+}
diff --git a/internal/plugin/api_view.go b/internal/plugin/api_view.go
new file mode 100644
index 0000000..b800426
--- /dev/null
+++ b/internal/plugin/api_view.go
@@ -0,0 +1,351 @@
+// Host API:运行时注册(clv.route / clv.slot / clv.on / clv.filter / clv.job ...)
+// 与视图渲染(clv.view.*)。
+//
+// 这里体现 B 型插件的核心设计:能力注册是命令式的(写在 setup() 里),
+// 而能力授权是声明式的(写在 plugin.json 的 permissions 里)。
+package plugin
+
+import (
+ "errors"
+ "fmt"
+ "html/template"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/dop251/goja"
+)
+
+// reqContext 当前请求上下文。
+// Worker 串行执行,同一时刻只有一个 handler 在跑,因此可以用单字段暂存。
+type reqContext struct {
+ UserID int64
+ AdminID int64
+ IsAdmin bool
+ IP string
+ Fingerprint string
+ Method string
+ Path string
+ Params map[string]string
+ Query map[string]string
+ Form map[string]string
+ CSRF string
+}
+
+func (rt *jsRuntime) installView(clv *goja.Object) error {
+ // ---------- 注册类 API ----------
+ _ = clv.Set("route", rt.jsFn(rt.regRoute))
+ _ = clv.Set("adminMenu", rt.jsFn(rt.regAdminMenu))
+ _ = clv.Set("adminPage", rt.jsFn(rt.regAdminPage))
+ _ = clv.Set("slot", rt.jsFn(rt.regSlot))
+ _ = clv.Set("on", rt.jsFn(rt.regEvent))
+ _ = clv.Set("emit", rt.jsFn(rt.emitEvent))
+ _ = clv.Set("filter", rt.jsFn(rt.regFilter))
+ _ = clv.Set("job", rt.jsFn(rt.regJob))
+ _ = clv.Set("middleware", rt.jsFn(rt.regMiddleware))
+
+ // ---------- clv.view ----------
+ v := rt.vm.NewObject()
+ _ = v.Set("template", rt.jsFn(rt.viewTemplate))
+ _ = v.Set("escape", rt.jsFn(func(call goja.FunctionCall) (any, error) {
+ args := argsOf(call)
+ if len(args) == 0 {
+ return "", nil
+ }
+ return template.HTMLEscapeString(strOf(args[0])), nil
+ }))
+ _ = v.Set("nl2br", rt.jsFn(func(call goja.FunctionCall) (any, error) {
+ args := argsOf(call)
+ if len(args) == 0 {
+ return "", nil
+ }
+ return strings.ReplaceAll(template.HTMLEscapeString(strOf(args[0])), "\n", "<br>"), nil
+ }))
+ _ = clv.Set("view", v)
+ return nil
+}
+
+// ---------- 注册实现 ----------
+
+// bindFn 支持两种写法:函数名字符串,或直接传函数(自动登记为内部名,便于跨请求调用)
+func (rt *jsRuntime) bindFn(v goja.Value, prefix string) (string, error) {
+ if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
+ return "", errors.New("需要一个函数或函数名")
+ }
+ if fnVal, ok := goja.AssertFunction(v); ok {
+ rt.fnSeq++
+ name := fmt.Sprintf("__%s_%d", prefix, rt.fnSeq)
+ if err := rt.vm.Set(name, fnVal); err != nil {
+ return "", err
+ }
+ return name, nil
+ }
+ name := strings.TrimSpace(strOf(exportJS(v)))
+ if name == "" {
+ return "", errors.New("需要一个函数或函数名")
+ }
+ return name, nil
+}
+
+func (rt *jsRuntime) regRoute(call goja.FunctionCall) (any, error) {
+ if rt.app == nil {
+ return nil, errNoApp
+ }
+ if len(call.Arguments) < 3 {
+ return nil, errors.New("clv.route(method, path, handler, opts?) 参数不足")
+ }
+ method := strings.ToUpper(strings.TrimSpace(strOf(exportJS(call.Argument(0)))))
+ path := normRelPath(strOf(exportJS(call.Argument(1))))
+ fn, err := rt.bindFn(call.Argument(2), "route")
+ if err != nil {
+ return nil, err
+ }
+ r := RouteReg{Method: method, Path: path, Fn: fn, Auth: "none"}
+ if opts := mapOfAny(call.Argument(3)); opts != nil {
+ if v := strings.ToLower(strOf(opts["auth"])); v != "" {
+ r.Auth = v
+ }
+ r.Perm = strings.TrimSpace(strOf(opts["perm"]))
+ r.JSON = boolOf(opts["json"])
+ }
+ if method == "" {
+ r.Method = "GET"
+ }
+ rt.app.Routes = append(rt.app.Routes, r)
+ return nil, nil
+}
+
+func (rt *jsRuntime) regAdminMenu(call goja.FunctionCall) (any, error) {
+ if rt.app == nil {
+ return nil, errNoApp
+ }
+ opts := mapOfAny(call.Argument(0))
+ if opts == nil {
+ return nil, errors.New("clv.adminMenu({label, path, perm, order}) 需要一个对象参数")
+ }
+ label := trimTo(strOf(opts["label"]), 24)
+ if label == "" {
+ return nil, errors.New("adminMenu 需要 label")
+ }
+ rt.app.Menus = append(rt.app.Menus, MenuReg{
+ Label: label,
+ Path: normRelPath(strOf(opts["path"])),
+ Perm: strings.TrimSpace(strOf(opts["perm"])),
+ Order: intOf(opts["order"]),
+ })
+ return nil, nil
+}
+
+func (rt *jsRuntime) regAdminPage(call goja.FunctionCall) (any, error) {
+ if rt.app == nil {
+ return nil, errNoApp
+ }
+ if len(call.Arguments) < 2 {
+ return nil, errors.New("clv.adminPage(path, handler, opts?) 参数不足")
+ }
+ path := normRelPath(strOf(exportJS(call.Argument(0))))
+ fn, err := rt.bindFn(call.Argument(1), "adminpage")
+ if err != nil {
+ return nil, err
+ }
+ p := PageReg{Path: path, Fn: fn}
+ if opts := mapOfAny(call.Argument(2)); opts != nil {
+ p.Perm = strings.TrimSpace(strOf(opts["perm"]))
+ }
+ rt.app.Pages = append(rt.app.Pages, p)
+ return nil, nil
+}
+
+func (rt *jsRuntime) regSlot(call goja.FunctionCall) (any, error) {
+ if rt.app == nil {
+ return nil, errNoApp
+ }
+ if len(call.Arguments) < 2 {
+ return nil, errors.New("clv.slot(name, handler) 参数不足")
+ }
+ name := strings.TrimSpace(strOf(exportJS(call.Argument(0))))
+ if name == "" {
+ return nil, errors.New("slot 名称不能为空")
+ }
+ fn, err := rt.bindFn(call.Argument(1), "slot")
+ if err != nil {
+ return nil, err
+ }
+ rt.app.Slots[name] = append(rt.app.Slots[name], fn)
+ return nil, nil
+}
+
+func (rt *jsRuntime) regEvent(call goja.FunctionCall) (any, error) {
+ if rt.app == nil {
+ return nil, errNoApp
+ }
+ if len(call.Arguments) < 2 {
+ return nil, errors.New("clv.on(event, handler) 参数不足")
+ }
+ event := strings.TrimSpace(strOf(exportJS(call.Argument(0))))
+ if event == "" {
+ return nil, errors.New("事件名不能为空")
+ }
+ fn, err := rt.bindFn(call.Argument(1), "event")
+ if err != nil {
+ return nil, err
+ }
+ rt.app.Events[event] = append(rt.app.Events[event], fn)
+ return nil, nil
+}
+
+// emitEvent 广播自定义事件(插件间通信);订阅方在自己的 worker 中异步执行,不会死锁
+func (rt *jsRuntime) emitEvent(call goja.FunctionCall) (any, error) {
+ args := argsOf(call)
+ if len(args) == 0 || strOf(args[0]) == "" {
+ return nil, errors.New("clv.emit(event, payload?) 需要事件名")
+ }
+ payload := map[string]any{}
+ if len(args) > 1 {
+ if m, ok := args[1].(map[string]any); ok {
+ payload = m
+ }
+ }
+ Emit(strOf(args[0]), payload)
+ return nil, nil
+}
+
+func (rt *jsRuntime) regFilter(call goja.FunctionCall) (any, error) {
+ if rt.app == nil {
+ return nil, errNoApp
+ }
+ if len(call.Arguments) < 2 {
+ return nil, errors.New("clv.filter(name, handler) 或 clv.filter(name, priority, handler) 参数不足")
+ }
+ name := strings.TrimSpace(strOf(exportJS(call.Argument(0))))
+ if name == "" {
+ return nil, errors.New("过滤器名不能为空")
+ }
+ priority := 100
+ fnArg := call.Argument(1)
+ if len(call.Arguments) >= 3 {
+ priority = intOf(exportJS(call.Argument(1)))
+ fnArg = call.Argument(2)
+ } else if _, ok := goja.AssertFunction(call.Argument(1)); !ok {
+ // 只给了一个非函数参数:视为优先级缺失,报错更友好
+ return nil, errors.New("clv.filter(name, priority, handler) 的 handler 必须是函数")
+ }
+ fn, err := rt.bindFn(fnArg, "filter")
+ if err != nil {
+ return nil, err
+ }
+ rt.app.Filters[name] = append(rt.app.Filters[name], FilterReg{Fn: fn, Priority: priority})
+ return nil, nil
+}
+
+func (rt *jsRuntime) regJob(call goja.FunctionCall) (any, error) {
+ if rt.app == nil {
+ return nil, errNoApp
+ }
+ if len(call.Arguments) < 3 {
+ return nil, errors.New("clv.job(name, every, handler) 参数不足")
+ }
+ name := trimTo(strOf(exportJS(call.Argument(0))), 40)
+ every, err := parseEvery(strOf(exportJS(call.Argument(1))))
+ if err != nil {
+ return nil, err
+ }
+ fn, err := rt.bindFn(call.Argument(2), "job")
+ if err != nil {
+ return nil, err
+ }
+ if name == "" {
+ name = fn
+ }
+ rt.app.Jobs = append(rt.app.Jobs, JobReg{Name: name, Every: every, Fn: fn})
+ return nil, nil
+}
+
+func (rt *jsRuntime) regMiddleware(call goja.FunctionCall) (any, error) {
+ if rt.app == nil {
+ return nil, errNoApp
+ }
+ if len(call.Arguments) < 2 {
+ return nil, errors.New("clv.middleware(phase, handler) 参数不足")
+ }
+ phase := strings.ToLower(strings.TrimSpace(strOf(exportJS(call.Argument(0)))))
+ if phase != "http.before" && phase != "http.after" {
+ return nil, errors.New("middleware phase 仅支持 \"http.before\" 或 \"http.after\"")
+ }
+ fn, err := rt.bindFn(call.Argument(1), "mw")
+ if err != nil {
+ return nil, err
+ }
+ rt.app.Middlewares = append(rt.app.Middlewares, MiddlewareReg{Phase: phase, Fn: fn})
+ return nil, nil
+}
+
+// ---------- 视图 ----------
+
+func (rt *jsRuntime) viewTemplate(call goja.FunctionCall) (any, error) {
+ if viewProvider == nil {
+ return nil, errors.New("当前环境未装配模板渲染能力")
+ }
+ args := argsOf(call)
+ if len(args) == 0 {
+ return nil, errors.New("clv.view.template(name, data?) 需要模板名")
+ }
+ name := strings.TrimSpace(strOf(args[0]))
+ if name == "" || strings.Contains(name, "..") || strings.HasPrefix(name, "/") {
+ return nil, errors.New("非法的模板名")
+ }
+ var data any = map[string]any{}
+ if len(args) > 1 {
+ data = args[1]
+ }
+ return viewProvider.RenderTemplate(os.DirFS(rt.dir), name, data)
+}
+
+// ---------- 工具 ----------
+
+// normRelPath 归一化相对路径(保证以 / 开头、无结尾斜杠)
+func normRelPath(p string) string {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ return "/"
+ }
+ p = strings.ReplaceAll(p, "\\", "/")
+ if !strings.HasPrefix(p, "/") {
+ p = "/" + p
+ }
+ for strings.Contains(p, "//") {
+ p = strings.ReplaceAll(p, "//", "/")
+ }
+ if strings.Contains(p, "..") {
+ return "/"
+ }
+ if len(p) > 1 {
+ p = strings.TrimRight(p, "/")
+ if p == "" {
+ return "/"
+ }
+ }
+ return p
+}
+
+// parseEvery 解析任务周期,最小 10 秒,避免插件写 1ms 打爆 CPU
+func parseEvery(s string) (time.Duration, error) {
+ d, err := time.ParseDuration(strings.TrimSpace(s))
+ if err != nil {
+ return 0, fmt.Errorf("无法解析周期 %q(示例:30s / 10m / 1h / 24h)", s)
+ }
+ if d < 10*time.Second {
+ return 0, errors.New("任务周期不能小于 10s")
+ }
+ return d, nil
+}
+
+// trimTo 截断到 n 个字符(按 rune,避免截断中文)
+func trimTo(s string, n int) string {
+ s = strings.TrimSpace(s)
+ r := []rune(s)
+ if len(r) > n {
+ return string(r[:n])
+ }
+ return s
+}
diff --git a/internal/plugin/app_test.go b/internal/plugin/app_test.go
new file mode 100644
index 0000000..22b3ce3
--- /dev/null
+++ b/internal/plugin/app_test.go
@@ -0,0 +1,271 @@
+package plugin
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "clearlove/internal/config"
+ "clearlove/internal/database"
+ "clearlove/internal/models"
+)
+
+// setupAppEnv 准备带数据库的应用型插件测试环境
+func setupAppEnv(t *testing.T) string {
+ t.Helper()
+ root := t.TempDir()
+ dataDir := filepath.Join(root, "data")
+ if err := os.MkdirAll(dataDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ config.Cfg = config.Config{
+ DataDir: dataDir,
+ UploadDir: filepath.Join(root, "uploads"),
+ DBType: "sqlite",
+ SQLitePath: filepath.Join(dataDir, "clearlove.db"),
+ Installed: true,
+ Secret: "test-secret",
+ }
+ if err := database.Connect(); err != nil {
+ t.Fatalf("数据库连接失败: %v", err)
+ }
+ t.Cleanup(func() {
+ if database.DB != nil {
+ _ = database.DB.Close()
+ database.DB = nil
+ }
+ })
+ if err := database.Migrate(); err != nil {
+ t.Fatalf("数据库迁移失败: %v", err)
+ }
+ return dataDir
+}
+
+func writeAppPlugin(t *testing.T, dataDir, dir, manifest, script string) {
+ t.Helper()
+ d := filepath.Join(dataDir, "plugins", dir)
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(d, "plugin.json"), []byte(manifest), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(d, "main.js"), []byte(script), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+const demoManifest = `{
+ "kind": "app",
+ "name": "demo",
+ "slug": "demo",
+ "version": "1.0.0",
+ "runtime": { "engine": "js", "entry": "main.js", "timeout_ms": 800 },
+ "permissions": ["db", "cache", "settings.read"]
+}`
+
+const demoScript = `
+var T = clv.db.table("log");
+function setup() {
+ clv.table("log", { user_id: "int", day: "string" }, { unique: [["user_id", "day"]] });
+ clv.route("GET", "/", "pageHome", { auth: "none" });
+ clv.route("POST", "/do", "doPost", { auth: "user" });
+ clv.adminPage("/", "adminHome", { perm: "plugins" });
+ clv.adminMenu({ label: "演示管理", path: "/", perm: "plugins", order: 10 });
+ clv.slot("footer_html", "footSlot");
+ clv.on("post_created", "onPost");
+ clv.filter("filter.content", 10, "censor");
+ clv.job("tick", "1h", "onTick");
+}
+function pageHome(ctx) { return { body: "<h1>hi</h1>" }; }
+function doPost(ctx) { return { json: { ok: true } }; }
+function adminHome(ctx) { return { body: "admin" }; }
+function footSlot(data) { return "<span class='demo-slot'>slot</span>"; }
+function onPost(e) { clv.cache.set("last_post", e.post_id, 60); }
+function censor(s) { return s.replace(/广告/g, "***"); }
+function onTick(ctx) { clv.log.info("tick"); }
+function __check() { return clv.cache.get("last_post") || 0; }
+function __badPerm() {
+ try { clv.setting.set("hack", "1"); return "allowed"; } catch (e) { return "denied"; }
+}
+function __badSQL() {
+ try { clv.db.exec("DELETE FROM posts WHERE id=0"); return "allowed"; } catch (e) { return "denied"; }
+}
+function __loop() { while (true) {} }
+`
+
+// TestAppPluginLifecycle 覆盖应用型插件的完整能力:注册、建表、slot、过滤器、事件、任务
+func TestAppPluginLifecycle(t *testing.T) {
+ dataDir := setupAppEnv(t)
+ writeAppPlugin(t, dataDir, "demo", demoManifest, demoScript)
+
+ SetEnabled("demo", true)
+ if err := LoadApp("demo"); err != nil {
+ t.Fatalf("加载应用型插件失败: %v", err)
+ }
+ defer func() {
+ UnloadApp("demo")
+ SetEnabled("demo", false)
+ }()
+
+ app := GetApp("demo")
+ if app == nil {
+ t.Fatal("插件未登记到注册表")
+ }
+ if app.Plugin.SlugOf() != "demo" {
+ t.Fatalf("slug 解析异常: %q", app.Plugin.SlugOf())
+ }
+
+ // 1) 各类扩展点均已注册
+ if len(app.Routes) != 2 {
+ t.Fatalf("路由数 = %d,期望 2", len(app.Routes))
+ }
+ if len(app.Pages) != 1 || len(AdminMenus()) != 1 {
+ t.Fatalf("后台页面/菜单注册异常: pages=%d menus=%d", len(app.Pages), len(AdminMenus()))
+ }
+ if len(EventHooks("post_created")) != 1 {
+ t.Fatal("事件未注册")
+ }
+ if len(FilterHooks("filter.content")) != 1 {
+ t.Fatal("过滤器未注册")
+ }
+ if len(app.Jobs) != 1 {
+ t.Fatal("定时任务未注册")
+ }
+
+ // 2) 数据表已创建(pl_<slug>_log)
+ if _, err := database.DB.Exec("INSERT INTO pl_demo_log(user_id,day) VALUES(1,'2026-01-01')"); err != nil {
+ t.Fatalf("插件数据表不可写: %v", err)
+ }
+
+ // 3) Slot 输出包含插件内容
+ if got := SlotHTML("footer_html", map[string]any{}); !strings.Contains(got, "demo-slot") {
+ t.Fatalf("Slot 未生效: %q", got)
+ }
+
+ // 4) 过滤器生效
+ if got := ApplyFilterStr("filter.content", "这是一条广告"); got != "这是一条***" {
+ t.Fatalf("过滤器未生效: %q", got)
+ }
+
+ // 5) 事件回调(异步)确实执行
+ Emit("post_created", map[string]any{"post_id": 42})
+ deadline := time.Now().Add(2 * time.Second)
+ var last any
+ for time.Now().Before(deadline) {
+ v, err := app.Worker.Do(context.Background(), func(rt *jsRuntime) (any, error) {
+ return rt.callFn("__check")
+ })
+ if err != nil {
+ t.Fatalf("脚本调用失败: %v", err)
+ }
+ last = v
+ if fmt.Sprint(v) == "42" {
+ break
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ if fmt.Sprint(last) != "42" {
+ t.Fatalf("事件回调未执行,cache = %v", last)
+ }
+}
+
+// TestAppPluginPermission 未声明的权限必须被拒绝
+func TestAppPluginPermission(t *testing.T) {
+ dataDir := setupAppEnv(t)
+ writeAppPlugin(t, dataDir, "demo", demoManifest, demoScript)
+
+ SetEnabled("demo", true)
+ if err := LoadApp("demo"); err != nil {
+ t.Fatalf("加载失败: %v", err)
+ }
+ defer func() {
+ UnloadApp("demo")
+ SetEnabled("demo", false)
+ }()
+
+ app := GetApp("demo")
+
+ // settings.write 未在 permissions 中声明
+ v, err := app.Worker.Do(context.Background(), func(rt *jsRuntime) (any, error) {
+ return rt.callFn("__badPerm")
+ })
+ if err != nil {
+ t.Fatalf("调用失败: %v", err)
+ }
+ if fmt.Sprint(v) != "denied" {
+ t.Fatalf("未声明的权限未被拒绝: %v", v)
+ }
+
+ // 写内核表必须被拒绝(只允许 pl_<slug>_ 前缀)
+ v2, err := app.Worker.Do(context.Background(), func(rt *jsRuntime) (any, error) {
+ return rt.callFn("__badSQL")
+ })
+ if err != nil {
+ t.Fatalf("调用失败: %v", err)
+ }
+ if fmt.Sprint(v2) != "denied" {
+ t.Fatalf("内核表写操作未被拒绝: %v", v2)
+ }
+}
+
+// TestAppPluginTimeoutInterrupt 死循环必须被超时中断,且中断后插件仍可继续服务
+func TestAppPluginTimeoutInterrupt(t *testing.T) {
+ dataDir := setupAppEnv(t)
+ writeAppPlugin(t, dataDir, "demo", demoManifest, demoScript)
+
+ SetEnabled("demo", true)
+ if err := LoadApp("demo"); err != nil {
+ t.Fatalf("加载失败: %v", err)
+ }
+ defer func() {
+ UnloadApp("demo")
+ SetEnabled("demo", false)
+ }()
+
+ app := GetApp("demo")
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ _, err := app.Worker.Do(ctx, func(rt *jsRuntime) (any, error) {
+ return rt.callFn("__loop")
+ })
+ if err == nil {
+ t.Fatal("死循环未被超时中断")
+ }
+
+ // 中断后虚拟机应恢复可用
+ ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel2()
+ if _, err := app.Worker.Do(ctx2, func(rt *jsRuntime) (any, error) {
+ return rt.callFn("__check")
+ }); err != nil {
+ t.Fatalf("超时中断后插件不可用: %v", err)
+ }
+}
+
+// TestDeclarativeUnaffected 声明式插件仍然按原逻辑工作(回归)
+func TestDeclarativeUnaffected(t *testing.T) {
+ dataDir := setupAppEnv(t)
+ writePlugin(t, dataDir, "lite", `{
+ "name":"lite","version":"1.0.0",
+ "hooks":[{"hook":"footer_html","type":"html","html":"<b>lite</b>"},
+ {"hook":"content_filter","type":"filter","match":"坏","replace":"好"}]
+ }`, "")
+
+ SetEnabled("lite", true)
+ defer SetEnabled("lite", false)
+
+ if got := CallHTML("footer_html"); !strings.Contains(got, "<b>lite</b>") {
+ t.Fatalf("声明式 HTML 钩子失效: %q", got)
+ }
+ if got := ApplyFilterStr("filter.content", "坏人"); got != "好人" {
+ t.Fatalf("声明式过滤器失效: %q", got)
+ }
+ if models.QueryInt("SELECT 1") != 1 {
+ t.Fatal("数据库不可用")
+ }
+}
diff --git a/internal/plugin/hub.go b/internal/plugin/hub.go
new file mode 100644
index 0000000..d79faae
--- /dev/null
+++ b/internal/plugin/hub.go
@@ -0,0 +1,246 @@
+// 事件总线与过滤器链。
+//
+// 同时服务两种形态:
+// - A 型:http 钩子(Notify)与 html/filter 静态片段(CallHTML/CallFilter)
+// - B 型:clv.on / clv.slot / clv.filter / clv.middleware 注册的脚本回调
+//
+// 约定:插件故障不影响站点 —— 事件与中间件失败只记日志,过滤器失败放行原值。
+package plugin
+
+import (
+ "context"
+
+ "clearlove/internal/models"
+)
+
+func withMeta(event string, payload map[string]any) map[string]any {
+ out := make(map[string]any, len(payload)+2)
+ for k, v := range payload {
+ out[k] = v
+ }
+ out["event"] = event
+ out["time"] = models.Now()
+ return out
+}
+
+// Emit 广播事件(A 型 Webhook + B 型 clv.on),B 型回调异步执行
+func Emit(event string, payload map[string]any) {
+ // A 型:现有 Webhook 行为保持不变
+ Notify(event, payload)
+
+ for _, h := range EventHooks(event) {
+ h := h
+ if h.App == nil || h.App.Worker == nil || h.App.Worker.Closed() {
+ continue
+ }
+ go func() {
+ ctx, cancel := context.WithTimeout(context.Background(), h.App.Plugin.Timeout())
+ defer cancel()
+ _, err := h.App.Worker.Do(ctx, func(rt *jsRuntime) (any, error) {
+ _, err := rt.callFn(h.Fn, withMeta(event, payload))
+ return nil, err
+ })
+ if err != nil {
+ noteFailure(h.App, err)
+ return
+ }
+ noteSuccess(h.App)
+ }()
+ }
+}
+
+// ApplyFilterStr 字符串过滤器(filter.content / filter.nickname 等)。
+// filter.content 会先执行 A 型声明式正则改写,再走 B 型运行时过滤器。
+func ApplyFilterStr(name, value string) string {
+ v := value
+ if name == "filter.content" {
+ v = CallFilter(v)
+ }
+ if len(FilterHooks(name)) == 0 {
+ return v
+ }
+ out := runFilterChain(name, v, nil)
+ if s, ok := out.(string); ok {
+ return s
+ }
+ return v
+}
+
+// ApplyFilterValue 通用过滤器(map / 布尔 等)
+func ApplyFilterValue(name string, value any, extra map[string]any) any {
+ if len(FilterHooks(name)) == 0 {
+ return value
+ }
+ return runFilterChain(name, value, extra)
+}
+
+// ApplyFilterBool 布尔过滤器(带上下文字段,如 filter.post.visible)
+func ApplyFilterBool(name string, value bool, extra map[string]any) bool {
+ if len(FilterHooks(name)) == 0 {
+ return value
+ }
+ out := runFilterChain(name, value, extra)
+ if b, ok := out.(bool); ok {
+ return b
+ }
+ return value
+}
+
+// FilterVeto 布尔型过滤器:任一插件返回 false 即否决
+func FilterVeto(name string, allow bool) bool {
+ for _, h := range FilterHooks(name) {
+ if h.App == nil || h.App.Worker == nil || h.App.Worker.Closed() {
+ continue
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), h.App.Plugin.Timeout())
+ v, err := h.App.Worker.Do(ctx, func(rt *jsRuntime) (any, error) {
+ return rt.callFn(h.Fn, allow)
+ })
+ cancel()
+ if err != nil {
+ noteFailure(h.App, err)
+ continue
+ }
+ noteSuccess(h.App)
+ switch t := v.(type) {
+ case bool:
+ if !t {
+ return false
+ }
+ case nil:
+ // 未表态
+ default:
+ if !boolOf(t) {
+ return false
+ }
+ }
+ }
+ return allow
+}
+
+func runFilterChain(name string, value any, extra map[string]any) any {
+ cur := value
+ for _, h := range FilterHooks(name) {
+ if h.App == nil || h.App.Worker == nil || h.App.Worker.Closed() {
+ continue
+ }
+ h := h
+ ctx, cancel := context.WithTimeout(context.Background(), h.App.Plugin.Timeout())
+ out, err := h.App.Worker.Do(ctx, func(rt *jsRuntime) (any, error) {
+ if extra == nil {
+ return rt.callFn(h.Fn, cur)
+ }
+ return rt.callFn(h.Fn, cur, extra)
+ })
+ cancel()
+ if err != nil {
+ noteFailure(h.App, err)
+ continue // 放行原值,不因插件故障阻断站点
+ }
+ noteSuccess(h.App)
+ if out == nil {
+ continue // 返回 undefined 表示不改
+ }
+ cur = out
+ }
+ return cur
+}
+
+// SlotHTML 计算某个 UI 注入点的完整 HTML(A 型静态片段 + B 型动态渲染)
+func SlotHTML(name string, data any) string {
+ out := CallHTML(name)
+ for _, h := range SlotHooks(name) {
+ h := h
+ if h.App == nil || h.App.Worker == nil || h.App.Worker.Closed() {
+ continue
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), h.App.Plugin.Timeout())
+ v, err := h.App.Worker.Do(ctx, func(rt *jsRuntime) (any, error) {
+ return rt.callFn(h.Fn, data)
+ })
+ cancel()
+ if err != nil {
+ noteFailure(h.App, err)
+ continue
+ }
+ noteSuccess(h.App)
+ if v == nil {
+ continue
+ }
+ out += strOf(v)
+ }
+ return out
+}
+
+// RunMiddlewareAfter 执行 http.after 中间件(只观察,忽略返回值与异常细节)
+func RunMiddlewareAfter(req map[string]any) {
+ for _, h := range MiddlewareHooks("http.after") {
+ if h.App == nil || h.App.Worker == nil || h.App.Worker.Closed() {
+ continue
+ }
+ h := h
+ ctx, cancel := context.WithTimeout(context.Background(), h.App.Plugin.Timeout())
+ _, err := h.App.Worker.Do(ctx, func(rt *jsRuntime) (any, error) {
+ rt.cur = nil
+ return rt.callFn(h.Fn, req)
+ })
+ cancel()
+ if err != nil {
+ noteFailure(h.App, err)
+ continue
+ }
+ noteSuccess(h.App)
+ }
+}
+
+// MiddlewareResult http.before 中间件的短路指令
+type MiddlewareResult struct {
+ Abort bool
+ Status int
+ Headers map[string]string
+ Body string
+ JSON any
+ Redirect string
+}
+
+// RunMiddlewareBefore 执行 http.before 中间件;返回 nil 表示继续正常处理
+func RunMiddlewareBefore(req map[string]any) *MiddlewareResult {
+ for _, h := range MiddlewareHooks("http.before") {
+ if h.App == nil || h.App.Worker == nil || h.App.Worker.Closed() {
+ continue
+ }
+ h := h
+ ctx, cancel := context.WithTimeout(context.Background(), h.App.Plugin.Timeout())
+ v, err := h.App.Worker.Do(ctx, func(rt *jsRuntime) (any, error) {
+ return rt.callFn(h.Fn, req)
+ })
+ cancel()
+ if err != nil {
+ noteFailure(h.App, err)
+ continue
+ }
+ noteSuccess(h.App)
+ m, ok := v.(map[string]any)
+ if !ok || !boolOf(m["abort"]) {
+ continue
+ }
+ res := &MiddlewareResult{
+ Abort: true,
+ Status: intOf(m["status"]),
+ Body: strOf(m["body"]),
+ JSON: m["json"],
+ Redirect: strOf(m["redirect"]),
+ }
+ if res.Status == 0 {
+ res.Status = 200
+ }
+ if hdrs, ok := m["headers"].(map[string]any); ok {
+ res.Headers = make(map[string]string, len(hdrs))
+ for k, hv := range hdrs {
+ res.Headers[k] = strOf(hv)
+ }
+ }
+ return res
+ }
+ return nil
+}
diff --git a/internal/plugin/lifecycle.go b/internal/plugin/lifecycle.go
new file mode 100644
index 0000000..c6e78f6
--- /dev/null
+++ b/internal/plugin/lifecycle.go
@@ -0,0 +1,176 @@
+// 应用型插件生命周期:加载(setup 注册)、卸载、启动时批量引导。
+package plugin
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "clearlove/internal/util"
+)
+
+// Boot 启动时加载全部已启用的应用型插件(A 型插件无需加载,按请求即时解析)
+func Boot() {
+ for name, p := range Load() {
+ if !p.IsApp() {
+ continue
+ }
+ if !enabledSet()[p.Name] {
+ continue
+ }
+ if err := LoadApp(name); err != nil {
+ util.Log("error", "应用型插件 %s 加载失败: %v", name, err)
+ logPlugin(p.SlugOf(), "load_error", err.Error(), 0)
+ }
+ }
+}
+
+// LoadApp 加载并启动一个应用型插件(幂等:重复调用会先卸载)
+func LoadApp(dir string) error {
+ dir = filepath.Base(strings.TrimSpace(dir))
+ if dir == "" || dir == "." {
+ return errors.New("无效的插件目录")
+ }
+ full := filepath.Join(dir_pluginsDir(), dir)
+ data, err := os.ReadFile(filepath.Join(full, "plugin.json"))
+ if err != nil {
+ return fmt.Errorf("读取 plugin.json 失败: %w", err)
+ }
+ var p Plugin
+ if err := unmarshalPlugin(data, &p); err != nil {
+ return err
+ }
+ if !p.IsApp() {
+ return errors.New("该插件不是应用型(kind != app),无需加载运行时")
+ }
+ if p.Runtime == nil || strings.TrimSpace(p.Runtime.Engine) == "" {
+ return errors.New("缺少 runtime.engine(当前支持 js)")
+ }
+ if !strings.EqualFold(strings.TrimSpace(p.Runtime.Engine), "js") {
+ return fmt.Errorf("不支持的运行时引擎 %q(当前仅支持 js)", p.Runtime.Engine)
+ }
+
+ // 幂等:先卸载旧实例
+ UnloadApp(dir)
+
+ rt, err := newJSRuntime(full, &p)
+ if err != nil {
+ return err
+ }
+ worker := NewWorker(rt)
+ app := newApp(dir, &p, worker)
+ rt.app = app // Host API 用它做权限校验与注册
+
+ // 1) 执行入口脚本(顶层只做定义)
+ ctx, cancel := context.WithTimeout(context.Background(), p.Timeout())
+ err = loadEntryWith(worker, ctx, rt)
+ cancel()
+ if err != nil {
+ worker.Close()
+ return err
+ }
+
+ // 2) 生命周期:安装回调(仅当标记为首次)
+ ctx2, cancel2 := context.WithTimeout(context.Background(), p.Timeout())
+ callOptional(worker, ctx2, rt, "onInstall")
+ cancel2()
+
+ // 3) setup:注册全部扩展点
+ setupCtx, cancelSetup := context.WithTimeout(context.Background(), setupTimeoutBudget(&p))
+ err = runSetup(worker, setupCtx, rt)
+ cancelSetup()
+ if err != nil {
+ worker.Close()
+ return fmt.Errorf("setup() 执行失败: %w", err)
+ }
+
+ // 4) 生成注册表快照并启动任务
+ RegisterApp(app)
+ startJobs(app)
+ noteSuccess(app)
+ logPlugin(p.SlugOf(), "enable", "插件已加载", 0)
+ util.Log("info", "应用型插件 %s(%s) 已加载:路由 %d、后台页 %d、菜单 %d、slot %d、事件 %d、过滤器 %d、任务 %d",
+ p.Name, p.SlugOf(), len(app.Routes), len(app.Pages), len(app.Menus),
+ len(app.Slots), len(app.Events), len(app.Filters), len(app.Jobs))
+ return nil
+}
+
+// UnloadApp 停止并移除一个应用型插件
+func UnloadApp(dir string) {
+ dir = filepath.Base(strings.TrimSpace(dir))
+ if dir == "" || dir == "." {
+ return
+ }
+ if a := GetApp(dir); a != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), a.Plugin.Timeout())
+ callOptional(a.Worker, ctx, a.Worker.rt, "onDisable")
+ cancel()
+ stopJobs(dir)
+ a.Worker.Close()
+ logPlugin(a.Plugin.SlugOf(), "disable", "插件已卸载", 0)
+ }
+ UnregisterApp(dir)
+}
+
+// ReloadApp 重载(用于开发调试:修改脚本后重新 setup)
+func ReloadApp(dir string) error {
+ UnloadApp(dir)
+ return LoadApp(dir)
+}
+
+// loadEntryWith 在 worker 中执行入口脚本
+func loadEntryWith(w *Worker, ctx context.Context, rt *jsRuntime) error {
+ _, err := w.Do(ctx, func(r *jsRuntime) (any, error) {
+ return nil, r.loadEntry()
+ })
+ return err
+}
+
+// runSetup 在 worker 中执行 setup()
+func runSetup(w *Worker, ctx context.Context, rt *jsRuntime) error {
+ _, err := w.Do(ctx, func(r *jsRuntime) (any, error) {
+ if !r.hasFn("setup") {
+ return nil, errors.New("应用型插件必须定义 setup() 函数")
+ }
+ _, err := r.callFn("setup")
+ return nil, err
+ })
+ return err
+}
+
+// callOptional 调用可选的生命周期回调(未定义或出错都不影响主流程)
+func callOptional(w *Worker, ctx context.Context, rt *jsRuntime, fn string) {
+ if w == nil || rt == nil {
+ return
+ }
+ _, err := w.Do(ctx, func(r *jsRuntime) (any, error) {
+ if !r.hasFn(fn) {
+ return nil, nil
+ }
+ _, err := r.callFn(fn)
+ return nil, err
+ })
+ if err != nil && !errors.Is(err, context.DeadlineExceeded) {
+ util.Log("warn", "插件回调 %s 执行失败: %v", fn, err)
+ }
+}
+
+// setupTimeoutBudget setup 允许稍长一点的时间,但不应像运行时那样放长
+// (初始化只做注册,耗时的网络调用应放在路由/任务里)
+func setupTimeoutBudget(p *Plugin) time.Duration {
+ d := p.Timeout()
+ if d > time.Duration(setupMaxMS)*time.Millisecond {
+ return time.Duration(setupMaxMS) * time.Millisecond
+ }
+ if d < time.Duration(setupTimeoutMS)*time.Millisecond {
+ return time.Duration(setupTimeoutMS) * time.Millisecond
+ }
+ return d
+}
+
+// dir_pluginsDir 插件根目录(跟随数据目录配置)
+func dir_pluginsDir() string { return dir() }
diff --git a/internal/plugin/manifest.go b/internal/plugin/manifest.go
new file mode 100644
index 0000000..2f8ebd8
--- /dev/null
+++ b/internal/plugin/manifest.go
@@ -0,0 +1,171 @@
+// 插件形态与清单解析。
+//
+// 两种形态(由 plugin.json 的 kind 字段区分):
+// - 声明式 declarative(默认,无 kind 字段):能力来自 hooks 声明,内核解释执行
+// - 应用型 app(kind=app):能力来自 main.js 中的 clv.* 运行时注册
+//
+// 设计原则:清单决定「能碰什么」(权限必须先于脚本加载确定),
+// 脚本决定「要做什么」(注册逻辑本身是代码,写成声明会退化成白名单枚举)。
+package plugin
+
+import (
+ "crypto/sha1"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// 插件形态
+const (
+ KindDeclarative = "declarative"
+ KindApp = "app"
+)
+
+// RuntimeSpec 应用型插件的运行时声明
+type RuntimeSpec struct {
+ Engine string `json:"engine"` // 当前仅支持 "js"
+ Entry string `json:"entry"` // 入口脚本(相对插件目录)
+ TimeoutMS int `json:"timeout_ms"` // 单次调用超时(毫秒),默认 1500,硬上限 5000
+}
+
+const (
+ defaultTimeoutMS = 1500
+ // maxTimeoutMS 单次调用超时硬上限。
+ // 放宽到 30s 是为了支持插件做耗时操作(如调用外部 AI 接口),
+ // 默认值仍是 1500ms,只有显式声明才会放宽。
+ maxTimeoutMS = 30000
+ setupTimeoutMS = 3000 // setup() 允许略长
+ setupMaxMS = 5000 // setup() 的上限:初始化不应耗时过长
+)
+
+// KindOf 返回插件形态(缺省为声明式,保证旧插件行为不变)
+func (p *Plugin) KindOf() string {
+ if p != nil && strings.EqualFold(strings.TrimSpace(p.Kind), KindApp) {
+ return KindApp
+ }
+ return KindDeclarative
+}
+
+// IsApp 是否为应用型插件
+func (p *Plugin) IsApp() bool { return p.KindOf() == KindApp }
+
+// SlugOf 返回 URL / 表名安全标识:显式声明优先,否则由 name 归一化,
+// 再退化为 plugin-<sha1 前 8 位>,保证任何插件名(含中文)都有可用标识。
+func (p *Plugin) SlugOf() string {
+ if p == nil {
+ return ""
+ }
+ if s := NormalizeSlug(p.Slug); s != "" {
+ return s
+ }
+ if s := NormalizeSlug(p.Name); s != "" {
+ return s
+ }
+ sum := sha1.Sum([]byte(p.Name))
+ return "plugin-" + hex.EncodeToString(sum[:4])
+}
+
+// NormalizeSlug 归一化为仅含 [a-z0-9_-] 的标识(连续非法字符折叠为一个 '-')
+func NormalizeSlug(s string) string {
+ s = strings.ToLower(strings.TrimSpace(s))
+ var b strings.Builder
+ dash := false
+ for _, r := range s {
+ switch {
+ case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_':
+ b.WriteRune(r)
+ dash = false
+ case r == '-':
+ if !dash {
+ b.WriteByte('-')
+ dash = true
+ }
+ default: // 中文、空格等一律折叠为 '-'
+ if !dash {
+ b.WriteByte('-')
+ dash = true
+ }
+ }
+ }
+ return strings.Trim(b.String(), "-")
+}
+
+// Timeout 单次脚本调用超时
+func (p *Plugin) Timeout() time.Duration {
+ ms := defaultTimeoutMS
+ if p != nil && p.Runtime != nil && p.Runtime.TimeoutMS > 0 {
+ ms = p.Runtime.TimeoutMS
+ }
+ if ms <= 0 || ms > maxTimeoutMS {
+ ms = defaultTimeoutMS
+ }
+ return time.Duration(ms) * time.Millisecond
+}
+
+// HasPermission 插件是否声明了指定权限(应用型的能力白名单)
+func (p *Plugin) HasPermission(name string) bool {
+ if p == nil {
+ return false
+ }
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return false
+ }
+ for _, v := range p.Permissions {
+ if strings.EqualFold(strings.TrimSpace(v), name) {
+ return true
+ }
+ }
+ return false
+}
+
+// TablePrefix 插件数据表前缀:pl_<slug>_(slug 中的 '-' 转为 '_',避免反引号)
+func (p *Plugin) TablePrefix() string {
+ return "pl_" + strings.ReplaceAll(p.SlugOf(), "-", "_") + "_"
+}
+
+// TableName 由逻辑名得到实际表名
+func (p *Plugin) TableName(logical string) string {
+ logical = NormalizeSlug(logical)
+ if logical == "" {
+ return ""
+ }
+ return p.TablePrefix() + strings.ReplaceAll(logical, "-", "_")
+}
+
+// unmarshalPlugin 解析插件清单(要求 UTF-8 无 BOM,带 BOM 会给出明确提示)
+func unmarshalPlugin(data []byte, p *Plugin) error {
+ if len(data) > 1<<20 {
+ return errors.New("plugin.json 过大(超过 1MB)")
+ }
+ if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
+ return errors.New("plugin.json 含有 UTF-8 BOM,请另存为「UTF-8 无 BOM」")
+ }
+ if err := json.Unmarshal(data, p); err != nil {
+ return fmt.Errorf("plugin.json 解析失败: %w", err)
+ }
+ if strings.TrimSpace(p.Name) == "" {
+ return errors.New("plugin.json 缺少 name 字段")
+ }
+ return nil
+}
+
+// entryPath 校验并返回入口脚本的安全相对路径
+func (p *Plugin) entryPath() string {
+ if p == nil || p.Runtime == nil {
+ return ""
+ }
+ name := strings.TrimSpace(p.Runtime.Entry)
+ if name == "" {
+ return ""
+ }
+ name = strings.ReplaceAll(name, "\\", "/")
+ // 只允许插件目录下的直接路径,拒绝穿越与绝对路径
+ if strings.HasPrefix(name, "/") || strings.Contains(name, "..") {
+ return ""
+ }
+ return name
+}
diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go
index 2657ebe..c843b58 100644
--- a/internal/plugin/plugin.go
+++ b/internal/plugin/plugin.go
@@ -1,11 +1,16 @@
-// Package plugin 钩子式插件系统。
-// 插件为目录 data/plugins/<name>/plugin.json(支持 zip 上传安装),
-// 支持三类钩子动作:
-// - html : 向页面钩子点注入 HTML(header_html / footer_html / card_extra)
-// - filter : 正则改写帖子/评论内容(content_filter)
-// - http : 事件 Webhook(post_created / comment_created / report_created)
+// Package plugin 插件系统,支持两种形态(由 plugin.json 的 kind 字段区分):
//
-// 插件开发文档见 docs/PLUGIN.md。
+// - 声明式(默认,无 kind):能力来自 hooks 声明,由内核解释执行
+// html : 向页面钩子点注入 HTML(header_html / footer_html / admin_plugins_top)
+// filter : 正则改写帖子/评论内容(content_filter)
+// http : 事件 Webhook(post_created / comment_created / report_created)
+// guard : 发帖守卫(compose_guard)
+// badge : 帖子标识(post_badge)
+// - 应用型(kind=app):能力来自 main.js 中的 clv.* 运行时注册
+// (路由 / 数据表 / 后台菜单与页面 / Slot / 事件 / 过滤器 / 中间件 / 定时任务)
+//
+// 插件为目录 data/plugins/<name>/plugin.json(支持 zip 上传安装)。
+// 开发文档:docs/PLUGIN.md(总览)、docs/PLUGIN-DECLARATIVE.md、docs/PLUGIN-APP.md。
package plugin
import (
@@ -56,15 +61,21 @@ type HookDef struct {
Message string `json:"message"` // 校验失败时的提示语
}
-// Plugin 插件清单
+// Plugin 插件清单。支持两种形态:
+// - 声明式(默认,无 kind 字段):能力来自 hooks 声明,内核解释执行
+// - 应用型(kind=app):能力来自 main.js 中的 clv.* 运行时注册
type Plugin struct {
- Name string `json:"name"`
- Version string `json:"version"`
- Author string `json:"author"`
- Description string `json:"description"`
- Requires string `json:"requires"` // 建议的最低表白墙版本(仅提示)
- Config []ConfigField `json:"config"` // 配置项声明(可选)
- Hooks []HookDef `json:"hooks"`
+ Kind string `json:"kind"` // "" / declarative = 声明式;app = 应用型
+ Name string `json:"name"` // 插件名(同时作为安装目录名)
+ Slug string `json:"slug"` // URL / 表名安全标识,缺省由 Name 归一化
+ Version string `json:"version"` // 版本号,展示用
+ Author string `json:"author"` // 作者
+ Description string `json:"description"` // 简介
+ Requires string `json:"requires"` // 建议的最低表白墙版本(仅提示)
+ Runtime *RuntimeSpec `json:"runtime"` // 应用型必填:脚本入口与超时
+ Permissions []string `json:"permissions"` // 应用型能力白名单(必须在清单中声明)
+ Config []ConfigField `json:"config"` // 配置项声明(两种形态都支持)
+ Hooks []HookDef `json:"hooks"` // 声明式钩子(应用型可不写)
}
// Meta 插件目录信息(管理页展示)
@@ -72,6 +83,9 @@ type Meta struct {
Plugin
Enabled bool
Warn string // 兼容性提示:使用了当前版本不支持的钩子 / 版本要求不满足
+ App bool // 是否应用型(kind=app)
+ Running bool // 应用型插件运行时是否已加载
+ Slug string // 应用型插件的 URL/表名标识
}
// supportedTypes 当前内核支持的钩子类型
@@ -120,6 +134,40 @@ func versionLess(a, b string) bool {
// dir 插件目录(跟随数据目录配置,支持 CLEARLOVE_DATA_DIR 自定义)
func dir() string { return filepath.Join(config.Cfg.DataDir, "plugins") }
+// ---------- 清单缓存 ----------
+//
+// 热路径(每次页面渲染、每次发帖)都会读取插件清单,
+// 这里加 5 秒短缓存;安装/启用/禁用/卸载等管理操作会立即失效缓存,
+// 因此管理端行为与"每次读盘"没有可感知差异。
+
+var loadCache struct {
+ sync.Mutex
+ exp time.Time
+ data map[string]*Plugin
+}
+
+// cachedPlugins 热路径使用的插件清单(带短 TTL)
+func cachedPlugins() map[string]*Plugin {
+ loadCache.Lock()
+ defer loadCache.Unlock()
+ if loadCache.data != nil && time.Now().Before(loadCache.exp) {
+ return loadCache.data
+ }
+ loadCache.data = Load()
+ loadCache.exp = time.Now().Add(5 * time.Second)
+ return loadCache.data
+}
+
+// InvalidatePluginCache 管理操作后主动失效缓存(安装/启用/禁用/卸载/配置变更)
+func InvalidatePluginCache() {
+ loadCache.Lock()
+ loadCache.exp = time.Time{}
+ loadCache.Unlock()
+ filterCache.Lock()
+ filterCache.exp = time.Time{}
+ filterCache.Unlock()
+}
+
// Load 读取磁盘上的全部插件清单
func Load() map[string]*Plugin {
out := map[string]*Plugin{}
@@ -194,12 +242,18 @@ func saveEnabled(set map[string]bool) {
_ = models.SetSetting("enabled_plugins", string(b))
}
-// List 管理页用:全部插件、启用状态与兼容性提示
+// List 管理页用:全部插件、启用状态、形态与运行状态
func List() []Meta {
enabled := enabledSet()
var out []Meta
- for _, p := range Load() {
- out = append(out, Meta{Plugin: *p, Enabled: enabled[p.Name], Warn: p.checkCompat()})
+ for name, p := range Load() {
+ m := Meta{Plugin: *p, Enabled: enabled[p.Name], Warn: p.checkCompat()}
+ if p.IsApp() {
+ m.App = true
+ m.Slug = p.SlugOf()
+ m.Running = GetApp(filepath.Base(name)) != nil
+ }
+ out = append(out, m)
}
return out
}
@@ -296,7 +350,7 @@ func CheckComposeGuard(nickname string) *GuardResult {
enabled := enabledSet()
res := &GuardResult{}
hit := false
- for pname, p := range Load() {
+ for pname, p := range cachedPlugins() {
if !enabled[pname] {
continue
}
@@ -338,7 +392,7 @@ func CheckComposeGuard(nickname string) *GuardResult {
func GuardNicknames() []string {
enabled := enabledSet()
var out []string
- for pname, p := range Load() {
+ for pname, p := range cachedPlugins() {
if !enabled[pname] {
continue
}
@@ -361,7 +415,7 @@ func NicknameBadges(nickname string) []string {
}
enabled := enabledSet()
var out []string
- for pname, p := range Load() {
+ for pname, p := range cachedPlugins() {
if !enabled[pname] {
continue
}
@@ -439,6 +493,7 @@ func Install(filename string, data []byte) error {
out.Close()
rc.Close()
}
+ InvalidatePluginCache()
return nil
}
@@ -448,6 +503,7 @@ func Remove(name string) error {
set := enabledSet()
delete(set, name)
saveEnabled(set)
+ InvalidatePluginCache()
return nil
}
@@ -460,6 +516,7 @@ func SetEnabled(name string, on bool) {
delete(set, name)
}
saveEnabled(set)
+ InvalidatePluginCache()
}
var filterCache struct {
@@ -483,7 +540,7 @@ func CallFilter(content string) string {
if time.Now().After(filterCache.exp) {
filterCache.rules = nil
enabled := enabledSet()
- for name, p := range Load() {
+ for name, p := range cachedPlugins() {
if !enabled[name] {
continue
}
@@ -509,7 +566,7 @@ func CallFilter(content string) string {
func CallHTML(hook string) string {
var b strings.Builder
enabled := enabledSet()
- for name, p := range Load() {
+ for name, p := range cachedPlugins() {
if !enabled[name] {
continue
}
@@ -526,7 +583,7 @@ func CallHTML(hook string) string {
// Notify 异步触发事件 Webhook(POST JSON),失败仅记录日志
func Notify(event string, payload map[string]any) {
enabled := enabledSet()
- for name, p := range Load() {
+ for name, p := range cachedPlugins() {
if !enabled[name] {
continue
}
diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go
new file mode 100644
index 0000000..383b7b1
--- /dev/null
+++ b/internal/plugin/registry.go
@@ -0,0 +1,341 @@
+// 应用型插件注册表。
+//
+// 所有扩展点(路由 / 后台页面与菜单 / Slot / 事件 / 过滤器 / 定时任务)
+// 统一登记在此,热路径通过不可变快照读取,避免每次请求加锁。
+package plugin
+
+import (
+ "context"
+ "sort"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+// RouteReg 应用型插件注册的 HTTP 路由(path 为相对路径)
+type RouteReg struct {
+ Method string // GET / POST / ...
+ Path string // 相对 "/x/<slug>" 或 "/admin/plugins/<slug>" 的路径
+ Fn string // 脚本函数名
+ Auth string // none | user | admin
+ Perm string // 后台页面所需的内核权限组
+ JSON bool // JSON 接口(错误以 JSON 返回)
+}
+
+// MenuReg 后台菜单项
+type MenuReg struct {
+ Slug string // 所属插件 slug(重建快照时填充)
+ Label string
+ Path string
+ Perm string
+ Order int
+}
+
+// PageReg 后台页面
+type PageReg struct {
+ Path string
+ Fn string
+ Perm string
+}
+
+// FilterReg 过滤器注册项
+type FilterReg struct {
+ Fn string
+ Priority int
+}
+
+// MiddlewareReg HTTP 中间件注册项(phase: http.before / http.after)
+type MiddlewareReg struct {
+ Phase string
+ Fn string
+}
+
+// JobReg 定时任务
+type JobReg struct {
+ Name string
+ Every time.Duration
+ Fn string
+}
+
+// App 一个已加载的应用型插件实例
+type App struct {
+ Dir string // 目录名(生命周期操作的键)
+ Plugin *Plugin
+ Worker *Worker
+
+ Routes []RouteReg
+ Pages []PageReg
+ Menus []MenuReg
+ Slots map[string][]string
+ Events map[string][]string
+ Filters map[string][]FilterReg
+ Jobs []JobReg
+ Middlewares []MiddlewareReg
+}
+
+func newApp(dir string, p *Plugin, w *Worker) *App {
+ return &App{
+ Dir: dir, Plugin: p, Worker: w,
+ Slots: map[string][]string{}, Events: map[string][]string{},
+ Filters: map[string][]FilterReg{},
+ }
+}
+
+// ---------- 供热路径读取的不可变快照 ----------
+
+// SlotHook 一个 Slot 处理器
+type SlotHook struct {
+ App *App
+ Fn string
+}
+
+// EventHook 一个事件处理器
+type EventHook struct {
+ App *App
+ Fn string
+}
+
+// FilterHook 一个过滤器
+type FilterHook struct {
+ App *App
+ Fn string
+ Priority int
+}
+
+// MiddlewareHook 一个 HTTP 中间件
+type MiddlewareHook struct {
+ App *App
+ Fn string
+}
+
+// RouteHook 一条路由
+type RouteHook struct {
+ App *App
+ Route RouteReg
+}
+
+type snapshot struct {
+ apps map[string]*App // dir -> app
+ bySlug map[string]*App // slug -> app
+ slots map[string][]SlotHook
+ events map[string][]EventHook
+ filters map[string][]FilterHook
+ middlewares map[string][]MiddlewareHook
+ routes []RouteHook
+ menus []MenuReg
+ pages map[string]string // "<slug><path>" -> fn 名
+ empty bool
+}
+
+var (
+ regMu sync.Mutex
+ regApps = map[string]*App{} // dir -> app
+ regSnap atomic.Pointer[snapshot]
+ regInitOne sync.Once
+)
+
+func init() {
+ regInitOne.Do(func() { regSnap.Store(&snapshot{empty: true}) })
+}
+
+// RegisterApp 登记一个已 setup 完成的应用型插件
+func RegisterApp(a *App) {
+ regMu.Lock()
+ regApps[a.Dir] = a
+ regMu.Unlock()
+ rebuildSnapshot()
+}
+
+// UnregisterApp 移除应用型插件(禁用 / 卸载)
+func UnregisterApp(dir string) {
+ regMu.Lock()
+ delete(regApps, dir)
+ regMu.Unlock()
+ rebuildSnapshot()
+}
+
+// GetApp 按目录名取已加载的应用型插件
+func GetApp(dir string) *App {
+ regMu.Lock()
+ defer regMu.Unlock()
+ return regApps[dir]
+}
+
+// AppBySlug 按 slug 取已加载的应用型插件
+func AppBySlug(slug string) *App {
+ s := regSnap.Load()
+ if s == nil {
+ return nil
+ }
+ return s.bySlug[slug]
+}
+
+// CallFn 调用某个应用型插件的脚本函数(内核与测试使用,参数与返回值可 JSON 化)
+func CallFn(slug, fn string, args ...any) (any, error) {
+ a := AppBySlug(slug)
+ if a == nil || a.Worker == nil {
+ return nil, errNoApp
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), a.Plugin.Timeout())
+ defer cancel()
+ return a.Worker.Do(ctx, func(rt *jsRuntime) (any, error) {
+ rt.cur = nil
+ return rt.callFn(fn, args...)
+ })
+}
+
+// LoadedApps 全部已加载的应用型插件
+func LoadedApps() []*App {
+ regMu.Lock()
+ defer regMu.Unlock()
+ out := make([]*App, 0, len(regApps))
+ for _, a := range regApps {
+ out = append(out, a)
+ }
+ return out
+}
+
+// rebuildSnapshot 重建不可变快照(注册变更时调用,非热路径)
+func rebuildSnapshot() {
+ regMu.Lock()
+ defer regMu.Unlock()
+ s := &snapshot{
+ apps: map[string]*App{},
+ bySlug: map[string]*App{},
+ slots: map[string][]SlotHook{},
+ events: map[string][]EventHook{},
+ filters: map[string][]FilterHook{},
+ middlewares: map[string][]MiddlewareHook{},
+ pages: map[string]string{},
+ }
+ for dir, a := range regApps {
+ s.apps[dir] = a
+ s.bySlug[a.Plugin.SlugOf()] = a
+ for slot, fns := range a.Slots {
+ for _, fn := range fns {
+ s.slots[slot] = append(s.slots[slot], SlotHook{App: a, Fn: fn})
+ }
+ }
+ for ev, fns := range a.Events {
+ for _, fn := range fns {
+ s.events[ev] = append(s.events[ev], EventHook{App: a, Fn: fn})
+ }
+ }
+ for fname, list := range a.Filters {
+ for _, f := range list {
+ s.filters[fname] = append(s.filters[fname], FilterHook{App: a, Fn: f.Fn, Priority: f.Priority})
+ }
+ }
+ for _, mw := range a.Middlewares {
+ s.middlewares[mw.Phase] = append(s.middlewares[mw.Phase], MiddlewareHook{App: a, Fn: mw.Fn})
+ }
+ for _, r := range a.Routes {
+ s.routes = append(s.routes, RouteHook{App: a, Route: r})
+ }
+ for _, m := range a.Menus {
+ m.Slug = a.Plugin.SlugOf()
+ s.menus = append(s.menus, m)
+ }
+ for _, p := range a.Pages {
+ s.pages[a.Plugin.SlugOf()+p.Path] = p.Fn
+ }
+ }
+ // 过滤器按优先级升序(小的先执行)
+ for k := range s.filters {
+ list := s.filters[k]
+ sort.SliceStable(list, func(i, j int) bool { return list[i].Priority < list[j].Priority })
+ s.filters[k] = list
+ }
+ sort.SliceStable(s.menus, func(i, j int) bool { return s.menus[i].Order < s.menus[j].Order })
+ s.empty = len(s.apps) == 0
+ regSnap.Store(s)
+}
+
+func currentSnapshot() *snapshot {
+ s := regSnap.Load()
+ if s == nil {
+ return &snapshot{empty: true}
+ }
+ return s
+}
+
+// HasApps 是否有任何应用型插件在运行(热路径零开销判断)
+func HasApps() bool { return !currentSnapshot().empty }
+
+// ---------- 注册表查询(内核侧使用) ----------
+
+// SlotHooks 取某 Slot 的全部处理器
+func SlotHooks(name string) []SlotHook { return currentSnapshot().slots[name] }
+
+// EventHooks 取某事件的全部处理器
+func EventHooks(event string) []EventHook { return currentSnapshot().events[event] }
+
+// FilterHooks 取某过滤器的全部处理器(已按优先级排序)
+func FilterHooks(name string) []FilterHook { return currentSnapshot().filters[name] }
+
+// AdminMenus 全部插件后台菜单
+func AdminMenus() []MenuReg { return currentSnapshot().menus }
+
+// MiddlewareHooks 取某阶段(http.before / http.after)的中间件
+func MiddlewareHooks(phase string) []MiddlewareHook { return currentSnapshot().middlewares[phase] }
+
+// FindRoute 匹配插件路由:返回 App、路由定义与剩余路径
+func FindRoute(prefix string, method, path string) (*App, *RouteReg, bool) {
+ s := currentSnapshot()
+ for i := range s.routes {
+ r := &s.routes[i]
+ if r.App.Plugin.SlugOf() != prefix {
+ continue
+ }
+ if !methodEqual(r.Route.Method, method) {
+ continue
+ }
+ if pathEqual(r.Route.Path, path) {
+ return r.App, &r.Route, true
+ }
+ }
+ return nil, nil, false
+}
+
+// AdminPageFn 查后台页面处理器
+func AdminPageFn(slug, path string) (fn string, ok bool) {
+ fn, ok = currentSnapshot().pages[slug+path]
+ return
+}
+
+func methodEqual(reg, got string) bool {
+ if reg == "" || reg == "*" {
+ return true
+ }
+ return equalFoldASCII(reg, got)
+}
+
+func equalFoldASCII(a, b string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := 0; i < len(a); i++ {
+ ca, cb := a[i], b[i]
+ if 'a' <= ca && ca <= 'z' {
+ ca -= 32
+ }
+ if 'a' <= cb && cb <= 'z' {
+ cb -= 32
+ }
+ if ca != cb {
+ return false
+ }
+ }
+ return true
+}
+
+// pathEqual 路径匹配,支持 "/*" 后缀通配
+func pathEqual(reg, got string) bool {
+ if reg == got {
+ return true
+ }
+ if len(reg) > 2 && reg[len(reg)-2:] == "/*" {
+ return len(got) >= len(reg)-2 && got[:len(reg)-2] == reg[:len(reg)-2]
+ }
+ return false
+}
diff --git a/internal/plugin/router.go b/internal/plugin/router.go
new file mode 100644
index 0000000..2886ffb
--- /dev/null
+++ b/internal/plugin/router.go
@@ -0,0 +1,342 @@
+// 应用型插件的 HTTP 分发。
+//
+// /x/<slug>/... 前台路由(auth: none | user | admin)
+// /x/<slug>/assets/... 插件静态资源(只读)
+// /admin/plugins/<slug>/... 后台页面(强制管理员 + perm 校验)
+package plugin
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "clearlove/internal/util"
+)
+
+func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
+ return context.WithTimeout(context.Background(), d)
+}
+
+const maxPluginBody = 1 << 20 // 插件请求体读取上限 1MB
+
+// AppHandler 前台插件路由分发器
+func AppHandler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ rest := strings.TrimPrefix(r.URL.Path, "/x/")
+ slug, sub := splitSlugPath(rest)
+ app := AppBySlug(slug)
+ if app == nil {
+ http.NotFound(w, r)
+ return
+ }
+ // 静态资源:/x/<slug>/assets/xxx
+ if strings.HasPrefix(sub, "/assets/") {
+ servePluginAsset(w, r, app, strings.TrimPrefix(sub, "/assets/"))
+ return
+ }
+ route, ok := matchRoute(app, r.Method, sub)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ if !checkAuth(w, r, route.Auth) {
+ return
+ }
+ serveHandler(w, r, app, route.Fn, route.JSON, nil)
+ })
+}
+
+// AdminAppHandler 后台插件页面分发器
+func AdminAppHandler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if viewProvider == nil {
+ http.Error(w, "服务未就绪", http.StatusServiceUnavailable)
+ return
+ }
+ if viewProvider.AdminID(r) == 0 {
+ http.Redirect(w, r, "/admin/login", http.StatusFound)
+ return
+ }
+ rest := strings.TrimPrefix(r.URL.Path, "/admin/plugins/")
+ slug, sub := splitSlugPath(rest)
+ app := AppBySlug(slug)
+ if app == nil {
+ http.NotFound(w, r)
+ return
+ }
+ fn, ok := AdminPageFn(slug, sub)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ // 页面权限:未声明 perm 时按 plugins 权限组处理
+ perm := "plugins"
+ for _, p := range app.Pages {
+ if p.Path == sub {
+ if strings.TrimSpace(p.Perm) != "" {
+ perm = p.Perm
+ }
+ break
+ }
+ }
+ if !viewProvider.AdminPerm(r, perm) {
+ http.Error(w, "没有操作权限", http.StatusForbidden)
+ return
+ }
+ serveHandler(w, r, app, fn, false, map[string]string{"admin": "1"})
+ })
+}
+
+// ---------- 内部实现 ----------
+
+// splitSlugPath 拆出 slug 与剩余路径
+func splitSlugPath(rest string) (slug, sub string) {
+ rest = strings.Trim(rest, "/")
+ if rest == "" {
+ return "", "/"
+ }
+ if i := strings.Index(rest, "/"); i >= 0 {
+ slug, sub = rest[:i], rest[i:]
+ } else {
+ slug, sub = rest, "/"
+ }
+ if !strings.HasPrefix(sub, "/") {
+ sub = "/" + sub
+ }
+ if len(sub) > 1 {
+ sub = strings.TrimRight(sub, "/")
+ if sub == "" {
+ sub = "/"
+ }
+ }
+ return slug, sub
+}
+
+func matchRoute(a *App, method, sub string) (*RouteReg, bool) {
+ for i := range a.Routes {
+ rt := &a.Routes[i]
+ if !methodEqual(rt.Method, method) {
+ continue
+ }
+ if pathEqual(rt.Path, sub) {
+ return rt, true
+ }
+ }
+ return nil, false
+}
+
+func checkAuth(w http.ResponseWriter, r *http.Request, auth string) bool {
+ if viewProvider == nil {
+ return true
+ }
+ switch strings.ToLower(strings.TrimSpace(auth)) {
+ case "user":
+ if viewProvider.UserID(r) == 0 {
+ next := r.URL.Path
+ if r.URL.RawQuery != "" {
+ next += "?" + r.URL.RawQuery
+ }
+ http.Redirect(w, r, "/login?next="+next, http.StatusFound)
+ return false
+ }
+ case "admin":
+ if viewProvider.AdminID(r) == 0 {
+ http.Error(w, "需要管理员身份", http.StatusForbidden)
+ return false
+ }
+ }
+ return true
+}
+
+// serveHandler 调用插件 handler 并写出响应
+func serveHandler(w http.ResponseWriter, r *http.Request, app *App, fn string, isJSON bool, extra map[string]string) {
+ if app.Worker == nil || app.Worker.Closed() {
+ http.Error(w, "插件未运行", http.StatusServiceUnavailable)
+ return
+ }
+ ctxMap := buildRequestCtx(r, extra)
+
+ ctx, cancel := contextWithTimeout(app.Plugin.Timeout())
+ defer cancel()
+ v, err := app.Worker.Do(ctx, func(rt *jsRuntime) (any, error) {
+ rt.cur = ctxToReqContext(r, ctxMap)
+ defer func() { rt.cur = nil }()
+ return rt.callFn(fn, ctxMap)
+ })
+ if err != nil {
+ noteFailure(app, err)
+ logPlugin(app.Plugin.SlugOf(), "route:"+fn, "执行失败: "+err.Error(), 0)
+ if isJSON {
+ writeJSONError(w, http.StatusInternalServerError, "插件执行失败")
+ return
+ }
+ http.Error(w, "插件执行失败", http.StatusInternalServerError)
+ return
+ }
+ noteSuccess(app)
+ writeResult(w, r, app, fn, v, isJSON)
+}
+
+func writeResult(w http.ResponseWriter, r *http.Request, app *App, fn string, v any, isJSON bool) {
+ m, ok := v.(map[string]any)
+ if !ok {
+ if isJSON {
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ for k, hv := range m {
+ if k == "headers" {
+ if hdrs, ok := hv.(map[string]any); ok {
+ for hk, hvv := range hdrs {
+ w.Header().Set(hk, strOf(hvv))
+ }
+ }
+ }
+ }
+ status := intOf(m["status"])
+ if status <= 0 {
+ status = http.StatusOK
+ }
+ if u := strOf(m["redirect"]); u != "" {
+ http.Redirect(w, r, u, http.StatusSeeOther)
+ return
+ }
+ if j, ok := m["json"]; ok {
+ writeJSON(w, status, j)
+ return
+ }
+ if tpl := strOf(m["template"]); tpl != "" {
+ if viewProvider == nil {
+ http.Error(w, "模板能力未就绪", http.StatusServiceUnavailable)
+ return
+ }
+ if strings.Contains(tpl, "..") || strings.HasPrefix(tpl, "/") {
+ http.Error(w, "非法模板名", http.StatusBadRequest)
+ return
+ }
+ html, err := viewProvider.RenderTemplate(os.DirFS(filepath.Join(dir(), app.Dir)), tpl, m["data"])
+ if err != nil {
+ noteFailure(app, err)
+ http.Error(w, "模板渲染失败", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(html))
+ return
+ }
+ if body := strOf(m["body"]); body != "" {
+ if w.Header().Get("Content-Type") == "" {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ }
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(body))
+ return
+ }
+ _ = fn
+ w.WriteHeader(status)
+}
+
+// buildRequestCtx 构造传给脚本的 ctx 对象
+func buildRequestCtx(r *http.Request, params map[string]string) map[string]any {
+ query := map[string]string{}
+ for k, v := range r.URL.Query() {
+ if len(v) > 0 {
+ query[k] = v[0]
+ }
+ }
+ form := map[string]string{}
+ _ = r.ParseForm()
+ for k, v := range r.PostForm {
+ if len(v) > 0 {
+ form[k] = v[0]
+ }
+ }
+ var jsonBody any
+ if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
+ if b, err := io.ReadAll(io.LimitReader(r.Body, maxPluginBody)); err == nil && len(b) > 0 {
+ _ = json.Unmarshal(b, &jsonBody)
+ }
+ }
+ ctxMap := map[string]any{
+ "req": map[string]any{
+ "method": r.Method,
+ "path": r.URL.Path,
+ "query": query,
+ "form": form,
+ "json": jsonBody,
+ "ip": util.ClientIP(r),
+ "fingerprint": util.FingerprintOf(r),
+ },
+ "params": params,
+ }
+ if viewProvider != nil {
+ uid := viewProvider.UserID(r)
+ aid := viewProvider.AdminID(r)
+ ctxMap["userId"] = uid
+ ctxMap["adminId"] = aid
+ ctxMap["isAdmin"] = aid > 0
+ ctxMap["csrf"] = viewProvider.CSRF(r)
+ }
+ return ctxMap
+}
+
+func ctxToReqContext(r *http.Request, ctxMap map[string]any) *reqContext {
+ rc := &reqContext{Method: r.Method, Path: r.URL.Path}
+ if v, ok := ctxMap["userId"].(int64); ok {
+ rc.UserID = v
+ }
+ if v, ok := ctxMap["adminId"].(int64); ok {
+ rc.AdminID = v
+ rc.IsAdmin = v > 0
+ }
+ if p, ok := ctxMap["params"].(map[string]string); ok {
+ rc.Params = p
+ }
+ if req, ok := ctxMap["req"].(map[string]any); ok {
+ if q, ok := req["query"].(map[string]string); ok {
+ rc.Query = q
+ }
+ if f, ok := req["form"].(map[string]string); ok {
+ rc.Form = f
+ }
+ rc.IP = strOf(req["ip"])
+ rc.Fingerprint = strOf(req["fingerprint"])
+ }
+ rc.CSRF = strOf(ctxMap["csrf"])
+ return rc
+}
+
+// servePluginAsset 插件静态资源(仅 assets 目录,防穿越)
+func servePluginAsset(w http.ResponseWriter, r *http.Request, app *App, rel string) {
+ rel = strings.TrimPrefix(rel, "/")
+ if rel == "" || strings.Contains(rel, "..") {
+ http.NotFound(w, r)
+ return
+ }
+ full := filepath.Join(dir(), app.Dir, "assets", filepath.FromSlash(rel))
+ info, err := os.Stat(full)
+ if err != nil || info.IsDir() {
+ http.NotFound(w, r)
+ return
+ }
+ http.ServeFile(w, r, full)
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func writeJSONError(w http.ResponseWriter, status int, msg string) {
+ writeJSON(w, status, map[string]any{"ok": false, "msg": msg})
+}
diff --git a/internal/plugin/router_test.go b/internal/plugin/router_test.go
new file mode 100644
index 0000000..c827217
--- /dev/null
+++ b/internal/plugin/router_test.go
@@ -0,0 +1,85 @@
+package plugin
+
+import (
+ "io/fs"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// stubView 模拟 handlers 注入的视图能力
+type stubView struct{ uid, aid int64 }
+
+func (s stubView) RenderTemplate(fsys fs.FS, name string, data any) (string, error) {
+ return "<rendered:" + name + ">", nil
+}
+func (s stubView) CSRF(r *http.Request) string { return "tok" }
+func (s stubView) UserID(r *http.Request) int64 { return s.uid }
+func (s stubView) AdminID(r *http.Request) int64 { return s.aid }
+func (s stubView) AdminPerm(r *http.Request, p string) bool { return true }
+
+// TestAppRouter 覆盖插件路由分发:鉴权、返回值约定、后台页面
+func TestAppRouter(t *testing.T) {
+ dataDir := setupAppEnv(t)
+ writeAppPlugin(t, dataDir, "demo", demoManifest, demoScript)
+
+ SetEnabled("demo", true)
+ if err := LoadApp("demo"); err != nil {
+ t.Fatalf("加载失败: %v", err)
+ }
+ defer func() {
+ UnloadApp("demo")
+ SetEnabled("demo", false)
+ SetViewProvider(nil)
+ }()
+
+ api := AppHandler()
+ admin := AdminAppHandler()
+
+ // 1) 公开路由:未登录也可访问(auth=none)
+ SetViewProvider(stubView{})
+ w := httptest.NewRecorder()
+ api.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x/demo/", nil))
+ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "hi") {
+ t.Fatalf("公开路由异常: code=%d body=%q", w.Code, w.Body.String())
+ }
+
+ // 2) 需要登录的路由:未登录应跳转登录页
+ w2 := httptest.NewRecorder()
+ api.ServeHTTP(w2, httptest.NewRequest(http.MethodPost, "/x/demo/do", nil))
+ if w2.Code != http.StatusFound || !strings.Contains(w2.Header().Get("Location"), "/login") {
+ t.Fatalf("未登录未拦截: code=%d location=%q", w2.Code, w2.Header().Get("Location"))
+ }
+
+ // 3) 已登录:返回 JSON
+ SetViewProvider(stubView{uid: 7})
+ w3 := httptest.NewRecorder()
+ api.ServeHTTP(w3, httptest.NewRequest(http.MethodPost, "/x/demo/do", nil))
+ if w3.Code != http.StatusOK || !strings.Contains(w3.Body.String(), `"ok"`) {
+ t.Fatalf("JSON 响应异常: code=%d body=%q", w3.Code, w3.Body.String())
+ }
+
+ // 4) 未知 slug → 404
+ w4 := httptest.NewRecorder()
+ api.ServeHTTP(w4, httptest.NewRequest(http.MethodGet, "/x/nope/", nil))
+ if w4.Code != http.StatusNotFound {
+ t.Fatalf("未知插件应 404,实际 %d", w4.Code)
+ }
+
+ // 5) 后台页面:未登录跳登录页
+ SetViewProvider(stubView{})
+ w5 := httptest.NewRecorder()
+ admin.ServeHTTP(w5, httptest.NewRequest(http.MethodGet, "/admin/plugins/demo/", nil))
+ if w5.Code != http.StatusFound {
+ t.Fatalf("后台页面未拦截: %d", w5.Code)
+ }
+
+ // 6) 后台页面:管理员可访问
+ SetViewProvider(stubView{aid: 1})
+ w6 := httptest.NewRecorder()
+ admin.ServeHTTP(w6, httptest.NewRequest(http.MethodGet, "/admin/plugins/demo/", nil))
+ if w6.Code != http.StatusOK || !strings.Contains(w6.Body.String(), "admin") {
+ t.Fatalf("后台页面异常: code=%d body=%q", w6.Code, w6.Body.String())
+ }
+}
diff --git a/internal/plugin/runtime.go b/internal/plugin/runtime.go
new file mode 100644
index 0000000..b93426c
--- /dev/null
+++ b/internal/plugin/runtime.go
@@ -0,0 +1,115 @@
+// 插件运行时:每插件一条串行消息队列。
+//
+// goja 的 Runtime 不是 goroutine-safe,所有脚本访问(setup、路由、事件、
+// 过滤器、定时任务)都必须经过 Worker 串行化,插件作者因此无需考虑并发。
+package plugin
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+var errWorkerClosed = errors.New("插件运行时已关闭")
+
+type callReq struct {
+ fn func(*jsRuntime) (any, error)
+ resp chan callResp
+}
+
+type callResp struct {
+ val any
+ err error
+}
+
+// Worker 串行执行某个插件的全部脚本调用
+type Worker struct {
+ rt *jsRuntime
+ jobs chan *callReq
+ quit chan struct{}
+ closed atomic.Bool
+ once sync.Once
+}
+
+// NewWorker 创建并启动 worker(rt 已装配好 Host API)
+func NewWorker(rt *jsRuntime) *Worker {
+ w := &Worker{
+ rt: rt,
+ jobs: make(chan *callReq, 64),
+ quit: make(chan struct{}),
+ }
+ go w.loop()
+ return w
+}
+
+func (w *Worker) loop() {
+ for {
+ select {
+ case req := <-w.jobs:
+ w.exec(req)
+ case <-w.quit:
+ return
+ }
+ }
+}
+
+// exec 执行一次调用,保证异常不逃逸(panic 隔离)
+func (w *Worker) exec(req *callReq) {
+ defer func() {
+ if r := recover(); r != nil {
+ req.resp <- callResp{nil, fmt.Errorf("插件脚本异常: %v", r)}
+ }
+ // 清理可能的中断标记,确保后续调用可用
+ defer func() { _ = recover() }()
+ w.rt.vm.ClearInterrupt()
+ }()
+ val, err := req.fn(w.rt)
+ req.resp <- callResp{val, err}
+}
+
+// Do 提交一次脚本调用;ctx 超时会中断虚拟机并返回错误
+func (w *Worker) Do(ctx context.Context, fn func(*jsRuntime) (any, error)) (any, error) {
+ if w == nil || w.rt == nil {
+ return nil, errWorkerClosed
+ }
+ if w.closed.Load() {
+ return nil, errWorkerClosed
+ }
+ req := &callReq{fn: fn, resp: make(chan callResp, 1)}
+ select {
+ case w.jobs <- req:
+ case <-w.quit:
+ return nil, errWorkerClosed
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ select {
+ case r := <-req.resp:
+ return r.val, r.err
+ case <-ctx.Done():
+ // 超时:中断正在执行的脚本,等 worker 回包后返回
+ w.rt.Stop()
+ select {
+ case <-req.resp:
+ case <-time.After(800 * time.Millisecond):
+ }
+ return nil, ctx.Err()
+ }
+}
+
+// Closed 是否已关闭
+func (w *Worker) Closed() bool { return w == nil || w.closed.Load() }
+
+// Close 关闭 worker(幂等)
+func (w *Worker) Close() {
+ if w == nil {
+ return
+ }
+ w.once.Do(func() {
+ w.closed.Store(true)
+ close(w.quit)
+ })
+}
diff --git a/internal/plugin/runtime_goja.go b/internal/plugin/runtime_goja.go
new file mode 100644
index 0000000..34a46ed
--- /dev/null
+++ b/internal/plugin/runtime_goja.go
@@ -0,0 +1,315 @@
+// goja 引擎实现:脚本加载、函数调用、Host API 装配。
+package plugin
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/dop251/goja"
+
+ "clearlove/internal/config"
+ "clearlove/internal/models"
+ "clearlove/internal/util"
+)
+
+// jsRuntime 单个插件的脚本运行时(只允许通过 Worker 访问)
+type jsRuntime struct {
+ vm *goja.Runtime
+ dir string
+ plugin *Plugin
+ app *App // 注册阶段回填,Host API 用它做权限校验
+
+ cur *reqContext // 当前请求上下文(worker 串行,同一时刻仅一个)
+ fnSeq int // 内联函数自增序号,用于生成 __slot_1 之类的内部函数名
+}
+
+// newJSRuntime 创建运行时并装配 Host API
+func newJSRuntime(dir string, p *Plugin) (*jsRuntime, error) {
+ vm := goja.New()
+ vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
+ rt := &jsRuntime{vm: vm, dir: dir, plugin: p}
+ if err := rt.installAPI(); err != nil {
+ return nil, err
+ }
+ return rt, nil
+}
+
+// Stop 中断当前执行(超时保护)
+func (rt *jsRuntime) Stop() {
+ if rt == nil || rt.vm == nil {
+ return
+ }
+ defer func() { _ = recover() }()
+ rt.vm.Interrupt("timeout")
+}
+
+// loadEntry 读取并执行入口脚本(顶层代码只做定义,不应有副作用)
+func (rt *jsRuntime) loadEntry() error {
+ entry := rt.plugin.entryPath()
+ if entry == "" {
+ return errors.New("runtime.entry 缺失或非法(不允许绝对路径与 .. 穿越)")
+ }
+ full := filepath.Join(rt.dir, filepath.FromSlash(entry))
+ data, err := os.ReadFile(full)
+ if err != nil {
+ return fmt.Errorf("读取入口脚本失败: %w", err)
+ }
+ if _, err := rt.vm.RunScript(entry, string(data)); err != nil {
+ return fmt.Errorf("执行入口脚本失败: %w", err)
+ }
+ return nil
+}
+
+// hasFn 脚本是否定义了某函数
+func (rt *jsRuntime) hasFn(name string) bool {
+ _, ok := goja.AssertFunction(rt.vm.Get(name))
+ return ok
+}
+
+// callFn 调用脚本函数,参数与返回值均为可 JSON 化的 Go 值
+func (rt *jsRuntime) callFn(fn string, args ...any) (any, error) {
+ f, ok := goja.AssertFunction(rt.vm.Get(fn))
+ if !ok {
+ return nil, fmt.Errorf("插件未定义函数 %s", fn)
+ }
+ vals := make([]goja.Value, 0, len(args))
+ for _, a := range args {
+ vals = append(vals, rt.vm.ToValue(a))
+ }
+ v, err := f(goja.Undefined(), vals...)
+ if err != nil {
+ return nil, err
+ }
+ return exportJS(v), nil
+}
+
+// ---------- 值转换 ----------
+
+// exportJS goja 值 -> 纯 Go 值
+func exportJS(v goja.Value) any {
+ if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
+ return nil
+ }
+ return v.Export()
+}
+
+// argsOf 导出全部实参
+func argsOf(call goja.FunctionCall) []any {
+ out := make([]any, 0, len(call.Arguments))
+ for _, a := range call.Arguments {
+ out = append(out, exportJS(a))
+ }
+ return out
+}
+
+// objOf 导出对象参数字典
+func objOf(v goja.Value) map[string]any {
+ if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
+ return nil
+ }
+ if m, ok := v.Export().(map[string]any); ok {
+ return m
+ }
+ return nil
+}
+
+func strOf(v any) string {
+ switch t := v.(type) {
+ case nil:
+ return ""
+ case string:
+ return t
+ case int64:
+ return strconv.FormatInt(t, 10)
+ case float64:
+ return strconv.FormatFloat(t, 'f', -1, 64)
+ case bool:
+ if t {
+ return "1"
+ }
+ return "0"
+ default:
+ return fmt.Sprint(t)
+ }
+}
+
+func intOf(v any) int {
+ switch t := v.(type) {
+ case int64:
+ return int(t)
+ case int:
+ return t
+ case float64:
+ return int(t)
+ case string:
+ n, _ := strconv.Atoi(strings.TrimSpace(t))
+ return n
+ default:
+ return 0
+ }
+}
+
+func boolOf(v any) bool {
+ switch t := v.(type) {
+ case bool:
+ return t
+ case string:
+ return t == "1" || strings.EqualFold(t, "true")
+ case int64:
+ return t != 0
+ case float64:
+ return t != 0
+ default:
+ return false
+ }
+}
+
+// jsFn 包装 Go 函数为 goja 函数:错误以 JS 异常抛出(插件可 try/catch)
+func (rt *jsRuntime) jsFn(fn func(call goja.FunctionCall) (any, error)) func(goja.FunctionCall) goja.Value {
+ return func(call goja.FunctionCall) goja.Value {
+ v, err := fn(call)
+ if err != nil {
+ panic(rt.vm.NewGoError(err))
+ }
+ if v == nil {
+ return goja.Undefined()
+ }
+ return rt.vm.ToValue(v)
+ }
+}
+
+// ---------- Host API 装配 ----------
+
+func (rt *jsRuntime) installAPI() error {
+ clv := rt.vm.NewObject()
+ if err := rt.installCore(clv); err != nil {
+ return err
+ }
+ if err := rt.installDB(clv); err != nil {
+ return err
+ }
+ if err := rt.installSite(clv); err != nil {
+ return err
+ }
+ if err := rt.installView(clv); err != nil {
+ return err
+ }
+ if err := rt.installNet(clv); err != nil {
+ return err
+ }
+ rt.vm.Set("clv", clv)
+ return nil
+}
+
+// installCore clv 的基础部分:元信息、日志、JSON、配置
+func (rt *jsRuntime) installCore(clv *goja.Object) error {
+ p := rt.plugin
+
+ // clv.version
+ if err := clv.Set("version", AppVersion); err != nil {
+ return err
+ }
+
+ // clv.plugin.{name,slug,dir,version,config,configs}
+ pl := rt.vm.NewObject()
+ _ = pl.Set("name", p.Name)
+ _ = pl.Set("slug", p.SlugOf())
+ _ = pl.Set("dir", rt.dir)
+ _ = pl.Set("version", p.Version)
+ _ = pl.Set("config", rt.jsFn(func(call goja.FunctionCall) (any, error) {
+ args := argsOf(call)
+ if len(args) == 0 {
+ return nil, errors.New("clv.plugin.config(key) 需要一个参数")
+ }
+ return pluginConfigValue(p, strOf(args[0])), nil
+ }))
+ _ = pl.Set("configs", rt.jsFn(func(call goja.FunctionCall) (any, error) {
+ cfg := GetConfig(p)
+ out := make(map[string]any, len(cfg))
+ for k, v := range cfg {
+ out[k] = v
+ }
+ return out, nil
+ }))
+ _ = clv.Set("plugin", pl)
+
+ // clv.log.{info,warn,error}
+ logObj := rt.vm.NewObject()
+ for _, lv := range []string{"info", "warn", "error"} {
+ level := lv
+ _ = logObj.Set(level, rt.jsFn(func(call goja.FunctionCall) (any, error) {
+ args := argsOf(call)
+ parts := make([]string, 0, len(args))
+ for _, a := range args {
+ parts = append(parts, strOf(a))
+ }
+ msg := strings.Join(parts, " ")
+ util.Log(level, "[插件 %s] %s", p.SlugOf(), msg)
+ logPlugin(p.SlugOf(), "log."+level, msg, 0)
+ return nil, nil
+ }))
+ }
+ _ = clv.Set("log", logObj)
+
+ // clv.json.{encode,decode}
+ jsonObj := rt.vm.NewObject()
+ _ = jsonObj.Set("encode", rt.jsFn(func(call goja.FunctionCall) (any, error) {
+ args := argsOf(call)
+ if len(args) == 0 {
+ return "", nil
+ }
+ return jsonEncode(args[0])
+ }))
+ _ = jsonObj.Set("decode", rt.jsFn(func(call goja.FunctionCall) (any, error) {
+ args := argsOf(call)
+ if len(args) == 0 {
+ return nil, nil
+ }
+ return jsonDecode(strOf(args[0]))
+ }))
+ _ = clv.Set("json", jsonObj)
+
+ return nil
+}
+
+// pluginConfigValue 读取插件配置(未设置时回落到清单里的 default)
+func pluginConfigValue(p *Plugin, key string) string {
+ if key == "" {
+ return ""
+ }
+ if v := models.GetSetting(configKey(p.Name, key)); v != "" {
+ return v
+ }
+ for _, f := range p.Config {
+ if f.Key == key {
+ return f.Default
+ }
+ }
+ return ""
+}
+
+// ---------- 小工具 ----------
+
+// AppVersion 内核版本(config 包不依赖 plugin,直接引用是安全的)
+var AppVersion = config.Version
+
+func jsonEncode(v any) (string, error) {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+}
+
+func jsonDecode(s string) (any, error) {
+ var v any
+ if err := json.Unmarshal([]byte(s), &v); err != nil {
+ return nil, err
+ }
+ return v, nil
+}
diff --git a/internal/plugin/sandbox.go b/internal/plugin/sandbox.go
new file mode 100644
index 0000000..78ddfea
--- /dev/null
+++ b/internal/plugin/sandbox.go
@@ -0,0 +1,103 @@
+// 沙箱与治理:权限校验、审计日志、失败熔断。
+package plugin
+
+import (
+ "errors"
+ "strings"
+ "sync"
+ "time"
+
+ "clearlove/internal/database"
+ "clearlove/internal/models"
+ "clearlove/internal/util"
+)
+
+var errNoApp = errors.New("插件未加载")
+
+// permError 权限未声明错误
+type permError struct{ slug, perm string }
+
+func (e *permError) Error() string {
+ return "插件 " + e.slug + " 未声明权限 " + e.perm + ",请在 plugin.json 的 permissions 中声明后重试"
+}
+
+// requirePerm 运行时侧的权限校验入口(Host API 统一从这里进入)
+func (rt *jsRuntime) requirePerm(perm string) error {
+ if rt == nil || rt.app == nil {
+ return errNoApp
+ }
+ return rt.app.requirePerm(perm)
+}
+
+// requirePerm 校验插件是否声明了指定权限(能力白名单的唯一入口)
+func (a *App) requirePerm(perm string) error {
+ if a == nil || a.Plugin == nil {
+ return errNoApp
+ }
+ if !a.Plugin.HasPermission(perm) {
+ return &permError{slug: a.Plugin.SlugOf(), perm: perm}
+ }
+ return nil
+}
+
+// logPlugin 写插件运行日志(失败静默,不阻断插件逻辑)
+func logPlugin(slug, action, detail string, dur time.Duration) {
+ if database.DB == nil {
+ return
+ }
+ detail = strings.TrimSpace(detail)
+ if len(detail) > 2000 {
+ detail = detail[:2000]
+ }
+ _, _ = database.DB.Exec(
+ "INSERT INTO plugin_logs(plugin_slug,action,detail,duration_ms,created_at) VALUES(?,?,?,?,?)",
+ slug, action, detail, dur.Milliseconds(), models.Now())
+}
+
+// ---------- 失败熔断 ----------
+
+const failThreshold = 20 // 连续失败阈值,达到后自动禁用插件
+
+var (
+ failMu sync.Mutex
+ failCount = map[string]int{}
+)
+
+// noteFailure 记录一次插件失败;达到阈值自动禁用,避免拖垮站点
+func noteFailure(a *App, err error) {
+ if a == nil || a.Plugin == nil {
+ return
+ }
+ slug := a.Plugin.SlugOf()
+ failMu.Lock()
+ failCount[slug]++
+ n := failCount[slug]
+ failMu.Unlock()
+ util.Log("warn", "插件 %s 执行失败(%d/%d): %v", slug, n, failThreshold, err)
+ if n == failThreshold {
+ util.Log("error", "插件 %s 连续失败达到 %d 次,已自动禁用", slug, failThreshold)
+ logPlugin(slug, "disable", "连续失败自动禁用", 0)
+ SetEnabled(a.Plugin.Name, false)
+ UnloadApp(a.Dir)
+ failMu.Lock()
+ delete(failCount, slug)
+ failMu.Unlock()
+ }
+}
+
+// noteSuccess 重置失败计数
+func noteSuccess(a *App) {
+ if a == nil || a.Plugin == nil {
+ return
+ }
+ failMu.Lock()
+ delete(failCount, a.Plugin.SlugOf())
+ failMu.Unlock()
+}
+
+// FailCount 查询插件当前连续失败次数(后台展示用)
+func FailCount(slug string) int {
+ failMu.Lock()
+ defer failMu.Unlock()
+ return failCount[slug]
+}
diff --git a/internal/plugin/scheduler.go b/internal/plugin/scheduler.go
new file mode 100644
index 0000000..29c097d
--- /dev/null
+++ b/internal/plugin/scheduler.go
@@ -0,0 +1,88 @@
+// 应用型插件定时任务调度。
+//
+// 每个任务一条 goroutine + Ticker,加载时启动、卸载时停止;
+// 触发走沙箱(超时、权限、审计),失败计入熔断。
+package plugin
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "clearlove/internal/util"
+)
+
+var (
+ jobMu sync.Mutex
+ jobStops = map[string]chan struct{}{} // dir -> stop
+)
+
+// startJobs 为插件的全部任务启动调度
+func startJobs(a *App) {
+ if a == nil || len(a.Jobs) == 0 {
+ return
+ }
+ stopJobs(a.Dir)
+ stop := make(chan struct{})
+ jobMu.Lock()
+ jobStops[a.Dir] = stop
+ jobMu.Unlock()
+
+ for _, j := range a.Jobs {
+ j := j
+ go func() {
+ ticker := time.NewTicker(j.Every)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-stop:
+ return
+ case <-ticker.C:
+ runJob(a, j)
+ }
+ }
+ }()
+ }
+ util.Log("info", "插件 %s 已注册 %d 个定时任务", a.Plugin.SlugOf(), len(a.Jobs))
+}
+
+// stopJobs 停止插件的全部任务
+func stopJobs(dir string) {
+ jobMu.Lock()
+ stop, ok := jobStops[dir]
+ if ok {
+ delete(jobStops, dir)
+ }
+ jobMu.Unlock()
+ if ok {
+ close(stop)
+ }
+}
+
+func runJob(a *App, j JobReg) {
+ if a.Worker == nil || a.Worker.Closed() {
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), a.Plugin.Timeout())
+ defer cancel()
+ start := time.Now()
+ _, err := a.Worker.Do(ctx, func(rt *jsRuntime) (any, error) {
+ rt.cur = nil
+ _, err := rt.callFn(j.Fn)
+ return nil, err
+ })
+ if err != nil {
+ noteFailure(a, err)
+ logPlugin(a.Plugin.SlugOf(), "job:"+j.Name, "执行失败: "+err.Error(), time.Since(start))
+ return
+ }
+ noteSuccess(a)
+ logPlugin(a.Plugin.SlugOf(), "job:"+j.Name, "执行完成", time.Since(start))
+}
+
+// JobCount 当前注册的任务数(后台展示用)
+func JobCount() int {
+ jobMu.Lock()
+ defer jobMu.Unlock()
+ return len(jobStops)
+}
diff --git a/internal/plugin/view.go b/internal/plugin/view.go
new file mode 100644
index 0000000..1d57529
--- /dev/null
+++ b/internal/plugin/view.go
@@ -0,0 +1,32 @@
+// 视图能力反向注入。
+//
+// 依赖方向是 handlers -> plugin,因此 plugin 不能 import handlers。
+// 插件页面/模板渲染需要模板与会话能力,这里用接口由 handlers 在启动时注入。
+package plugin
+
+import (
+ "io/fs"
+ "net/http"
+)
+
+// ViewProvider 由 handlers 实现的视图能力
+type ViewProvider interface {
+ // RenderTemplate 渲染插件目录(fsys)下的模板片段
+ RenderTemplate(fsys fs.FS, name string, data any) (string, error)
+ // CSRF 当前请求的 CSRF 令牌(插件表单需要带上)
+ CSRF(r *http.Request) string
+ // UserID 当前登录用户 ID(未登录返回 0)
+ UserID(r *http.Request) int64
+ // AdminID 当前登录管理员 ID(未登录返回 0)
+ AdminID(r *http.Request) int64
+ // AdminPerm 当前管理员是否拥有指定权限
+ AdminPerm(r *http.Request, perm string) bool
+}
+
+var viewProvider ViewProvider
+
+// SetViewProvider 由 handlers 在模板初始化完成后调用
+func SetViewProvider(v ViewProvider) { viewProvider = v }
+
+// HasViewProvider 是否已完成装配(测试环境可能未装配)
+func HasViewProvider() bool { return viewProvider != nil }
diff --git a/internal/util/util.go b/internal/util/util.go
index 4fd7526..5ebf984 100644
--- a/internal/util/util.go
+++ b/internal/util/util.go
@@ -250,6 +250,19 @@ func SaveImage(fh *multipart.FileHeader) (string, error) {
return writeToUpload(webp, ".webp")
}
+// SaveImageBytes 保存内存中的图片数据(自动转 WebP),返回可访问 URL 路径。
+// 供插件运行时(clv.media.saveImage)等无法拿到 multipart.FileHeader 的场景使用。
+func SaveImageBytes(data []byte) (string, error) {
+ if len(data) > MaxImageSize {
+ return "", fmt.Errorf("单张图片不能超过 10MB")
+ }
+ webp, err := ImageToWebP(data)
+ if err != nil {
+ return "", err
+ }
+ return writeToUpload(webp, ".webp")
+}
+
// SaveVideo 保存上传视频(校验大小与扩展名),返回可访问 URL 路径
func SaveVideo(fh *multipart.FileHeader) (string, error) {
if fh.Size > MaxVideoSize {
@@ -267,6 +280,19 @@ func SaveVideo(fh *multipart.FileHeader) (string, error) {
return saveStream(f, ext)
}
+// SaveVideoBytes 保存内存中的视频数据(校验扩展名与大小),返回可访问 URL 路径。
+// 供插件运行时(clv.media.saveVideo)使用。
+func SaveVideoBytes(data []byte, filename string) (string, error) {
+ if len(data) > MaxVideoSize {
+ return "", fmt.Errorf("单个视频不能超过 70MB")
+ }
+ ext := strings.ToLower(filepath.Ext(filename))
+ if !videoExts[ext] {
+ return "", fmt.Errorf("不支持的视频格式: %s", ext)
+ }
+ return writeToUpload(data, ext)
+}
+
func saveStream(f multipart.File, ext string) (string, error) {
sub := time.Now().UTC().Format("20060102")
dir := filepath.Join(config.Cfg.UploadDir, sub)
diff --git a/main.go b/main.go
index b9ad50d..80db0b0 100644
--- a/main.go
+++ b/main.go
@@ -17,6 +17,7 @@ import (
"clearlove/internal/handlers"
"clearlove/internal/middleware"
"clearlove/internal/migrate"
+ "clearlove/internal/plugin"
)
// webFS 嵌入全部模板与静态资源,保证单文件部署
@@ -51,6 +52,10 @@ func main() {
pluginFS, _ := fs.Sub(webFS, "web/static")
handlers.StaticFS = pluginFS
+ plugin.Boot() // 加载应用型插件(kind=app):执行 setup() 并注册路由/事件/任务
+ // 插件可能带有 templates/ 覆盖内核模板片段,需在加载完成后重建模板集
+ handlers.RebuildTemplates()
+
go ai.StartPatrol() // AI 自动巡查(每10分钟,仅在开启时工作)
// 监听端口:PORT 或 CLEARLOVE_PORT,默认 16868
@@ -225,6 +230,9 @@ func buildMux() *http.ServeMux {
mux.HandleFunc("POST /admin/plugins/toggle", handlers.AdminPluginToggle)
mux.HandleFunc("POST /admin/plugins/delete", handlers.AdminPluginDelete)
mux.HandleFunc("POST /admin/plugins/config", handlers.AdminPluginConfig)
+ mux.HandleFunc("POST /admin/plugins/reload", handlers.AdminPluginReload)
+ mux.HandleFunc("GET /admin/plugin-logs", handlers.AdminPluginLogs)
+ mux.HandleFunc("POST /admin/plugin-logs/clear", handlers.AdminPluginLogsClear)
// 旧版数据迁移(上传 data.db 或 MySQL 导出文件)
mux.HandleFunc("GET /admin/migrate", handlers.AdminMigrate)
mux.HandleFunc("POST /admin/migrate/start", handlers.AdminMigrateStart)
@@ -237,6 +245,11 @@ func buildMux() *http.ServeMux {
mux.HandleFunc("POST /admin/apikeys/delete", handlers.AdminAPIKeyDelete)
mux.HandleFunc("GET /admin/about", handlers.AdminAbout)
mux.HandleFunc("GET /admin/update", handlers.AdminUpdate)
+ mux.HandleFunc("POST /admin/update/apply", handlers.AdminUpdateApply)
+
+ // 应用型插件(B 型)分发:前台 /x/<slug>/... 、后台 /admin/plugins/<slug>/...
+ mux.Handle("/x/", plugin.AppHandler())
+ mux.Handle("/admin/plugins/", plugin.AdminAppHandler())
return mux
}
diff --git a/main_test.go b/main_test.go
new file mode 100644
index 0000000..21ea8f9
--- /dev/null
+++ b/main_test.go
@@ -0,0 +1,417 @@
+package main
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "clearlove/internal/config"
+ "clearlove/internal/database"
+ "clearlove/internal/handlers"
+ "clearlove/internal/middleware"
+ "clearlove/internal/models"
+ "clearlove/internal/plugin"
+)
+
+// TestAppPluginHTTPIntegration 端到端验证应用型插件:
+// 完整路由链(中间件 + 插件分发 + 鉴权)与站点模板渲染。
+func TestAppPluginHTTPIntegration(t *testing.T) {
+ root := t.TempDir()
+ dataDir := filepath.Join(root, "data")
+ uploadDir := filepath.Join(root, "uploads")
+ if err := os.MkdirAll(dataDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ config.Cfg = config.Config{
+ DataDir: dataDir,
+ UploadDir: uploadDir,
+ DBType: "sqlite",
+ SQLitePath: filepath.Join(dataDir, "clearlove.db"),
+ Installed: true, // 跳过安装门禁
+ Secret: "integration-secret",
+ Version: config.Version,
+ }
+ if err := database.Connect(); err != nil {
+ t.Fatalf("数据库连接失败: %v", err)
+ }
+ t.Cleanup(func() {
+ if database.DB != nil {
+ _ = database.DB.Close()
+ database.DB = nil
+ }
+ })
+ if err := database.Migrate(); err != nil {
+ t.Fatalf("建表失败: %v", err)
+ }
+ _ = models.SetSetting("site_name", "集成测试站")
+
+ // 装载模板(同时完成插件视图能力注入)
+ handlers.SetupTemplates(webFS)
+
+ // 准备应用型插件
+ pluginDir := filepath.Join(dataDir, "plugins", "demo")
+ if err := os.MkdirAll(pluginDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ writeFile(t, filepath.Join(pluginDir, "plugin.json"), `{
+ "kind": "app", "name": "demo", "slug": "demo", "version": "1.0.0",
+ "runtime": { "engine": "js", "entry": "main.js", "timeout_ms": 800 },
+ "permissions": ["db", "site.read"]
+ }`)
+ writeFile(t, filepath.Join(pluginDir, "main.js"), `
+ function setup() {
+ clv.table("hit", { path: "string", created_at: "time" });
+ clv.route("GET", "/open", "openPage", { auth: "none" });
+ clv.route("GET", "/json", "jsonPage", { auth: "none", json: true });
+ clv.route("GET", "/me", "mePage", { auth: "user" });
+ clv.adminMenu({ label: "演示管理", path: "/", perm: "plugins" });
+ clv.adminPage("/", "adminHome", { perm: "plugins" });
+ clv.slot("footer_html", "foot");
+ clv.middleware("http.before", "guard");
+ }
+ function openPage(ctx) { return { body: "<h1>plugin-open</h1>" }; }
+ function jsonPage(ctx) { return { json: { ok: true, dialect: clv.db.dialect() } }; }
+ function mePage(ctx) { return { json: { uid: ctx.userId } }; }
+ function adminHome(ctx) { return { body: "<h1>plugin-admin</h1>" }; }
+ function foot(data) { return "<span id='pf'>plugin-footer</span>"; }
+ function guard(req) {
+ if (req.path === "/guard-me") return { abort: true, status: 403, body: "blocked" };
+ return null;
+ }
+ `)
+ plugin.SetEnabled("demo", true)
+ if err := plugin.LoadApp("demo"); err != nil {
+ t.Fatalf("加载应用型插件失败: %v", err)
+ }
+ t.Cleanup(func() {
+ plugin.UnloadApp("demo")
+ plugin.SetEnabled("demo", false)
+ })
+
+ srv := middleware.Use(buildMux())
+ do := func(method, path string) *httptest.ResponseRecorder {
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, httptest.NewRequest(method, path, nil))
+ return w
+ }
+
+ // 1) 插件公开路由(HTML)
+ if w := do(http.MethodGet, "/x/demo/open"); w.Code != 200 || !strings.Contains(w.Body.String(), "plugin-open") {
+ t.Fatalf("公开路由异常: %d %q", w.Code, w.Body.String())
+ }
+
+ // 2) 插件 JSON 路由
+ w := do(http.MethodGet, "/x/demo/json")
+ if w.Code != 200 {
+ t.Fatalf("JSON 路由异常: %d", w.Code)
+ }
+ var payload map[string]any
+ if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
+ t.Fatalf("JSON 解析失败: %v (%q)", err, w.Body.String())
+ }
+ if payload["dialect"] != "sqlite" {
+ t.Fatalf("dialect 异常: %v", payload["dialect"])
+ }
+
+ // 3) 需要登录的插件路由:未登录跳转
+ if w := do(http.MethodGet, "/x/demo/me"); w.Code != http.StatusFound {
+ t.Fatalf("未登录应跳转,实际 %d", w.Code)
+ }
+
+ // 4) 插件中间件短路
+ if w := do(http.MethodGet, "/guard-me"); w.Code != http.StatusForbidden {
+ t.Fatalf("中间件未短路: %d", w.Code)
+ }
+
+ // 5) 站点首页正常渲染,并包含插件 footer slot 输出
+ w = do(http.MethodGet, "/")
+ if w.Code != 200 {
+ t.Fatalf("首页异常: %d", w.Code)
+ }
+ if !strings.Contains(w.Body.String(), "plugin-footer") {
+ t.Fatalf("footer slot 未注入首页")
+ }
+
+ // 6) 后台页面未登录跳转登录页
+ if w := do(http.MethodGet, "/admin/plugins/demo/"); w.Code != http.StatusFound {
+ t.Fatalf("后台页面未拦截: %d", w.Code)
+ }
+}
+
+func writeFile(t *testing.T, path, content string) {
+ t.Helper()
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// copyDir 递归复制目录(测试里把仓库示例插件装进临时站点)
+func copyDir(t *testing.T, src, dst string) {
+ t.Helper()
+ entries, err := os.ReadDir(src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(dst, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range entries {
+ s := filepath.Join(src, e.Name())
+ d := filepath.Join(dst, e.Name())
+ if e.IsDir() {
+ copyDir(t, s, d)
+ continue
+ }
+ b, err := os.ReadFile(s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(d, b, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+// TestAIPolishPlugin 端到端验证「AI 语句美化」应用型插件:
+// 静态 hook 注入、可用性探测、调用本机 AI 服务、长度限制。
+func TestAIPolishPlugin(t *testing.T) {
+ dataDir := setupSite(t)
+ copyDir(t, filepath.Join("plugins", "ai-polish"), filepath.Join(dataDir, "plugins", "ai-polish"))
+
+ plugin.SetEnabled("AI 语句美化", true)
+ if err := plugin.LoadApp("ai-polish"); err != nil {
+ t.Fatalf("加载 AI 美化插件失败: %v", err)
+ }
+ defer func() {
+ plugin.UnloadApp("ai-polish")
+ plugin.SetEnabled("AI 语句美化", false)
+ }()
+
+ // 1) 声明式 hook 静态注入按钮脚本(不占用运行时)
+ if got := plugin.CallHTML("footer_html"); !strings.Contains(got, "/x/ai-polish") {
+ t.Fatalf("未注入美化脚本: %q", got)
+ }
+
+ srv := middleware.Use(buildMux())
+ do := func(method, path, body, csrf string) *httptest.ResponseRecorder {
+ var rd io.Reader
+ if body != "" {
+ rd = strings.NewReader(body)
+ }
+ req := httptest.NewRequest(method, path, rd)
+ if body != "" {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if csrf != "" {
+ req.Header.Set("X-CSRF-Token", csrf)
+ req.AddCookie(&http.Cookie{Name: "clv_csrf", Value: csrf})
+ }
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+ return w
+ }
+
+ w0 := do(http.MethodGet, "/", "", "")
+ csrf := ""
+ for _, c := range w0.Result().Cookies() {
+ if c.Name == "clv_csrf" {
+ csrf = c.Value
+ }
+ }
+ if csrf == "" {
+ t.Fatal("未取到 CSRF 令牌")
+ }
+
+ // 2) 未接入 AI:config 标记不可用,polish 明确拒绝
+ if w := do(http.MethodGet, "/x/ai-polish/config", "", ""); !strings.Contains(w.Body.String(), `"available":false`) {
+ t.Fatalf("未接入 AI 时可用性判断异常: %s", w.Body.String())
+ }
+ if w := do(http.MethodPost, "/x/ai-polish/polish", `{"text":"你好呀"}`, csrf); w.Code != 400 {
+ t.Fatalf("未接入 AI 应返回 400,实际 %d: %s", w.Code, w.Body.String())
+ }
+
+ // 3) 配置本机 AI 服务(模拟 OpenAI 兼容接口,验证 net.local 权限放行内网)
+ ai := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/chat/completions" {
+ http.NotFound(rw, r)
+ return
+ }
+ if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
+ rw.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ rw.Header().Set("Content-Type", "application/json")
+ _, _ = rw.Write([]byte(`{"choices":[{"message":{"content":"\"今天天气很好,适合出门走走。\""}}]}`))
+ }))
+ defer ai.Close()
+
+ _ = models.SetSetting("ai_enabled", "1")
+ _ = models.SetSetting("ai_base", ai.URL)
+ _ = models.SetSetting("ai_key", "test-key")
+ _ = models.SetSetting("ai_model", "test-model")
+
+ if w := do(http.MethodGet, "/x/ai-polish/config", "", ""); !strings.Contains(w.Body.String(), `"available":true`) {
+ t.Fatalf("接入 AI 后可用性判断异常: %s", w.Body.String())
+ }
+
+ // 4) 美化成功,并清理模型输出的引号包裹
+ w := do(http.MethodPost, "/x/ai-polish/polish", `{"text":"今天天气不错"}`, csrf)
+ if w.Code != 200 {
+ t.Fatalf("美化失败: %d %s", w.Code, w.Body.String())
+ }
+ body := w.Body.String()
+ if !strings.Contains(body, "今天天气很好,适合出门走走。") {
+ t.Fatalf("返回内容异常: %s", body)
+ }
+ if strings.Contains(body, `\"今天天气很好`) {
+ t.Fatalf("未清理引号包裹: %s", body)
+ }
+
+ // 5) 超出单次处理上限被拒绝
+ long := strings.Repeat("啊", 700)
+ if w := do(http.MethodPost, "/x/ai-polish/polish", `{"text":"`+long+`"}`, csrf); w.Code != 400 {
+ t.Fatalf("超长内容应被拒绝,实际 %d", w.Code)
+ }
+}
+
+// setupSite 初始化一次完整的站点环境(数据库 + 模板)
+func setupSite(t *testing.T) (dataDir string) {
+ t.Helper()
+ root := t.TempDir()
+ dataDir = filepath.Join(root, "data")
+ if err := os.MkdirAll(dataDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ config.Cfg = config.Config{
+ DataDir: dataDir,
+ UploadDir: filepath.Join(root, "uploads"),
+ DBType: "sqlite",
+ SQLitePath: filepath.Join(dataDir, "clearlove.db"),
+ Installed: true,
+ Secret: "integration-secret",
+ Version: config.Version,
+ }
+ if err := database.Connect(); err != nil {
+ t.Fatalf("数据库连接失败: %v", err)
+ }
+ t.Cleanup(func() {
+ if database.DB != nil {
+ _ = database.DB.Close()
+ database.DB = nil
+ }
+ })
+ if err := database.Migrate(); err != nil {
+ t.Fatalf("建表失败: %v", err)
+ }
+ _ = models.SetSetting("site_name", "集成测试站")
+ handlers.SetupTemplates(webFS)
+ return dataDir
+}
+
+// TestPluginFiltersTemplateAndLogs 覆盖新增能力:
+// 过滤器(card / api.response / post.visible)、http.after、模板覆盖与运行日志。
+func TestPluginFiltersTemplateAndLogs(t *testing.T) {
+ dataDir := setupSite(t)
+
+ pluginDir := filepath.Join(dataDir, "plugins", "fx")
+ if err := os.MkdirAll(filepath.Join(pluginDir, "templates"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ writeFile(t, filepath.Join(pluginDir, "plugin.json"), `{
+ "kind": "app", "name": "fx", "slug": "fx", "version": "1.0.0",
+ "runtime": { "engine": "js", "entry": "main.js", "timeout_ms": 800 },
+ "permissions": ["db", "cache"]
+ }`)
+ writeFile(t, filepath.Join(pluginDir, "main.js"), `
+ function setup() {
+ clv.filter("filter.card", 10, "cardFilter");
+ clv.filter("filter.api.response", 10, "respFilter");
+ clv.filter("filter.post.visible", 10, "visibleFilter");
+ clv.middleware("http.after", "afterLog");
+ }
+ function cardFilter(card) { card.nickname = "改写:" + card.nickname; return card; }
+ function respFilter(resp) { resp.plugin_tag = "fx"; return resp; }
+ function visibleFilter(ok, ctx) { return (ctx && ctx.post_id === 999) ? false : ok; }
+ function afterLog(info) { clv.cache.set("last_status", info.status, 60); }
+ function __status() { return clv.cache.get("last_status") || 0; }
+ `)
+ // 模板覆盖:插件目录下的同名模板会覆盖内核片段
+ writeFile(t, filepath.Join(pluginDir, "templates", "pg_index.html"),
+ `{{define "pg_index"}}<div id="plugin-override">OVERRIDE</div>{{end}}`)
+
+ plugin.SetEnabled("fx", true)
+ if err := plugin.LoadApp("fx"); err != nil {
+ t.Fatalf("加载失败: %v", err)
+ }
+ defer func() {
+ plugin.UnloadApp("fx")
+ plugin.SetEnabled("fx", false)
+ handlers.ReloadTemplates()
+ }()
+ handlers.ReloadTemplates() // 应用插件模板覆盖
+
+ // 准备一条帖子(含一条 id=999 用于可见性过滤)
+ if _, err := database.DB.Exec(
+ `INSERT INTO posts(id,nickname,content,topic_id,ip,fingerprint,status,is_admin,badges,created_at)
+ VALUES(1,'原昵称','正文',0,'','',1,0,'',?)`, models.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ srv := middleware.Use(buildMux())
+ do := func(method, path string) *httptest.ResponseRecorder {
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, httptest.NewRequest(method, path, nil))
+ return w
+ }
+
+ // 1) 模板覆盖生效
+ if w := do(http.MethodGet, "/"); !strings.Contains(w.Body.String(), "plugin-override") {
+ t.Fatalf("模板覆盖未生效: %q", w.Body.String())
+ }
+
+ // 2) filter.api.response 改写响应体
+ if w := do(http.MethodGet, "/api/v1/topics"); !strings.Contains(w.Body.String(), "plugin_tag") {
+ t.Fatalf("filter.api.response 未生效: %q", w.Body.String())
+ }
+
+ // 3) filter.card 改写卡片数据
+ w := do(http.MethodGet, "/api/v1/posts?limit=5")
+ if !strings.Contains(w.Body.String(), "改写:原昵称") {
+ t.Fatalf("filter.card 未生效: %q", w.Body.String())
+ }
+
+ // 4) filter.post.visible 否决可见性
+ if _, err := database.DB.Exec(
+ `INSERT INTO posts(id,nickname,content,topic_id,ip,fingerprint,status,is_admin,badges,created_at)
+ VALUES(999,'x','y',0,'','',1,0,'',?)`, models.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if w := do(http.MethodGet, "/post/999"); w.Code != http.StatusNotFound {
+ t.Fatalf("filter.post.visible 未生效: %d", w.Code)
+ }
+
+ // 5) http.after 被调用(状态码写入插件缓存)
+ out, err := plugin.CallFn("fx", "__status")
+ if err != nil {
+ t.Fatalf("脚本调用失败: %v", err)
+ }
+ if n, ok := out.(int64); !ok || n == 0 {
+ t.Fatalf("http.after 未执行: %v", out)
+ }
+
+ // 6) 运行日志可读
+ logs := plugin.PluginLogs("fx", 50)
+ if len(logs) == 0 {
+ t.Fatal("插件运行日志为空")
+ }
+}
+
+// TestAppPluginHTTPIntegration 端到端验证应用型插件:
+// 完整路由链(中间件 + 插件分发 + 鉴权)与站点模板渲染。
+
diff --git a/plugins/ai-polish/main.js b/plugins/ai-polish/main.js
new file mode 100644
index 0000000..840b091
--- /dev/null
+++ b/plugins/ai-polish/main.js
@@ -0,0 +1,133 @@
+// AI 语句美化 —— 应用型插件(kind=app)
+//
+// 组合了两种能力,兼顾性能与功能:
+// 1. 声明式 hook(footer_html):静态注入按钮脚本,页面渲染零额外开销
+// 2. 应用型路由:GET /x/ai-polish/config 探测可用性;POST /x/ai-polish/polish 调用 AI 润色
+//
+// 复用站点「网站设置 → AI 模型接入」里已配置的接口,无需重复填写密钥。
+
+function setup() {
+ clv.route("GET", "/config", "readConfig", { auth: "none", json: true });
+ clv.route("POST", "/polish", "polish", { auth: "none", json: true });
+}
+
+/* ---------------- 配置与可用性 ---------------- */
+
+function aiReady() {
+ if (clv.setting.get("ai_enabled") !== "1") return false;
+ var base = clv.setting.get("ai_base");
+ var key = clv.setting.get("ai_key");
+ var model = clv.setting.get("ai_model");
+ return !!(base && key && model);
+}
+
+// readConfig 前端加载时探测一次:不可用时直接不显示按钮
+function readConfig(ctx) {
+ var requireLogin = clv.plugin.config("require_login") === "1";
+ var loggedIn = !!ctx.userId;
+ var ready = aiReady();
+ return {
+ json: {
+ ok: true,
+ available: ready && (!requireLogin || loggedIn),
+ label: clv.plugin.config("button_text") || "AI 美化",
+ max_chars: intOr(clv.plugin.config("max_chars"), 600),
+ reason: ready ? (requireLogin && !loggedIn ? "登录后可使用 AI 美化" : "") : "站点尚未接入 AI 模型"
+ }
+ };
+}
+
+/* ---------------- 核心:调用 AI 润色 ---------------- */
+
+function polish(ctx) {
+ if (!aiReady()) {
+ return { status: 400, json: { ok: false, msg: "站点尚未开启 AI 模型,请联系管理员在「网站设置 → AI 模型接入」中配置" } };
+ }
+ if (clv.plugin.config("require_login") === "1" && !ctx.userId) {
+ return { status: 401, json: { ok: false, msg: "请先登录后再使用 AI 美化" } };
+ }
+
+ var text = String((ctx.req && ctx.req.json && ctx.req.json.text) || "").trim();
+ var maxChars = intOr(clv.plugin.config("max_chars"), 600);
+ if (text.length < 2) {
+ return { status: 400, json: { ok: false, msg: "内容太短,无需美化" } };
+ }
+ if (text.length > maxChars) {
+ return { status: 400, json: { ok: false, msg: "内容超过 " + maxChars + " 字,请分段美化" } };
+ }
+
+ // 按访客 IP 限流(每分钟)
+ var limit = intOr(clv.plugin.config("rate_limit"), 3);
+ var rk = "rate:" + ((ctx.req && ctx.req.ip) || "unknown");
+ var used = Number(clv.cache.get(rk)) || 0;
+ if (used >= limit) {
+ return { status: 429, json: { ok: false, msg: "操作过于频繁,请稍后再试" } };
+ }
+ clv.cache.set(rk, used + 1, 60);
+
+ var base = String(clv.setting.get("ai_base") || "").replace(/\/+$/, "");
+ var key = clv.setting.get("ai_key") || "";
+ var model = clv.setting.get("ai_model") || "";
+ var temp = parseFloat(clv.plugin.config("temperature"));
+ if (isNaN(temp) || temp < 0 || temp > 1) temp = 0.7;
+ var prompt = clv.plugin.config("prompt") ||
+ "请在不改变原意的前提下润色下面的文字,只输出润色后的正文。";
+
+ var payload = JSON.stringify({
+ model: model,
+ messages: [
+ { role: "system", content: prompt },
+ { role: "user", content: text }
+ ],
+ temperature: temp
+ });
+
+ var resp;
+ try {
+ resp = clv.http.request({
+ method: "POST",
+ url: base + "/chat/completions",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": "Bearer " + key
+ },
+ body: payload,
+ timeout_ms: 25000,
+ max_bytes: 262144
+ });
+ } catch (e) {
+ clv.log.warn("AI 请求失败:" + e);
+ return { status: 502, json: { ok: false, msg: "AI 服务连接失败,请稍后重试" } };
+ }
+
+ if (!resp || resp.status < 200 || resp.status >= 300) {
+ var code = resp ? resp.status : "无响应";
+ clv.log.warn("AI 返回异常状态:" + code);
+ return { status: 502, json: { ok: false, msg: "AI 服务返回异常(" + code + ")" } };
+ }
+
+ var data = null;
+ try { data = JSON.parse(resp.body); } catch (e) { data = null; }
+ var out = "";
+ if (data && data.choices && data.choices.length && data.choices[0].message) {
+ out = String(data.choices[0].message.content || "").trim();
+ }
+ // 去掉模型有时会加上的引号包裹
+ out = out.replace(/^["'“”‘’\s]+|["'“”‘’\s]+$/g, "").trim();
+ if (!out) {
+ return { status: 502, json: { ok: false, msg: "AI 未返回有效内容,请稍后重试" } };
+ }
+ // 防止模型输出失控(例如复述要求、长篇解释)
+ var cap = maxChars * 3;
+ if (out.length > cap) out = out.slice(0, cap) + "……";
+
+ clv.log.info("AI 美化完成:" + text.length + " 字 → " + out.length + " 字(用户 " + (ctx.userId || "匿名") + ")");
+ return { json: { ok: true, text: out } };
+}
+
+/* ---------------- 小工具 ---------------- */
+
+function intOr(v, def) {
+ var n = parseInt(v, 10);
+ return (isNaN(n) || n <= 0) ? def : n;
+}
diff --git a/plugins/ai-polish/plugin.json b/plugins/ai-polish/plugin.json
new file mode 100644
index 0000000..f718b3b
--- /dev/null
+++ b/plugins/ai-polish/plugin.json
@@ -0,0 +1,62 @@
+{
+ "kind": "app",
+ "name": "AI 语句美化",
+ "slug": "ai-polish",
+ "version": "1.0.0",
+ "author": "ClearLove",
+ "description": "在发帖页提供「AI 美化」按钮:调用站点已接入的 AI 模型润色帖子内容,支持提示词、限流、登录门槛等配置",
+ "requires": "2.1.0",
+ "runtime": {
+ "engine": "js",
+ "entry": "main.js",
+ "timeout_ms": 28000
+ },
+ "permissions": ["settings.read", "net", "net.local", "cache"],
+ "config": [
+ {
+ "key": "prompt",
+ "label": "美化提示词",
+ "type": "textarea",
+ "default": "请在不改变原意、不增删事实的前提下,把下面这段文字润色得更通顺、真诚、有文采。只输出润色后的正文,不要任何解释、前缀或引号。",
+ "help": "系统提示词,决定美化的风格与尺度"
+ },
+ {
+ "key": "button_text",
+ "label": "按钮文案",
+ "type": "text",
+ "default": "AI 美化",
+ "help": "发帖页按钮上显示的文字"
+ },
+ {
+ "key": "max_chars",
+ "label": "单次处理上限(字)",
+ "type": "number",
+ "default": "600",
+ "help": "超过该长度的内容会被拒绝,避免一次消耗过多额度"
+ },
+ {
+ "key": "rate_limit",
+ "label": "每 IP 每分钟次数",
+ "type": "number",
+ "default": "3",
+ "help": "按访客 IP 限流,防止接口被刷"
+ },
+ {
+ "key": "temperature",
+ "label": "创造度(0-1)",
+ "type": "number",
+ "default": "0.7",
+ "help": "越高越有文采,越低越贴近原文"
+ },
+ {
+ "key": "require_login",
+ "label": "仅登录用户可用",
+ "type": "switch",
+ "default": "0",
+ "help": "开启后未登录访客不会看到美化按钮"
+ }
+ ],
+ "hooks": [
+ { "hook": "footer_html", "type": "html", "html": "@polish.html" }
+ ]
+}
diff --git a/plugins/ai-polish/polish.html b/plugins/ai-polish/polish.html
new file mode 100644
index 0000000..c13f931
--- /dev/null
+++ b/plugins/ai-polish/polish.html
@@ -0,0 +1,126 @@
+<!-- AI 语句美化(应用型插件 ai-polish 静态注入;仅在发帖页生效) -->
+<script>
+(function () {
+ var form = document.getElementById('compose-form');
+ if (!form) return;
+ var ta = form.querySelector('textarea[name="content"]');
+ if (!ta) return;
+
+ var API = '/x/ai-polish';
+ var state = { label: 'AI 美化', maxChars: 600, busy: false, lastText: '' };
+
+ // 探测插件是否可用(未接入 AI / 需登录时按钮不出现)
+ fetch(API + '/config', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
+ .then(function (r) { return r.json(); })
+ .then(function (cfg) {
+ if (!cfg || !cfg.ok || !cfg.available) return;
+ state.label = cfg.label || state.label;
+ state.maxChars = cfg.max_chars || state.maxChars;
+ build();
+ })
+ .catch(function () { });
+
+ function build() {
+ var bar = document.createElement('div');
+ bar.className = 'ai-polish-bar';
+ bar.style.cssText = 'display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:8px';
+
+ var btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'btn btn-ghost ai-polish-btn';
+ btn.style.cssText = 'display:inline-flex;align-items:center;gap:6px';
+ var icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ icon.setAttribute('viewBox', '0 0 24 24');
+ icon.setAttribute('width', '16');
+ icon.setAttribute('height', '16');
+ icon.setAttribute('fill', 'none');
+ icon.setAttribute('stroke', 'currentColor');
+ icon.setAttribute('stroke-width', '2');
+ var p1 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+ p1.setAttribute('d', 'M12 3l1.8 4.6L18.5 9l-4.7 1.4L12 15l-1.8-4.6L5.5 9l4.7-1.4z');
+ var p2 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+ p2.setAttribute('d', 'M18.5 15.5l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z');
+ icon.appendChild(p1);
+ icon.appendChild(p2);
+ var label = document.createElement('span');
+ label.textContent = state.label;
+ btn.appendChild(icon);
+ btn.appendChild(label);
+
+ var tip = document.createElement('span');
+ tip.style.cssText = 'font-size:12px';
+
+ var undo = document.createElement('button');
+ undo.type = 'button';
+ undo.className = 'mini-btn';
+ undo.textContent = '撤销';
+ undo.hidden = true;
+
+ bar.appendChild(btn);
+ bar.appendChild(tip);
+ bar.appendChild(undo);
+ // 插到「内容」这一整块(label)之后独立成行;若直接插在 textarea 后会落在 label 内受其排版影响
+ var anchor = ta.closest('label') || ta;
+ anchor.parentNode.insertBefore(bar, anchor.nextSibling);
+
+ btn.addEventListener('click', function () { run(btn, label, tip, undo); });
+ undo.addEventListener('click', function () {
+ ta.value = state.lastText;
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
+ undo.hidden = true;
+ say(tip, '已恢复原文', false);
+ });
+ }
+
+ function run(btn, label, tip, undo) {
+ if (state.busy) return;
+ var text = (ta.value || '').trim();
+ if (text.length < 2) { say(tip, '请先输入内容', true); return; }
+ if (text.length > state.maxChars) { say(tip, '内容超过 ' + state.maxChars + ' 字,请分段美化', true); return; }
+
+ state.busy = true;
+ btn.disabled = true;
+ label.textContent = '美化中…';
+ tip.textContent = '';
+ undo.hidden = true;
+
+ request('POST', API + '/polish', { text: text })
+ .then(function (r) { return r.json(); })
+ .then(function (res) {
+ if (!res || !res.ok) { say(tip, (res && res.msg) || '美化失败,请稍后再试', true); return; }
+ state.lastText = ta.value;
+ ta.value = res.text;
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
+ undo.hidden = false;
+ say(tip, '已完成美化', false);
+ })
+ .catch(function () { say(tip, '网络异常,请稍后再试', true); })
+ .then(function () {
+ state.busy = false;
+ btn.disabled = false;
+ label.textContent = state.label;
+ });
+ }
+
+ // 不依赖 app.js 的加载时机:直接带 CSRF 头请求
+ function request(method, url, body) {
+ var csrf = (document.querySelector('meta[name="csrf"]') || {}).content || '';
+ var opts = {
+ method: method,
+ headers: { 'X-CSRF-Token': csrf, 'X-Requested-With': 'XMLHttpRequest' }
+ };
+ if (body !== undefined) {
+ opts.headers['Content-Type'] = 'application/json';
+ opts.body = JSON.stringify(body);
+ }
+ return fetch(url, opts);
+ }
+
+ function say(el, msg, isErr) {
+ el.textContent = msg;
+ el.style.color = isErr ? 'var(--clv-danger, #d9534f)' : 'var(--clv-muted, #8a8f9c)';
+ clearTimeout(el._t);
+ el._t = setTimeout(function () { el.textContent = ''; }, 4000);
+ }
+})();
+</script>
diff --git a/plugins/signin/admin.html b/plugins/signin/admin.html
new file mode 100644
index 0000000..a01b1b9
--- /dev/null
+++ b/plugins/signin/admin.html
@@ -0,0 +1,22 @@
+<h1 class="page-title">签到管理</h1>
+
+<div class="stat-grid">
+ <div class="stat card"><span class="stat-num">{{.total.n}}</span><span class="stat-label">累计签到次数</span></div>
+ <div class="stat card"><span class="stat-num">{{.total.p}}</span><span class="stat-label">累计发放积分</span></div>
+</div>
+
+<div class="card table-card">
+ <h3>最近 30 天</h3>
+ <div class="table-scroll">
+ <table class="table">
+ <thead><tr><th>日期</th><th>签到人数</th></tr></thead>
+ <tbody>
+ {{range .days}}
+ <tr><td>{{.day}}</td><td>{{.n}}</td></tr>
+ {{else}}
+ <tr><td colspan="2" class="empty">暂无数据</td></tr>
+ {{end}}
+ </tbody>
+ </table>
+ </div>
+</div>
diff --git a/plugins/signin/main.js b/plugins/signin/main.js
new file mode 100644
index 0000000..91c1836
--- /dev/null
+++ b/plugins/signin/main.js
@@ -0,0 +1,137 @@
+// 每日签到 —— 应用型插件(kind=app)示例
+//
+// 演示能力:
+// 1. 自定义数据表 clv.table
+// 2. 前台路由与页面 clv.route + clv.view.template
+// 3. 后台页面与菜单 clv.adminPage + clv.adminMenu
+// 4. UI 注入 clv.slot
+// 5. 定时任务 clv.job
+// 6. 事件广播 clv.emit
+//
+// 注意:本插件没有 hooks 字段 —— 全部能力通过 setup() 中的运行时注册获得。
+//
+// 应用型插件可用的其它能力(本示例未使用,供参考):
+// clv.on("post.created", fn) 订阅站点事件(发帖/评论/点赞/注册/登录…)
+// clv.filter("filter.content", 10, fn) 链式改写内容(另见 filter.card / filter.api.response
+// / filter.post.visible / filter.theme.css /
+// filter.auth.login / filter.compose.guard /
+// filter.admin.stats / filter.settings.save)
+// clv.middleware("http.after", fn) 请求结束后观察状态码与耗时(http.before 可改写或短路)
+// clv.http.request({...}) 出网请求(需 net 权限,内网地址被拦截)
+// clv.mail.send(to, subject, body) 发邮件(需 mail 权限)
+// clv.media.saveImage/saveVideo({name,data}) 保存 base64 媒体(需 media.write)
+// clv.crypto.* clv.cache.* clv.post.* clv.comment.* clv.user.* clv.stats.*
+// 插件目录下放 templates/<内核模板名>.html 可覆盖站点模板片段
+
+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 '<div class="card clv-signin-days" style="margin-top:12px;font-size:13px;opacity:.85">' +
+ clv.view.escape(post.nickname || "该用户") + " 已累计签到 " + n + " 天</div>";
+}
+
+// 前台悬浮入口
+function floatingEntry() {
+ return '<a class="clv-signin-fab" href="/x/signin/" title="每日签到">' +
+ '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2">' +
+ '<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/><path d="M9 12l2 2 4-4"/></svg></a>' +
+ '<style>.clv-signin-fab{position:fixed;left:18px;bottom:22px;z-index:900;width:46px;height:46px;' +
+ 'border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;' +
+ 'background:linear-gradient(135deg,var(--clv-accent,#ff7a9c),var(--clv-accent-2,#8a6cff));' +
+ 'box-shadow:0 8px 22px rgba(0,0,0,.22)}</style>';
+}
+
+/* ---------------- 后台 ---------------- */
+
+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) + " 条");
+}
diff --git a/plugins/signin/page.html b/plugins/signin/page.html
new file mode 100644
index 0000000..dc613c5
--- /dev/null
+++ b/plugins/signin/page.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>每日签到</title>
+<link rel="icon" href="/favicon.svg" type="image/svg+xml">
+<link rel="stylesheet" href="/static/app.css">
+<link rel="stylesheet" href="/theme.css">
+</head>
+<body>
+<main class="container">
+ <h1 class="page-title">每日签到</h1>
+ <div class="card">
+ <p>累计签到 <strong>{{.days}}</strong> 天 · 累计积分 <strong>{{.points}}</strong></p>
+ {{if .done}}
+ <p class="muted">今天已经签到过了,明天再来~</p>
+ {{else}}
+ <form method="post" action="/x/signin/do">
+ <input type="hidden" name="_csrf" value="{{.csrf}}">
+ <button class="btn btn-primary" type="submit">立即签到(+{{.gain}})</button>
+ </form>
+ {{end}}
+ <p style="margin-top:14px">
+ <a href="/x/signin/rank">查看签到榜</a> ·
+ <a href="/">返回首页</a>
+ </p>
+ </div>
+</main>
+</body>
+</html>
diff --git a/plugins/signin/plugin.json b/plugins/signin/plugin.json
new file mode 100644
index 0000000..85aa36f
--- /dev/null
+++ b/plugins/signin/plugin.json
@@ -0,0 +1,31 @@
+{
+ "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": "签到一次获得的积分数量"
+ },
+ {
+ "key": "card_badge",
+ "label": "卡片标识文案",
+ "type": "text",
+ "default": "已签到",
+ "help": "前台帖子卡片上显示的标识文字"
+ }
+ ]
+}
diff --git a/web/static/app.css b/web/static/app.css
index 3c5fc4c..6cf9208 100644
--- a/web/static/app.css
+++ b/web/static/app.css
@@ -291,8 +291,19 @@ textarea { resize: vertical; }
color: #fff;
}
-/* ---------------- 插件设置表单(后台) ---------------- */
-.plugin-config h3 { margin-bottom: 6px; }
+/* ---------------- 插件设置表单(后台,默认折叠,点击展开) ---------------- */
+.plugin-config { padding: 0; overflow: hidden; }
+.plugin-config-head {
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
+ padding: 14px; margin: 0; cursor: pointer; user-select: none; list-style: none;
+}
+.plugin-config-head::-webkit-details-marker { display: none; }
+.plugin-config-title { font-size: 16px; font-weight: 700; }
+.plugin-config-arrow { font-size: 11px; color: #8a8fa3; transition: transform .15s ease; }
+.plugin-config[open] > .plugin-config-head .plugin-config-arrow { transform: rotate(90deg); }
+.plugin-config-body { padding: 0 14px 14px; border-top: 1px solid var(--clv-border); }
+.plugin-config-disabled .plugin-config-title { color: #8a8fa3; }
+.plugin-config-tip { margin: 0; padding: 12px 14px; border-top: 1px solid var(--clv-border); }
.plugin-config .plugin-help { margin: -6px 0 8px; }
.admin-verify-box { width: min(360px, 100%); }
.admin-verify-box h3 { margin-bottom: 6px; }
diff --git a/web/static/app.js b/web/static/app.js
index da78477..6f0ee74 100644
--- a/web/static/app.js
+++ b/web/static/app.js
@@ -197,9 +197,29 @@
if (ev.target.closest('button, a, video')) return;
window.location.href = '/post/' + p.id;
});
+ applyCardHooks(art, p);
return art;
}
+ /* 插件卡片扩展点:CLV.hooks.cardHtml / cardEl / afterRender
+ 插件通过注入脚本注册(见 docs/PLUGIN-APP.md 第 9.7 节) */
+ function applyCardHooks(art, p) {
+ var hooks = window.CLV && window.CLV.hooks;
+ if (!hooks) return;
+ (hooks.cardHtml || []).forEach(function (fn) {
+ try {
+ var extra = fn('', p);
+ if (extra) {
+ var box = el('div', 'clv-plugin-card-extra');
+ box.innerHTML = extra;
+ art.appendChild(box);
+ }
+ } catch (e) { }
+ });
+ (hooks.cardEl || []).forEach(function (fn) { try { fn(art, p); } catch (e) { } });
+ (hooks.afterRender || []).forEach(function (fn) { try { fn(art, p); } catch (e) { } });
+ }
+
function initMasonry() {
var box = document.getElementById('masonry');
if (!box) return;
@@ -1093,6 +1113,20 @@
});
}
+ /* ---------------- 插件扩展接口 ---------------- */
+ // 与 head 中的预初始化对象合并,保证插件在任意位置注入的脚本都能安全注册
+ var CLV = (window.CLV = window.CLV || {});
+ CLV.version = ((document.querySelector('meta[name="clv-version"]') || {}).content) || '';
+ CLV.csrf = CSRF;
+ CLV.api = api;
+ CLV.el = el;
+ CLV.toast = toast;
+ CLV.fingerprint = fingerprint;
+ CLV.hooks = CLV.hooks || {};
+ CLV.hooks.cardHtml = CLV.hooks.cardHtml || [];
+ CLV.hooks.cardEl = CLV.hooks.cardEl || [];
+ CLV.hooks.afterRender = CLV.hooks.afterRender || [];
+
/* ---------------- 启动 ---------------- */
document.addEventListener('DOMContentLoaded', function () {
diff --git a/web/templates/admin.html b/web/templates/admin.html
index b6ee47f..54fe339 100644
--- a/web/templates/admin.html
+++ b/web/templates/admin.html
@@ -33,6 +33,7 @@
<a class="btn btn-ghost" href="/admin/settings#ai">AI 接入</a>
</div>
</div>
+{{.admin_dashboard_after}}
{{end}}
{{/* ============ 帖子管理 ============ */}}
@@ -172,6 +173,7 @@
<button class="btn btn-ghost danger" type="submit">删除主题</button>
</form>
</div>
+{{.admin_settings_after}}
{{end}}
{{/* ============ 举报管理 ============ */}}
@@ -461,7 +463,7 @@
<input type="file" name="file" accept=".zip" required>
<button class="btn btn-primary" type="submit">上传并安装</button>
</form>
- <p class="muted">插件为 zip 包,需包含 plugin.json,支持 html 注入 / filter 内容过滤 / http 事件 Webhook / guard 发帖守卫 / badge 帖子标识。开发文档见项目 docs/PLUGIN.md。</p>
+ <p class="muted">插件为 zip 包,需包含 plugin.json。两种形态:声明式(html 注入 / filter 内容过滤 / http 事件 Webhook / guard 发帖守卫 / badge 帖子标识)与应用型(kind=app,脚本注册路由 / 数据表 / 后台菜单 / 定时任务)。开发文档见项目 docs/PLUGIN.md。</p>
</div>
{{if .adminTop}}
@@ -470,10 +472,18 @@
{{range .plugins}}
{{if .Config}}
-<form class="card plugin-config" method="post" action="/admin/plugins/config" enctype="multipart/form-data">
+<details class="card plugin-config{{if not .Enabled}} plugin-config-disabled{{end}}">
+ <summary class="plugin-config-head">
+ <span class="plugin-config-arrow" aria-hidden="true">▶</span>
+ <span class="plugin-config-title">{{.Name}}</span>
+ <small class="muted">v{{.Version}} 插件设置</small>
+ {{if .Enabled}}<span class="tag ok">已启用</span>{{else}}<span class="tag warn">插件未启用</span>{{end}}
+ </summary>
+ {{if .Enabled}}
+ <div class="plugin-config-body">
+ <form method="post" action="/admin/plugins/config" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{$.csrf}}">
<input type="hidden" name="plugin" value="{{.Name}}">
- <h3>{{.Name}} <small class="muted">v{{.Version}} 插件设置</small></h3>
{{$cfg := index $.configs .Name}}
{{range .Config}}
{{if eq .Type "audio"}}
@@ -506,6 +516,11 @@
{{end}}
<div class="btn-row"><button class="btn btn-primary" type="submit">保存设置</button></div>
</form>
+ </div>
+ {{else}}
+ <p class="muted plugin-config-tip">插件未启用,请先在下方「已安装插件」列表中启用该插件,再进行配置。</p>
+ {{end}}
+</details>
{{end}}
{{end}}
@@ -513,21 +528,30 @@
<h3>已安装插件</h3>
<div class="table-scroll">
<table class="table">
- <thead><tr><th>名称</th><th>版本</th><th>作者</th><th>说明</th><th>钩子数</th><th>状态</th><th>操作</th></tr></thead>
+ <thead><tr><th>名称</th><th>类型</th><th>版本</th><th>作者</th><th>说明</th><th>钩子数</th><th>状态</th><th>操作</th></tr></thead>
<tbody>
- {{range .plugins}}
+ {{range $p := .plugins}}
+ {{$info := index $.infos $p.Name}}
<tr>
- <td><b>{{.Name}}</b>{{if .Warn}}<br><small class="warn">⚠ {{.Warn}}</small>{{end}}</td>
- <td>{{.Version}}</td>
- <td>{{.Author}}</td>
- <td class="cell-content">{{.Description}}</td>
- <td>{{len .Hooks}}</td>
- <td>{{if .Enabled}}<span class="tag ok">已启用</span>{{else}}<span class="tag warn">已禁用</span>{{end}}</td>
+ <td><b>{{$p.Name}}</b>{{if $p.Warn}}<br><small class="warn">⚠ {{$p.Warn}}</small>{{end}}{{if $p.App}}<br><small class="muted">/x/{{$p.Slug}}/</small>{{end}}</td>
+ <td>{{if $p.App}}<span class="tag">应用型</span>{{else}}<span class="tag">声明式</span>{{end}}</td>
+ <td>{{$p.Version}}</td>
+ <td>{{$p.Author}}</td>
+ <td class="cell-content">{{$p.Description}}
+ {{if $p.App}}
+ <br><small class="muted">路由 {{$info.Routes}} · 页面 {{$info.Pages}} · Slot {{$info.Slots}} · 事件 {{$info.Events}} · 过滤器 {{$info.Filters}}</small>
+ {{if $info.Perms}}<br><small class="muted">权限:{{range $info.Perms}}<code>{{.}}</code> {{end}}</small>{{end}}
+ {{if $info.Jobs}}<br><small class="muted">定时任务:{{range $info.Jobs}}{{.Name}}({{.Every}}) {{end}}</small>{{end}}
+ {{if $info.Fails}}<br><small class="warn">连续失败 {{$info.Fails}} 次(达 20 次将自动禁用)</small>{{end}}
+ {{end}}
+ </td>
+ <td>{{len $p.Hooks}}</td>
+ <td>{{if $p.Enabled}}{{if $p.App}}{{if $info.Running}}<span class="tag ok">运行中</span>{{else}}<span class="tag warn">未运行</span>{{end}}{{else}}<span class="tag ok">已启用</span>{{end}}{{else}}<span class="tag warn">已禁用</span>{{end}}</td>
<td class="ops">
- <form method="post" action="/admin/plugins/toggle" class="inline-form">
+ <form method="post" action="/admin/plugins/toggle" class="inline-form"{{if and $p.App (not $p.Enabled)}}{{if $info.Perms}} data-confirm="该插件声明了权限:{{range $info.Perms}}{{.}} {{end}};确定启用吗?"{{end}}{{end}}>
<input type="hidden" name="_csrf" value="{{$.csrf}}">
- <input type="hidden" name="name" value="{{.Name}}">
- {{if .Enabled}}
+ <input type="hidden" name="name" value="{{$p.Name}}">
+ {{if $p.Enabled}}
<input type="hidden" name="on" value="0">
<button class="mini-btn" type="submit">禁用</button>
{{else}}
@@ -535,14 +559,22 @@
<button class="mini-btn" type="submit">启用</button>
{{end}}
</form>
+ {{if and $p.App $p.Enabled}}
+ <form method="post" action="/admin/plugins/reload" class="inline-form">
+ <input type="hidden" name="_csrf" value="{{$.csrf}}">
+ <input type="hidden" name="name" value="{{$p.Name}}">
+ <button class="mini-btn" type="submit">重载</button>
+ </form>
+ {{end}}
+ <a class="mini-btn" href="/admin/plugin-logs?slug={{$p.Slug}}">日志</a>
<form method="post" action="/admin/plugins/delete" class="inline-form" data-confirm="确定卸载该插件吗?">
<input type="hidden" name="_csrf" value="{{$.csrf}}">
- <input type="hidden" name="name" value="{{.Name}}">
+ <input type="hidden" name="name" value="{{$p.Name}}">
<button class="mini-btn danger" type="submit">卸载</button>
</form>
</td>
</tr>
- {{else}}<tr><td colspan="7" class="empty">暂无插件</td></tr>{{end}}
+ {{else}}<tr><td colspan="8" class="empty">暂无插件</td></tr>{{end}}
</tbody>
</table>
</div>
@@ -558,6 +590,11 @@
</div>
<form method="get" action="/admin/store" class="code-row">
<input type="text" name="q" value="{{.q}}" placeholder="搜索插件名称 / 描述">
+ <select name="kind">
+ <option value="">全部形态</option>
+ <option value="declarative" {{if eq .kind "declarative"}}selected{{end}}>声明式</option>
+ <option value="app" {{if eq .kind "app"}}selected{{end}}>应用型</option>
+ </select>
<button class="btn btn-ghost" type="submit">搜索</button>
</form>
</div>
@@ -582,13 +619,16 @@
<span>↓ {{.Downloads}}</span>
<span>安装 {{.Installs}}</span>
</div>
+ {{if eq .Kind "app"}}
+ <p class="muted"><span class="tag">应用型</span>{{if .Permissions}} 权限:{{range .Permissions}}<code>{{.}}</code> {{end}}{{end}}</p>
+ {{end}}
<div class="store-actions">
{{if index $.installed .Name}}
<span class="tag ok">已安装</span>
<a class="mini-btn" href="/admin/plugins">去启用</a>
{{else}}
{{if .Version}}
- <form method="post" action="/admin/store/install" class="inline-form" data-confirm="确定从云端安装插件「{{.Name}}」吗?">
+ <form method="post" action="/admin/store/install" class="inline-form" data-confirm="确定从云端安装插件「{{.Name}}」吗?{{if .Permissions}}该插件声明权限:{{range .Permissions}}{{.}} {{end}}{{end}}">
<input type="hidden" name="_csrf" value="{{$.csrf}}">
<input type="hidden" name="slug" value="{{.Slug}}">
<input type="hidden" name="version" value="{{.Version}}">
@@ -724,16 +764,92 @@
<div class="card">
<table class="kv-table">
<tr><th>当前版本</th><td>{{.result.current}}</td></tr>
+ <tr><th>运行平台</th><td>{{.platform}} {{if .canSelfUpdate}}<span class="tag ok">支持一键更新</span>{{else}}<span class="tag warn">一键更新仅支持 Linux x86_64</span>{{end}}</td></tr>
<tr><th>更新接口</th><td class="mono">{{.result.url}}</td></tr>
{{with .result.latest}}<tr><th>最新版本</th><td>{{.}}</td></tr>{{end}}
{{with .result.notes}}<tr><th>更新说明</th><td>{{.}}</td></tr>{{end}}
{{with .result.download}}<tr><th>下载地址</th><td class="mono">{{.}}</td></tr>{{end}}
+ {{with .result.sha256}}<tr><th>SHA256</th><td class="mono">{{.}}</td></tr>{{end}}
{{with .result.error}}<tr><th>结果</th><td class="warn">{{.}}</td></tr>{{end}}
</table>
{{if .result.upToDate}}<p class="tag ok">当前已是最新版本</p>
- {{else if .result.latest}}<p class="tag warn">发现新版本,请前往下载地址更新</p>{{end}}
+ {{else if .result.latest}}
+ <p class="tag warn">发现新版本</p>
+ {{if .canSelfUpdate}}
+ <div class="btn-row" style="margin-top:10px">
+ <button class="btn btn-primary" id="self-update-btn" type="button">立即更新</button>
+ <span id="self-update-msg" class="muted"></span>
+ </div>
+ {{else}}
+ <p class="muted small">请前往上方下载地址手动替换二进制文件后重启服务。</p>
+ {{end}}
+ {{end}}
<div class="btn-row">
<a class="btn btn-ghost" href="/admin/update">重新检查</a>
</div>
+ <p class="muted small">云端接口(网站设置 → 更新接口地址)返回 JSON,例如:
+ <code>{"version":"2.0.1","notes":"更新说明","packages":{"linux-amd64":{"url":"二进制直链","sha256":"文件SHA256"}}}</code>;
+ 一键更新必须提供 linux-amd64 的 sha256,更新时旧程序会备份为「程序名.bak」。</p>
+</div>
+<script>
+(function () {
+ var btn = document.getElementById("self-update-btn");
+ if (!btn) return;
+ var msg = document.getElementById("self-update-msg");
+ var csrf = "";
+ var m = document.querySelector('meta[name="csrf"]');
+ if (m) csrf = m.content || "";
+ btn.addEventListener("click", function () {
+ if (!confirm("确定立即更新到最新版本吗?\\n更新过程中服务会短暂重启(原地换载,一般 1-2 秒)。")) return;
+ btn.disabled = true;
+ msg.textContent = "正在下载并校验新版本,请勿关闭页面…";
+ fetch("/admin/update/apply", { method: "POST", headers: { "X-CSRF-Token": csrf } })
+ .then(function (r) { return r.json(); })
+ .then(function (res) {
+ if (res.ok) {
+ msg.textContent = (res.msg || "更新完成") + ",3 秒后自动刷新…";
+ setTimeout(function () { location.reload(); }, 3000);
+ } else {
+ msg.textContent = res.msg || "更新失败";
+ btn.disabled = false;
+ }
+ })
+ .catch(function () {
+ msg.textContent = "连接中断:若由重启所致,说明更新可能已成功,请刷新页面查看版本";
+ btn.disabled = false;
+ });
+ });
+})();
+</script>
+{{end}}
+
+{{/* ============ 插件运行日志 ============ */}}
+{{define "pg_admin_plugin_logs"}}
+<h1 class="page-title">插件运行日志{{if .slug}} <small>已筛选:{{.slug}}</small>{{end}}</h1>
+<div class="card table-card">
+ <div class="btn-row" style="margin-bottom:12px">
+ <a class="btn btn-ghost" href="/admin/plugins">返回插件管理</a>
+ {{if .slug}}<a class="btn btn-ghost" href="/admin/plugin-logs">查看全部</a>{{end}}
+ <form method="post" action="/admin/plugin-logs/clear" class="inline-form" data-confirm="确定清空全部插件运行日志吗?">
+ <input type="hidden" name="_csrf" value="{{.csrf}}">
+ <button class="mini-btn danger" type="submit">清空日志</button>
+ </form>
+ </div>
+ <div class="table-scroll">
+ <table class="table">
+ <thead><tr><th>时间</th><th>插件</th><th>动作</th><th>详情</th><th>耗时</th></tr></thead>
+ <tbody>
+ {{range .logs}}
+ <tr>
+ <td>{{datefmt .created_at}}</td>
+ <td><a href="/admin/plugin-logs?slug={{.plugin_slug}}">{{.plugin_slug}}</a></td>
+ <td><code>{{.action}}</code></td>
+ <td class="cell-content">{{.detail}}</td>
+ <td>{{.duration_ms}} ms</td>
+ </tr>
+ {{else}}<tr><td colspan="5" class="empty">暂无日志</td></tr>{{end}}
+ </tbody>
+ </table>
+ </div>
</div>
{{end}}
diff --git a/web/templates/admin_layout.html b/web/templates/admin_layout.html
index eefb79d..871dd1c 100644
--- a/web/templates/admin_layout.html
+++ b/web/templates/admin_layout.html
@@ -4,9 +4,12 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf" content="{{.csrf}}">
+<meta name="clv-version" content="{{.clvVersion}}">
+<script>window.CLV=window.CLV||{hooks:{cardHtml:[],cardEl:[],afterRender:[]}};</script>
<title>管理后台 - {{.site}}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/app.css">
+{{.admin_head_end}}
</head>
<body class="admin-body">
{{if .admin}}
@@ -34,6 +37,8 @@
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 3h4v4a2 2 0 1 0 4 0h3v4h-4a2 2 0 1 0 0 4h4v4h-4a2 2 0 1 0-4 0H9v-4a2 2 0 1 0-4 0H3v-4h4a2 2 0 1 0 0-4H3V7h4a2 2 0 1 0 4 0z"/></svg>插件管理</a>{{end}}
{{if .admin.HasPerm "plugins"}}<a href="/admin/store" class="{{if eq .Page "pg_admin_store"}}active{{end}}">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 8l1.6-4h12.8L20 8"/><path d="M4 8h16v12H4z"/><path d="M9 12h6"/></svg>插件商店</a>{{end}}
+ {{if .admin.HasPerm "plugins"}}<a href="/admin/plugin-logs" class="{{if eq .Page "pg_admin_plugin_logs"}}active{{end}}">
+ <svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3h9l5 5v13H6z"/><path d="M15 3v5h5"/><path d="M9 13h7M9 17h5"/></svg>插件日志</a>{{end}}
{{if .admin.HasPerm "api"}}<a href="/admin/apikeys" class="{{if eq .Page "pg_admin_apikeys"}}active{{end}}">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2.5 2.5"/></svg>API 接口</a>{{end}}
<a href="/admin/about" class="{{if eq .Page "pg_admin_about"}}active{{end}}">
@@ -42,7 +47,10 @@
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5.5" rx="7" ry="2.8"/><path d="M5 5.5v13c0 1.5 3.1 2.8 7 2.8s7-1.3 7-2.8v-13"/><path d="M5 12c0 1.5 3.1 2.8 7 2.8s7-1.3 7-2.8"/></svg>数据迁移</a>{{end}}
{{if .admin.HasPerm "settings"}}<a href="/admin/update" class="{{if eq .Page "pg_admin_update"}}active{{end}}">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 12a8 8 0 1 1-2.3-5.7M20 4v5h-5"/></svg>检查更新</a>{{end}}
+ {{range .plugin_menus}}{{if or (eq .Perm "") ($.admin.HasPerm .Perm)}}<a href="/admin/plugins/{{.Slug}}{{.Path}}">
+ <svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/><path d="M12 12l8-4.5M12 12v9M12 12L4 7.5"/></svg>{{.Label}}</a>{{end}}{{end}}
</nav>
+ {{.admin_sidebar_after}}
<div class="side-foot">
<a href="/" target="_blank">查看前台</a>
<a href="/admin/logout">退出登录</a>
@@ -73,5 +81,6 @@
</div>
<script src="/static/app.js" defer></script>
+{{.admin_body_end}}
</body>
</html>{{end}}
diff --git a/web/templates/front.html b/web/templates/front.html
index f7dfc61..1a91509 100644
--- a/web/templates/front.html
+++ b/web/templates/front.html
@@ -55,7 +55,9 @@
<span class="act"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a8 8 0 1 1-3.2-6.4L21 4l-1 4.4c.6 1.1 1 2.3 1 3.6z"/></svg>{{.CommentCount}}</span>
<button class="act report-btn" data-id="{{.ID}}" type="button">举报</button>
</div>
+ {{$.detail_actions_after}}
</article>
+{{$.detail_after}}
{{end}}
<section class="comments card">
@@ -78,6 +80,7 @@
<button class="btn btn-primary" type="submit">发表评论</button>
</form>
</section>
+{{.comments_after}}
{{if .rules}}
<details class="rules card">
@@ -191,6 +194,7 @@
</div>
</div>
{{end}}
+{{.compose_after}}
{{end}}
{{/* ============ 编辑帖子 ============ */}}
@@ -290,4 +294,5 @@
</article>
{{else}}<p class="empty">暂无帖子</p>{{end}}
</div>
+{{.profile_after}}
{{end}}
diff --git a/web/templates/layout.html b/web/templates/layout.html
index bd7aea4..d7c076f 100644
--- a/web/templates/layout.html
+++ b/web/templates/layout.html
@@ -4,10 +4,13 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="csrf" content="{{.csrf}}">
+<meta name="clv-version" content="{{.clvVersion}}">
+<script>window.CLV=window.CLV||{hooks:{cardHtml:[],cardEl:[],afterRender:[]}};</script>
<title>{{.site}}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/theme.css">
+{{.head_end}}
</head>
<body>
{{if .bg}}<div class="site-bg" style="background-image:url('{{.bg}}')"></div>{{end}}
@@ -49,5 +52,6 @@
</footer>
<script src="/static/app.js" defer></script>
+{{.body_end}}
</body>
</html>{{end}}