Bläddra i källkod

Merge remote-tracking branch 'origin/master'

zengy 1 vecka sedan
förälder
incheckning
989a707efd
100 ändrade filer med 8053 tillägg och 766 borttagningar
  1. 17 0
      collection-integration-tests/pom.xml
  2. 4 0
      collection-integration-tests/src/test/resources/logback-test.xml
  3. 13 9
      docs/cockpit/README.md
  4. 4 5
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/monitor/RealtimeMonitorController.java
  5. 53 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/video/VideoDeviceController.java
  6. 3 0
      pipe-network-service/zksy-admin/src/main/resources/application.yml
  7. 5 3
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitDeviceFactResolverTest.java
  8. 6 10
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitInfrastructureBaseArchivesAdapterTest.java
  9. 7 4
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitInfrastructureManagementInformationAdapterTest.java
  10. 7 5
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMapSnapshotAdapterTest.java
  11. 1 1
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMonitoringDistributionAdapterTest.java
  12. 5 3
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMonitoringPointDetailAdapterTest.java
  13. 14 11
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitOverviewInfrastructureAdapterTest.java
  14. 161 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitCoordinateValidatorTest.java
  15. 16 14
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitCurrentEventsControllerTest.java
  16. 144 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitFacilityCountCrossEndpointContractTest.java
  17. 15 14
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitFacilityGeometryControllerTest.java
  18. 46 21
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitFacilityGeometryServiceTest.java
  19. 11 9
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotControllerTest.java
  20. 8 2
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java
  21. 11 9
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringPointDetailControllerTest.java
  22. 35 30
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringPointDetailSnapshotContractTest.java
  23. 11 10
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringSituationControllerTest.java
  24. 8 12
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitPipeNetworkCrossEndpointContractTest.java
  25. 1 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/EquipmentBase.java
  26. 0 58
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/StationVideoDevice.java
  27. 39 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/mapper/EquipmentVideoBindingMapper.java
  28. 0 9
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/mapper/StationVideoDeviceMapper.java
  29. 12 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/mapper/VideoSessionMapper.java
  30. 0 11
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/StationVideoDeviceService.java
  31. 0 28
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/StationVideoDeviceServiceImpl.java
  32. 21 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/video/EquipmentVideoBinding.java
  33. 43 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/video/VideoCollectorClient.java
  34. 23 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/video/VideoDeviceRecord.java
  35. 282 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/video/VideoDeviceService.java
  36. 42 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/video/VideoRequests.java
  37. 22 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/video/VideoSession.java
  38. 2 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/equipment/BusinessCockpitEquipmentArchiveAdapter.java
  39. 3 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/BusinessCockpitEventAdapter.java
  40. 40 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitCoordinateValidator.java
  41. 100 138
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/BusinessCockpitInfrastructureBaseArchivesAdapter.java
  42. 45 136
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/BusinessCockpitInfrastructureManagementInformationAdapter.java
  43. 8 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitFacilityCatalog.java
  44. 1 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureBaseArchivesService.java
  45. 8 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureBaseArchivesSnapshot.java
  46. 5 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/BusinessCockpitMapSnapshotAdapter.java
  47. 2 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java
  48. 3 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringSituationAdapter.java
  49. 62 201
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/BusinessCockpitOverviewInfrastructureAdapter.java
  50. 2 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/BusinessCockpitMonitoringPointDetailAdapter.java
  51. 1 0
      pom.xml
  52. 25 0
      video-service/pom.xml
  53. 46 0
      video-service/src/main/java/com/ruoyi/wvp/common/CivilCodePo.java
  54. 5 0
      video-service/src/main/java/com/ruoyi/wvp/common/CommonCallback.java
  55. 5 0
      video-service/src/main/java/com/ruoyi/wvp/common/GeneralCallback.java
  56. 59 0
      video-service/src/main/java/com/ruoyi/wvp/common/InviteInfo.java
  57. 11 0
      video-service/src/main/java/com/ruoyi/wvp/common/InviteSessionStatus.java
  58. 9 0
      video-service/src/main/java/com/ruoyi/wvp/common/InviteSessionType.java
  59. 780 0
      video-service/src/main/java/com/ruoyi/wvp/common/StreamInfo.java
  60. 94 0
      video-service/src/main/java/com/ruoyi/wvp/common/StreamURL.java
  61. 54 0
      video-service/src/main/java/com/ruoyi/wvp/common/SystemAllInfo.java
  62. 149 0
      video-service/src/main/java/com/ruoyi/wvp/common/VersionPo.java
  63. 159 0
      video-service/src/main/java/com/ruoyi/wvp/common/VideoManagerConstants.java
  64. 21 0
      video-service/src/main/java/com/ruoyi/wvp/common/enums/ChannelDataType.java
  65. 77 0
      video-service/src/main/java/com/ruoyi/wvp/common/enums/DeviceControlType.java
  66. 159 0
      video-service/src/main/java/com/ruoyi/wvp/conf/DynamicTask.java
  67. 291 0
      video-service/src/main/java/com/ruoyi/wvp/conf/MediaConfig.java
  68. 13 0
      video-service/src/main/java/com/ruoyi/wvp/conf/MediaStatusTimerTask.java
  69. 37 0
      video-service/src/main/java/com/ruoyi/wvp/conf/ScheduleConfig.java
  70. 28 0
      video-service/src/main/java/com/ruoyi/wvp/conf/ServiceInfo.java
  71. 34 0
      video-service/src/main/java/com/ruoyi/wvp/conf/SipConfig.java
  72. 41 0
      video-service/src/main/java/com/ruoyi/wvp/conf/SystemInfoTimerTask.java
  73. 67 0
      video-service/src/main/java/com/ruoyi/wvp/conf/ThreadPoolTaskConfig.java
  74. 183 0
      video-service/src/main/java/com/ruoyi/wvp/conf/UserSetting.java
  75. 39 0
      video-service/src/main/java/com/ruoyi/wvp/conf/VersionConfig.java
  76. 27 0
      video-service/src/main/java/com/ruoyi/wvp/conf/VersionInfo.java
  77. 29 0
      video-service/src/main/java/com/ruoyi/wvp/conf/WVPTimerTask.java
  78. 24 0
      video-service/src/main/java/com/ruoyi/wvp/conf/redis/bean/RedisRpcMessage.java
  79. 93 0
      video-service/src/main/java/com/ruoyi/wvp/conf/redis/bean/RedisRpcRequest.java
  80. 99 0
      video-service/src/main/java/com/ruoyi/wvp/conf/redis/bean/RedisRpcResponse.java
  81. 244 0
      video-service/src/main/java/com/ruoyi/wvp/controller/GBRecordController.java
  82. 225 0
      video-service/src/main/java/com/ruoyi/wvp/controller/PlayController.java
  83. 259 0
      video-service/src/main/java/com/ruoyi/wvp/controller/PlaybackController.java
  84. 566 0
      video-service/src/main/java/com/ruoyi/wvp/controller/PtzController.java
  85. 153 0
      video-service/src/main/java/com/ruoyi/wvp/gb28181/ApiControlController.java
  86. 253 0
      video-service/src/main/java/com/ruoyi/wvp/gb28181/ApiDeviceController.java
  87. 276 0
      video-service/src/main/java/com/ruoyi/wvp/gb28181/ApiStreamController.java
  88. 186 0
      video-service/src/main/java/com/ruoyi/wvp/gb28181/SipLayer.java
  89. 235 0
      video-service/src/main/java/com/ruoyi/wvp/gb28181/auth/DigestServerAuthenticationHelper.java
  90. 24 0
      video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/BaiduPoint.java
  91. 37 0
      video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/CatalogChannelEvent.java
  92. 30 0
      video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/CatalogData.java
  93. 8 0
      video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/CmdType.java
  94. 411 0
      video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/CommonGBChannel.java
  95. 235 0
      video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/Device.java
  96. 322 0
      video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/DeviceChannel.java
  97. 27 0
      video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/DeviceNotFoundEvent.java
  98. 430 0
      video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/GBStringMsgParser.java
  99. 46 0
      video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/Gb28181Sdp.java
  100. 56 0
      video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/GbCode.java

+ 17 - 0
collection-integration-tests/pom.xml

@@ -0,0 +1,17 @@
+<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent><groupId>com.zksy</groupId><artifactId>pipe-ner-server</artifactId><version>1.0.0</version></parent>
+    <artifactId>collection-integration-tests</artifactId>
+    <dependencies>
+        <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
+        <dependency><groupId>com.zksy</groupId><artifactId>environment-service</artifactId><version>1.0.0</version><scope>test</scope></dependency>
+        <dependency><groupId>com.zksy</groupId><artifactId>manhole-service</artifactId><version>1.0.0</version><scope>test</scope></dependency>
+        <dependency><groupId>com.zksy</groupId><artifactId>firefighting-pressure-service</artifactId><version>1.0-SNAPSHOT</version><scope>test</scope></dependency>
+        <dependency><groupId>com.zksy</groupId><artifactId>flammable-gas-service</artifactId><version>1.0-SNAPSHOT</version><scope>test</scope></dependency>
+        <dependency><groupId>com.zksy</groupId><artifactId>telemetry-service</artifactId><version>1.0-SNAPSHOT</version><scope>test</scope></dependency>
+        <dependency><groupId>com.zksy</groupId><artifactId>radar-service</artifactId><version>1.0-SNAPSHOT</version><scope>test</scope></dependency>
+        <dependency><groupId>com.zksy</groupId><artifactId>water-level-service</artifactId><version>1.0-SNAPSHOT</version><scope>test</scope></dependency>
+        <dependency><groupId>com.zksy</groupId><artifactId>audio-service</artifactId><version>1.0-SNAPSHOT</version><scope>test</scope></dependency>
+    </dependencies>
+</project>

+ 4 - 0
collection-integration-tests/src/test/resources/logback-test.xml

@@ -0,0 +1,4 @@
+<configuration>
+    <!-- 故障由断言校验,避免70台并发注入异常打印数万行堆栈。 -->
+    <root level="OFF"/>
+</configuration>

+ 13 - 9
docs/cockpit/README.md

@@ -85,13 +85,17 @@
 
 - `page` 默认 1;`pageSize` 默认 100(地图快照默认 500),上限 1000;越界返回 `INVALID_REQUEST`。
 - `map-snapshot` 的 `page > 1` 必须携带**首屏无 token 请求签发**的同一 `snapshotAt`,否则 `SNAPSHOT_EXPIRED`;显式 token 仅复用已签发快照,缓存未命中不得按该时间重读当前库。同一 `snapshotAt` 下分页结果稳定,监测点优先、已关联设备不重复出点。
-- 无效或未验证坐标的对象保留在列表与详情中,但 `coordinateValidation.renderable = false`,禁止渲染。
+- 坐标不可上图的对象(结构非法,或落在主场景区外)保留在列表与详情中,但 `coordinateValidation.renderable = false`,禁止渲染。
+- **坐标准入(2026-10-12 维护者裁定,取代此前的「人工校核前置」)**:入库坐标即视为可信来源,判据为「结构合法 + 落在主场景区范围内」。判定顺序:缺经度/缺纬度/经度越界/纬度越界/零坐标 → 各自的 `INVALID` 原因;数值合法但落在主场景区外 → `INVALID` / `OUTSIDE_PRIMARY_AREA`;其余 → `VALID` / `renderable=true`。
+  - 主场景区闭区间(与前端部署配置 `map.primaryArea.bounds` 一致):`west=110.328749941417, south=28.3917230659234, east=110.465779226663, north=28.4913038992907`,常量与 `insidePrimaryArea()` 收在 `CockpitCoordinateValidator` 一处。
+  - **`coordinateVerified` 入参保留但不再影响结论**:现网既无可信校核字段也无校核入口,继续把它当前置门槛会让全部真实对象恒不可上图(实测修复前 map-snapshot 15/15、`infrastructure/geometry` 5272/5272 全不可渲染)。
+  - 因此新流程**不再产生 `UNVERIFIED`**;`UNVERIFIED_COORDINATE` 仅保留给历史快照与「调用方未提供校验段」的防御路径。
 - 成功但无数据返回 200(例如 `freshness.state = EMPTY`),不得用 5xx 表达空;仅上游读取失败才 `SERVER_ERROR`。
 - 时间范围口径:预设 `range` 取值 `LAST_24_HOURS` / `LAST_7_DAYS` / `LAST_30_DAYS`(`infrastructure/operating-information` 与 `overview/warning-settings` 同款);显式区间用 `startTime`/`endTime`(ISO-8601 带偏移,必须成对出现且 `startTime ≤ endTime`,回显 `range.type = CUSTOM`)。`overview/warning-settings` 不声明窗口时维持 `CURRENT_BASELINE`(`start`/`end` 为 `null`,趋势桶不筛选)。
 - `overview/warning-settings` 的趋势桶是**业务时区日期**(`yyyy-MM-dd`),声明窗口时按「桶日期落在窗口起止日期之间(含端点)」筛选,**桶顺序沿用适配器顺序**;桶标签无法解析时保留该桶,不静默丢数据。
 - 未知类型、非智能设备、非正式行业在**按行业/类型筛选时**显式排除,不做静默归类;`map-snapshot` 不带筛选时仍返回全部对象(`industry`/`typeName` 为 `null`),保证「全城底图」不因类型数据缺失而丢对象。
-- 事件行(`overview`/`warningResponse` 的 `events/current` 的 `EventItem`)携带 `coordinateValidation`,与地图/监测切片**同一校验实现**(`CockpitCoordinateValidator`);坐标缺失、越界或未验证时 `renderable = false` 且给出 `reason`。
-- 适配器未给出 `coordinateVerified` 时按**未验证**处理(`state=UNVERIFIED`、`reason=UNVERIFIED_COORDINATE`、`renderable=false`)——字典要求「未验证禁止渲染」,服务端不臆造验证状态。报警族在数据可得时另带 `thresholdMinValue`/`thresholdMaxValue`(预警族为 `null`)。
+- 事件行(`overview`/`warningResponse` 的 `events/current` 的 `EventItem`)携带 `coordinateValidation`,与地图/监测切片**同一校验实现**(`CockpitCoordinateValidator`);坐标结构非法、越界或落在主场景区外时 `renderable = false` 且给出 `reason`。
+- 事件坐标按同一自动准入判定(不再依赖 `coordinateVerified`):区内且数值合法 → `VALID` / `renderable=true`。报警族在数据可得时另带 `thresholdMinValue`/`thresholdMaxValue`(预警族为 `null`)。
 - `events/history-analysis` 的 `rows[]` 是**小时桶聚合行**,不存在逐事件行,因此不携带 `coordinateValidation` 与阈值字段(逐事件坐标只在 `events/current` 提供)。
 
 ### 监测点详情设备坐标
@@ -101,18 +105,18 @@
 | 字段 | 语义 |
 | --- | --- |
 | `longitude` / `latitude` | 来自 `equipment_base` 的设备坐标;不会复制所属监测点坐标,也不会人工偏移。缺失时字段为 `null`。 |
-| `coordinateValidation.state` | `VALID`、`INVALID` 或 `UNVERIFIED`。 |
-| `coordinateValidation.reason` | 不可上图时的非空原因,例如 `MISSING_LONGITUDE`、`LONGITUDE_OUT_OF_RANGE`、`ZERO_COORDINATE` 或 `UNVERIFIED_COORDINATE`。 |
-| `coordinateValidation.renderable` | 只有可信校核来源存在且坐标有效时为 `true`;前端仅在此为 `true` 时绘制设备点位。 |
+| `coordinateValidation.state` | `VALID`(可上图)或 `INVALID`(不可上图)。新流程不再产生 `UNVERIFIED`。 |
+| `coordinateValidation.reason` | 不可上图时的非空原因:`MISSING_LONGITUDE`、`MISSING_LATITUDE`、`LONGITUDE_OUT_OF_RANGE`、`LATITUDE_OUT_OF_RANGE`、`ZERO_COORDINATE` 或 `OUTSIDE_PRIMARY_AREA`。 |
+| `coordinateValidation.renderable` | 结构合法**且**落在主场景区内时为 `true`;前端仅在此为 `true` 时绘制设备点位。 |
 | `coordinateValidation.crs` | 当前统一为 `WGS84`。 |
 
-当前 `equipment_base` 没有可信坐标校核字段,因此真实适配器透传设备坐标但按 `UNVERIFIED_COORDINATE` 降级,`renderable=false`;设备仍保留在详情清单中。
+当前 `equipment_base` 没有可信坐标校核字段,但按 2026-10-12 裁定**不再需要**它:真实适配器透传设备坐标,由 `CockpitCoordinateValidator` 按「结构合法 + 主场景区内」自动准入,因此区内设备 `renderable=true` 可正常上图;区外设备保留在详情清单中并给出 `OUTSIDE_PRIMARY_AREA`。
 
 **跨端点同快照(#69)**:
 
 - 监测点详情接受可选查询参数 `snapshotAt`(ISO-8601、必须带 `+08:00`,语义与 `map-snapshot` 一致)。提供 `snapshotAt` 时,详情与地图按时间戳复用首次读取的地图快照;相同时间戳不会因两次请求之间的点位/设备关联变化而重新读取,详情 `devices[]` 与 `map-snapshot` 对应点位的 `deviceCodes` 集合一致(含离线设备、不重复、不静默丢失)。快照存于 Redis,保留至 `snapshotAt` 后 10 分钟的有效窗口结束,应用节点共享;单节点本地最多缓存 256 份以减少 Redis 读取。过期时返回 `SNAPSHOT_EXPIRED`;Redis/缓存不可用返回 `SERVER_ERROR`,调用方应分别刷新快照或提示上游暂不可用。
 - 未提供 `snapshotAt` 时走详情适配器路径(尽力模式):两个请求之间点位-设备关系变化时,详情与地图集合可能不一致,前端按决策票 05「跨端点尽力展示并提示可能不一致」处理。
-- **当前正式交付边界**:`equipment_base` 暂无可信坐标校核字段,现网关联设备均按 `UNVERIFIED_COORDINATE` 降级、`renderable=false`,前端准入规则下**只保留设备清单、不绘制设备位置**。可信坐标校核来源(如基于设备安装定位/人工复核的 `coordinateVerified` 字段)需另行排期,由后端在数据源就绪后接入并回填。
+- **坐标准入口径(2026-10-12 起)**:不再要求人工校核来源,改按「结构合法 + 落在主场景区内」自动判定。现网真实监测点/设备/管线坐标基本都落在主场景区内(实测 `map-snapshot` 12/15、`infrastructure/geometry` 5269/5272 可上图),因此**关联设备位置可正常绘制**。区外对象(含 `(0,0)` 零坐标、测试占位值)仍不可上图,但保留在清单与统计中并给出具体原因。
 
 ## 5. WebSocket 实时增量
 
@@ -205,7 +209,7 @@ mvn -pl zksy-admin -am test -Dtest='Cockpit*Test'
 ### 当前真实库只读观测
 
 - 既有只读探查记录:`work_order=1`、`alarm_data=3`、`early_warning=35`、`alarm_warning_rel=3`、`equipment_base=2`、`equipment_status=2`、`monitor_point=7`、`equipment_point_rel=20`、`case_info=2`、`hazard_hidden_account=12`;这些数量是观测时点事实,不代表生产规模。
-- 点位仍存在空坐标与 `(12,12)` 等异常坐标;当前 schema 没有可信坐标校核字段。缺可信校核来源一律 `coordinateVerified=false`、`renderable=false`;单纯经纬度范围校验不能替代业务校核。
+- 点位仍存在空坐标与 `(12,12)` 等异常坐标;当前 schema 没有可信坐标校核字段。2026-10-12 起不再依赖该字段:数值合法且落在主场景区内即 `renderable=true`(不再产生 `UNVERIFIED`),区外归 `OUTSIDE_PRIMARY_AREA`。**这仍是呈现层准入,不等于业务坐标已校核**——库里出现明显错误但落在区内的坐标依然会被画出来,人工核查该列数据的责任仍在业务侧。
 - 真实 `map-snapshot` HTTP 响应在本轮样例中返回 `total=15`,分页样例包含 9 个点位对象和 7 个设备编码;设备关系与样本数据会随数据库内容变化,响应的 `snapshotAt` 是唯一分页依据。
 
 ### 当前代码可验收性

+ 4 - 5
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/monitor/RealtimeMonitorController.java

@@ -1,8 +1,7 @@
 package com.zksy.web.controller.monitor;
 
-import com.zksy.base.domain.StationVideoDevice;
 import com.zksy.base.service.EquipmentBaseService;
-import com.zksy.base.service.StationVideoDeviceService;
+import com.zksy.base.video.VideoDeviceService;
 import com.zksy.common.annotation.Anonymous;
 import com.zksy.common.core.domain.AjaxResult;
 import io.swagger.annotations.Api;
@@ -21,10 +20,10 @@ import java.util.*;
 public class RealtimeMonitorController {
 
     @Autowired
-    private StationVideoDeviceService videoDeviceService;
+    private EquipmentBaseService equipmentService;
 
     @Autowired
-    private EquipmentBaseService equipmentService;
+    private VideoDeviceService videoDeviceService;
 
     @GetMapping("/pressure-history")
     @ApiOperation("压力历史曲线")
@@ -71,7 +70,7 @@ public class RealtimeMonitorController {
     @ApiOperation("获取视频设备列表")
     @Anonymous
     public AjaxResult getVideoList() {
-        return AjaxResult.success(videoDeviceService.list());
+        return AjaxResult.success(videoDeviceService.all());
     }
 
     @GetMapping("/points-with-latest-data")

+ 53 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/video/VideoDeviceController.java

@@ -0,0 +1,53 @@
+package com.zksy.web.controller.video;
+
+import java.util.*;
+import javax.validation.Valid;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.base.video.VideoDeviceRecord;
+import com.zksy.base.video.*;
+import com.zksy.common.core.domain.AjaxResult;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/video")
+public class VideoDeviceController {
+    private final VideoDeviceService service;
+    public VideoDeviceController(VideoDeviceService service) {this.service=service;}
+    @PreAuthorize("@ss.hasPermi('video:device:list')")
+    @GetMapping("/devices")
+    public AjaxResult list(@RequestParam(defaultValue="1") int pageNum,@RequestParam(defaultValue="10") int pageSize,
+                           @RequestParam(required=false) String query,@RequestParam(required=false) String sourceType,@RequestParam(required=false) String stationId) {
+        Page<VideoDeviceRecord> page=service.list(pageNum,pageSize,query,sourceType,stationId);
+        AjaxResult result=AjaxResult.success();result.put("rows",page.getRecords());result.put("total",page.getTotal());return result;
+    }
+    @PreAuthorize("@ss.hasPermi('video:device:list')") @GetMapping("/devices/{id}")
+    public AjaxResult detail(@PathVariable String id) {return AjaxResult.success(service.one(id));}
+    @PreAuthorize("@ss.hasPermi('video:device:add')") @PostMapping("/devices")
+    public AjaxResult create(@Valid @RequestBody VideoRequests.Save input) { input.setVideoId(null); return AjaxResult.success((Object)service.save(input)); }
+    @PreAuthorize("@ss.hasPermi('video:device:edit')") @PutMapping("/devices/{id}")
+    public AjaxResult update(@PathVariable String id,@Valid @RequestBody VideoRequests.Save input) { input.setVideoId(id); return AjaxResult.success((Object)service.save(input)); }
+    @PreAuthorize("@ss.hasPermi('video:device:remove')") @DeleteMapping("/devices/{id}")
+    public AjaxResult remove(@PathVariable String id) {service.remove(id);return AjaxResult.success();}
+    @PreAuthorize("@ss.hasPermi('video:device:add')") @GetMapping("/discovered")
+    public AjaxResult discover(@RequestParam(required=false) String query,@RequestParam(required=false) Boolean online,
+                               @RequestParam(defaultValue="1") int pageNum,@RequestParam(defaultValue="10") int pageSize) {return AjaxResult.success(service.discover(query,online,pageNum,pageSize));}
+    @PreAuthorize("@ss.hasPermi('video:device:add')") @PostMapping("/discovered/{id}/sync")
+    public AjaxResult sync(@PathVariable String id) {service.sync(id);return AjaxResult.success("目录同步已发起");}
+    @PreAuthorize("@ss.hasPermi('video:device:add')") @PostMapping("/discovered/sync")
+    public AjaxResult syncAll() {return AjaxResult.success(service.syncAll());}
+    @PreAuthorize("@ss.hasPermi('video:device:play')") @PostMapping("/devices/{id}/live")
+    public AjaxResult live(@PathVariable String id) {return AjaxResult.success(service.start(id,null));}
+    @PreAuthorize("@ss.hasPermi('video:device:record')") @GetMapping("/devices/{id}/records")
+    public AjaxResult records(@PathVariable String id,@Valid VideoRequests.Range range) {return AjaxResult.success(service.records(id,range));}
+    @PreAuthorize("@ss.hasPermi('video:device:record')") @PostMapping("/devices/{id}/playback")
+    public AjaxResult playback(@PathVariable String id,@Valid @RequestBody VideoRequests.Range range) {return AjaxResult.success(service.start(id,range));}
+    @PreAuthorize("@ss.hasPermi('video:device:ptz')") @PostMapping("/devices/{id}/ptz")
+    public AjaxResult ptz(@PathVariable String id,@Valid @RequestBody VideoRequests.Ptz input) {service.ptz(id,input);return AjaxResult.success("云台命令已发送");}
+    @PreAuthorize("@ss.hasPermi('video:device:record')") @PostMapping("/sessions/{id}/control")
+    public AjaxResult control(@PathVariable String id,@Valid @RequestBody VideoRequests.Control input) {service.control(id,input);return AjaxResult.success();}
+    @PreAuthorize("@ss.hasAnyPermi('video:device:play,video:device:record')") @PostMapping("/sessions/{id}/heartbeat")
+    public AjaxResult heartbeat(@PathVariable String id) {service.heartbeat(id);return AjaxResult.success();}
+    @PreAuthorize("@ss.hasAnyPermi('video:device:play,video:device:record')") @PostMapping("/sessions/{id}/stop")
+    public AjaxResult stop(@PathVariable String id) {service.stop(id);return AjaxResult.success();}
+}

+ 3 - 0
pipe-network-service/zksy-admin/src/main/resources/application.yml

@@ -0,0 +1,3 @@
+spring:
+  config:
+    import: "optional:file:./.local/video-business.properties,optional:file:../.local/video-business.properties,optional:file:../../.local/video-business.properties"

+ 5 - 3
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitDeviceFactResolverTest.java

@@ -70,9 +70,11 @@ class BusinessCockpitDeviceFactResolverTest {
         assertEquals("压力计", fact.getDeviceTypeName());
         assertEquals(new BigDecimal("110.4025"), fact.getLongitude());
         assertEquals(new BigDecimal("28.4500"), fact.getLatitude());
-        assertEquals("UNVERIFIED", fact.getCoordinateValidation().getState());
-        assertEquals("UNVERIFIED_COORDINATE", fact.getCoordinateValidation().getReason());
-        assertFalse(fact.getCoordinateValidation().isRenderable());
+        // 2026-10-12 契约:取消无入口的人工校核前置,改为「结构合法 + 落在主场景区内」自动准入。
+        // 本夹具坐标 110.4025/28.4500 落在主场景区内,故判定为可上图。
+        assertEquals("VALID", fact.getCoordinateValidation().getState());
+        assertNull(fact.getCoordinateValidation().getReason());
+        assertTrue(fact.getCoordinateValidation().isRenderable());
         assertEquals(CockpitMonitoringPointStatus.ALARM,
                 CockpitMonitoringPointStatusResolver.deviceStatus(fact));
     }

+ 6 - 10
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitInfrastructureBaseArchivesAdapterTest.java

@@ -1,6 +1,5 @@
 package com.zksy.web.controller.cockpit;
 
-import com.baomidou.mybatisplus.core.conditions.Wrapper;
 import com.zksy.WaterSupply.WaterPlantInfo.domain.WaterPlantInfo;
 import com.zksy.WaterSupply.WaterPlantInfo.service.IWaterPlantInfoService;
 import com.zksy.WaterSupply.WaterPumpStation.domain.PumpStationType;
@@ -17,10 +16,7 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 import java.math.BigDecimal;
-import java.time.Clock;
-import java.time.Instant;
 import java.time.LocalDate;
-import java.time.ZoneOffset;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
@@ -30,7 +26,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -50,7 +45,8 @@ class BusinessCockpitInfrastructureBaseArchivesAdapterTest {
         engineeringFacilityService = mock(EngineeringFacilityService.class);
         adapter = new BusinessCockpitInfrastructureBaseArchivesAdapter(
                 plantService, pumpStationService, pipeNetworkBaseService, engineeringFacilityService,
-                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+                mock(com.zksy.WaterSupply.WaterSourceInfo.service.IWaterSourceInfoService.class),
+                mock(com.zksy.base.service.ManholeBaseService.class));
     }
 
     @Test
@@ -80,14 +76,14 @@ class BusinessCockpitInfrastructureBaseArchivesAdapterTest {
         drainage.setFacilityType("调蓄设施");
 
         when(plantService.list()).thenReturn(Collections.singletonList(plant));
-        when(pumpStationService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(pump));
+        when(pumpStationService.list()).thenReturn(Collections.singletonList(pump));
         when(pipeNetworkBaseService.list()).thenReturn(Collections.singletonList(pipe));
         when(engineeringFacilityService.list()).thenReturn(Collections.singletonList(drainage));
 
         CockpitInfrastructureBaseArchivesSnapshot snapshot = adapter.load();
 
         assertEquals(4, snapshot.getFacilities().size());
-        assertEquals(8, snapshot.getTypes().size());
+        assertEquals(10, snapshot.getTypes().size());
         assertEquals("DRAINAGE_STORAGE", snapshot.getTypes().get(6).getCode());
         // 设施表都没有 equipment_base.district 那种权威片区来源,全部如实报未分区。
         assertTrue(snapshot.getFacilities().stream().allMatch(item -> item.getDistrict() == null),
@@ -124,7 +120,7 @@ class BusinessCockpitInfrastructureBaseArchivesAdapterTest {
         PipeNetworkBase gas = pipe("GAS-PIPE", "燃气管线", "GAS", "500");
         PipeNetworkBase unknown = pipe("UNKNOWN-PIPE", "未知管线", "99", "500");
         when(plantService.list()).thenReturn(Collections.emptyList());
-        when(pumpStationService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(pumpStationService.list()).thenReturn(Collections.emptyList());
         when(pipeNetworkBaseService.list()).thenReturn(Arrays.asList(drainage, gas, unknown));
         when(engineeringFacilityService.list()).thenReturn(Collections.emptyList());
 
@@ -148,7 +144,7 @@ class BusinessCockpitInfrastructureBaseArchivesAdapterTest {
         PipeNetworkBase drainage = pipe("P-DRAIN", "排水管线", "1", "1000");
         PipeNetworkBase water = pipe("P-WATER", "供水管线", "2", "1500");
         when(plantService.list()).thenReturn(Collections.emptyList());
-        when(pumpStationService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(pumpStationService.list()).thenReturn(Collections.emptyList());
         when(pipeNetworkBaseService.list()).thenReturn(Arrays.asList(gas, drainage, water));
         when(engineeringFacilityService.list()).thenReturn(Collections.emptyList());
 

+ 7 - 4
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitInfrastructureManagementInformationAdapterTest.java

@@ -11,6 +11,7 @@ import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.service.ManholeBaseService;
 import com.zksy.base.service.PipeNetworkBaseService;
 import com.zksy.cockpit.infrastructure.BusinessCockpitInfrastructureManagementInformationAdapter;
+import com.zksy.cockpit.infrastructure.BusinessCockpitInfrastructureBaseArchivesAdapter;
 import com.zksy.cockpit.infrastructure.CockpitInfrastructureManagementInformationSnapshot;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -44,9 +45,11 @@ class BusinessCockpitInfrastructureManagementInformationAdapterTest {
         adapter = new BusinessCockpitInfrastructureManagementInformationAdapter(
                 equipmentBaseService,
                 equipmentTypeService,
-                manholeBaseService,
-                pipeNetworkBaseService,
-                waterSourceInfoService);
+                new BusinessCockpitInfrastructureBaseArchivesAdapter(
+                        mock(com.zksy.WaterSupply.WaterPlantInfo.service.IWaterPlantInfoService.class),
+                        mock(com.zksy.WaterSupply.WaterPumpStation.service.IWaterPumpStationService.class),
+                        pipeNetworkBaseService, mock(com.zksy.drainage.service.EngineeringFacilityService.class),
+                        waterSourceInfoService, manholeBaseService));
     }
 
     @Test
@@ -163,7 +166,7 @@ class BusinessCockpitInfrastructureManagementInformationAdapterTest {
             List<CockpitInfrastructureManagementInformationSnapshot.SourceRelation> relations,
             String code) {
         return relations.stream()
-                .filter(item -> code.equals(item.getObjectCode()))
+                .filter(item -> code.equals(item.getObjectCode()) || item.getObjectCode().endsWith("|" + code))
                 .findFirst()
                 .orElseThrow(AssertionError::new);
     }

+ 7 - 5
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMapSnapshotAdapterTest.java

@@ -97,7 +97,8 @@ class BusinessCockpitMapSnapshotAdapterTest {
         assertEquals("Z-TEST-RQXL01", point.getDevices().get(0).getDeviceCode());
         assertEquals(new BigDecimal("110.4025"), point.getDevices().get(0).getLongitude());
         assertEquals(new BigDecimal("28.4500"), point.getDevices().get(0).getLatitude());
-        assertFalse(point.getDevices().get(0).getCoordinateValidation().isRenderable());
+        // 2026-10-12 契约:坐标落在主场景区内即自动准入,不再需要人工校核标记。
+        assertTrue(point.getDevices().get(0).getCoordinateValidation().isRenderable());
 
         assertTrue(snapshot.getTypes().stream().anyMatch(
                 type -> "22".equals(type.getCode()) && "GAS".equals(type.getIndustry())),
@@ -113,7 +114,7 @@ class BusinessCockpitMapSnapshotAdapterTest {
         EquipmentBase bound = device("D-1", "BOUND-1", "已关联", "11",
                 new BigDecimal("110.4"), new BigDecimal("28.4"));
         EquipmentBase free = device("D-2", "FREE-1", "未关联", "11",
-                new BigDecimal("110.5"), new BigDecimal("28.5"));
+                new BigDecimal("110.440"), new BigDecimal("28.450"));
         stubDevices(Arrays.asList(bound, free), Collections.singletonList(bound));
         when(deviceFactResolver.resolve(any(), anyString())).thenAnswer(invocation -> {
             EquipmentBase device = invocation.getArgument(0);
@@ -235,9 +236,10 @@ class BusinessCockpitMapSnapshotAdapterTest {
                 restoredSnapshot.getMonitoringPoints().get(0).getDevices().get(0);
         assertEquals("DEVICE-1", restoredDevice.getDeviceCode());
         assertEquals(new BigDecimal("110.4"), restoredDevice.getLongitude());
-        assertEquals("UNVERIFIED", restoredDevice.getCoordinateValidation().getState());
-        assertEquals("UNVERIFIED_COORDINATE", restoredDevice.getCoordinateValidation().getReason());
-        assertFalse(restoredDevice.getCoordinateValidation().isRenderable());
+        // 2026-10-12 契约:110.4/28.4 落在主场景区内,序列化往返后判定不变。
+        assertEquals("VALID", restoredDevice.getCoordinateValidation().getState());
+        assertNull(restoredDevice.getCoordinateValidation().getReason());
+        assertTrue(restoredDevice.getCoordinateValidation().isRenderable());
         assertFalse(restoredDevice.isOnline());
     }
 

+ 1 - 1
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMonitoringDistributionAdapterTest.java

@@ -63,7 +63,7 @@ class BusinessCockpitMonitoringDistributionAdapterTest {
         EquipmentBase bound = device("D-1", "Z-TEST-RQXL01", "Z-测试燃气泄漏01", "22",
                 new BigDecimal("110.4024"), new BigDecimal("28.4499"), 1);
         EquipmentBase free = device("D-2", "FREE-1", "独立燃气计", "22",
-                new BigDecimal("110.5"), new BigDecimal("28.5"), null);
+                new BigDecimal("110.440"), new BigDecimal("28.450"), null);
         stubDevices(Arrays.asList(bound, free), Collections.singletonList(bound));
         when(deviceFactResolver.resolve(any(), anyString())).thenAnswer(invocation -> {
             EquipmentBase device = invocation.getArgument(0);

+ 5 - 3
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMonitoringPointDetailAdapterTest.java

@@ -20,6 +20,7 @@ import java.util.Collections;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
@@ -66,9 +67,10 @@ class BusinessCockpitMonitoringPointDetailAdapterTest {
         assertFalse(detail.isCoordinateVerified(), "现网没有可信坐标校核字段");
         assertEquals(new BigDecimal("110.4025"), detail.getDevices().get(0).getLongitude());
         assertEquals(new BigDecimal("28.4500"), detail.getDevices().get(0).getLatitude());
-        assertEquals("UNVERIFIED", detail.getDevices().get(0).getCoordinateValidation().getState());
-        assertEquals("UNVERIFIED_COORDINATE", detail.getDevices().get(0).getCoordinateValidation().getReason());
-        assertFalse(detail.getDevices().get(0).getCoordinateValidation().isRenderable());
+        // 2026-10-12 契约:坐标落在主场景区内即自动准入,不再需要人工校核标记。
+        assertEquals("VALID", detail.getDevices().get(0).getCoordinateValidation().getState());
+        assertNull(detail.getDevices().get(0).getCoordinateValidation().getReason());
+        assertTrue(detail.getDevices().get(0).getCoordinateValidation().isRenderable());
         assertEquals(1, detail.getDevices().size());
         assertEquals("DEV-001", detail.getDevices().get(0).getDeviceCode());
         assertEquals("压力计", detail.getDevices().get(0).getDeviceTypeName());

+ 14 - 11
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitOverviewInfrastructureAdapterTest.java

@@ -1,6 +1,5 @@
 package com.zksy.web.controller.cockpit;
 
-import com.baomidou.mybatisplus.core.conditions.Wrapper;
 import com.zksy.WaterSupply.WaterPlantInfo.domain.WaterPlantInfo;
 import com.zksy.WaterSupply.WaterPlantInfo.service.IWaterPlantInfoService;
 import com.zksy.WaterSupply.WaterPumpStation.domain.PumpStationType;
@@ -12,7 +11,10 @@ import com.zksy.base.domain.PipeNetworkBase;
 import com.zksy.base.service.EquipmentBaseService;
 import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.infrastructure.BusinessCockpitInfrastructureBaseArchivesAdapter;
+import com.zksy.cockpit.infrastructure.CockpitFacilityCatalog;
 import com.zksy.cockpit.overview.BusinessCockpitOverviewInfrastructureAdapter;
+import com.zksy.drainage.service.EngineeringFacilityService;
 import com.zksy.cockpit.overview.CockpitOverviewInfrastructureSnapshot;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -25,7 +27,6 @@ import java.util.Collections;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
@@ -47,9 +48,11 @@ class BusinessCockpitOverviewInfrastructureAdapterTest {
         pipeNetworkBaseService = mock(PipeNetworkBaseService.class);
         plantService = mock(IWaterPlantInfoService.class);
         pumpStationService = mock(IWaterPumpStationService.class);
-        adapter = new BusinessCockpitOverviewInfrastructureAdapter(
-                equipmentTypeService, equipmentBaseService, pipeNetworkBaseService,
-                plantService, pumpStationService);
+        CockpitFacilityCatalog catalog = new BusinessCockpitInfrastructureBaseArchivesAdapter(
+                plantService, pumpStationService, pipeNetworkBaseService, mock(EngineeringFacilityService.class),
+                mock(com.zksy.WaterSupply.WaterSourceInfo.service.IWaterSourceInfoService.class),
+                mock(com.zksy.base.service.ManholeBaseService.class));
+        adapter = new BusinessCockpitOverviewInfrastructureAdapter(equipmentTypeService, equipmentBaseService, catalog);
     }
 
     @Test
@@ -73,7 +76,7 @@ class BusinessCockpitOverviewInfrastructureAdapterTest {
 
         CockpitOverviewInfrastructureSnapshot snapshot = adapter.load();
 
-        assertEquals(3, snapshot.getTypes().size());
+        assertEquals(12, snapshot.getTypes().size());
         assertEquals("2026-09-02T09:59:50+08:00", snapshot.getUpdatedAt());
 
         long deviceCount = snapshot.getCounts().stream()
@@ -103,7 +106,7 @@ class BusinessCockpitOverviewInfrastructureAdapterTest {
         when(equipmentTypeService.list()).thenReturn(Collections.emptyList());
         when(equipmentBaseService.list()).thenReturn(Collections.emptyList());
         when(plantService.list()).thenReturn(Collections.emptyList());
-        when(pumpStationService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(pumpStationService.list()).thenReturn(Collections.emptyList());
 
         java.util.List<PipeNetworkBase> pipes = new java.util.ArrayList<>();
         pipes.add(pipe("P-GAS", "燃气管线", "0", "500"));
@@ -152,20 +155,20 @@ class BusinessCockpitOverviewInfrastructureAdapterTest {
         WaterPumpStation unclassified = new WaterPumpStation();
         unclassified.setStationCode("PS-3");
         unclassified.setStationType("unknown");
-        when(pumpStationService.list(any(Wrapper.class))).thenReturn(Arrays.asList(water, drainage, unclassified));
+        when(pumpStationService.list()).thenReturn(Arrays.asList(water, drainage, unclassified));
 
         CockpitOverviewInfrastructureSnapshot snapshot = adapter.load();
         assertEquals("2026-09-03T08:00:00+08:00", snapshot.getUpdatedAt());
-        assertEquals(3, snapshot.getCounts().size());
+        assertEquals(4, snapshot.getCounts().size());
         assertEquals("WATER_PLANT", snapshot.getCounts().get(0).getTypeCode());
         assertEquals("WATER_PUMP_STATION", snapshot.getCounts().get(1).getTypeCode());
         assertEquals("DRAINAGE_PUMP_STATION", snapshot.getCounts().get(2).getTypeCode());
-        assertEquals("DRAINAGE", snapshot.getTypes().get(2).getIndustry());
+        assertEquals("DRAINAGE", snapshot.getTypes().get(7).getIndustry());
         verify(equipmentTypeService, times(1)).list();
         verify(equipmentBaseService, times(1)).list();
         verify(pipeNetworkBaseService, times(1)).list();
         verify(plantService, times(1)).list();
-        verify(pumpStationService, times(1)).list(any(Wrapper.class));
+        verify(pumpStationService, times(1)).list();
     }
 
     private static EquipmentType type(int id, String name, int parentId) {

+ 161 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitCoordinateValidatorTest.java

@@ -0,0 +1,161 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
+import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 坐标准入判据的单测。
+ *
+ * <p><b>2026-10-12 维护者裁定(取消人工校核前置)</b>:{@code coordinateVerified} 不再影响结果,
+ * 判据改为「结构合法(缺经纬度/越界/零坐标各有具体原因)+ 落在主场景区(闭区间)内」。
+ * 本测试钉住该判定的顺序、边界与「校核标记无效」这一核心变更。
+ */
+class CockpitCoordinateValidatorTest {
+
+    private static final BigDecimal WEST = new BigDecimal("110.328749941417");
+    private static final BigDecimal SOUTH = new BigDecimal("28.3917230659234");
+    private static final BigDecimal EAST = new BigDecimal("110.465779226663");
+    private static final BigDecimal NORTH = new BigDecimal("28.4913038992907");
+
+    /** 真实生产数据区间内的一个普通点。 */
+    private static final BigDecimal IN_AREA_LON = new BigDecimal("110.4024");
+    private static final BigDecimal IN_AREA_LAT = new BigDecimal("28.4499");
+
+    @Test
+    void inAreaCoordinatesAreValidAndRenderable() {
+        CockpitCoordinateValidation validation =
+                CockpitCoordinateValidator.validate(IN_AREA_LON, IN_AREA_LAT, false);
+
+        assertEquals("VALID", validation.getState());
+        assertNull(validation.getReason(), "合法坐标不得携带原因");
+        assertTrue(validation.isRenderable());
+        assertEquals("WGS84", validation.getCrs());
+    }
+
+    /**
+     * 本次变更的核心:{@code coordinateVerified} 只是历史入参,不再作为准入门槛。
+     * 同一组区内坐标无论传 true 还是 false,结论必须相同。
+     */
+    @Test
+    void coordinateVerifiedNoLongerChangesTheOutcome() {
+        CockpitCoordinateValidation withFlag = CockpitCoordinateValidator.validate(IN_AREA_LON, IN_AREA_LAT, true);
+        CockpitCoordinateValidation withoutFlag = CockpitCoordinateValidator.validate(IN_AREA_LON, IN_AREA_LAT, false);
+
+        assertEquals(withFlag.getState(), withoutFlag.getState());
+        assertEquals("VALID", withoutFlag.getState());
+        assertEquals(withFlag.getReason(), withoutFlag.getReason());
+        assertNull(withoutFlag.getReason());
+        assertEquals(withFlag.isRenderable(), withoutFlag.isRenderable());
+        assertTrue(withoutFlag.isRenderable());
+    }
+
+    /** 东/西/南/北四个方向各来一例:区外一律 INVALID / OUTSIDE_PRIMARY_AREA / 不可上图。 */
+    @ParameterizedTest
+    @CsvSource({
+            "110.500000,28.4500000",
+            "110.300000,28.4500000",
+            "110.400000,28.5000000",
+            "110.400000,28.3000000"})
+    void outsidePrimaryAreaIsRejectedInEveryDirection(String longitude, String latitude) {
+        CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
+                new BigDecimal(longitude), new BigDecimal(latitude), false);
+
+        assertEquals("INVALID", validation.getState());
+        assertEquals("OUTSIDE_PRIMARY_AREA", validation.getReason());
+        assertFalse(validation.isRenderable());
+    }
+
+    /** 区外坐标若同时带校核标记,仍然不可上图——标记不是通行证。 */
+    @Test
+    void outsidePrimaryAreaStaysRejectedEvenWhenMarkedVerified() {
+        CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
+                new BigDecimal("110.1460"), new BigDecimal("28.4570"), true);
+
+        assertEquals("INVALID", validation.getState());
+        assertEquals("OUTSIDE_PRIMARY_AREA", validation.getReason());
+        assertFalse(validation.isRenderable());
+    }
+
+    /** 主场景区是闭区间:正好落在四条边界上算命中。 */
+    @ParameterizedTest
+    @CsvSource({
+            "110.328749941417,28.3917230659234",
+            "110.465779226663,28.4913038992907",
+            "110.328749941417,28.4913038992907",
+            "110.465779226663,28.3917230659234"})
+    void boundaryCoordinatesCountAsInsideTheClosedRectangle(String longitude, String latitude) {
+        BigDecimal westOrEast = new BigDecimal(longitude);
+        BigDecimal southOrNorth = new BigDecimal(latitude);
+
+        assertTrue(CockpitCoordinateValidator.insidePrimaryArea(westOrEast, southOrNorth),
+                "闭区间边界必须算命中");
+
+        CockpitCoordinateValidation validation =
+                CockpitCoordinateValidator.validate(westOrEast, southOrNorth, false);
+        assertEquals("VALID", validation.getState());
+        assertNull(validation.getReason());
+        assertTrue(validation.isRenderable());
+    }
+
+    /** 边界外一丁点就不算命中,避免把闭区间写成开区间或写成越界。 */
+    @Test
+    void oneStepOutsideTheBoundaryIsNotInside() {
+        assertFalse(CockpitCoordinateValidator.insidePrimaryArea(WEST.subtract(new BigDecimal("0.000001")), SOUTH));
+        assertFalse(CockpitCoordinateValidator.insidePrimaryArea(EAST.add(new BigDecimal("0.000001")), NORTH));
+        assertFalse(CockpitCoordinateValidator.insidePrimaryArea(WEST, SOUTH.subtract(new BigDecimal("0.000001"))));
+        assertFalse(CockpitCoordinateValidator.insidePrimaryArea(EAST, NORTH.add(new BigDecimal("0.000001"))));
+    }
+
+    /**
+     * 结构错误优先于范围判定:区外且缺经度必须报 MISSING_LONGITUDE,而不是被 OUTSIDE_PRIMARY_AREA 掩盖。
+     * 零坐标同理优先((0,0) 数值上也在区外,但必须如实报 ZERO_COORDINATE)。
+     */
+    @ParameterizedTest
+    @CsvSource({
+            "null,28.3000000,MISSING_LONGITUDE",
+            "110.300000,null,MISSING_LATITUDE",
+            "181,28.4500000,LONGITUDE_OUT_OF_RANGE",
+            "-181,28.4500000,LONGITUDE_OUT_OF_RANGE",
+            "110.400000,91,LATITUDE_OUT_OF_RANGE",
+            "110.400000,-91,LATITUDE_OUT_OF_RANGE",
+            "0,0,ZERO_COORDINATE"})
+    void structuralErrorsTakePrecedenceOverAreaCheck(String longitude, String latitude, String reason) {
+        BigDecimal longitudeValue = "null".equals(longitude) ? null : new BigDecimal(longitude);
+        BigDecimal latitudeValue = "null".equals(latitude) ? null : new BigDecimal(latitude);
+
+        CockpitCoordinateValidation validation =
+                CockpitCoordinateValidator.validate(longitudeValue, latitudeValue, false);
+
+        assertEquals("INVALID", validation.getState());
+        assertEquals(reason, validation.getReason());
+        assertFalse(validation.isRenderable());
+    }
+
+    /** 数值合法但在区外的点,必须与结构错误区分开:原因只能是 OUTSIDE_PRIMARY_AREA。 */
+    @Test
+    void numericallyLegalButOutOfAreaHasItsOwnReason() {
+        CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
+                new BigDecimal("-180"), new BigDecimal("90"), false);
+
+        assertEquals("INVALID", validation.getState());
+        assertEquals("OUTSIDE_PRIMARY_AREA", validation.getReason());
+        assertFalse(validation.isRenderable());
+    }
+
+    @Test
+    void insidePrimaryAreaIsFalseWhenEitherCoordinateIsMissing() {
+        assertFalse(CockpitCoordinateValidator.insidePrimaryArea(null, IN_AREA_LAT));
+        assertFalse(CockpitCoordinateValidator.insidePrimaryArea(IN_AREA_LON, null));
+        assertFalse(CockpitCoordinateValidator.insidePrimaryArea(null, null));
+    }
+}

+ 16 - 14
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitCurrentEventsControllerTest.java

@@ -107,9 +107,10 @@ class CockpitCurrentEventsControllerTest {
                 .andExpect(jsonPath("$.data.sections.events.data[3].monitoringPointCode").value(nullValue()))
                 .andExpect(jsonPath("$.data.sections.events.data[4].eventNo").value("W-005"))
                 .andExpect(jsonPath("$.data.sections.events.data[4].levelColor").value("UNKNOWN"))
-                .andExpect(jsonPath("$.data.sections.events.data[4].coordinateValidation.state").value("UNVERIFIED"))
-                .andExpect(jsonPath("$.data.sections.events.data[4].coordinateValidation.reason").value("UNVERIFIED_COORDINATE"))
-                .andExpect(jsonPath("$.data.sections.events.data[4].coordinateValidation.renderable").value(false))
+                // 2026-10-12 契约:W-005 坐标 110.381/28.461 落在主场景区内 → 可上图。
+                .andExpect(jsonPath("$.data.sections.events.data[4].coordinateValidation.state").value("VALID"))
+                .andExpect(jsonPath("$.data.sections.events.data[4].coordinateValidation.reason").value(nullValue()))
+                .andExpect(jsonPath("$.data.sections.events.data[4].coordinateValidation.renderable").value(true))
                 .andExpect(jsonPath("$.data.sections.events.data[4].workOrderSummary.stateName").value("已结案"))
                 .andExpect(jsonPath("$.data.sections.workOrders.state").value("SUCCESS"))
                 .andExpect(jsonPath("$.data.sections.workOrders.reason").value("SUCCESS"))
@@ -133,7 +134,8 @@ class CockpitCurrentEventsControllerTest {
                 .andExpect(jsonPath("$.data.sections.events.data[0].thresholdMinValue").value(0.0))
                 .andExpect(jsonPath("$.data.sections.events.data[0].thresholdMaxValue").value(20.0))
                 .andExpect(jsonPath("$.data.sections.events.data[1].eventNo").value("A-002"))
-                .andExpect(jsonPath("$.data.sections.events.data[1].coordinateValidation.state").value("UNVERIFIED"))
+                // 2026-10-12 契约:A-002 坐标 110.400/28.480 落在主场景区内 → 可上图。
+                .andExpect(jsonPath("$.data.sections.events.data[1].coordinateValidation.state").value("VALID"))
                 .andExpect(jsonPath("$.data.sections.events.data[1].thresholdMinValue").value(nullValue()))
                 .andExpect(jsonPath("$.data.sections.events.data[1].workOrderSummary").value(nullValue()))
                 .andExpect(jsonPath("$.data.sections.workOrders.state").value("SUCCESS"))
@@ -346,15 +348,15 @@ class CockpitCurrentEventsControllerTest {
                         warning("W-001", "燃气浓度预警", "GAS_CONCENTRATION", 1, "ACTIVE",
                                 "2026-09-02T09:50:00+08:00", "2026-09-02T09:52:00+08:00",
                                 "12.5", "mg/m³", "D-GAS-1", "燃气浓度计-001", "MP-A-001", "城区A-燃气1号点", "城区A-燃气1号点",
-                                "GAS_SENSOR", "110.130", "28.440", true),
+                                "GAS_SENSOR", "110.360", "28.440", true),
                         warning("W-002", "燃气压力预警", "GAS_PRESSURE", 2, "ACTIVE",
                                 "2026-09-02T09:45:00+08:00", "2026-09-02T09:46:00+08:00",
                                 "0.32", "MPa", "D-GAS-2", "燃气浓度计-002", "MP-A-002", "城区A-燃气2号点", "城区A-燃气2号点",
-                                "GAS_SENSOR", "110.131", "28.441"),
+                                "GAS_SENSOR", "110.361", "28.441"),
                         warning("W-003", "供水压力预警", "WATER_PRESSURE", 3, "PENDING",
                                 "2026-09-02T09:40:00+08:00", "2026-09-02T09:41:00+08:00",
                                 "0.18", "MPa", "D-WS-1", "供水压力计-001", "MP-B-001", "城区B-供水1号点", "城区B-供水1号点",
-                                "WATER_SUPPLY_SENSOR", "110.150", "28.460"),
+                                "WATER_SUPPLY_SENSOR", "110.380", "28.460"),
                         warning("W-004", "污水浓度预警", "SEWAGE_CONCENTRATION", 4, "RELEASED",
                                 "2026-09-02T09:35:00+08:00", "2026-09-02T09:36:00+08:00",
                                 "18.2", "mg/L", "D-DR-1", "污水环境监测仪-001", null, null, "城区C-污水1号点",
@@ -362,24 +364,24 @@ class CockpitCurrentEventsControllerTest {
                         warning("W-005", "供水流量预警", "WATER_FLOW", 9, "ACTIVE",
                                 "2026-09-02T09:30:00+08:00", "2026-09-02T09:31:00+08:00",
                                 "66.6", "L/s", "D-WS-2", "供水压力计-002", "MP-B-002", "城区B-供水2号点", "城区B-供水2号点",
-                                "WATER_SUPPLY_SENSOR", "110.151", "28.461"),
+                                "WATER_SUPPLY_SENSOR", "110.381", "28.461"),
                         warning("W-006", "未知类型预警", "LEGACY_EVENT", 1, "ACTIVE",
                                 "2026-09-02T09:55:00+08:00", null,
                                 "1", "x", "D-LEGACY", "旧设备", null, null, "旧位置",
-                                "LEGACY", "110.100", "28.400"),
+                                "LEGACY", "110.340", "28.400"),
                         warning("W-007", "智能井盖预警", "MANHOLE_EVENT", 1, "ACTIVE",
                                 "2026-09-02T09:55:00+08:00", null,
                                 "1", "x", "D-MH", "智能井盖", null, null, "井盖位置",
-                                "SMART_MANHOLE", "110.101", "28.401")),
+                                "SMART_MANHOLE", "110.341", "28.401")),
                 Arrays.asList(
                         alarm("A-001", "燃气浓度报警", "GAS_CONCENTRATION", 1, "HANDLED",
                                 "2026-09-02T09:55:00+08:00", "2026-09-02T09:56:00+08:00",
                                 "22.6", "mg/m³", "D-GAS-3", "燃气浓度计-003", "MP-A-003", "城区A-燃气3号点", "城区A-燃气3号点",
-                                "GAS_SENSOR", "110.132", "28.442", true, "0.00", "20.00"),
+                                "GAS_SENSOR", "110.362", "28.442", true, "0.00", "20.00"),
                         alarm("A-002", "污水浓度报警", "SEWAGE_CONCENTRATION", 2, "PROCESSING",
                                 "2026-09-02T09:32:00+08:00", "2026-09-02T09:33:00+08:00",
                                 "42.1", "mg/L", "D-DR-1", "污水环境监测仪-001", "MP-C-001", "城区C-污水1号点", "城区C-污水1号点",
-                                "SEWAGE_ENV", "110.170", "28.480")),
+                                "SEWAGE_ENV", "110.400", "28.480")),
                 Arrays.asList(
                         workOrder("W-001", "WO-1", 2, "燃气浓度处置", 3),
                         workOrder("W-003", "WO-2", 3, "供水压力处置", 1),
@@ -409,12 +411,12 @@ class CockpitCurrentEventsControllerTest {
                         warning("W-001", "燃气浓度预警", "GAS_CONCENTRATION", 1, "ACTIVE",
                                 "2026-09-02T09:50:00+08:00", "2026-09-02T09:52:00+08:00",
                                 "12.5", "mg/m³", "D-GAS-1", "燃气浓度计-001", "MP-A-001", "城区A-燃气1号点", "城区A-燃气1号点",
-                                "GAS_SENSOR", "110.130", "28.440")),
+                                "GAS_SENSOR", "110.360", "28.440")),
                 Collections.singletonList(
                         alarm("A-001", "燃气浓度报警", "GAS_CONCENTRATION", 1, "HANDLED",
                                 "2026-09-02T09:55:00+08:00", "2026-09-02T09:56:00+08:00",
                                 "22.6", "mg/m³", "D-GAS-3", "燃气浓度计-003", "MP-A-003", "城区A-燃气3号点", "城区A-燃气3号点",
-                                "GAS_SENSOR", "110.132", "28.442")),
+                                "GAS_SENSOR", "110.362", "28.442")),
                 Collections.emptyList());
     }
 

+ 144 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitFacilityCountCrossEndpointContractTest.java

@@ -0,0 +1,144 @@
+package com.zksy.web.controller.cockpit;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.zksy.WaterSupply.WaterPlantInfo.domain.WaterPlantInfo;
+import com.zksy.WaterSupply.WaterPlantInfo.service.IWaterPlantInfoService;
+import com.zksy.WaterSupply.WaterPumpStation.domain.PumpStationType;
+import com.zksy.WaterSupply.WaterPumpStation.domain.WaterPumpStation;
+import com.zksy.WaterSupply.WaterPumpStation.service.IWaterPumpStationService;
+import com.zksy.WaterSupply.WaterSourceInfo.domain.WaterSourceInfo;
+import com.zksy.WaterSupply.WaterSourceInfo.service.IWaterSourceInfoService;
+import com.zksy.base.domain.ManholeBase;
+import com.zksy.base.domain.PipeNetworkBase;
+import com.zksy.base.service.*;
+import com.zksy.cockpit.infrastructure.*;
+import com.zksy.cockpit.overview.*;
+import com.zksy.drainage.domain.EngineeringFacility;
+import com.zksy.drainage.service.EngineeringFacilityService;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** 只替换表读取,真实设施来源与三套服务贯通;固定数据与时钟。 */
+class CockpitFacilityCountCrossEndpointContractTest {
+    private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-12T00:00:00Z"), ZoneOffset.UTC);
+    private static final ObjectMapper JSON = new ObjectMapper();
+
+    @Test
+    void allLegalFacilityTablesShareTotalsIndustriesAndDistrictSums() {
+        IWaterPlantInfoService plants = mock(IWaterPlantInfoService.class);
+        IWaterPumpStationService stations = mock(IWaterPumpStationService.class);
+        PipeNetworkBaseService pipes = mock(PipeNetworkBaseService.class);
+        EngineeringFacilityService engineering = mock(EngineeringFacilityService.class);
+        IWaterSourceInfoService sources = mock(IWaterSourceInfoService.class);
+        ManholeBaseService manholes = mock(ManholeBaseService.class);
+        WaterPlantInfo plant = new WaterPlantInfo();
+        plant.setPlantCode("SHARED-CODE");
+        plant.setCommissionDate(LocalDate.of(2030, 1, 1));
+        WaterPumpStation water = station("WATER-PUMP", PumpStationType.WATER_SUPPLY);
+        WaterPumpStation drainage = station("DRAIN-PUMP", PumpStationType.DRAINAGE);
+        WaterPumpStation unknownStation = station("UNKNOWN-PUMP", "unknown");
+        WaterSourceInfo source = new WaterSourceInfo();
+        source.setSourceId("SHARED-CODE"); // 跨类型同码是不同设施,不能由管理信息误去重。
+        ManholeBase manhole = new ManholeBase();
+        manhole.setManholeCode("MH-1");
+        EngineeringFacility gate = new EngineeringFacility();
+        gate.setId("GATE-1");
+        gate.setFacilityType("闸门");
+        EngineeringFacility pump = new EngineeringFacility();
+        pump.setId("ENGINEERING-PUMP");
+        pump.setFacilityType("泵站");
+        EngineeringFacility unknown = new EngineeringFacility();
+        unknown.setId("UNKNOWN-1");
+        unknown.setFacilityType("unclassified");
+        WaterPlantInfo missingCode = new WaterPlantInfo();
+        when(plants.list()).thenReturn(Arrays.asList(plant, missingCode));
+        when(stations.list()).thenReturn(Arrays.asList(water, drainage, unknownStation));
+        when(pipes.list()).thenReturn(Arrays.asList(pipe("GAS", "0"), pipe("DRAIN", "1"), pipe("WATER", "2"), pipe("UNKNOWN", "99")));
+        when(engineering.list()).thenReturn(Arrays.asList(gate, pump, unknown));
+        when(sources.list()).thenReturn(Collections.singletonList(source));
+        when(manholes.list()).thenReturn(Collections.singletonList(manhole));
+        CockpitFacilityCatalog catalog = new BusinessCockpitInfrastructureBaseArchivesAdapter(
+                plants, stations, pipes, engineering, sources, manholes);
+        assertThreeEndpoints(catalog, 10, 4);
+    }
+
+    @Test
+    void emptyCatalogProducesZeroCountsAcrossThreeEndpoints() {
+        assertThreeEndpoints(() -> new CockpitInfrastructureBaseArchivesSnapshot(
+                "2026-10-12T08:00:00+08:00", Collections.emptyList(), Collections.emptyList()), 0, 0);
+    }
+
+    private void assertThreeEndpoints(CockpitFacilityCatalog catalog, int total, int excluded) {
+        EquipmentBaseService devices = mock(EquipmentBaseService.class);
+        EquipmentTypeService types = mock(EquipmentTypeService.class);
+        JsonNode archives = JSON.valueToTree(new CockpitInfrastructureBaseArchivesService(catalog::load, CLOCK)
+                .load("archives", null, null, null, null));
+        JsonNode overview = JSON.valueToTree(new CockpitOverviewInfrastructureService(
+                new BusinessCockpitOverviewInfrastructureAdapter(types, devices, catalog), CLOCK).load("overview"));
+        CockpitInfrastructureManagementInformationService managementService = new CockpitInfrastructureManagementInformationService(
+                new BusinessCockpitInfrastructureManagementInformationAdapter(devices, types, catalog), CLOCK);
+        JsonNode management = JSON.valueToTree(managementService.load("management", null, null));
+        for (JsonNode response : Arrays.asList(archives, overview, management)) {
+            assertEquals(total, response.path("totals").path("facilityCount").asInt());
+        }
+        Map<String, Integer> expected = new HashMap<>();
+        expected.put("GAS", total == 0 ? 0 : 1);
+        expected.put("WATER_SUPPLY", total == 0 ? 0 : 4);
+        expected.put("DRAINAGE", total == 0 ? 0 : 4);
+        expected.put("MANHOLE", total == 0 ? 0 : 1);
+        assertEquals(expected, industryCounts(archives, "industryDistribution", "code", "count"));
+        assertEquals(expected, industryCounts(overview, "industries", "code", "facilityCount"));
+        assertEquals(expected, industryCounts(management, "districtIndustry", "industry", "facilityCount"));
+        assertEquals(total, sum(archives, "districtDistribution", "count"), "未来日期不能让片区分布漏设施");
+        assertEquals(total, sum(management, "districtIndustry", "facilityCount"));
+        assertEquals(excluded, archives.path("excluded").path("unknownTypeCount").asInt());
+        assertEquals(excluded, overview.path("excluded").path("unknownTypeCount").asInt());
+        assertEquals(excluded, management.path("excluded").path("unknownIndustryCount").asInt());
+        for (String industry : expected.keySet()) {
+            JsonNode filtered = JSON.valueToTree(managementService.load("filtered", industry, "UNASSIGNED"));
+            assertEquals(expected.get(industry).intValue(), filtered.path("totals").path("facilityCount").asInt());
+        }
+    }
+
+    private static Map<String, Integer> industryCounts(JsonNode response, String field, String key, String value) {
+        Map<String, Integer> result = new HashMap<>();
+        for (String industry : Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE")) result.put(industry, 0);
+        for (JsonNode row : response.path(field)) result.merge(row.path(key).asText(), row.path(value).asInt(), Integer::sum);
+        return result;
+    }
+
+    private static int sum(JsonNode response, String field, String value) {
+        int result = 0;
+        for (JsonNode row : response.path(field)) result += row.path(value).asInt();
+        return result;
+    }
+
+    private static WaterPumpStation station(String code, String type) {
+        WaterPumpStation station = new WaterPumpStation();
+        station.setStationCode(code);
+        station.setStationType(type);
+        return station;
+    }
+
+    private static PipeNetworkBase pipe(String code, String type) {
+        PipeNetworkBase pipe = new PipeNetworkBase();
+        pipe.setNetworkCode(code);
+        pipe.setNetworkType(type);
+        pipe.setLength(BigDecimal.valueOf(1000));
+        return pipe;
+    }
+}

+ 15 - 14
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitFacilityGeometryControllerTest.java

@@ -101,16 +101,16 @@ class CockpitFacilityGeometryControllerTest {
         com.zksy.base.domain.PipeNetworkBase line = new com.zksy.base.domain.PipeNetworkBase();
         line.setNetworkId("gas-line");
         line.setNetworkType("0");
-        line.setStartLongitude(new BigDecimal("110.4"));
-        line.setStartLatitude(new BigDecimal("28.4"));
-        line.setEndLongitude(new BigDecimal("110.6"));
-        line.setEndLatitude(new BigDecimal("28.6"));
+        line.setStartLongitude(new BigDecimal("110.360"));
+        line.setStartLatitude(new BigDecimal("28.420"));
+        line.setEndLongitude(new BigDecimal("110.420"));
+        line.setEndLatitude(new BigDecimal("28.460"));
         com.zksy.base.domain.GasPipePoint point = new com.zksy.base.domain.GasPipePoint();
         point.setPointId(1L);
         point.setPointType("三通");
         point.setNetworkId("gas-line");
-        point.setLongitude(new BigDecimal("110.5"));
-        point.setLatitude(new BigDecimal("28.5"));
+        point.setLongitude(new BigDecimal("110.440"));
+        point.setLatitude(new BigDecimal("28.450"));
         when(networks.list()).thenReturn(Collections.singletonList(line));
         when(points.list()).thenReturn(Collections.singletonList(point));
         when(manholes.list()).thenReturn(Collections.emptyList());
@@ -123,18 +123,19 @@ class CockpitFacilityGeometryControllerTest {
                 .andExpect(jsonPath("$.data.features", hasSize(2)))
                 .andExpect(jsonPath("$.data.features[0].geometry.type").value("LineString"))
                 .andExpect(jsonPath("$.data.features[0].geometry.coordinates", hasSize(2)))
-                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[0][0]").value(110.4))
-                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[0][1]").value(28.4))
-                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[1][0]").value(110.6))
-                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[1][1]").value(28.6))
+                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[0][0]").value(110.360))
+                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[0][1]").value(28.420))
+                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[1][0]").value(110.420))
+                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[1][1]").value(28.460))
                 .andExpect(jsonPath("$.data.features[1].geometry.type").value("Point"))
                 .andExpect(jsonPath("$.data.features[1].geometry.coordinates", hasSize(2)))
-                .andExpect(jsonPath("$.data.features[1].geometry.coordinates[0]").value(110.5))
-                .andExpect(jsonPath("$.data.features[1].geometry.coordinates[1]").value(28.5))
+                .andExpect(jsonPath("$.data.features[1].geometry.coordinates[0]").value(110.440))
+                .andExpect(jsonPath("$.data.features[1].geometry.coordinates[1]").value(28.450))
                 .andExpect(jsonPath("$.data.features[1].properties.pointType").value("三通"))
                 .andExpect(jsonPath("$.data.features[1].properties.networkId").value("gas-line"))
-                .andExpect(jsonPath("$.data.features[1].properties.coordinateValidation.state").value("UNVERIFIED"))
-                .andExpect(jsonPath("$.data.features[1].properties.coordinateValidation.renderable").value(false));
+                // 2026-10-12 契约:区内坐标自动准入 → VALID/可上图。
+                .andExpect(jsonPath("$.data.features[1].properties.coordinateValidation.state").value("VALID"))
+                .andExpect(jsonPath("$.data.features[1].properties.coordinateValidation.renderable").value(true));
     }
 
     @Test

+ 46 - 21
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitFacilityGeometryServiceTest.java

@@ -55,8 +55,8 @@ class CockpitFacilityGeometryServiceTest {
         when(points.list()).thenReturn(Collections.singletonList(point));
         ManholeBase manhole = new ManholeBase();
         manhole.setManholeId("same-id");
-        manhole.setLongitude(new BigDecimal("110.5"));
-        manhole.setLatitude(new BigDecimal("28.5"));
+        manhole.setLongitude(new BigDecimal("110.440"));
+        manhole.setLatitude(new BigDecimal("28.450"));
         when(manholes.list()).thenReturn(Collections.singletonList(manhole));
 
         CockpitFacilityGeometryResponse response = service.load();
@@ -68,8 +68,8 @@ class CockpitFacilityGeometryServiceTest {
                 response.getFeatures().stream().map(item -> item.getProperties().getIndustry()).collect(Collectors.toList()));
         Feature gas = response.getFeatures().get(0);
         assertEquals("LineString", gas.getGeometry().getType());
-        assertEquals(Arrays.asList(Arrays.asList(new BigDecimal("110.4"), new BigDecimal("28.4")),
-                Arrays.asList(new BigDecimal("110.6"), new BigDecimal("28.6"))), gas.getGeometry().getCoordinates());
+        assertEquals(Arrays.asList(Arrays.asList(new BigDecimal("110.360"), new BigDecimal("28.420")),
+                Arrays.asList(new BigDecimal("110.420"), new BigDecimal("28.460"))), gas.getGeometry().getCoordinates());
         assertEquals("GAS_PIPE", gas.getProperties().getTypeCode());
         assertEquals("PIPE_UNCLASSIFIED", response.getFeatures().get(3).getProperties().getTypeCode());
         assertEquals("3", response.getFeatures().get(3).getProperties().getNetworkType());
@@ -119,7 +119,7 @@ class CockpitFacilityGeometryServiceTest {
     }
 
     @Test
-    void invalidEndIsNotMaskedByAnUnverifiedStartAndEntireLineHasNullGeometry() {
+    void invalidEndIsNotMaskedByAValidStartAndEntireLineHasNullGeometry() {
         PipeNetworkBase line = network("bad-end", "1");
         line.setEndLatitude(null);
         when(networks.list()).thenReturn(Collections.singletonList(line));
@@ -127,14 +127,20 @@ class CockpitFacilityGeometryServiceTest {
         Feature feature = response.getFeatures().get(0);
         assertNull(feature.getGeometry());
         assertEquals("MISSING_LATITUDE", feature.getProperties().getCoordinateValidation().getReason());
-        assertEquals("UNVERIFIED", feature.getProperties().getCoordinateValidations().get(0).getState());
+        // 2026-10-12 契约:起点 110.360/28.420 落在主场景区内 → 单独判定为 VALID。
+        assertEquals("VALID", feature.getProperties().getCoordinateValidations().get(0).getState());
         assertEquals("INVALID", feature.getProperties().getCoordinateValidations().get(1).getState());
         assertEquals(1, coverage(response, "DRAINAGE", "LineString").getRecordCount());
         assertEquals(0, coverage(response, "DRAINAGE", "LineString").getGeometryCount());
     }
 
+    /**
+     * 2026-10-12 契约:数值落在 ±180/±90 内不再自动可渲染,判据改为「结构合法 + 落在主场景区内」。
+     * 本用例固化新语义:区内线段/构件可上图;数值合法但在区外的点被判 INVALID/OUTSIDE_PRIMARY_AREA,
+     * 且原始坐标原样保留、对象仍留在清单与统计中(只决定呈现,不决定是否计入)。
+     */
     @Test
-    void numericBoundsDoNotGrantCoordinateVerificationForAnySource() {
+    void outsidePrimaryAreaIsRejectedWhileInAreaCoordinatesRender() {
         when(networks.list()).thenReturn(Collections.singletonList(network("line", "0")));
         when(points.list()).thenReturn(Collections.singletonList(point(1L, "阀门")));
         ManholeBase manhole = new ManholeBase();
@@ -142,15 +148,34 @@ class CockpitFacilityGeometryServiceTest {
         manhole.setLongitude(new BigDecimal("-180"));
         manhole.setLatitude(new BigDecimal("90"));
         when(manholes.list()).thenReturn(Collections.singletonList(manhole));
+
         CockpitFacilityGeometryResponse response = service.load();
-        for (Feature feature : response.getFeatures()) {
-            assertNotNull(feature.getGeometry());
-            assertEquals("UNVERIFIED", feature.getProperties().getCoordinateValidation().getState());
-            assertEquals("UNVERIFIED_COORDINATE", feature.getProperties().getCoordinateValidation().getReason());
-            assertEquals("WGS84", feature.getProperties().getCoordinateValidation().getCrs());
-            assertFalse(feature.getProperties().getCoordinateValidation().isRenderable());
-        }
-        assertTrue(response.getCoverage().stream().allMatch(item -> item.getRenderableCount() == 0));
+
+        Feature line = response.getFeatures().get(0);
+        assertNotNull(line.getGeometry(), "区内两端点线段必须给出几何");
+        assertEquals("VALID", line.getProperties().getCoordinateValidation().getState());
+        assertNull(line.getProperties().getCoordinateValidation().getReason());
+        assertTrue(line.getProperties().getCoordinateValidation().isRenderable());
+
+        Feature component = response.getFeatures().get(1);
+        assertNotNull(component.getGeometry(), "区内构件点必须给出几何");
+        assertEquals("VALID", component.getProperties().getCoordinateValidation().getState());
+        assertTrue(component.getProperties().getCoordinateValidation().isRenderable());
+
+        Feature outside = response.getFeatures().get(2);
+        assertNull(outside.getGeometry());
+        assertEquals("INVALID", outside.getProperties().getCoordinateValidation().getState());
+        assertEquals("OUTSIDE_PRIMARY_AREA", outside.getProperties().getCoordinateValidation().getReason());
+        assertEquals("WGS84", outside.getProperties().getCoordinateValidation().getCrs());
+        assertFalse(outside.getProperties().getCoordinateValidation().isRenderable());
+        assertEquals(Arrays.asList(new BigDecimal("-180"), new BigDecimal("90")),
+                outside.getProperties().getSourceCoordinates().get(0), "区外坐标必须原样保留");
+
+        assertTrue(coverage(response, "GAS", "LineString").getRenderableCount() > 0,
+                "区内线段必须计入可渲染统计");
+        assertEquals(1, coverage(response, "GAS", "LineString").getRenderableCount());
+        assertEquals(1, coverage(response, "GAS", "Point").getRenderableCount());
+        assertEquals(0, coverage(response, "MANHOLE", "Point").getRenderableCount());
     }
 
     @Test
@@ -192,10 +217,10 @@ class CockpitFacilityGeometryServiceTest {
         network.setNetworkId(id);
         network.setNetworkCode("code-" + id);
         network.setNetworkType(type);
-        network.setStartLongitude(new BigDecimal("110.4"));
-        network.setStartLatitude(new BigDecimal("28.4"));
-        network.setEndLongitude(new BigDecimal("110.6"));
-        network.setEndLatitude(new BigDecimal("28.6"));
+        network.setStartLongitude(new BigDecimal("110.360"));
+        network.setStartLatitude(new BigDecimal("28.420"));
+        network.setEndLongitude(new BigDecimal("110.420"));
+        network.setEndLatitude(new BigDecimal("28.460"));
         return network;
     }
 
@@ -203,8 +228,8 @@ class CockpitFacilityGeometryServiceTest {
         GasPipePoint point = new GasPipePoint();
         point.setPointId(id);
         point.setPointType(type);
-        point.setLongitude(new BigDecimal("110.4"));
-        point.setLatitude(new BigDecimal("28.4"));
+        point.setLongitude(new BigDecimal("110.360"));
+        point.setLatitude(new BigDecimal("28.420"));
         return point;
     }
 

+ 11 - 9
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotControllerTest.java

@@ -82,9 +82,11 @@ class CockpitMapSnapshotControllerTest {
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].coordinateValidation.reason").value("LONGITUDE_OUT_OF_RANGE"))
                 .andExpect(jsonPath("$.data.coordinateValidation.crs").value("WGS84"))
                 .andExpect(jsonPath("$.data.coordinateValidation.totalObjectCount").value(7))
-                .andExpect(jsonPath("$.data.coordinateValidation.renderableCount").value(3))
+                // 2026-10-12 契约:3 个点位 + EQ-FREE-002 + EQ-FREE-003 落在主场景区内 → 可上图共 5;
+                // EQ-FREE-001(181) 与 EQ-FREE-004(0,0) 仍为 INVALID,UNVERIFIED 不再产生。
+                .andExpect(jsonPath("$.data.coordinateValidation.renderableCount").value(5))
                 .andExpect(jsonPath("$.data.coordinateValidation.invalidCount").value(2))
-                .andExpect(jsonPath("$.data.coordinateValidation.unverifiedCount").value(2))
+                .andExpect(jsonPath("$.data.coordinateValidation.unverifiedCount").value(0))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].district").value(0))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].districtName").value("城南"))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].industry").value("WATER_SUPPLY"))
@@ -434,7 +436,7 @@ class CockpitMapSnapshotControllerTest {
                                 "燃气调压站监测点",
                                 0,
                                 "GAS_SENSOR",
-                                new BigDecimal("110.142"),
+                                new BigDecimal("110.372"),
                                 new BigDecimal("28.453"),
                                 true,
                                 Collections.singletonList(device(
@@ -444,7 +446,7 @@ class CockpitMapSnapshotControllerTest {
                                 "供水干管监测点",
                                 0,
                                 "WATER_SUPPLY_SENSOR",
-                                new BigDecimal("110.141"),
+                                new BigDecimal("110.371"),
                                 new BigDecimal("28.452"),
                                 true,
                                 Collections.emptyList()),
@@ -453,7 +455,7 @@ class CockpitMapSnapshotControllerTest {
                                 "污水排口监测点",
                                 null,
                                 "SEWAGE_ENV",
-                                new BigDecimal("110.143"),
+                                new BigDecimal("110.373"),
                                 new BigDecimal("28.454"),
                                 false,
                                 Collections.emptyList())),
@@ -463,7 +465,7 @@ class CockpitMapSnapshotControllerTest {
                                 "燃气浓度计-001",
                                 0,
                                 "GAS_SENSOR",
-                                new BigDecimal("110.142"),
+                                new BigDecimal("110.372"),
                                 new BigDecimal("28.453"),
                                 true),
                         new CockpitMapSnapshot.SourceSmartDevice(
@@ -471,7 +473,7 @@ class CockpitMapSnapshotControllerTest {
                                 "智能井盖-002",
                                 null,
                                 null,
-                                new BigDecimal("110.145"),
+                                new BigDecimal("110.375"),
                                 new BigDecimal("28.456"),
                                 true),
                         new CockpitMapSnapshot.SourceSmartDevice(
@@ -487,7 +489,7 @@ class CockpitMapSnapshotControllerTest {
                                 "污水环境监测仪-003",
                                 2,
                                 "SEWAGE_ENV",
-                                new BigDecimal("110.146"),
+                                new BigDecimal("110.376"),
                                 new BigDecimal("28.457"),
                                 false),
                         new CockpitMapSnapshot.SourceSmartDevice(
@@ -524,7 +526,7 @@ class CockpitMapSnapshotControllerTest {
                 pointCode + "监测点",
                 district,
                 typeCode,
-                new BigDecimal("110.141"),
+                new BigDecimal("110.371"),
                 new BigDecimal("28.452"),
                 true,
                 devices);

+ 8 - 2
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java

@@ -382,8 +382,12 @@ class CockpitMonitoringDistributionControllerTest {
         assertEquals(3, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.NORMAL));
     }
 
+    /**
+     * 2026-10-12 契约:坐标能否上图只决定呈现,不决定是否计入台账与统计。
+     * 这六个无类型空点坐标落在主场景区内,故各自可上图;方法名据实去掉「WithoutVerifiedCoordinates」。
+     */
     @Test
-    void sixEmptyUntypedPointsRemainInLedgerAndStatusTotalsWithoutVerifiedCoordinates() throws Exception {
+    void sixEmptyUntypedPointsRemainInLedgerAndStatusTotals() throws Exception {
         List<CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint> points = new java.util.ArrayList<>();
         for (String code : Arrays.asList("GS001", "GS002", "PS001", "PS002", "RQ001", "RQ002")) {
             points.add(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
@@ -405,7 +409,9 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.data.monitoringPoints[0].pointStatus").value("EMPTY"))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].industry").value(nullValue()))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].deviceType.code").value(nullValue()))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.renderable").value(false))
+                // 2026-10-12 契约:110.399876/28.458287 落在主场景区内 → 可上图。
+                .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.state").value("VALID"))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.renderable").value(true))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[5].count").value(6))
                 .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("未分区"))
                 .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(6))

+ 11 - 9
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringPointDetailControllerTest.java

@@ -23,6 +23,7 @@ import java.util.Collections;
 import java.util.List;
 
 import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.nullValue;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verifyNoInteractions;
@@ -85,11 +86,12 @@ class CockpitMonitoringPointDetailControllerTest {
                 .andExpect(jsonPath("$.data.deviceStatistics.offlineDeviceCount").value(1))
                 .andExpect(jsonPath("$.data.devices", hasSize(5)))
                 .andExpect(jsonPath("$.data.devices[0].deviceCode").value("EQ-001"))
-                .andExpect(jsonPath("$.data.devices[0].longitude").value(110.142))
+                .andExpect(jsonPath("$.data.devices[0].longitude").value(110.372))
                  .andExpect(jsonPath("$.data.devices[0].latitude").value(28.453))
-                 .andExpect(jsonPath("$.data.devices[0].coordinateValidation.state").value("UNVERIFIED"))
-                 .andExpect(jsonPath("$.data.devices[0].coordinateValidation.reason").value("UNVERIFIED_COORDINATE"))
-                 .andExpect(jsonPath("$.data.devices[0].coordinateValidation.renderable").value(false))
+                 // 2026-10-12 契约:110.372/28.453 落在主场景区内 → 自动准入。
+                 .andExpect(jsonPath("$.data.devices[0].coordinateValidation.state").value("VALID"))
+                 .andExpect(jsonPath("$.data.devices[0].coordinateValidation.reason").value(nullValue()))
+                 .andExpect(jsonPath("$.data.devices[0].coordinateValidation.renderable").value(true))
                  .andExpect(jsonPath("$.data.devices[0].deviceStatus").value("ALARM"))
                 .andExpect(jsonPath("$.data.devices[0].statusLabel").value("报警"))
                 .andExpect(jsonPath("$.data.devices[1].deviceCode").value("EQ-003"))
@@ -202,7 +204,7 @@ class CockpitMonitoringPointDetailControllerTest {
                 "2026-09-02T10:00:00+08:00", Collections.emptyList(),
                 Collections.singletonList(new CockpitMapSnapshot.SourceMonitoringPoint(
                         "MP-SNAP", "供水干管监测点", null, "11",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
                         Collections.singletonList(
                                 device("EQ-001", true, false, false, true, Collections.emptyList())))),
                 Collections.emptyList());
@@ -238,7 +240,7 @@ class CockpitMonitoringPointDetailControllerTest {
                 "2026-09-02T11:00:00+08:00", Collections.emptyList(),
                 Collections.singletonList(new CockpitMapSnapshot.SourceMonitoringPoint(
                         "MP-SNAP", "供水干管监测点", null, "11",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
                         Collections.emptyList())),
                 Collections.emptyList());
         when(mapAdapter.load("2026-09-02T10:00:00+08:00")).thenReturn(snapshot);
@@ -295,7 +297,7 @@ class CockpitMonitoringPointDetailControllerTest {
                 "2026-09-02T10:00:00+08:00", Collections.emptyList(),
                 Collections.singletonList(new CockpitMapSnapshot.SourceMonitoringPoint(
                         "MP-SNAP", "供水干管监测点", null, "11",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
                         Collections.singletonList(
                                 device(" EQ-001 ", true, false, false, true, Collections.emptyList())))),
                 Collections.emptyList());
@@ -355,7 +357,7 @@ class CockpitMonitoringPointDetailControllerTest {
         return new CockpitMonitoringPointDetail(
                 pointCode,
                 "供水干管监测点",
-                new BigDecimal("110.141"),
+                new BigDecimal("110.371"),
                 new BigDecimal("28.452"),
                 true,
                 devices);
@@ -367,7 +369,7 @@ class CockpitMonitoringPointDetailControllerTest {
                 deviceCode,
                 deviceCode + "设备",
                 "压力计",
-                new BigDecimal("110.142"),
+                new BigDecimal("110.372"),
                 new BigDecimal("28.453"),
                 false,
                 online,

+ 35 - 30
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringPointDetailSnapshotContractTest.java

@@ -63,12 +63,12 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
         CockpitMapSnapshot snapshot = snapshotWith(SNAPSHOT_AT,
                 new CockpitMapSnapshot.SourceMonitoringPoint(
                         "MP-001", "供水干管监测点", null, "11",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
                         Arrays.asList(
-                                device("EQ-003", "110.142", "28.453", false, true, false, false, true),
-                                device("EQ-001", "110.143", "28.454", false, true, true, false, true),
-                                device("EQ-005", "110.144", "28.455", false, false, false, true, true),
-                                device("EQ-002", "110.145", "28.456", false, true, false, false, false),
+                                device("EQ-003", "110.372", "28.453", false, true, false, false, true),
+                                device("EQ-001", "110.373", "28.454", false, true, true, false, true),
+                                device("EQ-005", "110.374", "28.455", false, false, false, true, true),
+                                device("EQ-002", "110.375", "28.456", false, true, false, false, false),
                                 device("EQ-004", null, null, false, false, false, false, false))));
         when(mapAdapter.load(SNAPSHOT_AT)).thenReturn(snapshot);
 
@@ -102,12 +102,13 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
         CockpitMapSnapshot snapshot = snapshotWith(SNAPSHOT_AT,
                 new CockpitMapSnapshot.SourceMonitoringPoint(
                         "MP-001", "供水干管监测点", null, "11",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
                         Arrays.asList(
-                                device("EQ-VALID", "110.142", "28.453", false, true, true, false, false),
+                                device("EQ-VALID", "110.372", "28.453", false, true, true, false, false),
                                 device("EQ-MISSING", null, null, false, false, false, false, false),
                                 device("EQ-INVALID", "200.000", "28.453", true, true, false, false, false),
-                                device("EQ-UNVERIFIED", "110.146", "28.457", false, true, false, false, false))));
+                                // 110.146 刻意留在主场景区外,用于固化 OUTSIDE_PRIMARY_AREA 这一新原因
+                                device("EQ-OUTSIDE", "110.146", "28.457", false, true, false, false, false))));
         when(mapAdapter.load(SNAPSHOT_AT)).thenReturn(snapshot);
 
         CockpitMonitoringPointDetailResponse response =
@@ -116,27 +117,31 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
         List<CockpitMonitoringPointDeviceCard> devices = response.getDevices();
         assertEquals(4, devices.size());
 
+        // 2026-10-12 契约:区内坐标自动准入 → VALID / reason=null / 可上图。
         CockpitMonitoringPointDeviceCard valid = byCode(devices, "EQ-VALID");
-        assertEquals(new BigDecimal("110.142"), valid.getLongitude());
+        assertEquals(new BigDecimal("110.372"), valid.getLongitude());
         assertEquals(new BigDecimal("28.453"), valid.getLatitude());
-        assertEquals("UNVERIFIED", valid.getCoordinateValidation().getState());
-        assertFalse(valid.getCoordinateValidation().isRenderable());
+        assertEquals("VALID", valid.getCoordinateValidation().getState());
+        assertNull(valid.getCoordinateValidation().getReason());
+        assertTrue(valid.getCoordinateValidation().isRenderable());
 
         CockpitMonitoringPointDeviceCard missing = byCode(devices, "EQ-MISSING");
         assertNull(missing.getLongitude());
         assertNull(missing.getLatitude());
         assertFalse(missing.getCoordinateValidation().isRenderable());
-        assertEquals("UNVERIFIED_COORDINATE", missing.getCoordinateValidation().getReason());
+        assertEquals("MISSING_LONGITUDE", missing.getCoordinateValidation().getReason());
 
         CockpitMonitoringPointDeviceCard invalid = byCode(devices, "EQ-INVALID");
         assertEquals("INVALID", invalid.getCoordinateValidation().getState());
         assertEquals("LONGITUDE_OUT_OF_RANGE", invalid.getCoordinateValidation().getReason());
         assertFalse(invalid.getCoordinateValidation().isRenderable());
 
-        CockpitMonitoringPointDeviceCard unverified = byCode(devices, "EQ-UNVERIFIED");
-        assertEquals("UNVERIFIED", unverified.getCoordinateValidation().getState());
-        assertEquals("UNVERIFIED_COORDINATE", unverified.getCoordinateValidation().getReason());
-        assertFalse(unverified.getCoordinateValidation().isRenderable());
+        CockpitMonitoringPointDeviceCard outside = byCode(devices, "EQ-OUTSIDE");
+        assertEquals(new BigDecimal("110.146"), outside.getLongitude(), "区外原始坐标必须原样保留");
+        assertEquals(new BigDecimal("28.457"), outside.getLatitude());
+        assertEquals("INVALID", outside.getCoordinateValidation().getState());
+        assertEquals("OUTSIDE_PRIMARY_AREA", outside.getCoordinateValidation().getReason());
+        assertFalse(outside.getCoordinateValidation().isRenderable());
     }
 
     @Test
@@ -144,7 +149,7 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
         CockpitMapSnapshot snapshot = snapshotWith("2026-09-02T11:00:00+08:00",
                 new CockpitMapSnapshot.SourceMonitoringPoint(
                         "MP-001", "供水干管监测点", null, "11",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
                         Collections.emptyList()));
         when(mapAdapter.load(SNAPSHOT_AT)).thenReturn(snapshot);
 
@@ -157,7 +162,7 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
         CockpitMapSnapshot snapshot = snapshotWith(SNAPSHOT_AT,
                 new CockpitMapSnapshot.SourceMonitoringPoint(
                         "MP-OTHER", "另一个点位", null, "11",
-                        new BigDecimal("110.1"), new BigDecimal("28.4"), false,
+                        new BigDecimal("110.340"), new BigDecimal("28.4"), false,
                         Collections.emptyList()));
         when(mapAdapter.load(SNAPSHOT_AT)).thenReturn(snapshot);
 
@@ -171,8 +176,8 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
         when(detailAdapter.load("MP-001"))
                 .thenReturn(new com.zksy.cockpit.point.CockpitMonitoringPointDetail(
                         "MP-001", "供水干管监测点",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
-                        Collections.singletonList(device("EQ-001", "110.142", "28.453",
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
+                        Collections.singletonList(device("EQ-001", "110.372", "28.453",
                                 false, true, false, false, false))));
 
         CockpitMonitoringPointDetailResponse response =
@@ -187,8 +192,8 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
         when(detailAdapter.load("MP-001"))
                 .thenReturn(new com.zksy.cockpit.point.CockpitMonitoringPointDetail(
                         "MP-001", "供水干管监测点",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
-                        Collections.singletonList(device("EQ-001", "110.142", "28.453",
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
+                        Collections.singletonList(device("EQ-001", "110.372", "28.453",
                                 false, true, false, false, false))));
 
         CockpitMonitoringPointDetailResponse response =
@@ -210,12 +215,12 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
         CockpitMapSnapshot snapshot = snapshotWith(SNAPSHOT_AT,
                 new CockpitMapSnapshot.SourceMonitoringPoint(
                         "MP-001", "供水干管监测点", null, "11",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
                         Arrays.asList(
-                                device("EQ-001", "110.142", "28.453", false, true, false, false, false),
-                                device(" EQ-001 ", "110.143", "28.454", false, true, false, false, false),
-                                device(null, "110.144", "28.455", false, false, false, false, false),
-                                device("", "110.145", "28.456", false, false, false, false, false),
+                                device("EQ-001", "110.372", "28.453", false, true, false, false, false),
+                                device(" EQ-001 ", "110.373", "28.454", false, true, false, false, false),
+                                device(null, "110.374", "28.455", false, false, false, false, false),
+                                device("", "110.375", "28.456", false, false, false, false, false),
                                 device("EQ-002", null, null, false, false, false, false, false))));
         when(mapAdapter.load(SNAPSHOT_AT)).thenReturn(snapshot);
 
@@ -244,9 +249,9 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
         CockpitMapSnapshot snapshot = snapshotWith(SNAPSHOT_AT,
                 new CockpitMapSnapshot.SourceMonitoringPoint(
                         "MP-001", "供水干管监测点", null, "11",
-                        new BigDecimal("110.141"), new BigDecimal("28.452"), false,
+                        new BigDecimal("110.371"), new BigDecimal("28.452"), false,
                         Collections.singletonList(
-                                device("EQ-VERIFIED", "110.142", "28.453",
+                                device("EQ-VERIFIED", "110.372", "28.453",
                                         true, true, false, false, false))));
         when(mapAdapter.load(SNAPSHOT_AT)).thenReturn(snapshot);
 
@@ -254,7 +259,7 @@ class CockpitMonitoringPointDetailSnapshotContractTest {
                 detailService.load("req", "MP-001", SNAPSHOT_AT);
 
         CockpitMonitoringPointDeviceCard verified = byCode(response.getDevices(), "EQ-VERIFIED");
-        assertEquals(new BigDecimal("110.142"), verified.getLongitude());
+        assertEquals(new BigDecimal("110.372"), verified.getLongitude());
         assertEquals(new BigDecimal("28.453"), verified.getLatitude());
         assertEquals("VALID", verified.getCoordinateValidation().getState());
         assertTrue(verified.getCoordinateValidation().isRenderable());

+ 11 - 10
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringSituationControllerTest.java

@@ -407,27 +407,28 @@ class CockpitMonitoringSituationControllerTest {
                         type("SMART_MANHOLE", "智能井盖", "MANHOLE")),
                 Arrays.asList(
                         device("D-GAS-1", "燃气浓度计-001", 0, "GAS_SENSOR",
-                                "NORMAL", Collections.emptyList(), "110.130", "28.440", true),
+                                "NORMAL", Collections.emptyList(), "110.360", "28.440", true),
                         device("D-GAS-2", "燃气浓度计-002", 0, "GAS_SENSOR",
-                                "WARNING", Collections.singletonList("流量波动"), "110.131", "28.441", true),
+                                "WARNING", Collections.singletonList("流量波动"), "110.361", "28.441", true),
                         device("D-GAS-3", "燃气浓度计-003", 0, "GAS_SENSOR",
-                                "ALARM", Collections.singletonList("浓度超限"), "110.132", "28.442", true),
+                                "ALARM", Collections.singletonList("浓度超限"), "110.362", "28.442", true),
                         device("D-WS-1", "供水压力计-001", 1, "WATER_SUPPLY_SENSOR",
-                                "EXPIRED", Collections.singletonList("采样过期"), "110.150", "28.460", true),
+                                "EXPIRED", Collections.singletonList("采样过期"), "110.380", "28.460", true),
                         // 台账未登记片区 → 未分区(null),如实呈现而非猜一个片区
                         device("D-WS-2", "供水压力计-002", null, "WATER_SUPPLY_SENSOR",
-                                "OFFLINE", Collections.singletonList("离线"), "110.151", "28.461", true),
+                                "OFFLINE", Collections.singletonList("离线"), "110.381", "28.461", true),
                         // 无片区来源 → 未分区(null),如实呈现而非猜一个片区
                         device("D-DR-1", "污水环境监测仪-001", null, "SEWAGE_ENV",
-                                "NORMAL", Collections.emptyList(), "110.170", "28.480", true),
+                                "NORMAL", Collections.emptyList(), "110.400", "28.480", true),
                         device("D-DR-2", "污水环境监测仪-002", null, "SEWAGE_ENV",
-                                "UNKNOWN", Collections.singletonList("状态缺失"), "110.171", "28.481", true),
+                                "UNKNOWN", Collections.singletonList("状态缺失"), "110.401", "28.481", true),
                         device("D-MISMATCH", "水厂设备", 0, "WATER_PLANT",
-                                "NORMAL", Collections.emptyList(), "110.120", "28.430", true),
+                                "NORMAL", Collections.emptyList(), "110.350", "28.430", true),
                         device("D-UNKNOWN", "旧设备", 0, "LEGACY",
-                                "NORMAL", Collections.emptyList(), "110.121", "28.431", true),
+                                "NORMAL", Collections.emptyList(), "110.351", "28.431", true),
                         device("D-MH", "智能井盖", 2, "SMART_MANHOLE",
-                                "NORMAL", Collections.emptyList(), "110.122", "28.432", true),
+                                "NORMAL", Collections.emptyList(), "110.352", "28.432", true),
+                        // 越界经度保持原样:它要测的就是 LONGITUDE_OUT_OF_RANGE 导致不可上图
                         device("D-EXCL-GAS", "越界坐标燃气计", 0, "GAS_SENSOR",
                                 "ALARM", Collections.singletonList("浓度超限"), "181", "28.443", true)),
                 Arrays.asList(

+ 8 - 12
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitPipeNetworkCrossEndpointContractTest.java

@@ -1,7 +1,5 @@
 package com.zksy.web.controller.cockpit;
 
-import com.baomidou.mybatisplus.core.MybatisConfiguration;
-import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.zksy.WaterSupply.WaterPlantInfo.service.IWaterPlantInfoService;
@@ -24,7 +22,8 @@ import com.zksy.cockpit.infrastructure.CockpitInfrastructureOperatingInformation
 import com.zksy.cockpit.overview.BusinessCockpitOverviewInfrastructureAdapter;
 import com.zksy.cockpit.overview.CockpitOverviewInfrastructureService;
 import com.zksy.drainage.service.EngineeringFacilityService;
-import org.apache.ibatis.builder.MapperBuilderAssistant;
+
+
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 
@@ -47,11 +46,6 @@ class CockpitPipeNetworkCrossEndpointContractTest {
     private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-12T00:00:00Z"), ZoneOffset.UTC);
     private static final ObjectMapper JSON = new ObjectMapper();
 
-    @BeforeAll
-    static void initializeLambdaCache() {
-        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
-                WaterPumpStation.class);
-    }
 
     @Test
     void numericAndHistoricalTypesHaveEqualIndustryCountsAcrossFiveEndpoints() {
@@ -81,19 +75,21 @@ class CockpitPipeNetworkCrossEndpointContractTest {
         EquipmentTypeService equipmentTypes = mock(EquipmentTypeService.class);
         EquipmentBaseService equipment = mock(EquipmentBaseService.class);
 
+        com.zksy.cockpit.infrastructure.CockpitFacilityCatalog catalog =
+                new BusinessCockpitInfrastructureBaseArchivesAdapter(plants, stations, pipes, engineering,
+                        mock(IWaterSourceInfoService.class), mock(ManholeBaseService.class));
         // 只替换表读取;类型解析、未知计数、行业归属与响应构造全部走真实代码。
         JsonNode archives = JSON.valueToTree(new CockpitInfrastructureBaseArchivesService(
-                new BusinessCockpitInfrastructureBaseArchivesAdapter(plants, stations, pipes, engineering, CLOCK),
+                catalog::load,
                 CLOCK).load("archives", null, null, null, null));
         JsonNode management = JSON.valueToTree(new CockpitInfrastructureManagementInformationService(
-                new BusinessCockpitInfrastructureManagementInformationAdapter(equipment, equipmentTypes,
-                        mock(ManholeBaseService.class), pipes, mock(IWaterSourceInfoService.class)),
+                new BusinessCockpitInfrastructureManagementInformationAdapter(equipment, equipmentTypes, catalog),
                 CLOCK).load("management", null, null));
         JsonNode operating = JSON.valueToTree(new CockpitInfrastructureOperatingInformationService(
                 new BusinessCockpitInfrastructureOperatingInformationAdapter(plants, stations, pipes, engineering),
                 CLOCK, 60).load("operating", null, null, null, null));
         JsonNode overview = JSON.valueToTree(new CockpitOverviewInfrastructureService(
-                new BusinessCockpitOverviewInfrastructureAdapter(equipmentTypes, equipment, pipes, plants, stations),
+                new BusinessCockpitOverviewInfrastructureAdapter(equipmentTypes, equipment, catalog),
                 CLOCK).load("overview"));
         JsonNode boundary = JSON.valueToTree(new CockpitDeviceBoundaryService(
                 new BusinessCockpitDeviceBoundaryAdapter(equipmentTypes, equipment, pipes), CLOCK).load("boundary"));

+ 1 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/EquipmentBase.java

@@ -90,7 +90,7 @@ public class EquipmentBase {
     @ApiModelProperty("设备图片URL")
     private String equipmentImage;
 
-    @ApiModelProperty("创建时间")
+@ApiModelProperty("创建时间")
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
     private LocalDateTime createTime;
 

+ 0 - 58
pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/StationVideoDevice.java

@@ -1,58 +0,0 @@
-package com.zksy.base.domain;
-
-import com.baomidou.mybatisplus.annotation.IdType;
-import com.baomidou.mybatisplus.annotation.TableId;
-import com.baomidou.mybatisplus.annotation.TableName;
-import com.fasterxml.jackson.annotation.JsonFormat;
-import io.swagger.annotations.ApiModelProperty;
-import lombok.AllArgsConstructor;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
-import java.time.LocalDateTime;
-
-/**
- * 场站视频监控设备表
- */
-@Data
-@AllArgsConstructor
-@NoArgsConstructor
-@TableName(value = "station_video_device", schema = "app_user")
-public class StationVideoDevice {
-
-    @TableId(value = "video_id", type = IdType.ASSIGN_UUID)
-    @ApiModelProperty("视频设备ID")
-    private String videoId;
-
-    @ApiModelProperty("关联场站ID")
-    private String stationId;
-
-    @ApiModelProperty("摄像头名称")
-    private String deviceName;
-
-    @ApiModelProperty("设备编码")
-    private String deviceCode;
-
-    @ApiModelProperty("视频流地址")
-    private String videoUrl;
-
-    @ApiModelProperty("安装位置描述")
-    private String location;
-
-    @ApiModelProperty("状态")
-    private String status;
-
-    @ApiModelProperty("厂商")
-    private String manufacturer;
-
-    @ApiModelProperty("备注")
-    private String remark;
-
-    @ApiModelProperty("创建时间")
-    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
-    private LocalDateTime createTime;
-
-    @ApiModelProperty("更新时间")
-    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
-    private LocalDateTime updateTime;
-}

+ 39 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/mapper/EquipmentVideoBindingMapper.java

@@ -0,0 +1,39 @@
+package com.zksy.base.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.base.video.EquipmentVideoBinding;
+import com.zksy.base.video.VideoDeviceRecord;
+import org.apache.ibatis.annotations.*;
+import java.util.List;
+
+@Mapper
+public interface EquipmentVideoBindingMapper extends BaseMapper<EquipmentVideoBinding> {
+    String VIEW = "SELECT e.*, e.equipment_id AS video_id, e.equipment_name AS device_name, " +
+        "e.equipment_code AS device_code, e.equipment_location AS location, t.type_name AS equipment_type_name, " +
+        "s.current_status, v.source_type, v.video_url, v.gb_device_id, v.gb_channel_id, v.station_id, v.status " +
+        "FROM app_user.equipment_base e JOIN app_user.equipment_video_binding v ON v.equipment_id=e.equipment_id " +
+        "LEFT JOIN app_user.equipment_type t ON t.id=e.equipment_type_id " +
+        "LEFT JOIN app_user.equipment_status s ON s.equipment_id=e.equipment_id AND s.del_flag='0' ";
+
+    @Select("<script>" + VIEW + "WHERE e.equipment_type_id=20 AND e.del_flag='0' " +
+        "<if test='query != null and query != &quot;&quot;'>AND (e.equipment_name LIKE CONCAT('%',#{query},'%') " +
+        "OR e.equipment_code LIKE CONCAT('%',#{query},'%') OR v.gb_device_id LIKE CONCAT('%',#{query},'%') " +
+        "OR v.gb_channel_id LIKE CONCAT('%',#{query},'%'))</if> " +
+        "<if test='sourceType != null and sourceType != &quot;&quot;'>AND v.source_type=#{sourceType}</if> " +
+        "<if test='stationId != null and stationId != &quot;&quot;'>AND v.station_id=#{stationId}</if> " +
+        "ORDER BY e.create_time DESC,e.equipment_id</script>")
+    Page<VideoDeviceRecord> list(Page<VideoDeviceRecord> page, @Param("query") String query,
+        @Param("sourceType") String sourceType, @Param("stationId") String stationId);
+
+    @Select(VIEW + "WHERE e.equipment_id=#{id} AND e.equipment_type_id=20 AND e.del_flag='0'")
+    VideoDeviceRecord find(@Param("id") String id);
+
+    @Select(VIEW + "WHERE e.equipment_type_id=20 AND e.del_flag='0' ORDER BY e.create_time DESC,e.equipment_id")
+    List<VideoDeviceRecord> all();
+
+    @Update("UPDATE app_user.equipment_video_binding SET source_type=#{sourceType},video_url=#{videoUrl}," +
+        "gb_device_id=#{gbDeviceId},gb_channel_id=#{gbChannelId},station_id=#{stationId}," +
+        "status=#{status},update_time=#{updateTime} WHERE equipment_id=#{equipmentId}")
+    int updateBinding(EquipmentVideoBinding binding);
+}

+ 0 - 9
pipe-network-service/zksy-system/src/main/java/com/zksy/base/mapper/StationVideoDeviceMapper.java

@@ -1,9 +0,0 @@
-package com.zksy.base.mapper;
-
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import com.zksy.base.domain.StationVideoDevice;
-import org.apache.ibatis.annotations.Mapper;
-
-@Mapper
-public interface StationVideoDeviceMapper extends BaseMapper<StationVideoDevice> {
-}

+ 12 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/mapper/VideoSessionMapper.java

@@ -0,0 +1,12 @@
+package com.zksy.base.mapper;
+
+import com.zksy.base.video.VideoSession;
+
+import org.apache.ibatis.annotations.*;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+@Mapper
+public interface VideoSessionMapper extends BaseMapper<VideoSession> {
+    @Select("SELECT equipment_id FROM app_user.equipment_base WHERE equipment_id=#{videoId} FOR UPDATE")
+    String lockVideo(@Param("videoId") String videoId);
+}

+ 0 - 11
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/StationVideoDeviceService.java

@@ -1,11 +0,0 @@
-package com.zksy.base.service;
-
-import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import com.baomidou.mybatisplus.extension.service.IService;
-import com.zksy.base.domain.StationVideoDevice;
-
-public interface StationVideoDeviceService extends IService<StationVideoDevice> {
-
-    Page<StationVideoDevice> findByPage(long pageNum, long pageSize,
-                                        String deviceName, String stationId, String status);
-}

+ 0 - 28
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/StationVideoDeviceServiceImpl.java

@@ -1,28 +0,0 @@
-package com.zksy.base.service.impl;
-
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
-import com.zksy.base.domain.StationVideoDevice;
-import com.zksy.base.mapper.StationVideoDeviceMapper;
-import com.zksy.base.service.StationVideoDeviceService;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
-
-@Slf4j
-@Service
-public class StationVideoDeviceServiceImpl extends ServiceImpl<StationVideoDeviceMapper, StationVideoDevice>
-        implements StationVideoDeviceService {
-
-    @Override
-    public Page<StationVideoDevice> findByPage(long pageNum, long pageSize,
-                                               String deviceName, String stationId, String status) {
-        Page<StationVideoDevice> page = new Page<>(pageNum, pageSize);
-        LambdaQueryWrapper<StationVideoDevice> wrapper = new LambdaQueryWrapper<>();
-        wrapper.like(deviceName != null && !deviceName.isEmpty(), StationVideoDevice::getDeviceName, deviceName);
-        wrapper.eq(stationId != null && !stationId.isEmpty(), StationVideoDevice::getStationId, stationId);
-        wrapper.eq(status != null && !status.isEmpty(), StationVideoDevice::getStatus, status);
-        wrapper.orderByDesc(StationVideoDevice::getCreateTime);
-        return this.page(page, wrapper);
-    }
-}

+ 21 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/video/EquipmentVideoBinding.java

@@ -0,0 +1,21 @@
+package com.zksy.base.video;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.time.LocalDateTime;
+
+/** 仅存视频接入信息,设备基础与资产信息仍由 equipment_base 管理。 */
+@Data
+@TableName("equipment_video_binding")
+public class EquipmentVideoBinding {
+    @TableId private String equipmentId;
+    private String sourceType;
+    private String videoUrl;
+    private String gbDeviceId;
+    private String gbChannelId;
+    private String stationId;
+    private String status;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 43 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/video/VideoCollectorClient.java

@@ -0,0 +1,43 @@
+package com.zksy.base.video;
+
+import java.util.*;
+import java.net.URI;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.http.*;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.client.RestClientException;
+import org.springframework.web.util.UriComponentsBuilder;
+import com.zksy.common.exception.ServiceException;
+
+@Component
+public class VideoCollectorClient {
+    private final String base;
+    private final String token;
+    private final RestTemplate http;
+    public VideoCollectorClient(@Value("${video.service-url:http://127.0.0.1:8330}") String base,
+                                @Value("${video.internal-token:${VIDEO_INTERNAL_TOKEN:}}") String token) {
+        this.base=base; this.token=token;
+        SimpleClientHttpRequestFactory factory=new SimpleClientHttpRequestFactory();
+        factory.setConnectTimeout(3000); factory.setReadTimeout(95000);
+        this.http=new RestTemplate(factory);
+    }
+    public Object get(String path,Map<String,?> params) { return call(HttpMethod.GET,path,params,null); }
+    public Object post(String path,Object body) { return call(HttpMethod.POST,path,Collections.emptyMap(),body); }
+    public Object call(HttpMethod method,String path,Map<String,?> params,Object body) {
+        if(token.length()<32) throw new ServiceException("视频服务未配置内部鉴权,请设置 VIDEO_INTERNAL_TOKEN 后重启主服务");
+        UriComponentsBuilder builder=UriComponentsBuilder.fromHttpUrl(base).path(path);
+        params.forEach((key,value)->{if(value!=null) builder.queryParam(key,value);});
+        URI uri=builder.build().encode().toUri();
+        HttpHeaders headers=new HttpHeaders(); headers.set("X-Video-Token",token); headers.setContentType(MediaType.APPLICATION_JSON);
+        try {
+            ResponseEntity<Map> response=http.exchange(uri,method,new HttpEntity<>(body,headers),Map.class);
+            Map payload=response.getBody();
+            if(payload==null) return Collections.emptyMap();
+            Number code=(Number)payload.get("code");
+            if(code!=null && code.intValue()!=0 && code.intValue()!=200) throw new ServiceException(String.valueOf(payload.getOrDefault("msg","视频操作失败")));
+            return payload.containsKey("data") ? payload.get("data") : payload;
+        } catch(RestClientException error) { throw new ServiceException("国标视频服务连接失败或操作超时,请检查 video-service 与媒体节点"); }
+    }
+}

+ 23 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/video/VideoDeviceRecord.java

@@ -0,0 +1,23 @@
+package com.zksy.base.video;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import com.zksy.base.domain.EquipmentBase;
+
+
+/** equipment_base 与 equipment_video_binding 的联查视图,兼容视频页面字段。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class VideoDeviceRecord extends EquipmentBase {
+    private String videoId;
+    private String stationId;
+    private String deviceName;
+    private String deviceCode;
+    private String videoUrl;
+    private String sourceType;
+    private String gbDeviceId;
+    private String gbChannelId;
+    private String location;
+    private String status;
+    private Integer currentStatus;
+}

+ 282 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/video/VideoDeviceService.java

@@ -0,0 +1,282 @@
+package com.zksy.base.video;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.mapper.EquipmentBaseMapper;
+import com.zksy.base.mapper.EquipmentVideoBindingMapper;
+import org.springframework.beans.BeanUtils;
+import com.zksy.base.mapper.VideoSessionMapper;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.common.exception.ServiceException;
+import com.zksy.common.utils.SecurityUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import java.net.URI;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+/** 视频设备业务。视频台账统一使用 equipment_base,国标信令和媒体仍由 video-service 负责。 */
+@Slf4j
+@Service
+@EnableScheduling
+public class VideoDeviceService {
+
+    public static final int VIDEO_EQUIPMENT_TYPE_ID = 20;
+
+    private static final String GB28181 = "GB28181";
+
+    private static final DateTimeFormatter FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+    private final EquipmentBaseMapper devices;
+    private final EquipmentVideoBindingMapper bindings;
+    private final VideoCollectorClient collector;
+    private final VideoSessionMapper sessions;
+    private final EquipmentBaseService equipmentService;
+
+    public VideoDeviceService(EquipmentBaseMapper devices, VideoCollectorClient collector, VideoSessionMapper sessions, EquipmentBaseService equipmentService, EquipmentVideoBindingMapper bindings) {
+        this.bindings=bindings;
+        this.devices=devices;
+        this.collector=collector;
+        this.sessions=sessions;
+        this.equipmentService=equipmentService;
+    }
+
+    public Page<VideoDeviceRecord> list(int pageNum,int pageSize,String query,String sourceType,String stationId) {
+        return bindings.list(new Page<>(Math.max(1,pageNum),Math.max(1,Math.min(100,pageSize))),query,sourceType,stationId);
+    }
+
+    public VideoDeviceRecord one(String id) { return find(id); }
+    public List<VideoDeviceRecord> all() { return bindings.all(); }
+
+    @Transactional
+    public String save(VideoRequests.Save input) {
+        boolean insert=input.getVideoId()==null||input.getVideoId().isBlank();
+        if(insert&&!GB28181.equals(input.getSourceType())) throw new ServiceException("视频设备请从发现注册设备中纳管,手动添加入口已关闭");
+        EquipmentBase row=insert?new EquipmentBase():find(input.getVideoId());
+        if(!insert) { sessions.lockVideo(row.getEquipmentId()); ensureIdle(row.getEquipmentId()); }
+        if(input.getEquipment()!=null) {
+            BeanUtils.copyProperties(input.getEquipment(),row,"equipmentId","equipmentTypeId","createTime","updateTime");
+            if(row.getDistrict()==null || row.getDistrict()<0 || row.getDistrict()>2) throw new ServiceException("请选择有效的所属片区");
+        }
+        EquipmentVideoBinding binding=new EquipmentVideoBinding();
+        if(GB28181.equals(input.getSourceType())) {
+            requireGbId(input.getGbDeviceId()); requireGbId(input.getGbChannelId());
+            if(!Boolean.TRUE.equals(collector.get("/internal/video/exists",Map.of("deviceId",input.getGbDeviceId(),"channelId",input.getGbChannelId())))) throw new ServiceException("设备或通道尚未注册,请先同步目录");
+            LambdaQueryWrapper<EquipmentVideoBinding> duplicate=new LambdaQueryWrapper<EquipmentVideoBinding>()
+                .eq(EquipmentVideoBinding::getGbDeviceId,input.getGbDeviceId()).eq(EquipmentVideoBinding::getGbChannelId,input.getGbChannelId());
+            if(!insert) duplicate.ne(EquipmentVideoBinding::getEquipmentId,row.getEquipmentId());
+            if(bindings.selectCount(duplicate)>0) throw new ServiceException("此设备通道已纳管");
+            binding.setGbDeviceId(input.getGbDeviceId()); binding.setGbChannelId(input.getGbChannelId());
+        } else { validateManualUrl(input.getVideoUrl()); binding.setVideoUrl(input.getVideoUrl()); }
+        String code=input.getDeviceCode()==null||input.getDeviceCode().isBlank()?null:input.getDeviceCode().trim();
+        if(code==null) code=GB28181.equals(input.getSourceType())?input.getGbDeviceId()+"/"+input.getGbChannelId():row.getEquipmentCode();
+        if(code!=null) {
+            LambdaQueryWrapper<EquipmentBase> duplicateCode=new LambdaQueryWrapper<EquipmentBase>().eq(EquipmentBase::getEquipmentCode,code);
+            if(!insert) duplicateCode.ne(EquipmentBase::getEquipmentId,row.getEquipmentId());
+            if(devices.selectCount(duplicateCode)>0) throw new ServiceException("设备编码已被其他设备使用,请修改编码");
+        }
+        row.setEquipmentTypeId(VIDEO_EQUIPMENT_TYPE_ID); row.setEquipmentName(input.getDeviceName().trim());
+        row.setEquipmentCode(code); row.setEquipmentLocation(input.getLocation()); row.setManufacturer(input.getManufacturer());
+        row.setRemark(input.getRemark()); row.setUpdateTime(LocalDateTime.now());
+        binding.setSourceType(input.getSourceType()); binding.setStationId(input.getStationId());
+        binding.setStatus("0"); binding.setUpdateTime(row.getUpdateTime());
+        try {
+            if(insert) {
+                row.setCreateTime(LocalDateTime.now());
+                if(!equipmentService.saveWithCheck(row)) throw new ServiceException("设备保存失败");
+                binding.setEquipmentId(row.getEquipmentId()); binding.setCreateTime(row.getCreateTime());
+                bindings.insert(binding);
+            } else {
+                // Use a base entity so protocol view fields cannot enter the equipment mapper.
+                EquipmentBase equipment=new EquipmentBase(); BeanUtils.copyProperties(row,equipment);
+                if(input.getEquipment()!=null) {
+                    if(!equipmentService.updateWithCheck(equipment)) throw new ServiceException("设备修改失败");
+                } else if(devices.updateById(equipment)==0) throw new ServiceException("设备修改失败");
+                binding.setEquipmentId(row.getEquipmentId()); bindings.updateBinding(binding);
+            }
+            if(input.getCurrentStatus()!=null && !equipmentService.updateEquipmentStatus(row.getEquipmentId(),input.getCurrentStatus()))
+                throw new ServiceException("设备资产状态保存失败");
+        } catch(DuplicateKeyException error) { throw new ServiceException("设备编码或国标通道已存在,请刷新列表后检查"); }
+        return row.getEquipmentId();
+    }
+
+    @Transactional
+    public void remove(String id) {
+        VideoDeviceRecord row=find(id); sessions.lockVideo(id); ensureIdle(id);
+        // The binding foreign key follows the existing ledger deletion transaction.
+        equipmentService.removeWithCheck(row.getEquipmentId());
+    }
+
+    public Object discover(String query,Boolean online,int pageNum,int pageSize) {
+        Map<String,Object> p=new HashMap<>();
+        p.put("query",query);
+        p.put("online",online);
+        p.put("pageNum",pageNum);
+        p.put("pageSize",pageSize);
+        return collector.get("/internal/video/devices",p);
+    }
+
+    public void sync(String id) {
+        requireGbId(id);
+        collector.post("/internal/video/devices/"+id+"/sync",Map.of());
+    }
+    @SuppressWarnings("unchecked")
+
+    public Map<String,Object> syncAll() {
+        Object response=collector.post("/internal/video/devices/sync",Map.of());
+        return response instanceof Map?new HashMap<>((Map<String,Object>)response):Map.of();
+    }
+    @Transactional
+    @SuppressWarnings("unchecked") public Map<String,Object> start(String id,VideoRequests.Range range) {
+        VideoDeviceRecord row=find(id);
+        sessions.lockVideo(id);
+        String mode=range==null?"LIVE":"PLAYBACK";
+        Object response;
+        if(range!=null) {
+            requireGb(row);
+            validateRange(range);
+            if(sessions.selectCount(new LambdaQueryWrapper<VideoSession>().eq(VideoSession::getVideoId,id).eq(VideoSession::getMode,"PLAYBACK").eq(VideoSession::getStatus,"ACTIVE"))>0)throw new ServiceException("此通道已有录像回放,请结束后重试");
+            response=collector.get("/api/playback/start",rangeParams(row,range));
+        }
+        else if(GB28181.equals(row.getSourceType()))response=collector.get("/api/play/start/"+row.getGbDeviceId()+"/"+row.getGbChannelId(),Map.of());
+        else {
+            validateManualUrl(row.getVideoUrl());
+            String scheme=URI.create(row.getVideoUrl()).getScheme().toLowerCase(Locale.ROOT);
+            response=scheme.startsWith("rt")?collector.post("/internal/video/manual/start",Map.of("id",id,"url",row.getVideoUrl())):Map.of("url",row.getVideoUrl(),"native",true);
+        }
+        if(!(response instanceof Map))throw new ServiceException("媒体服务没有返回有效播放信息");
+        Map<String,Object> data=new HashMap<>((Map<String,Object>)response);
+        VideoSession session=new VideoSession();
+        session.setSessionId(UUID.randomUUID().toString());
+        session.setVideoId(id);
+        session.setUserId(SecurityUtils.getUserId());
+        session.setMode(mode);
+        session.setStatus("ACTIVE");
+        session.setGbDeviceId(row.getGbDeviceId());
+        session.setGbChannelId(row.getGbChannelId());
+        session.setStreamId((String)data.get("stream"));
+        session.setProxyKey((String)data.remove("proxyKey"));
+        session.setCreateTime(LocalDateTime.now());
+        session.setLastSeen(session.getCreateTime());
+        sessions.insert(session);
+        data.put("sessionId",session.getSessionId());
+        data.put("mode",mode);
+        return data;
+    }
+
+    public Object records(String id,VideoRequests.Range range) {
+        VideoDeviceRecord row=find(id);
+        requireGb(row);
+        validateRange(range);
+        return collector.get("/api/gb_record/query",rangeParams(row,range));
+    }
+
+    public void ptz(String id,VideoRequests.Ptz input) {
+        VideoDeviceRecord row=find(id);
+        requireGb(row);
+        collector.get("/api/front-end/ptz/"+row.getGbDeviceId()+"/"+row.getGbChannelId(),Map.of("command",input.getCommand(),"horizonSpeed",input.getSpeed(),"verticalSpeed",input.getSpeed(),"zoomSpeed",input.getZoomSpeed()));
+    }
+    private VideoSession owned(String id) {
+        VideoSession s=sessions.selectById(id);
+        if(s==null||!Objects.equals(s.getUserId(),SecurityUtils.getUserId()))throw new ServiceException("播放会话不存在或无权操作");
+        return s;
+    }
+    @Transactional
+
+    public void heartbeat(String id) {
+        VideoSession s=owned(id);
+        if("ACTIVE".equals(s.getStatus())) {
+            s.setLastSeen(LocalDateTime.now());
+            sessions.updateById(s);
+        }
+    }
+    @Transactional
+
+    public void stop(String id) {
+        finish(owned(id));
+    }
+    private void finish(VideoSession session) {
+        if(!"ACTIVE".equals(session.getStatus()))return;
+        sessions.lockVideo(session.getVideoId());
+        VideoSession current=sessions.selectById(session.getSessionId());
+        if(current==null||!"ACTIVE".equals(current.getStatus()))return;
+        long others=sessions.selectCount(new LambdaQueryWrapper<VideoSession>().eq(VideoSession::getVideoId,session.getVideoId()).eq(VideoSession::getStatus,"ACTIVE").eq(VideoSession::getMode,"LIVE").ne(VideoSession::getSessionId,session.getSessionId()));
+        if("PLAYBACK".equals(session.getMode()))collector.get("/api/playback/stop/"+session.getGbDeviceId()+"/"+session.getGbChannelId()+"/"+safeStream(session.getStreamId()),Map.of());
+        else if(others==0&&session.getGbDeviceId()!=null)collector.get("/api/play/stop/"+session.getGbDeviceId()+"/"+session.getGbChannelId(),Map.of());
+        else if(others==0&&session.getProxyKey()!=null)collector.post("/internal/video/manual/stop",Map.of("key",session.getProxyKey()));
+        current.setStatus("STOPPED");
+        current.setEndTime(LocalDateTime.now());
+        sessions.updateById(current);
+    }
+
+    public void control(String id,VideoRequests.Control input) {
+        VideoSession s=owned(id);
+        if(!"ACTIVE".equals(s.getStatus())||!"PLAYBACK".equals(s.getMode()))throw new ServiceException("没有正在进行的录像回放");
+        String suffix="";
+        if("speed".equals(input.getAction())) {
+            if(input.getValue()==null||!Set.of(0.25,0.5,1.0,2.0,4.0).contains(input.getValue()))throw new ServiceException("回放速度支持 0.25、0.5、1、2、4 倍");
+            suffix="/"+input.getValue();
+        }
+        else if("seek".equals(input.getAction())) {
+            if(input.getValue()==null||!Double.isFinite(input.getValue())||input.getValue()<0||input.getValue()>604800)throw new ServiceException("回放位置超出范围");
+            suffix="/"+input.getValue().longValue();
+        }
+        collector.get("/api/playback/"+input.getAction()+"/"+safeStream(s.getStreamId())+suffix,Map.of());
+    }
+    @Scheduled(fixedDelay=30000)
+    @Transactional public void expireSessions() {
+        for(VideoSession s:sessions.selectList(new LambdaQueryWrapper<VideoSession>().eq(VideoSession::getStatus,"ACTIVE").lt(VideoSession::getLastSeen,LocalDateTime.now().minusSeconds(120))))try {
+            finish(s);
+        }
+        catch(Exception e) {
+            log.warn("视频会话回收失败,稍后重试: {}",s.getSessionId());
+        }
+    }
+    private VideoDeviceRecord find(String id) {
+        VideoDeviceRecord row=bindings.find(id);
+        if(row==null) throw new ServiceException("视频设备不存在");
+        return row;
+    }
+    private void ensureIdle(String id) {
+        if(sessions.selectCount(new LambdaQueryWrapper<VideoSession>().eq(VideoSession::getVideoId,id).eq(VideoSession::getStatus,"ACTIVE"))>0)throw new ServiceException("设备正在播放,请结束播放后再修改或移除");
+    }
+
+    static void requireGbId(String value) {
+        if(value==null||!value.matches("[0-9]{20}"))throw new ServiceException("国标设备和通道编号必须为 20 位数字");
+    }
+
+    private static void requireGb(VideoDeviceRecord row) {
+        if(!GB28181.equals(row.getSourceType()))throw new ServiceException("此功能需要国标设备通道");
+    }
+
+    private static String safeStream(String value) {
+        if(value==null||!value.matches("[A-Za-z0-9_-]{1,200}"))throw new ServiceException("无效播放流编号");
+        return value;
+    }
+    static void validateManualUrl(String value) {
+        try {
+            URI uri=URI.create(value);
+            if(uri.getHost()==null||!Set.of("rtsp","rtsps","rtmp","rtmps","http","https","ws","wss").contains(uri.getScheme().toLowerCase(Locale.ROOT)))throw new IllegalArgumentException();
+        }
+        catch(Exception e) {
+            throw new ServiceException("请输入有效的视频地址");
+        }
+    }
+    static void validateRange(VideoRequests.Range range) {
+        try {
+            LocalDateTime from=LocalDateTime.parse(range.getStartTime(),FORMAT),to=LocalDateTime.parse(range.getEndTime(),FORMAT);
+            if(!to.isAfter(from)||Duration.between(from,to).compareTo(Duration.ofDays(7))>0)throw new IllegalArgumentException();
+        }
+        catch(Exception e) {
+            throw new ServiceException("录像时间格式为 yyyy-MM-dd HH:mm:ss,结束时间须晚于开始时间且范围不超过 7 天");
+        }
+    }
+
+    private static Map<String,Object> rangeParams(VideoDeviceRecord row,VideoRequests.Range range) {
+        return Map.of("deviceId",row.getGbDeviceId(),"channelId",row.getGbChannelId(),"startTime",range.getStartTime(),"endTime",range.getEndTime(),"type","all");
+    }
+}

+ 42 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/video/VideoRequests.java

@@ -0,0 +1,42 @@
+package com.zksy.base.video;
+
+import lombok.Data;
+import javax.validation.Valid;
+import com.zksy.base.domain.EquipmentBase;
+import javax.validation.constraints.*;
+
+public final class VideoRequests {
+    private VideoRequests() {}
+    @Data
+    public static class Save {
+        @Valid private EquipmentBase equipment;
+        @Min(0) @Max(2) private Integer currentStatus;
+        @Size(max=64) private String videoId;
+        @NotBlank @Size(max=256) private String deviceName;
+        @Size(max=64) private String deviceCode;
+        @Size(max=64) private String stationId;
+        @Size(max=512) private String location;
+        @Size(max=128) private String manufacturer;
+        @Size(max=2000) private String remark;
+        @Pattern(regexp="MANUAL|GB28181") @NotBlank private String sourceType;
+        @Size(max=512) private String videoUrl;
+        @Pattern(regexp="[0-9]{20}") private String gbDeviceId;
+        @Pattern(regexp="[0-9]{20}") private String gbChannelId;
+    }
+    @Data
+    public static class Range {
+        @NotBlank private String startTime;
+        @NotBlank private String endTime;
+    }
+    @Data
+    public static class Ptz {
+        @NotBlank @Pattern(regexp="left|right|up|down|upleft|upright|downleft|downright|zoomin|zoomout|stop") private String command;
+        @Min(1) @Max(255) private Integer speed=100;
+        @Min(1) @Max(15) private Integer zoomSpeed=8;
+    }
+    @Data
+    public static class Control {
+        @NotBlank @Pattern(regexp="pause|resume|seek|speed") private String action;
+        private Double value;
+    }
+}

+ 22 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/video/VideoSession.java

@@ -0,0 +1,22 @@
+package com.zksy.base.video;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import java.time.LocalDateTime;
+
+@Data
+@TableName(value="video_play_session",schema="app_user")
+public class VideoSession {
+    @TableId(type=IdType.INPUT) private String sessionId;
+    private String videoId;
+    private Long userId;
+    private String mode;
+    private String streamId;
+    private String proxyKey;
+    private String gbDeviceId;
+    private String gbChannelId;
+    private String status;
+    private LocalDateTime createTime;
+    private LocalDateTime lastSeen;
+    private LocalDateTime endTime;
+}

+ 2 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/equipment/BusinessCockpitEquipmentArchiveAdapter.java

@@ -27,7 +27,8 @@ import java.util.stream.Collectors;
  *
  * <p><b>不臆造</b>:现网 {@code equipment_base.equipment_image} 只有单个图片 URL、无拍摄时间列,
  * 因此图片 {@code capturedAt} 保持 {@code null}(服务层在无时间戳时将该图视为当前图片);
- * 坐标与台账一致,不补充校核来源。
+ * 坐标与台账一致,按原值透传,不改写也不补充校核来源(2026-10-12 起准入由
+ * {@code CockpitCoordinateValidator} 自动判定,不再要求人工校核字段)。
  */
 @Component
 @Primary

+ 3 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/BusinessCockpitEventAdapter.java

@@ -42,8 +42,9 @@ import java.util.stream.Collectors;
  * 这里统一按设备编码解析类型,并通过 {@code alarm_data.work_order_id} 与
  * {@code alarm_warning_rel} 回填事件工单关联,避免各端点各自猜测关联键。</p>
  *
- * <p>现有表没有可信的坐标校核字段,事件坐标即使来自记录也始终标记为未校核,
- * 由契约层统一判定为不可渲染。成因在当前模型中没有可靠来源,保持 {@code null};
+ * <p>现有表没有可信的坐标校核字段,事件坐标按原值透传({@code coordinateVerified} 传
+ * {@code false} 但**不再作为准入门槛**);准入由 {@code CockpitCoordinateValidator} 自动判定,
+ * 结构合法且落在主场景区内即可渲染。事件自身的「成因」在当前模型中没有可靠来源,保持 {@code null};
  * 事件片区沿设备解析 {@code equipment_base.district},无设备/未识别时如实为
  * {@code null}(未分区),不由城名或行业字段冒充。</p>
  */

+ 40 - 3
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitCoordinateValidator.java

@@ -2,17 +2,42 @@ package com.zksy.cockpit.foundation;
 
 import java.math.BigDecimal;
 
+/**
+ * 坐标准入判据(唯一一处)。
+ *
+ * <p><b>2026-10-12 维护者裁定(取消人工校核前置)</b>:原先第一道判断是
+ * {@code if (!coordinateVerified) → UNVERIFIED/不可渲染},而现网既没有可信校核字段,
+ * 也没有任何校核入口,导致全部真实点位/设备/几何恒为不可上图(实测 map-snapshot 15/15、
+ * geometry 5272/5272 全不可渲染)。维护者判定该步骤没有必要:**进入数据库的坐标即视为可信来源**,
+ * 改为按「结构合法 + 落在主场景区范围内」自动准入。
+ *
+ * <p>判定顺序刻意是「结构错误优先」:缺经纬度、越界、零坐标各有具体原因,
+ * 不能被范围判定掩盖;只有结构合法时才继续判是否落在主场景区内。
+ *
+ * <p>{@code coordinateVerified} 入参保留:它仍写入快照/Redis 序列化形状与既有调用点,
+ * 但**不再作为准入依据**(现网所有适配器都传 {@code false},若继续当门槛就等于恒不可上图)。
+ * 参数名与调用点保持原样是为了让本改动只落在判据一处,便于复核。
+ */
 public final class CockpitCoordinateValidator {
     private static final String WGS84 = "WGS84";
 
+    /**
+     * 主场景区矩形 {@code [西, 南, 东, 北]}(度)。
+     *
+     * <p>取值与部署配置 {@code map.primaryArea.bounds} 一致(甲方框定的沅陵县城范围)。
+     * 该范围只作**呈现准入**:区外坐标保留在清单与详情中并给出明确原因,
+     * 不静默丢弃、不改写坐标(CONTEXT.md「无地图位置」)。
+     */
+    public static final BigDecimal WEST = new BigDecimal("110.328749941417");
+    public static final BigDecimal SOUTH = new BigDecimal("28.3917230659234");
+    public static final BigDecimal EAST = new BigDecimal("110.465779226663");
+    public static final BigDecimal NORTH = new BigDecimal("28.4913038992907");
+
     private CockpitCoordinateValidator() {
     }
 
     public static CockpitCoordinateValidation validate(BigDecimal longitude, BigDecimal latitude,
             boolean coordinateVerified) {
-        if (!coordinateVerified) {
-            return new CockpitCoordinateValidation("UNVERIFIED", "UNVERIFIED_COORDINATE", false, WGS84);
-        }
         if (longitude == null) {
             return new CockpitCoordinateValidation("INVALID", "MISSING_LONGITUDE", false, WGS84);
         }
@@ -28,6 +53,18 @@ public final class CockpitCoordinateValidator {
         if (longitude.signum() == 0 && latitude.signum() == 0) {
             return new CockpitCoordinateValidation("INVALID", "ZERO_COORDINATE", false, WGS84);
         }
+        if (!insidePrimaryArea(longitude, latitude)) {
+            return new CockpitCoordinateValidation("INVALID", "OUTSIDE_PRIMARY_AREA", false, WGS84);
+        }
         return new CockpitCoordinateValidation("VALID", null, true, WGS84);
     }
+
+    /** 是否落在主场景区内(闭区间;边界值算命中)。 */
+    public static boolean insidePrimaryArea(BigDecimal longitude, BigDecimal latitude) {
+        if (longitude == null || latitude == null) {
+            return false;
+        }
+        return longitude.compareTo(WEST) >= 0 && longitude.compareTo(EAST) <= 0
+                && latitude.compareTo(SOUTH) >= 0 && latitude.compareTo(NORTH) <= 0;
+    }
 }

+ 100 - 138
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/BusinessCockpitInfrastructureBaseArchivesAdapter.java

@@ -1,12 +1,15 @@
 package com.zksy.cockpit.infrastructure;
 
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.zksy.WaterSupply.WaterPlantInfo.domain.WaterPlantInfo;
 import com.zksy.WaterSupply.WaterPlantInfo.service.IWaterPlantInfoService;
 import com.zksy.WaterSupply.WaterPumpStation.domain.PumpStationType;
 import com.zksy.WaterSupply.WaterPumpStation.domain.WaterPumpStation;
 import com.zksy.WaterSupply.WaterPumpStation.service.IWaterPumpStationService;
+import com.zksy.WaterSupply.WaterSourceInfo.domain.WaterSourceInfo;
+import com.zksy.WaterSupply.WaterSourceInfo.service.IWaterSourceInfoService;
+import com.zksy.base.domain.ManholeBase;
 import com.zksy.base.domain.PipeNetworkBase;
+import com.zksy.base.service.ManholeBaseService;
 import com.zksy.base.service.PipeNetworkBaseService;
 import com.zksy.cockpit.foundation.CockpitPipeNetworkType;
 import com.zksy.drainage.domain.EngineeringFacility;
@@ -15,8 +18,8 @@ import org.springframework.context.annotation.Primary;
 import org.springframework.stereotype.Component;
 
 import java.math.BigDecimal;
-import java.time.Clock;
 import java.time.LocalDate;
+import java.time.LocalDateTime;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
@@ -27,180 +30,139 @@ import java.util.Objects;
 import java.util.stream.Collectors;
 
 /**
- * 基础档案真实数据适配器(BT-5 第 6 片)。
- *
- * <p>组合现有设施表,不新增投影:水厂、共用泵站、统一管网基础表和排水工程设施。
- * 只有实体明确提供的字段才映射;缺少统一来源的规模、片区或长度保持 null,避免把
- * 容量、坐标、自由文本位置或展示文本误当作基础档案事实。
- *
- * <p>**片区(district)一律传 null**:水厂/泵站/管网/排水设施都没有
- * {@code equipment_base.district} 那种权威片区列,也不关联设备,故如实归入「未分区」,
- * 不新增数据库列、不用行业或管理单位冒充(决策票 06 / 执行票 12)。
+ * 三个驾驶舱设施统计端点的权威设施目录(执行票 31)。
+ * 包含管网、水厂、供排水泵站、排水工程设施、水源地与窨井的合法记录并集。
+ * 设施无权威片区来源,一律保持 null;未知类型或缺编码行以哨兵保留到 excluded。
  */
 @Component
 @Primary
 public class BusinessCockpitInfrastructureBaseArchivesAdapter
-        implements CockpitInfrastructureBaseArchivesAdapter {
-
+        implements CockpitInfrastructureBaseArchivesAdapter, CockpitFacilityCatalog {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+    private static final String UNCLASSIFIED = "FACILITY_UNCLASSIFIED";
     private static final List<CockpitInfrastructureBaseArchivesSnapshot.SourceType> TYPES = Arrays.asList(
             type("WATER_PLANT", "水厂", "WATER_SUPPLY"),
             type("WATER_PUMP_STATION", "供水泵站", "WATER_SUPPLY"),
-            type(CockpitPipeNetworkType.WATER_PIPE,
-                    CockpitPipeNetworkType.nameOf(CockpitPipeNetworkType.WATER_PIPE),
-                    CockpitPipeNetworkType.industryOfTypeCode(CockpitPipeNetworkType.WATER_PIPE)),
-            type(CockpitPipeNetworkType.GAS_PIPE,
-                    CockpitPipeNetworkType.nameOf(CockpitPipeNetworkType.GAS_PIPE),
-                    CockpitPipeNetworkType.industryOfTypeCode(CockpitPipeNetworkType.GAS_PIPE)),
-            type(CockpitPipeNetworkType.DRAINAGE_PIPE,
-                    CockpitPipeNetworkType.nameOf(CockpitPipeNetworkType.DRAINAGE_PIPE),
-                    CockpitPipeNetworkType.industryOfTypeCode(CockpitPipeNetworkType.DRAINAGE_PIPE)),
+            type(CockpitPipeNetworkType.WATER_PIPE, CockpitPipeNetworkType.nameOf(CockpitPipeNetworkType.WATER_PIPE), "WATER_SUPPLY"),
+            type(CockpitPipeNetworkType.GAS_PIPE, CockpitPipeNetworkType.nameOf(CockpitPipeNetworkType.GAS_PIPE), "GAS"),
+            type(CockpitPipeNetworkType.DRAINAGE_PIPE, CockpitPipeNetworkType.nameOf(CockpitPipeNetworkType.DRAINAGE_PIPE), "DRAINAGE"),
             type("DRAINAGE_GATE", "排水闸门", "DRAINAGE"),
             type("DRAINAGE_STORAGE", "排水调蓄设施", "DRAINAGE"),
-            type("DRAINAGE_PUMP_STATION", "排水泵站", "DRAINAGE"));
+            type("DRAINAGE_PUMP_STATION", "排水泵站", "DRAINAGE"),
+            type("WATER_SOURCE", "水源地", "WATER_SUPPLY"),
+            type("MANHOLE", "窨井", "MANHOLE"));
 
-    private final IWaterPlantInfoService plantService;
-    private final IWaterPumpStationService pumpStationService;
-    private final PipeNetworkBaseService pipeNetworkBaseService;
-    private final EngineeringFacilityService engineeringFacilityService;
-    private final Clock clock;
+    private final IWaterPlantInfoService plants;
+    private final IWaterPumpStationService stations;
+    private final PipeNetworkBaseService pipes;
+    private final EngineeringFacilityService engineering;
+    private final IWaterSourceInfoService sources;
+    private final ManholeBaseService manholes;
 
     public BusinessCockpitInfrastructureBaseArchivesAdapter(
-            IWaterPlantInfoService plantService,
-            IWaterPumpStationService pumpStationService,
-            PipeNetworkBaseService pipeNetworkBaseService,
-            EngineeringFacilityService engineeringFacilityService,
-            Clock clock) {
-        this.plantService = plantService;
-        this.pumpStationService = pumpStationService;
-        this.pipeNetworkBaseService = pipeNetworkBaseService;
-        this.engineeringFacilityService = engineeringFacilityService;
-        this.clock = clock;
+            IWaterPlantInfoService plants, IWaterPumpStationService stations,
+            PipeNetworkBaseService pipes, EngineeringFacilityService engineering,
+            IWaterSourceInfoService sources, ManholeBaseService manholes) {
+        this.plants = plants;
+        this.stations = stations;
+        this.pipes = pipes;
+        this.engineering = engineering;
+        this.sources = sources;
+        this.manholes = manholes;
     }
 
     @Override
     public CockpitInfrastructureBaseArchivesSnapshot load() {
         List<CockpitInfrastructureBaseArchivesSnapshot.SourceFacility> facilities = new ArrayList<>();
-        addPlants(facilities);
-        addPumpStations(facilities);
-        addPipes(facilities);
-        addDrainageFacilities(facilities);
-        return new CockpitInfrastructureBaseArchivesSnapshot(
-                TIME_FORMATTER.format(clock.instant().atZone(BUSINESS_ZONE)), TYPES, facilities);
-    }
-
-    private void addPlants(List<CockpitInfrastructureBaseArchivesSnapshot.SourceFacility> facilities) {
-        for (WaterPlantInfo plant : safeList(plantService.list())) {
-            String code = trimToNull(plant.getPlantCode());
-            if (code == null) {
-                continue;
-            }
-            facilities.add(facility(code, "WATER_PLANT", null, null,
-                    plant.getCommissionDate(), null));
+        LocalDateTime updatedAt = null;
+        for (WaterPlantInfo plant : safeList(plants.list())) {
+            updatedAt = latest(updatedAt, plant.getUpdateTime());
+            updatedAt = latest(updatedAt, plant.getCreateTime());
+            add(facilities, key("water_plant_info", plant.getId(), plant.getPlantCode()), plant.getPlantCode(), "WATER_PLANT", null, plant.getCommissionDate(), null);
         }
-    }
-
-    private void addPumpStations(List<CockpitInfrastructureBaseArchivesSnapshot.SourceFacility> facilities) {
-        for (WaterPumpStation station : safeList(pumpStationService.list(
-                new LambdaQueryWrapper<WaterPumpStation>()
-                        .in(WaterPumpStation::getStationType,
-                                PumpStationType.WATER_SUPPLY, PumpStationType.DRAINAGE)))) {
-            String code = trimToNull(station.getStationCode());
-            if (code == null) {
-                continue;
-            }
+        // 驾驶舱跨行业统计读取共用表全集,按 station_type 分类;未知归属显式 excluded。
+        // 不用于供水/排水管理入口(其强制归属过滤规则仍保持 ADR-0007)。
+        for (WaterPumpStation station : safeList(stations.list())) {
+            updatedAt = latest(updatedAt, station.getUpdateTime());
+            updatedAt = latest(updatedAt, station.getCreateTime());
             String stationType = trimToNull(station.getStationType());
-            String typeCode = PumpStationType.DRAINAGE.equalsIgnoreCase(stationType)
-                    ? "DRAINAGE_PUMP_STATION"
-                    : PumpStationType.WATER_SUPPLY.equalsIgnoreCase(stationType)
-                            ? "WATER_PUMP_STATION" : null;
-            if (typeCode == null) {
-                continue;
-            }
-            facilities.add(facility(code, typeCode, null, null,
-                    station.getCommissionDate(), null));
+            String type = PumpStationType.DRAINAGE.equalsIgnoreCase(stationType) ? "DRAINAGE_PUMP_STATION"
+                    : PumpStationType.WATER_SUPPLY.equalsIgnoreCase(stationType) ? "WATER_PUMP_STATION" : UNCLASSIFIED;
+            add(facilities, key("pump_station", station.getId(), station.getStationCode()), station.getStationCode(), type, null, station.getCommissionDate(), null);
         }
-    }
-
-    private void addPipes(List<CockpitInfrastructureBaseArchivesSnapshot.SourceFacility> facilities) {
-        for (PipeNetworkBase pipe : safeList(pipeNetworkBaseService.list())) {
-            String code = trimToNull(pipe.getNetworkCode());
-            if (code == null) {
-                continue;
-            }
-            // 权威映射在 CockpitPipeNetworkType(0-燃气/1-排水/2-供水)。无法映射的取值
-            // 归入未分类哨兵码:设施仍留在响应里,并由服务层计入 excluded.unknownTypeCount,
-            // 不再被静默 continue 掉(执行票 20:4272 条管网曾因漏认 0/1 而整体丢失)。
-            String typeCode = CockpitPipeNetworkType.typeCodeOf(pipe.getNetworkType());
-            BigDecimal lengthKm = pipe.getLength() == null
-                    ? null : pipe.getLength().divide(BigDecimal.valueOf(1000));
-            LocalDate builtAt = pipe.getBuildDate() == null
-                    ? null : new java.sql.Date(pipe.getBuildDate().getTime()).toLocalDate();
-            facilities.add(facility(code, typeCode, null, lengthKm, null, builtAt));
+        for (PipeNetworkBase pipe : safeList(pipes.list())) {
+            updatedAt = latest(updatedAt, pipe.getUpdateTime());
+            updatedAt = latest(updatedAt, pipe.getCreateTime());
+            BigDecimal lengthKm = pipe.getLength() == null ? null : pipe.getLength().divide(BigDecimal.valueOf(1000));
+            LocalDate builtAt = pipe.getBuildDate() == null ? null
+                    : new java.sql.Date(pipe.getBuildDate().getTime()).toLocalDate();
+            add(facilities, key("pipe_network_base", pipe.getNetworkId(), pipe.getNetworkCode()), pipe.getNetworkCode(), CockpitPipeNetworkType.typeCodeOf(pipe.getNetworkType()),
+                    lengthKm, null, builtAt);
+        }
+        for (EngineeringFacility facility : safeList(engineering.list())) {
+            updatedAt = latest(updatedAt, facility.getUpdateTime());
+            updatedAt = latest(updatedAt, facility.getCreateTime());
+            // 保留现有工程设施年限映射;本票只统一设施范围,不裁定建设日期来源。
+            add(facilities, key("psgw_engineering_facility", facility.getId(), facility.getId()),
+                    facility.getId(), drainageType(facility.getFacilityType()), null, null,
+                    facility.getCreateTime() == null ? null : facility.getCreateTime().toLocalDate());
         }
+        for (WaterSourceInfo source : safeList(sources.list())) {
+            updatedAt = latest(updatedAt, source.getUpdateTime());
+            updatedAt = latest(updatedAt, source.getCreateTime());
+            add(facilities, key("water_source_info", source.getId(), source.getSourceId()), source.getSourceId(), "WATER_SOURCE", null, null, null);
+        }
+        for (ManholeBase manhole : safeList(manholes.list())) {
+            updatedAt = latest(updatedAt, manhole.getUpdateTime());
+            updatedAt = latest(updatedAt, manhole.getCreateTime());
+            add(facilities, key("manhole_base", manhole.getManholeId(), manhole.getManholeCode()), manhole.getManholeCode(), "MANHOLE", null, null, null);
+        }
+        return new CockpitInfrastructureBaseArchivesSnapshot(
+                updatedAt == null ? null : DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(updatedAt.atZone(BUSINESS_ZONE)),
+                TYPES, facilities);
     }
 
-    private void addDrainageFacilities(
-            List<CockpitInfrastructureBaseArchivesSnapshot.SourceFacility> facilities) {
-        for (EngineeringFacility source : safeList(engineeringFacilityService.list())) {
-            String code = trimToNull(source.getId());
-            String typeCode = drainageFacilityType(source.getFacilityType());
-            if (code == null || typeCode == null) {
-                continue;
-            }
-            // 片区一律 null:排水工程设施只有自由文本 area,不是 equipment_base.district,
-            // 用它冒充片区会凭空造出片区归属(决策票 06),故如实交未分区。
-            facilities.add(facility(code, typeCode, null, null, null,
-                    source.getCreateTime() == null ? null : source.getCreateTime().toLocalDate()));
+    private static void add(List<CockpitInfrastructureBaseArchivesSnapshot.SourceFacility> facilities,
+            String sourceKey, String code, String type, BigDecimal lengthKm, LocalDate commissionedAt, LocalDate builtAt) {
+        String resolvedCode = trimToNull(code);
+        if (resolvedCode == null) {
+            // 缺少标识不能进入正式设施集合,但必须让三个服务都能复算排除数。
+            resolvedCode = "MISSING_CODE:" + facilities.size();
+            type = UNCLASSIFIED;
         }
+        facilities.add(new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility(
+                sourceKey == null ? resolvedCode : sourceKey, resolvedCode, type, null, lengthKm, null, commissionedAt, builtAt));
     }
 
-    private static String drainageFacilityType(String facilityType) {
-        String normalized = trimToNull(facilityType);
-        if (normalized == null) {
-            return null;
-        }
-        if (normalized.contains("闸")) {
-            return "DRAINAGE_GATE";
-        }
-        if (normalized.contains("调蓄") || normalized.contains("蓄水")) {
-            return "DRAINAGE_STORAGE";
-        }
-        if (normalized.contains("泵站")) {
-            return "DRAINAGE_PUMP_STATION";
-        }
-        return null;
+    private static String key(String table, Object id, String code) {
+        String identity = id == null ? trimToNull(code) : id.toString();
+        return identity == null ? null : table + "|" + identity;
     }
 
-    private static CockpitInfrastructureBaseArchivesSnapshot.SourceFacility facility(
-            String code,
-            String typeCode,
-            Integer district,
-            BigDecimal lengthKm,
-            LocalDate commissionedAt,
-            LocalDate builtAt) {
-        return new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility(
-                code, typeCode, district, lengthKm, null, commissionedAt, builtAt);
+    private static String drainageType(String value) {
+        String type = trimToNull(value);
+        if (type == null) return UNCLASSIFIED;
+        if (type.contains("闸")) return "DRAINAGE_GATE";
+        if (type.contains("调蓄") || type.contains("蓄水")) return "DRAINAGE_STORAGE";
+        if (type.contains("泵站")) return "DRAINAGE_PUMP_STATION";
+        return UNCLASSIFIED;
     }
 
-    private static CockpitInfrastructureBaseArchivesSnapshot.SourceType type(
-            String code, String name, String industry) {
-        return new CockpitInfrastructureBaseArchivesSnapshot.SourceType(
-                code, name, industry, "FACILITY");
+    private static CockpitInfrastructureBaseArchivesSnapshot.SourceType type(String code, String name, String industry) {
+        return new CockpitInfrastructureBaseArchivesSnapshot.SourceType(code, name, industry, "FACILITY");
     }
 
     private static <T> List<T> safeList(List<T> value) {
-        return value == null
-                ? Collections.emptyList()
+        return value == null ? Collections.emptyList()
                 : value.stream().filter(Objects::nonNull).collect(Collectors.toList());
     }
 
-    private static String trimToNull(String value) {
-        return isBlank(value) ? null : value.trim();
+    private static LocalDateTime latest(LocalDateTime current, LocalDateTime candidate) {
+        if (candidate == null) return current;
+        return current == null || candidate.isAfter(current) ? candidate : current;
     }
 
-    private static boolean isBlank(String value) {
-        return value == null || value.trim().isEmpty();
+    private static String trimToNull(String value) {
+        return value == null || value.trim().isEmpty() ? null : value.trim();
     }
 }

+ 45 - 136
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/BusinessCockpitInfrastructureManagementInformationAdapter.java

@@ -1,16 +1,9 @@
 package com.zksy.cockpit.infrastructure;
 
-import com.zksy.WaterSupply.WaterSourceInfo.domain.WaterSourceInfo;
-import com.zksy.WaterSupply.WaterSourceInfo.service.IWaterSourceInfoService;
 import com.zksy.base.domain.EquipmentBase;
 import com.zksy.base.domain.EquipmentType;
-import com.zksy.base.domain.ManholeBase;
-import com.zksy.base.domain.PipeNetworkBase;
 import com.zksy.base.service.EquipmentBaseService;
 import com.zksy.base.service.EquipmentTypeService;
-import com.zksy.base.service.ManholeBaseService;
-import com.zksy.base.service.PipeNetworkBaseService;
-import com.zksy.cockpit.foundation.CockpitPipeNetworkType;
 import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import org.springframework.context.annotation.Primary;
@@ -27,156 +20,72 @@ import java.util.Map;
 import java.util.Objects;
 import java.util.stream.Collectors;
 
-/**
- * 管理信息真实数据适配器(决策票 06 / 执行票 12)。
- *
- * <p>按「对象 → 行业 + 片区」上报关系,供服务层做片区×行业计数。片区**只**取
- * {@code equipment_base.district}(设备的权威片区来源):
- * <ul>
- *   <li>设备({@link EquipmentBase}):用自身 {@code district},归一交给服务层。</li>
- *   <li>窨井 / 管网设施 / 水源地:这些表没有片区列、也不关联设备,故如实传
- *       {@code null}=未分区,**不用管养单位或权属单位冒充片区**。</li>
- * </ul>
- * 组织与权属字段已随决策票 06 删除,不再上报。
- */
+/** 设备保留权威片区;设施关系只投影统一目录,不再独立选择设施表(执行票 31)。 */
 @Component
 @Primary
 public class BusinessCockpitInfrastructureManagementInformationAdapter
         implements CockpitInfrastructureManagementInformationAdapter {
-
-    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
-
-    private final EquipmentBaseService equipmentBaseService;
-    private final EquipmentTypeService equipmentTypeService;
-    private final ManholeBaseService manholeBaseService;
-    private final PipeNetworkBaseService pipeNetworkBaseService;
-    private final IWaterSourceInfoService waterSourceInfoService;
-
-    public BusinessCockpitInfrastructureManagementInformationAdapter(
-            EquipmentBaseService equipmentBaseService,
-            EquipmentTypeService equipmentTypeService,
-            ManholeBaseService manholeBaseService,
-            PipeNetworkBaseService pipeNetworkBaseService,
-            IWaterSourceInfoService waterSourceInfoService) {
-        this.equipmentBaseService = equipmentBaseService;
-        this.equipmentTypeService = equipmentTypeService;
-        this.manholeBaseService = manholeBaseService;
-        this.pipeNetworkBaseService = pipeNetworkBaseService;
-        this.waterSourceInfoService = waterSourceInfoService;
+    private final EquipmentBaseService equipment;
+    private final EquipmentTypeService equipmentTypes;
+    private final CockpitFacilityCatalog facilities;
+
+    public BusinessCockpitInfrastructureManagementInformationAdapter(EquipmentBaseService equipment,
+            EquipmentTypeService equipmentTypes, CockpitFacilityCatalog facilities) {
+        this.equipment = equipment;
+        this.equipmentTypes = equipmentTypes;
+        this.facilities = facilities;
     }
 
     @Override
     public CockpitInfrastructureManagementInformationSnapshot load() {
-        List<CockpitInfrastructureManagementInformationSnapshot.SourceRelation> relations =
-                new ArrayList<>();
-        String[] latestUpdatedAt = new String[1];
-        Map<Integer, EquipmentType> types = typeIndex(equipmentTypeService.list());
-
-        for (EquipmentBase device : safeList(equipmentBaseService.list())) {
-            if (device == null) {
-                continue;
-            }
-            String objectCode = trimToNull(device.getEquipmentCode());
-            String industry = device.getEquipmentTypeId() == null
-                    ? null
+        List<CockpitInfrastructureManagementInformationSnapshot.SourceRelation> relations = new ArrayList<>();
+        Map<Integer, EquipmentType> types = new HashMap<>();
+        for (EquipmentType type : safeList(equipmentTypes.list())) if (type.getId() != null) types.put(type.getId(), type);
+        String updatedAt = null;
+        for (EquipmentBase device : safeList(equipment.list())) {
+            String code = trimToNull(device.getEquipmentCode());
+            String industry = device.getEquipmentTypeId() == null ? null
                     : CockpitTypeIndustry.industryOf(types.get(device.getEquipmentTypeId()), types);
-            if (objectCode == null || industry == null) {
-                continue;
-            }
-            // 设备自身片区即权威来源(equipment_base.district)。
-            relations.add(relation(objectCode, "SMART_DEVICE", industry,
-                    CockpitDistrict.codeOf(device.getDistrict())));
-            latestUpdatedAt[0] = latest(latestUpdatedAt[0], format(device.getUpdateTime()));
-        }
-
-        for (ManholeBase manhole : safeList(manholeBaseService.list())) {
-            if (manhole == null) {
-                continue;
-            }
-            String objectCode = trimToNull(manhole.getManholeCode());
-            if (objectCode == null) {
-                continue;
-            }
-            // 窨井无片区来源 → 未分区。
-            relations.add(relation(objectCode, "FACILITY", "MANHOLE", null));
-            latestUpdatedAt[0] = latest(latestUpdatedAt[0], format(manhole.getUpdateTime()));
-        }
-
-        for (PipeNetworkBase pipe : safeList(pipeNetworkBaseService.list())) {
-            if (pipe == null) {
-                continue;
-            }
-            String objectCode = trimToNull(pipe.getNetworkCode());
-            if (objectCode == null) {
-                continue;
-            }
-            // 权威映射在 CockpitPipeNetworkType。无法映射的 network_type 交由 null 行业上行,
-            // 由服务层计入 excluded.unknownIndustryCount —— 不在适配器里静默丢弃(执行票 20)。
-            String industry = CockpitPipeNetworkType.industryOf(pipe.getNetworkType());
-            // 管网设施无片区来源 → 未分区,不用 managementUnit 冒充。
-            relations.add(relation(objectCode, "FACILITY", industry, null));
-            latestUpdatedAt[0] = latest(latestUpdatedAt[0], format(pipe.getUpdateTime()));
+            if (code == null || industry == null) continue;
+            relations.add(new CockpitInfrastructureManagementInformationSnapshot.SourceRelation(
+                    code, "SMART_DEVICE", industry, CockpitDistrict.codeOf(device.getDistrict())));
+            updatedAt = latest(updatedAt, format(device.getUpdateTime()));
         }
-
-        for (WaterSourceInfo source : safeList(waterSourceInfoService.list())) {
-            if (source == null) {
-                continue;
+        CockpitInfrastructureBaseArchivesSnapshot catalog = facilities.load();
+        if (catalog != null) {
+            updatedAt = latest(updatedAt, catalog.getUpdatedAt());
+            Map<String, CockpitInfrastructureBaseArchivesSnapshot.SourceType> facilityTypes = new HashMap<>();
+            for (CockpitInfrastructureBaseArchivesSnapshot.SourceType type : safeList(catalog.getTypes())) {
+                facilityTypes.put(type.getCode(), type);
             }
-            String objectCode = trimToNull(source.getSourceId());
-            if (objectCode == null) {
-                continue;
+            for (CockpitInfrastructureBaseArchivesSnapshot.SourceFacility facility : safeList(catalog.getFacilities())) {
+                CockpitInfrastructureBaseArchivesSnapshot.SourceType type = facilityTypes.get(facility.getTypeCode());
+                // 来源表+主键的稳定身份:不同表同码不合并,同源重复关系仍可识别。
+                String rowKey = facility.getSourceKey();
+                relations.add(new CockpitInfrastructureManagementInformationSnapshot.SourceRelation(
+                        rowKey, type == null ? "FACILITY" : type.getObjectKind(),
+                        type == null ? null : type.getIndustry(), facility.getDistrict()));
             }
-            // 水源地无片区来源 → 未分区。
-            relations.add(relation(objectCode, "FACILITY", "WATER_SUPPLY", null));
-            latestUpdatedAt[0] = latest(latestUpdatedAt[0], format(source.getUpdateTime()));
         }
-
-        return new CockpitInfrastructureManagementInformationSnapshot(latestUpdatedAt[0], relations);
+        return new CockpitInfrastructureManagementInformationSnapshot(updatedAt, relations);
     }
 
-    private static CockpitInfrastructureManagementInformationSnapshot.SourceRelation relation(
-            String objectCode,
-            String objectKind,
-            String industry,
-            Integer district) {
-        return new CockpitInfrastructureManagementInformationSnapshot.SourceRelation(
-                objectCode, objectKind, industry, district);
-    }
-
-    private static Map<Integer, EquipmentType> typeIndex(List<EquipmentType> values) {
-        Map<Integer, EquipmentType> result = new HashMap<>();
-        for (EquipmentType value : safeList(values)) {
-            if (value != null && value.getId() != null) {
-                result.put(value.getId(), value);
-            }
-        }
-        return result;
+    private static String latest(String current, String candidate) {
+        if (candidate == null) return current;
+        return current == null || candidate.compareTo(current) > 0 ? candidate : current;
     }
 
     private static String format(LocalDateTime value) {
-        return value == null
-                ? null
-                : TIME_FORMATTER.format(value.atZone(BUSINESS_ZONE).toOffsetDateTime());
-    }
-
-    private static String latest(String left, String right) {
-        if (left == null) {
-            return right;
-        }
-        if (right == null) {
-            return left;
-        }
-        return left.compareTo(right) >= 0 ? left : right;
-    }
-
-    private static <T> List<T> safeList(List<T> value) {
-        return value == null
-                ? Collections.emptyList()
-                : value.stream().filter(Objects::nonNull).collect(Collectors.toList());
+        return value == null ? null : DateTimeFormatter.ISO_OFFSET_DATE_TIME
+                .format(value.atZone(ZoneId.of("Asia/Shanghai")));
     }
 
     private static String trimToNull(String value) {
         return value == null || value.trim().isEmpty() ? null : value.trim();
     }
+
+    private static <T> List<T> safeList(List<T> values) {
+        return values == null ? Collections.emptyList()
+                : values.stream().filter(Objects::nonNull).collect(Collectors.toList());
+    }
 }

+ 8 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitFacilityCatalog.java

@@ -0,0 +1,8 @@
+package com.zksy.cockpit.infrastructure;
+
+/**
+ * 设施统计的唯一来源。各驾驶舱端点只能从同一份设施快照投影,避免各自选择表集合。
+ */
+public interface CockpitFacilityCatalog {
+    CockpitInfrastructureBaseArchivesSnapshot load();
+}

+ 1 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureBaseArchivesService.java

@@ -163,6 +163,7 @@ public class CockpitInfrastructureBaseArchivesService {
             typeSummaries.compute(type.getCode(), (key, existing) -> existing == null
                     ? new TypeSummary(type.getCode(), type.getName(), 1, facility.getLengthKm(), facility.getScale(), null)
                     : mergeSummary(existing, facility));
+            districtCounts.merge(normalizedDistrict, 1, Integer::sum);
             if ("FUTURE".equals(bucket)) {
                 continue;
             }
@@ -174,7 +175,6 @@ public class CockpitInfrastructureBaseArchivesService {
             } else {
                 unknownDateCount++;
             }
-            districtCounts.merge(normalizedDistrict, 1, Integer::sum);
         }
 
         List<AgeDistribution> ageDistribution = new ArrayList<>();

+ 8 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureBaseArchivesSnapshot.java

@@ -49,6 +49,7 @@ public class CockpitInfrastructureBaseArchivesSnapshot {
      * {@code null},由服务层如实归一为「未分区」,不用行业或管理单位冒充。
      */
     public static class SourceFacility {
+        private final String sourceKey;
         private final String code;
         private final String typeCode;
         private final Integer district;
@@ -65,6 +66,12 @@ public class CockpitInfrastructureBaseArchivesSnapshot {
                 String scale,
                 LocalDate commissionedAt,
                 LocalDate builtAt) {
+            this(typeCode + "|" + code, code, typeCode, district, lengthKm, scale, commissionedAt, builtAt);
+        }
+
+        public SourceFacility(String sourceKey, String code, String typeCode, Integer district,
+                BigDecimal lengthKm, String scale, LocalDate commissionedAt, LocalDate builtAt) {
+            this.sourceKey = sourceKey;
             this.code = code;
             this.typeCode = typeCode;
             this.district = district;
@@ -74,6 +81,7 @@ public class CockpitInfrastructureBaseArchivesSnapshot {
             this.builtAt = builtAt;
         }
 
+        public String getSourceKey() { return sourceKey; }
         public String getCode() { return code; }
         public String getTypeCode() { return typeCode; }
         public Integer getDistrict() { return district; }

+ 5 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/BusinessCockpitMapSnapshotAdapter.java

@@ -56,8 +56,11 @@ import java.util.stream.Collectors;
  * 缺失或过期时拒绝,不用当前数据冒充旧快照。地图与详情端点通过共享快照存储复用该版本,
  * 进程本地缓存只用于减少 Redis 访问,不负责定义跨实例一致性。
  *
- * <p><b>如实接线,不臆造</b>:现网没有「坐标是否已校核」字段,故一律按未校核(
- * {@code coordinateVerified=false})上报,由冻结字典裁决为不可渲染;不为了「图上有东西」而伪造校验状态。
+ * <p><b>坐标如实透传</b>:现网没有「坐标是否已校核」字段,故 {@code coordinateVerified} 一律传
+ * {@code false};但按 2026-10-12 维护者裁定,该参数**不再作为准入门槛**(无校核入口时保留它会让
+ * 全部真实对象恒不可上图)。准入改由 {@code CockpitCoordinateValidator} 自动判定:
+ * 结构合法且落在主场景区内即可渲染,区外归 {@code OUTSIDE_PRIMARY_AREA}。这里只透传原始坐标,
+ * 不改写、不伪造、不补默认坐标。
  * 现网 {@code monitor_point} 也没有行业/类型列,点位的 {@code typeCode} 只能在其关联设备类型唯一时推导。
  */
 @Component

+ 2 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java

@@ -34,7 +34,8 @@ import java.util.stream.Collectors;
  * {@link CockpitTypeIndustry}),只换快照形状:分布的未关联智能设备额外携带实时事实
  * (在线/报警/预警/在途工单 + 原因 + 状态时间),点位的设备仍经 {@code CockpitMonitoringPointDeviceSnapshot} 描述。
  *
- * <p>如实接线,不臆造:现网无坐标校核字段 → 一律 {@code coordinateVerified=false};
+ * <p>如实接线,不臆造:现网无坐标校核字段 → {@code coordinateVerified} 一律 {@code false}
+ * (按 2026-10-12 裁定,该参数不再作为准入门槛,准入由 {@code CockpitCoordinateValidator} 自动判定);
  * {@code monitor_point} 无行业列 → 点位 {@code typeCode} 仅在其关联设备类型唯一时推导。
  * 片区(district)的权威来源是 {@code equipment_base.district}:点位按「经其上挂设备归纳」
  * 取得(不一致/无设备 → 未分区),设备直接用自身片区。

+ 3 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringSituationAdapter.java

@@ -45,7 +45,9 @@ import java.util.stream.Collectors;
  * {@code device_code → equipment_base → equipment_type} 推导,保证与服务层类型字典一致;
  * 设备无法匹配到类型时 typeCode 为 null(服务层按「未知类型」计入 excluded,不静默归类)。
  *
- * <p><b>不臆造</b>:现网无「样本过期」依据 → 不产生 EXPIRED;无坐标校核字段 → {@code coordinateVerified=false};
+ * <p><b>不臆造</b>:现网无「样本过期」依据 → 不产生 EXPIRED;无坐标校核字段 →
+ * {@code coordinateVerified=false}(2026-10-12 起不再作为准入门槛,准入由
+ * {@code CockpitCoordinateValidator} 自动判定);
  * 片区取设备自身 {@code equipment_base.district}(未分区为 null)。指标态取值优先序:缺状态→UNKNOWN,离线→OFFLINE,报警→ALARM,预警中→WARNING,其余→NORMAL。
  */
 @Component

+ 62 - 201
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/BusinessCockpitOverviewInfrastructureAdapter.java

@@ -1,19 +1,12 @@
 package com.zksy.cockpit.overview;
 
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.zksy.WaterSupply.WaterPlantInfo.domain.WaterPlantInfo;
-import com.zksy.WaterSupply.WaterPlantInfo.service.IWaterPlantInfoService;
-import com.zksy.WaterSupply.WaterPumpStation.domain.PumpStationType;
-import com.zksy.WaterSupply.WaterPumpStation.domain.WaterPumpStation;
-import com.zksy.WaterSupply.WaterPumpStation.service.IWaterPumpStationService;
 import com.zksy.base.domain.EquipmentBase;
 import com.zksy.base.domain.EquipmentType;
-import com.zksy.base.domain.PipeNetworkBase;
 import com.zksy.base.service.EquipmentBaseService;
 import com.zksy.base.service.EquipmentTypeService;
-import com.zksy.base.service.PipeNetworkBaseService;
-import com.zksy.cockpit.foundation.CockpitPipeNetworkType;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
+import com.zksy.cockpit.infrastructure.CockpitFacilityCatalog;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureBaseArchivesSnapshot;
 import org.springframework.context.annotation.Primary;
 import org.springframework.stereotype.Component;
 
@@ -31,217 +24,85 @@ import java.util.Map;
 import java.util.Objects;
 import java.util.stream.Collectors;
 
-/**
- * 总体概览-基础设施 真实数据适配器(BT-5)。
- *
- * <p>智能设备按 {@code equipment_type} 分组计数({@code objectKind=SMART_DEVICE});
- * 水厂和泵站分别取自其设施台账,共用泵站按 {@code station_type} 区分供水/排水。
- * 管网设施按 {@code pipe_network_base.network_type} 分组计数({@code objectKind=FACILITY})。
- * 管网类型无可信行业字典,{@code industry} 如实为 {@code null};设施长度由 {@code length}(米)换算公里。
- * {@code updatedAt} 取各台账最新更新时间。
- */
+/** 设备按类型计数;设施只投影权威目录,禁止另选设施表集合(执行票 31)。 */
 @Component
 @Primary
-public class BusinessCockpitOverviewInfrastructureAdapter
-        implements CockpitOverviewInfrastructureAdapter {
-
-    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
-    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-
-    private final EquipmentTypeService equipmentTypeService;
-    private final EquipmentBaseService equipmentBaseService;
-    private final PipeNetworkBaseService pipeNetworkBaseService;
-    private final IWaterPlantInfoService plantService;
-    private final IWaterPumpStationService pumpStationService;
-
-    public BusinessCockpitOverviewInfrastructureAdapter(
-            EquipmentTypeService equipmentTypeService,
-            EquipmentBaseService equipmentBaseService,
-            PipeNetworkBaseService pipeNetworkBaseService,
-            IWaterPlantInfoService plantService,
-            IWaterPumpStationService pumpStationService) {
-        this.equipmentTypeService = equipmentTypeService;
-        this.equipmentBaseService = equipmentBaseService;
-        this.pipeNetworkBaseService = pipeNetworkBaseService;
-        this.plantService = plantService;
-        this.pumpStationService = pumpStationService;
+public class BusinessCockpitOverviewInfrastructureAdapter implements CockpitOverviewInfrastructureAdapter {
+    private final EquipmentTypeService equipmentTypes;
+    private final EquipmentBaseService equipment;
+    private final CockpitFacilityCatalog facilities;
+
+    public BusinessCockpitOverviewInfrastructureAdapter(EquipmentTypeService equipmentTypes,
+            EquipmentBaseService equipment, CockpitFacilityCatalog facilities) {
+        this.equipmentTypes = equipmentTypes;
+        this.equipment = equipment;
+        this.facilities = facilities;
     }
 
     @Override
     public CockpitOverviewInfrastructureSnapshot load() {
-        List<EquipmentType> equipmentTypes = safeList(equipmentTypeService.list());
-        Map<Integer, EquipmentType> types = equipmentTypes(equipmentTypes);
-
-        List<CockpitOverviewInfrastructureSnapshot.SourceType> sourceTypes = new ArrayList<>();
-        for (EquipmentType type : equipmentTypes) {
-            if (type == null || type.getId() == null) {
-                continue;
-            }
-            sourceTypes.add(new CockpitOverviewInfrastructureSnapshot.SourceType(
-                    String.valueOf(type.getId()),
-                    type.getTypeName(),
-                    CockpitTypeIndustry.industryOf(type, types),
-                    null,
-                    "SMART_DEVICE"));
+        List<EquipmentType> deviceTypes = safeList(equipmentTypes.list());
+        Map<Integer, EquipmentType> index = new HashMap<>();
+        for (EquipmentType type : deviceTypes) if (type.getId() != null) index.put(type.getId(), type);
+        List<CockpitOverviewInfrastructureSnapshot.SourceType> types = new ArrayList<>();
+        for (EquipmentType type : deviceTypes) {
+            if (type.getId() != null) types.add(new CockpitOverviewInfrastructureSnapshot.SourceType(
+                    String.valueOf(type.getId()), type.getTypeName(), CockpitTypeIndustry.industryOf(type, index), null, "SMART_DEVICE"));
         }
-
-        List<EquipmentBase> devices = safeList(equipmentBaseService.list());
         List<CockpitOverviewInfrastructureSnapshot.SourceCount> counts = new ArrayList<>();
-        Map<Integer, Integer> deviceCountByType = new LinkedHashMap<>();
-        for (EquipmentBase device : devices) {
-            if (device == null || device.getEquipmentTypeId() == null) {
-                continue;
-            }
-            deviceCountByType.merge(device.getEquipmentTypeId(), 1, Integer::sum);
-        }
-        for (Map.Entry<Integer, Integer> entry : deviceCountByType.entrySet()) {
-            counts.add(new CockpitOverviewInfrastructureSnapshot.SourceCount(
-                    "SMART_DEVICE", String.valueOf(entry.getKey()), entry.getValue()));
-        }
-
-        Map<String, FacilityAggregate> facilityByType = new LinkedHashMap<>();
-        List<PipeNetworkBase> facilities = safeList(pipeNetworkBaseService.list());
-        for (PipeNetworkBase facility : facilities) {
-            if (facility == null) {
-                continue;
-            }
-            // 权威映射在 CockpitPipeNetworkType(0-燃气/1-排水/2-供水)。此前这里直接把
-            // 原始 network_type 当类型码、且 industry 传 null,导致 5268 条管网整体被
-            // 服务层计入 excluded.unknownTypeCount(执行票 20)。
-            String typeCode = CockpitPipeNetworkType.typeCodeOf(facility.getNetworkType());
-            FacilityAggregate aggregate = facilityByType.computeIfAbsent(typeCode, key -> new FacilityAggregate());
-            aggregate.count++;
-            if (facility.getLength() != null) {
-                aggregate.lengthMeters = aggregate.lengthMeters == null
-                        ? BigDecimal.ZERO : aggregate.lengthMeters;
-                aggregate.lengthMeters = aggregate.lengthMeters.add(facility.getLength());
+        Map<Integer, Integer> deviceCounts = new LinkedHashMap<>();
+        String updatedAt = null;
+        for (EquipmentBase device : safeList(equipment.list())) {
+            if (device.getEquipmentTypeId() != null) deviceCounts.merge(device.getEquipmentTypeId(), 1, Integer::sum);
+            updatedAt = latest(updatedAt, format(device.getUpdateTime()));
+            updatedAt = latest(updatedAt, format(device.getCreateTime()));
+        }
+        for (Map.Entry<Integer, Integer> entry : deviceCounts.entrySet()) counts.add(
+                new CockpitOverviewInfrastructureSnapshot.SourceCount("SMART_DEVICE", String.valueOf(entry.getKey()), entry.getValue()));
+
+        CockpitInfrastructureBaseArchivesSnapshot catalog = facilities.load();
+        if (catalog != null) {
+            updatedAt = latest(updatedAt, catalog.getUpdatedAt());
+            for (CockpitInfrastructureBaseArchivesSnapshot.SourceType type : safeList(catalog.getTypes())) {
+                types.add(new CockpitOverviewInfrastructureSnapshot.SourceType(
+                        type.getCode(), type.getName(), type.getIndustry(), null, type.getObjectKind()));
             }
-        }
-        for (Map.Entry<String, FacilityAggregate> entry : facilityByType.entrySet()) {
-            String typeCode = entry.getKey();
-            FacilityAggregate aggregate = entry.getValue();
-            // 行业由权威映射给出;未分类哨兵无行业,服务层据实计入 unknownTypeCount。
-            sourceTypes.add(new CockpitOverviewInfrastructureSnapshot.SourceType(
-                    typeCode,
-                    CockpitPipeNetworkType.nameOf(typeCode),
-                    CockpitPipeNetworkType.industryOfTypeCode(typeCode),
-                    null, "FACILITY"));
-            BigDecimal lengthKm = aggregate.lengthMeters == null
-                    ? null : aggregate.lengthMeters.divide(BigDecimal.valueOf(1000), 3, RoundingMode.HALF_UP);
-            counts.add(new CockpitOverviewInfrastructureSnapshot.SourceCount(
-                    "FACILITY", typeCode, aggregate.count,
-                    new CockpitOverviewInfrastructureSnapshot.SourceValue(lengthKm, "km", null)));
-        }
-
-        List<WaterPlantInfo> plants = safeList(plantService.list());
-        int plantCount = 0;
-        for (WaterPlantInfo plant : plants) {
-            if (!isBlank(plant.getPlantCode())) {
-                plantCount++;
-            }
-        }
-        addFacilityCount(sourceTypes, counts, "WATER_PLANT", "水厂", "WATER_SUPPLY", plantCount);
-
-        List<WaterPumpStation> stations = safeList(pumpStationService.list(
-                new LambdaQueryWrapper<WaterPumpStation>()
-                        .in(WaterPumpStation::getStationType,
-                                PumpStationType.WATER_SUPPLY, PumpStationType.DRAINAGE)));
-        int waterStationCount = 0;
-        int drainageStationCount = 0;
-        for (WaterPumpStation station : stations) {
-            if (isBlank(station.getStationCode())) {
-                continue;
+            // 未分类哨兵不在正式字典中,由概览服务累计 excluded.unknownTypeCount。
+            Map<String, FacilityAggregate> aggregates = new LinkedHashMap<>();
+            for (CockpitInfrastructureBaseArchivesSnapshot.SourceFacility facility : safeList(catalog.getFacilities())) {
+                FacilityAggregate aggregate = aggregates.computeIfAbsent(facility.getTypeCode(), key -> new FacilityAggregate());
+                aggregate.count++;
+                if (facility.getLengthKm() != null) aggregate.lengthKm =
+                        (aggregate.lengthKm == null ? BigDecimal.ZERO : aggregate.lengthKm).add(facility.getLengthKm());
             }
-            String stationType = trimToNull(station.getStationType());
-            if (PumpStationType.WATER_SUPPLY.equalsIgnoreCase(stationType)) {
-                waterStationCount++;
-            } else if (PumpStationType.DRAINAGE.equalsIgnoreCase(stationType)) {
-                drainageStationCount++;
-            }
-        }
-        addFacilityCount(sourceTypes, counts, "WATER_PUMP_STATION", "供水泵站", "WATER_SUPPLY", waterStationCount);
-        addFacilityCount(sourceTypes, counts, "DRAINAGE_PUMP_STATION", "排水泵站", "DRAINAGE", drainageStationCount);
-
-        LocalDateTime updatedAt = null;
-        for (EquipmentBase device : devices) {
-            if (device == null) {
-                continue;
-            }
-            updatedAt = latest(updatedAt, device.getUpdateTime());
-            updatedAt = latest(updatedAt, device.getCreateTime());
-        }
-        for (PipeNetworkBase facility : facilities) {
-            if (facility == null) {
-                continue;
+            for (Map.Entry<String, FacilityAggregate> entry : aggregates.entrySet()) {
+                FacilityAggregate aggregate = entry.getValue();
+                counts.add(new CockpitOverviewInfrastructureSnapshot.SourceCount("FACILITY", entry.getKey(), aggregate.count,
+                        new CockpitOverviewInfrastructureSnapshot.SourceValue(
+                                aggregate.lengthKm == null ? null : aggregate.lengthKm.setScale(3, RoundingMode.HALF_UP),
+                                aggregate.lengthKm == null ? null : "km", null)));
             }
-            updatedAt = latest(updatedAt, facility.getUpdateTime());
-            updatedAt = latest(updatedAt, facility.getCreateTime());
         }
-        for (WaterPlantInfo plant : plants) {
-            updatedAt = latest(updatedAt, plant.getUpdateTime());
-            updatedAt = latest(updatedAt, plant.getCreateTime());
-        }
-        for (WaterPumpStation station : stations) {
-            updatedAt = latest(updatedAt, station.getUpdateTime());
-            updatedAt = latest(updatedAt, station.getCreateTime());
-        }
-
-        return new CockpitOverviewInfrastructureSnapshot(
-                offsetTime(updatedAt), sourceTypes, counts);
-    }
-
-    private static void addFacilityCount(
-            List<CockpitOverviewInfrastructureSnapshot.SourceType> types,
-            List<CockpitOverviewInfrastructureSnapshot.SourceCount> counts,
-            String code, String name, String industry, int count) {
-        if (count == 0) {
-            return;
-        }
-        types.add(new CockpitOverviewInfrastructureSnapshot.SourceType(code, name, industry, null, "FACILITY"));
-        counts.add(new CockpitOverviewInfrastructureSnapshot.SourceCount("FACILITY", code, count));
+        return new CockpitOverviewInfrastructureSnapshot(updatedAt, types, counts);
     }
 
-    private static Map<Integer, EquipmentType> equipmentTypes(List<EquipmentType> values) {
-        Map<Integer, EquipmentType> result = new HashMap<>();
-        for (EquipmentType type : values) {
-            if (type != null && type.getId() != null) {
-                result.put(type.getId(), type);
-            }
-        }
-        return result;
-    }
-
-    private static LocalDateTime latest(LocalDateTime current, LocalDateTime candidate) {
-        if (candidate == null) {
-            return current;
-        }
-        if (current == null || candidate.isAfter(current)) {
-            return candidate;
-        }
-        return current;
-    }
-
-    private static String offsetTime(LocalDateTime value) {
-        return value == null ? null : TIME_FORMATTER.format(value.atZone(BUSINESS_ZONE).toOffsetDateTime());
-    }
-
-    private static String trimToNull(String value) {
-        if (value == null || value.trim().isEmpty()) {
-            return null;
-        }
-        return value.trim();
+    private static class FacilityAggregate {
+        private int count;
+        private BigDecimal lengthKm;
     }
 
-    private static boolean isBlank(String value) {
-        return value == null || value.trim().isEmpty();
+    private static String latest(String current, String candidate) {
+        if (candidate == null) return current;
+        return current == null || candidate.compareTo(current) > 0 ? candidate : current;
     }
 
-    private static <T> List<T> safeList(List<T> value) {
-        return value == null ? Collections.emptyList() : value.stream().filter(Objects::nonNull).collect(Collectors.toList());
+    private static String format(LocalDateTime value) {
+        return value == null ? null : DateTimeFormatter.ISO_OFFSET_DATE_TIME
+                .format(value.atZone(ZoneId.of("Asia/Shanghai")));
     }
 
-    private static class FacilityAggregate {
-        private int count;
-        private BigDecimal lengthMeters;
+    private static <T> List<T> safeList(List<T> values) {
+        return values == null ? Collections.emptyList()
+                : values.stream().filter(Objects::nonNull).collect(Collectors.toList());
     }
 }

+ 2 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/BusinessCockpitMonitoringPointDetailAdapter.java

@@ -21,7 +21,8 @@ import java.util.stream.Collectors;
  *
  * <p>点位与设备关系复用现有 {@code monitor_point}、{@code equipment_point_rel}
  * 和 {@code equipment_base} 服务,设备实时事实复用共享解析器。现网没有可信坐标
- * 校核字段,因此不臆造校核结果,统一交由契约层判定为未验证坐标。
+ * 校核字段,故不臆造校核结果;坐标按原值透传,准入由 {@code CockpitCoordinateValidator}
+ * 自动判定(结构合法 + 主场景区内,2026-10-12 起不再要求人工校核前置)。
  */
 @Component
 @Primary

+ 1 - 0
pom.xml

@@ -27,6 +27,7 @@
         <module>radar-service</module>
         <module>audio-service</module>
         <module>water-level-service</module>
+        <module>video-service</module>
     </modules>
     <parent>
         <groupId>org.springframework.boot</groupId>

+ 25 - 0
video-service/pom.xml

@@ -0,0 +1,25 @@
+<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+  <parent><groupId>com.zksy</groupId><artifactId>pipe-ner-server</artifactId><version>1.0.0</version></parent>
+  <artifactId>video-service</artifactId>
+  <description>独立 GB28181 信令、设备采集及媒体控制服务</description>
+  <dependencies>
+    <dependency><groupId>com.zksy</groupId><artifactId>zksy-common</artifactId><version>3.9.0</version><exclusions><exclusion><groupId>com.zksy</groupId><artifactId>minioutil</artifactId></exclusion></exclusions></dependency>
+    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency>
+    <dependency><groupId>com.baomidou</groupId><artifactId>dynamic-datasource-spring-boot-starter</artifactId><version>3.6.1</version></dependency>
+    <dependency><groupId>cn.com.kingbase</groupId><artifactId>kingbase8</artifactId></dependency>
+    <dependency><groupId>javax.sip</groupId><artifactId>jain-sip-ri</artifactId><version>1.2.307</version></dependency>
+    <dependency><groupId>org.slf4j</groupId><artifactId>log4j-over-slf4j</artifactId></dependency>
+    <dependency><groupId>org.dom4j</groupId><artifactId>dom4j</artifactId><version>2.1.3</version></dependency>
+    <dependency><groupId>com.google.guava</groupId><artifactId>guava</artifactId><version>32.1.3-jre</version></dependency>
+    <dependency><groupId>com.alibaba.fastjson2</groupId><artifactId>fastjson2-extension</artifactId><version>2.0.17</version></dependency>
+    <dependency><groupId>com.squareup.okhttp3</groupId><artifactId>okhttp</artifactId><version>4.10.0</version></dependency>
+    <dependency><groupId>com.squareup.okhttp3</groupId><artifactId>logging-interceptor</artifactId><version>4.10.0</version></dependency>
+    <dependency><groupId>io.github.rburgst</groupId><artifactId>okhttp-digest</artifactId><version>2.7</version></dependency>
+    <dependency><groupId>com.github.oshi</groupId><artifactId>oshi-core</artifactId><version>6.2.2</version></dependency>
+    <dependency><groupId>org.springframework.session</groupId><artifactId>spring-session-core</artifactId></dependency>
+    <dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.34</version><scope>provided</scope></dependency>
+    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
+  </dependencies>
+  <build><finalName>video-service</finalName><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><configuration><mainClass>com.zksy.video.VideoApplication</mainClass></configuration></plugin></plugins></build>
+</project>

+ 46 - 0
video-service/src/main/java/com/ruoyi/wvp/common/CivilCodePo.java

@@ -0,0 +1,46 @@
+package com.ruoyi.wvp.common;
+
+import org.springframework.util.ObjectUtils;
+
+public class CivilCodePo {
+
+    private String code;
+
+    private String name;
+
+    private String parentCode;
+
+    public static CivilCodePo getInstance(String[] infoArray) {
+        CivilCodePo civilCodePo = new CivilCodePo();
+        civilCodePo.setCode(infoArray[0]);
+        civilCodePo.setName(infoArray[1]);
+        if (!ObjectUtils.isEmpty(infoArray[2])) {
+            civilCodePo.setParentCode(infoArray[2]);
+        }
+        return civilCodePo;
+    }
+
+    public String getCode() {
+        return code;
+    }
+
+    public void setCode(String code) {
+        this.code = code;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    public String getParentCode() {
+        return parentCode;
+    }
+
+    public void setParentCode(String parentCode) {
+        this.parentCode = parentCode;
+    }
+}

+ 5 - 0
video-service/src/main/java/com/ruoyi/wvp/common/CommonCallback.java

@@ -0,0 +1,5 @@
+package com.ruoyi.wvp.common;
+
+public interface CommonCallback<T>{
+    public void run(T t);
+}

+ 5 - 0
video-service/src/main/java/com/ruoyi/wvp/common/GeneralCallback.java

@@ -0,0 +1,5 @@
+package com.ruoyi.wvp.common;
+
+public interface GeneralCallback<T>{
+    void run(int code, String msg, T data);
+}

+ 59 - 0
video-service/src/main/java/com/ruoyi/wvp/common/InviteInfo.java

@@ -0,0 +1,59 @@
+package com.ruoyi.wvp.common;
+
+import com.ruoyi.wvp.service.bean.SSRCInfo;
+import lombok.Data;
+
+/**
+ * 记录每次发送invite消息的状态
+ */
+@Data
+public class InviteInfo {
+
+    private String deviceId;
+
+    private Integer channelId;
+
+    private String stream;
+
+    private SSRCInfo ssrcInfo;
+
+    private String receiveIp;
+
+    private Integer receivePort;
+
+    private String streamMode;
+
+    private InviteSessionType type;
+
+    private InviteSessionStatus status;
+
+    private StreamInfo streamInfo;
+
+    private String mediaServerId;
+
+    private Long expirationTime;
+
+    private Long createTime;
+
+    private Boolean record;
+
+
+    public static InviteInfo getInviteInfo(String deviceId, Integer channelId, String stream, SSRCInfo ssrcInfo, String mediaServerId,
+                                           String receiveIp, Integer receivePort, String streamMode,
+                                           InviteSessionType type, InviteSessionStatus status, Boolean record) {
+        InviteInfo inviteInfo = new InviteInfo();
+        inviteInfo.setDeviceId(deviceId);
+        inviteInfo.setChannelId(channelId);
+        inviteInfo.setStream(stream);
+        inviteInfo.setSsrcInfo(ssrcInfo);
+        inviteInfo.setReceiveIp(receiveIp);
+        inviteInfo.setReceivePort(receivePort);
+        inviteInfo.setStreamMode(streamMode);
+        inviteInfo.setType(type);
+        inviteInfo.setStatus(status);
+        inviteInfo.setMediaServerId(mediaServerId);
+        inviteInfo.setRecord(record);
+        return inviteInfo;
+    }
+
+}

+ 11 - 0
video-service/src/main/java/com/ruoyi/wvp/common/InviteSessionStatus.java

@@ -0,0 +1,11 @@
+package com.ruoyi.wvp.common;
+
+/**
+ * 标识invite消息发出后的各个状态,
+ * 收到ok钱停止invite发送cancel,
+ * 收到200ok后发送BYE停止invite
+ */
+public enum InviteSessionStatus {
+    ready,
+    ok,
+}

+ 9 - 0
video-service/src/main/java/com/ruoyi/wvp/common/InviteSessionType.java

@@ -0,0 +1,9 @@
+package com.ruoyi.wvp.common;
+
+public enum InviteSessionType {
+    PLAY,
+    PLAYBACK,
+    DOWNLOAD,
+    BROADCAST,
+    TALK
+}

+ 780 - 0
video-service/src/main/java/com/ruoyi/wvp/common/StreamInfo.java

@@ -0,0 +1,780 @@
+package com.ruoyi.wvp.common;
+
+
+import com.ruoyi.wvp.media.bean.MediaInfo;
+import com.ruoyi.wvp.media.bean.MediaServer;
+import com.ruoyi.wvp.service.bean.DownloadFileInfo;
+
+import java.io.Serializable;
+import java.util.Objects;
+
+
+/**
+ * 流信息
+ */
+public class StreamInfo implements Serializable, Cloneable{
+
+    /**
+     * Schema
+     */
+    private String app;
+    /**
+     * 流ID
+     */
+    private String stream;
+
+    /**
+     * 设备编号
+     */
+    private String deviceId;
+
+    /**
+     * 通道ID
+     */
+    private Integer channelId;
+
+    /**
+     * IP
+     */
+    private String ip;
+
+    /**
+     * HTTP-FLV流地址
+     */
+    private StreamURL flv;
+
+    /**
+     * HTTPS-FLV流地址
+     */
+    private StreamURL https_flv;
+
+    /**
+     * Websocket-FLV流地址
+     */
+    private StreamURL ws_flv;
+
+    /**
+     * Websockets-FLV流地址
+     */
+    private StreamURL wss_flv;
+
+    /**
+     * HTTP-FMP4流地址
+     */
+    private StreamURL fmp4;
+
+    /**
+     * HTTPS-FMP4流地址
+     */
+    private StreamURL https_fmp4;
+
+    /**
+     * Websocket-FMP4流地址
+     */
+    private StreamURL ws_fmp4;
+
+    /**
+     * Websockets-FMP4流地址
+     */
+    private StreamURL wss_fmp4;
+
+    /**
+     * HLS流地址
+     */
+    private StreamURL hls;
+
+    /**
+     * HTTPS-HLS流地址
+     */
+    private StreamURL https_hls;
+
+    /**
+     * Websocket-HLS流地址
+     */
+    private StreamURL ws_hls;
+
+    /**
+     * Websockets-HLS流地址
+     */
+    private StreamURL wss_hls;
+
+    /**
+     * HTTP-TS流地址
+     */
+    private StreamURL ts;
+
+    /**
+     * HTTPS-TS流地址
+     */
+    private StreamURL https_ts;
+
+    /**
+     * Websocket-TS流地址
+     */
+    private StreamURL ws_ts;
+
+    /**
+     * Websockets-TS流地址
+     */
+    private StreamURL wss_ts;
+
+    /**
+     * RTMP流地址
+     */
+    private StreamURL rtmp;
+
+    /**
+     * RTMPS流地址
+     */
+    private StreamURL rtmps;
+
+    /**
+     * RTSP流地址
+     */
+    private StreamURL rtsp;
+
+    /**
+     * RTSPS流地址
+     */
+    private StreamURL rtsps;
+
+    /**
+     * RTC流地址
+     */
+    private StreamURL rtc;
+
+    /**
+     * RTCS流地址
+     */
+    private StreamURL rtcs;
+
+    /**
+     * 流媒体节点
+     */
+    private MediaServer mediaServer;
+
+    /**
+     * 流编码信息
+     */
+    private MediaInfo mediaInfo;
+
+    /**
+     * 开始时间
+     */
+    private String startTime;
+
+    /**
+     * 结束时间
+     */
+    private String endTime;
+
+    /**
+     * 进度(录像下载使用)
+     */
+    private double progress;
+
+    /**
+     * 文件下载地址(录像下载使用)
+     */
+    private DownloadFileInfo downLoadFilePath;
+
+    /**
+     * 点播请求的callId
+     */
+    private String callId;
+
+    /**
+     * 是否暂停(录像回放使用)
+     */
+    private boolean pause;
+
+    /**
+     * 产生源类型,包括 unknown = 0,rtmp_push=1,rtsp_push=2,rtp_push=3,pull=4,ffmpeg_pull=5,mp4_vod=6,device_chn=7
+     */
+    private int originType;
+
+    /**
+     * 转码后的视频流
+     */
+    private StreamInfo transcodeStream;
+
+    /**
+     * 使用的WVP ID
+     */
+    private String serverId;
+
+    public void setFlv(StreamURL flv) {
+        this.flv = flv;
+    }
+
+    public void setHttps_flv(StreamURL https_flv) {
+        this.https_flv = https_flv;
+    }
+
+    public void setWs_flv(StreamURL ws_flv) {
+        this.ws_flv = ws_flv;
+    }
+
+    public void setWss_flv(StreamURL wss_flv) {
+        this.wss_flv = wss_flv;
+    }
+
+    public void setFmp4(StreamURL fmp4) {
+        this.fmp4 = fmp4;
+    }
+
+    public void setHttps_fmp4(StreamURL https_fmp4) {
+        this.https_fmp4 = https_fmp4;
+    }
+
+    public void setWs_fmp4(StreamURL ws_fmp4) {
+        this.ws_fmp4 = ws_fmp4;
+    }
+
+    public void setWss_fmp4(StreamURL wss_fmp4) {
+        this.wss_fmp4 = wss_fmp4;
+    }
+
+    public void setHls(StreamURL hls) {
+        this.hls = hls;
+    }
+
+    public void setHttps_hls(StreamURL https_hls) {
+        this.https_hls = https_hls;
+    }
+
+    public void setWs_hls(StreamURL ws_hls) {
+        this.ws_hls = ws_hls;
+    }
+
+    public void setWss_hls(StreamURL wss_hls) {
+        this.wss_hls = wss_hls;
+    }
+
+    public void setTs(StreamURL ts) {
+        this.ts = ts;
+    }
+
+    public void setHttps_ts(StreamURL https_ts) {
+        this.https_ts = https_ts;
+    }
+
+    public void setWs_ts(StreamURL ws_ts) {
+        this.ws_ts = ws_ts;
+    }
+
+    public void setWss_ts(StreamURL wss_ts) {
+        this.wss_ts = wss_ts;
+    }
+
+    public void setRtmp(StreamURL rtmp) {
+        this.rtmp = rtmp;
+    }
+
+    public void setRtmps(StreamURL rtmps) {
+        this.rtmps = rtmps;
+    }
+
+    public void setRtsp(StreamURL rtsp) {
+        this.rtsp = rtsp;
+    }
+
+    public void setRtsps(StreamURL rtsps) {
+        this.rtsps = rtsps;
+    }
+
+    public void setRtc(StreamURL rtc) {
+        this.rtc = rtc;
+    }
+
+    public void setRtcs(StreamURL rtcs) {
+        this.rtcs = rtcs;
+    }
+
+    public void setRtmp(String host, int port, int sslPort, String app, String stream, String callIdParam) {
+        String file = String.format("%s/%s%s", app, stream, callIdParam);
+        if (port > 0) {
+            this.rtmp = new StreamURL("rtmp", host, port, file);
+        }
+        if (sslPort > 0) {
+            this.rtmps = new StreamURL("rtmps", host, sslPort, file);
+        }
+    }
+
+    public void setRtsp(String host, int port, int sslPort, String app, String stream, String callIdParam) {
+        String file = String.format("%s/%s%s", app, stream, callIdParam);
+        if (port > 0) {
+            this.rtsp = new StreamURL("rtsp", host, port, file);
+        }
+        if (sslPort > 0) {
+            this.rtsps = new StreamURL("rtsps", host, sslPort, file);
+        }
+    }
+
+    public void setFlv(String host, int port, int sslPort, String file) {
+        if (port > 0) {
+            this.flv = new StreamURL("http", host, port, file);
+        }
+        this.ws_flv = new StreamURL("ws", host, port, file);
+        if (sslPort > 0) {
+            this.https_flv = new StreamURL("https", host, sslPort, file);
+            this.wss_flv = new StreamURL("wss", host, sslPort, file);
+        }
+    }
+
+    public void setWsFlv(String host, int port, int sslPort, String file) {
+        if (port > 0) {
+            this.ws_flv = new StreamURL("ws", host, port, file);
+        }
+        if (sslPort > 0) {
+            this.wss_flv = new StreamURL("wss", host, sslPort, file);
+        }
+    }
+
+    public void setFmp4(String host, int port, int sslPort, String app, String stream, String callIdParam) {
+        String file = String.format("%s/%s.live.mp4%s", app, stream, callIdParam);
+        if (port > 0) {
+            this.fmp4 = new StreamURL("http", host, port, file);
+            this.ws_fmp4 = new StreamURL("ws", host, port, file);
+        }
+        if (sslPort > 0) {
+            this.https_fmp4 = new StreamURL("https", host, sslPort, file);
+            this.wss_fmp4 = new StreamURL("wss", host, sslPort, file);
+        }
+    }
+
+    public void setHls(String host, int port, int sslPort, String app, String stream, String callIdParam) {
+        String file = String.format("%s/%s/hls.m3u8%s", app, stream, callIdParam);
+        if (port > 0) {
+            this.hls = new StreamURL("http", host, port, file);
+            this.ws_hls = new StreamURL("ws", host, port, file);
+        }
+        if (sslPort > 0) {
+            this.https_hls = new StreamURL("https", host, sslPort, file);
+            this.wss_hls = new StreamURL("wss", host, sslPort, file);
+        }
+    }
+
+    public void setTs(String host, int port, int sslPort, String app, String stream, String callIdParam) {
+        String file = String.format("%s/%s.live.ts%s", app, stream, callIdParam);
+
+        if (port > 0) {
+            this.ts = new StreamURL("http", host, port, file);
+            this.ws_ts = new StreamURL("ws", host, port, file);
+        }
+        if (sslPort > 0) {
+            this.https_ts = new StreamURL("https", host, sslPort, file);
+            this.wss_ts = new StreamURL("wss", host, sslPort, file);
+        }
+    }
+
+    public void setRtc(String host, int port, int sslPort, String app, String stream, String callIdParam, boolean isPlay) {
+        if (callIdParam != null) {
+            callIdParam = Objects.equals(callIdParam, "") ? callIdParam : callIdParam.replace("?", "&");
+        }
+        String file = String.format("index/api/webrtc?app=%s&stream=%s&type=%s%s", app, stream, isPlay?"play":"push", callIdParam);
+        if (port > 0) {
+            this.rtc = new StreamURL("http", host, port, file);
+        }
+        if (sslPort > 0) {
+            this.rtcs = new StreamURL("https", host, sslPort, file);
+        }
+    }
+
+    public void channgeStreamIp(String localAddr) {
+        if (this.flv != null) {
+            this.flv.setHost(localAddr);
+        }
+        if (this.ws_flv != null ){
+            this.ws_flv.setHost(localAddr);
+        }
+        if (this.hls != null ) {
+            this.hls.setHost(localAddr);
+        }
+        if (this.ws_hls != null ) {
+            this.ws_hls.setHost(localAddr);
+        }
+        if (this.ts != null ) {
+            this.ts.setHost(localAddr);
+        }
+        if (this.ws_ts != null ) {
+            this.ws_ts.setHost(localAddr);
+        }
+        if (this.fmp4 != null ) {
+            this.fmp4.setHost(localAddr);
+        }
+        if (this.ws_fmp4 != null ) {
+            this.ws_fmp4.setHost(localAddr);
+        }
+        if (this.rtc != null ) {
+            this.rtc.setHost(localAddr);
+        }
+        if (this.https_flv != null) {
+            this.https_flv.setHost(localAddr);
+        }
+        if (this.wss_flv != null) {
+            this.wss_flv.setHost(localAddr);
+        }
+        if (this.https_hls != null) {
+            this.https_hls.setHost(localAddr);
+        }
+        if (this.wss_hls != null) {
+            this.wss_hls.setHost(localAddr);
+        }
+        if (this.wss_ts != null) {
+            this.wss_ts.setHost(localAddr);
+        }
+        if (this.https_fmp4 != null) {
+            this.https_fmp4.setHost(localAddr);
+        }
+        if (this.wss_fmp4 != null) {
+            this.wss_fmp4.setHost(localAddr);
+        }
+        if (this.rtcs != null) {
+            this.rtcs.setHost(localAddr);
+        }
+        if (this.rtsp != null) {
+            this.rtsp.setHost(localAddr);
+        }
+        if (this.rtsps != null) {
+            this.rtsps.setHost(localAddr);
+        }
+        if (this.rtmp != null) {
+            this.rtmp.setHost(localAddr);
+        }
+        if (this.rtmps != null) {
+            this.rtmps.setHost(localAddr);
+        }
+    }
+
+
+    public static class TransactionInfo{
+        public String callId;
+        public String localTag;
+        public String remoteTag;
+        public String branch;
+    }
+
+    private TransactionInfo transactionInfo;
+
+    public String getApp() {
+        return app;
+    }
+
+    public void setApp(String app) {
+        this.app = app;
+    }
+
+    public String getDeviceId() {
+        return deviceId;
+    }
+
+    public void setDeviceId(String deviceId) {
+        this.deviceId = deviceId;
+    }
+
+    public Integer getChannelId() {
+        return channelId;
+    }
+
+    public void setChannelId(Integer channelId) {
+        this.channelId = channelId;
+    }
+
+    public String getStream() {
+        return stream;
+    }
+
+    public void setStream(String stream) {
+        this.stream = stream;
+    }
+
+    public String getIp() {
+        return ip;
+    }
+
+    public void setIp(String ip) {
+        this.ip = ip;
+    }
+
+    public StreamURL getFlv() {
+        return flv;
+    }
+
+    public StreamURL getHttps_flv() {
+        return https_flv;
+    }
+
+    public StreamURL getWs_flv() {
+        return ws_flv;
+    }
+
+
+    public StreamURL getWss_flv() {
+        return wss_flv;
+    }
+
+    public StreamURL getFmp4() {
+        return fmp4;
+    }
+
+
+
+    public StreamURL getHttps_fmp4() {
+        return https_fmp4;
+    }
+
+    public StreamURL getWs_fmp4() {
+        return ws_fmp4;
+    }
+
+    public StreamURL getWss_fmp4() {
+        return wss_fmp4;
+    }
+
+    public StreamURL getHls() {
+        return hls;
+    }
+
+
+    public StreamURL getHttps_hls() {
+        return https_hls;
+    }
+
+    public StreamURL getWs_hls() {
+        return ws_hls;
+    }
+
+    public StreamURL getWss_hls() {
+        return wss_hls;
+    }
+
+    public StreamURL getTs() {
+        return ts;
+    }
+
+
+    public StreamURL getHttps_ts() {
+        return https_ts;
+    }
+
+
+    public StreamURL getWs_ts() {
+        return ws_ts;
+    }
+
+
+    public StreamURL getWss_ts() {
+        return wss_ts;
+    }
+
+
+    public StreamURL getRtmp() {
+        return rtmp;
+    }
+
+    public StreamURL getRtmps() {
+        return rtmps;
+    }
+
+    public StreamURL getRtsp() {
+        return rtsp;
+    }
+
+    public StreamURL getRtsps() {
+        return rtsps;
+    }
+
+    public StreamURL getRtc() {
+        return rtc;
+    }
+
+    public StreamURL getRtcs() {
+        return rtcs;
+    }
+
+    public MediaServer getMediaServer() {
+        return mediaServer;
+    }
+
+    public void setMediaServer(MediaServer mediaServer) {
+        this.mediaServer = mediaServer;
+    }
+
+    public MediaInfo getMediaInfo() {
+        return mediaInfo;
+    }
+
+    public void setMediaInfo(MediaInfo mediaInfo) {
+        this.mediaInfo = mediaInfo;
+    }
+
+    public String getStartTime() {
+        return startTime;
+    }
+
+    public void setStartTime(String startTime) {
+        this.startTime = startTime;
+    }
+
+    public String getEndTime() {
+        return endTime;
+    }
+
+    public void setEndTime(String endTime) {
+        this.endTime = endTime;
+    }
+
+    public double getProgress() {
+        return progress;
+    }
+
+    public void setProgress(double progress) {
+        this.progress = progress;
+    }
+
+    public boolean isPause() {
+        return pause;
+    }
+
+    public void setPause(boolean pause) {
+        this.pause = pause;
+    }
+
+    public TransactionInfo getTransactionInfo() {
+        return transactionInfo;
+    }
+
+    public void setTransactionInfo(TransactionInfo transactionInfo) {
+        this.transactionInfo = transactionInfo;
+    }
+
+    public StreamInfo getTranscodeStream() {
+        return transcodeStream;
+    }
+
+    public void setTranscodeStream(StreamInfo transcodeStream) {
+        this.transcodeStream = transcodeStream;
+    }
+
+    @Override
+    public StreamInfo clone() {
+        StreamInfo instance = null;
+        try{
+            instance = (StreamInfo)super.clone();
+            if (this.flv != null) {
+                instance.flv=this.flv.clone();
+            }
+            if (this.ws_flv != null ){
+                instance.ws_flv= this.ws_flv.clone();
+            }
+            if (this.hls != null ) {
+                instance.hls= this.hls.clone();
+            }
+            if (this.ws_hls != null ) {
+                instance.ws_hls= this.ws_hls.clone();
+            }
+            if (this.ts != null ) {
+                instance.ts= this.ts.clone();
+            }
+            if (this.ws_ts != null ) {
+                instance.ws_ts= this.ws_ts.clone();
+            }
+            if (this.fmp4 != null ) {
+                instance.fmp4= this.fmp4.clone();
+            }
+            if (this.ws_fmp4 != null ) {
+                instance.ws_fmp4= this.ws_fmp4.clone();
+            }
+            if (this.rtc != null ) {
+                instance.rtc= this.rtc.clone();
+            }
+            if (this.https_flv != null) {
+                instance.https_flv= this.https_flv.clone();
+            }
+            if (this.wss_flv != null) {
+                instance.wss_flv= this.wss_flv.clone();
+            }
+            if (this.https_hls != null) {
+                instance.https_hls= this.https_hls.clone();
+            }
+            if (this.wss_hls != null) {
+                instance.wss_hls= this.wss_hls.clone();
+            }
+            if (this.wss_ts != null) {
+                instance.wss_ts= this.wss_ts.clone();
+            }
+            if (this.https_fmp4 != null) {
+                instance.https_fmp4= this.https_fmp4.clone();
+            }
+            if (this.wss_fmp4 != null) {
+                instance.wss_fmp4= this.wss_fmp4.clone();
+            }
+            if (this.rtcs != null) {
+                instance.rtcs= this.rtcs.clone();
+            }
+            if (this.rtsp != null) {
+                instance.rtsp= this.rtsp.clone();
+            }
+            if (this.rtsps != null) {
+                instance.rtsps= this.rtsps.clone();
+            }
+            if (this.rtmp != null) {
+                instance.rtmp= this.rtmp.clone();
+            }
+            if (this.rtmps != null) {
+                instance.rtmps= this.rtmps.clone();
+            }
+        }catch(CloneNotSupportedException e) {
+            e.printStackTrace();
+        }
+        return instance;
+    }
+
+
+    /*=========================设备主子码流逻辑START====================*/
+    /**
+     * 是否为子码流(true-是,false-主码流)
+     */
+    private boolean subStream;
+
+    public boolean isSubStream() {
+        return subStream;
+    }
+
+    public void setSubStream(boolean subStream) {
+        this.subStream = subStream;
+    }
+
+    public DownloadFileInfo getDownLoadFilePath() {
+        return downLoadFilePath;
+    }
+
+    public void setDownLoadFilePath(DownloadFileInfo downLoadFilePath) {
+        this.downLoadFilePath = downLoadFilePath;
+    }
+
+    public int getOriginType() {
+        return originType;
+    }
+
+    public void setOriginType(int originType) {
+        this.originType = originType;
+    }
+
+    public String getServerId() {
+        return serverId;
+    }
+
+    public void setServerId(String serverId) {
+        this.serverId = serverId;
+    }
+
+    public String getCallId() {
+        return callId;
+    }
+
+    public void setCallId(String callId) {
+        this.callId = callId;
+    }
+}

+ 94 - 0
video-service/src/main/java/com/ruoyi/wvp/common/StreamURL.java

@@ -0,0 +1,94 @@
+package com.ruoyi.wvp.common;
+
+import java.io.Serializable;
+
+
+/**
+ * 流地址信息
+ */
+public class StreamURL implements Serializable,Cloneable {
+
+    /**
+     * 协议
+     */
+    private String protocol;
+
+    /**
+     * 主机地址
+     */
+    private String host;
+
+    /**
+     * 端口
+     */
+    private int port = -1;
+
+    /**
+     * 定位位置
+     */
+    private String file;
+
+    /**
+     * 拼接后的地址
+     */
+    private String url;
+
+    public StreamURL() {
+    }
+
+    public StreamURL(String protocol, String host, int port, String file) {
+        this.protocol = protocol;
+        this.host = host;
+        this.port = port;
+        this.file = file;
+    }
+
+    public String getProtocol() {
+        return protocol;
+    }
+
+    public void setProtocol(String protocol) {
+        this.protocol = protocol;
+    }
+
+    public String getHost() {
+        return host;
+    }
+
+    public void setHost(String host) {
+        this.host = host;
+    }
+
+    public int getPort() {
+        return port;
+    }
+
+    public void setPort(int port) {
+        this.port = port;
+    }
+
+    public String getFile() {
+        return file;
+    }
+
+    public void setFile(String file) {
+        this.file = file;
+    }
+
+    public String getUrl() {
+        return this.toString();
+    }
+
+    @Override
+    public String toString() {
+        if (protocol != null && host != null && port != -1 ) {
+            return String.format("%s://%s:%s/%s", protocol, host, port, file);
+        }else {
+            return null;
+        }
+    }
+    @Override
+    public StreamURL clone() throws CloneNotSupportedException {
+        return (StreamURL) super.clone();
+    }
+}

+ 54 - 0
video-service/src/main/java/com/ruoyi/wvp/common/SystemAllInfo.java

@@ -0,0 +1,54 @@
+package com.ruoyi.wvp.common;
+
+import java.util.List;
+
+public class SystemAllInfo {
+
+    private List<Object> cpu;
+    private List<Object> mem;
+    private List<Object> net;
+
+    private long netTotal;
+
+    private Object disk;
+
+    public List<Object> getCpu() {
+        return cpu;
+    }
+
+    public void setCpu(List<Object> cpu) {
+        this.cpu = cpu;
+    }
+
+    public List<Object> getMem() {
+        return mem;
+    }
+
+    public void setMem(List<Object> mem) {
+        this.mem = mem;
+    }
+
+    public List<Object> getNet() {
+        return net;
+    }
+
+    public void setNet(List<Object> net) {
+        this.net = net;
+    }
+
+    public Object getDisk() {
+        return disk;
+    }
+
+    public void setDisk(Object disk) {
+        this.disk = disk;
+    }
+
+    public long getNetTotal() {
+        return netTotal;
+    }
+
+    public void setNetTotal(long netTotal) {
+        this.netTotal = netTotal;
+    }
+}

+ 149 - 0
video-service/src/main/java/com/ruoyi/wvp/common/VersionPo.java

@@ -0,0 +1,149 @@
+package com.ruoyi.wvp.common;
+
+import com.alibaba.fastjson2.annotation.JSONField;
+
+public class VersionPo {
+    /**
+     * git的全版本号
+     */
+    @JSONField(name="GIT_Revision")
+    private String GIT_Revision;
+    /**
+     * maven版本
+     */
+    @JSONField(name = "Create_By")
+    private String Create_By;
+    /**
+     * git的分支
+     */
+    @JSONField(name = "GIT_BRANCH")
+    private String GIT_BRANCH;
+    /**
+     * git的url
+     */
+    @JSONField(name = "GIT_URL")
+    private String GIT_URL;
+    /**
+     * 构建日期
+     */
+    @JSONField(name = "BUILD_DATE")
+    private String BUILD_DATE;
+    /**
+     * 构建日期
+     */
+    @JSONField(name = "GIT_DATE")
+    private String GIT_DATE;
+    /**
+     * 项目名称 配合pom使用
+     */
+    @JSONField(name = "artifactId")
+    private String artifactId;
+    /**
+     * git局部版本号
+     */
+    @JSONField(name = "GIT_Revision_SHORT")
+    private String GIT_Revision_SHORT;
+    /**
+     * 项目的版本如2.0.1.0 配合pom使用
+     */
+    @JSONField(name = "version")
+    private String version;
+    /**
+     * 子系统名称
+     */
+    @JSONField(name = "project")
+    private String project;
+    /**
+     * jdk版本
+     */
+    @JSONField(name="Build_Jdk")
+    private String Build_Jdk;
+
+    public void setGIT_Revision(String GIT_Revision) {
+        this.GIT_Revision = GIT_Revision;
+    }
+
+    public void setCreate_By(String create_By) {
+        Create_By = create_By;
+    }
+
+    public void setGIT_BRANCH(String GIT_BRANCH) {
+        this.GIT_BRANCH = GIT_BRANCH;
+    }
+
+    public void setGIT_URL(String GIT_URL) {
+        this.GIT_URL = GIT_URL;
+    }
+
+    public void setBUILD_DATE(String BUILD_DATE) {
+        this.BUILD_DATE = BUILD_DATE;
+    }
+
+    public void setArtifactId(String artifactId) {
+        this.artifactId = artifactId;
+    }
+
+    public void setGIT_Revision_SHORT(String GIT_Revision_SHORT) {
+        this.GIT_Revision_SHORT = GIT_Revision_SHORT;
+    }
+
+    public void setVersion(String version) {
+        this.version = version;
+    }
+
+    public void setProject(String project) {
+        this.project = project;
+    }
+
+    public void setBuild_Jdk(String build_Jdk) {
+        Build_Jdk = build_Jdk;
+    }
+
+    public String getGIT_Revision() {
+        return GIT_Revision;
+    }
+
+    public String getCreate_By() {
+        return Create_By;
+    }
+
+    public String getGIT_BRANCH() {
+        return GIT_BRANCH;
+    }
+
+    public String getGIT_URL() {
+        return GIT_URL;
+    }
+
+    public String getBUILD_DATE() {
+        return BUILD_DATE;
+    }
+
+    public String getArtifactId() {
+        return artifactId;
+    }
+
+    public String getGIT_Revision_SHORT() {
+        return GIT_Revision_SHORT;
+    }
+
+    public String getVersion() {
+        return version;
+    }
+
+    public String getProject() {
+        return project;
+    }
+
+    public String getBuild_Jdk() {
+        return Build_Jdk;
+    }
+
+    public String getGIT_DATE() {
+        return GIT_DATE;
+    }
+
+    public void setGIT_DATE(String GIT_DATE) {
+        this.GIT_DATE = GIT_DATE;
+    }
+}

+ 159 - 0
video-service/src/main/java/com/ruoyi/wvp/common/VideoManagerConstants.java

@@ -0,0 +1,159 @@
+package com.ruoyi.wvp.common;
+
+/**
+ * @description: 定义常量
+ * @author: swwheihei
+ * @date:   2019年5月30日 下午3:04:04
+ *
+ */
+public class VideoManagerConstants {
+
+	public static final String WVP_SERVER_PREFIX = "CITY_VIDEO_VMP_SIGNALLING_SERVER_INFO_";
+
+	public static final String WVP_SERVER_STREAM_PREFIX = "CITY_VIDEO_VMP_SIGNALLING_STREAM_";
+
+	public static final String MEDIA_SERVER_PREFIX = "CITY_VIDEO_VMP_MEDIA_SERVER_INFO:";
+
+	public static final String ONLINE_MEDIA_SERVERS_PREFIX = "CITY_VIDEO_VMP_ONLINE_MEDIA_SERVERS:";
+
+	public static final String DEVICE_PREFIX = "CITY_VIDEO_VMP_DEVICE_INFO";
+
+	public static final String INVITE_PREFIX = "CITY_VIDEO_VMP_GB_INVITE_INFO";
+
+	public static final String PLATFORM_CATCH_PREFIX = "CITY_VIDEO_VMP_PLATFORM_CATCH_";
+
+	public static final String PLATFORM_REGISTER_INFO_PREFIX = "CITY_VIDEO_VMP_PLATFORM_REGISTER_INFO_";
+
+	public static final String SEND_RTP_PORT = "VM_SEND_RTP_PORT:";
+	public static final String SEND_RTP_INFO_CALLID = "CITY_VIDEO_VMP_SEND_RTP_INFO:CALL_ID:";
+	public static final String SEND_RTP_INFO_STREAM = "CITY_VIDEO_VMP_SEND_RTP_INFO:STREAM:";
+	public static final String SEND_RTP_INFO_CHANNEL = "CITY_VIDEO_VMP_SEND_RTP_INFO:CHANNEL:";
+
+	public static final String SIP_INVITE_SESSION = "CITY_VIDEO_VMP_SIP_INVITE_SESSION_INFO:";
+	public static final String SIP_INVITE_SESSION_CALL_ID = SIP_INVITE_SESSION + "CALL_ID:";
+	public static final String SIP_INVITE_SESSION_STREAM = SIP_INVITE_SESSION + "STREAM:";
+
+	public static final String MEDIA_STREAM_AUTHORITY = "CITY_VIDEO_VMP_MEDIA_STREAM_AUTHORITY:";
+
+	public static final String SIP_CSEQ_PREFIX = "CITY_VIDEO_VMP_SIP_CSEQ_";
+
+	public static final String SIP_SUBSCRIBE_PREFIX = "CITY_VIDEO_VMP_SIP_SUBSCRIBE_";
+
+	public static final String SYSTEM_INFO_CPU_PREFIX = "CITY_VIDEO_VMP_SYSTEM_INFO_CPU_";
+
+	public static final String SYSTEM_INFO_MEM_PREFIX = "CITY_VIDEO_VMP_SYSTEM_INFO_MEM_";
+
+	public static final String SYSTEM_INFO_NET_PREFIX = "CITY_VIDEO_VMP_SYSTEM_INFO_NET_";
+
+	public static final String SYSTEM_INFO_DISK_PREFIX = "CITY_VIDEO_VMP_SYSTEM_INFO_DISK_";
+	public static final String BROADCAST_WAITE_INVITE = "task_broadcast_waite_invite_";
+
+	public static final String REGISTER_EXPIRE_TASK_KEY_PREFIX = "CITY_VIDEO_VMP_device_register_expire_";
+	public static final String PUSH_STREAM_LIST = "CITY_VIDEO_VMP_PUSH_STREAM_LIST_";
+	public static final String WAITE_SEND_PUSH_STREAM = "CITY_VIDEO_VMP_WAITE_SEND_PUSH_STREAM:";
+	public static final String START_SEND_PUSH_STREAM = "CITY_VIDEO_VMP_START_SEND_PUSH_STREAM:";
+	public static final String SSE_TASK_KEY = "SSE_TASK_";
+
+
+
+
+	//************************** redis 消息*********************************
+
+	/**
+	 * 流变化的通知
+	 */
+	public static final String WVP_MSG_STREAM_CHANGE_PREFIX = "WVP_MSG_STREAM_CHANGE_";
+
+	/**
+	 * 接收推流设备的GPS变化通知
+	 */
+	public static final String CITY_VIDEO_VM_MSG_GPS = "CITY_VIDEO_VM_MSG_GPS";
+
+	/**
+	 * 接收推流设备的GPS变化通知
+	 */
+	public static final String CITY_VIDEO_VM_MSG_PUSH_STREAM_STATUS_CHANGE = "CITY_VIDEO_VM_MSG_PUSH_STREAM_STATUS_CHANGE";
+	/**
+	 * 接收推流设备列表更新变化通知
+	 */
+	public static final String CITY_VIDEO_VM_MSG_PUSH_STREAM_LIST_CHANGE = "CITY_VIDEO_VM_MSG_PUSH_STREAM_LIST_CHANGE";
+
+	/**
+	 * redis 消息通知设备推流到平台
+	 */
+	public static final String CITY_VIDEO_VM_MSG_STREAM_PUSH_REQUESTED = "CITY_VIDEO_VM_MSG_STREAM_PUSH_REQUESTED";
+
+	/**
+	 * redis 消息通知上级平台开始观看流
+	 */
+	public static final String CITY_VIDEO_VM_MSG_STREAM_START_PLAY_NOTIFY = "CITY_VIDEO_VM_MSG_STREAM_START_PLAY_NOTIFY";
+
+	/**
+	 * redis 消息通知上级平台停止观看流
+	 */
+	public static final String CITY_VIDEO_VM_MSG_STREAM_STOP_PLAY_NOTIFY = "CITY_VIDEO_VM_MSG_STREAM_STOP_PLAY_NOTIFY";
+
+	/**
+	 * redis 消息接收关闭一个推流
+	 */
+	public static final String CITY_VIDEO_VM_MSG_STREAM_PUSH_CLOSE_REQUESTED = "CITY_VIDEO_VM_MSG_STREAM_PUSH_CLOSE_REQUESTED";
+
+
+	/**
+	 * redis 消息通知平台通知设备推流结果
+	 */
+	public static final String CITY_VIDEO_VM_MSG_STREAM_PUSH_RESPONSE = "CITY_VIDEO_VM_MSG_STREAM_PUSH_RESPONSE";
+
+	/**
+	 * redis 通知平台关闭推流
+	 */
+	public static final String CITY_VIDEO_VM_MSG_STREAM_PUSH_CLOSE = "CITY_VIDEO_VM_MSG_STREAM_PUSH_CLOSE";
+
+	/**
+	 * redis 消息请求所有的在线通道
+	 */
+	public static final String CITY_VIDEO_VM_MSG_GET_ALL_ONLINE_REQUESTED = "CITY_VIDEO_VM_MSG_GET_ALL_ONLINE_REQUESTED";
+
+	/**
+	 * 移动位置订阅通知
+	 */
+	public static final String CITY_VIDEO_VM_MSG_SUBSCRIBE_MOBILE_POSITION = "mobileposition";
+
+	/**
+	 * 报警订阅的通知(收到报警向redis发出通知)
+	 */
+	public static final String CITY_VIDEO_VM_MSG_SUBSCRIBE_ALARM = "alarm";
+
+
+	/**
+	 * 报警通知的发送 (收到redis发出的通知,转发给其他平台)
+	 */
+	public static final String CITY_VIDEO_VM_MSG_SUBSCRIBE_ALARM_RECEIVE= "alarm_receive";
+
+	/**
+	 * 设备状态订阅的通知
+	 */
+	public static final String CITY_VIDEO_VM_MSG_SUBSCRIBE_DEVICE_STATUS = "device";
+
+
+	//**************************    第三方  ****************************************
+
+	public static final String WVP_STREAM_GB_ID_PREFIX = "memberNo_";
+	public static final String WVP_STREAM_GPS_MSG_PREFIX = "WVP_STREAM_GPS_MSG_";
+	public static final String WVP_OTHER_SEND_RTP_INFO = "CITY_VIDEO_VMP_OTHER_SEND_RTP_INFO_";
+	public static final String WVP_OTHER_SEND_PS_INFO = "CITY_VIDEO_VMP_OTHER_SEND_PS_INFO_";
+	public static final String WVP_OTHER_RECEIVE_RTP_INFO = "CITY_VIDEO_VMP_OTHER_RECEIVE_RTP_INFO_";
+	public static final String WVP_OTHER_RECEIVE_PS_INFO = "CITY_VIDEO_VMP_OTHER_RECEIVE_PS_INFO_";
+
+	/**
+	 * Redis Const
+	 * 设备录像信息结果前缀
+	 */
+	public static final String REDIS_RECORD_INFO_RES_PRE = "GB_RECORD_INFO_RES_";
+	/**
+	 * Redis Const
+	 * 设备录像信息结果前缀
+	 */
+	public static final String REDIS_RECORD_INFO_RES_COUNT_PRE = "GB_RECORD_INFO_RES_COUNT:";
+
+}

+ 21 - 0
video-service/src/main/java/com/ruoyi/wvp/common/enums/ChannelDataType.java

@@ -0,0 +1,21 @@
+package com.ruoyi.wvp.common.enums;
+
+/**
+ * 支持的通道数据类型
+ */
+
+public enum ChannelDataType {
+
+    GB28181(1,"国标28181"),
+    STREAM_PUSH(2,"推流设备"),
+    STREAM_PROXY(3,"拉流代理");
+
+    public final int value;
+
+    public final String desc;
+
+    ChannelDataType(Integer value, String desc) {
+        this.value = value;
+        this.desc = desc;
+    }
+}

+ 77 - 0
video-service/src/main/java/com/ruoyi/wvp/common/enums/DeviceControlType.java

@@ -0,0 +1,77 @@
+package com.ruoyi.wvp.common.enums;
+
+import org.dom4j.Element;
+import org.springframework.util.ObjectUtils;
+
+
+/**
+ * @author gaofuwang
+ * @date 2023/01/18/ 10:09:00
+ * @since 1.0
+ */
+public enum DeviceControlType {
+
+    /**
+     * 云台控制
+     * 上下左右,预置位,扫描,辅助功能,巡航
+     */
+    PTZ("PTZCmd","云台控制"),
+    /**
+     * 远程启动
+     */
+    TELE_BOOT("TeleBoot","远程启动"),
+    /**
+     * 录像控制
+     */
+    RECORD("RecordCmd","录像控制"),
+    /**
+     * 布防撤防
+     */
+    GUARD("GuardCmd","布防撤防"),
+    /**
+     * 告警控制
+     */
+    ALARM("AlarmCmd","告警控制"),
+    /**
+     * 强制关键帧
+     */
+    I_FRAME("IFameCmd","强制关键帧"),
+    /**
+     * 拉框放大
+     */
+    DRAG_ZOOM_IN("DragZoomIn","拉框放大"),
+    /**
+     * 拉框缩小
+     */
+    DRAG_ZOOM_OUT("DragZoomOut","拉框缩小"),
+    /**
+     * 看守位
+     */
+    HOME_POSITION("HomePosition","看守位");
+
+    private final String val;
+
+    private final String desc;
+
+    DeviceControlType(String val, String desc) {
+        this.val = val;
+        this.desc = desc;
+    }
+
+    public String getVal() {
+        return val;
+    }
+
+    public String getDesc() {
+        return desc;
+    }
+
+    public static DeviceControlType typeOf(Element rootElement) {
+        for (DeviceControlType item : DeviceControlType.values()) {
+            if (!ObjectUtils.isEmpty(rootElement.element(item.val)) || !ObjectUtils.isEmpty(rootElement.elements(item.val))) {
+                return item;
+            }
+        }
+        return null;
+    }
+}

+ 159 - 0
video-service/src/main/java/com/ruoyi/wvp/conf/DynamicTask.java

@@ -0,0 +1,159 @@
+package com.ruoyi.wvp.conf;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.ObjectUtils;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PostConstruct;
+import java.time.Instant;
+import java.util.Date;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 动态定时任务
+ * @author lin
+ */
+@Slf4j
+@Component
+public class DynamicTask {
+
+    private ThreadPoolTaskScheduler threadPoolTaskScheduler;
+
+    private final Map<String, ScheduledFuture<?>> futureMap = new ConcurrentHashMap<>();
+    private final Map<String, Runnable> runnableMap = new ConcurrentHashMap<>();
+
+    @PostConstruct
+    public void DynamicTask() {
+        threadPoolTaskScheduler = new ThreadPoolTaskScheduler();
+        threadPoolTaskScheduler.setPoolSize(300);
+        threadPoolTaskScheduler.setWaitForTasksToCompleteOnShutdown(true);
+        threadPoolTaskScheduler.setAwaitTerminationSeconds(10);
+        threadPoolTaskScheduler.setThreadNamePrefix("dynamicTask-");
+        threadPoolTaskScheduler.initialize();
+    }
+
+    /**
+     * 循环执行的任务
+     * @param key 任务ID
+     * @param task 任务
+     * @param cycleForCatalog 间隔 毫秒
+     * @return
+     */
+    public void startCron(String key, Runnable task, int cycleForCatalog) {
+        if(ObjectUtils.isEmpty(key)) {
+            return;
+        }
+        ScheduledFuture<?> future = futureMap.get(key);
+        if (future != null) {
+            if (future.isCancelled()) {
+                log.debug("任务【{}】已存在但是关闭状态!!!", key);
+            } else {
+                log.debug("任务【{}】已存在且已启动!!!", key);
+                return;
+            }
+        }
+        // scheduleWithFixedDelay 必须等待上一个任务结束才开始计时period, cycleForCatalog表示执行的间隔
+
+        future = threadPoolTaskScheduler.scheduleAtFixedRate(task, new Date(System.currentTimeMillis() + cycleForCatalog), cycleForCatalog);
+        if (future != null){
+            futureMap.put(key, future);
+            runnableMap.put(key, task);
+            log.debug("任务【{}】启动成功!!!", key);
+        }else {
+            log.debug("任务【{}】启动失败!!!", key);
+        }
+    }
+
+    /**
+     * 延时任务
+     * @param key 任务ID
+     * @param task 任务
+     * @param delay 延时 /毫秒
+     * @return
+     */
+    public void startDelay(String key, Runnable task, int delay) {
+        if(ObjectUtils.isEmpty(key)) {
+            return;
+        }
+        stop(key);
+
+        // 获取执行的时刻
+        Instant startInstant = Instant.now().plusMillis(TimeUnit.MILLISECONDS.toMillis(delay));
+
+        ScheduledFuture future = futureMap.get(key);
+        if (future != null) {
+            if (future.isCancelled()) {
+                log.debug("任务【{}】已存在但是关闭状态!!!", key);
+            } else {
+                log.debug("任务【{}】已存在且已启动!!!", key);
+                return;
+            }
+        }
+        // scheduleWithFixedDelay 必须等待上一个任务结束才开始计时period, cycleForCatalog表示执行的间隔
+        future = threadPoolTaskScheduler.schedule(task, startInstant);
+        if (future != null){
+            futureMap.put(key, future);
+            runnableMap.put(key, task);
+            log.debug("任务【{}】启动成功!!!", key);
+        }else {
+            log.debug("任务【{}】启动失败!!!", key);
+        }
+    }
+
+    public boolean stop(String key) {
+        if(ObjectUtils.isEmpty(key)) {
+            return false;
+        }
+        boolean result = false;
+        if (!ObjectUtils.isEmpty(futureMap.get(key)) && !futureMap.get(key).isCancelled() && !futureMap.get(key).isDone()) {
+            result = futureMap.get(key).cancel(false);
+            futureMap.remove(key);
+            runnableMap.remove(key);
+        }
+        return result;
+    }
+
+    public boolean contains(String key) {
+        if(ObjectUtils.isEmpty(key)) {
+            return false;
+        }
+        return futureMap.get(key) != null;
+    }
+
+    public Set<String> getAllKeys() {
+        return futureMap.keySet();
+    }
+
+    public Runnable get(String key) {
+        if(ObjectUtils.isEmpty(key)) {
+            return null;
+        }
+        return runnableMap.get(key);
+    }
+
+    /**
+     * 每五分钟检查失效的任务,并移除
+     */
+    @Scheduled(cron="0 0/5 * * * ?")
+    public void execute(){
+        if (futureMap.size() > 0) {
+            for (String key : futureMap.keySet()) {
+                ScheduledFuture<?> future = futureMap.get(key);
+                if (future.isDone() || future.isCancelled()) {
+                    futureMap.remove(key);
+                    runnableMap.remove(key);
+                }
+            }
+        }
+    }
+
+    public boolean isAlive(String key) {
+        return futureMap.get(key) != null && !futureMap.get(key).isDone() && !futureMap.get(key).isCancelled();
+    }
+}

+ 291 - 0
video-service/src/main/java/com/ruoyi/wvp/conf/MediaConfig.java

@@ -0,0 +1,291 @@
+package com.ruoyi.wvp.conf;
+
+import com.ruoyi.wvp.media.bean.MediaServer;
+import com.ruoyi.wvp.utils.DateUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.annotation.Order;
+import org.springframework.util.ObjectUtils;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.regex.Pattern;
+
+@Slf4j
+@Configuration("mediaConfig")
+@Order(0)
+public class MediaConfig{
+
+    // 修改必须配置,不再支持自动获取
+    @Value("${media.id}")
+    private String id;
+
+    @Value("${media.ip}")
+    private String ip;
+
+    @Value("${media.wan_ip:}")
+    private String wanIp;
+
+    @Value("${media.hook-ip:127.0.0.1}")
+    private String hookIp;
+
+    @Value("${sip.domain}")
+    private String sipDomain;
+
+    @Value("${media.sdp-ip:${media.wan_ip:}}")
+    private String sdpIp;
+
+    @Value("${media.stream-ip:${media.wan_ip:}}")
+    private String streamIp;
+
+    @Value("${media.http-port:0}")
+    private Integer httpPort;
+
+    @Value("${media.flv-port:0}")
+    private Integer flvPort = 0;
+
+    @Value("${media.ws-flv-port:0}")
+    private Integer wsFlvPort = 0;
+
+    @Value("${media.http-ssl-port:0}")
+    private Integer httpSSlPort = 0;
+
+    @Value("${media.flv-ssl-port:0}")
+    private Integer flvSSlPort = 0;
+
+    @Value("${media.ws-flv-ssl-port:0}")
+    private Integer wsFlvSSlPort = 0;
+
+    @Value("${media.rtmp-port:0}")
+    private Integer rtmpPort = 0;
+
+    @Value("${media.rtmp-ssl-port:0}")
+    private Integer rtmpSSlPort = 0;
+
+    @Value("${media.rtp-proxy-port:0}")
+    private Integer rtpProxyPort = 0;
+
+    @Value("${media.rtsp-port:0}")
+    private Integer rtspPort = 0;
+
+    @Value("${media.rtsp-ssl-port:0}")
+    private Integer rtspSSLPort = 0;
+
+    @Value("${media.auto-config:true}")
+    private boolean autoConfig = true;
+
+    @Value("${media.secret}")
+    private String secret;
+
+    @Value("${media.rtp.enable}")
+    private boolean rtpEnable;
+
+    @Value("${media.rtp.port-range}")
+    private String rtpPortRange;
+
+    @Value("${media.rtp.send-port-range}")
+    private String rtpSendPortRange;
+
+    @Value("${media.record-assist-port:0}")
+    private Integer recordAssistPort = 0;
+
+    @Value("${media.record-day:7}")
+    private Integer recordDay;
+
+    @Value("${media.record-path:}")
+    private String recordPath;
+
+    @Value("${media.type:zlm}")
+    private String type;
+
+    public String getId() {
+        return id;
+    }
+
+    public String getIp() {
+        return ip;
+    }
+
+    public String getHookIp() {
+        return hookIp;
+    }
+
+    public int getHttpPort() {
+        return httpPort;
+    }
+
+    public int getHttpSSlPort() {
+        return httpSSlPort;
+    }
+
+    public int getRtmpPort() {
+        return rtmpPort;
+    }
+
+    public int getRtmpSSlPort() {
+        return rtmpSSlPort;
+    }
+
+    public int getRtpProxyPort() {
+        if (rtpProxyPort == null) {
+            return 0;
+        }else {
+            return rtpProxyPort;
+        }
+
+    }
+
+    public int getRtspPort() {
+        return rtspPort;
+    }
+
+    public int getRtspSSLPort() {
+        return rtspSSLPort;
+    }
+
+    public boolean isAutoConfig() {
+        return autoConfig;
+    }
+
+    public String getSecret() {
+        return secret;
+    }
+
+    public boolean isRtpEnable() {
+        return rtpEnable;
+    }
+
+    public String getRtpPortRange() {
+        return rtpPortRange;
+    }
+
+    public int getRecordAssistPort() {
+        return recordAssistPort;
+    }
+
+    public String getSdpIp() {
+        if (ObjectUtils.isEmpty(sdpIp)){
+            return ip;
+        }else {
+            if (isValidIPAddress(sdpIp)) {
+                return sdpIp;
+            }else {
+                // 按照域名解析
+                String hostAddress = null;
+                try {
+                    hostAddress = InetAddress.getByName(sdpIp).getHostAddress();
+                } catch (UnknownHostException e) {
+                    log.error("[获取SDP IP]: 域名解析失败");
+                }
+                return hostAddress;
+            }
+        }
+    }
+
+    public String getStreamIp() {
+        if (ObjectUtils.isEmpty(streamIp)){
+            return ip;
+        }else {
+            return streamIp;
+        }
+    }
+
+    public String getSipDomain() {
+        return sipDomain;
+    }
+
+    public MediaServer getMediaSerItem(){
+        MediaServer mediaServer = new MediaServer();
+        mediaServer.setId(id);
+        mediaServer.setIp(ip);
+        mediaServer.setDefaultServer(true);
+        mediaServer.setHookIp(getHookIp());
+        mediaServer.setSdpIp(getSdpIp());
+        mediaServer.setStreamIp(getStreamIp());
+        mediaServer.setHttpPort(httpPort);
+        if (flvPort == 0) {
+            mediaServer.setFlvPort(httpPort);
+        }else {
+            mediaServer.setFlvPort(flvPort);
+        }
+        if (wsFlvPort == 0) {
+            mediaServer.setWsFlvPort(httpPort);
+        }else {
+            mediaServer.setWsFlvPort(wsFlvPort);
+        }
+        if (flvSSlPort == 0) {
+            mediaServer.setFlvSSLPort(httpSSlPort);
+        }else {
+            mediaServer.setFlvSSLPort(flvSSlPort);
+        }
+        if (wsFlvSSlPort == 0) {
+            mediaServer.setWsFlvSSLPort(httpSSlPort);
+        }else {
+            mediaServer.setWsFlvSSLPort(wsFlvSSlPort);
+        }
+
+        mediaServer.setHttpSSlPort(httpSSlPort);
+        mediaServer.setRtmpPort(rtmpPort);
+        mediaServer.setRtmpSSlPort(rtmpSSlPort);
+        mediaServer.setRtpProxyPort(getRtpProxyPort());
+        mediaServer.setRtspPort(rtspPort);
+        mediaServer.setRtspSSLPort(rtspSSLPort);
+        mediaServer.setAutoConfig(autoConfig);
+        mediaServer.setSecret(secret);
+        mediaServer.setRtpEnable(rtpEnable);
+        mediaServer.setRtpPortRange(rtpPortRange);
+        mediaServer.setSendRtpPortRange(rtpSendPortRange);
+        mediaServer.setRecordAssistPort(recordAssistPort);
+        mediaServer.setHookAliveInterval(10f);
+        mediaServer.setRecordDay(recordDay);
+        mediaServer.setStatus(false);
+        mediaServer.setType(type);
+        if (recordPath != null) {
+            mediaServer.setRecordPath(recordPath);
+        }
+        mediaServer.setCreateTime(DateUtil.getNow());
+        mediaServer.setUpdateTime(DateUtil.getNow());
+
+        return mediaServer;
+    }
+
+    public Integer getRecordDay() {
+        return recordDay;
+    }
+
+    public void setRecordDay(Integer recordDay) {
+        this.recordDay = recordDay;
+    }
+
+    public String getRecordPath() {
+        return recordPath;
+    }
+
+    public void setRecordPath(String recordPath) {
+        this.recordPath = recordPath;
+    }
+
+    public String getRtpSendPortRange() {
+        return rtpSendPortRange;
+    }
+
+    public void setRtpSendPortRange(String rtpSendPortRange) {
+        this.rtpSendPortRange = rtpSendPortRange;
+    }
+
+    private boolean isValidIPAddress(String ipAddress) {
+        if ((ipAddress != null) && (!ipAddress.isEmpty())) {
+            return Pattern.matches("^([1-9]|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])(\\.(\\d|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])){3}$", ipAddress);
+        }
+        return false;
+    }
+
+    public String getWanIp() {
+        return wanIp;
+    }
+
+    public void setWanIp(String wanIp) {
+        this.wanIp = wanIp;
+    }
+}

+ 13 - 0
video-service/src/main/java/com/ruoyi/wvp/conf/MediaStatusTimerTask.java

@@ -0,0 +1,13 @@
+package com.ruoyi.wvp.conf;
+
+/**
+ * 定时向zlm同步媒体流状态
+ */
+public class MediaStatusTimerTask {
+
+
+//    @Scheduled(fixedRate = 2 * 1000)   //每3秒执行一次
+    public void execute(){
+
+    }
+}

+ 37 - 0
video-service/src/main/java/com/ruoyi/wvp/conf/ScheduleConfig.java

@@ -0,0 +1,37 @@
+package com.ruoyi.wvp.conf;
+
+import org.apache.commons.lang3.concurrent.BasicThreadFactory;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.SchedulingConfigurer;
+import org.springframework.scheduling.config.ScheduledTaskRegistrar;
+
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.ThreadPoolExecutor;
+
+import static com.ruoyi.wvp.conf.ThreadPoolTaskConfig.cpuNum;
+
+/**
+ * "@Scheduled"是Spring框架提供的一种定时任务执行机制,默认情况下它是单线程的,在同时执行多个定时任务时可能会出现阻塞和性能问题。
+ * 为了解决这种单线程瓶颈问题,可以将定时任务的执行机制改为支持多线程
+ */
+@Configuration
+public class ScheduleConfig implements SchedulingConfigurer {
+
+	/**
+	 * 核心线程数(默认线程数)
+	 */
+	private static final int corePoolSize = Math.max(cpuNum, 20);
+
+	/**
+	 * 线程池名前缀
+	 */
+	private static final String threadNamePrefix = "schedule";
+
+	@Override
+	public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
+		ScheduledThreadPoolExecutor scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(corePoolSize,
+				new BasicThreadFactory.Builder().namingPattern(threadNamePrefix).daemon(true).build(),
+				new ThreadPoolExecutor.CallerRunsPolicy());
+		taskRegistrar.setScheduler(scheduledThreadPoolExecutor);
+	}
+}

+ 28 - 0
video-service/src/main/java/com/ruoyi/wvp/conf/ServiceInfo.java

@@ -0,0 +1,28 @@
+package com.ruoyi.wvp.conf;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.web.context.WebServerInitializedEvent;
+import org.springframework.context.ApplicationListener;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+public class ServiceInfo implements ApplicationListener<WebServerInitializedEvent> {
+
+    private static int serverPort;
+
+    public static int getServerPort() {
+        return serverPort;
+    }
+
+    @Override
+    public void onApplicationEvent(WebServerInitializedEvent event) {
+        // 项目启动获取启动的端口号
+        ServiceInfo.serverPort = event.getWebServer().getPort();
+        log.info("项目启动获取启动的端口号:  " + ServiceInfo.serverPort);
+    }
+
+    public void setServerPort(int serverPort) {
+        ServiceInfo.serverPort = serverPort;
+    }
+}

+ 34 - 0
video-service/src/main/java/com/ruoyi/wvp/conf/SipConfig.java

@@ -0,0 +1,34 @@
+package com.ruoyi.wvp.conf;
+
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+@Component
+@ConfigurationProperties(prefix = "sip", ignoreInvalidFields = true)
+@Order(0)
+@Data
+public class SipConfig {
+
+	private String ip;
+
+	private String showIp;
+
+	private Integer port;
+
+	private String domain;
+
+	private String id;
+
+	private String password;
+
+	Integer ptzSpeed = 50;
+
+	Integer registerTimeInterval = 120;
+
+	private boolean alarm = false;
+
+	private long timeout = 15;
+}

+ 41 - 0
video-service/src/main/java/com/ruoyi/wvp/conf/SystemInfoTimerTask.java

@@ -0,0 +1,41 @@
+package com.ruoyi.wvp.conf;
+
+import com.ruoyi.wvp.storager.IRedisCatchStorage;
+import com.ruoyi.wvp.utils.SystemInfoUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 获取系统信息写入redis
+ */
+@Slf4j
+@Component
+public class SystemInfoTimerTask {
+
+    @Autowired
+    private IRedisCatchStorage redisCatchStorage;
+
+    @Scheduled(fixedRate = 2000)   //每1秒执行一次
+    public void execute(){
+        try {
+            double cpuInfo = SystemInfoUtils.getCpuInfo();
+            redisCatchStorage.addCpuInfo(cpuInfo);
+            double memInfo = SystemInfoUtils.getMemInfo();
+            redisCatchStorage.addMemInfo(memInfo);
+            Map<String, Double> networkInterfaces = SystemInfoUtils.getNetworkInterfaces();
+            redisCatchStorage.addNetInfo(networkInterfaces);
+            List<Map<String, Object>> diskInfo =SystemInfoUtils.getDiskInfo();
+            redisCatchStorage.addDiskInfo(diskInfo);
+        } catch (InterruptedException e) {
+            log.error("[获取系统信息失败] {}", e.getMessage());
+        }
+
+    }
+
+
+}

+ 67 - 0
video-service/src/main/java/com/ruoyi/wvp/conf/ThreadPoolTaskConfig.java

@@ -0,0 +1,67 @@
+package com.ruoyi.wvp.conf;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.annotation.Order;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.ThreadPoolExecutor;
+
+/**
+ *  ThreadPoolTask 配置类
+ * @author lin
+ */
+@Configuration
+@Order(1)
+@EnableAsync(proxyTargetClass = true)
+public class ThreadPoolTaskConfig {
+
+    public static final int cpuNum = Runtime.getRuntime().availableProcessors();
+
+    /**
+     *   默认情况下,在创建了线程池后,线程池中的线程数为0,当有任务来之后,就会创建一个线程去执行任务,
+     *    当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到缓存队列当中;
+     *  当队列满了,就继续创建线程,当线程数量大于等于maxPoolSize后,开始使用拒绝策略拒绝
+     */
+
+    /**
+     * 核心线程数(默认线程数)
+     */
+    private static final int corePoolSize = Math.max(cpuNum * 2, 16);
+    /**
+     * 最大线程数
+     */
+    private static final int maxPoolSize = corePoolSize * 10;
+    /**
+     * 允许线程空闲时间(单位:默认为秒)
+     */
+    private static final int keepAliveTime = 30;
+
+    /**
+     * 缓冲队列大小
+     */
+    private static final int queueCapacity = 10000;
+    /**
+     * 线程池名前缀
+     */
+    private static final String threadNamePrefix = "async-";
+
+
+    @Bean("taskExecutor") // bean的名称,默认为首字母小写的方法名
+    public ThreadPoolTaskExecutor taskExecutor() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setCorePoolSize(corePoolSize);
+        executor.setMaxPoolSize(maxPoolSize);
+        executor.setQueueCapacity(queueCapacity);
+        executor.setKeepAliveSeconds(keepAliveTime);
+        executor.setThreadNamePrefix(threadNamePrefix);
+
+        // 线程池对拒绝任务的处理策略
+        // CallerRunsPolicy:由调用线程(提交任务的线程)处理该任务
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        // 初始化
+        executor.initialize();
+        return executor;
+    }
+}

+ 183 - 0
video-service/src/main/java/com/ruoyi/wvp/conf/UserSetting.java

@@ -0,0 +1,183 @@
+package com.ruoyi.wvp.conf;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 配置文件 user-settings 映射的配置信息
+ */
+@Component
+@ConfigurationProperties(prefix = "user-settings", ignoreInvalidFields = true)
+@Order(0)
+@Data
+public class UserSetting {
+
+    /**
+     * 是否保存位置的历史记录(轨迹)
+     */
+    private Boolean savePositionHistory = Boolean.FALSE;
+
+    /**
+     * 是否开始自动点播: 请求流为未拉起的流时,自动开启点播, 需要rtp.enable=true
+     */
+    private Boolean autoApplyPlay = Boolean.FALSE;
+
+    /**
+     * [可选] 部分设备需要扩展SDP,需要打开此设置,一般设备无需打开
+     */
+    private Boolean seniorSdp = Boolean.FALSE;
+
+    /**
+     * 点播/录像回放 等待超时时间,单位:毫秒
+     */
+    private Integer playTimeout = 10000;
+
+    /**
+     * 上级点播等待超时时间,单位:毫秒
+     */
+    private int platformPlayTimeout = 20000;
+
+    /**
+     * 是否开启接口鉴权
+     */
+    private Boolean interfaceAuthentication = Boolean.TRUE;
+
+    /**
+     * 接口鉴权例外的接口, 即不进行接口鉴权的接口,尽量详细书写,尽量不用/**,至少两级目录
+     */
+    private List<String> interfaceAuthenticationExcludes = new ArrayList<>();
+
+    /**
+     * 推流直播是否录制
+     */
+    private Boolean recordPushLive = Boolean.TRUE;
+
+    /**
+     * 国标是否录制
+     */
+    private Boolean recordSip = Boolean.TRUE;
+
+    /**
+     * 使用推流状态作为推流通道状态
+     */
+    private Boolean usePushingAsStatus = Boolean.FALSE;
+
+    /**
+     * 使用来源请求ip作为streamIp,当且仅当你只有zlm节点它与wvp在一起的情况下开启
+     */
+    private Boolean useSourceIpAsStreamIp = Boolean.FALSE;
+
+    /**
+     * 是否使用设备来源Ip作为回复IP, 不设置则为 false
+     */
+    private Boolean sipUseSourceIpAsRemoteAddress = Boolean.FALSE;
+
+    /**
+     * 国标点播 按需拉流, true:有人观看拉流,无人观看释放, false:拉起后不自动释放
+     */
+    private Boolean streamOnDemand = Boolean.TRUE;
+
+    /**
+     * 推流鉴权, 默认开启
+     */
+    private Boolean pushAuthority = Boolean.TRUE;
+
+    /**
+     * 设备上线时是否自动同步通道
+     */
+    private Boolean syncChannelOnDeviceOnline = Boolean.FALSE;
+
+    /**
+     * 是否开启sip日志
+     */
+    private Boolean sipLog = Boolean.FALSE;
+
+    /**
+     * 是否开启mybatis-sql日志
+     */
+    private Boolean sqlLog = Boolean.FALSE;
+
+    /**
+     * 消息通道功能-缺少国标ID是否给所有上级发送消息
+     */
+    private Boolean sendToPlatformsWhenIdLost = Boolean.FALSE;
+
+    /**
+     * 保持通道状态,不接受notify通道状态变化, 兼容海康平台发送错误消息
+     */
+    private Boolean refuseChannelStatusChannelFormNotify = Boolean.FALSE;
+
+    /**
+     * 设备/通道状态变化时发送消息
+     */
+    private Boolean deviceStatusNotify = Boolean.TRUE;
+
+    /**
+     * 上级平台点播时不使用上级平台指定的ssrc,使用自定义的ssrc,参考国标文档-点播外域设备媒体流SSRC处理方式
+     */
+    private Boolean useCustomSsrcForParentInvite = Boolean.TRUE;
+
+    /**
+     * 开启接口文档页面。 默认开启,生产环境建议关闭,遇到swagger相关的漏洞时也可以关闭
+     */
+    private Boolean docEnable = Boolean.TRUE;
+
+    /**
+     * 服务ID,不写则为000000
+     */
+    private String serverId = "000000";
+
+
+    /**
+     * 国标级联语音喊话发流模式 * UDP:udp传输 TCP-ACTIVE:tcp主动模式 TCP-PASSIVE:tcp被动模式
+     */
+    private String broadcastForPlatform = "UDP";
+
+    /**
+     * 行政区划信息文件,系统启动时会加载到系统里
+     */
+    private String civilCodeFile = "classpath:civilCode.csv";
+
+    /**
+     * 跨域配置,不配置此项则允许所有跨域请求,配置后则只允许配置的页面的地址请求, 可以配置多个
+     */
+    private List<String> allowedOrigins = new ArrayList<>();
+
+    /**
+     * 设置notify缓存队列最大长度,超过此长度的数据将返回486 BUSY_HERE,消息丢弃, 默认100000
+     */
+    private int maxNotifyCountQueue = 100000;
+
+    /**
+     * 国标级联离线后多久重试一次注册
+     */
+    private int registerAgainAfterTime = 60;
+
+    /**
+     * 国标续订方式,true为续订,每次注册在同一个会话里,false为重新注册,每次使用新的会话
+     */
+    private boolean registerKeepIntDialog = false;
+
+    /**
+     *     # 国标设备离线后的上线策略,
+     *     # 0: 国标标准实现,设备离线后不回复心跳,直到设备重新注册上线,
+     *     # 1(默认): 对于离线设备,收到心跳就把设备设置为上线,并更新注册时间为上次这次心跳的时间。防止过期时间判断异常
+     */
+    private int gbDeviceOnline = 1;
+
+    /**
+     *    登录超时时间(分钟),
+     */
+    private long loginTimeout = 30;
+
+    /**
+     * jwk文件路径,若不指定则使用resources目录下的jwk.json
+     */
+    private String jwkFile = "classpath:jwk.json";
+
+}

+ 39 - 0
video-service/src/main/java/com/ruoyi/wvp/conf/VersionConfig.java

@@ -0,0 +1,39 @@
+package com.ruoyi.wvp.conf;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+@Component
+@ConfigurationProperties(prefix = "version")
+@Order(0)
+public class VersionConfig {
+
+    private String version;
+    private String artifactId;
+    private String description;
+
+    public void setVersion(String version) {
+        this.version = version;
+    }
+
+    public void setArtifactId(String artifactId) {
+        this.artifactId = artifactId;
+    }
+
+    public void setDescription(String description) {
+        this.description = description;
+    }
+
+    public String getVersion() {
+        return version;
+    }
+
+    public String getArtifactId() {
+        return artifactId;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+}

+ 27 - 0
video-service/src/main/java/com/ruoyi/wvp/conf/VersionInfo.java

@@ -0,0 +1,27 @@
+package com.ruoyi.wvp.conf;
+
+
+import com.ruoyi.wvp.common.VersionPo;
+import com.ruoyi.wvp.utils.GitUtil;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+@Component
+public class VersionInfo {
+
+    @Autowired
+    GitUtil gitUtil;
+
+    public VersionPo getVersion() {
+        VersionPo versionPo = new VersionPo();
+        versionPo.setGIT_Revision(gitUtil.getGitCommitId());
+        versionPo.setGIT_BRANCH(gitUtil.getBranch());
+        versionPo.setGIT_URL(gitUtil.getGitUrl());
+        versionPo.setBUILD_DATE(gitUtil.getBuildDate());
+        versionPo.setGIT_Revision_SHORT(gitUtil.getCommitIdShort());
+        versionPo.setVersion(gitUtil.getBuildVersion());
+        versionPo.setGIT_DATE(gitUtil.getCommitTime());
+
+        return versionPo;
+    }
+}

+ 29 - 0
video-service/src/main/java/com/ruoyi/wvp/conf/WVPTimerTask.java

@@ -0,0 +1,29 @@
+package com.ruoyi.wvp.conf;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.wvp.storager.IRedisCatchStorage;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+@Component
+public class WVPTimerTask {
+
+    @Autowired
+    private IRedisCatchStorage redisCatchStorage;
+
+    @Value("${server.port}")
+    private int serverPort;
+
+    @Autowired
+    private SipConfig sipConfig;
+
+    @Scheduled(fixedDelay = 2 * 1000)   //每3秒执行一次
+    public void execute(){
+        JSONObject jsonObject = new JSONObject();
+        jsonObject.put("ip", sipConfig.getShowIp());
+        jsonObject.put("port", serverPort);
+        redisCatchStorage.updateWVPInfo(jsonObject, 3);
+    }
+}

+ 24 - 0
video-service/src/main/java/com/ruoyi/wvp/conf/redis/bean/RedisRpcMessage.java

@@ -0,0 +1,24 @@
+package com.ruoyi.wvp.conf.redis.bean;
+
+public class RedisRpcMessage {
+
+    private RedisRpcRequest request;
+
+    private RedisRpcResponse response;
+
+    public RedisRpcRequest getRequest() {
+        return request;
+    }
+
+    public void setRequest(RedisRpcRequest request) {
+        this.request = request;
+    }
+
+    public RedisRpcResponse getResponse() {
+        return response;
+    }
+
+    public void setResponse(RedisRpcResponse response) {
+        this.response = response;
+    }
+}

+ 93 - 0
video-service/src/main/java/com/ruoyi/wvp/conf/redis/bean/RedisRpcRequest.java

@@ -0,0 +1,93 @@
+package com.ruoyi.wvp.conf.redis.bean;
+
+/**
+ * 通过redis发送请求
+ */
+public class RedisRpcRequest {
+
+    /**
+     * 来自的WVP ID
+     */
+    private String fromId;
+
+
+    /**
+     * 目标的WVP ID
+     */
+    private String toId;
+
+    /**
+     * 序列号
+     */
+    private long sn;
+
+    /**
+     * 访问的路径
+     */
+    private String uri;
+
+    /**
+     * 参数
+     */
+    private Object param;
+
+    public String getFromId() {
+        return fromId;
+    }
+
+    public void setFromId(String fromId) {
+        this.fromId = fromId;
+    }
+
+    public String getToId() {
+        return toId;
+    }
+
+    public void setToId(String toId) {
+        this.toId = toId;
+    }
+
+    public String getUri() {
+        return uri;
+    }
+
+    public void setUri(String uri) {
+        this.uri = uri;
+    }
+
+    public Object getParam() {
+        return param;
+    }
+
+    public void setParam(Object param) {
+        this.param = param;
+    }
+
+    public long getSn() {
+        return sn;
+    }
+
+    public void setSn(long sn) {
+        this.sn = sn;
+    }
+
+    @Override
+    public String toString() {
+        return "RedisRpcRequest{" +
+                "uri='" + uri + '\'' +
+                ", fromId='" + fromId + '\'' +
+                ", toId='" + toId + '\'' +
+                ", sn=" + sn +
+                ", param=" + param +
+                '}';
+    }
+
+    public RedisRpcResponse getResponse() {
+        RedisRpcResponse response = new RedisRpcResponse();
+        response.setFromId(fromId);
+        response.setToId(toId);
+        response.setSn(sn);
+        response.setUri(uri);
+        return response;
+    }
+}

+ 99 - 0
video-service/src/main/java/com/ruoyi/wvp/conf/redis/bean/RedisRpcResponse.java

@@ -0,0 +1,99 @@
+package com.ruoyi.wvp.conf.redis.bean;
+
+/**
+ * 通过redis发送回复
+ */
+public class RedisRpcResponse {
+
+    /**
+     * 来自的WVP ID
+     */
+    private String fromId;
+
+
+    /**
+     * 目标的WVP ID
+     */
+    private String toId;
+
+
+    /**
+     * 序列号
+     */
+    private long sn;
+
+    /**
+     * 状态码
+     */
+    private int statusCode;
+
+    /**
+     * 访问的路径
+     */
+    private String uri;
+
+    /**
+     * 参数
+     */
+    private Object body;
+
+    public String getFromId() {
+        return fromId;
+    }
+
+    public void setFromId(String fromId) {
+        this.fromId = fromId;
+    }
+
+    public String getToId() {
+        return toId;
+    }
+
+    public void setToId(String toId) {
+        this.toId = toId;
+    }
+
+    public long getSn() {
+        return sn;
+    }
+
+    public void setSn(long sn) {
+        this.sn = sn;
+    }
+
+    public int getStatusCode() {
+        return statusCode;
+    }
+
+    public void setStatusCode(int statusCode) {
+        this.statusCode = statusCode;
+    }
+
+    public String getUri() {
+        return uri;
+    }
+
+    public void setUri(String uri) {
+        this.uri = uri;
+    }
+
+    public Object getBody() {
+        return body;
+    }
+
+    public void setBody(Object body) {
+        this.body = body;
+    }
+
+    @Override
+    public String toString() {
+        return "RedisRpcResponse{" +
+                "uri='" + uri + '\'' +
+                ", fromId='" + fromId + '\'' +
+                ", toId='" + toId + '\'' +
+                ", sn=" + sn +
+                ", statusCode=" + statusCode +
+                ", body=" + body +
+                '}';
+    }
+}

+ 244 - 0
video-service/src/main/java/com/ruoyi/wvp/controller/GBRecordController.java

@@ -0,0 +1,244 @@
+package com.ruoyi.wvp.controller;
+
+import com.zksy.common.core.controller.BaseController;
+import com.zksy.common.core.domain.AjaxResult;
+import com.ruoyi.wvp.common.StreamInfo;
+import com.ruoyi.wvp.conf.UserSetting;
+import com.zksy.common.exception.ControllerException;
+import com.zksy.common.exception.SsrcTransactionNotFoundException;
+import com.ruoyi.wvp.gb28181.bean.Device;
+import com.ruoyi.wvp.gb28181.bean.DeviceChannel;
+import com.ruoyi.wvp.gb28181.service.IDeviceChannelService;
+import com.ruoyi.wvp.gb28181.service.IDeviceService;
+import com.ruoyi.wvp.gb28181.service.IPlayService;
+import com.ruoyi.wvp.gb28181.transmit.callback.DeferredResultHolder;
+import com.ruoyi.wvp.gb28181.transmit.callback.RequestMessage;
+import com.ruoyi.wvp.gb28181.transmit.cmd.impl.SIPCommander;
+import com.ruoyi.wvp.media.bean.RecordInfo;
+import com.ruoyi.wvp.service.bean.InviteErrorCode;
+import com.ruoyi.wvp.utils.DateUtil;
+import com.zksy.common.enums.ErrorCode;
+import com.ruoyi.wvp.vmanager.bean.StreamContent;
+import com.ruoyi.wvp.vmanager.bean.WVPResult;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.context.request.async.DeferredResult;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.sip.InvalidArgumentException;
+import javax.sip.SipException;
+import java.text.ParseException;
+import java.util.UUID;
+
+/**
+ * 国标录像
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/gb_record")
+public class GBRecordController extends BaseController {
+
+	@Autowired
+	private SIPCommander cmder;
+
+	@Autowired
+	private DeferredResultHolder resultHolder;
+
+	@Autowired
+	private IPlayService playService;
+
+	@Autowired
+	private IDeviceChannelService channelService;
+
+	@Autowired
+	private IDeviceService deviceService;
+
+	@Autowired
+	private UserSetting userSetting;
+
+	/**
+	 * 录像信息查询
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param startTime 开始时间
+	 * @param endTime 结束时间
+	 * @return
+	 */
+
+	@GetMapping("/query")
+	public DeferredResult<WVPResult<RecordInfo>> recordinfo( String deviceId, String channelId, String startTime, String endTime, String type){
+
+		if (log.isDebugEnabled()) {
+			log.debug(String.format("录像信息查询 API调用,deviceId:%s ,startTime:%s, endTime:%s",deviceId, startTime, endTime));
+		}
+		DeferredResult<WVPResult<RecordInfo>> result = new DeferredResult<>();
+		if (!DateUtil.verification(startTime, DateUtil.formatter)){
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "startTime格式为" + DateUtil.PATTERN);
+		}
+		if (!DateUtil.verification(endTime, DateUtil.formatter)){
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "endTime格式为" + DateUtil.PATTERN);
+		}
+
+		Device device = deviceService.getDeviceByDeviceId(deviceId);
+		// 指定超时时间 1分钟30秒
+		String uuid = UUID.randomUUID().toString();
+		int sn  =  (int)((Math.random()*9+1)*100000);
+		String key = DeferredResultHolder.CALLBACK_CMD_RECORDINFO + deviceId + sn;
+		RequestMessage msg = new RequestMessage();
+		msg.setId(uuid);
+		msg.setKey(key);
+		try {
+			cmder.recordInfoQuery(device, channelId, startTime, endTime, sn, null, type, null, (eventResult -> {
+				WVPResult<RecordInfo> wvpResult = new WVPResult<>();
+				wvpResult.setCode(ErrorCode.ERROR100.getCode());
+				wvpResult.setMsg("查询录像失败, status: " +  eventResult.statusCode + ", message: " + eventResult.msg);
+				msg.setData(wvpResult);
+				resultHolder.invokeResult(msg);
+			}));
+		} catch (InvalidArgumentException | SipException | ParseException e) {
+			log.error("[命令发送失败] 查询录像: {}", e.getMessage());
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "命令发送失败: " +  e.getMessage());
+		}
+
+		// 录像查询以channelId作为deviceId查询
+		resultHolder.put(key, uuid, result);
+		result.onTimeout(()->{
+			msg.setData("timeout");
+			WVPResult<RecordInfo> wvpResult = new WVPResult<>();
+			wvpResult.setCode(ErrorCode.ERROR100.getCode());
+			wvpResult.setMsg("timeout");
+			msg.setData(wvpResult);
+			resultHolder.invokeResult(msg);
+		});
+        return result;
+	}
+
+
+	/**
+	 * 录像下载
+	 *
+	 * @param request
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param startTime 开始时间
+	 * @param endTime 结束时间
+	 * @param downloadSpeed 下载倍速
+	 * @return
+	 */
+
+	@GetMapping("/download/start")
+	public DeferredResult<WVPResult<StreamContent>> download(HttpServletRequest request,  String deviceId, String channelId,
+															 String startTime, String endTime, String downloadSpeed) {
+
+		if (log.isDebugEnabled()) {
+			log.debug(String.format("历史媒体下载 API调用,deviceId:%s,channelId:%s,downloadSpeed:%s", deviceId, channelId, downloadSpeed));
+		}
+
+		String uuid = UUID.randomUUID().toString();
+		String key = DeferredResultHolder.CALLBACK_CMD_DOWNLOAD + deviceId + channelId;
+		DeferredResult<WVPResult<StreamContent>> result = new DeferredResult<>(30000L);
+		resultHolder.put(key, uuid, result);
+		RequestMessage requestMessage = new RequestMessage();
+		requestMessage.setId(uuid);
+		requestMessage.setKey(key);
+
+		Device device = deviceService.getDeviceByDeviceId(deviceId);
+		if (device == null) {
+			log.warn("[开始历史媒体下载] 未找到设备 deviceId: {},channelId:{}", deviceId, channelId);
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "未找到设备:" + deviceId);
+		}
+
+		DeviceChannel channel = channelService.getOne(deviceId, channelId);
+		if (channel == null) {
+			log.warn("[开始历史媒体下载] 未找到通道 deviceId: {},channelId:{}", deviceId, channelId);
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "未找到通道:" + channelId);
+		}
+		playService.download(device, channel, startTime, endTime, Integer.parseInt(downloadSpeed),
+		(code, msg, data)->{
+
+			WVPResult<StreamContent> wvpResult = new WVPResult<>();
+			if (code == InviteErrorCode.SUCCESS.getCode()) {
+				wvpResult.setCode(ErrorCode.SUCCESS.getCode());
+				wvpResult.setMsg(ErrorCode.SUCCESS.getMsg());
+
+				if (data != null) {
+					StreamInfo streamInfo = (StreamInfo)data;
+					if (userSetting.getUseSourceIpAsStreamIp()) {
+						streamInfo.channgeStreamIp(request.getLocalAddr());
+					}
+					wvpResult.setData(new StreamContent(streamInfo));
+				}
+			}else {
+				wvpResult.setCode(code);
+				wvpResult.setMsg(msg);
+			}
+			requestMessage.setData(wvpResult);
+			resultHolder.invokeResult(requestMessage);
+		});
+
+		return result;
+	}
+
+	/**
+	 * 停止录像下载
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param stream 流ID
+	 */
+	@GetMapping("/download/stop/{deviceId}/{channelId}/{stream}")
+	public void playStop(@PathVariable String deviceId, @PathVariable String channelId, @PathVariable String stream) {
+
+		if (log.isDebugEnabled()) {
+			log.debug(String.format("设备历史媒体下载停止 API调用,deviceId/channelId:%s_%s", deviceId, channelId));
+		}
+
+		if (deviceId == null || channelId == null) {
+			throw new ControllerException(ErrorCode.ERROR400);
+		}
+
+		Device device = deviceService.getDeviceByDeviceId(deviceId);
+		if (device == null) {
+			throw new ControllerException(ErrorCode.ERROR400.getCode(), "设备:" + deviceId + "未找到");
+		}
+
+		try {
+			cmder.streamByeCmd(device, channelId, "rtp", stream, null, null);
+		} catch (InvalidArgumentException | ParseException | SipException | SsrcTransactionNotFoundException e) {
+			log.warn("[停止历史媒体下载]停止历史媒体下载,发送BYE失败 {}", e.getMessage());
+		}
+	}
+
+	/**
+	 * 获取历史媒体下载进度
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param stream 流ID
+	 * @return
+	 */
+	@GetMapping("/download/progress/{deviceId}/{channelId}/{stream}")
+	public AjaxResult getProgress(@PathVariable String deviceId, @PathVariable String channelId, @PathVariable String stream) {
+		Device device = deviceService.getDeviceByDeviceId(deviceId);
+		if (device == null) {
+			log.warn("[获取历史媒体下载进度] 未找到设备 deviceId: {},channelId:{}", deviceId, channelId);
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "未找到设备:" + deviceId);
+		}
+
+		DeviceChannel channel = channelService.getOne(deviceId, channelId);
+		if (channel == null) {
+			log.warn("[获取历史媒体下载进度] 未找到通道 deviceId: {},channelId:{}", deviceId, channelId);
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "未找到通道:" + channelId);
+		}
+		StreamInfo downLoadInfo = playService.getDownLoadInfo(device, channel, stream);
+		if (downLoadInfo == null) {
+			throw new ControllerException(ErrorCode.ERROR404);
+		}
+		return success(new StreamContent(downLoadInfo));
+	}
+}

+ 225 - 0
video-service/src/main/java/com/ruoyi/wvp/controller/PlayController.java

@@ -0,0 +1,225 @@
+package com.ruoyi.wvp.controller;
+
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.zksy.common.core.controller.BaseController;
+import com.zksy.common.core.domain.AjaxResult;
+import com.ruoyi.wvp.common.InviteSessionType;
+import com.ruoyi.wvp.common.StreamInfo;
+import com.ruoyi.wvp.conf.UserSetting;
+import com.zksy.common.exception.ControllerException;
+import com.ruoyi.wvp.gb28181.bean.Device;
+import com.ruoyi.wvp.gb28181.bean.DeviceChannel;
+import com.ruoyi.wvp.gb28181.bean.SsrcTransaction;
+import com.ruoyi.wvp.gb28181.service.IDeviceChannelService;
+import com.ruoyi.wvp.gb28181.service.IDeviceService;
+import com.ruoyi.wvp.gb28181.service.IInviteStreamService;
+import com.ruoyi.wvp.gb28181.service.IPlayService;
+import com.ruoyi.wvp.gb28181.session.SipInviteSessionManager;
+import com.ruoyi.wvp.gb28181.transmit.callback.DeferredResultHolder;
+import com.ruoyi.wvp.gb28181.transmit.callback.RequestMessage;
+import com.ruoyi.wvp.media.bean.MediaServer;
+import com.ruoyi.wvp.media.service.IMediaServerService;
+import com.ruoyi.wvp.service.bean.InviteErrorCode;
+import com.ruoyi.wvp.utils.DateUtil;
+import com.zksy.common.enums.ErrorCode;
+import com.ruoyi.wvp.vmanager.bean.StreamContent;
+import com.ruoyi.wvp.vmanager.bean.WVPResult;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.util.Assert;
+import org.springframework.util.ObjectUtils;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.context.request.async.DeferredResult;
+
+import javax.servlet.http.HttpServletRequest;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.List;
+import java.util.UUID;
+
+
+/**
+ * 国标设备点播
+ *
+ * @author lin
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/play")
+public class PlayController extends BaseController {
+
+    @Autowired
+    private SipInviteSessionManager sessionManager;
+
+    @Autowired
+    private IInviteStreamService inviteStreamService;
+
+    @Autowired
+    private DeferredResultHolder resultHolder;
+
+    @Autowired
+    private IPlayService playService;
+
+    @Autowired
+    private IMediaServerService mediaServerService;
+
+    @Autowired
+    private UserSetting userSetting;
+
+    @Autowired
+    private IDeviceService deviceService;
+
+    @Autowired
+    private IDeviceChannelService deviceChannelService;
+
+    /**
+     * 播放视频
+     *
+     * @param request
+     * @param deviceId 设备国标编号
+     * @param channelId 通道国标编号
+     * @return
+     */
+
+    @GetMapping("/start/{deviceId}/{channelId}")
+    public DeferredResult<WVPResult<StreamContent>> play(HttpServletRequest request, @PathVariable String deviceId, @PathVariable String channelId) {
+        log.info("[开始点播] deviceId:{}, channelId:{}, ", deviceId, channelId);
+        Assert.notNull(deviceId, "设备国标编号不可为NULL");
+        Assert.notNull(channelId, "通道国标编号不可为NULL");
+        // 获取可用的zlm
+        Device device = deviceService.getDeviceByDeviceId(deviceId);
+        Assert.notNull(device, "设备不存在");
+        DeviceChannel channel = deviceChannelService.getOne(deviceId, channelId);
+        Assert.notNull(channel, "通道不存在");
+        MediaServer newMediaServerItem = playService.getNewMediaServerItem(device);
+
+        RequestMessage requestMessage = new RequestMessage();
+        String key = DeferredResultHolder.CALLBACK_CMD_PLAY + deviceId + channelId;
+        requestMessage.setKey(key);
+        String uuid = UUID.randomUUID().toString();
+        requestMessage.setId(uuid);
+        DeferredResult<WVPResult<StreamContent>> result = new DeferredResult<>(userSetting.getPlayTimeout().longValue());
+
+        result.onTimeout(() -> {
+            log.info("[点播等待超时] deviceId:{}, channelId:{}, ", deviceId, channelId);
+            // 释放rtpserver
+            WVPResult<StreamInfo> wvpResult = new WVPResult<>();
+            wvpResult.setCode(ErrorCode.ERROR100.getCode());
+            wvpResult.setMsg("点播超时");
+            requestMessage.setData(wvpResult);
+            resultHolder.invokeAllResult(requestMessage);
+            inviteStreamService.removeInviteInfoByDeviceAndChannel(InviteSessionType.PLAY, channel.getId());
+            deviceChannelService.stopPlay(channel.getId());
+        });
+
+        // 录像查询以channelId作为deviceId查询
+        resultHolder.put(key, uuid, result);
+
+        playService.play(newMediaServerItem, deviceId, channelId, null, (code, msg, streamInfo) -> {
+            WVPResult<StreamContent> wvpResult = new WVPResult<>();
+            if (code == InviteErrorCode.SUCCESS.getCode()) {
+                wvpResult.setCode(ErrorCode.SUCCESS.getCode());
+                wvpResult.setMsg(ErrorCode.SUCCESS.getMsg());
+
+                if (streamInfo != null) {
+                    if (userSetting.getUseSourceIpAsStreamIp()) {
+                        streamInfo = streamInfo.clone();//深拷贝
+                        String host;
+                        try {
+                            URL url = new URL(request.getRequestURL().toString());
+                            host = url.getHost();
+                        } catch (MalformedURLException e) {
+                            host = request.getLocalAddr();
+                        }
+                        streamInfo.channgeStreamIp(host);
+                    }
+                    if (!ObjectUtils.isEmpty(newMediaServerItem.getTranscodeSuffix()) && !"null".equalsIgnoreCase(newMediaServerItem.getTranscodeSuffix())) {
+                        streamInfo.setStream(streamInfo.getStream() + "_" + newMediaServerItem.getTranscodeSuffix());
+                    }
+                    wvpResult.setData(new StreamContent(streamInfo));
+                } else {
+                    wvpResult.setCode(code);
+                    wvpResult.setMsg(msg);
+                }
+            } else {
+                wvpResult.setCode(code);
+                wvpResult.setMsg(msg);
+            }
+            requestMessage.setData(wvpResult);
+            // 此处必须释放所有请求
+            resultHolder.invokeAllResult(requestMessage);
+        });
+        return result;
+    }
+
+    /**
+     * 停止点播
+     *
+     * @param deviceId  设备国标编号
+     * @param channelId 通道国标编号
+     * @return
+     */
+
+    @GetMapping("/stop/{deviceId}/{channelId}")
+    public AjaxResult playStop(@PathVariable String deviceId, @PathVariable String channelId) {
+        log.debug(String.format("设备预览/回放停止API调用,streamId:%s_%s", deviceId, channelId));
+        if (deviceId == null || channelId == null) {
+            throw new ControllerException(ErrorCode.ERROR400);
+        }
+
+        Device device = deviceService.getDeviceByDeviceId(deviceId);
+        DeviceChannel channel = deviceChannelService.getOneForSource(deviceId, channelId);
+        Assert.notNull(device, "设备不存在");
+        Assert.notNull(channel, "通道不存在");
+        String streamId = String.format("%s_%s", device.getDeviceId(), channel.getDeviceId());
+        playService.stop(InviteSessionType.PLAY, device, channel, streamId);
+        return success();
+    }
+
+    /**
+     * 结束转码
+     *
+     * @param key 视频流key
+     * @param mediaServerId 流媒体服务ID
+     */
+    @PostMapping("/convertStop/{key}")
+    public void playConvertStop(@PathVariable String key, String mediaServerId) {
+        if (mediaServerId == null) {
+            throw new ControllerException(ErrorCode.ERROR400.getCode(), "流媒体:" + mediaServerId + "不存在");
+        }
+        MediaServer mediaInfo = mediaServerService.getOne(mediaServerId);
+        if (mediaInfo == null) {
+            throw new ControllerException(ErrorCode.ERROR100.getCode(), "使用的流媒体已经停止运行");
+        } else {
+            Boolean deleted = mediaServerService.delFFmpegSource(mediaInfo, key);
+            if (!deleted) {
+                throw new ControllerException(ErrorCode.ERROR100);
+            }
+        }
+    }
+
+    @GetMapping("/ssrc")
+    public JSONObject getSSRC() {
+        if (log.isDebugEnabled()) {
+            log.debug("获取所有的ssrc");
+        }
+        JSONArray objects = new JSONArray();
+        List<SsrcTransaction> allSsrc = sessionManager.getAll();
+        for (SsrcTransaction transaction : allSsrc) {
+            JSONObject jsonObject = new JSONObject();
+            jsonObject.put("deviceId", transaction.getDeviceId());
+            jsonObject.put("channelId", transaction.getChannelId());
+            jsonObject.put("ssrc", transaction.getSsrc());
+            jsonObject.put("streamId", transaction.getStream());
+            objects.add(jsonObject);
+        }
+
+        JSONObject jsonObject = new JSONObject();
+        jsonObject.put("data", objects);
+        jsonObject.put("count", objects.size());
+        return jsonObject;
+    }
+
+}
+

+ 259 - 0
video-service/src/main/java/com/ruoyi/wvp/controller/PlaybackController.java

@@ -0,0 +1,259 @@
+package com.ruoyi.wvp.controller;
+
+import com.zksy.common.core.controller.BaseController;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.exception.ServiceException;
+import com.ruoyi.wvp.common.InviteInfo;
+import com.ruoyi.wvp.common.InviteSessionType;
+import com.ruoyi.wvp.common.StreamInfo;
+import com.ruoyi.wvp.conf.UserSetting;
+import com.zksy.common.exception.ControllerException;
+import com.ruoyi.wvp.gb28181.bean.Device;
+import com.ruoyi.wvp.gb28181.bean.DeviceChannel;
+import com.ruoyi.wvp.gb28181.service.IDeviceChannelService;
+import com.ruoyi.wvp.gb28181.service.IDeviceService;
+import com.ruoyi.wvp.gb28181.service.IInviteStreamService;
+import com.ruoyi.wvp.gb28181.service.IPlayService;
+import com.ruoyi.wvp.gb28181.transmit.callback.DeferredResultHolder;
+import com.ruoyi.wvp.gb28181.transmit.callback.RequestMessage;
+import com.ruoyi.wvp.gb28181.transmit.cmd.impl.SIPCommander;
+import com.ruoyi.wvp.service.bean.InviteErrorCode;
+import com.zksy.common.enums.ErrorCode;
+import com.ruoyi.wvp.vmanager.bean.StreamContent;
+import com.ruoyi.wvp.vmanager.bean.WVPResult;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.util.ObjectUtils;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.context.request.async.DeferredResult;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.sip.InvalidArgumentException;
+import javax.sip.SipException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.text.ParseException;
+import java.util.UUID;
+
+/**
+ * 视频回放
+ *
+ * @author lin
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/playback")
+public class PlaybackController extends BaseController {
+
+	@Autowired
+	private SIPCommander cmder;
+
+	@Autowired
+	private IInviteStreamService inviteStreamService;
+
+	@Autowired
+	private IPlayService playService;
+
+	@Autowired
+	private DeferredResultHolder resultHolder;
+
+	@Autowired
+	private UserSetting userSetting;
+
+	@Autowired
+	private IDeviceService deviceService;
+
+	@Autowired
+	private IDeviceChannelService channelService;
+
+	/**
+	 * 开始回放
+	 *
+	 * @param request
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param startTime 开始时间
+	 * @param endTime 结束时间
+	 * @return
+	 */
+
+	@GetMapping("/start")
+	public DeferredResult<WVPResult<StreamContent>> start(HttpServletRequest request, String deviceId,  String channelId,
+														  String startTime, String endTime) {
+
+		if (log.isDebugEnabled()) {
+			log.debug(String.format("设备回放 API调用,deviceId:%s ,channelId:%s", deviceId, channelId));
+		}
+
+		String uuid = UUID.randomUUID().toString();
+		String key = DeferredResultHolder.CALLBACK_CMD_PLAYBACK + deviceId + channelId;
+		DeferredResult<WVPResult<StreamContent>> result = new DeferredResult<>(userSetting.getPlayTimeout().longValue());
+		resultHolder.put(key, uuid, result);
+
+		RequestMessage requestMessage = new RequestMessage();
+		requestMessage.setKey(key);
+		requestMessage.setId(uuid);
+		Device device = deviceService.getDeviceByDeviceId(deviceId);
+		if (device == null) {
+			log.warn("[录像回放] 未找到设备 deviceId: {},channelId:{}", deviceId, channelId);
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "未找到设备:" + deviceId);
+		}
+
+		DeviceChannel channel = channelService.getOne(deviceId, channelId);
+		if (channel == null) {
+			log.warn("[录像回放] 未找到通道 deviceId: {},channelId:{}", deviceId, channelId);
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "未找到通道:" + channelId);
+		}
+		playService.playBack(device, channel, startTime, endTime,
+				(code, msg, data)->{
+
+					WVPResult<StreamContent> wvpResult = new WVPResult<>();
+					if (code == InviteErrorCode.SUCCESS.getCode()) {
+						wvpResult.setCode(ErrorCode.SUCCESS.getCode());
+						wvpResult.setMsg(ErrorCode.SUCCESS.getMsg());
+
+						if (data != null) {
+							StreamInfo streamInfo = (StreamInfo)data;
+							if (userSetting.getUseSourceIpAsStreamIp()) {
+								streamInfo=streamInfo.clone();//深拷贝
+								String host;
+								try {
+									URL url=new URL(request.getRequestURL().toString());
+									host=url.getHost();
+								} catch (MalformedURLException e) {
+									host=request.getLocalAddr();
+								}
+								streamInfo.channgeStreamIp(host);
+							}
+							wvpResult.setData(new StreamContent(streamInfo));
+						}
+					}else {
+						wvpResult.setCode(code);
+						wvpResult.setMsg(msg);
+					}
+					requestMessage.setData(wvpResult);
+					resultHolder.invokeResult(requestMessage);
+				});
+
+		return result;
+	}
+
+
+	/**
+	 * 停止回放
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param stream 流ID
+	 */
+	@GetMapping("/stop/{deviceId}/{channelId}/{stream}")
+	public AjaxResult playStop(
+			@PathVariable String deviceId,
+			@PathVariable String channelId,
+			@PathVariable String stream) {
+		if (ObjectUtils.isEmpty(deviceId) || ObjectUtils.isEmpty(channelId) || ObjectUtils.isEmpty(stream)) {
+			throw new ControllerException(ErrorCode.ERROR400);
+		}
+		Device device = deviceService.getDeviceByDeviceId(deviceId);
+		if (device == null) {
+			throw new ControllerException(ErrorCode.ERROR400.getCode(), "设备:" + deviceId + " 未找到");
+		}
+		DeviceChannel deviceChannel = channelService.getOneForSource(deviceId, channelId);
+		if (deviceChannel == null) {
+			throw new ControllerException(ErrorCode.ERROR400.getCode(), "通道:" + deviceChannel + " 未找到");
+		}
+		playService.stop(InviteSessionType.PLAYBACK, device, deviceChannel, stream);
+		return success();
+	}
+
+
+	/**
+	 * 暂停回放
+	 *
+	 * @param streamId 回放流ID
+	 */
+	@GetMapping("/pause/{streamId}")
+	public void playPause(@PathVariable String streamId) {
+		log.info("playPause: "+streamId);
+
+		try {
+			playService.pauseRtp(streamId);
+		} catch (ServiceException e) {
+			throw new ControllerException(ErrorCode.ERROR400.getCode(), e.getMessage());
+		} catch (InvalidArgumentException | ParseException | SipException e) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), e.getMessage());
+		}
+	}
+
+	/**
+	 * 恢复回放
+	 *
+	 * @param streamId 回放流ID
+	 */
+	@GetMapping("/resume/{streamId}")
+	public void playResume(@PathVariable String streamId) {
+		log.info("playResume: "+streamId);
+		try {
+			playService.resumeRtp(streamId);
+		} catch (ServiceException e) {
+			throw new ControllerException(ErrorCode.ERROR400.getCode(), e.getMessage());
+		} catch (InvalidArgumentException | ParseException | SipException e) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), e.getMessage());
+		}
+	}
+
+	/**
+	 * 拖动回放
+	 *
+	 * @param streamId 回放流ID
+	 * @param seekTime 拖动偏移量,单位s
+	 */
+	@GetMapping("/seek/{streamId}/{seekTime}")
+	public void playSeek(@PathVariable String streamId, @PathVariable long seekTime) {
+		log.info("playSeek: "+streamId+", "+seekTime);
+		InviteInfo inviteInfo = inviteStreamService.getInviteInfoByStream(InviteSessionType.PLAYBACK, streamId);
+
+		if (null == inviteInfo || inviteInfo.getStreamInfo() == null) {
+			log.warn("streamId不存在!");
+			throw new ControllerException(ErrorCode.ERROR400.getCode(), "streamId不存在");
+		}
+		Device device = deviceService.getDeviceByDeviceId(inviteInfo.getDeviceId());
+		DeviceChannel channel = channelService.getOneById(inviteInfo.getChannelId());
+		try {
+			cmder.playSeekCmd(device, channel, inviteInfo.getStreamInfo(), seekTime);
+		} catch (InvalidArgumentException | ParseException | SipException e) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), e.getMessage());
+		}
+	}
+
+	/**
+	 * 回放倍速
+	 *
+	 * @param streamId 回放流ID
+	 * @param speed 倍速0.25 0.5 1、2、4
+	 */
+	@GetMapping("/speed/{streamId}/{speed}")
+	public void playSpeed(@PathVariable String streamId, @PathVariable Double speed) {
+		log.info("playSpeed: "+streamId+", "+speed);
+		InviteInfo inviteInfo = inviteStreamService.getInviteInfoByStream(InviteSessionType.PLAYBACK, streamId);
+
+		if (null == inviteInfo || inviteInfo.getStreamInfo() == null) {
+			log.warn("streamId不存在!");
+			throw new ControllerException(ErrorCode.ERROR400.getCode(), "streamId不存在");
+		}
+		if(speed != 0.25 && speed != 0.5 && speed != 1 && speed != 2.0 && speed != 4.0) {
+			log.warn("不支持的speed: " + speed);
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "不支持的speed(0.25 0.5 1、2、4)");
+		}
+		Device device = deviceService.getDeviceByDeviceId(inviteInfo.getDeviceId());
+		DeviceChannel channel = channelService.getOneById(inviteInfo.getChannelId());
+		try {
+			cmder.playSpeedCmd(device, channel, inviteInfo.getStreamInfo(), speed);
+		} catch (InvalidArgumentException | ParseException | SipException e) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), e.getMessage());
+		}
+	}
+}

+ 566 - 0
video-service/src/main/java/com/ruoyi/wvp/controller/PtzController.java

@@ -0,0 +1,566 @@
+package com.ruoyi.wvp.controller;
+
+import com.zksy.common.core.controller.BaseController;
+import com.zksy.common.exception.ControllerException;
+import com.ruoyi.wvp.gb28181.bean.Device;
+import com.ruoyi.wvp.gb28181.service.IDeviceService;
+import com.ruoyi.wvp.gb28181.service.IPTZService;
+import com.ruoyi.wvp.gb28181.transmit.callback.DeferredResultHolder;
+import com.ruoyi.wvp.gb28181.transmit.callback.RequestMessage;
+import com.ruoyi.wvp.gb28181.transmit.cmd.impl.SIPCommander;
+import com.zksy.common.enums.ErrorCode;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.util.Assert;
+import org.springframework.util.ObjectUtils;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.context.request.async.DeferredResult;
+
+import javax.sip.InvalidArgumentException;
+import javax.sip.SipException;
+import java.text.ParseException;
+import java.util.UUID;
+
+/**
+ * 前端设备控制
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/front-end")
+public class PtzController extends BaseController {
+
+	@Autowired
+	private SIPCommander cmder;
+
+	@Autowired
+	private IDeviceService deviceService;
+
+	@Autowired
+	private IPTZService ptzService;
+
+	@Autowired
+	private DeferredResultHolder resultHolder;
+
+	/**
+	 * 云台控制
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param cmdCode 指令码(对应国标文档指令格式中的字节4)
+	 * @param parameter1 数据一(对应国标文档指令格式中的字节5, 范围0-255)
+	 * @param parameter2 数据二(对应国标文档指令格式中的字节6, 范围0-255)
+	 * @param combindCode2 组合码二(对应国标文档指令格式中的字节7, 范围0-15)
+	 */
+	@GetMapping("/common/{deviceId}/{channelId}")
+	public void frontEndCommand(@PathVariable String deviceId,@PathVariable String channelId,Integer cmdCode, Integer parameter1, Integer parameter2, Integer combindCode2){
+
+		if (log.isDebugEnabled()) {
+			log.debug(String.format("设备云台控制 API调用,deviceId:%s ,channelId:%s ,cmdCode:%d parameter1:%d parameter2:%d",deviceId, channelId, cmdCode, parameter1, parameter2));
+		}
+
+		if (parameter1 == null || parameter1 < 0 || parameter1 > 255) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "parameter1 为 0-255的数字");
+		}
+		if (parameter2 == null || parameter2 < 0 || parameter2 > 255) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "parameter2 为 0-255的数字");
+		}
+		if (combindCode2 == null || combindCode2 < 0 || combindCode2 > 15) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "combindCode2 为 0-15的数字");
+		}
+
+		Device device = deviceService.getDeviceByDeviceId(deviceId);
+
+		Assert.notNull(device, "设备[" + deviceId + "]不存在");
+
+		ptzService.frontEndCommand(device, channelId, cmdCode, parameter1, parameter2, combindCode2);
+	}
+
+	/**
+	 * 云台控制
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param command 控制指令,允许值: left, right, up, down, upleft, upright, downleft, downright, zoomin, zoomout, stop
+	 * @param horizonSpeed 水平速度(0-255)
+	 * @param verticalSpeed 垂直速度(0-255)
+	 * @param zoomSpeed 缩放速度(0-15)
+	 */
+	@GetMapping("/ptz/{deviceId}/{channelId}")
+	public void ptz(@PathVariable String deviceId,@PathVariable String channelId, String command, Integer horizonSpeed, Integer verticalSpeed, Integer zoomSpeed){
+
+		if (log.isDebugEnabled()) {
+			log.debug(String.format("设备云台控制 API调用,deviceId:%s ,channelId:%s ,command:%s ,horizonSpeed:%d ,verticalSpeed:%d ,zoomSpeed:%d",deviceId, channelId, command, horizonSpeed, verticalSpeed, zoomSpeed));
+		}
+		if (horizonSpeed == null) {
+			horizonSpeed = 100;
+		}else if (horizonSpeed < 0 || horizonSpeed > 255) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "horizonSpeed 为 0-255的数字");
+		}
+		if (verticalSpeed == null) {
+			verticalSpeed = 100;
+		}else if (verticalSpeed < 0 || verticalSpeed > 255) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "verticalSpeed 为 0-255的数字");
+		}
+		if (zoomSpeed == null) {
+			zoomSpeed = 16;
+		}else if (zoomSpeed < 0 || zoomSpeed > 15) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "zoomSpeed 为 0-15的数字");
+		}
+
+		int cmdCode = 0;
+		switch (command){
+			case "left":
+				cmdCode = 2;
+				break;
+			case "right":
+				cmdCode = 1;
+				break;
+			case "up":
+				cmdCode = 8;
+				break;
+			case "down":
+				cmdCode = 4;
+				break;
+			case "upleft":
+				cmdCode = 10;
+				break;
+			case "upright":
+				cmdCode = 9;
+				break;
+			case "downleft":
+				cmdCode = 6;
+				break;
+			case "downright":
+				cmdCode = 5;
+				break;
+			case "zoomin":
+				cmdCode = 16;
+				break;
+			case "zoomout":
+				cmdCode = 32;
+				break;
+			case "stop":
+				horizonSpeed = 0;
+				verticalSpeed = 0;
+				zoomSpeed = 0;
+				break;
+			default:
+				break;
+		}
+		frontEndCommand(deviceId, channelId, cmdCode, horizonSpeed, verticalSpeed, zoomSpeed);
+	}
+
+	/**
+	 * 设备光圈控制
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param command 控制指令,允许值: in, out, stop
+	 * @param speed 光圈速度(0-255)
+	 */
+	@GetMapping("/fi/iris/{deviceId}/{channelId}")
+	public void iris(@PathVariable String deviceId,@PathVariable String channelId, String command, Integer speed){
+
+		if (log.isDebugEnabled()) {
+			log.debug("设备光圈控制 API调用,deviceId:{} ,channelId:{} ,command:{} ,speed:{} ",deviceId, channelId, command, speed);
+		}
+
+		if (speed == null) {
+			speed = 100;
+		}else if (speed < 0 || speed > 255) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "speed 为 0-255的数字");
+		}
+
+		int cmdCode = 0x40;
+		switch (command){
+			case "in":
+				cmdCode = 0x44;
+				break;
+			case "out":
+				cmdCode = 0x48;
+				break;
+			case "stop":
+				speed = 0;
+				break;
+			default:
+				break;
+		}
+		frontEndCommand(deviceId, channelId, cmdCode, 0, speed, 0);
+	}
+
+	/**
+	 * 设备聚焦控制
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param command 控制指令,允许值: near, far, stop
+	 * @param speed 聚焦速度(0-255)
+	 */
+	@GetMapping("/fi/focus/{deviceId}/{channelId}")
+	public void focus(@PathVariable String deviceId,@PathVariable String channelId, String command, Integer speed){
+
+		if (log.isDebugEnabled()) {
+			log.debug("设备聚焦控制 API调用,deviceId:{} ,channelId:{} ,command:{} ,speed:{} ",deviceId, channelId, command, speed);
+		}
+
+		if (speed == null) {
+			speed = 100;
+		}else if (speed < 0 || speed > 255) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "speed 为 0-255的数字");
+		}
+
+		int cmdCode = 0x40;
+		switch (command){
+			case "near":
+				cmdCode = 0x42;
+				break;
+			case "far":
+				cmdCode = 0x41;
+				break;
+			case "stop":
+				speed = 0;
+				break;
+			default:
+				break;
+		}
+		frontEndCommand(deviceId, channelId, cmdCode, speed, 0, 0);
+	}
+
+	/**
+	 * 设备变倍控制
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @return
+	 */
+	@GetMapping("/preset/query/{deviceId}/{channelId}")
+	public DeferredResult<String> queryPreset(@PathVariable String deviceId, @PathVariable String channelId) {
+		if (log.isDebugEnabled()) {
+			log.debug("设备预置位查询API调用");
+		}
+		Device device = deviceService.getDeviceByDeviceId(deviceId);
+		String uuid =  UUID.randomUUID().toString();
+		String key =  DeferredResultHolder.CALLBACK_CMD_PRESETQUERY + (ObjectUtils.isEmpty(channelId) ? deviceId : channelId);
+		DeferredResult<String> result = new DeferredResult<String> (3 * 1000L);
+		result.onTimeout(()->{
+			log.warn(String.format("获取设备预置位超时"));
+			// 释放rtpserver
+			RequestMessage msg = new RequestMessage();
+			msg.setId(uuid);
+			msg.setKey(key);
+			msg.setData("获取设备预置位超时");
+			resultHolder.invokeResult(msg);
+		});
+		if (resultHolder.exist(key, null)) {
+			return result;
+		}
+		resultHolder.put(key, uuid, result);
+		try {
+			cmder.presetQuery(device, channelId, event -> {
+				RequestMessage msg = new RequestMessage();
+				msg.setId(uuid);
+				msg.setKey(key);
+				msg.setData(String.format("获取设备预置位失败,错误码: %s, %s", event.statusCode, event.msg));
+				resultHolder.invokeResult(msg);
+			});
+		} catch (InvalidArgumentException | SipException | ParseException e) {
+			log.error("[命令发送失败] 获取设备预置位: {}", e.getMessage());
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "命令发送失败: " + e.getMessage());
+		}
+		return result;
+	}
+
+	/**
+	 * 设备预置位添加
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param presetId 预置位编号(1-255)
+	 */
+	@GetMapping("/preset/add/{deviceId}/{channelId}")
+	public void addPreset(@PathVariable String deviceId, @PathVariable String channelId, Integer presetId) {
+		if (presetId == null || presetId < 1 || presetId > 255) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "预置位编号必须为1-255之间的数字");
+		}
+		frontEndCommand(deviceId, channelId, 0x81, 1, presetId, 0);
+	}
+
+	/**
+	 * 设备预置位调用
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param presetId 预置位编号(1-255)
+	 */
+	@GetMapping("/preset/call/{deviceId}/{channelId}")
+	public void callPreset(@PathVariable String deviceId, @PathVariable String channelId, Integer presetId) {
+		if (presetId == null || presetId < 1 || presetId > 255) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "预置位编号必须为1-255之间的数字");
+		}
+		frontEndCommand(deviceId, channelId, 0x82, 1, presetId, 0);
+	}
+
+	/**
+	 * 设备预置位删除
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param presetId 预置位编号(1-255)
+	 */
+	@GetMapping("/preset/delete/{deviceId}/{channelId}")
+	public void deletePreset(@PathVariable String deviceId, @PathVariable String channelId, Integer presetId) {
+		if (presetId == null || presetId < 1 || presetId > 255) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "预置位编号必须为1-255之间的数字");
+		}
+		frontEndCommand(deviceId, channelId, 0x83, 1, presetId, 0);
+	}
+
+	/**
+	 * 设备巡航点添加
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param cruiseId 巡航组号(0-255)
+	 * @param presetId 预置位编号(1-255)
+	 */
+	@GetMapping("/cruise/point/add/{deviceId}/{channelId}")
+	public void addCruisePoint(@PathVariable String deviceId, @PathVariable String channelId, Integer cruiseId, Integer presetId) {
+		if (presetId == null || cruiseId == null || presetId < 1 || presetId > 255 || cruiseId < 0 || cruiseId > 255) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "编号必须为1-255之间的数字");
+		}
+		frontEndCommand(deviceId, channelId, 0x84, cruiseId, presetId, 0);
+	}
+
+	/**
+	 * 设备巡航点删除
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param cruiseId 巡航组号(1-255)
+	 * @param presetId 预置位编号(0-255, 为0时删除整个巡航)
+	 */
+	@GetMapping("/cruise/point/delete/{deviceId}/{channelId}")
+	public void deleteCruisePoint(@PathVariable String deviceId, @PathVariable String channelId, Integer cruiseId, Integer presetId) {
+		if (presetId == null || presetId < 0 || presetId > 255) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "预置位编号必须为0-255之间的数字, 为0时删除整个巡航");
+		}
+		if (cruiseId == null || cruiseId < 0 || cruiseId > 255) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "巡航组号必须为0-255之间的数字");
+		}
+		frontEndCommand(deviceId, channelId, 0x85, cruiseId, presetId, 0);
+	}
+
+	/**
+	 * 设备巡航速度设置
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param cruiseId 巡航组号(0-255)
+	 * @param speed 巡航速度(1-4095)
+	 */
+	@GetMapping("/cruise/speed/{deviceId}/{channelId}")
+	public void setCruiseSpeed(@PathVariable String deviceId, @PathVariable String channelId, Integer cruiseId, Integer speed) {
+		if (cruiseId == null || cruiseId < 0 || cruiseId > 255) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "巡航组号必须为0-255之间的数字");
+		}
+		if (speed == null || speed < 1 || speed > 4095) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "巡航速度必须为1-4095之间的数字");
+		}
+		int parameter2 = speed & 0xFF;
+		int combindCode2 =  speed >> 8;
+		frontEndCommand(deviceId, channelId, 0x86, cruiseId, parameter2, combindCode2);
+	}
+
+	/**
+	 * 设备巡航停留时间设置
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param cruiseId 巡航组号
+	 * @param time 巡航停留时间(1-4095)
+	 */
+	@GetMapping("/cruise/time/{deviceId}/{channelId}")
+	public void setCruiseTime(@PathVariable String deviceId, @PathVariable String channelId, Integer cruiseId, Integer time) {
+		if (cruiseId == null || cruiseId < 0 || cruiseId > 255) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "巡航组号必须为0-255之间的数字");
+		}
+		if (time == null || time < 1 || time > 4095) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "巡航停留时间必须为1-4095之间的数字");
+		}
+		int parameter2 = time & 0xFF;
+		int combindCode2 =  time >> 8;
+		frontEndCommand(deviceId, channelId, 0x87, cruiseId, parameter2, combindCode2);
+	}
+
+	/**
+	 * 设备巡航启动
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param cruiseId 巡航组号
+	 */
+	@GetMapping("/cruise/start/{deviceId}/{channelId}")
+	public void startCruise(@PathVariable String deviceId, @PathVariable String channelId, Integer cruiseId) {
+		if (cruiseId == null || cruiseId < 0 || cruiseId > 255) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "巡航组号必须为0-255之间的数字");
+		}
+		frontEndCommand(deviceId, channelId, 0x88, cruiseId, 0, 0);
+	}
+
+	/**
+	 * 设备巡航停止
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param cruiseId 巡航组号
+	 */
+	@GetMapping("/cruise/stop/{deviceId}/{channelId}")
+	public void stopCruise(@PathVariable String deviceId, @PathVariable String channelId, Integer cruiseId) {
+		if (cruiseId == null || cruiseId < 0 || cruiseId > 255) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "巡航组号必须为0-255之间的数字");
+		}
+		frontEndCommand(deviceId, channelId, 0, 0, 0, 0);
+	}
+
+	/**
+	 * 设备扫描启动
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param scanId 扫描组号(0-255)
+	 */
+	@GetMapping("/scan/start/{deviceId}/{channelId}")
+	public void startScan(@PathVariable String deviceId, @PathVariable String channelId, Integer scanId) {
+		if (scanId == null || scanId < 0 || scanId > 255 ) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "扫描组号必须为0-255之间的数字");
+		}
+		frontEndCommand(deviceId, channelId, 0x89, scanId, 0, 0);
+	}
+
+	/**
+	 * 设备扫描停止
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param scanId 扫描组号(0-255)
+	 */
+	@GetMapping("/scan/stop/{deviceId}/{channelId}")
+	public void stopScan(@PathVariable String deviceId, @PathVariable String channelId, Integer scanId) {
+		if (scanId == null || scanId < 0 || scanId > 255 ) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "扫描组号必须为0-255之间的数字");
+		}
+		frontEndCommand(deviceId, channelId, 0, 0, 0, 0);
+	}
+
+	/**
+	 * 设备扫描上
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param scanId 扫描组号(0-255)
+	 */
+	@GetMapping("/scan/set/left/{deviceId}/{channelId}")
+	public void setScanLeft(@PathVariable String deviceId, @PathVariable String channelId, Integer scanId) {
+		if (scanId == null || scanId < 0 || scanId > 255 ) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "扫描组号必须为0-255之间的数字");
+		}
+		frontEndCommand(deviceId, channelId, 0x89, scanId, 1, 0);
+	}
+
+	/**
+	 * 设备扫描下
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param scanId 扫描组号(0-255)
+	 */
+	@GetMapping("/scan/set/right/{deviceId}/{channelId}")
+	public void setScanRight(@PathVariable String deviceId, @PathVariable String channelId, Integer scanId) {
+		if (scanId == null || scanId < 0 || scanId > 255 ) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "扫描组号必须为0-255之间的数字");
+		}
+		frontEndCommand(deviceId, channelId, 0x89, scanId, 2, 0);
+	}
+
+
+	/**
+	 * 设备扫描左
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param scanId 扫描组号(0-255)
+	 * @param speed 自动扫描速度(1-4095)
+	 */
+	@GetMapping("/scan/set/speed/{deviceId}/{channelId}")
+	public void setScanSpeed(@PathVariable String deviceId, @PathVariable String channelId, Integer scanId, Integer speed) {
+		if (scanId == null || scanId < 0 || scanId > 255 ) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "扫描组号必须为0-255之间的数字");
+		}
+		if (speed == null || speed < 1 || speed > 4095) {
+			throw new ControllerException(ErrorCode.ERROR100.getCode(), "自动扫描速度必须为1-4095之间的数字");
+		}
+		int parameter2 = speed & 0xFF;
+		int combindCode2 =  speed >> 8;
+		frontEndCommand(deviceId, channelId, 0x8A, scanId, parameter2, combindCode2);
+	}
+
+	/**
+	 * 设备雨刷控制
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param command 控制指令,允许值: on, off
+	 */
+	@GetMapping("/wiper/{deviceId}/{channelId}")
+	public void wiper(@PathVariable String deviceId,@PathVariable String channelId, String command){
+
+		if (log.isDebugEnabled()) {
+			log.debug("辅助开关控制指令-雨刷控制 API调用,deviceId:{} ,channelId:{} ,command:{}",deviceId, channelId, command);
+		}
+
+		int cmdCode = 0;
+		switch (command){
+			case "on":
+				cmdCode = 0x8c;
+				break;
+			case "off":
+				cmdCode = 0x8d;
+				break;
+			default:
+				break;
+		}
+		frontEndCommand(deviceId, channelId, cmdCode, 1, 0, 0);
+	}
+
+	/**
+	 * 设备雨刷控制
+	 *
+	 * @param deviceId 设备国标编号
+	 * @param channelId 通道国标编号
+	 * @param command 控制指令,允许值: on, off
+	 * @param switchId 开关编号
+	 */
+	@GetMapping("/auxiliary/{deviceId}/{channelId}")
+	public void auxiliarySwitch(@PathVariable String deviceId,@PathVariable String channelId, String command, Integer switchId){
+
+		if (log.isDebugEnabled()) {
+			log.debug("辅助开关控制指令-雨刷控制 API调用,deviceId:{} ,channelId:{} ,command:{}, switchId: {}",deviceId, channelId, command, switchId);
+		}
+
+		int cmdCode = 0;
+		switch (command){
+			case "on":
+				cmdCode = 0x8c;
+				break;
+			case "off":
+				cmdCode = 0x8d;
+				break;
+			default:
+				break;
+		}
+		frontEndCommand(deviceId, channelId, cmdCode, switchId, 0, 0);
+	}
+}

+ 153 - 0
video-service/src/main/java/com/ruoyi/wvp/gb28181/ApiControlController.java

@@ -0,0 +1,153 @@
+package com.ruoyi.wvp.gb28181;
+
+import com.zksy.common.exception.ControllerException;
+import com.ruoyi.wvp.gb28181.bean.Device;
+import com.ruoyi.wvp.gb28181.service.IDeviceService;
+import com.ruoyi.wvp.gb28181.transmit.cmd.impl.SIPCommander;
+import com.zksy.common.enums.ErrorCode;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.sip.InvalidArgumentException;
+import javax.sip.SipException;
+import java.text.ParseException;
+
+/**
+ * API兼容:设备控制
+ */
+@Slf4j
+@RestController
+@RequestMapping(value = "/api/v1/control")
+public class ApiControlController {
+
+    @Autowired
+    private SIPCommander cmder;
+
+    @Autowired
+    private IDeviceService deviceService;
+
+    /**
+     * 设备控制 - 云台控制
+     * @param serial 设备编号
+     * @param command 控制指令 允许值: left, right, up, down, upleft, upright, downleft, downright, zoomin, zoomout, stop
+     * @param channel 通道序号
+     * @param code 通道编号
+     * @param speed 速度(0~255) 默认值: 129
+     * @return
+     */
+    @GetMapping(value = "/ptz")
+    private void list(String serial,String command,
+                            @RequestParam(required = false)Integer channel,
+                            @RequestParam(required = false)String code,
+                            @RequestParam(required = false)Integer speed){
+
+        if (log.isDebugEnabled()) {
+            log.debug("模拟接口> 设备云台控制 API调用,deviceId:{} ,channelId:{} ,command:{} ,speed:{} ",
+                    serial, code, command, speed);
+        }
+        if (channel == null) {channel = 0;}
+        if (speed == null) {speed = 0;}
+        Device device = deviceService.getDeviceByDeviceId(serial);
+        if (device == null) {
+            throw new ControllerException(ErrorCode.ERROR100.getCode(), "device[ " + serial + " ]未找到");
+        }
+        int cmdCode = 0;
+        switch (command){
+            case "left":
+                cmdCode = 2;
+                break;
+            case "right":
+                cmdCode = 1;
+                break;
+            case "up":
+                cmdCode = 8;
+                break;
+            case "down":
+                cmdCode = 4;
+                break;
+            case "upleft":
+                cmdCode = 10;
+                break;
+            case "upright":
+                cmdCode = 9;
+                break;
+            case "downleft":
+                cmdCode = 6;
+                break;
+            case "downright":
+                cmdCode = 5;
+                break;
+            case "zoomin":
+                cmdCode = 16;
+                break;
+            case "zoomout":
+                cmdCode = 32;
+                break;
+            case "stop":
+                cmdCode = 0;
+                break;
+            default:
+                break;
+        }
+        // 默认值 50
+        try {
+            cmder.frontEndCmd(device, code, cmdCode, speed, speed, speed);
+        } catch (SipException | InvalidArgumentException | ParseException e) {
+            log.error("[命令发送失败] 云台控制: {}", e.getMessage());
+            throw new ControllerException(ErrorCode.ERROR100.getCode(), "命令发送失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 设备控制 - 预置位控制
+     * @param serial 设备编号
+     * @param code 通道编号,通过 /api/v1/device/channellist 获取的 ChannelList.ID, 该参数和 channel 二选一传递即可
+     * @param channel 通道序号, 默认值: 1
+     * @param command 控制指令 允许值: set, goto, remove
+     * @param preset 预置位编号(1~255)
+     * @param name 预置位名称, command=set 时有效
+     * @return
+     */
+    @GetMapping(value = "/preset")
+    private void list(String serial,String command,
+                            @RequestParam(required = false)Integer channel,
+                            @RequestParam(required = false)String code,
+                            @RequestParam(required = false)String name,
+                            @RequestParam(required = false)Integer preset){
+
+        if (log.isDebugEnabled()) {
+            log.debug("模拟接口> 预置位控制 API调用,deviceId:{} ,channelId:{} ,command:{} ,name:{} ,preset:{} ",
+                    serial, code, command, name, preset);
+        }
+
+        if (channel == null) {channel = 0;}
+        Device device = deviceService.getDeviceByDeviceId(serial);
+        if (device == null) {
+            throw new ControllerException(ErrorCode.ERROR100.getCode(), "device[ " + serial + " ]未找到");
+        }
+        int cmdCode = 0;
+        switch (command){
+            case "set":
+                cmdCode = 129;
+                break;
+            case "goto":
+                cmdCode = 130;
+                break;
+            case "remove":
+                cmdCode = 131;
+                break;
+            default:
+                break;
+        }
+        try {
+            cmder.frontEndCmd(device, code, cmdCode, 0, preset, 0);
+        } catch (SipException | InvalidArgumentException | ParseException e) {
+            log.error("[命令发送失败] 预置位控制: {}", e.getMessage());
+            throw new ControllerException(ErrorCode.ERROR100.getCode(), "命令发送失败: " + e.getMessage());
+        }
+    }
+}

+ 253 - 0
video-service/src/main/java/com/ruoyi/wvp/gb28181/ApiDeviceController.java

@@ -0,0 +1,253 @@
+package com.ruoyi.wvp.gb28181;
+
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.zksy.common.exception.ControllerException;
+import com.ruoyi.wvp.gb28181.bean.Device;
+import com.ruoyi.wvp.gb28181.bean.Preset;
+import com.ruoyi.wvp.gb28181.dto.DeviceChannelExtend;
+import com.ruoyi.wvp.gb28181.service.IDeviceChannelService;
+import com.ruoyi.wvp.gb28181.service.IDeviceService;
+import com.ruoyi.wvp.gb28181.transmit.callback.DeferredResultHolder;
+import com.ruoyi.wvp.gb28181.transmit.callback.RequestMessage;
+import com.ruoyi.wvp.gb28181.transmit.cmd.impl.SIPCommander;
+import com.ruoyi.wvp.vmanager.bean.DeferredResultEx;
+import com.zksy.common.enums.ErrorCode;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.util.ObjectUtils;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.context.request.async.DeferredResult;
+
+import javax.sip.InvalidArgumentException;
+import javax.sip.SipException;
+import java.text.ParseException;
+import java.util.*;
+
+/**
+ * API兼容:设备信息
+ */
+@SuppressWarnings("unchecked")
+@Slf4j
+@RestController
+@RequestMapping(value = "/api/v1/device")
+public class ApiDeviceController {
+
+    @Autowired
+    private SIPCommander cmder;
+    @Autowired
+    private IDeviceChannelService channelService;
+
+    @Autowired
+    private DeferredResultHolder resultHolder;
+
+    @Autowired
+    private IDeviceService deviceService;
+
+
+    /**
+     * 分页获取设备列表 现在直接返回,尚未实现分页
+     * @param start
+     * @param limit
+     * @param q
+     * @param online
+     * @return
+     */
+    @GetMapping(value = "/list")
+    public JSONObject list( @RequestParam(required = false)Integer start,
+                            @RequestParam(required = false)Integer limit,
+                            @RequestParam(required = false)String q,
+                            @RequestParam(required = false)Boolean online ){
+
+//        if (logger.isDebugEnabled()) {
+//            logger.debug("查询所有视频设备API调用");
+//        }
+
+        JSONObject result = new JSONObject();
+        List<Device> devices;
+        if (start == null || limit ==null) {
+            devices = deviceService.getAllByStatus(online);
+            result.put("DeviceCount", devices.size());
+        }else {
+            Device device = new Device();
+            device.setPageNum(start / limit);
+            device.setPageSize(limit);
+            List<Device> list = deviceService.getAll(device);
+            result.put("DeviceCount", list.size());
+            devices = list;
+        }
+
+        JSONArray deviceJSONList = new JSONArray();
+        devices.stream().forEach(device -> {
+            JSONObject deviceJsonObject = new JSONObject();
+            deviceJsonObject.put("ID", device.getDeviceId());
+            deviceJsonObject.put("Name", device.getName());
+            deviceJsonObject.put("Type", "GB");
+            deviceJsonObject.put("ChannelCount", device.getChannelCount());
+            deviceJsonObject.put("RecvStreamIP", "");
+            deviceJsonObject.put("CatalogInterval", 3600); // 通道目录抓取周期
+            deviceJsonObject.put("SubscribeInterval", device.getSubscribeCycleForCatalog()); // 订阅周期(秒), 0 表示后台不周期订阅
+            deviceJsonObject.put("Online", device.isOnLine());
+            deviceJsonObject.put("Password", "");
+            deviceJsonObject.put("MediaTransport", device.getTransport());
+            deviceJsonObject.put("RemoteIP", device.getIp());
+            deviceJsonObject.put("RemotePort", device.getPort());
+            deviceJsonObject.put("LastRegisterAt", "");
+            deviceJsonObject.put("LastKeepaliveAt", "");
+            deviceJsonObject.put("UpdatedAt", "");
+            deviceJsonObject.put("CreatedAt", "");
+            deviceJSONList.add(deviceJsonObject);
+        });
+        result.put("DeviceList",deviceJSONList);
+        return result;
+    }
+
+    @GetMapping(value = "/channellist")
+    public JSONObject channellist( String serial,
+                                   @RequestParam(required = false)String channel_type,
+                                   @RequestParam(required = false)String code ,
+                                   @RequestParam(required = false)String dir_serial ,
+                                   @RequestParam(required = false)Integer start,
+                                   @RequestParam(required = false)Integer limit,
+                                   @RequestParam(required = false)String q,
+                                   @RequestParam(required = false)Boolean online ){
+
+        JSONObject result = new JSONObject();
+        List<DeviceChannelExtend> deviceChannels;
+        List<String> channelIds = null;
+        if (!ObjectUtils.isEmpty(code)) {
+            String[] split = code.trim().split(",");
+            channelIds = Arrays.asList(split);
+        }
+        List<DeviceChannelExtend> allDeviceChannelList = channelService.queryChannelExtendsByDeviceId(serial,channelIds,online);
+        if (start == null || limit ==null) {
+            deviceChannels = allDeviceChannelList;
+            result.put("ChannelCount", deviceChannels.size());
+        }else {
+            if (start > allDeviceChannelList.size()) {
+                deviceChannels = new ArrayList<>();
+            }else {
+                if (start + limit < allDeviceChannelList.size()) {
+                    deviceChannels = allDeviceChannelList.subList(start, start + limit);
+                }else {
+                    deviceChannels = allDeviceChannelList.subList(start, allDeviceChannelList.size());
+                }
+            }
+            result.put("ChannelCount", allDeviceChannelList.size());
+        }
+        JSONArray channleJSONList = new JSONArray();
+        deviceChannels.stream().forEach(deviceChannelExtend -> {
+            JSONObject deviceJOSNChannel = new JSONObject();
+            deviceJOSNChannel.put("ID", deviceChannelExtend.getChannelId());
+            deviceJOSNChannel.put("DeviceID", deviceChannelExtend.getDeviceId());
+            deviceJOSNChannel.put("DeviceName", deviceChannelExtend.getDeviceName());
+            deviceJOSNChannel.put("DeviceOnline", deviceChannelExtend.isDeviceOnline());
+            deviceJOSNChannel.put("Channel", 0); // TODO 自定义序号
+            deviceJOSNChannel.put("Name", deviceChannelExtend.getName());
+            deviceJOSNChannel.put("Custom", false);
+            deviceJOSNChannel.put("CustomName", "");
+            deviceJOSNChannel.put("SubCount", deviceChannelExtend.getSubCount()); // TODO ? 子节点数, SubCount > 0 表示该通道为子目录
+            deviceJOSNChannel.put("SnapURL", "");
+            deviceJOSNChannel.put("Manufacturer ", deviceChannelExtend.getManufacture());
+            deviceJOSNChannel.put("Model", deviceChannelExtend.getModel());
+            deviceJOSNChannel.put("Owner", deviceChannelExtend.getOwner());
+            deviceJOSNChannel.put("CivilCode", deviceChannelExtend.getCivilCode());
+            deviceJOSNChannel.put("Address", deviceChannelExtend.getAddress());
+            deviceJOSNChannel.put("Parental", deviceChannelExtend.getParental()); // 当为通道设备时, 是否有通道子设备, 1-有,0-没有
+            deviceJOSNChannel.put("ParentID", deviceChannelExtend.getParentId()); // 直接上级编号
+            deviceJOSNChannel.put("Secrecy", deviceChannelExtend.getSecrecy());
+            deviceJOSNChannel.put("RegisterWay", 1); // 注册方式, 缺省为1, 允许值: 1, 2, 3
+            // 1-IETF RFC3261,
+            // 2-基于口令的双向认证,
+            // 3-基于数字证书的双向认证
+            deviceJOSNChannel.put("Status", deviceChannelExtend.getStatus());
+            deviceJOSNChannel.put("Longitude", deviceChannelExtend.getLongitude());
+            deviceJOSNChannel.put("Latitude", deviceChannelExtend.getLatitude());
+            deviceJOSNChannel.put("PTZType ", deviceChannelExtend.getPTZType()); // 云台类型, 0 - 未知, 1 - 球机, 2 - 半球,
+            //   3 - 固定枪机, 4 - 遥控枪机
+            deviceJOSNChannel.put("CustomPTZType", "");
+            deviceJOSNChannel.put("StreamID", deviceChannelExtend.getStreamId()); // StreamID 直播流ID, 有值表示正在直播
+            deviceJOSNChannel.put("NumOutputs ", -1); // 直播在线人数
+            channleJSONList.add(deviceJOSNChannel);
+        });
+        result.put("ChannelList", channleJSONList);
+        return result;
+    }
+
+    /**
+     * 设备信息 - 获取下级通道预置位
+     * @param serial 设备编号
+     * @param code 通道编号,通过 /api/v1/device/channellist 获取的 ChannelList.ID, 该参数和 channel 二选一传递即可
+     * @param channel 通道序号, 默认值: 1
+     * @param fill 是否填充空置预置位,当下级返回预置位,但不够255个时,自动填充空置预置位到255个, 默认值: true, 允许值: true, false
+     * @param timeout 超时时间(秒) 默认值: 15
+     * @return
+     */
+    @GetMapping(value = "/fetchpreset")
+    private DeferredResult<Object>  list(String serial,
+                      @RequestParam(required = false)Integer channel,
+                      @RequestParam(required = false)String code,
+                      @RequestParam(required = false)Boolean fill,
+                      @RequestParam(required = false)Integer timeout){
+
+        if (log.isDebugEnabled()) {
+            log.debug("<模拟接口> 获取下级通道预置位 API调用,deviceId:{} ,channel:{} ,code:{} ,fill:{} ,timeout:{} ",
+                    serial, channel, code, fill, timeout);
+        }
+
+        Device device = deviceService.getDeviceByDeviceId(serial);
+        String uuid =  UUID.randomUUID().toString();
+        String key =  DeferredResultHolder.CALLBACK_CMD_PRESETQUERY + (ObjectUtils.isEmpty(code) ? serial : code);
+        DeferredResult<Object> result = new DeferredResult<> (timeout * 1000L);
+        DeferredResultEx<Object> deferredResultEx = new DeferredResultEx<>(result);
+        result.onTimeout(()->{
+            log.warn("<模拟接口> 获取设备预置位超时");
+            // 释放rtpserver
+            RequestMessage msg = new RequestMessage();
+            msg.setId(uuid);
+            msg.setKey(key);
+            msg.setData("wait for presetquery timeout["+timeout+"s]");
+            resultHolder.invokeResult(msg);
+        });
+        if (resultHolder.exist(key, null)) {
+            return result;
+        }
+
+        deferredResultEx.setFilter(filterResult->{
+            List<Preset> presetQuerySipReqList = (List<Preset>)filterResult;
+            HashMap<String, Object> resultMap = new HashMap<>();
+            resultMap.put("DeviceID", code);
+            resultMap.put("Result", "OK");
+            resultMap.put("SumNum", presetQuerySipReqList.size());
+            ArrayList<Map<String, Object>> presetItemList = new ArrayList<>(presetQuerySipReqList.size());
+            for (Preset presetQuerySipReq : presetQuerySipReqList) {
+                Map<String, Object> item = new HashMap<>();
+                item.put("PresetID", presetQuerySipReq.getPresetId());
+                item.put("PresetName", presetQuerySipReq.getPresetName());
+                item.put("PresetEnable", true);
+                presetItemList.add(item);
+            }
+            resultMap.put("PresetItemList",presetItemList );
+            return resultMap;
+        });
+
+        resultHolder.put(key, uuid, deferredResultEx);
+
+        try {
+            cmder.presetQuery(device, code, event -> {
+                RequestMessage msg = new RequestMessage();
+                msg.setId(uuid);
+                msg.setKey(key);
+                msg.setData(String.format("获取设备预置位失败,错误码: %s, %s", event.statusCode, event.msg));
+                resultHolder.invokeResult(msg);
+            });
+        } catch (InvalidArgumentException | SipException | ParseException e) {
+            log.error("[命令发送失败] 获取设备预置位: {}", e.getMessage());
+            throw new ControllerException(ErrorCode.ERROR100.getCode(), "命令发送失败: " + e.getMessage());
+        }
+        return result;
+    }
+}

+ 276 - 0
video-service/src/main/java/com/ruoyi/wvp/gb28181/ApiStreamController.java

@@ -0,0 +1,276 @@
+package com.ruoyi.wvp.gb28181;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.wvp.common.InviteInfo;
+import com.ruoyi.wvp.common.InviteSessionType;
+import com.ruoyi.wvp.common.StreamInfo;
+import com.ruoyi.wvp.conf.UserSetting;
+import com.zksy.common.exception.SsrcTransactionNotFoundException;
+import com.ruoyi.wvp.gb28181.bean.Device;
+import com.ruoyi.wvp.gb28181.bean.DeviceChannel;
+import com.ruoyi.wvp.gb28181.service.IDeviceChannelService;
+import com.ruoyi.wvp.gb28181.service.IDeviceService;
+import com.ruoyi.wvp.gb28181.service.IInviteStreamService;
+import com.ruoyi.wvp.gb28181.service.IPlayService;
+import com.ruoyi.wvp.gb28181.transmit.cmd.impl.SIPCommander;
+import com.ruoyi.wvp.media.bean.MediaServer;
+import com.ruoyi.wvp.service.bean.InviteErrorCode;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.context.request.async.DeferredResult;
+
+import javax.sip.InvalidArgumentException;
+import javax.sip.SipException;
+import java.text.ParseException;
+
+/**
+ * API兼容:实时直播
+ */
+@SuppressWarnings(value = {"rawtypes", "unchecked"})
+
+@Slf4j
+@RestController
+@RequestMapping(value = "/api/v1/stream")
+public class ApiStreamController {
+
+    @Autowired
+    private SIPCommander cmder;
+
+    @Autowired
+    private UserSetting userSetting;
+
+    @Autowired
+    private IDeviceService deviceService;
+
+    @Autowired
+    private IDeviceChannelService deviceChannelService;
+
+    @Autowired
+    private IPlayService playService;
+
+    @Autowired
+    private IInviteStreamService inviteStreamService;
+
+    /**
+     * 实时直播 - 开始直播
+     * @param serial 设备编号
+     * @param channel 通道序号 默认值: 1
+     * @param code 通道编号,通过 /api/v1/device/channellist 获取的 ChannelList.ID, 该参数和 channel 二选一传递即可
+     * @param cdn 转推 CDN 地址, 形如: [rtmp|rtsp]://xxx, encodeURIComponent
+     * @param audio 是否开启音频, 默认 开启
+     * @param transport 流传输模式, 默认 UDP
+     * @param checkchannelstatus 是否检查通道状态, 默认 false, 表示 拉流前不检查通道状态是否在线
+     * @param transportmode 当 transport=TCP 时有效, 指示流传输主被动模式, 默认被动
+     * @param timeout 拉流超时(秒),
+     * @return
+     */
+    @GetMapping("/start")
+    private DeferredResult<JSONObject> start(String serial ,
+                                             @RequestParam(required = false)Integer channel ,
+                                             @RequestParam(required = false)String code,
+                                             @RequestParam(required = false)String cdn,
+                                             @RequestParam(required = false)String audio,
+                                             @RequestParam(required = false)String transport,
+                                             @RequestParam(required = false)String checkchannelstatus ,
+                                             @RequestParam(required = false)String transportmode,
+                                             @RequestParam(required = false)String timeout
+
+    ){
+        DeferredResult<JSONObject> result = new DeferredResult<>(userSetting.getPlayTimeout().longValue() + 10);
+        Device device = deviceService.getDeviceByDeviceId(serial);
+        if (device == null ) {
+            JSONObject resultJSON = new JSONObject();
+            resultJSON.put("error","device[ " + serial + " ]未找到");
+            result.setResult(resultJSON);
+            return result;
+        }else if (!device.isOnLine()) {
+            JSONObject resultJSON = new JSONObject();
+            resultJSON.put("error","device[ " + code + " ]offline");
+            result.setResult(resultJSON);
+            return result;
+        }
+
+
+        DeviceChannel deviceChannel = deviceChannelService.getOne(serial, code);
+        if (deviceChannel == null) {
+            JSONObject resultJSON = new JSONObject();
+            resultJSON.put("error","channel[ " + code + " ]未找到");
+            result.setResult(resultJSON);
+            return result;
+        }else if (!deviceChannel.getStatus().equalsIgnoreCase("ON")) {
+            JSONObject resultJSON = new JSONObject();
+            resultJSON.put("error","channel[ " + code + " ]offline");
+            result.setResult(resultJSON);
+            return result;
+        }
+
+        result.onTimeout(()->{
+            log.info("播放等待超时");
+            JSONObject resultJSON = new JSONObject();
+            resultJSON.put("error","timeout");
+            result.setResult(resultJSON);
+            inviteStreamService.removeInviteInfoByDeviceAndChannel(InviteSessionType.PLAY, deviceChannel.getId());
+            deviceChannelService.stopPlay(deviceChannel.getId());
+            // 清理RTP server
+        });
+
+        MediaServer newMediaServerItem = playService.getNewMediaServerItem(device);
+
+        playService.play(newMediaServerItem, serial, code, null, (errorCode, msg, data) -> {
+            if (errorCode == InviteErrorCode.SUCCESS.getCode()) {
+                if (data != null) {
+                    StreamInfo streamInfo = (StreamInfo)data;
+                    JSONObject resultJjson = new JSONObject();
+                    resultJjson.put("StreamID", streamInfo.getStream());
+                    resultJjson.put("DeviceID", serial);
+                    resultJjson.put("ChannelID", code);
+                    resultJjson.put("ChannelName", deviceChannel.getName());
+                    resultJjson.put("ChannelCustomName", "");
+                    if (streamInfo.getTranscodeStream() != null) {
+                        resultJjson.put("FLV", streamInfo.getTranscodeStream().getFlv().getUrl());
+                    }else {
+                        resultJjson.put("FLV", streamInfo.getFlv().getUrl());
+
+                    }
+                    if(streamInfo.getHttps_flv() != null) {
+                        if (streamInfo.getTranscodeStream() != null) {
+                            resultJjson.put("HTTPS_FLV", streamInfo.getTranscodeStream().getHttps_flv().getUrl());
+                        }else {
+                            resultJjson.put("HTTPS_FLV", streamInfo.getHttps_flv().getUrl());
+                        }
+                    }
+
+                    if (streamInfo.getTranscodeStream() != null) {
+                        resultJjson.put("WS_FLV", streamInfo.getTranscodeStream().getWs_flv().getUrl());
+                    }else {
+                        resultJjson.put("WS_FLV", streamInfo.getWs_flv().getUrl());
+                    }
+
+                    if(streamInfo.getWss_flv() != null) {
+                        if (streamInfo.getTranscodeStream() != null) {
+                            resultJjson.put("WSS_FLV", streamInfo.getTranscodeStream().getWss_flv().getUrl());
+                        }else {
+                            resultJjson.put("WSS_FLV", streamInfo.getWss_flv().getUrl());
+                        }
+                    }
+                    resultJjson.put("RTMP", streamInfo.getRtmp().getUrl());
+                    if (streamInfo.getRtmps() != null) {
+                        resultJjson.put("RTMPS", streamInfo.getRtmps().getUrl());
+                    }
+                    resultJjson.put("HLS", streamInfo.getHls().getUrl());
+                    if (streamInfo.getHttps_hls() != null) {
+                        resultJjson.put("HTTPS_HLS", streamInfo.getHttps_hls().getUrl());
+                    }
+                    resultJjson.put("RTSP", streamInfo.getRtsp().getUrl());
+                    if (streamInfo.getRtsps() != null) {
+                        resultJjson.put("RTSPS", streamInfo.getRtsps().getUrl());
+                    }
+                    resultJjson.put("WEBRTC", streamInfo.getRtc().getUrl());
+                    if (streamInfo.getRtcs() != null) {
+                        resultJjson.put("HTTPS_WEBRTC", streamInfo.getRtcs().getUrl());
+                    }
+                    resultJjson.put("CDN", "");
+                    resultJjson.put("SnapURL", "");
+                    resultJjson.put("Transport", device.getTransport());
+                    resultJjson.put("StartAt", "");
+                    resultJjson.put("Duration", "");
+                    resultJjson.put("SourceVideoCodecName", "");
+                    resultJjson.put("SourceVideoWidth", "");
+                    resultJjson.put("SourceVideoHeight", "");
+                    resultJjson.put("SourceVideoFrameRate", "");
+                    resultJjson.put("SourceAudioCodecName", "");
+                    resultJjson.put("SourceAudioSampleRate", "");
+                    resultJjson.put("AudioEnable", "");
+                    resultJjson.put("Ondemand", "");
+                    resultJjson.put("InBytes", "");
+                    resultJjson.put("InBitRate", "");
+                    resultJjson.put("OutBytes", "");
+                    resultJjson.put("NumOutputs", "");
+                    resultJjson.put("CascadeSize", "");
+                    resultJjson.put("RelaySize", "");
+                    resultJjson.put("ChannelPTZType", "0");
+                    result.setResult(resultJjson);
+                }else {
+                    JSONObject resultJjson = new JSONObject();
+                    resultJjson.put("error", "channel[ " + code + " ] " + msg);
+                    result.setResult(resultJjson);
+                }
+            }else {
+                JSONObject resultJjson = new JSONObject();
+                resultJjson.put("error", "channel[ " + code + " ] " + msg);
+                result.setResult(resultJjson);
+            }
+        });
+
+        return result;
+    }
+
+    /**
+     * 实时直播 - 直播流停止
+     * @param serial 设备编号
+     * @param channel 通道序号
+     * @param code 通道国标编号
+     * @param check_outputs
+     * @return
+     */
+    @GetMapping("/stop")
+    @ResponseBody
+    private JSONObject stop(String serial ,
+                             @RequestParam(required = false)Integer channel ,
+                             @RequestParam(required = false)String code,
+                             @RequestParam(required = false)String check_outputs
+
+    ){
+
+
+        Device device = deviceService.getDeviceByDeviceId(serial);
+        if (device == null) {
+            JSONObject result = new JSONObject();
+            result.put("error","未找到设备");
+            return result;
+        }
+        DeviceChannel deviceChannel = deviceChannelService.getOne(serial, code);
+        if (deviceChannel == null) {
+            JSONObject result = new JSONObject();
+            result.put("error","未找到通道");
+            return result;
+        }
+        InviteInfo inviteInfo = inviteStreamService.getInviteInfoByDeviceAndChannel(InviteSessionType.PLAY, deviceChannel.getId());
+        if (inviteInfo == null) {
+            JSONObject result = new JSONObject();
+            result.put("error","未找到流信息");
+            return result;
+        }
+
+        try {
+            cmder.streamByeCmd(device, code, "rtp", inviteInfo.getStream(), null, null);
+        } catch (InvalidArgumentException | ParseException | SipException | SsrcTransactionNotFoundException e) {
+            JSONObject result = new JSONObject();
+            result.put("error","发送BYE失败:" + e.getMessage());
+            return result;
+        }
+        inviteStreamService.removeInviteInfo(inviteInfo);
+        deviceChannelService.stopPlay(inviteInfo.getChannelId());
+        return null;
+    }
+
+    /**
+     * 实时直播 - 直播流保活
+     * @param serial 设备编号
+     * @param channel 通道序号
+     * @param code 通道国标编号
+     * @return
+     */
+    @GetMapping("/touch")
+    @ResponseBody
+    private JSONObject touch(String serial ,String t,
+                            @RequestParam(required = false)Integer channel ,
+                            @RequestParam(required = false)String code,
+                            @RequestParam(required = false)String autorestart,
+                            @RequestParam(required = false)String audio,
+                            @RequestParam(required = false)String cdn
+    ){
+        return null;
+    }
+}

+ 186 - 0
video-service/src/main/java/com/ruoyi/wvp/gb28181/SipLayer.java

@@ -0,0 +1,186 @@
+package com.ruoyi.wvp.gb28181;
+
+
+import com.ruoyi.wvp.conf.SipConfig;
+import com.ruoyi.wvp.conf.UserSetting;
+import com.ruoyi.wvp.gb28181.bean.GbStringMsgParserFactory;
+import com.ruoyi.wvp.gb28181.conf.DefaultProperties;
+import com.ruoyi.wvp.gb28181.transmit.ISIPProcessorObserver;
+import gov.nist.javax.sip.SipProviderImpl;
+import gov.nist.javax.sip.SipStackImpl;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.util.ObjectUtils;
+
+import javax.sip.*;
+import java.net.Inet4Address;
+import java.net.InetAddress;
+import java.net.NetworkInterface;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Slf4j
+@Component
+@Order(value=10)
+public class SipLayer implements CommandLineRunner {
+
+	@Autowired
+	private SipConfig sipConfig;
+
+	@Autowired
+	private ISIPProcessorObserver sipProcessorObserver;
+
+	@Autowired
+	private UserSetting userSetting;
+
+	private final Map<String, SipProviderImpl> tcpSipProviderMap = new ConcurrentHashMap<>();
+	private final Map<String, SipProviderImpl> udpSipProviderMap = new ConcurrentHashMap<>();
+	private final List<String> monitorIps = new ArrayList<>();
+
+	@Override
+	public void run(String... args) {
+		if (ObjectUtils.isEmpty(sipConfig.getIp())) {
+			try {
+				// 获得本机的所有网络接口
+				Enumeration<NetworkInterface> nifs = NetworkInterface.getNetworkInterfaces();
+				while (nifs.hasMoreElements()) {
+					NetworkInterface nif = nifs.nextElement();
+					// 获得与该网络接口绑定的 IP 地址,一般只有一个
+					Enumeration<InetAddress> addresses = nif.getInetAddresses();
+					while (addresses.hasMoreElements()) {
+						InetAddress addr = addresses.nextElement();
+						if (addr instanceof Inet4Address) {
+							if (addr.getHostAddress().equals("127.0.0.1")){
+								continue;
+							}
+							if (nif.getName().startsWith("docker")) {
+								continue;
+							}
+							log.info("[自动配置SIP监听网卡] 网卡接口地址: {}", addr.getHostAddress());// 只关心 IPv4 地址
+							monitorIps.add(addr.getHostAddress());
+						}
+					}
+				}
+			}catch (Exception e) {
+				log.error("[读取网卡信息失败]", e);
+			}
+			if (monitorIps.isEmpty()) {
+				log.error("[自动配置SIP监听网卡信息失败], 请手动配置SIP.IP后重新启动");
+				System.exit(1);
+			}
+		}else {
+			// 使用逗号分割多个ip
+			String separator = ",";
+			if (sipConfig.getIp().indexOf(separator) > 0) {
+				String[] split = sipConfig.getIp().split(separator);
+				monitorIps.addAll(Arrays.asList(split));
+			}else {
+				monitorIps.add(sipConfig.getIp());
+			}
+		}
+		if (ObjectUtils.isEmpty(sipConfig.getShowIp())){
+			sipConfig.setShowIp(String.join(",", monitorIps));
+		}
+		SipFactory.getInstance().setPathName("gov.nist");
+		if (monitorIps.size() > 0) {
+			for (String monitorIp : monitorIps) {
+				addListeningPoint(monitorIp, sipConfig.getPort());
+			}
+			if (udpSipProviderMap.size() + tcpSipProviderMap.size() == 0) {
+				System.exit(1);
+			}
+		}
+	}
+
+	private void addListeningPoint(String monitorIp, int port){
+		SipStackImpl sipStack;
+		try {
+			sipStack = (SipStackImpl)SipFactory.getInstance().createSipStack(DefaultProperties.getProperties("GB28181_SIP", userSetting.getSipLog()));
+			sipStack.setMessageParserFactory(new GbStringMsgParserFactory());
+		} catch (PeerUnavailableException e) {
+			log.error("[SIP SERVER] SIP服务启动失败, 监听地址{}失败,请检查ip是否正确", monitorIp);
+			System.out.println(e.getMessage());
+			return;
+		}
+
+		try {
+			ListeningPoint tcpListeningPoint = sipStack.createListeningPoint(monitorIp, port, "TCP");
+			SipProviderImpl tcpSipProvider = (SipProviderImpl)sipStack.createSipProvider(tcpListeningPoint);
+
+			tcpSipProvider.setDialogErrorsAutomaticallyHandled();
+			tcpSipProvider.addSipListener(sipProcessorObserver);
+			tcpSipProviderMap.put(monitorIp, tcpSipProvider);
+			log.info("[SIP SERVER] tcp://{}:{} 启动成功", monitorIp, port);
+		} catch (TransportNotSupportedException
+				 | TooManyListenersException
+				 | ObjectInUseException
+				 | InvalidArgumentException e) {
+			log.error("[SIP SERVER] tcp://{}:{} SIP服务启动失败,请检查端口是否被占用或者ip是否正确"
+					, monitorIp, port);
+		}
+
+		try {
+			ListeningPoint udpListeningPoint = sipStack.createListeningPoint(monitorIp, port, "UDP");
+
+			SipProviderImpl udpSipProvider = (SipProviderImpl)sipStack.createSipProvider(udpListeningPoint);
+			udpSipProvider.addSipListener(sipProcessorObserver);
+			udpSipProvider.setDialogErrorsAutomaticallyHandled();
+			udpSipProviderMap.put(monitorIp, udpSipProvider);
+
+			log.info("[SIP SERVER] udp://{}:{} 启动成功", monitorIp, port);
+		} catch (TransportNotSupportedException
+				 | TooManyListenersException
+				 | ObjectInUseException
+				 | InvalidArgumentException e) {
+			log.error("[SIP SERVER] udp://{}:{} SIP服务启动失败,请检查端口是否被占用或者ip是否正确"
+					, monitorIp, port);
+		}
+	}
+
+	public SipProviderImpl getUdpSipProvider(String ip) {
+		if (udpSipProviderMap.size() == 1) {
+			return udpSipProviderMap.values().stream().findFirst().get();
+		}
+		if (ObjectUtils.isEmpty(ip)) {
+			return null;
+		}
+		return udpSipProviderMap.get(ip);
+	}
+
+	public SipProviderImpl getUdpSipProvider() {
+		if (udpSipProviderMap.size() != 1) {
+			return null;
+		}
+		return udpSipProviderMap.values().stream().findFirst().get();
+	}
+
+	public SipProviderImpl getTcpSipProvider() {
+		if (tcpSipProviderMap.size() != 1) {
+			return null;
+		}
+		return tcpSipProviderMap.values().stream().findFirst().get();
+	}
+
+	public SipProviderImpl getTcpSipProvider(String ip) {
+		if (tcpSipProviderMap.size() == 1) {
+			return tcpSipProviderMap.values().stream().findFirst().get();
+		}
+		if (ObjectUtils.isEmpty(ip)) {
+			return null;
+		}
+		return tcpSipProviderMap.get(ip);
+	}
+
+	public String getLocalIp(String deviceLocalIp) {
+		if (monitorIps.size() == 1) {
+			return monitorIps.get(0);
+		}
+		if (!ObjectUtils.isEmpty(deviceLocalIp)) {
+			return deviceLocalIp;
+		}
+		return getUdpSipProvider().getListeningPoint().getIPAddress();
+	}
+}

+ 235 - 0
video-service/src/main/java/com/ruoyi/wvp/gb28181/auth/DigestServerAuthenticationHelper.java

@@ -0,0 +1,235 @@
+/*
+ * Conditions Of Use
+ *
+ * This software was developed by employees of the National Institute of
+ * Standards and Technology (NIST), an agency of the Federal Government.
+ * Pursuant to title 15 Untied States Code Section 105, works of NIST
+ * employees are not subject to copyright protection in the United States
+ * and are considered to be in the public domain.  As a result, a formal
+ * license is not needed to use the software.
+ *
+ * This software is provided by NIST as a service and is expressly
+ * provided "AS IS."  NIST MAKES NO WARRANTY OF ANY KIND, EXPRESS, IMPLIED
+ * OR STATUTORY, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTY OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT
+ * AND DATA ACCURACY.  NIST does not warrant or make any representations
+ * regarding the use of the software or the results thereof, including but
+ * not limited to the correctness, accuracy, reliability or usefulness of
+ * the software.
+ *
+ * Permission to use this software is contingent upon your acceptance
+ * of the terms of this agreement
+ *
+ * .
+ *
+ */
+package com.ruoyi.wvp.gb28181.auth;
+
+import gov.nist.core.InternalErrorHandler;
+import lombok.extern.slf4j.Slf4j;
+
+import javax.sip.address.URI;
+import javax.sip.header.AuthorizationHeader;
+import javax.sip.header.HeaderFactory;
+import javax.sip.header.WWWAuthenticateHeader;
+import javax.sip.message.Request;
+import javax.sip.message.Response;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.util.Random;
+
+/**
+ * Implements the HTTP digest authentication method server side functionality.
+ *
+ * @author M. Ranganathan
+ * @author Marc Bednarek
+ */
+@Slf4j
+public class DigestServerAuthenticationHelper  {
+
+    private MessageDigest messageDigest;
+
+    public static final String DEFAULT_ALGORITHM = "MD5";
+    public static final String DEFAULT_SCHEME = "Digest";
+
+    /** to hex converter */
+    private static final char[] toHex = { '0', '1', '2', '3', '4', '5', '6',
+            '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' };
+
+    /**
+     * Default constructor.
+     * @throws NoSuchAlgorithmException
+     */
+    public DigestServerAuthenticationHelper()
+            throws NoSuchAlgorithmException {
+        messageDigest = MessageDigest.getInstance(DEFAULT_ALGORITHM);
+    }
+
+    public static String toHexString(byte b[]) {
+        int pos = 0;
+        char[] c = new char[b.length * 2];
+        for (int i = 0; i < b.length; i++) {
+            c[pos++] = toHex[(b[i] >> 4) & 0x0F];
+            c[pos++] = toHex[b[i] & 0x0f];
+        }
+        return new String(c);
+    }
+
+    /**
+     * Generate the challenge string.
+     *
+     * @return a generated nonce.
+     */
+    private String generateNonce() {
+        long time = Instant.now().toEpochMilli();
+        Random rand = new Random();
+        long pad = rand.nextLong();
+        String nonceString = Long.valueOf(time).toString()
+                + Long.valueOf(pad).toString();
+        byte mdbytes[] = messageDigest.digest(nonceString.getBytes());
+        return toHexString(mdbytes);
+    }
+
+    public Response generateChallenge(HeaderFactory headerFactory, Response response, String realm) {
+        try {
+            WWWAuthenticateHeader proxyAuthenticate = headerFactory
+                    .createWWWAuthenticateHeader(DEFAULT_SCHEME);
+            proxyAuthenticate.setParameter("realm", realm);
+            proxyAuthenticate.setParameter("qop", "auth");
+            proxyAuthenticate.setParameter("nonce", generateNonce());
+            proxyAuthenticate.setParameter("algorithm", DEFAULT_ALGORITHM);
+
+            response.setHeader(proxyAuthenticate);
+        } catch (Exception ex) {
+            InternalErrorHandler.handleException(ex);
+        }
+        return response;
+    }
+    /**
+     * Authenticate the inbound request.
+     *
+     * @param request - the request to authenticate.
+     * @param hashedPassword -- the MD5 hashed string of username:realm:plaintext password.
+     *
+     * @return true if authentication succeded and false otherwise.
+     */
+    public boolean doAuthenticateHashedPassword(Request request, String hashedPassword) {
+        AuthorizationHeader authHeader = (AuthorizationHeader) request.getHeader(AuthorizationHeader.NAME);
+        if ( authHeader == null ) {
+            return false;
+        }
+        String realm = authHeader.getRealm();
+        String username = authHeader.getUsername();
+
+        if ( username == null || realm == null ) {
+            return false;
+        }
+
+        String nonce = authHeader.getNonce();
+        URI uri = authHeader.getURI();
+        if (uri == null) {
+            return false;
+        }
+
+
+
+        String A2 = request.getMethod().toUpperCase() + ":" + uri.toString();
+        String HA1 = hashedPassword;
+
+
+        byte[] mdbytes = messageDigest.digest(A2.getBytes());
+        String HA2 = toHexString(mdbytes);
+
+        String cnonce = authHeader.getCNonce();
+        String KD = HA1 + ":" + nonce;
+        if (cnonce != null) {
+            KD += ":" + cnonce;
+        }
+        KD += ":" + HA2;
+        mdbytes = messageDigest.digest(KD.getBytes());
+        String mdString = toHexString(mdbytes);
+        String response = authHeader.getResponse();
+
+
+        return mdString.equals(response);
+    }
+
+    /**
+     * Authenticate the inbound request given plain text password.
+     *
+     * @param request - the request to authenticate.
+     * @param pass -- the plain text password.
+     *
+     * @return true if authentication succeded and false otherwise.
+     */
+    public boolean doAuthenticatePlainTextPassword(Request request, String pass) {
+        AuthorizationHeader authHeader = (AuthorizationHeader) request.getHeader(AuthorizationHeader.NAME);
+        if ( authHeader == null || authHeader.getRealm() == null) {
+            return false;
+        }
+        String realm = authHeader.getRealm().trim();
+        String username = authHeader.getUsername().trim();
+
+        if ( username == null || realm == null ) {
+            return false;
+        }
+
+        String nonce = authHeader.getNonce();
+        URI uri = authHeader.getURI();
+        if (uri == null) {
+            return false;
+        }
+        // qop 保护质量 包含auth(默认的)和auth-int(增加了报文完整性检测)两种策略
+        String qop = authHeader.getQop();
+
+        // 客户端随机数,这是一个不透明的字符串值,由客户端提供,并且客户端和服务器都会使用,以避免用明文文本。
+        // 这使得双方都可以查验对方的身份,并对消息的完整性提供一些保护
+        String cnonce = authHeader.getCNonce();
+
+        // nonce计数器,是一个16进制的数值,表示同一nonce下客户端发送出请求的数量
+        int nc = authHeader.getNonceCount();
+        String ncStr = String.format("%08x", nc).toUpperCase();
+        // String ncStr = new DecimalFormat("00000000").format(nc);
+        // String ncStr = new DecimalFormat("00000000").format(Integer.parseInt(nc + "", 16));
+
+        String A1 = username + ":" + realm + ":" + pass;
+
+        String A2 = request.getMethod().toUpperCase() + ":" + uri.toString();
+
+        byte mdbytes[] = messageDigest.digest(A1.getBytes());
+        String HA1 = toHexString(mdbytes);
+        log.debug("A1: " + A1);
+        log.debug("A2: " + A2);
+        mdbytes = messageDigest.digest(A2.getBytes());
+        String HA2 = toHexString(mdbytes);
+        log.debug("HA1: " + HA1);
+        log.debug("HA2: " + HA2);
+        // String cnonce = authHeader.getCNonce();
+        log.debug("nonce: " + nonce);
+        log.debug("nc: " + ncStr);
+        log.debug("cnonce: " + cnonce);
+        log.debug("qop: " + qop);
+        String KD = HA1 + ":" + nonce;
+
+        if (qop != null && qop.equalsIgnoreCase("auth") ) {
+            if (nc != -1) {
+                KD += ":" + ncStr;
+            }
+            if (cnonce != null) {
+                KD += ":" + cnonce;
+            }
+            KD += ":" + qop;
+        }
+        KD += ":" + HA2;
+        log.debug("KD: " + KD);
+        mdbytes = messageDigest.digest(KD.getBytes());
+        String mdString = toHexString(mdbytes);
+        log.debug("mdString: " + mdString);
+        String response = authHeader.getResponse();
+        log.debug("response: " + response);
+        return mdString.equals(response);
+
+    }
+
+}

+ 24 - 0
video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/BaiduPoint.java

@@ -0,0 +1,24 @@
+package com.ruoyi.wvp.gb28181.bean;
+
+public class BaiduPoint {
+
+    String bdLng;
+
+    String bdLat;
+
+    public String getBdLng() {
+        return bdLng;
+    }
+
+    public void setBdLng(String bdLng) {
+        this.bdLng = bdLng;
+    }
+
+    public String getBdLat() {
+        return bdLat;
+    }
+
+    public void setBdLat(String bdLat) {
+        this.bdLat = bdLat;
+    }
+}

+ 37 - 0
video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/CatalogChannelEvent.java

@@ -0,0 +1,37 @@
+package com.ruoyi.wvp.gb28181.bean;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.extern.slf4j.Slf4j;
+import org.dom4j.Element;
+
+import java.lang.reflect.InvocationTargetException;
+
+@Data
+@Slf4j
+@EqualsAndHashCode(callSuper = true)
+public class CatalogChannelEvent extends DeviceChannel{
+
+    private String event;
+
+    private DeviceChannel channel;
+
+    public static CatalogChannelEvent decode(Element element) throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
+        Element eventElement = element.element("Event");
+        CatalogChannelEvent catalogChannelEvent = new CatalogChannelEvent();
+        if (eventElement != null) {
+            catalogChannelEvent.setEvent(eventElement.getText());
+        }else {
+            catalogChannelEvent.setEvent("ADD");
+        }
+        DeviceChannel deviceChannel;
+        if ("ADD".equalsIgnoreCase(catalogChannelEvent.getEvent()) ||
+        "UPDATE".equalsIgnoreCase(catalogChannelEvent.getEvent()) ){
+            deviceChannel = DeviceChannel.decode(element);
+        }else {
+            deviceChannel = DeviceChannel.decodeWithOnlyDeviceId(element);
+        }
+        catalogChannelEvent.setChannel(deviceChannel);
+        return catalogChannelEvent;
+    }
+}

+ 30 - 0
video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/CatalogData.java

@@ -0,0 +1,30 @@
+package com.ruoyi.wvp.gb28181.bean;
+
+import lombok.Data;
+
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * @author lin
+ */
+@Data
+public class CatalogData {
+    /**
+     * 命令序列号
+     */
+    private int sn;
+    private Integer total;
+    private Instant time;
+    private Device device;
+    private String errorMsg;
+    private Set<String> redisKeysForChannel = new HashSet<>();
+    private Set<String> errorChannel = new HashSet<>();
+
+    public enum CatalogDataStatus{
+        ready, runIng, end
+    }
+    private CatalogDataStatus status;
+
+}

+ 8 - 0
video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/CmdType.java

@@ -0,0 +1,8 @@
+package com.ruoyi.wvp.gb28181.bean;
+
+public class CmdType {
+
+    public static final String CATALOG = "Catalog";
+    public static final String ALARM = "Alarm";
+    public static final String MOBILE_POSITION = "MobilePosition";
+}

+ 411 - 0
video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/CommonGBChannel.java

@@ -0,0 +1,411 @@
+package com.ruoyi.wvp.gb28181.bean;
+
+
+import lombok.Data;
+
+/**
+ * 国标通道
+ */
+@Data
+public class CommonGBChannel {
+
+    /**
+     * 国标-数据库自增ID
+     */
+    private int gbId;
+
+    /**
+     * 国标-编码
+     */
+    private String gbDeviceId;
+
+    /**
+     * 国标-名称
+     */
+    private String gbName;
+
+    /**
+     * 国标-设备厂商
+     */
+    private String gbManufacturer;
+
+    /**
+     * 国标-设备型号
+     */
+    private String gbModel;
+
+    /**
+     * 国标-设备归属
+     */
+    private String gbOwner;
+
+    /**
+     * 国标-行政区域
+     */
+    private String gbCivilCode;
+
+    /**
+     * 国标-警区
+     */
+    private String gbBlock;
+
+    /**
+     * 国标-安装地址
+     */
+    private String gbAddress;
+
+    /**
+     * 国标-是否有子设备
+     */
+    private Integer gbParental;
+
+    /**
+     * 国标-父节点ID
+     */
+    private String gbParentId;
+
+    /**
+     * 国标-信令安全模式
+     */
+    private Integer gbSafetyWay;
+
+    /**
+     * 国标-注册方式
+     */
+    private Integer gbRegisterWay;
+
+    /**
+     * 国标-证书序列号
+     */
+    private String gbCertNum;
+
+    /**
+     * 国标-证书有效标识
+     */
+    private Integer gbCertifiable;
+
+    /**
+     * 国标-无效原因码(有证书且证书无效的设备必选)
+     */
+    private Integer gbErrCode;
+
+    /**
+     * 国标-证书终止有效期(有证书且证书无效的设备必选)
+     */
+    private String gbEndTime;
+
+    /**
+     * 国标-保密属性(必选)缺省为0;0-不涉密,1-涉密
+     */
+    private Integer gbSecrecy;
+
+    /**
+     * 国标-设备/系统IPv4/IPv6地址
+     */
+    private String gbIpAddress;
+
+    /**
+     * 国标-设备/系统端口
+     */
+    private Integer gbPort;
+
+    /**
+     * 国标-设备口令
+     */
+    private String gbPassword;
+
+    /**
+     * 国标-设备状态
+     */
+    private String gbStatus;
+
+    /**
+     * 国标-经度 WGS-84坐标系
+     */
+    private Double gbLongitude;
+
+    /**
+     * 国标-纬度 WGS-84坐标系
+     */
+    private Double gbLatitude;
+
+    /**
+     * 国标-虚拟组织所属的业务分组ID
+     */
+    private String gbBusinessGroupId;
+
+    /**
+     * 国标-摄像机结构类型,标识摄像机类型: 1-球机; 2-半球; 3-固定枪机; 4-遥控枪机;5-遥控半球;6-多目设备的全景/拼接通道;7-多目设备的分割通道
+     */
+    private Integer gbPtzType;
+
+    /**
+     * 摄像机位置类型扩展。1-省际检查站、2-党政机关、3-车站码头、4-中心广场、5-体育场馆、6-商业中心、7-宗教场所、
+     * 8-校园周边、9-治安复杂区域、10-交通干线。当目录项为摄像机时可选。
+     */
+    private Integer gbPositionType;
+
+    /**
+     * 国标-摄像机安装位置室外、室内属性。1-室外、2-室内。
+     */
+    private Integer gbRoomType;
+
+    /**
+     * 国标-用途属性
+     */
+    private Integer gbUseType;
+
+    /**
+     * 国标-摄像机补光属性。1-无补光;2-红外补光;3-白光补光;4-激光补光;9-其他
+     */
+    private Integer gbSupplyLightType;
+
+    /**
+     * 国标-摄像机监视方位(光轴方向)属性。1-东(西向东)、2-西(东向西)、3-南(北向南)、4-北(南向北)、
+     * 5-东南(西北到东南)、6-东北(西南到东北)、7-西南(东北到西南)、8-西北(东南到西北)
+     */
+    private Integer gbDirectionType;
+
+    /**
+     * 国标-摄像机支持的分辨率,可多值
+     */
+    private String gbResolution;
+
+    /**
+     * 国标-下载倍速(可选),可多值
+     */
+    private String gbDownloadSpeed;
+
+    /**
+     * 国标-空域编码能力,取值0-不支持;1-1级增强(1个增强层);2-2级增强(2个增强层);3-3级增强(3个增强层)
+     */
+    private Integer gbSvcSpaceSupportMod;
+
+    /**
+     * 国标-时域编码能力,取值0-不支持;1-1级增强;2-2级增强;3-3级增强(可选)
+     */
+    private Integer gbSvcTimeSupportMode;
+
+    /**
+     * 二进制保存的录制计划, 每一位表示每个小时的前半个小时
+     */
+    private Long recordPLan;
+
+    /**
+     * 关联的数据类型
+     */
+    private Integer dataType;
+
+    /**
+     * 关联的设备ID
+     */
+    private Integer dataDeviceId;
+
+    /**
+     * 创建时间
+     */
+    private String createTime;
+
+    /**
+     * 更新时间
+     */
+    private String updateTime;
+
+    public String encode(String serverDeviceId) {
+        return encode(null, serverDeviceId);
+    }
+
+    public String encode(String event,String serverDeviceId) {
+        String content;
+        if (event == null) {
+            return getFullContent(null, serverDeviceId);
+        }
+        switch (event) {
+            case "DEL":
+            case "DEFECT":
+            case "VLOST":
+                content = "<Item>\n" +
+                        "<DeviceID>" + this.getGbDeviceId() + "</DeviceID>\n" +
+                        "<Event>" + event + "</Event>\n" +
+                        "</Item>\n";
+                break;
+            case "ON":
+            case "OFF":
+                content = "<Item>\n" +
+                        "<DeviceID>" + this.getGbDeviceId() + "</DeviceID>\n" +
+                        "<Event>" + event + "</Event>\r\n" +
+                        "</Item>\n";
+                break;
+            case "ADD":
+            case "UPDATE":
+                content = getFullContent(event, serverDeviceId);
+                break;
+            default:
+                content = null;
+                break;
+        }
+        return content;
+    }
+
+    private String getFullContent(String event, String serverDeviceId) {
+        StringBuilder content = new StringBuilder();
+        // 行政区划目录项
+        content.append("<Item>\n")
+                .append("<DeviceID>" + this.getGbDeviceId() + "</DeviceID>\n")
+                .append("<Name>" + this.getGbName() + "</Name>\n");
+
+
+        if (this.getGbDeviceId().length() > 8) {
+
+            String type = this.getGbDeviceId().substring(10, 13);
+            if (type.equals("200")) {
+                // 业务分组目录项
+                if (this.getGbManufacturer() != null) {
+                    content.append("<Manufacturer>" + this.getGbManufacturer() + "</Manufacturer>\n");
+                }
+                if (this.getGbModel() != null) {
+                    content.append("<Model>" + this.getGbModel() + "</Model>\n");
+                }
+                if (this.getGbOwner() != null) {
+                    content.append("<Owner>" + this.getGbOwner() + "</Owner>\n");
+                }
+                if (this.getGbCivilCode() != null) {
+                    content.append("<CivilCode>" + this.getGbCivilCode() + "</CivilCode>\n");
+                }
+                if (this.getGbAddress() != null) {
+                    content.append("<Address>" + this.getGbAddress() + "</Address>\n");
+                }
+                if (this.getGbRegisterWay() != null) {
+                    content.append("<RegisterWay>" + this.getGbRegisterWay() + "</RegisterWay>\n");
+                }
+                if (this.getGbSecrecy() != null) {
+                    content.append("<Secrecy>" + this.getGbSecrecy() + "</Secrecy>\n");
+                }
+            } else if (type.equals("215")) {
+                // 业务分组
+                if (this.getGbCivilCode() != null) {
+                    content.append("<CivilCode>" + this.getGbCivilCode() + "</CivilCode>\n");
+                }
+                content.append("<ParentID>" + serverDeviceId + "</ParentID>\n");
+            } else if (type.equals("216")) {
+                // 虚拟组织目录项
+                if (this.getGbCivilCode() != null) {
+                    content.append("<CivilCode>" + this.getGbCivilCode() + "</CivilCode>\n");
+                }
+                if (this.getGbParentId() != null) {
+                    content.append("<ParentID>" + this.getGbParentId() + "</ParentID>\n");
+                }
+                content.append("<BusinessGroupID>" + this.getGbBusinessGroupId() + "</BusinessGroupID>\n");
+            } else {
+                if (this.getGbManufacturer() != null) {
+                    content.append("<Manufacturer>" + this.getGbManufacturer() + "</Manufacturer>\n");
+                }
+                if (this.getGbModel() != null) {
+                    content.append("<Model>" + this.getGbModel() + "</Model>\n");
+                }
+                if (this.getGbOwner() != null) {
+                    content.append("<Owner>" + this.getGbOwner() + "</Owner>\n");
+                }
+                if (this.getGbCivilCode() != null) {
+                    content.append("<CivilCode>" + this.getGbCivilCode() + "</CivilCode>\n");
+                }
+                if (this.getGbAddress() != null) {
+                    content.append("<Address>" + this.getGbAddress() + "</Address>\n");
+                }
+                if (this.getGbRegisterWay() != null) {
+                    content.append("<RegisterWay>" + this.getGbRegisterWay() + "</RegisterWay>\n");
+                }
+                if (this.getGbSecrecy() != null) {
+                    content.append("<Secrecy>" + this.getGbSecrecy() + "</Secrecy>\n");
+                }
+                if (this.getGbParentId() != null) {
+                    content.append("<ParentID>" + this.getGbParentId() + "</ParentID>\n");
+                }
+                if (this.getGbParental() != null) {
+                    content.append("<Parental>" + this.getGbParental() + "</Parental>\n");
+                }
+                if (this.getGbSafetyWay() != null) {
+                    content.append("<SafetyWay>" + this.getGbSafetyWay() + "</SafetyWay>\n");
+                }
+                if (this.getGbRegisterWay() != null) {
+                    content.append("<RegisterWay>" + this.getGbRegisterWay() + "</RegisterWay>\n");
+                }
+                if (this.getGbCertNum() != null) {
+                    content.append("<CertNum>" + this.getGbCertNum() + "</CertNum>\n");
+                }
+                if (this.getGbCertifiable() != null) {
+                    content.append("<Certifiable>" + this.getGbCertifiable() + "</Certifiable>\n");
+                }
+                if (this.getGbErrCode() != null) {
+                    content.append("<ErrCode>" + this.getGbErrCode() + "</ErrCode>\n");
+                }
+                if (this.getGbEndTime() != null) {
+                    content.append("<EndTime>" + this.getGbEndTime() + "</EndTime>\n");
+                }
+                if (this.getGbSecrecy() != null) {
+                    content.append("<Secrecy>" + this.getGbSecrecy() + "</Secrecy>\n");
+                }
+                if (this.getGbIpAddress() != null) {
+                    content.append("<IPAddress>" + this.getGbIpAddress() + "</IPAddress>\n");
+                }
+                if (this.getGbPort() != null) {
+                    content.append("<Port>" + this.getGbPort() + "</Port>\n");
+                }
+                if (this.getGbPassword() != null) {
+                    content.append("<Password>" + this.getGbPassword() + "</Password>\n");
+                }
+                if (this.getGbStatus() != null) {
+                    content.append("<Status>" + this.getGbStatus() + "</Status>\n");
+                }
+                if (this.getGbLongitude() != null) {
+                    content.append("<Longitude>" + this.getGbLongitude() + "</Longitude>\n");
+                }
+                if (this.getGbLatitude() != null) {
+                    content.append("<Latitude>" + this.getGbLatitude() + "</Latitude>\n");
+                }
+                content.append("<Info>\n");
+
+                if (this.getGbPtzType() != null) {
+                    content.append("  <PTZType>" + this.getGbPtzType() + "</PTZType>\n");
+                }
+                if (this.getGbPositionType() != null) {
+                    content.append("  <PositionType>" + this.getGbPositionType() + "</PositionType>\n");
+                }
+                if (this.getGbRoomType() != null) {
+                    content.append("  <RoomType>" + this.getGbRoomType() + "</RoomType>\n");
+                }
+                if (this.getGbUseType() != null) {
+                    content.append("  <UseType>" + this.getGbUseType() + "</UseType>\n");
+                }
+                if (this.getGbSupplyLightType() != null) {
+                    content.append("  <SupplyLightType>" + this.getGbSupplyLightType() + "</SupplyLightType>\n");
+                }
+                if (this.getGbDirectionType() != null) {
+                    content.append("  <DirectionType>" + this.getGbDirectionType() + "</DirectionType>\n");
+                }
+                if (this.getGbResolution() != null) {
+                    content.append("  <Resolution>" + this.getGbResolution() + "</Resolution>\n");
+                }
+                if (this.getGbBusinessGroupId() != null) {
+                    content.append("  <BusinessGroupID>" + this.getGbBusinessGroupId() + "</BusinessGroupID>\n");
+                }
+                if (this.getGbDownloadSpeed() != null) {
+                    content.append("  <DownloadSpeed>" + this.getGbDownloadSpeed() + "</DownloadSpeed>\n");
+                }
+                if (this.getGbSvcSpaceSupportMod() != null) {
+                    content.append("  <SVCSpaceSupportMode>" + this.getGbSvcSpaceSupportMod() + "</SVCSpaceSupportMode>\n");
+                }
+                if (this.getGbSvcTimeSupportMode() != null) {
+                    content.append("  <SVCTimeSupportMode>" + this.getGbSvcTimeSupportMode() + "</SVCTimeSupportMode>\n");
+                }
+                content.append("</Info>\n");
+            }
+        }
+        if (event != null) {
+            content.append("<Event>" + event + "</Event>\n");
+        }
+        content.append("</Item>\n");
+        return content.toString();
+    }
+
+}

+ 235 - 0
video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/Device.java

@@ -0,0 +1,235 @@
+package com.ruoyi.wvp.gb28181.bean;
+
+
+import com.zksy.common.core.domain.WvpBaseEntity;
+import lombok.Data;
+
+/**
+ * 国标设备/平台
+ *
+ * @author lin
+ */
+@Data
+public class Device extends WvpBaseEntity {
+
+    /**
+     * 当前页码
+     */
+    private int pageNum;
+
+    /**
+     * 每页大小
+     */
+    private int pageSize;
+
+    /**
+     * 部门ID
+     */
+    private Long deptId;
+
+    /**
+     * 部门名称
+     */
+    private String deptName;
+
+    /**
+     * 国标-数据库自增ID
+     */
+    private int id;
+
+    /**
+     * 设备国标编号
+     */
+    private String deviceId;
+
+    /**
+     * 设备名
+     */
+    private String name;
+
+    /**
+     * 生产厂商
+     */
+    private String manufacturer;
+
+    /**
+     * 型号
+     */
+    private String model;
+
+    /**
+     * 固件版本
+     */
+    private String firmware;
+
+    /**
+     * 在线状态
+     */
+    private String status;
+
+    /**
+     * 传输协议
+     * UDP/TCP
+     */
+    private String transport;
+
+    /**
+     * 数据流传输模式
+     * UDP:udp传输
+     * TCP-ACTIVE:tcp主动模式
+     * TCP-PASSIVE:tcp被动模式
+     */
+    private String streamMode;
+
+    /**
+     * wan地址_ip
+     */
+    private String ip;
+
+    /**
+     * wan地址_port
+     */
+    private int port;
+
+    /**
+     * wan地址
+     */
+    private String hostAddress;
+
+    /**
+     * 在线
+     * 是否在线,true为在线,false为离线
+     */
+    private boolean onLine;
+
+    /**
+     * 注册时间
+     */
+    private String registerTime;
+
+    /**
+     * 心跳时间
+     */
+    private String keepaliveTime;
+
+    /**
+     * 心跳间隔
+     */
+    private Integer heartBeatInterval;
+
+    /**
+     * 心跳超时次数
+     */
+    private Integer heartBeatCount;
+
+    /**
+     * 定位功能支持情况
+     * 取值:0-不支持;1-支持 GPS定位;2-支持北斗定位(可选,默认取值为0)
+     */
+    private Integer positionCapability;
+
+    /**
+     * 通道个数
+     */
+    private int channelCount;
+
+    /**
+     * 注册有效期
+     */
+    private int expires;
+
+    /**
+     * 创建时间
+     */
+    private String createTime;
+
+    /**
+     * 更新时间
+     */
+    private String updateTime;
+
+    /**
+     * 设备使用的媒体id, 默认为null
+     */
+    private String mediaServerId;
+
+    /**
+     * 字符集, 支持 UTF-8 与 GB2312
+     */
+    private String charset;
+
+    /**
+     * 目录订阅周期,0为不订阅
+     */
+    private int subscribeCycleForCatalog;
+
+    /**
+     * 移动设备位置订阅周期,0为不订阅
+     */
+    private int subscribeCycleForMobilePosition;
+
+    /**
+     * 移动设备位置信息上报时间间隔,单位:秒,默认值5
+     */
+    private int mobilePositionSubmissionInterval = 5;
+
+    /**
+     * 报警订阅周期,0为不订阅
+     */
+    private int subscribeCycleForAlarm;
+
+    /**
+     * 是否开启ssrc校验,默认关闭,开启可以防止串流
+     */
+    private boolean ssrcCheck = false;
+
+    /**
+     * 地理坐标系, 目前支持 WGS84,GCJ02, 此字段保留,暂无用
+     */
+    private String geoCoordSys;
+
+    /**
+     * 密码
+     */
+    private String password;
+
+    /**
+     * 收流IP
+     */
+    private String sdpIp;
+
+    /**
+     * SIP交互IP(设备访问平台的IP)
+     */
+    private String localIp;
+
+    /**
+     * 是否作为消息通道
+     */
+    private boolean asMessageChannel;
+
+    /**
+     * 设备注册的事务信息
+     */
+    private SipTransactionInfo sipTransactionInfo;
+
+    /**
+     * 控制语音对讲流程,释放收到ACK后发流
+     */
+    private boolean broadcastPushAfterAck;
+
+    /**
+     * 纬度
+     */
+    private String lat;
+
+    /**
+     * 经度
+     */
+    private String lng;
+
+    /**
+     * 地图定位地址
+     */
+    private String addressMap;
+}

+ 322 - 0
video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/DeviceChannel.java

@@ -0,0 +1,322 @@
+package com.ruoyi.wvp.gb28181.bean;
+
+import com.ruoyi.wvp.common.enums.ChannelDataType;
+import com.ruoyi.wvp.gb28181.utils.MessageElementForCatalog;
+import com.ruoyi.wvp.gb28181.utils.XmlUtil;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.extern.slf4j.Slf4j;
+import org.dom4j.Element;
+import org.springframework.util.ObjectUtils;
+
+import java.lang.reflect.InvocationTargetException;
+
+/**
+ * 通道信息
+ */
+@Data
+@Slf4j
+@EqualsAndHashCode(callSuper = true)
+public class DeviceChannel extends CommonGBChannel {
+
+    /**
+     * 国标-数据库自增ID
+     */
+    private int id;
+
+    /**
+     * 国标-编码
+     */
+    @MessageElementForCatalog("DeviceID")
+    private String deviceId;
+
+    /**
+     * 国标-名称
+     */
+    @MessageElementForCatalog("Name")
+    private String name;
+
+    /**
+     * 国标-设备厂商
+     */
+    @MessageElementForCatalog("Manufacturer")
+    private String manufacturer;
+
+    /**
+     * 国标-设备型号
+     */
+    @MessageElementForCatalog("Model")
+    private String model;
+
+    /**
+     * 国标-设备归属
+     */
+    @MessageElementForCatalog("Owner")
+    private String owner;
+
+    /**
+     * 国标-行政区域
+     */
+    @MessageElementForCatalog("CivilCode")
+    private String civilCode;
+
+    /**
+     * 国标-警区
+     */
+    @MessageElementForCatalog("Block")
+    private String block;
+
+    /**
+     * 国标-安装地址
+     */
+    @MessageElementForCatalog("Address")
+    private String address;
+
+    /**
+     * 国标-是否有子设备(必选)1有,0没有
+     */
+    @MessageElementForCatalog("Parental")
+    private Integer parental;
+
+    /**
+     * 国标-父节点ID
+     */
+    @MessageElementForCatalog("ParentID")
+    private String parentId;
+
+    /**
+     * 国标-信令安全模式
+     */
+    @MessageElementForCatalog("SafetyWay")
+    private Integer safetyWay;
+
+    /**
+     * 国标-注册方式
+     */
+    @MessageElementForCatalog("RegisterWay")
+    private Integer registerWay;
+
+    /**
+     * 国标-证书序列号
+     */
+    @MessageElementForCatalog("CertNum")
+    private String certNum;
+
+    /**
+     * 国标-证书有效标识, 缺省为0;证书有效标识:0:无效 1:有效
+     */
+    @MessageElementForCatalog("Certifiable")
+    private Integer certifiable;
+
+    /**
+     * 国标-无效原因码(有证书且证书无效的设备必选)
+     */
+    @MessageElementForCatalog("ErrCode")
+    private Integer errCode;
+
+    /**
+     * 国标-证书终止有效期(有证书且证书无效的设备必选)
+     */
+    @MessageElementForCatalog("EndTime")
+    private String endTime;
+
+    /**
+     * 国标-保密属性(必选)缺省为0;0-不涉密,1-涉密
+     */
+    @MessageElementForCatalog("Secrecy")
+    private Integer secrecy;
+
+    /**
+     * 国标-设备/系统IPv4/IPv6地址
+     */
+    @MessageElementForCatalog("IPAddress")
+    private String ipAddress;
+
+    /**
+     * 国标-设备/系统端口
+     */
+    @MessageElementForCatalog("Port")
+    private Integer port;
+
+    /**
+     * 国标-设备口令
+     */
+    @MessageElementForCatalog("Password")
+    private String password;
+
+    /**
+     * 国标-设备状态
+     */
+    @MessageElementForCatalog("Status")
+    private String status;
+
+    /**
+     * 国标-经度 WGS-84坐标系
+     */
+    @MessageElementForCatalog("Longitude")
+    private Double longitude;
+
+    /**
+     * 国标-纬度 WGS-84坐标系
+     */
+    @MessageElementForCatalog("Latitude")
+    private Double latitude;
+
+    /**
+     * 国标-摄像机结构类型,标识摄像机类型: 1-球机; 2-半球; 3-固定枪机; 4-遥控枪机;5-遥控半球;6-多目设备的全景/拼接通道;7-多目设备的分割通道
+     */
+    @MessageElementForCatalog("Info.PTZType")
+    private Integer ptzType;
+
+    /**
+     * 国标-摄像机位置类型扩展。1-省际检查站、2-党政机关、3-车站码头、4-中心广场、5-体育场馆、6-商业中心、7-宗教场所、8-校园周边、9-治安复杂区域、10-交通干线
+     */
+    @MessageElementForCatalog("Info.PositionType")
+    private Integer positionType;
+
+    /**
+     * 国标-摄像机安装位置室外、室内属性。1-室外、2-室内。
+     */
+    @MessageElementForCatalog("Info.RoomType")
+    private Integer roomType;
+
+    /**
+     * 国标-用途属性, 1-治安、2-交通、3-重点。
+     */
+    @MessageElementForCatalog("Info.UseType")
+    private Integer useType;
+
+    /**
+     * 国标-摄像机补光属性。1-无补光;2-红外补光;3-白光补光;4-激光补光;9-其他
+     */
+    @MessageElementForCatalog("Info.SupplyLightType")
+    private Integer supplyLightType;
+
+    /**
+     * 国标-摄像机监视方位(光轴方向)属性。1-东(西向东)、2-西(东向西)、3-南(北向南)、4-北(南向北)、5-东南(西北到东南)、6-东北(西南到东北)、7-西南(东北到西南)、8-西北(东南到西北)
+     */
+    @MessageElementForCatalog("Info.DirectionType")
+    private Integer directionType;
+
+    /**
+     * 国标-摄像机支持的分辨率,可多值
+     */
+    @MessageElementForCatalog("Info.Resolution")
+    private String resolution;
+
+    /**
+     * 国标-虚拟组织所属的业务分组ID
+     */
+    @MessageElementForCatalog({"BusinessGroupID", "Info.BusinessGroupID"})
+    private String businessGroupId;
+
+    /**
+     * 国标-下载倍速(可选),可多值
+     */
+    @MessageElementForCatalog("Info.DownloadSpeed")
+    private String downloadSpeed;
+
+    /**
+     * 国标-空域编码能力,取值0-不支持;1-1级增强(1个增强层);2-2级增强(2个增强层);3-3级增强(3个增强层)
+     */
+    @MessageElementForCatalog("Info.SVCSpaceSupportMode")
+    private Integer svcSpaceSupportMod;
+
+    /**
+     * 国标-时域编码能力,取值0-不支持;1-1级增强;2-2级增强;3-3级增强(可选)
+     */
+    @MessageElementForCatalog("Info.SVCTimeSupportMode")
+    private Integer svcTimeSupportMode;
+
+    /**
+     * 国标-云台类型描述字符串
+     */
+    private String ptzTypeText;
+
+    /**
+     * 国标-子设备数
+     */
+    private int subCount;
+
+    /**
+     * 国标-流唯一编号,存在表示正在直播
+     */
+    private String streamId;
+
+    /**
+     * 国标-是否含有音频
+     */
+    private boolean hasAudio;
+
+    /**
+     * 国标-GPS的更新时间
+     */
+    private String gpsTime;
+
+    /**
+     * 国标-码流标识,优先级高于设备中码流标识,用于选择码流时组成码流标识。默认为null,不设置。可选值: stream/streamnumber/streamprofile/streamMode
+     */
+    private String streamIdentification;
+
+    /**
+     * 国标-通道类型, 默认0, 0: 普通通道,1 行政区划 2 业务分组/虚拟组织
+     */
+    private int channelType;
+
+    private Integer dataType = ChannelDataType.GB28181.value;
+
+    public void setPtzType(int ptzType) {
+        this.ptzType = ptzType;
+        switch (ptzType) {
+            case 0:
+                this.ptzTypeText = "未知";
+                break;
+            case 1:
+                this.ptzTypeText = "球机";
+                break;
+            case 2:
+                this.ptzTypeText = "半球";
+                break;
+            case 3:
+                this.ptzTypeText = "固定枪机";
+                break;
+            case 4:
+                this.ptzTypeText = "遥控枪机";
+                break;
+            case 5:
+                this.ptzTypeText = "遥控半球";
+                break;
+            case 6:
+                this.ptzTypeText = "多目设备的全景/拼接通道";
+                break;
+            case 7:
+                this.ptzTypeText = "多目设备的分割通道";
+                break;
+        }
+    }
+
+    public static DeviceChannel decode(Element element) throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
+        DeviceChannel deviceChannel = XmlUtil.elementDecode(element, DeviceChannel.class);
+        if (deviceChannel.getCivilCode() != null) {
+            if (ObjectUtils.isEmpty(deviceChannel.getCivilCode())
+                    || deviceChannel.getCivilCode().length() > 8) {
+                deviceChannel.setCivilCode(null);
+            }
+            // 此处对于不在wvp缓存中的行政区划,默认直接存储.保证即使出现wvp的行政区划缓存过老,也可以通过用户自主创建的方式正常使用系统
+        }
+        GbCode gbCode = GbCode.decode(deviceChannel.getDeviceId());
+        if (gbCode != null && "138".equals(gbCode.getTypeCode())) {
+            deviceChannel.setHasAudio(true);
+        }
+        return deviceChannel;
+    }
+
+    public static DeviceChannel decodeWithOnlyDeviceId(Element element) {
+        Element deviceElement = element.element("DeviceID");
+        DeviceChannel deviceChannel = new DeviceChannel();
+        deviceChannel.setDeviceId(deviceElement.getText());
+        return deviceChannel;
+    }
+
+
+}

+ 27 - 0
video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/DeviceNotFoundEvent.java

@@ -0,0 +1,27 @@
+package com.ruoyi.wvp.gb28181.bean;
+
+import javax.sip.Dialog;
+import java.util.EventObject;
+
+public class DeviceNotFoundEvent extends EventObject {
+
+    private String callId;
+
+    /**
+     * Constructs a prototypical Event.
+     *
+     * @param dialog
+     * @throws IllegalArgumentException if source is null.
+     */
+    public DeviceNotFoundEvent(Dialog dialog) {
+        super(dialog);
+    }
+
+    public String getCallId() {
+        return callId;
+    }
+
+    public void setCallId(String callId) {
+        this.callId = callId;
+    }
+}

+ 430 - 0
video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/GBStringMsgParser.java

@@ -0,0 +1,430 @@
+package com.ruoyi.wvp.gb28181.bean;
+
+
+import gov.nist.core.Host;
+import gov.nist.core.HostNameParser;
+import gov.nist.javax.sip.SIPConstants;
+import gov.nist.javax.sip.address.AddressImpl;
+import gov.nist.javax.sip.address.GenericURI;
+import gov.nist.javax.sip.address.SipUri;
+import gov.nist.javax.sip.address.TelephoneNumber;
+import gov.nist.javax.sip.header.*;
+import gov.nist.javax.sip.message.SIPMessage;
+import gov.nist.javax.sip.message.SIPRequest;
+import gov.nist.javax.sip.message.SIPResponse;
+import gov.nist.javax.sip.parser.*;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.UnsupportedEncodingException;
+import java.text.ParseException;
+
+@Slf4j
+public class GBStringMsgParser implements MessageParser {
+
+    protected static boolean computeContentLengthFromMessage = false;
+
+    /**
+     * @since v0.9
+     */
+    public GBStringMsgParser() {
+        super();
+    }
+
+    /**
+     * Parse a buffer containing a single SIP Message where the body is an array
+     * of un-interpreted bytes. This is intended for parsing the message from a
+     * memory buffer when the buffer. Incorporates a bug fix for a bug that was
+     * noted by Will Sullin of Callcast
+     *
+     * @param msgBuffer a byte buffer containing the messages to be parsed. This can
+     *                  consist of multiple SIP Messages concatenated together.
+     * @return a SIPMessage[] structure (request or response) containing the
+     * parsed SIP message.
+     * @throws ParseException is thrown when an illegal message has been encountered
+     *                        (and the rest of the buffer is discarded).
+     * @see ParseExceptionListener
+     */
+    public SIPMessage parseSIPMessage(byte[] msgBuffer, boolean readBody, boolean strict, ParseExceptionListener parseExceptionListener) throws ParseException {
+        if (msgBuffer == null || msgBuffer.length == 0)
+            return null;
+
+        int i = 0;
+
+        // Squeeze out any leading control character.
+        try {
+            while (msgBuffer[i] < 0x20)
+                i++;
+        } catch (ArrayIndexOutOfBoundsException e) {
+            // Array contains only control char, return null.
+            if (log.isDebugEnabled()) {
+                log.debug("handled only control char so returning null");
+            }
+            return null;
+        }
+
+        // Iterate thru the request/status line and headers.
+        String currentLine = null;
+        String currentHeader = null;
+        boolean isFirstLine = true;
+        SIPMessage message = null;
+        do {
+            int lineStart = i;
+
+            // Find the length of the line.
+            try {
+                while (msgBuffer[i] != '\r' && msgBuffer[i] != '\n')
+                    i++;
+            } catch (ArrayIndexOutOfBoundsException e) {
+                // End of the message.
+                break;
+            }
+            int lineLength = i - lineStart;
+
+            // Make it a String.
+            try {
+                currentLine = new String(msgBuffer, lineStart, lineLength, "UTF-8");
+            } catch (UnsupportedEncodingException e) {
+                throw new ParseException("Bad message encoding!", 0);
+            }
+
+            currentLine = trimEndOfLine(currentLine);
+
+            if (currentLine.length() == 0) {
+                // Last header line, process the previous buffered header.
+                if (currentHeader != null && message != null) {
+                    processHeader(currentHeader, message, parseExceptionListener, msgBuffer);
+                }
+
+            } else {
+                if (isFirstLine) {
+                    message = processFirstLine(currentLine, parseExceptionListener, msgBuffer);
+                } else {
+                    char firstChar = currentLine.charAt(0);
+                    if (firstChar == '\t' || firstChar == ' ') {
+                        if (currentHeader == null)
+                            throw new ParseException("Bad header continuation.", 0);
+
+                        // This is a continuation, append it to the previous line.
+                        currentHeader += currentLine.substring(1);
+                    } else {
+                        if (currentHeader != null && message != null) {
+                            processHeader(currentHeader, message, parseExceptionListener, msgBuffer);
+                        }
+                        currentHeader = currentLine;
+                    }
+                }
+            }
+
+            if (msgBuffer[i] == '\r' && msgBuffer.length > i + 1 && msgBuffer[i + 1] == '\n')
+                i++;
+
+            i++;
+
+            isFirstLine = false;
+        } while (currentLine.length() > 0); // End do - while
+
+        if (message == null) throw new ParseException("Bad message", 0);
+        message.setSize(i);
+
+        // Check for content legth header
+        if (readBody && message.getContentLength() != null) {
+            if (message.getContentLength().getContentLength() != 0) {
+                int bodyLength = msgBuffer.length - i;
+
+                byte[] body = new byte[bodyLength];
+                System.arraycopy(msgBuffer, i, body, 0, bodyLength);
+                message.setMessageContent(body, !strict, computeContentLengthFromMessage, message.getContentLength().getContentLength());
+            } else if (message.getCSeqHeader().getMethod().equalsIgnoreCase("MESSAGE")) {
+                int bodyLength = msgBuffer.length - i;
+
+                byte[] body = new byte[bodyLength];
+                System.arraycopy(msgBuffer, i, body, 0, bodyLength);
+                message.setMessageContent(body, !strict, computeContentLengthFromMessage, bodyLength);
+            } else if (!computeContentLengthFromMessage && strict) {
+                String last4Chars = new String(msgBuffer, msgBuffer.length - 4, 4);
+                if (!"\r\n\r\n".equals(last4Chars)) {
+                    throw new ParseException("Extraneous characters at the end of the message ", i);
+                }
+            }
+        }
+
+        return message;
+    }
+
+    protected static String trimEndOfLine(String line) {
+        if (line == null)
+            return line;
+
+        int i = line.length() - 1;
+        while (i >= 0 && line.charAt(i) <= 0x20)
+            i--;
+
+        if (i == line.length() - 1)
+            return line;
+
+        if (i == -1)
+            return "";
+
+        return line.substring(0, i + 1);
+    }
+
+    protected SIPMessage processFirstLine(String firstLine, ParseExceptionListener parseExceptionListener, byte[] msgBuffer) throws ParseException {
+        SIPMessage message;
+        if (!firstLine.startsWith(SIPConstants.SIP_VERSION_STRING)) {
+            message = new SIPRequest();
+            try {
+                RequestLine requestLine = new RequestLineParser(firstLine + "\n")
+                        .parse();
+                ((SIPRequest) message).setRequestLine(requestLine);
+            } catch (ParseException ex) {
+                if (parseExceptionListener != null)
+                    try {
+                        parseExceptionListener.handleException(ex, message,
+                                RequestLine.class, firstLine, new String(msgBuffer, "UTF-8"));
+                    } catch (UnsupportedEncodingException e) {
+                        e.printStackTrace();
+                    }
+                else
+                    throw ex;
+
+            }
+        } else {
+            message = new SIPResponse();
+            try {
+                StatusLine sl = new StatusLineParser(firstLine + "\n").parse();
+                ((SIPResponse) message).setStatusLine(sl);
+            } catch (ParseException ex) {
+                if (parseExceptionListener != null) {
+                    try {
+                        parseExceptionListener.handleException(ex, message,
+                                StatusLine.class, firstLine, new String(msgBuffer, "UTF-8"));
+                    } catch (UnsupportedEncodingException e) {
+                        e.printStackTrace();
+                    }
+                } else
+                    throw ex;
+
+            }
+        }
+        return message;
+    }
+
+    protected void processHeader(String header, SIPMessage message, ParseExceptionListener parseExceptionListener, byte[] rawMessage) throws ParseException {
+        if (header == null || header.length() == 0)
+            return;
+
+        HeaderParser headerParser = null;
+        try {
+            headerParser = ParserFactory.createParser(header + "\n");
+        } catch (ParseException ex) {
+            // https://java.net/jira/browse/JSIP-456
+            if (parseExceptionListener != null) {
+                parseExceptionListener.handleException(ex, message, null,
+                        header, null);
+                return;
+            } else {
+                throw ex;
+            }
+        }
+
+        try {
+            SIPHeader sipHeader = headerParser.parse();
+            message.attachHeader(sipHeader, false);
+        } catch (ParseException ex) {
+            if (parseExceptionListener != null) {
+                String headerName = Lexer.getHeaderName(header);
+                Class headerClass = NameMap.getClassFromName(headerName);
+                if (headerClass == null) {
+                    headerClass = ExtensionHeaderImpl.class;
+
+                }
+                try {
+                    parseExceptionListener.handleException(ex, message,
+                            headerClass, header, new String(rawMessage, "UTF-8"));
+                } catch (UnsupportedEncodingException e) {
+                    e.printStackTrace();
+                }
+
+            }
+        }
+    }
+
+    /**
+     * Parse an address (nameaddr or address spec) and return and address
+     * structure.
+     *
+     * @param address is a String containing the address to be parsed.
+     * @return a parsed address structure.
+     * @throws ParseException when the address is badly formatted.
+     * @since v1.0
+     */
+    public AddressImpl parseAddress(String address) throws ParseException {
+        AddressParser addressParser = new AddressParser(address);
+        return addressParser.address(true);
+    }
+
+    /**
+     * Parse a host:port and return a parsed structure.
+     *
+     * @param hostport
+     *            is a String containing the host:port to be parsed
+     * @return a parsed address structure.
+     * @since v1.0
+     * @exception throws
+     *                a ParseException when the address is badly formatted.
+     *
+    public HostPort parseHostPort(String hostport) throws ParseException {
+    Lexer lexer = new Lexer("charLexer", hostport);
+    return new HostNameParser(lexer).hostPort();
+
+    }
+     */
+
+    /**
+     * Parse a host name and return a parsed structure.
+     *
+     * @param host is a String containing the host name to be parsed
+     * @return a parsed address structure.
+     * @throws ParseException a ParseException when the hostname is badly formatted.
+     * @since v1.0
+     */
+    public Host parseHost(String host) throws ParseException {
+        Lexer lexer = new Lexer("charLexer", host);
+        return new HostNameParser(lexer).host();
+
+    }
+
+    /**
+     * Parse a telephone number return a parsed structure.
+     *
+     * @param telephone_number is a String containing the telephone # to be parsed
+     * @return a parsed address structure.
+     * @throws ParseException a ParseException when the address is badly formatted.
+     * @since v1.0
+     */
+    public TelephoneNumber parseTelephoneNumber(String telephone_number)
+            throws ParseException {
+        // Bug fix contributed by Will Scullin
+        return new URLParser(telephone_number).parseTelephoneNumber(true);
+
+    }
+
+    /**
+     * Parse a SIP url from a string and return a URI structure for it.
+     *
+     * @param url a String containing the URI structure to be parsed.
+     * @return A parsed URI structure
+     * @throws ParseException if there was an error parsing the message.
+     */
+
+    public SipUri parseSIPUrl(String url) throws ParseException {
+        try {
+            return new URLParser(url).sipURL(true);
+        } catch (ClassCastException ex) {
+            throw new ParseException(url + " Not a SIP URL ", 0);
+        }
+    }
+
+    /**
+     * Parse a uri from a string and return a URI structure for it.
+     *
+     * @param url a String containing the URI structure to be parsed.
+     * @return A parsed URI structure
+     * @throws ParseException if there was an error parsing the message.
+     */
+
+    public GenericURI parseUrl(String url) throws ParseException {
+        return new URLParser(url).parse();
+    }
+
+    /**
+     * Parse an individual SIP message header from a string.
+     *
+     * @param header String containing the SIP header.
+     * @return a SIPHeader structure.
+     * @throws ParseException if there was an error parsing the message.
+     */
+    public static SIPHeader parseSIPHeader(String header) throws ParseException {
+        int start = 0;
+        int end = header.length() - 1;
+        try {
+            // Squeeze out any leading control character.
+            while (header.charAt(start) <= 0x20)
+                start++;
+
+            // Squeeze out any trailing control character.
+            while (header.charAt(end) <= 0x20)
+                end--;
+        } catch (ArrayIndexOutOfBoundsException e) {
+            // Array contains only control char.
+            throw new ParseException("Empty header.", 0);
+        }
+
+        StringBuilder buffer = new StringBuilder(end + 1);
+        int i = start;
+        int lineStart = start;
+        boolean endOfLine = false;
+        while (i <= end) {
+            char c = header.charAt(i);
+            if (c == '\r' || c == '\n') {
+                if (!endOfLine) {
+                    buffer.append(header.substring(lineStart, i));
+                    endOfLine = true;
+                }
+            } else {
+                if (endOfLine) {
+                    endOfLine = false;
+                    if (c == ' ' || c == '\t') {
+                        buffer.append(' ');
+                        lineStart = i + 1;
+                    } else {
+                        lineStart = i;
+                    }
+                }
+            }
+
+            i++;
+        }
+        buffer.append(header.substring(lineStart, i));
+        buffer.append('\n');
+
+        HeaderParser hp = ParserFactory.createParser(buffer.toString());
+        if (hp == null)
+            throw new ParseException("could not create parser", 0);
+        return hp.parse();
+    }
+
+    /**
+     * Parse the SIP Request Line
+     *
+     * @param requestLine a String containing the request line to be parsed.
+     * @return a RequestLine structure that has the parsed RequestLine
+     * @throws ParseException if there was an error parsing the requestLine.
+     */
+
+    public RequestLine parseSIPRequestLine(String requestLine)
+            throws ParseException {
+        requestLine += "\n";
+        return new RequestLineParser(requestLine).parse();
+    }
+
+    /**
+     * Parse the SIP Response message status line
+     *
+     * @param statusLine a String containing the Status line to be parsed.
+     * @return StatusLine class corresponding to message
+     * @throws ParseException if there was an error parsing
+     * @see StatusLine
+     */
+
+    public StatusLine parseSIPStatusLine(String statusLine)
+            throws ParseException {
+        statusLine += "\n";
+        return new StatusLineParser(statusLine).parse();
+    }
+
+    public static void setComputeContentLengthFromMessage(
+            boolean computeContentLengthFromMessage) {
+        GBStringMsgParser.computeContentLengthFromMessage = computeContentLengthFromMessage;
+    }
+}

+ 46 - 0
video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/Gb28181Sdp.java

@@ -0,0 +1,46 @@
+package com.ruoyi.wvp.gb28181.bean;
+
+import javax.sdp.SessionDescription;
+
+/**
+ * 28181 的SDP解析器
+ */
+public class Gb28181Sdp  {
+    private SessionDescription baseSdb;
+    private String ssrc;
+
+    private String mediaDescription;
+
+    public static Gb28181Sdp getInstance(SessionDescription baseSdb, String ssrc, String mediaDescription) {
+        Gb28181Sdp gb28181Sdp = new Gb28181Sdp();
+        gb28181Sdp.setBaseSdb(baseSdb);
+        gb28181Sdp.setSsrc(ssrc);
+        gb28181Sdp.setMediaDescription(mediaDescription);
+        return gb28181Sdp;
+    }
+
+
+    public SessionDescription getBaseSdb() {
+        return baseSdb;
+    }
+
+    public void setBaseSdb(SessionDescription baseSdb) {
+        this.baseSdb = baseSdb;
+    }
+
+    public String getSsrc() {
+        return ssrc;
+    }
+
+    public void setSsrc(String ssrc) {
+        this.ssrc = ssrc;
+    }
+
+    public String getMediaDescription() {
+        return mediaDescription;
+    }
+
+    public void setMediaDescription(String mediaDescription) {
+        this.mediaDescription = mediaDescription;
+    }
+}

+ 56 - 0
video-service/src/main/java/com/ruoyi/wvp/gb28181/bean/GbCode.java

@@ -0,0 +1,56 @@
+package com.ruoyi.wvp.gb28181.bean;
+
+import lombok.Data;
+
+/**
+ * 国标编码对象
+ */
+@Data
+public class GbCode {
+
+    /**
+     * 中心编码,由监控中心所在地的行政区划代码确定,符合GB/T2260—2007的要求
+     */
+    private String centerCode;
+
+    /**
+     * 行业编码
+     */
+    private String industryCode;
+
+    /**
+     * 类型编码
+     */
+    private String typeCode;
+
+    /***
+     * 网络标识
+     */
+    private String netCode;
+
+    /**
+     * 序号
+     */
+    private String sn;
+
+    /**
+     * 解析国标编号
+     */
+    public static GbCode decode(String code){
+        if (code == null || code.trim().length() != 20) {
+            return null;
+        }
+        code = code.trim();
+        GbCode gbCode = new GbCode();
+        gbCode.setCenterCode(code.substring(0, 8));
+        gbCode.setIndustryCode(code.substring(8, 10));
+        gbCode.setTypeCode(code.substring(10, 13));
+        gbCode.setNetCode(code.substring(13, 14));
+        gbCode.setSn(code.substring(14));
+        return gbCode;
+    }
+
+    public String ecode(){
+        return centerCode + industryCode + typeCode + netCode + sn;
+    }
+}

Vissa filer visades inte eftersom för många filer har ändrats