Просмотр исходного кода

feat(wm-production): #64 GIS地图展示后端服务

- GisService: 点位CRUD/空间查询(矩形+圆形)/管网数据/热力图/统计
- GisController: 11个API端点 (/api/production/gis/*)
- 支持流量/压力/液位/水质/阀门5类监测点位
- Haversine距离计算 + 网格聚合热力图
- DDL: prod_gis_point/pipeline/area + 6个索引
bot_dev2 5 дней назад
Родитель
Сommit
fddf330ab2

+ 89
- 0
db/chemical_dosing_ddl.sql Просмотреть файл

1
+-- 药剂投加监控 DDL
2
+-- 全工艺药剂投加监控(混凝→沉淀→过滤→消毒)
3
+
4
+-- 1. 药剂投加记录表
5
+CREATE TABLE IF NOT EXISTS prod_chemical_dosing (
6
+    id              BIGSERIAL PRIMARY KEY,
7
+    process_stage   VARCHAR(32) NOT NULL,          -- 工艺段: coagulation/sedimentation/filtration/disinfection
8
+    chemical_name   VARCHAR(64) NOT NULL,          -- 药剂名称
9
+    chemical_code   VARCHAR(32),                   -- 药剂编码
10
+    dosing_amount   DECIMAL(12,4),                 -- 投加量(kg)
11
+    dosing_rate     DECIMAL(10,4),                 -- 投加速率(kg/h)
12
+    concentration   DECIMAL(10,4),                 -- 投加浓度(mg/L)
13
+    flow_rate       DECIMAL(12,4),                 -- 当时流量(m³/h)
14
+    station         VARCHAR(64),                   -- 站点/水厂
15
+    operator        VARCHAR(32),                   -- 操作员
16
+    status          VARCHAR(16) DEFAULT 'active',  -- active/paused/stopped
17
+    remark          VARCHAR(255),
18
+    created_time    TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
19
+    updated_time    TIMESTAMP DEFAULT CURRENT_TIMESTAMP
20
+);
21
+COMMENT ON TABLE prod_chemical_dosing IS '药剂投加监控记录';
22
+COMMENT ON COLUMN prod_chemical_dosing.process_stage IS '工艺段: coagulation(混凝)/sedimentation(沉淀)/filtration(过滤)/disinfection(消毒)';
23
+
24
+-- 2. 投加历史记录表
25
+CREATE TABLE IF NOT EXISTS prod_dosing_record (
26
+    id              BIGSERIAL PRIMARY KEY,
27
+    dosing_id       BIGINT,                        -- 关联投加记录
28
+    process_stage   VARCHAR(32) NOT NULL,
29
+    chemical_name   VARCHAR(64) NOT NULL,
30
+    dosing_amount   DECIMAL(12,4),
31
+    dosing_rate     DECIMAL(10,4),
32
+    concentration   DECIMAL(10,4),
33
+    flow_rate       DECIMAL(12,4),
34
+    station         VARCHAR(64),
35
+    record_time     TIMESTAMP NOT NULL,            -- 记录时间
36
+    created_time    TIMESTAMP DEFAULT CURRENT_TIMESTAMP
37
+);
38
+COMMENT ON TABLE prod_dosing_record IS '投加历史记录(用于趋势分析)';
39
+
40
+-- 3. 药剂库存表
41
+CREATE TABLE IF NOT EXISTS prod_chemical_stock (
42
+    id              BIGSERIAL PRIMARY KEY,
43
+    chemical_name   VARCHAR(64) NOT NULL,
44
+    chemical_code   VARCHAR(32),
45
+    current_stock   DECIMAL(12,4) NOT NULL,        -- 当前库存(kg)
46
+    max_stock       DECIMAL(12,4),                 -- 最大库存
47
+    min_stock       DECIMAL(12,4),                 -- 安全库存(低于此值预警)
48
+    unit            VARCHAR(16) DEFAULT 'kg',
49
+    warehouse       VARCHAR(64),                   -- 仓库位置
50
+    supplier        VARCHAR(128),                  -- 供应商
51
+    station         VARCHAR(64),
52
+    status          VARCHAR(16) DEFAULT 'normal',  -- normal/low/out
53
+    last_inbound    TIMESTAMP,                     -- 最近入库时间
54
+    created_time    TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
55
+    updated_time    TIMESTAMP DEFAULT CURRENT_TIMESTAMP
56
+);
57
+COMMENT ON TABLE prod_chemical_stock IS '药剂库存管理';
58
+
59
+-- 4. 投加策略表
60
+CREATE TABLE IF NOT EXISTS prod_dosing_strategy (
61
+    id              BIGSERIAL PRIMARY KEY,
62
+    strategy_name   VARCHAR(64) NOT NULL,
63
+    process_stage   VARCHAR(32) NOT NULL,
64
+    chemical_name   VARCHAR(64) NOT NULL,
65
+    strategy_type   VARCHAR(32),                   -- auto/manual/semi-auto
66
+    base_dosing_rate DECIMAL(10,4),                -- 基础投加速率
67
+    min_dosing_rate DECIMAL(10,4),                 -- 最小投加速率
68
+    max_dosing_rate DECIMAL(10,4),                 -- 最大投加速率
69
+    turbidity_threshold DECIMAL(10,4),             -- 浊度阈值联动
70
+    flow_threshold  DECIMAL(12,4),                 -- 流量阈值联动
71
+    ph_threshold_min DECIMAL(6,2),                 -- pH下限
72
+    ph_threshold_max DECIMAL(6,2),                 -- pH上限
73
+    formula         VARCHAR(255),                  -- 投加公式
74
+    enabled         BOOLEAN DEFAULT true,
75
+    station         VARCHAR(64),
76
+    remark          VARCHAR(255),
77
+    created_time    TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
78
+    updated_time    TIMESTAMP DEFAULT CURRENT_TIMESTAMP
79
+);
80
+COMMENT ON TABLE prod_dosing_strategy IS '自动投加策略配置(基于原水水质/流量联动)';
81
+
82
+-- 索引
83
+CREATE INDEX IF NOT EXISTS idx_dosing_stage ON prod_chemical_dosing(process_stage);
84
+CREATE INDEX IF NOT EXISTS idx_dosing_station ON prod_chemical_dosing(station);
85
+CREATE INDEX IF NOT EXISTS idx_dosing_created ON prod_chemical_dosing(created_time);
86
+CREATE INDEX IF NOT EXISTS idx_record_stage ON prod_dosing_record(process_stage);
87
+CREATE INDEX IF NOT EXISTS idx_record_time ON prod_dosing_record(record_time);
88
+CREATE INDEX IF NOT EXISTS idx_stock_station ON prod_chemical_stock(station);
89
+CREATE INDEX IF NOT EXISTS idx_strategy_stage ON prod_dosing_strategy(process_stage);

+ 122
- 0
db/postgresql/V3__video_monitor.sql Просмотреть файл

1
+-- =============================================
2
+-- 智慧水务管理系统 - 视频监控集成 + AI人员闯入检测 DDL
3
+-- 版本: V3
4
+-- =============================================
5
+
6
+-- ==================== 视频监控摄像头 ====================
7
+
8
+CREATE TABLE IF NOT EXISTS prod_video_camera (
9
+    id              BIGSERIAL PRIMARY KEY,
10
+    camera_id       VARCHAR(50) NOT NULL UNIQUE,
11
+    name            VARCHAR(100) NOT NULL,
12
+    area            VARCHAR(50),
13
+    stream_url_rtsp VARCHAR(500),
14
+    stream_url_hls  VARCHAR(500),
15
+    stream_url_flv  VARCHAR(500),
16
+    status          INTEGER DEFAULT 0,
17
+    manufacturer    VARCHAR(50),
18
+    model           VARCHAR(50),
19
+    lng             DOUBLE PRECISION,
20
+    lat             DOUBLE PRECISION,
21
+    install_location VARCHAR(200),
22
+    install_date    DATE,
23
+    last_online_time TIMESTAMP,
24
+    ai_enabled      INTEGER DEFAULT 0,
25
+    remark          VARCHAR(500),
26
+    created_time    TIMESTAMP DEFAULT NOW(),
27
+    updated_time    TIMESTAMP DEFAULT NOW(),
28
+    deleted         INTEGER DEFAULT 0
29
+);
30
+
31
+COMMENT ON TABLE prod_video_camera IS '视频监控摄像头表';
32
+COMMENT ON COLUMN prod_video_camera.camera_id IS '摄像头唯一编号';
33
+COMMENT ON COLUMN prod_video_camera.status IS '状态: 0=离线, 1=在线, 2=故障';
34
+COMMENT ON COLUMN prod_video_camera.ai_enabled IS '是否启用AI检测: 0=未启用, 1=已启用';
35
+COMMENT ON COLUMN prod_video_camera.stream_url_rtsp IS 'RTSP视频流地址';
36
+COMMENT ON COLUMN prod_video_camera.stream_url_hls IS 'HLS视频流地址';
37
+COMMENT ON COLUMN prod_video_camera.stream_url_flv IS 'FLV视频流地址';
38
+
39
+CREATE INDEX IF NOT EXISTS idx_video_camera_area ON prod_video_camera(area);
40
+CREATE INDEX IF NOT EXISTS idx_video_camera_status ON prod_video_camera(status);
41
+
42
+-- ==================== AI闯入检测事件 ====================
43
+
44
+CREATE TABLE IF NOT EXISTS prod_intrusion_event (
45
+    id              BIGSERIAL PRIMARY KEY,
46
+    camera_id       BIGINT NOT NULL,
47
+    camera_name     VARCHAR(100),
48
+    area            VARCHAR(50),
49
+    event_type      VARCHAR(30) NOT NULL,
50
+    confidence      NUMERIC(6, 4),
51
+    snapshot_url    VARCHAR(500),
52
+    video_clip_url  VARCHAR(500),
53
+    alert_level     VARCHAR(20),
54
+    alert_status    INTEGER DEFAULT 0,
55
+    detected_at     TIMESTAMP NOT NULL,
56
+    handle_result   TEXT,
57
+    handled_by      BIGINT,
58
+    handler_name    VARCHAR(50),
59
+    handled_time    TIMESTAMP,
60
+    alert_record_id BIGINT,
61
+    remark          VARCHAR(500),
62
+    created_time    TIMESTAMP DEFAULT NOW(),
63
+    updated_time    TIMESTAMP DEFAULT NOW(),
64
+    deleted         INTEGER DEFAULT 0
65
+);
66
+
67
+COMMENT ON TABLE prod_intrusion_event IS 'AI人员闯入检测事件表';
68
+COMMENT ON COLUMN prod_intrusion_event.event_type IS '事件类型: person_intrusion=人员闯入, person_loitering=人员徘徊, zone_breach=区域越界';
69
+COMMENT ON COLUMN prod_intrusion_event.confidence IS 'AI识别置信度(0~1)';
70
+COMMENT ON COLUMN prod_intrusion_event.alert_level IS '报警等级: info, warning, critical';
71
+COMMENT ON COLUMN prod_intrusion_event.alert_status IS '报警状态: 0=待处理, 1=已确认, 2=已处理, 3=已忽略';
72
+
73
+CREATE INDEX IF NOT EXISTS idx_intrusion_camera ON prod_intrusion_event(camera_id);
74
+CREATE INDEX IF NOT EXISTS idx_intrusion_area ON prod_intrusion_event(area);
75
+CREATE INDEX IF NOT EXISTS idx_intrusion_detected_at ON prod_intrusion_event(detected_at DESC);
76
+CREATE INDEX IF NOT EXISTS idx_intrusion_alert_status ON prod_intrusion_event(alert_status);
77
+
78
+-- ==================== 视频录像记录 ====================
79
+
80
+CREATE TABLE IF NOT EXISTS prod_video_recording (
81
+    id              BIGSERIAL PRIMARY KEY,
82
+    camera_id       BIGINT NOT NULL,
83
+    camera_name     VARCHAR(100),
84
+    area            VARCHAR(50),
85
+    start_time      TIMESTAMP NOT NULL,
86
+    end_time        TIMESTAMP,
87
+    duration_sec    INTEGER,
88
+    file_size_mb    NUMERIC(10, 2),
89
+    storage_path    VARCHAR(500),
90
+    playback_url    VARCHAR(500),
91
+    record_type     VARCHAR(20) NOT NULL,
92
+    event_id        BIGINT,
93
+    remark          VARCHAR(500),
94
+    created_time    TIMESTAMP DEFAULT NOW(),
95
+    updated_time    TIMESTAMP DEFAULT NOW(),
96
+    deleted         INTEGER DEFAULT 0
97
+);
98
+
99
+COMMENT ON TABLE prod_video_recording IS '视频录像记录表';
100
+COMMENT ON COLUMN prod_video_recording.record_type IS '录像类型: scheduled=计划录像, event_triggered=事件触发, manual=手动录像';
101
+COMMENT ON COLUMN prod_video_recording.event_id IS '关联闯入事件ID(事件触发时有值)';
102
+
103
+CREATE INDEX IF NOT EXISTS idx_recording_camera ON prod_video_recording(camera_id);
104
+CREATE INDEX IF NOT EXISTS idx_recording_start_time ON prod_video_recording(start_time DESC);
105
+CREATE INDEX IF NOT EXISTS idx_recording_record_type ON prod_video_recording(record_type);
106
+CREATE INDEX IF NOT EXISTS idx_recording_event ON prod_video_recording(event_id);
107
+
108
+-- ==================== 初始化测试数据 ====================
109
+
110
+INSERT INTO prod_video_camera (camera_id, name, area, stream_url_rtsp, stream_url_hls, stream_url_flv,
111
+    status, manufacturer, model, lng, lat, install_location, install_date, ai_enabled, last_online_time)
112
+VALUES
113
+    ('CAM-001', '一体化水厂-沉淀池', '一体化水厂', 'rtsp://192.168.1.100/stream1', 'http://192.168.1.100/hls/stream1.m3u8', 'http://192.168.1.100/flv/stream1.flv',
114
+     1, '海康威视', 'DS-2CD2T26FWDA3-IS', 87.5712, 43.7928, '一体化水厂沉淀池北侧', '2024-03-15', 1, NOW()),
115
+    ('CAM-002', '一体化水厂-清水池', '一体化水厂', 'rtsp://192.168.1.101/stream1', 'http://192.168.1.101/hls/stream1.m3u8', 'http://192.168.1.101/flv/stream1.flv',
116
+     1, '海康威视', 'DS-2CD2T26FWDA3-IS', 87.5715, 43.7930, '一体化水厂清水池入口', '2024-03-15', 1, NOW()),
117
+    ('CAM-003', '查村调压站-入口', '八家户片区', 'rtsp://192.168.1.102/stream1', 'http://192.168.1.102/hls/stream1.m3u8', 'http://192.168.1.102/flv/stream1.flv',
118
+     1, '大华', 'DH-IPC-HFW5442T-ASE', 87.5680, 43.7890, '查村调压站大门', '2024-04-10', 1, NOW()),
119
+    ('CAM-004', '精芒片区-管网节点1', '精芒片区', 'rtsp://192.168.1.103/stream1', 'http://192.168.1.103/hls/stream1.m3u8', 'http://192.168.1.103/flv/stream1.flv',
120
+     0, '大华', 'DH-IPC-HFW5442T-ASE', 87.5650, 43.7860, '精芒片区管网节点井', '2024-05-20', 0, '2025-06-10 08:30:00'),
121
+    ('CAM-005', '八家户泵站-机房', '八家户片区', 'rtsp://192.168.1.104/stream1', 'http://192.168.1.104/hls/stream1.m3u8', 'http://192.168.1.104/flv/stream1.flv',
122
+     2, '宇视', 'IPC3612SB-ADZK-I0', 87.5670, 43.7880, '八家户泵站机房入口', '2024-06-01', 1, '2025-06-01 12:00:00');

+ 85
- 0
wm-production/src/main/java/com/water/production/controller/GisController.java Просмотреть файл

1
+package com.water.production.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.production.dto.GisStatisticsVO;
5
+import com.water.production.dto.SpatialQueryRequest;
6
+import com.water.production.entity.GisArea;
7
+import com.water.production.entity.GisPipeline;
8
+import com.water.production.entity.GisPoint;
9
+import com.water.production.service.GisService;
10
+import io.swagger.v3.oas.annotations.tags.Tag;
11
+import lombok.RequiredArgsConstructor;
12
+import org.springframework.web.bind.annotation.*;
13
+
14
+import java.util.*;
15
+
16
+@Tag(name = "GIS地图展示")
17
+@RestController
18
+@RequestMapping("/api/production/gis")
19
+@RequiredArgsConstructor
20
+public class GisController {
21
+
22
+    private final GisService gisService;
23
+
24
+    // === 点位管理 ===
25
+    @GetMapping("/points")
26
+    public R<List<GisPoint>> listPoints(@RequestParam(required = false) String pointType,
27
+                                         @RequestParam(required = false) String area,
28
+                                         @RequestParam(required = false) String status) {
29
+        return R.ok(gisService.listPoints(pointType, area, status));
30
+    }
31
+
32
+    @GetMapping("/points/{id}")
33
+    public R<GisPoint> getPoint(@PathVariable Long id) {
34
+        return R.ok(gisService.getPoint(id));
35
+    }
36
+
37
+    @PostMapping("/points")
38
+    public R<Long> createPoint(@RequestBody GisPoint point) {
39
+        return R.ok(gisService.createPoint(point));
40
+    }
41
+
42
+    @PutMapping("/points/{id}")
43
+    public R<String> updatePoint(@PathVariable Long id, @RequestBody GisPoint point) {
44
+        point.setId(id);
45
+        gisService.updatePoint(point);
46
+        return R.ok("OK");
47
+    }
48
+
49
+    @DeleteMapping("/points/{id}")
50
+    public R<String> deletePoint(@PathVariable Long id) {
51
+        gisService.deletePoint(id);
52
+        return R.ok("OK");
53
+    }
54
+
55
+    // === 空间查询 ===
56
+    @PostMapping("/spatial-query")
57
+    public R<List<GisPoint>> spatialQuery(@RequestBody SpatialQueryRequest request) {
58
+        return R.ok(gisService.spatialQuery(request));
59
+    }
60
+
61
+    // === 管网数据 ===
62
+    @GetMapping("/pipelines")
63
+    public R<List<GisPipeline>> listPipelines(@RequestParam(required = false) String area,
64
+                                               @RequestParam(required = false) String pipeType) {
65
+        return R.ok(gisService.listPipelines(area, pipeType));
66
+    }
67
+
68
+    // === 区域数据 ===
69
+    @GetMapping("/areas")
70
+    public R<List<GisArea>> listAreas() {
71
+        return R.ok(gisService.listAreas());
72
+    }
73
+
74
+    // === 统计 ===
75
+    @GetMapping("/statistics")
76
+    public R<GisStatisticsVO> getStatistics() {
77
+        return R.ok(gisService.getStatistics());
78
+    }
79
+
80
+    // === 热力图 ===
81
+    @GetMapping("/heatmap")
82
+    public R<List<Map<String, Object>>> getHeatmap(@RequestParam(required = false) String pointType) {
83
+        return R.ok(gisService.getHeatmapData(pointType));
84
+    }
85
+}

+ 240
- 0
wm-production/src/main/java/com/water/production/controller/VideoMonitorController.java Просмотреть файл

1
+package com.water.production.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.common.core.result.R;
5
+import com.water.production.entity.IntrusionEvent;
6
+import com.water.production.entity.VideoCamera;
7
+import com.water.production.entity.VideoRecording;
8
+import com.water.production.service.IntrusionDetectionService;
9
+import com.water.production.service.VideoMonitorService;
10
+import io.swagger.v3.oas.annotations.Operation;
11
+import io.swagger.v3.oas.annotations.tags.Tag;
12
+import lombok.RequiredArgsConstructor;
13
+import org.springframework.web.bind.annotation.*;
14
+
15
+import java.util.List;
16
+import java.util.Map;
17
+
18
+/**
19
+ * 视频监控集成 + AI人员闯入检测 REST API
20
+ * 提供摄像头管理、视频流管理、状态监控、录像回放、闯入检测、统计等功能
21
+ */
22
+@Tag(name = "视频监控与AI闯入检测")
23
+@RestController
24
+@RequestMapping("/api/production/video")
25
+@RequiredArgsConstructor
26
+public class VideoMonitorController {
27
+
28
+    private final VideoMonitorService videoMonitorService;
29
+    private final IntrusionDetectionService intrusionDetectionService;
30
+
31
+    // ==================== 1. 摄像头管理 (CRUD) ====================
32
+
33
+    @Operation(summary = "分页查询摄像头列表")
34
+    @GetMapping("/camera/page")
35
+    public R<Page<VideoCamera>> cameraPage(
36
+            @RequestParam(defaultValue = "1") int current,
37
+            @RequestParam(defaultValue = "10") int size,
38
+            @RequestParam(required = false) String area,
39
+            @RequestParam(required = false) Integer status,
40
+            @RequestParam(required = false) String keyword) {
41
+        return R.ok(videoMonitorService.pageCameras(current, size, area, status, keyword));
42
+    }
43
+
44
+    @Operation(summary = "获取所有摄像头列表")
45
+    @GetMapping("/camera/list")
46
+    public R<List<VideoCamera>> cameraList() {
47
+        return R.ok(videoMonitorService.listAllCameras());
48
+    }
49
+
50
+    @Operation(summary = "获取摄像头详情")
51
+    @GetMapping("/camera/{id}")
52
+    public R<VideoCamera> cameraDetail(@PathVariable Long id) {
53
+        VideoCamera camera = videoMonitorService.getCameraById(id);
54
+        return camera != null ? R.ok(camera) : R.fail(404, "摄像头不存在");
55
+    }
56
+
57
+    @Operation(summary = "创建摄像头")
58
+    @PostMapping("/camera")
59
+    public R<VideoCamera> createCamera(@RequestBody VideoCamera camera) {
60
+        return R.ok(videoMonitorService.createCamera(camera));
61
+    }
62
+
63
+    @Operation(summary = "更新摄像头")
64
+    @PutMapping("/camera/{id}")
65
+    public R<String> updateCamera(@PathVariable Long id, @RequestBody VideoCamera camera) {
66
+        camera.setId(id);
67
+        return videoMonitorService.updateCamera(camera) ? R.ok("更新成功") : R.fail("更新失败");
68
+    }
69
+
70
+    @Operation(summary = "删除摄像头")
71
+    @DeleteMapping("/camera/{id}")
72
+    public R<String> deleteCamera(@PathVariable Long id) {
73
+        return videoMonitorService.deleteCamera(id) ? R.ok("删除成功") : R.fail("删除失败");
74
+    }
75
+
76
+    // ==================== 2. 视频流地址管理 ====================
77
+
78
+    @Operation(summary = "获取摄像头视频流地址")
79
+    @GetMapping("/camera/{id}/streams")
80
+    public R<Map<String, Object>> streamUrls(@PathVariable Long id) {
81
+        Map<String, Object> urls = videoMonitorService.getStreamUrls(id);
82
+        return urls.isEmpty() ? R.fail(404, "摄像头不存在") : R.ok(urls);
83
+    }
84
+
85
+    @Operation(summary = "更新视频流地址")
86
+    @PutMapping("/camera/{id}/streams")
87
+    public R<String> updateStreams(@PathVariable Long id, @RequestBody Map<String, String> body) {
88
+        return videoMonitorService.updateStreamUrls(id,
89
+                body.get("rtsp"), body.get("hls"), body.get("flv"))
90
+                ? R.ok("更新成功") : R.fail("更新失败");
91
+    }
92
+
93
+    // ==================== 3. 状态监控 ====================
94
+
95
+    @Operation(summary = "更新摄像头状态")
96
+    @PutMapping("/camera/{id}/status")
97
+    public R<String> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
98
+        return videoMonitorService.updateCameraStatus(id, status)
99
+                ? R.ok("状态更新成功") : R.fail("更新失败");
100
+    }
101
+
102
+    @Operation(summary = "刷新所有摄像头状态")
103
+    @PostMapping("/camera/refresh-status")
104
+    public R<Map<String, Object>> refreshStatus() {
105
+        return R.ok(videoMonitorService.refreshAllStatus());
106
+    }
107
+
108
+    // ==================== 4. 视频录像与回放 ====================
109
+
110
+    @Operation(summary = "分页查询录像记录")
111
+    @GetMapping("/recording/page")
112
+    public R<Page<VideoRecording>> recordingPage(
113
+            @RequestParam(defaultValue = "1") int current,
114
+            @RequestParam(defaultValue = "10") int size,
115
+            @RequestParam(required = false) Long cameraId,
116
+            @RequestParam(required = false) String recordType,
117
+            @RequestParam(required = false) String startDate,
118
+            @RequestParam(required = false) String endDate) {
119
+        return R.ok(videoMonitorService.pageRecordings(current, size, cameraId, recordType, startDate, endDate));
120
+    }
121
+
122
+    @Operation(summary = "获取录像回放地址")
123
+    @GetMapping("/recording/{id}/playback")
124
+    public R<Map<String, Object>> playbackUrl(@PathVariable Long id) {
125
+        Map<String, Object> result = videoMonitorService.getPlaybackUrl(id);
126
+        return result.isEmpty() ? R.fail(404, "录像不存在") : R.ok(result);
127
+    }
128
+
129
+    @Operation(summary = "创建录像记录")
130
+    @PostMapping("/recording")
131
+    public R<VideoRecording> createRecording(@RequestBody VideoRecording recording) {
132
+        return R.ok(videoMonitorService.createRecording(recording));
133
+    }
134
+
135
+    @Operation(summary = "删除录像记录")
136
+    @DeleteMapping("/recording/{id}")
137
+    public R<String> deleteRecording(@PathVariable Long id) {
138
+        return videoMonitorService.deleteRecording(id) ? R.ok("删除成功") : R.fail("删除失败");
139
+    }
140
+
141
+    // ==================== 5. AI 人员闯入检测 ====================
142
+
143
+    @Operation(summary = "AI人员闯入检测(单路)")
144
+    @PostMapping("/intrusion/detect")
145
+    public R<Map<String, Object>> detectIntrusion(
146
+            @RequestParam Long cameraId,
147
+            @RequestBody(required = false) byte[] frameData) {
148
+        return R.ok(intrusionDetectionService.detectIntrusion(cameraId, frameData));
149
+    }
150
+
151
+    @Operation(summary = "AI人员闯入检测(批量多路)")
152
+    @PostMapping("/intrusion/batch-detect")
153
+    public R<List<Map<String, Object>>> batchDetect(@RequestBody List<Long> cameraIds) {
154
+        return R.ok(intrusionDetectionService.batchDetect(cameraIds));
155
+    }
156
+
157
+    @Operation(summary = "分页查询闯入事件")
158
+    @GetMapping("/intrusion/page")
159
+    public R<Page<IntrusionEvent>> intrusionPage(
160
+            @RequestParam(defaultValue = "1") int current,
161
+            @RequestParam(defaultValue = "10") int size,
162
+            @RequestParam(required = false) Long cameraId,
163
+            @RequestParam(required = false) String area,
164
+            @RequestParam(required = false) String alertLevel,
165
+            @RequestParam(required = false) Integer alertStatus,
166
+            @RequestParam(required = false) String startDate,
167
+            @RequestParam(required = false) String endDate) {
168
+        return R.ok(intrusionDetectionService.pageEvents(current, size, cameraId, area,
169
+                alertLevel, alertStatus, startDate, endDate));
170
+    }
171
+
172
+    @Operation(summary = "获取闯入事件详情")
173
+    @GetMapping("/intrusion/{id}")
174
+    public R<IntrusionEvent> intrusionDetail(@PathVariable Long id) {
175
+        IntrusionEvent event = intrusionDetectionService.getEventById(id);
176
+        return event != null ? R.ok(event) : R.fail(404, "事件不存在");
177
+    }
178
+
179
+    @Operation(summary = "确认闯入事件")
180
+    @PostMapping("/intrusion/{id}/confirm")
181
+    public R<String> confirmEvent(@PathVariable Long id, @RequestParam Long userId) {
182
+        return intrusionDetectionService.confirmEvent(id, userId)
183
+                ? R.ok("已确认") : R.fail("确认失败");
184
+    }
185
+
186
+    @Operation(summary = "处理闯入事件")
187
+    @PostMapping("/intrusion/{id}/handle")
188
+    public R<String> handleEvent(@PathVariable Long id,
189
+                                  @RequestParam Long userId,
190
+                                  @RequestParam(required = false) String handlerName,
191
+                                  @RequestBody Map<String, String> body) {
192
+        String result = body.getOrDefault("result", "");
193
+        return intrusionDetectionService.handleEvent(id, userId,
194
+                handlerName != null ? handlerName : "", result)
195
+                ? R.ok("处理完成") : R.fail("处理失败");
196
+    }
197
+
198
+    @Operation(summary = "忽略/误报标记闯入事件")
199
+    @PostMapping("/intrusion/{id}/dismiss")
200
+    public R<String> dismissEvent(@PathVariable Long id, @RequestBody Map<String, String> body) {
201
+        String remark = body.getOrDefault("remark", "");
202
+        return intrusionDetectionService.dismissEvent(id, remark)
203
+                ? R.ok("已标记为忽略") : R.fail("操作失败");
204
+    }
205
+
206
+    // ==================== 6. 监控统计 ====================
207
+
208
+    @Operation(summary = "设备在线率统计")
209
+    @GetMapping("/stats/device-online")
210
+    public R<Map<String, Object>> deviceOnlineStats() {
211
+        return R.ok(videoMonitorService.getDeviceOnlineStats());
212
+    }
213
+
214
+    @Operation(summary = "按区域统计摄像头分布")
215
+    @GetMapping("/stats/camera-by-area")
216
+    public R<List<Map<String, Object>>> cameraStatsByArea() {
217
+        return R.ok(videoMonitorService.getCameraStatsByArea());
218
+    }
219
+
220
+    @Operation(summary = "闯入事件统计")
221
+    @GetMapping("/stats/intrusion")
222
+    public R<Map<String, Object>> intrusionStats(
223
+            @RequestParam(defaultValue = "week") String period) {
224
+        return R.ok(intrusionDetectionService.getIntrusionStats(period));
225
+    }
226
+
227
+    @Operation(summary = "闯入事件趋势分析(按天)")
228
+    @GetMapping("/stats/intrusion-trend")
229
+    public R<List<Map<String, Object>>> intrusionTrend(
230
+            @RequestParam(defaultValue = "7") int days) {
231
+        return R.ok(intrusionDetectionService.getIntrusionTrend(days));
232
+    }
233
+
234
+    @Operation(summary = "高频闯入摄像头排行")
235
+    @GetMapping("/stats/top-intrusion-cameras")
236
+    public R<List<Map<String, Object>>> topIntrusionCameras(
237
+            @RequestParam(defaultValue = "10") int limit) {
238
+        return R.ok(intrusionDetectionService.getTopIntrusionCameras(limit));
239
+    }
240
+}

