Преглед изворни кода

解决stash恢复冲突:合并master与本地BaseDataTransferService修改

hanzhengyi пре 3 недеља
родитељ
комит
54166acb9a
43 измењених фајлова са 3306 додато и 42 уклоњено
  1. 31 0
      service-cdi/service-cdi-biz/pom.xml
  2. 96 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/controller/SipVideoController.java
  3. 111 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/controller/VideoIntegrationController.java
  4. 9 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/config/Gb28181VideoConfig.java
  5. 17 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/config/Gb28181VideoProperties.java
  6. 10 2
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/config/mqtt/MqttBaseConfig.java
  7. 5 31
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/config/mqtt/MqttOutConfig.java
  8. 0 1
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/BaseDataTransferService.java
  9. 0 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/VideoDataTransferService.java
  10. 71 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/VideoStreamPushService.java
  11. 66 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/job/VideoDeviceSyncJob.java
  12. 23 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/config/SipServerProperties.java
  13. 295 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/listener/SipServerListener.java
  14. 26 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/model/SdpMediaInfo.java
  15. 28 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/model/VideoStreamSession.java
  16. 103 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/FfmpegPsRemuxSession.java
  17. 109 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/FfmpegPsRemuxer.java
  18. 223 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/Gb28181PsHeaders.java
  19. 65 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/H264ExtradataUtils.java
  20. 163 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/HikvisionPesBuilder.java
  21. 73 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/PsStreamFramer.java
  22. 66 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/SipLocalIntegrationTestMain.java
  23. 82 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/SipVideoPushTestMain.java
  24. 332 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/SipVideoPusher.java
  25. 76 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/H264Depacketizer.java
  26. 61 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/RtpPacket.java
  27. 108 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/RtpVideoReceiver.java
  28. 181 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/sdp/SdpUtils.java
  29. 43 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/service/SipServerService.java
  30. 109 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/service/VideoStreamManager.java
  31. 36 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/util/NetworkUtils.java
  32. 49 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/util/PortAllocator.java
  33. 83 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/util/Gb28181MqttCredentialResolver.java
  34. 312 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/util/Gb28181VideoPlatformClient.java
  35. 12 8
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/util/WeatherFetcher.java
  36. 28 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoChannelVO.java
  37. 29 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoDeviceItemVO.java
  38. 35 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoDeviceSyncPacketVO.java
  39. 56 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoPlayInfoVO.java
  40. 35 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoStreamItemVO.java
  41. 27 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoStreamSyncPacketVO.java
  42. 20 0
      service-cdi/service-cdi-biz/src/main/resources/application-gb28181-video.yml
  43. 2 0
      service-cdi/service-cdi-biz/src/main/resources/bootstrap.yml

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

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

@@ -0,0 +1,96 @@
+package com.usky.cdi.controller;
+
+import com.usky.cdi.service.impl.VideoStreamPushService;
+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;
+    private final VideoStreamPushService videoStreamPushService;
+
+    @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;
+    }
+
+    /**
+     * 测试向本机 SIP 服务端推送 H264 视频流(模拟 GB28181 设备推流)
+     */
+    @PostMapping("/test/push")
+    public Map<String, Object> testPush(
+            @RequestParam(defaultValue = "127.0.0.1") String host,
+            @RequestParam(required = false) Integer port,
+            @RequestParam(defaultValue = "5") int durationSeconds,
+            @RequestParam(defaultValue = "34020000001320000001") String deviceId) {
+        int sipPort = port != null ? port : properties.getPort();
+        return videoStreamPushService.pushTestStream(host, sipPort, durationSeconds, deviceId);
+    }
+
+    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 {
+}

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

@@ -0,0 +1,17 @@
+package com.usky.cdi.service.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "gb28181.video")
+public class Gb28181VideoProperties {
+
+    private boolean enabled = false;
+    private Integer tenantId;
+    private Long engineeringId;
+    private String mqttUsername;
+    private String mqttPassword;
+}

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

+ 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://BSP0-0103.jpg";
             // 将图片文件读取为字节数组
             byte[] imageBytes = Files.readAllBytes(Paths.get(imagePath));

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


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

@@ -0,0 +1,71 @@
+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;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@ConditionalOnProperty(prefix = "sip.client", name = "enabled", havingValue = "true")
+public class VideoStreamPushService {
+
+    private final SipClientProperties properties;
+    private final Gb28181DeviceSimulator deviceSimulator;
+
+    /**
+     * 绑定 RTSP 视频源,等待平台 INVITE 后推流(海康 GB28181 摄像机模式)。
+     */
+    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 e) {
+            throw new BusinessException(e.getMessage());
+        } catch (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;
+    }
+}

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

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

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

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

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

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

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

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

@@ -0,0 +1,181 @@
+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";
+    }
+
+    /** @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;
+        }
+    }
+}

+ 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: