1. 为什么我决定用 Lume 重写一套 CRM
CRM 这个品类,做过的人都知道,功能本身不复杂,难的是"一套逻辑、多个出口"。我手上这套 CRM 最早是纯 Web 的,客户管理、跟进记录、扫码采集名片、订单流水,全都塞在一个后台里。后来业务方提了个需求:能不能让 Agent 也能直接操作这套系统?比如销售在聊天窗口里说一句"帮我把上周扫码进来的客户按行业分组,给每个组生成一份跟进话术",Agent 就得能读到真实数据、跑真实逻辑、写回真实结果。
问题就出在这。如果 Agent 走一套独立的接口,Web 走另一套,那业务逻辑就有两份。改一个字段校验规则,两边都得动;改一次权限判断,两边都可能漏。时间一长,Web 和 Agent 的数据口径必然分叉,这是所有"多端共用一套系统"项目里最经典的坑。
所以我给自己定了个硬指标:业务逻辑只能有一份,Web 和 Agent 都只是它的调用方。选 Lume 来做这件事,是因为它天生就是"内容即数据、数据即接口"的路子,配合 Deno 的运行时,能把数据层、逻辑层、渲染层收在一个工程里,不用为了给 Agent 开个口子再单独起一个服务。
这篇文章我会把这套 CRM 的完整设计思路、核心代码结构、Web 与 Agent 如何共用同一套业务逻辑、扫码采集怎么接、PDF 打印怎么处理、以及我踩过的那些坑,全部摊开讲。适合正在做 Web 项目、正在搭 Agent、或者被"多端逻辑不一致"折磨过的朋友。哪怕你只是想看看 Lume 到底能扛多复杂的业务,这篇也能给你一个真实的参照。
2. 整体架构设计:一份逻辑,两个入口
2.1 核心思路:把业务逻辑抽成"纯函数层"
我见过太多项目把业务逻辑写在路由处理函数里,Web 请求进来就执行,Agent 请求进来再复制一份。这种做法在项目小的时候没问题,一旦字段多起来就是灾难。我的做法是:所有业务规则写成不依赖 HTTP 的纯函数,输入是普通对象,输出也是普通对象,抛错就抛错,不关心调用方是谁。
举个最典型的例子,客户创建时的校验逻辑:
// src/core/customer.ts export interface CustomerInput { name: string; phone?: string; industry?: string; source: "scan" | "manual" | "import"; } export function validateCustomer(input: CustomerInput): CustomerInput { if (!input.name || input.name.trim().length < 2) { throw new Error("客户名称至少 2 个字符"); } if (input.phone && !/^1[3-9]\d{9}$/.test(input.phone)) { throw new Error("手机号格式不正确"); } return { ...input, name: input.name.trim(), industry: input.industry ?? "未分类", }; }这个函数不 import 任何 Web 框架的东西,也不 import 任何 Agent 框架的东西。Web 端调用它,Agent 端也调用它。校验规则改一次,两边同时生效。这就是"共用同一套业务逻辑"最朴素的落地方式——不是共享代码文件那么简单,而是共享一个不依赖传输层的核心层。
2.2 目录结构:core / web / agent 三层分离
我把工程分成三个清晰的目录,边界非常硬:
crm/ ├── src/ │ ├── core/ # 纯业务逻辑,零框架依赖 │ │ ├── customer.ts │ │ ├── deal.ts │ │ ├── scan.ts │ │ └── permission.ts │ ├── data/ # 数据存取,基于 Lume 的 data 能力 │ │ ├── store.ts │ │ └── schema.ts │ ├── web/ # Web 入口,页面 + 表单接口 │ │ ├── _config.ts │ │ └── routes/ │ └── agent/ # Agent 入口,工具定义 + 调用 │ ├── tools.ts │ └── runtime.tscore层是绝对不允许 importweb或agent的,这条规则我用 lint 强制卡住。为什么这么较真?因为一旦 core 里出现了Request、Response这类 Web 专有类型,Agent 端就没法直接用了,你就得写适配层,适配层一多,逻辑又开始分叉。边界越硬,复用越稳,这是我在多个项目里反复验证过的。
2.3 为什么是 Lume,而不是别的
Lume 是 Deno 生态里的静态站点生成器,很多人第一反应是"这不是做博客的吗"。但它的几个特性恰好适合 CRM 这种"数据驱动 + 多出口"的场景:
第一,Lume 的data机制允许你把任意数据源挂载成全局数据,页面、接口、脚本都能读同一份数据。CRM 里的客户列表、订单流水,天然就是这种"多处读取"的数据。
第二,Lume 跑在 Deno 上,Deno 原生支持 TypeScript、原生支持权限控制、原生有Deno.serve起 HTTP 服务。我不需要 webpack、不需要 ts-node、不需要额外装一堆构建工具,一个deno task就能把 Web 和 Agent 都跑起来。
第三,Lume 的插件体系很轻,扫码采集这种需要接第三方能力的模块,写成插件挂进去就行,不会污染核心逻辑。
提示:如果你团队里没人用过 Deno,上手成本大概半天。TypeScript 直接跑、不用配 tsconfig、不用管 node_modules,对新手反而更友好。
3. 核心业务逻辑层:CRM 到底要管哪些事
3.1 客户与跟进:状态机是灵魂
CRM 的核心不是"存客户",而是"管客户的流转状态"。我把客户状态定义成一个明确的状态机:
| 状态 | 含义 | 允许的下一步 |
|---|---|---|
new | 刚采集进来,未联系 | contacted、invalid |
contacted | 已首次联系 | following、invalid |
following | 跟进中 | deal、invalid |
deal | 已成交 | following(复购) |
invalid | 无效客户 | 无 |
状态机写在core/customer.ts里,是一个纯函数:
const TRANSITIONS: Record<string, string[]> = { new: ["contacted", "invalid"], contacted: ["following", "invalid"], following: ["deal", "invalid"], deal: ["following"], invalid: [], }; export function canTransition(from: string, to: string): boolean { return TRANSITIONS[from]?.includes(to) ?? false; } export function transition(customer: Customer, to: string): Customer { if (!canTransition(customer.status, to)) { throw new Error(`不允许从 ${customer.status} 转到 ${to}`); } return { ...customer, status: to, updatedAt: new Date().toISOString() }; }Web 端点击"标记为已联系",调的是transition;Agent 收到"把这个客户标记为已联系"的指令,调的也是transition。状态机的规则只有一份,谁都不能绕过。这一点特别重要,因为 Agent 最容易出的问题就是"自作主张跳状态",有了这层硬约束,Agent 想乱来也乱不了。
3.2 权限:Agent 和 Web 用同一套判断
权限是另一个必须共用的逻辑。Web 端有登录用户,Agent 端有调用者身份,但"谁能看哪些客户、谁能改哪些字段"的规则应该完全一致。
我的做法是把权限判断抽象成"主体 + 动作 + 资源"三元组:
export interface Principal { id: string; role: "admin" | "sales" | "viewer"; } export function can(principal: Principal, action: string, resource: any): boolean { if (principal.role === "admin") return true; if (principal.role === "viewer") return action === "read"; if (principal.role === "sales") { if (action === "read") return true; if (action === "write") return resource.ownerId === principal.id; } return false; }Web 端从 session 里拿Principal,Agent 端从调用上下文里拿Principal,然后都调can。这样即使 Agent 被赋予了很高的自动化权限,它也只能在can允许的范围内操作。权限逻辑共用,是 Agent 安全的第一道防线,比事后审计靠谱得多。
3.3 扫码采集:把外部数据规范化后再进核心层
扫码采集名片这个功能,热词里也提到了。实际场景是:销售参加展会,用手机扫客户名片上的二维码,二维码里可能是一段 vCard、可能是一个链接、也可能是一段自定义文本。这些格式五花八门,但进入 CRM 之前必须被规范化。
我在core/scan.ts里写了一个解析器,把各种来源统一成CustomerInput:
export function parseScanPayload(raw: string): CustomerInput { // vCard 格式 if (raw.startsWith("BEGIN:VCARD")) { const name = raw.match(/FN:(.+)/)?.[1]?.trim() ?? ""; const phone = raw.match(/TEL[^:]*:(.+)/)?.[1]?.trim(); return { name, phone, source: "scan" }; } // JSON 格式 try { const obj = JSON.parse(raw); return { name: obj.name, phone: obj.phone, source: "scan" }; } catch { // 纯文本,当作名字 return { name: raw.trim(), source: "scan" }; } }这个解析器同样是纯函数,Web 端的扫码接口调它,Agent 端如果收到"帮我解析这段名片信息"的指令也调它。外部数据的脏活累活在 core 层做完,Web 和 Agent 拿到的永远是干净数据。
4. Web 端实现:页面、接口与 PDF 打印
4.1 用 Lume 的 data 机制挂载客户数据
Lume 的_data文件可以把数据源变成页面可读的变量。我在src/web/_data/customers.ts里这样写:
import { listCustomers } from "../../data/store.ts"; export default async function () { return await listCustomers(); }然后在页面模板里直接for customer of customers就能渲染列表。这个机制的好处是,数据读取逻辑也收在 data 层,页面只负责展示。Agent 端要读客户列表,调的是同一个listCustomers,不经过页面。
4.2 表单提交:接口层只做"翻译"
Web 端的表单提交接口,我坚持只做三件事:解析请求、调 core 层、返回结果。绝不在接口里写业务规则。
// src/web/routes/api/customer.ts import { validateCustomer } from "../../../core/customer.ts"; import { createCustomer } from "../../../data/store.ts"; export async function POST(req: Request): Promise<Response> { const body = await req.json(); try { const input = validateCustomer(body); const created = await createCustomer(input); return Response.json({ ok: true, data: created }); } catch (e) { return Response.json({ ok: false, error: e.message }, { status: 400 }); } }你看这个接口,它自己没有任何"客户名称要几个字符"的知识,全部委托给validateCustomer。接口层越薄,逻辑越不会分叉。Agent 端调用时,也是先validateCustomer再createCustomer,路径完全一致。
4.3 PDF 打印:服务端渲染成静态 HTML 再转
热词里有"web页面pdf打印",CRM 里确实需要——销售要把客户跟进记录导成 PDF 发给主管。我的方案是:用 Lume 把跟进记录渲染成一个干净的静态 HTML 页面,再用无头浏览器转 PDF。
为什么不直接在浏览器里window.print()?因为浏览器打印受用户环境影响太大,边距、字体、分页都不可控。服务端渲染的好处是输出完全确定,同一份数据每次导出的 PDF 都一模一样。
// src/web/routes/export/[id].ts export default async function ({ params }: { params: { id: string } }) { const customer = await getCustomer(params.id); const records = await listFollowUps(params.id); return await renderToString("export_template.vto", { customer, records }); }模板里用@media print控制分页,page-break-inside: avoid防止一条跟进记录被拆到两页。这些细节看着小,但实际交付时客户第一个挑的就是这个。
注意:无头浏览器转 PDF 时,一定要显式设置
printBackground: true,否则你精心设计的表头底色全没了,导出效果会很难看。
5. Agent 端实现:让 Agent 安全地操作同一套逻辑
5.1 工具定义:每个工具都是 core 层的一个薄包装
Agent 要操作 CRM,靠的是"工具"。我的原则是:每个工具都只是 core 层函数的一个薄包装,工具本身不含业务逻辑。
// src/agent/tools.ts import { validateCustomer } from "../core/customer.ts"; import { transition } from "../core/customer.ts"; import { createCustomer, getCustomer } from "../data/store.ts"; export const tools = { create_customer: { description: "创建一个新客户", parameters: { name: { type: "string", required: true }, phone: { type: "string" }, industry: { type: "string" }, }, async execute(args: any, principal: Principal) { if (!can(principal, "write", { ownerId: principal.id })) { throw new Error("无权限创建客户"); } const input = validateCustomer({ ...args, source: "manual" }); return await createCustomer(input); }, }, update_status: { description: "更新客户状态", parameters: { id: { type: "string", required: true }, status: { type: "string", required: true }, }, async execute(args: any, principal: Principal) { const customer = await getCustomer(args.id); if (!can(principal, "write", customer)) { throw new Error("无权限修改该客户"); } const updated = transition(customer, args.status); return await saveCustomer(updated); }, }, };注意update_status这个工具,它没有自己判断"能不能从 new 跳到 deal",而是直接调transition,让 core 层去抛错。Agent 拿到的错误信息和 Web 端拿到的完全一样,这样调试的时候不用猜是哪边的问题。
5.2 Agent 安全:三道闸门
热词里有"agent安全",这是真问题。Agent 一旦能操作真实业务系统,风险比 Web 大得多,因为它是自动化的、批量的。我设了三道闸门:
第一道是权限闸门,就是上面说的can函数,Agent 的Principal通常权限受限,只能操作自己名下的客户。
第二道是状态机闸门,transition会拒绝一切非法状态跳转,Agent 想批量把客户直接标成"已成交"是做不到的。
第三道是操作量闸门,我在 Agent 运行时里加了一个计数器,单次会话内写操作超过阈值就暂停并要求人工确认:
export class AgentRuntime { private writeCount = 0; private readonly WRITE_LIMIT = 50; async callTool(name: string, args: any, principal: Principal) { const tool = tools[name]; if (!tool) throw new Error(`未知工具: ${name}`); const isWrite = name.startsWith("create") || name.startsWith("update"); if (isWrite && ++this.writeCount > this.WRITE_LIMIT) { throw new Error("单次会话写操作超限,请人工确认"); } return await tool.execute(args, principal); } }这三道闸门叠起来,Agent 就算被诱导,也很难造成大面积破坏。Agent 安全不是靠一个开关,而是靠层层约束,这是我做完这个项目最深的体会。
5.3 Agent 记忆:只记"事实",不记"逻辑"
热词里有"agent记忆"。我的做法是:Agent 的记忆里只存事实性信息(比如"用户上次查询的是华东区客户"),绝不存业务规则。因为业务规则一旦被 Agent 记进记忆,就可能和 core 层的规则产生偏差,这是最隐蔽的逻辑分叉。
记忆的读写也走 core 层:
export function rememberFact(session: Session, key: string, value: any): Session { return { ...session, facts: { ...session.facts, [key]: value } }; }这样即使换了 Agent 框架,记忆的语义也不会变。
6. 常见问题与排查技巧实录
6.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| Web 能创建客户,Agent 报"名称太短" | 两边传的 name 一个有 trim 一个没有 | 检查是否都走了validateCustomer |
| Agent 批量改状态失败 | 触发了状态机非法跳转 | 看错误信息里的 from/to,对照状态表 |
| PDF 导出缺表头底色 | 无头浏览器没开printBackground | 显式设置该参数 |
| 扫码采集进来一堆空客户 | 解析器没处理空字符串 | 在parseScanPayload里加空值兜底 |
| Agent 写操作被拦 | 超过WRITE_LIMIT | 调大阈值或改人工确认流程 |
6.2 我踩过的三个坑
第一个坑:core 层不小心 import 了 Web 类型。早期我在core/customer.ts里为了图方便,直接接收Request对象,结果 Agent 端根本没法用,只能重写。后来我用 lint 规则禁止 core 目录 import 任何web或agent路径,才彻底根治。
第二个坑:Agent 的错误信息太模糊。一开始 Agent 工具里用try/catch把 core 的报错吞掉了,只返回"操作失败",导致排查时完全不知道是权限问题还是状态问题。后来我改成原样透传 core 的错误信息,Agent 和 Web 看到的报错一模一样,排查效率立刻上来了。
第三个坑:扫码采集的编码问题。有些名片二维码里是 GBK 编码的中文,直接按 UTF-8 解析会乱码。解决办法是在解析前先做编码探测,或者要求采集端统一转成 UTF-8 再生成二维码。这个坑不踩一次根本想不到。
提示:如果你也在做"Web + Agent 共用逻辑"的项目,建议第一天就把 core 层的边界用 lint 卡死。等到项目中期再重构,成本会高十倍。
6.3 一个实用的调试技巧
我给自己写了一个小脚本,专门用来验证"同一操作在 Web 和 Agent 下结果是否一致":
// scripts/consistency_check.ts const input = { name: "测试客户", phone: "13800138000" }; const webResult = await webCreate(input); const agentResult = await agentCreate(input, adminPrincipal); console.log("Web:", webResult); console.log("Agent:", agentResult); // 两者除 id 外应完全一致每次改完 core 层,跑一遍这个脚本,几秒钟就能确认两边没跑偏。一致性不是靠人盯,是靠脚本自动验证,这是我强烈推荐的习惯。
7. 关于"永久在线"与部署的一点经验
热词里有"永久在线的crm网站",这其实是所有自建 CRM 的人最终都会面对的问题。我的方案是:Web 端和 Agent 端跑在同一个 Deno 进程里,用同一个Deno.serve起服务,这样部署只需要管一个进程,不用维护两套。
// main.ts import { handleWeb } from "./src/web/server.ts"; import { handleAgent } from "./src/agent/server.ts"; Deno.serve({ port: 8000 }, async (req) => { const url = new URL(req.url); if (url.pathname.startsWith("/agent")) { return await handleAgent(req); } return await handleWeb(req); });单进程的好处是数据层天然共享,不存在两个进程读同一份数据导致的不一致。坏处是 Agent 的负载会影响 Web,所以我在 Agent 路由上加了独立的超时和并发限制,避免 Agent 的长任务把 Web 拖垮。
至于"永久在线",我的经验是别迷信任何单一方案。数据要能随时导出成标准格式(我用的是 JSON + CSV 双备份),这样即使服务迁移,数据也不会丢。CRM 这种系统,数据比服务重要得多。
最后分享一个我在实际使用中的小技巧:给 Agent 的每个写操作都打上source: "agent"标记,Web 操作打source: "web"。这样事后审计时,一眼就能看出哪些数据是 Agent 改的。这个标记成本极低,但排查问题时价值极高,尤其是当 Agent 出现异常行为时,能快速定位影响范围。