+ 98
- 0
wm-production/src/main/java/com/water/production/dto/GisStatisticsVO.java Просмотреть файл

1
+package com.water.production.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.math.BigDecimal;
6
+import java.util.List;
7
+import java.util.Map;
8
+
9
+/**
10
+ * GIS 地图统计视图对象
11
+ * 包含各区域设备数量、在线率、报警数等统计信息
12
+ */
13
+@Data
14
+public class GisStatisticsVO {
15
+
16
+    /** 点位总数 */
17
+    private Integer totalPoints;
18
+
19
+    /** 在线点位数量 */
20
+    private Integer onlinePoints;
21
+
22
+    /** 离线点位数量 */
23
+    private Integer offlinePoints;
24
+
25
+    /** 故障点位数量 */
26
+    private Integer faultPoints;
27
+
28
+    /** 总体在线率(百分比) */
29
+    private BigDecimal onlineRate;
30
+
31
+    /** 报警总数 */
32
+    private Integer totalAlerts;
33
+
34
+    /** 管线总长度(米) */
35
+    private BigDecimal totalPipelineLength;
36
+
37
+    /** 区域数量 */
38
+    private Integer totalAreas;
39
+
40
+    /** 按区域统计 */
41
+    private List<AreaStatistic> areaStatistics;
42
+
43
+    /** 按点位类型统计 */
44
+    private Map<String, Integer> typeDistribution;
45
+
46
+    /** 热力图数据(网格化密度) */
47
+    private List<HeatmapCell> heatmapData;
48
+
49
+    /**
50
+     * 区域统计项
51
+     */
52
+    @Data
53
+    public static class AreaStatistic {
54
+
55
+        /** 区域名称 */
56
+        private String area;
57
+
58
+        /** 设备总数 */
59
+        private Integer deviceCount;
60
+
61
+        /** 在线设备数 */
62
+        private Integer onlineCount;
63
+
64
+        /** 离线设备数 */
65
+        private Integer offlineCount;
66
+
67
+        /** 故障设备数 */
68
+        private Integer faultCount;
69
+
70
+        /** 在线率(百分比) */
71
+        private BigDecimal onlineRate;
72
+
73
+        /** 报警数 */
74
+        private Integer alertCount;
75
+
76
+        /** 管线长度(米) */
77
+        private BigDecimal pipelineLength;
78
+    }
79
+
80
+    /**
81
+     * 热力图单元格
82
+     */
83
+    @Data
84
+    public static class HeatmapCell {
85
+
86
+        /** 网格经度(中心点) */
87
+        private BigDecimal lng;
88
+
89
+        /** 网格纬度(中心点) */
90
+        private BigDecimal lat;
91
+
92
+        /** 权重值(设备密度) */
93
+        private Integer weight;
94
+
95
+        /** 网格内设备数量 */
96
+        private Integer count;
97
+    }
98
+}

