RaisFastRaisFast
Plugins

完整实战 — CRM 插件

一个完整的 CRM 插件,包含 Hook、自定义路由、数据库查询和事件发射。

本实战构建一个真实的 CRM 插件:销售漏斗管理、联系人时间线和自定义 API 端点。

要构建什么

CRM 插件
├── Hook:响应内容创建,发射自定义事件
├── 路由:Pipeline API、交易详情、阶段更新
├── 数据库:查询交易、联系人、公司
└── 事件:联系人创建时发射 crm.lead_created

1. 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_changedcrm.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 插件。

On this page