puzhibing
92 分钟以前 2d2d58f78780767d8172081f8087ae412855527e
Merge remote-tracking branch 'origin/2.3' into 2.3

# Conflicts:
# ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java
4个文件已添加
23个文件已修改
1104 ■■■■■ 已修改文件
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/vo/SiteInfoVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TCardChargingOrder.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TChargingOrder.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/model/TOrderEvaluate.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/vo/ChargingOrderVO.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/TAppCouponController.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TAppUserWhitelistService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TAppUserWhitelistServiceImpl.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TCompanyAllocateRecordMapper.xml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TAppUserWhitelistServiceImplTest.java 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/iotda/utils/listener/IotMessageListener.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/iotda/utils/produce/IotMessageProduce.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/iotda/utils/tools/IotCommandLog.java 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-integration/src/main/resources/logback.xml 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TOrderEvaluateController.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/ChargingDetails.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TCardChargingOrderServiceImpl.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java 186 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TOrderEvaluateServiceImpl.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TCardChargingOrderMapper.xml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TVinChargingOrderMapper.xml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TChargingOrderServiceImplVinFallbackTest.java 143 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TChargingOrderStartupTimeoutTest.java 187 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TOrderEvaluateServiceImplVinTypeTest.java 165 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20261013_order_evaluate_vin_type.sql 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/charging_pile_order.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/vo/SiteInfoVO.java
@@ -64,6 +64,9 @@
    @ApiModelProperty(value = "是否命中白名单")
    private Boolean whitelistMatched = false;
    @ApiModelProperty(value = "白名单账户可用余额(企业账户充电显示,共享模式=公司可用余额,分配模式=白名单用户余额)")
    private BigDecimal whitelistBalance;
    @ApiModelProperty(value = "白名单权益:1=全部免费,2=免电费,3=免服务费")
    private Integer whitelistBenefitType;
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/model/TOrderEvaluate.java
@@ -31,7 +31,7 @@
    @TableId(value = "id", type = IdType.NONE)
    private Long id;
    @ApiModelProperty(value = "订单类型(1=充电订单,2=购物订单,3=兑换订单,4=会员订单)")
    @ApiModelProperty(value = "订单类型(1=充电订单,2=购物订单,3=兑换订单,4=会员订单,5=VIN码充电订单)")
    @TableField("order_type")
    private Integer orderType;
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-account/src/main/java/com/ruoyi/account/controller/TAppCouponController.java
@@ -179,6 +179,10 @@
            }
            AppUserWhitelistMatchVO whitelist = whitelistService.matchWhitelist(tokenService.getLoginUserApplet().getUserId(), chargingGun.getSiteId());
            data.setWhitelistMatched(Boolean.TRUE.equals(whitelist.getMatched()));
            if (Boolean.TRUE.equals(whitelist.getMatched())) {
                // 充电页余额显示:按白名单所属公司账户模式下发账户可用余额(共享=公司余额,分配=白名单余额)
                data.setWhitelistBalance(whitelistService.resolveWhitelistAccountBalance(whitelist));
            }
            // 白名单权益已下线(充电完全按企业优惠结算),不再下发权益类型与名称,仅保留命中标识
            data.setWhitelistBenefitType(null);
            data.setWhitelistBenefitName(null);
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/TAppUserWhitelistService.java
@@ -50,6 +50,13 @@
    AppUserWhitelistMatchVO matchWhitelist(Long appUserId, Integer siteId);
    /**
     * 解析白名单账户可用余额(小程序充电页余额显示):按所属公司账户模式,
     * 共享模式=公司可用余额,分配模式=白名单用户余额,账户模式未知按共享处理;
     * 公司信息查询失败时降级返回 null(由调用方保持原余额显示)
     */
    BigDecimal resolveWhitelistAccountBalance(AppUserWhitelistMatchVO match);
    /**
     * 按公司查询未删除的白名单用户列表(公司详情-充电用户信息聚合使用)
     */
    List<TAppUserWhitelist> listByCompany(Integer companyId);
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TAppUserWhitelistServiceImpl.java
@@ -457,6 +457,26 @@
    }
    @Override
    public BigDecimal resolveWhitelistAccountBalance(AppUserWhitelistMatchVO match) {
        if (match == null || !Boolean.TRUE.equals(match.getMatched()) || match.getCompanyId() == null) {
            return null;
        }
        try {
            TGroupCompany company = loadCompany(match.getCompanyId());
            Integer accountMode = company == null ? null : company.getAccountMode();
            if (accountMode == null) {
                // 历史数据账户模式为空,按共享模式处理
                accountMode = ACCOUNT_MODE_SHARED;
            }
            return resolveAvailableBalance(accountMode, match.getBalance(),
                    company == null ? null : company.getAvailableBalance());
        } catch (Exception ex) {
            log.warn("解析白名单账户可用余额失败,companyId={}", match.getCompanyId(), ex);
            return null;
        }
    }
    @Override
    public List<TAppUserWhitelist> listByCompany(Integer companyId) {
        if (companyId == null) {
            return Collections.emptyList();
ruoyi-service/ruoyi-account/src/main/resources/mapper/account/TCompanyAllocateRecordMapper.xml
@@ -4,6 +4,9 @@
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.account.mapper.TCompanyAllocateRecordMapper">
    <!-- 分配记录弹窗只统计「给子账号(VIN码/刷卡/白名单)分配余额」的流水:
         change_type = 1 仅取分配类(1=分配;排除 2=充电结算消费流水,其 after_balance 可为负数);
         user_type != 4 排除账户模式切换时企业账户自身的回退留痕(不属于子账号分配) -->
    <select id="selectRecordPage" resultType="com.ruoyi.account.api.model.TCompanyAllocateRecord">
        SELECT id,
               company_id,
@@ -21,6 +24,8 @@
               create_time
        FROM t_company_allocate_record
        WHERE company_id = #{query.companyId}
          AND change_type = 1
          AND user_type != 4
        ORDER BY operate_time DESC, id DESC
    </select>
</mapper>
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/TAppUserWhitelistServiceImplTest.java
@@ -50,6 +50,7 @@
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -259,6 +260,83 @@
        assertEquals(null, result.getFleetId());
    }
    // ---------------- 白名单账户可用余额解析(充电页余额显示) ----------------
    @Test
    void resolveWhitelistAccountBalanceReturnsCompanyBalanceForSharedMode() {
        TGroupCompany company = new TGroupCompany();
        company.setAccountMode(1);
        company.setAvailableBalance(new BigDecimal("500.50"));
        when(groupCompanyClient.getCompanyById(12)).thenReturn(R.ok(company));
        AppUserWhitelistMatchVO match = new AppUserWhitelistMatchVO();
        match.setMatched(true);
        match.setCompanyId(12);
        match.setBalance(new BigDecimal("100.00"));
        BigDecimal result = service.resolveWhitelistAccountBalance(match);
        assertEquals(new BigDecimal("500.50"), result);
    }
    @Test
    void resolveWhitelistAccountBalanceReturnsWhitelistBalanceForAllocationMode() {
        TGroupCompany company = new TGroupCompany();
        company.setAccountMode(2);
        company.setAvailableBalance(new BigDecimal("500.50"));
        when(groupCompanyClient.getCompanyById(12)).thenReturn(R.ok(company));
        AppUserWhitelistMatchVO match = new AppUserWhitelistMatchVO();
        match.setMatched(true);
        match.setCompanyId(12);
        match.setBalance(new BigDecimal("100.00"));
        BigDecimal result = service.resolveWhitelistAccountBalance(match);
        assertEquals(new BigDecimal("100.00"), result);
    }
    @Test
    void resolveWhitelistAccountBalanceTreatsNullAccountModeAsShared() {
        TGroupCompany company = new TGroupCompany();
        company.setAccountMode(null);
        company.setAvailableBalance(new BigDecimal("500.50"));
        when(groupCompanyClient.getCompanyById(12)).thenReturn(R.ok(company));
        AppUserWhitelistMatchVO match = new AppUserWhitelistMatchVO();
        match.setMatched(true);
        match.setCompanyId(12);
        match.setBalance(new BigDecimal("100.00"));
        BigDecimal result = service.resolveWhitelistAccountBalance(match);
        assertEquals(new BigDecimal("500.50"), result);
    }
    @Test
    void resolveWhitelistAccountBalanceReturnsNullWhenCompanyQueryFails() {
        when(groupCompanyClient.getCompanyById(12)).thenReturn(R.fail("公司信息查询失败"));
        AppUserWhitelistMatchVO match = new AppUserWhitelistMatchVO();
        match.setMatched(true);
        match.setCompanyId(12);
        match.setBalance(new BigDecimal("100.00"));
        BigDecimal result = service.resolveWhitelistAccountBalance(match);
        assertNull(result);
    }
    @Test
    void resolveWhitelistAccountBalanceReturnsNullWhenNotMatched() {
        AppUserWhitelistMatchVO match = new AppUserWhitelistMatchVO();
        match.setMatched(false);
        BigDecimal result = service.resolveWhitelistAccountBalance(match);
        assertNull(result);
    }
    // ---------------- 额度分配 ----------------
    @Test
ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/iotda/utils/listener/IotMessageListener.java
@@ -17,6 +17,7 @@
import com.ruoyi.integration.iotda.model.*;
import com.ruoyi.integration.iotda.utils.produce.IotMessageProduce;
import com.ruoyi.integration.iotda.utils.tools.CP56Time2aConverter;
import com.ruoyi.integration.iotda.utils.tools.IotCommandLog;
import com.ruoyi.integration.iotda.utils.tools.MessageUtil;
import com.ruoyi.integration.iotda.utils.tools.StrategyUtil;
import com.ruoyi.integration.iotda.utils.tools.ChargingMessageUtil;
@@ -84,11 +85,12 @@
            content = body.getJSONObject("content");
        }catch (Exception e){
            String content1 = body.getString("content");
            // 按对象解析失败时无法识别 service_id(无法判断是否心跳),记录原始报文兜底便于排查
            IotCommandLog.uplinkUnparsed(deviceNoOf(jsonObject), content1);
            String substring = content1.substring(content1.length() - 1);
            if(!"}".equals(substring)){
                content1 = content1.substring(0, content1.length() - 1) + "}";
            }
//            log.info("content1:"+content1);
            content = JSON.parseObject(content1);
        }
        JSONObject header = jsonObject.getJSONObject("notify_data").getJSONObject("header");
