☰
Spring Boot充电桩管理系统:从计费规则到订单对账实战
2026/10/9 6:25:56 网站建设 项目流程

简介:充电汽车管理系统是一套基于Java与MySQL的电动车充电管理平台解决方案,面向需要开发或学习充电站调度、用户计费、车辆管理等业务的Java开发人员,覆盖充电预约、监控、计费与报表统计等典型场景。资源压缩包仅316KB,共32个文件,以html、css、js前端页面与样式脚本为主,搭配iconfont图标字体和少量图片,具备完整的登录、注册、个人中心、车辆管理、充电位管理等Web页面,便于快速部署和二次开发。目前已有602人学习浏览。包内包含用户端与管理端两类操作界面,如用户自检、车辆自检、充电位查询、密码修改等流程,配合MySQL设计可落地车辆信息、充电记录、预约与结算等核心数据关系。整体结构清晰、页面完整,适合用于毕业设计、课程实训或初级Spring Boot项目参考,可在现有前端基础上结合Spring Data JPA或MyBatis快速完成后端接口与数据库交互,并进一步扩展计费、地图服务等模块。

1. 充电汽车管理系统到底在管什么:三个模块的分工与一条反直觉结论

一个充电汽车管理系统,名字里带着“电池充电”,很多人第一反应是先去做底层协议、先写电流电压采集。但真正做过运营后台的人会告诉你反直觉的事实:这类系统的核心难点不在设备通信,而在“计费规则”和“异常订单处理”。你写的每一行业务代码,最终都要回答一个问题——用户充完电,扣多少钱;扣错了,怎么退。围绕充电汽车管理系统做 Java 落地方案,我一般会拆成三个模块:充电设备管理(设备台账与在线状态)、充电业务流程(启动/结束充电、计费结算)、用户与订单管理(账户余额、消费流水)。适合的人群很明确:备战国赛的 Java 课程设计、需要给学校或园区做小规模充电站后台的开发者,以及想从 CRUD 走向“业务闭环”自学的 java 工程师。

这套系统的价值在于,它比普通的商城系统多了一个“硬件侧”的变量:设备会离线、数据会迟到、充电中途可能被拔枪。正因为这样,它非常适合当作 Java 后端项目练手——你能覆盖线程池、状态机、幂等、金额计算精度这些真正值钱的落地点,而不是停留在写一套增删改查。全文按一个可复现的小型单体项目展开,技术栈锁定 Java + Spring Boot + MyBatis-Plus + MySQL,不去碰微服务和云原生那种重型方案。

2. 先定技术栈:为什么用单体 Spring Boot 方案,而不是微服务或嵌入式直连

2.1 充电站管理场景的技术选型:从业务量反推架构

做这类系统的第一件事不是写代码,是决定边界:你到底做不做“充电桩硬件本身”,也就是控制功率、读取电池充电电压电流?如果是,那你是做充电桩嵌入式固件,不是做管理系统。管理系统要管的是“充电桩设备”的元数据——设备编号、所在站点、固件版本、是否在线、累计充电量、状态是否为可用。真正的电池充电数据(SOC、单体电压、温度)是充电桩上报上来的,系统负责接收、存储、展示和异常报警,而不是直接去控制。

这决定了技术形状:一个 Java 后端进程,通过 HTTP 接口或 TCP 协议接收充电桩上报的数据,写入数据库,同时给小程序/后台管理界面提供查询和远程控制接口。按照这种业务量级,单体应用(一个 Spring Boot 工程打包成 jar 运行)完全够用。常见做法是拆成三个数据域:设备域(充电桩、站点)、交易域(充电订单、计费)、用户域(车主账户、钱包流水)。这样即使以后订单量涨了,也可以先按这三个域做读写分离,而不是一上来就上 Spring Cloud 那套——那只会让一个 3 人项目连本地启动都困难。

还有一个选型细节值得提:ORM 选 MyBatis-Plus 而不是 MyBatis 原生。原因是充电管理系统的查询天然是“多条件动态拼接”——运营后台筛选订单时,时间范围、站点、充电桩编号、充电方式、订单状态可能全部为空或部分为空。MyBatis-Plus 的 LambdaQueryWrapper 能把这种动态查询写得很干净,比 Xml 里拼接 where 1=1 舒服得多。如果你看过 spring boot + mybatis 的 java 开源项目那种风格,应该知道我在说什么。

2.2 硬件通信协议选型:HTTP 回调与 TCP 长连接两种方式的取舍

这是选择系统架构时最容易被新手卡住的部分:充电桩怎么把数据交给你的 Java 后端?

第一种是 HTTP 回调。充电桩在启动充电、结束充电、实时上报电压电流的时候,分别向你后端接口发起请求。这种方式实现成本低,用 Spring Boot 的 Controller 就能接收并入库,适合课程设计和中小规模站点。坑在于:HTTP 是短连接,充电桩如果网络抖动,上报就断了,你需要靠“最后上线时间”字段去判断设备是否离线。

第二种是 TCP 长连接,充电桩主动连上你的 Socket 服务,维持心跳并持续上送数据。这更接近真实的充电桩通信(很多国产桩走的就是这类私有协议),但复杂度上了一个台阶:需要自己维护连接池、处理粘包拆包、考虑断线重连和心跳超时。课程设计里,如果做出一个简单的 TCP 服务端,能接收桩上送上来的电压和 SOC,展示到页面上,就已经是亮点。如果只是想先把业务闭环跑通,我建议第一版用 HTTP,第二版再升级 TCP 长连接。也不要觉得这太简单——真实项目里,很多聚合充电平台(比如一些省份的“充电一张网”)给中小充电站提供的接入方案,就是 HTTP 回调云端。黑匣子不要碰,方案要选自己能让页面滚动起来的那种。

2.3 项目骨架落地:Spring Initializr 初始化与三层分包逻辑

初始化项目不需要命令行,直接用 IDEA 的 Spring Initializr 生成即可。关键不是生成动作,而是第三方依赖的选择。依赖别贪多,只需要下面这些最基础的:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>

用 MyBatis-Plus 3.5.5 是因为它对 Java 8+ 的支持很稳定,而且自带分页插件,不需要额外引入 PageHelper。MySQL 驱动用 mysql-connector-j,这是新版 MySQL Connector/J 的 artifactId,老资料里写的是 mysql-connector-java,已经改版了,如果你复现老项目报找不到驱动类,多半是这个原因。Spring Boot 版本用 2.7.x 系列,不要用 3.x——3.x 要求 Java 17,而很多课程设计的运行环境还是 JDK 1.8,连 java 环境配置都折腾半天,别给自己挖坑。

目录分包按“基础设施 → 领域服务 → 接口层”三层走:

com.charge.station ├── config // MyBatis-Plus 分页插件、全局异常处理 ├── controller // 充电桩回调、用户端接口、管理端接口 ├── service // 订单、设备、计费、钱包四个核心业务 ├── mapper // 数据访问层 ├── entity // 设备、订单、计费规则、钱包流水等实体 └── common // 统一返回封装、金额工具类、状态枚举

这样的分包价值是:controller 只是薄薄一层,把 HTTP 请求参数转换成业务调用;计费、状态流转这类逻辑沉到 service 层。以后如果要加 OCPP 协议接入,只需要新增一个协议解析模块,改动模型统一进入 service,不需要重构页面层。这是仿着成熟单体项目的常规动作,不是花架子。

3. 数据库设计:五张核心表与充电计费的关键数据关系

3.1 建表 SQL:设备表、充电订单表、计费规则表怎么关联

数据库是这类系统最容易拉胯的地方——很多课程设计建的表,居然没有“钱包流水表”,退费只能硬删记录,这是大忌。我给你的是一套能跑通“充值 → 充电 → 扣费 → 异常退费”的最小表结构,共五张表:充电桩设备表(charge_pile)、充电订单表(charging_order)、计费规则表(billing_rule)、用户钱包表(user_wallet)、钱包流水表(wallet_transaction)。

CREATE TABLE charge_pile ( id BIGINT PRIMARY KEY AUTO_INCREMENT, pile_code VARCHAR(32) NOT NULL UNIQUE COMMENT '设备编号', station_id BIGINT NOT NULL COMMENT '所属站点ID', status TINYINT NOT NULL DEFAULT 1 COMMENT '1空闲 2充电中 3离线 4故障', firmware_version VARCHAR(32), total_charge_kwh DECIMAL(10,2) DEFAULT 0 COMMENT '累计充电量', last_online_time DATETIME COMMENT '最后心跳时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE charging_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号', user_id BIGINT NOT NULL, pile_code VARCHAR(32) NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME, start_soc TINYINT COMMENT '起始电量百分比', end_soc TINYINT, charge_kwh DECIMAL(10,2) COMMENT '本次充电量(度)', charge_amount DECIMAL(10,4) COMMENT '电费', service_amount DECIMAL(10,4) COMMENT '服务费', total_amount DECIMAL(10,4) COMMENT '实扣总额', status TINYINT NOT NULL COMMENT '1充电中 2已完成 3已结束待支付 4已取消 5异常退款', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE billing_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, station_id BIGINT, rule_type TINYINT COMMENT '1按时间 2按电量 3阶梯电价', price_per_kwh DECIMAL(10,4) COMMENT '电费单价(元/度)', service_fee_per_kwh DECIMAL(10,4) COMMENT '服务费单价', peek_start TIME COMMENT '峰时开始', peek_end TIME, peek_price DECIMAL(10,4), valley_price DECIMAL(10,4), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_station_type (station_id, rule_type) );

中间省略了 user_wallet 和 wallet_transaction 两张表,它们的结构比较直白:user_wallet 有 user_id、balance 字段;wallet_transaction 有 user_id、order_no、change_amount(正负值)、biz_type(充值/消费/退款)字段。有一个细节我必须强调:金额字段统一用 DECIMAL,禁止用 double。计费引擎每计算一次金额,double 的浮点误差就累积一次,退款时你会发现少了 0.01 元,到时候用户投诉,你连把计算逻辑改成 BigDecimal 的后悔药都没有。

另外还要注意 billing_rule 里的阶梯电价设计。真实的充电计费不是“一度电多少钱”这么简单——它会区分峰、谷、平时段,服务费还可以针对不同站点单独配置。把计费规则独立成表,而不是硬编码在 Java 里,是为了给运营留出“调整价格不重新发版”的空间。课程设计如果一开始就把价格写死在常量里,商业复杂度完全体现不出来,答辩时老师一问“价格怎么调整”,你就只能用“改代码重启”来回答,太尴尬。

3.2 从 MySQL 自动生成建表语句:MyBatis-Plus 的实体类同步技巧

很多时候表结构是先由 Java 实体类驱动出来的,而不是反过来手写 SQL。如果之前做过 mybatisplus根据java实体类生成创建表的sql语句的检索,你会发现 3.5.3+ 版本新增了 DDL 生成功能,能直接从实体类生成建表 SQL:

// 实体类示例(关键部分) @Data @TableName("charge_pile") public class ChargePile { @TableId(type = IdType.AUTO) private Long id; @TableField("pile_code") private String pileCode; @TableField("station_id") private Long stationId; @TableField("status") private Integer status; @TableField("total_charge_kwh") private BigDecimal totalChargeKwh; }

用 MyBatis-Plus 自带的代码生成器(MybatisPlusGenerator)可以把五张表反向生成 Mapper、Service、Controller,但这只是起点。重点在于 @TableField 注解的命名映射:Java 的驼峰命名(pileCode)和数据库的下划线命名(pile_code)必须保持一致,否则查询结果全是 null。Spring Boot 默认map-underscore-to-camel-case是开着的,但如果你 copy 了一份表结构过来,库里的列名却是大写或者带前缀,这一套映射就不生效——排查这类问题,优先看控制台打出的 SQL 和列名,而不是怀疑 ORM 框架坏了。

再说一句 DDL 生成的实用经验:可以把实体类建好后先用 MyBatis-Plus 的 DDL 导出 SQL 文件,人工检查一下字段类型是否合理,再执行到库里。尤其注意 TINYINT 在 Java 实体里要映射为 Integer,不要映射 Boolean,因为充电桩状态、订单状态都是多值状态(比如 2 代表充电中、3 代表离线),不是简单的 true/false,Boolean 直接焊死你的业务扩展空间。

3.3 状态字段的枚举化:char 与 tinyint 的取舍

