从零构建坐席工作台:Spring Boot + Vue3实现CRM与工单系统
2026/9/16 17:50:02 网站建设 项目流程

做客服系统(或者叫坐席工作台)这个方向的项目,做久了基本都会遇到一个尴尬时刻:通话记录、在线聊天、邮件、客户档案、工单跟进各自为政,客服一天下来要在五六个系统里来回切换,光复制粘贴客户编号就浪费大量时间。DeskcommCRM 这个项目最初就是冲着解决这个混乱来的。它的定位很清晰:给坐席人员提供一个“桌面通信 + 客户管理”的整合工作台,把来自电话、网页咨询、邮件等不同渠道的沟通记录串联成一条完整时间线,同时把客户档案、工单流转、团队协作沉淀到同一个系统里。这篇文章我从项目定位、技术选型、核心模块设计、部署落地到常见坑位,把整个实现过程完整复盘一遍,适合正在做客服系统、CRM、工单系统,或者想把公司零散客户数据统一管理起来的技术团队参考。

1. 项目定位:坐席桌面需要的不是一个传统 CRM

1.1 客服场景的真实痛点:五六个系统来回切

传统客服团队最常见的形态是:电话用一套话务系统,在线咨询用另一个平台,邮件走企业邮箱,客户资料散落在 Excel 甚至个人脑补里,工单再单独开一个系统。每个系统都有自己的账号、界面和数据口径,坐席要了解一个客户的全貌,得分别登录好几个后台,然后手动把记录拼起来。

这种碎片化带来的问题不只是慢。最典型的场景是:客户第二次来电,坐席因为看不到上一次的沟通记录,只能重新问一遍“您之前反馈过什么问题?”,客户体验瞬间崩塌。又比如客户在网页上聊了一半就切到电话,坐席接起电话时完全不知道对方刚才在咨询什么,只能让客户反复陈述。这种信息断裂不仅是效率问题,更是客户信任问题。

还有一个容易被忽视的痛点:管理侧完全拿不到过程数据。客服到底响应了多长时间?工单有没有超时?客户重复反馈的同类问题到底有多少?这些指标如果没有系统支撑,只能靠主管翻聊天记录手工统计,又慢又容易失真。DeskcommCRM 这个名字里的 Desk(坐席台面)加 Comm(通信),本质上就是想解决“客户沟通过程全程留痕并统一承载”的问题。

1.2 DeskcommCRM 的核心定位:操作型 CRM 而非分析型 CRM

在做方案选型之前,我们首先把产品定位想清楚了:DeskcommCRM 是一个操作型 CRM,而不是分析型 CRM。分析型 CRM 的核心是数据挖掘、客户画像、销售预测,偏重 BI;而操作型 CRM 的核心是支撑一线坐席每天的工作流——查客户、记沟通、开工单、做回访。

这个定位直接决定了功能边界。我们不需要在一期就做复杂的客户分群模型、流失预警、销售漏斗预测,而是先把“客户 - 沟通 - 工单”这条主干链路跑通。围绕这个主干,我们拆出了五个核心子模块:

  • 客户档案与联系人管理(统一客户视图)
  • 沟通记录统一存储(电话、在线消息、邮件)
  • 工单创建、流转与闭环
  • 坐席工作台与待办提醒
  • 基础数据看板(响应时长、工单饱和度、解决率)

这个范围看似简单,但因为每一条都涉及多角色的协同,真正做细之后工作量并不小。后面我会逐个模块拆解我当时的实现方案和踩坑经历。

1.3 与传统 CRM、帮助台的区别

很多团队问过我们:这和 Salesforce、Zendesk、或者开源的 SugarCRM 有什么区别?其实区别就在于“场景深度”和“集成深度”。

