☰
用 Lume 重写 CRM:一套业务逻辑同时服务 Web 与 AI Agent
2026/10/27 3:26:04 网站建设 项目流程

1. 为什么我决定用 Lume 重写一套 CRM

做 CRM 这行有些年头了,从最早的 PHP 单体,到后来的前后端分离,再到这两年各种低代码平台,我基本都趟过一遍。去年年底接了个内部需求,要给自己团队做一套轻量 CRM,核心诉求就三条:销售在浏览器里能正常用,我自己的 AI Agent 能直接调同一套接口,代码只维护一份。听起来简单,但真动手的时候,坑比想象中多。

传统做法是 Web 一套 REST API,Agent 再单独写一套工具函数去调。问题在于,业务逻辑一旦分叉,两边就开始漂移。比如"客户去重"这个规则,Web 端按手机号判重,Agent 端忘了加,结果 Agent 批量导入时塞了一堆重复客户,销售在页面上看到直接炸锅。这种漂移不是靠 code review 能根治的,因为根因是逻辑被复制了两份。

Lume 这个框架吸引我的点,恰好就在这。它是基于 Deno 的静态站点生成器,但真正好用的地方在于它的Data 层和Processor 机制——你可以把业务逻辑写成纯函数,Web 页面渲染时调用它,Agent 通过 HTTP 或直接 import 也调用它。同一份代码,两个入口。这不是什么新概念,但 Lume 把它做得足够轻,轻到我可以在一周内把原型跑起来。

这篇文章我会把整套设计思路、目录结构、核心代码、踩过的坑全部摊开讲。适合两类人看:一是想给现有 Web 项目接 Agent 能力的后端同学,二是想用一套逻辑同时服务人和机器的独立开发者。不需要你精通 Deno,但至少要写过 JavaScript 或者 TypeScript。

2. 整体架构设计:一份逻辑,两个入口

2.1 核心思路:把业务逻辑从"路由"里抽出来

大部分人写 Web 项目,习惯把逻辑直接写在路由处理函数里。比如/api/customer/create这个接口,参数校验、去重、写库、返回,全在一个函数里。这种写法在纯 Web 场景下没问题,但一旦 Agent 要复用,你就只能复制粘贴。

我的做法是反过来:先写业务逻辑层,再写 Web 路由层,最后写 Agent 工具层。业务逻辑层是纯 TypeScript 模块,不依赖任何 HTTP 上下文,输入是普通对象,输出也是普通对象。Web 路由和 Agent 工具都只是这层的"适配器"。

用生活化的类比:业务逻辑层是厨房,Web 和 Agent 是两个服务员。服务员负责点单和上菜,但菜怎么做、放多少盐,全由厨房说了算。服务员换了,菜的味道不变。

2.2 为什么选 Lume 而不是 Next.js 或 Hono

这里得说清楚,Lume 严格来说不是 Web 框架,它是静态站点生成器。我选它的原因有三个:

第一,Deno 原生支持 TypeScript,不需要配置 tsconfig、不需要构建步骤,改完代码直接跑。对于我这种讨厌 webpack 配置的人来说,省心程度直接拉满。

第二,Lume 的 Data 机制天然适合做"逻辑复用"。你可以在_data目录下写一个customers.ts,导出一个函数,页面模板里直接await它。这个函数同时可以被 Agent 的 HTTP handler import,零成本复用。

第三,部署简单。Lume 构建出来是静态文件,扔到任何静态托管上都能跑。但如果你需要动态接口(比如 Agent 调用),可以用 Deno 的serve起一个轻量服务,两者共存。

当然,Lume 也有短板。它没有内置的 ORM、没有中间件体系、没有成熟的鉴权方案。这些都得自己搭。但对于一个内部 CRM 来说,这些恰恰不是负担,反而是自由。

2.3 目录结构:让"共用"变成物理事实

我最终的目录结构是这样的:

crm/ ├── _data/ │ └── customers.ts # 业务逻辑层:客户相关 │ └── deals.ts # 业务逻辑层:商机相关 │ └── db.ts # 数据库连接 ├── _includes/ │ └── layout.njk # 页面布局 ├── api/ │ └── agent.ts # Agent 专用 HTTP 入口 ├── src/ │ └── index.njk # Web 页面 │ └── customer.njk # 客户详情页 ├── _config.ts # Lume 配置 └── deno.json # Deno 配置

关键点在于_data目录。Lume 会自动把这个目录下的文件作为全局数据源,页面模板可以直接访问。但更重要的是,这些文件本质就是普通的 TypeScript 模块,Agent 的 handler 里import一下就能用。

提示:不要把业务逻辑写在_data里就直接完事。_data是 Lume 的约定,但逻辑本身应该放在独立的lib/目录,_data只做一层薄薄的导出。这样即使以后换框架,逻辑层也不用动。

2.4 数据流:从请求到响应的完整链路

以"创建客户"为例,完整链路是这样的:

  1. Web 端:用户在表单提交 → POST 到/api/customer/create→ 路由 handler 解析 body → 调用createCustomer()→ 返回 JSON
  2. Agent 端:Agent 调用POST /api/agent→ handler 解析意图 → 调用同一个createCustomer()→ 返回 JSON
  3. createCustomer()内部:参数校验 → 手机号去重 → 写数据库 → 返回新客户对象

两个入口的差异只在"参数怎么来"和"结果怎么返回",中间的校验、去重、写库完全一致。这就是"共用同一套业务逻辑"的字面意思。

3. 业务逻辑层的核心实现细节

3.1 客户去重:一个容易被忽略的坑

去重规则看起来简单,但实际做的时候要考虑几种情况:

  • 手机号完全相同:直接判定重复
  • 手机号为空但邮箱相同:判定重复
  • 手机号格式不规范(比如带空格、带横线):先归一化再比较

我的实现是这样的:

// lib/customer.ts export interface Customer { id: string; name: string; phone: string; email: string; createdAt: string; } export function normalizePhone(phone: string): string { return phone.replace(/[\s\-\(\)]/g, ""); } export async function findDuplicate( phone: string, email: string, ): Promise<Customer | null> { const db = await getDb(); const normalized = normalizePhone(phone); if (normalized) { const byPhone = await db.customers.findOne({ phone: normalized }); if (byPhone) return byPhone; } if (email) { const byEmail = await db.customers.findOne({ email: email.toLowerCase() }); if (byEmail) return byEmail; } return null; }

这里有个细节:归一化必须在写入前做,而不是查询时做。我一开始偷懒,只在查询时归一化,结果数据库里存了138-0000-0000和13800000000两条记录,查询时归一化后匹配到两条,逻辑直接乱套。后来改成写入前统一归一化,问题消失。

3.2 参数校验:用 Zod 还是手写

Deno 生态里 Zod 可以用,但我最后选择了手写校验函数。原因有两个:一是 Zod 的报错信息对 Agent 不友好,Agent 拿到"Expected string, received number"这种信息没法自己修正;二是手写校验可以顺便做业务规则校验,比如"手机号必须是 11 位"。

export function validateCustomerInput(input: unknown): { ok: boolean; errors: string[]; data?: CustomerInput; } { const errors: string[] = []; const obj = input as Record<string, unknown>; if (typeof obj.name !== "string" || obj.name.trim().length === 0) { errors.push("name 必须是非空字符串"); } const phone = normalizePhone(String(obj.phone ?? "")); if (phone && !/^1\d{10}$/.test(phone)) { errors.push("phone 格式不正确,应为 11 位数字"); } const email = String(obj.email ?? "").toLowerCase(); if (email && !/^[^@]+@[^@]+\.[^@]+$/.test(email)) { errors.push("email 格式不正确"); } if (errors.length > 0) { return { ok: false, errors }; } return { ok: true, errors: [], data: { name: obj.name.trim(), phone, email }, }; }

注意:错误信息要写成"人话",因为 Agent 会把这些信息回传给用户。"phone 格式不正确,应为 11 位数字"比"invalid phone"有用得多。

3.3 数据库选型:Deno KV 够不够用

Deno 内置了 Deno KV,对于内部 CRM 这种量级(几千到几万条记录)完全够用。它的 API 很简单:

const kv = await Deno.openKv(); // 写入 await kv.set(["customers", id], customer); // 查询 const result = await kv.get(["customers", id]); // 列表 const iter = kv.list({ prefix: ["customers"] }); const customers = []; for await (const entry of iter) { customers.push(entry.value); }

