
简介这是一份基于Angular与TypeScript开发的前端客户管理项目界面样式采用Bootstrap通过模拟服务器代替真实后端用于演示客户数据的增删改查。项目面向正在学习组件化开发、前端工程化与前后端分离的入门及中级开发者适合作为CRM系统前端模块的练手范例。压缩包共含70个文件大小约2.35MB。文件构成以TypeScript源码、HTML页面模板、CSS样式表与JSON数据配置为主另有少量辅助脚本、图片、浏览器配置文件与说明文档。目录区分了源代码、环境配置、测试与构建产物等区域整体结构清晰便于按需查阅。目前已有154人学习使用。借助该资源可以观察Angular项目从创建组件、组织服务到对接模拟接口的完整常见写法同时学习Bootstrap在后台界面中的布局方式以及Mock数据在开发联调中的作用。对于希望快速入手同类管理后台开发的学习者是一份可运行的参考示例。1. CRM项目为什么总在半年后变成昂贵的ExcelCRM项目最大的坑通常不在技术而在数据模型和权限这两处地基定错了功能做得越全返工越狠。我见过不少团队把一套 CRM 做成「昂贵的 Excel」——录入靠自觉、统计靠导出、销售宁愿自己记也不往系统里填项目上线半年基本废弃。这篇笔记把从零搭一套可用的 CRM 核心模块讲清楚客户、联系人、商机、跟进记录怎么建模权限怎么做到「只能看自己的客户」商机漏斗怎么统计以及批量导入和并发更新里那些一踩一个准的坑。适合正在做选型对比、或者打算自建内部 CRM 的后端开发也适合刚接手某个已烂尾 CRM 项目、准备推倒重来的人。2. CRM项目的数据地基四张核心表怎么建模才不返工数据模型是 CRM 项目里返工成本最高的决定。界面不好看可以调接口不合理可以改但表结构一旦上线数据已经灌进去了再回头改模型等于把系统重做一遍。所以我一般会把建模阶段拉长一点宁可多讨论两天也不急着建表。2.1 客户、联系人、商机、跟进记录谁是谁的父表CRM 项目的核心对象就四个客户、联系人、商机、跟进记录。它们之间的关系是一棵固定的树客户是根联系人和商机都挂在客户下面跟进记录挂在商机下面同时冗余一个客户 ID 方便做全局搜索。客户 1 — N 联系人一个客户有多个对接人采购、技术、财务各一个。客户 1 — N 商机一个客户可以同时有几个进行中的项目。商机 1 — N 跟进记录每次打电话、拜访、报价都是一条跟进。联系人 1 — N 跟进记录可选关联很多跟进是「找某某人聊了商机」。为什么不让联系人、商机都塞进客户表因为一对多关系一旦塞进单表后面统计客户名下有多少商机、最近一次联系人是哪一位全得靠正则或者 LIKE 去匹配索引直接失效数据量到几万行就明显变慢。把一对多拆成子表是 CRM 项目第一个要守住的建模原则。联系人的归属需要做一个业务约定一个人原则上只属于一个客户。现实中会出现一个人同时是 A 客户的采购和 B 客户的技术对接人小团队的处理方式通常是「复制一份联系人到 B 客户」主数据仍归原客户。这个方案不完美但换来的建模简单和查询直接对大多数内部 CRM 来说性价比最高。商机挂在客户下而不是挂在联系人下。原因是商机的决策链常常涉及客户方多个角色挂在某个联系人身上会导致「这个人离职了商机归谁」的扯皮问题。商机只认客户、不认人联系人变更不影响商机归属这是我在多个项目里被逼出来的教训。2.2 建表SQL与字段设计为什么唯一键要带负责人建模讲清楚之后实际建表是下一步。下面这三张表是 CRM 项目最核心的底表我用 MySQL 8 的写法字符集统一 utf8mb4。CREATE TABLE customer ( id BIGINT AUTO_INCREMENT PRIMARY KEY, customer_no VARCHAR(32) NOT NULL COMMENT 客户编号业务可读, name VARCHAR(128) NOT NULL COMMENT 客户名称, owner_id BIGINT NOT NULL COMMENT 负责人用户ID, owner_dept_id BIGINT NOT NULL COMMENT 负责人部门ID, source VARCHAR(16) NOT NULL DEFAULT MANUAL COMMENT 来源: MANUAL手工/IMPORT导入, status TINYINT NOT NULL DEFAULT 1 COMMENT 1有效 0无效, deleted TINYINT NOT NULL DEFAULT 0 COMMENT 软删除标记 0正常 1已删, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_customer_name_owner (name, owner_id, deleted), KEY idx_owner (owner_id), KEY idx_dept (owner_dept_id, status) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 客户表;客户表有两个容易被忽略的设计细节。第一customer_no是业务编号给销售看的比如一串可读的字母加数字自增 ID 只给系统内部关联用不要直接暴露给业务人员否则他们会拿 ID 当客户编号用后续导入导出全是坑。第二唯一键(name, owner_id, deleted)带 deleted 是有原因的MySQL 唯一索引对 NULL 不生效软删除后如果 deleted 是 NULL同一负责人可以反复建同名客户且无法被唯一键拦住把 deleted 放进唯一键等于只对「未删除」的数据做重名校验删掉的历史重名不会挡新数据。联系人表和商机表分别这样建CREATE TABLE contact ( id BIGINT AUTO_INCREMENT PRIMARY KEY, customer_id BIGINT NOT NULL COMMENT 所属客户ID, name VARCHAR(64) NOT NULL COMMENT 联系人姓名, phone VARCHAR(32) NULL COMMENT 手机号允许为空, position VARCHAR(64) NULL COMMENT 职位, is_primary TINYINT NOT NULL DEFAULT 0 COMMENT 是否主要联系人 1是 0否, deleted TINYINT NOT NULL DEFAULT 0, KEY idx_customer (customer_id), UNIQUE KEY uk_contact_customer_name (customer_id, name, deleted) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 联系人表; CREATE TABLE opportunity ( id BIGINT AUTO_INCREMENT PRIMARY KEY, customer_id BIGINT NOT NULL COMMENT 所属客户ID, name VARCHAR(128) NOT NULL COMMENT 商机名称, amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT 预计金额, stage VARCHAR(32) NOT NULL COMMENT 商机阶段见枚举, owner_id BIGINT NOT NULL COMMENT 负责人, expect_close_date DATE NULL COMMENT 预计成交日期, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, deleted TINYINT NOT NULL DEFAULT 0, KEY idx_customer (customer_id), KEY idx_stage (stage), KEY idx_owner (owner_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 商机表;商机金额必须用 DECIMAL不能用 FLOAT 或者 DOUBLE。金额做加总时浮点误差会积累报表上差几分钱财务就敢说系统算错了。stage字段我存的是字符串枚举值而不是数字比如INIT、WON、LOST查数据库时一眼可读代码里映射成枚举也方便。这里还有一个提前加的字段version。现在用不上等并发编辑商机阶段的功能上线它就是防互相覆盖的顶梁柱。我在这类项目里吃过亏之后凡是带「状态流转」的业务表建表第一天就把乐观锁字段加上。2.3 用「主要联系人」冗余满足列表不让子表失控客户列表页和详情页都要显示「这个客户的主要联系人是谁」。如果每次查询都去 contact 子表取第一条一旦列表页有 50 个客户就产生 50 次额外查询也就是经典的 N1 问题。常见做法是在 customer 表上冗余两个字段main_contact_name和main_contact_phone当联系人被标记为主要联系人时在同一事务里同步更新这两列。业务规则上只允许一个客户有一个主要联系人设置新的主要联系人时先把旧的主要联系人置为非主要再写入新值。代价是冗余字段有同步问题但只要更新逻辑收口在同一个 service 方法里别让各模块自行改 contact一致性是可控的。这样做的收益是查询列表只需要扫 customer 一张表不用 join、不用子查询索引也用得干净。录入路径还可以设计成弹窗一步完成「新增客户 新增主要联系人」销售不用先建客户、再进联系人页、再回来填名单体验会顺很多。注意冗余可以但不要把「所有联系人手机号」都冗余进客户表。只冗余主要联系人这一个字段其他联系人的查询仍然走子表否则子表就没意义了。3. 权限模型把「只能看自己客户」做成可配置的数据范围权限是 CRM 项目里第二个大规模返工点。很多系统开发到一半业务才提出「主管要看部门所有客户销售只能看自己的老板全部都能看」这时候再改权限查询代码遍地开花。权限模型必须在写第一条查询 SQL 之前定下来。3.1 RBAC选型与数据范围四档设计为什么不用「给每个用户直接配置能看哪些客户」因为人员流动是常态。一个销售从 A 部门调到 B 部门如果是用户级配置要把他名下几百条客户可见记录全部改一遍如果用 RBAC把用户从 A 角色挪到 B 角色即可一条数据都不用动。数据范围我一般做成四档用角色的字段控制1本人只能看自己负责的客户、商机。2本部门看本部门所有人的数据。3本部门及以下看本部门加子部门适合多层组织。4全部看所有数据给老板或运营用。四档基本覆盖中小企业绝大多数场景。要不要加「自定义选人」这种档我建议第一阶段不做因为「谁能看谁的客户」一旦变成可勾选的名单权限就退化成用户级配置维护成本和用户数成正比而且业务自己都说不清楚规则。先在角色上固化四档等业务明确提出来再扩展。功能权限和数据权限要分开。功能权限控制「能不能点这个菜单、能不能导出」由前端菜单和接口登录校验控制数据范围只回答「他能看哪些行」。两者不要混在一张表里。3.2 用户、角色、数据权限表结构权限相关的表结构在 CRM 项目里通常长这样CREATE TABLE sys_dept ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL, parent_id BIGINT NOT NULL DEFAULT 0 COMMENT 父部门ID0表示根 ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 部门表; CREATE TABLE sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE, dept_id BIGINT NOT NULL COMMENT 所属部门 ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 用户表; CREATE TABLE sys_role ( id BIGINT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(32) NOT NULL UNIQUE COMMENT 角色编码如SALES, data_scope TINYINT NOT NULL DEFAULT 1 COMMENT 数据范围 1本人 2本部门 3本部门及以下 4全部 ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 角色表; CREATE TABLE user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 用户角色关联表;这个设计里只有一个关键点data_scope挂在角色上不挂在用户上。销售角色是1销售主管角色是2销售总监角色是3老板角色是4。新员工入职只需要分配角色权限自动生效调岗只需要换角色数据范围跟着变。部门表用parent_id自关联做树形结构根部门 parent_id 为 0。注意不要在部门表里做「一个部门多个父部门」的多对多现实中的组织架构都是严格的树做成多对多之后子部门查询、数据范围计算都会变复杂收益却几乎为零。树的高度一般不超过四层递归查询性能完全够用。3.3 查询层拼接一个注解还是服务层工具方法数据范围真正落地是在查询层。常见的做法有两种一种是给 Mapper 方法加自定义注解配合拦截器自动拼条件另一种是在 Service 层手动用 MyBatis-Plus 的 Wrapper 拼接。我倾向于后者一是逻辑显式、新人能看懂二是拦截器自动拼条件时出了错很难排查尤其是条件优先级和嵌套 and/or 的问题。public ListCustomer listCustomer(CustomerQuery query, LoginUser currentUser) { LambdaQueryWrapperCustomer wrapper Wrappers.lambdaQuery(); wrapper.eq(Customer::getDeleted, 0); if (query.getName() ! null !query.getName().isEmpty()) { wrapper.like(Customer::getName, query.getName()); } Integer dataScope currentUser.getRole().getDataScope(); switch (dataScope) { case 1: // 本人 wrapper.eq(Customer::getOwnerId, currentUser.getId()); break; case 2: // 本部门 wrapper.eq(Customer::getOwnerDeptId, currentUser.getDeptId()); break; case 3: // 本部门及以下 ListLong deptIds deptService.getChildDeptIds(currentUser.getDeptId()); deptIds.add(currentUser.getDeptId()); wrapper.in(Customer::getOwnerDeptId, deptIds); break; default: // 4 全部不加条件 break; } wrapper.orderByDesc(Customer::getCreatedAt); return customerMapper.selectList(wrapper); }逻辑说明查询条件用LambdaQueryWrapper构造数据范围的四个分支对应上面表结构里的四档。注意第 3 档需要先把子部门 ID 递归查出来再连同当前部门一起放进IN子部门查询结果为空时不要执行下面这行否则会拼出IN ()直接把 SQL 打崩。参数说明LoginUser是登录态对象从请求上下文取出里面至少要有用户 ID、部门 ID、角色数据范围三个值。为了不在每个 Service 方法里手动传这三个参数可以在请求入口处用 ThreadLocal 存一份当前用户信息Service 里随时取用请求结束再清理。这样代码干净也避免了每个查询方法都要加参数、改接口签名的问题。注意数据范围条件必须作为查询条件由数据库过滤而不是查出全量数据后在 Java 内存里 filter。后者在数据量超过几千行后就会拖慢接口而且把本不该看到的数据加载到了应用内存里属于数据越权。4. 商机漏斗与跟进记录状态机设计决定报表能不能用销售漏斗是 CRM 项目里业务最看重的报表。它统计每个商机阶段的数量和金额直接反映团队业绩进展。漏斗能不能用取决于商机状态机的设计和跟进记录怎么存。4.1 商机状态机用枚举还是字典表商机阶段是一个状态机初步沟通、需求确认、方案报价、谈判中、赢单、输单。阶段之间允许跳转但「赢单」和「输单」是终态不能再往回流。状态机我放在 Java 枚举里不放数据库字典表。原因是状态流转规则是业务逻辑比如「初步沟通不能直接跳到赢单」「输单必须填输单原因」这些校验写成代码是编译期可见的放字典表里只能靠注释约定改一次规则就要发一条 SQL而且线上跑着的服务不会自动感知。客户来源、商机类型这类静态字典才适合放数据库表。public enum OppStage { INIT(初步沟通), REQUIREMENT(需求确认), QUOTE(方案报价), NEGOTIATE(谈判中), WON(赢单), LOST(输单); private final String label; OppStage(String label) { this.label label; } public boolean canTransferTo(OppStage target) { if (this WON || this LOST) { return false; // 终态不可再流转 } return true; } }那「哪个阶段到哪个阶段不允许」要不要硬编码我一般只做两条硬规则终态不可流转、同阶段重复提交不产生变更。其余跳转适度放开因为实际销售流程里客户情况千差万别卡得太死业务会投诉「系统不懂业务」。把状态机做成枚举的真正价值是报表排序时能按枚举定义顺序展示统计代码里不会出现魔法字符串WON散落各处。4.2 跟进记录的表结构别用object_type/object_id多态跟进记录是 CRM 项目里最容易被低估的一张表。销售每天打电话、发微信、拜访客户所有接触过程都是一条跟进记录。它的表结构如果设计成「通用关联」——一张 follow_up 表用object_type和object_id分别指向客户、商机、联系人——后面统计就会处处踩坑。CREATE TABLE follow_up ( id BIGINT AUTO_INCREMENT PRIMARY KEY, customer_id BIGINT NOT NULL COMMENT 客户ID强制冗余, opportunity_id BIGINT NULL COMMENT 商机ID可空通用跟进不挂商机, contact_id BIGINT NULL COMMENT 联系人ID可空, content TEXT NOT NULL COMMENT 跟进内容, next_follow_date DATE NULL COMMENT 下次跟进日期空表示无需跟进, creator_id BIGINT NOT NULL COMMENT 记录人, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_customer_time (customer_id, created_at), KEY idx_next_follow (next_follow_date, creator_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 跟进记录表;为什么不用object_type/object_id多态设计多态的问题在于查询「某个客户最近 30 天的所有跟进」时条件会变成WHERE object_typeCUSTOMER AND object_id123这个条件没法很好地利用复合索引而且无法用外键约束保证object_id真的存在。把customer_id做成强制冗余字段查询条件直接命中索引还顺带解决了「跨对象看客户时间线」的场景。商机 ID 做成可空是因为「跟进」并不总发生在商机上。客户生日回访、纯客情维护这类跟进不挂商机强加一个商机 ID 会导致销售找不到归属对象。字段细节上content用 TEXT 类型就不能给默认值这是 MySQL 的规则所以建表时不要对它写 DEFAULT。created_at用 DATETIME 而不是 TIMESTAMP避免 2038 年问题应用层插入时也不需要显式传值数据库默认当前时间即可。4.3 漏斗统计SQL与下次跟进提醒漏斗统计的核心是对商机表按阶段分组统计数量和金额。下面是按负责人统计的版本SELECT stage, COUNT(*) AS opp_count, SUM(amount) AS total_amount FROM opportunity WHERE deleted 0 AND owner_id #{userId} GROUP BY stage HAVING stage IN (INIT, REQUIREMENT, QUOTE, NEGOTIATE, WON, LOST) ORDER BY FIELD(stage, INIT, REQUIREMENT, QUOTE, NEGOTIATE, WON, LOST);SQL 说明GROUP BY stage按阶段聚合HAVING在这里不是必需的但写上能把分组限定在合法阶段关键是最后面的ORDER BY FIELD它按业务漏斗顺序排序而不是按字母序这样报表渲染出来的就是一个从多到少、从初到终的标准漏斗。统计维度要换成部门时把owner_id #{userId}换成owner_dept_id IN (...)子部门范围由前面的权限逻辑先算好再传进来。下次跟进提醒的查询更简单SELECT id, customer_id, content, next_follow_date FROM follow_up WHERE next_follow_date CURRENT_DATE AND creator_id #{userId} ORDER BY next_follow_date ASC LIMIT 50;这条 SQL 会捞出所有今天及以前应跟进但还没跟进的记录。(next_follow_date, creator_id)这个索引就是为它服务的注意字段顺序不能反过来否则等值条件的 creator_id 无法被索引利用。5. 避坑CRM项目实施里常见的 5 个返工点下面这些坑基本是这类项目里反复出现的每一条我都按「现象 → 原因 → 解决」的顺序写清楚方便你对照排查。这些坑的共同点是都发生在数据模型或权限这种底层决策上表面上看是功能问题实际都是地基问题。5.1 联系人在客户表里越堆越多现象客户表出现了phone2、phone3这类字段或者同一个客户因为「联系人不同」被建了两行。原因销售嫌每次进联系人子表录入麻烦强烈要求把联系人直接做成客户表的多个字段。开发团队觉得「加几个字段更快」就顺着做了。解决坚持「一个客户多联系人」的子表模型同时在录入体验上做补偿——客户弹窗里支持一次录入主要联系人和多条联系人记录保存时一个事务同时写 customer 和 contact。业务人员要的不是数据模型是少点几下鼠标把录入路径做短比改表结构正确得多。5.2 物理删除把审计记录一起删了现象某天销售误删了一个大客户的记录连带商机和跟进记录全部消失管理员无法恢复只能让销售凭记忆重新录。原因删除时用了DELETE FROM或者建表时外键写了ON DELETE CASCADE。CRM 是业务系统数据是资产物理删除等于把资产丢弃。解决所有核心业务表统一带deleted字段删除操作只做UPDATE ... SET deleted1查询统一加deleted0过滤条件。有审计需求的表再补一张操作日志表记录谁在什么时间把哪条记录删了。这样误删还能从回收站还原删除原因也能追溯。5.3 数据权限条件被拼成SQL字符串现象权限模块上线后某主管发现自己能看到部分别人的客户或者某个查询接口直接抛 SQL 语法错误。原因数据范围条件是用字符串拼接进 SQL 的比如 MyBatis 里写了apply(owner_id in (select ...))其中一部分字符串来自请求参数或前端传入值。这既可能被注入攻击又会让 MyBatis 的 SQL 缓存失效。解决数据范围一律用 Wrapper 的条件构造方法生成所有值以参数绑定方式传入禁止直接拼接字符串。子部门范围用IN加一个 List 参数而不是拼1,2,3这种文本。代码审查时见到apply带字符串拼接直接打回。5.4 商机阶段并发更新赢单被输单覆盖现象两个销售同时打开同一个商机A 改成「赢单」保存B 也改了自己的字段然后保存成「输单」最终商机停在输单赢单记录丢失。原因更新 SQL 只带了WHERE id ?后提交的人覆盖先提交的人没有版本校验。阶段流转的枚举校验只能挡非法跳转挡不住并发场景。解决商机表加version字段更新语句写成UPDATE ... WHERE id ? AND version ?更新成功后把版本号加一。第二条更新到达时版本号已经变了影响行数为 0程序提示「数据已被他人修改请刷新后再试」。这是标准的乐观锁处理代码里用 MyBatis-Plus 的Version注解就能实现要尽早加上。5.5 日期统计差8小时凌晨数据算错天现象每天早上 8 点前看「今日新增客户」报表数字和销售实际录的对不上有时候是 0有时候把昨天的也算进来了。原因应用服务器时区和数据库会话时区不一致。MySQL 的CURRENT_TIMESTAMP跟随数据库time_zone设置如果数据库是 UTC应用是东八区凌晨 0 点到 8 点之间的数据会落在「昨天」的日期上。解决统一约定——数据库连接串加serverTimezoneAsia/Shanghai应用 JVM 时区也设成同一时区部署时用环境变量固定住。SQL 里的「今天」不要用应用层拼好的字符串日期传给数据库而是用数据库函数DATE(created_at) CURRENT_DATE让数据库自己算当天边界。这样即使服务器换了时区配置统计口径也不会漂移。6. 批量导入的幂等设计CRM项目上线第一周的求生课CRM 项目上线后的第一周几乎必然出现一个需求「把销售手上的 Excel 老客户批量导入系统」。如果导入不做幂等设计销售们会各自导入一遍客户表瞬间多出上千条重复数据后面所有统计和去重工作全是噩梦。幂等设计分两步。第一步导入前先解析 Excel按业务唯一键客户名 负责人 ID去库里查一次已存在的数据存在就标记为「跳过」不存在才进入待插入列表。第二步同一批导入文件里也可能有重复行这需要数据库层面的约束兜底。// 导入批次处理核心逻辑 String batchCode IMP System.currentTimeMillis(); for (ImportRow row : excelRows) { // 1. 查询已存在客户避免重复导入 Long exists customerMapper.selectCount( new LambdaQueryWrapperCustomer() .eq(Customer::getName, row.getName()) .eq(Customer::getOwnerId, row.getOwnerId()) .eq(Customer::getDeleted, 0) ); if (exists 0) { // 跳过已存在客户记录失败原因不影响其他行 importLogService.skip(batchCode, row.getRowNum(), 客户已存在); continue; } // 2. 插入前再写一次批次唯一键挡住并发重复提交 try { importLogService.tryInsert(batchCode, row.getRowNum(), row.getName()); } catch (DuplicateKeyException e) { importLogService.skip(batchCode, row.getRowNum(), 本批内重复行); continue; } customerMapper.insert(buildCustomer(row)); importLogService.success(batchCode, row.getRowNum()); }逻辑说明tryInsert往导入日志表写一条带(batch_code, row_num)唯一约束的记录这一步的作用是防止同一批导入被重复提交时并发线程同时通过第一步的「不存在」判断。数据库唯一键是最后一道防线比任何内存标志都可靠。每一行导入的结果都落日志成功、跳过、失败原因批量导入结束后把结果汇总下载让销售自己看哪行没进去、为什么没进去。参数说明批次号用时间戳生成就够了不必做太复杂。业务唯一键选「客户名 负责人 ID」而不是只选客户名因为不同销售各自都有叫「某科技公司」的客户只在全局查重会把别人的客户也拦住。第一次导入的数据清理成本很高我会在正式导入前用几千行真实数据做一次预跑观察跳过率和失败原因再决定要不要调整 Excel 模板。这套幂等设计的经验是我在某个模拟项目 X 里用血泪换来的。当时没做第一道查重销售把同一份名单导了三遍客户表多出两千多条重复数据最后靠写脚本按创建时间去重整整清理了两天一夜。清理完之后我养成了一个习惯接任何系统先问一句「数据从哪里来、能不能重复跑」先把幂等想清楚再把功能写下去。批量导入、消息重试、定时任务补偿本质上都是同一个问题。希望帮到你。本文还有配套的精品资源点击获取