简介:这套基于SSM框架的Java毕业设计源码,实现充电桩综合管理平台,面向计算机相关专业毕业生和课程设计学生,覆盖充电桩预约、开始/结束充电、告警信息、充电费用、维修工单等核心业务模块,适合作为毕业设计或课程设计演示项目。压缩包共1344个文件,约26.58MB,主要包含152个java源码文件、133个jsp页面、364个js脚本、172张png图片及146个css样式等,资源内含说明文档和LW文档,环境支持JDK1.8、MySQL5.7+与Tomcat7+,可用Eclipse或IDEA直接部署运行。设计涵盖主页、个人中心、用户管理、电站信息管理、充电桩管理、运营商管理等后台功能,并配有留言板与系统管理模块,业务链路较完整。当前已有50人学习下载,适合需要完整开源毕设方案并希望快速上手修改的读者,可作为项目参考与二次开发基础。
1. 充电桩综合管理在管什么:先看这套 SSM + MySQL 毕设源码的价值边界
拿到一套【java毕业设计】充电桩综合管理源码.zip,多数人的第一反应是解压、导入 IDE、满屏找建表 SQL。我的建议是反过来,先花半小时把“充电桩综合管理”这六个字拆透。充电桩不是普通商品,它有实时状态切换,有空闲、充电中、故障、离线四种状态;计费环节又同时涉及电费单价、服务费、充电时长、实际电量;一个充电订单从创建、结算到支付还要走完一套完整的状态机。这些业务约束,比 SSM 或者 MySQL 本身更能决定一个项目的好坏。
我是用 SSM 复刻过同类系统、也带人跑通过若干套毕设项目的一线工程师。这套方向值得拆的地方很清楚:六张核心表怎么建、三层架构的包边界划到哪、充电订单状态机怎么设计、计费和并发防重怎么做。下面按“骨架 → 配置 → 业务 → 避坑 → 交付”顺序展开,适合正在做 Java 毕设,或者想用一套完整管理系统练手的人照着复现。我不会假装这是某个作者的官方文档,只讲这个标题下最通用、最可靠的做法。
2. 拆开骨架:SSM 三层结构如何对应充电桩的设备、订单与计费模块
2.1 为什么毕设选 SSM 而不是 Spring Boot:把框架细节摊开给答辩老师看
Spring Boot 启动快、配置少,但自动配置把大量 Bean 装配细节吞掉了。答辩时老师随口一问“Spring 容器启动以后 @Autowired 是怎么找到对象的”,只写过 Boot 的同学经常当场断片。SSM 强迫你手写 spring-mvc.xml、spring-mybatis.xml、web.xml 三份配置,把 DispatcherServlet、SqlSessionFactoryBean、MapperScannerConfigurer 一个个显式配置出来,这个过程本身就是最好的框架复习提纲。
数据层用 MySQL 而不是 JPA,也是保守但稳妥的选择。充电桩管理里有分组统计、状态过滤、金额聚合这一类 SQL,MySQL 写起来直观,执行计划也能自己控制。压缩包里的 Spring 具体是 4.x 还是 5.x,以包内 pom.xml 或 lib 目录为准,但选型逻辑不变:让 IoC 容器和 ORM 映射都处于“可见”状态,而不是黑匣子。对想做毕业设计的同学来说,这意味着你讲得出每个依赖为什么存在。
2.2 充电桩系统的实体划分:六张核心表与字段设计要点
我会把业务拆成六个实体:用户、充电桩设备、计费策略、充电订单、充电过程记录、操作日志。注意“角色”是放在用户表里的字段,而不是单独一张角色表——毕设场景下用 role 字段区分管理员和普通用户就够了,单独做 RBAC 表会让演示和讲解都偏离主题。
先看用户表和设备表,这两张是整个系统的主表:
-- 用户表:管理员与充电用户共用,role 区分身份 CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(64) NOT NULL COMMENT 'MD5 加密后的密码', role TINYINT NOT NULL DEFAULT 1 COMMENT '0=管理员, 1=普通用户', balance DECIMAL(10, 2) NOT NULL DEFAULT 0 COMMENT '账户余额,单位元', phone VARCHAR(11) COMMENT '手机号', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 充电桩设备表:status 字段是后续并发防重的关键 CREATE TABLE charging_device ( id INT PRIMARY KEY AUTO_INCREMENT, device_code VARCHAR(32) NOT NULL UNIQUE COMMENT '设备编号,如 CD-0001', name VARCHAR(64) COMMENT '设备名称', address VARCHAR(128) COMMENT '安装地址', type TINYINT DEFAULT 0 COMMENT '0=交流桩, 1=直流桩', power DECIMAL(6, 2) COMMENT '额定功率 kW', status TINYINT DEFAULT 0 COMMENT '0=空闲, 1=充电中, 2=故障, 3=离线', price_id INT COMMENT '关联计费策略表', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='充电桩设备表';这里有两个细节值得注意。第一,status 用 TINYINT 数字而不是字符串,状态机流转时用 if 判断更轻,也方便后续做索引。第二,device_code 加 UNIQUE 约束,是防止同一台设备被重复插入的兜底手段。DEVICE 表的 status 从 0 变成 1 的操作,在第 4 章并发防重里会重点讲。
订单表和计费策略表是业务核心,设计上要预留扩展空间:
-- 计费策略表:先支持固定单价,后面要扩展峰谷电价也不动表结构 CREATE TABLE price_config ( id INT PRIMARY KEY AUTO_INCREMENT, strategy_name VARCHAR(32) COMMENT '策略名称,如标准电价', price_type TINYINT DEFAULT 0 COMMENT '0=固定单价, 1=峰谷分时', electricity_price DECIMAL(6, 3) COMMENT '电费单价 元/kWh', service_price DECIMAL(6, 2) COMMENT '服务费单价 元/小时', peak_start VARCHAR(8) COMMENT '峰段开始 HH:mm:ss,预留', peak_end VARCHAR(8) COMMENT '峰段结束 HH:mm:ss,预留' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='计费策略表'; -- 充电订单表:状态字段贯穿整个项目的核心流转逻辑 CREATE TABLE charging_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '业务订单号,格式如 20250101001', user_id INT NOT NULL COMMENT '用户 id', device_id INT NOT NULL COMMENT '充电桩 id', start_time DATETIME COMMENT '充电开始时间', end_time DATETIME COMMENT '充电结束时间', real_kwh DECIMAL(8, 2) DEFAULT 0 COMMENT '实际充电量 kWh', amount DECIMAL(8, 2) DEFAULT 0 COMMENT '订单总金额,元', status TINYINT NOT NULL DEFAULT 0 COMMENT '0=充电中, 1=待支付, 2=已支付, 3=已取消, 4=异常', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '订单创建时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='充电订单表';字段注释里把状态码写死,这比单独维护文档更可靠。我习惯在建表 SQL 里就把枚举含义用 COMMENT 标清楚,后面写 Service 层常量时直接对齐。charging_record 表按时间记录充电过程中的电压、电流、功率和单次电量,用于结算时累加 real_kwh;operation_log 表则可以记录管理员操作。两张辅助表字段少,建表时给 id、order_id 或 operator_id 加上普通索引即可。
2.3 三层架构的包结构:controller、service、dao 的边界与职责
SSM 项目拿到手,第一件事不是跑起来,而是看包结构。标准写法是按业务横向分包,而不是按技术纵向分包。下面是我梳理这类项目时习惯用的目录划分:
charging-station/ ├── src/main/java/com/station/ │ ├── controller/ # 接收请求、参数校验、返回 JSON 或页面 │ ├── service/ # 业务规则:状态机、计费计算、余额操作 │ │ └── impl/ # Service 接口实现 │ ├── dao/ # MyBatis Mapper 接口,只声明数据访问方法 │ ├── entity/ # 与数据库表对应的实体类 │ ├── common/ # 统一返回结果、分页对象、自定义异常 │ └── web/ # 登录拦截器、日期转换器 ├── src/main/resources/ │ ├── mappers/ # 每条 SQL 对应的 XML 文件 │ ├── spring-mybatis.xml │ ├── spring-mvc.xml │ └── jdbc.properties └── src/main/webapp/WEB-INF/web.xmlController 只做“取参数、调 Service、返回结果”这三件事,不写任何 SQL;业务规则统一收在 Service 实现里,尤其是状态机判断和金额计算;DAO 接口与 XML 的 namespace 必须完全匹配。最容易出问题的是实体类字段名和表字段名的映射,我一般在 mybatis-config.xml 里开启驼峰映射开关:
<settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings>开启之后,表里的 device_code 才能自动映射到实体类的 deviceCode 字段。否则 MyBatis 查询返回的列表里,你只会看到一堆 null,而且日志里看不出任何异常,属于典型的“查不出错、查了没用”的问题。
3. 从配置文件到第一个接口:把 MySQL 数据源和用户登录链路跑通
3.1 数据源与事务配置:jdbc.properties 与 spring-mybatis.xml 的参数取舍
SSM 环境跑通的第一关是数据源。很多新手直接抄网上的旧配置,结果连 MySQL 8 都连不上。我通常会先把 jdbc.properties 写成这样:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/charging_station?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=123456三个参数必须解释清楚:driverClassName 在 MySQL 8 之后要用 com.mysql.cj.jdbc.Driver,旧版 com.mysql.jdbc.Driver 在新驱动里已经被移除;url 里的 characterEncoding=utf8 负责中文,serverTimezone=Asia/Shanghai 负责时间,少了时区配置容易在写入时间字段时报错或者差 8 小时;useSSL=false 是测试环境省去 SSL 握手干扰。连接池方面,我一般会用 Druid,因为它自带监控页,演示时打开监控页能看到 SQL 执行次数,是一个很加分的展示点。
spring-mybatis.xml 里最关键的是 SqlSessionFactoryBean 的配置:
<!-- 数据源:Druid 连接池,测试环境参数不要贪大 --> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> </bean> <!-- SqlSessionFactory:mapperLocations 决定 XML 扫描位置 --> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="configLocation" value="classpath:mybatis-config.xml"/> <property name="mapperLocations" value="classpath:mappers/*.xml"/> </bean> <!-- 扫描 DAO 接口,自动生成实现类 --> <mybatis:scan base-package="com.station.dao"/>这段配置里最容易翻车的是 mapperLocations 的路径。如果写成 mappers/ 而不是 mappers/*.xml,Spring 找不到 XML 时不会启动报错,而是等到第一次调用 DAO 方法时才抛 BindingException,提示 Invalid bound statement。我排这种 bug 通常会先检查 target 目录里有没有把 XML 拷贝进去,而不是急着改代码。
3.2 用 MyBatis 实现设备条件查询:实体、Mapper 接口与 XML 的对应关系
有了数据源,下一个要跑通的接口是充电桩设备列表查询。这个接口能体现 MyBatis 最常见的用法:多条件动态查询。DAO 接口先声明方法:
public interface DeviceDao { /** * 分页查询设备列表 * @param status 设备状态,可空 * @param address 地址关键字,可空 * @param start 分页起始下标 * @param size 每页数量 */ List<ChargingDevice> selectDevicePage(@Param("status") Integer status, @Param("address") String address, @Param("start") int start, @Param("size") int size); }多个参数时必须加 @Param 注解,否则 MyBatis 报参数无法解析。对应的 XML 写在 resources/mappers/DeviceDao.xml 里:
<select id="selectDevicePage" resultType="com.station.entity.ChargingDevice"> SELECT id, device_code, name, address, type, power, status FROM charging_device <where> <if test="status != null"> AND status = #{status} </if> <if test="address != null and address != ''"> AND address LIKE CONCAT('%', #{address}, '%') </if> </where> ORDER BY id DESC LIMIT #{start}, #{size} </select>这里用 标签而不是手工拼 where 1=1,好处是第一个条件前面的 AND 会被自动去掉,SQL 语义干净,MySQL 优化器处理起来也更舒服。LIKE 查询用 CONCAT 拼接,而不是直接在 XML 里写 '%${address}%',后者会引入 SQL 注入风险。分页方面,毕设阶段手写 LIMIT 完全够用,等你真要集成 PageHelper 时反而要注意它和自定义 LIMIT 的冲突,那个在第 5 章再展开。
Controller 调用这个 DAO 时,返回结果建议统一包一层。我习惯写一个 Result 类,结构固定为 code、msg、data 三个字段,成功返回 200,业务异常返回自定义错误码。这样做的好处是前端 JS 只需要判断 code,不需要到处使用 try catch 解析字符串。
3.3 用户登录与 Session 拦截器:SSM 场景下的轻量权限控制
充电桩管理系统里,管理员后台和用户端页面通常混在一个应用里,最简单的权限控制就是 Session 拦截器。Spring MVC 的拦截器写法很直白:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user = request.getSession().getAttribute("loginUser"); if (user == null) { // 未登录跳回登录页;如果是 AJAX 请求返回 401 更合理 response.sendRedirect(request.getContextPath() + "/login"); return false; } return true; } }然后在 spring-mvc.xml 里注册拦截器,核心是路径放行规则:
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/register"/> <mvc:exclude-mapping path="/static/**"/> </mvc:interceptor> </mvc:interceptors>很多项目跑起来之后页面样式全丢,就是因为 /static/** 这个放行路径漏写了,CSS 和 JS 资源也被拦截到登录页。排查这类问题有一个固定套路:先在浏览器开发者工具里看静态资源请求的响应码,如果是 302,那基本就是拦截器放行路径没配全。密码存储方面,毕设项目用 MD5 不是不能交差,但我会顺手做一层加盐,把 username 拼进去再哈希,演示时能多讲一个安全点。
4. 核心业务实现:充电下单、计费结算与状态流转
4.1 充电状态机设计:用数值状态与时间线约束订单流转
充电订单是这颗系统的心脏,状态设计直接决定后续代码复杂度。我见过不少项目把状态表做成 20 多个字段的“大杂烩”,最后改需求的时候动一处崩三处。稳妥的用法是在 Service 层定义状态常量接口:
public interface OrderStatus { int CHARGING = 0; // 充电中 int FINISH_WAIT_PAY = 1; // 已结束待支付 int PAID = 2; // 已支付 int CANCELED = 3; // 已取消 int ABNORMAL = 4; // 异常 }配套的状态流转规则是这样的:设备空闲时用户发起充电,创建订单且订单状态为 0、设备状态改为 1;充电结束或者用户主动结束,订单状态改为 1,同时设备状态改回 0;用户支付后订单改为 2;超过 30 分钟未支付自动取消,改为 3;充电过程中设备上报故障或网络超时,订单改为 4,设备状态改为 2。设备状态本质上是订单状态的投影,不需要额外关联表。
写业务代码时,我要求每个状态变更都走 Service 的同一个方法入口,而不是在 Controller 里随手 update。比如结束充电这个方法,先查订单、校验当前状态是不是 0、再算费用、再改两个表的状态,四步必须在一个事务里。如果把状态校验散落到 Controller 里,后面做异常单处理时你就会发现每个入口的状态判断都不一样,这种坑比技术难题更难修。
4.2 计费结算算法:按电量与时长计算电费和充电服务费
计费是充电桩系统里最容易被看穿的部分。常见做法是订单金额 = 实际电量 × 电费单价 + 充电时长 × 服务费单价。电量字段 real_kwh 在真实场景里由充电桩上报,毕设里可以通过充电过程记录表累加模拟产生。我给出的结算逻辑放在 Service 层:
public BigDecimal settleOrder(int orderId) { // 先查订单,状态不对直接抛业务异常 ChargingOrder order = orderDao.selectById(orderId); if (order.getStatus() != OrderStatus.CHARGING) { throw new BizException("当前订单状态不允许结算"); } // 结算时间点设为当前时间 order.setEndTime(LocalDateTime.now()); // 实际电量常见做法:从充电记录表按订单累加 BigDecimal kwh = recordDao.sumKwhByOrderId(orderId); PriceConfig price = priceDao.selectById(deviceDao.selectById(order.getDeviceId()).getPriceId()); // 电费 = 电量 * 电费单价 BigDecimal electricityCost = kwh.multiply(price.getElectricityPrice()); // 服务费 = 分钟数除以 60,保留两位小数 long minutes = Duration.between(order.getStartTime(), order.getEndTime()).toMinutes(); BigDecimal serviceCost = BigDecimal.valueOf(minutes) .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP) .multiply(price.getServicePrice()); order.setRealKwh(kwh); order.setAmount(electricityCost.add(serviceCost)); order.setStatus(OrderStatus.FINISH_WAIT_PAY); orderDao.updateOrder(order); return order.getAmount(); }两个点必须养成习惯:金额计算一律用 BigDecimal,禁止 double 参与乘除,否则金额精度炸了说不清;服务费按分钟折算时,明确舍入模式,我一般用 HALF_UP,也就是四舍五入,并且保留两位小数。这个方法的调用时机在用户端“结束充电”按钮触发后,整体包在事务里,保证订单状态和金额要么一起成功要么一起回滚。
4.3 并发防重:用“先更新设备状态”代替“先查再插”
充电桩管理最典型的并发问题,是两辆车同时扫码要充同一个空闲桩。新手写法通常是先查设备状态,等于 0 再创建订单,再更新设备状态。这个顺序在并发下必然出问题:两个请求都查到状态为 0,然后都往下执行,同一个桩可能被创建两笔订单。
正确的做法是用一条带条件 UPDATE 抢占设备。我先写 DAO 方法和 SQL:
// 返回影响行数:1 表示抢占成功,0 表示设备已被占用 int compareAndSetStatus(@Param("deviceId") int deviceId, @Param("fromStatus") int fromStatus, @Param("toStatus") int toStatus);UPDATE charging_device SET status = #{toStatus} WHERE id = #{deviceId} AND status = #{fromStatus}这段 SQL 的语义很明确:把“从空闲改为充电中”这个操作变成原子条件更新。MySQL 的 UPDATE 语句在执行时会对匹配的行加锁,两个并发请求同时执行时只会有一个影响行数为 1,另一个拿到 0,直接提示“该充电桩已被占用”。Service 层代码就顺着这个思路写:
@Transactional(rollbackFor = Exception.class) public void startCharge(int userId, int deviceId) { // 关键一步:先抢占设备,再创建订单 int rows = deviceDao.compareAndSetStatus(deviceId, 0, 1); if (rows == 0) { throw new BizException("该充电桩已被占用,请更换充电桩"); } ChargingOrder order = new ChargingOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setDeviceId(deviceId); order.setStatus(OrderStatus.CHARGING); order.setStartTime(LocalDateTime.now()); orderDao.insert(order); }这个方法必须在事务里,因为“设备状态更新”和“订单插入”是两行数据,任何一个失败都要回滚。要注意的是 @Transactional 的 rollbackFor 我习惯显式写成 Exception.class,因为 Spring 默认只对 RuntimeException 回滚,自定义的 BizException 如果继承自 Exception,不写 rollbackFor 就会变成“事务提交后再抛异常”,后果是设备被占了但订单没建出来。这类由“事务不生效”引发的问题,下面第 5 章专门列作一个避坑点。
5. 部署联调避坑:SSM + MySQL 环境下的五个踩坑记录
5.1 MySQL 8 驱动加载失败:类名换掉与 url 参数少配置两道坎
现象:启动项目时直接报ClassNotFoundException: com.mysql.jdbc.Driver,或者连数据库时报Public Key Retrieval is not allowed。
原因:MySQL 8 之后驱动主类改成了 com.mysql.cj.jdbc.Driver,旧包名在新驱动里已经删了;启用 SSL 连接时,MySQL 8 默认要求客户端允许获取服务端公钥,而连接参数里没有放开这个权限。
解决:驱动类名改成com.mysql.cj.jdbc.Driver,同时 url 中补充useSSL=false&allowPublicKeyRetrieval=true。另外一个容易忽略的细节是确认 lib 目录里的驱动 jar 版本,如果本地装的是 MySQL 5.7,驱动用 5.x 没问题,但数据库是 MySQL 8 时驱动必须同步升级。遇到连不上库的报错,先看后 50 行堆栈,报错信息里几乎都会直接点出是驱动问题还是权限问题。
5.2 中文乱码:建库、连接、页面三层必须统一字符集
现象:页面上显示问号,或者数据库里存入的是乱码,明明代码里写的是中文。
原因:字符集链路有三处:MySQL 建库时未指定 utf8mb4、JDBC url 缺少 characterEncoding、Web 容器或页面编码不是 UTF-8。任何一层不一致,都会在这里或那里翻车。
解决:三层一起改。建库时写CREATE DATABASE charging_station DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;JDBC url 里带characterEncoding=utf8;Tomcat 的 server.xml 里给 Connector 加URIEncoding="UTF-8"。JSP 页面顶部保持pageEncoding="UTF-8"。排查顺序我建议从数据库终端开始验证:先用命令行直接插入一条中文,如果入库正常,说明问题在应用侧;如果入库就是乱码,那先改建库字符集。这套排查链路能省下大量“玄学乱码”的时间。
5.3 MyBatis 动态 SQL 条件不生效与分页排序冲突
现象:按状态筛选设备时,条件明明传了 status=0,查出来的却是全部设备;PageHelper 一开启,SQL 就报语法错误。
原因:第一个问题通常出在 if test 的判断上,MyBatis 表达式是从对象取值,status 是 Integer 时用status != null判断没问题,但写<if test="status == 0">在某些老版本里会出现拆箱比较异常,条件被静默跳过。第二个问题常见于分页插件和手写 LIMIT 并存,或者 ORDER BY 写在 LIMIT 之后——MySQL 的语法要求 ORDER BY 必须在 LIMIT 之前,分页插件再包一层时就非常容易冲突。
解决:第一个问题,参数统一加 @Param 注解,条件表达式里避免直接 equals 比较数字,用status != null and status == 0这种显式写法。排序方面,手写分页时把 LIMIT 放在 SQL 最末尾,ORDER BY 紧跟 WHERE 之后;如果集成了 PageHelper,SQL 里只写 WHERE 和 ORDER BY,LIMIT 交给插件生成,两者不可叠加使用。MySQL 排序还有一个常用细节:多条件排序时把索引列放在最前面,比如按状态排序再按创建时间倒序,这样ORDER BY status ASC, id DESC能吃到联合索引。
5.4 事务不生效:Service 内部方法自调用,异常照样写库
现象:一个 Service 方法里调用本类的另一个方法,被调用方法抛了异常,调用方前面对数据库的写入没有被回滚。
原因:@Transactional 是 Spring 在外部调用时通过容器生成增强对象来切入的,方法内部用 this.调用同类方法时,调用的是原始对象的方法,事务注解没有机会介入。这是 SSM 初学者最容易中招的坑,而且日志里不会有任何报错,属于典型的行为型 bug。
解决:把需要原子操作的两个方法拆到不同 Service 类,由外部对象调用;如果必须留在同类里,注入自身或从 ApplicationContext 里取当前 Bean 再调用。比如上面 4.3 的 startCharge 方法中,如果有另一处业务要复用它,就改成这样:
// 自调用不可靠,从容器里取出增强后的对象再调用 OrderService self = applicationContext.getBean(OrderService.class); self.startCharge(userId, deviceId);另外检查一个基础条件:MySQL 表引擎必须是 InnoDB,MyISAM 表不支持行级事务,就算注解配对了也回滚不了。看表结构的命令很简单:SHOW TABLE STATUS WHERE Name = 'charging_order',Engine 列不是 InnoDB 就赶紧转换。
5.5 LocalDateTime 与 JSON 序列化:后端对象有值,前端收到的时间却是乱码
现象:日志里实体类时间字段有值,但接口返回的 JSON 里时间要么是“2025-01-01T08:00:00”夹杂 T 的格式,要么直接序列化报错。
原因:JDK 8 的 LocalDateTime 默认不带时区,Jackson 老版本没有注册 JavaTimeModule,不知道如何转换。
解决:给实体时间字段加注解,或者全局配置 ObjectMapper。我推荐全局配置,避免每个字段都写注解:
<mvc:annotation-driven> <mvc:message-converters> <bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"> <property name="objectMapper"> <bean class="com.fasterxml.jackson.databind.ObjectMapper"> <property name="dateFormat"> <bean class="java.text.SimpleDateFormat"> <constructor-arg value="yyyy-MM-dd HH:mm:ss"/> </bean> </property> </bean> </property> </bean> </mvc:message-converters> </mvc:annotation-driven>如果项目里只有一两个字段用到时间返回,用@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")加在字段上是更快的方案。顺手提一句,数据库里的 DATETIME 映射到 LocalDateTime 是 MyBatis 3.4 以后才支持得干净,老版本需要自定义 TypeHandler,遇到实体映射报错时优先确认 MyBatis 版本。时间字段这种坑,排查时先看后端日志确认值,再看 JSON 输出确认格式,能少走弯路。
6. 交付收尾:说明文档组织、演示数据与答辩演示技巧
6.1 把 LW 说明文档写成“答辩能直接讲”的结构
压缩包里 LW 一般指配套的论文或设计说明文档,它和源码是同一套交付物的两面。我见过的通用结构是:项目背景与需求分析、数据库设计、系统架构设计、核心模块详细设计、测试与部署。数据库设计部分建议放三段内容:ER 图、六张表字段说明表、两条关键 SQL 讲解。字段说明表包含字段名、类型、是否可空、含义即可。核心模块单独花两页讲计费结算和状态机,配一张状态流转图,这是能拉开口碑的部分。
6.2 用一组 SQL 造出“看起来真实”的演示数据
答辩演示最怕空数据页面。我习惯造近 7 天内、覆盖多种状态和支付来源的 20 笔订单,这样统计页面的饼图和柱状图都有内容可看。演示数据可以通过一段 SQL 批量插入:
-- 生成 20 笔最近 7 天的有效订单:订单号唯一、时间顺序正确、金额与电量正相关 INSERT INTO charging_order (order_no, user_id, device_id, start_time, end_time, real_kwh, amount, status, create_time) SELECT CONCAT('2025', LPAD(id + 1, 6, '0')), 1 + FLOOR(RAND() * 5), 1 + FLOOR(RAND() * 10), NOW() - INTERVAL FLOOR(RAND() * 6) DAY - INTERVAL 2 HOUR, NOW() - INTERVAL FLOOR(RAND() * 6) DAY, ROUND(10 + RAND() * 30, 2), ROUND((10 + RAND() * 30) * 1.2, 2), 2, NOW() - INTERVAL FLOOR(RAND() * 6) DAY FROM information_schema.tables LIMIT 20;造数注意三点:订单号必须唯一,否则插入会撞 UNIQUE 索引;start_time 必须早于 end_time,否则结算逻辑会被演示出来当成 bug;金额不要直接写死 0,而是跟着电量浮动,统计图表才自然。真实感的数据比数量更重要,这也体现你对业务的理解。测试完还能顺手验证 5.4 里的事务和状态机逻辑,一举两得。
6.3 答辩现场的三个演示动作:把“踩坑”变成“亮点”
到了演示环节,我会按固定顺序走三步:先展示充电下单的完整链路,从选择空闲充电桩到订单生成,再展示结算和支付,最后打开数据库手动改一条记录模拟设备故障,演示异常订单处理。在做并发防重演示时,开两个浏览器标签同时点击同一个充电桩,这个动作比任何口述都有说服力,直接证明你处理过 4.3 的问题。
最后聊一个习惯。早期带项目时我也总想先把功能跑通再说,后来被乱码、事务、时间序列化连着坑过几个晚上,才养成“先看配置,再读代码,最后跑功能”的顺序。希望这些踩坑记录能让你在毕设路上少折腾几个通宵,希望帮到你。
本文还有配套的精品资源,点击获取