用 Lume 写一个完整 CRM:Web 与 Agent 共用同一套业务逻辑

作者:

在

🇬🇧 English

引言

lume-crm 是一个完全用 Lume 写的 CRM 后台:客户、商机、跟进管理,加一个可以直接对话的 LLM Agent。

这个项目真正想验证的不是"Lume 能不能做 CRUD",而是另一件事:Web 请求和 Agent 操作,能不能直接进入同一套业务逻辑?

在 lume-crm 里,HTTP 路由和 Agent Tool 都直接调用领域层函数。客户、商机、跟进的校验和 SQL 只有一份,Agent 不是额外外挂的一层,而是业务函数的另一种入口。

数据库、REST API、SSR 分享页、Agent 工具,全由三份 .lume 文件撑起,最后编译成一个 C11 单二进制。前端仍然使用标准 React,但运行时不需要 Node 服务。

这篇不重复讲 Lume 是什么、怎么安装、内部怎么工作(那篇在 erishen.cn/lume),而是直接看一个完整应用是怎么组织起来的。下面是这个项目的实际写法,全部片段从仓库直接摘的。

这个 CRM 能做什么(三十秒版)

仪表盘统计 + 客户列表/详情 + 商机管道(初步接洽 → 方案 → 谈判 → 成交/丢单)+ 跟进记录(电话/会议/邮件/拜访)+ Agent 聊天 + 客户 SSR 分享页 + 教学页。功能不是重点,重点是这堆东西的服务端只有三份 .lume 文件——路由、SQL、工具、页面渲染全在里面,没有第二个服务进程。

先看整体结构

lume-crm 的核心结构其实很简单:

  React SPA ──► HTTP 路由 ──┐
                            │
  LLM ──────► Agent Tools ──┼──► Domain Functions ──► SQLite
                            │
  SSR 分享页 ───────────────┘

这里最重要的是中间的 Domain Functions。HTTP 路由和 Agent Tool 都调用同一套业务函数——POST /api/customers 和 crm_add_customer Tool 最终进入的是同一个 add_customer()。不是给 CRM 再外挂一套 Agent 逻辑,而是让同一个业务函数同时成为 Web API 和 Agent 的入口。后面所有代码都是围绕这一点展开的。

入口:一个文件声明整站

crm.lume 是唯一的入口,开头就是整站的声明:

let crm_bind = env("LUME_BIND");
if (crm_bind == null) { crm_bind = "127.0.0.1"; }
server {
  port = 8089;
  workers = 2;
  bind = crm_bind;
  docroot = "./www";
  spa = true;        // SPA history 路由:静态 404 回退 index.html
  htpasswd = env("HTPASSWD_FILE");
};

几个值得注意的写法:

  • spa = true 一行解决 SPA history 路由——单页应用里刷新 /customers/3 这类深链会 404,这行让静态 404 自动回退到 index.html,API 404 保持原样。不用配 nginx fallback。
  • bind 从环境变量读——本地默认绑 127.0.0.1,容器里传 LUME_BIND=0.0.0.0 才能被端口映射命中。部署形态用 env 表达,而不是改脚本。
  • htpasswd 直接挂文件路径——Basic Auth 是语言级的,一行开、一行关。

路由:HTTP 只负责协议

读路径几乎就是一行:

get "/api/stats", (req) => {
  return crmdb.stats_payload();   // 返回 map,框架自动序列化成 JSON
};

get "/api/customers", (req) => {
  let q = get(req.query_params, "q", "");
  let sort = get(req.query_params, "sort", "");
  let list = crmdb.customer_list(q, sort);
  return { count: len(list), customers: list };
};

handler 返回一个 map,框架自动按 200 application/json 序列化——没有 stringify(),没有手写 status/type。

写路径是这个项目沉淀出来的一套约定,以新建客户为例:

post "/api/customers", (req) => {
  let gate = write_gate();
  if (gate != null) { return gate; }
  let b = body_of(req);
  if (b.err != null) {
    return { status: 400, body: { err: b.err } };
  }
  return resp(crmdb.add_customer(
    str(b.ok, "name", ""),
    str(b.ok, "company", ""),
    str(b.ok, "email", ""),
    str(b.ok, "phone", "")), true);
};

三个小工具函数承担了全部脏活,每个路由都是这个形状:

  • write_gate()——只读演示模式下整条写路径直接返回 403,本地开发时为 null 放行。读路由不经过它。
  • body_of(req)——body 解析。req.body 为空返回 { ok: null, err: "缺少 body" },JSON 非法返回 { ok: null, err: ... }。固定返回 { ok, err } 两键,路由侧只用 b.err/b.ok 访问,不会崩。
  • resp(res, created)——把领域层结果包成 HTTP 响应:失败带 status(默认 500)、新建返回 201。

约定是:路由只管 body 解析和状态码,校验、锁、SQL 全部下沉到 src/db.lume 的助手函数。路由薄,业务厚。