但 Deno KV 有个限制:不支持复杂查询。比如"查询所有创建时间在某个区间内的客户",你得自己遍历。对于 CRM 来说,这种查询很常见。我的解决方案是维护一个二级索引:

// 写入时同时写索引 await kv.set(["customers", id], customer); await kv.set(["customers_by_created", customer.createdAt, id], id); // 按时间范围查询 const iter = kv.list({ prefix: ["customers_by_created"], start: ["customers_by_created", startDate], end: ["customers_by_created", endDate], });

这个模式叫"索引表",在 KV 数据库里是标配。代价是写入时要多写一次,但查询效率提升明显。

3.4 逻辑层的"纯函数"原则

我给自己定了一条规矩:lib/目录下的函数,不允许直接访问Request或Response对象。所有输入通过参数传入,所有输出通过返回值传出。这样做的好处是:

  • 单元测试极其简单,不需要 mock HTTP 上下文
  • Agent 调用时不需要构造假的 Request
  • 逻辑可以随时被 CLI 脚本、定时任务复用

违反这条规矩的代码,我见过太多。比如有人在业务函数里直接return new Response(...),结果 Agent 调用时拿到一个 Response 对象,还得再解析一遍。这种设计就是没想清楚"逻辑层"和"适配层"的边界。

4. Web 端与 Agent 端的适配层实现

4.1 Web 路由:薄薄一层,只做三件事

Web 路由的职责被压缩到极致:解析请求、调用逻辑层、返回响应。以创建客户为例:

// api/customer/create.ts import { createCustomer } from "../../lib/customer.ts"; export async function handler(req: Request): Promise<Response> { if (req.method !== "POST") { return new Response("Method Not Allowed", { status: 405 }); } const body = await req.json(); const result = await createCustomer(body); if (!result.ok) { return Response.json({ errors: result.errors }, { status: 400 }); } return Response.json({ customer: result.customer }, { status: 201 }); }

注意这里没有任何业务逻辑。参数校验在createCustomer里,去重在createCustomer里,写库也在createCustomer里。路由只负责 HTTP 语义的转换。

4.2 Agent 入口:用"意图路由"代替"接口路由"

Agent 的调用方式和 Web 不一样。Web 是"一个接口对应一个操作",Agent 是"一个入口对应多种意图"。所以我设计了一个统一的/api/agent端点:

// api/agent.ts import { createCustomer, listCustomers, updateCustomer } from "../lib/customer.ts"; const handlers: Record<string, (params: unknown) => Promise<unknown>> = { create_customer: createCustomer, list_customers: listCustomers, update_customer: updateCustomer, }; export async function handler(req: Request): Promise<Response> { const { action, params } = await req.json(); const fn = handlers[action]; if (!fn) { return Response.json({ error: `未知操作: ${action}` }, { status: 400 }); } const result = await fn(params); return Response.json(result); }

Agent 只需要知道有哪些action,以及每个action需要什么参数。这些信息可以通过一个/api/agent/schema端点暴露出去,Agent 自己读取后就能调用。

4.3 参数适配:Web 用表单,Agent 用 JSON

Web 端提交的是表单数据,Agent 提交的是 JSON。这两者的差异在适配层处理:

// Web 端 const formData = await req.formData(); const input = { name: formData.get("name"), phone: formData.get("phone"), email: formData.get("email"), }; // Agent 端 const input = params; // 已经是 JSON 对象

逻辑层收到的都是同一个input对象,不关心它从哪来。这就是适配层的价值。

4.4 返回格式:给 Agent 的响应要"自解释"

Web 端的响应可以简单,因为前端知道怎么处理。但 Agent 端的响应必须"自解释",因为 Agent 可能要把结果转述给用户。我的做法是在响应里加一个message字段:

// 逻辑层返回 return { ok: true, customer: newCustomer, message: `客户 ${newCustomer.name} 创建成功`, }; // Agent 端直接把这个 message 回传给用户

这个message字段在 Web 端可以忽略,但在 Agent 端是刚需。我试过不加这个字段,结果 Agent 拿到{ id: "xxx", name: "张三" }后,自己编了一段话回给用户,编得乱七八糟。加上message后,Agent 直接转述,准确率大幅提升。

