资讯动态

从零构建DeskcommCRM:坐席通信与客户管理一体化实践

发布时间:2026/9/17 1:04:18 来源:尧图企业网站定制
从坐席通信到客户管理一体化的实践记录。先说清楚DeskcommCRM是什么它是一款面向客服中心和销售外呼团队的客户关系管理系统。我把它定位成“坐在工位上就能完成所有客户沟通动作”的工作台核心解题思路是把传统CRM里的客户档案、跟进记录、工单流转和通信能力打电话、接电话、录音、留言整合在同一个界面里。我在实际项目中主导开发过这套系统从零到一搭建了坐席工作台、话务服务、工单闭环三大块这篇文章把我整个设计和落地过程拆开来讲包括为什么这么选型、哪些地方容易踩坑、哪些教训是文档里根本不会写的。如果你正在规划客服系统、CRM选型或者自己也要搭一套类似的内部系统这篇应该能让你少走不少弯路。很多人一听到CRM第一反应是Salesforce或者纷享销客那种销售管理工具但DeskcommCRM的侧重点完全不同。它更贴近“联络中心”这个场景坐席每天上班打开系统任务列表里是今天要跟进的客户点击一个电话号码就能直接发起外呼客户来电时系统自动弹屏显示客户资料通话结束之后所有记录自动归档需要协作的工单自动转给下一个处理人。整个链路里用户不需要在浏览器、电话机、Excel之间来回切换这是它和普通CRM最大的差异点。我在这篇文章里不会只给你看功能清单我会把整个项目的设计思路、数据库怎么建模、软电话怎么和CRM深度集成、坐席状态怎么同步、工单自动流转规则怎么配置全部掰开揉碎讲清楚。中间涉及的关键代码、配置参数、排查命令我都会直接贴出来你可以照着改一改就能用。1. 项目整体设计与技术选型1.1 核心需求拆解不是做一个“多了打电话按钮的CRM”接触这个项目的前两周我一直在做需求调研。业务方一开始提的需求非常简单“我们要一个能打电话的CRM。”但这类模糊的需求背后通常藏着一堆没说出口的期望。我花了大量时间跟坐席主管、一线坐席、售后组长聊最后整理出四个核心场景第一坐席希望减少“无意义操作”。过去坐席一天最多打一百多通电话每通电话要先去Excel找号码用座机拨打通话完再回到Excel记录结果通时稍长一点还会忘记对方说过什么。第二管理者希望“看得见过程”。团队主管天天被问“这个客户跟到哪一步了”但答案基本靠坐席汇报数据滞后且主观。第三售后团队希望“工单自动流转”。客户电话里反馈的问题经常需要转给技术、财务或者物流问题是流转靠微信群经常出现重复建单、跟进断档。第四管理者希望“降低培训成本”。新人上手这个系统不能像传统呼叫中心那样先培训一个月话术和系统操作必须让系统界面本身足够直观。这些需求翻译成系统功能就变成了四个关键模块完整客户资料中心与通话记录强关联、坐席工作台电话拨打/接听/保持/转接全集成、工单管理系统自动分配与状态流转、数据看板实时通话量、接通率、工单处理时效。技术语言表述就是高可用的通信网关、低延迟的状态同步机制、自动化规则引擎、结构化日志存储和统计。1.2 技术栈选型与取舍为什么不用SaaS方案在技术选型阶段我们其实先考虑过直接用现成的云CRM再加呼叫中心插件。市面上的主流方案我基本都调研过它们的优势很明显功能全、迭代快、实施周期短。但我们这个项目有两个硬性条件直接排除了纯SaaS方案一是客户数据必须保存在企业自己的服务器上数据合规部门不允许把客户通讯录、通话录音放在第三方云平台二是现有业务系统有一套客户分级逻辑需要和CRM深度打通SaaS的开放接口未必能完美适配。所以最终我们决定自研技术栈如下后端采用Java Spring Boot 3.x配合MyBatis-Plus做数据持久化数据库选用MySQL 8.0InnoDB引擎缓存使用Redis 6.x通信网关通过FreeSWITCH进行二次开发前端使用Vue 3 Element Plus通过WebSocket接收实时通话事件。部署方面用Docker Compose编排在4台物理服务器上两台跑应用和数据库两台跑FreeSWITCH做高可用。选型时有个值得说一说的点为什么通信层选FreeSWITCH而不是Asterisk或者直接买运营商SIP中继服务原因是FreeSWITCH对WebSocket的集成更友好event socket机制可以让我们用Java原生代码控制呼叫流程而不必依赖额外的CTI中间件这套方案在开源社区很成熟后续扩展IVR、排队机都有现成模块。Asterisk也不是不能用但它的dialplan配置在复杂业务场景下调试成本高对我们这支Java团队不太友好。1.3 系统架构的整体视图系统从下至上分为四层接入层、服务层、应用层、展示层。接入层负责与运营商SIP中继对接管理PSTN电话线路也支持SIP软电话注册FreeSWITCH在这里承担“话务交换机”的角色。服务层是核心业务逻辑包括通话事件处理服务消费FreeSWITCH的event socket消息、任务调度服务外呼任务定时触发、工单引擎、数据统计服务。应用层是Restful API和WebSocket推送网关供前端调用。展示层就是坐席工作台、管理后台、监控大屏三个前端界面。其中有一个设计我特别想强调通话事件的消息流转我们全部走Redis Stream而不是RabbitMQ或Kafka。原因是通话事件的数据量不大但延迟敏感Redis Stream足够用而且Redis本来就是基础设施的一部分不需要再额外维护一套中间件。事件流从FreeSWITCH产生经过Java服务解析、业务规则匹配推送到坐席工作台整体延迟在200ms以内坐席感受不到延迟。2. 数据模型与核心表设计2.1 客户档案表的建模思路客户表是CRM的基石这块我花了不少时间取舍字段。刚开始业务方给的客户Excel表格有五十多个字段这要是全做成表字段后期维护绝对是噩梦。我先把字段分为基础信息、扩展属性、系统标记三类基础信息入主表扩展属性用JSON字段存储系统标记单独建表。最终核心表的DDL长这样CREATE TABLE customer ( id bigint(20) NOT NULL AUTO_INCREMENT, customer_no varchar(32) NOT NULL COMMENT 客户编号业务唯一标识, name varchar(128) DEFAULT NULL COMMENT 客户姓名/企业名称, mobile varchar(20) DEFAULT NULL COMMENT 联系电话用于电话外呼, level tinyint(4) DEFAULT 0 COMMENT 客户等级 0-未知 1-低 2-中 3-高, source varchar(32) DEFAULT NULL COMMENT 来源渠道, owner_id bigint(20) DEFAULT NULL COMMENT 归属坐席ID, team_id bigint(20) DEFAULT NULL COMMENT 归属团队ID, ext_info json DEFAULT NULL COMMENT 扩展属性, status tinyint(4) DEFAULT 1 COMMENT 数据状态 1-正常 0-删除, created_at datetime DEFAULT CURRENT_TIMESTAMP, updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_mobile (mobile), KEY idx_owner_team (owner_id,team_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT客户档案表;这里有几个细节值得展开。mobile字段我建议加上索引因为外呼时第一步就是通过号码查客户没索引的数据库在百万级数据量下会明显变慢。ext_info用JSON格式存储既保留了业务字段的灵活性又不用频繁修改表结构。owner_id和team_id做了联合索引因为坐席工作台最常见的查询是“我名下今天要跟进的客户列表”这个联合索引能命中这个查询前缀。但这样做也会带来一个问题一旦复用JSON字段查询性能会下降。我们的对策是规定两条路径高频查询字段必须建模成独立列低频或者报表类统计走离线数仓两者互不干扰。这里有个我自己踩过的坑最开始给ext_info里塞了“意向产品”、“预算范围”、“区域”三个字段结果业务临时要求“按区域意向产品筛选客户”查JSON字段慢得没法接受后来老老实实把区域字段提成了独立列。2.2 通话记录表与话单归档策略通话记录表是所有数据报表的基础这块表的数据量增长非常快。做压力测算的时候我们按每坐席每天120通电话、共50个坐席算一天6000条话单一个月18万条一年两百多万条。MySQL单表到500万条以后查询性能会急剧下降所以必须做归档策略。我们采用的是“热数据单表冷数据按月分区”的方案。当月数据存在主表calls里超过一个月的数据定时迁移到calls_history_YYYYMM的分区表里。查询报表时Java服务层先判断时间范围自动路由到对应分区表。CREATE TABLE calls ( call_id bigint(20) NOT NULL AUTO_INCREMENT, session_uuid varchar(64) NOT NULL COMMENT FreeSWITCH通话唯一标识, customer_id bigint(20) DEFAULT NULL, agent_id bigint(20) DEFAULT NULL, call_type tinyint(4) DEFAULT NULL COMMENT 1-外呼 2-呼入 3-转接 4-会议, direction tinyint(4) DEFAULT NULL COMMENT 1-主叫 2-被叫, status tinyint(4) DEFAULT NULL COMMENT 1-接通 2-未接通 3-忙线 4-拒接 5-系统错误, start_time datetime DEFAULT NULL, answer_time datetime DEFAULT NULL, end_time datetime DEFAULT NULL, duration_sec int(11) DEFAULT 0 COMMENT 通话时长秒数, record_url varchar(255) DEFAULT NULL COMMENT 录音文件存储路径, disposition varchar(32) DEFAULT NULL COMMENT 坐席挂断后的处理结果, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (call_id), KEY idx_customer (customer_id), KEY idx_agent_time (agent_id,start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT通话明细表;为了排查通话异常session_uuid我会直接关联FreeSWITCH的呼叫日志一旦用户投诉“通话中途断了”我可以直接根据这个字段定位到信令级别的日志。call_type和direction字段需要区分清楚direction指的是这个坐席是打电话的人还是接电话的人call_type描述的是通话的业务类型比如转接场景就是一条记录里两个人的话单靠call_type来区分客户是转给同事还是三方通话。2.3 工单表与状态机的设计工单表比客户表复杂的地方在于状态流转。我们的工单支持“待分配—处理中—待确认—已关闭—已驳回”五种状态而且不同来源的工单初始状态不同。工单表我采用了一种“状态机动作表”的组合工单表存储当前状态工单动作表存储每一次状态变更的操作记录。CREATE TABLE ticket ( id bigint(20) NOT NULL AUTO_INCREMENT, ticket_no varchar(32) NOT NULL, title varchar(255) NOT NULL, content text COMMENT 工单内容, source tinyint(4) DEFAULT NULL COMMENT 1-电话 2-在线 3-手工创建 4-API导入, priority tinyint(4) DEFAULT 2 COMMENT 1-紧急 2-普通 3-低, status tinyint(4) DEFAULT 1 COMMENT 1-待分配 2-处理中 3-待确认 4-已关闭 5-已驳回, customer_id bigint(20) DEFAULT NULL, creator_id bigint(20) DEFAULT NULL COMMENT 创建人坐席ID, assignee_id bigint(20) DEFAULT NULL COMMENT 处理人坐席ID, deadline_time datetime DEFAULT NULL COMMENT SLA超时时间, closed_time datetime DEFAULT NULL COMMENT 关闭时间, created_at datetime DEFAULT CURRENT_TIMESTAMP, updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT工单表;状态流转这里我强烈建议不要搞成“工单表随便更新状态”的方式一定要通过统一的状态机服务来改。我们当时就是偷懒直接在业务代码里写了个updateStatus方法结果一个月后状态数据乱了套有工单从“已关闭”被改回了“处理中”有工单一直卡在“待分配”没人处理。后来重构为状态机配置驱动所有允许的状态流转全部在配置文件里定义代码不再直接改status字段。这个配置文件长这样ticket: state-machine: - action: ASSIGN from: - 1 to: 2 - action: RESOLVE from: - 2 to: 3 - action: CONFIRM from: - 3 to: 4 - action: REJECT from: - 3 to: 2每次状态变更前后都有校验非法流转直接抛异常并记录日志。这套机制上线后工单状态再也没有乱过。3. 核心功能模块与关键实现3.1 坐席工作台通信与业务界面的深度融合坐席工作台是DeskcommCRM的门面也是用户每天工作八小时所面对的东西。市面上很多系统挂着“软电话”的名头实现方式其实是一个拨号盘加一通电话记录列表通信和业务彼此独立。我们的目标是做到“通信即业务”点开一条待跟进的客户记录工作台右侧自动加载这个客户的完整画像包括历史通话记录、未解决的工单、备注信息点击拨号按钮系统自动调用FreeSWITCH API发起外呼座席不需要手动输入号码。前端工作台的整体布局是左、中、右三栏左侧是客户列表和今日待办中间是主工作区客户详情/工单详情右侧是软电话面板。软电话面板的状态机包括空闲、振铃、通话中、保持中、事后整理。这个状态通过WebSocket实时同步坐席在系统的状态会影响FreeSWITCH侧的电话路由如果坐席在线状态是“忙碌”后续呼入电话就不再路由给这个坐席避免漏接。这里有一个细节坐席状态和电话状态是两个维度必须分开处理。举个例子坐席在系统里点击“小休”比如去倒杯水此时系统状态变为“小休”但如果FreeSWITCH那边还有一通正在通的话坐席被系统强制置忙会导致通话记录状态与实际通话不匹配。我们的解法是系统状态变更只影响“后续新呼入是否分配”而话务状态则是通话开始时实时从FreeSWITCH事件里同步。两套状态互不覆盖数据才不会出错。3.2 软电话集成FreeSWITCH事件驱动的设计软电话是DeskcommCRM里面技术挑战最大的模块。FreeSWITCH支持多种接入方式我们采用了mod_verto配合WebSocket让坐席直接在浏览器里接听和拨打电话不需要安装任何桌面软电话客户端。这个方案相比传统SIP软电话的优势非常明显零安装、跨平台、和业务页面天然融合。坐席只需要一个带麦克风的耳机打开Chrome浏览器就能工作。关键实现上我们用FreeSWITCH的Event Socket协议接收呼叫事件。FreeSWITCH会把每一个呼叫状态变化RINGING、ANSWER、HANGUP都推送成一条事件消息我们用Java写了一个事件订阅服务把这些事件解析后转换成业务事件再通过WebSocket推送到前端工作台。Component public class FsEventDispatcher { private static final String CHANNEL_EXECUTE_COMPLETE CHANNEL_EXECUTE_COMPLETE; private static final String CHANNEL_HANGUP_COMPLETE CHANNEL_HANGUP_COMPLETE; private static final String CHANNEL_ANSWER CHANNEL_ANSWER; private static final String CHANNEL_BRIDGE CHANNEL_BRIDGE; Resource private RedisTemplateString, String redisTemplate; Resource private WebSocketSessionManager sessionManager; public void handleEvent(FreeSwitchEvent event) { String eventName event.getEventName(); switch (eventName) { case CHANNEL_ANSWER - handleAnswer(event); case CHANNEL_HANGUP_COMPLETE - handleHangup(event); case CHANNEL_BRIDGE - handleBridge(event); case CHANNEL_EXECUTE_COMPLETE - handleExecute(event); default - log.debug(忽略事件: {}, eventName); } } private void handleAnswer(FreeSwitchEvent event) { String agentId event.getVariable(agent_id); String sessionUuid event.getSessionUuid(); // 推送通话开始事件给坐席工作台 CallEvent callEvent CallEvent.builder() .sessionUuid(sessionUuid) .agentId(agentId) .eventType(CallEventType.ANSWER) .timestamp(Instant.now().toString()) .build(); redisTemplate.convertAndSend(agent: agentId, JSON.toJSONString(callEvent)); sessionManager.sendToAgent(agentId, callEvent); } private void handleHangup(FreeSwitchEvent event) { // 在这里计算通话时长、生成话单、触发后续业务动作 CallRecord record buildCallRecord(event); callRecordService.save(record); // 更新客户最后跟进时间 customerService.touch(record.getCustomerId()); } private void handleBridge(FreeSwitchEvent event) { // 坐席成功连通客户表示已经接通 CallEvent callEvent CallEvent.builder() .sessionUuid(event.getSessionUuid()) .callId(event.getVariable(call_id)) .eventType(CallEventType.BRIDGE) .build(); redisTemplate.convertAndSend(agent: event.getVariable(agent_id), JSON.toJSONString(callEvent)); } }上面这段代码我在项目中真实使用有一个地方特别容易出问题FreeSWITCH的事件名称每个版本都可能有细微差异而且不同的呼叫流程外呼、呼入、转接产生的事件顺序不一样。比如转接场景下会产生两个CHANNEL_BRIDGE事件如果代码不做去重处理前端工作台会弹两次“通话已接通”的通知。我们的办法是每条消息带上session_uuid前端根据session_uuid判断是不是同一次通话全局做一次去重。3.3 外呼任务调度与预测外呼的升级路径外呼是客服和电销团队最常用的功能。我们第一版实现的是“双击号码点击拨打”的点击外呼模式但这种模式的效率完全依赖坐席的手速。第二步我们做了“外呼任务列表”模式管理员在后台上传一个Excel系统自动生成外呼任务坐席打开任务列表点击下一条系统自动呼叫下一位客户省掉手动查号码、手动拨号的环节。再往上走还有“预测外呼”模式系统根据空闲坐席数量动态调控并发呼叫数量坐席一空闲电话立即接入一通已接通的通话。预测外呼的算法核心是同时发起多个呼叫但要控制一定的“放弃率”否则客户接起来却没坐席接听体验会很差。我们的策略是设置一个5%左右的放弃率目标用PID控制器动态调整并发数这个不是我在纸上空想出来的实测下来确实能把空闲时间压到最低但也要根据团队容忍度去调别为了效率牺牲客户体验。外呼任务的调度我们用Spring的Scheduled注解做定时轮询每30秒扫描一次待执行任务满足条件就触发FreeSWITCH呼叫。为了确保任务不重复执行用Redis的setnx命令做了分布式锁任务执行时先尝试获取锁拿不到锁就跳过。这个锁的过期时间要设置合理我们设置的是2分钟因为一条外呼任务从开始到结束最多不会超过2分钟如果锁过期时间太短慢任务还没执行完定时任务轮询到下一轮就会重复发起外呼客户会连续接到两次电话。3.4 工单自动分配与SLA超时提醒工单自动分配我们提供了两种策略轮询分配和技能组分配。轮询分配最简单适合流程标准化、坐席技能无差异的团队。技能组分配则适合有分级支持体系的团队比如一线坐席只能处理普通咨询技术问题自动转给二线工程师。实现上是靠一个工单分配引擎监听工单创建事件根据规则计算目标坐席然后通过WebSocket给坐席推送“你有一条新工单待处理”。如果坐席5分钟内没有接单系统自动发送站内信和短信提醒再超时的话工单升级到主管。SLA超时这块我们的做法是给每类工单设置一个处理时限比如“紧急”工单必须4小时内解决“普通”工单48小时内解决。每次工单状态变化时计算剩余时间并刷新Redis中的SLA计时器。为了减轻数据库压力SLA计时器不是每分钟扫数据库而且用Redis的ZSET结构存储处理截止时间后台进程每秒从ZSET弹出超时工单触发提醒逻辑。4. 实操过程与线上部署细节4.1 环境准备与基础组件部署整个系统部署采用Docker Compose编排一台机器上跑所有依赖另外两台跑FreeSWITCH。这里我要交代一下FreeSWITCH的高可用方案我们用的是两台FreeSWITCH通过Keepalived做VIP漂移正常情况下SIP信令都走VIP一台挂了VIP自动漂移到另一台。需要注意的是FreeSWITCH本身的通话状态是内存态发生故障切换时正在通话的会话会全部断掉这个无法避免。我们的对策是所有通话记录在产生时实时写入Redis切换后业务系统能恢复话单数据但正在进行的通话只能中断。对于客服场景来说2秒内的切换时间用户可以接受通话中断的概率极低。部署参照下面这个docker-compose片段version: 3.8 services: mysql: image: mysql:8.0 container_name: deskcomm-mysql restart: always environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: deskcomm_crm volumes: - ./mysql/data:/var/lib/mysql - ./mysql/my.cnf:/etc/mysql/conf.d/my.cnf ports: - 3306:3306 redis: image: redis:6.2-alpine container_name: deskcomm-redis restart: always command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes volumes: - ./redis/data:/data ports: - 6379:6379 app: build: ./app container_name: deskcomm-app restart: always depends_on: - mysql - redis environment: SPRING_PROFILES_ACTIVE: prod ports: - 8080:8080有几点必须注意生产环境MySQL的my.cnf里务必开slow_query_log方便排查慢查询Redis必须设置密码并且禁用危险命令FLUSHALL、KEYS否则一旦端口暴露后果很严重。我见过不少团队因为Redis没设密码被黑客写crontab挖矿的案例不值得再踩一遍。4.2 数据库初始化与初始配置项目启动前需要执行数据库初始化脚本包括建库、建表、插入基础字典数据。我建议把所有建表脚本用Flyway管理这样每次版本升级的数据库变更都能自动执行避免生产环境“手动改数据库”带来的不可控风险。初始化时还必须要配置好系统字典包括坐席角色管理员、班组长、坐席、技能组售前、售后、技术支持、工单来源、工单优先级、客户等级、通话结果接通、未接、空号、拒接、忙线等。这些字典数据看着简单但后期一旦业务跑起来修改成本很高。尤其是通话结果这个字段坐席在事后整理时高频使用如果选项设置不合理坐席每天要多点好几下鼠标。我们的经验是把高频选项放在最前面比如“接通”、“空号”、“拒接”这三个放前面低频的“不清楚”、“网络问题”折叠起来。4.3 坐席账号与权限配置权限模型我们用的是RBAC基于角色的访问控制粒度控到按钮级别。角色包括超管、运营、电销坐席、售后坐席、质检员、团队主管。每个角色有菜单权限、数据权限、操作权限三层菜单权限决定能看到哪些页面数据权限决定能看到哪些客户数据比如普通坐席只能看自己名下的客户主管可以看全团队的客户操作权限决定能不能删除、导出、批量操作。实际配置时我们简化了很多。数据权限用了一个很直接的做法客户表里有owner_id和team_id查询时根据当前登录用户的角色动态拼接SQL条件。普通坐席查询只能加owner_id 当前用户主管可以加team_id 当前团队超管不做限制。这个逻辑写在一个MyBatis的拦截器里统一处理业务代码里不用到处判断角色省了很多重复代码。4.4 软电话联调与WebSocket网关配置软电话联调是整个项目周期里最磨人的环节。FreeSWITCH监听SIP端口5060然后在XML配置里注册网关信息。联调时我的排错顺序是先看FreeSWITCH日志/var/log/freeswitch/freeswitch.log确认SIP注册是否成功再通过fs_cli工具敲命令测试拨号最后才看Java服务收到的事件是否正确。很多时候问题并不是出在代码上而是FreeSWITCH与运营商SIP中继之间的SIP协议不兼容比如编码协商失败、NAT穿透问题。NAT穿透是我们在实际部署中遇到的一个“经典问题”运营商侧的SIP服务器要求客户端定期发送Keepalive包FreeSWITCH部署在内网如果不在配置文件里开启NAT相关的参数外网呼入电话会出现“单通”现象——客户听得到坐席声音坐席听不到客户声音。排查了半天发现SIP注册信息里的Contact地址是内网IP运营商回传的RTP媒体流走到内网IP上自然就丢了。解决方案是在FreeSWITCH的Verto或SIP profile配置里添加ext-rtp-ip和ext-sip-ip参数把外网IP或者VIP地址写进去。WebSocket网关这边我特别注意了断线重连机制。坐席工作台长时间挂机WebSocket连接很容易因为网络波动断开如果没有重连机制坐席会处于“无感知失联”状态。我们的前端实现是每5秒发送一次心跳如果10秒内没有收到后端响应自动断开重连。重连成功后后端会重新推送当前坐席的话务状态保证前端界面和数据一致。4.5 前端工作台核心交互实现前端工作台用Vue 3编写核心页面组件包括客户列表、客户详情、通话面板、工单面板、数据看板。我用Pinia做全局状态管理把所有与“当前坐席、当前客户、当前通话”相关的状态放在store里统一管理。这样组件之间的通信不需要层层传递props特别是通话状态发生变化时多个组件需要同时响应。每次通话状态变化时后端通过WebSocket推送给前端一个事件对象前端根据事件类型更新界面。有一个小细节是坐席点完挂断之后系统自动弹出“事后整理”弹窗让坐席填写本次通话的处理结果。这个弹窗如果不做自动弹出坐席经常忘了填导致话务统计缺失。我们的方案是通话挂断事件触发后前端延迟500毫秒自动弹出弹窗显示客户信息和通话时长坐席选择处理结果后点击确认表单自动提交。4.6 数据看板与实时监控数据看板用的是ECharts做可视化数据来源分两条链路实时数据从Redis读取比如当前在线坐席数、当前通话中的通话数历史统计数据从MySQL聚合查询。为了让大屏不卡聚合查询做了预计算用定时任务每5分钟把统计结果写入一张汇总表看板直接查询汇总表不再实时扫明细表。监控指标包括坐席状态分布空闲/通话/小休/离线、今日累计呼入/呼出量、接通率、平均通话时长、平均等待时长、工单超时数量、IVR按键分布。其中接通率这个指标最容易误导管理者。很多人一看接通率低就觉得坐席不努力但实际上接通率和外呼时段、客户号码质量、外呼策略都有很大关系。上线后我建议管理者关注“有效接通率”排除空号、拒接、忙线后的接通数除以有效外呼总数这个指标比单纯的接通率更能反映真实业务水平。5. 常见问题与排查技巧实录5.1 问题外呼任务重复拨打同一客户现象坐席明明已经拨打过某个客户任务列表里还是显示“待拨打”下一个坐席打开任务还能看到这位客户。排查思路外呼任务的调度靠Redis分布式锁保证不重复执行但锁只解决了任务维度的并发问题没解决数据维度的重复问题。原因是我们把“外呼任务明细状态”放在MySQL里面更新定时任务每30秒扫描一次如果坐席A拨打过程中坐席B也打开了同一个任务两个人同时看到了“待拨打”。解决方法是给外呼任务明细表加上调度状态字段并且通过乐观锁版本号控制当坐席点击“开始拨打”时先执行UPDATE ... WHERE version ?更新成功才允许发起呼叫。5.2 问题通话结束后坐席工作台没有弹出事后整理窗口现象通话记录已经生成但前端没有自动弹出整理窗口坐席需要手动去“历史记录”里找刚打完的通话。排查过程这类问题大部分出在WebSocket事件丢失或顺序错乱上。FreeSWITCH产生CHANNEL_HANGUP_COMPLETE事件时Java服务解析后写库然后通过Redis Stream发布消息WebSocket服务消费消息后推送给前端。如果中间某一步的消息消费失败前端就收不到挂断通知。我们的排查方式是在Java服务里增加事件请求日志观察FreeSWITCH原始事件是否成功转为业务事件前端再开Chrome DevTools看WebSocket的Frame数据确认是否推送成功。这类问题还有一个隐蔽原因坐席的浏览器标签页长时间挂机后浏览器的WebSocket被系统休眠机制强制断开了但前端的UI显示还在线。我们的对策是增加浏览器可见性监听当页面重新获得焦点时立即检查WebSocket连接状态如果断开立刻重连并重新拉取当前通话状态。5.3 问题SIP注册成功但呼入电话无声音现象外呼正常但呼入电话能接到事件通知通话双方都没有声音。这是典型的RTP媒体流问题。SIP注册成功只代表信令通道正常通话的音频走的是RTP协议如果RTP流走不通就会出现“双方都听不到声音”的诡异状态。排查时先登录FreeSWITCH命令行用sofia status指令看SIP profile的IP地址确认ext-rtp-ip配置是否正确然后在坐席的浏览器上确认软电话的WebSocket连接状态最后用tcpdump抓包分析RTP流量是否经过服务器。大多数情况下把ext-rtp-ip配置成公网IP就能解决。5.4 常见问题速查表我整理了一份线下团队排查时经常用到的速查表贴出来给大家参考问题现象可能原因快速排查/解决坐席状态不更新WebSocket连接断开检查浏览器Console的WebSocket状态确认后端服务是否存活外呼显示成功但客户收不到振铃运营商线路异常或SIP网关未注册fs_cli里执行sofia status检查网关注册状态通话记录没有生成FreeSWITCH事件解析失败查看Java服务日志确认CHANNEL_HANGUP_COMPLETE事件是否被接收客户资料弹屏不出现电话号码格式不一致检查呼入号码是否包含86或区号做号码归一化处理报表数据延迟定时统计任务未执行查看定时任务日志确认Redis连接和MySQL连接池是否正常录音文件无法播放存储路径权限问题检查录音文件所在目录的读写权限确认存储路径配置正确工单超时未提醒Redis中SLA定时器被清空检查Redis的ZSET是否有过期策略确认定时消费进程未挂掉5.5 数据一致性与幂等性保障呼叫系统经历过一段时间后数据一致性问题会逐渐暴露。最常见的场景是FreeSWITCH提示通话成功但由于网络异常Java服务没有成功保存话单到数据库导致数据漏记。为了应对这个问题我们在FreeSWITCH事件服务里增加了关键操作的消息确认机制Java服务处理完事件后向FreeSWITCH返回一个ACK如果FreeSWITCH在指定时间内没有收到ACK会重发事件。这样从源头保证了不丢事件。工单创建、录音文件的处理也必须做幂等设计。以录音为例通话结束后系统可能收到多个CHANNEL_HANGUP_COMPLETE事件因为FreeSWITCH里的channel和bridge会产生多事件如果不做幂等同一通电话会保存多条录音记录。我们的做法是基于session_uuid加唯一索引重复插入直接报错业务代码捕获错误后忽略。6. 复盘总结这套系统真正的价值点做到第六个模块我想退一步说说设计哲学层面的东西。DeskcommCRM这套系统从技术上看并不算复杂没有微服务、没有高并发但它能真正落地核心原因是所有设计都围绕“减少坐席无效操作”和“让数据自动流动”这两个原则。很多团队做CRM失败不是技术不行而是把CRM做成了“电子Excel”客户资料录进去就没有然后了该打电话还是去别的系统打该记跟进还是手工敲字工单流转还是靠口口相传。DeskcommCRM的价值不在系统本身而在它把通信链路和业务数据打通了——坐席每天的工作不再是“在多个工具之间搬运信息”而是集中在工作台上接待客户、处理问题。系统自动记录下来的数据又反过来成为管理决策的依据。我在这里只想说一件我在实际使用中体会最深的事情系统设计阶段花一天时间梳理业务的关键动作比后期代码写完了再去适配业务要节省十倍的精力。你现在做选型也好正准备自研也好先把“坐席每天的实际工作流程”画出来哪怕用白板画个草图都比直接抄一个成熟CRM的功能清单要靠谱得多。这行没有银弹每一套系统都是被真实业务流程磨出来的。我这个版本仅仅是一个起点后续还可以继续扩展在线客服IM渠道接入、智能质检语音转写情感分析、客户分群营销标签画像自动生成外呼策略这些方向路还很长。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价