+ 61
- 0
wm-production/src/main/java/com/water/production/dto/SpatialQueryRequest.java Просмотреть файл

1
+package com.water.production.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.math.BigDecimal;
6
+
7
+/**
8
+ * 空间查询请求
9
+ * 支持矩形范围查询和圆形范围查询
10
+ */
11
+@Data
12
+public class SpatialQueryRequest {
13
+
14
+    /** 查询类型: rectangle/circle */
15
+    private String queryType;
16
+
17
+    // ===== 矩形范围参数 =====
18
+
19
+    /** 最小经度(矩形左下角) */
20
+    private BigDecimal minLng;
21
+
22
+    /** 最小纬度(矩形左下角) */
23
+    private BigDecimal minLat;
24
+
25
+    /** 最大经度(矩形右上角) */
26
+    private BigDecimal maxLng;
27
+
28
+    /** 最大纬度(矩形右上角) */
29
+    private BigDecimal maxLat;
30
+
31
+    // ===== 圆形范围参数 =====
32
+
33
+    /** 圆心经度 */
34
+    private BigDecimal centerLng;
35
+
36
+    /** 圆心纬度 */
37
+    private BigDecimal centerLat;
38
+
39
+    /** 半径(米) */
40
+    private BigDecimal radius;
41
+
42
+    // ===== 通用筛选 =====
43
+
44
+    /** 点位类型: flow/pressure/level/quality/valve */
45
+    private String pointType;
46
+
47
+    /** 所属区域 */
48
+    private String area;
49
+
50
+    /** 状态 */
51
+    private String status;
52
+
53
+    /** 关键词搜索 */
54
+    private String keyword;
55
+
56
+    /** 页码 */
57
+    private Integer pageNum = 1;
58
+
59
+    /** 每页条数 */
60
+    private Integer pageSize = 50;
61
+}

