소스 검색

service-vpp模块代码更新

hanzhengyi 2 일 전
부모
커밋
f8932de95b
25개의 변경된 파일1002개의 추가작업 그리고 20개의 파일을 삭제
  1. 4 2
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/controller/SipPushController.java
  2. 10 1
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/SipRtspPushService.java
  3. 8 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/config/SipClientProperties.java
  4. 1 1
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/config/SipServerProperties.java
  5. 101 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/device/Gb28181CatalogBuilder.java
  6. 162 14
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/device/Gb28181DeviceSimulator.java
  7. 8 1
      service-cdi/service-cdi-biz/src/main/resources/application-gb28181-video.yml
  8. 85 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/BiddingConfigController.java
  9. 62 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppBiddingConfig.java
  10. 3 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDrParticipation.java
  11. 10 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/mapper/VppBiddingConfigMapper.java
  12. 27 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppBiddingConfigService.java
  13. 253 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppBiddingConfigServiceImpl.java
  14. 3 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrInvitationServiceImpl.java
  15. 7 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrServiceImpl.java
  16. 2 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnDrSyncServiceImpl.java
  17. 28 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BiddingConfigRequest.java
  18. 13 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BiddingConfigSiteVO.java
  19. 34 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BiddingConfigVO.java
  20. 2 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrParticipationVO.java
  21. 56 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppBiddingConfigHelper.java
  22. 37 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppDrParticipationHelper.java
  23. 35 0
      service-vpp/service-vpp-biz/src/main/resources/sql/vpp_bidding_config_migration.sql
  24. 24 0
      service-vpp/service-vpp-biz/src/main/resources/sql/vpp_dr_participation_completion_rate_migration.sql
  25. 27 1
      service-vpp/service-vpp-biz/src/main/resources/sql/vpp_schema.sql

+ 4 - 2
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/controller/SipPushController.java