设计充电订单状态的时候,很多新手喜欢用 VARCHAR 存字符串状态,比如 'charging'、'finished'。这个方案在开发前期很舒服,因为可读性好,但到后面做统计报表时会想哭:状态多一层字符串匹配,索引失效概率变大,而且 Java 侧还需要写一堆 if else 判断字符串内容。我推荐的做法是 TINYINT + Java 枚举双向映射。

public enum OrderStatus { CHARGING(1, "充电中"), FINISHED(2, "已完成"), WAIT_PAY(3, "已结束待支付"), CANCELED(4, "已取消"), REFUNDED(5, "已退款"); private final int code; private final String desc; }

状态这个字段是系统的“骨骼”。订单状态机的流转要有方向约束:充电中只能到已完成或已取消,已完成才能进入待支付,待支付才能退款。在 Service 层写一个状态校验方法,比如checkStatusTransition(oldStatus, newStatus),每次更新订单状态前先做校验,而不是直接update set status = ?。这个习惯能挡住很多“订单被异常置为已退款”的脏数据事故。

4. 充电计费核心业务:从启动充电到退款的完整代码闭环

4.1 启动充电:金额冻结与设备占用的一致性

充电流程的第一步不是扣钱,而是“冻结”。用户扫码、选择抢口、启动充电——此时系统并不知道最终要花多少钱,所以只能预估冻结,通常的做法是按一个初始电量差值(比如充满需要多少度)估算一个金额,在用户钱包里冻结;桩上开始充电后,真实电量逐步上报,结束时再根据实际电量结算。如果你不冻结,那用户边充边跑,充完只有 1 块钱余额,这个亏就得运营商自己扛。

