做销售管理的朋友应该都有过这种体验:客户电话打了十几通,信息却散落在微信、Excel、手机通讯录和便签纸里,哪条跟进到了哪一步全靠记忆力。我在做 DeskcommCRM 之前,就天天被这种状态折磨。这个项目本质上是一个面向桌面办公场景、自带桌面通信能力的轻量级客户关系管理系统,解决的是小团队在客户资料沉淀、跟进提醒、通话记录同步这几件事上的效率问题。如果你也在带一个三五人的销售小组,或者自己就是那个既当销售又当客服的人,这篇内容应该能帮上不少忙。

我给它取名叫 DeskcommCRM,拆开看就是 Desk Communication + CRM,意思很直白:把客户管理和日常桌面沟通放到一个系统里,打完电话自动弹窗、点一下就能记跟进、第二天该回访谁系统自己算好。这种思路和大厂的 CRM 逻辑不一样,大厂产品强调流程管控和规模化,而 DeskcommCRM 更在意“少填表、早响应、不漏人”。下面我从设计思路、技术选型、模块实现到踩坑记录,把整个项目从头到尾拆一遍。

1. 项目概述与整体设计思路

1.1 核心需求解析:到底要解决什么问题

先说说我为什么要做这个系统。当时我所在的团队做的是企业服务类销售,每个人手里同时跟进几十个客户,周期长、触点密、沟通渠道杂。客户上午在微信问方案,下午打电话聊合同,第二天又发邮件补充需求,信息分散在不同的工具里。最要命的是,“这个客户上次说考虑一下”到底发生在哪一天,几乎没人记得准。

我调研了一圈市面上的客户管理工具,不是功能太重,就是价格偏高。免费版限制字段数和用户数,付费版一年下来也是一笔不小的开销。更重要的是,这些工具大多是从“管理层看报表”的角度设计的,销售在一线填记录反而成了负担。DeskcommCRM 的思路完全反过来——它从一线人员的操作习惯出发,把“记一笔”这件事压缩到尽量少点击、尽量靠近沟通过程本身。

从需求优先级来看,我把客户资料管理、跟进记录、任务提醒、通话记录同步排在第一梯队;把线索分配、数据看板、多用户权限排在第二梯队;把自动化营销、工单系统这类重功能直接砍掉。做个人项目或者小团队内部工具,最忌讳的就是什么都想要,最后什么都做不透。

1.2 为什么叫 Deskcomm:桌面场景与客户系统的融合

Deskcomm 这个名字里的 Desk,一开始指的是“工位上的桌面电脑”,因为销售的大部分工作发生在办公桌前,电话、邮件、聊天窗口都在这块屏幕里。后来在做的过程中我逐渐把它理解成“桌面即工作台”——客户系统不应该是一个单独打开的后台页面,而应该和日常沟通工具融合在一起,让销售不用切换窗口就能完成信息记录。

所以 DeskcommCRM 在前端交互上做了两个关键设计。第一,客户详情页支持从任意页面侧滑弹出,不打断当前工作流;第二,对接了 WebRTC 语音能力,把浏览器里的软电话集成到客户卡片上,通话结束自动弹出记录窗口,字段已经预填了客户名号码和通话时长。这些设计看似是锦上添花,实际上极大降低了销售的使用抵触感。

我见过太多团队采购了 CRM 系统,结果销售根本不用,因为填记录占用了他们下班后的额外时间。DeskcommCRM 要打破的就是这个死循环,让系统成为销售工作中的一个自然环节,而不是额外负担。

1.3 功能边界:哪些做,哪些坚决不做

决定功能的取舍,其实比实现功能本身更难。我列出过一版“不做什么”清单,放在项目文档最前面:不做自定义复杂审批流、不做营销邮件群发、不做深度 BI 报表、不做移动端原生 App。原因很简单——这些功能要么需要大量运维精力,要么有成熟的独立工具可以做。

我做的是核心闭环:客户进来 → 建立资料 → 分配负责人 → 跟进沟通 → 记录互动 → 设置下一步任务 → 到期自动提醒。这条链路跑通了,业务就能转起来。至于审批流,团队里目前靠口头和群聊就能解决;BI 报表,PostgreSQL 里直接跑 SQL 查数据也比开发报表页面快得多。小团队的管理往往不需要复杂的系统约束,需要的是信息的集中和透明。

1.4 整体架构与数据流转设计