一句话总结这套设计:Lume CRM 的路由层故意保持很薄——HTTP 负责协议,领域函数负责业务,Agent Tool 负责把领域能力暴露给模型。HTTP、Agent 和 SSR 各自负责自己的访问或输出方式,中间的业务逻辑只有一份。

顺带认识一下上面出现的 str(b.ok, "name", "") 三参形式:str/int/float(m, k, default) 是 Lume 的安全读取写法——取值、转类型、缺键兜底一步到位(等价旧的 str(get(m, k, default)))。后面「读取与容错的几个写法习惯」一节还会细讲。

领域层:业务逻辑只有一份

CRM 的业务逻辑集中在 src/db.lume。SQL 直接写在 DSL 里,不需要再引入一套 ORM。更重要的是,这里的函数同时服务于 HTTP 和 Agent——比如 add_customer():

HTTP POST /api/customers ──┐
                          ├──► add_customer() ──► SQLite
crm_add_customer Tool ─────┘

因此客户创建的校验、SQL 和错误处理只有一份。以后如果增加一个 CLI、定时任务或者另一个 Agent,也不需要重新实现一套 CRM 逻辑,只需要增加一个新的入口。

SQL 就是 DSL 的一部分,不用拼 ORM:

export func stats_payload() {
  let by_stage = {};
  for (s in q("SELECT stage, COUNT(*) AS n, COALESCE(SUM(amount), 0.0) AS amt FROM deals GROUP BY stage", null)) {
    put(by_stage, s.stage, int(s.n));
    put(by_stage, s.stage + ".amt", float(s.amt));
  }
  let open = q("SELECT COALESCE(SUM(amount), 0.0) AS t FROM deals WHERE stage NOT IN (" + in_clause(closed_stages) + ")", null);
  ...
}

q("...") 直接执行 SQLite,参数化查询传数组(q("SELECT ... WHERE customer = ?", [cid])),聚合统计全部在 SQL 里算完再包装。阶段定义(closed_stages)只有一份,SQL 统计和推进商机时的自动留痕共用它,改一处全同步。

Agent 工具:业务函数声明即工具

在 Lume 里,Tool 不是另一套 API,而是业务函数的 Agent 入口。领域层已有的助手函数,加一个 tool 声明就变成 Agent 可调的工具:

tool "crm_add_customer", "新建客户。name 必填;company/email/phone 可空(空串占位)。", { name: string, company: string, email: string, phone: string }, (arg) => {
  return add_customer(arg.name, arg.company, arg.email, arg.phone);
};

tool "crm_add_activity", "给客户记一条跟进(电话/会议/邮件/拜访…),note 必填;kind 空串默认 记录。", { customer_id: int, kind: string, note: string }, (arg) => {
  return add_activity(arg.customer_id, arg.kind, arg.note);
};

要点:

  • 参数写裸类型关键字({ name: string, customer_id: int, amount: float }),给模型看的 JSON schema 由编译层生成,不手写。
  • 给 LLM 的描述是参数的一部分——「name 必填」「kind 空串默认 记录」这些约束直接写进声明,模型按它生成参数。
  • 工具和 HTTP 路由调的是同一个助手函数——聊天里说「记一条电话跟进」和 curl 打 POST /api/activities,走的是同一份校验和 SQL。不会出现"页面能建、聊天不能建"的两套逻辑。

10 个 crm_* 工具(增删改查客户/商机/跟进)就是这么声明出来的,一行一个。

这意味着什么?传统应用通常先设计 REST API,再为 Agent / MCP 单独设计工具接口,两边最终还需要保持业务规则一致。这里的做法则相反:先把业务能力沉淀成领域函数,再让 HTTP 和 Agent Tool 成为它们不同的入口。这也是 lume-crm 里"AI 原生"的部分:Agent 不需要一套平行的业务实现。

SSR 分享页:同一个领域数据的另一种输出

客户分享页 src/share.lume 是一个纯函数组件,用 html() 标量槽做转义:

func share_page(c) {
  return html(
    "<h1>{0}</h1>"
    + "<div class='contact'>…</div>"
    + "<section><h2>商机 Pipeline</h2>{5}{6}</section>"
    + "<section><h2>跟进记录</h2><ul>{7}{8}</ul></section>",
    str(c.name), str(c, "company", ""),
    str(c, "email", ""), str(c, "phone", ""),
    deal_rows, deal_empty, acts, act_empty);
}

SSR 不是再做一套业务逻辑,而是把领域数据转换成适合分享的 HTML。客户字段全部走 html() 槽自动转义,存进库里的恶意内容渲染出来也是纯文本。整个页面零 JavaScript,手机打开正常,直接外发链接当"客户档案"用。一个 SPA 应用里要服务端渲染的部分,不需要为此再养一个 Node 服务。

前端:标准 React,产物是静态文件

前端是标准 React SPA(仪表盘/客户/聊天/教学四个视图),esbuild 打成一个 app.js 静态产物,构建完 COPY 进镜像。运行时没有 Node 进程——docroot = "./www" 就是全部。

