从零构建坐席工作台:Spring Boot + Vue3实现CRM与工单系统

发布时间:2026/9/16 17:50:12
从零构建坐席工作台:Spring Boot + Vue3实现CRM与工单系统 做客服系统或者叫坐席工作台这个方向的项目做久了基本都会遇到一个尴尬时刻通话记录、在线聊天、邮件、客户档案、工单跟进各自为政客服一天下来要在五六个系统里来回切换光复制粘贴客户编号就浪费大量时间。DeskcommCRM 这个项目最初就是冲着解决这个混乱来的。它的定位很清晰给坐席人员提供一个“桌面通信 客户管理”的整合工作台把来自电话、网页咨询、邮件等不同渠道的沟通记录串联成一条完整时间线同时把客户档案、工单流转、团队协作沉淀到同一个系统里。这篇文章我从项目定位、技术选型、核心模块设计、部署落地到常见坑位把整个实现过程完整复盘一遍适合正在做客服系统、CRM、工单系统或者想把公司零散客户数据统一管理起来的技术团队参考。1. 项目定位坐席桌面需要的不是一个传统 CRM1.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 客户档案与联系人唯一性策略是第一道关客户档案是整套系统的心脏而心脏最容易出问题的就是“客户唯一性”。同一个客户可能昨天用手机号提交了咨询今天用邮箱注册了工单后天换了个微信号来问售后系统如果识别不出这是同一个人那前面做的所有数据统一都是白费。我当时的方案是手机号 邮箱作为强匹配键姓名 公司作为弱匹配键。具体逻辑分三层精确匹配创建或导入客户时先查手机号和邮箱是否已存在于客户扩展表中命中则直接挂接模糊匹配没有精确命中时按“姓名 公司”组合查询相近客户由坐席人工确认是否合并未知归档以上都没有命中时先创建一个“待完善客户”草稿档案坐席后续可以补充信息完成认领表结构上我把客户主表和联系方式扩展表分开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、REOPENEDPROCESSING处理中PENDING、WAITING_CUSTOMERWAITING_CUSTOMER等待客户确认PROCESSINGRESOLVED已解决待关闭PROCESSING、WAITING_CUSTOMERCLOSED已关闭归档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 处理中数量 SetZSetOperations.TypedTupleString 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 后续还可以扩展的方向很多比如结合工单历史记录训练自动回复模型、把沟通时间线做成交互式知识库、增加客户满意度评价闭环。但所有扩展都是建立在一期稳定可靠的数据模型和权限体系之上。先把坐席桌面这块地耕好比什么都强。