DeskcommCRM 采用前后端分离架构,前端是 Vue 3 + Element Plus 的单页应用,后端是 Python FastAPI 提供的 RESTful API,数据库使用 PostgreSQL。桌面通化模块通过 WebRTC 对接 SIP 语音网关,让浏览器直接拨打和接听 PSTN 电话。整体数据流可以概括为:前端页面和软电话产生操作 → API 层做权限和参数校验 → 业务逻辑处理 → 数据落库 → 通过 WebSocket 推送状态变化给前端。

关于技术栈的选择,我后面会单独拆一节详细说,但先讲整个流量的核心逻辑。客户资料是所有数据的锚点,跟进记录、任务、通话记录、附件都通过外键挂在客户下。这样可以保证无论从哪个入口进入,最终都能汇聚到一个统一的客户时间线上,查看一个客户就像翻聊天记录一样自然。

部署方面,我一开始是单体部署,一个服务器上同时跑 Nginx、FastAPI、PostgreSQL,后来用户量上来之后才把静态资源和 API 分离开。对于小团队这种规模,单体架构足够稳定,没必要一开始就上 Kubernetes 之类的重型方案。

2. 技术选型:为什么这么搭省心又够用

2.1 前端:Vue 3 与 Element Plus 的组合

前端选 Vue 3 而不是 React,说实话主要考虑的是团队的学习成本和生态成熟度。Vue 的模板语法对后端出身的人来说更友好,单文件组件天然把模板、脚本、样式放在一起,理解起来直观得多。Element Plus 组件库开箱即用,表格、表单、弹窗、日期选择器这些后台管理的高频组件都能直接引用,省去了造轮子的时间。

在项目结构上,我用 Vite 做构建工具,开发体验比 Webpack 快一个数量级,热更新几乎是秒级响应。状态管理用的 Pinia,比 Vuex 更轻,TypeScript 支持也更好。这里有个实操心得:表单校验尽量不要自己写正则硬判断,Element Plus 的 rules 加上 async-validator 基本覆盖了常规校验场景,我用它做了手机号格式、邮箱格式和必填项校验,几行配置就搞定。

另外我强烈建议在一开始就接入 ESlint 和 Prettier。这个项目前中期我一个人写代码,觉得代码规范无所谓,后面加人协作时才发现格式不统一会让 diff 变得极其难看。花了半天时间把全项目格式化了一遍,把规则写进 pre-commit 钩子,之后代码质量明显稳了。

2.2 后端:FastAPI 带来的开发效率提升

后端选 FastAPI 最重要的原因是异步支持好,配合 WebSocket 做实时通知非常顺手。FastAPI 基于 Pydantic 做数据校验,请求和响应模型直接定义成类,自动生成 OpenAPI 文档,前端同事对接接口时可以少问很多问题。

销售系统的权限模型不算复杂,我用了角色加细粒度权限码的方式。用户属于某个角色,角色拥有功能权限码列表,API 层通过依赖注入校验当前用户是否具备所需权限码。FastAPI 的 Depends 机制在实现这个逻辑时非常优雅,写一个 get_current_user 依赖,再写一个 require_permission 工厂函数,出一行函数装饰器就能给接口加上权限控制。

为了减少重复代码,我把列表查询抽了一个通用 BaseService,支持分页、排序、关键字搜索和字段过滤。这个抽象在客户列表、跟进记录列表、任务列表、通话记录列表上复用了很多次,新写一个列表接口只需要定义模型和 Pydantic 响应模型,服务层和路由层几乎是模板化生成。

2.3 数据库:PostgreSQL 与 JSONB 字段的搭配

数据库选 PostgreSQL 而不是 MySQL,一个重要原因是 JSONB 字段。客户资料、扩展属性这类半结构化数据,用 JSONB 存起来比建一堆可空字段灵活得多。比如客户来源,不同行业需要记录的字段不一样,数据库层面不用每次改表结构,前端把整包 JSON 传上来直接存进去就行。

PostgreSQL 的全文检索能力也帮了大忙。客户搜索支持按姓名、手机号、公司名、备注内容多字段检索,我用 tsvector 建了生成列加 GIN 索引,几万条数据实测毫秒级返回。这个性能在 MySQL 里要额外引入 Elasticsearch 才能达到,对一个小项目来说太重了。

