Jelajahi Sumber

对接虚拟电厂开发需求

james 6 hari lalu
induk
melakukan
0cafca8103
29 mengubah file dengan 761 tambahan dan 178 penghapusan
  1. 6 0
      service-job/src/main/java/com/ruoyi/job/task/RyTask.java
  2. 4 0
      service-vpp/service-vpp-api/src/main/java/com/usky/vpp/RemoteVppTaskService.java
  3. 10 2
      service-vpp/service-vpp-api/src/main/java/com/usky/vpp/factory/RemoteVppTaskFactory.java
  4. 7 3
      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. 9 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/api/ServiceVppTaskApi.java
  11. 43 2
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/crypto/VppUnCryptoService.java
  12. 85 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppTenantConfig.java
  13. 0 47
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/job/VppUnBootstrapRunner.java
  14. 0 36
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/job/VppUnPollScheduler.java
  15. 61 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/job/VppUnPropertiesLoader.java
  16. 10 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/mapper/VppTenantConfigMapper.java
  17. 3 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppUnIntegrationService.java
  18. 14 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppUnPollTaskService.java
  19. 8 3
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrEventIngestServiceImpl.java
  20. 37 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnDnServiceImpl.java
  21. 26 12
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnIntegrationServiceImpl.java
  22. 44 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnPollTaskServiceImpl.java
  23. 32 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnReportServiceImpl.java
  24. 20 4
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppAuditHelper.java
  25. 20 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppSiteResourceHelper.java
  26. 28 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppUnMessageBuilder.java
  27. 2 6
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/web/advice/VppUnDnCryptoRequestAdvice.java
  28. 1 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/web/advice/VppUnDnCryptoResponseAdvice.java
  29. 1 13
      service-vpp/service-vpp-biz/src/main/resources/bootstrap.yml

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

@@ -151,4 +151,10 @@ public class RyTask {
         remoteVppTaskService.refreshSiteAvgCompletionRate();
 	}
 
+    // 运管平台 UN Poll 拉取事件/反馈
+    public void pollUn(Integer tenantId) {
+        System.out.println("租户:" + tenantId + " 运管平台 UN Poll 定时任务开始执行......");
+        remoteVppTaskService.pollUn(tenantId);
+    }
+
 }

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

@@ -3,10 +3,14 @@ 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);
 }

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

@@ -15,8 +15,16 @@ 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());
+            }
         };
     }
 }

+ 7 - 3
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/client/VppUnHttpExecutor.java

@@ -3,6 +3,8 @@ 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 org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -31,7 +33,7 @@ public class VppUnHttpExecutor {
     private static final Logger log = LoggerFactory.getLogger(VppUnHttpExecutor.class);
 
     @Autowired
-    private VppUnProperties properties;
+    private VppUnPropertiesRegistry propertiesRegistry;
 
     @Autowired
     private VppUnCryptoService cryptoService;
@@ -62,6 +64,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");
         }
@@ -77,11 +80,12 @@ public class VppUnHttpExecutor {
                 headers.set("X-Sign", cryptoService.signRequest(requestBody));
             }
             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, cryptoService.isActive(), withToken);
 
             ResponseEntity<String> response = restTemplate.exchange(
                     url, HttpMethod.POST, new HttpEntity<>(requestBody, headers), String.class);

+ 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("缺少租户上下文,无法获取运管平台配置");
+    }
+
+    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();
+        }
+    }
+}

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

@@ -2,6 +2,7 @@ package com.usky.vpp.controller.api;
 
 import com.usky.vpp.RemoteVppTaskService;
 import com.usky.vpp.service.VppSiteCompletionRateTaskService;
+import com.usky.vpp.service.VppUnPollTaskService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.RestController;
 
@@ -11,8 +12,16 @@ public class ServiceVppTaskApi implements RemoteVppTaskService {
     @Autowired
     private VppSiteCompletionRateTaskService siteCompletionRateTaskService;
 
+    @Autowired
+    private VppUnPollTaskService unPollTaskService;
+
     @Override
     public void refreshSiteAvgCompletionRate() {
         siteCompletionRateTaskService.refreshSiteAvgCompletionRate();
     }
+
+    @Override
+    public void pollUn(Integer tenantId) {
+        unPollTaskService.pollUn(tenantId);
+    }
 }

+ 43 - 2
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/crypto/VppUnCryptoService.java

@@ -5,6 +5,8 @@ import cn.hutool.crypto.SmUtil;
 import cn.hutool.crypto.asymmetric.KeyType;
 import cn.hutool.crypto.asymmetric.SM2;
 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;