5. 实操过程:从零到跑通的完整记录

5.1 环境准备:Deno 安装与项目初始化

第一步是装 Deno。Windows 下用 PowerShell:

irm https://deno.land/install.ps1 | iex

macOS 或 Linux 下:

curl -fsSL https://deno.land/install.sh | sh

装完后验证:

deno --version

应该输出类似deno 1.40.0的信息。然后初始化项目:

mkdir crm && cd crm deno init

这会生成deno.json和main.ts。接着安装 Lume:

deno run -A https://deno.land/x/lume/init.ts

这个命令会问你几个问题,比如"是否使用 TypeScript"、"是否启用 JSX",按需选择即可。我全部选了默认。

5.2 配置 Lume:让静态页面和动态接口共存

Lume 默认只处理静态页面。要加动态接口,需要在_config.ts里配置:

import lume from "lume/mod.ts"; const site = lume({ src: "./src", dest: "./_site", }); // 把 api 目录下的文件作为动态路由 site.addEventListener("beforeBuild", () => { // 这里可以做一些构建前的准备 }); export default site;

但 Lume 本身不处理动态请求。我的做法是:Lume 负责构建静态页面,Deno 的serve负责动态接口。两者通过一个统一的入口启动:

// server.ts import { serveDir } from "https://deno.land/std/http/file_server.ts"; import { handler as agentHandler } from "./api/agent.ts"; import { handler as customerHandler } from "./api/customer/create.ts"; Deno.serve({ port: 8000 }, async (req) => { const url = new URL(req.url); // 动态接口 if (url.pathname === "/api/agent") { return agentHandler(req); } if (url.pathname === "/api/customer/create") { return customerHandler(req); } // 静态文件 return serveDir(req, { fsRoot: "./_site" }); });

启动时先跑deno task build(Lume 构建),再跑deno run -A server.ts。两个命令可以合并成一个deno task start。

5.3 写第一个业务逻辑:创建客户

在lib/customer.ts里写核心逻辑:

import { getDb } from "./db.ts"; export interface CustomerInput { name: string; phone: string; email: string; } export interface Customer extends CustomerInput { id: string; createdAt: string; } export async function createCustomer(input: unknown): Promise<{ ok: boolean; errors?: string[]; customer?: Customer; message?: string; }> { const validation = validateCustomerInput(input); if (!validation.ok) { return { ok: false, errors: validation.errors }; } const { name, phone, email } = validation.data!; const duplicate = await findDuplicate(phone, email); if (duplicate) { return { ok: false, errors: [`客户已存在: ${duplicate.name} (${duplicate.phone})`], }; } const customer: Customer = { id: crypto.randomUUID(), name, phone, email, createdAt: new Date().toISOString(), }; const db = await getDb(); await db.set(["customers", customer.id], customer); await db.set(["customers_by_created", customer.createdAt, customer.id], customer.id); return { ok: true, customer, message: `客户 ${customer.name} 创建成功`, }; }

这段代码里,validateCustomerInput和findDuplicate都是前面写好的函数。整个流程清晰:校验 → 去重 → 构造对象 → 写库 → 返回。

5.4 写 Web 页面:用 Nunjucks 模板渲染客户列表

Lume 支持多种模板引擎,我选了 Nunjucks,因为语法简单。在src/index.njk里:

--- layout: layout.njk title: 客户列表 --- <h1>客户列表</h1> <table> <thead> <tr> <th>姓名</th> <th>手机号</th> <th>邮箱</th> <th>创建时间</th> </tr> </thead> <tbody> {% for customer in customers %} <tr> <td>{{ customer.name }}</td> <td>{{ customer.phone }}</td> <td>{{ customer.email }}</td> <td>{{ customer.createdAt }}</td> </tr> {% endfor %} </tbody> </table>

customers这个变量从哪来?从_data/customers.ts来:

// _data/customers.ts import { listCustomers } from "../lib/customer.ts"; export default async function () { const result = await listCustomers({}); return result.customers ?? []; }

Lume 在构建时会自动执行这个函数,把返回值注入到模板里。这就是"业务逻辑复用"的体现——listCustomers同时被 Web 页面和 Agent 调用。

5.5 写 Agent 工具:让 AI 能创建客户

