ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

CRM系统技术实战:统一消息层设计与消息幂等性保障

CRM系统技术实战:统一消息层设计与消息幂等性保障 2. 技术选型与架构核心2.1 框架与数据库选型先说说技术栈。前端我选了 Vue 3 TypeScript Element Plus后端是 Spring Boot 3 MyBatis-Plus数据库用 PostgreSQL 15 Redis 7。这套组合不是赶时髦而是被实际需求推着走的。前端为什么不用 React团队当时对 Vue 更熟而且业务场景里有大量表单、弹窗、表格联动Vue 的响应式模型在这种中后台系统里写起来最顺手。TypeScript 是必须的客户数据模型太复杂没有类型约束改一个字段就要全局搜一遍迟早出事故。Element Plus 的表单校验、表格组件、日期选择器开箱即用省了大量造轮子的时间。后端选 Spring Boot 纯粹是为了生态成熟。团队里有 Java 背景的同事而且 CRM 这类系统对事务一致性要求高——比如创建客户的同时要写跟进记录、要扣减销售线索配额这种跨表操作用 Spring 的Transactional管理最稳妥。MyBatis-Plus 的 LambdaQueryWrapper 写起来比 JPA 直观复杂 SQL 又可以直接在 XML 里手写自由度足够。数据库是 PostgreSQL 而不是 MySQL关键原因是 PostgreSQL 的 JSONB 类型太适合存客户动态属性了。客户资料这种东西不同行业字段完全不一样有的要填税号有的要填微信号有的要填门店地址。你要是一开始就把字段定死后面加字段就得改表结构、改页面、改导入模板三个地方同步上线想想就头疼。用 JSONB 存扩展属性主表只保留通用字段灵活性和查询性能都能兼顾。Redis 在这里的角色比较重不只是缓存。在线状态、会话锁、消息序号、限流计数器、分布式锁全都放在 Redis 里。有一点要特别提醒Redis 一定要开启持久化至少 AOF否则重启一次在线状态和分布式锁全部丢失下面带的服务全部报错。我踩过这个坑生产环境 Redis 没配持久化一次内存抖动触发了主从切换所有锁全部失效工单分派重复执行了 200 多次数据一塌糊涂。2.2 通信接入与统一消息层整套系统最核心的设计DeskcommCRM 与传统 CRM 最大的区别就是通信这两个字。电话、邮件、网页聊天、微信客服、企业微信、钉钉、WhatsApp客户从哪个渠道进来消息必须能在同一套系统里流转。如果每个渠道单独做一套对接维护成本会爆炸。所以系统里必须先做一个统一消息层这是个抽象层也是整个系统的心脏。统一消息层我定义了三个核心模型关系很清楚Channel渠道代表一条通信通道比如客服邮箱 supportxxx.com、网页在线客服、微信客服号。一个渠道有唯一的channel_id。Conversation会话代表一次完整的沟通上下文。同一客户在不同渠道的会话可以合并也能分开管理。比如客户先发邮件说发票没收到过了两小时又在网页聊天问发票什么时候能到系统根据客户手机号匹配到同一个客户档案就能把两个会话关联到同一个customer_id下面。Message消息代表一条具体的交流内容。核心字段包括message_id全局唯一、channel_id、conversation_id、sender_type客户或客服、content_type文本、图片、文件、系统消息、content正文或文件 URL、direction进站或出站、status待发送、已发送、已读、失败。把消息做成独立模型之后所有渠道的接入方式就统一了每个渠道写一个适配器Adapter负责把渠道特有的消息格式转换成系统内的标准消息对象然后丢进同一个消息队列。下游的工单模块、通知模块、报表模块完全不用关心消息来自哪个渠道只要认准Message这一个数据结构就行。这里牵扯到一个比较底层的问题消息投递的可靠性和顺序性。举个实际场景客户在网页聊天里连续发三条消息你好、我的订单显示已签收但我没收到、请问怎么处理。这三条消息如果异步落库顺序乱了客服看到的就是请问怎么处理你 好 我的订单...非常影响体验。消息顺序性的做法是给每个会话单独做一个序列号。Redis 里存一个seq:conversation:{id}的计数器每条消息进入队列前先INCR拿一个序号落库的时候按序号写消费端按序号处理。虽然牺牲了一点并发度但在会话粒度上的串行处理完全能够支撑 99% 的客服场景而换来的是用户体验上的稳定。消息可靠性的问题我用了一张本地消息表 消息状态机的表来兜底。生产环境消息进数据库和 Redis 记录分两步执行当 Redis 不可用时消息先落本地表状态为pending。一个定时任务每隔 30 秒扫描本地表把pending状态的消息重新补偿发送。这样即使 Redis 挂掉消息也不会丢失最多延迟 30 秒。可靠性优先于实时性这个取舍在客服场景里完全成立。2.3 权限模型与数据隔离多人协作不串数据DeskcommCRM 是给团队用的不是给一个人用的所以权限体系从第一天就要设计好不然后面数据泄漏或者互相看不到数据改起来比重新开发还痛苦。我采用的是 RBAC基于角色的访问控制加数据范围双层隔离。用户表、角色表、权限表、用户角色关联表、角色权限关联表一共五张表这是最经典的实现方式。每个用户可以拥有多个角色每个角色可以配置多项权限权限精细到创建客户删除客户导出客户列表查看所有工单查看本部门工单这样的粒度。数据范围的实现是靠一个data_scope字段控制。1表示仅本人2表示仅本部门3表示全部。查询的时候通过 MyBatis-Plus 的拦截器自动拼接数据权限 SQL 条件。写 SQL 的时候要特别注意data_scope为1时条件是owner_id 当前用户ID2时条件是owner_id IN (部门下所有用户ID)这个 ID 列表要实时从用户表里查不能缓存太久否则新入职员工一直看不到老客户数据容易出问题。客户数据本身还有字段级权限。比如客户负责人、签单状态、预计成交金额这些字段可能只有销售总监和财务能看到普通销售只能看到自己的那部分。这个我用 JSONB 配置做了个字段可见性矩阵权限校验在服务端统一处理。前端拿到的是脱敏数据后端接口再校验一次防止有人直接调接口绕过页面。数据隔离这块还有一个细节很容易被忽略批量操作也要走权限校验。比如导出客户列表如果导出接口没有权限控制一个普通销售可以通过写脚本调用导出接口把全公司客户全部导走。所以导出接口的参数里必须带上权限过滤条件而且导出行为要写审计日志我是把导出日志单独存了一张表字段包括操作人、操作时间、导出条件、导出条数出现泄漏事件的时候可以回溯。3. 核心模块实现与实操细节3.1 客户 360° 视图如何把所有客户信息落到一个页面CRM 里使用频率最高的页面就是客户详情页我把它叫做客户 360° 视图。这个页面把客户的基本资料、沟通记录、工单记录、交易记录、跟进任务全部集中到一个页面展示客服接到客户电话时不用再开多个标签页来回切换。客户表设计上我用了customers主表 customer_ext扩展表的结构。customers表存的是通用字段customer_id、name、phone、email、company、source客户来源渠道、owner_id负责人、status潜在客户、意向客户、成交客户、流失客户、created_at、updated_at。customer_ext表用customer_id关联主表存的是ext_dataJSONB 字段放的是各行业差异化属性。听起来很合理对吧有个坑在后面等着你。刚开始用 JSONB 存扩展属性很爽但是有天运营同事提了个需求要把客户规模字段做成筛选条件在前端筛选客户规模大型企业的客户列表。我一看这个字段存在 JSONB 里怎么用 SQL 筛PostgreSQL 里确实可以WHERE ext_data-scale large但这种写法走不了索引如果客户量超过百万级别一次全表扫描就卡死了。后来我用了一个妥协方案高频筛选字段从 JSONB 里升级到主表的正式列比如把scale、industry、region这三个字段提升为主表字段并建了普通索引。低频字段继续留在 JSONB 里不参与筛选。这个高频升列、低频留在 JSONB的策略是我做这个项目学到的最实用的一条数据库设计经验。页面上除了信息展示还有一个重要的时间线组件。客户从第一次咨询到最终成交所有互动记录按时间倒序排列包括来电记录、邮件往来、聊天记录、工单创建和解决、跟进记录、报价记录。实现上我用了一张customer_timeline表每产生一种业务事件就向时间线表插入一条记录。事件类型、事件内容、关联业务表 ID、创建时间。时间线表非常容易无限膨胀一个老客户几年下来可能有几千条时间线记录。我加了两个机制控制一是只保留最近 500 条在线展示更早的进入归档查询二是时间线的内容字段尽量存摘要而不是全文比如聊天记录只存第一句话想看全文再跳转到具体的会话详情页。这样列表页查询压力大大降低。3.2 工单管理与流程自动化SLA 不是摆设工单模块是 DeskcommCRM 里承载问题处理的核心。客户发来一条消息如果消息内容判断为需要跟进处理系统就自动创建一张工单分配给对应的客服人员。工单表的核心字段包括ticket_id、customer_id、channel来源渠道、subject标题、description内容、priority优先级低、中、高、紧急、status待处理、处理中、待客户回复、已解决、已关闭、assignee_id处理人、SLA 截止时间。工单状态机的设计其实很讲究。刚开始我把状态搞得太简单只有待处理和已解决两种结果发现很多工单实际上处于等客户回复的状态却一直被计为待处理导致 SLA 超时率统计失准。我后来重构了状态机待处理工单创建后未有人认领或分派。处理中已分配处理人开始处理。待客户回复客服已给出中间反馈等待客户确认信息。已解决处理完成等待客户确认。已关闭客户确认解决或者系统超时自动关闭。SLA 的核心是为每个优先级设定了不同的响应时间和解决时间。比如紧急工单要求 15 分钟内首次响应、2 小时内解决高要求 30 分钟响应、8 小时解决。系统会定期跑一个定时任务扫描所有未完成工单计算剩余 SLA 时间快超时的时候自动给处理人发站内通知和邮件提醒。自动分派规则我用了简单但实用的策略先找当前在线且未分配满负荷正在处理的工单少于某个阈值且技能组匹配的处理人如果找不到就分派给部门负责人。技能组的匹配是根据工单分类设定的比如技术支持类工单分派给技术组售后投诉类分派给客服组不会出现把退款申请发给开发工程师的情况。这里有一个很多自研系统都容易忽略的细节SLA 时间应该按工作日历计算。如果你直接按自然时间计算周五下午 5 点创建的普通工单要求在 24 小时内解决那处理人周六还得加班这不合理。我在系统里配置了一套工作日历包含工作时段比如 09:00-18:00和节假日列表SLA 计算只统计工作日历内的有效时间。这个功能其实开发成本不高但对实际使用体验的提升是巨大的。3.3 跟进提醒与任务看板让每个客户都有人管很多 CRM 系统死掉不是因为功能少而是因为客户经理打开系统不知道今天要干什么。所以我在 DeskcommCRM 里做了一个今日工作台登录后第一眼看到的就是三块内容今日待办任务、即将到期的跟进提醒、新分配的工单。跟进任务不是人工创建的而是系统自动生成的。每个客户档案里设定了下次跟进时间字段到了时间系统自动生成一个跟进任务分配给这个客户负责人。任务逾期未完成提醒会升级从站内提醒升级到邮件提醒再升级到抄送直属领导。这个机制对于保证销售跟进率非常有用。任务的实现我用了tasks表 task_reminders表。tasks表存任务本身task_reminders存提醒规则支持提前 10 分钟提醒每天 09:00 提醒这类循环提醒。定时任务每分钟扫描一次到期任务把需要提醒的任务投递到通知队列。任务看板我用的是看板式布局按状态分为待处理处理中已完成三列支持拖拽改变任务状态。这是整个系统用户体验最好的部分因为销售主管能直观地看到每个成员的工作负荷。实现看板拖拽我用的是vuedraggable切换状态时调用接口更新任务状态和排序值注意要在拖拽结束事件里做防重复提交不然用户快速拖拽时会发出多个请求导致状态错乱。3.4 报表与团队看板指标口径一定要跟业务对齐DeskcommCRM 里报表模块的核心不是炫酷的图表而是指标口径。同样的响应时间不同人理解完全不同是从客户发消息到客服首次回复的时间还是从工单创建到客服首次处理的时间口径不统一数据就对不上业务部门天天来找你扯皮。所以报表模块上线之前我先跟业务负责人确认了一套指标字典每个指标的名称、定义、计算公式、数据来源表都写在文档里报表页面底部还有一个指标口径说明的展开区域。系统里最常用的几个报表页面客服工作量报表按天统计每个客服处理的会话数、工单数、平均首次响应时长、平均解决时长、满意度评分。客户转化漏斗从新增线索 → 已联系 → 意向客户 → 成交客户的全链路转化率。工单时效报表统计各优先级工单的 SLA 达标率、平均解决时长、积压工单数。技术实现上报表查询走的是独立的只读从库避免拖累主业务库。统计接口用 Redis 做 5 分钟级别的缓存。这里有个优化点值得讲不要用前端直接查明细表再聚合数据量一大必然卡死。我是把明细数据每天凌晨通过定时任务汇总到report_daily_agent、report_daily_channel这类汇总表里报表查询只查汇总表速度非常快。实时大屏那种需求另说但大多数日常报表其实不需要实时。我试过最开始的方案是每次打开报表页直接SELECT COUNT(*) FROM messages WHERE ...结果客服量稍微起来一点接口耗时直接飙到 10 秒以上。后来改成每小时增量汇总到 Redis Hash 里报表接口 100 毫秒内就能返回。记住一个原则同一份数据被反复查询时就该考虑预计算了。4. 常见问题与排查技巧实录4.1 消息重复推送与消息丢失DeskcommCRM 在联调阶段最头疼的问题就是消息重复和消息丢失这两个问题往往是同时出现的。先说消息丢失。网页聊天客户发了一条在吗客服端没收到客户以为发了客服以为客户没发体验极其糟糕。排查后发现原因出在 WebSocket 断线重连的处理上客户端断线后WebSocket 会自动重连但重连成功后没有做消息补偿断线期间的消息就丢了。解决办法分两步。第一步客户端维护一个last_received_seq记录最后一条收到的消息序号重连后主动向服务端发送同步消息请求服务端把这个会话中序号大于last_received_seq的消息全部补发。第二步服务端保留每个会话最近 24 小时的消息序号缓存防止客户端断线时间过长找不到历史消息。再说消息重复。这个问题更隐蔽WebSocket 断线后客户端重发消息前一条消息其实已经到达服务端并落库了客户端超时重试又发了一次客服端就收到了两条相同内容。解决办法是请求带上一个全局唯一client_message_id服务端在存储层做唯一约束。如果发现client_message_id已存在直接返回已有消息的message_id不重复落库。这是典型的幂等性设计在消息相关系统里是必须做的。4.2 权限越权问题一个低级别账号看到了高级别数据这个问题的发现很偶然。测试同事在验收时用一个普通销售账号搜索客户输入了一个只有销售总监才能看到的大客户名称搜索结果里居然出现了。排查后发现是 MyBatis-Plus 拦截器拼接数据权限 SQL 时出了问题搜索接口用了自定义 SQL自定义 SQL 没有走拦截器权限条件自然没有生效。这个教训很重要权限过滤不能只依赖 ORM 拦截器还得在业务代码里做好防御性校验。我后来把所有的数据访问统一到一个 Service 层接口强制要求传入currentUser对象在 Service 内部显式拼接权限条件不依赖拦截器。同时写了自动化测试脚本用不同权限的账号跑一遍核心接口确保权限校验不会被绕过。4.3 大数据量下的查询性能从 10 秒优化到 200 毫秒客户列表页是最常见的性能瓶颈点。初期客户量只有几千条的时候查询很快大家都觉得没问题。等客户量增长到几十万条列表页开始出现明显的卡顿最简单的筛选查询都要 10 秒以上。我做了三层优化。第一层给高频查询条件建复合索引。客户列表最常见的筛选是负责人 状态 创建时间所以我建了一个idx_owner_status_created(owner_id, status, created_at)的复合索引。查询时过滤条件能命中索引优化效果立竿见影。第二层列表页改用游标分页而不是传统的偏移量分页。偏移量分页在数据量大的时候OFFSET 500000 LIMIT 20这种 SQL 是要先扫描 50 万行再丢弃前 499980 行性能极差。游标分页则是用WHERE created_at 上次最后一条记录的创建时间 ORDER BY created_at DESC LIMIT 20走索引直接定位速度快了几个数量级。第三层列表页数据从明细查询改为缓存查询。客户列表的核心数据姓名、公司、手机号、负责人、状态放到 Redis 里存成 Hash查询的时候先走缓存缓存未命中再走数据库。这个方案把接口响应时间从平均 3 秒降到了 200 毫秒以内。4.4 通信服务的不稳定WebSocket 频繁断开WebSocket 频繁断开是通信类系统最常见的稳定性问题。我遇到过两种典型情况。第一种是负载均衡层的问题。系统部署在 Nginx 后面WebSocket 连接走的是长连接如果 Nginx 没有配置合适的proxy_read_timeout默认 60 秒就会断开空闲连接。客服正在看客户资料消息通道却被切断了。解决办法是把proxy_read_timeout调大到 3600 秒同时在服务端配置 WebSocket 心跳机制客户端每 30 秒发送一个 ping 心跳包服务端返回 pong保持连接活跃。第二种是服务端线程阻塞导致的心跳超时。某一时间段消息量突然增加服务端的消息处理线程池满了心跳请求排队等待客户端迟迟没收到 pong就会判定服务端不可用并断开连接。解决办法是把心跳处理放在独立的连接管理器里不跟业务消息处理共用一个线程池。心跳和数据传输要隔离这是一个非常实用的经验。4.5 常见问题速查表问题现象可能原因排查思路解决方案消息丢失WebSocket 断线未补偿检查重连后是否同步消息客户端记录last_received_seq重连后主动同步消息重复客户端超时重试检查是否有全局唯一标识引入client_message_id并做唯一约束权限越权ORM 拦截器未覆盖自定义 SQL检查所有数据查询路径业务 Service 层显式拼接权限条件列表查询慢缺少索引/偏移量分页查看慢查询日志建复合索引 游标分页WebSocket 断开Nginx 超时/心跳超时检查负载均衡配置调大 timeout 独立心跳处理SLA 超时统计不准未按工作日历计算检查 SLA 计算逻辑配置工作日历按有效工作时间计算工单重复分派分布式锁失效检查 Redis 锁持久化Redis 开启 AOF 持久化 锁自动续期5. 上线后的运营与迭代建议5.1 冷启动历史数据怎么迁入新系统DeskcommCRM 开发完成后最现实的问题不是功能而是数据。公司原有的客户资料散落在 Excel、个人微信聊天记录、旧的简陋 CRM 系统里怎么把这些数据导入新系统是个大工程。我建议不要追求一步到位。第一轮迁移只导入客户基础信息包括姓名、公司、手机号、邮箱、负责人、客户状态。损耗比较大的历史沟通记录可以只导入近三个月的更早的记录存成归档文件不进入主业务库。导入之前做一次数据清洗用脚本统一手机号格式、去掉空联系人、合并重复客户。还有一个很容易被忽略的点批导入前一定要先跑一遍试导入用一小批样例数据验证字段映射对不对。我第一次导入时直接把 Excel 里的负责人姓名对应成用户表的id字段结果导入进去后客户全部变成了 0 号负责人搞了一下午才修好。后来我在导入模块里加了映射预览功能先把 Excel 的前 10 行解析出来展示给管理员确认确认没问题再正式导入。5.2 用户培训与埋点让团队真正用起来系统开发完被业务团队嫌弃不好用往往不是因为功能缺失而是因为用户不知道怎么用。我给销售团队做培训的时候总结了一条经验不要从头到尾把每个按钮讲一遍而是讲清楚两个故事——一个客户从进线到成交你要点哪些按钮和一个客户投诉到解决你要点哪些按钮。培训之后还要用数据说话。我在系统里埋了用户行为日志统计每个用户每天的登录次数、访问页面、创建客户数量、完成工单数量。第一个月上线时我发现有个销售主管自己不用系统还让下属别用。我拉了下数据发现这位主管名下的客户数是最多的但他处理的工单数是 0。后来跟他聊了之后才知道他习惯用 Excel 管理客户觉得新系统浪费时间。于是我把他的客户数据导入系统后让他用了一周只在新系统里看数据的磨合期他发现 360° 视图确实比 Excel 方便这才慢慢转过来。埋点统计还有一个重要用途发现功能使用率低的地方。比如跟进提醒功能上线两周后我发现 70% 的用户从未使用过提醒功能。调研后发现是提醒配置入口藏得太深很多人根本不知道有這功能。于是我把提醒配置直接放到了客户详情页的醒目位置使用率立刻就上来了。5.3 开放 API 与二次开发给未来留好接口DeskcommCRM 发展到一个阶段内部功能已经比较完善了但企业客户会提出各种个性化需求比如想把 CRM 里的成交客户同步到企业微信通讯录想从 Excel 定时导入物流信息到客户备注里。如果每个需求都要改主系统代码项目就会越来越臃肿。所以我在系统里增加了一套开放 API采用 RESTful 风格通过access_key和secret做签名认证。开放 API 覆盖了三类核心能力客户数据 API创建客户、查询客户、更新客户、上传客户标签。工单数据 API创建工单、查询工单状态、更新工单处理结果。消息推送 API向指定客户微信/邮件推送通知。做开放 API 之前要想清楚一个安全问题API 的权限范围必须跟用户权限体系保持一致。否则你辛辛苦苦做了内部权限控制一个外部系统通过 API 把全量客户数据拉走了。这个坑我见到很多团队踩过API 网关层一定要再做一次 token 级别的权限校验。我个人在实际操作中的体会是做 CRM 这类系统最大的难点根本不是技术而是对人的理解。你做的每一个字段、每一条流转规则背后都是业务团队的真实工作习惯。我会在需求评审阶段多花一倍的时间去问为什么——为什么这个字段要放在这里、为什么这个流程要这样走、为什么你觉得这个数据重要。问题问得足够多系统才不会做出来被团队彻底抛弃。最后再分享一个小技巧整个系统里我最看重的是消息幂等设计。很多同事不理解觉得消息重复发一次没什么大不了。直到有一次生产环境网络抖动消息队列重复投递了 3000 多条系统靠着client_message_id唯一约束挡住了 99% 的重复消息。这个设计在关键时刻省下了整整一天的数据修复工作量。做通信类系统幂等这个概念一定要刻在骨子里。
返回列表