@@ -22,26 +24,64 @@ 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())
+        VppUnProperties properties = currentProperties();
+        return isCryptoActive(properties);
+    }
+
+    public boolean isAnyActive() {
+        return propertiesRegistry.hasCryptoEnabledTenant();
+    }
+
+    public String decryptInboundWithAutoTenant(String cipherBase64, String signBase64) {
+        Integer tenantId = VppUnTenantContext.getTenantId();
+        if (tenantId != null) {
+            if (!verifyInbound(cipherBase64, signBase64)) {
+                throw new com.usky.common.core.exception.BusinessException("UN 请求验签失败");
+            }
+            return decryptInbound(cipherBase64);
+        }
+        for (VppUnProperties properties : propertiesRegistry.getAll()) {
+            if (!isCryptoActive(properties)) {
+                continue;
+            }
+            VppUnTenantContext.setTenantId(properties.getTenantId());
+            if (verifyInbound(cipherBase64, signBase64)) {
+                return decryptInbound(cipherBase64);
+            }
+            VppUnTenantContext.clear();
+        }
+        throw new com.usky.common.core.exception.BusinessException("UN 请求验签失败");
+    }
+
+    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());
     }
 
     public String encryptRequest(String plainJson) {
+        VppUnProperties properties = currentProperties();
         SM2 sm2 = SmUtil.sm2(null, properties.getUnPublicKey());
         return sm2.encryptBase64(plainJson, KeyType.PublicKey);
     }
 
     public String signRequest(String cipherBase64) {
+        VppUnProperties properties = currentProperties();
         SM2 sm2 = SmUtil.sm2(properties.getDnPrivateKey(), null);
         byte[] sign = sm2.sign(cipherBase64.getBytes(StandardCharsets.UTF_8));
         return Base64.encode(sign);
     }
 
     public String decryptResponse(String cipherBase64) {
+        VppUnProperties properties = currentProperties();
         SM2 sm2 = SmUtil.sm2(properties.getDnPrivateKey(), properties.getDnPublicKey());
         return sm2.decryptStr(cipherBase64, KeyType.PrivateKey);
     }
@@ -56,6 +96,7 @@ public class VppUnCryptoService {
             log.warn("UN 请求缺少 X-Sign,跳过验签");
             return true;
         }
+        VppUnProperties properties = currentProperties();
         SM2 sm2 = SmUtil.sm2(null, properties.getUnPublicKey());
         return sm2.verify(cipherBase64.getBytes(StandardCharsets.UTF_8), Base64.decode(signBase64));
     }

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

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

@@ -0,0 +1,61 @@
+package com.usky.vpp.job;
+
+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 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.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+/**
+ * 服务启动时从 vpp_tenant_config 加载 UN 配置至内存,并按需自动注册 UN
+ */
+@Component
+@Order(100)
+public class VppUnPropertiesLoader implements ApplicationRunner {
+
+    private static final Logger log = LoggerFactory.getLogger(VppUnPropertiesLoader.class);
+
+    @Autowired
+    private VppUnPropertiesRegistry propertiesRegistry;
+
+    @Autowired
+    private VppUnIntegrationService integrationService;
+
+    @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;
+            }
+            Integer tenantId = properties.getTenantId();
+            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());
+                }
+            });
+        }
+    }
+}

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

+ 8 - 3
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrEventIngestServiceImpl.java

@@ -52,8 +52,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;
     }
@@ -143,7 +144,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)) {
@@ -163,6 +164,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);
@@ -191,7 +196,7 @@ public class VppDrEventIngestServiceImpl implements VppDrEventIngestService {
                 unDrSyncService.syncTargetResources(event, resources, false);
                 break;
             case SPLIT_NOTICE:
-                event.setEventStatus(EVENT_STATUS_DECLARED);
+                event.setEventStatus(EVENT_STATUS_PENDING);
                 unDrSyncService.syncTargetResources(event, resources, true);
                 break;
             case SPLIT_RESULT:

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

+ 26 - 12
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,10 +175,7 @@ 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;
         }
@@ -182,6 +188,10 @@ public class VppUnIntegrationServiceImpl implements VppUnIntegrationService {
         }
     }
 
+    private VppUnProperties currentProperties() {
+        return propertiesRegistry.getCurrent();
+    }
+
     private void maybeAutoAckClearing(VppDrEvent event) {
         if (event.getEventStatus() != null && event.getEventStatus() == 1
                 && event.getClearedCapacityKw() != null
@@ -217,14 +227,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 +246,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 +269,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/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());
+            }
+        });
+    }
+}

+ 32 - 1
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnReportServiceImpl.java

@@ -3,6 +3,7 @@ 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.domain.VppResourcePoint;
 import com.usky.vpp.domain.VppSite;
 import com.usky.vpp.mapper.VppResourcePointMapper;
