☰
自研CRM系统实践:数据模型、权限控制与工程落地
2026/10/19 3:46:19 网站建设 项目流程

去年下半年我们团队内部做了一次业务流程梳理,结果发现一个特别尴尬的情况:客户的联系方式散落在不同销售的微信聊天记录里,跟进的进度全凭个人记忆,销售一请假,客户就断档。后来我们花了几周时间从零搭了一个轻量级的客户关系管理系统,名字就叫DeskcommCRM。这个系统做的事情很纯粹,就是把客户资料、跟进记录、商机阶段和日常任务统一放在一个池子里,让每个销售都清楚自己手上有哪些客户、该做什么,也让管理者随时能看出真实的销售进度。这篇文章把整个项目的设计思路、表结构实现和上线过程中的坑整理出来,如果你也在考虑自研CRM,或者做toB管理系统想找点参考,可以直接抄里面的数据模型和权限方案。

1. 项目背景与整体设计思路

1.1 为什么会选择自研DeskcommCRM

决定自己写CRM之前,我们其实把市面上主流的现成产品都过了一遍。功能确实很全,但价格也是真的贵,按坐席数收费,二十个人的团队一年授权费就是一笔不小的开销,而且很多高级模块我们根本用不上。更重要的是字段和流程被平台锁死,销售希望把一个自定义字段加到客户表单里,这种需求在现成产品里要么做不到,要么要走漫长的工单审批流程,非常被动。

自研意味着可以完全按团队的使用习惯来设计。我们的核心诉求其实很朴素:客户资料能存、跟进记录能写、商机能追踪、数据能看。至于营销自动化、工单系统、在线客服这些东西,一期全部不做,因为团队就用不上,做出来只会增加维护成本。而且自研系统的数据掌控感是完全不同的,客户数据放在自己的服务器上,不用担心平台调整策略导致数据不可用,这一点对销售团队来说很重要。

当然,自研也不是没有代价,开发和迭代需要人手。但如果团队本身就有后端和前端工程师,投入的时间成本完全可以接受。我们整个项目从设计到上线用了将近三周,真正核心的开发时间大概就是两周,数据库设计和权限模型各花了两天,剩下时间全在打磨跟进记录的数据展示和导入导出的细节上,这个节奏对我们来说刚刚好。

1.2 核心模块与功能边界

DeskcommCRM一期规划了四个大模块:客户管理、跟进记录、商机漏斗和数据报表。客户管理负责客户资料的增删改查和导入导出;跟进记录用来沉淀每一次沟通内容,类似聊天记录一样的时间线展示;商机漏斗跟踪每一个潜在买家从初步接洽到最终成交的推进过程;数据报表给管理者看团队的整体表现。这四个模块基本覆盖了销售日常工作的全部场景。

在功能边界上我们做了很明确的取舍。不做市场活动管理,因为线索来源目前只有转介绍和展会,一个文本框存一下来源就行;不做客户公海池,因为团队规模不大,公海规则反而会让销售担心客户被抢走;不做复杂的审批流,所有商机的阶段变更只要记录操作人就行,管理者需要复盘的时候查操作日志。我始终坚持一个原则:一期功能宁可少做,不做只有少数人用的功能,空转的功能比没有功能更浪费精力。

2. 数据模型设计与核心细节

2.1 客户信息表:从扁平字段到动态扩展

客户表是整个CRM的地基,看起来最简单,实际设计的时候最考验细节。刚开始我们用的是完全扁平的字段设计,电话、微信、公司、地址、行业、来源全部做成固定列,结果上线第二天销售就提需求,要加一个“客户规模”的字段,后来又要加“预算区间”。每次加字段都要改表结构、改接口、改前端表单,非常痛苦。

后来我参考了业界比较通用的做法:核心高频字段固定,扩展属性放JSONB。PostgreSQL的JSONB类型在这个场景下非常好用,高频查询字段像客户名称、负责人ID、创建时间保留为独立列,方便建索引和排序,而那种不确定的、只有个别客户需要的属性统一放进attributes字段里。这样做的好处是,销售需要新增字段时,管理员直接在后台配置表单模板就行,代码一行不用改。

客户表的核心字段设计大概是这样的:

CREATE TABLE customers ( id BIGSERIAL PRIMARY KEY, name VARCHAR(200) NOT NULL, phone VARCHAR(30), company VARCHAR(200), owner_id BIGINT NOT NULL, status SMALLINT NOT NULL DEFAULT 1, source VARCHAR(50), attributes JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_customers_owner ON customers(owner_id); CREATE INDEX idx_customers_company ON customers(company);

这里有两个容易踩坑的点。第一个是status字段,它是客户的状态机,比如1是跟进中、2是已成交、3是已流失,建议用数字存,定义好在代码里做枚举映射,不要直接存中文,不然改状态名的时候要批量更新数据。第二个是owner_id,这个字段看起来很普通,但它决定了数据隔离的维度,所有按人查客户的操作都依赖这个字段走索引,后期做权限控制的时候就会发现当初给这个字段建索引是多么明智的决定。

2.2 跟进记录与时间线设计

跟进记录是整个DeskcommCRM里使用频率最高的功能,但对这个模块的设计,我们一开始其实走了一点弯路。第一版是仿照市面上的产品做了一个独立的“跟进列表”菜单,销售点进去按客户查记录,结果用了一周发现,没人愿意专门去那个页面维护记录,原因很简单,多一步跳转就多一份阻力。

后来我把跟进记录改成嵌入客户详情页里的时间线展示,形式上和聊天记录差不多,按时间倒序排列,销售在客户详情页处理完一件事,顺手就在底部输入框把跟进内容写了。这一步改动对使用率提升非常明显。跟进记录表的具体设计如下:

CREATE TABLE follow_up_records ( id BIGSERIAL PRIMARY KEY, customer_id BIGINT NOT NULL REFERENCES customers(id) ON DELETE CASCADE, owner_id BIGINT NOT NULL, contact_type SMALLINT NOT NULL DEFAULT 1, summary TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_followup_customer_time ON follow_up_records(customer_id, created_at DESC);

contact_type表示沟通方式,比如1是电话、2是微信、3是见面,这个字段不是为了统计,而是帮销售回忆当时的沟通场景。summary就是跟进内容,不限制长度,也不需要结构化。当初也有人问要不要把跟进内容拆成“客户需求”和“下一步计划”两个字段,实测下来发现会影响记录意愿,还是保留一个自由输入框最舒服。

跟进的另一个场景是任务提醒。销售回访客户不是想起来才回,“三天后跟进”这种需求很常见,所以我们给跟进记录加了一个待办提醒机制。提醒的实现方式没有用数据库定时扫描,而是在记录创建时把提醒时间写到Redis的有序集合里,单独起一个消费者进程去处理到期的提醒。这样避免每秒扫描一次数据库表的开销,也支持秒级精度。

2.3 商机阶段与销售漏斗

商机和客户是分开的两张表,一个客户名下可以挂多条商机。这样设计是合理的,一个客户可能同时买你的软件和硬件服务,两笔商机处在不同的阶段,金额也不一样,不应该被强行合并成一条记录去跟踪。

商机阶段的设计在这个模块里是核心。一开始想在数据库里用枚举类型直接定义阶段,后来被自己否了,因为业务阶段经常调整。最终方案是把阶段定义放在一张配置表里,商机表只存阶段的标识ID,管销售的同事调整阶段名称或顺序时,只改配置表不动业务数据,安全和方便都兼顾。

CREATE TABLE opportunity_stages ( id SMALLSERIAL PRIMARY KEY, stage_key VARCHAR(50) NOT NULL, stage_name VARCHAR(100) NOT NULL, sort_order SMALLINT NOT NULL DEFAULT 0 ); CREATE TABLE opportunities ( id BIGSERIAL PRIMARY KEY, customer_id BIGINT NOT NULL REFERENCES customers(id) ON DELETE CASCADE, title VARCHAR(200) NOT NULL, amount NUMERIC(12, 2) NOT NULL DEFAULT 0, stage_id SMALLINT NOT NULL REFERENCES opportunity_stages(id), owner_id BIGINT NOT NULL, expected_close_date DATE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() );

销售漏斗的统计也依赖这个阶段配置表。按stage_id分组、对amount求和,就能得到每个阶段的商机总金额,再结合成交商机的数量和转化周期,就能算出漏斗转化率。这个统计逻辑放在后面说,数据模型层面只需要保证stage_id外键关系和amount字段的数据准确即可。amount字段要警惕录入失误,我在接口层加了一个校验逻辑,凡是金额超过十万的商机,创建时必须填写备注说明,这样做不是限制销售,而是防止金额录错导致报表失真。

3. 实操过程与关键功能实现

3.1 从零搭建后端服务

技术选型方面,DeskcommCRM后端用的是Python的FastAPI框架,数据库是PostgreSQL,缓存和任务队列用Redis,前端是Vue 3搭配Element Plus组件库。选FastAPI的原因主要是三个:第一是异步性能好,并发处理能力足够应付几十人的小团队日常使用;第二是自动生成OpenAPI文档,前后端联调时接口文档实时同步,沟通成本直线下降;第三是Pydantic的数据校验写起来很顺手,客户数据的合法性判断基本都靠它。

前端选Vue 3加Element Plus,是因为这类管理系统本质上就是大量的表格、表单和弹窗,Element Plus正好把这些场景的组件都覆盖了,开发效率很高。项目结构上,前后端完全分离,后端只提供RESTful接口。目录结构大概长这样:

deskcomm-crm/ ├── app/ │ ├── api/v1/ │ │ ├── customers.py │ │ ├── followups.py │ │ ├── opportunities.py │ │ └── reports.py │ ├── models/ │ ├── schemas/ │ └── core/ │ ├── auth.py │ ├── permissions.py │ └── database.py ├── scripts/ └── docker-compose.yml

后端启动直接用Uvicorn命令就够了,数据库迁移用的Alembic。这里提醒一句,项目从一开始就应该把数据库迁移工具接进来,开发过程会频繁改表结构,没有迁移工具会非常痛苦。

3.2 客户查重与导入Excel的实现细节

客户查重是CRM导入功能里最容易忽略、实际使用中又最高频的问题。最直观的方案是在数据库的phone字段上直接加UNIQUE约束,但这个方案在真实场景下是行不通的。原因是同一个客户可能存在多个联系人的电话,或者销售录入时手机号和座机混着填,直接加唯一约束会导致大批量导入失败。

我们最终采用的方案是:导入时做模糊查重,不阻断写入,给出重复提示由用户自行决定是跳过还是合并。查重的核心逻辑分两步,先精确匹配phone字段,再用客户名称做分词相似度比对,两个维度都命中相同客户时标记为疑似重复。这样既避免了数据库层面的硬约束,又给了业务操作弹性。

Excel导入的代码核心逻辑大概是下面这样:

from openpyxl import load_workbook def import_customers(file_path, owner_id): wb = load_workbook(file_path, read_only=True) ws = wb.active valid_rows = [] error_rows = [] for row in ws.iter_rows(min_row=2, values_only=True): name, phone, company = row[0], row[1], row[2] if not name or not phone: error_rows.append((name, phone, "姓名和电话不能为空")) continue # 手机号格式校验 if phone and not is_valid_phone(phone): error_rows.append((name, phone, "手机号格式不正确")) continue valid_rows.append({ "name": name, "phone": phone, "company": company, "owner_id": owner_id }) # 分批写入,避免大数据量时连接超时 for i in range(0, len(valid_rows), 500): insert_customers_batch(valid_rows[i:i+500]) return {"success": len(valid_rows), "errors": error_rows}

注意这里分批写入,一次导入几千条数据,如果一条条插入会很慢,500条一批循环插入在性能和日志可读性之间算是比较平衡的。导入完成后最好返回一份错误报告,告诉用户哪些行失败、失败原因是什么,这个提示比单纯显示“导入完成”更有价值。

3.3 权限控制与数据隔离

权限模型我们用的是经典的RBAC加数据范围控制。用户在系统里有三个角色:销售、主管、管理员。销售的数据范围是“仅本人”,主管的数据范围是“本团队”,管理员的数据范围是“全部”。数据范围的判断是在后端查询层自动追加过滤条件,而不是在前端页面做按钮级别的隐藏,这是一个非常重要的原则,前端控制只是体验优化,后端才是真正的安全边界。

后端的权限过滤我用的是FastAPI的依赖注入,代码如下:

def get_visible_customers(user, db): query = db.query(Customer) if user.role == "admin": return query if user.role == "manager": return query.filter(Customer.owner_id.in_(get_team_member_ids(user))) return query.filter(Customer.owner_id == user.id)

这里有一个很容易被忽视的细节:数字ID在API接口里是可遍历的。比如销售A登录系统后,手动请求/customers/10086这个接口,如果后端只做了登录校验而没做归属校验,销售A就能看到销售B的客户信息。所以除了列表查询接口做数据范围过滤,详情接口、更新接口、删除接口也必须做对象级别的归属校验,每一条涉及数据操作的接口都不能漏。

权限测试的时候还有个技巧,不能用管理员账号测完就算完事,必须准备一个低权限的销售账号和一个主管账号,把三个角色的实际操作都过一遍。实际测试中我发现过不止一次主管账号能访问到其他团队客户的问题,这类问题光看代码不容易发现,必须用不同权限账号实际调接口才能暴露。

3.4 报表统计与仪表盘

报表模块给管理者看,核心指标是:商机总金额、赢单率、各阶段的转化率、团队个人的跟进量排名。最直接的做法是每次打开报表页面实时聚合数据库,但客户数和商机数据增长以后,实时聚合的查询延迟会越来越高,报表页面打开一次好几秒,体验非常差。

我们的解决方案是预聚合。写一个定时任务,每天凌晨跑一次,把当天的核心统计指标算好,写入一张独立的统计表。白天任何时间打开报表页面,直接读统计表里的数据就行,毫秒级响应。对于一些需要实时性的指标,比如今天的跟进记录数量,可以单独查一次,这部分数据量不大,性能压力也可控。

统计表的结构不需要太复杂,核心就是记录统计日期、统计维度和指标值。日更的频率对销售管理已经足够,管理者不需要看到分钟级别的实时变化,反而是一份稳定的日报更能帮助他们掌握团队整体状况。数据报表模块我个人的经验是,宁可指标少而准,不要指标多而乱,口径不统一的报表比没有报表更让管理者头疼。

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

4.1 数据关联错误与孤儿数据

CRM上线一段时间后最容易出现的脏数据问题,就是孤儿数据。所谓孤儿数据,就是子表里的记录指向了一个已经不存在的客户。我们的跟进记录表和商机表外键都设置了ON DELETE CASCADE,按理说删除客户时关联记录会被自动清除,但实际排查时依然发现过一部分跟进记录查询不到客户信息。

后来定位原因,发现是部分业务代码在删除客户时用了“软删除”,也就是给customers表加了一个deleted_at字段,删除时只是把时间戳填上,并没有真正删除主记录,而子表的外键级联只对物理删除生效。这个问题的本质是软删除和级联删除策略混用导致的。我们的处理方案是在所有查询语句中统一追加deleted_at IS NULL的条件,并且对删除接口做严格约束,物理删除只允许管理员操作,普通销售删除客户一律走软删除,保证数据可追溯。

4.2 权限越界与团队推行阻力

权限越界是CRM项目里最严重的合规风险。有一次我们上线新功能后做回归测试,发现一个接口竟然返回了其他部门的数据。排查之后确认是团队ID的关联条件写错了,用的团队归属跟客户所属ID不是同一个字段。这类问题靠人工测试很难做到全覆盖,后来我们引入了一个爬虫脚本,用不同权限的测试账号把所有API接口跑一遍,把请求结果的数据字段和允许访问的客户ID做比对,只要发现越界就构建失败。这个自动化检查脚本后来成了每次发版前必跑的环节。

相比技术问题,团队推行阻力更隐蔽也更难解决。销售对CRM的第一反应大概率是“又要填表了”,如果录入不能给他们带来即时价值,系统数据就越来越空,最后变成空壳。我们的做法是把“录入跟进”和“回访提醒”绑定在一起,销售在客户详情页写跟进记录的同时,系统自动生成一条下一条回访的待办提醒。销售每天上班打开系统看到的就是今天该干什么,而不是从上往下填一堆表格。这种“系统帮忙记事情”的感知建立起来之后,录入的意愿会明显提高。

4.3 性能优化注意事项

系统上线初期数据量小,性能问题完全暴露不出来。等客户数达到几万、跟进记录几十万条的时候,列表页会开始变慢。排查后发现主要瓶颈集中在几个地方,逐一说明一下。

第一是N+1查询。客户列表接口返回每条客户信息时都去查询一次跟进记录数量,导致几十家客户触发几十条SQL。优化方案是使用聚合查询,一次性查出所有客户的跟进数再合并结果。第二是SELECT出大量用不到的字段。跟进记录的summary字段在列表页根本不展示,但默认查询会把整条记录查出来,占用了大量内存和网络带宽,显式指定需要的字段就能解决。第三是索引失效。status字段在数据分布比较均匀的情况下,单独建索引的筛选效果很有限,建议和owner_id建联合索引,具体索引要看实际查询模式,不能盲目加。

给个参考索引方案:

CREATE INDEX idx_customers_owner_status ON customers(owner_id, status); CREATE INDEX idx_followup_customer_created ON follow_up_records(customer_id, created_at DESC);

优化完成后还要注意,不要让模糊查询以LIKE '%关键字%'的写法直接落到大表上,客户搜索功能我会建议引入PostgreSQL的全文检索或者外接搜索引擎,但如果数据量在十万级以内,前缀匹配索引也基本够用。性能优化没有银弹,核心思路是先慢查询日志定位再针对性优化,不要一上来就上缓存或者换数据库。

最后说一点个人感受。DeskcommCRM这个名字,当时起的初衷是“桌面沟通”的意思,真正落地后我发现,系统本质上就是把桌面上的沟通习惯固化了。技术选型、表结构设计、权限模型这些固然重要,但最关键的还是团队愿不愿意在每天的流程里坚持把数据录进去。工具做得再好,数据不进来都是白搭。想清楚这一层,再决定怎么设计你的CRM,方向基本就不会跑偏。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询