Agent 端的调用很简单,因为它只需要发一个 HTTP 请求:

// agent-tool.ts export async function callAgent(action: string, params: unknown) { const res = await fetch("http://localhost:8000/api/agent", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, params }), }); return res.json(); } // 使用 const result = await callAgent("create_customer", { name: "李四", phone: "13900000000", email: "lisi@example.com", }); console.log(result.message); // "客户 李四 创建成功"

如果你用的是支持 function calling 的 Agent 框架,可以把action和params的 schema 暴露出去,Agent 自己决定什么时候调用。

5.6 参数计算:手机号归一化的具体过程

前面提到手机号归一化,这里展开说下具体计算过程。假设用户输入"138-0000-0000":

  1. 正则replace(/[\s\-\(\)]/g, "")匹配所有空格、横线、括号
  2. 替换为空字符串
  3. 结果:"13800000000"

再假设用户输入"138 0000 0000"(带空格):

  1. 同样正则匹配空格
  2. 替换为空
  3. 结果:"13800000000"

如果用户输入"(138) 0000-0000":

  1. 匹配括号、空格、横线
  2. 全部替换
  3. 结果:"13800000000"

这个归一化必须在写入前做,否则数据库里会存多种格式,查询时匹配不到。

6. 常见问题与排查技巧实录

6.1 问题速查表

问题现象可能原因排查方法解决方案
Agent 调用返回 404路由未注册检查server.ts里的路径匹配添加对应的if分支
创建客户时报"重复"但数据库没有归一化不一致打印归一化前后的值统一在写入前归一化
Web 页面显示空白_data函数报错看 Lume 构建日志修复函数内的异常
Agent 返回乱码响应未设置 Content-Type检查Response.json的使用用Response.json而非new Response
Deno KV 查询慢缺少索引看查询是否全表扫描添加二级索引
表单提交后页面不刷新前端未处理响应看浏览器控制台添加location.reload()

6.2 踩过的坑:Agent 的"幻觉参数"

Agent 调用工具时,经常会传一些不存在的参数。比如我定义的create_customer只需要name、phone、email,但 Agent 有时会传company、position这些字段。如果逻辑层不处理,这些字段会被忽略,但 Agent 以为传成功了,后续可能会引用这些字段,导致错误。

我的解决方案是在校验函数里加一条:拒绝未知字段。

const allowedKeys = ["name", "phone", "email"]; const unknownKeys = Object.keys(obj).filter((k) => !allowedKeys.includes(k)); if (unknownKeys.length > 0) { errors.push(`未知字段: ${unknownKeys.join(", ")}`); }

这样 Agent 传错字段时会收到明确的错误信息,它自己会修正。

6.3 踩过的坑:Lume 构建时的"数据快照"

Lume 在构建时会执行_data里的函数,把结果"快照"到静态页面里。这意味着页面上的数据是构建时的数据,不是实时的。如果你在页面上看到客户列表,然后通过 Agent 创建了一个新客户,刷新页面不会看到新客户,除非重新构建。

这个坑我踩得很深。一开始我以为 Lume 会实时查询,结果发现页面数据永远是旧的。后来改成:静态页面只做展示框架,数据通过前端 fetch 动态加载。