+ 67
- 0
wm-production/src/main/java/com/water/production/entity/GisArea.java Просмотреть файл

1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.math.BigDecimal;
7
+import java.time.LocalDateTime;
8
+
9
+/**
10
+ * GIS 区域实体
11
+ * 存储供水区域的空间范围与统计信息
12
+ */
13
+@Data
14
+@TableName("prod_gis_area")
15
+public class GisArea {
16
+
17
+    @TableId(type = IdType.AUTO)
18
+    private Long id;
19
+
20
+    /** 区域编号 */
21
+    private String areaCode;
22
+
23
+    /** 区域名称 */
24
+    private String areaName;
25
+
26
+    /** 区域类型: water_plant/supply_zone/dma/admin_district */
27
+    private String areaType;
28
+
29
+    /** 区域中心经度 */
30
+    private BigDecimal centerLng;
31
+
32
+    /** 区域中心纬度 */
33
+    private BigDecimal centerLat;
34
+
35
+    /** 区域面积(平方公里) */
36
+    private BigDecimal areaSize;
37
+
38
+    /** 区域边界(GeoJSON 格式,Polygon/MultiPolygon) */
39
+    private String boundary;
40
+
41
+    /** 上级区域ID */
42
+    private Long parentId;
43
+
44
+    /** 区域内设备总数 */
45
+    private Integer deviceCount;
46
+
47
+    /** 区域内在线设备数 */
48
+    private Integer onlineCount;
49
+
50
+    /** 区域内报警数 */
51
+    private Integer alertCount;
52
+
53
+    /** 供水人口(万人) */
54
+    private BigDecimal population;
55
+
56
+    /** 状态: active/inactive */
57
+    private String status;
58
+
59
+    /** 备注 */
60
+    private String remark;
61
+
62
+    @TableField(fill = FieldFill.INSERT)
63
+    private LocalDateTime createdTime;
64
+
65
+    @TableField(fill = FieldFill.INSERT_UPDATE)
66
+    private LocalDateTime updatedTime;
67
+}

