james 3 هفته پیش
والد
کامیت
09a9402353
100فایلهای تغییر یافته به همراه8302 افزوده شده و 39 حذف شده
  1. 3 1
      pom.xml
  2. 31 0
      service-cdi/service-cdi-biz/pom.xml
  3. 53 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/controller/SipPushController.java
  4. 81 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/controller/SipVideoController.java
  5. 111 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/controller/VideoIntegrationController.java
  6. 9 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/config/Gb28181VideoConfig.java
  7. 46 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/config/Gb28181VideoProperties.java
  8. 1 6
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/config/mqtt/MqttBaseConfig.java
  9. 1 1
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/config/mqtt/MqttOutConfig.java
  10. 4 2
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/enums/MqttTopics.java
  11. 3 2
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/BaseDataTransferService.java
  12. 109 10
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/CdiDeliveryLogServiceImpl.java
  13. 93 15
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/IotDataTransferService.java
  14. 69 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/SipRtspPushService.java
  15. 144 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/VideoDataTransferService.java
  16. 209 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/VideoStreamPushService.java
  17. 66 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/job/VideoDeviceSyncJob.java
  18. 32 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/config/SipClientProperties.java
  19. 23 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/config/SipServerProperties.java
  20. 679 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/device/Gb28181DeviceSimulator.java
  21. 22 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/device/Gb28181StreamResult.java
  22. 295 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/listener/SipServerListener.java
  23. 26 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/model/SdpMediaInfo.java
  24. 28 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/model/VideoStreamSession.java
  25. 103 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/FfmpegPsRemuxSession.java
  26. 109 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/FfmpegPsRemuxer.java
  27. 223 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/Gb28181PsHeaders.java
  28. 65 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/Gb28181PsMuxer.java
  29. 162 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/H264AccessUnitBuilder.java
  30. 65 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/H264ExtradataUtils.java
  31. 163 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/HikvisionPesBuilder.java
  32. 73 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/ps/PsStreamFramer.java
  33. 257 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/RtspGb28181RtpSender.java
  34. 90 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/RtspH264RtpSender.java
  35. 66 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/SipLocalIntegrationTestMain.java
  36. 82 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/SipVideoPushTestMain.java
  37. 332 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/SipVideoPusher.java
  38. 28 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/push/SipVideoPusherFactory.java
  39. 78 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/AnnexBParser.java
  40. 76 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/H264Depacketizer.java
  41. 46 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/H264RtpPacketizer.java
  42. 35 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/PsRtpPacketizer.java
  43. 61 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/RtpPacket.java
  44. 29 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/RtpPacketBuilder.java
  45. 108 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/rtp/RtpVideoReceiver.java
  46. 191 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/sdp/SdpUtils.java
  47. 43 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/service/SipServerService.java
  48. 109 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/service/VideoStreamManager.java
  49. 36 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/util/NetworkUtils.java
  50. 49 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/util/PortAllocator.java
  51. 39 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/sip/util/SipDigestAuth.java
  52. 12 1
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/util/DeviceDataQuery.java
  53. 83 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/util/Gb28181MqttCredentialResolver.java
  54. 312 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/util/Gb28181VideoPlatformClient.java
  55. 1 1
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/util/WeatherFetcher.java
  56. 49 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/info/GeneratorMonitoringVO.java
  57. 28 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoChannelVO.java
  58. 29 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoDeviceItemVO.java
  59. 35 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoDeviceSyncPacketVO.java
  60. 56 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoPlayInfoVO.java
  61. 35 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoStreamItemVO.java
  62. 27 0
      service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/video/VideoStreamSyncPacketVO.java
  63. 20 0
      service-cdi/service-cdi-biz/src/main/resources/application-gb28181-video.yml
  64. 2 0
      service-cdi/service-cdi-biz/src/main/resources/bootstrap.yml
  65. 17 0
      service-data/pom.xml
  66. 33 0
      service-data/service-data-api/pom.xml
  67. 96 0
      service-data/service-data-biz/pom.xml
  68. 50 0
      service-data/service-data-biz/src/main/java/com/usky/data/DataApplicationRun.java
  69. 106 0
      service-data/service-data-biz/src/main/java/com/usky/data/MybatisGenerator.java
  70. 222 0
      service-data/service-data-biz/src/main/java/com/usky/data/controller/web/DataInterfaceController.java
  71. 21 0
      service-data/service-data-biz/src/main/java/com/usky/data/controller/web/DataInterfaceLogController.java
  72. 229 0
      service-data/service-data-biz/src/main/java/com/usky/data/controller/web/DataLinkController.java
  73. 179 0
      service-data/service-data-biz/src/main/java/com/usky/data/domain/DataInterface.java
  74. 122 0
      service-data/service-data-biz/src/main/java/com/usky/data/domain/DataInterfaceLog.java
  75. 136 0
      service-data/service-data-biz/src/main/java/com/usky/data/domain/DataLink.java
  76. 16 0
      service-data/service-data-biz/src/main/java/com/usky/data/mapper/DataInterfaceLogMapper.java
  77. 16 0
      service-data/service-data-biz/src/main/java/com/usky/data/mapper/DataInterfaceMapper.java
  78. 34 0
      service-data/service-data-biz/src/main/java/com/usky/data/mapper/DataLinkMapper.java
  79. 34 0
      service-data/service-data-biz/src/main/java/com/usky/data/model/DataInterfaceModel.java
  80. 52 0
      service-data/service-data-biz/src/main/java/com/usky/data/model/DbLinkBaseForm.java
  81. 24 0
      service-data/service-data-biz/src/main/java/com/usky/data/model/DbLinkCreUpForm.java
  82. 24 0
      service-data/service-data-biz/src/main/java/com/usky/data/model/FieldModel.java
  83. 14 0
      service-data/service-data-biz/src/main/java/com/usky/data/model/PaginationDbLink.java
  84. 26 0
      service-data/service-data-biz/src/main/java/com/usky/data/model/ParamModel.java
  85. 16 0
      service-data/service-data-biz/src/main/java/com/usky/data/service/DataInterfaceLogService.java
  86. 49 0
      service-data/service-data-biz/src/main/java/com/usky/data/service/DataInterfaceService.java
  87. 47 0
      service-data/service-data-biz/src/main/java/com/usky/data/service/DataLinkService.java
  88. 20 0
      service-data/service-data-biz/src/main/java/com/usky/data/service/impl/DataInterfaceLogServiceImpl.java
  89. 291 0
      service-data/service-data-biz/src/main/java/com/usky/data/service/impl/DataInterfaceServiceImpl.java
  90. 120 0
      service-data/service-data-biz/src/main/java/com/usky/data/service/impl/DataLinkServiceImpl.java
  91. 421 0
      service-data/service-data-biz/src/main/java/com/usky/data/service/util/InterfaceExecutor.java
  92. 49 0
      service-data/service-data-biz/src/main/java/com/usky/data/vo/DbLinkInfoVO.java
  93. 22 0
      service-data/service-data-biz/src/main/java/com/usky/data/vo/DbLinkListVO.java
  94. 14 0
      service-data/service-data-biz/src/main/java/com/usky/data/vo/DbLinkSelectorListVO.java
  95. 17 0
      service-data/service-data-biz/src/main/java/com/usky/data/vo/ListVO.java
  96. 19 0
      service-data/service-data-biz/src/main/java/com/usky/data/vo/PageListVO.java
  97. 14 0
      service-data/service-data-biz/src/main/java/com/usky/data/vo/PaginationVO.java
  98. 25 0
      service-data/service-data-biz/src/main/resources/bootstrap.yml
  99. 108 0
      service-data/service-data-biz/src/main/resources/doc/index.adoc
  100. 74 0
      service-data/service-data-biz/src/main/resources/logback.xml

+ 3 - 1
pom.xml

@@ -89,10 +89,12 @@
 
         <module>service-rule</module>
 
-        <!--    <module>service-data</module>-->
+            <module>service-data</module>
 
         <module>service-sas</module>
 
+        <module>service-vpp</module>
+
   </modules>
           
   

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

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

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

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

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

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

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

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

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

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

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

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

+ 1 - 6
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/config/mqtt/MqttBaseConfig.java

@@ -2,13 +2,8 @@ package com.usky.cdi.service.config.mqtt;
 
 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;
 
 @Data
 public class MqttBaseConfig {
@@ -23,7 +18,7 @@ public class MqttBaseConfig {
 
     //心跳间隔
     private int keepAliveInterval;
-    
+
     //完成超时
     private int completionTimeout;
 

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

@@ -120,4 +120,4 @@ public class MqttOutConfig {
          */
         void sendToMqtt(@Header(MqttHeaders.TOPIC) String topic, @Header(MqttHeaders.QOS) int qos, String payload);
     }
-}
+}

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

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

+ 3 - 2
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/BaseDataTransferService.java

@@ -170,8 +170,7 @@ public class BaseDataTransferService {
             if (vo.getPublishTime() == null) {
                 vo.setPublishTime(getCurrentTime());
             }
-
-            String imagePath = "D://BSP0-0103.jpg";
+            String imagePath = "D://142.jpg";
             // 将图片文件读取为字节数组
             byte[] imageBytes = Files.readAllBytes(Paths.get(imagePath));
 
@@ -264,6 +263,8 @@ public class BaseDataTransferService {
             // userIdToName.put(713, 36);
             userIdToName.put(714, 37);
             userIdToName.put(716, 26);
+            // 柴油发电机蓄电池监测传感器
+            userIdToName.put(720, 12);
 
             HashMap<String, Object> map = new HashMap<>();
             map.put("dataPacketID", generateDataPacketID());

+ 109 - 10
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/CdiDeliveryLogServiceImpl.java

@@ -36,6 +36,7 @@ import org.springframework.beans.factory.annotation.Value;
 import javax.annotation.PostConstruct;
 import javax.imageio.ImageIO;
 import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
@@ -636,19 +637,70 @@ public class CdiDeliveryLogServiceImpl extends AbstractCrudService<CdiDeliveryLo
         String time = DateUtil.format(new Date(), "yyyy-MM-dd HH:mm:ss.SSS");
 
         List<FloorPlaneVO> result = new ArrayList<>(filteredPlaneList.size());
+        int skipCount = 0;
+
         for (BaseBuildPlane buildPlane : filteredPlaneList) {
-            String planeViewUrl = buildPlane.getPlaneViewUrl();
-            FloorPlaneVO vo = new FloorPlaneVO();
-            checkFileSize(vo, planeViewUrl);
-            fillImageInfo(vo, planeViewUrl);
+            try {
+                String planeViewUrl = buildPlane.getPlaneViewUrl();
 
-            vo.setDataPacketID(generateDataPacketID());
-            vo.setEngineeringID(engineeringId);
-            vo.setFloor(buildPlane.getFloor());
-            vo.setFloorFileID(Long.valueOf(buildPlane.getId()));
-            vo.setPublishTime(time);
+                // ✅ 修复1: 增加详细的处理日志
+                log.info("正在处理楼层平面图:ID={}, Floor={}, URL={}",
+                        buildPlane.getId(), buildPlane.getFloor(), planeViewUrl);
 
-            result.add(vo);
+                FloorPlaneVO vo = new FloorPlaneVO();
+
+                // ✅ 修复2: 单条记录异常不中断整个批次
+                if (StrUtil.isBlank(planeViewUrl)) {
+                    log.warn("楼层平面图URL为空,跳过该记录:ID={}, Floor={}", buildPlane.getId(), buildPlane.getFloor());
+                    skipCount++;
+                    continue;
+                }
+
+                // 读取文件并校验大小
+                checkFileSize(vo, planeViewUrl);
+
+                // ✅ 修复3: 复用已读取的文件数据来获取图片尺寸(避免重复I/O)
+                fillImageInfoWithBytes(vo, planeViewUrl, vo.getFloorFile());
+
+                // 设置基础字段
+                vo.setDataPacketID(generateDataPacketID());
+                vo.setEngineeringID(engineeringId);
+                vo.setFloor(convertFloor(buildPlane.getFloor()));
+                vo.setFloorFileID(Long.valueOf(buildPlane.getId()));
+                vo.setPublishTime(time);
+
+                // ✅ 修复4: 校验关键字段完整性
+                if (vo.getFloorFile() == null || vo.getFloorFile().length == 0) {
+                    log.warn("楼层平面图文件为空,跳过该记录:ID={}, Floor={}", buildPlane.getId(), buildPlane.getFloor());
+                    skipCount++;
+                    continue;
+                }
+
+                // ✅ 修复5: 记录成功处理的详细信息
+                log.info("楼层平面图处理成功:FileID={}, Floor={}, FileName={}, FileSize={}bytes, Pix={}x{}",
+                        vo.getFloorFileID(),
+                        vo.getFloor(),
+                        vo.getFloorFileName(),
+                        vo.getFloorFile().length,
+                        vo.getFilePixWidth(),
+                        vo.getFilePixHeight());
+
+                result.add(vo);
+
+            } catch (BusinessException e) {
+                log.error("楼层平面图业务校验失败,跳过该记录:ID={}, Floor={}, 错误={}",
+                        buildPlane.getId(), buildPlane.getFloor(), e.getMessage());
+                skipCount++;
+            } catch (Exception e) {
+                log.error("楼层平面图处理异常,跳过该记录:ID={}, Floor={}",
+                        buildPlane.getId(), buildPlane.getFloor(), e);
+                skipCount++;
+            }
+        }
+
+        if (skipCount > 0) {
+            log.warn("楼层平面图处理完成:成功{}条,跳过{}条(文件不存在/超限/读取失败等)",
+                    result.size(), skipCount);
         }
 
         return result;
@@ -720,6 +772,21 @@ public class CdiDeliveryLogServiceImpl extends AbstractCrudService<CdiDeliveryLo
         }
     }
 
