Prechádzať zdrojové kódy

fix: 稳定性修复——停止充电任务订单匹配过滤+判空+失败重试,启动充电Redis锁防重复建单,设备状态拆箱NPE防护,进行中订单查询limit 1兜底,EN+推送判空,对账响应取sum,解密失败显式抛错,恢复saasClose注入

skyline 3 týždňov pred
rodič
commit
7855381478

+ 2 - 2
common/src/main/java/com/kym/common/utils/PlatformAesUtil.java

@@ -108,9 +108,9 @@ public class PlatformAesUtil {
             return new String(result, StandardCharsets.UTF_8);
         } catch (Exception ex) {
             Logger.getLogger(PlatformAesUtil.class.getName()).log(Level.SEVERE, null, ex);
+            // 解密失败必须抛出异常,禁止静默返回null导致上游NPE
+            throw new IllegalArgumentException("平台推送数据解密失败", ex);
         }
-
-        return null;
     }
 
     /**

+ 10 - 0
entity/src/main/java/com/kym/entity/common/RedisKeys.java

@@ -55,4 +55,14 @@ public interface RedisKeys {
      * 微信支付回调处理分布式锁(key拼接商户订单号)
      */
     String PAY_NOTIFY_LOCK = "PAY_NOTIFY_LOCK:";
+
+    /**
+     * 优惠券领取分布式锁(key拼接优惠券id+用户id)
+     */
+    String COUPON_LOCK = "COUPON_LOCK:";
+
+    /**
+     * 用户启动充电分布式锁(key拼接用户id)
+     */
+    String START_CHARGE_LOCK = "lock:start:charge:";
 }

+ 18 - 8
miniapp/src/main/java/com/kym/miniapp/jobs/StopChargeDelayJob.java

@@ -18,6 +18,7 @@ import org.springframework.context.event.EventListener;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Component;
 
