# 网关 Owl 模块整改方案 v1.0 > **版本**: 1.0 > **日期**: 2026-06-03 > **基准**: `doc/设计文档/网关owl模块检查报告20260603.md` > **架构原则**: 遵循网关设计原则 §3.2-3.4(显式、异步、统一分页、弹性 Extra、不修改已有接口签名) --- ## 1. 整改总览 | 阶段 | 优先级 | 内容 | 涉及文件 | 预计 | |:---:|:---:|------|------|:---:| | O1 | P0 | 设备通道展开 + OwlDevice 模型补全 | OwlAdapter.cs + OwlModels.cs | 2h | | O2 | P0 | AI 事件接入 IHasAlarms | OwlAdapter.cs + OwlModels.cs | 2h | | O3 | P1 | 回放取流修正 + PTZ 预设位 | OwlAdapter.cs | 1h | | O4 | P2 | AI 检测启停(IAcceptsControl) | OwlAdapter.cs | 1h | | O5 | P2 | 推流/拉流管理(可选独立路由) | Program.cs + OwlAdapter | 1.5h | | O6 | 验证 | 全量编译 + 联调 | — | 1h | | **合计** | — | — | **5 文件** | **~8.5h** | --- ## 2. 阶段 O1: 设备通道展开 + 模型补全(预计 2h) ### 2.1 现状与问题 ```csharp // 当前: GET /devices → 只返回NVR父设备 public async Task> GetDevicesAsync(...) { var json = await client.GetStringAsync($"/devices?page={page}&size={size}"); // MapDevice: IsParent=true, Category="硬盘录像机" — 无通道子设备 } ``` **后果**: Vol.Pro 设备列表只有 NVR,前端"预览"按钮找不到摄像头通道。 ### 2.2 整改设计 **改用** `GET /devices/channels` — Owl 的联合接口直接返回设备+通道的扁平列表: ```json { "items": [ { "id": "mp123", "type": "DEVICE", "name": "NVR-01", "is_online": "1", "channel_count": 4, ... }, { "id": "mp123/34020000001320000001", "type": "CHANNEL", "did": "mp123", "name": "仓库入口", "is_online": true, "ptztype": 1, ... }, { "id": "mp123/34020000001320000002", "type": "CHANNEL", "did": "mp123", "name": "仓库后门", "is_online": true, "ptztype": 0, ... } ], "total": 3 } ``` **映射逻辑**(单次请求完成父子映射): ```csharp // OwlDeviceChannel 联合模型 public class OwlDeviceChannel { public string? Id { get; set; } public string? Type { get; set; } // "DEVICE" | "CHANNEL" public string? Did { get; set; } // 通道所属设备ID public string? Name { get; set; } public string? IsOnline { get; set; } // DEVICE: "1"/"0", CHANNEL: true/false public string? Manufacturer { get; set; } public string? Model { get; set; } public string? Firmware { get; set; } public string? Longitude { get; set; } public string? Latitude { get; set; } public int? ChannelCount { get; set; } public int? Ptztype { get; set; } // CHANNEL: 0=无云台, 1=方向, 2=预置位 public string? App { get; set; } // CHANNEL: 流应用名 public string? StreamId { get; set; } // CHANNEL: 流ID // ... 其他字段 } ``` **GetDevicesAsync 重写**: ```csharp public async Task> GetDevicesAsync(int page, int size, string? keyword = null) { await _limiter.WaitAsync(); var client = await _auth.GetAuthenticatedClientAsync(); var url = $"/devices/channels?page={page}&size=1000"; // 大pageSize一次性获取 if (!string.IsNullOrEmpty(keyword)) url += $"&key={Uri.EscapeDataString(keyword)}"; var json = await client.GetStringAsync(url); var result = JsonSerializer.Deserialize>(json)!; var devices = new List(); // 第一遍: 映射 DEVICE 为父设备 var deviceItems = result.Items.Where(x => x.Type == "DEVICE").ToList(); var channelItems = result.Items.Where(x => x.Type == "CHANNEL").ToList(); foreach (var d in deviceItems) { // 收集该设备的通道 var childChannels = channelItems.Where(c => c.Did == d.Id).ToList(); devices.Add(new StandardDevice { SourceId = d.Id ?? "", Name = d.Name ?? d.Id ?? "", Category = "硬盘录像机", Group = "视频设备", IsOnline = d.IsOnline == "1", IsParent = true, IpAddress = d.Address, Extra = new Dictionary { ["manufacturer"] = d.Manufacturer, ["model"] = d.Model, ["firmware"] = d.Firmware, ["longitude"] = d.Longitude, ["latitude"] = d.Latitude, ["channelCount"] = d.ChannelCount ?? childChannels.Count } }); // 映射通道为子设备 foreach (var ch in childChannels) { devices.Add(new StandardDevice { SourceId = ch.Id ?? "", Name = ch.Name ?? $"通道{ch.Id}", Category = "摄像机", Group = "视频设备", IsOnline = ch.IsOnline?.ToLower() == "true" || ch.IsOnline == "1", IsParent = false, ParentSourceId = d.Id, Extra = new Dictionary { ["hasPtz"] = (ch.Ptztype ?? 0) > 0 ? "1" : "0", ["app"] = ch.App, ["streamId"] = ch.StreamId } }); } } return new PagedResult { Items = devices, Total = devices.Count }; } ``` ### 2.3 影响分析 | 影响点 | 说明 | |------|------| | 前端预览按钮 | 现在能找到 `DeviceGroup=视频设备, IsParent=否` 的通道子设备,预览按钮可用 | | 设备树同步 | A3 同步时有父子关系,`ParentSourceId` 解析为父设备 DeviceId | | 视频墙 | 摄像机通道列表包含 `hasPtz` 标识,云台面板按需显示 | | MC4/IoT | 零影响 — 不同适配器独立运行 | --- ## 3. 阶段 O2: AI 事件接入 IHasAlarms(预计 2h) ### 3.1 现状态 `OwlAdapter` **没有**实现 `IHasAlarms`,AI 事件走不到 Vol.Pro。 ### 3.2 整改设计 **OwlAdapter 增加 IHasAlarms 实现**: ```csharp public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAcceptsMetadataPush, IHasAlarms { // Capabilities 增加 HasAlarms = true /// GET /events → StandardAlarm[] public async Task> GetAlarmsAsync( int page, int size, DateTime from, DateTime to, string? level = null, string? state = null) { await _limiter.WaitAsync(); var client = await _auth.GetAuthenticatedClientAsync(); var fromMs = new DateTimeOffset(from).ToUnixTimeMilliseconds(); var toMs = new DateTimeOffset(to).ToUnixTimeMilliseconds(); var url = $"/events?page={page}&size={size}&start_ms={fromMs}&end_ms={toMs}"; var json = await client.GetStringAsync(url); var result = JsonSerializer.Deserialize>(json)!; return new PagedResult { Items = result.Items.Select(MapEventToAlarm).ToList(), Total = result.Total }; } private StandardAlarm MapEventToAlarm(OwlAiEvent e) => new() { AlarmId = $"owl-ai-{e.Id}", AdapterCode = AdapterCode, Level = e.Label switch { "person" => "重要", "car" => "重要", _ => "普通" }, Title = $"AI检测: {e.Label} (置信度 {e.Score:P0})", Content = $"通道{e.Cid}: {e.Zones ?? ""}", OccurTime = DateTimeOffset.FromUnixTimeMilliseconds(e.StartedAt ?? 0).DateTime, Status = e.EndedAt > 0 ? "已结束" : "未确认", Extra = new Dictionary { ["imagePath"] = e.ImagePath, ["score"] = e.Score, ["label"] = e.Label, ["model"] = e.Model } }; public async Task ConfirmAlarmAsync(string alarmId) { /* AI事件不支持确认 */ } public async Task EndAlarmAsync(string alarmId) { /* AI事件不支持结束 */ } } ``` ### 3.3 事件快照图片 网关注册一条 B-路由直接代理图片访问: ```csharp // 在 OwlAdapter 中增加 public async Task GetEventImageAsync(string imagePath) { var client = await _auth.GetAuthenticatedClientAsync(); return await client.GetByteArrayAsync($"/events/image/{imagePath}"); } // Program.cs 加路由 app.MapGet("/api/gateway/owl/image/{*path}", async (string path, AdapterRegistry registry) => { var owl = registry.FindByCode("Owl:main"); if (owl == null) return Results.NotFound(); var bytes = await owl.GetEventImageAsync(path); return Results.File(bytes, "image/jpeg"); }); ``` ### 3.4 后端 DTO 补充 ```csharp /// Owl AI 事件 public class OwlAiEvent { public long? Id { get; set; } public string? Did { get; set; } // 设备ID public string? Cid { get; set; } // 通道ID public long? StartedAt { get; set; } // 毫秒时间戳 public long? EndedAt { get; set; } public string? Label { get; set; } // person / car / ... public float? Score { get; set; } // 0.0-1.0 public string? Zones { get; set; } // 检测区域JSON public string? ImagePath { get; set; } public string? Model { get; set; } } ``` --- ## 4. 阶段 O3: 回放修正 + PTZ 扩展(预计 1h) ### 4.1 GetPlaybackUrlAsync 修正 当前手工拼 URL,改为调用 Owl API: GoWVP 文档中播放接口 POST /channels/{id}/play 返回的 `PlayOutput.Items[]` 包含 `Hls` 字段。录像回放无需额外接口——同一个 HLS 地址加上 `start_ms`/`end_ms` 参数即可。 **方案**: 保持当前实现(手工拼 URL 是 Owl 的约定用法),增加 URL 不存在时的 fallback: ```csharp public async Task GetPlaybackUrlAsync(string channelId, DateTime start, DateTime end) { await _limiter.WaitAsync(); var client = await _auth.GetAuthenticatedClientAsync(); var token = await _auth.GetTokenAsync(); var startMs = new DateTimeOffset(start).ToUnixTimeMilliseconds(); var endMs = new DateTimeOffset(end).ToUnixTimeMilliseconds(); var baseAddr = client.BaseAddress?.ToString().TrimEnd('/') ?? ""; return new StreamUrls { Hls = $"{baseAddr}/recordings/channels/{channelId}/index.m3u8?start_ms={startMs}&end_ms={endMs}&token={token}" }; } ``` 变化:`client.BaseAddress` → 实际 Owl 地址(之前隐式依赖 `HttpClient.BaseAddress` 已包含)。 ### 4.2 PTZ 预设位/巡航 `PtzControlAsync` 增加 action 参数透传: ```csharp public async Task PtzPresetAsync(string channelId, int presetIndex) { await _limiter.WaitAsync(); var client = await _auth.GetAuthenticatedClientAsync(); await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control", new { action = "preset", preset = presetIndex }); } ``` 无需修改 `IHasStreams` 接口——PTZ 扩展通过 `PtzControlAsync(direction: "preset_1")` 或新增公开方法由 B-路由直接调用。 --- ## 5. 阶段 O4: AI 检测启停(预计 1h) ### 5.1 通过 IAcceptsControl 暴露 `OwlAdapter` 实现 `IAcceptsControl`(已在 KMS 适配器中新增的接口): ```csharp public class OwlAdapter : ..., IAcceptsControl { public async Task SendControlAsync(string sourceDeviceId, string command, Dictionary parameters) { await _limiter.WaitAsync(); var client = await _auth.GetAuthenticatedClientAsync(); try { switch (command) { case "ai-enable": await client.PostAsync($"/channels/{sourceDeviceId}/ai/enable", null); break; case "ai-disable": await client.PostAsync($"/channels/{sourceDeviceId}/ai/disable", null); break; case "zone-add": await client.PostAsJsonAsync($"/channels/{sourceDeviceId}/zones", parameters!); break; default: return new ControlResult { Success = false, Message = $"不支持的指令: {command}" }; } return new ControlResult { Success = true }; } catch (Exception ex) { return new ControlResult { Success = false, Message = ex.Message }; } } } ``` 前端调用:`POST /api/gateway/control/Owl:main { deviceId: "ch123", command: "ai-enable" }` --- ## 6. 阶段 O5: 推流/拉流管理(可选项,预计 1.5h) ### 6.1 设计决策 推流/拉流管理属于**管理员操作**而非实时数据查询。建议通过 B-组新路由暴露,不新增 Core 接口: ```csharp // Program.cs — 推流/拉流 CRUD 路由组 app.MapGet("/api/gateway/owl/stream-pushs", async (int page, int size, AdapterRegistry registry) => { ... }); app.MapPost("/api/gateway/owl/stream-pushs", async (StreamPushRequest req, ...) => { ... }); // ... 类推 ``` ### 6.2 推流请求模型 ```csharp public class StreamPushRequest { public string Name { get; set; } = ""; public string App { get; set; } = "live"; public string Stream { get; set; } = ""; public bool? IsAuthEnabled { get; set; } } public class StreamProxyRequest { public string Name { get; set; } = ""; public string Type { get; set; } = "RTSP"; public string App { get; set; } = "live"; public string Stream { get; set; } = ""; public string? SourceUrl { get; set; } public int? Transport { get; set; } } ``` ### 6.3 作用 管理端通过网关统一管理 Owl 视频源添加/删除/状态查询,无需单独登录 Owl 控制台。前端可加"添加摄像头"按钮调用这些路由。 --- ## 7. 文件变更清单 | 文件 | 新增 | 修改 | 说明 | |------|:---:|:---:|------| | `OwlAdapter.cs` | — | ✅ | GetDevicesAsync 重写 + IHasAlarms 实现 + IAcceptsControl 实现 + PTZ 预设位 | | `OwlModels.cs` (新建) | ✅ | — | OwlDeviceChannel + OwlAiEvent + DTO 完整化(从 OwlAdapter.cs 分离) | | `OwlAuthHelper.cs` | — | ✅ | HealthCheck 端点改 /stats(需确认) | | `Program.cs` | ✅ | ✅ | AI 事件图片代理路由 + 推流/拉流路由(O5) | | `IAcceptsControl.cs` | — | — | 已存在(KMS 阶段新增) | --- ## 8. 与现有架构的兼容性 | 架构元素 | 影响 | |------|------| | IHasFlatDevices 签名 | 不变 — GetDevicesAsync 签名不变,仅内部实现改为调 /devices/channels | | IHasAlarms | OwlAdapter 新增实现,零冲突 — KMS 也实现了 IHasAlarms | | IAcceptsControl | OwlAdapter 新增实现 — KMS 已有实现,B10 路由自动发现 | | AdapterCapabilities | 扩展 HasAlarms=true, FeatureFlags["aiEnable"]=true | | Vol.Pro A3 同步 | ParentSourceId 已有解析逻辑,新通道子设备自然被正确处理 | | 前端 base_device.vue | 无改动 — 操作列按钮按 DeviceGroup="视频设备" 自动匹配新展开的通道 | --- ## 9. 验证点 | 场景 | 预期 | |------|------| | GET /api/gateway/devices?adapter=Owl:main | 返回 NVR 父设备 + 通道子设备,子设备有 hasPtz Extra | | Vol.Pro 设备列表 | 显示 Owl 摄像机通道,AdapterCode=Owl:main | | 前端预览按钮 | 通道子设备显示"预览"按钮,点击播放实时流 | | GET /api/gateway/alarms/Owl:main | 返回 AI 检测事件(人员/车辆等) | | 规则引擎 | 可将 Owl AI 事件作为告警源触发规则 | | POST /api/gateway/control/Owl:main ai-enable | 远程开启 Owl AI 检测 |