ソースを参照

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

fuyuchuan 1 日 前
コミット
55c32ea02c

+ 2 - 2
service-cdi/service-cdi-biz/src/main/resources/logback.xml

@@ -1,13 +1,13 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <?xml version="1.0" encoding="UTF-8"?>
 <configuration scan="true" scanPeriod="60 seconds" debug="false">
 <configuration scan="true" scanPeriod="60 seconds" debug="false">
     <!-- 日志存放路径 -->
     <!-- 日志存放路径 -->
-    <property name="log.path" value="/var/log/uskycloud/service-cdi" />
+    <property name="log.path" value="/var/log/uskycloud/service-vpp" />
     <!-- 日志输出格式 -->
     <!-- 日志输出格式 -->
     <property name="log.pattern" value="%d{MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{26}:%line: %msg%n" />
     <property name="log.pattern" value="%d{MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{26}:%line: %msg%n" />
     <!--    	<property name="log.pattern" value="%gray(%d{MM-dd HH:mm:ss.SSS}) %highlight(%-5level) &#45;&#45; [%gray(%thread)] %cyan(%logger{26}:%line): %msg%n" />-->
     <!--    	<property name="log.pattern" value="%gray(%d{MM-dd HH:mm:ss.SSS}) %highlight(%-5level) &#45;&#45; [%gray(%thread)] %cyan(%logger{26}:%line): %msg%n" />-->
 
 
 
 
-    <property name="SQL_PACKAGE" value="com.usky.cdi.mapper"/>
+    <property name="SQL_PACKAGE" value="com.usky.vpp.mapper"/>
 
 
     <!-- 控制台输出 -->
     <!-- 控制台输出 -->
     <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
     <appender name="console" class="ch.qos.logback.core.ConsoleAppender">

+ 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());
+    // }
+}

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

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

+ 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;
+}