DeskcommCRM 这个项目,说起来完全是被逼出来的。我们团队销售、客服加运营一共二十多号人,每天上班打开电脑要看四个地方:本地 Excel 客户表、企业微信聊天记录、个人邮箱,还有一张共享盘里永远不知道谁改过的跟进表。客户资料散落得到处都是,每次做报价、做回访之前,都要先花十几分钟把信息拼一遍,拼完还不知道是不是最新的。DeskcommCRM 就是在这么个背景下攒出来的内部系统,项目代号 Deskcomm,取桌面通信 Desktop Communication 的意思,核心只干一件事:把一个客户从线索到成交再到售后服务的完整过程,全部收纳到同一个工作台里。
这篇文章我会从需求背景、数据建模、技术选型、具体实现、上线踩坑和后续扩展这几个角度完整复盘一遍。如果你也正在纠结"要不要自建 CRM""到底怎么设计客户管理的数据结构"或者"权限怎么做才不漏",那这篇应该能给你不少可以抄作业的思路。
1. 为什么做 DeskcommCRM:一个办公室里每天都在发生的痛点
1.1 客户资料各自为政,找一条记录要开四个软件
先用一个真实场景来说说这个系统解决了什么问题。我们团队的业务模式是"销售获取线索,客服承接售后",中间涉及的人有三类:对外打电话的销售、处理咨询和投诉的客服、做客户成功回访的运营。在没有统一系统之前,他们的工作流是这样的:销售把客户信息记在自己的 Excel 里,成交之后把合同扫描件放共享盘;客服在处理售后问题时,需要去共享盘翻合同、去企业微信翻聊天记录、去邮件里翻往来信函,经常翻完三个地方才能搞清楚这个客户买了什么、之前有没有类似投诉;运营做回访时,只能照着共享盘里那份不知道什么时候更新的客户名单打电话,打完电话再在原名单上手动加备注。
这个流程最大的问题不是"工具多",而是"信息断层"。销售知道客户最初的决策逻辑,客服知道客户最近的使用问题,运营知道客户整体的活跃状态,但没有任何一个地方把这些信息串起来。结果就是同一个客户被不同人联系时,可能重复询问同样的问题,客户体验差;老员工离职时,他手头客户的历史记录跟着 Excel 一起消失,新人接过联系人完全靠猜。我记得最夸张的一次,一个客户投诉时说"你们业务员上周答应给我换个方案",客服翻了一个小时都没找到那条沟通记录,最后发现是销售只在他自己的微信收藏里存了一条语音。
这种痛点不解决,后面的精细化管理、数据报表、客户分群都是空谈。所以当时就下了个决心:得有一个统一的客户管理系统,让每一条客户记录、每一次跟进动作都有处可去,而且让这些数据成为团队资产而不是个人资产。
1.2 对比现成 CRM 之后,我为什么决定自研
做系统之前的第一个抉择,就是买现成的 SaaS CRM,还是自己开发。当时我们试了两家主流产品,也认真评估过企业微信自带的客户管理功能。客观说,现成 CRM 在功能完整度上确实很强,从线索管理、商机预测到销售漏斗报表,开箱即用。但落到我们这种二十人团队的场景,有三个绕不过去的问题。
第一是数据所有权。客户数据是团队最有价值的资产之一,存在别人的服务器上,哪天不续费了,导出数据还要走繁琐流程,心很累。第二是定制成本。我们的业务流程有很强的行业属性,比如售后工单需要关联"最终用户""设备序列号""服务合同版本"这类字段,在通用 CRM 里要么加不了,要么要通过很绕的方式实现。第三是价格。按坐席数收费的平台,二十个人一年下来是一笔不小的开销,而且功能冗余,百分之四十的模块我们根本用不到。
所以最后决定自研,目标很明确:做一个轻量的、桌面端优先的、只覆盖"客户档案 + 跟进记录 + 工单处理 + 基础报表"的 CRM,够用就行。这个决定有风险,开发周期至少要一两个月,但长远看,系统完全可控,想加字段就加字段,想接企业微信就接企业微信,不用受制于人。后来证明这个思路是对的,系统上线半年后,我们做的很多小调整,在 SaaS 产品里几乎都是不可能或者要额外付费的。
1.3 技术方案选型:Electron + Go + PostgreSQL 的组合逻辑
技术栈的选型其实也没纠结太久。客户端这边选了 Electron + Vue 3 + TypeScript,服务端用 Go 的 Gin 框架,数据库用的 PostgreSQL,缓存和临时队列用 Redis。这个组合看起来普通,但每一个选择背后都有明确理由。
桌面端为什么不用浏览器而用 Electron?因为我们的核心用户是坐席,一坐就是八个小时,他们的工作习惯是"同时开着好几个业务窗口",浏览器标签页很容易被误关,而且坐席需要本地缓存、托盘提醒、全局快捷键这类能力。Electron 虽然被很多人诟病体积大,但在团队开发效率优先的前提下,这个缺点可以接受,毕竟团队里前端最熟的就是 Vue,没必要为了"更原生"去额外维护一套 Qt 或者 WinForms。
后端选 Go 而不是 Java 或 Python,主要看中三点:部署简单、并发性能好、单二进制文件跑起来几乎没有运维成本。我们这种小团队没有专职运维,Go 编译出来一个可执行文件往服务器上一扔就能跑,出了问题直接 panic 日志排查,非常省心。
数据库的选型我额外想多说两句。我见过很多小团队用 MySQL 做这种系统,也完全没问题,但我选 PostgreSQL 的核心原因是两个:一是 JSONB 字段特别适合做"自定义字段"这种场景,不需要频繁 ALTER TABLE;二是它的全文检索和 pg_trgm 模糊搜索能力,比 MySQL 默认的 LIKE 方案强不少,后面踩坑部分我会详细讲。总之一句话,选型不是要追求新潮,而是要看清楚自己团队的技术储备和业务场景,用最顺手的东西先把系统跑起来。
2. 核心数据模型与功能设计:把客户当作一个有生命周期的对象
2.1 客户、联系人、跟进记录、工单之间怎么建模
数据模型是整个系统最核心的部分,它决定了系统能用多久。第一次设计时很容易犯错的地方,是把"客户"当成一张孤立的表,所有信息都塞进去。我们一开始的草稿就是把客户名称、联系人姓名、电话、地址、购买记录全放一张表,后来一提真实需求就崩了:一个客户可能有多个联系人,一个联系人可能对应多个工单,一条跟进记录可能同时涉及客户和联系人。
所以最终的模型分成了五张核心表,关系大致是这样的:
- 客户表 customers:存储一个公司或组织级别的基础信息,比如客户名称、行业、来源渠道、归属销售、创建时间。
- 联系人表 contacts:客户下面的具体人员,每一个联系人外键关联一个客户,各自有姓名、职位、电话、微信号。
- 跟进记录表 activities:记录每一次与客户的互动,外键同时关联客户和联系人,内容支持文本正文和附件。
- 工单表 tickets:处理售后或服务请求,关联客户、联系人、负责客服,包含工单状态、优先级、解决时间。
- 自定义字段元数据表 entity_metadata_configs:管理客户、联系人等实体上的动态字段,具体数据存 JSONB。
数据库建表的简化 SQL 长这样,字段不是全部,但核心关系都在:
CREATE TABLE customers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(200) NOT NULL, industry VARCHAR(100), source VARCHAR(50), owner_id UUID REFERENCES users(id), status SMALLINT NOT NULL DEFAULT 1, custom_data JSONB NOT NULL DEFAULT '{}', version INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE contacts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, position VARCHAR(100), phone VARCHAR(50), wechat VARCHAR(100), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE activities ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE, contact_id UUID REFERENCES contacts(id), creator_id UUID NOT NULL REFERENCES users(id), content TEXT NOT NULL, attachments JSONB DEFAULT '[]', next_follow_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );这里有个细节值得说:ID 统一用 UUID,而不是自增整数。原因有两个,一是分布式的环境下如果以后要拆库拆表,UUID 不会冲突;二是前端在离线状态下可以先本地生成 UUID,等网络恢复再同步,不用依赖服务端返回的自增 ID。这一点在后来的桌面端离线缓存设计里帮了大忙。
外键关系上,工单和活动都直接关联客户和联系人,这样一个客户详情页里,可以同时看到他的联系人列表、所有跟进记录、所有历史工单,形成完整的时间线;反过来,从任意一个工单也能顺藤摸瓜找到这个客户全貌。这就是我说的"把客户当作一个有生命周期的对象",而不是一行静态数据。
2.2 自定义字段的真正难点不是存储,是配置
做 CRM 最容易被提的需求就是"我们组想加一个字段"。如果每次加字段都改表结构,开发很快会崩溃,所以自定义字段系统是必需品。
我们的做法是两张表配合:一张是字段配置表,记录每个实体上允许有哪些自定义字段,以及字段的类型、名称、是否必填、下拉选项等;另一张直接在 customers、contacts 这些表上留一个 custom_data JSONB 字段,存实际值。
字段配置表的简化结构如下:
CREATE TABLE entity_metadata_configs ( id UUID PRIMARY KEY, entity_name VARCHAR(50) NOT NULL, field_key VARCHAR(100) NOT NULL, field_label VARCHAR(200) NOT NULL, field_type VARCHAR(30) NOT NULL, options JSONB, is_required BOOLEAN NOT NULL DEFAULT false, is_visible BOOLEAN NOT NULL DEFAULT true, sort_order INT NOT NULL DEFAULT 0 );这里有一个我们要特别注意的设计:field_key 是给前端和代码用的固定标识,不能改;field_label 是显示给用户看的中文名,可以随时改。这样即使以后把"客户等级"改叫"客户分层",代码逻辑都不用动,只需要改一条配置记录。
实际校验规则是这样的:后端收到创建或更新请求时,会先读取该实体对应的字段配置,遍历提交的 custom_data,检查必填项有没有填、枚举类型的值是否在 options 里、日期格式是否正确。通过之后再连同固定字段一起写入数据库。
试过之后最大的体会是:自定义字段的"配置管理"比"存储"复杂得多。存储无非是存 JSONB,但配置项的设计要考虑版本变化——今天这个字段叫"客户等级",明年可能改叫"客户分层",历史数据怎么办?我们的方案是保留字段配置的历史版本,老数据展示时按当时的配置渲染,新数据用新配置。这听起来复杂,但对长期使用的系统来说非常关键。
2.3 跟进自动提醒:Redis 延迟队列 + 托盘通知的设计细节
CRM 系统除了"记录",还有"提醒"这件事也很重要。销售经常遇到的情况是:上星期跟客户约好"下周再联系",结果一忙就忘了,等想起来,客户已经买了别家的产品。所以 DeskcommCRM 里做了一套"待跟进提醒"机制。
实现逻辑是这样的:用户在填写跟进记录时,可以选择"设置下次跟进时间"。这个时间一到,系统会通过桌面端托盘弹通知,提醒对应责任人。技术实现上,我们用了 Redis 的有序集合做延迟队列,成员是跟进记录 ID,分数是预定的执行时间戳。后台有一个常驻的定时器,每隔 30 秒扫描一次有序集合,把时间到达的记录捞出来,推送通知,同时把记录标记为"已到期"。
有人可能会问,为什么要用 Redis 延迟队列而不是简单写个定时任务扫数据库?确实,如果数据量不大,定时任务每小时扫一次数据库也完全可行。但我们当时考虑到桌面端对提醒的实时性要求比较高,用 Redis 延迟队列可以把扫描粒度降到 30 秒,同时对数据库的压力也小,不会因为频繁扫表影响正常业务接口的响应。
不过这里有个和产品设计相关的问题比技术细节更重要:提醒是"督促"而不是"催命"。上线早期我们把这个提醒做成弹窗加声音,结果销售觉得被打扰,后来改成了"只在托盘图标上亮一个小红点,点开能看到待办列表,不主动打断",反馈明显好很多。做工具类产品,克制比添加功能更难。
3. 权限体系与安全设计:从"防君子"到"防接口"
3.1 角色、数据范围、字段权限的三层模型
权限体系如果设计不好,后患无穷。最典型的场景是:销售 A 和销售 B 没有业务交集,但系统允许 A 看到所有人的客户列表,那客户资源和客户隐私都会出问题。所以 DeskcommCRM 的权限模型拆成了三个维度:角色、数据范围、字段权限。
角色决定你能做什么操作,比如"管理员"可以配置系统、创建用户、查看所有数据;"主管"可以看到数据范围内的所有数据,但改不了系统设置;"坐席"只能维护自己的数据和被分配工单;"只读角色"给老板和财务看报表用。数据范围解决的是"你能看哪些客户"的问题,分为"全部数据"、"本部门数据"、"本人数据"三类。字段权限解决的是更细粒度的问题——比如"坐席能看到客户电话,但看不到客户成本价",这在同一条记录的不同字段之间做区分。
三种维度组合下来,就形成了比较完备的权限体系。在代码层面,我们用中间件统一处理权限逻辑,每一个 API 请求到达后端时,先通过鉴权中间件把当前用户的 user_id、role 解析出来,再根据接口定义的角色要求做一层校验;数据范围则在每次数据库查询前拼接条件实现,后面会细说。
3.2 在 SQL 层做数据隔离,而不是在前端做隐藏
前端做权限控制是最常见的坑。很多系统是这样实现的:接口返回了所有客户的完整数据,前端根据当前用户角色把不该看的按钮隐藏、把不该看的下拉选项过滤掉。这种方式表面上看没问题,实际上等于裸奔——任何一个懂点开发的人,打开浏览器开发者工具直接调用接口,就能拿到所有数据。所以我们的原则很简单:权限必须由后端强制执行,前端只是用户体验层面的"二次隐藏"。
数据库查询层的统一封装长这样:
func (r *CustomerRepo) List(ctx context.Context, scope Scope) ([]Customer, error) { query := `SELECT id, name, owner_id, custom_data, status, created_at FROM customers WHERE 1=1` if scope.Type == "self" { query += ` AND owner_id = $1` } else if scope.Type == "department" { query += ` AND department_id = $1` } // 分页、排序等其他条件继续拼接 }核心是 Scope 这个结构体,它包含当前用户的 ID、部门 ID、角色,以及计算出的数据范围类型。每一个涉及客户数据的查询都必须走这个带 Scope 的 Repo 方法,不允许在业务代码里手动拼 SQL 拼接出"不经权限过滤"的查询。这样做虽然牺牲了一些灵活性,但能最大程度降低越权风险。
实际验证的时候,我们的测试用例会专门构造一个"低权限用户",直接调用涉及客户数据的每个接口,确认返回结果里只有他有权限看到的数据。这种测试不是一次性的,而是在每次修改查询逻辑后都要回归。
3.3 操作审计:哪些操作一定要记录下来
审计日志这个功能,一开始我们觉得不重要,后来发生了一次客户数据被误改的事件后才认真做起来。那次是有个销售离职前把一批客户的所有权转移给了另一个同事,转移完发现数量对不上,但没有任何日志能追溯是谁改的、什么时候改的。
现在的审计日志覆盖三大类操作:查看客户详情(尤其是有权限但非本人负责的客户)、修改数据(包括改归属人、改状态、改自定义字段)、导出数据。日志内容包括操作人、操作时间、IP、操作类型、目标实体 ID、修改前后的字段值。存储上单独建审计日志表,不随业务数据一起清理,保留期限设为两年。
不过,审计日志也不是什么都记,把"更新一次跟进记录"这种高频低风险操作也全量记录,日志表会很快膨胀,而且对排查问题没有实际帮助。我们只对高风险操作做记录,对那些正常业务数据变更,通过数据表的 updated_at 和操作人字段来追踪就足够了。
4. 落地实操:从初始化数据库到桌面端联调
4.1 后端项目结构与数据库迁移
项目结构我简单列一下,方便想参考的人。初始版本没有做得太复杂,就是典型的分层结构:
deskcomm-server/ ├── cmd/ │ └── server/main.go ├── internal/ │ ├── router/ -- 路由注册 │ ├── middleware/ -- 鉴权、日志、权限 │ ├── handler/ -- HTTP 层,解析请求和返回响应 │ ├── service/ -- 业务逻辑层,校验和数据编排 │ ├── repo/ -- 数据访问层,SQL 和执行 │ ├── model/ -- 数据模型定义 │ └── config/ -- 配置加载(环境变量或配置文件) ├── migrations/ -- SQL 迁移文件 └── go.mod数据库迁移直接用了一个简单的开源工具 golang-migrate,每次变更数据库结构都新增一个迁移文件,记录在 git 历史里。这样不管是开发环境还是生产环境,执行一遍所有迁移文件就能达到一致状态,不会出现"我的本地库比你多一张表"这种扯皮问题。
初始化流程大概是:先在服务器上创建 PostgreSQL 数据库和专用账号,账号用的最小权限,只有目标库的增删改查,不授予超级权限;然后跑迁移命令,把所有建表 SQL、索引 SQL 依次执行;最后再启动后端服务,通过 /healthz 接口确认数据库连接正常。
索引方面,有两个地方绝对不要省:一是 customers.owner_id 必须建索引,因为这是最频繁的筛选条件;二是 activities 表的 customer_id 联合 created_at 建索引,因为客户详情页要按时间倒序遍历跟进记录。省了这两个索引,数据量一上来,接口必然变慢。
4.2 登录、Token 刷新与本地缓存策略
桌面端和服务端的通信采用 Bearer Token 方式,登录成功后服务端颁发一个 access_token(有效期 2 小时)和一个 refresh_token(有效期 7 天)。每次请求携带 access_token,如果接口返回 401,客户端自动用 refresh_token 去换新 token,换完重放原请求。整个过程对用户无感,不用频繁重新登录。
这套双 token 机制最需要注意的地方是 refresh_token 的安全。Electron 应用打包后,代码是明文保存在本地资源的,如果把 refresh_token 硬编码或者存在不安全的位置,等于把长期有效的"后门钥匙"交给了攻击者。我们的做法是:refresh_token 只存在系统用户目录下的配置文件里,同时设置文件权限为当前用户可读写;桌面端尽量不明文打印任何请求日志,避免 token 泄露到日志文件。
本地缓存策略上,我们做了一层轻量优化:客户列表的常用字段(ID、名称、归属人、状态、更新时间)会缓存在本地 SQLite 中,每次打开应用先渲染本地缓存,让用户能立刻看到数据,后台再异步拉取最新数据并做对比更新。这不仅让界面响应速度体感快很多,而且支持弱网环境下基本浏览。但这里要特别注意,缓存的数据不能用于任何"写操作"的依据,比如你本地看到的状态可能是 1 小时前的,如果直接提交修改会把别人最新的修改覆盖掉,所以写操作一律以服务端数据为准。
4.3 Docker Compose 部署与每日备份
部署这块我们的方案是 Docker Compose,把服务端、Redis、PostgreSQL 三个容器编排在一起,跑在一台 4 核 8G 的云服务器上。选这个配置的预算是按"20 人团队 + 10 万级客户数据"来估算的,后续如果数据量增长,OpenTelemetry 先不用,主要是看慢查询和连接数,不够再加。
Docker Compose 文件的核心内容参考如下:
version: "3" services: db: image: postgres:16-alpine environment: POSTGRES_DB: deskcomm POSTGRES_USER: deskcomm POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - pgdata:/var/lib/postgresql/data ports: - "127.0.0.1:5432:5432" redis: image: redis:7-alpine command: redis-server --requirepass ${REDIS_PASSWORD} volumes: - redisdata:/data ports: - "127.0.0.1:6379:6379" server: build: . depends_on: - db - redis environment: DATABASE_URL: postgres://deskcomm:${DB_PASSWORD}@db:5432/deskcomm REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0 JWT_SECRET: ${JWT_SECRET} APP_PORT: 8080 ports: - "127.0.0.1:8080:8080"这里有个安全细节:数据库和 Redis 的端口只绑定在 127.0.0.1,不暴露到公网,只有服务端容器内部能互相访问。公网访问只留一个 Nginx 的 443 端口,这样外部直接打 PostgreSQL 和 Redis 的路径被彻底堵死。
备份方面,我们用的策略很朴素:每天凌晨 3 点跑一个 cron,执行 pg_dump 把全库数据导成 SQL 文件,压缩后存到另一台独立的备份机上,保留最近 30 天。不定时做一次恢复演练,确保备份文件不是一堆"看起来在但无法还原"的数据。
4.4 验收与交付:给团队的试用和培训
系统开发完成不代表事情结束,团队用起来才是真正的开始。我最开始犯的一个错误是"功能做完就上线",结果上线第一周同事们各种不顺手:有说界面太挤的、有说不清楚在哪改归属的、有说快捷键不生效的。后来总结经验,上线前必须先做一轮"小范围试用 + 面对面培训"。
我们当时的做法是:先邀请每个小组的一两个积极用户试用两周,收集真实意见后再调整;正式上线前开了两场培训,一场给销售、一场给客服,只讲他们日常最常用的操作路径,不讲多余功能;同时制作了一份三页纸的快速上手指南,放在桌面端帮助菜单里。上线后第一个月,每周五下午开个短会,集中解答问题并收集新需求。
这种循序渐进的方式有个额外的好处:早期试用用户会成为系统内部的"推广者",他们自己用顺手了,会帮助其他同事解答基础问题,省了我们不少答疑时间。
5. 上线后踩过的坑:连接池、中文搜索、越权与数据覆盖
5.1 连接池耗尽:一个不显眼的 defer 引发的连锁故障
上线第二周,有一个上午所有接口突然全部超时,用户端表现为"系统转圈转半天,什么都加载不出来"。查了服务器监控,CPU 和内存都正常,但数据库连接数打满了。通过查询 pg_stat_activity 发现大量状态为 idle in transaction 的连接占着连接池不放。
最后定位到原因非常微妙:某个查询客户详情的接口里,有一段代码用事务做数据组合查询,事务开启后,有个分支在 return 之前忘记调用 Commit 或 Rollback,虽然 defer 里写了 db.Close(),但这里关闭的是数据库连接池里的连接对象,而不是结束事务。这个 bug 在高并发下把连接池的 20 个连接全部占满,之后的请求全部排队等待可用连接,最终表现为系统假死。
解决办法分两层:代码层面,给所有涉及事务的函数补了严格的 Commit/Rollback 逻辑,并加了一个全局的超时机制,任何 SQL 事务超过 10 秒未结束就自动终止;运维层面,给 pgxpool 配置了连接生命周期上限和最大空闲时间,避免长时间占用。这个坑给我们的教训是:不用的连接要马上还回去,即使代码逻辑正确,也要考虑异常路径下的资源释放。
5.2 中文客户名搜不到:从 ILIKE 到 pg_trgm 的迁移
系统上线后,销售提得最频繁的需求就是"你们这搜索是不是有问题,客户名字我就记不全"。比如客户全名叫"临沂市建达机械有限公司",销售只记得"建达",用原版的模糊查询 SQLWHERE name LIKE '%建达%'死活搜不出来。后来才意识到,传统 LIKE 是严格按顺序匹配子串的,一旦你记忆中的名字顺序和全名不一致,就搜不到。
解决方案是给 PostgreSQL 开启 pg_trgm 扩展。这个扩展会把字符串切成三个字符的片段(trigram),搜索时先按相似度计算来匹配,能容忍一些拼写偏差和不完整的名字。开启方式和建索引的例子如下:
CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_customers_name_trgm ON customers USING gin (name gin_trgm_ops);实际效果非常明显,同样是搜"建达",开启之后能稳定匹配到包含"建"和"达"但顺序不完全一致的客户名。当然 pg_trgm 也不是万能的,它更擅长短文本的模糊匹配,如果未来需要全文级别的语义检索,还是要上更专业的方案,但对我们现在的使用场景已经完全够用。
这个搜索问题给我们的另一个启示是:在做搜索功能时,不要想当然地用最简单的方式实现,尤其面对中文场景,分词、词序、同义词这些因素都要考虑进去,否则用户用起来会非常有挫败感。
5.3 权限漏洞实录:普通坐席直接调用 API 看到了别人的客户
这个漏洞的发现很偶然:某天一个比较懂技术的同事用浏览器开发者工具看请求,发现前面说的"数据范围 SQL 强制过滤"只加在了列表接口上,而客户详情接口 GET /api/customers/:id 完全没有校验 owner_id,直接传别人的客户 ID 就能看到完整信息,包括电话、地址、往来记录。
这可以说是自研系统最容易出现的一类漏洞,开发时列表接口有权限组关心,详情接口容易疏漏。当时的修复方案是在 service 层增加一个统一的数据访问校验函数:
func (s *CustomerService) checkDataScope(ctx context.Context, customerID string) error { c, err := s.repo.GetByID(ctx, customerID) if err != nil { return err } if !canAccess(s.currentUser(ctx), c) { return errors.New("无权限访问该客户数据") } return nil }然后所有详情、编辑、导出、删除操作都先调用这个函数。同时,补了一批权限测试用例,核心就是"低权限用户访问高权限用户的数据必须 403"。这种测试用例在后续每一次接口变更时都要跑一遍,防止回归。
5.4 并发编辑导致的数据覆盖:给每行记录加上乐观锁
多人协作场景下的数据覆盖问题也是上线后才暴露的。场景是这样:客服和销售同时打开同一个客户详情页,客服改了客户地址并保存,销售没刷新页面,改完客户等级也保存,因为后端没有版本控制,销售保存的内容把客服刚更新的地址覆盖回了旧值。
解决方案很直接:在 customers 表上增加一个 version 字段,每次更新时校验传过来的 version 是否和数据库当前一致,一致才执行更新并把 version 加一,不一致直接返回"数据已被他人修改,请刷新后重试"。
UPDATE customers SET name = $1, custom_data = $2, version = version + 1 WHERE id = $3 AND version = $4;这样冲突给了客户端明确的信号,桌面端接到冲突响应后,会弹窗提示用户重新加载最新数据再做修改。这个机制不复杂,但确实避免了大量看不见的数据丢失,对一个多人同时使用的系统来说,是必要的投入。
6. 留给后续的扩展方向
6.1 接入企业微信与邮件,从外部渠道自动创建线索
DeskcommCRM 目前的数据录入还是以手动为主,这也是它相对克制的地方。但下一步最值得做的,是把企业和邮件渠道打通:比如企业微信里收到新客户好友申请时,自动在系统里创建一个"线索"记录;邮件来回复函时,自动关联到对应客户的时间线。这样可以大幅减少"忘了把聊天记录同步到系统"的概率,也能让历史沟通更完整。
技术实现并不复杂,企业微信提供了回调接口,邮件用 IMAP 监听收件箱即可。这里要重点注意消息去重和隐私边界:同一个客户在不同渠道的重复消息不要建多条记录,数据落地时也要遵循最小必要原则,不是所有内容都值得进 CRM。
6.2 用视图和报表代替"拍脑袋"的销售管理
系统跑起来之后,数据会慢慢积累,这时候最能体现 CRM 价值的是"数据能反过来指导管理"。目前我们只有一张简单的看板,展示每个销售手里有多少客户、本月新增了多少线索、有多少工单还没关闭。下一步想加的是:客户生命周期阶段分布、不同来源渠道的转化率、跟进频次与成交率的关系。这些分析不需要很复杂的算法,就是 SQL 聚合加几个图表就能出效果,但对团队管理决策的帮助非常大。
6.3 关于 AI:能用,但小团队要先解决数据规范化
现在都在聊 AI,我也认真想过能不能给 DeskcommCRM 加"智能跟进摘要"或者"客户画像自动打标"。结论是可以,但前提是数据要足够规范化。如果大家跟进记录内容随意,有的写"沟通一下",有的写"客户说下个月考虑",AI 再强也提炼不出高价值的洞察。所以我的态度是:先把数据采集和维护的习惯养好,等数据积累到一定阶段,再考虑用大模型做摘要归类。工具是辅助,流程和习惯才是根本。
最后说点题外话。做完这个项目,我最大的体会是:一个好的内部工具,不一定功能要多炫,而是要在"够用"和"不添乱"之间找到平衡。DeskcommCRM 没有搞很复杂的工作流引擎,也没有堆砌一堆没人用的功能,它只是解决了团队的三个最基本的问题:客户资料在哪里、最近和客户聊了什么、下一步该干什么。如果这篇文章里提到的数据模型、权限设计、踩坑经验,能给你的自研之路带去一点参考,那就值得了。