yanderong
2026-09-16 d4d70fe3a3a20f5fc29c9f37a1f89854a376597f
Merge remote-tracking branch 'origin/2.3' into 2.3
16个文件已修改
413 ■■■■■ 已修改文件
doc/耗时统计.md 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/ChargingCardFallbackFactory.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/ChargingCardClient.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TChargingCardController.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/mapper/TChargingCardMapper.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TChargingCardService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TChargingCardServiceImpl.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TChargingCardMapper.xml 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/controller/TChargingCardControllerTest.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TChargingCardServiceImplTest.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TCardChargingOrderService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/task/TaskUtil.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TCardChargingOrderAmountFieldsTest.java 179 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/util/task/TaskUtilCompensationTest.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/耗时统计.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,提取局部变量后修复 |
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());
            }
        };
    }
}
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);
}
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));
    }
}
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);
}
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);
}
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 {
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>
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);
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);
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);
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);
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");
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);
            }
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());
    }
}
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);