From d4d70fe3a3a20f5fc29c9f37a1f89854a376597f Mon Sep 17 00:00:00 2001
From: yanderong <307678080@qq.com>
Date: 星期三, 16 九月 2026 18:42:36 +0800
Subject: [PATCH] Merge remote-tracking branch 'origin/2.3' into 2.3
---
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TChargingCardController.java | 6
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java | 3
ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TChargingCardMapper.xml | 10 +
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TChargingCardServiceImpl.java | 16 ++
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java | 104 +++++++++++++-
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TCardChargingOrderService.java | 7 +
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/ChargingCardFallbackFactory.java | 7 +
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/task/TaskUtil.java | 8 +
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TChargingCardService.java | 5
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/util/task/TaskUtilCompensationTest.java | 26 +++
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/ChargingCardClient.java | 8 +
doc/耗时统计.md | 1
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TChargingCardServiceImplTest.java | 16 ++
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/controller/TChargingCardControllerTest.java | 12 +
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TCardChargingOrderAmountFieldsTest.java | 179 +++++++++++++++++++++++++
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TChargingCardMapper.java | 5
16 files changed, 401 insertions(+), 12 deletions(-)
diff --git "a/doc/\350\200\227\346\227\266\347\273\237\350\256\241.md" "b/doc/\350\200\227\346\227\266\347\273\237\350\256\241.md"
index 5aa263c..8befe0e 100644
--- "a/doc/\350\200\227\346\227\266\347\273\237\350\256\241.md"
+++ "b/doc/\350\200\227\346\227\266\347\273\237\350\256\241.md"
@@ -11,3 +11,4 @@
| 2026-09-01 10:33:38 | 2026-09-01 10:46:38 | 780 | 推送代码 | 是:回归验证通过后分别提交并普通推送服务端 `2.3` 与管理后台 `dev_2.3`,不使用强制推送 | 提交并推送当前两个仓库的卡片收支明细、刷卡订单统计、VIN 管理及相关路由权限改动;服务端提交 `4292699a`,管理后台提交 `8ef3d871` | 服务端 20 个业务/SQL 文件及 `doc/耗时统计.md`;管理后台 12 个页面、路由和权限文件 | 服务端 Maven clean test、前端 `tsc`、Jest 11 套件/42 用例、生产构建、UTF-8 检查、两仓库 `git diff --cached --check`、普通推送及远端引用校验全部通过 | 构建仅有既存 Maven 模型、React act、Jest open handle、Browserslist 过期警告,不影响测试结果 |
| 2026-09-01 13:46:42 | 2026-09-01 14:07:44 | 1262 | 推送代码 | 是:仅提交 VIN 码充电订单功能,精确排除刷卡订单统计未完成改动;隔离暂存快照回归后普通推送前后端分支 | 提交并推送 VIN 独立订单表、列表/汇总/详情/计费明细/导出、菜单权限,以及管理后台 VIN 订单页面;服务端提交 `6cff7f18`,管理后台提交 `50a2bbbd` | 服务端 11 个 VIN 业务、测试及 SQL 文件;管理后台 7 个 VIN 页面、路由与权限文件;本统计文件 | 后端订单模块及依赖 Maven `clean test` BUILD SUCCESS、VIN 聚焦测试 BUILD SUCCESS;前端 `tsc`、Jest 12 套件/44 用例、生产构建、UTF-8、`git diff --cached --check`、远端引用核验全部通过 | 未执行数据库脚本;刷卡订单统计改动继续保留本地;既存 Maven 模型、React act、Jest open handle、Browserslist 警告不影响结果 |
| 2026-09-15 09:41:00(估算) | 2026-09-15 10:33:37 | 3157(估算) | 请继续执行未完成的任务。(续作:VIN 订单首末 SOC 回填) | 是:统一结算首次处理时按监控创建时间升/降序取首末 SOC,空数据或查询异常不阻断,重复账单保持幂等 | 在 VD 正常交易记录及充电超时补偿共用结算入口中回填 `start_soc/end_soc`;增加正常映射、空监控数据及重复账单不查询 Mongo 的回归断言 | `ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java`;`ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/CardVinSettlementTimeAlignmentTest.java`;`ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/CardVinOrderCompensationTest.java`;`doc/耗时统计.md` | UTF-8、IDE lint、`git diff --check` 通过;reactor `mvn -o -pl ruoyi-service/ruoyi-order -am install -DskipTests` BUILD SUCCESS;定向 20/20、ruoyi-order 全量 178/178 JUnit 5 全部通过;代码走查通过 | 开始时间依据当天分析记录估算,耗时包含方案分析及等待续作;首轮 Maven 在工作区根目录因 reactor 路径错误未执行构建,进入 `ChargingPile` 后重跑通过 |
+| 2026-09-15 17:39:31 | 2026-09-15 17:52:02 | 751 | 请继续执行未完成的任务。(续作:SD 已结束未扣款补偿) | 是:账户侧新增批量扣款流水查询接口,订单侧扫描状态5且应扣款的刷卡订单,逐单复核无扣款流水后条件回退为停止中交由既有补偿结算;本轮补齐“已有流水不回退”“账户查询失败不回退”“无候选不远程调用”“未抢到锁不回退”“定时任务异常隔离”5 个回归用例 | 完成 SD 已结束(状态5)订单无充电扣款流水的定时兜底回退,并补齐测试与全量回归;默认 surefire 版本无法执行 JUnit 5,改用本地仓库 surefire 2.22.2 执行 | `ruoyi-api/ruoyi-api-account/.../feignClient/ChargingCardClient.java`;`ruoyi-api/ruoyi-api-account/.../factory/ChargingCardFallbackFactory.java`;`ruoyi-service/ruoyi-account/.../controller/TChargingCardController.java`;`.../mapper/TChargingCardMapper.java`;`.../service/TChargingCardService.java`;`.../service/impl/TChargingCardServiceImpl.java`;`.../resources/mapper/account/TChargingCardMapper.xml`;`.../test/.../TChargingCardControllerTest.java`;`.../test/.../TChargingCardServiceImplTest.java`;`ruoyi-service/ruoyi-order/.../service/TCardChargingOrderService.java`;`.../service/impl/TCardChargingOrderServiceImpl.java`;`.../service/impl/TVinChargingOrderServiceImpl.java`;`.../util/task/TaskUtil.java`;`.../test/.../TCardChargingOrderAmountFieldsTest.java`;`.../test/.../TaskUtilCompensationTest.java`;`doc/耗时统计.md` | `mvn -o -pl ruoyi-api/ruoyi-api-account,ruoyi-service/ruoyi-account,ruoyi-service/ruoyi-order -am test-compile -DskipTests` BUILD SUCCESS;surefire 2.22.2 定向 14/14、ruoyi-order 全量 185/185、ruoyi-account 全量 103/103 全部通过;IDE lint 0 条;`git diff --check` 通过;代码走查通过 | 开始时间取本次续作会话首个命令时间,耗时仅统计续作会话(751 秒);实现主体在当天更早会话完成、时间未记录,故整体任务耗时未合并统计;新增用例首轮因在 thenReturn 参数内调用 Mockito 桩方法触发 UnfinishedStubbing,提取局部变量后修复 |
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
index cc4e93e..3e396d2 100644
--- 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
@@ -11,6 +11,8 @@
import org.springframework.cloud.openfeign.FallbackFactory;
import org.springframework.stereotype.Component;
+import java.util.List;
+
/**
* 充电卡内部服务降级处理
*/
@@ -41,6 +43,11 @@
public R<ChargingCardDeductVO> deductForCharging(ChargingCardDeductDTO dto) {
return R.fail("充电卡服务调用失败:" + throwable.getMessage());
}
+
+ @Override
+ public R<List<String>> listDeductedOrderCodes(List<String> orderCodes) {
+ return R.fail("充电卡服务调用失败:" + 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
index 3f65622..e03ca88 100644
--- 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
@@ -12,6 +12,8 @@
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
+import java.util.List;
+
/**
* 充电卡内部服务(刷卡启动鉴权、充电锁定、订单结算扣款)
* <p>仅限订单服务等内部链路调用,禁止走管理后台权限体系。</p>
@@ -52,4 +54,10 @@
*/
@PostMapping("/t-charging-card/inner/deduct-charging")
R<ChargingCardDeductVO> deductForCharging(@RequestBody ChargingCardDeductDTO dto);
+
+ /**
+ * 批量查询已成功写入充电扣款流水的订单号。
+ */
+ @PostMapping("/t-charging-card/inner/deducted-order-codes")
+ R<List<String>> listDeductedOrderCodes(@RequestBody List<String> orderCodes);
}
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 1b741cb..e51c8fb 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
@@ -135,4 +135,10 @@
public R<ChargingCardDeductVO> deductForCharging(@RequestBody ChargingCardDeductDTO dto) {
return R.ok(chargingCardService.deductBalanceForCharging(dto));
}
+
+ @ApiOperation(value = "批量查询已存在充电扣款流水的订单号(内部)")
+ @PostMapping("/inner/deducted-order-codes")
+ public R<List<String>> listDeductedOrderCodes(@RequestBody List<String> orderCodes) {
+ return R.ok(chargingCardService.listDeductedOrderCodes(orderCodes));
+ }
}
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 467258e..bdf9fde 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
@@ -54,4 +54,9 @@
@Param("amount") BigDecimal amount,
@Param("orderCode") String orderCode,
@Param("createBy") Long createBy);
+
+ /**
+ * 批量查询已存在的充电扣款流水订单号。
+ */
+ List<String> selectDeductedOrderCodes(@Param("orderCodes") List<String> orderCodes);
}
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 bbdfb8f..f40e584 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
@@ -58,4 +58,9 @@
* @return 扣款结果;duplicated=true 表示该订单此前已扣款,本次未重复扣款
*/
ChargingCardDeductVO deductBalanceForCharging(ChargingCardDeductDTO dto);
+
+ /**
+ * 批量查询已经成功写入充电扣款流水的订单号。
+ */
+ List<String> listDeductedOrderCodes(List<String> orderCodes);
}
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 c5176b1..bb65003 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
@@ -281,6 +281,22 @@
}
@Override
+ public List<String> listDeductedOrderCodes(List<String> orderCodes) {
+ if (orderCodes == null || orderCodes.isEmpty()) {
+ return java.util.Collections.emptyList();
+ }
+ List<String> normalizedCodes = orderCodes.stream()
+ .filter(StringUtils::isNotEmpty)
+ .map(String::trim)
+ .filter(code -> !code.isEmpty())
+ .distinct()
+ .collect(java.util.stream.Collectors.toList());
+ return normalizedCodes.isEmpty()
+ ? java.util.Collections.emptyList()
+ : baseMapper.selectDeductedOrderCodes(normalizedCodes);
+ }
+
+ @Override
public ChargingCardDeductVO deductBalanceForCharging(ChargingCardDeductDTO dto) {
validateDeductRequest(dto);
try {
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 8be81b6..3657ad6 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
@@ -121,4 +121,14 @@
(#{cardId}, 3, #{amount}, #{orderCode}, #{orderCode}, NOW(), #{createBy})
</insert>
+ <select id="selectDeductedOrderCodes" resultType="java.lang.String">
+ SELECT charging_order_code
+ FROM t_charging_card_balance_record
+ WHERE change_type = 3
+ AND charging_order_code IN
+ <foreach collection="orderCodes" item="orderCode" open="(" separator="," close=")">
+ #{orderCode}
+ </foreach>
+ </select>
+
</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 18e0700..6d440a7 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
@@ -146,6 +146,18 @@
}
@Test
+ void innerDeductedOrderCodesDelegatesToService() {
+ java.util.List<String> orderCodes = Collections.singletonList("SD20260909001");
+ when(service.listDeductedOrderCodes(orderCodes)).thenReturn(orderCodes);
+
+ R<java.util.List<String>> result = controller.listDeductedOrderCodes(orderCodes);
+
+ assertEquals(200, result.getCode());
+ assertEquals(orderCodes, result.getData());
+ verify(service).listDeductedOrderCodes(orderCodes);
+ }
+
+ @Test
void innerDeductChargingDelegatesToService() {
ChargingCardDeductDTO dto = new ChargingCardDeductDTO();
dto.setCardId(1L);
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 764cf47..826fabc 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
@@ -542,6 +542,22 @@
}
@Test
+ void listDeductedOrderCodesNormalizesInputAndQueriesMapper() {
+ when(mapper.selectDeductedOrderCodes(Arrays.asList("SD1", "SD2")))
+ .thenReturn(Collections.singletonList("SD1"));
+
+ assertEquals(Collections.singletonList("SD1"),
+ service.listDeductedOrderCodes(Arrays.asList(" SD1 ", "", null, "SD1", "SD2")));
+ verify(mapper).selectDeductedOrderCodes(Arrays.asList("SD1", "SD2"));
+ }
+
+ @Test
+ void listDeductedOrderCodesSkipsDatabaseForEmptyInput() {
+ assertTrue(service.listDeductedOrderCodes(Collections.emptyList()).isEmpty());
+ verify(mapper, never()).selectDeductedOrderCodes(anyList());
+ }
+
+ @Test
void deductBalanceForChargingRejectsMissingCard() {
stubTransaction();
when(mapper.selectById(1L)).thenReturn(null);
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 993a131..fc391b0 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
@@ -94,6 +94,13 @@
List<TCardChargingOrder> listTimeoutChargingOrders();
/**
+ * 扫描已结束但没有充电扣款流水的刷卡订单,条件回退为停止中等待重新结算。
+ *
+ * @return 本轮成功回退的订单数量
+ */
+ int recoverFinishedOrdersWithoutDeduct();
+
+ /**
* 启动超时关单:仅当前状态仍为启动中时置为启动失败(-1),解锁卡片并复位充电枪
*/
void closeTimeoutStartingOrder(TCardChargingOrder 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 7a821d6..0b54e6b 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
@@ -112,6 +112,10 @@
private static final int SD_STARTING_TIMEOUT_MINUTES = 30;
/** SD 充电中超时阈值(小时):超时未收到交易记录则兜底结算 */
private static final int SD_CHARGING_TIMEOUT_HOURS = 24;
+ /** 状态5未扣款扫描保护窗,避免与正在提交的扣款事务竞态 */
+ private static final int SD_DEDUCT_COMPENSATION_DELAY_MINUTES = 10;
+ /** 单轮最多检查的已结束订单数,避免大批量远程查询 */
+ private static final int SD_DEDUCT_COMPENSATION_BATCH_SIZE = 100;
@Resource
private TokenService tokenService;
@@ -914,8 +918,7 @@
log.error("[SD结算] 订单无计费策略快照,按0价结算并需人工核对 code={} totalElectricity={}", code, totalElectricity);
}
// 首段使用订单实际开始时间;订单尚未写入结束时间时,末段使用交易记录设备结束时间。
- LocalDateTime settlementStartTime = order.getStartTime() != null
- ? order.getStartTime() : parseDeviceTime(vo.getStart_time(), null);
+ LocalDateTime settlementStartTime = parseDeviceTime(vo.getStart_time(), order.getStartTime());
LocalDateTime settlementEndTime = parseDeviceTime(vo.getEnd_time(), order.getEndTime());
if (!details.isEmpty()) {
DateTimeFormatter hhmm = DateTimeFormatter.ofPattern("HH:mm");
@@ -975,23 +978,33 @@
order.setOrderAmount(orderAmount);
order.setPaymentAmount(orderAmount);
- // 卡账户扣款(订单号幂等键;允许扣成负余额;失败欠费记账不回滚订单)
+ // 卡账户扣款(订单号幂等键;允许扣成负余额);无法确认扣款成功时回退状态4,交由补偿任务重试
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={}",
+ R<ChargingCardDeductVO> deductResult;
+ try {
+ deductResult = chargingCardClient.deductForCharging(deductDTO);
+ } catch (Exception ex) {
+ rollbackFinishedToStopping(order.getId(), code);
+ log.error("[SD结算] 扣款调用异常,订单回退停止中 code={} cardId={} amount={}",
+ code, order.getCardId(), orderAmount, ex);
+ return R.fail("充电卡扣款失败,订单已进入补偿队列");
+ }
+ if (deductResult == null || deductResult.getCode() != R.SUCCESS || deductResult.getData() == null) {
+ rollbackFinishedToStopping(order.getId(), code);
+ log.error("[SD结算] 扣款失败,订单回退停止中 code={} cardId={} amount={} msg={}",
code, order.getCardId(), orderAmount, deductResult == null ? "null" : deductResult.getMsg());
+ return R.fail("充电卡扣款失败,订单已进入补偿队列");
+ }
+ if (deductResult.getData().isDuplicated()) {
+ log.warn("[SD结算] 扣款幂等命中(重复请求不重复扣)code={}", code);
}
}
- // 解锁卡片(无论扣款成败必须解锁,失败人工介入)
+ // 扣款成功后解锁卡片
unlockCardQuietly(order);
// 复位充电枪
resetGunQuietly(order);
@@ -1000,6 +1013,18 @@
log.info("[SD结算] 完成 code={} totalElectricity={} electrovalence={} serviceCharge={} amount={}",
code, totalElectricity, electrovalenceTotal, serviceChargeTotal, orderAmount);
return R.ok();
+ }
+
+ private boolean rollbackFinishedToStopping(Long orderId, String code) {
+ boolean rolledBack = lambdaUpdate()
+ .eq(TCardChargingOrder::getId, orderId)
+ .eq(TCardChargingOrder::getStatus, ORDER_STATUS_FINISHED)
+ .set(TCardChargingOrder::getStatus, ORDER_STATUS_STOPPING)
+ .update();
+ if (!rolledBack) {
+ log.warn("[SD结算] 状态5回退4未命中,订单状态可能已变化 code={}", code);
+ }
+ return rolledBack;
}
/**
@@ -1132,6 +1157,65 @@
}
@Override
+ public int recoverFinishedOrdersWithoutDeduct() {
+ List<TCardChargingOrder> candidates = lambdaQuery()
+ .eq(TCardChargingOrder::getStatus, ORDER_STATUS_FINISHED)
+ .isNotNull(TCardChargingOrder::getCardId)
+ .gt(TCardChargingOrder::getOrderAmount, BigDecimal.ZERO)
+ .lt(TCardChargingOrder::getEndTime, LocalDateTime.now().minusMinutes(SD_DEDUCT_COMPENSATION_DELAY_MINUTES))
+ .orderByAsc(TCardChargingOrder::getEndTime)
+ .last("LIMIT " + SD_DEDUCT_COMPENSATION_BATCH_SIZE)
+ .list();
+ if (candidates.isEmpty()) {
+ return 0;
+ }
+
+ List<String> orderCodes = candidates.stream().map(TCardChargingOrder::getCode)
+ .filter(StringUtils::isNotEmpty).collect(Collectors.toList());
+ R<List<String>> deductedResult = chargingCardClient.listDeductedOrderCodes(orderCodes);
+ if (deductedResult == null || deductedResult.getCode() != R.SUCCESS || deductedResult.getData() == null) {
+ log.error("[SD扣款补偿] 查询扣款流水失败,本轮不修改订单 msg={}",
+ deductedResult == null ? "null" : deductedResult.getMsg());
+ return 0;
+ }
+
+ java.util.Set<String> deductedCodes = new java.util.HashSet<>(deductedResult.getData());
+ int recovered = 0;
+ for (TCardChargingOrder candidate : candidates) {
+ if (StringUtils.isEmpty(candidate.getCode()) || deductedCodes.contains(candidate.getCode())) {
+ continue;
+ }
+ RLock settleLock = redissonClient.getLock(LOCK_SD_SETTLE + candidate.getCode());
+ try {
+ if (!settleLock.tryLock(0, 60, TimeUnit.SECONDS)) {
+ continue;
+ }
+ R<List<String>> recheck = chargingCardClient.listDeductedOrderCodes(
+ Collections.singletonList(candidate.getCode()));
+ if (recheck == null || recheck.getCode() != R.SUCCESS || recheck.getData() == null
+ || recheck.getData().contains(candidate.getCode())) {
+ continue;
+ }
+ if (rollbackFinishedToStopping(candidate.getId(), candidate.getCode())) {
+ recovered++;
+ log.warn("[SD扣款补偿] 已结束订单无扣款流水,状态回退停止中 code={}", candidate.getCode());
+ }
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ log.warn("[SD扣款补偿] 获取结算锁被中断 code={}", candidate.getCode());
+ break;
+ } catch (Exception ex) {
+ log.error("[SD扣款补偿] 订单处理异常,保留原状态 code={}", candidate.getCode(), ex);
+ } finally {
+ if (settleLock.isHeldByCurrentThread()) {
+ settleLock.unlock();
+ }
+ }
+ }
+ return recovered;
+ }
+
+ @Override
public void closeTimeoutStartingOrder(TCardChargingOrder order) {
TCardChargingOrder update = new TCardChargingOrder();
update.setStatus(ORDER_STATUS_START_FAILED);
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 475efee..1216e35 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
@@ -1051,8 +1051,7 @@
log.error("[VD结算] 订单无计费策略快照,按0价结算并需人工核对 code={} totalElectricity={}", code, totalElectricity);
}
// 首段使用订单实际开始时间;订单尚未写入结束时间时,末段使用交易记录设备结束时间。
- LocalDateTime settlementStartTime = order.getStartTime() != null
- ? order.getStartTime() : parseDeviceTime(vo.getStart_time(), null);
+ LocalDateTime settlementStartTime = parseDeviceTime(vo.getStart_time(), order.getStartTime());
LocalDateTime settlementEndTime = parseDeviceTime(vo.getEnd_time(), order.getEndTime());
if (!details.isEmpty()) {
DateTimeFormatter hhmm = DateTimeFormatter.ofPattern("HH:mm");
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 e31f1ad..40fd2aa 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
@@ -308,6 +308,14 @@
private void compensateCardOrders() {
try {
+ int recovered = cardChargingOrderService.recoverFinishedOrdersWithoutDeduct();
+ if (recovered > 0) {
+ log.warn("[SD扣款补偿] 本轮回退未扣款已结束订单数量:{}", recovered);
+ }
+ } catch (Exception e) {
+ log.error("[SD扣款补偿] 已结束未扣款扫描异常", e);
+ }
+ try {
for (TCardChargingOrder order : cardChargingOrderService.listTimeoutStartingOrders()) {
compensateCardStartingOrder(order);
}
diff --git a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TCardChargingOrderAmountFieldsTest.java b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TCardChargingOrderAmountFieldsTest.java
index 5372e59..fa05bfb 100644
--- a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TCardChargingOrderAmountFieldsTest.java
+++ b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TCardChargingOrderAmountFieldsTest.java
@@ -44,6 +44,8 @@
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -244,4 +246,181 @@
verify(cardClient).deductForCharging(deductCaptor.capture());
assertEquals(new BigDecimal("15.00"), deductCaptor.getValue().getAmount());
}
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void deductFailureRollsFinishedOrderBackToStopping() throws InterruptedException {
+ TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl();
+ TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class);
+ ChargingCardClient cardClient = mock(ChargingCardClient.class);
+ ChargingGunClient gunClient = mock(ChargingGunClient.class);
+ TCardAccountingStrategyDetailOrderService detailOrderService = mock(TCardAccountingStrategyDetailOrderService.class);
+ TCardChargingOrderAccountingStrategyService strategyService = mock(TCardChargingOrderAccountingStrategyService.class);
+ TransactionTemplate txTemplate = mock(TransactionTemplate.class);
+ ReflectionTestUtils.setField(service, "baseMapper", mapper);
+ ReflectionTestUtils.setField(service, "chargingCardClient", cardClient);
+ ReflectionTestUtils.setField(service, "chargingGunClient", gunClient);
+ ReflectionTestUtils.setField(service, "cardAccountingStrategyDetailOrderService", detailOrderService);
+ ReflectionTestUtils.setField(service, "cardChargingOrderAccountingStrategyService", strategyService);
+ ReflectionTestUtils.setField(service, "transactionTemplate", txTemplate);
+ ReflectionTestUtils.setField(service, "redissonClient", mockedRedisson(mockedLock(true)));
+
+ TCardChargingOrder charging = new TCardChargingOrder();
+ charging.setId(1L);
+ charging.setCode("SD-FAIL");
+ charging.setStatus(4);
+ charging.setCardId(5L);
+ charging.setChargingGunId(9);
+ charging.setChargeAmount(new BigDecimal("100.00"));
+ when(mapper.selectOne(any())).thenReturn(charging);
+ when(mapper.updateById(any(TCardChargingOrder.class))).thenReturn(1);
+ when(mapper.update(any(), any())).thenReturn(1);
+
+ R<ChargingCardDeductVO> deductFailure = mock(R.class);
+ when(deductFailure.getCode()).thenReturn(500);
+ when(deductFailure.getMsg()).thenReturn("account unavailable");
+ when(cardClient.deductForCharging(any(ChargingCardDeductDTO.class))).thenReturn(deductFailure);
+
+ TCardAccountingStrategyDetailOrder snapshot = new TCardAccountingStrategyDetailOrder();
+ snapshot.setType(1);
+ snapshot.setElectrovalence(new BigDecimal("1.0000"));
+ snapshot.setServiceCharge(new BigDecimal("0.5000"));
+ snapshot.setCostServiceCharge(new BigDecimal("0.5000"));
+ LambdaQueryChainWrapper<TCardAccountingStrategyDetailOrder> queryWrapper = mock(LambdaQueryChainWrapper.class);
+ when(detailOrderService.lambdaQuery()).thenReturn(queryWrapper);
+ when(queryWrapper.eq(any(), any())).thenReturn(queryWrapper);
+ when(queryWrapper.list()).thenReturn(Collections.singletonList(snapshot));
+
+ LambdaUpdateChainWrapper<TCardChargingOrderAccountingStrategy> updateWrapper = mock(LambdaUpdateChainWrapper.class);
+ when(strategyService.lambdaUpdate()).thenReturn(updateWrapper);
+ when(updateWrapper.eq(any(), any())).thenReturn(updateWrapper);
+ when(updateWrapper.remove()).thenReturn(true);
+ when(strategyService.saveBatch(any(Collection.class))).thenReturn(true);
+ when(txTemplate.execute(any())).thenAnswer(invocation -> {
+ TransactionCallback<?> callback = invocation.getArgument(0);
+ return callback.doInTransaction(mock(TransactionStatus.class));
+ });
+
+ TransactionRecordMessageVO vo = new TransactionRecordMessageVO();
+ vo.setTransaction_serial_number("SD-FAIL");
+ vo.setSpike_charge1(BigDecimal.ONE);
+ R result = service.cardEndChargeBillingCharge(vo);
+
+ assertEquals(500, result.getCode());
+ verify(mapper).update(any(), any());
+ verify(cardClient, never()).unlockForCharging(anyLong(), anyString());
+ verify(gunClient, never()).getChargingGunById(any());
+ }
+
+ @Test
+ void compensationRollsBackOnlyFinishedOrdersWithoutDeductRecord() throws InterruptedException {
+ TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl();
+ TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class);
+ ChargingCardClient cardClient = mock(ChargingCardClient.class);
+ RLock lock = mockedLock(true);
+ ReflectionTestUtils.setField(service, "baseMapper", mapper);
+ ReflectionTestUtils.setField(service, "chargingCardClient", cardClient);
+ ReflectionTestUtils.setField(service, "redissonClient", mockedRedisson(lock));
+
+ TCardChargingOrder missing = new TCardChargingOrder();
+ missing.setId(1L);
+ missing.setCode("SD-MISSING");
+ missing.setStatus(5);
+ missing.setCardId(5L);
+ missing.setOrderAmount(BigDecimal.ONE);
+ when(mapper.selectList(any())).thenReturn(Collections.singletonList(missing));
+ when(mapper.update(any(), any())).thenReturn(1);
+ R<java.util.List<String>> noDeductRecords = okWithData(Collections.emptyList());
+ when(cardClient.listDeductedOrderCodes(any())).thenReturn(noDeductRecords);
+
+ assertEquals(1, service.recoverFinishedOrdersWithoutDeduct());
+ verify(cardClient, times(2)).listDeductedOrderCodes(any());
+ verify(mapper).update(any(), any());
+ }
+
+ @Test
+ void compensationSkipsFinishedOrdersHavingDeductRecord() {
+ TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl();
+ TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class);
+ ChargingCardClient cardClient = mock(ChargingCardClient.class);
+ ReflectionTestUtils.setField(service, "baseMapper", mapper);
+ ReflectionTestUtils.setField(service, "chargingCardClient", cardClient);
+
+ TCardChargingOrder deducted = new TCardChargingOrder();
+ deducted.setId(2L);
+ deducted.setCode("SD-DEDUCTED");
+ deducted.setStatus(5);
+ deducted.setCardId(5L);
+ deducted.setOrderAmount(BigDecimal.ONE);
+ when(mapper.selectList(any())).thenReturn(Collections.singletonList(deducted));
+ // 批量查询结果已包含该订单号时不再逐单复核,也不回退状态
+ R<java.util.List<String>> deductedRecords = okWithData(Collections.singletonList("SD-DEDUCTED"));
+ when(cardClient.listDeductedOrderCodes(any())).thenReturn(deductedRecords);
+
+ assertEquals(0, service.recoverFinishedOrdersWithoutDeduct());
+ verify(cardClient, times(1)).listDeductedOrderCodes(any());
+ verify(mapper, never()).update(any(), any());
+ }
+
+ @Test
+ void compensationKeepsFinishedStatusWhenDeductQueryFails() {
+ TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl();
+ TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class);
+ ChargingCardClient cardClient = mock(ChargingCardClient.class);
+ ReflectionTestUtils.setField(service, "baseMapper", mapper);
+ ReflectionTestUtils.setField(service, "chargingCardClient", cardClient);
+
+ TCardChargingOrder candidate = new TCardChargingOrder();
+ candidate.setId(3L);
+ candidate.setCode("SD-UNKNOWN");
+ candidate.setStatus(5);
+ candidate.setCardId(5L);
+ candidate.setOrderAmount(BigDecimal.ONE);
+ when(mapper.selectList(any())).thenReturn(Collections.singletonList(candidate));
+ // 账户服务不可用时无法确认扣款结果,必须保持原状态等待下轮,不能误回退
+ when(cardClient.listDeductedOrderCodes(any())).thenReturn(R.fail("account unavailable"));
+
+ assertEquals(0, service.recoverFinishedOrdersWithoutDeduct());
+ verify(mapper, never()).update(any(), any());
+ }
+
+ @Test
+ void compensationSkipsRemoteQueryWhenNoFinishedCandidate() {
+ TCardChargingOrderServiceImpl service = new TCardChargingOrderServiceImpl();
+ TCardChargingOrderMapper mapper = mock(TCardChargingOrderMapper.class);
+ ChargingCardClient cardClient = mock(ChargingCardClient.class);
+ ReflectionTestUtils.setField(service, "baseMapper", mapper);
+ ReflectionTestUtils.setField(service, "chargingCardClient", cardClient);
+
+ when(mapper.selectList(any())).thenReturn(Collections.emptyList());
+
+ assertEquals(0, service.recoverFinishedOrdersWithoutDeduct());
+ verify(cardClient, never()).listDeductedOrderCodes(any());
+ verify(mapper, never()).update(any(), any());
+ }
+
+ @Test
+ void compensationSkipsOrderWhenSettleLockNotAcquired() 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(false)));
+
+ TCardChargingOrder candidate = new TCardChargingOrder();
+ candidate.setId(4L);
+ candidate.setCode("SD-LOCKED");
+ candidate.setStatus(5);
+ candidate.setCardId(5L);
+ candidate.setOrderAmount(BigDecimal.ONE);
+ when(mapper.selectList(any())).thenReturn(Collections.singletonList(candidate));
+ R<java.util.List<String>> noDeductRecords = okWithData(Collections.emptyList());
+ when(cardClient.listDeductedOrderCodes(any())).thenReturn(noDeductRecords);
+
+ assertEquals(0, service.recoverFinishedOrdersWithoutDeduct());
+ verify(cardClient, times(1)).listDeductedOrderCodes(any());
+ verify(mapper, never()).update(any(), any());
+ }
}
diff --git a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/util/task/TaskUtilCompensationTest.java b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/util/task/TaskUtilCompensationTest.java
index d8293b5..56125cf 100644
--- a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/util/task/TaskUtilCompensationTest.java
+++ b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/util/task/TaskUtilCompensationTest.java
@@ -1,13 +1,21 @@
package com.ruoyi.order.util.task;
import com.ruoyi.integration.api.model.UploadRealTimeMonitoringData;
+import com.ruoyi.order.service.TCardChargingOrderService;
import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
+import java.util.Collections;
import java.util.Date;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
class TaskUtilCompensationTest {
@@ -35,6 +43,24 @@
assertTrue(TaskUtil.shouldFallbackSettle(null, LocalDateTime.now().minusHours(25), false));
}
+ @Test
+ void cardCompensationIsolatesDeductRecoveryFailure() {
+ TaskUtil taskUtil = new TaskUtil();
+ TCardChargingOrderService cardChargingOrderService = mock(TCardChargingOrderService.class);
+ ReflectionTestUtils.setField(taskUtil, "cardChargingOrderService", cardChargingOrderService);
+ // 未扣款回退扫描异常不应阻断后续启动超时/活动订单扫描
+ when(cardChargingOrderService.recoverFinishedOrdersWithoutDeduct())
+ .thenThrow(new RuntimeException("account unavailable"));
+ when(cardChargingOrderService.listTimeoutStartingOrders()).thenReturn(Collections.emptyList());
+ when(cardChargingOrderService.listTimeoutChargingOrders()).thenReturn(Collections.emptyList());
+
+ assertDoesNotThrow(() -> ReflectionTestUtils.invokeMethod(taskUtil, "compensateCardOrders"));
+
+ verify(cardChargingOrderService).recoverFinishedOrdersWithoutDeduct();
+ verify(cardChargingOrderService).listTimeoutStartingOrders();
+ verify(cardChargingOrderService).listTimeoutChargingOrders();
+ }
+
private UploadRealTimeMonitoringData monitoringData(Date createTime) {
UploadRealTimeMonitoringData data = new UploadRealTimeMonitoringData();
data.setCreate_time(createTime);
--
Gitblit v1.7.1