首页 / 资讯中心 / 文章详情

充电桩管理平台Java实战:状态机、分时计费与并发一致性

充电桩管理平台Java实战:状态机、分时计费与并发一致性 ★ FEATURED ARTICLE
简介面向电动车充电管理场景的Java Web项目资源适合正在学习后端框架、数据库及前端页面整合的开发者和高校学生。项目围绕汽车电池充电的智能化管理覆盖用户注册登录、个人信息维护、车辆登记、充电站录入与自助检查等核心页面也涉及充电预约与计费管理等环节可帮助理解充电管理系统的完整业务流程与界面实现。压缩包为rar格式共32个文件包含16个页面文件、7个样式文件、2个脚本文件以及图标字体和少量图片素材整包大小约316KB结构清晰精简便于快速部署和二次开发。目前已有六百余人学习内附前端页面、样式脚本与配套资源可作为课程设计或毕业设计的参考原型也能用于练习前后端交互及数据关联操作。1. 充电汽车管理系统 vs 普通Java后台三个必须先想清楚的实时问题凌晨两点运维群弹出一条告警3号桩离线。后台页面刷新这个桩的订单还在充电中、还在计费。等天亮排查发现桩只是网络闪断但系统已经积累了几个小时的坏数据和一笔算不清的账。充电汽车管理系统也叫充电桩运营管理平台和普通Java后台的定位完全不同它不只是把数据库里的记录展示给运维看而是要和真实的充电桩做状态博弈——插枪、鉴权、下发启动、采集电压电流、计费结算、充满自停每一步都涉及设备状态、订单生命周期和钱三方的一致性。用Java做这个系统选型不难难在状态建模和并发边界。这篇文章写给要自己搭充电管理平台的Java工程师我会用Spring Boot MyBatis Plus MySQL Redis这套最常见组合把从设备接入到结算的完整链路拆开讲包括电池充电状态怎么读、分时计费怎么落地以及四个最容易翻车的坑。2. 技术栈选型与核心数据模型桩、枪、订单、明细四张核心表的DDL设计2.1 为什么是Spring Boot MyBatis Plus规模决定技术选型先明确边界。这个方案适合的是几百台桩、几个运营人员、一个场站到几十个场站的运营规模——车主进停车场、插枪、扫码、充电、结算。到了这个规模Spring Boot MyBatis Plus MySQL Redis的组合有足够的确定性收益招Java工程师容易网上资料多出了问题能很快搜到解法。不推荐在初期用Netty自研协议解析也不要一上来拆微服务。充电桩的通信主流是MQTT和TCP私有协议两类MQTT用Eclipse Paho Java客户端就能接入TCP私有协议每家的报文格式都不同但那只是设备接入层的工作量用Netty也改变不了上层业务。如果一上来就纠结高并发框架最可能的结局是把订单状态机的复杂度拖垮。我在早期一个项目里以为要上Kafka做了异步全链路结果最忙的是运维每天重启Kafka。充电桩的上报是稳定周期流不是突发流一台桩5秒一条100台桩一天新增明细大约172万条MySQL分批插入完全扛得住。主流程选型清单Spring Boot 2.7 MyBatis Plus 3.5CRUD交给框架把编码时间留给状态机和计费。MySQL 8.0桩、订单、明细、计费快照、告警都放这里这个规模不用单独的分析库。Redis存桩实时运行数据、活跃会话也用于分布式锁。Redisson分布式锁客户端处理并发停止订单这种场景。定时任务直接用Spring Scheduled不需要引入Elastic-Job之类的框架。Scheduled(fixedDelay 5000) public void scanLiveOrders() { // 每轮扫描活跃订单做采样和状态推进 }2.2 四张核心表DDL设备、订单、明细、告警的字段与约束设备侧把桩和枪分开一个双枪桩的两把枪状态独立、可以同时给两辆车充电如果不拆表单枪维度做唯一约束会很别扭。桩表CREATE TABLE charge_pile ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, pile_code VARCHAR(32) NOT NULL COMMENT 桩编码全局唯一来自厂商, station_id BIGINT NOT NULL COMMENT 所属场站ID, model_name VARCHAR(64) COMMENT 型号用于加载适配器, firmware_version VARCHAR(32) COMMENT 固件版本OTA升级时比对, status TINYINT NOT NULL DEFAULT 0 COMMENT 0离线 1空闲 2充电中 3故障 4维护, rated_power DECIMAL(8,2) COMMENT 额定功率kW, trickle_current DECIMAL(6,2) DEFAULT 1.00 COMMENT 涓流电流阈值A按桩型配置, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_pile_code (pile_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT充电桩信息表;firmware_version不是摆设OTA升级时要用它决定下发哪个升级包trickle_current直接放在桩表因为不同型号桩的充满判定阈值不一样。枪表CREATE TABLE charge_gun ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, pile_code VARCHAR(32) NOT NULL, gun_no TINYINT NOT NULL COMMENT 枪号从1开始, status TINYINT NOT NULL DEFAULT 0 COMMENT 0空闲 1已插枪 2充电中 3故障, current_voltage DECIMAL(8,2) COMMENT 最新电压V, current_current DECIMAL(8,2) COMMENT 最新电流A, current_power DECIMAL(8,2) COMMENT 最新功率kW, last_report_time DATETIME COMMENT 最近上报时间离线判定依赖它, UNIQUE KEY uk_pile_gun (pile_code, gun_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT充电枪实时状态表;实时值字段会在每个上报周期被更新。注意Redis里也存一份最新值给App接口快速返回MySQL这份用于台账和故障排查两边不同步时以Redis为准。订单表是核心。这里有一个关键设计——active_flag。CREATE TABLE charge_order ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(36) NOT NULL COMMENT 订单号客户端生成的UUID幂等键, pile_code VARCHAR(32) NOT NULL, gun_no TINYINT NOT NULL, user_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME DEFAULT NULL, stop_reason TINYINT DEFAULT NULL COMMENT 1用户停止 2充满自停 3告警终止 4离线强制终止, start_soc TINYINT COMMENT 启动时SOC百分比, end_soc TINYINT COMMENT 结束时SOC百分比, total_power DECIMAL(12,4) DEFAULT 0 COMMENT 累计电量kWh, total_amount DECIMAL(10,2) DEFAULT 0 COMMENT 累计金额元, status TINYINT NOT NULL COMMENT 0启动中 1充电中 2已完成 3异常终止 4结算中, active_flag BIGINT NOT NULL DEFAULT 1 COMMENT 进行中为1结束时置为id, UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_pile_gun_active (pile_code, gun_no, active_flag), KEY idx_user_time (user_id, start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT充电订单表;MySQL没有部分索引可以表达同一把枪只能有一条未结束订单这种条件唯一约束我的做法是加冗余字段active_flag。进行中的订单active_flag恒为1联合唯一索引(pile_code, gun_no, active_flag)保证同一把枪同时最多只有一条进行中的订单订单结束时把active_flag置为订单id因为id必不重复历史记录不会互相冲突。这个技巧价值很大并发场景下即使两路请求同时进入数据库也能挡住第二条这是Java后端保证数据一致性里最硬的一道兜底。明细表CREATE TABLE charge_detail ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, sample_time DATETIME NOT NULL, voltage DECIMAL(8,2) COMMENT 电压V, current DECIMAL(8,2) COMMENT 电流A, power DECIMAL(8,2) COMMENT 功率kW, soc TINYINT COMMENT SOC百分比弱信号, meter_reading DECIMAL(12,4) COMMENT 电表读数kWh计费唯一依据, KEY idx_order_time (order_id, sample_time), KEY idx_sample_time (sample_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT充电明细表;明细表和计费快照表分开原因后面讲。2.3 状态机先于代码订单状态流转的四种路径状态机要先把路径定死不能在业务代码里随意setStatus。我的定义public enum ChargeOrderStatus { STARTING(0, 启动中), CHARGING(1, 充电中), FINISHED(2, 已完成), ABNORMAL(3, 异常终止), SETTLING(4, 结算中); private final int code; private final String desc; public int getCode() { return code; } ChargeOrderStatus(int code, String desc) { this.code code; this.desc desc; } }public final class OrderStateMachine { public static boolean canTransit(ChargeOrderStatus from, ChargeOrderStatus to) { switch (from) { case STARTING: return to ChargeOrderStatus.CHARGING || to ChargeOrderStatus.ABNORMAL; case CHARGING: return to ChargeOrderStatus.SETTLING || to ChargeOrderStatus.ABNORMAL; case SETTLING: return to ChargeOrderStatus.FINISHED || to ChargeOrderStatus.ABNORMAL; default: // FINISHED / ABNORMAL 是终态不允许再变 return false; } } }为什么停止和结算拆成两个状态CHARGING→SETTLING→FINISHED结算要调钱包扣款、优惠券核销有外部依赖不可能在一个事务里瞬间完成。先进入SETTLING后台任务做结算完成之后再置FINISHED。App端看到的是已停止结算中而不是错误的已完成。状态变更用条件更新SQL不能用先查再改UPDATE charge_order SET status #{newStatus}, active_flag id, end_time NOW() WHERE id #{id} AND status #{expectStatus} AND active_flag 1更新影响行数为1才算抢到状态为0说明另一个请求已经改变了状态当前请求直接忽略。这套条件更新唯一索引的组合经过线上多次验证比分布式锁更可靠是处理这类状态竞争的首选。分布式锁只用于保护外部接口调用这类操作状态本身交给数据库。3. 电池充电状态监控落地SOC识别、阶段判定与告警规则的Java实现3.1 桩上报的数据里哪些能信、哪些只能参考充电桩上报的基本都有电压、电流、功率、SOC四个值。我的可信度排序| 数据 | 来源 | 可信度 | 我的处理方式 | | 电压/电流 | 桩内计量模块 | 高 | 作为阶段判定和告警依据 | | 功率 | 桩按电压电流算出来的 | 中 | 只做展示 | | 电表读数 | 桩内电表 | 高 | 计费唯一依据 | | SOC | 桩转发BMS或桩自己估算 | 低 | 只做展示不参与结算 |SOC是典型的弱信号。很多交流桩根本没有和车载BMS通信桩端拿不到真实电量就按充电时间估算一个值上报。我接入过一批桩启动时上报SOC是100充了一小时后反而降到85因为桩把已输出多少分钟当成剩余多少。所以SOC字段要过滤单次跳变超过20%的采样直接忽略App展示用最近5次采样的中位数结算彻底不看SOC。3.2 5秒级采样聚合Scheduled Redis缓存的实现监控链路的输入来自MQTT上报。桩端MQTT消息到达后消费者把最新状态写入Rediskey设计为pile:runtime:{pileCode}:{gunNo}value是JSON。然后一个定时任务每5秒扫一次进行中的订单从Redis取状态做落库和判定。Component Slf4j public class ChargingMonitor { private static final Duration OFFLINE_TIMEOUT Duration.ofSeconds(15); private static final BigDecimal TRICKLE_CURRENT new BigDecimal(1.0); private static final Duration TRICKLE_DURATION Duration.ofMinutes(3); private final OrderMapper orderMapper; private final ChargeDetailMapper chargeDetailMapper; private final AlarmService alarmService; private final PileRuntimeCache pileRuntimeCache; private final TrickleWindow trickleWindow; Scheduled(fixedDelay 5000) public void scanLiveOrders() { ListChargeOrder liveOrders orderMapper.selectList( new LambdaQueryWrapperChargeOrder() .in(ChargeOrder::getStatus, ChargeOrderStatus.STARTING.getCode(), ChargeOrderStatus.CHARGING.getCode())); for (ChargeOrder order : liveOrders) { PileRuntime runtime pileRuntimeCache.get(order.getPileCode(), order.getGunNo()); if (runtime null || runtime.isOlderThan(OFFLINE_TIMEOUT)) { // 注意这里只告警不结束订单 alarmService.raise(order.getPileCode(), AlarmType.OFFLINE, 充电中桩心跳超时); continue; } saveDetail(order, runtime); judgeTrickleAndFull(order, runtime); judgeAlarm(order, runtime); } } }逻辑说明第一步从MySQL查活跃订单第二步从Redis取这台桩最近上报的数据。Redis里没有或时间戳比15秒更早说明桩失联此时只触发告警不直接终止订单理由很简单——一次网络抖动不该杀掉正在充电的车。第三步把采样写入明细表第四步做电池阶段判定第五步做告警判定。参数说明Scheduled(fixedDelay 5000)是上一轮执行完再等5秒和fixedRate的区别是任务执行时间不会叠加导致方法重入。扫描的频率不用太密充电是一个秒级到分钟级的过程5秒足够。明细写入的批量问题100台桩每5秒一条一天约172万条。单条插入肯定不行我一般每轮攒200条做一次batch insert。MySQL这个规模压力不大但明细表要按季度做归档否则一年后SQL会慢。3.3 充满判定与涓流阶段不要被一次采样骗了锂电池充电末端是恒压减流的模式当电压达到上限电流会逐渐下降。所以判断充满用两个信号叠加电流低于阈值且持续一段时间。开发时最容易犯的错误是拿单次采样就判定桩上报偶尔抖动一次电流从1.5A跳到0.9A系统就给用户发已充满——用户实际还在充。private void judgeTrickleAndFull(ChargeOrder order, PileRuntime runtime) { // 交流桩常见阈值1A直流桩2A实际从charge_pile表读配置 if (runtime.getCurrent().compareTo(TRICKLE_CURRENT) 0) { trickleWindow.clear(order.getId()); return; } trickleWindow.append(order.getId(), runtime.getSampleTime()); if (trickleWindow.continuousSeconds(order.getId()) TRICKLE_DURATION.getSeconds()) { orderStageService.markTrickle(order.getId()); // 达到涓流持续时间触发充满自停流程 stopService.stopByFull(order.getId()); } }逻辑说明trickleWindow是一个内存滑动窗口结构是ConcurrentHashMapLong, Deque 记录每个订单最近进入低电流的时间点只有低电流持续3分钟以上才允许充满自停。1A和3分钟是两个可调参数放在桩表里按桩型配置因为直流桩的涓流电流明显比交流桩高。这个滑动窗口为什么能放内存同时活跃的订单量通常几十到几百窗口数据在订单结束后清掉。如果运营规模大到上万并发订单再迁到Redis list不迟。3.4 告警规则表与实现温度、电压跌落和SOC跳变告警我不用规则引擎一个if-else链就够了。真正要花心思的是阈值定义和防抖。| 规则 | 阈值 | 级别 | 处理动作 | 防抖设计 | | 电压跌落 | 相比启动时电压跌5% | 故障 | 告警并可配置自动停止 | 排除启动后前10秒的暂态 | | 电流过流 | 额定电流120%持续10秒 | 警告 | 通知运维 | 用10秒均值不用瞬时值 | | SOC跳变 | 单次变化超过20% | 提示 | 只记录 | 不通知避免深夜骚扰 | | 通信超时 | 15秒无上报 | 警告 | 连续3轮超时才置离线 | 用历史窗口判断 |private void judgeAlarm(ChargeOrder order, PileRuntime runtime) { BigDecimal startVoltage runtimeCache.getStartVoltage(order.getId()); BigDecimal drop startVoltage.subtract(runtime.getVoltage()) .divide(startVoltage, 2, RoundingMode.HALF_UP); if (drop.compareTo(new BigDecimal(0.05)) 0 runtime.getSampleTime().isAfter(order.getStartTime().plusSeconds(10))) { alarmService.raise(order.getPileCode(), AlarmType.VOLTAGE_DROP, 电压跌落超过5%); } Integer lastSoc socFilter.lastStable(order.getId()); if (lastSoc ! null Math.abs(runtime.getSoc() - lastSoc) 20) { alarmService.raise(order.getPileCode(), AlarmType.SOC_JUMP, SOC跳变 lastSoc → runtime.getSoc()); } }参数说明电压跌落比较的基准是启动成功后第10秒的电压不是启动瞬间的电压因为插枪瞬间大电流会导致电压骤降拿那个值当基准会天天误告警。SOC跳变的20%是经验值BMS正常采样不会出现这么大的单步变化。这些规则的执行频率是5秒一次对CPU完全没压力。真正要注意的是告警去重同一个桩同一个告警类型恢复前不要重复插入数据库否则告警表会爆炸。我在AlarmService.raise里加了同类型未恢复不重复写恢复时统一关闭。4. 充电订单与计费链路从启动冻结到结算幂等的完整时序4.1 启动充电的先后顺序为什么先冻结再下发启动时序决定订单的干净程度。用户扫码后后台需要在一两秒内完成校验桩空闲、创建启动中订单、冻结用户支付能力、下发启动指令。这一步的关键是冻结必须在指令下发前完成否则桩开始输出电流了用户账户却没有钱结算时只能白充。标准时序App扫码传pileCode和gunNo。后台锁行校验桩状态是空闲。创建订单statusSTARTING同步冻结用户账户或调用预授权接口。MQTT下发启动指令给桩携带orderNo。桩返回启动成功后订单转CHARGING。超过3分钟可配置未收到启动成功订单转ABNORMAL并解冻。Transactional public ChargeOrder startOrder(String pileCode, Integer gunNo, Long userId) { ChargePile pile pileMapper.selectByPileCodeForUpdate(pileCode); if (pile.getStatus() ! PileStatus.IDLE.getCode()) { throw new BizException(当前枪不在空闲状态); } ChargeOrder order ChargeOrder.create(pileCode, gunNo, userId); orderMapper.insert(order); // 冻结金额防止结算时余额不足 userWalletService.freeze(userId, preFreezeAmount(pile)); return order; }逻辑说明锁行用selectByPileCodeForUpdate防止两辆车同时扫同一把枪。冻结金额做完后MQTT下发的动作不在这个事务里而是事务提交后发一条应用事件触发。有人会把网络调用放进事务里同步等桩回复这是很典型的反面教材——桩不响应时数据库连接会被占死。MQTT下发后桩的启动成功通过另一个topic异步上报后台收到后单独做startOrder.confirm。预冻结金额怎么算按桩的额定功率和充电上限时间估算比如60kW直流桩预估10度电按度电价乘一个系数。如果预冻结小于实际费用结算时再补扣或走欠费流程。4.2 增量计费为什么不能等结束再算总账分时电价峰、平、谷要求确定每一度电发生在哪个时间段。如果只在结束结算时看电表差值只能算出总电量分不出峰谷电量。所以计费必须增量每次采样拿到电表读数减去上一次的电表读数差值就是这5秒的充电量以采样时间取电价金额当场计算并落一条计费片段。CREATE TABLE charge_fee_snapshot ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, meter_start DECIMAL(12,4) NOT NULL COMMENT 本期起始电表读数kWh, meter_end DECIMAL(12,4) NOT NULL COMMENT 本期结束电表读数kWh, power_delta DECIMAL(10,4) NOT NULL COMMENT 本期电量增量kWh, fee_start_time DATETIME NOT NULL COMMENT 本期起始时间, fee_end_time DATETIME NOT NULL COMMENT 本期结束时间, tariff_id BIGINT NOT NULL COMMENT 命中的费率ID, unit_price DECIMAL(8,4) NOT NULL COMMENT 该时段的单价元/kWh, amount DECIMAL(10,4) NOT NULL COMMENT 本期金额元, KEY idx_order_time (order_id, fee_start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT充电计费片段表;private void settleSegment(ChargeOrder order, PileRuntime runtime) { BigDecimal lastMeter segmentCache.getLastMeter(order.getId()); BigDecimal meter runtime.getMeterReading(); BigDecimal delta meter.subtract(lastMeter); if (delta.compareTo(BigDecimal.ZERO) 0) { alarmService.raise(order.getPileCode(), AlarmType.METER_RESET, 电表读数回退疑似换表: lastMeter → meter); return; } Tariff tariff tariffService.getTariffAt(runtime.getSampleTime()); BigDecimal amount delta .multiply(tariff.getPrice()) .setScale(4, RoundingMode.HALF_UP); feeSnapshotMapper.insert(buildSnapshot(order, lastMeter, meter, tariff, amount)); orderMapper.incrementPowerAndAmount(order.getId(), delta, amount); }逻辑说明这段代码在每轮扫描里对每个充电订单执行。incrementPowerAndAmount用SQL原子更新订单上的累计电量和累计金额MySQL行锁保证不会多算。电表读数回退是一个必须处理的异常场景——桩换电表或桩端重启清零如果不校验delta为负会把订单金额算成负的。计量单位统一用kWh四位小数和元四位小数展示层再四舍五入到分。理由一次5秒的增量电量可能是0.0031度直接四舍五入两位小数会丢失精度累计到结束时会和电表读数对不上。计费片段表和明细表分开放明细表给监控看可以随时清理片段表是账单依据需要保留完整审计链。4.3 停止充电的三方竞争与幂等结算停止订单的触发源有三个用户在App点停止、桩端BMS充满自停、后台策略比如告警强制停止。三路请求几乎同时到达的情况很常见。处理核心两条状态更新只允许一次成功结算事件只允许落一次。public void stopOrder(Long orderId, StopReason reason) { int rows orderMapper.compareAndFinish(orderId); if (rows 0) { log.info(订单{}已被其他来源停止本次{}忽略, orderId, reason); return; } settleEventService.publish(orderId, SettleEventType.SETTLE); }UPDATE charge_order SET status 4, -- SETTLING active_flag id, end_time NOW(), stop_reason #{reason} WHERE id #{id} AND status 1 -- CHARGING AND active_flag 1compareAndFinish就是上面这条SQL。它同时做了状态变更和active_flag释放影响行数为1才继续发结算事件为0说明有另一个来源已经抢到了停止权当前请求直接静默丢弃。这种方式比在Java里加synchronized或分布式锁更干净因为锁可能过期、可能重入而数据库条件更新唯一可靠。结算组件消费事件charge_settle_event表对(order_id, event_type)有唯一索引同一订单的SETTLE事件只能插入一次。消费线程每3秒扫一次待处理事件成功后置状态为成功失败重试超过10次转人工。Component Slf4j public class SettleConsumer { Scheduled(fixedDelay 3000) public void consume() { ListChargeSettleEvent pending settleEventMapper.selectPending( SettleEventType.SETTLE, 100); for (ChargeSettleEvent event : pending) { try { settleService.settle(event.getOrderId()); settleEventMapper.markSuccess(event.getId()); } catch (Exception e) { log.error(订单{}结算失败进入重试, event.getOrderId(), e); settleEventMapper.incrementRetry(event.getId()); } } } }逻辑说明settle方法内部做三件事——按charge_fee_snapshot汇总金额、扣除用户钱包、更新订单为FINISHED。这三件事里用户钱包扣款是外部接口所以我不会把settle放在一个大事务里钱包扣款成功但订单更新失败时靠事件表重试钱包返回重复扣款时靠钱包侧订单号幂等。到这里从启动到结算的完整链路就通了。下一章我讲这套系统踩过的真实坑每一个都是线上事故换来的。5. 充电管理系统的四个避坑实录假离线、SOC跳变、双重扣费与协议兼容5.1 假离线心跳超时不能只看一次现象后台显示一台桩离线现场看桩正常充电、网络正常App上用户也在正常充电。原因桩的心跳上报是周期性的但偶尔一个周期会延迟比如桩端程序GC、上报线程被优先级更高的任务抢占、或者网络瞬时拥塞。原来系统的判定逻辑是定时任务扫到最后上报时间距今超过15秒就置离线等于把一次偶然延迟当成设备挂了运维半夜白跑一趟。排查时我做了个简单验证停掉离线告警的定时任务用脚本监控桩的实际上报间隔发现最长间隔28秒但平均间隔5秒。这说明了两个问题阈值不能定死在15秒判定也不能只看单次。把单次超时即离线改成连续3轮超时才离线之后假离线基本消失。解决离线判定改为连续多轮超时。我的实现是Redis里记录连续超时计数连续3轮每轮5秒都超过15秒才置离线同时置离线前用MQTT发一个探测topic桩正常订阅的话会立即回复。回复了就只记一条心跳延迟而不离线。public void evaluateOffline(PileRuntime runtime) { boolean timeout runtime.isOlderThan(OFFLINE_TIMEOUT); Long badCount redisTemplate.opsForValue().increment(offlineCountKey(runtime.getPileCode())); if (!timeout) { redisTemplate.delete(offlineCountKey(runtime.getPileCode())); return; } if (badCount 3) { pileMapper.markOffline(runtime.getPileCode()); alarmService.raise(runtime.getPileCode(), AlarmType.OFFLINE, 连续3轮心跳超时置离线); redisTemplate.delete(offlineCountKey(runtime.getPileCode())); } }5.2 SOC从100跳到5弱信号不要当强信号用现象一个直流桩在充电过程中App显示的电量在100%和5%之间横跳用户认为电池坏了投诉到客服。原因桩端和BMS的通信不稳定。BMS的SOC本身是估算值桩在通讯丢失时会按电压自己猜一个值更糟的是另一批桩的SOC字段单位不一样有的厂商直接上报0到1的小数解析代码当成百分数处理。现场抓包发现桩在BMS通信失败时会连续上送两条消息第一条是上一帧的值第二条是估算值两条间隔不到一秒数值可能差几十个百分点。解决两层过滤。第一层单次采样跳变超过20%就不更新缓存里的SOC展示值并记录一条SOC_JUMP告警第二层App查询SOC时返回最近5次稳定采样的中位数而不是最新值。结算和充满判定完全不用SOC充满用的是低电流持续时长结算用的是电表读数。想通这一点后SOC相关的投诉几乎归零。5.3 并发停止导致双重扣费现象用户点击停止的同一秒桩上报了充满自停最终账单金额是实际充电费用的两倍。原因旧代码的停止逻辑是先查订单状态状态是充电中就执行结算。两个请求都通过了这个判断都调了钱包扣款。数据库里没有条件更新的保护首版用的synchronized锁在方法级别多个实例同时部署时根本不共享后来换成Redisson分布式锁又因为锁的过期时间设成30秒结算流程超时后锁自动释放第二个请求照样能进来重复扣款。解决两层兜底。第一层是前面写过的那条条件更新SQL只有一个请求能把订单从CHARGING改成SETTLING另一个请求的影响行数是0直接返回。第二层是charge_settle_event表对(order_id, event_type)的唯一索引即使第一层失效比如有人以后改了SQL结算事件本身也只能落一次。这个教训让我明白在Java里保证数据一致性优先用数据库约束和条件更新不要依赖锁的假设。5.4 新桩型上报字段名不一致适配器层不能省现象接入一个新品牌的桩MQTT消息字段名和旧桩不同电压字段有的叫voltage有的叫volt电流有的叫current有的叫electric_current上报频率一个5秒一个30秒解析代码里到处是if-else判断厂商。原因充电桩的MQTT协议和topic没有强制国标。厂商之间各自为政有的还特意把字段改名做成私有协议。如果只接入一种桩解析代码写在消费者里没问题接入到五六种桩时消费者类会膨胀到几百行桩型判断散落各处改一个字段名要全文搜索。解决在MQTT消费者和业务逻辑之间加一个设备适配层。每个桩型一个Adapter实现类输入是原始JSON或字节流输出统一是PileRuntime DTO。业务侧只认统一DTO不感知桩型。新增桩型只写一个新Adapter。适配层的工作也让测试简单每个Adapter写一个单元测试喂一段该桩型的真实报文断言输出的DTO字段正确不用再跑到现场联调。public interface PileAdapter { PileRuntime parse(String topic, String payload); boolean supports(String modelName); }public class PileRuntimeCache { private final ListPileAdapter adapters; // Spring注入全部实现 public PileRuntime parse(String topic, String payload, String modelName) { for (PileAdapter adapter : adapters) { if (adapter.supports(modelName)) { return adapter.parse(topic, payload); } } throw new UnsupportedOperationException(未适配的桩型: modelName); } }适配层是值得提前投入的样板代码。看起来多写几个类实际上线后接入第20种桩时收益非常大。6. 最后的工程验证用Java桩模拟器一天跑通一桩一充闭环6.1 模拟器让系统在没有真桩时也能开发调试后端开发最大的痛点是等桩。买桩周期长而且桩的启动指令、上报格式都还在调试中。我通常在项目第一天就写一个PileSimulator用Java线程模拟一台桩每秒上报电压、电流、电表读数、SOC电压逐渐上升、电流逐渐下降模拟一节电池从恒流到恒压减流的充电过程。public class PileSimulator { private final String pileCode PILE-TEST-001; private final int gunNo 1; private final MqttClient mqttClient; public void run() throws Exception { double voltage 220.0; double current 32.0; double meter 0.0; long start System.currentTimeMillis(); while (!Thread.currentThread().isInterrupted()) { long elapsedSeconds (System.currentTimeMillis() - start) / 1000; // 模拟电池进入涓流电流随时间线性下降 voltage 230.0 - (elapsedSeconds / 3600.0) * 30.0; current Math.max(1.0, 32.0 - elapsedSeconds * 0.008); meter current * voltage / 1000.0 / 3600.0; String payload String.format( {\voltage\:%.2f,\current\:%.2f,\meter\:%.6f,\soc\:%.1f}, voltage, current, meter, Math.min(100, 20 elapsedSeconds / 60.0 * 0.8)); mqttClient.publish(pile/ pileCode /report, payload); Thread.sleep(1000); } } }验证清单模拟器启动后在后台创建一个充电订单观察订单状态从STARTING到CHARGING、电流跌破1A并持续3分钟后自动转SETTLING再到FINISHED核对累计电量和费用与模拟电表读数一致杀掉模拟器进程模拟断网观察订单先告警再离线、恢复后账不重不漏。6.2 这套方案下一步可延伸的方向当订单量和桩量再上一个量级第一步不是换框架而是把这些已经验证过的核心模块抽出去费率表接数据库动态配置、步骤规则接规则引擎、明细表和片段表按月分库分表。只要状态机和计费链路保持稳定上层架构怎么换都行。我第一次搭这套系统时把停止和结算放在同一个事务里以为最安全。后来真实桩上报一条充满自停同时用户在App点停止两路并发把账算成了负数。从那以后状态流转让条件更新和唯一索引兜底外部依赖从长事务里挪出去。设备系统的状态流转宁可慢一步也不要抢一步——状态错了就是钱错了。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站