1. 为什么我们要自己做 DeskcommCRM,而不是直接买一套现成的
说真的,最开始听到组里决定要自己搞一套 CRM 系统的时候,我是有点抗拒的。市面上成熟的客户管理系统一抓一大把,Salesforce、HubSpot、纷享销客、销售易,哪个不是打磨了好几年?我们一个小团队,凭什么去重复造轮子。但真把需求捋完,我发现这个结论其实没那么反直觉。DeskcommCRM 这个项目,本质上不是要做一个通用的 CRM,而是要做一套“长在我们办公场景里”的客户管理系统。Deskcomm 这个名字拆开看,Desk 是桌面办公,Comm 是通讯,CRM 自然就是客户关系管理,三个词拼在一起,定位就非常清楚:把桌面办公环境里的通话、即时消息、邮件往来,和客户档案、跟进记录、商机管理全部打通,让销售不用在多个系统之间来回切换。
当时我们面临的实际问题很具体:销售每天要打几十通电话,加一堆微信好友,邮件往来也频繁,但客户信息散落在 Excel、手机通讯录、企业 IM 聊天记录和个人邮箱里。想回答“这个客户上次聊了什么”要翻半天,想统计“这个月跟进了多少有效客户”更是无从下手。买现成的 CRM 能解决一部分问题,但打通办公电话和 IM 这个需求,几乎没有任何一家通用产品能开箱即用。加上按坐席收费的模式,对初创团队来说也是一笔不小的开支。所以 DeskcommCRM 从一开始就不是跟成熟产品比功能多少,而是盯准了一个更核心的问题:怎样让客户信息自动沉淀、让沟通记录可追溯、让销售动作可量化。
这套系统适合谁参考?如果你所在的团队正在做客户管理,又受困于数据散落和重复录入,或者你想自己动手搭一套轻量级的 CRM,把电话、IM、邮件整合到一个界面里,那这篇文章里从架构设计到实操落地的完整过程,应该能给你不少启发。我们做的不是什么高深的东西,但每一处设计都是在真实业务里被逼出来的。
2. 整体架构设计与核心数据模型
2.1 技术选型:为什么是前后端分离加 PostgreSQL
DeskcommCRM 的技术栈选择上,我们没有追求新奇,选的全是社区生态成熟、团队上手成本低的东西。后端用 Python 的 FastAPI,主要看重它三件事:异步性能足够应付几百人的并发请求,Pydantic 做参数校验和文档生成非常省事,还有 WebSocket 原生支持到位。前端选了 Vue 3 加 Element Plus,组件够用、文档中文资料多,团队里前端同学上手快。数据库用了 PostgreSQL,没有用 MySQL,核心原因是我们要做全文检索和数据去重,PostgreSQL 的 pg_trgm 扩展和强大的索引能力比 MySQL 顺手得多。
这里我想多说一句选型的逻辑。很多人喜欢一门心思追新框架,但在这种内部工具类的项目上,稳定和熟悉远比炫技重要。我们组后端主攻 Python,前端普遍写 Vue,与其为了性能去引入 Go 或 React,不如用自己最顺手的工具把业务逻辑做扎实。系统真正的复杂度根本不在框架,而在数据模型和业务规则。
2.2 客户、联系人、跟进记录的关系设计
数据模型是整个系统的地基,方案改起来成本最高。我们第一版设计时犯过一个典型错误:把客户和联系人混在一张表里,一个客户下面挂多个联系人时,字段就变得非常别扭,公司电话、个人电话、职位、部门全挤在一起,查询和展示都一团糟。后来重构成了标准的三层模型:
- customers 表存客户主体信息,包括公司名称、行业、规模、来源渠道、所属销售;
- contacts 表存具体联系人,一个客户下面可以挂多个联系人,每个联系人有自己的电话、邮箱、职位;
- activities 表存所有跟进动作,包括通话、消息、邮件、线下拜访、备注,每条记录必关联到一个客户,可以选关联具体联系人。
- deals 表才是真正的商机管理,一个客户可以挂多个进行中的商机,每个商机有金额、阶段、预计成交时间。
这套模型的优点是:客户是数据的唯一主轴线,所有行为都挂在客户下面,天然形成一个时间线。而且这种设计非常贴近销售的实际心智,销售想了解一个客户,只需要打开客户详情页,所有历史往来按时间倒序排列,一眼就能看明白。
2.3 数据库层面的防重约束:不能只靠前端提示
数据质量问题里最头疼的就是重复。同一个客户被三个人录了三次,同一个联系人的电话号码格式五花八门,这些脏数据会让后续所有统计分析失真。我们的做法是在数据库层面做硬约束,而不是只在前端弹个提示框。
客户表上建了公司名称的规范化唯一索引,不是直接对原始名称做唯一约束,因为“某某科技有限公司”和“某某科技公司”在字面上完全不同,但实际上是同一家。我们增加了一个 name_normalized 字段,存储经过清洗、去掉公司后缀和空格的名字,对这个字段做唯一索引。电话也一样,通讯录里同一个号码可能有 +86 前缀、有横线、有空格,导入时统一转成 E.164 格式,再对客户主电话和联系人的所有电话分别建唯一索引。
约束带来一个问题:导入数据时经常批量报错,但这其实是好事。宁可导入时报错让人去合并,也好过数据静默重复,后面越堆越乱。想让一个 CRM 长期可用,数据干净是第一位的,这点放到多后面说都不过分。
3. 核心模块与功能实现细节
3.1 客户时间线:所有交互历史串成一条线
客户时间线是 DeskcommCRM 使用频率最高的模块,也是我觉得整个系统最有价值的部分。它做的事情很简单:把客户名下的所有通话记录、消息记录、邮件往来、跟进备注、商机状态变更,按时间倒序排列成一个无限滚动的信息流。销售打开一个客户,不用去各个子页面翻,一段对话就能完整还原客户的跟进过程。
时间线的技术实现关键在 activities 表的设计。我们给每条活动记录设了 type 字段(call、message、email、note、deal_stage_change),设了 content 字段存文本内容,设了 metadata 字段存 JSON 格式的补充信息,像通话时长、消息方向、邮件主题这些零散数据都塞进 metadata。查询时按客户 ID 过滤、按 created_at 倒序、分页拉取。前端渲染时根据 type 渲染不同的图标和卡片样式,点击可以展开详情。
这里有个经验想分享:时间线看起来简单,但性能坑不少。一开始我们没给 activities 表的 customer_id 和 created_at 建复合索引,客户数据一多,滑动时间线就明显卡顿。后来加了 (customer_id, created_at DESC) 的复合索引,查询直接从几百毫秒降到十几毫秒。这类内部工具平时数据量不大,但一旦用起来,索引设计就决定体验上限。
3.2 全局搜索:让资料三秒内能找出来
销售经常遇到一个场景:客户打电话过来,报了个公司名,但接电话的人一时想不起是谁。如果搜索框也是“输入完整公司名才能搜到”,那这个搜索就是摆设。我们期望的效果是:输入“华科”能搜出“华科精密制造有限公司”,输入手机号后几位能倒查出联系人,输入一段聊天关键词能定位到具体跟进记录。
PostgreSQL 的 pg_trgm 扩展帮了大忙。它通过 trigram 相似度做模糊匹配,中文场景下效果出奇地好。我们给客户名称和联系人姓名字段建了 GIN 索引,配合 pg_trgm 的 GIN 操作符,用 ILIKE 加 %关键词% 这种查询也能走索引加速。搜索接口的聚合逻辑是:先搜客户,再搜联系人,最后搜活动和商机,把结果按类型分组返回。搜索耗时控制在 200 毫秒以内,这个体验就很能打了。
具体 SQL 上,关键就靠下面这层索引和查询:
CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_customers_name_trgm ON customers USING GIN (name gin_trgm_ops); CREATE INDEX idx_contacts_name_trgm ON contacts USING GIN (name gin_trgm_ops);SELECT id, name, industry, owner_id FROM customers WHERE name ILIKE '%' || :keyword || '%' OR name_normalized ILIKE '%' || :keyword || '%' ORDER BY CASE WHEN name ILIKE :keyword || '%' THEN 0 WHEN name_normalized ILIKE :keyword || '%' THEN 1 ELSE 2 END, updated_at DESC LIMIT 20;ORDER BY 里的 CASE 表达式是搜索体验的细节关键:完全前缀匹配的结果排最前,其次模糊匹配,最后才是相似度匹配,这样能保证最相关的结果永远在最上面。纯靠数据库默认的相似度排序,结果常常会因为一些奇怪的相似字而排错队。
3.3 权限模型:字段、记录、操作三层都要管
CRM 里的数据大部分是敏感的,客户资料、跟进记录、成交金额,哪个泄露出去都是事故。DeskcommCRM 的权限模型一开始就设计了三层:字段级权限控制哪些角色能看到哪些字段,记录级权限控制能看哪些客户的记录,操作级权限控制能做什么动作。
记录级权限是核心。我们用的是 RBAC 加数据范围组合:角色定义用户是普通销售、销售主管还是管理员,数据范围定义了本人数据、本组数据、全公司数据。比如普通销售默认只能看到自己名下和曾经跟进过的客户,销售主管能看到本组所有客户,管理员有全公司数据权限。这个数据范围不是写死在代码里的 if 判断,而是每个查询都拼接一个强制性的权限过滤条件,从底层保证越权查询根本拿不到数据。
字段级权限我们做得更细,比如普通销售看客户时,客户成本价、历史成交折扣这类敏感字段是直接隐藏的,主管以上才能看到。操作级权限则决定了这个人能不能删除客户、能不能转移客户归属、能不能导出数据。三层权限叠加起来,基本覆盖了所有常见的越权场景。权限做得好不好,不看页面藏了几个按钮,要看接口层有没有做同样的校验,我们后面会在常见问题里细讲接口防越权。
3.4 电话与消息自动关联:从通讯工具到客户档案
DeskcommCRM 最有特色的一块,是把桌面通讯工具和客户档案打通。销售坐席在系统里点一下拨号,通话结束后系统自动生成一条通话记录挂到客户时间线上。来电时根据电话号码自动匹配客户和联系人,弹出来电浮窗,显示客户的名称、上次跟进时间、最近一条备注。企业 IM 的消息记录也可以通过机器人回调主动推送到 CRM,按联系人自动匹配归集。
实现电话关联的核心是一个归一化函数,所有号码入库时统一成 E.164 标准格式,比如 +86-138-1234-5678 会变成 +8613812345678。来电匹配时,先把来电号码做同样的归一化,再拿归一化后的号码去 contacts 表和 customers 表的主电话字段里查。这个逻辑避免了“手机号带不带 0、带不带 86、中间有没有横线”导致匹配失败的问题。
IM 的对接稍微复杂一点,因为涉及回调签名验证和事件去重。我们的做法是收到回调后先验签,再用消息 ID 做幂等判断,同一个消息 ID 处理过一次就直接忽略,避免重复写入时间线。这里踩过很深的一个坑:回调推了两次,时间线上出现了两条一模一样的消息。幂等判断是在数据库层用唯一索引实现的,而不是在代码里判断,代码判断在并发场景下照样会穿。
4. 实操过程与核心环节落地
4.1 环境准备与初始化
项目开发环境基于 Docker Compose,一条命令就能拉起整套后端和依赖服务。我们统一用 Docker 还有一个考量:团队里 Windows 和 macOS 混用,依赖装起来经常出各种奇怪问题,容器化之后至少环境是一致的。
# 项目根目录下执行 docker-compose up -dDocker Compose 文件里定义了三个服务:postgres(带 pg_trgm 扩展的数据库)、redis(缓存)、api(FastAPI 应用)。首次启动后需要执行数据库迁移和初始化脚本。我们用 Alembic 做迁移管理,表结构的每次变更都记录在版本文件里,开发环境和生产环境执行同一套迁移,最大程度避免了“我这里能跑、你那里跑不起来”的问题。
数据库初始化完成后,会写入默认的角色数据和超级管理员账号。这一步也必须做成脚本而不是手工 SQL,原因很简单:整个团队的环境要保持一致,手工执行 SQL 很容易漏步骤,而且后续补数据非常麻烦。
4.2 核心表结构与建表要点
下面是我们实际使用的几张开表的核心结构,为了不混淆,我简化了字段,但保留了最关键的部分。你可以直接拿去做骨架,按自己业务加字段。
-- 客户主表 CREATE TABLE customers ( id BIGSERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, name_normalized VARCHAR(255) NOT NULL, industry VARCHAR(100), source VARCHAR(50) DEFAULT 'manual', owner_id BIGINT NOT NULL REFERENCES users(id), phone_e164 VARCHAR(50), email VARCHAR(255), status VARCHAR(20) DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), version INT NOT NULL DEFAULT 1, CONSTRAINT uq_customers_name_normalized UNIQUE (name_normalized) ); -- 联系人表 CREATE TABLE contacts ( id BIGSERIAL PRIMARY KEY, customer_id BIGINT NOT NULL REFERENCES customers(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, title VARCHAR(100), phone_e164 VARCHAR(50) NOT NULL, email VARCHAR(255), is_primary BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_contacts_phone UNIQUE (phone_e164) ); -- 跟进活动表 CREATE TABLE activities ( id BIGSERIAL PRIMARY KEY, customer_id BIGINT NOT NULL REFERENCES customers(id) ON DELETE CASCADE, contact_id BIGINT REFERENCES contacts(id) ON DELETE SET NULL, type VARCHAR(20) NOT NULL, content TEXT, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), FOREIGN KEY (customer_id) REFERENCES customers(id) ); CREATE INDEX idx_activities_customer_time ON activities (customer_id, created_at DESC); -- 商机表 CREATE TABLE deals ( id BIGSERIAL PRIMARY KEY, customer_id BIGINT NOT NULL REFERENCES customers(id) ON DELETE CASCADE, title VARCHAR(255) NOT NULL, amount DECIMAL(12, 2) NOT NULL DEFAULT 0, stage VARCHAR(50) NOT NULL DEFAULT 'lead', expected_close_date DATE, owner_id BIGINT NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );几个建表细节想单独拎出来说。第一,所有时间字段统一用 TIMESTAMPTZ,存的是 UTC 时间,展示层再根据用户时区转成本地时间,这样不管团队在哪个城市,数据都是同一套时间基准,不会出现跨时区协作时看到的时间不一致。第二,customers 表加了 version 字段,这是做乐观锁用的,后面讲并发更新时会细说。第三,contacts 表的 phone_e164 建了唯一索引,这保证了同一个手机号不能被录到两个联系人下面,避免同一个客户被不同销售抢着维护时产生混乱。第四,外键的 ON DELETE 规则必须按业务语义定清楚,客户删除时联系人跟着删,但活动记录不能删,因为可能跨客户引用历史记录,白纸黑字的数据不能丢。
4.3 后端接口示例:创建客户和添加跟进记录
接口是系统的门面,参数校验和错误返回必须做得严谨。我们用 FastAPI 的 Pydantic 模型定义请求和响应格式,自动生成 OpenAPI 文档,前端直接照着文档对接,省了很多沟通成本。下面是创建客户和添加跟进记录两个核心接口的精简代码。
from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session router = APIRouter(prefix="/api") class CustomerCreate(BaseModel): name: str industry: str | None = None phone: str | None = None email: str | None = None source: str = "manual" @router.post("/customers") def create_customer(data: CustomerCreate, db: Session = Depends(get_db)): normalized = normalize_company_name(data.name) existing = db.query(Customer).filter_by(name_normalized=normalized).first() if existing: raise HTTPException(status_code=409, detail="客户名称已存在,疑似重复") phone_e164 = None if data.phone: phone_e164 = normalize_phone(data.phone) customer = Customer( name=data.name, name_normalized=normalized, industry=data.industry, phone_e164=phone_e164, email=data.email, source=data.source, owner_id=current_user.id, version=1, ) db.add(customer) db.commit() db.refresh(customer) return customer.to_dict()class ActivityCreate(BaseModel): customer_id: int contact_id: int | None = None type: str content: str | None = None metadata: dict = {} @router.post("/activities") def create_activity(data: ActivityCreate, db: Session = Depends(get_db)): customer = db.get(Customer, data.customer_id) if not customer: raise HTTPException(status_code=404, detail="客户不存在") # 权限校验:只能给有权限的客户添加活动 if not can_access_customer(current_user, customer): raise HTTPException(status_code=403, detail="无权操作该客户") activity = Activity( customer_id=data.customer_id, contact_id=data.contact_id, type=data.type, content=data.content, metadata=data.metadata, ) db.add(activity) db.commit() db.refresh(activity) return activity.to_dict()创建客户时对名称做了规范化后再查重,这个逻辑是防重的第一道门槛,但是要注意它依赖 normalize_company_name 函数写得够不够健壮。我们的版本会去掉首尾空格、全角转半角、去掉“有限公司”“股份有限公司”“有限责任公司”等常见后缀,还会处理括号和横线。中文公司名的规范化没有银弹,得结合自己客户的命名习惯不断调。电话号码的归一化也是同类问题,中国手机号有 11 位、可能有 86 前缀、可能有 0 开头座机,需要单独写一批规则慢慢磨。
权限校验有个容易被忽略的点:必须在创建活动的接口里也做一次,而不只是前端隐藏按钮。因为恶意用户完全可以直接调 API 绕过前端界面,这一点在常见问题部分我还会展开讲。
4.4 前端核心交互:快速录入与全局搜索框
前端的体验目标定得很直接:让销售在 10 秒内完成一次客户录入,在 3 秒内找到任何一条历史信息。快速录入组件做了一个模态框,聚焦客户名称后直接 Enter 就创建,不用点保存。如果输的名字匹配到已有客户,会先弹出来提醒“你是不是要找这个客户”,防止重复创建。
全局搜索框是前端所有操作的入口,放在顶部导航的固定位置,任何页面下都能随时唤起,键盘快捷键是 Ctrl+K。搜索请求做了 300ms 防抖,避免每敲一个字符都打一次接口。下拉结果里按客户、联系人、活动、商机分组展示,每组最多显示 5 条,点击后跳到对应详情页。这个交互看起来简单,但实际开发中防抖的延迟值、结果分组策略、快捷键冲突处理,每一个点都需要调优。
客户详情页的时间线渲染是最重的前端部分。每个时间线条目根据类型渲染成卡片,卡片上有操作人、时间、内容摘要和展开按钮。列表用虚拟滚动,一开始我们是直接渲染全部 DOM,客户活动一多,浏览器直接卡成幻灯片。换成虚拟滚动后,不管活动有几万条,页面都稳定在 60 帧。这个技术细节在你自己的项目里很可能用得上:永远不要一次性渲染大量列表,尤其是无限滚动的历史记录。
5. 踩坑实录与问题排查速查
5.1 CSV 导入乱码和时区错位
第一次从 Excel 导客户数据进来的时候,满屏乱码。原因很简单:Windows 上 Excel 导出的 CSV 是 GBK 编码,而我们数据库默认按 UTF-8 导入。处理方式有两层:上传时自动检测编码,检测到非 UTF-8 就用 iconv 转码;另外给客户提供 CSV 模板下载,模板里统一带 UTF-8 BOM,这样用 Excel 打开也不会乱。转码逻辑放在导入服务的最前面,所有导入文件先过一遍编码检测再进解析器。
时区错位是另一个隐蔽的坑。Excel 里的日期是“2024-06-15 14:30:00”,看起来没有时区信息,但解析进 TIMESTAMPTZ 字段时,Python 默认按服务器时区解释,而服务器跑的又是 UTC,结果前端展示时所有时间都差了 8 个小时。解决办法是导入表单里加一个时区选择,用户选了北京时间,解析时就强制指定 Asia/Shanghai 再转 UTC 存入。这个坑的价值在于提醒你:任何涉及用户输入时间的系统,都要在入口处明确时区语义,不要依赖运行环境的默认时区。
5.2 并发更新导致数据丢失:乐观锁是关键
两个销售同时打开同一个客户详情页,一个改了行业分类,一个改了客户状态,后提交的会把先提交的覆盖掉。这种问题在早期版本真实发生过,而且特别难排查,因为不是每次都会复现,完全看操作顺序。解决方案是乐观锁:更新 customers 表时,SQL 里带上 version 条件。
UPDATE customers SET industry = :industry, version = version + 1 WHERE id = :id AND version = :version RETURNING version;如果更新的影响行数是 0,说明 version 不匹配,即这条数据在读取后被别人改过了,此时后端返回 409 冲突,提示前端“数据已被其他人修改,请刷新后重试”。这个方案不用加行锁,性能开销极小,又能保证并发场景下的最终一致性。任何需要多人协作编辑的数据实体都该考虑加 version 字段,它花不了多少成本,但能避免大量脏数据覆盖。
5.3 搜索变慢:索引失效的排查过程
系统上线跑了两三个月,客户数据到几万条后,搜索突然开始变慢,有时候要等两三秒。查了执行计划,发现有些模糊搜索根本没走 GIN 索引,而是走了全表扫描。原因出在 pg_trgm 对查询词长度有要求,默认至少 3 个字符才有效的 trigram。我们搜索关键词“华为”只有两个字,生成的 trigram 太少,优化器直接决定不走索引。
解决办法是调整 pg_trgm 的 pg_trgm_word_similarity_threshold 参数,同时给搜索接口做了个特殊处理:关键词少于 3 个字符时,改用按前缀匹配普通 B-tree 索引加 LIMIT 的查询策略,避免全表扫。这个优化做完之后,即使是单字关键词,响应也能稳定在 100 毫秒以内。这个坑给我们的教训是:索引不是建了就完事,要结合真实查询场景不断验证执行计划,尤其要注意中文字符的特殊性。
5.4 权限绕过:接口层的防越权清单
权限问题我们吃过一次大亏。有一版前端改造后,有些查询接口临时去掉了鉴权代码,结果任何一个登录用户都能通过直接构造 API 请求看到全公司所有客户的资料。虽然没造成实际损失,但这件事让我们把接口安全彻底梳理了一遍,并形成了一份必须逐条检查的清单:
| 检查项 | 说明 | 状态 |
|---|---|---|
| 列表接口是否按数据范围过滤 | 查询客户列表时,必须在 SQL 层拼接 owner 或部门条件,而不是查询后在内存里过滤 | 必须 |
| 详情接口是否越权可查 | 通过客户 ID 直接查详情时,必须先校验当前用户是否有该客户访问权限 | 必须 |
| 写操作是否越权可改 | 修改客户、添加跟进记录时,同样要校验操作权限,防止越权修改他人数据 | 必须 |
| 批量导出是否越权 | 导出接口要对导出数据条数和范围做权限校验,不能全量拉取 | 必须 |
| 校验逻辑是否落在后端 | 前端隐藏按钮、禁用输入框只是体验,不做安全边界 | 必须 |
这条清单纯粹是血泪教训换来的,现在每次发版前我们都会拿这条清单过一遍所有接口,谁负责的模块谁签字确认。安全这个东西,做一次是不够的,得变成流程的一部分才能守住。
5.5 其他常见问题速查
| 现象 | 原因 | 解法 |
|---|---|---|
| 导入数据报唯一约束冲突 | 客户已存在或电话号码重复 | 导入日志里标出冲突行,提供“合并到已有客户”或“跳过”选项 |
| 客户详情页时间线打开很慢 | activities 表缺少合适的复合索引 | 确保有 (customer_id, created_at DESC) 复合索引,并检查执行计划 |
| 搜索无结果但数据库里有 | 搜索关键词太短或 pg_trgm 阈值影响 | 短词走前缀匹配,长词走 trigram 匹配,两条路径都要测 |
| 回调消息重复写入 | 消息 ID 未做幂等约束 | 在消息表对 source_id 建唯一索引,重复入库直接报错并忽略 |
| 删除客户后活动记录丢失 | 外键 ON DELETE CASCADE 设置不当 | 客户删除改为软删除,活动记录永久保留,数据不能物理删 |
6. 我们踩过的“非技术坑”,以及 DeskcommCRM 的下一步
项目做到后面,最深的感受是:技术问题都有解,真正的复杂度在业务规则和数据治理上。DeskcommCRM 能在这个团队里活下来,不是因为我们代码写得多漂亮,而是我们一直在回答一个核心问题:销售打开这个系统时,能不能比用 Excel 更快地找到他想要的信息。如果一个功能不能让销售节省时间,那它再炫酷也应该被砍掉。我们砍掉过仪表盘上十几个图表,因为没人看;保留了最简单的“本周新增客户、本周跟进次数”两个数字,因为销售主管每天早上真的会看。
如果你也准备做类似的项目,我的建议是从最小闭环开始:先做客户档案加跟进时间线,这两个功能打透了,系统的骨架就立住了。商机管理、报表分析、通讯集成这些,都等核心链路跑顺了再加。别一开始就把功能规划得又大又全,那是给自己挖坑。
最后分享一个我们正在做的小改进:把自动摘要和跟进提醒做成更智能的规则引擎。比如客户超过 7 天没有跟进动作,系统自动提醒销售,同时把最近的往来记录推送到时间线顶部。这些功能都不复杂,但每一个都是销售真正需要的。DeskcommCRM 到现在也不是一个多完美的系统,但它是真正长在我们业务里、每天有人在使用和提需求的东西,这一点,比任何功能列表都重要。