@@ -24,8 +24,10 @@ public class SipPushController {
 
     /** 手动注册设备到 GB28181 平台(deviceId 动态传入,20 位数字) */
     @PostMapping("/device/register")
-    public Map<String, Object> registerDevice(@RequestParam String deviceId) {
-        return sipRtspPushService.registerDevice(deviceId);
+    public Map<String, Object> registerDevice(
+            @RequestParam String deviceId,
+            @RequestParam(required = false) String channelId) {
+        return sipRtspPushService.registerDevice(deviceId, channelId);
     }
 
     /** 手动注销设备(REGISTER Expires=0) */

+ 10 - 1
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/SipRtspPushService.java

@@ -36,8 +36,17 @@ public class SipRtspPushService {
     }
 
     public Map<String, Object> registerDevice(String deviceId) {
+        return registerDevice(deviceId, null);
+    }
+
+    public Map<String, Object> registerDevice(String deviceId, String channelId) {
         try {
-            return deviceSimulator.register(deviceId);
+            Map<String, Object> status = deviceSimulator.register(deviceId, channelId);
+            if (!Boolean.TRUE.equals(status.get("registered"))) {
+                Object err = status.get("lastRegisterError");
+                throw new BusinessException(err != null ? err.toString() : "GB28181 注册失败,请检查密码与平台设备配置");
+            }
+            return status;
         } catch (IllegalArgumentException | IllegalStateException e) {
             throw new BusinessException(e.getMessage());
         }

+ 8 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/config/SipClientProperties.java

@@ -25,8 +25,16 @@ public class SipClientProperties {
     private String password = "jkjj_wlgz";
     private String sipTransport = "tcp";
     private int registerExpires = 3600;
+    /** 等待 REGISTER 最终响应超时(秒) */
+    private int registerTimeout = 15;
+    /**
+     * REGISTER Request-URI 用户部分:platform=国标默认(平台编码),device=部分平台兼容模式(设备编码)
+     */
+    private String registerUriUser = "platform";
 
     private int inviteWaitSeconds = 300;
     private int defaultDurationSeconds = 60;
     private String rtspTransport = "tcp";
+    /** 通道名称(Catalog 上报),默认 Channel-1 */
+    private String channelName = "Channel-1";
 }

+ 1 - 1
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/config/SipServerProperties.java

@@ -10,7 +10,7 @@ import org.springframework.stereotype.Component;
 public class SipServerProperties {
 
     private boolean enabled = true;
-    private String host = "0.0.0.0";
+    private String host = "192.168.0.100";
     private int port = 5060;
     private String localIp;
     private String platformId = "34020000002000000001";

+ 101 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/device/Gb28181CatalogBuilder.java

@@ -0,0 +1,101 @@
+package com.usky.cdi.service.sip.device;
+
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * GB28181 MANSCDP+xml 目录应答(Catalog / DeviceInfo)。
+ */
+final class Gb28181CatalogBuilder {
+
+    private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
+
+    private Gb28181CatalogBuilder() {
+    }
+
+    static String buildCatalogResponse(String sn, String deviceId, String channelId, String channelName) {
+        String now = LocalDateTime.now().format(TIME_FMT);
+        String name = StringUtils.hasText(channelName) ? channelName : "Channel-1";
+        return "<?xml version=\"1.0\" encoding=\"GB2312\"?>\r\n"
+                + "<Response>\r\n"
+                + "<CmdType>Catalog</CmdType>\r\n"
+                + "<SN>" + escapeXml(sn) + "</SN>\r\n"
+                + "<DeviceID>" + escapeXml(deviceId) + "</DeviceID>\r\n"
+                + "<SumNum>1</SumNum>\r\n"
+                + "<DeviceList Num=\"1\">\r\n"
+                + "<Item>\r\n"
+                + "<DeviceID>" + escapeXml(channelId) + "</DeviceID>\r\n"
+                + "<Name>" + escapeXml(name) + "</Name>\r\n"
+                + "<Manufacturer>USky</Manufacturer>\r\n"
+                + "<Model>Gb28181Simulator</Model>\r\n"
+                + "<Owner>Owner</Owner>\r\n"
+                + "<CivilCode>" + civilCode(deviceId) + "</CivilCode>\r\n"
+                + "<Address>Simulator</Address>\r\n"
+                + "<Parental>0</Parental>\r\n"
+                + "<ParentID>" + escapeXml(deviceId) + "</ParentID>\r\n"
+                + "<SafetyWay>0</SafetyWay>\r\n"
+                + "<RegisterWay>1</RegisterWay>\r\n"
+                + "<Secrecy>0</Secrecy>\r\n"
+                + "<Status>ON</Status>\r\n"
+                + "<Event>ADD</Event>\r\n"
+                + "<RegisterTime>" + now + "</RegisterTime>\r\n"
+                + "</Item>\r\n"
+                + "</DeviceList>\r\n"
+                + "</Response>\r\n";
+    }
+
+    static String buildDeviceInfoResponse(String sn, String deviceId) {
+        String now = LocalDateTime.now().format(TIME_FMT);
+        return "<?xml version=\"1.0\" encoding=\"GB2312\"?>\r\n"
+                + "<Response>\r\n"
+                + "<CmdType>DeviceInfo</CmdType>\r\n"
+                + "<SN>" + escapeXml(sn) + "</SN>\r\n"
+                + "<DeviceID>" + escapeXml(deviceId) + "</DeviceID>\r\n"
+                + "<Result>OK</Result>\r\n"
+                + "<DeviceName>Gb28181Simulator</DeviceName>\r\n"
+                + "<Manufacturer>USky</Manufacturer>\r\n"
+                + "<Model>Gb28181Simulator</Model>\r\n"
+                + "<Firmware>1.0</Firmware>\r\n"
+                + "<Channel>1</Channel>\r\n"
+                + "<RegisterTime>" + now + "</RegisterTime>\r\n"
+                + "</Response>\r\n";
+    }
+
+    static String extractXmlValue(String xml, String tag) {
+        if (!StringUtils.hasText(xml) || !StringUtils.hasText(tag)) {
+            return null;
+        }
+        String open = "<" + tag + ">";
+        String close = "</" + tag + ">";
+        int start = xml.indexOf(open);
+        if (start < 0) {
+            return null;
+        }
+        start += open.length();
+        int end = xml.indexOf(close, start);
+        if (end < 0) {
+            return null;
+        }
+        return xml.substring(start, end).trim();
+    }
+
+    private static String civilCode(String deviceId) {
+        if (deviceId != null && deviceId.length() >= 6) {
+            return deviceId.substring(0, 6);
+        }
+        return "340200";
+    }
+
+    private static String escapeXml(String value) {
+        if (value == null) {
+            return "";
+        }
+        return value.replace("&", "&amp;")
+                .replace("<", "&lt;")
+                .replace(">", "&gt;")
+                .replace("\"", "&quot;")
+                .replace("'", "&apos;");
+    }
+}

+ 162 - 14
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/device/Gb28181DeviceSimulator.java

@@ -67,6 +67,10 @@ public class Gb28181DeviceSimulator implements SipListener {
     private volatile Future<?> streamingTask;
     private volatile ScheduledFuture<?> reRegisterTask;
     private volatile String activeDeviceId;
+    /** Catalog 上报的通道 ID,IPC 默认与 deviceId 相同 */
+    private volatile String activeChannelId;
+    private volatile CountDownLatch registerLatch;
+    private volatile String lastRegisterError;
 
     public Gb28181DeviceSimulator(SipClientProperties properties) {
         this.properties = properties;
@@ -98,9 +102,21 @@ public class Gb28181DeviceSimulator implements SipListener {
      * @param deviceId 20 位设备编码(动态传入)
      */
     public synchronized Map<String, Object> register(String deviceId) {
+        return register(deviceId, null);
+    }
+
+    /**
+     * 注册到 GB28181 平台。
+     *
+     * @param deviceId  20 位设备编码
+     * @param channelId 可选通道编码;为空时与 deviceId 相同(单路 IPC)
+     */
+    public synchronized Map<String, Object> register(String deviceId, String channelId) {
         validateDeviceId(deviceId);
-        if (registered.get() && deviceId.equals(activeDeviceId)) {
-            log.info("设备已注册: deviceId={}", activeDeviceId);
+        String resolvedChannelId = resolveChannelId(deviceId, channelId);
+        if (registered.get() && deviceId.equals(activeDeviceId)
+                && resolvedChannelId.equals(activeChannelId)) {
+            log.info("设备已注册: deviceId={}, channelId={}", activeDeviceId, activeChannelId);
             return status();
         }
         if (sipStack != null) {
@@ -108,16 +124,22 @@ public class Gb28181DeviceSimulator implements SipListener {
         }
         try {
             activeDeviceId = deviceId.trim();
+            activeChannelId = resolvedChannelId;
             initStack();
             registerAuthSent.set(false);
             authChallenge.set(null);
+            lastRegisterError = null;
+            registerLatch = new CountDownLatch(1);
             sendRegister(false, properties.getRegisterExpires());
             scheduleReRegister();
-            log.info("GB28181 注册请求已发送: deviceId={}, platform={}@{}:{}, transport={}",
-                    activeDeviceId, properties.getPlatformId(),
-                    properties.getServerHost(), properties.getServerPort(), sipTransport);
+            log.info("GB28181 注册请求已发送: deviceId={}, channelId={}, platform={}@{}:{}, transport={}, passwordLen={}",
+                    activeDeviceId, activeChannelId, properties.getPlatformId(),
+                    properties.getServerHost(), properties.getServerPort(), sipTransport,
+                    passwordLength());
+            awaitRegisterResult();
         } catch (Exception e) {
             activeDeviceId = null;
+            activeChannelId = null;
             shutdownStack();
             sipStack = null;
             sipProvider = null;
@@ -149,6 +171,7 @@ public class Gb28181DeviceSimulator implements SipListener {
         }
         registered.set(false);
         activeDeviceId = null;
+        activeChannelId = null;
         boundRtspUrl.set(null);
         pendingStream.set(null);
         shutdownStack();
@@ -197,6 +220,7 @@ public class Gb28181DeviceSimulator implements SipListener {
         Map<String, Object> map = new HashMap<>();
         map.put("registered", registered.get());
         map.put("deviceId", activeDeviceId);
+        map.put("channelId", activeChannelId);
         map.put("platformId", properties.getPlatformId());
         map.put("serverHost", properties.getServerHost());
         map.put("serverPort", properties.getServerPort());
@@ -205,9 +229,44 @@ public class Gb28181DeviceSimulator implements SipListener {
         map.put("localSipAddress", localIp != null ? localIp + ":" + localSipPort : null);
         map.put("boundRtspUrl", boundRtspUrl.get());
         map.put("streaming", streamingTask != null && !streamingTask.isDone());
+        map.put("lastRegisterError", lastRegisterError);
+        map.put("passwordConfigured", StringUtils.hasText(properties.getPassword()));
         return map;
     }
 
+    private void awaitRegisterResult() {
+        CountDownLatch latch = registerLatch;
+        if (latch == null) {
+            return;
+        }
+        try {
+            int timeout = Math.max(5, properties.getRegisterTimeout());
+            if (!latch.await(timeout, TimeUnit.SECONDS)) {
+                lastRegisterError = "注册超时(" + timeout + "s 内未收到平台最终响应)";
+                log.warn(lastRegisterError);
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            lastRegisterError = "注册等待被中断";
+        }
+    }
+
+    private void completeRegisterAttempt(int status, String error) {
+        if (status == Response.OK) {
+            lastRegisterError = null;
+        } else if (StringUtils.hasText(error)) {
+            lastRegisterError = error;
+        }
+        CountDownLatch latch = registerLatch;
+        if (latch != null) {
+            latch.countDown();
+        }
+    }
+
+    private int passwordLength() {
+        return properties.getPassword() != null ? properties.getPassword().length() : 0;
+    }
+
     private void initStack() throws Exception {
         SipFactory sipFactory = SipFactory.getInstance();
         sipFactory.setPathName("gov.nist");
@@ -255,15 +314,15 @@ public class Gb28181DeviceSimulator implements SipListener {
 
     private void sendRegister(boolean withAuth, int expires) throws Exception {
         ensureActiveDeviceId();
-        SipURI requestUri = createUri(activeDeviceId, properties.getDomain());
-        requestUri.setPort(properties.getServerPort());
-        requestUri.setTransportParam(sipTransport);
+        String uriUser = resolveRegisterUriUser();
+        SipURI requestUri = createUri(uriUser, properties.getDomain());
+        String digestUri = "sip:" + uriUser + "@" + properties.getDomain();
 
         Request register = buildRequest(Request.REGISTER, requestUri, activeDeviceId, "reg-tag");
         register.addHeader(headerFactory.createExpiresHeader(expires));
 
         if (withAuth) {
-            AuthorizationHeader auth = buildAuthorization(Request.REGISTER, requestUri.toString());
+            AuthorizationHeader auth = buildAuthorization(Request.REGISTER, digestUri);
             if (auth != null) {
                 register.addHeader(auth);
             }
@@ -271,7 +330,16 @@ public class Gb28181DeviceSimulator implements SipListener {
 
         ClientTransaction ct = sipProvider.getNewClientTransaction(register);
         ct.sendRequest();
-        log.debug("发送 REGISTER{} -> {}:{}", withAuth ? "(鉴权)" : "", properties.getServerHost(), properties.getServerPort());
+        log.info("发送 REGISTER{} uri={} mode={} deviceId={} -> {}:{}",
+                withAuth ? "(鉴权)" : "", digestUri, properties.getRegisterUriUser(), activeDeviceId,
+                properties.getServerHost(), properties.getServerPort());
+    }
+
+    private String resolveRegisterUriUser() {
+        if ("device".equalsIgnoreCase(properties.getRegisterUriUser())) {
+            return activeDeviceId;
+        }
+        return properties.getPlatformId();
     }
 
     private void handleIncomingInvite(RequestEvent event) throws Exception {
@@ -414,6 +482,51 @@ public class Gb28181DeviceSimulator implements SipListener {
         });
     }
 
+    private void handleIncomingMessage(RequestEvent event) throws Exception {
+        Request request = event.getRequest();
+        String body = extractSdp(request);
+        if (!StringUtils.hasText(body)) {
+            sendResponse(event, Response.BAD_REQUEST);
+            return;
+        }
+
+        String cmdType = Gb28181CatalogBuilder.extractXmlValue(body, "CmdType");
+        String sn = Gb28181CatalogBuilder.extractXmlValue(body, "SN");
+        if (!StringUtils.hasText(sn)) {
+            sn = "1";
+        }
+
+        sendResponse(event, Response.OK);
+
+        if ("Catalog".equalsIgnoreCase(cmdType)) {
+            String catalogXml = Gb28181CatalogBuilder.buildCatalogResponse(
+                    sn, activeDeviceId, activeChannelId, properties.getChannelName());
+            sendManscdpMessage(catalogXml);
+            log.info("已应答 Catalog 查询: deviceId={}, channelId={}", activeDeviceId, activeChannelId);
+        } else if ("DeviceInfo".equalsIgnoreCase(cmdType)) {
+            String infoXml = Gb28181CatalogBuilder.buildDeviceInfoResponse(sn, activeDeviceId);
+            sendManscdpMessage(infoXml);
+            log.info("已应答 DeviceInfo 查询: deviceId={}", activeDeviceId);
+        } else {
+            log.debug("忽略未处理的 MESSAGE CmdType={}", cmdType);
+        }
+    }
+
+    private void sendManscdpMessage(String xmlBody) throws Exception {
+        ensureActiveDeviceId();
+        SipURI requestUri = createUri(properties.getPlatformId(), properties.getDomain());
+        requestUri.setPort(properties.getServerPort());
+        requestUri.setTransportParam(sipTransport);
+
+        Request message = buildRequest(Request.MESSAGE, requestUri, activeDeviceId, "msg-tag");
+        message.setContent(xmlBody.getBytes(StandardCharsets.UTF_8),
+                headerFactory.createContentTypeHeader("Application", "MANSCDP+xml"));
+
+        ClientTransaction ct = sipProvider.getNewClientTransaction(message);
+        ct.sendRequest();
+        log.debug("发送 MESSAGE (MANSCDP+xml) -> {}:{}", properties.getServerHost(), properties.getServerPort());
+    }
+
     private void handleIncomingBye(RequestEvent event) throws Exception {
         log.info("收到平台 BYE");
         if (streamingTask != null) {
@@ -467,9 +580,10 @@ public class Gb28181DeviceSimulator implements SipListener {
         }
         String cnonce = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
         String nc = "00000001";
+        String qop = resolveQop(challenge.getQop());
         String response = SipDigestAuth.computeResponse(
                 activeDeviceId, properties.getPassword(), challenge.getRealm(),
-                method, uri, challenge.getNonce(), nc, cnonce, challenge.getQop());
+                method, uri, challenge.getNonce(), nc, cnonce, qop);
 
         AuthorizationHeader auth = headerFactory.createAuthorizationHeader("Digest");
         auth.setUsername(activeDeviceId);
@@ -478,14 +592,22 @@ public class Gb28181DeviceSimulator implements SipListener {
         auth.setURI(addressFactory.createURI(uri));
         auth.setResponse(response);
         auth.setAlgorithm("MD5");
-        if (challenge.getQop() != null && !challenge.getQop().isEmpty()) {
-            auth.setQop(challenge.getQop());
+        if (qop != null && !qop.isEmpty()) {
+            auth.setQop(qop);
             auth.setCNonce(cnonce);
             auth.setNonceCount(Integer.parseInt(nc, 16));
         }
         return auth;
     }
 
+    private static String resolveQop(String qop) {
+        if (qop == null || qop.isEmpty()) {
+            return null;
+        }
+        int comma = qop.indexOf(',');
+        return comma > 0 ? qop.substring(0, comma).trim() : qop.trim();
+    }
+
     private void sendResponse(RequestEvent event, int code) throws Exception {
         Response response = messageFactory.createResponse(code, event.getRequest());
         if (code >= 200) {
@@ -561,6 +683,14 @@ public class Gb28181DeviceSimulator implements SipListener {
         }
     }
 
+    private static String resolveChannelId(String deviceId, String channelId) {
+        if (StringUtils.hasText(channelId)) {
+            validateDeviceId(channelId);
+            return channelId.trim();
+        }
+        return deviceId.trim();
+    }
+
     private void shutdownStack() {
         try {
             if (sipProvider != null) {
@@ -589,6 +719,7 @@ public class Gb28181DeviceSimulator implements SipListener {
                 challenge = (WWWAuthenticateHeader) event.getResponse().getHeader(ProxyAuthenticateHeader.NAME);
             }
             authChallenge.set(challenge);
+            log.info("收到 REGISTER 401 挑战, realm={}", challenge != null ? challenge.getRealm() : "unknown");
             if (!registerAuthSent.getAndSet(true)) {
                 try {
                     ExpiresHeader expires = (ExpiresHeader) ct.getRequest().getHeader(ExpiresHeader.NAME);
@@ -607,9 +738,24 @@ public class Gb28181DeviceSimulator implements SipListener {
                 registered.set(true);
                 log.info("GB28181 注册成功: deviceId={}(平台应可见设备在线)", activeDeviceId);
             }
+            completeRegisterAttempt(status, null);
         } else if (status >= 300) {
             registered.set(false);
-            log.warn("GB28181 注册失败: deviceId={}, status={}", activeDeviceId, status);
+            String error;
+            if (status == Response.FORBIDDEN) {
+                error = "平台拒绝注册(403):密码错误或设备未在平台添加,当前 passwordLen="
+                        + passwordLength() + " registerUriUser=" + properties.getRegisterUriUser();
+                log.warn("GB28181 注册失败 403: deviceId={}, platformId={}, domain={}, passwordLen={}。"
+                        + " 请核对:① sip.client.password 与平台该设备密码一致"
+                        + " ② 平台已添加设备 {} ③ domain/platformId 与海康配置一致"
+                        + " ④ 仍失败可试 sip.client.register-uri-user=device",
+                        activeDeviceId, properties.getPlatformId(), properties.getDomain(),
+                        passwordLength(), activeDeviceId);
+            } else {
+                error = "平台拒绝注册(" + status + ")";
+                log.warn("GB28181 注册失败: deviceId={}, status={}", activeDeviceId, status);
+            }
+            completeRegisterAttempt(status, error);
         }
     }
 
@@ -624,6 +770,8 @@ public class Gb28181DeviceSimulator implements SipListener {
                 handleIncomingAck(event);
             } else if (Request.BYE.equals(method)) {
                 handleIncomingBye(event);
+            } else if (Request.MESSAGE.equals(method)) {
+                handleIncomingMessage(event);
             }
         } catch (Exception e) {
             log.error("处理 {} 失败: {}", method, e.getMessage(), e);

+ 8 - 1
service-cdi/service-cdi-biz/src/main/resources/application-gb28181-video.yml

@@ -8,13 +8,20 @@ sip:
   client:
     enabled: true
     auto-start: false
-    server-host: 192.168.20.166
+    server-host: 114.80.201.142
     server-port: 15060
     domain: 3402000000
     platform-id: 34020000002000000001
+    # 必须与平台侧该设备的 SIP 密码完全一致(Nacos 会覆盖此值)
     password: jkjj_wlgz
     sip-transport: tcp
     register-expires: 3600
+    register-uri-user: platform
     invite-wait-seconds: 300
     default-duration-seconds: 60
     rtsp-transport: tcp
+    local-ip: 192.168.0.100
+    # 注册超时时间,单位秒,建议设置为10~15秒,适配高延迟链路
+    register-timeout: 15
+    # TCP长连接开启保活,避免网络波动导致连接中断
+    tcp-keep-alive: true

+ 85 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/BiddingConfigController.java

@@ -0,0 +1,85 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.service.VppBiddingConfigService;
+import com.usky.vpp.service.vo.BiddingConfigRequest;
+import com.usky.vpp.service.vo.BiddingConfigVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 竞价配置接口
+ * 网关前缀: /prod-api/service-vpp/bidding
+ */
+@RestController
+@RequestMapping("/bidding")
+public class BiddingConfigController {
+
+    private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    @Autowired
+    private VppBiddingConfigService biddingConfigService;
+
+    /**
+     * 分页查询竞价配置
+     */
+    @GetMapping("/config")
+    public ApiResult<CommonPage<BiddingConfigVO>> page(
+            @RequestParam(value = "configName", required = false) String configName,
+            @RequestParam(value = "effectiveStart", required = false) String effectiveStart,
+            @RequestParam(value = "effectiveEnd", required = false) String effectiveEnd,
+            @RequestParam(value = "current", required = false, defaultValue = "1") Integer current,
+            @RequestParam(value = "size", required = false, defaultValue = "20") Integer size) {
+        return ApiResult.success(biddingConfigService.page(
+                configName,
+                parseDateTime(effectiveStart),
+                parseDateTime(effectiveEnd),
+                current,
+                size));
+    }
+
+    /**
+     * 竞价配置详情
+     */
+    @GetMapping("/config/{id}")
+    public ApiResult<BiddingConfigVO> get(@PathVariable("id") Long id) {
+        return ApiResult.success(biddingConfigService.get(id));
+    }
+
+    /**
+     * 新增竞价配置
+     */
+    @PostMapping("/config")
+    public ApiResult<Long> create(@RequestBody BiddingConfigRequest body) {
+        return ApiResult.success(biddingConfigService.create(body));
+    }
+
+    /**
+     * 更新竞价配置
+     */
+    @PutMapping("/config/{id}")
+    public ApiResult<Void> update(@PathVariable("id") Long id, @RequestBody BiddingConfigRequest body) {
+        biddingConfigService.update(id, body);
+        return ApiResult.success();
+    }
+
+    /**
+     * 删除竞价配置(软删除)
+     */
+    @DeleteMapping("/config/{id}")
+    public ApiResult<Void> delete(@PathVariable("id") Long id) {
+        biddingConfigService.delete(id);
+        return ApiResult.success();
+    }
+
+    private LocalDateTime parseDateTime(String value) {
+        if (!org.springframework.util.StringUtils.hasText(value)) {
+            return null;
+        }
+        return LocalDateTime.parse(value.trim(), DATE_TIME_FMT);
+    }
+}

+ 62 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppBiddingConfig.java

@@ -0,0 +1,62 @@
+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.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * vpp_bidding_config 竞价配置
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("vpp_bidding_config")
+public class VppBiddingConfig implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+    @TableField("config_name")
+    private String configName;
+    @TableField("effective_start")
+    private LocalDateTime effectiveStart;
+    @TableField("effective_end")
+    private LocalDateTime effectiveEnd;
+    @TableField("attachment_url")
+    private String attachmentUrl;
+    @TableField("attachment_name")
+    private String attachmentName;
+    @TableField("bidding_mode")
+    private Integer biddingMode;
+    @TableField("typical_coefficient")
+    private BigDecimal typicalCoefficient;
+    @TableField("advance_close_hours")
+    private Integer advanceCloseHours;
+    @TableField("max_price")
+    private BigDecimal maxPrice;
+    @TableField("min_price")
+    private BigDecimal minPrice;
+    @TableField("site_ids")
+    private String siteIds;
+    @TableField("tenant_id")
+    private Integer tenantId;
+    @TableField("create_time")
+    private LocalDateTime createTime;
+    @TableField("update_time")
+    private LocalDateTime updateTime;
+    @TableField("created_by")
+    private String createdBy;
+    @TableField("updated_by")
+    private String updatedBy;
+    @TableField("delete_flag")
+    private Integer deleteFlag;
+    @TableField("deleted_at")
+    private LocalDateTime deletedAt;
+}

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

@@ -34,6 +34,9 @@ public class VppDrParticipation implements Serializable {
     private BigDecimal declaredCapacityKw;
     @TableField("cleared_capacity_kw")
     private BigDecimal clearedCapacityKw;
+    /** 完成率 = 出清容量 / 申报容量 */
+    @TableField("completion_rate")
+    private BigDecimal completionRate;
     @TableField("declared_at")
     private LocalDateTime declaredAt;
     @TableField("tenant_id")

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

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

+ 27 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppBiddingConfigService.java

@@ -0,0 +1,27 @@
+package com.usky.vpp.service;
+
+import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.service.vo.BiddingConfigRequest;
+import com.usky.vpp.service.vo.BiddingConfigVO;
+
+import java.time.LocalDateTime;
+
+/**
+ * 竞价配置服务
+ */
+public interface VppBiddingConfigService {
+
+    CommonPage<BiddingConfigVO> page(String configName,
+                                     LocalDateTime effectiveStart,
+                                     LocalDateTime effectiveEnd,
+                                     Integer current,
+                                     Integer size);
+
+    BiddingConfigVO get(Long id);
+
+    Long create(BiddingConfigRequest request);
+
+    void update(Long id, BiddingConfigRequest request);
+
+    void delete(Long id);
+}

+ 253 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppBiddingConfigServiceImpl.java

@@ -0,0 +1,253 @@
+package com.usky.vpp.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.common.core.exception.BusinessException;
+import com.usky.common.security.utils.SecurityUtils;
+import com.usky.vpp.domain.VppBiddingConfig;
+import com.usky.vpp.domain.VppSite;
+import com.usky.vpp.mapper.VppBiddingConfigMapper;
+import com.usky.vpp.mapper.VppSiteMapper;
+import com.usky.vpp.service.VppBiddingConfigService;
+import com.usky.vpp.service.vo.BiddingConfigRequest;
+import com.usky.vpp.service.vo.BiddingConfigSiteVO;
+import com.usky.vpp.service.vo.BiddingConfigVO;
+import com.usky.vpp.util.VppAuditHelper;
+import com.usky.vpp.util.VppBiddingConfigHelper;
+import com.usky.vpp.util.VppPageHelper;
+import com.usky.vpp.util.VppReportHelper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+@Service
+public class VppBiddingConfigServiceImpl implements VppBiddingConfigService {
+
+    @Autowired
+    private VppBiddingConfigMapper biddingConfigMapper;
+    @Autowired
+    private VppSiteMapper siteMapper;
+
+    @Override
+    public CommonPage<BiddingConfigVO> page(String configName,
+                                              LocalDateTime effectiveStart,
+                                              LocalDateTime effectiveEnd,
+                                              Integer current,
+                                              Integer size) {
+        Map<String, Object> pageParams = new HashMap<>(2);
+        pageParams.put("current", current);
+        pageParams.put("size", size);
+        Page<VppBiddingConfig> page = VppPageHelper.of(pageParams);
+        LambdaQueryWrapper<VppBiddingConfig> wrapper = new LambdaQueryWrapper<VppBiddingConfig>()
+                .eq(VppBiddingConfig::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .orderByDesc(VppBiddingConfig::getUpdateTime);
+        applyTenantFilter(wrapper);
+
+        if (StringUtils.hasText(configName)) {
+            wrapper.like(VppBiddingConfig::getConfigName, configName.trim());
+        }
+        if (effectiveStart != null) {
+            wrapper.ge(VppBiddingConfig::getEffectiveEnd, effectiveStart);
+        }
+        if (effectiveEnd != null) {
+            wrapper.le(VppBiddingConfig::getEffectiveStart, effectiveEnd);
+        }
+
+        Page<VppBiddingConfig> result = biddingConfigMapper.selectPage(page, wrapper);
+        List<BiddingConfigVO> records = result.getRecords().stream()
+                .map(config -> toVo(config, false))
+                .collect(Collectors.toList());
+        return new CommonPage<>(records, result.getTotal(), result.getCurrent(), result.getSize());
+    }
+
+    @Override
+    public BiddingConfigVO get(Long id) {
+        return toVo(requireConfig(id), true);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Long create(BiddingConfigRequest request) {
+        validateRequest(request);
+        List<Long> siteIds = validateAndNormalizeSiteIds(request.getSiteIds(), resolveCurrentTenantId());
+
+        VppBiddingConfig config = new VppBiddingConfig();
+        fillConfigFromRequest(config, request, siteIds);
+        VppAuditHelper.fillCreate(config);
+        biddingConfigMapper.insert(config);
+        return config.getId();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void update(Long id, BiddingConfigRequest request) {
+        VppBiddingConfig config = requireConfig(id);
+        validateRequest(request);
+        List<Long> siteIds = validateAndNormalizeSiteIds(request.getSiteIds(), resolveCurrentTenantId());
+
+        fillConfigFromRequest(config, request, siteIds);
+        VppAuditHelper.fillUpdate(config);
+        biddingConfigMapper.updateById(config);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void delete(Long id) {
+        VppBiddingConfig config = requireConfig(id);
+        VppAuditHelper.fillSoftDelete(config);
+        biddingConfigMapper.updateById(config);
+    }
+
+    private VppBiddingConfig requireConfig(Long id) {
+        VppBiddingConfig config = biddingConfigMapper.selectById(id);
+        if (config == null || VppAuditHelper.isDeleted(config.getDeleteFlag())) {
+            throw new BusinessException("竞价配置不存在");
+        }
+        return config;
+    }
+
+    private void fillConfigFromRequest(VppBiddingConfig config,
+                                       BiddingConfigRequest request,
+                                       List<Long> siteIds) {
+        config.setConfigName(request.getConfigName().trim());
+        config.setEffectiveStart(request.getEffectiveStart());
+        config.setEffectiveEnd(request.getEffectiveEnd());
+        config.setAttachmentUrl(request.getAttachmentUrl());
+        config.setAttachmentName(request.getAttachmentName());
+        config.setBiddingMode(request.getBiddingMode());
+        config.setTypicalCoefficient(request.getTypicalCoefficient());
+        config.setAdvanceCloseHours(request.getAdvanceCloseHours());
+        config.setMaxPrice(request.getMaxPrice());
+        config.setMinPrice(request.getMinPrice());
+        config.setSiteIds(VppReportHelper.toJson(siteIds));
+    }
+
+    private BiddingConfigVO toVo(VppBiddingConfig config, boolean withSites) {
+        BiddingConfigVO vo = new BiddingConfigVO();
+        vo.setId(config.getId());
+        vo.setConfigName(config.getConfigName());
+        vo.setEffectiveStart(config.getEffectiveStart());
+        vo.setEffectiveEnd(config.getEffectiveEnd());
+        vo.setEffectivePeriod(VppBiddingConfigHelper.formatEffectivePeriod(
+                config.getEffectiveStart(), config.getEffectiveEnd()));
+        vo.setAttachmentUrl(config.getAttachmentUrl());
+        vo.setAttachmentName(config.getAttachmentName());
+        vo.setBiddingMode(config.getBiddingMode());
+        vo.setBiddingModeName(VppBiddingConfigHelper.resolveBiddingModeName(config.getBiddingMode()));
+        vo.setTypicalCoefficient(config.getTypicalCoefficient());
+        vo.setAdvanceCloseHours(config.getAdvanceCloseHours());
+        vo.setAdvanceCloseHoursLabel(
+                VppBiddingConfigHelper.resolveAdvanceCloseHoursLabel(config.getAdvanceCloseHours()));
+        vo.setMaxPrice(config.getMaxPrice());
+        vo.setMinPrice(config.getMinPrice());
+        List<Long> siteIds = VppReportHelper.parseSiteIds(config.getSiteIds());
+        vo.setSiteIds(siteIds);
+        vo.setUpdateTime(config.getUpdateTime());
+        if (withSites) {
+            vo.setSites(loadSiteBriefs(siteIds));
+        }
+        return vo;
+    }
+
+    private List<BiddingConfigSiteVO> loadSiteBriefs(List<Long> siteIds) {
+        if (siteIds == null || siteIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+        Map<Long, String> siteNameMap = siteMapper.selectBatchIds(siteIds).stream()
+                .filter(site -> !VppAuditHelper.isDeleted(site.getDeleteFlag()))
+                .collect(Collectors.toMap(VppSite::getId, VppSite::getSiteName, (left, right) -> left));
+        return siteIds.stream().map(siteId -> {
+            BiddingConfigSiteVO item = new BiddingConfigSiteVO();
+            item.setSiteId(siteId);
+            item.setSiteName(siteNameMap.get(siteId));
+            return item;
+        }).collect(Collectors.toList());
+    }
+
+    private void validateRequest(BiddingConfigRequest request) {
+        if (request == null) {
+            throw new BusinessException("请求体不能为空");
+        }
+        if (!StringUtils.hasText(request.getConfigName())) {
+            throw new BusinessException("配置名称不能为空");
+        }
+        if (request.getEffectiveStart() == null || request.getEffectiveEnd() == null) {
+            throw new BusinessException("适用时间段不能为空");
+        }
+        if (request.getEffectiveStart().isAfter(request.getEffectiveEnd())) {
+            throw new BusinessException("适用开始时间不能晚于结束时间");
+        }
+        if (request.getBiddingMode() == null
+                || (request.getBiddingMode() != VppBiddingConfigHelper.BIDDING_MODE_TIME_SLOT
+                && request.getBiddingMode() != VppBiddingConfigHelper.BIDDING_MODE_WHOLE)) {
+            throw new BusinessException("竞价模式无效,取值 1 或 2");
+        }
+        if (request.getTypicalCoefficient() == null) {
+            throw new BusinessException("典型系数不能为空");
+        }
+        if (request.getAdvanceCloseHours() == null || request.getAdvanceCloseHours() <= 0) {
+            throw new BusinessException("提前截标时间须为正整数(小时)");
+        }
+        if (request.getMaxPrice() == null) {
+            throw new BusinessException("最高价格不能为空");
+        }
+        if (request.getMinPrice() == null) {
+            throw new BusinessException("最低价格不能为空");
+        }
+        VppBiddingConfigHelper.validatePriceRange(request.getMinPrice(), request.getMaxPrice());
+        if (request.getSiteIds() == null || request.getSiteIds().isEmpty()) {
+            throw new BusinessException("请至少选择一个关联站点");
+        }
+    }
+
+    private List<Long> validateAndNormalizeSiteIds(List<Long> siteIds, Integer tenantId) {
+        if (siteIds == null || siteIds.isEmpty()) {
+            throw new BusinessException("请至少选择一个关联站点");
+        }
+        List<Long> distinct = siteIds.stream()
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+        if (distinct.isEmpty()) {
+            throw new BusinessException("请至少选择一个关联站点");
+        }
+        Map<Long, VppSite> siteMap = siteMapper.selectBatchIds(distinct).stream()
+                .filter(site -> !VppAuditHelper.isDeleted(site.getDeleteFlag()))
+                .collect(Collectors.toMap(VppSite::getId, site -> site, (left, right) -> left));
+        for (Long siteId : distinct) {
+            VppSite site = siteMap.get(siteId);
+            if (site == null) {
+                throw new BusinessException("站点不存在或已删除: " + siteId);
+            }
+            if (tenantId != null && site.getTenantId() != null && !tenantId.equals(site.getTenantId())) {
+                throw new BusinessException("站点不属于当前租户: " + siteId);
+            }
+        }
+        return distinct;
+    }
+
+    private Integer resolveCurrentTenantId() {
+        try {
+            return SecurityUtils.getTenantId();
+        } catch (Exception ex) {
+            return null;
+        }
+    }
+
+    private void applyTenantFilter(LambdaQueryWrapper<VppBiddingConfig> wrapper) {
+        Integer tenantId = resolveCurrentTenantId();
+        if (tenantId != null) {
+            wrapper.eq(VppBiddingConfig::getTenantId, tenantId);
+        }
+    }
+}

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

@@ -23,6 +23,7 @@ import com.usky.vpp.service.vo.DrInvitationReplyRequest;
 import com.usky.vpp.service.vo.DrInvitationRequest;
 import com.usky.vpp.service.vo.DrInvitationVO;
 import com.usky.vpp.util.VppAuditHelper;
+import com.usky.vpp.util.VppDrParticipationHelper;
 import com.usky.vpp.util.VppPageHelper;
 import com.usky.vpp.util.VppSmsTemplateHelper;
 import org.slf4j.Logger;
@@ -334,6 +335,7 @@ public class VppDrInvitationServiceImpl implements VppDrInvitationService {
             existing.setParticipateStatus(PARTICIPATE_ACCEPT);
             existing.setDeclaredCapacityKw(declaredCapacityKw);
             existing.setDeclaredAt(LocalDateTime.now());
+            VppDrParticipationHelper.refreshCompletionRate(existing);
             VppAuditHelper.fillUpdate(existing);
             participationMapper.updateById(existing);
             return existing;
@@ -345,6 +347,7 @@ public class VppDrInvitationServiceImpl implements VppDrInvitationService {
         participation.setParticipateStatus(PARTICIPATE_ACCEPT);
         participation.setDeclaredCapacityKw(declaredCapacityKw);
         participation.setDeclaredAt(LocalDateTime.now());
+        VppDrParticipationHelper.refreshCompletionRate(participation);
         VppAuditHelper.fillCreate(participation);
         participationMapper.insert(participation);
         return participation;

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

@@ -31,6 +31,7 @@ import com.usky.vpp.service.vo.DrParticipateRequest;
 import com.usky.vpp.service.vo.DrStrategyRequest;
 import com.usky.vpp.service.vo.DrStrategyVO;
 import com.usky.vpp.util.VppAuditHelper;
+import com.usky.vpp.util.VppDrParticipationHelper;
 import com.usky.vpp.util.VppPageHelper;
 import com.usky.vpp.util.VppSiteResourceHelper;
 import org.slf4j.Logger;
@@ -314,6 +315,7 @@ public class VppDrServiceImpl implements VppDrService {
                 participation.setParticipateStatus(PARTICIPATE_STATUS_ACCEPT);
                 participation.setDeclaredCapacityKw(resource.getDeclaredCapacityKw());
                 participation.setDeclaredAt(LocalDateTime.now());
+                VppDrParticipationHelper.refreshCompletionRate(participation);
                 VppAuditHelper.fillCreate(participation);
                 participationMapper.insert(participation);
             }
@@ -395,6 +397,7 @@ public class VppDrServiceImpl implements VppDrService {
                 throw new BusinessException("出清容量不能超过申报容量");
             }
             participation.setClearedCapacityKw(resource.getClearedCapacityKw());
+            VppDrParticipationHelper.refreshCompletionRate(participation);
             VppAuditHelper.fillUpdate(participation);
             participationMapper.updateById(participation);
         }
@@ -894,6 +897,10 @@ public class VppDrServiceImpl implements VppDrService {
             item.setParticipateStatus(p.getParticipateStatus());
             item.setDeclaredCapacityKw(p.getDeclaredCapacityKw());
             item.setClearedCapacityKw(p.getClearedCapacityKw());
+            item.setCompletionRate(p.getCompletionRate() != null
+                    ? p.getCompletionRate()
+                    : VppDrParticipationHelper.calcCompletionRate(
+                            p.getClearedCapacityKw(), p.getDeclaredCapacityKw()));
             item.setDeclaredAt(p.getDeclaredAt());
             VppCustomer customer = customerMap.get(p.getCustomerId());
             if (customer != null) {

+ 2 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppUnDrSyncServiceImpl.java

@@ -12,6 +12,7 @@ import com.usky.vpp.mapper.VppResourcePointMapper;
 import com.usky.vpp.mapper.VppSiteMapper;
 import com.usky.vpp.service.VppUnDrSyncService;
 import com.usky.vpp.util.VppAuditHelper;
+import com.usky.vpp.util.VppDrParticipationHelper;
 import com.usky.vpp.util.VppUnEventParser;
 import com.usky.vpp.util.VppUnPayloadHelper;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -112,6 +113,7 @@ public class VppUnDrSyncServiceImpl implements VppUnDrSyncService {
         } else {
             participation.setDeclaredCapacityKw(load.abs());
         }
+        VppDrParticipationHelper.refreshCompletionRate(participation);
         VppAuditHelper.fillUpdate(participation);
         if (participation.getId() == null) {
             participationMapper.insert(participation);

+ 28 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BiddingConfigRequest.java

@@ -0,0 +1,28 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 竞价配置创建/更新请求
+ */
+@Data
+public class BiddingConfigRequest {
+
+    private String configName;
+    private LocalDateTime effectiveStart;
+    private LocalDateTime effectiveEnd;
+    private String attachmentUrl;
+    private String attachmentName;
+    /** 1分时段竞价 2整体竞价 */
+    private Integer biddingMode;
+    private BigDecimal typicalCoefficient;
+    /** 提前截标时间(小时),如 24 表示 >24 小时 */
+    private Integer advanceCloseHours;
+    private BigDecimal maxPrice;
+    private BigDecimal minPrice;
+    private List<Long> siteIds;
+}

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

@@ -0,0 +1,13 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+/**
+ * 竞价配置关联站点
+ */
+@Data
+public class BiddingConfigSiteVO {
+
+    private Long siteId;
+    private String siteName;
+}

+ 34 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BiddingConfigVO.java

@@ -0,0 +1,34 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 竞价配置详情/列表项
+ */
+@Data
+public class BiddingConfigVO {
+
+    private Long id;
+    private String configName;
+    private LocalDateTime effectiveStart;
+    private LocalDateTime effectiveEnd;
+    /** 适用时间段展示,如 2025-01-01 00:00:00 - 2025-06-30 23:59:59 */
+    private String effectivePeriod;
+    private String attachmentUrl;
+    private String attachmentName;
+    private Integer biddingMode;
+    private String biddingModeName;
+    private BigDecimal typicalCoefficient;
+    private Integer advanceCloseHours;
+    /** 提前截标时间展示,如 >24 */
+    private String advanceCloseHoursLabel;
+    private BigDecimal maxPrice;
+    private BigDecimal minPrice;
+    private List<Long> siteIds;
+    private List<BiddingConfigSiteVO> sites;
+    private LocalDateTime updateTime;
+}

+ 2 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrParticipationVO.java

@@ -19,5 +19,7 @@ public class DrParticipationVO {
     private Integer participateStatus;
     private BigDecimal declaredCapacityKw;
     private BigDecimal clearedCapacityKw;
+    /** 完成率 = 出清容量 / 申报容量 */
+    private BigDecimal completionRate;
     private LocalDateTime declaredAt;
 }

+ 56 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppBiddingConfigHelper.java

@@ -0,0 +1,56 @@
+package com.usky.vpp.util;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 竞价配置工具
+ */
+public final class VppBiddingConfigHelper {
+
+    public static final int BIDDING_MODE_TIME_SLOT = 1;
+    public static final int BIDDING_MODE_WHOLE = 2;
+
+    private static final DateTimeFormatter PERIOD_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    private VppBiddingConfigHelper() {
+    }
+
+    public static String resolveBiddingModeName(Integer biddingMode) {
+        if (biddingMode == null) {
+            return null;
+        }
+        switch (biddingMode) {
+            case BIDDING_MODE_TIME_SLOT:
+                return "分时段竞价";
+            case BIDDING_MODE_WHOLE:
+                return "整体竞价";
+            default:
+                return "未知模式";
+        }
+    }
+
+    public static String resolveAdvanceCloseHoursLabel(Integer advanceCloseHours) {
+        if (advanceCloseHours == null) {
+            return null;
+        }
+        return ">" + advanceCloseHours;
+    }
+
+    public static String formatEffectivePeriod(LocalDateTime start, LocalDateTime end) {
+        if (start == null || end == null) {
+            return null;
+        }
+        return PERIOD_FMT.format(start) + " - " + PERIOD_FMT.format(end);
+    }
+
+    public static void validatePriceRange(BigDecimal minPrice, BigDecimal maxPrice) {
+        if (minPrice == null || maxPrice == null) {
+            return;
+        }
+        if (minPrice.compareTo(maxPrice) > 0) {
+            throw new com.usky.common.core.exception.BusinessException("最低价格不能高于最高价格");
+        }
+    }
+}

+ 37 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppDrParticipationHelper.java

@@ -0,0 +1,37 @@
+package com.usky.vpp.util;
+
+import com.usky.vpp.domain.VppDrParticipation;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+/**
+ * 需求响应参与记录工具
+ */
+public final class VppDrParticipationHelper {
+
+    private static final int RATE_SCALE = 4;
+
+    private VppDrParticipationHelper() {
+    }
+
+    /**
+     * 完成率 = 出清容量 / 申报容量;申报容量为空或 ≤0、出清容量为空时返回 null。
+     */
+    public static BigDecimal calcCompletionRate(BigDecimal clearedCapacityKw, BigDecimal declaredCapacityKw) {
+        if (clearedCapacityKw == null || declaredCapacityKw == null
+                || declaredCapacityKw.compareTo(BigDecimal.ZERO) <= 0) {
+            return null;
+        }
+        return clearedCapacityKw.divide(declaredCapacityKw, RATE_SCALE, RoundingMode.HALF_UP);
+    }
+
+    public static void refreshCompletionRate(VppDrParticipation participation) {
+        if (participation == null) {
+            return;
+        }
+        participation.setCompletionRate(calcCompletionRate(
+                participation.getClearedCapacityKw(),
+                participation.getDeclaredCapacityKw()));
+    }
+}

+ 35 - 0
service-vpp/service-vpp-biz/src/main/resources/sql/vpp_bidding_config_migration.sql

@@ -0,0 +1,35 @@
+-- 竞价配置表(已有库执行,可重复执行)
+
+SET @sql = IF(
+    (SELECT COUNT(*) FROM information_schema.tables
+     WHERE table_schema = DATABASE()
+       AND table_name = 'vpp_bidding_config') = 0,
+    'CREATE TABLE `vpp_bidding_config` (
+        `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
+        `config_name` VARCHAR(200) NOT NULL COMMENT ''配置名称'',
+        `effective_start` DATETIME(3) NOT NULL COMMENT ''适用开始时间'',
+        `effective_end` DATETIME(3) NOT NULL COMMENT ''适用结束时间'',
+        `attachment_url` VARCHAR(500) NULL COMMENT ''附件URL'',
+        `attachment_name` VARCHAR(200) NULL COMMENT ''附件原始文件名'',
+        `bidding_mode` TINYINT NOT NULL COMMENT ''1分时段竞价 2整体竞价'',
+        `typical_coefficient` DECIMAL(6,4) NOT NULL COMMENT ''典型系数'',
+        `advance_close_hours` INT NOT NULL COMMENT ''提前截标时间(小时),如24表示>24小时'',
+        `max_price` DECIMAL(12,4) NOT NULL COMMENT ''最高价格(元)'',
+        `min_price` DECIMAL(12,4) NOT NULL COMMENT ''最低价格(元)'',
+        `site_ids` JSON NULL COMMENT ''关联站点ID列表'',
+        `tenant_id` INT NULL COMMENT ''租户ID'',
+        `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间'',
+        `update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间'',
+        `created_by` VARCHAR(30) NULL COMMENT ''创建人'',
+        `updated_by` VARCHAR(30) NULL COMMENT ''更新人'',
+        `delete_flag` INT(1) NOT NULL DEFAULT 0 COMMENT ''删除标识 0未删除 1已删除'',
+        `deleted_at` DATETIME(3) NULL COMMENT ''软删除时间'',
+        PRIMARY KEY (`id`),
+        KEY `idx_bidding_config_effective` (effective_start, effective_end),
+        KEY `idx_bidding_config_name` (config_name)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT=''竞价配置''',
+    'SELECT 1'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 24 - 0
service-vpp/service-vpp-biz/src/main/resources/sql/vpp_dr_participation_completion_rate_migration.sql

@@ -0,0 +1,24 @@
+-- 事件参与记录增加完成率(已有库执行,可重复执行)
+-- completion_rate = cleared_capacity_kw / declared_capacity_kw
+
+SET @sql = IF(
+    (SELECT COUNT(*) FROM information_schema.columns
+     WHERE table_schema = DATABASE()
+       AND table_name = 'vpp_dr_participation'
+       AND column_name = 'completion_rate') = 0,
+    'ALTER TABLE `vpp_dr_participation` ADD COLUMN `completion_rate` DECIMAL(12,4) NULL COMMENT ''完成率=出清容量/申报容量'' AFTER `cleared_capacity_kw`',
+    'SELECT 1'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 回填已有出清数据
+UPDATE `vpp_dr_participation`
+SET `completion_rate` = ROUND(`cleared_capacity_kw` / `declared_capacity_kw`, 4)
+WHERE `delete_flag` = 0
+  AND `cleared_capacity_kw` IS NOT NULL
+  AND `declared_capacity_kw` IS NOT NULL
+  AND `declared_capacity_kw` > 0
+  AND (`completion_rate` IS NULL
+       OR `completion_rate` <> ROUND(`cleared_capacity_kw` / `declared_capacity_kw`, 4));

+ 27 - 1
service-vpp/service-vpp-biz/src/main/resources/sql/vpp_schema.sql

@@ -451,6 +451,7 @@ CREATE TABLE `vpp_dr_participation` (
     `participate_status` TINYINT NOT NULL COMMENT '0待确认 1参与 2拒绝',
     `declared_capacity_kw` DECIMAL(12,4) NULL COMMENT '申报容量',
     `cleared_capacity_kw` DECIMAL(12,4) NULL COMMENT '出清容量',
+    `completion_rate` DECIMAL(12,4) NULL COMMENT '完成率=出清容量/申报容量',
     `declared_at` DATETIME(3) NULL COMMENT '申报时间',
     `tenant_id` INT NULL COMMENT '租户ID',
     `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -622,4 +623,29 @@ CREATE TABLE vpp_file_archive (
     deleted_at  DATETIME(3)    NULL COMMENT '软删除时间',
     PRIMARY KEY (id),
     KEY idx_file_archive_biz (biz_type, biz_id)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='电子档案';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='电子档案';
+
+CREATE TABLE `vpp_bidding_config` (
+    `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `config_name` VARCHAR(200) NOT NULL COMMENT '配置名称',
+    `effective_start` DATETIME(3) NOT NULL COMMENT '适用开始时间',
+    `effective_end` DATETIME(3) NOT NULL COMMENT '适用结束时间',
+    `attachment_url` VARCHAR(500) NULL COMMENT '附件URL',
+    `attachment_name` VARCHAR(200) NULL COMMENT '附件原始文件名',
+    `bidding_mode` TINYINT NOT NULL COMMENT '1分时段竞价 2整体竞价',
+    `typical_coefficient` DECIMAL(6,4) NOT NULL COMMENT '典型系数',
+    `advance_close_hours` INT NOT NULL COMMENT '提前截标时间(小时),如24表示>24小时',
+    `max_price` DECIMAL(12,4) NOT NULL COMMENT '最高价格(元)',
+    `min_price` DECIMAL(12,4) NOT NULL COMMENT '最低价格(元)',
+    `site_ids` JSON NULL COMMENT '关联站点ID列表',
+    `tenant_id` INT NULL COMMENT '租户ID',
+    `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+    `update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+    `created_by` VARCHAR(30) NULL COMMENT '创建人',
+    `updated_by` VARCHAR(30) NULL COMMENT '更新人',
+    `delete_flag` INT(1) NOT NULL DEFAULT 0 COMMENT '删除标识 0未删除 1已删除',
+    `deleted_at` DATETIME(3) NULL COMMENT '软删除时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_bidding_config_effective` (effective_start, effective_end),
+    KEY `idx_bidding_config_name` (config_name)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='竞价配置';