25 KiB
Plugin 开发指南
概述
Plugin 系统是 raisfast 的运行时扩展机制,支持三种语言运行时,可独立于 Content Type 运行。Plugin 可以注册钩子、定时任务、自定义路由,并通过 Host API 访问数据库、HTTP、配置等受控资源。
架构
plugins/
└── {plugin-id}/
├── manifest.toml # 插件清单
└── main.js # 入口文件(JS/Lua/WASM)
↓ 启动加载
PluginManager(Arc 共享)
├─ 拓扑排序(依赖顺序)
├─ JS/Lua: per-request(每次调用新建 VM,用完销毁)
├─ WASM: 实例池(round-robin 并发)
└─ 热重载(文件系统监听)
↓ Hook 派发
Host API(沙箱权限控制,全局对象名: RaisFastHost)
├─ Host.dbQuery / Host.dbExecute
├─ Host.dbBegin / Host.dbCommit / Host.dbRollback
├─ Host.httpGet / Host.httpPost
├─ Host.getConfig
├─ Host.getData / Host.setData(KV 存储)
├─ Host.getPost(获取文章)
├─ Host.vfsRead / Host.vfsWrite / Host.vfsDelete / Host.vfsExists / Host.vfsList / Host.vfsStat
├─ Host.log / Host.emitEvent
└─ Host.newId
核心模块
| 模块 | 文件 | 职责 |
|---|---|---|
| PluginManager | src/plugins.rs |
加载/卸载/hook 派发/热重载/事件总线 |
| Manifest | src/plugins/manifest.rs |
TOML 清单解析 |
| Permissions | src/plugins/permissions.rs |
权限校验 + SQL 注入防护 + SSRF 防护 |
| JS Engine | src/plugins/engine_js.rs |
QuickJS per-request 运行时 |
| JS Host | src/plugins/js_host.rs |
JS → Rust Host API 桥接 |
| Lua Engine | src/plugins/engine_lua.rs |
Lua 5.4 per-request 运行时 |
| Lua Host | src/plugins/lua_host.rs |
Lua → Rust Host API 桥接 |
| WASM Engine | src/plugins/engine.rs |
wasmtime 实例池运行时 |
| WASM Host | src/plugins/host.rs |
WASM → Rust Host API 桥接 |
| Host Common | src/plugins/host_common.rs |
共享 Host 逻辑 |
| VFS | src/plugins/vfs.rs |
插件隔离虚拟文件系统 |
| HTTP Client | src/plugins/http_client.rs |
插件 HTTP 请求 |
| SDK v1 | src/plugins/sdk_v1.rs |
SDK 版本管理 + include_str! 嵌入 |
| CLI | src/cli/plugin_cmd.rs |
plugin new / plugin check |
SDK 分发
SDK 编译进 Rust 二进制文件(include_str!),不依赖外部文件:
plugin-sdk/
js/
js_plugin_v1.js ← JS SDK v1 源码
js_plugin_v1.ts ← TypeScript 源码(编译用)
lua/
lua_plugin_v1.lua ← Lua SDK v1 源码
三种运行时
| 运行时 | Cargo Feature | 入口文件 | 引擎 | 并发模型 |
|---|---|---|---|---|
| JavaScript | plugin-js |
main.js |
rquickjs (QuickJS) | per-request |
| Lua | plugin-lua |
init.lua |
mlua (Lua 5.4) | per-request |
| WASM | plugin-wasm |
plugin.wasm |
wasmtime | 实例池 |
三种运行时可同时编译、同时加载。
并发模型差异
| 模型 | 适用 | 原理 | 隔离性 |
|---|---|---|---|
| per-request | JS、Lua | 每次调用创建全新 VM,用完销毁 | 完美隔离,零状态泄漏 |
| 实例池 | WASM | N 个预编译实例,round-robin 分发 | 实例间隔离,实例内需注意状态 |
JS/Lua 的
PLUGIN_JS_POOL_SIZE/PLUGIN_LUA_POOL_SIZE配置项保留但当前 per-request 模式不使用。
Manifest 文件 (manifest.toml)
最小示例
[plugin]
id = "com.example.my-plugin"
name = "My Plugin"
version = "0.1.0"
runtime = "js"
entry = "main.js"
[permissions]
max_memory_mb = 16
timeout_ms = 5000
[plugin] 字段
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
id |
string | 是 | — | 插件唯一 ID(建议反向域名格式) |
name |
string | 是 | — | 显示名称 |
version |
string | 是 | — | 语义版本号 |
description |
string | 否 | "" |
描述 |
author |
string | 否 | — | 作者 |
license |
string | 否 | — | 许可证 |
runtime |
string | 否 | "wasm" |
运行时:js / lua / wasm |
language |
string | 否 | "rust" |
语言标识 |
entry |
string | 否 | "index.js" |
入口文件名 |
wasm |
string | 否 | "plugin.wasm" |
WASM 文件路径 |
sdk_version |
string | 否 | "v1" |
SDK 版本 |
[permissions] 权限声明
[permissions]
max_memory_mb = 16
timeout_ms = 5000
http = ["api.example.com", "*.github.com"]
config = ["app.*", "jwt.*"]
database = ["read:products", "write:orders", "categories"]
filesystem = ["read-write"]
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
max_memory_mb |
int | 配置默认值 | 单实例内存上限 |
timeout_ms |
int | 配置默认值 | Hook 执行超时 |
http |
string[] | [](禁止) |
HTTP 白名单 |
config |
string[] | [](禁止) |
配置读取白名单 |
database |
string[] | [](禁止) |
数据库权限 |
filesystem |
string[] | [](禁止) |
文件系统权限 |
数据库权限格式
| 格式 | 权限 |
|---|---|
"read:TABLE" |
只读 |
"write:TABLE" |
只写 |
"TABLE" |
读写 |
"*" |
所有表(受保护表除外) |
HTTP 白名单
- 精确域名:
api.example.com - 通配符子域:
*.github.com - 路径通配:
api.example.com/v1/*
内置 SSRF 防护:自动阻止 localhost、127.x、10.x、172.16-31.x、192.168.x、169.254.x、::1。
受保护表
以下系统表即使声明 "*" 也不可访问:
users, roles, permissions, audit_log, plugin_storage, options,
rbac_roles, rbac_permissions, rbac_role_permissions, tenants
[hooks.XXX] 钩子注册
[hooks.on-content-created]
priority = 50
[hooks.on-content-updating]
priority = 100
match = "product" # 仅匹配 content_type
content_types = ["product"] # 仅匹配指定 content type
[hooks.render-markdown]
priority = 10
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
priority |
int | 100 | 优先级,数字越小越先执行 |
match |
string | — | 匹配规则(Content Type 名称) |
content_types |
string[] | [] |
仅匹配指定的 Content Type |
钩子名使用连字符(on-content-created),系统自动转换为下划线(on_content_created)。
17 种钩子
| 钩子名 | 类型 | 说明 |
|---|---|---|
on-content-creating |
filter | 内容创建前,可修改数据 |
on-content-created |
action | 内容创建后 |
on-content-updating |
filter | 内容更新前,可修改数据 |
on-content-updated |
action | 内容更新后 |
on-content-deleted |
action | 内容删除后 |
on-content-viewed |
action | 内容被浏览 |
on-post-creating |
filter | 文章创建前(兼容) |
on-post-created |
action | 文章创建后(兼容) |
on-post-updating |
filter | 文章更新前(兼容) |
on-post-updated |
action | 文章更新后(兼容) |
on-post-deleted |
action | 文章删除后(兼容) |
on-comment-creating |
filter | 评论创建前(兼容) |
on-comment-created |
action | 评论创建后(兼容) |
render-markdown |
filter | Markdown 渲染覆盖(第一个返回 wins) |
filter-html |
filter | HTML 过滤 |
on-login |
action | 用户登录后 |
on-cron-tick |
action | 定时任务触发 |
filter 类型:可修改数据,返回值传递给下一个插件。 action 类型:仅副作用,返回值忽略。
[dependencies] 插件依赖
[dependencies]
"com.raisfast.auth" = ">=1.0.0"
"com.raisfast.analytics" = ">=2.0.0"
系统启动时按拓扑排序加载,确保依赖先于当前插件初始化。
[[cron]] 定时任务
[[cron]]
label = "每日统计"
job_type = "daily_stats"
cron_expr = "0 0 * * *"
payload = """{"type": "full"}"""
enabled = true
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
label |
string | 是 | 任务名称 |
job_type |
string | 是 | 任务类型(传给 on_cron_tick) |
cron_expr |
string | 是 | Cron 表达式 |
payload |
string | 否 | 附带数据 |
enabled |
bool | 否 | 默认 true |
[[routes]] 自定义路由
[[routes]]
method = "GET"
path = "/api/v1/plugins/crm/pipeline"
handler = "getPipeline"
auth = "admin"
[[routes]]
method = "GET"
path = "/api/v1/plugins/crm/contacts/:contactId"
handler = "getContact"
auth = "public"
description = "获取联系人详情"
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
method |
string | 是 | — | HTTP 方法 |
path |
string | 是 | — | 路由路径,支持 :param 占位符 |
handler |
string | 是 | — | 对应 Plugin 对象的函数名 |
auth |
string | 否 | default |
none / public / member / admin |
description |
string | 否 | — | 描述 |
permission |
string | 否 | — | 额外权限要求 |
路由参数定义(input)
[[routes]]
method = "POST"
path = "/api/v1/plugins/crm/deals"
handler = "createDeal"
[[routes.input]]
name = "title"
type = "string"
in = "body"
required = true
description = "交易标题"
[[routes.input]]
name = "page"
type = "integer"
in = "query"
default = 1
description = "页码"
路由输出定义(output)
[routes.output]
description = "交易列表"
[[routes.output.fields]]
name = "id"
type = "string"
description = "交易 ID"
[[routes.output.fields]]
name = "title"
type = "string"
description = "交易标题"
自定义路由的响应由框架统一包装为 { code: 0, message: "success", data: ... } 格式。
[[content_types]] Content Type 文件引用
[[content_types]]
file = "content_types/contact.toml"
插件可以自带 Content Type TOML 文件,安装时自动加载。
[[admin_pages]] 管理后台页面
[[admin_pages]]
path = "/admin/plugins/crm"
label = "CRM 管理"
icon = "users"
component = "CrmDashboard"
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
path |
string | 是 | 页面路径 |
label |
string | 是 | 显示名称 |
icon |
string | 否 | 图标 |
component |
string | 否 | 前端组件名 |
Host API
所有运行时通过统一的 RaisFastHost 全局对象访问宿主功能(由 PLUGIN_HOST_GLOBAL 常量定义):
数据库
const rows = JSON.parse(Host.dbQuery("SELECT * FROM products WHERE price > ?", JSON.stringify([100])));
const affected = Host.dbExecute("UPDATE products SET stock = stock - 1 WHERE id = ?", JSON.stringify([id]));
Host.dbBegin();
Host.dbExecute("INSERT INTO orders ...", null);
Host.dbExecute("UPDATE products ...", null);
Host.dbCommit(); // 或 Host.dbRollback()
| 函数 | 说明 | 权限 |
|---|---|---|
Host.dbQuery(sql, params?) |
SELECT 查询 | database read |
Host.dbExecute(sql, params?) |
INSERT/UPDATE/DELETE | database write |
Host.dbBegin() |
开启事务 | 需要 pool |
Host.dbCommit() |
提交事务 | 活跃事务 |
Host.dbRollback() |
回滚事务 | 活跃事务 |
注意:
dbQuery返回的整数列是null,需用CAST(col AS TEXT)转为字符串后再parseInt。
HTTP
const html = Host.httpGet("https://api.example.com/data");
const result = Host.httpPost("https://api.example.com/webhook", JSON.stringify({event: "test"}));
内容查询
const postJson = Host.getPost("my-post-slug");
KV 存储
Host.setData("last_sync", "2026-01-01");
const val = Host.getData("last_sync");
每个插件有独立命名空间,互不干扰。
配置
const host = Host.getConfig("app.host");
const env = Host.getConfig("app.env");
允许的配置键(需在 permissions.config 白名单中):
app.host, app.port, app.env, app.base_url, jwt.access_expires, jwt.refresh_expires, upload.dir, upload.max_size, plugin.max_memory_mb, plugin.default_timeout_ms
文件系统(VFS)
Host.vfsWrite("/reports/daily.json", reportJson);
const data = Host.vfsRead("/reports/daily.json");
const exists = Host.vfsExists("/reports/daily.json");
const files = Host.vfsList("/reports");
Host.vfsDelete("/reports/old.json");
const stat = Host.vfsStat("/reports/daily.json"); // → {"size":1024,"is_dir":false,"modified":1234567890}
每个插件在 {VFS_ROOT}/{plugin_id}/ 下有隔离沙箱,路径不能包含 ..。
其他
Host.log("info", "Processing order " + orderId);
Host.log("warn", "Low stock detected");
Host.log("error", "Payment failed: " + error);
Host.emitEvent("order.created", JSON.stringify({orderId: id}));
const id = Host.newId(); // UUID v7
| 函数 | 说明 |
|---|---|
Host.log(level, msg) |
日志输出(level: info/warn/error) |
Host.emitEvent(type, data) |
发射事件到事件总线 |
Host.newId() |
生成 UUID v7(时间排序,与系统主键一致) |
WASM 运行时注意:
Host.newId()仅在 JS/Lua 运行时可用,WASM 运行时未暴露此函数。
编写 JS 插件(ES Module + SDK)
项目结构
plugins/my-plugin/
├── manifest.toml
└── main.js
代码模板
import { dbQuery, dbExec, ok, fail, extractJson, logInfo, newId } from 'sdk';
// ── Hook ──
export function on_content_created(input) {
const data = extractJson(input, "body");
if (data?.content_type === "product") {
logInfo("[my-plugin] new product: " + data.id);
}
return ok(data);
}
// ── 自定义路由 ──
export function getProduct(input) {
const id = extractJson(input, "params.id");
if (!id) return fail(400, "id required");
const rows = dbQuery("SELECT * FROM products WHERE id = ?", [id]);
if (!rows || rows.length === 0) return fail(404, "product not found");
return ok(rows[0]);
}
// ── 定时任务 ──
export function on_cron_tick(input) {
const data = extractJson(input, "body");
if (data?.job_type === "daily_cleanup") {
dbExec("DELETE FROM sessions WHERE expires_at < datetime('now')");
logInfo("[my-plugin] daily cleanup done");
}
}
SDK v1 API(import { ... } from 'sdk')
| 函数 | 说明 |
|---|---|
dbQuery(sql, params?) |
参数化 SELECT 查询,返回对象数组;错误时抛异常 |
dbExec(sql, params?) |
INSERT/UPDATE/DELETE,返回 { error?, rows_affected } |
dbBegin() |
开启事务(失败时抛异常) |
dbCommit() |
提交事务(失败时抛异常) |
dbRollback() |
回滚事务 |
ok(data) |
成功响应:返回数据,框架自动包装为 {code:0, data} |
fail(status, msg) |
错误响应:框架包装为 {code:N, message} |
extractJson(input, field?) |
从 JSON 中提取指定字段(支持 params.id 点号路径),不存在返回 null |
logInfo(msg) / logWarn(msg) / logError(msg) |
日志输出 |
newId() |
生成 UUID v7(时间排序,与系统一致) |
eventEmit(type, data) |
发射事件到事件总线 |
httpGet(url) |
HTTP GET 返回原始字符串 |
httpGetJson(url) |
HTTP GET 并解析 JSON |
httpPost(url, body) |
HTTP POST 返回原始字符串 |
httpPostJson(url, body) |
HTTP POST 并解析 JSON |
configGet(key) |
读取配置(需 config 权限) |
storeGet(key) / storeSet(key, val) |
KV 存储 |
vfsRead(path) / vfsWrite(path, content) |
虚拟文件系统读写 |
vfsDelete(path) / vfsExists(path) |
虚拟文件系统删除/判断存在 |
vfsList(path) |
列出目录下文件,返回数组 |
vfsStat(path) |
获取文件信息(size, is_dir, modified) |
getPost(slug) |
按 slug 获取文章,返回 JSON 对象 |
关键约定
- 必须使用
export function导出 handler(ES Module 模式,引擎自动收集到 Plugin 对象) - 路由处理:
input包含{ path, method, body, headers, params },直接return ok(data)或return fail(status, msg) - Filter 钩子:接收 JSON 字符串
input,用extractJson(input, "body")提取数据 - Action 钩子:接收 JSON 字符串,返回值被忽略
- 支持 ES2024 完整语法(
let/const、箭头函数、async/await、可选链等) dbQuery()查询失败时抛异常,可用try/catch捕获dbQuery()返回的整数列为null,必须用CAST(col AS TEXT)转为字符串后再parseInt- SDK 不可被插件覆盖(Module Loader 优先匹配
"sdk") Host全局对象仍存在(SDK 内部使用),不推荐插件直接调用
相对路径导入
import { helper } from './utils.js';
编写 Lua 插件(SDK 模式)
项目结构
plugins/my-plugin/
├── manifest.toml
└── init.lua
代码模板
local sdk = require("sdk")
Plugin = {}
Plugin.on_content_created = function(input)
local data = sdk.extractJson(input, "body")
if data and data.content_type == "product" then
sdk.logInfo("[my-plugin] new product: " .. tostring(data.id))
end
return sdk.ok(data)
end
Plugin.on_cron_tick = function(input)
local data = sdk.extractJson(input, "body")
if data.job_type == "daily_cleanup" then
sdk.dbExec("DELETE FROM sessions WHERE expires_at < datetime('now')")
sdk.logInfo("[my-plugin] cleanup done")
end
end
Plugin.getStats = function(input)
local result = sdk.dbQuery("SELECT CAST(COUNT(*) AS TEXT) as cnt FROM products WHERE status = 'published'")
return sdk.ok({ total = tonumber(result[1].cnt) or 0 })
end
SDK v1 API(local sdk = require("sdk"))
| 函数 | 说明 |
|---|---|
sdk.dbQuery(sql, params?) |
参数化 SELECT 查询,返回数组表;错误时抛异常 |
sdk.dbExec(sql, params?) |
INSERT/UPDATE/DELETE,返回结果表 |
sdk.dbBegin() |
开启事务(失败时抛异常) |
sdk.dbCommit() |
提交事务(失败时抛异常) |
sdk.dbRollback() |
回滚事务 |
sdk.ok(data) |
成功响应:返回数据,框架自动包装 |
sdk.fail(status, msg) |
错误响应:框架包装为 {code:N, message} |
sdk.extractJson(input, field?) |
从 JSON 中提取指定字段(支持点号路径),不存在返回 nil |
sdk.logInfo(msg) / sdk.logWarn(msg) / sdk.logError(msg) |
日志输出 |
sdk.newId() |
生成 UUID v7(时间排序,与系统一致) |
sdk.eventEmit(type, data) |
发射事件 |
sdk.httpGet(url) |
HTTP GET 返回原始字符串 |
sdk.httpGetJson(url) |
HTTP GET 并解析 JSON |
sdk.httpPost(url, body) |
HTTP POST 返回原始字符串 |
sdk.httpPostJson(url, body) |
HTTP POST 并解析 JSON |
sdk.configGet(key) |
读取配置 |
sdk.storeGet(key) / sdk.storeSet(key, val) |
KV 存储 |
sdk.vfsRead(path) / sdk.vfsWrite(path, content) |
虚拟文件系统读写 |
sdk.vfsDelete(path) / sdk.vfsExists(path) |
虚拟文件系统删除/判断存在 |
sdk.vfsList(path) |
列出目录下文件,返回数组 |
sdk.vfsStat(path) |
获取文件信息(size, is_dir, modified) |
sdk.getPost(slug) |
按 slug 获取文章 |
关键约定
- 必须导出全局
Plugin表(Lua 不强制 ESM,但仍需Plugin = {}) - 使用
local sdk = require("sdk")导入 SDK 模块 - Filter 钩子:接收 Lua table,返回 Lua table
- 路由处理:
input包含{ path, method, body, headers, params },直接return sdk.ok(data)或return sdk.fail(status, msg) - 沙箱环境:仅暴露
table,string,math,utf8,coroutine标准库(无 IO/OS/debug) - 指令限制:5,000,000 条
sdk.dbQuery()查询失败时抛异常,可用pcall捕获sdk.dbQuery()返回的整数列在 Lua 中可能为nil,建议用CAST(col AS TEXT)转换- Lua SDK 额外提供
Host.jsonEncode(val)/Host.jsonDecode(str)用于 JSON 序列化(Lua 沙箱无原生 JSON 支持)
Host 函数对比(三运行时)
| 函数 | JS | Lua | WASM | 备注 |
|---|---|---|---|---|
log |
✅ | ✅ | ✅ | |
getConfig / get_config |
✅ | ✅ | ✅ | WASM 用 snake_case |
httpGet / http_get |
✅ | ✅ | ✅ | |
httpPost / http_post |
✅ | ✅ | ✅ | |
getData / get_data |
✅ | ✅ | ✅ | |
setData / set_data |
✅ | ✅ | ✅ | |
getPost / get_post |
✅ | ✅ | ✅ | |
dbQuery / db_query |
✅ | ✅ | ✅ | |
dbExecute / db_execute |
✅ | ✅ | ✅ | |
dbBegin / db_begin |
✅ | ✅ | ✅ | |
dbCommit / db_commit |
✅ | ✅ | ✅ | |
dbRollback / db_rollback |
✅ | ✅ | ✅ | |
vfsRead / vfs_read |
✅ | ✅ | ✅ | |
vfsWrite / vfs_write |
✅ | ✅ | ✅ | |
vfsDelete / vfs_delete |
✅ | ✅ | ✅ | |
vfsExists / vfs_exists |
✅ | ✅ | ✅ | |
vfsList / vfs_list |
✅ | ✅ | ✅ | |
vfsStat / vfs_stat |
✅ | ✅ | ✅ | |
newId / new_uuid |
✅ | ✅ | ❌ | WASM 未暴露 |
emitEvent / emit_event |
✅ | ✅ | ✅ | |
jsonEncode |
❌ | ✅ | ❌ | Lua 专用(无原生 JSON) |
jsonDecode |
❌ | ✅ | ❌ | Lua 专用(无原生 JSON) |
JS/Lua 用 camelCase,WASM 用 snake_case。
错误恢复
- 连续 5 次 错误自动禁用插件
- 错误计数在成功执行时重置
- 可通过 Admin API 手动重新启用
- 插件超时/崩溃时自动回滚未提交的事务
热重载
当 PLUGIN_HOT_RELOAD=true(默认开启)时:
- 文件系统监听器监控插件目录的
.js/.lua/.wasm文件变化 - 1 秒防抖
- 自动卸载 + 重新加载变化的插件
- 发出
PluginReloaded事件
管理 API
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/v1/admin/plugins |
列出所有插件(含状态/健康/指标) |
| GET | /api/v1/admin/plugins/{id} |
插件详情 |
| POST | /api/v1/admin/plugins/{id}/enable |
启用 |
| POST | /api/v1/admin/plugins/{id}/disable |
禁用 |
| POST | /api/v1/admin/plugins/{id}/reload |
热重载 |
| DELETE | /api/v1/admin/plugins/{id} |
卸载 |
CLI 命令
# 创建新插件
raisfast plugin new my-plugin --runtime js # JavaScript
raisfast plugin new my-plugin --runtime lua # Lua
raisfast plugin new my-plugin --runtime wasm # WASM
# 校验插件
raisfast plugin check # 校验默认目录
raisfast plugin check ./plugins/my-plugin # 校验指定目录
环境变量
| 变量 | 默认值 | 说明 |
|---|---|---|
PLUGIN_DIR |
./extensions/plugins |
插件目录 |
PLUGIN_VFS_ROOT |
./plugins-data |
VFS 根目录 |
PLUGIN_VFS_MAX_FILE_SIZE |
1048576 |
单文件最大 1MB |
PLUGIN_VFS_MAX_TOTAL_SIZE |
10485760 |
总配额 10MB |
PLUGIN_WASM_POOL_SIZE |
4 |
WASM 实例池大小 |
PLUGIN_JS_POOL_SIZE |
4 |
JS 配置(当前 per-request 不使用) |
PLUGIN_LUA_POOL_SIZE |
4 |
Lua 配置(当前 per-request 不使用) |
完整示例:CRM 插件
plugins/crm/
├── manifest.toml
└── main.js
manifest.toml:
[plugin]
id = "com.raisfast.crm"
name = "CRM API"
version = "0.1.0"
description = "CRM 销售漏斗、Pipeline 管理、联系人时间线"
runtime = "js"
entry = "main.js"
[permissions]
max_memory_mb = 16
timeout_ms = 5000
database = ["crm_contacts", "crm_companies", "crm_deals", "crm_activities", "crm_notes"]
config = ["app.*"]
[hooks.on-content-created]
priority = 50
[hooks.on-content-updated]
priority = 50
[[routes]]
method = "GET"
path = "/api/v1/plugins/crm/pipeline"
handler = "getPipeline"
[[routes]]
method = "GET"
path = "/api/v1/plugins/crm/pipeline/:dealId"
handler = "getDealDetail"
[[routes]]
method = "POST"
path = "/api/v1/plugins/crm/deals/:dealId/stage"
handler = "updateDealStage"
main.js(节选):
import { dbQuery, dbExec, ok, fail, extractJson, logInfo, eventEmit, newId } from 'sdk';
export function getPipeline() {
const stages = ["prospecting", "qualification", "proposal", "negotiation", "closed_won", "closed_lost"];
const pipeline = [];
for (const stage of stages) {
const rows = dbQuery(
`SELECT id, title, amount FROM crm_deals WHERE stage = ? ORDER BY amount DESC`,
[stage]
);
pipeline.push({ stage, deals: rows || [] });
}
return ok({ stages: pipeline });
}
export function getDealDetail(input) {
const dealId = extractJson(input, "params.dealId");
if (!dealId) return fail(400, "deal id required");
const deals = dbQuery(`SELECT * FROM crm_deals WHERE id = ?`, [dealId]);
if (!deals || deals.length === 0) return fail(404, "deal not found");
return ok(deals[0]);
}
export function on_content_created(input) {
const data = extractJson(input, "body");
if (data.content_type === "contact") {
logInfo(`[crm] new contact: ${data.id}`);
eventEmit("crm.lead_created", JSON.stringify({ contact_id: data.id }));
}
return ok(data);
}