0%

EspoCRM定制第1篇: 总纲——扩展点选择、模块架构与工程化

适用版本: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");
}

// 调用 Service
$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
# 1) 在宿主机源码里回滚到上一个可用版本(示例:Git)
git checkout <last-good-tag-or-commit> -- custom/Espo/Modules/MyModule/

# 2) 按“逐文件拷贝”重新部署到容器
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

# 3) rebuild 生效
docker exec "$CONTAINER_NAME" php /var/www/html/command.php rebuild

数据回滚

结论:不要指望 rebuild 自动“回收”数据库结构。

  • 默认 rebuild(soft)只会创建/变更需要的表、列、索引;不会 drop 表,也不会 drop 列
  • hard rebuild 可能会 drop 未使用的列、缩短超长列长度,但仍不会 drop 表,且有数据丢失风险

推荐回滚策略:

  1. 回滚前先备份数据库(至少 schema + 相关业务表数据)
  2. 回滚代码与元数据后执行 rebuild,让缓存与元数据状态一致
  3. 对“新增表/中间表/索引”的清理,采用显式的反向 SQL(DROP TABLE/INDEX),并在测试库验证后再执行到生产
  4. 对“新增列/字段”的回滚,优先走“弃用而非删除”:保留列与数据,仅从界面与业务逻辑中移除;确需删除时使用 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
# backup-admin-config.sh

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"

# 保留最近 30 天的备份
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
crontab -e

# 添加每天凌晨 2 点执行
0 2 * * * /path/to/backup-admin-config.sh >> /var/log/espocrm-backup.log 2>&1

恢复流程

1
2
3
4
5
6
7
8
9
# 恢复管理员配置(选择备份日期,会覆盖现有 Custom 配置)
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 最终建议

  1. 能配置就不写代码 —— 用好 Formula、Dynamic Logic
  2. 能扩展就不重写 —— 默认看板能扩展就别完全重写
  3. 改动锁在模块内 —— 不改 application/ 目录
  4. rebuild 是纪律 —— 改元数据必须 rebuild
  5. 日志用英文 —— 方便线上排障

系列开篇

EspoCRM 定制的核心不是”能不能改”,而是”能不能长期维护”。

希望这系列文章能给你一套可复用的工程模板:

  • 用扩展点金字塔选择最小侵入方案
  • 用目录分区隔离管理员配置与开发者代码
  • 用模块化架构锁住改动边界
  • 用 rebuild 纪律保证元数据生效
  • 用清单化部署把风险降到可控