@Transactional(rollbackFor = Exception.class) public StartChargeResult startCharge(StartChargeRequest req) { // 1. 校验设备状态 ChargePile pile = chargePileMapper.selectByPileCode(req.getPileCode()); if (pile.getStatus() != PileStatus.IDLE.getCode()) { throw new BusinessException("该充电桩当前不可用"); } // 2. 校验余额并冻结 UserWallet wallet = userWalletMapper.selectByUserIdForUpdate(req.getUserId()); BigDecimal estimatedAmount = calculateEstimatedAmount(pile.getStationId(), 30); if (wallet.getBalance().compareTo(estimatedAmount) < 0) { throw new BusinessException("余额不足,请先充值"); } // 3. 创建充电订单(状态=充电中) ChargingOrder order = new ChargingOrder(); order.setOrderNo(OrderNoGenerator.generate("CHG")); order.setUserId(req.getUserId()); order.setPileCode(req.getPileCode()); order.setStartTime(LocalDateTime.now()); order.setStatus(OrderStatus.CHARGING.getCode()); chargingOrderMapper.insert(order); // 4. 冻结金额:扣减可用余额,冻结字段单独记录 wallet.setFrozenAmount(wallet.getFrozenAmount().add(estimatedAmount)); wallet.setBalance(wallet.getBalance().subtract(estimatedAmount)); userWalletMapper.updateById(wallet); return new StartChargeResult(order.getOrderNo(), estimatedAmount); }

这段代码里有两个关键动作。第一个是selectByUserIdForUpdate,它使用了行级锁——同一时刻同一个用户只能有一个充电启动请求在处理,防止两个人同时操作一个钱包导致超扣。第二个是“冻结金额和可用余额分离”的设计:wallet 表里有一个 frozen_amount 字段,充电期间这笔钱既不能用来启动第二次充电,也不能提现。真实系统里,我见过有人直接扣 balance,结束充电时再把这个订单的金额加回来,这样做业务上也能通,但报表上“可用余额”和“冻结总额”对不上账,财务一核对就露馅。

还有一个容易被忽略的点:启动充电时,“检车桩状态”和“更新桩状态为充电中”这两步必须放在同一个事务并加锁。否则两个用户同时看到设备空闲、同时创建订单、同时去操作同一个充电桩,超卖问题就产生了。用乐观锁也行,比如 charge_pile 表加一个 version 字段,更新时带上where version = ?,update 影响行数为 0 就抛出异常。课程设计做到这一步,已经脱离 CRUD 层面了。

4.2 结束充电与实时计费:BigDecimal 结算的公式与边界

充电结束有两种触发方式:用户在小程序点“结束充电”,或者充电桩上报“已充满/已拔枪”。推荐的做法是,以充电桩上报的真实结束信号为准,用户点结束按钮只做一个“请求标记”,最终结算以设备回调为准。这个规则能在“用户点了结束但桩还在充”的歧义场景下,保住你不会扯皮——毕竟电是实打实充进电池里的。

@Transactional(rollbackFor = Exception.class) public ChargeResult finishCharge(String orderNo, BigDecimal finalKwh, Integer endSoc) { ChargingOrder order = chargingOrderMapper.selectByOrderNoForUpdate(orderNo); if (order.getStatus() != OrderStatus.CHARGING.getCode()) { throw new BusinessException("订单不在充电中状态"); } // 实际计费:电量 * 电价 + 电量 * 服务费 BillingRule rule = billingRuleMapper.selectByStationIdAndNow(order.getStationId()); BigDecimal electricFee = finalKwh.multiply(rule.getPricePerKwh()); BigDecimal serviceFee = finalKwh.multiply(rule.getServiceFeePerKwh()); BigDecimal total = electricFee.add(serviceFee).setScale(2, RoundingMode.HALF_UP); // 解冻并扣款:减少冻结金额,减去实际金额 UserWallet wallet = userWalletMapper.selectByUserIdForUpdate(order.getUserId()); wallet.setFrozenAmount(wallet.getFrozenAmount().subtract(order.getEstimatedAmount())); wallet.setBalance(wallet.getBalance().subtract(total)); // 如果冻结金额大于实际消费,差额要退回可用余额 BigDecimal refundDiff = order.getEstimatedAmount().subtract(total); if (refundDiff.compareTo(BigDecimal.ZERO) > 0) { wallet.setBalance(wallet.getBalance().add(refundDiff)); } userWalletMapper.updateById(wallet); order.setEndTime(LocalDateTime.now()); order.setEndSoc(endSoc); order.setChargeKwh(finalKwh); order.setChargeAmount(electricFee); order.setServiceAmount(serviceFee); order.setTotalAmount(total); order.setStatus(OrderStatus.WAIT_PAY.getCode()); chargingOrderMapper.updateById(order); return new ChargeResult(orderNo, total); }

这段代码建议反复读三遍。它一次完成了三件事:金额结算、解冻退款、状态流转。算法核心是“多退少补”——启动时冻结了 30 元,结束时实际只产生 18.5 元,那 11.5 元差额必须原路退回可用余额。如果这段逻辑写漏了,用户的反馈永远是“我充了两次电,余额怎么少了 20 块钱?”,你查流水单子却怎么都对不上。

关于 BigDecimal 的用法是 java 项目里避不开的细节。乘法不要用 double 参与,new BigDecimal("18.5"),字符串构造,绝对不要new BigDecimal(18.5),后者会用二进制浮点数直接构造,产生一堆 18.4999999 的垃圾尾数。最后统一setScale(2, RoundingMode.HALF_UP),四舍五入到分。如果你在这个环节看到金额差了 0.01,不要怀疑是“系统稳定性的玄学”,回头查一下有没有哪个步骤用了 double。

4.3 阶梯电价与峰谷计费:把规则配置化而不是写在 if 里

真实的充电站计费比“单价 x 电量”复杂得多。最常见的场景是分时电价:早上 8 点到 10 点是峰时,每度电 1.2 元;凌晨 0 点到 6 点是谷时,每度电 0.4 元。充电订单可能跨在两个时段里,比如凌晨 5:50 开始充,充满用了 40 分钟,那这 40 分钟里前 10 分钟按谷价,后 30 分钟按平价。这就是阶梯分段计费要解决的问题。

public BillingResult calculateAmount(BillingRule rule, LocalDateTime start, LocalDateTime end, BigDecimal kwh) { // 简化样例:假设按小时切片,累计各时段电量 BigDecimal totalAmount = BigDecimal.ZERO; LocalDateTime cursor = start; while (cursor.isBefore(end)) { LocalDateTime sliceEnd = cursor.plusHours(1).isAfter(end) ? end : cursor.plusHours(1); int hour = cursor.getHour(); BigDecimal price = getPriceByHour(rule, hour); // 实际项目中这里应按功率曲线估算电量,简单实现按时间占比折算 BigDecimal sliceKwh = kwh.multiply(BigDecimal.valueOf( Duration.between(cursor, sliceEnd).toMinutes())) .divide(BigDecimal.valueOf(Duration.between(start, end).toMinutes()), 4, RoundingMode.HALF_UP); totalAmount = totalAmount.add(sliceKwh.multiply(price)); cursor = sliceEnd; } return new BillingResult(totalAmount.setScale(2, RoundingMode.HALF_UP)); }

上面的代码是“时间占比近似法”——假设充电功率恒定,用电量按各时段分钟数占总充电时长的比例摊分。真实系统会更复杂,因为恒功率充电并不成立,SOC 低的时候充电快,接近满电时功率会掉下来。所以工业级的计费引擎是按照功率曲线的积分去算的,充电桩每上报一条实时功率数据,计费模块就做一次累计。课程设计和园区小站用时间占比近似,口径上说得过去,但是如果设备支持上送实时电量,那你就应该用“电量增量 x 当时电价”的方式,每 30 秒累加一次,更准。

阶梯电价的配置化是必须的。计费规则表里已经把峰、谷、平时段和对应的价格存下来了,Java 代码只在计费时读取这张表,而不是if (hour >= 8 && hour <= 10) { price = 1.2; }。否则运营要改价格的时候,你半夜爬起来改代码重新发版,这就是“不成熟的架构给运维带来的血泪”。

5. 充电桩接入与数据上报的避坑指南:设备离线、重复回调、粘包

5.1 不知道设备在不在线:心跳超时判定离不开定时任务机制

做充电管理系统最常遇到的一个问题:明明充电桩的灯亮着,管理后台却显示离线。原因多半是你的系统用回调更新状态,但桩没有上报新数据,你就认为它挂了。解决的标准姿势是“心跳超时机制”。充电桩一般每 15~30 秒上报一次心跳数据(包含设备编号、时间戳等),服务端收到后记录last_online_time。后台判断在线状态不能实时去查桩,只能看“当前时间 - 最后心跳时间 > 阈值”,超时就判定离线。

@Component public class PileOfflineDetectTask { @Scheduled(fixedRate = 60_000) public void detect() { // 找出 last_online_time 超过 90 秒没有更新的充电桩 List<ChargePile> offlinePiles = chargePileMapper.selectOfflinePiles( LocalDateTime.now().minusSeconds(90)); for (ChargePile pile : offlinePiles) { // 仅当状态还是“空闲/充电中”时才更新为离线,避免覆盖故障状态 if (pile.getStatus() == PileStatus.IDLE.getCode() || pile.getStatus() == PileStatus.CHARGING.getCode()) { ChargePile update = new ChargePile(); update.setId(pile.getId()); update.setStatus(PileStatus.OFFLINE.getCode()); chargePileMapper.updateById(update); } } } }

这段代码里有一个值得注意的细节:fixedRate = 60_000指每次任务启动后每 60 秒调度一次,生效前提是启动类标注了@EnableScheduling。同时你还需要在心跳上报接口里做“幂等更新”:即使充电桩每 15 秒上报一次,也不要在 charge_pile 表里产生新的记录,只更新 last_online_time。

最常见翻车点:充电桩与服务器时间不一致。桩上报的时间戳是设备本地时间,如果你的业务服务器判断超时用的是自己的系统时间,而桩端时间慢了 5 分钟,那每台桩在你眼里永远处于“离线”状态。解决办法是:last_online_time以服务端收到上报的当前时间为准,不要采信设备时间,只把设备时间存到另一个字段里去展示。宁可让桩的计时在页面上看起来偶尔不准,也不要让离线判定规则被设备时间带乱。

5.2 重复回调导致重复扣费:幂等不能只依赖数据库唯一索引

真实场景里,充电桩后端给你推送“结束充电”事件时,可能因为网络超时自动重试——同一个回调接口连打三次,如果代码不防重,用户就被扣了三次钱。这不是环境问题,这是业务上的重大事故。

public ChargeResult finishCharge(String orderNo, BigDecimal finalKwh, Integer endSoc) { // 先查状态,状态已不是“充电中”则直接返回原结果 ChargingOrder order = chargingOrderMapper.selectByOrderNo(orderNo); if (order.getStatus() != OrderStatus.CHARGING.getCode()) { return new ChargeResult(orderNo, order.getTotalAmount() == null ? BigDecimal.ZERO : order.getTotalAmount()); } // 业务处理... }

第一道防线的逻辑:先按订单号查状态,如果已经是“已完成”或“待支付”,说明之前处理过了,直接返回当前已结算的金额,不再走扣款逻辑。这里的关键是“查询状态”和“更新状态”必须处于同一事务并且加行锁,否则并发场景下两个线程同时读到“充电中”状态,同时扣款,就防不住了。

@Transactional public ChargeResult finishCharge(String orderNo, BigDecimal finalKwh, Integer endSoc) { // 使用 select for update,锁定订单行,防止重复回调并发处理 ChargingOrder order = chargingOrderMapper.selectByOrderNoForUpdate(orderNo); if (order.getStatus() != OrderStatus.CHARGING.getCode()) { return new ChargeResult(orderNo, order.getTotalAmount()); } // 扣款逻辑... }

用selectByOrderNoForUpdate是 MySQL 侧加锁,FOR UPDATE会让其他事务处理同一个订单号时阻塞等待,等到第一个事务提交后,它们再进来时拿到的是已更新的状态,自然走到“已不是充电中”的分支返回。这比单纯依赖“状态先查询再判断”更稳,是标准的并发幂等方案。补充说一句,数据库唯一索引也是兜底手段之一,但只能兜住插入场景(比如重复插入订单记录),防不住“先查再改”的读改写场景。真正能挡并发穿透的,要么是分布式锁,要么是数据库锁,不想引入 Redis 的话,MySQLFOR UPDATE在单机单体下是最省事的。还有一个进阶写法是在 order 表加version字段,update charging_order set status = 2, version = version + 1 where order_no = ? and version = ?,影响行数为 0 就冲突,自动放弃处理——这种乐观锁方案在低并发下也很好用。

5.3 充电桩上送的数据粘包与幂等入库:TCP 接入的三个坑

如果你升级到 TCP 长连接接收充电桩主动上报,就会遇到另一个经典的网络问题:粘包/半包。桩端连续发送 “心跳 + 实时功率 + SOC”,TCP 层可能把三段数据一次性交给你的服务端读取函数,或者一段数据分两次到达。如果你按“一次读取等于一条数据”写,解析一定出错。

常见做法是在自定义协议里约定“长度域”。比如每条消息 8 字节消息头:前 2 字节是魔数0x5A 0x01,中间 2 字节是消息类型,后 4 字节是 body 长度。接收时先读 8 字节头,再按长度字段读取 body,同时设置解码器的单条最大长度,防止空间溢出。在 Netty 里可以直接用LengthFieldBasedFrameDecoder处理:

new LengthFieldBasedFrameDecoder( 1024, // 单条消息最大长度 4, // 长度字段偏移:从第5个字节开始是body长度 4, // 长度字段本身占4字节 0, // 长度字段和body之间的长度 0) // 剥离长度字段

这个解码器会主动帮你解决粘包问题:它先读长度字段,然后从缓冲区里切出对应长度的数据,剩下的数据留给下一次解码。剩下两个坑也很隐蔽。第一个是没有处理半包,Netty 默认ByteToMessageDecoder在没凑够长度时会缓存等待,不需要你手动拼接,但如果你用传统的InputStream.read裸写,就必须自己实现“读长度 → 读满 body”两个阶段,很多自研小项目就是在这里崩的。第二个是设备断线后 socket 没有即时关闭,老连接占着线程不释放,连接数一多内存就爆——一定要设置心跳机制,超时没收到数据就主动关闭连接释放资源。

5.4 边充边付还是结束后付:两种商业模式的技术影响

有些小程序上的充电系统是“边充边扣”,每过几分钟就冻结一次钱包余额,剩余金额不足时停止充电。这种模式对计费引擎的要求更高,因为它需要高频结算,而且要求金额冻结/扣减操作尽量轻。课程设计如果想做完整闭环,建议用“结束后付 + 预冻结”的模式,步骤更清晰,坑更少。

结束后付有一个隐藏问题:用户充电完成后,订单是“待支付”状态,但从“待支付”到“确认扣款”之间有一段窗口期。如果用户余额不够支付实际电费怎么办?方案是启动时余额必须大于预估费,结束后如果实测费用高于冻结金额,系统要能自动从可用余额里补扣,或者显示“充电待支付”并限制该用户再次启动充电。

// 结束充电时的补扣逻辑 BigDecimal shortfall = total.subtract(order.getEstimatedAmount()); if (shortfall.compareTo(BigDecimal.ZERO) > 0) { wallet.setFrozenAmount(wallet.getFrozenAmount().subtract(order.getEstimatedAmount())); wallet.setBalance(wallet.getBalance().subtract(shortfall)); if (wallet.getBalance().compareTo(BigDecimal.ZERO) < 0) { throw new BusinessException("余额不足以支付本次充电费用,请充值后完成支付"); } }

这段代码把“冻结转实扣”和“补差额”放在同一个事务里,并检查余额是否变为负数。注意余额不足时绝不能允许扣成负数——钱包表里通常有余额大于 0 的约束,但如果你用 update 语句直接减,负余额就悄悄发生了。对这种“业务上不允许、数据库能存出来”的数据,在 service 层加校验,永远比在数据库上挂着各种各样的约束更灵活。

6. 联调与验证:从桩上报到状态流转到账单对账的三种实战验证法

在做完这些模块之后,项目并不算完,真正挠头的是联调。很多 java 新手把系统写完,然后拿着真实充电桩设备来对接,结果一连线就歇菜——因为不知道是桩的问题、网络的问题还是自己后端的问题。我总结三种最实用的验证方法,都是血泪经验换来的。

第一种是“模拟桩上报脚本”。自己写一个简单的 Java 或 Python 脚本,模拟充电桩的行为:启动充电请求带上设备号、功率电量,过 10 秒上报一次充电量,再隔 5 分钟上报“结束充电”回调。这条链路能验证你后端的接口是否正常、事务处理是否正确、状态机流转是否符合预期。不要一上来就接真桩,成本太高,排查太慢。脚本化模拟桩是最快的黑匣子探测手段。

# 模拟心跳上报(Linux 下用 curl 循环模拟) while true; do curl -X POST http://localhost:8080/api/pile/heartbeat \ -H "Content-Type: application/json" \ -d '{"pileCode":"P001","voltage":220.5,"current":32.0,"soc":78,"timestamp":"2026-01-01 12:00:00"}' sleep 15 done

这段命令胜在简单,可以直接验证心跳上报接口是否每次都会更新last_online_time,并且验证离线判定任务能否正确把不再上报数据的桩标记为离线。第二个验证点是“并发重复回调”。用两个终端同时执行结束充电请求,看是否只有一个请求成功扣款,另一个被幂等挡住。这个验证直接暴露并发漏洞。

第三种是“订单流水对账”。模拟用户充值 100 元,执行三次完整的启动-结束充电流程,余额应该在 100 减去三次实际扣费之和。支付宝这里有一个很好的习惯——每笔交易都有流水号,你的 wallet_transaction 表应该记下每次充值和消费的记录。写一个小的对账 SQL:

SELECT u.id, u.balance, (SELECT IFNULL(SUM(change_amount), 0) FROM wallet_transaction WHERE user_id = u.id) AS ledger_balance FROM user_wallet u WHERE u.balance <> ( SELECT IFNULL(SUM(change_amount), 0) FROM wallet_transaction WHERE user_id = u.id );

这条 SQL 执行后如果返回任何一行,说明 wallet 的余额和流水账对不上,你的扣款逻辑一定有分支漏写。重要的是在本地开发阶段就可以把“记账流水”这个模块沉淀下来,不要等到上生产才发现账对不上,到那时你看到的不是一行错误日志,而是一堆用户投诉。项目上线的熟练程度,本质上就是对这种边界情况的预判力和系统化防错习惯的总和。这个系统我做了不止一遍,最大的感受是:管理系统的难点不在接口多,而在数据一致性链路长,每一步都要能自查、能对账、能回滚。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询