@@ -96,10 +98,8 @@
        String nodeId = header.getString("node_id"); // 设备编号
        String productId = header.getString("product_id"); // 产品id
        String service_id = content.getString("service_id");
        if(!SendTagConstant.PING.equals(service_id)){
            log.info("收到IOT发送的消息:{}",jsonObject.toJSONString());
        }
//        log.info("服务id:{}",service_id);
        // 记录上行指令日志(独立日志文件、含指令中文含义;心跳 ping 不记录)
        IotCommandLog.uplink(deviceNoOf(jsonObject), service_id, content.toJSONString());
        ChargingMessage chargingMessage = new ChargingMessage();
        chargingMessage.setServiceId(service_id);
        // 设备消息下发
@@ -127,7 +127,6 @@
                log.warn("ONLINE消息处理:{} 毫秒", System.currentTimeMillis() - ONLINE);
                break;
            case SendTagConstant.PING:
                long PING = System.currentTimeMillis();
                PingMessage pingMessage = JSON.parseObject(content.toJSONString(),PingMessage.class);
                String fullName = pingMessage.getCharging_pile_code() + pingMessage.getCharging_gun_code();
                //主动丢弃一次心跳数据,用于降低调华为接口评率,防止被限流
@@ -174,8 +173,6 @@
                vo1.setPile_code(pingMessage.getCharging_pile_code());
                vo1.setStatus(pingMessage.getCharging_gun_status());
                chargingPileClient.updateChargingPileStatus(vo1);
                log.warn("PING消息处理:{} 毫秒", System.currentTimeMillis() - PING);
                break;
            case SendTagConstant.END_CHARGE:
                long END_CHARGE = System.currentTimeMillis();
