Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
184f101a86 | ||
|
|
4157d90e9f | ||
|
|
028519a710 | ||
|
|
44b60b5b46 | ||
|
|
ae19e59703 | ||
|
|
2e709aa4ff | ||
|
|
870cb4acec | ||
|
|
9c874734af | ||
|
|
966633d1cc | ||
|
|
dcbebdf01b | ||
|
|
f1c765f8cc | ||
|
|
14fda5395b | ||
|
|
e60d10b103 | ||
|
|
60036587a6 |
@@ -0,0 +1,3 @@
|
||||
# Safe local defaults. Do not add credentials, customer data, or production paths.
|
||||
BRAIN_DEVICE=auto
|
||||
BRAIN_LOG_LEVEL=INFO
|
||||
@@ -0,0 +1 @@
|
||||
3.11.15
|
||||
@@ -0,0 +1,90 @@
|
||||
# YoVision Brain
|
||||
|
||||
Brain 是无界面的独立推理交付单元。本骨架只提供可安装 Python 包、命令入口和运行时探测;尚不包含视频、模型、规则、事件或部署能力,也不依赖 Sense、Bell 在线。
|
||||
|
||||
## 冻结基线
|
||||
|
||||
| 项目 | 版本 / 选择 |
|
||||
|---|---|
|
||||
| Python | CPython `3.11.15`(`.python-version`;本机由 uv 隔离管理) |
|
||||
| 环境与包管理 | Python `venv` + pip `26.2.1`;可用 uv `0.11.6` 取得固定 Python |
|
||||
| 构建后端 | setuptools `80.9.0` |
|
||||
| 测试 | pytest `8.4.2` |
|
||||
| 数组运行时 | NumPy `2.3.3` |
|
||||
| 推理运行时 | PyTorch `2.12.1` |
|
||||
| CPU wheel | PyTorch 官方 `https://download.pytorch.org/whl/cpu` |
|
||||
| NVIDIA wheel | PyTorch 官方 CUDA 12.6 `https://download.pytorch.org/whl/cu126` |
|
||||
|
||||
选择 Python 3.11 是因为 PyTorch 的 Windows 支持范围包含 Python 3.9–3.12,并且本机已有隔离的 CPython 3.11.15。选择 `torch 2.12.1 + cu126` 是因为 PyTorch 官方为 Linux/Windows 同时发布该固定组合;NVIDIA 的 CUDA 12.x 兼容表要求 Windows 驱动至少为 528.33,本机驱动 566.24 满足运行 CUDA 12.6 wheel 的驱动前提。
|
||||
|
||||
本机安装的 CUDA Toolkit 11.2 不参与 PyTorch wheel 构建,也不因本项目而修改。驱动满足最低版本只是兼容前提,不等于 GPU 已验证;必须以 `--smoke cuda` 的真实结果为准。
|
||||
|
||||
官方依据:
|
||||
|
||||
- [PyTorch - Start Locally](https://docs.pytorch.org/get-started/locally/)
|
||||
- [PyTorch - Previous Versions](https://pytorch.org/get-started/previous-versions/)
|
||||
- [NVIDIA CUDA 12.6 Release Notes](https://docs.nvidia.com/cuda/archive/12.6.0/cuda-toolkit-release-notes/index.html)
|
||||
- [Python 3.11.15](https://www.python.org/downloads/release/python-31115/)
|
||||
|
||||
## 创建隔离环境
|
||||
|
||||
从仓库根目录执行。无需激活虚拟环境,也不需要更改 PowerShell 执行策略:
|
||||
|
||||
```powershell
|
||||
uv python install 3.11.15
|
||||
uv venv --python 3.11.15 Brain/.venv
|
||||
Brain\.venv\Scripts\python.exe -m pip install pip==26.2.1 --index-url https://pypi.org/simple
|
||||
Brain\.venv\Scripts\python.exe -m pip install -e "Brain[dev]" --index-url https://pypi.org/simple
|
||||
```
|
||||
|
||||
若不使用 uv,也可以让已安装的 Python 3.11.15 创建环境:
|
||||
|
||||
```powershell
|
||||
py -V:3.11 -m venv Brain/.venv
|
||||
Brain\.venv\Scripts\python.exe -m pip install pip==26.2.1 --index-url https://pypi.org/simple
|
||||
Brain\.venv\Scripts\python.exe -m pip install -e "Brain[dev]" --index-url https://pypi.org/simple
|
||||
```
|
||||
|
||||
## 安装运行时
|
||||
|
||||
CPU 环境使用 PyTorch 官方 CPU 索引:
|
||||
|
||||
```powershell
|
||||
Brain\.venv\Scripts\python.exe -m pip install numpy==2.3.3 --index-url https://pypi.org/simple
|
||||
Brain\.venv\Scripts\python.exe -m pip install torch==2.12.1 --index-url https://download.pytorch.org/whl/cpu
|
||||
```
|
||||
|
||||
目标 NVIDIA 环境使用官方 CUDA 12.6 wheel。该 wheel 自带所需 CUDA 用户态运行库,不要求把系统 Toolkit 改成 12.6:
|
||||
|
||||
```powershell
|
||||
Brain\.venv\Scripts\python.exe -m pip install numpy==2.3.3 --index-url https://pypi.org/simple
|
||||
Brain\.venv\Scripts\python.exe -m pip install torch==2.12.1 --index-url https://download.pytorch.org/whl/cu126
|
||||
```
|
||||
|
||||
不要在同一环境混装 CPU 与 CUDA wheel;切换后端时重建 `.venv`。
|
||||
|
||||
## 运行和验证
|
||||
|
||||
入口的帮助与版本查询不导入 PyTorch,因此未安装运行时时也可用:
|
||||
|
||||
```powershell
|
||||
Brain\.venv\Scripts\python.exe -m yovision_brain --help
|
||||
Brain\.venv\Scripts\python.exe -m yovision_brain --version
|
||||
```
|
||||
|
||||
安装相应运行时后执行:
|
||||
|
||||
```powershell
|
||||
Brain\.venv\Scripts\python.exe -m pytest Brain/tests/test_package.py -q
|
||||
Brain\.venv\Scripts\python.exe -m yovision_brain --runtime-info
|
||||
Brain\.venv\Scripts\python.exe -m yovision_brain --smoke cpu
|
||||
Brain\.venv\Scripts\python.exe -m yovision_brain --smoke cuda
|
||||
```
|
||||
|
||||
`--runtime-info` 只输出 Python、PyTorch 和设备能力,不读取或显示环境变量值。`--smoke cuda` 在 CUDA wheel、驱动或设备不可用时以非零状态退出,不会回退 CPU 后伪称成功。
|
||||
|
||||
## 配置与安全边界
|
||||
|
||||
`.env.example` 只有无秘密默认值。Brain 不接收用户会话,不持有账户、RBAC、Alert 或通知状态。不得把 token、摄像头凭据、客户数据、内部文件路径或未经授权的人脸信息写入配置、日志或事件。
|
||||
|
||||
第三方许可证与来源见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
|
||||
@@ -0,0 +1,17 @@
|
||||
# Third-party notices
|
||||
|
||||
本文件记录 Brain 骨架直接固定或明确依赖的第三方软件。具体安装包内许可证文本仍是最终依据。
|
||||
|
||||
| 组件 | 固定版本 | 许可证 | 来源 |
|
||||
|---|---:|---|---|
|
||||
| CPython | 3.11.15 | Python Software Foundation License | https://www.python.org/downloads/release/python-31115/ |
|
||||
| pip | 26.2.1 | MIT | https://github.com/pypa/pip/tree/26.2.1 |
|
||||
| uv(可选 Python 获取工具,不随 Brain 分发) | 0.11.6 | Apache-2.0 OR MIT | https://github.com/astral-sh/uv/tree/0.11.6 |
|
||||
| python-build-standalone(uv 管理的 Python 分发来源,不随 Brain 分发) | 2026 系列 | MPL-2.0;分发包内另含 CPython 与组件许可证 | https://github.com/astral-sh/python-build-standalone |
|
||||
| setuptools | 80.9.0 | MIT | https://github.com/pypa/setuptools/tree/v80.9.0 |
|
||||
| pytest | 8.4.2 | MIT | https://github.com/pytest-dev/pytest/tree/8.4.2 |
|
||||
| NumPy | 2.3.3 | BSD-3-Clause | https://github.com/numpy/numpy/tree/v2.3.3 |
|
||||
| PyTorch | 2.12.1 | BSD-3-Clause | https://github.com/pytorch/pytorch/tree/v2.12.1 |
|
||||
| NVIDIA CUDA runtime(随官方 PyTorch CUDA wheel 分发) | 12.6 系列 | NVIDIA CUDA Toolkit End User License Agreement | https://docs.nvidia.com/cuda/eula/index.html |
|
||||
|
||||
PyTorch 及后续模型可能带来额外第三方依赖与模型许可。本骨架未选择或分发任何模型;引入模型前必须另行核对商用、再分发、数据和输出限制,不能把框架许可证视为模型许可证。
|
||||
@@ -0,0 +1,27 @@
|
||||
[build-system]
|
||||
requires = ["setuptools==80.9.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "yovision-brain"
|
||||
version = "0.1.0"
|
||||
description = "Headless inference delivery unit for YoVision"
|
||||
readme = "README.md"
|
||||
requires-python = "==3.11.*"
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
# The wheel backend is selected by the official PyTorch index documented in
|
||||
# README.md. Keeping one pinned requirement here prevents CPU/CUDA drift.
|
||||
runtime = ["numpy==2.3.3", "torch==2.12.1"]
|
||||
dev = ["pytest==8.4.2"]
|
||||
|
||||
[project.scripts]
|
||||
yovision-brain = "yovision_brain.__main__:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "--strict-markers"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""YoVision Brain package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = ["__version__"]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Command-line entry point for safe Brain runtime diagnostics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import platform
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from yovision_brain import __version__
|
||||
|
||||
|
||||
def _load_torch() -> Any:
|
||||
try:
|
||||
import torch
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"PyTorch runtime is not installed; install the pinned CPU or CUDA wheel "
|
||||
"from Brain/README.md"
|
||||
) from exc
|
||||
return torch
|
||||
|
||||
|
||||
def runtime_info() -> dict[str, object]:
|
||||
"""Return non-sensitive interpreter and compute-runtime facts."""
|
||||
info: dict[str, object] = {
|
||||
"python": platform.python_version(),
|
||||
"torch_installed": False,
|
||||
"torch_version": None,
|
||||
"cuda_build": None,
|
||||
"cuda_available": False,
|
||||
"cuda_device_count": 0,
|
||||
}
|
||||
try:
|
||||
torch = _load_torch()
|
||||
except RuntimeError:
|
||||
return info
|
||||
|
||||
cuda_available = bool(torch.cuda.is_available())
|
||||
info.update(
|
||||
{
|
||||
"torch_installed": True,
|
||||
"torch_version": torch.__version__,
|
||||
"cuda_build": torch.version.cuda,
|
||||
"cuda_available": cuda_available,
|
||||
"cuda_device_count": torch.cuda.device_count() if cuda_available else 0,
|
||||
}
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
def smoke(device: str) -> dict[str, object]:
|
||||
"""Run a deterministic tensor operation on exactly the requested device."""
|
||||
torch = _load_torch()
|
||||
if device == "cuda" and not torch.cuda.is_available():
|
||||
raise RuntimeError("CUDA was requested but PyTorch reports no available CUDA device")
|
||||
|
||||
tensor = torch.tensor([[1.0, 2.0], [3.0, 4.0]], device=device)
|
||||
result = tensor @ tensor
|
||||
expected = torch.tensor([[7.0, 10.0], [15.0, 22.0]], device=device)
|
||||
if not torch.equal(result, expected):
|
||||
raise RuntimeError("tensor smoke result did not match the expected value")
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"device": device,
|
||||
"torch_version": torch.__version__,
|
||||
"cuda_build": torch.version.cuda,
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="yovision-brain",
|
||||
description="YoVision Brain runtime diagnostics",
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
||||
action = parser.add_mutually_exclusive_group()
|
||||
action.add_argument(
|
||||
"--runtime-info",
|
||||
action="store_true",
|
||||
help="print non-sensitive Python/PyTorch/CUDA capability information",
|
||||
)
|
||||
action.add_argument(
|
||||
"--smoke",
|
||||
choices=("cpu", "cuda"),
|
||||
help="run a tensor smoke test on exactly the selected device",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
if args.runtime_info:
|
||||
payload = runtime_info()
|
||||
elif args.smoke:
|
||||
payload = smoke(args.smoke)
|
||||
else:
|
||||
build_parser().print_help()
|
||||
return 0
|
||||
except RuntimeError as exc:
|
||||
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False))
|
||||
return 2
|
||||
|
||||
print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from yovision_brain import __version__
|
||||
from yovision_brain.__main__ import main, runtime_info, smoke
|
||||
|
||||
|
||||
def test_package_version() -> None:
|
||||
assert __version__ == "0.1.0"
|
||||
|
||||
|
||||
def test_module_help_runs_without_other_products() -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "yovision_brain", "--help"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "YoVision Brain runtime diagnostics" in result.stdout
|
||||
|
||||
|
||||
def test_runtime_info_is_non_sensitive(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert main(["--runtime-info"]) == 0
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert set(payload) == {
|
||||
"cuda_available",
|
||||
"cuda_build",
|
||||
"cuda_device_count",
|
||||
"python",
|
||||
"torch_installed",
|
||||
"torch_version",
|
||||
}
|
||||
assert payload == runtime_info()
|
||||
|
||||
|
||||
def test_cpu_tensor_smoke() -> None:
|
||||
pytest.importorskip("torch")
|
||||
result = smoke("cpu")
|
||||
assert result["status"] == "ok"
|
||||
assert result["device"] == "cpu"
|
||||
@@ -0,0 +1,46 @@
|
||||
# Sense 独立纵切验收
|
||||
|
||||
本验收只启动 Sense、临时 PostgreSQL 17、测试用 ONVIF/RTSP 夹具和独立 MediaMTX;不启动 Brain 或 Bell。测试凭据在每次运行时随机生成,只通过子进程环境和系统临时目录传递,测试结束后清理。
|
||||
|
||||
## 自动化入口
|
||||
|
||||
从仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
& Sense\tests\compatibility\run-static-regression.ps1
|
||||
& Sense\tests\e2e\run-isolated-e2e.ps1
|
||||
```
|
||||
|
||||
隔离 E2E 默认使用 `D:\pgsql17\bin`、已审核的 `C:\Users\ila20\Desktop\mediamtx\mediamtx.exe` 和本机 Chrome。路径不同时使用参数显式指定。运行数据、数据库、构建副本、浏览器截图和日志全部位于系统临时目录;只有排错时才使用 `-KeepTemporary`,其中可能含运行时秘密,必须按敏感数据保护并及时清理。
|
||||
|
||||
E2E 入口从 PowerShell 7 调用时会自动转入 Windows PowerShell 5.1 执行本地 HTTP 回归;源码打包仍显式使用冻结要求的 PowerShell 7。这样与 Windows 交付脚本的宿主一致,也避开当前机器 PowerShell 7 HTTP 客户端对本地 Go/MediaMTX 响应的兼容问题。
|
||||
|
||||
## 回归矩阵
|
||||
|
||||
| 范围 | 自动化证据 | 判定 |
|
||||
|---|---|---|
|
||||
| GoAdmin 来源与复用 | 检查冻结 commit、MIT 文件、Cobra、迁移、Gin Router、JWT、动态 Router/Store/Axios/Layout/权限入口 | 必须通过 |
|
||||
| 空白 PostgreSQL 17 | 临时集群执行完整迁移,核对迁移版本和 `capabilities` JSONB | 必须通过 |
|
||||
| 登录与 RBAC | 一次性安全初始化、免验证码登录、未认证访问拒绝、管理员权限链 | 必须通过 |
|
||||
| 中文与请求白名单 | 中文设备/安装位置/区域往返;未知凭据字段返回 400 | 必须通过 |
|
||||
| 凭据边界 | ONVIF/RTSP 分用途写入;API、媒体路径、日志和数据库均不出现明文 | 必须通过 |
|
||||
| ONVIF 与 RTSP | 测试 ONVIF 对每个 SOAP 操作强制并校验 Digest;RTSP OPTIONS 验证合成源 | 必须通过 |
|
||||
| MediaMTX | 独立端口、回环 Control API、按需路径、合成视频消费者和安全播放地址 | 必须通过 |
|
||||
| 实时监看 | 创建短期播放会话并验证同源 wrapper 与浏览器可达地址 | 必须通过 |
|
||||
| 区域配置 | 多边形保存;Profile 分辨率变化后自动标记需要重校准 | 必须通过 |
|
||||
| GoAdmin UI 外壳 | Chrome 验证侧栏、顶部导航、标签页和五个 Sense 页面;无无关入口 | 必须通过 |
|
||||
| Windows 交付 | 临时副本按固定 Go/Node/pnpm 构建,生产启动、停止、冷启动路径恢复 | 必须通过 |
|
||||
| 独立性 | 测试不连接 Brain/Bell,不写 `contracts/` 或根级部署 | 必须通过 |
|
||||
|
||||
## 历史迁移裁决
|
||||
|
||||
- 旧 `explore` 只提供需求与故障清单,不作为通过证据,也不复制其自研认证、RBAC、HTTP 外壳或业务实现。
|
||||
- 中文绑定、严格请求字段、Digest、凭据分离、Media 回环地址、按需拉流、冷启动恢复、区域重校准和 Windows 包必须由当前 `dev` 新基线重新运行。
|
||||
- 测试失败时保留失败证据并建立独立缺陷工单;本验收任务不修改产品实现以掩盖失败。
|
||||
|
||||
## 未验证边界
|
||||
|
||||
- 客户现场真实 ONVIF 摄像机型号、固件差异、网络丢包和时钟偏差。
|
||||
- 客户全新 Windows 主机、生产数据库账号权限、防火墙和服务管理器。
|
||||
- 16 路及更高并发、长时间稳定性、硬件解码与实际带宽;16 路只是默认交付配额,不是代码硬上限。
|
||||
- Brain/Bell 契约、跨项目端到端事件和证据链,必须由后续协调工单验证。
|
||||
@@ -0,0 +1,19 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/local_event"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
)
|
||||
|
||||
func init() { routerCheckRole = append(routerCheckRole, registerSenseLocalEventRouter) }
|
||||
|
||||
func registerSenseLocalEventRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||
api := &local_event.API{}
|
||||
r := v1.Group("/local-events").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||
r.GET("", api.List)
|
||||
r.GET("/:id", api.Get)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func TestLocalEventRouterIsReadOnly(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
registerSenseLocalEventRouter(engine.Group("/api/v1"), &jwt.GinJWTMiddleware{})
|
||||
|
||||
routes := make(map[string]struct{})
|
||||
for _, route := range engine.Routes() {
|
||||
routes[route.Method+" "+route.Path] = struct{}{}
|
||||
}
|
||||
for _, expected := range []string{"GET /api/v1/local-events", "GET /api/v1/local-events/:id"} {
|
||||
if _, ok := routes[expected]; !ok {
|
||||
t.Fatalf("missing local event route: %s", expected)
|
||||
}
|
||||
}
|
||||
for route := range routes {
|
||||
if route != "GET /api/v1/local-events" && route != "GET /api/v1/local-events/:id" {
|
||||
t.Fatalf("unexpected mutable local event route: %s", route)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/operations"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
)
|
||||
|
||||
func init() { routerCheckRole = append(routerCheckRole, registerSenseOperationsRouter) }
|
||||
|
||||
func registerSenseOperationsRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||
api := &operations.API{}
|
||||
r := v1.Group("/operations").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||
r.GET("", api.List)
|
||||
r.GET("/:id", api.Get)
|
||||
r.POST("/:id/retry", api.Retry)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func TestSenseOperationsRoutes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
registerSenseOperationsRouter(engine.Group("/api/v1"), &jwt.GinJWTMiddleware{})
|
||||
wanted := map[string]bool{
|
||||
http.MethodGet + " /api/v1/operations": false,
|
||||
http.MethodGet + " /api/v1/operations/:id": false,
|
||||
http.MethodPost + " /api/v1/operations/:id/retry": false,
|
||||
}
|
||||
for _, route := range engine.Routes() {
|
||||
key := route.Method + " " + route.Path
|
||||
if _, ok := wanted[key]; ok {
|
||||
wanted[key] = true
|
||||
}
|
||||
}
|
||||
for route, found := range wanted {
|
||||
if !found {
|
||||
t.Fatalf("route not registered: %s", route)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/provisioning"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
)
|
||||
|
||||
func init() { routerCheckRole = append(routerCheckRole, registerSenseProvisioningRouter) }
|
||||
|
||||
func registerSenseProvisioningRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||
api := &provisioning.API{}
|
||||
r := v1.Group("/provisioning/batches").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||
r.GET("", api.List)
|
||||
r.POST("", api.Create)
|
||||
r.GET("/:id", api.Get)
|
||||
r.POST("/:id/execute", api.Execute)
|
||||
r.POST("/:id/retry-failed", api.RetryFailed)
|
||||
r.POST("/:id/items/:itemId/retry", api.RetryItem)
|
||||
r.GET("/:id/export", api.Export)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package local_event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common"
|
||||
)
|
||||
|
||||
type API struct{ api.Api }
|
||||
|
||||
func (e *API) service(c *gin.Context) (*Service, error) {
|
||||
service := &Service{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (e *API) List(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
request := &PageRequest{}
|
||||
if err = e.MakeContext(c).Bind(request).Errors; err != nil {
|
||||
e.audit(c, service, "List", auditFailure, "本地事件查询条件格式不正确")
|
||||
e.Error(http.StatusBadRequest, err, "查询条件格式不正确")
|
||||
return
|
||||
}
|
||||
list, count, err := service.List(request)
|
||||
if err != nil {
|
||||
e.audit(c, service, "List", auditFailure, "本地事件列表查询失败")
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.audit(c, service, "List", auditSuccess, "读取本地事件列表")
|
||||
e.PageOK(list, int(count), request.GetPageIndex(), request.GetPageSize(), "查询成功")
|
||||
}
|
||||
|
||||
func (e *API) Get(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
item, err := service.Get(c.Param("id"))
|
||||
if err != nil {
|
||||
e.audit(c, service, "Get", auditFailure, "本地事件详情查询失败")
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.audit(c, service, "Get", auditSuccess, "读取本地事件详情 "+item.ID)
|
||||
e.OK(item, "查询成功")
|
||||
}
|
||||
|
||||
func (e *API) audit(c *gin.Context, service *Service, action, status, remark string) {
|
||||
err := WriteAccessAudit(service.Orm, AccessAudit{
|
||||
Action: action, Status: status, Username: user.GetUserName(c), UserID: user.GetUserId(c),
|
||||
ClientIP: common.GetClientIP(c), Route: c.FullPath(), Remark: remark, At: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
api.GetRequestLogger(c).Errorf("local event access audit failed: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (e *API) writeError(err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidFilter):
|
||||
e.Error(http.StatusBadRequest, err, ErrInvalidFilter.Error())
|
||||
case errors.Is(err, ErrNotFound):
|
||||
e.Error(http.StatusNotFound, err, ErrNotFound.Error())
|
||||
default:
|
||||
e.Error(http.StatusInternalServerError, err, "本地事件查询失败")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package local_event
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
|
||||
)
|
||||
|
||||
const (
|
||||
auditSuccess = "1"
|
||||
auditFailure = "2"
|
||||
)
|
||||
|
||||
type AccessAudit struct {
|
||||
Action string
|
||||
Status string
|
||||
Username string
|
||||
UserID int
|
||||
ClientIP string
|
||||
Route string
|
||||
Remark string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// WriteAccessAudit persists a minimal, synchronous GoAdmin operation record.
|
||||
// It deliberately excludes query values, response bodies and evidence data.
|
||||
func WriteAccessAudit(db *gorm.DB, input AccessAudit) error {
|
||||
model := adminModels.SysOperaLog{
|
||||
Title: "本地事件", BusinessType: "query", Method: "local_event.API." + input.Action,
|
||||
RequestMethod: "GET", OperatorType: "1", OperName: input.Username, OperUrl: input.Route,
|
||||
OperIp: input.ClientIP, Status: input.Status, OperTime: input.At.UTC(), Remark: input.Remark,
|
||||
CreatedAt: input.At.UTC(), UpdatedAt: input.At.UTC(),
|
||||
}
|
||||
model.CreateBy, model.UpdateBy = input.UserID, input.UserID
|
||||
return db.Create(&model).Error
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package local_event
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
commonDTO "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
|
||||
)
|
||||
|
||||
type PageRequest struct {
|
||||
commonDTO.Pagination `search:"-"`
|
||||
CandidateState string `form:"candidateState"`
|
||||
EvidenceState string `form:"evidenceState"`
|
||||
RuleRef string `form:"ruleRef"`
|
||||
Keyword string `form:"keyword"`
|
||||
OccurredAfter string `form:"occurredAfter"`
|
||||
OccurredBefore string `form:"occurredBefore"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
ID string `json:"id"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
SourceRef string `json:"sourceRef"`
|
||||
SourceLabel string `json:"sourceLabel"`
|
||||
RuleRef string `json:"ruleRef"`
|
||||
RuleName string `json:"ruleName"`
|
||||
RuleVersion string `json:"ruleVersion"`
|
||||
ZoneRef string `json:"zoneRef"`
|
||||
CandidateState string `json:"candidateState"`
|
||||
EvidenceState string `json:"evidenceState"`
|
||||
EvidenceRef string `json:"evidenceRef,omitempty"`
|
||||
EvidenceDetail string `json:"evidenceDetail,omitempty"`
|
||||
RetainUntil time.Time `json:"retainUntil"`
|
||||
RetentionState string `json:"retentionState"`
|
||||
RemainingDays int `json:"remainingDays"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package local_event
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// SeedSyntheticFixture explicitly installs anonymous development/test data.
|
||||
// Production startup never calls this function.
|
||||
func SeedSyntheticFixture(db *gorm.DB, now time.Time) error {
|
||||
now = now.UTC().Truncate(time.Second)
|
||||
items := []EventCandidate{
|
||||
{ID: "00000000-0000-4000-8000-000000000001", OccurredAt: now.Add(-18 * time.Minute), SourceRef: "SEN-CAM-03", SourceLabel: "东门通道", RuleRef: "rule-zone-intrusion", RuleName: "区域闯入", RuleVersion: "v3", ZoneRef: "ZONE-EAST-01", CandidateState: CandidateStateCandidate, EvidenceState: EvidenceStateSuccess, EvidenceRef: "local-evidence/fixture-001", EvidenceDetail: "已生成事件截图与短片索引", RetainUntil: now.Add(12 * 24 * time.Hour)},
|
||||
{ID: "00000000-0000-4000-8000-000000000002", OccurredAt: now.Add(-23 * time.Minute), SourceRef: "SEN-CAM-08", SourceLabel: "仓库北区", RuleRef: "rule-person-stay", RuleName: "人员滞留", RuleVersion: "v2", ZoneRef: "ZONE-WAREHOUSE-02", CandidateState: CandidateStateConfirmed, EvidenceState: EvidenceStatePending, EvidenceDetail: "证据短片正在生成", RetainUntil: now.Add(12 * 24 * time.Hour)},
|
||||
{ID: "00000000-0000-4000-8000-000000000003", OccurredAt: now.Add(-31 * time.Minute), SourceRef: "SEN-CAM-11", SourceLabel: "设备间入口", RuleRef: "rule-zone-intrusion", RuleName: "区域闯入", RuleVersion: "v3", ZoneRef: "ZONE-EQUIPMENT-01", CandidateState: CandidateStateCandidate, EvidenceState: EvidenceStateFailed, EvidenceDetail: "源视频片段暂不可用", RetainUntil: now.Add(2 * 24 * time.Hour)},
|
||||
}
|
||||
return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&items).Error
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package local_event
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
const (
|
||||
CandidateStateCandidate = "candidate"
|
||||
CandidateStateConfirmed = "confirmed"
|
||||
|
||||
EvidenceStatePending = "pending"
|
||||
EvidenceStateSuccess = "success"
|
||||
EvidenceStateFailed = "failed"
|
||||
)
|
||||
|
||||
// EventCandidate is a Sense-internal, anonymous event candidate. It is not a
|
||||
// cross-product event contract and does not represent a Bell alert.
|
||||
type EventCandidate struct {
|
||||
ID string `gorm:"size:36;primaryKey" json:"id"`
|
||||
OccurredAt time.Time `gorm:"not null;index" json:"occurredAt"`
|
||||
SourceRef string `gorm:"size:128;not null;index" json:"sourceRef"`
|
||||
SourceLabel string `gorm:"size:128;not null;default:''" json:"sourceLabel"`
|
||||
RuleRef string `gorm:"size:128;not null;index" json:"ruleRef"`
|
||||
RuleName string `gorm:"size:128;not null" json:"ruleName"`
|
||||
RuleVersion string `gorm:"size:64;not null;default:''" json:"ruleVersion"`
|
||||
ZoneRef string `gorm:"size:128;not null;default:''" json:"zoneRef"`
|
||||
CandidateState string `gorm:"size:32;not null;index" json:"candidateState"`
|
||||
EvidenceState string `gorm:"size:32;not null;index" json:"evidenceState"`
|
||||
EvidenceRef string `gorm:"size:512;not null;default:''" json:"evidenceRef,omitempty"`
|
||||
EvidenceDetail string `gorm:"size:512;not null;default:''" json:"evidenceDetail,omitempty"`
|
||||
RetainUntil time.Time `gorm:"not null;index" json:"retainUntil"`
|
||||
common.ControlBy
|
||||
common.ModelTime
|
||||
}
|
||||
|
||||
func (EventCandidate) TableName() string { return "sense_local_event_candidates" }
|
||||
@@ -0,0 +1,145 @@
|
||||
package local_event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidFilter = errors.New("本地事件查询条件不符合要求")
|
||||
ErrNotFound = errors.New("本地事件候选不存在")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
coreService.Service
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (s *Service) List(request *PageRequest) ([]Response, int64, error) {
|
||||
filter, err := normalizeFilter(request)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
query := s.Orm.Model(&EventCandidate{})
|
||||
if filter.CandidateState != "" {
|
||||
query = query.Where("candidate_state = ?", filter.CandidateState)
|
||||
}
|
||||
if filter.EvidenceState != "" {
|
||||
query = query.Where("evidence_state = ?", filter.EvidenceState)
|
||||
}
|
||||
if filter.RuleRef != "" {
|
||||
query = query.Where("rule_ref = ?", filter.RuleRef)
|
||||
}
|
||||
if filter.occurredAfter != nil {
|
||||
query = query.Where("occurred_at >= ?", *filter.occurredAfter)
|
||||
}
|
||||
if filter.occurredBefore != nil {
|
||||
query = query.Where("occurred_at <= ?", *filter.occurredBefore)
|
||||
}
|
||||
if filter.Keyword != "" {
|
||||
pattern := "%" + strings.ToLower(filter.Keyword) + "%"
|
||||
query = query.Where("LOWER(id) LIKE ? OR LOWER(source_ref) LIKE ? OR LOWER(source_label) LIKE ? OR LOWER(rule_name) LIKE ?", pattern, pattern, pattern, pattern)
|
||||
}
|
||||
var count int64
|
||||
if err = query.Count(&count).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("count local event candidates: %w", err)
|
||||
}
|
||||
var models []EventCandidate
|
||||
if err = query.Order("occurred_at DESC, id DESC").Limit(filter.GetPageSize()).Offset((filter.GetPageIndex() - 1) * filter.GetPageSize()).Find(&models).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("list local event candidates: %w", err)
|
||||
}
|
||||
result := make([]Response, 0, len(models))
|
||||
for _, model := range models {
|
||||
result = append(result, s.response(model))
|
||||
}
|
||||
return result, count, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(id string) (Response, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || len(id) > 64 {
|
||||
return Response{}, ErrInvalidFilter
|
||||
}
|
||||
var model EventCandidate
|
||||
if err := s.Orm.First(&model, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return Response{}, ErrNotFound
|
||||
}
|
||||
return Response{}, fmt.Errorf("get local event candidate: %w", err)
|
||||
}
|
||||
return s.response(model), nil
|
||||
}
|
||||
|
||||
type normalizedFilter struct {
|
||||
*PageRequest
|
||||
occurredAfter *time.Time
|
||||
occurredBefore *time.Time
|
||||
}
|
||||
|
||||
func normalizeFilter(request *PageRequest) (*normalizedFilter, error) {
|
||||
if request == nil {
|
||||
request = &PageRequest{}
|
||||
}
|
||||
request.CandidateState = strings.TrimSpace(request.CandidateState)
|
||||
request.EvidenceState = strings.TrimSpace(request.EvidenceState)
|
||||
request.RuleRef = strings.TrimSpace(request.RuleRef)
|
||||
request.Keyword = strings.TrimSpace(request.Keyword)
|
||||
if request.GetPageSize() > 100 || len([]rune(request.Keyword)) > 128 || len(request.RuleRef) > 128 {
|
||||
return nil, ErrInvalidFilter
|
||||
}
|
||||
if request.CandidateState != "" && request.CandidateState != CandidateStateCandidate && request.CandidateState != CandidateStateConfirmed {
|
||||
return nil, ErrInvalidFilter
|
||||
}
|
||||
if request.EvidenceState != "" && request.EvidenceState != EvidenceStatePending && request.EvidenceState != EvidenceStateSuccess && request.EvidenceState != EvidenceStateFailed {
|
||||
return nil, ErrInvalidFilter
|
||||
}
|
||||
result := &normalizedFilter{PageRequest: request}
|
||||
var err error
|
||||
if strings.TrimSpace(request.OccurredAfter) != "" {
|
||||
value, parseErr := time.Parse(time.RFC3339, request.OccurredAfter)
|
||||
if parseErr != nil {
|
||||
return nil, ErrInvalidFilter
|
||||
}
|
||||
result.occurredAfter = &value
|
||||
}
|
||||
if strings.TrimSpace(request.OccurredBefore) != "" {
|
||||
value, parseErr := time.Parse(time.RFC3339, request.OccurredBefore)
|
||||
if parseErr != nil {
|
||||
return nil, ErrInvalidFilter
|
||||
}
|
||||
result.occurredBefore = &value
|
||||
}
|
||||
if result.occurredAfter != nil && result.occurredBefore != nil && result.occurredAfter.After(*result.occurredBefore) {
|
||||
err = ErrInvalidFilter
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) response(model EventCandidate) Response {
|
||||
now := time.Now().UTC()
|
||||
if s.Now != nil {
|
||||
now = s.Now().UTC()
|
||||
}
|
||||
remaining := int(model.RetainUntil.Sub(now).Hours() / 24)
|
||||
state := "active"
|
||||
if !model.RetainUntil.After(now) {
|
||||
remaining, state = 0, "expired"
|
||||
} else if remaining < 3 {
|
||||
state = "expiring"
|
||||
}
|
||||
return Response{
|
||||
ID: model.ID, OccurredAt: model.OccurredAt, SourceRef: model.SourceRef, SourceLabel: model.SourceLabel,
|
||||
RuleRef: model.RuleRef, RuleName: model.RuleName, RuleVersion: model.RuleVersion, ZoneRef: model.ZoneRef,
|
||||
CandidateState: model.CandidateState, EvidenceState: model.EvidenceState, EvidenceRef: model.EvidenceRef,
|
||||
EvidenceDetail: model.EvidenceDetail, RetainUntil: model.RetainUntil, RetentionState: state,
|
||||
RemainingDays: remaining, CreatedAt: model.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package local_event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
|
||||
commonDTO "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
|
||||
)
|
||||
|
||||
func localEventService(t *testing.T, now time.Time) *Service {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+uuid.NewString()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err = db.AutoMigrate(&EventCandidate{}, &adminModels.SysOperaLog{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Service{Service: coreService.Service{Orm: db}, Now: func() time.Time { return now }}
|
||||
}
|
||||
|
||||
func TestSyntheticFixtureSupportsIndependentListFilterAndDetail(t *testing.T) {
|
||||
now := time.Date(2026, 8, 28, 2, 0, 0, 0, time.UTC)
|
||||
service := localEventService(t, now)
|
||||
if err := SeedSyntheticFixture(service.Orm, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Seeding is explicit and idempotent; no Bell or Brain service is involved.
|
||||
if err := SeedSyntheticFixture(service.Orm, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, count, err := service.List(&PageRequest{CandidateState: CandidateStateCandidate, EvidenceState: EvidenceStateFailed, Keyword: "SEN-CAM-11"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 || len(items) != 1 || items[0].RetentionState != "expiring" || items[0].RemainingDays != 2 {
|
||||
t.Fatalf("unexpected filtered fixture: count=%d items=%#v", count, items)
|
||||
}
|
||||
detail, err := service.Get(items[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.EvidenceState != EvidenceStateFailed || detail.CandidateState != CandidateStateCandidate || detail.EvidenceDetail == "" {
|
||||
t.Fatalf("unexpected detail: %#v", detail)
|
||||
}
|
||||
encoded, _ := json.Marshal(detail)
|
||||
if strings.Contains(strings.ToLower(string(encoded)), "bell") || strings.Contains(strings.ToLower(string(encoded)), "brain") {
|
||||
t.Fatalf("local response leaked a cross-project schema field: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPaginatesAndOrdersNewestFirst(t *testing.T) {
|
||||
now := time.Date(2026, 8, 28, 2, 0, 0, 0, time.UTC)
|
||||
service := localEventService(t, now)
|
||||
if err := SeedSyntheticFixture(service.Orm, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, count, err := service.List(&PageRequest{Pagination: pagination(1, 2)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, _, err := service.List(&PageRequest{Pagination: pagination(2, 2)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 3 || len(first) != 2 || len(second) != 1 || !first[0].OccurredAt.After(first[1].OccurredAt) || !first[1].OccurredAt.After(second[0].OccurredAt) {
|
||||
t.Fatalf("unexpected pagination: first=%#v second=%#v count=%d", first, second, count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRejectsInvalidStatesTimesAndOversizedPages(t *testing.T) {
|
||||
now := time.Date(2026, 8, 28, 2, 0, 0, 0, time.UTC)
|
||||
service := localEventService(t, now)
|
||||
cases := []*PageRequest{
|
||||
{CandidateState: "delivered"},
|
||||
{EvidenceState: "unknown"},
|
||||
{OccurredAfter: "not-a-time"},
|
||||
{OccurredAfter: now.Format(time.RFC3339), OccurredBefore: now.Add(-time.Hour).Format(time.RFC3339)},
|
||||
{Pagination: pagination(1, 101)},
|
||||
}
|
||||
for _, request := range cases {
|
||||
if _, _, err := service.List(request); err != ErrInvalidFilter {
|
||||
t.Fatalf("request %#v: expected ErrInvalidFilter, got %v", request, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetReportsMissingCandidate(t *testing.T) {
|
||||
service := localEventService(t, time.Now().UTC())
|
||||
if _, err := service.Get("00000000-0000-4000-8000-000000000099"); err != ErrNotFound {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessAuditIsSynchronousAndExcludesEventData(t *testing.T) {
|
||||
now := time.Date(2026, 8, 28, 2, 0, 0, 0, time.UTC)
|
||||
service := localEventService(t, now)
|
||||
if err := WriteAccessAudit(service.Orm, AccessAudit{Action: "Get", Status: auditSuccess, Username: "operator", UserID: 7, ClientIP: "127.0.0.1", Route: "/api/v1/local-events/:id", Remark: "读取本地事件详情 fixture-1", At: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stored adminModels.SysOperaLog
|
||||
if err := service.Orm.First(&stored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Title != "本地事件" || stored.Status != auditSuccess || stored.CreateBy != 7 || stored.OperUrl != "/api/v1/local-events/:id" {
|
||||
t.Fatalf("unexpected access audit: %#v", stored)
|
||||
}
|
||||
if stored.OperParam != "" || stored.JsonResult != "" {
|
||||
t.Fatalf("audit persisted query or response data: %#v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func pagination(index, size int) commonDTO.Pagination {
|
||||
return commonDTO.Pagination{PageIndex: index, PageSize: size}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package operations
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common"
|
||||
)
|
||||
|
||||
const auditSuccess, auditFailure = "1", "2"
|
||||
|
||||
type API struct{ api.Api }
|
||||
|
||||
func (e *API) service(c *gin.Context) (*Service, error) {
|
||||
base := coreService.Service{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&base).Errors; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewService(base.Orm), nil
|
||||
}
|
||||
|
||||
func (e *API) List(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
request := PageRequest{}
|
||||
if err = e.MakeContext(c).Bind(&request).Errors; err != nil {
|
||||
e.audit(c, service, "List", auditFailure, "运维中心查询条件格式不正确")
|
||||
e.Error(http.StatusBadRequest, err, "查询条件格式不正确")
|
||||
return
|
||||
}
|
||||
response, err := service.List(request)
|
||||
if err != nil {
|
||||
e.audit(c, service, "List", auditFailure, "运维中心列表查询失败")
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.audit(c, service, "List", auditSuccess, "读取运维中心问题列表")
|
||||
e.OK(response, "查询成功")
|
||||
}
|
||||
|
||||
func (e *API) Get(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
item, err := service.Get(c.Param("id"))
|
||||
if err != nil {
|
||||
e.audit(c, service, "Get", auditFailure, "运维问题详情查询失败")
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.audit(c, service, "Get", auditSuccess, "读取运维问题详情 "+item.ID)
|
||||
e.OK(item, "查询成功")
|
||||
}
|
||||
|
||||
func (e *API) Retry(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
request := RetryRequest{}
|
||||
if err = e.MakeContext(c).Bind(&request, binding.JSON).Errors; err != nil {
|
||||
e.audit(c, service, "Retry", auditFailure, "受控重试请求格式不正确")
|
||||
e.Error(http.StatusBadRequest, err, "请求格式不正确")
|
||||
return
|
||||
}
|
||||
item, err := service.Retry(c.Request.Context(), c.Param("id"), request.ExpectedVersion, user.GetUserId(c))
|
||||
if err != nil {
|
||||
e.audit(c, service, "Retry", auditFailure, "受控重试被拒绝")
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.audit(c, service, "Retry", auditSuccess, "受控重试已排队 "+item.ID)
|
||||
e.OK(item, "重试任务已排队")
|
||||
}
|
||||
|
||||
func (e *API) audit(c *gin.Context, service *Service, action, status, remark string) {
|
||||
err := WriteAudit(service.DB, Audit{Action: action, Method: c.Request.Method, Status: status, Username: user.GetUserName(c), UserID: user.GetUserId(c), ClientIP: common.GetClientIP(c), Route: c.FullPath(), Remark: remark, At: time.Now()})
|
||||
if err != nil {
|
||||
api.GetRequestLogger(c).Errorf("operations audit failed: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (e *API) writeError(err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidFilter):
|
||||
e.Error(http.StatusBadRequest, err, err.Error())
|
||||
case errors.Is(err, ErrVersionConflict), errors.Is(err, ErrRetryInProgress), errors.Is(err, ErrRetryNotAllowed):
|
||||
e.Error(http.StatusConflict, err, err.Error())
|
||||
case errors.Is(err, ErrProblemNotFound):
|
||||
e.Error(http.StatusNotFound, err, err.Error())
|
||||
default:
|
||||
e.Error(http.StatusInternalServerError, err, "运维中心操作失败")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package operations
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
|
||||
)
|
||||
|
||||
type Audit struct {
|
||||
Action, Method, Status, Username, ClientIP, Route, Remark string
|
||||
UserID int
|
||||
At time.Time
|
||||
}
|
||||
|
||||
func WriteAudit(db *gorm.DB, input Audit) error {
|
||||
model := adminModels.SysOperaLog{
|
||||
Title: "运维中心", BusinessType: "other", Method: "operations.API." + input.Action,
|
||||
RequestMethod: input.Method, OperatorType: "1", OperName: input.Username, OperUrl: input.Route,
|
||||
OperIp: input.ClientIP, Status: input.Status, OperTime: input.At.UTC(), Remark: input.Remark,
|
||||
CreatedAt: input.At.UTC(), UpdatedAt: input.At.UTC(),
|
||||
}
|
||||
model.CreateBy, model.UpdateBy = input.UserID, input.UserID
|
||||
return db.Create(&model).Error
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package operations
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
commonDto "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
|
||||
)
|
||||
|
||||
const (
|
||||
ObjectDevice = "device"
|
||||
ObjectMedia = "media"
|
||||
ObjectLocalInference = "local_inference"
|
||||
|
||||
ProblemAuthentication = "authentication_failed"
|
||||
ProblemBackoff = "backoff_wait"
|
||||
ProblemClockDrift = "clock_drift"
|
||||
ProblemOrphan = "orphan_safety_gate"
|
||||
ProblemUnavailable = "capability_unavailable"
|
||||
ProblemUnready = "not_converged"
|
||||
)
|
||||
|
||||
type PageRequest struct {
|
||||
commonDto.Pagination `search:"-"`
|
||||
ObjectType string `form:"objectType"`
|
||||
ProblemType string `form:"problemType"`
|
||||
Severity string `form:"severity"`
|
||||
Keyword string `form:"keyword"`
|
||||
}
|
||||
|
||||
type RetryRequest struct {
|
||||
ExpectedVersion int64 `json:"expectedVersion"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
ManagedCount int `json:"managedCount"`
|
||||
ConvergedCount int `json:"convergedCount"`
|
||||
ActionableProblems int `json:"actionableProblems"`
|
||||
LocalInference string `json:"localInference"`
|
||||
}
|
||||
|
||||
type Problem struct {
|
||||
ID string `json:"id"`
|
||||
ObjectType string `json:"objectType"`
|
||||
ObjectID string `json:"objectId"`
|
||||
ObjectName string `json:"objectName"`
|
||||
Location string `json:"location,omitempty"`
|
||||
ProblemType string `json:"problemType"`
|
||||
Severity string `json:"severity"`
|
||||
Expected string `json:"expected"`
|
||||
Actual string `json:"actual"`
|
||||
Difference string `json:"difference"`
|
||||
NextAction string `json:"nextAction"`
|
||||
NextRetryAt *time.Time `json:"nextRetryAt,omitempty"`
|
||||
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
|
||||
AttemptCount int `json:"attemptCount"`
|
||||
Retryable bool `json:"retryable"`
|
||||
RetryInProgress bool `json:"retryInProgress"`
|
||||
Version int64 `json:"version"`
|
||||
SafetyGate string `json:"safetyGate"`
|
||||
OperationalOnly bool `json:"operationalOnly"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PageResponse struct {
|
||||
Summary Summary `json:"summary"`
|
||||
List []Problem `json:"list"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package operations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
|
||||
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidFilter = errors.New("运维中心查询条件不符合要求")
|
||||
ErrProblemNotFound = errors.New("运维问题不存在或已经收敛")
|
||||
ErrVersionConflict = errors.New("状态已经变化,请刷新后重试")
|
||||
ErrRetryInProgress = errors.New("该对象已有重试任务")
|
||||
ErrRetryNotAllowed = errors.New("该问题不允许重试")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
Now func() time.Time
|
||||
LocalInferenceConfigured bool
|
||||
DeviceRetry func(context.Context, admission.ProbeRequest) error
|
||||
}
|
||||
|
||||
type admissionResult struct {
|
||||
DeviceID string
|
||||
Address string
|
||||
Status string
|
||||
Detail string
|
||||
CheckedAt time.Time
|
||||
}
|
||||
|
||||
func (admissionResult) TableName() string { return "sense_admission_results" }
|
||||
|
||||
func NewService(db *gorm.DB) *Service {
|
||||
service := &Service{DB: db, Now: time.Now, LocalInferenceConfigured: false}
|
||||
service.DeviceRetry = func(ctx context.Context, request admission.ProbeRequest) error {
|
||||
base := coreService.Service{}
|
||||
base.Orm = db
|
||||
runtimeService, err := admission.NewRuntime(base)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = runtimeService.Probe(ctx, request)
|
||||
if err == nil {
|
||||
// Keep the existing admission boundary: route intent is best-effort and
|
||||
// must not turn a successful device probe into a MediaMTX failure.
|
||||
_ = media.EnsureDeviceRoutes(ctx, db, request.DeviceID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) List(request PageRequest) (PageResponse, error) {
|
||||
if err := validateFilter(request); err != nil {
|
||||
return PageResponse{}, err
|
||||
}
|
||||
problems, summary, err := s.project()
|
||||
if err != nil {
|
||||
return PageResponse{}, err
|
||||
}
|
||||
filtered := make([]Problem, 0, len(problems))
|
||||
keyword := strings.ToLower(strings.TrimSpace(request.Keyword))
|
||||
for _, item := range problems {
|
||||
if request.ObjectType != "" && item.ObjectType != request.ObjectType {
|
||||
continue
|
||||
}
|
||||
if request.ProblemType != "" && item.ProblemType != request.ProblemType {
|
||||
continue
|
||||
}
|
||||
if request.Severity != "" && item.Severity != request.Severity {
|
||||
continue
|
||||
}
|
||||
searchable := strings.ToLower(strings.Join([]string{item.ObjectID, item.ObjectName, item.Location, item.Difference}, " "))
|
||||
if keyword != "" && !strings.Contains(searchable, keyword) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
pageIndex, pageSize := request.GetPageIndex(), request.GetPageSize()
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
start := (pageIndex - 1) * pageSize
|
||||
if start > len(filtered) {
|
||||
start = len(filtered)
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > len(filtered) {
|
||||
end = len(filtered)
|
||||
}
|
||||
return PageResponse{Summary: summary, List: filtered[start:end], Count: int64(len(filtered))}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(id string) (Problem, error) {
|
||||
problems, _, err := s.project()
|
||||
if err != nil {
|
||||
return Problem{}, err
|
||||
}
|
||||
for _, item := range problems {
|
||||
if item.ID == id {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return Problem{}, ErrProblemNotFound
|
||||
}
|
||||
|
||||
func (s *Service) Retry(ctx context.Context, id string, expectedVersion int64, userID int) (Problem, error) {
|
||||
if expectedVersion < 1 {
|
||||
return Problem{}, ErrVersionConflict
|
||||
}
|
||||
problem, err := s.Get(id)
|
||||
if err != nil {
|
||||
return Problem{}, err
|
||||
}
|
||||
if !problem.Retryable {
|
||||
if problem.RetryInProgress {
|
||||
return Problem{}, ErrRetryInProgress
|
||||
}
|
||||
return Problem{}, ErrRetryNotAllowed
|
||||
}
|
||||
now := s.now().UTC()
|
||||
switch problem.ObjectType {
|
||||
case ObjectDevice:
|
||||
var admissionState admissionResult
|
||||
if err := s.DB.First(&admissionState, "device_id = ?", problem.ObjectID).Error; err != nil || strings.TrimSpace(admissionState.Address) == "" {
|
||||
return Problem{}, ErrRetryNotAllowed
|
||||
}
|
||||
result := s.DB.Model(&deviceModels.Device{}).
|
||||
Where("id = ? AND version = ? AND retry_requested_at IS NULL", problem.ObjectID, expectedVersion).
|
||||
Updates(map[string]any{"retry_requested_at": now, "version": expectedVersion + 1, "updated_at": now})
|
||||
if result.Error != nil {
|
||||
return Problem{}, fmt.Errorf("queue device retry: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return Problem{}, s.retryConflict(ObjectDevice, problem.ObjectID, expectedVersion)
|
||||
}
|
||||
if s.DeviceRetry != nil {
|
||||
err = s.DeviceRetry(ctx, admission.ProbeRequest{DeviceID: problem.ObjectID, Address: admissionState.Address, Version: expectedVersion + 1, UpdateBy: userID})
|
||||
if err != nil {
|
||||
clearErr := s.DB.Model(&deviceModels.Device{}).Where("id = ? AND version = ?", problem.ObjectID, expectedVersion+1).Update("retry_requested_at", nil).Error
|
||||
if clearErr != nil {
|
||||
return Problem{}, errors.Join(fmt.Errorf("execute device retry: %w", err), fmt.Errorf("clear device retry gate: %w", clearErr))
|
||||
}
|
||||
return Problem{}, fmt.Errorf("execute device retry: %w", err)
|
||||
}
|
||||
}
|
||||
case ObjectMedia:
|
||||
result := s.DB.Model(&media.Route{}).
|
||||
Where("id = ? AND version = ? AND actual <> ?", problem.ObjectID, expectedVersion, "retry_pending").
|
||||
Updates(map[string]any{"actual": "retry_pending", "next_retry_at": now, "detail": "已请求受控重试,等待视频服务执行", "version": expectedVersion + 1, "updated_at": now})
|
||||
if result.Error != nil {
|
||||
return Problem{}, fmt.Errorf("queue media retry: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return Problem{}, s.retryConflict(ObjectMedia, problem.ObjectID, expectedVersion)
|
||||
}
|
||||
default:
|
||||
return Problem{}, ErrRetryNotAllowed
|
||||
}
|
||||
updated, err := s.Get(id)
|
||||
if errors.Is(err, ErrProblemNotFound) {
|
||||
problem.Actual, problem.Difference, problem.NextAction = "converged", "重试完成,状态已经收敛", "无需处理"
|
||||
problem.Retryable, problem.RetryInProgress, problem.Version, problem.UpdatedAt = false, false, expectedVersion+2, now
|
||||
return problem, nil
|
||||
}
|
||||
return updated, err
|
||||
}
|
||||
|
||||
func (s *Service) project() ([]Problem, Summary, error) {
|
||||
var devices []deviceModels.Device
|
||||
if err := s.DB.Order("updated_at DESC").Find(&devices).Error; err != nil {
|
||||
return nil, Summary{}, fmt.Errorf("list operation devices: %w", err)
|
||||
}
|
||||
var admissions []admissionResult
|
||||
if err := s.DB.Find(&admissions).Error; err != nil {
|
||||
return nil, Summary{}, fmt.Errorf("list admission results: %w", err)
|
||||
}
|
||||
var routes []media.Route
|
||||
if err := s.DB.Order("updated_at DESC").Find(&routes).Error; err != nil {
|
||||
return nil, Summary{}, fmt.Errorf("list operation media routes: %w", err)
|
||||
}
|
||||
admissionByDevice := make(map[string]admissionResult, len(admissions))
|
||||
for _, item := range admissions {
|
||||
admissionByDevice[item.DeviceID] = item
|
||||
}
|
||||
deviceByID := make(map[string]deviceModels.Device, len(devices))
|
||||
problems := make([]Problem, 0)
|
||||
summary := Summary{ManagedCount: len(devices) + len(routes), LocalInference: "configured"}
|
||||
for _, device := range devices {
|
||||
deviceByID[device.ID] = device
|
||||
if problem, ok := deviceProblem(device, admissionByDevice[device.ID]); ok {
|
||||
problems = append(problems, problem)
|
||||
summary.ActionableProblems++
|
||||
} else {
|
||||
summary.ConvergedCount++
|
||||
}
|
||||
}
|
||||
for _, route := range routes {
|
||||
if problem, ok := mediaProblem(route, deviceByID); ok {
|
||||
problems = append(problems, problem)
|
||||
summary.ActionableProblems++
|
||||
} else {
|
||||
summary.ConvergedCount++
|
||||
}
|
||||
}
|
||||
if !s.LocalInferenceConfigured {
|
||||
summary.LocalInference = "unavailable"
|
||||
now := s.now().UTC()
|
||||
problems = append(problems, Problem{
|
||||
ID: "local_inference:adapter", ObjectType: ObjectLocalInference, ObjectID: "adapter",
|
||||
ObjectName: "本地推理适配器", ProblemType: ProblemUnavailable, Severity: "info",
|
||||
Expected: "可选", Actual: "unavailable", Difference: "未配置本地推理适配器;不影响设备和媒体运维",
|
||||
NextAction: "无需处理", SafetyGate: "不读取 Brain 数据库,不阻断本页", OperationalOnly: true, UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(problems, func(i, j int) bool {
|
||||
rank := map[string]int{"high": 0, "medium": 1, "info": 2}
|
||||
if rank[problems[i].Severity] != rank[problems[j].Severity] {
|
||||
return rank[problems[i].Severity] < rank[problems[j].Severity]
|
||||
}
|
||||
return problems[i].UpdatedAt.After(problems[j].UpdatedAt)
|
||||
})
|
||||
return problems, summary, nil
|
||||
}
|
||||
|
||||
func deviceProblem(device deviceModels.Device, admission admissionResult) (Problem, bool) {
|
||||
if device.Status == deviceModels.StatusDisabled {
|
||||
return Problem{}, false
|
||||
}
|
||||
actual, detail, updatedAt := device.AdapterStatus, "设备尚未完成接入验证", device.UpdatedAt
|
||||
if admission.DeviceID != "" {
|
||||
actual, detail, updatedAt = admission.Status, admission.Detail, admission.CheckedAt
|
||||
}
|
||||
if device.Status == deviceModels.StatusActive && (actual == "ready" || actual == "verified") {
|
||||
return Problem{}, false
|
||||
}
|
||||
problemType, severity, nextAction := ProblemUnready, "medium", "检查设备和接入配置后重试"
|
||||
text := strings.ToLower(actual + " " + detail)
|
||||
if strings.Contains(text, "auth") || strings.Contains(text, "认证") || strings.Contains(text, "凭据") {
|
||||
problemType, severity, nextAction = ProblemAuthentication, "high", "确认设备账号未变更后执行受控重试"
|
||||
} else if strings.Contains(text, "clock") || strings.Contains(text, "time drift") || strings.Contains(text, "时间漂移") || strings.Contains(text, "时钟") {
|
||||
problemType, nextAction = ProblemClockDrift, "检查设备 NTP 和时区后重新检测"
|
||||
}
|
||||
retrying := device.RetryRequestedAt != nil
|
||||
retryable := !retrying && strings.TrimSpace(admission.Address) != ""
|
||||
if admission.DeviceID == "" || strings.TrimSpace(admission.Address) == "" {
|
||||
nextAction = "先到视频接入完成地址与凭据验证"
|
||||
}
|
||||
return Problem{
|
||||
ID: "device:" + device.ID, ObjectType: ObjectDevice, ObjectID: device.ID, ObjectName: device.Name,
|
||||
Location: device.Location, ProblemType: problemType, Severity: severity, Expected: "active / ready", Actual: actual,
|
||||
Difference: detail, NextAction: nextAction, AttemptCount: boolInt(retrying), Retryable: retryable,
|
||||
RetryInProgress: retrying, Version: device.Version, SafetyGate: "校验设备版本且同一设备仅允许一个在途重试",
|
||||
OperationalOnly: true, UpdatedAt: updatedAt,
|
||||
}, true
|
||||
}
|
||||
|
||||
func mediaProblem(route media.Route, devices map[string]deviceModels.Device) (Problem, bool) {
|
||||
device, exists := devices[route.DeviceID]
|
||||
name, location := route.Path, ""
|
||||
if exists {
|
||||
name, location = device.Name+" / "+route.Path, device.Location
|
||||
}
|
||||
if !exists {
|
||||
return Problem{
|
||||
ID: "media:" + route.ID, ObjectType: ObjectMedia, ObjectID: route.ID, ObjectName: name,
|
||||
ProblemType: ProblemOrphan, Severity: "medium", Expected: "路由关联有效设备", Actual: "已隔离待确认",
|
||||
Difference: "媒体路由存在,但找不到有效设备归属", NextAction: "人工核对;不会自动删除", Version: route.Version,
|
||||
SafetyGate: "孤儿资源仅隔离和提示,本接口没有删除动作", OperationalOnly: true, UpdatedAt: route.UpdatedAt,
|
||||
}, true
|
||||
}
|
||||
converged := (route.Desired == media.DesiredRunning && (route.Actual == "ready" || route.Actual == "waiting")) || (route.Desired == media.DesiredStopped && route.Actual == "stopped")
|
||||
if converged {
|
||||
return Problem{}, false
|
||||
}
|
||||
problemType, nextAction := ProblemUnready, "检查视频服务状态后重试"
|
||||
if route.NextRetryAt != nil {
|
||||
problemType, nextAction = ProblemBackoff, "等待退避到期或执行受控提前重试"
|
||||
}
|
||||
retrying := route.Actual == "retry_pending"
|
||||
return Problem{
|
||||
ID: "media:" + route.ID, ObjectType: ObjectMedia, ObjectID: route.ID, ObjectName: name, Location: location,
|
||||
ProblemType: problemType, Severity: "medium", Expected: route.Desired, Actual: route.Actual, Difference: route.Detail,
|
||||
NextAction: nextAction, NextRetryAt: route.NextRetryAt, AttemptCount: route.FailureCount, Retryable: !retrying,
|
||||
RetryInProgress: retrying, Version: route.Version, SafetyGate: "校验路由版本且只排队,不删除路径或修改凭据",
|
||||
OperationalOnly: true, UpdatedAt: route.UpdatedAt,
|
||||
}, true
|
||||
}
|
||||
|
||||
func (s *Service) retryConflict(objectType, objectID string, expectedVersion int64) error {
|
||||
var version int64
|
||||
var inProgress bool
|
||||
switch objectType {
|
||||
case ObjectDevice:
|
||||
var item deviceModels.Device
|
||||
if err := s.DB.Select("version", "retry_requested_at").First(&item, "id = ?", objectID).Error; err != nil {
|
||||
return ErrProblemNotFound
|
||||
}
|
||||
version, inProgress = item.Version, item.RetryRequestedAt != nil
|
||||
case ObjectMedia:
|
||||
var item media.Route
|
||||
if err := s.DB.Select("version", "actual").First(&item, "id = ?", objectID).Error; err != nil {
|
||||
return ErrProblemNotFound
|
||||
}
|
||||
version, inProgress = item.Version, item.Actual == "retry_pending"
|
||||
}
|
||||
if inProgress {
|
||||
return ErrRetryInProgress
|
||||
}
|
||||
if version != expectedVersion {
|
||||
return ErrVersionConflict
|
||||
}
|
||||
return ErrVersionConflict
|
||||
}
|
||||
|
||||
func (s *Service) now() time.Time {
|
||||
if s.Now != nil {
|
||||
return s.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func validateFilter(request PageRequest) error {
|
||||
valid := func(value string, values ...string) bool {
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range values {
|
||||
if value == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !valid(request.ObjectType, ObjectDevice, ObjectMedia, ObjectLocalInference) ||
|
||||
!valid(request.ProblemType, ProblemAuthentication, ProblemBackoff, ProblemClockDrift, ProblemOrphan, ProblemUnavailable, ProblemUnready) ||
|
||||
!valid(request.Severity, "high", "medium", "info") || len([]rune(request.Keyword)) > 128 {
|
||||
return ErrInvalidFilter
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package operations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
|
||||
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||
)
|
||||
|
||||
func operationsTestService(t *testing.T) (*Service, *gorm.DB, time.Time) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&deviceModels.Device{}, &admissionResult{}, &media.Route{}, &adminModels.SysOperaLog{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 28, 2, 0, 0, 0, time.UTC)
|
||||
service := NewService(db)
|
||||
service.Now = func() time.Time { return now }
|
||||
service.LocalInferenceConfigured = false
|
||||
service.DeviceRetry = nil
|
||||
return service, db, now
|
||||
}
|
||||
|
||||
func TestProjectionCoversOperationsStatesAndIndependentBoundaries(t *testing.T) {
|
||||
service, db, now := operationsTestService(t)
|
||||
devices := []deviceModels.Device{
|
||||
{ID: "ready", Name: "东门", Location: "一层", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "ready", Version: 1},
|
||||
{ID: "auth", Name: "仓库", Location: "北区", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "verification_failed", Version: 3},
|
||||
{ID: "clock", Name: "南门", Location: "室外", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "verification_failed", Version: 4},
|
||||
}
|
||||
for index := range devices {
|
||||
devices[index].CreatedAt, devices[index].UpdatedAt = now, now
|
||||
}
|
||||
if err := db.Create(&devices).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admissions := []admissionResult{
|
||||
{DeviceID: "ready", Address: "http://192.0.2.10/onvif", Status: "ready", Detail: "接入验证完成", CheckedAt: now},
|
||||
{DeviceID: "auth", Address: "http://192.0.2.11/onvif", Status: "authentication_failed", Detail: "ONVIF 认证失败", CheckedAt: now},
|
||||
{DeviceID: "clock", Address: "http://192.0.2.12/onvif", Status: "clock_drift", Detail: "设备时间漂移 96 秒", CheckedAt: now},
|
||||
}
|
||||
if err := db.Create(&admissions).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := now.Add(5 * time.Minute)
|
||||
routes := []media.Route{
|
||||
{ID: "auth:main", DeviceID: "auth", ProfileToken: "main", Path: "sense_auth", Desired: "running", Actual: "apply_failed", Detail: "媒体路径配置失败", FailureCount: 2, NextRetryAt: &next, Version: 5, UpdatedAt: now},
|
||||
{ID: "missing:main", DeviceID: "missing", ProfileToken: "main", Path: "sense_orphan", Desired: "running", Actual: "ready", Detail: "上游拉流正常", Version: 1, UpdatedAt: now},
|
||||
}
|
||||
if err := db.Create(&routes).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := service.List(PageRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Summary.ManagedCount != 5 || response.Summary.ConvergedCount != 1 || response.Summary.ActionableProblems != 4 || response.Summary.LocalInference != "unavailable" || response.Count != 5 {
|
||||
t.Fatalf("unexpected summary: %#v count=%d", response.Summary, response.Count)
|
||||
}
|
||||
wanted := map[string]bool{ProblemAuthentication: false, ProblemClockDrift: false, ProblemBackoff: false, ProblemOrphan: false, ProblemUnavailable: false}
|
||||
for _, item := range response.List {
|
||||
wanted[item.ProblemType] = true
|
||||
if !item.OperationalOnly {
|
||||
t.Fatalf("problem can be mistaken for Bell alert: %#v", item)
|
||||
}
|
||||
}
|
||||
for state, found := range wanted {
|
||||
if !found {
|
||||
t.Fatalf("missing problem state %s: %#v", state, response.List)
|
||||
}
|
||||
}
|
||||
filtered, err := service.List(PageRequest{ObjectType: ObjectDevice, ProblemType: ProblemClockDrift, Keyword: "南门"})
|
||||
if err != nil || filtered.Count != 1 || filtered.List[0].ObjectID != "clock" {
|
||||
t.Fatalf("filtered=%#v err=%v", filtered, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlledRetryUsesVersionAndInProgressGate(t *testing.T) {
|
||||
service, db, now := operationsTestService(t)
|
||||
device := deviceModels.Device{ID: "auth", Name: "东门", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "verification_failed", Version: 3}
|
||||
device.CreatedAt, device.UpdatedAt = now, now
|
||||
if err := db.Create(&device).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&admissionResult{DeviceID: "auth", Address: "http://192.0.2.11/onvif", Status: "authentication_failed", Detail: "认证失败", CheckedAt: now}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, err := service.Retry(context.Background(), "device:auth", 3, 7)
|
||||
if err != nil || !item.RetryInProgress || item.Retryable || item.Version != 4 {
|
||||
t.Fatalf("item=%#v err=%v", item, err)
|
||||
}
|
||||
if _, err = service.Retry(context.Background(), "device:auth", 3, 7); !errors.Is(err, ErrRetryInProgress) {
|
||||
t.Fatalf("expected in-progress gate, got %v", err)
|
||||
}
|
||||
var stored deviceModels.Device
|
||||
if err = db.First(&stored, "id = ?", "auth").Error; err != nil || stored.RetryRequestedAt == nil {
|
||||
t.Fatalf("stored=%#v err=%v", stored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaRetryQueuesWithoutDeletingOrChangingCredentials(t *testing.T) {
|
||||
service, db, now := operationsTestService(t)
|
||||
device := deviceModels.Device{ID: "camera", Name: "仓库", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "ready", Version: 1}
|
||||
device.CreatedAt, device.UpdatedAt = now, now
|
||||
if err := db.Create(&device).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&admissionResult{DeviceID: "camera", Address: "http://192.0.2.20/onvif", Status: "ready", Detail: "接入完成", CheckedAt: now}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := now.Add(time.Minute)
|
||||
route := media.Route{ID: "camera:main", DeviceID: "camera", ProfileToken: "main", Path: "sense_camera", Desired: "running", Actual: "apply_failed", FailureCount: 2, NextRetryAt: &next, Detail: "配置失败", Version: 6, UpdatedAt: now}
|
||||
if err := db.Create(&route).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, err := service.Retry(context.Background(), "media:camera:main", 6, 7)
|
||||
if err != nil || item.Actual != "retry_pending" || item.Version != 7 || !item.RetryInProgress {
|
||||
t.Fatalf("item=%#v err=%v", item, err)
|
||||
}
|
||||
var count int64
|
||||
if err = db.Model(&media.Route{}).Where("id = ?", route.ID).Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("route was removed: count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrphanAndUnavailableCannotRetry(t *testing.T) {
|
||||
service, db, now := operationsTestService(t)
|
||||
route := media.Route{ID: "missing:main", DeviceID: "missing", ProfileToken: "main", Path: "orphan", Desired: "running", Actual: "ready", Version: 1, UpdatedAt: now}
|
||||
if err := db.Create(&route).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Retry(context.Background(), "media:missing:main", 1, 7); !errors.Is(err, ErrRetryNotAllowed) {
|
||||
t.Fatalf("orphan retry should be rejected: %v", err)
|
||||
}
|
||||
if _, err := service.Retry(context.Background(), "local_inference:adapter", 1, 7); !errors.Is(err, ErrRetryNotAllowed) {
|
||||
t.Fatalf("optional adapter retry should be rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceRetryUsesExistingAdmissionPortAndOperatorIdentity(t *testing.T) {
|
||||
service, db, now := operationsTestService(t)
|
||||
device := deviceModels.Device{ID: "clock", Name: "南门", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "verification_failed", Version: 3}
|
||||
device.CreatedAt, device.UpdatedAt = now, now
|
||||
if err := db.Create(&device).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&admissionResult{DeviceID: "clock", Address: "http://192.0.2.12/onvif", Status: "clock_drift", Detail: "设备时间漂移", CheckedAt: now}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
service.DeviceRetry = func(_ context.Context, request admission.ProbeRequest) error {
|
||||
called = true
|
||||
if request.DeviceID != "clock" || request.Address != "http://192.0.2.12/onvif" || request.Version != 4 || request.UpdateBy != 9 {
|
||||
t.Fatalf("unexpected retry request: %#v", request)
|
||||
}
|
||||
return db.Model(&deviceModels.Device{}).Where("id = ? AND version = ?", request.DeviceID, request.Version).Updates(map[string]any{"retry_requested_at": nil, "version": request.Version + 1}).Error
|
||||
}
|
||||
item, err := service.Retry(context.Background(), "device:clock", 3, 9)
|
||||
if err != nil || !called || item.Version != 5 || item.RetryInProgress {
|
||||
t.Fatalf("item=%#v called=%v err=%v", item, called, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditIsMinimalAndDesensitized(t *testing.T) {
|
||||
_, db, now := operationsTestService(t)
|
||||
if err := WriteAudit(db, Audit{Action: "Retry", Method: "POST", Status: "1", Username: "operator", UserID: 7, ClientIP: "127.0.0.1", Route: "/api/v1/operations/:id/retry", Remark: "受控重试已排队 device:synthetic", At: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stored adminModels.SysOperaLog
|
||||
if err := db.First(&stored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Title != "运维中心" || stored.RequestMethod != "POST" || stored.OperParam != "" || stored.JsonResult != "" || stored.CreateBy != 7 {
|
||||
t.Fatalf("unexpected audit: %#v", stored)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
)
|
||||
|
||||
type API struct{ api.Api }
|
||||
|
||||
func (e *API) service(c *gin.Context) (*Service, error) {
|
||||
service := &Service{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (e *API) List(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
request := BatchPageRequest{}
|
||||
if err = e.MakeContext(c).Bind(&request).Errors; err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "查询条件格式不正确")
|
||||
return
|
||||
}
|
||||
list, count, err := service.ListBatches(&request)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.PageOK(list, int(count), request.GetPageIndex(), request.GetPageSize(), "查询成功")
|
||||
}
|
||||
|
||||
func (e *API) Get(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
response, err := service.GetBatch(c.Param("id"))
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(response, "查询成功")
|
||||
}
|
||||
|
||||
func (e *API) Create(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
request := CreateBatchRequest{CreateBy: user.GetUserId(c)}
|
||||
if err = bindStrictJSON(c, &request); err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "导入内容格式不正确")
|
||||
return
|
||||
}
|
||||
response, err := service.CreateBatch(request)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(response, "批次已导入并完成预校验")
|
||||
}
|
||||
|
||||
func (e *API) Execute(c *gin.Context) { e.execute(c, false, "") }
|
||||
func (e *API) RetryFailed(c *gin.Context) { e.execute(c, true, "") }
|
||||
func (e *API) RetryItem(c *gin.Context) { e.execute(c, true, c.Param("itemId")) }
|
||||
|
||||
func (e *API) execute(c *gin.Context, retryFailed bool, itemID string) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
request := ExecuteRequest{UpdateBy: user.GetUserId(c)}
|
||||
defer func() { clearCredentials(request.Credentials) }()
|
||||
if err = bindStrictJSON(c, &request); err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "执行内容格式不正确")
|
||||
return
|
||||
}
|
||||
response, err := service.Execute(c.Request.Context(), c.Param("id"), itemID, retryFailed, request)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(response, "批量开通处理完成")
|
||||
}
|
||||
|
||||
func (e *API) Export(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
batch, err := service.GetBatch(c.Param("id"))
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", "attachment; filename=provisioning-"+batch.ID+".csv")
|
||||
c.Status(http.StatusOK)
|
||||
writer := csv.NewWriter(c.Writer)
|
||||
if err = writer.Write([]string{"line_number", "name", "location", "address", "status", "failure_code", "detail", "device_id"}); err != nil {
|
||||
e.Logger.Error(err)
|
||||
return
|
||||
}
|
||||
for _, item := range batch.Items {
|
||||
if err = writer.Write([]string{strconv.Itoa(item.LineNumber), item.Name, item.Location, item.Address, item.Status, item.FailureCode, item.Detail, item.DeviceID}); err != nil {
|
||||
e.Logger.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
writer.Flush()
|
||||
if err = writer.Error(); err != nil {
|
||||
e.Logger.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *API) writeError(err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidRequest):
|
||||
e.Error(http.StatusBadRequest, err, err.Error())
|
||||
case errors.Is(err, ErrBatchNotFound), errors.Is(err, ErrItemNotFound):
|
||||
e.Error(http.StatusNotFound, err, err.Error())
|
||||
default:
|
||||
e.Error(http.StatusInternalServerError, err, "批量开通操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
func bindStrictJSON(c *gin.Context, target any) error {
|
||||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") {
|
||||
return errors.New("content type must be application/json")
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 2<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("request body must contain one JSON object")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearCredentials(values []CredentialInput) {
|
||||
for index := range values {
|
||||
values[index].ONVIFUsername = ""
|
||||
values[index].ONVIFPassword = ""
|
||||
values[index].RTSPUsername = ""
|
||||
values[index].RTSPPassword = ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestCreateRequestRejectsCredentialColumns(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
context.Request = httptest.NewRequest("POST", "/api/v1/provisioning/batches", strings.NewReader(`{
|
||||
"idempotencyKey":"import-1",
|
||||
"rows":[{"lineNumber":1,"name":"东门摄像机","location":"东门","address":"http://192.0.2.10/onvif","password":"must-not-be-accepted"}]
|
||||
}`))
|
||||
context.Request.Header.Set("Content-Type", "application/json")
|
||||
var request CreateBatchRequest
|
||||
if err := bindStrictJSON(context, &request); err == nil || !strings.Contains(err.Error(), "unknown field") {
|
||||
t.Fatalf("expected unknown credential field to be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearCredentialsOverwritesTransientValues(t *testing.T) {
|
||||
values := []CredentialInput{{ONVIFUsername: "installer", ONVIFPassword: "temporary-secret", RTSPUsername: "stream", RTSPPassword: "stream-secret"}}
|
||||
clearCredentials(values)
|
||||
if values[0].ONVIFUsername != "" || values[0].ONVIFPassword != "" || values[0].RTSPUsername != "" || values[0].RTSPPassword != "" {
|
||||
t.Fatalf("credentials were not cleared: %#v", values[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
commonDTO "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
|
||||
)
|
||||
|
||||
type BatchPageRequest struct {
|
||||
commonDTO.Pagination `search:"-"`
|
||||
Status string `form:"status"`
|
||||
}
|
||||
|
||||
type ImportRow struct {
|
||||
LineNumber int `json:"lineNumber"`
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
type CreateBatchRequest struct {
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
Rows []ImportRow `json:"rows"`
|
||||
CreateBy int `json:"-"`
|
||||
}
|
||||
|
||||
type CredentialInput struct {
|
||||
ItemID string `json:"itemId"`
|
||||
ONVIFUsername string `json:"onvifUsername"`
|
||||
ONVIFPassword string `json:"onvifPassword"`
|
||||
RTSPSameAsONVIF bool `json:"rtspSameAsOnvif"`
|
||||
RTSPUsername string `json:"rtspUsername"`
|
||||
RTSPPassword string `json:"rtspPassword"`
|
||||
}
|
||||
|
||||
type ExecuteRequest struct {
|
||||
Credentials []CredentialInput `json:"credentials"`
|
||||
UpdateBy int `json:"-"`
|
||||
}
|
||||
|
||||
type ItemResponse struct {
|
||||
ID string `json:"id"`
|
||||
LineNumber int `json:"lineNumber"`
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Address string `json:"address"`
|
||||
Status string `json:"status"`
|
||||
FailureCode string `json:"failureCode,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
Attempts int `json:"attempts"`
|
||||
LastTriedAt *time.Time `json:"lastTriedAt,omitempty"`
|
||||
}
|
||||
|
||||
type BatchResponse struct {
|
||||
ID string `json:"id"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
Status string `json:"status"`
|
||||
QuotaLimit int `json:"quotaLimit"`
|
||||
ExistingCount int `json:"existingCount"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
ReadyCount int `json:"readyCount"`
|
||||
SuccessCount int `json:"successCount"`
|
||||
FailureCount int `json:"failureCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Items []ItemResponse `json:"items,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
const (
|
||||
BatchReady = "ready"
|
||||
BatchRunning = "running"
|
||||
BatchSucceeded = "succeeded"
|
||||
BatchPartial = "partial"
|
||||
BatchFailed = "failed"
|
||||
|
||||
ItemInvalid = "invalid"
|
||||
ItemReady = "ready"
|
||||
ItemQuotaExceeded = "quota_exceeded"
|
||||
ItemRunning = "running"
|
||||
ItemSucceeded = "succeeded"
|
||||
ItemFailed = "failed"
|
||||
)
|
||||
|
||||
type Batch struct {
|
||||
ID string `gorm:"size:36;primaryKey" json:"id"`
|
||||
IdempotencyKey string `gorm:"size:128;not null;uniqueIndex" json:"idempotencyKey"`
|
||||
Status string `gorm:"size:32;not null;index" json:"status"`
|
||||
QuotaLimit int `gorm:"not null" json:"quotaLimit"`
|
||||
ExistingCount int `gorm:"not null" json:"existingCount"`
|
||||
TotalCount int `gorm:"not null" json:"totalCount"`
|
||||
ReadyCount int `gorm:"not null" json:"readyCount"`
|
||||
SuccessCount int `gorm:"not null" json:"successCount"`
|
||||
FailureCount int `gorm:"not null" json:"failureCount"`
|
||||
common.ControlBy
|
||||
common.ModelTime
|
||||
Items []Item `gorm:"foreignKey:BatchID" json:"items,omitempty"`
|
||||
}
|
||||
|
||||
func (Batch) TableName() string { return "sense_provisioning_batches" }
|
||||
|
||||
type Item struct {
|
||||
ID string `gorm:"size:36;primaryKey" json:"id"`
|
||||
BatchID string `gorm:"size:36;not null;uniqueIndex:batch_line;index" json:"batchId"`
|
||||
LineNumber int `gorm:"not null;uniqueIndex:batch_line" json:"lineNumber"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Location string `gorm:"size:255;not null;default:''" json:"location"`
|
||||
Address string `gorm:"size:1024;not null" json:"address"`
|
||||
Status string `gorm:"size:32;not null;index" json:"status"`
|
||||
FailureCode string `gorm:"size:64;not null;default:''" json:"failureCode,omitempty"`
|
||||
Detail string `gorm:"size:512;not null;default:''" json:"detail,omitempty"`
|
||||
DeviceID string `gorm:"size:36;not null;default:'';index" json:"deviceId,omitempty"`
|
||||
Attempts int `gorm:"not null;default:0" json:"attempts"`
|
||||
LastTriedAt *time.Time `json:"lastTriedAt,omitempty"`
|
||||
common.ControlBy
|
||||
common.ModelTime
|
||||
}
|
||||
|
||||
func (Item) TableName() string { return "sense_provisioning_items" }
|
||||
@@ -0,0 +1,366 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||
deviceService "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service"
|
||||
deviceDTO "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidRequest = errors.New("批量开通请求不符合要求")
|
||||
ErrBatchNotFound = errors.New("批量开通批次不存在")
|
||||
ErrItemNotFound = errors.New("批量开通条目不存在")
|
||||
)
|
||||
|
||||
type Activator func(context.Context, *Service, *Item, CredentialInput, int) (string, string, string, error)
|
||||
|
||||
type Service struct {
|
||||
coreService.Service
|
||||
Quota int
|
||||
Activator Activator
|
||||
}
|
||||
|
||||
func QuotaFromEnvironment() int {
|
||||
value := strings.TrimSpace(os.Getenv("SENSE_PROVISIONING_QUOTA"))
|
||||
if value == "" {
|
||||
return 16
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed < 1 || parsed > 100000 {
|
||||
return 16
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func (s *Service) quota() int {
|
||||
if s.Quota > 0 {
|
||||
return s.Quota
|
||||
}
|
||||
return QuotaFromEnvironment()
|
||||
}
|
||||
|
||||
func (s *Service) CreateBatch(request CreateBatchRequest) (BatchResponse, error) {
|
||||
request.IdempotencyKey = strings.TrimSpace(request.IdempotencyKey)
|
||||
if request.IdempotencyKey == "" || len(request.IdempotencyKey) > 128 || len(request.Rows) == 0 || len(request.Rows) > 1000 {
|
||||
return BatchResponse{}, ErrInvalidRequest
|
||||
}
|
||||
var existing Batch
|
||||
if err := s.Orm.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("line_number") }).First(&existing, "idempotency_key = ?", request.IdempotencyKey).Error; err == nil {
|
||||
return batchResponse(existing), nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return BatchResponse{}, fmt.Errorf("read provisioning idempotency key: %w", err)
|
||||
}
|
||||
|
||||
var existingDevices int64
|
||||
if err := s.Orm.Table("sense_devices").Where("status <> ?", "disabled").Count(&existingDevices).Error; err != nil {
|
||||
return BatchResponse{}, fmt.Errorf("count provisioned devices: %w", err)
|
||||
}
|
||||
quota := s.quota()
|
||||
available := quota - int(existingDevices)
|
||||
if available < 0 {
|
||||
available = 0
|
||||
}
|
||||
batch := Batch{ID: uuid.NewString(), IdempotencyKey: request.IdempotencyKey, Status: BatchReady, QuotaLimit: quota, ExistingCount: int(existingDevices), TotalCount: len(request.Rows)}
|
||||
batch.CreateBy, batch.UpdateBy = request.CreateBy, request.CreateBy
|
||||
seenLines := map[int]bool{}
|
||||
seenAddresses := map[string]bool{}
|
||||
ready := 0
|
||||
for _, row := range request.Rows {
|
||||
item := Item{ID: uuid.NewString(), BatchID: batch.ID, LineNumber: row.LineNumber, Name: strings.TrimSpace(row.Name), Location: strings.TrimSpace(row.Location), Address: strings.TrimSpace(row.Address), Status: ItemReady}
|
||||
item.CreateBy, item.UpdateBy = request.CreateBy, request.CreateBy
|
||||
code, detail := validateImportRow(item, seenLines, seenAddresses)
|
||||
if code != "" {
|
||||
item.Status, item.FailureCode, item.Detail = ItemInvalid, code, detail
|
||||
} else if ready >= available {
|
||||
item.Status, item.FailureCode, item.Detail = ItemQuotaExceeded, "quota_exceeded", "超出当前可用配额,请调整配额或批次后重试"
|
||||
} else {
|
||||
ready++
|
||||
}
|
||||
batch.Items = append(batch.Items, item)
|
||||
}
|
||||
batch.ReadyCount = ready
|
||||
if ready == 0 {
|
||||
batch.Status = BatchFailed
|
||||
}
|
||||
if err := s.Orm.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Omit("Items").Create(&batch).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&batch.Items).Error
|
||||
}); err != nil {
|
||||
if isDuplicateKey(err) {
|
||||
return s.GetBatchByKey(request.IdempotencyKey)
|
||||
}
|
||||
return BatchResponse{}, fmt.Errorf("create provisioning batch: %w", err)
|
||||
}
|
||||
return s.GetBatch(batch.ID)
|
||||
}
|
||||
|
||||
func validateImportRow(item Item, seenLines map[int]bool, seenAddresses map[string]bool) (string, string) {
|
||||
if item.LineNumber < 1 || seenLines[item.LineNumber] {
|
||||
return "invalid_line_number", "行号必须为正整数且批次内唯一"
|
||||
}
|
||||
seenLines[item.LineNumber] = true
|
||||
if item.Name == "" || len([]rune(item.Name)) > 128 || len([]rune(item.Location)) > 255 {
|
||||
return "invalid_device", "设备名称不能为空,名称或安装位置长度不能超过限制"
|
||||
}
|
||||
parsed, err := url.Parse(item.Address)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "invalid_address", "设备地址必须是无账号、查询参数和片段的 HTTP(S) 地址"
|
||||
}
|
||||
key := strings.ToLower(parsed.String())
|
||||
if seenAddresses[key] {
|
||||
return "duplicate_address", "同一批次中设备地址不能重复"
|
||||
}
|
||||
seenAddresses[key] = true
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func (s *Service) ListBatches(request *BatchPageRequest) ([]BatchResponse, int64, error) {
|
||||
query := s.Orm.Model(&Batch{})
|
||||
if request.Status != "" {
|
||||
query = query.Where("status = ?", request.Status)
|
||||
}
|
||||
var count int64
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
pageSize := request.GetPageSize()
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
var batches []Batch
|
||||
if err := query.Order("created_at DESC").Limit(pageSize).Offset((request.GetPageIndex() - 1) * pageSize).Find(&batches).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
result := make([]BatchResponse, 0, len(batches))
|
||||
for _, batch := range batches {
|
||||
result = append(result, batchResponse(batch))
|
||||
}
|
||||
return result, count, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetBatch(id string) (BatchResponse, error) {
|
||||
var batch Batch
|
||||
if err := s.Orm.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("line_number") }).First(&batch, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return BatchResponse{}, ErrBatchNotFound
|
||||
}
|
||||
return BatchResponse{}, err
|
||||
}
|
||||
return batchResponse(batch), nil
|
||||
}
|
||||
|
||||
func (s *Service) GetBatchByKey(key string) (BatchResponse, error) {
|
||||
var batch Batch
|
||||
if err := s.Orm.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("line_number") }).First(&batch, "idempotency_key = ?", key).Error; err != nil {
|
||||
return BatchResponse{}, err
|
||||
}
|
||||
return batchResponse(batch), nil
|
||||
}
|
||||
|
||||
func (s *Service) Execute(ctx context.Context, batchID string, itemID string, retryFailed bool, request ExecuteRequest) (BatchResponse, error) {
|
||||
credentialByItem := make(map[string]CredentialInput, len(request.Credentials))
|
||||
for _, input := range request.Credentials {
|
||||
if input.ItemID == "" || credentialByItem[input.ItemID].ItemID != "" {
|
||||
return BatchResponse{}, ErrInvalidRequest
|
||||
}
|
||||
credentialByItem[input.ItemID] = input
|
||||
}
|
||||
var batch Batch
|
||||
if err := s.Orm.First(&batch, "id = ?", batchID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return BatchResponse{}, ErrBatchNotFound
|
||||
}
|
||||
return BatchResponse{}, err
|
||||
}
|
||||
query := s.Orm.Where("batch_id = ?", batchID)
|
||||
if itemID != "" {
|
||||
query = query.Where("id = ?", itemID)
|
||||
}
|
||||
if retryFailed {
|
||||
query = query.Where("status = ?", ItemFailed)
|
||||
} else {
|
||||
query = query.Where("status = ?", ItemReady)
|
||||
}
|
||||
var items []Item
|
||||
if err := query.Order("line_number").Find(&items).Error; err != nil {
|
||||
return BatchResponse{}, err
|
||||
}
|
||||
if itemID != "" && len(items) == 0 {
|
||||
return BatchResponse{}, ErrItemNotFound
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return s.GetBatch(batchID)
|
||||
}
|
||||
if err := s.Orm.Model(&Batch{}).Where("id = ?", batchID).Updates(map[string]any{"status": BatchRunning, "update_by": request.UpdateBy}).Error; err != nil {
|
||||
return BatchResponse{}, fmt.Errorf("mark provisioning batch running: %w", err)
|
||||
}
|
||||
activate := s.Activator
|
||||
if activate == nil {
|
||||
activate = defaultActivate
|
||||
}
|
||||
for index := range items {
|
||||
item := &items[index]
|
||||
now := time.Now().UTC()
|
||||
expectedStatus := ItemReady
|
||||
if retryFailed {
|
||||
expectedStatus = ItemFailed
|
||||
}
|
||||
claim := s.Orm.Model(&Item{}).Where("id = ? AND status = ?", item.ID, expectedStatus).Updates(map[string]any{"status": ItemRunning, "attempts": gorm.Expr("attempts + 1"), "last_tried_at": now, "update_by": request.UpdateBy})
|
||||
if claim.Error != nil {
|
||||
return BatchResponse{}, fmt.Errorf("mark provisioning item running: %w", claim.Error)
|
||||
}
|
||||
if claim.RowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
input, ok := credentialByItem[item.ID]
|
||||
if !ok || strings.TrimSpace(input.ONVIFUsername) == "" || input.ONVIFPassword == "" {
|
||||
if err := s.failItem(item.ID, request.UpdateBy, "credentials_required", "请安全填写该设备的账号密码后重试"); err != nil {
|
||||
return BatchResponse{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
deviceID, status, detail, err := activate(ctx, s, item, input, request.UpdateBy)
|
||||
if deviceID != "" && deviceID != item.DeviceID {
|
||||
item.DeviceID = deviceID
|
||||
if err := s.Orm.Model(&Item{}).Where("id = ?", item.ID).Update("device_id", deviceID).Error; err != nil {
|
||||
return BatchResponse{}, fmt.Errorf("link provisioning item to device: %w", err)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
code, safeDetail := safeFailure(err)
|
||||
if updateErr := s.failItem(item.ID, request.UpdateBy, code, safeDetail); updateErr != nil {
|
||||
return BatchResponse{}, updateErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if status != "ready" {
|
||||
if detail == "" {
|
||||
detail = "设备或视频流尚未通过验证"
|
||||
}
|
||||
if err := s.failItem(item.ID, request.UpdateBy, status, detail); err != nil {
|
||||
return BatchResponse{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := s.Orm.Model(&Item{}).Where("id = ?", item.ID).Updates(map[string]any{"status": ItemSucceeded, "failure_code": "", "detail": "设备已开通并通过视频验证", "update_by": request.UpdateBy}).Error; err != nil {
|
||||
return BatchResponse{}, fmt.Errorf("mark provisioning item succeeded: %w", err)
|
||||
}
|
||||
}
|
||||
if err := s.refreshBatch(batchID, request.UpdateBy); err != nil {
|
||||
return BatchResponse{}, err
|
||||
}
|
||||
return s.GetBatch(batchID)
|
||||
}
|
||||
|
||||
func isDuplicateKey(err error) bool {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return true
|
||||
}
|
||||
var postgresError *pgconn.PgError
|
||||
return errors.As(err, &postgresError) && postgresError.Code == "23505"
|
||||
}
|
||||
|
||||
func (s *Service) failItem(id string, updateBy int, code, detail string) error {
|
||||
if err := s.Orm.Model(&Item{}).Where("id = ?", id).Updates(map[string]any{"status": ItemFailed, "failure_code": code, "detail": detail, "update_by": updateBy}).Error; err != nil {
|
||||
return fmt.Errorf("mark provisioning item failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) refreshBatch(batchID string, updateBy int) error {
|
||||
var items []Item
|
||||
if err := s.Orm.Where("batch_id = ?", batchID).Find(&items).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
ready, success, failure := 0, 0, 0
|
||||
for _, item := range items {
|
||||
switch item.Status {
|
||||
case ItemReady, ItemRunning:
|
||||
ready++
|
||||
case ItemSucceeded:
|
||||
success++
|
||||
case ItemFailed:
|
||||
failure++
|
||||
}
|
||||
}
|
||||
status := BatchFailed
|
||||
if success == len(items) {
|
||||
status = BatchSucceeded
|
||||
} else if success > 0 {
|
||||
status = BatchPartial
|
||||
} else if ready > 0 {
|
||||
status = BatchReady
|
||||
}
|
||||
return s.Orm.Model(&Batch{}).Where("id = ?", batchID).Updates(map[string]any{"status": status, "ready_count": ready, "success_count": success, "failure_count": failure, "update_by": updateBy}).Error
|
||||
}
|
||||
|
||||
func defaultActivate(ctx context.Context, service *Service, item *Item, input CredentialInput, updateBy int) (string, string, string, error) {
|
||||
device := deviceService.Device{Service: service.Service}
|
||||
deviceID := item.DeviceID
|
||||
var response deviceDTO.DeviceResponse
|
||||
if deviceID == "" {
|
||||
if err := device.Insert(&deviceDTO.CreateReq{Name: item.Name, Location: item.Location, Modality: "video", Capabilities: []string{"video"}, CreateBy: updateBy}, &response); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
deviceID = response.ID
|
||||
if err := service.Orm.Model(&Item{}).Where("id = ?", item.ID).Update("device_id", deviceID).Error; err != nil {
|
||||
return deviceID, "", "", err
|
||||
}
|
||||
} else if err := device.Get(deviceID, &response); err != nil {
|
||||
return deviceID, "", "", err
|
||||
}
|
||||
if err := device.UpdateCredentials(&deviceDTO.CredentialUpdateReq{ID: deviceID, ONVIFUsername: input.ONVIFUsername, ONVIFPassword: input.ONVIFPassword, RTSPSameAsONVIF: input.RTSPSameAsONVIF, RTSPUsername: input.RTSPUsername, RTSPPassword: input.RTSPPassword, Version: response.Version, UpdateBy: updateBy}, &response); err != nil {
|
||||
return deviceID, "", "", err
|
||||
}
|
||||
probeService, err := admission.NewRuntime(service.Service)
|
||||
if err != nil {
|
||||
return deviceID, "", "", err
|
||||
}
|
||||
result, err := probeService.Probe(ctx, admission.ProbeRequest{DeviceID: deviceID, Address: item.Address, Version: response.Version, UpdateBy: updateBy})
|
||||
if err != nil {
|
||||
return deviceID, "", "", err
|
||||
}
|
||||
return deviceID, result.Status, result.Detail, nil
|
||||
}
|
||||
|
||||
func safeFailure(err error) (string, string) {
|
||||
switch {
|
||||
case errors.Is(err, credential.ErrKeyUnavailable):
|
||||
return "credential_key_unavailable", "摄像头凭据安全配置不可用"
|
||||
case errors.Is(err, credential.ErrCredentialNotConfigured):
|
||||
return "credentials_required", "请安全填写摄像头账号密码后重试"
|
||||
case errors.Is(err, deviceService.ErrInvalidDevice), errors.Is(err, admission.ErrInvalid):
|
||||
return "invalid_device", "设备信息或凭据不符合要求"
|
||||
case errors.Is(err, deviceService.ErrVersionConflict), errors.Is(err, admission.ErrConflict):
|
||||
return "version_conflict", "设备已被其他操作更新,请重试"
|
||||
default:
|
||||
return "activation_failed", "设备开通失败,请检查网络、地址和凭据后重试"
|
||||
}
|
||||
}
|
||||
|
||||
func batchResponse(batch Batch) BatchResponse {
|
||||
response := BatchResponse{ID: batch.ID, IdempotencyKey: batch.IdempotencyKey, Status: batch.Status, QuotaLimit: batch.QuotaLimit, ExistingCount: batch.ExistingCount, TotalCount: batch.TotalCount, ReadyCount: batch.ReadyCount, SuccessCount: batch.SuccessCount, FailureCount: batch.FailureCount, CreatedAt: batch.CreatedAt, UpdatedAt: batch.UpdatedAt, Items: make([]ItemResponse, 0, len(batch.Items))}
|
||||
sort.Slice(batch.Items, func(i, j int) bool { return batch.Items[i].LineNumber < batch.Items[j].LineNumber })
|
||||
for _, item := range batch.Items {
|
||||
response.Items = append(response.Items, ItemResponse{ID: item.ID, LineNumber: item.LineNumber, Name: item.Name, Location: item.Location, Address: item.Address, Status: item.Status, FailureCode: item.FailureCode, Detail: item.Detail, DeviceID: item.DeviceID, Attempts: item.Attempts, LastTriedAt: item.LastTriedAt})
|
||||
}
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||
)
|
||||
|
||||
func provisioningService(t *testing.T, quota int) *Service {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+uuid.NewString()+"?mode=memory&cache=shared"), &gorm.Config{TranslateError: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err = db.AutoMigrate(&deviceModels.Device{}, &Batch{}, &Item{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Service{Service: coreService.Service{Orm: db}, Quota: quota}
|
||||
}
|
||||
|
||||
func TestCreateBatchValidatesQuotaAndIsIdempotent(t *testing.T) {
|
||||
service := provisioningService(t, 2)
|
||||
if err := service.Orm.Create(&deviceModels.Device{ID: "existing", Name: "已接入摄像机", Modality: "video", Status: "active", AdapterStatus: "ready", Version: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := CreateBatchRequest{
|
||||
IdempotencyKey: "import-001",
|
||||
CreateBy: 7,
|
||||
Rows: []ImportRow{
|
||||
{LineNumber: 1, Name: "东门摄像机", Location: "东门", Address: "http://192.0.2.10/onvif"},
|
||||
{LineNumber: 2, Name: "重复地址", Location: "东门", Address: "http://192.0.2.10/onvif"},
|
||||
{LineNumber: 3, Name: "西门摄像机", Location: "西门", Address: "https://192.0.2.11/onvif"},
|
||||
},
|
||||
}
|
||||
created, err := service.CreateBatch(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.QuotaLimit != 2 || created.ExistingCount != 1 || created.ReadyCount != 1 || len(created.Items) != 3 {
|
||||
t.Fatalf("unexpected batch: %#v", created)
|
||||
}
|
||||
if created.Items[0].Status != ItemReady || created.Items[1].Status != ItemInvalid || created.Items[2].Status != ItemQuotaExceeded {
|
||||
t.Fatalf("unexpected item statuses: %#v", created.Items)
|
||||
}
|
||||
|
||||
request.Rows = []ImportRow{{LineNumber: 1, Name: "不应覆盖", Address: "http://192.0.2.99/onvif"}}
|
||||
repeated, err := service.CreateBatch(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repeated.ID != created.ID || repeated.TotalCount != 3 || repeated.Items[0].Name != "东门摄像机" {
|
||||
t.Fatalf("idempotent replay changed the batch: %#v", repeated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSupportsPartialSuccessAndFailedOnlyRetry(t *testing.T) {
|
||||
service := provisioningService(t, 16)
|
||||
created, err := service.CreateBatch(CreateBatchRequest{
|
||||
IdempotencyKey: "execute-001",
|
||||
Rows: []ImportRow{
|
||||
{LineNumber: 1, Name: "东门摄像机", Address: "http://192.0.2.10/onvif"},
|
||||
{LineNumber: 2, Name: "西门摄像机", Address: "http://192.0.2.11/onvif"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := map[int]int{}
|
||||
service.Activator = func(_ context.Context, _ *Service, item *Item, input CredentialInput, _ int) (string, string, string, error) {
|
||||
calls[item.LineNumber]++
|
||||
if input.ONVIFPassword != "temporary-secret" {
|
||||
t.Fatalf("activator did not receive the transient credential")
|
||||
}
|
||||
if item.LineNumber == 2 && calls[item.LineNumber] == 1 {
|
||||
return "device-2", "", "", errors.New("synthetic network failure containing temporary-secret")
|
||||
}
|
||||
return "device-" + string(rune('0'+item.LineNumber)), "ready", "验证通过", nil
|
||||
}
|
||||
credentials := make([]CredentialInput, 0, len(created.Items))
|
||||
for _, item := range created.Items {
|
||||
credentials = append(credentials, CredentialInput{ItemID: item.ID, ONVIFUsername: "installer", ONVIFPassword: "temporary-secret", RTSPSameAsONVIF: true})
|
||||
}
|
||||
partial, err := service.Execute(context.Background(), created.ID, "", false, ExecuteRequest{Credentials: credentials, UpdateBy: 8})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if partial.Status != BatchPartial || partial.SuccessCount != 1 || partial.FailureCount != 1 {
|
||||
t.Fatalf("expected partial success, got %#v", partial)
|
||||
}
|
||||
if partial.Items[1].Detail == "" || strings.Contains(partial.Items[1].Detail, "temporary-secret") {
|
||||
t.Fatalf("unsafe failure detail: %q", partial.Items[1].Detail)
|
||||
}
|
||||
|
||||
retried, err := service.Execute(context.Background(), created.ID, "", true, ExecuteRequest{Credentials: []CredentialInput{{ItemID: created.Items[1].ID, ONVIFUsername: "installer", ONVIFPassword: "temporary-secret", RTSPSameAsONVIF: true}}, UpdateBy: 8})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retried.Status != BatchSucceeded || retried.SuccessCount != 2 || calls[1] != 1 || calls[2] != 2 {
|
||||
t.Fatalf("failed-only retry was not idempotent: response=%#v calls=%#v", retried, calls)
|
||||
}
|
||||
encoded, err := json.Marshal(retried)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), "temporary-secret") || strings.Contains(string(encoded), "installer") {
|
||||
t.Fatalf("response contains credentials: %s", encoded)
|
||||
}
|
||||
var stored []Item
|
||||
if err = service.Orm.Find(&stored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
storedJSON, _ := json.Marshal(stored)
|
||||
if strings.Contains(string(storedJSON), "temporary-secret") || strings.Contains(string(storedJSON), "installer") {
|
||||
t.Fatalf("provisioning rows contain credentials: %s", storedJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRequiresCredentialsWithoutCallingActivator(t *testing.T) {
|
||||
service := provisioningService(t, 16)
|
||||
created, err := service.CreateBatch(CreateBatchRequest{IdempotencyKey: "missing-credentials", Rows: []ImportRow{{LineNumber: 1, Name: "东门摄像机", Address: "http://192.0.2.10/onvif"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
service.Activator = func(context.Context, *Service, *Item, CredentialInput, int) (string, string, string, error) {
|
||||
called = true
|
||||
return "", "", "", nil
|
||||
}
|
||||
result, err := service.Execute(context.Background(), created.ID, "", false, ExecuteRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if called || result.Status != BatchFailed || result.Items[0].FailureCode != "credentials_required" {
|
||||
t.Fatalf("missing credentials were not handled safely: %#v called=%v", result, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotaFromEnvironment(t *testing.T) {
|
||||
t.Setenv("SENSE_PROVISIONING_QUOTA", "24")
|
||||
if got := QuotaFromEnvironment(); got != 24 {
|
||||
t.Fatalf("quota=%d", got)
|
||||
}
|
||||
t.Setenv("SENSE_PROVISIONING_QUOTA", "invalid")
|
||||
if got := QuotaFromEnvironment(); got != 16 {
|
||||
t.Fatalf("fallback quota=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentCreateUsesOneIdempotentBatch(t *testing.T) {
|
||||
service := provisioningService(t, 16)
|
||||
sqlDB, err := service.Orm.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
request := CreateBatchRequest{IdempotencyKey: "concurrent-import", Rows: []ImportRow{{LineNumber: 1, Name: "东门摄像机", Address: "http://192.0.2.10/onvif"}}}
|
||||
const workers = 8
|
||||
ids := make(chan string, workers)
|
||||
errs := make(chan error, workers)
|
||||
var wait sync.WaitGroup
|
||||
for index := 0; index < workers; index++ {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
batch, createErr := service.CreateBatch(request)
|
||||
if createErr != nil {
|
||||
errs <- createErr
|
||||
return
|
||||
}
|
||||
ids <- batch.ID
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
close(ids)
|
||||
close(errs)
|
||||
for createErr := range errs {
|
||||
t.Fatalf("concurrent create failed: %v", createErr)
|
||||
}
|
||||
var first string
|
||||
for id := range ids {
|
||||
if first == "" {
|
||||
first = id
|
||||
} else if id != first {
|
||||
t.Fatalf("idempotent creates returned different batches: %q and %q", first, id)
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
if err = service.Orm.Model(&Batch{}).Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("batch count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSixteenDeviceBaselineSmoke(t *testing.T) {
|
||||
service := provisioningService(t, 16)
|
||||
rows := make([]ImportRow, 0, 16)
|
||||
for index := 1; index <= 16; index++ {
|
||||
rows = append(rows, ImportRow{LineNumber: index, Name: fmt.Sprintf("摄像机-%02d", index), Address: fmt.Sprintf("http://192.0.2.%d/onvif", index)})
|
||||
}
|
||||
created, err := service.CreateBatch(CreateBatchRequest{IdempotencyKey: "sixteen-device-smoke", Rows: rows})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.ReadyCount != 16 {
|
||||
t.Fatalf("ready count=%d", created.ReadyCount)
|
||||
}
|
||||
service.Activator = func(_ context.Context, _ *Service, item *Item, _ CredentialInput, _ int) (string, string, string, error) {
|
||||
return "device-" + item.ID, "ready", "验证通过", nil
|
||||
}
|
||||
credentials := make([]CredentialInput, 0, 16)
|
||||
for _, item := range created.Items {
|
||||
credentials = append(credentials, CredentialInput{ItemID: item.ID, ONVIFUsername: "installer", ONVIFPassword: "temporary-secret", RTSPSameAsONVIF: true})
|
||||
}
|
||||
completed, err := service.Execute(context.Background(), created.ID, "", false, ExecuteRequest{Credentials: credentials})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if completed.Status != BatchSucceeded || completed.SuccessCount != 16 || completed.FailureCount != 0 {
|
||||
t.Fatalf("unexpected 16-device result: %#v", completed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentExecuteClaimsAnItemOnce(t *testing.T) {
|
||||
service := provisioningService(t, 16)
|
||||
created, err := service.CreateBatch(CreateBatchRequest{IdempotencyKey: "concurrent-execute", Rows: []ImportRow{{LineNumber: 1, Name: "东门摄像机", Address: "http://192.0.2.10/onvif"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls atomic.Int32
|
||||
service.Activator = func(_ context.Context, _ *Service, item *Item, _ CredentialInput, _ int) (string, string, string, error) {
|
||||
calls.Add(1)
|
||||
return "device-" + item.ID, "ready", "验证通过", nil
|
||||
}
|
||||
request := ExecuteRequest{Credentials: []CredentialInput{{ItemID: created.Items[0].ID, ONVIFUsername: "installer", ONVIFPassword: "temporary-secret", RTSPSameAsONVIF: true}}}
|
||||
var wait sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
for index := 0; index < 2; index++ {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
_, executeErr := service.Execute(context.Background(), created.ID, "", false, request)
|
||||
errs <- executeErr
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
close(errs)
|
||||
for executeErr := range errs {
|
||||
if executeErr != nil {
|
||||
t.Fatal(executeErr)
|
||||
}
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("activator calls=%d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresDuplicateKeyDetection(t *testing.T) {
|
||||
if !isDuplicateKey(&pgconn.PgError{Code: "23505"}) || isDuplicateKey(&pgconn.PgError{Code: "23503"}) {
|
||||
t.Fatal("PostgreSQL duplicate-key detection is incorrect")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/provisioning"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
|
||||
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseProvisioning)
|
||||
}
|
||||
|
||||
func migrateSenseProvisioning(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&provisioning.Batch{}, &provisioning.Item{}, &deviceCasbinRule{}); err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: senseLayoutMenuName, Title: "视频感知", Icon: "video-camera", Path: "/sense", MenuType: "M", Component: "Layout", Sort: 5, Visible: "0", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseProvisioning", Title: "批量开通", Icon: "upload", Path: "provisioning", Paths: fmt.Sprintf("/0/%d", root.MenuId), MenuType: "C", Permission: "sense:provisioning:list", ParentId: root.MenuId, Component: "/sense/provisioning/index", Sort: 2, Visible: "0", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
definitions := []struct{ name, title, action, permission string }{
|
||||
{"SenseProvisioningImport", "导入批次", "POST", "sense:provisioning:import"},
|
||||
{"SenseProvisioningExecute", "执行开通", "POST", "sense:provisioning:execute"},
|
||||
{"SenseProvisioningRetry", "重试失败项", "POST", "sense:provisioning:retry"},
|
||||
{"SenseProvisioningExport", "导出结果", "GET", "sense:provisioning:export"},
|
||||
}
|
||||
buttons := make([]migrationModels.SysMenu, 0, len(definitions))
|
||||
for index, definition := range definitions {
|
||||
button, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: definition.name, Title: definition.title, MenuType: "F", Action: definition.action, Permission: definition.permission, ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: index + 1, Visible: "1", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buttons = append(buttons, button)
|
||||
}
|
||||
allMenus := append([]migrationModels.SysMenu{page}, buttons...)
|
||||
for _, role := range []string{"implementation_operator", "site_admin"} {
|
||||
if err = attachDeviceRole(tx, role, allMenus); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page, buttons[3]}); err != nil {
|
||||
return err
|
||||
}
|
||||
read := [][2]string{{"/api/v1/provisioning/batches", "GET"}, {"/api/v1/provisioning/batches/:id", "GET"}, {"/api/v1/provisioning/batches/:id/export", "GET"}}
|
||||
write := [][2]string{{"/api/v1/provisioning/batches", "POST"}, {"/api/v1/provisioning/batches/:id/execute", "POST"}, {"/api/v1/provisioning/batches/:id/retry-failed", "POST"}, {"/api/v1/provisioning/batches/:id/items/:itemId/retry", "POST"}}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
policies := append([][2]string{}, read...)
|
||||
if role != "viewer" {
|
||||
policies = append(policies, write...)
|
||||
}
|
||||
for _, policy := range policies {
|
||||
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err = rebuildSenseMenuPaths(tx, root.MenuId, "/0"); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/provisioning"
|
||||
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func TestProvisioningMigrationOnPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("SENSE_PROVISIONING_MIGRATION_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set SENSE_PROVISIONING_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const schema = "sense_provisioning_72_test"
|
||||
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error })
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
const version = "2026082809000_provisioning.go"
|
||||
if err = migrateSenseProvisioning(db, version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var menus, policies, batches, items, applied int64
|
||||
db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseProvisioning%").Count(&menus)
|
||||
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/provisioning/%").Count(&policies)
|
||||
db.Model(&provisioning.Batch{}).Count(&batches)
|
||||
db.Model(&provisioning.Item{}).Count(&items)
|
||||
db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied)
|
||||
if menus != 5 || policies != 17 || batches != 0 || items != 0 || applied != 1 {
|
||||
t.Fatalf("menus=%d policies=%d batches=%d items=%d applied=%d", menus, policies, batches, items, applied)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/local_event"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
|
||||
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseLocalEvent)
|
||||
}
|
||||
|
||||
func migrateSenseLocalEvent(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&local_event.EventCandidate{}, &deviceCasbinRule{}); err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: senseLayoutMenuName, Title: "视频感知", Icon: "video-camera", Path: "/sense", MenuType: "M", Component: "Layout", Sort: 5, Visible: "0", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseLocalEvent", Title: "本地事件", Icon: "bell", Path: "local-event", Paths: fmt.Sprintf("/0/%d", root.MenuId), MenuType: "C", Permission: "sense:local-event:list", ParentId: root.MenuId, Component: "/sense/local-event/index", Sort: 6, Visible: "0", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
detail, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseLocalEventDetail", Title: "查看本地事件", MenuType: "F", Action: "GET", Permission: "sense:local-event:detail", ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 1, Visible: "1", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page, detail}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, policy := range [][2]string{{"/api/v1/local-events", "GET"}, {"/api/v1/local-events/:id", "GET"}} {
|
||||
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err = rebuildSenseMenuPaths(tx, root.MenuId, "/0"); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/local_event"
|
||||
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func TestLocalEventMigrationOnPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("SENSE_LOCAL_EVENT_MIGRATION_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set SENSE_LOCAL_EVENT_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const schema = "sense_local_event_73_test"
|
||||
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error })
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
const version = "2026082810000_local_event.go"
|
||||
if err = migrateSenseLocalEvent(db, version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var menus, policies, rows, applied int64
|
||||
db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseLocalEvent%").Count(&menus)
|
||||
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/local-events%").Count(&policies)
|
||||
db.Model(&local_event.EventCandidate{}).Count(&rows)
|
||||
db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied)
|
||||
if menus != 2 || policies != 6 || rows != 0 || applied != 1 {
|
||||
t.Fatalf("menus=%d policies=%d rows=%d applied=%d", menus, policies, rows, applied)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
|
||||
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseOperations)
|
||||
}
|
||||
|
||||
func migrateSenseOperations(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
root, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: senseLayoutMenuName, Title: "视频感知", Icon: "video-camera", Path: "/sense", MenuType: "M", Component: "Layout", Sort: 5, Visible: "0", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseOperations", Title: "运维中心", Icon: "operation", Path: "operations", Paths: fmt.Sprintf("/0/%d", root.MenuId), MenuType: "C", Permission: "sense:operations:list", ParentId: root.MenuId, Component: "/sense/operations/index", Sort: 7, Visible: "0", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
detail, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseOperationsDetail", Title: "查看运维详情", MenuType: "F", Action: "GET", Permission: "sense:operations:detail", ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 1, Visible: "1", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
retry, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseOperationsRetry", Title: "受控重试", MenuType: "F", Action: "POST", Permission: "sense:operations:retry", ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 2, Visible: "1", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
readPolicies := [][2]string{{"/api/v1/operations", "GET"}, {"/api/v1/operations/:id", "GET"}}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page, detail}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, policy := range readPolicies {
|
||||
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, role := range []string{"implementation_operator", "site_admin"} {
|
||||
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{retry}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: "/api/v1/operations/:id/retry", V2: "POST"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = rebuildSenseMenuPaths(tx, root.MenuId, "/0"); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func TestOperationsMigrationAddsLeastPrivilegeMenus(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
const version = "2026082811000_operations.go"
|
||||
if err = migrateSenseOperations(db, version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var menus, reads, retries, viewerRetries, applied int64
|
||||
db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseOperations%").Count(&menus)
|
||||
db.Model(&deviceCasbinRule{}).Where("v1 IN ?", []string{"/api/v1/operations", "/api/v1/operations/:id"}).Count(&reads)
|
||||
db.Model(&deviceCasbinRule{}).Where("v1 = ? AND v2 = ?", "/api/v1/operations/:id/retry", "POST").Count(&retries)
|
||||
db.Model(&deviceCasbinRule{}).Where("v0 = ? AND v1 = ?", "viewer", "/api/v1/operations/:id/retry").Count(&viewerRetries)
|
||||
db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied)
|
||||
if menus != 3 || reads != 6 || retries != 2 || viewerRetries != 0 || applied != 1 {
|
||||
t.Fatalf("menus=%d reads=%d retries=%d viewerRetries=%d applied=%d", menus, reads, retries, viewerRetries, applied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationsMigrationOnPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("SENSE_OPERATIONS_MIGRATION_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set SENSE_OPERATIONS_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const schema = "sense_operations_74_test"
|
||||
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error })
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = migrateSenseOperations(db, "2026082811000_operations.go"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var menus, policies int64
|
||||
db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseOperations%").Count(&menus)
|
||||
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/operations%").Count(&policies)
|
||||
if menus != 3 || policies != 8 {
|
||||
t.Fatalf("menus=%d policies=%d", menus, policies)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
param([string]$RepositoryRoot = '')
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) {
|
||||
$RepositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
|
||||
}
|
||||
$senseRoot = Join-Path $RepositoryRoot 'Sense'
|
||||
$passed = 0
|
||||
function Assert-True([bool]$Condition, [string]$Message) {
|
||||
if (-not $Condition) { throw "ASSERT FAILED: $Message" }
|
||||
$script:passed++
|
||||
}
|
||||
function Read-Utf8([string]$Path) { return [IO.File]::ReadAllText($Path, [Text.Encoding]::UTF8) }
|
||||
|
||||
$baseline = Get-Content -LiteralPath (Join-Path $RepositoryRoot 'goadmin-baseline.json') -Raw -Encoding utf8 | ConvertFrom-Json
|
||||
Assert-True ($baseline.sources.'go-admin'.commit -eq 'f06540883b41d03782bb6b2c4150f298f328c6b6') 'Sense backend baseline drifted'
|
||||
Assert-True ($baseline.sources.'go-admin-ui'.commit -eq '67d393d713877572fab0b897296a4c1d525fc81d') 'Sense UI baseline drifted'
|
||||
Assert-True (Test-Path (Join-Path $senseRoot 'server\LICENSE.md')) 'go-admin MIT license is missing'
|
||||
Assert-True (Test-Path (Join-Path $senseRoot 'ui\LICENSE')) 'go-admin-ui MIT license is missing'
|
||||
|
||||
$requiredBackend = @(
|
||||
'server\cmd\cobra.go', 'server\common\middleware\auth.go',
|
||||
'server\app\admin\router\router.go', 'server\cmd\migrate\migration\init.go'
|
||||
)
|
||||
foreach ($relative in $requiredBackend) {
|
||||
Assert-True (Test-Path (Join-Path $senseRoot $relative)) "GoAdmin backend path missing: $relative"
|
||||
}
|
||||
$requiredFrontend = @(
|
||||
'ui\src\router\index.js', 'ui\src\store\index.js',
|
||||
'ui\src\store\modules\permission.js', 'ui\src\utils\request.js',
|
||||
'ui\src\layout\index.vue', 'ui\src\directive\permission\permission.js'
|
||||
)
|
||||
foreach ($relative in $requiredFrontend) {
|
||||
Assert-True (Test-Path (Join-Path $senseRoot $relative)) "go-admin-ui path missing: $relative"
|
||||
}
|
||||
|
||||
$router = Read-Utf8 (Join-Path $senseRoot 'ui\src\store\modules\permission.js')
|
||||
Assert-True ($router.Contains("item.component === 'Layout' ? Layout")) 'dynamic routes no longer preserve GoAdmin Layout'
|
||||
$request = Read-Utf8 (Join-Path $senseRoot 'ui\src\utils\request.js')
|
||||
Assert-True ($request.Contains("Authorization") -and $request.Contains("axios.create")) 'Axios/JWT request chain is missing'
|
||||
$auth = Read-Utf8 (Join-Path $senseRoot 'server\common\middleware\auth.go')
|
||||
Assert-True ($auth.Contains('jwt.New') -and $auth.Contains('sense_session')) 'GoAdmin JWT chain is missing'
|
||||
|
||||
$fixtureFiles = Get-ChildItem -LiteralPath (Join-Path $senseRoot 'tests\fixtures') -Recurse -File
|
||||
foreach ($file in $fixtureFiles) {
|
||||
$content = Read-Utf8 $file.FullName
|
||||
Assert-True ($content -notmatch '(?i)(admin123|password123|BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY)') "fixture contains a forbidden credential marker: $($file.Name)"
|
||||
if ($file.Extension -eq '.json') { [void]($content | ConvertFrom-Json); $passed++ }
|
||||
}
|
||||
|
||||
Write-Host "Sense compatibility regression passed: $passed assertions."
|
||||
@@ -0,0 +1,203 @@
|
||||
'use strict'
|
||||
|
||||
const fs = require('fs')
|
||||
const net = require('net')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const { spawn } = require('child_process')
|
||||
|
||||
function required(name) {
|
||||
const value = process.env[name]
|
||||
if (!value) throw new Error(`${name} is required`)
|
||||
return value
|
||||
}
|
||||
|
||||
const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
|
||||
async function freePort() {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.unref()
|
||||
server.on('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = server.address().port
|
||||
server.close(() => resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function retry(operation, label, attempts = 80) {
|
||||
let lastError
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try { return await operation() } catch (error) { lastError = error }
|
||||
await delay(250)
|
||||
}
|
||||
throw new Error(`${label}: ${lastError ? lastError.message : 'timed out'}`)
|
||||
}
|
||||
|
||||
class PageSession {
|
||||
constructor(socketURL, failures) {
|
||||
this.socket = new WebSocket(socketURL)
|
||||
this.failures = failures
|
||||
this.nextID = 1
|
||||
this.pending = new Map()
|
||||
}
|
||||
|
||||
async open() {
|
||||
await new Promise((resolve, reject) => {
|
||||
this.socket.addEventListener('open', resolve, { once: true })
|
||||
this.socket.addEventListener('error', reject, { once: true })
|
||||
})
|
||||
this.socket.addEventListener('message', event => {
|
||||
const message = JSON.parse(event.data)
|
||||
if (message.id) {
|
||||
const pending = this.pending.get(message.id)
|
||||
if (!pending) return
|
||||
this.pending.delete(message.id)
|
||||
if (message.error) pending.reject(new Error(message.error.message))
|
||||
else pending.resolve(message.result || {})
|
||||
return
|
||||
}
|
||||
if (message.method === 'Runtime.exceptionThrown') {
|
||||
this.failures.push(`page: ${message.params.exceptionDetails.text}`)
|
||||
}
|
||||
if (message.method === 'Runtime.consoleAPICalled' && message.params.type === 'error') {
|
||||
const text = message.params.args.map(item => item.value || item.description || '').join(' ')
|
||||
if (!text.includes('favicon')) this.failures.push(`console: ${text}`)
|
||||
}
|
||||
if (message.method === 'Network.responseReceived') {
|
||||
const response = message.params.response
|
||||
if (response.status >= 400 && !response.url.includes('favicon')) {
|
||||
this.failures.push(`http ${response.status}: ${response.url}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
await Promise.all([
|
||||
this.send('Page.enable'), this.send('Runtime.enable'), this.send('Network.enable')
|
||||
])
|
||||
await this.send('Emulation.setDeviceMetricsOverride', {
|
||||
width: 1440, height: 900, deviceScaleFactor: 1, mobile: false
|
||||
})
|
||||
}
|
||||
|
||||
send(method, params = {}) {
|
||||
const id = this.nextID++
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject })
|
||||
this.socket.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
async evaluate(expression) {
|
||||
const result = await this.send('Runtime.evaluate', {
|
||||
expression, returnByValue: true, awaitPromise: true
|
||||
})
|
||||
if (result.exceptionDetails) throw new Error(result.exceptionDetails.text)
|
||||
return result.result.value
|
||||
}
|
||||
|
||||
async navigate(url) {
|
||||
await this.send('Page.navigate', { url })
|
||||
await retry(async () => {
|
||||
if (await this.evaluate('document.readyState') !== 'complete') throw new Error('document not ready')
|
||||
}, `navigate ${url}`)
|
||||
}
|
||||
|
||||
async waitFor(expression, label) {
|
||||
return await retry(async () => {
|
||||
const value = await this.evaluate(expression)
|
||||
if (!value) throw new Error('condition is false')
|
||||
return value
|
||||
}, label)
|
||||
}
|
||||
|
||||
close() { this.socket.close() }
|
||||
}
|
||||
|
||||
async function createPage(debugPort, failures) {
|
||||
const target = await retry(async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${debugPort}/json/new?about:blank`, { method: 'PUT' })
|
||||
if (!response.ok) throw new Error(`Chrome target returned ${response.status}`)
|
||||
return await response.json()
|
||||
}, 'create Chrome target')
|
||||
const page = new PageSession(target.webSocketDebuggerUrl, failures)
|
||||
await page.open()
|
||||
return page
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const baseURL = required('SENSE_E2E_BASE_URL')
|
||||
const token = required('SENSE_E2E_TOKEN')
|
||||
const screenshot = required('SENSE_E2E_SCREENSHOT')
|
||||
const debugPort = await freePort()
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'sense-chrome-'))
|
||||
const chrome = spawn(required('SENSE_E2E_BROWSER'), [
|
||||
'--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
|
||||
`--remote-debugging-port=${debugPort}`, `--user-data-dir=${profile}`, 'about:blank'
|
||||
], { stdio: 'ignore', windowsHide: true })
|
||||
const failures = []
|
||||
try {
|
||||
const anonymous = await createPage(debugPort, failures)
|
||||
await anonymous.navigate(baseURL)
|
||||
await anonymous.waitFor('location.hash.includes("login")', 'anonymous redirect to login')
|
||||
await anonymous.waitFor('!!document.querySelector(\'input[name="username"]\')', 'username input')
|
||||
if (await anonymous.evaluate('[...document.querySelectorAll("input")].some(input => input.placeholder.includes("验证码"))')) {
|
||||
failures.push('login page unexpectedly exposes a captcha input')
|
||||
}
|
||||
anonymous.close()
|
||||
|
||||
const page = await createPage(debugPort, failures)
|
||||
await page.send('Network.setCookie', { name: 'Sense-Admin-Token', value: token, url: baseURL })
|
||||
await page.navigate(`${baseURL}/#/dashboard`)
|
||||
await page.waitFor('!!document.querySelector(".sidebar-container")', 'GoAdmin sidebar')
|
||||
await page.waitFor('!!document.querySelector(".navbar")', 'GoAdmin navbar')
|
||||
await page.waitFor('!!document.querySelector(".tags-view-container")', 'GoAdmin tags')
|
||||
await page.evaluate(`(() => {
|
||||
const title = [...document.querySelectorAll('.el-sub-menu__title')]
|
||||
.find(item => item.innerText.includes('视频感知'))
|
||||
if (title) title.click()
|
||||
})()`)
|
||||
await delay(300)
|
||||
|
||||
const expected = [
|
||||
['设备管理', '/sense/device'],
|
||||
['视频接入', '/sense/admission'],
|
||||
['视频服务', '/sense/media'],
|
||||
['实时监看', '/sense/liveview'],
|
||||
['区域与警戒线', '/sense/area']
|
||||
]
|
||||
const sidebarText = await page.evaluate('document.querySelector(".sidebar-container").innerText')
|
||||
for (const [label, route] of expected) {
|
||||
if (!sidebarText.includes(label)) failures.push(`missing menu: ${label}`)
|
||||
const clicked = await page.evaluate(`(() => {
|
||||
const item = [...document.querySelectorAll('.sidebar-container .el-menu-item')]
|
||||
.find(node => node.innerText.trim() === ${JSON.stringify(label)})
|
||||
if (!item) return false
|
||||
item.click()
|
||||
return true
|
||||
})()`)
|
||||
if (!clicked) failures.push(`menu is not clickable: ${label}`)
|
||||
else await page.waitFor(`location.hash.includes(${JSON.stringify(route)})`, `route ${route}`)
|
||||
if (!await page.evaluate('!!document.querySelector(".sidebar-container")')) {
|
||||
failures.push(`GoAdmin shell disappeared after ${label}`)
|
||||
}
|
||||
}
|
||||
for (const label of ['开发工具', '定时任务', '系统监控']) {
|
||||
if (sidebarText.includes(label)) failures.push(`unrelated menu is visible: ${label}`)
|
||||
}
|
||||
const captured = await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true })
|
||||
fs.mkdirSync(path.dirname(screenshot), { recursive: true })
|
||||
fs.writeFileSync(screenshot, captured.data, 'base64')
|
||||
page.close()
|
||||
} finally {
|
||||
chrome.kill()
|
||||
try { fs.rmSync(profile, { recursive: true, force: true }) } catch {}
|
||||
}
|
||||
if (failures.length) throw new Error([...new Set(failures)].join('\n'))
|
||||
console.log('Sense browser smoke passed: login, GoAdmin shell, five product routes, no unrelated menus.')
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error.message)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,327 @@
|
||||
param(
|
||||
[string]$PostgresBin = 'D:\pgsql17\bin',
|
||||
[string]$MediaMTX = 'C:\Users\ila20\Desktop\mediamtx\mediamtx.exe',
|
||||
[string]$Browser = 'C:\Program Files\Google\Chrome\Application\chrome.exe',
|
||||
[string]$PreparedPackageRoot = '',
|
||||
[switch]$KeepTemporary
|
||||
)
|
||||
if ($PSVersionTable.PSEdition -eq 'Core') {
|
||||
$legacyArguments = @('-NoProfile', '-File', $PSCommandPath, '-PostgresBin', $PostgresBin, '-MediaMTX', $MediaMTX, '-Browser', $Browser)
|
||||
if (-not [string]::IsNullOrWhiteSpace($PreparedPackageRoot)) { $legacyArguments += @('-PreparedPackageRoot', $PreparedPackageRoot) }
|
||||
if ($KeepTemporary) { $legacyArguments += '-KeepTemporary' }
|
||||
& powershell.exe @legacyArguments
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
|
||||
$sourceSense = Join-Path $repositoryRoot 'Sense'
|
||||
$temporary = Join-Path ([IO.Path]::GetTempPath()) ('sense-e2e-' + [guid]::NewGuid().ToString('N'))
|
||||
$repoCopy = Join-Path $temporary 'repo'
|
||||
$senseCopy = Join-Path $repoCopy 'Sense'
|
||||
$pgData = Join-Path $temporary 'postgres'
|
||||
$pgLog = Join-Path $temporary 'postgres.log'
|
||||
$pgCtlLog = Join-Path $temporary 'pg-ctl.log'
|
||||
$runtimeLog = Join-Path $temporary 'sense.out.log'
|
||||
$runtimeError = Join-Path $temporary 'sense.err.log'
|
||||
$fixtureLog = Join-Path $temporary 'fixture.out.log'
|
||||
$fixtureError = Join-Path $temporary 'fixture.err.log'
|
||||
$ffmpegLog = Join-Path $temporary 'ffmpeg.out.log'
|
||||
$ffmpegError = Join-Path $temporary 'ffmpeg.err.log'
|
||||
$stateFile = Join-Path $temporary 'profile-state.txt'
|
||||
$fixtureStatus = Join-Path $temporary 'fixture-status.json'
|
||||
$server = $null
|
||||
$fixture = $null
|
||||
$publisher = $null
|
||||
$pgStarted = $false
|
||||
$savedEnvironment = @{}
|
||||
|
||||
function Get-FreePort {
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||
try { $listener.Start(); return ([Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() }
|
||||
}
|
||||
function Get-UniqueFreePorts([int]$Count) {
|
||||
$ports = New-Object System.Collections.Generic.List[int]
|
||||
while ($ports.Count -lt $Count) {
|
||||
$candidate = Get-FreePort
|
||||
if (-not $ports.Contains($candidate)) { $ports.Add($candidate) }
|
||||
}
|
||||
return $ports.ToArray()
|
||||
}
|
||||
function New-RandomText([int]$Bytes = 32) {
|
||||
$buffer = New-Object byte[] $Bytes
|
||||
$generator = [Security.Cryptography.RandomNumberGenerator]::Create()
|
||||
try { $generator.GetBytes($buffer) } finally { $generator.Dispose() }
|
||||
return [Convert]::ToBase64String($buffer).TrimEnd('=').Replace('+', 'A').Replace('/', 'B')
|
||||
}
|
||||
function Set-TestEnvironment([string]$Name, [string]$Value) {
|
||||
if (-not $script:savedEnvironment.ContainsKey($Name)) {
|
||||
$script:savedEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name, 'Process')
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable($Name, $Value, 'Process')
|
||||
}
|
||||
function Wait-Http([string]$Uri, [int]$Attempts = 120) {
|
||||
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
|
||||
try {
|
||||
$response = Invoke-WebRequest -UseBasicParsing -Uri $Uri -TimeoutSec 1
|
||||
if ($response.StatusCode -eq 200) { return }
|
||||
} catch {}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
throw "HTTP endpoint did not become ready: $Uri"
|
||||
}
|
||||
function Wait-Tcp([int]$Port, [bool]$Open, [int]$Attempts = 120) {
|
||||
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
|
||||
$client = [Net.Sockets.TcpClient]::new()
|
||||
try {
|
||||
$task = $client.ConnectAsync('127.0.0.1', $Port)
|
||||
$connected = $task.Wait(250) -and $client.Connected
|
||||
} catch { $connected = $false } finally { $client.Dispose() }
|
||||
if ($connected -eq $Open) { return }
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw "TCP port $Port did not reach expected open=$Open state"
|
||||
}
|
||||
function Invoke-SenseJson {
|
||||
param([string]$Method, [string]$Path, $Body = $null, [string]$Token = '', [int]$ExpectedCode = 200)
|
||||
$headers = @{}
|
||||
if ($Token) { $headers.Authorization = "Bearer $Token" }
|
||||
$arguments = @{ Method = $Method; Uri = "$script:baseUrl$Path"; Headers = $headers; TimeoutSec = 15 }
|
||||
if ($null -ne $Body) {
|
||||
$arguments.ContentType = 'application/json; charset=utf-8'
|
||||
$arguments.Body = $Body | ConvertTo-Json -Depth 12 -Compress
|
||||
}
|
||||
try { $response = Invoke-RestMethod @arguments } catch {
|
||||
$safe = $_.Exception.Message -replace '(?i)(password|token)=[^\s;]+', '$1=<redacted>'
|
||||
throw "Sense request failed for $Method $Path`: $safe"
|
||||
}
|
||||
if ([int]$response.code -ne $ExpectedCode) {
|
||||
throw "Unexpected Sense code for $Method $Path`: expected $ExpectedCode, got $($response.code), message=$($response.msg)"
|
||||
}
|
||||
return $response
|
||||
}
|
||||
function Start-SensePackage([string]$PackageRoot) {
|
||||
$launcher = Join-Path $PackageRoot 'start-sense.bat'
|
||||
$process = Start-Process -FilePath 'cmd.exe' -ArgumentList '/d', '/c', "`"$launcher`"" -WorkingDirectory $PackageRoot -RedirectStandardOutput $runtimeLog -RedirectStandardError $runtimeError -WindowStyle Hidden -PassThru
|
||||
Wait-Http "$script:baseUrl/"
|
||||
return $process
|
||||
}
|
||||
function Stop-ProcessTree($Process) {
|
||||
if ($Process -and -not $Process.HasExited) { & taskkill.exe /PID $Process.Id /T /F 2>$null | Out-Null }
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($required in @(
|
||||
(Join-Path $PostgresBin 'initdb.exe'), (Join-Path $PostgresBin 'pg_ctl.exe'),
|
||||
(Join-Path $PostgresBin 'createdb.exe'), (Join-Path $PostgresBin 'psql.exe'),
|
||||
$MediaMTX, $Browser
|
||||
)) {
|
||||
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Required test dependency not found: $required" }
|
||||
}
|
||||
$ffmpeg = (Get-Command ffmpeg.exe -ErrorAction Stop).Source
|
||||
if ([string]::IsNullOrWhiteSpace($PreparedPackageRoot)) {
|
||||
New-Item -ItemType Directory -Path $repoCopy | Out-Null
|
||||
Copy-Item -LiteralPath $sourceSense -Destination $senseCopy -Recurse
|
||||
& git -C $repoCopy init --quiet
|
||||
& git -C $repoCopy config user.name 'Sense E2E'
|
||||
& git -C $repoCopy config user.email 'sense-e2e@invalid.local'
|
||||
& git -C $repoCopy commit --allow-empty --quiet -m 'temporary acceptance source'
|
||||
|
||||
Write-Host 'Building Sense Windows package in an isolated temporary copy...'
|
||||
& pwsh.exe -NoProfile -File (Join-Path $senseCopy 'scripts\build\build-windows.ps1') -MediaMTXPath $MediaMTX
|
||||
if ($LASTEXITCODE -ne 0) { throw 'isolated Windows package build failed' }
|
||||
$packageRoot = Join-Path $senseCopy 'dist\sense-windows-amd64'
|
||||
} else {
|
||||
$packageRoot = [IO.Path]::GetFullPath($PreparedPackageRoot)
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $packageRoot 'sense.exe'))) { throw 'prepared Sense package is invalid' }
|
||||
$senseCopy = [IO.Path]::GetFullPath((Join-Path $packageRoot '..\..'))
|
||||
Write-Host "Using prepared isolated package: $packageRoot"
|
||||
}
|
||||
|
||||
$pgPort, $sensePort, $rtspPort, $hlsPort, $webrtcPort, $webrtcUDPort, $mediaAPIPort, $onvifPort = Get-UniqueFreePorts 8
|
||||
$script:baseUrl = "http://127.0.0.1:$sensePort"
|
||||
Write-Host "Initializing isolated PostgreSQL on port $pgPort..."
|
||||
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U sense_e2e -A trust --encoding=UTF8 --no-locale | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'isolated PostgreSQL initdb failed' }
|
||||
# Do not synchronously invoke pg_ctl on Windows. Its persistent
|
||||
# cmd/postgres child inherits console handles and can keep PowerShell
|
||||
# waiting even after pg_ctl exits. Start it hidden, then poll the port.
|
||||
$pgStartArguments = "-D `"$pgData`" -l `"$pgLog`" -o `"-p $pgPort -h 127.0.0.1`" start"
|
||||
[void](Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgStartArguments -RedirectStandardOutput $pgCtlLog -RedirectStandardError (Join-Path $temporary 'pg-ctl.err.log') -WindowStyle Hidden -PassThru)
|
||||
Wait-Tcp -Port $pgPort -Open $true
|
||||
$pgStarted = $true
|
||||
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U sense_e2e sense_e2e
|
||||
if ($LASTEXITCODE -ne 0) { throw 'isolated Sense database creation failed' }
|
||||
Write-Host 'Isolated PostgreSQL database is ready.'
|
||||
|
||||
$mediaConfig = @(
|
||||
'logLevel: warn', 'api: true', "apiAddress: 127.0.0.1:$mediaAPIPort",
|
||||
'rtspTransports: [tcp]', "rtspAddress: 127.0.0.1:$rtspPort", "hlsAddress: 127.0.0.1:$hlsPort",
|
||||
"webrtcAddress: 127.0.0.1:$webrtcPort", "webrtcLocalUDPAddress: 127.0.0.1:$webrtcUDPort",
|
||||
'rtmp: false', 'srt: false', 'moq: false', 'metrics: false', 'paths:', ' fixture:'
|
||||
) -join "`n"
|
||||
[IO.File]::WriteAllText((Join-Path $packageRoot 'config\mediamtx.yml'), $mediaConfig, (New-Object Text.UTF8Encoding($false)))
|
||||
|
||||
$jwt = New-RandomText 48
|
||||
$bootstrap = New-RandomText 48
|
||||
$adminPassword = New-RandomText 18
|
||||
$credentialBytes = New-Object byte[] 32
|
||||
$credentialGenerator = [Security.Cryptography.RandomNumberGenerator]::Create()
|
||||
try { $credentialGenerator.GetBytes($credentialBytes) } finally { $credentialGenerator.Dispose() }
|
||||
$credentialKey = [Convert]::ToBase64String($credentialBytes)
|
||||
$cameraUser = 'fixture_' + (New-RandomText 8)
|
||||
$cameraPassword = New-RandomText 24
|
||||
$database = "host=127.0.0.1 port=$pgPort user=sense_e2e dbname=sense_e2e sslmode=disable"
|
||||
$environment = @{
|
||||
SENSE_HOST = '127.0.0.1'; SENSE_PORT = "$sensePort"; SENSE_DATABASE_URL = $database;
|
||||
SENSE_JWT_SECRET = $jwt; SENSE_BOOTSTRAP_TOKEN = $bootstrap;
|
||||
SENSE_CREDENTIAL_KEY = $credentialKey; SENSE_ONVIF_DISCOVERY_IP = '127.0.0.1';
|
||||
SENSE_ONVIF_ALLOWED_CIDRS = '127.0.0.0/8'; SENSE_MEDIAMTX_MODE = 'managed';
|
||||
SENSE_MEDIAMTX_BINARY = 'bin\mediamtx.exe'; SENSE_MEDIAMTX_CONFIG = 'config\mediamtx.yml';
|
||||
SENSE_MEDIAMTX_API = "http://127.0.0.1:$mediaAPIPort"; SENSE_WEB_ROOT = 'web';
|
||||
SENSE_AUTO_MIGRATE = 'true'; SENSE_POSTGRES_BIN = $PostgresBin;
|
||||
SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE = "http://127.0.0.1:$webrtcPort"
|
||||
}
|
||||
foreach ($item in $environment.GetEnumerator()) { Set-TestEnvironment $item.Key $item.Value }
|
||||
Write-Host 'Generated ephemeral runtime values without persisting credentials.'
|
||||
|
||||
[IO.File]::WriteAllText($stateFile, 'initial', (New-Object Text.UTF8Encoding($false)))
|
||||
foreach ($item in @{
|
||||
SENSE_E2E_ONVIF_PORT = "$onvifPort"; SENSE_E2E_RTSP_PORT = "$rtspPort";
|
||||
SENSE_E2E_CAMERA_USERNAME = $cameraUser; SENSE_E2E_CAMERA_PASSWORD = $cameraPassword;
|
||||
SENSE_E2E_PROFILE_STATE_FILE = $stateFile; SENSE_E2E_FIXTURE_STATUS_FILE = $fixtureStatus
|
||||
}.GetEnumerator()) { Set-TestEnvironment $item.Key $item.Value }
|
||||
Write-Host "Starting Digest ONVIF fixture on port $onvifPort..."
|
||||
$fixture = Start-Process -FilePath 'python.exe' -ArgumentList (Join-Path $PSScriptRoot '..\fixtures\onvif_digest_fixture.py') -RedirectStandardOutput $fixtureLog -RedirectStandardError $fixtureError -WindowStyle Hidden -PassThru
|
||||
for ($attempt = 0; $attempt -lt 40 -and -not (Test-Path $fixtureStatus); $attempt++) { Start-Sleep -Milliseconds 250 }
|
||||
if (-not (Test-Path $fixtureStatus)) { throw 'ONVIF fixture did not become ready' }
|
||||
|
||||
$server = Start-SensePackage $packageRoot
|
||||
Wait-Http "http://127.0.0.1:$mediaAPIPort/v3/config/global/get"
|
||||
$publisherArguments = @(
|
||||
'-hide_banner', '-loglevel', 'error', '-re', '-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=10',
|
||||
'-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', '-f', 'rtsp', '-rtsp_transport', 'tcp',
|
||||
"rtsp://127.0.0.1:$rtspPort/fixture"
|
||||
)
|
||||
$publisher = Start-Process -FilePath $ffmpeg -ArgumentList $publisherArguments -RedirectStandardOutput $ffmpegLog -RedirectStandardError $ffmpegError -WindowStyle Hidden -PassThru
|
||||
Start-Sleep -Seconds 2
|
||||
if ($publisher.HasExited) { throw 'synthetic RTSP publisher exited before the acceptance flow' }
|
||||
|
||||
$bootstrapResponse = Invoke-SenseJson POST '/api/v1/bootstrap' @{ username = 'acceptance-admin'; password = $adminPassword; nickName = 'Acceptance Admin' } '' 403
|
||||
# Bootstrap token is a header, so use the dedicated request without placing it in a body or URI.
|
||||
$bootstrapResponse = Invoke-RestMethod -Method POST -Uri "$baseUrl/api/v1/bootstrap" -Headers @{ 'X-Sense-Bootstrap-Token' = $bootstrap } -ContentType 'application/json; charset=utf-8' -Body (@{ username = 'acceptance-admin'; password = $adminPassword; nickName = 'Acceptance Admin' } | ConvertTo-Json -Compress)
|
||||
if ([int]$bootstrapResponse.code -ne 200) { throw 'administrator bootstrap failed' }
|
||||
$login = Invoke-SenseJson POST '/api/v1/login' @{ username = 'acceptance-admin'; password = $adminPassword }
|
||||
$token = [string]$login.token
|
||||
if ($token.Length -lt 20) { throw 'login did not return a usable token' }
|
||||
$unauthorized = Invoke-SenseJson GET '/api/v1/devices' $null '' 401
|
||||
|
||||
$deviceBody = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\fixtures\device-create.zh-CN.json') -Raw -Encoding utf8 | ConvertFrom-Json
|
||||
$created = Invoke-SenseJson POST '/api/v1/devices' $deviceBody $token
|
||||
$device = $created.data
|
||||
if ($device.name -ne $deviceBody.name -or $device.location -ne $deviceBody.location) { throw 'Chinese device fields did not round-trip' }
|
||||
$badBody = @{ name = 'reject-unknown-field'; location = 'fixture'; modality = 'video'; capabilities = @('video'); password = $cameraPassword }
|
||||
$bad = Invoke-SenseJson POST '/api/v1/devices' $badBody $token 400
|
||||
$credentials = Invoke-SenseJson PUT "/api/v1/devices/$($device.id)/credentials" @{
|
||||
onvifUsername = $cameraUser; onvifPassword = $cameraPassword; rtspSameAsOnvif = $false;
|
||||
rtspUsername = $cameraUser; rtspPassword = $cameraPassword; version = [int64]$device.version
|
||||
} $token
|
||||
$device = $credentials.data
|
||||
if (-not $device.onvifCredentialConfigured -or -not $device.rtspCredentialConfigured) { throw 'purpose-separated credentials were not recorded' }
|
||||
$probe = Invoke-SenseJson POST "/api/v1/admission/devices/$($device.id)/probe" @{ address = "http://127.0.0.1:$onvifPort/onvif/device"; version = [int64]$device.version } $token
|
||||
if ($probe.data.status -ne 'ready' -or $probe.data.profiles[0].verificationStatus -ne 'ready') { throw 'Digest ONVIF/RTSP probe did not become ready' }
|
||||
$fixtureEvidence = Get-Content -LiteralPath $fixtureStatus -Raw -Encoding utf8 | ConvertFrom-Json
|
||||
if ([int]$fixtureEvidence.digestRequests -lt 3) { throw 'ONVIF fixture did not observe Digest requests for the full SOAP flow' }
|
||||
|
||||
$routeList = Invoke-SenseJson GET '/api/v1/media/routes' $null $token
|
||||
$route = @($routeList.data.list)[0]
|
||||
if (-not $route.id -or $route.path -match '(?i)@|password|credential') { throw 'media route is missing or exposes credentials' }
|
||||
[void](Invoke-SenseJson POST "/api/v1/media/routes/$([Uri]::EscapeDataString($route.id))/reconcile" @{} $token)
|
||||
$consumerOut = Join-Path $temporary 'consumer.out.log'
|
||||
$consumerErr = Join-Path $temporary 'consumer.err.log'
|
||||
$consumer = Start-Process -FilePath $ffmpeg -ArgumentList @(
|
||||
'-hide_banner', '-loglevel', 'error', '-rtsp_transport', 'tcp', '-i', "rtsp://127.0.0.1:$rtspPort/$($route.path)",
|
||||
'-t', '2', '-f', 'null', 'NUL'
|
||||
) -RedirectStandardOutput $consumerOut -RedirectStandardError $consumerErr -WindowStyle Hidden -PassThru -Wait
|
||||
if ($consumer.ExitCode -ne 0) { throw 'on-demand MediaMTX route did not deliver the synthetic stream' }
|
||||
|
||||
$liveRoutes = Invoke-SenseJson GET '/api/v1/liveview/routes?pageIndex=1&pageSize=10' $null $token
|
||||
$liveRoute = @($liveRoutes.data.list)[0]
|
||||
$session = Invoke-SenseJson POST '/api/v1/liveview/sessions' @{ routeId = $liveRoute.id } $token
|
||||
$player = Invoke-WebRequest -UseBasicParsing -Uri "$baseUrl$($session.data.playerUrl)" -TimeoutSec 10
|
||||
if ($player.StatusCode -ne 200 -or -not $player.Content.Contains(":$webrtcPort/")) { throw 'live-view wrapper did not use the safe browser-visible MediaMTX address' }
|
||||
|
||||
$areaBody = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\fixtures\area-polygon.zh-CN.json') -Raw -Encoding utf8 | ConvertFrom-Json
|
||||
$areaBody | Add-Member -NotePropertyName routeId -NotePropertyValue $route.id
|
||||
$areaCreated = Invoke-SenseJson POST '/api/v1/area/configurations' $areaBody $token
|
||||
if ($areaCreated.data.name -ne $areaBody.name) { throw 'Chinese area fields did not round-trip' }
|
||||
|
||||
[IO.File]::WriteAllText($stateFile, 'changed', (New-Object Text.UTF8Encoding($false)))
|
||||
$currentDevice = (Invoke-SenseJson GET "/api/v1/devices/$($device.id)" $null $token).data
|
||||
$reprobe = Invoke-SenseJson POST "/api/v1/admission/devices/$($device.id)/probe" @{ address = "http://127.0.0.1:$onvifPort/onvif/device"; version = [int64]$currentDevice.version } $token
|
||||
if ($reprobe.data.profiles[0].width -ne 1280) { throw 'changed profile resolution was not persisted' }
|
||||
$areas = Invoke-SenseJson GET '/api/v1/area/configurations?pageIndex=1&pageSize=10' $null $token
|
||||
$area = @($areas.data.list | Where-Object id -eq $areaCreated.data.id)[0]
|
||||
if (-not $area.needsRecalibration) { throw 'resolution change did not mark the area for recalibration' }
|
||||
|
||||
$browserScript = Join-Path $senseCopy 'ui\sense-browser-smoke.cjs'
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'browser-smoke.cjs') -Destination $browserScript
|
||||
foreach ($item in @{
|
||||
SENSE_E2E_BASE_URL = $baseUrl; SENSE_E2E_TOKEN = $token; SENSE_E2E_BROWSER = $Browser;
|
||||
SENSE_E2E_SCREENSHOT = (Join-Path $temporary 'sense-browser.png')
|
||||
}.GetEnumerator()) { Set-TestEnvironment $item.Key $item.Value }
|
||||
Push-Location (Join-Path $senseCopy 'ui')
|
||||
try { & node.exe $browserScript } finally { Pop-Location }
|
||||
if ($LASTEXITCODE -ne 0) { throw 'browser GoAdmin shell smoke failed' }
|
||||
|
||||
& (Join-Path $packageRoot 'stop-sense.bat')
|
||||
Start-Sleep -Seconds 2
|
||||
if (-not $server.HasExited) { Stop-ProcessTree $server }
|
||||
$server = Start-SensePackage $packageRoot
|
||||
$routesAfterRestart = Invoke-SenseJson GET '/api/v1/media/routes' $null $token
|
||||
if (@($routesAfterRestart.data.list).Count -lt 1) { throw 'cold restart lost persisted media routes' }
|
||||
if ($publisher.HasExited) {
|
||||
$publisher = Start-Process -FilePath $ffmpeg -ArgumentList $publisherArguments -RedirectStandardOutput $ffmpegLog -RedirectStandardError $ffmpegError -WindowStyle Hidden -PassThru
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
[void](Invoke-SenseJson POST "/api/v1/media/routes/$([Uri]::EscapeDataString($route.id))/reconcile" @{} $token)
|
||||
$restartConsumer = Start-Process -FilePath $ffmpeg -ArgumentList @(
|
||||
'-hide_banner', '-loglevel', 'error', '-rtsp_transport', 'tcp', '-i', "rtsp://127.0.0.1:$rtspPort/$($route.path)",
|
||||
'-t', '2', '-f', 'null', 'NUL'
|
||||
) -RedirectStandardOutput (Join-Path $temporary 'restart-consumer.out.log') -RedirectStandardError (Join-Path $temporary 'restart-consumer.err.log') -WindowStyle Hidden -PassThru -Wait
|
||||
if ($restartConsumer.ExitCode -ne 0) { throw 'cold restart did not restore on-demand playback' }
|
||||
|
||||
$psql = Join-Path $PostgresBin 'psql.exe'
|
||||
$migrationCount = (& $psql -X -h 127.0.0.1 -p $pgPort -U sense_e2e -d sense_e2e -tAc 'select count(*) from sys_migration;').Trim()
|
||||
$capabilitiesType = (& $psql -X -h 127.0.0.1 -p $pgPort -U sense_e2e -d sense_e2e -tAc "select data_type from information_schema.columns where table_name='sense_devices' and column_name='capabilities';").Trim()
|
||||
if ([int]$migrationCount -lt 8 -or $capabilitiesType -ne 'jsonb') { throw 'clean PostgreSQL migration chain did not reach the Sense schema' }
|
||||
$plainCredentialCount = (& $psql -X -h 127.0.0.1 -p $pgPort -U sense_e2e -d sense_e2e -tAc "select count(*) from sense_device_credentials where position(convert_to('$cameraPassword','UTF8') in ciphertext) > 0;").Trim()
|
||||
if ([int]$plainCredentialCount -ne 0) { throw 'camera credential appeared in plaintext storage' }
|
||||
|
||||
foreach ($log in @($runtimeLog, $runtimeError, $fixtureLog, $fixtureError, $ffmpegLog, $ffmpegError)) {
|
||||
if (Test-Path $log) {
|
||||
$text = [string](Get-Content -LiteralPath $log -Raw -ErrorAction SilentlyContinue)
|
||||
if ($null -eq $text) { $text = '' }
|
||||
if ($text.Contains($adminPassword) -or $text.Contains($cameraPassword) -or $text.Contains($token)) { throw "runtime log exposed acceptance credentials: $log" }
|
||||
}
|
||||
}
|
||||
Write-Host 'Sense isolated E2E passed: clean PostgreSQL, login/RBAC, Chinese device, Digest ONVIF/RTSP, separated credentials, MediaMTX on-demand playback, live view, area recalibration, browser shell, cold restart and Windows stop.'
|
||||
} finally {
|
||||
Stop-ProcessTree $publisher
|
||||
Stop-ProcessTree $fixture
|
||||
Stop-ProcessTree $server
|
||||
if ($pgStarted) {
|
||||
$pgStopArguments = "-D `"$pgData`" -m fast stop"
|
||||
[void](Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgStopArguments -RedirectStandardOutput (Join-Path $temporary 'pg-stop.log') -RedirectStandardError (Join-Path $temporary 'pg-stop.err.log') -WindowStyle Hidden -PassThru)
|
||||
try { Wait-Tcp -Port $pgPort -Open $false -Attempts 40 } catch {}
|
||||
}
|
||||
foreach ($item in $savedEnvironment.GetEnumerator()) { [Environment]::SetEnvironmentVariable($item.Key, $item.Value, 'Process') }
|
||||
if (-not $KeepTemporary -and (Test-Path -LiteralPath $temporary)) {
|
||||
$resolved = [IO.Path]::GetFullPath($temporary)
|
||||
if (-not $resolved.StartsWith([IO.Path]::GetTempPath(), [StringComparison]::OrdinalIgnoreCase)) { throw "Unsafe temporary cleanup path: $resolved" }
|
||||
Remove-Item -LiteralPath $resolved -Recurse -Force
|
||||
} elseif ($KeepTemporary) {
|
||||
Write-Host "Kept isolated acceptance directory: $temporary"
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "东门危险区域",
|
||||
"kind": "polygon",
|
||||
"points": [
|
||||
{ "x": 0.12, "y": 0.18 },
|
||||
{ "x": 0.82, "y": 0.18 },
|
||||
{ "x": 0.76, "y": 0.78 },
|
||||
{ "x": 0.18, "y": 0.72 }
|
||||
],
|
||||
"direction": "",
|
||||
"enabled": true,
|
||||
"expectedVersion": 0
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "东门测试摄像机",
|
||||
"location": "一号楼东门入口",
|
||||
"modality": "video",
|
||||
"capabilities": [
|
||||
"video"
|
||||
]
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
"""Isolated ONVIF fixture for Sense acceptance tests.
|
||||
|
||||
Credentials are required through process environment and are never printed or
|
||||
persisted. The fixture validates RFC 7616 MD5 qop=auth so the test exercises
|
||||
Sense's real Digest retry instead of accepting an arbitrary header.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = int(os.environ["SENSE_E2E_ONVIF_PORT"])
|
||||
RTSP_PORT = int(os.environ["SENSE_E2E_RTSP_PORT"])
|
||||
USERNAME = os.environ["SENSE_E2E_CAMERA_USERNAME"]
|
||||
PASSWORD = os.environ["SENSE_E2E_CAMERA_PASSWORD"]
|
||||
STATE_PATH = Path(os.environ["SENSE_E2E_PROFILE_STATE_FILE"])
|
||||
STATUS_PATH = Path(os.environ["SENSE_E2E_FIXTURE_STATUS_FILE"])
|
||||
REALM = "sense-e2e-camera"
|
||||
NONCE = hashlib.sha256(os.urandom(32)).hexdigest()
|
||||
|
||||
|
||||
def _md5(value: str) -> str:
|
||||
return hashlib.md5(value.encode("utf-8"), usedforsecurity=False).hexdigest()
|
||||
|
||||
|
||||
def _parameters(value: str) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for match in re.finditer(r'(\w+)=(?:"([^"]*)"|([^,\s]+))', value):
|
||||
result[match.group(1).lower()] = match.group(2) or match.group(3)
|
||||
return result
|
||||
|
||||
|
||||
def _authorized(header: str, method: str, uri: str) -> bool:
|
||||
if not header.startswith("Digest "):
|
||||
return False
|
||||
values = _parameters(header[7:])
|
||||
if values.get("username") != USERNAME or values.get("realm") != REALM:
|
||||
return False
|
||||
if values.get("nonce") != NONCE or values.get("uri") != uri:
|
||||
return False
|
||||
ha1 = _md5(f"{USERNAME}:{REALM}:{PASSWORD}")
|
||||
ha2 = _md5(f"{method}:{uri}")
|
||||
if values.get("qop") == "auth":
|
||||
expected = _md5(
|
||||
f"{ha1}:{NONCE}:{values.get('nc', '')}:{values.get('cnonce', '')}:auth:{ha2}"
|
||||
)
|
||||
else:
|
||||
expected = _md5(f"{ha1}:{NONCE}:{ha2}")
|
||||
return values.get("response") == expected
|
||||
|
||||
|
||||
def _write_status(digest_requests: int) -> None:
|
||||
STATUS_PATH.write_text(
|
||||
json.dumps({"ready": True, "digestRequests": digest_requests}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
digest_requests = 0
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
length = min(int(self.headers.get("Content-Length", "0")), 2 << 20)
|
||||
body = self.rfile.read(length).decode("utf-8", errors="replace")
|
||||
authorization = self.headers.get("Authorization", "")
|
||||
if not _authorized(authorization, "POST", self.path):
|
||||
self.send_response(401)
|
||||
self.send_header(
|
||||
"WWW-Authenticate",
|
||||
f'Digest realm="{REALM}", nonce="{NONCE}", algorithm=MD5, qop="auth"',
|
||||
)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
type(self).digest_requests += 1
|
||||
_write_status(type(self).digest_requests)
|
||||
state = STATE_PATH.read_text(encoding="utf-8").strip()
|
||||
width, height = ((1280, 720) if state == "changed" else (1920, 1080))
|
||||
if "GetCapabilities" in body:
|
||||
payload = (
|
||||
"<Envelope><Body><GetCapabilitiesResponse><Capabilities><Media>"
|
||||
f"<XAddr>http://{HOST}:{PORT}/onvif/media</XAddr>"
|
||||
"</Media></Capabilities></GetCapabilitiesResponse></Body></Envelope>"
|
||||
)
|
||||
elif "GetProfiles" in body:
|
||||
payload = (
|
||||
'<Envelope><Body><GetProfilesResponse><Profiles token="main">'
|
||||
"<Name>主码流</Name><VideoEncoderConfiguration><Encoding>H264</Encoding>"
|
||||
f"<Resolution><Width>{width}</Width><Height>{height}</Height></Resolution>"
|
||||
"</VideoEncoderConfiguration></Profiles></GetProfilesResponse></Body></Envelope>"
|
||||
)
|
||||
elif "GetStreamUri" in body:
|
||||
payload = (
|
||||
"<Envelope><Body><GetStreamUriResponse><MediaUri>"
|
||||
f"<Uri>rtsp://{HOST}:{RTSP_PORT}/fixture</Uri>"
|
||||
"</MediaUri></GetStreamUriResponse></Body></Envelope>"
|
||||
)
|
||||
else:
|
||||
self.send_error(400)
|
||||
return
|
||||
encoded = payload.encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/soap+xml; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(encoded)))
|
||||
self.end_headers()
|
||||
self.wfile.write(encoded)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_write_status(0)
|
||||
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
@@ -0,0 +1,9 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listLocalEvents(query) {
|
||||
return request({ url: '/api/v1/local-events', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function getLocalEvent(id) {
|
||||
return request({ url: `/api/v1/local-events/${encodeURIComponent(id)}`, method: 'get' })
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listOperationProblems(query) {
|
||||
return request({ url: '/api/v1/operations', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function getOperationProblem(id) {
|
||||
return request({ url: `/api/v1/operations/${encodeURIComponent(id)}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function retryOperationProblem(id, expectedVersion) {
|
||||
return request({ url: `/api/v1/operations/${encodeURIComponent(id)}/retry`, method: 'post', data: { expectedVersion }})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import request from '@/utils/request'
|
||||
import axios from 'axios'
|
||||
import { getToken } from '@/utils/auth'
|
||||
|
||||
export function listProvisioningBatches(query) {
|
||||
return request({ url: '/api/v1/provisioning/batches', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function getProvisioningBatch(id) {
|
||||
return request({ url: `/api/v1/provisioning/batches/${id}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function createProvisioningBatch(data) {
|
||||
return request({ url: '/api/v1/provisioning/batches', method: 'post', data })
|
||||
}
|
||||
|
||||
export function executeProvisioningBatch(id, data) {
|
||||
return request({ url: `/api/v1/provisioning/batches/${id}/execute`, method: 'post', data })
|
||||
}
|
||||
|
||||
export function retryProvisioningFailures(id, data) {
|
||||
return request({ url: `/api/v1/provisioning/batches/${id}/retry-failed`, method: 'post', data })
|
||||
}
|
||||
|
||||
export function retryProvisioningItem(batchId, itemId, data) {
|
||||
return request({ url: `/api/v1/provisioning/batches/${batchId}/items/${itemId}/retry`, method: 'post', data })
|
||||
}
|
||||
|
||||
export async function downloadProvisioningBatch(id) {
|
||||
const baseURL = String(process.env.VUE_APP_BASE_API || '').replace(/\/$/, '')
|
||||
const response = await axios.get(`${baseURL}/api/v1/provisioning/batches/${id}/export`, {
|
||||
responseType: 'blob',
|
||||
headers: { Authorization: `Bearer ${getToken()}` }
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h3>本地事件候选</h3>
|
||||
<p>查看 Sense 在本机形成的匿名事件候选及证据处理状态。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" @click="loadEvents">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" show-icon class="boundary-alert">
|
||||
<template #title>本页不依赖 Bell</template>
|
||||
这里展示的是 Sense 本地事件候选,不代表已生成或已送达 Bell 预警;Bell 未安装或未启动时仍可正常查看。
|
||||
</el-alert>
|
||||
|
||||
<el-row :gutter="12" class="summary-row" aria-label="当前页事件概览">
|
||||
<el-col :xs="24" :sm="8"><div class="summary-item"><span>当前页候选</span><strong>{{ pageSummary.candidate }}</strong></div></el-col>
|
||||
<el-col :xs="24" :sm="8"><div class="summary-item"><span>当前页已确认</span><strong>{{ pageSummary.confirmed }}</strong></div></el-col>
|
||||
<el-col :xs="24" :sm="8"><div class="summary-item"><span>当前页证据失败</span><strong>{{ pageSummary.failed }}</strong></div></el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form :model="query" label-width="78px" class="filter-form" @submit.prevent="search">
|
||||
<el-form-item label="发生时间" class="time-field">
|
||||
<el-date-picker v-model="occurredRange" type="datetimerange" start-placeholder="开始时间" end-placeholder="结束时间" range-separator="至" :clearable="true" />
|
||||
</el-form-item>
|
||||
<el-form-item label="候选状态">
|
||||
<el-select v-model="query.candidateState" clearable placeholder="全部">
|
||||
<el-option label="候选" value="candidate" /><el-option label="已确认事件" value="confirmed" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="证据状态">
|
||||
<el-select v-model="query.evidenceState" clearable placeholder="全部">
|
||||
<el-option label="处理中" value="pending" /><el-option label="成功" value="success" /><el-option label="失败" value="failed" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="规则来源"><el-input v-model="query.ruleRef" clearable placeholder="规则编号" /></el-form-item>
|
||||
<el-form-item label="关键词"><el-input v-model="query.keyword" clearable placeholder="事件 / 摄像头 / 规则" @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item class="filter-actions">
|
||||
<el-button type="primary" :icon="Search" native-type="submit">查询</el-button>
|
||||
<el-button :icon="RefreshLeft" @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="events" border stripe empty-text="没有符合条件的本地事件候选">
|
||||
<el-table-column prop="occurredAt" label="发生时间" width="176">
|
||||
<template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="id" label="事件编号" min-width="205" show-overflow-tooltip />
|
||||
<el-table-column label="摄像头" min-width="145">
|
||||
<template #default="scope"><div>{{ scope.row.sourceRef }}</div><small>{{ scope.row.sourceLabel || '未命名位置' }}</small></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="规则来源" min-width="160">
|
||||
<template #default="scope"><div>{{ scope.row.ruleName }}</div><small>{{ scope.row.ruleRef }} {{ scope.row.ruleVersion }}</small></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="候选状态" width="118" align="center">
|
||||
<template #default="scope"><el-tag :type="candidateType(scope.row.candidateState)">{{ candidateLabel(scope.row.candidateState) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="证据状态" width="104" align="center">
|
||||
<template #default="scope"><el-tag :type="evidenceType(scope.row.evidenceState)">{{ evidenceLabel(scope.row.evidenceState) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="保留期限" width="142">
|
||||
<template #default="scope"><span :class="{ 'retention-warning': scope.row.retentionState === 'expiring' }">{{ retentionLabel(scope.row) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="94" fixed="right">
|
||||
<template #default="scope"><el-button v-permisaction="['sense:local-event:detail']" type="primary" link @click="openDetail(scope.row.id)">查看详情</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="loadEvents" />
|
||||
<p class="retention-help">保留策略由 Sense 服务端配置;列表显示每条记录的实际到期时间,到期清理策略不由本页面修改。列表和详情读取均受权限控制并进入系统操作审计。</p>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="detailOpen" title="本地事件详情" width="min(760px, calc(100vw - 24px))" :close-on-click-modal="false">
|
||||
<el-descriptions v-if="selected" :column="2" border class="detail-descriptions">
|
||||
<el-descriptions-item label="事件编号">{{ selected.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发生时间">{{ parseTime(selected.occurredAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="摄像头">{{ selected.sourceRef }} / {{ selected.sourceLabel || '未命名位置' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="规则来源">{{ selected.ruleName }} {{ selected.ruleVersion }}</el-descriptions-item>
|
||||
<el-descriptions-item label="区域引用">{{ selected.zoneRef || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="保留期限">{{ retentionLabel(selected) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="候选状态"><el-tag :type="candidateType(selected.candidateState)">{{ candidateLabel(selected.candidateState) }}</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="证据状态"><el-tag :type="evidenceType(selected.evidenceState)">{{ evidenceLabel(selected.evidenceState) }}</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="Bell 送达状态" :span="2"><strong>不适用</strong> — 本地候选未发送到 Bell,本页不推断 Bell Alert 状态。</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-alert v-if="selected" :title="evidenceTitle" :type="evidenceType(selected.evidenceState)" :closable="false" show-icon class="evidence-alert">
|
||||
{{ selected.evidenceDetail || '暂无更多证据处理说明' }}<template v-if="selected.evidenceRef"><br>本地证据引用:{{ selected.evidenceRef }}</template>
|
||||
</el-alert>
|
||||
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getLocalEvent, listLocalEvents } from '@/api/sense/local-event'
|
||||
import { buildLocalEventQuery, candidateLabel, candidateType, evidenceLabel, evidenceType, retentionLabel } from './localEventState'
|
||||
|
||||
defineOptions({ name: 'SenseLocalEvent' })
|
||||
|
||||
const loading = ref(false)
|
||||
const events = ref([])
|
||||
const total = ref(0)
|
||||
const detailOpen = ref(false)
|
||||
const selected = ref(null)
|
||||
const now = Date.now()
|
||||
const occurredRange = ref([new Date(now - 24 * 60 * 60 * 1000), new Date(now)])
|
||||
const query = reactive({ pageIndex: 1, pageSize: 10, candidateState: '', evidenceState: '', ruleRef: '', keyword: '' })
|
||||
const pageSummary = computed(() => events.value.reduce((value, item) => {
|
||||
if (item.candidateState === 'candidate') value.candidate += 1
|
||||
if (item.candidateState === 'confirmed') value.confirmed += 1
|
||||
if (item.evidenceState === 'failed') value.failed += 1
|
||||
return value
|
||||
}, { candidate: 0, confirmed: 0, failed: 0 }))
|
||||
const evidenceTitle = computed(() => `证据处理:${evidenceLabel(selected.value?.evidenceState)}`)
|
||||
|
||||
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
|
||||
|
||||
async function loadEvents() {
|
||||
loading.value = true
|
||||
try {
|
||||
const payload = unwrap(await listLocalEvents(buildLocalEventQuery(query, occurredRange.value)))
|
||||
events.value = payload?.list || payload?.data || []
|
||||
total.value = payload?.count || 0
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '本地事件查询失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() { query.pageIndex = 1; loadEvents() }
|
||||
function reset() {
|
||||
query.pageIndex = 1; query.candidateState = ''; query.evidenceState = ''; query.ruleRef = ''; query.keyword = ''
|
||||
const current = Date.now(); occurredRange.value = [new Date(current - 24 * 60 * 60 * 1000), new Date(current)]
|
||||
loadEvents()
|
||||
}
|
||||
async function openDetail(id) {
|
||||
try {
|
||||
selected.value = unwrap(await getLocalEvent(id))
|
||||
detailOpen.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '本地事件详情查询失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadEvents)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.page-header h3{margin:0 0 6px}.page-header p{margin:0;color:#909399}.boundary-alert{margin:16px 0}.summary-row{margin-bottom:16px}.summary-item{display:flex;align-items:center;justify-content:space-between;min-height:68px;padding:12px 16px;border:1px solid #ebeef5;border-radius:4px}.summary-item span{color:#606266}.summary-item strong{font-size:24px}.filter-form{display:flex;align-items:flex-end;flex-wrap:wrap;gap:0 12px;margin-bottom:2px}.filter-form .el-form-item{width:210px}.filter-form .time-field{width:420px}.filter-form .filter-actions{width:auto}.filter-form :deep(.el-select),.filter-form :deep(.el-date-editor){width:100%}small{color:#909399}.retention-warning{color:#e6a23c}.retention-help{margin:12px 0 0;color:#909399;font-size:12px}.evidence-alert{margin-top:16px}@media (max-width:768px){.filter-form .el-form-item,.filter-form .time-field{width:100%}.detail-descriptions :deep(.el-descriptions__body .el-descriptions__table){display:block;overflow-x:auto}}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
export const candidateLabels = { candidate: '候选', confirmed: '已确认事件' }
|
||||
export const evidenceLabels = { pending: '处理中', success: '成功', failed: '失败' }
|
||||
|
||||
export function candidateLabel(value) {
|
||||
return candidateLabels[value] || value || '-'
|
||||
}
|
||||
|
||||
export function candidateType(value) {
|
||||
return value === 'confirmed' ? 'success' : 'primary'
|
||||
}
|
||||
|
||||
export function evidenceLabel(value) {
|
||||
return evidenceLabels[value] || value || '-'
|
||||
}
|
||||
|
||||
export function evidenceType(value) {
|
||||
return ({ pending: 'warning', success: 'success', failed: 'danger' })[value] || 'info'
|
||||
}
|
||||
|
||||
export function buildLocalEventQuery(query, occurredRange) {
|
||||
const result = {
|
||||
pageIndex: query.pageIndex,
|
||||
pageSize: query.pageSize,
|
||||
candidateState: query.candidateState || undefined,
|
||||
evidenceState: query.evidenceState || undefined,
|
||||
ruleRef: String(query.ruleRef || '').trim() || undefined,
|
||||
keyword: String(query.keyword || '').trim() || undefined
|
||||
}
|
||||
if (occurredRange?.length === 2) {
|
||||
result.occurredAfter = new Date(occurredRange[0]).toISOString()
|
||||
result.occurredBefore = new Date(occurredRange[1]).toISOString()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function retentionLabel(item) {
|
||||
if (!item?.retainUntil) return '-'
|
||||
if (item.retentionState === 'expired') return '已到期'
|
||||
if (item.retentionState === 'expiring') return `${item.remainingDays} 天内到期`
|
||||
return `保留至 ${String(item.retainUntil).slice(0, 10)}`
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h3>运维中心</h3>
|
||||
<p>集中查看设备、媒体和可选本地推理的期望态、实际态与未收敛问题。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="load">刷新状态</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" show-icon class="boundary-alert">
|
||||
<template #title>本地推理是可选能力</template>
|
||||
Brain 未安装时显示“不可用(未安装)”,不会导致页面失败,也不会阻断设备和媒体运维。运维异常不会发送到 Bell 作为业务预警。
|
||||
</el-alert>
|
||||
|
||||
<el-row :gutter="12" class="summary-row" aria-label="运维概览">
|
||||
<el-col :xs="24" :sm="8">
|
||||
<div class="summary-item"><span>设备与媒体已收敛</span><strong>{{ summary.convergedCount }} / {{ summary.managedCount }}</strong></div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<div class="summary-item"><span>待处理问题</span><strong class="warning-number">{{ summary.actionableProblems }}</strong></div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<div class="summary-item"><span>本地推理适配器</span><strong class="adapter-state">{{ inferenceState }}</strong></div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="section-header">
|
||||
<div><h4>问题队列</h4><p>默认只读诊断;只有明确可重试的项目才显示受控重试。</p></div>
|
||||
</div>
|
||||
|
||||
<el-form :model="query" label-width="76px" class="filter-form" @submit.prevent="search">
|
||||
<el-form-item label="对象类型">
|
||||
<el-select v-model="query.objectType" clearable placeholder="全部">
|
||||
<el-option label="设备" value="device" /><el-option label="媒体" value="media" /><el-option label="本地推理" value="local_inference" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="问题类型">
|
||||
<el-select v-model="query.problemType" clearable placeholder="全部">
|
||||
<el-option label="认证失败" value="authentication_failed" /><el-option label="退避等待" value="backoff_wait" />
|
||||
<el-option label="时间漂移" value="clock_drift" /><el-option label="孤儿安全闸" value="orphan_safety_gate" />
|
||||
<el-option label="能力未安装" value="capability_unavailable" /><el-option label="尚未收敛" value="not_converged" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="级别">
|
||||
<el-select v-model="query.severity" clearable placeholder="全部">
|
||||
<el-option label="高" value="high" /><el-option label="中" value="medium" /><el-option label="提示" value="info" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词"><el-input v-model="query.keyword" clearable placeholder="设备、位置或差异" @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item class="filter-actions">
|
||||
<el-button type="primary" :icon="Search" native-type="submit">查询</el-button>
|
||||
<el-button :icon="RefreshLeft" @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="problems" border stripe empty-text="当前筛选条件下没有未收敛问题">
|
||||
<el-table-column label="对象" min-width="170">
|
||||
<template #default="scope"><strong>{{ scope.row.objectName }}</strong><div class="muted">{{ objectLabel(scope.row.objectType) }} · {{ scope.row.location || scope.row.objectId }}</div></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="问题" min-width="138"><template #default="scope"><div>{{ problemLabel(scope.row.problemType) }}</div><small>{{ scope.row.id }}</small></template></el-table-column>
|
||||
<el-table-column label="期望态" min-width="110"><template #default="scope">{{ stateLabel(scope.row.expected) }}</template></el-table-column>
|
||||
<el-table-column label="实际态" min-width="120"><template #default="scope"><el-tag size="small" :type="severityType(scope.row.severity)">{{ stateLabel(scope.row.actual) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="difference" label="未收敛差异" min-width="210" show-overflow-tooltip />
|
||||
<el-table-column label="下次动作" min-width="190"><template #default="scope"><div>{{ scope.row.nextAction }}</div><small v-if="scope.row.nextRetryAt">自动重试:{{ formatTime(scope.row.nextRetryAt) }}</small></template></el-table-column>
|
||||
<el-table-column label="级别" width="78" align="center"><template #default="scope"><el-tag size="small" :type="severityType(scope.row.severity)">{{ severityLabel(scope.row.severity) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="156" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button v-permisaction="['sense:operations:detail']" type="primary" link @click="openDetail(scope.row.id)">详情</el-button>
|
||||
<el-button v-if="canRetry(scope.row)" v-permisaction="['sense:operations:retry']" type="warning" link @click="confirmRetry(scope.row)">受控重试</el-button>
|
||||
<span v-else-if="scope.row.retryInProgress" class="muted">重试已排队</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
<p class="safety-help">受控重试会校验权限、当前版本和并发占用,并写入操作审计。孤儿资源仅隔离和提示,本页没有删除操作。</p>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="detailOpen" title="状态详情" width="min(760px, calc(100vw - 24px))" :close-on-click-modal="false">
|
||||
<el-descriptions v-if="selected" :column="2" border>
|
||||
<el-descriptions-item label="问题编号">{{ selected.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="对象">{{ selected.objectName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="期望态">{{ stateLabel(selected.expected) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="实际态">{{ stateLabel(selected.actual) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="未收敛差异" :span="2">{{ selected.difference }}</el-descriptions-item>
|
||||
<el-descriptions-item label="退避 / 下次重试">{{ selected.nextRetryAt ? formatTime(selected.nextRetryAt) : '无退避' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="尝试次数">{{ selected.attemptCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="建议处理" :span="2">{{ selected.nextAction }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-alert v-if="selected" title="安全闸已启用" type="warning" :closable="false" show-icon class="safety-alert">{{ selected.safetyGate }}</el-alert>
|
||||
<p class="boundary-text">Brain/Bell 状态不会参与本地设备和媒体重试判定。</p>
|
||||
<template #footer>
|
||||
<el-button @click="detailOpen = false">关闭</el-button>
|
||||
<el-button v-if="canRetry(selected)" v-permisaction="['sense:operations:retry']" type="warning" @click="confirmRetry(selected)">受控重试</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getOperationProblem, listOperationProblems, retryOperationProblem } from '@/api/sense/operations'
|
||||
import { buildOperationsQuery, canRetry, objectLabel, problemLabel, severityLabel, severityType, stateLabel } from './operationsState'
|
||||
|
||||
defineOptions({ name: 'SenseOperations' })
|
||||
|
||||
const loading = ref(false)
|
||||
const problems = ref([])
|
||||
const total = ref(0)
|
||||
const summary = reactive({ managedCount: 0, convergedCount: 0, actionableProblems: 0, localInference: 'unavailable' })
|
||||
const query = reactive({ pageIndex: 1, pageSize: 10, objectType: '', problemType: '', severity: '', keyword: '' })
|
||||
const detailOpen = ref(false)
|
||||
const selected = ref(null)
|
||||
const inferenceState = ref('不可用(未安装)')
|
||||
|
||||
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
|
||||
function formatTime(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const payload = unwrap(await listOperationProblems(buildOperationsQuery(query))) || {}
|
||||
problems.value = payload.list || []
|
||||
total.value = payload.count || 0
|
||||
Object.assign(summary, payload.summary || { managedCount: 0, convergedCount: 0, actionableProblems: 0 })
|
||||
inferenceState.value = summary.localInference === 'configured' ? '已配置' : '不可用(未安装)'
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '运维状态加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() { query.pageIndex = 1; load() }
|
||||
function reset() { Object.assign(query, { pageIndex: 1, objectType: '', problemType: '', severity: '', keyword: '' }); load() }
|
||||
|
||||
async function openDetail(id) {
|
||||
try {
|
||||
selected.value = unwrap(await getOperationProblem(id))
|
||||
detailOpen.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '状态详情加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRetry(item) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`将对“${item.objectName}”发起一次重试。此操作不会修改凭据,也不会删除媒体资源。`, '确认受控重试', {
|
||||
type: 'warning', confirmButtonText: '确认重试', cancelButtonText: '取消'
|
||||
})
|
||||
await retryOperationProblem(item.id, item.version)
|
||||
ElMessage.success('重试任务已排队')
|
||||
detailOpen.value = false
|
||||
await load()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.warning(error.message || '重试未排队,请刷新状态后再试')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header,.section-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.page-header h3,.section-header h4{margin:0 0 6px}.page-header p,.section-header p{margin:0;color:#909399}.boundary-alert{margin:16px 0}.summary-row{margin-bottom:20px}.summary-item{display:flex;align-items:center;justify-content:space-between;min-height:72px;padding:12px 16px;border:1px solid #ebeef5;border-radius:4px}.summary-item span{color:#606266}.summary-item strong{font-size:22px}.summary-item .warning-number{color:#e6a23c}.summary-item .adapter-state{font-size:15px;color:#909399}.filter-form{display:flex;align-items:flex-end;flex-wrap:wrap;gap:0 12px;margin:16px 0 2px}.filter-form .el-form-item{width:210px}.filter-form .filter-actions{width:auto}.filter-form :deep(.el-select){width:100%}.muted,small{color:#909399;font-size:12px}.safety-help,.boundary-text{margin:12px 0 0;color:#909399;font-size:12px}.safety-alert{margin-top:16px}@media(max-width:768px){.filter-form .el-form-item{width:100%}.page-header{align-items:stretch;flex-direction:column}.summary-item{margin-bottom:8px}}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
const objectLabels = {
|
||||
device: '设备',
|
||||
media: '媒体',
|
||||
local_inference: '本地推理'
|
||||
}
|
||||
|
||||
const problemLabels = {
|
||||
authentication_failed: '认证失败',
|
||||
backoff_wait: '退避等待',
|
||||
clock_drift: '时间漂移',
|
||||
orphan_safety_gate: '孤儿安全闸',
|
||||
capability_unavailable: '能力未安装',
|
||||
not_converged: '尚未收敛'
|
||||
}
|
||||
|
||||
const stateLabels = {
|
||||
active: '已启用',
|
||||
ready: '就绪',
|
||||
running: '运行',
|
||||
stopped: '已停止',
|
||||
waiting: '等待拉流',
|
||||
pending: '等待处理',
|
||||
retry_pending: '重试已排队',
|
||||
authentication_failed: '认证失败',
|
||||
clock_drift: '时间漂移',
|
||||
apply_failed: '配置失败',
|
||||
process_unavailable: '进程不可用',
|
||||
status_unavailable: '状态不可用',
|
||||
unavailable: '不可用(未安装)'
|
||||
}
|
||||
|
||||
export function objectLabel(value) { return objectLabels[value] || value || '未知' }
|
||||
export function problemLabel(value) { return problemLabels[value] || value || '未知问题' }
|
||||
export function stateLabel(value) {
|
||||
return String(value || '').split(' / ').map(item => stateLabels[item] || item).join(' / ') || '未知'
|
||||
}
|
||||
export function severityLabel(value) { return ({ high: '高', medium: '中', info: '提示' })[value] || value || '未知' }
|
||||
export function severityType(value) { return ({ high: 'danger', medium: 'warning', info: 'info' })[value] || 'info' }
|
||||
export function canRetry(item) { return Boolean(item?.retryable && !item?.retryInProgress && item?.version > 0) }
|
||||
|
||||
export function buildOperationsQuery(query) {
|
||||
return {
|
||||
pageIndex: Number(query.pageIndex) || 1,
|
||||
pageSize: Number(query.pageSize) || 10,
|
||||
objectType: String(query.objectType || '').trim(),
|
||||
problemType: String(query.problemType || '').trim(),
|
||||
severity: String(query.severity || '').trim(),
|
||||
keyword: String(query.keyword || '').trim()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h3>摄像头批量开通</h3>
|
||||
<p>按模板导入、检查配额、临时填写凭据并执行开通;密码不会保存在批次记录中。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" @click="loadBatches">刷新记录</el-button>
|
||||
</div>
|
||||
|
||||
<el-steps :active="activeStep" finish-status="success" simple class="workflow-steps">
|
||||
<el-step title="导入清单" />
|
||||
<el-step title="预校验" />
|
||||
<el-step title="填写凭据" />
|
||||
<el-step title="查看结果" />
|
||||
</el-steps>
|
||||
|
||||
<el-alert title="默认交付配额为 16 路,实际可开通数量以服务端预校验结果为准。模板不得包含账号或密码列。" type="info" :closable="false" show-icon />
|
||||
|
||||
<div class="toolbar">
|
||||
<el-upload v-permisaction="['sense:provisioning:import']" :auto-upload="false" :show-file-list="false" accept=".xlsx,.xls,.csv" :on-change="handleFile">
|
||||
<el-button type="primary" :icon="Upload">选择清单</el-button>
|
||||
</el-upload>
|
||||
<el-button :icon="Download" @click="downloadTemplate">下载模板</el-button>
|
||||
<span class="file-name">{{ fileName || '尚未选择文件' }}</span>
|
||||
</div>
|
||||
|
||||
<el-table v-if="importRows.length" :data="importRows" border stripe max-height="360">
|
||||
<el-table-column prop="lineNumber" label="行号" width="80" />
|
||||
<el-table-column prop="name" label="设备名称" min-width="150" />
|
||||
<el-table-column prop="location" label="安装位置" min-width="160" />
|
||||
<el-table-column prop="address" label="ONVIF 地址" min-width="250" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<div v-if="importRows.length" class="import-actions">
|
||||
<el-button v-permisaction="['sense:provisioning:import']" type="primary" :loading="importing" @click="submitImport">提交预校验</el-button>
|
||||
<span>共 {{ importRows.length }} 条;提交后服务端会检查格式、重复地址和剩余配额。</span>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
<div class="section-title">
|
||||
<h4>开通批次</h4>
|
||||
<el-select v-model="statusFilter" placeholder="全部状态" clearable @change="loadBatches">
|
||||
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="batches" border stripe>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||
<template #default="scope">{{ parseTime(scope.row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="进度" min-width="190">
|
||||
<template #default="scope">总数 {{ scope.row.totalCount }} / 可执行 {{ scope.row.readyCount }} / 成功 {{ scope.row.successCount }} / 失败 {{ scope.row.failureCount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="配额" width="140">
|
||||
<template #default="scope">已用 {{ scope.row.existingCount }} / {{ scope.row.quotaLimit }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110" align="center">
|
||||
<template #default="scope"><el-tag :type="statusType(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="230" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" link @click="openBatch(scope.row.id)">查看</el-button>
|
||||
<el-button v-if="scope.row.readyCount" v-permisaction="['sense:provisioning:execute']" type="primary" link @click="openCredentials(scope.row.id, false)">填写凭据</el-button>
|
||||
<el-button v-if="scope.row.failureCount" v-permisaction="['sense:provisioning:retry']" type="warning" link @click="openCredentials(scope.row.id, true)">重试失败</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="loadBatches" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="detailOpen" title="批次详情" width="min(980px, calc(100vw - 24px))" :close-on-click-modal="false">
|
||||
<div v-if="selectedBatch" class="summary-cards" role="status" aria-live="polite">
|
||||
<el-tag>{{ statusLabel(selectedBatch.status) }}</el-tag>
|
||||
<span>配额 {{ selectedBatch.existingCount }} / {{ selectedBatch.quotaLimit }}</span>
|
||||
<span>成功 {{ selectedBatch.successCount }}</span>
|
||||
<span>失败 {{ selectedBatch.failureCount }}</span>
|
||||
</div>
|
||||
<el-table v-if="selectedBatch" :data="selectedBatch.items" border stripe max-height="470">
|
||||
<el-table-column prop="lineNumber" label="行号" width="70" />
|
||||
<el-table-column prop="name" label="设备名称" min-width="130" />
|
||||
<el-table-column prop="location" label="安装位置" min-width="130" />
|
||||
<el-table-column prop="address" label="地址" min-width="210" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="110"><template #default="scope"><el-tag :type="statusType(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="detail" label="说明" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="90" fixed="right"><template #default="scope"><el-button v-if="scope.row.status === 'failed'" v-permisaction="['sense:provisioning:retry']" type="warning" link @click="openSingleRetry(scope.row)">重试</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button v-permisaction="['sense:provisioning:export']" :icon="Download" @click="exportSelected">导出结果</el-button>
|
||||
<el-button @click="detailOpen = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="credentialOpen" :title="singleItemId ? '重试单个失败项' : retryMode ? '重试失败项' : '填写临时凭据并开通'" width="min(1180px, calc(100vw - 24px))" :close-on-click-modal="false" @closed="clearCredentials">
|
||||
<el-alert title="凭据仅用于本次请求,提交成功或关闭窗口后立即从页面内存清除。" type="warning" :closable="false" show-icon />
|
||||
<el-table :data="credentialItems" border stripe max-height="500" class="credential-table">
|
||||
<el-table-column prop="name" label="设备" min-width="130" />
|
||||
<el-table-column prop="address" label="地址" min-width="190" show-overflow-tooltip />
|
||||
<el-table-column label="ONVIF 用户名" min-width="145"><template #default="scope"><el-input v-model="credentials[scope.row.id].onvifUsername" autocomplete="off" /></template></el-table-column>
|
||||
<el-table-column label="ONVIF 密码" min-width="160"><template #default="scope"><el-input v-model="credentials[scope.row.id].onvifPassword" type="password" show-password autocomplete="new-password" /></template></el-table-column>
|
||||
<el-table-column label="RTSP" width="120"><template #default="scope"><el-checkbox v-model="credentials[scope.row.id].rtspSameAsOnvif">同上</el-checkbox></template></el-table-column>
|
||||
<el-table-column label="RTSP 用户名" min-width="145"><template #default="scope"><el-input v-model="credentials[scope.row.id].rtspUsername" :disabled="credentials[scope.row.id].rtspSameAsOnvif" autocomplete="off" /></template></el-table-column>
|
||||
<el-table-column label="RTSP 密码" min-width="160"><template #default="scope"><el-input v-model="credentials[scope.row.id].rtspPassword" :disabled="credentials[scope.row.id].rtspSameAsOnvif" type="password" show-password autocomplete="new-password" /></template></el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button type="primary" :loading="executing" @click="executeBatch">确认执行</el-button>
|
||||
<el-button @click="credentialOpen = false">取消</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Download, Refresh, Upload } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { createProvisioningBatch, downloadProvisioningBatch, executeProvisioningBatch, getProvisioningBatch, listProvisioningBatches, retryProvisioningFailures, retryProvisioningItem } from '@/api/sense/provisioning'
|
||||
import { clearCredentialMap, createBatchPayload, credentialPayload, normalizeImportRows } from './provisioningPayload'
|
||||
|
||||
defineOptions({ name: 'SenseProvisioning' })
|
||||
|
||||
const loading = ref(false); const importing = ref(false); const executing = ref(false)
|
||||
const batches = ref([]); const total = ref(0); const statusFilter = ref(''); const fileName = ref(''); const importRows = ref([])
|
||||
const selectedBatch = ref(null); const detailOpen = ref(false); const credentialOpen = ref(false); const retryMode = ref(false)
|
||||
const singleItemId = ref('')
|
||||
const credentials = reactive({}); const query = reactive({ pageIndex: 1, pageSize: 10 })
|
||||
const statusOptions = [{ value: 'ready', label: '待执行' }, { value: 'partial', label: '部分成功' }, { value: 'succeeded', label: '已完成' }, { value: 'failed', label: '失败' }]
|
||||
const activeStep = computed(() => credentialOpen.value ? 2 : selectedBatch.value?.status === 'succeeded' || selectedBatch.value?.status === 'partial' ? 3 : importRows.value.length ? 1 : 0)
|
||||
const credentialItems = computed(() => (selectedBatch.value?.items || []).filter(item => singleItemId.value ? item.id === singleItemId.value : retryMode.value ? item.status === 'failed' : item.status === 'ready'))
|
||||
|
||||
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
|
||||
function statusLabel(status) { return ({ ready: '待执行', running: '执行中', succeeded: '已完成', partial: '部分成功', failed: '失败', invalid: '格式错误', quota_exceeded: '超出配额' })[status] || status }
|
||||
function statusType(status) { return ({ succeeded: 'success', partial: 'warning', failed: 'danger', invalid: 'danger', quota_exceeded: 'info', ready: 'primary' })[status] || 'info' }
|
||||
function saveText(content, name, type = 'text/csv;charset=utf-8') { const url = URL.createObjectURL(new Blob([content], { type })); const link = document.createElement('a'); link.href = url; link.download = name; link.click(); URL.revokeObjectURL(url) }
|
||||
function saveBlob(content, name) { const url = URL.createObjectURL(content); const link = document.createElement('a'); link.href = url; link.download = name; link.click(); URL.revokeObjectURL(url) }
|
||||
|
||||
async function loadBatches() {
|
||||
loading.value = true
|
||||
try {
|
||||
const payload = unwrap(await listProvisioningBatches({ ...query, status: statusFilter.value }))
|
||||
batches.value = payload?.list || payload?.data || []
|
||||
total.value = payload?.count || 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
async function handleFile(file) {
|
||||
try {
|
||||
const workbook = XLSX.read(await file.raw.arrayBuffer(), { type: 'array' })
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]]
|
||||
const rows = XLSX.utils.sheet_to_json(sheet, { defval: '' })
|
||||
importRows.value = normalizeImportRows(rows)
|
||||
fileName.value = file.name
|
||||
if (!importRows.value.length) ElMessage.warning('清单中没有可导入的数据')
|
||||
} catch (error) { ElMessage.error(error.message || '无法读取清单文件') }
|
||||
}
|
||||
async function submitImport() {
|
||||
if (!importRows.value.length) return ElMessage.warning('请先选择清单')
|
||||
importing.value = true
|
||||
try {
|
||||
const key = `sense-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
selectedBatch.value = unwrap(await createProvisioningBatch(createBatchPayload(importRows.value, key)))
|
||||
detailOpen.value = true
|
||||
ElMessage.success('预校验完成')
|
||||
await loadBatches()
|
||||
} catch (error) { ElMessage.error(error.message || '清单预校验失败') } finally { importing.value = false }
|
||||
}
|
||||
async function openBatch(id) { selectedBatch.value = unwrap(await getProvisioningBatch(id)); detailOpen.value = true }
|
||||
async function openCredentials(id, retry) {
|
||||
selectedBatch.value = unwrap(await getProvisioningBatch(id)); retryMode.value = retry; singleItemId.value = ''
|
||||
credentialItems.value.forEach(item => { credentials[item.id] = { onvifUsername: '', onvifPassword: '', rtspSameAsOnvif: true, rtspUsername: '', rtspPassword: '' } })
|
||||
credentialOpen.value = true
|
||||
}
|
||||
function openSingleRetry(item) { retryMode.value = true; singleItemId.value = item.id; credentials[item.id] = { onvifUsername: '', onvifPassword: '', rtspSameAsOnvif: true, rtspUsername: '', rtspPassword: '' }; detailOpen.value = false; credentialOpen.value = true }
|
||||
function clearCredentials() { clearCredentialMap(credentials); Object.keys(credentials).forEach(key => delete credentials[key]); singleItemId.value = '' }
|
||||
async function executeBatch() {
|
||||
if (credentialItems.value.some(item => !credentials[item.id]?.onvifUsername || !credentials[item.id]?.onvifPassword)) return ElMessage.warning('请填写所有待处理设备的 ONVIF 用户名和密码')
|
||||
if (credentialItems.value.some(item => credentials[item.id]?.rtspSameAsOnvif === false && (!credentials[item.id]?.rtspUsername || !credentials[item.id]?.rtspPassword))) return ElMessage.warning('请填写所有独立 RTSP 凭据')
|
||||
executing.value = true
|
||||
try {
|
||||
const payload = credentialPayload(credentialItems.value, credentials)
|
||||
const action = singleItemId.value
|
||||
? retryProvisioningItem(selectedBatch.value.id, singleItemId.value, payload)
|
||||
: retryMode.value
|
||||
? retryProvisioningFailures(selectedBatch.value.id, payload)
|
||||
: executeProvisioningBatch(selectedBatch.value.id, payload)
|
||||
selectedBatch.value = unwrap(await action)
|
||||
clearCredentials(); credentialOpen.value = false; detailOpen.value = true
|
||||
ElMessage.success('批量开通处理完成')
|
||||
await loadBatches()
|
||||
} catch (error) { clearCredentials(); ElMessage.error(error.message || '批量开通失败') } finally { executing.value = false }
|
||||
}
|
||||
async function exportSelected() { if (!selectedBatch.value) return; try { saveBlob(await downloadProvisioningBatch(selectedBatch.value.id), `sense-provisioning-${selectedBatch.value.id}.csv`) } catch (error) { ElMessage.error(error.message || '结果导出失败') } }
|
||||
function downloadTemplate() { saveText('\uFEFFline_number,name,location,address\r\n1,东门摄像机,教学楼一楼东门,http://192.0.2.10/onvif', 'sense-provisioning-template.csv') }
|
||||
onMounted(loadBatches)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header,.section-title,.toolbar,.import-actions,.summary-cards{display:flex;align-items:center;gap:12px}.page-header,.section-title{justify-content:space-between}.page-header h3,.section-title h4{margin:0 0 6px}.page-header p{margin:0;color:#909399}.workflow-steps{margin:18px 0}.toolbar{margin:18px 0}.file-name,.import-actions span{color:#606266;font-size:13px}.import-actions{justify-content:flex-end;margin-top:16px}.section-title{margin-bottom:12px}.section-title .el-select{width:160px}.summary-cards{margin-bottom:14px}.credential-table{margin-top:16px}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
const aliases = {
|
||||
lineNumber: ['lineNumber', 'line_number', '序号', '行号'],
|
||||
name: ['name', '设备名称', '摄像头名称'],
|
||||
location: ['location', '安装位置', '位置'],
|
||||
address: ['address', '设备地址', 'ONVIF地址', 'onvif_address']
|
||||
}
|
||||
|
||||
function readAlias(row, names) {
|
||||
const key = names.find(name => Object.prototype.hasOwnProperty.call(row, name))
|
||||
return key ? row[key] : ''
|
||||
}
|
||||
|
||||
export function normalizeImportRows(rows) {
|
||||
return rows.map((row, index) => ({
|
||||
lineNumber: Number(readAlias(row, aliases.lineNumber)) || index + 1,
|
||||
name: String(readAlias(row, aliases.name) || '').trim(),
|
||||
location: String(readAlias(row, aliases.location) || '').trim(),
|
||||
address: String(readAlias(row, aliases.address) || '').trim()
|
||||
}))
|
||||
}
|
||||
|
||||
export function createBatchPayload(rows, idempotencyKey) {
|
||||
return {
|
||||
idempotencyKey: String(idempotencyKey || '').trim(),
|
||||
rows: normalizeImportRows(rows).map(row => ({
|
||||
lineNumber: row.lineNumber,
|
||||
name: row.name,
|
||||
location: row.location,
|
||||
address: row.address
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export function credentialPayload(items, credentialByItem) {
|
||||
return {
|
||||
credentials: items.map(item => {
|
||||
const value = credentialByItem[item.id] || {}
|
||||
const same = value.rtspSameAsOnvif !== false
|
||||
return {
|
||||
itemId: item.id,
|
||||
onvifUsername: String(value.onvifUsername || ''),
|
||||
onvifPassword: String(value.onvifPassword || ''),
|
||||
rtspSameAsOnvif: same,
|
||||
rtspUsername: same ? '' : String(value.rtspUsername || ''),
|
||||
rtspPassword: same ? '' : String(value.rtspPassword || '')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCredentialMap(credentialByItem) {
|
||||
Object.keys(credentialByItem).forEach(key => {
|
||||
credentialByItem[key].onvifUsername = ''
|
||||
credentialByItem[key].onvifPassword = ''
|
||||
credentialByItem[key].rtspUsername = ''
|
||||
credentialByItem[key].rtspPassword = ''
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import request from '@/utils/request'
|
||||
import { getLocalEvent, listLocalEvents } from '@/api/sense/local-event'
|
||||
|
||||
jest.mock('@/utils/request', () => jest.fn())
|
||||
|
||||
describe('Sense local event API', () => {
|
||||
beforeEach(() => request.mockReset())
|
||||
|
||||
test('uses read-only local endpoints', () => {
|
||||
listLocalEvents({ pageIndex: 1, pageSize: 10 })
|
||||
getLocalEvent('event/unsafe')
|
||||
expect(request).toHaveBeenNthCalledWith(1, { url: '/api/v1/local-events', method: 'get', params: { pageIndex: 1, pageSize: 10 }})
|
||||
expect(request).toHaveBeenNthCalledWith(2, { url: '/api/v1/local-events/event%2Funsafe', method: 'get' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { buildLocalEventQuery, candidateLabel, evidenceLabel, evidenceType, retentionLabel } from '@/views/sense/local-event/localEventState'
|
||||
|
||||
describe('Sense local event presentation state', () => {
|
||||
test('keeps candidate and evidence states distinct', () => {
|
||||
expect(candidateLabel('candidate')).toBe('候选')
|
||||
expect(candidateLabel('confirmed')).toBe('已确认事件')
|
||||
expect(evidenceLabel('pending')).toBe('处理中')
|
||||
expect(evidenceLabel('success')).toBe('成功')
|
||||
expect(evidenceLabel('failed')).toBe('失败')
|
||||
expect(evidenceType('failed')).toBe('danger')
|
||||
})
|
||||
|
||||
test('builds an allowlisted query with RFC3339 time bounds', () => {
|
||||
const result = buildLocalEventQuery({ pageIndex: 2, pageSize: 20, candidateState: 'candidate', evidenceState: 'failed', ruleRef: ' rule-1 ', keyword: ' camera-3 ', ignored: 'not-sent' }, ['2026-08-27T00:00:00Z', '2026-08-28T00:00:00Z'])
|
||||
expect(result).toEqual({ pageIndex: 2, pageSize: 20, candidateState: 'candidate', evidenceState: 'failed', ruleRef: 'rule-1', keyword: 'camera-3', occurredAfter: '2026-08-27T00:00:00.000Z', occurredBefore: '2026-08-28T00:00:00.000Z' })
|
||||
})
|
||||
|
||||
test('describes retention without inventing a global duration', () => {
|
||||
expect(retentionLabel({ retainUntil: '2026-09-09T00:00:00Z', retentionState: 'active', remainingDays: 12 })).toBe('保留至 2026-09-09')
|
||||
expect(retentionLabel({ retainUntil: '2026-08-30T00:00:00Z', retentionState: 'expiring', remainingDays: 2 })).toBe('2 天内到期')
|
||||
expect(retentionLabel({ retainUntil: '2026-08-01T00:00:00Z', retentionState: 'expired', remainingDays: 0 })).toBe('已到期')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import request from '@/utils/request'
|
||||
import { getOperationProblem, listOperationProblems, retryOperationProblem } from '@/api/sense/operations'
|
||||
|
||||
jest.mock('@/utils/request', () => jest.fn())
|
||||
|
||||
describe('Sense operations API', () => {
|
||||
beforeEach(() => request.mockReset())
|
||||
|
||||
test('encodes identifiers and sends only the expected version for controlled retry', () => {
|
||||
listOperationProblems({ pageIndex: 1, pageSize: 10 })
|
||||
getOperationProblem('media:camera/unsafe')
|
||||
retryOperationProblem('media:camera/unsafe', 7)
|
||||
expect(request).toHaveBeenNthCalledWith(1, { url: '/api/v1/operations', method: 'get', params: { pageIndex: 1, pageSize: 10 }})
|
||||
expect(request).toHaveBeenNthCalledWith(2, { url: '/api/v1/operations/media%3Acamera%2Funsafe', method: 'get' })
|
||||
expect(request).toHaveBeenNthCalledWith(3, { url: '/api/v1/operations/media%3Acamera%2Funsafe/retry', method: 'post', data: { expectedVersion: 7 }})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { buildOperationsQuery, canRetry, objectLabel, problemLabel, severityType, stateLabel } from '@/views/sense/operations/operationsState'
|
||||
|
||||
describe('Sense operations presentation state', () => {
|
||||
test('uses operator-facing state labels', () => {
|
||||
expect(objectLabel('local_inference')).toBe('本地推理')
|
||||
expect(problemLabel('orphan_safety_gate')).toBe('孤儿安全闸')
|
||||
expect(stateLabel('unavailable')).toBe('不可用(未安装)')
|
||||
expect(stateLabel('active / ready')).toBe('已启用 / 就绪')
|
||||
expect(severityType('high')).toBe('danger')
|
||||
})
|
||||
|
||||
test('allows retry only when the server projection says it is safe', () => {
|
||||
expect(canRetry({ retryable: true, retryInProgress: false, version: 2 })).toBe(true)
|
||||
expect(canRetry({ retryable: true, retryInProgress: true, version: 2 })).toBe(false)
|
||||
expect(canRetry({ retryable: false, retryInProgress: false, version: 2 })).toBe(false)
|
||||
})
|
||||
|
||||
test('builds an allowlisted query', () => {
|
||||
expect(buildOperationsQuery({ pageIndex: 2, pageSize: 20, objectType: ' device ', problemType: 'clock_drift', severity: ' medium ', keyword: ' 南门 ', ignored: 'not-sent' })).toEqual({
|
||||
pageIndex: 2, pageSize: 20, objectType: 'device', problemType: 'clock_drift', severity: 'medium', keyword: '南门'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import axios from 'axios'
|
||||
import { downloadProvisioningBatch } from '@/api/sense/provisioning'
|
||||
import { getToken } from '@/utils/auth'
|
||||
|
||||
jest.mock('axios', () => ({ get: jest.fn() }))
|
||||
jest.mock('@/utils/auth', () => ({ getToken: jest.fn() }))
|
||||
jest.mock('@/utils/request', () => jest.fn())
|
||||
|
||||
describe('Sense provisioning result export', () => {
|
||||
test('downloads the audited server export as a blob', async() => {
|
||||
const blob = new Blob(['status'], { type: 'text/csv' })
|
||||
getToken.mockReturnValue('synthetic-token')
|
||||
axios.get.mockResolvedValue({ data: blob })
|
||||
await expect(downloadProvisioningBatch('batch-1')).resolves.toBe(blob)
|
||||
expect(axios.get).toHaveBeenCalledWith(expect.stringContaining('/api/v1/provisioning/batches/batch-1/export'), {
|
||||
responseType: 'blob',
|
||||
headers: { Authorization: 'Bearer synthetic-token' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { clearCredentialMap, createBatchPayload, credentialPayload, normalizeImportRows } from '@/views/sense/provisioning/provisioningPayload'
|
||||
|
||||
describe('Sense provisioning payloads', () => {
|
||||
test('normalizes Chinese template headers and excludes unknown columns', () => {
|
||||
const rows = normalizeImportRows([{ 序号: 3, 设备名称: ' 东门摄像机 ', 安装位置: ' 东门 ', ONVIF地址: 'http://192.0.2.10/onvif', 密码: 'must-not-be-sent' }])
|
||||
expect(createBatchPayload(rows, ' import-1 ')).toEqual({
|
||||
idempotencyKey: 'import-1',
|
||||
rows: [{ lineNumber: 3, name: '东门摄像机', location: '东门', address: 'http://192.0.2.10/onvif' }]
|
||||
})
|
||||
})
|
||||
|
||||
test('credential payload is allowlisted and can be cleared in place', () => {
|
||||
const values = { item1: { onvifUsername: 'installer', onvifPassword: 'temporary-secret', rtspSameAsOnvif: true, rtspUsername: 'ignored', rtspPassword: 'ignored', unexpected: 'ignored' }}
|
||||
expect(credentialPayload([{ id: 'item1' }], values)).toEqual({ credentials: [{ itemId: 'item1', onvifUsername: 'installer', onvifPassword: 'temporary-secret', rtspSameAsOnvif: true, rtspUsername: '', rtspPassword: '' }] })
|
||||
clearCredentialMap(values)
|
||||
expect(values.item1.onvifPassword).toBe('')
|
||||
expect(values.item1.onvifUsername).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Project-Profile
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Project-Profile.-
|
||||
wiki_revision: 5e6d18991d69aad15311aae811f35206aa0b5ee6
|
||||
synchronized_at: 2026-08-27T09:04:26Z
|
||||
wiki_revision: 5894b3f4e3152420bd9addd63c1ce80205a6fd80
|
||||
synchronized_at: 2026-08-27T15:22:01Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 项目档案
|
||||
@@ -19,7 +19,7 @@ synchronized_at: 2026-08-27T09:04:26Z
|
||||
| 首期客户场景 | 民办寄宿学校,默认 16 路高风险点位 |
|
||||
| 首期规则 | 越线、危险区域、聚集等匿名安全规则;不启用人脸 |
|
||||
| 产品形态 | Sense 与 Bell 两个独立销售产品,Brain 为独立推理交付单元 |
|
||||
| 当前阶段 | 进入 GoAdmin 源码派生重建:旧实现归档于 `explore`,`main` 为审核基线,`dev` 为集成开发分支 |
|
||||
| 当前阶段 | Sense 独立纵切、Brain Python 骨架、Bell GoAdmin 产品骨架已通过用户验收并合入 `dev`;旧实现归档于 `explore`,`main` 仍为审核基线 |
|
||||
| 历史来源 | `D:\OPC\yovision_old`,只读追溯 |
|
||||
|
||||
## DevHarness 来源与基线
|
||||
@@ -54,7 +54,7 @@ YoVision 采用 DevHarness 的共同工作流、统一 `harness.py` 命令、Git
|
||||
- 证据:客户侧 MinIO/S3 兼容对象存储;常态录像优先留在客户已有 NVR。
|
||||
- 首期验证平台:NVIDIA x86/Jetson;M1-M3 不承诺 GB/T 28181、信创或原生 App。
|
||||
|
||||
2026-08-14 起,原 Sense、Bell 实现只在 `explore` 和原功能分支中作为迁移参考,不再作为新开发基础。新的 Sense、Bell 必须从下述冻结 go-admin/go-admin-ui 完整提交派生;实施时必须核对冻结 go-admin-doc。Brain 尚未初始化。
|
||||
2026-08-14 起,原 Sense、Bell 实现只在 `explore` 和原功能分支中作为迁移参考,不再作为新开发基础。当前 `dev` 中的 Sense、Bell 已分别从下述冻结 go-admin/go-admin-ui 完整提交派生;实施时仍必须核对冻结 go-admin-doc。Brain 已建立独立 Python/PyTorch 包骨架,但尚未包含推理业务能力。
|
||||
|
||||
## 阅读入口
|
||||
|
||||
@@ -66,7 +66,7 @@ YoVision 采用 DevHarness 的共同工作流、统一 `harness.py` 命令、Git
|
||||
|
||||
## 常用命令
|
||||
|
||||
当前初始化阶段可执行:
|
||||
仓库级检查可执行:
|
||||
|
||||
```powershell
|
||||
python dev_scripts/harness.py check --strict
|
||||
@@ -75,7 +75,7 @@ python dev_scripts/harness.py sync --check
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Sense、Brain、Bell 的构建、运行和测试命令尚待各自骨架工单建立,不以旧仓库命令冒充新仓库事实。
|
||||
Sense、Brain、Bell 的当前构建、运行和测试命令已分别记录在各自 README 与 `Local-Development-and-Verification`;不得以旧仓库命令替代。
|
||||
|
||||
## 环境、配置与凭据
|
||||
|
||||
@@ -108,16 +108,22 @@ Sense、Bell 共用的可复现技术基线记录在仓库根 `goadmin-baseline.
|
||||
<!-- sense-runtime:start -->
|
||||
## Sense 重建状态
|
||||
|
||||
当前可运行的旧 Sense 实现保存在 `explore`,不进入新 `main` / `dev` 基线。新 Sense 尚未初始化;后续必须从冻结的 go-admin 后端和 go-admin-ui 前端源码派生,保留上游骨架、许可证和来源证据,并在工单记录实际参考的 go-admin-doc 页面。
|
||||
Sense 已从冻结 go-admin/go-admin-ui 源码独立派生,并完成设备、视频接入、MediaMTX、单路监看、区域配置与 Windows 交付的独立纵切。工单 #71 已从当前源码重新打包并通过隔离 PostgreSQL 17、Digest ONVIF/合成 RTSP、独立 MediaMTX、Chrome 外壳和冷启动回归;当前成果已合入 `dev`,并于 2026-08-27 通过用户验收。现场真机、16 路长稳和跨项目链路不在本轮结论内。
|
||||
<!-- sense-runtime:end -->
|
||||
|
||||
|
||||
<!-- bell-runtime:start -->
|
||||
## Bell 重建状态
|
||||
|
||||
当前 Bell MVP 实现在 `explore` 和原功能分支中保留,只作需求、行为和迁移对照。新 Bell 尚未初始化;后续必须从与 Sense 相同的冻结 go-admin/go-admin-ui 基线独立派生,不能复制旧自研基础框架或共享 Sense 的认证与数据库。
|
||||
Bell 已从与 Sense 相同的冻结 go-admin/go-admin-ui 基线独立派生到 `Bell/server/` 与 `Bell/ui/`,保留来源和 MIT 许可证证据,以及独立 PostgreSQL、JWT、token key 和首次管理员边界。当前最小启用骨架已通过后端、前端和隔离 PostgreSQL smoke,并于 2026-08-27 通过用户验收、合入 `dev`;事件、规则、Alert 等业务能力继续按独立工单迁移。
|
||||
<!-- bell-runtime:end -->
|
||||
|
||||
<!-- brain-runtime:start -->
|
||||
## Brain 初始化状态
|
||||
|
||||
Brain 已建立 CPython 3.11.15 / PyTorch 2.12.1 的无界面包骨架,提供安装、版本、runtime-info 与 CPU/CUDA smoke 入口。CPU wheel、包测试和 CPU tensor smoke 已通过,并于 2026-08-27 通过用户验收、合入 `dev`;CUDA wheel、真实 GPU、视频、模型、规则、事件与部署尚未验证或实现。
|
||||
<!-- brain-runtime:end -->
|
||||
|
||||
## 分支治理
|
||||
|
||||
- `explore`:2026-08-14 前实现的只读聚合快照;不接受新功能。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: ee5b5507bf8a8a834e07f223f1a90c2f67a70d6f
|
||||
synchronized_at: 2026-08-27T09:14:31Z
|
||||
wiki_revision: 3c95336ab96aa1e245f67c598299f2904f0e4437
|
||||
synchronized_at: 2026-08-27T11:07:58Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -32,14 +32,14 @@ Bell 也可接收合成事件、传感器平台或第三方系统事件;Sense
|
||||
| 能力 | 路径 | 首个阅读入口 | 验证位置 | 风险 |
|
||||
|---|---|---|---|---|
|
||||
| Sense 产品 | `Sense/` | `Sense/AGENTS.md`、`Sense/README.md`、`Sense/server/main.go`、`Sense/ui/src/main.js` | `Sense/server/` Go 测试;`Sense/ui/` lint/单测/构建 | 高:GoAdmin 派生、设备、媒体、凭据 |
|
||||
| Brain 推理 | `Brain/` | `Brain/AGENTS.md`;后续包入口 | `Brain/` 内测试与契约测试 | 高:模型、GPU、隐私、事件语义 |
|
||||
| Bell 产品 | `Bell/` | `Bell/AGENTS.md`;待 GoAdmin 派生工单建立 README/入口 | 待新骨架建立 | 高:GoAdmin 派生、认证、事件与告警状态机 |
|
||||
| Brain 推理 | `Brain/` | `Brain/AGENTS.md`、`Brain/README.md`、`Brain/src/yovision_brain/__main__.py` | `Brain/tests/test_package.py`、CLI runtime/smoke | 高:模型、GPU、隐私、事件语义 |
|
||||
| Bell 产品 | `Bell/` | `Bell/AGENTS.md`、`Bell/README.md`、`Bell/server/main.go`、`Bell/ui/src/main.js` | `Bell/server/` Go 测试;`Bell/ui/` lint/单测/构建 | 高:GoAdmin 派生、认证、事件与告警状态机 |
|
||||
| 共享契约 | `contracts/` | `contracts/AGENTS.md` | 三端消费者/生产者测试 | 高:兼容性与跨项目影响 |
|
||||
| Harness | `dev_scripts/` | `harness.py check --strict` | `tests/` | 中 |
|
||||
| 工单模板 | `.gitea/issue_template/` | `task.md` | Harness 严格检查 | 中 |
|
||||
| Wiki 镜像 | `docs/` | `docs/README.md` | `harness.py sync --check` | 低;禁止直接编辑 |
|
||||
|
||||
旧 Sense、Bell 入口仅存在于 `explore` 快照,不是 `main` / `dev` 当前代码地图。三个项目的新入口必须随新骨架工单建立;不得把旧自研基础框架复制回 `dev`。
|
||||
旧 Sense、Bell 实现仅作为 `explore` 需求与行为证据;`dev` 当前已包含从冻结 GoAdmin 基线分别派生的 Sense、Bell 入口,以及 Brain 的独立 Python 包骨架。后续业务迁移仍不得把旧自研基础框架复制回 `dev`。
|
||||
|
||||
## 两条主要执行路径
|
||||
|
||||
@@ -115,6 +115,22 @@ ONVIF 支持 Basic 与 MD5/SHA-256 Digest challenge,Profile 与无凭据 Strea
|
||||
<!-- sense-mvp:end -->
|
||||
|
||||
|
||||
<!-- brain-runtime:start -->
|
||||
## Brain 当前代码入口
|
||||
|
||||
工单 #10 建立了无界面的独立 Python 包骨架:包入口为 `Brain/src/yovision_brain/__main__.py`,项目与依赖元数据位于 `Brain/pyproject.toml`,运行和验证说明位于 `Brain/README.md`。固定基线为 CPython 3.11.15、pip 26.2.1、setuptools 80.9.0、pytest 8.4.2、NumPy 2.3.3 和 PyTorch 2.12.1;CPU wheel 使用 PyTorch 官方 CPU 索引,NVIDIA 路径固定为官方 CUDA 12.6 wheel。
|
||||
|
||||
当前骨架只提供安装、版本、runtime-info 与 CPU/CUDA smoke 入口,不包含视频、模型、规则、事件或部署能力,也不依赖 Sense、Bell 在线。CPU wheel、4 项包测试和 CPU tensor smoke 已验证;本轮没有安装 CUDA wheel,也没有执行真实 GPU smoke,因此不得把驱动满足前提写成 CUDA 可用。
|
||||
<!-- brain-runtime:end -->
|
||||
|
||||
<!-- bell-runtime:start -->
|
||||
## Bell 新代码入口
|
||||
|
||||
工单 #62 已从冻结 go-admin `f06540883b41d03782bb6b2c4150f298f328c6b6` 与 go-admin-ui `67d393d713877572fab0b897296a4c1d525fc81d` 完整应用源码分别派生到 `Bell/server/` 和 `Bell/ui/`;来源、排除项和 MIT 许可证位于 `Bell/LICENSES/`。后端保留 Cobra、Gin、GORM、Casbin、JWT、迁移和认证/RBAC,前端保留 Router、Store、Axios、Layout、动态菜单和权限指令。
|
||||
|
||||
Bell 使用独立 PostgreSQL、JWT realm、token key 和首次迁移管理员环境变量。当前只注册用户、角色、菜单等骨架必需管理路由;部门、岗位、字典、参数、日志、代码生成、任务和监控等上游源码为升级追溯保留,但不注册路由或显示入口。骨架已验证 Go test/vet/build、前端 lint/29 项单测/生产构建及隔离 PostgreSQL 的迁移、登录和 RBAC smoke;尚未完成浏览器级前后端登录联调、生产 PostgreSQL 部署或发布打包。
|
||||
<!-- bell-runtime:end -->
|
||||
|
||||
<!-- bell-mvp:start -->
|
||||
## Bell 旧 MVP 对照
|
||||
|
||||
@@ -133,6 +149,14 @@ ONVIF 支持 Basic 与 MD5/SHA-256 Digest challenge,Profile 与无凭据 Strea
|
||||
认证 API 为 `/api/v1/area/configurations` 及其版本子资源,接入 GoAdmin JWT、Casbin、动态菜单和操作权限。API 只返回设备/Profile 展示字段、规格、归一化坐标和版本信息,不返回 RTSP URI、摄像头凭据或 MediaMTX 内部路径。
|
||||
<!-- sense-area:end -->
|
||||
|
||||
<!-- sense-acceptance:start -->
|
||||
## Sense 独立验收入口
|
||||
|
||||
工单 #71 在 `Sense/ACCEPTANCE.md` 固化独立纵切验收矩阵,并提供 `Sense/tests/compatibility/run-static-regression.ps1` 与 `Sense/tests/e2e/run-isolated-e2e.ps1`。隔离 E2E 从当前源码重新打 Windows 包,只启动临时 PostgreSQL 17、Sense、Digest ONVIF/合成 RTSP 夹具、独立 MediaMTX 和 Chrome,不连接 Brain 或 Bell;随机凭据只进入子进程环境与系统临时目录。
|
||||
|
||||
当前开发机已验证空库迁移、登录/RBAC、中文请求、严格字段、分用途加密凭据、Digest ONVIF/RTSP、MediaMTX 按需拉流、实时监看、区域重校准、GoAdmin 五菜单外壳、Windows 停止与冷启动。PowerShell 7 调用入口时会转入 Windows PowerShell 5.1 执行本地 HTTP 回归,源码打包仍使用冻结的 PowerShell 7。现场真机、客户全新 Windows、16 路长稳和跨项目链路仍未验证。
|
||||
<!-- sense-acceptance:end -->
|
||||
|
||||
<!-- sense-windows-delivery:start -->
|
||||
## Sense Windows 交付运行链
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: 7ede95108304cec08572d9da5ce1f41116e4970d
|
||||
synchronized_at: 2026-08-27T09:05:09Z
|
||||
wiki_revision: e1755deff68c95188e6bf2f759c7ec93293c8715
|
||||
synchronized_at: 2026-08-27T15:22:48Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -17,7 +17,7 @@ synchronized_at: 2026-08-27T09:05:09Z
|
||||
| Gitea | 工单与 Wiki | 浏览仓库或调用 MCP |
|
||||
| Gitea PAT | Wiki 写入 | 仅由 MCP 或 `GITEA_TOKEN` 提供,不打印 |
|
||||
|
||||
Sense/Bell 的 Go、Node 与 pnpm 基线已冻结并记录于下文;Brain 的 Python/CUDA 以及 PostgreSQL、MediaMTX 精确版本仍将在对应骨架工单冻结。旧仓库环境不自动成为新项目事实。
|
||||
Sense/Bell 的 Go、Node 与 pnpm 基线已冻结并记录于下文;Brain 的 Python/PyTorch 基线也已由骨架工单固定。PostgreSQL、MediaMTX 的交付版本仍按各产品工单与部署环境冻结,旧仓库环境不自动成为新项目事实。
|
||||
|
||||
## Windows PowerShell 与 UTF-8
|
||||
|
||||
@@ -63,9 +63,9 @@ Sense/Bell 的 Go、Node 与 pnpm 基线已冻结并记录于下文;Brain 的
|
||||
|
||||
| 项目 | 构建 | 测试 | 运行 | 当前状态 |
|
||||
|---|---|---|---|---|
|
||||
| Sense | `cd Sense/server; go build ./...`、`cd Sense/ui; corepack pnpm@9.15.1 build:prod` | `go test ./...`、`go vet ./...`、前端 lint/单测 | `sense server -c <仓库外配置>` | GoAdmin 源码骨架已建立;业务模块待后续工单 |
|
||||
| Brain | 待骨架工单冻结 | 待冻结 | 待冻结 | 未初始化业务代码 |
|
||||
| Bell | 待 GoAdmin 派生工单建立 | 待建立 | 待建立 | 旧实现仅在 `explore`;新基线未初始化 |
|
||||
| Sense | `cd Sense/server; go build ./...`、`cd Sense/ui; corepack pnpm@9.15.1 build:prod` | `go test ./...`、`go vet ./...`、前端 lint/单测、独立 E2E | `sense server -c <仓库外配置>` | GoAdmin 产品骨架与独立纵切已于 2026-08-27 通过验收 |
|
||||
| Brain | `Brain\.venv\Scripts\python.exe -m pip install -e "Brain[dev]"` | `python -m pytest Brain/tests/test_package.py -q`、CPU/CUDA smoke | `python -m yovision_brain --runtime-info` | Python/PyTorch 骨架已于 2026-08-27 通过验收;CPU 已验证,CUDA 未验证 |
|
||||
| Bell | `cd Bell/server; go build ./...`、`cd Bell/ui; corepack pnpm@9.15.1 build:prod` | `go test ./...`、`go vet ./...`、前端 lint/单测 | `go run . server -c config/settings.yml` | 冻结 GoAdmin 产品骨架已于 2026-08-27 通过验收 |
|
||||
|
||||
不得复制旧仓库命令来填空。每个骨架工单必须同时建立 README、可复制命令和最小测试。
|
||||
|
||||
@@ -231,12 +231,66 @@ corepack pnpm@9.15.1 build:prod
|
||||
<!-- sense-runtime:end -->
|
||||
|
||||
|
||||
<!-- brain-runtime:start -->
|
||||
## Brain 本地环境与验证
|
||||
|
||||
Brain 固定使用 CPython 3.11.15、pip 26.2.1、setuptools 80.9.0、pytest 8.4.2、NumPy 2.3.3 和 PyTorch 2.12.1。CPU 与 CUDA wheel 必须使用不同虚拟环境,不能混装:
|
||||
|
||||
```powershell
|
||||
uv python install 3.11.15
|
||||
uv venv --python 3.11.15 Brain/.venv
|
||||
Brain\.venv\Scripts\python.exe -m pip install pip==26.2.1 --index-url https://pypi.org/simple
|
||||
Brain\.venv\Scripts\python.exe -m pip install -e "Brain[dev]" --index-url https://pypi.org/simple
|
||||
Brain\.venv\Scripts\python.exe -m pip install numpy==2.3.3 --index-url https://pypi.org/simple
|
||||
Brain\.venv\Scripts\python.exe -m pip install torch==2.12.1 --index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
Brain\.venv\Scripts\python.exe -m pytest Brain/tests/test_package.py -q
|
||||
Brain\.venv\Scripts\python.exe -m yovision_brain --runtime-info
|
||||
Brain\.venv\Scripts\python.exe -m yovision_brain --smoke cpu
|
||||
```
|
||||
|
||||
NVIDIA 环境将最后一个索引替换为 `https://download.pytorch.org/whl/cu126`,再执行 `--smoke cuda`。本轮只验证了 CPU wheel 与 CPU tensor smoke;不得在未执行真实 CUDA smoke 时声明 GPU 可用。
|
||||
<!-- brain-runtime:end -->
|
||||
|
||||
<!-- bell-runtime:start -->
|
||||
## Bell 本地构建与验证
|
||||
|
||||
新 Bell 尚未初始化。骨架工单必须从冻结 GoAdmin 源码建立并验证独立构建、数据库、认证和前端流程;旧 Bell 命令只在 `explore` 对应提交中适用。
|
||||
Bell 已从冻结 GoAdmin 基线独立派生。运行前只在进程环境提供独立的 `BELL_DATABASE_URL`、至少 32 字符的 `BELL_JWT_SECRET`,以及仅首次迁移使用的 `BELL_BOOTSTRAP_USERNAME` / `BELL_BOOTSTRAP_PASSWORD`;不得提交真实值。
|
||||
|
||||
```powershell
|
||||
Set-Location Bell/server
|
||||
$env:GOTOOLCHAIN='go1.26.5'
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
go run . migrate -c config/settings.yml
|
||||
go run . server -c config/settings.yml
|
||||
|
||||
Set-Location ../../Bell/ui
|
||||
corepack pnpm@9.15.1 install --frozen-lockfile
|
||||
corepack pnpm@9.15.1 lint
|
||||
corepack pnpm@9.15.1 test:unit
|
||||
corepack pnpm@9.15.1 build:prod
|
||||
```
|
||||
|
||||
默认监听为 `127.0.0.1:18090`,可用 `BELL_HOST` / `BELL_PORT` 覆盖。当前已验证隔离 PostgreSQL 17 的迁移、健康检查、登录、未认证拒绝和 RBAC 菜单;浏览器级前后端登录、生产 PostgreSQL 部署与发布打包尚未验证。
|
||||
<!-- bell-runtime:end -->
|
||||
|
||||
<!-- sense-acceptance:start -->
|
||||
## Sense 独立端到端验收
|
||||
|
||||
从仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
& Sense\tests\compatibility\run-static-regression.ps1
|
||||
& Sense\tests\e2e\run-isolated-e2e.ps1
|
||||
```
|
||||
|
||||
详细矩阵见 `Sense/ACCEPTANCE.md`。隔离 E2E 默认需要 PostgreSQL 17 工具目录、已审核的 MediaMTX、FFmpeg 与 Chrome;路径不同时用参数显式指定。所有运行数据、随机凭据、数据库、日志、截图和构建副本位于系统临时目录,只有排错时才使用 `-KeepTemporary`,并须把保留目录当作敏感数据及时清理。
|
||||
|
||||
PowerShell 7 调用入口时会自动转入 Windows PowerShell 5.1 执行本地 HTTP 回归,源码打包仍显式使用 PowerShell 7。这是当前 Windows 交付宿主边界。现场真机、客户新主机、16 路长稳、硬件解码及 Brain/Bell 跨项目链路不在本验收覆盖内。
|
||||
<!-- sense-acceptance:end -->
|
||||
|
||||
<!-- sense-windows-package:start -->
|
||||
## Sense Windows 打包与验证
|
||||
|
||||
|
||||
Reference in New Issue
Block a user