自研CRM系统实战:从Excel崩溃到客户跟进高效管理

自研CRM系统实战:从Excel崩溃到客户跟进高效管理 1. 让Excel崩掉之后我决定把客户跟进系统化DeskcommCRM 这个名字源于我团队在去年年初经历过的一次客户资料“全军覆没”。当时销售部正用一张共享 Excel 管理所有意向客户某天一位同事误操作覆盖了整张表历史跟进记录、联系人备注、合同金额全部乱套。我们花了整整两天找回旧版本但那次之后我意识到不能再靠表格硬扛了——于是 DeskcommCRM 作为内部自研项目的代号从“Desk Communication”演变而来本质是想让每个销售伙伴的桌面工作台上客户沟通与跟进记录都能自动沉淀、随取随用。这个项目解决的核心问题非常具体销售团队手上的客户信息分散在微信聊天、个人备注、Excel 和邮件里每次跟进都要东翻西找管理者问起某个项目的当前状态没人能给出准确答案新同事接手老客户时光历史背景就要问三遍。DeskcommCRM 就是要把这些零散信息收敛到一个统一入口让“客户档案—跟进动作—下次计划—结果反馈”形成闭环。它不是要做一个对标大厂的全功能销售管理系统而是用最小可用的方案先把“记得到、找得到、有人管”这三件事做好。如果你正在一个人维护小团队、或者准备做一款企业内部工具这篇文章里记录的从需求收敛、技术选型到踩坑修复的过程应该能帮你省下不少弯路。我下面讲的每个环节都是真实做过、真实遇到问题又解决的不是纸上谈兵。2. 需求收敛DeskcommCRM 只做五件事而且严格不做六件事动手写第一行代码之前我们内部开过整整三天的需求会最后的结论是市面上 CRM 做不了的很多人喊功能少但我们缺的是“敢做减法”。DeskcommCRM 最终只保留五个核心能力客户档案库支持客户公司信息、联系人列表、来源渠道、owner 归属、备注标签的维护。跟进记录时间线每次电话、微信、邮件、拜访都能按时间轴追加记录支持文字和附件。待办与跟进提醒为每个客户设置下一次跟进时间到点后系统提醒 owner。管理看板按 owner、阶段、时间维度统计客户总数、已跟进次数、待办逾期情况。轻量权限体系分管理员、主管、销售三种角色销售只能看到自己名下的客户主管可看本团队。对应的“严格不做”的事情我们也写进了 README不做自定义审批流、不做复杂销售报表、不做工作流自动化、不做数据导入魔改、不做移动端原生 App、不做多租户。你可能觉得这些功能将来都要有但我当时的判断非常明确——这些东西一旦卷进来上线时间至少推一个季度而团队已经等不及了。事实证明这个决定救了这个项目。做一个能用的系统比做一个全功能却永远在开发中的系统重要一百倍。DeskcommCRM 至今仍然没有引入所谓强大的规则引擎和可视化报表但团队每天都在用它这就够了。3. 技术选型与核心模型Web端工作台背后的设计与取舍DeskcommCRM 在技术栈上没有追求新潮选型原则只有两条后端开发效率高、前端生态成熟以及部署时别给运维添麻烦。最终采用了 FastAPI Vue 3 PostgreSQL Redis 的组合。选择 FastAPI 是因为它写 CRUD 接口节奏爽快自带 OpenAPI 文档前后端联调时不用单独维护接口文档。Vue 3 配合 Element Plus 组件库搭建工作台熟悉这套的开发者到处都能找到。PostgreSQL 存储正式数据Redis 主要用来缓存当前登录用户的权限和消息提醒去重。这不是最炫的方案但每个部分都经过了验证。3.1 核心数据库模型长这样才够用客户档案是 CRM 的绝对核心设计表时我把客户主表、联系人表和跟进记录分开避免一次查询把整张大宽表拽出来。核心表的字段如下表名用途关键字段crm_account客户档案id, name, industry, source, owner_id, status, stage, next_follow_at, created_at, updated_atcrm_contact联系人id, account_id, name, role, phone, email, notecrm_follow_record跟进记录id, account_id, owner_id, contact_id, follow_type, content, extra_info, created_atcrm_todo待办任务id, account_id, todo_type, due_time, status, owner_id, follow_record_idsys_user用户id, username, password_hash, real_name, role, team_id一个容易忽略的点我把next_follow_at直接放在客户表上而不是单独放在待办表里。这样管理看板查询“今天要跟进的客户”时可以直接走一个索引字段不用反复 JOIN 待办表中的最大跟进时间。当然代价是更新待办状态时还要同步更新客户表我们用事务保证一致性。3.2 REST接口没必要把每个查询都抽象出来我见过很多团队把一个 CRUD 系统抽象成了八个模块、十层目录结果最简单的列表查询都变得很难改。DeskcommCRM 走了另一条路客户相关的接口就三个——列出客户支持筛选分页、创建客户、查看客户详情含时间线。代码里用很朴素的方式实现router.get(/accounts) async def list_accounts( page: int 1, page_size: int 20, stage: str , owner_id: int 0, keyword: str ): query select(Account) if owner_id: query query.where(Account.owner_id owner_id) if stage: query query.where(Account.stage stage) if keyword: query query.where(Account.name.like(f%{keyword}%)) total await db.scalar(select(func.count()).select_from(query.subquery())) result await db.scalars(query.offset((page-1)*page_size).limit(page_size)) return {total: total, items: result.all()}这不是最漂亮的代码但它短小、直接、容易改。后面我们专门解决过这个 like 查询的性能问题倒不是因为这段代码写错而是因为数据量上来后数据库姿势必须跟着变。3.3 权限判断放在哪里才算稳团队规模再小权限问题也得认真。DeskcommCRM 的权限策略是前端控制入口后端控制数据。前端根据角色决定显示哪些菜单后端在查询客户列表时强制拼接 owner 条件否则销售同学能直接通过 URL 猜测接口地址看到别人的客户。我用的方式是在 FastAPI 依赖中解析 JWT拿到当前用户 ID 和角色然后在服务层传递这个用户上下文所有查询统一经过一个函数叠加数据范围条件def add_scope_filter(query, user): if user.role in [admin, manager]: return query return query.where(Account.owner_id user.id)管理员可以看全部销售只能看自己名下主管可以看本团队。这个逻辑从第一天就写死在服务层后面没出过一次越权事故。安全这种东西宁可启动时多十分钟也不能等出事了再补。4. 客户跟进时间线和到点提醒两个容易被做砸的核心功能DeskcommCRM 上线后用户感知最强、也是使用最频繁的功能就是客户跟进时间线和到点提醒。如果这两个功能做不好整个系统基本就是废的。4.1 时间线不是单纯把记录倒序排最初实现跟进记录时间线我以为按created_at倒序查出来就好。但实际用下来用户反馈“看到的信息是断的”。原因是我们既记录了文字跟进内容又记录了客户阶段变化还穿插着附件上传如果只按创建时间排序客户从“初步接触”到“方案演示”再到“商务谈判”的历史轨迹根本看不出来。后来我们改成在时间线里支持“事件type”标记每条跟进记录除了内容外还可以关联一个阶段变更事件。前端渲染时把阶段变更显示成醒目的里程碑标志把普通跟进显示成简短列表。数据查询变成了SELECT * FROM crm_follow_record WHERE account_id $1 ORDER BY created_at DESC, id DESCid DESC这个细节很关键因为同一秒内并发追加记录时光按时间排序会出现顺序抖动。加上 id 作为二级排序能保证同一时刻的记录按插入顺序展示。这是我在线上拿到真实并发请求后才学到的。每次客户沟通后销售必须做一次记录归档然后在登记当天就设置下一次跟进时间。我们逼迫自己养成了“没有下次跟进时间的客户不视为有效客户”的操作习惯。这样一来时间线和待办提醒就串成了一条线。4.2 到点提醒采用扫描式方案而不是 Redis 过期事件提醒机制我们考虑过两个方案一是用 Redis 的 Key 过期事件触发提醒二是用定时任务扫描crm_todo表。最终选了扫描式。原因很简单——Redis 过期事件并不保证实时准确而且如果服务重启大量过期 key 会被直接漏掉风险太高。我们的实现是基于 APScheduler 每五分钟扫描一次未完成的待办任务凡满足以下条件的记录就触发通知due_time距今小于当前扫描周期比如五分钟内到期status仍然为待办或跟进中同一用户同一客户同一待办类型还没有发送过通知通知方式分两层站内信存到数据库避免丢企业微信 Webhook 推到群里提醒效果最明显。为了避免扫描重复触发我们在crm_todo表上增加了一个last_notified_at字段每处理完一条就更新查询语句里带上last_notified_at IS NULL OR last_notified_at due_time - interval 5 minutes彻底杜绝了同一个提醒被连续轰炸。def scan_due_todos(): now datetime.now(zone_utc) window now timedelta(minutes5) todos session.query(Todo).filter( Todo.status.in_([open, in_progress]), Todo.due_time window, Todo.due_time now, or_(Todo.last_notified_at None, Todo.last_notified_at Todo.due_time - timedelta(minutes5)) ).all() for todo in todos: send_notification(todo) todo.last_notified_at datetime.now(zone_utc) session.commit()这个极简逻辑上线后稳定运行了很久没有出现一次重复提醒。最重要的经验是定时任务必须自己具备“幂等”意识不能在业务代码里假设调度器只在某一时刻执行一次。5. 上线三周的真实数据与用户吐槽复盘DeskcommCRM 从开始开发到勉强能用到生产环境大约花了四周。真正上线后我们统计了使用前三周的数据对比去年同期用 Excel 做同样事情的结果变化是非常明显的。指标Excel 阶段DeskcommCRM 上线三周后客户档案完整率约 35%大量信息只有个人备注92%平均每日新增跟进记录4-6 条21 条超出应提醒时间一天未跟进的情况高频发生下降了 70%新同事接手客户需要的背景了解时间平均 30 分钟以上平均 10 分钟左右每周管理层统计进展耗时约 3 小时少于 20 分钟数据看起来很漂亮但实际使用中的吐槽同样密集。第一个槽点是“页面太丑”我不得不承认纯靠 Element Plus 默认样式做出来的界面信息密度低得吓人每个卡片都在抢视觉重心。第二个槽点是“录入成本太高”用户反馈每次跟进打那么多个字段确实麻烦。第三个槽点是“搜索太慢”这个问题其实在后面的踩坑环节才暴露但也成了当时的吐槽重点。针对“录入成本高”我们做了两个方向的改良一是把所有必填字段压到最少跟进记录只保留类型、内容、下次跟进时间其他全部以“非必填”的展开项存在二是加了一个“快捷跟进”按钮用户可以不进入详情页直接在列表页弹一个浮层填两句话就能保存。这个改动让日常记录量直接翻了一倍。页面丑的问题我们没有在大版本期内大力改只统一了表格密度和颜色层级把主要操作按钮固定到右侧统一位置让用户肌肉记忆先建立起来。视觉优化永远可以后置但交互路径变短必须优先做。6. 三个印象最深的坑并发、时区和重复任务别以为一个企业内部小系统就不会遇到并发问题数据量到几千条后该来的坑一个都不会少。我挑三个几乎每个做 CRM 的团队都可能踩到的坑把排查链路和修复方案完整写出来。6.1 时间线顺序错乱居然和数据库时钟没关系现象是某个客户在下午 3 点 45 分同时被两名销售追加记录结果时间线上展示顺序和实际发生顺序不一致。一开始我怀疑是时钟同步问题但检查服务器时间后发现每台机器的时间都对得上。再排查发现是应用层通过 ORM 对象写入时created_at由数据库默认值生成同一毫秒级别会有相同时间而排序只用了created_at顺序就乱了。定位到这个根因后修复方案分两步。第一步在排序条件中加入id DESC让同一时刻的数据按照自增主键倒序排列保证后插入的显示在上方。第二步为了避免极端情况下两个请求同时插入同一条客户记录造成重复我们在crm_follow_record表上加了唯一约束字段是account_id owner_id created_at。这个唯一键虽然偶尔会误伤“同一秒销售发两条不同内容”的情况但实际使用概率极低我们进一步在应用层把创建时间精度提升到微秒级并保留毫秒字段问题就彻底解决了。6.2 “今天待办”总是差八小时时区问题不能靠服务器设置硬扛上线第二天好几个销售反馈“今天要跟进的客户”数字不准下午看的时候还有一批早上就该出现的客户没有进列表。查了一圈发现是前端向后台传日期参数时用的是浏览器本地时区但后端存的是 UTC 时间前端查询时没有对查询边界做时区转换导致查询条件把 UTC 当前日期当成了本地日期。修复并不复杂统一规范所有 API 请求和响应的时间一律使用 ISO 8601 字符串带时区偏移数据库存储统一用 UTC前端展示时根据本地时区格式化涉及“某一天”范围查询时由后端先根据客户端传回的时区信息把当天零点转换为 UTC 时间再计算边界。这个坑最坑的地方在于代码里没有任何报错只会误以为系统漏了数据是那种很隐蔽的“数据缺失型 Bug”。排查时最好的工具是日志里同时记录 UTC 时间和客户端时间一对比就能看出问题。6.3 定时提醒连续轰炸问题出在调度器和工作节点是两个进程站内信通知上线后没几天有同事凌晨三点收到了三次“该跟进客户”的提醒。虽然我们的扫描式方案做了幂等还是出现了重复说明问题远比我想象的复杂。后来打开服务端日志才发现部署时我们把 API 服务和 APScheduler 定时任务服务放在同一个命令行启动但用了两个 Worker 进程。每个进程都各自初始化了 Scheduler结果每个 Worker 都在执行扫描任务同一批待办就被处理了两遍。虽然last_notified_at会在第一个进程提交后更新但第二个进程的查询事务在第一个提交之前已经启动所以也就读取到了旧值。修复方式是在给定时任务加一个 Redis 分布式锁只有获取到锁的进程才允许执行扫描。核心逻辑很简单def acquire_lock(lock_key, timeout_seconds10): exists redis.set(lock_key, locked, nxTrue, extimeout_seconds) return exists然后在定时任务函数开始处检查锁结束时释放。这个方案比数据库锁更轻量也更适合我们的场景。加入分布式锁后提醒再没有重复过。7. 回头总结给准备自建 CRM 团队的五条建议DeskcommCRM 已经在我们团队内部稳定运行了大半年。复盘整个从零到一的过程如果要给同样想自建 CRM 的团队一些实在经验我会说下面这五条。第一先把数据模型定清楚再决定功能列表。很多团队一上来就画原型、写接口结果做到一半发现客户和跟进记录之间的关联方式变了返工成本极大。DeskcommCRM 的运气在于我们花最多的力气设计了五个核心表以及它们的关系后续几乎没有做过破坏性变更。第二权限模型尽量从第一天加不要等数据多了再补。哪怕一开始只有“所有人对一个管理员”的简单模型也比完全没有好。等销售真的用了三周他们在系统里积累了大量自己的客户备注那时候再上权限业务反弹会非常激烈。第三跟进提醒的触发器一定要留日志。我们后期排查各种问题时最能依赖的就是提醒发送记录。系统里每次发送通知都会在crm_notify_log表中留一条记录。即使在最混乱的上线初期也靠这些日志快速定位重复提醒、漏提醒和时区错误。第四尽量不要一开始就把界面做得很花哨。用户对内部工具的容忍度比想象中高但对“找不到按钮”的容忍度非常低。先保证高频操作三步内能完成再去考虑视觉美感。第五小团队的 CRM 不需要大而全但必须快而稳。所谓“稳”不是指代码没有 Bug而是客户数据不能丢、提醒不能乱、权限不能漏。只要这三点守住了哪怕 UI 再难看、功能再保守用户依然会坚持用下去。DeskcommCRM 对我来说不只是一个小项目。它让我意识到很多团队业务停滞的根源不在能力而在大量信息被锁死在个人聊天记录和 Excel 文件中。用一个轻量工具把这些信息重新组织起来团队协作的效率提升是立竿见影的。如果你也想尝试自建别急着堆功能先把“客户—跟进—提醒”这条主线跑通你会有意想不到的收获。