Plugins
完整实战 — CRM 插件
一个完整的 CRM 插件,包含 Hook、自定义路由、数据库查询和事件发射。
本实战构建一个真实的 CRM 插件:销售漏斗管理、联系人时间线和自定义 API 端点。
要构建什么
CRM 插件
├── Hook:响应内容创建,发射自定义事件
├── 路由:Pipeline API、交易详情、阶段更新
├── 数据库:查询交易、联系人、公司
└── 事件:联系人创建时发射 crm.lead_created1. Manifest
创建 extensions/plugins/crm/manifest.toml:
[plugin]
id = "com.raisfast.crm"
name = "CRM API"
version = "0.1.0"
description = "销售漏斗和联系人管理"
author = "RaisFast"
license = "MIT"
runtime = "js"
entry = "main.js"
[permissions]
max_memory_mb = 16
timeout_ms = 5000
database = ["crm_contacts", "crm_companies", "crm_deals", "crm_activities"]
http = ["api.example.com"]
config = ["app.*"]
filesystem = ["read-write"]
[hooks.on-content-created]
priority = 50
[[routes]]
method = "GET"
path = "/api/v1/plugins/crm/pipeline"
handler = "getPipeline"
auth = "public"
[[routes]]
method = "GET"
path = "/api/v1/plugins/crm/pipeline/:dealId"
handler = "getDealDetail"
auth = "member"
[[routes]]
method = "POST"
path = "/api/v1/plugins/crm/deals/:dealId/stage"
handler = "updateDealStage"
auth = "admin"
description = "将交易移动到新的 Pipeline 阶段"
[[cron]]
label = "每日统计"
job_type = "crm_daily_stats"
cron_expr = "0 0 * * *"
payload = """{"type": "full"}"""2. 插件代码
创建 extensions/plugins/crm/main.js:
import {
dbQuery, dbExec, ok, fail,
extractJson, logInfo, eventEmit, newId,
vfsWrite, vfsRead, vfsExists
} from 'sdk';
const STAGES = [
"prospecting",
"qualification",
"proposal",
"negotiation",
"closed_won",
"closed_lost"
];
// ── 路由 ──────────────────────────────────────
export function getPipeline() {
const pipeline = [];
for (const stage of STAGES) {
const rows = dbQuery(
`SELECT id, title, CAST(amount AS TEXT) as 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 id, title, CAST(amount AS TEXT) as amount, stage
FROM crm_deals WHERE id = ?`,
[dealId]
);
if (!deals || deals.length === 0) return fail(404, "deal not found");
const activities = dbQuery(
`SELECT id, type, note, created_at
FROM crm_activities WHERE deal_id = ?
ORDER BY created_at DESC LIMIT 20`,
[dealId]
);
return ok({ deal: deals[0], activities: activities || [] });
}
export function updateDealStage(input) {
const dealId = extractJson(input, "params.dealId");
const body = extractJson(input, "body");
if (!dealId) return fail(400, "deal id required");
if (!body?.stage) return fail(400, "stage required");
if (!STAGES.includes(body.stage)) return fail(400, "invalid stage");
const result = dbExec(
"UPDATE crm_deals SET stage = ? WHERE id = ?",
[body.stage, dealId]
);
if (result.rows_affected === 0) return fail(404, "deal not found");
const activityId = newId();
dbExec(
"INSERT INTO crm_activities (id, deal_id, type, note) VALUES (?, ?, ?, ?)",
[activityId, dealId, "stage_change", `Moved to ${body.stage}`]
);
eventEmit("crm.deal_stage_changed", JSON.stringify({
deal_id: dealId,
new_stage: body.stage
}));
logInfo(`[crm] deal ${dealId} moved to ${body.stage}`);
return ok({ updated: true });
}
// ── Hook ───────────────────────────────────────
export function on_content_created(input) {
const data = extractJson(input, "body");
if (!data) return ok(input);
if (data.content_type === "contact") {
logInfo(`[crm] new contact: ${data.id}`);
eventEmit("crm.lead_created", JSON.stringify({
contact_id: data.id
}));
}
return ok(input);
}
// ── 定时任务 ───────────────────────────────────
export function on_cron_tick(input) {
const data = extractJson(input, "body");
if (!data || data.job_type !== "crm_daily_stats") return;
const won = dbQuery(
`SELECT CAST(COUNT(*) AS TEXT) as cnt,
CAST(COALESCE(SUM(CAST(amount AS REAL)), 0) AS TEXT) as total
FROM crm_deals WHERE stage = 'closed_won'`
);
const active = dbQuery(
`SELECT CAST(COUNT(*) AS TEXT) as cnt
FROM crm_deals WHERE stage NOT IN ('closed_won', 'closed_lost')`
);
const stats = {
date: new Date().toISOString().slice(0, 10),
won_count: parseInt(won?.[0]?.cnt || "0"),
won_total: parseFloat(won?.[0]?.total || "0"),
active_count: parseInt(active?.[0]?.cnt || "0")
};
vfsWrite("cache/daily_stats.json", JSON.stringify(stats));
logInfo(`[crm] daily stats: ${JSON.stringify(stats)}`);
}3. 结果
启动服务器。插件自动加载。
获取完整 Pipeline
curl http://localhost:9898/api/v1/plugins/crm/pipeline{
"code": 0,
"data": {
"stages": [
{ "stage": "prospecting", "deals": [...] },
{ "stage": "qualification", "deals": [...] },
{ "stage": "closed_won", "deals": [...] }
]
}
}获取交易详情(含活动记录)
curl http://localhost:9898/api/v1/plugins/crm/pipeline/deal123 \
-H "Authorization: Bearer TOKEN"移动交易到新阶段
curl -X POST \
http://localhost:9898/api/v1/plugins/crm/deals/deal123/stage \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"stage": "negotiation"}'自定义事件
其他插件可以通过注册 crm.deal_stage_changed 或 crm.lead_created Hook 来监听这些事件。
你获得了什么
| 功能 | 来源 |
|---|---|
| 3 个自定义 API 端点 | Manifest 中的 [[routes]] |
| 受权限保护的路由 | auth = "member" / "admin" |
| URL 参数提取 | :dealId 路径参数 |
| 内容生命周期 Hook | [hooks.on-content-created] |
| 定时任务 | [[cron]] + on_cron_tick |
| 数据库查询 | 带参数化 SQL 的 dbQuery |
| 数据库写入 | 用于更新和插入的 dbExec |
| ID 生成 | 用于 UUID v7 的 newId() |
| 事件发射 | 用于自定义事件的 eventEmit |
| 文件缓存 | vfsWrite / vfsRead |
| 权限控制 | [permissions] 白名单 |
零行 Rust — 纯 JavaScript 插件。
