简介智能电表远程抄表缴费管理平台JAVA源码是一套基于JAVA技术构建的物联网应用主要面向物业管理方、房东及物联网开发工程师。平台兼容正泰、人民、天正、许继等主流品牌电表通过GPRS、LoRa、NB-IoT等通信方式实时采集读数有效减少人工抄表负担同时提供线上缴费、用户权限管理、异常报警和能耗报表生成等完整功能适用于写字楼、园区等集中用电场景。资源包为rar压缩格式共86个文件含77个java源文件、8个xml配置文件和1个iml工程模块文件整体仅67KB。java文件承载业务逻辑与通信解析xml用于配置框架与接口目录结构简洁便于快速定位核心代码。该源码的价值在于完整呈现了从设备接入、数据任务调度到支付接口联动、报表输出的物联网闭环。尤其wwby-worker-ammeter模块清晰展示了定时采集与任务处理的实现思路。已有2181人学习下载适合希望掌握远程抄表系统设计、或需要二次开发为生产级能耗管理平台的技术人员。1. 它解决的痛点不止抄表从远程抄表到缴费对账的一站式Java工程最近拆了一套智能电表远程抄表缴费管理平台的Java源码工程第一感受是它做的不是“远程抄表”这一个动作而是把抄表、计费、缴费、对账、能耗报表这些原本散落在Excel里的活全部收拢成了代码闭环。管理员在后台点一下“抄表”系统自动向电表下发指令拿回读数生成账单用户缴费后回调自动核销整个过程不需要人碰电表。对开发者的意义在于这套源码把通信协议、定时调度、账单状态机、支付回调这些可复现的技术点完整地串了起来既适合做能源信息化项目落地也适合拿来当练手工程二次改造。下文我按“架构怎么拆、代码怎么读、本地怎么跑、坑在哪、怎么改”的顺序拆开来写。2. 技术选型与整体架构从Netty通信网关到MySQL账单落库2.1 三层模块划分网关负责通信核心负责业务任务负责调度这套工程的Maven结构是典型的多模块单体我拆包后看到的模块划分大致如下meter-platform ├── meter-common // 通用工具、统一返回体、常量、异常 ├── meter-gateway // Netty服务、DL/T645协议编解码、设备连接管理 ├── meter-core // 设备档案、抄表、账单、缴费业务逻辑 ├── meter-job // 定时任务与分布式锁 ├── meter-admin // 管理后台HTTP接口对接Vue前端 └── sql // 建表脚本与初始数据把网关层独立成模块是我比较认可的设计。抄表平台最容易变的部分是“设备怎么连、报文怎么解析”而账单和缴费相对稳定所以通信协议的变化被隔离在 meter-gateway 里不影响 meter-core 的业务代码。数据流向大致是电表或集中器主动连接Netty服务端口建立TCP长连接并维持心跳定时任务或手动指令触发抄表动作网关按设备档案拼装协议报文解析完的上行数据经倍率换算后写入抄表读数表业务层按账期聚合用电量套用电价方案生成账单用户缴费后支付网关的回调通知更新缴费流水和账单状态整条链路到这一步就形成了闭环。2.2 技术栈选型为什么用Netty而不是HTTP轮询层技术选型理由后端框架Spring Boot 2.x生态成熟Java开发效率高设备接入Netty 4.x处理电表长连接和高频报文比BIO可靠业务数据库MySQL 8.x存储设备档案、读数、账单、流水缓存Redis分布式锁防重复采集缓存设备地址ORMMyBatis-Plus单表CRUD代码量小前端Vue Element UI管理后台类项目标配组件丰富定时任务Spring Scheduled 自研Redis锁轻量不需要额外引入任务中心有人会问电表抄表为什么不用后端定时发HTTP请求去读常见做法是智能电表或集中器主动连采集主站因为电表大多部署在配电房、表箱这类内网环境主站主动访问会被网络策略挡住设备主动建连反而最简单。Netty在这种场景下有天然优势大量电表长连接复用少量线程报文边界靠ByteBuf处理协议解析用Handler链实现后续要支持新协议只需要新增一种decoder。2.3 数据库设计五张核心表撑起业务闭环整个业务闭环依赖的核心表有 meter_info、meter_reading、bill_info、payment_record、read_task。先看设备档案表这个表是网关层和业务层共同的基础CREATE TABLE meter_info ( id bigint(20) NOT NULL AUTO_INCREMENT, meter_no varchar(12) NOT NULL COMMENT 12位表地址BCD序低位在前, meter_status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1在线 0离线 2故障, location varchar(64) DEFAULT NULL COMMENT 安装位置, multiplier decimal(10,2) NOT NULL DEFAULT 1.00 COMMENT 互感器倍率, protocol varchar(16) NOT NULL DEFAULT DLT645 COMMENT 通信协议类型, create_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_meter_no (meter_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT电表档案表;表地址字段和倍率字段在抄表、计费两处都会用到所以做了唯一索引避免同一块表重复建档。meter_reading 表保存每次采集到的原始读数唯一索引建在 (meter_no, data_id, read_time) 上data_id 是数据标识用来区分总电量、峰电量、谷电量等不同数据项。bill_info 表里有一个 status 字段标识账单状态0待支付、1已支付、2已冲正这个状态字段是整个缴费流程的流转依据。明白这些表的关系之后再去看代码基本就能在半小时内把项目主路径摸清定时任务驱动网关读表读到的数据进 meter_reading业务层按账期聚合生成 bill_info缴费成功后 payment_record 记录流水并反向更新 bill_info。核心开发时只要不打破这条闭环在这几章表上做扩展都比较安全。3. 核心模块拆解一次抄表从指令下发到生成账单的完整链路3.1 网关层DL/T645报文构建与响应解析远程抄表的第一道坎是协议。国内智能电表大部分走DL/T645-2007规约报文特征是以多个FE作为前导符接着68开头、16结尾。读表指令长这样FE FE FE FE 68 12字节表地址 68 控制码 数据域长度 数据域 校验和 16。其中表地址是BCD码且低位在前控制码0x01表示读数据数据域里放的是数据标识。public class Dlt645Codec { private static final byte START 0x68; private static final byte END 0x16; /** 组装读表指令dataId 示例00010000 表示读当前组合有功总电能 */ public static byte[] buildReadRequest(String meterNo, String dataId) { byte[] addr reverseBcd(meterNo); byte[] data hexToByte(dataId); // 这里按DL/T645规则做加扰数据标识每个字节加0x33 for (int i 0; i data.length; i) { data[i] (byte) ((data[i] 0x33) 0xFF); } ByteBuffer buf ByteBuffer.allocate(1 12 1 1 1 data.length 1 1); buf.put(START); buf.put(addr); // 12字节地址域 buf.put(START); buf.put((byte) 0x01); // 控制码读数据 buf.put((byte) data.length); buf.put(data); buf.put((byte) 0); // 校验和占位 buf.put(END); byte[] frame buf.array(); frame[frame.length - 2] checksum(frame, 1, frame.length - 3); return frame; } /** 从起始符后一个字节开始累加到数据域结束为止 */ private static byte checksum(byte[] data, int from, int to) { int sum 0; for (int i from; i to; i) { sum (sum (data[i] 0xFF)) 0xFF; } return (byte) sum; } }校验和运算范围从第一个68之后开始更容易因为边界问题翻车。请求报文的组装逻辑里表地址翻转和0x33加扰是新手最容易漏的两步漏了以后电表要么不响应要么一直返回错误帧。响应报文的解析方向相反Netty的Handler里收到完整帧后先校验起始符、校验和再取数据域长度然后对数据区做反转加扰还原。DL/T645的数据区第4位是0x33加密后的科学计数因此还原时每个字节加0x33换算成十进制后再移小数点。这个读取逻辑可以封装为public static BigDecimal decodeReadResponse(byte[] frame) { if (frame[0] ! START || frame[frame.length - 1] ! END) { throw new IllegalArgumentException(非法帧); } int len frame[10] 0xFF; // 数据域长度 byte[] data Arrays.copyOfRange(frame, 14, 14 len); // 前4字节是数据标识后面才是电能值 byte[] valueBytes Arrays.copyOfRange(data, 4, data.length); for (int i 0; i valueBytes.length; i) { valueBytes[i] (byte) ((valueBytes[i] 0x33) 0xFF); } long raw bytesToLong(valueBytes); return BigDecimal.valueOf(raw).movePointLeft(4); }这里的 movePointLeft(4) 是因为DL/T645电能值最小单位是0.0001kWh读到的整数要除以10000。不同厂家的实时数据、需量数据、电压数据在数据标识和单位上各有差别所以实际工程中通常把 data_id 和单位一起配置成字典避免在代码里做死。3.2 采集调度定时任务配合Redis锁防止重复执行定时抄表最简单的方式就是Spring的Scheduled但生产环境部署多个实例时同一个任务会在每台机器上各跑一次导致同一时段出现多条读数。我习惯用Redis的setIfAbsent做一个分布式锁抢到锁的实例才执行任务Component Slf4j public class MeterReadScheduler { Resource private StringRedisTemplate redisTemplate; Resource private MeterReadService meterReadService; Scheduled(cron ${meter.gather.cron}) public void autoRead() { String lockKey task:autoRead:2025041502; Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, running, Duration.ofMinutes(30)); if (!Boolean.TRUE.equals(locked)) { log.warn(已有实例在执行抄表任务本次跳过); return; } try { meterReadService.readAllOnlineMeters(); } finally { redisTemplate.delete(lockKey); } } }lockKey 里带上任务批次号避免两轮任务之间因为执行时间过长而互相干扰。锁的过期时间按任务最大耗时设置我一般会留两倍余量防止断电或重启导致锁永久不释放。readAllOnlineMeters 内部会分批处理设备先把在线设备id列表查出来每100台一组分组发起读命令避免一次性把几千台设备全部压给网关层。若设备回包超时则把该设备标记为离线等待下轮任务重试。3.3 计费逻辑阶梯电价和倍率换算怎么落进代码抄到原始读数之后不能直接把读数差值乘以电价。实际用电量要先乘上互感器倍率因为大部分电表经电流互感器接入表上显示1度实际可能是几十度。其次大多场景采用阶梯电价按总用电量分档计价public BigDecimal calcBillAmount(MeterReading reading, PricePlan plan) { BigDecimal actual reading.getKwh().multiply(reading.getMultiplier()); BigDecimal amount BigDecimal.ZERO; if (actual.compareTo(plan.getFirstStage()) 0) { amount actual.multiply(plan.getPrice1()); } else if (actual.compareTo(plan.getSecondStage()) 0) { amount plan.getFirstStage().multiply(plan.getPrice1()) .add(actual.subtract(plan.getFirstStage()).multiply(plan.getPrice2())); } else { amount plan.getFirstStage().multiply(plan.getPrice1()) .add(plan.getSecondStage().subtract(plan.getFirstStage()).multiply(plan.getPrice2())) .add(actual.subtract(plan.getSecondStage()).multiply(plan.getPrice3())); } return amount.setScale(2, RoundingMode.HALF_UP); }所有金额计算全部用BigDecimal这一点是踩过坑才有的觉悟。用double计费每笔差几分钱日积月累到月底对账就是一笔糊涂账。阶梯判断的结构化表达其实可以用策略模式重构但当前这种if-else写法在电价档位不超过三档时最直观后续要支持峰平谷分时电价再增加按时间段拆分用电量的计算器即可。3.4 缴费回调用幂等键挡住重复请求缴费环节最容易出问题的不是支付而是回调。支付渠道为了确保通知送达往往会重试多次如果后端不处理重复请求同一笔缴费就会生成多条流水账单被更新两次。处理方式是一条唯一索引外加事务内的先查后写Transactional(rollbackFor Exception.class) public void handlePaymentNotify(PaymentNotifyDTO dto) { Integer count paymentRecordMapper.countByPaymentNo(dto.getPaymentNo()); if (count 0) { return; // 重复回调直接忽略 } PaymentRecord record new PaymentRecord(); record.setPaymentNo(dto.getPaymentNo()); record.setBillId(dto.getBillId()); record.setStatus(1); paymentRecordMapper.insert(record); billInfoMapper.updateStatus(dto.getBillId(), BillStatus.PAID); }代码层面的count查询只是第一道防线payment_record 表上的 payment_no 唯一索引才是兜底。万一两个请求同时进来一个插入成功另一个会触发唯一键冲突由全局异常处理器捕获后同样返回成功避免支付渠道误以为通知失败继续重试。4. 本地跑起来从建库建表到模拟电表回包的完整流程4.1 环境准备与工程导入需要准备JDK 8或以上版本、Maven 3.x、MySQL、Redis、IDEA。拿到源码包后用IDEA以Maven工程方式打开根目录 pom.xml等待依赖下载完成即可。这份工程没有依赖外部中间件MySQL和Redis装好就能跑。我通常先把 sql 目录下的脚本按顺序执行命名一般是 schema.sql 建表、data.sql 初始化演示数据执行完后确认 meter_info 里有几条设备记录。4.2 修改配置时区和端口是命门application.yml 里要改的是数据源、Redis和Netty端口三个地方server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/meter_platform?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4 username: root password: root redis: host: localhost port: 6379 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml meter: netty: port: 8304 boss-threads: 1 worker-threads: 4 gather: cron: 0 15 2 * * ?数据源连接串里的 serverTimezone 一定要显式写 Asia/Shanghai实践中有不少项目在本地连库没问题一部署到服务器上报表就少一天就是时区没指定。Netty端口记住一个数就行后面模拟电表也要用同一个端口。抄表 cron 我改成每分钟一次方便本地验证生产环境再换回凌晨执行。提示建议本地验证时把 cron 调成0 * * * * ?这样一分钟就能看到一次自动采集效果不用手动点按钮等半天。4.3 写一个模拟电表把回包做实没有真实电表怎么验证链路常见做法是本地起一个TCP服务模拟电表监听同一端口收到读表命令就回一帧固定数据。这里贴一个我在本地反复用过的最小实现public class SimulatorMeterServer { /** 数据区固定回复当前组合有功总电能 123.45 kWh已按DL/T645规则做过加扰 */ private static final byte[] RESPONSE hexStringToByteArray( 68 11 11 11 11 11 11 68 81 06 00 01 00 00 45 23 01 00 55 16); public static void main(String[] args) throws Exception { ServerSocket serverSocket new ServerSocket(8304); System.out.println(simulator meter listening on 8304); while (true) { Socket socket serverSocket.accept(); new Thread(() - handle(socket)).start(); } } private static void handle(Socket socket) { try (InputStream in socket.getInputStream(); OutputStream out socket.getOutputStream()) { byte[] buffer new byte[128]; int len in.read(buffer); // 粗略判断收到以68起始的读请求帧 if (len 4 buffer[4] (byte) 0x68) { out.write(RESPONSE); out.flush(); } } catch (IOException ignored) { } finally { try { socket.close(); } catch (IOException ignored) { } } } }这段模拟器只做了一件事收到读请求后返回写死的电量帧。真实工程里一个集中器下带多块表模拟器还要根据报文中的表地址返回对应读数但本地验证核心链路用这个就够了。运行时先启动模拟器再启动后端日志里能看到Netty服务启动并且定时任务触发后会打印读到的电量值。如果读到的数据不对先从模拟器回包检查起再对照协议帧格式排查。4.4 验证一条完整业务链后端启动完成后通过HTTP接口逐层验证更直观操作请求路径预期结果查看设备列表GET /api/meter/list?page1能看到模拟表记录手动抄表POST /api/meter/1/read返回当前读数生成账单POST /api/bill/settle?billId1账单状态为待支付缴费回调POST /api/pay/notify账单状态变为已支付curl -X POST http://localhost:8080/api/meter/1/read \ -H Content-Type: application/json -d {}我建议把这条链路完整走一遍再去看前端页面。后端调通能提前暴露大部分问题比如协议解析错误、账单金额不对、回调幂等失效这类问题卡在前端调试阶段反而费时间。走完这四步这个项目对你来说就已经从“源码”变成“跑起来的系统”了。5. 避坑与排查这套平台最容易翻车的五个细节5.1 现象模拟电表能连接但始终解析不到数据日志里出现 “invalid frame” 或 “校验和错误”时先不要怀疑代码逻辑大概率问题出在帧边界处理。原因在于前端发了4个FE前导符某些实现会在读取时把前导符也算进帧体导致程序从错误的位置开始解析。 解决方法是统一约定发送端补前导符接收端在解码器里跳过FE字节只拿FE后面的68 开头报文。另外检查表地址是否做了低位反转DL/T645里地址是按字节倒序传输的地址写反了电表会直接不响应。5.2 现象同一块表在同一抄表周期出现了两条读数这是典型的定时任务重复执行。本地单实例不暴露生产环境多节点部署后Scheduled在每个节点都会触发一遍。 原因就是缺分布式锁。 解决方法是给任务加Redis锁像前面代码里那样用 setIfAbsent 保证同一时刻只有一个实例执行。还有一种隐蔽场景任务A执行到一半重启锁还没释放下个周期任务B看到锁存在就跳过导致这个周期漏采。所以锁的过期时间要按任务最大耗时配置而不是默认10秒。5.3 现象缴费后账单状态一直停留在“待支付”这类问题大多出现在回调处理上。支付渠道回调通知后端后端更新数据库失败但返回了成功渠道认为通知已到达不再重试账单就卡住了。 另一类原因是先查后写没有做好幂等同一回调处理两次导致数据错乱。 解决方法是缴费流水表加唯一索引事务内先查流水再插流水最后更新账单任何一步失败都抛异常让事务回滚返回非成功让渠道继续重试。排查时先查 payment_record 表有没有对应流水没有就是回调压根没进来有就要看账单更新语句是否被异常拦截。5.4 现象账单金额和实际用电对不上月底总差几块钱金额对不上基本是精度问题。用double做乘法或者倍率计算顺序错了都会累积误差。 我在本地测试时试着把电表读数直接用kwh乘以单价结果10台模拟表跑一个月差异从几分钱慢慢放大到几块钱。 解决方法是结算金额统一用BigDecimal并且先乘倍率得到实际电量再按阶梯分档计算倍率要读 meter_info 里的 multiplier 字段不能写死在代码里。还有一个注意点抄表读数要保留数据来源总电量要区分峰平谷否则做分时电价时还得重新解析历史报文。5.5 现象前端页面一到月底报表查询就卡死数据量上来之后meter_reading 表会膨胀得很快一天一抄一年就是365条每表。几万块表跑一年就是上千万行报表查询没有索引就是全表扫描。 常见误区是只给主键建索引查询条件里的 meter_no 和 read_time 完全没有索引可用。 解决方法是建组合索引 (meter_no, read_time)报表统计时把日期条件作为强制过滤项。更彻底的做法是按月分表或建立日汇总表把明细数据归档到历史库日常对账只查汇总结果。这套工程本地跑没有问题要上生产必须提前考虑这个增长模型。6. 二次开发从抄表平台延伸到能耗分析6.1 用策略模式接入新协议实际落地时几乎一定会遇到第二种、第三种电表协议比如Modbus RTU或某个厂家的私有协议。网关层如果继续用if-else判断协议类型新协议多了之后代码会迅速腐化。更合适的做法是定义协议处理器接口public interface MeterProtocolHandler { boolean support(String protocol); MeterData read(MeterDevice meter); }网关收到抄表任务时遍历所有已注册的 ProtocolHandler找到 support 返回true的那个实例来执行读取。新增一种协议只需要写一个新的实现类并注册到Spring容器不需要改动原有的DL/T645逻辑。我第一次给这类平台加Modbus协议时因为协议解析耦合在读表服务里改一处动了全链路后来重构出这个接口才好了从那以后我每接触一套抄表平台都会先看协议层是否留了扩展点。6.2 把固定抄表频率改成按设备可配平台默认全局一个 cron 表达式所有设备一个频率。但能耗管理场景里要按15分钟频率采集园区重点回路普通住户表一天一次就够了。改造思路是给 read_task 表增加 cron 字段配置每类设备的采集频率调度器每分钟扫一次任务表命中时间点的任务才下发采集请求。这里要重点处理高频设备的并发问题同一集中器下的设备读取要串行化否则集中器处理不过来会大量丢包。6.3 从账单延伸到能耗分析抄表数据的价值不止于出账单。采集频率提高后可以用小时、天、月维度做聚合统计算出环比同比识别出异常用电时段。比如某条回路线路在半夜用电量突增大概率是设备非正常开启或线路漏电。DL/T645协议里电压、电流、有功功率都有对应的数据标识扩展采集时不改协议层只要在数据字典里增加新的 data_id 配置就能把更多电能质量数据拉回来。顺着这个方向这套平台就不再只是“抄表缴费”而是慢慢长成一个小型的企业能耗平台。我拆完这个工程最大的习惯变化是以后再接手这类Java项目第一件事不是读业务代码而是先把模拟器跑起来把端到端链路验证通再决定从哪里动手改。希望帮到你。本文还有配套的精品资源点击获取