传统 CRM 重销售管道,核心对象是商机(Opportunity),打法的起点是线索(Lead);帮助台(Help Desk)重工单和知识库,核心对象是 Ticket,但客户 360 度视图很弱。DeskcommCRM 恰好是两者的中间态:它以客户档案为中心,但又不把线索-商机-合同这套销售漏斗作为主流程;它有工单能力,但工单的嵌入口是客户详情页和时间线,而不是独立的报障入口。

说白了,DeskcommCRM 更像是一个“坐席的工作操作系统”:打开系统,今天的待办、客户的完整时间线、正在处理的工单、需要跟进的回访,全部在一个桌面里完成。这也是为什么我们把核心交互界面设计成“客户全景页 + 中间时间线 + 右侧信息抽屉”的布局,而不是传统列表页点进去编辑的模式。

2. 技术选型:克制比堆新技术重要

2.1 为什么前端选择 Vue 3 + TypeScript + Element Plus

前端框架的选型,我们的约束条件有三个:团队成员上手成本低、组件库够全、锁定 TypeScript 保证多人协作的可维护性。基于这三点,Vue 3 组合式 API 加上 TypeScript 是最稳的选择。

组合式 API 对复杂工作台页面特别友好。像客户全景页这种需要同时处理路由参数、多个接口并发请求、时间线组件状态、工单抽屉开关的页面,如果用 Options API,代码会散落在 data、methods、watch 各个区域,改起来很痛苦;用 setup function 把业务逻辑按“客户加载”“时间线加载”“工单操作”拆成独立逻辑块,可读性提升明显。

组件库选了 Element Plus,是因为它的表格、表单、时间选择器这些基础组件成熟度比较高,Table 虚拟滚动、Tree 组件在实现客户层级关系、工单列表时都很顺手。当然它也有坑,比如表格在频繁刷新数据时会出现闪烁,后面我会在问题排查章节专门讲。

2.2 后端为什么选 Spring Boot 3

后端我们用的是 Spring Boot 3。坦白说,如果从零开始用 Node.js 或者 Python FastAPI 也能做,但我当时考虑最多的是确定性:Spring Boot 在权限(Spring Security)、事务(@Transactional)、任务调度(@Scheduled)、消息集成(RabbitMQ 客户端)这些关键能力上,生态最完整,团队里各人的经验水平不一致时,框架的约束反而能保证代码不跑偏。

Spring Boot 3 相比 2.x 最大的提升是 JDK 17 基线。JDK 17 的虚拟线程(Virtual Threads)虽然我们没有大规模使用,但它在处理 IO 密集型的消息推送场景时确实能省很多事。另外 Spring Boot 3 的 GraalVM Native Image 支持,让后续做工具化部署保留了优化空间。

消息队列选了 RabbitMQ,而不是 Kafka。原因也很简单:这个系统的核心是“任务投递与状态更新”,消息量级在每秒几十到几百条,Kafka 的高吞吐优势发挥不出来,RabbitMQ 的多种交换机类型(direct、topic、fanout)实现工单路由、通知分发特别方便。后续如果有大量埋点数据需要处理,再考虑引入 Kafka 也不迟。

存储层的组合是MySQL 8.0 + Redis 7。MySQL 存业务主数据,Redis 存会话 token、客户摘要缓存、在线坐席状态等实时数据。自增主键用了雪花算法(Snowflake ID),主要是考虑到未来分库分表时不用回填 ID,值有连续性且可以反推时间戳,排查问题时很实用。

2.3 总体架构与数据流:先画清楚再写代码

模块划分上,我们没有一开始就上微服务,整体保持单体应用加模块化分包的模式。模块按业务域拆:

  • 接入层:REST API + WebSocket 网关
  • 业务层:customer、ticket、communication、user、dashboard 五大模块
  • 基础设施层:MySQL、Redis、RabbitMQ
  • 部署层:Nginx + Jar 包,Docker Compose 一键编排

数据流的主干是这样的:电话系统或在线客服回传一条沟通记录到服务端 → 服务端解析后先通过客户匹配逻辑定位到具体客户档案 → 写入 communication 表并追加到该客户的时间线 → 如果这条记录关联了工单,更新工单的最后沟通时间并触发通知 → 坐席客户全景页通过 WebSocket 收到实时提醒并把新记录渲染到时间线。

当时画完这张数据流图之后,整个开发节奏就快了,每个模块的开发人员都清楚自己的数据从哪里来、要去哪里。这里想多说一句:写代码之前把数据流理清楚,比任何技术选型都重要。我们曾经在通信记录模块因为没想清楚客户匹配逻辑,导致上线后一大半记录落到了“未知客户”归档里,后面我会展开讲这个案例。

3. 核心模块设计与实操要点

3.1 客户档案与联系人:唯一性策略是第一道关

客户档案是整套系统的心脏,而心脏最容易出问题的就是“客户唯一性”。同一个客户可能昨天用手机号提交了咨询,今天用邮箱注册了工单,后天换了个微信号来问售后,系统如果识别不出这是同一个人,那前面做的所有数据统一都是白费。

我当时的方案是手机号 + 邮箱作为强匹配键,姓名 + 公司作为弱匹配键。具体逻辑分三层:

  1. 精确匹配:创建或导入客户时,先查手机号和邮箱是否已存在于客户扩展表中,命中则直接挂接
  2. 模糊匹配:没有精确命中时,按“姓名 + 公司”组合查询相近客户,由坐席人工确认是否合并
  3. 未知归档:以上都没有命中时,先创建一个“待完善客户”草稿档案,坐席后续可以补充信息完成认领

表结构上,我把客户主表和联系方式扩展表分开:

CREATE TABLE customer ( id BIGINT PRIMARY KEY, name VARCHAR(128), company VARCHAR(256), source VARCHAR(32), level TINYINT DEFAULT 0, owner_id BIGINT, created_at DATETIME, updated_at DATETIME, is_merged TINYINT DEFAULT 0 ); CREATE TABLE customer_contact ( id BIGINT PRIMARY KEY, customer_id BIGINT NOT NULL, contact_type VARCHAR(16), -- MOBILE / EMAIL / WECHAT / QQ contact_value VARCHAR(128), is_primary TINYINT DEFAULT 1, UNIQUE KEY uk_contact (contact_type, contact_value) );

之所以把联系方式单独拆出来,是因为一个客户可能有多个手机、多个邮箱,而且联系方式变更频率比客户主档高得多。用唯一索引保证同一类型下不重复,再在业务层做客户归属判断,既保证数据一致性,又不会让主表因索引频繁变动而锁竞争严重。

坐席在客户列表页搜索时,可以按客户姓名、公司、手机号、邮箱等多个字段模糊查询。一开始我们直接LIKE '%keyword%',数据量过万之后查询延迟明显上升,后来加了全文索引,并对手机号等精确字段走了独立索引,才把响应压到 200ms 以内。具体的索引优化我在第 5 章统一讲。

3.2 工单状态机:没有状态流转图就做不好工单

工单模块的复杂度不在增删改查,而在状态流转的严谨性。我们定义了六个状态,覆盖一个工单的完整生命周期:

状态含义可进入该状态的前置状态
NEW新建待分配
PENDING待坐席处理NEW、REOPENED
PROCESSING处理中PENDING、WAITING_CUSTOMER
WAITING_CUSTOMER等待客户确认PROCESSING
RESOLVED已解决待关闭PROCESSING、WAITING_CUSTOMER
CLOSED已关闭归档RESOLVED

这个状态机看起来简单,但真正落地时碰到的第一个问题是:谁有权限把工单从 PROCESSING 改到 WAITING_CUSTOMER?如果坐席能随意切换,就会出现少数人把工单挂着不上报。我们的规则是:从 PROCESSING 到 WAITING_CUSTOMER 必须填写“客户反馈内容”字段,且系统自动记录切换人、切换时间、切换原因;从 WAITING_CUSTOMER 回到 PROCESSING,必须有客户的最新回复记录。这些限制既防止状态滥用,又为后续 SLA 统计提供了数据基础。

