Files
SecMPS/doc/设计文档/网关Owl模块整改方案_v1.0.md

424 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 网关 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<PagedResult<StandardDevice>> 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<PagedResult<StandardDevice>> 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<OwlPagedResult<OwlDeviceChannel>>(json)!;
var devices = new List<StandardDevice>();
// 第一遍: 映射 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<string, object?>
{
["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<string, object?>
{
["hasPtz"] = (ch.Ptztype ?? 0) > 0 ? "1" : "0",
["app"] = ch.App,
["streamId"] = ch.StreamId
}
});
}
}
return new PagedResult<StandardDevice> { 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
/// <summary>GET /events → StandardAlarm[]</summary>
public async Task<PagedResult<StandardAlarm>> 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<OwlPagedResult<OwlAiEvent>>(json)!;
return new PagedResult<StandardAlarm>
{
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<string, object?>
{
["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<byte[]> 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<OwlAdapter>("Owl:main");
if (owl == null) return Results.NotFound();
var bytes = await owl.GetEventImageAsync(path);
return Results.File(bytes, "image/jpeg");
});
```
### 3.4 后端 DTO 补充
```csharp
/// <summary>Owl AI 事件</summary>
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<StreamUrls> 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<ControlResult> SendControlAsync(string sourceDeviceId, string command, Dictionary<string, object?> 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 检测 |