Преглед на файлове

Merge branch 'feature/service-vpp-20260701' of uskycloud/usky-modules into master

hanzhengyi преди 19 часа
родител
ревизия
f39e380bfd
променени са 17 файла, в които са добавени 992 реда и са изтрити 2 реда
  1. 9 0
      service-tsdb/service-tsdb-api/src/main/java/com/usky/demo/RemoteTsdbProxyService.java
  2. 23 0
      service-tsdb/service-tsdb-api/src/main/java/com/usky/demo/domain/DeviceCsvImportResultVO.java
  3. 6 0
      service-tsdb/service-tsdb-api/src/main/java/com/usky/demo/factory/RemoteTsdbProxyFallbackFactory.java
  4. 10 0
      service-tsdb/service-tsdb-biz/src/main/java/com/usky/demo/controller/api/DataTsdbProxyControllerApi.java
  5. 6 0
      service-tsdb/service-tsdb-biz/src/main/java/com/usky/demo/service/QueryTdengineDataService.java
  6. 187 0
      service-tsdb/service-tsdb-biz/src/main/java/com/usky/demo/service/impl/QueryTdengineDataServiceImpl.java
  7. 84 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/DrMonitorController.java
  8. 16 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/VppDeviceController.java
  9. 4 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppDeviceService.java
  10. 35 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppDrMonitorService.java
  11. 26 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDeviceServiceImpl.java
  12. 463 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrMonitorServiceImpl.java
  13. 3 2
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrServiceImpl.java
  14. 19 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DeviceTsdbImportResultVO.java
  15. 40 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrMonitorCurveVO.java
  16. 40 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrMonitorRecordVO.java
  17. 21 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrMonitorSummaryVO.java

+ 9 - 0
service-tsdb/service-tsdb-api/src/main/java/com/usky/demo/RemoteTsdbProxyService.java

@@ -6,7 +6,9 @@ import com.usky.demo.domain.*;
 import com.usky.demo.factory.RemoteTsdbProxyFallbackFactory;
 import com.usky.demo.feign.RemoteTsdbProxyFeignConfiguration;
 import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
 
 import java.util.List;
 import java.util.Map;
@@ -105,4 +107,11 @@ public interface RemoteTsdbProxyService {
      */
     @PostMapping("/energyItemTrend")
     EnergyItemTrendResultVO sumEnergyItemTrend(@RequestBody EnergyItemTrendQueryVO requestVO);
+
+    /**
+     * 解析 CSV 并导入设备时序数据:INSERT INTO _{deviceUuid} (ts, p) VALUES ...
+     */
+    @PostMapping(value = "/importDeviceCsv", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    ApiResult<DeviceCsvImportResultVO> importDeviceCsv(@RequestParam("deviceUuid") String deviceUuid,
+                                                       @RequestPart("file") MultipartFile file);
 }

+ 23 - 0
service-tsdb/service-tsdb-api/src/main/java/com/usky/demo/domain/DeviceCsvImportResultVO.java

@@ -0,0 +1,23 @@
+package com.usky.demo.domain;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * TDengine CSV 导入结果
+ */
+@Data
+public class DeviceCsvImportResultVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 设备 UUID(入参) */
+    private String deviceUuid;
+
+    /** 实际写入的子表名(通常为 _{deviceUuid}) */
+    private String tableName;
+
+    /** 成功导入的数据行数 */
+    private Integer importedRows;
+}

+ 6 - 0
service-tsdb/service-tsdb-api/src/main/java/com/usky/demo/factory/RemoteTsdbProxyFallbackFactory.java

@@ -104,6 +104,12 @@ public class RemoteTsdbProxyFallbackFactory implements FallbackFactory<RemoteTsd
             {
                 throw new BusinessException("能耗分项趋势查询:" + throwable.getMessage());
             }
+
+            @Override
+            public ApiResult<DeviceCsvImportResultVO> importDeviceCsv(String deviceUuid, org.springframework.web.multipart.MultipartFile file)
+            {
+                throw new BusinessException("设备时序 CSV 导入:" + throwable.getMessage());
+            }
         };
     }
 }

+ 10 - 0
service-tsdb/service-tsdb-biz/src/main/java/com/usky/demo/controller/api/DataTsdbProxyControllerApi.java

@@ -16,6 +16,7 @@ import com.usky.system.RemoteUserService;
 import com.usky.system.domain.SysUserVO;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
@@ -175,4 +176,13 @@ public class DataTsdbProxyControllerApi implements RemoteTsdbProxyService {
         return queryTdengineDataService.sumEnergyItemTrend(requestVO);
     }
 
+    @Override
+    public ApiResult<DeviceCsvImportResultVO> importDeviceCsv(@RequestParam("deviceUuid") String deviceUuid,
+                                                              @RequestPart("file") MultipartFile file) {
+        if (!"taos".equals(sourcetype)) {
+            throw new BusinessException("当前数据源不支持 TDengine CSV 导入");
+        }
+        return ApiResult.success(queryTdengineDataService.importDeviceCsv(deviceUuid, file));
+    }
+
 }

+ 6 - 0
service-tsdb/service-tsdb-biz/src/main/java/com/usky/demo/service/QueryTdengineDataService.java

@@ -6,6 +6,7 @@ import com.usky.demo.service.vo.SuperTableVO;
 import org.apache.ibatis.annotations.Param;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.multipart.MultipartFile;
 
 import java.util.List;
 import java.util.Map;
@@ -48,4 +49,9 @@ public interface QueryTdengineDataService extends CrudService<QueryTdengineData>
      * 能耗分项趋势:按时间粒度 INTERVAL 聚合,返回 time/value 列表
      */
     EnergyItemTrendResultVO sumEnergyItemTrend(EnergyItemTrendQueryVO requestVO);
+
+    /**
+     * 从 CSV 解析并导入设备子表时序:INSERT INTO _{deviceUuid} (ts, p) VALUES ...
+     */
+    DeviceCsvImportResultVO importDeviceCsv(String deviceUuid, MultipartFile file);
 }

+ 187 - 0
service-tsdb/service-tsdb-biz/src/main/java/com/usky/demo/service/impl/QueryTdengineDataServiceImpl.java

@@ -23,12 +23,21 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.cache.annotation.Cacheable;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
 
 import javax.sql.DataSource;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
 import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
 import java.sql.*;
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
 import java.util.*;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
