
简介充电桩综合管理系统设计与实现文档是一份面向Java技术栈学习者和高校毕业设计场景的完整项目方案针对充电站运营管理中用户、电站、充电桩、费用及工单等信息化管理需求给出了系统化的解决思路。文档基于B/S架构采用Java和MySQL数据库实现完整覆盖市场调研、需求分析、概要设计、详细设计、编码和测试等软件开发全流程并绘制了功能模块图、流程图和E-R图便于理解系统结构。内容上系统功能模块涵盖用户管理、电站信息管理、充电桩管理、运营商管理、预约充电、告警信息、充电费用、维修工单和留言板管理等整体设计贴近实际运营场景。资源为单个docx文件大小6.94MB包含中英文摘要、目录、正文及测试结果可直接作为课程设计或毕业设计的参考模板。已有32人学习适合需要快速掌握充电桩管理系统开发流程和文档写作规范的同学借鉴。1. 充电桩综合管理系统到底管的什么一次扫码启动失败的现场一台测试桩扫码后一直卡在“启动中”后端日志里却看不到任何设备报文。我查了一整晚最后发现是设备网关在桩心跳超时后把状态误标成了“在线”订单只能停在待启动。这正好说明基于 Java 的充电桩综合管理系统设计与实现不只是做一套增删改查而是把设备接入、充电订单、实时计费和异常告警串成一个闭环。它要解决的核心问题是在桩、用户、钱三者之间保证每一次充电都能启动、可计量、算对账、留凭证。适合正在做充电桩平台或想入行能源物联网的 Java 后端开发者看里面涉及的技术选型和排错思路同样适用于其他设备接入类项目。2. 先选通信协议再写Java代码OCPP、MQTT和私有TCP哪条路适合你的充电桩搞充电桩系统很多人第一反应是建表、搭 Spring Boot、写 Controller结果桩的报文一过来就傻眼有的是 TCP 裸包有的是 MQTT 主题数据格式各家还都不一样。协议定错后面整个解析层都要返工。我在多个项目里反复栽过几次跟头后总结了一条经验先定桩和后端怎么说话再动代码。这个顺序一旦颠倒后面每次对接新桩型都是一次伤筋动骨。2.1 三种主流协议的实际差异别只看“能连上”市面充电桩的通信协议归纳下来就三类OCPP、MQTT、厂商私有 TCP。很多人觉得“能连上收数据就行”但放在运营环境里尤其是涉及远程启停和计费差异会直接决定系统的复杂度。协议类型典型场景Java 接入难点适合谁OCPP 1.6J / 2.0大型充电运营平台、跨国互联互通报文类型多状态机复杂JSON 和 SOAP 混用要做标准化平台的人MQTT有边缘网关、或桩端已带 MQTT 模组需要设计 topic 和 QoS还要处理离线遗嘱私桩服务、轻量平台私有 TCP国内中小桩厂没有公开协议文档字段靠抓包猜绑定特定硬件厂商OCPP 的好处是标准化换桩不换后端但代价是协议本身就有一百多个操作只实现“启动充电、实时数据、停止充电”这三个核心动作也要啃透消息定义。MQTT 适合桩和网关天然支持的情况桩端只发数据后端不用管长连接心跳但远程控制的可靠性会依赖 QoS 和遗嘱消息处理不好容易丢指令。私有 TCP 最简单直接国内很多 OEM 桩出厂就用私有协议缺点是每种桩都是一个适配器一桩一版解析代码。我的选择经验是如果项目是给某小区或工厂做专用桩直接选 TCP 私有协议开发成本最低如果目标是做城市级运营平台优先 OCPP后续接入新厂商省心MQTT 则适合已经有边缘网关或设备端用结构光模块的场景后端只要把 topic 订阅好就能把设备管理能力收敛到网关侧。2.2 后端模块怎么拆设备网关、订单中心、计费引擎与告警服务协议定了后端结构如果揉成一坨后面才是灾难。很多人会把设备报文解析和业务逻辑写在一起结果 NIO 线程里做数据库事务一旦数据库慢整个桩群都连不上。正确做法是把系统拆成四个独立的模块各自管理一块领域。第一个是设备接入网关负责所有桩的长连接、心跳、报文解析、指令下发。它只做协议转换不做业务判断。第二个是订单中心负责充电订单的创建、状态流转、异常结束。第三个是计费引擎负责按价格模板计算电费价格模板必须支持峰谷平时段否则调一次价就要改一次代码。最后是告警服务监听设备离线、订单超时、功率过载等事件及时通知运维。模块之间怎么通信我一般用 Spring 的事件机制或者 RocketMQ。设备网关收到一条充电报文后直接向 Spring 容器发一个ChargeInfoEvent订单中心监听事件后更新对应订单。这样可以保证 Netty 的 worker 线程只做解包和转发真正的业务逻辑全部落在业务线程池里。Netty 侧不阻塞桩端才能保持稳定在线这是充电桩系统最容易漏掉的问题。2.3 用Spring Boot Netty搭一个最小设备接入骨架充电桩设备端大多数是长连接 TCPTomcat 那套 HTTP 短连接根本扛不住几千个桩的心跳频率Netty 是实际最常用的选择。下面是一个最小可跑的设备接入骨架我通常把它放在一个单独的模块里通过Component交给 Spring 托管。Component public class ChargingDeviceServer { private final EventLoopGroup bossGroup new NioEventLoopGroup(1); private final EventLoopGroup workerGroup new NioEventLoopGroup(Runtime.getRuntime().availableProcessors() * 2); PostConstruct public void start() throws InterruptedException { ServerBootstrap bootstrap new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new ChargingFrameDecoder()); ch.pipeline().addLast(new DeviceMessageHandler()); } }) .option(ChannelOption.SO_BACKLOG, 1024) .childOption(ChannelOption.TCP_NODELAY, true) .childOption(ChannelOption.SO_KEEPALIVE, true); bootstrap.bind(8088).sync(); } }这段代码里bossGroup用 1 个线程就够它只负责接收新连接workerGroup线程数根据 CPU 核数决定我习惯设成 CPU 核数的两倍避免线程太多切换开销反而大。SO_BACKLOG是等待队列长度桩集中上线时如果这个值太小会直接拒连。TCP_NODELAY必须开否则小报文要攒到一定大小才发心跳就会延迟。SO_KEEPALIVE让内核层帮忙探测死链接但应用层还得靠后面讲的心跳超时判断。ChargingFrameDecoder是拆包解码器DeviceMessageHandler处理已经解析好的 Java 对象。这两个类下一章展开。注意PostConstruct里调bind().sync()会阻塞 Spring 启动建议改成用Thread包一层或者让ChargingDeviceServer实现ApplicationRunner在 Spring 完全起来后再绑定端口。这样即使 Netty 初始化失败Spring 还能给出完整报错信息。3. 数据模型与订单状态机字段设计决定你要不要返工设备报文解析完后数据模型就是下一个决定性环节。充电桩系统最核心的数据不只是设备表而是订单和账户。很多项目上线后出现“用户付了钱没充上”“充完电没扣钱”“账单金额对不上”这类问题基本都是表和状态机设计时留了坑。这一章能把字段和状态设计想清楚后面至少少加十张改动表。3.1 设备表、充电订单表、账户表的几个关键字段我先给出一个经过两个项目验证的建表方案字段覆盖了启动、计量、结算三个阶段需要的所有数据。-- 充电设备表 CREATE TABLE charging_device ( id BIGINT PRIMARY KEY AUTO_INCREMENT, device_no VARCHAR(32) NOT NULL UNIQUE COMMENT 桩编号, vendor_code VARCHAR(16) COMMENT 桩厂商编码, status TINYINT NOT NULL DEFAULT 0 COMMENT 0离线 1在线 2故障, last_heartbeat_time DATETIME COMMENT 最近心跳时间, firmware_version VARCHAR(32) COMMENT 固件版本, rated_power INT COMMENT 额定功率单位W, created_time DATETIME NOT NULL, updated_time DATETIME NOT NULL ); -- 充电订单表 CREATE TABLE charge_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL UNIQUE COMMENT 订单号, device_no VARCHAR(32) NOT NULL COMMENT 桩编号, user_id BIGINT NOT NULL COMMENT 用户ID, status TINYINT NOT NULL COMMENT 0待启动 1充电中 2完成 3取消 4异常, start_time DATETIME COMMENT 充电开始时间, end_time DATETIME COMMENT 充电结束时间, meter_start INT COMMENT 启动时电表读数单位0.001kWh, meter_end INT COMMENT 结束时电表读数, power_consumed INT COMMENT 累计电量单位0.001kWh, fee_frozen BIGINT COMMENT 冻结金额单位分, fee_real BIGINT COMMENT 实扣金额单位分, price_detail VARCHAR(500) COMMENT 计价快照JSON, created_time DATETIME NOT NULL, updated_time DATETIME NOT NULL, KEY idx_device_start (device_no, start_time), KEY idx_user_status (user_id, status) ); -- 用户账户表 CREATE TABLE user_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL UNIQUE, balance BIGINT NOT NULL COMMENT 可用余额单位分, frozen_balance BIGINT NOT NULL COMMENT 已冻结余额单位分, updated_time DATETIME NOT NULL );这里有两个容易踩坑的点。第一金额和电量一律用整数存储单位分别是“分”和“0.001kWh”绝不用double或float。Java 里浮点运算会出现 0.10.2 不等于 0.3 的问题计费算错一分钱用户投诉就来了。第二订单表里要有price_detail字段把计价规则以 JSON 快照存下来比如“峰段:单价1.2元/度,共10度”。这样以后改价格模板历史订单仍能按当时规则展示和对账不会因为模板更新导致旧账单金额变化。表设计还缺一个关键索引建议idx_device_start用来联合查询“某台桩某段时间的订单流水”对账和运营报表都要用idx_user_status用来查“用户当前有没有未结束订单”防重复启动。别小看这两个索引充电平台每天订单量上万后没有索引的查询能把数据库CPU打满。3.2 订单状态机从待启动到已完成异常态要单独处理订单状态如果只做“启动、结束”两个状态那系统上线三个月内一定会出现“僵尸订单”用户充完钱桩没启动订单永远挂在中间。状态机必须把异常态显式建模才能让补偿逻辑有地方挂载。public enum OrderStatus { PENDING(0, 待启动), CHARGING(1, 充电中), COMPLETED(2, 已完成), CANCELED(3, 已取消), ABNORMAL(4, 异常); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } }状态流转我一般这样约束PENDING只能到CHARGING或CANCELEDCHARGING只能到COMPLETED或ABNORMALABNORMAL允许人工补偿到COMPLETED但必须记录补偿原因和操作人。任何状态都不能回跳尤其不能从CHARGING回到PENDING否则会出现重复冻结。异常态是很多人忽略的。设备在充电中突然断网服务端收不到结束报文订单就停在CHARGING。如果不引入ABNORMAL这个订单永远不会被定时任务捞到。我一般在状态流转的更新语句里加上WHERE status #{oldStatus}用数据库行锁保证并发下状态不能乱跳。例如int rows orderMapper.updateStatus(orderNo, OrderStatus.CHARGING.code, OrderStatus.COMPLETED.code); if (rows 0) { throw new IllegalStateException(订单状态已变更不能重复结束); }这条更新返回 0说明当前订单已经被别的请求结束直接拒绝重复操作。这一层是防止用户连续点击“停止充电”时重复结算的关键。3.3 并发启动与余额扣减用数据库乐观锁还是Redis分布式锁充电桩最常见的并发场景是用户同时扫多个桩、同一账户并发下单。余额扣减如果写成先查后更新两个请求都查到余额够然后各自更新就会把余额扣成负数。解决这类“Java 怎么保证数据一致性”的问题我首选数据库乐观锁。UPDATE user_account SET frozen_balance frozen_balance #{amount}, balance balance - #{amount} WHERE user_id #{userId} AND balance #{amount}这条 SQL 把“余额是否够”和“扣款”合并成一条原子操作受影响行数为 0 就说明余额不足直接抛业务异常。注意这里不能用balance - amount 0当条件因为那只是查询时的判断更新期间余额可能被别的请求改掉。把balance #{amount}放在 WHERE 里数据库行锁会保证同一时刻只有一个更新能成功。那 Redis 分布式锁什么时候用我一般只在“预占功率”这种跨设备资源场景用比如后面的功率池。单用户扣款用乐观锁就够没必要引入 Redis 增加复杂度。如果在集群环境下数据库乐观锁仍然有效因为它依赖的是数据库本身的并发控制。对于启动充电这种事务优先让事务短不要把 Netty 报文解析放在事务里。4. 跑通一次充电流程从桩上报到账单生成的最小实现前面协议和表结构定好后最核心的链路就是“设备上线 - 用户下单 - 桩启动 - 实时上报 - 用户结束 - 结算”。这一章我会把实现链路拆成三段每段给出一段能直接借鉴的代码重点说清每个方法里的边界条件。4.1 解析充电桩心跳与充电状态报文设备端上报的报文格式五花八门但最终都会被解码成一个统一的 Java 对象。我用的私有桩协议是2 字节魔数 2 字节长度 JSON 体。解码器先把长度读出来再读取完整的 JSON避免粘包半包导致的 JSON 解析异常。public class DeviceMessage { private String cmd; private String deviceNo; private long ts; private int power; private int meterValue; }这是解码完成后的消息对象字段含义很直白cmd是命令类型deviceNo是桩编号ts是桩端时间戳power是实时功率meterValue是电表累计读数。注意ts只在排报文中有效不参与计费。public class DeviceMessageHandler extends SimpleChannelInboundHandlerDeviceMessage { Override protected void channelRead0(ChannelHandlerContext ctx, DeviceMessage msg) { if (heartbeat.equals(msg.getCmd())) { deviceService.refreshHeartbeat(msg.getDeviceNo(), ctx.channel()); } else if (chargeInfo.equals(msg.getCmd())) { orderService.updateMeter(msg.getDeviceNo(), msg.getMeterValue()); } else if (stopReport.equals(msg.getCmd())) { orderService.finishOrderByDevice(msg.getDeviceNo(), msg.getMeterValue()); } } }这段代码的逻辑是每种cmd分成不同的服务方法。refreshHeartbeat会更新设备表里的last_heartbeat_time并把设备状态置为在线updateMeter更新订单累计电量finishOrderByDevice是设备主动上报已结束充电比如用户拔枪或桩端按钮停止。注意这里所有方法都返回void因为 Netty 的 worker 线程不能阻塞如果直接同步调数据库桩一多就会把 Netty 线程拖死。我一般让 Spring 的Async或事件监听异步处理这些动作。4.2 启动充电与实时计量累计电量怎么算启动充电是一个典型的事务操作要同时生成订单、冻结余额、下发启动指令。顺序错了容易出问题如果先下发指令再冻结余额余额不足时桩已经启动就会出现“免费充电”。Transactional public ChargeOrder startCharging(String deviceNo, Long userId, Long priceTemplateId) { ChargingDevice device deviceService.getByDeviceNo(deviceNo); if (device.getStatus() ! 1) { throw new DeviceOfflineException(设备不在线); } Integer estimate chargeEngine.estimateAmount(priceTemplateId, 30); accountService.freeze(userId, estimate); ChargeOrder order new ChargeOrder(); order.setOrderNo(generateOrderNo()); order.setDeviceNo(deviceNo); order.setUserId(userId); order.setStatus(OrderStatus.PENDING.getCode()); order.setFeeFrozen(estimate); order.setMeterStart(0); orderMapper.insert(order); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { deviceGateway.sendStartCommand(deviceNo, order.getOrderNo()); } }); return order; }启动时先查设备是否在线再冻结预估金额。预估金额怎么来我通常按该桩的历史平均充电时长或价格模板最低费用来估比如设最低 5 元冻结。订单一开始是PENDING直到收到桩回复“启动成功”后才改为CHARGING。如果桩一直没有回复定时任务会把超时PENDING订单标记为CANCELED并解冻余额。重点看registerSynchronization这里把下发指令放在事务提交后。因为 Netty 发送是网络 IO如果放在事务里数据库回滚时指令已经发出去了桩启动但订单不存在就会造成计费事故。这个afterCommit模式是充电桩系统里控制远程命令与本地事务一致性的常用做法。实时计量这段是充电中最频繁的调用。桩每 5 秒上报一次当前电表读数后端不会每次都写库而是把最新读数更新到 Redis 缓存只定期落库。public void updateMeter(String deviceNo, int meterValue) { ChargeOrder order orderCache.getChargingOrder(deviceNo); if (order null || order.getStatus() ! OrderStatus.CHARGING.getCode()) { return; } int powerConsumed meterValue - order.getMeterStart(); if (powerConsumed 0) { log.warn(设备上报电表值异常: deviceNo{}, meterStart{}, meterEnd{}, deviceNo, order.getMeterStart(), meterValue); return; } orderCache.updatePower(deviceNo, powerConsumed); }这里的边界条件是powerConsumed 0必须过滤。有些桩在启动瞬间会先上报一个初始读数下一个周期才上报增长后的值如果直接用负值更新会把累计电量改小。另外订单不在CHARGING状态时直接忽略防止已经结算的订单又收到迟到的报文把电量改乱。4.3 停止充电与费用结算别忘了把冻结金额转成实扣停止充电可以由用户 App 发起也可以由桩端主动上报。两端入口最终都调用同一个结算方法这样能保证逻辑一致。Transactional public void finishOrder(String orderNo, int meterEnd) { ChargeOrder order orderMapper.selectForUpdate(orderNo); if (order null) { throw new IllegalArgumentException(订单不存在); } if (order.getStatus() ! OrderStatus.CHARGING.getCode()) { throw new IllegalStateException(订单状态不允许结束); } int powerConsumed meterEnd - order.getMeterStart(); long fee chargeEngine.calculate( order.getPriceDetail(), order.getStartTime(), LocalDateTime.now(), powerConsumed); order.setMeterEnd(meterEnd); order.setPowerConsumed(powerConsumed); order.setFeeReal(fee); order.setStatus(OrderStatus.COMPLETED.getCode()); orderMapper.updateById(order); accountService.realDeduct(order.getUserId(), fee, order.getFeeFrozen()); }selectForUpdate是行级锁多条线程同时调这个接口时只有先到的那条能进入状态判断后面的会阻塞到这行锁释放然后发现状态不是CHARGING直接抛异常。这样用户的重复点击只在日志里留下一次正常结算。realDeduct里的逻辑是从frozen_balance里扣掉实际电费剩余的冻结金额退回可用余额。例如用户冻结 30 元实际电费 12 元那么冻结余额减 12可用余额加回 18。这里必须保证先改订单状态再动账户两个操作同在一个事务里任何一个失败都回滚否则会出现电费扣了但订单未完成或者订单完成了钱没扣到。5. 充电桩系统的避坑指南粘包、假离线、价格漂移和功率超卖充电桩系统比普通后台系统多了一个设备接入层很多问题只有在真机联调时才会暴露。这一章我把几个高频踩坑点按“现象 - 原因 - 解决”整理出来每一条都是从现场排错里总结出来的血泪经验照着核对能少熬几个夜。5.1 报文粘包与半包用协议头里的长度字段做拆包现象两台桩同时上报后端收到的是一段拼接的字节或者一条长报文被拆成了两个 TCP 包JSON 解析直接失败。原因TCP 是字节流没有消息边界。桩端先后发的两条报文可能被内核合并也可能被拆开这不是业务 bug而是传输层特性。解决我在协议里强制约定前 4 字节固定格式2 字节魔数0x5A5A2 字节报文长度。解码器继承ByteToMessageDecoder每次先积攒字节够了才拆包。public class ChargingFrameDecoder extends ByteToMessageDecoder { private static final short MAGIC (short) 0x5A5A; Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, ListObject out) { if (in.readableBytes() 4) { return; } in.markReaderIndex(); short magic in.readShort(); if (magic ! MAGIC) { ctx.close(); return; } int length in.readUnsignedShort(); if (length 0 || length 2048) { ctx.close(); return; } if (in.readableBytes() length) { in.resetReaderIndex(); return; } byte[] body new byte[length]; in.readBytes(body); out.add(JSON.parseObject(body, DeviceMessage.class)); } }核心方法是markReaderIndex()配合resetReaderIndex()。当报文体还没收全时readableBytes() length我们把读指针重置到标记的位置等待下一次 TCP 数据到达后继续累积。这样既不会丢数据也不会把下一条报文当作当前报文的一部分。魔数校验不通过直接关闭连接防止错误报文污染链路。长度字段必须做上限校验我设了 2048 字节超过直接掐断避免恶意构造超大帧把内存撑爆。5.2 设备断连后订单怎么补心跳超时与状态对账现象用户正在充电桩被施工队意外断电后端没收到任何结束报文订单一直显示“充电中”。用户回来发现扣费远高于实际充电量客诉直接升级。原因TCP 断开不一定会立刻通知服务端尤其桩是断电连接半开服务端要等到写操作失败或内核超时才知道。而充电订单没有超时兜底。解决两条线并行。第一Netty 的IdleStateHandler设 30 秒读超时超过没收到任何数据就触发channelIdle把这个设备标记为离线。第二定时任务扫一遍所有CHARGING状态的订单如果超过 30 分钟没有电量更新强制进入异常补偿流程。Scheduled(cron 0 */5 * * * ?) public void checkStaleChargingOrders() { ListChargeOrder staleOrders orderMapper.listChargingOlderThan(30); for (ChargeOrder order : staleOrders) { orderMapper.updateStatus(order.getOrderNo(), OrderStatus.CHARGING.getCode(), OrderStatus.ABNORMAL.getCode()); // 按最近一次已知电量结算差额走人工审核 int lastMeter chargeCache.getLastMeter(order.getDeviceNo()); accountService.realDeduct(order.getUserId(), 0, order.getFeeFrozen()); } }这里我故意把订单状态置为ABNORMAL而不是直接COMPLETED是因为断电瞬间的实际电量无法 100% 确认。先用最近一次上报的电表值做一次预结算把冻结金额全部解冻后续等人工到场核对后再补扣或退款。这种“先保障用户不被多扣再处理损失”的策略在运营侧口碑更好。5.3 充电桩时钟不准计费时间一律以后端为准现象某台桩上报的开始时间是 00:00但桩所在地凌晨时段明明是谷价 0.3 元/度账单却按平段 0.8 元算了。原因是桩端 RTC 电池没电重启后时间变成 2015 年。原因桩端时钟只要依赖硬件电池就一定会漂移。很多桩出厂时没有 NTP 校准模块时间误差可达数小时。如果你用桩上报的时间戳计算峰谷时段必然翻车。解决所有计费时间统一以后端系统时间为准。桩上报的ts只用来排序或者计算心跳间隔绝不用来计费。在结算方法里时间基准直接取LocalDateTime.now()并且接入一个统一时钟服务保证多个后端节点时间一致。另外启动充电的时间戳由后端在收到启动成功时生成而不是用桩回复报文里的时间。public long calculateFee(String priceDetailJson, LocalDateTime startTime, LocalDateTime endTime, int powerConsumed) { // 后端显式指定时区不随服务器默认时区漂移 ZoneId zone ZoneId.of(Asia/Shanghai); LocalDateTime now LocalDateTime.now(Clock.system(zone)); // 计算 always 用 now不读任何设备时间 }这段代码的核心是固定时区。生产服务器如果部署在 UTC 环境而业务是北京时间直接用LocalDateTime.now()会错 8 小时。把ZoneId.of(Asia/Shanghai)写进计费方法里就算服务器时区被误改计费也不受影响。这个坑我在实际项目中真踩过当时是运维把 Docker 容器时区设成了 UTC所有订单的峰谷判断全部偏移一小时。5.4 并发启动导致总功率超卖用功率池做预占现象小区变压器容量 100kW装了 20 台 7kW 交流桩。早上通勤高峰有 15 台桩同时启动总功率 105kW变压器瞬间过载跳闸整个车棚断电。原因启动充电时没有检查当前剩余可用功率。所有桩各自为政谁都能抢到启动权系统没有“容量预算”。解决在 Redis 里维护一个全局功率池键power:remain存储剩余功率。每次启动前执行一个 Lua 脚本功率足够才扣减并预占订单结束后再归还。-- 功率预占脚本 local remain tonumber(redis.call(get, KEYS[1]) or 0) if remain tonumber(ARGV[1]) then redis.call(set, KEYS[1], remain - ARGV[1]) redis.call(hset, power:occupy, ARGV[2], ARGV[1]) return 1 end return 0Java 侧调用时把桩编号和需要占用的功率传进去返回 1 才能继续创建充电订单返回 0 则直接提示“当前车棚负载已满请稍后再试”。订单结束时从power:occupy里拿到预占值加回power:remain。这个脚本是原子性的Redis 单线程执行保证多台桩同时启动时不会把功率扣成负数。选 Redis 而不是数据库是因为启动充电是高频操作数据库行锁在峰值时可能拖慢整个订单链路。如果项目规模小也可以用一张功率表加乐观锁但 Redis 方案在后续扩展多个场站时更自然每个场站一个 key比如power:remain:STATION_001。5.5 没有真实桩怎么联调写一个模拟桩程序现象现场桩还没到货后端接口已经写完但无法验证协议解析和订单状态流转。等桩来了再测所有问题集中爆发加班到天亮。原因依赖真实硬件测试环境没有桩上送数据。解决写一个小型模拟桩用 Java Socket 连接后端的 Netty 端口按真实协议发心跳和充电状态甚至可以故意制造粘包半包来测试解码器。public class SimulatedCharger { public static void main(String[] args) throws Exception { Socket socket new Socket(127.0.0.1, 8088); OutputStream out socket.getOutputStream(); int meter 0; sendFrame(out, new DeviceMessage(heartbeat, SIM001, System.currentTimeMillis(), 0, meter)); Thread.sleep(2000); sendFrame(out, new DeviceMessage(chargeInfo, SIM001, System.currentTimeMillis(), 7000, meter)); Thread.sleep(5000); for (int i 0; i 10; i) { meter 100; sendFrame(out, new DeviceMessage(chargeInfo, SIM001, System.currentTimeMillis(), 7000, meter)); Thread.sleep(1000); } sendFrame(out, new DeviceMessage(stopReport, SIM001, System.currentTimeMillis(), 0, meter)); socket.close(); } private static void sendFrame(OutputStream out, DeviceMessage msg) throws IOException { byte[] body JSON.toJSONBytes(msg); ByteBuffer buf ByteBuffer.allocate(4 body.length); buf.putShort((short) 0x5A5A); buf.putShort((short) body.length); buf.put(body); out.write(buf.array()); out.flush(); } }模拟桩里的meter是逐渐递增的这样可以验证后端累计电量是否正确。联调时我还会故意分开发byte[]一次只写 2 字节逼出后半包场景确认解码器不会卡死。同样测试两帧拼在一起发验证拆包逻辑。这个模拟桩程序比用现成调试工具强在可控你想复现什么异常就在代码里加什么逻辑。6. 把计费引擎改成可配置价格模板设计与全链路验证6.1 价格模板表设计峰谷电价不再改代码很多充电桩项目的计费逻辑一开始是写死在 Java 代码里的比如if (hour 0) { price 0.3; }。这种实现上线后一旦要调到尖峰时段就得重新发布版本运维半夜打电话求你发补丁。正确做法是把计费规则配置化数据库里存时间段和价格代码只负责匹配。CREATE TABLE price_template ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL COMMENT 模板名称, start_time TIME NOT NULL COMMENT 时段开始, end_time TIME NOT NULL COMMENT 时段结束, price INT NOT NULL COMMENT 单价单位分/kWh, effective_date DATE NOT NULL COMMENT 生效日期, expire_date DATE NOT NULL COMMENT 失效日期, status TINYINT NOT NULL DEFAULT 1, KEY idx_effective (effective_date, expire_date, status) );实际价格模板是多条记录例如“峰段 08:00-11:001.2 元/度”“平段 11:00-17:000.8 元/度”。结算时把订单的起止时间按时间段切分分别计算各段电量费用。关键点是修改价格不要 UPDATE 现有记录而是插入新记录并禁用旧记录因为历史订单已经引用了旧模板快照改库会导致旧账单金额跟着变化。计费引擎读取当前有效的模板列表缓存到本地模板变化时通过消息通知刷新。6.2 用接口测试覆盖整条链路启动→充电→停止→结算配置化做完后最后一步是验证整个链路是否闭环。我在测试环境里会准备一组接口调用按真实业务顺序执行每步都检查数据库里的状态变化。# 1. 设备上线 curl -X POST http://localhost:8080/device/sim-online \ -H Content-Type: application/json \ -d {deviceNo:SIM001} # 2. 用户启动充电 curl -X POST http://localhost:8080/order/start \ -H Content-Type: application/json \ -d {userId:1,deviceNo:SIM001,priceTemplateId:1001} # 3. 模拟桩上报电量 curl -X POST http://localhost:8080/report/meter \ -H Content-Type: application/json \ -d {deviceNo:SIM001,meterValue:12800} # 4. 停止充电 curl -X POST http://localhost:8080/order/stop \ -H Content-Type: application/json \ -d {orderNo:20250515001} # 5. 查询订单最终金额 curl -X GET http://localhost:8080/order/detail?orderNo20250515001每步验证什么我心里有个清单。设备上线后charging_device.status应该变 1。启动充电后账户的frozen_balance增加订单状态从 0 变 1。上报电量后订单的power_consumed增长。停止后订单状态变 2fee_real等于按价格模板算出的金额账户冻结余额减少可用余额变回多退少补后的结果。如果中间任何一步没对上先看日志里报错的是哪个环节再回查事务边界。这套链路测试我习惯写成 JUnit 集成测试每次提交代码后自动跑一遍而不是只靠手动 curl。毕竟充电桩涉及钱真实设备又不可能随时触发全部报文自动化测试能让回归成本低很多。以前我做计费规则就是写死在常量类里一次调价要改代码、编版本、发补丁凌晨爬起来处理灰度问题。后来彻底改成价格模板加快照运维在后台改新模板、设置生效时间业务那边到点自动切换到新价格旧订单仍然按老快照清算这才算把计费系统做成一个能长期维护的模块。做充电桩系统协议对接只是入门把并通过状态机、事务边界、补偿机制把账单和订单做成闭环才是真正见功底的地方。希望帮到你。本文还有配套的精品资源点击获取