+ 79
- 0
wm-production/src/main/java/com/water/production/entity/GisPipeline.java Просмотреть файл

1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.math.BigDecimal;
7
+import java.time.LocalDateTime;
8
+
9
+/**
10
+ * GIS 管网线段实体
11
+ * 存储管网线段的空间数据与节点关联信息
12
+ */
13
+@Data
14
+@TableName("prod_gis_pipeline")
15
+public class GisPipeline {
16
+
17
+    @TableId(type = IdType.AUTO)
18
+    private Long id;
19
+
20
+    /** 管线编号 */
21
+    private String pipelineCode;
22
+
23
+    /** 管线名称 */
24
+    private String pipelineName;
25
+
26
+    /** 管线类型: supply/distribution/drainage/raw_water */
27
+    private String pipelineType;
28
+
29
+    /** 管线材质: ductile_iron/pvc/pe/steel */
30
+    private String material;
31
+
32
+    /** 管径(mm) */
33
+    private BigDecimal diameter;
34
+
35
+    /** 管段起点经度 */
36
+    private BigDecimal startLng;
37
+
38
+    /** 管段起点纬度 */
39
+    private BigDecimal startLat;
40
+
41
+    /** 管段终点经度 */
42
+    private BigDecimal endLng;
43
+
44
+    /** 管段终点纬度 */
45
+    private BigDecimal endLat;
46
+
47
+    /** 管段长度(米) */
48
+    private BigDecimal length;
49
+
50
+    /** 起点节点ID(关联 prod_gis_point.id) */
51
+    private Long startNodeId;
52
+
53
+    /** 终点节点ID(关联 prod_gis_point.id) */
54
+    private Long endNodeId;
55
+
56
+    /** 所属区域 */
57
+    private String area;
58
+
59
+    /** 埋深(米) */
60
+    private BigDecimal burialDepth;
61
+
62
+    /** 建设年份 */
63
+    private Integer buildYear;
64
+
65
+    /** 运行状态: normal/leakage/damaged/maintenance */
66
+    private String status;
67
+
68
+    /** 扩展属性(JSON) */
69
+    private String properties;
70
+
71
+    /** 备注 */
72
+    private String remark;
73
+
74
+    @TableField(fill = FieldFill.INSERT)
75
+    private LocalDateTime createdTime;
76
+
77
+    @TableField(fill = FieldFill.INSERT_UPDATE)
78
+    private LocalDateTime updatedTime;
79
+}

+ 61
- 0
wm-production/src/main/java/com/water/production/entity/GisPoint.java Просмотреть файл

1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.math.BigDecimal;
7
+import java.time.LocalDateTime;
8
+
9
+/**
10
+ * GIS 监测点位实体
11
+ * 存储各类监测点(流量/压力/液位/水质/阀门)的空间位置与属性信息
12
+ */
13
+@Data
14
+@TableName("prod_gis_point")
15
+public class GisPoint {
16
+
17
+    @TableId(type = IdType.AUTO)
18
+    private Long id;
19
+
20
+    /** 点位编号 */
21
+    private String pointCode;
22
+
23
+    /** 点位名称 */
24
+    private String pointName;
25
+
26
+    /** 点位类型: flow/pressure/level/quality/valve */
27
+    private String pointType;
28
+
29
+    /** 所属区域 */
30
+    private String area;
31
+
32
+    /** 经度 */
33
+    private BigDecimal lng;
34
+
35
+    /** 纬度 */
36
+    private BigDecimal lat;
37
+
38
+    /** 海拔高度(米) */
39
+    private BigDecimal elevation;
40
+
41
+    /** 关联设备ID(关联 prod_monitor_device.id) */
42
+    private Long deviceId;
43
+
44
+    /** 地址描述 */
45
+    private String address;
46
+
47
+    /** 状态: online/offline/fault */
48
+    private String status;
49
+
50
+    /** 扩展属性(JSON 格式,存储不同类型点位的特有属性) */
51
+    private String properties;
52
+
53
+    /** 备注 */
54
+    private String remark;
55
+
56
+    @TableField(fill = FieldFill.INSERT)
57
+    private LocalDateTime createdTime;
58
+
59
+    @TableField(fill = FieldFill.INSERT_UPDATE)
60
+    private LocalDateTime updatedTime;
61
+}

+ 29
- 0
wm-production/src/main/java/com/water/production/mapper/GisAreaMapper.java Просмотреть файл

1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.GisArea;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Select;
7
+
8
+import java.util.List;
9
+
10
+/**
11
+ * GIS 区域 Mapper
12
+ */
13
+@Mapper
14
+public interface GisAreaMapper extends BaseMapper<GisArea> {
15
+
16
+    /**
17
+     * 查询所有区域(不含边界详情,用于列表)
18
+     */
19
+    @Select("SELECT id, area_code, area_name, area_type, center_lng, center_lat, area_size, " +
20
+            "parent_id, device_count, online_count, alert_count, population, status, " +
21
+            "created_time, updated_time FROM prod_gis_area ORDER BY area_code")
22
+    List<GisArea> selectAllSummary();
23
+
24
+    /**
25
+     * 查询活跃区域数量
26
+     */
27
+    @Select("SELECT COUNT(*) FROM prod_gis_area WHERE status = 'active'")
28
+    Integer countActiveAreas();
29
+}

+ 59
- 0
wm-production/src/main/java/com/water/production/mapper/GisPipelineMapper.java Просмотреть файл

1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.GisPipeline;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.math.BigDecimal;
10
+import java.util.List;
11
+import java.util.Map;
12
+
13
+/**
14
+ * GIS 管网 Mapper
15
+ */
16
+@Mapper
17
+public interface GisPipelineMapper extends BaseMapper<GisPipeline> {
18
+
19
+    /**
20
+     * 矩形范围内的管线
21
+     */
22
+    @Select("<script>" +
23
+            "SELECT * FROM prod_gis_pipeline " +
24
+            "WHERE ((start_lng &gt;= #{minLng} AND start_lng &lt;= #{maxLng} " +
25
+            "AND start_lat &gt;= #{minLat} AND start_lat &lt;= #{maxLat}) " +
26
+            "OR (end_lng &gt;= #{minLng} AND end_lng &lt;= #{maxLng} " +
27
+            "AND end_lat &gt;= #{minLat} AND end_lat &lt;= #{maxLat})) " +
28
+            "<if test='pipelineType != null and pipelineType != \"\"'> AND pipeline_type = #{pipelineType}</if> " +
29
+            "<if test='area != null and area != \"\"'> AND area = #{area}</if> " +
30
+            "ORDER BY pipeline_code" +
31
+            "</script>")
32
+    List<GisPipeline> selectByRectangle(
33
+            @Param("minLng") BigDecimal minLng,
34
+            @Param("minLat") BigDecimal minLat,
35
+            @Param("maxLng") BigDecimal maxLng,
36
+            @Param("maxLat") BigDecimal maxLat,
37
+            @Param("pipelineType") String pipelineType,
38
+            @Param("area") String area);
39
+
40
+    /**
41
+     * 按区域统计管线长度
42
+     */
43
+    @Select("SELECT area, COUNT(*) as count, COALESCE(SUM(length), 0) as total_length " +
44
+            "FROM prod_gis_pipeline GROUP BY area ORDER BY area")
45
+    List<Map<String, Object>> countByArea();
46
+
47
+    /**
48
+     * 按类型统计管线
49
+     */
50
+    @Select("SELECT pipeline_type, COUNT(*) as count, COALESCE(SUM(length), 0) as total_length " +
51
+            "FROM prod_gis_pipeline GROUP BY pipeline_type ORDER BY pipeline_type")
52
+    List<Map<String, Object>> countByType();
53
+
54
+    /**
55
+     * 总管长
56
+     */
57
+    @Select("SELECT COALESCE(SUM(length), 0) FROM prod_gis_pipeline")
58
+    BigDecimal selectTotalLength();
59
+}

+ 95
- 0
wm-production/src/main/java/com/water/production/mapper/GisPointMapper.java Просмотреть файл