读取与容错的几个写法习惯

Lume 的成员访问是严格的——访问稀疏 map 的缺键会直接 500。所以这个项目跨 map 取值一律走安全读取,按场景分三种:

  • 要转换就一步到位:str/int/float(m, k, default) 三参形式,取值、转类型、缺键兜底一次完成(等价旧的 str(get(m, k, default)),两种写法都合法);
  • 纯取值:get(m, k, default);
  • 可能抛错的调用:try(() => ...),固定返回 { ok, err } 两键。

这套习惯是所有路由形状的来源:body 解析固定两键、助手返回稀疏 map、路由侧三参 cast-and-get / get() 兜底。写熟了之后,新加一个资源基本就是复制路由模板并修改对应业务函数。

为什么这样组织应用

这套组织的价值主要有三点:

  1. Web 和 Agent 共用领域函数,避免两套业务逻辑——HTTP 路由和 Agent Tool 调用同一批领域函数,SSR 分享页直接使用领域数据,业务校验和 SQL 只有一份,从结构上避免 Web 和 Agent 各维护一套业务逻辑。
  2. 运行时简单——服务端只有三份 .lume 文件,编译成一个静态二进制;React 编译成静态前端资源,运行时没有 Node 进程,docroot = "./www" 就是全部。
  3. 编译期拦错:lume --check 离线就能过一遍类型检查(这个项目 make check 就干这个),路由/工具/字段的错在提交前就拦掉,不用等到线上崩。

这些选择的自然结果:3.8MB 的静态二进制、约 18MB 的 Docker 镜像。对于小型 CRM 或内部工具,这种部署形态可以把运行时依赖压缩到很少。项目里还带一个 /examples 教学页,包含 5 段从 server{} 到 tool 声明的 DSL 片段,可以直接作为新应用的起点。

安全护栏

这些机制不是部署时再补的外围配置,而是直接写在应用 DSL 和运行时里:

  • 写操作:write_gate() 统一控制——只读演示模式整条写路径 403,本地开发放行。
  • SQL:q() 只允许参数化查询,业务代码没有一处把用户输入拼进 SQL 字符串。
  • XSS:React 默认转义 + SSR 分享页 html() 标量槽,双层防线。
  • Basic Auth:server{} 挂 htpasswd 文件路径即开,语言级、fail-closed——配错凭据一律 401,不会退化成"放行所有"。
  • 网络:默认绑 127.0.0.1,容器部署才显式传 LUME_BIND=0.0.0.0 开端口映射。
  • Agent Chat:底层 agent-httpd 提供同源 + POST-only 检查——想借聊天接口从别的站点发请求,在网关层就被拦。

怎么试

  • 线上 demo:https://lume-crm.erishen.cn —— 公网只读实例,打开就能玩。
  • 本地跑:装好 lume 后 lume crm.lume,浏览器开 http://127.0.0.1:8089。
  • Docker:docker run -d -p 8089:8089 lume-crm,SQLite 在 /app/.data/,挂卷持久化;接真实 LLM 传 LLM_API_URL / LLM_API_KEY,不配则用内置离线引擎,聊天照常可用。

关于 Lume 与它的底层 agent-httpd

  • Lume 本身(是什么、内部机制、工具链、设计取舍,基础介绍见引言链接):仓库 github.com/erishen/lume。
  • 底层 agent-httpd:lume 的聊天 SSE、Agent Tool 调度和工具 schema 生成,都来自独立开源项目 agent-httpd,以 libagenthttpd.a 静态链接进 Lume。聊天接口的同源 + POST-only 护栏也由它提供。想深入 agent 网关本身,直接看它。
  • 样板仓库:erishen/lume-crm。想照着写下一个应用,直接抄这个仓库的结构。

源码导航

  • crm.lume — 入口:server{} + 全部 HTTP 路由 + 写路由约定(write_gate / body_of / resp)。
  • src/db.lume — 领域层:SQLite 助手 + SQL 内联 + 10 个 crm_* 工具声明。
  • src/share.lume — SSR 分享页的纯 C 渲染组件。
  • src/crm/*.tsx — React SPA 四个视图。

FAQ

服务端真的要 Node 或 Python 吗?

不需要。整个后端是一个 C11 单二进制 lume + 三份 .lume 脚本;前端 React 是构建时的静态产物,运行时没有 Node 进程。

Docker 镜像多大?

约 18MB(alpine 基底 + lume 静态二进制,SQLite 内建,零运行时依赖)。

Agent 工具和 HTTP 接口是两套逻辑吗?

不是。工具声明和路由调的是同一个助手函数,校验和 SQL 只有一份。

怎么写下一个应用?

抄这个仓库的结构:server{} 声明 → 路由薄层 → src/ 领域助手 → tool 声明挂 Agent;make check 过一遍类型检查。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

商店 Web Chat Nsbp 关于 隐私政策

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号