☰
客户管理系统ER图设计:实体拆分与建表SQL落地指南
2026/10/9 19:39:27 网站建设 项目流程

简介:客户管理系统ER图文档,面向数据库设计学习者、软件工程专业学生及系统分析人员,用于快速理解客户管理业务中的数据实体、属性及其关联关系。文档基于标准ER模型,给出客户、订单、产品三类核心实体,并定义了客户编号、订单编号、产品价格等关键属性,以及客户与订单、订单与产品之间的一对多关系,可作为数据库概念设计、软件需求建模和业务流程梳理的参考底稿。资料为单个doc文件,压缩包整体仅184KB,轻量易用,便于直接阅读、标注或打印。目前已有604人学习浏览,适合正在做课程设计、毕业设计或准备数据库相关认证的读者参考借鉴。借助ER图,可快速提取客户管理系统的核心数据模型框架,辅助后续逻辑结构设计与数据库建表。

1. 拿到“客户管理系统ER图(1).doc”后,先别急着改图

你手里只有一份“客户管理系统ER图(1).doc”,双击打开,满屏是Word自选图形拼出来的矩形和箭头。这就是很多客户管理系统数据库设计的起点:图是画给评审看的,建表才是真正的战场。反直觉的一点是,这份doc最值钱的不是那张图,而是图背后没写出来的实体边界——客户和联系人是两张表还是塞在一张里,商机要不要独立建模,跟进记录留不留历史。客户管理系统ER图要做的事,就是把客户、联系人、商机、订单、跟进这些业务对象之间的血缘关系,在写第一行建表SQL之前用实体关系模型定死。吃透它,你才知道外键往哪放、唯一索引怎么加、哪些值只能进字典表。这篇适合正在搭CRM、以及接手一份来历不明的doc就得开始建库表的开发看。

2. 实体怎么拆:客户管理系统的六个实体边界

ER图第一步不是打开画图工具,而是把"有哪些实体"这件事定下来。客户管理系统看着业务简单,实际上实体边界特别容易画混:客户和联系人是不是一回事、商机要不要单飞、跟进记录算不算日志——这些问题没定清楚,后面的外键和索引全是错的。

2.1 先回答六个业务问题,再决定画几张图

我画ER图前会先列一张"业务问题清单",让每个实体都能回答至少一个真实问题。回答不上的实体砍掉,回答不了的问题就是缺失的实体或字段。对客户管理系统,我一般先从六个问题起步。

业务问题对应实体关键字段
这家客户叫什么、归哪个销售客户主表 customer客户编号、名称、类型、归属销售
客户下面有哪几个对接人联系人表 contact姓名、职位、电话、是否主联系人
这个客户有几个在谈项目商机表 opportunity商机名称、金额、阶段、预计成交时间
销售最近对客户做了什么跟进记录表 follow_up_record跟进方式、内容、下次跟进时间
哪些销售能看能改这个客户客户共享表 customer_share客户、被共享人、权限类型
客户来源、等级这些值从哪来数据字典表 sys_dict字典编码、字典值、排序

每一行从问题倒推字段,字段再从问题倒推必要性。比如"客户归哪个销售"这个问题,如果系统允许一个客户由多人协作,就不能只放一个owner_user_id,得额外拆共享表;如果允许客户被回收再分配,还得再加一个公海池相关的状态字段。问题清单决定了实体粒度,也决定库表结构稳定程度,这一步省掉,后面建表必返工。

2.2 客户和联系人为什么必须拆表

最常见的草图错误,是把联系人画进客户实体里。客户在建模上是登记主体,可以是一家公司,也可以是一个自然人;而联系人是能打电话找到的具体的人。一家企业客户通常同时有老板、财务、业务对接人三个人,如果联系人只是客户实体的几个属性,客户表里就只放得下一组phone、wechat,第二个联系人出现时数据直接无处可写。

联系人应该作为客户下的从实体存在,客户与联系人是1:N关系。我在联系人表里加一个is_main_contact字段,标识"默认联系人",用于报表里取数。归属销售owner_user_id放客户表,不放联系人表,因为客户归属是客户维度的属性,换了联系人不应改变归属关系。判断一对属性要不要拆表的经验就一条:同一套属性在业务上能否重复出现,能重复,就拆子表。

2.3 商机、跟进记录、字典表:三块容易画错的区域

商机独立成表是第二道坎。同一个客户一年可能同时推三个项目,每个项目金额不同、阶段不同,A项目谈崩了B项目还活着。如果把amount、stage直接挂到客户表,就只剩最后一组值,历史过程全部丢失。客户和商机是1:N,商机再对产品做明细,就是又一个1:N,ER图上这两层最好都画出来。

