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