适用版本: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 容器化部署架构说明 graph TD
subgraph Client [用户客户端]
A[Web 浏览器 / 移动端]
end
subgraph Docker_Environment [Docker 环境]
direction LR
subgraph EspoCRM_Monolith [EspoCRM 核心应用容器 Container]
C[Web Server : Nginx] --> D[PHP-FPM / EspoCRM Code]
D --> E[Cron Scheduler :Background Tasks]
end
Fsys[Filesystem Volume :持久化挂载]
end
subgraph External_Managed_Services [容器外公共托管服务]
G[Database :MySQL/ RDS]
H[Cache/Queue :Redis]
J[Identity Provider :Azure AD / LDAP]
K[Email Services :SMTP/IMAP]
L[AWS S3 :用户文件存储]
end
A -->|HTTPS/HTTP| C
D -->|SQL| G
D -->|Read/Write Logs| Fsys
D -->|S3 API / Uploaded Files| L
D -.->|API/Protocol| H
D -->|OIDC/SAML| J
D -->|SMTP/IMAP| K
E -->|Background Tasks| D
E -->|Data Access| G
style EspoCRM_Monolith fill:#FFF7E0,stroke:#333
style Docker_Environment fill:#E0FFEE,stroke:#333
style External_Managed_Services fill:#F0E6FF,stroke:#333
组件
部署位置
运维职责
依赖关系
备注(起步阶段)
Web Server / PHP-FPM / EspoCRM Code
Docker Container(容器内部)
开发团队负责构建和维护单一应用镜像
接收 Client 流量,连接外部服务(DB/IDP/Email)
核心组件,包含全部定制代码
Cron Scheduler
Docker Container(容器内部)
开发团队负责运行
依赖 PHP(执行任务);访问 Database(获取任务数据)
核心组件,必须定期运行
Filesystem Volume
容器外部持久化卷(Volume)
运维团队负责管理与备份
供 PHP 读写代码/配置与用户上传文件(data/upload/)
关键依赖,确保 PV 备份
Database(MySQL/PostgreSQL)
容器外公共服务(Managed Service)
外部服务商管理(如 RDS)
被 PHP 与 Cron 访问,存储业务数据与元数据
必须依赖
Identity Provider(Azure AD/LDAP)
容器外公共服务
外部服务商管理
被 PHP 通过 OIDC/SAML 用于 SSO
必须依赖
Email Services(SMTP/IMAP)
容器外公共服务
外部服务商管理
被 PHP 用于邮件发送与接收
必须依赖(实现邮件功能)
AWS S3
容器外公共服务
外部服务商管理
用户文件存储
必须依赖
Cache & Search Engine
暂时不用(使用内部回退机制)
暂无独立运维职责
内部 PHP 使用文件系统或数据库做缓存/搜索
现阶段可省略,用户增长后升级
5.2 逐文件拷贝原则 禁止 :目录拷贝(不要把整个 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.3 部署检查清单 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.4 回滚策略 代码回滚 :
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.5 管理员配置备份 开发者代码在 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 纪律保证元数据生效
用清单化部署把风险降到可控