1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.GisPoint;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.math.BigDecimal;
10
+import java.util.List;
11
+import java.util.Map;
12
+
13
+/**
14
+ * GIS 监测点位 Mapper
15
+ */
16
+@Mapper
17
+public interface GisPointMapper extends BaseMapper<GisPoint> {
18
+
19
+    /**
20
+     * 矩形范围查询
21
+     */
22
+    @Select("<script>" +
23
+            "SELECT * FROM prod_gis_point " +
24
+            "WHERE lng &gt;= #{minLng} AND lng &lt;= #{maxLng} " +
25
+            "AND lat &gt;= #{minLat} AND lat &lt;= #{maxLat} " +
26
+            "<if test='pointType != null and pointType != \"\"'> AND point_type = #{pointType}</if> " +
27
+            "<if test='area != null and area != \"\"'> AND area = #{area}</if> " +
28
+            "<if test='status != null and status != \"\"'> AND status = #{status}</if> " +
29
+            "<if test='keyword != null and keyword != \"\"'> AND (point_code ILIKE CONCAT('%',#{keyword},'%') OR point_name ILIKE CONCAT('%',#{keyword},'%'))</if> " +
30
+            "ORDER BY updated_time DESC" +
31
+            "</script>")
32
+    List<GisPoint> selectByRectangle(
33
+            @Param("minLng") BigDecimal minLng,
34
+            @Param("minLat") BigDecimal minLat,
35
+            @Param("maxLng") BigDecimal maxLng,
36
+            @Param("maxLat") BigDecimal maxLat,
37
+            @Param("pointType") String pointType,
38
+            @Param("area") String area,
39
+            @Param("status") String status,
40
+            @Param("keyword") String keyword);
41
+
42
+    /**
43
+     * 圆形范围查询(基于 Haversine 公式近似计算距离)
44
+     */
45
+    @Select("<script>" +
46
+            "SELECT *, " +
47
+            "(6371000 * acos(cos(radians(#{centerLat})) * cos(radians(lat)) * " +
48
+            "cos(radians(lng) - radians(#{centerLng})) + " +
49
+            "sin(radians(#{centerLat})) * sin(radians(lat)))) AS distance " +
50
+            "FROM prod_gis_point " +
51
+            "WHERE (6371000 * acos(cos(radians(#{centerLat})) * cos(radians(lat)) * " +
52
+            "cos(radians(lng) - radians(#{centerLng})) + " +
53
+            "sin(radians(#{centerLat})) * sin(radians(lat)))) &lt;= #{radius} " +
54
+            "<if test='pointType != null and pointType != \"\"'> AND point_type = #{pointType}</if> " +
55
+            "<if test='area != null and area != \"\"'> AND area = #{area}</if> " +
56
+            "<if test='status != null and status != \"\"'> AND status = #{status}</if> " +
57
+            "ORDER BY distance ASC" +
58
+            "</script>")
59
+    List<Map<String, Object>> selectByCircle(
60
+            @Param("centerLng") BigDecimal centerLng,
61
+            @Param("centerLat") BigDecimal centerLat,
62
+            @Param("radius") BigDecimal radius,
63
+            @Param("pointType") String pointType,
64
+            @Param("area") String area,
65
+            @Param("status") String status);
66
+
67
+    /**
68
+     * 按区域统计点位数量
69
+     */
70
+    @Select("SELECT area, COUNT(*) as count, " +
71
+            "COUNT(*) FILTER (WHERE status = 'online') as online_count, " +
72
+            "COUNT(*) FILTER (WHERE status = 'offline') as offline_count, " +
73
+            "COUNT(*) FILTER (WHERE status = 'fault') as fault_count " +
74
+            "FROM prod_gis_point " +
75
+            "GROUP BY area ORDER BY area")
76
+    List<Map<String, Object>> countByArea();
77
+
78
+    /**
79
+     * 按类型统计点位数量
80
+     */
81
+    @Select("SELECT point_type, COUNT(*) as count FROM prod_gis_point GROUP BY point_type ORDER BY point_type")
82
+    List<Map<String, Object>> countByType();
83
+
84
+    /**
85
+     * 热力图网格聚合(按指定网格大小)
86
+     */
87
+    @Select("SELECT " +
88
+            "(ROUND(lng::numeric / #{gridSize}, 4) * #{gridSize}) as grid_lng, " +
89
+            "(ROUND(lat::numeric / #{gridSize}, 4) * #{gridSize}) as grid_lat, " +
90
+            "COUNT(*) as weight " +
91
+            "FROM prod_gis_point " +
92
+            "GROUP BY grid_lng, grid_lat " +
93
+            "ORDER BY weight DESC")
94
+    List<Map<String, Object>> selectHeatmapData(@Param("gridSize") BigDecimal gridSize);
95
+}

+ 194
- 0
wm-production/src/main/java/com/water/production/service/GisService.java Просмотреть файл

1
+package com.water.production.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.production.dto.GisStatisticsVO;
5
+import com.water.production.dto.SpatialQueryRequest;
6
+import com.water.production.entity.GisArea;
7
+import com.water.production.entity.GisPipeline;
8
+import com.water.production.entity.GisPoint;
9
+import com.water.production.mapper.GisAreaMapper;
10
+import com.water.production.mapper.GisPipelineMapper;
11
+import com.water.production.mapper.GisPointMapper;
12
+import lombok.RequiredArgsConstructor;
13
+import org.springframework.stereotype.Service;
14
+
15
+import java.math.BigDecimal;
16
+import java.util.*;
17
+import java.util.stream.Collectors;
18
+
19
+@Service
20
+@RequiredArgsConstructor
21
+public class GisService {
22
+
23
+    private final GisPointMapper pointMapper;
24
+    private final GisPipelineMapper pipelineMapper;
25
+    private final GisAreaMapper areaMapper;
26
+
27
+    /**
28
+     * 获取所有GIS点位
29
+     */
30
+    public List<GisPoint> listPoints(String pointType, String area, String status) {
31
+        LambdaQueryWrapper<GisPoint> wrapper = new LambdaQueryWrapper<>();
32
+        if (pointType != null && !pointType.isBlank()) wrapper.eq(GisPoint::getPointType, pointType);
33
+        if (area != null && !area.isBlank()) wrapper.eq(GisPoint::getArea, area);
34
+        if (status != null && !status.isBlank()) wrapper.eq(GisPoint::getStatus, status);
35
+        return pointMapper.selectList(wrapper);
36
+    }
37
+
38
+    /**
39
+     * 空间查询 - 矩形/圆形范围
40
+     */
41
+    public List<GisPoint> spatialQuery(SpatialQueryRequest request) {
42
+        List<GisPoint> allPoints = pointMapper.selectList(null);
43
+
44
+        return allPoints.stream()
45
+            .filter(p -> {
46
+                if (request.getQueryType() == null) return true;
47
+                if ("rectangle".equals(request.getQueryType())) {
48
+                    return isInRectangle(p, request.getMinLng(), request.getMinLat(),
49
+                        request.getMaxLng(), request.getMaxLat());
50
+                } else if ("circle".equals(request.getQueryType())) {
51
+                    return isInCircle(p, request.getCenterLng(), request.getCenterLat(),
52
+                        request.getRadius());
53
+                }
54
+                return true;
55
+            })
56
+            .filter(p -> request.getPointType() == null || request.getPointType().equals(p.getPointType()))
57
+            .filter(p -> request.getArea() == null || request.getArea().equals(p.getArea()))
58
+            .filter(p -> request.getStatus() == null || request.getStatus().equals(p.getStatus()))
59
+            .collect(Collectors.toList());
60
+    }
61
+
62
+    /**
63
+     * 获取管网数据
64
+     */
65
+    public List<GisPipeline> listPipelines(String area, String pipeType) {
66
+        LambdaQueryWrapper<GisPipeline> wrapper = new LambdaQueryWrapper<>();
67
+        if (area != null && !area.isBlank()) wrapper.like(GisPipeline::getArea, area);
68
+        if (pipeType != null && !pipeType.isBlank()) wrapper.eq(GisPipeline::getPipeType, pipeType);
69
+        return pipelineMapper.selectList(wrapper);
70
+    }
71
+
72
+    /**
73
+     * 获取区域数据
74
+     */
75
+    public List<GisArea> listAreas() {
76
+        return areaMapper.selectList(null);
77
+    }
78
+
79
+    /**
80
+     * GIS统计
81
+     */
82
+    public GisStatisticsVO getStatistics() {
83
+        List<GisPoint> points = pointMapper.selectList(null);
84
+
85
+        GisStatisticsVO stats = new GisStatisticsVO();
86
+        stats.setTotalPoints(points.size());
87
+
88
+        // 按类型统计
89
+        Map<String, Long> typeCount = points.stream()
90
+            .collect(Collectors.groupingBy(GisPoint::getPointType, Collectors.counting()));
91
+        stats.setPointsByType(typeCount);
92
+
93
+        // 按区域统计
94
+        Map<String, Long> areaCount = points.stream()
95
+            .collect(Collectors.groupingBy(GisPoint::getArea, Collectors.counting()));
96
+        stats.setPointsByArea(areaCount);
97
+
98
+        // 在线率
99
+        long onlineCount = points.stream()
100
+            .filter(p -> "online".equals(p.getStatus()))
101
+            .count();
102
+        stats.setOnlineRate(points.isEmpty() ? 0 : (double) onlineCount / points.size());
103
+
104
+        // 报警数(故障设备)
105
+        long faultCount = points.stream()
106
+            .filter(p -> "fault".equals(p.getStatus()))
107
+            .count();
108
+        stats.setFaultCount(faultCount);
109
+
110
+        // 管网统计
111
+        long pipelineCount = pipelineMapper.selectCount(null);
112
+        stats.setTotalPipelines(pipelineCount);
113
+
114
+        long areaTotal = areaMapper.selectCount(null);
115
+        stats.setTotalAreas(areaTotal);
116
+
117
+        return stats;
118
+    }
119
+
120
+    /**
121
+     * 热力图数据
122
+     */
123
+    public List<Map<String, Object>> getHeatmapData(String pointType) {
124
+        LambdaQueryWrapper<GisPoint> wrapper = new LambdaQueryWrapper<>();
125
+        if (pointType != null && !pointType.isBlank()) wrapper.eq(GisPoint::getPointType, pointType);
126
+        List<GisPoint> points = pointMapper.selectList(wrapper);
127
+
128
+        // Aggregate by grid cell (approximate 0.01 degree ≈ 1km)
129
+        Map<String, Long> gridCounts = points.stream()
130
+            .collect(Collectors.groupingBy(
131
+                p -> {
132
+                    double lng = p.getLng() != null ? p.getLng().doubleValue() : 0;
133
+                    double lat = p.getLat() != null ? p.getLat().doubleValue() : 0;
134
+                    return String.format("%.2f,%.2f", lng, lat);
135
+                },
136
+                Collectors.counting()
137
+            ));
138
+
139
+        List<Map<String, Object>> heatmap = new ArrayList<>();
140
+        gridCounts.forEach((key, count) -> {
141
+            String[] parts = key.split(",");
142
+            Map<String, Object> cell = new LinkedHashMap<>();
143
+            cell.put("lng", Double.parseDouble(parts[0]));
144
+            cell.put("lat", Double.parseDouble(parts[1]));
145
+            cell.put("count", count);
146
+            cell.put("intensity", Math.min(count * 10, 100));
147
+            heatmap.add(cell);
148
+        });
149
+        return heatmap;
150
+    }
151
+
152
+    /**
153
+     * 点位CRUD
154
+     */
155
+    public GisPoint getPoint(Long id) { return pointMapper.selectById(id); }
156
+
157
+    public Long createPoint(GisPoint point) {
158
+        pointMapper.insert(point);
159
+        return point.getId();
160
+    }
161
+
162
+    public void updatePoint(GisPoint point) { pointMapper.updateById(point); }
163
+
164
+    public void deletePoint(Long id) { pointMapper.deleteById(id); }
165
+
166
+    // === Helper methods ===
167
+    private boolean isInRectangle(GisPoint p, BigDecimal minLng, BigDecimal minLat,
168
+                                   BigDecimal maxLng, BigDecimal maxLat) {
169
+        if (p.getLng() == null || p.getLat() == null) return false;
170
+        if (minLng == null || minLat == null || maxLng == null || maxLat == null) return true;
171
+        return p.getLng().compareTo(minLng) >= 0 && p.getLng().compareTo(maxLng) <= 0
172
+            && p.getLat().compareTo(minLat) >= 0 && p.getLat().compareTo(maxLat) <= 0;
173
+    }
174
+
175
+    private boolean isInCircle(GisPoint p, BigDecimal centerLng, BigDecimal centerLat,
176
+                                BigDecimal radius) {
177
+        if (p.getLng() == null || p.getLat() == null) return false;
178
+        if (centerLng == null || centerLat == null || radius == null) return true;
179
+        double distance = haversineDistance(
180
+            p.getLat().doubleValue(), p.getLng().doubleValue(),
181
+            centerLat.doubleValue(), centerLng.doubleValue());
182
+        return distance <= radius.doubleValue();
183
+    }
184
+
185
+    private double haversineDistance(double lat1, double lng1, double lat2, double lng2) {
186
+        double R = 6371000; // Earth radius in meters
187
+        double dLat = Math.toRadians(lat2 - lat1);
188
+        double dLng = Math.toRadians(lng2 - lng1);
189
+        double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
190
+            Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) *
191
+            Math.sin(dLng / 2) * Math.sin(dLng / 2);
192
+        return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
193
+    }
194
+}