@@ -26,7 +27,7 @@ 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
@@ -36,6 +37,7 @@ public class VppUnReportServiceImpl implements VppUnReportService {
 
     @Override
     public void handleCreateReportRequest(Map<String, Object> createReportRequest) {
+        VppUnProperties properties = propertiesRegistry.getCurrent();
         if (!properties.isOutboundActive()) {
             return;
         }
@@ -58,6 +60,12 @@ public class VppUnReportServiceImpl implements VppUnReportService {
             case "MomentDataReport":
                 submitMomentDataReport(createReportRequest);
                 break;
+//            case "LoadForecastReport":
+//                submitLoadForecastReport(createReportRequest);
+//                break;
+//            case "RegulateForecastReport":
+//                submitRegulateForecastReport(createReportRequest);
+//                break;
             default:
                 log.info("暂不支持的报告类型: {}", reportRequestId);
                 break;
@@ -65,6 +73,7 @@ public class VppUnReportServiceImpl implements VppUnReportService {
     }
 
     private void submitRegisterReport() {
+        VppUnProperties properties = propertiesRegistry.getCurrent();
         List<VppResourcePoint> resources = listActiveResources(false);
         Map<Long, VppSite> siteMap = siteResourceHelper.loadSiteMap(resources);
         Map<String, Object> request = VppUnMessageBuilder.buildRegisterReportRequest(resources, siteMap, properties);
@@ -73,6 +82,7 @@ public class VppUnReportServiceImpl implements VppUnReportService {
     }
 
     private void submitIntervalDataReport(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(
@@ -82,6 +92,7 @@ public class VppUnReportServiceImpl implements VppUnReportService {
     }
 
     private void submitMomentDataReport(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(
@@ -90,6 +101,26 @@ public class VppUnReportServiceImpl implements VppUnReportService {
         log.info("已提交 MomentDataReportRequest");
     }
 
+    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.buildLoadForecastReportRequest(
+                createReportRequest, resources, siteMap, properties);
+        unClient.post("LoadForecastReportRequest", request, true);
+        log.info("已提交 LoadForecastReportRequest");
+    }
+
+    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.buildRegulateForecastReportRequest(
+                createReportRequest, resources, siteMap, properties);
+        unClient.post("RegulateForecastReportRequest", request, true);
+        log.info("已提交 RegulateForecastReportRequest");
+    }
+
     private List<VppResourcePoint> listActiveResources(boolean controllableOnly) {
         LambdaQueryWrapper<VppResourcePoint> wrapper = new LambdaQueryWrapper<VppResourcePoint>()
                 .eq(VppResourcePoint::getDeleteFlag, VppAuditHelper.NOT_DELETED)

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

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

+ 28 - 1
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppUnMessageBuilder.java

@@ -38,7 +38,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,6 +90,11 @@ 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;
     }
 
@@ -135,6 +140,28 @@ public final class VppUnMessageBuilder {
         return req;
     }
 
+    public static Map<String, Object> buildLoadForecastReportRequest(Map<String, Object> createReportRequest,
+                                                                   List<VppResourcePoint> resources,
+                                                                   Map<Long, VppSite> siteMap,
+                                                                   VppUnProperties properties) {
+        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;
+    }
+
+    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> buildDefaultVppMetadataReport(VppUnProperties properties) {
         Map<String, Object> report = new LinkedHashMap<>();
         report.put("createdDateTime", CREATED_DATE_TIME.format(LocalDateTime.now()));

+ 2 - 6
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/web/advice/VppUnDnCryptoRequestAdvice.java

@@ -1,6 +1,5 @@
 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 org.springframework.beans.factory.annotation.Autowired;
@@ -30,7 +29,7 @@ public class VppUnDnCryptoRequestAdvice extends RequestBodyAdviceAdapter {
     @Override
     public boolean supports(MethodParameter methodParameter, Type targetType,
                             Class<? extends HttpMessageConverter<?>> converterType) {
-        return cryptoService.isActive();
+        return cryptoService.isAnyActive();
     }
 
     @Override
@@ -45,10 +44,7 @@ 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 请求验签失败");
-        }
-        String plain = cryptoService.decryptInbound(body);
+        String plain = cryptoService.decryptInboundWithAutoTenant(body, sign);
         return new DecodedHttpInputMessage(inputMessage.getHeaders(), plain.getBytes(StandardCharsets.UTF_8));
     }
 

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

@@ -26,7 +26,7 @@ public class VppUnDnCryptoResponseAdvice implements ResponseBodyAdvice<Object> {
 
     @Override
     public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
-        return cryptoService.isActive();
+        return cryptoService.isAnyActive();
     }
 
     @Override

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

@@ -18,20 +18,8 @@ spring:
         shared-configs:
           - application-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}
 
-# 运管平台 UN 对接(也可在 Nacos 中配置 vpp.un.*)
+# 运管平台 UN 对接配置已迁移至表 vpp_tenant_config,服务启动时加载至内存
 vpp:
-  un:
-    outbound-enabled: false
-    poll-enabled: false
-    crypto-enabled: false
-    poll-interval-sec: 10
-    price-down-coeff: "0.8"
-    token-header: Authorization
-    token-prefix: "Bearer "
-    token-ttl-minutes: 25
-    auto-ack-clearing: false
-    auto-register-on-startup: false
-  # 阿里云短信(密钥复用 Nacos 共享配置 sms.ali.*)
   sms:
     enabled: true
     sign-name: 上海永天科技股份有限公司