CRM —— 一个业务模块的解剖
本页用开源 crm-starter 插件(com.auraboot.crm-starter,命名空间 crm)讲清楚一件事:AuraBoot 上的业务模块长什么样、怎么搭出来。CRM 最普适——线索、商机、活动人人都懂。
更重要的是:AuraBoot 上每个业务模块都是同一个形状——模型 → 命令 → 权限 → 页面,全用 DSL 声明。所以读懂这一篇,采购、库存、质量、生产 那些模块你也就懂了大半——它们只是换了名词和领域规则。全部模块见 业务场景目录。
crm-starter是config型 demo(6 个 model);更完整的企业版 CRM(报价 / SLA / 投诉)是另一个插件。本页只描述 demo 真实交付的内容,每个标识符都能 grep 到。 源码:crm-starter on GitHub ↗
1. 用户场景
一支 B2B 销售团队:市场活动收集线索,跟进、资格化后转成商机,推进到赢单;过程中记录活动(电话/拜访/邮件),客户和联系人沉淀为账户主数据。难点不是存数据,而是每一步状态变更都要被授权、留痕,并且销售经理、市场、财务看到的视图和能做的操作各不相同。

2. 常规解法 vs AuraBoot 的设计哲学
这一节是平台级的设计哲学,各个 deep-dive 都引用回这里。
2.1 常规解法是怎样的
如果让一个团队从零做这个,通常是这条路:
- 领域建模:画实体和关系——账户、联系人、线索、商机、活动……
- 建表:翻译成一堆
CREATE TABLE,加外键、索引、状态字段。 - 写 CRUD:每个实体一套 Repository / Service / Controller。
- 把业务规则 hard-code 进 service:线索资格化、商机阶段流转(discovery→qualification→…)、转化时建商机、字段校验——全写死在 Java 里。
- 各处补横切关注点:权限判断散落在每个 controller;审计靠手动插日志;想把「商机赢单」事件发给下游(订单/财务),再搭一套 MQ。
- 前端:每个页面手写表单、列表、详情、看板。
- 想接 AI / 自动化:再包一层 API,再定义一遍权限和风险边界。
能跑,但业务规则散落在代码各处,改一条规则要同时动 service + controller + 前端;并发、审计、事件、AI 入口每一样都得自己扛。
2.2 AuraBoot 的设计哲学
AuraBoot 把这件事反过来:业务的「是什么」用声明描述,「怎么执行」由运行时统一兜底。四条核心理念,正好消掉上面的痛:
- 元数据驱动:model / field / command / page / permission 都是声明(DSL JSON),不是手写代码。平台据此自动建表、生成接口、渲染页面——上面第 2、3、6 步基本消失。
- 单一命令管道:所有写入都是一条命令,走同一条 命令管道。鉴权、校验、事务、审计、发事件这些横切关注点由管道统一处理,不写进每个业务里(第 4、5 步从「自己扛」变成「平台默认」)。
- 命令即契约:同一条命令同时服务 UI 按钮、自动化规则、BPM 节点、AI agent(靠
cmd_risk_level+agent_hint分级)。AI 原生不是事后包 API,而是天生的(第 7 步免了)。 - 五层权限内建:RBAC + ReBAC + 组织域 + ABAC + 字段级 在管道里统一求值,不在 controller 里手写
if。
2.3 同一个功能,两种活法
| 能力 | 自己写(手写后端) | 打包 ERP / 典型低代码 | AuraBoot |
|---|---|---|---|
| 状态流转 | 状态机写死在 service,改流程改代码 | 流程现成但改不动 | 声明 crm:qualify_opportunity(discovery→qualification),状态机即配置 |
| 操作的唯一入口 | UI 调 controller,自动化/脚本各走各的 | 多为 UI 封闭 | 一条命令,UI / 自动化 / BPM / AI 同一条路径 |
| 权限粒度 | controller 里手写判断,字段级靠拼 | 角色粗粒度,字段级常缺 | 五层权限内建,字段级开箱 |
| 审计 / 事件 | 手动插日志、再搭 MQ | 闭源难 hook | 管道自动留痕 + 发事件 |
| 被 AI 调用 | 再包一层 API,无风险分级 | 基本无原生 AI 入口 | 命令带 cmd_risk_level + agent_hint,安全可控 |
一句话:手写灵活但什么都要自己扛;打包 ERP 快但改不动;AuraBoot 用声明式配置拿到一套带权限、审计、AI 入口的业务内核,而且能被你自由扩展。
3. 一个业务模块的解剖
每个业务模块都是这四件套。看懂 CRM 的,其它模块照搬。
3.1 模型(model + field)——声明数据
crm-starter 有 6 个 model:crm_account(账户)、crm_contact(联系人)、crm_lead(线索)、crm_opportunity(商机)、crm_activity(活动)、crm_campaign(营销活动)。
字段是物理列,按 model 加前缀。例如 crm_lead:crm_lead_company、crm_lead_contact_email、crm_lead_source(enum)、crm_lead_score(integer)、crm_lead_status(enum)。类型用平台 dataType(string / integer / enum / text / date / decimal…),没有 string(120) 这种内联长度语法。详见 Model 与 Field。

3.2 命令(command)——声明操作 + 状态机
命令命名 crm:<动词>_<名词>。线索的生命周期就是一串状态流转命令:crm:contact_lead(new→contacted)、crm:qualify_lead、crm:convert_lead(转成商机)。一条命令的真实定义:
{
"code": "crm:contact_lead",
"displayName:zh-CN": "标记已联系",
"type": "state_transition",
"modelCode": "crm_lead",
"stateField": "crm_lead_status",
"fromStates": ["new"],
"toState": "contacted",
"permissions": ["crm.lead.manage"],
"agent_hint": "Transition crm lead status from NEW to CONTACTED.",
"cmd_risk_level": "L1"
}读出来的设计信息:这是一条 state_transition(new → contacted),要求 crm.lead.manage 权限,风险级 L1,并通过 agent_hint 告诉 AI agent 它能做什么。页面上的「标记已联系」按钮接的就是这个命令码,而不是裸写表——所以 UI、自动化、agent 走的是同一条被授权、被审计的路径。详见 Command。
3.3 页面(page)——声明 UI
crm-starter 用 21 个页面资源(6 个 model 各 list / form / detail = 18,加线索对账台,加 2 个 dashboard〔总览 + 线索分析〕)呈现这些数据,全部由 DSL 声明,Page Designer 拖出来。页面上的按钮绑命令码,列表/详情绑 model 字段。

3.4 权限(permission + role)——声明谁能做什么
12 个权限码(<模块>.<资源>.<动作>,每资源 manage + read),2 个角色 crm_admin / crm_sales。它们由平台的五层权限引擎求值。
四件套就是全部:换个领域(采购、库存、质量),就是换一组 model + 命令 + 权限 + 页面,形状不变。
4. 具体开发与实施
先掌握基础:Model 与 Field · Command · 命令管道 · Permission · 插件清单 · 纯配置 Plugin · Page Designer。
落地一个 config 插件的步骤:① 定义 model 与字段 → ② 声明命令(状态流转用 type: state_transition + 绑权限)→ ③ 配 model-field binding(独立 bindingRules.json 并在 resourceDirs 注册)→ ④ 设计页面 → ⑤ 权限/角色/字典/菜单 → ⑥ 用 aura CLI import-directory-sync 导入,校验返回 success:true。
5. 典型错误
- 命令码用点号:写成
crm.lead.contact跑不通——真实是冒号 + 动词_名词:crm:contact_lead。 - 绕过命令直接改表:跳过状态机、权限、审计和事件。一切写入都走命令。
- bindingRules 内联进 commands.json:不会被导入;必须独立
bindingRules.json并在resourceDirs注册。详见 纯配置 Plugin。 - 命令执行 payload 结构:字段放
{ "payload": { ... }, "operationType": ... },目标记录用targetRecordId(不是recordId)。