Owl整改起点: 整改方案v1.0+检查报告就绪

This commit is contained in:
2026-06-03 22:58:36 +08:00
parent ff8d7bcaf5
commit 1ad76ae33b
2 changed files with 3842 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,423 @@
# 网关 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 检测 |