跟进记录是典型的弱实体:没有客户就没有跟进,它的存在完全依赖父实体。但弱不等于可以塞进客户表,它是流水,是销售过程分析的数据源。"最近30天没跟进的高意向客户"这类报表,全靠follow_up_record的create_time和next_follow_time来筛。还有一类容易被忽略的是字典表,客户来源、等级、行业,这些值在ER图里不要画成客户属性下的一堆可选值,单独画一个sys_dict实体,用编码关联。枚举写死在代码里,改一次值就要发一次版,字典表能让这部分代价降到零。

3. 把ER图画出来:工具选型与三遍画法

实体边界想清楚,才轮到画图。很多同学拿到doc后就地修改,在Word里拖矩形、拉箭头,改到一半发现图比SQL还难维护。这个环节的关键不是画得好看,而是画完能不能改、能不能交给下一个人接着维护。

3.1 工具选型:别把Word自选图形当建模工具

打开那份doc,你会看到每个矩形都是一个独立的文本框,箭头是单独画的线条。想改一个字段名,得先按Ctrl逐个点选,改完还要担心文字溢出不换行。这就是自选图形做ER图的死穴:它没有结构化信息,图里的几个方块只是一堆散装图形,机器读不懂,人也很难维护。

工具是否免费能否自动检查适合场景
Word自选图形随办公套件附带否给文档配插图,不适合建模
draw.io免费弱,仅网格对齐快速重画、导出方便,我首选
MySQL Workbench免费有正向/逆向工程直接从库表生成ER图
PowerDesigner商业授权强企业级建模,团队统一标准

我的习惯是拒绝在doc里直接改图。客户要求必须交Word版,那就用draw.io画完导出到Word,源文件保留成可编辑的建模文件。draw.io免费、无安装负担、导出格式多,重画这份客户管理系统的ER图半小时足够。

3.2 三遍画法:第一遍粗实体、第二遍填属性、第三遍标基数

重画不要想着一遍到位,我习惯分三遍。第一遍只放实体矩形和连线,线不用箭头,先把实体数量和关系骨架摆出来。第二遍往矩形里填字段,每个字段都要标PK、UK、FK。第三遍专门处理关系:在连线上标基数1:1、1:N、N:M,并且把外键字段名直接写在关系线旁边,不许只画一条线然后全靠猜。

客户表 rectangle [PK] id BIGINT UNSIGNED customer_no VARCHAR(32) UK customer_name VARCHAR(128) owner_user_id BIGINT FK -> sys_user.id deleted TINYINT create_time DATETIME

字段排列约定:主键第一行,业务唯一键第二行,外键用"FK -> 表.字段"注明。这段文本既是画图依据,也顺手变成后续建表SQL的草稿。第三遍的基数标注不能偷懒,比如customer和contact的关系线上必须写"1:N,外键在contact.customer_id",写清楚之后建表时外键方向不会搞反。

3.3 从doc里把图抄出来:一张手抄表解决黑匣子

收到的doc如果里面全是自选图形,没有工具能直接把它转成模型,常见做法是手工抄一遍。别嫌原始,抄录过程本身就是一次字段梳理。先把原文件另存为docx备份,然后双击每个矩形复制文字,按下面两张表填。

实体名字段名类型主键唯一外键指向必填说明
customeridBIGINT是否无是逻辑主键
customercustomer_noVARCHAR(32)否是无是客户编号
contactcustomer_idBIGINT否否customer.id是所属客户

关系表单独抄一份:来源实体、目标实体、基数、外键所在实体、关联字段、业务含义。抄完对照doc里的线条逐条核一遍,确认没有漏画的关系。如果doc里的ER图已经是一张图片,没有可复制的文本,直接照着重画更快,不用纠结解析。抄录表才是后面的"后悔药",任何时候字段对不上,回来翻表不看图。

4. 从ER图到建表SQL:翻译规则与核心DDL

图画完,真正的落地动作是把模型翻译成建表SQL。翻译规则固定、机械,但细节里全是经验:主键用自增id还是业务编号做主键,外键建不建物理约束,逻辑删除字段和唯一索引怎么共存,每一步都影响后续开发。

4.1 四句翻译规则

ER图元素SQL对象说明
实体一张表表名单数、全小写下划线
属性一个字段字段名与表名同规则
1:N关系N端表加外键字段外键字段名一般为表名单数_id
M:N关系拆中间表中间表放两个外键加业务字段

