ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TCardChargingOrder.java
@@ -2,6 +2,7 @@ import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; @@ -33,6 +34,7 @@ private Integer fleetId; @ApiModelProperty("最大充电结束时间(授权截止时间,全天授权或未开启自动停充时为空)") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") @TableField("max_end_time") private LocalDateTime maxEndTime; ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TChargingOrder.java
@@ -233,6 +233,7 @@ private Integer fleetId; @ApiModelProperty(value = "最大充电结束时间(企业授权截止时间,全天授权或未开启自动停充时为空)") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") @TableField("max_end_time") private LocalDateTime maxEndTime; ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/vo/ChargingOrderVO.java
@@ -85,6 +85,7 @@ @ApiModelProperty(value = "账户模式(1=共享企业账户,2=分配卡账户;VIN/刷卡订单建单快照)") private Integer accountMode; @ApiModelProperty(value = "授权结束时间(企业政策授权截止时间,全天授权为空)") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime maxEndTime; @ApiModelProperty(value = "锁定额度(申请时冻结金额,实扣冻结口径)") private BigDecimal lockedAmount; ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java
@@ -10,6 +10,7 @@ import java.math.RoundingMode; import java.text.DecimalFormat; import java.time.*; import java.util.concurrent.TimeUnit; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; @@ -1871,6 +1872,12 @@ /** * 安全检测等待窗口(秒):设备在此时长内未上报有效安全检测时,前端按「未插枪」提示用户, * 超时订单由启动超时兜底任务自动取消,避免订单永久停留在启动中状态。 */ private static final long STARTUP_WAIT_SECONDS = 90L; @ResponseBody @GetMapping(value = "/preChargeCheck/{id}") @ApiOperation(value = "获取安全检测数据", tags = {"小程序-扫一扫"}) @@ -1881,9 +1888,16 @@ String key = "AQJC_" + id; PreChargeCheck cacheObject = redisService.getCacheObject(key); if(null != cacheObject){ if(LocalDateTime.now().minusMinutes(1).isAfter(cacheObject.getAddTime())){ redisService.deleteObject(key); cacheObject = null; LocalDateTime addTime = cacheObject.getAddTime(); // 等待安全检测超出窗口且启动指令尚未下发时,按「未插枪」提示前端; // 缓存必须保留:设备安全检测上报依赖该缓存推进启动,删除会导致订单永久卡在启动中 if (Integer.valueOf(1).equals(cacheObject.getStartupSuccess()) && !Boolean.TRUE.equals(cacheObject.getStartCommandSent()) && null != addTime && LocalDateTime.now().isAfter(addTime.plusSeconds(STARTUP_WAIT_SECONDS))) { cacheObject.setStartupSuccess(3); cacheObject.setFailureCause(5); redisService.setCacheObject(key, cacheObject, 24L, TimeUnit.HOURS); } } return AjaxResult.success(cacheObject); ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java
@@ -306,7 +306,9 @@ result.put("discountServiceCharge", order.getDiscountServiceCharge()); // 账户模式/授权截止时间/申请锁定额度(刷卡链路改造) result.put("accountMode", order.getAccountMode()); result.put("maxEndTime", order.getMaxEndTime()); // Map 值不走实体字段上的 @JsonFormat,显式格式化,避免前端展示 ISO 格式(含 T) result.put("maxEndTime", order.getMaxEndTime() == null ? null : order.getMaxEndTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); result.put("lockedAmount", order.getLockedAmount()); result.put("total", defaultDecimal(order.getElectrovalence()).add(defaultDecimal(order.getServiceCharge()))); result.put("realTotal", order.getPaymentAmount()); ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java
@@ -1936,13 +1936,7 @@ chargingOrder.setPayTime(callbackPayTime); //添加安全检测数据到缓存中,每步安全检测完成后需要更新缓存数据 PreChargeCheck preChargeCheck = new PreChargeCheck(); preChargeCheck.setElectronicLockLock(false); preChargeCheck.setInsulationTesting(false); preChargeCheck.setSecureConnectionDetection(false); preChargeCheck.setStartupSuccess(1); preChargeCheck.setStartCommandSent(false); preChargeCheck.setAddTime(chargingOrder.getReservationTime() != null ? chargingOrder.getReservationTime() : LocalDateTime.now()); PreChargeCheck preChargeCheck = newPreChargeCheck(chargingOrder); String key = "AQJC_" + chargingOrder.getChargingGunId(); redisService.setCacheObject(key, preChargeCheck, 24L, TimeUnit.HOURS); @@ -2082,6 +2076,33 @@ && Boolean.TRUE.equals(check.getSecureConnectionDetection()); } /** * 查询充电枪进行中的待启动订单(支付完成、启动中) */ private TChargingOrder getPendingStartOrder(Integer chargingGunId) { return this.getOne(new LambdaQueryWrapper<TChargingOrder>() .eq(TChargingOrder::getChargingGunId, chargingGunId) .eq(TChargingOrder::getRechargePaymentStatus, 2) .eq(TChargingOrder::getStatus, 2) .eq(TChargingOrder::getDelFlag, 0) .orderByDesc(TChargingOrder::getCreateTime) .last("limit 1")); } /** * 初始化安全检测等待对象(与建单/支付完成时的初始状态保持一致) */ private PreChargeCheck newPreChargeCheck(TChargingOrder chargingOrder) { PreChargeCheck preChargeCheck = new PreChargeCheck(); preChargeCheck.setElectronicLockLock(false); preChargeCheck.setInsulationTesting(false); preChargeCheck.setSecureConnectionDetection(false); preChargeCheck.setStartupSuccess(1); preChargeCheck.setStartCommandSent(false); preChargeCheck.setAddTime(chargingOrder.getReservationTime() != null ? chargingOrder.getReservationTime() : LocalDateTime.now()); return preChargeCheck; } private String normalizeVin(String vin) { return StringUtils.hasLength(vin) ? vin.replaceAll("\\s+", "").toUpperCase(Locale.ROOT) : null; } @@ -2119,6 +2140,44 @@ } } /** * 启动超时兜底:订单支付完成后长时间未真正开始充电(等待安全检测无进展、启动指令未生效), * 自动取消订单并释放锁定额度,避免订单永久停留在「启动中」导致该枪无法再次启动。 */ @Scheduled(fixedDelay = 30000L) public void cancelStartupTimeoutOrders() { LocalDateTime deadline = LocalDateTime.now().minusMinutes(3); List<TChargingOrder> timeoutOrders = this.list(new LambdaQueryWrapper<TChargingOrder>() .eq(TChargingOrder::getRechargePaymentStatus, 2) .eq(TChargingOrder::getStatus, 2) .eq(TChargingOrder::getDelFlag, 0) .isNull(TChargingOrder::getStartTime) .isNull(TChargingOrder::getReservationTime) .lt(TChargingOrder::getPayTime, deadline) .last("limit 100")); for (TChargingOrder order : timeoutOrders) { try { PreChargeCheck check = redisService.getCacheObject("AQJC_" + order.getChargingGunId()); if (null != check && Boolean.TRUE.equals(check.getStartCommandSent())) { // 已下发启动指令,交给既有启动结果检测(timingDetection)处理,避免误取消 continue; } log.warn("订单编号={}:等待安全检测超时,自动取消订单并释放额度,充电枪id={}", order.getCode(), order.getChargingGunId()); refund(order.getCode()); TChargingOrder finish = new TChargingOrder(); finish.setId(order.getId()); finish.setAppUserId(order.getAppUserId()); finish.setStatus(5); finish.setEndMode(0); finish.setEndTime(LocalDateTime.now()); this.updateById(finish); redisService.deleteObject("AQJC_" + order.getChargingGunId()); } catch (Exception e) { log.error("启动超时订单自动取消失败,orderCode={}", order.getCode(), e); } } } private void startChargingAfterSafety(TChargingOrder chargingOrder, PreChargeCheck check) { RLock lock = redissonClient.getLock("START_AFTER_SAFETY:" + chargingOrder.getId()); try { @@ -2152,12 +2211,18 @@ command.setCharging_gun_code(gun.getCode()); command.setCard_number(latest.getId().toString()); command.setAccount_balance(latest.getChargeAmount()); check.setStartCommandSent(true); latest.setActualVin(actualVin); this.updateById(latest); redisService.setCacheObject("AQJC_" + latest.getChargingGunId(), check, 24L, TimeUnit.HOURS); log.info(latest.getCode() + ":安全检测及VIN校验通过,下发启动充电命令:" + command); try { sendMessageClient.platformStartCharging(command); } catch (Exception e) { // 下发失败不置 startCommandSent:保留后续安全检测上报重试机会,超时订单由兜底任务取消 log.error(latest.getCode() + ":启动充电命令下发失败,等待下次安全检测上报重试", e); return; } check.setStartCommandSent(true); redisService.setCacheObject("AQJC_" + latest.getChargingGunId(), check, 24L, TimeUnit.HOURS); ScheduledExecutorService resultScheduler = Executors.newScheduledThreadPool(1); resultScheduler.scheduleAtFixedRate(() -> { if (timingDetection(latest.getId())) { @@ -2196,8 +2261,13 @@ Integer failure_cause = platformStartChargingReply.getFailure_cause(); Integer counter = counter_map.get(code); PreChargeCheck preChargeCheck1 = redisService.getCacheObject(key); //15分钟内还未插枪则取消充电,退回金额。 if (failure_cause == 5 && (null == counter || counter < 60 * 15)) { if (null == preChargeCheck1) { // 缓存缺失时用临时对象推进,避免启动结果补偿因空缓存中断 preChargeCheck1 = new PreChargeCheck(); preChargeCheck1.setStartupSuccess(1); } //15分钟内还未插枪则取消充电,退回金额。按10秒探测一次,90次约15分钟 if (failure_cause == 5 && (null == counter || counter < 90)) { counter = (null == counter ? 0 : counter) + 1; counter_map.put(code, counter); //启动失败 @@ -2248,6 +2318,11 @@ return true; } else { PreChargeCheck preChargeCheck1 = redisService.getCacheObject(key); if (null == preChargeCheck1) { // 缓存缺失时用临时对象推进,避免启动结果补偿因空缓存中断 preChargeCheck1 = new PreChargeCheck(); preChargeCheck1.setStartupSuccess(1); } UploadRealTimeMonitoringData uploadRealTimeMonitoringData = uploadRealTimeMonitoringDataService.getDataByOrderCode(code, "desc"); if (null != uploadRealTimeMonitoringData) { //启动成功 @@ -2279,8 +2354,8 @@ } else { Integer counter = boot_failed_map.get(code); log.info(code + ":-------------------未上传开启充电结果-------------------" + counter); //15分钟内未启动成功,退回金额。 if (null == counter || counter < 60 * 15) { //15分钟内未启动成功,退回金额。按10秒探测一次,90次约15分钟 if (null == counter || counter < 90) { counter = (null == counter ? 0 : counter) + 1; boot_failed_map.put(code, counter); //启动失败 @@ -2332,25 +2407,43 @@ GetChargingGunByCode code = new GetChargingGunByCode(); code.setCharging_pile_code(securityDetection.getCharging_pile_code()); code.setCharging_gun_code(securityDetection.getCharging_gun_code()); TChargingGun chargingGun = chargingGunClient.getChargingGunByCode(code).getData(); PreChargeCheck preChargeCheck1 = redisService.getCacheObject("AQJC_" + chargingGun.getId()); if (null != preChargeCheck1) { TChargingGun chargingGun = null; try { chargingGun = chargingGunClient.getChargingGunByCode(code).getData(); } catch (Exception e) { log.error("安全检测上报-查询充电枪异常,charging_pile_code={},charging_gun_code={}", securityDetection.getCharging_pile_code(), securityDetection.getCharging_gun_code(), e); } if (null == chargingGun) { log.warn("安全检测上报-未匹配到充电枪,忽略,charging_pile_code={},charging_gun_code={}", securityDetection.getCharging_pile_code(), securityDetection.getCharging_gun_code()); return; } String key = "AQJC_" + chargingGun.getId(); PreChargeCheck preChargeCheck1 = redisService.getCacheObject(key); if (null == preChargeCheck1) { // 缓存缺失(等待窗口内未收到上报、缓存被清理)时按该枪进行中的待启动订单重建, // 保证设备后续安全检测上报仍能推进启动,避免订单永久卡在启动中 TChargingOrder pendingOrder = getPendingStartOrder(chargingGun.getId()); if (null == pendingOrder) { log.info("安全检测上报-无待启动订单,忽略,充电枪id={},charging_pile_code={},charging_gun_code={}", chargingGun.getId(), securityDetection.getCharging_pile_code(), securityDetection.getCharging_gun_code()); return; } preChargeCheck1 = newPreChargeCheck(pendingOrder); log.warn("安全检测上报-AQJC缓存缺失,已按待启动订单重建,订单编号={},充电枪id={}", pendingOrder.getCode(), chargingGun.getId()); } preChargeCheck1.setElectronicLockLock(Integer.valueOf(1).equals(securityDetection.getElectronic_lock_lock())); preChargeCheck1.setInsulationTesting(Integer.valueOf(0).equals(securityDetection.getNsulation_detection())); preChargeCheck1.setSecureConnectionDetection(Integer.valueOf(1).equals(securityDetection.getSecure_connection())); preChargeCheck1.setActualVin(normalizeVin(securityDetection.getVin())); redisService.setCacheObject("AQJC_" + chargingGun.getId(), preChargeCheck1, 24L, TimeUnit.HOURS); // 刷新等待基准时间,避免前端等待窗口/超时判断误伤正在推进的安全检测 preChargeCheck1.setAddTime(LocalDateTime.now()); redisService.setCacheObject(key, preChargeCheck1, 24L, TimeUnit.HOURS); if (isSafetyCheckPassed(preChargeCheck1)) { TChargingOrder pendingOrder = this.getOne(new LambdaQueryWrapper<TChargingOrder>() .eq(TChargingOrder::getChargingGunId, chargingGun.getId()) .eq(TChargingOrder::getRechargePaymentStatus, 2) .eq(TChargingOrder::getStatus, 2) .eq(TChargingOrder::getDelFlag, 0) .orderByDesc(TChargingOrder::getCreateTime) .last("limit 1")); TChargingOrder pendingOrder = getPendingStartOrder(chargingGun.getId()); if (pendingOrder != null) { startChargingAfterSafety(pendingOrder, preChargeCheck1); } } } } @@ -2375,6 +2468,11 @@ preChargeCheck1.setElectronicLockLock(true); preChargeCheck1.setInsulationTesting(true); preChargeCheck1.setStartupSuccess(1); } else { // 缓存缺失时用临时对象推进订单状态,避免设备已启动成功而订单因缓存为空无法流转 preChargeCheck1 = new PreChargeCheck(); preChargeCheck1.setElectronicLockLock(true); preChargeCheck1.setInsulationTesting(true); } if (0 == startup_result) { //启动失败 @@ -2769,19 +2867,22 @@ @Override public ChargingDetails getChargingDetails(Integer id) { Long userId = tokenService.getLoginUserApplet().getUserId(); // 状态包含已结束(5):订单结束后小程序需继续拿到该笔订单,用于关闭「请勿拔枪,正在结束充电」 // 并展示「充电完成,可拔枪」;仅取最近一笔,避免历史完成订单长期滞留结束态 TChargingOrder one = this.getOne(new LambdaQueryWrapper<TChargingOrder>().eq(TChargingOrder::getAppUserId, userId).eq(TChargingOrder::getChargingGunId, id) .eq(TChargingOrder::getRechargePaymentStatus, 2) .in(TChargingOrder::getStatus, Arrays.asList(2, 3, 4)) .in(TChargingOrder::getStatus, Arrays.asList(2, 3, 4, 5)) .eq(TChargingOrder::getDelFlag, 0) .orderByDesc(TChargingOrder::getCreateTime) .last(" limit 0, 1")); if (null == one || !one.getAppUserId().equals(userId)) { // VIN 码充电订单兜底:VD 订单存于 t_vin_charging_order,且后付模式无支付动作, // 不按 rechargePaymentStatus 过滤;TVinChargingOrder 继承 TChargingOrder,后续组装原样复用 // 状态同样包含已结束(5),保证 VIN 订单结束后前端仍能拿到完成态 TVinChargingOrder vinOrder = vinChargingOrderMapper.selectOne(new LambdaQueryWrapper<TVinChargingOrder>() .eq(TVinChargingOrder::getAppUserId, userId) .eq(TVinChargingOrder::getChargingGunId, id) .in(TVinChargingOrder::getStatus, Arrays.asList(2, 3, 4)) .in(TVinChargingOrder::getStatus, Arrays.asList(2, 3, 4, 5)) .eq(TVinChargingOrder::getDelFlag, 0) .orderByDesc(TVinChargingOrder::getCreateTime) .last(" limit 0, 1")); @@ -8240,13 +8341,7 @@ //添加安全检测数据到缓存中,每步安全检测完成后需要更新缓存数据 PreChargeCheck preChargeCheck = new PreChargeCheck(); preChargeCheck.setElectronicLockLock(false); preChargeCheck.setInsulationTesting(false); preChargeCheck.setSecureConnectionDetection(false); preChargeCheck.setStartupSuccess(1); preChargeCheck.setStartCommandSent(false); preChargeCheck.setAddTime(chargingOrder.getReservationTime() != null ? chargingOrder.getReservationTime() : LocalDateTime.now()); PreChargeCheck preChargeCheck = newPreChargeCheck(chargingOrder); String key = "AQJC_" + chargingOrder.getChargingGunId(); redisService.setCacheObject(key, preChargeCheck, 24L, TimeUnit.HOURS); ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TChargingOrderServiceImplVinFallbackTest.java
@@ -18,6 +18,8 @@ import com.ruoyi.order.util.mongodb.service.UploadRealTimeMonitoringDataService; import com.ruoyi.order.vo.EndOfChargePageInfo; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.conditions.AbstractWrapper; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.ruoyi.system.api.model.LoginUserApplet; import org.apache.ibatis.builder.MapperBuilderAssistant; @@ -25,6 +27,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -32,13 +35,19 @@ import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -231,4 +240,98 @@ assertNotNull(exception); assertEquals("充电订单不存在", exception.getMessage()); } @Test void getChargingDetailsReturnsFinishedNormalOrderAndQueriesStatus5() { // 小程序主动结束充电后订单已结束(5),充电中页面仍需拿到该笔订单, // 用于关闭「请勿拔枪,正在结束充电」并展示「充电完成,可拔枪」 LoginUserApplet loginUser = new LoginUserApplet(); loginUser.setUserId(88L); when(tokenService.getLoginUserApplet()).thenReturn(loginUser); TChargingOrder order = new TChargingOrder(); order.setId(3003L); order.setAppUserId(88L); order.setChargingGunId(5); order.setStatus(5); order.setCode("CH20261014001"); order.setResidualAmount(new BigDecimal("20.00")); when(mapper.selectOne(any())).thenReturn(order); stubGunPileAndSite(); when(uploadRealTimeMonitoringDataService.getLastDataById("CH20261014001")).thenReturn(null); ChargingDetails details = service.getChargingDetails(5); assertNotNull(details, "订单已结束时应返回完成态而非 null"); assertEquals("3003", details.getId()); assertEquals(Integer.valueOf(5), details.getStatus()); assertEquals("CH20261014001", details.getCode()); assertEquals(0, new BigDecimal("20.00").compareTo(details.getChargingCost())); @SuppressWarnings("unchecked") ArgumentCaptor<Wrapper<TChargingOrder>> captor = ArgumentCaptor.forClass(Wrapper.class); verify(mapper).selectOne(captor.capture()); assertStatusFilterCoversFinished(captor.getValue()); } @Test void getChargingDetailsReturnsFinishedVinOrderAndQueriesStatus5() { // VIN 码订单结束后同样需要返回完成态,兜底查询的状态过滤必须与主查询一致 LoginUserApplet loginUser = new LoginUserApplet(); loginUser.setUserId(88L); when(tokenService.getLoginUserApplet()).thenReturn(loginUser); when(mapper.selectOne(any())).thenReturn(null); TVinChargingOrder vinOrder = new TVinChargingOrder(); vinOrder.setId(1001L); vinOrder.setAppUserId(88L); vinOrder.setChargingGunId(5); vinOrder.setStatus(5); vinOrder.setCode("VD20261014001"); vinOrder.setResidualAmount(new BigDecimal("66.60")); when(vinChargingOrderMapper.selectOne(any())).thenReturn(vinOrder); stubGunPileAndSite(); when(uploadRealTimeMonitoringDataService.getLastDataById("VD20261014001")).thenReturn(null); ChargingDetails details = service.getChargingDetails(5); assertNotNull(details, "VIN 订单已结束时应返回完成态而非 null"); assertEquals(Integer.valueOf(5), details.getStatus()); assertEquals("VD20261014001", details.getCode()); @SuppressWarnings("unchecked") ArgumentCaptor<Wrapper<TVinChargingOrder>> captor = ArgumentCaptor.forClass(Wrapper.class); verify(vinChargingOrderMapper).selectOne(captor.capture()); assertStatusFilterCoversFinished(captor.getValue()); } /** getChargingDetails 组装链路依赖的充电枪/充电桩/站点远程调用 stub */ private void stubGunPileAndSite() { TChargingGun gun = new TChargingGun(); gun.setChargingPileId(7); when(chargingGunClient.getChargingGunById(5)).thenReturn(R.ok(gun)); TChargingPile pile = new TChargingPile(); pile.setName("1号桩"); pile.setSiteId(9); when(chargingPileClient.getChargingPileById(7)).thenReturn(R.ok(pile)); Site site = new Site(); site.setName("城东站"); when(siteClient.getSiteByIds(Arrays.asList(9))).thenReturn(R.ok(Collections.singletonList(site))); } /** * 断言订单查询条件中的 status 过滤覆盖 2、3、4、5。 * 已结束(5) 必须在过滤范围内,否则小程序结束充电后拿不到订单,会一直停留在「正在结束充电」提示。 */ private void assertStatusFilterCoversFinished(Wrapper<?> wrapper) { AbstractWrapper<?, ?, ?> queryWrapper = (AbstractWrapper<?, ?, ?>) wrapper; Matcher inMatcher = Pattern.compile("status\\s+in\\s*\\(([^)]*)\\)", Pattern.CASE_INSENSITIVE) .matcher(queryWrapper.getSqlSegment()); assertTrue(inMatcher.find(), "查询条件中应存在 status IN 过滤"); List<Object> statusValues = new ArrayList<>(); Matcher placeholderMatcher = Pattern.compile("MPGENVAL\\d+").matcher(inMatcher.group(1)); while (placeholderMatcher.find()) { statusValues.add(queryWrapper.getParamNameValuePairs().get(placeholderMatcher.group())); } assertTrue(statusValues.containsAll(Arrays.asList(2, 3, 4, 5)), "status 过滤应包含 2,3,4,5,实际为:" + statusValues); } } ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TChargingOrderStartupTimeoutTest.java
New file @@ -0,0 +1,187 @@ package com.ruoyi.order.service.impl; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.ruoyi.chargingPile.api.feignClient.ChargingGunClient; import com.ruoyi.chargingPile.api.model.TChargingGun; import com.ruoyi.common.core.domain.R; import com.ruoyi.common.redis.service.RedisService; import com.ruoyi.order.api.model.TChargingOrder; import com.ruoyi.order.api.vo.SecurityDetectionVO; import com.ruoyi.order.dto.PreChargeCheck; import com.ruoyi.order.mapper.TChargingOrderMapper; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import java.util.Collections; import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * 启动等待链路单测:安全检测上报缓存自愈重建、启动超时订单兜底取消 */ class TChargingOrderStartupTimeoutTest { private static final Integer GUN_ID = 1967; private static final String ORDER_CODE = "CD3720261014104949469"; /** * 纯单测无 Spring 容器,MyBatis-Plus 的 lambda 列缓存需手工初始化 */ @BeforeAll static void initMybatisPlusTableInfo() { MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); TableInfoHelper.initTableInfo(assistant, TChargingOrder.class); } private TChargingOrderServiceImpl service; private TChargingOrderMapper mapper; private RedisService redisService; private ChargingGunClient chargingGunClient; @BeforeEach void setUp() { service = spy(new TChargingOrderServiceImpl()); mapper = mock(TChargingOrderMapper.class); redisService = mock(RedisService.class); chargingGunClient = mock(ChargingGunClient.class); ReflectionTestUtils.setField(service, "baseMapper", mapper); ReflectionTestUtils.setField(service, "redisService", redisService); ReflectionTestUtils.setField(service, "chargingGunClient", chargingGunClient); } /** * 缓存缺失时按待启动订单重建,保证设备安全检测上报仍能推进启动 */ @Test void securityDetectionRebuildsCacheWhenCacheMissing() { when(chargingGunClient.getChargingGunByCode(any())).thenReturn(R.ok(chargingGun())); when(redisService.getCacheObject("AQJC_" + GUN_ID)).thenReturn(null); when(mapper.selectOne(any())).thenReturn(pendingOrder()); // 绝缘检测未通过(1=不安全)时仅更新缓存,不进入启动下发 service.securityDetection(securityDetectionVO(1, 1, 1)); ArgumentCaptor<PreChargeCheck> captor = ArgumentCaptor.forClass(PreChargeCheck.class); verify(redisService).setCacheObject(eq("AQJC_" + GUN_ID), captor.capture(), eq(24L), eq(TimeUnit.HOURS)); PreChargeCheck saved = captor.getValue(); assertEquals(Integer.valueOf(1), saved.getStartupSuccess(), "重建后应回到等待启动状态"); assertEquals(Boolean.TRUE, saved.getElectronicLockLock()); assertEquals(Boolean.FALSE, saved.getInsulationTesting()); assertNotNull(saved.getAddTime(), "重建后应刷新等待基准时间"); } /** * 缓存缺失且无待启动订单时忽略上报,不产生脏缓存 */ @Test void securityDetectionIgnoresWhenNoPendingOrder() { when(chargingGunClient.getChargingGunByCode(any())).thenReturn(R.ok(chargingGun())); when(redisService.getCacheObject("AQJC_" + GUN_ID)).thenReturn(null); when(mapper.selectOne(any())).thenReturn(null); service.securityDetection(securityDetectionVO(1, 0, 1)); verify(redisService, never()).setCacheObject(anyString(), any(), anyLong(), any(TimeUnit.class)); } /** * 等待安全检测超时的订单自动取消:退款、推进为已结束并清理缓存 */ @Test void startupTimeoutOrderIsCancelled() { when(mapper.selectList(any())).thenReturn(Collections.singletonList(pendingOrder())); when(redisService.getCacheObject("AQJC_" + GUN_ID)).thenReturn(null); doNothing().when(service).refund(ORDER_CODE); service.cancelStartupTimeoutOrders(); ArgumentCaptor<TChargingOrder> captor = ArgumentCaptor.forClass(TChargingOrder.class); verify(mapper).updateById(captor.capture()); TChargingOrder finished = captor.getValue(); assertEquals(Integer.valueOf(5), finished.getStatus()); assertEquals(Integer.valueOf(0), finished.getEndMode()); assertNotNull(finished.getEndTime()); verify(service).refund(ORDER_CODE); verify(redisService).deleteObject("AQJC_" + GUN_ID); } /** * 已下发启动指令的订单交给启动结果检测处理,兜底任务不得误取消 */ @Test void startupTimeoutOrderWithCommandSentIsSkipped() { when(mapper.selectList(any())).thenReturn(Collections.singletonList(pendingOrder())); PreChargeCheck check = new PreChargeCheck(); check.setStartCommandSent(true); when(redisService.getCacheObject("AQJC_" + GUN_ID)).thenReturn(check); service.cancelStartupTimeoutOrders(); verify(service, never()).refund(anyString()); verify(mapper, never()).updateById(any()); verify(redisService, never()).deleteObject(anyString()); } /** * 初始化安全检测等待对象:预约订单以预约时间为等待基准 */ @Test void newPreChargeCheckUsesReservationTimeAsWaitBase() { LocalDateTime reservationTime = LocalDateTime.now().plusMinutes(5); TChargingOrder order = pendingOrder(); order.setReservationTime(reservationTime); PreChargeCheck check = ReflectionTestUtils.invokeMethod(service, "newPreChargeCheck", order); assertNotNull(check); assertEquals(reservationTime, check.getAddTime()); assertEquals(Integer.valueOf(1), check.getStartupSuccess()); assertEquals(Boolean.FALSE, check.getStartCommandSent()); } private static TChargingGun chargingGun() { TChargingGun gun = new TChargingGun(); gun.setId(GUN_ID); return gun; } private static TChargingOrder pendingOrder() { TChargingOrder order = new TChargingOrder(); order.setId(2110201381176229890L); order.setCode(ORDER_CODE); order.setChargingGunId(GUN_ID); order.setStatus(2); order.setRechargePaymentStatus(2); order.setDelFlag(false); order.setPayTime(LocalDateTime.now().minusMinutes(10)); return order; } private static SecurityDetectionVO securityDetectionVO(Integer electronicLock, Integer insulation, Integer secureConnection) { SecurityDetectionVO vo = new SecurityDetectionVO(); vo.setCharging_pile_code("8832010600495288"); vo.setCharging_gun_code("02"); vo.setElectronic_lock_lock(electronicLock); vo.setNsulation_detection(insulation); vo.setSecure_connection(secureConnection); return vo; } }