ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

SpringBoot+Vue充电桩管理系统:预约锁桩与分时计费实现

SpringBoot+Vue充电桩管理系统:预约锁桩与分时计费实现

简介:面向Java毕业设计与课程设计场景的新能源汽车充电桩管理系统完整项目包,采用SpringBoot后端与Vue前端分离架构,以MySQL作数据库,涵盖首页、个人中心、维修员与用户管理、电桩类别管理、充电桩管理、充电桩报修与维修回复、系统管理等模块,可直接作为毕设选题的落地方案。压缩包共1322个文件,约32.33MB,内含107个java源文件、43个vue组件、307个js脚本与101个css样式,另含html页面、xml配置、class编译文件及svg、png等图片图标资源,并附一份sql建库脚本与论文文档,源码、数据库与文档三者配套齐全,便于导入运行和二次修改。目前已有190人学习下载。读者可借此了解前后端分离项目的目录组织与接口调用方式,对照论文理清需求分析与功能设计脉络,快速搭建可演示的充电桩管理原型,并在此基础上调整业务字段与页面样式。

1. 从一张“桩被占着却没充电”的投诉单说起:这套系统要解决什么

新能源车主的抱怨往往不是充电慢,而是到了站桩已经被占着:有人充满了不走,有人预约了不来,月底账单上的电费和服务费还对不上。这类问题靠登记表和微信群里喊话管不住。这套基于 SpringBoot + Vue 的新能源汽车充电桩管理系统,把站点、充电桩、预约订单、计费规则、故障工单这五件事装进一个后台:车主端扫码启动、按时段计费,运营商端看站点收益,运维端接故障工单。它适合正在做 java毕业设计、需要一份既能跑起来又能讲清业务闭环的同学,也适合想拿一套前后端分离骨架改造成真实运营平台的开发者。java充电桩这类题目最容易被做成增删改查的壳子,差别就体现在计费和订单状态这两块。

2. 需求拆解与角色划分:四类用户、六个用例怎么切边界

做充电桩系统最先遇到的争论是“要不要做预约”。有同学觉得预约就是个定时任务,加个字段就行;真做起来会发现预约直接牵扯锁桩、超时释放、违约金三件事,而这三件事又都落在订单表上。我一般的做法是先把角色和用例边界画清楚,再回头决定哪些功能进一期、哪些砍掉,避免到第 5 周发现订单表设计支撑不了状态流转,返工重来。

2.1 四类角色与权限矩阵

系统里的用户不是“管理员 + 普通用户”两分法,而是四类,权限差别很大,前端路由和后端接口都要按这个矩阵卡一遍:

角色典型操作数据范围不能做什么
平台管理员运营商审核、价格模板维护、全局报表全部站点不能直接改订单金额
运营商站点与桩的录入、收益看板、工单派单本人名下站点看不到其他运营商的营收
运维人员接工单、上报故障、桩状态置检修被指派的站点不能改计费规则
车主预约、启动充电、查看账单、评价本人订单不能查他人充电记录

这张表看着简单,落地时最容易漏的是“运营商只能看自己的站点”。很多参考代码里station表压根没有operator_id,等到做数据隔离才发现要从头补字段、补查询条件,工作量比想象中大得多。数据库设计阶段就把归属字段留好,后面加一行where operator_id = #{currentUserId}就能解决。

2.2 核心用例与业务闭环

主线只有一条:查桩 → 预约 → 到场启动 → 充电中 → 结束计费 → 结算支付 → 评价。围绕这条主线派生六个用例:

  • 站点与桩管理:站点含经纬度、营业时间、停车费说明;桩含编号、型号、额定功率、枪口数、当前状态。
  • 预约锁桩:用户选定桩和时段,系统占位 15 分钟,超时未到场自动释放。
  • 充电订单:启动时记录起始电量(或按额定功率估算),结束时按分时电价算钱。
  • 计费与账单:分时电价模板 + 服务费单价,两者分开落库,方便对账。
  • 故障工单:车主上报“桩无法启动”,生成工单派给运维,桩状态转检修。
  • 统计看板:按站点、按月、按时段聚合订单量、电量、营收。

这里有个设计取舍值得说:预约要不要收预付金。课程设计层面通常不做支付,只做“信用分扣减”,超时三次冻结预约权限。这个规则写进需求文档比写进代码更重要,答辩时老师问“用户恶意占位怎么办”,你有答案。

2.3 非功能需求与可演示性约束

毕设项目和商用系统的差别在于:商用系统怕出错,毕设项目怕演示不出来。所以非功能需求要写得务实一点。

并发方面,真实场景一天几十单,但答辩演示时会有多窗口同时点预约,锁桩逻辑必须真的能挡住。数据一致性方面,订单状态和桩状态必须同事务提交,否则会出现“订单已完成、桩还显示充电中”的尴尬画面,这种画面一旦出现在答辩现场,基本没法圆。可演示性方面,统计数据不要依赖真实跑几天,SQL 脚本里预置近三个月的模拟订单,导入就有曲线。

提示:预置数据的时间字段用相对时间(如DATE_SUB(NOW(), INTERVAL 30 DAY))比写死日期更稳,隔一个月再演示也不会变成一堆陈年数据。

3. 总体架构设计:SpringBoot + Vue 前后端分离的分层与数据流

技术选型上没有太多悬念,SpringBoot 2.7 + MyBatis-Plus + MySQL 8.0 做后端,Vue 3 + Element Plus + ECharts 做前端,JWT 做无状态鉴权。选这一套的理由很实际:生态成熟、报错信息好搜、答辩老师基本都认识。真正决定这个项目好不好改的,是分层边界清不清楚,而不是用了多新的框架。

3.1 后端分层:Controller-Service-Mapper 的职责边界

常见的翻车写法是 Controller 里直接调 Mapper,顺手把业务判断也写了,最后 Service 层空着。这种代码在小 demo 里没问题,一旦要加“启动充电前校验桩状态并改状态”,就会变成 Controller 里二十行事务代码,测试和复用都很难。

我一般强制三条规矩:Controller 只做参数校验和 DTO 转换,不出现@Transactional;Service 承载事务和业务规则;Mapper 只写 SQL,不写 if-else。