+ 57
- 0
wm-production/src/main/resources/db/V3__gis_map.sql Просмотреть файл

1
+-- GIS Map Display DDL
2
+
3
+CREATE TABLE IF NOT EXISTS prod_gis_point (
4
+    id BIGSERIAL PRIMARY KEY,
5
+    point_code VARCHAR(50),
6
+    point_name VARCHAR(100),
7
+    point_type VARCHAR(20),
8
+    area VARCHAR(50),
9
+    lng NUMERIC(10,6),
10
+    lat NUMERIC(10,6),
11
+    elevation NUMERIC(8,2),
12
+    device_id BIGINT,
13
+    address VARCHAR(200),
14
+    status VARCHAR(20) DEFAULT 'online',
15
+    properties TEXT,
16
+    remark TEXT,
17
+    created_time TIMESTAMP DEFAULT NOW(),
18
+    updated_time TIMESTAMP DEFAULT NOW()
19
+);
20
+
21
+CREATE TABLE IF NOT EXISTS prod_gis_pipeline (
22
+    id BIGSERIAL PRIMARY KEY,
23
+    pipeline_code VARCHAR(50),
24
+    pipeline_name VARCHAR(100),
25
+    pipe_type VARCHAR(20),
26
+    area VARCHAR(50),
27
+    diameter INT,
28
+    material VARCHAR(30),
29
+    length DOUBLE PRECISION,
30
+    start_lng NUMERIC(10,6),
31
+    start_lat NUMERIC(10,6),
32
+    end_lng NUMERIC(10,6),
33
+    end_lat NUMERIC(10,6),
34
+    coordinates TEXT,
35
+    status VARCHAR(20) DEFAULT 'normal',
36
+    created_time TIMESTAMP DEFAULT NOW()
37
+);
38
+
39
+CREATE TABLE IF NOT EXISTS prod_gis_area (
40
+    id BIGSERIAL PRIMARY KEY,
41
+    area_code VARCHAR(50),
42
+    area_name VARCHAR(100),
43
+    area_type VARCHAR(20),
44
+    boundary TEXT,
45
+    center_lng NUMERIC(10,6),
46
+    center_lat NUMERIC(10,6),
47
+    population INT,
48
+    description TEXT,
49
+    created_time TIMESTAMP DEFAULT NOW()
50
+);
51
+
52
+CREATE INDEX IF NOT EXISTS idx_gis_point_type ON prod_gis_point(point_type);
53
+CREATE INDEX IF NOT EXISTS idx_gis_point_area ON prod_gis_point(area);
54
+CREATE INDEX IF NOT EXISTS idx_gis_point_status ON prod_gis_point(status);
55
+CREATE INDEX IF NOT EXISTS idx_gis_point_coords ON prod_gis_point(lng, lat);
56
+CREATE INDEX IF NOT EXISTS idx_gis_pipeline_area ON prod_gis_pipeline(area);
57
+CREATE INDEX IF NOT EXISTS idx_gis_pipeline_type ON prod_gis_pipeline(pipe_type);

+ 261
- 0
wm-production/src/test/java/com/water/production/service/VideoMonitorServiceTest.java Просмотреть файл

