从零构建EspoCRM消息流反应系统:设计原理与实战指南

从零构建EspoCRM消息流反应系统:设计原理与实战指南

【免费下载链接】espocrm EspoCRM – Open Source CRM Application 【免费下载链接】espocrm 项目地址: https://gitcode.com/GitHub_Trending/es/espocrm

引言:解决协作型CRM的互动痛点

你是否遇到过这些场景?团队成员在CRM系统中讨论客户需求时,重要反馈被冗长的评论淹没;销售跟进记录缺乏即时情感反馈,导致协作效率低下;用户需要多次点击才能表达对某条记录的认可。EspoCRM作为一款高度可定制的开源客户关系管理系统,其消息流(Stream)功能本应成为团队协作的核心枢纽,但原生实现中缺少类似社交媒体的快速反应机制。

本文将系统讲解如何为EspoCRM构建一套完整的消息流反应系统,通过6个技术模块、12个关键代码示例和3种架构优化方案,帮助开发者实现点赞、表情反馈等互动功能。读完本文你将掌握:

  • 消息流反应功能的领域模型设计
  • 前后端交互的RESTful API实现
  • 实时更新的WebSocket通信机制
  • 权限控制与数据安全最佳实践
  • 性能优化的缓存策略
  • 可扩展的前端组件架构

功能需求分析与用例设计

核心业务场景

消息流反应系统需支持以下核心场景:

  • 用户对消息流记录添加/移除反应(如点赞、感谢、疑问等)
  • 实时显示每条记录的反应统计与用户列表
  • 在列表视图和详情视图中展示反应状态
  • 支持反应通知与未读提醒
  • 管理员可自定义反应类型与权限控制

用例图:参与者与系统交互

mermaid

领域模型设计:实体关系与数据库架构

核心实体定义

在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 (!$

【免费下载链接】espocrm EspoCRM – Open Source CRM Application 【免费下载链接】espocrm 项目地址: https://gitcode.com/GitHub_Trending/es/espocrm

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值