从零构建EspoCRM消息流反应系统:设计原理与实战指南
引言:解决协作型CRM的互动痛点
你是否遇到过这些场景?团队成员在CRM系统中讨论客户需求时,重要反馈被冗长的评论淹没;销售跟进记录缺乏即时情感反馈,导致协作效率低下;用户需要多次点击才能表达对某条记录的认可。EspoCRM作为一款高度可定制的开源客户关系管理系统,其消息流(Stream)功能本应成为团队协作的核心枢纽,但原生实现中缺少类似社交媒体的快速反应机制。
本文将系统讲解如何为EspoCRM构建一套完整的消息流反应系统,通过6个技术模块、12个关键代码示例和3种架构优化方案,帮助开发者实现点赞、表情反馈等互动功能。读完本文你将掌握:
- 消息流反应功能的领域模型设计
- 前后端交互的RESTful API实现
- 实时更新的WebSocket通信机制
- 权限控制与数据安全最佳实践
- 性能优化的缓存策略
- 可扩展的前端组件架构
功能需求分析与用例设计
核心业务场景
消息流反应系统需支持以下核心场景:
- 用户对消息流记录添加/移除反应(如点赞、感谢、疑问等)
- 实时显示每条记录的反应统计与用户列表
- 在列表视图和详情视图中展示反应状态
- 支持反应通知与未读提醒
- 管理员可自定义反应类型与权限控制
用例图:参与者与系统交互
领域模型设计:实体关系与数据库架构
核心实体定义
在EspoCRM的元数据架构中,需新增两个核心实体:
1. StreamReaction实体
{
"fields": {
"streamId": {
"type": "link",
"entity": "Stream",
"required": true,
"index": true
},
"userId": {
"type": "link",
"entity": "User",
"required": true,
"index": true
},
"reactionType": {
"type": "enum",
"required": true,
"options": ["like", "thanks", "question", "celebrate"],
"default": "like",
"index": true
},
"createdAt": {
"type": "datetime",
"required": true,
"default": "now"
}
},
"indexes": {
"stream_user_reaction_unique": {
"columns": ["streamId", "userId", "reactionType"],
"unique": true
}
},
"links": {
"stream": {
"type": "belongsTo",
"entity": "Stream",
"foreign": "reactions"
},
"user": {
"type": "belongsTo",
"entity": "User",
"foreign": "streamReactions"
}
}
}
2. 扩展Stream实体
{
"fields": {
"reactionCount": {
"type": "jsonObject",
"default": {},
"readOnly": true
}
},
"links": {
"reactions": {
"type": "hasMany",
"entity": "StreamReaction",
"foreign": "stream"
}
}
}
数据库表结构设计
CREATE TABLE `stream_reaction` (
`id` char(36) NOT NULL,
`stream_id` char(36) NOT NULL,
`user_id` char(36) NOT NULL,
`reaction_type` varchar(50) NOT NULL,
`created_at` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `stream_user_reaction_unique` (`stream_id`,`user_id`,`reaction_type`),
KEY `idx_stream_id` (`stream_id`),
KEY `idx_user_id` (`user_id`),
CONSTRAINT `fk_stream_reaction_stream` FOREIGN KEY (`stream_id`) REFERENCES `stream` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_stream_reaction_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ALTER TABLE `stream` ADD COLUMN `reaction_count` JSON NULL DEFAULT '{}' AFTER `is_global`;
后端实现:从API到业务逻辑
服务层设计:StreamReactionService
<?php
namespace Espo\Modules\Crm\Services;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\ORM\EntityManager;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\Stream;
use Espo\Modules\Crm\Entities\StreamReaction;
class StreamReactionService extends \Espo\Core\Services\Base
{
public function __construct(
private EntityManager $entityManager,
private \Espo\Core\Acl $acl,
private \Espo\Core\Utils\Metadata $metadata
) {}
public function createReaction(string $streamId, string $reactionType, User $user): StreamReaction
{
// 权限检查
$stream = $this->entityManager->getEntityById(Stream::ENTITY_TYPE, $streamId);
if (!$stream) {
throw new \Espo\Core\Exceptions\NotFound("Stream not found");
}
if (!$this->acl->check($stream, 'read')) {
throw new Forbidden("No access to stream");
}
// 检查反应类型是否有效
$validTypes = $this->metadata->get(['entityDefs', 'StreamReaction', 'fields', 'reactionType', 'options']) ?? [];
if (!in_array($reactionType, $validTypes)) {
throw new \Espo\Core\Exceptions\BadRequest("Invalid reaction type");
}
// 检查是否已存在该反应
$existing = $this->entityManager->getRDBRepository(StreamReaction::ENTITY_TYPE)
->where([
'streamId' => $streamId,
'userId' => $user->getId(),
'reactionType' => $reactionType
])
->findOne();
if ($existing) {
return $existing;
}
// 创建新反应
$reaction = $this->entityManager->createEntity(StreamReaction::ENTITY_TYPE, [
'streamId' => $streamId,
'userId' => $user->getId(),
'reactionType' => $reactionType
]);
// 更新stream的reactionCount
$this->updateStreamReactionCount($stream);
// 触发WebSocket事件
$this->triggerWebSocketEvent($reaction, 'create');
return $reaction;
}
public function deleteReaction(string $streamId, string $reactionType, User $user): void
{
$reaction = $this->entityManager->getRDBRepository(StreamReaction::ENTITY_TYPE)
->where([
'streamId' => $streamId,
'userId' => $user->getId(),
'reactionType' => $reactionType
])
->findOne();
if (!$reaction) {
return;
}
$this->entityManager->removeEntity($reaction);
$stream = $this->entityManager->getEntityById(Stream::ENTITY_TYPE, $streamId);
if ($stream) {
$this->updateStreamReactionCount($stream);
}
// 触发WebSocket事件
$this->triggerWebSocketEvent($reaction, 'delete');
}
private function updateStreamReactionCount(Stream $stream): void
{
$counts = $this->entityManager->getRDBRepository(StreamReaction::ENTITY_TYPE)
->select(['reactionType', 'COUNT:id AS count'])
->where(['streamId' => $stream->getId()])
->groupBy('reactionType')
->find()
->toArray();
$reactionCount = [];
foreach ($counts as $item) {
$reactionCount[$item['reactionType']] = (int)$item['count'];
}
$stream->set('reactionCount', $reactionCount);
$this->entityManager->saveEntity($stream);
}
private function triggerWebSocketEvent(StreamReaction $reaction, string $eventType): void
{
$data = [
'event' => 'streamReaction.' . $eventType,
'data' => [
'id' => $reaction->getId(),
'streamId' => $reaction->get('streamId'),
'userId' => $reaction->get('userId'),
'reactionType' => $reaction->get('reactionType'),
'createdAt' => $reaction->get('createdAt')
]
];
// 通过WebSocket管理器发送事件
$this->getInjection('webSocketManager')->broadcast($data);
}
}
REST API端点设计
<?php
namespace Espo\Modules\Crm\Controllers;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Exceptions\BadRequest;
class StreamReactionController extends \Espo\Core\Controllers\Record
{
public function actionCreate(Request $request, Response $response): void
{
$streamId = $request->getQueryParam('streamId');
$reactionType = $request->getQueryParam('reactionType');
if (!$streamId || !$reactionType) {
throw new BadRequest("streamId and reactionType are required");
}
$service = $this->getService('StreamReaction');
$reaction = $service->createReaction($streamId, $reactionType, $this->getUser());
$response->setStatus(201);
$response->setBody($reaction->toArray());
}
public function actionDelete(Request $request, Response $response): void
{
$streamId = $request->getQueryParam('streamId');
$reactionType = $request->getQueryParam('reactionType');
if (!$streamId || !$reactionType) {
throw new BadRequest("streamId and reactionType are required");
}
$service = $this->getService('StreamReaction');
$service->deleteReaction($streamId, $reactionType, $this->getUser());
$response->setStatus(204);
}
public function actionGetForStream(Request $request, Response $response): void
{
$streamId = $request->getQueryParam('streamId');
if (!$streamId) {
throw new BadRequest("streamId is required");
}
$service = $this->getService('StreamReaction');
$data = $service->getReactionsForStream($streamId, $this->getUser());
$response->setBody($data);
}
}
前端实现:从组件到实时交互
反应按钮组件设计
// client/src/views/stream/record/reaction-buttons.js
define('views/stream/record/reaction-buttons', ['view', 'underscore'], function (View, _) {
return View.extend({
template: `
<div class="stream-reaction-buttons">
{{#each reactionTypes}}
<button
class="reaction-button {{type}}"
data-type="{{type}}"
title="{{label}}"
>
<span class="icon">{{icon}}</span>
<span class="count">{{count}}</span>
</button>
{{/each}}
</div>
`,
events: {
'click .reaction-button': function (e) {
const type = $(e.currentTarget).data('type');
this.toggleReaction(type);
}
},
data: function () {
return {
reactionTypes: this.getReactionTypesWithCounts()
};
},
init: function () {
this.streamId = this.options.streamId;
this.reactionData = this.options.reactionData || {};
this.userReactions = this.options.userReactions || {};
// 绑定模型事件
this.listenTo(this.model, 'change:reactionCount', this.render);
// 订阅WebSocket事件
this.subscribeToWebSocket();
},
getReactionTypesWithCounts: function () {
const metadata = this.getMetadata().get('entityDefs', 'StreamReaction', 'fields', 'reactionType', 'options') || [];
const counts = this.model.get('reactionCount') || {};
return metadata.map(type => {
const count = counts[type] || 0;
const isActive = this.userReactions[type] || false;
return {
type: type,
label: this.getLanguage().translateOption(type, 'reactionType', 'StreamReaction'),
icon: this.getReactionIcon(type),
count: count > 0 ? count : '',
isActive: isActive
};
});
},
getReactionIcon: function (type) {
const iconMap = {
like: 'far fa-thumbs-up',
thanks: 'far fa-heart',
question: 'far fa-question-circle',
celebrate: 'far fa-trophy'
};
return iconMap[type] || 'far fa-star';
},
toggleReaction: function (type) {
const isActive = this.userReactions[type] || false;
if (isActive) {
this.removeReaction(type);
} else {
this.addReaction(type);
}
},
addReaction: function (type) {
this.$el.find(`.reaction-button.${type}`).addClass('loading');
Espo.Ajax.postRequest('StreamReaction/action/create', {
streamId: this.streamId,
reactionType: type
})
.then(() => {
this.userReactions[type] = true;
this.render();
})
.fail(() => {
Espo.Ui.error(this.translate('Error while adding reaction', 'messages'));
})
.always(() => {
this.$el.find(`.reaction-button.${type}`).removeClass('loading');
});
},
removeReaction: function (type) {
this.$el.find(`.reaction-button.${type}`).addClass('loading');
Espo.Ajax.deleteRequest('StreamReaction/action/delete', {
streamId: this.streamId,
reactionType: type
})
.then(() => {
this.userReactions[type] = false;
this.render();
})
.fail(() => {
Espo.Ui.error(this.translate('Error while removing reaction', 'messages'));
})
.always(() => {
this.$el.find(`.reaction-button.${type}`).removeClass('loading');
});
},
subscribeToWebSocket: function () {
const channel = `stream.${this.streamId}.reactions`;
this.getWebSocketChannel().subscribe(channel, (data) => {
if (data.event === 'streamReaction.create' || data.event === 'streamReaction.delete') {
this.model.fetch();
}
});
}
});
});
集成到Stream记录视图
// client/src/views/stream/record.js
define('views/stream/record', ['view', 'views/stream/record/reaction-buttons'], function (View, ReactionButtonsView) {
return View.extend({
template: `
<div class="stream-record">
<div class="stream-content">{{{content}}}</div>
<div class="stream-meta">{{meta}}</div>
<div class="stream-actions">
{{reactionButtons}}
<button class="comment-button">{{translate 'Comment'}}</button>
</div>
</div>
`,
init: function () {
View.prototype.init.call(this);
// 加载反应数据
this.loadReactionData();
},
afterRender: function () {
View.prototype.afterRender.call(this);
// 渲染反应按钮
this.createView('reactionButtons', ReactionButtonsView, {
el: this.$el.find('.stream-actions'),
streamId: this.model.id,
model: this.model,
reactionData: this.reactionData,
userReactions: this.userReactions
}, view => {
view.render();
});
},
loadReactionData: function () {
Espo.Ajax.getRequest('StreamReaction/action/getForStream', {
streamId: this.model.id
})
.then(data => {
this.reactionData = data.counts;
this.userReactions = data.userReactions;
this.render();
});
}
});
});
实时更新机制:WebSocket通信实现
后端WebSocket服务
<?php
namespace Espo\Modules\Crm\Core\WebSocket\Handlers;
use Espo\Core\WebSocket\Handler;
use Espo\Core\WebSocket\Connection;
use Espo\Core\WebSocket\Message;
class StreamReactionHandler implements Handler
{
public function __construct(
private \Espo\Core\Acl $acl,
private \Espo\Core\ORM\EntityManager $entityManager
) {}
public function handle(Message $message, Connection $connection): void
{
$event = $message->getEvent();
$data = $message->getData();
switch ($event) {
case 'stream.reaction.subscribe':
$this->handleSubscribe($data, $connection);
break;
}
}
private function handleSubscribe(array $data, Connection $connection): void
{
$streamId = $data['streamId'] ?? null;
if (!$streamId) {
return;
}
$stream = $this->entityManager->getEntityById('Stream', $streamId);
if (!$
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