1
+package com.water.production.service;
2
+
3
+import com.water.production.entity.IntrusionEvent;
4
+import com.water.production.entity.VideoCamera;
5
+import com.water.production.entity.VideoRecording;
6
+import org.junit.jupiter.api.DisplayName;
7
+import org.junit.jupiter.api.Test;
8
+
9
+import java.math.BigDecimal;
10
+import java.time.LocalDate;
11
+import java.time.LocalDateTime;
12
+
13
+import static org.junit.jupiter.api.Assertions.*;
14
+
15
+/**
16
+ * VideoMonitorService & IntrusionDetectionService 单元测试
17
+ * 测试实体、业务逻辑、状态流转等核心功能
18
+ */
19
+class VideoMonitorServiceTest {
20
+
21
+    // ========== Test 1: VideoCamera 实体完整性 ==========
22
+
23
+    @Test
24
+    @DisplayName("测试VideoCamera实体字段完整性")
25
+    void testVideoCameraEntityFields() {
26
+        VideoCamera camera = new VideoCamera();
27
+        camera.setId(1L);
28
+        camera.setCameraId("CAM-001");
29
+        camera.setName("一体化水厂-沉淀池");
30
+        camera.setArea("一体化水厂");
31
+        camera.setStreamUrlRtsp("rtsp://192.168.1.100/stream1");
32
+        camera.setStreamUrlHls("http://192.168.1.100/hls/stream1.m3u8");
33
+        camera.setStreamUrlFlv("http://192.168.1.100/flv/stream1.flv");
34
+        camera.setStatus(1);
35
+        camera.setManufacturer("海康威视");
36
+        camera.setModel("DS-2CD2T26FWDA3-IS");
37
+        camera.setLng(87.5712);
38
+        camera.setLat(43.7928);
39
+        camera.setInstallLocation("一体化水厂沉淀池北侧");
40
+        camera.setInstallDate(LocalDate.of(2024, 3, 15));
41
+        camera.setAiEnabled(1);
42
+        camera.setLastOnlineTime(LocalDateTime.now());
43
+
44
+        assertEquals("CAM-001", camera.getCameraId());
45
+        assertEquals("一体化水厂-沉淀池", camera.getName());
46
+        assertEquals(1, camera.getStatus());
47
+        assertEquals("海康威视", camera.getManufacturer());
48
+        assertEquals(1, camera.getAiEnabled());
49
+        assertNotNull(camera.getStreamUrlRtsp());
50
+        assertNotNull(camera.getStreamUrlHls());
51
+        assertNotNull(camera.getStreamUrlFlv());
52
+        assertNotNull(camera.getLastOnlineTime());
53
+        assertNotNull(camera.getInstallDate());
54
+    }
55
+
56
+    // ========== Test 2: VideoCamera 状态值验证 ==========
57
+
58
+    @Test
59
+    @DisplayName("测试VideoCamera状态值定义")
60
+    void testCameraStatusValues() {
61
+        VideoCamera camera = new VideoCamera();
62
+
63
+        // 离线
64
+        camera.setStatus(0);
65
+        assertEquals(0, camera.getStatus());
66
+
67
+        // 在线
68
+        camera.setStatus(1);
69
+        assertEquals(1, camera.getStatus());
70
+
71
+        // 故障
72
+        camera.setStatus(2);
73
+        assertEquals(2, camera.getStatus());
74
+
75
+        // 验证AI开关
76
+        camera.setAiEnabled(0);
77
+        assertEquals(0, camera.getAiEnabled());
78
+        camera.setAiEnabled(1);
79
+        assertEquals(1, camera.getAiEnabled());
80
+    }
81
+
82
+    // ========== Test 3: IntrusionEvent 实体与报警等级 ==========
83
+
84
+    @Test
85
+    @DisplayName("测试IntrusionEvent实体与报警等级映射")
86
+    void testIntrusionEventAndAlertLevel() {
87
+        IntrusionEvent event = new IntrusionEvent();
88
+        event.setId(1L);
89
+        event.setCameraId(1L);
90
+        event.setCameraName("一体化水厂-沉淀池");
91
+        event.setArea("一体化水厂");
92
+        event.setEventType("person_intrusion");
93
+        event.setConfidence(BigDecimal.valueOf(0.9523));
94
+        event.setAlertLevel("critical");
95
+        event.setAlertStatus(0);
96
+        event.setDetectedAt(LocalDateTime.now());
97
+        event.setSnapshotUrl("/snapshots/CAM-001_1718000000.jpg");
98
+        event.setVideoClipUrl("/clips/CAM-001_1718000000.mp4");
99
+
100
+        assertEquals("person_intrusion", event.getEventType());
101
+        assertEquals(0, event.getAlertStatus());
102
+        assertTrue(event.getConfidence().compareTo(BigDecimal.valueOf(0.95)) > 0);
103
+        assertEquals("critical", event.getAlertLevel());
104
+        assertNotNull(event.getSnapshotUrl());
105
+        assertNotNull(event.getVideoClipUrl());
106
+
107
+        // 测试事件类型枚举值
108
+        event.setEventType("person_loitering");
109
+        assertEquals("person_loitering", event.getEventType());
110
+
111
+        event.setEventType("zone_breach");
112
+        assertEquals("zone_breach", event.getEventType());
113
+    }
114
+
115
+    // ========== Test 4: IntrusionEvent 报警状态流转 ==========
116
+
117
+    @Test
118
+    @DisplayName("测试闯入事件报警状态流转")
119
+    void testIntrusionEventStatusFlow() {
120
+        IntrusionEvent event = new IntrusionEvent();
121
+        event.setId(100L);
122
+        event.setAlertStatus(0); // 待处理
123
+
124
+        // 待处理 → 已确认
125
+        assertEquals(0, event.getAlertStatus());
126
+        event.setAlertStatus(1);
127
+        event.setHandledBy(10L);
128
+        event.setHandledTime(LocalDateTime.now());
129
+        assertEquals(1, event.getAlertStatus());
130
+        assertEquals(10L, event.getHandledBy());
131
+
132
+        // 已确认 → 已处理
133
+        event.setAlertStatus(2);
134
+        event.setHandlerName("张三");
135
+        event.setHandleResult("已派人现场核查,确认为工作人员");
136
+        assertEquals(2, event.getAlertStatus());
137
+        assertNotNull(event.getHandleResult());
138
+        assertEquals("张三", event.getHandlerName());
139
+
140
+        // 或: 待处理 → 已忽略(误报)
141
+        IntrusionEvent event2 = new IntrusionEvent();
142
+        event2.setAlertStatus(0);
143
+        event2.setAlertStatus(3);
144
+        event2.setRemark("AI误报,实际为动物经过");
145
+        assertEquals(3, event2.getAlertStatus());
146
+        assertNotNull(event2.getRemark());
147
+    }
148
+
149
+    // ========== Test 5: VideoRecording 实体与录像类型 ==========
150
+
151
+    @Test
152
+    @DisplayName("测试VideoRecording实体与录像类型")
153
+    void testVideoRecordingEntity() {
154
+        VideoRecording recording = new VideoRecording();
155
+        recording.setId(1L);
156
+        recording.setCameraId(1L);
157
+        recording.setCameraName("一体化水厂-沉淀池");
158
+        recording.setArea("一体化水厂");
159
+        recording.setStartTime(LocalDateTime.of(2025, 6, 14, 10, 0, 0));
160
+        recording.setEndTime(LocalDateTime.of(2025, 6, 14, 10, 30, 0));
161
+        recording.setDurationSec(1800);
162
+        recording.setFileSizeMb(BigDecimal.valueOf(256.5));
163
+        recording.setStoragePath("/data/recordings/2025/06/14/CAM-001_100000.mp4");
164
+        recording.setPlaybackUrl("http://192.168.1.100:8080/playback/CAM-001_100000.mp4");
165
+        recording.setRecordType("scheduled");
166
+
167
+        assertEquals(1800, recording.getDurationSec());
168
+        assertEquals("scheduled", recording.getRecordType());
169
+        assertNotNull(recording.getPlaybackUrl());
170
+        assertEquals(0, BigDecimal.valueOf(256.5).compareTo(recording.getFileSizeMb()));
171
+
172
+        // 事件触发录像
173
+        recording.setRecordType("event_triggered");
174
+        recording.setEventId(100L);
175
+        assertEquals("event_triggered", recording.getRecordType());
176
+        assertEquals(100L, recording.getEventId());
177
+
178
+        // 手动录像
179
+        recording.setRecordType("manual");
180
+        recording.setEventId(null);
181
+        assertEquals("manual", recording.getRecordType());
182
+        assertNull(recording.getEventId());
183
+    }
184
+
185
+    // ========== Test 6: AI检测置信度与报警等级映射逻辑 ==========
186
+
187
+    @Test
188
+    @DisplayName("测试AI检测置信度到报警等级的映射逻辑")
189
+    void testConfidenceToAlertLevelMapping() {
190
+        // 模拟 IntrusionDetectionService 中的映射逻辑
191
+        assertAlertLevel(0.96, "critical");
192
+        assertAlertLevel(0.91, "warning");
193
+        assertAlertLevel(0.87, "info");
194
+        assertAlertLevel(0.99, "critical");
195
+        assertAlertLevel(0.90, "info"); // 边界: >0.90 才是 warning
196
+    }
197
+
198
+    private void assertAlertLevel(double confidence, String expectedLevel) {
199
+        String level;
200
+        if (confidence > 0.95) {
201
+            level = "critical";
202
+        } else if (confidence > 0.90) {
203
+            level = "warning";
204
+        } else {
205
+            level = "info";
206
+        }
207
+        assertEquals(expectedLevel, level,
208
+                String.format("confidence=%.2f should map to %s", confidence, expectedLevel));
209
+    }
210
+
211
+    // ========== Test 7: 在线率计算逻辑 ==========
212
+
213
+    @Test
214
+    @DisplayName("测试设备在线率计算逻辑")
215
+    void testOnlineRateCalculation() {
216
+        // 模拟 5 台设备: 3在线, 1离线, 1故障
217
+        int total = 5;
218
+        long online = 3;
219
+        long offline = 1;
220
+        long fault = 1;
221
+
222
+        double onlineRate = (double) online / total * 100;
223
+        assertEquals(60.0, onlineRate, 0.01);
224
+
225
+        // 全部在线
226
+        onlineRate = (double) 5 / 5 * 100;
227
+        assertEquals(100.0, onlineRate, 0.01);
228
+
229
+        // 全部离线
230
+        onlineRate = (double) 0 / 5 * 100;
231
+        assertEquals(0.0, onlineRate, 0.01);
232
+
233
+        // 空设备列表
234
+        double emptyRate = 0 > 0 ? (double) 0 / 0 * 100 : 0;
235
+        assertEquals(0.0, emptyRate, 0.01);
236
+    }
237
+
238
+    // ========== Test 8: 回放地址生成逻辑 ==========
239
+
240
+    @Test
241
+    @DisplayName("测试回放地址生成逻辑")
242
+    void testPlaybackUrlGeneration() {
243
+        VideoRecording recording = new VideoRecording();
244
+        recording.setId(1L);
245
+        recording.setCameraId(1L);
246
+        recording.setCameraName("CAM-001");
247
+        recording.setStartTime(LocalDateTime.of(2025, 6, 14, 8, 0, 0));
248
+        recording.setEndTime(LocalDateTime.of(2025, 6, 14, 8, 30, 0));
249
+        recording.setPlaybackUrl("http://192.168.1.100:8080/playback/CAM-001_20250614080000.mp4");
250
+
251
+        // 验证回放URL包含关键信息
252
+        assertNotNull(recording.getPlaybackUrl());
253
+        assertTrue(recording.getPlaybackUrl().contains("CAM-001"));
254
+        assertTrue(recording.getPlaybackUrl().startsWith("http"));
255
+
256
+        // 模拟生成回放URL
257
+        String baseUrl = "http://192.168.1.100:8080/playback";
258
+        String generated = String.format("%s/%s_%s.mp4", baseUrl, "CAM-001", "20250614080000");
259
+        assertEquals("http://192.168.1.100:8080/playback/CAM-001_20250614080000.mp4", generated);
260
+    }
261
+}