+    private String convertFloor(String floor) {
+        if (floor == null || floor.trim().isEmpty()) {
+            return floor;
+        }
+        try {
+            int floorNum = Integer.parseInt(floor.trim());
+            if (floorNum < 0) {
+                return "B" + Math.abs(floorNum);
+            }
+        } catch (NumberFormatException e) {
+            log.warn("楼层格式转换失败,原始值: {}", floor);
+        }
+        return floor;
+    }
+
     private String encodeUrl(String url) throws UnsupportedEncodingException {
         if (StrUtil.isBlank(url)) {
             return url;
@@ -753,6 +820,38 @@ public class CdiDeliveryLogServiceImpl extends AbstractCrudService<CdiDeliveryLo
         return protocol + hostAndPort + encodedPath.toString();
     }
 
+    /**
+     * 填充图片信息(使用已读取的字节数组,避免重复I/O)
+     * @param vo 楼层平面图VO
+     * @param imageUrl 图片URL或本地路径(用于提取文件名和后缀)
+     * @param imageBytes 已读取的图片字节数组
+     */
+    private void fillImageInfoWithBytes(FloorPlaneVO vo, String imageUrl, byte[] imageBytes) {
+        if (StrUtil.isBlank(imageUrl)) {
+            return;
+        }
+
+        // 从URL/路径中提取文件名和后缀
+        String fileName = FileUtil.getName(imageUrl);
+        vo.setFloorFileName(FileUtil.mainName(fileName));
+        vo.setFloorFileSuffix(FileUtil.extName(fileName));
+
+        // ✅ 优化:直接从已读取的byte[]获取尺寸,避免重复读取文件
+        try {
+            if (imageBytes != null && imageBytes.length > 0) {
+                ByteArrayInputStream bais = new ByteArrayInputStream(imageBytes);
+                BufferedImage image = ImageIO.read(bais);
+
+                if (image != null) {
+                    vo.setFilePixWidth(image.getWidth());
+                    vo.setFilePixHeight(image.getHeight());
+                }
+            }
+        } catch (IOException e) {
+            log.error("从字节数组获取图片尺寸失败: {}", imageUrl, e);
+        }
+    }
+
     private void fillImageInfo(FloorPlaneVO vo, String imageUrl) {
         if (StrUtil.isBlank(imageUrl)) {
             return;

+ 93 - 15
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/impl/IotDataTransferService.java

@@ -1091,7 +1091,7 @@ public class IotDataTransferService {
 
         try {
 
-            log.info("开始推送集水井水位数据,设备类型:{},设备数量:{},获取到的数据条数:{}",
+            log.info("开始推送集水井水位数据,设备类型:{},设备数量:{},获取到的数据条数:{}",
                     deviceType, totalDevices, deviceData.size());
 
             if (deviceData.isEmpty()) {
@@ -1154,6 +1154,92 @@ public class IotDataTransferService {
         }
     }
 
+    /**
+     * 发送发电机蓄电池监测数据(720)
+     *
+     * @return 推送结果,包含成功数和失败数
+     **/
+    public Map<String, Integer> sendGeneratorMonitoring(IotDataTransferVO transferVO) {
+        Map<String, Integer> result = new HashMap<>();
+        result.put("successCount", 0);
+        result.put("failureCount", 0);
+
+        if (!validateMqttGateway(transferVO.getUsername())) {
+            return result;
+        }
+
+        LocalDateTime now = LocalDateTime.now();
+        long startTime = System.currentTimeMillis();
+        long endTime;
+
+        List<JSONObject> deviceData = deviceDataQuery.getDeviceData(transferVO);
+        Integer deviceType = transferVO.getDeviceType();
+        Integer totalDevices = transferVO.getDevices().size();
+
+        try {
+
+            log.info("开始推送【战时柴油发电机每日启动时长】数据,设备类型:{},设备数量:{},获取到的数据条数:{}",
+                    deviceType, totalDevices, deviceData.size());
+
+            if (deviceData.isEmpty()) {
+                log.warn("没有获取到战时柴油发电机每日启动时长数据!设备类型:{}", deviceType);
+                result.put("failureCount", totalDevices);
+                return result;
+            }
+
+            Long engineeringId = transferVO.getEngineeringId();
+            for (JSONObject deviceDataItem : deviceData) {
+                LocalDateTime dataEndTime = parseDataTime(deviceDataItem);
+                if (dataEndTime == null) {
+                    result.put("failureCount", result.get("failureCount") + 1);
+                    continue;
+                }
+                dataEndTime = addTimeOffset(dataEndTime);
+
+                Integer deviceId = deviceDataItem.getIntValue("device_id");
+                Integer startDuration = deviceDataItem.get("startDuration") == null ? 0 : deviceDataItem.getIntValue("startDuration");
+
+                GeneratorMonitoringVO vo = new GeneratorMonitoringVO();
+                vo.setDataPacketID(generateDataPacketID());
+                vo.setSensorID(deviceId);
+                vo.setEngineeringID(engineeringId);
+                vo.setPublishTime(getCurrentTime());
+                vo.setDataEndTime(dataEndTime);
+                vo.setSensorDate(dataEndTime.toLocalDate());
+                vo.setStartDuration(startDuration);
+                if (deviceDataItem.containsKey("batteryVoltage")) {
+                    vo.setBatteryVoltage(deviceDataItem.getDouble("batteryVoltage"));
+                }
+
+                try {
+                    log.info("【战时柴油发电机每日启动时长】开始推送,设备ID:{},数据:{}", deviceId, JSON.toJSONString(vo));
+                    sendMqttMessage(MqttTopics.IotInfo.ALTERNATOR_STARTUP_TIME.getTopic(), vo, MqttTopics.IotInfo.ALTERNATOR_STARTUP_TIME.getDesc(), transferVO.getUsername());
+                    result.put("successCount", result.get("successCount") + 1);
+                } catch (Exception e) {
+                    log.warn("设备{}的战时柴油发电机每日启动时长数据推送失败:{}", deviceId, e.getMessage());
+                    result.put("failureCount", result.get("failureCount") + 1);
+                }
+            }
+
+            log.info("战时柴油发电机每日启动时长数据推送完成,设备类型:{},成功:{},失败:{}",
+                    deviceType, result.get("successCount"), result.get("failureCount"));
+
+            endTime = System.currentTimeMillis();
+            saveLog(transferVO, now, startTime, endTime, totalDevices,
+                    result.get("successCount"), result.get("failureCount"),
+                    totalDevices - result.get("successCount") - result.get("failureCount"), 1, SecurityUtils.getUsername());
+            return result;
+        } catch (Exception e) {
+            log.error("战时柴油发电机每日启动时长数据推送发生异常", e);
+            result.put("failureCount", transferVO.getDevices().size());
+            endTime = System.currentTimeMillis();
+            saveLog(transferVO, now, startTime, endTime, totalDevices,
+                    result.get("successCount"), result.get("failureCount"),
+                    totalDevices - result.get("successCount") - result.get("failureCount"), 0, SecurityUtils.getUsername());
+            return result;
+        }
+    }
+
     /**
      * 同步设备数据
      * @param tenantId 租户ID
@@ -1247,6 +1333,7 @@ public class IotDataTransferService {
                     }
                     result = sendPersonPresence(transferVO);
                     break;
+                case 705:
                 case 704:
                     if (tenantId == 1205) {
                         // 设置默认值,避免空指针
@@ -1271,6 +1358,9 @@ public class IotDataTransferService {
                 case 719:
                     result = sendPersonCount(transferVO);
                     break;
+                case 720:
+                    result = sendGeneratorMonitoring(transferVO);
+                    break;
                 default:
                     log.debug("不支持的设备类型:{}", deviceType);
                     continue;
@@ -1428,8 +1518,7 @@ public class IotDataTransferService {
      * 序列化楼层平面图VO(将 floorFile byte[] 转为 Base64 字符串)
      */
     private String serializeFloorPlaneVO(com.usky.cdi.service.vo.info.FloorPlaneVO vo) {
-        com.alibaba.fastjson.JSONObject jsonObject = new com.alibaba.fastjson.JSONObject();
-
+        Map<String, Object> jsonObject = new HashMap<>();
         jsonObject.put("dataPacketID", vo.getDataPacketID());
         jsonObject.put("engineeringID", vo.getEngineeringID());
         jsonObject.put("floor", vo.getFloor());
@@ -1439,18 +1528,7 @@ public class IotDataTransferService {
         jsonObject.put("filePixWidth", vo.getFilePixWidth());
         jsonObject.put("filePixHeight", vo.getFilePixHeight());
         jsonObject.put("publishTime", vo.getPublishTime());
-
-        // 关键:将 byte[] 转为 Base64 字符串
-        if (vo.getFloorFile() != null) {
-            String base64File = java.util.Base64.getEncoder().encodeToString(vo.getFloorFile());
-            jsonObject.put("floorFile", base64File);
-            log.info("平面图文件转换Base64成功,FileID: {}, 原始大小: {} bytes, Base64长度: {}",
-                    vo.getFloorFileID(), vo.getFloorFile().length, base64File.length());
-        } else {
-            jsonObject.put("floorFile", "");
-            log.warn("平面图文件为空,FileID: {}", vo.getFloorFileID());
-        }
-
+        jsonObject.put("floorFile", vo.getFloorFile());
         Gson gson = new Gson();
         return gson.toJson(jsonObject);
     }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 12 - 1
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/util/DeviceDataQuery.java

@@ -501,7 +501,12 @@ public class DeviceDataQuery {
                         double value = FixedWaterLevelGenerator.getSensorValue(deviceId1);
                         simData.put("sensorValue", formatNumber(value, FORMAT_1_2));
                         break;
-
+                    // case 719:
+                    //     break;
+                    case 720:
+                        simData.put("startDuration", 0);
+                        simData.put("batteryVoltage", randomDouble261_266());
+                        break;
                     default:
                         log.warn("未知设备类型:{},无法生成模拟数据", deviceType);
                         continue;
@@ -515,6 +520,12 @@ public class DeviceDataQuery {
         return simulationList;
     }
 
+    private double randomDouble261_266() {
+        Random random = new Random();
+        int intVal = 2610 + random.nextInt(51);
+        return intVal / 100.0;
+    }
+
     private void addNoiseToNumericFields(JSONObject data, Integer deviceType) {
         Map<String, Double> noiseConfig = getNoiseConfigByDeviceType(deviceType);
 

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

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

@@ -170,4 +170,4 @@ public class WeatherFetcher {
     public static void main(String[] args) {
         fetchWeather();
     }
-}
+}

+ 49 - 0
service-cdi/service-cdi-biz/src/main/java/com/usky/cdi/service/vo/info/GeneratorMonitoringVO.java

@@ -0,0 +1,49 @@
+package com.usky.cdi.service.vo.info;
+
+import com.alibaba.fastjson.annotation.JSONField;
+import com.usky.cdi.service.vo.base.BaseEnvMonitorPushVO;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDate;
+
+/**
+ * 发电机蓄电池监测数据
+ * @author fyc
+ * @email yuchuan.fu@chinausky.com
+ * @date 2026/6/18
+ */
+@EqualsAndHashCode(callSuper = true)
+@Data
+public class GeneratorMonitoringVO extends BaseEnvMonitorPushVO {
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 监测日期(必填)
+     * 类型:Date(yyyy-MM-dd)
+     */
+    @JSONField(format = "yyyy-MM-dd")
+    private LocalDate sensorDate;
+
+    /**
+     * 启动时长(必填)
+     * 类型:Int,长度4,单位分钟
+     */
+    private Integer startDuration;
+
+    /**
+     * 蓄电池电压(非必填)
+     * 类型:Float(2,2),单位V
+     */
+    private Double batteryVoltage;
+
+    @Override
+    public Number getSensorValue() {
+        return null;
+    }
+
+    @Override
+    protected void validateSensorValue() {
+
+    }
+}

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

+ 17 - 0
service-data/pom.xml

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

+ 33 - 0
service-data/service-data-api/pom.xml

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

+ 96 - 0
service-data/service-data-biz/pom.xml

@@ -0,0 +1,96 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>service-data</artifactId>
+        <groupId>com.usky</groupId>
+        <version>0.0.1</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>service-data-biz</artifactId>
+    <dependencies>
+        <dependency>
+            <groupId>com.usky</groupId>
+            <artifactId>common-cloud-starter</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <!-- Pagehelper -->
+        <dependency>
+            <groupId>com.github.pagehelper</groupId>
+            <artifactId>pagehelper-spring-boot-starter</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.usky</groupId>
+            <artifactId>ruoyi-common-swagger</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.baomidou</groupId>
+            <artifactId>mybatis-plus-boot-starter</artifactId>
+        </dependency>
+
+        <!--Redis依赖-->
+<!--        <dependency>-->
+<!--            <groupId>org.springframework.boot</groupId>-->
+<!--            <artifactId>spring-boot-starter-data-redis</artifactId>-->
+<!--        </dependency>-->
+
+<!--        <dependency>-->
+<!--            <groupId>com.usky</groupId>-->
+<!--            <artifactId>usky-common-redis</artifactId>-->
+<!--        </dependency>-->
+
+    </dependencies>
+
+    <build>
+        <finalName>${project.artifactId}</finalName>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.2.6.RELEASE</version>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+            <plugin>
+                <groupId>com.github.shalousun</groupId>
+                <artifactId>smart-doc-maven-plugin</artifactId>
+                <version>2.1.1</version>
+                <configuration>
+                    <!--指定生成文档的使用的配置文件,配置文件放在自己的项目中-->
+                    <configFile>./src/main/resources/smart-doc.json</configFile>
+                    <!--指定项目名称-->
+                    <projectName>test</projectName>
+                    <!--                    <excludes>-->
+                    <!--                        <exclude>com.bizmatics:product-service-provider</exclude>-->
+                    <!--                        <exclude>cn.afterturn:easypoi-web</exclude>-->
+                    <!--                    </excludes>-->
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
+</project>

+ 50 - 0
service-data/service-data-biz/src/main/java/com/usky/data/DataApplicationRun.java

@@ -0,0 +1,50 @@
+package com.usky.data;
+
+
+import com.ruoyi.common.swagger.annotation.EnableCustomSwagger2;
+import org.mybatis.spring.annotation.MapperScan;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.cloud.openfeign.EnableFeignClients;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.core.env.Environment;
+import org.springframework.scheduling.annotation.EnableAsync;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+
+/**
+ * 应用启动模块
+ *
+ * @author fyc
+ */
+
+
+@EnableCustomSwagger2
+//@EnableSwagger2
+@EnableFeignClients(basePackages = "com.usky")
+@MapperScan(value = "com.usky.data.mapper")
+@ComponentScan(basePackages = {"com.usky"})
+@EnableAsync
+@SpringBootApplication
+public class DataApplicationRun {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(DataApplicationRun.class);
+
+    public static void main(String[] args) throws UnknownHostException {
+        ConfigurableApplicationContext application = SpringApplication.run(DataApplicationRun.class, args);
+        Environment env = application.getEnvironment();
+        String ip = InetAddress.getLocalHost().getHostAddress();
+        String port = env.getProperty("server.port");
+        String path = env.getProperty("server.servlet.context-path");
+        LOGGER.info("\n----------------------------------------------------------\n\t" +
+                "Application is running! Access URLs:\n\t" +
+                "Local: \t\thttp://localhost:" + port + (null == path ? "" : path) + "/\n\t" +
+                "External: \thttp://" + ip + ":" + port + (null == path ? "" : path) + "/\n\t" +
+                "Api: \t\thttp://" + ip + ":" + port + (null == path ? "" : path) + "/swagger-ui/index.html\n\t" +
+                "----------------------------------------------------------");
+    }
+}

+ 106 - 0
service-data/service-data-biz/src/main/java/com/usky/data/MybatisGenerator.java

@@ -0,0 +1,106 @@
+package com.usky.data;
+
+import com.baomidou.mybatisplus.core.toolkit.StringPool;
+import com.baomidou.mybatisplus.generator.AutoGenerator;
+import com.baomidou.mybatisplus.generator.InjectionConfig;
+import com.baomidou.mybatisplus.generator.config.*;
+import com.baomidou.mybatisplus.generator.config.po.TableInfo;
+import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ */
+public class MybatisGenerator {
+    public static void main(String[] args) {
+
+        shell("service-data", "service-data-biz");
+    }
+
+    private static void shell(String parentName, String model) {
+
+        AutoGenerator mpg = new AutoGenerator();
+        // 1、全局配置
+        GlobalConfig gc = new GlobalConfig();
+//        File file = new File(model);
+//        String path = file.getAbsolutePath();
+        String projectPath = System.getProperty("user.dir");
+        projectPath += "/" + parentName;
+        projectPath += "/" + model;
+        gc.setOutputDir(projectPath + "/src/main/java");  // 生成路径(一般都是生成在此项目的src/main/java下面)
+        // 修改为自己的名字
+        gc.setAuthor("fyc"); // 设置作者
+        gc.setOpen(false);
+        gc.setFileOverride(true); // 第二次生成会把第一次生成的覆盖掉
+        gc.setServiceName("%sService"); // 生成的service接口名字首字母是否为I,这样设置就没有
+        gc.setBaseResultMap(true); // 生成resultMap
+        mpg.setGlobalConfig(gc);
+
+        // 2、数据源配置
+        // 修改数据源
+        DataSourceConfig dsc = new DataSourceConfig();
+        dsc.setUrl("jdbc:mysql://192.168.10.165:3306/usky-cloud?useUnicode=true&serverTimezone=GMT&useSSL=false&characterEncoding=utf8");
+        dsc.setDriverName("com.mysql.jdbc.Driver");
+        dsc.setUsername("root");
+        dsc.setPassword("yt123456");
+        mpg.setDataSource(dsc);
+
+        // 3、包配置
+        PackageConfig pc = new PackageConfig();
+        pc.setParent("com.usky.data");
+        pc.setController("controller.web");
+        pc.setEntity("domain");
+        pc.setMapper("mapper");
+        pc.setService("service");
+        pc.setServiceImpl("service.impl");
+//        pc.setXml("mapper.data");
+        // pc.setModuleName("test");
+        mpg.setPackageInfo(pc);
+
+        // 4、策略配置
+        StrategyConfig strategy = new StrategyConfig();
+        strategy.setNaming(NamingStrategy.underline_to_camel);
+        strategy.setColumnNaming(NamingStrategy.underline_to_camel);
+        strategy.setSuperMapperClass("com.usky.common.mybatis.core.CrudMapper");
+        strategy.setSuperServiceClass("com.usky.common.mybatis.core.CrudService");
+        strategy.setSuperServiceImplClass("com.usky.common.mybatis.core.AbstractCrudService");
+        // strategy.setTablePrefix("t_"); // 表名前缀
+        strategy.setEntityLombokModel(true); // 使用lombok
+        // 修改自己想要生成的表
+        String[] tables = {"data_interface","data_interface_log"};
+        strategy.setInclude(tables);  // 逆向工程使用的表   如果要生成多个,这里可以传入String[]
+        mpg.setStrategy(strategy);
+
+        // 关闭默认 xml 生成,调整生成 至 根目录
+        // 修改对应的模块名称
+        TemplateConfig tc = new TemplateConfig();
+        // 自定义配置
+        InjectionConfig cfg = new InjectionConfig() {
+            @Override
+            public void initMap() {
+                // to do nothing
+            }
+        };
+        // 如果模板引擎是 velocity
+        String templatePath = "/templates/mapper.xml.vm";
+        // 自定义输出配置
+        List<FileOutConfig> focList = new ArrayList<>();
+        // 自定义配置会被优先输出
+        String finalProjectPath = projectPath;
+        focList.add(new FileOutConfig(templatePath) {
+            @Override
+            public String outputFile(TableInfo tableInfo) {
+                // 自定义输出文件名 , 如果你 Entity 设置了前后缀、此处注意 xml 的名称会跟着发生变化!!
+                return finalProjectPath + "/src/main/resources/mapper/data" + "/"
+                        + tableInfo.getEntityName() + "Mapper" + StringPool.DOT_XML;
+            }
+        });
+        cfg.setFileOutConfigList(focList);
+        mpg.setCfg(cfg);
+        tc.setXml(null);
+        mpg.setTemplate(tc);
+        // 5、执行
+        mpg.execute();
+    }
+}

+ 222 - 0
service-data/service-data-biz/src/main/java/com/usky/data/controller/web/DataInterfaceController.java

@@ -0,0 +1,222 @@
+package com.usky.data.controller.web;
+
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.common.security.utils.SecurityUtils;
+import com.usky.data.domain.DataInterface;
+import com.usky.data.service.DataInterfaceService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * <p>
+ * 数据中心_数据接口表 前端控制器
+ * </p>
+ *
+ * @author fyc
+ * @since 2026-01-24
+ */
+@RestController
+@RequestMapping("/dataInterface")
+public class DataInterfaceController {
+
+    @Autowired
+    private DataInterfaceService dataInterfaceService;
+
+    /**
+     * 获取数据接口列表
+     *
+     * @param currentPage 当前页
+     * @param pageSize    每页数量
+     * @param keyword     关键字
+     * @param type        接口类型 1:SQL操作, 2:静态数据, 3:API操作
+     * @param enabledMark 启用状态 0:未启用,1:已启用
+     * @param category    接口分类
+     * @return
+     */
+    @RequestMapping
+    @GetMapping
+    public ApiResult<CommonPage<DataInterface>> getDataInterfaceList(@RequestParam(value = "currentPage", defaultValue = "1") Integer currentPage,
+                                                                     @RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize,
+                                                                     @RequestParam(value = "keyword", required = false) String keyword,
+                                                                     @RequestParam(value = "type", required = false) String type,
+                                                                     @RequestParam(value = "enabledMark", required = false) Integer enabledMark,
+                                                                     @RequestParam(value = "category", required = false) String category
+    ) {
+        return ApiResult.success(dataInterfaceService.getDataInterfaceList(currentPage, pageSize, keyword, type, enabledMark, category));
+    }
+
+    /**
+     * 获取接口列表(工作流选择时调用)
+     *
+     * @param currentPage 当前页
+     * @param pageSize    每页数量
+     * @param keyword     关键字
+     * @param type        接口类型 1:静态数据, 2:SQL操作, 3:API操作
+     * @param enabledMark 启用状态 0:未启用,1:已启用
+     * @return
+     */
+    @GetMapping("/getList")
+    public ApiResult<CommonPage<DataInterface>> getWorkflowList(@RequestParam(value = "currentPage", defaultValue = "1") Integer currentPage,
+                                                                @RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize,
+                                                                @RequestParam(value = "keyword", required = false) String keyword,
+                                                                @RequestParam(value = "type", required = false) String type,
+                                                                @RequestParam(value = "enabledMark", required = false) Integer enabledMark
+    ) {
+        return ApiResult.success(dataInterfaceService.getWorkflowList(currentPage, pageSize, keyword, type, enabledMark));
+    }
+
+    /**
+     * 获取接口列表下拉框
+     *
+     * @return
+     */
+    @GetMapping("/Selector")
+    public ApiResult<List<DataInterface>> getSelector() {
+        return ApiResult.success(dataInterfaceService.getSelectorList());
+    }
+
+    /**
+     * 获取接口参数列表下拉框
+     *
+     * @param id 主键
+     * @return
+     */
+    @GetMapping("/getParam/{id}")
+    public ApiResult<String> getInterfaceParams(@PathVariable("id") String id) {
+        return ApiResult.success(dataInterfaceService.getInterfaceParameters(id));
+    }
+
+    /**
+     * 获取接口数据
+     *
+     * @param id 主键
+     * @return
+     */
+    @GetMapping("/{id}")
+    public ApiResult<DataInterface> getInterfaceInfo(@PathVariable("id") String id) {
+        return ApiResult.success(dataInterfaceService.getById(id));
+    }
+
+    /**
+     * 添加接口
+     *
+     * @param dataInterface 接口实体
+     * @return
+     */
+    @PostMapping
+    public ApiResult<Boolean> addInterface(@RequestBody DataInterface dataInterface) {
+        return ApiResult.success(dataInterfaceService.addInterface(dataInterface));
+    }
+
+    /**
+     * 修改接口
+     *
+     * @param dataInterface 接口实体
+     * @return
+     */
+    @PutMapping
+    public ApiResult<Boolean> updateInterface(@RequestBody DataInterface dataInterface) {
+        return ApiResult.success(dataInterfaceService.updateInterface(dataInterface));
+    }
+
+    /**
+     * 删除接口
+     *
+     * @param id 主键
+     * @return
+     */
+    @DeleteMapping("/{id}")
+    public ApiResult<Boolean> deleteInterface(@PathVariable("id") Long id) {
+        return ApiResult.success(dataInterfaceService.deleteInterface(id));
+    }
+
+    /**
+     * 更新接口状态
+     *
+     * @param id 主键
+     * @return
+     */
+    @PutMapping("/{id}/Actions/State")
+    public ApiResult<Boolean> updateInterfaceState(@PathVariable("id") String id) {
+        return ApiResult.success(dataInterfaceService.updateInterfaceStatus(id));
+    }
+
+    /**
+     * 测试接口
+     *
+     * @param id     主键
+     * @param params 参数、参数值对象
+     * @return
+     */
+    @PostMapping("/{id}/actions/preview")
+    public ApiResult<?> testInterface(@PathVariable("id") String id, @RequestBody(required = false) Map<String, Object> params) {
+        return dataInterfaceService.testInterface(id, params);
+    }
+
+    /**
+     * 访问接口GET
+     *
+     * @param id     主键
+     * @param params 参数、参数值对象
+     * @return
+     */
+    @GetMapping("/{id}/actions/response")
+    public ApiResult<?> getInterfaceResponse(@PathVariable("id") String id, @RequestParam(required = false) Map<String, String> params) {
+        return dataInterfaceService.getInterfaceResponse(id, params);
+    }
+
+    /**
+     * 访问接口POST
+     *
+     * @param id     主键
+     * @param params 参数、参数值对象
+     * @return
+     */
+    @PostMapping("/{id}/actions/response")
+    public ApiResult<?> postInterfaceResponse(@PathVariable("id") String id, @RequestBody(required = false) Map<String, String> params) {
+        return dataInterfaceService.postInterfaceResponse(id, params);
+    }
+
+    /**
+     * 获取接口分页数据
+     *
+     * @param id         主键
+     * @param pageParams 分页参数
+     * @return
+     */
+    @PostMapping("/{id}/actions/list")
+    public ApiResult<?> getInterfacePageData(@PathVariable("id") String id, @RequestBody Map<String, Object> pageParams) {
+        return dataInterfaceService.getInterfacePageData(id, pageParams);
+    }
+
+    /**
+     * 获取接口详情数据
+     *
+     * @param id         主键
+     * @param pageParams 分页参数
+     * @return
+     */
+    @PostMapping("/{id}/actions/infoByIds")
+    public ApiResult<List<Map<String, Object>>> getInterfaceDetailData(@PathVariable("id") String id, @RequestBody Map<String, Object> pageParams) {
+        return dataInterfaceService.getInterfaceDetailData(id, pageParams);
+    }
+
+    /**
+     * 获取接口字段
+     *
+     * @param id         主键
+     * @param pageParams 参数、参数值
+     * @return
+     */
+    @PostMapping("/{id}/actions/getFields")
+    public ApiResult<List<String>> getInterfaceFields(@PathVariable("id") String id, @RequestBody(required = false) Map<String, Object> pageParams) {
+        return dataInterfaceService.getInterfaceFields(id, pageParams);
+    }
+}
+

+ 21 - 0
service-data/service-data-biz/src/main/java/com/usky/data/controller/web/DataInterfaceLogController.java

@@ -0,0 +1,21 @@
+package com.usky.data.controller.web;
+
+
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import org.springframework.stereotype.Controller;
+
+/**
+ * <p>
+ * 数据中心_接口调用日志表 前端控制器
+ * </p>
+ *
+ * @author fyc
+ * @since 2026-01-24
+ */
+@Controller
+@RequestMapping("/dataInterfaceLog")
+public class DataInterfaceLogController {
+
+}
+

+ 229 - 0
service-data/service-data-biz/src/main/java/com/usky/data/controller/web/DataLinkController.java

@@ -0,0 +1,229 @@
+package com.usky.data.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.common.core.exception.BusinessException;
+import com.usky.data.domain.DataLink;
+import com.usky.data.model.DbLinkBaseForm;
+import com.usky.data.model.DbLinkCreUpForm;
+import com.usky.data.model.PaginationDbLink;
+import com.usky.data.service.DataLinkService;
+import com.usky.data.vo.*;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.Parameters;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 数据源配置控制器
+ *
+ * @author fyc
+ * @since 2026-01-23
+ */
+@Tag(name = "数据连接", description = "DataSource")
+@RestController
+@RequestMapping("/dataSource")
+public class DataLinkController {
+
+    @Autowired
+    private DataLinkService dataLinkService;
+
+    /**
+     * 获取数据连接下拉框列表
+     */
+    @GetMapping("/selector")
+    @Operation(summary = "获取数据连接下拉框列表")
+    public ApiResult<ListVO<DbLinkSelectorListVO>> selectorList(@RequestParam(value = "dbType") String type) {
+        List<DbLinkListVO> modelAll = new LinkedList<>();
+        List<DataLink> dbLinkList = dataLinkService.getList();
+
+        // 转换为VO列表
+        for (DataLink entity : dbLinkList) {
+            DbLinkListVO model = new DbLinkListVO();
+            BeanUtils.copyProperties(entity, model);
+            modelAll.add(model);
+        }
+
+        // 按数据库类型分组
+        Map<String, List<DbLinkListVO>> groupByType = modelAll.stream()
+                .collect(Collectors.groupingBy(DbLinkListVO::getDbType));
+
+        List<DbLinkSelectorListVO> list = new ArrayList<>();
+        // type为空时返回默认库
+        if (type == null) {
+            DbLinkListVO dbLink = new DbLinkListVO();
+            dbLink.setFullName("默认数据库");
+            dbLink.setId(0);
+            // 默认数据库类型
+            dbLink.setDbType("mysql");
+            DbLinkSelectorListVO defaultDb = new DbLinkSelectorListVO();
+            defaultDb.setFullName("");
+            defaultDb.setChildren(Collections.singletonList(dbLink));
+            list.add(defaultDb);
+        }
+
+        // 添加分组后的数据源
+        for (Map.Entry<String, List<DbLinkListVO>> entry : groupByType.entrySet()) {
+            DbLinkSelectorListVO selector = new DbLinkSelectorListVO();
+            selector.setFullName(entry.getKey());
+            selector.setChildren(entry.getValue());
+            list.add(selector);
+        }
+
+        return ApiResult.success(new ListVO<>(list));
+    }
+
+    /**
+     * 获取数据连接列表
+     */
+    @GetMapping
+    @Operation(summary = "获取数据连接列表")
+    public ApiResult<PageListVO<DbLinkListVO>> getList(@RequestBody PaginationDbLink page) {
+        List<DataLink> data = dataLinkService.getList(
+                page.getCurrentPage(),
+                page.getPageSize(),
+                page.getKeyword()
+        );
+
+        // 转换为VO列表
+        List<DbLinkListVO> jsonToList = new ArrayList<>();
+        for (DataLink entity : data) {
+            DbLinkListVO vo = new DbLinkListVO();
+            BeanUtils.copyProperties(entity, vo);
+            jsonToList.add(vo);
+        }
+
+        // 构建分页信息
+        PaginationVO paginationVO = new PaginationVO();
+        paginationVO.setCurrentPage(page.getCurrentPage());
+        paginationVO.setPageSize(page.getPageSize());
+        paginationVO.setTotalRows(data.size());
+        paginationVO.setTotalPages((int) Math.ceil((double) data.size() / page.getPageSize()));
+
+        return ApiResult.success(new PageListVO<>(jsonToList, paginationVO));
+    }
+
+    /**
+     * 获取数据连接列表分组
+     */
+    @GetMapping("/getGroup")
+    @Operation(summary = "获取数据连接列表分组")
+    public ApiResult<List<Map<String, Object>>> getGroup(@RequestBody PaginationDbLink page) {
+        ApiResult<PageListVO<DbLinkListVO>> result = getList(page);
+        List<DbLinkListVO> voList = result.getData().getList();
+
+        // 按host分组
+        Set<String> hostSet = voList.stream()
+                .sorted(Comparator.comparing(DbLinkListVO::getHost))
+                .map(DbLinkListVO::getHost)
+                .collect(Collectors.toSet());
+
+        List<Map<String, Object>> groupList = new ArrayList<>();
+        for (String host : hostSet) {
+            Map<String, Object> groupMap = new HashMap<>();
+            groupMap.put("host", host);
+            groupMap.put("options", voList.stream()
+                    .filter(vo -> vo.getHost().equals(host))
+                    .toArray());
+            groupList.add(groupMap);
+        }
+
+        return ApiResult.success(groupList);
+    }
+
+    /**
+     * 获取单条数据连接
+     */
+    @GetMapping("/{id}")
+    @Operation(summary = "获取数据连接")
+    @Parameters({
+            @Parameter(name = "id", description = "主键")
+    })
+    public ApiResult<DbLinkInfoVO> get(@PathVariable("id") Integer id) {
+        DataLink entity = dataLinkService.getById(id);
+        if (entity == null) {
+            return ApiResult.error("数据源不存在");
+        }
+        DbLinkInfoVO vo = new DbLinkInfoVO();
+        return ApiResult.success(vo.getDbLinkInfoVO(entity));
+    }
+
+    /**
+     * 新建数据连接
+     */
+    @PostMapping
+    @Operation(summary = "添加数据连接")
+    @Parameters({
+            @Parameter(name = "dbLinkCreUpForm", description = "新建数据连接表单对象", required = true)
+    })
+    public ApiResult<String> create(@RequestBody @Validated DbLinkCreUpForm dbLinkCreUpForm) {
+        DataLink entity = dbLinkCreUpForm.getDbLinkEntity(dbLinkCreUpForm);
+        if (dataLinkService.isExistByFullName(entity.getFullName(), null)) {
+            return ApiResult.error("连接名称已存在");
+        }
+        return ApiResult.success(dataLinkService.save(entity) ? "新增成功" : "新增失败");
+    }
+
+    /**
+     * 更新数据连接
+     */
+    @PutMapping("/{id}")
+    @Operation(summary = "修改数据连接")
+    @Parameters({
+            @Parameter(name = "id", description = "主键", required = true),
+            @Parameter(name = "dbLinkCreUpForm", description = "更新数据连接表单对象", required = true)
+    })
+    public ApiResult<String> update(@PathVariable("id") Integer id, @RequestBody @Validated DbLinkCreUpForm dbLinkCreUpForm) {
+        dbLinkCreUpForm.setId(id);
+        DataLink entity = dbLinkCreUpForm.getDbLinkEntity(dbLinkCreUpForm);
+        if (dataLinkService.isExistByFullName(entity.getFullName(), id)) {
+            throw new BusinessException("连接名称已存在");
+        }
+        if (!dataLinkService.updateById(entity)) {
+            return ApiResult.error("更新失败");
+        }
+        return ApiResult.success("更新成功");
+    }
+
+    /**
+     * 删除数据连接
+     */
+    @DeleteMapping("/{id}")
+    @Operation(summary = "删除数据连接")
+    @Parameters({
+            @Parameter(name = "id", description = "主键", required = true)
+    })
+    public ApiResult<String> delete(@PathVariable("id") Integer id) {
+        DataLink entity = dataLinkService.getById(id);
+        if (entity != null) {
+            dataLinkService.removeById(entity);
+            return ApiResult.success("删除成功");
+        }
+        return ApiResult.error("数据源不存在");
+    }
+
+    /**
+     * 测试连接
+     */
+    @PostMapping("/actions/test")
+    @Operation(summary = "测试连接")
+    @Parameters({
+            @Parameter(name = "dbLinkBaseForm", description = "数据连接参数", required = true)
+    })
+    public ApiResult<String> test(@RequestBody DbLinkBaseForm dbLinkBaseForm) throws Exception {
+        boolean data = dataLinkService.testDbConnection(dbLinkBaseForm.getDbLinkEntity(dbLinkBaseForm));
+        if (data) {
+            return ApiResult.success("连接成功");
+        } else {
+            return ApiResult.error("连接失败");
+        }
+    }
+}
+

+ 179 - 0
service-data/service-data-biz/src/main/java/com/usky/data/domain/DataInterface.java

@@ -0,0 +1,179 @@
+package com.usky.data.domain;
+
+import java.time.LocalDateTime;
+import java.io.Serializable;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+
+/**
+ * <p>
+ * 数据中心_数据接口表
+ * </p>
+ *
+ * @author fyc
+ * @since 2026-01-24
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+public class DataInterface implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键ID
+     */
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 接口名称
+     */
+    @NotBlank(message = "接口名称不能为空")
+    private String fullName;
+
+    /**
+     * 接口编码
+     */
+    @NotBlank(message = "接口编码不能为空")
+    private String enCode;
+
+    /**
+     * 接口分类
+     */
+    private String category;
+
+    /**
+     * 接口类型(1:静态数据, 2:SQL操作, 3:API操作)
+     */
+    @NotNull(message = "接口类型不能为空")
+    private Integer type;
+
+    /**
+     * 操作类型()
+     */
+    private Integer action;
+
+    /**
+     * 分页(0-禁用,1-启用)
+     */
+    private Integer hasPage;
+
+    /**
+     * 后置接口(0-否 1-是)
+     */
+    private Integer isPostposition;
+
+    /**
+     * 字段JSON
+     */
+    private String fieldJson;
+
+    /**
+     * 数据配置JSON
+     */
+    @TableField("config_json")
+    @NotBlank(message = "数据配置JSON不能为空")
+    private String dataConfigJson;
+
+    /**
+     * 数据统计json
+     */
+    @TableField("count_json")
+    @NotBlank(message = "数据统计json不能为空")
+    private String dataCountJson;
+
+    /**
+     * 数据回显json
+     */
+    @TableField("echo_json")
+    @NotBlank(message = "数据回显json不能为空")
+    private String dataEchoJson;
+
+    /**
+     * 数据处理json
+     */
+    @TableField("js_json")
+    @NotBlank(message = "数据处理json不能为空")
+    private String dataJsJson;
+
+    /**
+     * 参数配置JSON
+     */
+    private String parameterJson;
+
+    /**
+     * 异常验证JSON
+     */
+    private String exceptionJson;
+
+    /**
+     * 描述或说明
+     */
+    private String description;
+
+    /**
+     * 启用状态(0-禁用,1-启用)
+     */
+    private Integer enabledMark;
+
+    /**
+     * 排序
+     */
+    private Long sortCode;
+
+    /**
+     * 创建者
+     */
+    @NotBlank(message = "创建人不能为空")
+    private String createBy;
+
+    /**
+     * 创建时间
+     */
+    @NotNull(message = "创建时间不能为空")
+    private LocalDateTime createTime;
+
+    /**
+     * 更新者
+     */
+    @NotBlank(message = "更新人不能为空")
+    private String updateBy;
+
+    /**
+     * 更新时间
+     */
+    @NotNull(message = "更新时间不能为空")
+    private LocalDateTime updateTime;
+
+    /**
+     * 删除时间
+     */
+    @NotNull(message = "删除时间不能为空")
+    private LocalDateTime deleteTime;
+
+    /**
+     * 删除用户
+     */
+    @NotBlank(message = "删除人不能为空")
+    private String deleteBy;
+
+    /**
+     * 是否删除 0:未删除,1:已删除
+     */
+    private Integer isDelete;
+
+    /**
+     * 租户ID
+     */
+    private Integer tenantId;
+
+
+}

+ 122 - 0
service-data/service-data-biz/src/main/java/com/usky/data/domain/DataInterfaceLog.java

@@ -0,0 +1,122 @@
+package com.usky.data.domain;
+
+import java.time.LocalDateTime;
+import java.io.Serializable;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * <p>
+ * 数据中心_接口调用日志表
+ * </p>
+ *
+ * @author fyc
+ * @since 2026-01-24
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+public class DataInterfaceLog implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键ID
+     */
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 调用接口ID
+     */
+    private Long interfaceId;
+
+    /**
+     * 执行时间
+     */
+    private LocalDateTime executeTime;
+
+    /**
+     * 操作用户ID
+     */
+    private String userId;
+
+    /**
+     * 请求ip
+     */
+    private String requestIp;
+
+    /**
+     * 请求设备
+     */
+    private String requestDevice;
+
+    /**
+     * 请求类型
+     */
+    private String requestType;
+
+    /**
+     * 请求耗时
+     */
+    private Integer requestWasteTime;
+
+    /**
+     * 接口授权AppId
+     */
+    private String oauthAppId;
+
+    /**
+     * 排序
+     */
+    private Long sortCode;
+
+    /**
+     * 执行结果
+     */
+    private String executeResult;
+
+    /**
+     * 执行状态
+     */
+    private Integer executeState;
+
+    /**
+     * 执行消息
+     */
+    private String executeMsg;
+
+    /**
+     * 创建者
+     */
+    private String createBy;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createTime;
+
+    /**
+     * 更新者
+     */
+    private String updateBy;
+
+    /**
+     * 更新时间
+     */
+    private LocalDateTime updateTime;
+
+    /**
+     * 部门ID
+     */
+    private Long deptId;
+
+    /**
+     * 租户ID
+     */
+    private Integer tenantId;
+
+
+}

+ 136 - 0
service-data/service-data-biz/src/main/java/com/usky/data/domain/DataLink.java

@@ -0,0 +1,136 @@
+package com.usky.data.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import java.time.LocalDateTime;
+import java.io.Serializable;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * <p>
+ * 数据中心_数据连接表
+ * </p>
+ *
+ * @author fyc
+ * @since 2026-01-23
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+public class DataLink implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键ID
+     */
+    @TableId(value = "id", type = IdType.AUTO)
+    private Integer id;
+
+    /**
+     * 连接名称
+     */
+    private String fullName;
+
+    /**
+     * 连接驱动
+     */
+    private String dbType;
+
+    /**
+     * 主机地址
+     */
+    private String host;
+
+    /**
+     * 端口
+     */
+    private Integer port;
+
+    /**
+     * 数据库用户名
+     */
+    private String dbName;
+
+    /**
+     * 密码
+     */
+    private String password;
+
+    /**
+     * 服务名称
+     */
+    private String serviceName;
+
+    /**
+     * 描述或说明
+     */
+    private String description;
+
+    /**
+     * 模式
+     */
+    private String dbSchema;
+
+    /**
+     * 表空间
+     */
+    private String tableSpace;
+
+    /**
+     * oracle连接参数
+     */
+    private String oracleParam;
+
+    /**
+     * Oracle扩展开关 1:开启 0:关闭
+     */
+    private Integer oracleExtend;
+
+    /**
+     * 排序
+     */
+    private Integer sortCode;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createTime;
+
+    /**
+     * 创建用户
+     */
+    private String createBy;
+
+    /**
+     * 修改时间
+     */
+    private LocalDateTime updateTime;
+
+    /**
+     * 修改用户
+     */
+    private String updateBy;
+
+    /**
+     * 删除时间
+     */
+    private LocalDateTime deleteTime;
+
+    /**
+     * 删除用户
+     */
+    private String deleteBy;
+
+    /**
+     * 是否删除 0:未删除,1:已删除
+     */
+    private Integer isDelete;
+
+    /**
+     * 租户ID
+     */
+    private Integer tenantId;
+
+
+}

+ 16 - 0
service-data/service-data-biz/src/main/java/com/usky/data/mapper/DataInterfaceLogMapper.java

@@ -0,0 +1,16 @@
+package com.usky.data.mapper;
+
+import com.usky.data.domain.DataInterfaceLog;
+import com.usky.common.mybatis.core.CrudMapper;
+
+/**
+ * <p>
+ * 数据中心_接口调用日志表 Mapper 接口
+ * </p>
+ *
+ * @author fyc
+ * @since 2026-01-24
+ */
+public interface DataInterfaceLogMapper extends CrudMapper<DataInterfaceLog> {
+
+}

+ 16 - 0
service-data/service-data-biz/src/main/java/com/usky/data/mapper/DataInterfaceMapper.java

@@ -0,0 +1,16 @@
+package com.usky.data.mapper;
+
+import com.usky.common.mybatis.core.CrudMapper;
+import com.usky.data.domain.DataInterface;
+
+/**
+ * <p>
+ * 数据中心_数据接口表 Mapper 接口
+ * </p>
+ *
+ * @author fyc
+ * @since 2026-01-24
+ */
+public interface DataInterfaceMapper extends CrudMapper<DataInterface> {
+
+}

+ 34 - 0
service-data/service-data-biz/src/main/java/com/usky/data/mapper/DataLinkMapper.java

@@ -0,0 +1,34 @@
+package com.usky.data.mapper;
+
+import com.usky.data.domain.DataLink;
+import com.usky.common.mybatis.core.CrudMapper;
+
+import java.util.List;
+
+/**
+ * <p>
+ * 数据中心_数据连接表 Mapper 接口
+ * </p>
+ *
+ * @author fyc
+ * @since 2026-01-23
+ */
+public interface DataLinkMapper extends CrudMapper<DataLink> {
+
+    /**
+     * 根据连接名称统计数量(排除指定ID)
+     * @param fullName 连接名称
+     * @param id 排除的主键ID
+     * @return 数量
+     */
+    int countByFullName(String fullName, Integer id);
+
+    /**
+     * 分页查询数据源列表
+     * @param startIndex 起始索引
+     * @param pageSize 每页条数
+     * @param keyword 关键字
+     * @return 数据源列表
+     */
+    List<DataLink> selectPageList(int startIndex, int pageSize, String keyword);
+}

+ 34 - 0
service-data/service-data-biz/src/main/java/com/usky/data/model/DataInterfaceModel.java

@@ -0,0 +1,34 @@
+package com.usky.data.model;
+
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+public class DataInterfaceModel extends ParamModel implements Serializable {
+
+    /**
+     * 是否为空(0允许,1不允许)
+     */
+    @Schema(description = "是否为空(0允许,1不允许)")
+    private Integer required;
+
+    /**
+     * 参数名称
+     */
+    @Schema(description = "参数名称")
+    private String parameter;
+    /**
+     * 参数来源
+     */
+    @Schema(description = "参数来源(1-字段,2-自定义,3-为空,4-系统变量)")
+    private Integer sourceType = 1;
+
+    /**
+     * 表单字段
+     */
+    @Schema(description = "表单字段")
+    private String relationField;
+}

+ 52 - 0
service-data/service-data-biz/src/main/java/com/usky/data/model/DbLinkBaseForm.java

@@ -0,0 +1,52 @@
+package com.usky.data.model;
+
+import com.usky.data.domain.DataLink;
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+/**
+ * 数据源基础表单
+ */
+@Data
+public class DbLinkBaseForm {
+    @NotBlank(message = "连接名称不能为空")
+    private String fullName;
+    @NotBlank(message = "数据库类型不能为空")
+    private String dbType;
+    @NotBlank(message = "主机地址不能为空")
+    private String host;
+    private Integer port;
+    @NotBlank(message = "数据库用户名不能为空")
+    private String dbName;
+    @NotBlank(message = "密码不能为空")
+    private String password;
+    private String serviceName;
+    private String description;
+    private String dbSchema;
+    private String tableSpace;
+    private String oracleParam;
+    private Integer oracleExtend;
+    private Integer sortCode;
+
+    /**
+     * 转换为实体对象
+     */
+    public DataLink getDbLinkEntity(DbLinkBaseForm form) {
+        DataLink dataLink = new DataLink();
+        dataLink.setFullName(form.getFullName());
+        dataLink.setDbType(form.getDbType());
+        dataLink.setHost(form.getHost());
+        dataLink.setPort(form.getPort());
+        dataLink.setDbName(form.getDbName());
+        dataLink.setPassword(form.getPassword());
+        dataLink.setServiceName(form.getServiceName());
+        dataLink.setDescription(form.getDescription());
+        dataLink.setDbSchema(form.getDbSchema());
+        dataLink.setTableSpace(form.getTableSpace());
+        dataLink.setOracleParam(form.getOracleParam());
+        dataLink.setOracleExtend(form.getOracleExtend());
+        dataLink.setSortCode(form.getSortCode());
+        return dataLink;
+    }
+}

+ 24 - 0
service-data/service-data-biz/src/main/java/com/usky/data/model/DbLinkCreUpForm.java

@@ -0,0 +1,24 @@
+package com.usky.data.model;
+
+import com.usky.data.domain.DataLink;
+import lombok.Data;
+
+/**
+ * 数据源创建/更新表单
+ */
+@Data
+public class DbLinkCreUpForm extends DbLinkBaseForm {
+    private Integer id;
+
+    /**
+     * 转换为实体对象
+     */
+    @Override
+    public DataLink getDbLinkEntity(DbLinkBaseForm form) {
+        DataLink dataLink = super.getDbLinkEntity(form);
+        if (this.getId() != null) {
+            dataLink.setId(this.getId());
+        }
+        return dataLink;
+    }
+}

+ 24 - 0
service-data/service-data-biz/src/main/java/com/usky/data/model/FieldModel.java

@@ -0,0 +1,24 @@
+package com.usky.data.model;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Objects;
+
+@Data
+public class FieldModel implements Serializable {
+    @Schema(description = "主键")
+    private String id;
+
+    @Schema(description = "参数名称")
+    private String field;
+
+    @Schema(description = "默认值")
+    private String defaultValue;
+
+    public void setDefaultValue(Object defaultValue) {
+        if (Objects.isNull(defaultValue) || defaultValue.toString().trim().isEmpty()) return;
+        this.defaultValue = defaultValue.toString();
+    }
+}

+ 14 - 0
service-data/service-data-biz/src/main/java/com/usky/data/model/PaginationDbLink.java

@@ -0,0 +1,14 @@
+package com.usky.data.model;
+
+import lombok.Data;
+
+/**
+ * 数据源分页查询参数
+ */
+@Data
+public class PaginationDbLink {
+    private Integer currentPage = 1;
+    private Integer pageSize = 20;
+    private String keyword;
+    private String type;
+}

+ 26 - 0
service-data/service-data-biz/src/main/java/com/usky/data/model/ParamModel.java

@@ -0,0 +1,26 @@
+package com.usky.data.model;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+public class ParamModel extends FieldModel implements Serializable {
+
+    @Schema(description = "列说明")
+    private String fieldName;
+
+    /**
+     * 参数类型
+     * 字符串
+     * 整型
+     * 日期时间
+     * 浮点
+     * 长整型
+     * 文本
+     */
+    @Schema(description = "参数类型")
+    private String dataType;
+
+}

+ 16 - 0
service-data/service-data-biz/src/main/java/com/usky/data/service/DataInterfaceLogService.java

@@ -0,0 +1,16 @@
+package com.usky.data.service;
+
+import com.usky.data.domain.DataInterfaceLog;
+import com.usky.common.mybatis.core.CrudService;
+
+/**
+ * <p>
+ * 数据中心_接口调用日志表 服务类
+ * </p>
+ *
+ * @author fyc
+ * @since 2026-01-24
+ */
+public interface DataInterfaceLogService extends CrudService<DataInterfaceLog> {
+
+}

+ 49 - 0
service-data/service-data-biz/src/main/java/com/usky/data/service/DataInterfaceService.java

@@ -0,0 +1,49 @@
+package com.usky.data.service;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.common.mybatis.core.CrudService;
+import com.usky.data.domain.DataInterface;
+
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * <p>
+ * 数据中心_数据接口表 服务类
+ * </p>
+ *
+ * @author fyc
+ * @since 2026-01-24
+ */
+public interface DataInterfaceService extends CrudService<DataInterface> {
+
+    CommonPage<DataInterface> getDataInterfaceList(Integer currentPage, Integer pageSize, String keyword, String type, Integer enabledMark, String category);
+
+    CommonPage<DataInterface> getWorkflowList(Integer currentPage, Integer pageSize, String keyword, String type, Integer enabledMark);
+
+    List<DataInterface> getSelectorList();
+
+    String getInterfaceParameters(String id);
+
+    boolean updateInterfaceStatus(String id);
+
+    ApiResult<?> testInterface(String id, Map<String, Object> params);
+
+    ApiResult<?> getInterfaceResponse(String id, Map<String, String> params);
+
+    ApiResult<?> postInterfaceResponse(String id, Map<String, String> params);
+
+    ApiResult<?> getInterfacePageData(String id, Map<String, Object> pageParams);
+
+    ApiResult<List<Map<String, Object>>> getInterfaceDetailData(String id, Map<String, Object> pageParams);
+
+    ApiResult<List<String>> getInterfaceFields(String id, Map<String, Object> pageParams);
+
+    Boolean deleteInterface(Long id);
+
+    Boolean addInterface(DataInterface dataInterface);
+
+    Boolean updateInterface(DataInterface dataInterface);
+}

+ 47 - 0
service-data/service-data-biz/src/main/java/com/usky/data/service/DataLinkService.java

@@ -0,0 +1,47 @@
+package com.usky.data.service;
+
+import com.usky.data.domain.DataLink;
+import com.usky.common.mybatis.core.CrudService;
+
+import java.util.List;
+
+/**
+ * <p>
+ * 数据中心_数据连接表 服务类
+ * </p>
+ *
+ * @author fyc
+ * @since 2026-01-23
+ */
+public interface DataLinkService extends CrudService<DataLink> {
+
+    /**
+     * 测试数据库连接
+     * @param dataLink 数据源信息
+     * @return 是否连接成功
+     */
+    boolean testDbConnection(DataLink dataLink);
+
+    /**
+     * 根据连接名称检查是否存在
+     * @param fullName 连接名称
+     * @param id 排除的主键ID
+     * @return 是否存在
+     */
+    boolean isExistByFullName(String fullName, Integer id);
+
+    /**
+     * 获取所有数据源列表
+     * @return 数据源列表
+     */
+    List<DataLink> getList();
+
+    /**
+     * 分页获取数据源列表
+     * @param page 页码
+     * @param pageSize 每页条数
+     * @param keyword 关键字
+     * @return 数据源列表
+     */
+    List<DataLink> getList(int page, int pageSize, String keyword);
+}

+ 20 - 0
service-data/service-data-biz/src/main/java/com/usky/data/service/impl/DataInterfaceLogServiceImpl.java

@@ -0,0 +1,20 @@
+package com.usky.data.service.impl;
+
+import com.usky.data.domain.DataInterfaceLog;
+import com.usky.data.mapper.DataInterfaceLogMapper;
+import com.usky.data.service.DataInterfaceLogService;
+import com.usky.common.mybatis.core.AbstractCrudService;
+import org.springframework.stereotype.Service;
+
+/**
+ * <p>
+ * 数据中心_接口调用日志表 服务实现类
+ * </p>
+ *
+ * @author fyc
+ * @since 2026-01-24
+ */
+@Service
+public class DataInterfaceLogServiceImpl extends AbstractCrudService<DataInterfaceLogMapper, DataInterfaceLog> implements DataInterfaceLogService {
+
+}

+ 291 - 0
service-data/service-data-biz/src/main/java/com/usky/data/service/impl/DataInterfaceServiceImpl.java

@@ -0,0 +1,291 @@
+package com.usky.data.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.toolkit.StringUtils;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.usky.common.core.bean.ApiResult;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.common.core.exception.BusinessException;
+import com.usky.common.mybatis.core.AbstractCrudService;
+import com.usky.common.security.utils.SecurityUtils;
+import com.usky.data.domain.DataInterface;
+import com.usky.data.mapper.DataInterfaceMapper;
+import com.usky.data.service.DataInterfaceService;
+import com.usky.data.service.util.InterfaceExecutor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * <p>
+ * 数据中心_数据接口表 服务实现类
+ * </p>
+ *
+ * @author fyc
+ * @since 2026-01-24
+ */
+@Service
+@Slf4j
+public class DataInterfaceServiceImpl extends AbstractCrudService<DataInterfaceMapper, DataInterface> implements DataInterfaceService {
+
+    @Override
+    public CommonPage<DataInterface> getDataInterfaceList(Integer currentPage, Integer pageSize, String keyword, String type, Integer enabledMark, String category) {
+
+        // if (StringUtils.isBlank(category)) {
+        //     throw new BusinessException("接口分类不能为空!");
+        // }
+
+        IPage<DataInterface> page = new Page<>(currentPage, pageSize);
+        LambdaQueryWrapper<DataInterface> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(StringUtils.isNotBlank(category), DataInterface::getCategory, category)
+                .eq(DataInterface::getTenantId, SecurityUtils.getTenantId())
+                .eq(DataInterface::getIsDelete, 0)
+                .orderByDesc(DataInterface::getSortCode);
+        queryWrapper.eq(StringUtils.isNotBlank(type), DataInterface::getType, type)
+                .eq(enabledMark != null, DataInterface::getEnabledMark, enabledMark);
+        if (StringUtils.isNotBlank(keyword)) {
+            queryWrapper.like(DataInterface::getFullName, keyword)
+                    .or()
+                    .like(DataInterface::getEnCode, keyword);
+        }
+        page = baseMapper.selectPage(page, queryWrapper);
+        return new CommonPage<>(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize());
+    }
+
+    @Override
+    public CommonPage<DataInterface> getWorkflowList(Integer currentPage, Integer pageSize, String keyword, String type, Integer enabledMark) {
+        IPage<DataInterface> page = new Page<>(currentPage, pageSize);
+        LambdaQueryWrapper<DataInterface> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(DataInterface::getTenantId, SecurityUtils.getTenantId())
+                .like(StringUtils.isNotBlank(keyword), DataInterface::getFullName, keyword)
+                .like(StringUtils.isNotBlank(keyword), DataInterface::getEnCode, keyword)
+                .eq(StringUtils.isNotBlank(type), DataInterface::getType, type)
+                .eq(enabledMark != null, DataInterface::getEnabledMark, enabledMark)
+                .eq(DataInterface::getEnabledMark, 1); // 工作流选择时只显示启用的接口
+        page = baseMapper.selectPage(page, queryWrapper);
+        return new CommonPage<>(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize());
+    }
+
+    @Override
+    public List<DataInterface> getSelectorList() {
+        LambdaQueryWrapper<DataInterface> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(DataInterface::getTenantId, SecurityUtils.getTenantId())
+                .eq(DataInterface::getEnabledMark, 1); // 只返回启用的接口
+        return baseMapper.selectList(queryWrapper);
+    }
+
+    @Override
+    public String getInterfaceParameters(String id) {
+        DataInterface entity = baseMapper.selectById(id);
+        return entity != null ? entity.getParameterJson() : "[]";
+    }
+
+    @Override
+    public boolean updateInterfaceStatus(String id) {
+        DataInterface entity = baseMapper.selectById(id);
+        if (entity == null) {
+            return false;
+        }
+        // 切换启用状态
+        entity.setEnabledMark(entity.getEnabledMark() == 1 ? 0 : 1);
+        return baseMapper.updateById(entity) > 0;
+    }
+
+    @Override
+    public ApiResult<?> testInterface(String id, Map<String, Object> params) {
+        // 根据ID获取接口配置
+        DataInterface interfaceEntity = baseMapper.selectById(id);
+        if (interfaceEntity == null) {
+            return ApiResult.error("接口不存在");
+        }
+
+        try {
+            // 执行接口逻辑
+            Object result = InterfaceExecutor.executeInterface(interfaceEntity, params);
+            return ApiResult.success(result);
+        } catch (Exception e) {
+            return ApiResult.error("接口测试失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    public ApiResult<?> getInterfaceResponse(String id, Map<String, String> params) {
+        // 这里是访问接口GET的实现,实际逻辑需要根据接口类型和配置进行处理
+        DataInterface interfaceEntity = baseMapper.selectById(id);
+        if (interfaceEntity == null) {
+            return ApiResult.error("接口不存在");
+        }
+
+        try {
+            // 将String类型的Map转换为Object类型的Map
+            Map<String, Object> objectParams = new HashMap<>();
+            if (params != null) {
+                params.forEach((k, v) -> objectParams.put(k, v));
+            }
+
+            // 执行接口逻辑
+            Object result = InterfaceExecutor.executeInterface(interfaceEntity, objectParams);
+            return ApiResult.success(result);
+        } catch (Exception e) {
+            return ApiResult.error("接口访问失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    public ApiResult<?> postInterfaceResponse(String id, Map<String, String> params) {
+        // 这里是访问接口POST的实现,实际逻辑需要根据接口类型和配置进行处理
+        DataInterface interfaceEntity = baseMapper.selectById(id);
+        if (interfaceEntity == null) {
+            return ApiResult.error("接口不存在");
+        }
+
+        try {
+            // 将String类型的Map转换为Object类型的Map
+            Map<String, Object> objectParams = new HashMap<>();
+            if (params != null) {
+                params.forEach((k, v) -> objectParams.put(k, v));
+            }
+
+            // 执行接口逻辑
+            Object result = InterfaceExecutor.executeInterface(interfaceEntity, objectParams);
+            return ApiResult.success(result);
+        } catch (Exception e) {
+            return ApiResult.error("接口访问失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    public ApiResult<?> getInterfacePageData(String id, Map<String, Object> pageParams) {
+        // 这里是获取接口分页数据的实现,实际逻辑需要根据接口类型和配置进行处理
+        DataInterface interfaceEntity = baseMapper.selectById(id);
+        if (interfaceEntity == null) {
+            return ApiResult.error("接口不存在");
+        }
+
+        try {
+            // 获取接口分页数据
+            Map<String, Object> result = InterfaceExecutor.getInterfacePageData(interfaceEntity, pageParams);
+            return ApiResult.success(result);
+        } catch (Exception e) {
+            return ApiResult.error("获取接口分页数据失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    public ApiResult<List<Map<String, Object>>> getInterfaceDetailData(String id, Map<String, Object> pageParams) {
+        // 这里是获取接口详情数据的实现,实际逻辑需要根据接口类型和配置进行处理
+        DataInterface interfaceEntity = baseMapper.selectById(id);
+        if (interfaceEntity == null) {
+            return ApiResult.error("接口不存在");
+        }
+
+        try {
+            // 执行接口逻辑获取数据
+            Object result = InterfaceExecutor.executeInterface(interfaceEntity, pageParams);
+
+            // 转换结果为List<Map<String, Object>>类型
+            List<Map<String, Object>> resultList = new ArrayList<>();
+            if (result instanceof List) {
+                List<?> listResult = (List<?>) result;
+                for (Object item : listResult) {
+                    if (item instanceof Map) {
+                        @SuppressWarnings("unchecked")
+                        Map<String, Object> mapItem = (Map<String, Object>) item;
+                        resultList.add(mapItem);
+                    }
+                }
+            }
+
+            return ApiResult.success(resultList);
+        } catch (Exception e) {
+            return ApiResult.error("获取接口详情数据失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    public ApiResult<List<String>> getInterfaceFields(String id, Map<String, Object> pageParams) {
+        // 这里是获取接口字段的实现,实际逻辑需要根据接口类型和配置进行处理
+        DataInterface interfaceEntity = baseMapper.selectById(id);
+        if (interfaceEntity == null) {
+            return ApiResult.error("接口不存在");
+        }
+
+        try {
+            // 获取接口字段
+            List<String> fields = InterfaceExecutor.getInterfaceFields(interfaceEntity, pageParams);
+            return ApiResult.success(fields);
+        } catch (Exception e) {
+            return ApiResult.error("获取接口字段失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    @Transactional
+    public Boolean deleteInterface(Long id) {
+        LambdaQueryWrapper<DataInterface> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(DataInterface::getId, id);
+        DataInterface entity = baseMapper.selectOne(queryWrapper);
+        String username = SecurityUtils.getUsername();
+
+        if (entity == null) {
+            log.warn("尝试删除不存在的接口,ID: {},操作人{}", id, username);
+            throw new BusinessException("接口不存在!");
+        }
+
+        entity.setIsDelete(1);
+        entity.setDeleteTime(LocalDateTime.now());
+        entity.setDeleteBy(username);
+
+        return baseMapper.update(entity, queryWrapper) > 0;
+    }
+
+
+    @Override
+    @Transactional
+    public Boolean addInterface(DataInterface dataInterface) {
+        Integer tenantId = SecurityUtils.getTenantId();
+        if (!selectNameAndCode(dataInterface.getFullName(), dataInterface.getEnCode(), tenantId).isEmpty()) {
+            throw new BusinessException("接口名称或编码已存在");
+        }
+
+        dataInterface.setCreateBy(SecurityUtils.getUsername());
+        dataInterface.setCreateTime(LocalDateTime.now());
+        dataInterface.setIsDelete(0);
+        dataInterface.setEnabledMark(1);
+        dataInterface.setTenantId(tenantId);
+        return baseMapper.insert(dataInterface) > 0;
+    }
+
+    private List<DataInterface> selectNameAndCode(String name, String code, Integer tenantId) {
+        LambdaQueryWrapper<DataInterface> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(DataInterface::getTenantId, tenantId)
+                .and(w -> w.eq(DataInterface::getFullName, name)
+                        .or()
+                        .eq(DataInterface::getEnCode, code));
+        return baseMapper.selectList(queryWrapper);
+    }
+
+
+    @Override
+    public Boolean updateInterface(DataInterface dataInterface) {
+
+        if (dataInterface == null || dataInterface.getId() <= 0) {
+            throw new BusinessException("接口id为空或不合规");
+        }
+
+        if (!selectNameAndCode(dataInterface.getFullName(), dataInterface.getEnCode(), dataInterface.getTenantId()).isEmpty()) {
+            throw new BusinessException("接口名称或编码已存在");
+        }
+
+        dataInterface.setUpdateBy(SecurityUtils.getUsername());
+        dataInterface.setUpdateTime(LocalDateTime.now());
+        return baseMapper.updateById(dataInterface) > 0;
+    }
+}

+ 120 - 0
service-data/service-data-biz/src/main/java/com/usky/data/service/impl/DataLinkServiceImpl.java

@@ -0,0 +1,120 @@
+package com.usky.data.service.impl;
+
+import com.usky.data.domain.DataLink;
+import com.usky.data.mapper.DataLinkMapper;
+import com.usky.data.service.DataLinkService;
+import com.usky.common.mybatis.core.AbstractCrudService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.util.List;
+
+/**
+ * <p>
+ * 数据中心_数据连接表 服务实现类
+ * </p>
+ *
+ * @author fyc
+ * @since 2026-01-23
+ */
+@Service
+public class DataLinkServiceImpl extends AbstractCrudService<DataLinkMapper, DataLink> implements DataLinkService {
+
+    @Autowired
+    private DataLinkMapper dataLinkMapper;
+
+    @Override
+    public boolean testDbConnection(DataLink dataLink) {
+        Connection connection = null;
+        try {
+            // 根据数据库类型加载驱动
+            String driverClass = getDriverClass(dataLink.getDbType());
+            Class.forName(driverClass);
+
+            // 构建连接URL
+            String url = buildJdbcUrl(dataLink);
+
+            // 建立连接
+            connection = DriverManager.getConnection(url, dataLink.getDbName(), dataLink.getPassword());
+            return true;
+        } catch (Exception e) {
+            e.printStackTrace();
+            return false;
+        } finally {
+            if (connection != null) {
+                try {
+                    connection.close();
+                } catch (Exception e) {
+                    e.printStackTrace();
+                }
+            }
+        }
+    }
+
+    @Override
+    public boolean isExistByFullName(String fullName, Integer id) {
+        int count = dataLinkMapper.countByFullName(fullName, id);
+        return count > 0;
+    }
+
+    @Override
+    public List<DataLink> getList() {
+        return dataLinkMapper.selectList(null);
+    }
+
+    @Override
+    public List<DataLink> getList(int page, int pageSize, String keyword) {
+        // 计算起始索引
+        int startIndex = (page - 1) * pageSize;
+        return dataLinkMapper.selectPageList(startIndex, pageSize, keyword);
+    }
+
+    /**
+     * 根据数据库类型获取驱动类名
+     */
+    private String getDriverClass(String dbType) {
+        switch (dbType.toLowerCase()) {
+            case "mysql":
+                return "com.mysql.cj.jdbc.Driver";
+            case "oracle":
+                return "oracle.jdbc.OracleDriver";
+            case "sqlserver":
+                return "com.microsoft.sqlserver.jdbc.SQLServerDriver";
+            case "postgresql":
+                return "org.postgresql.Driver";
+            default:
+                throw new IllegalArgumentException("不支持的数据库类型: " + dbType);
+        }
+    }
+
+    /**
+     * 构建JDBC连接URL
+     */
+    private String buildJdbcUrl(DataLink dataLink) {
+        String dbType = dataLink.getDbType().toLowerCase();
+        String host = dataLink.getHost();
+        Integer port = dataLink.getPort();
+        String dbName = dataLink.getDbName();
+
+        switch (dbType) {
+            case "mysql":
+                return String.format("jdbc:mysql://%s:%d/%s?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%%2B8",
+                        host, port, dbName);
+            case "oracle":
+                // Oracle支持SID和Service Name两种方式
+                if (dataLink.getServiceName() != null && !dataLink.getServiceName().isEmpty()) {
+                    return String.format("jdbc:oracle:thin:@//%s:%d/%s", host, port, dataLink.getServiceName());
+                } else {
+                    return String.format("jdbc:oracle:thin:@%s:%d:%s", host, port, dbName);
+                }
+            case "sqlserver":
+                return String.format("jdbc:sqlserver://%s:%d;databaseName=%s;encrypt=false", host, port, dbName);
+            case "postgresql":
+                return String.format("jdbc:postgresql://%s:%d/%s", host, port, dbName);
+            default:
+                throw new IllegalArgumentException("不支持的数据库类型: " + dbType);
+        }
+    }
+}

+ 421 - 0
service-data/service-data-biz/src/main/java/com/usky/data/service/util/InterfaceExecutor.java

@@ -0,0 +1,421 @@
+package com.usky.data.service.util;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.usky.data.domain.DataInterface;
+import org.apache.commons.lang3.StringUtils;
+
+import java.text.SimpleDateFormat;
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 接口执行器,用于处理不同类型接口的执行逻辑
+ * <p>
+ * 支持三种接口类型:
+ * 1. SQL操作(type=1)
+ * 2. 静态数据(type=2)
+ * 3. API操作(type=3)
+ * </p>
+ */
+public class InterfaceExecutor {
+
+    /**
+     * 执行接口逻辑,参考原始infoToId方法
+     *
+     * @param dataInterface 接口实体
+     * @param params        请求参数
+     * @return 执行结果
+     */
+    public static Object executeInterface(DataInterface dataInterface, Map<String, Object> params) {
+        if (dataInterface == null) {
+            throw new IllegalArgumentException("接口配置不能为空");
+        }
+
+        // 转换params为Map<String, String>用于参数处理
+        Map<String, String> stringParams = new HashMap<>();
+        if (params != null) {
+            params.forEach((k, v) -> stringParams.put(k, v != null ? v.toString() : null));
+        }
+
+        // 替换默认值
+        replaceDefaultValue(dataInterface.getParameterJson(), stringParams);
+
+        // 验证参数必填或类型
+        String checkResult = checkRequestParams(dataInterface.getParameterJson(), stringParams);
+        if (StringUtils.isNotEmpty(checkResult)) {
+            throw new IllegalArgumentException(checkResult);
+        }
+
+        Object result = null;
+        try {
+            // 根据接口类型执行不同逻辑
+            Integer type = dataInterface.getType();
+            switch (type) {
+                case 1:
+                    // 静态数据
+                    result = callStaticData(dataInterface.getDataConfigJson());
+                    break;
+                case 3:
+                    // API调用
+                    result = callApi(dataInterface.getDataConfigJson(), stringParams);
+                    break;
+                case 2:
+                    // SQL操作
+                    result = executeSql(dataInterface, stringParams);
+                    break;
+                default:
+                    throw new IllegalArgumentException("不支持的接口类型: " + type);
+            }
+
+            // 处理结果,执行JS脚本
+            if (StringUtils.isNotEmpty(dataInterface.getDataJsJson())) {
+                // 这里模拟JS脚本执行,实际项目中需要引入JS引擎
+                result = processResult(result, dataInterface.getDataJsJson());
+            }
+
+            return result;
+        } catch (Exception e) {
+            throw new RuntimeException("接口执行失败: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 替换默认值
+     *
+     * @param parameterJson 参数配置JSON
+     * @param params        请求参数
+     */
+    private static void replaceDefaultValue(String parameterJson, Map<String, String> params) {
+        if (StringUtils.isEmpty(parameterJson)) {
+            return;
+        }
+
+        JSONArray paramArray = JSON.parseArray(parameterJson);
+        if (paramArray == null || paramArray.isEmpty()) {
+            return;
+        }
+
+        for (Object obj : paramArray) {
+            JSONObject paramObj = (JSONObject) obj;
+            String field = paramObj.getString("field");
+            String defaultValue = paramObj.getString("defaultValue");
+
+            // 如果参数中没有该字段或值为空,则设置默认值
+            if (!params.containsKey(field) || StringUtils.isEmpty(params.get(field))) {
+                params.put(field, defaultValue == null ? "" : defaultValue);
+            }
+        }
+    }
+
+    /**
+     * 检查请求参数
+     *
+     * @param parameterJson 参数配置JSON
+     * @param params        请求参数
+     * @return 检查结果,空字符串表示通过
+     */
+    private static String checkRequestParams(String parameterJson, Map<String, String> params) {
+        if (StringUtils.isEmpty(parameterJson)) {
+            return "";
+        }
+
+        JSONArray paramArray = JSON.parseArray(parameterJson);
+        if (paramArray == null || paramArray.isEmpty()) {
+            return "";
+        }
+
+        StringBuilder message = new StringBuilder();
+        for (Object obj : paramArray) {
+            JSONObject paramObj = (JSONObject) obj;
+
+            // 验证必填字段
+            Integer required = paramObj.getInteger("required");
+            if (required != null && required == 1) {
+                String field = paramObj.getString("field");
+                String value = params.get(field);
+                if (StringUtils.isEmpty(value)) {
+                    message.append(field).append("不能为空;");
+                }
+            }
+
+            // 验证数据类型
+            String dataType = paramObj.getString("dataType");
+            if (StringUtils.isNotEmpty(dataType)) {
+                String field = paramObj.getString("field");
+                String value = params.get(field);
+                if (StringUtils.isEmpty(value)) {
+                    continue;
+                }
+                try {
+                    if ("int".equals(dataType)) {
+                        Integer.parseInt(value);
+                    } else if ("decimal".equals(dataType)) {
+                        Double.parseDouble(value);
+                    } else if ("datetime".equals(dataType)) {
+                        try {
+                            SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+                            formatter.parse(value);
+                        } catch (Exception e) {
+                            // 尝试解析时间戳
+                            Long.parseLong(value);
+                        }
+                    }
+                } catch (Exception e) {
+                    message.append(field).append("类型必须为").append(dataType).append(";");
+                }
+            }
+        }
+
+        return message.length() > 0 ? message.substring(0, message.length() - 1) : "";
+    }
+
+    /**
+     * 执行静态数据类型接口
+     *
+     * @param dataConfigJson 数据配置JSON
+     * @return 静态数据结果
+     */
+    private static Object callStaticData(String dataConfigJson) {
+        JSONObject configJson = JSON.parseObject(dataConfigJson);
+        String staticData = configJson.getString("staticData");
+
+        Object obj;
+        try {
+            Object parse = JSON.parse(staticData);
+            if (parse instanceof JSONArray) {
+                obj = JSON.parseArray(staticData, Map.class);
+            } else {
+                obj = JSON.parseObject(staticData, Map.class);
+            }
+        } catch (Exception e) {
+            obj = staticData;
+        }
+
+        if (obj == null) {
+            return new ArrayList<>();
+        }
+
+        return obj;
+    }
+
+    /**
+     * 执行API类型接口
+     *
+     * @param dataConfigJson 数据配置JSON
+     * @param params        请求参数
+     * @return API调用结果
+     */
+    private static Object callApi(String dataConfigJson, Map<String, String> params) {
+        JSONObject configJson = JSON.parseObject(dataConfigJson);
+        JSONObject apiData = configJson.getJSONObject("apiData");
+
+        if (apiData == null) {
+            throw new IllegalArgumentException("API配置不能为空");
+        }
+
+        String url = apiData.getString("url");
+        String method = apiData.getString("method");
+        JSONObject headers = apiData.getJSONObject("headers");
+        JSONObject apiParams = apiData.getJSONObject("params");
+
+        // 合并参数
+        if (apiParams == null) {
+            apiParams = new JSONObject();
+        }
+        if (params != null) {
+            JSONObject finalApiParams = apiParams;
+            params.forEach((k, v) -> {
+                if (StringUtils.isNotEmpty(v)) {
+                    finalApiParams.put(k, v);
+                }
+            });
+        }
+
+        // 模拟API调用,实际项目中需要使用HTTP客户端
+        Map<String, Object> result = new HashMap<>();
+        result.put("success", true);
+        result.put("message", "API调用成功");
+        result.put("data", apiParams);
+        result.put("url", url);
+        result.put("method", method);
+
+        return result;
+    }
+
+    /**
+     * 执行SQL类型接口
+     *
+     * @param dataInterface 接口实体
+     * @param params        请求参数
+     * @return SQL执行结果
+     */
+    private static Object executeSql(DataInterface dataInterface, Map<String, String> params) {
+        JSONObject configJson = JSON.parseObject(dataInterface.getDataConfigJson());
+        JSONObject sqlData = configJson.getJSONObject("sqlData");
+
+        if (sqlData == null) {
+            throw new IllegalArgumentException("SQL配置不能为空");
+        }
+
+        String sql = sqlData.getString("sql");
+        if (StringUtils.isEmpty(sql)) {
+            throw new IllegalArgumentException("SQL语句不能为空");
+        }
+
+        // 替换SQL中的参数占位符
+        sql = replaceSqlParams(sql, params);
+
+        // 模拟SQL执行结果
+        List<Map<String, Object>> resultList = new ArrayList<>();
+        for (int i = 0; i < 5; i++) {
+            Map<String, Object> map = new HashMap<>();
+            map.put("id", i + 1);
+            map.put("name", "测试数据" + (i + 1));
+            map.put("value", i * 100);
+            map.put("created_at", "2026-01-24 10:00:00");
+            resultList.add(map);
+        }
+
+        // 如果是分页查询,处理分页信息
+        if (dataInterface.getHasPage() != null && dataInterface.getHasPage() == 1) {
+            Map<String, Object> pageResult = new HashMap<>();
+            pageResult.put("list", resultList);
+            pageResult.put("total", 100);
+            pageResult.put("current", params.getOrDefault("currentPage", "1"));
+            pageResult.put("pageSize", params.getOrDefault("pageSize", "20"));
+            return pageResult;
+        }
+
+        return resultList;
+    }
+
+    /**
+     * 替换SQL中的参数占位符
+     *
+     * @param sql    SQL语句
+     * @param params 请求参数
+     * @return 替换后的SQL语句
+     */
+    private static String replaceSqlParams(String sql, Map<String, String> params) {
+        if (StringUtils.isEmpty(sql) || params == null || params.isEmpty()) {
+            return sql;
+        }
+
+        // 匹配 {参数名} 格式的占位符
+        Pattern pattern = Pattern.compile("\\{(\\w+)\\}");
+        Matcher matcher = pattern.matcher(sql);
+
+        StringBuffer sb = new StringBuffer();
+        while (matcher.find()) {
+            String paramName = matcher.group(1);
+            String paramValue = params.getOrDefault(paramName, "");
+            matcher.appendReplacement(sb, paramValue);
+        }
+        matcher.appendTail(sb);
+
+        return sb.toString();
+    }
+
+    /**
+     * 处理结果,执行JS脚本
+     *
+     * @param result    原始结果
+     * @param jsScript  JS脚本
+     * @return 处理后的结果
+     */
+    private static Object processResult(Object result, String jsScript) {
+        // 这里模拟JS脚本执行,实际项目中需要引入JS引擎
+        // 例如使用javax.script.ScriptEngine
+        return result;
+    }
+
+    /**
+     * 判断是否为内部接口返回格式
+     *
+     * @param jsonObject JSON对象
+     * @return 是否为内部接口格式
+     */
+    private static boolean isInternal(JSONObject jsonObject) {
+        if (jsonObject != null) {
+            return jsonObject.size() == 3 &&
+                    jsonObject.containsKey("code") &&
+                    jsonObject.containsKey("msg") &&
+                    jsonObject.containsKey("data");
+        }
+        return false;
+    }
+
+    /**
+     * 获取接口字段信息
+     *
+     * @param dataInterface 接口实体
+     * @param params        请求参数
+     * @return 接口字段列表
+     */
+    public static List<String> getInterfaceFields(DataInterface dataInterface, Map<String, Object> params) {
+        // 执行接口获取数据
+        Object result = executeInterface(dataInterface, params);
+
+        // 解析结果获取字段列表
+        List<String> fields = new ArrayList<>();
+        if (result instanceof List) {
+            List<?> resultList = (List<?>) result;
+            if (!resultList.isEmpty() && resultList.get(0) instanceof Map) {
+                Map<?, ?> firstItem = (Map<?, ?>) resultList.get(0);
+                for (Object key : firstItem.keySet()) {
+                    fields.add(key.toString());
+                }
+            }
+        } else if (result instanceof Map) {
+            Map<?, ?> resultMap = (Map<?, ?>) result;
+            // 如果是分页结果,获取list中的字段
+            if (resultMap.containsKey("list") && resultMap.get("list") instanceof List) {
+                List<?> list = (List<?>) resultMap.get("list");
+                if (!list.isEmpty() && list.get(0) instanceof Map) {
+                    Map<?, ?> firstItem = (Map<?, ?>) list.get(0);
+                    for (Object key : firstItem.keySet()) {
+                        fields.add(key.toString());
+                    }
+                }
+            } else {
+                // 直接获取map中的字段
+                for (Object key : resultMap.keySet()) {
+                    fields.add(key.toString());
+                }
+            }
+        }
+
+        return fields;
+    }
+
+    /**
+     * 获取接口分页数据
+     *
+     * @param dataInterface 接口实体
+     * @param params        请求参数
+     * @return 分页数据结果
+     */
+    public static Map<String, Object> getInterfacePageData(DataInterface dataInterface, Map<String, Object> params) {
+        // 执行接口获取数据
+        Object result = executeInterface(dataInterface, params);
+
+        // 处理分页
+        Map<String, Object> pageResult = new HashMap<>();
+
+        if (result instanceof Map && ((Map<?, ?>) result).containsKey("list")) {
+            // 如果已经是分页格式,直接返回
+            pageResult.putAll((Map<? extends String, ?>) result);
+        } else {
+            // 否则包装成分页格式
+            pageResult.put("list", result);
+            pageResult.put("total", result instanceof List ? ((List<?>) result).size() : 1);
+            pageResult.put("current", params != null ? params.getOrDefault("currentPage", 1) : 1);
+            pageResult.put("pageSize", params != null ? params.getOrDefault("pageSize", 20) : 20);
+        }
+
+        return pageResult;
+    }
+}

+ 49 - 0
service-data/service-data-biz/src/main/java/com/usky/data/vo/DbLinkInfoVO.java

@@ -0,0 +1,49 @@
+package com.usky.data.vo;
+
+import com.usky.data.domain.DataLink;
+import lombok.Data;
+
+/**
+ * 数据源详细信息VO
+ */
+@Data
+public class DbLinkInfoVO {
+    private Integer id;
+    private String fullName;
+    private String dbType;
+    private String host;
+    private Integer port;
+    private String dbName;
+    private String serviceName;
+    private String description;
+    private String dbSchema;
+    private String tableSpace;
+    private String oracleParam;
+    private Integer oracleExtend;
+    private Integer sortCode;
+    private String createBy;
+    private String updateBy;
+
+    /**
+     * 转换为VO对象
+     */
+    public DbLinkInfoVO getDbLinkInfoVO(DataLink dataLink) {
+        DbLinkInfoVO vo = new DbLinkInfoVO();
+        vo.setId(dataLink.getId());
+        vo.setFullName(dataLink.getFullName());
+        vo.setDbType(dataLink.getDbType());
+        vo.setHost(dataLink.getHost());
+        vo.setPort(dataLink.getPort());
+        vo.setDbName(dataLink.getDbName());
+        vo.setServiceName(dataLink.getServiceName());
+        vo.setDescription(dataLink.getDescription());
+        vo.setDbSchema(dataLink.getDbSchema());
+        vo.setTableSpace(dataLink.getTableSpace());
+        vo.setOracleParam(dataLink.getOracleParam());
+        vo.setOracleExtend(dataLink.getOracleExtend());
+        vo.setSortCode(dataLink.getSortCode());
+        vo.setCreateBy(dataLink.getCreateBy());
+        vo.setUpdateBy(dataLink.getUpdateBy());
+        return vo;
+    }
+}

+ 22 - 0
service-data/service-data-biz/src/main/java/com/usky/data/vo/DbLinkListVO.java

@@ -0,0 +1,22 @@
+package com.usky.data.vo;
+
+import lombok.Data;
+
+/**
+ * 数据源列表VO
+ */
+@Data
+public class DbLinkListVO {
+    private Integer id;
+    private String fullName;
+    private String dbType;
+    private String host;
+    private Integer port;
+    private String dbName;
+    private String serviceName;
+    private String description;
+    private String dbSchema;
+    private String tableSpace;
+    private String createBy;
+    private String updateBy;
+}

+ 14 - 0
service-data/service-data-biz/src/main/java/com/usky/data/vo/DbLinkSelectorListVO.java

@@ -0,0 +1,14 @@
+package com.usky.data.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 数据源选择器列表VO
+ */
+@Data
+public class DbLinkSelectorListVO {
+    private String fullName;
+    private List<DbLinkListVO> children;
+}

+ 17 - 0
service-data/service-data-biz/src/main/java/com/usky/data/vo/ListVO.java

@@ -0,0 +1,17 @@
+package com.usky.data.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 通用列表包装类
+ */
+@Data
+public class ListVO<T> {
+    private List<T> list;
+
+    public ListVO(List<T> list) {
+        this.list = list;
+    }
+}

+ 19 - 0
service-data/service-data-biz/src/main/java/com/usky/data/vo/PageListVO.java

@@ -0,0 +1,19 @@
+package com.usky.data.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 分页列表包装类
+ */
+@Data
+public class PageListVO<T> {
+    private List<T> list;
+    private PaginationVO pagination;
+
+    public PageListVO(List<T> list, PaginationVO pagination) {
+        this.list = list;
+        this.pagination = pagination;
+    }
+}

+ 14 - 0
service-data/service-data-biz/src/main/java/com/usky/data/vo/PaginationVO.java

@@ -0,0 +1,14 @@
+package com.usky.data.vo;
+
+import lombok.Data;
+
+/**
+ * 分页信息包装类
+ */
+@Data
+public class PaginationVO {
+    private int currentPage;
+    private int pageSize;
+    private int totalRows;
+    private int totalPages;
+}

+ 25 - 0
service-data/service-data-biz/src/main/resources/bootstrap.yml

@@ -0,0 +1,25 @@
+# Tomcat
+server:
+  port: 9902
+
+# Spring
+spring: 
+  application:
+    # 应用名称
+    name: service-data
+  profiles:
+    # 环境配置
+    active: dev
+  cloud:
+    nacos:
+      discovery:
+        # 服务注册地址
+        server-addr: usky-cloud-nacos:8848
+      config:
+        # 配置中心地址
+        server-addr: usky-cloud-nacos:8848
+        # 配置文件格式
+        file-extension: yml
+        # 共享配置
+        shared-configs:
+          - application-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}

+ 108 - 0
service-data/service-data-biz/src/main/resources/doc/index.adoc

@@ -0,0 +1,108 @@
+= 安防项目
+
+[width="100%",options="header"]
+[stripes=even]
+|====================
+|Version |  Update Time  | Status | Author |  Description
+|v2022-04-21 16:57:08|2022-04-21 16:57:08|auto|@yq|Created by smart-doc
+|====================
+
+
+== &lt;p&gt;参数配置表 前端控制器&lt;/p&gt;
+== &lt;p&gt;部门信息&lt;/p&gt;
+=== 查看部门信息
+*URL:* http:10.23.39.1:8082/sysDept/list
+
+*Type:* POST
+
+*Author:* ya
+
+*Content-Type:* application/json; charset=utf-8
+
+
+
+
+*Body-parameters:*
+
+[width="100%",options="header"]
+[stripes=even]
+|====================
+|Parameter | Type|Description|Required|Since
+|deptId|int64|部门id|false|-
+|parentId|int64|父部门id|false|-
+|ancestors|string|祖级列表|false|-
+|deptName|string|部门名称|false|-
+|orderNum|int32|显示顺序|false|-
+|leader|string|负责人|false|-
+|phone|string|联系电话|false|-
+|email|string|邮箱|false|-
+|status|string|部门状态(0正常 1停用)|false|-
+|delFlag|string|删除标志(0代表存在 2代表删除)|false|-
+|createBy|string|创建者|false|-
+|createTime|string|创建时间|false|-
+|updateBy|string|更新者|false|-
+|updateTime|string|更新时间|false|-
+|bId|int64|建筑id|false|-
+|====================
+
+*Response-fields:*
+
+[width="100%",options="header"]
+[stripes=even]
+|====================
+|Field | Type|Description|Since
+|status|object|No comments found.|-
+|code|string|No comments found.|-
+|msg|string|No comments found.|-
+|data|object|No comments found.|-
+|└─deptId|int64|部门id|-
+|└─parentId|int64|父部门id|-
+|└─ancestors|string|祖级列表|-
+|└─deptName|string|部门名称|-
+|└─orderNum|int32|显示顺序|-
+|└─leader|string|负责人|-
+|└─phone|string|联系电话|-
+|└─email|string|邮箱|-
+|└─status|string|部门状态(0正常 1停用)|-
+|└─delFlag|string|删除标志(0代表存在 2代表删除)|-
+|└─createBy|string|创建者|-
+|└─createTime|string|创建时间|-
+|└─updateBy|string|更新者|-
+|└─updateTime|string|更新时间|-
+|└─bId|int64|建筑id|-
+|exception|string|No comments found.|-
+|====================
+
+*Response-example:*
+----
+{
+	"status": {
+		
+	},
+	"code": "97564",
+	"msg": "wnr5qt",
+	"data": [
+		{
+			"deptId": 540,
+			"parentId": 858,
+			"ancestors": "o5lg60",
+			"deptName": "文.沈",
+			"orderNum": 260,
+			"leader": "ufz93p",
+			"phone": "17852835049",
+			"email": "智渊.徐@yahoo.com",
+			"status": "nu6cnp",
+			"delFlag": "72oiji",
+			"createBy": "5fxr6j",
+			"createTime": "2022-04-21 16:57:10",
+			"updateBy": "4kcs4e",
+			"updateTime": "2022-04-21 16:57:10",
+			"bId": 977
+		}
+	],
+	"exception": "53u6bg"
+}
+----
+
+== &lt;p&gt;用户信息表 前端控制器&lt;/p&gt;
+

+ 74 - 0
service-data/service-data-biz/src/main/resources/logback.xml

@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration scan="true" scanPeriod="60 seconds" debug="false">
+    <!-- 日志存放路径 -->
+	<property name="log.path" value="/var/log/uskycloud/usky-data" />
+   <!-- 日志输出格式 -->
+	<property name="log.pattern" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - [%method,%line] - %msg%n" />
+
+    <!-- 控制台输出 -->
+	<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
+		<encoder>
+			<pattern>${log.pattern}</pattern>
+		</encoder>
+	</appender>
+
+    <!-- 系统日志输出 -->
+	<appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender">
+	    <file>${log.path}/info.log</file>
+        <!-- 循环政策:基于时间创建日志文件 -->
+		<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 日志文件名格式 -->
+			<fileNamePattern>${log.path}/info.%d{yyyy-MM-dd}.log</fileNamePattern>
+			<!-- 日志最大的历史 60天 -->
+			<maxHistory>60</maxHistory>
+		</rollingPolicy>
+		<encoder>
+			<pattern>${log.pattern}</pattern>
+		</encoder>
+		<filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <!-- 过滤的级别 -->
+            <level>INFO</level>
+            <!-- 匹配时的操作:接收(记录) -->
+            <onMatch>ACCEPT</onMatch>
+            <!-- 不匹配时的操作:拒绝(不记录) -->
+            <onMismatch>DENY</onMismatch>
+        </filter>
+	</appender>
+
+    <appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender">
+	    <file>${log.path}/error.log</file>
+        <!-- 循环政策:基于时间创建日志文件 -->
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 日志文件名格式 -->
+            <fileNamePattern>${log.path}/error.%d{yyyy-MM-dd}.log</fileNamePattern>
+			<!-- 日志最大的历史 60天 -->
+			<maxHistory>60</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+        </encoder>
+        <filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <!-- 过滤的级别 -->
+            <level>ERROR</level>
+			<!-- 匹配时的操作:接收(记录) -->
+            <onMatch>ACCEPT</onMatch>
+			<!-- 不匹配时的操作:拒绝(不记录) -->
+            <onMismatch>DENY</onMismatch>
+        </filter>
+    </appender>
+
+    <!-- 系统模块日志级别控制  -->
+	<logger name="com.usky" level="info" />
+	<!-- Spring日志级别控制  -->
+	<logger name="org.springframework" level="warn" />
+
+	<root level="info">
+		<appender-ref ref="console" />
+	</root>
+	
+	<!--系统操作日志-->
+    <root level="info">
+        <appender-ref ref="file_info" />
+        <appender-ref ref="file_error" />
+    </root>
+</configuration>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است