@@ -531,6 +528,22 @@
        return AjaxResult.success();
    }
    /**
     * 从IoTDA推送报文中提取设备编号(node_id 优先,缺失时回退 device_id)
     */
    private String deviceNoOf(JSONObject msg) {
        try {
            JSONObject header = msg.getJSONObject("notify_data").getJSONObject("header");
            if (header == null) {
                return null;
            }
            String nodeId = header.getString("node_id");
            return (nodeId == null || nodeId.isEmpty()) ? header.getString("device_id") : nodeId;
        } catch (Exception e) {
            return null;
        }
    }
    @ApiOperation(value = "运营平台远程控制启机",tags = {"硬件接口"})
    @GetMapping(value = "/platformStartCharging")
ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/iotda/utils/produce/IotMessageProduce.java
@@ -7,6 +7,7 @@
import com.huaweicloud.sdk.iotda.v5.model.DeviceMessageRequest;
import com.ruoyi.common.core.utils.uuid.UUID;
import com.ruoyi.integration.iotda.builder.IotBuilder;
import com.ruoyi.integration.iotda.utils.tools.IotCommandLog;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
@@ -38,11 +39,15 @@
        body.withName(name);
        body.withMessage(message);
        request.withBody(body);
        // 记录下行指令日志(独立日志文件、含指令中文含义;心跳应答 pong 不记录)
        String serviceId = message == null ? null : message.getString("service_id");
        IotCommandLog.downlink(code, name, serviceId, message);
        try {
            CreateMessageResponse response = iotBuilder.buildIot().createMessage(request);
            return JSON.toJSONString(response);
        } catch (Exception e) {
            e.printStackTrace();
            IotCommandLog.downlinkFail(code, name, serviceId, message, e.getMessage());
            return e.getMessage();
        }
    }
ruoyi-service/ruoyi-integration/src/main/java/com/ruoyi/integration/iotda/utils/tools/IotCommandLog.java
New file
@@ -0,0 +1,157 @@
package com.ruoyi.integration.iotda.utils.tools;
import com.ruoyi.integration.iotda.constant.SendTagConstant;
import com.ruoyi.integration.iotda.enums.ServiceIdMenu;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.Map;
/**
 * 充电桩指令收发日志
 * <p>
 * 1、使用独立 logger(pile-command)输出到独立日志文件,与业务日志分离(见 logback.xml);
 * 2、日志记录统一打印指令含义,格式:指令=中文含义(service_id);
 * 3、心跳指令(上行 ping、下行 pong)不记录,避免日志量过大。
 */
