适用版本:EspoCRM 9.2.2+(开源版)
你以为”能跑起来”就完了?真正的难点从你第一次升级开始。
TL;DR
- 用”扩展点金字塔”选择最小侵入方案,能配置就不写代码
- 用”目录分区”隔离管理员配置与开发者代码,避免不可审计混乱
- 用”模块化架构”把改动锁在可控边界内
- 用”清单化部署+回滚”把风险降到可控
1. 扩展点金字塔
1.1 我们解决的不是”能不能改”,而是”能不能长期维护”
很多团队做 EspoCRM 定制,第一阶段靠”改得快”赢;第二阶段会被”不可升级、不可回滚、不可定位问题”拖垮。
这套系列文章的目标很明确:
- 不讨论”改核心文件最快”的玩法,只讨论”升级后仍可活”的做法
- 不是展示技巧堆叠,而是给一套可复用的工程模板
1.2 选择扩展点的优先级(金字塔)
我们的默认策略:能不写代码就不写代码,能用系统机制就不用自造轮子。
1 2 3 4 5
| 1. Formula (优先) - 简单计算和条件逻辑 2. Dynamic Logic - 界面显示与字段依赖 3. Workflow / BPM - 复杂业务流程(谨慎) 4. Hook - 数据一致性保障(禁止复杂计算/HTTP/发信) 5. Service / Controller - API 与复杂逻辑(最后手段)
|
你会在后面几篇里看到同一个套路反复出现:
先用 Dynamic Logic 解决体验,再用 Hook/Service 解决”绕过与一致性”。
1.3 红线(违反就注定不可维护)
- 不修改
application/ 目录下任何文件(除非你准备永久自己维护一个 fork)
- 不在代码里硬编码环境信息(域名、容器名、数据库连接、密钥)
- 不把管理员配置和开发者代码混在同一套元数据文件里
- 不在 Hook 里做重逻辑(尤其是发邮件、复杂计算、HTTP 请求)
- 不绕过 ACL(任何”方便调试的后门”最终都会变成安全事故)
2. 模块化架构
2.1 目录分区:开发者模块 vs 管理员配置区
我们把”可升级”落到物理结构上:
- 管理员(GUI)产生的配置:
custom/Espo/Custom/
- 开发者(代码)交付的模块:
custom/Espo/Modules/{ModuleName}/
1 2 3 4 5 6 7 8 9 10 11 12
| custom/ ├── Espo/Custom/ # 管理员配置区(GUI) │ └── Resources/metadata/ └── Espo/Modules/{ModuleName}/ # 开发者模块区(代码) ├── Controllers/ ├── Services/ ├── Hooks/ ├── Jobs/ └── Resources/ ├── metadata/ ├── routes.json └── i18n/
|
为什么这么苛刻?
因为管理员配置可变、不可审计,而开发者代码必须可审计、可回滚、可复现。混在一起,等于把两种生命周期掺成一锅粥。
2.2 完整后端模块结构
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 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| custom/Espo/Modules/MyModule/ ├── Module.php # 模块定义类 ├── composer.json # 第三方依赖(可选) │ ├── Controllers/ # API 控制器 │ └── MyEntity.php │ ├── Services/ # 业务逻辑服务层 │ └── MyService.php │ ├── Hooks/ # 数据钩子 │ ├── MyEntity/ │ │ └── BeforeSave.php │ └── AnotherEntity/ │ └── AfterSave.php │ ├── Jobs/ # 定时任务 │ └── MyScheduledJob.php │ ├── Entities/ # 实体类(可选) │ └── MyEntity.php │ ├── Repositories/ # 数据仓库(可选) │ └── MyEntityRepository.php │ └── Resources/ # 元数据与配置 ├── metadata/ │ ├── entityDefs/ # 实体定义 │ ├── clientDefs/ # 前端定义 │ ├── scopes/ # 权限作用域 │ ├── app/ │ │ ├── adminPanel.json # 管理面板菜单 │ │ └── config.json # 系统配置 │ └── routes.json # API 路由 │ ├── layouts/ # 界面布局 │ └── MyEntity/ │ ├── list.json │ ├── detail.json │ ├── edit.json │ └── create.json │ └── i18n/ # 语言包 └── en_US/ ├── Global.json └── MyEntity.json
|
2.3 前端模块结构
1 2 3 4 5 6 7 8 9 10 11
| client/modules/my-module/ └── src/ ├── views/ # 自定义视图 │ └── my-entity/ │ ├── detail.js │ ├── edit.js │ └── list.js ├── fields/ # 自定义字段类型 │ └── my-field-type.js └── templates/ # Handlebars 模板(可选) └── my-template.tpl
|
2.4 管理员配置区(开发者不要动)
1 2 3 4 5 6
| custom/Espo/Custom/ └── Resources/ └── metadata/ # 管理员通过 GUI 添加的配置 ├── entityDefs/ ├── clientDefs/ └── scopes/
|
3. 各层职责分工
3.1 Controller:API 入口
职责:
- 处理 HTTP 请求
- 权限检查(ACL)
- 调用 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"); }
$result = $this->getContainer()->get('MyService')->doSomething($data->param);
return $result; } }
|
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 with param: {$param}");
$result = $this->processData($param);
$this->log->info("MyService::doSomething completed");
return $result; }
private function processData(string $param): array { } }
|
3.3 Hook:数据一致性保障
职责:
- 数据保存前的校验/补充(BeforeSave)
- 数据保存后的联动(AfterSave)
- 数据删除前的检查(BeforeDelete)
原则:
- 只做轻逻辑判断
- 不发邮件、不做 HTTP 请求
- 不做复杂计算
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <?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'); } }
|
4. rebuild 与 clear-cache
4.1 把”生效机制”当成工程事实
你在 EspoCRM 里改了元数据、前端视图映射、语言包、布局之后,最常见的错误不是”写错代码”,而是”你以为改了就生效”。
建议把下面清单当作工程制度,而不是”记得就做”:
| 操作 |
必须 |
| 改 metadata(entityDefs / clientDefs / scopes / routes / app) |
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. 部署与回滚
5.1 逐文件拷贝原则
禁止:目录拷贝(不要把整个 custom/ 目录一次性扔进容器)
正确:逐文件拷贝
1 2 3 4 5
| CONTAINER_NAME="<your-espocrm-container>" docker cp Module.php "$CONTAINER_NAME":/var/www/html/custom/Espo/Modules/MyModule/Module.php docker cp MyEntity.json "$CONTAINER_NAME":/var/www/html/custom/Espo/Modules/MyModule/Resources/metadata/entityDefs/MyEntity.json
|
5.2 部署检查清单
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| 文件部署 ├── [ ] 所有 .php 文件已拷贝 ├── [ ] 所有 .json 元数据文件已拷贝 ├── [ ] 所有 .js 前端文件已拷贝 └── [ ] 所有 .tpl 模板文件已拷贝
系统重建 ├── [ ] rebuild 已执行 ├── [ ] clear-cache 已执行 └── [ ] 浏览器缓存已清空
功能验证 ├── [ ] 新菜单项显示 ├── [ ] 新实体可创建/编辑 ├── [ ] 新 API 端点可访问 ├── [ ] ACL 权限正确 └── [ ] 日志无错误
备份确认 ├── [ ] 管理员配置已备份 └── [ ] 数据库已备份
|
5.3 回滚策略
代码回滚:
1 2 3 4 5 6 7 8 9 10 11 12
| git checkout <last-good-tag-or-commit> -- custom/Espo/Modules/MyModule/
CONTAINER_NAME="<your-espocrm-container>" docker cp custom/Espo/Modules/MyModule/Module.php \ "$CONTAINER_NAME":/var/www/html/custom/Espo/Modules/MyModule/Module.php docker cp custom/Espo/Modules/MyModule/Resources/metadata/entityDefs/MyEntity.json \ "$CONTAINER_NAME":/var/www/html/custom/Espo/Modules/MyModule/Resources/metadata/entityDefs/MyEntity.json
docker exec "$CONTAINER_NAME" php /var/www/html/command.php rebuild
|
数据回滚:
结论:不要指望 rebuild 自动“回收”数据库结构。
- 默认 rebuild(soft)只会创建/变更需要的表、列、索引;不会 drop 表,也不会 drop 列
- hard rebuild 可能会 drop 未使用的列、缩短超长列长度,但仍不会 drop 表,且有数据丢失风险
推荐回滚策略:
- 回滚前先备份数据库(至少 schema + 相关业务表数据)
- 回滚代码与元数据后执行 rebuild,让缓存与元数据状态一致
- 对“新增表/中间表/索引”的清理,采用显式的反向 SQL(DROP TABLE/INDEX),并在测试库验证后再执行到生产
- 对“新增列/字段”的回滚,优先走“弃用而非删除”:保留列与数据,仅从界面与业务逻辑中移除;确需删除时使用 hard rebuild 或反向 SQL,并明确数据保留/迁移方案
5.4 管理员配置备份
开发者代码在 Git 中有版本控制,但管理员配置(custom/Espo/Custom/)不在任何版本控制系统里。系统崩溃时,管理员配置会丢失。
问题本质:
1 2
| 开发者代码:Git 版本控制 → 随时恢复 ✅ 管理员配置:无版本控制 → 系统崩 = 配置丢 ❌
|
推荐方案:定时备份脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| #!/bin/bash
CONTAINER_NAME="<your-espocrm-container>" BACKUP_DIR="/backup/espocrm/admin-config" DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUP_DIR" TMP_FILE="/tmp/Custom_$DATE.tar.gz"
docker exec "$CONTAINER_NAME" tar -czf "$TMP_FILE" -C /var/www/html custom/Espo/Custom docker cp "$CONTAINER_NAME":"$TMP_FILE" "$BACKUP_DIR/Custom_$DATE.tar.gz" docker exec "$CONTAINER_NAME" rm -f "$TMP_FILE"
find "$BACKUP_DIR" -name "Custom_*.tar.gz" -mtime +30 -delete
echo "Admin config backed up: Custom_$DATE.tar.gz"
|
设置 cron 每天自动执行:
1 2 3 4 5
| crontab -e
0 2 * * * /path/to/backup-admin-config.sh >> /var/log/espocrm-backup.log 2>&1
|
恢复流程:
1 2 3 4 5 6 7 8 9
| CONTAINER_NAME="<your-espocrm-container>" BACKUP_FILE="/backup/espocrm/admin-config/Custom_20251227_020000.tar.gz" TMP_FILE="/tmp/Custom_restore.tar.gz"
docker cp "$BACKUP_FILE" "$CONTAINER_NAME":"$TMP_FILE" docker exec "$CONTAINER_NAME" tar -xzf "$TMP_FILE" -C /var/www/html docker exec "$CONTAINER_NAME" rm -f "$TMP_FILE" docker exec "$CONTAINER_NAME" php /var/www/html/command.php rebuild
|
6. 总结
6.1 一套可复用的工程模板
1 2 3 4 5 6 7 8 9
| 每个需求按同一模板交付:
├── 需求与验收标准 ├── 扩展点选择与理由 ├── 技术设计与数据流 ├── 代码实现(模块边界内) ├── 测试(UI + API + 边界) ├── 部署脚本(逐文件拷贝) └── 回滚策略
|
6.2 最终建议
- 能配置就不写代码 —— 用好 Formula、Dynamic Logic
- 能扩展就不重写 —— 默认看板能扩展就别完全重写
- 改动锁在模块内 —— 不改
application/ 目录
- rebuild 是纪律 —— 改元数据必须 rebuild
- 日志用英文 —— 方便线上排障
系列开篇
EspoCRM 定制的核心不是”能不能改”,而是”能不能长期维护”。
希望这系列文章能给你一套可复用的工程模板:
- 用扩展点金字塔选择最小侵入方案
- 用目录分区隔离管理员配置与开发者代码
- 用模块化架构锁住改动边界
- 用 rebuild 纪律保证元数据生效
- 用清单化部署把风险降到可控