
简介这是一套基于Java核心技术的汽车客户关系管理系统CRM设计源码面向Java开发学习者、汽车行业信息化人员以及需要快速搭建CRM原型的技术团队。项目采用前后端分离思想后端以Java实现业务逻辑与数据交互前端结合Vue组件化开发、CSS样式与JavaScript动态交互并遵循MVC架构便于理解企业级Web应用的模块划分与协作方式。资源包共包含532个文件压缩包大小11.01MB涵盖115个JavaScript文件、60个Java源文件、55个Vue组件文件、60个class文件、53个SVG图形、49个CSS样式文件、19个XML配置文件以及字体、图标、SQL脚本等辅助文件目录结构清晰能直观看到从配置到编译部署的完整链路。已有265人学习下载适合用于课程设计、毕业设计或CRM入门实战。通过阅读源码可掌握Java与Vue整合的实现方式、MVC各层职责划分、界面模块化构建思路并可直接复用其中的客户管理、订单处理等核心功能模块减少重复开发成本。1. 汽车CRM不是普通CRM从线索到复购的业务闭环做过几个行业的客户管理系统之后你会发现汽车行业的CRM是最特殊的一类——一辆车的客单价从几万到几十万客户从第一次进店到最终成交可能隔三个月成交之后还有保养、续保、置换、转介绍。这套系统记的不是一次交易而是客户在车辆生命周期里的每一个触点。标题里的“基于Java核心技术”指向的就是这样一套系统它不是用低代码平台拖出来的表单而是用Java生态里的事务、并发、缓存、消息这些能力把汽车销售和服务场景里的流程逻辑真正跑起来并且能承受门店并发、高峰进店的流量。这篇笔记会带着你把业务拆成模块把核心代码落到位再把几个容易翻车的地方提前告诉你。2. 为什么是这套技术组合选型逻辑与工程骨架2.1 汽车CRM场景对技术栈的硬性要求汽车CRM的业务特性决定了它在技术上不是普通的增删改查。首先是数据量一家经销商集团可能有几十家门店每个门店积累数年客户数据线索表、跟进记录表、客户车辆关系表动辄上千万行。其次是对事务要求高线索分配、商机阶段变更、试驾预约这些操作必须强一致不能出现两个销售同时抢到同一个线索。第三是响应速度销售在接待客户时打开系统客户档案加载时间超过两秒体验就明显下降需要用缓存和索引设计解决。我一般不会选过于复杂的分布式架构单体的Spring Boot应用配合Redis缓存和MySQL主从足够覆盖大部分4S店和经销商集团的使用场景。这个选择的核心逻辑是业务复杂度在流程和状态转换不在并发量本身。门店规模决定了并发峰值几十个销售同时操作单体应用的瓶颈在数据库查询和缓存命中率而不在应用服务器本身。2.2 Spring Boot MyBatis-Plus Redis的组合搭配技术选型遵循的是“主流、稳定、团队好招人”的原则。Spring Boot 提供自动配置和生态整合MyBatis-Plus 解决单表CRUD的重复劳动同时保留手写SQL的能力——汽车CRM里报表类和复杂关联查询非常多完全依赖ORM自动生成SQL反而难优化。Redis 在这里承担三个职责热点客户缓存、分布式锁、以及低频数据的临时存储。下面是工程的基础依赖配置我把它放在最前面因为后面的代码都基于这个骨架运行。Maven的依赖版本这里不锁死实际项目里以你当前Spring Boot版本对应的稳定版本为准。dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里有两个参数容易忽略。一个是mybatis-plus-boot-starter的版本必须与Spring Boot版本兼容3.5.x对应Spring Boot 2.x没问题如果项目直接上Spring Boot 3.x需要换用新的starter坐标或者升级到对应版本。另一个是mysql-connector-java的runtime scope——编译期不需要运行时由容器提供避免打包时把驱动冗余塞进业务Jar。2.3 工程目录与模块边界划分玩了几个项目之后我习惯把代码按业务域切包而不是按技术层切包。按controller/service/mapper这种切法在小项目里很清晰但汽车CRM的业务域边界明显——客户、线索、商机、车辆、服务提醒各自独立按域切包之后后续加功能时改动范围能控制在一个域内部。com.auto.crm ├── common # 通用返回体、异常处理、工具类 ├── customer # 客户主数据、一客多车、家庭关联 ├── lead # 线索管理、去重、分配 ├── opportunity # 商机管理、阶段流转 ├── vehicle # 车辆信息、合格证、库存关联 ├── service # 保养提醒、回访任务、投诉跟进 ├── system # 用户、角色、门店、数据权限 └── report # 报表查询、业绩统计每个业务域内部再按controller、service、mapper、entity分包。这样的好处是新来的同事看到lead包就知道线索相关的东西都在这不用在全局翻找。common包单独拎出来是因为跨域的东西太多——统一返回体、业务异常、分页结果这些一旦放在某个业务域里就会出现循环依赖。3. 核心业务模型设计从表结构看透汽车CRM的复杂度3.1 客户主数据一客多车与家庭关系汽车CRM里最核心的实体不是“客户”本身而是客户和车辆的关系。一个客户可能买了两辆车一辆自己开、一辆给家人一辆车可能有两个使用者车主是丈夫但日常驾驶是妻子。如果模型设计成客户表关联一辆车后面做保养提醒、保险到期、置换推荐的时候全都得返工。我常用的设计是四张表客户主表、车辆表、客户车辆关系表、家庭成员表。客户主表只存人的属性车辆表存车的属性关系表存人和车的绑定关系车主、常用驾驶人、购买日期、车辆状态。这样设计的关键在于关系表里要有relation_type字段——区分车主和驾驶人还要有is_primary字段——标识客户的主车辆因为后续所有提醒任务都基于主车辆来做。CREATE TABLE customer_vehicle_rel ( id BIGINT PRIMARY KEY AUTO_INCREMENT, customer_id BIGINT NOT NULL COMMENT 客户ID, vehicle_id BIGINT NOT NULL COMMENT 车辆ID, relation_type TINYINT NOT NULL COMMENT 1-车主 2-常用驾驶人, is_primary TINYINT DEFAULT 0 COMMENT 是否主车辆 1-是 0-否, purchase_date DATE COMMENT 购买日期, created_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_customer_vehicle (customer_id, vehicle_id) ) ENGINEInnoDB COMMENT 客户车辆关系表;这个表设计上有一个坑uk_customer_vehicle这个唯一索引限制了同一个客户和同一辆车只能有一条关系记录但真实场景里一个人可能既是车主又是常用驾驶人。我的做法是如果两个身份都存在只保留一条记录用relation_type记录高优先级身份同时加一个driving_flag来标记是否日常驾驶。你在设计阶段最好和业务方确认清楚否则上线之后会发现同一个客户出现两条车辆关系所有查询都翻倍。3.2 商机阶段流转状态机比状态字段更可靠客户从留资到提车会经历新建、邀约、到店、试驾、报价、谈判、成交、战败这些阶段。很多项目的做法是加一个stage字段直接update覆盖。问题在于没有阶段变更的历史记录管理上无法追溯客户为什么会战败阶段之间的合法跳转没有被约束可能出现从新建直接跳到成交这种明显不合逻辑的数据。我在两个项目里踩过这个坑之后改成了状态机加阶段历史表的方案。阶段历史表记录每一次变更的从哪个阶段到什么阶段、操作人、变更原因这样既满足追溯也能在后端做合法性校验。状态机的判断逻辑用Java写不用数据库的check约束——因为check约束一旦要改就得改表结构而Java代码的改动成本低得多。public enum OpportunityStage { NEW(1, 新建), INVITED(2, 已邀约), VISITED(3, 已到店), TEST_DRIVE(4, 试驾), QUOTED(5, 已报价), NEGOTIATION(6, 谈判中), WON(7, 成交), LOST(8, 战败); private final int code; private final String desc; private static final MapInteger, ListInteger TRANSITIONS new HashMap(); static { TRANSITIONS.put(1, Arrays.asList(2, 3, 8)); TRANSITIONS.put(2, Arrays.asList(3, 8)); TRANSITIONS.put(3, Arrays.asList(4, 5, 8)); TRANSITIONS.put(4, Arrays.asList(5, 6, 8)); TRANSITIONS.put(5, Arrays.asList(6, 7, 8)); TRANSITIONS.put(6, Arrays.asList(7, 8)); } public static boolean canTransition(int from, int to) { ListInteger nextStages TRANSITIONS.get(from); return nextStages ! null nextStages.contains(to); } }注意TRANSITIONS这个静态Map里LOST战败状态是几乎所有阶段都能跳过去的因为在真实业务中客户在任何环节都可能在竞争对手那里买车。但WON成交只能由QUOTED或NEGOTIATION进入防止销售误操作直接成交。这段代码里我没把战败加入所有阶段的跳转列表而是单独在Service层判断——如果目标是战败任何非终态都可以直接跳这样代码更简洁。3.3 试驾预约与提醒任务两张关联表的设计试驾是汽车行业特有的业务环节通用CRM没有这个概念。试驾预约涉及三个对象客户、车辆型号、门店。还要考虑到一个客户可能试驾多个车型所以是双向关联。提醒任务的表设计更要注意——提醒不是简单的Cron表达式就能覆盖因为汽车保养提醒是按里程或时间先到为准保险提醒是按到期日提前N天生日关怀是每年固定日期至少三种不同的触发逻辑。我把提醒任务设计成一张任务表加一张规则表。任务表存的是具体某年某月某日要向某个客户推送什么内容规则表存的是生成任务的规则。系统每天跑一次调度根据规则表生成未来30天的任务而不是每天当场判断哪些客户需要提醒——这样做的价值是任务生成后可以人工调整、暂停、补发如果把规则判断放在生成任务的环节里运营人员就无法在系统里看到未来将要触达的客户列表。CREATE TABLE remind_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, customer_id BIGINT NOT NULL, vehicle_id BIGINT NOT NULL, task_type TINYINT NOT NULL COMMENT 1-保养 2-保险 3-生日 4-年检, execute_date DATE NOT NULL, status TINYINT DEFAULT 0 COMMENT 0-待执行 1-已触达 2-已取消, channel TINYINT DEFAULT 1 COMMENT 1-短信 2-电话 3-微信, content VARCHAR(500), created_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_execute_date (execute_date), KEY idx_customer (customer_id) ) ENGINEInnoDB COMMENT 提醒任务表;这张表的查询模式很明确按execute_date查某一天的任务按customer_id查某个客户的所有任务。两个索引分别覆盖这两种场景。status字段的状态只有三个值不建议加索引因为区分度太低MySQL的优化器大概率还是全表扫加了索引反而导致回表开销。4. 关键功能模块的落地实现线索分配、跟进记录与报表统计4.1 线索去重手机号匹配与Redis布隆过滤的取舍汽车行业的线索来源非常多——官网留资、车展扫码、电商平台、电话外呼同一个客户可能在不同渠道留下信息。如果不去重销售会看到多条重复线索还可能两个销售各自跟进同一个人客户感受到的就是被反复骚扰。去重的第一步是手机号精确匹配这是最可靠的主键。实现上有两种选择查数据库或查Redis。查数据库简单每次来一条线索都按手机号查一下客户表和线索表问题是在线索进量大的时候这条查询会成为热点。我常用的做法是短码去重用数据库全量去重布隆过滤器——但这里必须说清楚布隆过滤器存在误判率它只能告诉你“这个手机号绝对没出现过”或“可能出现过”不能作为唯一的去重依据。Service public class LeadDuplicateCheckService { private static final String LEAD_DUP_KEY lead:dup:; Autowired private StringRedisTemplate redisTemplate; Autowired private LeadMapper leadMapper; public boolean isDuplicate(String mobile) { if (!StringUtils.hasText(mobile)) { return false; } String key LEAD_DUP_KEY mobile; Boolean hasKey redisTemplate.hasKey(key); if (Boolean.TRUE.equals(hasKey)) { return true; } Integer count leadMapper.countByMobile(mobile); if (count ! null count 0) { redisTemplate.opsForValue().set(key, 1, 30, TimeUnit.DAYS); return true; } redisTemplate.opsForValue().set(key, 0, 30, TimeUnit.DAYS); return false; } }这里的缓存设计有一个容易被忽略的参数缓存时间设了30天。线索去重的时间窗口是30天超过30天的重复线索在业务上视为新线索——因为客户可能已经忘记半年前留过资料再次留资说明有新的购车意向。如果你把缓存时间设成永久老客户在销售已经跟进周期之外重新进线反而去重掉了等于丢了一个新商机。countByMobile这个查询只查线索表不查客户表因为已经成交的客户再进线可能是增购或置换不应该被当成重复线索直接拦截。4.2 线索分配轮询分配与销售负载感知线索进来之后要分给销售分配算法看起来简单——轮询就行——但实际做起来有几个细节。首先是分配粒度是按门店分还是按品牌分同一集团下多个品牌门店一个线索只对某个品牌有兴趣分到其他门店没有意义。其次是销售的状态休假、离职、暂时不接新线索的销售不能被轮询到。第三是保护期分配给销售的线索如果N天内没有跟进应该退回池子重新分配。轮询分配的核心代码不复杂但要注意并发问题。多个线索同时进来不能出现两个线程取到同一个销售。我用Redis的INCR命令结合取模来实现这样避免了数据库行锁带来的性能开销。public Long assignLeadToSales(Long storeId, Long brandId) { String counterKey lead:round: storeId : brandId; Long seq redisTemplate.opsForValue().increment(counterKey); ListSalesPerson activeSales salesPersonMapper.findActiveByStoreAndBrand(storeId, brandId); if (activeSales.isEmpty()) { throw new BusinessException(该门店暂无空闲销售可分配); } int index Math.toIntExact(seq % activeSales.size()); return activeSales.get(index).getId(); }INCR命令在Redis中是原子的多个线程同时调用不会拿到相同的值所以取模得到的销售索引也不会冲突。这个方案的代价是seq会无限增长——解决方式是每周清理一次这个key或者用日期拼进key里比如lead:round:1001:2:20250616这样每天自动生成新key旧key自然过期不用额外清理。一个业务细节是findActiveByStoreAndBrand必须排除请假和离职状态如果销售请假三天轮询还能分到他名下线索就要在池子里多躺三天所以这个查询的条件里一定要过滤status 1的销售。4.3 跟进记录的幂等设计与防重销售每次联系客户之后会写跟进记录电话打了没接通、客户微信回了一句、到店看了车这些都是跟进内容。跟进记录本身只是insert但它牵扯到两个业务操作更新商机的last_follow_up_time和更新客户的last_contact_time。问题出现在网络抖动时销售点了一次保存前端超时又重试了一次结果产生两条一模一样的跟进记录。幂等设计的常见做法是加唯一业务键。我在跟进记录表里加了一个request_id字段前端每次进入跟进页面时生成一个UUID提交时带上这个UUID。后端在insert之前先按request_id查一次如果存在就直接返回已有记录不存在才执行插入并更新商机的最后跟进时间。这个方案需要满足一个前提request_id要加唯一索引否则高并发下两条相同请求同时通过查询同时进入插入逻辑还是会产生重复。Transactional(rollbackFor Exception.class) public FollowUpRecord addFollowUp(FollowUpCreateDTO dto) { Integer exists followUpMapper.countByRequestId(dto.getRequestId()); if (exists ! null exists 0) { return followUpMapper.findByRequestId(dto.getRequestId()); } FollowUpRecord record new FollowUpRecord(); record.setCustomerId(dto.getCustomerId()); record.setOpportunityId(dto.getOpportunityId()); record.setContent(dto.getContent()); record.setNextFollowTime(dto.getNextFollowTime()); record.setRequestId(dto.getRequestId()); followUpMapper.insert(record); opportunityMapper.updateLastFollowTime(dto.getOpportunityId(), LocalDateTime.now()); return record; }这个方法的countByRequestId在最极端的情况下还是会有并发穿透所以生产环境里我会额外在数据库加唯一索引然后捕获DuplicateKeyException做兜底。Transactional保证跟进记录的插入和商机最后跟进时间的更新要么同时成功、要么同时失败避免出现有记录但商机时间没更新的数据不一致。小项目里这种不一致看起来没什么但在做销售漏斗分析的时候last_follow_up_time是判断线索是否沉睡的核心字段差了哪怕几分钟跑批的筛选结果都会出偏差。4.4 销售漏斗报表SQL优化与统计口径报表是CRM系统里最容易被低估的工作量财务看成交率、销售总监看阶段转化率、市场部看线索来源质量每个人要的口径都不一样。一个常见的需求是查询某段时间内每个销售、每个阶段的商机数量和金额。如果用MyBatis-Plus的条件构造器硬拼报表查询会变得很难维护也容易产生慢SQL。我建议手写SQL用GROUP BY加条件聚合的方式一次查询出所有阶段的数据而不是循环查询数据库。下面这个SQL就是一个典型的销售漏斗统计按销售分组用SUM(CASE WHEN ...)把不同阶段的数量统计成不同的列。SELECT s.sales_name AS 销售, COUNT(CASE WHEN o.stage IN (1,2,3,4,5,6,7) THEN 1 END) AS 总商机数, COUNT(CASE WHEN o.stage 3 THEN 1 END) AS 到店数, COUNT(CASE WHEN o.stage 4 THEN 1 END) AS 试驾数, COUNT(CASE WHEN o.stage 7 THEN 1 END) AS 成交数, SUM(CASE WHEN o.stage 7 THEN o.amount ELSE 0 END) AS 成交金额 FROM opportunity o INNER JOIN sales_person s ON o.sales_id s.id WHERE o.created_time #{startDate} AND o.created_time #{endDate} GROUP BY s.id, s.sales_name ORDER BY 成交金额 DESC注意stage 3这种写法它依赖阶段编码的大小顺序——新建是1、已邀约是2、已到店是3、试驾是4。这里有一个隐藏的设计约定阶段编码要和业务顺序一致否则判断就没有意义。如果你在阶段设计时把编号打乱了比如新建是5、到店是3这个统计SQL就得改成stage IN (...)。所以我在阶段枚举设计阶段就和业务确认好编码按流程顺序排报表才能用简洁的条件聚合。另一个优化点是时间范围用 #{startDate} AND #{endDate}不用BETWEEN AND避免日期字段带了时分秒导致边界少算一天。5. 避坑与排查汽车CRM项目的高频翻车点5.1 一客多车引发的重复推送现象一个客户名下有两辆车保养提醒任务生成时生成了两条触达时客户收到两条内容几乎一样的短信体验很差。原因提醒任务的生成逻辑按customer_id和vehicle_id各生成了一条没有考虑同一个客户的主车辆策略。解决任务生成前先通过customer_vehicle_rel表的is_primary字段确定主车辆只对主车辆生成保养和保险提醒非主车辆只做档案记录不做主动触达。注意在车辆过户或增购时is_primary要允许业务手动变更否则换车之后提醒还是推给旧车。5.2 商机并发更新导致阶段跳变丢失现象两个销售在同一时间段分别操作同一个商机一个把阶段从“已报价”改成“谈判中”另一个把“预计成交时间”改了。后提交的一方把整个商机记录覆盖导致前面的阶段变更丢失。原因更新商机用了整行update不是按字段更新后提交的请求把stage字段的旧值写回去了。解决所有商机的更新操作只update允许修改的字段并且带上乐观锁版本号。MyBatis-Plus的Version注解加上之后更新语句会自动带上WHERE version ?版本不一致时更新0行代码里判断影响行数后提示用户数据已被其他人修改请刷新后再试。5.3 Redis缓存与数据库数据不一致现象客户资料改完界面显示的是旧值过几分钟才变过来。原因更新数据库后没有主动删缓存Redis中的旧数据一直躺着直到过期。常见于客户档案和车辆信息——这两个是高频读的数据缓存时间通常设得比较长。解决养成“先更新数据库再删除缓存”的习惯而不是更新缓存。删除缓存之后下一次读取会miss然后从数据库加载新值回填。如果担心删除缓存和数据库更新之间出现并发读写导致旧值回填可以把缓存key设计成带版本号或时间戳更新数据库时同时换一个新的key旧key自然过期——这是我个人更推荐的做法逻辑简单副作用最少。5.4 MyBatis-Plus自动填充被覆盖现象创建时间和更新时间两个字段新增记录时明明写了TableField(fill FieldFill.INSERT)但插入后时间是空。原因实体类里手动set了这两个字段的值把自动填充给顶掉了。MyBatis-Plus的自动填充只在字段值为null时生效手动赋值后框架不会覆盖。解决实体类的创建时间和更新时间不设默认值也不在业务代码里set统一由MetaObjectHandler实现类填充。如果某些场景需要自定义时间比如补录历史数据单独做接口处理不走常规插入逻辑。5.5 分页查询的内存溢出隐患现象线索列表页第一次打开正常翻到后面几页越来越慢最后直接超时。原因用了PageHelper但没有注意它拦截的是下一条SQL如果Service里先执行了一条非查询SQL分页插件会拦截错对象。更常见的是pageSize没有校验有人传了一个10000直接把全表查出来了。解决前端传参做严格的Min和Max校验限制pageSize最大100分页查询的service方法里先执行count查询再执行数据查询把两条SQL分开写避免插件误判。6. 从能跑到好用系统验证方法与三个进阶优化系统开发完成之后很多人直接联调前端就上线了结果发现并发一上来就各种问题。验证一个CRM系统有没有达到生产标准我习惯按三层来测功能层看看核心业务链路是否完整——线索进来到分配、跟进、商机流转、成交整条链路走下来数据是否正确接口层用JMeter或类似的工具发压测重点看线索分配接口和客户档案查询接口在50并发下的响应时间超过500毫秒就要查索引和缓存数据层跑一遍第二天凌晨的提醒任务生成脚本确认任务数量正确、没有重复、状态流转正常。三个进阶优化按优先级排。第一个是客户档案的读写分离——MySQL主库承担写入从库承担报表查询和列表查询用MyBatis-Plus的动态数据源注解做到读写分离。第二个是热点数据的多级缓存——客户档案在Redis之外加一层本地缓存Caffeine按门店维度配置每个门店的销售反复查自己的客户本地缓存命中率能到70%。第三个是操作日志的异步落库——跟进、分配、阶段变更这些操作日志如果同步写入数据库高并发时段会出现阻塞改成先发送到内存队列由单独线程批量落库性能提升明显。我自己的习惯是每做一个模块就写一个对应的验证脚本而且是把核心校验逻辑做成自动化接口——比如验证客户手机号格式、验证商机阶段跳转合法性、验证提醒任务的生成规则。这些都跑在代码里单元测试覆盖关键分支。走上正轨之后你会发现汽车CRM的技术难度不在某个单独的功能而在于那些跨模块的关联逻辑线索变成商机、商机关联车辆、车辆触发提醒、提醒促进复购这条链路里的每个环节都有数据一致性的问题等着你。做之前把这套模型想清楚比写一百个接口都管用。希望这些拆解和踩坑记录能帮你少走一段弯路。本文还有配套的精品资源点击获取