public class IotCommandLog {
    /** 独立日志 logger,输出到独立日志文件(logback.xml 中 pile-command 配置) */
    private static final Logger COMMAND_LOG = LoggerFactory.getLogger("pile-command");
    /** 上行指令 service_id -> 中文含义 */
    private static final Map<String, String> UPLINK_MEANING = new HashMap<>();
    static {
        UPLINK_MEANING.put(SendTagConstant.ONLINE, "充电桩登录认证");
        UPLINK_MEANING.put(SendTagConstant.PING, "充电桩心跳包");
        UPLINK_MEANING.put(SendTagConstant.END_CHARGE, "充电结束");
        UPLINK_MEANING.put(SendTagConstant.ERROR_MESSAGE, "错误报文");
        UPLINK_MEANING.put(SendTagConstant.BILLING_MODE_VERIFY, "计费模型验证请求");
        UPLINK_MEANING.put(SendTagConstant.ACQUISITION_BILLING_MODE, "充电桩计费模型请求");
        UPLINK_MEANING.put(SendTagConstant.UPLOAD_REAL_TIME_MONITORING_DATA, "上传实时监测数据");
        UPLINK_MEANING.put(SendTagConstant.CHARGING_HANDSHAKE, "充电握手");
        UPLINK_MEANING.put(SendTagConstant.PARAMETER_SETTING, "参数配置");
        UPLINK_MEANING.put(SendTagConstant.BMS_ABORT, "充电阶段BMS中止");
        UPLINK_MEANING.put(SendTagConstant.MOTOR_ABORT, "充电阶段充电机中止");
        UPLINK_MEANING.put(SendTagConstant.BMS_DEMAND_AND_CHARGER_EXPORTATION, "充电过程BMS需求、充电机输出");
        UPLINK_MEANING.put(SendTagConstant.BMS_INFORMATION, "充电过程BMS信息");
        UPLINK_MEANING.put(SendTagConstant.CHARGING_PILE_STARTS_CHARGING, "充电桩主动申请启动充电");
        UPLINK_MEANING.put(SendTagConstant.PLATFORM_START_CHARGING_REPLY, "远程启机命令回复");
        UPLINK_MEANING.put(SendTagConstant.PLATFORM_STOP_CHARGING_REPLY, "远程停机命令回复");
        UPLINK_MEANING.put(SendTagConstant.TRANSACTION_RECORD, "交易记录");
        UPLINK_MEANING.put(SendTagConstant.UPDATE_BALANCE_REPLY, "余额更新应答");
        UPLINK_MEANING.put(SendTagConstant.SYNCHRONIZE_OFFLINE_CARD_REPLY, "卡数据同步应答");
        UPLINK_MEANING.put(SendTagConstant.CLEAR_OFFLINE_CARD_REPLY, "离线卡数据清除应答");
        UPLINK_MEANING.put(SendTagConstant.QUERY_OFFLINE_CARD_REPLY, "离线卡数据查询应答");
        UPLINK_MEANING.put(SendTagConstant.WORKING_PARAMETER_SETTING_REPLY, "充电桩工作参数设置应答");
        UPLINK_MEANING.put(SendTagConstant.TIMING_SETTING, "对时设置");
        UPLINK_MEANING.put(SendTagConstant.SETUP_BILLING_MODEL_REPLY, "计费模型应答");
        UPLINK_MEANING.put(SendTagConstant.GROUND_LOCK_REAL_TIME_DATA, "地锁数据上送");
        UPLINK_MEANING.put(SendTagConstant.CHARGING_PILE_RETURNS_GROUND_LOCK_DATA, "充电桩返回地锁数据");
        UPLINK_MEANING.put(SendTagConstant.PLATFORM_RESTART_REPLY, "远程重启应答");
        UPLINK_MEANING.put(SendTagConstant.PLATFORM_REMOTE_UPDATE_REPLY, "远程更新应答");
        UPLINK_MEANING.put(SendTagConstant.QR_CODE_DELIVERY_REPLY, "二维码下发应答");
        UPLINK_MEANING.put(SendTagConstant.SECURITY_DETECTION, "安全监测");
        UPLINK_MEANING.put(SendTagConstant.GUN_STATUS, "推送充电设备接口状态信息");
        UPLINK_MEANING.put(SendTagConstant.ORDER_STATUS, "推送充电状态信息");
        UPLINK_MEANING.put(SendTagConstant.ORDER_INFO, "推送充电订单信息");
    }
    private IotCommandLog() {
    }
    /**
     * 记录上行指令(设备 -> 平台)
     * 心跳(ping)不记录
     *
     * @param deviceNo  设备编号
     * @param serviceId 指令 service_id
     * @param payload   报文内容
     */
    public static void uplink(String deviceNo, String serviceId, Object payload) {
        if (SendTagConstant.PING.equals(serviceId)) {
            // 心跳不记录日志
            return;
        }
        COMMAND_LOG.info("[上行] 设备={} 指令={} 报文={}", deviceNo, describeUplink(serviceId), payload);
    }
    /**
     * 记录下行指令(平台 -> 设备)
     * 心跳应答(pong)不记录
     *
     * @param deviceNo  设备编号
     * @param name      下发名称
     * @param serviceId 指令 service_id
     * @param payload   报文内容
     */
    public static void downlink(String deviceNo, String name, String serviceId, Object payload) {
        if (isPong(serviceId)) {
            // 心跳应答不记录日志
            return;
        }
        COMMAND_LOG.info("[下行] 设备={} 指令={} 报文={}", deviceNo, describeDownlink(serviceId, name), payload);
    }
    /**
     * 记录下行指令下发失败
     * 心跳应答(pong)不记录
     *
     * @param deviceNo  设备编号
     * @param name      下发名称
     * @param serviceId 指令 service_id
     * @param payload   报文内容
     * @param error     失败原因
     */
    public static void downlinkFail(String deviceNo, String name, String serviceId, Object payload, String error) {
        if (isPong(serviceId)) {
            // 心跳应答不记录日志
            return;
        }
        COMMAND_LOG.error("[下行失败] 设备={} 指令={} 报文={} 错误={}", deviceNo, describeDownlink(serviceId, name), payload, error);
    }
    /**
     * 记录无法解析的上行原始报文(兜底)
     * 报文解析失败时无法识别 service_id,不能判断是否为心跳,仍记录原始内容便于排查
     *
     * @param deviceNo   设备编号
     * @param rawPayload 原始报文内容
     */
    public static void uplinkUnparsed(String deviceNo, Object rawPayload) {
        COMMAND_LOG.info("[上行-原始报文] 设备={} 报文={}", deviceNo, rawPayload);
    }
    /** 是否心跳应答(pong) */
    private static boolean isPong(String serviceId) {
        return ServiceIdMenu.PONG.getValue().equals(serviceId);
    }
    /** 上行指令含义:中文含义(service_id) */
    private static String describeUplink(String serviceId) {
        String meaning = UPLINK_MEANING.get(serviceId);
        if (meaning == null || meaning.isEmpty()) {
            meaning = "未识别指令";
        }
        return meaning + "(" + serviceId + ")";
    }
    /** 下行指令含义:中文含义(service_id) */
    private static String describeDownlink(String serviceId, String name) {
        String meaning = null;
        if (serviceId != null) {
            for (ServiceIdMenu menu : ServiceIdMenu.values()) {
                if (menu.getValue().equals(serviceId)) {
                    meaning = menu.getKey();
                    break;
                }
            }
        }
        if (meaning == null || meaning.isEmpty()) {
            meaning = (name == null || name.isEmpty()) ? "未识别指令" : name;
        }
        return meaning + "(" + serviceId + ")";
    }
}
ruoyi-service/ruoyi-integration/src/main/resources/logback.xml
@@ -163,6 +163,32 @@
        </filter>
    </appender>
    
    <!-- 2.6 充电桩指令收发独立日志,独立文件输出(心跳不记录,见 IotCommandLog) -->
    <appender name="PILE_COMMAND_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 正在记录的日志文档的路径及文档名 -->
        <file>${log.path}/pile-command.log</file>
        <!--日志文档输出格式-->
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
            <charset>UTF-8</charset> <!-- 设置字符集 -->
        </encoder>
        <!-- 日志记录器的滚动策略,按日期,按大小记录 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!-- 日志归档 -->
            <fileNamePattern>${log.path}/%d{yyyy-MM-dd}/pile-command-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>100MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <!--日志文档保留天数-->
            <maxHistory>30</maxHistory>
        </rollingPolicy>
    </appender>
    <!-- 充电桩指令收发日志:独立文件输出,不向 root 传递(additivity=false),避免污染业务日志 -->
    <logger name="pile-command" level="INFO" additivity="false">
        <appender-ref ref="PILE_COMMAND_FILE" />
    </logger>
    <!--
        <logger>用来设置某一个包或者具体的某一个类的日志打印级别、
        以及指定<appender>。<logger>仅有一个name属性,
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 = {"小程序-扫一扫"})
@@ -1880,10 +1887,17 @@
    public AjaxResult<PreChargeCheck> preChargeCheck(@PathVariable Integer id) {
        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;
        if (null != cacheObject) {
            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/controller/TOrderEvaluateController.java
@@ -183,7 +183,7 @@
    @ApiOperation(value = "获取订单评价详情", tags = {"小程序-充电记录"})
    @ApiImplicitParams({
            @ApiImplicitParam(value = "订单id", name = "orderId", required = true),
            @ApiImplicitParam(value = "订单类型(1=充电订单,2=购物订单,3=兑换订单,4=会员订单)", name = "orderType", required = true),
            @ApiImplicitParam(value = "订单类型(1=充电订单,2=购物订单,3=兑换订单,4=会员订单,5=VIN码充电订单)", name = "orderType", required = true),
    })
    public AjaxResult<OrderEvaluateVo> getOrderEvaluate(String orderId, Integer orderType){
        OrderEvaluateVo orderEvaluate = orderEvaluateService.getOrderEvaluate(orderId, orderType);
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/ChargingDetails.java
@@ -36,7 +36,7 @@
    private BigDecimal chargedDegrees;
    @ApiModelProperty("已充电时间")
    private String chargedTime;
    @ApiModelProperty("充电费用")
    @ApiModelProperty("已使用费用(设备上报已充金额,缺失时回退订单已使用金额)")
    private BigDecimal chargingCost;
    @ApiModelProperty("充电状态(0=未知,1=等待中,2=启动中,3=充电中,4=停止中,5=已结束)")
    private Integer status;
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;
    }
@@ -2111,6 +2132,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 {
@@ -2133,12 +2192,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() + ":安全检测通过,下发启动充电命令:" + command);
            sendMessageClient.platformStartCharging(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())) {
@@ -2177,8 +2242,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);
                //启动失败
@@ -2229,6 +2299,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) {
                //启动成功
@@ -2260,8 +2335,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);
                    //启动失败
