瀏覽代碼

Merge branch 'vpp-zyj' into server-165

james 5 天之前
父節點
當前提交
0110881605
共有 100 個文件被更改,包括 7613 次插入45 次删除
  1. 2 0
      pom.xml
  2. 31 0
      service-cdi/service-cdi-biz/pom.xml
  3. 53 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/controller/SipPushController.java
  4. 81 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/controller/SipVideoController.java
  5. 111 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/controller/VideoIntegrationController.java
  6. 9 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/config/Gb28181VideoConfig.java
  7. 46 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/config/Gb28181VideoProperties.java
  8. 10 2
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/config/mqtt/MqttBaseConfig.java
  9. 5 31
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/config/mqtt/MqttOutConfig.java
  10. 4 2
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/enums/MqttTopics.java
  11. 0 1
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/BaseDataTransferService.java
  12. 69 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/SipRtspPushService.java
  13. 144 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/VideoDataTransferService.java
  14. 209 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/VideoStreamPushService.java
  15. 66 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/job/VideoDeviceSyncJob.java
  16. 32 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/config/SipClientProperties.java
  17. 23 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/config/SipServerProperties.java
  18. 679 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/device/Gb28181DeviceSimulator.java
  19. 22 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/device/Gb28181StreamResult.java
  20. 295 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/listener/SipServerListener.java
  21. 26 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/model/SdpMediaInfo.java
  22. 28 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/model/VideoStreamSession.java
  23. 103 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/FfmpegPsRemuxSession.java
  24. 109 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/FfmpegPsRemuxer.java
  25. 223 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/Gb28181PsHeaders.java
  26. 65 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/Gb28181PsMuxer.java
  27. 162 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/H264AccessUnitBuilder.java
  28. 65 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/H264ExtradataUtils.java
  29. 163 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/HikvisionPesBuilder.java
  30. 73 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/PsStreamFramer.java
  31. 257 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/RtspGb28181RtpSender.java
  32. 90 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/RtspH264RtpSender.java
  33. 66 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/SipLocalIntegrationTestMain.java
  34. 82 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/SipVideoPushTestMain.java
  35. 332 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/SipVideoPusher.java
  36. 28 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/SipVideoPusherFactory.java
  37. 78 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/AnnexBParser.java
  38. 76 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/H264Depacketizer.java
  39. 46 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/H264RtpPacketizer.java
  40. 35 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/PsRtpPacketizer.java
  41. 61 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/RtpPacket.java
  42. 29 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/RtpPacketBuilder.java
  43. 108 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/RtpVideoReceiver.java
  44. 191 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/sdp/SdpUtils.java
  45. 43 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/service/SipServerService.java
  46. 109 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/service/VideoStreamManager.java
  47. 36 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/util/NetworkUtils.java
  48. 49 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/util/PortAllocator.java
  49. 39 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/util/SipDigestAuth.java
  50. 83 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/util/Gb28181MqttCredentialResolver.java
  51. 312 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/util/Gb28181VideoPlatformClient.java
  52. 12 8
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/util/WeatherFetcher.java
  53. 28 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoChannelVO.java
  54. 29 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoDeviceItemVO.java
  55. 35 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoDeviceSyncPacketVO.java
  56. 56 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoPlayInfoVO.java
  57. 35 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoStreamItemVO.java
  58. 27 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoStreamSyncPacketVO.java
  59. 20 0
      service-cdi/service-cdi-biz/src/main/resources/application-gb28181-video.yml
  60. 2 0
      service-cdi/service-cdi-biz/src/main/resources/bootstrap.yml
  61. 4 0
      service-iot/service-iot-api/src/main/java/com/usky/iot/RemoteIotTaskService.java
  62. 5 0
      service-iot/service-iot-api/src/main/java/com/usky/iot/factory/RemoteIotTaskFactory.java
  63. 14 0
      service-iot/service-iot-biz/src/main/java/com/usky/iot/controller/api/ServiceIotTaskApi.java
  64. 9 1
      service-iot/service-iot-biz/src/main/java/com/usky/iot/service/impl/DmpDeviceInfoServiceImpl.java
  65. 65 0
      service-vpp/README.md
  66. 19 0
      service-vpp/pom.xml
  67. 24 0
      service-vpp/service-vpp-api/pom.xml
  68. 114 0
      service-vpp/service-vpp-biz/pom.xml
  69. 47 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/VppApplication.java
  70. 45 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/client/VppUnClient.java
  71. 137 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/client/VppUnHttpExecutor.java
  72. 107 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/client/VppUnTokenHolder.java
  73. 82 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/config/VppUnProperties.java
  74. 22 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/config/VppUnRestTemplateConfig.java
  75. 68 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/un/UnDnController.java
  76. 59 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/AlarmController.java
  77. 51 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/AnalyticsController.java
  78. 83 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ArchiveController.java
  79. 47 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ContractController.java
  80. 84 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/CustomerController.java
  81. 31 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/DashboardController.java
  82. 105 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/DrController.java
  83. 43 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/MobileController.java
  84. 35 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/MonitorController.java
  85. 55 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ReportInfoController.java
  86. 119 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ResourceController.java
  87. 51 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/SettlementController.java
  88. 134 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/SiteController.java
  89. 35 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/UnIntegrationController.java
  90. 50 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/VppDeviceController.java
  91. 77 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/crypto/VppUnCryptoService.java
  92. 67 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppContract.java
  93. 34 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppContractAuditLog.java
  94. 51 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppContractTemplate.java
  95. 66 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppCustomer.java
  96. 65 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppCustomerAccess.java
  97. 49 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppCustomerContact.java
  98. 65 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDevice.java
  99. 40 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDeviceControlLog.java
  100. 53 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDrEvaluation.java

+ 2 - 0
pom.xml

@@ -93,6 +93,8 @@
 
         <module>service-sas</module>
 
+        <module>service-vpp</module>
+
   </modules>
           
   

+ 31 - 0
service-cdi/service-cdi-biz/pom.xml

@@ -109,6 +109,37 @@
         <!--            <artifactId>nacos-client</artifactId>-->
         <!--        </dependency>-->
 
+        <!-- SIP 视频流接收(JAIN-SIP) -->
+        <dependency>
+            <groupId>javax.sip</groupId>
+            <artifactId>jain-sip-ri</artifactId>
+            <version>1.3.0-91</version>
+        </dependency>
+        <!-- JAIN-SIP 运行时需要 log4j 1.x -->
+        <dependency>
+            <groupId>log4j</groupId>
+            <artifactId>log4j</artifactId>
+            <version>1.2.17</version>
+        </dependency>
+
+        <!-- RTSP 取流 + H264 RTP 封装 -->
+        <dependency>
+            <groupId>org.bytedeco</groupId>
+            <artifactId>javacv</artifactId>
+            <version>1.5.6</version>
+        </dependency>
+        <!-- 显式引入 ffmpeg Java 绑定(含 org.bytedeco.ffmpeg.avcodec.AVPacket) -->
+        <dependency>
+            <groupId>org.bytedeco</groupId>
+            <artifactId>ffmpeg</artifactId>
+            <version>4.4-1.5.6</version>
+        </dependency>
+        <dependency>
+            <groupId>org.bytedeco</groupId>
+            <artifactId>ffmpeg-platform</artifactId>
+            <version>4.4-1.5.6</version>
+        </dependency>
+
     </dependencies>
 
     <build>

+ 53 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/controller/SipPushController.java

@@ -0,0 +1,53 @@
+package com.usky.cdi.controller;
+
+import com.usky.cdi.service.impl.SipRtspPushService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * GB28181 推流(海康摄像机模式):手动注册设备,传入 RTSP 后等平台点播。
+ */
+@RestController
+@RequestMapping("/api/sip")
+@RequiredArgsConstructor
+@ConditionalOnProperty(prefix = "sip.client", name = "enabled", havingValue = "true")
+public class SipPushController {
+
+    private final SipRtspPushService sipRtspPushService;
+
+    /** 手动注册设备到 GB28181 平台(deviceId 动态传入,20 位数字) */
+    @PostMapping("/device/register")
+    public Map<String, Object> registerDevice(@RequestParam String deviceId) {
+        return sipRtspPushService.registerDevice(deviceId);
+    }
+
+    /** 手动注销设备(REGISTER Expires=0) */
+    @PostMapping("/device/unregister")
+    public Map<String, Object> unregisterDevice() {
+        return sipRtspPushService.unregisterDevice();
+    }
+
+    /**
+     * 绑定 RTSP 视频源并等待平台 INVITE 推流(需先注册设备)。
+     * 调用后请在 GB28181 平台对设备发起「实时预览」。
+     */
+    @PostMapping("/push")
+    public Map<String, Object> pushRtsp(
+            @RequestParam String rtspUrl,
+            @RequestParam(required = false) Integer durationSeconds) {
+        return sipRtspPushService.pushRtspStream(rtspUrl, durationSeconds);
+    }
+
+    /** 设备注册状态(类似摄像机 GB28181 在线状态) */
+    @GetMapping("/device/status")
+    public Map<String, Object> deviceStatus() {
+        return sipRtspPushService.deviceStatus();
+    }
+}

+ 81 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/controller/SipVideoController.java

@@ -0,0 +1,81 @@
+package com.usky.cdi.controller;
+
+import com.usky.cdi.service.sip.config.SipServerProperties;
+import com.usky.cdi.service.sip.model.VideoStreamSession;
+import com.usky.cdi.service.sip.service.VideoStreamManager;
+import com.usky.cdi.service.sip.util.NetworkUtils;
+import lombok.RequiredArgsConstructor;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping("/api/sip")
+@RequiredArgsConstructor
+@ConditionalOnProperty(prefix = "sip.server", name = "enabled", havingValue = "true")
+public class SipVideoController {
+
+    private final SipServerProperties properties;
+    private final VideoStreamManager streamManager;
+
+    @GetMapping("/status")
+    public Map<String, Object> status() {
+        Map<String, Object> result = new HashMap<>();
+        result.put("enabled", properties.isEnabled());
+        result.put("host", properties.getHost());
+        result.put("port", properties.getPort());
+        result.put("localIp", NetworkUtils.resolveLocalIp(properties.getLocalIp()));
+        result.put("platformId", properties.getPlatformId());
+        result.put("activeSessions", streamManager.getAllSessions().size());
+        return result;
+    }
+
+    @GetMapping("/sessions")
+    public List<Map<String, Object>> sessions() {
+        return streamManager.getAllSessions().stream()
+                .map(this::toSessionMap)
+                .collect(Collectors.toList());
+    }
+
+    @GetMapping("/sessions/{callId}")
+    public Map<String, Object> session(@PathVariable String callId) {
+        VideoStreamSession session = streamManager.getSession(callId);
+        if (session == null) {
+            Map<String, Object> notFound = new HashMap<>();
+            notFound.put("error", "会话不存在");
+            return notFound;
+        }
+        return toSessionMap(session);
+    }
+
+    @DeleteMapping("/sessions/{callId}")
+    public Map<String, Object> stopSession(@PathVariable String callId) {
+        streamManager.stopSession(callId);
+        Map<String, Object> result = new HashMap<>();
+        result.put("callId", callId);
+        result.put("status", "stopped");
+        return result;
+    }
+
+    private Map<String, Object> toSessionMap(VideoStreamSession session) {
+        Map<String, Object> map = new HashMap<>();
+        map.put("sessionId", session.getSessionId());
+        map.put("callId", session.getCallId());
+        map.put("remoteAddress", session.getRemoteAddress());
+        map.put("remoteRtpPort", session.getRemoteRtpPort());
+        map.put("localRtpPort", session.getLocalRtpPort());
+        map.put("codec", session.getCodec());
+        map.put("payloadType", session.getPayloadType());
+        map.put("ssrc", session.getSsrc());
+        map.put("state", session.getState().name());
+        map.put("startTime", session.getStartTime());
+        map.put("receivedPackets", session.getReceivedPackets().get());
+        map.put("receivedFrames", session.getReceivedFrames().get());
+        map.put("receivedBytes", session.getReceivedBytes().get());
+        return map;
+    }
+}

+ 111 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/controller/VideoIntegrationController.java

@@ -0,0 +1,111 @@
+package com.usky.cdi.controller;
+
+import com.alibaba.fastjson.JSONObject;
+import com.usky.cdi.service.config.Gb28181VideoProperties;
+import com.usky.cdi.service.impl.VideoDataTransferService;
+import com.usky.cdi.service.impl.VideoStreamPushService;
+import com.usky.cdi.service.vo.video.VideoPlayInfoVO;
+import com.usky.cdi.service.vo.video.VideoStreamSyncPacketVO;
+import com.usky.common.core.bean.ApiResult;
+import lombok.RequiredArgsConstructor;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * GB28181 视频对接运维接口
+ */
+@RestController
+@RequestMapping("/video/gb28181")
+@RequiredArgsConstructor
+@ConditionalOnProperty(prefix = "gb28181.video", name = "enabled", havingValue = "true")
+public class VideoIntegrationController {
+
+    private final VideoDataTransferService videoDataTransferService;
+    private final VideoStreamPushService videoStreamPushService;
+    private final Gb28181VideoProperties properties;
+
+    /**
+     * 检查配置(SIP 端口 + 平台 API 登录)
+     */
+    @GetMapping("/check")
+    public ApiResult<JSONObject> checkConfiguration() {
+        return ApiResult.success(videoDataTransferService.checkConfiguration());
+    }
+
+    /**
+     * 手动触发视频设备同步
+     */
+    @PostMapping("/sync")
+    public ApiResult<Void> syncDevices() {
+        videoDataTransferService.synchronizeVideoDevices();
+        return ApiResult.success();
+    }
+
+    /**
+     * 手动触发单路视频流推送
+     */
+    @PostMapping("/stream/push")
+    public ApiResult<VideoPlayInfoVO> pushStream(@RequestParam String deviceId,
+                                                 @RequestParam String channelId) {
+        return ApiResult.success(videoStreamPushService.pushStream(deviceId, channelId));
+    }
+
+    /**
+     * 批量推送所有在线通道视频流
+     */
+    @PostMapping("/stream/push-all")
+    public ApiResult<VideoStreamSyncPacketVO> pushAllStreams() {
+        return ApiResult.success(videoStreamPushService.pushAllOnlineStreams());
+    }
+
+    /**
+     * 停止单路视频流
+     */
+    @PostMapping("/stream/stop")
+    public ApiResult<Void> stopStream(@RequestParam String deviceId,
+                                      @RequestParam String channelId) {
+        videoStreamPushService.stopStream(deviceId, channelId);
+        return ApiResult.success();
+    }
+
+    /**
+     * 查看当前活跃推流会话
+     */
+    @GetMapping("/stream/sessions")
+    public ApiResult<Map<String, VideoPlayInfoVO>> activeSessions() {
+        return ApiResult.success(videoStreamPushService.getActiveSessions());
+    }
+
+    /**
+     * 查看当前生效的国标配置(密码脱敏)
+     */
+    @GetMapping("/config")
+    public ApiResult<JSONObject> currentConfig() {
+        JSONObject config = new JSONObject();
+        config.put("enabled", properties.isEnabled());
+        config.put("sipId", properties.getSipId());
+        config.put("sipDomain", properties.getSipDomain());
+        config.put("sipIp", properties.getSipIp());
+        config.put("sipPort", properties.getSipPort());
+        config.put("cascadePort", properties.getCascadePort());
+        config.put("rtpPortMin", properties.getRtpPortMin());
+        config.put("rtpPortMax", properties.getRtpPortMax());
+        config.put("apiBaseUrl", properties.getApiBaseUrl());
+        config.put("mqttTopic", properties.getMqttTopic());
+        config.put("syncCron", properties.getSyncCron());
+        config.put("fetchPlayUrl", properties.isFetchPlayUrl());
+        config.put("streamPushEnabled", properties.isStreamPushEnabled());
+        config.put("streamPushCron", properties.getStreamPushCron());
+        config.put("mqttStreamTopic", properties.getMqttStreamTopic());
+        config.put("autoPushAllOnline", properties.isAutoPushAllOnline());
+        config.put("maxConcurrentStreams", properties.getMaxConcurrentStreams());
+        config.put("preferredProtocol", properties.getPreferredProtocol());
+        return ApiResult.success(config);
+    }
+}

+ 9 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/config/Gb28181VideoConfig.java

@@ -0,0 +1,9 @@
+package com.usky.cdi.service.config;
+
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@EnableConfigurationProperties(Gb28181VideoProperties.class)
+public class Gb28181VideoConfig {
+}

+ 46 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/config/Gb28181VideoProperties.java

@@ -0,0 +1,46 @@
+package com.usky.cdi.service.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@Data
+@ConfigurationProperties(prefix = "gb28181.video")
+public class Gb28181VideoProperties {
+
+    private boolean enabled = false;
+
+    /** 市平台 MQTT */
+    private Integer tenantId;
+    private Long engineeringId;
+    private String mqttUsername;
+    private String mqttPassword;
+    private String mqttTopic = "base/videoDevice";
+    private String mqttStreamTopic = "iotInfo/videoStream";
+
+    /** 国标流媒体平台(WVP)SIP 参数 */
+    private String sipId = "34020000002000000001";
+    private String sipDomain = "3402000000";
+    private String sipIp = "114.80.201.142";
+    private int sipPort = 15060;
+    private int cascadePort = 15061;
+    private int rtpPortMin = 30100;
+    private int rtpPortMax = 30500;
+
+    /** WVP HTTP API */
+    private String apiBaseUrl = "http://114.80.201.142:18080";
+    private String apiUsername = "admin";
+    private String apiPassword = "admin";
+
+    /** 定时同步设备目录 cron */
+    private String syncCron = "0 0/30 * * * ?";
+
+    /** 同步时是否为在线通道拉取预览地址 */
+    private boolean fetchPlayUrl = false;
+
+    /** WVP 点播推流 + MQTT 上报播放地址 */
+    private boolean streamPushEnabled = false;
+    private String streamPushCron = "0 */5 * * * ?";
+    private boolean autoPushAllOnline = false;
+    private int maxConcurrentStreams = 10;
+    private String preferredProtocol = "flv";
+}

+ 10 - 2
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/config/mqtt/MqttBaseConfig.java

@@ -4,36 +4,44 @@ import lombok.Data;
 import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.context.annotation.Bean;
 import org.springframework.integration.mqtt.core.DefaultMqttPahoClientFactory;
 import org.springframework.integration.mqtt.core.MqttPahoClientFactory;
 import org.springframework.stereotype.Component;
 
+@ConditionalOnProperty(prefix = "mqtt", value = {"enabled"}, havingValue = "true")
 @Data
