From 5d45ba0c31bf1e861cfc3bc1e5343488f6ea23ab Mon Sep 17 00:00:00 2001
From: puzhibing <393733352@qq.com>
Date: 星期二, 15 九月 2026 18:37:02 +0800
Subject: [PATCH] 修改bug和定时任务修改

---
 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