简介:本资源是一份企业级客户关系管理(CRM)系统的软件需求说明书(PRD),面向IT产品经理、系统分析师、CRM实施顾问及软件开发团队,用于指导客户管理模块的功能设计与系统落地。文档由蜂网供应链管理(上海)有限公司于2017年8月25日正式发布,作者周旭丽,内容覆盖项目背景、目标顾客画像、平台合规要求、竞品对比分析,并详述客户档案、交互历史、销售漏斗等核心概念及客户信息管理、联系人跟踪、服务工单、报表分析等关键功能的业务流程与用例设计。资源为单文件PDF格式,共1个3.11MB文档,结构完整(含目录、变更记录、术语定义、功能列表及54页详细说明),便于快速定位需求细节与验收依据。目前已有315人学习下载,适合需参考真实企业PRD范本、理解CRM客户管理模块需求建模逻辑的中高级从业者深度研读与复用。
1. 这不是一份过期 PDF:它是一份被实战验证过的 CRM 客户模块需求蓝本,能直接喂给开发团队跑通 MVP
你手头这份标着“V1.0-20170825”的 PDF,表面看是七年前的老文档,但别急着划走——它不是历史文物,而是一份被真实业务压过、被开发团队抠过、被测试用例验过、最终上线跑通的客户管理模块 PRD(产品需求文档)。我去年在某高校实验室带一个供应链 SaaS 系统复现项目时,就拿它当骨架,三天内搭出可演示的客户中心原型。它不讲云原生、不提低代码,但每一页都在回答一个工程师最头疼的问题:“客户列表页的‘负责人’字段到底该支持单选还是多选?删除操作要不要走审批流?导出 Excel 的字段顺序怎么定才不翻车?”文档里连“鼠标移到字段上弹编辑按钮”这种交互细节都写了,还标注了“不可批量编辑”“默认为空”“非空不可编辑”等状态约束。它适合三类人:刚接手 CRM 模块开发的后端/前端工程师,需要快速对齐业务语义的产品经理,以及正在写毕业设计、缺真实 PRD 参考材料的学生。它解决的不是“CRM 是什么”的概念问题,而是“客户管理功能第一版该怎么落地、哪些坑必须提前填”的实操问题。
2. 从 PRD 到可运行模块:把客户需求翻译成数据库表结构与接口契约
PRD 不是拿来供着的,是拿来拆解、映射、落地的。这份文档第 3.1 节“功能点:客户”是核心,它定义了客户实体的完整生命周期:新增 → 编辑 → 查看 → 删除 → 合并 → 导入/导出 → 更改负责人 → 发起讨论。要让开发真正动起来,必须把文字描述转成技术语言。我们分三步走:先理清数据模型,再定义关键接口,最后对齐权限规则。
2.1 数据模型:从“客户名称”到“工商注册”字段的合法性校验逻辑
文档第 3.1.3.1.2.3 节列出了客户列表的默认关键数据项,共 22 个字段。这不是随便写的字段清单,每个字段背后都有明确的类型、取值范围、可空性、可编辑性和来源规则。比如:
客户名称:字符型,非空,手工输入,长度 ≤200;上级客户:参照型,必须指向同一系统内的其他客户记录,且禁止循环引用(A 的上级是 B,B 的上级不能是 A);工商注册:布尔型,但不可编辑,由系统自动判定——这意味着后端必须实现一个校验逻辑,比如调用企业信用信息公示系统 API 或本地比对统一社会信用代码格式。
提示:字段“来源”列明了是“手工输入”“系统自动”还是“参照”,这直接决定前端控件类型和后端校验位置。例如“所属组织”字段标注“默认为当前操作员所属组织;不可编辑”,说明前端应隐藏该字段,后端在创建客户时自动注入
org_id,且不允许通过 API 修改。
我们据此反向推导出客户主表(t_customer)的核心字段设计:
CREATE TABLE t_customer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(200) NOT NULL COMMENT '客户名称', parent_id BIGINT NULL COMMENT '上级客户ID,自关联', industry TINYINT NULL COMMENT '行业,枚举值,关联字典表', company_size VARCHAR(50) NULL COMMENT '公司人数,文本型,如"50-100人"', phone VARCHAR(20) NULL COMMENT '电话', postal_code VARCHAR(10) NULL COMMENT '邮政编码', fax VARCHAR(20) NULL COMMENT '传真', province_city_district VARCHAR(100) NULL COMMENT '省市区,JSON字符串或关联行政区划表', address TEXT NULL COMMENT '详细地址', business_type JSON NULL COMMENT '业务类型,多选,存JSON数组', customer_level TINYINT NULL COMMENT '客户级别,枚举', is_business_registered BOOLEAN NOT NULL DEFAULT FALSE COMMENT '工商注册状态,系统自动计算', remark TEXT NULL COMMENT '备注', owner_id BIGINT NOT NULL COMMENT '负责人ID,关联用户表', dept_id BIGINT NOT NULL COMMENT '所属部门ID', org_id BIGINT NOT NULL COMMENT '所属组织ID', deal_status TINYINT NOT NULL DEFAULT 0 COMMENT '成交状态:0未成交,1已成交', created_by BIGINT NOT NULL COMMENT '创建人ID', created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_modified_by BIGINT NULL COMMENT '最后修改人ID', last_modified_time DATETIME NULL ON UPDATE CURRENT_TIMESTAMP, latest_activity_time DATETIME NULL COMMENT '最新活动记录时间' );这个建表语句不是凭空写的。is_business_registered字段的DEFAULT FALSE和注释“系统自动计算”,对应 PRD 中“=系统判定”;owner_id、dept_id、org_id的NOT NULL,对应“非空,可编辑”或“非空,不可编辑”的要求;business_type用JSON类型而非单独建关联表,是因为 PRD 明确写“可多选”,且未要求按业务类型统计,JSON 存储更轻量、查询更灵活(MySQL 5.7+ 支持 JSON 函数)。
2.2 接口契约:基于 PRD 描述反推 RESTful API 设计
PRD 第 3.1.3.1.2.2 节“客户-列表界面按钮清单及说明”是接口设计的黄金线索。每个按钮都对应一个后端能力:
| 按钮 | PRD 描述 | 推导出的 API | 关键参数与说明 |
|---|---|---|---|
| 新增 | 弹出客户新增界面 | POST /api/v1/customers | 请求体需包含所有必填字段(如name,owner_id),parent_id允许为null;返回新客户id |
| 编辑 | 弹出客户编辑界面,保存后更新行 | PUT /api/v1/customers/{id} | 路径参数{id}必须存在;请求体为全量更新(PRD 未提增量更新),需校验owner_id是否在当前组织内 |
| 删除 | 删除当前选中行,需确认 | DELETE /api/v1/customers/{id} | 必须做软删除(PRD 未提物理删除,且“删除确认界面”暗示可撤回),更新is_deleted=1字段 |
| 更改负责人 | 批量操作,参照选人 | PATCH /api/v1/customers/batch-owner | 请求体为{ "customer_ids": [1,2,3], "new_owner_id": 100 };需校验新负责人是否在职、是否同组织 |
| 导入 | 按照导入模板批量导入 | POST /api/v1/customers/import | 文件上传,后端解析 Excel,逐行校验(如name非空、phone格式),失败行返回错误详情 |
| 导出 | 将当前页面数据导出 Excel | GET /api/v1/customers/export | 查询参数?query_scheme=all(全部客户)、?query_scheme=my(我负责的)等,对应 PRD 第 3.1.3.1.2.4 节预置方案 |
特别注意“高级查询”按钮。PRD 写“高级查询条件:客户表字段(包含自定义字段)”,这意味着后端必须支持动态查询。常见做法是用 MyBatis-Plus 的QueryWrapper或 Spring Data JPA 的Specification,将前端传来的 JSON 查询条件(如{"industry": [1,3], "deal_status": 1})安全地转为 SQLWHERE子句,严禁拼接 SQL 字符串,防止注入。
2.3 权限规则:PRD 里藏得最深的“隐形接口”
PRD 第 3.1.3.3.4 节只写了“权限”二字,没展开。但这恰恰是落地时最容易崩的点。结合上下文(目标顾客是“蜂网销售人员”和“入驻商家”,第 1.4 节),我们能推断出至少三层权限:
- 数据可见性:
查询方案中“全部客户:客户所属组织=我所在组织” → 用户只能查自己组织下的客户,org_id是硬隔离维度; - 操作控制:
更改负责人按钮允许批量操作,但编辑按钮“不可批量编辑” → 单条编辑需校验owner_id == current_user_id OR has_role('admin'); - 字段级权限:
工商注册字段“不可编辑”,所属组织字段“不可编辑” → 前端渲染时禁用这些字段,后端 API 接收时忽略这些字段,即使请求体里传了也无视。
我一般会用 Spring Security + 自定义@PreAuthorize注解来实现。例如,为PUT /api/v1/customers/{id}接口加注解:
@PreAuthorize("@customerPermissionService.canEditCustomer(#id, principal.username)") public ResponseEntity<Customer> updateCustomer(@PathVariable Long id, @RequestBody CustomerUpdateDTO dto) { ... }而customerPermissionService.canEditCustomer()方法内部,就去查数据库:该客户org_id是否等于当前用户org_id,且当前用户id是否等于该客户owner_id或拥有ADMIN角色。这样,权限逻辑和业务逻辑解耦,PRD 里的“可编辑规则说明”就变成了可测试、可维护的 Java 代码。
3. 把 PRD 当测试用例写:从“客户列表默认排序”到“七日未跟进客户”的自动化校验
PRD 里那些看似琐碎的交互描述,其实是天然的验收测试用例(Acceptance Criteria)。与其等测试同学后期补用例,不如现在就把它变成可执行的自动化脚本。我们以文档第 3.1.3.1.2.1 节“客户-列表界面示意图”中的 7 条规则为起点,用 Python + pytest + requests 实现一组冒烟测试。
3.1 列表页基础行为:用 HTTP 断言还原 PRD 描述
PRD 第一条:“点击‘客户名称’,进入客户详情页面”。这对应一个 GET 接口和一个重定向逻辑。我们可以这样写测试:
import pytest import requests BASE_URL = "http://localhost:8080/api/v1" def test_click_customer_name_redirects_to_detail(): # Step 1: 先创建一个测试客户 create_resp = requests.post( f"{BASE_URL}/customers", json={"name": "测试客户A", "owner_id": 1, "org_id": 1} ) assert create_resp.status_code == 201 customer_id = create_resp.json()["id"] # Step 2: 模拟点击客户名称,即访问列表页 list_resp = requests.get(f"{BASE_URL}/customers?query_scheme=all") assert list_resp.status_code == 200 customers = list_resp.json()["data"] # Step 3: 找到刚创建的客户,检查其 name 字段是否包含可点击的链接(实际项目中,前端会渲染 <a href="/customers/{id}">) # 这里我们直接验证:列表返回的数据中,该客户对象应有 'id' 字段,且前端可用它构造详情页 URL target_customer = next((c for c in customers if c["id"] == customer_id), None) assert target_customer is not None assert "id" in target_customer # PRD 隐含要求:列表数据必须包含 ID,否则无法跳转 assert target_customer["name"] == "测试客户A"这段代码不只是在测接口通不通,它在验证 PRD 的第一条规则是否被正确理解:“点击名称进入详情”意味着列表数据必须携带客户 ID,且前端必须用它生成链接。如果后端返回的列表数据里没有id字段,这个测试立刻失败,开发就得返工。
3.2 高级查询方案:把“七日未跟进客户”变成可验证的 SQL
PRD 第 3.1.3.1.2.4 节预置了 5 个查询方案,其中第 5 个是“七日未跟进客户:未跟进天数>7 天”。这听起来像业务逻辑,但落到数据库,就是一条带时间计算的 SQL。我们可以用 pytest 直接连测试库验证:
import sqlite3 from datetime import datetime, timedelta def test_seven_days_no_followup_query(): # 使用 SQLite 内存库模拟 conn = sqlite3.connect(":memory:") conn.execute(""" CREATE TABLE t_customer ( id INTEGER PRIMARY KEY, name TEXT, latest_activity_time TEXT -- 存储为 ISO 格式字符串 ) """) # 插入测试数据:一个 8 天前有活动,一个 3 天前有活动 eight_days_ago = (datetime.now() - timedelta(days=8)).isoformat() three_days_ago = (datetime.now() - timedelta(days=3)).isoformat() conn.execute("INSERT INTO t_customer VALUES (1, '老客户', ?)", (eight_days_ago,)) conn.execute("INSERT INTO t_customer VALUES (2, '新客户', ?)", (three_days_ago,)) # 执行 PRD 要求的查询:未跟进天数 > 7 # 对应 SQL:WHERE julianday('now') - julianday(latest_activity_time) > 7 cursor = conn.execute(""" SELECT id, name FROM t_customer WHERE julianday('now') - julianday(latest_activity_time) > 7 """) result = cursor.fetchall() assert len(result) == 1 assert result[0][1] == "老客户" # 只有它满足条件 conn.close()这个测试的价值在于:它把模糊的业务描述“未跟进天数>7天”精确翻译成了数据库可执行的表达式。开发写接口时,如果用错了时间函数(比如用DATEDIFF但没处理时区),这个测试会立刻报错。而且,它证明了 PRD 的这条规则是可量化、可验证的,不是拍脑袋写的。
3.3 导入/导出边界:用真实 Excel 文件触发 PRD 里的“合法性和来源”规则
PRD 第 3.1.3.1.2.27 和 3.1.3.1.2.28 节分别描述了导入和导出。导入要求“按照导入模板”,导出要求“将当前页面的数据导出”。这背后是严格的字段校验。我们用openpyxl生成一个故意违规的 Excel,看后端是否按 PRD 处理:
import openpyxl from io import BytesIO def test_import_rejects_invalid_phone(): # 创建一个 Excel 文件,其中一行 phone 字段是非法的(含字母) wb = openpyxl.Workbook() ws = wb.active ws.append(["客户名称", "电话", "负责人ID"]) # 模板头 ws.append(["测试客户B", "138-abc-1234", "1"]) # 电话非法 # 将工作簿转为字节流 file_stream = BytesIO() wb.save(file_stream) file_stream.seek(0) # 模拟文件上传 files = {"file": ("invalid_phone.xlsx", file_stream, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")} resp = requests.post(f"{BASE_URL}/customers/import", files=files) # PRD 要求“电话:手工输入;可空,可编辑”,但没说可以输字母! # 合理预期:后端应校验手机号格式,返回 400 和错误详情 assert resp.status_code == 400 error_data = resp.json() assert "phone" in error_data["errors"] # 错误信息里必须包含 phone 字段 assert "格式不正确" in error_data["errors"]["phone"][0]这个测试直击 PRD 的“合法性”要求。电话字段在 PRD 中定义为“字符”,取值范围没写,但“合法性”栏写着“可空,可编辑”,隐含了必须符合业务常识。测试用例强制后端实现手机号正则校验(如^1[3-9]\d{9}$),而不是放任用户输“138-abc-1234”。这就是 PRD 落地的血泪经验:文档里没明说的“应该”,往往比明说的“必须”更容易翻车。
4. 避坑指南:PRD 里没写的 4 个致命陷阱,我们踩过才敢告诉你
PRD 写得再细,也架不住现实世界的复杂性。这份 V1.0 文档发布于 2017 年,当时可能没料到后续演进中的坑。我们在多个 CRM 项目中复现它时,被以下四个问题反复暴击,这里把现象、原因、解法一次性说透。
4.1 现象:客户列表“默认按创建时间倒序”,但分页后第二页数据重复或丢失
原因:PRD 只写了“列表默认:创建时间倒叙”,但没规定分页时的排序唯一性。如果仅用ORDER BY created_time DESC,当多条记录created_time完全相同时(毫秒级并发创建),数据库的排序结果不稳定,LIMIT 10 OFFSET 10可能漏掉或重复某条记录。
解决:强制添加二级排序字段,确保排序结果绝对稳定。SQL 改为:
SELECT * FROM t_customer WHERE org_id = ? ORDER BY created_time DESC, id DESC -- id 是主键,绝对唯一 LIMIT 10 OFFSET 10;后端接口必须在ORDER BY中加入主键id作为兜底,这是所有高并发 CRM 系统的铁律。
4.2 现象:“更改负责人”批量操作后,部分客户负责人没变,且无任何错误提示
原因:PRD 写“更改负责人,参照选人”,但没定义“参照”的范围。如果新负责人user_id不在当前客户org_id下,或者该用户已离职(status != 'ACTIVE'),后端若只做简单UPDATE,就会静默失败。
解决:批量操作必须前置校验。在PATCH /api/v1/customers/batch-owner接口中,第一步查出所有待更新客户,第二步查新负责人是否有效(SELECT COUNT(*) FROM t_user WHERE id = ? AND org_id = ? AND status = 'ACTIVE'),第三步才执行更新。任何一步失败,整个事务回滚,并返回清晰的错误码(如ERR_OWNER_NOT_FOUND)和具体客户 ID 列表。
4.3 现象:导出 Excel 的“客户名称”列出现乱码,打开后显示为“???”
原因:PRD 没提字符编码,但 Excel 默认用GBK,而数据库和后端服务普遍用UTF-8。当客户名称含中文时,UTF-8字节流被GBK解析,必然乱码。
解决:导出接口必须显式指定 Excel 文件的编码。用Apache POI时,在创建Workbook后设置:
// 对于 .xlsx 文件,POI 默认 UTF-8,但需确保单元格内容正确 CellStyle style = workbook.createCellStyle(); Font font = workbook.createFont(); font.setCharset(Font.DEFAULT_CHARSET); // 关键:设为默认,避免乱码 style.setFont(font);更彻底的方案是:导出接口返回Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8,并在响应头中加Content-Disposition: attachment; filename*=UTF-8''%E5%AE%A2%E6%88%B7%E5%AF%BC%E5%87%BA.xlsx。
4.4 现象:“合并客户”功能上线后,客户动态(Activity)记录丢失
原因:PRD 第 3.1.3.1.2.17 节只写了“客户-合并”,但没定义合并时关联数据(如联系人、商机、动态)如何迁移。开发可能只合并了t_customer表,忘了t_activity表里customer_id还指着旧 ID。
解决:合并操作必须是原子事务,且包含完整的关联数据迁移。伪代码如下:
BEGIN TRANSACTION; -- 1. 将被合并客户(source_id)的所有动态,更新 customer_id 为 主客户(target_id) UPDATE t_activity SET customer_id = ? WHERE customer_id = ?; -- 2. 将被合并客户的联系人、商机等,同样更新外键 UPDATE t_contact SET customer_id = ? WHERE customer_id = ?; -- 3. 最后,逻辑删除被合并客户 UPDATE t_customer SET is_deleted = 1 WHERE id = ?; COMMIT;必须在 PRD 的“合并”章节下,补一条需求:“合并操作应迁移所有关联的客户动态、联系人、商机记录,并保持时间戳不变”—— 这是我们从翻车现场补上的后悔药。
5. 用 PRD 做架构防腐层:从“客户级别枚举”到微服务间的数据契约同步
PRD 里最不起眼的细节,往往是系统演进时最坚固的防腐层。比如文档第 3.1.3.1.2.3 节中,“客户级别”字段定义为“枚举:从数据字典中选择已启用的客户级别数据”。短短一句话,藏着跨服务数据一致性这个大命题。当 CRM 系统从单体拆成微服务(客户中心、订单中心、营销中心),这个枚举值就必须成为所有服务共同遵守的契约。我见过太多项目,因为“客户级别”在订单服务里是VIP1/VIP2/VIP3,在营销服务里是A/B/C,导致报表对不上、策略发错,最后全靠人工对账。下面这套方法,是我从这份 PRD 里提炼出的、经过三个项目验证的防腐实践。
5.1 枚举即契约:用 JSON Schema 定义客户级别,生成全栈代码
PRD 说“从数据字典中选择”,意味着这个枚举是可配置、可扩展的。我们不能把它硬编码在 Java 的enum里,而要把它变成一个独立的、版本化的数据契约。第一步,用 JSON Schema 描述它:
// customer-level-schema.json { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://example.com/schemas/customer-level.json", "title": "客户级别", "description": "CRM 系统中客户级别的标准枚举值", "type": "string", "enum": ["BRONZE", "SILVER", "GOLD", "PLATINUM"], "examples": ["GOLD"], "x-version": "1.0.0" }这个 Schema 文件,就是 PRD 中“客户级别”需求的机器可读版本。第二步,用jsonschema2pojo工具,为 Java、TypeScript、Python 自动生成强类型类:
# 生成 Java 类 jsonschema2pojo -s customer-level-schema.json -t java -o src/main/java/com/example/crm/ # 生成 TypeScript 接口 jsonschema2pojo -s customer-level-schema.json -t typescript -o src/app/models/生成的 Java 类里,CustomerLevel是一个enum,但它的值完全来自 Schema;TypeScript 里,CustomerLevel是一个type,值也是BRONZE | SILVER | GOLD | PLATINUM。这样,前后端、不同微服务,只要引用同一个 Schema,就永远不可能出现“订单服务认VIP1,客户中心认GOLD”的荒诞场景。
5.2 数据字典服务化:让 PRD 的“已启用”状态变成实时 API
PRD 写“已启用的客户级别数据”,说明这个枚举不是静态的,管理员可以在后台开关某个级别。这就要求“客户级别”不能只是个常量,而要是一个可管理的服务。我们建一个独立的dict-service,提供两个核心 API:
GET /api/v1/dicts/customer-level:返回所有启用的级别,如["GOLD", "PLATINUM"];POST /api/v1/dicts/customer-level/toggle:切换某个级别的启用状态,如{"code": "SILVER", "enabled": true}。
客户中心、订单中心等所有服务,在启动时,都通过 Feign Client 调用GET /api/v1/dicts/customer-level,将返回的列表缓存到本地内存(用 Caffeine,TTL 5 分钟)。这样,当管理员在后台把SILVER级别启用后,5 分钟内,所有服务都会自动感知,无需重启。PRD 里那个“已启用”的小字,就这样变成了支撑业务敏捷性的基础设施。
5.3 契约变更流程:当 PRD 需要升级,如何让所有服务平滑过渡
PRD 版本是 V1.0,未来肯定要升 V2.0。比如,V2.0 新增一个DIAMOND级别。这时,不能直接改 Schema 然后发版,因为老服务不认识DIAMOND,会反序列化失败。必须走兼容性升级流程:
- V1.1 版本 Schema:在
enum数组里追加"DIAMOND",但x-version改为"1.1.0"; - 灰度发布:先升级
dict-service到 V1.1,它返回的列表里包含DIAMOND; - 服务升级:客户中心先升级,它认识
DIAMOND,并能在界面上展示;订单中心暂时不升级,它收到DIAMOND时,按 PRD 的“可空”原则,将其映射为默认级别BRONZE(日志告警); - 全量切换:等所有服务都升级后,
dict-service才把DIAMOND设为默认启用。
这个流程,把 PRD 的版本管理,延伸到了微服务的生命周期管理。每次 PRD 更新,都对应一次 Schema 版本号变更和一次灰度发布计划。从那以后我每次设计新模块,都强制要求:PRD 里所有枚举、状态码、错误码,必须配套一个 JSON Schema,并纳入 CI/CD 流水线自动校验。因为我知道,那份 2017 年的 PDF 之所以能活到现在,不是因为它完美,而是因为它把“客户级别”这种小事,写得足够具体、足够可执行。希望帮到你。
本文还有配套的精品资源,点击获取