主键我通常用BIGINT UNSIGNED自增id,业务编号单独加唯一索引。原因是客户编号customer_no这类字段会随业务流程变化,比如编号规则调整、前缀重排,用业务字段做主键以后改起来极其痛苦。外键方向按基数决定:1:N外键一定放在N端,比如联系人的customer_id;M:N拆中间表后,两个外键都放中间表。生产环境我一般只建索引不建物理外键约束,外键关系由应用层保证,理由在避坑章节细说。

4.2 核心三张表的DDL:客户表、联系人表、跟进记录表

下面以MySQL语法为例,给出客户管理系统最核心的三张表。这套结构可以直接套用,也适合当作评审别人设计时的对照基线。

-- 客户主表:一个客户是一个登记主体,公司或个人 CREATE TABLE customer ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '逻辑主键', customer_no VARCHAR(32) NOT NULL COMMENT '客户编号,业务唯一标识', customer_name VARCHAR(128) NOT NULL COMMENT '客户名称', customer_type TINYINT NOT NULL DEFAULT 1 COMMENT '1=企业客户 2=个人客户', owner_user_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '归属销售,指向sys_user.id,0=未分配', source_type VARCHAR(32) DEFAULT NULL COMMENT '来源,指向字典表编码,如adv/visit/referral', level TINYINT NOT NULL DEFAULT 1 COMMENT '客户等级 1/2/3', phone VARCHAR(32) DEFAULT NULL COMMENT '登记电话,仅作总机', remark VARCHAR(512) DEFAULT NULL COMMENT '备注', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0=正常 1=已删除', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_customer_no (customer_no), KEY idx_owner_user_id (owner_user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户主表';

owner_user_id默认0而不是NULL,是因为NULL在WHERE条件、JOIN关联里容易产生意外结果,用0表示"未分配"语义更干净。customer_no单独唯一索引,业务上保证编号不重复,与逻辑主键id解耦。逻辑删除标记deleted默认0,所有查询都带deleted=0条件,这个字段和唯一索引的兼容问题在第5章展开。

-- 联系人表:客户下的对接人,1:N的N端 CREATE TABLE contact ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '逻辑主键', customer_id BIGINT UNSIGNED NOT NULL COMMENT '所属客户,FK -> customer.id', contact_name VARCHAR(64) NOT NULL COMMENT '联系人姓名', position VARCHAR(64) DEFAULT NULL COMMENT '职位', phone VARCHAR(32) DEFAULT NULL COMMENT '手机号', wechat VARCHAR(64) DEFAULT NULL COMMENT '微信号', is_main_contact TINYINT NOT NULL DEFAULT 0 COMMENT '1=主联系人', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_customer_id (customer_id), UNIQUE KEY uk_customer_phone (customer_id, phone) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='联系人表';

联系人表把customer_id放第一索引位,因为绝大多数查询是"查某客户下的联系人"。唯一索引(customer_id, phone)防止同一客户下手机号重复录入,但MySQL对唯一索引中的NULL值不做去重,因此phone允许为空会造成多个空手机号行,业务上要通过程序约束至少填一个联系方式。