+import java.time.LocalDateTime;
 import java.util.concurrent.DelayQueue;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -88,7 +89,6 @@ public class StopChargeDelayJob implements DelayService<DelayChargeOrder> {
         // 启动线程池中的所有线程处理延迟队列
         for (int i = 0; i < MAX_THREAD_COUNT; i++) {
             executor.execute(() -> {
-                ThreadLocal<String> threadLocal = ThreadLocal.withInitial(() -> null); // 初始化为空值,避免使用new ThreadLocal()
                 log.info("预约停止充电处理线程:{}", Thread.currentThread().getName());
                 while (true) {
                     DelayedItem<DelayChargeOrder> delayedItem = null;
@@ -98,17 +98,19 @@ public class StopChargeDelayJob implements DelayService<DelayChargeOrder> {
                         log.info("出队预约停止充电订单:{}, 队列剩余:{}", delayedItem.data.getStartChargeSeq(), STOP_DELAY_QUEUE.size());
                         // 停止充电
                         var order = delayedItem.data;
-                        threadLocal.set(order.getStartChargeSeq());
-                        // 查询该设备最新订单是否是当前预约订单,是则按计划停止(优化:使用LIMIT 1代替查询所有)
+                        // 查询该设备最新一笔进行中的订单,与预约订单比对,防止已取消订单干扰匹配
                         var currentChargeOrder = chargeOrderService.lambdaQuery()
                                 .eq(ChargeOrder::getConnectorId, order.getConnectorId())
+                                .in(ChargeOrder::getChargeStatus, ChargeOrder.CHARGE_STATUS_预约中, ChargeOrder.CHARGE_STATUS_启动中, ChargeOrder.CHARGE_STATUS_充电中, ChargeOrder.CHARGE_STATUS_停止中)
                                 .orderByDesc(ChargeOrder::getCreateTime)
                                 .last("LIMIT 1")
                                 .one();
-                        if (currentChargeOrder.getStartChargeSeq().equals(order.getStartChargeSeq())) {
+                        if (currentChargeOrder == null) {
+                            // 设备已无进行中的订单,订单可能已被人工处理,无需停止,跳过即可
+                            log.warn("预约充电停止跳过:设备{}当前无进行中的订单,预约订单:{}", order.getConnectorId(), order.getStartChargeSeq());
+                        } else if (currentChargeOrder.getStartChargeSeq().equals(order.getStartChargeSeq())) {
                             chargeService.queryStopCharge(order.getUserId(), order.getConnectorId());
                             log.info("预约充电停止成功:用户:{}, 订单号:{}, 预约停止时间:{}", order.getUserId(), order.getStartChargeSeq(), order.getEndTime());
-
                         } else {
                             log.error("预约充电停止异常:订单不匹配:原订单:{}, 当前设备最新订单:{}", order.getStartChargeSeq(), currentChargeOrder.getStartChargeSeq());
                             throw new BusinessException("预约充电停止异常");
@@ -120,10 +122,18 @@ public class StopChargeDelayJob implements DelayService<DelayChargeOrder> {
                             // 线程被中断,退出循环
                             break;
                         } else {
-                            log.info("预约停止充电失败,订单号:{}", threadLocal.get(), e);
+                            log.info("预约停止充电失败,订单号:{}", delayedItem == null ? null : delayedItem.data.getStartChargeSeq(), e);
+                            // 失败重新入队延迟重试,防止订单一直挂“充电中”;超过预约结束时间2小时后放弃并告警
+                            if (delayedItem != null) {
+                                var data = delayedItem.data;
+                                if (LocalDateTime.now().isBefore(data.getEndTime().plusHours(2))) {
+                                    STOP_DELAY_QUEUE.add(new DelayedItem<>(data, LocalDateTime.now().plusSeconds(60)));
+                                    log.warn("预约停止充电重试入队,订单号:{},60秒后重试", data.getStartChargeSeq());
+                                } else {
+                                    log.error("预约停止充电多次失败,放弃重试,订单号:{},请人工核查", data.getStartChargeSeq());
+                                }
+                            }
                         }
-                    } finally {
-                        threadLocal.remove();
                     }
                 }
             });

+ 8 - 1
service/src/main/java/com/kym/service/miniapp/impl/ChargeOrderServiceImpl.java

@@ -74,7 +74,7 @@ public class ChargeOrderServiceImpl extends MyBaseServiceImpl<ChargeOrderMapper,
 
     private final StringRedisTemplate redisTemplate;
 
-//    @Value("${en-plus.sassClose}")
+    @Value("${en-plus.sassClose:}")
     public String saasClose;
 
 
@@ -92,10 +92,13 @@ public class ChargeOrderServiceImpl extends MyBaseServiceImpl<ChargeOrderMapper,
 
     @Override
     public ChargeOrder getChargingOrderByUserId(Long userId) {
+        // 取最新一笔进行中订单;存在脏数据双单时不抛异常,保证用户仍可充电
         return lambdaQuery()
                 .eq(ChargeOrder::getUserId, userId)
                 .eq(ChargeOrder::getOrderStatus, ChargeOrder.ORDER_STATUS_未知)
                 .in(ChargeOrder::getChargeStatus, ChargeOrder.CHARGE_STATUS_预约中, ChargeOrder.CHARGE_STATUS_启动中, ChargeOrder.CHARGE_STATUS_充电中, ChargeOrder.CHARGE_STATUS_停止中)
+                .orderByDesc(ChargeOrder::getCreateTime)
+                .last("limit 1")
                 .one();
     }
 
@@ -286,6 +289,10 @@ public class ChargeOrderServiceImpl extends MyBaseServiceImpl<ChargeOrderMapper,
      */
     @Override
     public void closeChargeOrder(String startChargeSeq) {
+        if (CommUtil.isEmptyOrNull(saasClose)) {
+            log.error("未配置en-plus.sassClose,无法关闭订单:{}", startChargeSeq);
+            throw new BusinessException("关闭订单功能未配置");
+        }
         Headers headers = Headers.of("satoken", redisTemplate.opsForValue().get(RedisKeys.EN_PLUS_SASS_TOKEN));
         var res = HttpUtil.parseJson(HttpUtil.post(saasClose + startChargeSeq, headers));
         if (!(res.containsKey("success") && res.getBoolean("success"))) {

+ 24 - 5
service/src/main/java/com/kym/service/miniapp/impl/ChargeServiceImpl.java

@@ -214,6 +214,20 @@ public class ChargeServiceImpl implements ChargeService {
     @Override
     @DSTransactional(rollbackFor = Exception.class)
     public Map<String, String> queryStartCharge(Long userId, String connectorId, Long userRechargeRightsId, Long userCouponId, Boolean isBooking, LocalDateTime startTime, LocalDateTime endTime) {
+        // 同一用户并发请求互斥,防止双击/重试重复建单
+        String startChargeLockKey = RedisKeys.START_CHARGE_LOCK + userId;
+        Boolean locked = redisTemplate.opsForValue().setIfAbsent(startChargeLockKey, "1", Duration.ofSeconds(10));
+        if (Boolean.FALSE.equals(locked)) {
+            throw new BusinessException("操作过于频繁,请稍后再试");
+        }
+        try {
+            return doQueryStartCharge(userId, connectorId, userRechargeRightsId, userCouponId, isBooking, startTime, endTime);
+        } finally {
+            redisTemplate.delete(startChargeLockKey);
+        }
+    }
+
+    private Map<String, String> doQueryStartCharge(Long userId, String connectorId, Long userRechargeRightsId, Long userCouponId, Boolean isBooking, LocalDateTime startTime, LocalDateTime endTime) {
         connectorId = KymCache.INSTANCE.getConnectorId(connectorId);
         var stationId = KymCache.INSTANCE.getStationIdByEquipmentIdOrConnectorId(connectorId);
         LOGGER.info("用户:{},站点:{},设备:{}请求充电", userId, stationId, connectorId);
@@ -342,14 +356,18 @@ public class ChargeServiceImpl implements ChargeService {
             throw new BusinessException("请输入正确的设备编号");
         }
 
-        // 设备是否插枪
-        if (KymCache.INSTANCE.getConnectorStatus(connectorId) == EquipmentInfo.SERVICE_STATUS_空闲) {
+        // 设备是否插枪(设备状态未知时直接提示,防止拆箱NPE)
+        var connectorStatus = KymCache.INSTANCE.getConnectorStatus(connectorId);
+        if (connectorStatus == null) {
+            throw new BusinessException("设备状态未知,请稍后重试");
+        }
+        if (connectorStatus == EquipmentInfo.SERVICE_STATUS_空闲) {
             throw new BusinessException("请插入充电枪");
         }
 
         if (isBooking) {
             // 设备状态必须为插枪才可以预约
-            if (KymCache.INSTANCE.getConnectorStatus(connectorId) != EquipmentInfo.SERVICE_STATUS_已连接) {
+            if (connectorStatus != EquipmentInfo.SERVICE_STATUS_已连接) {
                 throw new BusinessException("设备状态异常");
             }
             // 预约时间不能超过未来24H
@@ -621,8 +639,9 @@ public class ChargeServiceImpl implements ChargeService {
     public void queryStopCharge(long userId, String connectorId) {
         connectorId = getConnectorId(connectorId);
         LOGGER.info("用户:{},设备:{}请求停止充电", userId, connectorId);
-        // 设备状态校验,如果设备离线则提示用户
-        if (EquipmentInfo.SERVICE_STATUS_离线 == KymCache.INSTANCE.getConnectorStatus(connectorId)) {
+        // 设备状态校验,如果设备离线则提示用户(状态未知时按离线提示,防止拆箱NPE)
+        var connectorStatus = KymCache.INSTANCE.getConnectorStatus(connectorId);
+        if (connectorStatus == null || EquipmentInfo.SERVICE_STATUS_离线 == connectorStatus) {
             throw new BusinessException(ResponseEnum.PLATFORM_EQUIP_OFFLINE);
         }
         // 查询充电订单

+ 5 - 4
service/src/main/java/com/kym/service/platform/impl/PlatformNotifyServiceImpl.java

@@ -236,8 +236,9 @@ public class PlatformNotifyServiceImpl implements PlatformNotifyService {
         LOGGER.info("【{}互联互通平台推送】 :{},解密数据:{}", platformName, json, data);
         var startChargeSeq = data.getString("StartChargeSeq");
         var chargeOrder = chargeOrderService.getChargingOrderByStartChargeSeq(startChargeSeq);
-        // 如果订单已结束,则不更新订单信息
-        if (chargeOrder.getOrderStatus() == ChargeOrder.ORDER_STATUS_未知) {
+        if (chargeOrder == null) {
+            LOGGER.error("【{}互联互通平台推送】充电状态推送:订单{}不存在,跳过处理", platformName, startChargeSeq);
+        } else if (chargeOrder.getOrderStatus() == ChargeOrder.ORDER_STATUS_未知) {
             // 更新订单信息
             chargeOrder.setSoc(data.getDoubleValue("Soc"));
             chargeOrder.setTotalPower(data.getDoubleValue("TotalPower"));
@@ -445,8 +446,8 @@ public class PlatformNotifyServiceImpl implements PlatformNotifyService {
                 "StartTime", data.getStartTime(),
                 "EndTime", data.getEndTime(),
                 "TotalDisputeOrder", disputeCount.get(),
-                "TotalDisputePower", res.stream().collect(Collectors.summarizingDouble(PlatformCheckChargeOrders.DisputeOrder::getTotalPower)),
-                "TotalDisputeMoney", res.stream().collect(Collectors.summarizingDouble(PlatformCheckChargeOrders.DisputeOrder::getTotalMoney)),
+                "TotalDisputePower", res.stream().mapToDouble(PlatformCheckChargeOrders.DisputeOrder::getTotalPower).sum(),
+                "TotalDisputeMoney", res.stream().mapToDouble(PlatformCheckChargeOrders.DisputeOrder::getTotalMoney).sum(),
                 "DisputeOrders", JSONArray.parseArray(JSON.toJSONString(res))
         );
         var platform = PlatformCache.INSTANCE.getPlatformByName(platformName);