<div id="customer-list"></div> <script> fetch("/api/customer/list") .then((res) => res.json()) .then((data) => { // 渲染列表 }); </script>

这样页面是静态的,但数据是动态的。Lume 只负责构建 HTML 骨架,数据由 API 提供。

6.4 踩过的坑:Deno KV 的"最终一致性"

Deno KV 在某些部署环境下是最终一致的。意思是:你写入一条数据,立刻查询可能查不到。这个特性在本地开发时不会遇到,但部署到云端后会出现。

我的应对策略是:写入后不立刻查询,而是直接返回写入的对象。比如createCustomer里,写完库后直接返回customer对象,而不是再查一次。这样避免了"写完查不到"的问题。

await db.set(["customers", customer.id], customer); // 不要这样: // const saved = await db.get(["customers", customer.id]); // return saved.value; // 而是直接返回: return { ok: true, customer };

6.5 独家技巧:用"影子模式"验证 Agent 调用

Agent 调用业务逻辑时,我担心它会误操作(比如批量删除客户)。所以我加了一个"影子模式":Agent 的请求先走一遍逻辑,但不真正写库,只返回"如果执行会怎样"。

export async function createCustomer(input: unknown, dryRun = false) { // ... 校验、去重 ... if (dryRun) { return { ok: true, customer: { ...customer, id: "dry-run" }, message: `[影子模式] 将创建客户 ${customer.name}`, }; } // ... 真正写库 ... }

Agent 入口默认开启影子模式,只有明确传dryRun: false时才真正执行。这个技巧帮我避免了好几次误操作。

6.6 独家技巧:给 Agent 的响应加"下一步建议"

Agent 拿到结果后,往往不知道下一步该做什么。我在响应里加了一个nextActions字段:

return { ok: true, customer, message: `客户 ${customer.name} 创建成功`, nextActions: [ { action: "create_deal", label: "为该客户创建商机" }, { action: "list_customers", label: "查看客户列表" }, ], };

Agent 可以直接把这些建议展示给用户,用户点一下就能继续操作。这个设计让 Agent 的交互体验提升了一个档次。

7. 部署与运维:让 CRM 永久在线

7.1 部署方案选型

Lume 构建出来是静态文件,可以扔到任何静态托管上。但动态接口需要 Deno 运行时。我的方案是:

  • 静态文件:部署到静态托管(比如 Deno Deploy 的静态资源、或者任何 CDN)
  • 动态接口:部署到 Deno Deploy 的 Serverless 函数

Deno Deploy 的好处是免费额度够用,而且和 Deno KV 无缝集成。部署命令:

deployctl deploy --project=crm --prod server.ts

7.2 环境变量管理

数据库连接、API 密钥这些敏感信息不能写死在代码里。Deno 支持.env文件:

# .env KV_URL=https://... AGENT_TOKEN=xxx

读取:

const kvUrl = Deno.env.get("KV_URL");

注意:.env文件不要提交到 Git。在.gitignore里加上.env。

7.3 监控与日志

Deno Deploy 自带日志面板,但只能看最近的。我加了一个简单的日志中间件:

async function withLogging(req: Request, handler: () => Promise<Response>) { const start = Date.now(); const res = await handler(); const duration = Date.now() - start; console.log(`${req.method} ${req.url} ${res.status} ${duration}ms`); return res; }

这个日志帮我定位过好几次性能问题。比如某次 Agent 调用特别慢,看日志发现是findDuplicate全表扫描,加了索引后从 800ms 降到 20ms。

7.4 数据备份

Deno KV 的数据备份很简单,遍历所有 key 导出 JSON:

export async function backup() { const kv = await Deno.openKv(); const data: Record<string, unknown> = {}; for await (const entry of kv.list({ prefix: [] })) { data[JSON.stringify(entry.key)] = entry.value; } await Deno.writeTextFile(`backup-${Date.now()}.json`, JSON.stringify(data)); }

我设置了一个定时任务,每天凌晨跑一次备份。虽然 Deno KV 本身有持久化,但多一层备份总是好的。

8. 后续扩展方向

这套架构跑通后,扩展起来很轻松。我目前想到的几个方向:

第一,加权限系统。现在所有 Agent 调用都是无差别的,后续可以加一个role字段,不同角色的 Agent 能调用的action不同。

第二,加审计日志。每次 Agent 调用都记录到 KV 里,方便追溯"谁在什么时候做了什么"。

第三,加 Webhook。客户创建后触发 Webhook,通知其他系统。这个只需要在createCustomer里加一行fetch(webhookUrl, ...)。

第四,加批量操作。Agent 经常需要批量导入客户,可以加一个batch_create_customers的 action,内部循环调用createCustomer。

这套东西的核心价值不在于技术多先进,而在于结构清晰。业务逻辑、Web 适配、Agent 适配三层分离,每层职责明确。改业务逻辑时不用担心影响 Web 或 Agent,改 Web 时不用担心影响 Agent。这种清晰度,在项目变大后尤其重要。

我个人在实际操作中的体会是:不要为了"复用"而过度抽象。我见过有人把业务逻辑抽成十几个接口、几十个类,结果改一个字段要动五个文件。我的做法是保持逻辑层"扁平"——一个功能一个函数,函数之间通过参数传递数据,不搞复杂的继承和依赖注入。简单,但够用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询