还有一个小细节:PostgreSQL 的数组类型在打标签场景特别好用。我给客户表加了一个 tags text[] 字段,打多个标签不用拆子表,查询用 && 操作符做数组重叠判断,语法简洁、性能也不错。这种小功能看着不起眼,但在设计阶段选对数据库,后面开发能省很多事。

2.4 桌面通信:WebRTC 与 SIP 网关的对接方案

桌面通信是整个项目最有特点的一部分。一开始我考虑过直接集成第三方云呼叫中心,但费用和定制灵活性都不太理想。后来决定采用 WebRTC + SIP 网关的软电话方案,在浏览器里实现通话能力,由网关对接运营商线路。

这个方案的逻辑链路是:前端通过 WebRTC 建立音频流,经过 Signaling 服务器与 SIP 网关协商,最后通过网关和公共电话网络互通。浏览器里通话的麦克风权限、音频播放、通话状态通过 API 暴露给前端,DeskcommCRM 只需要把通话事件绑定到客户卡片上就能实现点击拨打、自动弹窗。

这里要提一个踩坑经历:WebRTC 在局域网环境很稳定,一旦跨互联网就必然遇到 NAT 穿透问题。解决方法是部署 STUN 和 TURN 服务,STUN 负责发现公网映射,TURN 负责在 P2P 不通时中转媒体流。TURN 服务器的带宽成本不低,但为了保证通话质量,这笔钱省不了。我使用的是开源方案 coturn,配置过程不复杂,关键是 TURN 的端口和防火墙规则必须放到位,否则会出现特定网络环境可以注册但不通音频的怪问题。

2.5 部署与桌面端:Docker Compose 与 Electron

部署方面,Docker Compose 是我唯一的选择。一个 docker-compose.yml 文件定义 PostgreSQL、后端 API、前端 Nginx、coturn 五个服务,服务器上一条命令就能拉起整套环境。数据目录挂载到宿主机,升级时容器重建不影响数据文件。

为什么要做一个 Electron 桌面端?因为部分销售同事习惯把客户系统当作一个固定窗口挂在副屏上,浏览器标签页太多容易误关。Electron 包一层壳,本质还是加载同一个前端资源,但能提供托盘常驻、全局快捷键和桌面通知能力。这个工作量和收益的性价比很高,我用 electron-builder 直接打包,一天就能出安装包,Windows 和 macOS 都能覆盖。

我把 Electron 壳和 Web 端保持在同一个代码库,通过环境变量判断运行平台,这样两个入口共用同一套业务代码,不会出现功能不一致的分叉。工作成果也比较明显,桌面上常驻一个 CRM 窗口,电话事件弹出来的时候不容易被其他窗口盖住,销售同事对这个体验好评度很高。

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

3.1 客户管理:从统一客户池开始

客户模块是整个系统的地基。我设计的客户表包含基础字段和扩展字段,基础字段如客户名称、联系人、手机号、公司地址、客户来源、所属销售;扩展字段以 JSONB 存入,方便根据不同业务场景存储差异化信息。客户状态用枚举区分:潜在客户、意向客户、成交客户、流失客户、无效客户。

在实操层面有个设计很关键:重复客户的合并。销售录入客户时,手机号和公司名是重复率最高的维度,我在新增客户接口里做了实时查重,发现疑似重复会弹出候选列表让用户主动选择“仍要新建”还是“并入已有客户”。这里不能直接禁止录入,因为确实存在同一个手机号对应不同联系人的情况,但必须让操作人知道潜在的重复风险。

客户池还支持批量导入。Excel 上传我用了 Python 的 openpyxl 库解析,模板里规定客户名称、联系人、手机号、来源是必填列,其他列映射到扩展字段。导入前先校验格式和重复项,生成一份导入报告,提示每一行的导入结果是成功还是失败原因。最初我图省事直接入库,结果错了一大堆格式不规范的数据,后来加了这个校验流程才稳定下来。

3.2 跟进记录:贴进业务动作的记录方式

跟进记录的目标是让销售“随手记”而不是“专门记”。我实现了一个简洁的记录窗口,通过客户详情页的“记一笔”按钮或者通话结束的自动弹窗打开,窗口默认显示当前时间和客户摘要,销售只需要选择跟进方式(电话、微信、上门、邮件等),填写进展摘要,再选一个下一步动作和日期,一条记录就完成了。