状态机实现上,我们没有引入工作流引擎,因为当前的流转规则还比较线性,引入 Activiti 等引擎反而让代码复杂化。用枚举 + 校验服务类就够了:

public enum TicketState { NEW, PENDING, PROCESSING, WAITING_CUSTOMER, RESOLVED, CLOSED; public boolean canTransitTo(TicketState target) { switch (this) { case NEW: return target == PENDING; case PENDING: return target == PROCESSING || target == CLOSED; case PROCESSING: return target == WAITING_CUSTOMER || target == RESOLVED; case WAITING_CUSTOMER: return target == PROCESSING || target == RESOLVED; case RESOLVED: return target == CLOSED || target == PROCESSING; default: return false; } } }

所有工单状态变更必须走统一的服务方法,在方法内部先做权限校验、再做状态校验、再打开事务写状态表和操作日志表。这里有一个非常容易踩的坑:状态更新如果用“先读后写”的普通方式实现,并发情况下两个坐席同时点了“接单”,会都成功,但实际只有一个应该成功。必须使用乐观锁:

UPDATE ticket SET state = #{targetState}, assignee_id = #{assigneeId} WHERE id = #{ticketId} AND state = #{currentState}

如果更新行数为 0,说明工单状态已经被别人改了,需要重新加载并提示坐席。这个小小的 WHERE 条件,能避免大量并发场景下的状态错乱,后面排障章节我会详细讲一次线上事故就是这个漏洞引发的。

3.3 通信记录的整合存储:不分渠道,统一时间线

通信记录是整个系统里最体现“整合价值”的地方。电话录音、在线消息、邮件来往,如果分开存储,坐席还是得一个个点开看;只有把它们合并成统一格式的时间线,客户全景页才能有“一眼看尽全部往来”的效果。

我设计了一张统一的communication_record表:

CREATE TABLE communication_record ( id BIGINT PRIMARY KEY, customer_id BIGINT NOT NULL, ticket_id BIGINT, channel VARCHAR(16) NOT NULL, -- CALL / CHAT / EMAIL / NOTE direction VARCHAR(8), -- IN / OUT content TEXT, media_url VARCHAR(512), -- 通话录音 / 聊天附件地址 duration_seconds INT, -- 通话秒数 created_by BIGINT, created_at DATETIME, KEY idx_customer_time (customer_id, created_at), KEY idx_ticket_time (ticket_id, created_at) );

每条记录通过customer_id关联客户,通过ticket_id可选关联工单。这里有一个设计细节:我没有为电话、聊天、邮件分别建表,而是用channel字段区分。虽然这样 content 字段的格式比较混(邮件可能是全文,聊天可能是多段拼接,电话可能是摘要),但好处是极大的简化了时间线查询——只需要按 customer_id 和时间排序取一条记录即可,不用做多表合并。

代价是前端的按渠道筛选必须在读取后进行,不过对内部门户来说这个性能开销可以接受。如果未来要做大规模 BI 分析,可以考虑用 ClickHouse 做列存分析库,把数据异步从 MySQL 同步过去,但这属于二期规划,一期不做。

实时提醒使用 WebSocket 推送。坐席端登录后建立长连接,服务端在通信记录和工单状态发生变化时,向对应的客户负责人推送一条消息,内容包括记录 ID、类型、客户名称、摘要。前端收到后把这个事件插入到内存中的待办列表里,同时更新客户全景页的时间线。WebSocket 的心跳保活和重连策略,我在第 4 章的代码片段里给出实现。

3.4 权限模型:数据可见范围是最容易失控的地方

权限设计直接关系到系统能不能真正落地。如果权限太粗放,所有坐席都能看到所有客户的所有沟通记录,那很多业务团队根本不敢用;如果权限太细,普通坐席处理一个客户就要申请 n 次权限,效率又没了。

