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..cabd415a0 --- /dev/null +++ b/doc/JCPP项目配合实现Prompt.md @@ -0,0 +1,402 @@ +# 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/auth/login` + +**请求格式**: +```json +{ + "username": "sanbing", + "password": "password123" +} +``` + +**响应格式**: +```json +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expiresIn": 1800 +} +``` + +**说明**: +- 用于 Web 项目获取访问令牌 +- token 有效期 30 分钟 +- Web 项目会将 token 缓存在 Redis 中 + +--- + +### 2. 充电桩同步接口 + +**接口路径**:`POST /api/sync/piles` + +**认证方式**:Bearer Token(在请求头中添加 `Authorization: Bearer {token}`) + +**重要说明**: +- ✅ 所有充电桩的 `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` +- **需要验证 Authorization 请求头中的 token** +- token 无效时返回 401 Unauthorized + +--- + +### 3. 充电枪同步接口 + +**接口路径**:`POST /api/sync/guns` + +**认证方式**:Bearer Token(在请求头中添加 `Authorization: Bearer {token}`) + +**重要说明**: +- ✅ 所有充电枪的 `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` +- **需要验证 Authorization 请求头中的 token** +- token 无效时返回 401 Unauthorized + +--- + +## 实现建议 + +### 技术栈 +- **框架**:Spring Boot +- **数据库**:PostgreSQL 17 +- **ORM**:JPA / MyBatis +- **JSON 处理**:Jackson / Gson + +### 代码结构建议 + +``` +src/main/java/com/jcpp/ +├── controller/ +│ ├── AuthController.java # 认证接口 Controller +│ └── SyncController.java # 同步接口 Controller +├── service/ +│ ├── AuthService.java # 认证服务接口 +│ ├── PileSyncService.java # 充电桩同步服务接口 +│ └── GunSyncService.java # 充电枪同步服务接口 +├── service/impl/ +│ ├── AuthServiceImpl.java +│ ├── PileSyncServiceImpl.java +│ └── GunSyncServiceImpl.java +├── entity/ +│ ├── Pile.java # t_pile 实体 +│ └── Gun.java # t_gun 实体 +├── repository/ +│ ├── PileRepository.java +│ └── GunRepository.java +├── dto/ +│ ├── LoginRequest.java # 登录请求 +│ ├── LoginResponse.java # 登录响应 +│ ├── PileSyncDTO.java # 充电桩同步 DTO +│ ├── GunSyncDTO.java # 充电枪同步 DTO +│ ├── SyncRequest.java # 同步请求 +│ └── SyncResponse.java # 同步响应 +└── security/ + └── JwtTokenProvider.java # JWT Token 生成和验证 +``` + +### 关键代码示例 + +**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. **安全性** + - 添加接口鉴权(JWT Token) + - 验证请求数据的合法性 + - 防止 SQL 注入 + - token 有效期 30 分钟 + +--- + +## 交付物 + +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..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/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/api/uniapp/business/BusinessFinancialController.java b/jsowell-admin/src/main/java/com/jsowell/api/uniapp/business/BusinessFinancialController.java new file mode 100644 index 000000000..f5840797e --- /dev/null +++ b/jsowell-admin/src/main/java/com/jsowell/api/uniapp/business/BusinessFinancialController.java @@ -0,0 +1,59 @@ +package com.jsowell.api.uniapp.business; + +import com.jsowell.common.core.controller.BaseController; +import com.jsowell.common.enums.ykc.ReturnCodeEnum; +import com.jsowell.common.exception.BusinessException; +import com.jsowell.common.response.RestApiResponse; +import com.jsowell.pile.dto.MerchantOrderReportDTO; +import com.jsowell.pile.service.BusinessFinancialService; +import com.jsowell.pile.vo.web.MerchantOrderReportVO; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 运营端财务信息相关Controller + * + * @author Lemon + */ +@RestController +@RequestMapping("/business/financial") +public class BusinessFinancialController extends BaseController { + + @Autowired + private BusinessFinancialService businessFinancialService; + + /** + * 我的钱包界面查询接口 + * + * @param dto 查询参数 + * @return 钱包信息查询结果 + */ + @PostMapping("/myWallet") + public RestApiResponse getMyWallet(@RequestBody MerchantOrderReportDTO dto) { + logger.info("我的钱包查询 merchantId:{}", dto != null ? dto.getMerchantId() : null); + RestApiResponse response; + try { + // 参数验证 + if (dto == null || StringUtils.isBlank(dto.getMerchantId())) { + throw new BusinessException(ReturnCodeEnum.CODE_PARAM_NOT_NULL_ERROR); + } + + MerchantOrderReportVO result = businessFinancialService.getMyWallet(dto); + response = new RestApiResponse<>(result); + logger.info("我的钱包查询成功 merchantId:{}", dto.getMerchantId()); + } catch (BusinessException e) { + logger.warn("我的钱包查询业务异常 merchantId:{}, code:{}, message:{}", + dto != null ? dto.getMerchantId() : null, e.getCode(), e.getMessage(), e); + response = new RestApiResponse<>(e.getCode(), e.getMessage()); + } catch (Exception e) { + logger.error("我的钱包查询系统异常 merchantId:{}", + dto != null ? dto.getMerchantId() : null, e); + response = new RestApiResponse<>(e); + } + return response; + } +} diff --git a/jsowell-admin/src/main/java/com/jsowell/service/MemberService.java b/jsowell-admin/src/main/java/com/jsowell/service/MemberService.java index 15950f0b0..98a49dec1 100644 --- a/jsowell-admin/src/main/java/com/jsowell/service/MemberService.java +++ b/jsowell-admin/src/main/java/com/jsowell/service/MemberService.java @@ -57,6 +57,7 @@ import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.cglib.beans.BeanMap; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import java.math.BigDecimal; @@ -185,7 +186,8 @@ public class MemberService { throw new BusinessException(ReturnCodeEnum.CODE_OPEN_ID_IS_NULL_ERROR); } - String lockKey = CacheConstants.USER_APP_REGISTER + phoneNumber; + // 锁键包含手机号和运营商ID,确保同一运营商下的同一手机号串行处理,不同运营商互不影响 + String lockKey = CacheConstants.USER_APP_REGISTER + phoneNumber + ":" + firstLevelMerchantId; String requestId = IdUtils.fastUUID(); Boolean isLock = false; try { @@ -193,7 +195,7 @@ public class MemberService { isLock = redisCache.lock(lockKey, requestId, 60); if (!isLock) { // 获取锁失败,说明有其他请求正在处理,提示用户稍候 - log.warn("获取注册锁失败,可能有并发请求正在处理, phoneNumber:{}", phoneNumber); + log.warn("获取注册锁失败,可能有并发请求正在处理, phoneNumber:{}, merchantId:{}", phoneNumber, firstLevelMerchantId); throw new BusinessException(ReturnCodeEnum.CODE_MEMBER_REGISTER_AND_LOGIN_PROCESSING); } @@ -226,7 +228,21 @@ public class MemberService { .build(); memberTransactionDTO.setMemberWalletInfo(memberWalletInfo); } - transactionService.createMember(memberTransactionDTO); + + try { + // 插入会员数据 + transactionService.createMember(memberTransactionDTO); + } catch (DuplicateKeyException e) { + // 捕获数据库唯一索引冲突异常(极端并发情况下的兜底机制) + // 说明该手机号在该运营商下已经被其他并发请求注册了,重新查询获取已存在的会员信息 + log.warn("会员注册时检测到唯一索引冲突,重新查询已存在的会员, phoneNumber:{}, merchantId:{}", phoneNumber, firstLevelMerchantId); + memberBasicInfo = memberBasicInfoService.selectInfoByMobileNumber(phoneNumber, firstLevelMerchantId); + if (memberBasicInfo == null) { + // 理论上不应该走到这里,如果走到这里说明数据被删除了,抛出异常 + log.error("唯一索引冲突后重新查询会员信息为空, phoneNumber:{}, merchantId:", phoneNumber, firstLevelMerchantId); + throw new BusinessException(ReturnCodeEnum.CODE_MEMBER_REGISTER_AND_LOGIN_ERROR); + } + } } else { boolean updateFlag = false; if (AdapayPayChannelEnum.WX_LITE.getValue().equals(dto.getRequestSource()) && !StringUtils.equals(memberBasicInfo.getOpenId(), openId)) { @@ -255,7 +271,7 @@ public class MemberService { redisCache.unLock(lockKey); } } catch (Exception e) { - log.error("释放注册锁失败, phoneNumber:{}, error:{}", phoneNumber, e.getMessage()); + log.error("释放注册锁失败, phoneNumber:{}, merchantId:{}, error:{}", phoneNumber, firstLevelMerchantId, e.getMessage()); } } } diff --git a/jsowell-admin/src/main/java/com/jsowell/web/controller/jcpp/JcppBillingController.java b/jsowell-admin/src/main/java/com/jsowell/web/controller/jcpp/JcppBillingController.java new file mode 100644 index 000000000..d91c30183 --- /dev/null +++ b/jsowell-admin/src/main/java/com/jsowell/web/controller/jcpp/JcppBillingController.java @@ -0,0 +1,129 @@ +package com.jsowell.web.controller.jcpp; + +import com.jsowell.common.core.controller.BaseController; +import com.jsowell.common.core.domain.AjaxResult; +import com.jsowell.pile.domain.PileBasicInfo; +import com.jsowell.pile.domain.PileBillingTemplate; +import com.jsowell.pile.jcpp.dto.JcppPricingModel; +import com.jsowell.pile.jcpp.util.PricingModelConverter; +import com.jsowell.pile.service.PileBasicInfoService; +import com.jsowell.pile.service.PileBillingTemplateService; +import com.jsowell.pile.vo.web.BillingTemplateVO; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; + +/** + * JCPP 计费模板接口 + * + * @author jsowell + */ +// @Slf4j +@Api(tags = "JCPP计费模板接口") +@RestController +@RequestMapping("/api/jcpp") +public class JcppBillingController extends BaseController { + + @Autowired + private PileBasicInfoService pileBasicInfoService; + + @Autowired + private PileBillingTemplateService pileBillingTemplateService; + + /** + * 获取充电桩计费模板 + * + * @param pileCode 充电桩编码 + * @return 计费模板 + */ + @ApiOperation("获取充电桩计费模板") + @GetMapping("/billing/{pileCode}") + public AjaxResult getBillingTemplate( + @ApiParam(value = "充电桩编码", required = true) @PathVariable String pileCode) { + try { + logger.info("查询充电桩计费模板: pileCode={}", pileCode); + + // 1. 根据 pileCode 查询充电桩 + PileBasicInfo pileInfo = pileBasicInfoService.selectPileBasicInfoBySN(pileCode); + if (pileInfo == null) { + logger.warn("充电桩不存在: pileCode={}", pileCode); + return AjaxResult.error(404, "充电桩不存在"); + } + + // 2. 获取关联的计费模板 ID + BillingTemplateVO billingTemplateVO = pileBillingTemplateService.selectBillingTemplateDetailByPileSn(pileCode); + if (billingTemplateVO == null || billingTemplateVO.getTemplateId() == null) { + logger.warn("充电桩未配置计费模板: pileCode={}", pileCode); + return AjaxResult.error(404, "未配置计费模板"); + } + Long billingTemplateId = Long.parseLong(billingTemplateVO.getTemplateId()); + + // 3. 查询计费模板 + PileBillingTemplate template = pileBillingTemplateService.selectPileBillingTemplateById(billingTemplateId); + if (template == null) { + logger.warn("计费模板不存在: pileCode={}, billingTemplateId={}", pileCode, billingTemplateId); + return AjaxResult.error(404, "计费模板不存在"); + } + + // 4. 转换为 JCPP 格式 + JcppPricingModel pricingModel = PricingModelConverter.convert(template); + + // 5. 构建返回结果 + Map data = new HashMap<>(); + data.put("pricingId", billingTemplateId); + data.put("pricingModel", pricingModel); + + logger.info("查询计费模板成功: pileCode={}, pricingId={}", pileCode, billingTemplateId); + return AjaxResult.success(data); + + } catch (Exception e) { + logger.error("查询计费模板异常: pileCode={}", pileCode, e); + return AjaxResult.error("查询计费模板失败: " + e.getMessage()); + } + } + + /** + * 根据计费模板ID获取计费模板 + * + * @param pricingId 计费模板ID + * @return 计费模板 + */ + @ApiOperation("根据ID获取计费模板") + @GetMapping("/billing/id/{pricingId}") + public AjaxResult getBillingTemplateById( + @ApiParam(value = "计费模板ID", required = true) @PathVariable Long pricingId) { + try { + logger.info("查询计费模板: pricingId={}", pricingId); + + // 查询计费模板 + PileBillingTemplate template = pileBillingTemplateService.selectPileBillingTemplateById(pricingId); + if (template == null) { + logger.warn("计费模板不存在: pricingId={}", pricingId); + return AjaxResult.error(404, "计费模板不存在"); + } + + // 转换为 JCPP 格式 + JcppPricingModel pricingModel = PricingModelConverter.convert(template); + + // 构建返回结果 + Map data = new HashMap<>(); + data.put("pricingId", pricingId); + data.put("pricingModel", pricingModel); + + logger.info("查询计费模板成功: pricingId={}", pricingId); + return AjaxResult.success(data); + + } catch (Exception e) { + logger.error("查询计费模板异常: pricingId={}", pricingId, e); + return AjaxResult.error("查询计费模板失败: " + e.getMessage()); + } + } +} diff --git a/jsowell-admin/src/main/java/com/jsowell/web/controller/jcpp/JcppMessageController.java b/jsowell-admin/src/main/java/com/jsowell/web/controller/jcpp/JcppMessageController.java new file mode 100644 index 000000000..6aaf660e6 --- /dev/null +++ b/jsowell-admin/src/main/java/com/jsowell/web/controller/jcpp/JcppMessageController.java @@ -0,0 +1,61 @@ +package com.jsowell.web.controller.jcpp; + +import com.jsowell.common.core.controller.BaseController; +import com.jsowell.pile.jcpp.config.JcppConfig; +import com.jsowell.pile.jcpp.dto.JcppUplinkMessage; +import com.jsowell.pile.jcpp.dto.JcppUplinkResponse; +import com.jsowell.pile.jcpp.service.IJcppMessageService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * JCPP 消息接收 Controller + * 用于接收 JCPP 项目推送的充电桩上行消息 + * + * @author jsowell + */ +// @Slf4j +@Api(tags = "JCPP消息接收接口") +@RestController +@RequestMapping("/api/jcpp") +public class JcppMessageController extends BaseController { + + @Autowired + private IJcppMessageService jcppMessageService; + + @Autowired + private JcppConfig jcppConfig; + + /** + * 接收 JCPP 上行消息 + * + * @param message 上行消息 + * @return 响应 + */ + @ApiOperation("接收JCPP上行消息") + @PostMapping("/uplink") + public JcppUplinkResponse receiveUplink(@RequestBody JcppUplinkMessage message) { + // 检查是否启用 JCPP 对接 + if (!jcppConfig.isEnabled()) { + logger.warn("JCPP 对接未启用,忽略消息: {}", message.getMessageId()); + return JcppUplinkResponse.error(message.getMessageId(), "JCPP 对接未启用"); + } + + logger.info("收到 JCPP 上行消息: messageId={}, messageType={}, pileCode={}, sessionId={}", + message.getMessageId(), message.getMessageType(), + message.getPileCode(), message.getSessionId()); + + try { + return jcppMessageService.handleMessage(message); + } catch (Exception e) { + logger.error("处理 JCPP 上行消息异常: messageId={}, error={}", + message.getMessageId(), e.getMessage(), e); + return JcppUplinkResponse.error(message.getMessageId(), "处理消息异常: " + e.getMessage()); + } + } +} 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..5efbbe17b --- /dev/null +++ b/jsowell-admin/src/main/java/com/jsowell/web/controller/jcpp/JcppPileSyncController.java @@ -0,0 +1,130 @@ +package com.jsowell.web.controller.jcpp; + +import com.jsowell.common.annotation.Anonymous; +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 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 充电桩同步") +@Anonymous +@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 { + logger.info("开始执行全量同步"); + JcppSyncResponse response = jcppPileSyncService.syncAllPiles(); + return AjaxResult.success("全量同步完成", response); + } catch (Exception e) { + logger.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 { + logger.info("开始执行增量同步,上次同步时间: {}", lastSyncTime); + JcppSyncResponse response = jcppPileSyncService.syncIncrementalPiles(lastSyncTime); + return AjaxResult.success("增量同步完成", response); + } catch (Exception e) { + logger.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 { + logger.info("开始同步单个充电桩: {}", pileSn); + boolean success = jcppPileSyncService.syncSinglePile(pileSn); + if (success) { + return AjaxResult.success("同步成功"); + } else { + return AjaxResult.error("同步失败"); + } + } catch (Exception e) { + logger.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/java/com/jsowell/web/controller/jcpp/JcppRemoteChargeController.java b/jsowell-admin/src/main/java/com/jsowell/web/controller/jcpp/JcppRemoteChargeController.java new file mode 100644 index 000000000..2f425014c --- /dev/null +++ b/jsowell-admin/src/main/java/com/jsowell/web/controller/jcpp/JcppRemoteChargeController.java @@ -0,0 +1,85 @@ +package com.jsowell.web.controller.jcpp; + +import com.jsowell.common.core.controller.BaseController; +import com.jsowell.common.core.domain.AjaxResult; +import com.jsowell.pile.jcpp.service.IJcppRemoteChargeService; +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.web.bind.annotation.*; + +/** + * JCPP 远程充电 Controller + * 用于 APP/小程序发起的远程启动/停止充电 + * + * @author jsowell + */ +// @Slf4j +@Api(tags = "JCPP远程充电接口") +@RestController +@RequestMapping("/api/jcpp/charge") +public class JcppRemoteChargeController extends BaseController { + + @Autowired + private IJcppRemoteChargeService jcppRemoteChargeService; + + /** + * 远程启动充电 + */ + @ApiOperation("远程启动充电") + @PostMapping("/start") + public AjaxResult startCharging( + @ApiParam(value = "会员ID", required = true) @RequestParam String memberId, + @ApiParam(value = "充电桩编码", required = true) @RequestParam String pileCode, + @ApiParam(value = "枪编号", required = true) @RequestParam String gunNo, + @ApiParam(value = "预付金额(元)", required = true) @RequestParam String payAmount) { + try { + String orderCode = jcppRemoteChargeService.remoteStartCharging(memberId, pileCode, gunNo, payAmount); + return AjaxResult.success("启动指令已发送", orderCode); + } catch (Exception e) { + logger.error("远程启动充电失败, memberId: {}, pileCode: {}, error: {}", + memberId, pileCode, e.getMessage(), e); + return AjaxResult.error(e.getMessage()); + } + } + + /** + * 远程停止充电 + */ + @ApiOperation("远程停止充电") + @PostMapping("/stop") + public AjaxResult stopCharging( + @ApiParam(value = "会员ID", required = true) @RequestParam String memberId, + @ApiParam(value = "订单号", required = true) @RequestParam String orderCode) { + try { + boolean result = jcppRemoteChargeService.remoteStopCharging(memberId, orderCode); + if (result) { + return AjaxResult.success("停止指令已发送"); + } else { + return AjaxResult.error("停止充电失败"); + } + } catch (Exception e) { + logger.error("远程停止充电失败, memberId: {}, orderCode: {}, error: {}", + memberId, orderCode, e.getMessage(), e); + return AjaxResult.error(e.getMessage()); + } + } + + /** + * 检查充电桩是否在线 + */ + @ApiOperation("检查充电桩是否在线") + @GetMapping("/online/{pileCode}") + public AjaxResult checkOnline( + @ApiParam(value = "充电桩编码", required = true) @PathVariable String pileCode) { + try { + boolean online = jcppRemoteChargeService.isPileOnline(pileCode); + return AjaxResult.success(online); + } catch (Exception e) { + logger.error("检查充电桩在线状态失败, pileCode: {}, error: {}", pileCode, e.getMessage(), e); + return AjaxResult.error(e.getMessage()); + } + } +} diff --git a/jsowell-admin/src/main/resources/application-sit.yml b/jsowell-admin/src/main/resources/application-sit.yml index 80c91af3d..fc31b5fd2 100644 --- a/jsowell-admin/src/main/resources/application-sit.yml +++ b/jsowell-admin/src/main/resources/application-sit.yml @@ -3,12 +3,46 @@ jsowell: # 文件路径 示例( Windows配置D:/jsowell/uploadPath,Linux配置 /home/jsowell/uploadPath) profile: /www/wwwroot/jsowellftp +# JCPP 配置 +jcpp: + # 连接超时时间(毫秒) + connect-timeout: 10000 + # 读取超时时间(毫秒) + timeout: 30000 + # 同步接口连接超时时间(毫秒) + sync-connect-timeout: 10000 + # 同步接口读取超时时间(毫秒)- 批量同步需要更长时间 + sync-timeout: 120000 + rabbitmq: + # 分区数量(与 JCPP 保持一致) + partition-count: 10 + # Exchange 名称 + exchange: jcpp.uplink.exchange + # 队列前缀 + queue-prefix: jcpp.uplink.partition + sync: + # JCPP 同步接口地址 + api-url: http://localhost:8180/api/sync + # 批量同步大小(每批充电桩数量) + batch-size: 1000 + # 超时时间(毫秒) + timeout: 60000 + # 是否启用自动增量同步 + auto-sync-enabled: false + # 自动同步间隔(分钟) + auto-sync-interval: 30 + auth: + # JCPP 认证用户名 + username: sanbing + # JCPP 认证密码 + password: password123 + # 数据源配置 spring: # redis 配置 redis: # 地址 - host: 192.168.0.32 + host: 106.14.94.149 # 端口,默认为6379 port: 6379 # 数据库索引 @@ -35,11 +69,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 +123,7 @@ spring: # rabbitmq配置 sit rabbitmq: - host: 192.168.0.32 + host: 106.14.94.149 port: 5672 username: admin password: admin @@ -264,7 +298,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/MemberBindingCarNoHttpTest.java b/jsowell-admin/src/test/java/MemberBindingCarNoHttpTest.java new file mode 100644 index 000000000..02222970f --- /dev/null +++ b/jsowell-admin/src/test/java/MemberBindingCarNoHttpTest.java @@ -0,0 +1,101 @@ +import com.alibaba.fastjson.JSON; +import com.jsowell.pile.dto.QueryOrderSplitRecordDTO; +import org.springframework.http.*; +import org.springframework.web.client.RestTemplate; + +import java.util.Arrays; +import java.util.List; + +public class MemberBindingCarNoHttpTest { + + public static void main(String[] args) { + + // 1️⃣ 接口地址(和 curl 完全一致) + String url = "https://apitest.jsowellcloud.com/order/commission/retryMerchantSplit"; + + // 2️⃣ 登录态 token(去掉 Bearer 前缀,setBearerAuth 会自动添加) + String token = "Bearer eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImEyNmNhZmNiLTk5YzQtNDZkNi05M2EyLTg2NGYyOGVmYmFkYSJ9.Hv5-CztUtq04POwcUeBwvPLr2CzAnXcvnAZzNTH0zFo8ZxsP1ZoeqK7Pnohg4a-cdwvlpGFrXLDQ_gkr5psyBw"; + + + + String endTime = "2025-11-28"; + String startTime = "2025-11-28"; + + List merchantIds = Arrays.asList( + 444, 281, 399, 355, 86, 162, 252, 341, 267, 327, + 312, 400, 384, 282, 297, 370, 87, 356, 41, 313, + 222, 255, 343, 447, 177, 25, 329, 147, 268, 283, + 298, 101, 314, 385, 401, 132, 193, 118, 166, 209, + 56, 357, 223, 416, 269, 88, 449, 330, 284, 402, + 102, 475, 149, 299, 237, 375, 210, 345, 72, 270, + 167, 134, 179, 26, 386, 316, 417, 89, 331, 451, + 195, 358, 103, 44, 376, 211, 432, 150, 119, 459, + 58, 259, 285, 301, 180, 73, 238, 477, 387, 28, + 452, 403, 226, 196, 272, 136, 332, 104, 45, 435, + 151, 460, 418, 377, 59, 302, 212, 319, 359, 349, + 273, 453, 388, 404, 170, 239, 29, 121, 227, 419, + 261, 106, 436, 138, 183, 76, 287, 198, 60, 462, + 47, 304, 389, 378, 481, 274, 351, 334, 214, 122, + 420, 321, 30, 405, 154, 228, 437, 172, 288, 352, + 108, 184, 263, 139, 322, 363, 275, 97, 199, 31, + 78, 421, 155, 391, 307, 123, 229, 353, 215, 173, + 380, 408, 185, 241, 364, 336, 276, 466, 140, 63, + 110, 422, 392, 32, 354, 230, 409, 309, 124, 324, + 186, 201, 51, 393, 141, 277, 337, 382, 33, 292, + 64, 366, 442, 310, 187, 489, 244, 468, 158, 410, + 232, 111, 425, 202, 125, 338, 81, 443, 188, 160, + 221, 34, 469, 143, 397, 427, 233, 294, 66, 54, + 126, 471, 367, 161, 189, 144, 113, 429, 295, 190, + 145, 114, 246, 127, 474, 82, 236, 368, 70, 146, + 369, 278, 115, 130, 248, 279, 131, 83, 280, 84, + 251, 116, 85, 35, 36, 3, 37, 9, + 17, 18, 19, 21, 22, 23, 24 + ); + + + + + + RestTemplate restTemplate = new RestTemplate(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(MediaType.parseMediaTypes("*/*")); + headers.setBearerAuth(token); // 等价于 Authorization: Bearer xxx + + int success = 0; + int fail = 0; + + for (Integer merchantId : merchantIds) { + + QueryOrderSplitRecordDTO dto = new QueryOrderSplitRecordDTO(); + dto.setMerchantId(String.valueOf(merchantId)); + dto.setStartTime(startTime); + dto.setEndTime(endTime); + + HttpEntity request = + new HttpEntity<>(JSON.toJSONString(dto), headers); + + try { + ResponseEntity response = + restTemplate.postForEntity(url, request, String.class); + + System.out.println("成功处理 merchantId=" + merchantId + + " response=" + response.getBody()); + success++; + + // 👇 可选:防止接口被限流 + Thread.sleep(100); + + } catch (Exception e) { + fail++; + System.err.println("处理失败 merchantId=" + merchantId); + e.printStackTrace(); + } + } + + System.out.println("====== 批量处理完成 ======"); + System.out.println("成功:" + success); + System.out.println("失败:" + fail); + } +} diff --git a/jsowell-admin/src/test/java/com/jsowell/jcpp/JcppMessageControllerTest.java b/jsowell-admin/src/test/java/com/jsowell/jcpp/JcppMessageControllerTest.java new file mode 100644 index 000000000..281a56835 --- /dev/null +++ b/jsowell-admin/src/test/java/com/jsowell/jcpp/JcppMessageControllerTest.java @@ -0,0 +1,256 @@ +package com.jsowell.jcpp; + +import com.alibaba.fastjson2.JSON; +import com.jsowell.pile.jcpp.dto.*; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.HashMap; +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * JCPP 消息接收接口测试 + * + * @author jsowell + */ +@SpringBootTest +@AutoConfigureMockMvc +public class JcppMessageControllerTest { + + @Autowired + private MockMvc mockMvc; + + /** + * 测试登录消息处理 + */ + @Test + public void testHandleLogin() throws Exception { + // 构造登录消息 + JcppLoginData loginData = JcppLoginData.builder() + .pileCode("TEST001") + .credential("test-credential") + .remoteAddress("192.168.1.100:8080") + .nodeId("node-1") + .nodeHostAddress("192.168.1.1") + .nodeRestPort(8080) + .nodeGrpcPort(9090) + .build(); + + JcppUplinkMessage message = JcppUplinkMessage.builder() + .messageId("msg-001") + .sessionId("session-001") + .protocolName("YKC_V1.6") + .pileCode("TEST001") + .messageType("LOGIN") + .timestamp(System.currentTimeMillis()) + .data(JSON.toJSONString(loginData)) + .build(); + + mockMvc.perform(post("/api/jcpp/uplink") + .contentType(MediaType.APPLICATION_JSON) + .content(JSON.toJSONString(message))) + .andExpect(status().isOk()); + } + + /** + * 测试心跳消息处理 + */ + @Test + public void testHandleHeartbeat() throws Exception { + Map heartbeatData = new HashMap<>(); + heartbeatData.put("pileCode", "TEST001"); + heartbeatData.put("remoteAddress", "192.168.1.100:8080"); + + JcppUplinkMessage message = JcppUplinkMessage.builder() + .messageId("msg-002") + .sessionId("session-001") + .protocolName("YKC_V1.6") + .pileCode("TEST001") + .messageType("HEARTBEAT") + .timestamp(System.currentTimeMillis()) + .data(JSON.toJSONString(heartbeatData)) + .build(); + + mockMvc.perform(post("/api/jcpp/uplink") + .contentType(MediaType.APPLICATION_JSON) + .content(JSON.toJSONString(message))) + .andExpect(status().isOk()); + } + + /** + * 测试查询计费模板消息处理 + */ + @Test + public void testHandleQueryPricing() throws Exception { + Map queryData = new HashMap<>(); + queryData.put("pileCode", "TEST001"); + + JcppUplinkMessage message = JcppUplinkMessage.builder() + .messageId("msg-003") + .sessionId("session-001") + .protocolName("YKC_V1.6") + .pileCode("TEST001") + .messageType("QUERY_PRICING") + .timestamp(System.currentTimeMillis()) + .data(JSON.toJSONString(queryData)) + .build(); + + mockMvc.perform(post("/api/jcpp/uplink") + .contentType(MediaType.APPLICATION_JSON) + .content(JSON.toJSONString(message))) + .andExpect(status().isOk()); + } + + /** + * 测试刷卡启动充电消息处理 + */ + @Test + public void testHandleStartCharge() throws Exception { + JcppStartChargeData startData = JcppStartChargeData.builder() + .pileCode("TEST001") + .gunNo("1") + .startType("CARD") + .cardNo("1234567890") + .needPassword(false) + .build(); + + JcppUplinkMessage message = JcppUplinkMessage.builder() + .messageId("msg-004") + .sessionId("session-001") + .protocolName("YKC_V1.6") + .pileCode("TEST001") + .messageType("START_CHARGE") + .timestamp(System.currentTimeMillis()) + .data(JSON.toJSONString(startData)) + .build(); + + mockMvc.perform(post("/api/jcpp/uplink") + .contentType(MediaType.APPLICATION_JSON) + .content(JSON.toJSONString(message))) + .andExpect(status().isOk()); + } + + /** + * 测试实时数据消息处理 + */ + @Test + public void testHandleRealTimeData() throws Exception { + JcppRealTimeData realTimeData = JcppRealTimeData.builder() + .pileCode("TEST001") + .gunNo("1") + .tradeNo("trade-001") + .outputVoltage("380.5") + .outputCurrent("32.0") + .soc(50) + .totalChargingDurationMin(30) + .totalChargingEnergyKWh("15.5") + .totalChargingCostYuan("12.40") + .build(); + + JcppUplinkMessage message = JcppUplinkMessage.builder() + .messageId("msg-005") + .sessionId("session-001") + .protocolName("YKC_V1.6") + .pileCode("TEST001") + .messageType("REAL_TIME_DATA") + .timestamp(System.currentTimeMillis()) + .data(JSON.toJSONString(realTimeData)) + .build(); + + mockMvc.perform(post("/api/jcpp/uplink") + .contentType(MediaType.APPLICATION_JSON) + .content(JSON.toJSONString(message))) + .andExpect(status().isOk()); + } + + /** + * 测试交易记录消息处理 + */ + @Test + public void testHandleTransactionRecord() throws Exception { + JcppTransactionData transactionData = JcppTransactionData.builder() + .pileCode("TEST001") + .gunNo("1") + .tradeNo("trade-001") + .startTs(System.currentTimeMillis() - 3600000) + .endTs(System.currentTimeMillis()) + .totalEnergyKWh("30.5") + .totalAmountYuan("24.40") + .tradeTs(System.currentTimeMillis()) + .stopReason("USER_STOP") + .build(); + + JcppUplinkMessage message = JcppUplinkMessage.builder() + .messageId("msg-006") + .sessionId("session-001") + .protocolName("YKC_V1.6") + .pileCode("TEST001") + .messageType("TRANSACTION_RECORD") + .timestamp(System.currentTimeMillis()) + .data(JSON.toJSONString(transactionData)) + .build(); + + mockMvc.perform(post("/api/jcpp/uplink") + .contentType(MediaType.APPLICATION_JSON) + .content(JSON.toJSONString(message))) + .andExpect(status().isOk()); + } + + /** + * 测试远程启动结果消息处理 + */ + @Test + public void testHandleRemoteStartResult() throws Exception { + JcppRemoteStartResultData resultData = JcppRemoteStartResultData.builder() + .pileCode("TEST001") + .gunNo("1") + .tradeNo("trade-001") + .success(true) + .build(); + + JcppUplinkMessage message = JcppUplinkMessage.builder() + .messageId("msg-007") + .sessionId("session-001") + .protocolName("YKC_V1.6") + .pileCode("TEST001") + .messageType("REMOTE_START_RESULT") + .timestamp(System.currentTimeMillis()) + .data(JSON.toJSONString(resultData)) + .build(); + + mockMvc.perform(post("/api/jcpp/uplink") + .contentType(MediaType.APPLICATION_JSON) + .content(JSON.toJSONString(message))) + .andExpect(status().isOk()); + } + + /** + * 测试无效消息类型 + */ + @Test + public void testHandleInvalidMessageType() throws Exception { + JcppUplinkMessage message = JcppUplinkMessage.builder() + .messageId("msg-008") + .sessionId("session-001") + .protocolName("YKC_V1.6") + .pileCode("TEST001") + .messageType("INVALID_TYPE") + .timestamp(System.currentTimeMillis()) + .data(JSON.toJSONString(new HashMap<>())) + .build(); + + mockMvc.perform(post("/api/jcpp/uplink") + .contentType(MediaType.APPLICATION_JSON) + .content(JSON.toJSONString(message))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(false)); + } +} diff --git a/jsowell-admin/src/test/java/com/jsowell/jcpp/PricingModelConverterTest.java b/jsowell-admin/src/test/java/com/jsowell/jcpp/PricingModelConverterTest.java new file mode 100644 index 000000000..a5d47b8d3 --- /dev/null +++ b/jsowell-admin/src/test/java/com/jsowell/jcpp/PricingModelConverterTest.java @@ -0,0 +1,145 @@ +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; +import com.jsowell.pile.vo.web.BillingTemplateVO; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 计费模板转换工具类测试 + * + * @author jsowell + */ +public class PricingModelConverterTest { + + /** + * 测试标准计费模板转换 + */ + @Test + public void testConvertStandardPricing() { + BillingTemplateVO template = new BillingTemplateVO(); + 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(Lists.newArrayList("00:00-24:00")); + details.add(detail); + // template.setBillingDetailList(details); + + JcppPricingModel model = PricingModelConverter.convert(template); + + assertNotNull(model); + assertEquals(1L, model.getPricingId()); + assertEquals("标准计费模板", model.getPricingName()); + assertEquals(1, model.getPricingType()); // 标准计费 + assertEquals(new BigDecimal("0.8"), model.getElectricityPrice()); + assertEquals(new BigDecimal("0.4"), model.getServicePrice()); + } + + /** + * 测试峰谷计费模板转换 + */ + @Test + public void testConvertPeakValleyPricing() { + BillingTemplateVO template = new BillingTemplateVO(); + template.setTemplateId("2L"); + template.setTemplateName("峰谷计费模板"); + + List details = new ArrayList<>(); + + // 尖时 + BillingDetailVO sharpDetail = new BillingDetailVO(); + sharpDetail.setTimeType("1"); + sharpDetail.setElectricityPrice(new BigDecimal("1.2")); + sharpDetail.setServicePrice(new BigDecimal("0.6")); + sharpDetail.setApplyTime(Lists.newArrayList("10:00-12:00","18:00-20:00")); + details.add(sharpDetail); + + // 峰时 + BillingDetailVO peakDetail = new BillingDetailVO(); + peakDetail.setTimeType("2"); + peakDetail.setElectricityPrice(new BigDecimal("1.0")); + peakDetail.setServicePrice(new BigDecimal("0.5")); + peakDetail.setApplyTime(Lists.newArrayList("08:00-10:00","12:00-18:00")); + details.add(peakDetail); + + // 平时 + BillingDetailVO flatDetail = new BillingDetailVO(); + flatDetail.setTimeType("3"); + flatDetail.setElectricityPrice(new BigDecimal("0.8")); + flatDetail.setServicePrice(new BigDecimal("0.4")); + flatDetail.setApplyTime(Lists.newArrayList("06:00-08:00","20:00-22:00")); + details.add(flatDetail); + + // 谷时 + BillingDetailVO valleyDetail = new BillingDetailVO(); + valleyDetail.setTimeType("4"); + valleyDetail.setElectricityPrice(new BigDecimal("0.4")); + valleyDetail.setServicePrice(new BigDecimal("0.2")); + valleyDetail.setApplyTime(Lists.newArrayList("00:00-06:00","22:00-24:00")); + details.add(valleyDetail); + + // template.setBillingDetailList(details); + + JcppPricingModel model = PricingModelConverter.convert(template); + + assertNotNull(model); + assertEquals(2L, model.getPricingId()); + assertEquals("峰谷计费模板", model.getPricingName()); + assertEquals(2, model.getPricingType()); // 峰谷计费 + + // 验证峰谷价格 + JcppPricingModel.PeakValleyPrice peakValley = model.getPeakValleyPrice(); + assertNotNull(peakValley); + assertEquals(new BigDecimal("1.2"), peakValley.getSharpElectricityPrice()); + assertEquals(new BigDecimal("0.6"), peakValley.getSharpServicePrice()); + assertEquals(new BigDecimal("1.0"), peakValley.getPeakElectricityPrice()); + assertEquals(new BigDecimal("0.5"), peakValley.getPeakServicePrice()); + assertEquals(new BigDecimal("0.8"), peakValley.getFlatElectricityPrice()); + assertEquals(new BigDecimal("0.4"), peakValley.getFlatServicePrice()); + assertEquals(new BigDecimal("0.4"), peakValley.getValleyElectricityPrice()); + assertEquals(new BigDecimal("0.2"), peakValley.getValleyServicePrice()); + + // 验证时段配置 + assertNotNull(peakValley.getTimePeriodConfigs()); + assertFalse(peakValley.getTimePeriodConfigs().isEmpty()); + } + + /** + * 测试空模板转换 + */ + @Test + public void testConvertNullTemplate() { + JcppPricingModel model = PricingModelConverter.convert((BillingTemplateVO) null); + assertNull(model); + } + + /** + * 测试无详情模板转换 + */ + @Test + public void testConvertTemplateWithoutDetails() { + BillingTemplateVO template = new BillingTemplateVO(); + template.setTemplateId("3L"); + template.setTemplateName("无详情模板"); + // template.setBillingDetailList(null); + + JcppPricingModel model = PricingModelConverter.convert(template); + + assertNotNull(model); + assertEquals(3L, model.getPricingId()); + assertEquals(1, model.getPricingType()); // 默认标准计费 + } +} diff --git a/jsowell-common/src/main/java/com/jsowell/common/constant/CacheConstants.java b/jsowell-common/src/main/java/com/jsowell/common/constant/CacheConstants.java index f7412bf96..44809f557 100644 --- a/jsowell-common/src/main/java/com/jsowell/common/constant/CacheConstants.java +++ b/jsowell-common/src/main/java/com/jsowell/common/constant/CacheConstants.java @@ -84,6 +84,9 @@ public class CacheConstants { // 查询桩型号信息 public static final String GET_PILE_MODEL_INFO_BY_MODEL_ID = "get_pile_model_info_by_model_id:"; + // 根据ID查询桩型号信息 + public static final String PILE_MODEL_INFO_BY_ID = "pile_model_info_by_id:"; + // 地锁数据 public static final String GROUND_LOCK_DATA = "ground_lock_data:"; 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/JcppConfig.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/config/JcppConfig.java new file mode 100644 index 000000000..3202e065d --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/config/JcppConfig.java @@ -0,0 +1,115 @@ +package com.jsowell.pile.jcpp.config; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +/** + * JCPP 配置类 + * + * @author jsowell + */ +@Slf4j +@Data +@Configuration +@ConfigurationProperties(prefix = "jcpp") +public class JcppConfig { + + /** + * JCPP 服务地址 + */ + private String url = "http://localhost:8180"; + + /** + * 下行接口路径 + */ + private String downlinkPath = "/api/downlink"; + + /** + * 会话查询接口路径 + */ + private String sessionPath = "/api/session"; + + /** + * 请求超时时间(毫秒) + */ + private int timeout = 5000; + + /** + * 连接超时时间(毫秒) + */ + private int connectTimeout = 3000; + + /** + * 同步接口超时时间(毫秒)- 批量同步需要更长时间 + */ + private int syncTimeout = 120000; + + /** + * 同步接口连接超时时间(毫秒) + */ + private int syncConnectTimeout = 10000; + + /** + * 是否启用 JCPP 对接 + */ + private boolean enabled = true; + + /** + * 重试次数 + */ + private int retryCount = 3; + + /** + * 重试间隔(毫秒) + */ + private int retryInterval = 1000; + + /** + * 获取下行接口完整 URL + */ + public String getDownlinkUrl() { + return url + downlinkPath; + } + + /** + * 获取会话查询接口完整 URL + */ + public String getSessionUrl() { + return url + sessionPath; + } + + /** + * 创建 JCPP 专用的 RestTemplate(默认) + */ + @Primary + @Bean("jcppRestTemplate") + public RestTemplate jcppRestTemplate() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(connectTimeout); + factory.setReadTimeout(timeout); + RestTemplate restTemplate = new RestTemplate(factory); + log.info("JCPP RestTemplate 初始化完成, url: {}, timeout: {}ms, connectTimeout: {}ms", + url, timeout, connectTimeout); + return restTemplate; + } + + /** + * 创建 JCPP 同步专用的 RestTemplate(超时时间更长) + */ + @Bean("jcppSyncRestTemplate") + public RestTemplate jcppSyncRestTemplate() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(syncConnectTimeout); + factory.setReadTimeout(syncTimeout); + RestTemplate restTemplate = new RestTemplate(factory); + log.info("JCPP 同步 RestTemplate 初始化完成, syncTimeout: {}ms, syncConnectTimeout: {}ms", + syncTimeout, syncConnectTimeout); + return restTemplate; + } +} 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/config/JcppRabbitConfig.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/config/JcppRabbitConfig.java new file mode 100644 index 000000000..053d453aa --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/config/JcppRabbitConfig.java @@ -0,0 +1,176 @@ +package com.jsowell.pile.jcpp.config; + +import com.jsowell.pile.jcpp.constant.JcppConstants; +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.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * JCPP RabbitMQ 配置类 + * + * @author jsowell + */ +@Configuration +public class JcppRabbitConfig { + + // ==================== Exchange ==================== + + /** + * 上行消息 Exchange + */ + @Bean + public TopicExchange jcppUplinkExchange() { + return new TopicExchange(JcppConstants.UPLINK_EXCHANGE, true, false); + } + + // ==================== Queues ==================== + + /** + * 登录消息队列 + */ + @Bean + public Queue jcppLoginQueue() { + return new Queue(JcppConstants.QUEUE_LOGIN, true, false, false); + } + + /** + * 心跳消息队列 + */ + @Bean + public Queue jcppHeartbeatQueue() { + return new Queue(JcppConstants.QUEUE_HEARTBEAT, true, false, false); + } + + /** + * 启动充电消息队列 + */ + @Bean + public Queue jcppStartChargeQueue() { + return new Queue(JcppConstants.QUEUE_START_CHARGE, true, false, false); + } + + /** + * 实时数据消息队列 + */ + @Bean + public Queue jcppRealTimeDataQueue() { + return new Queue(JcppConstants.QUEUE_REAL_TIME_DATA, true, false, false); + } + + /** + * 交易记录消息队列 + */ + @Bean + public Queue jcppTransactionQueue() { + return new Queue(JcppConstants.QUEUE_TRANSACTION, true, false, false); + } + + /** + * 枪状态消息队列 + */ + @Bean + public Queue jcppGunStatusQueue() { + return new Queue(JcppConstants.QUEUE_GUN_STATUS, true, false, false); + } + + /** + * 计费模板消息队列 + */ + @Bean + public Queue jcppPricingQueue() { + return new Queue(JcppConstants.QUEUE_PRICING, true, false, false); + } + + /** + * 远程操作结果消息队列 + */ + @Bean + public Queue jcppRemoteResultQueue() { + return new Queue(JcppConstants.QUEUE_REMOTE_RESULT, true, false, false); + } + + /** + * 会话关闭消息队列 + */ + @Bean + public Queue jcppSessionCloseQueue() { + return new Queue(JcppConstants.QUEUE_SESSION_CLOSE, true, false, false); + } + + // ==================== Bindings ==================== + + /** + * 登录消息绑定 + */ + @Bean + public Binding jcppLoginBinding(Queue jcppLoginQueue, TopicExchange jcppUplinkExchange) { + return BindingBuilder.bind(jcppLoginQueue).to(jcppUplinkExchange).with(JcppConstants.ROUTING_KEY_LOGIN); + } + + /** + * 心跳消息绑定 + */ + @Bean + public Binding jcppHeartbeatBinding(Queue jcppHeartbeatQueue, TopicExchange jcppUplinkExchange) { + return BindingBuilder.bind(jcppHeartbeatQueue).to(jcppUplinkExchange).with(JcppConstants.ROUTING_KEY_HEARTBEAT); + } + + /** + * 启动充电消息绑定 + */ + @Bean + public Binding jcppStartChargeBinding(Queue jcppStartChargeQueue, TopicExchange jcppUplinkExchange) { + return BindingBuilder.bind(jcppStartChargeQueue).to(jcppUplinkExchange).with(JcppConstants.ROUTING_KEY_START_CHARGE); + } + + /** + * 实时数据消息绑定 + */ + @Bean + public Binding jcppRealTimeDataBinding(Queue jcppRealTimeDataQueue, TopicExchange jcppUplinkExchange) { + return BindingBuilder.bind(jcppRealTimeDataQueue).to(jcppUplinkExchange).with(JcppConstants.ROUTING_KEY_REAL_TIME_DATA); + } + + /** + * 交易记录消息绑定 + */ + @Bean + public Binding jcppTransactionBinding(Queue jcppTransactionQueue, TopicExchange jcppUplinkExchange) { + return BindingBuilder.bind(jcppTransactionQueue).to(jcppUplinkExchange).with(JcppConstants.ROUTING_KEY_TRANSACTION); + } + + /** + * 枪状态消息绑定 + */ + @Bean + public Binding jcppGunStatusBinding(Queue jcppGunStatusQueue, TopicExchange jcppUplinkExchange) { + return BindingBuilder.bind(jcppGunStatusQueue).to(jcppUplinkExchange).with(JcppConstants.ROUTING_KEY_GUN_STATUS); + } + + /** + * 计费模板消息绑定(通配符) + */ + @Bean + public Binding jcppPricingBinding(Queue jcppPricingQueue, TopicExchange jcppUplinkExchange) { + return BindingBuilder.bind(jcppPricingQueue).to(jcppUplinkExchange).with("jcpp.uplink.pricing.#"); + } + + /** + * 远程操作结果消息绑定(通配符) + */ + @Bean + public Binding jcppRemoteResultBinding(Queue jcppRemoteResultQueue, TopicExchange jcppUplinkExchange) { + return BindingBuilder.bind(jcppRemoteResultQueue).to(jcppUplinkExchange).with("jcpp.uplink.remoteResult.#"); + } + + /** + * 会话关闭消息绑定 + */ + @Bean + public Binding jcppSessionCloseBinding(Queue jcppSessionCloseQueue, TopicExchange jcppUplinkExchange) { + return BindingBuilder.bind(jcppSessionCloseQueue).to(jcppUplinkExchange).with(JcppConstants.ROUTING_KEY_SESSION_CLOSE); + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/constant/JcppConstants.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/constant/JcppConstants.java new file mode 100644 index 000000000..122dc0e7a --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/constant/JcppConstants.java @@ -0,0 +1,395 @@ +package com.jsowell.pile.jcpp.constant; + +/** + * JCPP 常量类 + * + * @author jsowell + */ +public class JcppConstants { + + private JcppConstants() { + } + + // ==================== RabbitMQ Exchange ==================== + + /** + * 上行消息 Exchange + */ + public static final String UPLINK_EXCHANGE = "jcpp.uplink.exchange"; + + // ==================== RabbitMQ Queues ==================== + + /** + * 登录消息队列 + */ + public static final String QUEUE_LOGIN = "jcpp.login.queue"; + + /** + * 心跳消息队列 + */ + public static final String QUEUE_HEARTBEAT = "jcpp.heartbeat.queue"; + + /** + * 启动充电消息队列 + */ + public static final String QUEUE_START_CHARGE = "jcpp.startCharge.queue"; + + /** + * 实时数据消息队列 + */ + public static final String QUEUE_REAL_TIME_DATA = "jcpp.realTimeData.queue"; + + /** + * 交易记录消息队列 + */ + public static final String QUEUE_TRANSACTION = "jcpp.transaction.queue"; + + /** + * 枪状态消息队列 + */ + public static final String QUEUE_GUN_STATUS = "jcpp.gunStatus.queue"; + + /** + * 计费模板消息队列 + */ + public static final String QUEUE_PRICING = "jcpp.pricing.queue"; + + /** + * 远程操作结果消息队列 + */ + public static final String QUEUE_REMOTE_RESULT = "jcpp.remoteResult.queue"; + + /** + * 会话关闭消息队列 + */ + public static final String QUEUE_SESSION_CLOSE = "jcpp.sessionClose.queue"; + + // ==================== RabbitMQ Routing Keys ==================== + + /** + * 登录消息路由键 + */ + public static final String ROUTING_KEY_LOGIN = "jcpp.uplink.login"; + + /** + * 心跳消息路由键 + */ + public static final String ROUTING_KEY_HEARTBEAT = "jcpp.uplink.heartbeat"; + + /** + * 启动充电消息路由键 + */ + public static final String ROUTING_KEY_START_CHARGE = "jcpp.uplink.startCharge"; + + /** + * 实时数据消息路由键 + */ + public static final String ROUTING_KEY_REAL_TIME_DATA = "jcpp.uplink.realTimeData"; + + /** + * 交易记录消息路由键 + */ + public static final String ROUTING_KEY_TRANSACTION = "jcpp.uplink.transaction"; + + /** + * 枪状态消息路由键 + */ + public static final String ROUTING_KEY_GUN_STATUS = "jcpp.uplink.gunStatus"; + + /** + * 会话关闭消息路由键 + */ + public static final String ROUTING_KEY_SESSION_CLOSE = "jcpp.uplink.sessionClose"; + + // ==================== Redis Key 前缀 ==================== + + /** + * JCPP 下行指令 Redis Key 前缀 + * 完整 key: jcpp:downlink:{pileCode} + */ + public static final String REDIS_DOWNLINK_PREFIX = "jcpp:downlink:"; + + /** + * JCPP 会话信息 Redis Key 前缀 + * 完整 key: jcpp:session:{pileCode} + */ + public static final String REDIS_SESSION_PREFIX = "jcpp:session:"; + + /** + * 在线充电桩集合 Redis Key + */ + public static final String REDIS_ONLINE_PILES_KEY = "jcpp:online:piles"; + + /** + * JCPP 会话信息 Redis Key 前缀(兼容旧版本) + * 完整 key: jcpp:session:{pileCode} + */ + public static final String REDIS_KEY_SESSION = "jcpp:session:"; + + /** + * JCPP 节点信息 Redis Key 前缀 + * 完整 key: jcpp:node:{pileCode} + */ + public static final String REDIS_KEY_NODE = "jcpp:node:"; + + /** + * 充电桩在线状态 Redis Key 前缀 + * 完整 key: jcpp:online:{pileCode} + */ + public static final String REDIS_KEY_ONLINE = "jcpp:online:"; + + /** + * 会话过期时间(秒)- 默认5分钟 + */ + public static final long SESSION_EXPIRE_SECONDS = 300L; + + /** + * 在线状态过期时间(秒)- 默认3分钟 + */ + public static final long ONLINE_EXPIRE_SECONDS = 180L; + + // ==================== 消息类型枚举 ==================== + + /** + * 上行消息类型 + */ + public static class MessageType { + /** + * 充电桩登录 + */ + public static final String LOGIN = "LOGIN"; + + /** + * 心跳 + */ + public static final String HEARTBEAT = "HEARTBEAT"; + + /** + * 刷卡/扫码启动充电 + */ + public static final String START_CHARGE = "START_CHARGE"; + + /** + * 实时数据上报 + */ + public static final String REAL_TIME_DATA = "REAL_TIME_DATA"; + + /** + * 交易记录(充电结束) + */ + public static final String TRANSACTION_RECORD = "TRANSACTION_RECORD"; + + /** + * 枪状态变化 + */ + public static final String GUN_STATUS = "GUN_STATUS"; + + /** + * 校验计费模板 + */ + public static final String VERIFY_PRICING = "VERIFY_PRICING"; + + /** + * 查询计费模板 + */ + public static final String QUERY_PRICING = "QUERY_PRICING"; + + /** + * 远程启动结果 + */ + public static final String REMOTE_START_RESULT = "REMOTE_START_RESULT"; + + /** + * 远程停止结果 + */ + public static final String REMOTE_STOP_RESULT = "REMOTE_STOP_RESULT"; + + /** + * 会话关闭 + */ + public static final String SESSION_CLOSE = "SESSION_CLOSE"; + + private MessageType() { + } + } + + // ==================== 下行指令类型 ==================== + + /** + * 下行指令类型 + */ + public static class DownlinkCommand { + /** + * 登录应答 + */ + public static final String LOGIN_ACK = "LOGIN_ACK"; + + /** + * 远程启动充电 + */ + public static final String REMOTE_START = "REMOTE_START"; + + /** + * 远程停止充电 + */ + public static final String REMOTE_STOP = "REMOTE_STOP"; + + /** + * 下发计费模板 + */ + public static final String SET_PRICING = "SET_PRICING"; + + /** + * 查询计费应答 + */ + public static final String QUERY_PRICING_ACK = "QUERY_PRICING_ACK"; + + /** + * 校验计费应答 + */ + public static final String VERIFY_PRICING_ACK = "VERIFY_PRICING_ACK"; + + /** + * 启动充电应答 + */ + public static final String START_CHARGE_ACK = "START_CHARGE_ACK"; + + /** + * 交易记录应答 + */ + public static final String TRANSACTION_RECORD_ACK = "TRANSACTION_RECORD_ACK"; + + private DownlinkCommand() { + } + } + + // ==================== 启动类型 ==================== + + /** + * 充电启动类型 + */ + public static class StartType { + /** + * 刷卡启动 + */ + public static final String CARD = "CARD"; + + /** + * APP/小程序启动 + */ + public static final String APP = "APP"; + + /** + * VIN码启动 + */ + public static final String VIN = "VIN"; + + private StartType() { + } + } + + // ==================== 鉴权失败原因 ==================== + + /** + * 鉴权失败原因 + */ + public static class AuthFailReason { + /** + * 账户不存在 + */ + public static final String ACCOUNT_NOT_EXISTS = "ACCOUNT_NOT_EXISTS"; + + /** + * 账户冻结 + */ + public static final String ACCOUNT_FROZEN = "ACCOUNT_FROZEN"; + + /** + * 余额不足 + */ + public static final String INSUFFICIENT_BALANCE = "INSUFFICIENT_BALANCE"; + + /** + * 密码错误 + */ + public static final String PASSWORD_ERROR = "PASSWORD_ERROR"; + + /** + * 充电桩停用 + */ + public static final String PILE_DISABLED = "PILE_DISABLED"; + + /** + * 充电枪故障 + */ + public static final String GUN_FAULT = "GUN_FAULT"; + + /** + * 充电枪占用 + */ + public static final String GUN_OCCUPIED = "GUN_OCCUPIED"; + + /** + * 系统错误 + */ + public static final String SYSTEM_ERROR = "SYSTEM_ERROR"; + + private AuthFailReason() { + } + } + + // ==================== 停止原因 ==================== + + /** + * 充电停止原因 + */ + public static class StopReason { + /** + * 用户主动停止 + */ + public static final String USER_STOP = "USER_STOP"; + + /** + * 充满自停 + */ + public static final String FULL_STOP = "FULL_STOP"; + + /** + * 金额用尽 + */ + public static final String BALANCE_EXHAUSTED = "BALANCE_EXHAUSTED"; + + /** + * 异常停止 + */ + public static final String ABNORMAL_STOP = "ABNORMAL_STOP"; + + /** + * 远程停止 + */ + public static final String REMOTE_STOP = "REMOTE_STOP"; + + private StopReason() { + } + } + + // ==================== 计费类型 ==================== + + /** + * 计费明细类型 + */ + public static class PricingDetailType { + /** + * 峰谷计费 + */ + public static final String PEAK_VALLEY = "PEAK_VALLEY"; + + /** + * 时段计费 + */ + public static final String TIME_PERIOD = "TIME_PERIOD"; + + private PricingDetailType() { + } + } +} 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 new file mode 100644 index 000000000..1d006e188 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppGunStatusConsumer.java @@ -0,0 +1,106 @@ +package com.jsowell.pile.jcpp.consumer; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.jsowell.pile.jcpp.constant.JcppConstants; +import com.jsowell.pile.jcpp.dto.JcppUplinkMessage; +import com.jsowell.pile.service.PileConnectorInfoService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * JCPP 枪状态消息消费者 + * + * @author jsowell + */ +@Slf4j +@Component +public class JcppGunStatusConsumer { + + @Autowired + private PileConnectorInfoService pileConnectorInfoService; + + /** + * 枪状态映射:JCPP 状态 -> 系统状态 + * JCPP: IDLE, INSERTED, CHARGING, CHARGE_COMPLETE, FAULT, UNKNOWN + * 系统: 0-离网, 1-空闲, 2-占用(未充电), 3-占用(充电中), 4-占用(预约锁定), 255-故障 + */ + 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"; // 离网 + } + } + + @RabbitListener(queues = JcppConstants.QUEUE_GUN_STATUS) + public void handleGunStatus(JcppUplinkMessage uplinkMessage) { + try { + // 验证消息 + if (uplinkMessage == null || uplinkMessage.getData() == null) { + log.warn("枪状态消息格式错误"); + return; + } + + // 从 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 (gunNo == null) { + log.warn("枪状态消息缺少 gunNo"); + return; + } + + // 构建枪口编码(pileCode + gunNo) + String pileConnectorCode = pileCode + gunNo; + + // 映射状态 + String status = mapGunStatus(gunRunStatus); + + // 更新枪状态 + int result = pileConnectorInfoService.updateConnectorStatus(pileConnectorCode, status); + if (result > 0) { + log.info("更新枪状态成功: pileConnectorCode={}, status={}", pileConnectorCode, status); + } else { + log.warn("更新枪状态失败: pileConnectorCode={}, status={}", pileConnectorCode, status); + } + + // 记录故障信息 + if (faultMessages != null && !faultMessages.isEmpty()) { + log.warn("充电枪故障: pileConnectorCode={}, faults={}", pileConnectorCode, faultMessages); + // TODO: 可以将故障信息保存到数据库或发送告警 + } + + } catch (Exception 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 new file mode 100644 index 000000000..b45448277 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppHeartbeatConsumer.java @@ -0,0 +1,58 @@ +package com.jsowell.pile.jcpp.consumer; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.jsowell.pile.jcpp.constant.JcppConstants; +import com.jsowell.pile.jcpp.dto.JcppUplinkMessage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +/** + * JCPP 心跳消息消费者 + * + * @author jsowell + */ +@Slf4j +@Component +public class JcppHeartbeatConsumer { + + private static final String HEARTBEAT_KEY_PREFIX = "jcpp:heartbeat:"; + private static final long HEARTBEAT_EXPIRE_SECONDS = 180L; + + @Autowired + private StringRedisTemplate stringRedisTemplate; + + @RabbitListener(queues = JcppConstants.QUEUE_HEARTBEAT) + public void handleHeartbeat(JcppUplinkMessage uplinkMessage) { + try { + // 验证消息 + if (uplinkMessage == null || uplinkMessage.getData() == null) { + log.debug("心跳消息格式错误"); + return; + } + + // 从 uplinkMessage 中获取 pileCode + String pileCode = uplinkMessage.getPileCode(); + if (pileCode == null || pileCode.isEmpty()) { + log.debug("心跳消息缺少 pileCode"); + return; + } + + // 更新最后活跃时间到 Redis(避免频繁写数据库) + String key = HEARTBEAT_KEY_PREFIX + pileCode; + stringRedisTemplate.opsForValue().set(key, String.valueOf(System.currentTimeMillis()), + HEARTBEAT_EXPIRE_SECONDS, TimeUnit.SECONDS); + + log.debug("收到充电桩心跳: pileCode={}", pileCode); + + } catch (Exception 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 new file mode 100644 index 000000000..935f384fe --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppLoginConsumer.java @@ -0,0 +1,74 @@ +package com.jsowell.pile.jcpp.consumer; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.jsowell.pile.domain.PileBasicInfo; +import com.jsowell.pile.jcpp.constant.JcppConstants; +import com.jsowell.pile.jcpp.dto.JcppUplinkMessage; +import com.jsowell.pile.jcpp.service.IJcppDownlinkService; +import com.jsowell.pile.service.PileBasicInfoService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * JCPP 登录消息消费者 + * + * @author jsowell + */ +@Slf4j +@Component +public class JcppLoginConsumer { + + @Autowired + private PileBasicInfoService pileBasicInfoService; + + @Autowired + private IJcppDownlinkService jcppDownlinkService; + + @RabbitListener(queues = JcppConstants.QUEUE_LOGIN) + public void handleLogin(JcppUplinkMessage uplinkMessage) { + try { + // 验证消息 + if (uplinkMessage == null || uplinkMessage.getData() == null) { + log.warn("登录消息格式错误"); + return; + } + + // 从 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()); + + // 查询充电桩是否存在 + PileBasicInfo pileInfo = pileBasicInfoService.selectPileBasicInfoBySN(pileCode); + boolean exists = pileInfo != null; + + if (exists) { + // 更新充电桩在线状态 + // PileBasicInfo updateInfo = new PileBasicInfo(); + // updateInfo.setId(pileInfo.getId()); + // updateInfo.setOnlineStatus("1"); // 1-在线 + // pileBasicInfoService.updatePileBasicInfo(updateInfo); + log.info("充电桩登录成功: pileCode={}", pileCode); + } else { + log.warn("充电桩不存在: pileCode={}", pileCode); + } + + // 发送登录应答 + jcppDownlinkService.sendLoginAck(pileCode, exists); + + } catch (Exception 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 new file mode 100644 index 000000000..db18ebd81 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppPricingConsumer.java @@ -0,0 +1,150 @@ +package com.jsowell.pile.jcpp.consumer; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.jsowell.pile.domain.PileBasicInfo; +import com.jsowell.pile.domain.PileBillingTemplate; +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.util.PricingModelConverter; +import com.jsowell.pile.service.PileBasicInfoService; +import com.jsowell.pile.service.PileBillingTemplateService; +import com.jsowell.pile.vo.web.BillingTemplateVO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * JCPP 计费查询消息消费者 + * + * @author jsowell + */ +@Slf4j +@Component +public class JcppPricingConsumer { + + @Autowired + private PileBasicInfoService pileBasicInfoService; + + @Autowired + private PileBillingTemplateService pileBillingTemplateService; + + @Autowired + private IJcppDownlinkService jcppDownlinkService; + + @RabbitListener(queues = JcppConstants.QUEUE_PRICING) + public void handlePricing(JcppUplinkMessage uplinkMessage) { + try { + // 验证消息 + if (uplinkMessage == null || uplinkMessage.getData() == null) { + log.warn("计费消息格式错误"); + return; + } + + // 从 uplinkMessage 中获取 pileCode 和 messageType + String pileCode = uplinkMessage.getPileCode(); + String messageType = uplinkMessage.getMessageType(); + + if (pileCode == null || pileCode.isEmpty()) { + 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); + } else if (JcppConstants.MessageType.VERIFY_PRICING.equals(messageType)) { + Long pricingId = data.getLong("pricingId"); + handleVerifyPricing(pileCode, pricingId); + } else { + log.warn("未知的计费消息类型: {}", messageType); + } + + } catch (Exception e) { + log.error("处理 JCPP 计费消息异常: pileCode={}", + uplinkMessage != null ? uplinkMessage.getPileCode() : "unknown", e); + } + } + + /** + * 处理查询计费模板请求 + */ + private void handleQueryPricing(String pileCode) { + try { + // 根据 pileCode 查询充电桩 + PileBasicInfo pileInfo = pileBasicInfoService.selectPileBasicInfoBySN(pileCode); + if (pileInfo == null) { + log.warn("充电桩不存在: pileCode={}", pileCode); + jcppDownlinkService.sendQueryPricingAck(pileCode, null, null); + return; + } + + // 2. 获取关联的计费模板 ID + 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()); + + // 获取充电桩关联的计费模板 ID + // Long billingTemplateId = pileInfo.getBillingTemplateId(); + // if (billingTemplateId == null) { + // log.warn("充电桩未配置计费模板: pileCode={}", pileCode); + // jcppDownlinkService.sendQueryPricingAck(pileCode, null, null); + // return; + // } + + // 查询计费模板 + 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(String pileCode, Long 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); + } + } +} 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 new file mode 100644 index 000000000..94add398b --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppRealTimeDataConsumer.java @@ -0,0 +1,113 @@ +package com.jsowell.pile.jcpp.consumer; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.jsowell.common.util.StringUtils; +import com.jsowell.pile.domain.OrderBasicInfo; +import com.jsowell.pile.jcpp.constant.JcppConstants; +import com.jsowell.pile.jcpp.dto.JcppUplinkMessage; +import com.jsowell.pile.service.OrderBasicInfoService; +import com.jsowell.pile.service.PileConnectorInfoService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.concurrent.TimeUnit; + +/** + * JCPP 实时数据消息消费者 + * + * @author jsowell + */ +@Slf4j +@Component +public class JcppRealTimeDataConsumer { + + private static final String REALTIME_DATA_KEY_PREFIX = "jcpp:realtime:"; + private static final long REALTIME_DATA_EXPIRE_SECONDS = 300L; + + @Autowired + private OrderBasicInfoService orderBasicInfoService; + + @Autowired + private PileConnectorInfoService pileConnectorInfoService; + + @Autowired + private StringRedisTemplate stringRedisTemplate; + + @RabbitListener(queues = JcppConstants.QUEUE_REAL_TIME_DATA) + public void handleRealTimeData(JcppUplinkMessage uplinkMessage) { + try { + // 验证消息 + if (uplinkMessage == null || uplinkMessage.getData() == null) { + log.debug("实时数据消息格式错误"); + return; + } + + // 从 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"); + String outputCurrent = data.getString("outputCurrent"); + Integer soc = data.getInteger("soc"); + Integer totalChargingDurationMin = data.getInteger("totalChargingDurationMin"); + String totalChargingEnergyKWh = data.getString("totalChargingEnergyKWh"); + String totalChargingCostYuan = data.getString("totalChargingCostYuan"); + + 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) { + log.debug("订单不存在: tradeNo={}", tradeNo); + return; + } + + // 检查订单状态是否为充电中 + if (order.getOrderStatus() != null && StringUtils.equals(order.getOrderStatus(), "1")) { + // 更新订单实时数据 + OrderBasicInfo updateOrder = new OrderBasicInfo(); + updateOrder.setId(order.getId()); + if (totalChargingEnergyKWh != null) { + // updateOrder.setTotalElectricity(new BigDecimal(totalChargingEnergyKWh)); + } + if (totalChargingCostYuan != null) { + updateOrder.setOrderAmount(new BigDecimal(totalChargingCostYuan)); + } + if (totalChargingDurationMin != null) { + // updateOrder.setChargingDuration(totalChargingDurationMin); + } + orderBasicInfoService.updateOrderBasicInfo(updateOrder); + } + + // 更新枪状态为充电中 + String pileConnectorCode = pileCode + gunNo; + pileConnectorInfoService.updateConnectorStatus(pileConnectorCode, "3"); + + log.debug("处理实时数据: tradeNo={}, soc={}, energy={}kWh", tradeNo, soc, totalChargingEnergyKWh); + + } catch (Exception 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 new file mode 100644 index 000000000..a666002bb --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppRemoteResultConsumer.java @@ -0,0 +1,131 @@ +package com.jsowell.pile.jcpp.consumer; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.jsowell.pile.domain.OrderBasicInfo; +import com.jsowell.pile.jcpp.constant.JcppConstants; +import com.jsowell.pile.jcpp.dto.JcppUplinkMessage; +import com.jsowell.pile.service.OrderBasicInfoService; +import com.jsowell.pile.service.PileConnectorInfoService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * JCPP 远程操作结果消息消费者 + * + * @author jsowell + */ +@Slf4j +@Component +public class JcppRemoteResultConsumer { + + @Autowired + private OrderBasicInfoService orderBasicInfoService; + + @Autowired + private PileConnectorInfoService pileConnectorInfoService; + + @Transactional(rollbackFor = Exception.class) + @RabbitListener(queues = JcppConstants.QUEUE_REMOTE_RESULT) + public void handleRemoteResult(JcppUplinkMessage uplinkMessage) { + try { + log.info("收到 JCPP 远程操作结果消息: pileCode={}, messageType={}", + uplinkMessage.getPileCode(), uplinkMessage.getMessageType()); + + // 验证消息 + if (uplinkMessage == null || uplinkMessage.getData() == null) { + log.warn("远程操作结果消息格式错误"); + return; + } + + 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); + } else { + log.warn("未知的远程操作结果消息类型: {}", messageType); + } + + } catch (Exception e) { + log.error("处理 JCPP 远程操作结果消息异常: pileCode={}", + uplinkMessage != null ? uplinkMessage.getPileCode() : "unknown", e); + } + } + + /** + * 处理远程启动结果 + */ + 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; + } + + // 根据 tradeNo 查询订单 + 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); + } + + // 等待交易记录消息进行最终结算 + } +} 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 new file mode 100644 index 000000000..551c4a66d --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppSessionCloseConsumer.java @@ -0,0 +1,77 @@ +package com.jsowell.pile.jcpp.consumer; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.jsowell.pile.domain.PileBasicInfo; +import com.jsowell.pile.jcpp.constant.JcppConstants; +import com.jsowell.pile.jcpp.dto.JcppUplinkMessage; +import com.jsowell.pile.service.PileBasicInfoService; +import com.jsowell.pile.service.PileConnectorInfoService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * JCPP 会话关闭消息消费者 + * + * @author jsowell + */ +@Slf4j +@Component +public class JcppSessionCloseConsumer { + + @Autowired + private PileBasicInfoService pileBasicInfoService; + + @Autowired + private PileConnectorInfoService pileConnectorInfoService; + + @RabbitListener(queues = JcppConstants.QUEUE_SESSION_CLOSE) + public void handleSessionClose(JcppUplinkMessage uplinkMessage) { + try { + // 验证消息 + if (uplinkMessage == null || uplinkMessage.getData() == null) { + log.warn("会话关闭消息格式错误"); + return; + } + + // 从 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 reason = data.getString("reason"); + + log.info("充电桩会话关闭: pileCode={}, reason={}", pileCode, reason); + + // 1. 更新充电桩离线状态 + PileBasicInfo pileInfo = pileBasicInfoService.selectPileBasicInfoBySN(pileCode); + if (pileInfo != null) { + PileBasicInfo updateInfo = new PileBasicInfo(); + updateInfo.setId(pileInfo.getId()); + // updateInfo.setOnlineStatus(0); // 0-离线 + pileBasicInfoService.updatePileBasicInfo(updateInfo); + log.info("更新充电桩离线状态: pileCode={}", pileCode); + } + + // 2. 更新所有枪状态为离线 + int result = pileConnectorInfoService.updateConnectorStatusByPileSn(pileCode, "0"); + log.info("更新枪状态为离线: pileCode={}, affectedRows={}", pileCode, result); + + // 3. TODO: 查询是否有正在充电的订单,如果有则标记为异常 + // 这里需要根据实际业务逻辑处理正在充电的订单 + // 可以调用 OrderBasicInfoService 查询并处理 + + } catch (Exception 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 new file mode 100644 index 000000000..c096c5a1d --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppStartChargeConsumer.java @@ -0,0 +1,253 @@ +package com.jsowell.pile.jcpp.consumer; + +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.JcppUplinkMessage; +import com.jsowell.pile.jcpp.service.IJcppDownlinkService; +import com.jsowell.pile.service.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * JCPP 刷卡启动充电消息消费者 + * + * @author jsowell + */ +@Slf4j +@Component +public class JcppStartChargeConsumer { + + @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 IJcppDownlinkService jcppDownlinkService; + + @Transactional(rollbackFor = Exception.class) + @RabbitListener(queues = JcppConstants.QUEUE_START_CHARGE) + public void handleStartCharge(JcppUplinkMessage uplinkMessage) { + try { + // 验证消息 + if (uplinkMessage == null || uplinkMessage.getData() == null) { + log.warn("启动充电消息格式错误"); + return; + } + + // 从 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 (gunNo == null) { + log.warn("启动充电消息缺少 gunNo"); + return; + } + + // 处理刷卡启动 + if ("CARD".equals(startType)) { + handleCardStartCharge(pileCode, gunNo, cardNo, needPassword, password); + } else { + log.warn("不支持的启动类型: {}", startType); + jcppDownlinkService.sendStartChargeAck(pileCode, gunNo, null, cardNo, null, + false, "不支持的启动类型"); + } + + } catch (Exception e) { + log.error("处理 JCPP 启动充电消息异常: pileCode={}", + uplinkMessage != null ? uplinkMessage.getPileCode() : "unknown", e); + } + } + + /** + * 处理刷卡启动充电 + */ + private void handleCardStartCharge(String pileCode, String gunNo, String cardNo, + Boolean needPassword, String password) { + String failReason = null; + String tradeNo = null; + String limitYuan = null; + boolean authSuccess = false; + + try { + // 1. 查询充电桩信息 + PileBasicInfo pileInfo = pileBasicInfoService.selectPileBasicInfoBySN(pileCode); + if (pileInfo == null) { + failReason = "充电桩不存在"; + sendAck(pileCode, gunNo, tradeNo, cardNo, limitYuan, false, failReason); + return; + } + + // 检查充电桩状态 + if (pileInfo.getDelFlag() != null && StringUtils.equals(pileInfo.getDelFlag(), "1")) { + failReason = "充电桩已停用"; + sendAck(pileCode, gunNo, tradeNo, cardNo, limitYuan, false, failReason); + return; + } + + // 2. 查询授权卡信息 + PileAuthCard authCard = pileAuthCardService.selectCardInfoByLogicCard(cardNo); + if (authCard == null) { + failReason = "账户不存在"; + sendAck(pileCode, gunNo, tradeNo, cardNo, limitYuan, false, failReason); + return; + } + + // 检查卡状态 + if (authCard.getStatus() != null && !StringUtils.equals(authCard.getStatus(), "1")) { + failReason = "账户已冻结"; + sendAck(pileCode, gunNo, tradeNo, cardNo, limitYuan, false, failReason); + return; + } + + // 3. 如果需要密码验证 + // if (Boolean.TRUE.equals(needPassword)) { + // if (password == null || !password.equals(authCard.getPassword())) { + // failReason = "密码错误"; + // sendAck(pileCode, gunNo, tradeNo, cardNo, limitYuan, false, failReason); + // return; + // } + // } + + // 4. 查询会员信息和钱包余额 + 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()); + } + } + } + + // 5. 检查白名单 + boolean isWhitelist = checkWhitelist(pileInfo.getStationId(), cardNo, memberId); + + // 6. 验证余额(非白名单用户需要检查余额) + BigDecimal minAmount = new BigDecimal("1.00"); // 最低充电金额 + if (!isWhitelist && balance.compareTo(minAmount) < 0) { + failReason = "余额不足"; + sendAck(pileCode, gunNo, tradeNo, cardNo, limitYuan, false, failReason); + return; + } + + // 7. 生成交易流水号 + tradeNo = IdUtils.fastSimpleUUID(); + limitYuan = balance.toString(); + + // 8. 创建充电订单 + 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 = "系统错误"; + } + + // 发送鉴权结果 + sendAck(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); + if (pileStationWhitelist == null) { + return false; + } + return true; + // for (PileStationWhitelist whitelist : whitelists) { + // // 检查卡号 + // if (cardNo != null && cardNo.equals(whitelist.getLogicCardNo())) { + // return true; + // } + // // 检查会员ID + // if (memberId != null && memberId.equals(whitelist.getMemberId())) { + // return true; + // } + // } + } catch (Exception e) { + log.error("检查白名单异常: stationId={}", stationId, e); + } + return false; + } + + /** + * 发送启动充电应答 + */ + private void sendAck(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); + } + } +} 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 new file mode 100644 index 000000000..82d68b921 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppTransactionConsumer.java @@ -0,0 +1,134 @@ +package com.jsowell.pile.jcpp.consumer; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.jsowell.common.util.StringUtils; +import com.jsowell.pile.domain.OrderBasicInfo; +import com.jsowell.pile.jcpp.constant.JcppConstants; +import com.jsowell.pile.jcpp.dto.JcppUplinkMessage; +import com.jsowell.pile.jcpp.service.IJcppDownlinkService; +import com.jsowell.pile.service.OrderBasicInfoService; +import com.jsowell.pile.service.PileConnectorInfoService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * JCPP 交易记录消息消费者 + * + * @author jsowell + */ +@Slf4j +@Component +public class JcppTransactionConsumer { + + @Autowired + private OrderBasicInfoService orderBasicInfoService; + + @Autowired + private PileConnectorInfoService pileConnectorInfoService; + + @Autowired + private IJcppDownlinkService jcppDownlinkService; + + @Transactional(rollbackFor = Exception.class) + @RabbitListener(queues = JcppConstants.QUEUE_TRANSACTION) + public void handleTransaction(JcppUplinkMessage uplinkMessage) { + try { + // 验证消息 + if (uplinkMessage == null || uplinkMessage.getData() == null) { + log.warn("交易记录消息格式错误"); + return; + } + + // 从 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"); + Long endTs = data.getLong("endTs"); + String totalEnergyKWh = data.getString("totalEnergyKWh"); + String totalAmountYuan = data.getString("totalAmountYuan"); + String stopReason = data.getString("stopReason"); + JSONObject detail = data.getJSONObject("detail"); + + if (tradeNo == null || tradeNo.isEmpty()) { + log.warn("交易记录消息缺少 tradeNo"); + return; + } + + // 根据 tradeNo 查询订单 + OrderBasicInfo order = orderBasicInfoService.getOrderInfoByTransactionCode(tradeNo); + if (order == null) { + log.warn("订单不存在: tradeNo={}", tradeNo); + jcppDownlinkService.sendTransactionRecordAck(pileCode, tradeNo, false); + return; + } + + // 幂等性检查:避免重复处理 + if (order.getOrderStatus() != null && StringUtils.equals(order.getOrderStatus(), "2")) { + log.info("订单已处理,跳过: tradeNo={}", tradeNo); + jcppDownlinkService.sendTransactionRecordAck(pileCode, tradeNo, true); + return; + } + + // 更新订单信息 + OrderBasicInfo updateOrder = new OrderBasicInfo(); + updateOrder.setId(order.getId()); + updateOrder.setOrderStatus("2"); // 充电完成 + + if (startTs != null) { + updateOrder.setChargeStartTime(new Date(startTs)); + } + if (endTs != null) { + updateOrder.setChargeEndTime(new Date(endTs)); + } + if (totalEnergyKWh != null) { + // updateOrder.setTotalElectricity(new BigDecimal(totalEnergyKWh)); + } + if (totalAmountYuan != null) { + updateOrder.setOrderAmount(new BigDecimal(totalAmountYuan)); + } + if (stopReason != null) { + updateOrder.setReason(stopReason); + } + + // 保存充电明细数据 + if (detail != null) { + // updateOrder.setChargeDetail(detail.toJSONString()); + } + + orderBasicInfoService.updateOrderBasicInfo(updateOrder); + log.info("更新订单完成: tradeNo={}, totalEnergy={}kWh, totalAmount={}元", + tradeNo, totalEnergyKWh, totalAmountYuan); + + // 更新枪状态为空闲 + String pileConnectorCode = pileCode + gunNo; + pileConnectorInfoService.updateConnectorStatus(pileConnectorCode, "1"); + + // 发送交易记录应答 + jcppDownlinkService.sendTransactionRecordAck(pileCode, tradeNo, true); + + // TODO: 触发结算流程 + // orderBasicInfoService.realTimeOrderSplit(order.getId()); + + } catch (Exception e) { + log.error("处理 JCPP 交易记录消息异常: pileCode={}", + uplinkMessage != null ? uplinkMessage.getPileCode() : "unknown", e); + } + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppDownlinkCommand.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppDownlinkCommand.java new file mode 100644 index 000000000..6e3a583a4 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppDownlinkCommand.java @@ -0,0 +1,48 @@ +package com.jsowell.pile.jcpp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * JCPP 下行指令通用结构 + * + * @author jsowell + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class JcppDownlinkCommand implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 指令ID + */ + private String commandId; + + /** + * 指令类型 + * @see com.jsowell.pile.jcpp.constant.JcppConstants.DownlinkCommand + */ + private String commandType; + + /** + * 充电桩编码 + */ + private String pileCode; + + /** + * 时间戳 + */ + private Long timestamp; + + /** + * 指令数据 + */ + private Object data; +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppDownlinkRequest.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppDownlinkRequest.java new file mode 100644 index 000000000..b795fd78b --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppDownlinkRequest.java @@ -0,0 +1,53 @@ +package com.jsowell.pile.jcpp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * JCPP 下行请求 + * + * @author jsowell + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class JcppDownlinkRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 会话ID + */ + private String sessionId; + + /** + * 充电桩编码 + */ + private String pileCode; + + /** + * 指令类型 + * @see com.jsowell.pile.jcpp.constant.JcppConstants.DownlinkCommand + */ + private String commandType; + + /** + * 指令数据 + */ + private Object data; + + /** + * 请求ID(用于追踪) + */ + private String requestId; + + /** + * 时间戳 + */ + private Long timestamp; +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppLoginData.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppLoginData.java new file mode 100644 index 000000000..66869d89d --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppLoginData.java @@ -0,0 +1,63 @@ +package com.jsowell.pile.jcpp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.Map; + +/** + * JCPP 登录消息数据 + * + * @author jsowell + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class JcppLoginData implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 充电桩编码 + */ + private String pileCode; + + /** + * 认证凭证 + */ + private String credential; + + /** + * 远程地址 + */ + private String remoteAddress; + + /** + * JCPP 节点ID + */ + private String nodeId; + + /** + * JCPP 节点主机地址 + */ + private String nodeHostAddress; + + /** + * JCPP 节点 REST 端口 + */ + private Integer nodeRestPort; + + /** + * JCPP 节点 gRPC 端口 + */ + private Integer nodeGrpcPort; + + /** + * 附加信息 + */ + private Map additionalInfo; +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppPricingModel.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppPricingModel.java new file mode 100644 index 000000000..5987b4d2e --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppPricingModel.java @@ -0,0 +1,180 @@ +package com.jsowell.pile.jcpp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * JCPP 计费模板 + * + * @author jsowell + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class JcppPricingModel implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 计费模板ID + */ + private Long pricingId; + + /** + * 计费模板名称 + */ + private String pricingName; + + /** + * 计费类型:1-标准计费 2-峰谷计费 3-时段计费 + */ + private Integer pricingType; + + /** + * 电费单价(标准计费时使用) + */ + private BigDecimal electricityPrice; + + /** + * 服务费单价(标准计费时使用) + */ + private BigDecimal servicePrice; + + /** + * 时段计费明细 + */ + private List timePeriodPrices; + + /** + * 峰谷计费明细 + */ + private PeakValleyPrice peakValleyPrice; + + /** + * 时段计费明细 + */ + @Data + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class TimePeriodPrice implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 开始时间(格式:HH:mm) + */ + private String startTime; + + /** + * 结束时间(格式:HH:mm) + */ + private String endTime; + + /** + * 电费单价 + */ + private BigDecimal electricityPrice; + + /** + * 服务费单价 + */ + private BigDecimal servicePrice; + + /** + * 时段类型:1-尖 2-峰 3-平 4-谷 + */ + private Integer periodType; + } + + /** + * 峰谷计费明细 + */ + @Data + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class PeakValleyPrice implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 尖时电费 + */ + private BigDecimal sharpElectricityPrice; + + /** + * 尖时服务费 + */ + private BigDecimal sharpServicePrice; + + /** + * 峰时电费 + */ + private BigDecimal peakElectricityPrice; + + /** + * 峰时服务费 + */ + private BigDecimal peakServicePrice; + + /** + * 平时电费 + */ + private BigDecimal flatElectricityPrice; + + /** + * 平时服务费 + */ + private BigDecimal flatServicePrice; + + /** + * 谷时电费 + */ + private BigDecimal valleyElectricityPrice; + + /** + * 谷时服务费 + */ + private BigDecimal valleyServicePrice; + + /** + * 时段配置 + */ + private List timePeriodConfigs; + } + + /** + * 时段配置 + */ + @Data + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class TimePeriodConfig implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 开始时间(格式:HH:mm) + */ + private String startTime; + + /** + * 结束时间(格式:HH:mm) + */ + private String endTime; + + /** + * 时段类型:1-尖 2-峰 3-平 4-谷 + */ + private Integer periodType; + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppRealTimeData.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppRealTimeData.java new file mode 100644 index 000000000..080e2a08e --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppRealTimeData.java @@ -0,0 +1,82 @@ +package com.jsowell.pile.jcpp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * JCPP 实时数据 + * + * @author jsowell + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class JcppRealTimeData implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 充电桩编码 + */ + private String pileCode; + + /** + * 枪编号 + */ + private String gunNo; + + /** + * 交易流水号 + */ + private String tradeNo; + + /** + * 输出电压(V) + */ + private String outputVoltage; + + /** + * 输出电流(A) + */ + private String outputCurrent; + + /** + * SOC 百分比 + */ + private Integer soc; + + /** + * 充电时长(分钟) + */ + private Integer totalChargingDurationMin; + + /** + * 充电电量(kWh) + */ + private String totalChargingEnergyKWh; + + /** + * 充电费用(元) + */ + private String totalChargingCostYuan; + + /** + * 剩余充电时间(分钟) + */ + private Integer remainingTimeMin; + + /** + * 枪状态 + */ + private String gunStatus; + + /** + * 充电功率(kW) + */ + private String chargingPowerKW; +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppRemoteStartResultData.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppRemoteStartResultData.java new file mode 100644 index 000000000..e5497de6d --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppRemoteStartResultData.java @@ -0,0 +1,47 @@ +package com.jsowell.pile.jcpp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * JCPP 远程启动结果数据 + * + * @author jsowell + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class JcppRemoteStartResultData implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 充电桩编码 + */ + private String pileCode; + + /** + * 枪编号 + */ + private String gunNo; + + /** + * 交易流水号 + */ + private String tradeNo; + + /** + * 是否成功 + */ + private Boolean success; + + /** + * 失败原因 + */ + private String failReason; +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppSessionInfo.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppSessionInfo.java new file mode 100644 index 000000000..306585a7b --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppSessionInfo.java @@ -0,0 +1,77 @@ +package com.jsowell.pile.jcpp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * JCPP 会话信息 + * + * @author jsowell + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class JcppSessionInfo implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 会话ID + */ + private String sessionId; + + /** + * 充电桩编码 + */ + private String pileCode; + + /** + * 远程地址 + */ + private String remoteAddress; + + /** + * JCPP 节点ID + */ + private String nodeId; + + /** + * JCPP 节点主机地址 + */ + private String nodeHostAddress; + + /** + * JCPP 节点 REST 端口 + */ + private Integer nodeRestPort; + + /** + * JCPP 节点 gRPC 端口 + */ + private Integer nodeGrpcPort; + + /** + * 协议名称 + */ + private String protocolName; + + /** + * 登录时间戳 + */ + private Long loginTimestamp; + + /** + * 最后活跃时间戳 + */ + private Long lastActiveTimestamp; + + /** + * 是否在线 + */ + private Boolean online; +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppStartChargeData.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppStartChargeData.java new file mode 100644 index 000000000..a8236db55 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppStartChargeData.java @@ -0,0 +1,62 @@ +package com.jsowell.pile.jcpp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * JCPP 刷卡/扫码启动充电数据 + * + * @author jsowell + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class JcppStartChargeData implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 充电桩编码 + */ + private String pileCode; + + /** + * 枪编号 + */ + private String gunNo; + + /** + * 启动类型:CARD-刷卡, APP-APP/小程序, VIN-VIN码 + */ + private String startType; + + /** + * 卡号或账号(逻辑卡号) + */ + private String cardNo; + + /** + * 物理卡号 + */ + private String physicalCardNo; + + /** + * 是否需要密码 + */ + private Boolean needPassword; + + /** + * 密码 + */ + private String password; + + /** + * 车辆VIN码 + */ + private String carVinCode; +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppTransactionData.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppTransactionData.java new file mode 100644 index 000000000..54cfc424c --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppTransactionData.java @@ -0,0 +1,190 @@ +package com.jsowell.pile.jcpp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * JCPP 交易记录数据 + * + * @author jsowell + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class JcppTransactionData implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 充电桩编码 + */ + private String pileCode; + + /** + * 枪编号 + */ + private String gunNo; + + /** + * 交易流水号 + */ + private String tradeNo; + + /** + * 开始时间戳 + */ + private Long startTs; + + /** + * 结束时间戳 + */ + private Long endTs; + + /** + * 总电量(kWh) + */ + private String totalEnergyKWh; + + /** + * 总金额(元)- 可选,如果充电桩不上报则由平台计算 + */ + private String totalAmountYuan; + + /** + * 交易时间戳 + */ + private Long tradeTs; + + /** + * 停止原因 + */ + private String stopReason; + + /** + * 电量明细 + */ + private EnergyDetail detail; + + /** + * 电量明细 + */ + @Data + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class EnergyDetail implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 明细类型:PEAK_VALLEY-峰谷计费, TIME_PERIOD-时段计费 + */ + private String type; + + /** + * 峰谷明细 + */ + private PeakValleyDetail peakValley; + + /** + * 时段明细列表 + */ + private List timePeriods; + } + + /** + * 峰谷明细 + */ + @Data + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class PeakValleyDetail implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 尖时电量(kWh) + */ + private BigDecimal sharpEnergyKWh; + + /** + * 尖时金额(元) + */ + private BigDecimal sharpAmountYuan; + + /** + * 峰时电量(kWh) + */ + private BigDecimal peakEnergyKWh; + + /** + * 峰时金额(元) + */ + private BigDecimal peakAmountYuan; + + /** + * 平时电量(kWh) + */ + private BigDecimal flatEnergyKWh; + + /** + * 平时金额(元) + */ + private BigDecimal flatAmountYuan; + + /** + * 谷时电量(kWh) + */ + private BigDecimal valleyEnergyKWh; + + /** + * 谷时金额(元) + */ + private BigDecimal valleyAmountYuan; + } + + /** + * 时段明细 + */ + @Data + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class TimePeriodDetail implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 开始时间(HH:mm) + */ + private String startTime; + + /** + * 结束时间(HH:mm) + */ + private String endTime; + + /** + * 电量(kWh) + */ + private BigDecimal energyKWh; + + /** + * 金额(元) + */ + private BigDecimal amountYuan; + + /** + * 时段类型:1-尖 2-峰 3-平 4-谷 + */ + private Integer periodType; + } +} 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 new file mode 100644 index 000000000..423d0aeb3 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppUplinkMessage.java @@ -0,0 +1,58 @@ +package com.jsowell.pile.jcpp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * JCPP 上行消息 + * + * @author jsowell + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class JcppUplinkMessage implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 消息ID + */ + private String messageId; + + /** + * 会话ID + */ + private String sessionId; + + /** + * 协议名称 + */ + private String protocolName; + + /** + * 充电桩编码 + */ + private String pileCode; + + /** + * 消息类型 + * @see com.jsowell.pile.jcpp.constant.JcppConstants.MessageType + */ + private String messageType; + + /** + * 时间戳 + */ + private Long timestamp; + + /** + * 具体消息内容(JSON 字符串格式,根据 messageType 不同,结构不同) + */ + private String data; +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppUplinkResponse.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppUplinkResponse.java new file mode 100644 index 000000000..fe7201446 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppUplinkResponse.java @@ -0,0 +1,96 @@ +package com.jsowell.pile.jcpp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * JCPP 上行消息响应 + * + * @author jsowell + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class JcppUplinkResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 是否成功 + */ + private boolean success; + + /** + * 消息 + */ + private String message; + + /** + * 响应数据 + */ + private Object data; + + /** + * 消息ID(原样返回) + */ + private String messageId; + + /** + * 成功响应 + */ + public static JcppUplinkResponse success() { + return JcppUplinkResponse.builder() + .success(true) + .message("success") + .build(); + } + + /** + * 成功响应(带数据) + */ + public static JcppUplinkResponse success(Object data) { + return JcppUplinkResponse.builder() + .success(true) + .message("success") + .data(data) + .build(); + } + + /** + * 成功响应(带消息ID) + */ + public static JcppUplinkResponse success(String messageId, Object data) { + return JcppUplinkResponse.builder() + .success(true) + .message("success") + .messageId(messageId) + .data(data) + .build(); + } + + /** + * 失败响应 + */ + public static JcppUplinkResponse error(String message) { + return JcppUplinkResponse.builder() + .success(false) + .message(message) + .build(); + } + + /** + * 失败响应(带消息ID) + */ + public static JcppUplinkResponse error(String messageId, String message) { + return JcppUplinkResponse.builder() + .success(false) + .message(message) + .messageId(messageId) + .build(); + } +} 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..2eef0091f --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/sync/JcppSyncResult.java @@ -0,0 +1,63 @@ +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; + } + + public Boolean isSuccess() { + return success; + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppAuthService.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppAuthService.java new file mode 100644 index 000000000..96e995125 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppAuthService.java @@ -0,0 +1,23 @@ +package com.jsowell.pile.jcpp.service; + +/** + * JCPP 认证服务接口 + * + * @author jsowell + */ +public interface IJcppAuthService { + + /** + * 获取 JCPP 访问令牌 + * 如果 Redis 中有缓存且未过期,直接返回 + * 否则调用登录接口获取新的 token + * + * @return 访问令牌 + */ + String getAccessToken(); + + /** + * 清除缓存的令牌(用于强制刷新) + */ + void clearToken(); +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppDownlinkService.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppDownlinkService.java new file mode 100644 index 000000000..e2213f19e --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppDownlinkService.java @@ -0,0 +1,173 @@ +package com.jsowell.pile.jcpp.service; + +import com.jsowell.pile.jcpp.dto.JcppPricingModel; +import com.jsowell.pile.jcpp.dto.JcppSessionInfo; + +import java.util.List; +import java.util.Map; + +/** + * JCPP 下行调用服务接口 + * + * @author jsowell + */ +public interface IJcppDownlinkService { + + /** + * 发送登录应答 + * + * @param pileCode 充电桩编码 + * @param success 是否登录成功 + */ + void sendLoginAck(String pileCode, boolean success); + + /** + * 发送远程启动充电指令 + * + * @param pileCode 充电桩编码 + * @param gunNo 枪编号 + * @param tradeNo 交易流水号 + * @param limitYuan 限制金额(元) + * @param logicalCardNo 逻辑卡号 + * @param physicalCardNo 物理卡号 + */ + void sendRemoteStartCharging(String pileCode, String gunNo, String tradeNo, + String limitYuan, String logicalCardNo, String physicalCardNo); + + /** + * 发送远程停止充电指令 + * + * @param pileCode 充电桩编码 + * @param gunNo 枪编号 + */ + void sendRemoteStopCharging(String pileCode, String gunNo); + + /** + * 发送计费模板 + * + * @param pileCode 充电桩编码 + * @param pricingId 计费模板ID + * @param pricingModel 计费模板 + */ + void sendSetPricing(String pileCode, Long pricingId, Object pricingModel); + + /** + * 发送查询计费应答 + * + * @param pileCode 充电桩编码 + * @param pricingId 计费模板ID + * @param pricingModel 计费模板 + */ + void sendQueryPricingAck(String pileCode, Long pricingId, Object pricingModel); + + /** + * 发送校验计费应答 + * + * @param pileCode 充电桩编码 + * @param success 是否校验成功 + * @param pricingId 计费模板ID + */ + void sendVerifyPricingAck(String pileCode, boolean success, Long pricingId); + + /** + * 发送启动充电应答(刷卡鉴权结果) + * + * @param pileCode 充电桩编码 + * @param gunNo 枪编号 + * @param tradeNo 交易流水号 + * @param logicalCardNo 逻辑卡号 + * @param limitYuan 限制金额(元) + * @param authSuccess 鉴权是否成功 + * @param failReason 失败原因 + */ + void sendStartChargeAck(String pileCode, String gunNo, String tradeNo, + String logicalCardNo, String limitYuan, + boolean authSuccess, String failReason); + + /** + * 发送交易记录应答 + * + * @param pileCode 充电桩编码 + * @param tradeNo 交易流水号 + * @param success 是否成功 + */ + void sendTransactionRecordAck(String pileCode, String tradeNo, boolean success); + + /** + * 检查充电桩是否在线 + * + * @param pileCode 充电桩编码 + * @return 是否在线 + */ + boolean isPileOnline(String pileCode); + + /** + * 获取充电桩会话信息 + * + * @param pileCode 充电桩编码 + * @return 会话信息 + */ + Map getSessionInfo(String pileCode); + + // ==================== 兼容旧接口 ==================== + + /** + * 远程启动充电(兼容旧接口) + */ + boolean remoteStartCharging(String sessionId, String pileCode, String gunNo, + String tradeNo, String limitYuan, + String logicalCardNo, String physicalCardNo); + + /** + * 远程停止充电(兼容旧接口) + */ + boolean remoteStopCharging(String sessionId, String pileCode, String gunNo); + + /** + * 下发计费模板(兼容旧接口) + */ + boolean setPricing(String sessionId, String pileCode, Long pricingId, JcppPricingModel pricingModel); + + /** + * 查询计费应答(兼容旧接口) + */ + boolean queryPricingAck(String sessionId, String pileCode, Long pricingId, JcppPricingModel pricingModel); + + /** + * 校验计费应答(兼容旧接口) + */ + boolean verifyPricingAck(String sessionId, String pileCode, boolean success, Long pricingId); + + /** + * 登录应答(兼容旧接口) + */ + boolean loginAck(String sessionId, String pileCode, boolean success); + + /** + * 启动充电应答(兼容旧接口) + */ + boolean startChargeAck(String sessionId, String pileCode, String gunNo, + String tradeNo, String logicalCardNo, String limitYuan, + boolean authSuccess, String failReason); + + /** + * 交易记录应答(兼容旧接口) + */ + boolean transactionRecordAck(String sessionId, String tradeNo, boolean success); + + /** + * 查询充电桩会话信息(兼容旧接口) + */ + JcppSessionInfo getSession(String pileCode); + + /** + * 查询所有在线充电桩(兼容旧接口) + */ + List getSessions(); + + /** + * 发送通用下行指令(兼容旧接口) + */ + Map sendDownlinkCommand(String sessionId, String pileCode, + String commandType, Object data); +} 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/IJcppMessageService.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppMessageService.java new file mode 100644 index 000000000..49c3df703 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppMessageService.java @@ -0,0 +1,100 @@ +package com.jsowell.pile.jcpp.service; + +import com.jsowell.pile.jcpp.dto.JcppUplinkMessage; +import com.jsowell.pile.jcpp.dto.JcppUplinkResponse; + +/** + * JCPP 消息处理服务接口 + * + * @author jsowell + */ +public interface IJcppMessageService { + + /** + * 处理上行消息 + * + * @param message 上行消息 + * @return 响应 + */ + JcppUplinkResponse handleMessage(JcppUplinkMessage message); + + /** + * 处理登录消息 + * + * @param message 上行消息 + * @return 响应 + */ + JcppUplinkResponse handleLogin(JcppUplinkMessage message); + + /** + * 处理心跳消息 + * + * @param message 上行消息 + * @return 响应 + */ + JcppUplinkResponse handleHeartbeat(JcppUplinkMessage message); + + /** + * 处理刷卡/扫码启动充电消息 + * + * @param message 上行消息 + * @return 响应 + */ + JcppUplinkResponse handleStartCharge(JcppUplinkMessage message); + + /** + * 处理实时数据上报消息 + * + * @param message 上行消息 + * @return 响应 + */ + JcppUplinkResponse handleRealTimeData(JcppUplinkMessage message); + + /** + * 处理交易记录消息 + * + * @param message 上行消息 + * @return 响应 + */ + JcppUplinkResponse handleTransactionRecord(JcppUplinkMessage message); + + /** + * 处理枪状态变化消息 + * + * @param message 上行消息 + * @return 响应 + */ + JcppUplinkResponse handleGunStatus(JcppUplinkMessage message); + + /** + * 处理校验计费模板消息 + * + * @param message 上行消息 + * @return 响应 + */ + JcppUplinkResponse handleVerifyPricing(JcppUplinkMessage message); + + /** + * 处理查询计费模板消息 + * + * @param message 上行消息 + * @return 响应 + */ + JcppUplinkResponse handleQueryPricing(JcppUplinkMessage message); + + /** + * 处理远程启动结果消息 + * + * @param message 上行消息 + * @return 响应 + */ + JcppUplinkResponse handleRemoteStartResult(JcppUplinkMessage message); + + /** + * 处理远程停止结果消息 + * + * @param message 上行消息 + * @return 响应 + */ + JcppUplinkResponse handleRemoteStopResult(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/IJcppRemoteChargeService.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppRemoteChargeService.java new file mode 100644 index 000000000..0c2085489 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppRemoteChargeService.java @@ -0,0 +1,46 @@ +package com.jsowell.pile.jcpp.service; + +/** + * JCPP 远程充电服务接口 + * 用于 APP/小程序发起的远程启动/停止充电 + * + * @author jsowell + */ +public interface IJcppRemoteChargeService { + + /** + * 远程启动充电 + * + * @param memberId 会员ID + * @param pileCode 充电桩编码 + * @param gunNo 枪编号 + * @param payAmount 预付金额(元) + * @return 订单号 + */ + String remoteStartCharging(String memberId, String pileCode, String gunNo, String payAmount); + + /** + * 远程停止充电 + * + * @param memberId 会员ID + * @param orderCode 订单号 + * @return 是否成功 + */ + boolean remoteStopCharging(String memberId, String orderCode); + + /** + * 检查充电桩是否在线 + * + * @param pileCode 充电桩编码 + * @return 是否在线 + */ + boolean isPileOnline(String pileCode); + + /** + * 获取充电桩会话ID + * + * @param pileCode 充电桩编码 + * @return 会话ID + */ + String getSessionId(String pileCode); +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppAuthServiceImpl.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppAuthServiceImpl.java new file mode 100644 index 000000000..e125ad63b --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppAuthServiceImpl.java @@ -0,0 +1,124 @@ +package com.jsowell.pile.jcpp.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.jsowell.pile.jcpp.service.IJcppAuthService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.concurrent.TimeUnit; + +/** + * JCPP 认证服务实现 + * + * @author jsowell + */ +@Slf4j +@Service +public class JcppAuthServiceImpl implements IJcppAuthService { + + private static final String JCPP_TOKEN_KEY = "jcpp:auth:token"; + private static final long TOKEN_EXPIRE_MINUTES = 30L; + + @Autowired + private StringRedisTemplate stringRedisTemplate; + + @Autowired + private RestTemplate restTemplate; + + @Value("${jcpp.sync.api-url:http://localhost:8180/api/sync}") + private String jcppApiUrl; + + @Value("${jcpp.auth.username:sanbing}") + private String username; + + @Value("${jcpp.auth.password:password123}") + private String password; + + /** + * 获取 JCPP 访问令牌 + */ + @Override + public String getAccessToken() { + // 1. 尝试从 Redis 获取缓存的 token + String cachedToken = stringRedisTemplate.opsForValue().get(JCPP_TOKEN_KEY); + if (cachedToken != null && !cachedToken.isEmpty()) { + log.debug("使用缓存的 JCPP token"); + return cachedToken; + } + + // 2. 缓存中没有,调用登录接口获取新的 token + log.info("缓存中没有 token,调用登录接口获取"); + String token = login(); + + // 3. 将 token 缓存到 Redis,有效期 30 分钟 + if (token != null && !token.isEmpty()) { + stringRedisTemplate.opsForValue().set(JCPP_TOKEN_KEY, token, TOKEN_EXPIRE_MINUTES, TimeUnit.MINUTES); + log.info("JCPP token 已缓存,有效期 {} 分钟", TOKEN_EXPIRE_MINUTES); + } + + return token; + } + + /** + * 清除缓存的令牌 + */ + @Override + public void clearToken() { + stringRedisTemplate.delete(JCPP_TOKEN_KEY); + log.info("已清除缓存的 JCPP token"); + } + + /** + * 调用 JCPP 登录接口 + */ + private String login() { + // 构建登录 URL(从 api-url 中提取基础 URL) + String baseUrl = jcppApiUrl.replace("/api/sync", ""); + String loginUrl = baseUrl + "/api/auth/login"; + + try { + // 构建请求体 + JSONObject requestBody = new JSONObject(); + requestBody.put("username", username); + requestBody.put("password", password); + + // 设置请求头 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(requestBody.toJSONString(), headers); + + log.info("调用 JCPP 登录接口: {}", loginUrl); + + // 发送请求 + ResponseEntity response = restTemplate.postForEntity(loginUrl, entity, String.class); + + if (response.getStatusCode() == HttpStatus.OK) { + // 解析响应,提取 token + JSONObject responseBody = JSON.parseObject(response.getBody()); + String token = responseBody.getString("token"); + + if (token != null && !token.isEmpty()) { + log.info("JCPP 登录成功,获取到 token"); + return token; + } else { + log.error("JCPP 登录响应中没有 token: {}", response.getBody()); + throw new RuntimeException("登录响应中没有 token"); + } + } else { + log.error("JCPP 登录失败,状态码: ", response.getStatusCode()); + throw new RuntimeException("登录失败,状态码: " + response.getStatusCode()); + } + + } catch (Exception e) { + log.error("调用 JCPP 登录接口异常", e); + throw new RuntimeException("登录失败: " + e.getMessage(), e); + } + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppDownlinkServiceImpl.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppDownlinkServiceImpl.java new file mode 100644 index 000000000..94e9b2a4c --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppDownlinkServiceImpl.java @@ -0,0 +1,432 @@ +package com.jsowell.pile.jcpp.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.TypeReference; +import com.jsowell.pile.jcpp.config.JcppConfig; +import com.jsowell.pile.jcpp.constant.JcppConstants; +import com.jsowell.pile.jcpp.dto.JcppDownlinkCommand; +import com.jsowell.pile.jcpp.dto.JcppDownlinkRequest; +import com.jsowell.pile.jcpp.dto.JcppPricingModel; +import com.jsowell.pile.jcpp.dto.JcppSessionInfo; +import com.jsowell.pile.jcpp.service.IJcppDownlinkService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * JCPP 下行调用服务实现类 + * + * @author jsowell + */ +@Slf4j +@Service +public class JcppDownlinkServiceImpl implements IJcppDownlinkService { + + @Autowired + private JcppConfig jcppConfig; + + @Autowired + @Qualifier("jcppRestTemplate") + private RestTemplate restTemplate; + + @Autowired + private StringRedisTemplate stringRedisTemplate; + + // ==================== 新接口实现(基于 Redis 队列) ==================== + + @Override + public void sendLoginAck(String pileCode, boolean success) { + Map data = new HashMap<>(); + data.put("success", success); + sendCommand(pileCode, JcppConstants.DownlinkCommand.LOGIN_ACK, data); + } + + @Override + public void sendRemoteStartCharging(String pileCode, String gunNo, String tradeNo, + String limitYuan, String logicalCardNo, String physicalCardNo) { + Map data = new HashMap<>(); + data.put("gunNo", gunNo); + data.put("tradeNo", tradeNo); + data.put("limitYuan", limitYuan); + data.put("logicalCardNo", logicalCardNo); + data.put("physicalCardNo", physicalCardNo); + sendCommand(pileCode, JcppConstants.DownlinkCommand.REMOTE_START, data); + } + + @Override + public void sendRemoteStopCharging(String pileCode, String gunNo) { + Map data = new HashMap<>(); + data.put("gunNo", gunNo); + sendCommand(pileCode, JcppConstants.DownlinkCommand.REMOTE_STOP, data); + } + + @Override + public void sendSetPricing(String pileCode, Long pricingId, Object pricingModel) { + Map data = new HashMap<>(); + data.put("pricingId", pricingId); + data.put("pricingModel", pricingModel); + sendCommand(pileCode, JcppConstants.DownlinkCommand.SET_PRICING, data); + } + + @Override + public void sendQueryPricingAck(String pileCode, Long pricingId, Object pricingModel) { + Map data = new HashMap<>(); + data.put("pricingId", pricingId); + data.put("pricingModel", pricingModel); + sendCommand(pileCode, JcppConstants.DownlinkCommand.QUERY_PRICING_ACK, data); + } + + @Override + public void sendVerifyPricingAck(String pileCode, boolean success, Long pricingId) { + Map data = new HashMap<>(); + data.put("success", success); + data.put("pricingId", pricingId); + sendCommand(pileCode, JcppConstants.DownlinkCommand.VERIFY_PRICING_ACK, data); + } + + @Override + public void sendStartChargeAck(String pileCode, String gunNo, String tradeNo, + String logicalCardNo, String limitYuan, + boolean authSuccess, String failReason) { + Map data = new HashMap<>(); + data.put("gunNo", gunNo); + data.put("tradeNo", tradeNo); + data.put("logicalCardNo", logicalCardNo); + data.put("limitYuan", limitYuan); + data.put("authSuccess", authSuccess); + data.put("failReason", failReason); + sendCommand(pileCode, JcppConstants.DownlinkCommand.START_CHARGE_ACK, data); + } + + @Override + public void sendTransactionRecordAck(String pileCode, String tradeNo, boolean success) { + Map data = new HashMap<>(); + data.put("tradeNo", tradeNo); + data.put("success", success); + sendCommand(pileCode, JcppConstants.DownlinkCommand.TRANSACTION_RECORD_ACK, data); + } + + @Override + public boolean isPileOnline(String pileCode) { + Boolean isMember = stringRedisTemplate.opsForSet().isMember(JcppConstants.REDIS_ONLINE_PILES_KEY, pileCode); + return Boolean.TRUE.equals(isMember); + } + + @Override + public Map getSessionInfo(String pileCode) { + String key = JcppConstants.REDIS_SESSION_PREFIX + pileCode; + Map entries = stringRedisTemplate.opsForHash().entries(key); + Map result = new HashMap<>(); + entries.forEach((k, v) -> result.put(String.valueOf(k), String.valueOf(v))); + return result; + } + + /** + * 发送下行指令到 Redis 队列 + */ + private void sendCommand(String pileCode, String commandType, Object data) { + JcppDownlinkCommand command = JcppDownlinkCommand.builder() + .commandId(UUID.randomUUID().toString()) + .commandType(commandType) + .pileCode(pileCode) + .timestamp(System.currentTimeMillis()) + .data(data) + .build(); + + String key = JcppConstants.REDIS_DOWNLINK_PREFIX + pileCode; + String json = JSON.toJSONString(command); + stringRedisTemplate.opsForList().rightPush(key, json); + log.info("发送 JCPP 下行指令: pileCode={}, commandType={}, commandId={}", + pileCode, commandType, command.getCommandId()); + } + + // ==================== 兼容旧接口实现(基于 HTTP) ==================== + + @Override + public boolean remoteStartCharging(String sessionId, String pileCode, String gunNo, + String tradeNo, String limitYuan, + String logicalCardNo, String physicalCardNo) { + Map data = new HashMap<>(); + data.put("gunNo", gunNo); + data.put("tradeNo", tradeNo); + data.put("limitYuan", limitYuan); + data.put("logicalCardNo", logicalCardNo); + data.put("physicalCardNo", physicalCardNo); + + Map result = sendDownlinkCommand(sessionId, pileCode, + JcppConstants.DownlinkCommand.REMOTE_START, data); + return isSuccess(result); + } + + @Override + public boolean remoteStopCharging(String sessionId, String pileCode, String gunNo) { + Map data = new HashMap<>(); + data.put("gunNo", gunNo); + + Map result = sendDownlinkCommand(sessionId, pileCode, + JcppConstants.DownlinkCommand.REMOTE_STOP, data); + return isSuccess(result); + } + + @Override + public boolean setPricing(String sessionId, String pileCode, Long pricingId, JcppPricingModel pricingModel) { + Map data = new HashMap<>(); + data.put("pricingId", pricingId); + data.put("pricingModel", pricingModel); + + Map result = sendDownlinkCommand(sessionId, pileCode, + JcppConstants.DownlinkCommand.SET_PRICING, data); + return isSuccess(result); + } + + @Override + public boolean queryPricingAck(String sessionId, String pileCode, Long pricingId, JcppPricingModel pricingModel) { + Map data = new HashMap<>(); + data.put("pricingId", pricingId); + data.put("pricingModel", pricingModel); + + Map result = sendDownlinkCommand(sessionId, pileCode, + JcppConstants.DownlinkCommand.QUERY_PRICING_ACK, data); + return isSuccess(result); + } + + @Override + public boolean verifyPricingAck(String sessionId, String pileCode, boolean success, Long pricingId) { + Map data = new HashMap<>(); + data.put("success", success); + data.put("pricingId", pricingId); + + Map result = sendDownlinkCommand(sessionId, pileCode, + JcppConstants.DownlinkCommand.VERIFY_PRICING_ACK, data); + return isSuccess(result); + } + + @Override + public boolean loginAck(String sessionId, String pileCode, boolean success) { + Map data = new HashMap<>(); + data.put("success", success); + + Map result = sendDownlinkCommand(sessionId, pileCode, + JcppConstants.DownlinkCommand.LOGIN_ACK, data); + return isSuccess(result); + } + + @Override + public boolean startChargeAck(String sessionId, String pileCode, String gunNo, + String tradeNo, String logicalCardNo, String limitYuan, + boolean authSuccess, String failReason) { + Map data = new HashMap<>(); + data.put("gunNo", gunNo); + data.put("tradeNo", tradeNo); + data.put("logicalCardNo", logicalCardNo); + data.put("limitYuan", limitYuan); + data.put("authSuccess", authSuccess); + data.put("failReason", failReason); + + Map result = sendDownlinkCommand(sessionId, pileCode, + JcppConstants.DownlinkCommand.START_CHARGE_ACK, data); + return isSuccess(result); + } + + @Override + public boolean transactionRecordAck(String sessionId, String tradeNo, boolean success) { + Map data = new HashMap<>(); + data.put("tradeNo", tradeNo); + data.put("success", success); + + // 从 Redis 获取 pileCode + String pileCode = getPileCodeByTradeNo(tradeNo); + if (pileCode == null) { + log.warn("无法获取交易流水号对应的充电桩编码, tradeNo: {}", tradeNo); + return false; + } + + Map result = sendDownlinkCommand(sessionId, pileCode, + JcppConstants.DownlinkCommand.TRANSACTION_RECORD_ACK, data); + return isSuccess(result); + } + + @Override + public JcppSessionInfo getSession(String pileCode) { + // 先从 Redis 缓存获取 + String cacheKey = JcppConstants.REDIS_KEY_SESSION + pileCode; + String sessionJson = stringRedisTemplate.opsForValue().get(cacheKey); + if (sessionJson != null) { + return JSON.parseObject(sessionJson, JcppSessionInfo.class); + } + + // 从 JCPP 服务获取 + try { + String url = jcppConfig.getSessionUrl() + "/" + pileCode; + ResponseEntity response = restTemplate.getForEntity(url, String.class); + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + Map result = JSON.parseObject(response.getBody(), + new TypeReference>() {}); + if (Boolean.TRUE.equals(result.get("success"))) { + Object data = result.get("data"); + if (data != null) { + JcppSessionInfo sessionInfo = JSON.parseObject(JSON.toJSONString(data), JcppSessionInfo.class); + // 缓存到 Redis + stringRedisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(sessionInfo), + JcppConstants.SESSION_EXPIRE_SECONDS, TimeUnit.SECONDS); + return sessionInfo; + } + } + } + } catch (RestClientException e) { + log.error("获取 JCPP 会话信息失败, pileCode: {}, error: {}", pileCode, e.getMessage()); + } + return null; + } + + @Override + public List getSessions() { + try { + String url = jcppConfig.getSessionUrl(); + ResponseEntity response = restTemplate.getForEntity(url, String.class); + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + Map result = JSON.parseObject(response.getBody(), + new TypeReference>() {}); + if (Boolean.TRUE.equals(result.get("success"))) { + Object data = result.get("data"); + if (data != null) { + return JSON.parseArray(JSON.toJSONString(data), JcppSessionInfo.class); + } + } + } + } catch (RestClientException e) { + log.error("获取 JCPP 所有会话信息失败, error: {}", e.getMessage()); + } + return Collections.emptyList(); + } + + @Override + public Map sendDownlinkCommand(String sessionId, String pileCode, + String commandType, Object data) { + if (!jcppConfig.isEnabled()) { + log.warn("JCPP 对接未启用,忽略下行指令: commandType={}, pileCode={}", commandType, pileCode); + return createErrorResult("JCPP 对接未启用"); + } + + // 如果没有传入 sessionId,尝试从 Redis 获取 + if (sessionId == null || sessionId.isEmpty()) { + sessionId = getSessionIdFromRedis(pileCode); + if (sessionId == null) { + log.warn("无法获取充电桩会话ID, pileCode: {}", pileCode); + return createErrorResult("充电桩不在线"); + } + } + + JcppDownlinkRequest request = JcppDownlinkRequest.builder() + .sessionId(sessionId) + .pileCode(pileCode) + .commandType(commandType) + .data(data) + .requestId(UUID.randomUUID().toString()) + .timestamp(System.currentTimeMillis()) + .build(); + + return sendWithRetry(request); + } + + /** + * 带重试的发送 + */ + private Map sendWithRetry(JcppDownlinkRequest request) { + int retryCount = jcppConfig.getRetryCount(); + int retryInterval = jcppConfig.getRetryInterval(); + + for (int i = 0; i <= retryCount; i++) { + try { + return doSend(request); + } catch (RestClientException e) { + log.warn("发送 JCPP 下行指令失败, 第 {} 次尝试, requestId: {}, error: {}", + i + 1, request.getRequestId(), e.getMessage()); + if (i < retryCount) { + try { + Thread.sleep(retryInterval); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + } + } + return createErrorResult("发送下行指令失败,已重试 " + retryCount + " 次"); + } + + /** + * 实际发送 + */ + private Map doSend(JcppDownlinkRequest request) { + String url = jcppConfig.getDownlinkUrl(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(request, headers); + + log.info("发送 JCPP 下行指令: url=, requestId={}, commandType={}, pileCode={}", + url, request.getRequestId(), request.getCommandType(), request.getPileCode()); + + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + Map result = JSON.parseObject(response.getBody(), + new TypeReference>() {}); + log.info("JCPP 下行指令响应: requestId={}, result={}", request.getRequestId(), result); + return result; + } + + return createErrorResult("HTTP 响应异常: " + response.getStatusCode()); + } + + /** + * 从 Redis 获取会话ID + */ + private String getSessionIdFromRedis(String pileCode) { + String cacheKey = JcppConstants.REDIS_KEY_SESSION + pileCode; + String sessionJson = stringRedisTemplate.opsForValue().get(cacheKey); + if (sessionJson != null) { + JcppSessionInfo sessionInfo = JSON.parseObject(sessionJson, JcppSessionInfo.class); + return sessionInfo.getSessionId(); + } + return null; + } + + /** + * 根据交易流水号获取充电桩编码 + */ + private String getPileCodeByTradeNo(String tradeNo) { + // TODO: 从订单表或 Redis 中获取 + return null; + } + + /** + * 判断结果是否成功 + */ + private boolean isSuccess(Map result) { + return result != null && Boolean.TRUE.equals(result.get("success")); + } + + /** + * 创建错误结果 + */ + private Map createErrorResult(String message) { + Map result = new HashMap<>(); + result.put("success", false); + result.put("message", message); + return result; + } +} 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/JcppMessageServiceImpl.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppMessageServiceImpl.java new file mode 100644 index 000000000..b37faf9db --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppMessageServiceImpl.java @@ -0,0 +1,627 @@ +package com.jsowell.pile.jcpp.service.impl; + +import com.alibaba.fastjson2.JSON; +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.*; +import com.jsowell.pile.jcpp.service.IJcppDownlinkService; +import com.jsowell.pile.jcpp.service.IJcppMessageService; +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 消息处理服务实现类 + * + * @author jsowell + */ +@Slf4j +@Service +public class JcppMessageServiceImpl implements IJcppMessageService { + + @Autowired + private PileBasicInfoService pileBasicInfoService; + + @Autowired + private PileBillingTemplateService pileBillingTemplateService; + + @Autowired + private PileAuthCardService pileAuthCardService; + + @Autowired + private MemberBasicInfoService memberBasicInfoService; + + @Autowired + private MemberWalletInfoService memberWalletInfoService; + + @Autowired + private OrderBasicInfoService orderBasicInfoService; + + @Autowired + private PileConnectorInfoService pileConnectorInfoService; + + @Autowired + private IJcppDownlinkService jcppDownlinkService; + + @Autowired + private StringRedisTemplate stringRedisTemplate; + + @Override + public JcppUplinkResponse handleMessage(JcppUplinkMessage message) { + if (message == null || message.getMessageType() == null) { + log.warn("收到无效的 JCPP 消息: {}", message); + return JcppUplinkResponse.error("无效的消息"); + } + + String messageType = message.getMessageType(); + log.info("收到 JCPP 上行消息, messageId: {}, messageType: {}, pileCode: {}", + message.getMessageId(), messageType, message.getPileCode()); + + try { + switch (messageType) { + case JcppConstants.MessageType.LOGIN: + return handleLogin(message); + case JcppConstants.MessageType.HEARTBEAT: + return handleHeartbeat(message); + case JcppConstants.MessageType.START_CHARGE: + return handleStartCharge(message); + case JcppConstants.MessageType.REAL_TIME_DATA: + return handleRealTimeData(message); + case JcppConstants.MessageType.TRANSACTION_RECORD: + return handleTransactionRecord(message); + case JcppConstants.MessageType.GUN_STATUS: + return handleGunStatus(message); + case JcppConstants.MessageType.VERIFY_PRICING: + return handleVerifyPricing(message); + case JcppConstants.MessageType.QUERY_PRICING: + return handleQueryPricing(message); + case JcppConstants.MessageType.REMOTE_START_RESULT: + return handleRemoteStartResult(message); + case JcppConstants.MessageType.REMOTE_STOP_RESULT: + return handleRemoteStopResult(message); + default: + log.warn("未知的消息类型: {}", messageType); + return JcppUplinkResponse.error(message.getMessageId(), "未知的消息类型: " + messageType); + } + } catch (Exception e) { + log.error("处理 JCPP 消息异常, messageId: {}, messageType: {}, error: {}", + message.getMessageId(), messageType, e.getMessage(), e); + return JcppUplinkResponse.error(message.getMessageId(), "处理消息异常: " + e.getMessage()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public JcppUplinkResponse handleLogin(JcppUplinkMessage message) { + String pileCode = message.getPileCode(); + String sessionId = message.getSessionId(); + log.info("处理登录消息, pileCode: {}, sessionId: {}", pileCode, sessionId); + + // 解析登录数据 + JcppLoginData loginData = parseData(message.getData(), JcppLoginData.class); + if (loginData == null) { + log.warn("登录消息数据解析失败, pileCode: {}", pileCode); + jcppDownlinkService.loginAck(sessionId, pileCode, false); + return JcppUplinkResponse.error(message.getMessageId(), "登录数据解析失败"); + } + + // 查询充电桩是否存在 + PileBasicInfo pileInfo = pileBasicInfoService.selectPileBasicInfoBySN(pileCode); + if (pileInfo == null) { + log.warn("充电桩不存在, pileCode: {}", pileCode); + jcppDownlinkService.loginAck(sessionId, pileCode, false); + return JcppUplinkResponse.error(message.getMessageId(), "充电桩不存在"); + } + + // 检查充电桩是否被删除 + if ("1".equals(pileInfo.getDelFlag())) { + log.warn("充电桩已被删除, pileCode: {}", pileCode); + jcppDownlinkService.loginAck(sessionId, pileCode, false); + return JcppUplinkResponse.error(message.getMessageId(), "充电桩已被删除"); + } + + // 保存会话信息到 Redis + JcppSessionInfo sessionInfo = JcppSessionInfo.builder() + .sessionId(sessionId) + .pileCode(pileCode) + .remoteAddress(loginData.getRemoteAddress()) + .nodeId(loginData.getNodeId()) + .nodeHostAddress(loginData.getNodeHostAddress()) + .nodeRestPort(loginData.getNodeRestPort()) + .nodeGrpcPort(loginData.getNodeGrpcPort()) + .protocolName(message.getProtocolName()) + .loginTimestamp(System.currentTimeMillis()) + .lastActiveTimestamp(System.currentTimeMillis()) + .online(true) + .build(); + + // 保存会话到 Redis + String sessionKey = JcppConstants.REDIS_KEY_SESSION + pileCode; + stringRedisTemplate.opsForValue().set(sessionKey, JSON.toJSONString(sessionInfo), + JcppConstants.SESSION_EXPIRE_SECONDS, TimeUnit.SECONDS); + + // 保存节点信息到 Redis + String nodeKey = JcppConstants.REDIS_KEY_NODE + pileCode; + stringRedisTemplate.opsForValue().set(nodeKey, JSON.toJSONString(loginData), + JcppConstants.SESSION_EXPIRE_SECONDS, TimeUnit.SECONDS); + + // 设置在线状态 + String onlineKey = JcppConstants.REDIS_KEY_ONLINE + pileCode; + stringRedisTemplate.opsForValue().set(onlineKey, "1", + JcppConstants.ONLINE_EXPIRE_SECONDS, TimeUnit.SECONDS); + + log.info("充电桩登录成功, pileCode: {}, sessionId: {}, remoteAddress: {}", + pileCode, sessionId, loginData.getRemoteAddress()); + + // 发送登录应答 + jcppDownlinkService.loginAck(sessionId, pileCode, true); + + return JcppUplinkResponse.success(message.getMessageId(), null); + } + + @Override + public JcppUplinkResponse handleHeartbeat(JcppUplinkMessage message) { + String pileCode = message.getPileCode(); + String sessionId = message.getSessionId(); + log.debug("处理心跳消息, pileCode: {}, sessionId: {}", pileCode, sessionId); + + // 更新会话过期时间 + String sessionKey = JcppConstants.REDIS_KEY_SESSION + pileCode; + String sessionJson = stringRedisTemplate.opsForValue().get(sessionKey); + if (sessionJson != null) { + JcppSessionInfo sessionInfo = JSON.parseObject(sessionJson, JcppSessionInfo.class); + sessionInfo.setLastActiveTimestamp(System.currentTimeMillis()); + stringRedisTemplate.opsForValue().set(sessionKey, JSON.toJSONString(sessionInfo), + JcppConstants.SESSION_EXPIRE_SECONDS, TimeUnit.SECONDS); + } + + // 更新节点信息过期时间 + String nodeKey = JcppConstants.REDIS_KEY_NODE + pileCode; + stringRedisTemplate.expire(nodeKey, JcppConstants.SESSION_EXPIRE_SECONDS, TimeUnit.SECONDS); + + // 更新在线状态过期时间 + String onlineKey = JcppConstants.REDIS_KEY_ONLINE + pileCode; + stringRedisTemplate.opsForValue().set(onlineKey, "1", + JcppConstants.ONLINE_EXPIRE_SECONDS, TimeUnit.SECONDS); + + return JcppUplinkResponse.success(message.getMessageId(), null); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public JcppUplinkResponse handleStartCharge(JcppUplinkMessage message) { + String pileCode = message.getPileCode(); + String sessionId = message.getSessionId(); + log.info("处理启动充电消息, pileCode: {}, sessionId: {}", pileCode, sessionId); + + // 解析启动充电数据 + JcppStartChargeData startData = parseData(message.getData(), JcppStartChargeData.class); + if (startData == null) { + log.warn("启动充电数据解析失败, pileCode: {}", pileCode); + return JcppUplinkResponse.error(message.getMessageId(), "启动充电数据解析失败"); + } + + String gunNo = startData.getGunNo(); + String cardNo = startData.getCardNo(); + String startType = startData.getStartType(); + + try { + // 查询充电桩信息 + PileBasicInfo pileInfo = pileBasicInfoService.selectPileBasicInfoBySN(pileCode); + if (pileInfo == null) { + sendStartChargeAck(sessionId, pileCode, gunNo, null, cardNo, null, + false, JcppConstants.AuthFailReason.PILE_DISABLED); + return JcppUplinkResponse.error(message.getMessageId(), "充电桩不存在"); + } + + // 根据启动类型进行鉴权 + String memberId = null; + String merchantId = String.valueOf(pileInfo.getMerchantId()); + BigDecimal balance = BigDecimal.ZERO; + + if (JcppConstants.StartType.CARD.equals(startType)) { + // 刷卡启动 - 根据逻辑卡号查询授权卡 + PileAuthCard authCard = pileAuthCardService.selectCardInfoByLogicCard(cardNo); + if (authCard == null) { + log.warn("授权卡不存在, cardNo: {}", cardNo); + sendStartChargeAck(sessionId, pileCode, gunNo, null, cardNo, null, + false, JcppConstants.AuthFailReason.ACCOUNT_NOT_EXISTS); + return JcppUplinkResponse.error(message.getMessageId(), "授权卡不存在"); + } + + // 检查卡状态 + if (!"1".equals(authCard.getStatus())) { + log.warn("授权卡已停用, cardNo: {}", cardNo); + sendStartChargeAck(sessionId, pileCode, gunNo, null, cardNo, null, + false, JcppConstants.AuthFailReason.ACCOUNT_FROZEN); + return JcppUplinkResponse.error(message.getMessageId(), "授权卡已停用"); + } + + memberId = authCard.getMemberId(); + } else if (JcppConstants.StartType.VIN.equals(startType)) { + // VIN码启动 + String vinCode = startData.getCarVinCode(); + // TODO: 根据VIN码查询会员信息 + log.info("VIN码启动, vinCode: ", vinCode); + } + + // 查询会员信息和余额 + if (memberId != null) { + MemberBasicInfo memberInfo = memberBasicInfoService.selectInfoByMemberId(memberId); + if (memberInfo == null) { + sendStartChargeAck(sessionId, pileCode, gunNo, null, cardNo, null, + false, JcppConstants.AuthFailReason.ACCOUNT_NOT_EXISTS); + return JcppUplinkResponse.error(message.getMessageId(), "会员不存在"); + } + + // 检查会员状态 + if ("1".equals(memberInfo.getStatus())) { + sendStartChargeAck(sessionId, pileCode, gunNo, null, cardNo, null, + false, JcppConstants.AuthFailReason.ACCOUNT_FROZEN); + return JcppUplinkResponse.error(message.getMessageId(), "会员账户已冻结"); + } + + // 查询钱包余额 + MemberWalletInfo walletInfo = memberWalletInfoService.selectByMemberId(memberId, merchantId); + if (walletInfo != null) { + BigDecimal principalBalance = walletInfo.getPrincipalBalance() != null ? walletInfo.getPrincipalBalance() : BigDecimal.ZERO; + BigDecimal giftBalance = walletInfo.getGiftBalance() != null ? walletInfo.getGiftBalance() : BigDecimal.ZERO; + balance = principalBalance.add(giftBalance); + } + + // 检查余额是否充足(最低1元) + if (balance.compareTo(BigDecimal.ONE) < 0) { + log.warn("余额不足, memberId: {}, balance: {}", memberId, balance); + sendStartChargeAck(sessionId, pileCode, gunNo, null, cardNo, null, + false, JcppConstants.AuthFailReason.INSUFFICIENT_BALANCE); + return JcppUplinkResponse.error(message.getMessageId(), "余额不足"); + } + } + + // 生成交易流水号 + String tradeNo = IdUtils.fastSimpleUUID(); + + // 创建订单 + OrderBasicInfo order = new OrderBasicInfo(); + order.setOrderCode(tradeNo); + order.setTransactionCode(tradeNo); + order.setPileSn(pileCode); + order.setConnectorCode(pileCode + "-" + gunNo); + order.setMemberId(memberId); + order.setMerchantId(String.valueOf(pileInfo.getMerchantId())); + order.setStationId(String.valueOf(pileInfo.getStationId())); + order.setOrderStatus("1"); // 充电中 + order.setPayStatus("0"); // 待支付 + order.setChargeStartTime(new Date()); + order.setCreateTime(new Date()); + orderBasicInfoService.insert(order); + + // 保存交易流水号与充电桩的映射关系到 Redis + String tradeKey = "jcpp:trade:" + tradeNo; + stringRedisTemplate.opsForValue().set(tradeKey, pileCode, 24, TimeUnit.HOURS); + + // 发送启动充电应答 + sendStartChargeAck(sessionId, pileCode, gunNo, tradeNo, cardNo, balance.toString(), true, null); + + log.info("刷卡启动充电鉴权成功, pileCode: {}, gunNo: {}, tradeNo: {}, memberId: {}, balance: {}", + pileCode, gunNo, tradeNo, memberId, balance); + + return JcppUplinkResponse.success(message.getMessageId(), tradeNo); + } catch (Exception e) { + log.error("处理启动充电消息异常, pileCode: {}, error: {}", pileCode, e.getMessage(), e); + sendStartChargeAck(sessionId, pileCode, gunNo, null, cardNo, null, + false, JcppConstants.AuthFailReason.SYSTEM_ERROR); + return JcppUplinkResponse.error(message.getMessageId(), "处理启动充电异常: " + e.getMessage()); + } + } + + /** + * 发送启动充电应答 + */ + private void sendStartChargeAck(String sessionId, String pileCode, String gunNo, + String tradeNo, String cardNo, String limitYuan, + boolean success, String failReason) { + try { + jcppDownlinkService.startChargeAck(sessionId, pileCode, gunNo, tradeNo, cardNo, limitYuan, success, failReason); + } catch (Exception e) { + log.error("发送启动充电应答失败, pileCode: {}, error: {}", pileCode, e.getMessage()); + } + } + + @Override + public JcppUplinkResponse handleRealTimeData(JcppUplinkMessage message) { + String pileCode = message.getPileCode(); + log.debug("处理实时数据消息, pileCode: {}, sessionId: {}", pileCode, message.getSessionId()); + + // 解析实时数据 + JcppRealTimeData realTimeData = parseData(message.getData(), JcppRealTimeData.class); + if (realTimeData == null) { + log.warn("实时数据解析失败, pileCode: {}", pileCode); + return JcppUplinkResponse.error(message.getMessageId(), "实时数据解析失败"); + } + + String tradeNo = realTimeData.getTradeNo(); + String gunNo = realTimeData.getGunNo(); + + try { + // 根据交易流水号查询订单 + OrderBasicInfo order = orderBasicInfoService.getOrderInfoByTransactionCode(tradeNo); + if (order == null) { + log.warn("订单不存在, tradeNo: {}", tradeNo); + return JcppUplinkResponse.error(message.getMessageId(), "订单不存在"); + } + + // 更新订单实时数据 + OrderBasicInfo updateOrder = new OrderBasicInfo(); + updateOrder.setId(order.getId()); + if (realTimeData.getTotalChargingEnergyKWh() != null) { + // updateOrder.setTotalElectricity(new BigDecimal(realTimeData.getTotalChargingEnergyKWh())); + } + if (realTimeData.getTotalChargingCostYuan() != null) { + // updateOrder.setTotalAmount(new BigDecimal(realTimeData.getTotalChargingCostYuan())); + } + if (realTimeData.getTotalChargingDurationMin() != null) { + // updateOrder.setTotalTime(realTimeData.getTotalChargingDurationMin()); + } + updateOrder.setUpdateTime(new Date()); + orderBasicInfoService.updateByPrimaryKeySelective(updateOrder); + + // 保存实时数据到 Redis(用于实时查询) + String realtimeKey = "jcpp:realtime:" + tradeNo; + stringRedisTemplate.opsForValue().set(realtimeKey, JSON.toJSONString(realTimeData), 10, TimeUnit.MINUTES); + + // 保存监控数据到数据库(可选,根据需要控制写入频率) + // saveMonitorData(order, realTimeData); + + log.debug("实时数据处理成功, tradeNo: {}, energy: {}kWh, cost: {}元", + tradeNo, realTimeData.getTotalChargingEnergyKWh(), realTimeData.getTotalChargingCostYuan()); + + return JcppUplinkResponse.success(message.getMessageId(), null); + } catch (Exception e) { + log.error("处理实时数据异常, tradeNo: {}, error: {}", tradeNo, e.getMessage(), e); + return JcppUplinkResponse.error(message.getMessageId(), "处理实时数据异常: " + e.getMessage()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public JcppUplinkResponse handleTransactionRecord(JcppUplinkMessage message) { + String pileCode = message.getPileCode(); + String sessionId = message.getSessionId(); + log.info("处理交易记录消息, pileCode: {}, sessionId: {}", pileCode, sessionId); + + // 解析交易记录数据 + JcppTransactionData transactionData = parseData(message.getData(), JcppTransactionData.class); + if (transactionData == null) { + log.warn("交易记录数据解析失败, pileCode: {}", pileCode); + return JcppUplinkResponse.error(message.getMessageId(), "交易记录数据解析失败"); + } + + String tradeNo = transactionData.getTradeNo(); + + try { + // 根据交易流水号查询订单 + OrderBasicInfo order = orderBasicInfoService.getOrderInfoByTransactionCode(tradeNo); + if (order == null) { + log.warn("订单不存在, tradeNo: {}", tradeNo); + // 仍然发送确认,避免充电桩重复上报 + jcppDownlinkService.transactionRecordAck(sessionId, tradeNo, true); + return JcppUplinkResponse.error(message.getMessageId(), "订单不存在"); + } + + // 幂等性检查:如果订单已经是完成状态,直接返回成功 + if ("2".equals(order.getOrderStatus())) { + log.info("订单已完成,忽略重复的交易记录, tradeNo: {}", tradeNo); + jcppDownlinkService.transactionRecordAck(sessionId, tradeNo, true); + return JcppUplinkResponse.success(message.getMessageId(), null); + } + + // 更新订单信息 + OrderBasicInfo updateOrder = new OrderBasicInfo(); + updateOrder.setId(order.getId()); + updateOrder.setOrderStatus("2"); // 充电完成 + updateOrder.setChargeEndTime(transactionData.getEndTs() != null ? new Date(transactionData.getEndTs()) : new Date()); + updateOrder.setReason(transactionData.getStopReason()); + + // 设置电量 + if (transactionData.getTotalEnergyKWh() != null) { + // updateOrder.setTotalElectricity(new BigDecimal(transactionData.getTotalEnergyKWh())); + } + + // 设置金额(如果充电桩上报了金额则使用,否则需要根据计费模板计算) + if (transactionData.getTotalAmountYuan() != null) { + // updateOrder.setTotalAmount(new BigDecimal(transactionData.getTotalAmountYuan())); + } + + // 计算充电时长 + if (transactionData.getStartTs() != null && transactionData.getEndTs() != null) { + long durationMin = (transactionData.getEndTs() - transactionData.getStartTs()) / 60000; + // updateOrder.setTotalTime((int) durationMin); + } + + updateOrder.setUpdateTime(new Date()); + orderBasicInfoService.updateByPrimaryKeySelective(updateOrder); + + // 更新枪状态为空闲 + String connectorCode = pileCode + "-" + transactionData.getGunNo(); + // pileConnectorInfoService.updateConnectorStatus(connectorCode, "0"); // 空闲 + + // 发送交易记录确认 + jcppDownlinkService.transactionRecordAck(sessionId, tradeNo, true); + + // 清理 Redis 中的实时数据 + String realtimeKey = "jcpp:realtime:" + tradeNo; + stringRedisTemplate.delete(realtimeKey); + + log.info("交易记录处理成功, tradeNo: {}, energy: {}kWh, amount: {}元, stopReason: {}", + tradeNo, transactionData.getTotalEnergyKWh(), + transactionData.getTotalAmountYuan(), transactionData.getStopReason()); + + // TODO: 触发结算流程(如果是预付费模式) + // orderBasicInfoService.realTimeOrderSplit(order); + + return JcppUplinkResponse.success(message.getMessageId(), null); + } catch (Exception e) { + log.error("处理交易记录异常, tradeNo: {}, error: {}", tradeNo, e.getMessage(), e); + return JcppUplinkResponse.error(message.getMessageId(), "处理交易记录异常: " + e.getMessage()); + } + } + + @Override + public JcppUplinkResponse handleGunStatus(JcppUplinkMessage message) { + String pileCode = message.getPileCode(); + log.info("处理枪状态消息, pileCode: {}, sessionId: {}", pileCode, message.getSessionId()); + + // TODO: 解析枪状态数据并更新数据库 + // 枪状态变化通常用于更新充电枪的实时状态 + return JcppUplinkResponse.success(message.getMessageId(), null); + } + + @Override + public JcppUplinkResponse handleVerifyPricing(JcppUplinkMessage message) { + String pileCode = message.getPileCode(); + String sessionId = message.getSessionId(); + log.info("处理校验计费消息, pileCode: {}, sessionId: {}", pileCode, sessionId); + + // 校验计费模板:充电桩上报当前使用的计费模板ID,平台校验是否一致 + // 如果不一致,需要重新下发计费模板 + try { + // 获取平台当前的计费模板 + BillingTemplateVO billingTemplate = pileBillingTemplateService.selectBillingTemplateDetailByPileSn(pileCode); + Long currentPricingId = billingTemplate != null ? Long.parseLong(billingTemplate.getTemplateId()) : null; + + // TODO: 从消息中获取充电桩上报的计费模板ID进行比对 + // 这里简化处理,直接返回成功 + jcppDownlinkService.verifyPricingAck(sessionId, pileCode, true, currentPricingId); + + return JcppUplinkResponse.success(message.getMessageId(), null); + } catch (Exception e) { + log.error("校验计费模板失败, pileCode: , error: {}", pileCode, e.getMessage(), e); + jcppDownlinkService.verifyPricingAck(sessionId, pileCode, false, null); + return JcppUplinkResponse.error(message.getMessageId(), "校验计费模板失败: " + e.getMessage()); + } + } + + @Override + public JcppUplinkResponse handleQueryPricing(JcppUplinkMessage message) { + String pileCode = message.getPileCode(); + String sessionId = message.getSessionId(); + log.info("处理查询计费消息, pileCode: {}, sessionId: {}", pileCode, sessionId); + + try { + // 根据充电桩编码查询计费模板 + BillingTemplateVO billingTemplate = pileBillingTemplateService.selectBillingTemplateDetailByPileSn(pileCode); + if (billingTemplate == null) { + log.warn("未找到充电桩的计费模板, pileCode: {}", pileCode); + jcppDownlinkService.queryPricingAck(sessionId, pileCode, null, null); + return JcppUplinkResponse.error(message.getMessageId(), "未找到计费模板"); + } + + // 转换为 JCPP 计费模板格式 + JcppPricingModel pricingModel = PricingModelConverter.convert(billingTemplate); + + // 发送计费模板应答 + jcppDownlinkService.queryPricingAck(sessionId, pileCode, Long.parseLong(billingTemplate.getTemplateId()), pricingModel); + + log.info("查询计费模板成功, pileCode: {}, templateId: {}, templateName: {}", + pileCode, billingTemplate.getTemplateId(), billingTemplate.getTemplateName()); + + return JcppUplinkResponse.success(message.getMessageId(), pricingModel); + } catch (Exception e) { + log.error("查询计费模板失败, pileCode: {}, error: {}", pileCode, e.getMessage(), e); + return JcppUplinkResponse.error(message.getMessageId(), "查询计费模板失败: " + e.getMessage()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public JcppUplinkResponse handleRemoteStartResult(JcppUplinkMessage message) { + String pileCode = message.getPileCode(); + String sessionId = message.getSessionId(); + log.info("处理远程启动结果消息, pileCode: {}, sessionId: {}", pileCode, sessionId); + + // 解析远程启动结果数据 + JcppRemoteStartResultData resultData = parseData(message.getData(), JcppRemoteStartResultData.class); + if (resultData == null) { + log.warn("远程启动结果数据解析失败, pileCode: {}", pileCode); + return JcppUplinkResponse.error(message.getMessageId(), "远程启动结果数据解析失败"); + } + + String tradeNo = resultData.getTradeNo(); + boolean success = Boolean.TRUE.equals(resultData.getSuccess()); + + try { + // 根据交易流水号查询订单 + OrderBasicInfo order = orderBasicInfoService.getOrderInfoByTransactionCode(tradeNo); + if (order == null) { + log.warn("订单不存在, tradeNo: {}", tradeNo); + return JcppUplinkResponse.error(message.getMessageId(), "订单不存在"); + } + + OrderBasicInfo updateOrder = new OrderBasicInfo(); + updateOrder.setId(order.getId()); + updateOrder.setUpdateTime(new Date()); + + if (success) { + // 启动成功 + updateOrder.setOrderStatus("1"); // 充电中 + log.info("远程启动充电成功, tradeNo: {}, pileCode: {}, gunNo: {}", + tradeNo, pileCode, resultData.getGunNo()); + } else { + // 启动失败 + updateOrder.setOrderStatus("4"); // 异常结束 + updateOrder.setReason(resultData.getFailReason()); + log.warn("远程启动充电失败, tradeNo: {}, pileCode: {}, failReason: {}", + tradeNo, pileCode, resultData.getFailReason()); + + // TODO: 触发退款流程(如果已预付) + // refundService.refund(order); + } + + orderBasicInfoService.updateByPrimaryKeySelective(updateOrder); + + return JcppUplinkResponse.success(message.getMessageId(), null); + } catch (Exception e) { + log.error("处理远程启动结果异常, tradeNo: {}, error: {}", tradeNo, e.getMessage(), e); + return JcppUplinkResponse.error(message.getMessageId(), "处理远程启动结果异常: " + e.getMessage()); + } + } + + @Override + public JcppUplinkResponse handleRemoteStopResult(JcppUplinkMessage message) { + String pileCode = message.getPileCode(); + log.info("处理远程停止结果消息, pileCode: {}, sessionId: {}", pileCode, message.getSessionId()); + + // 远程停止结果通常不需要特殊处理,等待交易记录上报即可 + // 这里只记录日志 + return JcppUplinkResponse.success(message.getMessageId(), null); + } + + /** + * 解析消息数据 + */ + private T parseData(Object data, Class clazz) { + if (data == null) { + return null; + } + try { + if (data instanceof String) { + return JSON.parseObject((String) data, clazz); + } + return JSON.parseObject(JSON.toJSONString(data), clazz); + } catch (Exception e) { + log.error("解析消息数据失败, data: , clazz: {}, error: {}", data, clazz.getName(), e.getMessage()); + return null; + } + } +} 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..001aeea1d --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppPileSyncServiceImpl.java @@ -0,0 +1,867 @@ +package com.jsowell.pile.jcpp.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.google.common.collect.Lists; +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.domain.PileModelInfo; +import com.jsowell.pile.jcpp.dto.sync.*; +import com.jsowell.pile.jcpp.service.IJcppAuthService; +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 com.jsowell.pile.service.PileModelInfoService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +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 IJcppAuthService jcppAuthService; + + @Autowired + @Qualifier("jcppSyncRestTemplate") + private RestTemplate restTemplate; + + @Autowired + private PileModelInfoService pileModelInfoService; + + @Value("${jcpp.sync.api-url:http://localhost:8180/api/sync}") + private String jcppApiUrl; + + @Value("${jcpp.sync.batch-size:1000}") + private int batchSize; + + @Value("${jcpp.sync.timeout:60000}") + private int timeout; + + /** + * 全量同步充电桩数据到 JCPP(批量处理) + */ + @Override + public JcppSyncResponse syncAllPiles() { + log.info("开始全量同步充电桩数据到 JCPP,批量大小: {}", batchSize); + + // 创建同步记录 + 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> pileBatches = Lists.partition(pileList, batchSize); + int totalBatches = pileBatches.size(); + log.info("充电桩分为 {} 批,每批最多 {} 台", totalBatches, batchSize); + + // 4. 汇总所有批次的同步结果 + List allPileResults = new ArrayList<>(); + List allGunResults = new ArrayList<>(); + + // 5. 逐批处理充电桩 + for (int i = 0; i < pileBatches.size(); i++) { + List batchPiles = pileBatches.get(i); + int batchNo = i + 1; + + long batchStartTime = System.currentTimeMillis(); + log.info("开始处理第 {}/{} 批充电桩,本批数量: {}", batchNo, totalBatches, batchPiles.size()); + + try { + // 5.1 转换充电桩数据 + long convertStartTime = System.currentTimeMillis(); + List pileDTOs = convertPilesToDTO(batchPiles); + long convertTime = System.currentTimeMillis() - convertStartTime; + log.debug("第 {} 批:充电桩数据转换耗时 {} ms", batchNo, convertTime); + + // 5.2 先同步充电桩(充电枪依赖充电桩,必须先同步充电桩) + log.info("第 {} 批:开始同步充电桩...", batchNo); + long pileSyncStartTime = System.currentTimeMillis(); + List batchPileResults = syncPilesToJcpp(pileDTOs); + long pileSyncTime = System.currentTimeMillis() - pileSyncStartTime; + allPileResults.addAll(batchPileResults); + + // 统计充电桩同步结果 + long batchPileSuccess = batchPileResults.stream().filter(JcppSyncResult::isSuccess).count(); + long batchPileFailed = batchPiles.size() - batchPileSuccess; + log.info("第 {} 批:充电桩同步完成 {}/{} (成功/总数),耗时 {} ms", + batchNo, batchPileSuccess, batchPiles.size(), pileSyncTime); + + // 5.3 查找本批充电桩对应的充电枪(只同步充电桩成功的枪) + long gunFilterStartTime = System.currentTimeMillis(); + List successPileSns = batchPileResults.stream() + .filter(JcppSyncResult::isSuccess) + .map(JcppSyncResult::getCode) + .collect(java.util.stream.Collectors.toList()); + + List batchGuns = gunList.stream() + .filter(gun -> successPileSns.contains(gun.getPileSn())) + .collect(java.util.stream.Collectors.toList()); + long gunFilterTime = System.currentTimeMillis() - gunFilterStartTime; + + log.info("第 {} 批:找到 {} 个充电枪(对应 {} 个成功的充电桩),筛选耗时 {} ms", + batchNo, batchGuns.size(), successPileSns.size(), gunFilterTime); + + // 5.4 同步充电枪(只同步充电桩成功的枪) + List batchGunResults = new ArrayList<>(); + if (!batchGuns.isEmpty()) { + long gunConvertStartTime = System.currentTimeMillis(); + List gunDTOs = convertGunsToDTO(batchGuns); + long gunConvertTime = System.currentTimeMillis() - gunConvertStartTime; + log.debug("第 {} 批:充电枪数据转换耗时 {} ms", batchNo, gunConvertTime); + + log.info("第 {} 批:开始同步充电枪...", batchNo); + long gunSyncStartTime = System.currentTimeMillis(); + batchGunResults = syncGunsToJcpp(gunDTOs); + long gunSyncTime = System.currentTimeMillis() - gunSyncStartTime; + allGunResults.addAll(batchGunResults); + + long batchGunSuccess = batchGunResults.stream().filter(JcppSyncResult::isSuccess).count(); + log.info("第 {} 批:充电枪同步完成 {}/{} (成功/总数),耗时 {} ms", + batchNo, batchGunSuccess, batchGuns.size(), gunSyncTime); + } else { + log.warn("第 {} 批:没有需要同步的充电枪(充电桩全部失败)", batchNo); + } + + // 5.5 统计本批总体结果和耗时 + long batchGunSuccess = batchGunResults.stream().filter(JcppSyncResult::isSuccess).count(); + long batchTotalTime = System.currentTimeMillis() - batchStartTime; + log.info("第 {}/{} 批同步完成: 充电桩 {}/{}, 充电枪 {}/{}, 总耗时 {} ms", + batchNo, totalBatches, + batchPileSuccess, batchPiles.size(), + batchGunSuccess, batchGuns.size(), + batchTotalTime); + + } catch (Exception e) { + long batchTotalTime = System.currentTimeMillis() - batchStartTime; + log.error("第 {} 批同步失败,耗时 {} ms", batchNo, batchTotalTime, e); + // 记录失败,但继续处理下一批 + for (PileBasicInfo pile : batchPiles) { + allPileResults.add(JcppSyncResult.fail(pile.getSn(), "批次同步异常: " + e.getMessage())); + } + } + } + + // 6. 构建最终响应 + JcppSyncResponse response = JcppSyncResponse.build(allPileResults, allGunResults); + + // 7. 更新同步记录 + 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(Lists.newArrayList(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()); + String pileName = pile.getName(); + // 如果名称为空,使用充电桩编号 + if (StringUtils.isBlank(pileName)) { + pileName = pile.getSn(); + } + dto.setPileName(pileName); + dto.setProtocol("yunkuaichongV150"); + + // 品牌、型号、制造商(可为空) + dto.setBrand("jsowell"); // Web 项目中没有这些字段 + dto.setModel(null); + dto.setManufacturer("jsowell"); + + // 类型映射:从 pile_model_info 表获取 speed_type + // 1-快充(DC直流) → DC, 2-慢充(AC交流) → AC + String type = "AC"; // 默认交流桩 + if (pile.getModelId() != null) { + try { + PileModelInfo modelInfo = pileModelInfoService.selectPileModelInfoById(pile.getModelId()); + if (modelInfo != null && StringUtils.isNotEmpty(modelInfo.getSpeedType())) { + if ("1".equals(modelInfo.getSpeedType())) { + type = "DC"; // 快充-直流 + } else if ("2".equals(modelInfo.getSpeedType())) { + type = "AC"; // 慢充-交流 + } + } + } catch (Exception e) { + log.warn("查询充电桩型号信息失败,使用默认类型AC: pileSn={}, modelId={}", pile.getSn(), pile.getModelId(), e); + } + } + 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.get()); + + 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()); + + // 充电枪名称:如果为空,使用枪号生成默认名称 + String gunName = gun.getName(); + if (StringUtils.isEmpty(gunName)) { + String gunNo = extractGunNo(gun.getPileConnectorCode()); + gunName = gun.getPileSn() + "的" + gunNo + "号枪"; + // log.warn("充电枪名称为空,使用默认名称: {} (gunCode: {})", gunName, gun.getPileConnectorCode()); + } + dto.setGunName(gunName); + + 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"; + long methodStartTime = System.currentTimeMillis(); + + try { + // 获取访问令牌 + long tokenStartTime = System.currentTimeMillis(); + String token = jcppAuthService.getAccessToken(); + long tokenTime = System.currentTimeMillis() - tokenStartTime; + log.info("【性能】获取访问令牌耗时: {} ms", tokenTime); + if (token == null || token.isEmpty()) { + log.error("无法获取 JCPP 访问令牌"); + // 返回失败结果 + List results = new ArrayList<>(); + for (JcppPileSyncDTO dto : pileDTOs) { + results.add(JcppSyncResult.fail(dto.getPileCode(), "无法获取访问令牌")); + } + return results; + } + + // 构建请求体 + long buildStartTime = System.currentTimeMillis(); + JSONObject requestBody = new JSONObject(); + requestBody.put("piles", pileDTOs); + + // 设置请求头 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + token); + + HttpEntity entity = new HttpEntity<>(requestBody.toJSONString(), headers); + long buildTime = System.currentTimeMillis() - buildStartTime; + log.info("【性能】构建充电桩请求体耗时: {} ms", buildTime); + + // 发送请求 + log.info("调用 JCPP 充电桩同步接口: {}, 数量: {}", url, pileDTOs.size()); + long httpStartTime = System.currentTimeMillis(); + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + long httpTime = System.currentTimeMillis() - httpStartTime; + log.info("【性能】JCPP 充电桩接口 HTTP 请求耗时: {} ms (网络+JCPP处理)", httpTime); + + // 打印响应状态和内容 + // log.info("JCPP 充电桩同步接口响应 - 状态码: {}, 响应体: {}", response.getStatusCode(), response.getBody()); + + if (response.getStatusCode() == HttpStatus.OK) { + // 解析响应 + long parseStartTime = System.currentTimeMillis(); + JSONObject responseBody = JSON.parseObject(response.getBody()); + + // JCPP 响应格式:{ "success": true, "data": { "results": [...] } } + // 需要从 data 中获取 results + JSONObject data = responseBody.getJSONObject("data"); + List results = null; + + if (data != null) { + results = data.getList("results", JcppSyncResult.class); + } else { + log.warn("JCPP 充电桩同步响应中没有 data 字段"); + results = new ArrayList<>(); + } + long parseTime = System.currentTimeMillis() - parseStartTime; + log.info("【性能】解析充电桩响应耗时: {} ms", parseTime); + + // 统计结果 + long successCount = results != null ? results.stream().filter(JcppSyncResult::isSuccess).count() : 0; + long failCount = results != null ? results.size() - successCount : 0; + long totalTime = System.currentTimeMillis() - methodStartTime; + log.info("JCPP 充电桩同步结果 - 成功: {}, 失败: {}, 方法总耗时: {} ms", successCount, failCount, totalTime); + + return results != null ? results : new ArrayList<>(); + } else if (response.getStatusCode() == HttpStatus.UNAUTHORIZED) { + // token 过期,清除缓存并重试一次 + log.warn("JCPP 访问令牌已过期,清除缓存并重试"); + jcppAuthService.clearToken(); + // 递归调用重试(只重试一次) + return retrySyncPilesToJcpp(pileDTOs); + } else { + log.error("JCPP 充电桩同步接口返回错误 - 状态码: {}, 响应体: {}", + response.getStatusCode(), response.getBody()); + // 返回失败结果 + 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; + } + } + + /** + * 重试同步充电桩(token 过期时使用) + */ + private List retrySyncPilesToJcpp(List pileDTOs) { + String url = jcppApiUrl + "/piles"; + + try { + // 重新获取访问令牌 + String token = jcppAuthService.getAccessToken(); + if (token == null || token.isEmpty()) { + log.error("重试时仍无法获取 JCPP 访问令牌"); + // 返回失败结果 + List results = new ArrayList<>(); + for (JcppPileSyncDTO dto : pileDTOs) { + results.add(JcppSyncResult.fail(dto.getPileCode(), "无法获取访问令牌")); + } + return results; + } + + // 构建请求体 + JSONObject requestBody = new JSONObject(); + requestBody.put("piles", pileDTOs); + + // 设置请求头 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + token); + + HttpEntity entity = new HttpEntity<>(requestBody.toJSONString(), headers); + + // 发送请求 + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + if (response.getStatusCode() == HttpStatus.OK) { + // 解析响应 + JSONObject responseBody = JSON.parseObject(response.getBody()); + + // JCPP 响应格式:{ "success": true, "data": { "results": [...] } } + JSONObject data = responseBody.getJSONObject("data"); + List results = null; + + if (data != null) { + results = data.getList("results", JcppSyncResult.class); + } else { + log.warn("重试:JCPP 充电桩同步响应中没有 data 字段"); + results = new ArrayList<>(); + } + + 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"; + long methodStartTime = System.currentTimeMillis(); + + try { + // 获取访问令牌 + long tokenStartTime = System.currentTimeMillis(); + String token = jcppAuthService.getAccessToken(); + long tokenTime = System.currentTimeMillis() - tokenStartTime; + log.info("【性能】获取访问令牌耗时: {} ms", tokenTime); + if (token == null || token.isEmpty()) { + log.error("无法获取 JCPP 访问令牌"); + // 返回失败结果 + List results = new ArrayList<>(); + for (JcppGunSyncDTO dto : gunDTOs) { + results.add(JcppSyncResult.fail(dto.getGunCode(), "无法获取访问令牌")); + } + return results; + } + + // 构建请求体 + long buildStartTime = System.currentTimeMillis(); + JSONObject requestBody = new JSONObject(); + requestBody.put("guns", gunDTOs); + + // 设置请求头 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + token); + + HttpEntity entity = new HttpEntity<>(requestBody.toJSONString(), headers); + long buildTime = System.currentTimeMillis() - buildStartTime; + log.info("【性能】构建充电枪请求体耗时: {} ms", buildTime); + + // 发送请求 + log.info("调用 JCPP 充电枪同步接口: {}, 数量: {}", url, gunDTOs.size()); + long httpStartTime = System.currentTimeMillis(); + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + long httpTime = System.currentTimeMillis() - httpStartTime; + log.info("【性能】JCPP 充电枪接口 HTTP 请求耗时: {} ms (网络+JCPP处理)", httpTime); + + // 打印响应状态和内容 + // log.info("JCPP 充电枪同步接口响应 - 状态码: {}, 响应体: {}", response.getStatusCode(), response.getBody()); + + if (response.getStatusCode() == HttpStatus.OK) { + // 解析响应 + long parseStartTime = System.currentTimeMillis(); + JSONObject responseBody = JSON.parseObject(response.getBody()); + + // JCPP 响应格式:{ "success": true, "data": { "results": [...] } } + // 需要从 data 中获取 results + JSONObject data = responseBody.getJSONObject("data"); + List results = null; + + if (data != null) { + results = data.getList("results", JcppSyncResult.class); + } else { + log.warn("JCPP 充电枪同步响应中没有 data 字段"); + results = new ArrayList<>(); + } + long parseTime = System.currentTimeMillis() - parseStartTime; + log.info("【性能】解析充电枪响应耗时: {} ms", parseTime); + + // 统计结果 + long successCount = results != null ? results.stream().filter(JcppSyncResult::isSuccess).count() : 0; + long failCount = results != null ? results.size() - successCount : 0; + long totalTime = System.currentTimeMillis() - methodStartTime; + log.info("JCPP 充电枪同步结果 - 成功: {}, 失败: {}, 方法总耗时: {} ms", successCount, failCount, totalTime); + + return results != null ? results : new ArrayList<>(); + } else if (response.getStatusCode() == HttpStatus.UNAUTHORIZED) { + // token 过期,清除缓存并重试一次 + log.warn("JCPP 访问令牌已过期,清除缓存并重试"); + jcppAuthService.clearToken(); + // 递归调用重试(只重试一次) + return retrySyncGunsToJcpp(gunDTOs); + } else { + log.error("JCPP 充电枪同步接口返回错误 - 状态码: {}, 响应体: {}", + response.getStatusCode(), response.getBody()); + // 返回失败结果 + 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; + } + } + + /** + * 重试同步充电枪(token 过期时使用) + */ + private List retrySyncGunsToJcpp(List gunDTOs) { + String url = jcppApiUrl + "/guns"; + + try { + // 重新获取访问令牌 + String token = jcppAuthService.getAccessToken(); + if (token == null || token.isEmpty()) { + log.error("重试时仍无法获取 JCPP 访问令牌"); + // 返回失败结果 + List results = new ArrayList<>(); + for (JcppGunSyncDTO dto : gunDTOs) { + results.add(JcppSyncResult.fail(dto.getGunCode(), "无法获取访问令牌")); + } + return results; + } + + // 构建请求体 + JSONObject requestBody = new JSONObject(); + requestBody.put("guns", gunDTOs); + + // 设置请求头 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + token); + + HttpEntity entity = new HttpEntity<>(requestBody.toJSONString(), headers); + + // 发送请求 + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + if (response.getStatusCode() == HttpStatus.OK) { + // 解析响应 + JSONObject responseBody = JSON.parseObject(response.getBody()); + + // JCPP 响应格式:{ "success": true, "data": { "results": [...] } } + JSONObject data = responseBody.getJSONObject("data"); + List results = null; + + if (data != null) { + results = data.getList("results", JcppSyncResult.class); + } else { + log.warn("重试:JCPP 充电枪同步响应中没有 data 字段"); + results = new ArrayList<>(); + } + + 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()) { + // 限制错误信息长度,避免超过 TEXT 字段限制(65535字节) + // 只保存前100条错误信息,并限制总长度不超过60000字符 + List errors = response.getErrors(); + int maxErrors = Math.min(100, errors.size()); + StringBuilder errorBuilder = new StringBuilder(); + + for (int i = 0; i < maxErrors; i++) { + String error = errors.get(i); + // 如果添加这条错误后会超过限制,则停止 + if (errorBuilder.length() + error.length() + 2 > 60000) { + errorBuilder.append("... (还有 ").append(errors.size() - i).append(" 条错误信息被省略)"); + break; + } + if (i > 0) { + errorBuilder.append("; "); + } + errorBuilder.append(error); + } + + // 如果还有更多错误但没有超长,也添加提示 + if (maxErrors < errors.size() && errorBuilder.length() < 60000) { + errorBuilder.append("; ... (还有 ").append(errors.size() - maxErrors).append(" 条错误信息被省略)"); + } + + record.setErrorMessage(errorBuilder.toString()); + } + } + + if (errorMessage != null) { + // 限制单个错误消息的长度 + if (errorMessage.length() > 60000) { + errorMessage = errorMessage.substring(0, 60000) + "... (错误信息过长已截断)"; + } + record.setErrorMessage(errorMessage); + } + + jcppSyncRecordMapper.updateJcppSyncRecord(record); + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppRemoteChargeServiceImpl.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppRemoteChargeServiceImpl.java new file mode 100644 index 000000000..794063994 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppRemoteChargeServiceImpl.java @@ -0,0 +1,179 @@ +package com.jsowell.pile.jcpp.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.jsowell.common.util.id.IdUtils; +import com.jsowell.pile.domain.MemberBasicInfo; +import com.jsowell.pile.domain.MemberWalletInfo; +import com.jsowell.pile.domain.OrderBasicInfo; +import com.jsowell.pile.domain.PileBasicInfo; +import com.jsowell.pile.jcpp.constant.JcppConstants; +import com.jsowell.pile.jcpp.dto.JcppSessionInfo; +import com.jsowell.pile.jcpp.service.IJcppDownlinkService; +import com.jsowell.pile.jcpp.service.IJcppRemoteChargeService; +import com.jsowell.pile.service.MemberBasicInfoService; +import com.jsowell.pile.service.MemberWalletInfoService; +import com.jsowell.pile.service.OrderBasicInfoService; +import com.jsowell.pile.service.PileBasicInfoService; +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 远程充电服务实现类 + * + * @author jsowell + */ +@Slf4j +@Service +public class JcppRemoteChargeServiceImpl implements IJcppRemoteChargeService { + + @Autowired + private PileBasicInfoService pileBasicInfoService; + + @Autowired + private MemberBasicInfoService memberBasicInfoService; + + @Autowired + private MemberWalletInfoService memberWalletInfoService; + + @Autowired + private OrderBasicInfoService orderBasicInfoService; + + @Autowired + private IJcppDownlinkService jcppDownlinkService; + + @Autowired + private StringRedisTemplate stringRedisTemplate; + + @Override + @Transactional(rollbackFor = Exception.class) + public String remoteStartCharging(String memberId, String pileCode, String gunNo, String payAmount) { + log.info("远程启动充电, memberId: {}, pileCode: {}, gunNo: {}, payAmount: {}", + memberId, pileCode, gunNo, payAmount); + + // 1. 检查充电桩是否在线(使用新的 Redis Set 方式) + if (!jcppDownlinkService.isPileOnline(pileCode)) { + throw new RuntimeException("充电桩离线,请稍后重试"); + } + + // 2. 查询充电桩信息 + PileBasicInfo pileInfo = pileBasicInfoService.selectPileBasicInfoBySN(pileCode); + if (pileInfo == null) { + throw new RuntimeException("充电桩不存在"); + } + + // 3. 查询会员信息 + MemberBasicInfo memberInfo = memberBasicInfoService.selectInfoByMemberId(memberId); + if (memberInfo == null) { + throw new RuntimeException("会员不存在"); + } + + // 4. 检查会员状态 + if ("1".equals(memberInfo.getStatus())) { + throw new RuntimeException("会员账户已冻结"); + } + + // 5. 查询钱包余额 + String merchantId = String.valueOf(pileInfo.getMerchantId()); + MemberWalletInfo walletInfo = memberWalletInfoService.selectByMemberId(memberId, merchantId); + BigDecimal balance = BigDecimal.ZERO; + if (walletInfo != null) { + BigDecimal principalBalance = walletInfo.getPrincipalBalance() != null ? walletInfo.getPrincipalBalance() : BigDecimal.ZERO; + BigDecimal giftBalance = walletInfo.getGiftBalance() != null ? walletInfo.getGiftBalance() : BigDecimal.ZERO; + balance = principalBalance.add(giftBalance); + } + + // 6. 检查余额是否充足 + BigDecimal payAmountDecimal = new BigDecimal(payAmount); + if (balance.compareTo(payAmountDecimal) < 0) { + throw new RuntimeException("余额不足"); + } + + // 7. 生成订单 + String tradeNo = IdUtils.fastSimpleUUID(); + OrderBasicInfo order = new OrderBasicInfo(); + order.setOrderCode(tradeNo); + order.setTransactionCode(tradeNo); + order.setPileSn(pileCode); + order.setConnectorCode(pileCode + "-" + gunNo); + order.setMemberId(memberId); + order.setMerchantId(String.valueOf(pileInfo.getMerchantId())); + order.setStationId(String.valueOf(pileInfo.getStationId())); + order.setOrderStatus("0"); // 待充电(等待启动结果) + order.setPayStatus("0"); // 待支付 + order.setChargeStartTime(new Date()); + order.setCreateTime(new Date()); + order.setPayAmount(payAmountDecimal); + orderBasicInfoService.insert(order); + + // 8. 保存交易流水号映射(用于后续查询) + String tradeKey = "jcpp:trade:" + tradeNo; + stringRedisTemplate.opsForValue().set(tradeKey, pileCode, 24, TimeUnit.HOURS); + + // 9. 发送远程启动指令(使用新的 Redis 队列方式) + jcppDownlinkService.sendRemoteStartCharging(pileCode, gunNo, tradeNo, payAmount, null, null); + + log.info("远程启动充电指令已发送, tradeNo: {}, pileCode: {}, gunNo: {}", tradeNo, pileCode, gunNo); + + return tradeNo; + } + + @Override + public boolean remoteStopCharging(String memberId, String orderCode) { + log.info("远程停止充电, memberId: {}, orderCode: {}", memberId, orderCode); + + // 1. 查询订单 + OrderBasicInfo order = orderBasicInfoService.getOrderInfoByOrderCode(orderCode); + if (order == null) { + throw new RuntimeException("订单不存在"); + } + + // 2. 验证会员 + if (!memberId.equals(order.getMemberId())) { + throw new RuntimeException("无权操作此订单"); + } + + // 3. 检查订单状态 + if (!"1".equals(order.getOrderStatus())) { + throw new RuntimeException("订单状态不允许停止充电"); + } + + String pileCode = order.getPileSn(); + String gunNo = order.getConnectorCode().replace(pileCode + "-", ""); + + // 4. 检查充电桩是否在线 + if (!jcppDownlinkService.isPileOnline(pileCode)) { + throw new RuntimeException("充电桩离线,请稍后重试"); + } + + // 5. 发送远程停止指令(使用新的 Redis 队列方式) + jcppDownlinkService.sendRemoteStopCharging(pileCode, gunNo); + + log.info("远程停止充电指令已发送, orderCode: {}, pileCode: {}, gunNo: {}", orderCode, pileCode, gunNo); + + return true; + } + + @Override + public boolean isPileOnline(String pileCode) { + // 使用新的 Redis Set 方式检查在线状态 + return jcppDownlinkService.isPileOnline(pileCode); + } + + @Override + public String getSessionId(String pileCode) { + String sessionKey = JcppConstants.REDIS_KEY_SESSION + pileCode; + String sessionJson = stringRedisTemplate.opsForValue().get(sessionKey); + if (sessionJson != null) { + JcppSessionInfo sessionInfo = JSON.parseObject(sessionJson, JcppSessionInfo.class); + return sessionInfo.getSessionId(); + } + return null; + } +} 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/jcpp/util/PricingModelConverter.java b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/util/PricingModelConverter.java new file mode 100644 index 000000000..be6fac7cd --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/util/PricingModelConverter.java @@ -0,0 +1,274 @@ +package com.jsowell.pile.jcpp.util; + +import com.jsowell.pile.domain.PileBillingDetail; +import com.jsowell.pile.domain.PileBillingTemplate; +import com.jsowell.pile.jcpp.dto.JcppPricingModel; +import com.jsowell.pile.vo.web.BillingDetailVO; +import com.jsowell.pile.vo.web.BillingTemplateVO; +import lombok.extern.slf4j.Slf4j; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +/** + * 计费模板转换工具类 + * + * @author jsowell + */ +@Slf4j +public class PricingModelConverter { + + private PricingModelConverter() { + } + + /** + * 将系统计费模板转换为 JCPP 计费模板格式 + * + * @param template 系统计费模板 + * @return JCPP 计费模板 + */ + public static JcppPricingModel convert(PileBillingTemplate template) { + if (template == null) { + return null; + } + + JcppPricingModel model = new JcppPricingModel(); + model.setPricingId(template.getId()); + model.setPricingName(template.getName()); + + List details = template.getPileBillingDetailList(); + if (details == null || details.isEmpty()) { + // 无详情,使用标准计费 + model.setPricingType(1); + return model; + } + + // 根据详情判断计费类型 + // 如果只有一条记录且时段为全天,则为标准计费 + // 否则为峰谷计费或时段计费 + if (details.size() == 1 && isAllDay(details.get(0).getApplyTime())) { + model.setPricingType(1); + model.setElectricityPrice(details.get(0).getElectricityPrice()); + model.setServicePrice(details.get(0).getServicePrice()); + } else { + // 峰谷计费 + model.setPricingType(2); + model.setPeakValleyPrice(convertToPeakValley(details)); + model.setTimePeriodPrices(convertToTimePeriods(details)); + } + + return model; + } + + /** + * 将 BillingTemplateVO 转换为 JCPP 计费模板格式 + * + * @param vo 计费模板 VO + * @return JCPP 计费模板 + */ + public static JcppPricingModel convert(BillingTemplateVO vo) { + if (vo == null) { + return null; + } + + JcppPricingModel model = new JcppPricingModel(); + model.setPricingId(Long.parseLong(vo.getTemplateId())); + model.setPricingName(vo.getTemplateName()); + + List details = vo.getBillingDetailList(); + if (details == null || details.isEmpty()) { + model.setPricingType(1); + return model; + } + + if (details.size() == 1 + //&& isAllDay(details.get(0).getApplyTime()) + ) { + model.setPricingType(1); + model.setElectricityPrice(details.get(0).getElectricityPrice()); + model.setServicePrice(details.get(0).getServicePrice()); + } else { + model.setPricingType(2); + model.setPeakValleyPrice(convertToPeakValleyFromVO(details)); + model.setTimePeriodPrices(convertToTimePeriodsFromVO(details)); + } + + return model; + } + + /** + * 判断是否为全天时段 + */ + private static boolean isAllDay(String applyTime) { + if (applyTime == null) { + return true; + } + return "00:00-24:00".equals(applyTime) || "00:00-23:59".equals(applyTime) + || applyTime.isEmpty(); + } + + /** + * 转换为峰谷计费明细 + */ + private static JcppPricingModel.PeakValleyPrice convertToPeakValley(List details) { + JcppPricingModel.PeakValleyPrice peakValley = new JcppPricingModel.PeakValleyPrice(); + List configs = new ArrayList<>(); + + for (PileBillingDetail detail : details) { + String timeType = detail.getTimeType(); + BigDecimal electricityPrice = detail.getElectricityPrice(); + BigDecimal servicePrice = detail.getServicePrice(); + + // 根据时段类型设置价格 + switch (timeType) { + case "1": // 尖时 + peakValley.setSharpElectricityPrice(electricityPrice); + peakValley.setSharpServicePrice(servicePrice); + break; + case "2": // 峰时 + peakValley.setPeakElectricityPrice(electricityPrice); + peakValley.setPeakServicePrice(servicePrice); + break; + case "3": // 平时 + peakValley.setFlatElectricityPrice(electricityPrice); + peakValley.setFlatServicePrice(servicePrice); + break; + case "4": // 谷时 + peakValley.setValleyElectricityPrice(electricityPrice); + peakValley.setValleyServicePrice(servicePrice); + break; + default: + log.warn("未知的时段类型: {}", timeType); + } + + // 解析时段配置 + String applyTime = detail.getApplyTime(); + if (applyTime != null && !applyTime.isEmpty()) { + String[] periods = applyTime.split(","); + for (String period : periods) { + String[] times = period.split("-"); + if (times.length == 2) { + configs.add(JcppPricingModel.TimePeriodConfig.builder() + .startTime(times[0].trim()) + .endTime(times[1].trim()) + .periodType(Integer.parseInt(timeType)) + .build()); + } + } + } + } + + peakValley.setTimePeriodConfigs(configs); + return peakValley; + } + + /** + * 转换为时段计费明细 + */ + private static List convertToTimePeriods(List details) { + List prices = new ArrayList<>(); + + for (PileBillingDetail detail : details) { + String applyTime = detail.getApplyTime(); + if (applyTime != null && !applyTime.isEmpty()) { + String[] periods = applyTime.split(","); + for (String period : periods) { + String[] times = period.split("-"); + if (times.length == 2) { + prices.add(JcppPricingModel.TimePeriodPrice.builder() + .startTime(times[0].trim()) + .endTime(times[1].trim()) + .electricityPrice(detail.getElectricityPrice()) + .servicePrice(detail.getServicePrice()) + .periodType(Integer.parseInt(detail.getTimeType())) + .build()); + } + } + } + } + + return prices; + } + + /** + * 从 VO 转换为峰谷计费明细 + */ + private static JcppPricingModel.PeakValleyPrice convertToPeakValleyFromVO(List details) { + JcppPricingModel.PeakValleyPrice peakValley = new JcppPricingModel.PeakValleyPrice(); + List configs = new ArrayList<>(); + + for (BillingDetailVO detail : details) { + String timeType = detail.getTimeType(); + BigDecimal electricityPrice = detail.getElectricityPrice(); + BigDecimal servicePrice = detail.getServicePrice(); + + switch (timeType) { + case "1": + peakValley.setSharpElectricityPrice(electricityPrice); + peakValley.setSharpServicePrice(servicePrice); + break; + case "2": + peakValley.setPeakElectricityPrice(electricityPrice); + peakValley.setPeakServicePrice(servicePrice); + break; + case "3": + peakValley.setFlatElectricityPrice(electricityPrice); + peakValley.setFlatServicePrice(servicePrice); + break; + case "4": + peakValley.setValleyElectricityPrice(electricityPrice); + peakValley.setValleyServicePrice(servicePrice); + break; + default: + log.warn("未知的时段类型: {}", timeType); + } + + String applyTime = detail.getApplyTime().get(0); + if (applyTime != null && !applyTime.isEmpty()) { + String[] periods = applyTime.split(","); + for (String period : periods) { + String[] times = period.split("-"); + if (times.length == 2) { + configs.add(JcppPricingModel.TimePeriodConfig.builder() + .startTime(times[0].trim()) + .endTime(times[1].trim()) + .periodType(Integer.parseInt(timeType)) + .build()); + } + } + } + } + + peakValley.setTimePeriodConfigs(configs); + return peakValley; + } + + /** + * 从 VO 转换为时段计费明细 + */ + private static List convertToTimePeriodsFromVO(List details) { + List prices = new ArrayList<>(); + + for (BillingDetailVO detail : details) { + String applyTime = detail.getApplyTime().get(0); + if (applyTime != null && !applyTime.isEmpty()) { + String[] periods = applyTime.split(","); + for (String period : periods) { + String[] times = period.split("-"); + if (times.length == 2) { + prices.add(JcppPricingModel.TimePeriodPrice.builder() + .startTime(times[0].trim()) + .endTime(times[1].trim()) + .electricityPrice(detail.getElectricityPrice()) + .servicePrice(detail.getServicePrice()) + .periodType(Integer.parseInt(detail.getTimeType())) + .build()); + } + } + } + } + + return prices; + } +} 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..f9b64c017 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/mapper/JcppSyncRecordMapper.java @@ -0,0 +1,71 @@ +package com.jsowell.pile.mapper; + +import com.jsowell.pile.domain.JcppSyncRecord; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * JCPP 充电桩同步记录 Mapper 接口 + * + * @author jsowell + */ +@Repository +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/java/com/jsowell/pile/service/BusinessFinancialService.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/BusinessFinancialService.java new file mode 100644 index 000000000..0657a3041 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/BusinessFinancialService.java @@ -0,0 +1,8 @@ +package com.jsowell.pile.service; + +import com.jsowell.pile.dto.MerchantOrderReportDTO; +import com.jsowell.pile.vo.web.MerchantOrderReportVO; + +public interface BusinessFinancialService { + MerchantOrderReportVO getMyWallet(MerchantOrderReportDTO dto); +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/BusinessFinancialServiceImpl.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/BusinessFinancialServiceImpl.java new file mode 100644 index 000000000..0f4e558c6 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/BusinessFinancialServiceImpl.java @@ -0,0 +1,69 @@ +package com.jsowell.pile.service.impl; + +import com.huifu.adapay.core.exception.BaseAdaPayException; +import com.jsowell.adapay.service.AdapayService; +import com.jsowell.adapay.vo.AdapayAccountBalanceVO; +import com.jsowell.pile.dto.MerchantOrderReportDTO; +import com.jsowell.pile.service.BusinessFinancialService; +import com.jsowell.pile.service.ClearingWithdrawInfoService; +import com.jsowell.pile.service.SettleOrderReportService; +import com.jsowell.pile.vo.web.MerchantOrderReportVO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; + +/** + * 运营端小程序财务相关Service + */ +@Slf4j +@Service +public class BusinessFinancialServiceImpl implements BusinessFinancialService { + + @Autowired + private SettleOrderReportService settleOrderReportService; + + @Autowired + private AdapayService adapayService; + + @Autowired + private ClearingWithdrawInfoService clearingWithdrawInfoService; + + /** + * 我的钱包查询 + * @param dto + * @return + */ + @Override + public MerchantOrderReportVO getMyWallet(MerchantOrderReportDTO dto) { + // 查询运营商订单报表 + MerchantOrderReportVO result = settleOrderReportService.getMerchantOrderReportV2(dto); + + // 查询账户余额 + BigDecimal acctBalance = BigDecimal.ZERO; + try { + AdapayAccountBalanceVO accountBalanceVO = adapayService.queryAdapayAccountBalance(dto.getMerchantId()); + if (accountBalanceVO != null && accountBalanceVO.getAcctBalance() != null) { + acctBalance = accountBalanceVO.getAcctBalance(); + } + } catch (BaseAdaPayException e) { + log.error("查询汇付账户余额异常 merchantId:{}", dto.getMerchantId(), e); + } + result.getMerchantOrderReport().setAcctBalance(acctBalance); + + // 查询累计提现金额 + BigDecimal totalWithdraw = BigDecimal.ZERO; + try { + BigDecimal withdraw = clearingWithdrawInfoService.queryTotalWithdraw(dto.getMerchantId()); + if (withdraw != null) { + totalWithdraw = withdraw; + } + } catch (Exception e) { + log.error("查询累计提现金额异常 merchantId:{}", dto.getMerchantId(), e); + } + result.getMerchantOrderReport().setTotalWithdraw(totalWithdraw); + + return result; + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java index a8ef3fc5f..9fbc3d629 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java @@ -816,6 +816,10 @@ public class OrderBasicInfoServiceImpl implements OrderBasicInfoService { } } + OrderBasicInfo orderInfoByOrderCode = orderBasicInfoMapper.getOrderInfoByOrderCode(orderBasicInfo.getOrderCode()); + + orderBasicInfo.setPayMode(orderInfoByOrderCode.getPayMode()); + // 判断订单的支付方式 String payMode = orderBasicInfo.getPayMode(); if (StringUtils.equals(payMode, OrderPayModeEnum.PAYMENT_OF_PRINCIPAL_BALANCE.getValue())) { diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/PileModelInfoServiceImpl.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/PileModelInfoServiceImpl.java index 6940245e0..f16a217ff 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/PileModelInfoServiceImpl.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/PileModelInfoServiceImpl.java @@ -30,14 +30,32 @@ public class PileModelInfoServiceImpl implements PileModelInfoService { private RedisCache redisCache; /** - * 查询充电桩型号信息 + * 查询充电桩型号信息(带缓存) * * @param id 充电桩型号信息主键 * @return 充电桩型号信息 */ @Override public PileModelInfo selectPileModelInfoById(Long id) { - return pileModelInfoMapper.selectPileModelInfoById(id); + if (id == null) { + return null; + } + + // 1. 尝试从缓存获取 + String redisKey = CacheConstants.PILE_MODEL_INFO_BY_ID + id; + PileModelInfo modelInfo = redisCache.getCacheObject(redisKey); + + // 2. 缓存未命中,从数据库查询 + if (modelInfo == null) { + modelInfo = pileModelInfoMapper.selectPileModelInfoById(id); + + // 3. 查询结果存入缓存(1天有效期) + if (modelInfo != null) { + redisCache.setCacheObject(redisKey, modelInfo, CacheConstants.cache_expire_time_1d); + } + } + + return modelInfo; } /** @@ -135,10 +153,18 @@ public class PileModelInfoServiceImpl implements PileModelInfoService { return modelInfoVO; } + /** + * 清除缓存 + * + * @param modelIds 型号ID数组 + */ private void cleanCache(Long[] modelIds) { List redisKeyList = Lists.newArrayList(); for (Long modelId : modelIds) { + // 清除 getPileModelInfoByModelId 的缓存 redisKeyList.add(CacheConstants.GET_PILE_MODEL_INFO_BY_MODEL_ID + modelId); + // 清除 selectPileModelInfoById 的缓存 + redisKeyList.add(CacheConstants.PILE_MODEL_INFO_BY_ID + modelId); } redisCache.deleteObject(redisKeyList); } diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/vo/web/OrderReportDetail.java b/jsowell-pile/src/main/java/com/jsowell/pile/vo/web/OrderReportDetail.java index 0c0c9e624..3d61fd276 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/vo/web/OrderReportDetail.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/vo/web/OrderReportDetail.java @@ -42,4 +42,14 @@ public class OrderReportDetail { // 他人分账金额 private BigDecimal otherSplitAmount; + /** + * 账户总余额 + */ + private BigDecimal acctBalance; + + /** + * 累计提现金额 + */ + private BigDecimal totalWithdraw; + } 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 充电桩同步记录表';