From 7d170dbe649522f84e092b795fbace2ef3e46670 Mon Sep 17 00:00:00 2001 From: Guoqs <123@jsowell.com> Date: Sat, 27 Dec 2025 15:05:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=B9=E6=8E=A5jcpp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jcpp/JcppBillingController.java | 129 ++++ .../jcpp/JcppMessageController.java | 61 ++ .../jcpp/JcppRemoteChargeController.java | 85 +++ .../jcpp/JcppMessageControllerTest.java | 257 +++++++ .../jcpp/PricingModelConverterTest.java | 144 ++++ .../jsowell/pile/jcpp/config/JcppConfig.java | 89 +++ .../pile/jcpp/config/JcppRabbitConfig.java | 176 +++++ .../pile/jcpp/constant/JcppConstants.java | 395 +++++++++++ .../jcpp/consumer/JcppGunStatusConsumer.java | 100 +++ .../jcpp/consumer/JcppHeartbeatConsumer.java | 59 ++ .../pile/jcpp/consumer/JcppLoginConsumer.java | 72 ++ .../jcpp/consumer/JcppPricingConsumer.java | 147 ++++ .../consumer/JcppRealTimeDataConsumer.java | 107 +++ .../consumer/JcppRemoteResultConsumer.java | 126 ++++ .../consumer/JcppSessionCloseConsumer.java | 76 +++ .../consumer/JcppStartChargeConsumer.java | 247 +++++++ .../consumer/JcppTransactionConsumer.java | 128 ++++ .../pile/jcpp/dto/JcppDownlinkCommand.java | 48 ++ .../pile/jcpp/dto/JcppDownlinkRequest.java | 53 ++ .../jsowell/pile/jcpp/dto/JcppLoginData.java | 63 ++ .../pile/jcpp/dto/JcppPricingModel.java | 180 +++++ .../pile/jcpp/dto/JcppRealTimeData.java | 82 +++ .../jcpp/dto/JcppRemoteStartResultData.java | 47 ++ .../pile/jcpp/dto/JcppSessionInfo.java | 77 +++ .../pile/jcpp/dto/JcppStartChargeData.java | 62 ++ .../pile/jcpp/dto/JcppTransactionData.java | 190 ++++++ .../pile/jcpp/dto/JcppUplinkMessage.java | 58 ++ .../pile/jcpp/dto/JcppUplinkResponse.java | 96 +++ .../jcpp/service/IJcppDownlinkService.java | 173 +++++ .../jcpp/service/IJcppMessageService.java | 100 +++ .../service/IJcppRemoteChargeService.java | 46 ++ .../service/impl/JcppDownlinkServiceImpl.java | 432 ++++++++++++ .../service/impl/JcppMessageServiceImpl.java | 627 ++++++++++++++++++ .../impl/JcppRemoteChargeServiceImpl.java | 179 +++++ .../pile/jcpp/util/PricingModelConverter.java | 274 ++++++++ 35 files changed, 5185 insertions(+) create mode 100644 jsowell-admin/src/main/java/com/jsowell/web/controller/jcpp/JcppBillingController.java create mode 100644 jsowell-admin/src/main/java/com/jsowell/web/controller/jcpp/JcppMessageController.java create mode 100644 jsowell-admin/src/main/java/com/jsowell/web/controller/jcpp/JcppRemoteChargeController.java create mode 100644 jsowell-admin/src/test/java/com/jsowell/jcpp/JcppMessageControllerTest.java create mode 100644 jsowell-admin/src/test/java/com/jsowell/jcpp/PricingModelConverterTest.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/config/JcppConfig.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/config/JcppRabbitConfig.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/constant/JcppConstants.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppGunStatusConsumer.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppHeartbeatConsumer.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppLoginConsumer.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppPricingConsumer.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppRealTimeDataConsumer.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppRemoteResultConsumer.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppSessionCloseConsumer.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppStartChargeConsumer.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppTransactionConsumer.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppDownlinkCommand.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppDownlinkRequest.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppLoginData.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppPricingModel.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppRealTimeData.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppRemoteStartResultData.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppSessionInfo.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppStartChargeData.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppTransactionData.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppUplinkMessage.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/dto/JcppUplinkResponse.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppDownlinkService.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppMessageService.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/IJcppRemoteChargeService.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppDownlinkServiceImpl.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppMessageServiceImpl.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/service/impl/JcppRemoteChargeServiceImpl.java create mode 100644 jsowell-pile/src/main/java/com/jsowell/pile/jcpp/util/PricingModelConverter.java 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/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/test/java/com/jsowell/jcpp/JcppMessageControllerTest.java b/jsowell-admin/src/test/java/com/jsowell/jcpp/JcppMessageControllerTest.java new file mode 100644 index 000000000..e80274776 --- /dev/null +++ b/jsowell-admin/src/test/java/com/jsowell/jcpp/JcppMessageControllerTest.java @@ -0,0 +1,257 @@ +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 org.springframework.test.web.servlet.MvcResult; + +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(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(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(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(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(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(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(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(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..7f56a2ae5 --- /dev/null +++ b/jsowell-admin/src/test/java/com/jsowell/jcpp/PricingModelConverterTest.java @@ -0,0 +1,144 @@ +package com.jsowell.jcpp; + +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.setId(1L); + template.setName("标准计费模板"); + + List details = new ArrayList<>(); + BillingDetailVO detail = new BillingDetailVO(); + detail.setTimeType("3"); // 平时 + detail.setElectricityPrice(new BigDecimal("0.8")); + detail.setServicePrice(new BigDecimal("0.4")); + detail.setApplyTime("00:00-24:00"); + 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.setId(2L); + template.setName("峰谷计费模板"); + + 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("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("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("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("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.setId(3L); + template.setName("无详情模板"); + template.setBillingDetailList(null); + + JcppPricingModel model = PricingModelConverter.convert(template); + + assertNotNull(model); + assertEquals(3L, model.getPricingId()); + assertEquals(1, model.getPricingType()); // 默认标准计费 + } +} 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..df777eccc --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/config/JcppConfig.java @@ -0,0 +1,89 @@ +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.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:8080"; + + /** + * 下行接口路径 + */ + private String downlinkPath = "/api/downlink"; + + /** + * 会话查询接口路径 + */ + private String sessionPath = "/api/session"; + + /** + * 请求超时时间(毫秒) + */ + private int timeout = 5000; + + /** + * 连接超时时间(毫秒) + */ + private int connectTimeout = 3000; + + /** + * 是否启用 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 + */ + @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; + } +} 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..6582b2e62 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppGunStatusConsumer.java @@ -0,0 +1,100 @@ +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(String message) { + try { + log.info("收到 JCPP 枪状态消息: {}", message); + + // 解析消息 + JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class); + if (uplinkMessage == null || uplinkMessage.getData() == null) { + log.warn("枪状态消息格式错误: {}", message); + return; + } + + // 从 data 中获取信息 + JSONObject data = JSON.parseObject(JSON.toJSONString(uplinkMessage.getData())); + String pileCode = data.getString("pileCode"); + String gunNo = data.getString("gunNo"); + String gunRunStatus = data.getString("gunRunStatus"); + JSONArray faultMessages = data.getJSONArray("faultMessages"); + + if (pileCode == null || gunNo == null) { + log.warn("枪状态消息缺少必要字段: {}", message); + 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 枪状态消息异常: message={}", message, 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..bf96cd53b --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppHeartbeatConsumer.java @@ -0,0 +1,59 @@ +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(String message) { + try { + // 解析消息 + JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class); + if (uplinkMessage == null || uplinkMessage.getData() == null) { + log.debug("心跳消息格式错误: {}", message); + return; + } + + // 从 data 中获取 pileCode + JSONObject data = JSON.parseObject(JSON.toJSONString(uplinkMessage.getData())); + String pileCode = data.getString("pileCode"); + if (pileCode == null || pileCode.isEmpty()) { + log.debug("心跳消息缺少 pileCode: {}", message); + 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 心跳消息异常: message={}", message, e); + } + } +} 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..ff1f9c67b --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppLoginConsumer.java @@ -0,0 +1,72 @@ +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(String message) { + try { + log.info("收到 JCPP 登录消息: {}", message); + + // 解析消息 + JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class); + if (uplinkMessage == null || uplinkMessage.getData() == null) { + log.warn("登录消息格式错误: {}", message); + return; + } + + // 从 data 中获取 pileCode + JSONObject data = JSON.parseObject(JSON.toJSONString(uplinkMessage.getData())); + String pileCode = data.getString("pileCode"); + if (pileCode == null || pileCode.isEmpty()) { + log.warn("登录消息缺少 pileCode: {}", message); + return; + } + + // 查询充电桩是否存在 + 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 登录消息异常: message={}", message, 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..d47aa7473 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppPricingConsumer.java @@ -0,0 +1,147 @@ +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(String message) { + try { + log.info("收到 JCPP 计费消息: {}", message); + + // 解析消息 + JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class); + if (uplinkMessage == null || uplinkMessage.getData() == null) { + log.warn("计费消息格式错误: {}", message); + return; + } + + String messageType = uplinkMessage.getMessageType(); + JSONObject data = JSON.parseObject(JSON.toJSONString(uplinkMessage.getData())); + String pileCode = data.getString("pileCode"); + + if (pileCode == null || pileCode.isEmpty()) { + log.warn("计费消息缺少 pileCode: {}", message); + return; + } + + // 根据消息类型处理 + 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 计费消息异常: message={}", message, 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..faa4278dd --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppRealTimeDataConsumer.java @@ -0,0 +1,107 @@ +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(String message) { + try { + // 解析消息 + JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class); + if (uplinkMessage == null || uplinkMessage.getData() == null) { + log.debug("实时数据消息格式错误: {}", message); + return; + } + + // 从 data 中获取信息 + JSONObject data = JSON.parseObject(JSON.toJSONString(uplinkMessage.getData())); + String pileCode = data.getString("pileCode"); + 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: {}", message); + 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 实时数据消息异常: message={}", message, 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..fbf9d6eca --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppRemoteResultConsumer.java @@ -0,0 +1,126 @@ +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(String message) { + try { + log.info("收到 JCPP 远程操作结果消息: {}", message); + + // 解析消息 + JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class); + if (uplinkMessage == null || uplinkMessage.getData() == null) { + log.warn("远程操作结果消息格式错误: {}", message); + return; + } + + String messageType = uplinkMessage.getMessageType(); + JSONObject data = JSON.parseObject(JSON.toJSONString(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 远程操作结果消息异常: message={}", message, e); + } + } + + /** + * 处理远程启动结果 + */ + private void handleRemoteStartResult(JSONObject data) { + String pileCode = data.getString("pileCode"); + String gunNo = data.getString("gunNo"); + String tradeNo = data.getString("tradeNo"); + Boolean success = data.getBoolean("success"); + String failReason = data.getString("failReason"); + + 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) { + String pileCode = data.getString("pileCode"); + String gunNo = data.getString("gunNo"); + Boolean success = data.getBoolean("success"); + String failReason = data.getString("failReason"); + + 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..f1e24281d --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppSessionCloseConsumer.java @@ -0,0 +1,76 @@ +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(String message) { + try { + log.info("收到 JCPP 会话关闭消息: {}", message); + + // 解析消息 + JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class); + if (uplinkMessage == null || uplinkMessage.getData() == null) { + log.warn("会话关闭消息格式错误: {}", message); + return; + } + + // 从 data 中获取信息 + JSONObject data = JSON.parseObject(JSON.toJSONString(uplinkMessage.getData())); + String pileCode = data.getString("pileCode"); + String reason = data.getString("reason"); + + if (pileCode == null || pileCode.isEmpty()) { + log.warn("会话关闭消息缺少 pileCode: {}", message); + return; + } + + 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 会话关闭消息异常: message={}", message, 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..54c93e516 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppStartChargeConsumer.java @@ -0,0 +1,247 @@ +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(String message) { + try { + log.info("收到 JCPP 启动充电消息: {}", message); + + // 解析消息 + JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class); + if (uplinkMessage == null || uplinkMessage.getData() == null) { + log.warn("启动充电消息格式错误: {}", message); + return; + } + + // 从 data 中获取信息 + JSONObject data = JSON.parseObject(JSON.toJSONString(uplinkMessage.getData())); + String pileCode = data.getString("pileCode"); + String gunNo = data.getString("gunNo"); + String startType = data.getString("startType"); + String cardNo = data.getString("cardNo"); + Boolean needPassword = data.getBoolean("needPassword"); + String password = data.getString("password"); + + if (pileCode == null || gunNo == null) { + log.warn("启动充电消息缺少必要字段: {}", message); + 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 启动充电消息异常: message={}", message, 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..63b5a9dd4 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/jcpp/consumer/JcppTransactionConsumer.java @@ -0,0 +1,128 @@ +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(String message) { + try { + log.info("收到 JCPP 交易记录消息: {}", message); + + // 解析消息 + JcppUplinkMessage uplinkMessage = JSON.parseObject(message, JcppUplinkMessage.class); + if (uplinkMessage == null || uplinkMessage.getData() == null) { + log.warn("交易记录消息格式错误: {}", message); + return; + } + + // 从 data 中获取信息 + JSONObject data = JSON.parseObject(JSON.toJSONString(uplinkMessage.getData())); + String pileCode = data.getString("pileCode"); + 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: {}", message); + 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 交易记录消息异常: message={}", message, 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..e133ec450 --- /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; + + /** + * 具体消息内容(根据 messageType 不同,结构不同) + */ + private Object 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/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/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/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/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/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/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/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; + } +}