From 4a5e1477afc559cf870b34e4693f41d85e9580d1 Mon Sep 17 00:00:00 2001
From: puzhibing <393733352@qq.com>
Date: 星期三, 09 九月 2026 15:39:35 +0800
Subject: [PATCH] 新增加VIN充电和刷卡充电业务逻辑
---
ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/iotda/enums/ServiceIdMenu.java | 1
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TChargingCardController.java | 32
ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TChargingCardMapper.xml | 41
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TChargingCardService.java | 30
sql/20260909_sd_vd_order_enum_comment.sql | 18
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinAccountingStrategyOrderMapper.java | 15
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/feignClient/ChargingOrderClient.java | 9
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/VinManagementFallbackFactory.java | 15
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TVinManagementServiceImpl.java | 7
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TVinAccountingStrategyOrderService.java | 13
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TVinChargingOrderAccountingStrategyService.java | 13
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java | 582 ++++++++++
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TVinChargingOrderService.java | 73 +
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/ChargingCardFallbackFactory.java | 46
sql/20260909_card_password_charge_lock.sql | 86 +
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TVinChargingOrderAccountingStrategy.java | 18
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/ChargingCardClient.java | 55
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/factory/ChargingOrderFallbackFactory.java | 6
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TChargingCard.java | 14
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TChargingCardServiceImplTest.java | 365 ++++++
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinAccountingStrategyOrderServiceImpl.java | 17
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TChargingCardServiceImpl.java | 187 +++
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TVinAccountingStrategyOrder.java | 18
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/ChargingCardAuthDTO.java | 25
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/dto/DeviceStartChargingDTO.java | 34
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderAccountingStrategyServiceImpl.java | 17
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/ChargingCardDeductDTO.java | 27
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TVinAccountingStrategyDetailOrder.java | 18
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/vo/DeviceStartChargingVO.java | 47
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinChargingOrderAccountingStrategyMapper.java | 15
ruoyi-service/ruoyi-integration/src/test/java/com/ruoyi/integration/iotda/enums/ServiceIdMenuTest.java | 38
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/ChargingCardSaveDTO.java | 3
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/vo/ChargingCardDeductVO.java | 33
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/util/TransactionStageParserTest.java | 88 +
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TVinManagementController.java | 8
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java | 128 +
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TChargingCardMapper.java | 23
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinAccountingStrategyDetailOrderServiceImpl.java | 30
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TVinAccountingStrategyDetailOrderService.java | 20
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TVinManagementService.java | 5
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java | 628 ++++++++++
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TCardChargingOrderService.java | 64 +
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/task/TaskUtil.java | 92 +
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/TransactionStageParser.java | 98 +
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/CardVinOrderCompensationTest.java | 213 +++
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinAccountingStrategyDetailOrderMapper.java | 15
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/VinManagementClient.java | 7
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/controller/TChargingCardControllerTest.java | 46
ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/iotda/utils/listener/IotMessageListener.java | 44
49 files changed, 3,403 insertions(+), 24 deletions(-)
diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/ChargingCardAuthDTO.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/ChargingCardAuthDTO.java
new file mode 100644
index 0000000..6bac6e7
--- /dev/null
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/ChargingCardAuthDTO.java
@@ -0,0 +1,25 @@
+package com.ruoyi.account.api.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+@ApiModel(value = "充电卡刷卡启动鉴权参数")
+public class ChargingCardAuthDTO implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty(value = "卡号", required = true)
+ private String cardNo;
+
+ /**
+ * 卡密码(设备上报明文)。
+ * 条件校验规则(V0.2 定稿):卡片管理侧配置了卡密码时必须校验且匹配;
+ * 未配置卡密码时不要求、也不校验(设备上报时忽略)。
+ */
+ @ApiModelProperty(value = "卡密码(明文,卡片配置了密码时必填并校验,未配置时忽略)")
+ private String password;
+}
diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/ChargingCardDeductDTO.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/ChargingCardDeductDTO.java
new file mode 100644
index 0000000..97a4e5e
--- /dev/null
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/ChargingCardDeductDTO.java
@@ -0,0 +1,27 @@
+package com.ruoyi.account.api.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+@Data
+@ApiModel(value = "刷卡充电订单结算扣款参数")
+public class ChargingCardDeductDTO implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty(value = "卡片ID", required = true)
+ private Long cardId;
+
+ @ApiModelProperty(value = "刷卡订单号(SD 前缀),作为扣款幂等键", required = true)
+ private String orderCode;
+
+ @ApiModelProperty(value = "扣款金额(结算应付金额),允许大于卡当前余额,扣后余额可为负数", required = true)
+ private BigDecimal amount;
+
+ @ApiModelProperty(value = "操作人ID(设备链路传 null,默认记 0)")
+ private Long operatorId;
+}
diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/ChargingCardSaveDTO.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/ChargingCardSaveDTO.java
index a3c8506..9d37341 100644
--- a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/ChargingCardSaveDTO.java
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/dto/ChargingCardSaveDTO.java
@@ -19,6 +19,9 @@
@ApiModelProperty(value = "卡号", required = true)
private String cardNo;
+ @ApiModelProperty(value = "卡密码(明文,可选)。新增时配置该卡刷卡启动密码;编辑时传入则重置密码,留空表示不修改(V0.2:未配置密码的卡刷卡启动时不校验密码)")
+ private String password;
+
@ApiModelProperty(value = "卡余额")
private BigDecimal balance;
diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/ChargingCardFallbackFactory.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/ChargingCardFallbackFactory.java
new file mode 100644
index 0000000..cc4e93e
--- /dev/null
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/ChargingCardFallbackFactory.java
@@ -0,0 +1,46 @@
+package com.ruoyi.account.api.factory;
+
+import com.ruoyi.account.api.dto.ChargingCardAuthDTO;
+import com.ruoyi.account.api.dto.ChargingCardDeductDTO;
+import com.ruoyi.account.api.feignClient.ChargingCardClient;
+import com.ruoyi.account.api.model.TChargingCard;
+import com.ruoyi.account.api.vo.ChargingCardDeductVO;
+import com.ruoyi.common.core.domain.R;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.cloud.openfeign.FallbackFactory;
+import org.springframework.stereotype.Component;
+
+/**
+ * 充电卡内部服务降级处理
+ */
+@Component
+public class ChargingCardFallbackFactory implements FallbackFactory<ChargingCardClient> {
+ private static final Logger log = LoggerFactory.getLogger(ChargingCardFallbackFactory.class);
+
+ @Override
+ public ChargingCardClient create(Throwable throwable) {
+ log.error("充电卡服务调用失败:{}", throwable.getMessage(), throwable);
+ return new ChargingCardClient() {
+ @Override
+ public R<TChargingCard> verifyCharging(ChargingCardAuthDTO dto) {
+ return R.fail("充电卡服务调用失败:" + throwable.getMessage());
+ }
+
+ @Override
+ public R<Boolean> lockForCharging(Long cardId, String orderCode) {
+ return R.fail("充电卡服务调用失败:" + throwable.getMessage());
+ }
+
+ @Override
+ public R<Boolean> unlockForCharging(Long cardId, String orderCode) {
+ return R.fail("充电卡服务调用失败:" + throwable.getMessage());
+ }
+
+ @Override
+ public R<ChargingCardDeductVO> deductForCharging(ChargingCardDeductDTO dto) {
+ return R.fail("充电卡服务调用失败:" + throwable.getMessage());
+ }
+ };
+ }
+}
diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/VinManagementFallbackFactory.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/VinManagementFallbackFactory.java
index 66a9fea..c1b3fcb 100644
--- a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/VinManagementFallbackFactory.java
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/VinManagementFallbackFactory.java
@@ -1,11 +1,14 @@
package com.ruoyi.account.api.factory;
import com.ruoyi.account.api.feignClient.VinManagementClient;
+import com.ruoyi.account.api.model.TVinManagement;
import com.ruoyi.common.core.domain.R;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.openfeign.FallbackFactory;
import org.springframework.stereotype.Component;
+
+import java.util.List;
/**
* VIN 管理服务降级处理
@@ -17,6 +20,16 @@
@Override
public VinManagementClient create(Throwable throwable) {
log.error("VIN 管理服务调用失败:{}", throwable.getMessage());
- return appUserId -> R.fail("VIN 管理服务调用失败:" + throwable.getMessage());
+ return new VinManagementClient() {
+ @Override
+ public R<List<Long>> getVinIdsByAppUserId(Long appUserId) {
+ return R.fail("VIN 管理服务调用失败:" + throwable.getMessage());
+ }
+
+ @Override
+ public R<TVinManagement> getVinByCode(String vin) {
+ return R.fail("根据VIN码查询记录失败:" + throwable.getMessage());
+ }
+ };
}
}
diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/ChargingCardClient.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/ChargingCardClient.java
new file mode 100644
index 0000000..3f65622
--- /dev/null
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/ChargingCardClient.java
@@ -0,0 +1,55 @@
+package com.ruoyi.account.api.feignClient;
+
+import com.ruoyi.account.api.dto.ChargingCardAuthDTO;
+import com.ruoyi.account.api.dto.ChargingCardDeductDTO;
+import com.ruoyi.account.api.factory.ChargingCardFallbackFactory;
+import com.ruoyi.account.api.model.TChargingCard;
+import com.ruoyi.account.api.vo.ChargingCardDeductVO;
+import com.ruoyi.common.core.constant.ServiceNameConstants;
+import com.ruoyi.common.core.domain.R;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
+
+/**
+ * 充电卡内部服务(刷卡启动鉴权、充电锁定、订单结算扣款)
+ * <p>仅限订单服务等内部链路调用,禁止走管理后台权限体系。</p>
+ */
+@FeignClient(contextId = "ChargingCardClient", value = ServiceNameConstants.ACCOUNT_SERVICE,
+ fallbackFactory = ChargingCardFallbackFactory.class)
+public interface ChargingCardClient {
+
+ /**
+ * 刷卡启动鉴权(V0.2 定稿:卡片配置了密码才校验密码,未配置不校验)
+ *
+ * @param dto 卡号 + 设备上报密码
+ * @return 鉴权失败时 R 的 code != 200 且 msg 为失败原因;成功时 data 为卡片信息(不含密码密文)
+ */
+ @PostMapping("/t-charging-card/inner/verify-charging")
+ R<TChargingCard> verifyCharging(@RequestBody ChargingCardAuthDTO dto);
+
+ /**
+ * 锁定卡片为充电中(订单创建成功后调用)。
+ *
+ * @return data=false 表示卡片已被其他进行中订单占用(锁定前置条件未命中),调用方应放弃建单
+ */
+ @PostMapping("/t-charging-card/inner/lock-charging")
+ R<Boolean> lockForCharging(@RequestParam("cardId") Long cardId,
+ @RequestParam("orderCode") String orderCode);
+
+ /**
+ * 解锁卡片(订单到达终态后调用)。带订单号比对,防止误释放其他订单。
+ */
+ @PostMapping("/t-charging-card/inner/unlock-charging")
+ R<Boolean> unlockForCharging(@RequestParam("cardId") Long cardId,
+ @RequestParam("orderCode") String orderCode);
+
+ /**
+ * 刷卡订单最终结算扣款(幂等,允许扣成负余额)。
+ *
+ * @return data.duplicated=true 表示该订单此前已扣款成功,本次未重复扣款
+ */
+ @PostMapping("/t-charging-card/inner/deduct-charging")
+ R<ChargingCardDeductVO> deductForCharging(@RequestBody ChargingCardDeductDTO dto);
+}
diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/VinManagementClient.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/VinManagementClient.java
index 2773fb1..0f904c5 100644
--- a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/VinManagementClient.java
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/VinManagementClient.java
@@ -1,6 +1,7 @@
package com.ruoyi.account.api.feignClient;
import com.ruoyi.account.api.factory.VinManagementFallbackFactory;
+import com.ruoyi.account.api.model.TVinManagement;
import com.ruoyi.common.core.constant.ServiceNameConstants;
import com.ruoyi.common.core.domain.R;
import org.springframework.cloud.openfeign.FeignClient;
@@ -18,4 +19,10 @@
@GetMapping("/t-vin-management/inner/ids-by-app-user")
R<List<Long>> getVinIdsByAppUserId(@RequestParam("appUserId") Long appUserId);
+
+ /**
+ * 根据VIN码查询未删除的VIN记录(订单侧VIN鉴权使用)
+ */
+ @GetMapping("/t-vin-management/inner/get-by-vin")
+ R<TVinManagement> getVinByCode(@RequestParam("vin") String vin);
}
diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TChargingCard.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TChargingCard.java
index 70a9392..da8b468 100644
--- a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TChargingCard.java
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/model/TChargingCard.java
@@ -5,6 +5,7 @@
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import com.ruoyi.common.core.annotation.Excel;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
@@ -29,6 +30,11 @@
@ApiModelProperty(value = "卡号")
private String cardNo;
+ /** 卡密码(BCrypt 密文);为空表示未配置密码,刷卡启动时不校验密码(V0.2 定稿) */
+ @JsonIgnore
+ @ApiModelProperty(value = "卡密码(BCrypt 密文,仅内部存储与校验,不对外返回;空表示未配置,刷卡时不校验密码)")
+ private String password;
+
@Excel(name = "卡余额", width = 16, scale = 2)
@ApiModelProperty(value = "卡余额")
private BigDecimal balance;
@@ -37,6 +43,14 @@
@ApiModelProperty(value = "状态:1启用,2禁用")
private Integer status;
+ /** 充电锁定标记:0 未在充电,1 充电中(进行中刷卡订单占用,管理操作互斥,V0.2 定稿) */
+ @ApiModelProperty(value = "充电锁定:0未充电,1充电中锁定")
+ private Integer chargingFlag;
+
+ /** 充电锁定订单号:解锁时必须与订单号比对,防止误释放其他订单 */
+ @ApiModelProperty(value = "充电锁定订单号")
+ private String chargingOrderCode;
+
@TableLogic(value = "0", delval = "2")
@ApiModelProperty(value = "删除状态:0正常,2删除")
private Integer delFlag;
diff --git a/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/vo/ChargingCardDeductVO.java b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/vo/ChargingCardDeductVO.java
new file mode 100644
index 0000000..1848de9
--- /dev/null
+++ b/ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/vo/ChargingCardDeductVO.java
@@ -0,0 +1,33 @@
+package com.ruoyi.account.api.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+@Data
+@ApiModel(value = "刷卡充电订单结算扣款结果")
+public class ChargingCardDeductVO implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty(value = "卡片ID")
+ private Long cardId;
+
+ @ApiModelProperty(value = "订单号(扣款幂等键)")
+ private String orderCode;
+
+ @ApiModelProperty(value = "扣款金额")
+ private BigDecimal amount;
+
+ @ApiModelProperty(value = "扣款前余额")
+ private BigDecimal beforeBalance;
+
+ @ApiModelProperty(value = "扣款后余额(允许为负数)")
+ private BigDecimal afterBalance;
+
+ @ApiModelProperty(value = "是否幂等重复请求(true=该订单此前已扣款成功,本次未重复扣款)")
+ private boolean duplicated;
+}
diff --git a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/dto/DeviceStartChargingDTO.java b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/dto/DeviceStartChargingDTO.java
new file mode 100644
index 0000000..c1fd404
--- /dev/null
+++ b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/dto/DeviceStartChargingDTO.java
@@ -0,0 +1,34 @@
+package com.ruoyi.order.api.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 设备主动发起充电(刷卡/VIN码)鉴权建单请求
+ *
+ * @author codebuddy
+ * @since 2026-09-09
+ */
+@Data
+@ApiModel(value = "DeviceStartChargingDTO", description = "设备主动发起充电鉴权建单请求")
+public class DeviceStartChargingDTO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty("充电桩编码")
+ private String chargingPileCode;
+
+ @ApiModelProperty("充电枪编号")
+ private String chargingGunCode;
+
+ @ApiModelProperty("启动方式(1=刷卡启动,2=账号启动(暂不支持),3=VIN码启动)")
+ private Integer startMethod;
+
+ @ApiModelProperty("账户(刷卡=卡号;VIN码=反序VIN原文)")
+ private String account;
+
+ @ApiModelProperty("卡密码(桩上送,可为空)")
+ private String password;
+}
diff --git a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/factory/ChargingOrderFallbackFactory.java b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/factory/ChargingOrderFallbackFactory.java
index 8773e9f..7a30556 100644
--- a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/factory/ChargingOrderFallbackFactory.java
+++ b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/factory/ChargingOrderFallbackFactory.java
@@ -4,6 +4,7 @@
import com.ruoyi.common.core.dto.ChargingOrderGroup;
import com.ruoyi.common.core.dto.ChargingPercentProvinceDto;
import com.ruoyi.order.api.dto.ChargingStatisticeDTO;
+import com.ruoyi.order.api.dto.DeviceStartChargingDTO;
import com.ruoyi.order.api.feignClient.ChargingOrderClient;
import com.ruoyi.order.api.model.AddTripartitePlatformOrder;
import com.ruoyi.order.api.model.ChargingListQuery;
@@ -253,6 +254,11 @@
public R<Long> addChargingOrder(TChargingOrder chargingOrder) {
return R.fail("添加订单失败:" + throwable.getMessage());
}
+
+ @Override
+ public R<DeviceStartChargingVO> deviceStartCharging(DeviceStartChargingDTO dto) {
+ return R.fail("设备主动发起充电鉴权建单失败:" + throwable.getMessage());
+ }
};
}
}
diff --git a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/feignClient/ChargingOrderClient.java b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/feignClient/ChargingOrderClient.java
index c710110..1f09cd7 100644
--- a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/feignClient/ChargingOrderClient.java
+++ b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/feignClient/ChargingOrderClient.java
@@ -6,6 +6,7 @@
import com.ruoyi.common.core.dto.ChargingPercentProvinceDto;
import com.ruoyi.common.core.web.domain.AjaxResult;
import com.ruoyi.order.api.dto.ChargingStatisticeDTO;
+import com.ruoyi.order.api.dto.DeviceStartChargingDTO;
import com.ruoyi.order.api.factory.ChargingOrderFallbackFactory;
import com.ruoyi.order.api.model.AddTripartitePlatformOrder;
import com.ruoyi.order.api.model.ChargingListQuery;
@@ -364,4 +365,12 @@
*/
@PostMapping("/t-charging-order/addChargingOrder")
R<Long> addChargingOrder(@RequestBody TChargingOrder chargingOrder);
+
+ /**
+ * 设备主动发起充电(刷卡/VIN码)鉴权建单
+ * @param dto 启动鉴权请求(桩编码、枪编号、启动方式、账户、卡密码)
+ * @return 鉴权结果(含订单编号与失败原因)
+ */
+ @PostMapping("/t-charging-order/inner/device-start-charging")
+ R<DeviceStartChargingVO> deviceStartCharging(@RequestBody DeviceStartChargingDTO dto);
}
diff --git a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TVinAccountingStrategyDetailOrder.java b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TVinAccountingStrategyDetailOrder.java
new file mode 100644
index 0000000..29af5ce
--- /dev/null
+++ b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TVinAccountingStrategyDetailOrder.java
@@ -0,0 +1,18 @@
+package com.ruoyi.order.api.model;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * VIN码充电订单计费策略明细快照表(对应表 t_vin_accounting_strategy_detail_order)
+ *
+ * @author codebuddy
+ * @since 2026-09-09
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("t_vin_accounting_strategy_detail_order")
+public class TVinAccountingStrategyDetailOrder extends AccountingStrategyDetailOrder {
+ private static final long serialVersionUID = 1L;
+}
diff --git a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TVinAccountingStrategyOrder.java b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TVinAccountingStrategyOrder.java
new file mode 100644
index 0000000..8a9faa8
--- /dev/null
+++ b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TVinAccountingStrategyOrder.java
@@ -0,0 +1,18 @@
+package com.ruoyi.order.api.model;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * VIN码充电订单计费策略快照主表(对应表 t_vin_accounting_strategy_order)
+ *
+ * @author codebuddy
+ * @since 2026-09-09
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("t_vin_accounting_strategy_order")
+public class TVinAccountingStrategyOrder extends AccountingStrategyOrder {
+ private static final long serialVersionUID = 1L;
+}
diff --git a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TVinChargingOrderAccountingStrategy.java b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TVinChargingOrderAccountingStrategy.java
new file mode 100644
index 0000000..bd491e3
--- /dev/null
+++ b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TVinChargingOrderAccountingStrategy.java
@@ -0,0 +1,18 @@
+package com.ruoyi.order.api.model;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * VIN码充电订单计费明细表(对应表 t_vin_charging_order_accounting_strategy)
+ *
+ * @author codebuddy
+ * @since 2026-09-09
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("t_vin_charging_order_accounting_strategy")
+public class TVinChargingOrderAccountingStrategy extends TChargingOrderAccountingStrategy {
+ private static final long serialVersionUID = 1L;
+}
diff --git a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/vo/DeviceStartChargingVO.java b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/vo/DeviceStartChargingVO.java
new file mode 100644
index 0000000..a632156
--- /dev/null
+++ b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/vo/DeviceStartChargingVO.java
@@ -0,0 +1,47 @@
+package com.ruoyi.order.api.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 设备主动发起充电鉴权建单结果
+ *
+ * @author codebuddy
+ * @since 2026-09-09
+ */
+@Data
+@ApiModel(value = "DeviceStartChargingVO", description = "设备主动发起充电鉴权建单结果")
+public class DeviceStartChargingVO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty("鉴权结果(1=成功,0=失败)")
+ private Integer authentication;
+
+ @ApiModelProperty("失败原因(1=账户不存在,2=账户冻结,3=账户余额不足,4=该卡存在未结账记录,5=桩停用,7=密码错误,9=系统中vin码不存在,10=该桩存在未结账记录)")
+ private Integer failureCause;
+
+ @ApiModelProperty("订单编号(鉴权成功时返回,SD/VD前缀)")
+ private String orderCode;
+
+ @ApiModelProperty("账户余额(刷卡时返回,VIN码充电为空)")
+ private BigDecimal balance;
+
+ public static DeviceStartChargingVO success(String orderCode, BigDecimal balance) {
+ DeviceStartChargingVO vo = new DeviceStartChargingVO();
+ vo.setAuthentication(1);
+ vo.setOrderCode(orderCode);
+ vo.setBalance(balance);
+ return vo;
+ }
+
+ public static DeviceStartChargingVO fail(Integer failureCause) {
+ DeviceStartChargingVO vo = new DeviceStartChargingVO();
+ vo.setAuthentication(0);
+ vo.setFailureCause(failureCause);
+ return vo;
+ }
+}
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TChargingCardController.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TChargingCardController.java
index bd4e020..1b741cb 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TChargingCardController.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TChargingCardController.java
@@ -1,12 +1,15 @@
package com.ruoyi.account.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.ruoyi.account.api.dto.ChargingCardAuthDTO;
import com.ruoyi.account.api.dto.ChargingCardBalanceDTO;
+import com.ruoyi.account.api.dto.ChargingCardDeductDTO;
import com.ruoyi.account.api.dto.ChargingCardExcelDTO;
import com.ruoyi.account.api.dto.ChargingCardQueryDTO;
import com.ruoyi.account.api.dto.ChargingCardSaveDTO;
import com.ruoyi.account.api.dto.ChargingCardStatusDTO;
import com.ruoyi.account.api.model.TChargingCard;
+import com.ruoyi.account.api.vo.ChargingCardDeductVO;
import com.ruoyi.account.service.TChargingCardService;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.utils.poi.ExcelUtil;
@@ -22,6 +25,7 @@
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -103,4 +107,32 @@
List<ChargingCardExcelDTO> cards = util.importExcel(file.getInputStream());
return R.ok(chargingCardService.importCards(cards));
}
+
+ // ==================== 内部接口(刷卡充电链路,订单服务调用,V0.2 定稿) ====================
+
+ @ApiOperation(value = "刷卡启动鉴权(内部):卡片配置密码时才校验密码")
+ @PostMapping("/inner/verify-charging")
+ public R<TChargingCard> verifyCharging(@RequestBody ChargingCardAuthDTO dto) {
+ return R.ok(chargingCardService.verifyCardForCharging(dto));
+ }
+
+ @ApiOperation(value = "锁定卡片为充电中(内部):返回 false 表示同卡已有进行中订单")
+ @PostMapping("/inner/lock-charging")
+ public R<Boolean> lockForCharging(@RequestParam("cardId") Long cardId,
+ @RequestParam("orderCode") String orderCode) {
+ return R.ok(chargingCardService.lockCardForCharging(cardId, orderCode));
+ }
+
+ @ApiOperation(value = "解锁卡片(内部):订单终态后调用,带订单号比对")
+ @PostMapping("/inner/unlock-charging")
+ public R<Boolean> unlockForCharging(@RequestParam("cardId") Long cardId,
+ @RequestParam("orderCode") String orderCode) {
+ return R.ok(chargingCardService.unlockCardForCharging(cardId, orderCode));
+ }
+
+ @ApiOperation(value = "刷卡订单结算扣款(内部):订单号幂等、允许扣成负余额")
+ @PostMapping("/inner/deduct-charging")
+ public R<ChargingCardDeductVO> deductForCharging(@RequestBody ChargingCardDeductDTO dto) {
+ return R.ok(chargingCardService.deductBalanceForCharging(dto));
+ }
}
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TVinManagementController.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TVinManagementController.java
index f62aa5e..0f68e4f 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TVinManagementController.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TVinManagementController.java
@@ -57,6 +57,14 @@
return R.ok(service.unbindAppletVehicle(id, tokenService.getLoginUserApplet().getUserId()));
}
+ /**
+ * 内部接口:根据VIN码查询未删除的VIN记录(订单侧VIN鉴权使用)
+ */
+ @GetMapping("/inner/get-by-vin")
+ public R<TVinManagement> getByVin(@RequestParam("vin") String vin) {
+ return R.ok(service.getByVin(vin));
+ }
+
@GetMapping("/inner/ids-by-app-user")
public R<List<Long>> getVinIdsByAppUserId(@RequestParam Long appUserId) {
return R.ok(service.getVinIdsByAppUserId(appUserId));
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TChargingCardMapper.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TChargingCardMapper.java
index 3a98b6f..467258e 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TChargingCardMapper.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TChargingCardMapper.java
@@ -31,4 +31,27 @@
@Param("amount") BigDecimal amount,
@Param("remark") String remark,
@Param("createBy") Long createBy);
+
+ /**
+ * 锁定卡片为充电中(条件更新:仅空闲卡可锁定,用于防止同卡并发多单)
+ */
+ int lockCardForCharging(@Param("cardId") Long cardId, @Param("orderCode") String orderCode);
+
+ /**
+ * 解锁卡片(带订单号比对,防止误释放其他订单)
+ */
+ int unlockCardForCharging(@Param("cardId") Long cardId, @Param("orderCode") String orderCode);
+
+ /**
+ * 订单结算原子扣款:仅按卡 ID 扣减,不限制余额下限,允许扣成负数(V0.2 定稿)
+ */
+ int deductBalanceByCardId(@Param("cardId") Long cardId, @Param("amount") BigDecimal amount);
+
+ /**
+ * 写入订单结算扣款流水并占用幂等键(charging_order_code 唯一索引)
+ */
+ int insertOrderDeductRecord(@Param("cardId") Long cardId,
+ @Param("amount") BigDecimal amount,
+ @Param("orderCode") String orderCode,
+ @Param("createBy") Long createBy);
}
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TChargingCardService.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TChargingCardService.java
index 1e89501..bbdfb8f 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TChargingCardService.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TChargingCardService.java
@@ -2,12 +2,15 @@
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.account.api.dto.ChargingCardAuthDTO;
import com.ruoyi.account.api.dto.ChargingCardBalanceDTO;
+import com.ruoyi.account.api.dto.ChargingCardDeductDTO;
import com.ruoyi.account.api.dto.ChargingCardExcelDTO;
import com.ruoyi.account.api.dto.ChargingCardQueryDTO;
import com.ruoyi.account.api.dto.ChargingCardSaveDTO;
import com.ruoyi.account.api.dto.ChargingCardStatusDTO;
import com.ruoyi.account.api.model.TChargingCard;
+import com.ruoyi.account.api.vo.ChargingCardDeductVO;
import java.util.List;
@@ -28,4 +31,31 @@
List<ChargingCardExcelDTO> selectExportList(ChargingCardQueryDTO query);
String importCards(List<ChargingCardExcelDTO> cards);
+
+ /**
+ * 刷卡启动鉴权(内部接口)。
+ * 条件密码校验(V0.2 定稿):卡片配置了密码时校验设备上报密码;未配置密码时不校验。
+ *
+ * @param dto 卡号与设备上报密码
+ * @return 鉴权通过返回卡片实体(不包含密码密文);失败抛出 ServiceException 携带原因
+ */
+ TChargingCard verifyCardForCharging(ChargingCardAuthDTO dto);
+
+ /**
+ * 锁定卡片为充电中(刷卡订单创建成功后调用)。同卡已有进行中订单时返回 false。
+ */
+ boolean lockCardForCharging(Long cardId, String orderCode);
+
+ /**
+ * 解锁卡片(订单终态后调用)。带订单号比对,幂等:已解锁或订单号不匹配时返回 false。
+ */
+ boolean unlockCardForCharging(Long cardId, String orderCode);
+
+ /**
+ * 刷卡订单最终结算扣款(内部接口)。
+ * 幂等(订单号为幂等键)+ 允许扣成负余额(V0.2 定稿)。
+ *
+ * @return 扣款结果;duplicated=true 表示该订单此前已扣款,本次未重复扣款
+ */
+ ChargingCardDeductVO deductBalanceForCharging(ChargingCardDeductDTO dto);
}
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TVinManagementService.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TVinManagementService.java
index 3ebd03f..d8d2e6d 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TVinManagementService.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TVinManagementService.java
@@ -20,4 +20,9 @@
boolean bindAppletVehicle(Long appUserId, String keyword);
boolean unbindAppletVehicle(Long id, Long appUserId);
List<Long> getVinIdsByAppUserId(Long appUserId);
+
+ /**
+ * 根据VIN码查询未删除的VIN记录(含禁用状态,由调用方判断)
+ */
+ TVinManagement getByVin(String vin);
}
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TChargingCardServiceImpl.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TChargingCardServiceImpl.java
index d0829ca..c5176b1 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TChargingCardServiceImpl.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TChargingCardServiceImpl.java
@@ -1,14 +1,18 @@
package com.ruoyi.account.service.impl;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.account.api.dto.ChargingCardAuthDTO;
import com.ruoyi.account.api.dto.ChargingCardBalanceDTO;
+import com.ruoyi.account.api.dto.ChargingCardDeductDTO;
import com.ruoyi.account.api.dto.ChargingCardExcelDTO;
import com.ruoyi.account.api.dto.ChargingCardQueryDTO;
import com.ruoyi.account.api.dto.ChargingCardSaveDTO;
import com.ruoyi.account.api.dto.ChargingCardStatusDTO;
import com.ruoyi.account.api.model.TChargingCard;
+import com.ruoyi.account.api.vo.ChargingCardDeductVO;
import com.ruoyi.account.mapper.TChargingCardMapper;
import com.ruoyi.account.service.TChargingCardService;
import com.ruoyi.common.core.domain.R;
@@ -19,9 +23,13 @@
import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.feignClient.SysUserClient;
import com.ruoyi.system.api.model.LoginUser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@@ -33,17 +41,29 @@
public class TChargingCardServiceImpl extends ServiceImpl<TChargingCardMapper, TChargingCard>
implements TChargingCardService {
+ private static final Logger log = LoggerFactory.getLogger(TChargingCardServiceImpl.class);
+
private static final int ENABLED = 1;
private static final int DISABLED = 2;
private static final int BALANCE_ADD = 1;
private static final int BALANCE_SUBTRACT = 2;
+ /** 余额变动类型:3=刷卡订单充电结算扣款(V0.2 定稿,订单扣款专用) */
+ private static final int BALANCE_TYPE_ORDER_DEDUCT = 3;
+ /** 充电锁定标记:1=充电中(业务锁定,管理操作互斥) */
+ private static final int CHARGING_FLAG_LOCKED = 1;
+ /** 设备链路无登录操作人,流水统一记 0 */
+ private static final long SYSTEM_OPERATOR = 0L;
private static final BigDecimal MAX_AMOUNT = new BigDecimal("9999999999.99");
+ private static final int PASSWORD_MAX_LENGTH = 64;
@Autowired
private TokenService tokenService;
@Autowired
private SysUserClient sysUserClient;
+
+ @Autowired
+ private TransactionTemplate transactionTemplate;
@Override
public IPage<TChargingCard> selectCardPage(ChargingCardQueryDTO query) {
@@ -61,6 +81,9 @@
card.setCardNo(dto.getCardNo().trim());
card.setBalance(dto.getBalance() == null ? BigDecimal.ZERO : dto.getBalance());
card.setStatus(dto.getStatus() == null ? ENABLED : dto.getStatus());
+ card.setPassword(encodePasswordIfPresent(dto.getPassword()));
+ card.setChargingFlag(0);
+ card.setChargingOrderCode(null);
card.setDelFlag(0);
card.setCreateTime(LocalDateTime.now());
card.setCreateBy(SecurityUtils.getUserId());
@@ -73,13 +96,20 @@
@Override
public boolean updateCard(ChargingCardSaveDTO dto) {
validateCard(dto, true);
- if (getById(dto.getId()) == null) {
+ TChargingCard existing = getById(dto.getId());
+ if (existing == null) {
throw new ServiceException("卡片不存在或已删除");
}
+ assertNotChargingLocked(existing, "修改卡片或重置卡密码");
TChargingCard card = new TChargingCard();
card.setId(dto.getId());
card.setCardNo(dto.getCardNo().trim());
card.setStatus(dto.getStatus() == null ? ENABLED : dto.getStatus());
+ // 仅当传入新密码时才重置卡密码;留空表示不修改(V0.2 定稿)
+ String encodedPassword = encodePasswordIfPresent(dto.getPassword());
+ if (encodedPassword != null) {
+ card.setPassword(encodedPassword);
+ }
card.setUpdateTime(LocalDateTime.now());
card.setUpdateBy(SecurityUtils.getUserId());
if (!updateById(card)) {
@@ -94,9 +124,11 @@
throw new ServiceException("请选择需要删除的卡片");
}
List<Long> distinctIds = new java.util.ArrayList<>(new HashSet<>(ids));
- if (listByIds(distinctIds).size() != distinctIds.size()) {
+ List<TChargingCard> cards = listByIds(distinctIds);
+ if (cards.size() != distinctIds.size()) {
throw new ServiceException("部分卡片不存在或已删除");
}
+ assertNotChargingLocked(cards, "删除卡片");
if (!removeByIds(distinctIds)) {
throw new ServiceException("删除卡片失败");
}
@@ -108,9 +140,11 @@
if (dto == null || dto.getId() == null || !isValidStatus(dto.getStatus())) {
throw new ServiceException("卡片状态参数错误");
}
- if (getById(dto.getId()) == null) {
+ TChargingCard existing = getById(dto.getId());
+ if (existing == null) {
throw new ServiceException("卡片不存在或已删除");
}
+ assertNotChargingLocked(existing, "启用/禁用卡片");
TChargingCard card = new TChargingCard();
card.setId(dto.getId());
card.setStatus(dto.getStatus());
@@ -132,12 +166,13 @@
if (cards.size() != new HashSet<>(dto.getIds()).size()) {
throw new ServiceException("部分卡片不存在或已删除");
}
+ assertNotChargingLocked(cards, "调整余额");
int updated = baseMapper.adjustBalance(dto.getIds(), dto.getAmount(), dto.getType(),
MAX_AMOUNT, SecurityUtils.getUserId());
if (updated != cards.size()) {
throw new ServiceException(dto.getType() == BALANCE_SUBTRACT
- ? "部分卡片余额不足,无法完成扣减"
- : "部分卡片余额超过上限,无法完成增加");
+ ? "部分卡片正在充电中或余额不足,无法完成扣减"
+ : "部分卡片正在充电中或余额超过上限,无法完成增加");
}
for (TChargingCard card : cards) {
baseMapper.insertBalanceRecord(card.getId(), dto.getType(), dto.getAmount(),
@@ -187,12 +222,104 @@
card.setCardNo(item.getCardNo().trim());
card.setBalance(item.getBalance() == null ? BigDecimal.ZERO : item.getBalance());
card.setStatus(parseExcelStatus(item.getStatus(), 0));
+ card.setChargingFlag(0);
card.setDelFlag(0);
card.setCreateTime(now);
card.setCreateBy(userId);
save(card);
}
return "成功导入" + cards.size() + "条卡片数据";
+ }
+
+ @Override
+ public TChargingCard verifyCardForCharging(ChargingCardAuthDTO dto) {
+ if (dto == null || StringUtils.isEmpty(dto.getCardNo())) {
+ throw new ServiceException("卡号不能为空");
+ }
+ TChargingCard card = getOne(new LambdaQueryWrapper<TChargingCard>()
+ .eq(TChargingCard::getCardNo, dto.getCardNo().trim()));
+ if (card == null) {
+ throw new ServiceException("卡片不存在或已删除");
+ }
+ if (card.getStatus() == null || card.getStatus() != ENABLED) {
+ throw new ServiceException("卡片已禁用,无法启动充电");
+ }
+ // 条件密码校验(V0.2 定稿):卡片配置了密码才校验;未配置密码不要求、不校验
+ if (StringUtils.isNotEmpty(card.getPassword())) {
+ if (StringUtils.isEmpty(dto.getPassword())) {
+ throw new ServiceException("卡片已配置密码,请输入卡密码");
+ }
+ if (!SecurityUtils.matchesPassword(dto.getPassword(), card.getPassword())) {
+ throw new ServiceException("卡密码错误");
+ }
+ }
+ return card;
+ }
+
+ @Override
+ public boolean lockCardForCharging(Long cardId, String orderCode) {
+ if (cardId == null || StringUtils.isEmpty(orderCode)) {
+ throw new ServiceException("卡片ID与订单号不能为空");
+ }
+ return baseMapper.lockCardForCharging(cardId, orderCode.trim()) == 1;
+ }
+
+ @Override
+ public boolean unlockCardForCharging(Long cardId, String orderCode) {
+ if (cardId == null || StringUtils.isEmpty(orderCode)) {
+ throw new ServiceException("卡片ID与订单号不能为空");
+ }
+ if (baseMapper.unlockCardForCharging(cardId, orderCode.trim()) == 1) {
+ return true;
+ }
+ TChargingCard card = getById(cardId);
+ if (card == null) {
+ throw new ServiceException("卡片不存在或已删除");
+ }
+ // 已解锁(订单号已被清空)视为幂等成功;仍被其他订单占用则返回 false,防止误释放
+ return card.getChargingOrderCode() == null || orderCode.trim().equals(card.getChargingOrderCode());
+ }
+
+ @Override
+ public ChargingCardDeductVO deductBalanceForCharging(ChargingCardDeductDTO dto) {
+ validateDeductRequest(dto);
+ try {
+ return transactionTemplate.execute(status -> {
+ TChargingCard card = getById(dto.getCardId());
+ if (card == null) {
+ throw new ServiceException("卡片不存在或已删除");
+ }
+ BigDecimal before = card.getBalance() == null ? BigDecimal.ZERO : card.getBalance();
+ // 第一步先占扣款幂等键:同一订单并发结算时,唯一索引 uk_order_code 只允许一个事务插入成功
+ baseMapper.insertOrderDeductRecord(dto.getCardId(), dto.getAmount(),
+ dto.getOrderCode(), dto.getOperatorId() == null ? SYSTEM_OPERATOR : dto.getOperatorId());
+ // 第二步原子扣减:允许扣成负余额,禁止携带 balance >= amount 下限条件(V0.2 定稿)
+ int updated = baseMapper.deductBalanceByCardId(dto.getCardId(), dto.getAmount());
+ if (updated != 1) {
+ throw new ServiceException("卡片不存在或已删除,扣款失败");
+ }
+ TChargingCard afterCard = getById(dto.getCardId());
+ BigDecimal after = afterCard == null || afterCard.getBalance() == null
+ ? BigDecimal.ZERO : afterCard.getBalance();
+ ChargingCardDeductVO vo = new ChargingCardDeductVO();
+ vo.setCardId(dto.getCardId());
+ vo.setOrderCode(dto.getOrderCode());
+ vo.setAmount(dto.getAmount());
+ vo.setBeforeBalance(before);
+ vo.setAfterBalance(after);
+ vo.setDuplicated(false);
+ return vo;
+ });
+ } catch (DuplicateKeyException e) {
+ // 唯一键冲突:该订单此前已扣款成功(含并发下慢事务先提交),按幂等重复返回,不重复扣款
+ log.warn("刷卡订单结算扣款幂等命中,不重复扣款。订单号={}, 卡片ID={}", dto.getOrderCode(), dto.getCardId());
+ ChargingCardDeductVO vo = new ChargingCardDeductVO();
+ vo.setCardId(dto.getCardId());
+ vo.setOrderCode(dto.getOrderCode());
+ vo.setAmount(dto.getAmount());
+ vo.setDuplicated(true);
+ return vo;
+ }
}
private void validateCard(ChargingCardSaveDTO dto, boolean update) {
@@ -206,6 +333,11 @@
if (cardNo.length() > 64) {
throw new ServiceException("卡号不能超过64个字符");
}
+ if (dto.getPassword() != null && !dto.getPassword().trim().isEmpty()) {
+ if (dto.getPassword().length() > PASSWORD_MAX_LENGTH) {
+ throw new ServiceException("卡密码不能超过" + PASSWORD_MAX_LENGTH + "个字符");
+ }
+ }
if (!update && dto.getBalance() != null) {
validateAmount(dto.getBalance(), true, "卡余额");
}
@@ -214,6 +346,35 @@
}
if (existsCardNo(cardNo, dto.getId())) {
throw new ServiceException("卡号已存在");
+ }
+ }
+
+ private String encodePasswordIfPresent(String rawPassword) {
+ if (rawPassword == null || rawPassword.trim().isEmpty()) {
+ return null;
+ }
+ return SecurityUtils.encryptPassword(rawPassword.trim());
+ }
+
+ /**
+ * 卡片处于充电锁定状态时拒绝管理操作(V0.2 定稿)。
+ *
+ * @param cards 待操作的卡片
+ * @param action 操作描述,用于异常提示
+ */
+ private void assertNotChargingLocked(List<TChargingCard> cards, String action) {
+ if (cards == null) {
+ return;
+ }
+ for (TChargingCard card : cards) {
+ assertNotChargingLocked(card, action);
+ }
+ }
+
+ private void assertNotChargingLocked(TChargingCard card, String action) {
+ if (card.getChargingFlag() != null && card.getChargingFlag() == CHARGING_FLAG_LOCKED) {
+ throw new ServiceException("卡片【" + card.getCardNo() + "】正在充电中,禁止" + action
+ + ",充电结束后可重试");
}
}
@@ -237,6 +398,22 @@
}
}
+ private void validateDeductRequest(ChargingCardDeductDTO dto) {
+ if (dto == null) {
+ throw new ServiceException("扣款参数不能为空");
+ }
+ if (dto.getCardId() == null) {
+ throw new ServiceException("卡片ID不能为空");
+ }
+ if (StringUtils.isEmpty(dto.getOrderCode())) {
+ throw new ServiceException("订单号不能为空");
+ }
+ if (dto.getOrderCode().length() > 64) {
+ throw new ServiceException("订单号不能超过64个字符");
+ }
+ validateAmount(dto.getAmount(), false, "扣款金额");
+ }
+
private void validateAmount(BigDecimal amount, boolean allowZero, String fieldName) {
if (amount == null || (allowZero ? amount.compareTo(BigDecimal.ZERO) < 0 : amount.compareTo(BigDecimal.ZERO) <= 0)) {
throw new ServiceException(fieldName + (allowZero ? "不能小于0" : "必须大于0"));
diff --git a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TVinManagementServiceImpl.java b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TVinManagementServiceImpl.java
index 0c96fb1..2c306f3 100644
--- a/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TVinManagementServiceImpl.java
+++ b/ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TVinManagementServiceImpl.java
@@ -92,6 +92,13 @@
return true;
}
@Override public List<Long> getVinIdsByAppUserId(Long appUserId) { return baseMapper.selectIdsByAppUserId(appUserId); }
+ @Override public TVinManagement getByVin(String vin) {
+ if (StringUtils.isEmpty(vin)) return null;
+ return lambdaQuery().eq(TVinManagement::getVin, vin.trim())
+ .orderByDesc(TVinManagement::getId)
+ .last("limit 1")
+ .one();
+ }
@Override @Transactional(rollbackFor = Exception.class) public String importVins(List<VinManagementExcelDTO> items) { if (items == null || items.isEmpty()) throw new ServiceException("导入数据不能为空"); Set<String> vins = new HashSet<>(); int row = 1; for (VinManagementExcelDTO item : items) { row++; if (item == null || StringUtils.isEmpty(item.getVin())) throw new ServiceException("第" + row + "行VIN码不能为空"); String vin = item.getVin().trim(); if (vin.length() > 64) throw new ServiceException("第" + row + "行VIN码不能超过64个字符"); if (!vins.add(vin) || baseMapper.countByVin(vin, null) > 0) throw new ServiceException("第" + row + "行VIN码已存在或重复"); parseStatus(item.getStatus(), row); } Long userId = SecurityUtils.getUserId(); LocalDateTime now = LocalDateTime.now(); for (VinManagementExcelDTO item : items) { TVinManagement v = new TVinManagement(); v.setVin(item.getVin().trim()); v.setPlateNumber(trim(item.getPlateNumber())); v.setPhone(trim(item.getPhone())); v.setCompanyName(trim(item.getCompanyName())); v.setRemark(trim(item.getRemark())); v.setStatus(parseStatus(item.getStatus(), 0)); v.setDelFlag(0); v.setCreateBy(userId); v.setCreateTime(now); save(v); } return "成功导入" + items.size() + "条VIN码数据"; }
private void validate(VinManagementSaveDTO dto, boolean update) { if (dto == null || StringUtils.isEmpty(dto.getVin())) throw new ServiceException("VIN码不能为空"); if (update && dto.getId() == null) throw new ServiceException("VIN码ID不能为空"); String vin = dto.getVin().trim(); if (vin.length() > 64) throw new ServiceException("VIN码不能超过64个字符"); if (!validStatus(dto.getStatus() == null ? ENABLED : dto.getStatus())) throw new ServiceException("VIN码状态参数错误"); if (baseMapper.countByVin(vin, dto.getId()) > 0) throw new ServiceException("VIN码已存在"); }
private TVinManagement from(VinManagementSaveDTO d) { TVinManagement v = new TVinManagement(); v.setVin(d.getVin().trim()); v.setPlateNumber(trim(d.getPlateNumber())); v.setPhone(trim(d.getPhone())); v.setCompanyName(trim(d.getCompanyName())); v.setRemark(trim(d.getRemark())); v.setStatus(d.getStatus() == null ? ENABLED : d.getStatus()); return v; }
diff --git a/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TChargingCardMapper.xml b/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TChargingCardMapper.xml
index 174516f..8be81b6 100644
--- a/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TChargingCardMapper.xml
+++ b/ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TChargingCardMapper.xml
@@ -49,6 +49,7 @@
</if>
</select>
+ <!-- 人工调账:仍保持"增加不超过上限、扣减不低过 0"的运营约束,并叠加充电锁定条件兜底(V0.2 定稿) -->
<update id="adjustBalance">
UPDATE t_charging_card
SET balance = balance
@@ -59,6 +60,7 @@
update_time = NOW(),
update_by = #{updateBy}
WHERE del_flag = 0
+ AND charging_flag = 0
AND id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
@@ -80,4 +82,43 @@
(#{cardId}, #{type}, #{amount}, #{remark}, NOW(), #{createBy})
</insert>
+ <!-- 锁定:仅空闲卡可锁定,条件更新防止同卡并发多单 -->
+ <update id="lockCardForCharging">
+ UPDATE t_charging_card
+ SET charging_flag = 1,
+ charging_order_code = #{orderCode},
+ update_time = NOW()
+ WHERE id = #{cardId}
+ AND del_flag = 0
+ AND charging_flag = 0
+ </update>
+
+ <!-- 解锁:带订单号比对,防止误释放其他进行中订单;已解锁(order_code 已清空)时影响行数为 0 -->
+ <update id="unlockCardForCharging">
+ UPDATE t_charging_card
+ SET charging_flag = 0,
+ charging_order_code = NULL,
+ update_time = NOW()
+ WHERE id = #{cardId}
+ AND del_flag = 0
+ AND charging_order_code = #{orderCode}
+ </update>
+
+ <!-- 订单结算原子扣款:只按卡 ID 扣减,禁止带 balance >= amount 下限条件,允许扣成负数 -->
+ <update id="deductBalanceByCardId">
+ UPDATE t_charging_card
+ SET balance = balance - #{amount},
+ update_time = NOW()
+ WHERE id = #{cardId}
+ AND del_flag = 0
+ </update>
+
+ <!-- 订单结算扣款流水:charging_order_code 唯一索引即扣款幂等键(uk_order_code) -->
+ <insert id="insertOrderDeductRecord">
+ INSERT INTO t_charging_card_balance_record
+ (card_id, change_type, amount, remark, charging_order_code, create_time, create_by)
+ VALUES
+ (#{cardId}, 3, #{amount}, #{orderCode}, #{orderCode}, NOW(), #{createBy})
+ </insert>
+
</mapper>
diff --git a/ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/controller/TChargingCardControllerTest.java b/ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/controller/TChargingCardControllerTest.java
index c82f133..18e0700 100644
--- a/ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/controller/TChargingCardControllerTest.java
+++ b/ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/controller/TChargingCardControllerTest.java
@@ -1,12 +1,15 @@
package com.ruoyi.account.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.account.api.dto.ChargingCardAuthDTO;
import com.ruoyi.account.api.dto.ChargingCardBalanceDTO;
+import com.ruoyi.account.api.dto.ChargingCardDeductDTO;
import com.ruoyi.account.api.dto.ChargingCardExcelDTO;
import com.ruoyi.account.api.dto.ChargingCardQueryDTO;
import com.ruoyi.account.api.dto.ChargingCardSaveDTO;
import com.ruoyi.account.api.dto.ChargingCardStatusDTO;
import com.ruoyi.account.api.model.TChargingCard;
+import com.ruoyi.account.api.vo.ChargingCardDeductVO;
import com.ruoyi.account.service.TChargingCardService;
import com.ruoyi.common.core.domain.R;
import org.junit.jupiter.api.BeforeEach;
@@ -116,6 +119,49 @@
verify(service).importCards(any());
}
+ @Test
+ void innerVerifyChargingDelegatesToService() {
+ ChargingCardAuthDTO dto = new ChargingCardAuthDTO();
+ dto.setCardNo("CARD-1");
+ TChargingCard card = new TChargingCard();
+ card.setId(1L);
+ when(service.verifyCardForCharging(dto)).thenReturn(card);
+
+ R<TChargingCard> result = controller.verifyCharging(dto);
+
+ assertEquals(200, result.getCode());
+ assertEquals(card, result.getData());
+ verify(service).verifyCardForCharging(dto);
+ }
+
+ @Test
+ void innerLockAndUnlockDelegateToService() {
+ when(service.lockCardForCharging(1L, "SD20260909001")).thenReturn(true);
+ when(service.unlockCardForCharging(1L, "SD20260909001")).thenReturn(true);
+
+ assertTrue(controller.lockForCharging(1L, "SD20260909001").getData());
+ assertTrue(controller.unlockForCharging(1L, "SD20260909001").getData());
+ verify(service).lockCardForCharging(1L, "SD20260909001");
+ verify(service).unlockCardForCharging(1L, "SD20260909001");
+ }
+
+ @Test
+ void innerDeductChargingDelegatesToService() {
+ ChargingCardDeductDTO dto = new ChargingCardDeductDTO();
+ dto.setCardId(1L);
+ dto.setOrderCode("SD20260909001");
+ dto.setAmount(java.math.BigDecimal.TEN);
+ ChargingCardDeductVO vo = new ChargingCardDeductVO();
+ vo.setDuplicated(false);
+ when(service.deductBalanceForCharging(dto)).thenReturn(vo);
+
+ R<ChargingCardDeductVO> result = controller.deductForCharging(dto);
+
+ assertEquals(200, result.getCode());
+ assertEquals(vo, result.getData());
+ verify(service).deductBalanceForCharging(dto);
+ }
+
private byte[] excelBytes() throws Exception {
org.apache.poi.xssf.usermodel.XSSFWorkbook workbook = new org.apache.poi.xssf.usermodel.XSSFWorkbook();
try {
diff --git a/ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TChargingCardServiceImplTest.java b/ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TChargingCardServiceImplTest.java
index a2a315d..764cf47 100644
--- a/ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TChargingCardServiceImplTest.java
+++ b/ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TChargingCardServiceImplTest.java
@@ -1,17 +1,22 @@
package com.ruoyi.account.service.impl;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.account.api.dto.ChargingCardAuthDTO;
import com.ruoyi.account.api.dto.ChargingCardBalanceDTO;
+import com.ruoyi.account.api.dto.ChargingCardDeductDTO;
import com.ruoyi.account.api.dto.ChargingCardExcelDTO;
import com.ruoyi.account.api.dto.ChargingCardSaveDTO;
import com.ruoyi.account.api.dto.ChargingCardStatusDTO;
import com.ruoyi.account.api.model.TChargingCard;
+import com.ruoyi.account.api.vo.ChargingCardDeductVO;
import com.ruoyi.account.mapper.TChargingCardMapper;
import com.ruoyi.common.core.context.SecurityContextHolder;
import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.security.service.TokenService;
+import com.ruoyi.common.security.utils.SecurityUtils;
import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.feignClient.SysUserClient;
import com.ruoyi.system.api.model.LoginUser;
@@ -22,7 +27,10 @@
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DuplicateKeyException;
import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.support.TransactionCallback;
+import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.util.Arrays;
@@ -35,6 +43,7 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doReturn;
@@ -49,6 +58,7 @@
@Mock private TChargingCardMapper mapper;
@Mock private TokenService tokenService;
@Mock private SysUserClient sysUserClient;
+ @Mock private TransactionTemplate transactionTemplate;
private TChargingCardServiceImpl service;
@BeforeEach
@@ -57,6 +67,7 @@
ReflectionTestUtils.setField(service, "baseMapper", mapper);
ReflectionTestUtils.setField(service, "tokenService", tokenService);
ReflectionTestUtils.setField(service, "sysUserClient", sysUserClient);
+ ReflectionTestUtils.setField(service, "transactionTemplate", transactionTemplate);
SecurityContextHolder.setUserId("123");
}
@@ -216,4 +227,358 @@
assertTrue(service.selectExportList(null).isEmpty());
verify(mapper).selectExportList(any());
}
+
+ // ==================== V0.2 定稿:卡密码配置(仅配置密码的卡刷卡时才校验) ====================
+
+ @Test
+ void saveCardEncodesPasswordWhenProvided() {
+ when(mapper.countByCardNo(anyString(), isNull())).thenReturn(0);
+ when(mapper.insert(any(TChargingCard.class))).thenAnswer(invocation -> 1);
+ ChargingCardSaveDTO dto = new ChargingCardSaveDTO();
+ dto.setCardNo("CARD-PWD");
+ dto.setPassword(" 123456 ");
+
+ assertTrue(service.saveCard(dto));
+
+ ArgumentCaptor<TChargingCard> card = ArgumentCaptor.forClass(TChargingCard.class);
+ verify(mapper).insert(card.capture());
+ assertNotNull(card.getValue().getPassword());
+ assertTrue(SecurityUtils.matchesPassword("123456", card.getValue().getPassword()));
+ assertEquals(Integer.valueOf(0), card.getValue().getChargingFlag());
+ }
+
+ @Test
+ void saveCardKeepsPasswordNullWhenNotConfigured() {
+ when(mapper.countByCardNo(anyString(), isNull())).thenReturn(0);
+ when(mapper.insert(any(TChargingCard.class))).thenAnswer(invocation -> 1);
+ ChargingCardSaveDTO dto = new ChargingCardSaveDTO();
+ dto.setCardNo("CARD-NOPWD");
+
+ assertTrue(service.saveCard(dto));
+
+ ArgumentCaptor<TChargingCard> card = ArgumentCaptor.forClass(TChargingCard.class);
+ verify(mapper).insert(card.capture());
+ assertEquals(null, card.getValue().getPassword());
+ }
+
+ @Test
+ void updateCardRejectsCardInChargingLock() {
+ ChargingCardSaveDTO dto = new ChargingCardSaveDTO();
+ dto.setId(7L);
+ dto.setCardNo("CARD-7");
+ when(mapper.countByCardNo("CARD-7", 7L)).thenReturn(0);
+ TChargingCard locked = new TChargingCard();
+ locked.setId(7L);
+ locked.setCardNo("CARD-7");
+ locked.setChargingFlag(1);
+ when(mapper.selectById(7L)).thenReturn(locked);
+
+ ServiceException exception = assertThrows(ServiceException.class, () -> service.updateCard(dto));
+
+ assertTrue(exception.getMessage().contains("正在充电中"));
+ verify(mapper, never()).updateById(any());
+ }
+
+ @Test
+ void updateCardResetsPasswordOnlyWhenProvided() {
+ ChargingCardSaveDTO dto = new ChargingCardSaveDTO();
+ dto.setId(7L);
+ dto.setCardNo("CARD-7");
+ dto.setPassword("new-pwd");
+ when(mapper.countByCardNo("CARD-7", 7L)).thenReturn(0);
+ TChargingCard existing = new TChargingCard();
+ existing.setId(7L);
+ existing.setCardNo("CARD-7");
+ when(mapper.selectById(7L)).thenReturn(existing);
+ when(mapper.updateById(any(TChargingCard.class))).thenReturn(1);
+
+ assertTrue(service.updateCard(dto));
+
+ ArgumentCaptor<TChargingCard> card = ArgumentCaptor.forClass(TChargingCard.class);
+ verify(mapper).updateById(card.capture());
+ assertNotNull(card.getValue().getPassword());
+ assertTrue(SecurityUtils.matchesPassword("new-pwd", card.getValue().getPassword()));
+ }
+
+ @Test
+ void updateCardKeepsPasswordWhenNotProvided() {
+ ChargingCardSaveDTO dto = new ChargingCardSaveDTO();
+ dto.setId(7L);
+ dto.setCardNo("CARD-7");
+ when(mapper.countByCardNo("CARD-7", 7L)).thenReturn(0);
+ TChargingCard existing = new TChargingCard();
+ existing.setId(7L);
+ existing.setCardNo("CARD-7");
+ when(mapper.selectById(7L)).thenReturn(existing);
+ when(mapper.updateById(any(TChargingCard.class))).thenReturn(1);
+
+ assertTrue(service.updateCard(dto));
+
+ ArgumentCaptor<TChargingCard> card = ArgumentCaptor.forClass(TChargingCard.class);
+ verify(mapper).updateById(card.capture());
+ assertEquals(null, card.getValue().getPassword());
+ }
+
+ // ==================== V0.2 定稿:充电期间卡片管理操作互斥 ====================
+
+ @Test
+ void deleteCardsRejectsCardInChargingLock() {
+ TChargingCard first = new TChargingCard();
+ first.setId(1L);
+ TChargingCard locked = new TChargingCard();
+ locked.setId(2L);
+ locked.setCardNo("CARD-2");
+ locked.setChargingFlag(1);
+ doReturn(Arrays.asList(first, locked)).when(service).listByIds(anyList());
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> service.deleteCards(Arrays.asList(1L, 2L)));
+
+ assertTrue(exception.getMessage().contains("正在充电中"));
+ verify(service, never()).removeByIds(anyList());
+ }
+
+ @Test
+ void updateStatusRejectsCardInChargingLock() {
+ ChargingCardStatusDTO dto = new ChargingCardStatusDTO();
+ dto.setId(1L);
+ dto.setStatus(2);
+ TChargingCard locked = new TChargingCard();
+ locked.setId(1L);
+ locked.setCardNo("CARD-1");
+ locked.setChargingFlag(1);
+ when(mapper.selectById(1L)).thenReturn(locked);
+
+ ServiceException exception = assertThrows(ServiceException.class, () -> service.updateStatus(dto));
+
+ assertTrue(exception.getMessage().contains("正在充电中"));
+ verify(mapper, never()).updateById(any());
+ }
+
+ @Test
+ void updateBalanceRejectsCardInChargingLock() {
+ LoginUser loginUser = new LoginUser();
+ loginUser.setUserid(123L);
+ SysUser user = new SysUser();
+ user.setOperationPassword(SecurityUtils.encryptPassword("pwd"));
+ when(tokenService.getLoginUser()).thenReturn(loginUser);
+ when(sysUserClient.getSysUser(123L)).thenReturn(R.ok(user));
+ TChargingCard locked = new TChargingCard();
+ locked.setId(1L);
+ locked.setCardNo("CARD-1");
+ locked.setChargingFlag(1);
+ when(mapper.selectBatchIds(anyList())).thenReturn(Collections.singletonList(locked));
+ ChargingCardBalanceDTO dto = new ChargingCardBalanceDTO();
+ dto.setIds(Collections.singletonList(1L));
+ dto.setType(2);
+ dto.setAmount(new BigDecimal("10.00"));
+ dto.setOperationPassword("pwd");
+
+ ServiceException exception = assertThrows(ServiceException.class, () -> service.updateBalance(dto));
+
+ assertTrue(exception.getMessage().contains("正在充电中"));
+ verify(mapper, never()).adjustBalance(anyList(), any(), any(), any(), any());
+ }
+
+ // ==================== V0.2 定稿:刷卡启动鉴权(条件密码校验) ====================
+
+ @Test
+ void verifyCardForChargingPassesWithoutPasswordConfigured() {
+ TChargingCard card = new TChargingCard();
+ card.setId(1L);
+ card.setCardNo("CARD-NOPWD");
+ card.setStatus(1);
+ card.setPassword(null);
+ when(mapper.selectOne(any(Wrapper.class))).thenReturn(card);
+
+ TChargingCard result = service.verifyCardForCharging(authDto("CARD-NOPWD", "whatever"));
+
+ assertSame(card, result);
+ }
+
+ @Test
+ void verifyCardForChargingPassesWithEmptyPasswordWhenNotConfigured() {
+ TChargingCard card = new TChargingCard();
+ card.setId(1L);
+ card.setCardNo("CARD-NOPWD");
+ card.setStatus(1);
+ card.setPassword("");
+ when(mapper.selectOne(any(Wrapper.class))).thenReturn(card);
+
+ TChargingCard result = service.verifyCardForCharging(authDto("CARD-NOPWD", null));
+
+ assertSame(card, result);
+ }
+
+ @Test
+ void verifyCardForChargingRequiresPasswordWhenConfigured() {
+ TChargingCard card = new TChargingCard();
+ card.setId(1L);
+ card.setCardNo("CARD-PWD");
+ card.setStatus(1);
+ card.setPassword(SecurityUtils.encryptPassword("123456"));
+ when(mapper.selectOne(any(Wrapper.class))).thenReturn(card);
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> service.verifyCardForCharging(authDto("CARD-PWD", null)));
+
+ assertEquals("卡片已配置密码,请输入卡密码", exception.getMessage());
+ }
+
+ @Test
+ void verifyCardForChargingRejectsWrongPasswordWhenConfigured() {
+ TChargingCard card = new TChargingCard();
+ card.setId(1L);
+ card.setCardNo("CARD-PWD");
+ card.setStatus(1);
+ card.setPassword(SecurityUtils.encryptPassword("123456"));
+ when(mapper.selectOne(any(Wrapper.class))).thenReturn(card);
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> service.verifyCardForCharging(authDto("CARD-PWD", "bad-pwd")));
+
+ assertEquals("卡密码错误", exception.getMessage());
+ }
+
+ @Test
+ void verifyCardForChargingRejectsDisabledCard() {
+ TChargingCard card = new TChargingCard();
+ card.setId(1L);
+ card.setCardNo("CARD-1");
+ card.setStatus(2);
+ when(mapper.selectOne(any(Wrapper.class))).thenReturn(card);
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> service.verifyCardForCharging(authDto("CARD-1", null)));
+
+ assertEquals("卡片已禁用,无法启动充电", exception.getMessage());
+ }
+
+ @Test
+ void verifyCardForChargingRejectsMissingCard() {
+ when(mapper.selectOne(any(Wrapper.class))).thenReturn(null);
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> service.verifyCardForCharging(authDto("NO-SUCH", null)));
+
+ assertEquals("卡片不存在或已删除", exception.getMessage());
+ }
+
+ // ==================== V0.2 定稿:充电锁定 / 解锁 ====================
+
+ @Test
+ void lockCardForChargingReturnsMapperResult() {
+ when(mapper.lockCardForCharging(1L, "SD20260909001")).thenReturn(1);
+ assertTrue(service.lockCardForCharging(1L, "SD20260909001"));
+
+ when(mapper.lockCardForCharging(1L, "SD20260909001")).thenReturn(0);
+ assertEquals(false, service.lockCardForCharging(1L, "SD20260909001"));
+ }
+
+ @Test
+ void unlockCardForChargingIsIdempotentWhenAlreadyUnlocked() {
+ when(mapper.unlockCardForCharging(1L, "SD20260909001")).thenReturn(0);
+ TChargingCard unlocked = new TChargingCard();
+ unlocked.setId(1L);
+ unlocked.setChargingOrderCode(null);
+ when(mapper.selectById(1L)).thenReturn(unlocked);
+
+ assertTrue(service.unlockCardForCharging(1L, "SD20260909001"));
+ }
+
+ @Test
+ void unlockCardForChargingRefusesWhenOccupiedByAnotherOrder() {
+ when(mapper.unlockCardForCharging(1L, "SD-OLD")).thenReturn(0);
+ TChargingCard occupied = new TChargingCard();
+ occupied.setId(1L);
+ occupied.setChargingOrderCode("SD-NEW");
+ when(mapper.selectById(1L)).thenReturn(occupied);
+
+ assertEquals(false, service.unlockCardForCharging(1L, "SD-OLD"));
+ }
+
+ // ==================== V0.2 定稿:最终结算扣款(幂等 + 允许负余额) ====================
+
+ @Test
+ void deductBalanceForChargingAllowsNegativeBalance() {
+ stubTransaction();
+ TChargingCard beforeCard = new TChargingCard();
+ beforeCard.setId(1L);
+ beforeCard.setCardNo("CARD-1");
+ beforeCard.setBalance(new BigDecimal("5.00"));
+ TChargingCard afterCard = new TChargingCard();
+ afterCard.setId(1L);
+ afterCard.setCardNo("CARD-1");
+ afterCard.setBalance(new BigDecimal("-5.00"));
+ when(mapper.selectById(1L)).thenReturn(beforeCard, afterCard);
+ when(mapper.insertOrderDeductRecord(1L, new BigDecimal("10.00"), "SD20260909001", 0L)).thenReturn(1);
+ when(mapper.deductBalanceByCardId(1L, new BigDecimal("10.00"))).thenReturn(1);
+
+ ChargingCardDeductVO result = service.deductBalanceForCharging(
+ deductDto(1L, "SD20260909001", new BigDecimal("10.00")));
+
+ assertEquals(false, result.isDuplicated());
+ assertEquals(0, result.getAfterBalance().compareTo(new BigDecimal("-5.00")));
+ verify(mapper).insertOrderDeductRecord(1L, new BigDecimal("10.00"), "SD20260909001", 0L);
+ verify(mapper).deductBalanceByCardId(1L, new BigDecimal("10.00"));
+ }
+
+ @Test
+ void deductBalanceForChargingDuplicateRequestDoesNotDeductTwice() {
+ stubTransaction();
+ TChargingCard beforeCard = new TChargingCard();
+ beforeCard.setId(1L);
+ beforeCard.setCardNo("CARD-1");
+ beforeCard.setBalance(new BigDecimal("-5.00"));
+ when(mapper.selectById(1L)).thenReturn(beforeCard);
+ when(mapper.insertOrderDeductRecord(1L, new BigDecimal("10.00"), "SD20260909001", 0L))
+ .thenThrow(new DuplicateKeyException("uk_order_code"));
+
+ ChargingCardDeductVO result = service.deductBalanceForCharging(
+ deductDto(1L, "SD20260909001", new BigDecimal("10.00")));
+
+ assertEquals(true, result.isDuplicated());
+ verify(mapper, never()).deductBalanceByCardId(anyLong(), any());
+ }
+
+ @Test
+ void deductBalanceForChargingRejectsMissingCard() {
+ stubTransaction();
+ when(mapper.selectById(1L)).thenReturn(null);
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> service.deductBalanceForCharging(deductDto(1L, "SD20260909001", new BigDecimal("10.00"))));
+
+ assertEquals("卡片不存在或已删除", exception.getMessage());
+ }
+
+ @Test
+ void deductBalanceForChargingRejectsInvalidAmount() {
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> service.deductBalanceForCharging(deductDto(1L, "SD20260909001", new BigDecimal("0"))));
+
+ assertEquals("扣款金额必须大于0", exception.getMessage());
+ }
+
+ private void stubTransaction() {
+ when(transactionTemplate.execute(any(TransactionCallback.class))).thenAnswer(invocation -> {
+ TransactionCallback<Object> callback = invocation.getArgument(0);
+ return callback.doInTransaction(null);
+ });
+ }
+
+ private ChargingCardAuthDTO authDto(String cardNo, String password) {
+ ChargingCardAuthDTO dto = new ChargingCardAuthDTO();
+ dto.setCardNo(cardNo);
+ dto.setPassword(password);
+ return dto;
+ }
+
+ private ChargingCardDeductDTO deductDto(Long cardId, String orderCode, BigDecimal amount) {
+ ChargingCardDeductDTO dto = new ChargingCardDeductDTO();
+ dto.setCardId(cardId);
+ dto.setOrderCode(orderCode);
+ dto.setAmount(amount);
+ return dto;
+ }
}
diff --git a/ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/iotda/enums/ServiceIdMenu.java b/ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/iotda/enums/ServiceIdMenu.java
index 017973a..60dff5e 100644
--- a/ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/iotda/enums/ServiceIdMenu.java
+++ b/ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/iotda/enums/ServiceIdMenu.java
@@ -100,6 +100,7 @@
ServiceIdMenu(String key,String value) {
+ this.key = key;
this.value = value;
}
diff --git a/ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/iotda/utils/listener/IotMessageListener.java b/ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/iotda/utils/listener/IotMessageListener.java
index f696f2d..3e5ae6b 100644
--- a/ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/iotda/utils/listener/IotMessageListener.java
+++ b/ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/iotda/utils/listener/IotMessageListener.java
@@ -20,6 +20,9 @@
import com.ruoyi.integration.iotda.utils.tools.MessageUtil;
import com.ruoyi.integration.iotda.utils.tools.StrategyUtil;
import com.ruoyi.integration.iotda.utils.tools.ChargingMessageUtil;
+import com.ruoyi.order.api.dto.DeviceStartChargingDTO;
+import com.ruoyi.order.api.feignClient.ChargingOrderClient;
+import com.ruoyi.order.api.vo.DeviceStartChargingVO;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
@@ -58,6 +61,9 @@
@Resource
private ChargingMessageUtil chargingMessageUtil;
+
+ @Resource
+ private ChargingOrderClient chargingOrderClient;
private final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(100, 500, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());
@@ -312,14 +318,46 @@
ChargingPileStartsChargingMessage chargingPileStartsChargingMessage = JSON.parseObject(content.toJSONString(),ChargingPileStartsChargingMessage.class);
chargingMessage.setChargingPileStartsChargingMessage(chargingPileStartsChargingMessage);
chargingMessageUtil.handleMessage(chargingMessage);
+ // 按启动方式执行真实业务鉴权建单(1=刷卡SD订单,2=账号暂不支持,3=VIN码VD订单)
+ DeviceStartChargingVO startVO = null;
+ try {
+ DeviceStartChargingDTO startDTO = new DeviceStartChargingDTO();
+ startDTO.setChargingPileCode(chargingPileStartsChargingMessage.getCharging_pile_code());
+ startDTO.setChargingGunCode(chargingPileStartsChargingMessage.getCharging_gun_code());
+ startDTO.setStartMethod(chargingPileStartsChargingMessage.getStart_method());
+ // VIN码启动时协议将反序VIN放在vin字段(account置零),刷卡启动时为卡号
+ startDTO.setAccount(chargingPileStartsChargingMessage.getStart_method() != null
+ && chargingPileStartsChargingMessage.getStart_method() == 3
+ ? chargingPileStartsChargingMessage.getVin()
+ : chargingPileStartsChargingMessage.getAccount());
+ startDTO.setPassword(chargingPileStartsChargingMessage.getPassword());
+ R<DeviceStartChargingVO> startResult = chargingOrderClient.deviceStartCharging(startDTO);
+ if (startResult != null && startResult.getCode() == R.SUCCESS) {
+ startVO = startResult.getData();
+ } else {
+ log.error("设备主动启动充电鉴权失败(订单服务返回异常)pile={} gun={} msg={}",
+ startDTO.getChargingPileCode(), startDTO.getChargingGunCode(),
+ startResult == null ? "null" : startResult.getMsg());
+ }
+ } catch (Exception ex) {
+ log.error("设备主动启动充电鉴权建单异常 pile={} gun={}",
+ chargingPileStartsChargingMessage.getCharging_pile_code(),
+ chargingPileStartsChargingMessage.getCharging_gun_code(), ex);
+ }
// 响应硬件
PlatformConfirmationCharging platformConfirmationCharging = new PlatformConfirmationCharging();
platformConfirmationCharging.setCharging_pile_code(chargingPileStartsChargingMessage.getCharging_pile_code());
platformConfirmationCharging.setCharging_gun_code(chargingPileStartsChargingMessage.getCharging_gun_code());
platformConfirmationCharging.setCard_number(chargingPileStartsChargingMessage.getAccount());
- platformConfirmationCharging.setAccount_balance(BigDecimal.ZERO);
- platformConfirmationCharging.setAuthentication(1);
- // TODO 若是失败,给出失败原因
+ if (startVO != null && Integer.valueOf(1).equals(startVO.getAuthentication())) {
+ platformConfirmationCharging.setTransaction_serial_number(startVO.getOrderCode());
+ platformConfirmationCharging.setAccount_balance(startVO.getBalance() == null ? BigDecimal.ZERO : startVO.getBalance());
+ platformConfirmationCharging.setAuthentication(1);
+ } else {
+ platformConfirmationCharging.setAccount_balance(BigDecimal.ZERO);
+ platformConfirmationCharging.setAuthentication(0);
+ platformConfirmationCharging.setFailure_cause(startVO == null ? null : startVO.getFailureCause());
+ }
iotMessageProduce.sendMessage(platformConfirmationCharging.getCharging_pile_code(), ServiceIdMenu.PLATFORM_CONFIRMATION_CHARGING.getKey(),messageUtil.platformConfirmationCharging(platformConfirmationCharging));
log.warn("CHARGING_PILE_STARTS_CHARGING消息处理:{} 毫秒", System.currentTimeMillis() - CHARGING_PILE_STARTS_CHARGING);
diff --git a/ruoyi-service/ruoyi-integration/src/test/java/com/ruoyi/integration/iotda/enums/ServiceIdMenuTest.java b/ruoyi-service/ruoyi-integration/src/test/java/com/ruoyi/integration/iotda/enums/ServiceIdMenuTest.java
new file mode 100644
index 0000000..1a871f2
--- /dev/null
+++ b/ruoyi-service/ruoyi-integration/src/test/java/com/ruoyi/integration/iotda/enums/ServiceIdMenuTest.java
@@ -0,0 +1,38 @@
+package com.ruoyi.integration.iotda.enums;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * IoTDA service_id 枚举完整性测试:
+ * 防止历史"key 恒为 null"类构造/取值错误回归,保证协议下发应答可正常匹配。
+ */
+class ServiceIdMenuTest {
+
+ @Test
+ void allKeysAndValuesPresent() {
+ for (ServiceIdMenu menu : ServiceIdMenu.values()) {
+ assertNotNull(menu.getKey(), "枚举 " + menu.name() + " 的 key(中文描述) 为空");
+ assertNotNull(menu.getValue(), "枚举 " + menu.name() + " 的 value(service_id) 为空");
+ assertFalse(menu.getKey().trim().isEmpty(), "枚举 " + menu.name() + " 的 key(中文描述) 为空白");
+ assertFalse(menu.getValue().trim().isEmpty(), "枚举 " + menu.name() + " 的 value(service_id) 为空白");
+ }
+ }
+
+ @Test
+ void serviceIdValuesAreGloballyUnique() {
+ Set<String> seen = new HashSet<>();
+ for (ServiceIdMenu menu : ServiceIdMenu.values()) {
+ assertFalse(seen.contains(menu.getValue()),
+ "service_id 重复定义:" + menu.getValue() + "(" + menu.name() + ")");
+ seen.add(menu.getValue());
+ }
+ assertEquals(ServiceIdMenu.values().length, seen.size());
+ }
+}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java
index 9b1d28c..9d201e7 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java
@@ -42,6 +42,7 @@
import com.ruoyi.common.core.web.domain.AjaxResult;
import com.ruoyi.common.core.web.page.PageInfo;
import com.ruoyi.order.api.dto.ChargingStatisticeDTO;
+import com.ruoyi.order.api.dto.DeviceStartChargingDTO;
import com.ruoyi.order.api.model.*;
import com.ruoyi.order.api.query.ChargingOrderQuery;
import com.ruoyi.common.core.dto.MongoChargingOrderQuery;
@@ -1789,14 +1790,21 @@
}
/**
- * 远程启动充电应答
+ * 远程启动充电应答(按订单号前缀路由:SD=刷卡订单,VD=VIN订单,其余=普通订单)
* @param message
*/
@ResponseBody
@PostMapping(value = "/startChargeSuccessfully")
public void startChargeSuccessfully(@RequestBody PlatformStartChargingReplyMessageVO message){
log.info("-------------------远程启动充电请求应答-------------------:" + message);
- chargingOrderService.startChargeSuccessfully(message);
+ String orderCode = message == null ? null : message.getTransaction_serial_number();
+ if (isCardOrder(orderCode)) {
+ cardChargingOrderService.cardStartChargeSuccessfully(message);
+ } else if (isVinOrder(orderCode)) {
+ vinChargingOrderService.vinStartChargeSuccessfully(message);
+ } else {
+ chargingOrderService.startChargeSuccessfully(message);
+ }
}
@@ -1909,23 +1917,36 @@
/**
- * 停止充电应答处理逻辑
+ * 停止充电应答处理逻辑(普通订单原逻辑 + VIN订单按枪兜底确认)
* @param platformStopChargingReply
*/
@PostMapping("/terminateSuccessfulResponse")
public void terminateSuccessfulResponse(@RequestBody PlatformStopChargingReplyVO platformStopChargingReply){
log.info("-------------------远程停止充电请求应答-------------------:" + platformStopChargingReply);
+ // VIN订单平台停止应答兜底(按枪定位停止中订单,内部自带空判断,不影响普通订单处理)
+ try {
+ vinChargingOrderService.vinTerminateStopByReply(platformStopChargingReply);
+ } catch (Exception ex) {
+ log.error("VIN订单停止应答处理异常:", ex);
+ }
chargingOrderService.terminateSuccessfulResponse(platformStopChargingReply);
}
-
-
+
+
/**
- * 停止充电返回账单后计算费用
+ * 停止充电返回账单后计算费用(按订单号前缀路由:SD=刷卡订单,VD=VIN订单,其余=普通订单)
* @param vo
*/
@PostMapping("/endChargeBillingCharge")
public R endChargeBillingCharge(@RequestBody TransactionRecordMessageVO vo){
log.info("-------------------停止充电返回账单后计算费用及修改业务状态-------------------:" + vo);
+ String orderCode = vo == null ? null : vo.getTransaction_serial_number();
+ if (isCardOrder(orderCode)) {
+ return cardChargingOrderService.cardEndChargeBillingCharge(vo);
+ }
+ if (isVinOrder(orderCode)) {
+ return vinChargingOrderService.vinEndChargeBillingCharge(vo);
+ }
return chargingOrderService.endChargeBillingCharge(vo);
}
@@ -2304,22 +2325,41 @@
}
/**
- * 处理充电订单实时监控数据相关的业务逻辑
+ * 处理充电订单实时监控数据相关的业务逻辑(按订单号前缀路由)
* @param query
*/
@PostMapping("/chargeMonitoring")
public void chargeMonitoring(@RequestBody UploadRealTimeMonitoringDataQuery query){
- chargingOrderService.chargeMonitoring(query);
+ String orderCode = query == null ? null : query.getTransaction_serial_number();
+ if (isCardOrder(orderCode)) {
+ cardChargingOrderService.cardChargeMonitoring(query);
+ } else if (isVinOrder(orderCode)) {
+ vinChargingOrderService.vinChargeMonitoring(query);
+ } else {
+ chargingOrderService.chargeMonitoring(query);
+ }
}
/**
- * 通过流水号查询订单
+ * 通过流水号查询订单(统一前缀路由:SD=刷卡订单,VD=VIN订单,其余=普通订单)
+ * <p>设备链路(实时监控/BMS需求/交易记录确认等)统一经此按订单号前缀取三类订单,
+ * SD/VD 订单实体继承自 TChargingOrder,字段兼容直接返回。</p>
* @param code
* @return
*/
@PostMapping(value = "/getOrderByCode/{code}")
public R<TChargingOrder> getOrderByCode(@PathVariable("code") String code){
- TChargingOrder one = chargingOrderService.getOne(Wrappers.lambdaQuery(TChargingOrder.class)
- .eq(TChargingOrder::getCode, code));
+ TChargingOrder one;
+ if (isCardOrder(code)) {
+ one = cardChargingOrderService.getCardOrderByCode(code);
+ } else if (isVinOrder(code)) {
+ one = vinChargingOrderService.getVinOrderByCode(code);
+ } else {
+ one = chargingOrderService.getOne(Wrappers.lambdaQuery(TChargingOrder.class)
+ .eq(TChargingOrder::getCode, code));
+ }
+ if (one == null) {
+ return R.ok(null);
+ }
one.setIdStr(one.getId().toString());
return R.ok(one);
}
@@ -2348,6 +2388,52 @@
return R.ok(false);
}
return R.ok(updated);
+ }
+
+ /**
+ * 设备链路订单号前缀判断:SD=刷卡充电订单
+ */
+ private boolean isCardOrder(String orderCode) {
+ return orderCode != null && orderCode.startsWith("SD");
+ }
+
+ /**
+ * 设备链路订单号前缀判断:VD=VIN码充电订单
+ */
+ private boolean isVinOrder(String orderCode) {
+ return orderCode != null && orderCode.startsWith("VD");
+ }
+
+ /**
+ * 设备主动发起充电(刷卡/VIN码)鉴权建单统一入口。
+ * <p>协议 start_method:1=刷卡(创建SD订单),2=账号启动(暂不支持),3=VIN码(创建VD订单)。</p>
+ */
+ @PostMapping("/inner/device-start-charging")
+ public R<DeviceStartChargingVO> deviceStartCharging(@RequestBody DeviceStartChargingDTO dto) {
+ if (dto == null || dto.getStartMethod() == null) {
+ log.warn("[设备启动] 缺少启动方式,拒绝建单 dto={}", dto);
+ return R.ok(DeviceStartChargingVO.fail(null));
+ }
+ DeviceStartChargingVO result;
+ switch (dto.getStartMethod()) {
+ case 1:
+ result = cardChargingOrderService.startCardCharging(dto);
+ break;
+ case 3:
+ result = vinChargingOrderService.startVinCharging(dto);
+ break;
+ case 2:
+ // 账号启动暂不支持(协议无对应失败码,按最接近的“账户不存在”返回)
+ log.warn("[设备启动] 账号启动方式暂不支持 pile={} account={}", dto.getChargingPileCode(), dto.getAccount());
+ result = DeviceStartChargingVO.fail(1);
+ break;
+ default:
+ log.warn("[设备启动] 未知启动方式 startMethod={} pile={} gun={}",
+ dto.getStartMethod(), dto.getChargingPileCode(), dto.getChargingGunCode());
+ result = DeviceStartChargingVO.fail(null);
+ break;
+ }
+ return R.ok(result);
}
@@ -3277,25 +3363,37 @@
/**
- * 硬件充电结束后的处理逻辑
+ * 硬件充电结束后的处理逻辑(按订单号前缀路由,结束方式2=设备自动结束)
* @param code
*/
@PostMapping("/endCharge")
public void endCharge(@RequestParam("code") String code){
log.info(code + ":-------------------充电桩自动结束充电-------------------");
- chargingOrderService.endCharge(code, 2);
+ if (isCardOrder(code)) {
+ cardChargingOrderService.cardEndCharge(code, 2);
+ } else if (isVinOrder(code)) {
+ vinChargingOrderService.vinEndCharge(code, 2);
+ } else {
+ chargingOrderService.endCharge(code, 2);
+ }
}
/**
- * 硬件异常结束充电后的处理逻辑
+ * 硬件异常结束充电后的处理逻辑(按订单号前缀路由,结束方式0=异常终止)
* @param code
*/
@PostMapping("/excelEndCharge")
public void excelEndCharge(@RequestParam("code") String code){
log.info(code + ":-------------------充电异常,停止充电-------------------");
- chargingOrderService.excelEndCharge(code);
+ if (isCardOrder(code)) {
+ cardChargingOrderService.cardEndCharge(code, 0);
+ } else if (isVinOrder(code)) {
+ vinChargingOrderService.vinEndCharge(code, 0);
+ } else {
+ chargingOrderService.excelEndCharge(code);
+ }
}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinAccountingStrategyDetailOrderMapper.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinAccountingStrategyDetailOrderMapper.java
new file mode 100644
index 0000000..0d08b68
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinAccountingStrategyDetailOrderMapper.java
@@ -0,0 +1,15 @@
+package com.ruoyi.order.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.order.api.model.TVinAccountingStrategyDetailOrder;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * VIN码充电订单计费策略明细快照表 Mapper
+ *
+ * @author codebuddy
+ * @since 2026-09-09
+ */
+@Mapper
+public interface TVinAccountingStrategyDetailOrderMapper extends BaseMapper<TVinAccountingStrategyDetailOrder> {
+}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinAccountingStrategyOrderMapper.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinAccountingStrategyOrderMapper.java
new file mode 100644
index 0000000..54e542c
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinAccountingStrategyOrderMapper.java
@@ -0,0 +1,15 @@
+package com.ruoyi.order.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.order.api.model.TVinAccountingStrategyOrder;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * VIN码充电订单计费策略快照主表 Mapper
+ *
+ * @author codebuddy
+ * @since 2026-09-09
+ */
+@Mapper
+public interface TVinAccountingStrategyOrderMapper extends BaseMapper<TVinAccountingStrategyOrder> {
+}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinChargingOrderAccountingStrategyMapper.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinChargingOrderAccountingStrategyMapper.java
new file mode 100644
index 0000000..75a7094
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TVinChargingOrderAccountingStrategyMapper.java
@@ -0,0 +1,15 @@
+package com.ruoyi.order.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.order.api.model.TVinChargingOrderAccountingStrategy;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * VIN码充电订单计费明细表 Mapper
+ *
+ * @author codebuddy
+ * @since 2026-09-09
+ */
+@Mapper
+public interface TVinChargingOrderAccountingStrategyMapper extends BaseMapper<TVinChargingOrderAccountingStrategy> {
+}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TCardChargingOrderService.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TCardChargingOrderService.java
index c2357dc..1e21e11 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TCardChargingOrderService.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TCardChargingOrderService.java
@@ -2,11 +2,17 @@
import com.ruoyi.common.core.web.page.PageInfo;
import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.common.core.domain.R;
+import com.ruoyi.order.api.dto.DeviceStartChargingDTO;
import com.ruoyi.order.api.model.TCardChargingOrder;
import com.ruoyi.order.api.query.CardBalanceDetailQuery;
import com.ruoyi.order.api.query.CardChargingOrderQuery;
+import com.ruoyi.order.api.query.UploadRealTimeMonitoringDataQuery;
import com.ruoyi.order.api.vo.CardBalanceDetailVO;
import com.ruoyi.order.api.vo.ChargingOrderVO;
+import com.ruoyi.order.api.vo.DeviceStartChargingVO;
+import com.ruoyi.order.api.vo.PlatformStartChargingReplyMessageVO;
+import com.ruoyi.order.api.vo.TransactionRecordMessageVO;
import java.util.List;
import java.util.Map;
@@ -33,4 +39,62 @@
Map<String, Object> balanceDetailSummary(CardBalanceDetailQuery query);
List<CardBalanceDetailVO> balanceDetailExport(CardBalanceDetailQuery query);
+
+ // ===== 设备侧刷卡充电链路(V0.2 刷卡充电) =====
+
+ /**
+ * 设备主动发起刷卡充电:卡片鉴权 -> 建单(状态2) -> 策略快照 -> 锁卡;
+ * 锁卡失败回滚删除订单并返回失败原因4。
+ */
+ DeviceStartChargingVO startCardCharging(DeviceStartChargingDTO dto);
+
+ /**
+ * 根据订单编号查询刷卡充电订单(SD前缀)
+ */
+ TCardChargingOrder getCardOrderByCode(String code);
+
+ /**
+ * 刷卡订单收到桩启动应答:成功(结果1)置为充电中;失败置为启动失败并解锁卡片、复位枪
+ */
+ void cardStartChargeSuccessfully(PlatformStartChargingReplyMessageVO vo);
+
+ /**
+ * 刷卡订单实时监控数据更新(首次数据将订单置为充电中)
+ */
+ void cardChargeMonitoring(UploadRealTimeMonitoringDataQuery query);
+
+ /**
+ * 刷卡订单充电结束(设备自动结束/异常终止时更新结束方式)
+ */
+ void cardEndCharge(String orderCode, Integer endMode);
+
+ /**
+ * 刷卡订单最终结算(统一结算入口,SD):
+ * 订单号结算锁 -> 幂等(状态5跳过) -> 分时电量按快照计价 -> 写计费明细 -> 更新订单(状态5,允许负余额)
+ * -> 卡账户扣款(幂等) -> 解锁卡片 -> 复位枪
+ */
+ R cardEndChargeBillingCharge(TransactionRecordMessageVO vo);
+
+ // ===== 定时补偿(文档13.3:异常断电/补账场景,走统一结算入口) =====
+
+ /**
+ * 查询启动超时(状态2超过阈值未收到启动成功应答)的刷卡订单
+ */
+ List<TCardChargingOrder> listTimeoutStartingOrders();
+
+ /**
+ * 查询充电超时(状态3超过阈值未收到交易记录)的刷卡订单
+ */
+ List<TCardChargingOrder> listTimeoutChargingOrders();
+
+ /**
+ * 启动超时关单:状态置为启动失败(6),解锁卡片并复位充电枪
+ */
+ void closeTimeoutStartingOrder(TCardChargingOrder order);
+
+ /**
+ * 充电超时兜底结算:以订单累计电量构造平段账单,复用统一结算入口
+ * cardEndChargeBillingCharge(结算锁/状态幂等/扣款幂等/解锁/复位全复用)
+ */
+ void compensationSettle(TCardChargingOrder order);
}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TVinAccountingStrategyDetailOrderService.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TVinAccountingStrategyDetailOrderService.java
new file mode 100644
index 0000000..153a5fd
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TVinAccountingStrategyDetailOrderService.java
@@ -0,0 +1,20 @@
+package com.ruoyi.order.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.order.api.model.TVinAccountingStrategyDetailOrder;
+
+import java.util.List;
+
+/**
+ * VIN码充电订单计费策略明细快照表服务
+ *
+ * @author codebuddy
+ * @since 2026-09-09
+ */
+public interface TVinAccountingStrategyDetailOrderService extends IService<TVinAccountingStrategyDetailOrder> {
+
+ /**
+ * 根据订单id查询计费策略明细快照
+ */
+ List<TVinAccountingStrategyDetailOrder> listByChargingOrderId(Long chargingOrderId);
+}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TVinAccountingStrategyOrderService.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TVinAccountingStrategyOrderService.java
new file mode 100644
index 0000000..847e350
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TVinAccountingStrategyOrderService.java
@@ -0,0 +1,13 @@
+package com.ruoyi.order.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.order.api.model.TVinAccountingStrategyOrder;
+
+/**
+ * VIN码充电订单计费策略快照主表服务
+ *
+ * @author codebuddy
+ * @since 2026-09-09
+ */
+public interface TVinAccountingStrategyOrderService extends IService<TVinAccountingStrategyOrder> {
+}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TVinChargingOrderAccountingStrategyService.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TVinChargingOrderAccountingStrategyService.java
new file mode 100644
index 0000000..5984948
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TVinChargingOrderAccountingStrategyService.java
@@ -0,0 +1,13 @@
+package com.ruoyi.order.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.order.api.model.TVinChargingOrderAccountingStrategy;
+
+/**
+ * VIN码充电订单计费明细表服务
+ *
+ * @author codebuddy
+ * @since 2026-09-09
+ */
+public interface TVinChargingOrderAccountingStrategyService extends IService<TVinChargingOrderAccountingStrategy> {
+}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TVinChargingOrderService.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TVinChargingOrderService.java
index 507a191..7c35a40 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TVinChargingOrderService.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TVinChargingOrderService.java
@@ -1,11 +1,18 @@
package com.ruoyi.order.service;
import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.web.page.PageInfo;
+import com.ruoyi.order.api.dto.DeviceStartChargingDTO;
import com.ruoyi.order.api.model.TVinChargingOrder;
+import com.ruoyi.order.api.query.UploadRealTimeMonitoringDataQuery;
import com.ruoyi.order.api.query.VinChargingOrderQuery;
import com.ruoyi.common.core.web.domain.AjaxResult;
import com.ruoyi.order.api.vo.ChargingOrderVO;
+import com.ruoyi.order.api.vo.DeviceStartChargingVO;
+import com.ruoyi.order.api.vo.PlatformStartChargingReplyMessageVO;
+import com.ruoyi.order.api.vo.PlatformStopChargingReplyVO;
+import com.ruoyi.order.api.vo.TransactionRecordMessageVO;
import com.ruoyi.order.api.vo.VinChargingStatusVO;
import com.ruoyi.order.dto.MyVinChargingOrderList;
@@ -31,4 +38,70 @@
AjaxResult stopCharging(String chargingOrderId, Integer chargingGunId);
List<Map<String, Object>> accountingStrategyList(Long chargingOrderId);
+
+ // ===== 设备侧VIN码充电链路(V0.2 VIN码充电) =====
+
+ /**
+ * 设备主动发起VIN码充电:VIN反序解析 -> 绑定关系校验 -> 建单(状态2) -> 策略快照
+ */
+ DeviceStartChargingVO startVinCharging(DeviceStartChargingDTO dto);
+
+ /**
+ * 根据订单编号查询VIN码充电订单(VD前缀)
+ */
+ TVinChargingOrder getVinOrderByCode(String code);
+
+ /**
+ * VIN订单收到桩启动应答:成功置为充电中;失败置为启动失败并复位枪
+ */
+ void vinStartChargeSuccessfully(PlatformStartChargingReplyMessageVO vo);
+
+ /**
+ * VIN订单实时监控数据更新(首次数据将订单置为充电中)
+ */
+ void vinChargeMonitoring(UploadRealTimeMonitoringDataQuery query);
+
+ /**
+ * VIN订单充电结束(设备自动结束/异常终止时更新结束方式)
+ */
+ void vinEndCharge(String orderCode, Integer endMode);
+
+ /**
+ * VIN订单最终结算(统一结算入口,VD):订单号结算锁 -> 幂等(状态5跳过) -> 分时电量按快照计价
+ * -> 写计费明细 -> 更新订单(状态5);不做任何账户扣款操作(后付记账模式)
+ */
+ R vinEndChargeBillingCharge(TransactionRecordMessageVO vo);
+
+ /**
+ * 查询充电枪最新停止中(状态4)的VIN订单(平台停止应答兜底处理)
+ */
+ TVinChargingOrder getLatestStoppingByGunId(Integer gunId);
+
+ /**
+ * 平台停止充电应答兜底:确认结束方式为主动终止(与stopCharging前置更新幂等合并)
+ */
+ void vinTerminateStopByReply(PlatformStopChargingReplyVO vo);
+
+ // ===== 定时补偿(文档13.3:异常断电/补账场景,走统一结算入口) =====
+
+ /**
+ * 查询启动超时(状态2超过阈值未收到启动成功应答)的VIN订单
+ */
+ List<TVinChargingOrder> listTimeoutStartingOrders();
+
+ /**
+ * 查询充电超时(状态3/4超过阈值未收到交易记录)的VIN订单
+ */
+ List<TVinChargingOrder> listTimeoutChargingOrders();
+
+ /**
+ * 启动超时关单:状态置为启动失败(6)并复位充电枪
+ */
+ void closeTimeoutStartingOrder(TVinChargingOrder order);
+
+ /**
+ * 充电超时兜底结算:以订单累计电量构造平段账单,复用统一结算入口
+ * vinEndChargeBillingCharge(结算锁/状态幂等/枪复位全复用)
+ */
+ void compensationSettle(TVinChargingOrder order);
}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java
index e2593fb..0b69c48 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java
@@ -1,46 +1,118 @@
package com.ruoyi.order.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.account.api.dto.ChargingCardAuthDTO;
+import com.ruoyi.account.api.dto.ChargingCardDeductDTO;
import com.ruoyi.account.api.feignClient.AppUserCarClient;
+import com.ruoyi.account.api.feignClient.ChargingCardClient;
import com.ruoyi.account.api.model.TAppUserCar;
+import com.ruoyi.account.api.model.TChargingCard;
+import com.ruoyi.account.api.vo.ChargingCardDeductVO;
+import com.ruoyi.chargingPile.api.feignClient.AccountingStrategyClient;
+import com.ruoyi.chargingPile.api.feignClient.AccountingStrategyDetailClient;
import com.ruoyi.chargingPile.api.feignClient.ChargingGunClient;
import com.ruoyi.chargingPile.api.feignClient.ChargingPileClient;
import com.ruoyi.chargingPile.api.feignClient.SiteClient;
import com.ruoyi.chargingPile.api.dto.GetSiteListDTO;
+import com.ruoyi.chargingPile.api.model.TAccountingStrategy;
+import com.ruoyi.chargingPile.api.model.TAccountingStrategyDetail;
import com.ruoyi.chargingPile.api.model.TChargingGun;
+import com.ruoyi.chargingPile.api.model.TChargingPile;
+import com.ruoyi.chargingPile.api.vo.GetChargingGunByCode;
+import com.ruoyi.common.core.domain.R;
+import com.ruoyi.common.core.utils.DateUtils;
+import com.ruoyi.common.core.utils.StringUtils;
+import com.ruoyi.common.core.utils.bean.BeanUtils;
import com.ruoyi.common.core.web.page.PageInfo;
import com.ruoyi.common.security.service.TokenService;
+import com.ruoyi.order.api.dto.DeviceStartChargingDTO;
+import com.ruoyi.order.api.model.TCardAccountingStrategyDetailOrder;
+import com.ruoyi.order.api.model.TCardAccountingStrategyOrder;
import com.ruoyi.order.api.model.TCardChargingOrder;
+import com.ruoyi.order.api.model.TCardChargingOrderAccountingStrategy;
import com.ruoyi.order.api.query.CardBalanceDetailQuery;
import com.ruoyi.order.api.query.CardChargingOrderQuery;
+import com.ruoyi.order.api.query.UploadRealTimeMonitoringDataQuery;
import com.ruoyi.order.api.vo.CardBalanceDetailVO;
import com.ruoyi.order.api.vo.ChargingOrderVO;
+import com.ruoyi.order.api.vo.DeviceStartChargingVO;
+import com.ruoyi.order.api.vo.PlatformStartChargingReplyMessageVO;
+import com.ruoyi.order.api.vo.TransactionRecordMessageVO;
import com.ruoyi.order.mapper.TCardChargingOrderMapper;
+import com.ruoyi.order.service.TCardAccountingStrategyDetailOrderService;
+import com.ruoyi.order.service.TCardAccountingStrategyOrderService;
+import com.ruoyi.order.service.TCardChargingOrderAccountingStrategyService;
import com.ruoyi.order.service.TCardChargingOrderService;
import com.ruoyi.chargingPile.api.model.Site;
+import com.ruoyi.integration.api.feignClient.ChargingMessageClient;
+import com.ruoyi.integration.api.feignClient.ChengDuJianGuanClient;
import com.ruoyi.integration.api.model.UploadRealTimeMonitoringData;
+import com.ruoyi.integration.api.vo.ChargingOrderVo;
+import com.ruoyi.integration.api.vo.NotificationConnectorChargeStatusVo;
+import com.ruoyi.order.util.TransactionStageParser;
import com.ruoyi.order.util.mongodb.service.UploadRealTimeMonitoringDataService;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionTemplate;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
+import java.text.SimpleDateFormat;
+import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
+import java.util.Date;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class TCardChargingOrderServiceImpl extends ServiceImpl<TCardChargingOrderMapper, TCardChargingOrder>
implements TCardChargingOrderService {
+ private static final Logger log = LoggerFactory.getLogger(TCardChargingOrderServiceImpl.class);
+
+ /** SD 订单号前缀(设备刷卡充电) */
+ private static final String ORDER_CODE_PREFIX_SD = "SD";
+ /** 订单状态:启动中 */
+ private static final int ORDER_STATUS_STARTING = 2;
+ /** 订单状态:充电中 */
+ private static final int ORDER_STATUS_CHARGING = 3;
+ /** 订单状态:已结束 */
+ private static final int ORDER_STATUS_FINISHED = 5;
+ /** 订单状态:启动失败(沿用 t_charging_order 枚举约定:-1 为负值语义,前端按 status<0 渲染) */
+ private static final int ORDER_STATUS_START_FAILED = -1;
+ /** 充电枪状态:充电中 */
+ private static final int GUN_STATUS_CHARGING = 4;
+ /** 充电枪状态:空闲 */
+ private static final int GUN_STATUS_IDLE = 2;
+ /** SD 建单枪锁前缀(桩+枪维度,防同枪并发建单,文档13.2) */
+ private static final String LOCK_SD_START_GUN = "SD_START_GUN:";
+ /** SD 建单卡锁前缀(卡ID维度,防同卡多枪并发启动,文档13.2) */
+ private static final String LOCK_SD_START_CARD = "SD_START_CARD:";
+ /** SD 结算锁前缀(订单号维度,防并发重复结算/扣款,文档13.3) */
+ private static final String LOCK_SD_SETTLE = "SD_SETTLE:";
+ /** SD 启动中超时阈值(分钟):超时未收到启动成功应答则关单 */
+ private static final int SD_STARTING_TIMEOUT_MINUTES = 30;
+ /** SD 充电中超时阈值(小时):超时未收到交易记录则兜底结算 */
+ private static final int SD_CHARGING_TIMEOUT_HOURS = 24;
+
@Resource
private TokenService tokenService;
+ @Resource
+ private ChargingMessageClient chargingMessageClient;
+ @Resource
+ private ChengDuJianGuanClient chengDuJianGuanClient;
@Resource
private SiteClient siteClient;
@Resource
@@ -51,6 +123,22 @@
private AppUserCarClient appUserCarClient;
@Resource
private UploadRealTimeMonitoringDataService uploadRealTimeMonitoringDataService;
+ @Resource
+ private ChargingCardClient chargingCardClient;
+ @Resource
+ private AccountingStrategyClient accountingStrategyClient;
+ @Resource
+ private AccountingStrategyDetailClient accountingStrategyDetailClient;
+ @Resource
+ private TCardAccountingStrategyOrderService cardAccountingStrategyOrderService;
+ @Resource
+ private TCardAccountingStrategyDetailOrderService cardAccountingStrategyDetailOrderService;
+ @Resource
+ private TCardChargingOrderAccountingStrategyService cardChargingOrderAccountingStrategyService;
+ @Resource
+ private TransactionTemplate transactionTemplate;
+ @Resource
+ private RedissonClient redissonClient;
@Override
public PageInfo<ChargingOrderVO> pageList(CardChargingOrderQuery query) {
@@ -391,4 +479,544 @@
private String[] parseTimeRange(CardChargingOrderQuery query) {
return new String[]{query.getStartTime1(), query.getStartTime2(), query.getEndTime1(), query.getEndTime2()};
}
+
+ // ===== 设备侧刷卡充电链路(V0.2 刷卡充电) =====
+
+ @Override
+ public DeviceStartChargingVO startCardCharging(DeviceStartChargingDTO dto) {
+ if (dto == null || StringUtils.isEmpty(dto.getChargingPileCode())
+ || StringUtils.isEmpty(dto.getChargingGunCode()) || StringUtils.isEmpty(dto.getAccount())) {
+ return DeviceStartChargingVO.fail(1);
+ }
+ // 并发防护(文档13.2):桩+枪维度分布式锁,抢不到说明同枪并发启动,快速失败
+ RLock gunLock = redissonClient.getLock(LOCK_SD_START_GUN + dto.getChargingPileCode() + ":" + dto.getChargingGunCode());
+ try {
+ if (!gunLock.tryLock(0, 30, TimeUnit.SECONDS)) {
+ return DeviceStartChargingVO.fail(10);
+ }
+ // 1. 充电枪校验(不存在->5 桩停用;充电中->10 该桩存在未结账记录)
+ GetChargingGunByCode gunQuery = new GetChargingGunByCode();
+ gunQuery.setCharging_pile_code(dto.getChargingPileCode());
+ gunQuery.setCharging_gun_code(dto.getChargingGunCode());
+ TChargingGun gun = chargingGunClient.getChargingGunByCode(gunQuery).getData();
+ if (gun == null) {
+ return DeviceStartChargingVO.fail(5);
+ }
+ if (gun.getStatus() != null && gun.getStatus() == GUN_STATUS_CHARGING) {
+ return DeviceStartChargingVO.fail(10);
+ }
+ TChargingPile pile = gun.getChargingPileId() == null ? null
+ : chargingPileClient.getChargingPileById(gun.getChargingPileId()).getData();
+ Site site = gun.getSiteId() == null ? null
+ : Optional.ofNullable(siteClient.getSiteByIds(Collections.singletonList(gun.getSiteId())).getData())
+ .orElse(Collections.emptyList()).stream().findFirst().orElse(null);
+
+ // 2. 卡片鉴权(卡号存在性/禁用/密码条件校验在账户服务内完成)
+ ChargingCardAuthDTO authDTO = new ChargingCardAuthDTO();
+ authDTO.setCardNo(dto.getAccount().trim());
+ authDTO.setPassword(dto.getPassword());
+ R<TChargingCard> verifyResult = chargingCardClient.verifyCharging(authDTO);
+ TChargingCard card = verifyResult.getData();
+ if (card == null) {
+ return DeviceStartChargingVO.fail(mapAuthFailure(verifyResult.getMsg()));
+ }
+ // 3. 启动余额校验(余额>1 元方可启动,与 CD 费用不足终止阈值对齐;结算允许扣成负余额)
+ if (card.getBalance() == null || card.getBalance().compareTo(BigDecimal.ONE) <= 0) {
+ return DeviceStartChargingVO.fail(3);
+ }
+
+ // 并发防护(文档13.2):卡ID维度锁,防同卡多枪并发建单(数据库锁卡条件更新为第二道保护)
+ RLock cardLock = redissonClient.getLock(LOCK_SD_START_CARD + card.getId());
+ boolean cardLocked = false;
+ try {
+ cardLocked = cardLock.tryLock(0, 30, TimeUnit.SECONDS);
+ if (!cardLocked) {
+ return DeviceStartChargingVO.fail(4);
+ }
+ // 4. 建单(状态2 启动中)
+ TCardChargingOrder order = new TCardChargingOrder();
+ order.setCode(ORDER_CODE_PREFIX_SD + Double.valueOf(Math.random() * 1000).intValue() + new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date()));
+ order.setOrderType(2);
+ order.setOrderSource(1);
+ order.setOrderClassification(2);
+ // 充电支付模式(沿用 CD payMethod 枚举:1=余额支付):SD 全程卡余额扣款
+ order.setPayMethod(1);
+ order.setStatus(ORDER_STATUS_STARTING);
+ order.setTitle("【充电桩充电】" + (pile != null && pile.getNumber() != null ? pile.getNumber() : dto.getChargingPileCode())
+ + "号桩/" + dto.getChargingGunCode() + "号枪");
+ order.setSiteId(gun.getSiteId());
+ order.setChargingPileId(gun.getChargingPileId());
+ order.setChargingGunId(gun.getId());
+ order.setCardId(card.getId());
+ order.setCardNo(card.getCardNo());
+ order.setCreateTime(LocalDateTime.now());
+ save(order);
+
+ // 5. 计费策略快照(枪策略优先,站点策略兜底;无策略照常建单,结算按0价并告警)
+ Integer strategyId = gun.getAccountingStrategyId() != null
+ ? gun.getAccountingStrategyId()
+ : (site == null ? null : site.getAccountingStrategyId());
+ try {
+ saveStrategySnapshot(order.getId(), strategyId);
+ } catch (Exception snapshotEx) {
+ log.error("[SD建单] 保存计费策略快照失败 orderId={} strategyId={}", order.getId(), strategyId, snapshotEx);
+ }
+
+ // 6. 锁卡(条件更新:仅空闲卡可锁;失败回滚删除订单与快照)
+ R<Boolean> lockResult = chargingCardClient.lockForCharging(card.getId(), order.getCode());
+ if (!Boolean.TRUE.equals(lockResult.getData())) {
+ log.warn("[SD建单] 锁卡失败回滚订单 code={} cardId={} msg={}", order.getCode(), card.getId(), lockResult.getMsg());
+ cardAccountingStrategyDetailOrderService.remove(
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TCardAccountingStrategyDetailOrder>()
+ .eq(TCardAccountingStrategyDetailOrder::getChargingOrderId, order.getId()));
+ cardAccountingStrategyOrderService.remove(
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TCardAccountingStrategyOrder>()
+ .eq(TCardAccountingStrategyOrder::getChargingOrderId, order.getId()));
+ removeById(order.getId());
+ return DeviceStartChargingVO.fail(4);
+ }
+ log.info("[SD建单] 刷卡充电订单创建成功 code={} cardNo={} gun={}", order.getCode(), card.getCardNo(), dto.getChargingGunCode());
+ return DeviceStartChargingVO.success(order.getCode(), card.getBalance());
+ } finally {
+ if (cardLocked && cardLock.isHeldByCurrentThread()) {
+ cardLock.unlock();
+ }
+ }
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ log.error("[SD建单] 建单锁获取被中断 pile={} gun={}", dto.getChargingPileCode(), dto.getChargingGunCode(), ie);
+ return DeviceStartChargingVO.fail(null);
+ } catch (Exception e) {
+ log.error("[SD建单] 刷卡充电启动异常 pile={} gun={} account={}",
+ dto.getChargingPileCode(), dto.getChargingGunCode(), dto.getAccount(), e);
+ return DeviceStartChargingVO.fail(null);
+ } finally {
+ if (gunLock.isHeldByCurrentThread()) {
+ gunLock.unlock();
+ }
+ }
+ }
+
+ /**
+ * 保存计费策略快照(主表 + 明细表),与 CD 订单快照逻辑一致
+ */
+ private void saveStrategySnapshot(Long orderId, Integer strategyId) {
+ if (strategyId == null) {
+ return;
+ }
+ TAccountingStrategy strategy = accountingStrategyClient.getAccountingStrategyById(strategyId).getData();
+ List<TAccountingStrategyDetail> details = accountingStrategyDetailClient.getListByAccountingStrategyId(strategyId).getData();
+ if (strategy == null || details == null || details.isEmpty()) {
+ return;
+ }
+ TCardAccountingStrategyOrder strategyOrder = new TCardAccountingStrategyOrder();
+ BeanUtils.copyProperties(strategy, strategyOrder);
+ strategyOrder.setId(null);
+ strategyOrder.setChargingOrderId(orderId);
+ cardAccountingStrategyOrderService.save(strategyOrder);
+ List<TCardAccountingStrategyDetailOrder> detailOrders = new ArrayList<>();
+ for (TAccountingStrategyDetail detail : details) {
+ TCardAccountingStrategyDetailOrder detailOrder = new TCardAccountingStrategyDetailOrder();
+ BeanUtils.copyProperties(detail, detailOrder);
+ detailOrder.setId(null);
+ detailOrder.setChargingOrderId(orderId);
+ detailOrders.add(detailOrder);
+ }
+ cardAccountingStrategyDetailOrderService.saveBatch(detailOrders);
+ }
+
+ /**
+ * 账户服务鉴权失败消息 -> 协议失败原因码(1=账户不存在 2=账户冻结 7=密码错误)
+ */
+ private Integer mapAuthFailure(String msg) {
+ if (StringUtils.isNotEmpty(msg)) {
+ if (msg.contains("密码")) {
+ return 7;
+ }
+ if (msg.contains("禁用") || msg.contains("冻结")) {
+ return 2;
+ }
+ if (msg.contains("不存在")) {
+ return 1;
+ }
+ }
+ return 1;
+ }
+
+ @Override
+ public TCardChargingOrder getCardOrderByCode(String code) {
+ if (StringUtils.isEmpty(code)) {
+ return null;
+ }
+ return getOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TCardChargingOrder>()
+ .eq(TCardChargingOrder::getCode, code)
+ .last("limit 1"));
+ }
+
+ @Override
+ public void cardStartChargeSuccessfully(PlatformStartChargingReplyMessageVO vo) {
+ if (vo == null || StringUtils.isEmpty(vo.getTransaction_serial_number())) {
+ return;
+ }
+ TCardChargingOrder order = getCardOrderByCode(vo.getTransaction_serial_number());
+ if (order == null) {
+ return;
+ }
+ // 幂等:已进入充电中/已结束的订单不再处理
+ if (order.getStatus() != null && (order.getStatus() >= ORDER_STATUS_CHARGING)) {
+ return;
+ }
+ TCardChargingOrder update = new TCardChargingOrder();
+ update.setId(order.getId());
+ if (vo.getStartup_result() != null && vo.getStartup_result() == 1) {
+ update.setStatus(ORDER_STATUS_CHARGING);
+ update.setStartTime(LocalDateTime.now());
+ updateById(update);
+ TChargingGun gun = new TChargingGun();
+ gun.setId(order.getChargingGunId());
+ gun.setStatus(GUN_STATUS_CHARGING);
+ chargingGunClient.updateChargingGunById(gun);
+ log.info("[SD启动] 刷卡订单进入充电中 code={}", order.getCode());
+ // 推送监管平台(复用 CD 约定:充电中推平台订单状态 + 成都监管枪状态)
+ order.setStatus(ORDER_STATUS_CHARGING);
+ pushRegulatoryAsync(order, true, false);
+ } else {
+ // 启动失败:订单关闭 + 解锁卡片 + 复位枪
+ update.setStatus(ORDER_STATUS_START_FAILED);
+ update.setEndTime(LocalDateTime.now());
+ updateById(update);
+ unlockCardQuietly(order);
+ resetGunQuietly(order);
+ log.warn("[SD启动] 刷卡订单启动失败关闭 code={} result={}", order.getCode(), vo.getStartup_result());
+ pushRegulatoryAsync(order, false, false);
+ }
+ }
+
+ @Override
+ public void cardChargeMonitoring(UploadRealTimeMonitoringDataQuery query) {
+ if (query == null || StringUtils.isEmpty(query.getTransaction_serial_number())) {
+ return;
+ }
+ TCardChargingOrder order = getCardOrderByCode(query.getTransaction_serial_number());
+ if (order == null) {
+ return;
+ }
+ // 已结束/已关闭的订单不再更新监控数据
+ if (order.getStatus() != null && (order.getStatus() >= ORDER_STATUS_FINISHED)) {
+ return;
+ }
+ TCardChargingOrder update = new TCardChargingOrder();
+ update.setId(order.getId());
+ // 首次监控数据:启动中 -> 充电中
+ if (order.getStatus() == null || order.getStatus() < ORDER_STATUS_CHARGING) {
+ update.setStatus(ORDER_STATUS_CHARGING);
+ if (order.getStartTime() == null) {
+ update.setStartTime(LocalDateTime.now());
+ }
+ TChargingGun gun = new TChargingGun();
+ gun.setId(order.getChargingGunId());
+ gun.setStatus(GUN_STATUS_CHARGING);
+ chargingGunClient.updateChargingGunById(gun);
+ }
+ if (query.getOutput_current() != null && query.getOutput_current().compareTo(BigDecimal.ZERO) != 0
+ && query.getOutput_voltage() != null) {
+ BigDecimal power = query.getOutput_current().multiply(query.getOutput_voltage())
+ .divide(new BigDecimal(1000), 2, BigDecimal.ROUND_HALF_UP);
+ update.setChargingPower(power);
+ update.setCurrent(query.getOutput_current());
+ update.setVoltage(query.getOutput_voltage());
+ update.setPower(power);
+ update.setNeedElec(query.getOutput_current());
+ TChargingGun gun = new TChargingGun();
+ gun.setId(order.getChargingGunId());
+ gun.setChargingPower(power);
+ chargingGunClient.updateChargingGunById(gun);
+ }
+ if (query.getCharging_degree() != null) {
+ update.setChargingCapacity(query.getCharging_degree());
+ update.setElectricity(query.getCharging_degree());
+ }
+ updateById(update);
+ }
+
+ @Override
+ public void cardEndCharge(String orderCode, Integer endMode) {
+ if (StringUtils.isEmpty(orderCode)) {
+ return;
+ }
+ TCardChargingOrder order = getCardOrderByCode(orderCode);
+ if (order == null) {
+ return;
+ }
+ TCardChargingOrder update = new TCardChargingOrder();
+ update.setId(order.getId());
+ update.setEndMode(endMode);
+ updateById(update);
+ pushRegulatoryAsync(order, false, false);
+ }
+
+ @Override
+ public R cardEndChargeBillingCharge(TransactionRecordMessageVO vo) {
+ String code = vo == null ? null : vo.getTransaction_serial_number();
+ log.info("[SD结算] 收到交易记录 code={}", code);
+ if (StringUtils.isEmpty(code)) {
+ return R.fail("交易记录缺少交易流水号");
+ }
+ // 结算锁(文档13.3 统一结算入口第一层):订单号维度,防并发/重复账单同时进入结算;
+ // 抢不到锁说明正在结算,按重复账单语义返回成功;锁内仍有状态幂等与扣款唯一键两层兜底
+ RLock settleLock = redissonClient.getLock(LOCK_SD_SETTLE + code);
+ try {
+ if (!settleLock.tryLock(5, 60, TimeUnit.SECONDS)) {
+ log.warn("[SD结算] 并发结算进行中,跳过重复账单 code={}", code);
+ return R.ok("重复账单,结算处理中");
+ }
+ return doCardEndChargeBillingCharge(vo, code);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ return R.fail("结算锁获取被中断:" + code);
+ } finally {
+ if (settleLock.isHeldByCurrentThread()) {
+ settleLock.unlock();
+ }
+ }
+ }
+
+ /**
+ * SD 结算实际执行体(调用前须持有订单号结算锁)
+ */
+ private R doCardEndChargeBillingCharge(TransactionRecordMessageVO vo, String code) {
+ TCardChargingOrder order = getCardOrderByCode(code);
+ if (order == null) {
+ return R.fail("刷卡充电订单不存在:" + code);
+ }
+ // 幂等:已结算订单直接返回成功(重复账单/重复推送)
+ if (order.getStatus() != null && order.getStatus() == ORDER_STATUS_FINISHED) {
+ log.warn("[SD结算] 重复账单跳过 code={}", code);
+ return R.ok("重复账单,已按首次账单结算");
+ }
+ if (order.getStatus() != null && order.getStatus() == ORDER_STATUS_START_FAILED) {
+ return R.ok("订单已关闭,跳过结算");
+ }
+
+ // 分时电量(以平台快照计价,V0.2 定稿)
+ Map<Integer, BigDecimal> stageElectricity = TransactionStageParser.parseStageElectricity(vo);
+ BigDecimal totalElectricity = stageElectricity.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
+ if (totalElectricity.compareTo(BigDecimal.ZERO) <= 0 && vo.getTotal_electricity() != null) {
+ totalElectricity = vo.getTotal_electricity();
+ }
+
+ // 快照明细(1=尖 2=峰 3=平 4=谷)
+ Map<Integer, TCardAccountingStrategyDetailOrder> snapshotByType = cardAccountingStrategyDetailOrderService
+ .lambdaQuery().eq(TCardAccountingStrategyDetailOrder::getChargingOrderId, order.getId())
+ .list().stream()
+ .filter(item -> item.getType() != null)
+ .collect(Collectors.toMap(TCardAccountingStrategyDetailOrder::getType, Function.identity(), (a, b) -> a));
+
+ BigDecimal electrovalenceTotal = BigDecimal.ZERO;
+ BigDecimal serviceChargeTotal = BigDecimal.ZERO;
+ List<TCardChargingOrderAccountingStrategy> details = new ArrayList<>();
+ for (Map.Entry<Integer, BigDecimal> stage : stageElectricity.entrySet()) {
+ TCardAccountingStrategyDetailOrder snapshot = snapshotByType.get(stage.getKey());
+ BigDecimal capacity = stage.getValue();
+ BigDecimal electricPrice = snapshot == null || snapshot.getElectrovalence() == null ? BigDecimal.ZERO : snapshot.getElectrovalence();
+ BigDecimal servicePrice = snapshot == null || snapshot.getServiceCharge() == null ? BigDecimal.ZERO : snapshot.getServiceCharge();
+ BigDecimal originalServicePrice = snapshot == null || snapshot.getCostServiceCharge() == null
+ ? BigDecimal.ZERO : snapshot.getCostServiceCharge();
+ BigDecimal electricAmount = capacity.multiply(electricPrice).setScale(2, BigDecimal.ROUND_HALF_UP);
+ BigDecimal serviceAmount = capacity.multiply(servicePrice).setScale(2, BigDecimal.ROUND_HALF_UP);
+ BigDecimal originalServiceAmount = capacity.multiply(originalServicePrice).setScale(2, BigDecimal.ROUND_HALF_UP);
+ electrovalenceTotal = electrovalenceTotal.add(electricAmount);
+ serviceChargeTotal = serviceChargeTotal.add(serviceAmount);
+
+ TCardChargingOrderAccountingStrategy detail = new TCardChargingOrderAccountingStrategy();
+ detail.setChargingOrderId(order.getId());
+ detail.setType(stage.getKey());
+ detail.setElectrovalence(electricPrice);
+ detail.setServiceCharge(servicePrice);
+ detail.setCostServiceCharge(originalServicePrice);
+ detail.setChargingCapacity(capacity);
+ detail.setPeriodElectricPrice(electricAmount);
+ detail.setPeriodServicePrice(serviceAmount);
+ detail.setPeriodOriginalServicePrice(originalServiceAmount);
+ if (snapshot != null) {
+ detail.setStartTime(snapshot.getStartTime());
+ detail.setEndTime(snapshot.getEndTime());
+ }
+ details.add(detail);
+ }
+ if (snapshotByType.isEmpty()) {
+ log.error("[SD结算] 订单无计费策略快照,按0价结算并需人工核对 code={} totalElectricity={}", code, totalElectricity);
+ }
+ BigDecimal orderAmount = electrovalenceTotal.add(serviceChargeTotal);
+ // lambda 引用要求有效final,拷贝最终值
+ final BigDecimal totalElec = totalElectricity;
+ final BigDecimal elecTotal = electrovalenceTotal;
+ final BigDecimal svcTotal = serviceChargeTotal;
+
+ // 本地事务:写计费明细 + 更新订单(状态5,允许负余额金额记录)
+ transactionTemplate.execute(status -> {
+ cardChargingOrderAccountingStrategyService.lambdaUpdate()
+ .eq(TCardChargingOrderAccountingStrategy::getChargingOrderId, order.getId())
+ .remove();
+ if (!details.isEmpty()) {
+ cardChargingOrderAccountingStrategyService.saveBatch(details);
+ }
+ TCardChargingOrder update = new TCardChargingOrder();
+ update.setId(order.getId());
+ update.setTotalElectricity(totalElec);
+ update.setChargingCapacity(totalElec);
+ update.setElectrovalence(elecTotal);
+ update.setServiceCharge(svcTotal);
+ update.setOrderAmount(orderAmount);
+ update.setPaymentAmount(orderAmount);
+ update.setEndTime(LocalDateTime.now());
+ update.setStatus(ORDER_STATUS_FINISHED);
+ if (order.getEndMode() == null) {
+ update.setEndMode(1);
+ }
+ updateById(update);
+ return true;
+ });
+
+ // 卡账户扣款(订单号幂等键;允许扣成负余额;失败欠费记账不回滚订单)
+ if (orderAmount.compareTo(BigDecimal.ZERO) > 0 && order.getCardId() != null) {
+ ChargingCardDeductDTO deductDTO = new ChargingCardDeductDTO();
+ deductDTO.setCardId(order.getCardId());
+ deductDTO.setOrderCode(order.getCode());
+ deductDTO.setAmount(orderAmount);
+ R<ChargingCardDeductVO> deductResult = chargingCardClient.deductForCharging(deductDTO);
+ if (deductResult != null && deductResult.getData() != null
+ && deductResult.getData().isDuplicated()) {
+ log.warn("[SD结算] 扣款幂等命中(重复请求不重复扣)code={}", code);
+ } else if (deductResult == null || deductResult.getCode() != R.SUCCESS) {
+ log.error("[SD结算] 扣款失败,欠费记账 code={} cardId={} amount={} msg={}",
+ code, order.getCardId(), orderAmount, deductResult == null ? "null" : deductResult.getMsg());
+ }
+ }
+
+ // 解锁卡片(无论扣款成败必须解锁,失败人工介入)
+ unlockCardQuietly(order);
+ // 复位充电枪
+ resetGunQuietly(order);
+ // 推送成都监管(结算完成推枪状态 + 订单信息,与 CD 结算链路一致)
+ pushRegulatoryAsync(order, false, true);
+ log.info("[SD结算] 完成 code={} totalElectricity={} electrovalence={} serviceCharge={} amount={}",
+ code, totalElectricity, electrovalenceTotal, serviceChargeTotal, orderAmount);
+ return R.ok();
+ }
+
+ /**
+ * 异步推送监管平台(复用 CD 约定:充电中推平台订单状态 pushOrderStatus、成都监管推枪状态
+ * notificationConnectorChargeStatus / 结算完成推订单信息 notificationOrderInfo);
+ * 推送失败仅告警不阻断主流程;单元测试环境 client 未注入时静默跳过
+ */
+ private void pushRegulatoryAsync(TCardChargingOrder order, boolean pushOrderStatus, boolean pushOrderInfo) {
+ if (chargingMessageClient == null && chengDuJianGuanClient == null) {
+ return;
+ }
+ new Thread(() -> {
+ try {
+ if (pushOrderStatus && chargingMessageClient != null) {
+ ChargingOrderVo chargingOrderVo = new ChargingOrderVo();
+ // 与 CD 一致使用 Spring BeanUtils(本类 ruoyi BeanUtils 同名,故用全限定名)
+ org.springframework.beans.BeanUtils.copyProperties(order, chargingOrderVo);
+ chargingMessageClient.pushOrderStatus(chargingOrderVo);
+ }
+ if (chengDuJianGuanClient != null) {
+ NotificationConnectorChargeStatusVo statusVo = new NotificationConnectorChargeStatusVo();
+ statusVo.setOrderCode(order.getCode());
+ statusVo.setChargingGunId(order.getChargingGunId());
+ chengDuJianGuanClient.notificationConnectorChargeStatus(statusVo);
+ if (pushOrderInfo) {
+ chengDuJianGuanClient.notificationOrderInfo(statusVo);
+ }
+ }
+ } catch (Exception ex) {
+ log.error("[SD监管] 推送监管平台失败 code={}", order.getCode(), ex);
+ }
+ }, "SD-Regulatory-Push").start();
+ }
+
+ /**
+ * 解锁卡片(按订单号条件释放,失败仅告警不阻断结算流程)
+ */
+ private void unlockCardQuietly(TCardChargingOrder order) {
+ if (order.getCardId() == null) {
+ return;
+ }
+ try {
+ R<Boolean> unlockResult = chargingCardClient.unlockForCharging(order.getCardId(), order.getCode());
+ if (!Boolean.TRUE.equals(unlockResult.getData())) {
+ log.error("[SD结算] 解锁卡片失败,需人工处理 code={} cardId={} msg={}",
+ order.getCode(), order.getCardId(), unlockResult.getMsg());
+ }
+ } catch (Exception ex) {
+ log.error("[SD结算] 解锁卡片异常 code={} cardId={}", order.getCode(), order.getCardId(), ex);
+ }
+ }
+
+ /**
+ * 复位充电枪为空闲(失败仅告警)
+ */
+ private void resetGunQuietly(TCardChargingOrder order) {
+ if (order.getChargingGunId() == null) {
+ return;
+ }
+ try {
+ TChargingGun gun = chargingGunClient.getChargingGunById(order.getChargingGunId()).getData();
+ if (gun != null) {
+ gun.setStatus(GUN_STATUS_IDLE);
+ chargingGunClient.updateChargingGunById(gun);
+ }
+ } catch (Exception ex) {
+ log.error("[SD结算] 复位充电枪异常 code={} gunId={}", order.getCode(), order.getChargingGunId(), ex);
+ }
+ }
+
+ // ===== 定时补偿(文档13.3:异常断电/补账场景,走统一结算入口) =====
+
+ @Override
+ public List<TCardChargingOrder> listTimeoutStartingOrders() {
+ return lambdaQuery()
+ .eq(TCardChargingOrder::getStatus, ORDER_STATUS_STARTING)
+ .lt(TCardChargingOrder::getCreateTime, LocalDateTime.now().minusMinutes(SD_STARTING_TIMEOUT_MINUTES))
+ .list();
+ }
+
+ @Override
+ public List<TCardChargingOrder> listTimeoutChargingOrders() {
+ return lambdaQuery()
+ .eq(TCardChargingOrder::getStatus, ORDER_STATUS_CHARGING)
+ .lt(TCardChargingOrder::getCreateTime, LocalDateTime.now().minusHours(SD_CHARGING_TIMEOUT_HOURS))
+ .list();
+ }
+
+ @Override
+ public void closeTimeoutStartingOrder(TCardChargingOrder order) {
+ TCardChargingOrder update = new TCardChargingOrder();
+ update.setId(order.getId());
+ update.setStatus(ORDER_STATUS_START_FAILED);
+ // 异常终止(沿用 CD 约定:启动失败/异常关单 endMode=0,避免前端结束方式显示空)
+ update.setEndMode(0);
+ update.setEndTime(LocalDateTime.now());
+ updateById(update);
+ unlockCardQuietly(order);
+ resetGunQuietly(order);
+ pushRegulatoryAsync(order, false, false);
+ log.warn("[SD补偿] 启动超时关单 code={} cardId={} gunId={}", order.getCode(), order.getCardId(), order.getChargingGunId());
+ }
+
+ @Override
+ public void compensationSettle(TCardChargingOrder order) {
+ // 无设备交易记录:以订单累计电量构造全平段账单,复用统一结算入口
+ // (结算锁/状态幂等/扣款幂等/解锁/复位全部复用,满足文档13.3"所有路径汇聚同一结算入口")
+ BigDecimal capacity = order.getChargingCapacity() != null ? order.getChargingCapacity()
+ : Optional.ofNullable(order.getElectricity()).orElse(BigDecimal.ZERO);
+ TransactionRecordMessageVO vo = new TransactionRecordMessageVO();
+ vo.setTransaction_serial_number(order.getCode());
+ vo.setFlat_charge1(capacity);
+ vo.setTotal_electricity(capacity);
+ log.warn("[SD补偿] 充电超时兜底结算 code={} 按平段计价 capacity={}", order.getCode(), capacity);
+ cardEndChargeBillingCharge(vo);
+ }
}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinAccountingStrategyDetailOrderServiceImpl.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinAccountingStrategyDetailOrderServiceImpl.java
new file mode 100644
index 0000000..05484f1
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinAccountingStrategyDetailOrderServiceImpl.java
@@ -0,0 +1,30 @@
+package com.ruoyi.order.service.impl;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.order.api.model.TVinAccountingStrategyDetailOrder;
+import com.ruoyi.order.mapper.TVinAccountingStrategyDetailOrderMapper;
+import com.ruoyi.order.service.TVinAccountingStrategyDetailOrderService;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * VIN码充电订单计费策略明细快照表服务实现
+ *
+ * @author codebuddy
+ * @since 2026-09-09
+ */
+@Service
+public class TVinAccountingStrategyDetailOrderServiceImpl extends ServiceImpl<TVinAccountingStrategyDetailOrderMapper, TVinAccountingStrategyDetailOrder> implements TVinAccountingStrategyDetailOrderService {
+
+ @Override
+ public List<TVinAccountingStrategyDetailOrder> listByChargingOrderId(Long chargingOrderId) {
+ if (chargingOrderId == null) {
+ return Collections.emptyList();
+ }
+ return list(Wrappers.lambdaQuery(TVinAccountingStrategyDetailOrder.class)
+ .eq(TVinAccountingStrategyDetailOrder::getChargingOrderId, chargingOrderId));
+ }
+}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinAccountingStrategyOrderServiceImpl.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinAccountingStrategyOrderServiceImpl.java
new file mode 100644
index 0000000..16e3245
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinAccountingStrategyOrderServiceImpl.java
@@ -0,0 +1,17 @@
+package com.ruoyi.order.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.order.api.model.TVinAccountingStrategyOrder;
+import com.ruoyi.order.mapper.TVinAccountingStrategyOrderMapper;
+import com.ruoyi.order.service.TVinAccountingStrategyOrderService;
+import org.springframework.stereotype.Service;
+
+/**
+ * VIN码充电订单计费策略快照主表服务实现
+ *
+ * @author codebuddy
+ * @since 2026-09-09
+ */
+@Service
+public class TVinAccountingStrategyOrderServiceImpl extends ServiceImpl<TVinAccountingStrategyOrderMapper, TVinAccountingStrategyOrder> implements TVinAccountingStrategyOrderService {
+}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderAccountingStrategyServiceImpl.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderAccountingStrategyServiceImpl.java
new file mode 100644
index 0000000..06a8eaa
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderAccountingStrategyServiceImpl.java
@@ -0,0 +1,17 @@
+package com.ruoyi.order.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.order.api.model.TVinChargingOrderAccountingStrategy;
+import com.ruoyi.order.mapper.TVinChargingOrderAccountingStrategyMapper;
+import com.ruoyi.order.service.TVinChargingOrderAccountingStrategyService;
+import org.springframework.stereotype.Service;
+
+/**
+ * VIN码充电订单计费明细表服务实现
+ *
+ * @author codebuddy
+ * @since 2026-09-09
+ */
+@Service
+public class TVinChargingOrderAccountingStrategyServiceImpl extends ServiceImpl<TVinChargingOrderAccountingStrategyMapper, TVinChargingOrderAccountingStrategy> implements TVinChargingOrderAccountingStrategyService {
+}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java
index 2b10759..325373e 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java
@@ -2,29 +2,60 @@
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.api.feignClient.VinManagementClient;
+import com.ruoyi.account.api.model.TVinManagement;
import com.ruoyi.chargingPile.api.dto.GetSiteListDTO;
+import com.ruoyi.chargingPile.api.feignClient.AccountingStrategyClient;
+import com.ruoyi.chargingPile.api.feignClient.AccountingStrategyDetailClient;
import com.ruoyi.chargingPile.api.feignClient.ChargingGunClient;
import com.ruoyi.chargingPile.api.feignClient.ChargingPileClient;
import com.ruoyi.chargingPile.api.feignClient.SiteClient;
import com.ruoyi.chargingPile.api.model.Site;
+import com.ruoyi.chargingPile.api.model.TAccountingStrategy;
+import com.ruoyi.chargingPile.api.model.TAccountingStrategyDetail;
import com.ruoyi.chargingPile.api.model.TChargingGun;
import com.ruoyi.chargingPile.api.model.TChargingPile;
+import com.ruoyi.chargingPile.api.vo.GetChargingGunByCode;
import com.ruoyi.common.core.domain.R;
+import com.ruoyi.common.core.utils.DateUtils;
+import com.ruoyi.common.core.utils.StringUtils;
+import com.ruoyi.common.core.utils.bean.BeanUtils;
import com.ruoyi.common.core.web.domain.AjaxResult;
import com.ruoyi.common.core.web.page.PageInfo;
import com.ruoyi.common.security.service.TokenService;
+import com.ruoyi.integration.api.feignClient.ChargingMessageClient;
+import com.ruoyi.integration.api.feignClient.ChengDuJianGuanClient;
import com.ruoyi.integration.api.feignClient.SendMessageClient;
import com.ruoyi.integration.api.model.PlatformStopCharging;
import com.ruoyi.integration.api.model.UploadRealTimeMonitoringData;
+import com.ruoyi.integration.api.vo.ChargingOrderVo;
+import com.ruoyi.integration.api.vo.NotificationConnectorChargeStatusVo;
+import com.ruoyi.order.api.dto.DeviceStartChargingDTO;
+import com.ruoyi.order.api.model.TVinAccountingStrategyDetailOrder;
+import com.ruoyi.order.api.model.TVinAccountingStrategyOrder;
import com.ruoyi.order.api.model.TVinChargingOrder;
+import com.ruoyi.order.api.model.TVinChargingOrderAccountingStrategy;
+import com.ruoyi.order.api.query.UploadRealTimeMonitoringDataQuery;
import com.ruoyi.order.api.query.VinChargingOrderQuery;
import com.ruoyi.order.api.vo.ChargingOrderVO;
+import com.ruoyi.order.api.vo.DeviceStartChargingVO;
+import com.ruoyi.order.api.vo.PlatformStartChargingReplyMessageVO;
+import com.ruoyi.order.api.vo.PlatformStopChargingReplyVO;
+import com.ruoyi.order.api.vo.TransactionRecordMessageVO;
import com.ruoyi.order.api.vo.VinChargingStatusVO;
import com.ruoyi.order.dto.MyVinChargingOrderList;
import com.ruoyi.order.mapper.TVinChargingOrderMapper;
+import com.ruoyi.order.service.TVinAccountingStrategyDetailOrderService;
+import com.ruoyi.order.service.TVinAccountingStrategyOrderService;
+import com.ruoyi.order.service.TVinChargingOrderAccountingStrategyService;
import com.ruoyi.order.service.TVinChargingOrderService;
+import com.ruoyi.order.util.TransactionStageParser;
import com.ruoyi.order.util.mongodb.service.UploadRealTimeMonitoringDataService;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionTemplate;
import javax.annotation.Resource;
import java.math.BigDecimal;
@@ -36,19 +67,54 @@
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
+import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Service
public class TVinChargingOrderServiceImpl extends ServiceImpl<TVinChargingOrderMapper, TVinChargingOrder>
implements TVinChargingOrderService {
+ private static final Logger log = LoggerFactory.getLogger(TVinChargingOrderServiceImpl.class);
+
+ /** VD 订单号前缀(设备VIN码充电) */
+ private static final String ORDER_CODE_PREFIX_VD = "VD";
+ /** 订单状态:启动中 */
+ private static final int ORDER_STATUS_STARTING = 2;
+ /** 订单状态:充电中 */
+ private static final int ORDER_STATUS_CHARGING = 3;
+ /** 订单状态:停止中 */
+ private static final int ORDER_STATUS_STOPPING = 4;
+ /** 订单状态:已结束 */
+ private static final int ORDER_STATUS_FINISHED = 5;
+ /** 订单状态:启动失败(沿用 t_charging_order 枚举约定:-1 为负值语义,前端按 status<0 渲染) */
+ private static final int ORDER_STATUS_START_FAILED = -1;
+ /** 充电枪状态:充电中 */
+ private static final int GUN_STATUS_CHARGING = 4;
+ /** 充电枪状态:空闲 */
+ private static final int GUN_STATUS_IDLE = 2;
+ /** VD 建单枪锁前缀(桩+枪维度,防同枪并发建单,文档13.2) */
+ private static final String LOCK_VD_START_GUN = "VD_START_GUN:";
+ /** VD 建单VIN锁前缀(VIN ID维度,防同VIN多枪并发启动,文档13.2) */
+ private static final String LOCK_VD_START_VIN = "VD_START_VIN:";
+ /** VD 结算锁前缀(订单号维度,防并发重复结算,文档13.3) */
+ private static final String LOCK_VD_SETTLE = "VD_SETTLE:";
+ /** VD 启动中超时阈值(分钟):超时未收到启动成功应答则关单 */
+ private static final int VD_STARTING_TIMEOUT_MINUTES = 30;
+ /** VD 充电中超时阈值(小时):超时未收到交易记录则兜底结算 */
+ private static final int VD_CHARGING_TIMEOUT_HOURS = 24;
+
@Resource
private TokenService tokenService;
+ @Resource
+ private ChargingMessageClient chargingMessageClient;
+ @Resource
+ private ChengDuJianGuanClient chengDuJianGuanClient;
@Resource
private VinManagementClient vinManagementClient;
@Resource
@@ -61,6 +127,20 @@
private SendMessageClient sendMessageClient;
@Resource
private UploadRealTimeMonitoringDataService monitoringDataService;
+ @Resource
+ private AccountingStrategyClient accountingStrategyClient;
+ @Resource
+ private AccountingStrategyDetailClient accountingStrategyDetailClient;
+ @Resource
+ private TVinAccountingStrategyOrderService vinAccountingStrategyOrderService;
+ @Resource
+ private TVinAccountingStrategyDetailOrderService vinAccountingStrategyDetailOrderService;
+ @Resource
+ private TVinChargingOrderAccountingStrategyService vinChargingOrderAccountingStrategyService;
+ @Resource
+ private TransactionTemplate transactionTemplate;
+ @Resource
+ private RedissonClient redissonClient;
@Override
public PageInfo<ChargingOrderVO> pageList(VinChargingOrderQuery query) {
@@ -460,4 +540,506 @@
item.setChargePower(power);
}
}
+
+ // ===== 设备侧VIN码充电链路(V0.2 VIN码充电) =====
+
+ @Override
+ public DeviceStartChargingVO startVinCharging(DeviceStartChargingDTO dto) {
+ if (dto == null || StringUtils.isEmpty(dto.getChargingPileCode())
+ || StringUtils.isEmpty(dto.getChargingGunCode()) || StringUtils.isEmpty(dto.getAccount())) {
+ return DeviceStartChargingVO.fail(9);
+ }
+ // 并发防护(文档13.2):桩+枪维度分布式锁,抢不到说明同枪并发启动,快速失败
+ RLock gunLock = redissonClient.getLock(LOCK_VD_START_GUN + dto.getChargingPileCode() + ":" + dto.getChargingGunCode());
+ try {
+ if (!gunLock.tryLock(0, 30, TimeUnit.SECONDS)) {
+ return DeviceStartChargingVO.fail(10);
+ }
+ // 1. 充电枪校验
+ GetChargingGunByCode gunQuery = new GetChargingGunByCode();
+ gunQuery.setCharging_pile_code(dto.getChargingPileCode());
+ gunQuery.setCharging_gun_code(dto.getChargingGunCode());
+ TChargingGun gun = chargingGunClient.getChargingGunByCode(gunQuery).getData();
+ if (gun == null) {
+ return DeviceStartChargingVO.fail(5);
+ }
+ if (gun.getStatus() != null && gun.getStatus() == GUN_STATUS_CHARGING) {
+ return DeviceStartChargingVO.fail(10);
+ }
+ TChargingPile pile = gun.getChargingPileId() == null ? null
+ : chargingPileClient.getChargingPileById(gun.getChargingPileId()).getData();
+ Site site = gun.getSiteId() == null ? null
+ : Optional.ofNullable(siteClient.getSiteByIds(Collections.singletonList(gun.getSiteId())).getData())
+ .orElse(Collections.emptyList()).stream().findFirst().orElse(null);
+
+ // 2. VIN反序解析与绑定校验(协议要求VIN码反序上送)
+ String vin = new StringBuilder(dto.getAccount().trim()).reverse().toString();
+ TVinManagement vinRecord = vinManagementClient.getVinByCode(vin).getData();
+ if (vinRecord == null) {
+ return DeviceStartChargingVO.fail(9);
+ }
+ if (vinRecord.getStatus() == null || vinRecord.getStatus() != 1) {
+ return DeviceStartChargingVO.fail(2);
+ }
+
+ // 并发防护(文档13.2):VIN ID维度锁,防同VIN多枪并发建单
+ RLock vinLock = redissonClient.getLock(LOCK_VD_START_VIN + vinRecord.getId());
+ boolean vinLocked = false;
+ try {
+ vinLocked = vinLock.tryLock(0, 30, TimeUnit.SECONDS);
+ if (!vinLocked) {
+ return DeviceStartChargingVO.fail(10);
+ }
+ // 3. 建单(状态2 启动中)
+ TVinChargingOrder order = new TVinChargingOrder();
+ order.setCode(ORDER_CODE_PREFIX_VD + Double.valueOf(Math.random() * 1000).intValue() + new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date()));
+ order.setOrderType(2);
+ order.setOrderSource(1);
+ order.setOrderClassification(2);
+ // VD 无支付动作(后付记账模式,不执行在线支付/扣款):payMethod 留空,
+ // 不套用 CD 余额支付枚举,避免下游按支付模式误判走余额扣款逻辑
+ order.setStatus(ORDER_STATUS_STARTING);
+ order.setTitle("【充电桩充电】" + (pile != null && pile.getNumber() != null ? pile.getNumber() : dto.getChargingPileCode())
+ + "号桩/" + dto.getChargingGunCode() + "号枪");
+ order.setSiteId(gun.getSiteId());
+ order.setChargingPileId(gun.getChargingPileId());
+ order.setChargingGunId(gun.getId());
+ order.setVinId(vinRecord.getId());
+ order.setAppUserId(vinRecord.getAppUserId());
+ order.setCreateTime(LocalDateTime.now());
+ save(order);
+
+ // 4. 计费策略快照(枪策略优先,站点策略兜底)
+ Integer strategyId = gun.getAccountingStrategyId() != null
+ ? gun.getAccountingStrategyId()
+ : (site == null ? null : site.getAccountingStrategyId());
+ try {
+ saveStrategySnapshot(order.getId(), strategyId);
+ } catch (Exception snapshotEx) {
+ log.error("[VD建单] 保存计费策略快照失败 orderId={} strategyId={}", order.getId(), strategyId, snapshotEx);
+ }
+ log.info("[VD建单] VIN码充电订单创建成功 code={} vinId={} gun={}", order.getCode(), vinRecord.getId(), dto.getChargingGunCode());
+ return DeviceStartChargingVO.success(order.getCode(), null);
+ } finally {
+ if (vinLocked && vinLock.isHeldByCurrentThread()) {
+ vinLock.unlock();
+ }
+ }
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ log.error("[VD建单] 建单锁获取被中断 pile={} gun={}", dto.getChargingPileCode(), dto.getChargingGunCode(), ie);
+ return DeviceStartChargingVO.fail(null);
+ } catch (Exception e) {
+ log.error("[VD建单] VIN码充电启动异常 pile={} gun={} account={}",
+ dto.getChargingPileCode(), dto.getChargingGunCode(), dto.getAccount(), e);
+ return DeviceStartChargingVO.fail(null);
+ } finally {
+ if (gunLock.isHeldByCurrentThread()) {
+ gunLock.unlock();
+ }
+ }
+ }
+
+ /**
+ * 保存VIN订单计费策略快照(主表 + 明细表)
+ */
+ private void saveStrategySnapshot(Long orderId, Integer strategyId) {
+ if (strategyId == null) {
+ return;
+ }
+ TAccountingStrategy strategy = accountingStrategyClient.getAccountingStrategyById(strategyId).getData();
+ List<TAccountingStrategyDetail> details = accountingStrategyDetailClient.getListByAccountingStrategyId(strategyId).getData();
+ if (strategy == null || details == null || details.isEmpty()) {
+ return;
+ }
+ TVinAccountingStrategyOrder strategyOrder = new TVinAccountingStrategyOrder();
+ BeanUtils.copyProperties(strategy, strategyOrder);
+ strategyOrder.setId(null);
+ strategyOrder.setChargingOrderId(orderId);
+ vinAccountingStrategyOrderService.save(strategyOrder);
+ List<TVinAccountingStrategyDetailOrder> detailOrders = new ArrayList<>();
+ for (TAccountingStrategyDetail detail : details) {
+ TVinAccountingStrategyDetailOrder detailOrder = new TVinAccountingStrategyDetailOrder();
+ BeanUtils.copyProperties(detail, detailOrder);
+ detailOrder.setId(null);
+ detailOrder.setChargingOrderId(orderId);
+ detailOrders.add(detailOrder);
+ }
+ vinAccountingStrategyDetailOrderService.saveBatch(detailOrders);
+ }
+
+ @Override
+ public TVinChargingOrder getVinOrderByCode(String code) {
+ if (StringUtils.isEmpty(code)) {
+ return null;
+ }
+ return getOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TVinChargingOrder>()
+ .eq(TVinChargingOrder::getCode, code)
+ .last("limit 1"));
+ }
+
+ @Override
+ public void vinStartChargeSuccessfully(PlatformStartChargingReplyMessageVO vo) {
+ if (vo == null || StringUtils.isEmpty(vo.getTransaction_serial_number())) {
+ return;
+ }
+ TVinChargingOrder order = getVinOrderByCode(vo.getTransaction_serial_number());
+ if (order == null) {
+ return;
+ }
+ if (order.getStatus() != null && order.getStatus() >= ORDER_STATUS_CHARGING) {
+ return;
+ }
+ TVinChargingOrder update = new TVinChargingOrder();
+ update.setId(order.getId());
+ if (vo.getStartup_result() != null && vo.getStartup_result() == 1) {
+ update.setStatus(ORDER_STATUS_CHARGING);
+ update.setStartTime(LocalDateTime.now());
+ updateById(update);
+ TChargingGun gun = new TChargingGun();
+ gun.setId(order.getChargingGunId());
+ gun.setStatus(GUN_STATUS_CHARGING);
+ chargingGunClient.updateChargingGunById(gun);
+ log.info("[VD启动] VIN订单进入充电中 code={}", order.getCode());
+ // 推送监管平台(复用 CD 约定:充电中推平台订单状态 + 成都监管枪状态)
+ order.setStatus(ORDER_STATUS_CHARGING);
+ pushRegulatoryAsync(order, true, false);
+ } else {
+ update.setStatus(ORDER_STATUS_START_FAILED);
+ update.setEndTime(LocalDateTime.now());
+ updateById(update);
+ resetGunQuietly(order);
+ log.warn("[VD启动] VIN订单启动失败关闭 code={} result={}", order.getCode(), vo.getStartup_result());
+ pushRegulatoryAsync(order, false, false);
+ }
+ }
+
+ @Override
+ public void vinChargeMonitoring(UploadRealTimeMonitoringDataQuery query) {
+ if (query == null || StringUtils.isEmpty(query.getTransaction_serial_number())) {
+ return;
+ }
+ TVinChargingOrder order = getVinOrderByCode(query.getTransaction_serial_number());
+ if (order == null) {
+ return;
+ }
+ if (order.getStatus() != null && order.getStatus() >= ORDER_STATUS_FINISHED) {
+ return;
+ }
+ TVinChargingOrder update = new TVinChargingOrder();
+ update.setId(order.getId());
+ if (order.getStatus() == null || order.getStatus() < ORDER_STATUS_CHARGING) {
+ update.setStatus(ORDER_STATUS_CHARGING);
+ if (order.getStartTime() == null) {
+ update.setStartTime(LocalDateTime.now());
+ }
+ TChargingGun gun = new TChargingGun();
+ gun.setId(order.getChargingGunId());
+ gun.setStatus(GUN_STATUS_CHARGING);
+ chargingGunClient.updateChargingGunById(gun);
+ }
+ if (query.getOutput_current() != null && query.getOutput_current().compareTo(BigDecimal.ZERO) != 0
+ && query.getOutput_voltage() != null) {
+ BigDecimal power = query.getOutput_current().multiply(query.getOutput_voltage())
+ .divide(new BigDecimal(1000), 2, BigDecimal.ROUND_HALF_UP);
+ update.setChargingPower(power);
+ update.setCurrent(query.getOutput_current());
+ update.setVoltage(query.getOutput_voltage());
+ update.setPower(power);
+ update.setNeedElec(query.getOutput_current());
+ TChargingGun gun = new TChargingGun();
+ gun.setId(order.getChargingGunId());
+ gun.setChargingPower(power);
+ chargingGunClient.updateChargingGunById(gun);
+ }
+ if (query.getCharging_degree() != null) {
+ update.setChargingCapacity(query.getCharging_degree());
+ update.setElectricity(query.getCharging_degree());
+ }
+ updateById(update);
+ }
+
+ @Override
+ public void vinEndCharge(String orderCode, Integer endMode) {
+ if (StringUtils.isEmpty(orderCode)) {
+ return;
+ }
+ TVinChargingOrder order = getVinOrderByCode(orderCode);
+ if (order == null) {
+ return;
+ }
+ TVinChargingOrder update = new TVinChargingOrder();
+ update.setId(order.getId());
+ update.setEndMode(endMode);
+ updateById(update);
+ pushRegulatoryAsync(order, false, false);
+ }
+
+ @Override
+ public R vinEndChargeBillingCharge(TransactionRecordMessageVO vo) {
+ String code = vo == null ? null : vo.getTransaction_serial_number();
+ log.info("[VD结算] 收到交易记录 code={}", code);
+ if (StringUtils.isEmpty(code)) {
+ return R.fail("交易记录缺少交易流水号");
+ }
+ // 结算锁(文档13.3 统一结算入口第一层):订单号维度,防并发/重复账单同时进入结算;
+ // 抢不到锁说明正在结算,按重复账单语义返回成功;锁内仍有状态幂等第二层兜底
+ RLock settleLock = redissonClient.getLock(LOCK_VD_SETTLE + code);
+ try {
+ if (!settleLock.tryLock(5, 60, TimeUnit.SECONDS)) {
+ log.warn("[VD结算] 并发结算进行中,跳过重复账单 code={}", code);
+ return R.ok("重复账单,结算处理中");
+ }
+ return doVinEndChargeBillingCharge(vo, code);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ return R.fail("结算锁获取被中断:" + code);
+ } finally {
+ if (settleLock.isHeldByCurrentThread()) {
+ settleLock.unlock();
+ }
+ }
+ }
+
+ /**
+ * VD 结算实际执行体(调用前须持有订单号结算锁)
+ */
+ private R doVinEndChargeBillingCharge(TransactionRecordMessageVO vo, String code) {
+ TVinChargingOrder order = getVinOrderByCode(code);
+ if (order == null) {
+ return R.fail("VIN码充电订单不存在:" + code);
+ }
+ if (order.getStatus() != null && order.getStatus() == ORDER_STATUS_FINISHED) {
+ log.warn("[VD结算] 重复账单跳过 code={}", code);
+ return R.ok("重复账单,已按首次账单结算");
+ }
+ if (order.getStatus() != null && order.getStatus() == ORDER_STATUS_START_FAILED) {
+ return R.ok("订单已关闭,跳过结算");
+ }
+
+ // 分时电量(以平台快照计价)
+ java.util.Map<Integer, BigDecimal> stageElectricity = TransactionStageParser.parseStageElectricity(vo);
+ BigDecimal totalElectricity = stageElectricity.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
+ if (totalElectricity.compareTo(BigDecimal.ZERO) <= 0 && vo.getTotal_electricity() != null) {
+ totalElectricity = vo.getTotal_electricity();
+ }
+
+ Map<Integer, TVinAccountingStrategyDetailOrder> snapshotByType = vinAccountingStrategyDetailOrderService
+ .lambdaQuery().eq(TVinAccountingStrategyDetailOrder::getChargingOrderId, order.getId())
+ .list().stream()
+ .filter(item -> item.getType() != null)
+ .collect(Collectors.toMap(TVinAccountingStrategyDetailOrder::getType, java.util.function.Function.identity(), (a, b) -> a));
+
+ BigDecimal electrovalenceTotal = BigDecimal.ZERO;
+ BigDecimal serviceChargeTotal = BigDecimal.ZERO;
+ List<TVinChargingOrderAccountingStrategy> details = new ArrayList<>();
+ for (Map.Entry<Integer, BigDecimal> stage : stageElectricity.entrySet()) {
+ TVinAccountingStrategyDetailOrder snapshot = snapshotByType.get(stage.getKey());
+ BigDecimal capacity = stage.getValue();
+ BigDecimal electricPrice = snapshot == null || snapshot.getElectrovalence() == null ? BigDecimal.ZERO : snapshot.getElectrovalence();
+ BigDecimal servicePrice = snapshot == null || snapshot.getServiceCharge() == null ? BigDecimal.ZERO : snapshot.getServiceCharge();
+ BigDecimal originalServicePrice = snapshot == null || snapshot.getCostServiceCharge() == null
+ ? BigDecimal.ZERO : snapshot.getCostServiceCharge();
+ BigDecimal electricAmount = capacity.multiply(electricPrice).setScale(2, BigDecimal.ROUND_HALF_UP);
+ BigDecimal serviceAmount = capacity.multiply(servicePrice).setScale(2, BigDecimal.ROUND_HALF_UP);
+ BigDecimal originalServiceAmount = capacity.multiply(originalServicePrice).setScale(2, BigDecimal.ROUND_HALF_UP);
+ electrovalenceTotal = electrovalenceTotal.add(electricAmount);
+ serviceChargeTotal = serviceChargeTotal.add(serviceAmount);
+
+ TVinChargingOrderAccountingStrategy detail = new TVinChargingOrderAccountingStrategy();
+ detail.setChargingOrderId(order.getId());
+ detail.setType(stage.getKey());
+ detail.setElectrovalence(electricPrice);
+ detail.setServiceCharge(servicePrice);
+ detail.setCostServiceCharge(originalServicePrice);
+ detail.setChargingCapacity(capacity);
+ detail.setPeriodElectricPrice(electricAmount);
+ detail.setPeriodServicePrice(serviceAmount);
+ detail.setPeriodOriginalServicePrice(originalServiceAmount);
+ if (snapshot != null) {
+ detail.setStartTime(snapshot.getStartTime());
+ detail.setEndTime(snapshot.getEndTime());
+ }
+ details.add(detail);
+ }
+ if (snapshotByType.isEmpty()) {
+ log.error("[VD结算] 订单无计费策略快照,按0价结算并需人工核对 code={} totalElectricity={}", code, totalElectricity);
+ }
+ BigDecimal orderAmount = electrovalenceTotal.add(serviceChargeTotal);
+ // lambda 引用要求有效final,拷贝最终值
+ final BigDecimal totalElec = totalElectricity;
+ final BigDecimal elecTotal = electrovalenceTotal;
+ final BigDecimal svcTotal = serviceChargeTotal;
+
+ // 本地事务:写计费明细 + 更新订单(状态5;VD后付记账模式,不做账户扣款)
+ transactionTemplate.execute(status -> {
+ vinChargingOrderAccountingStrategyService.lambdaUpdate()
+ .eq(TVinChargingOrderAccountingStrategy::getChargingOrderId, order.getId())
+ .remove();
+ if (!details.isEmpty()) {
+ vinChargingOrderAccountingStrategyService.saveBatch(details);
+ }
+ TVinChargingOrder update = new TVinChargingOrder();
+ update.setId(order.getId());
+ update.setTotalElectricity(totalElec);
+ update.setChargingCapacity(totalElec);
+ update.setElectrovalence(elecTotal);
+ update.setServiceCharge(svcTotal);
+ update.setOrderAmount(orderAmount);
+ update.setPaymentAmount(orderAmount);
+ update.setEndTime(LocalDateTime.now());
+ update.setStatus(ORDER_STATUS_FINISHED);
+ if (order.getEndMode() == null) {
+ update.setEndMode(1);
+ }
+ updateById(update);
+ return true;
+ });
+
+ // 复位充电枪
+ resetGunQuietly(order);
+ // 推送成都监管(结算完成推枪状态 + 订单信息,与 CD 结算链路一致)
+ pushRegulatoryAsync(order, false, true);
+ log.info("[VD结算] 完成 code={} totalElectricity={} electrovalence={} serviceCharge={} amount={}",
+ code, totalElectricity, electrovalenceTotal, serviceChargeTotal, orderAmount);
+ return R.ok();
+ }
+
+ @Override
+ public TVinChargingOrder getLatestStoppingByGunId(Integer gunId) {
+ if (gunId == null) {
+ return null;
+ }
+ return getOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TVinChargingOrder>()
+ .eq(TVinChargingOrder::getChargingGunId, gunId)
+ .eq(TVinChargingOrder::getStatus, ORDER_STATUS_STOPPING)
+ .orderByDesc(TVinChargingOrder::getId)
+ .last("limit 1"));
+ }
+
+ @Override
+ public void vinTerminateStopByReply(PlatformStopChargingReplyVO vo) {
+ // stopCharging 已前置更新状态4/结束方式1,此处仅兜底确认(幂等)
+ if (vo == null) {
+ return;
+ }
+ TChargingGun gun = null;
+ try {
+ GetChargingGunByCode gunQuery = new GetChargingGunByCode();
+ gunQuery.setCharging_pile_code(vo.getCharging_pile_code());
+ gunQuery.setCharging_gun_code(vo.getCharging_gun_code());
+ gun = chargingGunClient.getChargingGunByCode(gunQuery).getData();
+ } catch (Exception ex) {
+ log.error("[VD停止] 查询充电枪失败 pile={} gun={}", vo.getCharging_pile_code(), vo.getCharging_gun_code(), ex);
+ return;
+ }
+ if (gun == null) {
+ return;
+ }
+ TVinChargingOrder order = getLatestStoppingByGunId(gun.getId());
+ if (order == null) {
+ return;
+ }
+ TVinChargingOrder update = new TVinChargingOrder();
+ update.setId(order.getId());
+ update.setEndMode(1);
+ updateById(update);
+ log.info("[VD停止] 平台停止应答兜底确认 code={}", order.getCode());
+ }
+
+ /**
+ * 异步推送监管平台(复用 CD 约定:充电中推平台订单状态 pushOrderStatus、成都监管推枪状态
+ * notificationConnectorChargeStatus / 结算完成推订单信息 notificationOrderInfo);
+ * 推送失败仅告警不阻断主流程;单元测试环境 client 未注入时静默跳过
+ */
+ private void pushRegulatoryAsync(TVinChargingOrder order, boolean pushOrderStatus, boolean pushOrderInfo) {
+ if (chargingMessageClient == null && chengDuJianGuanClient == null) {
+ return;
+ }
+ new Thread(() -> {
+ try {
+ if (pushOrderStatus && chargingMessageClient != null) {
+ ChargingOrderVo chargingOrderVo = new ChargingOrderVo();
+ // 与 CD 一致使用 Spring BeanUtils(本类 ruoyi BeanUtils 同名,故用全限定名)
+ org.springframework.beans.BeanUtils.copyProperties(order, chargingOrderVo);
+ chargingMessageClient.pushOrderStatus(chargingOrderVo);
+ }
+ if (chengDuJianGuanClient != null) {
+ NotificationConnectorChargeStatusVo statusVo = new NotificationConnectorChargeStatusVo();
+ statusVo.setOrderCode(order.getCode());
+ statusVo.setChargingGunId(order.getChargingGunId());
+ chengDuJianGuanClient.notificationConnectorChargeStatus(statusVo);
+ if (pushOrderInfo) {
+ chengDuJianGuanClient.notificationOrderInfo(statusVo);
+ }
+ }
+ } catch (Exception ex) {
+ log.error("[VD监管] 推送监管平台失败 code={}", order.getCode(), ex);
+ }
+ }, "VD-Regulatory-Push").start();
+ }
+
+ /**
+ * 复位充电枪为空闲(失败仅告警)
+ */
+ private void resetGunQuietly(TVinChargingOrder order) {
+ if (order.getChargingGunId() == null) {
+ return;
+ }
+ try {
+ TChargingGun gun = chargingGunClient.getChargingGunById(order.getChargingGunId()).getData();
+ if (gun != null) {
+ gun.setStatus(GUN_STATUS_IDLE);
+ chargingGunClient.updateChargingGunById(gun);
+ }
+ } catch (Exception ex) {
+ log.error("[VD结算] 复位充电枪异常 code={} gunId={}", order.getCode(), order.getChargingGunId(), ex);
+ }
+ }
+
+ // ===== 定时补偿(文档13.3:异常断电/补账场景,走统一结算入口) =====
+
+ @Override
+ public List<TVinChargingOrder> listTimeoutStartingOrders() {
+ return lambdaQuery()
+ .eq(TVinChargingOrder::getStatus, ORDER_STATUS_STARTING)
+ .lt(TVinChargingOrder::getCreateTime, LocalDateTime.now().minusMinutes(VD_STARTING_TIMEOUT_MINUTES))
+ .list();
+ }
+
+ @Override
+ public List<TVinChargingOrder> listTimeoutChargingOrders() {
+ return lambdaQuery()
+ .in(TVinChargingOrder::getStatus, ORDER_STATUS_CHARGING, ORDER_STATUS_STOPPING)
+ .lt(TVinChargingOrder::getCreateTime, LocalDateTime.now().minusHours(VD_CHARGING_TIMEOUT_HOURS))
+ .list();
+ }
+
+ @Override
+ public void closeTimeoutStartingOrder(TVinChargingOrder order) {
+ TVinChargingOrder update = new TVinChargingOrder();
+ update.setId(order.getId());
+ update.setStatus(ORDER_STATUS_START_FAILED);
+ // 异常终止(沿用 CD 约定:启动失败/异常关单 endMode=0,避免前端结束方式显示空)
+ update.setEndMode(0);
+ update.setEndTime(LocalDateTime.now());
+ updateById(update);
+ resetGunQuietly(order);
+ pushRegulatoryAsync(order, false, false);
+ log.warn("[VD补偿] 启动超时关单 code={} vinId={} gunId={}", order.getCode(), order.getVinId(), order.getChargingGunId());
+ }
+
+ @Override
+ public void compensationSettle(TVinChargingOrder order) {
+ // 无设备交易记录:以订单累计电量构造全平段账单,复用统一结算入口
+ // (结算锁/状态幂等/枪复位全部复用,满足文档13.3"所有路径汇聚同一结算入口")
+ BigDecimal capacity = order.getChargingCapacity() != null ? order.getChargingCapacity()
+ : Optional.ofNullable(order.getElectricity()).orElse(BigDecimal.ZERO);
+ TransactionRecordMessageVO vo = new TransactionRecordMessageVO();
+ vo.setTransaction_serial_number(order.getCode());
+ vo.setFlat_charge1(capacity);
+ vo.setTotal_electricity(capacity);
+ log.warn("[VD补偿] 充电超时兜底结算 code={} 按平段计价 capacity={}", order.getCode(), capacity);
+ vinEndChargeBillingCharge(vo);
+ }
}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/TransactionStageParser.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/TransactionStageParser.java
new file mode 100644
index 0000000..b35e6e9
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/TransactionStageParser.java
@@ -0,0 +1,98 @@
+package com.ruoyi.order.util;
+
+import com.ruoyi.order.api.vo.TransactionRecordMessageVO;
+
+import java.lang.reflect.Field;
+import java.math.BigDecimal;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 设备交易记录(charging_pile_transaction_record)分时电量解析工具。
+ * <p>
+ * 协议中尖/峰/平/谷四类时段电量分组上送(spike_chargeN / peak_chargeN / flat_chargeN / valley_chargeN,N=1..12),
+ * 本工具按阶段类型汇总电量,供订单结算按平台快照单价计费使用。
+ *
+ * @author codebuddy
+ * @since 2026-09-09
+ */
+public final class TransactionStageParser {
+
+ /** 阶段类型(1=尖,2=峰,3=平,4=谷)与协议字段前缀的映射 */
+ private static final Map<Integer, String> TYPE_PREFIX;
+
+ /** 协议单类型最大分组数 */
+ private static final int MAX_GROUPS = 12;
+
+ static {
+ Map<Integer, String> prefix = new HashMap<>(8);
+ prefix.put(1, "spike");
+ prefix.put(2, "peak");
+ prefix.put(3, "flat");
+ prefix.put(4, "valley");
+ TYPE_PREFIX = Collections.unmodifiableMap(prefix);
+ }
+
+ private TransactionStageParser() {
+ }
+
+ /**
+ * 解析交易记录中各阶段(1=尖,2=峰,3=平,4=谷)的电量合计(度)。
+ * 仅返回电量大于 0 的阶段。
+ *
+ * @param vo 设备交易记录
+ * @return 阶段类型 -> 电量合计(无有效数据时返回空Map)
+ */
+ public static Map<Integer, BigDecimal> parseStageElectricity(TransactionRecordMessageVO vo) {
+ Map<Integer, BigDecimal> result = new HashMap<>(8);
+ if (vo == null) {
+ return result;
+ }
+ for (Map.Entry<Integer, String> entry : TYPE_PREFIX.entrySet()) {
+ BigDecimal total = readGroupTotal(vo, entry.getValue());
+ if (total != null && total.compareTo(BigDecimal.ZERO) > 0) {
+ result.put(entry.getKey(), total);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * 读取指定前缀分组的电量合计(spike_charge1..spike_charge12)。
+ */
+ private static BigDecimal readGroupTotal(TransactionRecordMessageVO vo, String prefix) {
+ BigDecimal total = BigDecimal.ZERO;
+ boolean hasValue = false;
+ for (int i = 1; i <= MAX_GROUPS; i++) {
+ BigDecimal value = readField(vo, prefix + "_charge" + i);
+ if (value != null) {
+ hasValue = true;
+ total = total.add(value);
+ }
+ }
+ return hasValue ? total : null;
+ }
+
+ private static BigDecimal readField(TransactionRecordMessageVO vo, String fieldName) {
+ try {
+ Field field = vo.getClass().getDeclaredField(fieldName);
+ field.setAccessible(true);
+ Object value = field.get(vo);
+ if (value instanceof BigDecimal) {
+ return (BigDecimal) value;
+ }
+ if (value instanceof Number) {
+ return new BigDecimal(value.toString());
+ }
+ if (value instanceof String && ((String) value).length() > 0) {
+ return new BigDecimal((String) value);
+ }
+ } catch (NoSuchFieldException ignored) {
+ // 分组不存在说明协议未上送该组,按0处理
+ } catch (IllegalAccessException | NumberFormatException ex) {
+ // 非法数据按0处理,避免结算中断
+ }
+ return null;
+ }
+}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/task/TaskUtil.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/task/TaskUtil.java
index a2b0242..36093a4 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/task/TaskUtil.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/task/TaskUtil.java
@@ -96,6 +96,12 @@
private SiteClient siteClient;
@Resource
private TChargingOrderService chargingOrderService;
+
+ @Resource
+ private TCardChargingOrderService cardChargingOrderService;
+
+ @Resource
+ private TVinChargingOrderService vinChargingOrderService;
@Resource
private TChargingOrderRefundService chargingOrderRefundService;
@@ -283,6 +289,92 @@
}
}
+ /**
+ * 刷卡/VIN 订单超时补偿(文档13.3 定时任务补偿路径,10分钟一轮):
+ * 1) 启动中超时(状态2超30分钟未收到启动成功应答)→ 关单(状态6)+解锁卡片+复位枪;
+ * 2) 充电中超时(状态3/4超24小时未收到交易记录)→ 以订单累计电量构造平段账单,
+ * 复用统一结算入口兜底结算(结算锁/状态幂等/扣款幂等全复用)
+ */
+ @Scheduled(fixedRate = 1000 * 60 * 10, initialDelay = 1000 * 60)
+ public void cardVinOrderCompensationTask() {
+ if (isScheduledTaskPort(port)) {
+ compensateCardOrders();
+ compensateVinOrders();
+ }
+ }
+
+ /**
+ * SD 刷卡订单超时补偿
+ */
+ private void compensateCardOrders() {
+ try {
+ List<TCardChargingOrder> startingOrders = cardChargingOrderService.listTimeoutStartingOrders();
+ if (!startingOrders.isEmpty()) {
+ log.info("[SD补偿] 启动超时订单数量:{}", startingOrders.size());
+ for (TCardChargingOrder order : startingOrders) {
+ try {
+ cardChargingOrderService.closeTimeoutStartingOrder(order);
+ } catch (Exception ex) {
+ log.error("[SD补偿] 启动超时关单失败 code={}", order.getCode(), ex);
+ }
+ }
+ }
+ } catch (Exception e) {
+ log.error("[SD补偿] 启动超时扫描异常", e);
+ }
+ try {
+ List<TCardChargingOrder> chargingOrders = cardChargingOrderService.listTimeoutChargingOrders();
+ if (!chargingOrders.isEmpty()) {
+ log.info("[SD补偿] 充电超时订单数量:{}", chargingOrders.size());
+ for (TCardChargingOrder order : chargingOrders) {
+ try {
+ cardChargingOrderService.compensationSettle(order);
+ } catch (Exception ex) {
+ log.error("[SD补偿] 充电超时兜底结算失败 code={}", order.getCode(), ex);
+ }
+ }
+ }
+ } catch (Exception e) {
+ log.error("[SD补偿] 充电超时扫描异常", e);
+ }
+ }
+
+ /**
+ * VD VIN码订单超时补偿
+ */
+ private void compensateVinOrders() {
+ try {
+ List<TVinChargingOrder> startingOrders = vinChargingOrderService.listTimeoutStartingOrders();
+ if (!startingOrders.isEmpty()) {
+ log.info("[VD补偿] 启动超时订单数量:{}", startingOrders.size());
+ for (TVinChargingOrder order : startingOrders) {
+ try {
+ vinChargingOrderService.closeTimeoutStartingOrder(order);
+ } catch (Exception ex) {
+ log.error("[VD补偿] 启动超时关单失败 code={}", order.getCode(), ex);
+ }
+ }
+ }
+ } catch (Exception e) {
+ log.error("[VD补偿] 启动超时扫描异常", e);
+ }
+ try {
+ List<TVinChargingOrder> chargingOrders = vinChargingOrderService.listTimeoutChargingOrders();
+ if (!chargingOrders.isEmpty()) {
+ log.info("[VD补偿] 充电超时订单数量:{}", chargingOrders.size());
+ for (TVinChargingOrder order : chargingOrders) {
+ try {
+ vinChargingOrderService.compensationSettle(order);
+ } catch (Exception ex) {
+ log.error("[VD补偿] 充电超时兜底结算失败 code={}", order.getCode(), ex);
+ }
+ }
+ }
+ } catch (Exception e) {
+ log.error("[VD补偿] 充电超时扫描异常", e);
+ }
+ }
+
//十分钟循环执行的定时任务
@Scheduled(fixedRate = 1000 * 60 * 10)
diff --git a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/CardVinOrderCompensationTest.java b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/CardVinOrderCompensationTest.java
new file mode 100644
index 0000000..cb879bc
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/CardVinOrderCompensationTest.java
@@ -0,0 +1,213 @@
+package com.ruoyi.order.service.impl;
+
+import com.ruoyi.account.api.feignClient.ChargingCardClient;
+import com.ruoyi.chargingPile.api.feignClient.ChargingGunClient;
+import com.ruoyi.chargingPile.api.model.TChargingGun;
+import com.ruoyi.common.core.domain.R;
+import com.ruoyi.order.api.model.TCardChargingOrder;
+import com.ruoyi.order.api.model.TVinChargingOrder;
+import com.ruoyi.order.api.vo.TransactionRecordMessageVO;
+import com.ruoyi.order.mapper.TCardChargingOrderMapper;
+import com.ruoyi.order.mapper.TVinChargingOrderMapper;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Collections;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * SD/VD 定时补偿与结算锁行为测试(文档13.2/13.3):
+ * 1) 启动超时关单(状态-1+结束方式0+解锁卡片+复位枪,沿用 t_charging_order 枚举约定);
+ * 2) 结算入口并发锁抢占失败快速返回,不触达订单数据;
+ * 3) 已结算订单重复账单幂等跳过,不再扣款/解锁。
+ */
+class CardVinOrderCompensationTest {
+
+ @SuppressWarnings("unchecked")
+ private R<Boolean> okBoolean() {
+ R<Boolean> result = mock(R.class);
+ when(result.getData()).thenReturn(true);
+ return result;
+ }
+
+ @SuppressWarnings("unchecked")
+ private R<TChargingGun> gunOf(TChargingGun gun) {
+ R<TChargingGun> result = mock(R.class);
+ when(result.getData()).thenReturn(gun);
+ return result;
+ }
+
+ private RLock mockedLock(boolean tryLockResult) throws InterruptedException {
+ RLock lock = mock(RLock.class);
+ when(lock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenReturn(tryLockResult);
+ when(lock.isHeldByCurrentThread()).thenReturn(false);
+ return lock;
+ }
+
+ private RedissonClient mockedRedisson(RLock lock) {
+ RedissonClient redisson = mock(RedissonClient.class);
+ when(redisson.getLock(anyString())).thenReturn(lock);
+ return redisson;
+ }
+
+ // ===== SD:启动超时关单 =====
+
+ @Test
+ void closesCardTimeoutStartingOrderWithCardUnlockAndGunReset() {
+ TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl();
+ TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class);
+ ChargingCardClient cardClient = mock(ChargingCardClient.class);
+ ChargingGunClient gunClient = mock(ChargingGunClient.class);
+ ReflectionTestUtils.setField(service, "baseMapper", mapper);
+ ReflectionTestUtils.setField(service, "chargingCardClient", cardClient);
+ ReflectionTestUtils.setField(service, "chargingGunClient", gunClient);
+ when(mapper.updateById(any(TCardChargingOrder.class))).thenReturn(1);
+ R<Boolean> unlockResult = okBoolean();
+ when(cardClient.unlockForCharging(anyLong(), anyString())).thenReturn(unlockResult);
+ TChargingGun gun = new TChargingGun();
+ gun.setId(9);
+ gun.setStatus(4);
+ R<TChargingGun> gunResult = gunOf(gun);
+ when(gunClient.getChargingGunById(any())).thenReturn(gunResult);
+
+ TCardChargingOrder order = new TCardChargingOrder();
+ order.setId(1L);
+ order.setCode("SD1");
+ order.setStatus(2);
+ order.setCardId(5L);
+ order.setChargingGunId(9);
+ service.closeTimeoutStartingOrder(order);
+
+ ArgumentCaptor<TCardChargingOrder> captor = ArgumentCaptor.forClass(TCardChargingOrder.class);
+ verify(mapper).updateById(captor.capture());
+ assertEquals(-1, captor.getValue().getStatus());
+ assertEquals(0, captor.getValue().getEndMode());
+ assertNotNull(captor.getValue().getEndTime());
+ verify(cardClient).unlockForCharging(5L, "SD1");
+ ArgumentCaptor<TChargingGun> gunCaptor = ArgumentCaptor.forClass(TChargingGun.class);
+ verify(gunClient).updateChargingGunById(gunCaptor.capture());
+ assertEquals(2, gunCaptor.getValue().getStatus());
+ }
+
+ // ===== SD:结算锁抢占失败快速返回 =====
+
+ @Test
+ void returnsOkWithoutTouchingOrderWhenSettleLockBusy() throws InterruptedException {
+ TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl();
+ TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class);
+ ReflectionTestUtils.setField(service, "baseMapper", mapper);
+ ReflectionTestUtils.setField(service, "redissonClient", mockedRedisson(mockedLock(false)));
+
+ TransactionRecordMessageVO vo = new TransactionRecordMessageVO();
+ vo.setTransaction_serial_number("SD1");
+ R result = service.cardEndChargeBillingCharge(vo);
+
+ assertEquals(R.SUCCESS, result.getCode());
+ verifyNoInteractions(mapper);
+ }
+
+ // ===== SD:已结算订单重复账单幂等 =====
+
+ @Test
+ void skipsCardSettlementWhenOrderAlreadyFinished() throws InterruptedException {
+ TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl();
+ TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class);
+ ChargingCardClient cardClient = mock(ChargingCardClient.class);
+ ReflectionTestUtils.setField(service, "baseMapper", mapper);
+ ReflectionTestUtils.setField(service, "chargingCardClient", cardClient);
+ ReflectionTestUtils.setField(service, "redissonClient", mockedRedisson(mockedLock(true)));
+
+ TCardChargingOrder finished = new TCardChargingOrder();
+ finished.setId(1L);
+ finished.setCode("SD1");
+ finished.setStatus(5);
+ finished.setCardId(5L);
+ when(mapper.selectOne(any())).thenReturn(finished);
+ when(mapper.selectList(any())).thenReturn(Collections.singletonList(finished));
+
+ TransactionRecordMessageVO vo = new TransactionRecordMessageVO();
+ vo.setTransaction_serial_number("SD1");
+ vo.setFlat_charge1(new java.math.BigDecimal("10"));
+ R result = service.cardEndChargeBillingCharge(vo);
+
+ assertEquals(R.SUCCESS, result.getCode());
+ verify(cardClient, never()).deductForCharging(any(com.ruoyi.account.api.dto.ChargingCardDeductDTO.class));
+ verify(cardClient, never()).unlockForCharging(anyLong(), anyString());
+ }
+
+ // ===== VD:启动超时关单(无卡片,仅复位枪) =====
+
+ @Test
+ void closesVinTimeoutStartingOrderWithGunReset() {
+ TVinChargingOrderServiceImpl service = new TVinChargingOrderServiceImpl();
+ TVinChargingOrderMapper mapper = mock(TVinChargingOrderMapper.class);
+ ChargingGunClient gunClient = mock(ChargingGunClient.class);
+ ReflectionTestUtils.setField(service, "baseMapper", mapper);
+ ReflectionTestUtils.setField(service, "chargingGunClient", gunClient);
+ when(mapper.updateById(any(TVinChargingOrder.class))).thenReturn(1);
+ TChargingGun gun = new TChargingGun();
+ gun.setId(7);
+ gun.setStatus(4);
+ R<TChargingGun> gunResult = gunOf(gun);
+ when(gunClient.getChargingGunById(any())).thenReturn(gunResult);
+
+ TVinChargingOrder order = new TVinChargingOrder();
+ order.setId(2L);
+ order.setCode("VD1");
+ order.setStatus(2);
+ order.setChargingGunId(7);
+ service.closeTimeoutStartingOrder(order);
+
+ ArgumentCaptor<TVinChargingOrder> captor = ArgumentCaptor.forClass(TVinChargingOrder.class);
+ verify(mapper).updateById(captor.capture());
+ assertEquals(-1, captor.getValue().getStatus());
+ assertEquals(0, captor.getValue().getEndMode());
+ assertNotNull(captor.getValue().getEndTime());
+ ArgumentCaptor<TChargingGun> gunCaptor = ArgumentCaptor.forClass(TChargingGun.class);
+ verify(gunClient).updateChargingGunById(gunCaptor.capture());
+ assertEquals(2, gunCaptor.getValue().getStatus());
+ }
+
+ // ===== VD:已结算订单重复账单幂等 =====
+
+ @Test
+ void skipsVinSettlementWhenOrderAlreadyFinished() throws InterruptedException {
+ TVinChargingOrderServiceImpl service = new TVinChargingOrderServiceImpl();
+ TVinChargingOrderMapper mapper = mock(TVinChargingOrderMapper.class);
+ ChargingGunClient gunClient = mock(ChargingGunClient.class);
+ ReflectionTestUtils.setField(service, "baseMapper", mapper);
+ ReflectionTestUtils.setField(service, "chargingGunClient", gunClient);
+ ReflectionTestUtils.setField(service, "redissonClient", mockedRedisson(mockedLock(true)));
+
+ TVinChargingOrder finished = new TVinChargingOrder();
+ finished.setId(2L);
+ finished.setCode("VD1");
+ finished.setStatus(5);
+ finished.setChargingGunId(7);
+ when(mapper.selectOne(any())).thenReturn(finished);
+ when(mapper.selectList(any())).thenReturn(Collections.singletonList(finished));
+
+ TransactionRecordMessageVO vo = new TransactionRecordMessageVO();
+ vo.setTransaction_serial_number("VD1");
+ vo.setFlat_charge1(new java.math.BigDecimal("8"));
+ R result = service.vinEndChargeBillingCharge(vo);
+
+ assertEquals(R.SUCCESS, result.getCode());
+ verify(gunClient, never()).updateChargingGunById(any(TChargingGun.class));
+ }
+}
diff --git a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/util/TransactionStageParserTest.java b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/util/TransactionStageParserTest.java
new file mode 100644
index 0000000..ac38126
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/util/TransactionStageParserTest.java
@@ -0,0 +1,88 @@
+package com.ruoyi.order.util;
+
+import com.ruoyi.order.api.vo.TransactionRecordMessageVO;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 设备交易记录分时电量解析测试(尖1/峰2/平3/谷4,spike/peak/flat/valley_charge1..12 分组求和)
+ */
+class TransactionStageParserTest {
+
+ @Test
+ void parsesFourStagesAcrossGroups() {
+ TransactionRecordMessageVO vo = new TransactionRecordMessageVO();
+ vo.setSpike_charge1(new BigDecimal("1.10"));
+ vo.setPeak_charge2(new BigDecimal("2.20"));
+ vo.setFlat_charge3(new BigDecimal("3.30"));
+ vo.setValley_charge1(new BigDecimal("4.40"));
+
+ Map<Integer, BigDecimal> stages = TransactionStageParser.parseStageElectricity(vo);
+
+ assertEquals(4, stages.size());
+ assertEquals(0, new BigDecimal("1.10").compareTo(stages.get(1)));
+ assertEquals(0, new BigDecimal("2.20").compareTo(stages.get(2)));
+ assertEquals(0, new BigDecimal("3.30").compareTo(stages.get(3)));
+ assertEquals(0, new BigDecimal("4.40").compareTo(stages.get(4)));
+ }
+
+ @Test
+ void sumsMultipleGroupsPerStage() {
+ TransactionRecordMessageVO vo = new TransactionRecordMessageVO();
+ vo.setFlat_charge1(new BigDecimal("1.50"));
+ vo.setFlat_charge5(new BigDecimal("2.25"));
+
+ Map<Integer, BigDecimal> stages = TransactionStageParser.parseStageElectricity(vo);
+
+ assertEquals(1, stages.size());
+ assertEquals(0, new BigDecimal("3.75").compareTo(stages.get(3)));
+ }
+
+ @Test
+ void ignoresMissingStages() {
+ TransactionRecordMessageVO vo = new TransactionRecordMessageVO();
+ vo.setFlat_charge1(new BigDecimal("10"));
+
+ Map<Integer, BigDecimal> stages = TransactionStageParser.parseStageElectricity(vo);
+
+ assertEquals(1, stages.size());
+ assertNotNull(stages.get(3));
+ }
+
+ @Test
+ void returnsEmptyForNullVo() {
+ assertTrue(TransactionStageParser.parseStageElectricity(null).isEmpty());
+ }
+
+ @Test
+ void ignoresZeroOrMissingElectricity() {
+ TransactionRecordMessageVO vo = new TransactionRecordMessageVO();
+ vo.setFlat_charge1(BigDecimal.ZERO);
+
+ assertTrue(TransactionStageParser.parseStageElectricity(vo).isEmpty());
+ }
+
+ /**
+ * 定时补偿场景:无设备分时账单时以订单累计电量构造全平段 VO,
+ * 解析后必须落在平段(3),保证兜底结算可按平段快照计价
+ */
+ @Test
+ void compensationVoCarriesFlatStageElectricity() {
+ TransactionRecordMessageVO vo = new TransactionRecordMessageVO();
+ vo.setTransaction_serial_number("SD999");
+ vo.setFlat_charge1(new BigDecimal("17.36"));
+ vo.setTotal_electricity(new BigDecimal("17.36"));
+
+ Map<Integer, BigDecimal> stages = TransactionStageParser.parseStageElectricity(vo);
+
+ assertEquals(1, stages.size());
+ assertEquals(0, new BigDecimal("17.36").compareTo(stages.get(3)));
+ assertEquals(0, new BigDecimal("17.36").compareTo(vo.getTotal_electricity()));
+ }
+}
diff --git a/sql/20260909_card_password_charge_lock.sql b/sql/20260909_card_password_charge_lock.sql
new file mode 100644
index 0000000..dc61a39
--- /dev/null
+++ b/sql/20260909_card_password_charge_lock.sql
@@ -0,0 +1,86 @@
+-- 刷卡充电 V0.2 定稿:卡片密码配置、充电期间锁定、订单结算幂等扣款(允许负余额)
+-- 目标库:charging_pile_account(t_charging_card、t_charging_card_balance_record)
+-- 适用:MySQL(含 Mycat 代理下的 MySQL 8 后端)。Mycat 对 PREPARE/INFORMATION_SCHEMA 变量支持不稳定时,
+-- 可直接执行下方「直接 DDL 小节」并回查。
+-- 脚本幂等:字段/索引已存在则自动跳过,可重复执行。
+
+USE `charging_pile_account`;
+
+-- ============ 1. t_charging_card 新增 password / charging_flag / charging_order_code ============
+SET @has_password_col := (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = 'charging_pile_account' AND TABLE_NAME = 't_charging_card' AND COLUMN_NAME = 'password'
+);
+SET @add_password_sql := IF(
+ @has_password_col = 0,
+ 'ALTER TABLE `t_charging_card` ADD COLUMN `password` varchar(100) DEFAULT NULL COMMENT ''卡密码(BCrypt密文,空=未配置不校验)'' AFTER `card_no`',
+ 'SELECT 1'
+);
+PREPARE add_password_stmt FROM @add_password_sql;
+EXECUTE add_password_stmt;
+DEALLOCATE PREPARE add_password_stmt;
+
+SET @has_charging_flag_col := (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = 'charging_pile_account' AND TABLE_NAME = 't_charging_card' AND COLUMN_NAME = 'charging_flag'
+);
+SET @add_charging_flag_sql := IF(
+ @has_charging_flag_col = 0,
+ 'ALTER TABLE `t_charging_card` ADD COLUMN `charging_flag` tinyint NOT NULL DEFAULT 0 COMMENT ''充电锁定:0未充电,1充电中锁定'' AFTER `status`',
+ 'SELECT 1'
+);
+PREPARE add_charging_flag_stmt FROM @add_charging_flag_sql;
+EXECUTE add_charging_flag_stmt;
+DEALLOCATE PREPARE add_charging_flag_stmt;
+
+SET @has_charging_order_code_col := (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = 'charging_pile_account' AND TABLE_NAME = 't_charging_card' AND COLUMN_NAME = 'charging_order_code'
+);
+SET @add_charging_order_code_sql := IF(
+ @has_charging_order_code_col = 0,
+ 'ALTER TABLE `t_charging_card` ADD COLUMN `charging_order_code` varchar(64) DEFAULT NULL COMMENT ''充电锁定订单号'' AFTER `charging_flag`',
+ 'SELECT 1'
+);
+PREPARE add_charging_order_code_stmt FROM @add_charging_order_code_sql;
+EXECUTE add_charging_order_code_stmt;
+DEALLOCATE PREPARE add_charging_order_code_stmt;
+
+-- ============ 2. t_charging_card_balance_record 新增充电订单号(扣款幂等键)与唯一索引 ============
+SET @has_record_order_col := (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = 'charging_pile_account' AND TABLE_NAME = 't_charging_card_balance_record' AND COLUMN_NAME = 'charging_order_code'
+);
+SET @add_record_order_sql := IF(
+ @has_record_order_col = 0,
+ 'ALTER TABLE `t_charging_card_balance_record` ADD COLUMN `charging_order_code` varchar(64) DEFAULT NULL COMMENT ''关联订单号(刷卡充电订单结算扣款,幂等键)'' AFTER `remark`',
+ 'SELECT 1'
+);
+PREPARE add_record_order_stmt FROM @add_record_order_sql;
+EXECUTE add_record_order_stmt;
+DEALLOCATE PREPARE add_record_order_stmt;
+
+SET @has_record_uk := (
+ SELECT COUNT(1) FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = 'charging_pile_account' AND TABLE_NAME = 't_charging_card_balance_record' AND INDEX_NAME = 'uk_order_code'
+);
+SET @add_record_uk_sql := IF(
+ @has_record_uk = 0,
+ 'ALTER TABLE `t_charging_card_balance_record` ADD UNIQUE KEY `uk_order_code` (`charging_order_code`)',
+ 'SELECT 1'
+);
+PREPARE add_record_uk_stmt FROM @add_record_uk_sql;
+EXECUTE add_record_uk_stmt;
+DEALLOCATE PREPARE add_record_uk_stmt;
+
+-- ========== 直接 DDL 小节(Mycat 下如上面 PREPARE 报错,可按序手工执行以下四条) ==========
+-- ALTER TABLE `charging_pile_account`.`t_charging_card`
+-- ADD COLUMN `password` varchar(100) DEFAULT NULL COMMENT '卡密码(BCrypt密文,空=未配置不校验)' AFTER `card_no`;
+-- ALTER TABLE `charging_pile_account`.`t_charging_card`
+-- ADD COLUMN `charging_flag` tinyint NOT NULL DEFAULT 0 COMMENT '充电锁定:0未充电,1充电中锁定' AFTER `status`;
+-- ALTER TABLE `charging_pile_account`.`t_charging_card`
+-- ADD COLUMN `charging_order_code` varchar(64) DEFAULT NULL COMMENT '充电锁定订单号' AFTER `charging_flag`;
+-- ALTER TABLE `charging_pile_account`.`t_charging_card_balance_record`
+-- ADD COLUMN `charging_order_code` varchar(64) DEFAULT NULL COMMENT '关联订单号(刷卡充电订单结算扣款,幂等键)' AFTER `remark`;
+-- ALTER TABLE `charging_pile_account`.`t_charging_card_balance_record`
+-- ADD UNIQUE KEY `uk_order_code` (`charging_order_code`);
diff --git a/sql/20260909_sd_vd_order_enum_comment.sql b/sql/20260909_sd_vd_order_enum_comment.sql
new file mode 100644
index 0000000..faeecb9
--- /dev/null
+++ b/sql/20260909_sd_vd_order_enum_comment.sql
@@ -0,0 +1,18 @@
+-- SD/VD 订单表枚举注释补全(对齐 t_charging_order 既有约定)
+-- 背景:SD/VD 代码已将"启动失败"状态从 6 修正为 -1(沿用 CD 枚举:负值语义,前端按 status<0 渲染"启动失败"),
+-- 并补设 endMode(异常终止=0)与 SD payMethod(1=余额支付);本脚本仅补全列注释,不改变数据。
+-- MyCat 环境请在逻辑库 charging_pile_order 执行(分表表名会自动路由)。
+
+USE charging_pile_order;
+
+-- 刷卡充电订单(SD)
+ALTER TABLE `t_card_charging_order`
+ MODIFY COLUMN `status` int DEFAULT NULL COMMENT '订单状态(-1=启动失败,0=未支付,1=已支付待充电,2=启动中,3=充电中,4=停止中,5=已完成;沿用 t_charging_order 枚举)',
+ MODIFY COLUMN `end_mode` int DEFAULT NULL COMMENT '结束方式(0=异常终止,1=主动终止,2=满电自动终止,3=费用不足终止)',
+ MODIFY COLUMN `pay_method` int DEFAULT NULL COMMENT '充电支付模式(1=余额支付,2=余额组合支付,3=第三方支付;SD 固定 1=余额支付)';
+
+-- VIN码充电订单(VD)
+ALTER TABLE `t_vin_charging_order`
+ MODIFY COLUMN `status` int DEFAULT NULL COMMENT '订单状态(-1=启动失败,0=未支付,1=已支付待充电,2=启动中,3=充电中,4=停止中,5=已完成;沿用 t_charging_order 枚举)',
+ MODIFY COLUMN `end_mode` int DEFAULT NULL COMMENT '结束方式(0=异常终止,1=主动终止,2=满电自动终止,3=费用不足终止)',
+ MODIFY COLUMN `pay_method` int DEFAULT NULL COMMENT '充电支付模式(1=余额支付,2=余额组合支付,3=第三方支付;VD 后付记账模式留空=无支付动作)';
--
Gitblit v1.7.1