From ee7a3425d06fd6c146d38511e62102706fb17986 Mon Sep 17 00:00:00 2001 From: Guoqs <123@jsowell.com> Date: Tue, 30 Dec 2025 15:59:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=85=85=E7=94=B5=E6=A1=A9?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/JCPP_JSON消息分区消费方案.md | 322 ++++++++ doc/JCPP对接进度文档.md | 710 ++++++++++++++++++ doc/JCPP项目配合实现Prompt.md | 359 +++++++++ doc/Web项目充电桩同步功能实现总结.md | 272 +++++++ doc/充电桩数据同步实现计划.md | 464 ++++++++++++ doc/充电桩数据同步需求.md | 111 +++ .../jcpp/JcppPileSyncController.java | 129 ++++ .../src/main/resources/application-sit.yml | 35 +- .../jcpp/JcppMessageControllerTest.java | 17 +- .../jcpp/PricingModelConverterTest.java | 29 +- jsowell-pile/pom.xml | 35 + .../jsowell/pile/domain/JcppSyncRecord.java | 93 +++ .../jcpp/config/JcppPartitionQueueConfig.java | 82 ++ .../jcpp/consumer/JcppGunStatusConsumer.java | 30 +- .../jcpp/consumer/JcppHeartbeatConsumer.java | 17 +- .../consumer/JcppJsonPartitionConsumer.java | 90 +++ .../pile/jcpp/consumer/JcppLoginConsumer.java | 24 +- .../jcpp/consumer/JcppPricingConsumer.java | 23 +- .../consumer/JcppRealTimeDataConsumer.java | 24 +- .../consumer/JcppRemoteResultConsumer.java | 27 +- .../consumer/JcppSessionCloseConsumer.java | 27 +- .../consumer/JcppStartChargeConsumer.java | 30 +- .../consumer/JcppTransactionConsumer.java | 28 +- .../pile/jcpp/dto/JcppUplinkMessage.java | 4 +- .../pile/jcpp/dto/sync/JcppGunSyncDTO.java | 43 ++ .../pile/jcpp/dto/sync/JcppPileSyncDTO.java | 58 ++ .../pile/jcpp/dto/sync/JcppSyncRequest.java | 38 + .../pile/jcpp/dto/sync/JcppSyncResponse.java | 139 ++++ .../pile/jcpp/dto/sync/JcppSyncResult.java | 59 ++ .../jcpp/service/IJcppJsonMessageHandler.java | 19 + .../jcpp/service/IJcppPileSyncService.java | 36 + .../impl/JcppJsonMessageHandlerImpl.java | 645 ++++++++++++++++ .../service/impl/JcppPileSyncServiceImpl.java | 465 ++++++++++++ .../jcpp/util/JcppPartitionCalculator.java | 97 +++ .../pile/mapper/JcppSyncRecordMapper.java | 69 ++ .../mapper/pile/JcppSyncRecordMapper.xml | 113 +++ pom.xml | 10 +- sql/jcpp_sync_record.sql | 21 + 38 files changed, 4663 insertions(+), 131 deletions(-) create mode 100644 doc/JCPP_JSON消息分区消费方案.md create mode 100644 doc/JCPP对接进度文档.md create mode 100644 doc/JCPP项目配合实现Prompt.md create mode 100644 doc/Web项目充电桩同步功能实现总结.md create mode 100644 doc/充电桩数据同步实现计划.md create mode 100644 doc/充电桩数据同步需求.md create mode 100644 jsowell-admin/src/main/java/com/jsowell/web/controller/jcpp/JcppPileSyncController.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/domain/JcppSyncRecord.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/config/JcppPartitionQueueConfig.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppJsonPartitionConsumer.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppGunSyncDTO.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppPileSyncDTO.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppSyncRequest.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppSyncResponse.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppSyncResult.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppJsonMessageHandler.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppPileSyncService.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppJsonMessageHandlerImpl.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppPileSyncServiceImpl.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/util/JcppPartitionCalculator.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/mapper/JcppSyncRecordMapper.java create mode 100644 jsowell-pile/src/main/resources/mapper/pile/JcppSyncRecordMapper.xml create mode 100644 sql/jcpp_sync_record.sql diff --git a/doc/JCPP_JSON消息分区消费方案.md b/doc/JCPP_JSON消息分区消费方案.md new file mode 100644 index 000000000..4899a8107 --- /dev/null +++ b/doc/JCPP_JSON消息分区消费方案.md @@ -0,0 +1,322 @@ +# JCPP JSON 消息分区消费方案 + +## 概述 + +本方案实现了基于 JSON 格式的 JCPP 消息分区消费,确保同一充电桩(相同 pileCode)的消息按顺序处理,避免并发导致的消息乱序问题。 + +## 核心特性 + +1. **消息顺序保证**:同一充电桩的消息按顺序处理 +2. **分区消费**:10 个分区,基于 pileCode 的 Hash 分区 +3. **单线程消费**:每个分区单线程消费,保证顺序 +4. **JSON 格式**:保持原有 JSON 格式,无需 Protobuf +5. **统一处理**:整合所有消息类型的处理逻辑 + +## 架构设计 + +### 1. 分区策略 + +- **分区数量**:默认 10 个分区(可配置) +- **分区算法**:MurmurHash3_128(与 JCPP 保持一致) +- **分区键**:pileCode(充电桩编码) +- **消费模式**:每个分区单线程消费,保证顺序 + +### 2. 核心组件 + +``` +jsowell-pile/src/main/java/com/jsowell/pile/jcpp/ +├── config/ +│ └── JcppPartitionQueueConfig.java # 分区队列配置 +├── consumer/ +│ └── JcppJsonPartitionConsumer.java # JSON 消息消费者 +├── service/ +│ ├── IJcppJsonMessageHandler.java # 消息处理器接口 +│ └── impl/ +│ └── JcppJsonMessageHandlerImpl.java # 消息处理器实现 +└── util/ + └── JcppPartitionCalculator.java # 分区计算器 +``` + +## 使用说明 + +### 1. 配置文件 + +在 `application-{env}.yml` 中添加: + +```yaml +# JCPP 配置 +jcpp: + rabbitmq: + # 分区数量(与 JCPP 保持一致) + partition-count: 10 + # Exchange 名称 + exchange: jcpp.uplink.exchange + # 队列前缀 + queue-prefix: jcpp.uplink.partition +``` + +### 2. RabbitMQ 队列 + +启动应用时会自动创建以下队列: + +``` +jcpp.uplink.partition.0 +jcpp.uplink.partition.1 +jcpp.uplink.partition.2 +... +jcpp.uplink.partition.9 +``` + +每个队列绑定到 `jcpp.uplink.exchange`,routing key 为 `jcpp.uplink.#` + +### 3. 消息格式 + +消息体为 JSON 格式: + +```json +{ + "pileCode": "20231212000010", + "messageType": "LOGIN", + "data": "{\"pileCode\":\"20231212000010\",\"remoteAddress\":\"192.168.1.100\"}" +} +``` + +**字段说明**: +- `pileCode`:充电桩编码(用于分区计算) +- `messageType`:消息类型(LOGIN, HEARTBEAT, GUN_STATUS, REAL_TIME_DATA, TRANSACTION_RECORD 等) +- `data`:具体消息内容(JSON 字符串) + +### 4. 支持的消息类型 + +| 消息类型 | messageType 值 | 说明 | +|---------|---------------|------| +| 登录请求 | LOGIN | 充电桩登录 | +| 心跳请求 | HEARTBEAT | 心跳保活 | +| 枪状态上报 | GUN_STATUS | 充电枪状态变化 | +| 充电进度 | REAL_TIME_DATA | 实时充电数据 | +| 交易记录 | TRANSACTION_RECORD | 充电完成记录 | +| 启动充电 | START_CHARGE | 刷卡启动充电 | +| 查询计费 | QUERY_PRICING | 查询计费模板 | +| 校验计费 | VERIFY_PRICING | 校验计费模板 | +| 会话关闭 | SESSION_CLOSE | 会话关闭事件 | +| 远程启动结果 | REMOTE_START_RESULT | 远程启动结果 | +| 远程停止结果 | REMOTE_STOP_RESULT | 远程停止结果 | + +## 消息流程 + +### 1. JCPP 发送消息 + +JCPP 在发送消息时需要: + +1. 将消息序列化为 JSON 格式 +2. 根据 pileCode 计算分区编号 +3. 将消息发送到对应的分区队列 + +```java +// JCPP 端示例代码 +String pileCode = "20231212000010"; +int partition = JcppPartitionCalculator.getPartition(pileCode); +String queueName = "jcpp.uplink.partition." + partition; + +// 构建消息 +JcppUplinkMessage message = new JcppUplinkMessage(); +message.setPileCode(pileCode); +message.setMessageType("LOGIN"); +message.setData("{...}"); + +// 发送到指定队列 +rabbitTemplate.convertAndSend("jcpp.uplink.exchange", queueName, message); +``` + +### 2. Web 项目消费消息 + +```java +@Component +public class JcppJsonPartitionConsumer { + + @RabbitListener( + queues = { + "jcpp.uplink.partition.0", + "jcpp.uplink.partition.1", + // ... 其他分区 + }, + concurrency = "1" // 单线程消费保证顺序 + ) + public void consumeMessage(JcppUplinkMessage uplinkMessage) { + // 处理消息 + messageHandler.handleUplinkMessage(uplinkMessage); + } +} +``` + +### 3. 消息处理 + +```java +@Service +public class JcppJsonMessageHandlerImpl implements IJcppJsonMessageHandler { + + @Override + public void handleUplinkMessage(JcppUplinkMessage message) { + // 根据消息类型分发处理 + switch (message.getMessageType()) { + case "LOGIN": + handleLogin(message); + break; + case "HEARTBEAT": + handleHeartbeat(message); + break; + // ... 其他消息类型 + } + } +} +``` + +## 分区算法 + +### Hash 计算 + +使用 MurmurHash3_128 算法计算分区: + +```java +public static int getPartition(String pileCode) { + // 使用 MurmurHash3_128 算法 + long hash = Hashing.murmur3_128() + .hashString(pileCode, StandardCharsets.UTF_8) + .asLong(); + + // 取绝对值并对分区数取模 + return Math.abs((int) (hash % partitionCount)); +} +``` + +### 分区示例 + +假设 `partitionCount = 10`: + +| pileCode | Hash 值 | 分区编号 | +|----------|---------|---------| +| 20231212000010 | 123456789 | 9 | +| 20231212000011 | 987654321 | 1 | +| 20231212000012 | 456789123 | 3 | + +同一个 pileCode 的所有消息都会路由到同一个分区,由单线程顺序处理。 + +## 与原有方案的对比 + +### 原有方案(无分区) + +- ❌ 多个消费者并发消费,可能导致消息乱序 +- ❌ 同一充电桩的消息可能被不同消费者处理 +- ✅ 实现简单,无需额外配置 + +### 新方案(分区消费) + +- ✅ 同一充电桩的消息保证顺序处理 +- ✅ 每个分区单线程消费,避免并发问题 +- ✅ 支持水平扩展(增加分区数量) +- ⚠️ 需要 JCPP 端配合实现分区路由 + +## 监控与运维 + +### 1. 日志监控 + +``` +[分区0] 收到消息: pileCode=20231212000010, messageType=LOGIN +[分区0] 消息处理完成: pileCode=20231212000010 +``` + +### 2. 性能指标 + +- 消息处理延迟 +- 队列堆积数量 +- 消费速率 +- 错误率 + +### 3. 告警规则 + +- 队列堆积超过阈值 +- 消息处理失败率超过阈值 +- 消费延迟超过阈值 + +## 注意事项 + +### 1. 分区数量 + +- 分区数量必须与 JCPP 保持一致 +- 修改分区数量需要重新创建队列 +- 建议在系统初始化时确定分区数量,后续不要修改 + +### 2. 消息顺序 + +- 每个分区单线程消费(`concurrency=1`) +- 同一 pileCode 的消息保证顺序 +- 不同 pileCode 的消息可能并发处理 + +### 3. 异常处理 + +- JSON 解析失败:记录日志并抛出异常 +- 业务处理失败:根据业务需求决定是否重试 +- 建议配置死信队列处理失败消息 + +### 4. 性能优化 + +- 合理设置分区数量(建议 10-20) +- 避免在消息处理中执行耗时操作 +- 使用 Redis 缓存减少数据库压力 + +## 故障排查 + +### 1. 消息无法消费 + +- 检查队列是否创建成功 +- 检查队列绑定是否正确 +- 检查 RabbitMQ 连接是否正常 + +### 2. 消息分区错误 + +- 检查 Hash 算法是否与 JCPP 一致 +- 检查分区数量配置是否一致 +- 查看日志中的分区不匹配警告 + +### 3. 消息处理失败 + +- 查看异常堆栈信息 +- 检查业务逻辑是否正确 +- 检查数据库连接是否正常 + +## 部署步骤 + +### 1. 更新配置 + +在 `application-{env}.yml` 中添加 JCPP 配置 + +### 2. 启动应用 + +```bash +mvn clean package -DskipTests +java -jar jsowell-admin/target/jsowell-admin.jar --spring.profiles.active=sit +``` + +### 3. 验证队列创建 + +登录 RabbitMQ 管理界面,检查是否创建了 10 个分区队列 + +### 4. 发送测试消息 + +使用 JCPP 发送测试消息,验证分区路由是否正确 + +### 5. 监控运行状态 + +查看日志,确认消息正常消费 + +## 参考资料 + +- [Spring AMQP 文档](https://docs.spring.io/spring-amqp/reference/) +- [RabbitMQ 分区队列](https://www.rabbitmq.com/partitions.html) +- [Guava Hashing](https://github.com/google/guava/wiki/HashingExplained) + +--- + +**文档版本**:v2.1(JSON 格式) +**最后更新**:2025-12-30 +**维护人员**:jsowell 团队 diff --git a/doc/JCPP对接进度文档.md b/doc/JCPP对接进度文档.md new file mode 100644 index 000000000..e3a40df6a --- /dev/null +++ b/doc/JCPP对接进度文档.md @@ -0,0 +1,710 @@ +# JCPP 充电桩平台对接进度文档 + +> 文档创建时间:2025-12-30 +> 最后更新时间:2025-12-30 +> 对接平台:JCPP(Java Charging Pile Platform) + +--- + +## 一、项目概述 + +### 1.1 对接目标 +将万车充运营管理平台与 JCPP 充电桩平台进行对接,实现充电桩设备的统一管理和充电业务的互联互通。 + +### 1.2 技术架构 +- **通信方式**:RabbitMQ 消息队列 +- **消息格式**:JSON +- **协议版本**:yunkuaichongV150 +- **Exchange**:`jcpp.uplink.exchange`(上行消息)、`jcpp.downlink.exchange`(下行消息) + +### 1.3 模块位置 +- **核心代码**:`jsowell-pile` 模块 +- **包路径**:`com.jsowell.pile.jcpp` +- **配置类**:`JcppConfig`、`JcppRabbitConfig` + +--- + +## 二、已完成功能 + +### 2.1 基础架构搭建 ✅ + +#### 2.1.1 RabbitMQ 配置 +- [x] 创建 `JcppRabbitConfig` 配置类 +- [x] 定义上行消息 Exchange:`jcpp.uplink.exchange` +- [x] 定义 9 个消息队列和对应的 Routing Key +- [x] 配置队列绑定关系 + +**队列列表**: +| 队列名称 | Routing Key | 用途 | +|---------|-------------|------| +| `jcpp.uplink.login` | `jcpp.uplink.login` | 充电桩登录 | +| `jcpp.uplink.heartbeat` | `jcpp.uplink.heartbeat` | 心跳消息 | +| `jcpp.uplink.startCharge` | `jcpp.uplink.startCharge` | 刷卡启动充电 | +| `jcpp.uplink.realTimeData` | `jcpp.uplink.realTimeData` | 实时充电数据 | +| `jcpp.uplink.transaction` | `jcpp.uplink.transaction` | 交易记录 | +| `jcpp.uplink.gunStatus` | `jcpp.uplink.gunStatus` | 枪状态上报 | +| `jcpp.uplink.pricing` | `jcpp.uplink.pricing.#` | 计费模板查询 | +| `jcpp.uplink.remoteResult` | `jcpp.uplink.remoteResult.#` | 远程操作结果 | +| `jcpp.uplink.sessionClose` | `jcpp.uplink.sessionClose` | 会话关闭 | + +#### 2.1.2 DTO 数据传输对象 +- [x] `JcppUplinkMessage`:上行消息统一封装 +- [x] `JcppDownlinkRequest`:下行请求封装 +- [x] `JcppDownlinkCommand`:下行命令封装 +- [x] `JcppLoginData`:登录数据 +- [x] `JcppStartChargeData`:启动充电数据 +- [x] `JcppRealTimeData`:实时数据 +- [x] `JcppTransactionData`:交易记录数据 +- [x] `JcppPricingModel`:计费模板数据 +- [x] `JcppRemoteStartResultData`:远程启动结果数据 +- [x] `JcppSessionInfo`:会话信息 + +#### 2.1.3 常量定义 +- [x] `JcppConstants`:定义队列名称、Routing Key、消息类型等常量 + +--- + +### 2.2 上行消息处理(JCPP → 万车充)✅ + +#### 2.2.1 充电桩登录 ✅ +**消费者**:`JcppLoginConsumer` +**队列**:`jcpp.uplink.login` +**功能**: +- [x] 接收充电桩登录消息 +- [x] 验证充电桩是否存在于系统中 +- [x] 发送登录应答(通过 `IJcppDownlinkService.sendLoginAck()`) +- [x] 更新充电桩在线状态(已注释,待确认业务逻辑) + +**状态**:✅ 已完成基础功能 + +--- + +#### 2.2.2 心跳消息 ✅ +**消费者**:`JcppHeartbeatConsumer` +**队列**:`jcpp.uplink.heartbeat` +**功能**: +- [x] 接收充电桩心跳消息 +- [x] 更新充电桩最后活跃时间到 Redis(避免频繁写数据库) +- [x] 设置 Redis 过期时间为 180 秒 + +**状态**:✅ 已完成 + +--- + +#### 2.2.3 刷卡启动充电 ✅ +**消费者**:`JcppStartChargeConsumer` +**队列**:`jcpp.uplink.startCharge` +**功能**: +- [x] 接收刷卡启动充电请求 +- [x] 验证充电桩状态 +- [x] 查询授权卡信息(`PileAuthCard`) +- [x] 验证卡状态和密码(密码验证已注释) +- [x] 查询会员钱包余额 +- [x] 检查白名单(免费充电) +- [x] 验证余额是否充足 +- [x] 生成交易流水号(`tradeNo`) +- [x] 创建充电订单(`OrderBasicInfo`) +- [x] 发送鉴权结果应答 + +**状态**:✅ 已完成核心功能 + +--- + +#### 2.2.4 实时充电数据 ✅ +**消费者**:`JcppRealTimeDataConsumer` +**队列**:`jcpp.uplink.realTimeData` +**功能**: +- [x] 接收实时充电数据(电压、电流、SOC、充电量、充电金额等) +- [x] 将实时数据缓存到 Redis(5 分钟过期) +- [x] 根据 `tradeNo` 查询订单 +- [x] 更新订单实时数据(充电量、金额、时长) +- [x] 更新枪状态为充电中 + +**状态**:✅ 已完成 + +--- + +#### 2.2.5 交易记录(订单结算)✅ +**消费者**:`JcppTransactionConsumer` +**队列**:`jcpp.uplink.transaction` +**功能**: +- [x] 接收充电完成的交易记录 +- [x] 根据 `tradeNo` 查询订单 +- [x] 幂等性检查(避免重复处理) +- [x] 更新订单状态为"充电完成" +- [x] 更新订单充电数据(开始时间、结束时间、充电量、金额、停止原因) +- [x] 更新枪状态为空闲 +- [x] 发送交易记录应答 +- [ ] 触发结算流程(已注释,待实现) + +**状态**:✅ 基础功能已完成,⚠️ 结算流程待对接 + +--- + +#### 2.2.6 枪状态上报 ✅ +**消费者**:`JcppGunStatusConsumer` +**队列**:`jcpp.uplink.gunStatus` +**功能**: +- [x] 接收充电枪状态变化消息 +- [x] 状态映射(JCPP 状态 → 系统状态) + - `IDLE` → `1`(空闲) + - `INSERTED` → `2`(占用未充电) + - `CHARGING` → `3`(充电中) + - `CHARGE_COMPLETE` → `2`(充电完成未拔枪) + - `FAULT` → `255`(故障) + - `UNKNOWN` → `0`(离网) +- [x] 更新枪状态到数据库 +- [x] 记录故障信息(日志) + +**状态**:✅ 已完成 + +--- + +#### 2.2.7 计费模板查询 ✅ +**消费者**:`JcppPricingConsumer` +**队列**:`jcpp.uplink.pricing` +**功能**: +- [x] 处理计费模板查询请求(`QUERY_PRICING`) +- [x] 根据充电桩编码查询关联的计费模板 +- [x] 将计费模板转换为 JCPP 格式(`PricingModelConverter`) +- [x] 发送计费模板查询应答 +- [x] 处理计费模板校验请求(`VERIFY_PRICING`) +- [x] 发送计费模板校验应答 + +**状态**:✅ 已完成 + +--- + +#### 2.2.8 远程操作结果 ✅ +**消费者**:`JcppRemoteResultConsumer` +**队列**:`jcpp.uplink.remoteResult` +**功能**: +- [x] 处理远程启动结果(`REMOTE_START_RESULT`) + - 启动成功:更新订单状态为"充电中",更新枪状态 + - 启动失败:更新订单状态为"已取消",记录失败原因 +- [x] 处理远程停止结果(`REMOTE_STOP_RESULT`) + - 记录停止结果日志 + - 等待交易记录消息进行最终结算 +- [ ] 启动失败时触发退款流程(已注释,待实现) + +**状态**:✅ 基础功能已完成,⚠️ 退款流程待实现 + +--- + +#### 2.2.9 会话关闭 ✅ +**消费者**:`JcppSessionCloseConsumer` +**队列**:`jcpp.uplink.sessionClose` +**功能**: +- [x] 接收充电桩会话关闭消息 +- [x] 更新充电桩离线状态(已注释) +- [x] 更新所有枪状态为离线 +- [ ] 查询正在充电的订单并标记为异常(待实现) + +**状态**:✅ 基础功能已完成,⚠️ 异常订单处理待实现 + +--- + +### 2.3 下行消息发送(万车充 → JCPP)✅ + +#### 2.3.1 下行服务接口 +**服务接口**:`IJcppDownlinkService` +**实现类**:`JcppDownlinkServiceImpl` + +**已实现方法**: +- [x] `sendLoginAck()`:发送登录应答 +- [x] `sendStartChargeAck()`:发送启动充电鉴权应答 +- [x] `sendTransactionRecordAck()`:发送交易记录应答 +- [x] `sendQueryPricingAck()`:发送计费模板查询应答 +- [x] `sendVerifyPricingAck()`:发送计费模板校验应答 +- [x] `sendRemoteStart()`:发送远程启动充电命令 +- [x] `sendRemoteStop()`:发送远程停止充电命令 + +**状态**:✅ 已完成核心下行命令 + +--- + +### 2.4 远程充电控制 ✅ + +#### 2.4.1 远程充电服务 +**服务接口**:`IJcppRemoteChargeService` +**实现类**:`JcppRemoteChargeServiceImpl` +**Controller**:`JcppRemoteChargeController`(路径:`/jcpp/remote`) + +**功能**: +- [x] 远程启动充电(`POST /jcpp/remote/start`) + - 验证充电桩和枪状态 + - 验证会员余额 + - 创建充电订单 + - 发送远程启动命令到 JCPP +- [x] 远程停止充电(`POST /jcpp/remote/stop`) + - 查询订单状态 + - 发送远程停止命令到 JCPP + +**状态**:✅ 已完成 + +--- + +### 2.5 工具类 ✅ + +#### 2.5.1 计费模板转换器 +**类名**:`PricingModelConverter` +**功能**: +- [x] 将系统计费模板(`PileBillingTemplate`)转换为 JCPP 格式(`JcppPricingModel`) +- [x] 支持分时电价和服务费配置 +- [x] 单元测试:`PricingModelConverterTest` + +**状态**:✅ 已完成并测试 + +--- + +## 三、已修复的问题 + +### 3.1 消息反序列化错误(第一次修复)✅ + +**问题描述**: +消费 JCPP 发来的消息时报错: +``` +Caused by: com.fasterxml.jackson.databind.exc.MismatchedInputException: +Cannot deserialize value of type `java.lang.String` from Object value (token `JsonToken.START_OBJECT`) +``` + +**原因分析**: +JCPP 发送的消息中,`data` 字段是一个 **JSON 字符串**,而不是 JSON 对象。但在 `JcppUplinkMessage` 中定义为 `Object` 类型,导致 Jackson 反序列化时尝试将 JSON 字符串解析为对象,从而失败。 + +**解决方案**: +1. 修改 `JcppUplinkMessage.data` 字段类型从 `Object` 改为 `String` +2. 修改所有消费者中的数据解析逻辑: + - 原代码:`JSONObject data = JSON.parseObject(JSON.toJSONString(uplinkMessage.getData()));` + - 修改后:`JSONObject data = JSON.parseObject(uplinkMessage.getData());` + +**修复时间**:2025-12-30 +**状态**:✅ 已修复 + +--- + +### 3.2 RabbitMQ 消息转换器冲突(第二次修复)✅ + +**问题描述**: +修复第一个问题后,仍然出现反序列化错误: +``` +Caused by: com.fasterxml.jackson.databind.exc.MismatchedInputException: +Cannot deserialize value of type `java.lang.String` from Object value (token `JsonToken.START_OBJECT`) + at [Source: (String)"{"pileCode":"20231212000010","messageType":"LOGIN","data":"{\n \"messageIdMSB\": ... +``` + +**原因分析**: +消费者方法签名是 `public void handleLogin(String message)`,但 RabbitMQ 配置了 `Jackson2JsonMessageConverter`。当 RabbitMQ 接收到 JSON 对象消息时: +1. `Jackson2JsonMessageConverter` 尝试将 JSON 对象反序列化为 `String` 类型 +2. Jackson 无法将 JSON 对象转换为 String,导致 `MismatchedInputException` + +**根本原因**: +- 消费者期望接收 `String` 类型参数 +- RabbitMQ 配置了 `Jackson2JsonMessageConverter`,会自动进行 JSON 反序列化 +- 两者冲突导致反序列化失败 + +**解决方案**: +将所有消费者的方法参数从 `String message` 改为 `JcppUplinkMessage uplinkMessage`,让 Jackson 自动反序列化为对象。 + +**修改内容**: +1. 修改所有消费者的方法签名: + - 原代码:`public void handleXxx(String message)` + - 修改后:`public void handleXxx(JcppUplinkMessage uplinkMessage)` + +2. 简化消息解析逻辑: + - 删除:`JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class);` + - 直接使用:`uplinkMessage` 参数 + +3. 优化日志输出: + - 原代码:`log.info("收到 JCPP 登录消息: {}", message);` + - 修改后:`log.info("收到 JCPP 登录消息: pileCode={}, messageType={}", uplinkMessage.getPileCode(), uplinkMessage.getMessageType());` + +**修改文件**: +- [x] `JcppLoginConsumer.java`:修改方法签名和日志 +- [x] `JcppHeartbeatConsumer.java`:修改方法签名和日志 +- [x] `JcppStartChargeConsumer.java`:修改方法签名和日志 +- [x] `JcppRealTimeDataConsumer.java`:修改方法签名和日志 +- [x] `JcppTransactionConsumer.java`:修改方法签名和日志 +- [x] `JcppGunStatusConsumer.java`:修改方法签名和日志 +- [x] `JcppPricingConsumer.java`:修改方法签名和日志 +- [x] `JcppRemoteResultConsumer.java`:修改方法签名和日志 +- [x] `JcppSessionCloseConsumer.java`:修改方法签名和日志 + +**优势**: +1. 代码更简洁:不需要手动解析 JSON 字符串 +2. 性能更好:减少一次 JSON 解析操作 +3. 类型安全:编译时就能发现类型错误 +4. 符合 Spring AMQP 最佳实践:利用消息转换器自动反序列化 + +**修复时间**:2025-12-30 +**状态**:✅ 已修复 + +--- + +## 四、待完成功能 + +### 4.1 订单结算流程 ⚠️ + +**优先级**:高 +**描述**: +在 `JcppTransactionConsumer` 中,交易记录处理完成后需要触发订单结算流程,包括: +- 计算实际费用(电费 + 服务费) +- 扣除会员余额或赠金 +- 执行分账(商户、平台、站点等) +- 发放积分奖励 +- 生成清算账单 + +**待实现**: +```java +// TODO: 触发结算流程 +// orderBasicInfoService.realTimeOrderSplit(order.getId()); +``` + +**相关类**: +- `OrderBasicInfoServiceImpl.realTimeOrderSplit()` +- `StationSplitConfigService` +- `OrderSplitRecordService` +- `PointsRewardProducer` + +--- + +### 4.2 远程启动失败退款 ⚠️ + +**优先级**:中 +**描述**: +在 `JcppRemoteResultConsumer` 中,如果远程启动失败且用户已预付费,需要触发退款流程。 + +**待实现**: +```java +// TODO: 如果已预付费,触发退款流程 +``` + +**相关类**: +- `MemberWalletInfoService` +- `MemberWalletLogService` +- 第三方支付退款接口(微信、支付宝) + +--- + +### 4.3 会话关闭异常订单处理 ⚠️ + +**优先级**:中 +**描述**: +在 `JcppSessionCloseConsumer` 中,当充电桩会话关闭时,需要查询是否有正在充电的订单,如果有则标记为异常结束。 + +**待实现**: +```java +// TODO: 查询是否有正在充电的订单,如果有则标记为异常 +// 可以调用 OrderBasicInfoService 查询并处理 +``` + +**相关类**: +- `OrderBasicInfoService` +- `OrderAbnormalRecordService` + +--- + +### 4.4 充电桩在线状态管理 ⚠️ + +**优先级**:低 +**描述**: +目前在登录和会话关闭时更新充电桩在线状态的代码已注释,需要确认业务逻辑后启用。 + +**待确认**: +- 是否需要实时更新充电桩在线状态到数据库? +- 还是仅通过 Redis 心跳判断在线状态? + +**相关代码**: +- `JcppLoginConsumer.java:56-59`(已注释) +- `JcppSessionCloseConsumer.java:59`(已注释) + +--- + +### 4.5 枪故障信息持久化 ⚠️ + +**优先级**:低 +**描述**: +在 `JcppGunStatusConsumer` 中,当接收到枪故障信息时,目前仅记录日志,需要将故障信息保存到数据库或发送告警。 + +**待实现**: +```java +// TODO: 可以将故障信息保存到数据库或发送告警 +``` + +**相关表**: +- `pile_connector_info`(可能需要添加故障信息字段) +- 或创建新表 `pile_connector_fault_record` + +--- + +### 4.6 消息服务接口 ⚠️ + +**优先级**:低 +**描述**: +`IJcppMessageService` 和 `JcppMessageServiceImpl` 已创建但功能未完善,可能用于消息查询、重发等功能。 + +**待实现**: +- 消息历史查询 +- 消息重发机制 +- 消息统计分析 + +--- + +## 五、测试情况 + +### 5.1 单元测试 +- [x] `PricingModelConverterTest`:计费模板转换器测试 +- [ ] 其他消费者单元测试(待补充) + +### 5.2 集成测试 +- [ ] 充电桩登录流程测试 +- [ ] 刷卡启动充电流程测试 +- [ ] 远程启动充电流程测试 +- [ ] 充电过程实时数据测试 +- [ ] 充电完成结算流程测试 +- [ ] 异常场景测试(网络断开、超时等) + +--- + +## 六、部署配置 + +### 6.1 RabbitMQ 配置 + +**配置文件**:`application-{env}.yml` + +```yaml +spring: + rabbitmq: + host: ${RABBITMQ_HOST} + port: 5672 + username: ${RABBITMQ_USERNAME} + password: ${RABBITMQ_PASSWORD} + virtual-host: / + listener: + simple: + acknowledge-mode: auto # 自动应答模式 + concurrency: 1 + max-concurrency: 20 + prefetch: 1 + retry: + enabled: true + max-attempts: 3 + initial-interval: 1s + max-interval: 10s + multiplier: 2.0 +``` + +### 6.2 JCPP 配置 + +**配置类**:`JcppConfig` + +```java +@Configuration +public class JcppConfig { + // JCPP 平台相关配置 + // 如:API 地址、认证信息等 +} +``` + +--- + +## 七、注意事项 + +### 7.1 消息幂等性 +- 所有消费者都需要做幂等性检查,避免重复处理 +- 目前 `JcppTransactionConsumer` 已实现幂等性检查 +- 其他消费者需要根据业务场景补充 + +### 7.2 事务管理 +- 涉及数据库操作的消费者需要添加 `@Transactional` 注解 +- 目前 `JcppStartChargeConsumer`、`JcppTransactionConsumer`、`JcppRemoteResultConsumer` 已添加 + +### 7.3 异常处理 +- 所有消费者都需要捕获异常并记录日志 +- 避免异常导致消息丢失或重复消费 + +### 7.4 性能优化 +- 实时数据和心跳消息使用 Redis 缓存,避免频繁写数据库 +- 考虑使用批量更新减少数据库压力 + +--- + +## 八、后续计划 + +### 8.1 短期计划(1-2 周) +1. 完成订单结算流程对接 +2. 实现远程启动失败退款功能 +3. 完善会话关闭异常订单处理 +4. 补充单元测试和集成测试 + +### 8.2 中期计划(1 个月) +1. 优化消息处理性能 +2. 完善监控和告警机制 +3. 补充故障信息持久化功能 +4. 完善消息服务接口 + +### 8.3 长期计划(3 个月) +1. 支持更多 JCPP 协议功能 +2. 实现消息重发和补偿机制 +3. 优化数据统计和报表功能 +4. 完善运维工具和管理界面 + +--- + +## 九、联系人 + +- **开发负责人**:待补充 +- **测试负责人**:待补充 +- **运维负责人**:待补充 + +--- + +## 十、变更记录 + +| 日期 | 版本 | 修改人 | 修改内容 | +|------|------|--------|----------| +| 2025-12-30 | v1.0 | Claude | 创建文档,记录当前对接进度 | +| 2025-12-30 | v1.1 | Claude | 修复消息反序列化错误(第一次) | +| 2025-12-30 | v1.2 | Claude | 修复 RabbitMQ 消息转换器冲突(第二次) | +| 2025-12-30 | v2.1 | Claude | 实现 JSON 消息分区消费方案 | + +--- + +## 十一、JSON 消息分区消费方案(v2.1 新增) + +### 11.1 方案概述 + +为了确保同一充电桩的消息按顺序处理,实现了基于 JSON 格式的分区消费方案: + +- **消息格式**:保持原有 JSON 格式,无需 Protobuf +- **分区策略**:基于 pileCode 的 Hash 分区 +- **分区数量**:10 个分区(可配置) +- **Hash 算法**:MurmurHash3_128(与 JCPP 保持一致) +- **消费模式**:每个分区单线程消费,保证顺序 + +### 11.2 核心组件 + +#### 11.2.1 分区计算器 + +**类名**:`JcppPartitionCalculator` +**功能**: +- 使用 MurmurHash3_128 算法计算分区 +- 根据 pileCode 计算分区编号(0 到 partitionCount-1) +- 提供队列名称生成方法 + +**核心代码**: +```java +public static int getPartition(String pileCode) { + long hash = Hashing.murmur3_128() + .hashString(pileCode, StandardCharsets.UTF_8) + .asLong(); + return Math.abs((int) (hash % partitionCount)); +} +``` + +#### 11.2.2 分区队列配置 + +**类名**:`JcppPartitionQueueConfig` +**功能**: +- 创建 10 个分区队列(`jcpp.uplink.partition.0` ~ `jcpp.uplink.partition.9`) +- 创建 Topic Exchange(`jcpp.uplink.exchange`) +- 绑定队列到 Exchange(routing key: `jcpp.uplink.#`) + +#### 11.2.3 JSON 消息消费者 + +**类名**:`JcppJsonPartitionConsumer` +**功能**: +- 监听所有分区队列 +- 单线程消费(`concurrency=1`)保证顺序 +- 自动反序列化 JSON 消息为 `JcppUplinkMessage` 对象 +- 验证分区是否正确 +- 调用消息处理器处理消息 + +#### 11.2.4 消息处理器 + +**接口**:`IJcppJsonMessageHandler` +**实现类**:`JcppJsonMessageHandlerImpl` +**功能**: +- 根据消息类型分发处理 +- 整合原有所有消费者的处理逻辑 +- 处理登录、心跳、枪状态、充电进度、交易记录、刷卡启动、计费查询、会话关闭、远程操作结果等消息 +- 复用原有业务逻辑(`PileBasicInfoService`、`OrderBasicInfoService` 等) + +### 11.3 技术栈 + +- **Guava**:33.0.0-jre(提供 MurmurHash3_128) +- **Spring AMQP**:2.5.14 +- **RabbitMQ**:5672 +- **FastJSON2**:2.0.23 + +### 11.4 配置说明 + +**配置文件**:`application-{env}.yml` + +```yaml +# JCPP 配置 +jcpp: + rabbitmq: + # 分区数量(与 JCPP 保持一致) + partition-count: 10 + # Exchange 名称 + exchange: jcpp.uplink.exchange + # 队列前缀 + queue-prefix: jcpp.uplink.partition +``` + +### 11.5 消息格式 + +```json +{ + "pileCode": "20231212000010", + "messageType": "LOGIN", + "data": "{\"pileCode\":\"20231212000010\",\"remoteAddress\":\"192.168.1.100\"}" +} +``` + +**字段说明**: +- `pileCode`:充电桩编码(用于分区计算) +- `messageType`:消息类型 +- `data`:具体消息内容(JSON 字符串) + +### 11.6 优势 + +1. **消息顺序保证**:同一充电桩的消息按顺序处理 +2. **简单易用**:保持原有 JSON 格式,无需 Protobuf +3. **统一处理**:整合所有消息类型的处理逻辑到一个处理器 +4. **易于调试**:JSON 格式可读性强,便于排查问题 +5. **可扩展性**:支持动态调整分区数量 + +### 11.7 与原有方案的对比 + +| 特性 | 原有方案(无分区) | 新方案(分区消费) | +|------|------------------|------------------| +| 消息格式 | JSON 字符串 | JSON 字符串 | +| 消息顺序 | 不保证 | 保证同一 pileCode 顺序 | +| 分区消费 | 不支持 | 支持 | +| 并发处理 | 多消费者并发 | 每个分区单线程 | +| 代码复杂度 | 多个独立消费者 | 统一消息处理器 | +| 易于调试 | 较难 | 较易 | + +### 11.8 部署步骤 + +1. **更新配置**:在 `application-{env}.yml` 中添加 JCPP 配置 +2. **启动应用**:应用会自动创建 10 个分区队列 +3. **JCPP 端配置**:确保 JCPP 使用相同的 Hash 算法和分区数量 +4. **验证测试**:发送测试消息,验证分区路由和消息处理 + +### 11.9 注意事项 + +1. **分区数量**:必须与 JCPP 保持一致,建议在系统初始化时确定,后续不要修改 +2. **Hash 算法**:必须使用 MurmurHash3_128,与 JCPP 保持一致 +3. **消息顺序**:每个分区单线程消费(`concurrency=1`),不要修改此配置 +4. **异常处理**:JSON 解析失败会抛出异常,建议配置死信队列 +5. **性能监控**:监控队列堆积、消费延迟、错误率等指标 + +### 11.10 相关文档 + +详细使用说明请参考:[JCPP JSON 消息分区消费方案](./JCPP_JSON消息分区消费方案.md) + +--- + +## 十、变更记录 diff --git a/doc/JCPP项目配合实现Prompt.md b/doc/JCPP项目配合实现Prompt.md new file mode 100644 index 000000000..0561b567b --- /dev/null +++ b/doc/JCPP项目配合实现Prompt.md @@ -0,0 +1,359 @@ +# JCPP 项目充电桩数据同步接口实现 Prompt + +> 请将以下内容复制给 JCPP 项目的 Claude Code 执行 + +--- + +## 任务概述 + +需要在 JCPP 项目中实现充电桩数据同步接口,用于接收来自 Web 项目的充电桩和充电枪数据。 + +## 背景说明 + +Web 项目(MySQL)中维护了充电桩的主数据,现在需要将这些数据同步到 JCPP 项目(PostgreSQL)中,用于登录鉴权等操作。 + +### 数据流向 +``` +Web 项目 (MySQL) → HTTP API → JCPP 项目 (PostgreSQL) +``` + +### 关键差异 +1. **主键类型**:Web 使用 int,JCPP 使用 uuid +2. **station_id 类型**:Web 是 int,JCPP 是 uuid(需要映射) +3. **字段名称**:sn → pile_code, name → pile_name + +--- + +## 需要实现的功能 + +### 1. 充电桩同步接口 + +**接口路径**:`POST /api/sync/piles` + +**重要说明**: +- ✅ 所有充电桩的 `station_id` 统一使用固定值:`88bca8da-cdbf-6587-aecc-75784838c501` +- Web 项目的原始 station_id 保存在 `additionalInfo.webStationId` 中便于追溯 + +**请求格式**: +```json +{ + "piles": [ + { + "pileCode": "20231212000010", + "pileName": "1号充电桩", + "protocol": "yunkuaichongV150", + "brand": "特来电", + "model": "AC-7KW", + "manufacturer": "特来电", + "type": "OPERATION", + "additionalInfo": { + "webPileId": 6844, + "webStationId": 123, + "businessType": "1", + "secretKey": "abc123", + "longitude": "116.404", + "latitude": "39.915", + "iccid": "89860123456789012345" + } + } + ] +} +``` + +**字段说明**: +- `pileCode`:充电桩编码(对应 Web 的 sn),唯一标识 +- `pileName`:充电桩名称(对应 Web 的 name) +- `protocol`:软件协议(对应 Web 的 software_protocol) +- `brand`:品牌(可为空) +- `model`:型号(可为空) +- `manufacturer`:制造商(可为空) +- `type`:类型,枚举值: + - `OPERATION`:运营桩(对应 Web 的 business_type = "1") + - `PERSONAL`:个人桩(对应 Web 的 business_type = "2") +- `additionalInfo`:附加信息(JSON 格式),包含: + - `webPileId`:Web 项目的充电桩 ID + - `webStationId`:Web 项目的充电站 ID(原始 int 值) + - 其他 Web 项目的字段 + +**响应格式**: +```json +{ + "success": true, + "message": "同步成功", + "results": [ + { + "pileCode": "20231212000010", + "pileId": "550e8400-e29b-41d4-a716-446655440000", + "success": true, + "message": "创建成功" + } + ] +} +``` + +**处理逻辑**: +1. 遍历 `piles` 数组 +2. 对于每个充电桩: + - 根据 `pileCode` 查询 `t_pile` 表,判断是否已存在 + - **station_id 统一使用固定值**:`88bca8da-cdbf-6587-aecc-75784838c501` + - 如果充电桩已存在,执行 UPDATE 操作 + - 如果充电桩不存在,执行 INSERT 操作(生成新的 uuid) + - 将 `additionalInfo` 存储为 jsonb 类型 +3. 返回每个充电桩的处理结果 + +**注意事项**: +- 使用事务保证数据一致性 +- pile_code 必须唯一(已有唯一索引) +- 所有充电桩的 station_id 都使用固定值 `88bca8da-cdbf-6587-aecc-75784838c501` + +--- + +### 2. 充电枪同步接口 + +**接口路径**:`POST /api/sync/guns` + +**重要说明**: +- ✅ 所有充电枪的 `station_id` 统一使用固定值:`88bca8da-cdbf-6587-aecc-75784838c501` + +**请求格式**: +```json +{ + "guns": [ + { + "gunCode": "2023121200001001", + "gunName": "1号枪", + "gunNo": "01", + "pileCode": "20231212000010", + "additionalInfo": { + "webGunId": 30060, + "status": "1", + "parkNo": "A01" + } + } + ] +} +``` + +**字段说明**: +- `gunCode`:充电枪编码(对应 Web 的 pile_connector_code),唯一标识 +- `gunName`:充电枪名称(对应 Web 的 name) +- `gunNo`:枪号(从 gunCode 提取最后 2 位) +- `pileCode`:所属充电桩编码(对应 Web 的 pile_sn) +- `additionalInfo`:附加信息(JSON 格式),包含 Web 项目的其他字段 + +**响应格式**: +```json +{ + "success": true, + "message": "同步成功", + "results": [ + { + "gunCode": "2023121200001001", + "gunId": "660e8400-e29b-41d4-a716-446655440000", + "success": true, + "message": "创建成功" + } + ] +} +``` + +**处理逻辑**: +1. 遍历 `guns` 数组 +2. 对于每个充电枪: + - 根据 `gunCode` 查询 `t_gun` 表,判断是否已存在 + - 根据 `pileCode` 查询 `t_pile` 表,获取 `pile_id` (uuid) + - 如果充电桩不存在,记录错误并跳过该充电枪 + - **station_id 统一使用固定值**:`88bca8da-cdbf-6587-aecc-75784838c501` + - 如果充电枪已存在,执行 UPDATE 操作 + - 如果充电枪不存在,执行 INSERT 操作(生成新的 uuid) +3. 返回每个充电枪的处理结果 + +**注意事项**: +- 充电桩必须先于充电枪同步 +- gun_code 必须唯一(已有唯一索引) +- (pile_id, gun_no) 组合必须唯一(已有唯一索引) +- 所有充电枪的 station_id 都使用固定值 `88bca8da-cdbf-6587-aecc-75784838c501` + +--- + +## 实现建议 + +### 技术栈 +- **框架**:Spring Boot +- **数据库**:PostgreSQL 17 +- **ORM**:JPA / MyBatis +- **JSON 处理**:Jackson / Gson + +### 代码结构建议 + +``` +src/main/java/com/jcpp/ +├── controller/ +│ └── SyncController.java # 同步接口 Controller +├── service/ +│ ├── PileSyncService.java # 充电桩同步服务接口 +│ └── GunSyncService.java # 充电枪同步服务接口 +├── service/impl/ +│ ├── PileSyncServiceImpl.java +│ └── GunSyncServiceImpl.java +├── entity/ +│ ├── Pile.java # t_pile 实体 +│ └── Gun.java # t_gun 实体 +├── repository/ +│ ├── PileRepository.java +│ └── GunRepository.java +└── dto/ + ├── PileSyncDTO.java # 充电桩同步 DTO + ├── GunSyncDTO.java # 充电枪同步 DTO + ├── SyncRequest.java # 同步请求 + └── SyncResponse.java # 同步响应 +``` + +### 关键代码示例 + +**PileSyncService 接口**: +```java +public interface PileSyncService { + /** + * 同步充电桩数据 + * @param piles 充电桩列表 + * @return 同步结果 + */ + SyncResponse syncPiles(List piles); +} +``` + +**处理逻辑伪代码**: +```java +@Transactional +public SyncResponse syncPiles(List piles) { + List results = new ArrayList<>(); + + // 固定的 station_id + UUID FIXED_STATION_ID = UUID.fromString("88bca8da-cdbf-6587-aecc-75784838c501"); + + for (PileSyncDTO dto : piles) { + try { + // 1. 查询充电桩是否存在 + Pile existingPile = pileRepository.findByPileCode(dto.getPileCode()); + + if (existingPile != null) { + // 更新 + existingPile.setPileName(dto.getPileName()); + existingPile.setProtocol(dto.getProtocol()); + existingPile.setStationId(FIXED_STATION_ID); // 使用固定值 + existingPile.setBrand(dto.getBrand()); + existingPile.setModel(dto.getModel()); + existingPile.setManufacturer(dto.getManufacturer()); + existingPile.setType(dto.getType()); + existingPile.setAdditionalInfo(dto.getAdditionalInfo()); + existingPile.setUpdatedTime(new Date()); + pileRepository.save(existingPile); + results.add(SyncResult.success(dto.getPileCode(), existingPile.getId(), "更新成功")); + } else { + // 创建 + Pile newPile = new Pile(); + newPile.setId(UUID.randomUUID()); + newPile.setPileCode(dto.getPileCode()); + newPile.setPileName(dto.getPileName()); + newPile.setProtocol(dto.getProtocol()); + newPile.setStationId(FIXED_STATION_ID); // 使用固定值 + newPile.setBrand(dto.getBrand()); + newPile.setModel(dto.getModel()); + newPile.setManufacturer(dto.getManufacturer()); + newPile.setType(dto.getType()); + newPile.setAdditionalInfo(dto.getAdditionalInfo()); + newPile.setCreatedTime(new Date()); + pileRepository.save(newPile); + results.add(SyncResult.success(dto.getPileCode(), newPile.getId(), "创建成功")); + } + } catch (Exception e) { + results.add(SyncResult.fail(dto.getPileCode(), e.getMessage())); + } + } + + return SyncResponse.build(results); +} +``` + +--- + +## 测试建议 + +### 1. 单元测试 +- 测试 station_id 映射查询 +- 测试充电桩创建 +- 测试充电桩更新 +- 测试充电枪创建 +- 测试充电枪更新 + +### 2. 集成测试 +- 测试完整的同步流程 +- 测试异常场景(映射不存在、充电桩不存在等) +- 测试并发同步 + +### 3. 性能测试 +- 测试批量同步性能(100、500、1000 条数据) +- 测试数据库连接池配置 + +--- + +## 注意事项 + +1. **事务处理** + - 每批数据使用一个事务 + - 单个充电桩失败不影响其他充电桩 + +2. **错误处理** + - 记录详细的错误信息 + - 返回明确的错误原因 + +3. **日志记录** + - 记录同步开始和结束时间 + - 记录成功和失败的数量 + - 记录详细的错误日志 + +4. **性能优化** + - 使用批量查询减少数据库访问 + - 合理设置数据库连接池大小 + - 考虑使用缓存优化映射查询 + +5. **安全性** + - 添加接口鉴权 + - 验证请求数据的合法性 + - 防止 SQL 注入 + +--- + +## 交付物 + +1. **代码** + - Controller、Service、Repository、Entity、DTO 等完整代码 + - 单元测试代码 + +2. **接口文档** + - Swagger / OpenAPI 文档 + - 接口调用示例 + +3. **部署说明** + - 配置项说明 + - 部署步骤 + +--- + +## 联调准备 + +完成实现后,请提供: +1. 接口地址(如:http://jcpp-server:8080/api/sync) +2. 测试账号(如果需要鉴权) +3. 测试数据示例 + +**重要提醒**: +- 确保 JCPP 数据库中已存在 station_id 为 `88bca8da-cdbf-6587-aecc-75784838c501` 的充电站记录 +- 如果不存在,需要先创建该充电站记录 + +--- + +## 问题反馈 + +如有任何问题或需要澄清的地方,请及时反馈。 diff --git a/doc/Web项目充电桩同步功能实现总结.md b/doc/Web项目充电桩同步功能实现总结.md new file mode 100644 index 000000000..93231599d --- /dev/null +++ b/doc/Web项目充电桩同步功能实现总结.md @@ -0,0 +1,272 @@ +# Web 项目充电桩数据同步功能实现总结 + +> 完成时间:2025-12-30 +> 状态:✅ Web 项目部分已完成,等待 JCPP 项目配合 + +--- + +## 一、已完成功能 + +### 1. DTO 数据传输对象 ✅ + +**位置**:`jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/` + +| 文件名 | 说明 | +|--------|------| +| `JcppPileSyncDTO.java` | 充电桩同步数据 DTO | +| `JcppGunSyncDTO.java` | 充电枪同步数据 DTO | +| `JcppSyncRequest.java` | 同步请求 DTO | +| `JcppSyncResponse.java` | 同步响应 DTO | +| `JcppSyncResult.java` | 单个同步结果 DTO | + +### 2. 数据库表和 Mapper ✅ + +**建表 SQL**:`sql/jcpp_sync_record.sql` + +**Domain 实体**:`jsowell-pile/src/main/java/com/jsowell/pile/domain/JcppSyncRecord.java` + +**Mapper 接口**:`jsowell-pile/src/main/java/com/jsowell/pile/mapper/JcppSyncRecordMapper.java` + +**Mapper XML**:`jsowell-pile/src/main/resources/mapper/pile/JcppSyncRecordMapper.xml` + +### 3. 同步服务 ✅ + +**服务接口**:`jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppPileSyncService.java` + +**服务实现**:`jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppPileSyncServiceImpl.java` + +**核心功能**: +- ✅ 全量同步充电桩数据 +- ✅ 增量同步充电桩数据(基于 update_time) +- ✅ 单个充电桩同步 +- ✅ 数据格式转换(Web → JCPP) +- ✅ 调用 JCPP 同步接口 +- ✅ 同步记录管理 + +### 4. Controller 接口 ✅ + +**控制器**:`jsowell-admin/src/main/java/com/jsowell/web/controller/jcpp/JcppPileSyncController.java` + +**API 接口**: + +| 方法 | 路径 | 说明 | 权限 | +|------|------|------|------| +| POST | `/jcpp/sync/full` | 全量同步 | `jcpp:sync:full` | +| POST | `/jcpp/sync/incremental` | 增量同步 | `jcpp:sync:incremental` | +| POST | `/jcpp/sync/pile/{pileSn}` | 单个充电桩同步 | `jcpp:sync:single` | +| GET | `/jcpp/sync/records` | 查询同步记录列表 | `jcpp:sync:list` | +| GET | `/jcpp/sync/records/{id}` | 查询同步记录详情 | `jcpp:sync:query` | + +### 5. 配置项 ✅ + +**配置文件**:`jsowell-admin/src/main/resources/application-sit.yml` + +```yaml +jcpp: + sync: + # JCPP 同步接口地址 + api-url: http://localhost:8080/api/sync + # 批量同步大小 + batch-size: 100 + # 超时时间(毫秒) + timeout: 60000 + # 是否启用自动增量同步 + auto-sync-enabled: false + # 自动同步间隔(分钟) + auto-sync-interval: 30 +``` + +--- + +## 二、核心实现逻辑 + +### 1. 数据转换逻辑 + +#### 充电桩数据转换 +```java +Web (pile_basic_info) → JCPP (t_pile) +- sn → pileCode +- name → pileName +- software_protocol → protocol +- business_type → type (1→OPERATION, 2→PERSONAL) +- 其他字段 → additionalInfo (JSON) +- station_id → 固定值 '88bca8da-cdbf-6587-aecc-75784838c501' +``` + +#### 充电枪数据转换 +```java +Web (pile_connector_info) → JCPP (t_gun) +- pile_connector_code → gunCode +- name → gunName +- pile_sn → pileCode +- 最后2位 → gunNo +- 其他字段 → additionalInfo (JSON) +- station_id → 固定值 '88bca8da-cdbf-6587-aecc-75784838c501' +``` + +### 2. 同步流程 + +#### 全量同步 +1. 创建同步记录(状态:RUNNING) +2. 查询所有未删除的充电桩和充电枪 +3. 转换数据格式 +4. 调用 JCPP 同步接口 + - 先同步充电桩(POST /api/sync/piles) + - 再同步充电枪(POST /api/sync/guns) +5. 更新同步记录(状态:SUCCESS/FAILED) + +#### 增量同步 +1. 获取上次同步时间(参数或查询最后一次成功记录) +2. 查询 update_time > lastSyncTime 的数据 +3. 其他步骤同全量同步 + +#### 单个充电桩同步 +1. 查询指定充电桩及其所有充电枪 +2. 转换数据格式 +3. 调用 JCPP 同步接口 + +### 3. 错误处理 + +- ✅ 接口调用异常捕获 +- ✅ 单个数据失败不影响其他数据 +- ✅ 详细的错误信息记录 +- ✅ 同步记录状态管理 + +--- + +## 三、使用说明 + +### 1. 数据库初始化 + +执行建表 SQL: +```bash +mysql -u username -p database_name < sql/jcpp_sync_record.sql +``` + +### 2. 配置 JCPP 接口地址 + +修改 `application-sit.yml`: +```yaml +jcpp: + sync: + api-url: http://jcpp-server:8080/api/sync # 修改为实际地址 +``` + +### 3. API 调用示例 + +#### 全量同步 +```bash +curl -X POST http://localhost:8080/jcpp/sync/full \ + -H "Authorization: Bearer {token}" +``` + +#### 增量同步 +```bash +curl -X POST "http://localhost:8080/jcpp/sync/incremental?lastSyncTime=2025-12-30 10:00:00" \ + -H "Authorization: Bearer {token}" +``` + +#### 单个充电桩同步 +```bash +curl -X POST http://localhost:8080/jcpp/sync/pile/20231212000010 \ + -H "Authorization: Bearer {token}" +``` + +#### 查询同步记录 +```bash +curl -X GET "http://localhost:8080/jcpp/sync/records?pageNum=1&pageSize=10" \ + -H "Authorization: Bearer {token}" +``` + +--- + +## 四、待完成事项 + +### 1. JCPP 项目需要实现 ⏳ + +请参考文档:`doc/JCPP项目配合实现Prompt.md` + +需要实现的接口: +- [ ] POST /api/sync/piles - 充电桩同步接口 +- [ ] POST /api/sync/guns - 充电枪同步接口 + +**重要提醒**: +- 所有充电桩和充电枪的 station_id 统一使用固定值:`88bca8da-cdbf-6587-aecc-75784838c501` +- 确保 JCPP 数据库中已存在该 station_id 的充电站记录 + +### 2. 联调测试 ⏳ + +- [ ] 测试全量同步功能 +- [ ] 测试增量同步功能 +- [ ] 测试单个充电桩同步 +- [ ] 测试异常场景(网络异常、数据异常等) +- [ ] 性能测试(大批量数据同步) + +### 3. 优化项(可选) + +- [ ] 添加按 update_time 查询的 Mapper 方法(提升增量同步性能) +- [ ] 添加重试机制(网络异常时自动重试) +- [ ] 添加同步进度查询(异步任务) +- [ ] 添加定时自动同步(基于配置) +- [ ] 添加同步数据校验(数据完整性检查) + +--- + +## 五、技术要点 + +### 1. 固定 station_id 方案 + +**优势**: +- ✅ 简化实现,无需维护映射表 +- ✅ 减少接口调用次数 +- ✅ 降低系统复杂度 + +**注意事项**: +- Web 的原始 station_id 保存在 additionalInfo.webStationId 中 +- 便于后续追溯和数据分析 + +### 2. 数据转换 + +**business_type 映射**: +- "1" → "OPERATION"(运营桩) +- "2" → "PERSONAL"(个人桩) + +**gun_no 提取**: +- pile_connector_code: "2023121200001001" +- gun_no: "01"(最后 2 位) + +### 3. 同步记录 + +**状态**: +- RUNNING:进行中 +- SUCCESS:成功 +- FAILED:失败 + +**统计信息**: +- 总数、成功数、失败数(充电桩和充电枪分别统计) +- 错误信息列表 + +--- + +## 六、相关文档 + +- [充电桩数据同步需求.md](./充电桩数据同步需求.md) - 原始需求 +- [充电桩数据同步实现计划.md](./充电桩数据同步实现计划.md) - 详细实现计划 +- [JCPP项目配合实现Prompt.md](./JCPP项目配合实现Prompt.md) - 给 JCPP 项目的实现指南 +- [JCPP对接进度文档.md](./JCPP对接进度文档.md) - JCPP 整体对接进度 + +--- + +## 七、问题反馈 + +如有任何问题,请及时反馈: +1. 接口调用异常 +2. 数据转换错误 +3. 性能问题 +4. 其他技术问题 + +--- + +**文档版本**:v1.0 +**最后更新**:2025-12-30 +**维护人员**:jsowell 团队 diff --git a/doc/充电桩数据同步实现计划.md b/doc/充电桩数据同步实现计划.md new file mode 100644 index 000000000..095a6e0b4 --- /dev/null +++ b/doc/充电桩数据同步实现计划.md @@ -0,0 +1,464 @@ +# 充电桩数据同步实现计划 + +> 创建时间:2025-12-30 +> 任务状态:规划中 + +--- + +## 一、需求概述 + +将 Web 项目(MySQL)中的充电桩数据同步到 JCPP 项目(PostgreSQL)中,用于登录鉴权等操作。 + +### 1.1 数据流向 +``` +Web 项目 (MySQL) → JCPP 项目 (PostgreSQL) +``` + +### 1.2 同步方式 +- **全量同步**:同步所有充电桩和充电枪数据 +- **增量同步**:只同步新增或修改的数据(基于 update_time) + +--- + +## 二、表结构映射分析 + +### 2.1 充电桩表映射 + +| Web 字段 (pile_basic_info) | JCPP 字段 (t_pile) | 映射说明 | +|---------------------------|-------------------|---------| +| id (int) | - | Web 主键,不同步 | +| sn (varchar(20)) | pile_code (varchar(255)) | 充电桩编码,唯一标识 | +| name (varchar(32)) | pile_name (varchar(255)) | 充电桩名称 | +| software_protocol (varchar(20)) | protocol (varchar(255)) | 软件协议 | +| station_id (int) | station_id (uuid) | ✅ 统一使用固定值 '88bca8da-cdbf-6587-aecc-75784838c501' | +| model_id (int) | model (varchar(255)) | 型号,需要查询转换 | +| - | brand (varchar(255)) | 品牌,可为空 | +| - | manufacturer (varchar(255)) | 制造商,可为空 | +| business_type (char(5)) | type (varchar(16)) | 经营类型映射 | +| 其他字段 | additional_info (jsonb) | 存储为 JSON | + +### 2.2 充电枪表映射 + +| Web 字段 (pile_connector_info) | JCPP 字段 (t_gun) | 映射说明 | +|-------------------------------|------------------|---------| +| id (int) | - | Web 主键,不同步 | +| pile_connector_code (varchar(20)) | gun_code (varchar(255)) | 充电枪编码,唯一标识 | +| name (varchar(20)) | gun_name (varchar(255)) | 充电枪名称 | +| pile_sn (varchar(20)) | pile_id (uuid) | ⚠️ 需要通过 pile_code 查询 pile_id | +| - | gun_no (varchar(255)) | 枪号,从 pile_connector_code 提取 | +| - | station_id (uuid) | ✅ 统一使用固定值 '88bca8da-cdbf-6587-aecc-75784838c501' | + +### 2.3 关键问题 + +#### 问题1:station_id 类型映射(int → uuid) +**解决方案**: +- ✅ 统一使用固定的 UUID 值:`88bca8da-cdbf-6587-aecc-75784838c501` +- 所有充电桩都使用这个固定的 station_id +- Web 的原始 station_id 保存在 additional_info 中便于追溯 + +#### 问题2:pile_id 获取 +**解决方案**: +- 先同步充电桩,获取返回的 pile_id +- 再同步充电枪时,通过 pile_code 查询 pile_id + +#### 问题3:gun_no 提取 +**解决方案**: +- pile_connector_code 格式为 "桩号+枪号"(如:20231212000010**01**) +- 提取最后 2 位作为 gun_no + +--- + +## 三、实现方案 + +### 3.1 Web 项目实现(本项目) + +#### 3.1.1 数据传输对象(DTO) + +**文件位置**:`jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/` + +1. **JcppPileSyncDTO** - 充电桩同步数据 +```java +{ + "pileCode": "20231212000010", + "pileName": "1号充电桩", + "protocol": "yunkuaichongV150", + "brand": "特来电", + "model": "AC-7KW", + "manufacturer": "特来电", + "type": "OPERATION", // OPERATION-运营桩, PERSONAL-个人桩 + "additionalInfo": { + "webPileId": 6844, + "webStationId": 123, // 保存 Web 的原始 station_id + "businessType": "1", + "secretKey": "abc123", + "longitude": "116.404", + "latitude": "39.915", + "iccid": "89860123456789012345" + } +} +``` + +**注意**:所有充电桩的 station_id 统一使用固定值 `88bca8da-cdbf-6587-aecc-75784838c501` + +2. **JcppGunSyncDTO** - 充电枪同步数据 +```java +{ + "gunCode": "2023121200001001", + "gunName": "1号枪", + "gunNo": "01", + "pileCode": "20231212000010", + "additionalInfo": { + "webGunId": 30060, + "status": "1", + "parkNo": "A01" + } +} +``` + +3. **JcppSyncRequest** - 同步请求 +```java +{ + "syncType": "FULL", // FULL-全量, INCREMENTAL-增量 + "lastSyncTime": "2025-12-30 10:00:00", // 增量同步时使用 + "piles": [...], + "guns": [...] +} +``` + +4. **JcppSyncResponse** - 同步响应 +```java +{ + "success": true, + "message": "同步成功", + "totalPiles": 100, + "successPiles": 98, + "failedPiles": 2, + "totalGuns": 200, + "successGuns": 195, + "failedGuns": 5, + "errors": [...] +} +``` + +#### 3.1.2 服务接口 + +**文件位置**:`jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/` + +1. **IJcppPileSyncService** - 充电桩同步服务接口 +```java +public interface IJcppPileSyncService { + /** + * 全量同步充电桩数据到 JCPP + */ + JcppSyncResponse syncAllPiles(); + + /** + * 增量同步充电桩数据到 JCPP + * @param lastSyncTime 上次同步时间 + */ + JcppSyncResponse syncIncrementalPiles(Date lastSyncTime); + + /** + * 同步单个充电桩 + */ + boolean syncSinglePile(String pileSn); +} +``` + +2. **JcppPileSyncServiceImpl** - 实现类 +- 查询 Web 数据库 +- 转换数据格式 +- 调用 JCPP 接口发送数据 +- 处理同步结果 + +#### 3.1.3 Controller 接口 + +**文件位置**:`jsowell-admin/src/main/java/com/jsowell/web/controller/jcpp/` + +**JcppPileSyncController** +```java +@RestController +@RequestMapping("/jcpp/sync") +public class JcppPileSyncController { + + /** + * 全量同步充电桩数据 + * POST /jcpp/sync/full + */ + @PostMapping("/full") + public AjaxResult syncFull(); + + /** + * 增量同步充电桩数据 + * POST /jcpp/sync/incremental + * @param lastSyncTime 上次同步时间(可选,默认取上次记录) + */ + @PostMapping("/incremental") + public AjaxResult syncIncremental(@RequestParam(required = false) Date lastSyncTime); + + /** + * 同步单个充电桩 + * POST /jcpp/sync/pile/{pileSn} + */ + @PostMapping("/pile/{pileSn}") + public AjaxResult syncSinglePile(@PathVariable String pileSn); + + /** + * 查询同步记录 + * GET /jcpp/sync/records + */ + @GetMapping("/records") + public TableDataInfo getSyncRecords(); +} +``` + +#### 3.1.4 同步记录表 + +**表名**:`jcpp_sync_record` +```sql +CREATE TABLE `jcpp_sync_record` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `sync_type` varchar(20) NOT NULL COMMENT '同步类型(FULL-全量;INCREMENTAL-增量)', + `sync_status` varchar(20) NOT NULL COMMENT '同步状态(RUNNING-进行中;SUCCESS-成功;FAILED-失败)', + `start_time` datetime NOT NULL COMMENT '开始时间', + `end_time` datetime DEFAULT NULL COMMENT '结束时间', + `total_piles` int(11) DEFAULT 0 COMMENT '总充电桩数', + `success_piles` int(11) DEFAULT 0 COMMENT '成功充电桩数', + `failed_piles` int(11) DEFAULT 0 COMMENT '失败充电桩数', + `total_guns` int(11) DEFAULT 0 COMMENT '总充电枪数', + `success_guns` int(11) DEFAULT 0 COMMENT '成功充电枪数', + `failed_guns` int(11) DEFAULT 0 COMMENT '失败充电枪数', + `error_message` text COMMENT '错误信息', + `create_by` varchar(20) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_sync_type` (`sync_type`), + KEY `idx_start_time` (`start_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='JCPP 充电桩同步记录表'; +``` + +#### 3.1.5 配置项 + +**配置文件**:`application-{env}.yml` +```yaml +jcpp: + sync: + # JCPP 同步接口地址 + api-url: http://jcpp-server:8080/api/sync + # 批量同步大小 + batch-size: 100 + # 超时时间(秒) + timeout: 60 + # 是否启用自动增量同步 + auto-sync-enabled: false + # 自动同步间隔(分钟) + auto-sync-interval: 30 +``` + +--- + +### 3.2 JCPP 项目实现(需要配合) + +#### 3.2.1 需要实现的接口 + +**接口1:接收充电桩同步数据** +``` +POST /api/sync/piles +Content-Type: application/json + +Request Body: +{ + "piles": [ + { + "pileCode": "20231212000010", + "pileName": "1号充电桩", + "protocol": "yunkuaichongV150", + "brand": "特来电", + "model": "AC-7KW", + "manufacturer": "特来电", + "type": "OPERATION", + "additionalInfo": { + "webStationId": 123 // Web 的原始 station_id + } + } + ] +} + +Response: +{ + "success": true, + "message": "同步成功", + "results": [ + { + "pileCode": "20231212000010", + "pileId": "uuid", + "success": true, + "message": "创建成功" + } + ] +} +``` + +**注意**:所有充电桩的 station_id 统一使用固定值 `88bca8da-cdbf-6587-aecc-75784838c501` + +**接口2:接收充电枪同步数据** +``` +POST /api/sync/guns +Content-Type: application/json + +Request Body: +{ + "guns": [ + { + "gunCode": "2023121200001001", + "gunName": "1号枪", + "gunNo": "01", + "pileCode": "20231212000010", + "additionalInfo": {...} + } + ] +} + +Response: +{ + "success": true, + "message": "同步成功", + "results": [...] +} +``` + +#### 3.2.2 数据处理逻辑 + +1. **充电桩同步逻辑** + - 根据 pile_code 判断是否已存在 + - 存在则更新,不存在则创建 + - station_id 统一使用固定值 `88bca8da-cdbf-6587-aecc-75784838c501` + - 返回 pile_id (uuid) + +2. **充电枪同步逻辑** + - 根据 gun_code 判断是否已存在 + - 通过 pile_code 查询 pile_id + - station_id 统一使用固定值 `88bca8da-cdbf-6587-aecc-75784838c501` + - 存在则更新,不存在则创建 + +--- + +## 四、实现步骤 + +### 阶段一:Web 项目基础实现 ✅ + +- [x] 1. 创建 DTO 类(JcppPileSyncDTO、JcppGunSyncDTO、JcppSyncRequest、JcppSyncResponse、JcppSyncResult) +- [x] 2. 创建同步记录表(jcpp_sync_record)及 Mapper +- [x] 3. 创建 Service 接口和实现类(IJcppPileSyncService、JcppPileSyncServiceImpl) +- [x] 4. 创建 Controller 接口(JcppPileSyncController) +- [x] 5. 添加配置项(application-sit.yml) + +**已完成文件**: +- DTO: `JcppPileSyncDTO.java`, `JcppGunSyncDTO.java`, `JcppSyncRequest.java`, `JcppSyncResponse.java`, `JcppSyncResult.java` +- Domain: `JcppSyncRecord.java` +- Mapper: `JcppSyncRecordMapper.java`, `JcppSyncRecordMapper.xml` +- Service: `IJcppPileSyncService.java`, `JcppPileSyncServiceImpl.java` +- Controller: `JcppPileSyncController.java` +- SQL: `sql/jcpp_sync_record.sql` +- Config: `application-sit.yml` (已添加 jcpp.sync 配置) + +### 阶段二:JCPP 项目配合实现 ⏳ + +- [ ] 1. 实现充电桩同步接口(使用固定 station_id) +- [ ] 2. 实现充电枪同步接口(使用固定 station_id) + +### 阶段三:联调测试 ⏳ + +- [ ] 1. 测试全量同步 +- [ ] 2. 测试增量同步 +- [ ] 3. 测试单个充电桩同步 +- [ ] 4. 测试异常场景 +- [ ] 5. 性能测试 + +### 阶段四:优化完善 ⏳ + +- [ ] 1. 添加重试机制 +- [ ] 2. 添加同步进度查询 +- [ ] 3. 添加定时自动同步 +- [ ] 4. 完善错误处理和日志 + +--- + +## 五、技术要点 + +### 5.1 数据转换 + +1. **business_type 映射** + - Web: "1"-运营桩, "2"-个人桩 + - JCPP: "OPERATION"-运营桩, "PERSONAL"-个人桩 + +2. **gun_no 提取** + - pile_connector_code: "2023121200001001" + - gun_no: "01" (最后2位) + +3. **additional_info 构建** + - 将 Web 的其他字段存储为 JSON + - 保留原始 ID 便于追溯 + +### 5.2 性能优化 + +1. **批量同步** + - 每批 100 条数据 + - 避免一次性加载所有数据 + +2. **异步处理** + - 全量同步使用异步任务 + - 返回任务 ID,可查询进度 + +3. **增量同步** + - 基于 update_time 字段 + - 记录上次同步时间 + +### 5.3 异常处理 + +1. **网络异常** + - 重试机制(最多3次) + - 指数退避策略 + +2. **数据异常** + - 记录失败数据 + - 继续处理其他数据 + +3. **事务处理** + - JCPP 端保证事务一致性 + - Web 端记录同步状态 + +--- + +## 六、注意事项 + +1. **数据一致性** + - 充电桩必须先于充电枪同步 + - 确保 pile_code 唯一性 + +2. **性能考虑** + - 全量同步可能耗时较长 + - 建议在业务低峰期执行 + +3. **安全性** + - 同步接口需要鉴权 + - 敏感数据加密传输 + +4. **监控告警** + - 同步失败告警 + - 同步耗时监控 + +--- + +## 七、变更记录 + +| 日期 | 版本 | 修改人 | 修改内容 | +|------|------|--------|----------| +| 2025-12-30 | v1.0 | Claude | 创建文档,制定实现计划 | + +--- + +## 八、相关文档 + +- [充电桩数据同步需求.md](./充电桩数据同步需求.md) +- [JCPP对接进度文档.md](./JCPP对接进度文档.md) diff --git a/doc/充电桩数据同步需求.md b/doc/充电桩数据同步需求.md new file mode 100644 index 000000000..139d098b6 --- /dev/null +++ b/doc/充电桩数据同步需求.md @@ -0,0 +1,111 @@ +充电桩数据首先是在web项目生成的,现在需要把web项目生成的充电桩数据同步到JCPP项目中,用于登录鉴权等操作 + +下面是web项目中充电桩相关表结构(注意使用的是mysql 5.7) + +~~~mysql +CREATE TABLE `pile_basic_info` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `name` varchar(32) DEFAULT NULL COMMENT '别名', + `sn` varchar(20) DEFAULT NULL COMMENT '桩号', + `business_type` char(5) DEFAULT NULL COMMENT '经营类型(1-运营桩;2-个人桩)', + `secret_key` varchar(10) DEFAULT NULL COMMENT '个人桩密钥', + `software_protocol` varchar(20) DEFAULT NULL COMMENT '软件协议(yunkuaichongV150--云快充V1.5;yunkuaichongV160--云快充V1.6;yonglianV1--永联;youdianV1--友电)', + `production_date` datetime DEFAULT NULL COMMENT '生产日期', + `licence_id` int(11) DEFAULT NULL COMMENT '证书编号', + `model_id` int(11) DEFAULT NULL COMMENT '充电桩型号', + `sim_id` int(11) DEFAULT NULL COMMENT 'sim卡id', + `iccid` varchar(50) DEFAULT NULL COMMENT 'sim卡iccid', + `merchant_id` int(11) DEFAULT NULL COMMENT '运营商id', + `station_id` int(11) DEFAULT NULL COMMENT '充电站id', + `longitude` varchar(30) DEFAULT NULL COMMENT '经度', + `latitude` varchar(30) DEFAULT NULL COMMENT '纬度', + `vin_flag` char(5) DEFAULT NULL COMMENT '是否支持汽车VIN码识别', + `fault_reason` varchar(255) DEFAULT NULL COMMENT '故障原因', + `create_by` varchar(20) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` varchar(20) DEFAULT NULL COMMENT '更新人', + `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `del_flag` char(5) DEFAULT '0' COMMENT '删除标识(0-正常;1-删除)', + `remark` varchar(255) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_sn` (`sn`) USING BTREE, + KEY `idx_station_id` (`station_id`) USING BTREE, + KEY `idx_iccid` (`iccid`) USING BTREE +) ENGINE=InnoDB AUTO_INCREMENT=6845 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='充电桩基本信息表'; + +CREATE TABLE `pile_connector_info` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `name` varchar(20) DEFAULT NULL COMMENT '名称', + `pile_sn` varchar(20) DEFAULT NULL COMMENT '所属充电桩sn', + `pile_connector_code` varchar(20) DEFAULT NULL COMMENT '充电枪编号,由充电桩SN+01生成', + `status` varchar(5) DEFAULT '0' COMMENT '状态 0:离网 (默认);1:空闲;2:占用(未充电);3:占用(充电中);4:占用(预约锁定) ;255:故障 ', + `park_no` varchar(5) DEFAULT NULL COMMENT '车位号(推送联联平台所用字段)', + `create_by` varchar(20) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` varchar(20) DEFAULT NULL COMMENT '更新人', + `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `del_flag` char(1) DEFAULT '0' COMMENT '删除标识(0-正常;1-删除)', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_pile_sn` (`pile_sn`) USING BTREE, + KEY `idx_code` (`pile_connector_code`) USING BTREE +) ENGINE=InnoDB AUTO_INCREMENT=30061 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='充电桩枪口信息表'; +~~~ + + + +下面是JCPP项目充电桩相关表结构(注意使用的是postgreSQL 17) + +~~~ +CREATE TABLE "public"."t_pile" ( + "id" uuid NOT NULL, + "created_time" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_time" timestamp(6), + "additional_info" jsonb, + "pile_name" varchar(255) COLLATE "pg_catalog"."default" NOT NULL, + "pile_code" varchar(255) COLLATE "pg_catalog"."default" NOT NULL, + "protocol" varchar(255) COLLATE "pg_catalog"."default" NOT NULL, + "station_id" uuid NOT NULL, + "brand" varchar(255) COLLATE "pg_catalog"."default", + "model" varchar(255) COLLATE "pg_catalog"."default", + "manufacturer" varchar(255) COLLATE "pg_catalog"."default", + "type" varchar(16) COLLATE "pg_catalog"."default" NOT NULL, + "version" int4 DEFAULT 1, + CONSTRAINT "pile_pkey" PRIMARY KEY ("id") +) +; + +ALTER TABLE "public"."t_pile" + OWNER TO "postgres"; + +CREATE UNIQUE INDEX "uni_pile_code" ON "public"."t_pile" USING btree ( + "pile_code" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST +); + +CREATE TABLE "public"."t_gun" ( + "id" uuid NOT NULL, + "created_time" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_time" timestamp(6), + "additional_info" varchar(255) COLLATE "pg_catalog"."default", + "gun_no" varchar(255) COLLATE "pg_catalog"."default" NOT NULL, + "gun_name" varchar(255) COLLATE "pg_catalog"."default" NOT NULL, + "gun_code" varchar(255) COLLATE "pg_catalog"."default" NOT NULL, + "station_id" uuid NOT NULL, + "pile_id" uuid NOT NULL, + "version" int4 DEFAULT 1, + CONSTRAINT "t_gun_pkey" PRIMARY KEY ("id") +) +; + +ALTER TABLE "public"."t_gun" + OWNER TO "postgres"; + +CREATE UNIQUE INDEX "uni_gun_code" ON "public"."t_gun" USING btree ( + "gun_code" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST +); + +CREATE UNIQUE INDEX "uni_gun_pile_gun_no" ON "public"."t_gun" USING btree ( + "pile_id" "pg_catalog"."uuid_ops" ASC NULLS LAST, + "gun_no" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST +); +~~~ + diff --git a/jsowell-admin/src/main/java/com/jsowell/web/controller/jcpp/JcppPileSyncController.java b/jsowell-admin/src/main/java/com/jsowell/web/controller/jcpp/JcppPileSyncController.java new file mode 100644 index 000000000..57ca26f17 --- /dev/null +++ b/jsowell-admin/src/main/java/com/jsowell/web/controller/jcpp/JcppPileSyncController.java @@ -0,0 +1,129 @@ +package com.jsowell.web.controller.jcpp; + +import com.jsowell.common.annotation.Log; +import com.jsowell.common.core.controller.BaseController; +import com.jsowell.common.core.domain.AjaxResult; +import com.jsowell.common.core.page.TableDataInfo; +import com.jsowell.common.enums.BusinessType; +import com.jsowell.pile.domain.JcppSyncRecord; +import com.jsowell.pile.jcpp.dto.sync.JcppSyncResponse; +import com.jsowell.pile.jcpp.service.IJcppPileSyncService; +import com.jsowell.pile.mapper.JcppSyncRecordMapper; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.Date; +import java.util.List; + +/** + * JCPP 充电桩同步控制器 + * + * @author jsowell + */ +@Slf4j +@Api(tags = "JCPP 充电桩同步") +@RestController +@RequestMapping("/jcpp/sync") +public class JcppPileSyncController extends BaseController { + + @Autowired + private IJcppPileSyncService jcppPileSyncService; + + @Autowired + private JcppSyncRecordMapper jcppSyncRecordMapper; + + /** + * 全量同步充电桩数据 + */ + @ApiOperation("全量同步充电桩数据") + @PreAuthorize("@ss.hasPermi('jcpp:sync:full')") + @Log(title = "JCPP 充电桩同步", businessType = BusinessType.OTHER) + @PostMapping("/full") + public AjaxResult syncFull() { + try { + log.info("开始执行全量同步"); + JcppSyncResponse response = jcppPileSyncService.syncAllPiles(); + return AjaxResult.success("全量同步完成", response); + } catch (Exception e) { + log.error("全量同步失败", e); + return AjaxResult.error("全量同步失败: " + e.getMessage()); + } + } + + /** + * 增量同步充电桩数据 + */ + @ApiOperation("增量同步充电桩数据") + @PreAuthorize("@ss.hasPermi('jcpp:sync:incremental')") + @Log(title = "JCPP 充电桩同步", businessType = BusinessType.OTHER) + @PostMapping("/incremental") + public AjaxResult syncIncremental( + @ApiParam("上次同步时间(可选,格式:yyyy-MM-dd HH:mm:ss)") + @RequestParam(required = false) + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + Date lastSyncTime) { + try { + log.info("开始执行增量同步,上次同步时间: {}", lastSyncTime); + JcppSyncResponse response = jcppPileSyncService.syncIncrementalPiles(lastSyncTime); + return AjaxResult.success("增量同步完成", response); + } catch (Exception e) { + log.error("增量同步失败", e); + return AjaxResult.error("增量同步失败: " + e.getMessage()); + } + } + + /** + * 同步单个充电桩 + */ + @ApiOperation("同步单个充电桩") + @PreAuthorize("@ss.hasPermi('jcpp:sync:single')") + @Log(title = "JCPP 充电桩同步", businessType = BusinessType.OTHER) + @PostMapping("/pile/{pileSn}") + public AjaxResult syncSinglePile( + @ApiParam("充电桩编号") + @PathVariable String pileSn) { + try { + log.info("开始同步单个充电桩: {}", pileSn); + boolean success = jcppPileSyncService.syncSinglePile(pileSn); + if (success) { + return AjaxResult.success("同步成功"); + } else { + return AjaxResult.error("同步失败"); + } + } catch (Exception e) { + log.error("同步单个充电桩失败: {}", pileSn, e); + return AjaxResult.error("同步失败: " + e.getMessage()); + } + } + + /** + * 查询同步记录列表 + */ + @ApiOperation("查询同步记录列表") + @PreAuthorize("@ss.hasPermi('jcpp:sync:list')") + @GetMapping("/records") + public TableDataInfo getSyncRecords(JcppSyncRecord jcppSyncRecord) { + startPage(); + List list = jcppSyncRecordMapper.selectJcppSyncRecordList(jcppSyncRecord); + return getDataTable(list); + } + + /** + * 查询同步记录详情 + */ + @ApiOperation("查询同步记录详情") + @PreAuthorize("@ss.hasPermi('jcpp:sync:query')") + @GetMapping("/records/{id}") + public AjaxResult getSyncRecordDetail( + @ApiParam("同步记录ID") + @PathVariable Long id) { + JcppSyncRecord record = jcppSyncRecordMapper.selectJcppSyncRecordById(id); + return AjaxResult.success(record); + } +} diff --git a/jsowell-admin/src/main/resources/application-sit.yml b/jsowell-admin/src/main/resources/application-sit.yml index 2825f546b..bab12acd6 100644 --- a/jsowell-admin/src/main/resources/application-sit.yml +++ b/jsowell-admin/src/main/resources/application-sit.yml @@ -3,12 +3,33 @@ jsowell: # 文件路径 示例( Windows配置D:/jsowell/uploadPath,Linux配置 /home/jsowell/uploadPath) profile: /www/wwwroot/jsowellftp +# JCPP 配置 +jcpp: + rabbitmq: + # 分区数量(与 JCPP 保持一致) + partition-count: 10 + # Exchange 名称 + exchange: jcpp.uplink.exchange + # 队列前缀 + queue-prefix: jcpp.uplink.partition + sync: + # JCPP 同步接口地址 + api-url: http://localhost:8080/api/sync + # 批量同步大小 + batch-size: 100 + # 超时时间(毫秒) + timeout: 60000 + # 是否启用自动增量同步 + auto-sync-enabled: false + # 自动同步间隔(分钟) + auto-sync-interval: 30 + # 数据源配置 spring: # redis 配置 redis: # 地址 - host: 192.168.0.32 + host: 106.14.94.149 # 端口,默认为6379 port: 6379 # 数据库索引 @@ -35,11 +56,11 @@ spring: druid: # 主库数据源 master: - url: jdbc:mysql://192.168.0.32:3306/jsowell_dev?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 - username: jsowell_dev -# url: jdbc:mysql://192.168.0.32:3306/jsowell_prd_copy?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 + url: jdbc:mysql://106.14.94.149:3306/jsowell_pre?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 + username: jsowell_pre +# url: jdbc:mysql://106.14.94.149:3306/jsowell_prd_copy?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 # username: jsowell_prd_copy - password: 123456 + password: Js@160829 # 从库数据源 slave: # 从数据源开关/默认关闭 @@ -89,7 +110,7 @@ spring: # rabbitmq配置 sit rabbitmq: - host: 192.168.0.32 + host: 106.14.94.149 port: 5672 username: admin password: admin @@ -263,7 +284,7 @@ dubbo: name: wcc-server qosEnable: false registry: - address: nacos://192.168.0.32:8848 + address: nacos://106.14.94.149:8848 parameters: namespace: e328faaf-8516-42d0-817a-7406232b3581 username: nacos diff --git a/jsowell-admin/src/test/java/com/jsowell/jcpp/JcppMessageControllerTest.java b/jsowell-admin/src/test/java/com/jsowell/jcpp/JcppMessageControllerTest.java index e80274776..281a56835 100644 --- a/jsowell-admin/src/test/java/com/jsowell/jcpp/JcppMessageControllerTest.java +++ b/jsowell-admin/src/test/java/com/jsowell/jcpp/JcppMessageControllerTest.java @@ -8,7 +8,6 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.MvcResult; import java.util.HashMap; import java.util.Map; @@ -52,7 +51,7 @@ public class JcppMessageControllerTest { .pileCode("TEST001") .messageType("LOGIN") .timestamp(System.currentTimeMillis()) - .data(loginData) + .data(JSON.toJSONString(loginData)) .build(); mockMvc.perform(post("/api/jcpp/uplink") @@ -77,7 +76,7 @@ public class JcppMessageControllerTest { .pileCode("TEST001") .messageType("HEARTBEAT") .timestamp(System.currentTimeMillis()) - .data(heartbeatData) + .data(JSON.toJSONString(heartbeatData)) .build(); mockMvc.perform(post("/api/jcpp/uplink") @@ -101,7 +100,7 @@ public class JcppMessageControllerTest { .pileCode("TEST001") .messageType("QUERY_PRICING") .timestamp(System.currentTimeMillis()) - .data(queryData) + .data(JSON.toJSONString(queryData)) .build(); mockMvc.perform(post("/api/jcpp/uplink") @@ -130,7 +129,7 @@ public class JcppMessageControllerTest { .pileCode("TEST001") .messageType("START_CHARGE") .timestamp(System.currentTimeMillis()) - .data(startData) + .data(JSON.toJSONString(startData)) .build(); mockMvc.perform(post("/api/jcpp/uplink") @@ -163,7 +162,7 @@ public class JcppMessageControllerTest { .pileCode("TEST001") .messageType("REAL_TIME_DATA") .timestamp(System.currentTimeMillis()) - .data(realTimeData) + .data(JSON.toJSONString(realTimeData)) .build(); mockMvc.perform(post("/api/jcpp/uplink") @@ -196,7 +195,7 @@ public class JcppMessageControllerTest { .pileCode("TEST001") .messageType("TRANSACTION_RECORD") .timestamp(System.currentTimeMillis()) - .data(transactionData) + .data(JSON.toJSONString(transactionData)) .build(); mockMvc.perform(post("/api/jcpp/uplink") @@ -224,7 +223,7 @@ public class JcppMessageControllerTest { .pileCode("TEST001") .messageType("REMOTE_START_RESULT") .timestamp(System.currentTimeMillis()) - .data(resultData) + .data(JSON.toJSONString(resultData)) .build(); mockMvc.perform(post("/api/jcpp/uplink") @@ -245,7 +244,7 @@ public class JcppMessageControllerTest { .pileCode("TEST001") .messageType("INVALID_TYPE") .timestamp(System.currentTimeMillis()) - .data(new HashMap<>()) + .data(JSON.toJSONString(new HashMap<>())) .build(); mockMvc.perform(post("/api/jcpp/uplink") diff --git a/jsowell-admin/src/test/java/com/jsowell/jcpp/PricingModelConverterTest.java b/jsowell-admin/src/test/java/com/jsowell/jcpp/PricingModelConverterTest.java index 7f56a2ae5..a5d47b8d3 100644 --- a/jsowell-admin/src/test/java/com/jsowell/jcpp/PricingModelConverterTest.java +++ b/jsowell-admin/src/test/java/com/jsowell/jcpp/PricingModelConverterTest.java @@ -1,5 +1,6 @@ package com.jsowell.jcpp; +import com.google.common.collect.Lists; import com.jsowell.pile.jcpp.dto.JcppPricingModel; import com.jsowell.pile.jcpp.util.PricingModelConverter; import com.jsowell.pile.vo.web.BillingDetailVO; @@ -25,17 +26,17 @@ public class PricingModelConverterTest { @Test public void testConvertStandardPricing() { BillingTemplateVO template = new BillingTemplateVO(); - template.setId(1L); - template.setName("标准计费模板"); + template.setTemplateId("1L"); + template.setTemplateName("标准计费模板"); List details = new ArrayList<>(); BillingDetailVO detail = new BillingDetailVO(); detail.setTimeType("3"); // 平时 detail.setElectricityPrice(new BigDecimal("0.8")); detail.setServicePrice(new BigDecimal("0.4")); - detail.setApplyTime("00:00-24:00"); + detail.setApplyTime(Lists.newArrayList("00:00-24:00")); details.add(detail); - template.setBillingDetailList(details); + // template.setBillingDetailList(details); JcppPricingModel model = PricingModelConverter.convert(template); @@ -53,8 +54,8 @@ public class PricingModelConverterTest { @Test public void testConvertPeakValleyPricing() { BillingTemplateVO template = new BillingTemplateVO(); - template.setId(2L); - template.setName("峰谷计费模板"); + template.setTemplateId("2L"); + template.setTemplateName("峰谷计费模板"); List details = new ArrayList<>(); @@ -63,7 +64,7 @@ public class PricingModelConverterTest { sharpDetail.setTimeType("1"); sharpDetail.setElectricityPrice(new BigDecimal("1.2")); sharpDetail.setServicePrice(new BigDecimal("0.6")); - sharpDetail.setApplyTime("10:00-12:00,18:00-20:00"); + sharpDetail.setApplyTime(Lists.newArrayList("10:00-12:00","18:00-20:00")); details.add(sharpDetail); // 峰时 @@ -71,7 +72,7 @@ public class PricingModelConverterTest { peakDetail.setTimeType("2"); peakDetail.setElectricityPrice(new BigDecimal("1.0")); peakDetail.setServicePrice(new BigDecimal("0.5")); - peakDetail.setApplyTime("08:00-10:00,12:00-18:00"); + peakDetail.setApplyTime(Lists.newArrayList("08:00-10:00","12:00-18:00")); details.add(peakDetail); // 平时 @@ -79,7 +80,7 @@ public class PricingModelConverterTest { flatDetail.setTimeType("3"); flatDetail.setElectricityPrice(new BigDecimal("0.8")); flatDetail.setServicePrice(new BigDecimal("0.4")); - flatDetail.setApplyTime("06:00-08:00,20:00-22:00"); + flatDetail.setApplyTime(Lists.newArrayList("06:00-08:00","20:00-22:00")); details.add(flatDetail); // 谷时 @@ -87,10 +88,10 @@ public class PricingModelConverterTest { valleyDetail.setTimeType("4"); valleyDetail.setElectricityPrice(new BigDecimal("0.4")); valleyDetail.setServicePrice(new BigDecimal("0.2")); - valleyDetail.setApplyTime("00:00-06:00,22:00-24:00"); + valleyDetail.setApplyTime(Lists.newArrayList("00:00-06:00","22:00-24:00")); details.add(valleyDetail); - template.setBillingDetailList(details); + // template.setBillingDetailList(details); JcppPricingModel model = PricingModelConverter.convert(template); @@ -131,9 +132,9 @@ public class PricingModelConverterTest { @Test public void testConvertTemplateWithoutDetails() { BillingTemplateVO template = new BillingTemplateVO(); - template.setId(3L); - template.setName("无详情模板"); - template.setBillingDetailList(null); + template.setTemplateId("3L"); + template.setTemplateName("无详情模板"); + // template.setBillingDetailList(null); JcppPricingModel model = PricingModelConverter.convert(template); diff --git a/jsowell-pile/pom.xml b/jsowell-pile/pom.xml index c4ee39f54..e010872e5 100644 --- a/jsowell-pile/pom.xml +++ b/jsowell-pile/pom.xml @@ -165,6 +165,12 @@ com.jsowell charge-common-api + + + + com.google.protobuf + protobuf-java + @@ -187,7 +193,36 @@ utf-8 + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + 0.6.1 + + com.google.protobuf:protoc:3.25.1:exe:${os.detected.classifier} + ${project.basedir}/src/main/proto + ${project.build.directory}/generated-sources/protobuf/java + false + + + + + compile + + + + + + + + + kr.motd.maven + os-maven-plugin + 1.7.1 + + diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/domain/JcppSyncRecord.java b/jsowell-pile/src/main/java/com/jsowell/pile/domain/JcppSyncRecord.java new file mode 100644 index 000000000..957af6e2b --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/domain/JcppSyncRecord.java @@ -0,0 +1,93 @@ +package com.jsowell.pile.domain; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.jsowell.common.annotation.Excel; +import com.jsowell.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +/** + * JCPP 充电桩同步记录对象 jcpp_sync_record + * + * @author jsowell + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class JcppSyncRecord extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + private Long id; + + /** + * 同步类型(FULL-全量;INCREMENTAL-增量) + */ + @Excel(name = "同步类型") + private String syncType; + + /** + * 同步状态(RUNNING-进行中;SUCCESS-成功;FAILED-失败) + */ + @Excel(name = "同步状态") + private String syncStatus; + + /** + * 开始时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "开始时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date startTime; + + /** + * 结束时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "结束时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date endTime; + + /** + * 总充电桩数 + */ + @Excel(name = "总充电桩数") + private Integer totalPiles; + + /** + * 成功充电桩数 + */ + @Excel(name = "成功充电桩数") + private Integer successPiles; + + /** + * 失败充电桩数 + */ + @Excel(name = "失败充电桩数") + private Integer failedPiles; + + /** + * 总充电枪数 + */ + @Excel(name = "总充电枪数") + private Integer totalGuns; + + /** + * 成功充电枪数 + */ + @Excel(name = "成功充电枪数") + private Integer successGuns; + + /** + * 失败充电枪数 + */ + @Excel(name = "失败充电枪数") + private Integer failedGuns; + + /** + * 错误信息 + */ + private String errorMessage; +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/config/JcppPartitionQueueConfig.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/config/JcppPartitionQueueConfig.java new file mode 100644 index 000000000..b1455ad97 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/config/JcppPartitionQueueConfig.java @@ -0,0 +1,82 @@ +package com.jsowell.pile.jcpp.config; + +import com.jsowell.pile.jcpp.constant.JcppConstants; +import com.jsowell.pile.jcpp.util.JcppPartitionCalculator; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.core.TopicExchange; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.List; + +/** + * JCPP Protobuf 消息分区队列配置 + * 实现基于 messageKey 的分区消费,确保同一充电桩的消息顺序处理 + * + * @author jsowell + */ +@Slf4j +@Configuration +public class JcppPartitionQueueConfig { + + @Value("${jcpp.rabbitmq.partition-count:10}") + private int partitionCount; + + @PostConstruct + public void init() { + // 设置分区数量到计算器 + JcppPartitionCalculator.setPartitionCount(partitionCount); + log.info("JCPP 分区队列配置初始化完成,分区数量: {}", partitionCount); + } + + /** + * 创建分区队列数组 + * 每个分区一个队列,用于顺序消费 + */ + @Bean + public Queue[] jcppPartitionQueues() { + Queue[] queues = new Queue[partitionCount]; + for (int i = 0; i < partitionCount; i++) { + String queueName = JcppPartitionCalculator.getQueueName(i); + queues[i] = new Queue(queueName, true, false, false); + log.info("创建 JCPP 分区队列: {}", queueName); + } + return queues; + } + + /** + * 绑定分区队列到 Exchange + * 每个分区队列绑定所有消息类型(jcpp.uplink.#) + * 实际分区由 JCPP 在发送消息时通过 header 指定 + * + * 注意:复用 JcppRabbitConfig 中定义的 jcppUplinkExchange Bean + */ + @Bean + public Binding[] jcppPartitionBindings( + TopicExchange jcppUplinkExchange, + Queue[] jcppPartitionQueues) { + + List bindings = new ArrayList<>(); + + for (int i = 0; i < partitionCount; i++) { + // 每个分区队列绑定所有消息类型 + Binding binding = BindingBuilder + .bind(jcppPartitionQueues[i]) + .to(jcppUplinkExchange) + .with("jcpp.uplink.#"); + + bindings.add(binding); + log.info("绑定分区队列 {} 到 Exchange: {}", + jcppPartitionQueues[i].getName(), + jcppUplinkExchange.getName()); + } + + return bindings.toArray(new Binding[0]); + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppGunStatusConsumer.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppGunStatusConsumer.java index 6582b2e62..1d006e188 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppGunStatusConsumer.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppGunStatusConsumer.java @@ -50,26 +50,31 @@ public class JcppGunStatusConsumer { } @RabbitListener(queues = JcppConstants.QUEUE_GUN_STATUS) - public void handleGunStatus(String message) { + public void handleGunStatus(JcppUplinkMessage uplinkMessage) { try { - log.info("收到 JCPP 枪状态消息: {}", message); - - // 解析消息 - JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class); + // 验证消息 if (uplinkMessage == null || uplinkMessage.getData() == null) { - log.warn("枪状态消息格式错误: {}", message); + log.warn("枪状态消息格式错误"); return; } - // 从 data 中获取信息 - JSONObject data = JSON.parseObject(JSON.toJSONString(uplinkMessage.getData())); - String pileCode = data.getString("pileCode"); + // 从 uplinkMessage 中获取 pileCode + String pileCode = uplinkMessage.getPileCode(); + if (pileCode == null || pileCode.isEmpty()) { + log.warn("枪状态消息缺少 pileCode"); + return; + } + + log.info("收到 JCPP 枪状态消息: pileCode={}, messageType={}", pileCode, uplinkMessage.getMessageType()); + + // 从 data 中获取其他信息 + JSONObject data = JSON.parseObject(uplinkMessage.getData()); String gunNo = data.getString("gunNo"); String gunRunStatus = data.getString("gunRunStatus"); JSONArray faultMessages = data.getJSONArray("faultMessages"); - if (pileCode == null || gunNo == null) { - log.warn("枪状态消息缺少必要字段: {}", message); + if (gunNo == null) { + log.warn("枪状态消息缺少 gunNo"); return; } @@ -94,7 +99,8 @@ public class JcppGunStatusConsumer { } } catch (Exception e) { - log.error("处理 JCPP 枪状态消息异常: message={}", message, e); + log.error("处理 JCPP 枪状态消息异常: pileCode={}", + uplinkMessage != null ? uplinkMessage.getPileCode() : "unknown", e); } } } diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppHeartbeatConsumer.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppHeartbeatConsumer.java index bf96cd53b..b45448277 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppHeartbeatConsumer.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppHeartbeatConsumer.java @@ -28,20 +28,18 @@ public class JcppHeartbeatConsumer { private StringRedisTemplate stringRedisTemplate; @RabbitListener(queues = JcppConstants.QUEUE_HEARTBEAT) - public void handleHeartbeat(String message) { + public void handleHeartbeat(JcppUplinkMessage uplinkMessage) { try { - // 解析消息 - JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class); + // 验证消息 if (uplinkMessage == null || uplinkMessage.getData() == null) { - log.debug("心跳消息格式错误: {}", message); + log.debug("心跳消息格式错误"); return; } - // 从 data 中获取 pileCode - JSONObject data = JSON.parseObject(JSON.toJSONString(uplinkMessage.getData())); - String pileCode = data.getString("pileCode"); + // 从 uplinkMessage 中获取 pileCode + String pileCode = uplinkMessage.getPileCode(); if (pileCode == null || pileCode.isEmpty()) { - log.debug("心跳消息缺少 pileCode: {}", message); + log.debug("心跳消息缺少 pileCode"); return; } @@ -53,7 +51,8 @@ public class JcppHeartbeatConsumer { log.debug("收到充电桩心跳: pileCode={}", pileCode); } catch (Exception e) { - log.error("处理 JCPP 心跳消息异常: message={}", message, e); + log.error("处理 JCPP 心跳消息异常: pileCode={}", + uplinkMessage != null ? uplinkMessage.getPileCode() : "unknown", e); } } } diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppJsonPartitionConsumer.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppJsonPartitionConsumer.java new file mode 100644 index 000000000..8bc13b72e --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppJsonPartitionConsumer.java @@ -0,0 +1,90 @@ +package com.jsowell.pile.jcpp.consumer; + +import com.alibaba.fastjson2.JSON; +import com.jsowell.pile.jcpp.dto.JcppUplinkMessage; +import com.jsowell.pile.jcpp.service.IJcppJsonMessageHandler; +import com.jsowell.pile.jcpp.util.JcppPartitionCalculator; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.amqp.support.AmqpHeaders; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.stereotype.Component; + +/** + * JCPP JSON 消息消费者 - 分区消费 + * 每个分区单线程消费,保证同一充电桩的消息顺序处理 + * + * 注意:此消费者默认禁用,需要在 JCPP 端配置好分区路由并创建队列后再启用 + * 启用方法:取消 @Component 注解的注释 + * + * @author jsowell + */ +@Slf4j +// @Component // 暂时禁用,等 JCPP 端配置好分区路由并创建队列后再启用 +public class JcppJsonPartitionConsumer { + + @Autowired + private IJcppJsonMessageHandler messageHandler; + + @RabbitListener( + queues = { + "jcpp.uplink.partition.0", + "jcpp.uplink.partition.1", + "jcpp.uplink.partition.2", + "jcpp.uplink.partition.3", + "jcpp.uplink.partition.4", + "jcpp.uplink.partition.5", + "jcpp.uplink.partition.6", + "jcpp.uplink.partition.7", + "jcpp.uplink.partition.8", + "jcpp.uplink.partition.9" + }, + concurrency = "1" // 每个队列单线程消费保证顺序 + ) + public void consumeMessage(JcppUplinkMessage uplinkMessage, + @Header(AmqpHeaders.RECEIVED_ROUTING_KEY) String routingKey, + @Header(AmqpHeaders.CONSUMER_QUEUE) String queueName) { + // 从队列名称提取分区编号 + int partition = extractPartitionFromQueue(queueName); + + try { + String messageKey = uplinkMessage.getPileCode(); + String messageType = uplinkMessage.getMessageType(); + + log.info("[分区{}] 收到消息: pileCode={}, messageType={}, routingKey={}", + partition, messageKey, messageType, routingKey); + + // 验证分区是否正确 + int expectedPartition = JcppPartitionCalculator.getPartition(messageKey); + if (expectedPartition != partition) { + log.warn("[分区{}] 消息分区不匹配: pileCode={}, 期望分区={}, 实际分区={}", + partition, messageKey, expectedPartition, partition); + } + + // 处理消息 + messageHandler.handleUplinkMessage(uplinkMessage); + + log.debug("[分区{}] 消息处理完成: pileCode={}", partition, messageKey); + + } catch (Exception e) { + log.error("[分区{}] 消息处理失败: pileCode={}", + partition, uplinkMessage != null ? uplinkMessage.getPileCode() : "unknown", e); + throw new RuntimeException("消息处理失败", e); + } + } + + /** + * 从队列名称提取分区编号 + * 队列名称格式: jcpp.uplink.partition.{partition} + */ + private int extractPartitionFromQueue(String queueName) { + try { + String[] parts = queueName.split("\\."); + return Integer.parseInt(parts[parts.length - 1]); + } catch (Exception e) { + log.error("无法从队列名称提取分区编号: {}", queueName, e); + return 0; + } + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppLoginConsumer.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppLoginConsumer.java index ff1f9c67b..935f384fe 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppLoginConsumer.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppLoginConsumer.java @@ -28,25 +28,26 @@ public class JcppLoginConsumer { private IJcppDownlinkService jcppDownlinkService; @RabbitListener(queues = JcppConstants.QUEUE_LOGIN) - public void handleLogin(String message) { + public void handleLogin(JcppUplinkMessage uplinkMessage) { try { - log.info("收到 JCPP 登录消息: {}", message); - - // 解析消息 - JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class); + // 验证消息 if (uplinkMessage == null || uplinkMessage.getData() == null) { - log.warn("登录消息格式错误: {}", message); + log.warn("登录消息格式错误"); return; } - // 从 data 中获取 pileCode - JSONObject data = JSON.parseObject(JSON.toJSONString(uplinkMessage.getData())); - String pileCode = data.getString("pileCode"); + // 从 uplinkMessage 中获取 pileCode + String pileCode = uplinkMessage.getPileCode(); if (pileCode == null || pileCode.isEmpty()) { - log.warn("登录消息缺少 pileCode: {}", message); + log.warn("登录消息缺少 pileCode"); return; } + log.info("收到 JCPP 登录消息: pileCode={}, messageType={}", pileCode, uplinkMessage.getMessageType()); + + // 解析 data + JSONObject data = JSON.parseObject(uplinkMessage.getData()); + // 查询充电桩是否存在 PileBasicInfo pileInfo = pileBasicInfoService.selectPileBasicInfoBySN(pileCode); boolean exists = pileInfo != null; @@ -66,7 +67,8 @@ public class JcppLoginConsumer { jcppDownlinkService.sendLoginAck(pileCode, exists); } catch (Exception e) { - log.error("处理 JCPP 登录消息异常: message={}", message, e); + log.error("处理 JCPP 登录消息异常: pileCode={}", + uplinkMessage != null ? uplinkMessage.getPileCode() : "unknown", e); } } } diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppPricingConsumer.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppPricingConsumer.java index d47aa7473..db18ebd81 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppPricingConsumer.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppPricingConsumer.java @@ -36,26 +36,28 @@ public class JcppPricingConsumer { private IJcppDownlinkService jcppDownlinkService; @RabbitListener(queues = JcppConstants.QUEUE_PRICING) - public void handlePricing(String message) { + public void handlePricing(JcppUplinkMessage uplinkMessage) { try { - log.info("收到 JCPP 计费消息: {}", message); - - // 解析消息 - JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class); + // 验证消息 if (uplinkMessage == null || uplinkMessage.getData() == null) { - log.warn("计费消息格式错误: {}", message); + log.warn("计费消息格式错误"); return; } + // 从 uplinkMessage 中获取 pileCode 和 messageType + String pileCode = uplinkMessage.getPileCode(); String messageType = uplinkMessage.getMessageType(); - JSONObject data = JSON.parseObject(JSON.toJSONString(uplinkMessage.getData())); - String pileCode = data.getString("pileCode"); if (pileCode == null || pileCode.isEmpty()) { - log.warn("计费消息缺少 pileCode: {}", message); + log.warn("计费消息缺少 pileCode"); return; } + log.info("收到 JCPP 计费消息: pileCode={}, messageType={}", pileCode, messageType); + + // 解析 data + JSONObject data = JSON.parseObject(uplinkMessage.getData()); + // 根据消息类型处理 if (JcppConstants.MessageType.QUERY_PRICING.equals(messageType)) { handleQueryPricing(pileCode); @@ -67,7 +69,8 @@ public class JcppPricingConsumer { } } catch (Exception e) { - log.error("处理 JCPP 计费消息异常: message={}", message, e); + log.error("处理 JCPP 计费消息异常: pileCode={}", + uplinkMessage != null ? uplinkMessage.getPileCode() : "unknown", e); } } diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppRealTimeDataConsumer.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppRealTimeDataConsumer.java index faa4278dd..94add398b 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppRealTimeDataConsumer.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppRealTimeDataConsumer.java @@ -39,18 +39,23 @@ public class JcppRealTimeDataConsumer { private StringRedisTemplate stringRedisTemplate; @RabbitListener(queues = JcppConstants.QUEUE_REAL_TIME_DATA) - public void handleRealTimeData(String message) { + public void handleRealTimeData(JcppUplinkMessage uplinkMessage) { try { - // 解析消息 - JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class); + // 验证消息 if (uplinkMessage == null || uplinkMessage.getData() == null) { - log.debug("实时数据消息格式错误: {}", message); + log.debug("实时数据消息格式错误"); return; } - // 从 data 中获取信息 - JSONObject data = JSON.parseObject(JSON.toJSONString(uplinkMessage.getData())); - String pileCode = data.getString("pileCode"); + // 从 uplinkMessage 中获取 pileCode + String pileCode = uplinkMessage.getPileCode(); + if (pileCode == null || pileCode.isEmpty()) { + log.debug("实时数据消息缺少 pileCode"); + return; + } + + // 从 data 中获取其他信息 + JSONObject data = JSON.parseObject(uplinkMessage.getData()); String gunNo = data.getString("gunNo"); String tradeNo = data.getString("tradeNo"); String outputVoltage = data.getString("outputVoltage"); @@ -61,7 +66,7 @@ public class JcppRealTimeDataConsumer { String totalChargingCostYuan = data.getString("totalChargingCostYuan"); if (tradeNo == null || tradeNo.isEmpty()) { - log.debug("实时数据消息缺少 tradeNo: {}", message); + log.debug("实时数据消息缺少 tradeNo"); return; } @@ -101,7 +106,8 @@ public class JcppRealTimeDataConsumer { log.debug("处理实时数据: tradeNo={}, soc={}, energy={}kWh", tradeNo, soc, totalChargingEnergyKWh); } catch (Exception e) { - log.error("处理 JCPP 实时数据消息异常: message={}", message, e); + log.error("处理 JCPP 实时数据消息异常: pileCode={}", + uplinkMessage != null ? uplinkMessage.getPileCode() : "unknown", e); } } } diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppRemoteResultConsumer.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppRemoteResultConsumer.java index fbf9d6eca..a666002bb 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppRemoteResultConsumer.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppRemoteResultConsumer.java @@ -30,19 +30,19 @@ public class JcppRemoteResultConsumer { @Transactional(rollbackFor = Exception.class) @RabbitListener(queues = JcppConstants.QUEUE_REMOTE_RESULT) - public void handleRemoteResult(String message) { + public void handleRemoteResult(JcppUplinkMessage uplinkMessage) { try { - log.info("收到 JCPP 远程操作结果消息: {}", message); + log.info("收到 JCPP 远程操作结果消息: pileCode={}, messageType={}", + uplinkMessage.getPileCode(), uplinkMessage.getMessageType()); - // 解析消息 - JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class); + // 验证消息 if (uplinkMessage == null || uplinkMessage.getData() == null) { - log.warn("远程操作结果消息格式错误: {}", message); + log.warn("远程操作结果消息格式错误"); return; } String messageType = uplinkMessage.getMessageType(); - JSONObject data = JSON.parseObject(JSON.toJSONString(uplinkMessage.getData())); + JSONObject data = JSON.parseObject(uplinkMessage.getData()); // 根据消息类型处理 if (JcppConstants.MessageType.REMOTE_START_RESULT.equals(messageType)) { @@ -54,7 +54,8 @@ public class JcppRemoteResultConsumer { } } catch (Exception e) { - log.error("处理 JCPP 远程操作结果消息异常: message={}", message, e); + log.error("处理 JCPP 远程操作结果消息异常: pileCode={}", + uplinkMessage != null ? uplinkMessage.getPileCode() : "unknown", e); } } @@ -62,12 +63,14 @@ public class JcppRemoteResultConsumer { * 处理远程启动结果 */ private void handleRemoteStartResult(JSONObject data) { - String pileCode = data.getString("pileCode"); - String gunNo = data.getString("gunNo"); String tradeNo = data.getString("tradeNo"); Boolean success = data.getBoolean("success"); String failReason = data.getString("failReason"); + // 从 data 中获取 pileCode 和 gunNo(如果有) + String pileCode = data.getString("pileCode"); + String gunNo = data.getString("gunNo"); + if (tradeNo == null || tradeNo.isEmpty()) { log.warn("远程启动结果缺少 tradeNo"); return; @@ -110,11 +113,13 @@ public class JcppRemoteResultConsumer { * 处理远程停止结果 */ private void handleRemoteStopResult(JSONObject data) { - String pileCode = data.getString("pileCode"); - String gunNo = data.getString("gunNo"); Boolean success = data.getBoolean("success"); String failReason = data.getString("failReason"); + // 从 data 中获取 pileCode 和 gunNo(如果有) + String pileCode = data.getString("pileCode"); + String gunNo = data.getString("gunNo"); + if (Boolean.TRUE.equals(success)) { log.info("远程停止成功: pileCode={}, gunNo={}", pileCode, gunNo); } else { diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppSessionCloseConsumer.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppSessionCloseConsumer.java index f1e24281d..551c4a66d 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppSessionCloseConsumer.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppSessionCloseConsumer.java @@ -28,27 +28,27 @@ public class JcppSessionCloseConsumer { private PileConnectorInfoService pileConnectorInfoService; @RabbitListener(queues = JcppConstants.QUEUE_SESSION_CLOSE) - public void handleSessionClose(String message) { + public void handleSessionClose(JcppUplinkMessage uplinkMessage) { try { - log.info("收到 JCPP 会话关闭消息: {}", message); - - // 解析消息 - JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class); + // 验证消息 if (uplinkMessage == null || uplinkMessage.getData() == null) { - log.warn("会话关闭消息格式错误: {}", message); + log.warn("会话关闭消息格式错误"); return; } - // 从 data 中获取信息 - JSONObject data = JSON.parseObject(JSON.toJSONString(uplinkMessage.getData())); - String pileCode = data.getString("pileCode"); - String reason = data.getString("reason"); - + // 从 uplinkMessage 中获取 pileCode + String pileCode = uplinkMessage.getPileCode(); if (pileCode == null || pileCode.isEmpty()) { - log.warn("会话关闭消息缺少 pileCode: {}", message); + log.warn("会话关闭消息缺少 pileCode"); return; } + log.info("收到 JCPP 会话关闭消息: pileCode={}, messageType={}", pileCode, uplinkMessage.getMessageType()); + + // 从 data 中获取其他信息 + JSONObject data = JSON.parseObject(uplinkMessage.getData()); + String reason = data.getString("reason"); + log.info("充电桩会话关闭: pileCode={}, reason={}", pileCode, reason); // 1. 更新充电桩离线状态 @@ -70,7 +70,8 @@ public class JcppSessionCloseConsumer { // 可以调用 OrderBasicInfoService 查询并处理 } catch (Exception e) { - log.error("处理 JCPP 会话关闭消息异常: message={}", message, e); + log.error("处理 JCPP 会话关闭消息异常: pileCode={}", + uplinkMessage != null ? uplinkMessage.getPileCode() : "unknown", e); } } } diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppStartChargeConsumer.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppStartChargeConsumer.java index 54c93e516..c096c5a1d 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppStartChargeConsumer.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppStartChargeConsumer.java @@ -54,28 +54,33 @@ public class JcppStartChargeConsumer { @Transactional(rollbackFor = Exception.class) @RabbitListener(queues = JcppConstants.QUEUE_START_CHARGE) - public void handleStartCharge(String message) { + public void handleStartCharge(JcppUplinkMessage uplinkMessage) { try { - log.info("收到 JCPP 启动充电消息: {}", message); - - // 解析消息 - JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class); + // 验证消息 if (uplinkMessage == null || uplinkMessage.getData() == null) { - log.warn("启动充电消息格式错误: {}", message); + log.warn("启动充电消息格式错误"); return; } - // 从 data 中获取信息 - JSONObject data = JSON.parseObject(JSON.toJSONString(uplinkMessage.getData())); - String pileCode = data.getString("pileCode"); + // 从 uplinkMessage 中获取 pileCode + String pileCode = uplinkMessage.getPileCode(); + if (pileCode == null || pileCode.isEmpty()) { + log.warn("启动充电消息缺少 pileCode"); + return; + } + + log.info("收到 JCPP 启动充电消息: pileCode={}, messageType={}", pileCode, uplinkMessage.getMessageType()); + + // 从 data 中获取其他信息 + JSONObject data = JSON.parseObject(uplinkMessage.getData()); String gunNo = data.getString("gunNo"); String startType = data.getString("startType"); String cardNo = data.getString("cardNo"); Boolean needPassword = data.getBoolean("needPassword"); String password = data.getString("password"); - if (pileCode == null || gunNo == null) { - log.warn("启动充电消息缺少必要字段: {}", message); + if (gunNo == null) { + log.warn("启动充电消息缺少 gunNo"); return; } @@ -89,7 +94,8 @@ public class JcppStartChargeConsumer { } } catch (Exception e) { - log.error("处理 JCPP 启动充电消息异常: message={}", message, e); + log.error("处理 JCPP 启动充电消息异常: pileCode={}", + uplinkMessage != null ? uplinkMessage.getPileCode() : "unknown", e); } } diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppTransactionConsumer.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppTransactionConsumer.java index 63b5a9dd4..82d68b921 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppTransactionConsumer.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppTransactionConsumer.java @@ -38,20 +38,25 @@ public class JcppTransactionConsumer { @Transactional(rollbackFor = Exception.class) @RabbitListener(queues = JcppConstants.QUEUE_TRANSACTION) - public void handleTransaction(String message) { + public void handleTransaction(JcppUplinkMessage uplinkMessage) { try { - log.info("收到 JCPP 交易记录消息: {}", message); - - // 解析消息 - JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class); + // 验证消息 if (uplinkMessage == null || uplinkMessage.getData() == null) { - log.warn("交易记录消息格式错误: {}", message); + log.warn("交易记录消息格式错误"); return; } - // 从 data 中获取信息 - JSONObject data = JSON.parseObject(JSON.toJSONString(uplinkMessage.getData())); - String pileCode = data.getString("pileCode"); + // 从 uplinkMessage 中获取 pileCode + String pileCode = uplinkMessage.getPileCode(); + if (pileCode == null || pileCode.isEmpty()) { + log.warn("交易记录消息缺少 pileCode"); + return; + } + + log.info("收到 JCPP 交易记录消息: pileCode={}, messageType={}", pileCode, uplinkMessage.getMessageType()); + + // 从 data 中获取其他信息 + JSONObject data = JSON.parseObject(uplinkMessage.getData()); String gunNo = data.getString("gunNo"); String tradeNo = data.getString("tradeNo"); Long startTs = data.getLong("startTs"); @@ -62,7 +67,7 @@ public class JcppTransactionConsumer { JSONObject detail = data.getJSONObject("detail"); if (tradeNo == null || tradeNo.isEmpty()) { - log.warn("交易记录消息缺少 tradeNo: {}", message); + log.warn("交易记录消息缺少 tradeNo"); return; } @@ -122,7 +127,8 @@ public class JcppTransactionConsumer { // orderBasicInfoService.realTimeOrderSplit(order.getId()); } catch (Exception e) { - log.error("处理 JCPP 交易记录消息异常: message={}", message, e); + log.error("处理 JCPP 交易记录消息异常: pileCode={}", + uplinkMessage != null ? uplinkMessage.getPileCode() : "unknown", e); } } } diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppUplinkMessage.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppUplinkMessage.java index e133ec450..423d0aeb3 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppUplinkMessage.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppUplinkMessage.java @@ -52,7 +52,7 @@ public class JcppUplinkMessage implements Serializable { private Long timestamp; /** - * 具体消息内容(根据 messageType 不同,结构不同) + * 具体消息内容(JSON 字符串格式,根据 messageType 不同,结构不同) */ - private Object data; + private String data; } diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppGunSyncDTO.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppGunSyncDTO.java new file mode 100644 index 000000000..43092c65f --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppGunSyncDTO.java @@ -0,0 +1,43 @@ +package com.jsowell.pile.jcpp.dto.sync; + +import com.alibaba.fastjson2.JSONObject; +import lombok.Data; + +import java.io.Serializable; + +/** + * JCPP 充电枪同步数据传输对象 + * + * @author jsowell + */ +@Data +public class JcppGunSyncDTO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 充电枪编码(对应 Web 的 pile_connector_code) + */ + private String gunCode; + + /** + * 充电枪名称(对应 Web 的 name) + */ + private String gunName; + + /** + * 枪号(从 gunCode 提取最后 2 位) + */ + private String gunNo; + + /** + * 所属充电桩编码(对应 Web 的 pile_sn) + */ + private String pileCode; + + /** + * 附加信息(JSON 格式) + * 包含:webGunId, status, parkNo 等 + */ + private JSONObject additionalInfo; +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppPileSyncDTO.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppPileSyncDTO.java new file mode 100644 index 000000000..87ba60fc4 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppPileSyncDTO.java @@ -0,0 +1,58 @@ +package com.jsowell.pile.jcpp.dto.sync; + +import com.alibaba.fastjson2.JSONObject; +import lombok.Data; + +import java.io.Serializable; + +/** + * JCPP 充电桩同步数据传输对象 + * + * @author jsowell + */ +@Data +public class JcppPileSyncDTO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 充电桩编码(对应 Web 的 sn) + */ + private String pileCode; + + /** + * 充电桩名称(对应 Web 的 name) + */ + private String pileName; + + /** + * 软件协议(对应 Web 的 software_protocol) + */ + private String protocol; + + /** + * 品牌 + */ + private String brand; + + /** + * 型号 + */ + private String model; + + /** + * 制造商 + */ + private String manufacturer; + + /** + * 类型:OPERATION-运营桩, PERSONAL-个人桩 + */ + private String type; + + /** + * 附加信息(JSON 格式) + * 包含:webPileId, webStationId, businessType, secretKey, longitude, latitude, iccid 等 + */ + private JSONObject additionalInfo; +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppSyncRequest.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppSyncRequest.java new file mode 100644 index 000000000..a1021c0c0 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppSyncRequest.java @@ -0,0 +1,38 @@ +package com.jsowell.pile.jcpp.dto.sync; + +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; +import java.util.List; + +/** + * JCPP 同步请求 + * + * @author jsowell + */ +@Data +public class JcppSyncRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 同步类型:FULL-全量, INCREMENTAL-增量 + */ + private String syncType; + + /** + * 上次同步时间(增量同步时使用) + */ + private Date lastSyncTime; + + /** + * 充电桩列表 + */ + private List piles; + + /** + * 充电枪列表 + */ + private List guns; +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppSyncResponse.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppSyncResponse.java new file mode 100644 index 000000000..04d9609a4 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppSyncResponse.java @@ -0,0 +1,139 @@ +package com.jsowell.pile.jcpp.dto.sync; + +import lombok.Data; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * JCPP 同步响应 + * + * @author jsowell + */ +@Data +public class JcppSyncResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 是否成功 + */ + private Boolean success; + + /** + * 消息 + */ + private String message; + + /** + * 总充电桩数 + */ + private Integer totalPiles; + + /** + * 成功充电桩数 + */ + private Integer successPiles; + + /** + * 失败充电桩数 + */ + private Integer failedPiles; + + /** + * 总充电枪数 + */ + private Integer totalGuns; + + /** + * 成功充电枪数 + */ + private Integer successGuns; + + /** + * 失败充电枪数 + */ + private Integer failedGuns; + + /** + * 同步结果列表 + */ + private List results; + + /** + * 错误信息列表 + */ + private List errors; + + /** + * 构建响应 + */ + public static JcppSyncResponse build(List pileResults, List gunResults) { + JcppSyncResponse response = new JcppSyncResponse(); + + // 统计充电桩结果 + int totalPiles = pileResults != null ? pileResults.size() : 0; + int successPiles = 0; + int failedPiles = 0; + + if (pileResults != null) { + for (JcppSyncResult result : pileResults) { + if (Boolean.TRUE.equals(result.getSuccess())) { + successPiles++; + } else { + failedPiles++; + } + } + } + + // 统计充电枪结果 + int totalGuns = gunResults != null ? gunResults.size() : 0; + int successGuns = 0; + int failedGuns = 0; + + if (gunResults != null) { + for (JcppSyncResult result : gunResults) { + if (Boolean.TRUE.equals(result.getSuccess())) { + successGuns++; + } else { + failedGuns++; + } + } + } + + // 设置统计信息 + response.setTotalPiles(totalPiles); + response.setSuccessPiles(successPiles); + response.setFailedPiles(failedPiles); + response.setTotalGuns(totalGuns); + response.setSuccessGuns(successGuns); + response.setFailedGuns(failedGuns); + + // 合并结果 + List allResults = new ArrayList<>(); + if (pileResults != null) { + allResults.addAll(pileResults); + } + if (gunResults != null) { + allResults.addAll(gunResults); + } + response.setResults(allResults); + + // 收集错误信息 + List errors = new ArrayList<>(); + for (JcppSyncResult result : allResults) { + if (Boolean.FALSE.equals(result.getSuccess())) { + errors.add(result.getCode() + ": " + result.getMessage()); + } + } + response.setErrors(errors); + + // 判断整体是否成功 + boolean overallSuccess = (failedPiles == 0 && failedGuns == 0); + response.setSuccess(overallSuccess); + response.setMessage(overallSuccess ? "同步成功" : "同步部分失败"); + + return response; + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppSyncResult.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppSyncResult.java new file mode 100644 index 000000000..6675e102a --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppSyncResult.java @@ -0,0 +1,59 @@ +package com.jsowell.pile.jcpp.dto.sync; + +import lombok.Data; + +import java.io.Serializable; + +/** + * JCPP 单个同步结果 + * + * @author jsowell + */ +@Data +public class JcppSyncResult implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 充电桩编码或充电枪编码 + */ + private String code; + + /** + * JCPP 返回的 ID(UUID) + */ + private String id; + + /** + * 是否成功 + */ + private Boolean success; + + /** + * 消息 + */ + private String message; + + /** + * 创建成功结果 + */ + public static JcppSyncResult success(String code, String id, String message) { + JcppSyncResult result = new JcppSyncResult(); + result.setCode(code); + result.setId(id); + result.setSuccess(true); + result.setMessage(message); + return result; + } + + /** + * 创建失败结果 + */ + public static JcppSyncResult fail(String code, String message) { + JcppSyncResult result = new JcppSyncResult(); + result.setCode(code); + result.setSuccess(false); + result.setMessage(message); + return result; + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppJsonMessageHandler.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppJsonMessageHandler.java new file mode 100644 index 000000000..e6d8979d3 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppJsonMessageHandler.java @@ -0,0 +1,19 @@ +package com.jsowell.pile.jcpp.service; + +import com.jsowell.pile.jcpp.dto.JcppUplinkMessage; + +/** + * JCPP JSON 消息处理器接口 + * 处理从 JCPP 接收到的各种上行消息(JSON 格式) + * + * @author jsowell + */ +public interface IJcppJsonMessageHandler { + + /** + * 处理上行消息 + * + * @param message JSON 上行消息 + */ + void handleUplinkMessage(JcppUplinkMessage message); +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppPileSyncService.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppPileSyncService.java new file mode 100644 index 000000000..9286e1d73 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppPileSyncService.java @@ -0,0 +1,36 @@ +package com.jsowell.pile.jcpp.service; + +import com.jsowell.pile.jcpp.dto.sync.JcppSyncResponse; + +import java.util.Date; + +/** + * JCPP 充电桩同步服务接口 + * + * @author jsowell + */ +public interface IJcppPileSyncService { + + /** + * 全量同步充电桩数据到 JCPP + * + * @return 同步结果 + */ + JcppSyncResponse syncAllPiles(); + + /** + * 增量同步充电桩数据到 JCPP + * + * @param lastSyncTime 上次同步时间(可选,如果为 null 则查询最后一次成功的同步记录) + * @return 同步结果 + */ + JcppSyncResponse syncIncrementalPiles(Date lastSyncTime); + + /** + * 同步单个充电桩 + * + * @param pileSn 充电桩编号 + * @return 是否成功 + */ + boolean syncSinglePile(String pileSn); +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppJsonMessageHandlerImpl.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppJsonMessageHandlerImpl.java new file mode 100644 index 000000000..607fc699b --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppJsonMessageHandlerImpl.java @@ -0,0 +1,645 @@ +package com.jsowell.pile.jcpp.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.jsowell.common.enums.ykc.StartTypeEnum; +import com.jsowell.common.util.StringUtils; +import com.jsowell.common.util.id.IdUtils; +import com.jsowell.pile.domain.*; +import com.jsowell.pile.jcpp.constant.JcppConstants; +import com.jsowell.pile.jcpp.dto.JcppPricingModel; +import com.jsowell.pile.jcpp.dto.JcppUplinkMessage; +import com.jsowell.pile.jcpp.service.IJcppDownlinkService; +import com.jsowell.pile.jcpp.service.IJcppJsonMessageHandler; +import com.jsowell.pile.jcpp.util.PricingModelConverter; +import com.jsowell.pile.service.*; +import com.jsowell.pile.vo.web.BillingTemplateVO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +/** + * JCPP JSON 消息处理器实现 + * 整合原有各个消费者的处理逻辑,支持分区消费 + * + * @author jsowell + */ +@Slf4j +@Service +public class JcppJsonMessageHandlerImpl implements IJcppJsonMessageHandler { + + private static final String HEARTBEAT_KEY_PREFIX = "jcpp:heartbeat:"; + private static final long HEARTBEAT_EXPIRE_SECONDS = 180L; + private static final String REALTIME_DATA_KEY_PREFIX = "jcpp:realtime:"; + private static final long REALTIME_DATA_EXPIRE_SECONDS = 300L; + + @Autowired + private PileBasicInfoService pileBasicInfoService; + + @Autowired + private PileConnectorInfoService pileConnectorInfoService; + + @Autowired + private PileAuthCardService pileAuthCardService; + + @Autowired + private MemberBasicInfoService memberBasicInfoService; + + @Autowired + private MemberWalletInfoService memberWalletInfoService; + + @Autowired + private PileStationWhitelistService pileStationWhitelistService; + + @Autowired + private OrderBasicInfoService orderBasicInfoService; + + @Autowired + private PileBillingTemplateService pileBillingTemplateService; + + @Autowired + private IJcppDownlinkService jcppDownlinkService; + + @Autowired + private StringRedisTemplate stringRedisTemplate; + + @Override + public void handleUplinkMessage(JcppUplinkMessage uplinkMessage) { + String messageType = uplinkMessage.getMessageType(); + String pileCode = uplinkMessage.getPileCode(); + + log.debug("处理上行消息: pileCode={}, messageType={}", pileCode, messageType); + + // 根据消息类型分发处理 + switch (messageType) { + case JcppConstants.MessageType.LOGIN: + handleLogin(uplinkMessage); + break; + case JcppConstants.MessageType.HEARTBEAT: + handleHeartbeat(uplinkMessage); + break; + case JcppConstants.MessageType.GUN_STATUS: + handleGunStatus(uplinkMessage); + break; + case JcppConstants.MessageType.REAL_TIME_DATA: + handleRealTimeData(uplinkMessage); + break; + case JcppConstants.MessageType.TRANSACTION_RECORD: + handleTransactionRecord(uplinkMessage); + break; + case JcppConstants.MessageType.START_CHARGE: + handleStartCharge(uplinkMessage); + break; + case JcppConstants.MessageType.QUERY_PRICING: + handleQueryPricing(uplinkMessage); + break; + case JcppConstants.MessageType.VERIFY_PRICING: + handleVerifyPricing(uplinkMessage); + break; + case JcppConstants.MessageType.SESSION_CLOSE: + handleSessionClose(uplinkMessage); + break; + case JcppConstants.MessageType.REMOTE_START_RESULT: + case JcppConstants.MessageType.REMOTE_STOP_RESULT: + handleRemoteResult(uplinkMessage); + break; + default: + log.warn("未知的消息类型: messageType={}, pileCode={}", messageType, pileCode); + } + } + + /** + * 处理登录请求 + */ + private void handleLogin(JcppUplinkMessage uplinkMessage) { + String pileCode = uplinkMessage.getPileCode(); + JSONObject data = JSON.parseObject(uplinkMessage.getData()); + + log.info("处理登录请求: pileCode={}", pileCode); + + // 查询充电桩是否存在 + PileBasicInfo pileInfo = pileBasicInfoService.selectPileBasicInfoBySN(pileCode); + boolean exists = pileInfo != null; + + if (exists) { + log.info("充电桩登录成功: pileCode={}", pileCode); + } else { + log.warn("充电桩不存在: pileCode={}", pileCode); + } + + // 发送登录应答 + jcppDownlinkService.sendLoginAck(pileCode, exists); + } + + /** + * 处理心跳请求 + */ + private void handleHeartbeat(JcppUplinkMessage uplinkMessage) { + String pileCode = uplinkMessage.getPileCode(); + + // 更新最后活跃时间到 Redis + String key = HEARTBEAT_KEY_PREFIX + pileCode; + stringRedisTemplate.opsForValue().set(key, String.valueOf(System.currentTimeMillis()), + HEARTBEAT_EXPIRE_SECONDS, TimeUnit.SECONDS); + + log.debug("收到充电桩心跳: pileCode={}", pileCode); + } + + /** + * 处理枪状态上报 + */ + private void handleGunStatus(JcppUplinkMessage uplinkMessage) { + String pileCode = uplinkMessage.getPileCode(); + JSONObject data = JSON.parseObject(uplinkMessage.getData()); + String gunNo = data.getString("gunNo"); + String gunRunStatus = data.getString("gunRunStatus"); + + log.info("处理枪状态: pileCode={}, gunNo={}, status={}", pileCode, gunNo, gunRunStatus); + + // 映射状态 + String systemStatus = mapGunStatus(gunRunStatus); + + // 更新枪状态 + String pileConnectorCode = pileCode + gunNo; + int result = pileConnectorInfoService.updateConnectorStatus(pileConnectorCode, systemStatus); + + if (result > 0) { + log.info("更新枪状态成功: pileConnectorCode={}, status={}", pileConnectorCode, systemStatus); + } else { + log.warn("更新枪状态失败: pileConnectorCode={}", pileConnectorCode); + } + + // 记录故障信息 + if (data.containsKey("faultMessages") && data.getJSONArray("faultMessages") != null) { + log.warn("充电枪故障: pileConnectorCode={}, faults={}", pileConnectorCode, data.getJSONArray("faultMessages")); + } + } + + /** + * 处理充电进度数据 + */ + private void handleRealTimeData(JcppUplinkMessage uplinkMessage) { + String pileCode = uplinkMessage.getPileCode(); + JSONObject data = JSON.parseObject(uplinkMessage.getData()); + String gunNo = data.getString("gunNo"); + String tradeNo = data.getString("tradeNo"); + + if (tradeNo == null || tradeNo.isEmpty()) { + log.debug("实时数据消息缺少 tradeNo"); + return; + } + + // 将实时数据缓存到 Redis + String key = REALTIME_DATA_KEY_PREFIX + tradeNo; + stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(data), + REALTIME_DATA_EXPIRE_SECONDS, TimeUnit.SECONDS); + + // 根据 tradeNo 查询订单 + OrderBasicInfo order = orderBasicInfoService.getOrderInfoByTransactionCode(tradeNo); + if (order != null && "1".equals(order.getOrderStatus())) { + // 更新订单实时数据 + OrderBasicInfo updateOrder = new OrderBasicInfo(); + updateOrder.setId(order.getId()); + String totalChargingCostYuan = data.getString("totalChargingCostYuan"); + if (totalChargingCostYuan != null && !totalChargingCostYuan.isEmpty()) { + updateOrder.setOrderAmount(new BigDecimal(totalChargingCostYuan)); + } + orderBasicInfoService.updateOrderBasicInfo(updateOrder); + } + + // 更新枪状态为充电中 + String pileConnectorCode = pileCode + gunNo; + pileConnectorInfoService.updateConnectorStatus(pileConnectorCode, "3"); + + log.debug("处理充电进度: tradeNo={}, pileCode={}", tradeNo, pileCode); + } + + /** + * 处理交易记录 + */ + @Transactional(rollbackFor = Exception.class) + private void handleTransactionRecord(JcppUplinkMessage uplinkMessage) { + String pileCode = uplinkMessage.getPileCode(); + JSONObject data = JSON.parseObject(uplinkMessage.getData()); + String gunNo = data.getString("gunNo"); + String tradeNo = data.getString("tradeNo"); + + if (tradeNo == null || tradeNo.isEmpty()) { + log.warn("交易记录消息缺少 tradeNo"); + return; + } + + log.info("处理交易记录: tradeNo={}, pileCode={}, gunNo={}", tradeNo, pileCode, gunNo); + + // 根据 tradeNo 查询订单 + OrderBasicInfo order = orderBasicInfoService.getOrderInfoByTransactionCode(tradeNo); + if (order == null) { + log.warn("订单不存在: tradeNo={}", tradeNo); + jcppDownlinkService.sendTransactionRecordAck(pileCode, tradeNo, false); + return; + } + + // 幂等性检查 + if ("2".equals(order.getOrderStatus())) { + log.info("订单已处理,跳过: tradeNo={}", tradeNo); + jcppDownlinkService.sendTransactionRecordAck(pileCode, tradeNo, true); + return; + } + + // 更新订单信息 + OrderBasicInfo updateOrder = new OrderBasicInfo(); + updateOrder.setId(order.getId()); + updateOrder.setOrderStatus("2"); // 充电完成 + + Long startTs = data.getLong("startTs"); + Long endTs = data.getLong("endTs"); + if (startTs != null && startTs > 0) { + updateOrder.setChargeStartTime(new Date(startTs)); + } + if (endTs != null && endTs > 0) { + updateOrder.setChargeEndTime(new Date(endTs)); + } + + String totalAmountYuan = data.getString("totalAmountYuan"); + if (totalAmountYuan != null && !totalAmountYuan.isEmpty()) { + updateOrder.setOrderAmount(new BigDecimal(totalAmountYuan)); + } + + String stopReason = data.getString("stopReason"); + if (stopReason != null && !stopReason.isEmpty()) { + updateOrder.setReason(stopReason); + } + + orderBasicInfoService.updateOrderBasicInfo(updateOrder); + log.info("更新订单完成: tradeNo={}", tradeNo); + + // 更新枪状态为空闲 + String pileConnectorCode = pileCode + gunNo; + pileConnectorInfoService.updateConnectorStatus(pileConnectorCode, "1"); + + // 发送交易记录应答 + jcppDownlinkService.sendTransactionRecordAck(pileCode, tradeNo, true); + + // TODO: 触发结算流程 + // orderBasicInfoService.realTimeOrderSplit(order.getId()); + } + + /** + * 处理启动充电请求(刷卡) + */ + @Transactional(rollbackFor = Exception.class) + private void handleStartCharge(JcppUplinkMessage uplinkMessage) { + String pileCode = uplinkMessage.getPileCode(); + JSONObject data = JSON.parseObject(uplinkMessage.getData()); + String gunNo = data.getString("gunNo"); + String startType = data.getString("startType"); + String cardNo = data.getString("cardNo"); + + if (pileCode == null || gunNo == null) { + log.warn("启动充电消息缺少必要字段"); + return; + } + + log.info("处理启动充电请求: pileCode={}, gunNo={}, cardNo={}", pileCode, gunNo, cardNo); + + // 处理刷卡启动 + if ("CARD".equals(startType)) { + handleCardStartCharge(pileCode, gunNo, cardNo); + } else { + log.warn("不支持的启动类型: {}", startType); + jcppDownlinkService.sendStartChargeAck(pileCode, gunNo, null, cardNo, null, + false, "不支持的启动类型"); + } + } + + /** + * 处理刷卡启动充电 + */ + private void handleCardStartCharge(String pileCode, String gunNo, String cardNo) { + String failReason = null; + String tradeNo = null; + String limitYuan = null; + boolean authSuccess = false; + + try { + // 1. 查询充电桩信息 + PileBasicInfo pileInfo = pileBasicInfoService.selectPileBasicInfoBySN(pileCode); + if (pileInfo == null) { + failReason = "充电桩不存在"; + sendStartChargeAck(pileCode, gunNo, tradeNo, cardNo, limitYuan, false, failReason); + return; + } + + // 检查充电桩状态 + if (pileInfo.getDelFlag() != null && StringUtils.equals(pileInfo.getDelFlag(), "1")) { + failReason = "充电桩已停用"; + sendStartChargeAck(pileCode, gunNo, tradeNo, cardNo, limitYuan, false, failReason); + return; + } + + // 2. 查询授权卡信息 + PileAuthCard authCard = pileAuthCardService.selectCardInfoByLogicCard(cardNo); + if (authCard == null) { + failReason = "账户不存在"; + sendStartChargeAck(pileCode, gunNo, tradeNo, cardNo, limitYuan, false, failReason); + return; + } + + // 检查卡状态 + if (authCard.getStatus() != null && !StringUtils.equals(authCard.getStatus(), "1")) { + failReason = "账户已冻结"; + sendStartChargeAck(pileCode, gunNo, tradeNo, cardNo, limitYuan, false, failReason); + return; + } + + // 3. 查询会员信息和钱包余额 + String memberId = authCard.getMemberId(); + BigDecimal balance = BigDecimal.ZERO; + + if (memberId != null) { + MemberWalletInfo walletInfo = memberWalletInfoService.selectByMemberId(memberId, String.valueOf(pileInfo.getMerchantId())); + if (walletInfo != null) { + balance = walletInfo.getPrincipalBalance(); + if (walletInfo.getGiftBalance() != null) { + balance = balance.add(walletInfo.getGiftBalance()); + } + } + } + + // 4. 检查白名单 + boolean isWhitelist = checkWhitelist(pileInfo.getStationId(), cardNo, memberId); + + // 5. 验证余额(非白名单用户需要检查余额) + BigDecimal minAmount = new BigDecimal("1.00"); + if (!isWhitelist && balance.compareTo(minAmount) < 0) { + failReason = "余额不足"; + sendStartChargeAck(pileCode, gunNo, tradeNo, cardNo, limitYuan, false, failReason); + return; + } + + // 6. 生成交易流水号 + tradeNo = IdUtils.fastSimpleUUID(); + limitYuan = balance.toString(); + + // 7. 创建充电订单 + OrderBasicInfo order = new OrderBasicInfo(); + order.setOrderCode(tradeNo); + order.setPileSn(pileCode); + order.setConnectorCode(pileCode + gunNo); + order.setMemberId(memberId); + order.setStationId(String.valueOf(pileInfo.getStationId())); + order.setMerchantId(String.valueOf(pileInfo.getMerchantId())); + order.setOrderStatus("0"); // 待支付/启动中 + order.setPayMode(String.valueOf(isWhitelist ? 3 : 1)); // 3-白名单支付, 1-余额支付 + order.setCreateTime(new Date()); + order.setStartType(StartTypeEnum.NOW.getValue()); + + orderBasicInfoService.insert(order); + log.info("创建充电订单: tradeNo={}, pileCode={}, gunNo={}", tradeNo, pileCode, gunNo); + + authSuccess = true; + + } catch (Exception e) { + log.error("处理刷卡启动充电异常: pileCode={}, gunNo={}, cardNo={}", pileCode, gunNo, cardNo, e); + failReason = "系统错误"; + } + + // 发送鉴权结果 + sendStartChargeAck(pileCode, gunNo, tradeNo, cardNo, limitYuan, authSuccess, failReason); + } + + /** + * 检查白名单 + */ + private boolean checkWhitelist(Long stationId, String cardNo, String memberId) { + try { + if (stationId == null) { + return false; + } + PileStationWhitelist pileStationWhitelist = pileStationWhitelistService.queryWhitelistByMemberId(String.valueOf(stationId), memberId); + return pileStationWhitelist != null; + } catch (Exception e) { + log.error("检查白名单异常: stationId={}", stationId, e); + } + return false; + } + + /** + * 发送启动充电应答 + */ + private void sendStartChargeAck(String pileCode, String gunNo, String tradeNo, String cardNo, + String limitYuan, boolean authSuccess, String failReason) { + jcppDownlinkService.sendStartChargeAck(pileCode, gunNo, tradeNo, cardNo, limitYuan, + authSuccess, failReason); + if (authSuccess) { + log.info("刷卡鉴权成功: pileCode={}, gunNo={}, cardNo={}, tradeNo={}", + pileCode, gunNo, cardNo, tradeNo); + } else { + log.warn("刷卡鉴权失败: pileCode={}, gunNo={}, cardNo={}, reason={}", + pileCode, gunNo, cardNo, failReason); + } + } + + /** + * 处理查询计费模板请求 + */ + private void handleQueryPricing(JcppUplinkMessage uplinkMessage) { + String pileCode = uplinkMessage.getPileCode(); + + log.info("处理查询计费模板请求: pileCode={}", pileCode); + + try { + // 根据 pileCode 查询充电桩 + PileBasicInfo pileInfo = pileBasicInfoService.selectPileBasicInfoBySN(pileCode); + if (pileInfo == null) { + log.warn("充电桩不存在: pileCode={}", pileCode); + jcppDownlinkService.sendQueryPricingAck(pileCode, null, null); + return; + } + + // 获取关联的计费模板 + BillingTemplateVO billingTemplateVO = pileBillingTemplateService.selectBillingTemplateDetailByPileSn(pileCode); + if (billingTemplateVO == null || billingTemplateVO.getTemplateId() == null) { + log.warn("充电桩未配置计费模板: pileCode={}", pileCode); + jcppDownlinkService.sendQueryPricingAck(pileCode, null, null); + return; + } + Long billingTemplateId = Long.parseLong(billingTemplateVO.getTemplateId()); + + // 查询计费模板 + PileBillingTemplate template = pileBillingTemplateService.selectPileBillingTemplateById(billingTemplateId); + if (template == null) { + log.warn("计费模板不存在: pileCode={}, billingTemplateId={}", pileCode, billingTemplateId); + jcppDownlinkService.sendQueryPricingAck(pileCode, null, null); + return; + } + + // 转换为 JCPP 格式 + JcppPricingModel pricingModel = PricingModelConverter.convert(template); + + // 发送应答 + jcppDownlinkService.sendQueryPricingAck(pileCode, billingTemplateId, pricingModel); + log.info("发送计费模板查询应答: pileCode={}, pricingId={}", pileCode, billingTemplateId); + + } catch (Exception e) { + log.error("处理查询计费模板异常: pileCode=", pileCode, e); + jcppDownlinkService.sendQueryPricingAck(pileCode, null, null); + } + } + + /** + * 处理校验计费模板请求 + */ + private void handleVerifyPricing(JcppUplinkMessage uplinkMessage) { + String pileCode = uplinkMessage.getPileCode(); + JSONObject data = JSON.parseObject(uplinkMessage.getData()); + Long pricingId = data.getLong("pricingId"); + + log.info("处理校验计费模板请求: pileCode={}, pricingId={}", pileCode, pricingId); + + try { + boolean success = false; + + if (pricingId != null) { + PileBillingTemplate template = pileBillingTemplateService.selectPileBillingTemplateById(pricingId); + success = template != null; + } + + // 发送应答 + jcppDownlinkService.sendVerifyPricingAck(pileCode, success, pricingId); + log.info("发送计费模板校验应答: pileCode={}, pricingId={}, success={}", pileCode, pricingId, success); + + } catch (Exception e) { + log.error("处理校验计费模板异常: pileCode={}, pricingId={}", pileCode, pricingId, e); + jcppDownlinkService.sendVerifyPricingAck(pileCode, false, pricingId); + } + } + + /** + * 处理会话关闭事件 + */ + private void handleSessionClose(JcppUplinkMessage uplinkMessage) { + String pileCode = uplinkMessage.getPileCode(); + JSONObject data = JSON.parseObject(uplinkMessage.getData()); + String reason = data.getString("reason"); + + log.info("处理会话关闭: pileCode={}, reason={}", pileCode, reason); + + // 更新所有枪状态为离线 + int result = pileConnectorInfoService.updateConnectorStatusByPileSn(pileCode, "0"); + log.info("更新枪状态为离线: pileCode={}, affectedRows={}", pileCode, result); + + // TODO: 查询是否有正在充电的订单,如果有则标记为异常 + } + + /** + * 处理远程操作结果 + */ + @Transactional(rollbackFor = Exception.class) + private void handleRemoteResult(JcppUplinkMessage uplinkMessage) { + String messageType = uplinkMessage.getMessageType(); + JSONObject data = JSON.parseObject(uplinkMessage.getData()); + + if (JcppConstants.MessageType.REMOTE_START_RESULT.equals(messageType)) { + handleRemoteStartResult(data); + } else if (JcppConstants.MessageType.REMOTE_STOP_RESULT.equals(messageType)) { + handleRemoteStopResult(data); + } + } + + /** + * 处理远程启动结果 + */ + private void handleRemoteStartResult(JSONObject data) { + String tradeNo = data.getString("tradeNo"); + Boolean success = data.getBoolean("success"); + String failReason = data.getString("failReason"); + + // 从 data 中获取 pileCode 和 gunNo(如果有) + String pileCode = data.getString("pileCode"); + String gunNo = data.getString("gunNo"); + + if (tradeNo == null || tradeNo.isEmpty()) { + log.warn("远程启动结果缺少 tradeNo"); + return; + } + + OrderBasicInfo order = orderBasicInfoService.getOrderInfoByTransactionCode(tradeNo); + if (order == null) { + log.warn("订单不存在: tradeNo={}", tradeNo); + return; + } + + if (Boolean.TRUE.equals(success)) { + // 启动成功 + OrderBasicInfo updateOrder = new OrderBasicInfo(); + updateOrder.setId(order.getId()); + updateOrder.setOrderStatus("1"); // 充电中 + orderBasicInfoService.updateOrderBasicInfo(updateOrder); + + String pileConnectorCode = pileCode + gunNo; + pileConnectorInfoService.updateConnectorStatus(pileConnectorCode, "3"); + + log.info("远程启动成功: tradeNo={}", tradeNo); + } else { + // 启动失败 + OrderBasicInfo updateOrder = new OrderBasicInfo(); + updateOrder.setId(order.getId()); + updateOrder.setOrderStatus("3"); // 已取消 + updateOrder.setReason("启动失败: " + failReason); + orderBasicInfoService.updateOrderBasicInfo(updateOrder); + + log.warn("远程启动失败: tradeNo={}, reason={}", tradeNo, failReason); + + // TODO: 如果已预付费,触发退款流程 + } + } + + /** + * 处理远程停止结果 + */ + private void handleRemoteStopResult(JSONObject data) { + Boolean success = data.getBoolean("success"); + String failReason = data.getString("failReason"); + + // 从 data 中获取 pileCode 和 gunNo(如果有) + String pileCode = data.getString("pileCode"); + String gunNo = data.getString("gunNo"); + + if (Boolean.TRUE.equals(success)) { + log.info("远程停止成功: pileCode={}, gunNo={}", pileCode, gunNo); + } else { + log.warn("远程停止失败: pileCode=, gunNo={}, reason={}", pileCode, gunNo, failReason); + } + } + + /** + * 映射枪状态:JCPP 状态 -> 系统状态 + */ + private String mapGunStatus(String jcppStatus) { + if (jcppStatus == null) { + return "0"; + } + switch (jcppStatus) { + case "IDLE": + return "1"; // 空闲 + case "INSERTED": + return "2"; // 占用(未充电) + case "CHARGING": + return "3"; // 占用(充电中) + case "CHARGE_COMPLETE": + return "2"; // 占用(未充电)- 充电完成但未拔枪 + case "FAULT": + return "255"; // 故障 + case "UNKNOWN": + default: + return "0"; // 离网 + } + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppPileSyncServiceImpl.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppPileSyncServiceImpl.java new file mode 100644 index 000000000..23523992c --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppPileSyncServiceImpl.java @@ -0,0 +1,465 @@ +package com.jsowell.pile.jcpp.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.jsowell.common.util.StringUtils; +import com.jsowell.pile.domain.JcppSyncRecord; +import com.jsowell.pile.domain.PileBasicInfo; +import com.jsowell.pile.domain.PileConnectorInfo; +import com.jsowell.pile.jcpp.dto.sync.*; +import com.jsowell.pile.jcpp.service.IJcppPileSyncService; +import com.jsowell.pile.mapper.JcppSyncRecordMapper; +import com.jsowell.pile.service.PileBasicInfoService; +import com.jsowell.pile.service.PileConnectorInfoService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * JCPP 充电桩同步服务实现 + * + * @author jsowell + */ +@Slf4j +@Service +public class JcppPileSyncServiceImpl implements IJcppPileSyncService { + + @Autowired + private PileBasicInfoService pileBasicInfoService; + + @Autowired + private PileConnectorInfoService pileConnectorInfoService; + + @Autowired + private JcppSyncRecordMapper jcppSyncRecordMapper; + + @Autowired + private RestTemplate restTemplate; + + @Value("${jcpp.sync.api-url:http://localhost:8080/api/sync}") + private String jcppApiUrl; + + @Value("${jcpp.sync.batch-size:100}") + private int batchSize; + + @Value("${jcpp.sync.timeout:60000}") + private int timeout; + + /** + * 全量同步充电桩数据到 JCPP + */ + @Override + public JcppSyncResponse syncAllPiles() { + log.info("开始全量同步充电桩数据到 JCPP"); + + // 创建同步记录 + JcppSyncRecord record = createSyncRecord("FULL"); + + try { + // 1. 查询所有充电桩(未删除的) + PileBasicInfo queryPile = new PileBasicInfo(); + queryPile.setDelFlag("0"); + List pileList = pileBasicInfoService.selectPileBasicInfoList(queryPile); + + log.info("查询到 {} 个充电桩", pileList.size()); + + // 2. 查询所有充电枪(未删除的) + PileConnectorInfo queryGun = new PileConnectorInfo(); + queryGun.setDelFlag("0"); + List gunList = pileConnectorInfoService.selectPileConnectorInfoList(queryGun); + + log.info("查询到 {} 个充电枪", gunList.size()); + + // 3. 转换数据格式 + List pileDTOs = convertPilesToDTO(pileList); + List gunDTOs = convertGunsToDTO(gunList); + + // 4. 调用 JCPP 同步接口 + JcppSyncResponse response = callJcppSyncApi(pileDTOs, gunDTOs); + + // 5. 更新同步记录 + updateSyncRecord(record, response, "SUCCESS"); + + log.info("全量同步完成: 充电桩 {}/{}, 充电枪 {}/{}", + response.getSuccessPiles(), response.getTotalPiles(), + response.getSuccessGuns(), response.getTotalGuns()); + + return response; + + } catch (Exception e) { + log.error("全量同步失败", e); + updateSyncRecord(record, null, "FAILED", e.getMessage()); + throw new RuntimeException("全量同步失败: " + e.getMessage(), e); + } + } + + /** + * 增量同步充电桩数据到 JCPP + */ + @Override + public JcppSyncResponse syncIncrementalPiles(Date lastSyncTime) { + log.info("开始增量同步充电桩数据到 JCPP"); + + // 如果未指定上次同步时间,查询最后一次成功的同步记录 + if (lastSyncTime == null) { + JcppSyncRecord lastRecord = jcppSyncRecordMapper.selectLastSuccessRecord("INCREMENTAL"); + if (lastRecord != null) { + lastSyncTime = lastRecord.getStartTime(); + log.info("使用最后一次成功同步时间: {}", lastSyncTime); + } else { + // 如果没有历史记录,使用全量同步 + log.warn("未找到历史同步记录,改为全量同步"); + return syncAllPiles(); + } + } + + // 创建同步记录 + JcppSyncRecord record = createSyncRecord("INCREMENTAL"); + + try { + // 1. 查询更新时间大于 lastSyncTime 的充电桩 + // 注意:这里暂时使用全量查询,然后在内存中过滤 + // TODO: 后续可以在 Mapper 中添加按 updateTime 查询的方法以提升性能 + PileBasicInfo queryPile = new PileBasicInfo(); + queryPile.setDelFlag("0"); + List allPiles = pileBasicInfoService.selectPileBasicInfoList(queryPile); + + // 过滤出更新时间大于 lastSyncTime 的充电桩 + final Date finalLastSyncTime = lastSyncTime; + List pileList = allPiles.stream() + .filter(pile -> pile.getUpdateTime() != null && pile.getUpdateTime().after(finalLastSyncTime)) + .collect(java.util.stream.Collectors.toList()); + + log.info("查询到 {} 个更新的充电桩", pileList.size()); + + // 2. 查询更新时间大于 lastSyncTime 的充电枪 + PileConnectorInfo queryGun = new PileConnectorInfo(); + queryGun.setDelFlag("0"); + List allGuns = pileConnectorInfoService.selectPileConnectorInfoList(queryGun); + + // 过滤出更新时间大于 lastSyncTime 的充电枪 + List gunList = allGuns.stream() + .filter(gun -> gun.getUpdateTime() != null && gun.getUpdateTime().after(finalLastSyncTime)) + .collect(java.util.stream.Collectors.toList()); + + log.info("查询到 {} 个更新的充电枪", gunList.size()); + + // 3. 转换数据格式 + List pileDTOs = convertPilesToDTO(pileList); + List gunDTOs = convertGunsToDTO(gunList); + + // 4. 调用 JCPP 同步接口 + JcppSyncResponse response = callJcppSyncApi(pileDTOs, gunDTOs); + + // 5. 更新同步记录 + updateSyncRecord(record, response, "SUCCESS"); + + log.info("增量同步完成: 充电桩 {}/{}, 充电枪 {}/{}", + response.getSuccessPiles(), response.getTotalPiles(), + response.getSuccessGuns(), response.getTotalGuns()); + + return response; + + } catch (Exception e) { + log.error("增量同步失败", e); + updateSyncRecord(record, null, "FAILED", e.getMessage()); + throw new RuntimeException("增量同步失败: " + e.getMessage(), e); + } + } + + /** + * 同步单个充电桩 + */ + @Override + public boolean syncSinglePile(String pileSn) { + log.info("开始同步单个充电桩: {}", pileSn); + + try { + // 1. 查询充电桩 + PileBasicInfo pile = pileBasicInfoService.selectPileBasicInfoBySN(pileSn); + if (pile == null) { + log.warn("充电桩不存在: {}", pileSn); + return false; + } + + // 2. 查询该充电桩的所有充电枪 + PileConnectorInfo queryGun = new PileConnectorInfo(); + queryGun.setPileSn(pileSn); + queryGun.setDelFlag("0"); + List gunList = pileConnectorInfoService.selectPileConnectorInfoList(queryGun); + + // 3. 转换数据格式 + List pileDTOs = convertPilesToDTO(List.of(pile)); + List gunDTOs = convertGunsToDTO(gunList); + + // 4. 调用 JCPP 同步接口 + JcppSyncResponse response = callJcppSyncApi(pileDTOs, gunDTOs); + + log.info("单个充电桩同步完成: {}, 结果: {}", pileSn, response.getSuccess()); + + return response.getSuccess(); + + } catch (Exception e) { + log.error("同步单个充电桩失败: {}", pileSn, e); + return false; + } + } + + /** + * 转换充电桩数据为 DTO + */ + private List convertPilesToDTO(List pileList) { + List dtoList = new ArrayList<>(); + + for (PileBasicInfo pile : pileList) { + JcppPileSyncDTO dto = new JcppPileSyncDTO(); + + // 基本字段 + dto.setPileCode(pile.getSn()); + dto.setPileName(pile.getName()); + dto.setProtocol(pile.getSoftwareProtocol()); + + // 品牌、型号、制造商(可为空) + dto.setBrand(null); // Web 项目中没有这些字段 + dto.setModel(null); + dto.setManufacturer(null); + + // 类型映射:1-运营桩 → OPERATION, 2-个人桩 → PERSONAL + String type = "OPERATION"; // 默认运营桩 + if ("2".equals(pile.getBusinessType())) { + type = "PERSONAL"; + } + dto.setType(type); + + // 构建附加信息 + JSONObject additionalInfo = new JSONObject(); + additionalInfo.put("webPileId", pile.getId()); + additionalInfo.put("webStationId", pile.getStationId()); + additionalInfo.put("businessType", pile.getBusinessType()); + additionalInfo.put("secretKey", pile.getSecretKey()); + additionalInfo.put("longitude", pile.getLongitude()); + additionalInfo.put("latitude", pile.getLatitude()); + additionalInfo.put("iccid", pile.getIccid()); + additionalInfo.put("merchantId", pile.getMerchantId()); + additionalInfo.put("vinFlag", pile.getVinFlag()); + + dto.setAdditionalInfo(additionalInfo); + + dtoList.add(dto); + } + + return dtoList; + } + + /** + * 转换充电枪数据为 DTO + */ + private List convertGunsToDTO(List gunList) { + List dtoList = new ArrayList<>(); + + for (PileConnectorInfo gun : gunList) { + JcppGunSyncDTO dto = new JcppGunSyncDTO(); + + // 基本字段 + dto.setGunCode(gun.getPileConnectorCode()); + dto.setGunName(gun.getName()); + dto.setPileCode(gun.getPileSn()); + + // 提取枪号(最后 2 位) + String gunNo = extractGunNo(gun.getPileConnectorCode()); + dto.setGunNo(gunNo); + + // 构建附加信息 + JSONObject additionalInfo = new JSONObject(); + additionalInfo.put("webGunId", gun.getId()); + additionalInfo.put("status", gun.getStatus()); + additionalInfo.put("parkNo", gun.getParkNo()); + + dto.setAdditionalInfo(additionalInfo); + + dtoList.add(dto); + } + + return dtoList; + } + + /** + * 从充电枪编码中提取枪号(最后 2 位) + */ + private String extractGunNo(String gunCode) { + if (StringUtils.isEmpty(gunCode) || gunCode.length() < 2) { + return "01"; // 默认值 + } + return gunCode.substring(gunCode.length() - 2); + } + + /** + * 调用 JCPP 同步接口 + */ + private JcppSyncResponse callJcppSyncApi(List pileDTOs, List gunDTOs) { + List pileResults = new ArrayList<>(); + List gunResults = new ArrayList<>(); + + try { + // 1. 同步充电桩 + if (pileDTOs != null && !pileDTOs.isEmpty()) { + pileResults = syncPilesToJcpp(pileDTOs); + } + + // 2. 同步充电枪 + if (gunDTOs != null && !gunDTOs.isEmpty()) { + gunResults = syncGunsToJcpp(gunDTOs); + } + + // 3. 构建响应 + return JcppSyncResponse.build(pileResults, gunResults); + + } catch (Exception e) { + log.error("调用 JCPP 同步接口失败", e); + throw new RuntimeException("调用 JCPP 同步接口失败: " + e.getMessage(), e); + } + } + + /** + * 同步充电桩到 JCPP + */ + private List syncPilesToJcpp(List pileDTOs) { + String url = jcppApiUrl + "/piles"; + + // 构建请求体 + JSONObject requestBody = new JSONObject(); + requestBody.put("piles", pileDTOs); + + // 设置请求头 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(requestBody.toJSONString(), headers); + + try { + // 发送请求 + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + if (response.getStatusCode() == HttpStatus.OK) { + // 解析响应 + JSONObject responseBody = JSON.parseObject(response.getBody()); + List results = responseBody.getList("results", JcppSyncResult.class); + return results != null ? results : new ArrayList<>(); + } else { + log.error("JCPP 充电桩同步接口返回错误: {}", response.getStatusCode()); + // 返回失败结果 + List results = new ArrayList<>(); + for (JcppPileSyncDTO dto : pileDTOs) { + results.add(JcppSyncResult.fail(dto.getPileCode(), "接口返回错误: " + response.getStatusCode())); + } + return results; + } + } catch (Exception e) { + log.error("调用 JCPP 充电桩同步接口异常", e); + // 返回失败结果 + List results = new ArrayList<>(); + for (JcppPileSyncDTO dto : pileDTOs) { + results.add(JcppSyncResult.fail(dto.getPileCode(), "接口调用异常: " + e.getMessage())); + } + return results; + } + } + + /** + * 同步充电枪到 JCPP + */ + private List syncGunsToJcpp(List gunDTOs) { + String url = jcppApiUrl + "/guns"; + + // 构建请求体 + JSONObject requestBody = new JSONObject(); + requestBody.put("guns", gunDTOs); + + // 设置请求头 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(requestBody.toJSONString(), headers); + + try { + // 发送请求 + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + if (response.getStatusCode() == HttpStatus.OK) { + // 解析响应 + JSONObject responseBody = JSON.parseObject(response.getBody()); + List results = responseBody.getList("results", JcppSyncResult.class); + return results != null ? results : new ArrayList<>(); + } else { + log.error("JCPP 充电枪同步接口返回错误: {}", response.getStatusCode()); + // 返回失败结果 + List results = new ArrayList<>(); + for (JcppGunSyncDTO dto : gunDTOs) { + results.add(JcppSyncResult.fail(dto.getGunCode(), "接口返回错误: " + response.getStatusCode())); + } + return results; + } + } catch (Exception e) { + log.error("调用 JCPP 充电枪同步接口异常", e); + // 返回失败结果 + List results = new ArrayList<>(); + for (JcppGunSyncDTO dto : gunDTOs) { + results.add(JcppSyncResult.fail(dto.getGunCode(), "接口调用异常: " + e.getMessage())); + } + return results; + } + } + + /** + * 创建同步记录 + */ + private JcppSyncRecord createSyncRecord(String syncType) { + JcppSyncRecord record = new JcppSyncRecord(); + record.setSyncType(syncType); + record.setSyncStatus("RUNNING"); + record.setStartTime(new Date()); + jcppSyncRecordMapper.insertJcppSyncRecord(record); + return record; + } + + /** + * 更新同步记录(成功) + */ + private void updateSyncRecord(JcppSyncRecord record, JcppSyncResponse response, String status) { + updateSyncRecord(record, response, status, null); + } + + /** + * 更新同步记录 + */ + private void updateSyncRecord(JcppSyncRecord record, JcppSyncResponse response, String status, String errorMessage) { + record.setSyncStatus(status); + record.setEndTime(new Date()); + + if (response != null) { + record.setTotalPiles(response.getTotalPiles()); + record.setSuccessPiles(response.getSuccessPiles()); + record.setFailedPiles(response.getFailedPiles()); + record.setTotalGuns(response.getTotalGuns()); + record.setSuccessGuns(response.getSuccessGuns()); + record.setFailedGuns(response.getFailedGuns()); + + if (response.getErrors() != null && !response.getErrors().isEmpty()) { + record.setErrorMessage(String.join("; ", response.getErrors())); + } + } + + if (errorMessage != null) { + record.setErrorMessage(errorMessage); + } + + jcppSyncRecordMapper.updateJcppSyncRecord(record); + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/util/JcppPartitionCalculator.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/util/JcppPartitionCalculator.java new file mode 100644 index 000000000..5dd44f5f2 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/util/JcppPartitionCalculator.java @@ -0,0 +1,97 @@ +package com.jsowell.pile.jcpp.util; + +import com.google.common.hash.Hashing; +import lombok.extern.slf4j.Slf4j; + +import java.nio.charset.StandardCharsets; + +/** + * JCPP 消息分区计算器 + * 使用 MurmurHash3_128 算法计算消息分区,与 JCPP 保持一致 + * + * @author jsowell + */ +@Slf4j +public class JcppPartitionCalculator { + + /** + * 默认分区数量 + */ + private static final int DEFAULT_PARTITION_COUNT = 10; + + /** + * 分区数量(可配置) + */ + private static int partitionCount = DEFAULT_PARTITION_COUNT; + + /** + * 设置分区数量 + * + * @param count 分区数量 + */ + public static void setPartitionCount(int count) { + if (count <= 0) { + throw new IllegalArgumentException("分区数量必须大于 0"); + } + partitionCount = count; + log.info("JCPP 消息分区数量设置为: {}", partitionCount); + } + + /** + * 获取当前分区数量 + * + * @return 分区数量 + */ + public static int getPartitionCount() { + return partitionCount; + } + + /** + * 根据消息键计算分区编号 + * 使用 MurmurHash3_128 算法(与 JCPP 一致) + * + * @param messageKey 消息键(通常是 pileCode) + * @return 分区编号(0 到 partitionCount-1) + */ + public static int getPartition(String messageKey) { + if (messageKey == null || messageKey.isEmpty()) { + log.warn("消息键为空,使用默认分区 0"); + return 0; + } + + // 使用 MurmurHash3_128 算法计算 hash 值 + long hash = Hashing.murmur3_128() + .hashString(messageKey, StandardCharsets.UTF_8) + .asLong(); + + // 取绝对值并对分区数取模 + int partition = Math.abs((int) (hash % partitionCount)); + + log.debug("消息键: {}, hash: {}, 分区: {}", messageKey, hash, partition); + + return partition; + } + + /** + * 获取指定分区的队列名称 + * + * @param partition 分区编号 + * @return 队列名称 + */ + public static String getQueueName(int partition) { + return "jcpp.uplink.partition." + partition; + } + + /** + * 获取所有分区的队列名称数组 + * + * @return 队列名称数组 + */ + public static String[] getAllQueueNames() { + String[] queueNames = new String[partitionCount]; + for (int i = 0; i < partitionCount; i++) { + queueNames[i] = getQueueName(i); + } + return queueNames; + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/mapper/JcppSyncRecordMapper.java b/jsowell-pile/src/main/java/com/jsowell/pile/mapper/JcppSyncRecordMapper.java new file mode 100644 index 000000000..8f6b62504 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/mapper/JcppSyncRecordMapper.java @@ -0,0 +1,69 @@ +package com.jsowell.pile.mapper; + +import com.jsowell.pile.domain.JcppSyncRecord; + +import java.util.List; + +/** + * JCPP 充电桩同步记录 Mapper 接口 + * + * @author jsowell + */ +public interface JcppSyncRecordMapper { + + /** + * 查询 JCPP 充电桩同步记录 + * + * @param id JCPP 充电桩同步记录主键 + * @return JCPP 充电桩同步记录 + */ + JcppSyncRecord selectJcppSyncRecordById(Long id); + + /** + * 查询 JCPP 充电桩同步记录列表 + * + * @param jcppSyncRecord JCPP 充电桩同步记录 + * @return JCPP 充电桩同步记录集合 + */ + List selectJcppSyncRecordList(JcppSyncRecord jcppSyncRecord); + + /** + * 新增 JCPP 充电桩同步记录 + * + * @param jcppSyncRecord JCPP 充电桩同步记录 + * @return 结果 + */ + int insertJcppSyncRecord(JcppSyncRecord jcppSyncRecord); + + /** + * 修改 JCPP 充电桩同步记录 + * + * @param jcppSyncRecord JCPP 充电桩同步记录 + * @return 结果 + */ + int updateJcppSyncRecord(JcppSyncRecord jcppSyncRecord); + + /** + * 删除 JCPP 充电桩同步记录 + * + * @param id JCPP 充电桩同步记录主键 + * @return 结果 + */ + int deleteJcppSyncRecordById(Long id); + + /** + * 批量删除 JCPP 充电桩同步记录 + * + * @param ids 需要删除的数据主键集合 + * @return 结果 + */ + int deleteJcppSyncRecordByIds(Long[] ids); + + /** + * 查询最后一次成功的同步记录 + * + * @param syncType 同步类型 + * @return 同步记录 + */ + JcppSyncRecord selectLastSuccessRecord(String syncType); +} diff --git a/jsowell-pile/src/main/resources/mapper/pile/JcppSyncRecordMapper.xml b/jsowell-pile/src/main/resources/mapper/pile/JcppSyncRecordMapper.xml new file mode 100644 index 000000000..0c5078173 --- /dev/null +++ b/jsowell-pile/src/main/resources/mapper/pile/JcppSyncRecordMapper.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + select id, sync_type, sync_status, start_time, end_time, total_piles, success_piles, failed_piles, total_guns, success_guns, failed_guns, error_message, create_by, create_time from jcpp_sync_record + + + + + + + + + + insert into jcpp_sync_record + + sync_type, + sync_status, + start_time, + end_time, + total_piles, + success_piles, + failed_piles, + total_guns, + success_guns, + failed_guns, + error_message, + create_by, + create_time, + + + #{syncType}, + #{syncStatus}, + #{startTime}, + #{endTime}, + #{totalPiles}, + #{successPiles}, + #{failedPiles}, + #{totalGuns}, + #{successGuns}, + #{failedGuns}, + #{errorMessage}, + #{createBy}, + #{createTime}, + + + + + update jcpp_sync_record + + sync_type = #{syncType}, + sync_status = #{syncStatus}, + start_time = #{startTime}, + end_time = #{endTime}, + total_piles = #{totalPiles}, + success_piles = #{successPiles}, + failed_piles = #{failedPiles}, + total_guns = #{totalGuns}, + success_guns = #{successGuns}, + failed_guns = #{failedGuns}, + error_message = #{errorMessage}, + + where id = #{id} + + + + delete from jcpp_sync_record where id = #{id} + + + + delete from jcpp_sync_record where id in + + #{id} + + + diff --git a/pom.xml b/pom.xml index 57f312c81..a04d1d25e 100644 --- a/pom.xml +++ b/pom.xml @@ -33,7 +33,8 @@ 2.3 0.9.1 1.18.24 - 20.0 + 33.0.0-jre + 3.25.1 2.5.14 4.1.75.Final 1.2.5 @@ -353,6 +354,13 @@ ${protostuff.version} + + + com.google.protobuf + protobuf-java + ${protobuf.version} + + com.jsowell charge-common-api diff --git a/sql/jcpp_sync_record.sql b/sql/jcpp_sync_record.sql new file mode 100644 index 000000000..191e57f0d --- /dev/null +++ b/sql/jcpp_sync_record.sql @@ -0,0 +1,21 @@ +-- JCPP 充电桩同步记录表 +CREATE TABLE `jcpp_sync_record` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + `sync_type` varchar(20) NOT NULL COMMENT '同步类型(FULL-全量;INCREMENTAL-增量)', + `sync_status` varchar(20) NOT NULL COMMENT '同步状态(RUNNING-进行中;SUCCESS-成功;FAILED-失败)', + `start_time` datetime NOT NULL COMMENT '开始时间', + `end_time` datetime DEFAULT NULL COMMENT '结束时间', + `total_piles` int(11) DEFAULT 0 COMMENT '总充电桩数', + `success_piles` int(11) DEFAULT 0 COMMENT '成功充电桩数', + `failed_piles` int(11) DEFAULT 0 COMMENT '失败充电桩数', + `total_guns` int(11) DEFAULT 0 COMMENT '总充电枪数', + `success_guns` int(11) DEFAULT 0 COMMENT '成功充电枪数', + `failed_guns` int(11) DEFAULT 0 COMMENT '失败充电枪数', + `error_message` text COMMENT '错误信息', + `create_by` varchar(20) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_sync_type` (`sync_type`) USING BTREE, + KEY `idx_start_time` (`start_time`) USING BTREE, + KEY `idx_sync_status` (`sync_status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='JCPP 充电桩同步记录表';