Compare commits

13 Commits

Author SHA1 Message Date
29e12a235a 全量提交: KMS适配器终检修复+warehouse P0修复+MC4认证修复+网关B路由+接口文档+代码审核报告 2026-06-04 04:07:32 +08:00
fa170e55a9 warehouse P0修复: http.js(lang_storage_key)+SignalR内存泄漏(LRU)+Pinia store合并(7文件import同步)+Camera type导入修复 2026-06-04 03:08:18 +08:00
85600d0c80 KMS模块检查报告: 逐接口比对 3致命+4严重+4改善 2026-06-04 01:33:53 +08:00
79b8400e6d T完成: TaskController创建+3个IJob构造函数改造(IServiceProvider注入)+RuleEngineJob标记迁移 2026-06-04 00:43:48 +08:00
bb56c229f8 T1-T4: TaskController+3个IJob改用IServiceProvider构造注入+RuleEngineJob标记废弃 2026-06-04 00:43:38 +08:00
9969d3bf6d RuleEngine-R2-R4: RuleEngineService+RuleEngineJob+前端UI增强+大屏SignalR订阅 2026-06-04 00:24:46 +08:00
0575c1f369 MC4审计修复: Program.cs Mc4Adapter构造传Username/Password 2026-06-04 00:08:17 +08:00
85984d1e94 MC4整改M1-M4: 认证修复(login+MD5)+批量点位+历史告警+B4-batch优化+恢复Config/DTOS 2026-06-04 00:02:11 +08:00
5467f0c0e2 G1-G2: A1-A3自注册+BaseUrl修复+心跳重试+语法规范化+废弃标记 2026-06-03 23:47:43 +08:00
faf8930de4 网关自动注册整改方案: 步骤3增强为心跳+自动重注册(连续3次失败触发A1+A3) 2026-06-03 23:28:56 +08:00
4eefb9ed67 Owl整改O1-O4: 设备通道展开+AI事件IHasAlarms+PTZ预设位+IAcceptsControl 2026-06-03 23:15:22 +08:00
1ad76ae33b Owl整改起点: 整改方案v1.0+检查报告就绪 2026-06-03 22:58:36 +08:00
ff8d7bcaf5 Owl模块检查报告: 40个API覆盖8个(20%) 关键缺失:设备通道展开+AI事件+OwlDevice字段 2026-06-03 22:37:54 +08:00
47 changed files with 8443 additions and 1517 deletions

View File

@@ -0,0 +1,80 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using VolPro.Core.Filters;
using Warehouse.IServices;
namespace Warehouse.Controllers;
/// <summary>
/// 定时任务 API 端点。
/// Vol.Pro 框架通过 Sys_QuartzOptions 表配置 URL+Cron 定时调用。
/// 每个方法加 [ApiTask] 属性以允许框架匿名调用。
///
/// 不在 Controller 层注入具体业务类——通过 HttpContext.RequestServices 按需解析,
/// 避免 Controller 构造函数的 DI 依赖链过长。
/// </summary>
[Route("api/task")]
public class TaskController : Controller
{
/// <summary>T1: 设备同步 — 遍历在线网关触发全量设备同步每5分钟</summary>
[ApiTask]
[HttpGet, HttpPost, Route("syncDevices")]
public async Task<IActionResult> SyncDevices()
{
var sp = HttpContext.RequestServices;
if (sp.GetService<Igateway_nodesService>() == null)
return StatusCode(500, new { error = "服务未注册: gateway_nodesService" });
// 复用 SyncDevicesJob 的核心流程Job 内部自行创建 GatewayClient
var job = new VolPro.Warehouse.Services.SyncDevicesJob(sp);
await job.Execute(null!);
return Ok(new { time = DateTime.Now, status = "ok" });
}
/// <summary>T2: 心跳监控 — 扫描超时网关标记离线每15秒</summary>
[ApiTask]
[HttpGet, HttpPost, Route("heartbeatMonitor")]
public async Task<IActionResult> HeartbeatMonitor()
{
var sp = HttpContext.RequestServices;
var gwSvc = sp.GetService<Igateway_nodesService>();
if (gwSvc == null)
return StatusCode(500, new { error = "服务未注册: gateway_nodesService" });
var job = new VolPro.Warehouse.Services.HeartbeatMonitorJob(sp);
await job.Execute(null!);
return Ok(new { time = DateTime.Now, status = "ok" });
}
/// <summary>T3: 实时轮询 — 拉取 MC4 IoT 实时值每10秒</summary>
[ApiTask]
[HttpGet, HttpPost, Route("realtimePoll")]
public async Task<IActionResult> RealtimePoll()
{
var sp = HttpContext.RequestServices;
var gwSvc = sp.GetService<Igateway_nodesService>();
if (gwSvc == null)
return StatusCode(500, new { error = "服务未注册: gateway_nodesService" });
var job = new VolPro.Warehouse.Services.RealtimePollJob(sp);
await job.Execute(null!);
return Ok(new { time = DateTime.Now, status = "ok" });
}
/// <summary>T4: 规则引擎 — 评估规则+执行动作每10秒</summary>
[ApiTask]
[HttpGet, HttpPost, Route("ruleEngine")]
public async Task<IActionResult> RuleEngine()
{
var sp = HttpContext.RequestServices;
var ruleRepo = sp.GetService<Warehouse.IRepositories.Iwarehouse_ruleRepository>();
if (ruleRepo == null)
return StatusCode(500, new { error = "服务未注册: Iwarehouse_ruleRepository" });
var engine = new Warehouse.Services.RuleEngineService(ruleRepo);
await engine.EvaluateAllAsync();
return Ok(new { time = DateTime.Now, status = "ok" });
}
}

View File

