简介:面向Java方向毕业设计与课程实践的新能源汽车充电桩管理系统完整项目包,采用SpringBoot后端与Vue前端分离架构,MySQL存储数据,涵盖首页、个人中心、维修员与用户管理、电桩类别与充电桩管理、充电桩报修及维修回复、系统管理等模块,可作为毕设选题的实现参考或中小型充电运营场景的原型。压缩包共1322个文件、约32.33MB,以107个java源文件与43个vue组件构成核心业务代码,307个js、101个css与72个html支撑前端页面与交互,另含1份sql建库脚本、xml配置及图片字体等静态资源,并附docx论文与ppt材料,源码、数据库与文档配套齐全。目前已有190人学习。读者可据此理清前后端分层结构与接口对应关系,并参照论文的需求分析、数据库设计思路推进自己的系统,减少从零搭建与联调的时间。
1. 从一根桩两个车主说起:这套系统真正要解决的是什么
新能源汽车充电桩管理系统是 Java 毕设里出现频率很高的一类题目,很多人第一反应是把它做成一张大表的增删改查:桩信息录进去,订单记下来,页面能翻页,就算完事。真到演示环节问题全冒出来——两辆车同时点同一根桩,谁先占上?一笔从 22:40 充到次日 01:10 的订单,电费按峰时还是谷时算?桩在充电中途报故障,这单怎么收尾、钱怎么退?这三个问题答不上来,功能列表再长也撑不住追问。这套 SpringBoot+Vue 的完整版方案,就是围绕「桩位状态」「分时计费」「订单状态机」三条主线搭起来的,源码、SQL 脚本和配套文档都已成型,适合正在做 Java 毕设或课程设计、需要一套既跑得通又能讲清业务闭环的同学,也适合想拿一个完整案例练手 SpringBoot 分层开发的人。
2. 需求拆解与技术选型:为什么是 SpringBoot + Vue 这套组合
2.1 三类角色与功能边界
把角色先钉死,后面所有表结构和接口都是从这个矩阵里长出来的。这套系统划分为平台管理员、站点运维员、车主用户三类角色,权限粒度到菜单和按钮,不是简单的一个 is_admin 字段打天下。
管理员负责全局:充电站与充电桩的建档、计费规则维护、用户与角色管理、全平台订单和营收看板、故障工单的派发与关闭。运维员只对名下站点有权限,处理桩状态变更(空闲、故障、离线)、响应报修、查看本站点日报。车主用户则是注册登录、充值钱包、查看附近站点、预约桩位、扫码或手输桩号启动充电、查看订单与账单、提交故障报修。
| 角色 | 核心操作 | 明确不做的事 |
|---|---|---|
| 平台管理员 | 站点、桩、计费规则、角色菜单、全量报表 | 不直接改订单金额,只能走异常订单流程 |
| 站点运维员 | 本站点桩状态维护、工单处理、日报导出 | 不能跨站点查看数据 |
| 车主用户 | 预约、充电、支付、报修、查历史 | 不能修改已完成的订单 |
这张矩阵的意义在于:它直接决定了后面 JWT 里要带哪些声明、Service 层要不要做数据权限过滤。很多同学的毕设栽在这里,管理员和普通用户共用一个接口,前端靠隐藏按钮做"权限",答辩老师随便改个请求参数就穿帮了。
2.2 后端为什么选 SpringBoot + MyBatis-Plus 而不是别的
题目定了 SpringBoot,真正要纠结的是持久层。这套方案用的是 SpringBoot 2.7 + MyBatis-Plus + MySQL 8 + JWT,没有引入微服务那一套。理由很实在:毕设或课设的评审看的是业务闭环是否完整、代码分层是否清晰,不是看你拆了几个服务。单体应用配合清晰的分层,反而更容易讲明白一次充电请求从 Controller 到数据库的完整链路。
MyBatis-Plus 在这里的收益主要是三块。一是 BaseMapper 省掉大量单表 CRUD 的 XML;二是条件构造器让站点、订单这类多条件分页查询写起来很短;三是它自带的分页插件和逻辑删除,能避免手写 count 语句时算错总数这种低级翻车。至于连表查询较多的报表部分,仍然手写 XML,不要硬套 Wrapper,那样可读性反而更差。
Redis 在这套方案里是可选件。如果机器上方便起,用它缓存桩的实时状态和登录 token 的黑名单;如果不方便,直接查库也能跑通,只是并发演示时性能数字不好看。我的建议是先保证不依赖 Redis 也能完整跑通,把它当作加分项而不是必需项,否则答辩机器上起不来服务就是灾难。
配置文件里几个关键项要写清楚,连接池、时区、逻辑删除规则都影响后面调试:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/charging_pile?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 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: 0serverTimezone必须显式指定,否则 MySQL 8 与 JDBC 驱动之间的时区推断会让订单的开始时间整体偏移 8 小时,表现出来就是"刚充完电的订单查不到"。map-underscore-to-camel-case打开之后,数据库的pile_no才能自动映射到实体的pileNo,省掉一堆 @Results 注解。逻辑删除配置是全局的,只要实体上有deleted字段就自动生效,注意它只对 MyBatis-Plus 提供的方法有效,手写 XML 的查询要自己带上deleted = 0。
2.3 前端为什么用 Vue 2 + Element UI 而不是追新
前端用 Vue 2 + Element UI + Axios + ECharts。有人会问为什么不上 Vue 3,答案不太浪漫:Element UI 的表格、表单、日期选择器在后台管理场景里开箱即用,配套的示例和问答资料密度高,遇到问题能很快定位;Vue 3 加 Element Plus 当然更现代,但组合式 API 的写法对第一次做完整项目的同学来说,调试成本会明显上升,尤其是在动态路由和权限菜单这块。
后台管理系统的前端骨架其实是固定的:登录页、布局容器(侧边菜单加顶部面包屑)、业务页面、图表看板。真正需要动脑的是两件事,一是路由守卫怎么和 JWT 配合,二是菜单怎么根据后端返回的权限动态生成。
// router/index.js —— 全局前置守卫,拦未登录与越权访问 router.beforeEach((to, from, next) => { const token = store.getters.token if (to.meta.public) return next() // 登录、注册页放行 if (!token) return next({ path: '/login', query: { redirect: to.fullPath } }) // 已登录但还没拉过用户信息和菜单,先拉一次再放行 if (store.getters.menus.length === 0) { store.dispatch('user/getInfo').then(() => { next({ ...to, replace: true }) // 重新进入,确保 addRoutes 已生效 }).catch(() => next('/login')) } else { next() } })这里最关键的是next({ ...to, replace: true })这一行。动态添加路由之后必须重新触发一次导航,否则刷新页面时会直接落到 404,这个坑几乎每套动态菜单项目都要踩一遍。to.meta.public用来标记免登录页面,比在主逻辑里写一堆 if 判断路径名要干净得多。
3. 总体架构与数据库设计:从表结构看业务闭环
3.1 分层结构与包职责划分
后端采用标准四层结构,包名按职责切分而不是按技术切分。很多人习惯把所有 Controller 堆在一个包里,项目小的时候没问题,一旦模块变多就很难找。按业务域分包,每个域内部再分层,是更耐看的写法。
com.example.charging ├── common # 统一返回、异常、常量、工具类 │ ├── Result.java │ ├── BizException.java │ └── GlobalExceptionHandler.java ├── config # MyBatis-Plus、跨域、拦截器注册 ├── security # JWT 工具、登录拦截器、权限注解 ├── module │ ├── pile # 充电桩:controller / service / mapper / entity │ ├── order # 订单与计费 │ ├── station # 站点 │ ├── user # 用户、角色、菜单 │ └── report # 统计看板 └── ChargingApplication.javacommon里的统一返回体是整套代码的地基,前端 Axios 拦截器只认一种结构,后面所有页面都不用再各自判断 code。全局异常处理器负责把BizException转成带提示语的失败响应,把未捕获异常转成"系统繁忙",避免堆栈直接吐到浏览器。
@Data public class Result<T> { private Integer code; // 200 成功,500 业务失败,401 未登录 private String msg; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMsg("操作成功"); r.setData(data); return r; } public static <T> Result<T> fail(String msg) { Result<T> r = new Result<>(); r.setCode(500); r.setMsg(msg); return r; } }配合@RestControllerAdvice之后,Service 层只管throw new BizException("该充电桩已被占用"),不需要关心怎么组装响应。这样做的好处是业务代码干净,坏处是新人容易在 Controller 里再 try-catch 一遍,把异常吞掉,导致前端永远收到成功。约定好一件事:Controller 层不写 try-catch。
3.2 核心表结构与字段设计
这套系统一共十来张表,其中真正撑起业务的是充电桩、订单、计费规则三张,其余如用户、角色、菜单、钱包流水、报修工单都是配套。
-- 充电桩:状态与版本号是并发控制的核心 CREATE TABLE `charging_pile` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `pile_no` varchar(32) NOT NULL COMMENT '桩编号,如 CP-A-001', `station_id` bigint NOT NULL COMMENT '所属站点', `type` tinyint NOT NULL DEFAULT 1 COMMENT '1-交流慢充 2-直流快充', `power` decimal(6,2) NOT NULL COMMENT '额定功率 kW', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0-空闲 1-已预约 2-充电中 3-故障 4-离线', `price_rule_id` bigint DEFAULT NULL COMMENT '绑定的计费规则组', `version` int NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '逻辑删除', PRIMARY KEY (`id`), UNIQUE KEY `uk_pile_no` (`pile_no`), KEY `idx_station_status` (`station_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='充电桩'; -- 充电订单:金额字段全部冗余存,不做实时计算 CREATE TABLE `charging_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '业务订单号', `user_id` bigint NOT NULL, `pile_id` bigint NOT NULL, `start_time` datetime NOT NULL COMMENT '开始充电时间', `end_time` datetime DEFAULT NULL, `electricity` decimal(10,3) DEFAULT '0.000' COMMENT '充电度数 kWh', `electric_fee` decimal(10,2) DEFAULT '0.00' COMMENT '电费', `service_fee` decimal(10,2) DEFAULT '0.00' COMMENT '服务费', `total_amount` decimal(10,2) DEFAULT '0.00' COMMENT '订单总额', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0-待充电 1-充电中 2-已完成 3-已取消 4-异常', `deleted` tinyint NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_time` (`user_id`, `start_time`), KEY `idx_pile_status` (`pile_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='充电订单'; -- 分时计费规则:一天切四段,甲乙两套应对不同站点 CREATE TABLE `billing_rule` ( `id` bigint NOT NULL AUTO_INCREMENT, `rule_name` varchar(64) NOT NULL COMMENT '规则名称,如 A站-2024', `period` tinyint NOT NULL COMMENT '1-尖峰 2-高峰 3-平段 4-低谷', `start_time` time NOT NULL COMMENT '时段起点', `end_time` time NOT NULL COMMENT '时段终点', `electric_price` decimal(6,4) NOT NULL COMMENT '电费单价 元/kWh', `service_price` decimal(6,4) NOT NULL COMMENT '服务费单价 元/kWh', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分时计费规则';几个设计取舍值得讲。第一,订单里的电费、服务费、总额都做了冗余存储,而不是每次查询时用规则表实时算。原因是计费规则会调价,一旦调价,历史订单的金额就会跟着变,那对账就没法看了。下单即快照,是这类计费系统的铁律。
第二,桩编号做了唯一索引,站点与状态建了联合索引。前端"查看某站点空闲桩"是最热的查询,这个索引直接决定列表页刷不刷得出来。
第三,状态用 tinyint 而不是 varchar。数字状态在 SQL 里比较和建索引都更省,代价是可读性差,所以要在实体里用枚举或常量类做一层映射,别让 magic number 散落在 Service 里。
3.3 订单状态机:把所有异常路径先想清楚
订单状态是整套系统最容易失控的地方。如果没有明确的状态机,代码里就会出现"已完成的订单又被取消""已取消的订单还能结算"这类脏数据,而且往往在演示时才暴露。
这套方案把订单定义为五个状态:待充电、充电中、已完成、已取消、异常。合法流转只有五条路径,其余一律拒绝。
| 当前状态 | 允许流转到 | 触发动作 |
|---|---|---|
| 待充电 | 充电中 / 已取消 | 启动充电 / 超时未启动或用户取消 |
| 充电中 | 已完成 / 异常 | 正常结束 / 桩故障、掉线 |
| 异常 | 已完成 / 已取消 | 运维核账后结算 / 全额退款 |
| 已完成 | 无 | 终态,不允许任何修改 |
| 已取消 | 无 | 终态,桩位释放回空闲 |
实现上,不要用一堆 if-else 判断,而是把合法流转写成 Map,转状态前统一校验一次:
public enum OrderStatus { WAIT(0), CHARGING(1), FINISHED(2), CANCELED(3), ABNORMAL(4); private final int code; OrderStatus(int code) { this.code = code; } public int getCode() { return code; } // 合法流转表:key 为当前状态,value 为允许到达的状态集合 private static final Map<OrderStatus, Set<OrderStatus>> FLOW = Map.of( WAIT, Set.of(CHARGING, CANCELED), CHARGING, Set.of(FINISHED, ABNORMAL), ABNORMAL, Set.of(FINISHED, CANCELED), FINISHED, Set.of(), CANCELED, Set.of() ); public static void check(OrderStatus from, OrderStatus to) { if (!FLOW.getOrDefault(from, Set.of()).contains(to)) { throw new BizException("订单状态不允许从 " + from + " 变为 " + to); } } }FLOW用静态 Map 定义之后,新增状态只需要改这一处,业务方法里统一调OrderStatus.check(oldStatus, newStatus)。答辩时老师问"如果充电中途桩坏了怎么办",答案就在这张表里:充电中 → 异常,运维确认实际充电量后从异常 → 已完成,或者直接异常 → 已取消并全额退款,桩位同时释放。
4. 核心模块实现:预约抢桩、分时计费、鉴权与看板
4.1 抢桩与防超卖:乐观锁加一次状态校验
这是整套系统里唯一真正涉及并发的地方,也是最值得写的部分。场景很朴素:两个用户几乎同时点了同一根空闲桩的"预约",如果按常规写法先selectById判断状态再update,中间这段时间足够另一个请求插进来,结果就是一根桩被两个人预约,桩位状态被覆盖。
解决方式是数据库乐观锁,把"判断"和"更新"合并成一条原子 SQL:
<!-- ChargingPileMapper.xml —— 只有状态仍为空闲且版本号未变,才允许占用 --> <update id="occupyPile"> UPDATE charging_pile SET status = 1, version = version + 1 WHERE id = #{pileId} AND status = 0 AND version = #{version} AND deleted = 0 </update>Service 层的处理逻辑是:先查一次拿到当前版本号,再执行这条带版本条件的更新,看影响行数。
@Transactional(rollbackFor = Exception.class) public Long reserve(Long pileId, Long userId) { ChargingPile pile = pileMapper.selectById(pileId); if (pile == null) { throw new BizException("充电桩不存在"); } if (pile.getStatus() != PileStatus.FREE.getCode()) { throw new BizException("该充电桩当前不可预约"); } // 关键:影响行数为 0 说明在查询与更新之间被别的请求抢先了 int rows = pileMapper.occupyPile(pileId, pile.getVersion()); if (rows == 0) { throw new BizException("手慢了,桩位刚被其他用户抢走"); } // 落预约记录,并设置 15 分钟未启动自动取消的延迟任务 Reservation r = new Reservation(); r.setPileId(pileId); r.setUserId(userId); r.setExpireTime(LocalDateTime.now().plusMinutes(15)); reservationMapper.insert(r); return r.getId(); }rows == 0就是并发冲突的唯一信号,直接把"手慢了"抛给前端,用户看到提示刷新列表即可,不需要重试逻辑。这里没有用 Redis 分布式锁,因为单机部署下数据库行锁已经足够,多引入一个组件反而增加答辩时解释不清的风险。如果确实要讲"高并发",可以说清楚:桩位预约的并发量级远到不了需要分布式锁的程度,用乐观锁是成本与收益最匹配的选择,这话反而比硬上 Redis 更能体现判断力。
另外那个 15 分钟超时释放,通常的做法是用 Spring 的@Scheduled每分钟扫一次过期预约,把桩状态改回空闲。别图省事在查询接口里顺手判断过期,那样桩位会长期挂着不释放,演示时看起来像"桩被幽灵占用"。
4.2 分时计费引擎:把一段时间切成若干段
计费是这套系统最出彩的部分,也最容易写错。真实场景下电价分峰平谷,一笔订单可能横跨两个甚至三个时段,所以不能简单地用"总电量 × 当前单价"。
思路是把充电区间按计费规则的边界切片,每片算出时长占比,再把总电量按占比分摊到各片,最后分别乘以对应单价累加。这个算法在数学上是精确的,实现上也不复杂。
/** * 分时计费:把 [start, end] 按 30 分钟切片,按时间占比分摊电量后累加费用 * @param start 开始充电时间 * @param end 结束充电时间 * @param totalKwh 本次充电总度数 */ public BigDecimal calcElectricFee(LocalDateTime start, LocalDateTime end, BigDecimal totalKwh) { if (totalKwh.compareTo(BigDecimal.ZERO) <= 0 || !end.isAfter(start)) { return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); } List<BillingRule> rules = billingRuleMapper.selectList(null); long totalSeconds = Duration.between(start, end).getSeconds(); BigDecimal fee = BigDecimal.ZERO; LocalDateTime cursor = start; while (cursor.isBefore(end)) { // 切片步长 30 分钟,且不越过结束时间 LocalDateTime next = cursor.plusMinutes(30).isBefore(end) ? cursor.plusMinutes(30) : end; long sliceSeconds = Duration.between(cursor, next).getSeconds(); // 命中该时刻所属的计费时段 BillingRule rule = matchRule(rules, cursor.toLocalTime()); BigDecimal sliceKwh = totalKwh .multiply(BigDecimal.valueOf(sliceSeconds)) .divide(BigDecimal.valueOf(totalSeconds), 3, RoundingMode.HALF_UP); fee = fee.add(sliceKwh.multiply(rule.getElectricPrice())); cursor = next; } return fee.setScale(2, RoundingMode.HALF_UP); } /** 找到覆盖当前时刻的规则;跨零点时段用 or 条件处理 */ private BillingRule matchRule(List<BillingRule> rules, LocalTime time) { return rules.stream() .filter(r -> r.getStartTime().isBefore(r.getEndTime()) ? !time.isBefore(r.getStartTime()) && time.isBefore(r.getEndTime()) : !time.isBefore(r.getStartTime()) || time.isBefore(r.getEndTime())) .findFirst() .orElseThrow(() -> new BizException("未匹配到计费规则,请检查时段配置")); }三个参数要点。切片步长取 30 分钟是个折中:步长越小越精确,但循环次数越多;考虑到计费时段本身就是整点划分,30 分钟已经足够贴合边界。RoundingMode.HALF_UP放在最后一步,中间过程保留三位小数,避免逐片四舍五入累积误差——这是财务类计算里最典型的血泪经验。跨零点时段的判断要单独写,22:00-06:00这种规则用普通的区间判断是永远匹配不上的。
服务费的计算更简单,通常是固定单价乘电量,部分站点会按小时收占位费,这部分可以作为扩展点在文档里提一句,不必真写。
4.3 JWT 鉴权与数据权限过滤
登录这块没有用 Spring Security 全家桶,而是自己写了一个轻量的拦截器。原因很直接:Spring Security 的过滤器链对初次接触的同学来说调试成本太高,出问题时报错信息不友好,而这套系统的权限模型其实很简单——登录态加角色菜单。
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (!(handler instanceof HandlerMethod)) return true; // 放行静态资源 String token = request.getHeader("Authorization"); if (StrUtil.isBlank(token)) { throw new BizException("未登录或登录已过期"); } Claims claims = JwtUtil.parse(token.replace("Bearer ", "")); // 把用户信息放进 ThreadLocal,Service 层随处可取 UserContext.set(new LoginUser( claims.get("userId", Long.class), claims.get("username", String.class), claims.get("stationId", Long.class))); return true; } @Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) { UserContext.clear(); // 必须清理,否则线程复用会串数据 } }afterCompletion里的UserContext.clear()千万不能省。Tomcat 的线程是复用的,如果不清,下一个请求可能读到上一个用户的身份,表现为"偶尔看到别人的订单",这种问题几乎不可能靠日志复现,是典型的黑匣子故障。
数据权限的过滤放在 Service 层,运维员查订单时强制拼上站点条件:
public Page<OrderVO> pageOrders(OrderQuery query) { LoginUser user = UserContext.get(); LambdaQueryWrapper<ChargingOrder> qw = new LambdaQueryWrapper<>(); qw.eq(ChargingOrder::getDeleted, 0) .eq(query.getStatus() != null, ChargingOrder::getStatus, query.getStatus()) .ge(query.getStartDate() != null, ChargingOrder::getStartTime, query.getStartDate()) .orderByDesc(ChargingOrder::getStartTime); // 运维员只能看本站点,普通用户只能看自己的 if (RoleConst.OPERATOR.equals(user.getRole())) { qw.inSql(ChargingOrder::getPileId, "SELECT id FROM charging_pile WHERE station_id = " + user.getStationId()); } else if (RoleConst.USER.equals(user.getRole())) { qw.eq(ChargingOrder::getUserId, user.getUserId()); } return orderMapper.selectPage(query.toPage(), qw).convert(OrderConverter::toVO); }注意inSql里的站点 id 来自服务端解析的 token,不接受前端传参,这是数据权限的基本底线。前端传什么都会被伪造,只有 token 里的声明可信。
4.4 统计看板:三条 SQL 撑起首页图表
首页看板一般放四张图:月度订单趋势、时段充电量分布、桩利用率排行、营收构成。前端的 ECharts 配置不用自己从零写,关键是后端这三个接口的 SQL 要准。
-- 1. 按日统计订单量与营收(近 30 天) SELECT DATE(start_time) AS day, COUNT(*) AS orderCount, SUM(total_amount) AS amount FROM charging_order WHERE status = 2 AND deleted = 0 AND start_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY day ORDER BY day; -- 2. 各时段充电量分布,用来做峰谷分析 SELECT HOUR(start_time) AS hour, SUM(electricity) AS kwh FROM charging_order WHERE status = 2 AND deleted = 0 GROUP BY hour ORDER BY hour; -- 3. 单桩利用率:占用时长 / 可用时长,简化版用订单数近似排名 SELECT p.pile_no, COUNT(o.id) AS times, IFNULL(SUM(o.electricity), 0) AS kwh FROM charging_pile p LEFT JOIN charging_order o ON o.pile_id = p.id AND o.status = 2 WHERE p.deleted = 0 GROUP BY p.id, p.pile_no ORDER BY times DESC LIMIT 10;第一条 SQL 里status = 2是"已完成",只有已结算的订单才计入营收,未完成和已取消的要排除。第二条按小时分组时不用DATE_FORMAT是因为HOUR()更短且能走索引。第三条用 LEFT JOIN 保证零订单的桩也出现在列表里,否则利用率排名会缺桩,演示时看起来像数据丢了。
前端拿到的数组直接喂给 ECharts 的series.data,记得把日期补齐——某天没有订单时后端不会返回这一天,图上就会出现断点或者 x 轴错位。通常的做法是在 Service 里补零:先构造完整日期列表,再把查询结果按日期填进去。
5. 避坑与排查:这套系统最容易翻车的五个点
5.1 状态类问题
现象:演示时某根桩一直显示"已预约",反复刷新也不变,但订单列表里找不到对应订单。
原因:预约超时释放的定时任务没生效,或者任务里的时间条件写成了expire_time < now()但字段存的是字符串导致比较失效。还有一种可能是@Scheduled没有在启动类上加@EnableScheduling。
解决:先在数据库里直接查reservation表的expire_time,确认数据本身是否过期;再在定时任务里打日志确认它有没有被触发。如果部署在多台机器上,定时任务会重复执行,此时要给任务加分布式锁或者干脆改成查询时惰性释放。我一般会在桩列表接口里加一层兜底:查出来的预约超过 15 分钟就顺手释放,避免定时任务挂掉造成脏状态。
现象:桩状态在有订单的情况下仍然是"空闲",导致同一根桩被重复下单。
原因:启动充电、结束充电这两个动作没在同一个事务里改订单和桩两张表,或者事务注解加在 private 方法上失效了。
解决:把双表更新放在同一个@Transactional(rollbackFor = Exception.class)方法里,并确认调用是通过代理对象走外部调用,不要在同类内部自调用。
5.2 计费与金额类问题
现象:一笔 38.5 元的订单,用户端显示 38.5,管理端报表里汇总出 38.49。
原因:计算过程中间结果没有统一保留位数,或者有的地方用double计算导致精度丢失。
解决:金额和电量全部用BigDecimal,且在建表时就用decimal(10,2)和decimal(10,3)。构造BigDecimal时不要用new BigDecimal(0.1),要用BigDecimal.valueOf(0.1)或者字符串构造,否则一上来就带误差。
现象:跨零点的订单费用明显偏高。
原因:计费时段匹配用了startTime <= t && t < endTime,对于22:00-06:00这种跨零点规则永远不成立,于是 fallback 到了默认单价。
解决:匹配逻辑里先判断start < end还是start > end,后者用||连接,如上一章代码所示。写完一定要造一笔 22:40 到次日 01:10 的测试数据跑一遍,别只测白天的订单。
5.3 联调与环境类问题
现象:前端请求全部 401,但浏览器里能看到 token 明明带上了。
原因:跨域预检请求 OPTIONS 被 JWT 拦截器拦下来了,或者Authorization头名字对不上,前端传的是小写authorization。
解决:拦截器开头对OPTIONS方法直接放行;同时检查 CORS 配置是否允许Authorization头,很多人只写了allowedOrigins("*")却忘了allowedHeaders。
现象:本地跑得好好的,换台机器导入 SQL 后中文全是问号。
原因:建库语句没指定字符集,或者jdbc.url里少了characterEncoding=utf8。
解决:建库时显式写DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci,连接串里带上useUnicode=true&characterEncoding=utf8,两边都对齐。
6. 验收与答辩:把系统讲成一条完整业务闭环
验收这件事,我建议倒着准备:先想清楚要在十分钟里演示哪一条链路,再回头检查这条链路上的每个环节是否稳定。这套系统最适合演示的链路是"注册登录 → 查询空闲桩 → 预约 → 启动充电 → 结束结算 → 查账单",它把权限、并发、计费、状态机四个技术点全串起来了,比挨个点菜单有说服力得多。
下面这张表是我一般在演示前会自己过一遍的检查项,也可以直接当作验收清单用。
| 验收项 | 判定标准 | 演示动作 |
|---|---|---|
| 角色权限 | 运维员看不到其他站点订单 | 用两个账号分别登录,对比订单列表条数 |
| 桩位并发 | 同一根桩不能被两人同时预约 | 开两个浏览器窗口,先后点同一个桩 |
| 分时计费 | 跨零点订单按两段单价分别计算 | 造一笔 22:40—01:10 的订单,手算核对 |
| 状态机 | 已完成订单不能再次取消 | 在订单详情页点取消,应提示不允许 |
| 数据看板 | 图表数据与订单列表汇总一致 | 把近 30 天订单金额加总,和图表对比 |
| 异常兜底 | 桩故障后订单进入异常态且可退款 | 手工把桩状态改故障,再结束订单 |
答辩环节最容易被问的三个问题,提前准备好答案。第一个是"为什么用乐观锁而不是悲观锁",答:桩位预约是读多写少、冲突概率低的场景,悲观锁会让查询也持锁,得不偿失;乐观锁失败一次重试成本极低,用户体验上是"手慢了"的提示,可以接受。第二个是"为什么金额要冗余存",答:计费规则会调价,历史订单必须保留下单时的价格快照,否则对账逻辑不成立。第三个是"这套系统还能扩展什么",答:可以接物联网平台做桩的实时心跳上报,可以把计费规则做成按站点、按时段、按会员等级的多维配置,但要说清楚这是扩展方向而不是当前已实现,别把没做的说成做了。
还有个细节值得单独提:答辩时如果老师要求现场改数据,一定用后台管理页面改,不要直接开数据库客户端敲 SQL。前者体现的是系统有完整的管理能力,后者只会让人觉得你的功能是手工造的。同理,演示用的测试数据要提前造好,至少保证有 20 条以上订单、3 个站点、8 根桩,数据太少的看板看起来像没做完。
从那以后我每次交方案前都强制走一遍这张验收表,哪怕功能一个没改,也要把六个演示动作完整跑两遍——第一遍检查功能,第二遍掐时间。翻车往往不是因为不会做,而是因为演示路径上有个从没点过的按钮。希望帮到你。
本文还有配套的精品资源,点击获取