+@Component
 public class MqttBaseConfig {
 
+    @Value("${mqtt.username}")
     private String username;
 
+    @Value("${mqtt.password}")
     private String password;
 
+    @Value("${mqtt.url}")
     private String hostUrl;
 
+    @Value("${mqtt.sub-topics:}")
     private String msgTopic;
 
     //心跳间隔
+    @Value("${mqtt.keep-alive-interval:60}")
     private int keepAliveInterval;
     
     //完成超时
+    @Value("${mqtt.completionTimeout:3000}")
     private int completionTimeout;
 
 
+    @Bean
     public MqttPahoClientFactory mqttClientFactory() {
         DefaultMqttPahoClientFactory factory = new DefaultMqttPahoClientFactory();
         MqttConnectOptions options = new MqttConnectOptions();
         options.setServerURIs(new String[]{this.getHostUrl()});
         options.setUserName(this.getUsername());
-        if (this.getPassword() != null) {
+        if (this.getPassword() != null && !this.getPassword().isEmpty()) {
             options.setPassword(this.getPassword().toCharArray());
         }
         options.setKeepAliveInterval(this.getKeepAliveInterval());

+ 5 - 31
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/config/mqtt/MqttOutConfig.java

@@ -1,11 +1,11 @@
 package com.usky.cdi.service.config.mqtt;
 
-import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.context.annotation.Bean;
 import org.springframework.integration.annotation.MessagingGateway;
 import org.springframework.integration.annotation.ServiceActivator;
 import org.springframework.integration.channel.DirectChannel;
-import org.springframework.integration.mqtt.core.DefaultMqttPahoClientFactory;
+import org.springframework.integration.mqtt.core.MqttPahoClientFactory;
 import org.springframework.integration.mqtt.outbound.MqttPahoMessageHandler;
 import org.springframework.integration.mqtt.support.MqttHeaders;
 import org.springframework.messaging.MessageChannel;
@@ -19,9 +19,9 @@ import java.util.Map;
  * @author han
  * @date 2025/03/20 14:31
  */
+@ConditionalOnProperty(prefix = "mqtt", value = {"enabled"}, havingValue = "true")
 @Component
 public class MqttOutConfig {
-    public MqttBaseConfig mqttBaseConfig;
 
     public static final String CHANNEL_NAME_OUT = "mqttOutboundChannel";
 
@@ -49,41 +49,15 @@ public class MqttOutConfig {
      */
     @Bean(name = MESSAGE_NAME)
     @ServiceActivator(inputChannel = CHANNEL_NAME_OUT)
-    public MessageHandler outbound(DefaultMqttPahoClientFactory factory) {
-        // 注意:这里的client-id暂时使用固定值,因为username在启动时还不可用
-        // 实际使用时,会在createMqttConnection方法中重新设置
-        String clientId = "mqttx-" + System.currentTimeMillis();
+    public MessageHandler outbound(MqttPahoClientFactory factory) {
+        String clientId = "mqttx-out-" + System.currentTimeMillis();
         MqttPahoMessageHandler messageHandler =
                 new MqttPahoMessageHandler(clientId, factory);
-        // 如果设置成true,发送消息时将不会阻塞。
         messageHandler.setAsync(true);
         messageHandler.setDefaultTopic(DEFAULT_TOPIC);
         return messageHandler;
     }
 
-    /**
-     * MQTT客户端工厂
-     * 注意:这个方法会被Spring自动创建,用于创建MQTT客户端
-     *
-     * @return DefaultMqttPahoClientFactory实例
-     */
-    @Bean
-    public DefaultMqttPahoClientFactory mqttClientFactory() {
-        // 创建默认的MqttPahoClientFactory
-        DefaultMqttPahoClientFactory factory = new DefaultMqttPahoClientFactory();
-
-        // 设置默认的MqttConnectOptions,确保serverURIs不为null
-        // 使用时,会在createMqttConnection方法中重新配置
-        MqttConnectOptions options = new MqttConnectOptions();
-        // 设置默认的服务器地址
-        options.setServerURIs(new String[]{"ssl://114.80.201.143:8883"});
-        // 设置默认的心跳间隔
-        options.setKeepAliveInterval(60);
-        factory.setConnectionOptions(options);
-
-        return factory;
-    }
-
     // 注意:这个接口需要被Spring扫描到,所以我们保留@MessagingGateway注解
     // Spring会自动创建这个接口的实现类
     @MessagingGateway(defaultRequestChannel = CHANNEL_NAME_OUT)

+ 4 - 2
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/enums/MqttTopics.java

@@ -18,7 +18,8 @@ public final class MqttTopics {
         ENGINEERING("base/engineering", "人防工程基础信息"),
         PROTECTIVE_UNIT("base/protectiveUnit", "防护单元基础信息"),
         FLOOR_PLANE("base/floorPlane", "楼层平面图信息"),
-        SENSOR_INFO("base/sensorInfo", "智能监管物联设施数据");
+        SENSOR_INFO("base/sensorInfo", "智能监管物联设施数据"),
+        VIDEO_DEVICE("base/videoDevice", "GB28181视频设备目录");
 
         private final String topic;
         private final String desc;
@@ -74,7 +75,8 @@ public final class MqttTopics {
         // 人员闯入情况
         PERSON_PRESENCE("iotInfo/personPresence", "人员闯入情况"),
         // 用电负荷
-        ELECTRICITY_LOAD("iotInfo/electricityLoad", "人防用电负荷");
+        ELECTRICITY_LOAD("iotInfo/electricityLoad", "人防用电负荷"),
+        VIDEO_STREAM("iotInfo/videoStream", "GB28181视频流地址");
 
         private final String topic;
         private final String desc;

+ 0 - 1
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/BaseDataTransferService.java

@@ -170,7 +170,6 @@ public class BaseDataTransferService {
             if (vo.getPublishTime() == null) {
                 vo.setPublishTime(getCurrentTime());
             }
-
             String imagePath = "D://142.jpg";
             // 将图片文件读取为字节数组
             byte[] imageBytes = Files.readAllBytes(Paths.get(imagePath));

+ 69 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/SipRtspPushService.java

@@ -0,0 +1,69 @@
+package com.usky.cdi.service.impl;
+
+import com.usky.cdi.service.sip.config.SipClientProperties;
+import com.usky.cdi.service.sip.device.Gb28181DeviceSimulator;
+import com.usky.cdi.service.sip.device.Gb28181StreamResult;
+import com.usky.common.core.exception.BusinessException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * GB28181 SIP 模拟摄像机推流:手动 REGISTER + RTSP → PS/RTP。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@ConditionalOnProperty(prefix = "sip.client", name = "enabled", havingValue = "true")
+public class SipRtspPushService {
+
+    private final SipClientProperties properties;
+    private final Gb28181DeviceSimulator deviceSimulator;
+
+    public Map<String, Object> pushRtspStream(String rtspUrl, Integer durationSeconds) {
+        if (!StringUtils.hasText(rtspUrl)) {
+            throw new BusinessException("rtspUrl 不能为空");
+        }
+        int duration = durationSeconds != null ? durationSeconds : properties.getDefaultDurationSeconds();
+        Gb28181StreamResult result = deviceSimulator.bindRtspAndWait(
+                rtspUrl, properties.getInviteWaitSeconds(), duration);
+        return toMap(result);
+    }
+
+    public Map<String, Object> registerDevice(String deviceId) {
+        try {
+            return deviceSimulator.register(deviceId);
+        } catch (IllegalArgumentException | IllegalStateException e) {
+            throw new BusinessException(e.getMessage());
+        }
+    }
+
+    public Map<String, Object> unregisterDevice() {
+        return deviceSimulator.unregister();
+    }
+
+    public Map<String, Object> deviceStatus() {
+        return deviceSimulator.status();
+    }
+
+    private Map<String, Object> toMap(Gb28181StreamResult result) {
+        Map<String, Object> map = new HashMap<>();
+        map.put("success", result.isSuccess());
+        map.put("rtspUrl", result.getRtspUrl());
+        map.put("deviceId", deviceSimulator.status().get("deviceId"));
+        map.put("serverHost", properties.getServerHost());
+        map.put("serverPort", properties.getServerPort());
+        map.put("callId", result.getCallId());
+        map.put("localRtpPort", result.getLocalRtpPort());
+        map.put("remoteRtpHost", result.getRemoteRtpHost());
+        map.put("remoteRtpPort", result.getRemoteRtpPort());
+        map.put("packetsSent", result.getPacketsSent());
+        map.put("errors", result.getErrors());
+        return map;
+    }
+}

+ 144 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/VideoDataTransferService.java

@@ -0,0 +1,144 @@
+package com.usky.cdi.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.usky.cdi.service.CdiDeliveryLogService;
+import com.usky.cdi.service.config.Gb28181VideoProperties;
+import com.usky.cdi.service.enums.MqttTopics;
+import com.usky.cdi.service.mqtt.MqttConnectionTool;
+import com.usky.cdi.service.util.Gb28181MqttCredentialResolver;
+import com.usky.cdi.service.util.Gb28181VideoPlatformClient;
+import com.usky.cdi.service.util.SnowflakeIdGenerator;
+import com.usky.cdi.service.vo.video.VideoChannelVO;
+import com.usky.cdi.service.vo.video.VideoDeviceItemVO;
+import com.usky.cdi.service.vo.video.VideoDeviceSyncPacketVO;
+import com.usky.common.core.exception.BusinessException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import javax.annotation.PostConstruct;
+import java.text.SimpleDateFormat;
+import java.time.LocalDateTime;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * GB28181 视频设备目录同步:WVP HTTP API 拉取 → MQTT 上报市平台。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@ConditionalOnProperty(prefix = "gb28181.video", name = "enabled", havingValue = "true")
+public class VideoDataTransferService {
+
+    private static final int DELIVERY_DATA_TYPE = 5;
+
+    @Value("${snowflake.worker-id:4}")
+    private long workerId;
+
+    @Value("${snowflake.data-center-id:4}")
+    private long dataCenterId;
+
+    private final Gb28181VideoProperties properties;
+    private final Gb28181VideoPlatformClient platformClient;
+    private final MqttConnectionTool mqttConnectionTool;
+    private final CdiDeliveryLogService cdiDeliveryLogService;
+    private final Gb28181MqttCredentialResolver mqttCredentialResolver;
+
+    private SnowflakeIdGenerator idGenerator;
+    private final SimpleDateFormat timeFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
+
+    @PostConstruct
+    public void init() {
+        this.idGenerator = new SnowflakeIdGenerator(workerId, dataCenterId);
+    }
+
+    public JSONObject checkConfiguration() {
+        return platformClient.checkConfiguration();
+    }
+
+    public void synchronizeVideoDevices() {
+        Gb28181MqttCredentialResolver.MqttCredentials credentials = mqttCredentialResolver.resolve();
+        long startTime = System.currentTimeMillis();
+        int total = 0;
+        int failure = 0;
+
+        log.info("开始同步 GB28181 视频设备目录: engineeringId={}", credentials.getEngineeringId());
+        List<VideoDeviceItemVO> devices = platformClient.queryAllDevicesWithChannels();
+
+        if (properties.isFetchPlayUrl()) {
+            enrichPlayUrls(devices);
+        }
+
+        for (VideoDeviceItemVO device : devices) {
+            if (device.getChannels() != null) {
+                total += device.getChannels().size();
+            } else {
+                total++;
+            }
+        }
+
+        String topic = resolveDeviceTopic();
+        VideoDeviceSyncPacketVO packet = buildDeviceSyncPacket(credentials.getEngineeringId(), devices);
+        String jsonMessage = JSON.toJSONString(packet);
+
+        try {
+            MqttConnectionTool.MqttGateway gateway = mqttConnectionTool.connectOrRefresh(
+                    credentials.getUsername(), credentials.getPassword());
+            gateway.sendToMqtt(topic, jsonMessage);
+            log.info("视频设备目录 MQTT 上报成功: topic={}, devices={}, channels={}",
+                    topic, devices.size(), total);
+        } catch (Exception e) {
+            failure = total;
+            log.error("视频设备目录 MQTT 上报失败: {}", e.getMessage(), e);
+            throw new BusinessException("视频设备目录 MQTT 上报失败: " + e.getMessage());
+        } finally {
+            long endTime = System.currentTimeMillis();
+            int success = total - failure;
+            cdiDeliveryLogService.saveLog(topic, MqttTopics.Base.VIDEO_DEVICE.getDesc(), DELIVERY_DATA_TYPE,
+                    credentials.getTenantId(), credentials.getEngineeringId(), LocalDateTime.now(),
+                    startTime, endTime, total, success, failure, 0, failure > 0 ? 0 : 1, "自动同步");
+        }
+    }
+
+    private void enrichPlayUrls(List<VideoDeviceItemVO> devices) {
+        for (VideoDeviceItemVO device : devices) {
+            if (device.getChannels() == null) {
+                continue;
+            }
+            for (VideoChannelVO channel : device.getChannels()) {
+                if (!"ON".equalsIgnoreCase(channel.getStatus())) {
+                    continue;
+                }
+                try {
+                    channel.setPlayUrl(platformClient.queryPlayUrl(device.getDeviceId(), channel.getChannelId()));
+                } catch (Exception e) {
+                    log.warn("获取预览地址失败 deviceId={} channelId={}: {}",
+                            device.getDeviceId(), channel.getChannelId(), e.getMessage());
+                }
+            }
+        }
+    }
+
+    private VideoDeviceSyncPacketVO buildDeviceSyncPacket(Long engineeringId, List<VideoDeviceItemVO> devices) {
+        VideoDeviceSyncPacketVO packet = new VideoDeviceSyncPacketVO();
+        packet.setDataPacketID(idGenerator.nextPacketId());
+        packet.setEngineeringID(engineeringId);
+        packet.setPublishTime(timeFormat.format(new Date()));
+        packet.setPlatformSipId(properties.getSipId());
+        packet.setPlatformSipDomain(properties.getSipDomain());
+        packet.setDevices(devices);
+        return packet;
+    }
+
+    private String resolveDeviceTopic() {
+        if (StringUtils.hasText(properties.getMqttTopic())) {
+            return properties.getMqttTopic();
+        }
+        return MqttTopics.Base.VIDEO_DEVICE.getTopic();
+    }
+}

+ 209 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/VideoStreamPushService.java

@@ -0,0 +1,209 @@
+package com.usky.cdi.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.usky.cdi.service.CdiDeliveryLogService;
+import com.usky.cdi.service.config.Gb28181VideoProperties;
+import com.usky.cdi.service.enums.MqttTopics;
+import com.usky.cdi.service.mqtt.MqttConnectionTool;
+import com.usky.cdi.service.util.Gb28181MqttCredentialResolver;
+import com.usky.cdi.service.util.Gb28181VideoPlatformClient;
+import com.usky.cdi.service.util.SnowflakeIdGenerator;
+import com.usky.cdi.service.vo.video.VideoChannelVO;
+import com.usky.cdi.service.vo.video.VideoDeviceItemVO;
+import com.usky.cdi.service.vo.video.VideoPlayInfoVO;
+import com.usky.cdi.service.vo.video.VideoStreamItemVO;
+import com.usky.cdi.service.vo.video.VideoStreamSyncPacketVO;
+import com.usky.common.core.exception.BusinessException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import javax.annotation.PostConstruct;
+import java.text.SimpleDateFormat;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * WVP 点播推流:调用平台 API 拉流,并通过 MQTT 上报播放地址。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@ConditionalOnProperty(prefix = "gb28181.video", name = "enabled", havingValue = "true")
+public class VideoStreamPushService {
+
+    private static final int DELIVERY_DATA_TYPE = 6;
+
+    @Value("${snowflake.worker-id:5}")
+    private long workerId;
+
+    @Value("${snowflake.data-center-id:5}")
+    private long dataCenterId;
+
+    private final Gb28181VideoProperties properties;
+    private final Gb28181VideoPlatformClient platformClient;
+    private final MqttConnectionTool mqttConnectionTool;
+    private final CdiDeliveryLogService cdiDeliveryLogService;
+    private final Gb28181MqttCredentialResolver mqttCredentialResolver;
+
+    private final Map<String, VideoPlayInfoVO> activeSessions = new ConcurrentHashMap<>();
+    private SnowflakeIdGenerator idGenerator;
+    private final SimpleDateFormat timeFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
+
+    @PostConstruct
+    public void init() {
+        this.idGenerator = new SnowflakeIdGenerator(workerId, dataCenterId);
+    }
+
+    public VideoPlayInfoVO pushStream(String deviceId, String channelId) {
+        validateIds(deviceId, channelId);
+        VideoPlayInfoVO playInfo = platformClient.startPlay(deviceId, channelId);
+        activeSessions.put(sessionKey(deviceId, channelId), playInfo);
+        log.info("WVP 点播成功: deviceId={}, channelId={}, url={}",
+                deviceId, channelId, playInfo.getPreferredPlayUrl(properties.getPreferredProtocol()));
+        return playInfo;
+    }
+
+    public VideoStreamSyncPacketVO pushAllOnlineStreams() {
+        Gb28181MqttCredentialResolver.MqttCredentials credentials = mqttCredentialResolver.resolve();
+        long startTime = System.currentTimeMillis();
+        List<VideoStreamItemVO> streamItems = new ArrayList<>();
+        int total = 0;
+        int failure = 0;
+
+        List<VideoDeviceItemVO> devices = platformClient.queryAllDevicesWithChannels();
+        int max = Math.max(1, properties.getMaxConcurrentStreams());
+        int pushed = 0;
+
+        for (VideoDeviceItemVO device : devices) {
+            if (!"ON".equalsIgnoreCase(device.getStatus()) || device.getChannels() == null) {
+                continue;
+            }
+            for (VideoChannelVO channel : device.getChannels()) {
+                if (!"ON".equalsIgnoreCase(channel.getStatus()) || pushed >= max) {
+                    continue;
+                }
+                total++;
+                try {
+                    VideoPlayInfoVO playInfo = pushStream(device.getDeviceId(), channel.getChannelId());
+                    streamItems.add(toStreamItem(device, channel, playInfo));
+                    pushed++;
+                } catch (Exception e) {
+                    failure++;
+                    log.warn("点播失败 deviceId={} channelId={}: {}",
+                            device.getDeviceId(), channel.getChannelId(), e.getMessage());
+                    streamItems.add(toFailedStreamItem(device, channel, e.getMessage()));
+                }
+            }
+        }
+
+        VideoStreamSyncPacketVO packet = buildStreamSyncPacket(credentials.getEngineeringId(), streamItems);
+        publishStreamPacket(credentials, packet, startTime, total, failure);
+        return packet;
+    }
+
+    public void maintainActiveStreams() {
+        if (!properties.isStreamPushEnabled()) {
+            return;
+        }
+        if (properties.isAutoPushAllOnline()) {
+            pushAllOnlineStreams();
+            return;
+        }
+        log.debug("当前 WVP 活跃推流会话数: {}", activeSessions.size());
+    }
+
+    public void stopStream(String deviceId, String channelId) {
+        validateIds(deviceId, channelId);
+        platformClient.stopPlay(deviceId, channelId);
+        VideoPlayInfoVO removed = activeSessions.remove(sessionKey(deviceId, channelId));
+        if (removed != null) {
+            removed.setStreamStatus("STOPPED");
+        }
+        log.info("已停止 WVP 点播: deviceId={}, channelId={}", deviceId, channelId);
+    }
+
+    public Map<String, VideoPlayInfoVO> getActiveSessions() {
+        return Collections.unmodifiableMap(new LinkedHashMap<>(activeSessions));
+    }
+
+    private void publishStreamPacket(Gb28181MqttCredentialResolver.MqttCredentials credentials,
+                                     VideoStreamSyncPacketVO packet, long startTime,
+                                     int total, int failure) {
+        String topic = resolveStreamTopic();
+        String jsonMessage = JSON.toJSONString(packet);
+        try {
+            MqttConnectionTool.MqttGateway gateway = mqttConnectionTool.connectOrRefresh(
+                    credentials.getUsername(), credentials.getPassword());
+            gateway.sendToMqtt(topic, jsonMessage);
+            log.info("视频流 MQTT 上报成功: topic={}, streams={}", topic, packet.getStreams().size());
+        } catch (Exception e) {
+            failure = total;
+            log.error("视频流 MQTT 上报失败: {}", e.getMessage(), e);
+            throw new BusinessException("视频流 MQTT 上报失败: " + e.getMessage());
+        } finally {
+            long endTime = System.currentTimeMillis();
+            int success = total - failure;
+            cdiDeliveryLogService.saveLog(topic, MqttTopics.IotInfo.VIDEO_STREAM.getDesc(), DELIVERY_DATA_TYPE,
+                    credentials.getTenantId(), credentials.getEngineeringId(), LocalDateTime.now(),
+                    startTime, endTime, total, success, failure, 0, failure > 0 ? 0 : 1, "自动同步");
+        }
+    }
+
+    private VideoStreamSyncPacketVO buildStreamSyncPacket(Long engineeringId, List<VideoStreamItemVO> streams) {
+        VideoStreamSyncPacketVO packet = new VideoStreamSyncPacketVO();
+        packet.setDataPacketID(idGenerator.nextPacketId());
+        packet.setEngineeringID(engineeringId);
+        packet.setPublishTime(timeFormat.format(new Date()));
+        packet.setMediaServerIp(properties.getSipIp());
+        packet.setStreams(streams);
+        return packet;
+    }
+
+    private VideoStreamItemVO toStreamItem(VideoDeviceItemVO device, VideoChannelVO channel, VideoPlayInfoVO playInfo) {
+        VideoStreamItemVO item = new VideoStreamItemVO();
+        item.setDeviceId(device.getDeviceId());
+        item.setDeviceName(device.getDeviceName());
+        item.setChannelId(channel.getChannelId());
+        item.setChannelName(channel.getChannelName());
+        item.setStreamStatus(playInfo.getStreamStatus());
+        item.setPlayUrl(playInfo.getPreferredPlayUrl(properties.getPreferredProtocol()));
+        item.setFlvUrl(playInfo.getFlvUrl());
+        item.setHlsUrl(playInfo.getHlsUrl());
+        item.setRtspUrl(playInfo.getRtspUrl());
+        return item;
+    }
+
+    private VideoStreamItemVO toFailedStreamItem(VideoDeviceItemVO device, VideoChannelVO channel, String message) {
+        VideoStreamItemVO item = toStreamItem(device, channel, new VideoPlayInfoVO());
+        item.setStreamStatus("FAILED");
+        item.setPlayUrl(message);
+        return item;
+    }
+
+    private static void validateIds(String deviceId, String channelId) {
+        if (!StringUtils.hasText(deviceId) || !StringUtils.hasText(channelId)) {
+            throw new BusinessException("deviceId 与 channelId 不能为空");
+        }
+    }
+
+    private static String sessionKey(String deviceId, String channelId) {
+        return deviceId + "_" + channelId;
+    }
+
+    private String resolveStreamTopic() {
+        if (StringUtils.hasText(properties.getMqttStreamTopic())) {
+            return properties.getMqttStreamTopic();
+        }
+        return MqttTopics.IotInfo.VIDEO_STREAM.getTopic();
+    }
+}

+ 66 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/job/VideoDeviceSyncJob.java

@@ -0,0 +1,66 @@
+package com.usky.cdi.service.job;
+
+import com.alibaba.fastjson.JSONObject;
+import com.usky.cdi.service.config.Gb28181VideoProperties;
+import com.usky.cdi.service.impl.VideoDataTransferService;
+import com.usky.cdi.service.impl.VideoStreamPushService;
+import com.usky.cdi.service.util.Gb28181VideoPlatformClient;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * GB28181 视频对接定时任务
+ * <p>
+ * 基于国标流媒体平台(WVP 等)HTTP API 拉取设备/通道目录,并通过 MQTT 上报至市适配平台。
+ * SIP 参数与平台「国标级联」配置页一致。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+@ConditionalOnProperty(prefix = "gb28181.video", name = "enabled", havingValue = "true")
+public class VideoDeviceSyncJob {
+
+    private final VideoDataTransferService videoDataTransferService;
+    private final VideoStreamPushService videoStreamPushService;
+    private final Gb28181VideoPlatformClient platformClient;
+    private final Gb28181VideoProperties properties;
+
+    /**
+     * 定时同步视频设备目录
+     */
+    @Scheduled(cron = "${gb28181.video.sync-cron:0 0/30 * * * ?}")
+    public void scheduledVideoDeviceSync() {
+        log.info("开始执行 GB28181 视频设备同步定时任务");
+        try {
+            videoDataTransferService.synchronizeVideoDevices();
+        } catch (Exception e) {
+            log.error("GB28181 视频设备同步定时任务失败:{}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 定时推流:WVP 点播拉流 + MQTT 上报播放地址
+     */
+    @Scheduled(cron = "${gb28181.video.stream-push-cron:0 */5 * * * ?}")
+    public void scheduledVideoStreamPush() {
+        if (!properties.isStreamPushEnabled()) {
+            return;
+        }
+        log.info("开始执行 GB28181 视频流推送定时任务");
+        try {
+            videoStreamPushService.maintainActiveStreams();
+        } catch (Exception e) {
+            log.error("GB28181 视频流推送定时任务失败:{}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 检查国标配置(对应页面「检查配置」),可供 Controller 或运维手动调用
+     */
+    public JSONObject checkGb28181Configuration() {
+        return platformClient.checkConfiguration();
+    }
+}

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

@@ -0,0 +1,32 @@
+package com.usky.cdi.service.sip.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * GB28181 配置(与海康摄像机 Web 配置项对应)
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "sip.client")
+public class SipClientProperties {
+
+    private boolean enabled = true;
+    /** 启动时自动注册(默认关闭,改用手动 API) */
+    private boolean autoStart = false;
+
+    private String serverHost = "114.80.201.142";
+    private int serverPort = 15060;
+    private String domain = "3402000000";
+    private String platformId = "34020000002000000001";
+    /** 仅作兼容保留,实际设备 ID 由注册接口动态传入 */
+    private String deviceId;
+    private String password = "jkjj_wlgz";
+    private String sipTransport = "tcp";
+    private int registerExpires = 3600;
+
+    private int inviteWaitSeconds = 300;
+    private int defaultDurationSeconds = 60;
+    private String rtspTransport = "tcp";
+}

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

@@ -0,0 +1,23 @@
+package com.usky.cdi.service.sip.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "sip.server")
+public class SipServerProperties {
+
+    private boolean enabled = true;
+    private String host = "0.0.0.0";
+    private int port = 5060;
+    private String localIp;
+    private String platformId = "34020000002000000001";
+    private String domain = "3402000000";
+    private int rtpPortMin = 30000;
+    private int rtpPortMax = 30500;
+    private boolean saveSnapshot = false;
+    private String snapshotDir = "./sip-snapshots";
+    private int snapshotInterval = 10;
+}

+ 679 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/device/Gb28181DeviceSimulator.java

@@ -0,0 +1,679 @@
+package com.usky.cdi.service.sip.device;
+
+import com.usky.cdi.service.sip.config.SipClientProperties;
+import com.usky.cdi.service.sip.model.SdpMediaInfo;
+import com.usky.cdi.service.sip.push.RtspGb28181RtpSender;
+import com.usky.cdi.service.sip.sdp.SdpUtils;
+import com.usky.cdi.service.sip.util.NetworkUtils;
+import com.usky.cdi.service.sip.util.SipDigestAuth;
+import gov.nist.javax.sip.RequestEventExt;
+import gov.nist.javax.sip.SipStackImpl;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import javax.annotation.PreDestroy;
+import javax.sip.*;
+import javax.sip.address.Address;
+import javax.sip.address.AddressFactory;
+import javax.sip.address.SipURI;
+import javax.sip.header.*;
+import javax.sip.message.MessageFactory;
+import javax.sip.message.Request;
+import javax.sip.message.Response;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * 模拟海康摄像机 GB28181 接入:手动 REGISTER,平台点播 INVITE 后从 RTSP 推流。
+ */
+@Slf4j
+@Service
+@ConditionalOnProperty(prefix = "sip.client", name = "enabled", havingValue = "true")
+public class Gb28181DeviceSimulator implements SipListener {
+
+    private static final String GB28181_SSRC = "0100000001";
+
+    private final SipClientProperties properties;
+    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(
+            r -> new Thread(r, "gb28181-device"));
+
+    private SipStack sipStack;
+    private SipProvider sipProvider;
+    private AddressFactory addressFactory;
+    private MessageFactory messageFactory;
+    private HeaderFactory headerFactory;
+
+    private String sipTransport;
+    private String localIp;
+    private int localSipPort;
+    private int cseq = 1;
+
+    private final AtomicBoolean registered = new AtomicBoolean(false);
+    private final AtomicBoolean registerAuthSent = new AtomicBoolean(false);
+    private final AtomicReference<WWWAuthenticateHeader> authChallenge = new AtomicReference<>();
+    private final AtomicReference<String> boundRtspUrl = new AtomicReference<>();
+    private final AtomicReference<PendingStream> pendingStream = new AtomicReference<>();
+    private volatile Future<?> streamingTask;
+    private volatile ScheduledFuture<?> reRegisterTask;
+    private volatile String activeDeviceId;
+
+    public Gb28181DeviceSimulator(SipClientProperties properties) {
+        this.properties = properties;
+    }
+
+    @EventListener(ApplicationReadyEvent.class)
+    public void onReady() {
+        if (properties.isAutoStart()) {
+            log.warn("sip.client.auto-start=true 已废弃,请使用 POST /api/sip/device/register?deviceId=...");
+        } else {
+            log.info("GB28181 设备模拟就绪,请手动调用 POST /api/sip/device/register?deviceId=... 注册");
+        }
+    }
+
+    @PreDestroy
+    public void destroy() {
+        cancelReRegister();
+        try {
+            unregisterInternal(true);
+        } catch (Exception e) {
+            log.debug("销毁时注销失败: {}", e.getMessage());
+        }
+        scheduler.shutdownNow();
+    }
+
+    /**
+     * 手动注册到 GB28181 平台。
+     *
+     * @param deviceId 20 位设备编码(动态传入)
+     */
+    public synchronized Map<String, Object> register(String deviceId) {
+        validateDeviceId(deviceId);
+        if (registered.get() && deviceId.equals(activeDeviceId)) {
+            log.info("设备已注册: deviceId={}", activeDeviceId);
+            return status();
+        }
+        if (sipStack != null) {
+            unregisterInternal(true);
+        }
+        try {
+            activeDeviceId = deviceId.trim();
+            initStack();
+            registerAuthSent.set(false);
+            authChallenge.set(null);
+            sendRegister(false, properties.getRegisterExpires());
+            scheduleReRegister();
+            log.info("GB28181 注册请求已发送: deviceId={}, platform={}@{}:{}, transport={}",
+                    activeDeviceId, properties.getPlatformId(),
+                    properties.getServerHost(), properties.getServerPort(), sipTransport);
+        } catch (Exception e) {
+            activeDeviceId = null;
+            shutdownStack();
+            sipStack = null;
+            sipProvider = null;
+            throw new IllegalStateException("GB28181 注册失败: " + e.getMessage(), e);
+        }
+        return status();
+    }
+
+    /** 手动注销(REGISTER Expires=0)并释放 SIP 栈。 */
+    public synchronized Map<String, Object> unregister() {
+        unregisterInternal(true);
+        return status();
+    }
+
+    private void unregisterInternal(boolean sendExpiresZero) {
+        cancelReRegister();
+        if (streamingTask != null) {
+            streamingTask.cancel(true);
+            streamingTask = null;
+        }
+        if (sendExpiresZero && sipStack != null && StringUtils.hasText(activeDeviceId)) {
+            try {
+                registerAuthSent.set(false);
+                sendRegister(registered.get(), 0);
+                log.info("GB28181 注销请求已发送: deviceId={}", activeDeviceId);
+            } catch (Exception e) {
+                log.warn("GB28181 注销请求发送失败: {}", e.getMessage());
+            }
+        }
+        registered.set(false);
+        activeDeviceId = null;
+        boundRtspUrl.set(null);
+        pendingStream.set(null);
+        shutdownStack();
+        sipStack = null;
+        sipProvider = null;
+    }
+
+    /**
+     * 绑定 RTSP 并等待平台点播(与海康摄像机:配置好平台后,平台发 INVITE 取流)。
+     */
+    public Gb28181StreamResult bindRtspAndWait(String rtspUrl, int inviteWaitSeconds, int streamDurationSeconds) {
+        if (!StringUtils.hasText(rtspUrl)) {
+            return fail("rtspUrl 不能为空");
+        }
+        if (!registered.get() || !StringUtils.hasText(activeDeviceId)) {
+            return fail("设备未注册,请先调用 POST /api/sip/device/register?deviceId=...");
+        }
+        if (sipStack == null) {
+            return fail("SIP 栈未就绪,请重新注册设备");
+        }
+
+        PendingStream session = new PendingStream(rtspUrl, streamDurationSeconds);
+        pendingStream.set(session);
+        boundRtspUrl.set(rtspUrl);
+
+        log.info("已绑定 RTSP,等待平台 INVITE: deviceId={}, wait={}s", activeDeviceId, inviteWaitSeconds);
+        log.info(">>> 请在 GB28181 平台对设备 {} 点击「实时预览」", activeDeviceId);
+
+        try {
+            if (!session.inviteReady.await(inviteWaitSeconds, TimeUnit.SECONDS)) {
+                pendingStream.compareAndSet(session, null);
+                return fail("等待平台 INVITE 超时,请在平台对设备 " + activeDeviceId + " 发起实时预览");
+            }
+            long streamWait = streamDurationSeconds + 15L;
+            if (!session.streamDone.await(streamWait, TimeUnit.SECONDS)) {
+                return fail("推流超时");
+            }
+            return session.result != null ? session.result : fail("推流未返回结果");
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return fail("等待被中断");
+        }
+    }
+
+    public Map<String, Object> status() {
+        Map<String, Object> map = new HashMap<>();
+        map.put("registered", registered.get());
+        map.put("deviceId", activeDeviceId);
+        map.put("platformId", properties.getPlatformId());
+        map.put("serverHost", properties.getServerHost());
+        map.put("serverPort", properties.getServerPort());
+        map.put("domain", properties.getDomain());
+        map.put("sipTransport", sipTransport);
+        map.put("localSipAddress", localIp != null ? localIp + ":" + localSipPort : null);
+        map.put("boundRtspUrl", boundRtspUrl.get());
+        map.put("streaming", streamingTask != null && !streamingTask.isDone());
+        return map;
+    }
+
+    private void initStack() throws Exception {
+        SipFactory sipFactory = SipFactory.getInstance();
+        sipFactory.setPathName("gov.nist");
+        java.util.Properties stackProps = new java.util.Properties();
+        stackProps.setProperty("javax.sip.STACK_NAME", "UskyGb28181Device");
+        stackProps.setProperty("gov.nist.javax.sip.TRACE_LEVEL", "0");
+
+        localIp = NetworkUtils.resolveLocalIp(null);
+        sipTransport = normalizeTransport(properties.getSipTransport());
+        stackProps.setProperty("javax.sip.OUTBOUND_PROXY",
+                properties.getServerHost() + ":" + properties.getServerPort() + "/" + sipTransport);
+
+        sipStack = sipFactory.createSipStack(stackProps);
+        addressFactory = sipFactory.createAddressFactory();
+        messageFactory = sipFactory.createMessageFactory();
+        headerFactory = sipFactory.createHeaderFactory();
+
+        localSipPort = findAvailablePort();
+        ListeningPoint lp = sipStack.createListeningPoint(localIp, localSipPort, sipTransport);
+        sipProvider = sipStack.createSipProvider(lp);
+        sipProvider.addSipListener(this);
+    }
+
+    private void scheduleReRegister() {
+        cancelReRegister();
+        long interval = Math.max(60, (long) (properties.getRegisterExpires() * 0.85));
+        reRegisterTask = scheduler.scheduleAtFixedRate(() -> {
+            if (sipStack != null && StringUtils.hasText(activeDeviceId)) {
+                try {
+                    registerAuthSent.set(false);
+                    sendRegister(registered.get(), properties.getRegisterExpires());
+                } catch (Exception e) {
+                    log.warn("GB28181 续注册失败: {}", e.getMessage());
+                }
+            }
+        }, interval, interval, TimeUnit.SECONDS);
+    }
+
+    private void cancelReRegister() {
+        if (reRegisterTask != null) {
+            reRegisterTask.cancel(false);
+            reRegisterTask = null;
+        }
+    }
+
+    private void sendRegister(boolean withAuth, int expires) throws Exception {
+        ensureActiveDeviceId();
+        SipURI requestUri = createUri(activeDeviceId, properties.getDomain());
+        requestUri.setPort(properties.getServerPort());
+        requestUri.setTransportParam(sipTransport);
+
+        Request register = buildRequest(Request.REGISTER, requestUri, activeDeviceId, "reg-tag");
+        register.addHeader(headerFactory.createExpiresHeader(expires));
+
+        if (withAuth) {
+            AuthorizationHeader auth = buildAuthorization(Request.REGISTER, requestUri.toString());
+            if (auth != null) {
+                register.addHeader(auth);
+            }
+        }
+
+        ClientTransaction ct = sipProvider.getNewClientTransaction(register);
+        ct.sendRequest();
+        log.debug("发送 REGISTER{} -> {}:{}", withAuth ? "(鉴权)" : "", properties.getServerHost(), properties.getServerPort());
+    }
+
+    private void handleIncomingInvite(RequestEvent event) throws Exception {
+        Request request = event.getRequest();
+        PendingStream session = pendingStream.get();
+        String rtspUrl = session != null ? session.rtspUrl : boundRtspUrl.get();
+        if (!StringUtils.hasText(rtspUrl)) {
+            sendResponse(event, Response.BUSY_HERE);
+            log.warn("收到平台 INVITE 但未绑定 RTSP,请先调用 POST /api/sip/push?rtspUrl=...");
+            return;
+        }
+
+        String sdpBody = extractSdp(request);
+        if (!StringUtils.hasText(sdpBody)) {
+            sendResponse(event, Response.BAD_REQUEST);
+            failPending(fail("平台 INVITE 无 SDP"));
+            return;
+        }
+
+        log.info("平台 INVITE SDP:\n{}", sdpBody);
+        SdpMediaInfo media = SdpUtils.parseVideoMedia(sdpBody);
+        if (media == null) {
+            sendResponse(event, Response.NOT_ACCEPTABLE);
+            log.warn("平台 INVITE SDP 解析失败,无法识别 video m= 行");
+            failPending(fail("平台 INVITE SDP 无效"));
+            return;
+        }
+
+        if (session == null) {
+            session = new PendingStream(rtspUrl, properties.getDefaultDurationSeconds());
+            pendingStream.set(session);
+        }
+
+        int localRtpPort = findAvailablePort();
+        String remoteHost = resolveRemoteRtpHost(media, event);
+
+        String answerSdp = SdpUtils.buildGb28181AnswerSdp(
+                localIp, localRtpPort, media, activeDeviceId, GB28181_SSRC);
+
+        log.info("应答 SDP:\n{}", answerSdp);
+        Response ok = messageFactory.createResponse(Response.OK, request);
+        ToHeader toHeader = (ToHeader) ok.getHeader(ToHeader.NAME);
+        toHeader.setTag(generateTag());
+        ok.addHeader(createContactHeader());
+        Header subjectHeader = request.getHeader("Subject");
+        if (subjectHeader != null) {
+            ok.addHeader(subjectHeader);
+        }
+        ok.setContent(answerSdp, headerFactory.createContentTypeHeader("application", "sdp"));
+
+        ServerTransaction st = event.getServerTransaction();
+        if (st == null) {
+            st = sipProvider.getNewServerTransaction(request);
+        }
+        st.sendResponse(ok);
+
+        CallIdHeader callIdHeader = (CallIdHeader) request.getHeader(CallIdHeader.NAME);
+        session.callId = callIdHeader != null ? callIdHeader.getCallId() : null;
+        session.localRtpPort = localRtpPort;
+        session.remoteRtpHost = remoteHost;
+        session.remoteRtpPort = media.getPort();
+        if (!StringUtils.hasText(media.getSsrc())) {
+            media.setSsrc(GB28181_SSRC);
+        }
+        session.media = media;
+        session.rtspUrl = rtspUrl;
+
+        log.info("已应答平台 INVITE 200 OK: rtp={}:{}, protocol={}, codec={}, setup={}",
+                remoteHost, media.getPort(), media.getMediaProtocol(), media.getCodec(), media.getSetup());
+    }
+
+    private String extractSdp(Request request) {
+        byte[] raw = request.getRawContent();
+        if (raw != null && raw.length > 0) {
+            return new String(raw, StandardCharsets.UTF_8);
+        }
+        Object content = request.getContent();
+        if (content instanceof byte[]) {
+            return new String((byte[]) content, StandardCharsets.UTF_8);
+        }
+        return content != null ? content.toString() : null;
+    }
+
+    private String resolveRemoteRtpHost(SdpMediaInfo media, RequestEvent event) {
+        String host = media.getConnectionAddress();
+        if (host != null && !"0.0.0.0".equals(host)) {
+            return host;
+        }
+        if (event instanceof RequestEventExt) {
+            RequestEventExt ext = (RequestEventExt) event;
+            if (StringUtils.hasText(ext.getRemoteIpAddress())) {
+                return ext.getRemoteIpAddress();
+            }
+        }
+        return properties.getServerHost();
+    }
+
+    private void handleIncomingAck(RequestEvent event) {
+        PendingStream session = pendingStream.get();
+        if (session == null || session.streamStarted) {
+            return;
+        }
+        session.streamStarted = true;
+        session.inviteReady.countDown();
+        log.info("收到平台 ACK,开始 RTSP 推流: {}", maskUrl(session.rtspUrl));
+
+        streamingTask = scheduler.submit(() -> {
+            try {
+                long packets = RtspGb28181RtpSender.stream(
+                        session.rtspUrl,
+                        session.media,
+                        session.remoteRtpHost,
+                        session.remoteRtpPort,
+                        session.localRtpPort,
+                        session.durationSeconds,
+                        properties.getRtspTransport());
+                session.result = Gb28181StreamResult.builder()
+                        .success(packets > 0)
+                        .rtspUrl(session.rtspUrl)
+                        .callId(session.callId)
+                        .localRtpPort(session.localRtpPort)
+                        .remoteRtpHost(session.remoteRtpHost)
+                        .remoteRtpPort(session.remoteRtpPort)
+                        .packetsSent(packets)
+                        .errors(packets > 0 ? new ArrayList<>() : singleError(
+                                "未发出 RTP 包,请检查 RTSP 地址/网络或是否收到 IDR 关键帧"))
+                        .build();
+                if (packets > 0) {
+                    log.info("推流完成: packets={}", packets);
+                } else {
+                    log.warn("推流结束但未发送 RTP 包");
+                }
+            } catch (Exception e) {
+                log.error("推流失败: {}", e.getMessage(), e);
+                session.result = fail("推流失败: " + e.getMessage());
+            } finally {
+                session.streamDone.countDown();
+                pendingStream.compareAndSet(session, null);
+            }
+        });
+    }
+
+    private void handleIncomingBye(RequestEvent event) throws Exception {
+        log.info("收到平台 BYE");
+        if (streamingTask != null) {
+            streamingTask.cancel(true);
+        }
+        sendResponse(event, Response.OK);
+        PendingStream session = pendingStream.getAndSet(null);
+        if (session != null) {
+            session.inviteReady.countDown();
+            if (session.result == null) {
+                session.result = fail("平台 BYE 结束会话");
+            }
+            session.streamDone.countDown();
+        }
+    }
+
+    private Request buildRequest(String method, SipURI requestUri, String fromUser, String fromTag) throws Exception {
+        Address toAddress = addressFactory.createAddress(requestUri);
+        ToHeader toHeader = headerFactory.createToHeader(toAddress, null);
+
+        SipURI fromUri = createUri(fromUser, properties.getDomain());
+        fromUri.setPort(localSipPort);
+        fromUri.setTransportParam(sipTransport);
+        FromHeader fromHeader = headerFactory.createFromHeader(addressFactory.createAddress(fromUri), fromTag);
+
+        ContactHeader contactHeader = createContactHeader();
+
+        List<ViaHeader> viaHeaders = new ArrayList<>();
+        viaHeaders.add(headerFactory.createViaHeader(localIp, localSipPort, sipTransport, null));
+
+        CSeqHeader cSeqHeader = headerFactory.createCSeqHeader(cseq++, method);
+        Request request = messageFactory.createRequest(requestUri, method, sipProvider.getNewCallId(),
+                cSeqHeader, fromHeader, toHeader, viaHeaders, headerFactory.createMaxForwardsHeader(70));
+        request.addHeader(contactHeader);
+        return request;
+    }
+
+    private ContactHeader createContactHeader() throws Exception {
+        ensureActiveDeviceId();
+        SipURI contactUri = createUri(activeDeviceId, properties.getDomain());
+        contactUri.setHost(localIp);
+        contactUri.setPort(localSipPort);
+        contactUri.setTransportParam(sipTransport);
+        return headerFactory.createContactHeader(addressFactory.createAddress(contactUri));
+    }
+
+    private AuthorizationHeader buildAuthorization(String method, String uri) throws Exception {
+        WWWAuthenticateHeader challenge = authChallenge.get();
+        if (challenge == null) {
+            return null;
+        }
+        String cnonce = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
+        String nc = "00000001";
+        String response = SipDigestAuth.computeResponse(
+                activeDeviceId, properties.getPassword(), challenge.getRealm(),
+                method, uri, challenge.getNonce(), nc, cnonce, challenge.getQop());
+
+        AuthorizationHeader auth = headerFactory.createAuthorizationHeader("Digest");
+        auth.setUsername(activeDeviceId);
+        auth.setRealm(challenge.getRealm());
+        auth.setNonce(challenge.getNonce());
+        auth.setURI(addressFactory.createURI(uri));
+        auth.setResponse(response);
+        auth.setAlgorithm("MD5");
+        if (challenge.getQop() != null && !challenge.getQop().isEmpty()) {
+            auth.setQop(challenge.getQop());
+            auth.setCNonce(cnonce);
+            auth.setNonceCount(Integer.parseInt(nc, 16));
+        }
+        return auth;
+    }
+
+    private void sendResponse(RequestEvent event, int code) throws Exception {
+        Response response = messageFactory.createResponse(code, event.getRequest());
+        if (code >= 200) {
+            ToHeader toHeader = (ToHeader) response.getHeader(ToHeader.NAME);
+            if (toHeader.getTag() == null) {
+                toHeader.setTag(generateTag());
+            }
+        }
+        ServerTransaction st = event.getServerTransaction();
+        if (st == null) {
+            st = sipProvider.getNewServerTransaction(event.getRequest());
+        }
+        st.sendResponse(response);
+    }
+
+    private SipURI createUri(String user, String domain) throws Exception {
+        return addressFactory.createSipURI(user, domain);
+    }
+
+    private void failPending(Gb28181StreamResult result) {
+        PendingStream session = pendingStream.getAndSet(null);
+        if (session != null) {
+            session.result = result;
+            session.inviteReady.countDown();
+            session.streamDone.countDown();
+        }
+    }
+
+    private static Gb28181StreamResult fail(String msg) {
+        List<String> errors = new ArrayList<>();
+        errors.add(msg);
+        return Gb28181StreamResult.builder().success(false).errors(errors).build();
+    }
+
+    private static List<String> singleError(String msg) {
+        List<String> errors = new ArrayList<>();
+        errors.add(msg);
+        return errors;
+    }
+
+    private static String maskUrl(String url) {
+        return url.replaceAll("://([^:]+):([^@]+)@", "://***:***@");
+    }
+
+    private static String normalizeTransport(String t) {
+        return t != null && t.equalsIgnoreCase("udp") ? "udp" : "tcp";
+    }
+
+    private static int findAvailablePort() throws Exception {
+        try (java.net.DatagramSocket socket = new java.net.DatagramSocket(0)) {
+            int port = socket.getLocalPort();
+            return port % 2 == 0 ? port : port + 1;
+        }
+    }
+
+    private static String generateTag() {
+        return Long.toHexString(System.nanoTime());
+    }
+
+    private void ensureActiveDeviceId() {
+        if (!StringUtils.hasText(activeDeviceId)) {
+            throw new IllegalStateException("设备 ID 未设置,请先注册");
+        }
+    }
+
+    private static void validateDeviceId(String deviceId) {
+        if (!StringUtils.hasText(deviceId)) {
+            throw new IllegalArgumentException("deviceId 不能为空");
+        }
+        String id = deviceId.trim();
+        if (id.length() != 20 || !id.chars().allMatch(Character::isDigit)) {
+            throw new IllegalArgumentException("deviceId 必须为 20 位数字");
+        }
+    }
+
+    private void shutdownStack() {
+        try {
+            if (sipProvider != null) {
+                sipProvider.removeSipListener(this);
+            }
+        } catch (Exception ignored) {
+        }
+        if (sipStack != null) {
+            try {
+                ((SipStackImpl) sipStack).stop();
+            } catch (Exception ignored) {
+            }
+        }
+    }
+
+    @Override
+    public void processResponse(ResponseEvent event) {
+        ClientTransaction ct = event.getClientTransaction();
+        if (ct == null || !Request.REGISTER.equals(ct.getRequest().getMethod())) {
+            return;
+        }
+        int status = event.getResponse().getStatusCode();
+        if (status == Response.UNAUTHORIZED || status == Response.PROXY_AUTHENTICATION_REQUIRED) {
+            WWWAuthenticateHeader challenge = (WWWAuthenticateHeader) event.getResponse().getHeader(WWWAuthenticateHeader.NAME);
+            if (challenge == null) {
+                challenge = (WWWAuthenticateHeader) event.getResponse().getHeader(ProxyAuthenticateHeader.NAME);
+            }
+            authChallenge.set(challenge);
+            if (!registerAuthSent.getAndSet(true)) {
+                try {
+                    ExpiresHeader expires = (ExpiresHeader) ct.getRequest().getHeader(ExpiresHeader.NAME);
+                    int expireSeconds = expires != null ? expires.getExpires() : properties.getRegisterExpires();
+                    sendRegister(true, expireSeconds);
+                } catch (Exception e) {
+                    log.error("REGISTER 鉴权失败: {}", e.getMessage());
+                }
+            }
+        } else if (status == Response.OK) {
+            ExpiresHeader expires = (ExpiresHeader) ct.getRequest().getHeader(ExpiresHeader.NAME);
+            if (expires != null && expires.getExpires() == 0) {
+                registered.set(false);
+                log.info("GB28181 注销成功: deviceId={}", activeDeviceId);
+            } else {
+                registered.set(true);
+                log.info("GB28181 注册成功: deviceId={}(平台应可见设备在线)", activeDeviceId);
+            }
+        } else if (status >= 300) {
+            registered.set(false);
+            log.warn("GB28181 注册失败: deviceId={}, status={}", activeDeviceId, status);
+        }
+    }
+
+    @Override
+    public void processRequest(RequestEvent event) {
+        String method = event.getRequest().getMethod();
+        log.info("收到 SIP 请求: {} from {}", method, formatRemote(event));
+        try {
+            if (Request.INVITE.equals(method)) {
+                handleIncomingInvite(event);
+            } else if (Request.ACK.equals(method)) {
+                handleIncomingAck(event);
+            } else if (Request.BYE.equals(method)) {
+                handleIncomingBye(event);
+            }
+        } catch (Exception e) {
+            log.error("处理 {} 失败: {}", method, e.getMessage(), e);
+            failPending(fail("处理 " + method + " 失败: " + e.getMessage()));
+        }
+    }
+
+    private String formatRemote(RequestEvent event) {
+        if (event instanceof RequestEventExt) {
+            RequestEventExt ext = (RequestEventExt) event;
+            return ext.getRemoteIpAddress() + ":" + ext.getRemotePort();
+        }
+        return properties.getServerHost();
+    }
+
+    @Override
+    public void processTimeout(TimeoutEvent timeoutEvent) {
+        log.warn("SIP 超时");
+    }
+
+    @Override
+    public void processIOException(IOExceptionEvent exceptionEvent) {
+        log.warn("SIP IO 异常: {}:{}", exceptionEvent.getHost(), exceptionEvent.getPort());
+        registered.set(false);
+    }
+
+    @Override
+    public void processTransactionTerminated(TransactionTerminatedEvent event) {
+    }
+
+    @Override
+    public void processDialogTerminated(DialogTerminatedEvent event) {
+    }
+
+    private static final class PendingStream {
+        private String rtspUrl;
+        private final int durationSeconds;
+        private final CountDownLatch inviteReady = new CountDownLatch(1);
+        private final CountDownLatch streamDone = new CountDownLatch(1);
+        private volatile boolean streamStarted;
+        private volatile Gb28181StreamResult result;
+        private String callId;
+        private int localRtpPort;
+        private String remoteRtpHost;
+        private int remoteRtpPort;
+        private SdpMediaInfo media;
+
+        private PendingStream(String rtspUrl, int durationSeconds) {
+            this.rtspUrl = rtspUrl;
+            this.durationSeconds = durationSeconds;
+        }
+    }
+}

+ 22 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/device/Gb28181StreamResult.java

@@ -0,0 +1,22 @@
+package com.usky.cdi.service.sip.device;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Data
+@Builder
+public class Gb28181StreamResult {
+
+    private boolean success;
+    private String rtspUrl;
+    private String callId;
+    private int localRtpPort;
+    private String remoteRtpHost;
+    private Integer remoteRtpPort;
+    private long packetsSent;
+    @Builder.Default
+    private List<String> errors = new ArrayList<>();
+}

+ 295 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/listener/SipServerListener.java

@@ -0,0 +1,295 @@
+package com.usky.cdi.service.sip.listener;
+
+import com.usky.cdi.service.sip.config.SipServerProperties;
+import com.usky.cdi.service.sip.model.SdpMediaInfo;
+import com.usky.cdi.service.sip.model.VideoStreamSession;
+import com.usky.cdi.service.sip.service.VideoStreamManager;
+import com.usky.cdi.service.sip.sdp.SdpUtils;
+import com.usky.cdi.service.sip.util.NetworkUtils;
+import gov.nist.javax.sip.RequestEventExt;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+import javax.sip.*;
+import javax.sip.address.Address;
+import javax.sip.address.AddressFactory;
+import javax.sip.address.SipURI;
+import javax.sip.header.*;
+import javax.sip.message.MessageFactory;
+import javax.sip.message.Request;
+import javax.sip.message.Response;
+import java.nio.charset.StandardCharsets;
+
+@Slf4j
+@Component
+@ConditionalOnProperty(prefix = "sip.server", name = "enabled", havingValue = "true")
+public class SipServerListener implements SipListener {
+
+    private final SipServerProperties properties;
+    private final VideoStreamManager streamManager;
+
+    private SipStack sipStack;
+    private SipProvider sipProvider;
+    private AddressFactory addressFactory;
+    private MessageFactory messageFactory;
+    private HeaderFactory headerFactory;
+
+    public SipServerListener(SipServerProperties properties, VideoStreamManager streamManager) {
+        this.properties = properties;
+        this.streamManager = streamManager;
+    }
+
+    public void start() throws Exception {
+        SipFactory sipFactory = SipFactory.getInstance();
+        sipFactory.setPathName("gov.nist");
+
+        java.util.Properties stackProps = new java.util.Properties();
+        stackProps.setProperty("javax.sip.STACK_NAME", "UskySipVideoServer");
+        stackProps.setProperty("gov.nist.javax.sip.TRACE_LEVEL", "0");
+        stackProps.setProperty("gov.nist.javax.sip.THREAD_POOL_SIZE", "16");
+
+        sipStack = sipFactory.createSipStack(stackProps);
+        addressFactory = sipFactory.createAddressFactory();
+        messageFactory = sipFactory.createMessageFactory();
+        headerFactory = sipFactory.createHeaderFactory();
+
+        String bindAddress = properties.getHost();
+        if (bindAddress == null || bindAddress.isEmpty()) {
+            bindAddress = "0.0.0.0";
+        }
+        String localIp = NetworkUtils.resolveLocalIp(properties.getLocalIp());
+        ListeningPoint listeningPoint = sipStack.createListeningPoint(bindAddress, properties.getPort(), "udp");
+        sipProvider = sipStack.createSipProvider(listeningPoint);
+        sipProvider.addSipListener(this);
+
+        log.info("SIP 服务端已启动: bind={}:{}, localIp={}, platformId={}",
+                bindAddress, properties.getPort(), localIp, properties.getPlatformId());
+    }
+
+    public void stop() {
+        if (sipStack != null) {
+            sipStack.stop();
+            log.info("SIP 服务端已停止");
+        }
+    }
+
+    @Override
+    public void processRequest(RequestEvent requestEvent) {
+        Request request = requestEvent.getRequest();
+        String method = request.getMethod();
+        log.info("收到 SIP 请求: {} from {}", method, formatRemoteEndpoint(requestEvent));
+
+        try {
+            switch (method) {
+                case Request.INVITE:
+                    handleInvite(requestEvent);
+                    break;
+                case Request.ACK:
+                    handleAck(requestEvent);
+                    break;
+                case Request.BYE:
+                    handleBye(requestEvent);
+                    break;
+                case Request.REGISTER:
+                    handleRegister(requestEvent);
+                    break;
+                case Request.OPTIONS:
+                    handleOptions(requestEvent);
+                    break;
+                default:
+                    sendResponse(requestEvent, Response.NOT_IMPLEMENTED);
+            }
+        } catch (Exception e) {
+            log.error("处理 SIP 请求失败: method={}, error={}", method, e.getMessage(), e);
+            try {
+                sendResponse(requestEvent, Response.SERVER_INTERNAL_ERROR);
+            } catch (Exception ex) {
+                log.error("发送错误响应失败", ex);
+            }
+        }
+    }
+
+    private void handleInvite(RequestEvent event) throws Exception {
+        Request request = event.getRequest();
+        CallIdHeader callIdHeader = (CallIdHeader) request.getHeader(CallIdHeader.NAME);
+        String callId = callIdHeader.getCallId();
+
+        byte[] rawContent = request.getRawContent();
+        if (rawContent == null) {
+            sendResponse(event, Response.BAD_REQUEST);
+            return;
+        }
+        String sdp = new String(rawContent, StandardCharsets.UTF_8);
+        SdpMediaInfo mediaInfo = SdpUtils.parseVideoMedia(sdp);
+        if (mediaInfo == null) {
+            log.warn("INVITE SDP 中未找到视频媒体描述");
+            sendResponse(event, Response.NOT_ACCEPTABLE);
+            return;
+        }
+
+        String remoteAddress = mediaInfo.getConnectionAddress();
+        if (remoteAddress == null) {
+            remoteAddress = resolveRemoteIpAddress(event);
+            mediaInfo.setConnectionAddress(remoteAddress);
+        }
+
+        VideoStreamSession session = streamManager.createSession(callId, remoteAddress, mediaInfo);
+        String answerSdp = streamManager.buildAnswerSdp(session);
+
+        Response okResponse = messageFactory.createResponse(Response.OK, request);
+        ToHeader toHeader = (ToHeader) okResponse.getHeader(ToHeader.NAME);
+        toHeader.setTag(generateTag());
+
+        String localIp = NetworkUtils.resolveLocalIp(properties.getLocalIp());
+        SipURI contactUri = addressFactory.createSipURI(properties.getPlatformId(), localIp);
+        contactUri.setPort(properties.getPort());
+        Address contactAddress = addressFactory.createAddress(contactUri);
+        ContactHeader contactHeader = headerFactory.createContactHeader(contactAddress);
+        okResponse.addHeader(contactHeader);
+
+        ContentTypeHeader contentType = headerFactory.createContentTypeHeader("application", "sdp");
+        okResponse.setContent(answerSdp, contentType);
+
+        ServerTransaction serverTransaction = event.getServerTransaction();
+        if (serverTransaction == null) {
+            serverTransaction = sipProvider.getNewServerTransaction(request);
+        }
+        serverTransaction.sendResponse(okResponse);
+        log.info("已应答 INVITE: callId={}, localRtpPort={}", callId, session.getLocalRtpPort());
+    }
+
+    private void handleAck(RequestEvent event) {
+        Request request = event.getRequest();
+        CallIdHeader callIdHeader = (CallIdHeader) request.getHeader(CallIdHeader.NAME);
+        String callId = callIdHeader.getCallId();
+        VideoStreamSession session = streamManager.getSession(callId);
+        if (session != null) {
+            streamManager.startReceiving(session);
+            log.info("收到 ACK,开始接收视频流: callId={}", callId);
+        }
+    }
+
+    private void handleBye(RequestEvent event) throws Exception {
+        Request request = event.getRequest();
+        CallIdHeader callIdHeader = (CallIdHeader) request.getHeader(CallIdHeader.NAME);
+        String callId = callIdHeader.getCallId();
+        streamManager.stopSession(callId);
+        sendResponse(event, Response.OK);
+    }
+
+    private void handleRegister(RequestEvent event) throws Exception {
+        Response okResponse = messageFactory.createResponse(Response.OK, event.getRequest());
+        ToHeader toHeader = (ToHeader) okResponse.getHeader(ToHeader.NAME);
+        toHeader.setTag(generateTag());
+
+        ExpiresHeader expiresHeader = headerFactory.createExpiresHeader(3600);
+        okResponse.addHeader(expiresHeader);
+
+        ContactHeader contactHeader = (ContactHeader) event.getRequest().getHeader(ContactHeader.NAME);
+        if (contactHeader != null) {
+            okResponse.addHeader(contactHeader);
+        }
+
+        ServerTransaction st = event.getServerTransaction();
+        if (st == null) {
+            st = sipProvider.getNewServerTransaction(event.getRequest());
+        }
+        st.sendResponse(okResponse);
+        log.info("设备注册成功: from={}", formatRemoteEndpoint(event));
+    }
+
+    private void handleOptions(RequestEvent event) throws Exception {
+        Response okResponse = messageFactory.createResponse(Response.OK, event.getRequest());
+        okResponse.addHeader(headerFactory.createAllowHeader(
+                "INVITE, ACK, BYE, CANCEL, OPTIONS, REGISTER"));
+        sendResponse(event, okResponse);
+    }
+
+    private void sendResponse(RequestEvent event, int statusCode) throws Exception {
+        Response response = messageFactory.createResponse(statusCode, event.getRequest());
+        sendResponse(event, response);
+    }
+
+    private void sendResponse(RequestEvent event, Response response) throws Exception {
+        if (response.getStatusCode() >= 200) {
+            ToHeader toHeader = (ToHeader) response.getHeader(ToHeader.NAME);
+            if (toHeader.getTag() == null) {
+                toHeader.setTag(generateTag());
+            }
+        }
+        ServerTransaction st = event.getServerTransaction();
+        if (st == null) {
+            st = sipProvider.getNewServerTransaction(event.getRequest());
+        }
+        st.sendResponse(response);
+    }
+
+    private String generateTag() {
+        return Long.toHexString(System.nanoTime());
+    }
+
+    private String resolveRemoteIpAddress(RequestEvent event) {
+        if (event instanceof RequestEventExt) {
+            String ip = ((RequestEventExt) event).getRemoteIpAddress();
+            if (ip != null && !ip.isEmpty()) {
+                return ip;
+            }
+        }
+        Request request = event.getRequest();
+        ViaHeader via = (ViaHeader) request.getHeader(ViaHeader.NAME);
+        if (via != null && via.getHost() != null) {
+            return via.getHost();
+        }
+        FromHeader from = (FromHeader) request.getHeader(FromHeader.NAME);
+        if (from != null && from.getAddress() != null && from.getAddress().getURI() instanceof SipURI) {
+            return ((SipURI) from.getAddress().getURI()).getHost();
+        }
+        return "unknown";
+    }
+
+    private String formatRemoteEndpoint(RequestEvent event) {
+        if (event instanceof RequestEventExt) {
+            RequestEventExt ext = (RequestEventExt) event;
+            String ip = ext.getRemoteIpAddress();
+            int port = ext.getRemotePort();
+            if (ip != null && !ip.isEmpty()) {
+                return port > 0 ? ip + ":" + port : ip;
+            }
+        }
+        return resolveRemoteIpAddress(event);
+    }
+
+    @Override
+    public void processResponse(ResponseEvent responseEvent) {
+        log.debug("收到 SIP 响应: {}", responseEvent.getResponse().getStatusCode());
+    }
+
+    @Override
+    public void processTimeout(TimeoutEvent timeoutEvent) {
+        log.warn("SIP 超时: event={}", timeoutEvent.getTimeout());
+    }
+
+    @Override
+    public void processIOException(IOExceptionEvent exceptionEvent) {
+        log.warn("SIP IO 异常: host={}, port={}",
+                exceptionEvent.getHost(), exceptionEvent.getPort());
+    }
+
+    @Override
+    public void processTransactionTerminated(TransactionTerminatedEvent event) {
+        log.debug("SIP 事务终止");
+    }
+
+    @Override
+    public void processDialogTerminated(DialogTerminatedEvent event) {
+        Dialog dialog = event.getDialog();
+        if (dialog != null && dialog.getCallId() != null) {
+            CallIdHeader callIdHeader = dialog.getCallId();
+            if (callIdHeader != null) {
+                streamManager.stopSession(callIdHeader.getCallId());
+            }
+        }
+        log.debug("SIP 对话终止");
+    }
+}

+ 26 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/model/SdpMediaInfo.java

@@ -0,0 +1,26 @@
+package com.usky.cdi.service.sip.model;
+
+import lombok.Data;
+
+@Data
+public class SdpMediaInfo {
+
+    private String mediaType;
+    private int port;
+    private int payloadType;
+    private String codec;
+    private String connectionAddress;
+    private boolean sendOnly;
+    /** m= 行协议,如 RTP/AVP、TCP/RTP/AVP、RTP/AVP/TCP */
+    private String mediaProtocol = "RTP/AVP";
+    /** a=setup 值:active / passive */
+    private String setup;
+    /** GB28181 y= SSRC(十进制字符串) */
+    private String ssrc;
+    /** 是否 TCP 传输媒体 */
+    private boolean tcpMedia;
+
+    public boolean isTcpMedia() {
+        return tcpMedia || (mediaProtocol != null && mediaProtocol.toUpperCase().contains("TCP"));
+    }
+}

+ 28 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/model/VideoStreamSession.java

@@ -0,0 +1,28 @@
+package com.usky.cdi.service.sip.model;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.concurrent.atomic.AtomicLong;
+
+@Data
+public class VideoStreamSession {
+
+    private String sessionId;
+    private String callId;
+    private String remoteAddress;
+    private int remoteRtpPort;
+    private int localRtpPort;
+    private String codec;
+    private int payloadType;
+    private String ssrc;
+    private volatile SessionState state = SessionState.INIT;
+    private LocalDateTime startTime;
+    private final AtomicLong receivedPackets = new AtomicLong();
+    private final AtomicLong receivedFrames = new AtomicLong();
+    private final AtomicLong receivedBytes = new AtomicLong();
+
+    public enum SessionState {
+        INIT, ESTABLISHED, RECEIVING, STOPPED
+    }
+}

+ 103 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/FfmpegPsRemuxSession.java

@@ -0,0 +1,103 @@
+package com.usky.cdi.service.sip.ps;
+
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * FFmpeg H264→PS remux 会话:后台读 stdout,按 Pack 边界切帧。
+ */
+@Slf4j
+public final class FfmpegPsRemuxSession implements AutoCloseable {
+
+    private static final int READ_BUF = 256 * 1024;
+
+    private final FfmpegPsRemuxer remuxer;
+    private final PsStreamFramer framer = new PsStreamFramer();
+    private final LinkedBlockingQueue<byte[]> packQueue = new LinkedBlockingQueue<>();
+    private final Thread readerThread;
+    private volatile boolean running = true;
+    private volatile String readerError;
+
+    public FfmpegPsRemuxSession(double frameRate) throws IOException {
+        remuxer = new FfmpegPsRemuxer(frameRate);
+        readerThread = new Thread(this::readLoop, "ffmpeg-ps-read");
+        readerThread.setDaemon(true);
+        readerThread.start();
+    }
+
+    public void submitAccessUnit(byte[] annexB) throws IOException {
+        remuxer.writeAccessUnit(annexB);
+    }
+
+    public boolean isAlive() {
+        return remuxer.isAlive() && readerError == null;
+    }
+
+    /**
+     * 等待 remux 产出的完整 PS Pack(可能 0~N 个)。
+     */
+    public List<byte[]> pollPacks(long timeoutMs) throws InterruptedException {
+        List<byte[]> packs = new ArrayList<>();
+        byte[] first = packQueue.poll(timeoutMs, TimeUnit.MILLISECONDS);
+        if (first != null) {
+            packs.add(first);
+            packQueue.drainTo(packs);
+        }
+        return packs;
+    }
+
+    private void readLoop() {
+        byte[] buf = new byte[READ_BUF];
+        try {
+            InputStream in = remuxer.psOutput();
+            while (running) {
+                int n = in.read(buf);
+                if (n < 0) {
+                    break;
+                }
+                if (n > 0) {
+                    for (byte[] pack : framer.feed(buf, 0, n)) {
+                        packQueue.offer(pack);
+                    }
+                }
+            }
+            for (byte[] pack : framer.flush()) {
+                packQueue.offer(pack);
+            }
+        } catch (IOException e) {
+            if (running) {
+                readerError = e.getMessage();
+                log.warn("FFmpeg PS 读取异常: {}", e.getMessage());
+            }
+        }
+    }
+
+    @Override
+    public void close() {
+        running = false;
+        remuxer.close();
+        try {
+            readerThread.join(2000);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+        packQueue.clear();
+    }
+
+    public String getReaderError() {
+        return readerError;
+    }
+
+    public List<byte[]> drainAll() {
+        List<byte[]> packs = new ArrayList<>();
+        packQueue.drainTo(packs);
+        return packs.isEmpty() ? Collections.emptyList() : packs;
+    }
+}

+ 109 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/FfmpegPsRemuxer.java

@@ -0,0 +1,109 @@
+package com.usky.cdi.service.sip.ps;
+
+import lombok.extern.slf4j.Slf4j;
+import org.bytedeco.javacpp.Loader;
+import org.bytedeco.ffmpeg.ffmpeg;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 将 Annex-B H264 通过 FFmpeg 标准 remux 为 MPEG-PS(与 demo 解码器兼容)。
+ */
+@Slf4j
+public final class FfmpegPsRemuxer implements AutoCloseable {
+
+    private final Process process;
+    private final OutputStream stdin;
+    private final InputStream stdout;
+
+    public FfmpegPsRemuxer(double frameRate) throws IOException {
+        int fps = frameRate > 1 && frameRate < 121 ? (int) Math.round(frameRate) : 25;
+        ProcessBuilder pb = new ProcessBuilder(
+                resolveFfmpegCommand(),
+                "-hide_banner",
+                "-loglevel", "error",
+                "-fflags", "+nobuffer+flush_packets",
+                "-f", "h264",
+                "-framerate", String.valueOf(fps),
+                "-probesize", "32768",
+                "-analyzeduration", "0",
+                "-i", "pipe:0",
+                "-an",
+                "-c:v", "copy",
+                "-f", "mpeg",
+                "-muxrate", "10000000",
+                "-muxdelay", "0",
+                "-muxpreload", "0",
+                "-flush_packets", "1",
+                "-"
+        );
+        process = pb.start();
+        stdin = process.getOutputStream();
+        stdout = process.getInputStream();
+        startStderrDrainer(process.getErrorStream());
+        log.info("FFmpeg PS remux 已启动: framerate={}", fps);
+    }
+
+    public void writeAccessUnit(byte[] annexBFrame) throws IOException {
+        if (annexBFrame == null || annexBFrame.length == 0) {
+            return;
+        }
+        stdin.write(annexBFrame);
+        stdin.flush();
+    }
+
+    public InputStream psOutput() {
+        return stdout;
+    }
+
+    public boolean isAlive() {
+        return process.isAlive();
+    }
+
+    @Override
+    public void close() {
+        try {
+            stdin.flush();
+            stdin.close();
+        } catch (IOException ignored) {
+        }
+        try {
+            process.waitFor(3, TimeUnit.SECONDS);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+        if (process.isAlive()) {
+            process.destroyForcibly();
+        }
+    }
+
+    private static void startStderrDrainer(InputStream stderr) {
+        Thread t = new Thread(() -> {
+            try (BufferedReader reader = new BufferedReader(
+                    new InputStreamReader(stderr, StandardCharsets.UTF_8))) {
+                String line;
+                while ((line = reader.readLine()) != null) {
+                    log.warn("FFmpeg PS remux: {}", line);
+                }
+            } catch (IOException ignored) {
+            }
+        }, "ffmpeg-ps-stderr");
+        t.setDaemon(true);
+        t.start();
+    }
+
+    private static String resolveFfmpegCommand() {
+        try {
+            return Loader.load(ffmpeg.class);
+        } catch (Throwable e) {
+            log.warn("未找到 JavaCV 内置 ffmpeg,尝试系统 PATH: {}", e.getMessage());
+            return "ffmpeg";
+        }
+    }
+}

+ 223 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/Gb28181PsHeaders.java

@@ -0,0 +1,223 @@
+package com.usky.cdi.service.sip.ps;
+
+/**
+ * GB28181 PS/PES 头生成(位级写法,与社区 gb28181_make_* 参考实现一致)。
+ */
+public final class Gb28181PsHeaders {
+
+    private static final int PS_HDR_LEN = 14;
+    private static final int SYS_HDR_LEN = 18;
+    private static final int PSM_HDR_LEN = 24;
+    private static final int PES_HDR_LEN = 19;
+
+    private Gb28181PsHeaders() {
+    }
+
+    /** 海康 Pack 头 stuffing 长度(stuffinglen & 0x07) */
+    private static final int HIKVISION_PACK_STUFFING = 6;
+
+    /** GB28181 标准 Pack 头(14 字节,stuffing=0,与 gb28181_make_ps_header 一致) */
+    public static byte[] buildPsHeader(long pts90k) {
+        return buildStandardPackHeader(pts90k);
+    }
+
+    public static byte[] buildStandardPackHeader(long pts90k) {
+        long scrExt = pts90k % 100;
+        long scr = pts90k / 100;
+        BitWriter bits = new BitWriter(PS_HDR_LEN);
+        bits.write(32, 0x000001BA);
+        bits.write(2, 1);
+        bits.write(3, (scr >> 30) & 0x07);
+        bits.write(1, 1);
+        bits.write(15, (scr >> 15) & 0x7FFF);
+        bits.write(1, 1);
+        bits.write(15, scr & 0x7FFF);
+        bits.write(1, 1);
+        bits.write(9, scrExt & 0x01FF);
+        bits.write(1, 1);
+        bits.write(22, 255);
+        bits.write(2, 3);
+        bits.write(5, 0x1F);
+        bits.write(3, 0);
+        return bits.toArray();
+    }
+
+    /**
+     * 海康 Pack 头:14 字节标准头 + 6 字节 stuffing(FF FF 00 00 00 01)。
+     */
+    public static byte[] buildHikvisionPackHeader(long pts90k) {
+        long scrExt = pts90k % 100;
+        long scr = pts90k / 100;
+        BitWriter bits = new BitWriter(PS_HDR_LEN);
+        bits.write(32, 0x000001BA);
+        bits.write(2, 1);
+        bits.write(3, (scr >> 30) & 0x07);
+        bits.write(1, 1);
+        bits.write(15, (scr >> 15) & 0x7FFF);
+        bits.write(1, 1);
+        bits.write(15, scr & 0x7FFF);
+        bits.write(1, 1);
+        bits.write(9, scrExt & 0x01FF);
+        bits.write(1, 1);
+        bits.write(22, 255);
+        bits.write(2, 3);
+        bits.write(5, 0x1F);
+        bits.write(3, HIKVISION_PACK_STUFFING);
+        byte[] header = bits.toArray();
+        byte[] stuffed = new byte[header.length + HIKVISION_PACK_STUFFING];
+        System.arraycopy(header, 0, stuffed, 0, header.length);
+        stuffed[header.length] = (byte) 0xFF;
+        stuffed[header.length + 1] = (byte) 0xFF;
+        stuffed[header.length + 2] = 0x00;
+        stuffed[header.length + 3] = 0x00;
+        stuffed[header.length + 4] = 0x00;
+        stuffed[header.length + 5] = 0x01;
+        return stuffed;
+    }
+
+    public static byte[] buildSystemHeader() {
+        BitWriter bits = new BitWriter(SYS_HDR_LEN);
+        bits.write(32, 0x000001BB);
+        bits.write(16, SYS_HDR_LEN - 6);
+        bits.write(1, 1);
+        bits.write(22, 50000);
+        bits.write(1, 1);
+        bits.write(6, 1);
+        bits.write(1, 0);
+        bits.write(1, 1);
+        bits.write(1, 1);
+        bits.write(1, 1);
+        bits.write(1, 1);
+        bits.write(5, 1);
+        bits.write(1, 0);
+        bits.write(7, 0x7F);
+        bits.write(8, 0xC0);
+        bits.write(2, 3);
+        bits.write(1, 0);
+        bits.write(13, 512);
+        bits.write(8, 0xE0);
+        bits.write(2, 3);
+        bits.write(1, 1);
+        bits.write(13, 2048);
+        return bits.toArray();
+    }
+
+    /**
+     * GB28181 标准 PSM(24 字节,含 H264/G711 映射 + CRC),与 gb28181_make_psm_header 一致。
+     */
+    public static byte[] buildStandardPsm() {
+        BitWriter bits = new BitWriter(PSM_HDR_LEN);
+        bits.write(24, 0x000001);
+        bits.write(8, 0xBC);
+        bits.write(16, 18);
+        bits.write(1, 1);
+        bits.write(2, 3);
+        bits.write(5, 0);
+        bits.write(7, 0x7F);
+        bits.write(1, 1);
+        bits.write(16, 0);
+        bits.write(16, 8);
+        bits.write(8, 0x90);
+        bits.write(8, 0xC0);
+        bits.write(16, 0);
+        bits.write(8, 0x1B);
+        bits.write(8, 0xE0);
+        bits.write(16, 0);
+        bits.write(8, 0x45);
+        bits.write(8, 0xBD);
+        bits.write(8, 0xDC);
+        bits.write(8, 0xF4);
+        return bits.toArray();
+    }
+
+    /** @deprecated 海康 EHome 私有 PSM,FFmpeg mpeg demuxer 无法可靠解析 */
+    public static byte[] buildHikvisionPsm() {
+        return HIKVISION_PSM.clone();
+    }
+
+    /** 海康 IPC 典型 PSM 模板(1280x720 H264 + G711A) */
+    private static final byte[] HIKVISION_PSM = {
+            0x00, 0x00, 0x01, (byte) 0xBC, 0x00, 0x5A, (byte) 0xE0, (byte) 0xFF,
+            0x00, 0x24, 0x40, 0x0E, 0x48, 0x4B, 0x00, 0x00,
+            0x17, (byte) 0x9D, 0x3E, (byte) 0xB4, 0x60, 0x00, 0x00, (byte) 0xFF,
+            (byte) 0xFF, (byte) 0xFF, 0x41, 0x12, 0x00, 0x00, 0x00, 0x00,
+            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2C,
+            0x1B, (byte) 0xE0, 0x00, 0x10, 0x42, 0x0E, 0x00, 0x00,
+            (byte) 0xA0, 0x21, 0x05, 0x00, 0x02, (byte) 0xD0, 0x12, 0x1C,
+            0x3F, 0x00, 0x1C, 0x21, 0x0F, (byte) 0xC0, 0x00, 0x0C,
+            0x43, 0x0A, 0x00, 0x00, (byte) 0xFE, 0x00, (byte) 0xFA, 0x03,
+            0x01, (byte) 0xF4, 0x03, (byte) 0xFF, (byte) 0xBD, (byte) 0xBD,
+            0x00, 0x00, (byte) 0xBF, (byte) 0xBF
+    };
+
+    /** @deprecated 使用 {@link #buildStandardPsm()} */
+    public static byte[] buildPsmHeader() {
+        return buildStandardPsm();
+    }
+
+    public static byte[] buildPesHeader(int streamId, int payloadLen, long pts90k) {
+        long pts = pts90k / 100;
+        long dts = pts;
+        int packetLen = payloadLen + 13;
+        if (packetLen > 0xFFFF) {
+            packetLen = 0;
+        }
+        BitWriter bits = new BitWriter(PES_HDR_LEN);
+        bits.write(24, 0x000001);
+        bits.write(8, streamId);
+        bits.write(16, packetLen);
+        bits.write(2, 2);
+        bits.write(2, 0);
+        bits.write(1, 0);
+        bits.write(1, 0);
+        bits.write(1, 0);
+        bits.write(1, 0);
+        bits.write(1, 1);
+        bits.write(1, 1);
+        bits.write(1, 0);
+        bits.write(1, 0);
+        bits.write(1, 0);
+        bits.write(1, 0);
+        bits.write(1, 0);
+        bits.write(1, 0);
+        bits.write(8, 10);
+        bits.write(4, 3);
+        bits.write(3, (pts >> 30) & 0x07);
+        bits.write(1, 1);
+        bits.write(15, (pts >> 15) & 0x7FFF);
+        bits.write(1, 1);
+        bits.write(15, pts & 0x7FFF);
+        bits.write(1, 1);
+        bits.write(4, 1);
+        bits.write(3, (dts >> 30) & 0x07);
+        bits.write(1, 1);
+        bits.write(15, (dts >> 15) & 0x7FFF);
+        bits.write(1, 1);
+        bits.write(15, dts & 0x7FFF);
+        bits.write(1, 1);
+        return bits.toArray();
+    }
+
+    private static final class BitWriter {
+        private final byte[] data;
+        private int bitPos;
+
+        BitWriter(int size) {
+            data = new byte[size];
+        }
+
+        void write(int bitCount, long value) {
+            for (int i = bitCount - 1; i >= 0; i--) {
+                if (((value >> i) & 1) == 1) {
+                    data[bitPos / 8] |= (byte) (0x80 >> (bitPos % 8));
+                }
+                bitPos++;
+            }
+        }
+
+        byte[] toArray() {
+            return data;
+        }
+    }
+}

+ 65 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/Gb28181PsMuxer.java

@@ -0,0 +1,65 @@
+package com.usky.cdi.service.sip.ps;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * GB28181 标准 PS 封装(与 gb28181_streampackageForH264 一致,FFmpeg -f mpeg 可解)。
+ * <p>
+ * I 帧:Pack + System + PSM + PES(Annex-B)<br>
+ * P 帧:Pack + PES(Annex-B)<br>
+ * 超大帧按 65522 字节分片,每片独立 Pack(I 帧每片带 System+PSM)。
+ */
+public final class Gb28181PsMuxer {
+
+    /** gb28181 PS_PES_PAYLOAD_SIZE */
+    private static final int MAX_PES_PAYLOAD = 65522;
+    private static final int VIDEO_STREAM_ID = 0xE0;
+
+    private Gb28181PsMuxer() {
+    }
+
+    public static byte[] mux(List<byte[]> nals, long pts90k, boolean keyFrame) throws IOException {
+        if (nals == null || nals.isEmpty()) {
+            return new byte[0];
+        }
+        return muxAnnexB(toAnnexB(nals), pts90k, keyFrame);
+    }
+
+    static byte[] muxAnnexB(byte[] annexB, long pts90k, boolean keyFrame) throws IOException {
+        if (annexB == null || annexB.length == 0) {
+            return new byte[0];
+        }
+        ByteArrayOutputStream out = new ByteArrayOutputStream(annexB.length + 256);
+        int offset = 0;
+        while (offset < annexB.length) {
+            int chunk = Math.min(annexB.length - offset, MAX_PES_PAYLOAD);
+            writePsChunk(out, annexB, offset, chunk, pts90k, keyFrame);
+            offset += chunk;
+        }
+        return out.toByteArray();
+    }
+
+    private static void writePsChunk(ByteArrayOutputStream out, byte[] annexB, int offset, int length,
+                                     long pts90k, boolean keyFrame) throws IOException {
+        out.write(Gb28181PsHeaders.buildStandardPackHeader(pts90k));
+        if (keyFrame) {
+            out.write(Gb28181PsHeaders.buildSystemHeader());
+            out.write(Gb28181PsHeaders.buildStandardPsm());
+        }
+        out.write(Gb28181PsHeaders.buildPesHeader(VIDEO_STREAM_ID, length, pts90k));
+        out.write(annexB, offset, length);
+    }
+
+    private static byte[] toAnnexB(List<byte[]> nals) throws IOException {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        for (byte[] nal : nals) {
+            if (nal != null && nal.length > 0) {
+                out.write(new byte[]{0x00, 0x00, 0x00, 0x01});
+                out.write(nal);
+            }
+        }
+        return out.toByteArray();
+    }
+}

+ 162 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/H264AccessUnitBuilder.java

@@ -0,0 +1,162 @@
+package com.usky.cdi.service.sip.ps;
+
+import com.usky.cdi.service.sip.rtp.AnnexBParser;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * 组装 Annex-B H264 访问单元,保证 I 帧前 SPS/PPS 顺序正确。
+ */
+public final class H264AccessUnitBuilder {
+
+    private byte[] sps;
+    private byte[] pps;
+    private boolean sentFirstKeyFrame;
+
+    public void updateParameterSets(List<byte[]> nals) {
+        for (byte[] nal : nals) {
+            if (nal.length == 0) {
+                continue;
+            }
+            int type = nal[0] & 0x1F;
+            if (type == 7) {
+                sps = nal;
+            } else if (type == 8) {
+                pps = nal;
+            }
+        }
+    }
+
+    public boolean hasParameterSets() {
+        return sps != null && pps != null;
+    }
+
+    public void setSps(byte[] sps) {
+        if (sps != null && sps.length > 0) {
+            this.sps = sps;
+        }
+    }
+
+    public void setPps(byte[] pps) {
+        if (pps != null && pps.length > 0) {
+            this.pps = pps;
+        }
+    }
+
+    public boolean isParameterSetOnly(List<byte[]> nals) {
+        if (nals.isEmpty()) {
+            return true;
+        }
+        for (byte[] nal : nals) {
+            if (nal.length == 0) {
+                continue;
+            }
+            int type = nal[0] & 0x1F;
+            if (type != 7 && type != 8) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    public boolean isKeyFrame(List<byte[]> nals) {
+        for (byte[] nal : nals) {
+            if (nal.length > 0 && (nal[0] & 0x1F) == 5) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * @return Annex-B 访问单元;未就绪返回 null
+     */
+    public byte[] build(byte[] packetData, boolean keyFrameHint) throws IOException {
+        List<byte[]> nals = AnnexBParser.extractNals(packetData);
+        updateParameterSets(nals);
+        if (nals.isEmpty() || isParameterSetOnly(nals)) {
+            return null;
+        }
+
+        boolean keyFrame = keyFrameHint || isKeyFrame(nals);
+        if (!keyFrame && !sentFirstKeyFrame) {
+            return null;
+        }
+        if (keyFrame && !hasParameterSets() && !(containsType(nals, 7) && containsType(nals, 8))) {
+            return null;
+        }
+
+        List<byte[]> ordered = orderNals(nals, keyFrame);
+        sentFirstKeyFrame = sentFirstKeyFrame || keyFrame;
+        return toAnnexB(ordered);
+    }
+
+    private List<byte[]> orderNals(List<byte[]> nals, boolean keyFrame) {
+        List<byte[]> ordered = new ArrayList<>();
+        if (keyFrame) {
+            if (sps != null && !containsType(nals, 7)) {
+                ordered.add(sps);
+            }
+            if (pps != null && !containsType(nals, 8)) {
+                ordered.add(pps);
+            }
+        }
+        List<byte[]> sorted = new ArrayList<>(nals);
+        sorted.sort(Comparator.comparingInt(H264AccessUnitBuilder::nalPriority));
+        for (byte[] nal : sorted) {
+            if (nal.length == 0) {
+                continue;
+            }
+            int type = nal[0] & 0x1F;
+            if (type == 7 && ordered.stream().anyMatch(n -> (n[0] & 0x1F) == 7)) {
+                continue;
+            }
+            if (type == 8 && ordered.stream().anyMatch(n -> (n[0] & 0x1F) == 8)) {
+                continue;
+            }
+            ordered.add(nal);
+        }
+        return ordered;
+    }
+
+    private static int nalPriority(byte[] nal) {
+        if (nal.length == 0) {
+            return 99;
+        }
+        switch (nal[0] & 0x1F) {
+            case 7:
+                return 0;
+            case 8:
+                return 1;
+            case 5:
+                return 2;
+            default:
+                return 3;
+        }
+    }
+
+    private static boolean containsType(List<byte[]> nals, int type) {
+        for (byte[] nal : nals) {
+            if (nal.length > 0 && (nal[0] & 0x1F) == type) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static byte[] toAnnexB(List<byte[]> nals) throws IOException {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        for (byte[] nal : nals) {
+            if (nal.length == 0) {
+                continue;
+            }
+            out.write(new byte[]{0x00, 0x00, 0x00, 0x01});
+            out.write(nal);
+        }
+        return out.toByteArray();
+    }
+}

+ 65 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/H264ExtradataUtils.java

@@ -0,0 +1,65 @@
+package com.usky.cdi.service.sip.ps;
+
+import org.bytedeco.ffmpeg.avcodec.AVCodecParameters;
+import org.bytedeco.javacv.FFmpegFrameGrabber;
+
+import java.util.Arrays;
+
+/**
+ * 从 RTSP/FLV extradata (AVCDecoderConfigurationRecord) 提取 SPS/PPS。
+ */
+public final class H264ExtradataUtils {
+
+    private H264ExtradataUtils() {
+    }
+
+    public static void seedParameterSets(FFmpegFrameGrabber grabber, H264AccessUnitBuilder builder) {
+        if (grabber == null || builder == null) {
+            return;
+        }
+        try {
+            if (grabber.getFormatContext() == null || grabber.getVideoStream() < 0) {
+                return;
+            }
+            AVCodecParameters codecpar = grabber.getFormatContext()
+                    .streams(grabber.getVideoStream()).codecpar();
+            if (codecpar == null || codecpar.extradata_size() <= 0 || codecpar.extradata() == null) {
+                return;
+            }
+            byte[] extradata = new byte[codecpar.extradata_size()];
+            codecpar.extradata().get(extradata);
+            parseAvccExtradata(extradata, builder);
+        } catch (Exception ignored) {
+        }
+    }
+
+    private static void parseAvccExtradata(byte[] avcc, H264AccessUnitBuilder builder) {
+        if (avcc.length < 8 || avcc[0] != 0x01) {
+            return;
+        }
+        int offset = 5;
+        int numSps = avcc[offset] & 0x1F;
+        offset++;
+        for (int i = 0; i < numSps && offset + 2 <= avcc.length; i++) {
+            int len = ((avcc[offset] & 0xFF) << 8) | (avcc[offset + 1] & 0xFF);
+            offset += 2;
+            if (len > 0 && offset + len <= avcc.length) {
+                builder.setSps(Arrays.copyOfRange(avcc, offset, offset + len));
+                offset += len;
+            }
+        }
+        if (offset >= avcc.length) {
+            return;
+        }
+        int numPps = avcc[offset] & 0xFF;
+        offset++;
+        for (int i = 0; i < numPps && offset + 2 <= avcc.length; i++) {
+            int len = ((avcc[offset] & 0xFF) << 8) | (avcc[offset + 1] & 0xFF);
+            offset += 2;
+            if (len > 0 && offset + len <= avcc.length) {
+                builder.setPps(Arrays.copyOfRange(avcc, offset, offset + len));
+                offset += len;
+            }
+        }
+    }
+}

+ 163 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/HikvisionPesBuilder.java

@@ -0,0 +1,163 @@
+package com.usky.cdi.service.sip.ps;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+/**
+ * 海康风格 PES 封装(参考海康 PS 码流分析)。
+ * <p>
+ * SPS:{@code 8C 80 07 + PTS(5) + FF FC + Annex-B}<br>
+ * PPS:{@code 8C 00 03 + FF FF FC + Annex-B}<br>
+ * I 帧首片:{@code 8C 00 02 + FF FD + Annex-B 起始}<br>
+ * I 帧续片:{@code 88 00 02 + FF FF + 裸 ES 续数据}<br>
+ * P 帧:{@code 8C 80 07 + PTS(5) + FF FD + Annex-B}
+ */
+public final class HikvisionPesBuilder {
+
+    /** 海康 PES packlen 典型值 0x13FA */
+    private static final int PES_PACK_LEN = 5114;
+    /** 首片/续片 PES 头(flags + stufflen + stuff)固定 5 字节 */
+    private static final int CHUNK_PES_HDR = 5;
+    /** 8C 80 类 PES 头固定 10 字节(flags + stufflen + 7 字节 stuff) */
+    private static final int STD_PES_HDR = 10;
+
+    private HikvisionPesBuilder() {
+    }
+
+    public static void writeSpsPes(ByteArrayOutputStream out, byte[] annexBNal, long pts90k) throws IOException {
+        out.write(buildStdPes(annexBNal, pts90k, new byte[]{(byte) 0xFF, (byte) 0xFC}));
+    }
+
+    public static void writePpsPes(ByteArrayOutputStream out, byte[] annexBNal) throws IOException {
+        out.write(buildPpsPes(annexBNal));
+    }
+
+    public static void writePFramePesChunks(ByteArrayOutputStream out, byte[] annexB, long pts90k)
+            throws IOException {
+        writeStdPesChunks(out, annexB, pts90k, new byte[]{(byte) 0xFF, (byte) 0xFD});
+    }
+
+    public static void writeIdrPesChunks(ByteArrayOutputStream out, byte[] annexB, long pts90k)
+            throws IOException {
+        if (annexB.length <= maxFirstChunkEs()) {
+            out.write(buildFirstIdrPes(annexB, 0, annexB.length, pts90k));
+            return;
+        }
+        int offset = 0;
+        boolean first = true;
+        while (offset < annexB.length) {
+            int chunk = Math.min(first ? maxFirstChunkEs() : maxContinueEs(), annexB.length - offset);
+            if (first) {
+                out.write(buildFirstIdrPes(annexB, offset, chunk, pts90k));
+            } else {
+                out.write(buildContinueIdrPes(annexB, offset, chunk));
+            }
+            offset += chunk;
+            first = false;
+        }
+    }
+
+    private static void writeStdPesChunks(ByteArrayOutputStream out, byte[] annexB, long pts90k,
+                                          byte[] suffix) throws IOException {
+        int maxEs = PES_PACK_LEN - STD_PES_HDR;
+        int offset = 0;
+        boolean first = true;
+        while (offset < annexB.length) {
+            int chunk = Math.min(first ? maxEs : maxContinueEs(), annexB.length - offset);
+            if (first) {
+                byte[] payload = slice(annexB, offset, chunk);
+                out.write(buildStdPes(payload, pts90k, suffix));
+            } else {
+                out.write(buildContinueIdrPes(annexB, offset, chunk));
+            }
+            offset += chunk;
+            first = false;
+        }
+    }
+
+    private static int maxFirstChunkEs() {
+        return PES_PACK_LEN - CHUNK_PES_HDR;
+    }
+
+    private static int maxContinueEs() {
+        return PES_PACK_LEN - CHUNK_PES_HDR;
+    }
+
+    private static byte[] buildStdPes(byte[] payload, long pts90k, byte[] suffix) {
+        byte[] pts5 = buildPts5(pts90k);
+        byte[] stuff = new byte[pts5.length + suffix.length];
+        System.arraycopy(pts5, 0, stuff, 0, pts5.length);
+        System.arraycopy(suffix, 0, stuff, pts5.length, suffix.length);
+        return buildPes((byte) 0x8C, (byte) 0x80, stuff, payload);
+    }
+
+    private static byte[] buildPpsPes(byte[] payload) {
+        return buildPes((byte) 0x8C, (byte) 0x00, new byte[]{(byte) 0xFF, (byte) 0xFF, (byte) 0xFC}, payload);
+    }
+
+    private static byte[] buildFirstIdrPes(byte[] payload, int offset, int length, long pts90k) {
+        byte[] stuff = buildIdrFirstStuff(pts90k);
+        byte[] slice = slice(payload, offset, length);
+        return buildPes((byte) 0x8C, (byte) 0x00, stuff, slice);
+    }
+
+    private static byte[] buildContinueIdrPes(byte[] payload, int offset, int length) {
+        int bodyLen = CHUNK_PES_HDR + length;
+        byte[] pes = new byte[6 + bodyLen];
+        pes[0] = 0x00;
+        pes[1] = 0x00;
+        pes[2] = 0x01;
+        pes[3] = (byte) 0xE0;
+        pes[4] = (byte) ((bodyLen >> 8) & 0xFF);
+        pes[5] = (byte) (bodyLen & 0xFF);
+        pes[6] = (byte) 0x88;
+        pes[7] = (byte) 0x00;
+        pes[8] = 0x02;
+        pes[9] = (byte) 0xFF;
+        pes[10] = (byte) 0xFF;
+        System.arraycopy(payload, offset, pes, 11, length);
+        return pes;
+    }
+
+    private static byte[] buildPes(byte flag1, byte flag2, byte[] stuff, byte[] payload) {
+        int bodyLen = 2 + 1 + stuff.length + payload.length;
+        byte[] pes = new byte[6 + bodyLen];
+        pes[0] = 0x00;
+        pes[1] = 0x00;
+        pes[2] = 0x01;
+        pes[3] = (byte) 0xE0;
+        pes[4] = (byte) ((bodyLen >> 8) & 0xFF);
+        pes[5] = (byte) (bodyLen & 0xFF);
+        pes[6] = flag1;
+        pes[7] = flag2;
+        pes[8] = (byte) stuff.length;
+        System.arraycopy(stuff, 0, pes, 9, stuff.length);
+        System.arraycopy(payload, 0, pes, 9 + stuff.length, payload.length);
+        return pes;
+    }
+
+    private static byte[] buildIdrFirstStuff(long pts90k) {
+        if (pts90k == 0) {
+            return new byte[]{(byte) 0xFF, (byte) 0xFD};
+        }
+        long pts = pts90k / 100;
+        return new byte[]{(byte) ((pts >> 8) & 0xFF), (byte) (pts & 0xFF)};
+    }
+
+    private static byte[] buildPts5(long pts90k) {
+        long pts = pts90k / 100;
+        byte[] buf = new byte[5];
+        buf[0] = (byte) (0x21 | ((pts >> 29) & 0x0E));
+        buf[1] = (byte) (pts >> 22);
+        buf[2] = (byte) (0x01 | ((pts >> 14) & 0xFE));
+        buf[3] = (byte) (pts >> 7);
+        buf[4] = (byte) (0x01 | ((pts & 0x7F) << 1));
+        return buf;
+    }
+
+    private static byte[] slice(byte[] data, int offset, int length) {
+        byte[] out = new byte[length];
+        System.arraycopy(data, offset, out, 0, length);
+        return out;
+    }
+}

+ 73 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/PsStreamFramer.java

@@ -0,0 +1,73 @@
+package com.usky.cdi.service.sip.ps;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 从 FFmpeg mpegps 输出流中按 Pack Header(0x000001BA) 切分完整 PS 包。
+ */
+public final class PsStreamFramer {
+
+    private byte[] buffer = new byte[0];
+
+    public List<byte[]> feed(byte[] chunk, int offset, int length) {
+        if (length <= 0) {
+            return Collections.emptyList();
+        }
+        buffer = concat(buffer, chunk, offset, length);
+        return extractCompletePacks(false);
+    }
+
+    public List<byte[]> flush() {
+        if (buffer.length < 4) {
+            buffer = new byte[0];
+            return Collections.emptyList();
+        }
+        return extractCompletePacks(true);
+    }
+
+    private List<byte[]> extractCompletePacks(boolean flushRemainder) {
+        List<Integer> starts = findPackStarts(buffer);
+        List<byte[]> packs = new ArrayList<>();
+        if (starts.size() >= 2) {
+            for (int i = 0; i < starts.size() - 1; i++) {
+                packs.add(Arrays.copyOfRange(buffer, starts.get(i), starts.get(i + 1)));
+            }
+            buffer = Arrays.copyOfRange(buffer, starts.get(starts.size() - 1), buffer.length);
+        }
+        if (flushRemainder && buffer.length >= 14 && isPackStart(buffer, 0)) {
+            packs.add(buffer);
+            buffer = new byte[0];
+        }
+        return packs;
+    }
+
+    private static List<Integer> findPackStarts(byte[] data) {
+        List<Integer> starts = new ArrayList<>();
+        for (int i = 0; i <= data.length - 4; i++) {
+            if (isPackStart(data, i)) {
+                if (starts.isEmpty() || starts.get(starts.size() - 1) != i) {
+                    starts.add(i);
+                }
+            }
+        }
+        return starts;
+    }
+
+    private static boolean isPackStart(byte[] data, int offset) {
+        return offset + 3 < data.length
+                && data[offset] == 0x00
+                && data[offset + 1] == 0x00
+                && data[offset + 2] == 0x01
+                && (data[offset + 3] & 0xFF) == 0xBA;
+    }
+
+    private static byte[] concat(byte[] left, byte[] right, int offset, int length) {
+        byte[] merged = new byte[left.length + length];
+        System.arraycopy(left, 0, merged, 0, left.length);
+        System.arraycopy(right, offset, merged, left.length, length);
+        return merged;
+    }
+}

+ 257 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/RtspGb28181RtpSender.java

@@ -0,0 +1,257 @@
+package com.usky.cdi.service.sip.push;
+
+import com.usky.cdi.service.sip.model.SdpMediaInfo;
+import com.usky.cdi.service.sip.ps.Gb28181PsMuxer;
+import com.usky.cdi.service.sip.ps.H264AccessUnitBuilder;
+import com.usky.cdi.service.sip.ps.H264ExtradataUtils;
+import com.usky.cdi.service.sip.rtp.AnnexBParser;
+import com.usky.cdi.service.sip.rtp.PsRtpPacketizer;
+import com.usky.cdi.service.sip.sdp.SdpUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.bytedeco.ffmpeg.avcodec.AVPacket;
+import org.bytedeco.ffmpeg.global.avcodec;
+import org.bytedeco.javacv.FFmpegFrameGrabber;
+
+import java.io.OutputStream;
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * GB/T 28181 推流:JavaCV 拉 RTSP → 海康兼容 PS 封装 → RTP(PT=96)。
+ */
+@Slf4j
+public final class RtspGb28181RtpSender {
+
+    private static final long DEFAULT_SSRC = 100_000_001L;
+    private static final int DEFAULT_TIMESTAMP_INCREMENT = 3600;
+    private static final long RTSP_DATA_WAIT_MS = 20_000L;
+    private static final long FIRST_RTP_WAIT_MS = 45_000L;
+
+    private RtspGb28181RtpSender() {
+    }
+
+    public static long stream(String rtspUrl, SdpMediaInfo media, String rtpHost, int rtpPort,
+                              int localRtpPort, int durationSeconds, String rtspTransport) throws Exception {
+        int payloadType = media != null && media.getPayloadType() > 0 ? media.getPayloadType() : 96;
+        long ssrc = SdpUtils.parseSsrcValue(media != null ? media.getSsrc() : null, DEFAULT_SSRC);
+        boolean useTcp = media != null && media.isTcpMedia();
+        String answerSetup = media != null && "active".equalsIgnoreCase(media.getSetup()) ? "passive" : "active";
+
+        log.info("GB28181 PS 推流: target={}:{}, localRtpPort={}, ssrc={}, tcp={}",
+                rtpHost, rtpPort, localRtpPort, ssrc, useTcp);
+
+        InetAddress remoteAddress = InetAddress.getByName(rtpHost);
+        AtomicLong packetCount = new AtomicLong();
+        H264AccessUnitBuilder auBuilder = new H264AccessUnitBuilder();
+
+        try (RtpTransport transport = openTransport(useTcp, answerSetup, remoteAddress, rtpPort, localRtpPort)) {
+            FFmpegFrameGrabber grabber = createGrabber(rtspUrl, rtspTransport);
+            try {
+                grabber.start();
+                int videoStream = grabber.getVideoStream();
+                double fps = grabber.getFrameRate();
+                int timestampIncrement = fps > 1 && fps < 121
+                        ? (int) (90000 / fps) : DEFAULT_TIMESTAMP_INCREMENT;
+                H264ExtradataUtils.seedParameterSets(grabber, auBuilder);
+                log.info("RTSP 已连接: videoStream={}, fps={}, tsInc={}, hasSpsPps={}",
+                        videoStream, fps, timestampIncrement, auBuilder.hasParameterSets());
+
+                int sequence = 0;
+                long timestamp = 0;
+                long endTime = System.currentTimeMillis() + durationSeconds * 1000L;
+                long streamStart = System.currentTimeMillis();
+                long rtspDataDeadline = streamStart + RTSP_DATA_WAIT_MS;
+                long firstRtpDeadline = streamStart + FIRST_RTP_WAIT_MS;
+                long rtspVideoPackets = 0;
+                long psPacks = 0;
+
+                while (System.currentTimeMillis() < endTime) {
+                    AVPacket pkt = grabber.grabPacket();
+                    if (pkt == null || pkt.size() <= 0) {
+                        if (rtspVideoPackets == 0 && System.currentTimeMillis() > rtspDataDeadline) {
+                            throw new IllegalStateException(
+                                    "RTSP 在 " + (RTSP_DATA_WAIT_MS / 1000) + "s 内未收到视频包");
+                        }
+                        Thread.sleep(2);
+                        continue;
+                    }
+                    if (pkt.stream_index() != videoStream) {
+                        continue;
+                    }
+                    rtspVideoPackets++;
+
+                    byte[] data = new byte[pkt.size()];
+                    pkt.data().get(data, 0, pkt.size());
+                    boolean keyFrame = (pkt.flags() & avcodec.AV_PKT_FLAG_KEY) != 0;
+                    byte[] accessUnit = auBuilder.build(data, keyFrame);
+                    if (accessUnit == null) {
+                        if (packetCount.get() == 0 && System.currentTimeMillis() > firstRtpDeadline) {
+                            throw new IllegalStateException(
+                                    "等待首个可发送帧超时,已收 RTSP 包=" + rtspVideoPackets
+                                            + ",hasSpsPps=" + auBuilder.hasParameterSets());
+                        }
+                        continue;
+                    }
+
+                    List<byte[]> nals = AnnexBParser.extractNals(accessUnit);
+                    boolean psKeyFrame = keyFrame || auBuilder.isKeyFrame(nals);
+                    byte[] psFrame = Gb28181PsMuxer.mux(nals, timestamp, psKeyFrame);
+                    if (psFrame.length == 0) {
+                        continue;
+                    }
+                    sequence = sendPsFrame(transport, psFrame, payloadType, sequence, timestamp, ssrc, packetCount);
+                    timestamp += timestampIncrement;
+                    psPacks++;
+
+                    if (psPacks == 1) {
+                        log.info("首包 PS 已发送: psBytes={}, keyFrame={}, pesHead=[{}], rtspPackets={}",
+                                psFrame.length, psKeyFrame, hexHead(psFrame, 48), rtspVideoPackets);
+                    }
+                }
+
+                log.info("GB28181 推流统计: psPacks={}, rtpPackets={}, rtspVideoPackets={}",
+                        psPacks, packetCount.get(), rtspVideoPackets);
+                if (packetCount.get() == 0) {
+                    throw new IllegalStateException("推流结束但未发出 RTP");
+                }
+            } finally {
+                closeGrabber(grabber);
+            }
+        }
+        return packetCount.get();
+    }
+
+    private static int sendPsFrame(RtpTransport transport, byte[] psFrame, int payloadType,
+                                   int sequence, long timestamp, long ssrc, AtomicLong packetCount) throws Exception {
+        List<byte[]> rtpPackets = PsRtpPacketizer.packetize(psFrame, payloadType, sequence, timestamp, ssrc);
+        for (byte[] rtp : rtpPackets) {
+            transport.send(rtp);
+            packetCount.incrementAndGet();
+        }
+        return (sequence + rtpPackets.size()) & 0xFFFF;
+    }
+
+    private static FFmpegFrameGrabber createGrabber(String rtspUrl, String rtspTransport) {
+        FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(rtspUrl);
+        grabber.setFormat("rtsp");
+        grabber.setOption("rtsp_transport", rtspTransport != null ? rtspTransport : "tcp");
+        grabber.setOption("stimeout", "5000000");
+        grabber.setOption("max_delay", "500000");
+        grabber.setOption("fflags", "nobuffer");
+        grabber.setOption("flags", "low_delay");
+        return grabber;
+    }
+
+    private static void closeGrabber(FFmpegFrameGrabber grabber) {
+        try {
+            grabber.stop();
+            grabber.release();
+        } catch (Exception e) {
+            log.debug("关闭 RTSP: {}", e.getMessage());
+        }
+    }
+
+    private static String hexHead(byte[] data, int max) {
+        if (data == null || data.length == 0) {
+            return "";
+        }
+        int n = Math.min(data.length, max);
+        StringBuilder sb = new StringBuilder(n * 3);
+        for (int i = 0; i < n; i++) {
+            if (i > 0) {
+                sb.append(' ');
+            }
+            sb.append(String.format("%02X", data[i] & 0xFF));
+        }
+        return sb.toString();
+    }
+
+    private static RtpTransport openTransport(boolean useTcp, String answerSetup, InetAddress remoteHost,
+                                              int remotePort, int localPort) throws Exception {
+        if (!useTcp) {
+            return new UdpTransport(remoteHost, remotePort, localPort);
+        }
+        if ("active".equalsIgnoreCase(answerSetup)) {
+            Socket socket = new Socket(remoteHost, remotePort);
+            socket.setTcpNoDelay(true);
+            return new TcpTransport(socket);
+        }
+        ServerSocket serverSocket = new ServerSocket(localPort);
+        serverSocket.setSoTimeout(30000);
+        Socket socket = serverSocket.accept();
+        serverSocket.close();
+        socket.setTcpNoDelay(true);
+        return new TcpTransport(socket);
+    }
+
+    private interface RtpTransport extends AutoCloseable {
+        void send(byte[] rtp) throws Exception;
+
+        @Override
+        void close();
+    }
+
+    private static final class UdpTransport implements RtpTransport {
+        private final DatagramSocket socket;
+        private final DatagramPacket template;
+
+        UdpTransport(InetAddress host, int port, int localPort) throws Exception {
+            if (localPort > 0) {
+                this.socket = new DatagramSocket(localPort);
+                log.info("RTP 绑定本地端口: {}", localPort);
+            } else {
+                this.socket = new DatagramSocket();
+            }
+            this.template = new DatagramPacket(new byte[0], 0, host, port);
+        }
+
+        @Override
+        public void send(byte[] rtp) throws Exception {
+            template.setData(rtp);
+            template.setLength(rtp.length);
+            socket.send(template);
+        }
+
+        @Override
+        public void close() {
+            socket.close();
+        }
+    }
+
+    private static final class TcpTransport implements RtpTransport {
+        private final Socket socket;
+        private final OutputStream out;
+
+        TcpTransport(Socket socket) throws Exception {
+            this.socket = socket;
+            this.out = socket.getOutputStream();
+        }
+
+        @Override
+        public void send(byte[] rtp) throws Exception {
+            byte[] frame = new byte[rtp.length + 2];
+            frame[0] = (byte) ((rtp.length >> 8) & 0xFF);
+            frame[1] = (byte) (rtp.length & 0xFF);
+            System.arraycopy(rtp, 0, frame, 2, rtp.length);
+            out.write(frame);
+            out.flush();
+        }
+
+        @Override
+        public void close() {
+            try {
+                out.close();
+            } catch (Exception ignored) {
+            }
+            try {
+                socket.close();
+            } catch (Exception ignored) {
+            }
+        }
+    }
+}

+ 90 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/RtspH264RtpSender.java

@@ -0,0 +1,90 @@
+package com.usky.cdi.service.sip.push;
+
+import com.usky.cdi.service.sip.rtp.AnnexBParser;
+import com.usky.cdi.service.sip.rtp.H264RtpPacketizer;
+import lombok.extern.slf4j.Slf4j;
+import org.bytedeco.ffmpeg.avcodec.AVPacket;
+import org.bytedeco.javacv.FFmpegFrameGrabber;
+
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.util.List;
+
+/**
+ * 从 RTSP 拉取 H264 码流并封装为 RTP 发送到目标地址
+ */
+@Slf4j
+public class RtspH264RtpSender {
+
+    private static final int PAYLOAD_TYPE = 96;
+    private static final long SSRC = 0x12345678L;
+    private static final int TIMESTAMP_INCREMENT = 3000;
+
+    private RtspH264RtpSender() {
+    }
+
+    public static long stream(String rtspUrl, String rtpHost, int rtpPort, int durationSeconds,
+                              String rtspTransport) throws Exception {
+        InetAddress address = InetAddress.getByName(rtpHost);
+        FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(rtspUrl);
+        grabber.setFormat("rtsp");
+        grabber.setOption("rtsp_transport", rtspTransport != null ? rtspTransport : "tcp");
+        grabber.setOption("stimeout", "5000000");
+        grabber.setOption("max_delay", "500000");
+        grabber.setOption("fflags", "nobuffer");
+        grabber.setOption("flags", "low_delay");
+
+        long packetCount = 0;
+        int sequence = 0;
+        long timestamp = 0;
+        long endTime = System.currentTimeMillis() + durationSeconds * 1000L;
+
+        try (DatagramSocket socket = new DatagramSocket()) {
+            grabber.start();
+            int videoStream = grabber.getVideoStream();
+            log.info("RTSP 已连接: {}, videoStream={}", maskUrl(rtspUrl), videoStream);
+
+            while (System.currentTimeMillis() < endTime) {
+                AVPacket pkt = grabber.grabPacket();
+                if (pkt == null || pkt.size() <= 0) {
+                    Thread.sleep(5);
+                    continue;
+                }
+                if (pkt.stream_index() != videoStream) {
+                    continue;
+                }
+                byte[] data = new byte[pkt.size()];
+                pkt.data().get(data, 0, pkt.size());
+                List<byte[]> nals = AnnexBParser.extractNals(data);
+                for (byte[] nal : nals) {
+                    List<byte[]> rtpPackets = H264RtpPacketizer.packetize(
+                            nal, PAYLOAD_TYPE, sequence, timestamp, SSRC);
+                    for (byte[] rtp : rtpPackets) {
+                        DatagramPacket dg = new DatagramPacket(rtp, rtp.length, address, rtpPort);
+                        socket.send(dg);
+                        packetCount++;
+                        sequence++;
+                    }
+                }
+                timestamp += TIMESTAMP_INCREMENT;
+            }
+        } finally {
+            try {
+                grabber.stop();
+                grabber.release();
+            } catch (Exception e) {
+                log.debug("关闭 RTSP: {}", e.getMessage());
+            }
+        }
+        log.info("RTSP 推流结束: target={}:{}, rtpPackets={}", rtpHost, rtpPort, packetCount);
+        return packetCount;
+    }
+
+    private static String maskUrl(String rtspUrl) {
+        if (rtspUrl == null) {
+            return "";
+        }
+        return rtspUrl.replaceAll("://([^:]+):([^@]+)@", "://***:***@");
+    }
+}

+ 66 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/SipLocalIntegrationTestMain.java

@@ -0,0 +1,66 @@
+package com.usky.cdi.service.sip.push;
+
+import com.usky.cdi.service.sip.config.SipServerProperties;
+import com.usky.cdi.service.sip.listener.SipServerListener;
+import com.usky.cdi.service.sip.model.VideoStreamSession;
+import com.usky.cdi.service.sip.service.VideoStreamManager;
+import com.usky.cdi.service.sip.util.PortAllocator;
+
+/**
+ * 本地 SIP 收发集成测试:启动服务端并模拟设备推流。
+ */
+public class SipLocalIntegrationTestMain {
+
+    public static void main(String[] args) throws Exception {
+        int sipPort = 15060;
+        SipServerProperties properties = new SipServerProperties();
+        properties.setEnabled(true);
+        properties.setHost("127.0.0.1");
+        properties.setPort(sipPort);
+        properties.setLocalIp("127.0.0.1");
+        properties.setRtpPortMin(30100);
+        properties.setRtpPortMax(30200);
+
+        PortAllocator portAllocator = new PortAllocator(properties);
+        VideoStreamManager streamManager = new VideoStreamManager(properties, portAllocator);
+        SipServerListener listener = new SipServerListener(properties, streamManager);
+
+        listener.start();
+        Thread.sleep(500);
+        System.out.println("SIP 服务端已启动: 127.0.0.1:" + sipPort);
+
+        SipVideoPusher pusher = new SipVideoPusher(
+                "127.0.0.1", sipPort,
+                "34020000001320000001",
+                properties.getPlatformId(),
+                3,
+                "127.0.0.1"
+        );
+        SipVideoPusher.PushResult result = pusher.push();
+
+        Thread.sleep(500);
+        VideoStreamSession session = result.getCallId() != null
+                ? streamManager.getSession(result.getCallId()) : null;
+
+        System.out.println("--- 测试结果 ---");
+        System.out.println("pushSuccess=" + result.isSuccess());
+        System.out.println("packetsSent=" + result.getPacketsSent());
+        if (session != null) {
+            System.out.println("receivedPackets=" + session.getReceivedPackets().get());
+            System.out.println("receivedBytes=" + session.getReceivedBytes().get());
+            System.out.println("localRtpPort=" + session.getLocalRtpPort());
+        } else {
+            System.out.println("serverSession=null");
+        }
+        if (!result.getErrors().isEmpty()) {
+            System.out.println("errors=" + result.getErrors());
+        }
+
+        listener.stop();
+        boolean ok = result.isSuccess()
+                && session != null
+                && session.getReceivedPackets().get() > 0;
+        System.out.println(ok ? "PASS" : "FAIL");
+        System.exit(ok ? 0 : 1);
+    }
+}

+ 82 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/SipVideoPushTestMain.java

@@ -0,0 +1,82 @@
+package com.usky.cdi.service.sip.push;
+
+import com.usky.cdi.service.sip.config.SipClientProperties;
+import com.usky.cdi.service.sip.device.Gb28181DeviceSimulator;
+import com.usky.cdi.service.sip.device.Gb28181StreamResult;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.core.env.MapPropertySource;
+import org.springframework.core.env.StandardEnvironment;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 命令行 RTSP 推流测试入口(独立运行,不参与 Spring Boot 组件扫描)。
+ */
+public class SipVideoPushTestMain {
+
+    public static void main(String[] args) {
+        if (args.length < 2) {
+            System.err.println("用法: SipVideoPushTestMain <deviceId> <rtspUrl> [durationSeconds]");
+            System.exit(2);
+        }
+        String deviceId = args[0];
+        String rtspUrl = args[1];
+        int duration = args.length > 2 ? Integer.parseInt(args[2]) : 60;
+
+        SipClientProperties props = loadProperties();
+        Gb28181DeviceSimulator simulator = new Gb28181DeviceSimulator(props);
+        simulator.register(deviceId);
+        sleepUntilRegistered(simulator, 10);
+
+        try {
+            SipVideoPusherFactory factory = new SipVideoPusherFactory(simulator, props);
+            Gb28181StreamResult result = factory.pushRtsp(rtspUrl, duration);
+            System.out.println("success=" + result.isSuccess());
+            System.out.println("packetsSent=" + result.getPacketsSent());
+            if (!result.getErrors().isEmpty()) {
+                System.out.println("errors=" + result.getErrors());
+            }
+            System.exit(result.isSuccess() ? 0 : 1);
+        } finally {
+            simulator.destroy();
+        }
+    }
+
+    private static SipClientProperties loadProperties() {
+        Map<String, Object> map = new HashMap<>();
+        map.put("sip.client.enabled", "true");
+        map.put("sip.client.auto-start", "false");
+        map.put("sip.client.server-host", env("SIP_SERVER_HOST", "114.80.201.142"));
+        map.put("sip.client.server-port", env("SIP_SERVER_PORT", "15060"));
+        map.put("sip.client.domain", env("SIP_DOMAIN", "3402000000"));
+        map.put("sip.client.platform-id", env("SIP_PLATFORM_ID", "34020000002000000001"));
+        map.put("sip.client.password", env("SIP_PASSWORD", "jkjj_wlgz"));
+        map.put("sip.client.sip-transport", env("SIP_TRANSPORT", "tcp"));
+        StandardEnvironment environment = new StandardEnvironment();
+        environment.getPropertySources().addFirst(new MapPropertySource("test", map));
+        return Binder.get(environment)
+                .bind("sip.client", SipClientProperties.class)
+                .orElse(new SipClientProperties());
+    }
+
+    private static String env(String key, String def) {
+        String v = System.getenv(key);
+        return v != null && !v.isEmpty() ? v : def;
+    }
+
+    private static void sleepUntilRegistered(Gb28181DeviceSimulator simulator, int maxSeconds) {
+        for (int i = 0; i < maxSeconds * 10; i++) {
+            if (Boolean.TRUE.equals(simulator.status().get("registered"))) {
+                return;
+            }
+            try {
+                Thread.sleep(100);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                return;
+            }
+        }
+        System.err.println("警告: 设备在 " + maxSeconds + "s 内未完成注册");
+    }
+}

+ 332 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/SipVideoPusher.java

@@ -0,0 +1,332 @@
+package com.usky.cdi.service.sip.push;
+
+import com.usky.cdi.service.sip.model.SdpMediaInfo;
+import com.usky.cdi.service.sip.sdp.SdpUtils;
+import com.usky.cdi.service.sip.util.NetworkUtils;
+import gov.nist.javax.sip.SipStackImpl;
+import lombok.extern.slf4j.Slf4j;
+
+import javax.sip.*;
+import javax.sip.address.Address;
+import javax.sip.address.AddressFactory;
+import javax.sip.address.SipURI;
+import javax.sip.header.*;
+import javax.sip.message.MessageFactory;
+import javax.sip.message.Request;
+import javax.sip.message.Response;
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * SIP 客户端:向服务端发送 INVITE 并推送 H264 RTP 测试流
+ */
+@Slf4j
+public class SipVideoPusher implements SipListener {
+
+    private static final int PAYLOAD_TYPE = 96;
+    private static final long SSRC = 0x12345678L;
+
+    private final String serverHost;
+    private final int serverPort;
+    private final String deviceId;
+    private final String platformId;
+    private final int durationSeconds;
+    private final String forcedLocalIp;
+
+    private SipStack sipStack;
+    private SipProvider sipProvider;
+    private AddressFactory addressFactory;
+    private MessageFactory messageFactory;
+    private HeaderFactory headerFactory;
+
+    private final AtomicReference<String> remoteRtpHost = new AtomicReference<>();
+    private final AtomicReference<Integer> remoteRtpPort = new AtomicReference<>();
+    private final AtomicReference<Dialog> dialogRef = new AtomicReference<>();
+    private final AtomicReference<Long> inviteOkCseq = new AtomicReference<>();
+    private final CountDownLatch inviteLatch = new CountDownLatch(1);
+    private final List<String> errors = new ArrayList<>();
+
+    private int localRtpPort;
+    private String localIp;
+    private String callId;
+    private int cseq = 1;
+    private long packetsSent;
+
+    public SipVideoPusher(String serverHost, int serverPort, String deviceId,
+                          String platformId, int durationSeconds) {
+        this(serverHost, serverPort, deviceId, platformId, durationSeconds, null);
+    }
+
+    public SipVideoPusher(String serverHost, int serverPort, String deviceId,
+                          String platformId, int durationSeconds, String forcedLocalIp) {
+        this.serverHost = serverHost;
+        this.serverPort = serverPort;
+        this.deviceId = deviceId;
+        this.platformId = platformId;
+        this.durationSeconds = durationSeconds;
+        this.forcedLocalIp = forcedLocalIp;
+    }
+
+    public PushResult push() {
+        try {
+            initStack();
+            localIp = forcedLocalIp != null ? forcedLocalIp : NetworkUtils.resolveLocalIp(null);
+            localRtpPort = findAvailablePort();
+            sendInvite();
+            if (!inviteLatch.await(10, TimeUnit.SECONDS)) {
+                errors.add("等待 INVITE 响应超时");
+                return buildResult(false);
+            }
+            if (remoteRtpPort.get() == null) {
+                errors.add("未从 SDP 应答中解析到服务端 RTP 端口");
+                return buildResult(false);
+            }
+            sendAck();
+            packetsSent = sendRtpStream(durationSeconds);
+            sendBye();
+            return buildResult(true);
+        } catch (Exception e) {
+            log.error("推流测试失败: {}", e.getMessage(), e);
+            errors.add(e.getMessage());
+            return buildResult(false);
+        } finally {
+            shutdown();
+        }
+    }
+
+    private void initStack() throws Exception {
+        SipFactory sipFactory = SipFactory.getInstance();
+        sipFactory.setPathName("gov.nist");
+        java.util.Properties props = new java.util.Properties();
+        props.setProperty("javax.sip.STACK_NAME", "UskySipVideoPusher");
+        props.setProperty("gov.nist.javax.sip.TRACE_LEVEL", "0");
+        sipStack = sipFactory.createSipStack(props);
+        addressFactory = sipFactory.createAddressFactory();
+        messageFactory = sipFactory.createMessageFactory();
+        headerFactory = sipFactory.createHeaderFactory();
+
+        localIp = forcedLocalIp != null ? forcedLocalIp : NetworkUtils.resolveLocalIp(null);
+        int sipLocalPort = findAvailablePort();
+        ListeningPoint lp = sipStack.createListeningPoint(localIp, sipLocalPort, "udp");
+        sipProvider = sipStack.createSipProvider(lp);
+        sipProvider.addSipListener(this);
+    }
+
+    private void sendInvite() throws Exception {
+        String sdp = SdpUtils.buildInviteSdp(localIp, localRtpPort, PAYLOAD_TYPE, deviceId);
+        SipURI requestUri = addressFactory.createSipURI(platformId, serverHost);
+        requestUri.setPort(serverPort);
+        Address toAddress = addressFactory.createAddress(requestUri);
+        ToHeader toHeader = headerFactory.createToHeader(toAddress, null);
+
+        SipURI fromUri = addressFactory.createSipURI(deviceId, localIp);
+        fromUri.setPort(sipProvider.getListeningPoint("udp").getPort());
+        Address fromAddress = addressFactory.createAddress(fromUri);
+        FromHeader fromHeader = headerFactory.createFromHeader(fromAddress, "push-tag");
+
+        SipURI contactUri = addressFactory.createSipURI(deviceId, localIp);
+        contactUri.setPort(sipProvider.getListeningPoint("udp").getPort());
+        Address contactAddress = addressFactory.createAddress(contactUri);
+        ContactHeader contactHeader = headerFactory.createContactHeader(contactAddress);
+
+        List<ViaHeader> viaHeaders = new ArrayList<>();
+        ViaHeader viaHeader = headerFactory.createViaHeader(localIp,
+                sipProvider.getListeningPoint("udp").getPort(), "udp", null);
+        viaHeaders.add(viaHeader);
+
+        CallIdHeader callIdHeader = sipProvider.getNewCallId();
+        callId = callIdHeader.getCallId();
+        CSeqHeader cSeqHeader = headerFactory.createCSeqHeader(cseq++, Request.INVITE);
+        MaxForwardsHeader maxForwards = headerFactory.createMaxForwardsHeader(70);
+        ContentTypeHeader contentType = headerFactory.createContentTypeHeader("application", "sdp");
+
+        Request invite = messageFactory.createRequest(requestUri, Request.INVITE, callIdHeader,
+                cSeqHeader, fromHeader, toHeader, viaHeaders, maxForwards);
+        invite.addHeader(contactHeader);
+        invite.setContent(sdp, contentType);
+
+        ClientTransaction ct = sipProvider.getNewClientTransaction(invite);
+        ct.sendRequest();
+        log.info("已发送 INVITE: {}:{} deviceId={} localRtpPort={}", serverHost, serverPort, deviceId, localRtpPort);
+    }
+
+    private void sendAck() throws Exception {
+        Dialog dialog = dialogRef.get();
+        if (dialog == null) {
+            return;
+        }
+        long ackCseq = inviteOkCseq.get() != null ? inviteOkCseq.get() : 1L;
+        Request ack = dialog.createAck(ackCseq);
+        dialog.sendAck(ack);
+        log.info("已发送 ACK");
+    }
+
+    private void sendBye() throws Exception {
+        Dialog dialog = dialogRef.get();
+        if (dialog == null) {
+            return;
+        }
+        Request bye = dialog.createRequest(Request.BYE);
+        CSeqHeader cSeqHeader = (CSeqHeader) bye.getHeader(CSeqHeader.NAME);
+        cSeqHeader.setSeqNumber(cseq++);
+        ClientTransaction ct = sipProvider.getNewClientTransaction(bye);
+        dialog.sendRequest(ct);
+        Thread.sleep(500);
+        log.info("已发送 BYE");
+    }
+
+    private long sendRtpStream(int seconds) throws Exception {
+        String host = remoteRtpHost.get();
+        int port = remoteRtpPort.get();
+        InetAddress address = InetAddress.getByName(host);
+        DatagramSocket socket = new DatagramSocket();
+        int seq = 0;
+        long timestamp = 0;
+        long count = 0;
+        long end = System.currentTimeMillis() + seconds * 1000L;
+        byte[] nalPayload = buildTestH264Nal();
+
+        while (System.currentTimeMillis() < end) {
+            byte[] rtp = buildRtpPacket(nalPayload, PAYLOAD_TYPE, seq++, timestamp, true);
+            DatagramPacket packet = new DatagramPacket(rtp, rtp.length, address, port);
+            socket.send(packet);
+            count++;
+            timestamp += 3000;
+            Thread.sleep(33);
+        }
+        socket.close();
+        log.info("RTP 推流完成: target={}:{}, packets={}", host, port, count);
+        return count;
+    }
+
+    private byte[] buildTestH264Nal() {
+        // 最小 SPS NAL (type 7)
+        return new byte[]{(byte) 0x67, (byte) 0x42, (byte) 0x00, (byte) 0x0A, (byte) 0xF8, (byte) 0x3C};
+    }
+
+    private byte[] buildRtpPacket(byte[] payload, int pt, int seq, long timestamp, boolean marker) {
+        byte[] packet = new byte[12 + payload.length];
+        packet[0] = (byte) 0x80;
+        packet[1] = (byte) ((marker ? 0x80 : 0) | (pt & 0x7F));
+        packet[2] = (byte) (seq >> 8);
+        packet[3] = (byte) (seq & 0xFF);
+        packet[4] = (byte) (timestamp >> 24);
+        packet[5] = (byte) (timestamp >> 16);
+        packet[6] = (byte) (timestamp >> 8);
+        packet[7] = (byte) (timestamp);
+        packet[8] = (byte) (SSRC >> 24);
+        packet[9] = (byte) (SSRC >> 16);
+        packet[10] = (byte) (SSRC >> 8);
+        packet[11] = (byte) (SSRC);
+        System.arraycopy(payload, 0, packet, 12, payload.length);
+        return packet;
+    }
+
+    private int findAvailablePort() throws Exception {
+        try (DatagramSocket socket = new DatagramSocket(0)) {
+            int port = socket.getLocalPort();
+            return port % 2 == 0 ? port : port + 1;
+        }
+    }
+
+    private void shutdown() {
+        if (sipStack != null) {
+            ((SipStackImpl) sipStack).stop();
+        }
+    }
+
+    private PushResult buildResult(boolean success) {
+        PushResult result = new PushResult();
+        result.setSuccess(success);
+        result.setCallId(callId);
+        result.setLocalRtpPort(localRtpPort);
+        result.setRemoteRtpHost(remoteRtpHost.get());
+        result.setRemoteRtpPort(remoteRtpPort.get());
+        result.setPacketsSent(packetsSent);
+        result.setErrors(errors);
+        return result;
+    }
+
+    @Override
+    public void processResponse(ResponseEvent responseEvent) {
+        Response response = responseEvent.getResponse();
+        int status = response.getStatusCode();
+        log.info("收到 SIP 响应: {}", status);
+        if (status == Response.TRYING || status == Response.RINGING) {
+            return;
+        }
+        if (status == Response.OK && responseEvent.getClientTransaction() != null
+                && Request.INVITE.equals(responseEvent.getClientTransaction().getRequest().getMethod())) {
+            try {
+                Dialog dialog = responseEvent.getDialog();
+                dialogRef.set(dialog);
+                CSeqHeader cSeqHeader = (CSeqHeader) response.getHeader(CSeqHeader.NAME);
+                if (cSeqHeader != null) {
+                    inviteOkCseq.set(cSeqHeader.getSeqNumber());
+                }
+                byte[] raw = response.getRawContent();
+                if (raw != null) {
+                    String sdp = new String(raw, StandardCharsets.UTF_8);
+                    SdpMediaInfo media = SdpUtils.parseVideoMedia(sdp);
+                    if (media != null) {
+                        String host = media.getConnectionAddress();
+                        if (host == null) {
+                            host = serverHost;
+                        }
+                        remoteRtpHost.set(host);
+                        remoteRtpPort.set(media.getPort());
+                        log.info("解析 SDP 应答: rtp={}:{}", host, media.getPort());
+                    }
+                }
+                inviteLatch.countDown();
+            } catch (Exception e) {
+                errors.add("处理 200 OK 失败: " + e.getMessage());
+                inviteLatch.countDown();
+            }
+        } else if (status >= 300) {
+            errors.add("INVITE 失败,状态码: " + status);
+            inviteLatch.countDown();
+        }
+    }
+
+    @Override
+    public void processRequest(RequestEvent requestEvent) {
+    }
+
+    @Override
+    public void processTimeout(TimeoutEvent timeoutEvent) {
+        errors.add("SIP 超时");
+        inviteLatch.countDown();
+    }
+
+    @Override
+    public void processIOException(IOExceptionEvent exceptionEvent) {
+        errors.add("SIP IO 异常");
+    }
+
+    @Override
+    public void processTransactionTerminated(TransactionTerminatedEvent event) {
+    }
+
+    @Override
+    public void processDialogTerminated(DialogTerminatedEvent event) {
+    }
+
+    @lombok.Data
+    public static class PushResult {
+        private boolean success;
+        private String callId;
+        private int localRtpPort;
+        private String remoteRtpHost;
+        private Integer remoteRtpPort;
+        private long packetsSent;
+        private List<String> errors = new ArrayList<>();
+    }
+}

+ 28 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/SipVideoPusherFactory.java

@@ -0,0 +1,28 @@
+package com.usky.cdi.service.sip.push;
+
+import com.usky.cdi.service.sip.config.SipClientProperties;
+import com.usky.cdi.service.sip.device.Gb28181DeviceSimulator;
+import com.usky.cdi.service.sip.device.Gb28181StreamResult;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+/**
+ * 从 Spring 配置构建 GB28181 推流(委托设备模拟器)。
+ */
+@Component
+@ConditionalOnProperty(prefix = "sip.client", name = "enabled", havingValue = "true")
+public class SipVideoPusherFactory {
+
+    private final Gb28181DeviceSimulator deviceSimulator;
+    private final SipClientProperties properties;
+
+    public SipVideoPusherFactory(Gb28181DeviceSimulator deviceSimulator, SipClientProperties properties) {
+        this.deviceSimulator = deviceSimulator;
+        this.properties = properties;
+    }
+
+    public Gb28181StreamResult pushRtsp(String rtspUrl, Integer durationSeconds) {
+        int duration = durationSeconds != null ? durationSeconds : properties.getDefaultDurationSeconds();
+        return deviceSimulator.bindRtspAndWait(rtspUrl, properties.getInviteWaitSeconds(), duration);
+    }
+}

+ 78 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/AnnexBParser.java

@@ -0,0 +1,78 @@
+package com.usky.cdi.service.sip.rtp;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * 从 FFmpeg AVPacket 数据中解析 H264 NAL(Annex B 或 AVCC)
+ */
+public final class AnnexBParser {
+
+    private AnnexBParser() {
+    }
+
+    public static List<byte[]> extractNals(byte[] data) {
+        if (data == null || data.length == 0) {
+            return new ArrayList<>();
+        }
+        if (isAnnexB(data)) {
+            return splitAnnexB(data);
+        }
+        return splitAvcc(data);
+    }
+
+    private static boolean isAnnexB(byte[] data) {
+        return data.length >= 3 && data[0] == 0 && data[1] == 0
+                && (data[2] == 1 || (data.length >= 4 && data[2] == 0 && data[3] == 1));
+    }
+
+    private static List<byte[]> splitAnnexB(byte[] data) {
+        List<byte[]> nals = new ArrayList<>();
+        List<Integer> codeStarts = new ArrayList<>();
+        List<Integer> nalBegins = new ArrayList<>();
+        for (int i = 0; i < data.length - 3; i++) {
+            if (data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1) {
+                codeStarts.add(i);
+                nalBegins.add(i + 3);
+                i += 2;
+            } else if (i + 3 < data.length && data[i] == 0 && data[i + 1] == 0
+                    && data[i + 2] == 0 && data[i + 3] == 1) {
+                codeStarts.add(i);
+                nalBegins.add(i + 4);
+                i += 3;
+            }
+        }
+        if (nalBegins.isEmpty()) {
+            nals.add(data);
+            return nals;
+        }
+        for (int s = 0; s < nalBegins.size(); s++) {
+            int begin = nalBegins.get(s);
+            int end = (s + 1 < codeStarts.size()) ? codeStarts.get(s + 1) : data.length;
+            if (begin < end) {
+                nals.add(Arrays.copyOfRange(data, begin, end));
+            }
+        }
+        return nals;
+    }
+
+    private static List<byte[]> splitAvcc(byte[] data) {
+        List<byte[]> nals = new ArrayList<>();
+        int pos = 0;
+        while (pos + 4 <= data.length) {
+            int len = ((data[pos] & 0xFF) << 24) | ((data[pos + 1] & 0xFF) << 16)
+                    | ((data[pos + 2] & 0xFF) << 8) | (data[pos + 3] & 0xFF);
+            pos += 4;
+            if (len <= 0 || pos + len > data.length) {
+                break;
+            }
+            nals.add(Arrays.copyOfRange(data, pos, pos + len));
+            pos += len;
+        }
+        if (nals.isEmpty() && data.length > 0) {
+            nals.add(data);
+        }
+        return nals;
+    }
+}

+ 76 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/H264Depacketizer.java

@@ -0,0 +1,76 @@
+package com.usky.cdi.service.sip.rtp;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * H264 RTP 解包,支持 Single NAL、STAP-A、FU-A
+ */
+public class H264Depacketizer {
+
+    private static final byte[] NAL_START_CODE = {0x00, 0x00, 0x00, 0x01};
+
+    private int fuExpectedType = -1;
+
+    public void depacketize(RtpPacket packet, OutputStream out) throws IOException {
+        if (packet == null || packet.getPayloadLength() <= 0) {
+            return;
+        }
+        byte[] data = packet.getPayload();
+        int offset = packet.getPayloadOffset();
+        int length = packet.getPayloadLength();
+        int nalType = data[offset] & 0x1F;
+
+        if (nalType >= 1 && nalType <= 23) {
+            writeNal(out, data, offset, length);
+        } else if (nalType == 24) {
+            depacketizeStapA(data, offset, length, out);
+        } else if (nalType == 28) {
+            depacketizeFuA(data, offset, length, out);
+        }
+    }
+
+    private void depacketizeStapA(byte[] data, int offset, int length, OutputStream out)
+            throws IOException {
+        int pos = offset + 1;
+        int end = offset + length;
+        while (pos + 2 <= end) {
+            int nalSize = ((data[pos] & 0xFF) << 8) | (data[pos + 1] & 0xFF);
+            pos += 2;
+            if (pos + nalSize > end) {
+                break;
+            }
+            writeNal(out, data, pos, nalSize);
+            pos += nalSize;
+        }
+    }
+
+    private void depacketizeFuA(byte[] data, int offset, int length, OutputStream out)
+            throws IOException {
+        if (length < 2) {
+            return;
+        }
+        int fuHeader = data[offset + 1] & 0xFF;
+        boolean start = (fuHeader & 0x80) != 0;
+        int nalType = fuHeader & 0x1F;
+
+        if (start) {
+            fuExpectedType = nalType;
+            int nalHeader = (data[offset] & 0xE0) | nalType;
+            out.write(NAL_START_CODE);
+            out.write((byte) nalHeader);
+        } else if (nalType != fuExpectedType) {
+            return;
+        }
+        out.write(data, offset + 2, length - 2);
+        if ((fuHeader & 0x40) != 0) {
+            fuExpectedType = -1;
+        }
+    }
+
+    private void writeNal(OutputStream out, byte[] data, int offset, int length)
+            throws IOException {
+        out.write(NAL_START_CODE);
+        out.write(data, offset, length);
+    }
+}

+ 46 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/H264RtpPacketizer.java

@@ -0,0 +1,46 @@
+package com.usky.cdi.service.sip.rtp;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * H264 NAL 单元 RTP 封装(Single NAL / FU-A)
+ */
+public final class H264RtpPacketizer {
+
+    private static final int MAX_PAYLOAD = 1400;
+
+    private H264RtpPacketizer() {
+    }
+
+    public static List<byte[]> packetize(byte[] nal, int payloadType, int startSequence,
+                                         long timestamp, long ssrc) {
+        List<byte[]> packets = new ArrayList<>();
+        if (nal == null || nal.length == 0) {
+            return packets;
+        }
+        if (nal.length <= MAX_PAYLOAD) {
+            packets.add(RtpPacketBuilder.build(nal, payloadType, startSequence, timestamp, ssrc, true));
+            return packets;
+        }
+        int nalType = nal[0] & 0x1F;
+        int nalHeader = nal[0] & 0xE0;
+        int offset = 1;
+        int remaining = nal.length - 1;
+        int seq = startSequence;
+        boolean first = true;
+        while (remaining > 0) {
+            int chunk = Math.min(remaining, MAX_PAYLOAD - 2);
+            boolean last = chunk == remaining;
+            byte[] payload = new byte[2 + chunk];
+            payload[0] = (byte) (nalHeader | 28);
+            payload[1] = (byte) ((first ? 0x80 : 0) | (last ? 0x40 : 0) | nalType);
+            System.arraycopy(nal, offset, payload, 2, chunk);
+            packets.add(RtpPacketBuilder.build(payload, payloadType, seq++, timestamp, ssrc, last));
+            offset += chunk;
+            remaining -= chunk;
+            first = false;
+        }
+        return packets;
+    }
+}

+ 35 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/PsRtpPacketizer.java

@@ -0,0 +1,35 @@
+package com.usky.cdi.service.sip.rtp;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * GB28181 PS over RTP:同一 PS 帧分片共用 timestamp,末包置 M 位。
+ */
+public final class PsRtpPacketizer {
+
+    /** GB28181 常用 RTP 负载上限 */
+    public static final int MAX_PAYLOAD = 1400;
+
+    private PsRtpPacketizer() {
+    }
+
+    public static List<byte[]> packetize(byte[] psFrame, int payloadType, int startSequence,
+                                         long timestamp, long ssrc) {
+        List<byte[]> packets = new ArrayList<>();
+        if (psFrame == null || psFrame.length == 0) {
+            return packets;
+        }
+        int offset = 0;
+        int seq = startSequence;
+        while (offset < psFrame.length) {
+            int chunk = Math.min(psFrame.length - offset, MAX_PAYLOAD);
+            boolean marker = offset + chunk >= psFrame.length;
+            byte[] payload = Arrays.copyOfRange(psFrame, offset, offset + chunk);
+            packets.add(RtpPacketBuilder.build(payload, payloadType, seq++, timestamp, ssrc, marker));
+            offset += chunk;
+        }
+        return packets;
+    }
+}

+ 61 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/RtpPacket.java

@@ -0,0 +1,61 @@
+package com.usky.cdi.service.sip.rtp;
+
+import lombok.Getter;
+
+@Getter
+public class RtpPacket {
+
+    private int version;
+    private boolean padding;
+    private boolean extension;
+    private int csrcCount;
+    private boolean marker;
+    private int payloadType;
+    private int sequenceNumber;
+    private long timestamp;
+    private long ssrc;
+    private byte[] payload;
+    private int payloadOffset;
+    private int payloadLength;
+
+    public static RtpPacket parse(byte[] data, int length) {
+        if (length < 12) {
+            return null;
+        }
+        RtpPacket packet = new RtpPacket();
+        int b0 = data[0] & 0xFF;
+        packet.version = (b0 >> 6) & 0x03;
+        packet.padding = ((b0 >> 5) & 0x01) == 1;
+        packet.extension = ((b0 >> 4) & 0x01) == 1;
+        packet.csrcCount = b0 & 0x0F;
+
+        int b1 = data[1] & 0xFF;
+        packet.marker = ((b1 >> 7) & 0x01) == 1;
+        packet.payloadType = b1 & 0x7F;
+
+        packet.sequenceNumber = ((data[2] & 0xFF) << 8) | (data[3] & 0xFF);
+        packet.timestamp = ((data[4] & 0xFFL) << 24) | ((data[5] & 0xFFL) << 16)
+                | ((data[6] & 0xFFL) << 8) | (data[7] & 0xFFL);
+        packet.ssrc = ((data[8] & 0xFFL) << 24) | ((data[9] & 0xFFL) << 16)
+                | ((data[10] & 0xFFL) << 8) | (data[11] & 0xFFL);
+
+        int headerLength = 12 + packet.csrcCount * 4;
+        if (packet.extension && length > headerLength + 4) {
+            int extLen = ((data[headerLength + 2] & 0xFF) << 8 | (data[headerLength + 3] & 0xFF)) * 4;
+            headerLength += 4 + extLen;
+        }
+        if (headerLength >= length) {
+            return null;
+        }
+
+        packet.payload = data;
+        packet.payloadOffset = headerLength;
+        packet.payloadLength = length - headerLength;
+
+        if (packet.padding && packet.payloadLength > 0) {
+            int padLen = data[length - 1] & 0xFF;
+            packet.payloadLength -= padLen;
+        }
+        return packet;
+    }
+}

+ 29 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/RtpPacketBuilder.java

@@ -0,0 +1,29 @@
+package com.usky.cdi.service.sip.rtp;
+
+/**
+ * RTP 包组装
+ */
+public final class RtpPacketBuilder {
+
+    private RtpPacketBuilder() {
+    }
+
+    public static byte[] build(byte[] payload, int payloadType, int sequenceNumber,
+                               long timestamp, long ssrc, boolean marker) {
+        byte[] packet = new byte[12 + payload.length];
+        packet[0] = (byte) 0x80;
+        packet[1] = (byte) ((marker ? 0x80 : 0) | (payloadType & 0x7F));
+        packet[2] = (byte) (sequenceNumber >> 8);
+        packet[3] = (byte) (sequenceNumber & 0xFF);
+        packet[4] = (byte) (timestamp >> 24);
+        packet[5] = (byte) (timestamp >> 16);
+        packet[6] = (byte) (timestamp >> 8);
+        packet[7] = (byte) (timestamp);
+        packet[8] = (byte) (ssrc >> 24);
+        packet[9] = (byte) (ssrc >> 16);
+        packet[10] = (byte) (ssrc >> 8);
+        packet[11] = (byte) (ssrc);
+        System.arraycopy(payload, 0, packet, 12, payload.length);
+        return packet;
+    }
+}

+ 108 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/RtpVideoReceiver.java

@@ -0,0 +1,108 @@
+package com.usky.cdi.service.sip.rtp;
+
+import com.usky.cdi.service.sip.model.VideoStreamSession;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.SocketException;
+import java.util.function.Consumer;
+
+@Slf4j
+public class RtpVideoReceiver implements Runnable {
+
+    private final VideoStreamSession session;
+    private final int localPort;
+    private final boolean isPsStream;
+    private final Consumer<byte[]> psConsumer;
+    private final H264Depacketizer h264Depacketizer;
+    private final OutputStream h264OutputStream;
+
+    private volatile boolean running = true;
+    private DatagramSocket socket;
+
+    public RtpVideoReceiver(VideoStreamSession session, int localPort, boolean isPsStream,
+                            OutputStream h264OutputStream, Consumer<byte[]> psConsumer) {
+        this.session = session;
+        this.localPort = localPort;
+        this.isPsStream = isPsStream;
+        this.h264OutputStream = h264OutputStream;
+        this.psConsumer = psConsumer;
+        this.h264Depacketizer = isPsStream ? null : new H264Depacketizer();
+    }
+
+    public void start() throws SocketException {
+        socket = new DatagramSocket(localPort);
+        socket.setSoTimeout(5000);
+        Thread thread = new Thread(this, "rtp-receiver-" + session.getSessionId());
+        thread.setDaemon(true);
+        thread.start();
+        session.setState(VideoStreamSession.SessionState.RECEIVING);
+        log.info("RTP 接收已启动: session={}, port={}, codec={}",
+                session.getSessionId(), localPort, session.getCodec());
+    }
+
+    public void stop() {
+        running = false;
+        if (socket != null && !socket.isClosed()) {
+            socket.close();
+        }
+        session.setState(VideoStreamSession.SessionState.STOPPED);
+        log.info("RTP 接收已停止: session={}", session.getSessionId());
+    }
+
+    @Override
+    public void run() {
+        byte[] buffer = new byte[65535];
+        long lastLogPackets = 0;
+        while (running) {
+            try {
+                DatagramPacket datagram = new DatagramPacket(buffer, buffer.length);
+                socket.receive(datagram);
+                long packets = session.getReceivedPackets().incrementAndGet();
+                session.getReceivedBytes().addAndGet(datagram.getLength());
+
+                RtpPacket rtp = RtpPacket.parse(datagram.getData(), datagram.getLength());
+                if (rtp == null) {
+                    continue;
+                }
+                if (session.getSsrc() == null) {
+                    session.setSsrc(String.format("%08X", rtp.getSsrc()));
+                }
+
+                if (isPsStream) {
+                    handlePsPayload(rtp);
+                } else if (h264OutputStream != null) {
+                    h264Depacketizer.depacketize(rtp, h264OutputStream);
+                    if (rtp.isMarker()) {
+                        h264OutputStream.flush();
+                        session.getReceivedFrames().incrementAndGet();
+                    }
+                }
+
+                if (packets - lastLogPackets >= 100) {
+                    lastLogPackets = packets;
+                    log.info("RTP 接收统计: callId={}, packets={}, bytes={}",
+                            session.getCallId(), packets, session.getReceivedBytes().get());
+                }
+            } catch (java.net.SocketTimeoutException e) {
+                // 超时继续等待
+            } catch (IOException e) {
+                if (running) {
+                    log.warn("RTP 接收异常: session={}, error={}", session.getSessionId(), e.getMessage());
+                }
+            }
+        }
+    }
+
+    private void handlePsPayload(RtpPacket rtp) {
+        if (psConsumer == null || rtp.getPayloadLength() <= 0) {
+            return;
+        }
+        byte[] chunk = new byte[rtp.getPayloadLength()];
+        System.arraycopy(rtp.getPayload(), rtp.getPayloadOffset(), chunk, 0, chunk.length);
+        psConsumer.accept(chunk);
+    }
+}

+ 191 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/sdp/SdpUtils.java

@@ -0,0 +1,191 @@
+package com.usky.cdi.service.sip.sdp;
+
+import com.usky.cdi.service.sip.model.SdpMediaInfo;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public final class SdpUtils {
+
+    private static final Pattern CONNECTION_PATTERN =
+            Pattern.compile("c=IN IP4 ([\\d.]+)", Pattern.CASE_INSENSITIVE);
+    /** GB28181 常见:RTP/AVP、TCP/RTP/AVP、RTP/AVP/TCP、UDP/RTP/AVP */
+    private static final Pattern MEDIA_PATTERN = Pattern.compile(
+            "m=video\\s+(\\d+)\\s+((?:UDP/|TCP/)?RTP/AVP(?:/(?:UDP|TCP))?)\\s+([\\d\\s]+)",
+            Pattern.CASE_INSENSITIVE);
+    private static final Pattern RTPMAP_PATTERN =
+            Pattern.compile("a=rtpmap:(\\d+)\\s+([^/\\s]+)/\\d+", Pattern.CASE_INSENSITIVE);
+    private static final Pattern Y_PATTERN =
+            Pattern.compile("y=([0-9]+)", Pattern.CASE_INSENSITIVE);
+    private static final Pattern SETUP_PATTERN =
+            Pattern.compile("a=setup:(\\w+)", Pattern.CASE_INSENSITIVE);
+
+    private SdpUtils() {
+    }
+
+    public static SdpMediaInfo parseVideoMedia(String sdp) {
+        if (sdp == null || sdp.isEmpty()) {
+            return null;
+        }
+        String normalized = sdp.replace("\n", "\r\n");
+        if (!normalized.contains("\r\n")) {
+            normalized = sdp;
+        }
+
+        SdpMediaInfo info = new SdpMediaInfo();
+        info.setMediaType("video");
+
+        Matcher connMatcher = CONNECTION_PATTERN.matcher(normalized);
+        if (connMatcher.find()) {
+            info.setConnectionAddress(connMatcher.group(1));
+        }
+
+        Matcher mediaMatcher = MEDIA_PATTERN.matcher(normalized);
+        if (!mediaMatcher.find()) {
+            return null;
+        }
+
+        info.setPort(Integer.parseInt(mediaMatcher.group(1)));
+        String protocol = mediaMatcher.group(2).trim();
+        info.setMediaProtocol(protocol);
+        info.setTcpMedia(protocol.toUpperCase().contains("TCP"));
+
+        String payloadPart = mediaMatcher.group(3).trim();
+        int payloadType = parseFirstPayloadType(payloadPart);
+        if (payloadType <= 0) {
+            return null;
+        }
+        info.setPayloadType(payloadType);
+
+        String codec = findCodec(normalized, payloadType);
+        if (codec == null) {
+            codec = payloadType == 96 ? "PS" : "H264";
+        }
+        info.setCodec(codec.toUpperCase());
+
+        Matcher setupMatcher = SETUP_PATTERN.matcher(normalized);
+        if (setupMatcher.find()) {
+            info.setSetup(setupMatcher.group(1).toLowerCase());
+        }
+
+        Matcher yMatcher = Y_PATTERN.matcher(normalized);
+        if (yMatcher.find()) {
+            info.setSsrc(yMatcher.group(1));
+        }
+
+        info.setSendOnly(normalized.contains("a=sendonly") || normalized.contains("a=recvonly"));
+        return info;
+    }
+
+    /**
+     * 构建 GB28181 200 OK SDP,尽量与平台 INVITE 的传输/编码一致。
+     */
+    public static String buildGb28181AnswerSdp(String localIp, int localRtpPort,
+                                                SdpMediaInfo media, String deviceId, String defaultSsrc) {
+        int payloadType = media != null && media.getPayloadType() > 0 ? media.getPayloadType() : 96;
+        String codec = resolveAnswerCodec(media, payloadType);
+        String ssrc = media != null && media.getSsrc() != null ? media.getSsrc() : defaultSsrc;
+        boolean tcp = media != null && media.isTcpMedia();
+        String mediaLine = tcp
+                ? "m=video " + localRtpPort + " TCP/RTP/AVP " + payloadType + "\r\n"
+                : "m=video " + localRtpPort + " RTP/AVP " + payloadType + "\r\n";
+
+        StringBuilder sb = new StringBuilder();
+        sb.append("v=0\r\n");
+        sb.append("o=").append(deviceId).append(" 0 0 IN IP4 ").append(localIp).append("\r\n");
+        sb.append("s=Play\r\n");
+        sb.append("u=").append(deviceId).append(":0\r\n");
+        sb.append("c=IN IP4 ").append(localIp).append("\r\n");
+        sb.append("t=0 0\r\n");
+        sb.append(mediaLine);
+        sb.append("a=rtpmap:").append(payloadType).append(" ").append(codec).append("/90000\r\n");
+        sb.append("a=sendonly\r\n");
+        if (tcp) {
+            String answerSetup = resolveAnswerSetup(media != null ? media.getSetup() : null);
+            sb.append("a=setup:").append(answerSetup).append("\r\n");
+            sb.append("a=connection:new\r\n");
+        }
+        sb.append("y=").append(ssrc).append("\r\n");
+        return sb.toString();
+    }
+
+    public static long parseSsrcValue(String ssrcStr, long defaultValue) {
+        if (ssrcStr == null || ssrcStr.isEmpty()) {
+            return defaultValue;
+        }
+        try {
+            return Long.parseLong(ssrcStr.trim());
+        } catch (NumberFormatException e) {
+            return defaultValue;
+        }
+    }
+
+    private static int parseFirstPayloadType(String payloadPart) {
+        String[] parts = payloadPart.split("\\s+");
+        for (String part : parts) {
+            if (!part.isEmpty()) {
+                return Integer.parseInt(part);
+            }
+        }
+        return 0;
+    }
+
+    private static String findCodec(String sdp, int payloadType) {
+        Matcher rtpmapMatcher = RTPMAP_PATTERN.matcher(sdp);
+        String fallback = null;
+        while (rtpmapMatcher.find()) {
+            int pt = Integer.parseInt(rtpmapMatcher.group(1));
+            String name = rtpmapMatcher.group(2).toUpperCase();
+            if (pt == payloadType) {
+                return name;
+            }
+            if ("PS".equals(name)) {
+                fallback = name;
+            }
+        }
+        return fallback;
+    }
+
+    private static String resolveAnswerCodec(SdpMediaInfo media, int payloadType) {
+        if (media != null && media.getCodec() != null) {
+            String codec = media.getCodec().toUpperCase();
+            if ("PS".equals(codec) || "H264".equals(codec) || "MPEG4".equals(codec)) {
+                return codec;
+            }
+        }
+        return payloadType == 96 ? "PS" : "H264";
+    }
+
+    /** 平台 passive -> 设备 active;平台 active -> 设备 passive */
+    private static String resolveAnswerSetup(String inviteSetup) {
+        if ("active".equalsIgnoreCase(inviteSetup)) {
+            return "passive";
+        }
+        return "active";
+    }
+
+    /** SIP 服务端 200 OK 应答 SDP(简化版) */
+    public static String buildVideoAnswerSdp(String localIp, int localRtpPort,
+                                             int payloadType, String codec) {
+        SdpMediaInfo media = new SdpMediaInfo();
+        media.setPayloadType(payloadType);
+        media.setCodec(codec != null ? codec : (payloadType == 96 ? "PS" : "H264"));
+        media.setSsrc("0100000001");
+        return buildGb28181AnswerSdp(localIp, localRtpPort, media, "34020000001320000001", media.getSsrc());
+    }
+
+    /** @deprecated 使用 {@link #buildGb28181AnswerSdp} */
+    public static String buildGb28181InviteSdp(String localIp, int localRtpPort,
+                                                int payloadType, String deviceId, String ssrc) {
+        SdpMediaInfo media = new SdpMediaInfo();
+        media.setPayloadType(payloadType);
+        media.setCodec(payloadType == 96 ? "PS" : "H264");
+        media.setSsrc(ssrc);
+        return buildGb28181AnswerSdp(localIp, localRtpPort, media, deviceId, ssrc);
+    }
+
+    /** @deprecated 使用 {@link #buildGb28181InviteSdp} */
+    public static String buildInviteSdp(String localIp, int localRtpPort, int payloadType, String deviceId) {
+        return buildGb28181InviteSdp(localIp, localRtpPort, payloadType, deviceId, "0100000001");
+    }
+}

+ 43 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/service/SipServerService.java

@@ -0,0 +1,43 @@
+package com.usky.cdi.service.sip.service;
+
+import com.usky.cdi.service.sip.config.SipServerProperties;
+import com.usky.cdi.service.sip.listener.SipServerListener;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.PreDestroy;
+
+@Slf4j
+@Service
+@ConditionalOnProperty(prefix = "sip.server", name = "enabled", havingValue = "true")
+public class SipServerService {
+
+    private final SipServerProperties properties;
+    private final SipServerListener sipServerListener;
+
+    public SipServerService(SipServerProperties properties, SipServerListener sipServerListener) {
+        this.properties = properties;
+        this.sipServerListener = sipServerListener;
+    }
+
+    @EventListener(ApplicationReadyEvent.class)
+    public void onApplicationReady() {
+        if (!properties.isEnabled()) {
+            log.info("SIP 服务端未启用 (sip.server.enabled=false)");
+            return;
+        }
+        try {
+            sipServerListener.start();
+        } catch (Exception e) {
+            log.error("SIP 服务端启动失败: {}", e.getMessage(), e);
+        }
+    }
+
+    @PreDestroy
+    public void shutdown() {
+        sipServerListener.stop();
+    }
+}

+ 109 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/service/VideoStreamManager.java

@@ -0,0 +1,109 @@
+package com.usky.cdi.service.sip.service;
+
+import com.usky.cdi.service.sip.config.SipServerProperties;
+import com.usky.cdi.service.sip.model.SdpMediaInfo;
+import com.usky.cdi.service.sip.model.VideoStreamSession;
+import com.usky.cdi.service.sip.rtp.RtpVideoReceiver;
+import com.usky.cdi.service.sip.sdp.SdpUtils;
+import com.usky.cdi.service.sip.util.NetworkUtils;
+import com.usky.cdi.service.sip.util.PortAllocator;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.time.LocalDateTime;
+import java.util.Collection;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Slf4j
+@Service
+@ConditionalOnProperty(prefix = "sip.server", name = "enabled", havingValue = "true")
+public class VideoStreamManager {
+
+    private final SipServerProperties properties;
+    private final PortAllocator portAllocator;
+    private final Map<String, VideoStreamSession> sessions = new ConcurrentHashMap<>();
+    private final Map<String, RtpVideoReceiver> receivers = new ConcurrentHashMap<>();
+
+    public VideoStreamManager(SipServerProperties properties, PortAllocator portAllocator) {
+        this.properties = properties;
+        this.portAllocator = portAllocator;
+    }
+
+    public VideoStreamSession createSession(String callId, String remoteAddress, SdpMediaInfo mediaInfo) {
+        String sessionId = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
+        int localRtpPort = portAllocator.allocate();
+
+        VideoStreamSession session = new VideoStreamSession();
+        session.setSessionId(sessionId);
+        session.setCallId(callId);
+        session.setRemoteAddress(remoteAddress);
+        session.setRemoteRtpPort(mediaInfo.getPort());
+        session.setLocalRtpPort(localRtpPort);
+        session.setCodec(mediaInfo.getCodec());
+        session.setPayloadType(mediaInfo.getPayloadType());
+        session.setStartTime(LocalDateTime.now());
+        session.setState(VideoStreamSession.SessionState.INIT);
+
+        sessions.put(callId, session);
+        log.info("创建视频会话: callId={}, sessionId={}, remote={}:{}, localRtpPort={}, codec={}",
+                callId, sessionId, remoteAddress, mediaInfo.getPort(), localRtpPort, mediaInfo.getCodec());
+        return session;
+    }
+
+    public String buildAnswerSdp(VideoStreamSession session) {
+        String localIp = NetworkUtils.resolveLocalIp(properties.getLocalIp());
+        return SdpUtils.buildVideoAnswerSdp(localIp, session.getLocalRtpPort(),
+                session.getPayloadType(), session.getCodec());
+    }
+
+    public void startReceiving(VideoStreamSession session) {
+        try {
+            boolean psStream = isPsCodec(session.getCodec());
+            RtpVideoReceiver receiver = new RtpVideoReceiver(
+                    session,
+                    session.getLocalRtpPort(),
+                    psStream,
+                    null,
+                    null
+            );
+            receiver.start();
+            receivers.put(session.getCallId(), receiver);
+            session.setState(VideoStreamSession.SessionState.ESTABLISHED);
+        } catch (IOException e) {
+            log.error("启动视频接收失败: callId={}, error={}", session.getCallId(), e.getMessage(), e);
+            stopSession(session.getCallId());
+        }
+    }
+
+    public void stopSession(String callId) {
+        RtpVideoReceiver receiver = receivers.remove(callId);
+        if (receiver != null) {
+            receiver.stop();
+        }
+        VideoStreamSession session = sessions.get(callId);
+        if (session != null) {
+            session.setState(VideoStreamSession.SessionState.STOPPED);
+        }
+        log.info("视频会话已结束: callId={}", callId);
+    }
+
+    public VideoStreamSession getSession(String callId) {
+        return sessions.get(callId);
+    }
+
+    public Collection<VideoStreamSession> getAllSessions() {
+        return sessions.values();
+    }
+
+    private boolean isPsCodec(String codec) {
+        if (codec == null) {
+            return false;
+        }
+        String upper = codec.toUpperCase();
+        return upper.contains("PS") || upper.contains("MPEG");
+    }
+}

+ 36 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/util/NetworkUtils.java

@@ -0,0 +1,36 @@
+package com.usky.cdi.service.sip.util;
+
+import java.net.Inet4Address;
+import java.net.InetAddress;
+import java.net.NetworkInterface;
+import java.util.Enumeration;
+
+public final class NetworkUtils {
+
+    private NetworkUtils() {
+    }
+
+    public static String resolveLocalIp(String configuredIp) {
+        if (configuredIp != null && !configuredIp.isEmpty() && !"0.0.0.0".equals(configuredIp)) {
+            return configuredIp;
+        }
+        try {
+            Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
+            while (interfaces.hasMoreElements()) {
+                NetworkInterface ni = interfaces.nextElement();
+                if (!ni.isUp() || ni.isLoopback() || ni.isVirtual()) {
+                    continue;
+                }
+                Enumeration<InetAddress> addresses = ni.getInetAddresses();
+                while (addresses.hasMoreElements()) {
+                    InetAddress addr = addresses.nextElement();
+                    if (addr instanceof Inet4Address && !addr.isLoopbackAddress()) {
+                        return addr.getHostAddress();
+                    }
+                }
+            }
+        } catch (Exception ignored) {
+        }
+        return "127.0.0.1";
+    }
+}

+ 49 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/util/PortAllocator.java

@@ -0,0 +1,49 @@
+package com.usky.cdi.service.sip.util;
+
+import com.usky.cdi.service.sip.config.SipServerProperties;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+import java.net.DatagramSocket;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@Component
+@ConditionalOnProperty(prefix = "sip.server", name = "enabled", havingValue = "true")
+public class PortAllocator {
+
+    private final int minPort;
+    private final int maxPort;
+    private final AtomicInteger cursor;
+
+    public PortAllocator(SipServerProperties properties) {
+        this.minPort = properties.getRtpPortMin();
+        this.maxPort = properties.getRtpPortMax();
+        this.cursor = new AtomicInteger(minPort);
+    }
+
+    public synchronized int allocate() {
+        int range = maxPort - minPort + 1;
+        for (int i = 0; i < range; i++) {
+            int port = cursor.getAndIncrement();
+            if (port > maxPort) {
+                cursor.set(minPort);
+                port = minPort;
+            }
+            if (port % 2 != 0) {
+                port++;
+            }
+            if (isPortAvailable(port)) {
+                return port;
+            }
+        }
+        throw new IllegalStateException("无可用 RTP 端口: " + minPort + "-" + maxPort);
+    }
+
+    private boolean isPortAvailable(int port) {
+        try (DatagramSocket socket = new DatagramSocket(port)) {
+            return true;
+        } catch (Exception e) {
+            return false;
+        }
+    }
+}

+ 39 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/util/SipDigestAuth.java

@@ -0,0 +1,39 @@
+package com.usky.cdi.service.sip.util;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * SIP Digest 认证(GB28181 注册)
+ */
+public final class SipDigestAuth {
+
+    private SipDigestAuth() {
+    }
+
+    public static String computeResponse(String username, String password, String realm,
+                                           String method, String uri, String nonce,
+                                           String nc, String cnonce, String qop) {
+        String ha1 = md5Hex(username + ":" + realm + ":" + password);
+        String ha2 = md5Hex(method + ":" + uri);
+        if (qop != null && !qop.isEmpty()) {
+            return md5Hex(ha1 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + ha2);
+        }
+        return md5Hex(ha1 + ":" + nonce + ":" + ha2);
+    }
+
+    public static String md5Hex(String input) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("MD5");
+            byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder();
+            for (byte b : digest) {
+                sb.append(String.format("%02x", b));
+            }
+            return sb.toString();
+        } catch (NoSuchAlgorithmException e) {
+            throw new IllegalStateException("MD5 not available", e);
+        }
+    }
+}

+ 83 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/util/Gb28181MqttCredentialResolver.java

@@ -0,0 +1,83 @@
+package com.usky.cdi.service.util;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.usky.cdi.domain.CdiDefenseProject;
+import com.usky.cdi.mapper.CdiDefenseProjectMapper;
+import com.usky.cdi.service.config.Gb28181VideoProperties;
+import com.usky.common.core.exception.BusinessException;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+/**
+ * 解析 GB28181 视频 MQTT 凭据:优先读 yml,缺失时从人防工程表补全。
+ */
+@Component
+@RequiredArgsConstructor
+@ConditionalOnProperty(prefix = "gb28181.video", name = "enabled", havingValue = "true")
+public class Gb28181MqttCredentialResolver {
+
+    private final Gb28181VideoProperties properties;
+    private final CdiDefenseProjectMapper cdiDefenseProjectMapper;
+
+    public MqttCredentials resolve() {
+        Integer tenantId = properties.getTenantId();
+        Long engineeringId = properties.getEngineeringId();
+        String username = properties.getMqttUsername();
+        String password = properties.getMqttPassword();
+
+        if (!StringUtils.hasText(username) || !StringUtils.hasText(password)
+                || tenantId == null || engineeringId == null) {
+            CdiDefenseProject project = lookupDefenseProject(tenantId, engineeringId);
+            if (project != null) {
+                if (tenantId == null) {
+                    tenantId = project.getTenantId();
+                }
+                if (engineeringId == null) {
+                    engineeringId = project.getEngineeringId();
+                }
+                if (!StringUtils.hasText(username)) {
+                    username = project.getMqttUserName();
+                }
+                if (!StringUtils.hasText(password)) {
+                    password = project.getMqttPassword();
+                }
+            }
+        }
+
+        if (engineeringId == null) {
+            throw new BusinessException("未配置 gb28181.video.engineering-id");
+        }
+        if (tenantId == null) {
+            throw new BusinessException("未配置 gb28181.video.tenant-id");
+        }
+        if (!StringUtils.hasText(username) || !StringUtils.hasText(password)) {
+            throw new BusinessException(
+                    "未配置 gb28181.video.mqtt-username / mqtt-password,且人防工程表(cdi_defense_project)中未找到有效凭据");
+        }
+        return new MqttCredentials(username, password, tenantId, engineeringId);
+    }
+
+    private CdiDefenseProject lookupDefenseProject(Integer tenantId, Long engineeringId) {
+        LambdaQueryWrapper<CdiDefenseProject> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(CdiDefenseProject::getIsEnable, 1);
+        if (engineeringId != null) {
+            queryWrapper.eq(CdiDefenseProject::getEngineeringId, engineeringId);
+        }
+        if (tenantId != null) {
+            queryWrapper.eq(CdiDefenseProject::getTenantId, tenantId);
+        }
+        return cdiDefenseProjectMapper.selectOne(queryWrapper);
+    }
+
+    @Getter
+    @RequiredArgsConstructor
+    public static class MqttCredentials {
+        private final String username;
+        private final String password;
+        private final Integer tenantId;
+        private final Long engineeringId;
+    }
+}

+ 312 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/util/Gb28181VideoPlatformClient.java

@@ -0,0 +1,312 @@
+package com.usky.cdi.service.util;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.usky.cdi.service.config.Gb28181VideoProperties;
+import com.usky.cdi.service.vo.video.VideoChannelVO;
+import com.usky.cdi.service.vo.video.VideoDeviceItemVO;
+import com.usky.cdi.service.vo.video.VideoPlayInfoVO;
+import com.usky.common.core.exception.BusinessException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.utils.URIBuilder;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.springframework.stereotype.Component;
+
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * GB28181 流媒体平台(WVP 等)HTTP API 客户端
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class Gb28181VideoPlatformClient {
+
+    private static final int CONNECT_TIMEOUT_MS = 8000;
+    private static final int SOCKET_TIMEOUT_MS = 15000;
+
+    private final Gb28181VideoProperties properties;
+
+    private volatile String accessToken;
+    private volatile long tokenExpireAt;
+
+    /**
+     * 检查配置:SIP 端口可达 + API 登录成功
+     */
+    public JSONObject checkConfiguration() {
+        JSONObject result = new JSONObject();
+        result.put("sipId", properties.getSipId());
+        result.put("sipDomain", properties.getSipDomain());
+        result.put("sipIp", properties.getSipIp());
+        result.put("sipPort", properties.getSipPort());
+        result.put("cascadePort", properties.getCascadePort());
+        result.put("rtpPortMin", properties.getRtpPortMin());
+        result.put("rtpPortMax", properties.getRtpPortMax());
+
+        boolean sipReachable = testTcpPort(properties.getSipIp(), properties.getSipPort());
+        result.put("sipPortReachable", sipReachable);
+
+        boolean cascadeReachable = testTcpPort(properties.getSipIp(), properties.getCascadePort());
+        result.put("cascadePortReachable", cascadeReachable);
+
+        try {
+            login(true);
+            result.put("apiLoginSuccess", true);
+            result.put("apiBaseUrl", properties.getApiBaseUrl());
+        } catch (Exception e) {
+            result.put("apiLoginSuccess", false);
+            result.put("apiLoginMessage", e.getMessage());
+        }
+        result.put("success", sipReachable && Boolean.TRUE.equals(result.getBoolean("apiLoginSuccess")));
+        return result;
+    }
+
+    /**
+     * 查询平台全部设备及通道
+     */
+    public List<VideoDeviceItemVO> queryAllDevicesWithChannels() {
+        login(false);
+        List<VideoDeviceItemVO> devices = new ArrayList<>();
+        int page = 1;
+        int pageSize = 100;
+        int total = Integer.MAX_VALUE;
+
+        while ((page - 1) * pageSize < total) {
+            JSONObject pageData = getJson("/api/device/query/devices", buildPageParam(page, pageSize));
+            JSONArray list = pageData.getJSONArray("list");
+            if (list == null || list.isEmpty()) {
+                break;
+            }
+            total = pageData.getIntValue("total");
+            for (int i = 0; i < list.size(); i++) {
+                JSONObject deviceJson = list.getJSONObject(i);
+                VideoDeviceItemVO device = parseDevice(deviceJson);
+                device.setChannels(queryChannels(device.getDeviceId()));
+                devices.add(device);
+            }
+            page++;
+        }
+        return devices;
+    }
+
+    /**
+     * 发起点播:WVP 向设备发 SIP INVITE,RTP 流汇入 114.80.201.142 媒体服务器
+     */
+    public VideoPlayInfoVO startPlay(String deviceId, String channelId) {
+        login(false);
+        JSONObject data = getJson("/api/play/start/" + deviceId + "/" + channelId, null);
+        return parsePlayInfo(deviceId, channelId, data);
+    }
+
+    /**
+     * 停止点播
+     */
+    public void stopPlay(String deviceId, String channelId) {
+        login(false);
+        getJson("/api/play/stop/" + deviceId + "/" + channelId, null);
+        log.info("已停止点播 deviceId={} channelId={}", deviceId, channelId);
+    }
+
+    /**
+     * 获取通道实时预览地址(内部调用 startPlay)
+     */
+    public String queryPlayUrl(String deviceId, String channelId) {
+        VideoPlayInfoVO playInfo = startPlay(deviceId, channelId);
+        return playInfo.getPreferredPlayUrl(properties.getPreferredProtocol());
+    }
+
+    private VideoPlayInfoVO parsePlayInfo(String deviceId, String channelId, JSONObject data) {
+        VideoPlayInfoVO info = new VideoPlayInfoVO();
+        info.setDeviceId(deviceId);
+        info.setChannelId(channelId);
+        info.setApp(data.getString("app"));
+        info.setStream(data.getString("stream"));
+        info.setFlvUrl(firstNonBlank(data, "flv", "ws_flv"));
+        info.setHlsUrl(data.getString("hls"));
+        info.setRtspUrl(data.getString("rtsp"));
+        info.setRtcUrl(data.getString("rtc"));
+        info.setWsFlvUrl(data.getString("ws_flv"));
+        info.setStreamStatus(info.getPreferredPlayUrl(properties.getPreferredProtocol()) != null ? "PUSHING" : "FAILED");
+        return info;
+    }
+
+    private String firstNonBlank(JSONObject data, String... keys) {
+        for (String key : keys) {
+            String value = data.getString(key);
+            if (value != null && !value.trim().isEmpty()) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    public List<VideoChannelVO> queryChannels(String deviceId) {
+        login(false);
+        JSONObject data = getJson("/api/device/query/devices/" + deviceId + "/channels", buildPageParam(1, 500));
+        JSONArray list = data.getJSONArray("list");
+        List<VideoChannelVO> channels = new ArrayList<>();
+        if (list == null) {
+            return channels;
+        }
+        for (int i = 0; i < list.size(); i++) {
+            channels.add(parseChannel(list.getJSONObject(i)));
+        }
+        return channels;
+    }
+
+    private VideoDeviceItemVO parseDevice(JSONObject json) {
+        VideoDeviceItemVO device = new VideoDeviceItemVO();
+        device.setDeviceId(json.getString("deviceId"));
+        device.setDeviceName(json.getString("name"));
+        device.setManufacturer(json.getString("manufacturer"));
+        Boolean onLine = json.getBoolean("onLine");
+        if (onLine == null) {
+            onLine = json.getBoolean("online");
+        }
+        device.setStatus(Boolean.TRUE.equals(onLine) ? "ON" : "OFF");
+        return device;
+    }
+
+    private VideoChannelVO parseChannel(JSONObject json) {
+        VideoChannelVO channel = new VideoChannelVO();
+        channel.setChannelId(json.getString("channelId"));
+        channel.setChannelName(json.getString("name"));
+        Boolean onLine = json.getBoolean("onLine");
+        if (onLine == null) {
+            onLine = json.getBoolean("online");
+        }
+        channel.setStatus(Boolean.TRUE.equals(onLine) ? "ON" : "OFF");
+        return channel;
+    }
+
+    private Map<String, String> buildPageParam(int page, int count) {
+        Map<String, String> param = new HashMap<>(4);
+        param.put("page", String.valueOf(page));
+        param.put("count", String.valueOf(count));
+        return param;
+    }
+
+    private synchronized void login(boolean force) {
+        long now = System.currentTimeMillis();
+        if (!force && accessToken != null && now < tokenExpireAt) {
+            return;
+        }
+        JSONObject body = new JSONObject();
+        body.put("username", properties.getApiUsername());
+        body.put("password", properties.getApiPassword());
+
+        String response = doPostJson(properties.getApiBaseUrl() + "/api/user/login", body.toJSONString(), null);
+        JSONObject root = parseResponse(response);
+        JSONObject data = root.getJSONObject("data");
+        if (data == null || data.getString("accessToken") == null) {
+            throw new BusinessException("国标视频平台登录失败,未返回 accessToken");
+        }
+        accessToken = data.getString("accessToken");
+        tokenExpireAt = now + 50 * 60 * 1000L;
+        log.info("国标视频平台 API 登录成功");
+    }
+
+    private JSONObject getJson(String path, Map<String, String> queryParam) {
+        String response = doGet(properties.getApiBaseUrl() + path, queryParam, accessToken);
+        JSONObject root = parseResponse(response);
+        return root.getJSONObject("data") != null ? root.getJSONObject("data") : new JSONObject();
+    }
+
+    private JSONObject parseResponse(String response) {
+        if (response == null || response.trim().isEmpty()) {
+            throw new BusinessException("国标视频平台 API 无响应");
+        }
+        JSONObject root = JSON.parseObject(response);
+        Integer code = root.getInteger("code");
+        if (code != null && code != 0) {
+            throw new BusinessException("国标视频平台 API 错误:" + root.getString("msg"));
+        }
+        return root;
+    }
+
+    private boolean testTcpPort(String host, int port) {
+        try (Socket socket = new Socket()) {
+            socket.connect(new InetSocketAddress(host, port), CONNECT_TIMEOUT_MS);
+            return true;
+        } catch (Exception e) {
+            log.warn("端口检测失败 {}:{} - {}", host, port, e.getMessage());
+            return false;
+        }
+    }
+
+    private String doGet(String url, Map<String, String> param, String token) {
+        CloseableHttpResponse response = null;
+        try (CloseableHttpClient client = createClient()) {
+            URIBuilder builder = new URIBuilder(url);
+            if (param != null) {
+                for (Map.Entry<String, String> entry : param.entrySet()) {
+                    builder.addParameter(entry.getKey(), entry.getValue());
+                }
+            }
+            URI uri = builder.build();
+            HttpGet httpGet = new HttpGet(uri);
+            if (token != null) {
+                httpGet.setHeader("access-token", token);
+            }
+            response = client.execute(httpGet);
+            return EntityUtils.toString(response.getEntity(), "UTF-8");
+        } catch (Exception e) {
+            throw new BusinessException("国标视频平台 GET 请求失败:" + e.getMessage());
+        } finally {
+            closeQuietly(response);
+        }
+    }
+
+    private String doPostJson(String url, String json, String token) {
+        CloseableHttpResponse response = null;
+        try (CloseableHttpClient client = createClient()) {
+            HttpPost httpPost = new HttpPost(url);
+            httpPost.setHeader("Content-Type", "application/json");
+            if (token != null) {
+                httpPost.setHeader("access-token", token);
+            }
+            httpPost.setEntity(new StringEntity(json, ContentType.APPLICATION_JSON));
+            response = client.execute(httpPost);
+            return EntityUtils.toString(response.getEntity(), "UTF-8");
+        } catch (Exception e) {
+            throw new BusinessException("国标视频平台 POST 请求失败:" + e.getMessage());
+        } finally {
+            closeQuietly(response);
+        }
+    }
+
+    private CloseableHttpClient createClient() {
+        RequestConfig config = RequestConfig.custom()
+                .setConnectTimeout(CONNECT_TIMEOUT_MS)
+                .setSocketTimeout(SOCKET_TIMEOUT_MS)
+                .setConnectionRequestTimeout(CONNECT_TIMEOUT_MS)
+                .build();
+        return HttpClients.custom().setDefaultRequestConfig(config).build();
+    }
+
+    private void closeQuietly(CloseableHttpResponse response) {
+        if (response != null) {
+            try {
+                response.close();
+            } catch (Exception ignored) {
+                // ignore
+            }
+        }
+    }
+}

+ 12 - 8
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/util/WeatherFetcher.java

@@ -1,5 +1,8 @@
 package com.usky.cdi.service.util;
 
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.*;
@@ -9,7 +12,6 @@ import java.net.HttpURLConnection;
 import java.net.URL;
 
 import lombok.extern.slf4j.Slf4j;
-import org.json.JSONObject;
 
 /**
  * 天气数据获取工具类
@@ -95,14 +97,14 @@ public class WeatherFetcher {
                 }
                 reader.close();
 
-                // 4. 解析JSON数据(使用org.json库)
-                JSONObject jsonResponse = new JSONObject(response.toString());
+                // 4. 解析JSON数据
+                JSONObject jsonResponse = JSON.parseObject(response.toString());
                 JSONObject main = jsonResponse.getJSONObject("main");
 
                 // 注意:温度默认是开尔文单位,转换为摄氏度需要 -273.15
                 double tempKelvin = main.getDouble("temp");
                 tempCelsius = tempKelvin - 273.15;
-                humidity = main.getInt("humidity");
+                humidity = main.getInteger("humidity");
                 double feelsLikeKelvin = main.getDouble("feels_like");
                 double feelsLikeCelsius = feelsLikeKelvin - 273.15;
 
@@ -113,14 +115,16 @@ public class WeatherFetcher {
                 // 记录日志
                 log.info("=== 天气解析结果 ===");
                 log.info("城市: {}", jsonResponse.getString("name"));
-                log.info("温度: {:.2f}°C (原始: {}K)", tempCelsius, tempKelvin);
-                log.info("体感温度: {:.2f}°C", feelsLikeCelsius);
+                log.info("温度: {}°C (原始: {}K)", String.format("%.2f", tempCelsius), tempKelvin);
+                log.info("体感温度: {}°C", String.format("%.2f", feelsLikeCelsius));
                 log.info("湿度: {}%", humidity);
                 log.info("天气状况: {}", description);
-                log.info("时区偏移: {}小时", (jsonResponse.getInt("timezone") / 3600));
+                log.info("时区偏移: {}小时", (jsonResponse.getIntValue("timezone") / 3600));
 
                 // 检查是否包含臭氧数据
-                if (jsonResponse.has("air_quality") || jsonResponse.has("o3") || jsonResponse.has("components")) {
+                if (jsonResponse.containsKey("air_quality")
+                        || jsonResponse.containsKey("o3")
+                        || jsonResponse.containsKey("components")) {
                     log.info("包含空气质量数据");
                 } else {
                     log.info("当前数据不包含臭氧浓度等空气质量指标");

+ 28 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoChannelVO.java

@@ -0,0 +1,28 @@
+package com.usky.cdi.service.vo.video;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * GB28181 视频通道信息
+ */
+@Data
+public class VideoChannelVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private String channelId;
+
+    private String channelName;
+
+    /**
+     * 在线状态:ON / OFF
+     */
+    private String status;
+
+    /**
+     * 预览流地址(可选)
+     */
+    private String playUrl;
+}

+ 29 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoDeviceItemVO.java

@@ -0,0 +1,29 @@
+package com.usky.cdi.service.vo.video;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * GB28181 视频设备信息
+ */
+@Data
+public class VideoDeviceItemVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private String deviceId;
+
+    private String deviceName;
+
+    /**
+     * 设备在线状态:ON / OFF
+     */
+    private String status;
+
+    private String manufacturer;
+
+    private List<VideoChannelVO> channels = new ArrayList<>();
+}

+ 35 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoDeviceSyncPacketVO.java

@@ -0,0 +1,35 @@
+package com.usky.cdi.service.vo.video;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 视频设备同步 MQTT 报文
+ * Topic: base/videoDevice
+ */
+@Data
+public class VideoDeviceSyncPacketVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long dataPacketID;
+
+    private Long engineeringID;
+
+    private String publishTime;
+
+    /**
+     * 国标平台 SIP ID
+     */
+    private String platformSipId;
+
+    /**
+     * 国标平台 SIP 域
+     */
+    private String platformSipDomain;
+
+    private List<VideoDeviceItemVO> devices = new ArrayList<>();
+}

+ 56 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoPlayInfoVO.java

@@ -0,0 +1,56 @@
+package com.usky.cdi.service.vo.video;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * WVP 点播返回的流信息
+ */
+@Data
+public class VideoPlayInfoVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private String deviceId;
+
+    private String channelId;
+
+    private String app;
+
+    private String stream;
+
+    private String flvUrl;
+
+    private String hlsUrl;
+
+    private String rtspUrl;
+
+    private String rtcUrl;
+
+    private String wsFlvUrl;
+
+    /**
+     * 流状态:PUSHING / STOPPED / FAILED
+     */
+    private String streamStatus;
+
+    public String getPreferredPlayUrl(String protocol) {
+        if ("hls".equalsIgnoreCase(protocol) && hlsUrl != null) {
+            return hlsUrl;
+        }
+        if ("rtsp".equalsIgnoreCase(protocol) && rtspUrl != null) {
+            return rtspUrl;
+        }
+        if (flvUrl != null) {
+            return flvUrl;
+        }
+        if (hlsUrl != null) {
+            return hlsUrl;
+        }
+        if (rtspUrl != null) {
+            return rtspUrl;
+        }
+        return rtcUrl;
+    }
+}

+ 35 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoStreamItemVO.java

@@ -0,0 +1,35 @@
+package com.usky.cdi.service.vo.video;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 单路视频流 MQTT 上报项
+ */
+@Data
+public class VideoStreamItemVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private String deviceId;
+
+    private String deviceName;
+
+    private String channelId;
+
+    private String channelName;
+
+    /**
+     * 流状态:PUSHING / STOPPED / FAILED
+     */
+    private String streamStatus;
+
+    private String playUrl;
+
+    private String flvUrl;
+
+    private String hlsUrl;
+
+    private String rtspUrl;
+}

+ 27 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoStreamSyncPacketVO.java

@@ -0,0 +1,27 @@
+package com.usky.cdi.service.vo.video;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 视频流推送 MQTT 报文
+ * Topic: iotInfo/videoStream
+ */
+@Data
+public class VideoStreamSyncPacketVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long dataPacketID;
+
+    private Long engineeringID;
+
+    private String publishTime;
+
+    private String mediaServerIp;
+
+    private List<VideoStreamItemVO> streams = new ArrayList<>();
+}

+ 20 - 0
service-cdi/service-cdi-biz/src/main/resources/application-gb28181-video.yml

@@ -0,0 +1,20 @@
+# GB28181 视频 / SIP 推流(海康摄像机模式)
+gb28181:
+  video:
+    enabled: false
+
+# 与海康 Web「GB28181」页签配置一致(设备 ID 通过 API 注册时动态传入)
+sip:
+  client:
+    enabled: true
+    auto-start: false
+    server-host: 192.168.20.166
+    server-port: 15060
+    domain: 3402000000
+    platform-id: 34020000002000000001
+    password: jkjj_wlgz
+    sip-transport: tcp
+    register-expires: 3600
+    invite-wait-seconds: 300
+    default-duration-seconds: 60
+    rtsp-transport: tcp

+ 2 - 0
service-cdi/service-cdi-biz/src/main/resources/bootstrap.yml

@@ -10,6 +10,8 @@ spring:
   profiles:
     # 环境配置
     active: dev
+    # 加载 GB28181 视频对接配置(application-gb28181-video.yml)
+    include: gb28181-video
   cloud:
     nacos:
       discovery:

+ 4 - 0
service-iot/service-iot-api/src/main/java/com/usky/iot/RemoteIotTaskService.java

@@ -24,6 +24,10 @@ public interface RemoteIotTaskService {
     @GetMapping("/addDeviceInfo")
     void addDeviceInfo(@RequestParam("productCode") String productCode, @RequestParam(value = "deviceUuid") String deviceUuid, @RequestParam(value = "deviceId") String deviceId, @RequestParam(value = "deviceName") String deviceName, @RequestParam(value = "installAddress") String installAddress, @RequestParam(value = "serviceStatus") Integer serviceStatus);
 
+
+    @GetMapping("/addSubsystemDeviceInfo")
+    void addSubsystemDeviceInfo(@RequestParam(value = "deviceUuid") String deviceUuid, @RequestParam(value = "deviceId") String deviceId, @RequestParam(value = "tenantId") Integer tenantId);
+
     @GetMapping("/deleteDeviceInfo")
     void deleteDeviceInfo(@RequestParam(value = "deviceUuid") String deviceUuid);
 }

+ 5 - 0
service-iot/service-iot-api/src/main/java/com/usky/iot/factory/RemoteIotTaskFactory.java

@@ -49,6 +49,11 @@ public class RemoteIotTaskFactory implements FallbackFactory<RemoteIotTaskServic
                 throw new FeignBadRequestException(500,"新增设备信息异常"+throwable.getMessage());
             }
 
+            @Override
+            public void addSubsystemDeviceInfo(String deviceUuid, String deviceId, Integer tenantId) {
+                throw new FeignBadRequestException(500,"新增子系统设备信息异常"+throwable.getMessage());
+            }
+
             @Override
             public void deleteDeviceInfo(String deviceUuid) {
                 throw new FeignBadRequestException(500,"删除设备信息异常"+throwable.getMessage());

+ 14 - 0
service-iot/service-iot-biz/src/main/java/com/usky/iot/controller/api/ServiceIotTaskApi.java

@@ -61,6 +61,20 @@ public class ServiceIotTaskApi implements RemoteIotTaskService {
         dmpDeviceInfoService.add(dmpDeviceInfo);
     }
 
+    @Override
+    public void addSubsystemDeviceInfo(String deviceUuid, String deviceId, Integer tenantId){
+        DmpDeviceInfo dmpDeviceInfo = new DmpDeviceInfo();
+        dmpDeviceInfo.setProductCode("");
+        dmpDeviceInfo.setDeviceUuid(deviceUuid);
+        dmpDeviceInfo.setDeviceId(deviceId);
+        dmpDeviceInfo.setDeviceName("");
+        dmpDeviceInfo.setInstallAddress("");
+        dmpDeviceInfo.setServiceStatus(1);
+        dmpDeviceInfo.setTenantId(tenantId);
+
+        dmpDeviceInfoService.add(dmpDeviceInfo);
+    }
+
     @Override
     public void deleteDeviceInfo(String deviceUuid){
         LambdaQueryWrapper<DmpDeviceInfo> deviceQueryWrapper = Wrappers.lambdaQuery();

+ 9 - 1
service-iot/service-iot-biz/src/main/java/com/usky/iot/service/impl/DmpDeviceInfoServiceImpl.java

@@ -546,7 +546,10 @@ public class DmpDeviceInfoServiceImpl extends AbstractCrudService<DmpDeviceInfoM
             dmpDeviceInfo.setProductId(list.get(0).getId());
             dmpDeviceInfo.setCreatedBy(SecurityUtils.getUsername());
             dmpDeviceInfo.setCreatedTime(LocalDateTime.now());
-            dmpDeviceInfo.setTenantId(SecurityUtils.getTenantId());
+            if(dmpDeviceInfo.getTenantId() == null) {
+                dmpDeviceInfo.setTenantId(SecurityUtils.getTenantId());
+            }
+
             dmpDeviceInfo.setServiceStatus(1);
             if (StringUtils.isBlank(dmpDeviceInfo.getDeviceUuid())) {
                 dmpDeviceInfo.setDeviceUuid(UUIDUtils.uuid().substring(0, 16));
@@ -577,6 +580,11 @@ public class DmpDeviceInfoServiceImpl extends AbstractCrudService<DmpDeviceInfoM
         }
         this.updateById(dmpDeviceInfo);
 
+        LambdaUpdateWrapper<DmpDeviceStatus> updateWrapper = Wrappers.lambdaUpdate();
+        updateWrapper.set(DmpDeviceStatus::getProductId,dmpDeviceInfo.getProductId())
+                .set(DmpDeviceStatus::getProductCode,dmpDeviceInfo.getProductCode())
+                .eq(DmpDeviceStatus::getDeviceId, dmpDeviceInfo.getDeviceId());
+
     }
 
     @Override

+ 65 - 0
service-vpp/README.md

@@ -0,0 +1,65 @@
+# 虚拟电厂运营管理平台
+
+## 模块信息
+
+| 项目 | 内容 |
+|------|------|
+| 所属系统(模块) | 虚拟电厂运营管理平台 |
+| 分区 | vpp |
+| 英文全称 | Virtual Power Plant |
+| 描述 | 分布式能源聚合管理、运行监控、需求响应、结算报送 |
+| 模块分类 | 应用模块 |
+| 工程模块 | service-vpp |
+
+## 功能范围
+
+依据《虚拟电厂平台详细设计文档 V1.0》,涵盖 13 个一级业务模块:
+
+- 客户管理 / 合同管理 / 资源管理
+- 运行监控 / 告警管理(复用 `base_*`、`rule_*`)
+- 需求响应 / 结算管理 / 信息报送
+- 统计报表 / 数据分析 / 系统管理 / 移动端
+
+## 技术栈
+
+- Java + Spring Boot + MyBatis-Plus
+- MySQL 8.0(`vpp_*` 业务表 + 复用 `sys_*`/`dmp_*`/`base_*`/`rule_*`)
+- TDengine(设备运行曲线)
+- Redis(缓存)
+- 国密 SM2/SM3(运管平台 UN/DN 对接)
+
+## 模块结构
+
+```
+service-vpp/
+├── service-vpp-api/         # Feign API 模块
+├── service-vpp-biz/         # 业务逻辑与启动模块
+│   ├── controller/          # REST 接口(网关前缀 /prod-api/service-vpp)
+│   ├── service/             # 业务服务
+│   ├── domain/              # 实体(28 张 vpp_* 表)
+│   ├── mapper/              # MyBatis Mapper
+│   └── resources/sql/       # DDL 脚本 vpp_schema.sql
+└── pom.xml
+```
+
+## 接口规范
+
+- 网关路径:`/prod-api/service-vpp/{module}/...`
+- 响应格式:`ApiResult`(status/code/msg/data)
+- 分页参数:`current`、`size`
+- 运管平台 DN 端点:`/TokenRequest`、`/Poll` 等(见接口文档第 3 章)
+
+## 启动
+
+```bash
+cd service-vpp/service-vpp-biz
+mvn spring-boot:run
+```
+
+默认端口:`9906`
+
+## 数据库初始化
+
+执行 `service-vpp-biz/src/main/resources/sql/vpp_schema.sql` 创建 VPP 专属表。
+
+平台公共表(用户、设备、告警、规则引擎等)沿用 `usky-cloud.sql`,无需重复创建。

+ 19 - 0
service-vpp/pom.xml

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>usky-modules</artifactId>
+        <groupId>com.usky</groupId>
+        <version>0.0.1</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>service-vpp</artifactId>
+    <packaging>pom</packaging>
+    <version>0.0.1</version>
+
+    <modules>
+        <module>service-vpp-biz</module>
+        <module>service-vpp-api</module>
+    </modules>
+</project>

+ 24 - 0
service-vpp/service-vpp-api/pom.xml

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>service-vpp</artifactId>
+        <groupId>com.usky</groupId>
+        <version>0.0.1</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>service-vpp-api</artifactId>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.cloud</groupId>
+            <artifactId>spring-cloud-starter-openfeign</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.usky</groupId>
+            <artifactId>usky-common-core</artifactId>
+        </dependency>
+    </dependencies>
+
+</project>

+ 114 - 0
service-vpp/service-vpp-biz/pom.xml

@@ -0,0 +1,114 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>service-vpp</artifactId>
+        <groupId>com.usky</groupId>
+        <version>0.0.1</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>service-vpp-biz</artifactId>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.usky</groupId>
+            <artifactId>common-cloud-starter</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.usky</groupId>
+            <artifactId>service-vpp-api</artifactId>
+            <version>0.0.1</version>
+        </dependency>
+
+        <dependency>
+            <groupId>com.usky</groupId>
+            <artifactId>service-iot-api</artifactId>
+            <version>0.0.1</version>
+        </dependency>
+
+        <dependency>
+            <groupId>com.usky</groupId>
+            <artifactId>service-tsdb-api</artifactId>
+            <version>0.0.1</version>
+        </dependency>
+
+        <dependency>
+            <groupId>com.usky</groupId>
+            <artifactId>service-alarm-api</artifactId>
+            <version>0.0.1</version>
+        </dependency>
+
+        <dependency>
+            <groupId>com.usky</groupId>
+            <artifactId>service-rule-api</artifactId>
+            <version>0.0.1</version>
+        </dependency>
+
+        <dependency>
+            <groupId>com.usky</groupId>
+            <artifactId>ruoyi-common-core</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.usky</groupId>
+            <artifactId>ruoyi-common-swagger</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.usky</groupId>
+            <artifactId>usky-common-mybatis</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>mysql</groupId>
+            <artifactId>mysql-connector-java</artifactId>
+            <scope>runtime</scope>
+        </dependency>
+
+        <!-- 国密 SM2/SM3(运管平台 UN/DN 对接) -->
+        <dependency>
+            <groupId>org.bouncycastle</groupId>
+            <artifactId>bcprov-jdk15on</artifactId>
+            <version>1.70</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.hutool</groupId>
+            <artifactId>hutool-crypto</artifactId>
+            <version>5.8.41</version>
+        </dependency>
+
+        <!-- TDengine JDBC(设备运行曲线) -->
+        <dependency>
+            <groupId>com.taosdata.jdbc</groupId>
+            <artifactId>taos-jdbcdriver</artifactId>
+            <version>3.6.3</version>
+        </dependency>
+
+        <dependency>
+            <groupId>com.usky</groupId>
+            <artifactId>service-file</artifactId>
+            <version>0.0.1</version>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <finalName>${project.artifactId}</finalName>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.2.6.RELEASE</version>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+
+</project>

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

@@ -0,0 +1,47 @@
+package com.usky.vpp;
+
+import com.ruoyi.common.swagger.annotation.EnableCustomSwagger2;
+import org.mybatis.spring.annotation.MapperScan;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.cloud.openfeign.EnableFeignClients;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.core.env.Environment;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+
+/**
+ * 虚拟电厂运营管理平台服务
+ *
+ * @author usky
+ */
+@EnableCustomSwagger2
+@EnableFeignClients(basePackages = "com.usky")
+@MapperScan({"com.usky.vpp.mapper","com.ruoyi.file.mapper"})
+@ComponentScan("com.usky")
+@SpringBootApplication
+@EnableScheduling
+public class VppApplication {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(VppApplication.class);
+
+    public static void main(String[] args) throws UnknownHostException {
+        ConfigurableApplicationContext application = SpringApplication.run(VppApplication.class, args);
+        Environment env = application.getEnvironment();
+        String ip = InetAddress.getLocalHost().getHostAddress();
+        String port = env.getProperty("server.port");
+        String path = env.getProperty("server.servlet.context-path");
+        LOGGER.info("\n----------------------------------------------------------\n\t" +
+                "Application is running! Access URLs:\n\t" +
+                "Local: \t\thttp://localhost:" + port + (null == path ? "" : path) + "/\n\t" +
+                "External: \thttp://" + ip + ":" + port + (null == path ? "" : path) + "/\n\t" +
+                "Api: \t\thttp://" + ip + ":" + port + (null == path ? "" : path) + "/swagger-ui/index.html\n\t" +
+                "Gateway: \t/prod-api/service-vpp\n\t" +
+                "----------------------------------------------------------");
+    }
+}

+ 45 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/client/VppUnClient.java

@@ -0,0 +1,45 @@
+package com.usky.vpp.client;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.usky.common.core.exception.BusinessException;
+import com.usky.vpp.util.VppUnPayloadHelper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * UN 平台 JSON 客户端
+ */
+@Component
+public class VppUnClient {
+
+    @Autowired
+    private VppUnHttpExecutor httpExecutor;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    public Map<String, Object> post(String serviceName, Map<String, Object> body, boolean withToken) {
+        String raw = httpExecutor.postRaw(serviceName, body, withToken);
+        try {
+            Map<String, Object> response = objectMapper.readValue(raw, new TypeReference<Map<String, Object>>() {
+            });
+            assertSuccess(serviceName, response);
+            return response;
+        } catch (BusinessException ex) {
+            throw ex;
+        } catch (Exception ex) {
+            throw new BusinessException("UN 响应解析失败: " + serviceName);
+        }
+    }
+
+    private void assertSuccess(String serviceName, Map<String, Object> response) {
+        Integer code = VppUnPayloadHelper.getInteger(response, "code");
+        if (code != null && code >= 400) {
+            Object description = response.get("description");
+            throw new BusinessException("运管平台 " + serviceName + " 返回错误: " + description);
+        }
+    }
+}

+ 137 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/client/VppUnHttpExecutor.java

@@ -0,0 +1,137 @@
+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.crypto.VppUnCryptoService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.client.HttpStatusCodeException;
+import org.springframework.web.client.ResourceAccessException;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Map;
+
+/**
+ * 向 UN 发送 HTTP 请求(可选国密加密 / Token 认证)
+ */
+@Component
+public class VppUnHttpExecutor {
+
+    private static final Logger log = LoggerFactory.getLogger(VppUnHttpExecutor.class);
+
+    @Autowired
+    private VppUnProperties properties;
+
+    @Autowired
+    private VppUnCryptoService cryptoService;
+
+    @Autowired
+    @Qualifier("vppUnRestTemplate")
+    private RestTemplate restTemplate;
+
+    @Lazy
+    @Autowired
+    private VppUnTokenHolder tokenHolder;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    public String postRaw(String serviceName, Map<String, Object> body, boolean withToken) {
+        try {
+            return executePost(serviceName, body, withToken);
+        } catch (BusinessException ex) {
+            if (withToken && isTokenError(ex)) {
+                log.info("Token 失效,刷新后重试 {}", serviceName);
+                tokenHolder.invalidate();
+                tokenHolder.getToken();
+                return executePost(serviceName, body, withToken);
+            }
+            throw ex;
+        }
+    }
+
+    private String executePost(String serviceName, Map<String, Object> body, boolean withToken) {
+        if (!properties.isOutboundActive()) {
+            throw new BusinessException("运管平台 outbound 未启用或未配置 baseUrl");
+        }
+        try {
+            String plainJson = objectMapper.writeValueAsString(body);
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            headers.setAccept(java.util.Collections.singletonList(MediaType.APPLICATION_JSON));
+
+            String requestBody = plainJson;
+            if (cryptoService.isActive()) {
+                requestBody = cryptoService.encryptRequest(plainJson);
+                headers.set("X-Sign", cryptoService.signRequest(requestBody));
+            }
+            if (withToken) {
+                headers.set(properties.getTokenHeader(), properties.getTokenPrefix() + tokenHolder.getToken());
+            }
+
+            String url = normalizeUrl(properties.getBaseUrl()) + "/" + serviceName;
+            log.debug("UN 请求 {} crypto={} token={}", url, cryptoService.isActive(), withToken);
+
+            ResponseEntity<String> response = restTemplate.exchange(
+                    url, HttpMethod.POST, new HttpEntity<>(requestBody, headers), String.class);
+            return decodeResponseBody(response.getBody(), response.getHeaders().getFirst("X-Sign"));
+        } catch (HttpStatusCodeException ex) {
+            String message = ex.getResponseBodyAsString();
+            log.warn("UN 请求 {} 失败 status={} body={}", serviceName, ex.getRawStatusCode(), message);
+            if (withToken && isTokenErrorMessage(message)) {
+                tokenHolder.invalidate();
+            }
+            throw new BusinessException("运管平台请求失败: " + serviceName + " " + ex.getRawStatusCode()
+                    + (StringUtils.hasText(message) ? " " + message : ""));
+        } catch (ResourceAccessException ex) {
+            throw new BusinessException("运管平台连接失败: " + ex.getMessage());
+        } catch (BusinessException ex) {
+            throw ex;
+        } catch (Exception ex) {
+            throw new BusinessException("运管平台请求异常: " + ex.getMessage());
+        }
+    }
+
+    private boolean isTokenError(BusinessException ex) {
+        return isTokenErrorMessage(ex.getMessage());
+    }
+
+    private boolean isTokenErrorMessage(String message) {
+        return message != null && (message.contains("TokenExpiredException")
+                || message.contains("JWTVerificationException")
+                || message.contains("NoToken")
+                || message.contains("JWTDecodeException"));
+    }
+
+    private String decodeResponseBody(String body, String signHeader) {
+        if (!StringUtils.hasText(body)) {
+            return "{}";
+        }
+        String trimmed = body.trim();
+        if (cryptoService.isActive() && !trimmed.startsWith("{")) {
+            if (!cryptoService.verifyResponse(trimmed, signHeader)) {
+                throw new BusinessException("UN 响应验签失败");
+            }
+            return cryptoService.decryptResponse(trimmed);
+        }
+        return trimmed;
+    }
+
+    private static String normalizeUrl(String baseUrl) {
+        if (baseUrl.endsWith("/")) {
+            return baseUrl.substring(0, baseUrl.length() - 1);
+        }
+        return baseUrl;
+    }
+}

+ 107 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/client/VppUnTokenHolder.java

@@ -0,0 +1,107 @@
+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.util.VppUnMessageBuilder;
+import com.usky.vpp.util.VppUnPayloadHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * UN Token 缓存(TokenRequest 获取,默认约 30 分钟过期)
+ */
+@Component
+public class VppUnTokenHolder {
+
+    private static final Logger log = LoggerFactory.getLogger(VppUnTokenHolder.class);
+
+    private final ReentrantLock lock = new ReentrantLock();
+
+    @Autowired
+    private VppUnProperties properties;
+
+    @Autowired
+    private VppUnHttpExecutor httpExecutor;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    private volatile String token;
+    private volatile Instant expiresAt = Instant.EPOCH;
+
+    public String getToken() {
+        if (!needsRefresh()) {
+            return token;
+        }
+        lock.lock();
+        try {
+            if (!needsRefresh()) {
+                return token;
+            }
+            refreshTokenInternal();
+            return token;
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    public void invalidate() {
+        lock.lock();
+        try {
+            token = null;
+            expiresAt = Instant.EPOCH;
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    public void applyTokenResponse(Map<String, Object> response) {
+        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();
+        }
+    }
+
+    private boolean needsRefresh() {
+        return !StringUtils.hasText(token) || Instant.now().isAfter(expiresAt);
+    }
+
+    private void refreshTokenInternal() {
+        Map<String, Object> request = VppUnMessageBuilder.buildTokenRequest(properties);
+        String responseBody = httpExecutor.postRaw("TokenRequest", request, false);
+        try {
+            Map<String, Object> response = objectMapper.readValue(responseBody, new TypeReference<Map<String, Object>>() {
+            });
+            Integer code = VppUnPayloadHelper.getInteger(response, "code");
+            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 已刷新");
+        } catch (IllegalStateException ex) {
+            throw ex;
+        } catch (Exception ex) {
+            throw new IllegalStateException("TokenResponse 解析失败", ex);
+        }
+    }
+}

+ 82 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/config/VppUnProperties.java

@@ -0,0 +1,82 @@
+package com.usky.vpp.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 运管平台 UN/DN 对接配置
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "vpp.un")
+public class VppUnProperties {
+
+    /** 运管平台 UN 基地址 */
+    private String baseUrl;
+
+    /** 虚拟电厂运营商 DN ID */
+    private String dnId;
+
+    /** DN 名称 */
+    private String dnName;
+
+    /** DN 对外服务地址(供 UN 回调) */
+    private String transportAddress;
+
+    /** UN 公钥 Base64 */
+    private String unPublicKey;
+
+    /** DN 公钥 Base64 */
+    private String dnPublicKey;
+
+    /** DN 私钥 Base64 */
+    private String dnPrivateKey;
+
+    /** Poll 轮询间隔秒,默认 10 */
+    private Integer pollIntervalSec = 10;
+
+    /** 是否启用 DN 主动调用 UN(Poll/申报/出清等) */
+    private Boolean outboundEnabled = false;
+
+    /** 是否启用 Poll 定时任务 */
+    private Boolean pollEnabled = false;
+
+    /** 是否启用 SM2/SM3 加解密(未配置密钥时自动降级为明文) */
+    private Boolean cryptoEnabled = false;
+
+    /** 申报价格下调系数,默认 0.8 */
+    private String priceDownCoeff = "0.8";
+
+    /** 已保存的 registrationID(首次注册后需持久化) */
+    private String registrationId;
+
+    /** Token 请求头,默认 Authorization */
+    private String tokenHeader = "Authorization";
+
+    /** Token 前缀,默认 Bearer  */
+    private String tokenPrefix = "Bearer ";
+
+    /** Token 有效期分钟(文档默认 30),用于本地缓存刷新 */
+    private Integer tokenTtlMinutes = 25;
+
+    /** 收到出清公示后是否自动向 UN 发送 CreateEventResponse */
+    private Boolean autoAckClearing = false;
+
+    /** 启动时自动注册 UN(需 outbound-enabled=true) */
+    private Boolean autoRegisterOnStartup = false;
+
+    /** HTTP 连接超时毫秒 */
+    private Integer connectTimeoutMs = 10000;
+
+    /** HTTP 读超时毫秒 */
+    private Integer readTimeoutMs = 30000;
+
+    public boolean isOutboundActive() {
+        return Boolean.TRUE.equals(outboundEnabled) && baseUrl != null && !baseUrl.trim().isEmpty();
+    }
+
+    public boolean isPollActive() {
+        return Boolean.TRUE.equals(pollEnabled) && isOutboundActive();
+    }
+}

+ 22 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/config/VppUnRestTemplateConfig.java

@@ -0,0 +1,22 @@
+package com.usky.vpp.config;
+
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.client.RestTemplate;
+
+import java.time.Duration;
+
+@Configuration
+public class VppUnRestTemplateConfig {
+
+    @Bean
+    public RestTemplate vppUnRestTemplate(VppUnProperties properties, RestTemplateBuilder builder) {
+        int connectMs = properties.getConnectTimeoutMs() != null ? properties.getConnectTimeoutMs() : 10000;
+        int readMs = properties.getReadTimeoutMs() != null ? properties.getReadTimeoutMs() : 30000;
+        return builder
+                .setConnectTimeout(Duration.ofMillis(connectMs))
+                .setReadTimeout(Duration.ofMillis(readMs))
+                .build();
+    }
+}

+ 68 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/un/UnDnController.java

@@ -0,0 +1,68 @@
+package com.usky.vpp.controller.un;
+
+import com.usky.vpp.service.VppUnDnService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/**
+ * 运管平台 DN 侧被动接口(UN 调用 DN)
+ * 路径与服务名一致,如 /TokenRequest、/Poll
+ */
+@RestController
+public class UnDnController {
+
+    @Autowired
+    private VppUnDnService vppUnDnService;
+
+    @PostMapping("/TokenRequest")
+    public Map<String, Object> tokenRequest(@RequestBody(required = false) Map<String, Object> body) {
+        return vppUnDnService.tokenRequest(body);
+    }
+
+    @PostMapping("/CreateRegistrationRequest")
+    public Map<String, Object> createRegistration(@RequestBody(required = false) Map<String, Object> body) {
+        return vppUnDnService.createRegistration(body);
+    }
+
+    @PostMapping("/RegisterReportRequest")
+    public Map<String, Object> registerReport(@RequestBody(required = false) Map<String, Object> body) {
+        return vppUnDnService.registerReport(body);
+    }
+
+    @PostMapping("/MomentDataReportRequest")
+    public Map<String, Object> momentDataReport(@RequestBody(required = false) Map<String, Object> body) {
+        return vppUnDnService.momentDataReport(body);
+    }
+
+    @PostMapping("/IntervalDataReportRequest")
+    public Map<String, Object> intervalDataReport(@RequestBody(required = false) Map<String, Object> body) {
+        return vppUnDnService.intervalDataReport(body);
+    }
+
+    @PostMapping("/Poll")
+    public Map<String, Object> poll(@RequestBody(required = false) Map<String, Object> body) {
+        return vppUnDnService.poll(body);
+    }
+
+    @PostMapping("/DistributeEventRequest")
+    public Map<String, Object> distributeEvent(@RequestBody(required = false) Map<String, Object> body) {
+        return vppUnDnService.distributeEvent(body);
+    }
+
+    @PostMapping("/CreateOptRequest")
+    public Map<String, Object> createOpt(@RequestBody(required = false) Map<String, Object> body) {
+        return vppUnDnService.createOpt(body);
+    }
+
+    @PostMapping("/CreateCqRequest")
+    public Map<String, Object> createCq(@RequestBody(required = false) Map<String, Object> body) {
+        return vppUnDnService.createCq(body);
+    }
+
+    @PostMapping("/CreateEventResponse")
+    public Map<String, Object> createEventResponse(@RequestBody(required = false) Map<String, Object> body) {
+        return vppUnDnService.createEventResponse(body);
+    }
+}

+ 59 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/AlarmController.java

@@ -0,0 +1,59 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.vpp.service.VppAlarmService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 虚拟电厂 - Alarm 接口
+ * 网关前缀: /prod-api/service-vpp
+ */
+@RestController
+@RequestMapping("/alarm")
+public class AlarmController {
+
+    @Autowired
+    private VppAlarmService vppAlarmService;
+
+    @GetMapping(value = "/rule")
+    public ApiResult<Object> pageRule(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @PostMapping(value = "/rule")
+    public ApiResult<Object> createRule(@RequestBody(required = false) Object body) {
+        return ApiResult.success(null);
+    }
+    @PutMapping(value = "/rule/{id}")
+    public ApiResult<Void> updateRule(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
+        return ApiResult.success();
+    }
+    @DeleteMapping(value = "/rule/{id}")
+    public ApiResult<Void> deleteRule(@PathVariable("id") Long id) {
+        return ApiResult.success();
+    }
+    @GetMapping(value = "/type")
+    public ApiResult<Object> listType(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @GetMapping
+    public ApiResult<Object> pageAlarm() {
+        return ApiResult.success(null);
+    }
+    @PutMapping(value = "/{id}/handle")
+    public ApiResult<Void> handleAlarm(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
+        return ApiResult.success();
+    }
+    @PutMapping(value = "/{id}/false-alarm")
+    public ApiResult<Void> falseAlarm(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
+        return ApiResult.success();
+    }
+    @GetMapping(value = "/notice")
+    public ApiResult<Object> listNotice(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/statistic")
+    public ApiResult<Object> statistic(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+}

+ 51 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/AnalyticsController.java

@@ -0,0 +1,51 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.vpp.service.VppAnalyticsService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 虚拟电厂 - Analytics 接口
+ * 网关前缀: /prod-api/service-vpp
+ */
+@RestController
+@RequestMapping("/analytics")
+public class AnalyticsController {
+
+    @Autowired
+    private VppAnalyticsService vppAnalyticsService;
+
+    @GetMapping(value = "/energy")
+    public ApiResult<Object> energyReport(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/monthly")
+    public ApiResult<Object> monthlyReport(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/settlement")
+    public ApiResult<Object> settlementReport(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/dr-settlement")
+    public ApiResult<Object> drSettlementReport(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/total-energy")
+    public ApiResult<Object> totalEnergy(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/time-of-use")
+    public ApiResult<Object> timeOfUse(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/compare")
+    public ApiResult<Object> compare(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @PostMapping(value = "/export")
+    public ApiResult<Object> export(@RequestBody(required = false) Object body) {
+        return ApiResult.success(null);
+    }
+}

+ 83 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ArchiveController.java

@@ -0,0 +1,83 @@
+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.VppArchiveService;
+import com.usky.vpp.service.vo.VppFileArchiveRequestVO;
+import com.usky.vpp.service.vo.VppFileArchiveResponseVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletResponse;
+import java.util.List;
+
+/**
+ * 虚拟电厂 - Archive 接口
+ * 网关前缀: /prod-api/service-vpp
+ */
+@RestController
+@RequestMapping("/archive")
+public class ArchiveController {
+
+    @Autowired
+    private VppArchiveService vppArchiveService;
+
+    /**
+     * 创建电子档案
+     */
+    @PostMapping
+    public ApiResult<Boolean> create(@RequestBody VppFileArchiveRequestVO vo) {
+        return vppArchiveService.create(vo) ? ApiResult.success(true) : ApiResult.error("创建电子档案失败!请重试!");
+    }
+
+    /**
+     * 查询电子档案
+     * archiveName 档案名称 模糊匹配
+     * archiveType 档案类型 1图纸 2文档 3设计稿 4验收报告 5其他
+     * siteId 站点id
+     * bizType 业务类型
+     * bizId 业务主键
+     * pageNum 页码
+     * pageSize 页大小
+     */
+    @GetMapping
+    public ApiResult<CommonPage<VppFileArchiveResponseVO>> page(@RequestParam(value = "archiveName", required = false) String archiveName,
+                                                                @RequestParam(value = "archiveType", required = false) Integer archiveType,
+                                                                @RequestParam(value = "siteId", required = false) Long siteId,
+                                                                @RequestParam(value = "bizType", required = false) String bizType,
+                                                                @RequestParam(value = "bizId", required = false) Long bizId,
+                                                                @RequestParam(value = "pageNum", required = false, defaultValue = "1") Integer pageNum,
+                                                                @RequestParam(value = "pageSize", required = false, defaultValue = "20") Integer pageSize) {
+        return ApiResult.success(vppArchiveService.page(archiveName, archiveType, siteId, bizType, bizId, pageNum, pageSize));
+    }
+
+    /**
+     * 更新电子档案
+     * version 字段自增,每次更新+0.1
+     */
+    @PutMapping
+    public ApiResult<Boolean> upload(@RequestBody VppFileArchiveRequestVO vo) {
+        return vppArchiveService.upload(vo) ? ApiResult.success(true) : ApiResult.error("更新电子档案失败!请重试!");
+    }
+
+    /**
+     * 删除电子档案
+     */
+    @DeleteMapping(value = "/{id}")
+    public ApiResult<Boolean> delete(@PathVariable("id") Long id) {
+        return vppArchiveService.delete(id) ? ApiResult.success(true) : ApiResult.error("删除电子档案失败!请重试!");
+    }
+
+    /**
+     * 批量导出电子档案
+     * 根据主键ID数组查询档案,下载文件并打包为ZIP返回
+     * 请求示例: GET /archive/export?ids=1&ids=2&ids=3
+     *
+     * @param ids 主键ID列表
+     */
+    @GetMapping("/export")
+    public void export(@RequestParam("ids") List<Long> ids, HttpServletResponse response) {
+        vppArchiveService.export(ids, response);
+    }
+}

+ 47 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ContractController.java

@@ -0,0 +1,47 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.vpp.service.VppContractService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 虚拟电厂 - Contract 接口
+ * 网关前缀: /prod-api/service-vpp
+ */
+@RestController
+@RequestMapping("/contract")
+public class ContractController {
+
+    @Autowired
+    private VppContractService vppContractService;
+
+    @GetMapping(value = "/template")
+    public ApiResult<Object> listTemplate(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @PostMapping
+    public ApiResult<Object> createContract() {
+        return ApiResult.success(null);
+    }
+    @GetMapping
+    public ApiResult<Object> pageContract() {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/{id}")
+    public ApiResult<Object> getContract(@PathVariable("id") Long id, @RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @PostMapping(value = "/{id}/submit")
+    public ApiResult<Void> submitContract(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
+        return ApiResult.success();
+    }
+    @PutMapping(value = "/{id}/audit")
+    public ApiResult<Void> auditContract(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
+        return ApiResult.success();
+    }
+    @PostMapping(value = "/{id}/archive")
+    public ApiResult<Void> archiveContract(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
+        return ApiResult.success();
+    }
+}

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

@@ -0,0 +1,84 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.domain.VppCustomer;
+import com.usky.vpp.domain.VppCustomerAccess;
+import com.usky.vpp.domain.VppCustomerContact;
+import com.usky.vpp.service.VppCustomerService;
+import com.usky.vpp.service.vo.CustomerAccessAuditRequest;
+import com.usky.vpp.service.vo.CustomerAccessRequest;
+import com.usky.vpp.service.vo.CustomerContactRequest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/**
+ * 客户管理接口
+ * 网关前缀: /prod-api/service-vpp/customer
+ */
+@RestController
+@RequestMapping("/customer")
+public class CustomerController {
+
+    @Autowired
+    private VppCustomerService vppCustomerService;
+
+    @PostMapping("/access")
+    public ApiResult<VppCustomerAccess> submitAccess(@RequestBody CustomerAccessRequest body) {
+        return ApiResult.success(vppCustomerService.submitAccess(body));
+    }
+
+    @GetMapping("/access")
+    public ApiResult<CommonPage<VppCustomerAccess>> pageAccess(@RequestParam(required = false) Map<String, Object> params) {
+        return ApiResult.success(vppCustomerService.pageAccess(params));
+    }
+
+    @PutMapping("/access/{id}/audit")
+    public ApiResult<Void> auditAccess(@PathVariable("id") Long id, @RequestBody CustomerAccessAuditRequest body) {
+        vppCustomerService.auditAccess(id, body);
+        return ApiResult.success();
+    }
+
+    @GetMapping
+    public ApiResult<CommonPage<VppCustomer>> pageCustomer(@RequestParam(required = false) Map<String, Object> params) {
+        return ApiResult.success(vppCustomerService.pageCustomer(params));
+    }
+
+    @GetMapping("/{id}")
+    public ApiResult<VppCustomer> getCustomer(@PathVariable("id") Long id) {
+        return ApiResult.success(vppCustomerService.getCustomer(id));
+    }
+
+    @PutMapping("/{id}")
+    public ApiResult<Void> updateCustomer(@PathVariable("id") Long id, @RequestBody VppCustomer body) {
+        vppCustomerService.updateCustomer(id, body);
+        return ApiResult.success();
+    }
+
+    @GetMapping("/{id}/contact")
+    public ApiResult<CommonPage<VppCustomerContact>> listContact(@PathVariable("id") Long id,
+                                                                  @RequestParam(required = false) Map<String, Object> params) {
+        return ApiResult.success(vppCustomerService.listContact(id, params));
+    }
+
+    @PostMapping("/{id}/contact")
+    public ApiResult<VppCustomerContact> addContact(@PathVariable("id") Long id, @RequestBody CustomerContactRequest body) {
+        return ApiResult.success(vppCustomerService.addContact(id, body));
+    }
+
+    @PutMapping("/{id}/contact/{contactId}")
+    public ApiResult<Void> updateContact(@PathVariable("id") Long id,
+                                         @PathVariable("contactId") Long contactId,
+                                         @RequestBody CustomerContactRequest body) {
+        vppCustomerService.updateContact(id, contactId, body);
+        return ApiResult.success();
+    }
+
+    @DeleteMapping("/{id}/contact/{contactId}")
+    public ApiResult<Void> deleteContact(@PathVariable("id") Long id, @PathVariable("contactId") Long contactId) {
+        vppCustomerService.deleteContact(id, contactId);
+        return ApiResult.success();
+    }
+}

+ 31 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/DashboardController.java

@@ -0,0 +1,31 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.vpp.service.VppDashboardService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 虚拟电厂 - Dashboard 接口
+ * 网关前缀: /prod-api/service-vpp
+ */
+@RestController
+@RequestMapping("/dashboard")
+public class DashboardController {
+
+    @Autowired
+    private VppDashboardService vppDashboardService;
+
+    @GetMapping(value = "/summary")
+    public ApiResult<Object> summary(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/map")
+    public ApiResult<Object> mapResources(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/curve")
+    public ApiResult<Object> curve(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+}

+ 105 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/DrController.java

@@ -0,0 +1,105 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.domain.VppDrEvent;
+import com.usky.vpp.domain.VppDrEvaluation;
+import com.usky.vpp.domain.VppDrStrategy;
+import com.usky.vpp.service.VppDrService;
+import com.usky.vpp.service.vo.DrClearingRequest;
+import com.usky.vpp.service.vo.DrInterveneRequest;
+import com.usky.vpp.service.vo.DrParticipateRequest;
+import com.usky.vpp.service.vo.DrStrategyRequest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/**
+ * 需求响应接口
+ * 网关前缀: /prod-api/service-vpp/dr
+ */
+@RestController
+@RequestMapping("/dr")
+public class DrController {
+
+    @Autowired
+    private VppDrService vppDrService;
+
+    @GetMapping(value = "/event")
+    public ApiResult<CommonPage<VppDrEvent>> pageEvent(@RequestParam(required = false) Map<String, Object> params) {
+        return ApiResult.success(vppDrService.pageEvent(params));
+    }
+
+    @GetMapping(value = "/event/{id}")
+    public ApiResult<VppDrEvent> getEvent(@PathVariable("id") Long id) {
+        return ApiResult.success(vppDrService.getEvent(id));
+    }
+
+    @GetMapping(value = "/event/{id}/capability")
+    public ApiResult<Object> assessCapability(@PathVariable("id") Long id) {
+        return ApiResult.success(vppDrService.assessCapability(id));
+    }
+
+    @PostMapping(value = "/event/{id}/participate")
+    public ApiResult<Void> participate(@PathVariable("id") Long id, @RequestBody DrParticipateRequest body) {
+        vppDrService.participate(id, body);
+        return ApiResult.success();
+    }
+
+    @PostMapping(value = "/event/{id}/clearing")
+    public ApiResult<Void> clearing(@PathVariable("id") Long id, @RequestBody DrClearingRequest body) {
+        vppDrService.clearing(id, body);
+        return ApiResult.success();
+    }
+
+    @GetMapping(value = "/event/{id}/monitor")
+    public ApiResult<Object> monitorExecution(@PathVariable("id") Long id) {
+        return ApiResult.success(vppDrService.monitorExecution(id));
+    }
+
+    @PostMapping(value = "/event/{id}/intervene")
+    public ApiResult<Void> intervene(@PathVariable("id") Long id, @RequestBody DrInterveneRequest body) {
+        vppDrService.intervene(id, body);
+        return ApiResult.success();
+    }
+
+    @PostMapping(value = "/event/{id}/ack-clearing")
+    public ApiResult<Void> acknowledgeClearing(@PathVariable("id") Long id) {
+        vppDrService.acknowledgeClearing(id);
+        return ApiResult.success();
+    }
+
+    @PostMapping(value = "/event/{id}/complete")
+    public ApiResult<Void> completeEvent(@PathVariable("id") Long id) {
+        vppDrService.completeEvent(id);
+        return ApiResult.success();
+    }
+
+    @GetMapping(value = "/event/{id}/evaluation")
+    public ApiResult<VppDrEvaluation> getEvaluation(@PathVariable("id") Long id) {
+        return ApiResult.success(vppDrService.getEvaluation(id));
+    }
+
+    @GetMapping(value = "/strategy")
+    public ApiResult<CommonPage<VppDrStrategy>> pageStrategy(@RequestParam(required = false) Map<String, Object> params) {
+        return ApiResult.success(vppDrService.pageStrategy(params));
+    }
+
+    @PostMapping(value = "/strategy")
+    public ApiResult<VppDrStrategy> createStrategy(@RequestBody DrStrategyRequest body) {
+        return ApiResult.success(vppDrService.createStrategy(body));
+    }
+
+    @PutMapping(value = "/strategy/{id}")
+    public ApiResult<Void> updateStrategy(@PathVariable("id") Long id, @RequestBody DrStrategyRequest body) {
+        vppDrService.updateStrategy(id, body);
+        return ApiResult.success();
+    }
+
+    @DeleteMapping(value = "/strategy/{id}")
+    public ApiResult<Void> deleteStrategy(@PathVariable("id") Long id) {
+        vppDrService.deleteStrategy(id);
+        return ApiResult.success();
+    }
+}

+ 43 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/MobileController.java

@@ -0,0 +1,43 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.vpp.service.VppMobileService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 虚拟电厂 - Mobile 接口
+ * 网关前缀: /prod-api/service-vpp
+ */
+@RestController
+@RequestMapping("/mobile")
+public class MobileController {
+
+    @Autowired
+    private VppMobileService vppMobileService;
+
+    @GetMapping(value = "/operator/dashboard")
+    public ApiResult<Object> operatorDashboard(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/owner/resource")
+    public ApiResult<Object> ownerResource(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/owner/bill")
+    public ApiResult<Object> ownerBill(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/message")
+    public ApiResult<Object> messages(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @PutMapping(value = "/message/{id}/read")
+    public ApiResult<Void> readMessage(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
+        return ApiResult.success();
+    }
+    @PostMapping(value = "/wechat/subscribe")
+    public ApiResult<Void> wechatSubscribe(@RequestBody(required = false) Object body) {
+        return ApiResult.success();
+    }
+}

+ 35 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/MonitorController.java

@@ -0,0 +1,35 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.vpp.service.VppMonitorService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 虚拟电厂 - Monitor 接口
+ * 网关前缀: /prod-api/service-vpp
+ */
+@RestController
+@RequestMapping("/monitor")
+public class MonitorController {
+
+    @Autowired
+    private VppMonitorService vppMonitorService;
+
+    @GetMapping(value = "/realtime")
+    public ApiResult<Object> realtime(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/history")
+    public ApiResult<Object> history(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @PostMapping(value = "/control")
+    public ApiResult<Object> control(@RequestBody(required = false) Object body) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/control/log")
+    public ApiResult<Object> controlLog(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+}

+ 55 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ReportInfoController.java

@@ -0,0 +1,55 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.vpp.service.VppReportInfoService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 虚拟电厂 - ReportInfo 接口
+ * 网关前缀: /prod-api/service-vpp
+ */
+@RestController
+@RequestMapping("/report")
+public class ReportInfoController {
+
+    @Autowired
+    private VppReportInfoService vppReportInfoService;
+
+    @GetMapping(value = "/task")
+    public ApiResult<Object> pageTask(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/record/{id}")
+    public ApiResult<Object> getRecord(@PathVariable("id") Long id, @RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/record/{id}/prefill")
+    public ApiResult<Object> prefill(@PathVariable("id") Long id, @RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @PostMapping(value = "/record/{id}/data")
+    public ApiResult<Void> saveData(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
+        return ApiResult.success();
+    }
+    @PostMapping(value = "/record/{id}/validate")
+    public ApiResult<Object> validateData(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
+        return ApiResult.success(null);
+    }
+    @PostMapping(value = "/record/{id}/submit")
+    public ApiResult<Void> submitAudit(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
+        return ApiResult.success();
+    }
+    @PutMapping(value = "/record/{id}/audit")
+    public ApiResult<Void> audit(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
+        return ApiResult.success();
+    }
+    @PostMapping(value = "/record/{id}/submit-external")
+    public ApiResult<Void> submitExternal(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
+        return ApiResult.success();
+    }
+    @GetMapping(value = "/history")
+    public ApiResult<Object> history(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+}

+ 119 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ResourceController.java

@@ -0,0 +1,119 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.domain.VppResourcePoint;
+import com.usky.vpp.service.VppResourceBoardService;
+import com.usky.vpp.service.VppResourceOverviewService;
+import com.usky.vpp.service.VppResourcePointService;
+import com.usky.vpp.service.vo.AlarmLevelStatVO;
+import com.usky.vpp.service.vo.EnergyTodayVO;
+import com.usky.vpp.service.vo.ResourceBoardDetailVO;
+import com.usky.vpp.service.vo.ResourceBoardItemVO;
+import com.usky.vpp.service.vo.ResourcePointListVO;
+import com.usky.vpp.service.vo.ResourcePointRequest;
+import com.usky.vpp.service.vo.ResourceTypeStatVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 资源管理接口
+ * <p>网关前缀: /prod-api/service-vpp/resource</p>
+ */
+@RestController
+@RequestMapping("/resource")
+public class ResourceController {
+
+    @Autowired
+    private VppResourcePointService vppResourcePointService;
+    @Autowired
+    private VppResourceBoardService vppResourceBoardService;
+    @Autowired
+    private VppResourceOverviewService vppResourceOverviewService;
+
+    /**
+     * 资源看板 - 实时运行数据列表(模拟数据)
+     * <p>展示所有资源点的实时功率、电压、电流、储能SOC、充电桩充电功率;
+     * 支持按 resourceType(资源类型)、province/city/district(区域)、status(normal/abnormal)筛选;
+     * abnormal=true 的资源需前端红色高亮。</p>
+     *
+     * @param params resourceType, province, city, district, status, pageNum, pageSize
+     */
+    @GetMapping("/board")
+    public ApiResult<CommonPage<ResourceBoardItemVO>> resourceBoard(@RequestParam(required = false) Map<String, Object> params) {
+        return ApiResult.success(vppResourceBoardService.pageBoard(params));
+    }
+
+    /**
+     * 资源看板 - 资源点运行详情(异常资源点击查看)
+     */
+    @GetMapping("/board/{resourceId}")
+    public ApiResult<ResourceBoardDetailVO> resourceBoardDetail(@PathVariable("resourceId") Long resourceId) {
+        return ApiResult.success(vppResourceBoardService.getBoardDetail(resourceId));
+    }
+
+    /**
+     * 资源概览 - 按资源类型统计
+     * <p>统计各类型资源总数、总装机容量、总可调容量(is_control=1)。</p>
+     */
+    @GetMapping("/overview/type-stats")
+    public ApiResult<List<ResourceTypeStatVO>> resourceOverviewTypeStats() {
+        return ApiResult.success(vppResourceOverviewService.getTypeStats());
+    }
+
+    /**
+     * 资源概览 - 今日电能概况
+     * <p>总发电量、总用电量、绿电消纳比例(模拟数据)。</p>
+     */
+    @GetMapping("/overview/energy-today")
+    public ApiResult<EnergyTodayVO> resourceOverviewEnergyToday() {
+        return ApiResult.success(vppResourceOverviewService.getEnergyToday());
+    }
+
+    /**
+     * 资源概览 - 实时告警统计
+     * <p>按紧急/重要/一般统计告警数量,含跳转告警列表路径(模拟数据)。</p>
+     */
+    @GetMapping("/overview/alarm-stats")
+    public ApiResult<List<AlarmLevelStatVO>> resourceOverviewAlarmStats() {
+        return ApiResult.success(vppResourceOverviewService.getAlarmStats());
+    }
+
+    /**
+     * 资源点管理 - 分页列表
+     * <p>支持按 siteName(站点名称)、deviceName(设备名称)、resourceType、siteId 搜索。</p>
+     */
+    @GetMapping
+    public ApiResult<CommonPage<ResourcePointListVO>> pageResource(@RequestParam(required = false) Map<String, Object> params) {
+        return ApiResult.success(vppResourcePointService.pageResourcePointList(params));
+    }
+
+    /**
+     * 资源点管理 - 详情
+     */
+    @GetMapping("/{id}")
+    public ApiResult<VppResourcePoint> getResource(@PathVariable("id") Long id) {
+        return ApiResult.success(vppResourcePointService.getResourcePoint(id));
+    }
+
+    /**
+     * 资源点管理 - 编辑
+     */
+    @PutMapping("/{id}")
+    public ApiResult<Void> updateResource(@PathVariable("id") Long id, @RequestBody ResourcePointRequest body) {
+        vppResourcePointService.updateResourcePoint(id, body);
+        return ApiResult.success();
+    }
+
+    /**
+     * 资源点管理 - 删除(软删除)
+     */
+    @DeleteMapping("/{id}")
+    public ApiResult<Void> deleteResource(@PathVariable("id") Long id) {
+        vppResourcePointService.deleteResourcePoint(id);
+        return ApiResult.success();
+    }
+}

+ 51 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/SettlementController.java

@@ -0,0 +1,51 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.vpp.service.VppSettlementService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 虚拟电厂 - Settlement 接口
+ * 网关前缀: /prod-api/service-vpp
+ */
+@RestController
+@RequestMapping("/settlement")
+public class SettlementController {
+
+    @Autowired
+    private VppSettlementService vppSettlementService;
+
+    @GetMapping(value = "/reading")
+    public ApiResult<Object> pageReading(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @PostMapping(value = "/reading/calculate")
+    public ApiResult<Void> calculateReading(@RequestBody(required = false) Object body) {
+        return ApiResult.success();
+    }
+    @GetMapping(value = "/bill")
+    public ApiResult<Object> pageBill(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @PostMapping(value = "/bill")
+    public ApiResult<Object> generateBill(@RequestBody(required = false) Object body) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/bill/{id}")
+    public ApiResult<Object> getBill(@PathVariable("id") Long id, @RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/bill/{id}/download")
+    public ApiResult<Object> downloadBill(@PathVariable("id") Long id) {
+        return ApiResult.success(null);
+    }
+    @GetMapping(value = "/payment")
+    public ApiResult<Object> pagePayment(@RequestParam(required = false) java.util.Map<String, Object> params) {
+        return ApiResult.success(null);
+    }
+    @PostMapping(value = "/payment")
+    public ApiResult<Object> recordPayment(@RequestBody(required = false) Object body) {
+        return ApiResult.success(null);
+    }
+}

+ 134 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/SiteController.java

@@ -0,0 +1,134 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.domain.VppDevice;
+import com.usky.vpp.domain.VppResourcePoint;
+import com.usky.vpp.domain.VppSite;
+import com.usky.vpp.service.VppDeviceService;
+import com.usky.vpp.service.VppResourcePointService;
+import com.usky.vpp.service.VppSiteService;
+import com.usky.vpp.service.vo.DeviceRequest;
+import com.usky.vpp.service.vo.ResourcePointRequest;
+import com.usky.vpp.service.vo.SiteConfigDetailVO;
+import com.usky.vpp.service.vo.SiteConfigRequest;
+import com.usky.vpp.service.vo.SiteListVO;
+import com.usky.vpp.service.vo.SiteRequest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/**
+ * 站点管理接口
+ * <p>网关前缀: /prod-api/service-vpp/site</p>
+ */
+@RestController
+@RequestMapping("/site")
+public class SiteController {
+
+    @Autowired
+    private VppSiteService vppSiteService;
+    @Autowired
+    private VppResourcePointService vppResourcePointService;
+    @Autowired
+    private VppDeviceService vppDeviceService;
+
+    /**
+     * 站点管理 - 分页列表
+     * <p>支持按 siteName(站点名称)、province/city/district(区域)、resourceType(站点下资源类型)筛选。</p>
+     */
+    @GetMapping
+    public ApiResult<CommonPage<SiteListVO>> pageSite(@RequestParam(required = false) Map<String, Object> params) {
+        return ApiResult.success(vppSiteService.pageSiteList(params));
+    }
+
+    /**
+     * 站点管理 - 新增
+     */
+    @PostMapping
+    public ApiResult<VppSite> createSite(@RequestBody SiteRequest body) {
+        return ApiResult.success(vppSiteService.createSite(body));
+    }
+
+    /**
+     * 站点管理 - 详情
+     */
+    @GetMapping("/{id}")
+    public ApiResult<VppSite> getSite(@PathVariable("id") Long id) {
+        return ApiResult.success(vppSiteService.getSite(id));
+    }
+
+    /**
+     * 站点管理 - 编辑
+     */
+    @PutMapping("/{id}")
+    public ApiResult<Void> updateSite(@PathVariable("id") Long id, @RequestBody SiteRequest body) {
+        vppSiteService.updateSite(id, body);
+        return ApiResult.success();
+    }
+
+    /**
+     * 站点管理 - 删除(软删除)
+     */
+    @DeleteMapping("/{id}")
+    public ApiResult<Void> deleteSite(@PathVariable("id") Long id) {
+        vppSiteService.deleteSite(id);
+        return ApiResult.success();
+    }
+
+    /**
+     * 站点参数配置 - 查询
+     * <p>包含采集频率、功率/SOC告警阈值、离线超时、响应优先级等。</p>
+     */
+    @GetMapping("/{siteId}/config")
+    public ApiResult<SiteConfigDetailVO> getSiteConfig(@PathVariable("siteId") Long siteId) {
+        return ApiResult.success(vppSiteService.getSiteConfigDetail(siteId));
+    }
+
+    /**
+     * 站点参数配置 - 保存
+     * <p>配置采集频率、告警阈值;responsePriority 写入站点响应优先级。</p>
+     */
+    @PutMapping("/{siteId}/config")
+    public ApiResult<SiteConfigDetailVO> saveSiteConfig(@PathVariable("siteId") Long siteId,
+                                                        @RequestBody SiteConfigRequest body) {
+        return ApiResult.success(vppSiteService.saveSiteConfigDetail(siteId, body));
+    }
+
+    /**
+     * 站点下设备列表
+     */
+    @GetMapping("/{siteId}/device")
+    public ApiResult<CommonPage<VppDevice>> listSiteDevice(@PathVariable("siteId") Long siteId,
+                                                           @RequestParam(required = false) Map<String, Object> params) {
+        return ApiResult.success(vppDeviceService.listBySiteId(siteId, params));
+    }
+
+    /**
+     * 站点下新增设备
+     */
+    @PostMapping("/{siteId}/device")
+    public ApiResult<VppDevice> addSiteDevice(@PathVariable("siteId") Long siteId,
+                                              @RequestBody DeviceRequest body) {
+        return ApiResult.success(vppDeviceService.createDevice(siteId, body));
+    }
+
+    /**
+     * 站点下资源点列表
+     */
+    @GetMapping("/{siteId}/resource")
+    public ApiResult<CommonPage<VppResourcePoint>> listSiteResource(@PathVariable("siteId") Long siteId,
+                                                                    @RequestParam(required = false) Map<String, Object> params) {
+        return ApiResult.success(vppResourcePointService.listBySiteId(siteId, params));
+    }
+
+    /**
+     * 站点下新增资源点
+     */
+    @PostMapping("/{siteId}/resource")
+    public ApiResult<VppResourcePoint> addSiteResource(@PathVariable("siteId") Long siteId,
+                                                       @RequestBody ResourcePointRequest body) {
+        return ApiResult.success(vppResourcePointService.createResourcePoint(siteId, body));
+    }
+}

+ 35 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/UnIntegrationController.java

@@ -0,0 +1,35 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.vpp.service.VppUnIntegrationService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/**
+ * 运管平台 UN 主动对接(Poll、注册、Token)
+ * 网关前缀: /prod-api/service-vpp/un
+ */
+@RestController
+@RequestMapping("/un")
+public class UnIntegrationController {
+
+    @Autowired
+    private VppUnIntegrationService integrationService;
+
+    @PostMapping("/token/refresh")
+    public ApiResult<Map<String, Object>> refreshToken() {
+        return ApiResult.success(integrationService.refreshToken());
+    }
+
+    @PostMapping("/register")
+    public ApiResult<Map<String, Object>> register() {
+        return ApiResult.success(integrationService.register());
+    }
+
+    @PostMapping("/poll")
+    public ApiResult<Map<String, Object>> poll() {
+        return ApiResult.success(integrationService.pollOnce());
+    }
+}

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

@@ -0,0 +1,50 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.domain.VppDevice;
+import com.usky.vpp.service.VppDeviceService;
+import com.usky.vpp.service.vo.DeviceRequest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/**
+ * 虚拟电厂 - 设备接口
+ * 网关前缀: /prod-api/service-vpp
+ */
+@RestController
+@RequestMapping("/device")
+public class VppDeviceController {
+
+    @Autowired
+    private VppDeviceService vppDeviceService;
+
+    @GetMapping
+    public ApiResult<CommonPage<VppDevice>> pageDevice(@RequestParam(required = false) Map<String, Object> params) {
+        return ApiResult.success(vppDeviceService.pageDevice(params));
+    }
+
+    @GetMapping("/uuid/{deviceUuid}")
+    public ApiResult<VppDevice> getByDeviceUuid(@PathVariable("deviceUuid") String deviceUuid) {
+        return ApiResult.success(vppDeviceService.getByDeviceUuid(deviceUuid));
+    }
+
+    @GetMapping("/{id}")
+    public ApiResult<VppDevice> getDevice(@PathVariable("id") Long id) {
+        return ApiResult.success(vppDeviceService.getDevice(id));
+    }
+
+    @PutMapping("/{id}")
+    public ApiResult<Void> updateDevice(@PathVariable("id") Long id, @RequestBody DeviceRequest body) {
+        vppDeviceService.updateDevice(id, body);
+        return ApiResult.success();
+    }
+
+    @DeleteMapping("/{id}")
+    public ApiResult<Void> deleteDevice(@PathVariable("id") Long id) {
+        vppDeviceService.deleteDevice(id);
+        return ApiResult.success();
+    }
+}

+ 77 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/crypto/VppUnCryptoService.java

@@ -0,0 +1,77 @@
+package com.usky.vpp.crypto;
+
+import cn.hutool.core.codec.Base64;
+import cn.hutool.crypto.SmUtil;
+import cn.hutool.crypto.asymmetric.KeyType;
+import cn.hutool.crypto.asymmetric.SM2;
+import com.usky.vpp.config.VppUnProperties;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 运管平台 UN/DN 国密加解密与签名(SM2/SM3withSM2)
+ */
+@Service
+public class VppUnCryptoService {
+
+    private static final Logger log = LoggerFactory.getLogger(VppUnCryptoService.class);
+
+    @Autowired
+    private VppUnProperties properties;
+
+    public boolean isActive() {
+        return Boolean.TRUE.equals(properties.getCryptoEnabled())
+                && StringUtils.hasText(properties.getUnPublicKey())
+                && StringUtils.hasText(properties.getDnPrivateKey());
+    }
+
+    public String encryptRequest(String plainJson) {
+        SM2 sm2 = SmUtil.sm2(null, properties.getUnPublicKey());
+        return sm2.encryptBase64(plainJson, KeyType.PublicKey);
+    }
+
+    public String signRequest(String cipherBase64) {
+        SM2 sm2 = SmUtil.sm2(properties.getDnPrivateKey(), null);
+        byte[] sign = sm2.sign(cipherBase64.getBytes(StandardCharsets.UTF_8));
+        return Base64.encode(sign);
+    }
+
+    public String decryptResponse(String cipherBase64) {
+        SM2 sm2 = SmUtil.sm2(properties.getDnPrivateKey(), properties.getDnPublicKey());
+        return sm2.decryptStr(cipherBase64, KeyType.PrivateKey);
+    }
+
+    public boolean verifyResponse(String cipherBase64, String signBase64) {
+        return verifyInbound(cipherBase64, signBase64);
+    }
+
+    /** UN→DN 请求验签 */
+    public boolean verifyInbound(String cipherBase64, String signBase64) {
+        if (!StringUtils.hasText(signBase64)) {
+            log.warn("UN 请求缺少 X-Sign,跳过验签");
+            return true;
+        }
+        SM2 sm2 = SmUtil.sm2(null, properties.getUnPublicKey());
+        return sm2.verify(cipherBase64.getBytes(StandardCharsets.UTF_8), Base64.decode(signBase64));
+    }
+
+    /** UN→DN 请求解密 */
+    public String decryptInbound(String cipherBase64) {
+        return decryptResponse(cipherBase64);
+    }
+
+    /** DN→UN 响应加密 */
+    public String encryptOutbound(String plainJson) {
+        return encryptRequest(plainJson);
+    }
+
+    /** DN→UN 响应签名 */
+    public String signOutbound(String cipherBase64) {
+        return signRequest(cipherBase64);
+    }
+}

+ 67 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppContract.java

@@ -0,0 +1,67 @@
+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.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * vpp_contract
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("vpp_contract")
+public class VppContract implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+    @TableField("contract_no")
+    private String contractNo;
+    @TableField("customer_id")
+    private Long customerId;
+    @TableField("template_id")
+    private Long templateId;
+    @TableField("contract_type")
+    private Integer contractType;
+    @TableField("contract_name")
+    private String contractName;
+    @TableField("contract_status")
+    private Integer contractStatus;
+    @TableField("sign_date")
+    private LocalDate signDate;
+    @TableField("effective_date")
+    private LocalDate effectiveDate;
+    @TableField("expire_date")
+    private LocalDate expireDate;
+    @TableField("file_url")
+    private String fileUrl;
+    @TableField("share_ratio")
+    private BigDecimal shareRatio;
+    @TableField("price_json")
+    private String priceJson;
+    @TableField("account_info_json")
+    private String accountInfoJson;
+    private String remark;
+    @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;
+}

+ 34 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppContractAuditLog.java

@@ -0,0 +1,34 @@
+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_contract_audit_log

+ */

+@Data

+@EqualsAndHashCode(callSuper = false)

+@TableName("vpp_contract_audit_log")

+public class VppContractAuditLog implements Serializable {

+

+    private static final long serialVersionUID = 1L;

+

+    @TableId(value = "id", type = IdType.ASSIGN_ID)

+    private Long id;

+    @TableField("contract_id")

+    private Long contractId;

+    private Integer action;

+    private String opinion;

+    @TableField("tenant_id")

+    private Integer tenantId;

+    @TableField("create_time")

+    private LocalDateTime createTime;

+    @TableField("created_by")

+    private String createdBy;

+}


+ 51 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppContractTemplate.java

@@ -0,0 +1,51 @@
+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_contract_template
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("vpp_contract_template")
+public class VppContractTemplate implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+    @TableField("template_code")
+    private String templateCode;
+    @TableField("template_name")
+    private String templateName;
+    @TableField("contract_type")
+    private Integer contractType;
+    private String version;
+    @TableField("file_url")
+    private String fileUrl;
+    @TableField("variables_json")
+    private String variablesJson;
+    @TableField("is_enabled")
+    private Integer isEnabled;
+    @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;
+}

+ 66 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppCustomer.java

@@ -0,0 +1,66 @@
+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_customer
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("vpp_customer")
+public class VppCustomer implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+    @TableField("account_no")
+    private String accountNo;
+    @TableField("customer_name")
+    private String customerName;
+    @TableField("customer_type")
+    private Integer customerType;
+    @TableField("power_company")
+    private String powerCompany;
+    @TableField("contract_capacity")
+    private BigDecimal contractCapacity;
+    @TableField("credit_status")
+    private Integer creditStatus;
+    @TableField("lifecycle_status")
+    private Integer lifecycleStatus;
+    @TableField("dr_notify_minutes")
+    private Integer drNotifyMinutes;
+    @TableField("dr_up_capacity_kw")
+    private BigDecimal drUpCapacityKw;
+    @TableField("dr_down_capacity_kw")
+    private BigDecimal drDownCapacityKw;
+    private String province;
+    private String city;
+    private String district;
+    private String address;
+    @TableField("business_license_url")
+    private String businessLicenseUrl;
+    private String remark;
+    @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;
+}

+ 65 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppCustomerAccess.java

@@ -0,0 +1,65 @@
+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_customer_access
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("vpp_customer_access")
+public class VppCustomerAccess implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+    @TableField("apply_no")
+    private String applyNo;
+    @TableField("account_no")
+    private String accountNo;
+    @TableField("customer_name")
+    private String customerName;
+    @TableField("customer_type")
+    private Integer customerType;
+    @TableField("power_company")
+    private String powerCompany;
+    @TableField("contract_capacity")
+    private BigDecimal contractCapacity;
+    @TableField("business_license_url")
+    private String businessLicenseUrl;
+    @TableField("credit_status")
+    private Integer creditStatus;
+    @TableField("access_status")
+    private Integer accessStatus;
+    @TableField("audit_opinion")
+    private String auditOpinion;
+    @TableField("audit_at")
+    private LocalDateTime auditAt;
+    @TableField("customer_id")
+    private Long customerId;
+    @TableField("apply_at")
+    private LocalDateTime applyAt;
+    @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;
+}

+ 49 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppCustomerContact.java

@@ -0,0 +1,49 @@
+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_customer_contact
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("vpp_customer_contact")
+public class VppCustomerContact implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+    @TableField("customer_id")
+    private Long customerId;
+    @TableField("contact_name")
+    private String contactName;
+    @TableField("contact_phone")
+    private String contactPhone;
+    @TableField("contact_email")
+    private String contactEmail;
+    @TableField("is_primary")
+    private Integer isPrimary;
+    private String position;
+    @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;
+}

+ 65 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDevice.java

@@ -0,0 +1,65 @@
+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_device
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("vpp_device")
+public class VppDevice implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+    @TableField("device_code")
+    private String deviceCode;
+    @TableField("device_uuid")
+    private String deviceUuid;
+    @TableField("device_name")
+    private String deviceName;
+    /** 所属站点ID,关联 vpp_site.id */
+    @TableField("site_id")
+    private Long siteId;
+    @TableField("device_type")
+    private String deviceType;
+    private String manufacturer;
+    private String model;
+    @TableField("rated_power_kw")
+    private BigDecimal ratedPowerKw;
+    @TableField("comm_status")
+    private Integer commStatus;
+    @TableField("run_status")
+    private Integer runStatus;
+    @TableField("firmware_version")
+    private String firmwareVersion;
+    @TableField("last_online_at")
+    private LocalDateTime lastOnlineAt;
+    @TableField("gateway_id")
+    private String gatewayId;
+    private String remark;
+    @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;
+}

+ 40 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDeviceControlLog.java

@@ -0,0 +1,40 @@
+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_device_control_log

+ */

+@Data

+@EqualsAndHashCode(callSuper = false)

+@TableName("vpp_device_control_log")

+public class VppDeviceControlLog implements Serializable {

+

+    private static final long serialVersionUID = 1L;

+

+    @TableId(value = "id", type = IdType.ASSIGN_ID)

+    private Long id;

+    @TableField("device_id")

+    private Long deviceId;

+    @TableField("control_type")

+    private String controlType;

+    @TableField("control_params")

+    private String controlParams;

+    @TableField("control_result")

+    private Integer controlResult;

+    @TableField("result_message")

+    private String resultMessage;

+    @TableField("tenant_id")

+    private Integer tenantId;

+    @TableField("create_time")

+    private LocalDateTime createTime;

+    @TableField("created_by")

+    private String createdBy;

+}


+ 53 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDrEvaluation.java

@@ -0,0 +1,53 @@
+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_dr_evaluation
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("vpp_dr_evaluation")
+public class VppDrEvaluation implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+    @TableField("event_id")
+    private Long eventId;
+    @TableField("actual_capacity_kw")
+    private BigDecimal actualCapacityKw;
+    @TableField("response_duration_min")
+    private Integer responseDurationMin;
+    @TableField("subsidy_amount")
+    private BigDecimal subsidyAmount;
+    @TableField("qualified_rate")
+    private BigDecimal qualifiedRate;
+    @TableField("report_file_url")
+    private String reportFileUrl;
+    @TableField("evaluated_at")
+    private LocalDateTime evaluatedAt;
+    @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;
+}

部分文件因文件數量過多而無法顯示