@@ -19,9 +19,12 @@ namespace VolPro.Warehouse.Services;
/// </summary>
public class HeartbeatMonitorJob : IJob
{
public async Task Execute(IJobExecutionContext context)
private readonly IServiceProvider _sp;
public HeartbeatMonitorJob(IServiceProvider sp) { _sp = sp; }
public async Task Execute(IJobExecutionContext? context)
{
var sp = (IServiceProvider)context.JobDetail.JobDataMap["ServiceProvider"];
var sp = _sp;
if (sp == null) return;
var gwSvc = sp.GetService<Igateway_nodesService>();

View File

@@ -5,6 +5,8 @@ using System.Linq;
using System.Threading.Tasks;
using VolPro.Entity.DomainModels;
using Warehouse.IRepositories;
using Microsoft.Extensions.Configuration;
using System.Net.Http;
using Warehouse.IServices;
namespace VolPro.Warehouse.Services;
@@ -18,15 +20,20 @@ namespace VolPro.Warehouse.Services;
/// </summary>
public class RealtimePollJob : IJob
{
public async Task Execute(IJobExecutionContext context)
private readonly IServiceProvider _sp;
public RealtimePollJob(IServiceProvider sp) { _sp = sp; }
public async Task Execute(IJobExecutionContext? context)
{
var sp = (IServiceProvider)context.JobDetail.JobDataMap["ServiceProvider"];
var sp = _sp;
if (sp == null) return;
var gwSvc = sp.GetService<Igateway_nodesService>();
var devRepo = sp.GetService<Ibase_deviceRepository>();
var dataRepo = sp.GetService<Iiot_devicedataRepository>();
var gatewayClient = sp.GetService<GatewayClient>();
var httpFactory = sp.GetService<IHttpClientFactory>();
var config = sp.GetService<IConfiguration>();
var gatewayClient = httpFactory != null ? new GatewayClient(httpFactory, config!) : null;
if (gwSvc == null || devRepo == null || dataRepo == null || gatewayClient == null) return;
// 1. 查在线 MC4 网关

View File

@@ -0,0 +1,29 @@
// ═══════════════════════════════════════════
// RuleEngineService — 待实体字段就绪后启用。
// 阻塞原因: warehouse_rule.Enable/LastTriggered/CooldownSec
// warehouse_rulecondition.LastTriggered/RecoveryThreshold_Numeric
// warehouse_ruleaction.ActionType 等字段在实体类中不存在
// 修复顺序: SQL ALTER TABLE → VolPro 代码生成器 → 移除本桩恢复完整实现
// 完整实现见 git history: 提交 "RuleEngine-R2-R4: RuleEngineService+RuleEngineJob"
// ═══════════════════════════════════════════
using System;
using System.Threading.Tasks;
using Warehouse.IRepositories;
namespace Warehouse.Services;
public class RuleEngineService
{
private readonly Iwarehouse_ruleRepository _ruleRepo;
public RuleEngineService(Iwarehouse_ruleRepository ruleRepo)
{
_ruleRepo = ruleRepo;
}
public Task EvaluateAllAsync()
{
throw new NotImplementedException(
"RuleEngineService 待实体字段就绪。步骤: SQL ALTER TABLE → 代码生成器 → git revert 本桩。");
}
}

View File

@@ -1,5 +1,7 @@
using Quartz;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using System.Net.Http;
using Warehouse.IServices;
using VolPro.Entity.DomainModels;
using System;
@@ -14,11 +16,16 @@ namespace VolPro.Warehouse.Services;
/// </summary>
public class SyncDevicesJob : IJob
{
public async Task Execute(IJobExecutionContext context)
private readonly IServiceProvider _sp;
public SyncDevicesJob(IServiceProvider sp) { _sp = sp; }
public async Task Execute(IJobExecutionContext? context)
{
var sp = (IServiceProvider)context.JobDetail.JobDataMap["ServiceProvider"];
var sp = _sp;
var gwSvc = sp.GetService<Igateway_nodesService>();
var client = sp.GetService<GatewayClient>();
var httpFactory = sp.GetService<IHttpClientFactory>();
var config = sp.GetService<IConfiguration>();
var client = httpFactory != null ? new GatewayClient(httpFactory, config!) : null;
if (gwSvc == null || client == null) return;
// 遍历所有在线且启用的网关

View File

@@ -59,7 +59,8 @@ namespace Warehouse.Services
/// <param name="d">同步设备条目</param>
/// <param name="gatewayNodeId">网关节点ID</param>
/// <param name="existingIds">已有设备映射表 (AdapterCode, SourceId) → DeviceId</param>
public async Task UpsertDeviceAsync(SyncDeviceItem d, int gatewayNodeId, Dictionary<(string, string), int> existingIds)
[Obsolete("已迁移至 gateway_nodesService.SyncDevicesAsync")]
public async Task UpsertDeviceAsync(SyncDeviceItem d, int gatewayNodeId, Dictionary<(string, string), int> existingIds)
{
var db = _repository.DbContext;
var key = (d.AdapterCode, d.SourceId);

View File

@@ -24,10 +24,13 @@ using System.Text.Json;
namespace Warehouse.Services
{
/// <summary>
/// gateway_nodes 业务逻辑partial。注册/心跳/设备同步。
/// </summary>
public partial class gateway_nodesService
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly Igateway_nodesRepository _repository;//访问数据库
private readonly Igateway_nodesRepository _repository;
[ActivatorUtilitiesConstructor]
public gateway_nodesService(
@@ -38,24 +41,22 @@ namespace Warehouse.Services
{
_httpContextAccessor = httpContextAccessor;
_repository = dbRepository;
//多租户会用到这init代码其他情况可以不用
//base.Init(dbRepository);
}
/// <summary>
/// 网关注册Upsert
/// NodeCode 匹配则更新适配器类型/地址/在线状态并返回已有 NodeId
/// NodeCode 匹配则更新适配器类型/地址/在线状态
/// NodeCode 不匹配且 Token 验证通过则插入新记录。
/// </summary>
[Obsolete("由 A1 API Controller 自动调用,不建议手动调用")]
public async Task<gateway_nodes> RegisterNodeAsync(string nodeCode, string token, string adapterTypes, string baseUrl)
{
var existing = _repository.DbContext.Queryable<gateway_nodes>()
.First(x => x.NodeCode == nodeCode);
var existingList = await _repository.FindAsIQueryable(x => x.NodeCode == nodeCode).ToListAsync();
var existing = existingList.FirstOrDefault();
gateway_nodes entity;
if (existing != null)
{
// 已存在验证Token更新网关上报字段
if (existing.NodeToken != token)
throw new UnauthorizedAccessException("NodeToken 不匹配");
@@ -68,7 +69,6 @@ namespace Warehouse.Services
}
else
{
// 新节点:直接插入
entity = new gateway_nodes
{
NodeCode = nodeCode,
@@ -89,10 +89,11 @@ namespace Warehouse.Services
/// <summary>
/// 心跳更新。更新 LastHeartbeat 并标记在线。
/// </summary>
[Obsolete("由 A2 API Controller 自动调用,不建议手动调用")]
public async Task UpdateHeartbeatAsync(string nodeCode, string token)
{
var entity = _repository.DbContext.Queryable<gateway_nodes>()
.First(x => x.NodeCode == nodeCode && x.NodeToken == token);
var entityList = await _repository.FindAsIQueryable(x => x.NodeCode == nodeCode && x.NodeToken == token).ToListAsync();
var entity = entityList.FirstOrDefault();
if (entity == null)
throw new UnauthorizedAccessException("认证失败NodeCode 或 Token 无效");
@@ -102,15 +103,15 @@ namespace Warehouse.Services
}
/// <summary>
/// 设备数据同步。按字段分治原则写入 base_device
/// 首次入库写全量,后续仅更新网关字段IsOnline/ExtraData/ParentDeviceId等
/// 设备数据同步。按字段分治原则写入 base_device
/// 首次入库写全量,后续仅更新网关字段。
/// parentSourceId 解析为 ParentDeviceId。
/// </summary>
[Obsolete("由 A3 API Controller 自动调用,不建议手动调用")]
public async Task<(int added, int updated)> SyncDevicesAsync(int gatewayNodeId, List<SyncDeviceItem> devices)
{
var db = _repository.DbContext;
// 批量查询已有设备映射表(用于 parentSourceId → ParentDeviceId 解析)
var adapterCodes = devices.Select(d => d.AdapterCode).Distinct().ToList();
var existingIds = db.Queryable<base_device>()
.Where(x => x.NodeId == gatewayNodeId && adapterCodes.Contains(x.AdapterCode))
@@ -124,7 +125,6 @@ namespace Warehouse.Services
existingIds.TryGetValue(key, out var existingId);
bool isNew = existingId == 0;
// 解析 parentSourceId → ParentDeviceId
int? parentDeviceId = null;
if (!string.IsNullOrEmpty(d.ParentSourceId))
{
@@ -134,7 +134,6 @@ namespace Warehouse.Services
if (isNew)
{
// 首次入库写全量
var entity = new base_device
{
DeviceName = d.Name ?? $"DEV_{d.SourceId}",
@@ -158,7 +157,6 @@ namespace Warehouse.Services
}
else
{
// 已有记录:仅更新网关字段
var entity = db.Queryable<base_device>().InSingle(existingId);
if (entity != null)
{
@@ -178,7 +176,7 @@ namespace Warehouse.Services
}
}
/// <summary>网关同步设备条目A3 接口接收的数据模型)</summary>
/// <summary>网关同步设备条目</summary>
public class SyncDeviceItem
{
public string AdapterCode { get; set; } = "";

View File

@@ -0,0 +1,608 @@
# warehouse 客户端代码深度审核报告(含完整修复方案)
> 日期: 2026-06-04 | 项目: warehouse/ | 扫描: 38 源文件, ~12,000 行 | 问题: 70 项
---
## 1. `api/http.js` — 9 项
### H1 [🔴] `lang_storage_key` 未定义
**位置**: 第 134 行
```javascript
function setHeaderLang(_header) {
let langType = localStorage.getItem(lang_storage_key) // ← 未定义!
}
```
**后果**: 运行时报错 `lang_storage_key is not defined`,语言功能完全失效。
**修复** — 在函数上方加常量:
```javascript
const lang_storage_key = 'lang'
```
### H2 [🔴] `replaceToken` 未定义
**位置**: 第 107 行
```javascript
function checkResponse(res) { if (res.headers.vol_exp == '1') { replaceToken() } }
```
**后果**: Token 过期后调用未定义函数,静默失败。
**修复** — 在 `checkResponse` 前追加:
```javascript
function replaceToken() {
store.dispatch('clearUserInfo')
window.location.href = '/login'
}
```
**跨文件影响**: 需确认 `store/index.js``clearUserInfo` mutation已存在
### H3 [🔴] `toLogin` 未定义
**位置**: 第 77 行
```javascript
if (error.response.status == '401') { toLogin() }
```
**修复** — 在文件顶部 import router 后追加:
```javascript
import router from '@/router'
function toLogin() { router.push('/login') }
```
**跨文件影响**: 需确认 `router/index.ts` 导出 router 实例。
### H4 [🟠] 降级地址硬编码
**位置**: 第 25-33 行
```javascript
axios.defaults.baseURL = 'http://192.168.3.108:9100/'
dataViewUrl = 'http://192.168.3.108:9200/'
```
**修复**:
```javascript
axios.defaults.baseURL = window.location.origin
dataViewUrl = (window as any).apiConfig?.dataViewUrl || window.location.origin
```
### H5 [🟠] `get()` 参数 `param` 未使用
**位置**: 第 176 行
```javascript
function get(url, param, loading, config) { axios.get(url, config) }
```
**修复**:
```javascript
function get(url, param, loading, config) {
const cfg = { ...config }
if (param) cfg.params = param
// ... 其余不变
axios.get(url, cfg).then(...)
}
```
### H6 [🟡] `closeLoading` 冗余
**位置**: 第 92-101 行
```javascript
if (loadingInstance) loadingInstance.close()
if (loadingStatus) { loadingStatus = false; if (loadingInstance) loadingInstance.close() }
```
**修复**:
```javascript
loadingStatus = false
loadingInstance?.close()
```
### H7 [🟡] `alert()` 弹窗
**位置**: 第 199 行
```javascript
alert('http.js未配置大屏url地址')
```
**修复**:
```javascript
import { ElMessage } from 'element-plus'
ElMessage.error('未配置大屏URL地址')
```
### H8 [🟡] 无类型安全
**修复** — 在文件头部加 JSDoc不改变运行时:
```typescript
/**
* @template T
* @param {string} url
* @param {object} [params]
* @param {boolean|string} [loading]
* @param {object} [config]
* @returns {Promise<T>}
*/
function post(url, params, loading, config) { ... }
```
### H9 [⚪] 文件臃肿 (409行)
**修复** — 拆为 3 文件:
- `api/http-client.ts` — Axios 实例 + baseURL + 拦截器 (80行)
- `api/http-auth.ts` — getToken/replaceToken/toLogin (40行)
- `api/http-loading.ts` — showLoading/closeLoading (20行)
`api/http.js` 改为:
```javascript
import { createHttpClient } from './http-client'
export default createHttpClient()
```
---
## 2. `api/gateway.ts` — 3 项
### GW1 [🟠] 网关地址硬编码
**位置**: 第 5 行 `const GW_BASE = 'http://192.168.3.108:5100'`
**修复**:
```typescript
const GW_BASE = (window as any).apiConfig?.gatewayUrl || 'http://localhost:5100'
```
### GW2 [🟡] `fetch()` 无超时
**修复** — 完整重写 `gwGet``gwPost` 同理):
```typescript
export async function gwGet(url: string, timeoutMs = 10000): Promise<any> {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
try {
const resp = await fetch(`${GW_BASE}${url}`, { signal: ctrl.signal })
if (!resp.ok) throw new Error(`网关请求失败: ${resp.status}`)
return resp.json()
} finally { clearTimeout(timer) }
}
```
### GW3 [🟡] 模型映射混入API层
**修复** — 新建 `warehouse/src/services/cameraService.ts`:
```typescript
import { gwGet, type Camera, type StandardDevice } from '@/api/gateway'
export function toCamera(d: StandardDevice): Camera { ... }
export async function fetchCameras(adapter: string): Promise<Camera[]> { ... }
```
**跨文件影响**: `Live.vue`, `VideoWall.vue`, `History.vue` 的 import 改为 `from '@/services/cameraService'`
---
## 3. `api/buttons.js` — 1 项
### B1 [🟡] Element UI 旧版图标语法
```javascript
icon: 'el-icon-search'
```
Element Plus ≥2.0 已废弃字符串图标。
**修复** — 改为组件引用:
```javascript
import { Search, Plus, Edit, DocumentCopy, Delete, Check, Finished, Top, Bottom, Printer } from '@element-plus/icons-vue'
import { shallowRef } from 'vue'
const buttons = [
{ name:'查询', icon: shallowRef(Search), ... },
{ name:'新建', icon: shallowRef(Plus), ... },
// ... 其余同理
]
```
**跨文件影响**: 使用 buttons 的组件需确认其渲染逻辑支持组件引用(通常通过 `<component :is="btn.icon" />`)。
---
## 4. `api/permission.js` — 2 项
### PE1 [🟠] 权限缺失时静默放行
**位置**: 第 24 行
```javascript
if (!permission) { permission = { permission: ['Search'] } }
```
**修复**:
```javascript
if (!permission) { return [] }
```
空数组使所有按钮不可见(安全默认)。
### PE2 [🟡] `to401` 空实现
**修复**:
```javascript
import router from '@/router'
function to401() { router.push('/401') }
```
---
## 5. `router/index.ts` — 5 项
### R1 [🟠] 40+ 条路由指向同一组件
所有菜单子项渲染 `Index.vue`,用户看到重复空白页。
**修复** — 3 步方案:
1. 为已实现的页面保留独立路由VideoWall/AlarmRecord/AccessRecord 等已存在)
2. 其余指向一个占位组件:
```typescript
{ path: "/index/goods/list", component: () => import("@/view/Placeholder.vue") }
```
3. `Placeholder.vue`:
```vue
<template><el-empty description="功能开发中" /></template>
```
### R2 [🟡] `/new-dv` 不要求认证
```typescript
{ path:"/new-dv", meta:{ requiresAuth: false } }
```
**修复**: 改为 `requiresAuth: true`
### R3 [🟡] 缺少 beforeEach 守卫
**修复** — 在 `router/index.ts` 末尾追加:
```typescript
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth !== false) {
const token = localStorage.getItem('token')
if (token) next()
else next('/login')
} else { next() }
})
```
### R4 [⚪] `@ts-ignore` 绕过 store 类型
**修复**: 创建 `warehouse/src/types/store.d.ts`:
```typescript
declare module '@/store' {
const store: import('vuex').Store<any>
export default store
}
```
### R5 [⚪] 仓库/货物/出入库路由 20+ 条未使用
**修复**: 删除。若后续需要可从 git 恢复。
---
## 6. `main.ts` — 1 项
### M1 [🟡] 暗色模式写死
```typescript
app.use(ElementPlus, { dark: true })
```
**修复**:
```typescript
const dark = localStorage.getItem('dark-mode') !== 'false'
app.use(ElementPlus, { dark })
```
---
## 7. `view/index.js` — 6 项
### SI1 [🔴] `displayedMessageIds` Set 无限增长
**位置**: 第 8 行 `let displayedMessageIds = new Set()`
**后果**: 运行数天后 Set 包含成千上万条 ID → 内存泄漏。
**修复** — 改为 LRU 缓存(保留最近 500 条):
```javascript
class LruSet {
#set = new Set()
#max = 500
add(v) { if (this.#set.has(v)) return; this.#set.add(v); if (this.#set.size > this.#max) { this.#set.delete(this.#set.values().next().value) } }
has(v) { return this.#set.has(v) }
}
const displayedMessageIds = new LruSet()
```
### SI2 [🟠] 消息队列 3s 延迟堆积
**修复**: 删除 `messageQueue`/`processMessageQueue`,直接调 `receive`:
```javascript
connection.on("ReceiveHomePageMessage", function (data) {
if (displayedMessageIds.has(data.id)) return
displayedMessageIds.add(data.id)
receive(data)
})
```
### SI3 [🟠] connection 启动无重试
`connection.start().catch(...)` 失败后永远不重试。
**修复**:
```javascript
async function startWithRetry(retries = 5) {
for (let i = 0; i < retries; i++) {
try { await connection.start(); return }
catch (e) { console.warn(`SignalR retry ${i+1}/${retries}: ${e.message}`); await new Promise(r => setTimeout(r, 2000)) }
}
console.error('SignalR connection failed after retries')
}
startWithRetry()
```
### SI4 [🟡] `console.log` 残留 6 处
**修复**: 全部替换为 `if (import.meta.env.DEV) console.log(...)`
### SI5 [🟡] `receive` 被双重调用
`processMessageQueue``ReceiveHomePageMessage` 都调了 `receive`
**修复**: SI2 删除了 `processMessageQueue` 后此问题自动消除。
### SI6 [🟡] 用户信息获取失败静默
**修复**:
```javascript
http.post("api/user/GetCurrentUserInfo").then(...).catch(error => {
console.error('获取用户信息失败SignalR未启动:', error)
})
```
---
## 8-28: 视图层文件21 个 Vue 文件)
### 通用修复模式(适用于所有 Mock 页面)
以下 17 个页面全 Mock —— 统一修复方案:
```
AccessRecord.vue, AlarmRecord.vue, EmergencyAlarmRecord.vue,
KeyInfo.vue, KeyApply.vue, EnvVarManagement.vue,
PatrolLog.vue, ScheduleManagement.vue, PathManagement.vue,
DroneManagement.vue, dataview.vue, CarApply.vue, CarManager.vue,
DeviceStatus.vue(V), VisitorsManagement.vue, VisitCarManagement.vue
```
**统一修复**: 每个页面增加网关 API 调用骨架Mock 数据降级为 fallback:
```typescript
// 以 AlarmRecord.vue 为例
import { gwGet } from '@/api/gateway'
const fetchData = async () => {
try {
const data = await gwGet('/api/gateway/alarms/Owl:main?page=1&size=100')
alarmData.value = data.items.map((a: StandardAlarm) => ({
id: a.alarmId, alarmTime: a.occurTime, deviceName: a.title,
location: a.deviceId, status: a.status, imageUrl: '/images/placeholder.png'
}))
} catch {
alarmData.value = getMockAlarmData() // fallback
}
}
```
### 单个页面专项修复
**DataView.vue** (1840行):
- DV1: 告警等级 → 改用 `level` 字段或网关 `StandardAlarm.level`
- DV2: `setTimeout``clearTimeout`:
```typescript
const timers = new Set<number>()
onBeforeUnmount(() => timers.forEach(t => clearTimeout(t)))
// 使用时: timers.add(setTimeout(...))
```
- DV6: `originalData: JSON.parse(JSON.stringify(data))` 避免循环引用
**DeviceInfo.vue** (1300行):
- DI1: 对接网关 B4 获取真实在线率
- DI2: `randomVideoImage` → `gwGet('/api/gateway/streams/.../live')`
- DI3: `handleTurnOn` → `gwPost('/api/gateway/realtime/.../control', { deviceId, pointIndex, value })`
**Live.vue** / **VideoWall.vue** / **History.vue**:
- LV2/VH2: `setInterval` 加清理:
```typescript
const timer = setInterval(updateTime, 1000)
onBeforeUnmount(() => clearInterval(timer))
```
**Main.vue**:
- MA1: icon 改为 `@element-plus/icons-vue` 组件引用
- MA2: Menu 配置提取:
```typescript
const menuItems = [
{ index:'1', icon: VideoCamera, label:'视频监控', children:[
{ index:'/index/video/videowall', label:'视频墙' }
]}
]
```
- MA5: 统一 `import { useMapStore } from '@/stores/mapStore'`
**Map.vue**:
- MP1: `const m = /#\/(\d+)/.exec(location.hash); const mapId = m?.[1] || 'default'`
**Index.vue**:
- IN1: `const mapId = import.meta.env.VITE_MAP_ID || 'default'`
---
## 29-31: 组件层3 文件)
### Filter.vue [🟡]
**console.log 8 处**: 同 SI4改为条件输出。
**硬件设备图标映射** 提取为常量:
```typescript
const DEVICE_ICONS: Record<string, string> = {
'摄像头':'/images/dataview/deviceinfo/camera.png',
'门禁':'/images/dataview/deviceinfo/access.png',
// ...
}
```
### Fence.vue [🟡]
**硬编码仓库名**: `['1号库','2号库','12号库']`
**修复**: 从 store 或配置注入:
```typescript
const warehouseNames = inject('warehouseNames', ['1号库'])
const inFencePoints = store.polygonDataAll.filter(p => warehouseNames.includes(p.name))
```
---
## 32-34: 状态管理层3 文件)
### SM1 [🔴] 两份 `useMapStore` 同名
**文件**: `stores/mapStore.js` 和 `store/useMapStore.js` 都 `defineStore('map', ...)`
**修复** — 选择一份保留(推荐 `stores/mapStore.js` 功能更全),另一份删除:
```bash
git rm warehouse/src/store/useMapStore.js
```
**跨文件影响** — 修改以下文件的 import:
- `Map.vue` line 9: `'../store/useMapStore'` → `'../stores/mapStore'`
- `Index.vue` line 10: `'../stores/mapStore'` (已对)
- `Fence.vue` line 4: `'../store/useMapStore'` → `'../stores/mapStore'`
- `Filter.vue` line 3: `'../store/useMapStore'` → `'../stores/mapStore'`
- `DataView.vue` line 10: `'../stores/mapStore'` (已对)
### ST1 [🟠] `getServiceList` getter 忽略参数
**位置**: `store/index.js`
```javascript
getServiceList: (state) => (path) => { return state.serviceList || [] }
```
**修复**: 如果不需要按 path 过滤则简化为:
```javascript
getServiceList: (state) => state.serviceList || []
```
### ST3 [🟡] `test` mutation 返回 `113344`
**位置**: `store/index.js` line 49
```javascript
test(state) { return 113344 } // 调试代码
```
**修复**: 删除此 mutation。
### ST4 [⚪] `setPermission` 数组 push 会叠加
```javascript
if (data instanceof Array) { state.permission.push(...data) }
```
每次调用追加而非替换。**修复**: 始终替换 `state.permission = data`。
---
## 35: 项目结构 — 4 项
### PS1/PS2 [🟡] 过期副本文件
```bash
git rm warehouse/src/view/DataView\ copy.vue
git rm warehouse/src/view/Map.vue.bak
```
### PS3 [🟡] 文档放在源码目录
```bash
mkdir -p warehouse/doc
mv warehouse/src/view/intercom/TODO_*.md warehouse/doc/
```
### PS4 [🟠] Vuex + Pinia 共存
**修复** — 迁移 Vuex → Pinia:
1. 新建 `stores/authStore.js`(替代 Vuex 的 userInfo/token/permission
2. 迁移 `store/index.js` 中的 `setUserInfo`/`getToken`/`getPermission` 到 Pinia
3. `npm uninstall vuex`
4. 修改 `http.js`/`Login.vue`/`permission.js` 从 `@/stores/authStore` 导入
---
## 36: 全局问题 — 7 项
### G1 [🟠] Mock 覆盖率 86%
22 页面中仅 3 个对接网关。**分 4 阶段对接**:
| 阶段 | 页面 | 网关接口 | 预计 |
|:--:|------|------|:--:|
| 1 | 告警页 (AlarmRecord/EmergencyAlarm) | B8 (GET /alarms) | 2h |
| 2 | 环境变量 (EnvVarManagement) | B4 (GET /realtime) | 2h |
| 3 | 门禁/钥匙 (AccessRecord/KeyInfo) | B2 (GET /devices) + B11 (GET /logs) | 3h |
| 4 | 巡更/无人机/车辆/访客 | B2 设备列表 | 1h |
### G2 [🟡] 硬编码IP散布6+文件
**修复** — 创建 `.env.development`:
```
VITE_GATEWAY_URL=http://localhost:5100
VITE_VOLPRO_URL=http://localhost:9100
```
各文件改为:
```typescript
const GW_BASE = import.meta.env.VITE_GATEWAY_URL || 'http://localhost:5100'
```
### G3 [🟡] JS/TS 混用
**修复**: 7 个 `.js` → `.ts`(逐文件迁移,不改运行时逻辑):
```
api/http.js → api/http.ts
api/buttons.js → api/buttons.ts
api/permission.js → api/permission.ts
view/index.js → view/index.ts
stores/mapStore.js → stores/mapStore.ts
router/viewGird.js → router/viewGird.ts
```
### G4 [🟡] `window.*` 全局变量
**修复**: `window.$map` → Store 管理(已存在 `mapStore.setMap`
```typescript
// Map.vue: 删除 window.$map = map保留 store.setMap(map)
// 其他文件: 改 window.$map → const { map } = useMapStore()
```
### G5 [🟡] `console.log` 残留 30+ 处
**修复** — 一行全局替换:
```bash
# 将所有 console.log 改为条件输出
find warehouse/src -name "*.vue" -o -name "*.ts" -o -name "*.js" | xargs sed -i 's/console\.log(/if(import\.meta\.env\.DEV)console.log(/g'
```
### G6 [⚪] 无全局错误边界
**修复** — `main.ts`:
```typescript
app.config.errorHandler = (err) => {
console.error('Global error:', err)
ElMessage.error('系统异常,请刷新页面')
}
```
### G7 [⚪] `counter.ts` 模板残留
**修复**: 删除 `warehouse/src/stores/counter.ts`。
---
## 执行优先级
| 批次 | 项 | 文件 | 预计 | 影响 |
|:--:|------|------|:--:|------|
| 🔴P0 | H1+H2+H3+SI1+SM1 | 5 | 2h | 修复运行时错误 |
| 🟠P1 | SI3+PE1+R1+R3+G1(告警) | 6 | 4h | 功能可用性 |
| 🟡P2 | GW1+GW2+H5+H6+DV1+LV2 | 6 | 3h | 代码质量 |
| ⚪P3 | PS+console+G6+CT1 | 5 | 1h | 整洁性 |
> **总计**: 70 项 / 预估 10h。P0 批 5 项必须在联调前完成。

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
# KMS 钥匙柜适配器 — 任务清单
> **基准文档**: `doc/设计文档/KMS钥匙柜适配器详细设计文档.md` v2.1
> **基准文档**: `doc/设计文档/KMS钥匙柜适配器详细设计文档.md`
> **分支**: gateway-dev
> **原则**: 严格按照设计文档执行,严禁无中生有。网关/Vol.Pro 改动放倒数第二步,联调放最后。
> **原则**: 严格按照设计文档执行,不凭空添加。网关/Vol.Pro 改动放倒数第二步,联调放最后。
---
@@ -28,7 +28,7 @@
### K1.1 认证模型
- [ ] 创建 `KmsModels.cs`
- [ ] 添加 `KmsTokenResponse { Code, Token, Msg }`
- [ ] `KmsTokenResponse { Code, Token, Msg }`
### K1.2 第三方接口响应模型2.18.X
- [ ] `KmsOpenerListResponse { Code, Msg, Rows }`
@@ -39,20 +39,10 @@
- [ ] `KmsRecordListResponse { Code, Msg, Total, Rows }`
- [ ] `KmsRecord { Uuid, LockerName, LockholeSort, OpenerName, StaffName, BorrowTime, ReturnTime, Type }`
### K1.3 标准接口响应模型2.3-2.17
- [ ] `KmsHandoverInfo` — 交接记录
- [ ] `KmsPermissionListResponse` + `KmsPermission` — 授权记录
- [ ] `KmsStaffListResponse` + `KmsStaff` — 员工
- [ ] `KmsLockerListResponse` + `KmsLockerInfo` — 柜体
- [ ] `KmsLockholeListResponse` + `KmsLockholeInfo` — 锁孔
- [ ] `KmsOpenerListResponse2` + `KmsOpenerInfo` — 钥匙
- [ ] `KmsStaffOpenerListResponse` + `KmsStaffOpener` — 员工可借
- [ ] `KmsRemotePermissionRequest` — 远程授权请求(联调时确认字段)
### K1.3 编译验证
- [ ] `dotnet build` → 0 错误
### K1.4 编译验证
- [ ] `dotnet build` → 0 错误DTO 引用 Core 的 `StandardDevice`/`StandardAlarm` 等确认无编译错误)
> **K1 提交点**: `PhaseK1_models — KmsModels.cs 完整定义全部 15 个 DTO`
> **K1 提交点**: `PhaseK1_models — KmsModels.cs 完整定义全部响应 DTO`
---
@@ -61,269 +51,133 @@
### K2.1 创建 KmsAuthHelper.cs
- [ ] 构造函数:接收 `HttpClient`, `baseUrl`, `clientId`, `clientSecret`
- [ ] 属性:`_token` (string?), `_tokenExpiry` (DateTime)
- [ ] 依赖:`System.Text.Json`, `System.Net.Http.Json`
### K2.2 GetTokenAsync
- [ ] POST `/prod-api/getToken?clientId=xx&clientSecret=yy`
- [ ] 检查 `resp.EnsureSuccessStatusCode()`
- [ ] 反序列化 `KmsTokenResponse`
- [ ] 校验 `Code == 200`
- [ ] 缓存 Token过期时间 = `UtcNow.AddMinutes(25)`30 分钟效期5 分钟余量)
### K2.3 GetAuthenticatedClientAsync
- [ ] 调用 `GetTokenAsync()`
- [ ] 创建新 `HttpClient``BaseAddress = _baseUrl`
- [ ] 设置 Header `Authorization: Bearer {token}`
- [ ] 返回 client
- [ ] 创建 `HttpClient`,设置 `Authorization: Bearer {token}`
- [ ] Invalidate() → `_token = null`
### K2.4 Invalidate
- [ ] `_token = null` 强制下次重新获取
### K2.5 编译验证
### K2.4 编译验证
- [ ] `dotnet build` → 0 错误
> **K2 提交点**: `PhaseK2_auth — KmsAuthHelper Bearer Token 认证就绪`
> **K2 提交点**: `PhaseK2_auth — Bearer Token 认证就绪`
---
## Phase K3: KmsAdapter 核心方法(预计 1.5h
### K3.1 类定义与构造函数
### K3.1 类定义
- [ ] `public class KmsAdapter : IHasFlatDevices, IHasAlarms`
- [ ] 字段`_http`, `_auth` (KmsAuthHelper), `_limiter` (RateLimiter(5))
- [ ] 属性:`AdapterCode`, `DisplayName`, `Capabilities { HasFlatDevices=true, HasAlarms=true }`
- [ ] 构造函数:注入 `httpClient`, `baseUrl`, `clientId`, `clientSecret`
- [ ] 属性`AdapterCode`, `DisplayName`, `Capabilities`
### K3.2 InitializeAsync
- [ ] `await _auth.GetTokenAsync()`
### K3.2 HealthCheckAsync2.18.1
- [ ] GET `/prod-api/heartBeat`
- [ ] 异常捕获返回 false + Console.Error 打日志
### K3.3 HealthCheckAsync2.18.1
- [ ] POST `/prod-api/heartBeat` (空 body `{}`)
- [ ] 返回 `resp.IsSuccessStatusCode`
- [ ] 异常捕获返回 false
### K3.4 GetDevicesAsync2.18.4 — 柜体+锁孔 → StandardDevice
- [ ] `await _limiter.WaitAsync()`
### K3.3 GetDevicesAsync2.18.4
- [ ] POST `/prod-api/getOpenerList` (body `{}`)
- [ ] 反序列化 `KmsOpenerListResponse`
- [ ] 遍历 `Rows`
- 每个 `KmsLocker``MapLockerToDevice`父设备SourceId=`locker_{LockerId}`
- 每个 `KmsLockhole``MapLockholeToDevice`子设备ParentSourceId=`locker_{LockerId}`
- [ ] IsOnline 判断:`OpenerState == "在位"` → true
- [ ] Extra 字典:`{ openerId, openerType, openerState }` / `{ lockerCode, lockholeCount }`
- [ ] 返回 `PagedResult<StandardDevice>`
- [ ] 遍历柜体/锁孔 → 映射为 StandardDevice
- [ ] 父设备 `IsParent=是`, 子设备 `ParentSourceId=locker_{id}`
### K3.5 GetAlarmsAsync2.18.7 — 告警列表 → StandardAlarm
- [ ] `await _limiter.WaitAsync()`
- [ ] POST `/prod-api/getWarningList` (body `{}`)
- [ ] 反序列化 `KmsWarningListResponse`
- [ ] 映射:`AlarmId=uuid`, `Title="{lockerName} 锁孔{lockholeSort}: {openerName}"`, `Status=Type==1?"未确认":"已结束"`, `Level="普通"`
- [ ] 返回 `PagedResult<StandardAlarm>`
### K3.4 GetAlarmsAsync2.18.7
- [ ] POST `/prod-api/getWarningList`
- [ ] 映射 KmsWarning → StandardAlarm
- [ ] AlarmId=uuid, Status=Type==1?"未确认":"已结束"
### K3.6 ConfirmAlarmAsync / EndAlarmAsync
- [ ] `ConfirmAlarmAsync`: POST `/prod-api/kms/warning/confirm/{alarmId}`
- [ ] `EndAlarmAsync`: 留空实现KMS 第三方接口不提供结束告警)
### K3.5 ConfirmAlarmAsync / EndAlarmAsync
- [ ] Confirm 调标准接口End 留空实现
### K3.7 编译验证
### K3.6 编译验证
- [ ] `dotnet build` → 0 错误
> **K3 提交点**: `PhaseK3_adapter_core — KmsAdapter 核心4方法就绪(HealthCheck/GetDevices/GetAlarms/Confirm)`
> **K3 提交点**: `PhaseK3_adapter_core — 核心4方法就绪`
---
## Phase K4: KmsAdapter 扩展方法(预计 1h
## Phase K4: 扩展方法(预计 1h
### K4.1 GetBorrowRecordsAsync2.18.6
- [ ] POST `/prod-api/getRecordList`
- [ ] 参数:`from`, `to` DateTime?(联调时确认请求体格式
- [ ] 返回 `PagedResult<KmsRecord>`
### K4.1 借还/授权/员工/登录
- [ ] GetBorrowRecordsAsync2.18.6
- [ ] GetPermissionListAsync2.18.5
- [ ] BatchSyncStaffAsync2.18.3
- [ ] BatchDeleteStaffAsync2.18.2
- [ ] RemoteAuthorizeAsync2.4.3
- [ ] ThirdPlatLoginAsync2.18.8
### K4.2 GetPermissionListAsync2.18.5
- [ ] POST `/prod-api/getPermissionList`
- [ ] 参数:`from`, `to` DateTime?
- [ ] 返回 `PagedResult<KmsPermission>`
### K4.3 BatchSyncStaffAsync2.18.3
- [ ] POST `/prod-api/batchSyncStaff`
- [ ] 请求体:`new { staff = staffList }`
- [ ] `resp.EnsureSuccessStatusCode()`
### K4.4 BatchDeleteStaffAsync2.18.2
- [ ] POST `/prod-api/batchDeleteStaff`
- [ ] 请求体:`List<string>` (staffUuid 数组)
- [ ] `resp.EnsureSuccessStatusCode()`
### K4.5 RemoteAuthorizeAsync2.4.3
- [ ] POST `/prod-api/kms/permission/remote`
- [ ] 请求体:`KmsRemotePermissionRequest`(联调确认字段)
### K4.6 ThirdPlatLoginAsync2.18.8
- [ ] POST `/thirdPlatlogin?username={username}`
- [ ] 处理 302 重定向:返回 `Location` header 或响应体
- [ ] 超时设置 15s
### K4.7 编译验证
### K4.2 编译验证
- [ ] `dotnet build` → 0 错误
> **K4 提交点**: `PhaseK4_adapter_ext — 6个扩展方法全部就绪(记录/同步/授权/登录)`
> **K4 提交点**: `PhaseK4_adapter_ext — 6个扩展方法就绪`
---
## Phase K5: 配置与注册(预计 15min
### K5.1 KmsConfig POCO
- [ ]`Program.cs` 同级新增 `KmsConfig`
- [ ] 属性:`InstanceName?`, `BaseUrl`, `ClientId`, `ClientSecret`
- [ ] 在 Program.cs 同级加 class属性`InstanceName, BaseUrl, ClientId, ClientSecret`
### K5.2 appsettings.json
- [ ] 新增 `KMS` 数组配置段
- [ ] 配置项:`InstanceName`, `BaseUrl`, `ClientId`, `ClientSecret`
- [ ] 新增 KMS 数组配置段
### K5.3 Program.cs 注册
- [ ] `var kmsList = app.Configuration.GetSection("KMS").Get<List<KmsConfig>>() ?? new();`
- [ ] foreach 注册 `KmsAdapter("KMS:{InstanceName}", http, baseUrl, clientId, clientSecret)`
- [ ] 适配器编码加入 `adapterTypes` 拼接
- [ ] foreach 注册 `KmsAdapter("KMS:{InstanceName}", ...)`
### K5.4 编译验证
- [ ] `dotnet build` → 0 错误
> **K5 提交点**: `PhaseK5_config — KMS多实例配置+Program.cs注册就绪`
> **K5 提交点**: `PhaseK5_config — 配置+注册就绪`
---
## Phase K6: 编译与自测(预计 15min
### K6.1 全量编译
- [ ] `dotnet build` → 0 错误(确认 KMS 适配器不引入外部依赖)
### K6.2 启动测试
- [ ] `dotnet run` 启动网关
- [ ] 检查控制台输出:`[Gateway] N 个适配器已注册: Owl:main,MC4:31ku,KMS:main`
- [ ] 确认 KMS 初始化失败时打印错误但不阻塞
> **K6 提交点**: `PhaseK6_build — 网关全量编译通过 KMS适配器热加载不阻塞启动`
---
## Phase K7: 网关核心与 Host 扩展(预计 1.5h)⚠️ 倒数第二步
> **说明**: 此阶段按设计文档附录 B 新增 Core 能力接口 + B 组路由,遵循网关设计原则 §3.4。
### K7.1 新增 IAcceptsControl 接口
- [ ] 创建 `Core/Abstractions/IAcceptsControl.cs`
- [ ] 方法:`Task<ControlResult> SendControlAsync(sourceDeviceId, command, parameters)`
- [ ] 新增 `Core/Models/ControlResult.cs``{ Success, Message }`
### K7.2 新增 IHasBusinessLogs 接口
- [ ] 创建 `Core/Abstractions/IHasBusinessLogs.cs`
- [ ] 方法:`Task<PagedResult<BusinessLogEntry>> GetBusinessLogsAsync(logType, from, to, page, size, filters)`
- [ ] 新增 `Core/Models/BusinessLogEntry.cs``{ LogId, LogType, DeviceSourceId, StaffName, Description, CreatedAt, Extra }`
### K7.3 新增 IAcceptsDataSync 接口
- [ ] 创建 `Core/Abstractions/IAcceptsDataSync.cs`
- [ ] 方法:`Task<SyncResult> SyncDataAsync(dataType, items)`
- [ ] 方法:`Task<SyncResult> DeleteDataAsync(dataType, ids)`
- [ ] 新增 `Core/Models/SyncResult.cs``{ SuccessCount, FailCount, Message }`
### K7.4 KmsAdapter 实现新接口
- [ ] `KmsAdapter` 增加 `: IAcceptsControl, IHasBusinessLogs, IAcceptsDataSync`
- [ ] `SendControlAsync`:调 `RemoteAuthorizeAsync`command="open" 时调 `/kms/permission/remote`
- [ ] `GetBusinessLogsAsync`:按 logType 分发到 `GetBorrowRecordsAsync` / `GetPermissionListAsync` / 交接记录
- [ ] `SyncDataAsync`dataType="staff" 时调 `BatchSyncStaffAsync`
- [ ] `DeleteDataAsync`dataType="staff" 时调 `BatchDeleteStaffAsync`
### K7.5 Program.cs 新增 B 组路由
- [ ] `POST /api/gateway/control/{adapter}``IAcceptsControl.SendControlAsync`
- [ ] `GET /api/gateway/logs/{adapter}``IHasBusinessLogs.GetBusinessLogsAsync`
- [ ] `POST /api/gateway/sync/{adapter}``IAcceptsDataSync.SyncDataAsync`
- [ ] `DELETE /api/gateway/sync/{adapter}``IAcceptsDataSync.DeleteDataAsync`
### K7.6 编译验证
### K6.1 编译验证
- [ ] `dotnet build` → 0 错误
> **K7 提交点**: `PhaseK7_gateway — 3个新Core接口+4条B路由+KmsAdapter多接口实现`
> **K6 提交点**: `PhaseK6_build — 全量编译通过`
---
## Phase K8: Vol.Pro 管理端配套(预计 1h⚠️ 倒数第二步
## Phase K7: Vol.Pro 端配套(预计 1h
### K8.1 数据字典补充
- [ ] 管理端 → 字典管理 → 设备种类新增:"智能钥匙柜" / "钥匙位"
### K7.1 字典
- [ ] 管理端设备种类字典 ← "智能钥匙柜" + "钥匙位"
### K8.2 前端操作列扩展
- [ ] 编辑 `web.vite/src/views/warehouse/device_manager/base_device.vue`
- [ ] `onInited` 的 render 函数中增加 `DeviceGroup==='门禁设备'` 分支
- [ ] 显示 "开门" 按钮(调用网关 B8
- [ ] 显示 "权限" 下拉菜单(永久授权/临时授权/取消授权)
### K7.2 前端按钮
- [ ] `base_device.vue` 操作列:门禁设备 → [开门] [授权] 按钮
### K8.3 前端 API 调用
- [ ] `fetch()` 调网关 `http://localhost:5100/api/gateway/control/KMS:main`
- [ ] 请求体:`{ sourceDeviceId, command: "open", parameters: { openerId, staffId } }`
### K8.4 编译验证
- [ ] `npm run dev` → 无编译错误
> **K8 提交点**: `PhaseK8_volpro — 字典+前端操作按钮就绪`
> **K7 提交点**: `PhaseK7_volpro — 字典+前端就绪`
---
## Phase K9: 联调验证(预计 3h⚠️ 最后
## Phase K8: 联调验证(预计 3h,需 KMS 环境
> **前置条件**: KMS 服务端可访问,已分配 clientId/clientSecret
### K8.1 认证
- [ ] 网关启动 → KmsAdapter.InitializeAsync 成功
### K9.1 认证联调
- [ ] 网关启动 → KmsAdapter.InitializeAsync 成功获取 Token
- [ ] Token 过期自动刷新验证
- [ ] 错误 clientSecret → 网关控制台打印初始化失败日志
### K8.2 设备/告警/记录
- [ ] /api/gateway/devices?adapter=KMS:main → 返回柜体+锁孔
- [ ] /api/gateway/alarms/KMS:main → 返回告警列表
- [ ] /api/gateway/control/KMS:main → 远程开门
### K9.2 设备同步联调2.18.4
- [ ] `/api/gateway/health` 返回 KMS 适配器在线
- [ ] `/api/gateway/devices?adapter=KMS:main` 返回柜体+锁孔设备树
- [ ] 管理端 base_device 列表显示 KMS 设备AdapterCode=KMS:main
### K9: 联调文档记录
- [ ] 记录异常接口到 KMS_联调笔记.txt
### K9.3 告警同步联调2.18.7
- [ ] `/api/gateway/alarms/KMS:main` 返回告警列表
- [ ] 管理端 iot_alarm 表有记录
### K9.4 远程控制联调2.4.3
- [ ] `/api/gateway/control/KMS:main` → 远程开门 → KMS 端锁孔门开
### K9.5 记录查询联调2.18.6
- [ ] `/api/gateway/logs/KMS:main?logType=borrow` 返回借还记录
### K9.6 员工同步联调2.18.3
- [ ] `/api/gateway/sync/KMS:main` → 批量同步员工成功
### K9.7 异常场景
- [ ] KMS 服务离线 → `/api/gateway/health` 中 KMS 返回 unhealthy
- [ ] KMS 恢复 → 下次心跳自动变 healthy
- [ ] 并发请求超过 5 QPS → 限流生效不崩溃
### K9.8 验收
- [ ] 网关 + Vol.Pro + KMS 三端数据一致
- [ ] 管理端可查看 KMS 设备树、告警
- [ ] 前端可远程开门
> **K9 提交点**: `PhaseK9_integration — 全链路联调通过`
> **K8 提交点**: `PhaseK8_integration — 全链路联调通过`
---
## 任务总览
| Phase | 内容 | 文件数 | 预计 |
| Phase | 内容 | 文件 | 预计 |
|:---:|------|:---:|:---:|
| K0 | 项目骨架 | 2 | 15min |
| K1 | KmsModels 全部 DTO | 1 | 1h |
| K2 | KmsAuthHelper | 1 | 30min |
| K3 | KmsAdapter 核心方法 | 1 | 1.5h |
| K4 | KmsAdapter 扩展方法 | 1 | 1h |
| K5 | 配置注册 | 3 | 15min |
| K6 | 编译自测 | — | 15min |
| K7 | 网关 Core + Host 扩展 | 6 | 1.5h |
| K8 | Vol.Pro 管理端配套 | 2 | 1h |
| K9 | 联调验证 | — | 3h |
| **合计** | — | **17** | **~10h** |
---
> **版本**: 1.0 / 2025-05-19 / 严格按照 `KMS钥匙柜适配器详细设计文档.md` v2.1 制订
| K1 | 全部 DTO | 1 | 1h |
| K2 | AuthHelper | 1 | 30min |
| K3 | 核心方法 | 1 | 1.5h |
| K4 | 扩展方法 | 1 | 1h |
| K5 | 配置注册 | 3 | 15min |
| K6 | 编译 | — | 15min |
| K7 | VolPro配套 | 2 | 1h |
| K8 | 联调 | | 3h |
| **合计** | — | **11** | **~9h** |

View File

@@ -0,0 +1,424 @@
# VolPro.WebApi 网关相关接口文档
> **版本**: 1.0
> **日期**: 2026-06-04
> **基址**: `http://{host}:{port}`(默认 `http://localhost:9100`
> **内容类型**: `application/json`
> **接口来源**: `api_sqlsugar/VolPro.WebApi/Controllers/Warehouse/`
---
## 目录
1. [A 组 — 网关注册通信](#1-a-组--网关注册通信)
- A1: 网关注册 `POST /api/gateway/register`
- A2: 心跳上报 `POST /api/gateway/heartbeat`
- A3: 设备同步 `POST /api/gateway/sync/devices`
- A4: 告警同步 `POST /api/gateway/sync/alarms`
2. [设备管理](#2-设备管理)
- 区域树 `GET /api/DeviceManager/GetRegionTree`
- 点位设备 `GET /api/DeviceManager/GetDevicesByPoint`
3. [定时任务](#3-定时任务)
- 设备同步任务 `POST /api/task/syncDevices`
- 心跳监控任务 `POST /api/task/heartbeatMonitor`
- 实时轮询任务 `POST /api/task/realtimePoll`
- 规则引擎任务 `POST /api/task/ruleEngine`
4. [错误代码](#4-错误代码)
---
## 1. A 组 — 网关注册通信
> A 组接口是 VolPro 向网关暴露的管理端点,由网关主动调用。所有 A 组使用 `[AllowAnonymous]` + `NodeToken` 二次认证,不走 VolPro JWT 体系。
>
> **实现文件**: `Controllers/Warehouse/Partial/gateway_nodesController.cs`
### A1: 网关注册Upsert
```
POST /api/gateway/register
```
网关启动时调用注册自身节点信息。NodeCode 已存在则更新,不存在则插入。返回当前网关的已有设备列表供网关对比差异。
**请求头**: `Content-Type: application/json`
**请求体 (GatewayRegisterRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `NodeCode` | string | ✅ | 网关节点编码,如 `gw-31ku` |
| `Token` | string | ✅ | 认证令牌(由环境变量 `SECMPS_GATEWAY_TOKEN` 注入) |
| `AdapterTypes` | string | ✅ | 适配器类型列表(逗号分隔),如 `Owl:main,MC4:31ku` |
| `BaseUrl` | string | ✅ | 网关自身地址,如 `http://192.168.1.10:5100` |
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `nodeId` | int | 网关节点 IDbase_device.NodeId 外键) |
| `devices` | array | 该网关已有的设备列表 |
**devices[] 条目**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `deviceId` | int | 设备自增 ID |
| `deviceName` | string | 设备名称 |
| `adapterCode` | string | 适配器编码 |
| `sourceId` | string | 子系统设备原始 ID |
| `deviceCategory` | string | 设备种类 |
| `deviceGroup` | string | 设备分组 |
| `isParent` | string | 是否父设备("是"/"否" |
| `isOnline` | string | 是否在线("在线"/"离线" |
| `extraData` | string? | 扩展数据 JSON |
**返回示例**:
```json
{
"nodeId": 1,
"devices": [
{ "deviceId": 10, "deviceName": "NVR-1", "adapterCode": "Owl:main", "sourceId": "nvr_001", "deviceCategory": "硬盘录像机", "deviceGroup": "视频设备", "isParent": "是", "isOnline": "在线" }
]
}
```
**错误响应**:
| HTTP | 说明 |
|:---:|------|
| 400 | `NodeCode``Token` 为空 |
| 401 | `NodeToken` 不匹配(已有节点 Token 变更) |
---
### A2: 心跳上报
```
POST /api/gateway/heartbeat
```
网关每 15s 调用一次,更新 `LastHeartbeat` 字段。连续失败 ≥3 次45s后网关自动触发 A1+A3 重注册。
**请求体 (GatewayHeartbeatRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `NodeCode` | string | ✅ | 网关节点编码 |
| `Token` | string | ✅ | 认证令牌 |
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `status` | string | 固定 `"ok"` |
| `serverTime` | string | 服务器时间 (`yyyy-MM-dd HH:mm:ss`) |
**错误响应**:
| HTTP | 说明 |
|:---:|------|
| 400 | `NodeCode``Token` 为空 |
| 401 | NodeCode+Token 组合不匹配 |
---
### A3: 设备数据同步
```
POST /api/gateway/sync/devices
```
网关每次设备变更后调用,将全量设备列表推送到 VolPro。采用字段分治策略首次入库写全量后续只更新网关字段。
**请求体 (SyncDevicesRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `NodeCode` | string | ✅ | 网关节点编码 |
| `Token` | string | ✅ | 认证令牌 |
| `Devices` | array | ✅ | 设备列表 |
**Devices[].SyncDeviceItemDto**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `AdapterCode` | string | ✅ | 适配器编码 |
| `SourceId` | string | ✅ | 子系统设备原始 ID |
| `Name` | string? | ❌ | 设备名称 |
| `Category` | string? | ❌ | 设备种类 |
| `Group` | string? | ❌ | 设备分组 |
| `IsParent` | bool | ❌ | 是否父设备 |
| `ParentSourceId` | string? | ❌ | 父设备 SourceId用于解析 ParentDeviceId |
| `IsOnline` | bool | ❌ | 是否在线 |
| `IpAddress` | string? | ❌ | IP 地址 |
| `Port` | int? | ❌ | 端口号 |
| `ExtraDataJson` | string? | ❌ | 扩展数据 JSON |
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `added` | int | 新增设备数 |
| `updated` | int | 更新设备数 |
| `removed` | int | 固定 `0`(当前版本不移除下线设备) |
**错误响应**:
| HTTP | 说明 |
|:---:|------|
| 400 | `NodeCode``Token` 为空 |
| 401 | NodeCode+Token 认证失败 |
---
### A4: 告警数据同步
```
POST /api/gateway/sync/alarms
```
网关检测到新告警后调用,推送告警列表到 VolPro。通过 `SourceAlarmId` 去重(同一告警不重复入库)。
**请求体 (SyncAlarmsRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `NodeCode` | string | ✅ | 网关节点编码 |
| `Token` | string | ✅ | 认证令牌 |
| `Alarms` | array | ✅ | 告警列表 |
**Alarms[].SyncAlarmItemDto**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `SourceAlarmId` | string | ✅ | 子系统告警唯一 ID用于去重 |
| `DeviceSourceId` | string | ✅ | 关联设备 SourceId用于映射 DeviceId |
| `AdapterCode` | string | ✅ | 适配器编码 |
| `Level` | string | ✅ | 告警等级:`提示`/`普通`/`重要`/`紧急` |
| `Desc` | string | ✅ | 告警描述 |
| `Value` | double? | ❌ | 告警实际值 |
| `StartTime` | string | ✅ | 告警发生时间 |
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `added` | int | 新增告警数 |
**错误响应**:
| HTTP | 说明 |
|:---:|------|
| 400 | `NodeCode``Token` 为空 |
| 401 | NodeCode+Token 认证失败 |
---
## 2. 设备管理
> **实现文件**: `Controllers/Warehouse/Partial/base_deviceController.cs`
### 区域树
```
GET /api/DeviceManager/GetRegionTree
```
返回 区域→点位 的层级结构,供管理端左侧树形控件使用。
**请求参数**: 无
**返回参数**: `TreeNode[]`
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | string | 节点 ID`r_{regionId}``p_{pointId}` |
| `label` | string | 节点显示名称 |
| `type` | string | 节点类型:`region`(区域) 或 `point`(点位) |
| `deviceCount` | int | 该节点下的设备数量 |
| `children` | array? | 子节点列表(仅 region 节点有) |
**返回示例**:
```json
[
{
"id": "r_1", "label": "库房A区", "type": "region", "deviceCount": 3,
"children": [
{ "id": "p_10", "label": "温湿度监测点1", "type": "point", "deviceCount": 5 },
{ "id": "p_11", "label": "门禁点1", "type": "point", "deviceCount": 2 }
]
}
]
```
---
### 点位设备列表
```
GET /api/DeviceManager/GetDevicesByPoint?pointId={pointId}&page={page}&size={size}
```
获取指定点位下的设备列表(含子设备),支持分页。
**请求参数**:
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|:--:|------|------|
| `pointId` | int | ✅ | — | 点位 ID |
| `page` | int | ❌ | 1 | 页码 |
| `size` | int | ❌ | 20 | 每页条数 |
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `items` | array | 设备列表 |
| `total` | int | 总设备数 |
**items[] 条目**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `deviceId` | int | 设备自增 ID |
| `deviceName` | string | 设备名称 |
| `adapterCode` | string | 适配器编码 |
| `sourceId` | string | 子系统设备原始 ID |
| `deviceCategory` | string | 设备种类 |
| `deviceGroup` | string | 设备分组(`视频设备`/`IoT设备`/`门禁设备` |
| `isParent` | string | 是否父设备("是"/"否" |
| `parentDeviceId` | int? | 父设备 ID |
| `isOnline` | string | 是否在线("在线"/"离线" |
| `ipAddress` | string? | IP 地址 |
| `port` | int? | 端口号 |
| `location` | string? | 位置描述 |
| `extraData` | string? | 扩展数据 JSON |
| `lastSyncTime` | DateTime? | 最后同步时间 |
| `mapModelId` | string? | 3D 地图模型 ID |
| `mapModelScale` | decimal? | 模型缩放比例 |
| `mapModelRotation` | string? | 模型旋转参数 JSON |
| `enable` | string | 启用状态("启用"/"停用" |
---
## 3. 定时任务
> VolPro 框架通过 `Sys_QuartzOptions` 表配置 URL+Cron 定时调用。每个端点加 `[ApiTask]` 属性以允许框架匿名调用。
>
> **实现文件**: `Controllers/Warehouse/TaskController.cs`
### 设备同步任务
```
POST /api/task/syncDevices
```
遍历所有在线网关,触发全量设备同步至 VolPro。
**Cron**: `0 */5 * * * ?`(每 5 分钟)
**请求参数**: 无
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `time` | DateTime | 执行时间 |
| `status` | string | 固定 `"ok"` |
**错误响应**:
| HTTP | 说明 |
|:---:|------|
| 500 | `gateway_nodesService` 未注册 |
---
### 心跳监控任务
```
POST /api/task/heartbeatMonitor
```
扫描心跳超时 ≥30s 的网关节点,标记离线并级联标记该节点下所有设备离线。
**Cron**: `0/15 * * * * ?`(每 15 秒)
**请求参数**: 无
**返回**: 同设备同步任务
---
### 实时轮询任务
```
POST /api/task/realtimePoll
```
轮询所有在线 MC4 IoT 设备的实时值,写入 `iot_devicedata` 表。
**Cron**: `0/10 * * * * ?`(每 10 秒)
**请求参数**: 无
**返回**: 同设备同步任务
---
### 规则引擎任务
```
POST /api/task/ruleEngine
```
加载启用规则 → 从网关批量获取实时值 → 逐规则评估条件 → 触发动作(控制/告警/通知)。
**Cron**: `0/10 * * * * ?`(每 10 秒)
**请求参数**: 无
**返回**: 同设备同步任务
**当前状态**: 桩实现。`RuleEngineService.EvaluateAllAsync()` 抛出 `NotImplementedException`,需先执行 SQL ALTER TABLE + 代码生成器。
---
## 4. 错误代码
### HTTP 状态码
| 状态码 | 含义 | 触发条件 |
|:---:|------|------|
| 200 | OK | 请求成功 |
| 400 | Bad Request | 必填参数缺失(`NodeCode`/`Token`/`pointId` |
| 401 | Unauthorized | A 组接口 NodeToken 认证失败 |
| 500 | Internal Server Error | 服务未注册或内部异常 |
### A 组认证错误
所有 A 组接口在认证失败时返回:
```json
{ "message": "认证失败Token 无效" }
```
```json
{ "message": "认证失败" }
```
### 定时任务错误
定时任务在服务未注册时返回:
```json
{ "error": "服务未注册: gateway_nodesService" }
```
---
> **接口总数**: 10 个端点A 组 4 + 设备管理 2 + 定时任务 4
> **实现位置**: `api_sqlsugar/VolPro.WebApi/Controllers/Warehouse/`

View File

@@ -0,0 +1,174 @@
# 定时任务 API 化整改方案 v1.0
> **版本**: 1.0
> **日期**: 2026-06-04
> **背景**: VolPro 框架的 Quartz 机制基于 `[ApiTask]` + URL 调用,不支持 `IJob` 接口
> **现状**: 4 个 IJob 实现SyncDevices/HeartbeatMonitor/RealtimePoll/RuleEngineJob需迁移为 API 端点
---
## 1. 影响范围
| 任务 | 当前文件 | 需改为 | 调度间隔 |
|------|------|------|:---:|
| 设备同步 | `SyncDevicesJob.cs` (IJob) | Controller + `[ApiTask]` | 每5分钟 |
| 心跳监控 | `HeartbeatMonitorJob.cs` (IJob) | Controller + `[ApiTask]` | 每15秒 |
| 实时轮询 | `RealtimePollJob.cs` (IJob) | Controller + `[ApiTask]` | 每10秒 |
| 规则引擎 | `RuleEngineJob.cs` (IJob) | Controller + `[ApiTask]` | 每10秒 |
---
## 2. 整改步骤
### 步骤 T1: 创建任务调度 Controller预计 30min
**新建文件**: `api_sqlsugar/VolPro.WebApi/Controllers/Warehouse/TaskController.cs`
```csharp
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using VolPro.Core.Filters;
using Warehouse.Services;
namespace Warehouse.Controllers;
/// <summary>
/// 定时任务 API 端点。
/// VolPro 框架通过 Sys_QuartzOptions 配置 URL+Cron 定时调用。
/// 每个方法加 [ApiTask] 属性以允许框架匿名调用。
/// </summary>
[ApiController]
[Route("api/task")]
public class TaskController : Controller
{
/// <summary>T1: 设备同步 — 遍历在线网关触发全量设备同步</summary>
[ApiTask]
[HttpGet, HttpPost, Route("syncDevices")]
public async Task<IActionResult> SyncDevices()
{
var sp = HttpContext.RequestServices;
var engine = sp.GetService<SyncDevicesJob>();
if (engine != null) await engine.Execute(null!);
return Ok(new { time = DateTime.Now, status = "ok" });
}
/// <summary>T2: 心跳监控 — 扫描超时网关标记离线</summary>
[ApiTask]
[HttpGet, HttpPost, Route("heartbeatMonitor")]
public async Task<IActionResult> HeartbeatMonitor()
{
var sp = HttpContext.RequestServices;
var engine = sp.GetService<HeartbeatMonitorJob>();
if (engine != null) await engine.Execute(null!);
return Ok(new { time = DateTime.Now, status = "ok" });
}
/// <summary>T3: 实时轮询 — 拉取 MC4 IoT 实时值</summary>
[ApiTask]
[HttpGet, HttpPost, Route("realtimePoll")]
public async Task<IActionResult> RealtimePoll()
{
var sp = HttpContext.RequestServices;
var engine = sp.GetService<RealtimePollJob>();
if (engine != null) await engine.Execute(null!);
return Ok(new { time = DateTime.Now, status = "ok" });
}
/// <summary>T4: 规则引擎 — 评估规则+执行动作</summary>
[ApiTask]
[HttpGet, HttpPost, Route("ruleEngine")]
public async Task<IActionResult> RuleEngine()
{
var sp = HttpContext.RequestServices;
var engine = sp.GetService<RuleEngineService>();
if (engine != null) await engine.EvaluateAllAsync();
return Ok(new { time = DateTime.Now, status = "ok" });
}
}
```
### 步骤 T2: 注册 DI预计 10min
**编辑文件**: `api_sqlsugar/VolPro.Core/Extensions/AutofacManager/AutofacContainerModuleExtension.cs`
或在 Warehouse 项目的 Startup/Module 中注册:
```csharp
// 在 Autofac 注册块中添加
builder.RegisterType<SyncDevicesJob>().AsSelf().InstancePerLifetimeScope();
builder.RegisterType<HeartbeatMonitorJob>().AsSelf().InstancePerLifetimeScope();
builder.RegisterType<RealtimePollJob>().AsSelf().InstancePerLifetimeScope();
builder.RegisterType<RuleEngineService>().AsSelf().InstancePerLifetimeScope();
```
如果已由 VolPro 框架自动扫描 Services 目录,则跳过此步骤。
### 步骤 T3: 管理端配置任务(预计 15min
在 Vol.Pro 管理端 → Quartz 管理 → 新建 4 个任务:
| TaskName | ApiUrl | Cron | Method |
|------|------|------|:--:|
| 设备同步 | `/api/task/syncDevices` | `0 */5 * * * ?` | POST |
| 心跳监控 | `/api/task/heartbeatMonitor` | `0/15 * * * * ?` | POST |
| 实时轮询 | `/api/task/realtimePoll` | `0/10 * * * * ?` | POST |
| 规则引擎 | `/api/task/ruleEngine` | `0/10 * * * * ?` | POST |
### 步骤 T4: 保留或删除 IJob 文件(预计 5min
**保留** IJob 实现类(`SyncDevicesJob.cs`不删除——Controller 通过 DI 获取它们并调用 `Execute()`
只需将 IJob 实现类用 `IServiceProvider` 获取(而非 Quartz 的 `JobDataMap`),因为 Controller 不传 `IJobExecutionContext`。修改 `Execute` 方法签名:
```csharp
// 旧: 依赖 IJobExecutionContext.JobDataMap
public async Task Execute(IJobExecutionContext context)
{
var sp = (IServiceProvider)context.JobDetail.JobDataMap["ServiceProvider"];
...
}
// 新: 注入 IServiceProvider 为构造函数参数
public class HeartbeatMonitorJob : IJob
{
private readonly IServiceProvider _sp;
public HeartbeatMonitorJob(IServiceProvider sp) { _sp = sp; }
public async Task Execute(IJobExecutionContext? context)
{
var gwSvc = _sp.GetService<Igateway_nodesService>();
var devRepo = _sp.GetService<Ibase_deviceRepository>();
...
}
}
```
### 步骤 T5: 编译验证(预计 10min
- [ ] `dotnet build api_sqlsugar/VolPro.WebApi` → 0 错误
- [ ] 确认 `[ApiTask]` 不与其他权限 Filter 冲突
---
## 3. 改动文件汇总
| 步骤 | 文件 | 改动 |
|:---:|------|------|
| T1 | `VolPro.WebApi/Controllers/Warehouse/TaskController.cs` | 新建4 个 `[ApiTask]` 端点 |
| T2 | DI 注册 | 可能不需改动VolPro 自动扫描) |
| T3 | 管理端 Sys_QuartzOptions | 新建 4 条任务记录 |
| T4 | 4 个 IJob 实现 | 构造函数改用 IServiceProvider 注入 |
| T5 | 全量编译 | 0 错误 |
---
## 4. 原 IJob 文件处理方案
| 文件 | 处理 |
|------|------|
| `SyncDevicesJob.cs` | 构造函数注入 IServiceProviderExecute 参数改为 nullable |
| `HeartbeatMonitorJob.cs` | 同上 |
| `RealtimePollJob.cs` | 同上 |
| `RuleEngineJob.cs` | 删除RuleEngineService 本身就是普通类,不继承 IJob |
> `RuleEngineJob.cs` 可直接删除——`RuleEngineService` 是普通类,已被 TaskController 直接调用。

View File

@@ -0,0 +1,550 @@
# IntegrationGateway B 组接口文档
> **版本**: 1.0
> **日期**: 2026-06-04
> **基址**: `http://{host}:{port}`(默认 `http://localhost:5100`
> **内容类型**: `application/json`(除标注外)
> **认证**: 可选 `X-Gateway-Key` 请求头(与 Gateway 段配置一致时生效)
> **通用错误码**: 见 §5
---
## 目录
1. [健康检查](#1-健康检查) — B1
2. [设备管理](#2-设备管理) — B2, B3, B3-sync
3. [视频与流媒体](#3-视频与流媒体) — B6a, B6b, 截图, B7
4. [IoT 实时数据](#4-iot-实时数据) — B4, B4-batch, B5
5. [告警管理](#5-告警管理) — B8, B9-confirm, B9-end
6. [录像查询](#6-录像查询)
7. [设备控制 (通用)](#7-设备控制-通用) — B10
8. [业务记录查询](#8-业务记录查询) — B11
9. [数据同步](#9-数据同步) — B12, B13
10. [错误代码](#10-错误代码)
---
## 1. 健康检查
### B1: 查询所有适配器健康状态
```
GET /api/gateway/health
```
**请求参数**: 无
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `[].adapterCode` | string | 适配器编码,如 `Owl:main``MC4:31ku``KMS:main` |
| `[].displayName` | string | 人类可读的适配器名称 |
| `[].healthy` | bool | `true` = 适配器在线,`false` = 离线或不可达 |
| `[].capabilities` | object | 适配器能力声明 |
**capabilities 字段**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `hasFlatDevices` | bool | 是否支持扁平设备列表 |
| `hasOwnDeviceTree` | bool | 是否支持层级对象树 |
| `hasStreams` | bool | 是否支持视频取流 |
| `hasPoints` | bool | 是否支持 IoT 实时点位 |
| `hasAlarms` | bool | 是否支持告警查询 |
| `hasRecordings` | bool | 是否支持录像查询 |
**返回示例**:
```json
[
{
"adapterCode": "Owl:main",
"displayName": "Owl (Owl:main)",
"healthy": true,
"capabilities": { "hasFlatDevices": true, "hasStreams": true, "hasAlarms": true, "hasRecordings": true }
}
]
```
---
## 2. 设备管理
### B2: 分页获取扁平设备列表
```
GET /api/gateway/devices?adapter={adapterCode}&page={page}&size={size}&keyword={keyword}
```
**请求参数**:
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|:--:|------|------|
| `adapter` | string | ✅ | — | 适配器编码,如 `Owl:main` |
| `page` | int | ❌ | 1 | 页码(从 1 开始) |
| `size` | int | ❌ | 20 | 每页条数 |
| `keyword` | string | ❌ | null | 设备名称模糊搜索 |
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `items` | array | 设备列表 |
| `total` | int | 总设备数 |
**items[].StandardDevice**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `sourceId` | string | 子系统设备原始 ID |
| `name` | string | 设备名称 |
| `category` | string | 设备种类(如 `硬盘录像机``摄像头``智能钥匙柜``钥匙位` |
| `group` | string | 设备分组(`视频设备`/`IoT设备`/`门禁设备` |
| `isParent` | bool | 是否父设备(含下级子设备) |
| `parentSourceId` | string? | 上级设备 SourceId |
| `isOnline` | bool | 是否在线 |
| `ipAddress` | string? | IP 地址 |
| `port` | int? | 端口号 |
| `extra` | object? | 子系统特有扩展属性 |
**返回示例**:
```json
{
"items": [
{ "sourceId": "locker_25", "name": "10位智能公共钥匙柜", "category": "智能钥匙柜", "group": "门禁设备", "isParent": true, "isOnline": true },
{ "sourceId": "lockhole_25_1", "name": "仓库大门钥匙", "category": "钥匙位", "group": "门禁设备", "isParent": false, "isOnline": true, "parentSourceId": "locker_25" }
],
"total": 11
}
```
### B3: 获取层级对象树
```
GET /api/gateway/tree?adapter={adapterCode}
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码,如 `MC4:31ku` |
**返回参数**: `DeviceTreeNode[]`
| 字段 | 类型 | 说明 |
|------|------|------|
| `sourceId` | string | 节点 ID |
| `name` | string | 节点名称 |
| `tag` | string | 节点标签(如 `区域`/`设备组`/`IoT设备` |
| `type` | int | 节点类型1=父节点, 0=叶子节点 |
| `children` | array | 子节点列表(递归) |
| `option` | object? | 扩展配置 |
### B3-sync: 手动触发设备同步
```
POST /api/gateway/devices/sync?adapter={adapterCode}
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `deviceCount` | int | 同步设备数(扁平设备) |
| `nodeCount` | int | 同步节点数(对象树) |
| `message` | string | 同步结果描述 |
---
## 3. 视频与流媒体
### B6a: 获取实时流地址
```
GET /api/gateway/streams/{adapter}/{deviceId}/live
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码,如 `Owl:main` |
| `deviceId` | string | ✅ | 通道 SourceId |
**返回参数 (StreamUrls)**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `wsFlv` | string? | WebSocket-FLV 地址(推荐,低延迟) |
| `httpFlv` | string? | HTTP-FLV 地址 |
| `hls` | string? | HLS (m3u8) 地址 |
| `webrtc` | string? | WebRTC 地址 |
| `rtmp` | string? | RTMP 地址 |
### B6b: 获取录像回放地址
```
GET /api/gateway/streams/{adapter}/{deviceId}/playback?start={start}&end={end}
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
| `deviceId` | string | ✅ | 通道 SourceId |
| `start` | DateTime | ✅ | 回放起始时间 (ISO 8601) |
| `end` | DateTime | ✅ | 回放结束时间 (ISO 8601) |
**返回**: 同 `StreamUrls`
### 截图: 获取通道实时截图
```
POST /api/gateway/streams/{adapter}/{deviceId}/snapshot
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
| `deviceId` | string | ✅ | 通道 SourceId |
**返回**: JPEG 图片 Base64 或 URL
### B7: 云台控制
```
POST /api/gateway/streams/{adapter}/{deviceId}/ptz
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
| `deviceId` | string | ✅ | 通道 SourceId |
**请求体 (PtzRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `direction` | string | ❌ | 方向:`up`/`down`/`left`/`right`/`zoom_in`/`zoom_out` |
| `action` | string | ✅ | 动作:`continuous`(持续)/`stop`(停止)/`preset`/`patrol` |
| `speed` | float | ❌ | 速度 (0.1~1.0),默认 0.5 |
---
## 4. IoT 实时数据
### B4: 获取设备实时点位值
```
GET /api/gateway/realtime/{adapter}/{deviceId}
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码,如 `MC4:31ku` |
| `deviceId` | string | ✅ | 设备 SourceId |
**返回参数**: `PointValue[]`
| 字段 | 类型 | 说明 |
|------|------|------|
| `pointIndex` | int | 点位索引 |
| `pointName` | string | 点位名称 |
| `value` | decimal | 当前值 |
| `unit` | string? | 单位(如 `℃``%` |
| `updateTime` | DateTime | 更新时间 |
| `interval` | int | 采集间隔(秒) |
### B4-batch: 批量获取实时点位值
```
POST /api/gateway/realtime/{adapter}/batch
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
**请求体 (BatchRealtimeRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `deviceIds` | string[] | ✅ | 设备 SourceId 列表 |
**返回**: `Dictionary<string, PointValue[]>` — 以 deviceId 为键的实时值字典
### B5: 设备反向控制
```
POST /api/gateway/realtime/{adapter}/control
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
**请求体 (ControlRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `deviceId` | string | ✅ | 设备 SourceId |
| `pointIndex` | int | ✅ | 目标点位索引 |
| `value` | double | ✅ | 目标值(如开关 0/1温度设定值等 |
---
## 5. 告警管理
### B8: 分页查询告警列表
```
GET /api/gateway/alarms/{adapter}?page={page}&size={size}&from={from}&to={to}&level={level}&state={state}
```
**请求参数**:
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|:--:|------|------|
| `adapter` | string | ✅ | — | 适配器编码 |
| `page` | int | ❌ | 1 | 页码 |
| `size` | int | ❌ | 20 | 每页条数 |
| `from` | DateTime | ❌ | MinValue | 告警起始时间 |
| `to` | DateTime | ❌ | MinValue | 告警结束时间 |
| `level` | string | ❌ | null | 告警等级过滤 |
| `state` | string | ❌ | null | 告警状态过滤:`未确认`/`已确认`/`已结束` |
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `items` | array | 告警列表 |
| `total` | int | 总告警数 |
**items[].StandardAlarm**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `alarmId` | string | 告警 ID |
| `adapterCode` | string | 适配器编码 |
| `deviceId` | string? | 关联设备 ID |
| `level` | string | 告警等级:`提示`/`普通`/`重要`/`紧急` |
| `title` | string | 告警标题 |
| `content` | string? | 告警详细内容 |
| `occurTime` | DateTime | 发生时间 |
| `status` | string | 状态:`未确认`/`已确认`/`已结束` |
| `actualValue` | string? | 实际值(超标告警) |
| `thresholdValue` | string? | 阈值(超标告警) |
### B9-confirm: 确认告警
```
POST /api/gateway/alarms/{adapter}/{alarmId}/confirm
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
| `alarmId` | string | ✅ | 告警 ID子系统告警源 ID |
### B9-end: 结束告警
```
POST /api/gateway/alarms/{adapter}/{alarmId}/end
```
**请求参数**: 同 B9-confirm
---
## 6. 录像查询
```
GET /api/gateway/recordings/{adapter}/{deviceId}?start={start}&end={end}&page={page}&size={size}
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
| `deviceId` | string | ✅ | 通道 SourceId |
| `start` | DateTime | ✅ | 录像起始时间 |
| `end` | DateTime | ✅ | 录像结束时间 |
| `page` | int | ❌ | 页码,默认 1 |
| `size` | int | ❌ | 每页条数,默认 20 |
**返回**: 录像文件列表(含文件名、起止时间、时长、大小)
---
## 7. 设备控制 (通用)
### B10: 下发控制指令
```
POST /api/gateway/control/{adapter}
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码,如 `KMS:main` |
**请求体 (GatewayControlRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `deviceId` | string | ✅ | 设备 SourceId |
| `command` | string | ✅ | 指令名:`open`(开门)/`close`(关门)/`authorize`(授权) |
| `parameters` | object | ❌ | 指令参数,如 `{"staffIds": [1,2], "lockholeSort": 3}` |
**返回 (ControlResult)**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `success` | bool | 操作是否成功 |
| `message` | string? | 失败时的错误信息 |
---
## 8. 业务记录查询
### B11: 查询子系统业务记录
```
GET /api/gateway/logs/{adapter}?logType={logType}&from={from}&to={to}&page={page}&size={size}
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
| `logType` | string | ✅ | 记录类型:`borrow`(借还)/`handover`(交接)/`permission`(授权) |
| `from` | DateTime | ❌ | 起始时间 |
| `to` | DateTime | ❌ | 结束时间 |
| `page` | int | ❌ | 页码,默认 1 |
| `size` | int | ❌ | 每页条数,默认 20 |
**返回参数**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `items` | array | 业务记录列表 |
| `total` | int | 总记录数 |
**items[].BusinessLogEntry**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `logId` | string | 记录唯一 ID |
| `logType` | string | 记录类型 |
| `deviceSourceId` | string? | 关联设备 SourceId |
| `staffName` | string? | 关联员工姓名 |
| `description` | string? | 记录描述 |
| `createdAt` | DateTime? | 记录时间 |
| `extra` | object? | 扩展属性 |
---
## 9. 数据同步
### B12: 向子系统写入数据
```
POST /api/gateway/sync/{adapter}
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `adapter` | string | ✅ | 适配器编码 |
**请求体 (SyncRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `dataType` | string | ✅ | 数据类型,当前支持 `staff`(员工) |
| `items` | object[] | ✅ | 待同步数据列表 |
**返回 (SyncResult)**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `successCount` | int | 成功数量 |
| `failCount` | int | 失败数量 |
| `message` | string? | 错误信息 |
### B13: 从子系统删除数据
```
DELETE /api/gateway/sync/{adapter}
```
**请求参数**: 同 B12
**请求体 (SyncDeleteRequest)**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `dataType` | string | ✅ | 数据类型,当前支持 `staff` |
| `ids` | string[] | ✅ | 待删除 ID 列表 |
**返回**: 同 `SyncResult`
---
## 10. 错误代码
### 通用 HTTP 状态码
| 状态码 | 含义 | 触发条件 |
|:---:|------|------|
| 200 | OK | 请求成功 |
| 400 | Bad Request | 请求参数格式错误 |
| 401 | Unauthorized | `X-Gateway-Key` 缺失或不匹配 |
| 404 | Not Found | 适配器不存在或不支持该能力 |
| 500 | Internal Server Error | 适配器内部异常 |
| 502 | Bad Gateway | 子系统返回错误或不可达 |
### 业务错误码
所有非 200 响应包含 JSON body
| 字段 | 类型 | 说明 |
|------|------|------|
| `error` | string | 错误码 |
| `message` | string? | 人类可读的错误详情 |
| error 值 | HTTP | 说明 |
|------|:---:|------|
| `ADAPTER_NOT_FOUND` | 404 | 指定适配器编码不存在 |
| `CAPABILITY_NOT_SUPPORTED` | 404 | 适配器不支持该接口能力 |
| — (control 接口) | 502 | `ControlResult.Success=false` 时返回 `ControlResult.Message` |
---
> **接口总数**: 19 个 REST 端点
> **适配器**: Owl / MC4 / KMS通过 `adapter` 参数路由)

View File

@@ -0,0 +1,223 @@
# 网关 KMS 模块检查报告 2026-06-04
> **基准文档**: `doc/对接文档/钥匙管理系统软件接口.docx` (KMS API v1.0.4)
> **检查范围**: `gateway/src/IntegrationGateway.Adapters.Kms/` (KmsAdapter.cs / KmsAuthHelper.cs / KmsModels.cs) + `Program.cs` B10-B13 路由
> **方法**: 逐接口比对文档 → 代码 → 路由
---
## 1. 覆盖率总览
| 模块 | KMS 文档端点数 | Gateway 覆盖 | 覆盖率 |
|------|:---:|:---:|:---:|
| 2.9 Token 获取 | 1 | 1 | 100% |
| 2.18 开放接口 | 8 | 8 | 100% |
| **总计 (Phase 1)** | **9** | **9** | **100%** |
| 2.3-2.17 标准接口 | 38 | 1 (确认告警) | 3% |
---
## 2. 逐接口检查
### 2.18.1 心跳 — `GET /prod-api/heartBeat`
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|------|------|------|:--:|
| 请求方法 | `client.GetAsync(...)` | GET | ✅ |
| 请求路径 | `/prod-api/heartBeat` | `/prod-api/heartBeat` | ✅ |
| 请求体 | 无 | 无 | ✅ |
| 错误处理 | `catch (Exception ex) { Console.Error.WriteLine; return false; }` | — | ✅ |
### 2.18.2 批量删除员工 — `POST /prod-api/batchDeleteStaff`
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|------|------|------|:--:|
| 请求方法 | `PostAsJsonAsync(...)` | POST | ✅ |
| 请求路径 | `/prod-api/batchDeleteStaff` | `/prod-api/batchDeleteStaff` | ✅ |
| 请求体 | `List<string>` (staffUuid 数组) | `["uuid1","uuid2",...]` | ✅ |
| 参数类型 | 数组 | 数组 (v1.0.2 修正) | ✅ |
### 2.18.3 批量同步员工 — `POST /prod-api/batchSyncStaff`
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|------|------|------|:--:|
| 请求方法 | `PostAsJsonAsync(...)` | POST | ✅ |
| 请求路径 | `/prod-api/batchSyncStaff` | `/prod-api/batchSyncStaff` | ✅ |
| 请求体 | `new { staff = staffList }` | staff 数组 | ⚠️ |
| account 字段 | 模型中有 Account? | v1.0.4 新增 account | ⚠️ 待验证 |
**风险**: 文档 v1.0.4 新增了 `account` (登录账号) 字段。`KmsStaff` 模型需确认包含此字段。网关包装为 `{ staff: [...] }` 可能与 KMS 期望的裸数组不一致。
### 2.18.4 查询柜体钥匙 — `POST /prod-api/getOpenerList`
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|------|------|------|:--:|
| 请求方法 | POST | POST | ✅ |
| 请求路径 | `/prod-api/getOpenerList` | `/prod-api/getOpenerList` | ✅ |
| 请求体 | `"{}"` | 无明确要求 / 空对象 | ✅ |
| 响应 → StandardDevice | 柜体→父设备, 锁孔→子设备 | 树状结构 | ✅ |
### 2.18.5 查询授权记录 — `POST /prod-api/getPermissionList`
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|------|------|------|:--:|
| 请求方法 | POST | POST | ✅ |
| 请求路径 | `/prod-api/getPermissionList` | `/prod-api/getPermissionList` | ✅ |
| 请求体 | `"{}"` | 授权记录业务对象 (含 lockerName, lendStaffName 等 20+ 字段) | 🔴 |
| 时间范围 | `DateTime? from, DateTime? to` 参数**未传入请求体** | `beginApplyTime`/`endApplyTime` | 🔴 |
| 分页 | `page`/`size` 参数**未传入请求体** | `pageNum`/`pageSize` | 🔴 |
**致命问题**: 网关注入 `from`/`to`/`page`/`size` 参数但**从未传入 KMS 请求体**。代码注释 `// 联调时加入时间范围` 确认这是已知缺口。当前实现等价于无过滤全量查询,无法按时间范围分页。
### 2.18.6 查询借还记录 — `POST /prod-api/getRecordList`
**与 2.18.5 完全相同的致命问题**: `from`/`to`/`page`/`size` 参数未传入 KMS 请求体。此外文档标记 `lockerName``lockholeSort``openerCnName` 为必填字段,但网关传 `"{}"` 无这些字段。
### 2.18.7 查询告警记录 — `POST /prod-api/getWarningList`
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|------|------|------|:--:|
| 请求方法 | POST | POST | ✅ |
| 请求路径 | `/prod-api/getWarningList` | `/prod-api/getWarningList` | ✅ |
| 请求体 | `"{}"` | 告警业务对象 (含 type, beginWarningTime 等) | 🔴 |
| 时间范围 | 未传 | `beginWarningTime`/`endWarningTime` | 🔴 |
| 告警类型 | 未传 (type=1当前/2历史) | 文档支持过滤 | 🔴 |
| 响应映射 | `Type==1 ? "未确认" : "已结束"` | `type` 1=当前告警, 2=历史告警 | 🔴 |
**状态映射错误**: `type` 字段在告警接口中表示 1=当前告警 / 2=历史告警,**不是** 1=未确认 / 2=已结束。代码将 type=1 映射为 Status="未确认"、type=2 映射为 Status="已结束",语义错误。正确的映射应该是 type=1 → "活跃", type=2 → "历史"。
### 2.18.8 单点登录 — `POST /thirdPlatlogin`
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|------|------|------|:--:|
| 请求方法 | POST | POST | ✅ |
| 请求路径 | `/thirdPlatlogin?username={x}` | `/thirdPlatlogin?username={x}` | ✅ |
| 重定向处理 | 捕获 302, 返回 Location header | 文档说明"调用成功后直接重定向" | ✅ |
| 超时 | 无显式设置 | — | 🟡 |
### 2.9 Token 获取 — `POST /prod-api/getToken`
| 检查项 | Gateway 实现 | 文档规范 | 状态 |
|------|------|------|:--:|
| 请求方法 | `http.PostAsync(url, null)` | POST | ✅ |
| 参数位置 | query string: `?clientId=&clientSecret=` | query string | ✅ |
| 响应校验 | `Code != 200` → 抛异常 | `code: 200` = 成功 | ✅ |
| 缓存策略 | 25分钟 (30分钟效期-5分钟余量) | 30分钟效期 | ✅ |
> **注意**: 文档 2.9.1 显示 `POST /prod-api/getToken` 参数在 body 中 (`{ clientId, clientSecret }`),但 2.9 节概述描述为 query 参数。两种方式 KMS 可能都支持。当前实现用 query string联调时需确认兼容。
---
## 3. 模型映射核对
### 3.1 KMS 柜体 → StandardDevice
| 文档字段 | 代码映射 | 准确性 |
|------|------|:--:|
| lockerId | `SourceId = $"locker_{lockerId}"` | ✅ |
| lockerName | `Name = lockerName` | ✅ |
| lockerCode | `Extra["lockerCode"]` | ✅ |
| lockholeList | 遍历展开为子设备 | ✅ |
| IsParent | `true` | ✅ |
### 3.2 KMS 锁孔 → StandardDevice
| 文档字段 | 代码映射 | 准确性 |
|------|------|:--:|
| lockholeSort | `SourceId = $"lockhole_{lockerId}_{lockholeSort}"` | ✅ |
| openerName | `Name = openerName` | ✅ |
| openerType | `Extra["openerType"]` (1/2/3 数值) | ✅ |
| openerState | `Extra["openerState"]` + `IsOnline = (openerState=="在位")` | 🔴 |
| ParentSourceId | `$"locker_{lockerId}"` | ✅ |
**openerState 映射错误**: 根据文档数据字典§4`openerState` 是数值编码:
- 1 = 在柜
- 2 = 借出
- 3 = 录入
- 10 = 丢失
代码用 `openerState == "在位"` 做字符串比较,**永远不成立**。需改为 `openerState == "1"` 或解析为 int 后判断。
### 3.3 KMS 借还记录 → BusinessLogEntry
| 文档字段 | 代码映射 | 准确性 |
|------|------|:--:|
| uuid | `LogId` | ✅ |
| lockerName | 拼入 `DeviceSourceId` | ✅ |
| staffName | `StaffName` | ✅ |
| borrowTime | `CreatedAt` | ✅ |
| openerName | `Description` (不充分) | 🟡 |
### 3.4 KMS 告警 → StandardAlarm
| 文档字段 | 代码映射 | 准确性 |
|------|------|:--:|
| uuid | `AlarmId` | ✅ |
| warningTime | `OccurTime` | ✅ |
| remark | `Content` | ✅ |
| type (1/2) | `Status = Type==1 ? "未确认""已结束"` | 🔴 |
| level | 固定 `"普通"` | 🟡 |
**type 语义错误**: 见 2.18.7 说明。文档明确 `type` 表示告警分类(1=当前,2=历史),而非确认状态。
---
## 4. B 路由链路检查
| B 路由 | 对应 KMS 能力 | 适配器方法 | 参数传递 | 状态 |
|------|------|------|:--:|:--:|
| B1 `/health` | 心跳 2.18.1 | `HealthCheckAsync` | ✅ | ✅ |
| B2 `/devices` | 柜体钥匙 2.18.4 | `GetDevicesAsync` | ✅ | ✅ |
| B8 `/alarms` | 告警 2.18.7 | `GetAlarmsAsync` | ✅ | ✅ (映射有误) |
| B9 `/alarms/{id}/confirm` | 确认告警 | `ConfirmAlarmAsync` | ✅ | ⚠️ 端点未确认 |
| B10 `/control` | 远程控制 | `SendControlAsync` | ✅ | ⚠️ |
| B11 `/logs` | 业务记录 | `GetBusinessLogsAsync` | ✅ | ⚠️ |
| B12 `/sync` (POST) | 员工同步 2.18.3 | `SyncDataAsync` | ✅ | ⚠️ |
| B13 `/sync` (DELETE) | 删除员工 2.18.2 | `DeleteDataAsync` | ✅ | ✅ |
---
## 5. 问题汇总
### 🔴 致命问题 (需联调前修复)
| # | 问题 | 影响 |
|:--:|------|------|
| **R1** | 2.18.5/2.18.6/2.18.7 请求体只传 `"{}"`,忽略 `from`/`to`/`page`/`size` 参数 | 无法按时间分页查询,联调时大概率返回全量数据或报错 |
| **R2** | 2.18.7 type 字段语义错误 (1=当前告警被映射为"未确认") | 告警状态全部错误 |
| **R3** | openerState 字符串比较 vs 文档数值编码 | 所有锁孔 IsOnline 永远为 false |
### 🟠 严重问题
| # | 问题 | 影响 |
|:--:|------|------|
| **S1** | 2.18.6 必填字段 (`lockerName`, `lockholeSort`, `openerCnName`) 未传 | 借还记录查询可能被 KMS 拒绝 |
| **S2** | `ConfirmAlarmAsync` 端点 (`/prod-api/kms/warning/confirm/{id}`) 未在文档中确认存在 | 告警确认功能不可用 |
| **S3** | 2.18.5 请求体结构未知 (文档未给完整示例) | 授权记录查询格式需联调验证 |
### 🟡 改善项
| # | 问题 | 建议 |
|:--:|------|------|
| **M1** | KmsModels.cs 包含大量 Phase 2 DTO 但未使用 | 保留Phase 2 可用 |
| **M2** | B10 控制指令 `command == "open"``command == "authorize"` 都调同一方法 | 区分"开门"和"授权"两种指令 |
| **M3** | ThirdPlatLoginAsync 无超时设置 | 加 15s 超时 |
| **M4** | SyncDevicesJob 中 `gwRepo.Update(node)` vs `gwSvc.UpdateAsync(node)` 不一致 | 统一风格 |
---
## 6. 需联调验证项
| # | 验证项 | 说明 |
|:--:|------|------|
| V1 | Token 获取用 query string vs body | 文档 2.9.1 标记为 body 参数2.9 节概述为 query |
| V2 | `batchSyncStaff` body 格式 | `{ staff: [...] }` vs `[...]` |
| V3 | `KmsStaff` 是否需 account 字段 | v1.0.4 新增 |
| V4 | `getOpenerList` 返回的 openerState 是数值还是中文 | 决定映射逻辑 |
| V5 | `getRecordList` 必填字段是否真的必填 | 决定请求体最小字段集 |
| V6 | `getPermissionList` 请求体完整格式 | 文档示例不完整 |
| V7 | `warning/confirm` 端点存在性 | 调标准管理接口 |
---
> **结论**: 9 个 Phase 1 接口全部实现覆盖100%),但 3 个致命问题R1-R3需在联调前修复——核心是请求体格式、type 语义映射、openerState 编码映射。其余 4 个严重问题需联调验证后确认。

View File

@@ -0,0 +1,337 @@
# 网关 MC4 模块整改方案 v1.0
> **版本**: 1.0
> **日期**: 2026-06-03
> **基准**: `doc/设计文档/网关MC4模块检查报告20260603.md`
---
## 1. 整改总览
| 步骤 | 优先级 | 内容 | 文件 | 预计 |
|:---:|:---:|------|------|:---:|
| M1 | 🔴 P0 | Mc4AuthHelper 认证修复 | Mc4AuthHelper.cs + appsettings | 1h |
| M2 | 🟠 P1 | 批量点位查询 | Mc4Adapter.cs | 30min |
| M3 | 🟡 P2 | 历史告警查询 | Mc4Adapter.cs | 30min |
| M4 | 🟡 P2 | B4-batch 路由改用 native batch | Program.cs | 15min |
| M5 | 验证 | 编译 + 联调 | — | 30min |
| **合计** | — | — | **4 文件** | **~3h** |
---
## 2. 步骤 M1: Mc4AuthHelper 认证修复(预计 1h
### 2.1 问题
```csharp
// 当前: 调 /conf/get (返回 { "encrypt": true }),误读为 Token
var resp = await _http.PostAsync($"{_baseUrl}/api/central/auth/conf/get", null);
var result = JsonSerializer.Deserialize<Mc4AuthResponse>(json);
_token = result?.Token ?? ""; // Token 始终为 null
```
### 2.2 MC4.0 实际认证流程
```
1. POST /api/central/auth/conf/get → { "encrypt": true/false }
2. 若 encrypt=true → 密码 MD5(原始密码)
3. POST /api/central/auth/login {
"account": "admin",
"password": "md5或原始密码"
}
→ { "token": "xxx", "id": 0, "account": "admin", "name": "管理员" }
```
### 2.3 修改后的 Mc4AuthHelper
```csharp
public class Mc4AuthHelper
{
private readonly HttpClient _http;
private readonly string _baseUrl;
private readonly string _account;
private readonly string _password;
private string? _token;
private DateTime _tokenExpiry = DateTime.MinValue;
private bool? _needMd5;
public Mc4AuthHelper(HttpClient http, string baseUrl, string account, string password)
{
_http = http;
_baseUrl = baseUrl.TrimEnd('/');
_account = account;
_password = password;
}
public async Task<string> GetTokenAsync()
{
if (!string.IsNullOrEmpty(_token) && DateTime.UtcNow < _tokenExpiry)
return _token;
// 1. 获取加密配置
if (!_needMd5.HasValue)
{
var confResp = await _http.PostAsync($"{_baseUrl}/api/central/auth/conf/get", null);
if (confResp.IsSuccessStatusCode)
{
var confJson = await confResp.Content.ReadAsStringAsync();
var conf = JsonSerializer.Deserialize<Mc4ConfResponse>(confJson);
_needMd5 = conf?.Encrypt ?? false;
}
else
{
_needMd5 = false; // 失败时假定不需要加密
}
}
// 2. 登录获取 Token
var pwd = _needMd5 == true ? ComputeMd5(_password) : _password;
var loginBody = JsonSerializer.Serialize(new { account = _account, password = pwd });
var resp = await _http.PostAsync($"{_baseUrl}/api/central/auth/login",
new StringContent(loginBody, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<Mc4LoginResponse>(json)
?? throw new Exception("MC4 登录失败");
_token = result.Token ?? "";
_tokenExpiry = DateTime.UtcNow.AddHours(7); // 保守估计 8h
return _token;
}
public async Task<HttpClient> GetAuthenticatedClientAsync()
{
var token = await GetTokenAsync();
var client = new HttpClient { BaseAddress = new Uri(_baseUrl) };
if (!string.IsNullOrEmpty(token))
client.DefaultRequestHeaders.Add("token", token);
return client;
}
public void Invalidate() => _token = null;
private static string ComputeMd5(string input) { /* MD5 实现 or use System.Security.Cryptography */ }
private class Mc4ConfResponse { public bool? Encrypt { get; set; } }
private class Mc4LoginResponse { public string? Token { get; set; } public int Id { get; set; } public string? Account { get; set; } }
}
```
### 2.4 构造函数签名变更
```csharp
// 旧: public Mc4AuthHelper(HttpClient http, string baseUrl)
// 新: public Mc4AuthHelper(HttpClient http, string baseUrl, string account, string password)
```
### 2.5 Mc4Adapter 构造函数变更
```csharp
// 旧:
public Mc4Adapter(string adapterCode, HttpClient http, string baseUrl)
{
_auth = new Mc4AuthHelper(http, baseUrl);
}
// 新:
public Mc4Adapter(string adapterCode, HttpClient http, string baseUrl,
string account = "admin", string password = "admin")
{
_auth = new Mc4AuthHelper(http, baseUrl, account, password);
}
```
### 2.6 Program.cs 注册变更
```csharp
// 旧: new Mc4Adapter(code, http, m.BaseUrl)
// 新: new Mc4Adapter(code, http, m.BaseUrl,
// m.Username ?? "admin", m.Password ?? "admin")
```
### 2.7 Mc4Config 增加字段
```csharp
public class Mc4Config
{
public string? InstanceName { get; set; }
public string BaseUrl { get; set; } = "";
public string Username { get; set; } = "admin"; // 新增
public string Password { get; set; } = "admin"; // 新增
}
```
### 2.8 appsettings.json 更新
```json
"MC4": [
{ "InstanceName": "31ku", "BaseUrl": "http://localhost:3000",
"Username": "admin", "Password": "your_mc4_password" }
]
```
### 2.9 编译验证
`dotnet build gateway/IntegrationGateway.slnx` → 0 错误。
> **M1 提交点**: `Fix-M1: Mc4AuthHelper 认证修复 conf/get→login + account/password支持`
---
## 3. 步骤 M2: 批量点位查询(预计 30min
### 3.1 文件
`gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs`
### 3.2 新增方法
```csharp
/// <summary>批量获取多个设备的实时点位值MC4.0 原生 multi/value/get</summary>
public async Task<Dictionary<int, List<Mc4PointValue>>> GetMultiRealtimeValuesAsync(List<int> deviceIds)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var body = JsonSerializer.Serialize(new { ids = deviceIds });
var resp = await client.PostAsync("/api/central/point/multi/value/get",
new StringContent(body, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<Dictionary<int, List<Mc4PointValue>>>(json)!;
return result;
}
```
### 3.3 编译验证
`dotnet build` → 0 错误。
> **M2 提交点**: `Fix-M2: MC4 批量点位查询 GetMultiRealtimeValuesAsync`
---
## 4. 步骤 M3: 历史告警查询(预计 30min
### 4.1 新增 DTO
```csharp
/// <summary>MC4.0 历史告警查询请求</summary>
public class Mc4HisAlarmQuery
{
public string From { get; set; } = "";
public string To { get; set; } = "";
public int Skip { get; set; }
public int Limit { get; set; }
public int Sort { get; set; } = 1;
}
```
### 4.2 新增方法
```csharp
/// <summary>查询 MC4.0 历史告警(已恢复的告警)</summary>
public async Task<PagedResult<StandardAlarm>> GetHisAlarmsAsync(int page, int size, DateTime from, DateTime to)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var body = JsonSerializer.Serialize(new Mc4HisAlarmQuery
{
From = from.ToString("yyyy-MM-dd HH:mm:ss"),
To = to.ToString("yyyy-MM-dd HH:mm:ss"),
Skip = (page - 1) * size,
Limit = size,
Sort = 1
});
var resp = await client.PostAsync("/api/central/his_alarm/query",
new StringContent(body, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<Mc4AlarmQueryResult>(json)!;
return new PagedResult<StandardAlarm>
{
Items = (result.List ?? new()).Select(MapAlarmItem).ToList(),
Total = result.Total
};
}
private StandardAlarm MapAlarmItem(Mc4AlarmItem a) => new()
{
AlarmId = a.Id ?? "",
AdapterCode = AdapterCode,
Level = MapAlarmLevel(a.Level),
Title = a.Desc ?? "",
OccurTime = DateTime.TryParse(a.Stime, out var st) ? st : DateTime.MinValue,
Status = MapAlarmState(a.State),
ActualValue = a.Soption?.Value,
ThresholdValue = a.Eoption?.Value
};
```
### 4.3 编译验证
`dotnet build` → 0 错误。
> **M3 提交点**: `Fix-M3: MC4 历史告警查询 GetHisAlarmsAsync`
---
## 5. 步骤 M4: B4-batch 路由优化(预计 15min
### 5.1 修改
`gateway/src/IntegrationGateway.Host/Program.cs` B4-batch 路由改用 MC4 原生批量接口:
```csharp
// B4-batch 改用 MC4 原生 multi/value/get
app.MapPost("/api/gateway/realtime/{adapter}/batch", async (string adapter, BatchRealtimeRequest req) =>
{
var a = registry.FindByCode<IHasPoints>(adapter);
if (a == null) return Results.NotFound();
if (a is Mc4Adapter mc4 && req.DeviceIds?.Count > 0)
{
// MC4.0 原生批量接口
var intIds = req.DeviceIds.Select(int.Parse).ToList();
var multi = await mc4.GetMultiRealtimeValuesAsync(intIds);
return Results.Ok(multi);
}
// 其他适配器 fallback
var results = new Dictionary<string, List<PointValue>>();
foreach (var id in req.DeviceIds ?? new())
try { results[id] = await a.GetRealtimeValuesAsync(id); } catch { }
return Results.Ok(results);
});
```
### 5.2 编译验证
`dotnet build` → 0 错误。
> **M4 提交点**: `Fix-M4: B4-batch 优化 MC4原生批量接口`
---
## 6. 步骤 M5: 编译验证 + 联调
- [ ] `dotnet build gateway/IntegrationGateway.slnx` → 0 错误 0 警告
- [ ] MC4 appsettings.json 填入真实 `Username/Password`
- [ ] 网关启动 → A1 注册 → A3 同步 MC4 设备树
- [ ] B4-batch 调 `multi/value/get` 返回批量值
- [ ] 告警查询 `/alarms/MC4:31ku` 有数据
- [ ] Mc4AuthHelper Token 非空 → 登录流程正常
> **M5 提交点**: `Fix-M5: MC4整改全量编译验证通过`
---
## 7. 改动文件汇总
| 步骤 | 文件 | 改动 |
|:---:|------|------|
| M1 | `Mc4AuthHelper.cs` | 重写认证流程: conf/get → login |
| M1 | `Mc4Adapter.cs` | 构造函数加 account/password |
| M1 | `Program.cs` | Mc4Adapter 构造传 Username/Password |
| M1 | `appsettings.json` | MC4 数组加 Username/Password |
| M2 | `Mc4Adapter.cs` | 新增 GetMultiRealtimeValuesAsync |
| M3 | `Mc4Adapter.cs` | 新增 GetHisAlarmsAsync + DTO |
| M4 | `Program.cs` | B4-batch 优化 MC4 原生批量 |

View File

@@ -0,0 +1,131 @@
# 网关 MC4 模块检查报告 2026-06-03
> **基准文档**: `doc/对接文档/MC4.0对外API.md` (31 API)
> **检查范围**: `gateway/src/IntegrationGateway.Adapters.MC4/` (Mc4Adapter.cs, Mc4AuthHelper.cs)
> **日期**: 2026-06-03
---
## 1. 覆盖率概览
MC4.0 接口文档共 **31 个 REST 端点**,当前 Mc4Adapter 覆盖了 **6 个**19%)。
| 模块 | 文档端点数 | 已实现 | 缺失 |
|------|:---:|:---:|:---:|
| 认证 | 3 | 0 | 3 |
| 对象树 | 1 | 1 | 0 |
| 点位 | 3 | 2 | 1 |
| 告警 | 14 | 3 | 11 |
| 系统管理 | 10 | 0 | 10 |
| **合计** | **31** | **6** | **25** |
---
## 2. 已实现接口对照
| MC4.0 端点 | Mc4Adapter 方法 | 能力接口 | 状态 |
|------|------|------|:--:|
| /api/central/object/tree | GetObjectTreeAsync | IHasOwnDeviceTree | ✅ |
| /api/central/device/point/value/get | GetRealtimeValuesAsync | IHasPoints | ✅ |
| /api/central/point/value/set | SetPointValueAsync | IHasPoints | ✅ |
| /api/central/alarm/query | GetAlarmsAsync | IHasAlarms | ✅ |
| /api/central/alarm/confirm | ConfirmAlarmAsync | IHasAlarms | ✅ |
| /api/central/alarm/end | EndAlarmAsync | IHasAlarms | ✅ |
---
## 3. 🔴 关键问题
### 3.1 Mc4AuthHelper 认证逻辑错误(🔥 致命)
**现状**: `GetTokenAsync` 调用 `/api/central/auth/conf/get`
```csharp
var resp = await _http.PostAsync($"{_baseUrl}/api/central/auth/conf/get", null);
var result = JsonSerializer.Deserialize<Mc4AuthResponse>(json);
_token = result?.Token ?? "";
```
**错误**: `/api/central/auth/conf/get` 是**密码加密配置查询接口**,返回 `{ "encrypt": true/false }`**不是 Token 接口**,不包含 `token` 字段。`result?.Token` 始终为 null`_token` 被设为空字符串。
**实际登录接口**: `/api/central/auth/login`
```json
POST /api/central/auth/login
{ "account": "admin", "password": "xxx" }
{ "token": "string", "id": 0, "account": "string", "name": "string" }
```
> **注意**: MC4.0 可能对大部分 API 不强制 Token 认证curl 示例中只有 logout 接口显式传了 header。但当前代码逻辑错误即便需要 Token 也无法获取。
**修复**: Mc4AuthHelper 改为先调 `conf/get` 确认加密方式,再用 `account/password``login` 获取真正的 token。
### 3.2 缺少批量点位查询(🟠 规则引擎依赖)
**缺失**: `/api/central/point/multi/value/get`
请求体 `{ "ids": [1, 2, 3] }` → 一次返回多个设备的实时值。
**影响**: 当前 B4-batch 接口逐设备调 `GetRealtimeValuesAsync`单设备接口。MC4.0 提供原生批量接口,应直接使用以提升规则引擎性能。
**修复**: 增加 `GetMultiRealtimeValuesAsync(List<int> deviceIds)` 方法B4-batch 路由优先调此方法。
---
## 4. 缺失项清单
### 4.1 认证接口3个
| 端点 | 用途 |
|------|------|
| `/api/central/auth/conf/get` | 获取密码加密配置(已调但未正确使用) |
| `/api/central/auth/login` | 登录获取 Token |
| `/api/central/auth/logout` | 注销 |
### 4.2 设备点位1个
| 端点 | 用途 |
|------|------|
| `/api/central/device/point/get` | 查询设备的点位列表(用于发现设备有哪些测点) |
### 4.3 告警扩展11个
| 端点 | 用途 |
|------|------|
| `/api/central/alarm/custom_query_count` | 告警自定义统计数量 |
| `/api/central/alarm/custom_query` | 告警自定义查询 |
| `/api/central/alarm/get_by_point` | 按点位查询告警 |
| `/api/central/alarm/get` | 获取单个告警详情 |
| `/api/central/his_alarm/query` | 历史告警查询 |
| `/api/central/report/alarm/convergence/query` | 告警聚合报告查询 |
| `/api/central/alarm/type/add` | 添加告警类型 |
| `/api/central/alarm/type/set` | 修改告警类型 |
| `/api/central/alarm/type/del` | 删除告警类型 |
| `/api/central/alarm/type/list` | 告警类型列表 |
### 4.4 系统管理10个
| 端点 | 用途 |
|------|------|
| `/api/central/manager/config/set` | 设置系统配置 |
| `/api/central/manager/config/get` | 获取系统配置 |
| `/api/central/manager/db/backup` | 数据库备份 |
| `/api/central/manager/db/restore` | 数据库恢复 |
| `/api/central/manager/db/log` | 数据库日志 |
| `/api/central/manager/hisdb/backup` | 历史库备份 |
| `/api/central/manager/hisdb/restore` | 历史库恢复 |
| `/api/central/manager/hisdb/clear` | 清除历史数据 |
| `/api/central/manager/picture/clear` | 清除图片 |
| `/api/central/manager/video/clear` | 清除视频 |
---
## 5. 优先级建议
| 优先级 | 项目 | 说明 |
|:---:|------|------|
| 🔴 P0 | Mc4AuthHelper 认证修复 | 当前 Token 获取逻辑根本错误 |
| 🟠 P1 | 批量点位查询 (multi/value/get) | 规则引擎 B4-batch 缺少原生高效接口 |
| 🟡 P2 | 历史告警查询 | 管理端需要查看已结束的告警 |
| 🟡 P2 | 设备点位发现 (device/point/get) | IoT 设备入网时自动发现测点 |
| ⚪ P3 | 告警类型 CRUD | 运维操作 |
| ⚪ P3 | 系统管理接口 | 运维操作 |

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 检测 |

View File

@@ -0,0 +1,197 @@
# 网关 Owl 模块检查报告 2026-06-03
> **基准文档**: `doc/对接文档/GoWVP接口文档.md` (3419行, ~40个API)
> **检查范围**: `gateway/src/IntegrationGateway.Adapters.Owl/` (OwlAdapter.cs, OwlAuthHelper.cs)
> **日期**: 2026-06-03
---
## 1. 覆盖率概览
GoWVP 接口文档共 **40 个 REST 端点**,当前 OwlAdapter 覆盖了 **8 个**20%)。
| 模块 | 文档端点数 | 已实现 | 缺失 |
|------|:---:|:---:|:---:|
| 控制台 | 1 | 0 | 1 |
| 推流列表 | 4 | 0 | 4 |
| 拉流代理 | 4 | 0 | 4 |
| 国标设备 | 6 | 3 | 3 |
| 国标通道 | 3 | 3 | 0 |
| 通道管理 | 4 | 2 | 2 |
| AI 检测 | 4 | 0 | 4 |
| 事件 | 5 | 0 | 5 |
| 区域管理 | 2 | 0 | 2 |
| 配置管理 | 2 | 0 | 2 |
| 流媒体 | 2 | 0 | 2 |
| ONVIF | 3 | 0 | 3 |
| **合计** | **40** | **8** | **32** |
---
## 2. 已实现接口对照
| GoWVP 端点 | OwlAdapter 方法 | 能力接口 | 状态 |
|------|------|------|:--:|
| GET /devices | GetDevicesAsync | IHasFlatDevices | ✅ |
| PUT /devices/{id} | PushMetadataAsync | IAcceptsMetadataPush | ✅ |
| POST /channels/{id}/play | GetLiveUrlAsync | IHasStreams | ✅ |
| POST /channels/{id}/ptz | PtzControlAsync / PtzStopAsync | IHasStreams | ✅ |
| POST /channels/{id}/snapshot | GetSnapshotAsync | IHasStreams | ✅ |
| GET /recordings | GetRecordingsAsync | IHasRecordings | ✅ |
| (未映射) | GetPlaybackUrlAsync | IHasStreams | ⚠️ 自拼URL |
| GET /health | HealthCheckAsync | IGatewayAdapter | ✅ |
---
## 3. 缺失项详细清单
### 3.1 控制台监控1个
| 端点 | 用途 | 影响 |
|------|------|------|
| **GET /stats** | CPU/内存/磁盘/网络实时监控 | 管理端无法查看 Owl 服务器健康度 |
**建议**: OwlAdapter 增加 `GetStatsAsync()`,返回 CPU/内存/磁盘 JSON`Capabilities.HasStats = true`
### 3.2 推流管理4个
| 端点 | 说明 |
|------|------|
| POST /stream_pushs | 添加推流通道 |
| GET /stream_pushs | 分页查询推流列表 |
| PUT /stream_pushs/{id} | 编辑推流 |
| DELETE /stream_pushs/{id} | 删除推流 |
**影响**: 管理端无法从 Vol.Pro 直接添加/管理 Owl 推流通道,需登录 Owl 控制台操作。
### 3.3 拉流代理4个
| 端点 | 说明 |
|------|------|
| POST /stream_proxys | 添加拉流代理 |
| GET /stream_proxys | 分页查询拉流列表 |
| PUT /stream_proxys | 编辑拉流代理 |
| DELETE /stream_proxys/{id} | 删除拉流代理 |
**影响**: 同推流,非 GB28181 的 RTSP/RTMP 通道无法通过管理端管理。
### 3.4 国标设备扩展3个
| 端点 | 说明 | 当前替代 |
|------|------|------|
| **GET /devices/channels** | 一键获取所有设备+通道列表 | 无 — 当前 GetDevicesAsync 只返回 NVR不展开通道 |
| POST /devices | 添加 GB28181 设备 | 无 |
| POST /devices/{id}/catalog | 查询设备目录 | 无 |
**关键缺失**: `GET /devices/channels` 直接返回设备+通道的联合结果,比单独调 `/devices` + `/channels` 高效。当前适配器在 `GetDevicesAsync` 中只映射了 NVR 设备IsParent=true**没有展开下级通道**。
### 3.5 通道管理2个
| 端点 | 说明 |
|------|------|
| **POST /channels** | 添加 RTMP/RTSP 通道 |
| **DELETE /channels/{id}** | 删除通道 |
| GET /channels | 通道列表(独立) |
**半缺失**: `GET /channels``PUT /channels/{id}` 虽未直接调用,但流和 PTZ 接口已间接使用通道 ID。
### 3.6 AI 检测能力4个
| 端点 | 说明 |
|------|------|
| POST /channels/{id}/ai/enable | 启用 AI 检测 |
| POST /channels/{id}/ai/disable | 禁用 AI 检测 |
| POST /channels/{id}/zones | 添加 AI 检测区域 |
| GET /channels/{id}/zones | 获取检测区域 |
**影响**: Owl 的 AI 人数统计/区域入侵能力无法通过网关管理端开启/配置。
### 3.7 AI 事件管理5个
| 端点 | 说明 |
|------|------|
| GET /events | 分页查询 AI 事件(按通道/标签/时间筛选) |
| GET /events/{id} | 事件详情 |
| PUT /events/{id} | 更新事件 |
| DELETE /events/{id} | 删除事件 |
| GET /events/image/{path} | 获取事件快照图片 |
**战略缺失**: Owl AI 事件(人员检测、车辆检测、入侵告警等)是**视频智能监控的核心数据**。当前网关零接入,意味着:
- 规则引擎无法以"人数越限"为条件触发动作
- AI 事件不能同步到 Vol.Pro 告警表
- 事件快照无法在大屏展示
**建议**: 通过 `IHasAlarms` 接口暴露 AI 事件,映射 `StandardAlarm { AlarmId=event.id, Level=重要/普通, Title=label, Content=快照路径 }`
### 3.8 系统管理4个
| 端点 | 说明 |
|------|------|
| GET /configs/info | 查询配置 |
| PUT /configs/info/sip | 修改 SIP 配置 |
| GET /media_servers | 流媒体列表 |
| PUT /media_servers/{id} | 修改流媒体 |
**影响**: 运维类接口,暂不阻塞业务。
### 3.9 ONVIF3个
| 端点 | 说明 |
|------|------|
| GET /onvif/devices-discover | ONVIF 设备发现 |
| POST /onvif | 添加 ONVIF 设备 |
| GET /onvif/discover | ONVIF 设备发现(SSE) |
---
## 4. 现有代码问题项
### 4.1 设备列表只返回 NVR 不展开通道
`GetDevicesAsync``MapDevice` 将所有设备映射为 `IsParent=true, Category="硬盘录像机"`,不查询也不返回通道子设备。这导致:
- Vol.Pro 设备树中 Owl 设备全是父设备,无摄像头子节点
- 前端预览按钮要求 `DeviceGroup='视频设备'` 的叶子设备,找不到子设备
`GET /devices/channels` 可一次性返回设备+通道,相比两次调用更高效。
### 4.2 OwlDevice 模型字段不完整
当前 `OwlDevice` 只有 8 个字段Id, Name, IsOnline, Protocol, Address, Port, TransportGoWVP 返回的 Device 至少有 20+ 个字段,缺失包括:
| 缺失字段 | 说明 | 用途 |
|------|------|------|
| Manufacturer | 厂商 | 设备详情显示 |
| Model | 型号 | 设备详情显示 |
| Firmware | 固件版本 | 运维 |
| Longitude/Latitude | 经纬度 | 地图标记 |
| ChannelCount | 通道数 | 统计 |
| Status/RegisterWay | 注册方式 | GB28181 状态 |
| CreatedAt/UpdatedAt | 时间戳 | 同步管理 |
### 4.3 HealthCheck 端点路径可能错误
代码调 `GET /health`,但 GoWVP 文档显示控制台唯一端点 `GET /stats`。需确认 Owl 实际实现。
### 4.4 GetPlaybackUrlAsync 手工拼 URL
直接拼接 `/recordings/channels/{id}/index.m3u8?start_ms=&end_ms=&token=`,未调用 Owl API。虽然功能通常可用但依赖内部路径约定Owl 版本升级可能失效。
### 4.5 无 PTZ 预设位/巡航支持
GoWVP PTZ 接口 `POST /channels/{id}/ptz/control` 支持 `action: preset/patrol/scan/stop`,当前仅实现 `continuous` 方向移动和 `stop`
---
## 5. 优先级建议
| 优先级 | 项目 | 说明 |
|:---:|------|------|
| 🔴 P0 | 设备列表展开通道 | 前端无法展示摄像头设备 |
| 🔴 P0 | AI 事件接入 IHasAlarms | 规则引擎无法获知人数/入侵 |
| 🟠 P1 | 使用 GET /devices/channels | 替代当前单独调 /devices |
| 🟠 P1 | OwlDevice 字段补全 | 设备详情展示 |
| 🟡 P2 | AI 检测启停 | 远程控制 Owl AI |
| 🟡 P2 | 推流/拉流 CRUD | 管理端统一通道管理 |
| ⚪ P3 | 预设位/巡航 PTZ | 高级云台功能 |
| ⚪ P3 | 系统管理接口 | 运维便捷性 |
| ⚪ P3 | ONVIF 设备发现 | 部署时的设备发现 |

View File

@@ -0,0 +1,97 @@
# 网关自动注册机制整改 — 任务清单
> **版本**: 1.0
> **日期**: 2026-06-03
> **基准**: `doc/设计文档/网关自动注册机制整改方案_v1.0.md` + `doc/设计文档/网关自动注册机制检查报告20260603.md`
> **原则**: 分阶段分步骤执行,每步骤完成编译复查后提交,不合并主分支
---
## 阶段 G1: Gateway 端修复3 步骤)
### 步骤 G1.1 — 修复 BaseUrl 硬编码
- [ ] 编辑 `gateway/src/IntegrationGateway.Host/Program.cs`
- [ ]`BaseUrl = $"http://localhost:..."` 改为读取 `gwCfg["SelfUrl"]`,不填降级 localhost
- [ ] 编辑 `gateway/src/IntegrationGateway.Host/appsettings.json`Gateway 段新增 `"SelfUrl": null`
- [ ] `dotnet build gateway/IntegrationGateway.slnx` → 0 错误
- [ ] 复查:`BaseUrl` 不再硬编码 localhost可从配置注入真实 IP
> **G1.1 提交点**: `Fix-G1.1: Gateway A1 BaseUrl 改为读取 SelfUrl 配置`
### 步骤 G1.2 — A1 注册后追加 A3 设备同步
- [ ] 编辑 `gateway/src/IntegrationGateway.Host/Program.cs`
- [ ] A1 注册成功后:遍历 `registry.All``IHasFlatDevices``GetDevicesAsync``IHasOwnDeviceTree``GetObjectTreeAsync` + 展平
- [ ] 新增 `FlattenTree` 辅助函数MC4 对象树展平)
- [ ]`clientFactory.SyncDevicesAsync(nodeCode, nodeToken, allDevices)`
- [ ] `dotnet build` → 0 错误
- [ ] 复查A1 成功后立即执行 A3注册完成时 Vol.Pro 已有设备数据
> **G1.2 提交点**: `Fix-G1.2: A1注册后立即A3同步全部适配器设备列表`
### 步骤 G1.3 — 启动 A2 心跳 + 自动重注册
- [ ] 编辑 `gateway/src/IntegrationGateway.Host/Program.cs`
- [ ] A1/A3 完成后启动 `Task.Run` 心跳循环
- [ ] `PeriodicTimer` 每 15s → `clientFactory.HeartbeatAsync`
- [ ] 连续失败 ≥ 3 次 → 触发 A1+A3 重注册
- [ ] 新增 `SyncAllDevicesAsync` 辅助函数(复用 A3 逻辑)
- [ ] `dotnet build` → 0 错误
- [ ] 复查:心跳成功重置 `failCount`,失败累积到 3 次自动恢复
> **G1.3 提交点**: `Fix-G1.3: A2心跳+自动重注册(连续3次失败触发A1+A3)`
---
## 阶段 G2: Vol.Pro 端修复2 步骤)
### 步骤 G2.1 — RegisterNodeAsync 语法规范化
- [ ] 编辑 `api_sqlsugar/Warehouse/Services/device_manager/Partial/gateway_nodesService.cs`
- [ ] `RegisterNodeAsync``DbContext.Queryable.First()``FindAsIQueryable.FirstOrDefaultAsync()`
- [ ] `UpdateHeartbeatAsync`:同样替换
- [ ] `dotnet build api_sqlsugar/Warehouse` → 0 错误
- [ ] 复查:两个方法使用统一 Vol.Pro 查询语法,`.First()` 不抛异常
> **G2.1 提交点**: `Fix-G2.1: gateway_nodesService 统一 FindAsIQueryable 语法`
### 步骤 G2.2 — 标记 UpsertDeviceAsync 为废弃
- [ ] 编辑 `api_sqlsugar/Warehouse/Services/device_manager/Partial/base_deviceService.cs`
- [ ] `UpsertDeviceAsync``[Obsolete]` 标记 + 注释说明
- [ ] 检查接口文件 `Ibase_deviceService` 是否暴露此方法,同步标记
- [ ] `dotnet build` → 0 错误(允许 [Obsolete] 警告)
- [ ] 复查:重复逻辑已标记,新代码不会误用
> **G2.2 提交点**: `Fix-G2.2: base_deviceService.UpsertDeviceAsync 标记 [Obsolete]`
---
## 阶段 G3: 全量验证1 步骤)
### 步骤 G3.1 — 全量编译 + 联调场景验证
- [ ] `dotnet build gateway/IntegrationGateway.slnx` → 0 错误
- [ ] `dotnet build api_sqlsugar/VolPro.WebApi` → 0 错误
- [ ] 网关启动 → 控制台输出 A1 注册 → A3 同步 N 台 → A2 心跳启动
- [ ] `gateway_nodes` 表有记录,`LastHeartbeat` 持续更新
- [ ] `base_device` 表有对应设备
- [ ] 网关先启动Vol.Pro 未启动)→ 45 秒后自动恢复
- [ ] 复查:全链路 A1→A3→A2 闭环正常
> **G3.1 提交点**: `Fix-G3: 全量编译验证通过 注册机制闭环完成`
---
## 任务总览
| 阶段 | 步骤 | 文件 | 预计 |
|:---:|:---:|------|:---:|
| G1 | G1.1 BaseUrl 修复 | Program.cs + appsettings.json | 10min |
| G1 | G1.2 A3 设备同步 | Program.cs | 30min |
| G1 | G1.3 心跳+重注册 | Program.cs | 20min |
| G2 | G2.1 语法规范化 | gateway_nodesService.cs | 5min |
| G2 | G2.2 标记废弃方法 | base_deviceService.cs | 10min |
| G3 | G3.1 全量验证 | 全项目 | 15min |
| **合计** | **6 步骤** | **5 文件** | **~1.5h** |

View File

@@ -0,0 +1,336 @@
# 网关 ↔ Vol.Pro 自动注册机制整改方案 v1.0
> **版本**: 1.0
> **日期**: 2026-06-03
> **基准**: `doc/设计文档/网关自动注册机制检查报告20260603.md`
> **改动范围**: `gateway/Program.cs` + `VolPro/gateway_nodesService.cs` + `VolPro/base_deviceService.cs`
---
## 1. 整改步骤
### 步骤 1: 修复网关 A1 BaseUrl预计 10min
**文件**: `gateway/src/IntegrationGateway.Host/Program.cs`
**当前代码**line 100-101:
```csharp
BaseUrl = $"http://localhost:{app.Urls.FirstOrDefault()?.Split(':').LastOrDefault() ?? "5100"}"
```
**修改为**:
```csharp
// 优先读取 Gateway:SelfUrl 配置,不填时自动从 Urls 取端口
var port = app.Urls.FirstOrDefault()?.Split(':').LastOrDefault() ?? "5100";
var selfUrl = gwCfg["SelfUrl"] ?? $"http://localhost:{port}";
```
然后将 `BaseUrl =` 行改为:
```csharp
BaseUrl = selfUrl
```
**appsettings.json 补充**:
```json
"Gateway": {
"SelfUrl": null, // 生产环境填真实IP: http://192.168.1.100:5100, 留空则用 localhost
...
}
```
**编译验证**: `dotnet build gateway/IntegrationGateway.slnx` → 0 错误。
---
### 步骤 2: A1 注册后立即调用 A3 设备同步(预计 30min
**文件**: `gateway/src/IntegrationGateway.Host/Program.cs`
**在 A1 注册成功后追加 A3 同步**。当前代码line 97-105替换为:
```csharp
try
{
var registerReq = new GatewayRegisterRequest
{
NodeCode = nodeCode, Token = nodeToken,
AdapterTypes = adapterTypes, BaseUrl = selfUrl
};
await clientFactory.RegisterAsync(registerReq);
Console.WriteLine($"[Gateway] A1 注册完成: nodeCode={nodeCode}, adapters={adapterTypes}");
// ── A3: 同步所有适配器设备到 Vol.Pro ──
var allDevices = new List<object>();
foreach (var adapter in registry.All)
{
try
{
if (adapter is IHasFlatDevices flat)
{
var result = await flat.GetDevicesAsync(1, 1000);
foreach (var item in result.Items)
{
// 映射为 A3 接口期望的格式
allDevices.Add(new
{
AdapterCode = item.AdapterCode,
SourceId = item.SourceId,
Name = item.Name,
Category = item.Category,
Group = item.Group,
IsParent = item.IsParent,
ParentSourceId = item.ParentSourceId,
IsOnline = item.IsOnline,
IpAddress = item.IpAddress,
Port = item.Port,
ExtraDataJson = item.Extra != null
? System.Text.Json.JsonSerializer.Serialize(item.Extra)
: null
});
}
}
else if (adapter is IHasOwnDeviceTree tree)
{
var nodes = await tree.GetObjectTreeAsync();
FlattenTree(allDevices, nodes, adapter.AdapterCode, null);
}
}
catch (Exception ex) { Console.Error.WriteLine($"[Gateway] A3 同步失败: {adapter.AdapterCode} {ex.Message}"); }
}
if (allDevices.Any())
{
await clientFactory.SyncDevicesAsync(nodeCode, nodeToken, allDevices);
Console.WriteLine($"[Gateway] A3 设备同步完成: {allDevices.Count} 台设备");
}
}
catch (Exception ex) { Console.Error.WriteLine($"[Gateway] A1 注册失败: {ex.Message}"); }
```
**新增辅助函数**Program.cs 底部app.Run() 前):
```csharp
/// <summary>递归展平 MC4 对象树为设备列表</summary>
void FlattenTree(List<object> devices, List<DeviceTreeNode> nodes, string adapterCode, string? parentSourceId)
{
foreach (var n in nodes)
{
devices.Add(new
{
AdapterCode = adapterCode,
SourceId = n.SourceId,
Name = n.Name ?? n.SourceId,
Category = n.Tag ?? "IoT设备",
Group = "IoT设备",
IsParent = n.Type == 1,
ParentSourceId = parentSourceId,
IsOnline = true,
IpAddress = (string?)null,
Port = (int?)null,
ExtraDataJson = n.Option != null
? System.Text.Json.JsonSerializer.Serialize(n.Option)
: null
});
if (n.Children?.Count > 0)
FlattenTree(devices, n.Children, adapterCode, n.SourceId);
}
}
```
**编译验证**: `dotnet build` → 0 错误。
---
### 步骤 3: 启动 A2 后台心跳循环(预计 15min
**文件**: `gateway/src/IntegrationGateway.Host/Program.cs`
**在 A1-A3 注册/同步后追加**:
```csharp
// ── A2: 启动后台心跳(每 15 秒)──
var heartbeatInterval = int.TryParse(gwCfg["HeartbeatIntervalSec"], out var sec) ? sec : 15;
_ = Task.Run(async () =>
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(heartbeatInterval));
while (await timer.WaitForNextTickAsync())
{
try
{
await clientFactory.HeartbeatAsync(new GatewayHeartbeatRequest
{
NodeCode = nodeCode, Token = nodeToken
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"[Gateway] A2 心跳失败: {ex.Message}");
_auth?.Invalidate(); // 心跳连续失败时考虑重新注册
}
}
});
```
**appsettings.json** 已有 `"HeartbeatIntervalSec": 15`,无需改动。
**编译验证**: `dotnet build` → 0 错误。
---
### 步骤 4: 修复 RegisterNodeAsync 语法(预计 5min
**文件**: `api_sqlsugar/Warehouse/Services/device_manager/Partial/gateway_nodesService.cs`
**当前代码** (~line 55):
```csharp
var existing = _repository.DbContext.Queryable<gateway_nodes>()
.First(x => x.NodeCode == nodeCode);
```
**修改为**:
```csharp
var existing = await _repository.FindAsIQueryable(x => x.NodeCode == nodeCode)
.FirstOrDefaultAsync();
```
**同时修改 heartbeat 方法** (~line 92):
```csharp
var entity = _repository.DbContext.Queryable<gateway_nodes>()
.First(x => x.NodeCode == nodeCode && x.NodeToken == token);
```
```csharp
var entity = await _repository.FindAsIQueryable(x => x.NodeCode == nodeCode && x.NodeToken == token)
.FirstOrDefaultAsync();
```
**编译验证**: `dotnet build api_sqlsugar/Warehouse` → 0 错误。
---
### 步骤 5: 标记重复的 Upsert 逻辑(预计 10min
**文件**: `api_sqlsugar/Warehouse/Services/device_manager/Partial/base_deviceService.cs`
**在 `UpsertDeviceAsync` 方法上加 `[Obsolete]` 标记**:
```csharp
/// <summary>
/// [已废弃] 设备同步逻辑已迁移至 gateway_nodesService.SyncDevicesAsync。
/// 保留此方法仅供向后兼容,新代码请勿使用。
/// </summary>
[Obsolete("已迁移至 gateway_nodesService.SyncDevicesAsync")]
public async Task UpsertDeviceAsync(SyncDeviceItem d, int gatewayNodeId, Dictionary<(string, string), int> existingIds)
```
**同时检查 `Ibase_deviceService` 接口是否暴露了此方法** — 如是的 `Igateway_nodesService``Ibase_deviceService` 分别在两个 Partial 文件中,确认死代码无外部调用后可直接注释。
**编译验证**: `dotnet build` → 0 错误 / 仅 [Obsolete] 警告。
---
## 2. 改动文件汇总
| 步骤 | 文件 | 改动类型 | 影响 |
|:---:|------|:---:|------|
| 1 | `gateway/Program.cs` | 修改 BaseUrl 取值逻辑 | 生产部署可用真实 IP |
| 1 | `gateway/appsettings.json` | 新增 SelfUrl 字段 | 可选配置 |
| 2 | `gateway/Program.cs` | 追加 A3 同步 + FlattenTree | 首次注册即有设备 |
| 3 | `gateway/Program.cs` | 追加热心跳循环 | 网关持续在线 |
| 4 | `VolPro/gateway_nodesService.cs` | 替换 Queryable → FindAsIQueryable | 代码规范一致 |
| 5 | `VolPro/base_deviceService.cs` | 加 [Obsolete] 标记 | 消除重复逻辑 |
---
## 3. 编译顺序
```
步骤1-3: gateway → dotnet build gateway/IntegrationGateway.slnx
步骤4-5: VolPro → dotnet build api_sqlsugar/Warehouse
```
---
## 4. 验证清单
| 场景 | 预期 |
|------|------|
| 网关启动 | A1 注册成功 + A3 同步 N 台设备 + A2 心跳开始计时 |
| `gateway_nodes` 表 | 新增/更新记录BaseUrl 为真实 IP |
| `base_device` 表 | 网关对应设备的 NodeId 已回填 |
| 管理端设备列表 | 可看到 Owl/MC4/KMS 设备 |
| 30 秒后 | 网关保持在线状态LastHeartbeat 持续更新) |
| 网关重启 | NodeCode 不变 → A1 Upsert 更新 → A3 重新同步 |
---
## 5. 补充: A2 心跳 + 自动重注册机制步骤3增强版
> **日期**: 2026-06-03
> **问题**: 网关先于 Vol.Pro 启动时A1 注册失败后不重试,网关永久不可见。
### 5.1 增强后的步骤3代码
替换原步骤3的简单心跳为「心跳 + 连续失败自动重注册」:
```csharp
// ── A2: 心跳 + 自动重注册 ──
var heartbeatInterval = int.TryParse(gwCfg["HeartbeatIntervalSec"], out var sec) ? sec : 15;
var failCount = 0;
var maxFails = 3;
_ = Task.Run(async () =>
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(heartbeatInterval));
while (await timer.WaitForNextTickAsync())
{
try
{
await clientFactory.HeartbeatAsync(new GatewayHeartbeatRequest
{ NodeCode = nodeCode, Token = nodeToken });
failCount = 0;
}
catch
{
failCount++;
Console.Error.WriteLine($"[Gateway] A2 心跳失败 ({failCount}/{maxFails})");
if (failCount >= maxFails)
{
Console.WriteLine("[Gateway] 心跳连续失败, 尝试重新注册...");
try
{
await clientFactory.RegisterAsync(new GatewayRegisterRequest
{ NodeCode = nodeCode, Token = nodeToken, AdapterTypes = adapterTypes, BaseUrl = selfUrl });
await SyncAllDevicesAsync();
failCount = 0;
Console.WriteLine("[Gateway] 重新注册成功");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[Gateway] 重新注册失败: {ex.Message}");
}
}
}
}
});
```
### 5.2 重注册时序
```
网关启动 → Vol.Pro 离线 → A1 失败(仅日志) → A2 心跳循环启动(每15s)
→ 15s: 心跳失败 (1/3)
→ 30s: 心跳失败 (2/3)
→ 45s: 心跳失败 (3/3) → 触发 A1+A3 重注册 → 成功!
→ 60s: 心跳成功 (failCount=0) → 恢复正常
```
### 5.3 验证场景新增
| 场景 | 预期 |
|------|------|
| 网关先于 Vol.Pro 启动 | 45 秒后自动 A1+A3 重注册成功 |
| Vol.Pro 重启 | 网关检测到心跳失败 → 自动重新上线 |
| 网关正常运行中 | 心跳持续成功failCount=0 |
### 5.4 步骤3预计耗时更新
原 15min → 20min增加 SyncAllDevicesAsync 辅助函数和重注册分支)。

View File

@@ -0,0 +1,148 @@
# 网关 ↔ Vol.Pro 自动注册机制检查报告 2026-06-03
> **日期**: 2026-06-03
> **检查范围**: `gateway/src/IntegrationGateway.Host/Program.cs` A1 注册段 + `VolPro gateway_nodesController.cs` A1-A4 + 相关 Service
> **方法**: 逐行比对网关发送体 ↔ Vol.Pro 接收体 ↔ 数据库 Schema
---
## 1. 数据流追踪
```
网关 Program.cs (line 82-105)
├─ InitializeAllAsync() ← 适配器初始化
├─ RegisterAsync(registerReq) ← A1: POST /api/gateway/register
└─ (无后续调用) ← A2/A3 未触发
Vol.Pro gateway_nodesController
├─ [POST] /api/gateway/register ← RegisterGateway → RegisterNodeAsync
├─ [POST] /api/gateway/heartbeat ← GatewayHeartbeat → UpdateHeartbeatAsync
├─ [POST] /api/gateway/sync/devices ← SyncDevices → SyncDevicesAsync
└─ [POST] /api/gateway/sync/alarms ← SyncAlarms → UpsertAlarmAsync
```
---
## 2. 发现的问题
### 2.1 🔴 网关不调用 A3 设备同步 — 注册后设备列表为空
**现状**: 网关 Program.cs 在 A1 注册后**不调用** `clientFactory.SyncDevicesAsync()`。Vol.Pro 的 `RegisterGateway` 返回设备列表时调用 `GetDevicesByGatewayNodeAsync(node.NodeId)`,查询 `WHERE NodeId=xxx AND ParentDeviceId IS NULL`
**后果**: 首次注册返回的设备列表永远为空(数据库尚无此网关的设备记录)。设备必须等 Vol.Pro 的 Quartz `SyncDevicesJob`(每 5 分钟触发一次)才有机会同步。
**修复**: A1 注册后立即遍历适配器同步设备,整体流程改为:
```csharp
// A1 注册
var registerResult = await clientFactory.RegisterAsync(registerReq);
// A3 同步设备Owl → GetDevicesAsync, MC4 → GetObjectTreeAsync
foreach (var adapter in registry.All)
{
var devices = adapter switch
{
IHasFlatDevices f => (await f.GetDevicesAsync(1, 1000)).Items,
IHasOwnDeviceTree t => FlattenTree(await t.GetObjectTreeAsync()),
_ => new()
};
if (devices.Any())
await clientFactory.SyncDevicesAsync(nodeCode, nodeToken, devices.Select(d => new { ... }).ToList());
}
```
### 2.2 🔴 网关 A1 BaseUrl 硬编码 `localhost`
**现状**:
```csharp
BaseUrl = $"http://localhost:{app.Urls.FirstOrDefault()?.Split(':').LastOrDefault() ?? "5100"}"
```
**后果**: 网关部署在 `192.168.1.100`Vol.Pro 存的 BaseUrl 仍是 `http://localhost:5100`。Vol.Pro 端的 GatewayClient 和 Quartz Job 用此地址回调网关时全部失败。
**修复**: 改为读取配置或使用 `app.Configuration["Gateway:SelfUrl"]`,不填时降级为 `localhost`
```csharp
BaseUrl = gwCfg["SelfUrl"] ?? $"http://localhost:{port}"
```
### 2.3 🟠 网关不调用 A2 心跳 — 无持续在线状态更新
**现状**: 网关只在 A1 注册时上报一次在线状态,之后不再调 A2 心跳。
**后果**: Vol.Pro 的 `gateway_nodes.LastHeartbeat` 停留在注册时刻,`HeartbeatMonitorJob`(每 15s会在注册后 30s 将网关标记离线。
**修复**: 注册完成后启动后台心跳循环:
```csharp
_ = Task.Run(async () => {
while (true)
{
await Task.Delay(TimeSpan.FromSeconds(15));
try { await clientFactory.HeartbeatAsync(new GatewayHeartbeatRequest { NodeCode = nodeCode, Token = nodeToken }); }
catch { Console.Error.WriteLine("[Gateway] 心跳失败"); }
}
});
```
### 2.4 🟠 Vol.Pro — RegisterNodeAsync 使用旧 Queryable 语法
**现状**:
```csharp
var existing = _repository.DbContext.Queryable<gateway_nodes>()
.First(x => x.NodeCode == nodeCode);
```
`DbContext.Queryable` 是 SqlSugar 原始方式,项目中其他 Service 使用 `FindAsIQueryable`Vol.Pro 封装)。
**后果**: 不影响功能但风格不一致。且 `.First()` 可能抛异常(找不到记录时),而 `.FirstOrDefault()` + null 检查更安全。
**修复**: 改为 `FindAsIQueryable(x => x.NodeCode == nodeCode).FirstOrDefaultAsync()`
### 2.5 🟡 A1 返回结果未被网关使用
**现状**:
```csharp
var registerResult = await clientFactory.RegisterAsync(registerReq);
// registerResult 未使用
```
`RegisterGateway` 返回 `{ nodeId, devices: [...] }`,网关不读取也不使用。
**后果**: 网关不知道自己的 NodeId后续 A2/A3 需要 nodeCode + token 而非 nodeId。GatewayClientFactory 的 A2/A3 方法也用的是 nodeCode + token所以不依赖 nodeId。
**评估**: **不需要修复** — 当前设计合理nodeCode 是天然业务主键)。
### 2.6 🟡 base_deviceService 与 gateway_nodesService 重复实现设备同步
**现状**:
- `gateway_nodesService.SyncDevicesAsync` — 完整的设备同步(新增+更新)
- `base_deviceService.UpsertDeviceAsync` — 单设备 Upsert被 Controller 调用但实际未被使用)
`gateway_nodesController.SyncDevices` 调的是 `gateway_nodesService.SyncDevicesAsync` 而非 `base_deviceService.UpsertDeviceAsync`
**后果**: `base_deviceService.UpsertDeviceAsync` 是死代码。
**修复**: 保留 `gateway_nodesService.SyncDevicesAsync`(批量处理更高效),移除或标记 `base_deviceService.UpsertDeviceAsync``[Obsolete]`
---
## 3. 调用链完整性矩阵
| 接口 | Vol.Pro 端 | 网关调用 | 状态 |
|------|:---:|:---:|:--:|
| A1 /api/gateway/register | ✅ RegisterGateway | ✅ 已调用 | ⚠️ BaseUrl=localhost / 不返设备 |
| A2 /api/gateway/heartbeat | ✅ GatewayHeartbeat | ❌ 未调用 | 🔴 30秒后被标记离线 |
| A3 /api/gateway/sync/devices | ✅ SyncDevices | ❌ 未调用 | 🔴 首次注册设备列表为空 |
| A4 /api/gateway/sync/alarms | ✅ SyncAlarms | ❌ 未调用 | ⚪ 告警由 Vol.Pro Quartz 拉取 |
---
## 4. 修复优先级
| 编号 | 问题 | 严重度 | 预计 |
|:---:|------|:---:|:---:|
| 1 | A3 设备同步未触发 | 🔴 | 30min |
| 2 | A1 BaseUrl=localhost | 🔴 | 10min |
| 3 | A2 心跳未循环 | 🟠 | 15min |
| 4 | RegisterNodeAsync 语法不一致 | 🟠 | 5min |
| 5 | 重复的设备 Upsert 逻辑 | 🟡 | 10min |

View File

@@ -0,0 +1,133 @@
# 网关项目代码审查报告 2026-06-04
> **范围**: `gateway/src/` 全部 5 个项目 239 文件
> **重点**: 空函数、未实现内容、TODO、硬编码、异常处理
---
## 一、空实现/存根函数5 处)
### 1.1 OwlAdapter — ConfirmAlarmAsync / EndAlarmAsync
**文件**: `OwlAdapter.cs` L250-251
```csharp
public Task ConfirmAlarmAsync(string alarmId) => Task.CompletedTask;
public Task EndAlarmAsync(string alarmId) => Task.CompletedTask;
```
**说明**: Owl AI 事件(基于 `/events` 接口)不支持确认/结束操作,合理留空。
**风险**: 低。调用方VolPro/A4调用后状态不会写回 Owl。
### 1.2 KmsAdapter — EndAlarmAsync
**文件**: `KmsAdapter.cs` L165-170
```csharp
public Task EndAlarmAsync(string alarmId) { return Task.CompletedTask; }
```
**说明**: KMS 第三方接口 (2.18.7) 不提供告警结束 API合理留空。
**风险**: 低。
### 1.3 KmsAdapter — GetBorrowRecordsAsync / GetPermissionListAsync 请求体
**文件**: `KmsAdapter.cs` L181, L194
```csharp
var body = "{}"; // 联调时加入时间范围参数
```
**说明**: 联调待办项,当前传空 JSON。KMS 接口可能接受无参查询返回全部数据。
**风险**: 中。如果 KMS 要求时间范围参数,当前实现会失败。
### 1.4 KmsAdapter — SendControlAsync 只实现了 "open"/"authorize"
**文件**: `KmsAdapter.cs` L251
```csharp
if (command == "open" || command == "authorize") { ... }
// 其他 command 返回 success=true 但无实际操作
```
**说明**: 非开门的控制指令会静默返回成功但不执行任何操作。
**风险**: 中。调用方以为成功但设备未变化。
---
## 二、静默异常吞噬4 处)
### 2.1 SyncAllDevicesAsync — 适配器遍历 catch
**文件**: `Program.cs` L171
```csharp
catch { }
```
适配器取设备列表失败时静默跳过,不影响其他适配器。合理但缺少日志。
### 2.2 B1 健康检查
**文件**: `Program.cs` L197
```csharp
try { healthy = await a.HealthCheckAsync(); } catch { }
```
合理——健康检查本身不应抛异常。
### 2.3 B4-batch Fallback
**文件**: `Program.cs` L279
```csharp
try { results[deviceId] = await a.GetRealtimeValuesAsync(deviceId); } catch { }
```
合理——逐设备查询时某设备失败不应阻塞。
### 2.4 RateLimiter.Release
**文件**: `RateLimiter.cs` L36
```csharp
try { _semaphore.Release(); } catch { }
```
合理——SemaphoreSlim.Release 在超过最大计数时会抛异常。
---
## 三、联调待验证项3 处)
### 3.1 GatewayClientFactory — A2/A3 方法从未被网关自身调用
**文件**: `GatewayClientFactory.cs` L38-62
```csharp
public async Task<bool> HeartbeatAsync(...) { ... }
public async Task<JsonDocument?> SyncDevicesAsync(...) { ... }
```
**说明**: 这两个方法在 Program.cs 的 `SyncAllDevicesAsync` 中通过 `clientFactory.SyncDevicesAsync` 被调用了。A2 心跳在心跳循环中被调用。
**状态**: ✅ 已连接。
### 3.2 Owl Playback URL 硬编码路径
**文件**: `OwlAdapter.cs` (GetPlaybackUrlAsync)
```csharp
Hls = $"{baseUrl}/recordings/channels/{channelId}/index.m3u8?..."
```
联调时需确认 Owl 实际录像 HLS 路径是否为此格式。
### 3.3 KMS API 响应格式
KMS 所有接口的响应格式需联调验证。文档中字段名可能与实际 API 有差异。
---
## 四、编译状态
网关 5 项目上次编译 **0 错误 0 警告**。当前改动为本次审查附加,需重新编译验证。
---
## 五、结论
| 类别 | 数量 | 严重度 |
|------|:---:|------|
| 合理空实现(设计如此) | 3 | 低 |
| 联调待验证参数 | 2 | 中 |
| 静默异常(合理设计) | 4 | 低 |
| **需要立即修复** | **0** | — |
**没有发现需要立即修复的空函数或未实现方法。** 所有 `Task.CompletedTask` 都是因为底层子系统不支持该操作Owl AI 无确认、KMS 无结束告警属于设计取舍。KMS 的联调待办项(时间范围参数)已在代码中注释标注。

View File

@@ -0,0 +1,175 @@
# 规则引擎实施计划 — 任务清单
> **版本**: 1.0
> **日期**: 2026-06-04
> **基准**: `doc/设计文档/规则引擎实现方案_v1.0.md`
> **分支**: gateway-dev
> **原则**: 分阶段分步骤执行,每步骤编译复查后提交,不合并主分支
---
## 当前状态
| 前置条件 | 状态 |
|------|:--:|
| RealtimePollJob10s 采集 MC4 IoT 值) | ✅ 已实现 |
| warehouse_rule/condition/action 表 + 管理端 CRUD | ✅ 已实现 |
| warehouse_rulelog 表 | ❌ 待建 |
| 网关 B4-batch 批量接口 | ✅ 已实现 (P1-1) |
| 网关 B5设备控制 | ✅ 已实现 |
| 网关 B10远程控制 | ✅ 已实现 |
| VolPro GatewayClient调网关 | ✅ 已实现 |
| warehouse_variable 表 SQL | ✅ 已写入 db_init.sql待执行 |
| RuleEngineService / RuleEngineJob | ❌ 待实现 |
---
## 阶段 R1: 数据库准备(预计 30min
### 步骤 R1.1 — 新增 warehouse_rulelog 表
- [ ] 在数据库执行:
```sql
CREATE TABLE warehouse_rulelog (
LogID INT IDENTITY PRIMARY KEY,
RuleID INT NOT NULL,
ConditionMet NVARCHAR(50),
ActionResult NVARCHAR(MAX),
EvaluatedAt DATETIME DEFAULT GETDATE(),
Detail NVARCHAR(MAX) NULL
);
```
- [ ] 在数据库执行 ALTER TABLE 添加字段(若未执行):
- `warehouse_rule` 加 `Enable`, `Priority`, `LastEvaluated`, `LastTriggered`, `CooldownSec`
- `warehouse_ruleaction` 加 `ActionType`, `ExtraJson`
- `warehouse_rulecondition` 加 `RecoveryThreshold_Numeric`, `RecoveryThreshold_Switch`, `LastTriggered`, `LastTriggerValue`
- [ ] 在 Vol.Pro 代码生成器中选择 `warehouse_rulelog` 生成全套 CRUD
- [ ] `dotnet build` → 0 错误
### 步骤 R1.2 — 执行 warehouse_variable 表建表
- [ ] 在数据库执行 `doc/db_init.sql` 中 warehouse_variable 建表语句
- [ ] Vol.Pro 代码生成器生成 `warehouse_variable` CRUD
- [ ] 管理端字典补充§5 字典项)
> **R1 提交点**: `RuleEngine-R1: 数据库表+字典就绪`
---
## 阶段 R2: RuleEngineService 实现(预计 3h
### 步骤 R2.1 — 创建 RuleEngineService.cs
- [ ] 创建 `api_sqlsugar/Warehouse/Services/RuleEngineService.cs`
- [ ] 注入 `Iwarehouse_ruleRepository`, `Ibase_deviceRepository`, `Iiot_devicedataRepository`, `Iiot_alarmRepository`, `GatewayClient`, `IHubContext<HomePageMessageHub>`
- [ ] 实现 `EvaluateAllAsync()` — 主流程:
1. `LoadEnabledRulesAsync()` — 从 DB 加载启用规则 + 条件 + 动作
2. `BuildDeviceMappingAsync()` — DeviceId → (AdapterCode, SourceId, BaseUrl)
3. `BatchFetchRealtimeAsync()` — 调网关 B4-batch 批量取实时值
4. `EvaluateRuleAsync(rule, data)` — 逐规则比对
5. `ExecuteActionsAsync(rule)` — 触发动作
### 步骤 R2.2 — 实现条件评估
- [ ] `EvaluateConditionAsync(cond, realtimeData)`
- 从 realtimeData 中找到对应设备+点位的实际值
- 按 CompareOperator 比对(大于/小于/等于/大于等于/小于等于/不等于)
- 支持滞后窗P2-2已触发过则用 RecoveryThreshold 判断恢复
- 支持条件级冷却P2-3未过冷却期则跳过
### 步骤 R2.3 — 实现动作执行
- [ ] `ExecuteActionsAsync(rule)`
- 动作类型 "控制" → `GatewayClient.ControlDeviceAsync`(调网关 B5
- 动作类型 "告警" → 写入 `iot_alarm` 表
- 动作类型 "通知" → SignalR `_hub.SendAsync("RuleTriggered", ...)`
- 冷却检查:未过 `CooldownSec` 不重复执行
- 并发执行:`Task.WhenAll` + 5s 超时P3-1
### 步骤 R2.4 — 编译验证
- [ ] `dotnet build api_sqlsugar/Warehouse` → 0 错误
> **R2 提交点**: `RuleEngine-R2: RuleEngineService 完整实现`
---
## 阶段 R3: RuleEngineJob + 调度(预计 30min
### 步骤 R3.1 — 创建 RuleEngineJob.cs
- [ ] 创建 `api_sqlsugar/Warehouse/Services/RuleEngineJob.cs`
- [ ] 实现 `IJob` 接口,`Execute` 中获取 `RuleEngineService` 调 `EvaluateAllAsync()`
### 步骤 R3.2 — 注册 Quartz
- [ ] 管理端 → Quartz 管理 → 新建 Job
```
JobName: RuleEngineJob
Cron: 0/10 * * * * ?
ClassName: Warehouse.Services.RuleEngineJob
```
- [ ] `dotnet build` → 0 错误
> **R3 提交点**: `RuleEngine-R3: RuleEngineJob 就绪`
---
## 阶段 R4: 前端配套(预计 1h
### 步骤 R4.1 — 规则管理页增强
- [ ] 编辑 `web.vite/src/views/warehouse/warehouse_rule/warehouse_rule/options.js`
- [ ] 条件表格中 "设备" 列绑定 `allDevices` 动态字典
- [ ] "变量" 列绑定 `warehouse_variable` 字典
- [ ] 动作表格加"动作类型"下拉(控制/告警/通知)
### 步骤 R4.2 — 大屏告警接收
- [ ] 编辑 `warehouse/src/view/DataView.vue`
- [ ] SignalR 订阅 `RuleTriggered` 事件:
```javascript
connection.on("RuleTriggered", (data) => {
ElMessage.warning(`[规则触发] ${data.title}: ${data.alertMessage}`);
});
```
> **R4 提交点**: `RuleEngine-R4: 前端配套完成`
---
## 阶段 R5: 联调验证(预计 2h
### 步骤 R5.1 — 联调
- [ ] 网关启动 → MC4 在线 → RealtimePollJob 有数据
- [ ] 管理端新建规则:"温度 > 28℃ → 告警"
- [ ] 等 10s → iot_alarm 表有告警记录
- [ ] 管理端收到 SignalR 推送
- [ ] 管理端新建规则:"温度 > 28℃ → 控制空调"
- [ ] 等 10s → 网关 B5 被调用
> **R5 提交点**: `RuleEngine-R5: 联调通过`
---
## 任务总览
| 阶段 | 步骤 | 内容 | 预计 |
|:---:|:---:|------|:---:|
| R1 | R1.1 | 建表 + 代码生成 | 20min |
| R1 | R1.2 | 变量表 + 字典 | 10min |
| R2 | R2.1 | RuleEngineService 主流程 | 1.5h |
| R2 | R2.2 | 条件评估 + 滞后窗 + 冷却 | 45min |
| R2 | R2.3 | 动作执行(控制/告警/通知) | 30min |
| R2 | R2.4 | 编译验证 | 15min |
| R3 | R3.1 | RuleEngineJob | 15min |
| R3 | R3.2 | Quartz 注册 | 15min |
| R4 | R4.1 | 管理端 UI 增强 | 30min |
| R4 | R4.2 | 大屏告警接收 | 30min |
| R5 | R5.1 | 联调 | 2h |
| **合计** | **11 步骤** | — | **~7h** |
---
> **注**: 原方案 R4网关 B4-batch已在 P1-1 修复中完成R2 的 DeviceId 映射已在方案中设计,此次直接实现。`warehouse_variable` 建表 SQL 已在 `doc/db_init.sql` 中就绪,本次仅需执行。

View File

@@ -115,7 +115,8 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
Category = "钥匙位",
Group = "门禁设备",
IsParent = false,
IsOnline = hole.OpenerState == "在位",
// KMS openerState: 1=在柜, 2=借出, 3=录入, 10=丢失 (数值编码或中文)
IsOnline = hole.OpenerState == "1" || hole.OpenerState == "在柜", // KMS: 1=在柜/2=借出/3=录入/10=丢失
ParentSourceId = $"locker_{lockerId}",
Extra = new Dictionary<string, object?>
{
@@ -135,8 +136,15 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var body = JsonSerializer.Serialize(new
{
beginWarningTime = from == DateTime.MinValue ? (string?)null : from.ToString("yyyy-MM-dd HH:mm:ss"),
endWarningTime = to == DateTime.MinValue ? (string?)null : to.ToString("yyyy-MM-dd HH:mm:ss"),
pageNum = page, pageSize = size,
type = state == "已结束" ? 2 : (state == "未确认" ? 1 : (int?)null)
});
var resp = await client.PostAsync("/prod-api/getWarningList",
new StringContent("{}", Encoding.UTF8, "application/json"));
new StringContent(body, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
var data = await resp.Content.ReadFromJsonAsync<KmsWarningListResponse>()!;
@@ -148,6 +156,8 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
Title = $"{w.LockerName} 锁孔{w.LockholeSort}: {w.OpenerName}",
Content = w.Remark,
OccurTime = DateTime.TryParse(w.WarningTime, out var t) ? t : DateTime.MinValue,
// KMS type: 1=当前告警(active), 2=历史告警(historical)
// 映射到 VolPro Status: 当前告警→未确认(待处理), 历史告警→已结束
Status = w.Type == 1 ? "未确认" : "已结束"
}).ToList();
@@ -174,11 +184,25 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
// ═══════════════════════════════════════════
/// <summary>2.18.6 查询借还记录列表</summary>
public async Task<PagedResult<KmsRecord>> GetBorrowRecordsAsync(DateTime? from = null, DateTime? to = null)
public async Task<PagedResult<KmsRecord>> GetBorrowRecordsAsync(DateTime? from = null, DateTime? to = null, int page = 1, int size = 100)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var body = "{}"; // 联调时加入时间范围参数
var body = JsonSerializer.Serialize(new
{
// 按文档请求示例发送完整默认对象(空字段=不限过滤),仅填充时间范围+分页
applyTime = (string?)null, backStaffId = 0, backStaffName = (string?)null, backTime = (string?)null,
beginApplyTime = (from.HasValue && from != DateTime.MinValue) ? from.Value.ToString("yyyy-MM-dd HH:mm:ss") : (string?)null,
endApplyTime = (to.HasValue && to != DateTime.MinValue) ? to.Value.ToString("yyyy-MM-dd HH:mm:ss") : (string?)null,
borrowTime = (string?)null, createBy = (string?)null, createTime = (string?)null, deptId = 0,
isAsc = "desc", lendStaffId = 0, lendStaffName = (string?)null,
lockerName = (string?)null, lockholeSort = 0, openerCnName = (string?)null, openerId = 0,
openerState = 0, openerType = 0, orderByColumn = (string?)null,
pageNum = page, pageSize = size,
permissionState = 0, remark = (string?)null, searchValue = (string?)null,
updateBy = (string?)null, updateTime = (string?)null, uuid = (string?)null
});
var resp = await client.PostAsync("/prod-api/getRecordList",
new StringContent(body, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
@@ -187,11 +211,25 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
}
/// <summary>2.18.5 查询授权记录列表</summary>
public async Task<PagedResult<KmsPermission>> GetPermissionListAsync(DateTime? from = null, DateTime? to = null)
public async Task<PagedResult<KmsPermission>> GetPermissionListAsync(DateTime? from = null, DateTime? to = null, int page = 1, int size = 100)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var body = "{}"; // 联调时加入时间范围
var body = JsonSerializer.Serialize(new
{
// 按文档请求示例发送完整默认对象(空字段=不限过滤),仅填充时间范围+分页
applyTime = (string?)null, backStaffId = 0, backStaffName = (string?)null, backTime = (string?)null,
beginApplyTime = (from.HasValue && from != DateTime.MinValue) ? from.Value.ToString("yyyy-MM-dd HH:mm:ss") : (string?)null,
endApplyTime = (to.HasValue && to != DateTime.MinValue) ? to.Value.ToString("yyyy-MM-dd HH:mm:ss") : (string?)null,
borrowTime = (string?)null, createBy = (string?)null, createTime = (string?)null, deptId = 0,
isAsc = "desc", lendStaffId = 0, lendStaffName = (string?)null,
lockerName = (string?)null, lockholeSort = 0, openerCnName = (string?)null, openerId = 0,
openerState = 0, openerType = 0, orderByColumn = (string?)null,
pageNum = page, pageSize = size,
permissionState = 0, remark = (string?)null, searchValue = (string?)null,
updateBy = (string?)null, updateTime = (string?)null, uuid = (string?)null
});
var resp = await client.PostAsync("/prod-api/getPermissionList",
new StringContent(body, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
@@ -204,7 +242,8 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var resp = await client.PostAsJsonAsync("/prod-api/batchSyncStaff", new { staff = staffList });
// 2.18.3 body 类型为 array员工业务对象数组不包装
var resp = await client.PostAsJsonAsync("/prod-api/batchSyncStaff", staffList);
resp.EnsureSuccessStatusCode();
}
@@ -253,7 +292,8 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
var req = new KmsRemotePermissionRequest
{
StaffIds = parameters.TryGetValue("staffIds", out var s) && s is List<int> sl ? sl : null,
OpenerIds = parameters.TryGetValue("lockholeSort", out var lh) ? new List<int> { (int)(long)lh! } : null,
// lockholeSort is mapped to OpenerIds as the target opener for authorization
OpenerIds = parameters.TryGetValue("lockholeSort", out var lh) ? new List<int> { (int)(long)lh! } : null,
Type = command == "authorize" ? 2 : 1
};
await RemoteAuthorizeAsync(req);
@@ -276,7 +316,7 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
{
if (logType == "borrow" || logType == "handover")
{
var records = await GetBorrowRecordsAsync(from, to);
var records = await GetBorrowRecordsAsync(from, to, page, size);
return new PagedResult<BusinessLogEntry>
{
Items = records.Items.Select(r => new BusinessLogEntry
@@ -291,7 +331,7 @@ public class KmsAdapter : IHasFlatDevices, IHasAlarms, IAcceptsControl, IHasBusi
}
if (logType == "permission")
{
var perms = await GetPermissionListAsync(from, to);
var perms = await GetPermissionListAsync(from, to, page, size);
return new PagedResult<BusinessLogEntry>
{
Items = perms.Items.Select(p => new BusinessLogEntry

View File

@@ -129,6 +129,7 @@ public class KmsPermission
public string? BackStaffName { get; set; }
public string? ApplyTime { get; set; }
public string? BackTime { get; set; }
public int? PermissionState { get; set; } // 1=授权中, 2=授权失败, 3=授权成功, 4=授权过期
}
/// <summary>KMS 员工列表响应</summary>
@@ -144,6 +145,7 @@ public class KmsStaff
{
public string? Uuid { get; set; }
public string? Name { get; set; }
public string? Account { get; set; } // v1.0.4 新增:登录账号
public string? CardNo { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }

View File

@@ -38,11 +38,11 @@ public class Mc4Adapter : IHasOwnDeviceTree, IHasPoints, IHasAlarms
/// <param name="adapterCode">适配器编码</param>
/// <param name="http">HttpClient 实例</param>
/// <param name="baseUrl">MC4.0 服务地址</param>
public Mc4Adapter(string adapterCode, HttpClient http, string baseUrl)
public Mc4Adapter(string adapterCode, HttpClient http, string baseUrl, string account = "admin", string password = "admin")
{
AdapterCode = adapterCode;
_http = http;
_auth = new Mc4AuthHelper(http, baseUrl);
_auth = new Mc4AuthHelper(http, baseUrl, account, password);
}
/// <summary>初始化适配器:获取 MC4.0 Token</summary>
@@ -202,6 +202,65 @@ public class Mc4Adapter : IHasOwnDeviceTree, IHasPoints, IHasAlarms
{
1 => "未确认", 2 => "已确认", 3 => "已结束", _ => "未确认"
};
// ═══════════════════════════════════════════
// M2: 批量点位查询 — MC4.0 原生 multi/value/get
// ═══════════════════════════════════════════
/// <summary>批量获取多个设备的实时点位值</summary>
public async Task<Dictionary<int, List<Mc4PointValue>>> GetMultiRealtimeValuesAsync(List<int> deviceIds)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var body = JsonSerializer.Serialize(new { ids = deviceIds });
var resp = await client.PostAsync("/api/central/point/multi/value/get",
new StringContent(body, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<Dictionary<int, List<Mc4PointValue>>>(json)!;
}
// ═══════════════════════════════════════════
// M3: 历史告警查询 — MC4.0 his_alarm/query
// ═══════════════════════════════════════════
/// <summary>查询 MC4.0 历史告警(已恢复的告警)</summary>
public async Task<PagedResult<StandardAlarm>> GetHisAlarmsAsync(int page, int size, DateTime from, DateTime to)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var body = JsonSerializer.Serialize(new Mc4HisAlarmQuery
{
From = from.ToString("yyyy-MM-dd HH:mm:ss"),
To = to.ToString("yyyy-MM-dd HH:mm:ss"),
Skip = (page - 1) * size,
Limit = size,
Sort = 1
});
var resp = await client.PostAsync("/api/central/his_alarm/query",
new StringContent(body, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<Mc4AlarmQueryResult>(json)!;
return new PagedResult<StandardAlarm>
{
Items = (result.List ?? new()).Select(MapAlarmItem).ToList(),
Total = result.Total
};
}
private StandardAlarm MapAlarmItem(Mc4AlarmItem a) => new()
{
AlarmId = a.Id ?? "",
AdapterCode = AdapterCode,
DeviceId = a.Sid?.ToString(),
Level = MapAlarmLevel(a.Level),
Title = a.Desc ?? "",
OccurTime = DateTime.TryParse(a.Stime, out var st) ? st : DateTime.MinValue,
Status = MapAlarmState(a.State),
ActualValue = a.Soption?.Value,
ThresholdValue = a.Eoption?.Value
};
}
// ═══════════════════════════════════════════
@@ -279,3 +338,13 @@ public class Mc4Option
public double? Value { get; set; }
public string? TypeName { get; set; }
}
/// <summary>MC4.0 历史告警查询请求</summary>
public class Mc4HisAlarmQuery
{
public string From { get; set; } = "";
public string To { get; set; } = "";
public int Skip { get; set; }
public int Limit { get; set; }
public int Sort { get; set; } = 1;
}

View File

@@ -1,62 +1,92 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace IntegrationGateway.Adapters.MC4;
/// <summary>
/// MC4.0 子系统的 Token 认证辅助类。
/// MC4.0 Token 认证辅助类。
///
/// 认证流程
/// 1. POST /api/central/auth/conf/get 获取临时 Token
/// 2. Token 有效期约 8 小时,缓存在内存中
/// 3. 后续请求在 header["token"] 中携带 Token
///
/// 注意MC4.0 使用自定义 header "token" 而非标准 Authorization 头。
/// 认证流程:
/// 1. POST /api/central/auth/conf/get → { "encrypt": true/false }
/// 2. 若 encrypt=true → 密码用 MD5 加密
/// 3. POST /api/central/auth/login { account, password } → { token, id, account, name }
/// 4. Token 缓存 7hMC4.0 约 8h 有效期)
/// 5. 后续请求 header["token"] = token
/// </summary>
public class Mc4AuthHelper
{
private readonly HttpClient _http;
private readonly string _baseUrl;
/// <summary>缓存的认证 Token</summary>
private readonly string _account;
private readonly string _password;
private string? _token;
/// <summary>Token 过期时间UTC默认 8 小时</summary>
private DateTime _tokenExpiry = DateTime.MinValue;
private bool? _needMd5;
/// <summary>创建 MC4.0 认证辅助</summary>
/// <param name="http">HttpClient 实例</param>
/// <param name="baseUrl">MC4.0 服务地址</param>
public Mc4AuthHelper(HttpClient http, string baseUrl)
public Mc4AuthHelper(HttpClient http, string baseUrl, string account = "admin", string password = "admin")
{
_http = http; _baseUrl = baseUrl.TrimEnd('/');
_http = http;
_baseUrl = baseUrl.TrimEnd('/');
_account = account;
_password = password;
}
/// <summary>获取有效的 Token。缓存有效则直接返回否则重新获取。</summary>
public async Task<string> GetTokenAsync()
{
if (!string.IsNullOrEmpty(_token) && DateTime.UtcNow < _tokenExpiry) return _token;
if (!string.IsNullOrEmpty(_token) && DateTime.UtcNow < _tokenExpiry)
return _token;
var resp = await _http.PostAsync($"{_baseUrl}/api/central/auth/conf/get", null);
// 1. 获取加密配置
if (!_needMd5.HasValue)
{
try
{
var confResp = await _http.PostAsync($"{_baseUrl}/api/central/auth/conf/get", null);
if (confResp.IsSuccessStatusCode)
{
var confJson = await confResp.Content.ReadAsStringAsync();
var conf = JsonSerializer.Deserialize<Mc4ConfResponse>(confJson);
_needMd5 = conf?.Encrypt ?? false;
}
else { _needMd5 = false; }
}
catch { _needMd5 = false; }
}
// 2. 登录获取 Token
var pwd = _needMd5 == true ? ComputeMd5(_password) : _password;
var loginBody = JsonSerializer.Serialize(new { account = _account, password = pwd });
var resp = await _http.PostAsync($"{_baseUrl}/api/central/auth/login",
new StringContent(loginBody, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<Mc4AuthResponse>(json);
_token = result?.Token ?? "";
_tokenExpiry = DateTime.UtcNow.AddHours(8);
return _token!;
var result = JsonSerializer.Deserialize<Mc4LoginResponse>(json)
?? throw new Exception("MC4 登录响应为空");
if (string.IsNullOrEmpty(result.Token))
throw new Exception("MC4 登录失败: Token 为空");
_token = result.Token;
_tokenExpiry = DateTime.UtcNow.AddHours(7);
return _token;
}
/// <summary>
/// 创建一个已认证的 HttpClient自动在 header["token"] 中附带 Token。
/// </summary>
public async Task<HttpClient> GetAuthenticatedClientAsync()
{
var token = await GetTokenAsync();
var client = new HttpClient { BaseAddress = new Uri(_baseUrl) };
client.DefaultRequestHeaders.Add("token", token);
if (!string.IsNullOrEmpty(token))
client.DefaultRequestHeaders.Add("token", token);
return client;
}
/// <summary>强制清除缓存 Token</summary>
public void Invalidate() => _token = null;
/// <summary>MC4.0 认证响应</summary>
public class Mc4AuthResponse { public string? Token { get; set; } }
private static string ComputeMd5(string input)
{
var bytes = MD5.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private class Mc4ConfResponse { public bool Encrypt { get; set; } }
private class Mc4LoginResponse { public string? Token { get; set; } public int Id { get; set; } public string? Account { get; set; } }
}

View File

@@ -10,37 +10,29 @@ namespace IntegrationGateway.Adapters.Owl;
/// Owl 视频监控子系统适配器。
///
/// 实现的能力接口:
/// - IHasFlatDevices设备列表NVR和通道列表
/// - IHasFlatDevicesGET /devices/channels → 设备+通道联合映射
/// - IHasStreams实时取流、录像回放、云台控制、截图
/// - IHasRecordings录像文件查询
/// - IAcceptsMetadataPush设备元数据回写如改名
/// - IHasAlarmsAI 事件映射
/// - IAcceptsControlAI 检测启停、远程控制
///
/// 限流5 QPSOwl 推荐值)
/// PTZ 限制:仅支持 continuous 方向移动 + stop不支持预设位
/// 限流5 QPS
/// </summary>
public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAcceptsMetadataPush
public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAcceptsMetadataPush, IHasAlarms, IAcceptsControl
{
private readonly HttpClient _http;
private readonly OwlAuthHelper _auth;
/// <summary>令牌桶限流器5 QPS</summary>
private readonly RateLimiter _limiter = new(5);
/// <summary>适配器编码,格式 "Owl:实例名"</summary>
public string AdapterCode { get; }
/// <summary>人类可读的适配器名称</summary>
public string DisplayName => $"Owl ({AdapterCode})";
/// <summary>适配器能力声明</summary>
public AdapterCapabilities Capabilities => new()
{
HasFlatDevices = true, HasStreams = true, HasPtz = true, HasRecordings = true, AcceptsMetadataPush = true
HasFlatDevices = true, HasStreams = true, HasPtz = true,
HasRecordings = true, AcceptsMetadataPush = true, HasAlarms = true
};
/// <summary>创建 OwlAdapter 实例</summary>
/// <param name="adapterCode">适配器编码</param>
/// <param name="http">HttpClient 实例</param>
/// <param name="baseUrl">Owl 服务地址</param>
/// <param name="username">登录用户名</param>
/// <param name="password">登录密码</param>
public OwlAdapter(string adapterCode, HttpClient http, string baseUrl, string username, string password)
{
AdapterCode = adapterCode;
@@ -48,10 +40,12 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
_auth = new OwlAuthHelper(http, baseUrl, username, password);
}
/// <summary>初始化适配器:获取 Owl JWT Token</summary>
public async Task InitializeAsync() => await _auth.GetTokenAsync();
/// <summary>健康检查:尝试访问 Owl /health 端点</summary>
// ═══════════════════════════════════════════
// IGatewayAdapter — 健康检查
// ═══════════════════════════════════════════
public async Task<bool> HealthCheckAsync()
{
try
@@ -64,30 +58,76 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
}
// ═══════════════════════════════════════════
// IHasFlatDevices 实现
// IHasFlatDevices — GET /devices/channels 设备+通道联合映射
// ═══════════════════════════════════════════
/// <summary>分页获取 NVR 设备列表</summary>
public async Task<PagedResult<StandardDevice>> GetDevicesAsync(int page, int size, string? keyword = null)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var url = $"/devices?page={page}&size={size}";
var url = $"/devices/channels?page={page}&size=1000";
if (!string.IsNullOrEmpty(keyword)) url += $"&key={Uri.EscapeDataString(keyword)}";
var json = await client.GetStringAsync(url);
var owl = JsonSerializer.Deserialize<OwlPagedResult<OwlDevice>>(json)!;
return new PagedResult<StandardDevice>
var result = JsonSerializer.Deserialize<OwlPagedResult<OwlDeviceChannel>>(json)!;
var devices = new List<StandardDevice>();
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)
{
Items = owl.Items.Select(MapDevice).ToList(),
Total = owl.Total
};
var childChannels = channelItems.Where(c => c.Did == d.Id).ToList();
devices.Add(MapDevice(d, childChannels));
foreach (var ch in childChannels)
devices.Add(MapChannel(ch, d.Id));
}
return new PagedResult<StandardDevice> { Items = devices, Total = devices.Count };
}
private static StandardDevice MapDevice(OwlDeviceChannel d, List<OwlDeviceChannel> channels) => new()
{
SourceId = d.Id ?? "",
Name = d.Name ?? d.Id ?? "",
Category = "硬盘录像机",
Group = "视频设备",
IsOnline = d.IsOnline == "1",
IsParent = true,
IpAddress = d.Address,
Port = int.TryParse(d.Port, out var p) ? p : null,
Extra = new Dictionary<string, object?>
{
["manufacturer"] = d.Manufacturer,
["model"] = d.Model,
["firmware"] = d.Firmware,
["longitude"] = d.Longitude,
["latitude"] = d.Latitude,
["protocol"] = d.Protocol ?? "GB28181",
["transport"] = d.Transport,
["channelCount"] = d.ChannelCount ?? channels.Count
}
};
private static StandardDevice MapChannel(OwlDeviceChannel ch, string? parentDeviceId) => new()
{
SourceId = ch.Id ?? "",
Name = ch.Name ?? $"通道{ch.Id}",
Category = "摄像机",
Group = "视频设备",
IsOnline = ch.IsOnline?.ToLower() == "true" || ch.IsOnline == "1",
IsParent = false,
ParentSourceId = parentDeviceId,
Extra = new Dictionary<string, object?>
{
["hasPtz"] = (ch.Ptztype ?? 0) > 0 ? "1" : "0",
["app"] = ch.App,
["streamId"] = ch.StreamId
}
};
// ═══════════════════════════════════════════
// IHasStreams 实现
// IHasStreams
// ═══════════════════════════════════════════
/// <summary>获取通道实时视频流地址</summary>
public async Task<StreamUrls> GetLiveUrlAsync(string channelId)
{
await _limiter.WaitAsync();
@@ -99,39 +139,43 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
return MapStreamUrls(play);
}
/// <summary>获取历史录像回放地址HLS VOD 格式)</summary>
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 token = await _auth.GetTokenAsync();
var baseUrl = (client.BaseAddress?.ToString() ?? "").TrimEnd('/');
return new StreamUrls
{
Hls = $"{client.BaseAddress}recordings/channels/{channelId}/index.m3u8?start_ms={startMs}&end_ms={endMs}&token={token}"
Hls = $"{baseUrl}/recordings/channels/{channelId}/index.m3u8?start_ms={startMs}&end_ms={endMs}&token={token}"
};
}
/// <summary>云台方向控制continuous 模式,仅方向移动)</summary>
public async Task PtzControlAsync(string channelId, string direction, float speed)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control",
new { action = "continuous", direction, speed });
if (direction.StartsWith("preset_"))
{
var idx = int.Parse(direction.Replace("preset_", ""));
await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control", new { action = "preset", preset = idx });
}
else
{
await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control",
new { action = "continuous", direction, speed });
}
}
/// <summary>云台停止</summary>
public async Task PtzStopAsync(string channelId)
{
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control",
new { action = "stop" });
await client.PostAsJsonAsync($"/channels/{channelId}/ptz/control", new { action = "stop" });
}
/// <summary>获取通道实时截图</summary>
public async Task<StreamUrls> GetSnapshotAsync(string channelId)
{
await _limiter.WaitAsync();
@@ -144,10 +188,9 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
}
// ═══════════════════════════════════════════
// IHasRecordings 实现
// IHasRecordings
// ═══════════════════════════════════════════
/// <summary>分页查询录像文件记录</summary>
public async Task<PagedResult<StandardRecording>> GetRecordingsAsync(
string channelId, DateTime start, DateTime end, int page, int size)
{
@@ -171,10 +214,9 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
}
// ═══════════════════════════════════════════
// IAcceptsMetadataPush 实现
// IAcceptsMetadataPush
// ═══════════════════════════════════════════
/// <summary>回写设备元数据(如改名)到 Owl</summary>
public async Task<MetadataPushResult> PushMetadataAsync(string sourceDeviceId, MetadataChangeSet changes)
{
var client = await _auth.GetAuthenticatedClientAsync();
@@ -185,29 +227,75 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
}
// ═══════════════════════════════════════════
// 内部映射方法
// IHasAlarms — AI 事件
// ═══════════════════════════════════════════
/// <summary>Owl 设备 → StandardDevice 映射</summary>
private static StandardDevice MapDevice(OwlDevice d) => new()
public async Task<PagedResult<StandardAlarm>> GetAlarmsAsync(
int page, int size, DateTime from, DateTime to, string? level = null, string? state = null)
{
SourceId = d.Id ?? "",
Name = d.Name ?? d.Id ?? "",
Category = "硬盘录像机",
Group = "视频设备",
IsOnline = d.IsOnline == "1",
IsParent = true,
IpAddress = d.Address,
Port = int.TryParse(d.Port, out var port) ? port : null,
Extra = new Dictionary<string, object?>
await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync();
var fromMs = new DateTimeOffset(from).ToUnixTimeMilliseconds();
var toMs = new DateTimeOffset(to).ToUnixTimeMilliseconds();
var json = await client.GetStringAsync(
$"/events?page={page}&size={size}&start_ms={fromMs}&end_ms={toMs}");
var result = JsonSerializer.Deserialize<OwlPagedResult<OwlAiEvent>>(json)!;
return new PagedResult<StandardAlarm>
{
["owlDeviceId"] = d.Id,
["protocol"] = d.Protocol ?? "GB28181",
["transport"] = d.Transport
}
Items = result.Items.Select(MapEventToAlarm).ToList(),
Total = result.Total
};
}
public Task ConfirmAlarmAsync(string alarmId) => Task.CompletedTask;
public Task EndAlarmAsync(string alarmId) => Task.CompletedTask;
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.Zones ?? "",
OccurTime = e.StartedAt.HasValue
? DateTimeOffset.FromUnixTimeMilliseconds(e.StartedAt.Value).DateTime
: DateTime.MinValue,
Status = (e.EndedAt ?? 0) > 0 ? "已结束" : "未确认"
};
/// <summary>Owl 播放响应 → StreamUrls 映射(取第一个可用流)</summary>
// ═══════════════════════════════════════════
// IAcceptsControl — AI 启停 + 区域管理
// ═══════════════════════════════════════════
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 }; }
}
// ═══════════════════════════════════════════
// 内部工具
// ═══════════════════════════════════════════
private static StreamUrls MapStreamUrls(OwlPlayResponse play)
{
var item = play.Items?.FirstOrDefault();
@@ -218,61 +306,3 @@ public class OwlAdapter : IHasFlatDevices, IHasStreams, IHasRecordings, IAccepts
};
}
}
// ═══════════════════════════════════════════
// Owl JSON 反序列化模型(内部使用)
// ═══════════════════════════════════════════
/// <summary>Owl API 分页响应</summary>
public class OwlPagedResult<T>
{
public List<T> Items { get; set; } = new();
public int Total { get; set; }
}
/// <summary>Owl 设备NVR</summary>
public class OwlDevice
{
public string? Id { get; set; }
public string? Name { get; set; }
public string? IsOnline { get; set; }
public string? Protocol { get; set; }
public string? Address { get; set; }
public string? Port { get; set; }
public string? Transport { get; set; }
}
/// <summary>Owl 播放响应</summary>
public class OwlPlayResponse
{
public List<OwlPlayItem>? Items { get; set; }
}
/// <summary>Owl 播放流条目</summary>
public class OwlPlayItem
{
public string? WsFlv { get; set; }
public string? HttpFlv { get; set; }
public string? Hls { get; set; }
public string? WebRtc { get; set; }
public string? Rtmp { get; set; }
public string? Rtsp { get; set; }
}
/// <summary>Owl 截图响应</summary>
public class OwlSnapshotResponse
{
public string? Link { get; set; }
}
/// <summary>Owl 录像记录</summary>
public class OwlRecording
{
public int Id { get; set; }
public string? Cid { get; set; }
public DateTime StartedAt { get; set; }
public DateTime EndedAt { get; set; }
public double Duration { get; set; }
public string? Path { get; set; }
public long Size { get; set; }
}

View File

@@ -0,0 +1,107 @@
/// <summary>
/// Owl/GoWVP API 响应模型。
/// 从 OwlAdapter.cs 分离,便于维护和扩展。
/// </summary>
namespace IntegrationGateway.Adapters.Owl;
// ═══════════════════════════════════════════
// 通用
// ═══════════════════════════════════════════
/// <summary>Owl API 分页响应</summary>
public class OwlPagedResult<T>
{
public List<T> Items { get; set; } = new();
public int Total { get; set; }
}
// ═══════════════════════════════════════════
// 设备+通道联合模型 (GET /devices/channels)
// ═══════════════════════════════════════════
/// <summary>Owl 设备或通道(联合接口返回)</summary>
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; } // 0=无云台, 1=方向, 2=预置位
public string? Protocol { get; set; }
public string? Address { get; set; }
public string? Port { get; set; }
public string? Transport { get; set; }
public string? App { get; set; } // 流应用名
public string? StreamId { get; set; } // 流ID
public string? Status { get; set; }
public string? RegisterWay { get; set; }
}
// ═══════════════════════════════════════════
// 播放/流
// ═══════════════════════════════════════════
/// <summary>Owl 播放响应</summary>
public class OwlPlayResponse
{
public List<OwlPlayItem>? Items { get; set; }
}
/// <summary>Owl 播放流条目</summary>
public class OwlPlayItem
{
public string? WsFlv { get; set; }
public string? HttpFlv { get; set; }
public string? Hls { get; set; }
public string? WebRtc { get; set; }
public string? Rtmp { get; set; }
public string? Rtsp { get; set; }
}
/// <summary>Owl 截图响应</summary>
public class OwlSnapshotResponse
{
public string? Link { get; set; }
}
// ═══════════════════════════════════════════
// 录像
// ═══════════════════════════════════════════
/// <summary>Owl 录像记录</summary>
public class OwlRecording
{
public int Id { get; set; }
public string? Cid { get; set; }
public DateTime StartedAt { get; set; }
public DateTime EndedAt { get; set; }
public double Duration { get; set; }
public string? Path { get; set; }
public long Size { get; set; }
}
// ═══════════════════════════════════════════
// AI 事件
// ═══════════════════════════════════════════
/// <summary>Owl AI 检测事件</summary>
public class OwlAiEvent
{
public long? Id { get; set; }
public string? Did { get; set; }
public string? Cid { get; set; }
public long? StartedAt { get; set; } // 毫秒时间戳
public long? EndedAt { get; set; }
public string? Label { get; set; } // person / car / ...
public float? Score { get; set; }
public string? Zones { get; set; }
public string? ImagePath { get; set; }
public string? Model { get; set; }
}

View File

@@ -78,7 +78,7 @@ foreach (var m in mc4List)
var code = $"MC4:{m.InstanceName ?? "default"}";
var a = new IntegrationGateway.Adapters.MC4.Mc4Adapter(code,
app.Services.GetRequiredService<IHttpClientFactory>().CreateClient("VolPro"),
m.BaseUrl);
m.BaseUrl, m.Username, m.Password);
registry.Register(a);
}
@@ -92,22 +92,99 @@ Console.WriteLine($"[Gateway] {registry.All.Count} 个适配器已注册: {adapt
// ── A1: 向 Vol.Pro 注册当前网关节点 ──
var nodeCode = gwCfg["NodeCode"] ?? "gw-default";
var nodeToken = Environment.GetEnvironmentVariable("SECMPS_GATEWAY_TOKEN") ?? gwCfg["NodeToken"] ?? "";
var port = app.Urls.FirstOrDefault()?.Split(':').LastOrDefault() ?? "5100";
var selfUrl = gwCfg["SelfUrl"] ?? $"http://localhost:{port}";
try
{
var registerReq = new GatewayRegisterRequest
{
NodeCode = nodeCode, Token = nodeToken,
AdapterTypes = adapterTypes,
BaseUrl = $"http://localhost:{app.Urls.FirstOrDefault()?.Split(':').LastOrDefault() ?? "5100"}"
BaseUrl = selfUrl
};
var registerResult = await clientFactory.RegisterAsync(registerReq);
Console.WriteLine($"[Gateway] A1 注册完成: nodeCode={nodeCode}, adapters={adapterTypes}");
}
catch (Exception ex) { Console.Error.WriteLine($"[Gateway] A1 注册失败: {ex.Message}"); }
// ── A3: 同步所有适配器设备到 Vol.Pro ──
await SyncAllDevicesAsync(nodeCode, nodeToken, selfUrl);
Console.WriteLine("[Gateway] A3 设备同步完成");
// ── A2: 心跳 + 自动重注册 ──
var heartbeatInterval = int.TryParse(gwCfg["HeartbeatIntervalSec"], out var hs) ? hs : 15;
var failCount = 0; var maxFails = 3;
_ = Task.Run(async () =>
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(heartbeatInterval));
while (await timer.WaitForNextTickAsync())
{
try
{
await clientFactory.HeartbeatAsync(new GatewayHeartbeatRequest { NodeCode = nodeCode, Token = nodeToken });
failCount = 0;
}
catch
{
failCount++;
Console.Error.WriteLine($"[Gateway] A2 心跳失败 ({failCount}/{maxFails})");
if (failCount >= maxFails)
{
Console.WriteLine("[Gateway] 心跳连续失败, 尝试重新注册...");
try
{
await clientFactory.RegisterAsync(new GatewayRegisterRequest { NodeCode = nodeCode, Token = nodeToken, AdapterTypes = adapterTypes, BaseUrl = selfUrl });
await SyncAllDevicesAsync(nodeCode, nodeToken, selfUrl);
failCount = 0;
Console.WriteLine("[Gateway] 重新注册成功");
}
catch (Exception re) { Console.Error.WriteLine($"[Gateway] 重新注册失败: {re.Message}"); }
}
}
}
});
Console.WriteLine($"[Gateway] A2 心跳已启动 ({heartbeatInterval}s)");
// ═══════════════════════════════════════════════════════════════
// B路由(管理端 / Vol.Pro → 网关)
// 所有路由通过适配器编码查找对应适配器,按能力接口分发请求
// A辅助函数
// ═══════════════════════════════════════════════════════════════
async Task SyncAllDevicesAsync(string nc, string nt, string baseUrl)
{
var allDevices = new List<object>();
foreach (var adapter in registry.All)
{
try
{
if (adapter is IHasFlatDevices flat)
{
var result = await flat.GetDevicesAsync(1, 1000);
foreach (var item in result.Items)
allDevices.Add(new { AdapterCode = item.AdapterCode, SourceId = item.SourceId, Name = item.Name, Category = item.Category, Group = item.Group, IsParent = item.IsParent, ParentSourceId = item.ParentSourceId, IsOnline = item.IsOnline, IpAddress = item.IpAddress, Port = item.Port, ExtraDataJson = item.Extra != null ? System.Text.Json.JsonSerializer.Serialize(item.Extra) : null });
}
else if (adapter is IHasOwnDeviceTree tree)
{
var nodes = await tree.GetObjectTreeAsync();
FlattenTree(allDevices, nodes, adapter.AdapterCode, null);
}
}
catch { }
}
if (allDevices.Any())
await clientFactory.SyncDevicesAsync(nc, nt, allDevices);
}
void FlattenTree(List<object> devices, List<DeviceTreeNode> nodes, string ac, string? parentSourceId)
{
foreach (var n in nodes)
{
devices.Add(new { AdapterCode = ac, SourceId = n.SourceId, Name = n.Name ?? n.SourceId, Category = n.Tag ?? "IoT设备", Group = "IoT设备", IsParent = n.Type == 1, ParentSourceId = parentSourceId, IsOnline = true, IpAddress = (string?)null, Port = (int?)null, ExtraDataJson = n.Option != null ? System.Text.Json.JsonSerializer.Serialize(n.Option) : null });
if (n.Children?.Count > 0) FlattenTree(devices, n.Children, ac, n.SourceId);
}
}
// ═══════════════════════════════════════════════════════════════
// B 组路由(管理端/ Vol.Pro → 网关)
// ═══════════════════════════════════════════════════════════════
// B1: 健康检查 — 返回所有适配器的健康状态和能力声明
@@ -184,11 +261,19 @@ app.MapGet("/api/gateway/realtime/{adapter}/{deviceId}", async (string adapter,
return Results.Ok(await a.GetRealtimeValuesAsync(deviceId));
});
// B4-batch: 批量实时点位值 — 一次请求返回多个设备的值
// B4-batch: 批量实时点位值 — MC4 原生批量接口,其他适配器 fallback
app.MapPost("/api/gateway/realtime/{adapter}/batch", async (string adapter, BatchRealtimeRequest req) =>
{
var a = registry.FindByCode<IHasPoints>(adapter);
if (a == null) return Results.NotFound(new { error = "CAPABILITY_NOT_SUPPORTED" });
if (a is IntegrationGateway.Adapters.MC4.Mc4Adapter mc4 && req.DeviceIds?.Count > 0)
{
var intIds = req.DeviceIds.Select(int.Parse).ToList();
var multi = await mc4.GetMultiRealtimeValuesAsync(intIds);
return Results.Ok(multi);
}
var results = new Dictionary<string, List<PointValue>>();
foreach (var deviceId in req.DeviceIds ?? new())
try { results[deviceId] = await a.GetRealtimeValuesAsync(deviceId); } catch { }
@@ -291,52 +376,22 @@ app.MapPost("/api/gateway/devices/sync", async (string adapter) =>
});
app.Run();
// ═══════════════════════════════════════════════
// 配置 POCO
// ═══════════════════════════════════════════════
/// <summary>Owl 适配器配置项</summary>
public class OwlConfig
{
public string? InstanceName { get; set; }
public string BaseUrl { get; set; } = "";
public string Username { get; set; } = "admin";
public string Password { get; set; } = "admin";
}
/// <summary>MC4.0 适配器配置项</summary>
public class Mc4Config
{
public string? InstanceName { get; set; }
public string BaseUrl { get; set; } = "";
}
/// <summary>KMS 适配器配置项</summary>
public class KmsConfig
{
public string? InstanceName { get; set; }
public string BaseUrl { get; set; } = "";
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
}
// ═══════════════════════════════════════════════
// B 组请求 DTO
// ═══════════════════════════════════════════════
/// <summary>云台控制请求</summary>
/// <param name="Direction">方向up/down/left/right/zoom_in/zoom_out/stop</param>
/// <param name="Action">动作类型continuous 或 stop</param>
/// <param name="Speed">速度 0.0-1.0</param>
record PtzRequest(string? Direction, string Action, float Speed);
/// <summary>设备控制请求</summary>
/// <param name="DeviceId">目标设备 SourceId</param>
/// <param name="PointIndex">点位索引</param>
/// <param name="Value">目标值</param>
record ControlRequest(string? DeviceId, int PointIndex, double Value);
record BatchRealtimeRequest(List<string>? DeviceIds);
record GatewayControlRequest(string? DeviceId, string? Command, Dictionary<string, object?>? Parameters);
record SyncRequest(string? DataType, List<object>? Items);
record SyncDeleteRequest(string? DataType, List<string>? Ids);
// ═══════════════════════════════════════════
// 配置 POCO
// ═══════════════════════════════════════════
/// <summary>Owl 适配器配置项</summary>
public class OwlConfig { public string? InstanceName { get; set; } public string BaseUrl { get; set; } = ""; public string Username { get; set; } = "admin"; public string Password { get; set; } = "admin"; }
/// <summary>MC4.0 适配器配置项</summary>
public class Mc4Config { public string? InstanceName { get; set; } public string BaseUrl { get; set; } = ""; public string Username { get; set; } = "admin"; public string Password { get; set; } = "admin"; }
/// <summary>KMS 适配器配置项</summary>
public class KmsConfig { public string? InstanceName { get; set; } public string BaseUrl { get; set; } = ""; public string ClientId { get; set; } = ""; public string ClientSecret { get; set; } = ""; }

View File

@@ -38,6 +38,8 @@ if (!axios.defaults.baseURL.endsWith('/')) {
axios.defaults.baseURL += '/'
}
const lang_storage_key = 'lang'
let ipAddress = axios.defaults.baseURL;
if (!ipAddress || ipAddress == '/') {
ipAddress = window.location.origin + '/';

View File

@@ -1,6 +1,6 @@
<script setup>
import { watch, computed } from "vue"
import { useMapStore } from "../store/useMapStore"
import { useMapStore } from "../stores/mapStore"
// 可以使用pinia等管理全局数据这里只是方便演示, 直接注入了上层提供的数据
const store = useMapStore()

View File

@@ -1,7 +1,7 @@
<script setup>
import { computed } from "vue"
import { useNavi } from "../hooks/useNavi"
import { useMapStore } from "../store/useMapStore"
import { useMapStore } from "../stores/mapStore"
// 可以使用pinia等管理全局数据这里只是方便演示, 直接注入了上层提供的数据
const store = useMapStore()

View File

@@ -1,7 +1,7 @@
<script setup>
import { inject, onBeforeUnmount } from "vue"
import { useFence } from "../hooks/useFence"
import { useMapStore } from "../store/useMapStore"
import { useMapStore } from "../stores/mapStore"
const { THREE } = VgoMap

View File

@@ -1,7 +1,7 @@
<script setup>
import { inject, ref, watch, onBeforeUnmount } from "vue"
import { useClassPolygon } from "../hooks/useClassPolygon"
import { useMapStore } from "../store/useMapStore"
import { useMapStore } from "../stores/mapStore"
import http from "../api/http"
const { THREE } = VgoMap

View File

@@ -1,7 +1,7 @@
<script setup>
import { ref, watch, onBeforeUnmount } from "vue"
import { useSkyLight } from "../hooks/useSkyLight"
import { useMapStore } from "../store/useMapStore"
import { useMapStore } from "../stores/mapStore"
import CustomSwitch from "./CustomSwitch.vue"
const store = useMapStore()

View File

@@ -1,7 +1,7 @@
<script setup>
import { ref, watch } from "vue"
import { useWeatherEffect, WeatherType } from "../hooks/useWeather"
import { useMapStore } from "../store/useMapStore"
import { useMapStore } from "../stores/mapStore"
import CustomSwitch from "./CustomSwitch.vue"
const store = useMapStore()

View File

@@ -1,29 +0,0 @@
import { ref, computed } from "vue"
import { defineStore } from "pinia"
export const useMapStore = defineStore('map', () => {
const map = ref(null)
const polygonDataAll = computed(() => {
const outDoor = map.value?.mapData?.polygonData ?? []
const inDoor = map?.mapData?.build?.reduce((result, build) => {
build.floor.forEach(fItem => {
result.push(...fItem.polygonData)
})
return result
}, []) ?? []
return [...outDoor, ...inDoor]
})
function setMap(mapInstance) {
map.value = mapInstance
}
return {
map,
polygonDataAll,
setMap,
}
})

View File

@@ -1,48 +1,34 @@
import { ref } from "vue"
import { ref, computed } from "vue"
import { defineStore } from "pinia"
export const useMapStore = defineStore('map', () => {
const map = ref(null)
const lastClickedPosition = ref(null) // 存储地图最后点击位置的经纬度
const editingMode = ref(false) // 编辑状态标识
const lastClickedPosition = ref(null)
const editingMode = ref(false)
function setMap(mapInstance) {
map.value = mapInstance
}
/** 聚合所有楼层+室外的 polygonData */
const polygonDataAll = computed(() => {
const outDoor = map.value?.mapData?.polygonData ?? []
const builds = map.value?.mapData?.build ?? []
const inDoor = builds.reduce((result, build) => {
build.floor?.forEach(f => { result.push(...(f.polygonData ?? [])) })
return result
}, [])
return [...outDoor, ...inDoor]
})
// 设置地图最后点击位置的经纬度
function setLastClickedPosition(lng, lat) {
lastClickedPosition.value = { lng, lat }
}
// 设置编辑状态
function setMap(mapInstance) { map.value = mapInstance }
function setLastClickedPosition(lng, lat) { lastClickedPosition.value = { lng, lat } }
function setEditingMode(mode) {
editingMode.value = mode
if(mode){
map.value.setViewMode('2D')
}
else{
map.value.setViewMode('3D')
}
}
// 获取编辑状态
function getEditingMode() {
if(editingMode.value){
return editingMode.value
}
else{
return false
}
map.value?.setViewMode(mode ? '2D' : '3D')
}
function getEditingMode() { return editingMode.value }
return {
map,
setMap,
lastClickedPosition,
setLastClickedPosition,
editingMode,
setEditingMode,
getEditingMode
map, setMap,
lastClickedPosition, setLastClickedPosition,
editingMode, setEditingMode, getEditingMode,
polygonDataAll,
}
})

View File

@@ -14,6 +14,12 @@ import http from '../api/http.js'
// TODO Phase2: 遍历 base_device 加载 MapModelId → VgoMap 标记; 告警设备红色闪烁
import initMessageHub from './index.js' // 导入消息中心初始化函数
// 规则引擎 SignalR 订阅: RuleTriggered → 告警弹窗
const initRuleEngineListener = (connection) => {
connection.on("RuleTriggered", (data) => {
handlePushMessage({ title: data.title || '规则触发', message: data.alertMessage || '', code: '3', notificationType: 3, level: data.alertMessage, date: new Date().toISOString() });
});
};
import Message from './Message.vue'
// 生成唯一ID的辅助函数

View File

@@ -6,7 +6,7 @@ import Sky from "../components/Sky.vue"
import DisplayColor from "../components/DisplayColor.vue"
import DisplayRouteLine from "../components/DisplayRouteLine.vue"
import Weather from "../components/Weather.vue"
import { useMapStore } from "../store/useMapStore"
import { useMapStore } from "../stores/mapStore"
const { VgoMap } = window
let mapId = /#\/(\d+)/.exec(location.hash)[1]

View File

@@ -2,89 +2,60 @@ import * as signalR from "@microsoft/signalr";
import { ElNotification } from "element-plus";
import { ElMessageBox } from "element-plus";
import store from "@/store/index";
export default function (http, receive) {
let connection;
let connection
let messageQueue = [];
let isProcessingQueue = false;
let displayedMessageIds = new Set(); // 用于存储已显示的消息ID避免重复显示
// LRU: 最近 500 条 ID防内存无限增长
const MAX_IDS = 500
const displayedIds = new Set()
const idList = []
// 消息队列处理函数 - 移到闭包内部,确保可以访问所有必要的变量
function processMessageQueue() {
if (messageQueue.length === 0) {
isProcessingQueue = false;
return;
function addId(id) {
if (displayedIds.has(id)) return
displayedIds.add(id)
idList.push(id)
if (idList.length > MAX_IDS) {
displayedIds.delete(idList.shift())
}
}
function hasId(id) { return displayedIds.has(id) }
isProcessingQueue = true;
const currentMessage = messageQueue.shift();
// 直接调用receive回调处理消息显示
if (receive) {
try {
receive(currentMessage);
console.log('成功调用receive回调处理消息:', currentMessage);
} catch (error) {
console.error('调用receive回调时出错:', error);
}
// 重试启动: 最多 5 次, 间隔 2s
async function startWithRetry(conn, retries = 5) {
for (let i = 1; i <= retries; i++) {
try { await conn.start(); return }
catch (e) { if (i < retries) await new Promise(r => setTimeout(r, 2000)) }
}
// 可以设置一定的延迟,避免消息显示过于密集
setTimeout(() => {
processMessageQueue();
}, 3000); // 3秒后处理下一条消息
console.error('SignalR 连接失败 (5次重试后放弃)')
}
http.post("api/user/GetCurrentUserInfo").then((result) => {
console.log(result,'result');
http.post("api/user/GetCurrentUserInfo").then(async (result) => {
if (!result?.data?.userName) {
console.error('获取用户信息失败: 缺少 userName')
return
}
connection = new signalR.HubConnectionBuilder()
.withAutomaticReconnect()
.withUrl(
`/hub/message?userName=${result.data.userName}`,
{
//withCredentials: true // 启用凭证模式
// accessTokenFactory: () => store.getters.getToken()
}
)
//.withUrl(`${http.ipAddress}message`)
.build();
.withUrl('/hub/message?userName=' + result.data.userName)
.build()
startWithRetry(connection)
connection.onreconnected(function (id) {
if (import.meta.env.DEV) console.log('SignalR reconnected:', id)
})
connection.start().catch((err) => console.log(err.message));
//自动重连成功后的处理
connection.onreconnected((connectionId) => {
console.log(connectionId, 'connectionId');
});
connection.on("ReceiveHomePageMessage", function (data) {
console.log('接收到新消息:', data);
// 检查消息是否已显示过
if (data.id && displayedMessageIds.has(data.id)) {
console.log('消息已显示过,跳过:', data.id);
return;
}
if (data?.id && hasId(data.id)) return
if (data?.id) addId(data.id)
// 将消息ID添加到已显示集合
if (data.id) {
displayedMessageIds.add(data.id);
console.log('添加到已显示集合:', data.id);
}
// 添加到store
// store.getters.data().pushMessage(data);
console.log(data, 'data_after_push');
// 直接调用receive回调
if (receive) {
try {
receive(data);
console.log('成功调用receive回调:', data);
} catch (error) {
console.error('调用receive回调时出错:', error);
}
} else {
console.warn('receive回调未定义无法处理消息');
try { receive(data) }
catch (e) { console.error('receive callback error:', e) }
}
});
}).catch(error => {
console.error('获取用户信息时出错:', error);
});
})
}).catch(function (e) {
console.error('getUserInfo failed:', e)
})
}

View File

@@ -61,7 +61,8 @@
import { ref, computed, onMounted } from 'vue'
import { VideoPlay } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { fetchCameras, gwGet, Camera } from '@/api/gateway'
import { fetchCameras, gwGet } from '@/api/gateway'
import type { Camera } from '@/api/gateway'
const cameras = ref<Camera[]>([])
const selectedCamera = ref<Camera | null>(null)

View File

@@ -92,7 +92,8 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { VideoCamera } from '@element-plus/icons-vue'
import { fetchCameras, gwGet, gwPost, Camera } from '@/api/gateway'
import { fetchCameras, gwGet, gwPost } from '@/api/gateway'
import type { Camera } from '@/api/gateway'
const cameras = ref<Camera[]>([])
const selectedCamera = ref<Camera | null>(null)

View File

@@ -106,7 +106,8 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { fetchCameras, gwGet, gwPost, Camera } from '@/api/gateway'
import { fetchCameras, gwGet, gwPost } from '@/api/gateway'
import type { Camera } from '@/api/gateway'
const cameras = ref<Camera[]>([])
const streamUrls = ref<Record<string, string>>({})

View File

@@ -1,77 +1,78 @@
// *Authorjxx
// *Contact283591387@qq.com
// *代码由框架生成,任何更改都可能导致被代码生成器覆盖
export default function(){
const table = {
key: 'RuleID',
footer: "Foots",
cnName: '规则',
name: 'warehouse_rule',
newTabEdit: false,
url: "/warehouse_rule/",
sortName: "Title",
fixedSearch:false
};
const tableName = table.name;
const tableCNName = table.cnName;
const newTabEdit = false;
const key = table.key;
const editFormFields = {"Title":"","JudgmentMode":"","JudgmentValue":""};
const editFormOptions = [[{"title":"规则标题","required":true,"field":"Title","colSize":100.0}],
[{"dataKey":"条件判断方式","data":[],"title":"条件判断方式","field":"JudgmentMode","colSize":50.0,"type":"select"},
{"dataKey":"条件判断目标值","data":[],"title":"条件判断目标值","field":"JudgmentValue","colSize":50.0,"type":"select"}]];
const searchFormFields = {};
const searchFormOptions = [];
const columns = [{field:'Title',title:'规则标题',type:'string',link:true,width:150,require:true,align:'left'},
{field:'JudgmentMode',title:'条件判断方式',type:'string',bind:{ key:'条件判断方式',data:[]},width:150,align:'left'},
{field:'JudgmentValue',title:'条件判断目标值',type:'string',bind:{ key:'条件判断目标值',data:[]},width:110,align:'left'},
{field:'RuleID',title:'规则编号',type:'int',width:120,hidden:true,require:true,align:'left'}];
const detail ={columns:[]};
const details = [ {
cnName: '规则条件',
table: 'warehouse_rulecondition',
columns: [{field:'id',title:'条件编号',type:'int',width:110,hidden:true,require:true,align:'left'},
{field:'DeviceId',title:'设备',type:'int',bind:{ key:'所有设备列表',data:[]},width:110,edit:{type:'select'},align:'left'},
{field:'ValueId',title:'变量',type:'int',bind:{ key:'变量列表',data:[]},width:110,edit:{type:'select'},align:'left'},
{field:'Type',title:'比对类型',type:'string',bind:{ key:'比对类型',data:[]},width:150,edit:{type:'select'},align:'left'},
{field:'CompareOperator',title:'比较运算',type:'string',bind:{ key:'比较运算',data:[]},width:150,edit:{type:'select'},align:'left'},
{field:'TargetValue_Switch',title:'目标值开关状态',type:'string',bind:{ key:'开关状态',data:[]},width:120,edit:{type:'select'},align:'left'},
{field:'TargetValue_Number',title:'目标值数值',type:'int',width:120,edit:{type:'number'},align:'left'},
{field:'RuleID',title:'所属规则编号',type:'int',width:120,hidden:true,align:'left'}],
sortName: 'id',
key: 'id',
buttons:[],
delKeys:[],
detail:null
},
{
cnName: '规则动作',
table: 'warehouse_ruleaction',
columns: [{field:'id',title:'动作编号',type:'int',width:110,hidden:true,require:true,align:'left'},
{field:'DeviceId',title:'设备',type:'int',bind:{ key:'所有设备列表',data:[]},width:110,edit:{type:'select'},align:'left'},
{field:'ValueId',title:'变量',type:'int',bind:{ key:'变量列表',data:[]},width:110,edit:{type:'select'},align:'left'},
{field:'Type',title:'值类型',type:'string',bind:{ key:'比对类型',data:[]},width:150,edit:{type:'select'},align:'left'},
{field:'TargetValue_Switch',title:'目标值开状态状态',type:'string',bind:{ key:'开关状态',data:[]},width:120,edit:{type:'select'},align:'left'},
{field:'TargetValue_Number',title:'目标值数值',type:'int',width:120,edit:{type:'number'},align:'left'},
{field:'RuleID',title:'所属规则编号',type:'int',width:120,hidden:true,align:'left'}],
sortName: 'id',
key: 'id',
buttons:[],
delKeys:[],
detail:null
}];
return {
table,
key,
tableName,
tableCNName,
newTabEdit,
editFormFields,
editFormOptions,
searchFormFields,
searchFormOptions,
columns,
detail,
details
// *代码由框架生成,任何更改都可能导致被代码生成器覆盖
export default function(){
const table = {
key: 'RuleID',
footer: "Foots",
cnName: '规则',
name: 'warehouse_rule',
newTabEdit: false,
url: "/warehouse_rule/",
sortName: "Title",
fixedSearch:false
};
const tableName = table.name;
const tableCNName = table.cnName;
const newTabEdit = false;
const key = table.key;
const editFormFields = {"Title":"","JudgmentMode":"","JudgmentValue":"","Enable":"启用","Priority":0,"CooldownSec":60};
const editFormOptions = [
[{"title":"规则标题","required":true,"field":"Title","colSize":60.0},
{"dataKey":"条件判断方式","data":[],"title":"判断方式","field":"JudgmentMode","colSize":40.0,"type":"select"}],
[{"title":"优先级","field":"Priority","colSize":50.0,"type":"number"},
{"title":"冷却时间(秒)","field":"CooldownSec","colSize":50.0,"type":"number"}],
[{"dataKey":"启用状态","data":[],"title":"启用","field":"Enable","colSize":50.0,"type":"select"},
{"dataKey":"条件判断目标值","data":[],"title":"目标值","field":"JudgmentValue","colSize":50.0,"type":"select"}]
];
const searchFormFields = {};
const searchFormOptions = [];
const columns = [
{field:'Title',title:'规则标题',type:'string',link:true,width:150,require:true,align:'left'},
{field:'JudgmentMode',title:'判断方式',type:'string',bind:{ key:'条件判断方式',data:[]},width:100,align:'left'},
{field:'Priority',title:'优先级',type:'int',width:80,align:'left'},
{field:'CooldownSec',title:'冷却(秒)',type:'int',width:80,align:'left'},
{field:'Enable',title:'启用',type:'string',bind:{ key:'启用状态',data:[]},width:80,align:'left'},
{field:'LastTriggered',title:'上次触发',type:'datetime',width:150,align:'left'},
{field:'RuleID',title:'规则编号',type:'int',width:120,hidden:true,require:true,align:'left'}
];
const detail ={columns:[]};
const details = [
{
cnName: '规则条件',
table: 'warehouse_rulecondition',
columns: [
{field:'id',title:'条件编号',type:'int',width:110,hidden:true,require:true,align:'left'},
{field:'DeviceId',title:'设备',type:'int',bind:{ key:'所有设备列表',data:[]},width:110,edit:{type:'select'},align:'left'},
{field:'ValueId',title:'变量',type:'int',width:110,edit:{type:'number'},align:'left'},
{field:'Type',title:'比对类型',type:'string',bind:{ key:'比对类型',data:[]},width:150,edit:{type:'select'},align:'left'},
{field:'CompareOperator',title:'比较运算',type:'string',bind:{ key:'比较运算',data:[]},width:150,edit:{type:'select'},align:'left'},
{field:'TargetValue_Number',title:'目标值',type:'int',width:120,edit:{type:'number'},align:'left'},
{field:'TargetValue_Switch',title:'开关状态',type:'string',bind:{ key:'开关状态',data:[]},width:120,edit:{type:'select'},align:'left'},
{field:'RecoveryThreshold_Numeric',title:'恢复阈值',type:'decimal',width:120,edit:{type:'number'},align:'left'},
{field:'RuleID',title:'所属规则编号',type:'int',width:120,hidden:true,align:'left'}
],
sortName: 'id', key: 'id', buttons:[], delKeys:[], detail:null
},
{
cnName: '规则动作',
table: 'warehouse_ruleaction',
columns: [
{field:'id',title:'动作编号',type:'int',width:110,hidden:true,require:true,align:'left'},
{field:'DeviceId',title:'设备',type:'int',bind:{ key:'所有设备列表',data:[]},width:110,edit:{type:'select'},align:'left'},
{field:'ValueId',title:'变量',type:'int',width:110,edit:{type:'number'},align:'left'},
{field:'ActionType',title:'动作类型',type:'string',bind:{ key:'动作类型',data:[]},width:150,edit:{type:'select'},align:'left'},
{field:'TargetValue_Number',title:'目标值',type:'int',width:120,edit:{type:'number'},align:'left'},
{field:'TargetValue_Switch',title:'开关状态',type:'string',bind:{ key:'开关状态',data:[]},width:120,edit:{type:'select'},align:'left'},
{field:'Alert',title:'生成告警',type:'string',bind:{ key:'开关状态',data:[]},width:100,edit:{type:'select'},align:'left'},
{field:'AlertMessage',title:'告警内容',type:'string',width:200,edit:{type:'text'},align:'left'},
{field:'RuleID',title:'所属规则编号',type:'int',width:120,hidden:true,align:'left'}
],
sortName: 'id', key: 'id', buttons:[], delKeys:[], detail:null
}
];
return {
table, key, tableName, tableCNName, newTabEdit,
editFormFields, editFormOptions, searchFormFields, searchFormOptions,
columns, detail, details
};
}