我的方案是采用RBAC(基于角色的访问控制) + 数据范围隔离的组合。角色分三层:管理员(Admin)、主管(Manager)、坐席(Agent)。菜单和操作权限由角色决定,数据可见范围由组织归属决定。

数据范围的规则定是这样:

  • 管理员:所有客户、工单、数据看板
  • 主管:本部门全部客户与工单,可以查看下属坐席的客户详情
  • 坐席:自己名下客户,以及被临时共享给自己的客户

架构上我用 Spring Security 做认证和授权,核心是自定义了一个DataScopeFilter注解,加在 Controller 方法上,在查询层自动追加数据范围条件。比如坐席查询工单列表时,SQL 会被自动拼上assignee_id = {当前用户ID}creator_id = {当前用户ID},这样即使代码里忘了写条件,也不会越权。这个兜底机制在生产中救了无数次。

客户共享是用一张customer_share表实现的,记录被共享客户 ID、共享给谁、共享截止时间。共享时间过期后自动移除坐席的访问权。当时我们没做递归可见(比如共享客户被 A 转发给 B),因为权限的传播链越深越难控制,宁可让坐席主动申请主管转派,也要避免权限混乱。

4. 关键功能实现与代码示例

4.1 客户去重与合并:一次导入,半墙脏数据

上线初期最惨痛的教训就是客户去重没做严。当时市场同事导入了 3 万条历史客户数据,由于没有在导入环节做实时查重,入库后按手机号一查,近 4000 条是重复数据。一个客户 3 个档案,每个档案下都挂着不同时期的沟通记录,时间线被拆得七零八落。

去重逻辑做了三层:导入时实时查重、日常创建时接口查重、定期批量合并任务

实时查重的核心 SQL 是:

SELECT c.id, c.name, cc.contact_value FROM customer c JOIN customer_contact cc ON cc.customer_id = c.id WHERE (cc.contact_type = 'MOBILE' AND cc.contact_value IN (?)) OR (cc.contact_type = 'EMAIL' AND cc.contact_value IN (?))

批量导入时,把每个联系人的手机号和邮箱拼成集合,一次性查询已存在的客户 ID,然后分“完全重复”和“部分匹配”两种情况处理。完全重复指手机号或邮箱完全命中,直接关联到老客户;部分匹配指姓名和公司都相同但没有联系方式命中,进入人工确认队列。

合并的实现则是事务性的:先把被合并客户的 communication_record、ticket、share 记录全部更新为目标 customer_id,再将被合并客户标记为 MERGED,最后在页面提示业务人员更新成已合并状态。这里强烈建议在合并操作前做一次 dry-run 预演,把影响的行数打出来,避免误操作把两个恰好同名的客户合在一起。

4.2 工单自动分配:轮询不科学,按负载分配

工单分配最开始用的是最简单的轮询(Round Robin):每来一个新工单,就在在线坐席列表里轮流转一圈。但上线一周就发现问题——有的坐席处理快,有的处理慢,轮询会导致工单在“慢手”那边积压,而“快手”已经空闲了。

后来改成按当前处理中工单数量的倒序分配,即优先分配给当前未完成工单最少的在线坐席。这个逻辑不复杂,用 Redis 的 ZSet 就能实现:score 是处理中数量,取最小的几个坐席,然后在这几个人里再按最近一次接单时间做轮询,避免同一个坐席被连续分配。

核心代码如下:

