项目代号DeskcommCRM,是我最近大半年主导落地的一套客服场景客户关系管理系统。说是系统,其实更像一个把客户资料、跟进记录、工单任务和电话通信串在一起的工作台。当初起名字的时候,Desk代表坐席工位,Comm是Communication,CRM不用解释——合在一起就是“坐席通信型客户管理系统”,这也基本框定了它的核心定位:给每天泡在电话和工单里的客服、销售团队用,而不是给市场部做营销自动化用的。
做这个项目之前,我调研过市面上不少CRM产品,大厂的那几套功能确实全,但价格和配置复杂度对中小团队不友好,尤其是“通信”这个模块,要么是外挂第三方呼叫中心,要么干脆不做,坐席还是要自己拿手机打电话,客户资料和通话记录永远对不上号。DeskcommCRM从头设计就是把电话能力内建进去,让坐席在浏览器里直接发起呼叫、接听来电,系统自动弹屏显示客户历史记录,通话结束自动归档录音和时长,工单和下次跟进计划也跟着生成。这套逻辑听起来不复杂,真正落地时从架构选型到细枝末节,坑一点都不少。今天这篇就按项目推进的顺序,把我实际做过的方案、踩过的坑、最后沉淀下来的可用配置完整写出来。
1. 项目定位与前期需求拆解
1.1 DeskcommCRM解决的核心问题
先说清楚这个系统的边界。传统CRM管的是“客户信息”和“销售流程”,但客服团队真正的工作流是“电话进来—识别客户—查资料—处理问题—记录结果—跟踪回访”。这里面最痛的不是数据录入,而是通信和业务系统两张皮。坐席接完电话,要手工在Excel或CRM里找客户、补记录,通话录音存在话机里,调取又麻烦。
DeskcommCRM把通信能力作为基础设施,而不是独立模块来设计。每个坐席账号绑定一个分机号,所有呼入呼出都走系统自带的话务通道,通话事件实时推送到前端,配合客户库自动匹配号码。换句话说,业务数据和通话数据从源头就是一条流水线,不需要坐席做二次搬运。
这个定位决定了后续所有技术选型——系统必须能处理实时信令,必须有稳定的通话录音存储,必须支持浏览器端发起通话(不能要求每个坐席都装软电话客户端),同时还要保留传统CRM应有的客户管理、工单、统计报表能力。它服务的典型团队规模在10到50个坐席左右,不需要复杂的多级组织架构,但权限和角色划分要够用。
1.2 从需求收集到功能清单
启动阶段我坚持先做需求访谈,直接跟客服主管、一线坐席聊了整整一周。最后整理出来的核心诉求集中在四类:
- 来电必须自动识别客户:只要号码在客户库里,弹屏就要带出姓名、历史工单、最近跟进记录,不能让坐席先问“您是哪位”。
- 通话结束要自动归档:录音、时长、呼叫方向、通话结果全部自动落库,坐席只需要补填一个“通话小结”,甚至小结都可以用模板。
- 工单要能流转和催办:一线处理不了的问题要能升级到二线,超时要自动提醒,不能靠人工盯群。
- 管理者要能看实时数据:今天接了多少电话、平均通话时长、待处理工单数、每个人的工作量排名,一张看板全出。
其实这些需求单独看都不算稀奇,但把它们整合在一个系统里,并且把通信作为默认能力而非选配模块,市面产品就少了很多。这也是我坚持自研而不是买现成系统的直接原因。
1.3 目标用户与使用场景画像
系统上线后的实际使用人群是三类角色。一线坐席是每天打开系统时间最长的人,他们的体验重点在于“少切屏、少打字、少重复操作”,任何需要多点的按钮都是负担;客服主管关注工单积压、响应时长和坐席工作质量,需要的是数据下钻能力;系统管理员则关心权限配置、号码资源管理和录音合规存储。
场景上覆盖三种典型业务:售前咨询(客户来电问产品、要报价,坐席在弹屏商机卡里直接记录意向)、售后工单(电话报修生成工单,流转到技术组处理)、主动回访(系统导出待回访客户名单,坐席一键点击号码发起外呼)。这三种场景对通信链路的稳定性要求都很高,因为一旦通话出问题,业务直接中断。
2. 系统架构与核心技术选型
2.1 技术栈选型与理由
DeskcommCRM采用前后端分离架构。后端主框架是Spring Boot 3.x,数据库用PostgreSQL 14,缓存和任务队列用Redis,前端是Vue 3 + Element Plus,部署采用Docker Compose。之所以选Spring Boot而不是Node.js或Go,主要考虑因素有两个:一是团队对Java生态更熟,招人和维护成本低;二是CRM这类系统有大量复杂的条件查询、事务操作和报表统计,Spring生态里Spring Data JPA、Spring Security、Quartz这些组件能直接复用,开发速度快很多。
前端选Vue 3纯粹是因为组件生态成熟,尤其是表格、表单、弹窗这类管理系统高频组件,Element Plus开箱即用。通话能力用的是WebRTC + FreeSWITCH的软电话方案,没有采用商用的云呼叫中心SDK——不是不好,而是客户希望通话录音和话单数据能完全本地化存储,敏感数据不出内网。
PostgreSQL是这套系统的数据底座。一开始有人建议MySQL,我在对比后坚持用PG,核心原因是系统里有大量JSON字段(客户画像标签、工单扩展属性)和需要复杂窗口函数的统计报表,PG的原生JSONB和窗口函数支持比MySQL顺手得多。实际开发下来,这个选择确实省了不少事。
2.2 通话链路方案:FreeSWITCH + WebRTC + 运营商SIP中继
这是整个系统里技术复杂度最高的部分。我的方案是:运营商提供SIP中继线路,FreeSWITCH负责信令控制和音频桥接,坐席浏览器通过WebRTC注册到FreeSWITCH,系统后端通过Event Socket协议监听通话事件并落库。
拨号流程大概是这样的:坐席在网页上点击拨号,后端生成一个外呼任务,通过ESL命令让FreeSWITCH先呼叫坐席分机,再桥接外部被叫号码。这种“先呼坐席再呼客户”的方式有一个好处:坐席不会错过客户接通那一刻——因为只有坐席先接起来了,系统才继续呼客户。客户接通后,FreeSWITCH自动开始录音,同时把CallerID(客户号码)和UniqueID(通话唯一标识)推给后端,后端查询客户库判断是否弹屏。
呼入流程对称:客户拨入号码后,FreeSWITCH根据IVR或者ACD队列策略分配到空闲坐席分机,坐席浏览器接听,同时通话事件推给后端弹屏。这里的弹屏不是等通话结束才弹,而是在振铃阶段就要弹——因为坐席接电话需要立刻看到客户信息,理想状态下电话响起时浏览器已经切到了客户详情页。
2.3 数据库设计要点
数据库设计是整个项目成功与否的关键,我花在建模上的时间比写代码还多。核心表有这些:
- sys_user(坐席/管理员账号),关联分机号和角色。
- crm_customer(客户主表),存放客户名称、行业、等级、来源等,独立出crm_contact(联系人表),因为一个客户可能有多个联系人,每个联系人有自己的电话。
- crm_clue(线索表),用于暂存从广告、表单进来的待分配线索。
- crm_order/crm_contract(订单和合同),这部分我做得比较轻,因为客户的销售流程相对简单,但字段预留了扩展位。
- crm_ticket(工单表),包含工单号、标题、状态、优先级、当前处理人、SLA截止时间、关联客户。
- call_log(通话记录表),记录呼叫方向、通话时间、时长、录音地址、绑定坐席和关联客户。
- sys_operation_log(操作日志表),所有关键数据的增删改都留痕,这个是客户审计要求。
设计时重点考虑了号码关联的性能。来电弹屏的场景是毫秒级的,如果每次来电都做一次全表LIKE查询,数据库压力会很大。我的做法是单独建一张call_number_mapping表,把客户联系人的所有电话号码拆出来做索引,来电时直接精确匹配号码,然后通过映射表反查客户ID。号码入库前统一格式清洗(去空格、去横线、统一前缀),确保匹配命中率。
3. 核心模块实现与实操细节
3.1 来电弹屏与号码匹配
先聊弹屏,这个功能直接决定坐席的第一感受。实现逻辑不算复杂,但细节很多。
FreeSWITCH收到来电后,通过ESL推送ChannelCreate事件,后端拿到主叫号码之后先去Redis缓存里查一次,查不到再查数据库映射表,拿到客户ID后查客户详情和最近工单,组装成弹屏数据主动推送给对应坐席的WebSocket连接。这里必须用Redis做一级缓存,因为如果大量来电同时到达,数据库会被查爆。
号码匹配有一个坑:用户可能用手机或座机打来,手机号能直接匹配,但座机号码有时带区号有时不带,有的还带分机号。我做的处理是在号码入库存时统一格式化为E.164标准,匹配时先对来话号码做归一化处理,如果直接匹配失败,再尝试去掉区号、补0等规则。即便如此,还是有大约5%到8%的号码匹配不上,这时候弹屏就显示“未知客户”,坐席接听后可以手动关联或新建客户。
3.2 工单流转与SLA机制
工单模块我设计成状态机驱动:新建、受理中、处理中、待客户确认、已解决、已关闭、已重新打开。每个状态都有对应的操作按钮和权限限制,比如一线坐席能创建工单,但不能直接关闭工单,只能标记“等待客户确认”;只有主管或二线技术员才能把工单标记为“已解决”;超过7天未确认的已解决工单自动关闭。
SLA超时提醒是工单模块的核心亮点。规则在后台可配置:普通工单要求4小时内首次响应,24小时内解决;高优先级工单要求30分钟内首次响应,4小时内解决。系统用Quartz定时任务每分钟扫描一次工单表,对比SLA截止时间,发现超时就把工单状态置为“SLA超时”,同时推送站内信给当前处理人,并通过邮件/企业微信Webhook通知主管。
实操中遇到的麻烦是首次响应时间的定义。单纯从工单创建时间算不公平,因为如果用户是晚上11点提交的工单,早上才有人处理是合理的。后来我加了一套工作时间日历:系统读取工作日历配置,只有工作时段才计入SLA时长,工作时间下午5点创建的工单,截止时间自动顺延到第二天上午9点后开始计算。配置了一套默认的9:00-18:00工作日历,客户可以在后台自行调整。
3.3 通话录音与质检评估
通话录音默认全部开启,存储路径按日期分目录,文件格式为WAV(后续可转MP3)。FreeSWITCH侧通过dialplan配置录音变量,在桥接开始前打开录音,结束后自动写入文件,录音文件名与UniqueID一致,后端话单落库时同步记录录音相对路径。
录音文件的管理有几个必须处理的问题:存储增长很快,一个坐席一天通话200分钟,按8kHz采样率的WAV算,每天约1.9GB,一个20人的团队每月就是1TB级别的数据量。我做的方案是:录音文件先保存在本地数据盘,每晚凌晨2点定时任务把3天前的录音文件压缩成MP3(从8kHz的112kbps降到32kbps,体积缩减70%以上),再同步到对象存储;同时设置保留策略,录音保留180天,过期自动清理。
坐席和管理员的质检流程是能在线播放录音、填写质检评分表和备注。这里涉及一个权限控制:坐席只能播放自己外呼的录音,管理员能播放所有录音。技术上就是后端根据当前登录用户ID和通话记录中的坐席ID做匹配鉴权,播放接口返回预签名URL而不是直接暴露文件路径,防止用户通过抓包拿到任意录音地址。
3.4 数据看板与多维报表
数据看板是主管使用频率最高的页面。我实现了两类看板:实时看板(今日概览)和周期性报表(日报/周报/月报)。实时看板展示今日电话量(呼入、呼出、未接)、在线坐席数、当前排队电话数、今日已解决工单数。这些数据不能全部查数据库,否则每次刷新都是压力,我采用Redis计数器+定时聚合的策略:通话事件和工单状态变更时实时更新Redis计数器,页面每5秒拉取一次就能满足实时性要求。
周期性报表则走数据库SQL聚合。日报统计每个坐席的呼出电话数、平均通话时长、接通率、工单处理数、首次响应时长等。这里SQL比较复杂,会用到GROUP BY + CASE WHEN + 窗口函数,因为要同时统计多个维度的多个指标。报表支持导出Excel,底部额外加了累计趋势折线图,直接用前端图表库渲染,数据接口按天聚合好返回给前端,前端画图。
3.5 权限模型与数据隔离
权限设计我采用了RBAC(基于角色的访问控制)模型,外加数据范围控制。角色分为管理员、主管、坐席三种,权限点细粒度到按钮级别,比如“新建客户”“编辑工单”“删除录音”“导出报表”等,每个权限点用唯一编码标识,角色关联权限点集合。
数据范围控制相对麻烦:坐席默认只能看到自己和名下客户的数据,主管能看到自己部门的数据,管理员看全局。我通过给用户表加dept_id(部门ID),业务表查询时强制拼接部门过滤条件实现。为了避免开发过程中漏加过滤条件导致越权,我封装了一个统一的BaseRepository,所有业务查询入口都强制走数据权限校验,不直接暴露原生的Repository方法。这个二次封装花了一些时间,但上线后没有出现过越权数据泄露的问题,值得。
4. 部署上线与运维实践
4.1 服务器规划与Docker化部署
DeskcommCRM项目我采用了单机Docker Compose部署方案,服务器配置为8核16GB内存。数据库、Redis、后端服务、前端Nginx、FreeSWITCH各跑一个容器。这套方案足够支撑50个坐席以内的团队使用,后续如果并发上来,可以单独把FreeSWITCH和数据库拆到独立服务器。
这里给出核心的docker-compose.yml骨架,后续新项目可以直接参考:
version: "3.8" services: postgres: image: postgres:14-alpine environment: POSTGRES_DB: deskcomm POSTGRES_USER: deskcomm POSTGRES_PASSWORD: change_me volumes: - pgdata:/var/lib/postgresql/data restart: always redis: image: redis:7-alpine command: redis-server --appendonly yes volumes: - redisdata:/data restart: always backend: build: ./backend depends_on: - postgres - redis environment: SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/deskcomm SPRING_DATA_REDIS_HOST: redis volumes: - ./storage:/app/storage restart: always ports: - "8080:8080" freeswitch: image: safarov/estuary-freeswitch network_mode: host volumes: - ./freeswitch/conf:/etc/freeswitch - ./freeswitch/recordings:/recordings restart: always web: image: nginx:alpine depends_on: - backend volumes: - ./frontend/dist:/usr/share/nginx/html - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf ports: - "80:80" - "443:443" restart: always volumes: pgdata: redisdata:部署时特别注意FreeSWITCH使用host网络模式,因为SIP协议涉及大量动态端口,用桥接模式会导致端口映射配置非常繁琐,而且容易出音频单通问题。WebRTC需要WSS加密,所以Nginx里配置了WebSocket反向代理,把wss://域名/ws转发到后端WebSocket服务,同时把WebRTC的信令通道也走这个入口。
4.2 WebRTC通话相关端口与防火墙配置
浏览器端WebRTC通话不仅需要WSS信令通道,还需要UDP媒体端口。FreeSWITCH默认使用UDP 16384到32768端口段传输音频流,加上SIP的5060端口。很多团队部署后电话打不通,查了半天发现是云安全组没放行UDP端口范围。我遇到过客户公司自建机房,运维只开了TCP 443端口,WebRTC的媒体包根本进不来,现象就是页面显示“已连接”,但双方听不到声音。
排查这一类问题有个实用套路:先用sip show channels看SIP注册状态,再用fs_cli里的sofia status确认网关注册是否正常,然后用rtp debug抓媒体包看RTP是否到达FreeSWITCH。通过这三步,基本能定位问题出在信令层还是媒体层。经验是:信令走TCP 443没问题,媒体必须开放UDP端口,这个务必提前写进网络规划文档。
4.3 初始数据迁移与上线切换
新系统上线最大的心理负担是数据迁移。客户之前用Excel管理客户和工单,我需要把三千多条客户记录、八千多条历史工单导入新系统。数据清洗占了大半时间:电话号码格式五花八门(有带“-”的、有加86前缀的、有中间空格的),Excel里客户名称重复率接近10%。我写了Python脚本统一清洗,电话号码用正则提取数字后归一化,客户重复的判断逻辑结合名称相似度和电话号码重复度加权计算,合并时保留最近更新时间的数据。
导入的批次很重要。我先导入客户和联系人数据,确认无误后再导工单,避免工单表外键找不到客户。每个批次前都做全量备份,万一导入失败能立刻回滚。上线切换时,我采用了“并行运行+逐步切换”策略:第一周只有客服一组用新系统,其他组继续用Excel,期间每天导出一份新系统的运营数据给主管对比;一周后确认数据一致,再全员切换,旧Excel归档保存。
5. 常见问题与排查技巧实录
5.1 通话单通或听不见声音
这个问题出现频率最高,现象是坐席能听到客户说话,但客户听不到坐席声音。我的排查思路是先确认是不是WebRTC的麦克风权限问题:浏览器地址栏旁边有没有麦克风图标,是否被系统拦截。排除了前端权限之后,重点查FreeSWITCH的音频编码协商。WebRTC通常用opus编码,而SIP中继可能只支持PCMA/PCMU,如果编码不匹配且没有转码配置,就会出现单通。
解决方案是在FreeSWITCH的dialplan里显式调用set变量强制音频编解码顺序,并且在var.xml里开启media_bug_answer_req=true以便录音功能正常工作。还有一类“声音断断续续”的问题,多半是UDP丢包,检查网络QoS配置,如果走的是公网SIP中继,建议把媒体流改成通过服务器中转(即B2BUA模式),而不是端到端直连,虽然增加微小的延迟,但稳定性提升明显。
5.2 来电不弹屏
来电不弹屏的比例大概在5%左右,主要原因有三个。第一是号码匹配失败,常见于用户手机号是虚拟运营商号段,不在常规号段列表里;或者用户开通过呼叫转移,来显显示的是转移号码。第二是坐席页面WebSocket连接断开后未自动重连,这会导致前端收不到后端推送的弹屏事件。我在前端实现了WebSocket心跳重连机制,断线后每3秒自动重连一次,并在页面上显示连接状态提示。第三是后端处理事件异常,比如通话事件里客户号码为空,导致下游查询直接跳过。我在话单入库时加了校验,号码为空也照常保存话单,但弹屏事件会走一条“未知号码”通道,不会阻塞通话。
5.3 坐席收到大量重复呼叫任务
上线第一周出现过一次诡异故障:多个坐席同时报告系统自动拨打同一个客户号码。排查后发现是外呼任务的并发控制有漏洞——销售经理一次性导入了1000条外呼名单,系统自动分配给所有在线坐席,但分配逻辑里缺少对名单条目的“锁定”机制,导致同一个号码被多个坐席同时领取。
修复方案是引入Redis分布式锁:每个名单条目在分配前先尝试获取锁(key为名单ID,过期时间30秒),拿到锁的坐席才能看到这条记录,其余人看不到。上线这个机制后,同样的场景没有再发生过。这个故障也提醒了我,凡是涉及“多人同时操作同一批数据”的模块,都要优先考虑并发安全,不能让资源竞争的问题拖到生产环境才暴露。
5.4 报表导出慢和Excel乱码
导出Excel在数据量超过5万行时,同步导出会导致后端请求超时。我的方案是改异步导出:前端发起导出请求后,后端先生成一个导出任务,返回任务ID给前端,前端轮询任务状态,任务完成后返回下载地址。Excel生成采用EasyExcel流式写入,避免一次性把所有数据加载到内存引发OOM。另一个坑是Excel里的中文乱码问题,通常是因为没有设置文件头的编码为UTF-8,以及缺失BOM标记。EasyExcel本身不会产生乱码,但如果是自己拼接CSV导出,就一定记得加BOM头。
5.5 浏览器兼容性与权限问题
WebRTC通话对浏览器有硬性要求。实测下来Chrome和Edge的兼容性最好,Firefox在特定情况下麦克风回采会出现回声,Safari对H264编解码支持与FreeSWITCH对接有兼容性问题。我建议团队统一用Chrome浏览器,并禁止在系统内打开自动翻译功能,因为Google自动翻译会重写DOM节点,导致前端组件事件绑定失效,按钮点击失灵。这个问题很隐蔽,排查了很久才发现是翻译插件导致的,后来直接在前端检测并提示关闭翻译插件。
6. 项目复盘与实用经验
6.1 项目推进中容易低估的部分
这类系统开发起来,编码只占四成工作量,剩下的全是沟通、测试和数据准备。我这次最大的教训是低估了坐席人员对系统操作习惯的依赖。开发时我按标准的CRM交互设计,弹屏页默认展示客户详情、工单列表、通话记录,信息密度很高。但一线坐席反馈说“接电话时要看的就三样:客户是谁、上次聊了什么、还有一个框记这次通话结果”。最终我把弹屏页改成极简模式,大字号显示客户名和来电号码,中间是最近跟进记录,下方一个大输入框和“保存通话小结”按钮。这个改动看似很小,但对坐席的实际使用体验提升巨大。
还有一个点是测试环节必须用真实电话线路跑全流程。开发时我一直用软电话模拟器测试,通话和弹屏都正常。真实验收时才发现运营商线路的回声抑制参数没调好,客户反馈“能听到自己的说话回声”。后来在FreeSWITCH里调整了echo_cancel相关配置,并启用了NDLB回声消除,问题才解决。这类问题不接真实线路根本发现不了,项目排期时一定要预留1到2周的真实线路联调时间。
6.2 沉淀下来的模板和工具
我把以下几样东西做成了公司内部可复用的模板:一是docker-compose部署模板,新项目只需改环境变量和域名配置;二是FreeSWITCH的dialplan和SIP profile配置模板,包含WebRTC接入、录音、编码协商、回声消除等常用配置;三是数据清洗脚本模板,支持Excel导入客户、号码归一化、重复合并;四是WebSocket弹屏推送的通用封装,含心跳、重连、鉴权逻辑,其他需要实时推送的业务模块可以直接复用。
这些模板的价值在于,下次再做类似的通信型业务系统,不用从零起步,两周内就能把基础框架搭出来,把精力集中在业务字段和流程定制上。
6.3 给后来者的三条建议
第一,通信能力一定要从项目第一天就纳入架构设计,不要后期再接。如果业务里明确有电话沟通场景,那么数据模型、权限设计、前端路由都要为弹屏和通话记录预留接口,后期补会牵扯一堆历史数据问题。
第二,部署环境越早接近生产越好。我在项目中期因为一直用开发环境测试,没有按生产模式配置Nginx、WSS、UDP端口,导致临近上线时临时排错花费了大量时间。环境和配置问题要提前收敛。
第三,多留操作日志和埋点。系统上线后一定会面对“这个数据为什么不对”“这个操作是谁做的”这类问题,完整操作日志能帮你快速定位,而不是去翻数据库binlog。DeskcommCRM的sys_operation_log表现在每天记录上千条操作,已经成为排查问题和做报表审计最重要的数据来源。
这个项目上线三个月,目前每天处理约1500通电话、生成400张左右工单,系统运行稳定。我最大的感受是:做行业系统,技术本身不是瓶颈,真正花时间的永远是对业务场景的理解和对细节的打磨。如果后续要继续扩展,我会把AI自动生成通话小结和工单摘要作为下一个迭代方向,把坐席从重复的记录工作中再解放一部分。