这里有一个关键交互:下一步任务自动生成。当销售在跟进记录中选择了“需要跟进”的状态,系统会生成一个关联该客户的任务,到期时间是销售选择的日期,负责人自动带出当前操作人。这个逻辑避免了销售记完跟进还要再跑一趟任务模块去建任务的情况,把两步合成一步,使用感受提升非常明显。

跟进记录列表在客户详情页里按时间倒序展示,类似朋友圈信息流,一目了然。我还做了简单的支持富文本的备注能力,但业务人员大多数时候只需要纯文本和几个快捷短语。快捷短语配置存在系统参数表里,销售可以维护自己的常用话术,写记录时点一下就能插入,大大减少了打字量。

3.3 任务与提醒:到期不漏人的保障机制

任务模块解决的是“该联系谁”的问题。任务表包含主题、客户、负责人、截止时间、优先级、状态。销售可以在待办列表看到今天要完成的任务,也可以按客户维度和日期维度查看。从我的实际使用体验看,销售每天打开系统第一眼看到今天必须跟进的重点客户,这个设计比任何报表都管用。

提醒机制我做了两层。第一层是站内通知,任务到期当天早上 9:00 自动推送到用户的待办页和 WebSocket 通知栏;第二层是飞书/企业微信机器人通知,通过 Webhook 把今天逾期的任务清单推送到工作群。第一层保证在系统内可见,第二层保证不开系统也能收到提醒。现在很多团队的工作重心都在 IM 工具里,系统的消息触达能力不能只停留在站内信。

有个细节要注意:时区问题。服务器默认用了 UTC,国内业务时区是 GMT+8,如果任务提醒直接用服务器的 now() 比较,会出现早上 9 点的提醒实际在下午 5 点才推送的情况。我全部改用带时区的时间戳,统一存储为 UTC,展示和比较时转换为公司所在时区,这个小坑花了我不少调试时间。

3.4 通信中心:软电话与客户卡的联动

通信中心是 DeskcommCRM 里最有辨识度的模块。前置条件是用户已经登录系统且浏览器授权了麦克风权限,页面上会出现一个拨号盘,支持输入号码直接拨打,也支持点客户卡片上的电话图标一键拨打。通话开始时,系统自动创建一条通话记录,状态为“呼叫中”;通话结束时,状态更新为成功或失败,时长记录到字段中。

通话结束弹窗是核心交互。弹窗里已经预填了客户ID、通话方向、通话时长、呼叫号码,销售只需要补一句跟进摘要。这里做了个逻辑合并:通话记录也可以直接当作一条跟进记录,不需要在通话区和跟进区各存一遍。虽然表结构上是两张表,但展示时合并在客户时间线上,数据层面用通联ID关联。

我在这里踩过一个并发问题:WebRTC 的通话状态事件触发顺序在极端情况下会乱,比如“已结束”事件先于“已接通”事件到达,导致通话时长计算出负数。解决办法是在前端做事件状态机,只接受合法的状态流转:呼叫中 → 已接通 → 已结束;异常分支直接丢弃并置为未知状态,同时后端以最终结束请求里带的时间戳为准校准,避免脏数据落库。

3.5 数据看板:给管理者的最小必要报表

数据看板我没有做复杂 BI,只实现了一张销售驾驶舱页面:今日新增客户数、今日通话量、待办任务数、逾期任务数、本月的首次响应时长中位数。每个数字下面一行环比变化,点击可以下钻到明细列表。数据都是实时从数据库聚合出来的,因为数据量不大,不需要额外的数据仓库。

首次响应时长是我自己定义的一个指标,指从客户首次被分配或录入到销售第一次主动发起联系之间的时间间隔,以分钟为单位。这个指标比“通话时长”更能反映跟进效率,销售是不是在客户进来之后及时响应了,从这里一眼就能看出来。看板上放指标的标准是:能被行动影响的,才值得被展示。展示一个销售无法改变的指标,只是在制造焦虑。

看板的数据权限也需要注意:普通销售只能看到自己和名下客户的数据,销售组长能看到本组数据,管理员才能看全量数据。数据权限的控制要在 SQL 查询层就做限制,不能只是前端按钮显隐,否则别人写个接口请求就能绕过。我在所有列表和聚合接口里统一传入了当前用户的可见客户ID范围,通过 SQL 的 IN 条件拼进去,确保数据边界在服务端强制生效。

4. 实操过程与关键环节实现

4.1 数据库表结构设计与核心字段说明

