
上个月我把团队用了大半年的客户管理Excel表彻底换掉了换成了一套我们自己搭的CRM系统名字叫DeskcommCRM。起因很简单两个销售因为同一个企业客户撞了单主管来问“这个客户到底是谁在跟”我打开两份Excel一脸懵里面甚至还有同一家公司的两个联系方式标在不同的负责人名下。那天下班后我开始认真想一个问题一个不到二十人的销售团队到底需不需要一套正儿八经的CRM。后来答案越来越明确需要而且不能再拖。DeskcommCRM并不是什么大厂级产品而是一套面向中小团队、跑在内网桌面浏览器里的轻量客户管理系统。它目前解决的核心问题只有四件事客户资料沉淀、跟进记录留痕、销售漏斗统计、权限分级隔离。如果你正在犹豫要不要上CRM或者你是想自己搭一套内部工具的技术负责人这篇从需求、设计、开发到踩坑的完整记录应该会对你有点用。1. 从Excel到DeskcommCRM我的真实动机和需求拆解1.1 两个销售撞单的那天发生了什么事情发生得很普通。销售A在周会上说自己跟了一个月的“XX科技”快要成交了销售B当场接话这个客户我上周刚加了微信约了下周二见面。两人对视一眼空气里全是火药味。我调出客户管理Excel发现问题比撞单本身更严重同一个公司名A记录在“客户名单.xlsx”的sheet1B记录在另一个维护了半年的“潜在客户.xlsx”里。A写的是座机号B记的是手机号不放到一起根本看不出是同一家客户。一个客户的跟进情况全在销售个人聊天记录里领导想知道进展只能把人叫过来口头问。上个月有个同事离职他负责的四十多个客户散落在他个人电脑的表格里交接只给了一份简单的文档。这些问题其实所有用过Excel管理客户的人都懂但在场面上它被叫作“团队管理问题”。对我来说它首先是一个数据治理问题客户数据没有统一模型没有归属规则没有变更日志任何带状态的维护都靠人的自觉。所以DeskcommCRM的第一个设计原则很早就定了下来所有客户信息只能有一个入口一条记录一份时间线任何人改过什么都要能追溯。1.2 市面CRM工具的共性与不足决定自建之前我认真对比过当下可选的路线。先看了一轮SaaS型CRM再看开源方案最终才确定自己搭。这里我把三类方案放在一起比过方案类型成本定制灵活度数据掌控适合团队SaaS型CRM按月付费中高按人头算功能模块常需额外加钱低只能做字段增改流程逻辑很难动数据在厂商服务器导出受限制没有技术人力急于上线开源CRM系统如SugarCRM、SuiteCRM等免费但有部署和维护成本中二次开发有学习曲线数据自持有技术人力但能接受复杂系统自研轻量系统人力成本为主服务器成本极低高完全按团队习惯定制完全自持团队有一定开发能力且流程相对简单我并不是否定SaaS型CRM它们的功能完整度和UI打磨确实不是自研能轻易比的。但对一个十几人的销售团队来说很多SaaS CRM的功能实时用不上反而会在录入流程上增加负担。销售每天最烦的是什么是系统里要填的字段比客户信息本身还多。我们要的是一套打开就能用、今天该联系谁一眼能看到的工具不是一套需要培训两周的流程化平台。至于开源CRM我试用过几个功能确实全但也确实重。部署一套需要PHP、数据库、邮件服务、计划任务默认菜单几十个销售人员根本不想点。让业务去适应系统还是让系统适应业务这个选择直接影响之后的使用率。于是“自己写一个足够轻、足够贴合自己团队流程的CRM”成了最合理的路径DeskcommCRM这个项目就这么立项了。1.3 DeskcommCRM不做什么很多自研工具失败不是因为做得太少而是因为一开始想做太多。立项时我给DeskcommCRM划了一条明确边界下面这些功能确定不做不做复杂的进销存和订单履约。不做财务回款对账。不做客户自动化和营销邮件群发。不做在线客服和工单系统。不做销售业绩考核排名。这些功能听起来都很有吸引力但每一个都会把项目周期拉长一倍。CRM本质上是承载销售过程数据的工具不是企业资源计划系统。我们首先要把客户的“来龙去脉”管清楚把跟进过程中的动作记录下来之后如果需要扩展再围绕这些数据去做报表和自动化这样系统的核心才不会失控。最终DeskcommCRM的定位很朴素一个帮助销售记得住、团队查得到、管理层看得见的客户管理工具。2. 技术选型和项目骨架轻量但要有扩展性2.1 技术栈的选择过程DeskcommCRM的技术栈我选的是Go Gin GORM PostgreSQL Vue3 Element Plus。这个组合不是跟风而是根据团队实际情况和部署环境一条条筛出来的。后端没有选Java Spring Boot因为对我们这种内部系统来说它太重了。一个嵌入式设备厂商的内网服务器内存本来就不富裕一个Spring Boot应用起步就要几百兆部署时还要配JVM参数、打war包、管Tomcat维护成本不比业务代码低。Go编译出来就是一个二进制文件丢到服务器上跑起来就行内存占用低并发能力对我们这个场景绰绰有余这是很现实的选型理由。GORM作为ORM用起来效率很高虽然它有一些“神奇”的默认行为需要踩坑后面会讲但整体上能让开发者把时间花在业务逻辑而不是SQL拼接上。数据库用了PostgreSQL而不是MySQL原因有两个一是它自带的jsonb类型方便给客户标签和扩展字段用二是pg的时区处理比mysql明确对后面统计“今日待跟进”这种需求会友好很多。前端选择Vue3配合Element Plus主要是Vue在国内社区活跃Element Plus的表格、表单、日期选择器组件都很成熟做管理后台效率极高。状态管理用了Pinia路由用的Vue Router没有引入重型状态方案因为页面之间的数据共享并不多。项目结构大概是这样的deskcomm-crm/ ├── backend/ │ ├── main.go │ ├── config/ │ ├── models/ │ ├── api/ │ └── middleware/ ├── frontend/ │ ├── src/ │ │ ├── views/ │ │ ├── components/ │ │ └── stores/ └── deploy/ ├── docker-compose.yml └── nginx.conf2.2 核心数据库表是怎么设计的整个系统最核心的表就五张用户表、客户表、跟进记录表、商机表和操作日志表。这里我把当初建表时的关键结构整理出来。用户表简单重点在角色字段和部门字段为后续权限控制打底CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, username VARCHAR(50) UNIQUE NOT NULL, password_hash VARCHAR(100) NOT NULL, display_name VARCHAR(50) NOT NULL, role VARCHAR(20) NOT NULL DEFAULT sales, -- admin, manager, sales department_id BIGINT NOT NULL, is_active BOOLEAN DEFAULT true, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() );客户表是核心中的核心。我特意把客户资料拆成了“必要字段”和“扩展字段”。必要字段只有公司名称、联系人、手机、微信、来源渠道、负责人、状态、下次跟进时间。其他像行业、规模、地址、备注全部放到一个jsonb扩展字段里销售愿意填就填不愿意填也不影响系统使用。这个设计的出发点很直白减少录入阻力。CREATE TABLE customers ( id BIGSERIAL PRIMARY KEY, customer_no VARCHAR(20) UNIQUE NOT NULL, name VARCHAR(200) NOT NULL, contact_name VARCHAR(50), phone VARCHAR(30), wechat VARCHAR(50), source VARCHAR(30), level VARCHAR(20) DEFAULT B, owner_id BIGINT REFERENCES users(id), status VARCHAR(20) DEFAULT active, next_follow_up_at TIMESTAMPTZ, last_follow_up_at TIMESTAMPTZ, tags JSONB DEFAULT [], remark TEXT, is_deleted BOOLEAN DEFAULT false, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() );客户编号和唯一索引也要重点说。客户编号我用了时间戳加随机数的组合类似202505071345001234保证手工录入重复时也不会产生歧义。预防撞单靠的当然不只是编号真正起作用的是统一的客户名归一化逻辑系统录入时会自动去除公司名的后缀比如“XX科技有限公司”会归一化成“XX科技”再去查重降低重复概率。跟进记录表负责保存所有历史动作这是DeskcommCRM最有价值的数据资产CREATE TABLE follow_ups ( id BIGSERIAL PRIMARY KEY, customer_id BIGINT NOT NULL REFERENCES customers(id), user_id BIGINT NOT NULL REFERENCES users(id), type VARCHAR(20) NOT NULL, -- phone, wechat, visit, email content TEXT NOT NULL, next_follow_up_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now() );2.3 前端工作台和桌面场景适配DeskcommCRM一开始就是给坐在工位上的销售用的所以界面优先适配桌面浏览器分辨率最低按1366x768设计。我没有一上来就包Electron而是先把Web端做扎实后面真有需要再加壳。工作台首屏只放四块内容今日待跟进列表按下次跟进时间倒序只显示今天及之前该跟进的客户。我的客户总数和本周新增数。最近一周新增跟进记录数。本月个人成交金额。这个“今日待跟进”列表是整个系统里被销售点开频率最高的页面。原因很简单它帮销售省掉了“翻本子回忆今天该联系谁”的脑力劳动。为了让这个列表准确后端每天凌晨会跑一次定时任务把昨日未跟进且已经超过计划时间的客户往前排同时给负责人推送桌面通知。前端和后端通信用的RESTful接口所有时间字段返回的时间戳统一用UTC前端在展示时转换成本地时间。这一点从第一天就约定好避免在时区问题上反反复复。后面部署时我们还是踩了一次时区坑等会在第五章详细讲。3. 四个核心模块的设计细节3.1 客户资料如何避免“信息一多就不想录”我们在设计客户模块时有一个核心原则任何新增字段都必须回答一个问题这个字段对销售后面的动作有没有直接影响。没有就直接进扩展字段。像客户行业、客户规模、所在地区这类信息跟进的销售人员其实不太关心但对管理层做渠道分析有价值。我把它们全部放到jsonb的tags里录入时默认不展示销售可以跳过。管理员可以在客户列表里按标签筛选聚合出报表来。客户来源是我专门保留的枚举字段它是后续市场渠道效果分析的关键。我们只设置了五个来源转介绍、官网咨询、行业展会、电话陌拜、其他。为什么不多设因为选项越多销售在点选时思考时间越长。五个选项足够覆盖绝大多数来源而且后续统计时每个分组的样本量也够大。客户等级用了A/B/C/D四档。A表示高意向客户两周内可能成交B表示有明确意向但还在比较C表示长期培育D表示基本无价值可以退回公海。档次由销售自己判断但主管可以看到所有客户的等级变动历史。等级变动会写入操作日志这样如果一个客户被反复调整等级管理层能看出销售是在认真判断还是在敷衍填写。3.2 跟进记录时间轴和幂等校验客户资料是静态的跟进记录才是让客户“活”起来的动态数据。跟进记录的核心是时间轴展示。每一条记录按照创建时间排列同一客户页面的时间轴上能直接看到第一次电话是什么时候、中间谁接手过、每次沟通之间的间隔是不是合理。这里有一个很实用的设计细节每次新写一条跟进记录时系统会自动把上一次跟进记录和当前记录的间隔天数显示出来。这个小小的提示非常管用它让销售自己意识到“这个客户已经两周没联系了”比任何主管催办都有效。另一个值得提的是防重复提交。写跟进记录时用户很容易因为网络卡顿多点一下按钮导致同一条内容被插入两次。我在后端加了一个接口幂等校验前端每次打开跟进弹窗时向后端申请一个请求ID提交时带着这个ID后端在指定时间段内如果发现同一个请求ID就直接拒绝重复插入。核心代码如下func CreateFollowUp(c *gin.Context) { var req FollowUpRequest if err : c.ShouldBindJSON(req); err ! nil { c.JSON(400, gin.H{error: err.Error()}) return } // 幂等校验同一请求ID在同一客户、同一用户下不能重复写入 var count int64 db.Model(FollowUp{}). Where(idempotent_key ?, req.IdempotentKey). Count(count) if count 0 { c.JSON(200, gin.H{duplicated: true}) return } followUp : FollowUp{ CustomerId: req.CustomerId, UserId: c.GetInt64(userId), Type: req.Type, Content: strings.TrimSpace(req.Content), NextFollowUpAt: req.NextFollowUpAt, IdempotentKey: req.IdempotentKey, } tx : db.Begin() if err : tx.Create(followUp).Error; err ! nil { tx.Rollback() c.JSON(500, gin.H{error: 创建跟进记录失败}) return } // 同步更新客户表的下次跟进时间和最后跟进时间 tx.Model(Customer{}).Where(id ?, req.CustomerId). Updates(map[string]interface{}{ last_follow_up_at: time.Now(), next_follow_up_at: req.NextFollowUpAt, }) tx.Commit() c.JSON(200, gin.H{data: followUp}) }幂等校验不是花哨功能在真实生产环境里它就是最后一道防线。销售用的笔记本放在桌面上蹭到F5刷新是常有的事公司的无线网络偶尔抽风转半天圈后用户又点了一次提交这种事情一旦发生后面的时间线就会有两条一模一样的内容看起来很业余。3.3 销售漏斗统计口径要和销售对账销售漏斗这个模块开发起来本身不难难的是确定每个阶段的定义。我在上线前拿着漏斗阶段去问销售负责人时对方第一反应是“你定就行”。我说不行如果定得跟你们实际工作情况不一样这个图就是个摆设没人看。最终我们把漏斗阶段定义为六段阶段含义进入条件1 新线索刚录入系统未联系客户创建即进入2 已联系已和客户发生过有效沟通首次跟进后进入3 意向确认客户明确表达合作意向销售手动推进4 方案报价已向客户提交过方案或报价有报价记录后5 合同审批已发合同或处于内部审批流程中销售手动推进6 成交客户已回传合同销售确认每个阶段之间的转化率系统按商机金额和数量分别统计。这里有一个关键点漏斗金额必须用商机表里的预计成交金额而不是客户表里的某个字段。因为一个客户可能同时有好几个产品线的商机在推进如果只在客户上挂一个总金额后续统计会乱掉。商机表结构如下CREATE TABLE deals ( id BIGSERIAL PRIMARY KEY, customer_id BIGINT NOT NULL REFERENCES customers(id), user_id BIGINT NOT NULL REFERENCES users(id), title VARCHAR(200) NOT NULL, amount NUMERIC(12,2) NOT NULL, stage VARCHAR(20) NOT NULL, expected_close_date DATE, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() );上线两周后销售会主动看漏斗我觉得不是因为我们这个页面做得多好看而是因为统计口径和他们对齐了。大家知道每一个阶段代表什么图上的数字和心里的判断一致工具才可信。3.4 公海池和自动回收规则公海池是让客户资源流转起来的关键模块。简单说没有所有者的客户都放在公海里销售可以主动捞取也可以把自己长期跟不动的客户退回公海。规则是这样定的新导入的客户默认进公海池由主管分配或销售自行捞取。每个销售名下最多持有300个客户超出后必须退回一部分公海才能继续捞取。客户超过30天没有跟进记录自动退回公海并且系统会记录退回原因。自己名下的客户不能主动退回给自己的同事只能退回公海或由主管调配。自动回收的定时任务我最初用了一个简单的goroutine配合time.Ticker来实现。这个小功能在单机部署时没什么问题但如果后面想扩容成双实例就会遇到重复执行的问题。这个坑在后面第五章具体讲。公海池的设计让销售开始珍惜自己名下的客户也倒逼大家把长期沉睡的客户释放出来给团队其他同事使用。系统跑通后主管最明显的感受是客户待在个人名单里吃灰的情况明显变少了。4. 权限模型和员工交接团队协作的底线4.1 数据权限矩阵客户数据是公司的核心资产权限控制绝不能马虎。DeskcommCRM的角色只设计了三个没有更多管理员、销售主管、销售员。权限矩阵如下功能销售员销售主管管理员查看客户列表仅本人本部门全部新增/编辑客户本人名下本部门全部查看跟进记录本人名下本部门全部添加跟进记录本人名下本部门及下属全部分配客户不可本部门内可分配全部可分配查看成交金额报表仅个人本部门全部管理公海池捞取全部全部系统设置不可不可可数据隔离的实现没有用复杂的行级权限插件因为我觉得角色只有三个规则是有限的直接在SQL查询时拼条件就够了。后端中间件会从JWT里解析出当前用户的角色和部门ID然后在每个查询接口上调用一个公共函数追加数据范围条件。这样做的好处是代码直观出问题容易排查。坏处是如果以后权限规则变复杂中间件的逻辑会膨胀。对一个内部工具来说这个取舍是可以接受的。4.2 离职员工客户一键交接员工离职时的客户交接是我在做权限模块时顺手加的需求也是后来被主管表扬最多的一个功能。以前用Excel管理时期人一旦离职他手上的Excel如果忘了导出客户就跟着人事档案一起消失了。DeskcommCRM做了两件事第一管理员可以一键搜索某个离职员工的全部客户和商机批量转移给指定的人。转移时保留所有历史跟进记录不会因为换负责人而丢失时间线。第二离职员工账号不会物理删除而是做离职标记状态变为“已停用”他名下那些尚未转移的客户会标记为待交接避免出现一段时间内“无主客户”的真空状态。实现这个逻辑时客户表里的owner_id不动另外加了一个交接记录表每次批量转移都记录操作人和原负责人。万一后续出现客户归属纠纷可以从操作日志里看到确切的转移时间线和操作人。4.3 操作日志和防抵赖操作日志这个模块技术含量很低但价值很高。我把所有敏感动作都记录了下来修改客户所属人修改客户状态或等级删除客户软删除导入导出客户数据修改系统配置日志表结构是这样的CREATE TABLE operation_logs ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, action VARCHAR(30) NOT NULL, target_type VARCHAR(20) NOT NULL, target_id BIGINT NOT NULL, detail JSONB, created_at TIMESTAMPTZ DEFAULT now() );有人可能会说这不就是记录流水嘛没什么技术含量。但在真实团队里这个表救过我一次。有一次一个销售说自己名下的客户被另一个同事偷偷抢走了我们查了操作日志发现那个客户是主管在系统中转移的转移时间、操作人、原因备注全都写得很清楚几分钟就化解了矛盾。所以如果你也要做内部系统无论多简单操作日志一定要从第一天就做好。后期想补是最麻烦的。5. 上线部署踩过的三个坑5.1 数据库时区混乱导致“下次跟进时间”错8小时系统开发阶段我们用的都是本地数据库时区问题一直没暴露。部署到内网服务器后测试人员反馈销售在下午四点钟设置了一条“明天上午十点跟进”的提醒结果第二天上午六点系统就开始提醒了整整早了四个小时有时候还会出现显示时间比实际时间晚8小时的情况。排查过程是这样的。我先去服务器上输入date命令发现系统时区是UTC而我们的人都在东八区。数据库里的timestamptz字段本身没问题PostgreSQL在存储时会转成UTC取出时会按会话时区转换。问题出在后端Go程序连接数据库时GORM默认使用UTC作为time.Local而前端传过来的时间戳又是带时区的ISO字符串。最后统一了配置数据库连接串里显式指定TimeZoneAsia/Shanghai。Go后端设置time.Local time.FixedZone(CST, 8*3600)。前端所有input日期控件按本地时间展示提交时转成ISO 8601带偏移格式。后端接收时间参数时统一用time.RFC3339解析。这个坑解决后系统所有时间提醒都正常了。以后做任何跨时区系统千万不要依赖操作系统默认时区一定要在应用层显式设置。5.2 定时任务重复执行从偶发到定位公海池自动回收任务上线一周后主管反映有些客户会连续收到两封“已退回公海”的提醒而且客户状态也确实出现了重复。最初我以为是前端提示重复了后来一查数据库才发现同一条回收记录插了两遍。原因很简单我一开始用go-cron起了定时任务但公司服务器上后来用Docker启动了两份后端实例一台机器上为了平滑升级跑了新旧两个容器结果两个实例同时去扫30天未跟进的客户产生了并发竞争。这个问题的标准解法是加分布式锁。我用Redis的SET NX EX实现了一个最简单的锁func acquireLock(ctx context.Context, redis *redis.Client, key string, ttl time.Duration) (bool, error) { ok, err : redis.SetNX(ctx, key, 1, ttl).Result() if err ! nil { return false, err } return ok, nil }定时任务每执行一次前先尝试获取锁拿到锁的实例才继续执行另一个实例直接跳过。锁的过期时间设置成30秒任务正常执行不会超过这个时间。如果实例崩溃了锁也会自动过期不会造成死锁。这个坑告诉我们一个道理只要涉及定时任务就要以多实例部署为前提去设计别管现在是不是只有一台服务器。因为服务器以后一定会扩容到那时再改就涉及数据一致性问题了。5.3 内网连接池耗尽导致页面假死系统上线后前两周一切正常第三周开始每天早上九点到十点销售集中上班打卡后页面加载非常慢甚至出现“无法连接服务器”。一开始我怀疑是带宽问题后来看后端日志才发现是数据库连接池被占满了。原因是公司内网有段时间网络不太稳定部分请求从后端到数据库的TCP连接没有正确释放导致PostgreSQL连接数一直累积很快就打满了默认的100个连接。我在GORM初始化时对连接池参数做了调整sqlDB, _ : db.DB() sqlDB.SetMaxIdleConns(10) sqlDB.SetMaxOpenConns(50) sqlDB.SetConnMaxLifetime(time.Hour)同时把PostgreSQL的max_connections从100调到了200。最重要的修改是给所有数据库查询接口设置了超时时间不允许任何一个慢查询无限期占用连接ctx, cancel : context.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() db.WithContext(ctx).Find(customers)这之后页面假死的问题基本消失了。内网系统虽然用户不多但数据库连接池配置依然不能省略这是很多内部小系统容易忽略的地方。6. 上线两个月的使用反馈和后续计划6.1 销售最认可的一个页面DeskcommCRM上线两个月后我私下问了几个销售最常用的功能是什么。大部分人回答的不是客户列表也不是漏斗报表而是工作台首页的“今日待跟进”。这个答案我完全理解。因为工作台解决的是销售每天最基础的一个问题今天上班该干什么。传统Excel管理没有“提醒”这个概念所有事情都靠脑子记。系统上线后销售只要打开电脑看一眼工作台哪些客户今天该联系、哪些已经超过三天没有动静一目了然。这种确定性带来的安心感比一个复杂的报表页面管用得多。顺便说一句我们为了防止工作台变成无意义的待办堆砌做了个限制每天最多展示20条待跟进客户按紧急程度排优先级。如果销售手上有超过20个客户需要跟进那说明任务排得有问题应该优先处理最紧急的部分而不是让系统把所有积压全部列出来制造焦虑。6.2 管理层要的报表怎么自动生成管理层对系统的诉求和销售完全不同他们要的是结果数据不是过程数据。每周一主管会要一份带渠道维度的周报这周新增了多少客户、每个渠道分别贡献了多少、哪些渠道转化率最高、整个销售漏斗健康程度如何。刚开始我的做法是让后台实时聚合统计但客户量上来之后复杂查询会拖慢接口响应特别是跨多个表的漏斗统计。后来调整了方案每天凌晨用定时任务把当天的汇总数据预先算好存到一张统计汇总表里报表页面只查这张表不直接查明细表。这个“预计算按天汇总”的思路在做内部系统时特别实用。很多时候不需要实时计算报表数据晚几分钟更新完全不影响决策但实时聚合的数据库压力却是实打实的。6.3 接下来要做的事和我的建议DeskcommCRM目前还在持续迭代。下一步打算做的功能有三个一是把跟进记录里的“客户意向变化”用趋势图展示出来让销售和历史数据对比判断自己的跟进策略是否有效。二是做一个手机端适配的简化版不追求完整功能重点解决在外拜访客户时快速添加跟进记录和查看客户详情的需求。三是接入企业微信提醒把“今日待跟进”和超过时限未跟进的客户推送到对应销售的手机上。如果你也想构建类似的内部系统我的建议是先把“客户能存进去、跟进能记下来、销售能想起来、管理层能看到”这四件事跑通其他都往后放。不要一开始就上AI销售预测、智能营销这些噱头功能先把基础的数据可信度做扎实。DeskcommCRM这个项目给我的最大收获不是代码量而是让我重新理解了工具和业务的关系。工具只有贴合业务实际才会被团队真正用起来而一个被大家每天都打开的系统它的数据才会越来越值钱。希望这篇实战记录能给正准备做类似事情的人一些参考。