EspoCRM 定制开发:升级安全与工程化规范
适用版本
EspoCRM 9.2.2+(开源版)
背景
很多团队做 EspoCRM 定制,第一阶段靠”改得快”赢;第二阶段会被”不可升级、不可回滚、不可定位问题”拖垮。
具体表现:
- 花两周做的定制,官方更新后全部失效
- 团队成员改了核心文件,导致无法升级
- 代码和配置混在一起,出问题无法定位
- 没有备份机制,系统崩溃后无法恢复
本文的目标很明确:不讨论”改核心文件最快”的玩法,只讨论”升级后仍可活”的做法。
阅读指引
适合谁读
1 2 3 4
| □ 第一次用 EspoCRM 做定制开发 □ 升级时曾经踩过坑 □ 需要给团队定开发规范 □ 正在做企业级项目,不能冒险
|
读完你能获得
- 一套可复制的 EspoCRM 定制开发模板
- 扩展点选择优先级(金字塔)
- 完整的模块结构与部署规范
- 可直接使用的备份与回滚脚本
一、扩展点选择金字塔
1.1 默认策略:能不写代码就不写代码
| 优先级 |
扩展点 |
适用场景 |
| 1 |
Formula |
简单计算和条件逻辑 |
| 2 |
Dynamic Logic |
界面显示与字段依赖 |
| 3 |
Workflow / BPM |
复杂业务流程(谨慎用) |
| 4 |
Hook |
数据一致性保障(禁止重逻辑) |
| 5 |
Service / Controller |
API 与复杂逻辑(最后手段) |
核心原则:先用 Dynamic Logic 解决体验问题,再用 Hook/Service 解决”绕过与一致性”。
1.2 五条红线
| 红线 |
后果 |
不修改 application/ 目录 |
每次升级都要重写 |
| 不硬编码环境信息 |
迁移环境就爆炸 |
| 不混用配置与代码 |
无法审计、无法回滚 |
| Hook 里不做重逻辑 |
会阻塞保存流程 |
| 不绕过 ACL |
安全事故 |
二、模块化架构
2.1 目录分区原则
核心思想:把管理员(GUI)产生的配置,和开发者(代码)交付的内容,物理隔离。
1 2 3 4 5 6 7 8 9 10
| custom/ ├── Espo/Custom/ # 管理员配置区 │ └── Resources/metadata/ └── Espo/Modules/{ModuleName}/ # 开发者模块区 ├── Controllers/ ├── Services/ ├── Hooks/ └── Resources/ ├── metadata/ └── i18n/
|
为什么?因为管理员配置可变、不可审计,而开发者代码必须可审计、可回滚、可复现。
2.2 完整后端模块结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| custom/Espo/Modules/MyModule/ ├── Module.php # 模块定义类 ├── Controllers/ # API 控制器 ├── Services/ # 业务逻辑服务层 ├── Hooks/ # 数据钩子 │ ├── MyEntity/BeforeSave.php │ └── AnotherEntity/AfterSave.php ├── Jobs/ # 定时任务 ├── Entities/ # 实体类(可选) └── Resources/ # 元数据与配置 ├── metadata/ │ ├── entityDefs/ # 实体定义 │ ├── clientDefs/ # 前端定义 │ ├── scopes/ # 权限作用域 │ └── routes.json # API 路由 ├── layouts/ # 界面布局 └── i18n/ # 语言包
|
2.3 前端模块结构
1 2 3 4 5
| client/modules/my-module/ └── src/ ├── views/ # 自定义视图 ├── fields/ # 自定义字段类型 └── templates/ # 模板
|
三、各层职责分工
3.1 Controller:API 入口
职责:处理 HTTP 请求 → 权限检查 → 调用 Service → 返回 JSON
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| <?php namespace Espo\Modules\MyModule\Controllers;
use Espo\Core\Controllers\Record; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Forbidden;
class MyEntity extends Record { public function actionMyAction($params, $data, $request) { if (!$this->getUser()->isAdmin()) { throw new Forbidden(); }
if (empty($data->param)) { throw new BadRequest("param is required"); }
return $this->getContainer() ->get('MyService') ->doSomething($data->param); } }
|
3.2 Service:业务逻辑层
职责:复杂业务逻辑、跨实体操作、调用外部 API
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| <?php namespace Espo\Modules\MyModule\Services;
use Espo\Core\ORM\EntityManager; use Espo\Core\Utils\Config; use Espo\Core\Utils\Log;
class MyService { public function __construct( private EntityManager $entityManager, private Config $config, private Log $log ) {}
public function doSomething(string $param): array { $this->log->info("MyService::doSomething started");
$result = $this->processData($param);
$this->log->info("MyService::doSomething completed");
return $result; }
private function processData(string $param): array { } }
|
3.3 Hook:数据一致性保障
职责:保存前校验/补充、保存后联动、删除前检查
红线:不发邮件、不做 HTTP 请求、不做复杂计算
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| <?php namespace Espo\Modules\MyModule\Hooks\MyEntity;
use Espo\ORM\Entity; use Espo\Core\Exceptions\BadRequest;
class BeforeSave { public function beforeSave(Entity $entity, array $options): void { if ($entity->get('status') === 'Closed' && !$entity->get('closedReason') ) { throw new BadRequest("closedReason is required"); }
if ($entity->isNew()) { $entity->set('assignedUserId', $this->getUser()->id); } } }
|
3.4 Job:定时后台任务
职责:定时触发、批量数据处理、发送通知/邮件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| <?php namespace Espo\Modules\MyModule\Jobs;
use Espo\Core\Job\JobDataLess; use Espo\Core\ORM\EntityManager; use Espo\Core\Utils\Log;
class MyScheduledJob implements JobDataLess { public function __construct( private EntityManager $entityManager, private Log $log ) {}
public function run(): void { $this->log->info('MyScheduledJob started');
$this->log->info('MyScheduledJob completed'); } }
|
四、rebuild 与 clear-cache
4.1 操作清单
| 操作 |
必须 |
| 改 metadata(entityDefs / clientDefs / scopes / routes) |
rebuild |
| 改前端视图或模板 |
clear-cache + 浏览器强刷 |
| 改语言包 |
rebuild |
4.2 执行方式
1 2 3
| CONTAINER_NAME="<your-espocrm-container>" docker exec "$CONTAINER_NAME" php /var/www/html/command.php rebuild docker exec "$CONTAINER_NAME" php /var/www/html/command.php clear-cache
|
五、总结
5.1 工程模板
每个需求按同一模板交付:
1 2 3 4 5 6 7
| ├── 需求与验收标准 ├── 扩展点选择与理由 ├── 技术设计与数据流 ├── 代码实现(模块边界内) ├── 测试(UI + API + 边界) ├── 部署脚本(逐文件拷贝) └── 回滚策略
|
5.2 核心原则
- 能配置就不写代码 —— 用好 Formula、Dynamic Logic
- 能扩展就不重写 —— 默认看板能扩展就别完全重写
- 改动锁在模块内 —— 不改
application/ 目录
- rebuild 是纪律 —— 改元数据必须 rebuild