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

Java电表远程抄表系统:协议适配、断网续传与双向对账实战

Java电表远程抄表系统:协议适配、断网续传与双向对账实战 ★ FEATURED ARTICLE
简介本资源是一套面向物联网开发初学者与物业/能源管理类项目工程师的Java全栈源码聚焦智能电表远程抄表与线上缴费一体化管理场景解决传统人工抄表效率低、数据滞后、缴费流程割裂等痛点。压缩包共86个文件含77个Java核心业务类覆盖定时采集、数据解析、报警触发、支付回调等模块、8个XML配置文件用于Spring框架Bean管理与MyBatis映射以及1个IntelliJ项目配置文件.iml整体仅67KB轻量易读适合快速上手与二次开发。已有2185人学习下载源码结构清晰以wwby-worker-ammeter为工作组件主干完整呈现GPRS/NB-IoT通信适配、能耗统计分析、用户权限分级、短信邮件通知及标准API接口设计等关键能力。读者可直接复用通信协议解析逻辑、报表生成模板与缴费集成方案快速构建自有能耗管理平台。1. 这不是个“抄表App”而是一套能扛住小区级并发、支持断网续传、自动对账的电表数据闭环系统某高校后勤处上线前一周运维同学深夜发来截图32栋宿舍楼、2846块智能电表凌晨2点集中上报数据MySQL连接池打满缴费状态延迟超15分钟。他们试过把Spring Boot默认HikariCP最大连接数从20拉到100结果数据库CPU飙到98%反而更卡。最后换上这个「智能电表远程抄表缴费管理平台JAVA源码」只改了3个配置项——把数据落库策略从“每条记录实时INSERT”切到“按楼栋分批批量写入本地SQLite缓存兜底”故障直接消失。这不是一个教你怎么连串口读电表的Demo它已经跑在多个物业系统里核心是解决真实场景里的三座大山设备协议碎片化DL/T645-2007 / 698.45 / Modbus RTU混用、网络不可靠校园弱网/地下室信号差、财务强一致性缴费成功≠扣费成功必须双向对账。如果你正被“电表数据收不全、缴费状态不同步、后台查不到哪块表掉线”反复折磨这份源码就是你该拆的第一份生产级能耗平台底座。2. 架构全景图四层解耦设计协议解析与业务逻辑彻底分离这个项目最值得细看的不是它用了Spring Boot 2.7.18或MyBatis-Plus 3.5.3而是它怎么把“电表这种嵌入式设备”的通信不确定性塞进Java企业级开发的确定性框架里。整个系统没走微服务但通过清晰的四层抽象实现了高内聚低耦合设备接入层 → 协议适配层 → 业务服务层 → 管理控制台。每一层都可独立替换比如你想把DL/T645-2007解析器换成自己写的JNI加速版只要实现MeterProtocolHandler接口其他层完全无感。这种设计不是为炫技是为应对现场真实困境——某次项目交付时甲方临时要求接入一批国产新电表协议文档只有PDF扫描件我们三天内就替换了协议层业务代码一行没动。2.1 设备接入层TCP长连接池 心跳保活 断线自动重连设备接入不依赖第三方MQTT Broker而是用Netty自建轻量级TCP Server每个电表IP:Port对应一个Channel但Channel不直接绑定业务逻辑。关键在MeterConnectionManager类里维护的连接池// com.meter.core.connection.MeterConnectionManager.java public class MeterConnectionManager { // 每个电表ID对应一个连接上下文含重连计数、最后心跳时间、当前协议类型 private final MapString, MeterContext contextMap new ConcurrentHashMap(); // 使用ScheduledExecutorService定时扫表非阻塞式心跳检测 private final ScheduledExecutorService heartbeatScheduler Executors.newScheduledThreadPool(2, r - new Thread(r, heartbeat-pool-%d)); public void startHeartbeat(String meterId) { MeterContext ctx contextMap.get(meterId); if (ctx null || !ctx.getChannel().isActive()) return; // 发送DL/T645-2007标准心跳帧68 AA AA AA AA AA 68 13 00 DF 16 ByteBuf heartbeat Unpooled.buffer(); heartbeat.writeBytes(new byte[]{0x68, (byte)0xAA, (byte)0xAA, (byte)0xAA, (byte)0xAA, (byte)0xAA, 0x68, 0x13, 0x00, (byte)0xDF, 0x16}); ctx.getChannel().writeAndFlush(heartbeat); } }提示心跳帧末尾的0xDF是控制码表示“无数据请求”避免触发电表响应造成信道拥堵。很多团队翻车在这里——用0x01读数据命令当心跳结果电表真返回数据TCP缓冲区溢出。2.2 协议适配层SPI机制加载协议插件新增电表型号只需加一个JAR协议解析不是if-else硬编码而是基于Java SPIService Provider Interface。项目根目录下有src/main/resources/META-INF/services/com.meter.protocol.MeterProtocolHandler文件内容为com.meter.protocol.dlt645.DLT6452007Handler com.meter.protocol.modbus.ModbusRTUHandler com.meter.protocol.sixnineeight.DL69845Handler每个Handler实现统一接口// com.meter.protocol.MeterProtocolHandler.java public interface MeterProtocolHandler { /** * 解析原始字节数组为标准电表数据对象 * param rawBytes 从TCP Channel收到的原始字节含帧头帧尾校验 * return 解析后的MeterData含电压、电流、有功总电量等字段 * throws ProtocolParseException 当校验失败或字段越界时抛出 */ MeterData parse(byte[] rawBytes) throws ProtocolParseException; /** * 将业务指令如读取当前电量序列化为电表可识别的字节流 * param command 指令类型如 READ_TOTAL_ACTIVE_ENERGY * param address 电表地址6字节BCD码 * return 可直接写入Channel的字节数组 */ byte[] buildCommand(ProtocolCommand command, byte[] address); }新增一款电表只需新建模块meter-protocol-custom实现parse()和buildCommand()打包成JAR丢进lib/目录重启服务即生效。我们曾用此机制快速接入某国产电表其协议在DL/T645-2007基础上增加了2字节厂商私有标识仅需重写parse()中校验逻辑30行代码搞定。2.3 业务服务层状态机驱动的缴费流程拒绝“半截子交易”缴费不是简单的“用户付钱→更新余额”而是严格的状态流转UNPAID → PAYING → PAID_WAITING_CONFIRM → PAID → FAILED。关键在PaymentService的confirmPayment()方法// com.meter.service.PaymentService.java Transactional(rollbackFor Exception.class) public void confirmPayment(String paymentId) { PaymentRecord record paymentMapper.selectById(paymentId); if (!record.getStatus().equals(PaymentStatus.PAYING)) { throw new BusinessException(支付状态异常无法确认); } // 第一步调用电表读取当前总电量防篡改 MeterData current meterDataService.readTotalEnergy(record.getMeterId()); // 第二步比对预充值电量与电表实际增量容忍0.01kWh误差 BigDecimal energyDelta current.getTotalActiveEnergy() .subtract(record.getPreEnergy()); if (energyDelta.compareTo(record.getRechargeEnergy().multiply(BigDecimal.valueOf(0.99))) 0) { // 电表未执行充值触发告警并标记为FAILED alarmService.sendAlarm(电表充值失败, record.getMeterId(), 预充 record.getRechargeEnergy() kWh实增 energyDelta); record.setStatus(PaymentStatus.FAILED); paymentMapper.updateById(record); return; } // 第三步双写账本——更新用户账户余额 记录充值流水 accountService.addBalance(record.getUserId(), record.getAmount()); rechargeLogMapper.insert(buildRechargeLog(record)); record.setStatus(PaymentStatus.PAID); paymentMapper.updateById(record); }这段代码的血泪经验是永远不要相信前端传来的“已支付”状态必须由后端主动向电表发起一次读操作验证物理执行结果。某次因网络抖动支付宝回调成功但电表指令未送达若没这步校验用户余额已扣、电表却没充就是资损事故。3. 核心模块拆解从一条抄表指令到数据入库的完整链路我们以最典型的“每日0点自动抄表”任务为例追踪一条数据如何穿越整个系统。这不是理论路径而是你打断点能看到的真实调用栈。3.1 定时任务触发Quartz集群模式防重复执行抄表任务用Quartz而非SpringScheduled因为后者在多实例部署时会重复执行。application.yml中配置spring: quartz: job-store-type: jdbc jdbc: initialize-schema: never # 表已由flyway初始化 properties: org: quartz: scheduler: instanceName: meter-scheduler instanceId: AUTO jobStore: class: org.quartz.impl.jdbcjobstore.JobStoreTX driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate tablePrefix: QRTZ_ isClustered: true clusterCheckinInterval: 20000对应的Job类DailyMeterReadingJob// com.meter.job.DailyMeterReadingJob.java Component DisallowConcurrentExecution // 防止同一任务实例并发 public class DailyMeterReadingJob implements Job { Override public void execute(JobExecutionContext context) throws JobExecutionException { // 获取所有在线电表从Redis缓存读非DB查询 SetString onlineMeters redisTemplate.opsForSet().members(meter:online:set); // 分片处理按电表ID哈希取模避免单节点压力过大 ListString shard onlineMeters.stream() .filter(id - Math.abs(id.hashCode()) % 4 0) // 当前实例处理0号分片 .collect(Collectors.toList()); // 异步批量读取注意不是CompletableFuture而是线程池submit ExecutorService readerPool Executors.newFixedThreadPool(10); ListFuture? futures new ArrayList(); for (String meterId : shard) { futures.add(readerPool.submit(() - readAndPersist(meterId))); } // 等待全部完成超时120秒 futures.forEach(f - { try { f.get(120, TimeUnit.SECONDS); } catch (Exception e) { log.error(抄表失败, e); } }); } }注意这里用ExecutorService而非Async因为Async的线程池默认无界高并发时OOM风险极高。我们线上将线程池大小设为CPU核数×2并配置拒绝策略为CallerRunsPolicy让主线程自己执行避免任务丢失。3.2 数据解析与校验DL/T645-2007帧结构逐字节还原当Netty收到一帧数据DLT6452007Handler.parse()开始工作。以典型读取当前总电量帧为例68 11 22 33 44 55 68 11 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0......解析核心逻辑public MeterData parse(byte[] rawBytes) throws ProtocolParseException { // 1. 校验帧头帧尾0x68 if (rawBytes[0] ! 0x68 || rawBytes[rawBytes.length-1] ! 0x16) { throw new ProtocolParseException(帧头或帧尾错误); } // 2. 提取地址域6字节位置1~6 byte[] address Arrays.copyOfRange(rawBytes, 1, 7); // 3. 控制码位置70x11表示“读数据响应” if (rawBytes[7] ! 0x11) { throw new ProtocolParseException(非读响应帧控制码 rawBytes[7]); } // 4. 数据长度位置8实际数据域字节数 int dataLen rawBytes[8] 0xFF; // 5. 数据域起始位置 9结束位置 9 dataLen byte[] dataField Arrays.copyOfRange(rawBytes, 9, 9 dataLen); // 6. 关键DL/T645-2007中电量为8字节BCD码需转为BigDecimal BigDecimal totalEnergy bcdToBigDecimal(dataField, 0, 8); // 从dataField第0位开始取8字节 MeterData data new MeterData(); data.setMeterId(bcdToString(address)); // 地址转字符串ID data.setTotalActiveEnergy(totalEnergy); data.setVoltage(parseVoltage(dataField, 8)); // 电压在dataField第8位开始 data.setCurrent(parseCurrent(dataField, 10)); // 电流在第10位 data.setTimestamp(System.currentTimeMillis()); return data; } private BigDecimal bcdToBigDecimal(byte[] bcd, int offset, int len) { StringBuilder sb new StringBuilder(); for (int i offset; i offset len; i) { byte b bcd[i]; sb.append((b 4) 0x0F).append(b 0x0F); // 高4位、低4位分别转数字 } return new BigDecimal(sb.toString()).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); }这段代码的玄学点在于BCD码转换必须严格按字节顺序且小数点位置由协议规定此处是除以100。曾有团队用Integer.parseInt(String.format(%02X, b), 16)导致高位零丢失算出电量比实际小100倍。3.3 数据落库策略批量写入 本地SQLite缓存兜底抄表数据不直接INSERT而是先写入内存队列再由后台线程批量刷盘// com.meter.storage.DataBatchWriter.java Component public class DataBatchWriter { private final BlockingQueueMeterData dataQueue new LinkedBlockingQueue(10000); private final ScheduledExecutorService flushScheduler Executors.newSingleThreadScheduledExecutor(r - new Thread(r, batch-writer)); PostConstruct public void init() { // 每5秒刷一次或队列满1000条立即刷 flushScheduler.scheduleAtFixedRate(this::flushBatch, 0, 5, TimeUnit.SECONDS); } public void write(MeterData data) { try { dataQueue.offer(data, 1, TimeUnit.SECONDS); // 队列满则丢弃避免阻塞采集 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } private void flushBatch() { ListMeterData batch new ArrayList(); dataQueue.drainTo(batch, 1000); // 一次最多取1000条 if (batch.isEmpty()) return; // 先写本地SQLite保证断网不丢数据 localDbService.batchInsert(batch); // 再写MySQL主库异步失败不影响本地 CompletableFuture.runAsync(() - { try { meterDataMapper.insertBatch(batch); } catch (Exception e) { log.error(MySQL写入失败已降级到本地存储, e); // 触发告警运维需手动同步 alarmService.sendAlarm(MySQL批量写入异常, batch-size batch.size(), e.getMessage()); } }); } }提示SQLite文件路径在application.yml中配置为storage.local.db-path/var/lib/meter/local.db生产环境务必挂载SSD盘避免机械硬盘IO瓶颈。4. 运行与部署从源码编译到生产环境三步走别被“Java源码”吓住这项目对环境要求极简但关键配置一步不能错。我们按真实交付流程拆解。4.1 环境准备JDK 8u291 MySQL 5.7 Redis 6.2项目明确要求JDK 8非11或17因为Netty 4.1.73.Final与JDK 11的TLS握手存在兼容问题。验证命令# 检查JDK版本必须输出1.8.0_291 java -version # 检查MySQL必须5.78.0的默认认证插件不兼容 mysql --version # Redis必须6.2因使用了Stream数据结构存告警事件 redis-cli --version注意若用Docker推荐镜像组合FROM openjdk:8u291-jdk-slim RUN apt-get update apt-get install -y mysql-client redis-tools rm -rf /var/lib/apt/lists/* COPY target/meter-platform.jar /app.jar ENTRYPOINT [java,-Xms512m,-Xmx1024m,-jar,/app.jar]4.2 配置文件详解application.yml关键参数说明src/main/resources/application.yml是运行成败的关键重点改以下5处# 1. 数据库连接必须用时区useSSLfalse spring: datasource: url: jdbc:mysql://192.168.1.100:3306/meter_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: meter_user password: your_strong_password # 2. Redis配置用于在线状态、分布式锁 redis: host: 192.168.1.101 port: 6379 password: redis_pass database: 0 # 3. 设备接入参数TCP Server监听端口 meter: server: port: 8081 # 电表主动连接此端口 max-connections: 5000 # 单机最大连接数按电表数量预估 heartbeat-interval: 30 # 心跳间隔秒数 # 4. 批量写入策略直接影响性能 storage: batch-size: 500 # 每次批量写入MySQL的记录数 local-db-path: /data/meter/local.db # SQLite文件绝对路径确保目录可写 # 5. 告警通知集成企业微信机器人 alarm: wecom-webhook: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?keyxxxxxx血泪经验serverTimezoneAsia/Shanghai漏写会导致所有时间字段存成UTC查询报表全乱local-db-path必须是绝对路径且父目录存在否则启动报java.sql.SQLException: path to xxx does not exist。4.3 启动与验证三步确认服务健康启动后不要只看Started Application in X seconds要验证三个关键点第一步检查TCP Server是否监听# 查看8081端口是否LISTEN netstat -tuln | grep :8081 # 正常输出tcp6 0 0 :::8081 :::* LISTEN # 模拟电表连接用telnet测试连通性 telnet 127.0.0.1 8081 # 成功后应看到Connected to localhost第二步验证Redis在线状态缓存# 进入Redis CLI redis-cli -h 192.168.1.101 -a redis_pass # 查询在线电表集合 127.0.0.1:6379 SMEMBERS meter:online:set # 初次启动为空接入电表后应有数据如1) 001122334455第三步触发一次手动抄表并查库# 调用管理接口需先登录获取token curl -X POST http://localhost:8080/api/v1/meter/manual-read \ -H Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... \ -d {meterId:001122334455} # 查MySQL确认数据入库 mysql -u meter_user -p -e SELECT * FROM t_meter_data WHERE meter_id001122334455 ORDER BY create_time DESC LIMIT 1; meter_db # 应返回一条记录total_active_energy字段为非零值5. 避坑指南上线前必须绕开的5个深坑这些不是文档里写的“注意事项”而是我们踩过、修过、被甲方指着鼻子骂过的真问题。每一条都对应一个具体现象、根本原因和可执行解决方案。5.1 现象电表连接后频繁断开日志显示“Connection reset by peer”原因电表厂商固件BUG——当TCP KeepAlive探测包到达时部分国产电表会直接RST连接而非响应。而Linux内核默认KeepAlive间隔是2小时电表在1小时内就休眠了。解决在MeterConnectionManager中关闭系统KeepAlive改用应用层心跳// 在ChannelInitializer中添加 ch.config().setOption(ChannelOption.SO_KEEPALIVE, false); // 关闭系统心跳 // 同时确保heartbeatScheduler定时调用startHeartbeat()并在application.yml中将meter.server.heartbeat-interval设为15秒比电表休眠时间短。5.2 现象MySQL批量插入时报错“Packet for query is too large”原因MyBatis-Plus的insertBatch()默认拼接单条SQL当batch-size500且每条记录含20个字段时SQL长度超MySQL默认max_allowed_packet4M。解决在application.yml中增大MySQL参数并在MyBatis配置中启用分批mybatis-plus: configuration: default-statement-timeout: 60 global-config: db-config: insert-strategy: NOT_NULL # 同时在MySQL服务端执行 # SET GLOBAL max_allowed_packet 64*1024*1024;更彻底的方案是改用JdbcTemplate.batchUpdate()它会自动分片。5.3 现象Redis内存暴涨meter:online:set集合占用超10GB原因电表离线后MeterConnectionManager未及时从Redis移除ID。原逻辑只在Channel关闭时删除但网络闪断时Channel可能卡在CLOSE_WAIT状态ID一直残留。解决增加Redis Key过期机制在startHeartbeat()中设置// 每次心跳成功后重置Redis中该电表的过期时间 redisTemplate.opsForValue().set(meter:last-seen: meterId, System.currentTimeMillis(), 180, TimeUnit.SECONDS); // 后台线程扫描过期Key并清理集合同时修改SMEMBERS为SSCAN分页查询避免大Key阻塞。5.4 现象缴费成功后用户APP显示余额未变但数据库已更新原因前端缓存了用户余额未监听WebSocket推送。而项目默认开启WebSocket推送缴费结果但前端未实现onmessage处理。解决强制前端接入WebSocket后端推送格式为{ type: BALANCE_UPDATE, userId: U1001, newBalance: 125.50, timestamp: 1712345678901 }在application.yml中确认spring.websocket.enabledtrue并检查Nginx反向代理配置是否透传WebSocket头location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; }5.5 现象定时抄表任务在集群环境下重复执行同一电表每天被读两次原因Quartz集群模式下QRTZ_LOCKS表未正确初始化或多个实例配置了相同instanceName。解决检查MySQL中QRTZ_LOCKS表是否存在若无则执行quartz-2.3.2.jar中的tables_mysql_innodb.sql确保每个实例application.yml中org.quartz.scheduler.instanceName唯一如org: quartz: scheduler: instanceName: meter-scheduler-node1 # node2, node3...验证集群状态SELECT * FROM QRTZ_SCHEDULER_STATE; -- 应显示多个INSTANCE_NAME SELECT * FROM QRTZ_LOCKS WHERE LOCK_NAMETRIGGER_ACCESS; -- 应只有一行被锁定6. 二次开发实战如何快速接入新电表型号并支持微信小程序扫码缴费接到新需求“下周要接入XX牌智能水表协议文档已发还要让住户用微信小程序扫电表二维码直接缴费”。不用重写整个平台只需聚焦两个模块改造——协议适配层加水表解析器支付网关层对接微信JSAPI。6.1 接入新水表三步完成协议解析器开发XX牌水表协议基于Modbus RTU但寄存器地址与标准不同。我们复用现有ModbusRTUHandler新建子类// com.meter.protocol.water.xx.XXWaterMeterHandler.java Component public class XXWaterMeterHandler extends ModbusRTUHandler { // 重写寄存器映射标准Modbus中0x0000是电压XX水表中0x0000是累计用水量 Override protected MapInteger, MeterField getRegisterMap() { MapInteger, MeterField map new HashMap(); map.put(0x0000, new MeterField(totalWater, FieldType.UINT32, 2)); // 32位整数占2寄存器 map.put(0x0002, new MeterField(pressure, FieldType.FLOAT32, 2)); // 浮点数 map.put(0x0004, new MeterField(battery, FieldType.UINT16, 1)); // 电池电压 return map; } // 重写校验逻辑XX水表CRC16算法为自定义多项式 Override protected boolean verifyCRC(byte[] frame) { // 使用XX水表文档指定的CRC16-CCITT算法 return crc16CCITT(frame, 0, frame.length - 2) ((frame[frame.length-2] 0xFF) 8) | (frame[frame.length-1] 0xFF); } // 新增水表特有指令读取设备序列号存在0x0010寄存器 Override public byte[] buildCommand(ProtocolCommand command, byte[] address) { if (command ProtocolCommand.READ_SERIAL_NUMBER) { return buildReadRequest(address, 0x0010, 1); // 读1个寄存器 } return super.buildCommand(command, address); } }然后在SPI配置文件META-INF/services/com.meter.protocol.MeterProtocolHandler末尾追加一行com.meter.protocol.water.xx.XXWaterMeterHandler重启服务通过管理后台“设备类型”下拉框即可选择“XX水表”。6.2 微信小程序缴费支付网关扩展与前端联调项目原有支付宝支付微信JSAPI需新增WechatPayService// com.meter.payment.WechatPayService.java Service public class WechatPayService { Value(${wechat.appid}) private String appId; Value(${wechat.mch-id}) private String mchId; Value(${wechat.api-v3-key}) private String apiV3Key; public WechatPayOrder createOrder(String userId, String meterId, BigDecimal amount) { // 1. 生成商户订单号全局唯一 String outTradeNo WX System.currentTimeMillis() RandomStringUtils.randomNumeric(6); // 2. 构造微信统一下单参数 MapString, Object params new HashMap(); params.put(appid, appId); params.put(mch_id, mchId); params.put(nonce_str, RandomStringUtils.randomAlphanumeric(32)); params.put(body, 电表充值); params.put(out_trade_no, outTradeNo); params.put(total_fee, amount.multiply(BigDecimal.valueOf(100)).intValue()); // 分为单位 params.put(spbill_create_ip, 127.0.0.1); params.put(notify_url, https://your-domain.com/api/v1/wechat/notify); params.put(trade_type, JSAPI); params.put(openid, getUserOpenId(userId)); // 从用户表查微信openid // 3. 签名并请求微信API String sign generateSign(params, apiV3Key); params.put(sign, sign); String xml mapToXml(params); String response HttpUtil.post(https://api.mch.weixin.qq.com/pay/unifiedorder, xml); // 4. 解析返回的prepay_id构造小程序调用参数 MapString, String result xmlToMap(response); WechatPayOrder order new WechatPayOrder(); order.setAppId(appId); order.setTimeStamp(String.valueOf(System.currentTimeMillis() / 1000)); order.setNonceStr(RandomStringUtils.randomAlphanumeric(32)); order.setPackage(prepay_id result.get(prepay_id)); order.setSignType(RSA); order.setPaySign(generatePaySign(order, apiV3Key)); return order; } }前端小程序调用示例// pages/pay/pay.js wx.request({ url: https://your-api.com/api/v1/wechat/create-order, method: POST, data: { userId: U1001, meterId: 001122334455, amount: 10.00 }, success: res { const payParams res.data; wx.requestPayment({ timeStamp: payParams.timeStamp, nonceStr: payParams.nonceStr, package: payParams.package, signType: payParams.signType, paySign: payParams.paySign, success: () console.log(支付成功), fail: err console.error(支付失败, err) }) } })6.3 支付结果异步通知幂等处理与状态闭环微信回调地址/api/v1/wechat/notify必须做到幂等因为微信可能多次推送同一通知// com.meter.controller.WechatNotifyController.java PostMapping(/wechat/notify) public String handleWechatNotify(RequestBody String xml) { MapString, String notifyMap xmlToMap(xml); // 1. 校验签名微信官方SDK if (!WXPayUtil.isSignatureValid(notifyMap, apiV3Key)) { return xmlreturn_code![CDATA[FAIL]]/return_codereturn_msg![CDATA[SIGNERROR]]/return_msg/xml; } // 2. 检查out_trade_no是否已处理防重复 String outTradeNo notifyMap.get(out_trade_no); if (paymentMapper.existsByOutTradeNo(outTradeNo)) { return xmlreturn_code![CDATA[SUCCESS]]/return_codereturn_msg![CDATA[OK]]/return_msg/xml; } // 3. 更新支付状态并触发电表充值 PaymentRecord record new PaymentRecord(); record.setOutTradeNo(outTradeNo); record.setStatus(PaymentStatus.PAYING); paymentMapper.updateByOutTradeNo(record); // 4. 异步调用电表充值指令非阻塞 CompletableFuture.runAsync(() - { try { meterCommandService.recharge(record.getMeterId(), record.getAmount()); record.setStatus(PaymentStatus.PAID_WAITING_CONFIRM); paymentMapper.updateById(record); } catch (Exception e) { log.error(电表充值指令发送失败, e); } }); return xmlreturn_code![CDATA[SUCCESS]]/return_codereturn_msg![CDATA[OK]]/return_msg/xml; }从那以后我每次接入新硬件都强制走一遍“协议解析器单元测试→模拟设备连接→压力测试1000并发→线上灰度1%流量”的四步流程哪怕甲方催得再急。因为电表这种物理设备协议文档里没写的坑永远比写了的多十倍。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站