Compare commits

...
Author SHA1 Message Date
QiuSW 870cb4acec docs: 记录三项目并行工单验收 (#113) 2026-08-27 23:29:52 +08:00
ila 9c874734af merge: 同步三项目并行实施事实 (#113)
Wiki 已回读,18 页镜像一致,DevHarness strict 与 48 项测试通过。#10、#62、#71、#113 均保持待验收。
2026-08-27 19:14:17 +08:00
QiuSW 966633d1cc docs: 同步三项目并行实施事实 (#113) 2026-08-27 19:14:01 +08:00
ila dcbebdf01b merge: 完成 Sense 独立端到端回归 (#71)
协调复核通过:范围、隔离清理、静态兼容、Go 全量测试与 DevHarness strict 均通过。工单保持待验收。
2026-08-27 19:00:16 +08:00
QiuSW f1c765f8cc test: 补充 Sense 独立端到端回归 (#71) 2026-08-27 18:56:59 +08:00
ila 14fda5395b merge: 完成 Bell GoAdmin 产品骨架 (#62)
协调复核通过:来源、许可证、安全默认值、Go 测试、前端单测与 DevHarness strict 均通过。工单保持待验收。
2026-08-27 18:29:50 +08:00
ila e60d10b103 Merge pull request #110: 初始化 Brain Python CUDA 项目骨架
关联工单 #10;等待用户验收。
2026-08-27 18:17:20 +08:00
QiuSW 60036587a6 feat: 初始化 Brain Python CUDA 项目骨架 (#10) 2026-08-27 18:15:09 +08:00
18 changed files with 1175 additions and 20 deletions
+3
View File
@@ -0,0 +1,3 @@
# Safe local defaults. Do not add credentials, customer data, or production paths.
BRAIN_DEVICE=auto
BRAIN_LOG_LEVEL=INFO
+1
View File
@@ -0,0 +1 @@
3.11.15
+90
View File
@@ -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)。
+17
View File
@@ -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 及后续模型可能带来额外第三方依赖与模型许可。本骨架未选择或分发任何模型;引入模型前必须另行核对商用、再分发、数据和输出限制,不能把框架许可证视为模型许可证。
+27
View File
@@ -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"
+5
View File
@@ -0,0 +1,5 @@
"""YoVision Brain package."""
__version__ = "0.1.0"
__all__ = ["__version__"]
+112
View File
@@ -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())
+46
View File
@@ -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"
+46
View File
@@ -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,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."
+203
View File
@@ -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
})
+327
View File
@@ -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
View File
@@ -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
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "东门测试摄像机",
"location": "一号楼东门入口",
"modality": "video",
"capabilities": [
"video"
]
}
+121
View File
@@ -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()
+14 -8
View File
@@ -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 前实现的只读聚合快照;不接受新功能。
+29 -5
View File
@@ -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 交付运行链
+61 -7
View File
@@ -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 打包与验证