浏览代码

Merge branch 'vpp-zyj' into feature/service-vpp-20260701

james 2 天之前
父节点
当前提交
0c08744061
共有 47 个文件被更改,包括 2247 次插入298 次删除
  1. 18 0
      service-job/src/main/java/com/ruoyi/job/task/RyTask.java
  2. 10 0
      service-vpp/service-vpp-api/src/main/java/com/usky/vpp/RemoteVppTaskService.java
  3. 20 2
      service-vpp/service-vpp-api/src/main/java/com/usky/vpp/factory/RemoteVppTaskFactory.java
  4. 31 14
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/client/VppUnHttpExecutor.java
  5. 50 30
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/client/VppUnTokenHolder.java
  6. 7 11
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/config/VppUnProperties.java
  7. 166 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/config/VppUnPropertiesRegistry.java
  8. 6 5
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/config/VppUnRestTemplateConfig.java
  9. 61 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/config/VppUnTenantContext.java
  10. 1 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/constant/VppDrEventStatus.java
  11. 27 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/api/ServiceVppTaskApi.java
  12. 104 34
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/crypto/VppUnCryptoService.java
  13. 232 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/crypto/VppUnSmCryptoUtil.java
  14. 85 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppTenantConfig.java
  15. 0 47
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/job/VppUnBootstrapRunner.java
  16. 0 36
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/job/VppUnPollScheduler.java
  17. 39 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/job/VppUnPropertiesLoader.java
  18. 10 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/mapper/VppTenantConfigMapper.java
  19. 3 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppUnIntegrationService.java
  20. 14 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppUnIntervalDataReportTaskService.java
  21. 14 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppUnPollTaskService.java
  22. 14 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppUnRegisterTaskService.java
  23. 13 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppUnReportService.java
  24. 16 2
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppBaselineServiceImpl.java
  25. 29 4
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrEventIngestServiceImpl.java
  26. 1 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrServiceImpl.java
  27. 1 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppSiteServiceImpl.java
  28. 37 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnDnServiceImpl.java
  29. 35 19
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnIntegrationServiceImpl.java
  30. 44 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnIntervalDataReportTaskServiceImpl.java
  31. 44 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnPollTaskServiceImpl.java
  32. 68 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnRegisterTaskServiceImpl.java
  33. 282 22
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnReportServiceImpl.java
  34. 1 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/SiteDeclaredCapacityVO.java
  35. 1 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/SiteListVO.java
  36. 20 4
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppAuditHelper.java
  37. 14 7
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppBaselineHelper.java
  38. 20 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppSiteResourceHelper.java
  39. 45 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppUnCipherPayloadHelper.java
  40. 196 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppUnIntervalDataHelper.java
  41. 106 48
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppUnMessageBuilder.java
  42. 170 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppUnMomentDataHelper.java
  43. 61 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppUnRegisterMetricDefinitions.java
  44. 98 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppUnRegisterReportIndex.java
  45. 19 5
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/web/advice/VppUnDnCryptoRequestAdvice.java
  46. 13 6
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/web/advice/VppUnDnCryptoResponseAdvice.java
  47. 1 0
      service-vpp/service-vpp-biz/src/main/resources/bootstrap.yml

+ 18 - 0
service-job/src/main/java/com/ruoyi/job/task/RyTask.java

@@ -151,4 +151,22 @@ public class RyTask {
         remoteVppTaskService.refreshSiteAvgCompletionRate();
 	}
 
+    // 运管平台 UN Poll 拉取事件/反馈
+    public void pollUn(Integer tenantId) {
+        System.out.println("租户:" + tenantId + " 运管平台 UN Poll 定时任务开始执行......");
+        remoteVppTaskService.pollUn(tenantId);
+    }
+
+    // 运管平台 UN Token 刷新与注册
+    public void registerUn(Integer tenantId) {
+        System.out.println("租户:" + tenantId + " 运管平台 UN 注册定时任务开始执行......");
+        remoteVppTaskService.registerUn(tenantId);
+    }
+
+    // 运管平台 UN 曲线数据报告(IntervalDataReport)
+    public void intervalDataReportUn(Integer tenantId) {
+        System.out.println("租户:" + tenantId + " 运管平台 UN 曲线数据报告定时任务开始执行......");
+        remoteVppTaskService.intervalDataReportUn(tenantId);
+    }
+
 }

+ 10 - 0
service-vpp/service-vpp-api/src/main/java/com/usky/vpp/RemoteVppTaskService.java

@@ -3,10 +3,20 @@ package com.usky.vpp;
 import com.usky.vpp.factory.RemoteVppTaskFactory;
 import org.springframework.cloud.openfeign.FeignClient;
 import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
 
 @FeignClient(contextId = "remoteVppTaskService", value = "service-vpp", fallbackFactory = RemoteVppTaskFactory.class)
 public interface RemoteVppTaskService {
 
     @GetMapping("/refreshSiteAvgCompletionRate")
     void refreshSiteAvgCompletionRate();
+
+    @GetMapping("/pollUn")
+    void pollUn(@RequestParam(value = "tenantId") Integer tenantId);
+
+    @GetMapping("/registerUn")
+    void registerUn(@RequestParam(value = "tenantId") Integer tenantId);
+
+    @GetMapping("/intervalDataReportUn")
+    void intervalDataReportUn(@RequestParam(value = "tenantId") Integer tenantId);
 }

+ 20 - 2
service-vpp/service-vpp-api/src/main/java/com/usky/vpp/factory/RemoteVppTaskFactory.java

@@ -15,8 +15,26 @@ public class RemoteVppTaskFactory implements FallbackFactory<RemoteVppTaskServic
     @Override
     public RemoteVppTaskService create(Throwable throwable) {
         log.error("VPP 定时任务服务调用失败: {}", throwable.getMessage());
-        return () -> {
-            throw new FeignBadRequestException(500, "定时刷新站点平均完成率异常" + throwable.getMessage());
+        return new RemoteVppTaskService() {
+            @Override
+            public void refreshSiteAvgCompletionRate() {
+                throw new FeignBadRequestException(500, "定时刷新站点平均完成率异常" + throwable.getMessage());
+            }
+
+            @Override
+            public void pollUn(Integer tenantId) {
+                throw new FeignBadRequestException(500, "定时 UN Poll 异常, tenantId=" + tenantId + throwable.getMessage());
+            }
+
+            @Override
+            public void registerUn(Integer tenantId) {
+                throw new FeignBadRequestException(500, "定时 UN 注册异常, tenantId=" + tenantId + throwable.getMessage());
+            }
+
+            @Override
+            public void intervalDataReportUn(Integer tenantId) {
+                throw new FeignBadRequestException(500, "定时 UN 曲线数据报告异常, tenantId=" + tenantId + throwable.getMessage());
+            }
         };
     }
 }

+ 31 - 14
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/client/VppUnHttpExecutor.java

@@ -3,7 +3,11 @@ package com.usky.vpp.client;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.usky.common.core.exception.BusinessException;
 import com.usky.vpp.config.VppUnProperties;
+import com.usky.vpp.config.VppUnPropertiesRegistry;
+import com.usky.vpp.config.VppUnTenantContext;
 import com.usky.vpp.crypto.VppUnCryptoService;