先看最核心的客户表,实际开发中字段要比这个多一些,我把关键的列列出来:

CREATE TABLE customer (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(128) NOT NULL,
    contact_name VARCHAR(64),
    phone VARCHAR(32),
    email VARCHAR(128),
    company VARCHAR(255),
    source VARCHAR(32) DEFAULT 'manual',
    owner_id BIGINT REFERENCES sys_user(id),
    status VARCHAR(32) DEFAULT 'potential',
    tags TEXT[] DEFAULT '{}',
    ext JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX idx_customer_owner ON customer(owner_id);
CREATE INDEX idx_customer_phone ON customer(phone);
CREATE INDEX idx_customer_company ON customer(company);
CREATE INDEX idx_customer_tsv ON customer
    USING gin(to_tsvector('simple', coalesce(name,'') || coalesce(contact_name,'')
         || coalesce(company,'') || coalesce(phone,'')));

这里解释几个设计用意。tags 字段用数组而不是字符串,是为了查“同时拥有 A 和 B 标签的客户”时能用到数组操作符,比字符串 like 查询性能好一个档次。ext JSONB 字段吸收了所有不确定的扩展属性,比如某个客户需要记录“渠道经理是谁”,不需要单独加一列,直接塞进去。

全文检索索引使用的是生成列语法,查询时同样用 to_tsvector 函数构造查询向量,和索引匹配。这个方案在几万条客户数据下性能完全没问题,但超过百万级还是考虑专用搜索引擎更合适,我这里小规模使用足够了。

跟进记录表的关键结构:

CREATE TABLE follow_up (
    id BIGSERIAL PRIMARY KEY,
    customer_id BIGINT NOT NULL REFERENCES customer(id),
    owner_id BIGINT NOT NULL REFERENCES sys_user(id),
    method VARCHAR(16) DEFAULT 'phone',
    summary TEXT,
    next_action VARCHAR(128),
    next_time TIMESTAMPTZ,
    deal_id BIGINT REFERENCES deal(id),
    created_at TIMESTAMPTZ DEFAULT now()
);

我把 next_action 和 next_time 直接放在跟进记录里,而不是单独建一张任务表,是为了保证“一条跟进记录天然携带下一步信息”。如果下一步任务在另一张表里,就需要额外的关联和同步逻辑,容易出数据不一致。实际的任务展示视图通过视图SQL从这张表里抽取状态为待处理的记录生成。

4.2 后端 API 设计与几个关键接口示例

API 设计遵循 RESTful 风格,资源用复数名词,动作由 HTTP 方法区分。下面是客户模块的几个核心端点:

方法与路径 功能说明
GET /api/customers 客户分页列表,支持关键字搜索、标签过滤、状态过滤
POST /api/customers 新增客户,服务端做重复检查
GET /api/customers/{id} 获取客户详情,包含扩展字段、标签、时间线
PUT /api/customers/{id} 更新客户基础信息
DELETE /api/customers/{id} 删除客户,软删除策略
POST /api/customers/{id}/follow-ups 追加一条跟进记录
POST /api/customers/{id}/tasks 为客户创建任务
GET /api/customers/{id}/timeline 聚合时间线,包含跟进、任务、通话记录

一个关键接口是时间线聚合,它要从三张表取数据再按时间排序,我用的是 Financially 简单的方案:先分别查三张表,再在内存里按创建时间归并排序。数据量不大时,这个方案比写复杂的 SQL UNION 更容易维护。另一个关键点是权限过滤逻辑,所有查询都必须在 SQL 层面带上 owner 范围的约束。

@router.get("/customers")
async def list_customers(
    q: str = "",
    tags: str = "",
    status: str = "",
    page: int = 1,
    page_size: int = 20,
    user: User = Depends(get_current_user)
):
    query = select(Customer)
    if user.role != "admin":
        allowed_ids = await permission_service.get_visible_customer_ids(user)
        query = query.where(Customer.id.in_(allowed_ids))
    if q:
        query = query.where(
            Customer.search_vector.op("@@")(func.plainto_tsquery("simple", q))
        )
    total = await db.scalar(select(func.count()).select_from(query.subquery()))
    rows = await db.execute(query.offset((page-1)*page_size).limit(page_size))
    return {"total": total, "items": rows.scalars().all()}

注意这里 search_vector 是生成列,直接在 ORM 里映射查询,不需要额外维护索引字段。如果你用 Django 或者其他 ORM,也建议在数据库迁移层就把这个生成列定义好,避免查询时临时计算索引失效。

4.3 前端关键交互:侧滑客户详情与通话弹窗

前端核心交互是客户列表和客户详情之间的“侧滑抽屉”效果。点击客户列表任意一行,页面右侧滑出客户详情,内容区域有标签页分别展示资料、时间线、关联任务、通话记录。这种交互模式避免了从列表跳转到新页面再跳回来的割裂感,销售连续查看多个客户时效率高很多。

侧滑抽屉用 Element Plus 的 el-drawer 组件实现,宽度设成 640px,高度占满屏幕,内部分成头部、内容区、底部操作栏。详情数据通过一个 watch 监听当前选中客户 ID,变化时拉取新的详情接口,配合 loading 状态避免闪烁。客户时间线我做了分组渲染:按日期分组,组内按时间倒序,展示来源类型图标、文案和时间。

通话弹窗和 WebRTC 的集成是通信模块的关键地方。当软电话状态变成 incoming 或 outgoing 时,前端组件会响应状态变化,自动弹出一个小悬浮窗,显示对方号码和通话计时器。通话结束后,弹窗切换到记录模式,预填数据变成可编辑表单。这里有一个交互细节:通话弹窗不能打断用户正在进行的其他操作,如果用户正在填写其他客户的跟进,弹窗只在屏幕角落出现,不抢焦点,用户可以选择稍后补记。

4.4 通话数据的同步与状态机处理

通话数据从 WebRTC 到业务表,中间有一个状态机处理层。我定义了一个 CallyOpen 对象处理整个通话生命周期:呼叫开始时创建会话,收到建立事件时把状态从 dialing 改为 in_call,记录接通时间;收到关闭事件时再改为 ended,记录结束时间。

这里我学到的最重要一课是:状态变化要和持久化操作解耦。WebRTC 事件处理函数里只更新内存中的会话对象和界面状态,真正写库的动作放到一个异步队列里延迟 500ms 执行,这样可以聚合短时间内可能出现的重复事件,避免并发写库造成脏数据。比如对方秒挂断时,CDR 事件流可能快速经过 connect、disconnect 两个状态,如果每个事件都立刻写库,就可能产生两条通话记录。

后端收到通话结束请求时,会做一次“最终清洗”:以请求中的开始时间、结束时间为准,计算通话时长,更新对应通话记录的状态。如果发现同一通电话重复提交了两次结束请求,第二次会被幂等机制直接丢弃,靠交易记录的会话 ID 唯一约束防止重复。

5. 常见问题与排查技巧实录

5.1 WebRTC 音频异常:一边能听到一边听不到

这个问题的典型场景是:A 和 B 都在同一个局域网里,WebRTC 通话建立成功,但 B 听不到 A 的声音。排查思路先看媒体流的状态,打开浏览器的 about://webrtc-internals 页面,查看 Audio 轨道的 sending 和 receiving 状态。如果 sending 为 false,通常是麦克风权限没有正确授予,也就是浏览器权限设置里把站点麦克风权限禁了。

如果 showing 正常,问题大概率出现在 TURN 服务器配置。当两端的 NAT 类型导致 P2P 打洞失败时,媒体流走的是 TURN 中继,如果 TURN 没有正确配置(比如只开放了 3478 端口但没放通 UDP 中继端口段),就会出现信令通、媒体不通的现象。检查 coturn 日志,看到 relay allocation 失败,说明端口没放通。

解决方法是把 TURN 的 relay 端口段固定下来,比如 49160-49200,在云安全组和本机防火墙里同时放行,同时确认 TURN 的认证用户配置正确。注意 coturn 的配置里除了 listening-port 还要配 relay-port 范围,否则用的是一段默认端口,放行规则容易漏。

5.2 客户列表变慢:索引失效与全表扫描

客户表数据量过了十万以后,如果发现列表接口响应越来越慢,第一个要查的就是执行计划。用 EXPLAIN ANALYZE 查看查询是否走了索引,很多情况是 where 条件里对字段做了隐式类型转换或者函数包裹,导致索引失效。比如 phone 字段如果用了 varchar 类型,查询时传了数字类型参数,PostgreSQL 在不匹配时只能全表扫描。

另一个常见原因是排序字段没索引。客户列表默认按 updated_at 倒序排序,这个字段必须建索引。很多人只给 where 条件里的字段建索引,忘掉排序字段,数据量一大就是 filesort。还有分页深翻页问题,page 到一万页时 offset 很大,PostgreSQL 要跳过海量行。我的方案是使用游标分页或者限制最大翻页深度,列表页只让翻前 100 页,再深就要求用户用搜索条件缩小范围,这个限制在实际使用中几乎没影响体验。

5.3 任务提醒没推送:时区与定时任务的坑

有段时间同事反馈任务提醒的推送时间不准确,比设定时间早了 8 小时。排查后发现服务器环境 TZ 是 UTC,Docker 容器内的时间默认也是 UTC,而定时任务读取当前时间用 now() 在与任务截止时间比较时,数据库返回的是 UTC 时间,前端显示的却是转换后的本地时间,导致“看起来”早了很多。

彻底解决办法:所有容器统一设置 TZ=Asia/Shanghai,数据库连接也指定时区参数。更重要的是代码层面全部使用 aware datetime,不要使用 naive datetime。FastAPI 里 Pydantic 默认对 datatime 字段的处理比较宽松,容易在序列化时丢掉时区信息,我在项目里配置了统一的 datetime 格式转换中间件,入参强制转 UTC 存储,出参自动转本地时间展示。

另一个坑是定时任务的执行频率。我最初用 APScheduler 每分钟扫一次任务表,但可能出现扫描到一半数据更新导致重复推送。后来改成每次推送前更新一个 task_notification_log 表,把推送过的任务ID记录下来,查询时用 NOT EXISTS 排除历史记录,保证同一任务不会重复推两次。

5.4 数据权限越权:只在接口层做控制是不够的

我一开始以为前端把按钮和页面隐藏了就安全了,直到测试时直接调接口发现能拿到不在自己名下的客户列表。这个问题的根源在于权限控制只做了页面层,没有下沉到数据层。后来我写了一个统一的 PermissionService,所有查询客户数据的接口都调这个服务获取当前用户的可视客户 ID 范围,而且这个范围是实时计算的,用户角色变更后立即生效。

这个服务的核心逻辑是:管理员返回全量范围,销售组长返回本组所有销售名下的客户 ID,普通销售只返回自己名下客户 ID。权限范围用一个递归函数根据用户所在组织层级拼装出来,返回一个 set,再传给 SQL 层作为 IN 条件。实测下来这样虽然多了一次权限计算的开销,但对于几十万的数据量,多出的几个毫秒完全可以接受,安全性却好了不止一个档次。

权限这块还有一个隐蔽点:列表接口做了权限控制,但详情接口、批量导出接口、统计接口如果各自写各的,很容易漏掉过滤。我在 Review 代码时,专门列了一份“所有涉及客户数据的接口清单”,逐个检查并补上了权限过滤,同时写了一个测试用例,用低权限用户调用每个接口验证返回结果是否只包含有权限的数据,把越权风险挡在发布前。

5.5 常见问题速查表

问题现象 可能原因 处理方式
浏览器软电话无法录音 麦克风权限未授权 检查站点权限设置,使用 HTTPS 访问
通话一端无声音 TURN 未生效或 NAT 穿透失败 查看 webrtc-internals,检查 coturn 端口和日志
通话时长显示负数 状态事件乱序 前端状态机过滤非法流转,后端校准时长
任务提醒时间偏差 时区配置错误 统一容器时区,代码强制使用 aware datetime
客户列表加载缓慢 缺少索引或全表扫描 用 EXPLAIN ANALYZE 定位,补索引和分页优化
导入 Excel 报错 字段格式不匹配或重复数据 解析前校验模板,导入报告逐行提示原因
客户重复录入 录入时未查重 新增接口做实时查重,弹窗确认后处理
通知没收到 推送队列积压或 Webhook 配置错误 检查任务通知日志和 Webhook 回调日志
WebSocket 断连 Nginx 未配置升级头 代理需要开启 Upgrade 和 Connection 头透传
文件上传超时 Nginx 请求体大小限制 调整 client_max_body_size 参数

6. 数据安全、备份与后续扩展思路

6.1 数据加密与访问控制

客户系统中存了大量手机号和公司信息,数据安全不能马虎。传输层全站启用 HTTPS,这是底线要求,连 Electron 壳里加载的接口也用 HTTPS。数据库口令脱敏用 AES 加密存储,加密密钥放在环境变量文件里,不提交到代码仓库。日志里也要注意把手机号等敏感字段打码,避免排查问题时把隐私数据晒在日志平台上。

密码认证我直接用 FastAPI 的 OAuth2 方案加 JWT 令牌,密码哈希用 bcrypt 算法。JWT 过期时间设为 8 小时,前端在令牌过期前 5 分钟自动调用 refresh 接口续期,用户在连续使用场景下不会被突然踢下线。WebSocket 连接也在 URL 参数里带临时令牌,连接建立后服务端校验令牌有效性,防止未授权连接。

6.2 备份策略:从手工导出到自动快照

数据备份是 CRM 这种业务系统的生命线。我最初是每周手工 pg_dump 一次,后来想想如果数据丢了,一周的跟进记录和客户资料全没了,后果无法接受。所以改成了每天凌晨 2 点自动备份,备份文件保留 30 天,通过 crontab 脚本调用 pg_dump 将整个数据库导出为压缩文件,同时同步一份到对象存储。

备份脚本里有个坑:如果不先锁定数据库再导出,备份过程中发生的数据变更可能产生不一致的备份文件。pg_dump 默认使用 repeatable read 事务,一致性没问题,但大表备份时可能长时间占用资源。我的做法是提供一个专门的低峰期备份副本,在备份前创建一次基于 PITR 的临时副本数据库,从副本导出,避免影响主库性能。

恢复流程也值得提前演练。真到数据丢失时再翻备份脚本怎么用就晚了。我现在每季度在测试环境做一次整库恢复演练,从最新的备份文件恢复到空库,再用几个关键查询验证数据完整性。这个过程发现过一次备份文件损坏的问题,原因是磁盘满了导致写入不完整,后来在备份脚本里加了一步校验:备份完成后立即尝试解压并检查表数量,确认无误后才上报成功。

6.3 后续扩展:AI 辅助跟进与移动端轻量化

DeskcommCRM 目前已经支撑我所在团队日常运转大半年,下一步的扩展方向我在代码和思路上都预留了位置。首先是 AI 辅助跟进摘要:通话的音频转文字接口已经对接了,后续可以用大模型自动生成一段摘要建议,销售只需要确认或微调,省去从脑子里挤文案的功夫。这需要把音频文件转码成合适的格式再上传,我建议在音频模块里预留一个 transcript 字段,前端可以逐步迭代而不影响现有数据结构。

移动端轻量化是另一个被问得最多的需求。我不想开发一套完整的移动 App,更现实的方案是做一个 H5 适配版本,覆盖最核心的三种场景:查看今日待办任务、查看客户关键资料、快速发起通话。移动 H5 通过 PWA 可以做到离线缓存和桌面图标,开发成本远低于原生开发。不过移动端的通话能力依赖 WebRTC,在弱网环境体验一般,目前策略是移动端只查和提醒,需要打电话时引导用户点击号码调起系统拨号盘。

6.4 关于开源与多团队复用的思考

项目做到后面,不少人问过我能不能开源或者直接拿去给别的团队用。我的观点是:技术架构和设计思路可以分享,但直接拿去部署需要考虑定制化成本。每个团队的销售流程、字段定义、通知渠道都不一样,DeskcommCRM 的很多设计是我基于自己的业务场景做的取舍,比如跟进方式枚举、客户状态机、提醒时间点,换一个团队可能需要改动很大。

如果我把它做成一个相对通用的版本,下一步会在模块化上做更多工作:核心客户管理独立成一个基础版本,外部通信能力作为可选插件,通知渠道做成适配器模式支持接入不同的 IM 工具。这样团队可以基于核心版本改造,而不是从头做一个系统。这也是我维护这个项目最重要的收获——在一线用过的东西,才知道哪些设计是真正帮忙的,哪些设计只是理论上的优雅。

我个人的体会是,这类内部工具最重要的不是功能丰富,而是让使用者真正依赖它。DeskcommCRM 做到现在,销售已经形成了“电话打完顺手看一眼弹窗”的习惯,这就说明它在工作流里找到了自己的位置。技术上的选型、性能上的调优都是手段,最终衡量一个 CRM 系统好不好的标准,永远是团队里有多少人愿意主动打开它。如果你也在做类似的项目,建议从最小的核心闭环开始,先把“记一笔”做顺,再谈其他。

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