ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpringBoot充电桩管理系统实战:订单状态机与并发计费核心设计

2026/10/6 3:05:31 拓冰建站 浏览量
SpringBoot充电桩管理系统实战:订单状态机与并发计费核心设计 1. 项目定型和需求拆解先把“充电全流程”这件事讲清楚先说结论这个项目表面上是个毕设实际上是一套典型的“物联网 业务中台 支付结算”混合体。我当初拿到“电车充电系统”这个题目时第一反应不是直接写代码而是把“全流程”三个字拆开用户找桩、发起充电、实时计数、结束扣费、账单支付、运营管理、设备监控这一整条链路里每一个环节都需要一个明确的数据载体和状态流转。如果只是做几个增删改查页面答辩时很容易被一句话问住“你系统里的订单状态是怎么从待支付变成已完成的”所以我在做需求分析时先画了一个业务闭环然后反推功能清单。前端给三类角色用车主端小程序页面或H5、运营方管理后台PC端、系统管理员后端控制台。车主端需要的地图找桩、扫码充电、余额充值、充电记录运营端要的充电桩管理、价格策略、订单管理、异常订单处理管理员要的账号权限、数据统计、系统配置。这个闭环跑通之后才算一个完整的“充电全流程管理平台”而不是一堆孤立的页面。我做系统设计时坚持一个原则状态驱动一切。充电订单的所有操作比如开始、暂停、结束、故障回调都通过状态机来约束不允许任意跳转。这一点在后面写代码时帮我省下了大量非法操作校验逻辑也成了项目答辩里最能体现“工程思维”的亮点。这个项目适合两种人参考一是计算机专业的毕业生需要一套结构清楚、扩展性够好、能在答辩中讲出深度的课题二是刚入门SpringBoot的开发者想完整走一遍从需求到部署的项目过程。技术栈没有刻意追新都是国内企业当前真实在用的组合复现成本很低。2. 技术选型的真实理由SpringBoot MyBatis MySQL 这套组合到底强在哪2.1 为什么没有用SSM老组合也没有上SpringCloud很多毕设题单里还写着SSM但我拿到题目第一时间就把Spring MVC改成了SpringBoot。原因很现实SpringBoot的自动配置和内置容器能让我把90%的精力放在业务代码上而不是花三天折腾web.xml和Tomcat配置。尤其是充电桩上报数据这种场景本质上就是HTTP接口接收POST请求SpringBoot自带的Tomcat处理这种情况非常省心。至于SpringCloud我承认微服务更贴合大型充电网络但一个毕设系统OAuth2、网关、注册中心全上只会让答辩变得难证明因为面试官一旦追到服务拆分边界就很难自圆其说。我的建议是单应用内部分模块device管设备、order管订单、payment管结算。代码内聚逻辑清晰还能在答辩时主动提一句“后续可以按模块拆分微服务”。2.2 项目结构按业务模块分包不要按controller/service/dao不分层网络上有很多SpringBoot项目把Controller、Service、Mapper各建一个包然后往里面堆几十个类。我之前也这么干结果后来加一个充电价格策略功能光是找代码就翻了半天。这个项目我改成了按业务模块分包com.charge.platform ├── common // 统一返回结果、异常处理、工具类 ├── config // 拦截器、WebSocket、全局配置 ├── module │ ├── user // 用户注册、登录、余额管理 │ ├── station // 充电站、充电桩设备管理 │ ├── order // 订单创建、状态流转、异步任务 │ ├── payment // 支付回调、退款 │ └── report // 数据统计、报表 ├── security // JWT鉴权、角色权限 └── generator // 代码生成MyBatis Generator这样的好处是每个模块内部的Service只负责自己的领域逻辑模块之间通过接口调用权限拦截器在security包统一处理。当你加一个“充电计费规则”时你能立刻定位到module/order/strategy目录而不是在一个600行的ServiceImpl里找到底。2.3 开发环境与IDEA启动配置热词里有个“idea配置springboot服务 编辑配置数据 比如启动端口”其实SpringBoot启动端口根本不需要在IDEA里配直接在application.yml里写server.port就行IDEA只是把它当成一个普通变量读取。真正需要配置的是环境变量比如数据库密码、第三方支付密钥我建议用ConfigurationProperties或Value注入环境变量不要写死这样后续部署到服务器时切环境不用改代码。一个我踩过的坑SpringBoot版本不要贪高。我一开始用了SpringBoot 3.x结果MyBatis和某些第三方库跟不上折腾了一下午把版本降到2.7.x才稳定。毕设项目不需要尝鲜稳定兼容比新特性重要得多。3. 数据库设计用一张订单表串联充电全流程3.1 核心表结构设计与字段说明充电系统的数据模型核心有6张表用户表、充电站表、充电桩表、充电订单表、支付流水表、价格策略表。其中订单表最关键因为它承载了整个充电流程的状态和数据。我给出实际建表SQL的核心部分你可以直接抄。CREATE TABLE charge_order ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 订单ID, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, user_id BIGINT NOT NULL COMMENT 下单用户ID, station_id BIGINT NOT NULL COMMENT 充电站ID, pile_id BIGINT NOT NULL COMMENT 充电桩ID, order_status TINYINT NOT NULL COMMENT 状态0待支付1充电中2已完成3已取消4异常, start_time DATETIME DEFAULT NULL COMMENT 充电开始时间, end_time DATETIME DEFAULT NULL COMMENT 充电结束时间, expected_kwh DECIMAL(10,2) DEFAULT 0 COMMENT 预计电量(kWh), actual_kwh DECIMAL(10,2) DEFAULT 0 COMMENT 实际电量(kWh), total_amount DECIMAL(10,2) DEFAULT 0 COMMENT 应付金额(元), pay_amount DECIMAL(10,2) DEFAULT 0 COMMENT 实付金额(元), payment_method TINYINT DEFAULT 0 COMMENT 支付方式, finish_reason VARCHAR(50) DEFAULT NULL COMMENT 结束原因, version INT DEFAULT 0 COMMENT 乐观锁版本号, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_pile_id_status (pile_id,order_status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个设计细节很多人容易忽略。第一是version字段这是乐观锁后面做并发扣费时要用。第二是finish_reason字段它记录了充电因为什么结束——用户停止、充满自停、故障中断还是超时强制关闭。别小看这个字段它能让运营后台快速分辨哪些订单需要人工介入。3.2 充电订单状态设计为什么状态字段要比接口先定我先定义了一套状态枚举然后才去写Controller接口。状态跳转我用一个Map或数据库约束来管理避免出现“从已完成直接转成充电中”的荒唐逻辑。实际状态机如下待支付(0) - 充电中(1) - 已完成(2) \ \ \ - 异常(4) - 已完成(2) 或 已取消(3) - 已取消(3)后端启动时会加载状态转移规则表每次更新订单状态时先校验合法性。我建议状态流转不要直接写if-else而是建一个枚举类每个枚举里定义nextStatus列表Service里调用status.canTransitTo(newStatus)判断。这样做的好处是以后接入第三方充电协议比如充电桩推送异常信号你能知道异常状态可以由谁触发不会一头雾水。3.3 索引设计和SQL示例充电桩的数据上报非常频繁主键索引肯定有但要特别注意(pile_id, order_status)这个联合索引。因为运营端的列表页经常按充电桩查当前订单而消费端按用户ID查历史订单这两个查询如果没索引数据一多就慢得离谱。计费时我需要读取当前价格策略具体SQL是SELECT price_per_kwh, service_fee_per_kwh FROM charge_price_rule WHERE station_id #{stationId} AND start_time NOW() AND end_time NOW() ORDER BY start_time DESC LIMIT 1;这一句很有代表性价格策略是时间分段命中的白天和深夜电价不同所以用区间查询而不是直接存一个数值。4. 后端核心模块实现预约、启动、计费、结算4.1 充电桩设备管理模块充电桩接入方式很直接桩端设备通过HTTP接口向后端推送状态后端用REST接口接收。设备模块的核心是两个接口设备心跳上报和设备状态变更。心跳上报不仅用来显示在线离线还顺带维护设备最后心跳时间状态变更则是设备从“空闲”到“充电中”再到“空闲”的关键信号也是订单状态更新的触发源。我用一个Spring Boot组件DeviceStatusHandler统一处理这些回调里面按deviceId加锁避免同一台桩的并发上报导致订单错乱。设备上报的报文字段不多核心就是:{ deviceId: CDZ-001, pileStatus: 2, currentKwh: 12.5, voltage: 220.3, current: 13.6 }这里有个易踩坑的地方桩端上报是异步并发请求直接updateById可能出现旧状态覆盖新状态的情况。我的做法是每条上报带一个reportTime处理前比较上报时间与数据库里的更新时间如果上报时间更旧就直接丢弃从根源上解决批量重连时的乱序问题。4.2 订单状态机流转实现订单创建发生在用户选好充电桩并扫码之后。此时我先创建一个订单状态为“待支付”同时预占充电桩。占桩的方式是利用数据库唯一索引(pile_id, order_status)中只允许一个“待支付/充电中”的订单存在这个约束由数据库保证比在应用中加锁更可靠。开始充电时前端调用POST /order/start后端做三件事校验订单状态是否为“待支付”。调用设备服务下发充电指令。订单状态更新为“充电中”同时记录start_time。我实际写代码时用了事务加乐观锁Transactional(rollbackFor Exception.class) public void startCharge(Long orderId) { ChargeOrder order orderMapper.selectByIdForUpdate(orderId); if (!OrderStatus.PENDING_PAY.equals(order.getOrderStatus())) { throw new BusinessException(订单状态不能开始充电); } // 更新状态 order.setOrderStatus(OrderStatus.CHARGING.getStatus()); order.setStartTime(LocalDateTime.now()); // version字段作为乐观锁条件 int rows orderMapper.updateByVersion(order); if (rows 0) { throw new BusinessException(更新失败请重试); } // 下发设备指令 deviceFeignClient.startCharge(order.getPileId(), order.getOrderNo()); }由于selectByIdForUpdate加了行锁同一订单的并发更新会排队再配合更新后返回的受影响行数判断版本就不会出现两边都认为自己是“最新”的问题。4.3 计费引擎与并发扣费处理计费是最能讲故事的部分。我做了两种计费模式按电量计费和按时长计费。默认走电量计费多退少补。在充电过程中桩端会实时上报当前累计电量后端计算实际费用public BigDecimal calcChargeAmount(BigDecimal kwh, BigDecimal pricePerKwh, BigDecimal serviceFee) { if (kwh null || kwh.compareTo(BigDecimal.ZERO) 0) { return BigDecimal.ZERO; } return kwh.multiply(pricePerKwh).add(kwh.multiply(serviceFee)) .setScale(2, RoundingMode.HALF_UP); }这里有一个真实业务里很容易踩的雷金额计算一律用BigDecimal千万不要用double。我写第一版计费的时候图省事用了double后来出现订单金额是19.999999这种结果直接翻车。所以所有金额字段全部设成DECIMAL(10,2)Java侧用BigDecimal参与运算和比较。并发扣费主要发生在用户余额不足和实时费用更新的场景。用户点击“结束充电”时系统需要同时做三件事计算最终费用、冻结余额扣费、更新订单状态。我用数据库事务配合表的悲观锁解决逻辑如下Transactional(rollbackFor Exception.class) public void finishCharge(Long orderId) { // 先锁定订单 ChargeOrder order orderMapper.selectByIdForUpdate(orderId); // 计算金额 BigDecimal total calcByOrder(order); // 扣减用户余额 userBalanceMapper.deductBalance(order.getUserId(), total); // 更新订单为已完成 order.setOrderStatus(OrderStatus.COMPLETED.getStatus()); order.setTotalAmount(total); order.setEndTime(LocalDateTime.now()); orderMapper.updateById(order); }把整个过程包在一个事务里如果扣款失败则全部回滚不会出现钱扣了订单还是充电中的情况。这也是面试官最喜欢追问的地方——分布式事务怎么做这个项目里我直接回答“单机事务保证后续可扩展Seata”既回答清楚又展现了思考深度。4.4 WebSocket实时推送充电进度实时状态是所有“看上去有科技感”的功能里最容易实现的一种。我用SpringBoot内置的WebSocket在充电进行中每5秒向车主端推送当前功率、已充电量、预估剩余时间。关键是这样的Component public class ChargeProgressHandler extends TextWebSocketHandler { private final MapString, Session sessionMap new ConcurrentHashMap(); public void sendToUser(Long userId, String json) { String key user_ userId; Session session sessionMap.get(key); if (session ! null session.isOpen()) { session.sendMessage(new TextMessage(json)); } } Override public void afterConnectionEstablished(WebSocketSession session) { String userId (String) session.getAttributes().get(userId); sessionMap.put(user_ userId, session); } }WebSocket的鉴权我直接复用JWT握手时从请求参数里带上token在拦截器里校验通过之后才建立连接。这是毕设级别最省心的做法不需要额外引入消息队列。我之前踩过一个坑忘记注册WebSocket拦截器结果前端连上来后所有的session.getAttributes()都是空的。解决办法是在WebSocketHandlerRegistration.addInterceptors()里注册一个HandshakeInterceptor把解析好的用户信息塞进attribute。5. 前端Vue与SpringBoot的集成部署5.1 Vite构建配置与打包路径前端我用了Vue 3 Vite Element Plus。因为后端使用SpringBoot作为静态资源服务器所以前端的打包输入目录必须对齐后端的static目录。我的vite.config.js配置是export default defineConfig({ build: { outDir: ../backend/src/main/resources/static, assetsDir: assets, emptyOutDir: true, }, server: { host: 0.0.0.0, port: 5173, proxy: { /api: http://localhost:8080 } } })emptyOutDir: true很关键否则每次打包后旧文件残留容易产生缓存混乱。assetsDir单独放静态资源配合publicPath使用相对路径这样部署到服务器子目录也不会出错。5.2 将Vue打包产物放到SpringBoot静态目录前端打包完成后整个static目录会复制到后端工程里。此时启动SpringBoot访问http://localhost:8080/就会自动显示前端首页。如果一个页面刷新提示404多半是前端用了history路由而后端没有做fallback路由。最简单的解决办法是在SpringBoot里加一个控制器把非/api的请求统一转发到index.htmlController public class SpaForwardController { RequestMapping(value {/, /login, /map, /order/**}) public String forward() { return forward:/index.html; } }如果你有多个子路由可以用ViewResolver但我更推荐用UrlRewriteFilter毕设项目就不展开更多方案了。5.3 跨域与反向代理的取舍开发环境我直接启用Vite的proxy这样前端请求/api/xxx会被自动转发到后端8080端口不存在跨域。生产环境我的做法是把前端打包进SpringBoot的static目录然后用Nginx统一代理/api路径到同一台服务器的Java进程并开启Gzip。一句话总结能不加跨域配置就不加加了反而让代码变复杂。当然如果你非要跨域也可以在SpringBoot里写一个全局CorsFilter但要注意拦截器的执行顺序否则JWT校验在CORS之前执行会把预检请求拦截掉。我的经验是生产环境坚决依赖Nginx反向代理开发环境依赖Vite代理两边都不需要跨域。6. 常见问题排查这10个坑我基本都踩过6.1 数据库连接池爆满我第一版上线没开数据库连接池结果设备上报一多后端报“connection is not available”错误。排查过程花了两小时最终发现默认的HikariCP最大连接数是10而设备上报的线程池又开了50个线程导致大量线程等在连接池外面。解决办法很简单在application.yml里调整参数spring: datasource: hikari: maximum-pool-size: 30 minimum-idle: 5 connection-timeout: 30000但是这个数值不能拍脑袋要按线程数 * (单线程耗时 / 1秒) 1来估算。我这里是设备上报平均耗时200ms每秒约40次请求算出来需要40 * 0.2 1 9个连接但为了峰值余量我设到30避免重启后瞬间并发打满。6.2 并发更新订单导致状态错乱这个坑是真实发生过的用户同时点了两次“结束充电”结果第一笔请求更新完状态后第二笔请求也通过了if判断直接重复扣费。这就是典型的并发更新问题。我的解法除了上面用的selectByIdForUpdate外还在更新SQL里加了WHERE status 旧状态条件UPDATE charge_order SET order_status 2, pay_amount #{payAmount} WHERE id #{orderId} AND order_status 1;如果返回的影响行数为0说明订单状态已经变了当前请求直接视为非法操作。这个方法很简单但在我接触过的项目中至少有一半新手不知道都是用updateById盲目覆盖迟早出bug。6.3 MyBatis动态SQL的if条件失效写多条件列表查询时我经常遇到“参数为0被当作空过滤掉”的问题。比如只传status0查询待支付订单MyBatis里如果用if teststatus ! null愣是没生效。原因是MyBatis对int类型自动拆箱当status为0时status ! null直接变成0 ! null结果恒为true然后动态SQL判断就失效了。解决方式是实体字段统一用Integer包装类型而不是int。这属于非常细致的坑但面试时你能主动讲出来会让人觉得你真的写过项目。6.4 时区问题导致计费金额偏差我开发机在本地数据库在服务器两边时区不一致导致NOW()和系统时间差了8小时用户23点发起的订单被算到第二天凌晨价格策略匹配完全错乱。排查方法很简单执行SELECT NOW() - INTERVAL 0 SECOND对比服务器时间和数据库时间。解决办法是在数据库连接URL里加serverTimezoneAsia/Shanghai并且所有时间字段都用DATETIME不要用TIMESTAMP避免MySQL在读取时做默认时区转换。这里再补充一个避免采坑的原则时间统一用后端服务器的本地时间写入读取时统一用前端格式化不要指望数据库给。我在订单创建表、支付流水表里全部用LocalDateTimeJava序列化时设置成yyyy-MM-dd HH:mm:ss全程不依赖数据库函数问题彻底解决。6.5 WebSocket推送频率过高导致前端卡顿设备上报数据本身是高频的但推送给用户不需要那么高频。我最初设计是每1秒推一次结果前端渲染平滑但是CPU占用极高手机发热严重。后来改成每5秒推一次并且在后端做滑动窗口聚合不再推送每一帧数据前端体验反而更明显提升。7. 这个项目怎么讲出亮点答辩与面试侧重点7.1 答辩时重点讲“业务闭环”不要只讲“我用了SpringBoot”很多毕业生在答辩时喜欢说“我用了SpringBoot和MyBatis实现了用户CRUD”这基本上等于自爆。真正有含金量的讲述逻辑是先从需求出发说明充电流程有哪些环节然后说明为了支撑这些环节做了哪些设计最后举一两个具体问题说明你是怎么解决的。我最推荐的答辩演示路径是用手机端H5扫码——点击开始充电——模拟设备端推送电流电压数据——看到后台订单状态从待支付变成充电中——点结束充电——看到费用账单生成、余额扣减——打开运营后台看到订单列表、充电桩状态变化。整个流程连贯走一遍评委很快就会觉得这个系统是真的能跑的而不是PPT项目。7.2 常见追问与回答思路有人会问“用户退款了充电桩状态怎么恢复”这个问题看着基础但能考察是否理解状态机与设备状态联动。我当时的回答是退款回调后后端先修改订单状态为已退款然后调用设备服务把对应充电桩置为空闲状态同时清理订单占用的桩资源。还有人问“高峰期很多用户同时扫码系统怎么避免同一个桩被两个人抢”。我直接回答数据库层面用(pile_id, order_status)唯一索引保证同一桩只能有一个待支付或充电中的订单应用层面再用分布式锁兜底双重保护。7.3 值得扩展的方向这个项目再往下扩展方向很多。如果你为了体现架构能力可以加一个Redis缓存设备实时状态解决高频读取问题如果你愿意深挖并发可以引入Redisson分布式锁处理跨服务扣费如果你想体现业务思考可以加一个“充电排队”功能让用户预约充电桩后不用现场等位。这些都是能再写一篇论文素材的点但毕设阶段我建议点到为止把一个核心问题讲透比铺开十个半成品更值钱。8. 写给自己和你的一点实战心得这个项目从确定题目到跑通全流程我大概花了四周每天两小时加上周末。最大的感受是充电系统抛开电动车的背景本质上是一个完整的订单交易系统难点集中在订单状态管理、并发扣费、第三方对接设备回调三块。这三块你能讲清楚一种处理方式就已经超过90%的毕设项目。最后说一个最容易被忽略但很加分的细节用Git从第一天就记录每一个功能的提交。答辩时老师问“怎么证明这是你自己做的”你切到Git日志展示每个模块的提交记录和commit message比嘴上说多少句都有说服力。如果你现在还没开始先把数据库表建出来再写一个最简单的“设备上报接口”项目就能跑起来后面都是往这个骨架上添东西。别想太多写起来再说。