-- 跟进记录表:销售过程流水,弱实体 CREATE TABLE follow_up_record ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '逻辑主键', customer_id BIGINT UNSIGNED NOT NULL COMMENT '被跟进的客户', contact_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '本次对接的联系人,0=未指定', follow_type VARCHAR(32) NOT NULL COMMENT '方式: phone/visit/wechat', content VARCHAR(1000) NOT NULL COMMENT '跟进内容', next_follow_time DATETIME DEFAULT NULL COMMENT '计划下次跟进时间', follow_user_id BIGINT UNSIGNED NOT NULL COMMENT '跟进人', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (id), KEY idx_customer_id (customer_id), KEY idx_follow_user_id (follow_user_id), KEY idx_next_follow_time (next_follow_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟进记录表';

这张表不需要update_time,因为跟进记录是流水,业务上不允许修改历史内容。next_follow_time单独上索引,支撑"今天该跟进谁"这类待办查询。create_time用数据库默认值而不是应用传值,避免各服务器时钟不一致造成时间混乱,这个习惯后面还会用到。

4.3 多对多关系拆表:客户协作共享表

一个客户多个销售协作,一个销售负责多个客户,这是典型的M:N。ER图上直接连线会让物理表无从下手,常见做法是拆中间表。客户共享场景下,中间表不只是两个外键,还要带权限类型,解决"谁能看、谁能改"。

-- 客户共享表:customer和sys_user的多对多拆分子表 CREATE TABLE customer_share ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '逻辑主键', customer_id BIGINT UNSIGNED NOT NULL COMMENT '被共享的客户', user_id BIGINT UNSIGNED NOT NULL COMMENT '被共享人', perm_type TINYINT NOT NULL DEFAULT 1 COMMENT '1=只读 2=编辑', share_user_id BIGINT UNSIGNED NOT NULL COMMENT '共享发起人', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '共享时间', PRIMARY KEY (id), UNIQUE KEY uk_customer_user (customer_id, user_id), KEY idx_user_id (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户共享表';

唯一索引(customer_id, user_id)保证同一客户对同一人只共享一次,重复共享直接报错,由程序提示"已共享"。查询客户详情时要展示所有有权限的销售,走customer_id索引;查询某销售名下能看的客户,走user_id索引。M:N拆表后,中间表里的业务字段就是这段关系自己的属性,比如perm_type,千万不要把这类字段放到customer表里,否则同一个人不同客户不同权限就完全没法表达。

4.4 把字段手抄表批量转成DDL草稿

有了第3章的字段抄录表,又嫌一张张写DDL慢,可以写个小脚本生成草稿。前提是抄录表导出为TSV或CSV,列固定为:实体、字段名、类型、主键、唯一、外键、必填、默认、说明。如果doc里的字段本身就是Word表格,先另存为HTML再用表格工具转CSV,比手敲快。

import csv import sys def generate(csv_path): """读取字段抄录表,输出CREATE TABLE草稿,只负责字段和主键""" with open(csv_path, encoding="utf-8-sig") as f: rows = list(csv.DictReader(f)) tables = {} for r in rows: tables.setdefault(r["实体"].strip(), []).append(r) for table, cols in tables.items(): print(f"CREATE TABLE `{table}` (") fields = [] for c in cols: nullable = "NULL" if c["必填"].strip() == "否" else "NOT NULL" default = "" if c["默认"].strip(): default = f" DEFAULT {c['默认'].strip()}" comment = c["说明"].strip().replace("'", " ") fields.append( f" `{c['字段名'].strip()}` {c['类型'].strip()} " f"{nullable}{default} COMMENT '{comment}'" ) pk_cols = [c["字段名"] for c in cols if c["主键"].strip() == "是"] if pk_cols: fields.append(f" PRIMARY KEY (`{'`, `'.join(pk_cols)}`)") else: fields.append(" -- 警告: 缺少主键") print(",\n".join(fields) + "\n);\n") if __name__ == "__main__": generate(sys.argv[1])

运行方式:python gen_ddl.py fields.csv。脚本只处理字段、必填、默认、主键这几项,外键、唯一索引、联合索引全部不生成。这是有意为之:脚本输出是草稿,用来省去重复打字的体力活,设计决策必须人工review后再补索引和外键。真实落地时,我往往把唯一索引和外键直接写进Review注释,而不是让脚本自动生成,因为这种自动化最容易把ER图上标错的基数原样带进数据库。

5. 客户管理系统ER图避坑:五个高频坑的现象、原因与处理

这部分是社区里反复出现的翻车场景,每一条我都见过不止一次。现象描述、根因分析、处理方式按顺序写,方便你直接对照排查。

5.1 联系人字段进客户表:一张表想装两套人的数据

现象:客户表里躺着contact_name、contact_phone、contact_position三列,业务方反馈"第二个联系人加不进去",只能把第一个人的信息覆盖掉。

原因:建模时把"客户"和"联系人"当成同一个对象,认为一个客户对应一个人就够了。等客户是家30人的公司时,表结构直接卡死。

处理:拆出contact表,客户表只保留总机phone之类的登记电话。已上线数据用INSERT SELECT把客户表里的联系人字段迁移到contact表,customer_id回填,is_main_contact设为1。迁移后客户表这三列删除,后续所有联系人查询走contact表。设计期判断方法:在ER图关系线上写清楚1:N,别画成属性。

5.2 逻辑删除和唯一索引打架:删掉的数据堵住新增的路

现象:客户A被逻辑删除后,再录一个同名同编号的客户B,插入报错Duplicate entry,错误码1062。

原因:deleted只是把行标记为1,行还在表里,唯一索引uk_customer_no依然生效。逻辑删除是给查询用的"软删除",数据库约束可不知道这层业务语义。

处理:方案一是唯一索引改成(customer_no, deleted),但同一customer_no删除两次会再次冲突;方案二是删除时改写业务编号,比如把customer_no更新为原值加_del_id后缀,这样原编号释放,真实数据又保留痕迹。我推荐方案二,因为系统里客户编号一旦可释放,后续导入、重录都简单。另外,deleted字段不要加入所有唯一索引,只加在真正需要"保留历史编号"的业务字段上。

5.3 商机被当作客户的一排字段:阶段和金额互相覆盖

现象:客户表出现opportunity_amount、opportunity_stage、expected_close_time三列,销售在系统里只能记录当前一个商机,历史商机全部丢失,管理层看不到转化率。

原因:设计者把"客户当前状态"和"客户的商机流水"混为一谈。客户状态可以冗余在customer表,但商机本身必须独立。状态是当前值,商机是过程数据,性质完全不同。

处理:拆opportunity表,customer_id关联客户,amount用DECIMAL(18,2),stage用字典编码或状态机。统计"这个季度成交金额""哪些商机推进超过60天还没关单"全部基于opportunity表。customer表里的owner_user_id仍然保留,它表示客户的归属销售,和商机表里的负责人不冲突。

5.4 在doc里直接改ER图:改一个字段牵动整个版式

现象:想给联系人加一个birthday字段,双击文本框、输入文字、回车,结果矩形变大,整个图错位,后面的箭头全歪了。改完保存,下次打开还是同一个维护黑洞。

原因:doc里的ER图是一堆自选图形,没有结构约束。它连"字段属于哪个实体"这个信息都不存在,只是文字恰好被放在矩形内部,机器完全无法识别。

处理:这份doc只当存档,不再当编辑对象。把实体、字段、关系抄到表格里作为唯一事实来源,用draw.io重画,画完导出图片或PDF用于汇报。如果需要交付可编辑的Word版,draw.io的导出结果可以嵌入docx,但源建模文件必须另存,绝不能把Word当模型维护。顺便说一句,收到"(1)"这种带括号编号的文件名,第一件事是核对版本,别拿旧图画新表。

5.5 金额用浮点、时间靠手传:报表数据翻车是玄学

现象:多个订单金额SUM出来是99999.9,差了0.1;同一条记录的create_time在不同服务器查出来差8小时。

原因:金额字段用了FLOAT或DOUBLE,浮点数二进制无法精确表示十进制小数,累加误差相乘后会放大。时间字段用了TIMESTAMP或让应用代码传值,前者有时区转换问题,后者依赖应用服务器时钟。

处理:金额一律DECIMAL(18,2),涉及折扣、税率再按业务扩大精度。所有时间字段统一DATETIME,创建时间用CURRENT_TIMESTAMP默认值,不让应用手传。这个习惯要从ER图的字段类型标注开始,画图时就把类型写死,别到建表才拍脑袋。数据和预期对不上时,先查字段类型,八成是浮点。

6. 验证ER图的三把尺子:覆盖度、一致性、可演进性

画完图、抄完表、建完草稿,先别急着转文档,用三把尺子量一遍。尺子不够,图看着再整齐也是纸糊的。

第一把尺子叫覆盖度。把第2章的业务问题清单重新拿出来,逐条问ER图。比如"最近30天没跟进的高意向客户有哪些",这张图能不能答上来:客户有level和owner_user_id,跟进记录有create_time和next_follow_time,两个实体JOIN一下就能查,说明覆盖到位。如果这个问题需要左拼右凑还缺字段,那就是图不完整。第二把尺子叫一致性。逐个关系核对基数和外键方向:customer和contact之间画了1:N,外键就必须在contact表里写着customer_id;customer和sys_user画了共享的M:N,就必须存在中间表。关系线上不写外键字段名的图,直接打回重画。第三把尺子叫可演进性。想想下周产品要加"客户年营业额"字段,要动几张表;下个月要支持公海池划分,是加字段还是加表。改动落到一张表内是正常,落到三张表以上说明实体边界画错了。

检查项通过标准
覆盖度每条业务问题都有实体和字段承接,不缺不冗
一致性所有1:N外键在N端,所有M:N有中间表
演进性新增一个客户属性不需要动三张以上的表
命名表名、字段名全小写下划线,主键统一id

我曾为了赶进度,跳过第三遍基数标注直接建表,结果把contact和customer的外键方向画反,上线后客户详情页查不出联系人,排查了大半天。后来给自己定了条规矩:关系线上不写外键字段名和外键方向的ER图,不算画完。你拿到任何一份"某某ER图.doc",优先做的永远是把它变成字段表和关系表,图只是个视图,数据模型才是活的东西。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询