@@ -47,6 +56,16 @@ public class QueryTdengineDataServiceImpl extends AbstractCrudService<QueryTdeng
 
     private static final Pattern SQL_IDENTIFIER_PATTERN = Pattern.compile("^[a-zA-Z_][a-zA-Z0-9_]*$");
 
+    private static final Pattern DEVICE_UUID_PATTERN = Pattern.compile("^[a-zA-Z0-9_-]+$");
+
+    private static final int IMPORT_BATCH_SIZE = 500;
+
+    private static final DateTimeFormatter TS_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    private static final List<String> TS_HEADER_ALIASES = Arrays.asList("ts", "timestamp", "time");
+
+    private static final List<String> P_HEADER_ALIASES = Arrays.asList("p", "power", "active_power", "value");
+
     @Autowired
     private TsdbUtils tsdbUtils;
     @Autowired
@@ -460,4 +479,172 @@ public class QueryTdengineDataServiceImpl extends AbstractCrudService<QueryTdeng
             throw new BusinessException(paramName + " 格式非法,仅允许字母、数字与下划线,且不能以数字开头");
         }
     }
+
+    @Override
+    public DeviceCsvImportResultVO importDeviceCsv(String deviceUuid, MultipartFile file) {
+        if (StringUtils.isBlank(deviceUuid)) {
+            throw new BusinessException("设备UUID不能为空");
+        }
+        if (!DEVICE_UUID_PATTERN.matcher(deviceUuid.trim()).matches()) {
+            throw new BusinessException("设备UUID格式无效");
+        }
+        if (file == null || file.isEmpty()) {
+            throw new BusinessException("导入文件不能为空");
+        }
+        String originalFilename = file.getOriginalFilename();
+        if (StringUtils.isBlank(originalFilename) || !originalFilename.toLowerCase(Locale.ROOT).endsWith(".csv")) {
+            throw new BusinessException("仅支持 CSV 文件");
+        }
+
+        String normalizedUuid = deviceUuid.trim();
+        String tableName = buildDeviceSubTableName(normalizedUuid);
+        try {
+            List<TsdbCsvRow> rows = parseCsvRows(file);
+            batchInsertRows(tableName, rows);
+
+            DeviceCsvImportResultVO result = new DeviceCsvImportResultVO();
+            result.setDeviceUuid(normalizedUuid);
+            result.setTableName(tableName);
+            result.setImportedRows(rows.size());
+            return result;
+        } catch (IOException ex) {
+            throw new BusinessException("解析 CSV 文件失败: " + ex.getMessage());
+        } catch (SQLException ex) {
+            throw new BusinessException("时序数据导入失败: " + ex.getMessage());
+        }
+    }
+
+    private List<TsdbCsvRow> parseCsvRows(MultipartFile file) throws IOException {
+        List<TsdbCsvRow> rows = new ArrayList<>();
+        try (BufferedReader reader = new BufferedReader(
+                new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {
+            String line;
+            int tsIndex = 0;
+            int pIndex = 1;
+            boolean headerResolved = false;
+            int lineNo = 0;
+            while ((line = reader.readLine()) != null) {
+                lineNo++;
+                line = line.trim();
+                if (line.isEmpty()) {
+                    continue;
+                }
+                String[] parts = splitCsvLine(line);
+                if (!headerResolved) {
+                    if (isHeaderRow(parts)) {
+                        tsIndex = findColumnIndex(parts, TS_HEADER_ALIASES, "ts");
+                        pIndex = findColumnIndex(parts, P_HEADER_ALIASES, "p");
+                        headerResolved = true;
+                        continue;
+                    }
+                    headerResolved = true;
+                }
+                if (parts.length <= Math.max(tsIndex, pIndex)) {
+                    throw new BusinessException("CSV 第 " + lineNo + " 行列数不足");
+                }
+                rows.add(new TsdbCsvRow(
+                        parseTimestamp(parts[tsIndex], lineNo),
+                        parseMetricValue(parts[pIndex], lineNo)));
+            }
+        }
+        if (rows.isEmpty()) {
+            throw new BusinessException("CSV 无有效数据行");
+        }
+        return rows;
+    }
+
+    private void batchInsertRows(String tableName, List<TsdbCsvRow> rows) throws SQLException {
+        try (Connection connection = dataSource.getConnection();
+             Statement statement = connection.createStatement()) {
+            for (int start = 0; start < rows.size(); start += IMPORT_BATCH_SIZE) {
+                int end = Math.min(start + IMPORT_BATCH_SIZE, rows.size());
+                StringBuilder sql = new StringBuilder("INSERT INTO ")
+                        .append(tableName)
+                        .append(" (ts, p) VALUES ");
+                for (int i = start; i < end; i++) {
+                    TsdbCsvRow row = rows.get(i);
+                    if (i > start) {
+                        sql.append(' ');
+                    }
+                    sql.append('(').append(row.tsMillis).append(',').append(row.pValue).append(')');
+                }
+                statement.execute(sql.toString());
+            }
+        }
+    }
+
+    private static String[] splitCsvLine(String line) {
+        return Arrays.stream(line.split(",", -1))
+                .map(String::trim)
+                .toArray(String[]::new);
+    }
+
+    private static boolean isHeaderRow(String[] parts) {
+        for (String part : parts) {
+            String normalized = part.trim().toLowerCase(Locale.ROOT);
+            if (TS_HEADER_ALIASES.contains(normalized) || P_HEADER_ALIASES.contains(normalized)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static int findColumnIndex(String[] headers, List<String> aliases, String columnLabel) {
+        for (int i = 0; i < headers.length; i++) {
+            if (aliases.contains(headers[i].trim().toLowerCase(Locale.ROOT))) {
+                return i;
+            }
+        }
+        throw new BusinessException("CSV 缺少 " + columnLabel + " 列");
+    }
+
+    private static long parseTimestamp(String rawValue, int lineNo) {
+        String value = rawValue == null ? "" : rawValue.trim();
+        if (value.isEmpty()) {
+            throw new BusinessException("CSV 第 " + lineNo + " 行 ts 为空");
+        }
+        if (value.matches("\\d+")) {
+            long epoch = Long.parseLong(value);
+            return value.length() <= 10 ? epoch * 1000L : epoch;
+        }
+        try {
+            return LocalDateTime.parse(value, TS_FORMAT)
+                    .atZone(ZoneId.systemDefault())
+                    .toInstant()
+                    .toEpochMilli();
+        } catch (DateTimeParseException ignored) {
+            // fall through
+        }
+        try {
+            return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss").parse(value).getTime();
+        } catch (ParseException ex) {
+            throw new BusinessException("CSV 第 " + lineNo + " 行 ts 格式无效: " + value);
+        }
+    }
+
+    private static double parseMetricValue(String rawValue, int lineNo) {
+        String value = rawValue == null ? "" : rawValue.trim();
+        if (value.isEmpty()) {
+            throw new BusinessException("CSV 第 " + lineNo + " 行 p 为空");
+        }
+        try {
+            return new BigDecimal(value).doubleValue();
+        } catch (NumberFormatException ex) {
+            throw new BusinessException("CSV 第 " + lineNo + " 行 p 不是有效数值: " + value);
+        }
+    }
+
+    private static String buildDeviceSubTableName(String deviceUuid) {
+        return "_" + deviceUuid;
+    }
+
+    private static final class TsdbCsvRow {
+        private final long tsMillis;
+        private final double pValue;
+
+        private TsdbCsvRow(long tsMillis, double pValue) {
+            this.tsMillis = tsMillis;
+            this.pValue = pValue;
+        }
+    }
 }

+ 84 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/DrMonitorController.java

@@ -0,0 +1,84 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.service.VppDrMonitorService;
+import com.usky.vpp.service.vo.DrMonitorCurveVO;
+import com.usky.vpp.service.vo.DrMonitorRecordVO;
+import com.usky.vpp.service.vo.DrMonitorSummaryVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 需求响应监测(响应记录)
+ * <p>网关前缀: /prod-api/service-vpp/dr-monitor</p>
+ */
+@RestController
+@RequestMapping("/drMonitor")
+public class DrMonitorController {
+
+    @Autowired
+    private VppDrMonitorService drMonitorService;
+
+    /**
+     * 顶部 KPI:响应次数 / 响应容量(kW) / 历史达标率
+     * <p>必传站点;先查站点下 is_support_peak=1 资源点,再按资源点平均。</p>
+     */
+    @GetMapping("/summary")
+    public ApiResult<DrMonitorSummaryVO> summary(@RequestParam("startDate") String startDate,
+                                                 @RequestParam("endDate") String endDate,
+                                                 @RequestParam("siteIds") List<Long> siteIds) {
+        return ApiResult.success(drMonitorService.getSummary(startDate, endDate, siteIds));
+    }
+
+    /**
+     * 响应记录分页(字段对齐 vpp_dr_event)
+     * <p>必传站点;仅统计可调峰资源点参与过的事件。</p>
+     */
+    @GetMapping("/records")
+    public ApiResult<CommonPage<DrMonitorRecordVO>> records(@RequestParam("startDate") String startDate,
+                                                            @RequestParam("endDate") String endDate,
+                                                            @RequestParam("siteIds") List<Long> siteIds,
+                                                            @RequestParam(required = false) Map<String, Object> params) {
+        return ApiResult.success(drMonitorService.pageRecords(startDate, endDate, siteIds, params));
+    }
+
+    /**
+     * 折线图:基线/实际负荷 + 申报/实际响应容量
+     * <p>必传站点;仅 is_support_peak=1 资源点;曲线按资源点加权平均。</p>
+     */
+    @GetMapping("/records/{id}/curve")
+    public ApiResult<DrMonitorCurveVO> curve(@PathVariable("id") Long id,
+                                             @RequestParam("siteIds") List<Long> siteIds) {
+        return ApiResult.success(drMonitorService.getCurve(id, siteIds));
+    }
+
+    // private List<Long> parseSiteIds(List<String> raw) {
+    //     if (raw == null || raw.isEmpty()) {
+    //         return Collections.emptyList();
+    //     }
+    //     List<Long> ids = new ArrayList<>();
+    //     for (String item : raw) {
+    //         if (!StringUtils.hasText(item)) {
+    //             continue;
+    //         }
+    //         Arrays.stream(item.split(","))
+    //                 .map(String::trim)
+    //                 .filter(StringUtils::hasText)
+    //                 .forEach(token -> ids.add(Long.parseLong(token)));
+    //     }
+    //     return ids.stream().distinct().collect(Collectors.toList());
+    // }
+}

+ 16 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/VppDeviceController.java

@@ -5,8 +5,11 @@ import com.usky.common.core.bean.CommonPage;
 import com.usky.vpp.domain.VppDevice;
 import com.usky.vpp.service.VppDeviceService;
 import com.usky.vpp.service.vo.DeviceRequest;
+import com.usky.vpp.service.vo.DeviceTsdbImportResultVO;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
 
 import java.util.Map;
 
@@ -47,4 +50,17 @@ public class VppDeviceController {
         vppDeviceService.deleteDevice(id);
         return ApiResult.success();
     }
+
+    /**
+     * 解析 CSV 并导入设备时序数据到 TDengine。
+     * <p>CSV 需包含 ts(时间戳)与 p(数值)列,支持表头;时间支持毫秒/秒时间戳或 yyyy-MM-dd HH:mm:ss。</p>
+     *
+     * @param deviceUuid 设备 UUID,对应子表 _{deviceUuid}
+     * @param file       CSV 文件
+     */
+    @PostMapping(value = "/import-tsdb", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    public ApiResult<DeviceTsdbImportResultVO> importTsdbData(@RequestParam("deviceUuid") String deviceUuid,
+                                                             @RequestPart("file") MultipartFile file) {
+        return ApiResult.success(vppDeviceService.importTsdbData(deviceUuid, file));
+    }
 }

+ 4 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppDeviceService.java

@@ -3,6 +3,8 @@ package com.usky.vpp.service;
 import com.usky.common.core.bean.CommonPage;
 import com.usky.vpp.domain.VppDevice;
 import com.usky.vpp.service.vo.DeviceRequest;
+import com.usky.vpp.service.vo.DeviceTsdbImportResultVO;
+import org.springframework.web.multipart.MultipartFile;
 
 import java.util.Map;
 
@@ -24,4 +26,6 @@ public interface VppDeviceService {
     void updateDevice(Long id, DeviceRequest request);
 
     void deleteDevice(Long id);
+
+    DeviceTsdbImportResultVO importTsdbData(String deviceUuid, MultipartFile file);
 }

+ 35 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppDrMonitorService.java

@@ -0,0 +1,35 @@
+package com.usky.vpp.service;
+
+import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.service.vo.DrMonitorCurveVO;
+import com.usky.vpp.service.vo.DrMonitorRecordVO;
+import com.usky.vpp.service.vo.DrMonitorSummaryVO;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 需求响应监测(响应记录)
+ */
+public interface VppDrMonitorService {
+
+    /**
+     * 顶部 KPI
+     *
+     * @param startDate 开始日期 yyyy-MM-dd
+     * @param endDate   结束日期 yyyy-MM-dd
+     * @param siteIds   站点 ID(必填,可多个);先查站点下 is_support_peak=1 资源点再平均
+     */
+    DrMonitorSummaryVO getSummary(String startDate, String endDate, List<Long> siteIds);
+
+    /**
+     * 响应记录分页(按事件表字段;仅可调峰资源点参与过的事件)
+     */
+    CommonPage<DrMonitorRecordVO> pageRecords(String startDate, String endDate,
+                                              List<Long> siteIds, Map<String, Object> params);
+
+    /**
+     * 折线图:基线/实际负荷按可调峰资源点加权平均;申报/实际响应按资源点平均
+     */
+    DrMonitorCurveVO getCurve(Long eventId, List<Long> siteIds);
+}

+ 26 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDeviceServiceImpl.java

@@ -2,10 +2,13 @@ package com.usky.vpp.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.usky.common.core.bean.ApiResult;
 import com.usky.common.core.bean.CommonPage;
 import com.usky.common.core.exception.BusinessException;
 import com.usky.common.core.util.UUIDUtils;
 import com.usky.common.security.utils.SecurityUtils;
+import com.usky.demo.RemoteTsdbProxyService;
+import com.usky.demo.domain.DeviceCsvImportResultVO;
 import com.usky.iot.RemoteIotTaskService;
 import com.usky.vpp.domain.DmpDeviceStatus;
 import com.usky.vpp.domain.VppDevice;
@@ -14,6 +17,7 @@ import com.usky.vpp.mapper.VppDeviceMapper;
 import com.usky.vpp.service.VppDeviceService;
 import com.usky.vpp.service.VppSiteService;
 import com.usky.vpp.service.vo.DeviceRequest;
+import com.usky.vpp.service.vo.DeviceTsdbImportResultVO;
 import com.usky.vpp.util.VppAuditHelper;
 import com.usky.vpp.util.VppPageHelper;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -21,6 +25,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.CollectionUtils;
 import org.springframework.util.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
 
 import java.util.List;
 import java.util.Map;
@@ -37,6 +42,8 @@ public class VppDeviceServiceImpl implements VppDeviceService {
     private VppSiteService siteService;
     @Autowired
     private RemoteIotTaskService remoteIotTaskService;
+    @Autowired
+    private RemoteTsdbProxyService remoteTsdbProxyService;
 
     @Override
     public CommonPage<VppDevice> pageDevice(Map<String, Object> params) {
@@ -123,6 +130,25 @@ public class VppDeviceServiceImpl implements VppDeviceService {
         remoteIotTaskService.deleteDeviceInfo(device.getDeviceUuid());
     }
 
+    @Override
+    public DeviceTsdbImportResultVO importTsdbData(String deviceUuid, MultipartFile file) {
+        getByDeviceUuid(deviceUuid);
+        if (file == null || file.isEmpty()) {
+            throw new BusinessException("导入文件不能为空");
+        }
+        ApiResult<DeviceCsvImportResultVO> result = remoteTsdbProxyService.importDeviceCsv(deviceUuid, file);
+        if (result == null || !result.isSuccess() || result.getData() == null) {
+            throw new BusinessException(result != null && StringUtils.hasText(result.getMsg())
+                    ? result.getMsg() : "时序数据导入失败");
+        }
+        DeviceCsvImportResultVO data = result.getData();
+        DeviceTsdbImportResultVO vo = new DeviceTsdbImportResultVO();
+        vo.setDeviceUuid(data.getDeviceUuid());
+        vo.setTableName(data.getTableName());
+        vo.setImportedRows(data.getImportedRows());
+        return vo;
+    }
+
     private LambdaQueryWrapper<VppDevice> buildQueryWrapper(Map<String, Object> params) {
         LambdaQueryWrapper<VppDevice> wrapper = new LambdaQueryWrapper<VppDevice>()
                 .eq(VppDevice::getDeleteFlag, VppAuditHelper.NOT_DELETED)

+ 463 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrMonitorServiceImpl.java

@@ -0,0 +1,463 @@
+package com.usky.vpp.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.common.core.exception.BusinessException;
+import com.usky.common.security.utils.SecurityUtils;
+import com.usky.vpp.domain.VppDrEvent;
+import com.usky.vpp.domain.VppDrInvitation;
+import com.usky.vpp.domain.VppDrParticipation;
+import com.usky.vpp.domain.VppResourcePoint;
+import com.usky.vpp.mapper.VppDrEventMapper;
+import com.usky.vpp.mapper.VppDrInvitationMapper;
+import com.usky.vpp.mapper.VppDrParticipationMapper;
+import com.usky.vpp.mapper.VppResourcePointMapper;
+import com.usky.vpp.service.VppBaselineService;
+import com.usky.vpp.service.VppDrMonitorService;
+import com.usky.vpp.service.vo.BaselinePointVO;
+import com.usky.vpp.service.vo.DrMonitorCurveVO;
+import com.usky.vpp.service.vo.DrMonitorRecordVO;
+import com.usky.vpp.service.vo.DrMonitorSummaryVO;
+import com.usky.vpp.service.vo.SiteBaselineVO;
+import com.usky.vpp.util.VppAuditHelper;
+import com.usky.vpp.util.VppPageHelper;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 需求响应监测(响应记录)
+ * <p>统一口径:站点 → is_support_peak=1 资源点 → 按资源点平均。</p>
+ */
+@Service
+public class VppDrMonitorServiceImpl implements VppDrMonitorService {
+
+    private static final int EVENT_STATUS_EXECUTING = 2;
+    private static final int EVENT_STATUS_ENDED = 3;
+    private static final int SUPPORT_PEAK = 1;
+    private static final int PARTICIPATE_ACCEPT = 1;
+    private static final int SCALE = 4;
+    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+    private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+    private static final BigDecimal ONE = BigDecimal.ONE;
+    private static final BigDecimal HUNDRED = new BigDecimal("100");
+
+    @Autowired
+    private VppDrEventMapper eventMapper;
+    @Autowired
+    private VppDrInvitationMapper invitationMapper;
+    @Autowired
+    private VppDrParticipationMapper participationMapper;
+    @Autowired
+    private VppResourcePointMapper resourcePointMapper;
+    @Autowired
+    private VppBaselineService baselineService;
+
+    @Override
+    public DrMonitorSummaryVO getSummary(String startDate, String endDate, List<Long> siteIds) {
+        Integer tenantId = SecurityUtils.getTenantId();
+        LocalDateTime rangeStart = parseStart(startDate);
+        LocalDateTime rangeEnd = parseEnd(endDate);
+        requireSiteIds(siteIds);
+
+        List<VppResourcePoint> peakResources = listPeakResources(siteIds, tenantId);
+        DrMonitorSummaryVO vo = new DrMonitorSummaryVO();
+        vo.setPeakResourceCount((long) peakResources.size());
+        if (peakResources.isEmpty()) {
+            return vo;
+        }
+
+        Set<Long> resourceIds = peakResources.stream().map(VppResourcePoint::getId).collect(Collectors.toSet());
+        Map<Long, Long> resourceSiteMap = peakResources.stream()
+                .collect(Collectors.toMap(VppResourcePoint::getId, VppResourcePoint::getSiteId, (a, b) -> a));
+
+        List<VppDrParticipation> participations = listParticipations(resourceIds, tenantId);
+        Map<Long, VppDrEvent> eventMap = loadEventMap(participations, tenantId);
+        List<VppDrEvent> rangedEvents = filterEventsByTime(eventMap.values(), rangeStart, rangeEnd);
+
+        Set<Long> countEventIds = rangedEvents.stream()
+                .filter(e -> e.getEventStatus() != null
+                        && (e.getEventStatus() == EVENT_STATUS_EXECUTING || e.getEventStatus() == EVENT_STATUS_ENDED))
+                .map(VppDrEvent::getId)
+                .collect(Collectors.toSet());
+        Set<Long> endedEventIds = rangedEvents.stream()
+                .filter(e -> Objects.equals(e.getEventStatus(), EVENT_STATUS_ENDED))
+                .map(VppDrEvent::getId)
+                .collect(Collectors.toSet());
+
+        // 每个资源点:参与的执行中/已结束事件数
+        Map<Long, Set<Long>> resourceCountEvents = new HashMap<>();
+        // 每个资源点:参与的已结束事件
+        Map<Long, Set<Long>> resourceEndedEvents = new HashMap<>();
+        for (VppDrParticipation p : participations) {
+            if (p.getResourceId() == null || p.getEventId() == null) {
+                continue;
+            }
+            if (countEventIds.contains(p.getEventId())) {
+                resourceCountEvents.computeIfAbsent(p.getResourceId(), k -> new HashSet<>()).add(p.getEventId());
+            }
+            if (endedEventIds.contains(p.getEventId())) {
+                resourceEndedEvents.computeIfAbsent(p.getResourceId(), k -> new HashSet<>()).add(p.getEventId());
+            }
+        }
+
+        Long countSum = 0L;
+        for (VppResourcePoint resource : peakResources) {
+            countSum += resourceCountEvents.getOrDefault(resource.getId(), Collections.emptySet()).size();
+        }
+        vo.setResponseCount(countSum);
+
+        // 邀约按站点:容量与达标
+        Set<Long> peakSiteIds = peakResources.stream().map(VppResourcePoint::getSiteId).collect(Collectors.toSet());
+        List<VppDrInvitation> endedInvitations = listInvitations(endedEventIds, peakSiteIds, tenantId);
+        Map<Long, List<VppDrInvitation>> invitationsBySite = endedInvitations.stream()
+                .filter(i -> i.getSiteId() != null)
+                .collect(Collectors.groupingBy(VppDrInvitation::getSiteId));
+
+        BigDecimal capacitySum = BigDecimal.ZERO;
+        BigDecimal rateSum = BigDecimal.ZERO;
+        int rateResourceCount = 0;
+        for (VppResourcePoint resource : peakResources) {
+            Long siteId = resourceSiteMap.get(resource.getId());
+            Set<Long> endedForResource = resourceEndedEvents.getOrDefault(resource.getId(), Collections.emptySet());
+            List<VppDrInvitation> siteInvitations = invitationsBySite.getOrDefault(siteId, Collections.emptyList())
+                    .stream()
+                    .filter(i -> endedForResource.contains(i.getDrEventId()))
+                    .collect(Collectors.toList());
+
+            BigDecimal resourceCapacity = BigDecimal.ZERO;
+            long qualified = 0;
+            for (VppDrInvitation invitation : siteInvitations) {
+                resourceCapacity = resourceCapacity.add(nz(invitation.getActualResponseCapacityKw()));
+                if (isCompletionQualified(invitation.getResponseCompletionRate())) {
+                    qualified++;
+                }
+            }
+            capacitySum = capacitySum.add(resourceCapacity);
+            if (!siteInvitations.isEmpty()) {
+                rateSum = rateSum.add(BigDecimal.valueOf(qualified)
+                        .divide(BigDecimal.valueOf(siteInvitations.size()), 8, RoundingMode.HALF_UP));
+                rateResourceCount++;
+            }
+        }
+        vo.setResponseCapacityKw(avg(capacitySum, peakResources.size()));
+        if (rateResourceCount > 0) {
+            vo.setHistoricalQualifiedRate(avg(rateSum, rateResourceCount));
+        }
+        return vo;
+    }
+
+    @Override
+    public CommonPage<DrMonitorRecordVO> pageRecords(String startDate, String endDate,
+                                                     List<Long> siteIds, Map<String, Object> params) {
+        Integer tenantId = SecurityUtils.getTenantId();
+        LocalDateTime rangeStart = parseStart(startDate);
+        LocalDateTime rangeEnd = parseEnd(endDate);
+        requireSiteIds(siteIds);
+
+        List<VppResourcePoint> peakResources = listPeakResources(siteIds, tenantId);
+        if (peakResources.isEmpty()) {
+            Page<?> page = VppPageHelper.of(params);
+            return new CommonPage<>(Collections.emptyList(), 0L, page.getCurrent(), page.getSize());
+        }
+
+        Set<Long> resourceIds = peakResources.stream().map(VppResourcePoint::getId).collect(Collectors.toSet());
+        Set<Long> peakSiteIds = peakResources.stream().map(VppResourcePoint::getSiteId).collect(Collectors.toSet());
+        List<VppDrParticipation> participations = listParticipations(resourceIds, tenantId);
+        Map<Long, VppDrEvent> eventMap = loadEventMap(participations, tenantId);
+
+        List<VppDrEvent> events = filterEventsByTime(eventMap.values(), rangeStart, rangeEnd).stream()
+                .filter(e -> e.getEventStatus() != null
+                        && (e.getEventStatus() == EVENT_STATUS_EXECUTING || e.getEventStatus() == EVENT_STATUS_ENDED))
+                .sorted(Comparator.comparing(VppDrEvent::getStartTime, Comparator.nullsLast(Comparator.reverseOrder())))
+                .collect(Collectors.toList());
+
+        Set<Long> eventIds = events.stream().map(VppDrEvent::getId).collect(Collectors.toSet());
+        List<VppDrInvitation> invitations = listInvitations(eventIds, peakSiteIds, tenantId);
+        Map<Long, List<VppDrInvitation>> byEvent = invitations.stream()
+                .filter(i -> i.getDrEventId() != null)
+                .collect(Collectors.groupingBy(VppDrInvitation::getDrEventId));
+
+        List<DrMonitorRecordVO> records = events.stream()
+                .map(e -> toRecordVo(e, byEvent.getOrDefault(e.getId(), Collections.emptyList())))
+                .collect(Collectors.toList());
+
+        Page<?> page = VppPageHelper.of(params);
+        long current = page.getCurrent();
+        long size = page.getSize();
+        int from = (int) Math.min((current - 1) * size, records.size());
+        int to = (int) Math.min(from + size, records.size());
+        List<DrMonitorRecordVO> pageRecords = from >= records.size()
+                ? Collections.emptyList()
+                : records.subList(from, to);
+        return new CommonPage<>(pageRecords, (long) records.size(), current, size);
+    }
+
+    @Override
+    public DrMonitorCurveVO getCurve(Long eventId, List<Long> siteIds) {
+        if (eventId == null) {
+            throw new BusinessException("事件ID不能为空");
+        }
+        requireSiteIds(siteIds);
+        Integer tenantId = SecurityUtils.getTenantId();
+        VppDrEvent event = eventMapper.selectById(eventId);
+        if (event == null || VppAuditHelper.isDeleted(event.getDeleteFlag())) {
+            throw new BusinessException("事件不存在");
+        }
+        if (tenantId != null && event.getTenantId() != null && !tenantId.equals(event.getTenantId())) {
+            throw new BusinessException("事件不存在");
+        }
+        if (event.getStartTime() == null) {
+            throw new BusinessException("事件缺少开始时间");
+        }
+
+        List<VppResourcePoint> peakResources = listPeakResources(siteIds, tenantId);
+        if (peakResources.isEmpty()) {
+            throw new BusinessException("所选站点下无可参与调峰的资源点");
+        }
+        int resourceCount = peakResources.size();
+        Map<Long, Long> siteResourceCount = new HashMap<>();
+        for (VppResourcePoint resource : peakResources) {
+            siteResourceCount.merge(resource.getSiteId(), 1L, Long::sum);
+        }
+        Set<Long> peakSiteIds = siteResourceCount.keySet();
+
+        List<VppDrInvitation> invitations = listInvitations(
+                Collections.singleton(event.getId()), peakSiteIds, tenantId);
+        BigDecimal declaredSum = BigDecimal.ZERO;
+        BigDecimal actualSum = BigDecimal.ZERO;
+        for (VppDrInvitation invitation : invitations) {
+            declaredSum = declaredSum.add(nz(invitation.getDeclaredCapacityKw()));
+            actualSum = actualSum.add(nz(invitation.getActualResponseCapacityKw()));
+        }
+        BigDecimal declaredAvg = avg(declaredSum, resourceCount);
+        BigDecimal actualAvg = avg(actualSum, resourceCount);
+
+        String responseStartTime = event.getStartTime().format(DATE_TIME_FMT);
+        // 按资源点加权:站点曲线 × 该站可调峰资源数,再 / 总资源数
+        Map<String, BigDecimal> baselineWeighted = new LinkedHashMap<>();
+        Map<String, BigDecimal> loadWeighted = new LinkedHashMap<>();
+        Set<String> dataSources = new LinkedHashSet<>();
+        List<Long> usedSiteIds = new ArrayList<>();
+
+        for (Map.Entry<Long, Long> entry : siteResourceCount.entrySet()) {
+            Long siteId = entry.getKey();
+            BigDecimal weight = BigDecimal.valueOf(entry.getValue());
+            SiteBaselineVO baseline = baselineService.getSiteBaseline(siteId, responseStartTime);
+            if (baseline == null || CollectionUtils.isEmpty(baseline.getPoints())) {
+                continue;
+            }
+            usedSiteIds.add(siteId);
+            if (StringUtils.hasText(baseline.getDataSource())) {
+                dataSources.add(baseline.getDataSource());
+            }
+            for (BaselinePointVO point : baseline.getPoints()) {
+                if (!StringUtils.hasText(point.getTime())) {
+                    continue;
+                }
+                baselineWeighted.merge(point.getTime(),
+                        nz(point.getPredictedBaselineKw()).multiply(weight), BigDecimal::add);
+                loadWeighted.merge(point.getTime(),
+                        nz(point.getTodayActualKw()).multiply(weight), BigDecimal::add);
+            }
+        }
+
+        BigDecimal divisor = BigDecimal.valueOf(resourceCount);
+        DrMonitorCurveVO curve = new DrMonitorCurveVO();
+        curve.setId(event.getId());
+        curve.setEventId(event.getEventId());
+        curve.setResponseDate(event.getStartTime().toLocalDate().format(DATE_FMT));
+        curve.setSiteIds(usedSiteIds);
+        curve.setDeclaredCapacityKw(declaredAvg);
+        curve.setActualResponseCapacityKw(actualAvg);
+        if (dataSources.isEmpty()) {
+            curve.setDataSource("mock");
+        } else if (dataSources.size() == 1) {
+            curve.setDataSource(dataSources.iterator().next());
+        } else {
+            curve.setDataSource("mixed");
+        }
+
+        List<String> times = new ArrayList<>(baselineWeighted.keySet());
+        times.sort(Comparator.naturalOrder());
+        for (String time : times) {
+            DrMonitorCurveVO.Point point = new DrMonitorCurveVO.Point();
+            point.setTime(time);
+            point.setBaselineKw(baselineWeighted.getOrDefault(time, BigDecimal.ZERO)
+                    .divide(divisor, SCALE, RoundingMode.HALF_UP));
+            point.setLoadKw(loadWeighted.getOrDefault(time, BigDecimal.ZERO)
+                    .divide(divisor, SCALE, RoundingMode.HALF_UP));
+            point.setDeclaredCapacityKw(declaredAvg);
+            point.setActualResponseCapacityKw(actualAvg);
+            curve.getPoints().add(point);
+        }
+        return curve;
+    }
+
+    // ---------- loaders ----------
+
+    private void requireSiteIds(List<Long> siteIds) {
+        if (CollectionUtils.isEmpty(siteIds)) {
+            throw new BusinessException("站点ID不能为空");
+        }
+    }
+
+    private List<VppResourcePoint> listPeakResources(List<Long> siteIds, Integer tenantId) {
+        return resourcePointMapper.selectList(new LambdaQueryWrapper<VppResourcePoint>()
+                .eq(VppResourcePoint::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(VppResourcePoint::getIsSupportPeak, SUPPORT_PEAK)
+                .in(VppResourcePoint::getSiteId, siteIds)
+                .eq(tenantId != null, VppResourcePoint::getTenantId, tenantId)
+                .isNotNull(VppResourcePoint::getSiteId));
+    }
+
+    private List<VppDrParticipation> listParticipations(Set<Long> resourceIds, Integer tenantId) {
+        if (CollectionUtils.isEmpty(resourceIds)) {
+            return Collections.emptyList();
+        }
+        return participationMapper.selectList(new LambdaQueryWrapper<VppDrParticipation>()
+                .in(VppDrParticipation::getResourceId, resourceIds)
+                .eq(VppDrParticipation::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(VppDrParticipation::getParticipateStatus, PARTICIPATE_ACCEPT)
+                .eq(tenantId != null, VppDrParticipation::getTenantId, tenantId));
+    }
+
+    private Map<Long, VppDrEvent> loadEventMap(List<VppDrParticipation> participations, Integer tenantId) {
+        Set<Long> eventIds = participations.stream()
+                .map(VppDrParticipation::getEventId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (eventIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return eventMapper.selectList(new LambdaQueryWrapper<VppDrEvent>()
+                        .in(VppDrEvent::getId, eventIds)
+                        .eq(VppDrEvent::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                        .eq(tenantId != null, VppDrEvent::getTenantId, tenantId))
+                .stream()
+                .collect(Collectors.toMap(VppDrEvent::getId, e -> e, (a, b) -> a));
+    }
+
+    private List<VppDrEvent> filterEventsByTime(Iterable<VppDrEvent> events,
+                                                LocalDateTime rangeStart, LocalDateTime rangeEnd) {
+        List<VppDrEvent> result = new ArrayList<>();
+        for (VppDrEvent event : events) {
+            if (event.getStartTime() == null) {
+                continue;
+            }
+            if (rangeStart != null && event.getStartTime().isBefore(rangeStart)) {
+                continue;
+            }
+            if (rangeEnd != null && event.getStartTime().isAfter(rangeEnd)) {
+                continue;
+            }
+            result.add(event);
+        }
+        return result;
+    }
+
+    private List<VppDrInvitation> listInvitations(Set<Long> eventIds, Set<Long> siteIds, Integer tenantId) {
+        if (CollectionUtils.isEmpty(eventIds) || CollectionUtils.isEmpty(siteIds)) {
+            return Collections.emptyList();
+        }
+        return invitationMapper.selectList(new LambdaQueryWrapper<VppDrInvitation>()
+                .in(VppDrInvitation::getDrEventId, eventIds)
+                .in(VppDrInvitation::getSiteId, siteIds)
+                .eq(VppDrInvitation::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(tenantId != null, VppDrInvitation::getTenantId, tenantId));
+    }
+
+    private DrMonitorRecordVO toRecordVo(VppDrEvent event, List<VppDrInvitation> invitations) {
+        DrMonitorRecordVO vo = new DrMonitorRecordVO();
+        BeanUtils.copyProperties(event, vo);
+        if (CollectionUtils.isEmpty(invitations)) {
+            vo.setResponseCompletionRate(null);
+            vo.setQualified(false);
+            return vo;
+        }
+        BigDecimal sum = BigDecimal.ZERO;
+        int rateCount = 0;
+        boolean allQualified = true;
+        for (VppDrInvitation invitation : invitations) {
+            BigDecimal rate = invitation.getResponseCompletionRate();
+            if (rate != null) {
+                sum = sum.add(rate);
+                rateCount++;
+            }
+            if (!isCompletionQualified(rate)) {
+                allQualified = false;
+            }
+        }
+        if (rateCount > 0) {
+            vo.setResponseCompletionRate(sum.divide(BigDecimal.valueOf(rateCount), SCALE, RoundingMode.HALF_UP));
+        }
+        vo.setQualified(allQualified && rateCount == invitations.size());
+        return vo;
+    }
+
+    static boolean isCompletionQualified(BigDecimal rate) {
+        if (rate == null) {
+            return false;
+        }
+        BigDecimal scaled = rate.setScale(2, RoundingMode.HALF_UP);
+        return scaled.compareTo(ONE.setScale(2, RoundingMode.HALF_UP)) == 0
+                || scaled.compareTo(HUNDRED.setScale(2, RoundingMode.HALF_UP)) == 0;
+    }
+
+    private static BigDecimal avg(BigDecimal sum, int divisor) {
+        if (divisor <= 0) {
+            return BigDecimal.ZERO.setScale(SCALE, RoundingMode.HALF_UP);
+        }
+        return nz(sum).divide(BigDecimal.valueOf(divisor), SCALE, RoundingMode.HALF_UP);
+    }
+
+    private static LocalDateTime parseStart(String date) {
+        if (!StringUtils.hasText(date)) {
+            throw new BusinessException("开始日期不能为空");
+        }
+        try {
+            return LocalDate.parse(date.trim(), DATE_FMT).atStartOfDay();
+        } catch (DateTimeParseException ex) {
+            throw new BusinessException("开始日期格式无效,应为 yyyy-MM-dd");
+        }
+    }
+
+    private static LocalDateTime parseEnd(String date) {
+        if (!StringUtils.hasText(date)) {
+            throw new BusinessException("结束日期不能为空");
+        }
+        try {
+            return LocalDate.parse(date.trim(), DATE_FMT).atTime(LocalTime.MAX);
+        } catch (DateTimeParseException ex) {
+            throw new BusinessException("结束日期格式无效,应为 yyyy-MM-dd");
+        }
+    }
+
+    private static BigDecimal nz(BigDecimal value) {
+        return value == null ? BigDecimal.ZERO : value;
+    }
+}

+ 3 - 2
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrServiceImpl.java

@@ -21,6 +21,7 @@ import com.usky.vpp.mapper.VppDrStrategyMapper;
 import com.usky.vpp.mapper.VppDrStrategyResourceMapper;
 import com.usky.vpp.mapper.VppResourcePointMapper;
 import com.usky.vpp.service.VppDrService;
+import com.usky.vpp.service.VppDrSubsidyPredictionService;
 import com.usky.vpp.service.VppUnIntegrationService;
 import com.usky.vpp.service.vo.DrClearingRequest;
 import com.usky.vpp.service.vo.DrEventDetailVO;
@@ -85,7 +86,7 @@ public class VppDrServiceImpl implements VppDrService {
     @Autowired
     private VppUnIntegrationService unIntegrationService;
     @Autowired
-    private VppDrSubsidyPredictionServiceImpl subsidyPredictionService;
+    private VppDrSubsidyPredictionService subsidyPredictionService;
 
     @Override
     public CommonPage<VppDrEvent> pageEvent(Map<String, Object> params) {
@@ -935,7 +936,7 @@ public class VppDrServiceImpl implements VppDrService {
             item.setCompletionRate(p.getCompletionRate() != null
                     ? p.getCompletionRate()
                     : VppDrParticipationHelper.calcCompletionRate(
-                            p.getClearedCapacityKw(), p.getDeclaredCapacityKw()));
+                    p.getClearedCapacityKw(), p.getDeclaredCapacityKw()));
             item.setDeclaredAt(p.getDeclaredAt());
             VppCustomer customer = customerMap.get(p.getCustomerId());
             if (customer != null) {

+ 19 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DeviceTsdbImportResultVO.java

@@ -0,0 +1,19 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+/**
+ * TDengine CSV 导入结果
+ */
+@Data
+public class DeviceTsdbImportResultVO {
+
+    /** 设备 UUID */
+    private String deviceUuid;
+
+    /** TDengine 子表名(_{deviceUuid}) */
+    private String tableName;
+
+    /** 成功导入的数据行数 */
+    private Integer importedRows;
+}

+ 40 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrMonitorCurveVO.java

@@ -0,0 +1,40 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 响应监测 - 折线图(基线/实际负荷 + 申报/实际响应容量)
+ */
+@Data
+public class DrMonitorCurveVO {
+
+    private Long id;
+    private String eventId;
+    private String responseDate;
+    /** tsdb / mock / mixed */
+    private String dataSource;
+    private List<Long> siteIds = new ArrayList<>();
+    /** 申报容量合计 kW(邀约) */
+    private BigDecimal declaredCapacityKw = BigDecimal.ZERO;
+    /** 实际响应容量合计 kW(邀约) */
+    private BigDecimal actualResponseCapacityKw = BigDecimal.ZERO;
+    private List<Point> points = new ArrayList<>();
+
+    @Data
+    public static class Point {
+        /** 时刻 HH:mm */
+        private String time;
+        /** 基线负荷(预测)kW */
+        private BigDecimal baselineKw;
+        /** 实际负荷 kW */
+        private BigDecimal loadKw;
+        /** 申报容量 kW */
+        private BigDecimal declaredCapacityKw;
+        /** 实际响应容量 kW */
+        private BigDecimal actualResponseCapacityKw;
+    }
+}

+ 40 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrMonitorRecordVO.java

@@ -0,0 +1,40 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 响应监测 - 响应记录(按 vpp_dr_event 实际字段 + 完成率)
+ */
+@Data
+public class DrMonitorRecordVO {
+
+    private Long id;
+    private String eventId;
+    private String eventName;
+    private Integer responseType;
+    private Integer eventType;
+    private LocalDateTime startTime;
+    private LocalDateTime endTime;
+    private LocalDateTime declareDeadline;
+    private String inviteScope;
+    private BigDecimal targetCapacityKw;
+    private BigDecimal clearedCapacityKw;
+    private String remark;
+    private String attachmentUrl;
+    private BigDecimal subsidyPrice;
+    private Integer eventStatus;
+    private String rawPayload;
+    private Integer tenantId;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+    private String createdBy;
+    private String updatedBy;
+
+    /** 关联邀约完成率均值(0~1) */
+    private BigDecimal responseCompletionRate;
+    /** 是否达标:关联邀约 response_completion_rate = 100% */
+    private Boolean qualified;
+}

+ 21 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrMonitorSummaryVO.java

@@ -0,0 +1,21 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 响应监测 - 顶部 KPI(按可调峰资源点平均)
+ */
+@Data
+public class DrMonitorSummaryVO {
+
+    /** 可调峰资源点数(分母) */
+    private Long peakResourceCount = 0L;
+    /** 响应次数(执行中+已结束,按资源点平均) */
+    private Long responseCount = 0L;
+    /** 响应容量 kW(仅已结束,按资源点平均) */
+    private BigDecimal responseCapacityKw = BigDecimal.ZERO;
+    /** 历史达标率 0~1(已结束,按资源点平均) */
+    private BigDecimal historicalQualifiedRate = BigDecimal.ZERO;
+}