
1. 客户管理平台的业务定位与整体设计思路1.1 这个系统到底在解决什么问题做客户管理平台之前我一直在想一个问题很多小团队或者个人创业者手头客户信息散落在Excel表格、微信聊天记录、纸质名片、甚至脑子里。客户跟进了几次、上次沟通说了什么、答应过什么条件完全靠记忆和翻聊天记录。这种模式在客户量少的时候还能凑合一旦超过几十个客户基本上就乱套了。Spring Boot客户管理平台就是针对这个痛点来的。它把客户信息、跟进记录、联系人、商机动态这些核心数据统一收敛到一个系统里让管理员和业务人员能随时查到某个客户从首次接触到最后成交的完整链路。系统解决的不只是“存数据”的问题更重要的是让客户跟进过程变得可追溯、可量化、可协作。从技术选型上说这个平台选用Spring Boot作为后端框架是最稳妥的选择。Spring Boot在Java生态里的地位有点像“开箱即用的标准化工具包”它内置了Tomcat、自动配置、健康检查、外部化配置这些能力一个普通的订单管理系统可能只需要一个启动类加几个注解就能跑起来。相比传统的SSH架构或者纯Servlet开发Spring Boot把繁琐的XML配置全部干掉项目结构更清晰维护成本大幅降低。1.2 功能模块划分的核心逻辑在设计客户管理平台的功能模块时我遵循的原则是“业务闭环权限分明”。整个系统按角色分成三类用户系统管理员、业务人员、普通访客。管理员负责基础数据维护和账号管理业务人员负责客户录入、跟进、订单操作访客只具备查询和导出权限。核心功能模块我拆成了六大块。客户信息管理是地基所有业务都围绕客户展开。这里的客户信息不只是姓名电话那么简单还包括客户来源自然到访、老客转介绍、线上推广、展会收集客户等级A/B/C/D四级按成交意向和预算规模划分以及自定义的备注标签。设计上我用了单表存储加冗余字段的方式而不是搞复杂的E-R拆分因为客户信息的查询频率远高于写入频率冗余几个字段能省掉大量联表查询。跟进记录模块是系统的重头戏。客户不是录进去就完事了需要持续跟进才能转化。每条跟进记录要记录客户当前状态、沟通内容、下次跟进时间、跟进方式电话、微信、上门拜访。这个模块我设计了单独的跟进历史表而不是在客户表里直接存“最新跟进内容”因为跟进是一个持续过程历史轨迹比最新状态更有业务价值。联系人管理解决的是“一个客户对应多个联系人”的场景。很多企业客户不只是跟一个人对接可能有采购负责人、技术负责人、财务负责人都在这个项目里参与决策。所以我在客户表之外单独建了联系人表通过客户ID关联每个联系人可以设置是否为主要联系人、所在部门、职务、生日等扩展字段。商机管理是给销售团队用的。一条商机关联一个客户记录项目名称、预计金额、成交概率、预计结单时间、当前阶段。商机阶段的推进逻辑参考了经典销售漏斗模型初步接洽、需求确认、方案报价、商务谈判、赢单/输单。系统里我用一个状态字段标识业务人员每次更新商机状态时自动生成一条动态记录方便管理者看整个销售漏斗的转化情况。订单管理跟财务挂钩。客户成交之后生成订单订单里包含产品名称、数量、单价、折扣、总金额、付款状态。订单模块要留出“关联合同”的字段位置虽然论文里没有做真正的合同文件上传功能但字段预留意味着后续可以平滑扩展。统计报表模块是用来给管理层看的。按客户来源统计、按业务员统计成交金额、按月统计新增客户数这些报表我用ECharts做前端可视化后端提供聚合查询接口不搞复杂的OLAP直接SQL聚合就够用。1.3 为什么选Spring Boot而不是其他框架很多初学者会问为什么不用Python的Django或者Flask为什么不用Node.js这里有几个非常现实的考虑。Java生态在中小型企业管理软件领域依然是招聘市场和企业内部系统的主流。Spring Boot的社区活跃度极高遇到问题搜解决方案基本一搜一大把。对于一个要写论文的学生党或者刚入行的开发者来说遇到诡异bug时能找到参考案例比什么都重要。Spring Boot自带的安全机制和事务管理在某些业务场景下更可靠。客户管理系统涉及客户隐私数据权限控制和数据安全是硬指标。Spring Security虽然在配置上有学习曲线但一旦跑通角色权限管理非常扎实。Spring Boot的Transactional注解让事务控制变得极其简单比如创建订单时要同时扣减库存和更新客户成交状态任何一个步骤失败都应该回滚这种强一致性需求对金融敏感的业务来说很关键。另外一个现实考量是Spring Boot对部署环境的要求相对标准化。一个打包好的JAR文件扔到任何装了JDK8的服务器上就能跑不像某些框架还要装一堆运行时依赖。这对后面调试部署环节来说省了不少心。2. 数据库设计与核心数据模型2.1 数据表结构怎么设计才合理先说结论客户管理平台我设计了7张核心表用户表、角色表、客户表、联系人表、跟进记录表、商机表、订单表另外加了一张操作日志表用来记录关键操作。整个库的命名规范统一全部小写加下划线字符集采用utf8mb4排序规则用utf8mb4_general_ci。客户表的结构是花了最多心思的。我贴一下核心字段的设计思路CREATE TABLE customer ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, customer_no varchar(32) DEFAULT NULL COMMENT 客户编号业务唯一标识, customer_name varchar(100) NOT NULL COMMENT 客户名称, customer_type tinyint(4) DEFAULT 1 COMMENT 客户类型1企业客户2个人客户, customer_source varchar(50) DEFAULT NULL COMMENT 客户来源, customer_level char(1) DEFAULT C COMMENT 客户等级A/B/C/D, industry varchar(100) DEFAULT NULL COMMENT 所属行业, phone varchar(20) DEFAULT NULL COMMENT 联系电话, province varchar(50) DEFAULT NULL COMMENT 省份, city varchar(50) DEFAULT NULL COMMENT 城市, address varchar(255) DEFAULT NULL COMMENT 详细地址, remark varchar(500) DEFAULT NULL COMMENT 备注, owner_id bigint(20) DEFAULT NULL COMMENT 归属业务员ID, status tinyint(4) DEFAULT 1 COMMENT 状态1启用0禁用, create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_owner_id (owner_id), KEY idx_customer_level (customer_level) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT客户信息表;这里有几个设计细节值得说一下。客户编号customer_no我用了独立字段而不是直接拿主键id当编号目的是避免客户看到自己编号时察觉系统数据量。生成规则是“C”加日期加四位流水号比如C202501130001代码里用Redis自增或者查当日最大编号加一都行。索引的建立不是越多越好。我在owner_id和customer_level上建了索引因为这两个字段是高频查询条件。但像remark这种备注字段坚决不建索引因为查询场景极少建了索引反而降低写入性能并且浪费存储空间。联系人表和客户表之间是典型的多对一关系。一个客户可能有多个联系人所以联系人表里存customer_id作为外键同时冗余了customer_name字段。这里冗余客户名称是故意的因为联系人列表页面一般要同时显示所属客户名称如果每次都去联查客户表数据量大了之后查询性能会下降。跟进记录表是整个系统里写入最频繁的表。每次业务人员点击“新增跟进”就往里面插一条数据。这个表我刻意不做太多索引只保留customer_id和create_time的联合索引因为查询模式非常固定查某个客户的全部跟进记录按时间倒序排列。2.2 数据库事务和数据一致性要怎么保证客户管理平台的业务场景里有一个高频率操作必须保证事务一致性创建订单时同步更新客户状态。比如一个客户从“商机阶段”变为“已成交”系统要同时做三件事插入订单记录、更新商机状态为赢单、更新客户等级和状态。任何一个步骤失败数据就出现不一致。我写这段逻辑时的方式是这样的Service public class OrderServiceImpl implements OrderService { Autowired private OrderMapper orderMapper; Autowired private CustomerMapper customerMapper; Autowired private OpportunityMapper opportunityMapper; Override Transactional(rollbackFor Exception.class) public void createOrder(Order order) { // 1. 插入订单记录 orderMapper.insert(order); // 2. 更新商机状态为赢单 opportunityMapper.updateStatus(order.getOpportunityId(), WIN); // 3. 更新客户成交状态和等级 customerMapper.updateDealStatus(order.getCustomerId()); } }重点注意rollbackFor Exception.class这个参数。Spring的Transactional默认只在遇到RuntimeException时才回滚如果抛的是受检异常比如IOException事务不会回滚。所以我在实际开发中统一习惯把rollbackFor设为Exception.class避免某些异常被吃掉导致脏数据。还有一个容易踩坑的点事务方法不能通过this调用。如果你在一个类里写了事务方法A同类里另一个方法B直接调A()事务是不会生效的因为Spring的事务代理只拦截外部调用。正确做法是通过注入自身Service或者把事务逻辑拆到单独的Service类里。这个坑我在项目联调时踩过一次排查了半天才意识到是代理失效。2.3 数据库连接池和参数调优项目里连接池我用的Druid这是国内用得最多的数据库连接池。配置的时候几个关键参数要理解透彻。spring: datasource: url: jdbc:mysql://localhost:3306/crm_system?characterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: root123456 driver-class-name: com.mysql.cj.jdbc.Driver druid: initial-size: 5 min-idle: 5 max-active: 20 max-wait: 60000 validation-query: SELECT 1 test-while-idle: trueinitial-size是启动时初始化的连接数min-idle是最小空闲连接数max-active是最大活跃连接数。对于一个客户管理系统并发量不会特别高5到20的区间足够。max-wait是获取连接的超时时间60000毫秒的意思是如果60秒内拿不到连接就抛异常这个参数在生产环境可以调小一些比如30000否则请求会堆积得很厉害。validation-query设为SELECT 1是为了在每次获取连接时验证连接是否有效防止数据库重启后应用还拿着死连接。test-while-idle会在连接空闲时进行检测提前剔除坏连接。这些参数看起来不起眼但在真机部署时能避免大量“Communications link failure”的报错。3. 后端核心模块的工程化实现3.1 Spring Boot工程目录结构怎么摆项目我用的是标准的Maven单模块结构没有做multimodule拆分。原因很简单客户管理平台的业务量级还没到非要分模块的程度单模块部署简单打包快也方便论文里写清楚源码结构。后面如果真要扩展把AOP、MQ这些拆独立模块也不难架构上Spring Boot天然支持这种演进。目录结构如下com.crm.system ├── common // 通用工具类、统一返回结果、异常处理 │ ├── result │ ├── exception │ └── utils ├── config // 全局配置跨域、拦截器、MyBatis配置 ├── controller // 控制层接收HTTP请求 ├── service // 业务层核心业务逻辑 │ └── impl ├── mapper // MyBatis的Mapper接口 ├── entity // 数据库实体类 ├── dto // 数据传输对象用于接口入参校验 ├── vo // 视图对象用于接口返回 └── job // 定时任务比如定时提醒下次跟进分层原则严格遵循Controller做参数接收和响应封装Service做业务逻辑Mapper做数据持久化。但我不会教条地在Controller里只写一行调用Service的代码小操作比如根据ID查询单条客户信息Controller直接调用Service的方法就好没必要为了一两个字段的转换写大量VO类。实体类跟数据库字段的映射我推荐用MyBatis-Plus。它比原生MyBatis省事的地方在于内置了BaseMapper公共的增删改查一行代码都不用写。比如查询客户列表带分页ServiceImpl里写Page page this.page(new Page(current, size), wrapper)就搞定了完全不用手写SQL。复杂查询比如统计报表的多表聚合再单独在Mapper里写XML。3.2 统一返回结果和全局异常处理的硬编码规范接口设计我做了统一返回格式。前端不管请求成功还是失败拿到的数据结构永远是一致的这样前端解析逻辑可以固化成模板。返回格式定义如下Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }全局异常处理用RestControllerAdvice配合ExceptionHandler来实现。这个机制的好处是Service层只要抛出业务异常比如“客户名称为空”“该商机已被操作”统一异常处理器会把异常捕获并转成规范的Result返回前端不需要针对每种错误做特殊处理。这里我格外提一个细节业务异常一定不要抛Exception顶层类型要定义自己的BizException。否则全局异常处理器没法区分是业务逻辑错误还是系统内部错误返回给用户的提示信息就会很不友好甚至直接把SQL报错堆栈暴露给用户这在真实项目里是绝对不允许的。我在项目里还定义了一个自定义校验器用JSR-303注解处理入参校验。实体类上加NotBlank、Email、Pattern这些注解Controller入参加Validated非法参数直接在入口就被拦下来根本不会进入Service层。这样业务代码里不用写一堆if校验干净利索。3.3 客户管理核心业务流程的实现细节客户新增功能的完整流程是前端提交客户表单数据Controller用CustomerDTO接收数据并校验Service层将DTO转成Entity设置默认值比如客户等级默认为C、客户状态默认为启用、所属业务员取当前登录用户最后调用Mapper插入数据库。插入成功后返回带自增ID的客户对象前端拿到这个ID后就可以继续初始化联系人、写入第一条跟进记录。客户分页查询我用了MyBatis-Plus的LambdaQueryWrapper。这种写法相比字符串硬编码的好处是字段名写错编译期就会报错不会等到运行期才发现SQL拼错了。多条件组合查询的代码看起来是这样的Override public PageResultCustomerVO queryCustomerPage(CustomerQueryDTO dto) { LambdaQueryWrapperCustomer wrapper new LambdaQueryWrapper(); // 按客户名称模糊查询 if (StringUtils.hasText(dto.getCustomerName())) { wrapper.like(Customer::getCustomerName, dto.getCustomerName()); } // 按客户等级精确匹配 if (StringUtils.hasText(dto.getCustomerLevel())) { wrapper.eq(Customer::getCustomerLevel, dto.getCustomerLevel()); } // 按归属业务员筛选 if (dto.getOwnerId() ! null) { wrapper.eq(Customer::getOwnerId, dto.getOwnerId()); } // 按创建时间倒序排列 wrapper.orderByDesc(Customer::getCreateTime); PageCustomer page this.page(new Page(dto.getCurrent(), dto.getSize()), wrapper); // 转VO 填充归属人姓名 ListCustomerVO voList customerConverter.toVOList(page.getRecords()); return new PageResult(voList, page.getTotal()); }这里需要补充说明的是“转VO 填充归属人姓名”这个操作。Customer实体里只有ownerId但前端列表页要显示的是“张三”而不是“ID为12”所以查询出来之后要批量查用户表转换。这里我做了个优化先把所有客户的ownerId收集起来用in查询一次性查出对应的用户列表再组装Map按ID映射避免循环里逐条查用户表——也就是传说中的N1查询问题。这个优化在数据量只有几十条时感觉不出来但几千条客户数据时性能差距立竿见影。接下来是客户导入导出功能的实现思路。导入功能我用的EasyExcel支持Excel模板下载、批量导入、错误行定位提示。导出直接用EasyExcel写数据到输出流前端用Blob接收实现文件下载。这两个功能看起来是锦上添花但在实际使用中反而被用得最多因为很多业务团队还是习惯Excel里处理数据。4. 权限设计、登录认证与前端实现4.1 基于JWT的登录认证登录认证我用了JWT方案而不是传统的Session方案。原因很简单项目是前后端分离架构Vue前端和Spring Boot后端分别部署在不同端口甚至不同服务器Session这种依赖服务端状态的方案天然不太适合。JWT是无状态的服务端不用保存会话信息对水平扩展比较友好。实现逻辑是用户提交用户名密码到登录接口服务端校验通过后生成一个包含用户ID、用户名、角色ID的TokenToken有效期设置为24小时。前端拿到Token后存储到localStorage每次请求在HTTP头Authorization字段携带Token。后端配置一个拦截器拦截所有非白名单接口解析Token判断是否有效如果无效或者过期则返回401状态码。Token生成用jjwt库核心代码String token Jwts.builder() .setSubject(user.getUsername()) .claim(userId, user.getId()) .claim(role, user.getRole()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 86400000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();这里要提醒一个很多人会忽略的点JWT的secretKey在配置文件里不要写死在代码里专人保管部署环境里用环境变量注入。因为一旦泄露任何人都可以伪造Token绕过认证登录系统。论文里写代码可以把secretKey放在配置文件做演示但实际生产必须换掉。4.2 基于角色的权限控制怎么落地系统里我定义了三个角色ADMIN、SALES、VIEWER。权限控制采用注解配合拦截器的方式在Controller方法上标注RequiresRole(ADMIN)通过AOP或者拦截器在方法执行前校验当前用户的角色。这种方式代码侵入小可读性也强。但是光有后端权限校验是不够的。前端的菜单和按钮也要做权限控制否则用户虽然调不到接口但能看到页面结构体验很差。前端路由配置时给每个路由设置meta.roles字段比如客户管理模块允许ADMIN和SALES访问系统管理模块只允许ADMIN访问。用户在登录时拿到自己的角色信息动态过滤路由表没有权限的路由直接不注册。值得注意的是前端权限只能做展示层的限制真正的安全底线一定在后端。因为用户可以直接调接口绕过前端校验所以后端每个接口都必须校验角色权限这个观念必须从一开始就打牢。4.3 Vue前端页面与接口联调前端我选择的是Vue3加Element-Plus。为什么选Element-Plus因为它的表格、表单、弹窗、分页组件覆盖了管理后台80%以上页面需求。客户列表页面就是典型的“搜索表单加数据表格加新增按钮”这种页面用Element-Plus的标准组件组合一下午就能搭出来。接口联调阶段我封装了一个request工具基于Axiosconst service axios.create({ baseURL: /api, timeout: 15000 }); service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] token; } return config; }); service.interceptors.response.use(response { const res response.data; if (res.code ! 200) { ElMessage.error(res.message); return Promise.reject(new Error(res.message)); } return res; }, error { if (error.response error.response.status 401) { router.push(/login); } ElMessage.error(网络请求异常); return Promise.reject(error); });这里拦截器的逻辑要注意看三块请求拦截器给每个请求带上Token响应拦截器统一处理业务错误码非200的直接弹错误提示HTTP状态码401时自动跳转到登录页。这三块处理完每个业务页面请求后台接口时都不用重复写错误处理逻辑省了很多重复的if判断。客户列表页面的表格我用el-table实现数据由分页接口返回。搜索表单里放客户名称输入框、客户等级下拉框、所属业务员下拉框点击搜索按钮时把表单绑定的查询参数传给后端用current和size配合页码变化。这种CRUD页面的开发模式非常固定第一个页面写好了后面的订单管理、商机管理页面基本就是复制改字段名的事。5. 项目调试、打包与部署全流程5.1 本地开发环境搭建的几个细节开发环境我用的组合是JDK 1.8、Maven 3.8、MySQL 8.0、Node 16、Redis 6。这里特别注意JDK版本和Spring Boot版本的匹配。Spring Boot 2.7.x系列最高支持到JDK 17但如果用JDK 1.8开发Spring Boot版本建议选2.6.x到2.7.x之间最稳妥的是2.7.18这是2.x系列的最后一个版本修复了大量已知问题。Maven的镜像仓库配置也很关键。由于默认的中央仓库在国内访问速度极不稳定我在settings.xml里配置了阿里云镜像mirror idaliyunmaven/id mirrorOfcentral/mirrorOf name阿里云公共仓库/name urlhttps://maven.aliyun.com/repository/central/url /mirror配置完之后Maven下载依赖的速度可以说是飞一般的感觉遇到过下载依赖卡死半天的问题的同学应该都懂这个配置有多重要。Redis在项目里的用途主要是两块一是验证码存储登录页的图形验证码存Redis有效期两分钟二是公告或者一些热点数据的缓存。虽然这两块不用Redis也能实现但引入Redis能让系统结构更接近生产环境论文里加分不少。5.2 打包过程中的坑与经验Spring Boot项目打包分为两种模式JAR包和WAR包。我全部使用JAR包模式因为部署方式最简单一个java -jar命令就搞定。打包命令mvn clean package -DskipTestsskipTests必须带上因为项目里集的测试类如果没有写完整的MockMvc测试环境再跑一遍测试会浪费大量时间还有可能因为测试类找不到上下文直接报错。打包后生成的target目录下会有两个JAR包一个是xxxx-0.0.1-SNAPSHOT.jar另一个是xxxx-0.0.1-SNAPSHOT.jar.original。前者是Spring Boot的可执行JAR包含了所有依赖体积通常在50MB以上。后者是普通JAR不带依赖库。部署时一定要选那个大的可执行JAR放反了会导致ClassNotFoundException。JAR包里的配置文件在打包时会被打进包内但如果部署时想临时改数据库密码或者端口不需要重新打包只要在启动命令里加参数覆盖就行java -jar crm-system.jar --spring.profiles.activeprod --server.port8081这个技巧在临时切换环境或者调整启动参数时会方便很多。5.3 服务器部署的经典三步流程我这里以一台普通的Linux服务器为例操作系统是Ubuntu 20.04或者CentOS 7都适用。第一步是装环境。JDK的安装最省事的做法是apt install openjdk-8-jdk或者yum install java-1.8.0-openjdk安装完java -version验证一下。MySQL安装完之后要建库建用户并且把MySQL的bind-address设为0.0.0.0否则远程连不上数据库。Redis同理如果部署在同一台机器上本地连接不需要改bind配置。第二步是上传和启动。把JAR包通过sftp或者scp传到/opt/crm目录下然后编写启动脚本#!/bin/bash APP_NAME/opt/crm/crm-system.jar LOG_FILE/opt/crm/crm.log start() { nohup java -Xms256m -Xmx512m -jar $APP_NAME --spring.profiles.activeprod $LOG_FILE 21 echo Service started. } stop() { pid$(ps -ef | grep $APP_NAME | grep -v grep | awk {print $2}) if [ -n $pid ]; then kill -9 $pid echo Service stopped. fi } case $1 in start) start ;; stop) stop ;; restart) stop start ;; esac使用nohup后台运行并输出日志到文件是部署的标准姿势。Xms256m和Xmx512m是JVM堆内存的设置对于这个量级的系统足够注意不要贪多设置为物理内存的一半以内更稳妥。第三步是验证。启动完之后打开部署机器的IP加端口比如http://192.168.1.100:8080看到登录页面就说明部署成功。如果打不开依次检查防火墙systemctl status firewalld查看状态或者直接看crm.log日志定位问题。90%的部署失败问题都能在启动日志里找到答案不要盲目猜测。5.4 Nginx反向代理配置前端打包后生成dist目录静态文件需要一个Web服务器来承载。同时生产环境里一般不希望用户直接访问8080端口而是通过80端口访问。所以我在服务器上安装了Nginx做了反向代理配置server { listen 80; server_name your-domain.com; location / { root /opt/crm/dist; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里的关键配置是try_files $uri $uri/ /index.html这是Vue Router使用history模式时的必备配置。SPA应用只有一个index.html入口文件前端路由由JS控制用户如果直接刷新一个二级路由地址比如访问http://yourip/customer/listNginx默认会尝试找customer目录下的list文件结果肯定是404。加上try_files后所有找不到的静态路径都会回退到index.html由前端路由接管。反向代理将/api前缀的请求转发到后端8080端口。这样前端代码里的请求地址统一写/api/xxx不管开发还是生产代码里不用改请求地址环境配置只要Nginx代理规则正确就行。这也是前后端分离项目里非常主流的部署方案。6. 常见问题排查与避坑指南6.1 数据库时区报错一次解决很多第一次跑这个项目的人都会遇到一个经典报错大概是这样的The server time zone value йʱ is unrecognized or represents more than one time zone这个错误的原因是MySQL驱动8.0以后要求必须显式指定时区否则无法判断数据库所在时区。解决方法就是在数据库连接URL上加上serverTimezoneAsia/Shanghaiurl: jdbc:mysql://localhost:3306/crm_system?characterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai字符串编码乱码的问题一般是characterEncodingutf8没有加加了之后中文不会出现问号。另外如果使用MySQL 8.0以上版本驱动类名需要从com.mysql.jdbc.Driver改成com.mysql.cj.jdbc.Driver旧驱动类在8.0版本里已经被移除了。6.2 前端跨域问题的两种解决方式开发环境调试时前端地址是localhost:8081Vue默认端口后端是localhost:8080两者端口不同浏览器就会触发跨域限制。报错信息是类似Access to XMLHttpRequest at http://localhost:8080/api/login from origin http://localhost:8081 has been blocked by CORS policy。解决跨域常见的有两种方案我在项目里采用了后端CORS全局配置的方式Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowedOriginPatterns不要直接使用allowedOrigins()因为allowCredentials(true)和allowedOrigins()同时使用时浏览器会判定配置非法。这是一个比较隐蔽的坑实际开发中不少人在这里卡住。还有一种方式是前端Vite开发服务器配置proxy代理把/api的请求转发到后端。两种方式都可以但生产环境部署时Nginx反向代理已经把跨域问题从根源解决了所以开发环境用Vite proxy其实更贴近生产架构可以提前避免很多联调阶段的问题。6.3 数据库连接不上的排查步骤我遇到过不少同学问“为什么我启动项目报数据库连接失败”排查思路非常固定。第一步先确认MySQL服务启动了。Linux下systemctl status mysqld查看服务状态Windows下services.msc里看MySQL服务是否在运行。很多时候不是代码问题单纯是MySQL没启动。第二步确认账号密码和权限。命令行里直接mysql -uroot -p你的密码测试连接如果命令行连不上那应用肯定连不上。需要注意的是MySQL 8.0默认使用了caching_sha2_password认证方式有些旧版本的驱动不支持需要改成mysql_native_password。虽然新版驱动已经兼容这个认证方式但如果遇到认证插件报错ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 你的密码可以解决。第三步检查URL里的端口和库名。看看自己建的数据库名是不是叫crm_system端口是不是3306。这类问题数据面占了一半以上多半是库名打错了或者端口用成了3307。6.4 内存溢出问题的一个实战案例我测试系统时用JConsole观察过系统运行情况发现导出大量数据时偶尔会出现内存溢出。原因是EasyExcel导出大量数据到本地是流式写入但导出到Web响应流时如果不加限制直接把全部数据加载到内存再写出去数据量大时就会OOM。解决思路是分页查询配合流式写入GetMapping(/export) public void exportExcel(HttpServletResponse response) throws IOException { ListCustomer allCustomers customerService.list(); // 使用EasyExcel的ExcelWriterBuilder写响应流 EasyExcel.write(response.getOutputStream(), CustomerExcelVO.class) .sheet(客户数据) .doWrite(allCustomers); }这里需要使用ExcelWriter按页写入而不是把所有数据一次性doWrite。一次性doWrite在数据量破万以后内存占用非常可观。这个优化属于锦上添花但面试或者写论文时提到这个细节能体现出你对真实生产问题的理解深度。7. 写在最后的几点经验回到开头那个问题客户管理平台这个选题技术难度不高业务模型清晰非常适合用来把Spring Boot的核心能力完整地走一遍。如果你也是自己在做类似的项目我给你的建议是不要只停留在CRUD的层面适当加入引入权限控制、批量导入导出、数据聚合统计这些偏实战的功能整个项目的气质会一下子不一样。一个很多人在交付项目时会忽略的问题数据库的初始化脚本一定要写清楚。我的项目里在doc目录放了一个crm_system.sql文件包含建库建表语句和基础测试数据。别人拿到项目后执行一遍这个脚本就能把数据库环境搭起来这个体验对评分或者团队协作来说都很重要。另外系统的界面设计虽然没有花里胡哨的东西但我坚持做了几个细节导航菜单根据角色动态渲染、表格操作列统一做权限按钮控制、登录页和首页加了简单的统计卡片展示。这些在真实企业系统里都是标准配置也让整个系统的完成度比一般的学生作品高出一截。