@Service public class ChargingOrderServiceImpl implements ChargingOrderService { @Resource private ChargingPileMapper pileMapper; @Resource private ChargingOrderMapper orderMapper; /** * 启动充电:桩状态校验 + 落订单 + 改桩状态,三步同事务 * pileId 桩主键,userId 当前登录车主 */ @Override @Transactional(rollbackFor = Exception.class) public Long startCharge(Long pileId, Long userId) { // selectForUpdate 走行锁,挡住两个用户同时点“启动” ChargingPile pile = pileMapper.selectForUpdate(pileId); if (pile == null || pile.getStatus() != PileStatus.IDLE.getCode()) { throw new BizException("该充电桩当前不可用,请换一个桩"); } if (orderMapper.countRunningByUser(userId) > 0) { throw new BizException("你有正在进行的充电订单"); } ChargingOrder order = new ChargingOrder(); order.setOrderNo(SnowflakeId.next()); order.setPileId(pileId); order.setUserId(userId); order.setStationId(pile.getStationId()); order.setStartTime(LocalDateTime.now()); order.setState(OrderState.CHARGING.getCode()); orderMapper.insert(order); // 桩状态与订单同事务提交,避免出现订单已建、桩还空闲的中间态 pileMapper.updateStatus(pileId, PileStatus.CHARGING.getCode()); return order.getId(); } }

这段代码里两个参数值得留意:rollbackFor = Exception.class保证受检异常也回滚;selectForUpdate要求pile_id上有主键或唯一索引,否则会锁全表,演示时表现为“点了没反应”。countRunningByUser那条查询是防重复下单的兜底,不要删,用户手抖连点两次的概率比你想的高得多。

3.2 前端页面分区与路由守卫

前端按角色分成三块:/portal车主端(移动端适配)、/admin运营后台(PC 端)、/ops运维端(工单为主)。三块共用一套 axios 封装和权限指令,但路由表分开维护,这样打包时也方便按需拆分。

鉴权放在全局前置守卫里,别在每个页面里写一遍if (!token):

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') // 未登录且目标页需要鉴权,带上来源地址,登录后跳回去 if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) return } // 角色校验:车主账号即使手输 /admin 也进不去 const roles = JSON.parse(localStorage.getItem('roles') || '[]') if (to.meta.roles && !to.meta.roles.some(r => roles.includes(r))) { next('/403') return } next() })

to.meta.roles在路由表里配成数组,比如运营后台的路由统一写roles: ['ADMIN', 'OPERATOR']。前端守卫只是体验层,真正的权限校验必须在后端拦截器里再做一次,前端能改 localStorage,这一点答辩时被问到要能说清楚。

3.3 数据库设计:核心表与关键索引

一共十二张表,真正需要细看的是订单表,因为它是所有统计和状态流转的落点。

CREATE TABLE `charging_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号,雪花ID', `user_id` BIGINT NOT NULL COMMENT '车主ID', `pile_id` BIGINT NOT NULL COMMENT '充电桩ID', `station_id` BIGINT NOT NULL COMMENT '站点ID,冗余便于统计', `start_time` DATETIME DEFAULT NULL COMMENT '启动时间', `end_time` DATETIME DEFAULT NULL COMMENT '结束时间', `duration` INT DEFAULT 0 COMMENT '时长(分钟)', `energy` DECIMAL(10,2) DEFAULT 0 COMMENT '电量(kWh)', `electric_fee` DECIMAL(10,2) DEFAULT 0 COMMENT '电费', `service_fee` DECIMAL(10,2) DEFAULT 0 COMMENT '服务费', `total_fee` DECIMAL(10,2) DEFAULT 0 COMMENT '合计金额', `state` TINYINT NOT NULL DEFAULT 1 COMMENT '1预约 2待启动 3充电中 4已结束 5已结算 6已取消 7异常', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_pile_time` (`pile_id`, `start_time`), KEY `idx_user_state` (`user_id`, `state`), KEY `idx_station_time` (`station_id`, `start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='充电订单表';

几个字段是血泪经验换来的:station_id冗余存一份,统计时不用反复 join 桩表;金额一律DECIMAL,用DOUBLE算到最后会出现 0.01 的差额,对账时很难解释;idx_station_time是给月度报表用的,没有它数据量上到十万行,看板会明显卡一下。

4. 核心模块实现:订单状态机、计费引擎与预约锁桩

这一章是这个项目真正能拉开差距的地方。增删改查谁都会写,能把订单状态管住、把跨峰谷的钱算对、把并发预约挡住,才算摸到了业务系统。

4.1 订单状态机:七个状态与流转约束

订单状态最怕散落在各个 Service 里各改各的,最后出现“已取消的订单又被结算了一次”。做法是抽一个状态机,把合法流转定义成一张表,所有状态变更必须过它。

public enum OrderState { BOOKED(1), WAITING(2), CHARGING(3), FINISHED(4), SETTLED(5), CANCELED(6), ABNORMAL(7); private final int code; OrderState(int code) { this.code = code; } public int getCode() { return code; } // 合法流转表:key 当前状态,value 允许到达的状态 private static final Map<OrderState, Set<OrderState>> FLOW = Map.of( BOOKED, Set.of(WAITING, CANCELED), WAITING, Set.of(CHARGING, CANCELED, ABNORMAL), CHARGING, Set.of(FINISHED, ABNORMAL), FINISHED, Set.of(SETTLED), SETTLED, Set.of(), CANCELED, Set.of(), ABNORMAL, Set.of(SETTLED, CANCELED) ); public static void check(OrderState from, OrderState to) { if (!FLOW.getOrDefault(from, Set.of()).contains(to)) { throw new BizException("订单状态不允许从 " + from + " 变更为 " + to); } } }

调用侧统一写成OrderState.check(current, target)再更新,异常直接抛给前端。这张流转表还有个隐性好处:写论文时它就是一张现成的状态图说明,不用再单独画。

实际业务里要留一个ABNORMAL,用来兜“充电中途桩掉线、电表读不到数”的情况。没有这个状态,遇到异常只能强行结束订单,金额算出来是错的,运维也没法介入。

4.2 计费引擎:分时电价 + 服务费的切片算法

分时电价是汽车充电桩计费里最容易翻车的地方。整单按结束时刻的电价算,会让一个从谷时充到峰时的订单少收一大截;按小时粗粒度切,跨点的那一小时又算不准。我一般按分钟切片,逻辑直白,误差可以接受。

参数示例值说明
峰时电价1.20 元/kWh08:00–11:00、18:00–21:00
平时电价0.85 元/kWh11:00–18:00、21:00–23:00
谷时电价0.45 元/kWh23:00–次日 08:00
服务费单价0.60 元/kWh按总电量计,不参与分时
额定功率60 kW无电表读数时用于估算
/** * 按分钟切片计费 * @param start 启动时间 @param end 结束时间 * @param segments 分时电价区间列表 @param serviceRate 服务费单价(元/kWh) */ public FeeResult calculate(LocalDateTime start, LocalDateTime end, List<PriceSegment> segments, BigDecimal serviceRate) { long minutes = Duration.between(start, end).toMinutes(); BigDecimal energy = BigDecimal.ZERO; // 累计电量 BigDecimal electricFee = BigDecimal.ZERO; for (long i = 0; i < minutes; i++) { LocalDateTime point = start.plusMinutes(i); // 命中该分钟所属的电价区间,跨零点也能正确回绕 PriceSegment seg = segments.stream() .filter(s -> s.contains(point.toLocalTime())) .findFirst() .orElseThrow(() -> new BizException("时段未配置电价")); // 每分钟电量 = 额定功率 / 60,保留 4 位小数避免累计误差放大 BigDecimal minuteEnergy = seg.getPowerKw() .divide(BigDecimal.valueOf(60), 4, RoundingMode.HALF_UP); energy = energy.add(minuteEnergy); electricFee = electricFee.add(minuteEnergy.multiply(seg.getPrice())); } BigDecimal serviceFee = energy.multiply(serviceRate); // 服务费按总电量算 return new FeeResult( energy.setScale(2, RoundingMode.HALF_UP), electricFee.setScale(2, RoundingMode.HALF_UP), serviceFee.setScale(2, RoundingMode.HALF_UP)); }

三个细节决定这套算法能不能站住:RoundingMode.HALF_UP明确指定,别用默认的HALF_EVEN,否则 0.125 会变成 0.12,对账时对不上;每分钟电量保留四位小数再累加,是因为 60kW 每分钟是 1kWh,但 7kW 慢充桩每分钟只有 0.1167,先舍入再累加会差出几度电;contains方法里要处理23:00-08:00这种跨零点区间,简单的大小比较会直接失效。

真接了电表的话,energy应该取电表读数差,上面这套估算只用于演示和离线补算。这一点在论文的“系统局限”一节写清楚,比藏着更好。

4.3 预约锁桩与并发冲突:Redis 占位 + 唯一索引兜底

预约功能的本质是“在一段时间内独占一个桩”。单机部署时用数据库行锁就够,但只要想体现一点工程性,还是 Redis 占位 + 数据库唯一索引双保险更稳妥。

/** * 预约占位:SETNX 成功即持有,过期时间等于预约时长 * 返回 false 表示该桩在此时段已被占用 */ public boolean tryBook(Long pileId, Long userId, Duration hold) { String key = "pile:book:" + pileId; Boolean ok = redisTemplate.opsForValue() .setIfAbsent(key, String.valueOf(userId), hold); return Boolean.TRUE.equals(ok); } /** 到场启动或用户取消时主动释放,避免占位空转到过期 */ public void release(Long pileId) { redisTemplate.delete("pile:book:" + pileId); }

Redis 挡的是瞬时并发,数据库挡的是“Redis 挂了或者被清了”的情况。在charging_order上加一条针对预约中的唯一索引思路——用pile_id + 预约时段起点建唯一键,重复插入直接抛DuplicateKeyException,Service 层捕获后转成“该时段已被预约”的友好提示。

注意:Redis 占位的过期时间不要设太长,15 分钟是常见值;设成 2 小时会出现“桩空着但谁都不能约”的尴尬,用户投诉起来很难解释。

4.4 统计看板:按站点与月份的聚合查询

看板不需要复杂的 OLAP,一条分组 SQL 加上前端 ECharts 就够了。关键是过滤条件要卡准,只统计已结算订单。

SELECT s.station_name, DATE_FORMAT(o.start_time, '%Y-%m') AS month, COUNT(o.id) AS order_cnt, ROUND(SUM(o.energy), 2) AS total_kwh, ROUND(SUM(o.electric_fee), 2) AS electric_amount, ROUND(SUM(o.service_fee), 2) AS service_amount, ROUND(SUM(o.total_fee), 2) AS total_amount FROM charging_order o JOIN charging_station s ON s.id = o.station_id WHERE o.state = 5 -- 5 = 已结算,未结算订单不计入营收 AND o.start_time >= #{begin} AND o.start_time < #{end} GROUP BY s.station_name, month ORDER BY month DESC, total_amount DESC;

用>= begin AND < end而不是BETWEEN,是为了避开右端点重复计入的边界问题——月底最后一秒的订单被算进两个月,报表就会多一笔,这种错误在演示时几乎发现不了,但写进论文被追问就很被动。前端的 ECharts 拿到这个结果直接setOption,横轴取month,两条系列分别取total_kwh和total_amount,双 Y 轴展示。

5. 避坑与常见问题排查:从环境起不来算到数据对不上

这套系统我在不同机器上跑过好几次,翻车的点高度集中在下面五处,按出现频率排序。

5.1 环境与部署类三连坑

坑一:前端npm run dev起了但所有接口 404 或跨域报错。现象是浏览器控制台一排 CORS 红字,Network 里请求地址是http://localhost:5173/api/...。原因是 Vite 的代理没配,或者配了但rewrite写错,把/api前缀带给了后端。解决办法是在vite.config.js里补 proxy,rewrite: path => path.replace(/^\/api/, ''),同时确认后端server.servlet.context-path没有被重复设置成/api,两边都去掉前缀才是对的。

坑二:SQL 脚本导入报错,或者导进去了中文全是问号。现象是 Navicat 执行脚本时提示Incorrect string value。原因通常是建库语句没指定字符集,或者客户端连接用的是latin1。解决办法是建库时显式写DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci,导入前把连接编码改成 UTF-8,脚本开头加SET NAMES utf8mb4;。MySQL 8.0 默认已经是 utf8mb4,但导出的脚本来自旧环境时仍会带utf8,一条条改太慢,直接全局替换。

坑三:项目起来了,登录一直提示密码错误。现象是明明数据库里看着有 admin 账号。原因是密码字段存的是 BCrypt 密文,而 SQL 脚本里塞的是明文或者别的方式生成的哈希,matches()永远返回 false。解决办法是统一用同一个PasswordEncoder生成一次密文,把结果写进脚本;调试阶段可以临时在启动类里打一行生成密文输出,用完删掉。

5.2 业务与数据一致性类两个坑

坑四:两个窗口同时预约同一个桩,两边都成功了。现象是列表里出现两条BOOKED状态、同一个pile_id的订单。原因是只用了“先查再插”,两个请求都在查询阶段看到桩空闲。解决办法是把判断收进一次原子写:Redis 的setIfAbsent占位,或者数据库唯一索引兜底,捕获DuplicateKeyException后返回业务提示。中间加synchronized只在单机有效,多实例部署直接失效,不要当成最终方案。

坑五:订单金额和自己手算的对不上,差几毛钱。现象是同一笔订单,系统算出 43.27,手算 43.26。原因有三个:金额字段用了DOUBLE,二进制浮点从根上就存不准;服务费被算了两遍,一次在计费引擎里、一次在结算接口里;跨峰谷订单整单按结束时刻的电价算了。解决办法是金额统一DECIMAL(10,2),服务费只在计费引擎里算一次并落库,结算只做状态流转不改金额。改完拿一笔跨 18:00 的订单手工对一遍,能对上才算真的修好。

6. 验收与答辩:怎么证明这套系统真的能跑通

验收不是把功能点挨个点一遍,而是拿一条完整业务链走穿:新用户注册 → 预约桩 → 到场启动 → 充 3 分钟 → 结束 → 生成账单 → 结算 → 看板数据变化。这条链走完,八成的问题会自己冒出来。下面这份清单我一般会提前一天过一遍,避免现场手忙脚乱。

验收项判断标准现场演示动作
环境可复现按 README 从零导入 SQL、启动前后端换一台机器重新跑一遍
权限隔离车主账号访问/admin被拦手输 URL 直接访问
状态机已结算订单无法再取消在订单详情点取消按钮
计费准确跨峰谷订单金额与手算一致造一笔 17:55 启动的订单
并发安全双窗口同时预约同一桩,只有一个成功两个浏览器并排点击
数据完整看板数据与订单列表汇总一致对比总数与图表数值

答辩被问得最多的三个问题,提前准备好答案比临场编强:为什么选 SpringBoot 而不是别的——说清自动装配省掉了多少 XML 配置、生态里 MyBatis-Plus 和 Spring Security 拿来即用;预约并发是怎么解决的——答 Redis 占位加唯一索引双保险,并说清楚单机锁为什么不够;计费为什么按时段切片——拿一笔跨峰谷的订单当场算一遍,比任何解释都管用。

我吃过一次亏:演示前没清缓存,前端还是旧版本打包结果,点了预约一直转圈,最后发现是接口路径对不上,当场排查花了七八分钟,后面讲设计的时间被压掉一半。从那以后我每次上台前都强制走一遍「清缓存 → 重启后端 → 用无痕窗口跑完整链路」的流程,宁可多花十分钟,也不要在投屏上debug。这套系统的源码、SQL 脚本和论文文档我整理成了一份完整包,按上面的清单走一遍就能直接跑起来;如果你正好卡在订单状态或者计费算法上,把它拆开对着改会比从零写快得多。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表