diff --git a/docs/extension-dev-guide.md b/docs/extension-dev-guide.md new file mode 100644 index 00000000..cabfd987 --- /dev/null +++ b/docs/extension-dev-guide.md @@ -0,0 +1,1524 @@ +# Extension 开发手册 + +> 本手册指导 AI 或开发者从零创建一个完整的 rust-blog Extension。 +> rust-blog 的 Extension = Content Type(数据模型)+ Plugin(业务逻辑),打包为一个独立模块。 + +--- + +## 目录 + +- [1. 概览](#1-概览) +- [2. Extension 目录结构](#2-extension-目录结构) +- [3. extension.toml — Extension 清单](#3-extensiontoml--extension-清单) +- [4. Content Type TOML — 数据模型](#4-content-type-toml--数据模型) + - [4.1 content_type 头部](#41-content_type-头部) + - [4.2 字段类型 (FieldType)](#42-字段类型-fieldtype) + - [4.3 字段属性](#43-字段属性) + - [4.4 relation 字段](#44-relation-字段) + - [4.5 media 字段](#45-media-字段) + - [4.6 uid 字段](#46-uid-字段) + - [4.7 enum 字段](#47-enum-字段) + - [4.8 indexes 索引](#48-indexes-索引) + - [4.9 list_view 列表视图](#49-list_view-列表视图) + - [4.10 api 访问控制](#410-api-访问控制) + - [4.11 CMS 自动生成的列](#411-cms-自动生成的列) + - [4.12 CMS 自动生成的 REST API](#412-cms-自动生成的-rest-api) +- [5. plugin/manifest.toml — Plugin 清单](#5-pluginmanifesttoml--plugin-清单) + - [5.1 plugin 基本信息字段](#51-plugin-基本信息字段) + - [5.2 permissions 权限声明](#52-permissions-权限声明) + - [5.3 routes 路由声明](#53-routes-路由声明) + - [5.4 hooks 钩子声明](#54-hooks-钩子声明) + - [5.5 cron 定时任务](#55-cron-定时任务) +- [6. Plugin JS 代码编写](#6-plugin-js-代码编写) + - [6.1 框架约定](#61-框架约定) + - [6.2 工具函数(必须复制)](#62-工具函数必须复制) + - [6.3 Host API](#63-host-api) + - [6.4 Route Handler 编写](#64-route-handler-编写) + - [6.5 Hook Handler 编写](#65-hook-handler-编写) + - [6.6 数据库事务](#66-数据库事务) + - [6.7 事件触发(emitEvent)](#67-事件触发emitevent) + - [6.8 QuickJS 限制](#68-quickjs-限制) + - [6.9 框架响应格式统一](#69-框架响应格式统一) +- [7. Plugin Lua 代码编写](#7-plugin-lua-代码编写) +- [8. API Rule 表达式语法](#8-api-rule-表达式语法) +- [9. 前端对接](#9-前端对接) +- [10. Extension 版本迁移](#10-extension-版本迁移) +- [11. 完整示例:Todo Extension](#11-完整示例-todo-extension) +- [12. 常见陷阱](#12-常见陷阱) + +--- + +## 1. 概览 + +一个 Extension 由两部分组成: + +| 部分 | 作用 | 必需 | +|------|------|------| +| **Content Type** | 声明数据模型(表结构),CMS 自动生成 CRUD API | 否 | +| **Plugin** | 编写自定义业务逻辑(自定义路由、Hook、定时任务) | 否 | + +两者可以独立存在,但大多数扩展两者都需要。 + +**最小化 Extension** 只需要一个 `extension.toml`。最大的 Extension 可以包含: +- 多个 Content Type TOML(每文件一张表) +- 一个 Plugin(JS 或 Lua 运行时),包含自定义路由 + Hook + 定时任务 + +### 加载流程 + +``` +Extension 目录 (extensions/my-ext/) + ├── extension.toml ← 入口 + ├── content_types/*.toml ← 解析 → ContentTypeSchema → 自动迁移建表 → 注册 CRUD 路由 + └── plugin/ + ├── manifest.toml ← 解析 → 注册 Plugin 路由 + Hook + Cron + └── main.js ← QuickJS 沙箱执行 +``` + +--- + +## 2. Extension 目录结构 + +``` +extensions/my-extension/ +├── extension.toml # Extension 清单(必须) +├── content_types/ # Content Type 定义目录 +│ ├── todo.toml # 每个 .toml 定义一张表 +│ └── todo_comment.toml # 可以有多张表 +└── plugin/ # Plugin 目录(可选) + ├── manifest.toml # Plugin 清单 + └── main.js # JS 入口(或 main.lua) +``` + +**规则:** +- `extension.toml` 必须存在于 Extension 根目录 +- `content_types/` 目录路径在 `extension.toml` 中指定,可以为空或省略 +- `plugin/` 目录路径在 `extension.toml` 中指定,可以省略 +- 一个 Extension 只能有一个 Plugin(JS 或 Lua 二选一) + +--- + +## 3. extension.toml — Extension 清单 + +```toml +[extension] +# ── 必填 ── +id = "my-extension" # 全局唯一标识(kebab-case) +name = "My Extension" # 显示名称 +version = "1.0.0" # 语义化版本 + +# ── 可选 ── +description = "描述文本" +author = "Author Name" +license = "MIT" +homepage = "https://github.com/example/my-ext" + +# Content Type 目录路径(相对于 extension 根目录) +# 省略或留空 = 无 Content Type +content_types = "content_types/" + +# Plugin manifest 路径(相对于 extension 根目录) +# 省略或留空 = 无 Plugin +plugin = "plugin/manifest.toml" + +# 依赖的其他 Extension(id → version range) +[extension.dependencies] +# forum = ">=0.1.0" +``` + +### 字段说明 + +| 字段 | 必填 | 说明 | +|------|------|------| +| `id` | 是 | 全局唯一,kebab-case 格式 | +| `name` | 是 | 显示名称 | +| `version` | 是 | 语义化版本 | +| `description` | 否 | 描述 | +| `author` | 否 | 作者 | +| `license` | 否 | 许可证 | +| `homepage` | 否 | 主页 URL | +| `content_types` | 否 | Content Type TOML 文件所在目录路径 | +| `plugin` | 否 | Plugin manifest.toml 文件路径 | + +--- + +## 4. Content Type TOML — 数据模型 + +每个 Content Type TOML 文件定义一张数据库表和对应的 CRUD API。 + +### 4.1 content_type 头部 + +```toml +[content_type] +name = "Todo" # 显示名称 +singular = "todo" # 单数标识,用于 API 路径和注册 key +plural = "todos" # 复数标识,用于 API 路径 +table = "todos" # 数据库表名 +description = "待办事项" # 描述 +draft_publish = false # 是否启用草稿/发布状态流 +slug_field = "title" # 自动从哪个字段生成 slug(可选) +timestamps = true # 自动维护 created_at / updated_at(默认 true) +soft_delete = false # 软删除(添加 deleted_at 列) +``` + +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `name` | — | 显示名称 | +| `singular` | — | 单数标识,如 `"todo"` | +| `plural` | — | 复数标识,如 `"todos"` | +| `table` | — | 数据库表名,如 `"todos"` | +| `description` | `""` | 描述 | +| `draft_publish` | `false` | `true` = 表有 `status` 列(draft/published/archived),`false` = 无 status 列 | +| `slug_field` | `None` | 设置后自动从该字段值生成 URL slug | +| `timestamps` | `true` | 自动添加 `created_at` / `updated_at` 列 | +| `soft_delete` | `false` | 自动添加 `deleted_at` 列,删除时更新而非物理删除 | + +**重要:** `draft_publish = false` 的表**没有 `status` 列**。API Rule 和 Plugin 查询不能引用 `status`。 + +### 4.2 字段类型 (FieldType) + +| type | SQL 类型 | 说明 | +|------|----------|------| +| `text` | `TEXT` | 短文本、字符串 | +| `richtext` | `TEXT` | 富文本(Markdown) | +| `integer` | `INTEGER` | 32 位整数 | +| `big_int` | `INTEGER` | 64 位整数 | +| `decimal` | `REAL` | 高精度小数 | +| `float` | `REAL` | 浮点数 | +| `boolean` | `INTEGER` | 布尔(0/1) | +| `date` | `TEXT` | 日期 (ISO 8601) | +| `datetime` | `TEXT` | 日期时间 (ISO 8601) | +| `time` | `TEXT` | 时间 | +| `email` | `TEXT` | 邮箱(带格式校验) | +| `password` | `TEXT` | 密码(自动 hash) | +| `enum` | `TEXT` | 枚举(需配合 `enum_values`) | +| `uid` | `TEXT` | 自动生成 URL 标识 | +| `json` | `TEXT` | JSON 对象 | +| `media` | `TEXT` | 媒体文件引用 | +| `relation` | — | 关联关系(不生成列,生成 foreign_key 列) | + +### 4.3 字段属性 + +```toml +[fields.title] +type = "text" +required = true # 必填 +unique = true # 唯一约束 +max_length = 200 # 最大长度(text/email/password) +min = 0 # 最小值(数值类型) +max = 100 # 最大值(数值类型) +default = "draft" # 默认值 +auto_fill = "user_id" # 自动填充(见下文) +private = true # 私有字段,不出现在 API 响应中 +immutable = true # 创建后不可修改 +label = "标题" # Admin UI 显示标签 +description = "文章标题" # 字段说明 +pattern = "^[a-z]+$" # 正则校验(text/email) +``` + +#### auto_fill 自动填充 + +| 值 | 说明 | +|----|------| +| `"user_id"` | 当前认证用户 ID | +| `"user_role"` | 当前认证用户角色 | +| `"current_tenant_id"` | 当前租户 ID | +| `"current_timestamp"` | 当前 ISO 8601 时间戳 | + +`auto_fill` 优先级高于 `default` 和客户端传值。用于 `author_id` 等字段自动注入当前用户。 + +### 4.4 relation 字段 + +```toml +[fields.board] +type = "relation" +relation_type = "many_to_one" # 关系类型 +target = "forum_boards" # 目标 content type 的 plural 名称 +foreign_key = "board_id" # 外键列名 +required = true +label = "所属版块" +``` + +**relation_type 可选值:** + +| 值 | 说明 | 生成的列 | +|----|------|----------| +| `one_to_one` | 一对一 | `foreign_key` 列 + UNIQUE | +| `one_to_many` | 一对多 | `foreign_key` 列(在 target 表) | +| `many_to_one` | 多对一 | `foreign_key` 列(在当前表) | +| `many_to_many` | 多对多 | 需指定 `through` 中间表 | +| `one_way` | 单向引用 | `foreign_key` 列 | +| `many_way` | 多向引用 | `foreign_key` 列 | + +**many_to_many 示例:** + +```toml +[fields.tags] +type = "relation" +relation_type = "many_to_many" +target = "tags" +through = "articles_tags" # 中间表名 +label = "标签" +``` + +**重要:** 字段名和数据库列名不同。例如 `board` 字段的列名是 `board_id`。CMS Handler 自动处理映射: +- API 响应用字段名(`board`) +- 前端 filter 用字段名(`?board=xxx`) +- Handler 自动映射为列名(`board_id`) + +### 4.5 media 字段 + +```toml +[fields.cover_image] +type = "media" +accept = ["image/*"] # 接受的 MIME 类型 +max_count = 1 # 最大文件数量(默认 1) +label = "封面图片" +``` + +存储为 JSON 字符串,包含文件路径和元信息。 + +### 4.6 uid 字段 + +```toml +[fields.slug] +type = "uid" +target_field = "title" # 从哪个字段生成 +unique = true +label = "URL 标识" +``` + +自动从 `target_field` 的值生成 URL 友好的 slug。 + +### 4.7 enum 字段 + +```toml +[fields.status] +type = "enum" +enum_values = ["draft", "published", "archived"] +default = "draft" +label = "状态" +``` + +### 4.8 indexes 索引 + +```toml +[[indexes]] +fields = ["slug"] +unique = true + +[[indexes]] +fields = ["board_id", "is_pinned", "last_reply_at"] + +[[indexes]] +fields = ["author_id", "created_at"] +``` + +- 使用 `[[indexes]]` 数组语法(每个索引一个 `[[indexes]]`) +- `fields` 是列名数组(用 foreign_key 列名,不是字段名) +- `unique = true` 表示唯一索引 + +### 4.9 list_view 列表视图 + +```toml +[list_view] +default_sort = "is_pinned:desc,last_reply_at:desc" +columns = ["title", "board", "author_id", "reply_count", "created_at"] +``` + +- `default_sort`:默认排序,格式 `字段名:asc/desc`,多字段用逗号分隔 +- `columns`:列表页显示的列 + +### 4.10 api 访问控制 + +```toml +[api.list] +access = "public" # public / member / admin / none +cache = true # 是否启用服务端缓存 +filter = 'status = "published"' # 数据过滤表达式 +filter_auth = 'author_id = @request.auth.id' # 已登录用户的额外过滤 + +[api.get] +access = "public" +cache = true + +[api.create] +access = "member" # 需要登录 + +[api.update] +access = "member" +filter = "@auth.id == author_id || @auth.role == 'admin'" # API Rule + +[api.delete] +access = "member" +filter = "@auth.id == author_id || @auth.role == 'admin'" +``` + +**access 级别:** + +| 值 | 说明 | +|----|------| +| `none` | 完全禁止 | +| `public` | 公开,无需认证 | +| `member` | 需要登录 | +| `admin` | 需要管理员角色 | + +**cache 默认为 `false`。** 需要显式设置 `cache = true` 才会启用缓存。 + +**filter vs filter_auth:** +- `filter`:对所有通过 access 检查的请求生效(SQL WHERE 附加条件) +- `filter_auth`:仅对已登录用户额外生效,与 `filter` 取 OR 关系 + +### 4.11 CMS 自动生成的列 + +CMS 框架会自动添加以下列,**不需要在 fields 中声明**: + +| 条件 | 自动添加的列 | +|------|-------------| +| 始终 | `id TEXT PRIMARY KEY` | +| 始终 | `tenant_id TEXT NOT NULL DEFAULT 'default'` | +| `draft_publish = true` | `status TEXT DEFAULT 'draft'`、`published_at TEXT` | +| `timestamps = true`(默认) | `created_at TEXT`、`updated_at TEXT` | +| `soft_delete = true` | `deleted_at TEXT` | + +**Plugin INSERT 语句必须包含 `tenant_id` 列(值为 `'default'`)。** + +**Plugin INSERT 语句如果 `timestamps = true`,必须包含 `created_at` 和 `updated_at` 列。** + +### 4.12 CMS 自动生成的 REST API + +每个 Content Type 自动注册以下 5 个 API 端点: + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/v1/cms/{plural}` | 列表查询(支持分页、排序、field filter) | +| GET | `/api/v1/cms/{plural}/{id}` | 单条查询 | +| POST | `/api/v1/cms/{plural}` | 创建 | +| PUT | `/api/v1/cms/{plural}/{id}` | 更新 | +| DELETE | `/api/v1/cms/{plural}/{id}` | 删除 | + +**列表查询参数:** + +| 参数 | 说明 | 示例 | +|------|------|------| +| `page` | 页码(默认 1) | `?page=2` | +| `page_size` | 每页条数(默认 20) | `?page_size=50` | +| `sort` | 排序字段 | `?sort=created_at:desc` | +| `{field_name}` | 按字段过滤 | `?board=xxx` | + +--- + +## 5. plugin/manifest.toml — Plugin 清单 + +### 5.1 plugin 基本信息字段 + +```toml +[plugin] +id = "com.rust-blog.forum" # 全局唯一 ID(推荐反向域名格式) +name = "Forum API" # 显示名称 +version = "0.1.0" # 语义化版本 +description = "论坛 API" +author = "rust-blog" +license = "MIT" +runtime = "js" # "js" 或 "lua" +language = "js" # "js" 或 "lua" +entry = "main.js" # 入口文件名 +``` + +| 字段 | 必填 | 默认值 | 说明 | +|------|------|--------|------| +| `id` | 是 | — | 全局唯一 Plugin ID | +| `name` | 是 | — | 显示名称 | +| `version` | 是 | — | 语义化版本 | +| `description` | 否 | `""` | 描述 | +| `author` | 否 | — | 作者 | +| `license` | 否 | — | 许可证 | +| `runtime` | 否 | `"wasm"` | 运行时:`"js"` / `"lua"` / `"wasm"` | +| `language` | 否 | `"rust"` | 语言:`"js"` / `"lua"` / `"rust"` 等 | +| `entry` | 否 | `"index.js"` | 入口文件名 | + +### 5.2 permissions 权限声明 + +```toml +[permissions] +max_memory_mb = 16 # 内存限制(MB) +timeout_ms = 5000 # 单次执行超时(毫秒) +database = [ # 允许访问的数据库表 + "forum_boards", # 读写权限 + "forum_topics", + "read:product_categories", # 只读权限(read: 前缀) +] +http = ["api.example.com/*"] # 允许的 HTTP 请求(预留) +config = ["seo.*"] # 允许读取的配置 key(预留) +filesystem = ["read-write"] # 文件系统权限(预留) +``` + +**database 权限格式:** +- `"table_name"` = 读写权限 +- `"read:table_name"` = 只读权限 + +### 5.3 routes 路由声明 + +```toml +[[routes]] +method = "GET" +path = "/api/v1/plugins/forum/boards/:slug/topics" +handler = "listBoardTopics" +# auth = "member" # 可选:public(默认)/ member / admin + +[[routes]] +method = "POST" +path = "/api/v1/plugins/forum/vote" +handler = "vote" +auth = "member" # 需要登录 +``` + +**规则:** +- 路径必须以 `/api/v1/plugins/{extension_id}/` 开头 +- `:param` 是路径参数,如 `:id`、`:slug`、`:pollId` +- `handler` 对应 JS 中 `Plugin.xxx` 函数名 +- `auth` 省略时为 `public`(无需认证) +- 框架自动将 Plugin 返回值统一为 `{code, message, data}` 格式 + +### 5.4 hooks 钩子声明 + +```toml +[hooks.on-content-creating] +priority = 50 # 优先级(数字越小越先执行) + +[hooks.on-content-created] +priority = 50 + +[hooks.on-content-deleted] +priority = 50 + +[hooks.on-content-viewed] +priority = 50 +``` + +**Hook 名称(TOML 中用短横线,框架自动转为下划线):** + +| Hook 名(TOML) | JS 函数名 | 触发时机 | 数据内容 | +|------------------|-----------|----------|----------| +| `on-content-creating` | `on_content_creating` | CMS 内容创建前 | `{content_type, data: {...}, ...}` | +| `on-content-created` | `on_content_created` | CMS 内容创建后 | `{content_type, id, ...}` | +| `on-content-updating` | `on_content_updating` | CMS 内容更新前 | `{content_type, id, data: {...}}` | +| `on-content-updated` | `on_content_updated` | CMS 内容更新后 | `{content_type, id, ...}` | +| `on-content-deleted` | `on_content_deleted` | CMS 内容删除后 | `{content_type, id}` | +| `on-content-viewed` | `on_content_viewed` | CMS 内容查看后 | `{content_type, id}` | +| `on-post-creating` | `on_post_creating` | 文章创建前 | `{title, content, ...}` | +| `on-post-created` | `on_post_created` | 文章创建后 | `{id, title, ...}` | +| `on-post-updating` | `on_post_updating` | 文章更新前 | 同上 + `id` | +| `on-post-updated` | `on_post_updated` | 文章更新后 | 同上 | +| `on-post-deleted` | `on_post_deleted` | 文章删除后 | `{id}` | +| `on-comment-creating` | `on_comment_creating` | 评论创建前 | `{content, post_id, ...}` | +| `on-comment-created` | `on_comment_created` | 评论创建后 | `{id, ...}` | +| `render-markdown` | `render_markdown` | Markdown 渲染 | 字符串 | +| `filter-html` | `filter_html` | HTML 后处理 | 字符串 | +| `on-login` | `on_login` | 用户登录后 | `{email, success}` | +| `on-cron-tick` | `on_cron_tick` | 定时任务触发 | `{job_type, payload}` | + +**on-content-creating vs on-post-creating:** +- `on-content-*` 是通用 Hook,对所有 CMS content type 生效 +- `on-post-*` 是专用 Hook,仅对内置 posts 表生效 +- 推荐使用 `on-content-*` 系列 + +### 5.5 cron 定时任务 + +```toml +[[cron]] +label = "Cleanup Sessions" +job_type = "cleanup_sessions" +payload = '{"max_age_hours": 24}' +cron_expr = "0 0 */6 * * *" # 七段式(含秒) +enabled = true # 默认 true +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `label` | 是 | 可读标签 | +| `job_type` | 是 | 自定义任务类型字符串 | +| `payload` | 否 | JSON payload | +| `cron_expr` | 是 | 七段式 Cron 表达式(秒 分 时 日 月 周) | +| `enabled` | 否 | 默认 `true` | + +--- + +## 6. Plugin JS 代码编写 + +### 6.1 框架约定 + +1. 必须声明全局 `var Plugin = {};` 对象 +2. Route Handler 定义为 `Plugin.handlerName = function(input) { ... }` +3. Hook Handler 定义为 `Plugin.hookName = function(input) { ... }` +4. 所有 Handler 返回**JSON 字符串** +5. 框架传入的 `input` 是**JSON 字符串**(需要双重解析) + +### 6.2 工具函数(必须复制) + +以下工具函数是每个 Plugin 的基础骨架,直接复制使用: + +```javascript +var Plugin = {}; + +function ok(result) { + if (result && result._error) { + return JSON.stringify({ status: result._status || 400, body: JSON.stringify({ ok: false, error: result._error }) }); + } + return JSON.stringify({ status: 200, body: JSON.stringify({ ok: true, data: result }) }); +} + +function err(status, msg) { + return { _error: msg, _status: status }; +} + +function parseBody(input) { + try { + if (typeof input === "string") { + var parsed = JSON.parse(input); + if (parsed && typeof parsed.body === "string" && parsed.body.charAt(0) === "{") { + return JSON.parse(parsed.body); + } + return parsed; + } + if (input && input.body) return JSON.parse(input.body); + return {}; + } catch (e) { return {}; } +} + +function routeParam(input, index) { + var obj = input; + if (typeof input === "string") { + try { obj = JSON.parse(input); } catch (e) { return ""; } + } + var path = (obj.path || "").replace(/\/+$/, ""); + var qIdx = path.indexOf("?"); + if (qIdx >= 0) path = path.substring(0, qIdx); + var parts = path.split("/"); + return parts[parts.length - (index || 1)]; +} + +function genId() { + return "xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { + var r = (Math.random() * 16) | 0; + var v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +function nowISO() { + return new Date().toISOString(); +} + +function query(sql, params) { + var result = Host.dbQuery(sql, params ? JSON.stringify(params) : null); + if (!result || result.indexOf("error:") === 0) return null; + return JSON.parse(result); +} + +function exec(sql, params) { + var result = Host.dbExecute(sql, params ? JSON.stringify(params) : null); + return JSON.parse(result); +} +``` + +#### parseBody 详解 + +框架传给 JS 插件的 `input` 是 JSON 字符串,结构如下: + +```json +{ + "path": "/api/v1/plugins/forum/vote?user_id=xxx", + "method": "POST", + "body": "{\"user_id\":\"xxx\",\"target_type\":\"topic\"}", + "headers": {...} +} +``` + +`parseBody` 做了两层解析: +1. 先 `JSON.parse(input)` 得到外层对象 +2. 如果外层对象有 `body` 字段且是 JSON 字符串,再 `JSON.parse(body)` + +#### routeParam 详解 + +从 URL path 中提取路径参数。`index` 参数表示从路径末尾倒数第几个段: + +``` +path = "/api/v1/plugins/forum/boards/my-slug/topics" +parts = ["", "api", "v1", "plugins", "forum", "boards", "my-slug", "topics"] + +routeParam(input, 1) → "topics" (最后一段) +routeParam(input, 2) → "my-slug" (倒数第二段) +routeParam(input, 3) → "boards" (倒数第三段) +``` + +对应路由 `/api/v1/plugins/forum/boards/:slug/topics`,`:slug` 是倒数第二段。 + +**注意:** 框架传递的 `path` 包含 query string,`routeParam` 已自动处理(截取 `?` 之前的部分)。 + +#### ok / err 详解 + +```javascript +// 成功响应 +return ok({ id: "xxx", name: "hello" }); +// → 框架转为 {code: 0, message: "ok", data: {id: "xxx", name: "hello"}} + +// 错误响应 +return ok(err(404, "not found")); +// → 框架转为 {code: 40400, message: "not found", data: null} + +// 错误响应(自定义状态码) +return ok(err(403, "forbidden")); +// → 框架转为 {code: 40300, message: "forbidden", data: null} +``` + +### 6.3 Host API + +Plugin 通过全局 `Host` 对象与宿主交互: + +| API | 说明 | 返回值 | +|-----|------|--------| +| `Host.dbQuery(sql, paramsJson)` | 执行 SELECT 查询(支持参数化) | JSON 字符串数组,或 `"error:..."` | +| `Host.dbExecute(sql, paramsJson)` | 执行写操作(INSERT/UPDATE/DELETE) | `{"rows_affected":N,"error":null}` | +| `Host.dbBegin()` | 开启数据库事务 | `{"ok":true}` 或 `{"error":"..."}` | +| `Host.dbCommit()` | 提交事务 | `{"ok":true}` 或 `{"error":"..."}` | +| `Host.dbRollback()` | 回滚事务 | `{"ok":true}` 或 `{"error":"..."}` | +| `Host.emitEvent(eventType, dataJson)` | 触发自定义事件(广播到 EventBus + WebSocket) | `{"ok":true}` 或 `{"error":"..."}` | +| `Host.log(level, message)` | 写日志 | 无 | +| `Host.getConfig(key)` | 读取配置 | 字符串或 `null` | + +**dbQuery 返回值:** +- 成功:`[{"id":"xxx","name":"hello","count":"5"},...]`(注意整数列返回字符串或 null) +- 失败:`"error: table not found"` + +**dbQuery 参数化查询(推荐,防 SQL 注入):** + +```javascript +// ✅ 推荐:使用参数化查询 +var rows = query("SELECT id, name FROM forum_topics WHERE board_id = ? AND is_pinned = ?", [boardId, 1]); + +// ❌ 危险:字符串拼接(有 SQL 注入风险) +var rows = query("SELECT id, name FROM forum_topics WHERE board_id = '" + boardId + "'"); +``` + +**dbExecute 参数化查询:** +- `{"rows_affected":1,"last_insert_rowid":null,"error":null}` + +**整数列问题:** QuickJS SQLite 绑定返回整数列为 `null`。必须用 `CAST(col AS TEXT)` 转换: + +```javascript +// ❌ 错误 — vote_count 为 null +var rows = query("SELECT id, vote_count FROM forum_poll_options"); + +// ✅ 正确 — 用 CAST 转为字符串再 parseInt +var rows = query("SELECT id, CAST(vote_count AS TEXT) as vote_count FROM forum_poll_options"); +var count = parseInt(rows[0].vote_count, 10) || 0; +``` + +### 6.4 Route Handler 编写 + +Route Handler 是 `Plugin.xxx` 函数,框架传入 `input`(JSON 字符串),返回 JSON 字符串。 + +**模板:** + +```javascript +Plugin.myHandler = function(input) { + var data = parseBody(input); // 解析请求 body + var paramId = routeParam(input, 1); // 提取路径参数 + + // 参数校验 + if (!paramId) return ok(err(400, "id required")); + + // 数据库查询 + var rows = query("SELECT * FROM my_table WHERE id = '" + escapeSQL(paramId) + "'"); + if (!rows || rows.length === 0) return ok(err(404, "not found")); + + // 业务逻辑 + return ok(rows[0]); +}; +``` + +**从 query string 获取参数:** + +```javascript +Plugin.getPoll = function(input) { + var topicId = routeParam(input, 1); + var obj = input; + if (typeof input === "string") { try { obj = JSON.parse(input); } catch (e) {} } + var fullPath = obj.path || ""; + var qsIdx = fullPath.indexOf("?"); + var userId = ""; + if (qsIdx >= 0) { + var qs = fullPath.substring(qsIdx + 1); + var pairs = qs.split("&"); + for (var p = 0; p < pairs.length; p++) { + if (pairs[p].indexOf("user_id=") === 0) { + userId = decodeURIComponent(pairs[p].substring(8)); + } + } + } + // ... 使用 userId +}; +``` + +**写入数据(INSERT 必须包含 tenant_id 和 timestamps):** + +```javascript +Plugin.createItem = function(input) { + var data = parseBody(input); + var id = genId(); + var now = nowISO(); + exec( + "INSERT INTO my_table (id, tenant_id, name, created_at, updated_at) VALUES (?, 'default', ?, ?, ?)", + [id, data.name, now, now] + ); + return ok({ id: id, name: data.name }); +}; +``` + +### 6.5 Hook Handler 编写 + +**on_content_creating — 创建前拦截/修改:** + +```javascript +Plugin.on_content_creating = function(input) { + var data = parseBody(input); + var ct = data.content_type; // content type 名称 + var body = data.data || {}; // 请求数据 + + if (ct === "forum_reply") { + var topicId = body.topic_id; + if (topicId) { + var topics = query("SELECT is_locked FROM forum_topics WHERE id = '" + escapeSQL(topicId) + "'"); + if (topics && topics.length > 0 && topics[0].is_locked) { + return JSON.stringify({ status: 400, body: JSON.stringify({ ok: false, error: "topic is locked" }) }); + } + } + } + + return JSON.stringify({ status: 200, body: JSON.stringify(data) }); +}; +``` + +**注意:** `on_content_creating` 返回的不是 `ok()`/`err()` 格式,而是直接返回 `{status, body}` JSON。 + +**on_content_created — 创建后副作用:** + +```javascript +Plugin.on_content_created = function(input) { + var data = parseBody(input); + var ct = data.content_type; + var id = data.id; + + if (ct === "forum_topic") { + var topics = query("SELECT board_id FROM forum_topics WHERE id = '" + escapeSQL(id) + "'"); + if (topics && topics.length > 0) { + var now = nowISO(); + exec("UPDATE forum_boards SET topic_count = topic_count + 1, post_count = post_count + 1, last_activity_at = ?, last_topic_id = ? WHERE id = ?", + [now, id, topics[0].board_id]); + } + } + + return ok(data); +}; +``` + +**on_content_deleted — 删除后副作用:** + +```javascript +Plugin.on_content_deleted = function(input) { + var data = parseBody(input); + var ct = data.content_type; + var id = data.id; + + if (ct === "forum_topic") { + var topics = query("SELECT board_id FROM forum_topics WHERE id = '" + escapeSQL(id) + "'"); + if (topics && topics.length > 0) { + exec("UPDATE forum_boards SET topic_count = CASE WHEN topic_count > 0 THEN topic_count - 1 ELSE 0 END WHERE id = ?", + [topics[0].board_id]); + } + } + + return ok(data); +}; +``` + +**on_content_viewed — 查看后副作用:** + +```javascript +Plugin.on_content_viewed = function(input) { + var data = parseBody(input); + var ct = data.content_type; + var id = data.id; + + if (ct === "forum_topic") { + exec("UPDATE forum_topics SET view_count = view_count + 1 WHERE id = ?", [id]); + } + + return ok(data); +}; +``` + +### 6.6 数据库事务 + +使用 `Host.dbBegin()` / `dbCommit()` / `dbRollback()` 实现事务包裹: + +```javascript +Plugin.checkout = function(input) { + var data = parseBody(input); + var userId = data.user_id; + + // 开启事务 + var beginResult = JSON.parse(Host.dbBegin()); + if (!beginResult.ok) return ok(err(500, "failed to begin transaction")); + + // 执行多步操作 + var orderId = genId(); + var r = exec( + "INSERT INTO orders (id, tenant_id, user_id, status, created_at, updated_at) VALUES (?, 'default', ?, 'pending', ?, ?)", + [orderId, userId, nowISO(), nowISO()] + ); + if (r.error) { + Host.dbRollback(); // 失败时必须回滚 + return ok(err(500, "order failed: " + r.error)); + } + + var r2 = exec("UPDATE products SET stock = stock - 1 WHERE id = ?", [data.product_id]); + if (r2.error || r2.rows_affected === 0) { + Host.dbRollback(); + return ok(err(500, "stock deduction failed")); + } + + // 提交事务 + var commitResult = JSON.parse(Host.dbCommit()); + if (!commitResult.ok) return ok(err(500, "commit failed")); + + return ok({ order_id: orderId }); +}; +``` + +**事务规则:** +- 同一时刻只能有一个活跃事务 +- 插件超时/崩溃时框架自动 rollback +- 失败时必须手动调用 `dbRollback()`,否则事务残留到超时 + +### 6.7 事件触发(emitEvent) + +Plugin 可通过 `Host.emitEvent()` 主动触发自定义事件: + +```javascript +Plugin.checkout = function(input) { + // ... 创建订单逻辑 ... + + // 触发自定义事件,其他插件可通过 Hook 监听 + Host.emitEvent("OrderCreated", JSON.stringify({ + order_id: orderId, + user_id: userId, + total: totalAmount + })); + + return ok({ order_id: orderId }); +}; +``` + +**触发的事件会:** +1. 通过 `EventBus` 广播到所有订阅者 +2. 推送到 WebSocket 客户端(前端可通过 WS 实时接收) +3. 事件类型名 `eventType` 用于 WS/SSE 过滤 + +**前端接收:** + +```typescript +// WebSocket 接收自定义事件 +ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.type === "event" && msg.event === "OrderCreated") { + console.log("New order:", msg.data); + } +}; + +// 订阅特定事件类型 +ws.send(JSON.stringify({ + type: "subscribe", + filter: ["OrderCreated", "PaymentReceived"] +})); +``` + +### 6.8 QuickJS 限制 + +JS 插件运行在 QuickJS 沙箱中,**不支持以下 ES6+ 特性**: + +| 不支持 | 替代方案 | +|--------|----------| +| `let` / `const` | 用 `var` | +| 箭头函数 `() => {}` | 用 `function() {}` | +| 模板字符串 `` `hello ${name}` `` | 用字符串拼接 `"hello " + name` | +| 可选链 `obj?.prop` | 用 `obj && obj.prop` | +| `new URL()` | 手动解析字符串 | +| `async` / `await` / `Promise` | 全部同步 | +| `for...of` | 用 `for (var i = 0; i < arr.length; i++)` | +| 解构赋值 `const {a} = obj` | 用 `var a = obj.a` | +| `class` 语法 | 用 `function` + 原型 | +| `import` / `export` | 单文件,全局 `Plugin` 对象 | + +### 6.9 框架响应格式统一 + +**重要:** 框架在 `src/plugins.rs` 中将 Plugin 的返回值自动统一为 `{code, message, data}` 格式。 + +Plugin 返回的原始格式: +```json +{"status": 200, "body": "{\"ok\": true, \"data\": {...}}"} +``` + +框架转换后(前端实际收到的): +```json +{"code": 0, "message": "ok", "data": {...}} +``` + +**前端无需特殊处理**,直接用 `apiRequest` 调用 Plugin 路由即可。 + +--- + +## 7. Plugin Lua 代码编写 + +Lua 插件使用 `mlua` 运行时。结构类似: + +```lua +Plugin = {} + +Plugin.stats_overview = function(input) + local result = Host.dbQuery("SELECT COUNT(*) as total FROM posts") + if not result then return {status = 500, body = '{"code":50000,"message":"query failed"}'} end + + local total = tonumber(result:match('"total":"?(%d+)"?')) or 0 + local data = '{"total_posts":' .. total .. '}' + + return { + status = 200, + body = '{"code":0,"message":"ok","data":' .. data .. '}' + } +end +``` + +**Lua 特点:** +- Host API 相同:`Host.dbQuery(sql)`、`Host.dbExecute(sql, params)`、`Host.log(level, msg)` +- 返回 Lua table `{status = N, body = "json string"}` +- `dbQuery` 返回原始 JSON 字符串,需要手动解析(用 Lua 模式匹配) +- 不需要 `parseBody`(框架直接传 Lua table) + +--- + +## 8. API Rule 表达式语法 + +在 Content Type TOML 的 `[api.*]` 中使用 `filter` 字段控制数据访问: + +```toml +filter = "@auth.id == author_id || @auth.role == 'admin'" +``` + +### 操作数 + +| 操作数 | 说明 | 示例 | +|--------|------|------| +| `field_name` | 当前表字段 | `author_id`、`status` | +| `@auth.id` | 当前认证用户 ID | `@auth.id == author_id` | +| `@auth.role` | 当前认证用户角色 | `@auth.role == "admin"` | +| `@request.body.field` | 请求体字段 | `@request.body.title != ""` | +| `@request.query.param` | URL 查询参数 | `@request.query.category = "news"` | +| `@now` | 当前 ISO 8601 时间 | `created_at > @now` | +| `"string"` | 字符串字面量 | `"published"` | +| `123` | 数字字面量 | `42`、`3.14` | +| `true` / `false` | 布尔字面量 | `true` | +| `null` | 空值 | `null` | + +### 比较运算符 + +| 运算符 | 说明 | 示例 | +|--------|------|------| +| `=` 或 `==` | 等于 | `status = "published"` | +| `!=` | 不等于 | `status != "draft"` | +| `>` | 大于 | `price > 0` | +| `>=` | 大于等于 | `stock >= 1` | +| `<` | 小于 | `created_at < @now` | +| `<=` | 小于等于 | `score <= 100` | +| `~` | 包含(LIKE) | `title ~ "rust"` | +| `!~` | 不包含 | `title !~ "spam"` | + +### 逻辑运算符 + +| 运算符 | 说明 | 示例 | +|--------|------|------| +| `&&` | 与 | `status = "published" && author_id = @auth.id` | +| `\|\|` | 或 | `@auth.id == author_id \|\| @auth.role == 'admin'` | + +### 后缀操作 + +| 操作 | 说明 | 示例 | +|------|------|------| +| `field:isset` | 字段非空 | `avatar:isset` | +| `field:length > N` | 字符串/数组长度 | `title:length > 0` | + +--- + +## 9. 前端对接 + +### 调用 CMS CRUD API + +```typescript +import { apiRequest } from "@/lib/api"; + +// 列表 +const data = await apiRequest("GET", "/cms/todos?page=1&page_size=20"); +// data = { items: [...], total: 100, page: 1, page_size: 20 } + +// 单条 +const todo = await apiRequest("GET", "/cms/todos/" + id); + +// 创建 +await apiRequest("POST", "/cms/todos", { title: "My Todo", done: false }); + +// 更新 +await apiRequest("PUT", "/cms/todos/" + id, { done: true }); + +// 删除 +await apiRequest("DELETE", "/cms/todos/" + id); + +// Field filter(用字段名,不用列名) +const data = await apiRequest("GET", "/cms/forum_replies?topic=" + topicId); +``` + +### 调用 Plugin 路由 + +```typescript +// Plugin 路由和 CMS 路由使用相同的 apiRequest +const result = await apiRequest("POST", "/plugins/forum/vote", { + target_type: "topic", + target_id: topicId, + value: 1 +}); +``` + +### Boolean 字段注意 + +CMS boolean 字段从 API 返回的是整数 `0`/`1` 而非 `true`/`false`。前端条件渲染必须严格比较: + +```tsx +// ❌ 错误 — 0 && 会渲染 "0" +{topic.is_pinned && } + +// ✅ 正确 +{topic.is_pinned === true && } +// 或者 +{topic.is_pinned === 1 && } +``` + +--- + +## 10. Extension 版本迁移 + +Extension 支持版本升级时执行自定义 SQL 迁移。 + +### 迁移文件目录 + +``` +extensions/my-extension/ +├── extension.toml +├── migrations/ ← 版本迁移 SQL 文件 +│ ├── 0.2.0.sql ← 升级到 0.2.0 时执行 +│ └── 0.3.0.sql ← 升级到 0.3.0 时执行 +└── ... +``` + +### 工作原理 + +1. Extension 加载时,框架读取数据库中 `extensions` 表记录的 `version` +2. 如果 TOML 中的 `version` 大于数据库记录的 `version`,触发升级 +3. 框架扫描 `migrations/` 目录,执行所有版本号 > old_version 且 ≤ new_version 的 `.sql` 文件 +4. 升级完成后更新数据库中的 `version` 和 `updated_at` + +### 迁移文件格式 + +文件名为版本号(如 `0.2.0.sql`),内容为标准 SQL: + +```sql +-- migrations/0.2.0.sql +ALTER TABLE my_table ADD COLUMN priority TEXT DEFAULT 'medium'; +CREATE INDEX IF NOT EXISTS idx_my_table_priority ON my_table(priority); +``` + +### 注意事项 + +- 版本比较使用字符串序(`"0.2.0" < "0.3.0" < "1.0.0"`) +- Content Type TOML 的新增列会由 CMS 自动 ALTER TABLE,不需要手动写迁移 +- 迁移 SQL 执行失败会记录错误日志但不阻止 Extension 加载 +- 没有 `migrations/` 目录的 Extension 不受影响 + +--- + +## 11. 完整示例:Todo Extension + +### 文件结构 + +``` +extensions/todo/ +├── extension.toml +├── content_types/ +│ └── todo.toml +└── plugin/ + ├── manifest.toml + └── main.js +``` + +### extension.toml + +```toml +[extension] +id = "todo" +name = "Todo" +version = "1.0.0" +description = "待办事项扩展" +author = "Team" +license = "MIT" +content_types = "content_types/" +plugin = "plugin/manifest.toml" + +[extension.dependencies] +``` + +### content_types/todo.toml + +```toml +[content_type] +name = "Todo" +singular = "todo" +plural = "todos" +table = "todos" +description = "待办事项" +draft_publish = false +timestamps = true +soft_delete = false + +[fields.title] +type = "text" +required = true +max_length = 200 +label = "标题" + +[fields.description] +type = "richtext" +label = "描述" + +[fields.done] +type = "boolean" +default = false +label = "已完成" + +[fields.due_date] +type = "datetime" +label = "截止日期" + +[fields.priority] +type = "enum" +enum_values = ["low", "medium", "high"] +default = "medium" +label = "优先级" + +[fields.author_id] +type = "text" +required = true +auto_fill = "user_id" +label = "创建者" + +[[indexes]] +fields = ["author_id", "created_at"] + +[[indexes]] +fields = ["done", "priority"] + +[list_view] +default_sort = "created_at:desc" +columns = ["title", "done", "priority", "due_date", "created_at"] + +[api.list] +access = "public" +cache = true + +[api.get] +access = "public" +cache = true + +[api.create] +access = "member" + +[api.update] +access = "member" +filter = "@auth.id == author_id || @auth.role == 'admin'" + +[api.delete] +access = "member" +filter = "@auth.id == author_id || @auth.role == 'admin'" +``` + +### plugin/manifest.toml + +```toml +[plugin] +id = "com.example.todo" +name = "Todo API" +version = "1.0.0" +description = "待办事项统计和批量操作" +author = "Team" +license = "MIT" +runtime = "js" +language = "js" +entry = "main.js" + +[permissions] +max_memory_mb = 8 +timeout_ms = 3000 +database = ["todos"] + +[hooks.on-content-created] +priority = 50 + +[[routes]] +method = "GET" +path = "/api/v1/plugins/todo/stats" +handler = "getStats" +``` + +### plugin/main.js + +```javascript +var Plugin = {}; + +function ok(result) { + if (result && result._error) { + return JSON.stringify({ status: result._status || 400, body: JSON.stringify({ ok: false, error: result._error }) }); + } + return JSON.stringify({ status: 200, body: JSON.stringify({ ok: true, data: result }) }); +} + +function err(status, msg) { + return { _error: msg, _status: status }; +} + +function parseBody(input) { + try { + if (typeof input === "string") { + var parsed = JSON.parse(input); + if (parsed && typeof parsed.body === "string" && parsed.body.charAt(0) === "{") { + return JSON.parse(parsed.body); + } + return parsed; + } + if (input && input.body) return JSON.parse(input.body); + return {}; + } catch (e) { return {}; } +} + +function query(sql, params) { + var result = Host.dbQuery(sql, params ? JSON.stringify(params) : null); + if (!result || result.indexOf("error:") === 0) return null; + return JSON.parse(result); +} + +function exec(sql, params) { + var result = Host.dbExecute(sql, params ? JSON.stringify(params) : null); + return JSON.parse(result); +} + +// ── Hooks ─────────────────────────────────────────────────── + +Plugin.on_content_created = function(input) { + var data = parseBody(input); + if (data.content_type === "todo") { + Host.log("info", "[todo] new todo created: " + data.id); + } + return ok(data); +}; + +// ── GET /stats ────────────────────────────────────────────── + +Plugin.getStats = function(input) { + var data = parseBody(input); + var userId = data.user_id; + + var totalResult = query("SELECT COUNT(*) as cnt FROM todos"); + var total = (totalResult && totalResult[0]) ? parseInt(totalResult[0].cnt, 10) : 0; + + var doneResult = query("SELECT COUNT(*) as cnt FROM todos WHERE done = 1"); + var done = (doneResult && doneResult[0]) ? parseInt(doneResult[0].cnt, 10) : 0; + + return ok({ + total: total, + done: done, + pending: total - done + }); +}; + +--- + +## 12. 常见陷阱 + +### 1. INSERT 忘记 tenant_id + +```javascript +// ❌ 错误 +exec("INSERT INTO my_table (id, name) VALUES (?, ?)", [id, name]); + +// ✅ 正确 +exec("INSERT INTO my_table (id, tenant_id, name, created_at, updated_at) VALUES (?, 'default', ?, ?, ?)", + [id, name, now, now]); +``` + +### 2. 整数列返回 null + +```javascript +// ❌ 错误 — QuickJS 中 vote_count 为 null +var rows = query("SELECT vote_count FROM my_table"); +var count = rows[0].vote_count; // null + +// ✅ 正确 — 用 CAST 转为字符串 +var rows = query("SELECT CAST(vote_count AS TEXT) as vote_count FROM my_table"); +var count = parseInt(rows[0].vote_count, 10) || 0; +``` + +### 3. draft_publish = false 时查询 status + +```javascript +// ❌ 错误 — 该表没有 status 列 +var rows = query("SELECT * FROM forum_topics WHERE status = 'published'"); + +// ✅ 正确 — draft_publish = false 的表无 status 列 +var rows = query("SELECT * FROM forum_topics WHERE id = ?", [id]); +``` + +### 4. 使用 ES6+ 语法 + +```javascript +// ❌ 错误 — QuickJS 不支持 +const name = data?.name ?? "default"; +const result = items.map(i => i.id); + +// ✅ 正确 — 使用 ES5 +var name = (data && data.name) ? data.name : "default"; +var result = []; +for (var i = 0; i < items.length; i++) { result.push(items[i].id); } +``` + +### 5. 前端 Boolean 比较 + +```tsx +// ❌ 错误 — 0 && 渲染 "0" +{todo.done && } + +// ✅ 正确 +{todo.done === true && } +{todo.done === 1 && } +``` + +### 6. Hook on_content_creating 返回格式不同 + +```javascript +// on_content_creating 返回原始 {status, body} 格式(不用 ok/err) +Plugin.on_content_creating = function(input) { + return JSON.stringify({ status: 200, body: JSON.stringify(data) }); +}; + +// 其他 Hook 可以用 ok() +Plugin.on_content_created = function(input) { + return ok(data); +}; +``` + +### 7. CMS 字段名 vs 数据库列名 + +```javascript +// 字段名: board → 数据库列名: board_id +// 字段名: topic → 数据库列名: topic_id + +// ✅ Plugin SQL 用列名 +query("SELECT board_id FROM forum_topics WHERE id = ?", [id]); + +// ✅ 前端 API filter 用字段名 +apiRequest("GET", "/cms/forum_replies?topic=" + topicId); +``` + +### 8. 字符串拼接 SQL 注入 + +```javascript +// ❌ 危险 — 有 SQL 注入风险 +var rows = query("SELECT * FROM topics WHERE slug = '" + slug + "'"); + +// ✅ 安全 — 使用参数化查询 +var rows = query("SELECT * FROM topics WHERE slug = ?", [slug]); +``` + +### 9. 事务未回滚 + +```javascript +// ❌ 错误 — 失败后未回滚,事务残留 +Plugin.checkout = function(input) { + Host.dbBegin(); + var r = exec("INSERT INTO orders ..."); + if (r.error) return ok(err(500, "failed")); // 事务未关闭! + Host.dbCommit(); +}; + +// ✅ 正确 — 失败时回滚 +Plugin.checkout = function(input) { + var begin = JSON.parse(Host.dbBegin()); + if (!begin.ok) return ok(err(500, "begin failed")); + var r = exec("INSERT INTO orders ..."); + if (r.error) { Host.dbRollback(); return ok(err(500, r.error)); } + Host.dbCommit(); + return ok({ success: true }); +}; +``` + +## 附录 A:现有 Extension 参考 + +| Extension | Content Types | Plugin 路由 | Plugin Hooks | 运行时 | +|-----------|--------------|-------------|-------------|--------| +| `first-ext` | 1 (article) | 4 (stats) | 0 | Lua | +| `ecommerce` | 5 (product, category, cart_item, order, order_item) | 9 | 0 | JS | +| `forum` | 7 (board, topic, reply, vote, poll, poll_option, poll_vote) | 8 | 4 | JS | + +**学习顺序推荐:** +1. `first-ext` — 最简单的 Extension 结构 +2. `ecommerce` — 展示 Content Type 多样性和复杂 Plugin 路由 +3. `forum` — 最完整,包含 Hook + 复杂查询 + 计数维护 + +## 附录 B:Content Type TOML → SQL 映射 + +| TOML 字段类型 | SQLite 列类型 | 约束 | +|---------------|---------------|------| +| `text` | `TEXT` | — | +| `richtext` | `TEXT` | — | +| `integer` | `INTEGER` | — | +| `big_int` | `INTEGER` | — | +| `decimal` | `REAL` | — | +| `float` | `REAL` | — | +| `boolean` | `INTEGER` | DEFAULT 0 | +| `date` | `TEXT` | — | +| `datetime` | `TEXT` | — | +| `time` | `TEXT` | — | +| `email` | `TEXT` | — | +| `password` | `TEXT` | — | +| `enum` | `TEXT` | CHECK(col IN (...)) | +| `uid` | `TEXT` | UNIQUE if specified | +| `json` | `TEXT` | — | +| `media` | `TEXT` | — | +| `relation` (many_to_one) | `TEXT` | FOREIGN KEY via foreign_key | + +## 附录 C:CMS 自动添加的列汇总 + +``` +id TEXT PRIMARY KEY ← 始终 +tenant_id TEXT NOT NULL DEFAULT 'default' ← 始终 +status TEXT DEFAULT 'draft' ← draft_publish = true +published_at TEXT ← draft_publish = true +created_at TEXT ← timestamps = true(默认) +updated_at TEXT ← timestamps = true(默认) +deleted_at TEXT ← soft_delete = true +``` diff --git a/extensions/ecommerce/plugin/main.js b/extensions/ecommerce/plugin/main.js index 06a30d8e..193db5cc 100644 --- a/extensions/ecommerce/plugin/main.js +++ b/extensions/ecommerce/plugin/main.js @@ -1,5 +1,3 @@ -// ── 工具函数 ────────────────────────────────────────────────── - var Plugin = {}; function ok(result) { @@ -14,13 +12,29 @@ function err(status, msg) { } function parseBody(input) { - try { return input.body ? JSON.parse(input.body) : {}; } - catch (e) { return {}; } + try { + if (typeof input === "string") { + var parsed = JSON.parse(input); + if (parsed && typeof parsed.body === "string" && parsed.body.charAt(0) === "{") { + return JSON.parse(parsed.body); + } + return parsed; + } + if (input && input.body) return JSON.parse(input.body); + return {}; + } catch (e) { return {}; } } -function routeParam(input) { - var parts = (input.path || "").replace(/\/+$/, "").split("/"); - return parts[parts.length - 1]; +function routeParam(input, index) { + var obj = input; + if (typeof input === "string") { + try { obj = JSON.parse(input); } catch (e) { return ""; } + } + var path = (obj.path || "").replace(/\/+$/, ""); + var qIdx = path.indexOf("?"); + if (qIdx >= 0) path = path.substring(0, qIdx); + var parts = path.split("/"); + return parts[parts.length - (index || 1)]; } function genId() { @@ -31,8 +45,8 @@ function genId() { }); } -function query(sql) { - var result = Host.dbQuery(sql); +function query(sql, params) { + var result = Host.dbQuery(sql, params ? JSON.stringify(params) : null); if (!result || result.indexOf("error:") === 0) return null; return JSON.parse(result); } @@ -54,7 +68,7 @@ Plugin.listProducts = function(input) { Plugin.getProduct = function(input) { var id = routeParam(input); - var rows = query("SELECT id, name, slug, description, price, compare_at_price, stock, sku, images, featured, weight FROM products WHERE id = '" + id + "' AND status = 'published'"); + var rows = query("SELECT id, name, slug, description, price, compare_at_price, stock, sku, images, featured, weight FROM products WHERE id = ? AND status = 'published'", [id]); if (!rows) return ok(err(500, "query failed")); if (rows.length === 0) return ok(err(404, "product not found")); return ok(rows[0]); @@ -66,7 +80,7 @@ Plugin.viewCart = function(input) { var data = parseBody(input); var userId = data.user_id; if (!userId) return ok(err(400, "user_id required")); - var rows = query("SELECT c.id as cart_id, c.product_id, c.quantity, p.name, p.price, p.stock, p.images, p.sku FROM cart_items c LEFT JOIN products p ON c.product_id = p.id WHERE c.user_id = '" + userId + "' ORDER BY c.created_at DESC"); + var rows = query("SELECT c.id as cart_id, c.product_id, c.quantity, p.name, p.price, p.stock, p.sku FROM cart_items c LEFT JOIN products p ON c.product_id = p.id WHERE c.user_id = ?", [userId]); if (!rows) return ok(err(500, "query failed")); var total = 0; for (var i = 0; i < rows.length; i++) { @@ -85,13 +99,13 @@ Plugin.addToCart = function(input) { var quantity = data.quantity || 1; if (!userId || !productId) return ok(err(400, "user_id and product_id required")); - var products = query("SELECT id, name, price, stock, status FROM products WHERE id = '" + productId + "'"); + var products = query("SELECT id, name, price, stock, status FROM products WHERE id = ?", [productId]); if (!products) return ok(err(500, "query failed")); if (products.length === 0) return ok(err(404, "product not found")); if (products[0].status !== "published") return ok(err(400, "product not available")); if (products[0].stock < quantity) return ok(err(400, "insufficient stock")); - var existing = query("SELECT id, quantity FROM cart_items WHERE user_id = '" + userId + "' AND product_id = '" + productId + "'"); + var existing = query("SELECT id, quantity FROM cart_items WHERE user_id = ? AND product_id = ?", [userId, productId]); if (!existing) return ok(err(500, "query failed")); if (existing.length > 0) { @@ -100,7 +114,8 @@ Plugin.addToCart = function(input) { if (r.error) return ok(err(500, r.error)); } else { var id = genId(); - var r = exec("INSERT INTO cart_items (id, user_id, product_id, quantity) VALUES (?, ?, ?, ?)", [id, userId, productId, quantity]); + var now = new Date().toISOString(); + var r = exec("INSERT INTO cart_items (id, tenant_id, user_id, product_id, quantity, created_at, updated_at) VALUES (?, 'default', ?, ?, ?, ?, ?)", [id, userId, productId, quantity, now, now]); if (r.error) return ok(err(500, r.error)); } return ok({ added: true }); @@ -140,14 +155,14 @@ Plugin.removeCartItem = function(input) { return ok({ removed: true }); } -// ── POST /checkout ─────────────────────────────────────────── +// ── POST /checkout (事务) ──────────────────────────────────── Plugin.checkout = function(input) { var data = parseBody(input); var userId = data.user_id; if (!userId) return ok(err(400, "user_id required")); - var cartItems = query("SELECT c.id as cart_id, c.product_id, c.quantity, p.name, p.price, p.stock, p.status FROM cart_items c LEFT JOIN products p ON c.product_id = p.id WHERE c.user_id = '" + userId + "'"); + var cartItems = query("SELECT c.id as cart_id, c.product_id, c.quantity, p.name, p.price, p.stock, p.status FROM cart_items c LEFT JOIN products p ON c.product_id = p.id WHERE c.user_id = ?", [userId]); if (!cartItems) return ok(err(500, "query failed")); if (cartItems.length === 0) return ok(err(400, "cart is empty")); @@ -167,15 +182,21 @@ Plugin.checkout = function(input) { orderItems.push({ product_id: item.product_id, product_name: item.name, price: item.price, quantity: item.quantity, subtotal: subtotal }); } + var beginResult = JSON.parse(Host.dbBegin()); + if (!beginResult.ok) return ok(err(500, "failed to begin transaction")); + var orderId = genId(); var orderNo = "ORD-" + Date.now().toString(36).toUpperCase() + "-" + Math.random().toString(36).substring(2, 6).toUpperCase(); var shippingJson = data.shipping_address ? JSON.stringify(data.shipping_address) : ""; var r = exec( - "INSERT INTO orders (id, order_no, user_id, status, total_amount, shipping_address, note) VALUES (?, ?, ?, 'pending', ?, ?, ?)", - [orderId, orderNo, userId, totalAmount, shippingJson, data.note || ""] + "INSERT INTO orders (id, tenant_id, order_no, user_id, status, total_amount, shipping_address, note, created_at, updated_at) VALUES (?, 'default', ?, ?, 'pending', ?, ?, ?, ?, ?)", + [orderId, orderNo, userId, totalAmount, shippingJson, data.note || "", new Date().toISOString(), new Date().toISOString()] ); - if (r.error) return ok(err(500, "create order failed: " + r.error)); + if (r.error) { + Host.dbRollback(); + return ok(err(500, "create order failed: " + r.error)); + } for (var i = 0; i < orderItems.length; i++) { var oi = orderItems[i]; @@ -185,19 +206,22 @@ Plugin.checkout = function(input) { [oiId, orderId, oi.product_id, oi.product_name, oi.price, oi.quantity, oi.subtotal] ); if (r2.error) { - exec("UPDATE orders SET status = 'cancelled' WHERE id = ?", [orderId]); + Host.dbRollback(); return ok(err(500, "create order item failed: " + r2.error)); } var r3 = exec("UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?", [oi.quantity, oi.product_id, oi.quantity]); if (r3.error || r3.rows_affected === 0) { - exec("UPDATE orders SET status = 'cancelled' WHERE id = ?", [orderId]); + Host.dbRollback(); return ok(err(500, "stock deduction failed for " + oi.product_name)); } } exec("DELETE FROM cart_items WHERE user_id = ?", [userId]); + var commitResult = JSON.parse(Host.dbCommit()); + if (!commitResult.ok) return ok(err(500, "commit failed")); + return ok({ order_id: orderId, order_no: orderNo, @@ -212,7 +236,7 @@ Plugin.checkout = function(input) { Plugin.listOrders = function(input) { var data = parseBody(input); if (!data.user_id) return ok(err(400, "user_id required")); - var rows = query("SELECT id, order_no, status, total_amount, note, created_at FROM orders WHERE user_id = '" + data.user_id + "' ORDER BY created_at DESC LIMIT 50"); + var rows = query("SELECT id, order_no, status, total_amount, note, created_at FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 50", [data.user_id]); if (!rows) return ok(err(500, "query failed")); return ok({ items: rows, total: rows.length }); } @@ -223,13 +247,13 @@ Plugin.getOrder = function(input) { var orderId = routeParam(input); var data = parseBody(input); if (!orderId) return ok(err(400, "order id required")); - var orders = query("SELECT id, order_no, user_id, status, total_amount, shipping_address, note, paid_at, shipped_at, created_at FROM orders WHERE id = '" + orderId + "'"); + var orders = query("SELECT id, order_no, user_id, status, total_amount, shipping_address, note, paid_at, shipped_at, created_at FROM orders WHERE id = ?", [orderId]); if (!orders) return ok(err(500, "query failed")); if (orders.length === 0) return ok(err(404, "order not found")); var order = orders[0]; if (data.user_id && order.user_id !== data.user_id) return ok(err(403, "forbidden")); - var items = query("SELECT id, product_id, product_name, price, quantity, subtotal FROM order_items WHERE order_id = '" + orderId + "'"); + var items = query("SELECT id, product_id, product_name, price, quantity, subtotal FROM order_items WHERE order_id = ?", [orderId]); order.items = items || []; return ok(order); } diff --git a/extensions/forum/plugin/main.js b/extensions/forum/plugin/main.js index 64cf0805..2043e547 100644 --- a/extensions/forum/plugin/main.js +++ b/extensions/forum/plugin/main.js @@ -49,8 +49,8 @@ function nowISO() { return new Date().toISOString(); } -function query(sql) { - var result = Host.dbQuery(sql); +function query(sql, params) { + var result = Host.dbQuery(sql, params ? JSON.stringify(params) : null); if (!result || result.indexOf("error:") === 0) return null; return JSON.parse(result); } @@ -60,11 +60,6 @@ function exec(sql, params) { return JSON.parse(result); } -function escapeSQL(str) { - if (str === null || str === undefined) return ""; - return String(str).replace(/'/g, "''"); -} - // ── Hooks ─────────────────────────────────────────────────── Plugin.on_content_creating = function (input) { @@ -75,7 +70,7 @@ Plugin.on_content_creating = function (input) { if (ct === "forum_reply") { var topicId = body.topic_id; if (topicId) { - var topics = query("SELECT is_locked FROM forum_topics WHERE id = '" + escapeSQL(topicId) + "'"); + var topics = query("SELECT is_locked FROM forum_topics WHERE id = ?", [topicId]); if (topics && topics.length > 0 && topics[0].is_locked) { return JSON.stringify({ status: 400, body: JSON.stringify({ ok: false, error: "topic is locked" }) }); } @@ -91,7 +86,7 @@ Plugin.on_content_created = function (input) { var id = data.id; if (ct === "forum_topic") { - var topics = query("SELECT board_id FROM forum_topics WHERE id = '" + escapeSQL(id) + "'"); + var topics = query("SELECT board_id FROM forum_topics WHERE id = ?", [id]); if (topics && topics.length > 0) { var now = nowISO(); exec("UPDATE forum_boards SET topic_count = topic_count + 1, post_count = post_count + 1, last_activity_at = ?, last_topic_id = ? WHERE id = ?", @@ -100,20 +95,22 @@ Plugin.on_content_created = function (input) { } if (ct === "forum_reply") { - var replies = query("SELECT topic_id, author_id FROM forum_replies WHERE id = '" + escapeSQL(id) + "'"); + var replies = query("SELECT topic_id, author_id FROM forum_replies WHERE id = ?", [id]); if (replies && replies.length > 0) { var reply = replies[0]; var now = nowISO(); exec("UPDATE forum_topics SET reply_count = reply_count + 1, last_reply_at = ?, last_reply_user_id = ?, updated_at = ? WHERE id = ?", [now, reply.author_id, now, reply.topic_id]); - var topics = query("SELECT board_id FROM forum_topics WHERE id = '" + escapeSQL(reply.topic_id) + "'"); + var topics = query("SELECT board_id FROM forum_topics WHERE id = ?", [reply.topic_id]); if (topics && topics.length > 0) { exec("UPDATE forum_boards SET post_count = post_count + 1, last_activity_at = ? WHERE id = ?", [now, topics[0].board_id]); } } } + + return ok(data); }; Plugin.on_content_deleted = function (input) { @@ -122,7 +119,7 @@ Plugin.on_content_deleted = function (input) { var id = data.id; if (ct === "forum_topic") { - var topics = query("SELECT board_id FROM forum_topics WHERE id = '" + escapeSQL(id) + "'"); + var topics = query("SELECT board_id FROM forum_topics WHERE id = ?", [id]); if (topics && topics.length > 0) { exec("UPDATE forum_boards SET topic_count = CASE WHEN topic_count > 0 THEN topic_count - 1 ELSE 0 END, post_count = CASE WHEN post_count > 0 THEN post_count - 1 ELSE 0 END WHERE id = ?", [topics[0].board_id]); @@ -130,12 +127,14 @@ Plugin.on_content_deleted = function (input) { } if (ct === "forum_reply") { - var replies = query("SELECT topic_id FROM forum_replies WHERE id = '" + escapeSQL(id) + "'"); + var replies = query("SELECT topic_id FROM forum_replies WHERE id = ?", [id]); if (replies && replies.length > 0) { exec("UPDATE forum_topics SET reply_count = CASE WHEN reply_count > 0 THEN reply_count - 1 ELSE 0 END WHERE id = ?", [replies[0].topic_id]); } } + + return ok(data); }; Plugin.on_content_viewed = function (input) { @@ -146,12 +145,13 @@ Plugin.on_content_viewed = function (input) { if (ct === "forum_topic") { exec("UPDATE forum_topics SET view_count = view_count + 1 WHERE id = ?", [id]); } + + return ok(data); }; // ── GET /boards/:slug/topics ──────────────────────────────── Plugin.listBoardTopics = function (input) { - var fullPath = input.path || ""; var slug = routeParam(input, 2); var page = 1; var pageSize = 20; @@ -159,19 +159,20 @@ Plugin.listBoardTopics = function (input) { if (pageSize < 1 || pageSize > 100) pageSize = 20; var offset = (page - 1) * pageSize; - var boards = query("SELECT id FROM forum_boards WHERE slug = '" + escapeSQL(slug) + "'"); + var boards = query("SELECT id FROM forum_boards WHERE slug = ?", [slug]); if (!boards || boards.length === 0) return ok(err(404, "board not found for slug: " + slug)); var boardId = boards[0].id; - var totalResult = query("SELECT COUNT(*) as cnt FROM forum_topics WHERE board_id = '" + boardId + "'"); + var totalResult = query("SELECT COUNT(*) as cnt FROM forum_topics WHERE board_id = ?", [boardId]); var total = (totalResult && totalResult[0]) ? parseInt(totalResult[0].cnt, 10) : 0; var rows = query( "SELECT id, title, slug, author_id, reply_count, view_count, is_pinned, is_locked, is_solved, " + "last_reply_at, last_reply_user_id, tags, created_at " + - "FROM forum_topics WHERE board_id = '" + boardId + "' " + + "FROM forum_topics WHERE board_id = ? " + "ORDER BY is_pinned DESC, last_reply_at DESC, created_at DESC " + - "LIMIT " + pageSize + " OFFSET " + offset + "LIMIT ? OFFSET ?", + [boardId, pageSize, offset] ); return ok({ items: rows || [], total: total, page: page, page_size: pageSize, board_id: boardId }); @@ -185,11 +186,11 @@ Plugin.acceptAnswer = function (input) { var userId = data.user_id; if (!userId) return ok(err(400, "user_id required")); - var replies = query("SELECT id, topic_id, author_id FROM forum_replies WHERE id = '" + escapeSQL(replyId) + "' AND status = 'published'"); + var replies = query("SELECT id, topic_id, author_id FROM forum_replies WHERE id = ?", [replyId]); if (!replies || replies.length === 0) return ok(err(404, "reply not found")); var reply = replies[0]; - var topics = query("SELECT id, author_id FROM forum_topics WHERE id = '" + escapeSQL(reply.topic_id) + "' AND status = 'published'"); + var topics = query("SELECT id, author_id FROM forum_topics WHERE id = ?", [reply.topic_id]); if (!topics || topics.length === 0) return ok(err(404, "topic not found")); if (topics[0].author_id !== userId) return ok(err(403, "only topic author can accept answer")); @@ -217,7 +218,7 @@ Plugin.vote = function (input) { if (!targetId) return ok(err(400, "target_id required")); if (targetType !== "topic" && targetType !== "reply") return ok(err(400, "target_type must be topic or reply")); - var existing = query("SELECT id, value FROM forum_votes WHERE target_type = '" + escapeSQL(targetType) + "' AND target_id = '" + escapeSQL(targetId) + "' AND user_id = '" + escapeSQL(userId) + "'"); + var existing = query("SELECT id, value FROM forum_votes WHERE target_type = ? AND target_id = ? AND user_id = ?", [targetType, targetId, userId]); if (existing && existing.length > 0) { var oldValue = parseInt(existing[0].value, 10); var diff = value - oldValue; @@ -227,7 +228,7 @@ Plugin.vote = function (input) { } else { var id = genId(); var now = nowISO(); - exec("INSERT INTO forum_votes (id, target_type, target_id, user_id, value, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'published', ?, ?)", + exec("INSERT INTO forum_votes (id, tenant_id, target_type, target_id, user_id, value, created_at, updated_at) VALUES (?, 'default', ?, ?, ?, ?, ?, ?)", [id, targetType, targetId, userId, value, now, now]); updateVoteCount(targetType, targetId, value); } @@ -247,7 +248,7 @@ Plugin.unvote = function (input) { if (!targetType) return ok(err(400, "target_type required")); if (!targetId) return ok(err(400, "target_id required")); - var existing = query("SELECT id, value FROM forum_votes WHERE target_type = '" + escapeSQL(targetType) + "' AND target_id = '" + escapeSQL(targetId) + "' AND user_id = '" + escapeSQL(userId) + "'"); + var existing = query("SELECT id, value FROM forum_votes WHERE target_type = ? AND target_id = ? AND user_id = ?", [targetType, targetId, userId]); if (!existing || existing.length === 0) return ok(err(404, "vote not found")); var oldValue = parseInt(existing[0].value, 10); @@ -280,11 +281,11 @@ Plugin.createPoll = function (input) { if (maxChoices < 1) maxChoices = 1; if (maxChoices > options.length) maxChoices = options.length; - var topics = query("SELECT id, author_id FROM forum_topics WHERE id = '" + escapeSQL(topicId) + "'"); + var topics = query("SELECT id, author_id FROM forum_topics WHERE id = ?", [topicId]); if (!topics || topics.length === 0) return ok(err(404, "topic not found")); if (topics[0].author_id !== userId) return ok(err(403, "only topic author can create poll")); - var existing = query("SELECT id FROM forum_polls WHERE topic_id = '" + escapeSQL(topicId) + "'"); + var existing = query("SELECT id FROM forum_polls WHERE topic_id = ?", [topicId]); if (existing && existing.length > 0) return ok(err(400, "poll already exists for this topic")); var pollId = genId(); @@ -332,18 +333,18 @@ Plugin.getPoll = function (input) { } } - var polls = query("SELECT id, topic_id, question, max_choices, is_closed, created_at FROM forum_polls WHERE topic_id = '" + escapeSQL(topicId) + "'"); + var polls = query("SELECT id, topic_id, question, max_choices, is_closed, created_at FROM forum_polls WHERE topic_id = ?", [topicId]); if (!polls || polls.length === 0) return ok(null); var poll = polls[0]; - var options = query("SELECT id, text, CAST(vote_count AS TEXT) as vote_count, CAST(sort_order AS TEXT) as sort_order FROM forum_poll_options WHERE poll_id = '" + poll.id + "' ORDER BY sort_order"); + var options = query("SELECT id, text, CAST(vote_count AS TEXT) as vote_count, CAST(sort_order AS TEXT) as sort_order FROM forum_poll_options WHERE poll_id = ? ORDER BY sort_order", [poll.id]); - var totalResult = query("SELECT CAST(SUM(vote_count) AS TEXT) as total FROM forum_poll_options WHERE poll_id = '" + poll.id + "'"); + var totalResult = query("SELECT CAST(SUM(vote_count) AS TEXT) as total FROM forum_poll_options WHERE poll_id = ?", [poll.id]); var totalVotes = (totalResult && totalResult[0] && totalResult[0].total) ? parseInt(totalResult[0].total, 10) : 0; var userVotes = []; if (userId) { - var votes = query("SELECT option_id FROM forum_poll_votes WHERE poll_id = '" + poll.id + "' AND user_id = '" + escapeSQL(userId) + "'"); + var votes = query("SELECT option_id FROM forum_poll_votes WHERE poll_id = ? AND user_id = ?", [poll.id, userId]); if (votes) { for (var i = 0; i < votes.length; i++) { userVotes.push(votes[i].option_id); @@ -385,7 +386,7 @@ Plugin.castVote = function (input) { if (!userId) return ok(err(400, "user_id required")); if (!optionIds || optionIds.length === 0) return ok(err(400, "option_ids required")); - var polls = query("SELECT id, topic_id, question, max_choices, is_closed FROM forum_polls WHERE id = '" + escapeSQL(pollId) + "'"); + var polls = query("SELECT id, topic_id, question, max_choices, is_closed FROM forum_polls WHERE id = ?", [pollId]); if (!polls || polls.length === 0) return ok(err(404, "poll not found")); var poll = polls[0]; @@ -394,12 +395,12 @@ Plugin.castVote = function (input) { var maxChoices = parseInt(poll.max_choices, 10); if (optionIds.length > maxChoices) return ok(err(400, "too many choices (max " + maxChoices + ")")); - var existingVotes = query("SELECT option_id FROM forum_poll_votes WHERE poll_id = '" + escapeSQL(pollId) + "' AND user_id = '" + escapeSQL(userId) + "'"); + var existingVotes = query("SELECT option_id FROM forum_poll_votes WHERE poll_id = ? AND user_id = ?", [pollId, userId]); if (existingVotes && existingVotes.length > 0) return ok(err(400, "already voted")); for (var i = 0; i < optionIds.length; i++) { var optId = optionIds[i]; - var opts = query("SELECT id FROM forum_poll_options WHERE id = '" + escapeSQL(optId) + "' AND poll_id = '" + escapeSQL(pollId) + "'"); + var opts = query("SELECT id FROM forum_poll_options WHERE id = ? AND poll_id = ?", [optId, pollId]); if (!opts || opts.length === 0) return ok(err(400, "option not found: " + optId)); var voteId = genId(); @@ -419,10 +420,10 @@ Plugin.deletePoll = function (input) { if (!userId) return ok(err(400, "user_id required")); - var polls = query("SELECT id, topic_id FROM forum_polls WHERE id = '" + escapeSQL(pollId) + "'"); + var polls = query("SELECT id, topic_id FROM forum_polls WHERE id = ?", [pollId]); if (!polls || polls.length === 0) return ok(err(404, "poll not found")); - var topics = query("SELECT author_id FROM forum_topics WHERE id = '" + escapeSQL(polls[0].topic_id) + "'"); + var topics = query("SELECT author_id FROM forum_topics WHERE id = ?", [polls[0].topic_id]); if (!topics || topics.length === 0 || topics[0].author_id !== userId) return ok(err(403, "only topic author can delete poll")); exec("DELETE FROM forum_poll_votes WHERE poll_id = ?", [pollId]); diff --git a/plugins-protocol/wit/plugin.wit b/plugins-protocol/wit/plugin.wit index a5b3f864..14ec08fa 100644 --- a/plugins-protocol/wit/plugin.wit +++ b/plugins-protocol/wit/plugin.wit @@ -52,7 +52,7 @@ interface host-api { get-data: func(key: string) -> option; set-data: func(key: string, value: string) -> bool; get-post: func(slug: string) -> option; - db-query: func(sql: string) -> string; + db-query: func(sql: string, params: option) -> string; db-execute: func(sql: string, params: option) -> string; db-begin: func() -> string; db-commit: func() -> string; @@ -63,6 +63,7 @@ interface host-api { fs-exists: func(path: string) -> bool; fs-list: func(path: string) -> option; fs-stat: func(path: string) -> option; + emit-event: func(event-type: string, data: string) -> string; } interface plugin-hooks { diff --git a/src/eventbus.rs b/src/eventbus.rs index 631442ca..fea819b9 100644 --- a/src/eventbus.rs +++ b/src/eventbus.rs @@ -97,6 +97,13 @@ pub enum Event { email: String, verify_token: String, }, + + // ── 插件自定义事件 ── + Custom { + source: String, + event_type: String, + data: serde_json::Value, + }, } /// 事件订阅者 diff --git a/src/extension/manager.rs b/src/extension/manager.rs index bb9478eb..4c7a9226 100644 --- a/src/extension/manager.rs +++ b/src/extension/manager.rs @@ -208,6 +208,31 @@ impl ExtensionManager { ext_id )) })?; + } else if let Some(ref existing) = db_record { + let new_version = &manifest.extension.version; + if existing.version != *new_version { + tracing::info!( + "extension '{}' upgrading: {} → {}", + ext_id, + existing.version, + new_version + ); + if let Err(e) = self + .run_extension_migrations(ext_root, &existing.version, new_version) + .await + { + tracing::error!("extension '{}' migration failed: {e}", ext_id); + } + let (_, now) = crate::utils::id::new_id_and_timestamp(); + model::update_version(&self.pool, ext_id, new_version, &manifest.extension.name, &now) + .await + .map_err(|e| { + AppError::Internal(anyhow::anyhow!( + "extension '{}': update version failed: {e}", + ext_id + )) + })?; + } } if let Some(ct_dir) = manifest.content_types_dir(ext_root) @@ -288,6 +313,68 @@ impl ExtensionManager { Ok(names) } + /// 执行 Extension 目录下的自定义 SQL 迁移脚本。 + /// + /// 迁移文件放在 `extensions/{ext_id}/migrations/` 目录下, + /// 文件名格式:`{version}.sql`(如 `0.2.0.sql`)。 + /// 仅执行版本号大于 old_version 且小于等于 new_version 的脚本。 + async fn run_extension_migrations( + &self, + ext_root: &Path, + old_version: &str, + new_version: &str, + ) -> AppResult<()> { + let migrations_dir = ext_root.join("migrations"); + if !migrations_dir.exists() { + tracing::debug!( + "no migrations/ directory for extension, skipping custom migrations" + ); + return Ok(()); + } + + let entries = std::fs::read_dir(&migrations_dir).map_err(|e| { + AppError::Internal(anyhow::anyhow!( + "cannot read migrations dir {migrations_dir:?}: {e}" + )) + })?; + + let mut migration_files: Vec<(String, std::path::PathBuf)> = Vec::new(); + for entry in entries { + let entry = entry.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?; + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "sql") { + let stem = path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + migration_files.push((stem, path)); + } + } + + migration_files.sort_by(|a, b| a.0.cmp(&b.0)); + + for (version, path) in &migration_files { + if version.as_str() > old_version && version.as_str() <= new_version { + let sql = + std::fs::read_to_string(path) + .map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?; + tracing::info!("running extension migration: {} ({})", path.display(), version); + sqlx::query(&sql) + .execute(&self.pool) + .await + .map_err(|e| { + AppError::Internal(anyhow::anyhow!( + "migration {} failed: {e}", + path.display() + )) + })?; + } + } + + Ok(()) + } + /// 获取所有已加载 Extension 列表 pub fn list_loaded(&self) -> Vec { self.extensions diff --git a/src/handlers/sse.rs b/src/handlers/sse.rs index 82958f25..dd0fe263 100644 --- a/src/handlers/sse.rs +++ b/src/handlers/sse.rs @@ -3,6 +3,7 @@ //! 将 `EventBus` 的事件流转换为 HTTP SSE 推送,供前端实时接收业务事件。 //! 支持按事件类型过滤和心跳保活。 +use std::borrow::Cow; use std::convert::Infallible; use std::sync::Arc; @@ -25,25 +26,26 @@ pub struct SubscribeQuery { } /// 提取事件类型名称 -pub fn event_type_name(event: &Event) -> &'static str { +pub fn event_type_name(event: &Event) -> Cow<'static, str> { match event { - Event::PostCreating { .. } => "PostCreating", - Event::PostCreated { .. } => "PostCreated", - Event::PostUpdated { .. } => "PostUpdated", - Event::PostDeleted { .. } => "PostDeleted", - Event::CommentCreated { .. } => "CommentCreated", - Event::CommentDeleted { .. } => "CommentDeleted", - Event::ContentCreating { .. } => "ContentCreating", - Event::ContentCreated { .. } => "ContentCreated", - Event::ContentUpdating { .. } => "ContentUpdating", - Event::ContentUpdated { .. } => "ContentUpdated", - Event::ContentDeleted { .. } => "ContentDeleted", - Event::UserRegistered { .. } => "UserRegistered", - Event::UserLoggedIn { .. } => "UserLoggedIn", - Event::MediaUploaded { .. } => "MediaUploaded", - Event::MediaDeleted { .. } => "MediaDeleted", - Event::PasswordResetRequested { .. } => "PasswordResetRequested", - Event::EmailVerificationRequested { .. } => "EmailVerificationRequested", + Event::PostCreating { .. } => Cow::Borrowed("PostCreating"), + Event::PostCreated { .. } => Cow::Borrowed("PostCreated"), + Event::PostUpdated { .. } => Cow::Borrowed("PostUpdated"), + Event::PostDeleted { .. } => Cow::Borrowed("PostDeleted"), + Event::CommentCreated { .. } => Cow::Borrowed("CommentCreated"), + Event::CommentDeleted { .. } => Cow::Borrowed("CommentDeleted"), + Event::ContentCreating { .. } => Cow::Borrowed("ContentCreating"), + Event::ContentCreated { .. } => Cow::Borrowed("ContentCreated"), + Event::ContentUpdating { .. } => Cow::Borrowed("ContentUpdating"), + Event::ContentUpdated { .. } => Cow::Borrowed("ContentUpdated"), + Event::ContentDeleted { .. } => Cow::Borrowed("ContentDeleted"), + Event::UserRegistered { .. } => Cow::Borrowed("UserRegistered"), + Event::UserLoggedIn { .. } => Cow::Borrowed("UserLoggedIn"), + Event::MediaUploaded { .. } => Cow::Borrowed("MediaUploaded"), + Event::MediaDeleted { .. } => Cow::Borrowed("MediaDeleted"), + Event::PasswordResetRequested { .. } => Cow::Borrowed("PasswordResetRequested"), + Event::EmailVerificationRequested { .. } => Cow::Borrowed("EmailVerificationRequested"), + Event::Custom { event_type, .. } => Cow::Owned(event_type.clone()), } } @@ -75,7 +77,7 @@ pub async fn subscribe( let type_name = event_type_name(arc_event.as_ref()); - if !filter_types.is_empty() && !filter_types.iter().any(|f| f == type_name) { + if !filter_types.is_empty() && !filter_types.iter().any(|f| f == type_name.as_ref()) { return None; } @@ -170,9 +172,17 @@ mod tests { "MediaUploaded", ), (Event::MediaDeleted { id: "1".into() }, "MediaDeleted"), + ( + Event::Custom { + source: "test-plugin".into(), + event_type: "OrderCreated".into(), + data: serde_json::json!({"order_id": "o1"}), + }, + "OrderCreated", + ), ]; - assert_eq!(cases.len(), 10, "所有 Event 变体都应有对应名称"); + assert_eq!(cases.len(), 11, "所有 Event 变体都应有对应名称"); for (event, expected_name) in &cases { assert_eq!(event_type_name(event), *expected_name); } @@ -252,7 +262,7 @@ mod tests { .unwrap() .unwrap(); let name = event_type_name(event.as_ref()); - if allowed.iter().any(|a| a == name) { + if allowed.iter().any(|a| *a == name.as_ref()) { received.push(name.to_string()); } } @@ -292,7 +302,7 @@ mod tests { .unwrap() .unwrap(); let name = event_type_name(event.as_ref()); - if allowed.iter().any(|a| a == name) { + if allowed.iter().any(|a| *a == name.as_ref()) { received.push(name.to_string()); } } diff --git a/src/handlers/ws.rs b/src/handlers/ws.rs index a621197b..1e80b6e9 100644 --- a/src/handlers/ws.rs +++ b/src/handlers/ws.rs @@ -107,7 +107,7 @@ async fn handle_socket(socket: WebSocket, state: crate::AppState, initial_filter let type_name = event_type_name(arc_event.as_ref()); if !filter_types.is_empty() - && !filter_types.iter().any(|f| f == type_name) + && !filter_types.iter().any(|f| f == type_name.as_ref()) { continue; } diff --git a/src/plugins.rs b/src/plugins.rs index 06ace548..2f015d44 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -208,6 +208,7 @@ pub struct PluginInfoResponse { /// 设置 `PluginManager` 的可选依赖 pub struct PluginManagerOptions { pub pool: Option, + pub event_bus: Option, } impl PluginManager { @@ -215,7 +216,7 @@ impl PluginManager { /// /// 返回 `Arc` 是因为热重载 watcher 需要持有自引用来执行 reload。 pub async fn new(config: Arc) -> Arc { - Self::new_with_options(config, PluginManagerOptions { pool: None }).await + Self::new_with_options(config, PluginManagerOptions { pool: None, event_bus: None }).await } /// 带可选依赖创建 `PluginManager` @@ -259,13 +260,13 @@ impl PluginManager { }; #[cfg(feature = "plugin-js")] - let js_engine = JsEngine::new(&config, opts.pool.clone()) + let js_engine = JsEngine::new(&config, opts.pool.clone(), opts.event_bus.clone()) .await .expect("failed to create js engine"); #[cfg(feature = "plugin-lua")] let lua_engine = - LuaEngine::new(&config, opts.pool.clone()).expect("failed to create lua engine"); + LuaEngine::new(&config, opts.pool.clone(), opts.event_bus).expect("failed to create lua engine"); let (reload_tx, reload_rx) = tokio::sync::mpsc::channel::(32); let (event_tx, _) = tokio::sync::broadcast::channel::>(256); @@ -2932,6 +2933,7 @@ end config, PluginManagerOptions { pool: Some(pool.clone()), + event_bus: None, }, ) .await; diff --git a/src/plugins/engine_js.rs b/src/plugins/engine_js.rs index e53c26a1..303a2cf8 100644 --- a/src/plugins/engine_js.rs +++ b/src/plugins/engine_js.rs @@ -54,10 +54,15 @@ pub struct JsEngine { config: Arc, pool: Option, pool_size: usize, + event_bus: Option, } impl JsEngine { - pub async fn new(config: &AppConfig, pool: Option) -> anyhow::Result { + pub async fn new( + config: &AppConfig, + pool: Option, + event_bus: Option, + ) -> anyhow::Result { let default_memory_limit_bytes = (config.plugin_max_memory_mb as usize) * 1024 * 1024; let pool_size = config.plugin_js_pool_size.max(1) as usize; @@ -69,6 +74,7 @@ impl JsEngine { config: Arc::new(config.clone()), pool, pool_size, + event_bus, }) } @@ -99,6 +105,7 @@ impl JsEngine { plugin_id, perms, self.pool.clone(), + self.event_bus.clone(), )?; ctx.eval::<(), _>(code)?; Ok::<_, rquickjs::Error>(()) @@ -302,13 +309,13 @@ mod tests { #[tokio::test] async fn js_engine_create() { - let engine = JsEngine::new(&test_config(), None).await; + let engine = JsEngine::new(&test_config(), None, None).await; assert!(engine.is_ok()); } #[tokio::test] async fn js_engine_load_and_call_filter() { - let engine = JsEngine::new(&test_config(), None).await.unwrap(); + let engine = JsEngine::new(&test_config(), None, None).await.unwrap(); let code = r#" var Plugin = { @@ -338,7 +345,7 @@ var Plugin = { #[tokio::test] async fn js_engine_call_filter_missing_plugin() { - let engine = JsEngine::new(&test_config(), None).await.unwrap(); + let engine = JsEngine::new(&test_config(), None, None).await.unwrap(); let result: Option = engine .call_filter("nonexistent", "on_post_creating", &serde_json::json!({})) .await @@ -348,7 +355,7 @@ var Plugin = { #[tokio::test] async fn js_engine_call_filter_missing_function() { - let engine = JsEngine::new(&test_config(), None).await.unwrap(); + let engine = JsEngine::new(&test_config(), None, None).await.unwrap(); let code = r#"var Plugin = {};"#; engine @@ -365,7 +372,7 @@ var Plugin = { #[tokio::test] async fn js_engine_call_action() { - let engine = JsEngine::new(&test_config(), None).await.unwrap(); + let engine = JsEngine::new(&test_config(), None, None).await.unwrap(); let code = r#" var Plugin = { @@ -392,7 +399,7 @@ var Plugin = { #[tokio::test] async fn js_engine_call_string_filter() { - let engine = JsEngine::new(&test_config(), None).await.unwrap(); + let engine = JsEngine::new(&test_config(), None, None).await.unwrap(); let code = r#" var Plugin = { @@ -421,7 +428,7 @@ var Plugin = { #[tokio::test] async fn js_engine_unload_plugin() { - let engine = JsEngine::new(&test_config(), None).await.unwrap(); + let engine = JsEngine::new(&test_config(), None, None).await.unwrap(); let code = r#"var Plugin = {};"#; engine @@ -436,7 +443,7 @@ var Plugin = { #[tokio::test] async fn js_engine_multiple_plugins() { - let engine = JsEngine::new(&test_config(), None).await.unwrap(); + let engine = JsEngine::new(&test_config(), None, None).await.unwrap(); for i in 0..3 { let code = format!( @@ -453,7 +460,7 @@ var Plugin = { #[tokio::test] async fn js_engine_host_log_available() { - let engine = JsEngine::new(&test_config(), None).await.unwrap(); + let engine = JsEngine::new(&test_config(), None, None).await.unwrap(); let code = r#" var Plugin = { @@ -472,7 +479,7 @@ var Plugin = { #[tokio::test] async fn js_engine_host_get_config_returns_value() { - let engine = JsEngine::new(&test_config(), None).await.unwrap(); + let engine = JsEngine::new(&test_config(), None, None).await.unwrap(); let code = r#" var Plugin = { @@ -502,7 +509,7 @@ var Plugin = { #[tokio::test] async fn js_engine_syntax_error_fails_load() { - let engine = JsEngine::new(&test_config(), None).await.unwrap(); + let engine = JsEngine::new(&test_config(), None, None).await.unwrap(); let result = engine .load_plugin_default("test-bad-syntax", "var !!!invalid!!!") .await; @@ -513,7 +520,7 @@ var Plugin = { async fn js_engine_timeout_interrupts_long_execution() { let mut config = (*test_config()).clone(); config.plugin_default_timeout_ms = 100; - let engine = JsEngine::new(&Arc::new(config), None).await.unwrap(); + let engine = JsEngine::new(&Arc::new(config), None, None).await.unwrap(); let code = r#" var Plugin = { @@ -537,7 +544,7 @@ var Plugin = { #[tokio::test] async fn js_engine_filter_chain_multiple_plugins() { - let engine = JsEngine::new(&test_config(), None).await.unwrap(); + let engine = JsEngine::new(&test_config(), None, None).await.unwrap(); let code_a = r#" var Plugin = { @@ -579,7 +586,7 @@ var Plugin = { #[tokio::test] async fn js_engine_action_exception_does_not_crash() { - let engine = JsEngine::new(&test_config(), None).await.unwrap(); + let engine = JsEngine::new(&test_config(), None, None).await.unwrap(); let code = r#" var Plugin = { diff --git a/src/plugins/engine_lua.rs b/src/plugins/engine_lua.rs index 75e2d572..a1601e3e 100644 --- a/src/plugins/engine_lua.rs +++ b/src/plugins/engine_lua.rs @@ -52,10 +52,15 @@ pub struct LuaEngine { config: Arc, pool: Option, pool_size: usize, + event_bus: Option, } impl LuaEngine { - pub fn new(config: &AppConfig, pool: Option) -> anyhow::Result { + pub fn new( + config: &AppConfig, + pool: Option, + event_bus: Option, + ) -> anyhow::Result { let pool_size = config.plugin_lua_pool_size.max(1) as usize; Ok(Self { pools: Mutex::new(HashMap::new()), @@ -63,6 +68,7 @@ impl LuaEngine { config: Arc::new(config.clone()), pool, pool_size, + event_bus, }) } @@ -89,6 +95,7 @@ impl LuaEngine { plugin_id.to_string(), permissions.clone(), self.pool.clone(), + self.event_bus.clone(), )?; lua.load(code).exec()?; Ok(lua) @@ -264,13 +271,13 @@ mod tests { #[tokio::test] async fn lua_engine_create() { - let engine = LuaEngine::new(&test_config(), None); + let engine = LuaEngine::new(&test_config(), None, None); assert!(engine.is_ok()); } #[tokio::test] async fn lua_engine_load_and_call_filter() { - let engine = LuaEngine::new(&test_config(), None).unwrap(); + let engine = LuaEngine::new(&test_config(), None, None).unwrap(); let code = r#" Plugin = { @@ -299,7 +306,7 @@ Plugin = { #[tokio::test] async fn lua_engine_call_filter_missing_plugin() { - let engine = LuaEngine::new(&test_config(), None).unwrap(); + let engine = LuaEngine::new(&test_config(), None, None).unwrap(); let result: Option = engine .call_filter("nonexistent", "on_post_creating", &serde_json::json!({})) .await @@ -309,7 +316,7 @@ Plugin = { #[tokio::test] async fn lua_engine_call_filter_missing_function() { - let engine = LuaEngine::new(&test_config(), None).unwrap(); + let engine = LuaEngine::new(&test_config(), None, None).unwrap(); engine .load_plugin_default("test-nofunc", "Plugin = {}") .await @@ -324,7 +331,7 @@ Plugin = { #[tokio::test] async fn lua_engine_call_action() { - let engine = LuaEngine::new(&test_config(), None).unwrap(); + let engine = LuaEngine::new(&test_config(), None, None).unwrap(); let code = r#" Plugin = { @@ -350,7 +357,7 @@ Plugin = { #[tokio::test] async fn lua_engine_call_string_filter() { - let engine = LuaEngine::new(&test_config(), None).unwrap(); + let engine = LuaEngine::new(&test_config(), None, None).unwrap(); let code = r#" Plugin = { @@ -379,7 +386,7 @@ Plugin = { #[tokio::test] async fn lua_engine_unload_plugin() { - let engine = LuaEngine::new(&test_config(), None).unwrap(); + let engine = LuaEngine::new(&test_config(), None, None).unwrap(); engine .load_plugin_default("test-unload", "Plugin = {}") .await @@ -392,7 +399,7 @@ Plugin = { #[tokio::test] async fn lua_engine_multiple_plugins() { - let engine = LuaEngine::new(&test_config(), None).unwrap(); + let engine = LuaEngine::new(&test_config(), None, None).unwrap(); for i in 0..3 { let code = format!( @@ -409,7 +416,7 @@ Plugin = { #[tokio::test] async fn lua_engine_syntax_error_fails_load() { - let engine = LuaEngine::new(&test_config(), None).unwrap(); + let engine = LuaEngine::new(&test_config(), None, None).unwrap(); let result = engine .load_plugin_default("test-bad", "function !!!invalid!!!") .await; @@ -420,7 +427,7 @@ Plugin = { async fn lua_engine_timeout_interrupts_long_execution() { let mut config = (*test_config()).clone(); config.plugin_default_timeout_ms = 100; - let engine = LuaEngine::new(&Arc::new(config), None).unwrap(); + let engine = LuaEngine::new(&Arc::new(config), None, None).unwrap(); let code = r#" Plugin = { @@ -444,7 +451,7 @@ Plugin = { #[tokio::test] async fn lua_engine_action_exception_does_not_crash() { - let engine = LuaEngine::new(&test_config(), None).unwrap(); + let engine = LuaEngine::new(&test_config(), None, None).unwrap(); let code = r#" Plugin = { @@ -470,7 +477,7 @@ Plugin = { #[tokio::test] async fn lua_engine_host_get_config_returns_value() { - let engine = LuaEngine::new(&test_config(), None).unwrap(); + let engine = LuaEngine::new(&test_config(), None, None).unwrap(); let code = r#" Plugin = { @@ -500,7 +507,7 @@ Plugin = { #[tokio::test] async fn lua_engine_no_io_os_libs() { - let engine = LuaEngine::new(&test_config(), None).unwrap(); + let engine = LuaEngine::new(&test_config(), None, None).unwrap(); let code = r#" Plugin = {} @@ -516,7 +523,7 @@ if debug ~= nil then error("debug should not be available") end async fn lua_engine_memory_limit_enforced() { let mut config = (*test_config()).clone(); config.plugin_max_memory_mb = 1; - let engine = LuaEngine::new(&Arc::new(config), None).unwrap(); + let engine = LuaEngine::new(&Arc::new(config), None, None).unwrap(); let code = r#" local t = {} diff --git a/src/plugins/host.rs b/src/plugins/host.rs index 0eba6b4f..bf5e8fc4 100644 --- a/src/plugins/host.rs +++ b/src/plugins/host.rs @@ -40,8 +40,8 @@ impl Host for Arc { (**self).get_post(&slug) } - fn db_query(&mut self, sql: String) -> String { - (**self).db_query(&sql) + fn db_query(&mut self, sql: String, params: Option) -> String { + (**self).db_query(&sql, params.as_deref()) } fn db_execute(&mut self, sql: String, params: Option) -> String { @@ -86,4 +86,8 @@ impl Host for Arc { fn fs_stat(&mut self, path: String) -> Option { (**self).fs_stat(&path).ok() } + + fn emit_event(&mut self, event_type: String, data: String) -> String { + (**self).emit_event(&event_type, &data) + } } diff --git a/src/plugins/host_common.rs b/src/plugins/host_common.rs index 625a215e..f3c722ca 100644 --- a/src/plugins/host_common.rs +++ b/src/plugins/host_common.rs @@ -11,6 +11,7 @@ use crate::db::Pool; use crate::plugins::Permissions; use crate::plugins::permissions::PermissionChecker; use crate::plugins::vfs::VirtualFs; +use crate::eventbus::{EventBus, Event}; use sqlx::Arguments; use std::sync::Mutex; @@ -31,6 +32,7 @@ pub struct HostContext { pool: Option, tx: Mutex>, vfs: Option>, + event_bus: Option, } impl Clone for HostContext { @@ -43,6 +45,7 @@ impl Clone for HostContext { pool: self.pool.clone(), tx: Mutex::new(None), vfs: self.vfs.clone(), + event_bus: self.event_bus.clone(), } } } @@ -65,9 +68,15 @@ impl HostContext { pool, tx: Mutex::new(None), vfs: None, + event_bus: None, } } + /// 设置事件总线(在 PluginManager 初始化后调用) + pub fn set_event_bus(&mut self, bus: EventBus) { + self.event_bus = Some(bus); + } + /// 返回插件 ID #[must_use] pub fn plugin_id(&self) -> &str { @@ -213,9 +222,12 @@ impl HostContext { }) } - /// 执行只读 SQL 查询(返回 JSON 数组字符串) + /// 执行只读 SQL 查询(返回 JSON 数组字符串)。 + /// + /// 当 `params_json` 为 `Some` 时使用参数化查询(防 SQL 注入), + /// 为 `None` 时执行原始 SQL(向后兼容)。 #[must_use] - pub fn db_query(&self, sql: &str) -> String { + pub fn db_query(&self, sql: &str, params_json: Option<&str>) -> String { if !PermissionChecker::is_readonly_query(sql) { return "error: only SELECT queries are allowed".to_string(); } @@ -232,11 +244,24 @@ impl HostContext { if !PermissionChecker::is_table_readable(&self.permissions, &table) { return format!("error: no read permission for table: {table}"); } + let parsed_params = Self::parse_params(params_json); + if let Err(e) = &parsed_params { + return format!(r#"{{"error":"invalid params: {e}"}}"#); + } let handle = tokio::runtime::Handle::current(); let sql = crate::db::dialect::translate(sql).into_owned(); tokio::task::block_in_place(|| { match handle.block_on(async { - let rows = sqlx::query(&sql).fetch_all(pool).await?; + let rows = match parsed_params.unwrap() { + Some(params) => { + let mut args = sqlx::sqlite::SqliteArguments::default(); + for p in ¶ms { + Self::add_param(&mut args, p); + } + sqlx::query_with(&sql, args).fetch_all(pool).await? + } + None => sqlx::query(&sql).fetch_all(pool).await?, + }; let json = crate::plugins::rows_to_json(&rows); Ok::<_, sqlx::Error>(json) }) { @@ -270,23 +295,9 @@ impl HostContext { if !PermissionChecker::is_table_writable(&self.permissions, &table) { return format!(r#"{{"error":"no write permission for table: {table}"}}"#); } - let parsed_params = match params_json { - Some(pj) if !pj.is_empty() => { - let params: Vec = match serde_json::from_str(pj) { - Ok(p) => p, - Err(e) => return format!(r#"{{"error":"invalid params JSON: {e}"}}"#), - }; - for p in ¶ms { - if matches!( - p, - serde_json::Value::Array(_) | serde_json::Value::Object(_) - ) { - return format!(r#"{{"error":"unsupported param type: {p}"}}"#); - } - } - Some(params) - } - _ => None, + let parsed_params = match Self::parse_params(params_json) { + Ok(p) => p, + Err(e) => return format!(r#"{{"error":"{e}"}}"#), }; let tx_guard = self.tx.lock().unwrap_or_else(|e| e.into_inner()); @@ -317,25 +328,7 @@ impl HostContext { Some(params) => { let mut args = sqlx::sqlite::SqliteArguments::default(); for p in ¶ms { - match p { - serde_json::Value::String(s) => { - args.add(s.clone()).ok(); - } - serde_json::Value::Number(n) => { - if let Some(i) = n.as_i64() { - args.add(i).ok(); - } else { - args.add(n.as_f64().unwrap_or(0.0)).ok(); - } - } - serde_json::Value::Bool(b) => { - args.add(*b).ok(); - } - serde_json::Value::Null => { - args.add(Option::::None).ok(); - } - _ => {} - } + Self::add_param(&mut args, p); } handle.block_on(async { sqlx::query_with(&sql, args).execute(pool).await }) } @@ -477,6 +470,74 @@ impl HostContext { let info = vfs.stat(path).map_err(|e| e.to_string())?; serde_json::to_string(&info).map_err(|e| format!("error: {e}")) } + + /// 插件主动触发自定义事件,通过 EventBus 广播。 + /// + /// 其他插件可通过 Hook 监听,WebSocket 客户端也会收到推送。 + /// 返回 `{"ok":true}` 或 `{"error":"..."}`。 + #[must_use] + pub fn emit_event(&self, event_type: &str, data: &str) -> String { + match &self.event_bus { + Some(bus) => { + let data_value: serde_json::Value = serde_json::from_str(data) + .unwrap_or(serde_json::Value::String(data.to_string())); + bus.emit(Event::Custom { + source: self.plugin_id.clone(), + event_type: event_type.to_string(), + data: data_value, + }); + tracing::info!( + "[plugin:{}] emitted custom event: {}", + self.plugin_id, + event_type + ); + r#"{"ok":true}"#.to_string() + } + None => r#"{"error":"event bus not available"}"#.to_string(), + } + } + + /// 解析参数 JSON 为 Vec + fn parse_params( + params_json: Option<&str>, + ) -> Result>, String> { + match params_json { + Some(pj) if !pj.is_empty() => { + let params: Vec = serde_json::from_str(pj) + .map_err(|e| format!("invalid params JSON: {e}"))?; + for p in ¶ms { + if matches!(p, serde_json::Value::Array(_) | serde_json::Value::Object(_)) { + return Err(format!("unsupported param type: {p}")); + } + } + Ok(Some(params)) + } + _ => Ok(None), + } + } + + /// 将单个 JSON Value 添加到 sqlx 参数列表 + fn add_param(args: &mut sqlx::sqlite::SqliteArguments, p: &serde_json::Value) { + match p { + serde_json::Value::String(s) => { + args.add(s.clone()).ok(); + } + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + args.add(i).ok(); + } else { + args.add(n.as_f64().unwrap_or(0.0)).ok(); + } + } + serde_json::Value::Bool(b) => { + args.add(*b).ok(); + } + serde_json::Value::Null => { + args.add(Option::::None).ok(); + } + _ => {} + } + } } /// 在连接上执行参数化或原始写操作 @@ -585,7 +646,7 @@ mod tests { fn host_context_db_query_rejects_non_select() { let config = make_test_config(); let ctx = HostContext::new("test", config, "p1".into(), Permissions::default(), None); - let result = ctx.db_query("DELETE FROM posts"); + let result = ctx.db_query("DELETE FROM posts", None); assert!(result.contains("error")); assert!(!result.contains("status")); } @@ -615,7 +676,7 @@ mod tests { fn host_context_db_query_returns_error_without_pool() { let config = make_test_config(); let ctx = HostContext::new("test", config, "p1".into(), Permissions::default(), None); - let result = ctx.db_query("SELECT 1"); + let result = ctx.db_query("SELECT 1", None); assert!(result.contains("no database access")); } @@ -653,11 +714,11 @@ mod tests { let config = make_test_config(); let ctx = HostContext::new("test", config, "p1".into(), Permissions::default(), None); assert!( - ctx.db_query("INSERT INTO posts VALUES(1)") + ctx.db_query("INSERT INTO posts VALUES(1)", None) .contains("error") ); - assert!(ctx.db_query("UPDATE posts SET title='x'").contains("error")); - assert!(ctx.db_query("DELETE FROM posts").contains("error")); + assert!(ctx.db_query("UPDATE posts SET title='x'", None).contains("error")); + assert!(ctx.db_query("DELETE FROM posts", None).contains("error")); } #[test] @@ -669,7 +730,7 @@ mod tests { }; // No pool → first check is "no database access", but even with pool it should fail let ctx = HostContext::new("test", config, "p1".into(), perms, None); - let result = ctx.db_query("SELECT * FROM posts"); + let result = ctx.db_query("SELECT * FROM posts", None); assert!(result.contains("no database access")); } @@ -787,7 +848,7 @@ mod tests { let config = make_test_config(); let ctx = HostContext::new("test", config, "p1".into(), perms, Some(pool)); - let result = ctx.db_query("SELECT COUNT(*) as cnt FROM posts"); + let result = ctx.db_query("SELECT COUNT(*) as cnt FROM posts", None); assert!(!result.contains("error")); assert!(result.contains("cnt")); } @@ -807,7 +868,7 @@ mod tests { let config = make_test_config(); let ctx = HostContext::new("test", config, "p1".into(), perms, Some(pool)); - let result = ctx.db_query("SELECT * FROM posts"); + let result = ctx.db_query("SELECT * FROM posts", None); assert!(result.contains("no read permission")); } @@ -826,7 +887,7 @@ mod tests { let config = make_test_config(); let ctx = HostContext::new("test", config, "p1".into(), perms, Some(pool)); - let result = ctx.db_query("SELECT COUNT(*) as cnt FROM posts"); + let result = ctx.db_query("SELECT COUNT(*) as cnt FROM posts", None); assert!(!result.contains("error")); } diff --git a/src/plugins/js_host.rs b/src/plugins/js_host.rs index 1791420b..fccdc6ec 100644 --- a/src/plugins/js_host.rs +++ b/src/plugins/js_host.rs @@ -19,11 +19,17 @@ pub fn register_host_functions( plugin_id: String, permissions: Permissions, pool: Option, + event_bus: Option, ) -> rquickjs::Result<()> { let global = ctx.globals(); let host = Object::new(ctx.clone())?; - let host_ctx = Arc::new(HostContext::new("js", config, plugin_id, permissions, pool)); + let mut hc_inner = + HostContext::new("js", config, plugin_id, permissions, pool); + if let Some(bus) = event_bus { + hc_inner.set_event_bus(bus); + } + let host_ctx = Arc::new(hc_inner); let hc = host_ctx.clone(); let log_fn = Function::new(ctx.clone(), move |level: String, msg: String| { @@ -68,9 +74,12 @@ pub fn register_host_functions( host.set("getPost", get_post_fn)?; let hc = host_ctx.clone(); - let db_query_fn = Function::new(ctx.clone(), move |sql: String| -> String { - hc.db_query(&sql) - })?; + let db_query_fn = Function::new( + ctx.clone(), + move |sql: String, params: Option| -> String { + hc.db_query(&sql, params.as_deref()) + }, + )?; host.set("dbQuery", db_query_fn)?; let hc = host_ctx.clone(); @@ -124,12 +133,19 @@ pub fn register_host_functions( })?; host.set("fsList", fs_list_fn)?; - let hc = host_ctx; - let fs_stat_fn = Function::new(ctx, move |path: String| -> Option { + let hc = host_ctx.clone(); + let fs_stat_fn = Function::new(ctx.clone(), move |path: String| -> Option { hc.fs_stat(&path).ok() })?; host.set("fsStat", fs_stat_fn)?; + let hc = host_ctx; + let emit_event_fn = + Function::new(ctx, move |event_type: String, data: String| -> String { + hc.emit_event(&event_type, &data) + })?; + host.set("emitEvent", emit_event_fn)?; + global.set("Host", host)?; Ok(()) } @@ -151,7 +167,7 @@ mod tests { let perms = Permissions::default(); ctx.with(|ctx| { - register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None) + register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None) .unwrap(); let global = ctx.globals(); @@ -178,7 +194,7 @@ mod tests { }; ctx.with(|ctx| { - register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None) + register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None) .unwrap(); let global = ctx.globals(); @@ -202,7 +218,7 @@ mod tests { let perms = Permissions::default(); ctx.with(|ctx| { - register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None) + register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None) .unwrap(); let global = ctx.globals(); @@ -223,7 +239,7 @@ mod tests { let perms = Permissions::default(); ctx.with(|ctx| { - register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None) + register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None) .unwrap(); let global = ctx.globals(); @@ -244,7 +260,7 @@ mod tests { let perms = Permissions::default(); ctx.with(|ctx| { - register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None) + register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None) .unwrap(); let global = ctx.globals(); @@ -265,7 +281,7 @@ mod tests { let perms = Permissions::default(); ctx.with(|ctx| { - register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None) + register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None) .unwrap(); let global = ctx.globals(); @@ -286,7 +302,7 @@ mod tests { let perms = Permissions::default(); ctx.with(|ctx| { - register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None) + register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None) .unwrap(); let global = ctx.globals(); @@ -307,14 +323,14 @@ mod tests { let perms = Permissions::default(); ctx.with(|ctx| { - register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None) + register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None) .unwrap(); let global = ctx.globals(); let host: Object = global.get("Host").unwrap(); let db_fn: Function = host.get("dbQuery").unwrap(); - let result: String = db_fn.call(("SELECT 1",)).unwrap(); + let result: String = db_fn.call(("SELECT 1", rquickjs::Undefined)).unwrap(); assert!(result.contains("no database access")); }) .await; @@ -328,14 +344,14 @@ mod tests { let perms = Permissions::default(); ctx.with(|ctx| { - register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None) + register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None) .unwrap(); let global = ctx.globals(); let host: Object = global.get("Host").unwrap(); let db_fn: Function = host.get("dbQuery").unwrap(); - let result: String = db_fn.call(("DELETE FROM posts",)).unwrap(); + let result: String = db_fn.call(("DELETE FROM posts", rquickjs::Undefined)).unwrap(); assert!(result.contains("only SELECT")); }) .await; @@ -349,7 +365,7 @@ mod tests { let perms = Permissions::default(); ctx.with(|ctx| { - register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None) + register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None) .unwrap(); let global = ctx.globals(); diff --git a/src/plugins/lua_host.rs b/src/plugins/lua_host.rs index 81525d74..faa07b54 100644 --- a/src/plugins/lua_host.rs +++ b/src/plugins/lua_host.rs @@ -9,8 +9,8 @@ use mlua::Lua; use crate::config::app::AppConfig; use crate::db::Pool; -use crate::plugins::Permissions; use crate::plugins::host_common::HostContext; +use crate::plugins::Permissions; /// 注册宿主函数到 Lua 全局作用域。 pub fn register_host_functions( @@ -19,17 +19,16 @@ pub fn register_host_functions( plugin_id: String, permissions: Permissions, pool: Option, + event_bus: Option, ) -> anyhow::Result<()> { let globals = lua.globals(); let host = lua.create_table()?; - let host_ctx = Arc::new(HostContext::new( - "lua", - config, - plugin_id, - permissions, - pool, - )); + let mut hc_inner = HostContext::new("lua", config, plugin_id, permissions, pool); + if let Some(bus) = event_bus { + hc_inner.set_event_bus(bus); + } + let host_ctx = Arc::new(hc_inner); let hc = host_ctx.clone(); let log_fn = lua.create_function(move |_, (level, msg): (String, String)| { @@ -79,9 +78,12 @@ pub fn register_host_functions( host.set("getPost", get_post_fn)?; let hc = host_ctx.clone(); - let db_query_fn = lua.create_function(move |lua, sql: String| { - Ok(mlua::Value::String(lua.create_string(hc.db_query(&sql))?)) - })?; + let db_query_fn = + lua.create_function(move |lua, (sql, params): (String, Option)| { + Ok(mlua::Value::String( + lua.create_string(hc.db_query(&sql, params.as_deref()))?, + )) + })?; host.set("dbQuery", db_query_fn)?; let hc = host_ctx.clone(); @@ -151,13 +153,21 @@ pub fn register_host_functions( })?; host.set("fsList", fs_list_fn)?; - let hc = host_ctx; + let hc = host_ctx.clone(); let fs_stat_fn = lua.create_function(move |lua, path: String| match hc.fs_stat(&path) { Ok(json) => Ok(mlua::Value::String(lua.create_string(&json)?)), Err(_) => Ok(mlua::Value::Nil), })?; host.set("fsStat", fs_stat_fn)?; + let hc = host_ctx; + let emit_event_fn = lua.create_function(move |lua, (event_type, data): (String, String)| { + Ok(mlua::Value::String( + lua.create_string(hc.emit_event(&event_type, &data))?, + )) + })?; + host.set("emitEvent", emit_event_fn)?; + globals.set("Host", host)?; Ok(()) } @@ -183,7 +193,7 @@ mod tests { let lua = create_sandboxed_lua(); let config = make_test_config(); let perms = Permissions::default(); - register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap(); + register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap(); let globals = lua.globals(); let host: mlua::Table = globals.get("Host").unwrap(); @@ -204,7 +214,7 @@ mod tests { config: vec!["app.*".into()], ..Permissions::default() }; - register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap(); + register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap(); let globals = lua.globals(); let host: mlua::Table = globals.get("Host").unwrap(); @@ -225,7 +235,7 @@ mod tests { let lua = create_sandboxed_lua(); let config = make_test_config(); let perms = Permissions::default(); - register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap(); + register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap(); let globals = lua.globals(); let host: mlua::Table = globals.get("Host").unwrap(); @@ -240,7 +250,7 @@ mod tests { let lua = create_sandboxed_lua(); let config = make_test_config(); let perms = Permissions::default(); - register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap(); + register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap(); let globals = lua.globals(); let host: mlua::Table = globals.get("Host").unwrap(); @@ -255,7 +265,7 @@ mod tests { let lua = create_sandboxed_lua(); let config = make_test_config(); let perms = Permissions::default(); - register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap(); + register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap(); let globals = lua.globals(); let host: mlua::Table = globals.get("Host").unwrap(); @@ -270,7 +280,7 @@ mod tests { let lua = create_sandboxed_lua(); let config = make_test_config(); let perms = Permissions::default(); - register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap(); + register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap(); let globals = lua.globals(); let host: mlua::Table = globals.get("Host").unwrap(); @@ -285,7 +295,7 @@ mod tests { let lua = create_sandboxed_lua(); let config = make_test_config(); let perms = Permissions::default(); - register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap(); + register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap(); let globals = lua.globals(); let host: mlua::Table = globals.get("Host").unwrap(); @@ -300,7 +310,7 @@ mod tests { let lua = create_sandboxed_lua(); let config = make_test_config(); let perms = Permissions::default(); - register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap(); + register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap(); let globals = lua.globals(); let host: mlua::Table = globals.get("Host").unwrap(); @@ -315,7 +325,7 @@ mod tests { let lua = create_sandboxed_lua(); let config = make_test_config(); let perms = Permissions::default(); - register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap(); + register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap(); let globals = lua.globals(); let host: mlua::Table = globals.get("Host").unwrap(); @@ -330,7 +340,7 @@ mod tests { let lua = create_sandboxed_lua(); let config = make_test_config(); let perms = Permissions::default(); - register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap(); + register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap(); let globals = lua.globals(); let host: mlua::Table = globals.get("Host").unwrap(); diff --git a/src/server.rs b/src/server.rs index 9e3a848a..e7d6ccad 100644 --- a/src/server.rs +++ b/src/server.rs @@ -152,6 +152,7 @@ async fn build_app(config: &AppConfig, limiters: RateLimiterSet) -> anyhow::Resu Arc::new(config.clone()), crate::plugins::PluginManagerOptions { pool: Some(pool.clone()), + event_bus: Some(eventbus.clone()), }, ) .await; diff --git a/src/worker/dispatcher.rs b/src/worker/dispatcher.rs index 0f2f45fb..1d9e0496 100644 --- a/src/worker/dispatcher.rs +++ b/src/worker/dispatcher.rs @@ -64,7 +64,7 @@ mod tests { let config = Arc::new(crate::config::app::AppConfig::test_defaults()); let mgr = PluginManager::new_with_options( config, - crate::plugins::PluginManagerOptions { pool: None }, + crate::plugins::PluginManagerOptions { pool: None, event_bus: None }, ) .await; let dispatcher = PluginCronDispatcher::new(mgr);