资讯动态

基于Java的CRM系统开发实战:从Spring Boot到数据库设计全解析

发布时间:2026/10/9 1:13:32 来源:尧图企业网站定制
简介一份基于Java的客户关系管理系统CRM设计与实现的完整毕业设计文档面向计算机相关专业学生、Java Web开发初学者以及需要快速搭建客户管理系统的项目人员。文档以B/S架构为基础系统阐述从需求分析、可行性研究到功能模块设计、数据库规划、界面设计及系统测试的全过程覆盖客户信息管理、销售机会跟踪、服务请求处理等核心业务并给出具体实现思路与操作界面说明。资源共1个文件为docx格式压缩包大小3.84MB内容结构完整、目录清晰便于直接阅读或作为论文写作参考。目前已有72人学习下载适合用于毕业设计选题、论文撰写或CRM系统开发的借鉴。文档结合Java、JSP、MySQL和Tomcat等主流技术详细说明了系统如何降低人力成本、提升数据管理效率对理解B/S模式下的企业级应用开发具有较高的参考价值。1. 基于 Java 的客户关系管理系统到底在做什么从课程设计到真实业务的一次完整落地如果你搜到这个标题大概率正面临一门课设或毕设用 Java 写一个客户关系管理系统文档名字里还挂着 .docx。但别把这件事只当成交差——客户关系管理系统CRM的真实价值是让一个销售团队从「客户信息记在各自微信和 Excel 里」变成「所有客户、跟进、商机都在一个系统里可查可管」。这套东西做出来既能当毕业设计答辩的完整项目也能在中小公司里真正跑起来。我见过太多人一上来就写代码结果表结构设计错了后面每加一个功能都在打补丁。这篇笔记会顺着「技术选型 → 数据库 → 后端接口 → 排错」的路径把整个系统从零到能演示、能部署的关键步骤和踩坑记录拆给你看全是能复现的实际操作。2. 先定技术栈再写代码Spring Boot MyBatis-Plus Vue 的选型理由与最小工程结构2.1 为什么是 Spring Boot 而不是 SSM一个老项目的迁移成本对比十年前的 Java CRM 课程设计大多基于 SSMSpring Spring MVC MyBatis配置文件堆成山每个接口要写 XML 映射、写繁琐的配置类。现在做新项目我一般直接用 Spring Boot理由很朴素Spring Boot 的内嵌 Tomcat 让你不用再折腾 WAR 包部署自动配置把大部分样板代码消掉了写一个 REST 接口从建类到跑起来只要几分钟。如果你看网上老教程发现要配web.xml、springmvc.xml那是时代眼泪千万别照着做。还有一点常被忽略课程设计答辩时老师大概率会问「你的项目如何启动」。Spring Boot 只要mvn spring-boot:run或直接运行 main 方法部署时说清楚「内嵌 Tomcat不需要单独装」这本身就是一个加分项。MyBatis-Plus 更是省事它帮你把单表 CRUD 的 SQL 全部封装好你只需要写业务逻辑。有人担心 MP 会让 SQL 变黑匣子实际上它支持在 XML 里自定义复杂查询两者不冲突。2.2 建一个能跑的最小工程pom.xml 与目录结构的实战配置先建一个标准 Spring Boot 项目。我用最常用的方式Spring Initializr 选 Java 8如果服务器是 JDK 8或 11依赖选 Spring Web、MySQL Driver、MyBatis-Plus 框架在 Initializr 里叫 MyBatis Framework但更常用是手动加 MP 依赖。下面是核心pom.xml片段parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里选 Spring Boot 2.7.18 而不是 3.x是因为 3.x 要求 JDK 17而且javax包名变jakarta很多老毕业设计资料里的代码直接搬会报错。如果你只是做演示JDK 8 Boot 2.7 的兼容性最稳答辩现场也最好说话。目录结构我建议按功能分包而不是按层分包。常见错了的是建controller/service/mapper/entity四个包放所有模块结果客户和用户混在一起改一个东西要翻好几个文件。我一般按模块拆com.example.crm ├── common // 统一返回结果、异常处理、工具类 ├── config // MyBatis-Plus 分页、CORS、拦截器配置 ├── customer // 客户模块controller/service/mapper/entity ├── follow // 跟进记录模块 ├── user // 用户与登录模块 └── statistic // 统计报表模块这样新增一个功能时改动范围被限制在对应模块里就算你一个人写整个系统后期调试也比堆大包省时间。application.yml里最要紧的是数据库连接和 MyBatis-Plus 配置下面给一个我常用的最小配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/crm_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted # 全局逻辑删除字段 logic-delete-value: 1 logic-not-delete-value: 0值得注意的是serverTimezoneAsia/Shanghai这一项不加的话连接 MySQL 8 会报时区错误即使不报错时间出入 8 小时也够你查半天。map-underscore-to-camel-case开启后数据库的customer_name能自动映射到实体类customerName不用写一堆 ResultMap。3. 客户表、跟进记录表、权限表CRM 数据库设计的 5 张核心表与字段对照3.1 客户主表和联系人表字段怎么设才不会后期返工CRM 的第一张表永远是客户表。很多新手把客户当成一个大杂烩客户名称、联系人、电话、地址、备注全塞一张表。这在演示阶段没问题但一旦系统要支持一个客户有多个联系人你会后悔。标准做法是拆成客户主表和联系人表。客户主表customer的核心字段字段名类型说明idbigint主键自增或雪花customer_namevarchar(128)客户公司名必填加唯一索引industryvarchar(64)所属行业用字典值不要自由文本leveltinyint客户等级1普通 2重要 3 VIPsourcevarchar(32)来源渠道官网、转介绍、广告statustinyint1潜在 2跟进中 3成交 4流失deletedtinyint逻辑删除标记create_timedatetime创建时间update_timedatetime更新时间联系人表customer_contact则把姓名、电话、职位、微信、qq 放在这里通过customer_id关联客户主表。我最想强调的是电话字段不要用int因为手机号可能以 0 开头而且超出 int 范围用varchar(20)最稳。status状态字段建议用 tinyint 并放字典说明不建议存中文「潜在」「跟进中」否则后面做统计和流程引擎时你根本没法判断大小改起来全是硬编码。3.2 跟进记录与商机状态机用一张 status 字段管住销售漏斗跟进记录表follow_record是 CRM 的灵魂。它记录销售和客户每次交互的内容、时间、下一步计划。没有跟进记录的 CRM 只是一个联系人通讯录。这张表设计成这样CREATE TABLE follow_record ( id bigint NOT NULL AUTO_INCREMENT, customer_id bigint NOT NULL COMMENT 客户id, contact_id bigint DEFAULT NULL COMMENT 联系人id, content text COMMENT 跟进内容, next_plan varchar(255) COMMENT 下一步计划, follow_time datetime DEFAULT NULL COMMENT 实际跟进时间, status tinyint DEFAULT 1 COMMENT 1待处理 2已完成, create_by bigint COMMENT 跟进人用户id, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_customer (customer_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;为什么加contact_id因为一次跟进可能只针对某个具体联系人比如「和张总谈了合同细节」如果联系人字段冗余在 follow_record 里以后联系人改名了就没法追踪。create_by用来区分这条跟进是谁写的后面做「我的客户」和「团队客户」权限隔离时直接靠它过滤。商机表我建议合并进客户表用status字段模拟状态机潜在客户 → 跟进中 → 成交/流失。很多真实 CRM 有独立的商机表和 stage 字段但课程设计或小团队用状态机足够。你只需要在 Service 层写清楚状态流转规则比如status 2跟进中的客户不能被直接删除必须先进3或4。这种业务规则写在代码里比数据库触发器好调。3.3 用户与角色表基于 RBAC 的权限模型在 CRM 里的简化实现CRM 一定不能所有销售看所有客户否则数据隐私和业绩归属都会乱。我用最经典的三张表user、role、user_role关联表不做菜单权限表因为课设和中小团队用不到按钮级权限。用户表需要加一个dept_id来区分销售团队至少要有部门维度。真实业务里一个销售只能看自己的客户销售主管可以看整个部门的客户这是最常见的权限需求。实现方式很简单在客户表加owner_id归属人查询时用 MyBatis-Plus 的LambdaQueryWrapper加上过滤条件// 当前登录用户从 SecurityContext 里取 Long currentUserId getCurrentUserId(); Integer roleCode getCurrentUserRole(); LambdaQueryWrapperCustomer wrapper new LambdaQueryWrapper(); if (roleCode 1) { // 销售只看自己的 wrapper.eq(Customer::getOwnerId, currentUserId); } else if (roleCode 2) { // 主管看本部门可以通过用户表映射部门再 in 子查询 wrapper.inSql(Customer::getOwnerId, select id from user where dept_id (select dept_id from user where id currentUserId )); }这里注意防止 SQL 注入inSql里的字符串不能直接拼外部输入这里用的是用户 ID 和内部逻辑所以安全。权限控制的本质就是查询条件不要想着在 Controller 层每个接口都手动判断一遍那样维护成本太高。4. 从登录到客户列表用 Spring Security JWT 跑通核心业务接口4.1 登录鉴权JWT 令牌在前后端分离场景下的配置与拦截现在做 CRM 几乎都是前后端分离Vue 或 React 前端访问后端 REST API。登录后会话怎么保持传统 Session 在跨域和集群部署时很麻烦所以我用 JWT登录成功后签发一个令牌前端每次请求放在Authorization头里后端拦截器解析令牌并取出用户 ID。Spring Security 配置虽然是出了名的繁琐但只做 JWT 认证的话可以精简。我提供一个能跑通的最小实现思路写一个JwtAuthenticationFilter继承OncePerRequestFilter在doFilterInternal里解析请求头然后手动把用户信息放进SecurityContextHolder。public class JwtAuthenticationFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { token token.substring(7); try { Claims claims Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody(); Long userId Long.valueOf(claims.get(userId).toString()); // 这里简单构造一个 UsernamePasswordAuthenticationToken UsernamePasswordAuthenticationToken auth new UsernamePasswordAuthenticationToken( userId, null, new ArrayList()); SecurityContextHolder.getContext().setAuthentication(auth); } catch (Exception e) { // 令牌无效不设置认证信息即可 } } chain.doFilter(request, response); } }逻辑说明这段代码先检查请求头是否有Bearer开头的令牌再用Jwts.parser()校验签名和解密。Claims里存了userId直接把它作为认证主体扔进SecurityContextHolder后续 Controller 里通过SecurityContextHolder.getContext().getAuthentication().getPrincipal()就能拿到当前用户 ID避免在每个接口里都传 user 参数。注意secretKey不要硬编码在代码里至少放在application.yml中生产环境用环境变量注入。痛点Spring Security 默认会拦截所有请求并弹登录表单你要在SecurityFilterChain里允许/api/auth/login匿名访问其余请求全部走 JWT 过滤。这个配置如果不熟悉很容易出现在线文档里复制过来仍 403 的情况。我的建议是在SecurityConfig里关掉 CSRF 和 Session因为 JWT 是无状态的。4.2 客户增删改查的 Service 层事务、分页与条件查询客户列表页是 CRM 使用频率最高的页面。后端接口必须支持分页、关键字搜索、状态筛选。MyBatis-Plus 的Page对象配合LambdaQueryWrapper写起来最方便Service public class CustomerServiceImpl extends ServiceImplCustomerMapper, Customer implements CustomerService { Override public PageCustomer pageCustomers(int page, int size, String keyword, Integer status) { LambdaQueryWrapperCustomer wrapper new LambdaQueryWrapper(); // 关键字匹配客户名称或联系人姓名这里用 exists 子查询防止 N1 if (StringUtils.hasText(keyword)) { wrapper.and(w - w.like(Customer::getCustomerName, keyword) .or().inSql(Customer::getId, select customer_id from customer_contact where contact_name like concat(%, keyword , %))); } if (status ! null) { wrapper.eq(Customer::getStatus, status); } wrapper.orderByDesc(Customer::getCreateTime); // 分页插件 PageCustomer pageObj new Page(page, size); return this.page(pageObj, wrapper); } }逻辑说明这段实现两个核心一是关键字同时搜客户名和联系人姓名联系人的搜索用inSql子查询避免在主表里查不到二是this.page是 MyBatis-Plus 的通用方法前提是配置了分页插件。很多人在这里翻车——不配置MybatisPlusInterceptor分页插件时page方法执行后会查出全部数据且total不对。分页插件是必须的看下面的配置。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这里DbType.MYSQL必须和你数据库一致否则分页方言不对会报错或生成错误的 limit 语句。还有一点新增和修改客户时要处理归属人和时间字段我一般直接用 MyBatis-Plus 的MetaObjectHandler自动填充不要在 Controller 里手动 set。4.3 跟进记录的批量导出POI 导出 Excel 的接口实现与内存坑CRM 里导出客户列表是高频需求也是最能体现工程经验的点。用 Apache POI 的XSSFWorkbook导出几千条数据问题不大但导出上万条时内存溢出OOM会让你服务直接挂掉。正确做法是用SXSSFWorkbook它是 POI 的流式版本只保留窗口内的行在内存中。RequestMapping(/export) public void export(HttpServletResponse response) throws Exception { ListCustomer list customerService.listAll(); // 实际要分批查 try (SXSSFWorkbook workbook new SXSSFWorkbook(100)) { // 窗口100行 Sheet sheet workbook.createSheet(客户列表); // 创建表头 Row header sheet.createRow(0); header.createCell(0).setCellValue(客户名称); header.createCell(1).setCellValue(行业); header.createCell(2).setCellValue(状态); // 填充数据注意从第1行开始 for (int i 0; i list.size(); i) { Row row sheet.createRow(i 1); row.createCell(0).setCellValue(list.get(i).getCustomerName()); row.createCell(1).setCellValue(list.get(i).getIndustry()); row.createCell(2).setCellValue(list.get(i).getStatus()); } response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filenamecustomers.xlsx); workbook.write(response.getOutputStream()); } }参数说明new SXSSFWorkbook(100)表示内存中只保留最近 100 行其余写入磁盘这是默认 100 行可调整。注意关闭顺序——workbook.write后一定要调用workbook.dispose()清理临时文件所以用 try-with-resources 最保险。另外导出接口不要把所有数据一次list()查出来数据量过万会卡死最好用流式查询或分批分页循环查每查一次就往 sheet 里写一次最后刷新。这块有个玄学问题Excel 打开提示「文件已损坏」。原因往往是response.setHeader(Content-Disposition, ...)里的文件名带了中文且没有做 URL 编码。我一般改成URLEncoder.encode(fileName, UTF-8).replaceAll(\\, %20)再拼到 header 里。这个坑我踩过两次每次都是帮别人查才想起来。5. 最容易翻车的 5 个坑从编码问题到数据一致性排查5.1 JSON 中文乱码响应编码不一致导致的前端显示黑匣子现象前端拿到接口数据后中文全变成类似????或系统的乱码。原因通常不是前端问题而是后端的Content-Type没有指定charsetutf-8。Spring Boot 默认的MappingJackson2HttpMessageConverter编码是 UTF-8但如果你在 Controller 里直接操作HttpServletResponse写出字符串或者自定义了WebMvcConfigurer覆盖了消息转换器就可能导致乱码。解决全局配置里强制 UTF-8。最简单的做法是在application.yml里加server: servlet: encoding: force: true同时确认数据库连接字符串里已经有characterEncodingutf8。如果已经写了但还乱码检查 MySQL 表是不是utf8mb4utf8在 MySQL 里不是真正的 UTF-8utf8mb4才是。排查时先看响应头curl -i http://localhost:8080/api/customer/list如果Content-Type显示application/json没有 charset就在WebMvcConfigurer里手动加消息转换器强制设置MediaType.APPLICATION_JSON_UTF8。这个坑在一开始配置好后基本不会遇到但当你从网上复制一段 CORS 配置时很可能会破坏给一位。5.2 MyBatis-Plus 分页失效total 总是 0 或查出全部现象调用this.page()方法后能返回数据但total字段是 0或者 limit 没起作用。原因没有注入分页插件。这是 MP 最常见的误用。注意MyBatis-Plus 3.5 版本后PaginationInnerInterceptor的包路径有变化而且如果你引入了多个 MyBatis 插件分页拦截器必须放在最后一个否则会和别的拦截器冲突。解决确保MybatisPlusInterceptor被 Spring 管理且PaginationInnerInterceptor里的DbType.MYSQL正确。最好在main方法启动时打印一行日志确认插件加载。另外如果分页查询时 wrapper 里使用了group by分页 SQL 会变成SELECT COUNT(*) FROM (SELECT ...) TOTAL这个 MP 能处理但count结果可能不是你想要的必要时自己写count查询。5.3 逻辑删除字段和唯一索引冲突客户重复录入现象删除了一个客户再新增一个同名的客户数据库报 Duplicate entry。原因你在customer_name上建了唯一索引但客户表用的是逻辑删除deleted字段。第一次逻辑删除后记录还在表里deleted1再次插入同名客户时唯一索引仍生效所以报冲突。解决唯一索引改成联合唯一索引(customer_name, deleted)。但这样有个新问题同一个客户被删除两次第二次删除后插入deleted2第三个同名的deleted3查询时deleted0才是正常逻辑删除值不能固定为 1。MyBatis-Plus 的逻辑删除只支持一个固定值所以我建议在业务层做处理删除前先查询是否存在未删除的同名客户或者干脆把逻辑删除字段改成deleted_at存删除时间唯一索引用(customer_name, deleted_at)删除后deleted_at不为 NULL这样多条删除记录因为deleted_at不同而不会冲突。这是很实用的细节。5.4 POI 导出大 Excel OOM堆内存设置 512M 也不管用现象导出 5 万条数据时接口超时日志出现java.lang.OutOfMemoryError: Java heap space。原因XSSFWorkbook 会把每个单元格对象建在内存里一个 5 万行 10 列的表头数据内存占用轻松超过 1GB。你调大堆内存只是延迟崩溃时间不是根本办法。解决换 SXSSFWorkbook原理上面已说过。再加一条血泪经验导出前先做数据分页查不要一次select * from customer拿 5 万条到内存。用 MyBatis-Plus 的last(limit 5000)分页或者流式游标查询。我一般这样写long total customerService.count(); int batchSize 2000; for (long offset 0; offset total; offset batchSize) { ListCustomer batch customerService.list( new LambdaQueryWrapperCustomer().last(limit batchSize offset offset)); writeBatchToSheet(workbook, sheet, batch, rowIndex); }注意last方法里拼接offset用了外部变量要确保offset是数字不是用户输入否则有注入风险。这里因为是服务端自己计算的所以安全。5.5 时间字段时区差 8 小时所有时间显示都不对现象数据库存的create_time是 14:00前端显示 22:00或者反过来。原因MySQL 连接 URL 没有指定serverTimezone或者指定成了GMT。Java 的java.util.Date拿到的是 JVM 默认时区的时间如果 JVM 时区和数据库时区不同就会出现偏移。解决连接串统一加serverTimezoneAsia/Shanghai同时 Spring Boot 的Jackson序列化时间时在application.yml里配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai实体类里的时间字段用LocalDateTime不要用Date。LocalDateTime本身不带时区配合 JDBC 8 驱动能保持数据库原本的时区语义。这个组合我用下来最稳。6. 最后一个技巧把 CRM 的可视化统计做到让答辩和领导都满意6.1 用 ECharts 展示销售漏斗后端返回聚合数据的最小接口CRM 系统里最能体现「设计与实现」水平的不是 CRUD而是统计报表。答辩或演示时一张销售漏斗图比十个列表页都有说服力。前端用 ECharts 的 funnel 图后端只需要一个接口返回各状态下的客户数量。我用 MyBatis-Plus 的QueryWrapper做聚合GetMapping(/funnel) public ResultListMapString, Object funnel() { QueryWrapperCustomer wrapper new QueryWrapper(); wrapper.select(status, count(*) as total); wrapper.groupBy(status); ListMapString, Object maps customerMapper.selectMaps(wrapper); // 转成前端需要的格式status 映射成中文名 return Result.success(maps.stream().map(m - { MapString, Object item new HashMap(); Integer status (Integer) m.get(status); item.put(name, statusName(status)); item.put(value, m.get(total)); return item; }).collect(Collectors.toList())); }逻辑说明selectMaps返回的Map里 status 和 total 是数据库原始值groupBy(status)是 SQL 聚合效率比在 Java 里循环统计高一个量级。注意selectMaps返回的total类型可能是Long或BigInteger转字符串再给前端避免 JSON 精度丢失MySQL count 结果大于 2^53 才会但保险起见。前端 ECharts 配置略关键是把[{name: 潜在, value: 20}, {name: 跟进中, value: 15}]直接塞给漏斗图的data。这个方法我之所以放在最后是因为很多人做完 CRM 只会展示表格和增删改查却忽略了统计这块。你哪怕只多做这一个接口配合一个页面上的图表整个项目的完成度立刻不一样。另外建议把统计接口放到/api/statistic/funnel并在 Service 层加一个简单的缓存比如用Cacheable缓存 30 秒防止多人同时点击时数据库压力变大——虽然演示环境无所谓但这个习惯能让你在谈到高并发时多一句从容。写到最后多说一句CRM 这种系统技术难度不在某个算法而在把业务状态理顺、把数据字段设计扎实。当年我第一个 CRM 课设就是贪全结果客户和联系人混一张表权限只做了登录没有任何数据隔离答辩被老师一句话问住。后来在实习公司修一个老 CRM 的 bug才明白字段设计和状态机比炫技重要得多。希望这篇笔记能帮你少走那段弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