
简介这是一套面向Java初学者与企业级Web开发入门者的CRM客户关系管理系统实战源码基于SpringBoot快速构建聚焦客户信息管理、跟进记录、统计分析等核心业务场景助力开发者掌握企业级后台系统开发全流程。资源包含188个文件涵盖84个Java后端逻辑类、35个jQueryBootstrap前端交互脚本、23个FreeMarker模板页、13个MyBatis映射配置及9个CSS样式文件辅以SQL建表语句与SpringBoot配置文件完整呈现前后端分离式开发结构压缩包仅2.21MB轻量易导入。已有3300人学习下载配套MySQL 5.7数据库脚本与全量源码开箱即用支持在IDEA或Eclipse中一键运行适合用于课程设计、毕业项目或SpringBoot技术栈进阶实践。1. 项目整体设计与模块拆解最近花了两天时间把一套基于 Java SpringBoot 的 CRM 客户关系管理系统源码完整过了一遍从项目结构到业务代码再到部署脚本都捋了个遍。这套源码不是那种只做了登录注册的玩具项目而是带客户管理、线索管理、商机管理、合同回款、报表统计、RBAC 权限控制这些成熟业务模块的完整系统非常适合准备做毕业设计、公司内部系统二次开发或者想系统学习 SpringBoot 企业级项目做法的同学去研究。1.1 技术栈选型与原因先聊聊这套源码最核心的技术选型。后端用的是 SpringBoot MyBatis-Plus MySQL Redis权限这块是 Spring Security 配合 JWT前端是 Vue Element UI打包后由 Nginx 托管。这套组合算得上是当前中小型企业管理系统的标准配方了每一个选型背后都有它非常实际的理由SpringBoot不用再像 SSM 时代那样写一堆繁琐的 XML 配置内嵌 Tomcat一个 fat jar 直接跑起来极大降低了项目搭建和部署的门槛。这套源码用的是 SpringBoot 2.x稳定且生态成熟各种第三方组件都有现成的 starter。MyBatis-Plus和原生 MyBatis 相比内置了通用 Mapper 和通用 Service单表 CRUD 几乎不用写 SQL开发效率直接翻倍。同时保留了自定义 SQL 的灵活性复杂多表联查还能自己手写 XML。MySQL关系型数据库里的常青树单表几百万数据量级完全够用运维成本低社区资料到处都是遇到问题一搜基本都能解决。Redis在项目里主要用来做缓存和数据字典的存储另外还承担了验证码的临时存储、JWT 的黑名单、以及一些热点数据的缓存。这些场景都要求读写速度快Redis 的内存操作特性天然匹配。Spring Security JWTSpring Security 是 Java 生态中公认的安全框架和 SpringBoot 集成非常顺滑。JWT 让前后端分离架构下的无状态认证变得特别简单服务端不需要维护 session解决了多实例部署时 session 不一致的问题。Vue Element UI后台管理系统的经典前端组合。Element UI 组件丰富表格、表单、弹窗、分页这些后台高频组件全都覆盖了二次开发成本低。我看过不少网上的开源 CRM 项目像悟空 CRM、芋道源码这些主流方案和这套源码的差别其实不大基本都是这套技术栈。这反过来也说明这套选型是经过大量企业项目验证过的不是作者拍脑袋定的。1.2 包结构与分层思路这套源码的包结构走的是现在最主流的 Controller-Service-Mapper 三层架构不过在这个基础上还做了更细的划分。大体是这样的com.example.crm ├── controller // 接口层只做参数接收和结果返回 ├── service // 业务层核心业务逻辑都在这 │ └── impl // 实现类 ├── mapper // 数据访问层放 MyBatis-Plus 的 Mapper 接口 ├── entity // 数据库实体类 ├── dto // 入参和出参的数据传输对象 ├── vo // 视图对象给前端展示用的 ├── common // 公共模块返回结果封装、全局异常处理、常量等 ├── config // 配置类SpringSecurity配置、Redis配置、Swagger配置等 ├── utils // 工具类 └── aspect // AOP切面日志、权限、数据权限等这个分层最核心的原则就是“各层职责单一依赖从上往下”。Controller 层不写业务逻辑只负责接收前端参数、调用 Service、把结果包成统一结构返回。Service 层是业务逻辑的核心事务控制、数据组装都在这一层完成。Mapper 层只和数据库打交道不掺和业务。很多新手写项目的时候喜欢把业务逻辑写在 Controller 里看这套源码的时候可以特别注意一下一个查询客户列表的接口Controller 可能就几行代码真正复杂的逻辑都在 Service 实现类里面。这样做的好处很明显一是代码可读性强二是测试好写三是以后做微服务拆分的时候Service 层的逻辑可以直接搬过去。另外这套源码还把入参出参DTO/VO和数据库实体Entity做了分离。比如新增客户的时候前端传的参数需要一个 CustomerDTO 来接收而不是直接拿 Customer 实体类接收。这个细节很多人会忽略但实际上非常重要。如果直接用实体类接收前端参数那么前端传一个 status 字段你就更新 status传一个 remark 你就更新 remark很容易暴露出不该暴露的字段甚至在批量更新的时候被恶意篡改数据。DTO 可以根据接口的实际需求来定义字段相当于做了一层数据防火墙。1.3 核心表结构设计CRM 系统说到底核心就是围绕“客户”这张表来展开的。我对着这套源码的数据库脚本把核心表结构梳理了一遍大概有这么几张核心表customer 客户表客户名称、客户编号、所属行业、客户来源、客户等级、联系人、联系电话、地址、状态、所属员工ID、所属部门ID、创建时间、更新时间等。customer_contacts 联系人表姓名、电话、职位、微信号、邮箱、所属客户ID、是否主要联系人等。crm_business 商机表商机名称、客户ID、商机金额、预计成交日期、当前阶段、赢单率、所属员工等。contract 合同表合同编号、客户ID、合同金额、签约日期、开始日期、结束日期、合同附件路径、状态等。receivables 回款表回款编号、合同ID、回款金额、回款日期、回款方式、备注等。crm_clue 线索表线索来源、线索名称、联系电话、线索状态、分配员工、转换状态等。customer_pool 客户公海表实际就是记录进入公海的客户ID、进入原因、进入时间等。sys_user 用户表、sys_role 角色表、sys_menu 菜单表这三张表是 RBAC 权限模型的基础。这些表之间的关系可以用生活化一点的方式来理解线索是还没确认需求、还没建立合作的可能客户客户是已经建立联系、有潜在合作或已合作的对象一次销售机会就是商机商机推进到最后签约就变成合同合同签订后的回款就是回款记录。整个链条从线索到回款逻辑非常清晰。这套源码的建表脚本还做了一些很规范的设计比如所有业务表都有 create_time、update_time 字段并且在 MyBatis-Plus 里通过 MetaObjectHandler 自动填充不需要在业务代码里手动 set。另一套值得学的操作是逻辑删除所有核心表都加了 deleted 字段删除操作就被改写成了更新操作这样就算手误删了客户数据也能快速恢复不会造成不可逆的损失。2. 核心业务模块源码解析2.1 客户管理增删改查以外的细节客户管理是 CRM 系统的地基模块看起来就是标准的 CRUD但真正写起来还是有很多值得看细节的地方。先说分页条件查询。这套源码用的是 MyBatis-Plus 的分页插件调用 Page 对象和 QueryWrapper 来拼条件。看了一个列表查询的源码大致逻辑是这样的public PageCustomerVO pageCustomer(CustomerQueryDTO dto) { PageCustomer page new Page(dto.getPageNum(), dto.getPageSize()); LambdaQueryWrapperCustomer wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.isNotBlank(dto.getCustomerName()), Customer::getName, dto.getCustomerName()) .eq(dto.getLevel() ! null, Customer::getLevel, dto.getLevel()) .eq(dto.getOwnerUserId() ! null, Customer::getOwnerUserId, dto.getOwnerUserId()) .orderByDesc(Customer::getCreateTime); PageCustomer customerPage customerMapper.selectPage(page, wrapper); // 转换为VO并填充额外字段 }这里有两个小细节值得注意一是用了LambdaQueryWrapper而不是普通的 QueryWrapper。LambdaQueryWrapper 通过方法引用来指定字段编译期就能校验字段名是否正确避免手写字符串字段名出错。二是where 条件拼装都用了条件判断第一个参数是 boolean 条件只有条件成立时才会把查询条件加进去。比如 StringUtils.isNotBlank(dto.getCustomerName()) 为 true 时才会拼接 name 的模糊查询。这样就不用像早年写 MyBatis 的 XML 那样堆一堆 if 标签。再说客户信息的校验和去重。源码里的新增客户方法用了 Spring 的注解校验在 DTO 字段上标了 NotBlank、Size 之类的注解Controller 层加一个 Validated 就能在进入业务逻辑之前完成参数校验。客户名称这个字段还做了重复校验加了一个逻辑先根据客户名称查一次库如果存在相同名称的客户就直接抛出业务异常。这个去重逻辑虽然会多一次数据库查询但是实际使用中非常有必要。要是不做限制同一个客户被不同销售录了好几遍后面统计和分析的数据就全乱了。第三块是 Excel 批量导入。CRM 系统里这个功能几乎必配毕竟让销售手工一条一条录客户信息体验太差了。这套源码用的是 EasyExcel 来解析 Excel 文件新版 EasyExcel 是 FastExcel。核心逻辑是把上传的 InputStream 解析成 List 对象然后批量插入数据库。批量插入这里有个性能相关的坑如果直接用 MyBatis 的 saveBatch默认每批 1000 条写几万条数据时速度还可以接受但如果数据量大到几十万条就需要调大 batchSize 或者改成分批插入的事务策略不然可能塞爆内存。这也是为什么开源版本的导入模块一般都会加一个“单次最多导入X条”的限制。2.2 线索转客户与商机推进线索模块是我觉得这套源码里最有 CRM 业务味道的一块。线索不是普通意义上的客户它是潜在客户的原始信息可能是来自官网留资、市场活动、第三方购买等不同渠道。线索的完整生命周期在这个项目里是这样的新增线索销售或市场人员录入记录来源、姓名、电话、需求概述。线索分配管理员把线索分配给具体销售销售负责跟进。线索跟进记录每次沟通时间、沟通内容、下一步计划。线索转客户当销售确认这个线索有明确的合作意向就可以把它转换为正式客户。无效线索关停如果联系多次后确定没有意向可以关闭线索并记录原因。线索转客户这个动作源码里是一个事务方法我截取一下核心逻辑Transactional(rollbackFor Exception.class) public void convertClue(Long clueId, Long userId) { // 1. 校验线索状态 Clue clue clueMapper.selectById(clueId); if (clue null || clue.getDeleted() 1) { throw new BizException(线索不存在); } if (clue.getStatus() ! ClueStatusEnum.FOLLOWING.getCode()) { throw new BizException(当前线索状态不可转换); } // 2. 创建客户记录 Customer customer new Customer(); customer.setName(clue.getName()); customer.setPhone(clue.getPhone()); customer.setSource(clue.getSource()); customer.setOwnerUserId(userId); customerMapper.insert(customer); // 3. 更新线索状态 clue.setStatus(ClueStatusEnum.CONVERTED.getCode()); clue.setConvertCustomerId(customer.getId()); clueMapper.updateById(clue); }这段代码加了 Transactional 注解保证了创建客户和更新线索状态这两个操作要么同时成功要么同时回滚。这个是很典型的做法要是不加事务可能出现客户创建了但线索状态没更新下次还能重复转换一顿操作就把数据搞脏了。商机模块也是类似的状态机设计。商机从初步沟通、需求确认、方案报价、谈判、赢单/输单每个阶段都有对应的阶段编码和预计赢单率。每次推进商机阶段时系统会自动同步更新赢单率和预计成交日期。这块在源码里做成了一个 Comon 状态机辅助类把状态流转的合法性集中管理避免在业务代码里到处散落 if-else。2.3 公海客户与分配机制公海客户这个概念CRM 圈外的人可能不太熟简单解释一下长时间没有被员工跟进的客户会被系统自动收回放到一个公共池子里其他销售可以去领取。这样做是为了避免“占着客户不出单”的情况把客户资源盘活。这套源码的公海规则是这样的在系统配置里设置了“N 天未跟进自动进入公海”的参数。通过 Spring 自带的 Scheduled 定时任务每天凌晨执行一次检查。扫描客户表把 last_follow_time 超过 N 天且状态为“未成交”的客户插入到公海记录表同时把客户表的归属员工置空。这个定时任务的核心代码大致长这样Scheduled(cron 0 0 2 * * ?) Transactional(rollbackFor Exception.class) public void autoPutCustomersToPool() { LocalDateTime deadline LocalDateTime.now().minusDays(config.getPoolDays()); ListCustomer expiredList customerMapper.selectList( new LambdaQueryWrapperCustomer() .isNotNull(Customer::getOwnerUserId) .lt(Customer::getLastFollowTime, deadline) .eq(Customer::getStatus, CustomerStatusEnum.ACTIVE.getCode()) ); for (Customer customer : expiredList) { CustomerPoolRecord record new CustomerPoolRecord(); record.setCustomerId(customer.getId()); record.setReason(超过 config.getPoolDays() 天未跟进); record.setPreOwnerUserId(customer.getOwnerUserId()); customerPoolRecordMapper.insert(record); customer.setOwnerUserId(null); customerMapper.updateById(customer); } }公海领取的并发控制是一个容易被忽视的点。如果两个销售同时点击领取同一个客户都查到了这个客户的 ownerUserId 为空然后都执行了更新那最后这个客户会被第二个更新的人“抢走”第一个操作却以为领取成功了。源码里解决这个问题的方式是在更新 SQL 里加条件判断UPDATE customer SET owner_user_id #{currentUserId} WHERE id #{customerId} AND owner_user_id IS NULL这种方式本质是利用数据库的原子性来保证“只有归属为空才能被领取”如果更新影响行数为 0说明客户已经被别人领走了直接抛出“手慢了客户已被领取”的异常。这种乐观锁的做法在业务系统里非常实用不需要引入额外的分布式锁组件代码量少性能也好。3. RBAC 权限与数据隔离CRM 系统里权限是个必须聊透的话题。销售只能看到自己的客户销售主管能看到自己团队所有成员的客户老板能看到全公司的客户这种需求在每一个 CRM 项目里几乎都有。这套源码用了业界最经典的 RBAC 模型并在上面扩展了数据权限的控制范围。3.1 权限模型五张表如何串起来标准的 RBAC 模型在数据库层面就是五张表sys_user用户表、sys_role角色表、sys_menu菜单表、user_role用户角色关联表、role_menu角色菜单关联表。用户和角色是多对多关系角色和菜单也是多对多关系。一个用户登录后系统通过用户ID关联查询出他所有的角色再通过角色ID关联查询出所有菜单权限最后把权限信息缓存到 Redis 里避免每次请求都去数据库反复查询。菜单表的层级结构是这样的sys_menu ├── id ├── parent_id // 父菜单ID顶级菜单为0 ├── menu_name // 菜单名称 ├── path // 路由路径 ├── perm // 权限标识如 customer:add、customer:delete ├── type // 类型目录、菜单、按钮 └── sort // 排序这里有个值得注意的设计按钮权限也是菜单表里的一个节点。比如“新增客户”这个按钮在菜单表里对应一条 perm 为 customer:add 的记录type 为按钮。如果某个角色没有分配 customer:add 这条数据那这个角色下的用户登录前端后页面上根本看不到“新增客户”这个按钮。按钮权限的前端控制是通过 Vue 的自定义指令实现的核心思路是登录后把当前用户的所有权限标识列表存到 Vuex然后自定义一个 v-permission 指令渲染按钮前检查权限列表里有没有对应的标识没有就移除这个按钮。这个做法比单纯后端接口校验要好因为后端接口校验只能保证接口不被主动调用但前端页面如果不控制用户还是能看到按钮点了才知道没权限体验很差。前后端双重控制既保证了安全也保证了交互上的友好性。3.2 SpringSecurity JWT 集成这套源码的认证流程我完整跟了一遍逻辑非常清晰用户输入账号密码前端发起 /login 请求。后端用 Spring Security 的 AuthenticationManager 做用户名密码校验。校验通过后生成 JWT Token返回给前端。前端把 Token 存到 localStorage每次请求在请求头里带上Authorization: Bearer xxx。后端通过一个 JWT 认证过滤器从请求头取出 Token解析出用户ID然后查询用户信息和权限列表设置到 SecurityContextHolder 里。后续的接口访问通过 Spring Security 的注解做权限校验。JWT 的生成和校验源码里用的是 jjwt 库。Token 里一般只放用户ID和过期时间这些必要信息不放用户的敏感数据因为 JWT 是 base64 编码的并不是加密的谁拿到都能解出来看。Token 有效期一般设置成 24 小时过期后前端会收到 401然后自动跳转登录页重新登录后再继续操作。Spring Security 的过滤器链配置是这套源码里最容易看懂也最难改对的地方。放行的路径比如 /login、/captcha、/doc.html 这些在 configure 方法里要明确配置其他所有请求都要经过认证。另外还要配置自定义的访问拒绝处理器当用户访问没有权限的接口时返回一个统一的 JSON 格式错误而不是默认的 403 页面。3.3 数据权限的实现方式数据权限和菜单权限是两回事。菜单权限决定“能看哪些页面、点哪些按钮”数据权限决定“能看哪些数据行”。像前面提到的那样销售只能看自己的客户主管能看整个团队的客户老板能看全公司的客户。这套源码实现数据权限的方式是在 SQL 层做数据隔离。系统里定义了一个 DataScope 注解标注在需要做数据权限控制的方法上。通过 AOP 切面拦截在查询前把当前用户的数据权限范围自动注入到 SQL 条件里。具体方式是通过 MyBatis-Plus 的拦截器在 SQL 执行前动态拼接 where 条件。实现思路大致是这样的从 SecurityContextHolder 里拿到当前登录用户。判断用户的角色类型。如果角色是普通销售拼上AND owner_user_id 当前用户ID。如果角色是部门主管拼上AND dept_id IN (当前用户所在部门及其子部门)。如果角色是管理员不拼任何数据权限条件。这样做的最大好处是业务代码无感知Service 层写查询的时候不用关心数据权限的判断逻辑切面自动处理。缺点是 SQL 层面的动态拼装比较复杂如果项目里大量使用自定义 SQL需要在每个 SQL 里都预留数据权限的占位符容易漏掉。不过对于中小型 CRM 系统来说这种动态拼 SQL 的方式完全够用。一套源码学会数据权限的动态 SQL 拼接技巧后面在别的企业级项目里也能直接复用。4. 环境搭建与部署实战光看源码不跑起来等于白看。我把这套 CRM 从零到一完整部署了一遍本地开发环境和 Linux 服务器生产环境都过了一遍记录一下实操过程。4.1 本地开发环境快速跑起来本地跑起来需要准备的环境是JDK 1.8源码是 SpringBoot 2.xJDK 8 够用、Maven 3.6、Node.js 14、MySQL 5.7、Redis 5。初始化步骤创建数据库并导入脚本。在 MySQL 里建一个 crm 数据库然后执行源码里 sql 目录下的 init.sql 和 data.sql。建库的时候要注意字符集建议用 utf8mb4。修改 application.yml 配置。这是本地跑项目最容易踩坑的地方。数据库连接串、Redis 地址、JWT 密钥、文件上传路径这些都要确认一遍。特别是数据库连接串里的时区参数要检查推荐写成serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingUTF-8不然部署到服务器上容易出现时间相差 8 小时的诡异问题。启动后端服务。在项目根目录执行mvn clean install -DskipTests mvn spring-boot:run启动成功后控制台会输出 SpringBoot 的启动日志默认端口是 8080。如果启动失败大概率是数据库连不上、Redis 没启动、端口被占用这三类问题。启动前端工程。前端项目在单独的目录下执行npm install npm run dev前端默认端口一般是 9528开发模式下通过 Nginx 或者 devServer 的代理转发到后端的 8080 端口。浏览器的 DevTools Network 面板看一下请求路径能确认代理有没有配对。这套源码前端配了 Swagger 接口文档后端启动后访问/swagger-ui/index.html或者/doc.html看配置就能看到所有的接口列表方便调试和熟悉接口定义。对研究源码的人来说对照接口文档看代码效率能提升不少。4.2 Linux Docker 生产部署生产环境我比较推荐 Docker 部署。虽然这套源码本身没有自带 Dockerfile 和 docker-compose 配置但自己补齐其实不难。先写一个后端 DockerfileFROM openjdk:8-jdk-alpine WORKDIR /app COPY target/crm-system.jar /app/crm-system.jar ENV SPRING_PROFILES_ACTIVEprod EXPOSE 8080 ENTRYPOINT [java, -jar, /app/crm-system.jar]再写一个 docker-compose.yml把 MySQL、Redis、后端服务、前端 Nginx 一次性编排起来version: 3 services: mysql: image: mysql:5.7 restart: always environment: MYSQL_DATABASE: crm TZ: Asia/Shanghai volumes: - ./mysql/data:/var/lib/mysql - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql ports: - 3306:3306 redis: image: redis:5 restart: always ports: - 6379:6379 backend: build: ./backend restart: always depends_on: - mysql - redis ports: - 8080:8080 frontend: image: nginx:alpine restart: always depends_on: - backend ports: - 80:80 volumes: - ./frontend/dist:/usr/share/nginx/html - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf前端通过 Nginx 反代到后端的 /api 路径需要配置一下 proxy_passserver { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://backend:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }实际部署时还要注意一个比较隐蔽的问题JVM 内存参数。热词里有人搜 “java: outofmemoryerror: insufficient memory”这个在部署新项目时很容易遇到特别是服务器内存本来就只有 2G 或 4G 的情况下。推荐在启动命令里显式指定 JVM 内存java -Xms512m -Xmx1024m -jar crm-system.jar不要给 JVM 分太多内存要留出系统本身和 MySQL、Redis 要用的内存。我见过不少同事第一次部署就是这么踩坑的一启动服务MySQL 和 Redis 先崩了。4.3 二次开发扩展建议如果要在这套源码上做二次开发比如新增一个“回访记录”模块可以参考下面的落地步骤数据库建表比如 crm_visit_record包含 id、customer_id、visit_time、visit_content、create_by、create_time 等字段记得加逻辑删除字段。创建 Entity 实体类用 MyBatis-Plus 的注解映射表名和字段名。创建 Mapper 接口继承 BaseMapper。创建 DTO定义新增和查询时前端要传的参数。创建 Service 接口和实现类写业务逻辑比如添加回访记录时同步更新客户的 last_follow_time。创建 Controller暴露 RESTful 接口。前端新增页面在菜单配置里挂上路由和菜单节点。给角色分配菜单权限。这套流程走一遍基本上就把 SpringBoot 后端开发的完整套路摸透了。看源码的时候我建议多注意框架层面用了哪些东西比如 Swagger 的配置方式、Java 配置类里的 Bean 注册方式、Jackson 处理日期格式的方式、统一返回结果 Result 的结构这些在企业项目里是通用的。5. 常见问题与排查技巧实录5.1 启动失败高频问题本地跑项目和部署过程中我遇到的和在网上搜到的高频问题整理了一张速查表症状可能原因排查思路启动时报连接数据库失败数据库连接串配错、数据库服务没启动、账号密码不对ping数据库服务器用 Navicat/DBeaver 测试连接启动时报 Redis 连接失败Redis 服务没启动、密码没配对、端口被改本地redis-cli ping验证端口被占用上次启动的服务没停掉Linuxnetstat -tlnp | grep 8080找出进程 kill 掉登录后提示 token 过期系统时间和服务器时间差太多检查时区设置必须统一 Asia/Shanghai页面加载但接口 404前端代理没配对、Nginx 路径不对打开浏览器 network 看请求的实际路径中文乱码数据库连接串没加 characterEncoding连接串加useUnicodetruecharacterEncodingUTF-8登录接口返回 401 或者报错第一件事不是去翻源代码而是去看日志。这套源码全项目集成了 SLF4J Logback日志文件默认放在logs目录下。启动失败的原因日志最后几十行基本都会给出明确提示。我在排查问题的时候从不直接猜都是先看日志能省大量时间。5.2 代码层的坑与性能问题第一类坑是MyBatis-Plus 的 QueryWrapper 使用不当。最典型的是前端传入的排序字段直接被拼进 order bySQL 注入倒不至于MyBatis 参数化查询兜底了但可能出现列名不存在导致的 SQL 异常。源码里的处理方式是白名单校验前端传排序字段时只允许传入 sort 字段不在白名单里就使用默认排序。第二类坑是逻辑删除字段和多租户插件联用时的排他冲突。MyBatis-Plus 的逻辑删除本质上是在 SQL 中拼deleted 0多租户插件会在 SQL 中拼tenant_id 当前租户。如果两个插件同时起作用顺序不对可能产生错误。这套源码虽然没启用多租户但如果你后续做成 SaaS 版本需要特别注意插件顺序和字段拼装规则。第三类是N1 查询问题。列表查询时如果先在 Mapper 查出客户列表再在 Service 循环里查每个客户的回款金额或者跟进记录就会产生 N1 次查询数据量到一两百条的时候页面会明显卡顿。源码在客户列表这个场景用了自己写的自定义 SQL通过 LEFT JOIN 一次性把列表需要的数据查出来这类查询写在 mapper XML 里遇到列表性能问题优先看这些地方。第四类是批量导入数据量过大。前面提到的 OOM 问题在这套源码的 Excel 导入功能里尤其容易出现。FastExcel 的同步读模式会把所有行对象全部加载到内存如果导入 5 万行数据每行对象假设 200 字节再加上容器内存开销很容易触发堆内存溢出。解决方式是改用批次读取模式分批解析、批次写入事务内存峰值能降一个数量级。5.3 二次开发时最容易被忽略的两个地方第一新增接口后一定要记得在菜单表配置权限标识。Spring Security 的方法级权限校验靠的是PreAuthorize(hasAuthority(customer:add))这样的注解如果你在代码里新写了一个接口但没有给任何角色分配这个权限标识那所有人调用这个接口都会返回 403。这不是代码 bug而是权限数据没配置上。我见过好几个同事二次开发时被绕进去过明明代码没问题接口就是不通最后发现是少了权限数据。第二修改数据字典后要清理 Redis 缓存。这套源码的数据字典是缓存在 Redis 里的如果你直接在数据库里改了字典数据但 Redis 里还存着旧的前端下拉框仍然显示老数据。源码里提供了手动刷缓存的接口二次开发时改完字典记得调用一下或者干脆在修改字典时主动删除 Redis 里的 key。结尾我之前整理这套 Java SpringBoot CRM 源码的时候最大的一个感触是一个看起来功能不算复杂的业务系统仔细捋下来里面其实藏着大量值得琢磨的工程细节。从表结构设计到状态流转从权限模型到动态 SQL 拼接从定时任务到并发控制每个模块单独拿出来都能讲出一大堆门道。对于刚开始接触企业级 SpringBoot 项目的朋友这套源码作为学习蓝本特别合适——它不像学生大作业那样只有零散的增删改查也没有重到看完就劝退的微服务复杂度正好卡在“麻雀虽小五脏俱全”这个位置。如果你们公司刚好有类似的客户管理需求直接在这套代码基础上改改就能上线比从零开始造轮子划算太多了。后续如果大家需要我还可以把这套 CRM 的数据库表关系拆解和前端权限控制细节单独再写两篇来讲。本文还有配套的精品资源点击获取