public Long allocateTicket(TicketRequest req) { // 获取在线且处理中最少的坐席, score = 处理中数量 Set<ZSetOperations.TypedTuple<String>> top = redis .opsForZSet() .rangeWithScores("agent:workload:online", 0, 0); // 如果有多个 score 相同的坐席,用最近接单时间打破平局 String leastLoadedAgent = redis.opsForZSet().reverseRangeWithScores( "agent:lastassigned", 0, 0).get(0).getValue(); return Long.valueOf(leastLoadedAgent); }

这里还有一个细节:当坐席离线时,要把它的在线状态和 workload 从 ZSet 里移出,否则分配器会尝试把工单派给一个不在线的人。我们是用 WebSocket 的断线回调 + Redis 的过期时间双保险,保证在线状态尽量实时准确。

4.3 WebSocket 消息推送:心跳和重连一个都不能少

实时提醒这块,服务端用 Spring WebSocket 做事件推送。连接建立后,前端用 Stomp.js 订阅/user/{userId}/notify队列,服务端用 SimpMessagingTemplate 向指定用户推送消息,这是 Spring 生态最成熟的一套方案。

但这里我踩了一个大坑:WebSocket 连接在 Nginx 层如果 60 秒没有消息传输,会被默认空闲超时断开。实际上浏览器和后端虽然在维持 HTTP 长连接,但 Nginx 不管这些,直接掐断。表现为坐席放着页面不动,过几分钟后待办提醒就收不到了,刷新页面又恢复。

解决思路有两个:一是调大 Nginx 的 proxy_read_timeout,二是前端定时发送心跳包。两者我都做了,心跳每 30 秒发一次,保证链路上一直有数据流动。前端连接断开后还要做自动重连,重连成功后重新拉一次未读通知,防止断开期间漏掉消息:

const connectWs = () => { const socket = new SockJS('/ws'); const stompClient = Stomp.over(socket); stompClient.connect({}, frame => { stompClient.subscribe('/user/queue/notify', res => { const payload = JSON.parse(res.body); // 插入待办提醒 pushToNoticeList(payload); }); }, error => { // 指数退避重连:2秒、4秒、8秒... setTimeout(connectWs, Math.min(Math.pow(2, retryCount++) * 1000, 30000)); }); };

4.4 数据看板:指标先定义清楚,再谈可视化

看板模块看着简单,实际统计口径很容易扯皮。比如“平均首响时长”,是从工单创建到坐席第一次回复的时间,还是从客户发消息到坐席回复的时间?“解决时长”是到 RESOLVED 还是到 CLOSED?如果不定义清楚,业务方和开发方会无休止的拉扯。

我在系统里把这些指标做了统一枚举说明,并让业务方签字确认:

  • 首响时长:工单创建时间 → 坐席在该工单下发送第一条回复消息的时间
  • 平均解决时长:工单创建时间 → 工单变为 RESOLVED 的时间差,排除等待客户确认时间(WAITING_CUSTOMER 累计时长)
  • 工单饱和度:某日新增工单数 / 在线坐席人数
  • 超时率:超过 SLA 时限仍未处理的工单占比

SQL 上,工单的解决时长使用了 TIMESTAMPDIFF,并结合状态变更历史表来减去等待客户确认的时间:

SELECT DATE(created_at) AS day, COUNT(*) AS closed_count, AVG(TIMESTAMPDIFF(MINUTE, created_at, resolved_at)) AS avg_resolve_minutes FROM ticket WHERE state = 'RESOLVED' AND resolved_at >= ? GROUP BY DATE(created_at) ORDER BY day DESC;

这个统计口径先跟业务方对齐,再看板模块就没返工过。所以我特别建议:凡是做数据类的功能,第一步永远是确认指标口径,而不是急着画图表。

5. 部署落地与性能优化

5.1 部署架构:先单体部署,再考虑拆分

项目的起步阶段用户量不大,我选择了单体应用 + 独立中间件的部署方案:一个 Spring Boot Jar 包 + MySQL + Redis + RabbitMQ,部署在一台 8C16G 的云主机上,前端静态资源由 Nginx 托管并代理后端 API 和 WebSocket。

用 Docker Compose 编排整套环境,好处是新环境一行命令起服务,排障时也不用手工装依赖:

version: '3.8' services: mysql8: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: ${DB_PASS} volumes: - ./mysql-data:/var/lib/mysql ports: ["3306:3306"] redis: image: redis:7.0 command: redis-server --appendonly yes volumes: - ./redis-data:/data rabbitmq: image: rabbitmq:3.12-management environment: RABBITMQ_DEFAULT_USER: ${MQ_USER} RABBITMQ_DEFAULT_PASS: ${MQ_PASS} ports: ["5672:5672", "15672:15672"] app: build: . depends_on: - mysql8 - redis - rabbitmq environment: SPRING_PROFILES_ACTIVE: production ports: ["8080:8080"]

单体部署的最大好处是运维简单。但也要注意,如果后续接入渠道变多,比如增加企业微信、抖音私信等,通信模块的写入量会剧增,那时候就需要把“通信记录接收”单独拆成消费者服务,通过 RabbitMQ 解耦。我在设计表结构时已经为这个演进留下了 route key,所以后续拆分时并不需要改表结构。

5.2 数据库索引与慢查询:一半的接口变快是索引的功劳

上线一段时间后,接入的坐席和客户量上来,整个系统明显变迟钝。我花了大半天抓慢查询日志,发现 80% 的慢 SQL 集中在三块:客户列表搜索、工单列表查询、时间线查询。优化后响应时间从 800ms 降到 200ms 以内,核心思路是精确索引 + 覆盖索引。

客户列表搜索的慢是因为同时对多列模糊匹配,原来 LIKE '%keyword%' 无法走常规索引。我的优化方案是区分场景:对手机号这种唯一性字段,前后端约定为精确匹配,走索引;对姓名和公司这种名称模糊字段,建立拼接字段的全文索引,用 MATCH AGAINST 语法。实测在 10 万客户规模下,全文索引比 LIKE 模糊查询快一个数量级。

工单列表的缓存策略是:工单列表的实时性要求不高,把当前处理中工单的列表放 Redis,缓存 10 秒;每次状态变化时主动失效对应缓存。这样热点查询不打到 MySQL,数据库压力骤降。

另外覆盖索引也很关键。时间线查询原来SELECT * FROM communication_record WHERE customer_id = ? ORDER BY created_at会全行回表,只取需要的字段后建了(customer_id, created_at, ticket_id, channel, content)的复合索引,查询效率提升明显。注意不要建太多索引,写入频繁的表索引多了会拖慢插入速度,我最后只保留了最核心的 3-4 个索引。

5.3 前端性能优化:虚拟滚动和大表格

客户时间线随着记录越来越多,一次性渲染几百条 DOM 会明显卡顿。前端用了虚拟滚动方案:只渲染可视区域内的记录,上下滚动的可视区数据即时替换。Element Plus 的 Table 组件也提供了虚拟滚动属性,但默认行为在多层嵌套时表现一般,我们最后选用了@tanstack/vue-virtual这类专门虚拟列表库,配合分页加载,体验好了很多。

客户列表和工单列表也一样,所有超过 1000 行的列表接口都强制分页,每页 50 条。同时,前端把搜索条件作为 query 参数同步到 URL 上,刷新页面后筛选条件不丢,这个细节虽然没有技术含量,但业务人员非常喜欢。

6. 上线过程中的坑与排查实录

6.1 客户数据重复:导入没做查重差点翻车

这个问题前面提过,3 万条导入数据产生了近 4000 条重复记录。复盘原因是在导入工具里,查重逻辑只针对了 Excel 内部行之间的重复,没有把它们和数据库存量数据做比对。

当时排查的思路是:先按手机号分组统计,找出重复 ID,然后看这些重复档案下的沟通记录分布,确认哪些是真实客户、哪些是空档案。最后合并时花了整整一个下午,还专门写了一个核对 SQL 确保每条 communication_record 都迁到了目标客户下。这个教训让团队把“先查重再入库”定成了写数据任务的第一原则。如果你也在做类似系统,导入模块一定不要图省事,宁可多花时间在查重逻辑上,也不要用一个晚上去清理脏数据。

6.2 工单并发更新:乐观锁是底线

另一个印象深刻的坑是工单并发更新。某个大促活动期间,两个主管几乎同时把一个工单从 PENDING 转发给不同的坐席,系统返回了两条成功提示,工单被派给了两个人。原因是代码里先查出工单当前状态,在内存里判断,再执行更新,这个判断和更新之间没有形成原子操作。

解决方式就是第 3 章提到的乐观锁,在 UPDATE 语句的 WHERE 中带上原状态。排查这个问题还有一个技巧:在开启 Web 管理端支持 SQL 慢日志的情况下,打开 binlog 日志,按时间范围回放工单表更新记录,能清楚看到两条 UPDATE 的先后顺序和影响行数。这种并发问题只要出现一次,就要坚持所有工单状态变更都走统一 Service 方法,不允许在 Controller 里直接调用 Mapper 去改状态。

6.3 WebSocket 断连导致通知丢失

WebSocket 断连问题在正式上线的第三周集中暴露。坐席普遍反馈“挂机久了就收不到新工单提醒”,排查发现 Nginx 空闲超时截断了连接,而前端的重连逻辑又不完善,断线后需要手动刷新页面。

解决方案前面已经提过:服务端配合前端心跳保活,前端指数退避重连,重连成功后补偿拉取未读通知。这里有三个注意点,一是心跳包不要和业务消息混在一起,单独用一个/heartbeat的 topic 定期发送;二是 WebSocket 连接必须带上用户认证信息,不能因为支持长连接就降低安全要求;三是断开后重新订阅的 topic 必须和断开前一致,否则会出现“连接建好了但收不到消息”的假死状态。

6.4 避坑清单速查

坑点表象预防方案
导入未查重数据重复、时间线断裂入库前强制查重,提供 dry-run 预演
工单 UPDATE 缺少乐观锁并发分配/转派后状态错乱WHERE 条件带上当前状态
WebSocket 被 Nginx 断开待办不及时、通知丢失心跳保活 + 重连 + 补偿拉取
时间线查询全表扫描页面卡顿、DB CPU 飙升时间线加复合索引,按需查字段
状态变更没有日志责任不清、统计失真所有变更写入操作日志表
权限遗漏数据范围坐席看到其他组客户Controller 层 DataScope 兜底

除了这些,还有一个小坑值得提:工单关联的 communication_record 在状态变更时,如果不更新ticket_id关联,时间线能看到记录但工单详情里统计错误。我在写记录时的逻辑是:消息到达后先定位客户,再尝试把记录挂到当前处于 PROCESSING 或 WAITING_CUSTOMER 状态的工单下,优先级取最新更新的工单。这个规则当时讨论了挺久,但现在回看,至少保证了一期业务场景下“客户问了什么”和“工单处理到什么程度”能对齐。

结尾:如果重新做一遍,我会把更多时间花在哪

DeskcommCRM 做到现在,我最有感触的一点是:技术难点从来不在“某个框架怎么用”,而在“业务规则怎么落到数据模型里”。客户唯一性、工单状态机、权限数据范围、指标统计口径,这四个设计决策几乎决定了系统的上限。如果哪一天有人要重新做一个同类系统,我的建议是花至少 40% 的时间在设计阶段把这些规则想明白、和业务方对齐文字版口径,剩下 60% 的时间写代码会很顺。

再分享一个实际开发中的小技巧:给所有核心表都加上 created_at、updated_at 和操作人字段,并且强制走 MyBatis 的自动填充,别在业务代码里手动 set。这套元信息在排查问题、做审计、分析数据来源时,价值远超它本身那一点点存储开销。还有,任何涉及批量修改的接口,上线前至少拿生产数据做一次影子演练,不要只测空库。

DeskcommCRM 后续还可以扩展的方向很多,比如结合工单历史记录训练自动回复模型、把沟通时间线做成交互式知识库、增加客户满意度评价闭环。但所有扩展都是建立在一期稳定可靠的数据模型和权限体系之上。先把坐席桌面这块地耕好,比什么都强。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询