+import com.usky.vpp.crypto.VppUnSmCryptoUtil;
+import com.usky.vpp.util.VppUnCipherPayloadHelper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -30,8 +34,11 @@ public class VppUnHttpExecutor {
 
     private static final Logger log = LoggerFactory.getLogger(VppUnHttpExecutor.class);
 
+    /** Token 获取接口使用明文,不参与 SM2/SM3 加解密。 */
+    private static final String TOKEN_REQUEST = "TokenRequest";
+
     @Autowired
-    private VppUnProperties properties;
+    private VppUnPropertiesRegistry propertiesRegistry;
 
     @Autowired
     private VppUnCryptoService cryptoService;
@@ -62,6 +69,7 @@ public class VppUnHttpExecutor {
     }
 
     private String executePost(String serviceName, Map<String, Object> body, boolean withToken) {
+        VppUnProperties properties = propertiesRegistry.getCurrent();
         if (!properties.isOutboundActive()) {
             throw new BusinessException("运管平台 outbound 未启用或未配置 baseUrl");
         }
@@ -72,20 +80,23 @@ public class VppUnHttpExecutor {
             headers.setAccept(java.util.Collections.singletonList(MediaType.APPLICATION_JSON));
 
             String requestBody = plainJson;
-            if (cryptoService.isActive()) {
-                requestBody = cryptoService.encryptRequest(plainJson);
-                headers.set("X-Sign", cryptoService.signRequest(requestBody));
+            boolean useCrypto = useCrypto(serviceName);
+            if (useCrypto && cryptoService.isActive()) {
+                VppUnSmCryptoUtil.EncryptedPayload payload = cryptoService.prepareRequest(plainJson);
+                requestBody = VppUnCipherPayloadHelper.wrapCipherPayload(payload.getCipherBase64(), objectMapper);
+                headers.set("X-Sign", payload.getSignBase64());
             }
             if (withToken) {
-                headers.set(properties.getTokenHeader(), properties.getTokenPrefix() + tokenHolder.getToken());
+                headers.set("token", tokenHolder.getToken());
             }
 
             String url = normalizeUrl(properties.getBaseUrl()) + "/" + serviceName;
-            log.debug("UN 请求 {} crypto={} token={}", url, cryptoService.isActive(), withToken);
+            log.debug("租户 {} UN 请求 {} crypto={} token={}",
+                    VppUnTenantContext.resolveTenantId(), url, useCrypto && cryptoService.isActive(), withToken);
 
             ResponseEntity<String> response = restTemplate.exchange(
                     url, HttpMethod.POST, new HttpEntity<>(requestBody, headers), String.class);
-            return decodeResponseBody(response.getBody(), response.getHeaders().getFirst("X-Sign"));
+            return decodeResponseBody(response.getBody(), response.getHeaders().getFirst("X-Sign"), useCrypto);
         } catch (HttpStatusCodeException ex) {
             String message = ex.getResponseBodyAsString();
             log.warn("UN 请求 {} 失败 status={} body={}", serviceName, ex.getRawStatusCode(), message);
@@ -114,18 +125,24 @@ public class VppUnHttpExecutor {
                 || message.contains("JWTDecodeException"));
     }
 
-    private String decodeResponseBody(String body, String signHeader) {
+    private String decodeResponseBody(String body, String signHeader, boolean useCrypto) {
         if (!StringUtils.hasText(body)) {
             return "{}";
         }
         String trimmed = body.trim();
-        if (cryptoService.isActive() && !trimmed.startsWith("{")) {
-            if (!cryptoService.verifyResponse(trimmed, signHeader)) {
-                throw new BusinessException("UN 响应验签失败");
-            }
-            return cryptoService.decryptResponse(trimmed);
+        if (!useCrypto || !cryptoService.isActive()) {
+            return trimmed;
+        }
+        if (trimmed.startsWith("{")) {
+            log.warn("UN 响应为明文 JSON,跳过验签解密");
+            return trimmed;
         }
-        return trimmed;
+        String cipherBase64 = VppUnCipherPayloadHelper.extractCipherPayload(trimmed, objectMapper);
+        return cryptoService.parseResponse(cipherBase64, signHeader);
+    }
+
+    private static boolean useCrypto(String serviceName) {
+        return !TOKEN_REQUEST.equals(serviceName);
     }
 
     private static String normalizeUrl(String baseUrl) {

+ 50 - 30
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/client/VppUnTokenHolder.java

@@ -3,6 +3,8 @@ package com.usky.vpp.client;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.usky.vpp.config.VppUnProperties;
+import com.usky.vpp.config.VppUnPropertiesRegistry;
+import com.usky.vpp.config.VppUnTenantContext;
 import com.usky.vpp.util.VppUnMessageBuilder;
 import com.usky.vpp.util.VppUnPayloadHelper;
 import org.slf4j.Logger;
@@ -13,20 +15,22 @@ import org.springframework.util.StringUtils;
 
 import java.time.Instant;
 import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.locks.ReentrantLock;
 
 /**
- * UN Token 缓存(TokenRequest 获取,默认约 30 分钟过期)
+ * UN Token 缓存(按租户维度,TokenRequest 获取,默认约 30 分钟过期)
  */
 @Component
 public class VppUnTokenHolder {
 
     private static final Logger log = LoggerFactory.getLogger(VppUnTokenHolder.class);
 
-    private final ReentrantLock lock = new ReentrantLock();
+    private final ConcurrentHashMap<Integer, TokenState> tokenStates = new ConcurrentHashMap<>();
+    private final ConcurrentHashMap<Integer, ReentrantLock> locks = new ConcurrentHashMap<>();
 
     @Autowired
-    private VppUnProperties properties;
+    private VppUnPropertiesRegistry propertiesRegistry;
 
     @Autowired
     private VppUnHttpExecutor httpExecutor;
@@ -34,55 +38,61 @@ public class VppUnTokenHolder {
     @Autowired
     private ObjectMapper objectMapper;
 
-    private volatile String token;
-    private volatile Instant expiresAt = Instant.EPOCH;
-
     public String getToken() {
-        if (!needsRefresh()) {
-            return token;
+        Integer tenantId = VppUnTenantContext.resolveTenantId();
+        TokenState state = tokenStates.computeIfAbsent(tenantId, id -> createInitialState(id));
+        if (!state.needsRefresh()) {
+            return state.token;
         }
+        ReentrantLock lock = locks.computeIfAbsent(tenantId, id -> new ReentrantLock());
         lock.lock();
         try {
-            if (!needsRefresh()) {
-                return token;
+            state = tokenStates.computeIfAbsent(tenantId, id -> createInitialState(id));
+            if (!state.needsRefresh()) {
+                return state.token;
             }
-            refreshTokenInternal();
-            return token;
+            refreshTokenInternal(tenantId);
+            return state.token;
         } finally {
             lock.unlock();
         }
     }
 
     public void invalidate() {
+        Integer tenantId = VppUnTenantContext.resolveTenantId();
+        ReentrantLock lock = locks.computeIfAbsent(tenantId, id -> new ReentrantLock());
         lock.lock();
         try {
-            token = null;
-            expiresAt = Instant.EPOCH;
+            tokenStates.remove(tenantId);
         } finally {
             lock.unlock();
         }
     }
 
     public void applyTokenResponse(Map<String, Object> response) {
+        Integer tenantId = VppUnTenantContext.resolveTenantId();
         String newToken = VppUnPayloadHelper.getString(response, "token");
         if (!StringUtils.hasText(newToken)) {
             throw new IllegalStateException("TokenResponse 缺少 token");
         }
-        lock.lock();
-        try {
-            token = newToken;
-            int ttlMinutes = properties.getTokenTtlMinutes() != null ? properties.getTokenTtlMinutes() : 25;
-            expiresAt = Instant.now().plusSeconds(ttlMinutes * 60L);
-        } finally {
-            lock.unlock();
-        }
+        VppUnProperties properties = propertiesRegistry.require(tenantId);
+        int ttlMinutes = properties.getTokenTtlMinutes() != null ? properties.getTokenTtlMinutes() : 25;
+        TokenState state = new TokenState(newToken, Instant.now().plusSeconds(ttlMinutes * 60L));
+        tokenStates.put(tenantId, state);
+        propertiesRegistry.updateToken(tenantId, newToken);
     }
 
-    private boolean needsRefresh() {
-        return !StringUtils.hasText(token) || Instant.now().isAfter(expiresAt);
+    private TokenState createInitialState(Integer tenantId) {
+        VppUnProperties properties = propertiesRegistry.get(tenantId);
+        if (properties != null && StringUtils.hasText(properties.getToken())) {
+            int ttlMinutes = properties.getTokenTtlMinutes() != null ? properties.getTokenTtlMinutes() : 25;
+            return new TokenState(properties.getToken(), Instant.now().plusSeconds(ttlMinutes * 60L));
+        }
+        return new TokenState(null, Instant.EPOCH);
     }
 
-    private void refreshTokenInternal() {
+    private void refreshTokenInternal(Integer tenantId) {
+        VppUnProperties properties = propertiesRegistry.require(tenantId);
         Map<String, Object> request = VppUnMessageBuilder.buildTokenRequest(properties);
         String responseBody = httpExecutor.postRaw("TokenRequest", request, false);
         try {
@@ -92,16 +102,26 @@ public class VppUnTokenHolder {
             if (code != null && code != 200) {
                 throw new IllegalStateException("TokenRequest 失败: " + response.get("description"));
             }
-            String newToken = VppUnPayloadHelper.getString(response, "token");
-            if (!StringUtils.hasText(newToken)) {
-                throw new IllegalStateException("TokenResponse 缺少 token");
-            }
             applyTokenResponse(response);
-            log.info("UN Token 已刷新");
+            log.info("租户 {} UN Token 已刷新", tenantId);
         } catch (IllegalStateException ex) {
             throw ex;
         } catch (Exception ex) {
             throw new IllegalStateException("TokenResponse 解析失败", ex);
         }
     }
+
+    private static final class TokenState {
+        private final String token;
+        private final Instant expiresAt;
+
+        private TokenState(String token, Instant expiresAt) {
+            this.token = token;
+            this.expiresAt = expiresAt;
+        }
+
+        private boolean needsRefresh() {
+            return !StringUtils.hasText(token) || Instant.now().isAfter(expiresAt);
+        }
+    }
 }

+ 7 - 11
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/config/VppUnProperties.java

@@ -1,17 +1,16 @@
 package com.usky.vpp.config;
 
 import lombok.Data;
-import org.springframework.boot.context.properties.ConfigurationProperties;
-import org.springframework.stereotype.Component;
 
 /**
- * 运管平台 UN/DN 对接配置
+ * 运管平台 UN/DN 对接配置(按租户维度,由 vpp_tenant_config 加载至内存)
  */
 @Data
-@Component
-@ConfigurationProperties(prefix = "vpp.un")
 public class VppUnProperties {
 
+    /** 租户 ID */
+    private Integer tenantId;
+
     /** 运管平台 UN 基地址 */
     private String baseUrl;
 
@@ -33,6 +32,9 @@ public class VppUnProperties {
     /** DN 私钥 Base64 */
     private String dnPrivateKey;
 
+    /** 数据库持久化的 token */
+    private String token;
+
     /** Poll 轮询间隔秒,默认 10 */
     private Integer pollIntervalSec = 10;
 
@@ -51,12 +53,6 @@ public class VppUnProperties {
     /** 已保存的 registrationID(首次注册后需持久化) */
     private String registrationId;
 
-    /** Token 请求头,默认 Authorization */
-    private String tokenHeader = "Authorization";
-
-    /** Token 前缀,默认 Bearer  */
-    private String tokenPrefix = "Bearer ";
-
     /** Token 有效期分钟(文档默认 30),用于本地缓存刷新 */
     private Integer tokenTtlMinutes = 25;
 

+ 166 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/config/VppUnPropertiesRegistry.java

@@ -0,0 +1,166 @@
+package com.usky.vpp.config;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.usky.common.core.exception.BusinessException;
+import com.usky.vpp.domain.VppRegistration;
+import com.usky.vpp.domain.VppTenantConfig;
+import com.usky.vpp.mapper.VppRegistrationMapper;
+import com.usky.vpp.mapper.VppTenantConfigMapper;
+import com.usky.vpp.util.VppAuditHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 租户 UN 配置全局内存缓存,key=tenantId,value=VppUnProperties
+ */
+@Component
+public class VppUnPropertiesRegistry {
+
+    private static final Logger log = LoggerFactory.getLogger(VppUnPropertiesRegistry.class);
+
+    private final ConcurrentHashMap<Integer, VppUnProperties> cache = new ConcurrentHashMap<>();
+    private final ConcurrentHashMap<String, Integer> dnIdToTenantId = new ConcurrentHashMap<>();
+
+    @Autowired
+    private VppTenantConfigMapper tenantConfigMapper;
+
+    @Autowired
+    private VppRegistrationMapper registrationMapper;
+
+    public void refreshAll() {
+        List<VppTenantConfig> configs = tenantConfigMapper.selectList(new LambdaQueryWrapper<>());
+        if (CollectionUtils.isEmpty(configs)) {
+            cache.clear();
+            dnIdToTenantId.clear();
+            log.warn("vpp_tenant_config 无配置记录,UN 对接配置缓存为空");
+            return;
+        }
+
+        Map<Integer, VppUnProperties> nextCache = new ConcurrentHashMap<>();
+        Map<String, Integer> nextDnIndex = new ConcurrentHashMap<>();
+        for (VppTenantConfig config : configs) {
+            if (config.getTenantId() == null) {
+                continue;
+            }
+            VppUnProperties properties = toProperties(config);
+            enrichRegistrationId(properties);
+            nextCache.put(config.getTenantId(), properties);
+            if (StringUtils.hasText(config.getDnId())) {
+                nextDnIndex.put(config.getDnId(), config.getTenantId());
+            }
+        }
+        cache.clear();
+        cache.putAll(nextCache);
+        dnIdToTenantId.clear();
+        dnIdToTenantId.putAll(nextDnIndex);
+        log.info("已加载 {} 条租户 UN 配置至内存", cache.size());
+    }
+
+    public VppUnProperties get(Integer tenantId) {
+        if (tenantId == null) {
+            return null;
+        }
+        return cache.get(tenantId);
+    }
+
+    public VppUnProperties require(Integer tenantId) {
+        VppUnProperties properties = get(tenantId);
+        if (properties == null) {
+            throw new BusinessException("租户 " + tenantId + " 未配置运管平台 UN 对接信息");
+        }
+        return properties;
+    }
+
+    public VppUnProperties getCurrent() {
+        // 定时任务:runWithTenant 传入的 tenantId;接口调用:SecurityUtils.getTenantId()
+        return require(VppUnTenantContext.resolveTenantId());
+    }
+
+    public VppUnProperties getByDnId(String dnId) {
+        if (!StringUtils.hasText(dnId)) {
+            return null;
+        }
+        Integer tenantId = dnIdToTenantId.get(dnId);
+        return tenantId != null ? get(tenantId) : null;
+    }
+
+    public Collection<VppUnProperties> getAll() {
+        return Collections.unmodifiableCollection(cache.values());
+    }
+
+    public boolean hasCryptoEnabledTenant() {
+        return cache.values().stream().anyMatch(this::isCryptoActive);
+    }
+
+    private boolean isCryptoActive(VppUnProperties properties) {
+        return Boolean.TRUE.equals(properties.getCryptoEnabled())
+                && StringUtils.hasText(properties.getUnPublicKey())
+                && StringUtils.hasText(properties.getDnPrivateKey());
+    }
+
+    public void updateRegistrationId(Integer tenantId, String registrationId) {
+        VppUnProperties properties = get(tenantId);
+        if (properties != null) {
+            properties.setRegistrationId(registrationId);
+        }
+    }
+
+    public void updateToken(Integer tenantId, String token) {
+        VppUnProperties properties = get(tenantId);
+        if (properties != null) {
+            properties.setToken(token);
+        }
+    }
+
+    private VppUnProperties toProperties(VppTenantConfig config) {
+        VppUnProperties properties = new VppUnProperties();
+        properties.setTenantId(config.getTenantId());
+        properties.setDnId(config.getDnId());
+        properties.setDnName(config.getDnName());
+        properties.setBaseUrl(config.getBaseUrl());
+        properties.setTransportAddress(config.getTransportAddress());
+        properties.setUnPublicKey(config.getUnPublicKey());
+        properties.setDnPublicKey(config.getDnPublicKey());
+        properties.setDnPrivateKey(config.getDnPrivateKey());
+        properties.setToken(config.getToken());
+        properties.setPollIntervalSec(config.getPollIntervalSec() != null ? config.getPollIntervalSec() : 10);
+        properties.setOutboundEnabled(config.getOutboundEnabled());
+        properties.setPollEnabled(config.getPollEnabled());
+        properties.setCryptoEnabled(config.getCryptoEnabled());
+        properties.setPriceDownCoeff(StringUtils.hasText(config.getPriceDownCoeff()) ? config.getPriceDownCoeff() : "0.8");
+        properties.setAutoAckClearing(config.getAutoAckClearing());
+        properties.setAutoRegisterOnStartup(config.getAutoRegisterOnStartup());
+        properties.setTokenTtlMinutes(25);
+        properties.setConnectTimeoutMs(10000);
+        properties.setReadTimeoutMs(30000);
+        return properties;
+    }
+
+    private void enrichRegistrationId(VppUnProperties properties) {
+        if (!StringUtils.hasText(properties.getDnId())) {
+            return;
+        }
+        LambdaQueryWrapper<VppRegistration> wrapper = new LambdaQueryWrapper<VppRegistration>()
+                .eq(VppRegistration::getDnId, properties.getDnId())
+                .eq(VppRegistration::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .orderByDesc(VppRegistration::getRegisteredAt)
+                .last("LIMIT 1");
+        if (properties.getTenantId() != null) {
+            wrapper.eq(VppRegistration::getTenantId, properties.getTenantId());
+        }
+        VppRegistration registration = registrationMapper.selectOne(wrapper);
+        if (registration != null && StringUtils.hasText(registration.getRegistrationId())) {
+            properties.setRegistrationId(registration.getRegistrationId());
+        }
+    }
+}

+ 6 - 5
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/config/VppUnRestTemplateConfig.java

@@ -10,13 +10,14 @@ import java.time.Duration;
 @Configuration
 public class VppUnRestTemplateConfig {
 
+    private static final int DEFAULT_CONNECT_TIMEOUT_MS = 10000;
+    private static final int DEFAULT_READ_TIMEOUT_MS = 30000;
+
     @Bean
-    public RestTemplate vppUnRestTemplate(VppUnProperties properties, RestTemplateBuilder builder) {
-        int connectMs = properties.getConnectTimeoutMs() != null ? properties.getConnectTimeoutMs() : 10000;
-        int readMs = properties.getReadTimeoutMs() != null ? properties.getReadTimeoutMs() : 30000;
+    public RestTemplate vppUnRestTemplate(RestTemplateBuilder builder) {
         return builder
-                .setConnectTimeout(Duration.ofMillis(connectMs))
-                .setReadTimeout(Duration.ofMillis(readMs))
+                .setConnectTimeout(Duration.ofMillis(DEFAULT_CONNECT_TIMEOUT_MS))
+                .setReadTimeout(Duration.ofMillis(DEFAULT_READ_TIMEOUT_MS))
                 .build();
     }
 }

+ 61 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/config/VppUnTenantContext.java

@@ -0,0 +1,61 @@
+package com.usky.vpp.config;
+
+import com.usky.common.core.exception.BusinessException;
+import com.usky.common.security.utils.SecurityUtils;
+
+/**
+ * 当前线程 UN 对接所使用的租户上下文
+ */
+public final class VppUnTenantContext {
+
+    private static final ThreadLocal<Integer> TENANT_ID = new ThreadLocal<>();
+
+    private VppUnTenantContext() {
+    }
+
+    public static void setTenantId(Integer tenantId) {
+        TENANT_ID.set(tenantId);
+    }
+
+    public static Integer getTenantId() {
+        return TENANT_ID.get();
+    }
+
+    /**
+     * 解析当前租户 ID:定时任务等场景优先使用 {@link #runWithTenant} 传入的 tenantId;
+     * 接口调用场景使用 {@link SecurityUtils#getTenantId()}。
+     */
+    public static Integer resolveTenantId() {
+        Integer tenantId = TENANT_ID.get();
+        if (tenantId != null) {
+            return tenantId;
+        }
+        tenantId = SecurityUtils.getTenantId();
+        if (tenantId != null && tenantId > 0) {
+            return tenantId;
+        }
+        throw new BusinessException("缺少租户上下文,无法获取运管平台配置租户Id");
+    }
+
+    public static void clear() {
+        TENANT_ID.remove();
+    }
+
+    public static void runWithTenant(Integer tenantId, Runnable action) {
+        setTenantId(tenantId);
+        try {
+            action.run();
+        } finally {
+            clear();
+        }
+    }
+
+    public static <T> T callWithTenant(Integer tenantId, java.util.function.Supplier<T> action) {
+        setTenantId(tenantId);
+        try {
+            return action.get();
+        } finally {
+            clear();
+        }
+    }
+}

+ 1 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/constant/VppDrEventStatus.java

@@ -12,6 +12,7 @@ public final class VppDrEventStatus {
     public static final int EXECUTING = 3;
     public static final int ENDED = 4;
     public static final int CANCELLED = 5;
+    public static final int LAST_PLACE_SPLITTING = 6;
 
     private VppDrEventStatus() {
     }

+ 27 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/api/ServiceVppTaskApi.java

@@ -2,6 +2,9 @@ package com.usky.vpp.controller.api;
 
 import com.usky.vpp.RemoteVppTaskService;
 import com.usky.vpp.service.VppSiteCompletionRateTaskService;
+import com.usky.vpp.service.VppUnIntervalDataReportTaskService;
+import com.usky.vpp.service.VppUnPollTaskService;
+import com.usky.vpp.service.VppUnRegisterTaskService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.RestController;
 
@@ -11,8 +14,32 @@ public class ServiceVppTaskApi implements RemoteVppTaskService {
     @Autowired
     private VppSiteCompletionRateTaskService siteCompletionRateTaskService;
 
+    @Autowired
+    private VppUnPollTaskService unPollTaskService;
+
+    @Autowired
+    private VppUnRegisterTaskService unRegisterTaskService;
+
+    @Autowired
+    private VppUnIntervalDataReportTaskService unIntervalDataReportTaskService;
+
     @Override
     public void refreshSiteAvgCompletionRate() {
         siteCompletionRateTaskService.refreshSiteAvgCompletionRate();
     }
+
+    @Override
+    public void pollUn(Integer tenantId) {
+        unPollTaskService.pollUn(tenantId);
+    }
+
+    @Override
+    public void registerUn(Integer tenantId) {
+        unRegisterTaskService.registerUn(tenantId);
+    }
+
+    @Override
+    public void intervalDataReportUn(Integer tenantId) {
+        unIntervalDataReportTaskService.intervalDataReportUn(tenantId);
+    }
 }

+ 104 - 34
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/crypto/VppUnCryptoService.java

@@ -1,20 +1,19 @@
 package com.usky.vpp.crypto;
 
-import cn.hutool.core.codec.Base64;
-import cn.hutool.crypto.SmUtil;
-import cn.hutool.crypto.asymmetric.KeyType;
-import cn.hutool.crypto.asymmetric.SM2;
+import com.usky.common.core.exception.BusinessException;
 import com.usky.vpp.config.VppUnProperties;
+import com.usky.vpp.config.VppUnPropertiesRegistry;
+import com.usky.vpp.config.VppUnTenantContext;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.util.StringUtils;
 
-import java.nio.charset.StandardCharsets;
+import java.util.Base64;
 
 /**
- * 运管平台 UN/DN 国密加解密与签名(SM2/SM3withSM2)
+ * 运管平台 UN/DN 加解密门面(除 TokenRequest 外均启用)。
  */
 @Service
 public class VppUnCryptoService {
@@ -22,56 +21,127 @@ public class VppUnCryptoService {
     private static final Logger log = LoggerFactory.getLogger(VppUnCryptoService.class);
 
     @Autowired
-    private VppUnProperties properties;
+    private VppUnPropertiesRegistry propertiesRegistry;
 
     public boolean isActive() {
-        return Boolean.TRUE.equals(properties.getCryptoEnabled())
-                && StringUtils.hasText(properties.getUnPublicKey())
-                && StringUtils.hasText(properties.getDnPrivateKey());
+        return isCryptoActive(currentProperties());
     }
 
-    public String encryptRequest(String plainJson) {
-        SM2 sm2 = SmUtil.sm2(null, properties.getUnPublicKey());
-        return sm2.encryptBase64(plainJson, KeyType.PublicKey);
+    public boolean isAnyActive() {
+        return propertiesRegistry.hasCryptoEnabledTenant();
     }
 
-    public String signRequest(String cipherBase64) {
-        SM2 sm2 = SmUtil.sm2(properties.getDnPrivateKey(), null);
-        byte[] sign = sm2.sign(cipherBase64.getBytes(StandardCharsets.UTF_8));
-        return Base64.encode(sign);
+    /** DN 主动请求 UN:UN 公钥加密 + DN 私钥对密文字节签名。 */
+    public VppUnSmCryptoUtil.EncryptedPayload prepareRequest(String plainJson) {
+        VppUnProperties properties = requireCryptoProperties();
+        try {
+            return VppUnSmCryptoUtil.encryptAndSignRequest(
+                    plainJson, properties.getUnPublicKey(), properties.getDnPrivateKey());
+        } catch (Exception ex) {
+            throw new BusinessException("UN 请求加密签名失败: " + ex.getMessage());
+        }
     }
 
-    public String decryptResponse(String cipherBase64) {
-        SM2 sm2 = SmUtil.sm2(properties.getDnPrivateKey(), properties.getDnPublicKey());
-        return sm2.decryptStr(cipherBase64, KeyType.PrivateKey);
+    /** DN 解析 UN 响应:UN 公钥验签 + DN 私钥解密。 */
+    public String parseResponse(String cipherBase64, String signBase64) {
+        if (!StringUtils.hasText(signBase64)) {
+            log.warn("UN 响应缺少 X-Sign");
+            throw new BusinessException("UN 响应验签失败");
+        }
+        VppUnProperties properties = requireCryptoProperties();
+        try {
+            return VppUnSmCryptoUtil.verifyAndDecryptResponse(
+                    cipherBase64, signBase64, properties.getUnPublicKey(), properties.getDnPrivateKey());
+        } catch (BusinessException ex) {
+            throw ex;
+        } catch (Exception ex) {
+            if (ex.getMessage() != null && ex.getMessage().toLowerCase().contains("sign verify")) {
+                throw new BusinessException("UN 响应验签失败");
+            }
+            throw new BusinessException("UN 响应解密失败: " + ex.getMessage());
+        }
     }
 
-    public boolean verifyResponse(String cipherBase64, String signBase64) {
-        return verifyInbound(cipherBase64, signBase64);
+    /** UN 被动调用 DN:验签后解密。 */
+    public String decryptInboundWithAutoTenant(String cipherBase64, String signBase64) {
+        Integer tenantId = VppUnTenantContext.getTenantId();
+        if (tenantId != null) {
+            assertInboundVerified(cipherBase64, signBase64);
+            return decryptInbound(cipherBase64);
+        }
+        for (VppUnProperties properties : propertiesRegistry.getAll()) {
+            if (!isCryptoActive(properties)) {
+                continue;
+            }
+            VppUnTenantContext.setTenantId(properties.getTenantId());
+            try {
+                if (verifyInbound(cipherBase64, signBase64)) {
+                    return decryptInbound(cipherBase64);
+                }
+            } finally {
+                VppUnTenantContext.clear();
+            }
+        }
+        throw new BusinessException("UN 请求验签失败");
     }
 
-    /** UN→DN 请求验签 */
+    /** UN→DN 请求验签(UN 公钥,对密文字节验签)。 */
     public boolean verifyInbound(String cipherBase64, String signBase64) {
         if (!StringUtils.hasText(signBase64)) {
-            log.warn("UN 请求缺少 X-Sign,跳过验签");
+            log.warn("UN 请求缺少 X-Sign");
+            return false;
+        }
+        VppUnProperties properties = currentProperties();
+        if (!isCryptoActive(properties)) {
             return true;
         }
-        SM2 sm2 = SmUtil.sm2(null, properties.getUnPublicKey());
-        return sm2.verify(cipherBase64.getBytes(StandardCharsets.UTF_8), Base64.decode(signBase64));
+        try {
+            byte[] cipherBytes = Base64.getDecoder().decode(cipherBase64.trim());
+            return VppUnSmCryptoUtil.verifyCipherBytes(
+                    cipherBytes, signBase64, properties.getUnPublicKey());
+        } catch (Exception ex) {
+            log.warn("UN 请求验签异常: {}", ex.getMessage());
+            return false;
+        }
     }
 
-    /** UN→DN 请求解密 */
+    /** UN→DN 请求解密(DN 私钥)。 */
     public String decryptInbound(String cipherBase64) {
-        return decryptResponse(cipherBase64);
+        VppUnProperties properties = requireCryptoProperties();
+        try {
+            return VppUnSmCryptoUtil.decryptByDnPrivateKey(cipherBase64, properties.getDnPrivateKey());
+        } catch (Exception ex) {
+            throw new BusinessException("UN 请求解密失败: " + ex.getMessage());
+        }
     }
 
-    /** DN→UN 响应加密 */
-    public String encryptOutbound(String plainJson) {
-        return encryptRequest(plainJson);
+    /** DN→UN 被动响应。 */
+    public VppUnSmCryptoUtil.EncryptedPayload prepareOutbound(String plainJson) {
+        return prepareRequest(plainJson);
     }
 
-    /** DN→UN 响应签名 */
-    public String signOutbound(String cipherBase64) {
-        return signRequest(cipherBase64);
+    private void assertInboundVerified(String cipherBase64, String signBase64) {
+        if (!verifyInbound(cipherBase64, signBase64)) {
+            throw new BusinessException("UN 请求验签失败");
+        }
+    }
+
+    private VppUnProperties requireCryptoProperties() {
+        VppUnProperties properties = currentProperties();
+        if (!isCryptoActive(properties)) {
+            throw new BusinessException("运管平台国密加解密未启用或密钥未配置");
+        }
+        return properties;
+    }
+
+    private VppUnProperties currentProperties() {
+        return propertiesRegistry.getCurrent();
+    }
+
+    private boolean isCryptoActive(VppUnProperties properties) {
+        return properties != null
+                && Boolean.TRUE.equals(properties.getCryptoEnabled())
+                && StringUtils.hasText(properties.getUnPublicKey())
+                && StringUtils.hasText(properties.getDnPrivateKey());
     }
 }

+ 232 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/crypto/VppUnSmCryptoUtil.java

@@ -0,0 +1,232 @@
+package com.usky.vpp.crypto;
+
+import org.bouncycastle.crypto.engines.SM2Engine;
+import org.bouncycastle.crypto.params.ECPrivateKeyParameters;
+import org.bouncycastle.crypto.params.ECPublicKeyParameters;
+import org.bouncycastle.crypto.params.ParametersWithRandom;
+import org.bouncycastle.jcajce.provider.asymmetric.util.ECUtil;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+
+import cn.hutool.crypto.SmUtil;
+import cn.hutool.crypto.asymmetric.KeyType;
+import cn.hutool.crypto.asymmetric.SM2;
+
+import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.SecureRandom;
+import java.security.Security;
+import java.security.Signature;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Base64;
+
+import javax.crypto.Cipher;
+
+/**
+ * 运管平台 UN/DN 国密工具(SM2 加解密 + SM3withSM2 签名/验签)。
+ * <p>加解密优先使用 BC {@code Cipher("SM2")}(与联调 SM3Util 一致,C1C2C3);签名/验签针对密文字节。</p>
+ */
+public final class VppUnSmCryptoUtil {
+
+    private static final String EC = "EC";
+    private static final String BC = BouncyCastleProvider.PROVIDER_NAME;
+    private static final String SM2 = "SM2";
+    private static final String SIGNATURE_ALGORITHM = "SM3withSM2";
+
+    static {
+        if (Security.getProvider(BC) == null) {
+            Security.addProvider(new BouncyCastleProvider());
+        }
+    }
+
+    private VppUnSmCryptoUtil() {
+    }
+
+    public static final class EncryptedPayload {
+        private final String cipherBase64;
+        private final String signBase64;
+
+        public EncryptedPayload(String cipherBase64, String signBase64) {
+            this.cipherBase64 = cipherBase64;
+            this.signBase64 = signBase64;
+        }
+
+        public String getCipherBase64() {
+            return cipherBase64;
+        }
+
+        public String getSignBase64() {
+            return signBase64;
+        }
+    }
+
+    /** DN 请求 UN:UN 公钥加密 + DN 私钥对密文字节签名。 */
+    public static EncryptedPayload encryptAndSignRequest(String plainJson,
+                                                         String unPublicKeyBase64,
+                                                         String dnPrivateKeyBase64) throws Exception {
+        byte[] cipherBytes = encryptByPublicKey(plainJson, unPublicKeyBase64);
+        String cipherBase64 = Base64.getEncoder().encodeToString(cipherBytes);
+        String signBase64 = signCipherBytes(cipherBytes, dnPrivateKeyBase64);
+        return new EncryptedPayload(cipherBase64, signBase64);
+    }
+
+    /** DN 解析 UN 响应:UN 公钥验签密文字节 + DN 私钥解密。 */
+    public static String verifyAndDecryptResponse(String cipherBase64,
+                                                  String signBase64,
+                                                  String unPublicKeyBase64,
+                                                  String dnPrivateKeyBase64) throws Exception {
+        byte[] cipherBytes = decodeBase64(cipherBase64);
+        if (!verifyCipherBytes(cipherBytes, signBase64, unPublicKeyBase64)) {
+            throw new IllegalStateException("sign verify fail");
+        }
+        return new String(decryptByPrivateKey(cipherBytes, dnPrivateKeyBase64), StandardCharsets.UTF_8);
+    }
+
+    /** 仅 SM2 解密(已验签场景)。 */
+    public static String decryptByDnPrivateKey(String cipherBase64, String dnPrivateKeyBase64) throws Exception {
+        byte[] cipherBytes = decodeBase64(cipherBase64);
+        return new String(decryptByPrivateKey(cipherBytes, dnPrivateKeyBase64), StandardCharsets.UTF_8);
+    }
+
+    public static String signCipherBytes(byte[] cipherBytes, String privateKeyBase64) throws Exception {
+        return sign(cipherBytes, privateKeyBase64);
+    }
+
+    public static boolean verifyCipherBytes(byte[] cipherBytes, String signBase64, String publicKeyBase64)
+            throws Exception {
+        return verify(cipherBytes, publicKeyBase64, signBase64);
+    }
+
+    private static String sign(byte[] data, String privateKeyBase64) throws Exception {
+        try {
+            PrivateKey privateKey = loadPrivateKey(privateKeyBase64);
+            Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM, BC);
+            signature.initSign(privateKey);
+            signature.update(data);
+            return Base64.getEncoder().encodeToString(signature.sign());
+        } catch (Exception ex) {
+            SM2 sm2 = SmUtil.sm2(privateKeyBase64.trim(), null);
+            return Base64.getEncoder().encodeToString(sm2.sign(data));
+        }
+    }
+
+    private static boolean verify(byte[] data, String publicKeyBase64, String signBase64) throws Exception {
+        try {
+            PublicKey publicKey = loadPublicKey(publicKeyBase64);
+            Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM, BC);
+            signature.initVerify(publicKey);
+            signature.update(data);
+            return signature.verify(decodeBase64(signBase64));
+        } catch (Exception ex) {
+            SM2 sm2 = SmUtil.sm2(null, publicKeyBase64.trim());
+            return sm2.verify(data, decodeBase64(signBase64));
+        }
+    }
+
+    /** 与 SM3Util.encryptByPublicKey 一致:BC Cipher SM2。 */
+    private static byte[] encryptByPublicKey(String data, String publicKeyBase64) throws Exception {
+        Exception last = null;
+        try {
+            return encryptByPublicKeyCipher(data, publicKeyBase64);
+        } catch (Exception ex) {
+            last = ex;
+        }
+        try {
+            return encryptByPublicKeyEngine(data, publicKeyBase64, SM2Engine.Mode.C1C2C3);
+        } catch (Exception ex) {
+            last = ex;
+        }
+        try {
+            return encryptByPublicKeyEngine(data, publicKeyBase64, SM2Engine.Mode.C1C3C2);
+        } catch (Exception ex) {
+            last = ex;
+        }
+        try {
+            SM2 sm2 = SmUtil.sm2(null, publicKeyBase64.trim());
+            return sm2.encrypt(data.getBytes(StandardCharsets.UTF_8), KeyType.PublicKey);
+        } catch (Exception ex) {
+            last = ex;
+        }
+        throw last != null ? last : new IllegalStateException("SM2 加密失败");
+    }
+
+    /** 与 SM3Util.decryptByPrivateKey 一致,并兼容 C1C3C2 密文。 */
+    private static byte[] decryptByPrivateKey(byte[] cipherBytes, String privateKeyBase64) throws Exception {
+        Exception last = null;
+        try {
+            return decryptByPrivateKeyCipher(cipherBytes, privateKeyBase64);
+        } catch (Exception ex) {
+            last = ex;
+        }
+        try {
+            return decryptByPrivateKeyEngine(cipherBytes, privateKeyBase64, SM2Engine.Mode.C1C2C3);
+        } catch (Exception ex) {
+            last = ex;
+        }
+        try {
+            return decryptByPrivateKeyEngine(cipherBytes, privateKeyBase64, SM2Engine.Mode.C1C3C2);
+        } catch (Exception ex) {
+            last = ex;
+        }
+        try {
+            SM2 sm2 = SmUtil.sm2(privateKeyBase64.trim(), null);
+            return sm2.decrypt(cipherBytes, KeyType.PrivateKey);
+        } catch (Exception ex) {
+            last = ex;
+        }
+        throw last != null ? last : new IllegalStateException("SM2 解密失败");
+    }
+
+    private static byte[] encryptByPublicKeyCipher(String data, String publicKeyBase64) throws Exception {
+        PublicKey publicKey = loadPublicKey(publicKeyBase64);
+        Cipher cipher = Cipher.getInstance(SM2, BC);
+        cipher.init(Cipher.ENCRYPT_MODE, publicKey);
+        return cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private static byte[] decryptByPrivateKeyCipher(byte[] cipherBytes, String privateKeyBase64) throws Exception {
+        PrivateKey privateKey = loadPrivateKey(privateKeyBase64);
+        Cipher cipher = Cipher.getInstance(SM2, BC);
+        cipher.init(Cipher.DECRYPT_MODE, privateKey);
+        return cipher.doFinal(cipherBytes);
+    }
+
+    private static byte[] encryptByPublicKeyEngine(String data, String publicKeyBase64, SM2Engine.Mode mode)
+            throws Exception {
+        PublicKey publicKey = loadPublicKey(publicKeyBase64);
+        ECPublicKeyParameters publicKeyParameters =
+                (ECPublicKeyParameters) ECUtil.generatePublicKeyParameter(publicKey);
+        SM2Engine engine = new SM2Engine(mode);
+        engine.init(true, new ParametersWithRandom(publicKeyParameters, new SecureRandom()));
+        byte[] input = data.getBytes(StandardCharsets.UTF_8);
+        return engine.processBlock(input, 0, input.length);
+    }
+
+    private static byte[] decryptByPrivateKeyEngine(byte[] cipherBytes, String privateKeyBase64, SM2Engine.Mode mode)
+            throws Exception {
+        PrivateKey privateKey = loadPrivateKey(privateKeyBase64);
+        ECPrivateKeyParameters privateKeyParameters =
+                (ECPrivateKeyParameters) ECUtil.generatePrivateKeyParameter(privateKey);
+        SM2Engine engine = new SM2Engine(mode);
+        engine.init(false, privateKeyParameters);
+        return engine.processBlock(cipherBytes, 0, cipherBytes.length);
+    }
+
+    private static PublicKey loadPublicKey(String publicKeyBase64) throws Exception {
+        byte[] keyBytes = decodeBase64(publicKeyBase64);
+        KeyFactory keyFactory = KeyFactory.getInstance(EC, BC);
+        return keyFactory.generatePublic(new X509EncodedKeySpec(keyBytes));
+    }
+
+    private static PrivateKey loadPrivateKey(String privateKeyBase64) throws Exception {
+        byte[] keyBytes = decodeBase64(privateKeyBase64);
+        KeyFactory keyFactory = KeyFactory.getInstance(EC, BC);
+        return keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
+    }
+
+    private static byte[] decodeBase64(String value) {
+        return Base64.getDecoder().decode(value.trim());
+    }
+}

+ 85 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppTenantConfig.java

@@ -0,0 +1,85 @@
+package com.usky.vpp.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * vpp_tenant_config 虚拟电厂租户配置
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("vpp_tenant_config")
+public class VppTenantConfig implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Integer id;
+
+    @TableField("tenant_id")
+    private Integer tenantId;
+
+    @TableField("dn_id")
+    private String dnId;
+
+    @TableField("dn_name")
+    private String dnName;
+
+    @TableField("base_url")
+    private String baseUrl;
+
+    @TableField("transport_address")
+    private String transportAddress;
+
+    @TableField("un_public_key")
+    private String unPublicKey;
+
+    @TableField("dn_public_key")
+    private String dnPublicKey;
+
+    @TableField("dn_private_key")
+    private String dnPrivateKey;
+
+    @TableField(value = "token",exist = false)
+    private String token;
+
+    @TableField("poll_interval_sec")
+    private Integer pollIntervalSec;
+
+    @TableField("outbound_enabled")
+    private Boolean outboundEnabled;
+
+    @TableField("poll_enabled")
+    private Boolean pollEnabled;
+
+    @TableField("crypto_enabled")
+    private Boolean cryptoEnabled;
+
+    @TableField("price_down_coeff")
+    private String priceDownCoeff;
+
+    @TableField("auto_ack_clearing")
+    private Boolean autoAckClearing;
+
+    @TableField("auto_register_on_startup")
+    private Boolean autoRegisterOnStartup;
+
+    @TableField("create_by")
+    private String createBy;
+
+    @TableField("create_time")
+    private LocalDateTime createTime;
+
+    @TableField("update_by")
+    private String updateBy;
+
+    @TableField("update_time")
+    private LocalDateTime updateTime;
+}

+ 0 - 47
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/job/VppUnBootstrapRunner.java

@@ -1,47 +0,0 @@
-package com.usky.vpp.job;
-
-import com.usky.vpp.config.VppUnProperties;
-import com.usky.vpp.service.VppUnIntegrationService;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.ApplicationArguments;
-import org.springframework.boot.ApplicationRunner;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.stereotype.Component;
-import org.springframework.util.StringUtils;
-
-/**
- * 启动时自动向 UN 注册(可选)
- */
-@Component
-@ConditionalOnProperty(name = "vpp.un.auto-register-on-startup", havingValue = "true")
-public class VppUnBootstrapRunner implements ApplicationRunner {
-
-    private static final Logger log = LoggerFactory.getLogger(VppUnBootstrapRunner.class);
-
-    @Autowired
-    private VppUnProperties properties;
-
-    @Autowired
-    private VppUnIntegrationService integrationService;
-
-    @Override
-    public void run(ApplicationArguments args) {
-        if (!properties.isOutboundActive()) {
-            log.warn("auto-register-on-startup 已开启但 outbound 未激活,跳过");
-            return;
-        }
-        try {
-            integrationService.refreshToken();
-            if (!StringUtils.hasText(properties.getRegistrationId())) {
-                integrationService.register();
-                log.info("启动注册 UN 完成");
-            } else {
-                log.info("已有 registrationID={},跳过启动注册", properties.getRegistrationId());
-            }
-        } catch (Exception ex) {
-            log.warn("启动注册 UN 失败: {}", ex.getMessage());
-        }
-    }
-}

+ 0 - 36
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/job/VppUnPollScheduler.java

@@ -1,36 +0,0 @@
-package com.usky.vpp.job;
-
-import com.usky.vpp.config.VppUnProperties;
-import com.usky.vpp.service.VppUnIntegrationService;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.scheduling.annotation.Scheduled;
-import org.springframework.stereotype.Component;
-
-/**
- * 定时 Poll UN 拉取事件/反馈
- */
-@Component
-public class VppUnPollScheduler {
-
-    private static final Logger log = LoggerFactory.getLogger(VppUnPollScheduler.class);
-
-    @Autowired
-    private VppUnProperties properties;
-
-    @Autowired
-    private VppUnIntegrationService integrationService;
-
-    //@Scheduled(fixedDelayString = "#{${vpp.un.poll-interval-sec:10} * 1000}")
-    public void pollUn() {
-        if (!properties.isPollActive()) {
-            return;
-        }
-        try {
-            integrationService.pollOnce();
-        } catch (Exception ex) {
-            log.warn("UN Poll 失败: {}", ex.getMessage());
-        }
-    }
-}

+ 39 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/job/VppUnPropertiesLoader.java

@@ -0,0 +1,39 @@
+package com.usky.vpp.job;
+
+import com.usky.vpp.config.VppUnProperties;
+import com.usky.vpp.config.VppUnPropertiesRegistry;
+import com.usky.vpp.service.VppUnRegisterTaskService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+/**
+ * 服务启动时从 vpp_tenant_config 加载 UN 配置至内存,并按需自动注册 UN
+ */
+@Component
+@Order(100)
+public class VppUnPropertiesLoader implements ApplicationRunner {
+
+    @Autowired
+    private VppUnPropertiesRegistry propertiesRegistry;
+
+    @Autowired
+    private VppUnRegisterTaskService unRegisterTaskService;
+
+    @Override
+    public void run(ApplicationArguments args) {
+        propertiesRegistry.refreshAll();
+        autoRegisterOnStartup();
+    }
+
+    private void autoRegisterOnStartup() {
+        for (VppUnProperties properties : propertiesRegistry.getAll()) {
+            if (!Boolean.TRUE.equals(properties.getAutoRegisterOnStartup()) || !properties.isOutboundActive()) {
+                continue;
+            }
+            unRegisterTaskService.registerUn(properties.getTenantId());
+        }
+    }
+}

+ 10 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/mapper/VppTenantConfigMapper.java

@@ -0,0 +1,10 @@
+package com.usky.vpp.mapper;
+
+import com.usky.common.mybatis.core.CrudMapper;
+import com.usky.vpp.domain.VppTenantConfig;
+
+/**
+ * vpp_tenant_config Mapper
+ */
+public interface VppTenantConfigMapper extends CrudMapper<VppTenantConfig> {
+}

+ 3 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppUnIntegrationService.java

@@ -11,6 +11,9 @@ public interface VppUnIntegrationService {
 
     Map<String, Object> refreshToken();
 
+    /** 从数据库加载已持久化的 registrationID(配置未设置时) */
+    void loadPersistedRegistrationId();
+
     Map<String, Object> register();
 
     Map<String, Object> pollOnce();

+ 14 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppUnIntervalDataReportTaskService.java

@@ -0,0 +1,14 @@
+package com.usky.vpp.service;
+
+/**
+ * 运管平台 UN 曲线数据报告(IntervalDataReport)定时任务
+ */
+public interface VppUnIntervalDataReportTaskService {
+
+    /**
+     * 向 UN 提交 IntervalDataReportRequest。
+     *
+     * @param tenantId 租户 ID
+     */
+    void intervalDataReportUn(Integer tenantId);
+}

+ 14 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppUnPollTaskService.java

@@ -0,0 +1,14 @@
+package com.usky.vpp.service;
+
+/**
+ * 运管平台 UN Poll 定时任务
+ */
+public interface VppUnPollTaskService {
+
+    /**
+     * 向 UN 发起一次 Poll,拉取事件/反馈。
+     *
+     * @param tenantId 租户 ID
+     */
+    void pollUn(Integer tenantId);
+}

+ 14 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppUnRegisterTaskService.java

@@ -0,0 +1,14 @@
+package com.usky.vpp.service;
+
+/**
+ * 运管平台 UN 注册定时任务
+ */
+public interface VppUnRegisterTaskService {
+
+    /**
+     * 按租户向 UN 执行 Token 刷新、加载 registrationID、注册/重新注册。
+     *
+     * @param tenantId 租户 ID
+     */
+    void registerUn(Integer tenantId);
+}

+ 13 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppUnReportService.java

@@ -8,4 +8,17 @@ import java.util.Map;
 public interface VppUnReportService {
 
     void handleCreateReportRequest(Map<String, Object> createReportRequest);
+
+    /** 提交 RegisterReportRequest(元数据报告)。 */
+    void submitRegisterReport();
+
+    /**
+     * 提交 IntervalDataReportRequest(实时/区间数据报告)。
+     *
+     * @param createReportRequest UN Poll 下发的 CreateReportRequest;主动上报时可传 null
+     */
+    void submitIntervalDataReport(Map<String, Object> createReportRequest);
+
+    /** 提交 MomentDataReportRequest(实时数据报告,主动上报)。 */
+    void submitMomentDataReport();
 }

+ 16 - 2
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppBaselineServiceImpl.java

@@ -197,7 +197,9 @@ public class VppBaselineServiceImpl implements VppBaselineService {
     /**
      * 查询站点响应申报容量和预估响应容量。
      *
-     * <p>公式:响应时段内同时间段历史负荷均值 × 执行时段小时数 / 2。</p>
+     * <p>申报容量公式:响应时段内同时间段历史负荷均值 × 执行时段小时数 / 2。</p>
+     * <p>预估响应容量公式:响应时段内今日负荷均值 − 响应时段内历史负荷均值 × 修正系数 K;
+     * K 由响应开始前 2 小时今日实测 / 同时段历史基线计算,限制在 [0.7, 1.2]。</p>
      * <p>典型历史日选取规则(工作日 5 天 / 非工作日 3 天),剔除响应当日、历史响应日(vpp_dr_event 已结束)
      * 及响应日 &gt;= 今日。</p>
      */
@@ -256,10 +258,22 @@ public class VppBaselineServiceImpl implements VppBaselineService {
             return result;
         }
 
+        LocalDateTime correctionWindowStart = startTime.minusHours(VppBaselineHelper.CORRECTION_HOURS);
+        Map<String, BigDecimal> correctionBaselineByTime = vppTsdbQueryService.queryBaselineAvgByTimePoint(
+                deviceUuids,
+                referenceDates,
+                correctionWindowStart.toLocalTime(),
+                startTime.toLocalTime(),
+                VppBaselineHelper.POWER_METRICS,
+                VppBaselineHelper.INTERVAL_MINUTES,
+                null);
+        BigDecimal correctionFactorK = VppBaselineHelper.calculateCorrectionFactor(
+                startTime, todayActualByTime, correctionBaselineByTime);
+
         result.setDeclaredCapacityKw(VppBaselineHelper.calculateDeclaredCapacityKw(
                 historicalAvgByTime, startTime, endTime));
         result.setEstimatedResponseCapacityKw(VppBaselineHelper.calculateEstimatedResponseCapacityKw(
-                todayActualByTime, startTime, endTime));
+                todayActualByTime, historicalAvgByTime, correctionFactorK, startTime, endTime));
         result.setPoints(VppBaselineHelper.buildDeclaredCapacityPoints(
                 historicalAvgByTime, startTime, endTime));
         result.setDataSource("tsdb");

+ 29 - 4
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrEventIngestServiceImpl.java

@@ -5,8 +5,10 @@ import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.usky.vpp.constant.VppDrEventStatus;
 import com.usky.vpp.domain.VppDrEvent;
+import com.usky.vpp.domain.VppDrParticipation;
 import com.usky.vpp.enums.VppUnEventPhase;
 import com.usky.vpp.mapper.VppDrEventMapper;
+import com.usky.vpp.mapper.VppDrParticipationMapper;
 import com.usky.vpp.service.VppDrEventIngestService;
 import com.usky.vpp.service.VppDrExecutionBootstrapService;
 import com.usky.vpp.service.VppUnDrSyncService;
@@ -35,10 +37,13 @@ public class VppDrEventIngestServiceImpl implements VppDrEventIngestService {
     private static final int EVENT_STATUS_EXECUTING = VppDrEventStatus.EXECUTING;
     private static final int EVENT_STATUS_ENDED = VppDrEventStatus.ENDED;
     private static final int EVENT_STATUS_CANCELLED = VppDrEventStatus.CANCELLED;
+    private static final int EVENT_STATUS_SPLITTING = VppDrEventStatus.LAST_PLACE_SPLITTING;
 
     @Autowired
     private VppDrEventMapper eventMapper;
     @Autowired
+    private VppDrParticipationMapper participationMapper;
+    @Autowired
     private VppUnDrSyncService unDrSyncService;
     @Autowired
     private VppDrExecutionBootstrapService executionBootstrapService;
@@ -53,8 +58,9 @@ public class VppDrEventIngestServiceImpl implements VppDrEventIngestService {
             throw new IllegalArgumentException("DistributeEventRequest 缺少 events");
         }
         VppDrEvent last = null;
+        String dnID = VppUnPayloadHelper.getString(body, "dnID");
         for (Map<String, Object> eventMap : events) {
-            last = ingestSingleEvent(eventMap, body, source);
+            last = ingestSingleEvent(eventMap, body, source, dnID);
         }
         return last;
     }
@@ -144,7 +150,7 @@ public class VppDrEventIngestServiceImpl implements VppDrEventIngestService {
         return event;
     }
 
-    private VppDrEvent ingestSingleEvent(Map<String, Object> eventMap, Map<String, Object> rawBody, String source) {
+    private VppDrEvent ingestSingleEvent(Map<String, Object> eventMap, Map<String, Object> rawBody, String source, String dnID) {
         Map<String, Object> descriptor = VppUnEventParser.getDescriptor(eventMap);
         String platformEventId = VppUnEventParser.getEventId(eventMap);
         if (!StringUtils.hasText(platformEventId)) {
@@ -164,6 +170,10 @@ public class VppDrEventIngestServiceImpl implements VppDrEventIngestService {
             event.setEventStatus(EVENT_STATUS_PENDING);
             applyBaseFields(event, eventMap, descriptor);
             event.setRawPayload(toJson(rawBody));
+            Integer tenantId = VppAuditHelper.resolveTenantId(dnID);
+            if (tenantId != null) {
+                event.setTenantId(tenantId);
+            }
             VppAuditHelper.fillCreate(event);
             eventMapper.insert(event);
             log.info("[{}] 新建事件 eventId={}, phase={}", source, platformEventId, phase);
@@ -192,8 +202,9 @@ public class VppDrEventIngestServiceImpl implements VppDrEventIngestService {
                 unDrSyncService.syncTargetResources(event, resources, false);
                 break;
             case SPLIT_NOTICE:
-                event.setEventStatus(EVENT_STATUS_DECLARED);
-                unDrSyncService.syncTargetResources(event, resources, true);
+                event.setEventStatus(EVENT_STATUS_SPLITTING);
+                event.setTargetCapacityKw(VppUnEventParser.sumResourceLoadKw(resources, true));
+                deleteEventParticipations(event);
                 break;
             case SPLIT_RESULT:
                 event.setEventStatus(EVENT_STATUS_DECLARED);
@@ -219,6 +230,20 @@ public class VppDrEventIngestServiceImpl implements VppDrEventIngestService {
         }
     }
 
+    private void deleteEventParticipations(VppDrEvent event) {
+        if (event == null || event.getId() == null) {
+            return;
+        }
+        List<VppDrParticipation> participations = participationMapper.selectList(
+                new LambdaQueryWrapper<VppDrParticipation>()
+                        .eq(VppDrParticipation::getEventId, event.getId())
+                        .eq(VppDrParticipation::getDeleteFlag, VppAuditHelper.NOT_DELETED));
+        for (VppDrParticipation participation : participations) {
+            VppAuditHelper.fillSoftDelete(participation);
+            participationMapper.updateById(participation);
+        }
+    }
+
     private void applyBaseFields(VppDrEvent event, Map<String, Object> eventMap, Map<String, Object> descriptor) {
         String comment = VppUnPayloadHelper.getString(descriptor, "comment");
         event.setEventName(StringUtils.hasText(comment) ? comment : event.getEventId());

+ 1 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrServiceImpl.java

@@ -66,6 +66,7 @@ public class VppDrServiceImpl implements VppDrService {
     private static final int EVENT_STATUS_EXECUTING = VppDrEventStatus.EXECUTING;
     private static final int EVENT_STATUS_ENDED = VppDrEventStatus.ENDED;
     private static final int EVENT_STATUS_CANCELLED = VppDrEventStatus.CANCELLED;
+    private static final int EVENT_STATUS_SPLITTING = VppDrEventStatus.LAST_PLACE_SPLITTING;
 
     private static final int PARTICIPATE_STATUS_ACCEPT = 1;
 

+ 1 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppSiteServiceImpl.java

@@ -232,6 +232,7 @@ public class VppSiteServiceImpl implements VppSiteService {
             vo.setOwnerName(site.getOwnerName());
             vo.setContactName(site.getContactName());
             vo.setContactPhone(site.getContactPhone());
+            vo.setUnResourceId(site.getUnResourceId());
             vo.setResponsePriority(site.getResponsePriority());
             vo.setCreateTime(site.getCreateTime());
             List<VppResourcePoint> siteResources = resourceBySite.getOrDefault(site.getId(), Collections.emptyList());

+ 37 - 1
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnDnServiceImpl.java

@@ -1,6 +1,9 @@
 package com.usky.vpp.service.impl;
 
+import com.usky.common.core.exception.BusinessException;
 import com.usky.vpp.config.VppUnProperties;
+import com.usky.vpp.config.VppUnPropertiesRegistry;
+import com.usky.vpp.config.VppUnTenantContext;
 import com.usky.vpp.domain.VppDrEvent;
 import com.usky.vpp.service.VppDrEventIngestService;
 import com.usky.vpp.service.VppUnDnService;
@@ -26,7 +29,7 @@ public class VppUnDnServiceImpl implements VppUnDnService {
     private static final Logger log = LoggerFactory.getLogger(VppUnDnServiceImpl.class);
 
     @Autowired
-    private VppUnProperties unProperties;
+    private VppUnPropertiesRegistry propertiesRegistry;
 
     @Autowired
     private VppDrEventIngestService drEventIngestService;
@@ -38,6 +41,7 @@ public class VppUnDnServiceImpl implements VppUnDnService {
 
     @Override
     public Map<String, Object> createRegistration(Map<String, Object> body) {
+        VppUnProperties unProperties = bindTenantContext(body);
         Map<String, Object> resp = ok("CreateRegistrationResponse", body);
         resp.put("registrationID", UUID.randomUUID().toString());
         resp.put("pollFreq", "PT" + unProperties.getPollIntervalSec() + "S");
@@ -74,6 +78,7 @@ public class VppUnDnServiceImpl implements VppUnDnService {
 
     @Override
     public Map<String, Object> createOpt(Map<String, Object> body) {
+        VppUnProperties unProperties = bindTenantContext(body);
         try {
             VppDrEvent event = drEventIngestService.ingestCreateOptRequest(body, "CreateOptRequest");
             Map<String, Object> resp = VppUnMessageBuilder.buildCreateOptResponse(body, unProperties);
@@ -90,6 +95,7 @@ public class VppUnDnServiceImpl implements VppUnDnService {
 
     @Override
     public Map<String, Object> createCq(Map<String, Object> body) {
+        VppUnProperties unProperties = bindTenantContext(body);
         try {
             VppDrEvent event = drEventIngestService.ingestCreateCqRequest(body, "CreateCqRequest");
             Map<String, Object> resp = VppUnMessageBuilder.buildCreateCqResponse(body, unProperties);
@@ -106,6 +112,7 @@ public class VppUnDnServiceImpl implements VppUnDnService {
 
     @Override
     public Map<String, Object> createEventResponse(Map<String, Object> body) {
+        VppUnProperties unProperties = bindTenantContext(body);
         try {
             String eventId = resolveEventIdForAck(body);
             if (!StringUtils.hasText(eventId)) {
@@ -127,7 +134,34 @@ public class VppUnDnServiceImpl implements VppUnDnService {
         }
     }
 
+    private VppUnProperties bindTenantContext(Map<String, Object> body) {
+        Integer tenantId = VppUnTenantContext.getTenantId();
+        if (tenantId == null) {
+            String dnId = body != null ? VppUnPayloadHelper.getString(body, "dnID", "dnId") : null;
+            VppUnProperties byDn = propertiesRegistry.getByDnId(dnId);
+            if (byDn != null) {
+                tenantId = byDn.getTenantId();
+            }
+        }
+        if (tenantId == null) {
+            try {
+                tenantId = VppUnTenantContext.resolveTenantId();
+            } catch (BusinessException ignored) {
+                for (VppUnProperties candidate : propertiesRegistry.getAll()) {
+                    tenantId = candidate.getTenantId();
+                    break;
+                }
+            }
+        }
+        if (tenantId == null) {
+            throw new BusinessException("未找到运管平台租户配置");
+        }
+        VppUnTenantContext.setTenantId(tenantId);
+        return propertiesRegistry.require(tenantId);
+    }
+
     private Map<String, Object> ingestDistributeAndRespond(String root, Map<String, Object> body, IngestAction action) {
+        bindTenantContext(body);
         try {
             VppDrEvent event = action.run();
             Map<String, Object> resp = ok(root, body);
@@ -186,6 +220,7 @@ public class VppUnDnServiceImpl implements VppUnDnService {
     }
 
     private Map<String, Object> ok(String root, Map<String, Object> body) {
+        VppUnProperties unProperties = bindTenantContext(body);
         Map<String, Object> resp = new HashMap<>();
         resp.put("root", root);
         resp.put("version", 1);
@@ -197,6 +232,7 @@ public class VppUnDnServiceImpl implements VppUnDnService {
     }
 
     private Map<String, Object> error(String root, Map<String, Object> body, int code, String description) {
+        VppUnProperties unProperties = bindTenantContext(body);
         Map<String, Object> resp = new HashMap<>();
         resp.put("root", root);
         resp.put("version", 1);

+ 35 - 19
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnIntegrationServiceImpl.java

@@ -5,6 +5,7 @@ import com.usky.common.core.exception.BusinessException;
 import com.usky.vpp.client.VppUnClient;
 import com.usky.vpp.client.VppUnTokenHolder;
 import com.usky.vpp.config.VppUnProperties;
+import com.usky.vpp.config.VppUnPropertiesRegistry;
 import com.usky.vpp.domain.VppCustomer;
 import com.usky.vpp.domain.VppDrEvent;
 import com.usky.vpp.domain.VppDrParticipation;
@@ -26,8 +27,6 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
 
-import javax.annotation.PostConstruct;
-
 import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
@@ -42,7 +41,7 @@ public class VppUnIntegrationServiceImpl implements VppUnIntegrationService {
     private static final int PARTICIPATE_STATUS_ACCEPT = 1;
 
     @Autowired
-    private VppUnProperties properties;
+    private VppUnPropertiesRegistry propertiesRegistry;
     @Autowired
     private VppUnClient unClient;
     @Autowired
@@ -60,19 +59,22 @@ public class VppUnIntegrationServiceImpl implements VppUnIntegrationService {
     @Autowired
     private VppUnReportService reportService;
 
-    @PostConstruct
-    public void loadRegistrationFromDb() {
+    @Override
+    public void loadPersistedRegistrationId() {
+        VppUnProperties properties = currentProperties();
         if (!StringUtils.hasText(properties.getDnId()) || StringUtils.hasText(properties.getRegistrationId())) {
             return;
         }
         VppRegistration registration = registrationMapper.selectOne(new LambdaQueryWrapper<VppRegistration>()
                 .eq(VppRegistration::getDnId, properties.getDnId())
+                .eq(VppRegistration::getTenantId, properties.getTenantId())
                 .eq(VppRegistration::getDeleteFlag, VppAuditHelper.NOT_DELETED)
                 .orderByDesc(VppRegistration::getRegisteredAt)
                 .last("LIMIT 1"));
         if (registration != null && StringUtils.hasText(registration.getRegistrationId())) {
             properties.setRegistrationId(registration.getRegistrationId());
-            log.info("已加载 registrationID={}", registration.getRegistrationId());
+            propertiesRegistry.updateRegistrationId(properties.getTenantId(), registration.getRegistrationId());
+            log.info("租户 {} 已从数据库加载 registrationID={}", properties.getTenantId(), registration.getRegistrationId());
         }
     }
 
@@ -80,6 +82,7 @@ public class VppUnIntegrationServiceImpl implements VppUnIntegrationService {
     public Map<String, Object> refreshToken() {
         ensureOutboundEnabled();
         tokenHolder.invalidate();
+        VppUnProperties properties = currentProperties();
         Map<String, Object> response = unClient.post("TokenRequest", VppUnMessageBuilder.buildTokenRequest(properties), false);
         tokenHolder.applyTokenResponse(response);
         return response;
@@ -89,6 +92,7 @@ public class VppUnIntegrationServiceImpl implements VppUnIntegrationService {
     @Transactional(rollbackFor = Exception.class)
     public Map<String, Object> register() {
         ensureOutboundEnabled();
+        VppUnProperties properties = currentProperties();
         Map<String, Object> request = VppUnMessageBuilder.buildCreateRegistrationRequest(properties);
         Map<String, Object> response = unClient.post("CreateRegistrationRequest", request, true);
         persistRegistration(response);
@@ -98,6 +102,7 @@ public class VppUnIntegrationServiceImpl implements VppUnIntegrationService {
     @Override
     public Map<String, Object> pollOnce() {
         ensureOutboundEnabled();
+        VppUnProperties properties = currentProperties();
         Map<String, Object> response = unClient.post("Poll", VppUnMessageBuilder.buildPollRequest(properties), true);
         handlePollResponse(response);
         return response;
@@ -105,6 +110,7 @@ public class VppUnIntegrationServiceImpl implements VppUnIntegrationService {
 
     @Override
     public Map<String, Object> submitParticipation(Long eventId, boolean participate) {
+        VppUnProperties properties = currentProperties();
         if (!properties.isOutboundActive()) {
             return null;
         }
@@ -120,6 +126,7 @@ public class VppUnIntegrationServiceImpl implements VppUnIntegrationService {
 
     @Override
     public Map<String, Object> submitClearing(Long eventId) {
+        VppUnProperties properties = currentProperties();
         if (!properties.isOutboundActive()) {
             return null;
         }
@@ -138,6 +145,7 @@ public class VppUnIntegrationServiceImpl implements VppUnIntegrationService {
     @Override
     @Transactional(rollbackFor = Exception.class)
     public Map<String, Object> acknowledgeClearing(Long eventId) {
+        VppUnProperties properties = currentProperties();
         if (!properties.isOutboundActive()) {
             return null;
         }
@@ -156,6 +164,7 @@ public class VppUnIntegrationServiceImpl implements VppUnIntegrationService {
         if (response == null || response.isEmpty()) {
             return;
         }
+        VppUnProperties properties = currentProperties();
         String root = VppUnPayloadHelper.getString(response, "root");
         if ("DistributeEventRequest".equals(root) || response.containsKey("events")) {
             VppDrEvent event = drEventIngestService.ingestDistributeEvent(response, "Poll-Outbound");
@@ -166,20 +175,23 @@ public class VppUnIntegrationServiceImpl implements VppUnIntegrationService {
         }
         if ("ReregistrationRequest".equals(root)) {
             log.info("UN 要求重新注册");
-            String registrationId = VppUnPayloadHelper.getString(response, "registrationID", "registrationId");
-            if (StringUtils.hasText(registrationId)) {
-                properties.setRegistrationId(registrationId);
-            }
+            loadPersistedRegistrationId();
             register();
             return;
         }
-        if ("CreateReportRequest".equals(root)) {
-            try {
-                reportService.handleCreateReportRequest(response);
-            } catch (Exception ex) {
-                log.warn("处理 CreateReportRequest 失败: {}", ex.getMessage());
-            }
-        }
+
+//        //删除
+//        if ("CreateReportRequest".equals(root)) {
+//            try {
+//                reportService.handleCreateReportRequest(response);
+//            } catch (Exception ex) {
+//                log.warn("处理 CreateReportRequest 失败: {}", ex.getMessage());
+//            }
+//        }
+    }
+
+    private VppUnProperties currentProperties() {
+        return propertiesRegistry.getCurrent();
     }
 
     private void maybeAutoAckClearing(VppDrEvent event) {
@@ -217,14 +229,17 @@ public class VppUnIntegrationServiceImpl implements VppUnIntegrationService {
     }
 
     private void persistRegistration(Map<String, Object> response) {
+        VppUnProperties properties = currentProperties();
         String registrationId = VppUnPayloadHelper.getString(response, "registrationID", "registrationId");
         if (!StringUtils.hasText(registrationId)) {
             return;
         }
         properties.setRegistrationId(registrationId);
+        propertiesRegistry.updateRegistrationId(properties.getTenantId(), registrationId);
 
         VppRegistration existing = registrationMapper.selectOne(new LambdaQueryWrapper<VppRegistration>()
                 .eq(VppRegistration::getDnId, properties.getDnId())
+                .eq(VppRegistration::getTenantId, properties.getTenantId())
                 .eq(VppRegistration::getDeleteFlag, VppAuditHelper.NOT_DELETED)
                 .last("LIMIT 1"));
         if (existing == null) {
@@ -233,6 +248,7 @@ public class VppUnIntegrationServiceImpl implements VppUnIntegrationService {
             existing.setRegistrationId(registrationId);
             existing.setRegStatus(1);
             existing.setRegisteredAt(LocalDateTime.now());
+            existing.setTenantId(properties.getTenantId());
             VppAuditHelper.fillCreate(existing);
             registrationMapper.insert(existing);
         } else {
@@ -255,8 +271,8 @@ public class VppUnIntegrationServiceImpl implements VppUnIntegrationService {
     }
 
     private void ensureOutboundEnabled() {
-        if (!properties.isOutboundActive()) {
-            throw new BusinessException("运管平台 outbound 未启用,请配置 vpp.un.baseUrl 与 outbound-enabled");
+        if (!currentProperties().isOutboundActive()) {
+            throw new BusinessException("运管平台 outbound 未启用,请在 vpp_tenant_config 中配置 baseUrl 与 outbound_enabled");
         }
     }
 }

+ 44 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnIntervalDataReportTaskServiceImpl.java

@@ -0,0 +1,44 @@
+package com.usky.vpp.service.impl;
+
+import com.usky.vpp.config.VppUnProperties;
+import com.usky.vpp.config.VppUnPropertiesRegistry;
+import com.usky.vpp.config.VppUnTenantContext;
+import com.usky.vpp.service.VppUnIntervalDataReportTaskService;
+import com.usky.vpp.service.VppUnReportService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+@Service
+public class VppUnIntervalDataReportTaskServiceImpl implements VppUnIntervalDataReportTaskService {
+
+    private static final Logger log = LoggerFactory.getLogger(VppUnIntervalDataReportTaskServiceImpl.class);
+
+    @Autowired
+    private VppUnPropertiesRegistry propertiesRegistry;
+
+    @Autowired
+    private VppUnReportService reportService;
+
+    @Override
+    public void intervalDataReportUn(Integer tenantId) {
+        log.info("intervalDataReportUn start, tenantId={}", tenantId);
+        VppUnProperties properties = propertiesRegistry.get(tenantId);
+        if (properties == null) {
+            log.warn("intervalDataReportUn skipped: tenantId={} 未配置运管平台 UN 对接信息", tenantId);
+            return;
+        }
+        if (!properties.isOutboundActive()) {
+            log.info("intervalDataReportUn skipped: outbound not active, tenantId={}", tenantId);
+            return;
+        }
+        VppUnTenantContext.runWithTenant(tenantId, () -> {
+            try {
+                reportService.submitIntervalDataReport(null);
+            } catch (Exception ex) {
+                log.warn("IntervalDataReport 提交失败, tenantId={}: {}", tenantId, ex.getMessage());
+            }
+        });
+    }
+}

+ 44 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnPollTaskServiceImpl.java

@@ -0,0 +1,44 @@
+package com.usky.vpp.service.impl;
+
+import com.usky.vpp.config.VppUnProperties;
+import com.usky.vpp.config.VppUnPropertiesRegistry;
+import com.usky.vpp.config.VppUnTenantContext;
+import com.usky.vpp.service.VppUnIntegrationService;
+import com.usky.vpp.service.VppUnPollTaskService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+@Service
+public class VppUnPollTaskServiceImpl implements VppUnPollTaskService {
+
+    private static final Logger log = LoggerFactory.getLogger(VppUnPollTaskServiceImpl.class);
+
+    @Autowired
+    private VppUnPropertiesRegistry propertiesRegistry;
+
+    @Autowired
+    private VppUnIntegrationService integrationService;
+
+    @Override
+    public void pollUn(Integer tenantId) {
+        log.info("pollUn start, tenantId={}", tenantId);
+        VppUnProperties properties = propertiesRegistry.get(tenantId);
+        if (properties == null) {
+            log.warn("pollUn skipped: tenantId={} 未配置运管平台 UN 对接信息", tenantId);
+            return;
+        }
+        if (!properties.isPollActive()) {
+            log.info("pollUn skipped: poll not active, tenantId={}", tenantId);
+            return;
+        }
+        VppUnTenantContext.runWithTenant(tenantId, () -> {
+            try {
+                integrationService.pollOnce();
+            } catch (Exception ex) {
+                log.warn("UN Poll 失败, tenantId={}: {}", tenantId, ex.getMessage());
+            }
+        });
+    }
+}

+ 68 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnRegisterTaskServiceImpl.java

@@ -0,0 +1,68 @@
+package com.usky.vpp.service.impl;
+
+import com.usky.vpp.config.VppUnProperties;
+import com.usky.vpp.config.VppUnPropertiesRegistry;
+import com.usky.vpp.config.VppUnTenantContext;
+import com.usky.vpp.service.VppUnIntegrationService;
+import com.usky.vpp.service.VppUnRegisterTaskService;
+import com.usky.vpp.service.VppUnReportService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+@Service
+public class VppUnRegisterTaskServiceImpl implements VppUnRegisterTaskService {
+
+    private static final Logger log = LoggerFactory.getLogger(VppUnRegisterTaskServiceImpl.class);
+
+    @Autowired
+    private VppUnPropertiesRegistry propertiesRegistry;
+
+    @Autowired
+    private VppUnIntegrationService integrationService;
+
+    @Autowired
+    private VppUnReportService reportService;
+
+    @Override
+    public void registerUn(Integer tenantId) {
+        log.info("registerUn start, tenantId={}", tenantId);
+        VppUnProperties properties = propertiesRegistry.get(tenantId);
+        if (properties == null) {
+            log.warn("registerUn skipped: tenantId={} 未配置运管平台 UN 对接信息", tenantId);
+            return;
+        }
+        if (!properties.isOutboundActive()) {
+            log.info("registerUn skipped: outbound not active, tenantId={}", tenantId);
+            return;
+        }
+        VppUnTenantContext.runWithTenant(tenantId, () -> {
+            try {
+                integrationService.refreshToken();
+                integrationService.loadPersistedRegistrationId();
+                boolean reregister = StringUtils.hasText(properties.getRegistrationId());
+                if (reregister) {
+                    log.info("租户 {} 使用已持久化的 registrationID={} 向 UN 重新注册",
+                            tenantId, properties.getRegistrationId());
+                }
+                integrationService.register();
+                log.info("租户 {} {} UN 完成 registrationID={}",
+                        tenantId, reregister ? "重新注册" : "注册", properties.getRegistrationId());
+            } catch (Exception ex) {
+                log.warn("租户 {} UN 注册失败: {}", tenantId, ex.getMessage());
+            }
+            try {
+                reportService.submitRegisterReport();
+            } catch (Exception ex) {
+                log.warn("租户 {} RegisterReport元数据报告请求 提交失败: {}", tenantId, ex.getMessage());
+            }
+            try {
+                reportService.submitMomentDataReport();
+            } catch (Exception ex) {
+                log.warn("租户 {} MomentDataReport实时数据报告请求 提交失败: {}", tenantId, ex.getMessage());
+            }
+        });
+    }
+}

+ 282 - 22
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnReportServiceImpl.java

@@ -3,22 +3,39 @@ package com.usky.vpp.service.impl;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.usky.vpp.client.VppUnClient;
 import com.usky.vpp.config.VppUnProperties;
+import com.usky.vpp.config.VppUnPropertiesRegistry;
+import com.usky.vpp.config.VppUnTenantContext;
+import com.usky.vpp.domain.VppDevice;
 import com.usky.vpp.domain.VppResourcePoint;
 import com.usky.vpp.domain.VppSite;
+import com.usky.vpp.mapper.VppDeviceMapper;
 import com.usky.vpp.mapper.VppResourcePointMapper;
+import com.usky.vpp.mapper.VppSiteMapper;
+import com.usky.vpp.service.VppTsdbQueryService;
 import com.usky.vpp.service.VppUnReportService;
-import com.usky.vpp.util.VppAuditHelper;
-import com.usky.vpp.util.VppSiteResourceHelper;
-import com.usky.vpp.util.VppUnMessageBuilder;
-import com.usky.vpp.util.VppUnPayloadHelper;
+import com.usky.vpp.constant.VppDrEventStatus;
+import com.usky.vpp.domain.VppDrEvent;
+import com.usky.vpp.mapper.VppDrEventMapper;
+import com.usky.vpp.util.*;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.util.StringUtils;
 
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.HashMap;
+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;
 
 @Service
 public class VppUnReportServiceImpl implements VppUnReportService {
@@ -26,16 +43,25 @@ public class VppUnReportServiceImpl implements VppUnReportService {
     private static final Logger log = LoggerFactory.getLogger(VppUnReportServiceImpl.class);
 
     @Autowired
-    private VppUnProperties properties;
+    private VppUnPropertiesRegistry propertiesRegistry;
     @Autowired
     private VppUnClient unClient;
     @Autowired
     private VppResourcePointMapper resourcePointMapper;
     @Autowired
+    private VppSiteMapper siteMapper;
+    @Autowired
+    private VppDeviceMapper deviceMapper;
+    @Autowired
     private VppSiteResourceHelper siteResourceHelper;
+    @Autowired
+    private VppTsdbQueryService vppTsdbQueryService;
+    @Autowired
+    private VppDrEventMapper drEventMapper;
 
     @Override
     public void handleCreateReportRequest(Map<String, Object> createReportRequest) {
+        VppUnProperties properties = propertiesRegistry.getCurrent();
         if (!properties.isOutboundActive()) {
             return;
         }
@@ -50,56 +76,290 @@ public class VppUnReportServiceImpl implements VppUnReportService {
         }
         switch (reportRequestId) {
             case "MetaDataReport":
-                submitRegisterReport();
+                doSubmitRegisterReport();
                 break;
             case "IntervalDataReport":
-                submitIntervalDataReport(createReportRequest);
+                doSubmitIntervalDataReport(createReportRequest);
                 break;
             case "MomentDataReport":
-                submitMomentDataReport(createReportRequest);
+                doSubmitMomentDataReport(createReportRequest);
                 break;
+//            case "LoadForecastReport":
+//                submitLoadForecastReport(createReportRequest);
+//                break;
+//            case "RegulateForecastReport":
+//                submitRegulateForecastReport(createReportRequest);
+//                break;
             default:
                 log.info("暂不支持的报告类型: {}", reportRequestId);
                 break;
         }
     }
 
-    private void submitRegisterReport() {
-        List<VppResourcePoint> resources = listActiveResources(false);
-        Map<Long, VppSite> siteMap = siteResourceHelper.loadSiteMap(resources);
-        Map<String, Object> request = VppUnMessageBuilder.buildRegisterReportRequest(resources, siteMap, properties);
+    @Override
+    public void submitRegisterReport() {
+        VppUnProperties properties = propertiesRegistry.getCurrent();
+        if (!properties.isOutboundActive()) {
+            return;
+        }
+        doSubmitRegisterReport();
+    }
+
+    private void doSubmitRegisterReport() {
+        VppUnProperties properties = propertiesRegistry.getCurrent();
+        Integer tenantId = VppUnTenantContext.resolveTenantId();
+        List<VppSite> sites = listActiveSites(tenantId);
+        List<VppResourcePoint> resources = listActiveResources(true);
+        Map<Long, String> deviceUnResourceIdMap = loadDeviceUnResourceIdMap(resources);
+        Map<String, Object> request = VppUnMessageBuilder.buildRegisterReportRequest(
+                properties, sites, resources, deviceUnResourceIdMap);
         unClient.post("RegisterReportRequest", request, true);
-        log.info("已提交 RegisterReportRequest");
+        long siteMetricCount = sites.stream()
+                .filter(site -> site != null && StringUtils.hasText(site.getUnResourceId()))
+                .count();
+        long resourceMetricCount = resources.stream()
+                .filter(resource -> resource != null
+                        && resource.getDeviceId() != null
+                        && StringUtils.hasText(deviceUnResourceIdMap.get(resource.getDeviceId())))
+                .count();
+        log.info("已提交 RegisterReportRequest,虚拟电厂量测点={},站点量测点={},资源点量测点={}",
+                VppUnRegisterMetricDefinitions.VPP_REGISTER_METRICS.size(), siteMetricCount, resourceMetricCount);
+    }
+
+    @Override
+    public void submitIntervalDataReport(Map<String, Object> createReportRequest) {
+        VppUnProperties properties = propertiesRegistry.getCurrent();
+        if (!properties.isOutboundActive()) {
+            return;
+        }
+        doSubmitIntervalDataReport(createReportRequest);
+    }
+
+    private void doSubmitIntervalDataReport(Map<String, Object> createReportRequest) {
+        VppUnProperties properties = propertiesRegistry.getCurrent();
+        Integer tenantId = VppUnTenantContext.resolveTenantId();
+        List<VppSite> sites = listActiveSites(tenantId);
+        List<VppResourcePoint> resources = listActiveResources(false);
+        List<VppResourcePoint> peakResources = listActiveResources(true);
+        Map<Long, String> deviceUnResourceIdMap = loadDeviceUnResourceIdMap(resources);
+        Map<Long, VppDevice> deviceMap = loadDeviceMap(resources);
+        Map<Long, List<String>> peakDeviceUuidsBySite = groupPeakDeviceUuidsBySite(peakResources, deviceMap);
+        List<String> tenantPeakDeviceUuids = collectDeviceUuids(peakResources, deviceMap);
+
+        LocalDate forecastDate = VppUnIntervalDataHelper.resolveForecastDate();
+        boolean workday = VppBaselineHelper.isWorkdayResponse(forecastDate);
+        int requiredCount = workday
+                ? VppBaselineHelper.WEEKDAY_REFERENCE_COUNT
+                : VppBaselineHelper.NON_WEEKDAY_REFERENCE_COUNT;
+        List<LocalDate> referenceDates = VppBaselineHelper.selectReferenceDates(
+                forecastDate, workday, loadResponseHistoryDates(), requiredCount, LocalDate.now());
+
+        LocalDateTime reportTime = LocalDateTime.now();
+        List<VppUnRegisterReportIndex.Entry> entries = VppUnRegisterReportIndex.buildEntries(
+                properties, sites, resources, deviceUnResourceIdMap);
+        List<Map<String, Object>> pointCurveData = new ArrayList<>(entries.size());
+        for (VppUnRegisterReportIndex.Entry entry : entries) {
+            List<String> deviceUuids = resolveMomentDataDeviceUuids(
+                    entry, tenantPeakDeviceUuids, peakDeviceUuidsBySite, deviceMap);
+            List<Map<String, Object>> curveValues = VppUnIntervalDataHelper.buildForecastCurveValues(
+                    vppTsdbQueryService, deviceUuids, referenceDates, forecastDate, entry.getMetric());
+            pointCurveData.add(VppUnMessageBuilder.buildIntervalCurveDataEntry(entry.getRid(), curveValues));
+        }
+
+        Map<String, Object> request = VppUnMessageBuilder.buildIntervalDataReportRequest(
+                properties, createReportRequest, pointCurveData, reportTime);
+        unClient.post("IntervalDataReportRequest", request, true);
+        log.info("已提交 IntervalDataReportRequest,pointCurveData={},预测日={},参考日数={}",
+                pointCurveData.size(), forecastDate, referenceDates.size());
+    }
+
+    @Override
+    public void submitMomentDataReport() {
+        VppUnProperties properties = propertiesRegistry.getCurrent();
+        if (!properties.isOutboundActive()) {
+            return;
+        }
+        doSubmitMomentDataReport(null);
     }
 
-    private void submitIntervalDataReport(Map<String, Object> createReportRequest) {
+    private void doSubmitMomentDataReport(Map<String, Object> createReportRequest) {
+        VppUnProperties properties = propertiesRegistry.getCurrent();
+        Integer tenantId = VppUnTenantContext.resolveTenantId();
+        List<VppSite> sites = listActiveSites(tenantId);
+        List<VppResourcePoint> resources = listActiveResources(false);
+        List<VppResourcePoint> peakResources = listActiveResources(true);
+        Map<Long, String> deviceUnResourceIdMap = loadDeviceUnResourceIdMap(resources);
+        Map<Long, VppDevice> deviceMap = loadDeviceMap(resources);
+        Map<Long, List<String>> peakDeviceUuidsBySite = groupPeakDeviceUuidsBySite(peakResources, deviceMap);
+        List<String> tenantPeakDeviceUuids = collectDeviceUuids(peakResources, deviceMap);
+
+        LocalDateTime windowEnd = VppUnMomentDataHelper.resolveWindowEnd(LocalDateTime.now());
+        LocalDateTime windowStart = VppUnMomentDataHelper.resolveWindowStart(windowEnd);
+
+        List<VppUnRegisterReportIndex.Entry> entries = VppUnRegisterReportIndex.buildEntries(
+                properties, sites, resources, deviceUnResourceIdMap);
+        List<Map<String, Object>> pointData = new ArrayList<>(entries.size());
+        for (VppUnRegisterReportIndex.Entry entry : entries) {
+            List<String> deviceUuids = resolveMomentDataDeviceUuids(
+                    entry, tenantPeakDeviceUuids, peakDeviceUuidsBySite, deviceMap);
+            BigDecimal value = VppUnMomentDataHelper.aggregateMetricValue(
+                    vppTsdbQueryService, deviceUuids, windowStart, windowEnd, entry.getMetric());
+            pointData.add(VppUnMessageBuilder.buildMomentDataPoint(
+                    entry.getRid(), value, windowEnd, "good"));
+        }
+
+        Map<String, Object> request = VppUnMessageBuilder.buildMomentDataReportRequest(
+                properties, createReportRequest, pointData, windowEnd);
+        unClient.post("MomentDataReportRequest", request, true);
+        log.info("已提交 MomentDataReportRequest,pointData={},窗口 {} ~ {}",
+                pointData.size(), windowStart, windowEnd);
+    }
+
+    private void submitLoadForecastReport(Map<String, Object> createReportRequest) {
+        VppUnProperties properties = propertiesRegistry.getCurrent();
         List<VppResourcePoint> resources = listActiveResources(true);
         Map<Long, VppSite> siteMap = siteResourceHelper.loadSiteMap(resources);
-        Map<String, Object> request = VppUnMessageBuilder.buildIntervalDataReportRequest(
+        Map<String, Object> request = VppUnMessageBuilder.buildLoadForecastReportRequest(
                 createReportRequest, resources, siteMap, properties);
-        unClient.post("IntervalDataReportRequest", request, true);
-        log.info("已提交 IntervalDataReportRequest");
+        unClient.post("LoadForecastReportRequest", request, true);
+        log.info("已提交 LoadForecastReportRequest");
     }
 
-    private void submitMomentDataReport(Map<String, Object> createReportRequest) {
+    private void submitRegulateForecastReport(Map<String, Object> createReportRequest) {
+        VppUnProperties properties = propertiesRegistry.getCurrent();
         List<VppResourcePoint> resources = listActiveResources(true);
         Map<Long, VppSite> siteMap = siteResourceHelper.loadSiteMap(resources);
-        Map<String, Object> request = VppUnMessageBuilder.buildMomentDataReportRequest(
+        Map<String, Object> request = VppUnMessageBuilder.buildRegulateForecastReportRequest(
                 createReportRequest, resources, siteMap, properties);
-        unClient.post("MomentDataReportRequest", request, true);
-        log.info("已提交 MomentDataReportRequest");
+        unClient.post("RegulateForecastReportRequest", request, true);
+        log.info("已提交 RegulateForecastReportRequest");
+    }
+
+    private List<VppSite> listActiveSites(Integer tenantId) {
+        return siteMapper.selectList(new LambdaQueryWrapper<VppSite>()
+                .eq(VppSite::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(tenantId != null, VppSite::getTenantId, tenantId)
+                .orderByAsc(VppSite::getId));
     }
 
     private List<VppResourcePoint> listActiveResources(boolean controllableOnly) {
+        Integer tenantId = VppUnTenantContext.resolveTenantId();
         LambdaQueryWrapper<VppResourcePoint> wrapper = new LambdaQueryWrapper<VppResourcePoint>()
                 .eq(VppResourcePoint::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(tenantId != null, VppResourcePoint::getTenantId, tenantId)
                 .orderByAsc(VppResourcePoint::getId);
         if (controllableOnly) {
-            wrapper.eq(VppResourcePoint::getIsControl, 1);
+            wrapper.eq(VppResourcePoint::getIsSupportPeak, 1);
         }
         return resourcePointMapper.selectList(wrapper);
     }
 
+    private Map<Long, VppDevice> loadDeviceMap(List<VppResourcePoint> resources) {
+        if (resources == null || resources.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        List<Long> deviceIds = resources.stream()
+                .map(VppResourcePoint::getDeviceId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+        if (deviceIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return deviceMapper.selectBatchIds(deviceIds).stream()
+                .filter(device -> device != null && !VppAuditHelper.isDeleted(device.getDeleteFlag()))
+                .collect(Collectors.toMap(VppDevice::getId, device -> device, (a, b) -> a));
+    }
+
+    private Map<Long, List<String>> groupPeakDeviceUuidsBySite(List<VppResourcePoint> peakResources,
+                                                                 Map<Long, VppDevice> deviceMap) {
+        Map<Long, List<String>> grouped = new HashMap<>();
+        if (peakResources == null || deviceMap == null) {
+            return grouped;
+        }
+        for (VppResourcePoint resource : peakResources) {
+            if (resource == null || resource.getSiteId() == null || resource.getDeviceId() == null) {
+                continue;
+            }
+            VppDevice device = deviceMap.get(resource.getDeviceId());
+            if (device == null || !StringUtils.hasText(device.getDeviceUuid())) {
+                continue;
+            }
+            grouped.computeIfAbsent(resource.getSiteId(), key -> new ArrayList<>())
+                    .add(device.getDeviceUuid().trim());
+        }
+        return grouped;
+    }
+
+    private List<String> collectDeviceUuids(List<VppResourcePoint> resources, Map<Long, VppDevice> deviceMap) {
+        if (resources == null || deviceMap == null) {
+            return Collections.emptyList();
+        }
+        Set<String> uuids = new LinkedHashSet<>();
+        for (VppResourcePoint resource : resources) {
+            if (resource == null || resource.getDeviceId() == null) {
+                continue;
+            }
+            VppDevice device = deviceMap.get(resource.getDeviceId());
+            if (device != null && StringUtils.hasText(device.getDeviceUuid())) {
+                uuids.add(device.getDeviceUuid().trim());
+            }
+        }
+        return new ArrayList<>(uuids);
+    }
+
+    private List<String> resolveMomentDataDeviceUuids(VppUnRegisterReportIndex.Entry entry,
+                                                      List<String> tenantPeakDeviceUuids,
+                                                      Map<Long, List<String>> peakDeviceUuidsBySite,
+                                                      Map<Long, VppDevice> deviceMap) {
+        if (entry.getScope() == VppUnRegisterReportIndex.Scope.VPP) {
+            return tenantPeakDeviceUuids;
+        }
+        if (entry.getScope() == VppUnRegisterReportIndex.Scope.SITE && entry.getSite() != null) {
+            return peakDeviceUuidsBySite.getOrDefault(entry.getSite().getId(), Collections.emptyList());
+        }
+        if (entry.getScope() == VppUnRegisterReportIndex.Scope.RESOURCE && entry.getResource() != null) {
+            VppDevice device = deviceMap.get(entry.getResource().getDeviceId());
+            if (device != null && StringUtils.hasText(device.getDeviceUuid())) {
+                return Collections.singletonList(device.getDeviceUuid().trim());
+            }
+        }
+        return Collections.emptyList();
+    }
+
+    private Map<Long, String> loadDeviceUnResourceIdMap(List<VppResourcePoint> resources) {
+        if (resources == null || resources.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        List<Long> deviceIds = resources.stream()
+                .map(VppResourcePoint::getDeviceId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+        if (deviceIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return deviceMapper.selectBatchIds(deviceIds).stream()
+                .filter(device -> device != null
+                        && !VppAuditHelper.isDeleted(device.getDeleteFlag())
+                        && StringUtils.hasText(device.getUnResourceId()))
+                .collect(Collectors.toMap(VppDevice::getId, VppDevice::getUnResourceId, (a, b) -> a));
+    }
+
+    private Set<LocalDate> loadResponseHistoryDates() {
+        List<VppDrEvent> events = drEventMapper.selectList(
+                new LambdaQueryWrapper<VppDrEvent>()
+                        .eq(VppDrEvent::getEventStatus, VppDrEventStatus.ENDED)
+                        .eq(VppDrEvent::getDeleteFlag, VppAuditHelper.NOT_DELETED));
+        Set<LocalDate> dates = new LinkedHashSet<>();
+        for (VppDrEvent event : events) {
+            if (event.getStartTime() != null) {
+                dates.add(event.getStartTime().toLocalDate());
+            }
+        }
+        return dates;
+    }
+
     @SuppressWarnings("unchecked")
     private String resolveReportRequestId(Map<String, Object> request) {
         String id = VppUnPayloadHelper.getString(request, "reportRequestID", "reportRequestId");

+ 1 - 1
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/SiteDeclaredCapacityVO.java

@@ -20,7 +20,7 @@ public class SiteDeclaredCapacityVO {
     private LocalDateTime responseEndTime;
     /** 响应申报容量 kW(历史均值 × 执行时段小时数 / 2) */
     private BigDecimal declaredCapacityKw;
-    /** 预估响应容量 kW(estimated_response_capacity_kw) */
+    /** 预估响应容量 kW:响应时段今日负荷均值 − 历史负荷均值 × 修正系数 */
     private BigDecimal estimatedResponseCapacityKw;
     /** 参与计算的典型历史日(yyyy-MM-dd) */
     private List<String> referenceDates;

+ 1 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/SiteListVO.java

@@ -26,6 +26,7 @@ public class SiteListVO {
     private String contactName;
     private String contactPhone;
     private Integer responsePriority;
+    private String unResourceId;
     /** 站点下资源类型列表(去重) */
     private List<String> resourceTypes;
     /** 站点下资源点数量 */

+ 20 - 4
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppAuditHelper.java

@@ -29,10 +29,6 @@ public final class VppAuditHelper {
             invokeSetter(entity, "setCreatedBy", auditUser);
             invokeSetter(entity, "setUpdatedBy", auditUser);
         }
-        Integer tenantId = currentTenantId();
-        if (tenantId != null) {
-            invokeSetter(entity, "setTenantId", tenantId);
-        }
     }
 
     public static void fillUpdate(Object entity) {
@@ -92,6 +88,26 @@ public final class VppAuditHelper {
         }
     }
 
+    public static Integer resolveTenantId(String dnId) {
+        if (StringUtils.hasText(dnId)) {
+            Integer tenantId = VppSiteResourceHelper.resolveTenantIdByUnResourceId(dnId);
+            if (tenantId != null) {
+                return tenantId;
+            }
+        }
+        return currentTenantId();
+    }
+
+    private static String invokeGetter(Object entity, String method) {
+        try {
+            Method m = entity.getClass().getMethod(method);
+            Object value = m.invoke(entity);
+            return value != null ? value.toString() : null;
+        } catch (Exception ignored) {
+            return null;
+        }
+    }
+
     private static Integer currentTenantId() {
         try {
             return SecurityUtils.getTenantId();

+ 14 - 7
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppBaselineHelper.java

@@ -329,16 +329,23 @@ public final class VppBaselineHelper {
     }
 
     /**
-     * 预估响应容量 = 响应时段内今日负荷均值。
+     * 预估响应容量 = 响应时段内今日负荷均值 − 响应时段内历史负荷均值 × 修正系数
      */
-    public static BigDecimal calculateEstimatedResponseCapacityKw(Map<String, BigDecimal> historicalAvgByTime,
-                                                         LocalDateTime windowStart,
-                                                         LocalDateTime windowEnd) {
-        BigDecimal historicalAvg = averageValuesInWindow(historicalAvgByTime, windowStart, windowEnd);
-        if (historicalAvg == null || historicalAvg.compareTo(BigDecimal.ZERO) <= 0) {
+    public static BigDecimal calculateEstimatedResponseCapacityKw(Map<String, BigDecimal> todayActualByTime,
+                                                                  Map<String, BigDecimal> historicalAvgByTime,
+                                                                  BigDecimal correctionFactorK,
+                                                                  LocalDateTime windowStart,
+                                                                  LocalDateTime windowEnd) {
+        BigDecimal todayAvg = averageValuesInWindow(todayActualByTime, windowStart, windowEnd);
+        if (todayAvg == null) {
             return BigDecimal.ZERO;
         }
-        return historicalAvg;
+        BigDecimal historicalAvg = averageValuesInWindow(historicalAvgByTime, windowStart, windowEnd);
+        BigDecimal k = correctionFactorK != null ? correctionFactorK : K_DEFAULT;
+        BigDecimal correctedHistorical = historicalAvg != null
+                ? historicalAvg.multiply(k)
+                : BigDecimal.ZERO;
+        return scale(todayAvg.subtract(correctedHistorical));
     }
 
     private static BigDecimal applyDeclaredCapacityFormula(BigDecimal historicalAvg,

+ 20 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppSiteResourceHelper.java

@@ -1,11 +1,14 @@
 package com.usky.vpp.util;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.usky.vpp.domain.VppResourcePoint;
 import com.usky.vpp.domain.VppSite;
 import com.usky.vpp.mapper.VppSiteMapper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
 
+import javax.annotation.PostConstruct;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
@@ -18,9 +21,26 @@ import java.util.stream.Collectors;
 @Component
 public class VppSiteResourceHelper {
 
+    private static VppSiteResourceHelper instance;
+
     @Autowired
     private VppSiteMapper siteMapper;
 
+    @PostConstruct
+    void register() {
+        instance = this;
+    }
+
+    public static Integer resolveTenantIdByUnResourceId(String unResourceId) {
+        if (instance == null || !StringUtils.hasText(unResourceId)) {
+            return null;
+        }
+        VppSite site = instance.siteMapper.selectOne(new LambdaQueryWrapper<VppSite>()
+                .eq(VppSite::getUnResourceId, unResourceId)
+                .last("LIMIT 1"));
+        return site != null ? site.getTenantId() : null;
+    }
+
     public Map<Long, VppSite> loadSiteMap(List<VppResourcePoint> resources) {
         if (resources == null || resources.isEmpty()) {
             return Collections.emptyMap();

+ 45 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppUnCipherPayloadHelper.java

@@ -0,0 +1,45 @@
+package com.usky.vpp.util;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.usky.common.core.exception.BusinessException;
+import org.springframework.util.StringUtils;
+
+/**
+ * 运管平台 UN/DN 国密 HTTP 载荷解析(application/json 下密文常为 JSON 字符串)。
+ */
+public final class VppUnCipherPayloadHelper {
+
+    private VppUnCipherPayloadHelper() {
+    }
+
+    /**
+     * 从 HTTP body 提取 Base64 密文。
+     * <ul>
+     *   <li>JSON 字符串:{@code "MIIB..."}</li>
+     *   <li>裸 Base64:{@code MIIB...}</li>
+     * </ul>
+     */
+    public static String extractCipherPayload(String body, ObjectMapper objectMapper) {
+        if (!StringUtils.hasText(body)) {
+            return "";
+        }
+        String trimmed = body.trim();
+        if (trimmed.startsWith("\"")) {
+            try {
+                return objectMapper.readValue(trimmed, String.class);
+            } catch (Exception ex) {
+                throw new BusinessException("密文 JSON 字符串解析失败: " + ex.getMessage());
+            }
+        }
+        return trimmed;
+    }
+
+    /** 将 Base64 密文包装为合法 JSON 字符串请求体。 */
+    public static String wrapCipherPayload(String cipherBase64, ObjectMapper objectMapper) {
+        try {
+            return objectMapper.writeValueAsString(cipherBase64);
+        } catch (Exception ex) {
+            throw new BusinessException("密文 JSON 包装失败: " + ex.getMessage());
+        }
+    }
+}

+ 196 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppUnIntervalDataHelper.java

@@ -0,0 +1,196 @@
+package com.usky.vpp.util;
+
+import com.usky.vpp.service.VppTsdbQueryService;
+
+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.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * IntervalDataReport 明日预测曲线(15 分钟整点,按量测点类型均值/累加)。
+ */
+public final class VppUnIntervalDataHelper {
+
+    public static final int SLOT_MINUTES = VppUnMomentDataHelper.WINDOW_MINUTES;
+
+    private static final DateTimeFormatter TIME_KEY_FMT = DateTimeFormatter.ofPattern("HH:mm");
+
+    private VppUnIntervalDataHelper() {
+    }
+
+    public static LocalDate resolveForecastDate() {
+        return LocalDate.now().plusDays(1);
+    }
+
+    public static List<LocalDateTime> buildDayQuarterHourSlots(LocalDate forecastDate) {
+        List<LocalDateTime> slots = new ArrayList<>();
+        if (forecastDate == null) {
+            return slots;
+        }
+        LocalDateTime cursor = forecastDate.atStartOfDay();
+        LocalDateTime dayEnd = forecastDate.plusDays(1).atStartOfDay();
+        while (cursor.isBefore(dayEnd)) {
+            slots.add(cursor);
+            cursor = cursor.plusMinutes(SLOT_MINUTES);
+        }
+        return slots;
+    }
+
+    /**
+     * 基于典型历史日预测 forecastDate 全天 96 个 15 分钟点。
+     */
+    public static List<Map<String, Object>> buildForecastCurveValues(VppTsdbQueryService tsdbQueryService,
+                                                                     List<String> deviceUuids,
+                                                                     List<LocalDate> referenceDates,
+                                                                     LocalDate forecastDate,
+                                                                     VppUnRegisterMetricDefinitions.RegisterMetric registerMetric) {
+        List<LocalDateTime> slots = buildDayQuarterHourSlots(forecastDate);
+        if (slots.isEmpty()) {
+            return Collections.emptyList();
+        }
+        if (tsdbQueryService == null || deviceUuids == null || deviceUuids.isEmpty()
+                || referenceDates == null || referenceDates.isEmpty() || registerMetric == null) {
+            return buildZeroCurve(slots);
+        }
+        List<String> tsdbMetrics = VppUnMomentDataHelper.resolveTsdbMetrics(registerMetric);
+        if (tsdbMetrics.isEmpty()) {
+            return buildZeroCurve(slots);
+        }
+        List<String> distinctUuids = new ArrayList<>(new LinkedHashSet<>(deviceUuids));
+        if (VppUnMomentDataHelper.resolveAggregationMode(registerMetric)
+                == VppUnMomentDataHelper.AggregationMode.AVERAGE) {
+            return buildAverageForecastCurve(tsdbQueryService, distinctUuids, referenceDates, slots, tsdbMetrics);
+        }
+        return buildAccumulativeForecastCurve(tsdbQueryService, distinctUuids, referenceDates, slots, tsdbMetrics);
+    }
+
+    private static List<Map<String, Object>> buildAverageForecastCurve(
+            VppTsdbQueryService tsdbQueryService,
+            List<String> deviceUuids,
+            List<LocalDate> referenceDates,
+            List<LocalDateTime> slots,
+            List<String> tsdbMetrics) {
+        Map<String, BigDecimal> avgByTime = tsdbQueryService.queryBaselineAvgByTimePoint(
+                deviceUuids,
+                referenceDates,
+                LocalTime.MIN,
+                LocalTime.of(23, 59, 59),
+                tsdbMetrics,
+                SLOT_MINUTES,
+                null);
+        List<Map<String, Object>> values = new ArrayList<>(slots.size());
+        for (LocalDateTime slot : slots) {
+            String timeKey = slot.format(TIME_KEY_FMT);
+            BigDecimal value = avgByTime != null ? avgByTime.get(timeKey) : null;
+            values.add(VppUnMessageBuilder.buildIntervalCurveValue(scale(value), slot, "good"));
+        }
+        return values;
+    }
+
+    private static List<Map<String, Object>> buildAccumulativeForecastCurve(
+            VppTsdbQueryService tsdbQueryService,
+            List<String> deviceUuids,
+            List<LocalDate> referenceDates,
+            List<LocalDateTime> slots,
+            List<String> tsdbMetrics) {
+        Map<String, List<BigDecimal>> samplesByTimeKey = new TreeMap<>();
+        for (LocalDate referenceDate : referenceDates) {
+            LocalDateTime dayStart = referenceDate.atStartOfDay();
+            LocalDateTime dayEnd = referenceDate.plusDays(1).atStartOfDay();
+            Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> history =
+                    tsdbQueryService.queryDeviceMetricHistory(deviceUuids, dayStart, dayEnd, tsdbMetrics);
+            if (history == null || history.isEmpty()) {
+                continue;
+            }
+            for (LocalDateTime slot : slots) {
+                LocalDateTime slotStart = LocalDateTime.of(referenceDate, slot.toLocalTime());
+                LocalDateTime slotEnd = slotStart.plusMinutes(SLOT_MINUTES);
+                LocalDateTime endExclusive = slotEnd.plusSeconds(1);
+                BigDecimal slotTotal = BigDecimal.ZERO;
+                for (String deviceUuid : deviceUuids) {
+                    Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap = history.get(deviceUuid);
+                    if (metricMap == null || metricMap.isEmpty()) {
+                        continue;
+                    }
+                    TreeMap<LocalDateTime, BigDecimal> series = pickSeries(metricMap, tsdbMetrics);
+                    BigDecimal usage = VppEnergyUsageHelper.calcUsage(series, slotStart, endExclusive);
+                    if (usage != null) {
+                        slotTotal = slotTotal.add(usage);
+                    }
+                }
+                samplesByTimeKey.computeIfAbsent(slot.format(TIME_KEY_FMT), key -> new ArrayList<>())
+                        .add(slotTotal);
+            }
+        }
+        List<Map<String, Object>> values = new ArrayList<>(slots.size());
+        for (LocalDateTime slot : slots) {
+            List<BigDecimal> samples = samplesByTimeKey.get(slot.format(TIME_KEY_FMT));
+            values.add(VppUnMessageBuilder.buildIntervalCurveValue(
+                    scale(average(samples)), slot, "good"));
+        }
+        return values;
+    }
+
+    private static List<Map<String, Object>> buildZeroCurve(List<LocalDateTime> slots) {
+        List<Map<String, Object>> values = new ArrayList<>(slots.size());
+        for (LocalDateTime slot : slots) {
+            values.add(VppUnMessageBuilder.buildIntervalCurveValue(BigDecimal.ZERO, slot, "good"));
+        }
+        return values;
+    }
+
+    private static TreeMap<LocalDateTime, BigDecimal> pickSeries(Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap,
+                                                                 List<String> preferredMetrics) {
+        if (metricMap == null || metricMap.isEmpty()) {
+            return null;
+        }
+        if (preferredMetrics != null) {
+            for (String metric : preferredMetrics) {
+                TreeMap<LocalDateTime, BigDecimal> series = metricMap.get(metric);
+                if (series != null && !series.isEmpty()) {
+                    return series;
+                }
+            }
+        }
+        for (TreeMap<LocalDateTime, BigDecimal> series : metricMap.values()) {
+            if (series != null && !series.isEmpty()) {
+                return series;
+            }
+        }
+        return null;
+    }
+
+    private static BigDecimal average(List<BigDecimal> values) {
+        if (values == null || values.isEmpty()) {
+            return BigDecimal.ZERO;
+        }
+        BigDecimal sum = BigDecimal.ZERO;
+        int count = 0;
+        for (BigDecimal value : values) {
+            if (value != null) {
+                sum = sum.add(value);
+                count++;
+            }
+        }
+        if (count == 0) {
+            return BigDecimal.ZERO;
+        }
+        return sum.divide(BigDecimal.valueOf(count), 6, RoundingMode.HALF_UP);
+    }
+
+    private static BigDecimal scale(BigDecimal value) {
+        if (value == null) {
+            return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+        }
+        return value.setScale(2, RoundingMode.HALF_UP);
+    }
+}

+ 106 - 48
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppUnMessageBuilder.java

@@ -24,6 +24,8 @@ public final class VppUnMessageBuilder {
     private static final DateTimeFormatter CREATED_DATE_TIME =
             DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
 
+    private static final DateTimeFormatter MOMENT_DATE_TIME = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
+
     private VppUnMessageBuilder() {
     }
 
@@ -38,7 +40,7 @@ public final class VppUnMessageBuilder {
     public static Map<String, Object> buildCreateRegistrationRequest(VppUnProperties properties) {
         Map<String, Object> req = buildBaseRequest("CreateRegistrationRequest", properties);
         req.put("dnName", properties.getDnName());
-        if (properties.getRegistrationId() != null) {
+        if (StringUtils.hasText(properties.getRegistrationId())) {
             req.put("registrationID", properties.getRegistrationId());
         }
         req.put("reportOnly", false);
@@ -90,85 +92,141 @@ public final class VppUnMessageBuilder {
             resp.put("requestID", UUID.randomUUID().toString());
         }
         resp.put("dnID", properties.getDnId());
+        Map<String, Object> pendingReports = new LinkedHashMap<>();
+        List<String> reportRequestIds = new ArrayList<>();
+        reportRequestIds.add("MetaDataReport");
+        pendingReports.put("reportRequestID", reportRequestIds);
+        resp.put("pendingReports", pendingReports);
         return resp;
     }
 
-    public static Map<String, Object> buildRegisterReportRequest(List<VppResourcePoint> resources,
-                                                                 Map<Long, VppSite> siteMap,
-                                                                 VppUnProperties properties) {
+    /**
+     * 构造 RegisterReportRequest:虚拟电厂 13 项量测点 + 站点/资源点有功功率(kW)。
+     */
+    public static Map<String, Object> buildRegisterReportRequest(VppUnProperties properties,
+                                                                 List<VppSite> sites,
+                                                                 List<VppResourcePoint> resources,
+                                                                 Map<Long, String> deviceUnResourceIdMap) {
         Map<String, Object> req = buildBaseRequest("RegisterReportRequest", properties);
         req.put("reportRequestID", "MetaDataReport");
         List<Map<String, Object>> reports = new ArrayList<>();
-        int rid = 0;
-        if (resources != null) {
-            for (VppResourcePoint resource : resources) {
-                VppSite site = siteMap != null ? siteMap.get(resource.getSiteId()) : null;
-                reports.add(buildMetadataReportItem(resource, site, rid++, properties));
-            }
-        }
-        if (reports.isEmpty()) {
-            reports.add(buildDefaultVppMetadataReport(properties));
+        String createdDateTime = CREATED_DATE_TIME.format(LocalDateTime.now());
+        for (VppUnRegisterReportIndex.Entry entry : VppUnRegisterReportIndex.buildEntries(
+                properties, sites, resources, deviceUnResourceIdMap)) {
+            String resourceId = resolveRegisterResourceId(entry, properties, deviceUnResourceIdMap);
+            reports.add(buildVppMetadataReportItem(entry.getRid(), entry.getMetric(), resourceId, createdDateTime));
         }
         req.put("report", reports);
         return req;
     }
 
-    public static Map<String, Object> buildIntervalDataReportRequest(Map<String, Object> createReportRequest,
-                                                                     List<VppResourcePoint> resources,
-                                                                     Map<Long, VppSite> siteMap,
-                                                                     VppUnProperties properties) {
+    private static String resolveRegisterResourceId(VppUnRegisterReportIndex.Entry entry,
+                                                      VppUnProperties properties,
+                                                      Map<Long, String> deviceUnResourceIdMap) {
+        if (entry.getScope() == VppUnRegisterReportIndex.Scope.SITE) {
+            return entry.getSite().getUnResourceId();
+        }
+        if (entry.getScope() == VppUnRegisterReportIndex.Scope.RESOURCE) {
+            return deviceUnResourceIdMap.get(entry.getResource().getDeviceId());
+        }
+        return properties.getDnId();
+    }
+
+    public static Map<String, Object> buildIntervalDataReportRequest(VppUnProperties properties,
+                                                                     Map<String, Object> createReportRequest,
+                                                                     List<Map<String, Object>> pointCurveData,
+                                                                     LocalDateTime reportTime) {
         Map<String, Object> req = buildBaseRequest("IntervalDataReportRequest", properties);
         req.put("reportRequestID", resolveReportRequestIdFromPoll(createReportRequest, "IntervalDataReport"));
-        req.put("createdDateTime", CREATED_DATE_TIME.format(LocalDateTime.now()));
-        req.put("pointData", buildPointData(resources, siteMap, properties));
+        LocalDateTime createdAt = reportTime != null ? reportTime : LocalDateTime.now();
+        req.put("createdDateTime", MOMENT_DATE_TIME.format(createdAt));
+        req.put("pointCurveData", pointCurveData != null ? pointCurveData : new ArrayList<>());
+        return req;
+    }
+
+    public static Map<String, Object> buildIntervalCurveDataEntry(int rid, List<Map<String, Object>> values) {
+        Map<String, Object> entry = new LinkedHashMap<>();
+        entry.put("rID", rid);
+        Map<String, Object> irregular = new LinkedHashMap<>();
+        irregular.put("values", values != null ? values : new ArrayList<>());
+        entry.put("irregular", irregular);
+        return entry;
+    }
+
+    public static Map<String, Object> buildIntervalCurveValue(BigDecimal value,
+                                                              LocalDateTime timestamp,
+                                                              String quality) {
+        Map<String, Object> point = new LinkedHashMap<>();
+        point.put("value", value != null ? value : BigDecimal.ZERO);
+        point.put("timestamp", MOMENT_DATE_TIME.format(timestamp != null ? timestamp : LocalDateTime.now()));
+        point.put("quality", StringUtils.hasText(quality) ? quality : "good");
+        return point;
+    }
+
+    public static Map<String, Object> buildMomentDataReportRequest(VppUnProperties properties,
+                                                                   Map<String, Object> createReportRequest,
+                                                                   List<Map<String, Object>> pointData,
+                                                                   LocalDateTime reportTime) {
+        Map<String, Object> req = buildBaseRequest("MomentDataReportRequest", properties);
+        req.put("reportRequestID", resolveReportRequestIdFromPoll(createReportRequest, "MomentDataReport"));
+        LocalDateTime createdAt = reportTime != null ? reportTime : LocalDateTime.now();
+        req.put("createdDateTime", MOMENT_DATE_TIME.format(createdAt));
+        req.put("pointData", pointData != null ? pointData : new ArrayList<>());
         return req;
     }
 
-    public static Map<String, Object> buildMomentDataReportRequest(Map<String, Object> createReportRequest,
+    public static Map<String, Object> buildMomentDataPoint(int rid,
+                                                           BigDecimal value,
+                                                           LocalDateTime timestamp,
+                                                           String quality) {
+        Map<String, Object> point = new LinkedHashMap<>();
+        point.put("rID", rid);
+        point.put("value", value != null ? value : BigDecimal.ZERO);
+        point.put("timestamp", MOMENT_DATE_TIME.format(timestamp != null ? timestamp : LocalDateTime.now()));
+        point.put("quality", StringUtils.hasText(quality) ? quality : "good");
+        return point;
+    }
+
+    public static Map<String, Object> buildLoadForecastReportRequest(Map<String, Object> createReportRequest,
                                                                    List<VppResourcePoint> resources,
                                                                    Map<Long, VppSite> siteMap,
                                                                    VppUnProperties properties) {
-        Map<String, Object> req = buildBaseRequest("MomentDataReportRequest", properties);
-        req.put("reportRequestID", resolveReportRequestIdFromPoll(createReportRequest, "MomentDataReport"));
+        Map<String, Object> req = buildBaseRequest("LoadForecastReportRequest", properties);
+        req.put("reportRequestID", resolveReportRequestIdFromPoll(createReportRequest, "LoadForecastReport"));
         req.put("createdDateTime", CREATED_DATE_TIME.format(LocalDateTime.now()));
         req.put("pointData", buildPointData(resources, siteMap, properties));
         return req;
     }
 
-    private static Map<String, Object> buildDefaultVppMetadataReport(VppUnProperties properties) {
-        Map<String, Object> report = new LinkedHashMap<>();
-        report.put("createdDateTime", CREATED_DATE_TIME.format(LocalDateTime.now()));
-        Map<String, Object> description = new LinkedHashMap<>();
-        description.put("rID", 0);
-        Map<String, Object> metric = new LinkedHashMap<>();
-        metric.put("metricName", "AP");
-        metric.put("multiplier", "k");
-        metric.put("symbol", "W");
-        description.put("metric", metric);
-        Map<String, Object> dataSource = new LinkedHashMap<>();
-        dataSource.put("resourceID", java.util.Collections.singletonList(properties.getDnId()));
-        description.put("reportDataSource", dataSource);
-        description.put("readingType", "Summed");
-        report.put("reportDescription", description);
-        return report;
+    public static Map<String, Object> buildRegulateForecastReportRequest(Map<String, Object> createReportRequest,
+                                                                   List<VppResourcePoint> resources,
+                                                                   Map<Long, VppSite> siteMap,
+                                                                   VppUnProperties properties) {
+        Map<String, Object> req = buildBaseRequest("RegulateForecastReportRequest", properties);
+        req.put("reportRequestID", resolveReportRequestIdFromPoll(createReportRequest, "RegulateForecastReport"));
+        req.put("createdDateTime", CREATED_DATE_TIME.format(LocalDateTime.now()));
+        req.put("pointData", buildPointData(resources, siteMap, properties));
+        return req;
     }
 
-    private static Map<String, Object> buildMetadataReportItem(VppResourcePoint resource, VppSite site, int rid,
-                                                               VppUnProperties properties) {
+    private static Map<String, Object> buildVppMetadataReportItem(int rid,
+                                                                  VppUnRegisterMetricDefinitions.RegisterMetric metric,
+                                                                  String resourceId,
+                                                                  String createdDateTime) {
         Map<String, Object> report = new LinkedHashMap<>();
-        report.put("createdDateTime", CREATED_DATE_TIME.format(LocalDateTime.now()));
+        report.put("createdDateTime", createdDateTime);
         Map<String, Object> description = new LinkedHashMap<>();
         description.put("rID", rid);
-        Map<String, Object> metric = new LinkedHashMap<>();
-        metric.put("metricName", "AP");
-        metric.put("multiplier", "k");
-        metric.put("symbol", "W");
-        description.put("metric", metric);
+        Map<String, Object> metricBody = new LinkedHashMap<>();
+        metricBody.put("metricName", metric.getMetricName());
+        if (StringUtils.hasText(metric.getMultiplier())) {
+            metricBody.put("multiplier", metric.getMultiplier());
+        }
+        metricBody.put("symbol", metric.getSymbol());
+        description.put("metric", metricBody);
         Map<String, Object> dataSource = new LinkedHashMap<>();
-        String resourceId = resolveUnResourceId(resource, site, properties);
         dataSource.put("resourceID", java.util.Collections.singletonList(resourceId));
         description.put("reportDataSource", dataSource);
-        description.put("readingType", "Summed");
         report.put("reportDescription", description);
         return report;
     }

+ 170 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppUnMomentDataHelper.java

@@ -0,0 +1,170 @@
+package com.usky.vpp.util;
+
+import com.usky.vpp.constant.VppTsdbConstants;
+import com.usky.vpp.service.VppTsdbQueryService;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * MomentDataReport pointData 聚合(按量测点类型选择均值或累加)。
+ */
+public final class VppUnMomentDataHelper {
+
+    public static final int WINDOW_MINUTES = 15;
+
+    public enum AggregationMode {
+        /** 窗口内时序均值,多设备再求和(功率、能力等瞬时值) */
+        AVERAGE,
+        /** 窗口内累计量增量(末值 − 首值),多设备再求和(电能 Wh) */
+        ACCUMULATIVE
+    }
+
+    /**
+     * 将参考时间对齐到 00/15/30/45 整分作为窗口结束时间。
+     * 例:15:17 → 15:15;15:15 → 15:15。
+     */
+    public static LocalDateTime resolveWindowEnd(LocalDateTime referenceTime) {
+        LocalDateTime base = referenceTime != null ? referenceTime : LocalDateTime.now();
+        int flooredMinute = (base.getMinute() / WINDOW_MINUTES) * WINDOW_MINUTES;
+        return base.withMinute(flooredMinute).withSecond(0).withNano(0);
+    }
+
+    public static LocalDateTime resolveWindowStart(LocalDateTime windowEnd) {
+        return windowEnd != null ? windowEnd.minusMinutes(WINDOW_MINUTES) : null;
+    }
+
+    private VppUnMomentDataHelper() {
+    }
+
+    public static List<String> resolveTsdbMetrics(VppUnRegisterMetricDefinitions.RegisterMetric metric) {
+        if (metric == null) {
+            return Collections.emptyList();
+        }
+        switch (metric.getMetricName()) {
+            case "AP":
+                return VppBaselineHelper.POWER_METRICS;
+            case "AP_E":
+                return Collections.singletonList(VppTsdbConstants.METRIC_EPP);
+            case "AP_PE":
+                return Arrays.asList(VppTsdbConstants.METRIC_EPN, VppTsdbConstants.METRIC_EDAY);
+            case "REGULATE_UP":
+            case "F_REGULATE_UP":
+                return VppTsdbConstants.UP_CAPACITY_METRICS;
+            case "REGULATE_DOWN":
+            case "F_REGULATE_DOWN":
+                return VppTsdbConstants.DOWN_CAPACITY_METRICS;
+            default:
+                return Collections.emptyList();
+        }
+    }
+
+    public static AggregationMode resolveAggregationMode(VppUnRegisterMetricDefinitions.RegisterMetric metric) {
+        if (metric != null && "Wh".equals(metric.getSymbol())) {
+            return AggregationMode.ACCUMULATIVE;
+        }
+        return AggregationMode.AVERAGE;
+    }
+
+    /**
+     * 按量测点类型聚合:瞬时值类取窗口均值后跨设备求和;累计电能类取窗口增量后跨设备求和。
+     */
+    public static BigDecimal aggregateMetricValue(VppTsdbQueryService tsdbQueryService,
+                                                  List<String> deviceUuids,
+                                                  LocalDateTime windowStart,
+                                                  LocalDateTime windowEnd,
+                                                  VppUnRegisterMetricDefinitions.RegisterMetric registerMetric) {
+        if (tsdbQueryService == null || deviceUuids == null || deviceUuids.isEmpty() || registerMetric == null) {
+            return BigDecimal.ZERO;
+        }
+        List<String> tsdbMetrics = resolveTsdbMetrics(registerMetric);
+        if (tsdbMetrics.isEmpty()) {
+            return BigDecimal.ZERO;
+        }
+        AggregationMode mode = resolveAggregationMode(registerMetric);
+        List<String> distinctUuids = new ArrayList<>(new LinkedHashSet<>(deviceUuids));
+        Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> history =
+                tsdbQueryService.queryDeviceMetricHistory(distinctUuids, windowStart, windowEnd, tsdbMetrics);
+        if (history == null || history.isEmpty()) {
+            return BigDecimal.ZERO;
+        }
+        BigDecimal total = BigDecimal.ZERO;
+        for (String deviceUuid : distinctUuids) {
+            Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap = history.get(deviceUuid);
+            if (metricMap == null || metricMap.isEmpty()) {
+                continue;
+            }
+            TreeMap<LocalDateTime, BigDecimal> series = pickSeries(metricMap, tsdbMetrics);
+            if (series == null || series.isEmpty()) {
+                continue;
+            }
+            BigDecimal deviceValue = aggregateDeviceSeries(series, windowStart, windowEnd, mode);
+            if (deviceValue != null) {
+                total = total.add(deviceValue);
+            }
+        }
+        return scale(total);
+    }
+
+    private static BigDecimal aggregateDeviceSeries(TreeMap<LocalDateTime, BigDecimal> series,
+                                                    LocalDateTime windowStart,
+                                                    LocalDateTime windowEnd,
+                                                    AggregationMode mode) {
+        if (mode == AggregationMode.ACCUMULATIVE) {
+            LocalDateTime endExclusive = windowEnd != null ? windowEnd.plusSeconds(1) : null;
+            return VppEnergyUsageHelper.calcUsage(series, windowStart, endExclusive);
+        }
+        return average(series.values());
+    }
+
+    private static TreeMap<LocalDateTime, BigDecimal> pickSeries(Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap,
+                                                                 List<String> preferredMetrics) {
+        if (metricMap == null || metricMap.isEmpty()) {
+            return null;
+        }
+        if (preferredMetrics != null) {
+            for (String metric : preferredMetrics) {
+                TreeMap<LocalDateTime, BigDecimal> series = metricMap.get(metric);
+                if (series != null && !series.isEmpty()) {
+                    return series;
+                }
+            }
+        }
+        for (TreeMap<LocalDateTime, BigDecimal> series : metricMap.values()) {
+            if (series != null && !series.isEmpty()) {
+                return series;
+            }
+        }
+        return null;
+    }
+
+    private static BigDecimal average(Iterable<BigDecimal> values) {
+        BigDecimal sum = BigDecimal.ZERO;
+        int count = 0;
+        for (BigDecimal value : values) {
+            if (value != null) {
+                sum = sum.add(value);
+                count++;
+            }
+        }
+        if (count == 0) {
+            return BigDecimal.ZERO;
+        }
+        return sum.divide(BigDecimal.valueOf(count), 6, RoundingMode.HALF_UP);
+    }
+
+    private static BigDecimal scale(BigDecimal value) {
+        if (value == null) {
+            return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+        }
+        return value.setScale(2, RoundingMode.HALF_UP);
+    }
+}

+ 61 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppUnRegisterMetricDefinitions.java

@@ -0,0 +1,61 @@
+package com.usky.vpp.util;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 虚拟电厂 RegisterReport 量测点定义(DL/T 1867—2024 表31 MetricName)。
+ */
+public final class VppUnRegisterMetricDefinitions {
+
+    private static final String MULTIPLIER_K = "k";
+
+    public static final class RegisterMetric {
+        private final String metricName;
+        /** SI 前缀倍数,无倍数时为 null(JSON 中省略 multiplier)。 */
+        private final String multiplier;
+        private final String symbol;
+
+        public RegisterMetric(String metricName, String multiplier, String symbol) {
+            this.metricName = metricName;
+            this.multiplier = multiplier;
+            this.symbol = symbol;
+        }
+
+        public String getMetricName() {
+            return metricName;
+        }
+
+        public String getMultiplier() {
+            return multiplier;
+        }
+
+        public String getSymbol() {
+            return symbol;
+        }
+    }
+
+    /** 站点/资源点注册的有功功率量测点(kW)。 */
+    public static final RegisterMetric ACTIVE_POWER = new RegisterMetric("AP", MULTIPLIER_K, "W");
+
+    /** 虚拟电厂需注册的 13 项量测点,resourceID 统一为 dnID。 */
+    public static final List<RegisterMetric> VPP_REGISTER_METRICS = Collections.unmodifiableList(Arrays.asList(
+            ACTIVE_POWER,
+            new RegisterMetric("AP_E", MULTIPLIER_K, "Wh"),
+            new RegisterMetric("AP_PE", MULTIPLIER_K, "Wh"),
+            new RegisterMetric("REGULATE_UP", MULTIPLIER_K, "W"),
+            new RegisterMetric("REGULATE_DOWN", MULTIPLIER_K, "W"),
+            new RegisterMetric("RESPONSE_TIME", null, "s"),
+            new RegisterMetric("CONTROL_RATE", MULTIPLIER_K, "W/min"),
+            new RegisterMetric("DURATION", null, "min"),
+            new RegisterMetric("F_REGULATE_UP", MULTIPLIER_K, "W"),
+            new RegisterMetric("F_REGULATE_DOWN", MULTIPLIER_K, "W"),
+            new RegisterMetric("F_RESPONSE_TIME", null, "s"),
+            new RegisterMetric("F_CONTROL_RATE", MULTIPLIER_K, "W/min"),
+            new RegisterMetric("F_DURATION", null, "min")
+    ));
+
+    private VppUnRegisterMetricDefinitions() {
+    }
+}

+ 98 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppUnRegisterReportIndex.java

@@ -0,0 +1,98 @@
+package com.usky.vpp.util;
+
+import com.usky.vpp.config.VppUnProperties;
+import com.usky.vpp.domain.VppResourcePoint;
+import com.usky.vpp.domain.VppSite;
+import org.springframework.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * RegisterReport 量测点 rID 顺序索引(与 MetaDataReport 注册顺序一致)。
+ */
+public final class VppUnRegisterReportIndex {
+
+    public enum Scope {
+        VPP, SITE, RESOURCE
+    }
+
+    public static final class Entry {
+        private final int rid;
+        private final VppUnRegisterMetricDefinitions.RegisterMetric metric;
+        private final Scope scope;
+        private final VppSite site;
+        private final VppResourcePoint resource;
+
+        public Entry(int rid,
+                     VppUnRegisterMetricDefinitions.RegisterMetric metric,
+                     Scope scope,
+                     VppSite site,
+                     VppResourcePoint resource) {
+            this.rid = rid;
+            this.metric = metric;
+            this.scope = scope;
+            this.site = site;
+            this.resource = resource;
+        }
+
+        public int getRid() {
+            return rid;
+        }
+
+        public VppUnRegisterMetricDefinitions.RegisterMetric getMetric() {
+            return metric;
+        }
+
+        public Scope getScope() {
+            return scope;
+        }
+
+        public VppSite getSite() {
+            return site;
+        }
+
+        public VppResourcePoint getResource() {
+            return resource;
+        }
+    }
+
+    public static List<Entry> buildEntries(VppUnProperties properties,
+                                           List<VppSite> sites,
+                                           List<VppResourcePoint> resources,
+                                           Map<Long, String> deviceUnResourceIdMap) {
+        List<Entry> entries = new ArrayList<>();
+        int rid = 0;
+        for (VppUnRegisterMetricDefinitions.RegisterMetric metric
+                : VppUnRegisterMetricDefinitions.VPP_REGISTER_METRICS) {
+            entries.add(new Entry(rid++, metric, Scope.VPP, null, null));
+        }
+        if (sites != null) {
+            for (VppSite site : sites) {
+                if (site == null || !StringUtils.hasText(site.getUnResourceId())) {
+                    continue;
+                }
+                entries.add(new Entry(rid++, VppUnRegisterMetricDefinitions.ACTIVE_POWER,
+                        Scope.SITE, site, null));
+            }
+        }
+        if (resources != null && deviceUnResourceIdMap != null) {
+            for (VppResourcePoint resource : resources) {
+                if (resource == null || resource.getDeviceId() == null) {
+                    continue;
+                }
+                String resourceId = deviceUnResourceIdMap.get(resource.getDeviceId());
+                if (!StringUtils.hasText(resourceId)) {
+                    continue;
+                }
+                entries.add(new Entry(rid++, VppUnRegisterMetricDefinitions.ACTIVE_POWER,
+                        Scope.RESOURCE, null, resource));
+            }
+        }
+        return entries;
+    }
+
+    private VppUnRegisterReportIndex() {
+    }
+}

+ 19 - 5
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/web/advice/VppUnDnCryptoRequestAdvice.java

@@ -1,14 +1,16 @@
 package com.usky.vpp.web.advice;
 
-import com.usky.common.core.exception.BusinessException;
 import com.usky.vpp.controller.un.UnDnController;
 import com.usky.vpp.crypto.VppUnCryptoService;
+import com.usky.vpp.util.VppUnCipherPayloadHelper;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.core.MethodParameter;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpInputMessage;
 import org.springframework.http.converter.HttpMessageConverter;
 import org.springframework.util.StreamUtils;
+import org.springframework.util.StringUtils;
 import org.springframework.web.bind.annotation.ControllerAdvice;
 import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdviceAdapter;
 
@@ -27,10 +29,16 @@ public class VppUnDnCryptoRequestAdvice extends RequestBodyAdviceAdapter {
     @Autowired
     private VppUnCryptoService cryptoService;
 
+    @Autowired
+    private ObjectMapper objectMapper;
+
     @Override
     public boolean supports(MethodParameter methodParameter, Type targetType,
                             Class<? extends HttpMessageConverter<?>> converterType) {
-        return cryptoService.isActive();
+        if (isTokenRequest(methodParameter)) {
+            return false;
+        }
+        return cryptoService.isAnyActive();
     }
 
     @Override
@@ -45,13 +53,19 @@ public class VppUnDnCryptoRequestAdvice extends RequestBodyAdviceAdapter {
             return new DecodedHttpInputMessage(inputMessage.getHeaders(), bodyBytes);
         }
         String sign = inputMessage.getHeaders().getFirst("X-Sign");
-        if (!cryptoService.verifyInbound(body, sign)) {
-            throw new BusinessException("UN 请求验签失败");
+        if (!StringUtils.hasText(sign)) {
+            throw new com.usky.common.core.exception.BusinessException("UN 请求缺少 X-Sign");
         }
-        String plain = cryptoService.decryptInbound(body);
+        String cipherBase64 = VppUnCipherPayloadHelper.extractCipherPayload(body, objectMapper);
+        String plain = cryptoService.decryptInboundWithAutoTenant(cipherBase64, sign);
         return new DecodedHttpInputMessage(inputMessage.getHeaders(), plain.getBytes(StandardCharsets.UTF_8));
     }
 
+    private static boolean isTokenRequest(MethodParameter methodParameter) {
+        return methodParameter.getMethod() != null
+                && "tokenRequest".equals(methodParameter.getMethod().getName());
+    }
+
     private static class DecodedHttpInputMessage implements HttpInputMessage {
         private final HttpHeaders headers;
         private final byte[] body;

+ 13 - 6
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/web/advice/VppUnDnCryptoResponseAdvice.java

@@ -3,6 +3,8 @@ package com.usky.vpp.web.advice;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.usky.vpp.controller.un.UnDnController;
 import com.usky.vpp.crypto.VppUnCryptoService;
+import com.usky.vpp.crypto.VppUnSmCryptoUtil;
+import com.usky.vpp.util.VppUnCipherPayloadHelper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.core.MethodParameter;
 import org.springframework.http.MediaType;
@@ -26,7 +28,10 @@ public class VppUnDnCryptoResponseAdvice implements ResponseBodyAdvice<Object> {
 
     @Override
     public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
-        return cryptoService.isActive();
+        if (returnType.getMethod() != null && "tokenRequest".equals(returnType.getMethod().getName())) {
+            return false;
+        }
+        return cryptoService.isAnyActive();
     }
 
     @Override
@@ -38,12 +43,14 @@ public class VppUnDnCryptoResponseAdvice implements ResponseBodyAdvice<Object> {
         }
         try {
             String plainJson = body instanceof String ? (String) body : objectMapper.writeValueAsString(body);
-            String cipher = cryptoService.encryptOutbound(plainJson);
-            response.getHeaders().set("X-Sign", cryptoService.signOutbound(cipher));
-            response.getHeaders().setContentType(MediaType.TEXT_PLAIN);
-            return cipher;
+            VppUnSmCryptoUtil.EncryptedPayload payload = cryptoService.prepareOutbound(plainJson);
+            response.getHeaders().set("X-Sign", payload.getSignBase64());
+            response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
+            return VppUnCipherPayloadHelper.wrapCipherPayload(payload.getCipherBase64(), objectMapper);
+        } catch (com.usky.common.core.exception.BusinessException ex) {
+            throw ex;
         } catch (Exception ex) {
-            return body;
+            throw new com.usky.common.core.exception.BusinessException("DN 响应加密失败: " + ex.getMessage());
         }
     }
 }

+ 1 - 0
service-vpp/service-vpp-biz/src/main/resources/bootstrap.yml

@@ -18,6 +18,7 @@ spring:
         shared-configs:
           - application-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}
 
+
 #配置已经移到nacos中!
 # 运管平台 UN 对接(也可在 Nacos 中配置 vpp.un.*)
 #vpp: