基于54页CRM PRD的客户管理二次开发实战:从需求到落地
简介:这份PDF是蜂网供应链管理(上海)有限公司于2017年8月发布的客户关系管理CRM客户管理软件需求说明书V1.0,面向产品经理、需求分析师及CRM系统开发测试人员,用于指导客户管理模块从需求定义到开发落地的全过程。文档共54页,系统梳理了项目背景与目标、目标顾客、平台政策与竞品分析,并给出名词解释、业务流程、系统与角色用例、功能列表及基本流程等方案概述,覆盖客户档案、交互历史、销售漏斗等核心概念,以及客户信息录入管理、联系人跟踪、销售预测、市场营销自动化、客户服务与报表分析等模块需求,同时涉及数据集成、移动支持、安全性与接口等技术要求。资源包仅含1个PDF文件,大小约3.11MB,结构完整、目录清晰,便于按章节检索。已有316人学习,适合需要参考真实企业级CRM需求文档范本、梳理客户管理功能边界或撰写同类PRD的读者研读借鉴。
1. 从一份 54 页的 CRM 客户管理 PRD 说起:它到底能落地成什么
翻到这份文档第 10 页,客户列表界面的交互说明写得比很多代码注释还细:点客户名称进详情、点网址跳外链、列表默认按创建时间倒序、显示列可自定义但客户名称必选、鼠标悬停字段出现编辑按钮失焦即存。这不是一份讲概念的 PPT,而是一份能直接照着建表、画原型、写接口的客户管理需求说明书。它把 CRM 里最核心也最容易做烂的一块——客户主数据——从列表、新增、编辑、详情、合并、导入导出到查重规则,一条条拆到了字段级。适合谁?正在做 CRM 二次开发的后端和前端、需要把需求翻译成表结构和接口的产品技术、以及想拿一份真实业务文档练手建模的开发者。下面我按“这文档定义了什么 → 怎么把它跑成系统 → 哪里会翻车 → 怎么改”的顺序拆。
2. 需求文档的骨架:从功能列表到客户主数据的建模路径
2.1 文档分层结构与优先级约定
这份 PRD 的组织方式很典型,值得先摸清它的分层逻辑,后面建模才不会漏。第 1 章项目介绍交代需求来源(直接为 PRD 等级)、背景、目标顾客(内部销售人员 + 入驻商家);第 2 章方案概述放名词解释、业务流程、系统用例、角色用例、功能列表、业务单据;第 3 章才是产品详细需求,全部压在“客户”这一个功能点上,从 3.1.1 业务定义一路铺到 3.1.4 设置。功能列表里用 P1/P2/P3 标优先级,P1 最高,P2/P3 视开发资源调整到下一期——这条约定很关键,它意味着客户主数据是 P1 必做,而线索获取、统计分析、销售漏斗被明确划到二期。
建模时我一般按这个优先级切迭代:一期只做客户 CRUD + 列表查询方案 + 详情页聚合 + 导入导出 + 查重,二期再碰线索分配和漏斗。文档 1.3 节把二期功能列得很清楚:客户通讯信息自动读取(邮件、电话、短信进动态)、线索获取和分配、统计分析、销售漏斗。这些在一期表结构里要预留字段但不必实现,否则后期加表改起来很痛。
2.2 客户主数据的关键字段与约束
第 3.1.3.1.2.3 节的“客户列表-默认关键数据项”其实是整份文档的信息密度最高处,它把每个字段的类型、取值范围、合法性、来源都定了。我把它整理成建表时直接能用的约束表:
| 字段 | 类型 | 约束 | 来源 |
|---|---|---|---|
| 客户名称 | 字符 | 非空,≤200 字符,可编辑 | 手工输入 |
| 上级客户 | 参照 CRM 客户 | 可空,不允许互为上下级 | 手工输入 |
| 行业 | 枚举 | 取数据字典已启用项 | 手工输入 |
| 业务类型 | 枚举 | 可多选 | 手工输入 |
| 客户级别 | 枚举 | 取数据字典 | 手工输入 |
| 工商注册 | 布尔 | 非空,不可编辑,系统判定 | 系统自动 |
| 负责人 | 参照人员 | 非空,可编辑 | 手工输入 |
| 所属部门 | 参照部门 | 非空,默认当前用户部门 | 手工输入 |
| 所属组织 | 参照组织 | 默认当前操作员组织,不可编辑 | 系统自动 |
| 成交状态 | 枚举 | 未成交/已成交,默认未成交 | 手工输入 |
| 创建人/时间、最后修改人/时间 | 字符/日期时间 | 不可编辑 | 系统自动 |
| 最新活动记录时间 | 日期时间 | 不可编辑 | 系统自动 |
这张表里有两个坑要提前注意。一是“上级客户不允许互为上下级”,这不是简单的外键,而是要在保存时做环检测,A 的上级是 B、B 的上级又指回 A 必须拦住。二是“所属组织不可编辑、默认当前操作员组织”,说明这是个多组织隔离的系统,所有查询都要带组织维度过滤,否则数据串组织是血泪教训。
2.3 查询方案与高级查询的设计
3.1.3.1.2.4 节预置了五个查询方案,这是列表页的默认视图,直接决定后端接口怎么设计:
- 全部客户:客户所属组织 = 我所在组织
- 我负责的客户:负责人 = 我本人
- 我参与的客户:团队成员包含我且负责人 ≠ 我
- 我关注的客户:我已加关注
- 七日未跟进客户:未跟进天数 > 7
前四个是权限维度的过滤,第五个是时间维度的业务规则。落到接口上,我一般把查询方案做成一个view_type参数,后端用策略模式分发过滤条件,而不是让前端拼一堆 where。高级查询(3.1.3.1.2.5)则要求支持客户表字段含自定义字段,还要叠加团队成员、是否关注等关联条件——这意味着查询构造器不能只认主表列,得能 join 团队成员表和关注表。自定义字段的存在还要求客户表之外有一张扩展字段值表,常见做法是customer_field(字段定义)+customer_field_value(客户 ID + 字段 ID + 值)的 EAV 结构,查询时动态拼条件。
-- 客户主表:组织隔离 + 软删除是底线 CREATE TABLE crm_customer ( id BIGINT PRIMARY KEY, org_id BIGINT NOT NULL, -- 所属组织,查询必带 name VARCHAR(200) NOT NULL, -- 客户名称,非空≤200 parent_id BIGINT, -- 上级客户,保存时做环检测 industry VARCHAR(64), -- 行业,字典值 biz_type VARCHAR(255), -- 业务类型,多选存逗号分隔或关联表 level VARCHAR(32), -- 客户级别 is_registered TINYINT NOT NULL DEFAULT 0, -- 工商注册,系统判定不可编辑 owner_id BIGINT NOT NULL, -- 负责人 dept_id BIGINT NOT NULL, -- 所属部门 deal_status TINYINT NOT NULL DEFAULT 0, -- 0未成交 1已成交 last_active_at DATETIME, -- 最新活动记录时间 created_by BIGINT, created_at DATETIME, updated_by BIGINT, updated_at DATETIME, is_deleted TINYINT NOT NULL DEFAULT 0 );这段建表语句把文档里的约束直接翻译成了 DDL:org_id非空保证组织隔离,name长度对齐 200 字符上限,is_registered默认 0 且业务层禁止更新,deal_status默认未成交。biz_type多选这里给了逗号分隔的简化写法,如果后续要按业务类型精确筛选,建议改成crm_customer_biz_type关联表,否则LIKE '%xx%'会误命中。parent_id的环检测放在服务层,保存前向上遍历祖先链,遇到自己就拒绝。
3. 把 PRD 跑成系统:列表、详情、合并三条主链路的实现
3.1 客户列表接口:查询方案 + 分页 + 字段自定义
列表页是用户每天打开次数最多的页面,文档 3.1.3.1.2.1 把交互定得很死:默认创建时间倒序、默认查询方案“全部客户”、显示列可自定义但客户名称必选、客户名称搜索对全部可见数据模糊匹配。后端接口要同时接住这几件事,我一般设计成这样一个入参结构:
# 客户列表查询:方案过滤 + 关键字 + 自定义列 + 分页 def list_customers(req, current_user): q = Customer.query.filter_by(org_id=current_user.org_id, is_deleted=0) # 1. 查询方案分发,对应 PRD 预置的五个方案 scheme = req.get("scheme", "all") if scheme == "mine": q = q.filter(Customer.owner_id == current_user.id) elif scheme == "joined": q = q.join(TeamMember).filter( TeamMember.user_id == current_user.id, Customer.owner_id != current_user.id) elif scheme == "followed": q = q.join(Follow).filter(Follow.user_id == current_user.id) elif scheme == "stale7": q = q.filter(Customer.last_active_at < days_ago(7)) # 2. 客户名称模糊搜索,作用于全部可见数据 if req.get("keyword"): q = q.filter(Customer.name.like(f"%{req['keyword']}%")) # 3. 自定义显示列:客户名称必选,其余按用户配置 columns = req.get("columns") or DEFAULT_COLUMNS if "name" not in columns: columns.insert(0, "name") # 4. 默认创建时间倒序,分页返回 total = q.count() rows = q.order_by(Customer.created_at.desc()) \ .offset((req.page - 1) * req.size).limit(req.size).all() return {"total": total, "columns": columns, "rows": [pick(r, columns) for r in rows]}逻辑说明:scheme参数对应五个预置方案,用 if 分发而不是让前端传 SQL 条件,安全且可控;keyword只匹配name,符合文档“按客户名称搜索”的约定;columns做了一次兜底插入,保证客户名称永远在列里,这是文档明确要求的必选字段;排序固定created_at desc,因为文档写了“列表默认创建时间倒叙”。参数上page/size控制分页,scheme缺省为all,columns缺省走系统默认列集。这里有个容易忽略的点:joined方案要 join 团队成员表,如果团队成员数据量大,记得在(user_id, customer_id)上建联合索引,否则列表一卡就是几秒。
3.2 客户详情页:一个聚合接口撑起动态、相关、团队
3.1.1 节把客户详情定义为“客户数据中心”,要能新建和查看联系人、商机、动态、合同、订单、团队成员。如果每个 Tab 一个接口,详情页一打开就是七八个请求,移动端体验会很差。我的做法是一个聚合接口按需返回,前端用sections参数声明要哪些块:
// 客户详情聚合:按 sections 拉取,避免详情页请求风暴 async function getCustomerDetail(customerId, sections, user) { const base = await db.customer.findFirst({ where: { id: customerId, orgId: user.orgId, isDeleted: 0 } }); if (!base) throw new Error('客户不存在或无权限'); const result = { base }; // 动态:按时间倒序取最近 20 条活动记录 if (sections.includes('activities')) { result.activities = await db.activity.findMany({ where: { customerId }, orderBy: { createdAt: 'desc' }, take: 20 }); } // 团队成员:负责人 + 参与人,用于“我参与的客户”方案 if (sections.includes('team')) { result.team = await db.teamMember.findMany({ where: { customerId } }); } // 相关:联系人、商机、合同、订单各自取摘要 if (sections.includes('related')) { result.related = await loadRelatedSummary(customerId); } return result; }逻辑说明:先查主表并校验orgId和isDeleted,这一步是权限和软删除的双保险,漏了就是越权读数据。sections让前端按当前 Tab 决定拉哪些块,首屏只传['activities'],切到团队再补拉。动态取最近 20 条是常见做法,文档 3.1.3.1.2.21 的动态页面本质就是活动流,全量拉会拖垮详情页。loadRelatedSummary里对联系人、商机、合同、订单各取 count 加前几条摘要,避免把四张表全量塞进一个响应。参数上customerId必传,sections缺省返回base,这样即使前端不传也不会报错。
3.3 客户合并:字段取舍与关联数据迁移
3.1.3.1.2.17 的客户合并是这份文档里技术含量最高的一块,也是二次开发最容易做残的功能。合并的本质是把两个客户合成一个,保留主客户的字段,把被合并客户的联系人、商机、动态、合同、订单、团队成员全部迁过来,然后软删除被合并方。难点在字段冲突:两个客户都有负责人、都有上级客户,合并后听谁的?
# 客户合并:主客户保留字段,从客户关联数据整体迁移 def merge_customer(main_id, sub_id, field_choices, operator): if main_id == sub_id: raise BizError("不能合并自身") main = get_customer(main_id, operator.org_id) sub = get_customer(sub_id, operator.org_id) # 1. 字段取舍:冲突字段由操作人显式选择,未选的保留主客户值 for field, source in field_choices.items(): if source == "sub": setattr(main, field, getattr(sub, field)) # 2. 关联数据迁移:所有指向 sub 的外键改指 main for model in (Contact, Opportunity, Activity, Contract, Order, TeamMember): model.query.filter_by(customer_id=sub_id) \ .update({"customer_id": main_id}) # 3. 被合并方软删除,保留审计痕迹 sub.is_deleted = 1 sub.merged_into = main_id sub.updated_by = operator.id db.session.commit() return main逻辑说明:field_choices是前端弹窗让用户逐字段选“保留主客户”还是“用被合并客户”,没出现在里面的字段默认保留主客户值,这样交互简单且不会误覆盖。关联迁移用批量update而不是逐条改,六张表一次事务提交,中途失败整体回滚,避免迁一半留下脏数据。merged_into字段是后悔药,合并错了还能顺着它找回原客户。参数上main_id/sub_id必传且不能相等,operator用于组织校验和审计。这里要注意:如果被合并客户是别人的上级客户,迁移后要检查环,否则合并完层级关系就成环了。
4. 避坑与排查:客户管理落地时最容易翻车的五件事
4.1 现象:列表越翻越慢,第七页开始转圈
原因:查询方案里“我参与的客户”每次 join 团队成员表,而团队成员表没建(user_id, customer_id)联合索引,数据量一过十万就全表扫。解决:给团队成员、关注表都补联合索引,并在列表接口加explain慢查询日志,超过 500ms 的查询打点告警。我一般上线前用一万条客户数据压一遍五个方案,哪个方案慢就单独优化。
4.2 现象:新增客户保存成功,但列表里看不到
原因:org_id取的是当前操作员组织,而列表查询也按组织过滤,如果新增时组织取错(比如从缓存拿了旧组织),数据就“消失”了。文档明确“所属组织默认当前操作员所属组织且不可编辑”,所以这个值必须服务端从登录态取,绝不能信前端传的orgId。解决:新增接口里org_id强制覆盖为current_user.org_id,前端传了也忽略。
4.3 现象:客户合并后,商机还在但负责人变成了空
原因:合并时只迁了customer_id,没处理商机自己的负责人字段,而被合并客户的负责人可能已离职或不在当前组织。解决:合并前先校验被合并客户的所有关联数据在当前组织内可见,迁移时对负责人做一次有效性检查,无效的落到主客户负责人身上,并在合并日志里记一条。
4.4 现象:导入 Excel 后客户名称重复,查重没生效
原因:3.1.4.2 的查重设置要求按规则判重,但导入走的是批量插入,绕过了单条新增的查重逻辑。解决:导入前先把整批数据按查重规则(常见是客户名称 + 电话 + 组织)在内存里去重,再和库里已有数据比对,重复的标记为“跳过”并回写导入结果表,让用户看到哪几行没进来。批量插入不能图快跳过校验,这是血泪经验。
4.5 现象:详情页动态时间不对,最新活动记录时间不更新
原因:last_active_at只在活动记录保存时更新,但文档 3.1.3.1.2.3 把它定义为“最后活动记录保存时系统时间”,如果活动是通过其他入口(比如批量导入动态)写入的,没走同一个更新钩子就漏了。解决:把last_active_at的更新收敛到一个领域事件里,任何写活动记录的路径都发这个事件,避免多处散落更新逻辑。“七日未跟进客户”方案直接依赖这个字段,它不准,整个方案就废了。
5. 二次开发改造:把这份 PRD 变成可演进的客户中台
拿到这份文档做二次开发,最忌讳的是照着页面一个个堆接口,最后客户表变成一张什么都往里塞的宽表。我的改造思路是先立三条线,再动字段。
第一条线是主数据与扩展分离。客户主表只放文档 3.1.3.1.2.3 里那些稳定字段,自定义字段全部走 EAV 扩展表,高级查询时动态拼 join。这样后续加字段不用改表结构,代价是查询要多一次关联,用缓存字段定义可以抵消。
第二条线是组织隔离贯穿始终。所有客户相关查询、更新、合并、导入导出,第一层过滤永远是org_id,我习惯在 ORM 层做一个全局 scope,业务代码里想绕都绕不过去。文档里“所属组织不可编辑”就是这条线的需求依据。
第三条线是状态与审计可回溯。deal_status的变更、合并操作、负责人变更都要落审计表,字段至少包含操作人、操作类型、变更前后值、时间。客户合并的merged_into就是审计的一部分,二次开发时把它扩展成一张customer_merge_log更稳妥。
改造验证我一般跑一个最小回归集:新建客户带上级 → 列表五个方案各查一遍 → 编辑单字段失焦保存 → 合并两个客户看关联数据是否全迁 → 导入一批含重复的 Excel 看查重结果 → 导出核对列。这套跑通,客户管理的主链路基本就稳了。
| 改造点 | 一期做法 | 二期预留 |
|---|---|---|
| 自定义字段 | EAV 扩展表 + 字段定义缓存 | 字段级权限 |
| 组织隔离 | ORM 全局 scope | 跨组织共享客户 |
| 合并审计 | merged_into 字段 | 独立合并日志表 |
| 动态流 | 活动表倒序取 20 条 | 邮件/电话/短信自动接入 |
二期那些“客户通讯信息自动读取”“线索获取和分配”,在一期表结构里留好source(来源渠道)和external_id(外部消息 ID)两个字段,到时候接消息网关就不用改主表。销售漏斗和统计分析则建议单独走数仓或读库,别在业务库上跑聚合,否则列表和报表互相拖慢。
从那以后我每次接这类需求文档,都先把“关键数据项”那张表抄成 DDL 和校验规则,再动手写接口,字段约束对不上的地方一律回头找产品确认,绝不自己猜。希望帮到你。
本文还有配套的精品资源,点击获取