@@ -2313,25 +2388,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) {
            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);
            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"));
                if (pendingOrder != null) {
                    startChargingAfterSafety(pendingOrder, 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()));
        // 刷新等待基准时间,避免前端等待窗口/超时判断误伤正在推进的安全检测
        preChargeCheck1.setAddTime(LocalDateTime.now());
        redisService.setCacheObject(key, preChargeCheck1, 24L, TimeUnit.HOURS);
        if (isSafetyCheckPassed(preChargeCheck1)) {
            TChargingOrder pendingOrder = getPendingStartOrder(chargingGun.getId());
            if (pendingOrder != null) {
                startChargingAfterSafety(pendingOrder, preChargeCheck1);
            }
        }
    }
@@ -2356,6 +2449,11 @@
            preChargeCheck1.setElectronicLockLock(true);
            preChargeCheck1.setInsulationTesting(true);
            preChargeCheck1.setStartupSuccess(1);
        } else {
            // 缓存缺失时用临时对象推进订单状态,避免设备已启动成功而订单因缓存为空无法流转
            preChargeCheck1 = new PreChargeCheck();
            preChargeCheck1.setElectronicLockLock(true);
            preChargeCheck1.setInsulationTesting(true);
        }
        if (0 == startup_result) {
            //启动失败
@@ -2750,19 +2848,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"));
@@ -2782,7 +2883,8 @@
        boolean reservationPending = ReservationOrderSupport.isPending(
                one.getStatus(), one.getReservationTime(), LocalDateTime.now());
        chargingDetails.setStatus(reservationPending ? 1 : one.getStatus());
        chargingDetails.setChargingCost(one.getResidualAmount());
        // 已使用费用:默认取订单已使用金额,存在实时监测数据时以设备上报已充金额(电费+服务费)为准
        chargingDetails.setChargingCost(money(one.getResidualAmount()));
        UploadRealTimeMonitoringData data = uploadRealTimeMonitoringDataService.getLastDataById(one.getCode());
        if (null != data) {
            chargingDetails.setChargeCurrent(data.getOutput_current());
@@ -2795,6 +2897,10 @@
            Integer h = Integer.valueOf(data.getCumulative_charging_time() / 60);
            Integer m = Integer.valueOf(data.getCumulative_charging_time() % 60);
            chargingDetails.setChargedTime(String.format("%02d", h) + ":" + String.format("%02d", m));
            // 设备上报已充金额非空时覆盖,上报缺失则沿用订单已使用金额
            if (null != data.getPaid_amount()) {
                chargingDetails.setChargingCost(money(data.getPaid_amount()));
            }
            if (null != one.getAppUserCarId()) {
                TAppUserCar appUserCar = appUserCarClient.getCarById(one.getAppUserCarId().toString()).getData();
                //   续航 = 续航里程 * soc
@@ -8216,13 +8322,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/main/java/com/ruoyi/order/service/impl/TOrderEvaluateServiceImpl.java
@@ -24,6 +24,7 @@
import com.ruoyi.order.api.model.TEvaluationAutoReply;
import com.ruoyi.order.api.model.TOrderEvaluate;
import com.ruoyi.order.api.model.TOrderEvaluateTag;
import com.ruoyi.order.api.model.TVinChargingOrder;
import com.ruoyi.order.api.query.TOrderEvaluateQuery;
import com.ruoyi.order.api.vo.SiteDetailEvaluateVO;
import com.ruoyi.order.api.vo.TOrderEvaluateVO;
@@ -36,6 +37,7 @@
import com.ruoyi.order.service.TChargingOrderService;
import com.ruoyi.order.service.TEvaluationAutoReplyService;
import com.ruoyi.order.service.TOrderEvaluateService;
import com.ruoyi.order.service.TVinChargingOrderService;
import com.ruoyi.order.service.WxSubscribeMessageService;
import com.ruoyi.other.api.domain.TEvaluationTag;
import com.ruoyi.other.api.domain.TIntegralRule;
@@ -90,6 +92,9 @@
    
    @Resource
    private TChargingOrderService chargingOrderService;
    @Resource
    private TVinChargingOrderService vinChargingOrderService;
    
    @Resource
    private IntegralRuleClient integralRuleClient;
@@ -345,9 +350,25 @@
        BeanUtils.copyProperties(query, orderEvaluate);
        Long userid = tokenService.getLoginUserApplet().getUserId();
        orderEvaluate.setAppUserId(userid);
        if(query.getOrderType() == 1){
        Integer orderType = query.getOrderType();
        if(null == orderType){
            return AjaxResult.error("订单类型不能为空");
        }
        if(orderType == 1){
            TChargingOrder chargingOrder = chargingOrderService.getById(query.getOrderId());
            if(null == chargingOrder){
                return AjaxResult.error("订单不存在");
            }
            orderEvaluate.setAppUserCarId(chargingOrder.getAppUserCarId());
        }else if(orderType == 5){
            //VIN码充电订单,仅允许评价本人订单
            TVinChargingOrder vinChargingOrder = vinChargingOrderService.getById(query.getOrderId());
            if(null == vinChargingOrder || !userid.equals(vinChargingOrder.getAppUserId())){
                return AjaxResult.error("订单不存在");
            }
            orderEvaluate.setAppUserCarId(vinChargingOrder.getAppUserCarId());
        }else{
            return AjaxResult.error("订单类型不支持评价");
        }
        TAppUser appUser = appUserClient.getUserById(userid).getData();
    
@@ -392,7 +413,6 @@
                appUser.setPoints(appUser.getPoints() + num1);
                appUserClient.updateAppUser(appUser);
    
                TChargingOrder chargingOrder = chargingOrderService.getById(orderEvaluate.getOrderId());
                //积分变动明细
                TAppUserIntegralChange appUserIntegralChange = new TAppUserIntegralChange();
                appUserIntegralChange.setAppUserId(appUser.getId());
@@ -400,7 +420,7 @@
                appUserIntegralChange.setHistoricalIntegral(points);
                appUserIntegralChange.setCurrentIntegral(points + num1);
                appUserIntegralChange.setCreateTime(LocalDateTime.now());
                appUserIntegralChange.setOrderCode(chargingOrder.getCode());
                appUserIntegralChange.setOrderCode(getOrderCode(orderEvaluate.getOrderType(), orderEvaluate.getOrderId()));
                appUserIntegralChange.setExtension(orderEvaluate.getOrderId().toString());
                appUserIntegralChangeClient.addAppUserIntegralChange(appUserIntegralChange);
            }
@@ -408,6 +428,21 @@
        return AjaxResult.success();
    }
    
    /**
     * 根据评价的订单类型获取对应的订单编号
     * @param orderType 订单类型(1=充电订单,5=VIN码充电订单)
     * @param orderId 订单id
     * @return 订单编号
     */
    private String getOrderCode(Integer orderType, Long orderId) {
        if(Integer.valueOf(5).equals(orderType)){
            TVinChargingOrder vinChargingOrder = vinChargingOrderService.getById(orderId);
            return null == vinChargingOrder ? null : vinChargingOrder.getCode();
        }
        TChargingOrder chargingOrder = chargingOrderService.getById(orderId);
        return null == chargingOrder ? null : chargingOrder.getCode();
    }
    private void applyAutoReply(TOrderEvaluate orderEvaluate) {
        TEvaluationAutoReply rule = evaluationAutoReplyService.matchRule(orderEvaluate.getContent(), orderEvaluate.getMark());
        if (rule == null || StringUtils.isEmpty(rule.getReplyContent())) {
ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TCardChargingOrderMapper.xml
@@ -36,7 +36,7 @@
                AND o.charging_gun_id = #{query.gunId}
            </if>
            <if test="startTime1 != null and startTime1 != '' and startTime2 != null and startTime2 != ''">
                AND o.pay_time BETWEEN #{startTime1} AND #{startTime2}
                AND o.start_time BETWEEN #{startTime1} AND #{startTime2}
            </if>
            <if test="endTime1 != null and endTime1 != '' and endTime2 != null and endTime2 != ''">
                AND o.end_time BETWEEN #{endTime1} AND #{endTime2}
ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TVinChargingOrderMapper.xml
@@ -60,7 +60,7 @@
               o.status,
               CASE WHEN EXISTS (
                   SELECT 1 FROM t_order_evaluate e
                   WHERE e.order_id = o.id AND e.order_type = 1 AND e.del_flag = 0
                   WHERE e.order_id = o.id AND e.order_type = 5 AND e.del_flag = 0
               ) THEN 2 ELSE 1 END AS evaluate
        FROM t_vin_charging_order o
        WHERE o.del_flag = 0
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;
/**
@@ -134,6 +143,46 @@
    }
    @Test
    void getChargingDetailsUsesPaidAmountAsUsedCostWhenRealTimeDataPresent() {
        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(3);
        vinOrder.setCode("VD20261013001");
        vinOrder.setResidualAmount(new BigDecimal("66.60"));
        when(vinChargingOrderMapper.selectOne(any())).thenReturn(vinOrder);
        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)));
        UploadRealTimeMonitoringData data = new UploadRealTimeMonitoringData();
        data.setOutput_current(new BigDecimal("10"));
        data.setOutput_voltage(new BigDecimal("220"));
        data.setSoc(50);
        data.setTime_remaining(30);
        data.setCharging_degree(new BigDecimal("5.5"));
        data.setCumulative_charging_time(75);
        // 设备上报已充金额为 4 位小数,展示口径统一保留 2 位
        data.setPaid_amount(new BigDecimal("12.3456"));
        when(uploadRealTimeMonitoringDataService.getLastDataById("VD20261013001")).thenReturn(data);
        ChargingDetails details = service.getChargingDetails(5);
        assertEquals(0, new BigDecimal("12.35").compareTo(details.getChargingCost()));
    }
    @Test
    void getChargingDetailsReturnsNullWhenNeitherNormalNorVinOrderExists() {
        LoginUserApplet loginUser = new LoginUserApplet();
        loginUser.setUserId(88L);
@@ -191,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;
    }
}
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/TOrderEvaluateServiceImplVinTypeTest.java
New file
@@ -0,0 +1,165 @@
package com.ruoyi.order.service.impl;
import com.ruoyi.account.api.feignClient.AppUserClient;
import com.ruoyi.account.api.model.TAppUser;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.web.domain.AjaxResult;
import com.ruoyi.common.security.service.TokenService;
import com.ruoyi.order.api.model.TChargingOrder;
import com.ruoyi.order.api.model.TOrderEvaluate;
import com.ruoyi.order.api.model.TVinChargingOrder;
import com.ruoyi.order.dto.OrderEvaluateVo;
import com.ruoyi.order.mapper.TOrderEvaluateMapper;
import com.ruoyi.order.service.TChargingOrderService;
import com.ruoyi.order.service.TEvaluationAutoReplyService;
import com.ruoyi.order.service.TVinChargingOrderService;
import com.ruoyi.system.api.model.LoginUserApplet;
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;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
 * VIN 码充电订单评价(orderType=5)分支测试
 * 覆盖:本人 VIN 订单可评价、他人订单/不存在订单被拒、普通充电订单不存在的兜底校验
 */
@ExtendWith(MockitoExtension.class)
class TOrderEvaluateServiceImplVinTypeTest {
    @InjectMocks
    private TOrderEvaluateServiceImpl service;
    @Mock
    private TOrderEvaluateMapper orderEvaluateMapper;
    @Mock
    private TokenService tokenService;
    @Mock
    private TChargingOrderService chargingOrderService;
    @Mock
    private TVinChargingOrderService vinChargingOrderService;
    @Mock
    private AppUserClient appUserClient;
    @Mock
    private TEvaluationAutoReplyService evaluationAutoReplyService;
    @BeforeEach
    void setUp() {
        ReflectionTestUtils.setField(service, "baseMapper", orderEvaluateMapper);
        ReflectionTestUtils.setField(service, "orderEvaluateMapper", orderEvaluateMapper);
        LoginUserApplet loginUser = new LoginUserApplet();
        loginUser.setUserId(88L);
        when(tokenService.getLoginUserApplet()).thenReturn(loginUser);
    }
    private OrderEvaluateVo buildQuery(Long orderId, Integer orderType) {
        OrderEvaluateVo query = new OrderEvaluateVo();
        query.setOrderId(orderId);
        query.setOrderType(orderType);
        query.setMark(5);
        return query;
    }
    @Test
    void addOrderEvaluateForOwnVinOrderStoresTypeFiveAndOwnerCar() {
        TVinChargingOrder vinOrder = new TVinChargingOrder();
        vinOrder.setId(1001L);
        vinOrder.setAppUserId(88L);
        vinOrder.setAppUserCarId(66L);
        vinOrder.setCode("VD_1001");
        when(vinChargingOrderService.getById(1001L)).thenReturn(vinOrder);
        TAppUser appUser = new TAppUser();
        appUser.setId(88L);
        appUser.setWxOpenid("openid_88");
        when(appUserClient.getUserById(88L)).thenReturn(R.ok(appUser));
        when(orderEvaluateMapper.insert(any(TOrderEvaluate.class))).thenReturn(1);
        AjaxResult result = service.addOrderEvaluate(buildQuery(1001L, 5));
        assertTrue(result.isSuccess());
        ArgumentCaptor<TOrderEvaluate> captor = ArgumentCaptor.forClass(TOrderEvaluate.class);
        verify(orderEvaluateMapper).insert(captor.capture());
        TOrderEvaluate saved = captor.getValue();
        assertEquals(Integer.valueOf(5), saved.getOrderType());
        assertEquals(Long.valueOf(88L), saved.getAppUserId());
        assertEquals(Long.valueOf(66L), saved.getAppUserCarId());
        // VIN 订单评价不应再走普通充电订单表
        verify(chargingOrderService, never()).getById(anyLong());
    }
    @Test
    void addOrderEvaluateRejectsVinOrderBelongingToOtherUser() {
        TVinChargingOrder vinOrder = new TVinChargingOrder();
        vinOrder.setId(1001L);
        vinOrder.setAppUserId(99L);
        when(vinChargingOrderService.getById(1001L)).thenReturn(vinOrder);
        AjaxResult result = service.addOrderEvaluate(buildQuery(1001L, 5));
        assertFalse(result.isSuccess());
        assertEquals("订单不存在", result.get(AjaxResult.MSG_TAG));
        verify(orderEvaluateMapper, never()).insert(any(TOrderEvaluate.class));
    }
    @Test
    void addOrderEvaluateRejectsMissingVinOrder() {
        AjaxResult result = service.addOrderEvaluate(buildQuery(1001L, 5));
        assertFalse(result.isSuccess());
        assertEquals("订单不存在", result.get(AjaxResult.MSG_TAG));
        verify(orderEvaluateMapper, never()).insert(any(TOrderEvaluate.class));
    }
    @Test
    void addOrderEvaluateRejectsMissingChargingOrder() {
        AjaxResult result = service.addOrderEvaluate(buildQuery(2002L, 1));
        assertFalse(result.isSuccess());
        assertEquals("订单不存在", result.get(AjaxResult.MSG_TAG));
        verify(orderEvaluateMapper, never()).insert(any(TOrderEvaluate.class));
    }
    @Test
    void addOrderEvaluateRejectsUnsupportedOrderType() {
        AjaxResult result = service.addOrderEvaluate(buildQuery(3003L, 2));
        assertFalse(result.isSuccess());
        assertEquals("订单类型不支持评价", result.get(AjaxResult.MSG_TAG));
        verify(orderEvaluateMapper, never()).insert(any(TOrderEvaluate.class));
    }
    @Test
    void addOrderEvaluateStillWorksForNormalChargingOrder() {
        TChargingOrder chargingOrder = new TChargingOrder();
        chargingOrder.setId(2002L);
        chargingOrder.setAppUserCarId(55L);
        chargingOrder.setCode("CD_2002");
        when(chargingOrderService.getById(2002L)).thenReturn(chargingOrder);
        TAppUser appUser = new TAppUser();
        appUser.setId(88L);
        appUser.setWxOpenid("openid_88");
        when(appUserClient.getUserById(88L)).thenReturn(R.ok(appUser));
        when(orderEvaluateMapper.insert(any(TOrderEvaluate.class))).thenReturn(1);
        AjaxResult result = service.addOrderEvaluate(buildQuery(2002L, 1));
        assertTrue(result.isSuccess());
        ArgumentCaptor<TOrderEvaluate> captor = ArgumentCaptor.forClass(TOrderEvaluate.class);
        verify(orderEvaluateMapper).insert(captor.capture());
        TOrderEvaluate saved = captor.getValue();
        assertEquals(Integer.valueOf(1), saved.getOrderType());
        assertEquals(Long.valueOf(55L), saved.getAppUserCarId());
        verify(vinChargingOrderService, never()).getById(anyLong());
    }
}
sql/20261013_order_evaluate_vin_type.sql
New file
@@ -0,0 +1,6 @@
-- VIN码充电订单评价:t_order_evaluate.order_type 新增取值 5=VIN码充电订单
-- 背景:小程序 VIN 码充电订单列表"评价/查看评价"原先按 order_type=1 提交,后端按普通充电订单表查询导致空指针;
--       现改为独立类型 5,与普通充电订单(1)区分,本脚本仅更新字段注释,无表结构调整。
-- 执行环境:测试库已执行,其他环境发版前同步执行
ALTER TABLE `t_order_evaluate`
    MODIFY COLUMN `order_type` int NULL DEFAULT NULL COMMENT '订单类型(1=充电订单,2=购物订单,3=兑换订单,4=会员订单,5=VIN码充电订单)';
sql/charging_pile_order.sql
@@ -4287,7 +4287,7 @@
DROP TABLE IF EXISTS `t_order_evaluate`;
CREATE TABLE `t_order_evaluate`  (
  `id` bigint NOT NULL COMMENT '主键',
  `order_type` int NULL DEFAULT NULL COMMENT '订单类型(1=充电订单,2=购物订单,3=兑换订单,4=会员订单)',
  `order_type` int NULL DEFAULT NULL COMMENT '订单类型(1=充电订单,2=购物订单,3=兑换订单,4=会员订单,5=VIN码充电订单)',
  `order_id` bigint NULL DEFAULT NULL COMMENT '订单id',
  `app_user_id` bigint NULL DEFAULT NULL COMMENT '用户id',
  `mark` int NULL DEFAULT NULL COMMENT '评分',