Compare commits

...
Author SHA1 Message Date
QiuSW 60036587a6 feat: 初始化 Brain Python CUDA 项目骨架 (#10) 2026-08-27 18:15:09 +08:00
ila b37c350096 Merge pull request #109: 同步 DevHarness 最新工作流与文档基线
关联工单 #108;等待用户验收。
2026-08-27 17:21:33 +08:00
QiuSW a3d1bdfb14 docs: 同步 DevHarness 长期文档 (#108) 2026-08-27 17:16:50 +08:00
QiuSW 266bf236b8 chore: 升级 DevHarness 工作流 (#108) 2026-08-27 17:16:26 +08:00
ila e14b65b586 merge: 完成 Sense 根目录启动脚本 (#90)
用户已于 2026-08-27 验收通过;将 #90 合入 dev,Supervisor 实例保持运行,main 不变。
2026-08-27 16:40:51 +08:00
QiuSW e0ab023557 docs: 标记任务 #90 验收完成 2026-08-27 16:40:40 +08:00
QiuSW dc426774ac docs: 更新任务 #90 最新 dev 回归证据 2026-08-27 16:30:41 +08:00
QiuSW 404e25584e merge: 更新 #90 到最新 dev
# Conflicts:
#	wiki-docs.json
2026-08-27 16:22:04 +08:00
ila 1514215729 merge: 完成本机 Supervisor Sense 实例 (#106)
用户已于 2026-08-27 验收通过;将 #106 文档与归档合入 dev,外部 Supervisor 实例保持运行,main 不变。
2026-08-27 16:17:51 +08:00
QiuSW 120a9efee4 docs: 标记任务 #106 验收完成 2026-08-27 16:17:36 +08:00
QiuSW 356f891c9e docs: 归档任务 #106 待验收证据 2026-08-27 16:10:28 +08:00
QiuSW 0646f5effd docs: 记录 Sense Supervisor 托管方式 (#106) 2026-08-27 16:08:23 +08:00
ila d264758dee merge: 完成 Sense GoAdmin 应用外壳 (#101)
用户已于 2026-08-27 验收通过;将 #101 合入 dev,main 保持不变。
2026-08-27 15:53:22 +08:00
QiuSW 83012ad37f docs: 标记任务 #101 验收完成 2026-08-27 15:53:08 +08:00
QiuSW 54f10d894a docs: 更新工单 #101 组合验收证据 2026-08-27 15:34:22 +08:00
QiuSW 8cdd3f61cb Merge remote-tracking branch 'origin/dev' into fix/101-sense-layout-shell
# Conflicts:
#	docs/02-architecture-and-code-map.md
2026-08-27 15:23:19 +08:00
ila 46c3232da4 merge: 完成 Sense 30天登录有效期 (#104)
用户已验收通过 #104;将 PR #105 合入 dev,main 保持不变。
2026-08-17 11:46:42 +08:00
QiuSW a53afb219c docs: 标记任务 #104 验收完成 2026-08-17 11:46:31 +08:00
QiuSW 4adf2d60aa docs: 完成任务 #104 待验收归档 2026-08-17 11:09:23 +08:00
QiuSW adc227f110 test: 验证 Sense 30天 JWT 到期时间 (#104) 2026-08-17 10:56:26 +08:00
QiuSW a8431216e3 docs: 记录 Sense 30天登录有效期 (#104) 2026-08-17 10:55:21 +08:00
QiuSW 857ba45541 feat: 将 Sense 登录有效期调整为30天 (#104) 2026-08-17 10:53:20 +08:00
QiuSW faa96a3bea docs: 更新工单 #101 组合验收包 2026-08-16 23:52:42 +08:00
QiuSW 42dc77b1f9 Merge branch 'dev' into fix/101-sense-layout-shell
# Conflicts:
#	docs/02-architecture-and-code-map.md
#	wiki-docs.json
2026-08-16 23:43:52 +08:00
ila 4b135b852b Merge pull request '#103' from docs/99-acceptance into dev
归档工单 #99 验收结果
2026-08-16 23:43:01 +08:00
QiuSW 3c668ccff6 docs: 归档工单 #99 验收结果 2026-08-16 23:41:53 +08:00
ila 34ee5ed619 Merge pull request '#100' from feature/99-sense-app-config into dev
[SEN] 恢复登录页匿名只读 app-config 接口(#99)

用户已于 2026-08-16 明确验收通过。
2026-08-16 23:38:40 +08:00
QiuSW 9ff2f6339f docs: 完成任务 #101 待验收归档 2026-08-16 23:32:42 +08:00
QiuSW e652109744 docs: 记录 Sense Layout 菜单结构 (#101) 2026-08-16 23:26:55 +08:00
QiuSW 4423d528b1 fix: 保留 Sense GoAdmin 应用外壳 (#101) 2026-08-16 23:12:53 +08:00
QiuSW d067f0b989 docs: 完成任务 #99 待验收归档 2026-08-16 19:49:19 +08:00
QiuSW 08b4b61f90 docs: 记录登录外壳只读配置边界 (#99) 2026-08-16 19:46:07 +08:00
QiuSW 713c9e4e30 fix: 恢复登录页前端配置接口 (#99) 2026-08-16 19:40:29 +08:00
ila 863b232a73 Merge pull request '#89' from feature/70-sense-windows-delivery into dev
[SEN] 建立 Windows 配置、启动与打包交付(#70)
2026-08-16 19:33:55 +08:00
QiuSW 5a67a17e17 docs: 记录工单 #70 验收通过 2026-08-16 19:33:44 +08:00
QiuSW ce0793505f Merge remote-tracking branch 'origin/dev' into feature/70-sense-windows-delivery
# Conflicts:
#	wiki-docs.json
2026-08-16 19:33:09 +08:00
ila 12e1bff8b4 Merge pull request '#96' from fix/95-sense-media-path-constraint into dev
[SEN] 兼容旧媒体路由 path 唯一约束迁移(#95)
2026-08-16 19:32:12 +08:00
QiuSW 15bd000816 docs: 记录工单 #95 验收通过 2026-08-16 19:32:02 +08:00
QiuSW 7f13e595b6 Merge remote-tracking branch 'origin/dev' into fix/95-sense-media-path-constraint
# Conflicts:
#	wiki-docs.json
2026-08-16 19:31:00 +08:00
QiuSW 098d0fafee docs: 记录 #70 最终集成回归 2026-08-16 19:27:38 +08:00
QiuSW b66c39724c fix: 移除打包清单模块依赖 (#70) 2026-08-16 19:18:26 +08:00
QiuSW ae0c26434e Merge remote-tracking branch 'origin/dev' into feature/70-sense-windows-delivery
# Conflicts:
#	docs/04-local-development-and-verification.md
#	wiki-docs.json
2026-08-16 19:15:00 +08:00
ila 116318df74 Merge pull request '#98' from feature/97-sense-password-login into dev
[SEN] 恢复免验证码登录并安全设置管理员密码(#97)
2026-08-16 19:05:35 +08:00
QiuSW bdb9a5b474 docs: 记录工单 #97 验收通过 2026-08-16 19:05:17 +08:00
QiuSW a92e4da043 docs: 归档 Sense 免验证码登录任务 (#97) 2026-08-15 18:08:33 +08:00
QiuSW bc1a01848d docs: 更新 Sense 登录安全边界 (#97) 2026-08-15 18:02:16 +08:00
QiuSW 0bbdb27f6d fix: 恢复 Sense 免验证码登录 (#97) 2026-08-15 18:02:10 +08:00
QiuSW 2c0e39bae8 docs: 记录 #70 白屏修复验收证据 2026-08-15 17:12:17 +08:00
QiuSW 4ca4abff7b fix: 修复 Sense Windows 包白屏 (#70) 2026-08-15 17:07:54 +08:00
QiuSW d0185caa0e docs: 完成任务 #95 待验收归档 2026-08-15 16:43:41 +08:00
QiuSW 6257859b83 docs: 记录旧媒体路由迁移排错 (#95) 2026-08-15 16:09:40 +08:00
QiuSW 38549760aa docs: 校正 #70 回归归档 2026-08-15 15:57:19 +08:00
QiuSW 6a8c763dec docs: 更新 #70 真实旧库交付回归 2026-08-15 15:56:21 +08:00
QiuSW c088caf816 merge: 集成媒体旧库迁移修复 (#70 #95) 2026-08-15 15:49:18 +08:00
QiuSW 3dd489066c fix: 兼容旧媒体路由约束迁移 (#95) 2026-08-15 15:48:10 +08:00
QiuSW b9824a8312 merge: 纳入已验收的设备迁移修复 (#70) 2026-08-15 15:32:15 +08:00
ila 40409707cc Merge pull request '#94' from docs/92-acceptance into dev
记录工单 #92 验收通过
2026-08-15 15:31:29 +08:00
QiuSW 9472151103 docs: 校正工单 #92 验收归档 2026-08-15 15:31:14 +08:00
QiuSW e82f15f1fb docs: 记录工单 #92 验收通过 2026-08-15 15:30:10 +08:00
ila eaa6ae0815 Merge pull request '#93' from fix/92-sense-capabilities-jsonb into dev
[SEN] 修复旧设备 capabilities 迁移到 JSONB(#92)
2026-08-15 15:28:03 +08:00
QiuSW 3a686a8151 docs: 完成任务 #92 待验收归档 2026-08-15 15:20:01 +08:00
QiuSW 3c4578c804 docs: 登记任务 #92 归档镜像 2026-08-15 15:19:12 +08:00
QiuSW fe3badfe3e test: 覆盖旧设备完整迁移链 (#92) 2026-08-15 15:17:28 +08:00
QiuSW aee5f45d96 docs: 记录设备能力迁移排错 (#92) 2026-08-15 15:16:32 +08:00
QiuSW 68b436ac62 fix: 兼容旧设备能力字段迁移 (#92) 2026-08-15 15:15:02 +08:00
QiuSW 23bfc04884 docs: 完成任务 #90 待验收归档 2026-08-15 14:48:43 +08:00
QiuSW 7112840362 docs: 登记任务 #90 归档镜像 2026-08-15 14:47:48 +08:00
QiuSW 06d982d220 docs: 记录 Sense 根目录启动方式 (#90) 2026-08-15 14:45:23 +08:00
QiuSW a865dda1c3 feat: 增加 Sense 根目录启动脚本 (#90) 2026-08-15 14:42:34 +08:00
QiuSW 2e76aafd0d docs: 完成任务 #70 待验收归档 2026-08-15 11:10:53 +08:00
QiuSW 23d3cdbf84 docs: 登记任务 #70 归档镜像 2026-08-15 11:08:16 +08:00
QiuSW e4544a011f docs: 记录 Sense Windows 交付流程 (#70) 2026-08-15 11:04:15 +08:00
QiuSW 6b79478414 feat: 建立 Sense Windows 交付包 (#70) 2026-08-15 10:56:47 +08:00
ila cf50c285f4 Merge pull request '#88' from docs/69-acceptance into dev
记录工单 #69 验收通过。
2026-08-15 09:37:08 +08:00
QiuSW 0bc113af04 docs: 记录工单 #69 验收通过 2026-08-15 09:36:34 +08:00
ila 61b79db9f7 Merge pull request '#87' from feature/69-sense-area into dev
[SEN] 重建多边形区域与方向警戒线配置(#69)

用户已明确验收通过。
2026-08-15 09:33:32 +08:00
QiuSW 55be48069f docs: 同步 Sense 区域配置归档 (#69) 2026-08-15 09:24:42 +08:00
QiuSW afd4dab567 docs: 归档 Sense 区域配置工单 (#69) 2026-08-15 09:19:40 +08:00
QiuSW f82dd51d95 docs: 记录 Sense 区域配置架构 (#69) 2026-08-15 09:15:11 +08:00
QiuSW 17bd383229 feat: 重建 Sense 区域与警戒线配置 (#69) 2026-08-15 09:10:43 +08:00
ila 020bf3fe5a Merge pull request '#86' from feature/68-sense-liveview into dev
[SEN] 重建单路实时监看与播放状态反馈(#68)

用户于 2026-08-15 验收通过。
2026-08-15 08:42:04 +08:00
QiuSW 17c1afd195 docs: 记录工单 #68 验收通过 2026-08-15 08:40:23 +08:00
QiuSW c83c181a12 docs: 归档工单 #68 待验收证据 2026-08-14 18:56:50 +08:00
QiuSW a279a1ec0d docs: 记录 Sense 实时监看架构 (#68) 2026-08-14 18:48:11 +08:00
QiuSW c63c623df5 feat: 重建 Sense 单路实时监看 (#68) 2026-08-14 18:48:06 +08:00
ila b1bdb91fdb Merge pull request '#85' from feature/67-sense-mediamtx-reconcile into dev
[SEN] 在 GoAdmin 基线上重建 MediaMTX 生命周期与状态对账(#67)

用户于 2026-08-14 验收通过。
2026-08-14 18:16:32 +08:00
QiuSW 35b9c7b8a4 docs: 记录工单 #67 验收通过 2026-08-14 18:16:15 +08:00
QiuSW 263a68c98d docs: 归档工单 #67 待验收证据 2026-08-14 18:00:44 +08:00
QiuSW 4149cc4426 docs: 记录 Sense 视频服务生命周期 (#67) 2026-08-14 17:51:16 +08:00
QiuSW 19f9bfa1a0 feat: 重建 MediaMTX 生命周期与状态对账 (#67) 2026-08-14 17:42:26 +08:00
ila 24cf4bbd65 Merge pull request '#84' from feature/66-sense-onvif-admission into dev
[SEN] 在 GoAdmin 基线上重建 ONVIF 发现、手工接入与 RTSP Profile (#66)
2026-08-14 17:18:27 +08:00
QiuSW ab54235819 docs: 记录工单 #66 验收通过 2026-08-14 17:18:13 +08:00
QiuSW d74e1aee85 docs: 归档工单 #66 待验收证据 2026-08-14 17:09:40 +08:00
QiuSW 26e2e632d5 docs: 记录 Sense 视频接入安全边界 (#66) 2026-08-14 16:58:13 +08:00
QiuSW 2bb1614a3c feat: 重建 ONVIF 与 RTSP 视频接入 (#66) 2026-08-14 16:50:00 +08:00
ila 5856b76de9 Merge pull request '#83' from feature/65-sense-device-credential into dev
[SEN] 在 GoAdmin 基线上重建设备台账与凭据边界 (#65)
2026-08-14 16:27:59 +08:00
185 changed files with 12151 additions and 601 deletions
+12
View File
@@ -0,0 +1,12 @@
# 仓库内文本统一以 LF 存储,避免 Windows/WSL 混用产生全量行尾差异。
# 行尾差异会淹没真实改动,也会让 gitea.env 之类的配置在 bash 中带上 \r。
* text=auto eol=lf
*.py text eol=lf
*.md text eol=lf
*.json text eol=lf
*.ps1 text eol=crlf
*.png binary
*.jpg binary
*.zip binary
+25 -15
View File
@@ -1,9 +1,6 @@
## 基本信息
- 类型:需求 / 缺陷 / 重构
- 任务类型:单项目 / 协同
- 主项目:Sense / Brain / Bell / contracts / 根级
- 主 agent:
- 所属 Epic:#
- 所属 MVP / 版本:#
- 阶段:
@@ -21,20 +18,8 @@
- 仅影响的子项目 / 交付单元:
- 是否跨子项目:是 / 否
- 是否修改共享接口或契约:是 / 否;唯一事实来源:
- write_paths:
- 各子项目需要执行的验证:
## 协同接口
<!-- 单项目工单填写“不适用”;协同工单必须完整填写。 -->
- 生产者:
- 消费者:
- 契约/共享事实源:
- 兼容策略:不适用 / 向后兼容 / 发布新版本
- 被阻塞或需要适配的工单:
- 集成顺序:
## 原始需求
- 来源:用户对话 / Gitea / 其他
@@ -68,6 +53,22 @@
|---|---|---|---|
| | | | 是 / 否 |
## 设计与原型门禁
<!-- 先判断是否属于纯显示文案豁免,再选择最低成本、足以确认的设计证据。 -->
- 修改类型:纯显示文案 / 小范围 UI / 新组件 / 新页面或独立用户功能 / 重大交互或导航 / 非 UI / 恢复既有行为的 Bug
- 所需设计证据:无 / 标注截图 / 低保真图 / 已确认原型 / 架构、API、数据、状态或流程设计 / 原设计或复现证据
- 可编辑设计源、线上原型链接和访问检查:
- 审核版本、revision、复制版本或确认日期及识别方式:
- 本地 HTML 导出:未要求 / 用户明确要求 / 项目规则要求
- 本地 HTML 路径、版本和资源检查(仅显式导出时填写):
- 状态:无 / 草稿 / 已确认 / 已废弃
- 确认人、确认时间和覆盖范围:
- 无需 UI 原型或无需任何原型的原因:
<!-- 新页面、独立用户功能、重大交互或导航变化默认通过可访问且版本明确的线上原型审核;只有用户或项目规则明确要求时才导出 prototypes/<工单号>/<版本>/index.html。原型和文字需求未确认前不得编写生产代码。 -->
## 文档影响
<!-- 至少选择一项;不影响长期文档时必须写明原因。 -->
@@ -88,6 +89,15 @@
- [ ] 新增交付文档,受众与页面:
- [ ] 需要目标岗位或客户代表验证:是 / 否;验证方式:
## 任务记录与可选快照
- 单次任务事实来源:当前 Gitea 工单正文与评论
- [ ] 默认不创建任务快照
- [ ] 用户明确要求专项快照;用途和范围:
- [ ] 项目专用规则要求任务快照;规则入口:
<!-- 工单正文保存确认基线;重要变化、最终证据和验收结论通过评论追加。只有长期事实变化时才更新 Wiki 和同步镜像。 -->
## 验收标准
- [ ] <!-- 填写 -->
+1
View File
@@ -1,4 +1,5 @@
.codex/gitea.env
gitea.env
.codex/*.log
.codex/config.toml
__pycache__/
+91 -28
View File
@@ -1,8 +1,25 @@
# Agent 开发规则
本仓库采用 DevHarness 工作流:Gitea 工单是任务过程的事实来源,Gitea Wiki 是长期开发文档和任务归档的事实来源,Git 是代码与版本绑定资料的变更记录,`docs/` 只保存 Wiki 的只读镜像。人负责确认方案与验收,Agent 负责检查、实现、测试和留下证据。
本仓库采用 DevHarness 工作流:Gitea 工单是单次任务需求、变化、实现、测试、提交和验收的事实来源,Gitea Wiki 是长期开发文档的事实来源,Git 是代码与版本绑定资料的变更记录。`docs/` 默认保存核心 Wiki 的只读镜像,`docs/task/` 只保存人工明确要求的专项或历史兼容快照。人负责确认方案与验收,Agent 负责检查、实现、测试和留下证据。
开始工作前先阅读 [项目档案](docs/00-project-profile.md) 和任务涉及目录中的 `AGENTS.md`。目录越深的规则越具体,但不得削弱上级安全规则。
开始工作前先阅读任务涉及目录中的 `AGENTS.md`。目录越深的规则越具体,但不得削弱上级安全规则。
[项目档案](docs/00-project-profile.md) 按需阅读,不作为每次任务的固定前置。出现下列情况之一时必须读:需要环境、配置或凭据来源;需要确认目录边界;需要判断子项目与交付单元划分;需要 DevHarness 来源与基线;需要项目专用验收要求。只为查命令不必打开项目档案。
## 常用命令
所有命令默认从仓库根目录执行,与项目档案保持一致。
| 用途 | 命令 |
|---|---|
| 查看工作区 | `git status --short --branch` |
| 检查模板结构 | `python dev_scripts/harness.py check --strict` |
| 运行单元测试 | `python -m unittest discover -s tests -v` |
| 导出核心 Wiki 镜像 | `python dev_scripts/harness.py sync` |
| 检查核心 Wiki 镜像 | `python dev_scripts/harness.py sync --check` |
| 创建可选任务快照 | `python dev_scripts/harness.py archive 123 "修复登录超时"` |
| 增量导出已有快照 | `python dev_scripts/harness.py export` |
| 全量导出已有快照 | `python dev_scripts/harness.py export --all` |
## 1. 永久规则
@@ -18,14 +35,15 @@
新功能、缺陷修复、重构以及任何用户可感知或改变程序行为的修改,必须先建立单元任务工单。
以下小改动可以直接提交,不要求工单和任务归档:
以下小改动可以直接提交,不要求工单:
- 只改错别字、注释或文档措辞;
- 只做格式化、导入排序或不跨文件的内部变量改名;
- 补充类型标注或文档字符串且不改变行为;
- 删除已经确认无人使用的死代码。
- 只修改用户看到的界面显示文案,并且满足本文件“工单与设计证据双门禁”的全部豁免条件。
只要涉及接口、数据库、状态、权限、安全、并发、用户界面,或者无法确定是否改变行为,就必须建工单。
除严格符合界面显示文案豁免的修改外,只要涉及接口、数据库、状态、权限、安全、并发、用户界面,或者无法确定是否改变行为,就必须建工单。代码组件名、类名、变量、国际化键、API 字段和数据库字段不是显示文案,不适用豁免。
## 3. 需求到实施
@@ -37,24 +55,61 @@
6. 开始实施前检查分支和工作区,明确哪些现有改动不属于本任务。
7. 严格按工单范围实现;新发现的问题先记录,不顺手混入当前任务。
8. 执行与风险相称的测试,把关键结果和未验证部分更新到工单。实施过程中出现计划外、当前无法解除的问题时才标记“阻塞”。
9. 长期文档必须先修改 Wiki、读取确认,再运行 `python dev_scripts/sync_wiki_docs.py` 导出本地镜像;不得直接编辑 `docs/` 后反向覆盖 Wiki。
9. 只有长期事实变化时才修改 Wiki、读取确认,再运行 `python dev_scripts/harness.py sync` 导出本地镜像;没有长期文档影响时在工单说明原因并跳过 Wiki 同步。不得直接编辑镜像后反向覆盖 Wiki。
工单正文保存用户确认的任务基线;根因、范围、方案、风险或阻塞发生重要变化时追加评论。完成实现后用一条评论集中记录最终差异、测试、未验证内容、提交哈希和文档影响;用户验收后再追加验收时间与结论,不重复覆盖或抄写已有证据。
Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明确授权,不得默认绕过建单。
### Gitea 交互与工单最小读取
- 所有 Gitea 工单和 Wiki 的查询、创建、更新、评论、状态变更及关闭操作,优先使用项目已配置的 Gitea MCP。
- MCP 不可用或不支持所需操作时才回退 Gitea API,并在当前工单记录回退原因;初始化阶段尚无工单时记录到初始化工单草稿,建单后补回。凭据只从环境或 MCP 安全配置读取。
- 首次接手任务时读取工单确认基线和完成当前判断所需的评论,不因节省 Token 跳过范围、依赖、安全、验收或重要变更。
- 同一任务、同一会话且关键前提未变化时,复用仍有效的工单事实,优先关注当前状态、最新评论和首个未完成步骤,不重复分析已经确认且仍有效的内容。
- 会话、代码、配置、依赖、凭据、远端状态或关键前提变化,任务基线不清楚,或最新评论声明历史需求、方案、范围、风险或验收发生变化时,重新读取必要历史;无法判断影响范围时读取完整工单。
- 连接器不支持评论分页或增量读取时允许读取完整工单,但不得把“已读取全文”误当成需要重新分析全部历史,也不得为规避完整读取而新增本地工单、缓存或第二事实来源。
- 正确性、安全规则和已确认范围优先于 Token 优化;读取边界存在不确定时补读必要证据。
### 新项目 Wiki 初始化门禁
- 从本模板创建新项目时,先创建 Gitea 远端仓库并启用工单和 Wiki,再配置 `wiki-docs.json`;不得把模板自带的本地 `docs/` 当作新项目 Wiki 已初始化的证据。
- 优先使用项目已配置的 Gitea MCP 查询和写入 Wiki;MCP 不可用或不支持所需写操作时,才使用 Gitea API,并在初始化工单记录回退原因。凭据只从环境或 MCP 安全配置读取。
- 先查询线上页面列表;`Home` 不存在时必须先创建 `Home`,在线回读正文并记录 revision,然后再逐页创建或更新其他核心映射页面。
- 每个核心页面写入后必须在线回读并取得 revision。页面缺失、回读失败或没有 revision 时停止初始化,不得开始产品代码。
- 产品编码前必须运行 `python dev_scripts/harness.py sync --verify`;全部成功才表示线上 Wiki 和核心镜像初始化完成。
### 工单与设计证据双门禁
- 纯界面显示文案只有在不改变业务含义、流程、权限、状态、接口、数据、法律/安全/支付/单位等高风险含义、国际化键、程序标识符、布局和可访问性,且没有任何不确定时,才免工单和原型;修改后执行最小界面检查。
- 新页面、独立用户功能、重大交互或导航变化,必须先用 Quant-UX 或其他合适工具制作可审阅原型;用户确认原型、文字需求和覆盖范围后,才能建立或放行实现工单并编写生产代码。
- 上述完整原型形成待审核版本后,默认直接通过 Quant-UX 或其他设计工具的线上链接审核,不要求每次导出本地 HTML。工单必须记录可访问链接、版本/revision 或确认日期、审核版本识别方式、确认人、确认时间和覆盖范围;线上链接无法访问或无法区分版本时停止审核,等待用户确认等效方案。
- 只有用户明确要求 `导出原型 #N`、`导出全部原型`,或项目专用规则明确要求离线交付时,才导出到 `prototypes/<工单号>/<版本>/index.html`。已确认的本地快照不得原位覆盖;版本目录内资源使用相对路径,导出后检查入口、主要交互和资源完整性,但不自动提交。
- 现有界面的小范围样式或布局调整使用标注截图、低保真图或明确复用的现有规范;新组件记录状态、错误和边界。两者只要不符合纯文案豁免就必须建单。
- 后端、接口、数据处理和定时任务不强制 UI 原型,但必须先确认架构、API、数据、状态或流程设计;恢复既有确认行为的 Bug 可以复用原设计、截图、复现步骤或已有验收证据。
- 需要设计证据的工单记录链接或路径、版本/revision 或日期、状态、确认人、确认时间和覆盖范围;没有 UI 原型时记录替代技术设计或原因。
- 页面结构、主要流程、状态、权限、异常处理或验收结果变化时,必须更新原型或文字需求并重新确认后再继续正式编码。
- 草稿原型可以用于讨论;写入 Git/Wiki、多人协作或单独实施时建立设计任务。草稿和经明确授权的隔离技术验证都不得直接作为生产实现。
- 设计工具无法生成用户明确要求的可用 HTML 时,在工单记录限制并停止该导出或离线交付,等待用户确认等效方案;线上原型可访问且版本明确时不因此阻塞线上审核。纯显示文案、小范围 UI、非 UI 需求和恢复既有行为的 Bug 不强制建立完整线上原型或导出 HTML。
### 自然语言快捷指令
快捷指令只是本工作流的自然语言别名,不得绕过方案确认、前置依赖、安全规则、工单范围、Wiki 主源、必要验证或人工验收:
- `只分析`:只读检查并给出方案;不建单、不修改,停在等待确认。
- `建工单`:根据已确认方案创建单元任务工单;建单后停止,不修改代码。
- `执行工单 #N`:检查工单和依赖,实施、测试、提交、归档、推送并回写证据;停在“待验收”。
- `执行工单 #N`:检查工单和依赖,实施、测试、提交、推送并回写证据;仅在明确要求时创建任务快照;停在“待验收”。
- `建工单并做`:依次建单和执行,`建工单,做`、`建工单,做` 含义相同;停在“待验收”。
- `继续工单 #N`:核对工单、Git 和 Wiki 证据,从首个未完成步骤继续,不重复仍然有效的检查。
- `继续工单 #N`:优先核对当前状态、最新评论、Git 和必要 Wiki 证据,从首个未完成步骤继续;关键前提未变化时不重复读取和分析仍有效的内容。
- `检查工单 #N`:只读核对范围、验收、测试和证据并输出报告;不自动修复。
- `同步文档`:读取 Wiki、导出 `docs/` 并检查一致性;不修改 Wiki、不自动提交。
- `#N 验收通过`:仅在用户明确验收后,更新归档、同步并提交镜像、推送、同步父工单并关闭任务。
- `同步文档`:读取 Wiki、导出核心 `docs/` 并检查一致性,不处理任务归档;不修改 Wiki、不自动提交。
- `导出原型 #N`:人工触发导出指定工单已确认的原型版本,按工单和版本写入 `prototypes/`;不扩展范围、不自动提交。
- `导出全部原型`:人工触发导出当前项目已明确范围内的全部已确认原型;不自动提交。
- `导出任务归档`:人工触发 `python dev_scripts/harness.py export`,只导出新增或 revision 已变化的任务归档;不删除本地文件、不自动提交。
- `导出全部任务归档`:人工触发 `python dev_scripts/harness.py export --all`,读取并导出全部线上任务归档;不删除本地文件、不自动提交。
- `#N 验收通过`:仅在用户明确验收后,在工单追加验收结论;按需更新真实变化的长期 Wiki,推送、同步父工单并关闭任务;不创建或导出任务归档。
方案未确认或前置依赖未满足时,实施类指令必须停在对应门禁;除 `#N 验收通过` 外,快捷指令不得关闭待验收工单。Gitea 工单不导出全文,本地只保存 Wiki 任务归档镜像。详细语义见 [开发工作流](docs/01-workflow.md)。
方案未确认或前置依赖未满足时,实施类指令必须停在对应门禁;除 `#N 验收通过` 外,快捷指令不得关闭待验收工单。原型和任务快照的导出必须由用户明确提出或项目专用规则明确要求,其他指令不得隐式执行。Gitea 工单不导出全文,`docs/task/` 只是可能不完整的专项或历史兼容快照。详细语义见 [开发工作流](docs/01-workflow.md)。
### 需求记录与流转
@@ -62,13 +117,13 @@ Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明
- 工单中的目标、非目标、已确认方案、验收标准和文档影响构成确认后的正式任务需求。
- 影响范围、接口、数据、风险或验收的需求变化必须记录日期、内容、原因和用户确认;会改变已确认结果时先更新工单并等待再次确认。
- 不复制完整聊天,不保存 Agent 内部推理,不写入密码、令牌、个人数据或生产数据;包含敏感信息的原话必须删除敏感部分或改写为脱敏摘要。
- 长期有效的产品需求、业务规则和系统边界进入对应 Wiki 主题页并导出 `docs/`;完成结果进入 Wiki 任务归档并导出 `docs/task/`。Gitea 工单全文不导出到仓库。
- 长期有效的产品需求、业务规则和系统边界进入对应 Wiki 主题页并导出核心 `docs/`;单次任务的完成结果和验收保留在工单正文与评论。只有用户明确要求专项快照或项目专用规则要求时才创建 Wiki 任务快照并按需导出 `docs/task/`。Gitea 工单全文不导出到仓库。
详细记录边界见 [开发工作流](docs/01-workflow.md) 与 [业务规则和术语](docs/03-business-rules-and-glossary.md)。
### 效率与范围控制
本节只用于减少无关工作和重复检查,不得削弱安全规则、已确认方案、工单范围、必要测试、Wiki 同步、Git 提交和验收归档要求。
本节只用于减少无关工作和重复检查,不得削弱安全规则、已确认方案、工单范围、必要测试、必要的长期文档同步、Git 提交和人工验收要求。
#### 严格控制范围
@@ -116,28 +171,26 @@ Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明
## 6. Git 与验证
- `explore` 是旧实现的只读聚合快照,不接受新功能、修复或文档演进;需要恢复旧行为时从其来源提交读取证据,不在该分支续写产品。
- `main` 是用户审核基线,禁止直接开发、直接提交或未经用户明确审核的合并。
- 新任务分支必须从当前 `dev` 创建,完成后通过 PR 合回 `dev`;不得把功能分支直接合入 `main`。
- `dev` 完成集成测试后仍不能自动进入 `main`;只有用户明确表示审核/验收通过,Agent 才能执行 `dev → main`。
- 紧急修复也必须建单并取得用户对分支与合并路径的明确授权,不默认绕过 `dev`。
- 提交只包含当前工单相关文件。
- 实现提交信息引用工单号,例如:`fix: 修复登录超时 (#123)`。
- 不为流程制造空提交。
- 优先运行项目档案中记录的格式检查、静态检查、单元测试和必要的集成测试。
- 不能验证的真机、生产、迁移或并发行为必须写入工单和归档。
- 不能验证的真机、生产、迁移或并发行为必须写入工单;存在明确要求的任务快照时再同步记录。
- Windows 环境优先使用当前已配置的 PowerShell;可选择时优先 PowerShell 7 `pwsh.exe`,不得仅为设置编码重复启动一层 PowerShell。
- 文本文件读写在命令支持时显式指定 UTF-8;文件解码和控制台输出分别处理,只有出现真实乱码或已知宿主非 UTF-8 时才设置当前进程的输出编码或 Python UTF-8 环境变量。
- 不得默认使用 `-ExecutionPolicy Bypass`;只有可信 `.ps1`确实被执行策略阻止且没有更小替代方案时,才对该次进程使用并在工单记录原因。
## 7. 完成、验收和归档
## 7. 完成和验收
1. 逐项完成验收、测试和实现提交,并把最终方案、差异、结果、提交及遗留问题写回工单。
1. 逐项完成验收、测试和实现提交,在工单追加最终证据评论,记录最终方案、差异、结果、提交、遗留问题和文档影响。
2. 工单保持“待验收”,用户没有明确验收通过前不得关闭。
3. 运行 `python dev_scripts/new_task_archive.py <编号> "<短标题>"`,先创建 Wiki 归档,再登记并导出本地镜像。
4. 读取确认 Wiki,运行 `python dev_scripts/sync_wiki_docs.py --check`;归档镜像单独提交,并把页面、revision、路径和提交哈希写回工单。
5. 用户验收通过后关闭单元工单,并同步更新 MVP 和 Epic。
3. 有长期文档影响时,读取确认 Wiki,运行 `python dev_scripts/harness.py sync --check` 检查核心镜像,并把页面 revision 和镜像提交哈希写回工单;没有长期文档影响时不运行 Wiki 同步。
4. 用户验收通过后,在工单追加验收时间和结论,关闭单元工单,并同步更新 MVP 和 Epic;没有真实变化的 Wiki 不重复更新或检查。
5. 默认不创建任务归档。只有用户明确要求专项快照或项目专用规则明确要求时,才运行 `python dev_scripts/harness.py archive <编号> "<短标题>"`;`export` 和 `export --all` 同样必须显式触发。
MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才能关闭 MVP。Epic 的全部范围完成后才能关闭 Epic。
详细归档顺序和字段见 [开发工作流](docs/01-workflow.md)。
详细证据回写、可选快照和文档同步边界见 [开发工作流](docs/01-workflow.md)。
## 8. 可维护性
@@ -158,8 +211,9 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
- 每个单元任务必须在工单中选择“无长期文档影响并说明原因”或列出需要更新的 Wiki 页面。
- 启动、测试、部署、排错命令,模块入口、目录职责、主要调用路径,配置、API、数据结构、状态、业务规则、安全边界、日志位置发生变化时,必须更新对应 Wiki。
- 部署命令变化时,有常驻服务的项目更新自己的 `Deployment-and-Operations` 页面(由 [部署文档模板](docs/templates/deployment.md) 复制建立);没有常驻服务的项目记录为无部署文档影响,不创建空的部署页。
- 普通内部重构只有在入口、行为、配置和验证方式均未改变时,才可以记录为不影响长期文档。
- 必需核心页面及结构以 `python dev_scripts/check_harness.py --strict` 和 [新项目文档初始化](docs/07-new-project-documentation-setup.md) 为准;稳定文档与任务归档的分工见 [开发工作流](docs/01-workflow.md)。
- 必需核心页面及结构以 `python dev_scripts/harness.py check --strict` 和 [新项目文档初始化](docs/07-new-project-documentation-setup.md) 为准;稳定文档与可选历史快照的分工见 [开发工作流](docs/01-workflow.md)。
## 9. 引导提交例外
@@ -169,11 +223,20 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
## 10. 项目专用规则
### YoVision 分支治理
- `explore` 是旧实现的只读聚合快照,不接受新功能、修复或文档演进;需要恢复旧行为时从其来源提交读取证据,不在该分支续写产品。
- `main` 是用户审核基线,禁止直接开发、直接提交或未经用户明确审核的合并。
- 新任务分支必须从当前 `dev` 创建,完成后通过 PR 合回 `dev`;不得把功能分支直接合入 `main`。
- `dev` 完成集成测试后仍不能自动进入 `main`;只有用户明确表示审核/验收通过,Agent 才能执行 `dev → main`。
- 紧急修复也必须建单并取得用户对分支与合并路径的明确授权,不默认绕过 `dev`。
<!-- 在项目初始化时填写不可违反的技术、安全和业务约束。复杂子项目请在其目录中增加 AGENTS.md。 -->
- 长期开发文档以 Gitea Wiki 为事实来源,`docs/` 是显式映射生成的只读镜像。
- Wiki 与镜像的固定顺序是:修改 Wiki → 读取确认 → 导出 `docs` → 校验差异 → 提交镜像。
- 同步配置只允许写入 `docs/` 下的 Markdown;发现镜像有未提交修改时必须停止。
- 长期开发文档以 Gitea Wiki 为事实来源,单次任务证据以 Gitea 工单为事实来源;`docs/` 默认保存显式映射生成的核心只读镜像,`docs/task/` 是人工明确要求的专项或历史兼容快照,可能不完整或不是最新状态。
- 核心 Wiki 与镜像的固定顺序是:修改 Wiki → 读取确认 → 导出核心 `docs` → 校验差异 → 提交镜像。
- 默认不创建任务归档;`archive`、`导出任务归档` 或 `导出全部任务归档` 必须由用户明确提出或项目专用规则明确要求,且不得自动传播删除或重命名。
- 同步配置只允许写入 `docs/` 下的 Markdown;发现核心镜像有未提交修改时必须停止。
- Gitea 凭据只通过进程环境或 MCP 安全配置提供,不得写入仓库。
- `dev_scripts/` 只存放 DevHarness 自身工具;业务项目的通用脚本必须使用独立目录,不得混放。
- 本仓库包含 `Sense/`、`Brain/`、`Bell/` 三个独立开发与交付单元;Sense 与 Bell 是认证、数据、部署和发布完全独立的销售产品,Brain 是无界面推理交付单元。
+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"
+54 -1
View File
@@ -8,7 +8,48 @@ YoVision 是一个单仓多项目的智能视频事件平台,包含三个可
Sense 与 Bell 是账户、数据、部署和发布边界完全独立的两个销售产品;Brain 是无界面的独立推理交付单元,默认随 Sense 部署。跨项目协作只通过 `contracts/` 中的版本化契约。
开始工作前阅读 [AGENTS.md](AGENTS.md) 和 [文档中心](docs/README.md)。本仓库采用 DevHarness:Gitea 工单记录任务过程,Gitea Wiki 保存长期文档,`docs/` 是 Wiki 的只读镜像。
开始工作前阅读 [AGENTS.md](AGENTS.md) 和 [文档中心](docs/README.md)。本仓库采用 DevHarness:Gitea 工单是单次任务唯一事实来源,Gitea Wiki 保存长期文档,`docs/` 默认保存核心 Wiki 的只读镜像。
## 工作闭环
```text
讨论需求或缺陷
-> 阅读代码并提出方案
-> 人工确认方案
-> 创建 Epic / MVP / 单元任务工单
-> Agent 实现并测试
-> 提交代码并集中回写工单证据
-> 人工验收后记录结论并关闭工单
-> 长期事实变化时才同步 Wiki
```
默认不创建任务归档;`docs/task/` 只保存人工明确要求的专项或历史兼容快照。
## 首次初始化顺序
1. 创建 Gitea 远端仓库并推送当前引导提交,启用工单和 Wiki。
2. 配置 `wiki-docs.json` 和安全访问方式;优先使用已配置的 Gitea MCP,MCP 不可用时才使用 Gitea API并记录原因。
3. 查询线上 Wiki;`Home` 不存在时先创建并回读 `Home`,取得 revision 后再创建其他核心页面。
4. 本地 `docs/` 的存在不能证明线上 Wiki 已初始化。
5. 开始产品编码前运行:
```powershell
python dev_scripts/harness.py sync --verify
```
YoVision 已完成远端与 Wiki 初始化,上述步骤用于维护者理解门禁和以后建立新仓库。
## 常用命令
```powershell
python dev_scripts/harness.py check --strict
python dev_scripts/harness.py sync
python dev_scripts/harness.py sync --check
python -m unittest discover -s tests -v
git diff --check
```
只有明确要求时才运行 `python dev_scripts/harness.py archive <编号> "<标题>"`、`export` 或 `export --all`。
## 分支模型
@@ -18,3 +59,15 @@ Sense 与 Bell 是账户、数据、部署和发布边界完全独立的两个
- 只有用户明确审核通过,才能将 `dev` 合入 `main`。
Sense 与 Bell 的新实现必须从根 `goadmin-baseline.json` 冻结的 go-admin 和 go-admin-ui 完整提交派生,开发时必须核对对应 commit 的 go-admin-doc。仅参考 GoAdmin 的技术栈、布局或视觉不属于本项目认可的二次开发。
## Harness 目录
```text
AGENTS.md Agent 通用规则与 YoVision 专用门禁
CLAUDE.md Claude Code 入口和三项目角色路由
.gitea/issue_template/ Epic、MVP、单元任务模板
docs/ 核心 Wiki 只读镜像及可选历史快照
wiki-docs.json 核心 Wiki 页面显式映射
dev_scripts/harness.py check / sync / archive / export 单一入口
dev_scripts/wiki_docs.py Gitea Wiki 客户端与镜像生成库
```
+3
View File
@@ -3,5 +3,8 @@ server/*.exe
server/*.db
ui/node_modules/
ui/dist/
dist/
!scripts/build/
!scripts/build/**
.env
*.local.yml
+157
View File
@@ -0,0 +1,157 @@
# Sense Windows 安装与运行
本说明适用于 `sense-windows-amd64` 交付包。Sense 后端和管理网页来自冻结的 GoAdmin/go-admin-ui 基线;启动仍使用 GoAdmin Cobra 的 `migrate` 与 `server` 命令。Brain、Bell 不需要启动。
## 1. 准备环境
- Windows 10/11 或 Windows Server 2019 及以上,amd64。
- PostgreSQL 17;先由数据库管理员创建独立的 Sense 数据库和最小权限账号。
- 已审核版本与许可证的 Windows amd64 `mediamtx.exe`,放到 `bin\mediamtx.exe`。
- 备份、恢复时还需要 PostgreSQL 客户端的 `pg_dump.exe`、`pg_restore.exe`;可把目录加入 PATH,或配置 `SENSE_POSTGRES_BIN`。
交付包不包含 PostgreSQL、数据库数据、管理员默认密码、摄像头密码或客户配置。不要把包解压到所有用户都可写的共享目录。
## 2. 配置 production
编辑 `config\sense.env`。脚本只按 `NAME=value` 读取白名单字段,不会执行文件内容。值中可以包含 `#`、`&`、`;`、空格或 `=`;如首尾使用成对单/双引号,外层引号会被移除。
至少填写:
```dotenv
SENSE_DATABASE_URL=host=127.0.0.1 port=5432 user=sense password=请替换 dbname=sense sslmode=disable
SENSE_JWT_SECRET=请替换为至少32字符的随机值
SENSE_MEDIAMTX_MODE=managed
SENSE_MEDIAMTX_BINARY=bin\mediamtx.exe
SENSE_MEDIAMTX_CONFIG=config\mediamtx.yml
```
用 PowerShell 生成随机值,不要把输出写入工单、Wiki 或 Git:
```powershell
$bytes = New-Object byte[] 48
[Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
[Convert]::ToBase64String($bytes)
```
同名的非空进程环境变量优先于 `sense.env`。这便于由服务管理器或秘密管理工具注入值;空进程变量不会覆盖文件值。脚本不会打印数据库连接串、JWT secret、Bootstrap token 或摄像头密钥。
运行启动前检查:
```bat
check-sense.bat
```
它会检查配置格式、HTTP 端口、PostgreSQL TCP 连接、数据库名、JWT 长度、网页文件和 MediaMTX 模式。`managed` 模式要求二进制与配置文件存在;`external` 模式要求本机 Control API 已可连接。production 不允许 `disabled`。
## 3. 启动、迁移与停止
首次及日常启动:
```bat
start-sense.bat
```
脚本先执行 `sense.exe migrate -c data\runtime\settings.yml`,成功后再执行 `sense.exe server -c ...`。迁移失败时不会启动 HTTP 服务。迁移会检查 PostgreSQL 数据库是否存在;脚本不会自动创建生产数据库。
`config\db.sql` 与 `config\pg.sql` 是冻结 GoAdmin 首次初始化所需的无秘密基线数据,必须和 `sense.exe` 同版本保留;删除它们会导致空库首次迁移失败。
浏览器访问 `http://127.0.0.1:18080/`。当前窗口按 `Ctrl+C` 可让 Sense 优雅停止,并请求停止由它启动的 MediaMTX。也可在另一管理员终端运行:
```bat
stop-sense.bat
```
停止脚本只会强制停止监听配置端口、且可执行文件确实位于当前交付包的 Sense 进程树;端口属于其他程序时会拒绝操作。日常维护优先在启动窗口按 `Ctrl+C` 完成优雅停止,窗口丢失或进程失去响应时再使用停止脚本。
只执行迁移或禁用启动时自动迁移:
```bat
migrate-sense.bat
start-sense.bat -SkipMigration
```
只有已完成备份并明确掌握版本状态时才使用 `-SkipMigration`。也可把 `SENSE_AUTO_MIGRATE=false` 放到外部进程环境中。
## 4. 创建首个管理员与修改密码
Sense 不提供生产默认管理员。首次初始化:
1. 生成至少 32 字符的一次性随机值,临时填入 `SENSE_BOOTSTRAP_TOKEN`。
2. 启动 Sense。
3. 在另一个终端运行 `initialize-admin.bat -Username admin`,按隐藏提示输入至少 6 位密码。
4. 成功后立即清空 `SENSE_BOOTSTRAP_TOKEN` 并重启 Sense。
Bootstrap 只允许在用户表为空时执行一次,token 通过请求头传递,不放在 JSON 或命令行中。不要把密码作为 bat 参数。
管理员登录后,在右上角头像进入“个人中心 → 修改密码”。密码至少 6 个字符;修改成功后重新登录。其他管理员的密码重置只能由授权管理员通过 GoAdmin 用户管理入口完成并形成审计记录。
## 5. 备份与恢复
创建 PostgreSQL custom-format 备份:
```bat
backup-sense.bat
backup-sense.bat -OutputDirectory D:\SenseBackups
```
默认写入包外可单独保护的 `backups` 目录。脚本从连接串移除密码后再构造 `pg_dump` 命令,密码只通过子进程环境传递。
恢复会清理并替换目标库中的对象,必须先停止 Sense、备份当前库,并两次确认数据库名:
```bat
restore-sense.bat -BackupFile D:\SenseBackups\sense-sense-20260815-120000.dump -ConfirmDatabaseName sense
migrate-sense.bat
```
恢复脚本还会交互要求输入 `RESTORE-数据库名`;名称不完全一致时拒绝执行。不要对来源不明或版本不匹配的备份执行恢复。
## 6. Demo 隔离
Demo 使用独立的 `config\sense.demo.env` 和 `SENSE_DEMO_DATABASE_URL`:
```bat
start-sense.bat demo
```
数据库名必须包含 `demo` 或 `test`,且不会回退到 production 的 `SENSE_DATABASE_URL`。默认 HTTP 端口为 18081、MediaMTX 为 disabled。Demo 数据不属于生产数据,不得迁入生产库或用于客户交付。
Demo 启动窗口按 `Ctrl+C` 停止;窗口不可用时执行 `stop-sense.bat -Mode demo`。
## 7. 日志与排错
- Sense 文件日志:`logs\`
- 运行时生成的 GoAdmin YAML:`data\runtime\settings.yml`(包含秘密,不得复制到工单或发送给无权限人员)
- MediaMTX 日志:由 Sense 启动窗口和 MediaMTX 自身输出提供
- 包完整性:`MANIFEST.sha256`
常见错误:
- `SENSE_DATABASE_URL is required`:编辑当前包的 `config\sense.env`,或设置非空进程变量。
- `PostgreSQL is unreachable`:确认服务、地址、端口和防火墙;数据库不存在会在迁移阶段明确失败。
- `port ... already in use`:先运行 `stop-sense.bat`,或确认占用者后修改 `SENSE_PORT`。
- `Managed MediaMTX binary not found`:把已审核的 `mediamtx.exe` 放入 `bin`,不要只复制配置文件。
- `External MediaMTX Control API is unreachable`:启动外部实例并确认 API 只监听回环地址。
- `migration failed`:不要跳过;先备份,保留错误输出,核对数据库账号权限和版本。
- 网页返回 404:检查 `web\index.html` 与 `SENSE_WEB_ROOT=web`,不要把源码目录或 `node_modules` 放进包。
- 网页返回 200 但白屏:在浏览器开发者工具检查 JS/CSS 是否 404;正式包的构建审计会逐项核对 `web\index.html` 引用的本地资源,缺失时拒绝生成交付包。
## 8. 构建交付包
开发机在仓库根目录执行:
```bat
Sense\scripts\build\build-windows.bat
```
若要把已审核的 MediaMTX 一并放入包:
```powershell
Sense\scripts\build\build-windows.ps1 -MediaMTXPath D:\approved\mediamtx.exe
```
构建严格检查 Go 1.26.5、Node 22.22.1 和 pnpm 9.15.1,生成:
- `Sense\dist\sense-windows-amd64\`
- `Sense\dist\sense-windows-amd64.zip`
构建末尾会审计包内容:逐项核对 `web\index.html` 引用的本地 JS/CSS,并拒绝 `node_modules`、嵌套 `dist`、Git/缓存目录、数据库/备份文件、非空秘密字段、常见默认密码和私钥标记。`dist` 为可重建产物,不提交 Git。
+13
View File
@@ -13,6 +13,15 @@ Sense 是从项目冻结的 GoAdmin 后端与 go-admin-ui 前端源码独立派
## 本地验证入口
已使用 Windows 打包脚本生成 `Sense\dist\sense-windows-amd64` 后,可从 Sense 项目目录直接启动交付包:
```bat
start_sense.bat
start_sense.bat demo
```
该入口只负责定位并调用包内 `start-sense.bat`;生产配置、迁移和 MediaMTX 编排仍由交付包处理。交付包不存在时,入口会提示先运行 `scripts\build\build-windows.bat`,不会自动构建或启动开发服务。
后端:
```powershell
@@ -42,4 +51,8 @@ go run . server -c C:\secure-path\sense-settings.yml
设备台账本身可以在不配置摄像头凭据的情况下使用。创建或更新 ONVIF/RTSP 凭据前,还必须在启动进程环境中设置 `SENSE_CREDENTIAL_KEY`:该值是随机 32 字节密钥的 Base64 编码,仅保存在仓库外。变量名模板见 `server/config/credential.env.example`;不要把真实值写入配置、脚本、日志或工单。密钥缺失或格式不正确时,Sense 会拒绝凭据写入,不会降级为明文存储。
使用 ONVIF 发现或手工接入前,还必须设置 `SENSE_ONVIF_DISCOVERY_IP` 和 `SENSE_ONVIF_ALLOWED_CIDRS`。前者只能是获准用于 WS-Discovery 的本机网卡地址;后者是获准访问的摄像头网段(多个 CIDR 用逗号分隔)。未配置时系统会给出可行动提示且不会扫描任意网卡;手工地址、Media XAddr 和 Stream URI 同样受该网段限制,并拒绝重定向或 URL 内凭据。
MediaMTX 保持独立二进制。配置 `SENSE_MEDIAMTX_BINARY`、`SENSE_MEDIAMTX_CONFIG` 和只允许回环地址的 `SENSE_MEDIAMTX_API`。Sense 只生成无摄像头凭据的基础配置;路径和凭据在运行时通过回环 Control API 下发。模板见 `server/config/mediamtx/mediamtx.yml.example`。
仓库不提供默认账号、默认密码或可用密钥。首位管理员通过受仓库外 `SENSE_BOOTSTRAP_TOKEN` 保护的一次性初始化接口创建,详细步骤以项目 Wiki 的本地开发与验证页为准。
+5
View File
@@ -0,0 +1,5 @@
logLevel: info
api: true
apiAddress: 127.0.0.1:9997
metrics: false
paths: {}
+18
View File
@@ -0,0 +1,18 @@
# Demo mode must use a disposable PostgreSQL database whose name contains
# "demo" or "test". It never falls back to SENSE_DATABASE_URL.
SENSE_MODE=demo
SENSE_HOST=127.0.0.1
SENSE_PORT=18081
SENSE_DEMO_DATABASE_URL=
SENSE_JWT_SECRET=
SENSE_BOOTSTRAP_TOKEN=
SENSE_CREDENTIAL_KEY=
SENSE_ONVIF_DISCOVERY_IP=
SENSE_ONVIF_ALLOWED_CIDRS=
SENSE_MEDIAMTX_MODE=disabled
SENSE_MEDIAMTX_BINARY=
SENSE_MEDIAMTX_CONFIG=
SENSE_MEDIAMTX_API=http://127.0.0.1:9997
SENSE_WEB_ROOT=web
SENSE_AUTO_MIGRATE=true
SENSE_POSTGRES_BIN=
+18
View File
@@ -0,0 +1,18 @@
# Sense production configuration. Copy this file as config\sense.env.
# Values are parsed as data; this file is never executed as a script.
SENSE_MODE=production
SENSE_HOST=127.0.0.1
SENSE_PORT=18080
SENSE_DATABASE_URL=
SENSE_JWT_SECRET=
SENSE_BOOTSTRAP_TOKEN=
SENSE_CREDENTIAL_KEY=
SENSE_ONVIF_DISCOVERY_IP=
SENSE_ONVIF_ALLOWED_CIDRS=
SENSE_MEDIAMTX_MODE=managed
SENSE_MEDIAMTX_BINARY=bin\mediamtx.exe
SENSE_MEDIAMTX_CONFIG=config\mediamtx.yml
SENSE_MEDIAMTX_API=http://127.0.0.1:9997
SENSE_WEB_ROOT=web
SENSE_AUTO_MIGRATE=true
SENSE_POSTGRES_BIN=
+5
View File
@@ -0,0 +1,5 @@
Place the approved Windows amd64 mediamtx.exe in this package's bin directory,
or pass -MediaMTXPath to scripts\build\build-windows.ps1.
Sense does not redistribute MediaMTX automatically. Verify its version,
license, checksum, and customer approval before delivery.
+37
View File
@@ -0,0 +1,37 @@
param([Parameter(Mandatory = $true)][string]$WebRoot)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath($WebRoot)
$indexPath = Join-Path $root 'index.html'
if (-not (Test-Path -LiteralPath $indexPath -PathType Leaf)) {
throw "Web index not found: $indexPath"
}
$rootPrefix = $root.TrimEnd('\') + '\'
$html = Get-Content -LiteralPath $indexPath -Raw
$references = [regex]::Matches($html, '(?i)(?:src|href)\s*=\s*["''](?<path>[^"'']+)["'']')
$checked = 0
foreach ($match in $references) {
$assetReference = $match.Groups['path'].Value.Trim()
if (-not $assetReference -or $assetReference.StartsWith('//') -or $assetReference -match '^[a-z][a-z0-9+.-]*:') {
continue
}
$assetPath = ($assetReference -split '[?#]', 2)[0]
if ([IO.Path]::GetExtension($assetPath).ToLowerInvariant() -notin @('.js', '.css')) {
continue
}
$relative = [Uri]::UnescapeDataString($assetPath).TrimStart('/').Replace('/', '\')
if (-not $relative) { throw "Web index contains an empty local asset path: $assetReference" }
$resolved = [IO.Path]::GetFullPath((Join-Path $root $relative))
if (-not $resolved.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "Web index asset escapes the web root: $assetReference"
}
if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) {
throw "Web index references missing local asset: $assetReference"
}
$checked++
}
if ($checked -eq 0) { throw 'Web index does not reference any local JavaScript or CSS assets.' }
Write-Host "Sense web asset audit passed: $checked local references."
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0build-windows.ps1" %*
exit /b %errorlevel%
+117
View File
@@ -0,0 +1,117 @@
param([string]$MediaMTXPath = '')
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$senseRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
$serverRoot = Join-Path $senseRoot 'server'
$uiRoot = Join-Path $senseRoot 'ui'
$distRoot = Join-Path $senseRoot 'dist'
$target = Join-Path $distRoot 'sense-windows-amd64'
$archive = Join-Path $distRoot 'sense-windows-amd64.zip'
$staging = Join-Path $distRoot ('.sense-windows-amd64.staging-' + $PID)
function Assert-ChildPath([string]$Parent, [string]$Child) {
$parentPath = [IO.Path]::GetFullPath($Parent).TrimEnd('\') + '\'
$childPath = [IO.Path]::GetFullPath($Child)
if (-not $childPath.StartsWith($parentPath, [StringComparison]::OrdinalIgnoreCase)) {
throw "Unsafe build path outside $Parent`: $Child"
}
}
function Get-SenseFileSha256([string]$Path) {
$sha256 = [Security.Cryptography.SHA256]::Create()
$stream = [IO.File]::OpenRead($Path)
try {
return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace('-', '')
} finally {
$stream.Dispose()
$sha256.Dispose()
}
}
Assert-ChildPath $senseRoot $distRoot
Assert-ChildPath $distRoot $target
Assert-ChildPath $distRoot $archive
Assert-ChildPath $distRoot $staging
$goVersion = ''
Push-Location $serverRoot
try { $goVersion = (& go env GOVERSION).Trim() } finally { Pop-Location }
$nodeVersion = (& node --version).Trim().TrimStart('v')
$pnpmVersion = (& corepack pnpm@9.15.1 --version).Trim()
if ($goVersion -ne 'go1.26.5') { throw "Go 1.26.5 is required; module toolchain reported $goVersion." }
if ($nodeVersion -ne '22.22.1') { throw "Node 22.22.1 is required; found $nodeVersion." }
if ($pnpmVersion -ne '9.15.1') { throw "pnpm 9.15.1 is required; corepack reported $pnpmVersion." }
$hadNodeModules = Test-Path -LiteralPath (Join-Path $uiRoot 'node_modules')
$hadUIDist = Test-Path -LiteralPath (Join-Path $uiRoot 'dist')
try {
New-Item -ItemType Directory -Force -Path $distRoot | Out-Null
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
New-Item -ItemType Directory -Path $staging | Out-Null
Push-Location $uiRoot
try {
& corepack pnpm@9.15.1 install --frozen-lockfile
if ($LASTEXITCODE -ne 0) { throw 'pnpm install failed.' }
& corepack pnpm@9.15.1 run build:prod
if ($LASTEXITCODE -ne 0) { throw 'Sense UI production build failed.' }
} finally { Pop-Location }
$oldGOOS, $oldGOARCH, $oldCGO = $env:GOOS, $env:GOARCH, $env:CGO_ENABLED
try {
$env:GOOS = 'windows'; $env:GOARCH = 'amd64'; $env:CGO_ENABLED = '0'
Push-Location $serverRoot
try {
& go build -trimpath -ldflags '-s -w' -o (Join-Path $staging 'sense.exe') .
if ($LASTEXITCODE -ne 0) { throw 'Sense server Windows build failed.' }
} finally { Pop-Location }
} finally {
$env:GOOS, $env:GOARCH, $env:CGO_ENABLED = $oldGOOS, $oldGOARCH, $oldCGO
}
Copy-Item -LiteralPath (Join-Path $uiRoot 'dist') -Destination (Join-Path $staging 'web') -Recurse
New-Item -ItemType Directory -Path (Join-Path $staging 'scripts\runtime'), (Join-Path $staging 'config'), (Join-Path $staging 'bin') | Out-Null
Copy-Item -Path (Join-Path $senseRoot 'scripts\runtime\*.ps1') -Destination (Join-Path $staging 'scripts\runtime')
foreach ($name in @('start-sense', 'stop-sense', 'check-sense', 'migrate-sense', 'backup-sense', 'restore-sense', 'initialize-admin')) {
Copy-Item -LiteralPath (Join-Path $senseRoot "scripts\runtime\$name.bat") -Destination (Join-Path $staging "$name.bat")
}
Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.env.example') -Destination (Join-Path $staging 'config\sense.env.example')
Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.env.example') -Destination (Join-Path $staging 'config\sense.env')
Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.demo.env.example') -Destination (Join-Path $staging 'config\sense.demo.env.example')
Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.demo.env.example') -Destination (Join-Path $staging 'config\sense.demo.env')
Copy-Item -LiteralPath (Join-Path $senseRoot 'config\mediamtx.yml') -Destination (Join-Path $staging 'config\mediamtx.yml')
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\db.sql') -Destination (Join-Path $staging 'config\db.sql')
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\pg.sql') -Destination (Join-Path $staging 'config\pg.sql')
Copy-Item -LiteralPath (Join-Path $senseRoot 'README-WINDOWS.md') -Destination (Join-Path $staging 'README-WINDOWS.md')
Copy-Item -LiteralPath (Join-Path $senseRoot 'package\README-MEDIAMTX.txt') -Destination (Join-Path $staging 'bin\README-MEDIAMTX.txt')
Copy-Item -LiteralPath (Join-Path $senseRoot 'LICENSES') -Destination (Join-Path $staging 'LICENSES') -Recurse
if (-not [string]::IsNullOrWhiteSpace($MediaMTXPath)) {
$mediaSource = [IO.Path]::GetFullPath($MediaMTXPath)
if (-not (Test-Path -LiteralPath $mediaSource -PathType Leaf)) { throw "MediaMTX binary not found: $mediaSource" }
Copy-Item -LiteralPath $mediaSource -Destination (Join-Path $staging 'bin\mediamtx.exe')
}
$commit = (& git -C (Split-Path $senseRoot -Parent) rev-parse HEAD).Trim()
[IO.File]::WriteAllLines((Join-Path $staging 'VERSION.txt'), @(
"source_commit=$commit", 'go=1.26.5', 'node=22.22.1', 'pnpm=9.15.1'
), (New-Object Text.UTF8Encoding($false)))
& (Join-Path $PSScriptRoot 'test-package.ps1') -PackageRoot $staging
if ($LASTEXITCODE -ne 0) { throw 'Sense package audit failed.' }
$manifest = foreach ($file in Get-ChildItem -LiteralPath $staging -Recurse -File | Sort-Object FullName) {
$relative = $file.FullName.Substring($staging.Length + 1).Replace('\', '/')
"$(Get-SenseFileSha256 -Path $file.FullName) $relative"
}
[IO.File]::WriteAllLines((Join-Path $staging 'MANIFEST.sha256'), $manifest, (New-Object Text.UTF8Encoding($false)))
if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Recurse -Force }
Move-Item -LiteralPath $staging -Destination $target
if (Test-Path -LiteralPath $archive) { Remove-Item -LiteralPath $archive -Force }
Compress-Archive -LiteralPath $target -DestinationPath $archive -CompressionLevel Optimal
Write-Host "Sense Windows package: $target"
Write-Host "Sense Windows archive: $archive"
} finally {
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
if (-not $hadUIDist -and (Test-Path -LiteralPath (Join-Path $uiRoot 'dist'))) { Remove-Item -LiteralPath (Join-Path $uiRoot 'dist') -Recurse -Force }
if (-not $hadNodeModules -and (Test-Path -LiteralPath (Join-Path $uiRoot 'node_modules'))) { Remove-Item -LiteralPath (Join-Path $uiRoot 'node_modules') -Recurse -Force }
}
+37
View File
@@ -0,0 +1,37 @@
param([Parameter(Mandatory = $true)][string]$PackageRoot)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath($PackageRoot)
if (-not (Test-Path -LiteralPath $root -PathType Container)) { throw "Package directory not found: $root" }
$required = @(
'sense.exe', 'start-sense.bat', 'stop-sense.bat', 'check-sense.bat',
'migrate-sense.bat', 'backup-sense.bat', 'restore-sense.bat',
'initialize-admin.bat', 'README-WINDOWS.md', 'config\sense.env',
'config\sense.env.example', 'config\sense.demo.env',
'config\mediamtx.yml', 'config\db.sql', 'config\pg.sql',
'web\index.html', 'scripts\runtime\sense-common.ps1'
)
foreach ($relative in $required) {
if (-not (Test-Path -LiteralPath (Join-Path $root $relative))) { throw "Package is missing required path: $relative" }
}
& (Join-Path $PSScriptRoot 'assert-web-assets.ps1') -WebRoot (Join-Path $root 'web')
$forbiddenDirectories = Get-ChildItem -LiteralPath $root -Recurse -Directory | Where-Object { $_.Name -in @('node_modules', '.git', 'dist', '.cache') }
if ($forbiddenDirectories) { throw "Package contains forbidden build directory: $($forbiddenDirectories[0].FullName)" }
$forbiddenFiles = Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $_.Extension -in @('.db', '.sqlite', '.sqlite3', '.dump', '.bak') }
if ($forbiddenFiles) { throw "Package contains database or backup data: $($forbiddenFiles[0].FullName)" }
$configFiles = @((Join-Path $root 'config\sense.env'), (Join-Path $root 'config\sense.demo.env'))
foreach ($configFile in $configFiles) {
$content = Get-Content -LiteralPath $configFile -Raw
foreach ($secret in @('SENSE_DATABASE_URL', 'SENSE_DEMO_DATABASE_URL', 'SENSE_JWT_SECRET', 'SENSE_BOOTSTRAP_TOKEN', 'SENSE_CREDENTIAL_KEY')) {
if ($content -match "(?m)^$secret[ \t]*=[ \t]*[^ \t\r\n]") { throw "Package contains a non-empty secret field: $secret" }
}
}
$textExtensions = @('.md', '.txt', '.env', '.example', '.ps1', '.bat', '.yml', '.yaml', '.json', '.html', '.js', '.css', '.sql')
foreach ($file in Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $textExtensions -contains $_.Extension.ToLowerInvariant() }) {
$content = Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue
if ($content -match '(?i)(admin123|password123|BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY)') {
throw "Package contains a forbidden default credential or private key marker: $($file.FullName)"
}
}
Write-Host "Sense package audit passed: $root"
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\backup-sense.ps1" %*
exit /b %errorlevel%
+22
View File
@@ -0,0 +1,22 @@
param(
[ValidateSet('production', 'demo')][string]$Mode = 'production',
[string]$OutputDirectory = ''
)
. (Join-Path $PSScriptRoot 'sense-common.ps1')
try {
$root = Get-SensePackageRoot
$state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort
$pgDump = Get-SensePostgresTool -Name 'pg_dump'
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { $OutputDirectory = Join-Path $root 'backups' }
$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$output = Join-Path $OutputDirectory "sense-$($state.Database.Database)-$stamp.dump"
Invoke-SensePostgresTool -Tool $pgDump -Database $state.Database -Arguments @('--dbname', $state.Database.Sanitized, '--format=custom', '--no-owner', '--file', $output)
Write-Host "Sense backup created: $output"
exit 0
} catch {
Write-Error $_.Exception.Message
exit 1
}
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\check-sense.ps1" %*
exit /b %errorlevel%
+19
View File
@@ -0,0 +1,19 @@
param(
[ValidateSet('production', 'demo')][string]$Mode = 'production',
[switch]$Running
)
. (Join-Path $PSScriptRoot 'sense-common.ps1')
try {
$root = Get-SensePackageRoot
$state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort:$Running
if ($Running -and -not (Test-SenseTcpEndpoint -HostName $state.Host -Port $state.Port)) {
throw "Sense is not accepting TCP connections at $($state.Host):$($state.Port)."
}
Write-Host "Sense $Mode configuration check passed."
Write-Host "PostgreSQL endpoint: reachable; MediaMTX mode: $($state.MediaMode); HTTP port: $($state.Port)."
exit 0
} catch {
Write-Error $_.Exception.Message
exit 1
}
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\initialize-admin.ps1" %*
exit /b %errorlevel%
@@ -0,0 +1,29 @@
param(
[string]$Username = '',
[ValidateSet('production', 'demo')][string]$Mode = 'production'
)
. (Join-Path $PSScriptRoot 'sense-common.ps1')
$passwordPointer = [IntPtr]::Zero
try {
$root = Get-SensePackageRoot
$state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort
$token = Get-SenseEnvironmentValue -Name 'SENSE_BOOTSTRAP_TOKEN'
if ($token.Length -lt 32) { throw 'Set a temporary random SENSE_BOOTSTRAP_TOKEN of at least 32 characters, then restart Sense.' }
if ([string]::IsNullOrWhiteSpace($Username)) { $Username = Read-Host 'Administrator username' }
$securePassword = Read-Host 'Administrator password (at least 6 characters)' -AsSecureString
$passwordPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword)
$password = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($passwordPointer)
$body = @{ username = $Username; password = $password; nickName = $Username } | ConvertTo-Json -Compress
$headers = @{ 'X-Sense-Bootstrap-Token' = $token }
$uri = "http://$($state.Host):$($state.Port)/api/v1/public/bootstrap"
Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $body | Out-Null
Write-Host 'Sense administrator created. Remove SENSE_BOOTSTRAP_TOKEN from config/sense.env and restart Sense now.'
exit 0
} catch {
Write-Error $_.Exception.Message
exit 1
} finally {
if ($passwordPointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($passwordPointer) }
Remove-Variable password -ErrorAction SilentlyContinue
}
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\migrate-sense.ps1" %*
exit /b %errorlevel%
+19
View File
@@ -0,0 +1,19 @@
param([ValidateSet('production', 'demo')][string]$Mode = 'production')
. (Join-Path $PSScriptRoot 'sense-common.ps1')
try {
$root = Get-SensePackageRoot
$state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort
$sense = Join-Path $root 'sense.exe'
Write-Host 'Applying pending Sense database migrations...'
Push-Location $root
try {
& $sense migrate -c $state.SettingsPath
if ($LASTEXITCODE -ne 0) { throw 'Sense database migration failed.' }
} finally { Pop-Location }
Write-Host 'Sense database migration completed.'
exit 0
} catch {
Write-Error $_.Exception.Message
exit 1
}
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\restore-sense.ps1" %*
exit /b %errorlevel%
+27
View File
@@ -0,0 +1,27 @@
param(
[Parameter(Mandatory = $true)][string]$BackupFile,
[Parameter(Mandatory = $true)][string]$ConfirmDatabaseName,
[ValidateSet('production', 'demo')][string]$Mode = 'production',
[string]$Confirmation = ''
)
. (Join-Path $PSScriptRoot 'sense-common.ps1')
try {
$root = Get-SensePackageRoot
$state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort
$backup = [IO.Path]::GetFullPath($BackupFile)
if (-not (Test-Path -LiteralPath $backup -PathType Leaf)) { throw "Backup file not found: $backup" }
if ($ConfirmDatabaseName -cne $state.Database.Database) {
throw 'Restore confirmation does not exactly match the configured database name.'
}
$pgRestore = Get-SensePostgresTool -Name 'pg_restore'
Write-Warning "Restoring will replace objects in database '$ConfirmDatabaseName'. Stop Sense before continuing."
$answer = if ([string]::IsNullOrWhiteSpace($Confirmation)) { Read-Host "Type RESTORE-$ConfirmDatabaseName to continue" } else { $Confirmation }
if ($answer -cne "RESTORE-$ConfirmDatabaseName") { throw 'Restore cancelled.' }
Invoke-SensePostgresTool -Tool $pgRestore -Database $state.Database -Arguments @('--dbname', $state.Database.Sanitized, '--clean', '--if-exists', '--no-owner', '--exit-on-error', $backup)
Write-Host 'Sense database restore completed. Run migrate-sense.bat before starting Sense.'
exit 0
} catch {
Write-Error $_.Exception.Message
exit 1
}
+281
View File
@@ -0,0 +1,281 @@
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$script:SenseAllowedEnvironment = @(
'SENSE_MODE', 'SENSE_HOST', 'SENSE_PORT', 'SENSE_DATABASE_URL',
'SENSE_DEMO_DATABASE_URL', 'SENSE_JWT_SECRET', 'SENSE_BOOTSTRAP_TOKEN',
'SENSE_CREDENTIAL_KEY', 'SENSE_ONVIF_DISCOVERY_IP',
'SENSE_ONVIF_ALLOWED_CIDRS', 'SENSE_MEDIAMTX_MODE',
'SENSE_MEDIAMTX_BINARY', 'SENSE_MEDIAMTX_CONFIG',
'SENSE_MEDIAMTX_API', 'SENSE_WEB_ROOT', 'SENSE_AUTO_MIGRATE',
'SENSE_POSTGRES_BIN'
)
function Get-SensePackageRoot {
param([string]$ScriptDirectory = $PSScriptRoot)
return [System.IO.Path]::GetFullPath((Join-Path $ScriptDirectory '..\..'))
}
function Import-SenseEnvironment {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
throw "Sense configuration file not found: $Path"
}
$lineNumber = 0
foreach ($rawLine in Get-Content -LiteralPath $Path -Encoding UTF8) {
$lineNumber++
$line = $rawLine.Trim()
if ($line.Length -eq 0 -or $line.StartsWith('#')) { continue }
$separator = $line.IndexOf('=')
if ($separator -lt 1) {
throw "Invalid Sense configuration at line $lineNumber. Expected NAME=value."
}
$name = $line.Substring(0, $separator).Trim()
if ($script:SenseAllowedEnvironment -notcontains $name) {
throw "Unsupported Sense configuration key at line ${lineNumber}: $name"
}
$value = $line.Substring($separator + 1)
if ($value.Length -ge 2) {
$first, $last = $value[0], $value[$value.Length - 1]
if (($first -eq '"' -and $last -eq '"') -or ($first -eq "'" -and $last -eq "'")) {
$value = $value.Substring(1, $value.Length - 2)
}
}
$existing = [Environment]::GetEnvironmentVariable($name, 'Process')
if ([string]::IsNullOrWhiteSpace($existing)) {
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
}
}
}
function Get-SenseEnvironmentValue {
param([Parameter(Mandatory = $true)][string]$Name, [string]$Default = '')
$value = [Environment]::GetEnvironmentVariable($Name, 'Process')
if ([string]::IsNullOrWhiteSpace($value)) { return $Default }
return $value
}
function ConvertTo-SenseYamlString {
param([AllowEmptyString()][string]$Value)
return ($Value | ConvertTo-Json -Compress)
}
function Resolve-SenseConfiguredPath {
param([Parameter(Mandatory = $true)][string]$PackageRoot, [AllowEmptyString()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
if ([System.IO.Path]::IsPathRooted($Value)) {
return [System.IO.Path]::GetFullPath($Value)
}
return [System.IO.Path]::GetFullPath((Join-Path $PackageRoot $Value))
}
function Get-SenseDatabaseInfo {
param([Parameter(Mandatory = $true)][string]$Connection)
$result = @{ Host = '127.0.0.1'; Port = 5432; Database = ''; Sanitized = $Connection; Password = '' }
if ($Connection -match '^postgres(?:ql)?://') {
$uri = [Uri]$Connection
$result.Host = $uri.Host
if (-not $uri.IsDefaultPort) { $result.Port = $uri.Port }
$result.Database = $uri.AbsolutePath.TrimStart('/')
if ($uri.UserInfo) {
$parts = $uri.UserInfo.Split(':', 2)
$user = [Uri]::UnescapeDataString($parts[0])
if ($parts.Count -eq 2) { $result.Password = [Uri]::UnescapeDataString($parts[1]) }
$builder = [UriBuilder]$uri
$builder.UserName = $user
$builder.Password = ''
$result.Sanitized = $builder.Uri.AbsoluteUri
}
return $result
}
$matches = [regex]::Matches($Connection, '(?:^|\s)(?<key>[A-Za-z_][A-Za-z0-9_]*)=(?<value>''(?:[^'']|'''')*''|"(?:[^"]|"")*"|[^\s]+)')
$sanitized = New-Object System.Collections.Generic.List[string]
foreach ($match in $matches) {
$key = $match.Groups['key'].Value
$raw = $match.Groups['value'].Value
$value = $raw
if ($raw.Length -ge 2 -and (($raw[0] -eq "'" -and $raw[$raw.Length - 1] -eq "'") -or ($raw[0] -eq '"' -and $raw[$raw.Length - 1] -eq '"'))) {
$value = $raw.Substring(1, $raw.Length - 2)
}
if ($key.ToLowerInvariant() -eq 'password') {
$result.Password = $value
continue
}
switch ($key.ToLowerInvariant()) {
'host' { $result.Host = $value }
'port' { $result.Port = [int]$value }
'dbname' { $result.Database = $value }
}
$sanitized.Add("$key=$raw")
}
if ($matches.Count -eq 0) { throw 'SENSE_DATABASE_URL must be a PostgreSQL URI or keyword connection string.' }
$result.Sanitized = $sanitized -join ' '
return $result
}
function Test-SenseTcpEndpoint {
param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port, [int]$TimeoutMilliseconds = 2000)
$client = New-Object System.Net.Sockets.TcpClient
try {
$task = $client.ConnectAsync($HostName, $Port)
if (-not $task.Wait($TimeoutMilliseconds)) { return $false }
return $client.Connected
} catch {
return $false
} finally {
$client.Dispose()
}
}
function Test-SenseListenPortAvailable {
param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port)
$ip = if ($HostName -eq '0.0.0.0') { [Net.IPAddress]::Any } elseif ($HostName -eq 'localhost') { [Net.IPAddress]::Loopback } else { [Net.IPAddress]::Parse($HostName) }
$listener = New-Object Net.Sockets.TcpListener($ip, $Port)
try { $listener.Start(); return $true } catch { return $false } finally { try { $listener.Stop() } catch {} }
}
function Initialize-SenseRuntime {
param(
[Parameter(Mandatory = $true)][string]$PackageRoot,
[ValidateSet('production', 'demo')][string]$Mode = 'production',
[switch]$AllowOccupiedPort
)
$configName = if ($Mode -eq 'demo') { 'sense.demo.env' } else { 'sense.env' }
Import-SenseEnvironment -Path (Join-Path $PackageRoot "config\$configName")
$hostName = Get-SenseEnvironmentValue -Name 'SENSE_HOST' -Default '127.0.0.1'
$portText = Get-SenseEnvironmentValue -Name 'SENSE_PORT' -Default '18080'
$port = 0
if (-not [int]::TryParse($portText, [ref]$port) -or $port -lt 1 -or $port -gt 65535) {
throw 'SENSE_PORT must be an integer between 1 and 65535.'
}
if ($hostName -notin @('127.0.0.1', '0.0.0.0', 'localhost')) {
throw 'SENSE_HOST must be 127.0.0.1, localhost, or 0.0.0.0.'
}
if (-not $AllowOccupiedPort -and -not (Test-SenseListenPortAvailable -HostName $hostName -Port $port)) {
throw "Sense HTTP port $hostName`:$port is already in use. Stop the existing process or change SENSE_PORT."
}
$databaseVariable = if ($Mode -eq 'demo') { 'SENSE_DEMO_DATABASE_URL' } else { 'SENSE_DATABASE_URL' }
$databaseURL = Get-SenseEnvironmentValue -Name $databaseVariable
if ([string]::IsNullOrWhiteSpace($databaseURL)) { throw "$databaseVariable is required." }
$database = Get-SenseDatabaseInfo -Connection $databaseURL
if ([string]::IsNullOrWhiteSpace($database.Database)) { throw "$databaseVariable must name a database." }
if ($Mode -eq 'demo' -and $database.Database -notmatch '(?i)demo|test') {
throw 'Demo mode requires a database name containing demo or test; production data must never be reused as demo data.'
}
if (-not (Test-SenseTcpEndpoint -HostName $database.Host -Port $database.Port)) {
throw "PostgreSQL is unreachable at $($database.Host):$($database.Port). Start PostgreSQL and verify the database connection."
}
$jwtSecret = Get-SenseEnvironmentValue -Name 'SENSE_JWT_SECRET'
if ($Mode -eq 'production' -and $jwtSecret.Trim().Length -lt 32) {
throw 'SENSE_JWT_SECRET must contain at least 32 characters in production.'
}
if ([string]::IsNullOrWhiteSpace($jwtSecret)) { throw 'SENSE_JWT_SECRET is required.' }
$mediaMode = (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_MODE' -Default $(if ($Mode -eq 'demo') { 'disabled' } else { 'managed' })).ToLowerInvariant()
if ($mediaMode -notin @('managed', 'external', 'disabled')) { throw 'SENSE_MEDIAMTX_MODE must be managed, external, or disabled.' }
if ($Mode -eq 'production' -and $mediaMode -eq 'disabled') { throw 'MediaMTX cannot be disabled in production.' }
$mediaAPI = Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_API' -Default 'http://127.0.0.1:9997'
$apiUri = [Uri]$mediaAPI
if ($apiUri.Scheme -ne 'http' -or $apiUri.Host -notin @('127.0.0.1', 'localhost', '::1')) {
throw 'SENSE_MEDIAMTX_API must be an HTTP loopback URL.'
}
$mediaBinary = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_BINARY')
$mediaConfig = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_CONFIG')
if ($mediaMode -eq 'managed') {
if (-not (Test-Path -LiteralPath $mediaBinary -PathType Leaf)) { throw 'Managed MediaMTX binary not found. Set SENSE_MEDIAMTX_BINARY to mediamtx.exe.' }
if (-not (Test-Path -LiteralPath $mediaConfig -PathType Leaf)) { throw 'Managed MediaMTX configuration not found. Set SENSE_MEDIAMTX_CONFIG.' }
}
if ($mediaMode -eq 'external' -and -not (Test-SenseTcpEndpoint -HostName $apiUri.Host -Port $apiUri.Port)) {
throw "External MediaMTX Control API is unreachable at $($apiUri.Host):$($apiUri.Port)."
}
$webRoot = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_WEB_ROOT' -Default 'web')
if (-not (Test-Path -LiteralPath (Join-Path $webRoot 'index.html') -PathType Leaf)) { throw 'Sense web assets are missing. Rebuild or replace the delivery package.' }
[Environment]::SetEnvironmentVariable('SENSE_WEB_ROOT', $webRoot, 'Process')
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_MODE', $mediaMode, 'Process')
if ($mediaMode -eq 'managed') {
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_BINARY', $mediaBinary, 'Process')
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_CONFIG', $mediaConfig, 'Process')
} else {
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_BINARY', '', 'Process')
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_CONFIG', '', 'Process')
}
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_API', $mediaAPI, 'Process')
$runtimeDir = Join-Path $PackageRoot 'data\runtime'
$logDir = Join-Path $PackageRoot 'logs'
New-Item -ItemType Directory -Force -Path $runtimeDir, $logDir | Out-Null
$settingsPath = Join-Path $runtimeDir 'settings.yml'
$applicationMode = if ($Mode -eq 'production') { 'prod' } else { 'test' }
$lines = @(
'settings:',
' application:',
" mode: $applicationMode",
" host: $(ConvertTo-SenseYamlString $hostName)",
' name: sense',
" port: $port",
' readtimeout: 10',
' writertimeout: 20',
' enabledp: false',
' logger:',
" path: $(ConvertTo-SenseYamlString $logDir)",
" stdout: ''",
' level: info',
' enableddb: false',
' jwt:',
" secret: $(ConvertTo-SenseYamlString $jwtSecret)",
' timeout: 2592000',
' database:',
' driver: postgres',
" source: $(ConvertTo-SenseYamlString $databaseURL)",
' gen:',
" dbname: $(ConvertTo-SenseYamlString $database.Database)",
" frontpath: ''",
' extend:',
' demo:',
' name: data',
' cache:',
" memory: ''",
' queue:',
' memory:',
' poolSize: 100',
' locker:',
' redis:'
)
[IO.File]::WriteAllLines($settingsPath, $lines, (New-Object Text.UTF8Encoding($false)))
return @{ PackageRoot = $PackageRoot; SettingsPath = $settingsPath; Host = $hostName; Port = $port; Database = $database; Mode = $Mode; MediaMode = $mediaMode }
}
function Get-SensePostgresTool {
param([Parameter(Mandatory = $true)][string]$Name)
$configured = Get-SenseEnvironmentValue -Name 'SENSE_POSTGRES_BIN'
if (-not [string]::IsNullOrWhiteSpace($configured)) {
$candidate = Join-Path $configured "$Name.exe"
if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate }
}
$command = Get-Command "$Name.exe" -ErrorAction SilentlyContinue
if ($command) { return $command.Source }
throw "$Name.exe was not found. Install PostgreSQL client tools or set SENSE_POSTGRES_BIN."
}
function Invoke-SensePostgresTool {
param(
[Parameter(Mandatory = $true)][string]$Tool,
[Parameter(Mandatory = $true)][hashtable]$Database,
[Parameter(Mandatory = $true)][string[]]$Arguments
)
$oldPassword = [Environment]::GetEnvironmentVariable('PGPASSWORD', 'Process')
try {
if (-not [string]::IsNullOrEmpty($Database.Password)) {
[Environment]::SetEnvironmentVariable('PGPASSWORD', $Database.Password, 'Process')
}
& $Tool @Arguments
if ($LASTEXITCODE -ne 0) { throw "PostgreSQL tool failed with exit code $LASTEXITCODE." }
} finally {
[Environment]::SetEnvironmentVariable('PGPASSWORD', $oldPassword, 'Process')
}
}
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\start-sense.ps1" %*
exit /b %errorlevel%
+31
View File
@@ -0,0 +1,31 @@
param(
[ValidateSet('production', 'demo')][string]$Mode = 'production',
[switch]$SkipMigration
)
. (Join-Path $PSScriptRoot 'sense-common.ps1')
try {
$root = Get-SensePackageRoot
$state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode
$sense = Join-Path $root 'sense.exe'
if (-not (Test-Path -LiteralPath $sense -PathType Leaf)) { throw "Sense executable not found: $sense" }
$autoMigrate = (Get-SenseEnvironmentValue -Name 'SENSE_AUTO_MIGRATE' -Default 'true').ToLowerInvariant()
Push-Location $root
try {
if (-not $SkipMigration -and $autoMigrate -notin @('false', '0', 'no')) {
Write-Host 'Applying pending Sense database migrations...'
& $sense migrate -c $state.SettingsPath
if ($LASTEXITCODE -ne 0) { throw 'Sense database migration failed. Review the error above and the PostgreSQL connection.' }
}
if ($Mode -eq 'demo') {
Write-Warning 'Sense is running in isolated demo mode. Demo data must not be used as production data.'
}
Write-Host "Starting Sense at http://$($state.Host):$($state.Port)/ ..."
Write-Host 'Press Ctrl+C in this window to stop Sense and its managed MediaMTX process.'
& $sense server -c $state.SettingsPath
exit $LASTEXITCODE
} finally { Pop-Location }
} catch {
Write-Error $_.Exception.Message
exit 1
}
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\stop-sense.ps1" %*
exit /b %errorlevel%
+24
View File
@@ -0,0 +1,24 @@
param([ValidateSet('production', 'demo')][string]$Mode = 'production')
. (Join-Path $PSScriptRoot 'sense-common.ps1')
try {
$root = Get-SensePackageRoot
$configName = if ($Mode -eq 'demo') { 'sense.demo.env' } else { 'sense.env' }
$config = Join-Path $root "config\$configName"
Import-SenseEnvironment -Path $config
$port = [int](Get-SenseEnvironmentValue -Name 'SENSE_PORT' -Default '18080')
$connection = Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $connection) { Write-Host "Sense is not listening on port $port."; exit 0 }
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $($connection.OwningProcess)"
$expected = [IO.Path]::GetFullPath((Join-Path $root 'sense.exe'))
if (-not $process -or [IO.Path]::GetFullPath($process.ExecutablePath) -ne $expected) {
throw "Port $port belongs to another process; it was not stopped."
}
& taskkill.exe /PID $process.ProcessId /T /F | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Failed to stop the Sense process tree.' }
Write-Host 'Sense and its managed child processes were stopped.'
exit 0
} catch {
Write-Error $_.Exception.Message
exit 1
}
@@ -0,0 +1,18 @@
package router
import (
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func init() { routerCheckRole = append(routerCheckRole, registerSenseAdmissionRouter) }
func registerSenseAdmissionRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
api := &admission.API{}
r := v1.Group("/admission").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
r.GET("/discover", api.Discover)
r.GET("/devices/:id", api.Get)
r.POST("/devices/:id/probe", api.Probe)
}
@@ -0,0 +1,20 @@
package router
import (
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func init() { routerCheckRole = append(routerCheckRole, registerSenseAreaRouter) }
func registerSenseAreaRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
api := &area.API{}
r := v1.Group("/area").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
r.GET("/configurations", api.List)
r.POST("/configurations", api.Create)
r.PUT("/configurations/:id", api.Update)
r.GET("/configurations/:id/versions", api.Versions)
}
@@ -0,0 +1,27 @@
package router
import (
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/liveview"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func init() {
routerCheckRole = append(routerCheckRole, registerSenseLiveviewRouter)
routerNoCheckRole = append(routerNoCheckRole, registerSenseLiveviewPlayerRouter)
}
func registerSenseLiveviewRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
api := &liveview.API{}
r := v1.Group("/liveview").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
r.GET("/routes", api.List)
r.POST("/sessions", api.Create)
r.GET("/sessions/:id", api.Get)
}
func registerSenseLiveviewPlayerRouter(v1 *gin.RouterGroup) {
api := &liveview.API{}
v1.GET("/liveview/player/:id", api.Player)
}
@@ -0,0 +1,21 @@
package router
import (
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func init() { routerCheckRole = append(routerCheckRole, registerSenseMediaRouter) }
func registerSenseMediaRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
api := &media.API{}
r := v1.Group("/media").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
r.GET("/routes", api.List)
r.GET("/process", api.Process)
r.POST("/reconcile", api.ReconcileAll)
r.POST("/routes/:id/reconcile", api.Reconcile)
r.POST("/routes/:id/stop", api.Stop)
}
@@ -80,6 +80,11 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
func registerBaseRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
api := apis.SysMenu{}
api2 := apis.SysDept{}
configAPI := apis.SysConfig{}
// The GoAdmin login shell reads frontend-only branding before a user is
// authenticated. Keep this single read route public without registering the
// disabled system-configuration CRUD and write routes.
v1.GET("/app-config", configAPI.Get2SysApp)
v1auth := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
v1auth.GET("/roleMenuTreeselect/:roleId", api.GetMenuTreeSelect)
@@ -0,0 +1,37 @@
package router
import (
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func TestRegisterBaseRouterExposesOnlyFrontendAppConfig(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
registerBaseRouter(engine.Group("/api/v1"), &jwt.GinJWTMiddleware{})
routes := make(map[string]struct{})
for _, route := range engine.Routes() {
routes[route.Method+" "+route.Path] = struct{}{}
}
if _, ok := routes["GET /api/v1/app-config"]; !ok {
t.Fatal("anonymous frontend app-config route is not registered")
}
for _, disabled := range []string{
"GET /api/v1/config",
"POST /api/v1/config",
"GET /api/v1/config/:id",
"PUT /api/v1/config/:id",
"DELETE /api/v1/config",
"GET /api/v1/configKey/:configKey",
"GET /api/v1/set-config",
"PUT /api/v1/set-config",
} {
if _, ok := routes[disabled]; ok {
t.Fatalf("disabled system-configuration route was registered: %s", disabled)
}
}
}
+109
View File
@@ -0,0 +1,109 @@
package admission
import (
"encoding/json"
"errors"
"io"
"net/http"
"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"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
)
type API struct{ api.Api }
func (e *API) runtime(c *gin.Context) (*Service, error) {
base := Service{}
if err := e.MakeContext(c).MakeOrm().MakeService(&base.Service).Errors; err != nil {
return nil, err
}
return NewRuntime(base.Service)
}
func (e *API) Discover(c *gin.Context) {
service, err := e.runtime(c)
if err != nil {
e.writeError(err)
return
}
result, err := service.Discover(c.Request.Context())
if err != nil {
e.writeError(err)
return
}
e.OK(result, "发现完成")
}
func (e *API) Probe(c *gin.Context) {
service, err := e.runtime(c)
if err != nil {
e.writeError(err)
return
}
request := ProbeRequest{DeviceID: c.Param("id"), UpdateBy: user.GetUserId(c)}
if err = bindStrict(c, &request); err != nil {
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
return
}
result, err := service.Probe(c.Request.Context(), request)
if err != nil {
e.writeError(err)
return
}
// Media route intent is best-effort and credential-free. A MediaMTX
// failure must never roll back the verified device/Profile transaction.
_ = media.EnsureDeviceRoutes(c.Request.Context(), service.Orm, request.DeviceID)
e.OK(result, "探测完成")
}
func (e *API) Get(c *gin.Context) {
service := &Service{}
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
e.writeError(err)
return
}
result, err := service.Get(c.Param("id"))
if err != nil {
e.writeError(err)
return
}
e.OK(result, "查询成功")
}
func (e *API) writeError(err error) {
switch {
case errors.Is(err, ErrInvalid):
e.Error(http.StatusBadRequest, err, err.Error())
case errors.Is(err, ErrNotFound):
e.Error(http.StatusNotFound, err, err.Error())
case errors.Is(err, ErrConflict):
e.Error(http.StatusConflict, err, err.Error())
case errors.Is(err, onvif.ErrDiscoveryNotConfigured), errors.Is(err, onvif.ErrDiscoveryInterface), errors.Is(err, onvif.ErrTargetNotAllowed):
e.Error(http.StatusBadRequest, err, err.Error())
case errors.Is(err, credential.ErrCredentialNotConfigured):
e.Error(http.StatusConflict, err, "请先在设备管理中配置摄像头凭据")
case errors.Is(err, credential.ErrKeyUnavailable):
e.Error(http.StatusServiceUnavailable, err, "摄像头凭据安全配置不可用")
default:
e.Error(http.StatusInternalServerError, err, "视频接入操作失败")
}
}
func bindStrict(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, 64<<10))
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
}
@@ -0,0 +1,21 @@
package admission
import (
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestProbePayloadRejectsCredentialFields(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest("POST", "/api/v1/admission/devices/device/probe", strings.NewReader(`{"address":"http://192.0.2.10/onvif","version":1,"password":"must-not-be-accepted"}`))
ctx.Request.Header.Set("Content-Type", "application/json")
var request ProbeRequest
if err := bindStrict(ctx, &request); err == nil {
t.Fatal("credential-like unknown field accepted")
}
}
+34
View File
@@ -0,0 +1,34 @@
package admission
import "time"
type ProbeRequest struct {
DeviceID string `json:"-"`
Address string `json:"address"`
Version int64 `json:"version"`
UpdateBy int `json:"-"`
}
type ProfileResponse struct {
Token string `json:"token"`
Name string `json:"name"`
Width int `json:"width"`
Height int `json:"height"`
Encoding string `json:"encoding"`
StreamURI string `json:"streamUri"`
Kind string `json:"kind"`
VerificationStatus string `json:"verificationStatus"`
VerificationLatencyMS int64 `json:"verificationLatencyMs"`
VerificationDetail string `json:"verificationDetail"`
}
type ResultResponse struct {
DeviceID string `json:"deviceId"`
Address string `json:"address"`
Status string `json:"status"`
Detail string `json:"detail"`
CheckedAt time.Time `json:"checkedAt"`
Profiles []ProfileResponse `json:"profiles"`
}
type DiscoveryResponse struct {
Addresses []string `json:"addresses"`
Interface string `json:"interface"`
}
@@ -0,0 +1,32 @@
package admission
import "time"
type Result struct {
DeviceID string `gorm:"size:36;primaryKey"`
Address string `gorm:"size:1024;not null"`
Status string `gorm:"size:32;not null;index"`
Detail string `gorm:"size:512;not null"`
CheckedAt time.Time `gorm:"not null"`
UpdatedAt time.Time
Profiles []Profile `gorm:"foreignKey:DeviceID;references:DeviceID;constraint:OnDelete:CASCADE"`
}
func (Result) TableName() string { return "sense_admission_results" }
type Profile struct {
DeviceID string `gorm:"size:36;primaryKey"`
Token string `gorm:"size:255;primaryKey"`
Name string `gorm:"size:255;not null"`
Width int `gorm:"not null"`
Height int `gorm:"not null"`
Encoding string `gorm:"size:32;not null"`
StreamURI string `gorm:"size:2048;not null"`
Kind string `gorm:"size:16;not null"`
VerificationStatus string `gorm:"size:32;not null"`
VerificationLatencyMS int64 `gorm:"not null"`
VerificationDetail string `gorm:"size:512;not null"`
UpdatedAt time.Time
}
func (Profile) TableName() string { return "sense_admission_profiles" }
+181
View File
@@ -0,0 +1,181 @@
package admission
import (
"context"
"errors"
"fmt"
"os"
"sort"
"strings"
"time"
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/rtsp"
)
var (
ErrNotFound = errors.New("尚无该设备的接入结果")
ErrInvalid = errors.New("接入请求不符合要求")
ErrConflict = errors.New("设备已被其他用户更新,请刷新后重试")
)
type Service struct {
coreService.Service
ONVIF onvif.Client
RTSP rtsp.Verifier
Policy onvif.Policy
DiscoveryIP string
Timeout time.Duration
}
func NewRuntime(service coreService.Service) (*Service, error) {
policy, err := onvif.ParsePolicy(os.Getenv("SENSE_ONVIF_ALLOWED_CIDRS"))
if err != nil {
return nil, err
}
return &Service{Service: service, Policy: policy, DiscoveryIP: strings.TrimSpace(os.Getenv("SENSE_ONVIF_DISCOVERY_IP")), ONVIF: onvif.NewHTTPClient(8*time.Second, policy), RTSP: rtsp.NetVerifier{Timeout: 5 * time.Second, Policy: policy}}, nil
}
func (s *Service) Discover(ctx context.Context) (DiscoveryResponse, error) {
addresses, err := onvif.Discover(ctx, s.DiscoveryIP, 3*time.Second, s.Policy)
return DiscoveryResponse{Addresses: addresses, Interface: s.DiscoveryIP}, err
}
func (s *Service) Probe(ctx context.Context, request ProbeRequest) (ResultResponse, error) {
if request.Version < 1 || strings.TrimSpace(request.Address) == "" || len(request.Address) > 1024 {
return ResultResponse{}, ErrInvalid
}
var device deviceModels.Device
if err := s.Orm.Select("id", "modality", "version").First(&device, "id = ?", request.DeviceID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ResultResponse{}, ErrNotFound
}
return ResultResponse{}, err
}
if device.Modality != deviceModels.ModalityVideo {
return ResultResponse{}, ErrInvalid
}
if device.Version != request.Version {
return ResultResponse{}, ErrConflict
}
onvifValue, err := credential.Read(s.Orm, request.DeviceID, credential.PurposeONVIF)
if err != nil {
return ResultResponse{}, err
}
rtspValue, err := credential.Read(s.Orm, request.DeviceID, credential.PurposeRTSP)
if err != nil {
return ResultResponse{}, err
}
profiles, probeErr := s.ONVIF.Profiles(ctx, request.Address, onvif.Credential{Username: onvifValue.Username, Password: onvifValue.Password})
now := time.Now().UTC()
result := Result{DeviceID: request.DeviceID, Address: strings.TrimSpace(request.Address), CheckedAt: now, UpdatedAt: now}
if probeErr != nil {
result.Status, result.Detail = classify(probeErr)
if err = s.save(result, request, false); err != nil {
return ResultResponse{}, err
}
return s.Get(request.DeviceID)
}
for _, profile := range profiles {
verification, verifyErr := s.RTSP.Verify(ctx, profile.StreamURI, rtsp.Credential{Username: rtspValue.Username, Password: rtspValue.Password})
if verifyErr != nil {
verification = rtsp.Result{Status: "failed", Detail: "视频地址未通过安全检查"}
}
result.Profiles = append(result.Profiles, Profile{DeviceID: request.DeviceID, Token: profile.Token, Name: profile.Name, Width: profile.Width, Height: profile.Height, Encoding: profile.Encoding, StreamURI: profile.StreamURI, Kind: "other", VerificationStatus: verification.Status, VerificationLatencyMS: verification.LatencyMS, VerificationDetail: verification.Detail, UpdatedAt: now})
}
sort.Slice(result.Profiles, func(i, j int) bool {
return result.Profiles[i].Width*result.Profiles[i].Height > result.Profiles[j].Width*result.Profiles[j].Height
})
if len(result.Profiles) > 0 {
result.Profiles[0].Kind = "main"
}
if len(result.Profiles) > 1 {
result.Profiles[len(result.Profiles)-1].Kind = "sub"
}
result.Status = "ready"
result.Detail = "设备与视频 Profile 已验证"
for _, profile := range result.Profiles {
if profile.VerificationStatus != "ready" {
result.Status = "profile_failed"
result.Detail = "部分视频 Profile 验证失败"
}
}
if err = s.save(result, request, true); err != nil {
return ResultResponse{}, err
}
return response(result), nil
}
func (s *Service) save(result Result, request ProbeRequest, replaceProfiles bool) error {
return s.Orm.Transaction(func(tx *gorm.DB) error {
updates := map[string]any{"version": request.Version + 1, "update_by": request.UpdateBy, "updated_at": result.UpdatedAt, "retry_requested_at": nil}
if replaceProfiles {
updates["status"] = map[bool]string{true: deviceModels.StatusActive, false: deviceModels.StatusPending}[result.Status == "ready"]
updates["adapter_status"] = map[bool]string{true: deviceModels.AdapterReady, false: deviceModels.AdapterFailed}[result.Status == "ready"]
}
update := tx.Model(&deviceModels.Device{}).Where("id = ? AND version = ?", request.DeviceID, request.Version).Updates(updates)
if update.Error != nil {
return update.Error
}
if update.RowsAffected == 0 {
return ErrConflict
}
if err := tx.Omit("Profiles").Save(&result).Error; err != nil {
return err
}
if replaceProfiles {
if err := tx.Where("device_id = ?", request.DeviceID).Delete(&Profile{}).Error; err != nil {
return err
}
}
if replaceProfiles && len(result.Profiles) > 0 {
if err := tx.Create(&result.Profiles).Error; err != nil {
return err
}
}
if replaceProfiles {
snapshots := make([]area.ProfileSnapshot, 0, len(result.Profiles))
for _, profile := range result.Profiles {
snapshots = append(snapshots, area.ProfileSnapshot{Token: profile.Token, Width: profile.Width, Height: profile.Height, Encoding: profile.Encoding})
}
return area.MarkProfilesReplaced(tx, request.DeviceID, snapshots)
}
return nil
})
}
func (s *Service) Get(deviceID string) (ResultResponse, error) {
var result Result
if err := s.Orm.Preload("Profiles", func(db *gorm.DB) *gorm.DB { return db.Order("width * height DESC") }).First(&result, "device_id = ?", deviceID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ResultResponse{}, ErrNotFound
}
return ResultResponse{}, fmt.Errorf("read admission: %w", err)
}
return response(result), nil
}
func response(result Result) ResultResponse {
out := ResultResponse{DeviceID: result.DeviceID, Address: result.Address, Status: result.Status, Detail: result.Detail, CheckedAt: result.CheckedAt, Profiles: make([]ProfileResponse, 0, len(result.Profiles))}
for _, p := range result.Profiles {
out.Profiles = append(out.Profiles, ProfileResponse{Token: p.Token, Name: p.Name, Width: p.Width, Height: p.Height, Encoding: p.Encoding, StreamURI: p.StreamURI, Kind: p.Kind, VerificationStatus: p.VerificationStatus, VerificationLatencyMS: p.VerificationLatencyMS, VerificationDetail: p.VerificationDetail})
}
return out
}
func classify(err error) (string, string) {
switch {
case errors.Is(err, onvif.ErrAuthentication):
return "authentication_failed", "设备拒绝了当前凭据,请更新后重试"
case errors.Is(err, onvif.ErrTargetNotAllowed):
return "target_not_allowed", "设备地址不在获准网段内"
case errors.Is(err, onvif.ErrRedirect):
return "redirect_rejected", "设备返回了不允许的重定向"
case errors.Is(err, context.DeadlineExceeded) || strings.Contains(strings.ToLower(err.Error()), "timeout"):
return "timeout", "设备响应超时"
case strings.Contains(strings.ToLower(err.Error()), "time") || strings.Contains(strings.ToLower(err.Error()), "clock"):
return "clock_skew", "设备时间可能不准确,请校时后重试"
default:
return "unreachable", "无法读取设备信息,请检查地址和网络"
}
}
@@ -0,0 +1,138 @@
package admission
import (
"context"
"encoding/base64"
"errors"
"testing"
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/rtsp"
)
type fakeONVIF struct {
err error
profiles []onvif.Profile
}
func (f fakeONVIF) Profiles(context.Context, string, onvif.Credential) ([]onvif.Profile, error) {
if f.err != nil {
return nil, f.err
}
if f.profiles != nil {
return f.profiles, nil
}
return []onvif.Profile{{Token: "main", Name: "主码流", Width: 1920, Height: 1080, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/main"}, {Token: "sub", Name: "子码流", Width: 640, Height: 360, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/sub"}}, nil
}
type fakeRTSP struct{}
func (fakeRTSP) Verify(context.Context, string, rtsp.Credential) (rtsp.Result, error) {
return rtsp.Result{Status: "ready", Detail: "码流可访问"}, nil
}
func admissionService(t *testing.T) *Service {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&deviceModels.Device{}, &credential.DeviceCredential{}, &Result{}, &Profile{}); err != nil {
t.Fatal(err)
}
key := []byte("0123456789abcdef0123456789abcdef")
t.Setenv(credential.EnvironmentKey, base64.StdEncoding.EncodeToString(key))
vault, _ := credential.NewVault(key)
device := deviceModels.Device{ID: "device-1", Name: "东门摄像机", Modality: deviceModels.ModalityVideo, Version: 1, Status: "pending", AdapterStatus: "ready"}
if err = db.Create(&device).Error; err != nil {
t.Fatal(err)
}
for _, purpose := range []string{credential.PurposeONVIF, credential.PurposeRTSP} {
cipher, _ := vault.Encrypt(device.ID, purpose, "synthetic-user", "synthetic-password")
if err = db.Create(&credential.DeviceCredential{DeviceID: device.ID, Purpose: purpose, Ciphertext: cipher, KeyVersion: credential.Version()}).Error; err != nil {
t.Fatal(err)
}
}
return &Service{Service: coreService.Service{Orm: db}, ONVIF: fakeONVIF{}, RTSP: fakeRTSP{}}
}
func TestProbePersistsProfilesWithoutReturningCredentials(t *testing.T) {
service := admissionService(t)
result, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 1})
if err != nil {
t.Fatal(err)
}
if result.Status != "ready" || len(result.Profiles) != 2 || result.Profiles[0].Kind != "main" || result.Profiles[1].Kind != "sub" {
t.Fatalf("result=%#v", result)
}
if result.Profiles[0].StreamURI == "" {
t.Fatal("stream URI missing")
}
saved, err := service.Get("device-1")
if err != nil || len(saved.Profiles) != 2 {
t.Fatalf("saved=%#v err=%v", saved, err)
}
}
func TestFailedReprobePreservesLastVerifiedProfiles(t *testing.T) {
service := admissionService(t)
if _, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 1}); err != nil {
t.Fatal(err)
}
service.ONVIF = fakeONVIF{err: onvif.ErrAuthentication}
result, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 2})
if err != nil {
t.Fatal(err)
}
if result.Status != "authentication_failed" || len(result.Profiles) != 2 {
t.Fatalf("last verified profiles lost: %#v", result)
}
if _, err = service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 2}); !errors.Is(err, ErrConflict) {
t.Fatalf("stale error=%v", err)
}
}
func TestProbeErrorsHaveActionableStates(t *testing.T) {
for _, test := range []struct {
err error
status string
}{
{onvif.ErrAuthentication, "authentication_failed"},
{onvif.ErrTargetNotAllowed, "target_not_allowed"},
{errors.New("device clock time fault"), "clock_skew"},
{context.DeadlineExceeded, "timeout"},
} {
status, detail := classify(test.err)
if status != test.status || detail == "" {
t.Fatalf("error=%v status=%s detail=%s", test.err, status, detail)
}
}
}
func TestProfileResolutionChangeMarksAreaForRecalibration(t *testing.T) {
service := admissionService(t)
if _, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 1}); err != nil {
t.Fatal(err)
}
if err := service.Orm.AutoMigrate(&area.Definition{}, &area.Version{}); err != nil {
t.Fatal(err)
}
definition := area.Definition{ID: "area-1", Name: "东门警戒线", Kind: area.KindDirectionLine, DeviceID: "device-1", ProfileToken: "main", ProfileWidth: 1920, ProfileHeight: 1080, ProfileEncoding: "H264", CurrentVersion: 1, CurrentVersionID: "version-1", Enabled: true, CreatedBy: 7, UpdatedBy: 7}
if err := service.Orm.Create(&definition).Error; err != nil {
t.Fatal(err)
}
service.ONVIF = fakeONVIF{profiles: []onvif.Profile{{Token: "main", Name: "主码流", Width: 1280, Height: 720, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/main"}}}
if _, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 2}); err != nil {
t.Fatal(err)
}
if err := service.Orm.First(&definition, "id = ?", "area-1").Error; err != nil {
t.Fatal(err)
}
if !definition.NeedsRecalibration {
t.Fatal("profile resolution change did not mark the bound area")
}
}
+126
View File
@@ -0,0 +1,126 @@
package area
import (
"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"
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
)
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.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
return
}
pageIndex, _ := strconv.Atoi(c.DefaultQuery("pageIndex", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
items, total, err := service.List(c.Request.Context(), PageRequest{Keyword: c.Query("keyword"), Kind: c.Query("kind"), RecalibrationState: c.Query("recalibrationState"), PageIndex: pageIndex, PageSize: pageSize})
if err != nil {
e.writeError(err)
return
}
e.PageOK(items, int(total), pageIndex, pageSize, "查询成功")
}
func (e *API) Create(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
return
}
var request UpsertRequest
if err = decodeJSON(c, &request); err != nil {
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
return
}
request.UpdateBy = user.GetUserId(c)
item, err := service.Create(c.Request.Context(), request)
if err != nil {
e.writeError(err)
return
}
e.OK(item, "区域配置已创建")
}
func (e *API) Update(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
return
}
var request UpsertRequest
if err = decodeJSON(c, &request); err != nil {
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
return
}
request.UpdateBy = user.GetUserId(c)
item, err := service.Update(c.Request.Context(), c.Param("id"), request)
if err != nil {
e.writeError(err)
return
}
e.OK(item, "已保存新版本")
}
func (e *API) Versions(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
return
}
items, err := service.Versions(c.Request.Context(), c.Param("id"))
if err != nil {
e.writeError(err)
return
}
e.OK(items, "查询成功")
}
func (e *API) writeError(err error) {
switch {
case errors.Is(err, ErrInvalidRequest), errors.Is(err, ErrInvalidGeometry):
e.Error(http.StatusBadRequest, err, err.Error())
case errors.Is(err, ErrNotFound), errors.Is(err, ErrProfileMissing):
e.Error(http.StatusNotFound, err, err.Error())
case errors.Is(err, ErrConflict):
e.Error(http.StatusConflict, err, err.Error())
default:
e.Error(http.StatusInternalServerError, err, "区域配置操作失败")
}
}
func decodeJSON(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, 64<<10))
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
}
+81
View File
@@ -0,0 +1,81 @@
package area
import "time"
const (
KindPolygon = "polygon"
KindDirectionLine = "direction_line"
DirectionForward = "forward"
DirectionReverse = "reverse"
)
type Point struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
type PageRequest struct {
Keyword string
Kind string
RecalibrationState string
PageIndex int
PageSize int
}
type UpsertRequest struct {
Name string `json:"name"`
Kind string `json:"kind"`
RouteID string `json:"routeId"`
Points []Point `json:"points"`
Direction string `json:"direction"`
Enabled bool `json:"enabled"`
ExpectedVersion int64 `json:"expectedVersion"`
UpdateBy int `json:"-"`
}
type Response struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
DeviceLocation string `json:"deviceLocation"`
ProfileToken string `json:"profileToken"`
ProfileName string `json:"profileName"`
ProfileWidth int `json:"profileWidth"`
ProfileHeight int `json:"profileHeight"`
ProfileEncoding string `json:"profileEncoding"`
RouteID string `json:"routeId"`
Version int64 `json:"version"`
Points []Point `json:"points"`
Direction string `json:"direction"`
Enabled bool `json:"enabled"`
NeedsRecalibration bool `json:"needsRecalibration"`
UpdatedBy int `json:"updatedBy"`
UpdatedAt time.Time `json:"updatedAt"`
}
type VersionResponse struct {
ID string `json:"id"`
Version int64 `json:"version"`
Name string `json:"name"`
Kind string `json:"kind"`
DeviceID string `json:"deviceId"`
ProfileToken string `json:"profileToken"`
ProfileWidth int `json:"profileWidth"`
ProfileHeight int `json:"profileHeight"`
ProfileEncoding string `json:"profileEncoding"`
Points []Point `json:"points"`
Direction string `json:"direction"`
Enabled bool `json:"enabled"`
SupersedesID string `json:"supersedesId"`
CreatedBy int `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
}
type ProfileSnapshot struct {
Token string
Width int
Height int
Encoding string
}
+84
View File
@@ -0,0 +1,84 @@
package area
import (
"errors"
"math"
)
var ErrInvalidGeometry = errors.New("区域几何不符合要求")
func validateGeometry(kind, direction string, points []Point) error {
if kind != KindPolygon && kind != KindDirectionLine {
return ErrInvalidGeometry
}
if (kind == KindPolygon && (len(points) < 3 || len(points) > 64)) || (kind == KindDirectionLine && len(points) != 2) {
return ErrInvalidGeometry
}
if kind == KindDirectionLine && direction != DirectionForward && direction != DirectionReverse {
return ErrInvalidGeometry
}
if kind == KindPolygon && direction != "" {
return ErrInvalidGeometry
}
for i, point := range points {
if math.IsNaN(point.X) || math.IsNaN(point.Y) || math.IsInf(point.X, 0) || math.IsInf(point.Y, 0) || point.X < 0 || point.X > 1 || point.Y < 0 || point.Y > 1 {
return ErrInvalidGeometry
}
if i > 0 && samePoint(point, points[i-1]) {
return ErrInvalidGeometry
}
}
if kind == KindDirectionLine {
if samePoint(points[0], points[1]) {
return ErrInvalidGeometry
}
return nil
}
if samePoint(points[0], points[len(points)-1]) || math.Abs(polygonArea(points)) < 0.000001 || polygonSelfIntersects(points) {
return ErrInvalidGeometry
}
return nil
}
func samePoint(a, b Point) bool {
return math.Abs(a.X-b.X) < 0.0000001 && math.Abs(a.Y-b.Y) < 0.0000001
}
func polygonArea(points []Point) float64 {
area := 0.0
for i := range points {
next := points[(i+1)%len(points)]
area += points[i].X*next.Y - next.X*points[i].Y
}
return area / 2
}
func polygonSelfIntersects(points []Point) bool {
for i := range points {
a1, a2 := points[i], points[(i+1)%len(points)]
for j := i + 1; j < len(points); j++ {
if j == i || j == (i+1)%len(points) || i == (j+1)%len(points) {
continue
}
b1, b2 := points[j], points[(j+1)%len(points)]
if segmentsIntersect(a1, a2, b1, b2) {
return true
}
}
}
return false
}
func segmentsIntersect(a, b, c, d Point) bool {
orientation := func(p, q, r Point) float64 {
return (q.Y-p.Y)*(r.X-q.X) - (q.X-p.X)*(r.Y-q.Y)
}
o1, o2, o3, o4 := orientation(a, b, c), orientation(a, b, d), orientation(c, d, a), orientation(c, d, b)
if ((o1 > 0 && o2 < 0) || (o1 < 0 && o2 > 0)) && ((o3 > 0 && o4 < 0) || (o3 < 0 && o4 > 0)) {
return true
}
onSegment := func(p, q, r Point) bool {
return q.X <= math.Max(p.X, r.X)+0.0000001 && q.X >= math.Min(p.X, r.X)-0.0000001 && q.Y <= math.Max(p.Y, r.Y)+0.0000001 && q.Y >= math.Min(p.Y, r.Y)-0.0000001
}
return (math.Abs(o1) < 0.0000001 && onSegment(a, c, b)) || (math.Abs(o2) < 0.0000001 && onSegment(a, d, b)) || (math.Abs(o3) < 0.0000001 && onSegment(c, a, d)) || (math.Abs(o4) < 0.0000001 && onSegment(c, b, d))
}
@@ -0,0 +1,27 @@
package area
import "testing"
func TestGeometryValidation(t *testing.T) {
tests := []struct {
name string
kind string
direction string
points []Point
valid bool
}{
{"polygon", KindPolygon, "", []Point{{0.1, 0.1}, {0.8, 0.1}, {0.5, 0.8}}, true},
{"self intersecting", KindPolygon, "", []Point{{0.1, 0.1}, {0.8, 0.8}, {0.8, 0.1}, {0.1, 0.8}}, false},
{"outside", KindPolygon, "", []Point{{-0.1, 0.1}, {0.8, 0.1}, {0.5, 0.8}}, false},
{"line", KindDirectionLine, DirectionForward, []Point{{0.2, 0.5}, {0.8, 0.5}}, true},
{"line missing direction", KindDirectionLine, "", []Point{{0.2, 0.5}, {0.8, 0.5}}, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := validateGeometry(test.kind, test.direction, test.points)
if (err == nil) != test.valid {
t.Fatalf("valid=%v err=%v", test.valid, err)
}
})
}
}
+45
View File
@@ -0,0 +1,45 @@
package area
import "time"
type Definition struct {
ID string `gorm:"size:36;primaryKey"`
Name string `gorm:"size:128;not null;index"`
Kind string `gorm:"size:32;not null;index"`
DeviceID string `gorm:"size:36;not null;index"`
ProfileToken string `gorm:"size:255;not null"`
ProfileWidth int `gorm:"not null"`
ProfileHeight int `gorm:"not null"`
ProfileEncoding string `gorm:"size:32;not null"`
CurrentVersion int64 `gorm:"not null"`
CurrentVersionID string `gorm:"size:36;not null;uniqueIndex"`
Enabled bool `gorm:"not null"`
NeedsRecalibration bool `gorm:"not null;index"`
CreatedBy int `gorm:"not null"`
UpdatedBy int `gorm:"not null"`
CreatedAt time.Time `gorm:"not null"`
UpdatedAt time.Time `gorm:"not null"`
}
func (Definition) TableName() string { return "sense_area_definitions" }
type Version struct {
ID string `gorm:"size:36;primaryKey"`
DefinitionID string `gorm:"size:36;not null;uniqueIndex:ux_sense_area_version,priority:1;index"`
Version int64 `gorm:"not null;uniqueIndex:ux_sense_area_version,priority:2"`
Name string `gorm:"size:128;not null"`
Kind string `gorm:"size:32;not null"`
DeviceID string `gorm:"size:36;not null"`
ProfileToken string `gorm:"size:255;not null"`
ProfileWidth int `gorm:"not null"`
ProfileHeight int `gorm:"not null"`
ProfileEncoding string `gorm:"size:32;not null"`
GeometryJSON string `gorm:"type:text;not null"`
Direction string `gorm:"size:16;not null"`
Enabled bool `gorm:"not null"`
SupersedesID string `gorm:"size:36"`
CreatedBy int `gorm:"not null"`
CreatedAt time.Time `gorm:"not null"`
}
func (Version) TableName() string { return "sense_area_versions" }
@@ -0,0 +1,95 @@
package area
import (
"context"
"errors"
"os"
"strings"
"sync"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func TestConcurrentUpdateOnPostgresReturnsConflict(t *testing.T) {
baseDSN := os.Getenv("SENSE_AREA_TEST_DATABASE_URL")
if baseDSN == "" {
t.Skip("set SENSE_AREA_TEST_DATABASE_URL to run the PostgreSQL concurrency test")
}
admin, err := gorm.Open(postgres.Open(baseDSN), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
const schema = "sense_area_69_concurrency"
if err = admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
t.Fatal(err)
}
if err = admin.Exec("CREATE SCHEMA " + schema).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") })
separator := "?"
if strings.Contains(baseDSN, "?") {
separator = "&"
}
db, err := gorm.Open(postgres.Open(baseDSN+separator+"search_path="+schema), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&Definition{}, &Version{}); err != nil {
t.Fatal(err)
}
for _, statement := range []string{
`CREATE TABLE sense_devices (id text primary key, name text, location text, status text)`,
`CREATE TABLE sense_admission_profiles (device_id text, token text, name text, width integer, height integer, encoding text, verification_status text)`,
`CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text)`,
`INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`,
`INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流',1920,1080,'H264','ready')`,
`INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main')`,
} {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
service := NewService(db)
created, err := service.Create(context.Background(), triangleRequest())
if err != nil {
t.Fatal(err)
}
start := make(chan struct{})
errorsChannel := make(chan error, 2)
var wait sync.WaitGroup
for index := 0; index < 2; index++ {
wait.Add(1)
go func() {
defer wait.Done()
<-start
request := triangleRequest()
request.ExpectedVersion = created.Version
_, updateErr := service.Update(context.Background(), created.ID, request)
errorsChannel <- updateErr
}()
}
close(start)
wait.Wait()
close(errorsChannel)
successes, conflicts := 0, 0
for updateErr := range errorsChannel {
switch {
case updateErr == nil:
successes++
case errors.Is(updateErr, ErrConflict):
conflicts++
default:
t.Fatalf("unexpected concurrent update error: %v", updateErr)
}
}
if successes != 1 || conflicts != 1 {
t.Fatalf("successes=%d conflicts=%d", successes, conflicts)
}
versions, err := service.Versions(context.Background(), created.ID)
if err != nil || len(versions) != 2 {
t.Fatalf("versions=%d err=%v", len(versions), err)
}
}
+304
View File
@@ -0,0 +1,304 @@
package area
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
ErrInvalidRequest = errors.New("区域配置请求不符合要求")
ErrNotFound = errors.New("区域配置不存在")
ErrConflict = errors.New("区域配置已被其他用户更新,请刷新后重试")
ErrProfileMissing = errors.New("绑定的视频 Profile 不可用,请先完成视频接入")
)
type Service struct{ db *gorm.DB }
func NewService(db *gorm.DB) *Service { return &Service{db: db} }
type routeSnapshot struct {
RouteID string `gorm:"column:route_id"`
DeviceID string `gorm:"column:device_id"`
DeviceName string `gorm:"column:device_name"`
DeviceLocation string `gorm:"column:device_location"`
ProfileToken string `gorm:"column:profile_token"`
ProfileName string `gorm:"column:profile_name"`
Width int `gorm:"column:width"`
Height int `gorm:"column:height"`
Encoding string `gorm:"column:encoding"`
Verification string `gorm:"column:verification_status"`
}
func (s *Service) List(ctx context.Context, request PageRequest) ([]Response, int64, error) {
request.PageIndex, request.PageSize = normalizePage(request.PageIndex, request.PageSize)
if utf8.RuneCountInString(request.Keyword) > 128 || (request.Kind != "" && request.Kind != KindPolygon && request.Kind != KindDirectionLine) {
return nil, 0, ErrInvalidRequest
}
query := s.db.WithContext(ctx).Model(&Definition{})
if keyword := strings.TrimSpace(request.Keyword); keyword != "" {
pattern := "%" + escapeLike(keyword) + "%"
query = query.Where("LOWER(name) LIKE LOWER(?) ESCAPE '\\'", pattern)
}
if request.Kind != "" {
query = query.Where("kind = ?", request.Kind)
}
switch request.RecalibrationState {
case "", "all":
case "needed":
query = query.Where("needs_recalibration = ?", true)
case "ready":
query = query.Where("needs_recalibration = ?", false)
default:
return nil, 0, ErrInvalidRequest
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var definitions []Definition
if err := query.Order("updated_at DESC, name ASC").Offset((request.PageIndex - 1) * request.PageSize).Limit(request.PageSize).Find(&definitions).Error; err != nil {
return nil, 0, err
}
items := make([]Response, 0, len(definitions))
for i := range definitions {
item, err := s.response(ctx, &definitions[i])
if err != nil {
return nil, 0, err
}
items = append(items, item)
}
return items, total, nil
}
func (s *Service) Create(ctx context.Context, request UpsertRequest) (Response, error) {
if request.ExpectedVersion != 0 {
return Response{}, ErrInvalidRequest
}
name, err := validateRequest(request)
if err != nil {
return Response{}, err
}
now := time.Now().UTC()
definitionID := uuid.NewString()
versionID := uuid.NewString()
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
route, routeErr := loadRoute(tx, request.RouteID)
if routeErr != nil {
return routeErr
}
geometry, marshalErr := json.Marshal(request.Points)
if marshalErr != nil {
return ErrInvalidGeometry
}
definition := Definition{ID: definitionID, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, CurrentVersion: 1, CurrentVersionID: versionID, Enabled: request.Enabled, CreatedBy: request.UpdateBy, UpdatedBy: request.UpdateBy, CreatedAt: now, UpdatedAt: now}
version := Version{ID: versionID, DefinitionID: definitionID, Version: 1, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, GeometryJSON: string(geometry), Direction: request.Direction, Enabled: request.Enabled, CreatedBy: request.UpdateBy, CreatedAt: now}
if err = tx.Create(&definition).Error; err != nil {
return err
}
return tx.Create(&version).Error
})
if err != nil {
return Response{}, err
}
return s.Get(ctx, definitionID)
}
func (s *Service) Update(ctx context.Context, id string, request UpsertRequest) (Response, error) {
if strings.TrimSpace(id) == "" || request.ExpectedVersion < 1 {
return Response{}, ErrInvalidRequest
}
name, err := validateRequest(request)
if err != nil {
return Response{}, err
}
now := time.Now().UTC()
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current Definition
if readErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, "id = ?", id).Error; readErr != nil {
if errors.Is(readErr, gorm.ErrRecordNotFound) {
return ErrNotFound
}
return readErr
}
if current.CurrentVersion != request.ExpectedVersion {
return ErrConflict
}
route, routeErr := loadRoute(tx, request.RouteID)
if routeErr != nil {
return routeErr
}
geometry, marshalErr := json.Marshal(request.Points)
if marshalErr != nil {
return ErrInvalidGeometry
}
versionID := uuid.NewString()
version := Version{ID: versionID, DefinitionID: current.ID, Version: current.CurrentVersion + 1, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, GeometryJSON: string(geometry), Direction: request.Direction, Enabled: request.Enabled, SupersedesID: current.CurrentVersionID, CreatedBy: request.UpdateBy, CreatedAt: now}
if createErr := tx.Create(&version).Error; createErr != nil {
return createErr
}
updates := map[string]any{"name": name, "kind": request.Kind, "device_id": route.DeviceID, "profile_token": route.ProfileToken, "profile_width": route.Width, "profile_height": route.Height, "profile_encoding": route.Encoding, "current_version": version.Version, "current_version_id": versionID, "enabled": request.Enabled, "needs_recalibration": false, "updated_by": request.UpdateBy, "updated_at": now}
result := tx.Model(&Definition{}).Where("id = ? AND current_version = ?", current.ID, request.ExpectedVersion).Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrConflict
}
return nil
})
if err != nil {
return Response{}, err
}
return s.Get(ctx, id)
}
func (s *Service) Get(ctx context.Context, id string) (Response, error) {
var definition Definition
if err := s.db.WithContext(ctx).First(&definition, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return Response{}, ErrNotFound
}
return Response{}, err
}
return s.response(ctx, &definition)
}
func (s *Service) Versions(ctx context.Context, id string) ([]VersionResponse, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&Definition{}).Where("id = ?", id).Count(&count).Error; err != nil {
return nil, err
}
if count == 0 {
return nil, ErrNotFound
}
var versions []Version
if err := s.db.WithContext(ctx).Where("definition_id = ?", id).Order("version DESC").Find(&versions).Error; err != nil {
return nil, err
}
out := make([]VersionResponse, 0, len(versions))
for _, version := range versions {
points, err := decodePoints(version.GeometryJSON)
if err != nil {
return nil, fmt.Errorf("decode area version %d: %w", version.Version, err)
}
out = append(out, VersionResponse{ID: version.ID, Version: version.Version, Name: version.Name, Kind: version.Kind, DeviceID: version.DeviceID, ProfileToken: version.ProfileToken, ProfileWidth: version.ProfileWidth, ProfileHeight: version.ProfileHeight, ProfileEncoding: version.ProfileEncoding, Points: points, Direction: version.Direction, Enabled: version.Enabled, SupersedesID: version.SupersedesID, CreatedBy: version.CreatedBy, CreatedAt: version.CreatedAt})
}
return out, nil
}
func (s *Service) response(ctx context.Context, definition *Definition) (Response, error) {
var version Version
if err := s.db.WithContext(ctx).First(&version, "id = ?", definition.CurrentVersionID).Error; err != nil {
return Response{}, err
}
points, err := decodePoints(version.GeometryJSON)
if err != nil {
return Response{}, err
}
var route routeSnapshot
query := s.db.WithContext(ctx).Table("sense_devices AS d").
Select("COALESCE(r.id, '') AS route_id, d.id AS device_id, d.name AS device_name, d.location AS device_location, COALESCE(p.token, '') AS profile_token, COALESCE(p.name, '') AS profile_name, COALESCE(p.width, 0) AS width, COALESCE(p.height, 0) AS height, COALESCE(p.encoding, '') AS encoding, COALESCE(p.verification_status, '') AS verification_status").
Joins("LEFT JOIN sense_admission_profiles AS p ON p.device_id = d.id AND p.token = ?", definition.ProfileToken).
Joins("LEFT JOIN sense_media_routes AS r ON r.device_id = d.id AND r.profile_token = ?", definition.ProfileToken).
Where("d.id = ?", definition.DeviceID).Limit(1).Scan(&route)
if query.Error != nil {
return Response{}, query.Error
}
recalibration := definition.NeedsRecalibration || route.ProfileToken == "" || route.Verification != "ready" || route.Width != definition.ProfileWidth || route.Height != definition.ProfileHeight || !strings.EqualFold(route.Encoding, definition.ProfileEncoding)
if recalibration && !definition.NeedsRecalibration {
if err = s.db.WithContext(ctx).Model(&Definition{}).Where("id = ? AND needs_recalibration = ?", definition.ID, false).Update("needs_recalibration", true).Error; err != nil {
return Response{}, err
}
definition.NeedsRecalibration = true
}
return Response{ID: definition.ID, Name: definition.Name, Kind: definition.Kind, DeviceID: definition.DeviceID, DeviceName: route.DeviceName, DeviceLocation: route.DeviceLocation, ProfileToken: definition.ProfileToken, ProfileName: route.ProfileName, ProfileWidth: definition.ProfileWidth, ProfileHeight: definition.ProfileHeight, ProfileEncoding: definition.ProfileEncoding, RouteID: route.RouteID, Version: definition.CurrentVersion, Points: points, Direction: version.Direction, Enabled: definition.Enabled, NeedsRecalibration: recalibration, UpdatedBy: definition.UpdatedBy, UpdatedAt: definition.UpdatedAt}, nil
}
func MarkProfilesReplaced(tx *gorm.DB, deviceID string, profiles []ProfileSnapshot) error {
if tx == nil || !tx.Migrator().HasTable(&Definition{}) {
return nil
}
available := make(map[string]ProfileSnapshot, len(profiles))
for _, profile := range profiles {
available[profile.Token] = profile
}
var definitions []Definition
if err := tx.Where("device_id = ? AND needs_recalibration = ?", deviceID, false).Find(&definitions).Error; err != nil {
return err
}
for _, definition := range definitions {
profile, ok := available[definition.ProfileToken]
if !ok || profile.Width != definition.ProfileWidth || profile.Height != definition.ProfileHeight || !strings.EqualFold(profile.Encoding, definition.ProfileEncoding) {
if err := tx.Model(&Definition{}).Where("id = ?", definition.ID).Update("needs_recalibration", true).Error; err != nil {
return err
}
}
}
return nil
}
func loadRoute(db *gorm.DB, routeID string) (routeSnapshot, error) {
if strings.TrimSpace(routeID) == "" {
return routeSnapshot{}, ErrProfileMissing
}
var route routeSnapshot
err := db.Table("sense_media_routes AS r").
Select("r.id AS route_id, r.device_id, d.name AS device_name, d.location AS device_location, r.profile_token, p.name AS profile_name, p.width, p.height, p.encoding, p.verification_status").
Joins("JOIN sense_devices AS d ON d.id = r.device_id").
Joins("JOIN sense_admission_profiles AS p ON p.device_id = r.device_id AND p.token = r.profile_token").
Where("r.id = ? AND p.verification_status = ?", routeID, "ready").Limit(1).Scan(&route).Error
if err != nil {
return routeSnapshot{}, err
}
if route.RouteID == "" || route.Width < 1 || route.Height < 1 {
return routeSnapshot{}, ErrProfileMissing
}
return route, nil
}
func validateRequest(request UpsertRequest) (string, error) {
name := strings.TrimSpace(request.Name)
if name == "" || utf8.RuneCountInString(name) > 128 || request.UpdateBy < 1 || strings.TrimSpace(request.RouteID) == "" {
return "", ErrInvalidRequest
}
if err := validateGeometry(request.Kind, request.Direction, request.Points); err != nil {
return "", err
}
return name, nil
}
func decodePoints(value string) ([]Point, error) {
var points []Point
if err := json.Unmarshal([]byte(value), &points); err != nil {
return nil, err
}
return points, nil
}
func normalizePage(index, size int) (int, int) {
if index < 1 {
index = 1
}
if size < 1 {
size = 10
}
if size > 50 {
size = 50
}
return index, size
}
func escapeLike(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
value = strings.ReplaceAll(value, `%`, `\%`)
return strings.ReplaceAll(value, `_`, `\_`)
}
@@ -0,0 +1,95 @@
package area
import (
"context"
"errors"
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func areaTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&Definition{}, &Version{}); err != nil {
t.Fatal(err)
}
statements := []string{
`CREATE TABLE sense_devices (id text primary key, name text, location text, status text)`,
`CREATE TABLE sense_admission_profiles (device_id text, token text, name text, width integer, height integer, encoding text, verification_status text)`,
`CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text)`,
`INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`,
`INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流',1920,1080,'H264','ready')`,
`INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main')`,
}
for _, statement := range statements {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
return db
}
func triangleRequest() UpsertRequest {
return UpsertRequest{Name: "操场危险区域", Kind: KindPolygon, RouteID: "device-1:main", Points: []Point{{0.1, 0.1}, {0.8, 0.1}, {0.5, 0.8}}, Enabled: true, UpdateBy: 7}
}
func TestVersionsAreAppendOnlyAndUseOptimisticConcurrency(t *testing.T) {
service := NewService(areaTestDB(t))
created, err := service.Create(context.Background(), triangleRequest())
if err != nil || created.Version != 1 || created.ProfileWidth != 1920 {
t.Fatalf("created=%+v err=%v", created, err)
}
request := triangleRequest()
request.Name = "操场危险区域(校准)"
request.ExpectedVersion = 1
updated, err := service.Update(context.Background(), created.ID, request)
if err != nil || updated.Version != 2 {
t.Fatalf("updated=%+v err=%v", updated, err)
}
if _, err = service.Update(context.Background(), created.ID, request); !errors.Is(err, ErrConflict) {
t.Fatalf("stale update err=%v", err)
}
versions, err := service.Versions(context.Background(), created.ID)
if err != nil || len(versions) != 2 || versions[0].Version != 2 || versions[1].Version != 1 || versions[0].SupersedesID != versions[1].ID {
t.Fatalf("versions=%+v err=%v", versions, err)
}
}
func TestProfileReplacementMarksOnlyChangedBindings(t *testing.T) {
db := areaTestDB(t)
service := NewService(db)
created, err := service.Create(context.Background(), triangleRequest())
if err != nil {
t.Fatal(err)
}
if err = MarkProfilesReplaced(db, "device-1", []ProfileSnapshot{{Token: "main", Width: 1920, Height: 1080, Encoding: "H264"}}); err != nil {
t.Fatal(err)
}
item, _ := service.Get(context.Background(), created.ID)
if item.NeedsRecalibration {
t.Fatal("unchanged profile was marked for recalibration")
}
if err = MarkProfilesReplaced(db, "device-1", []ProfileSnapshot{{Token: "main", Width: 1280, Height: 720, Encoding: "H264"}}); err != nil {
t.Fatal(err)
}
item, _ = service.Get(context.Background(), created.ID)
if !item.NeedsRecalibration {
t.Fatal("resolution change did not mark recalibration")
}
}
func TestListIsPaginatedAndDoesNotExposeMediaSecrets(t *testing.T) {
service := NewService(areaTestDB(t))
if _, err := service.Create(context.Background(), triangleRequest()); err != nil {
t.Fatal(err)
}
items, total, err := service.List(context.Background(), PageRequest{Keyword: "操场", PageIndex: 1, PageSize: 10})
if err != nil || total != 1 || len(items) != 1 || items[0].RouteID != "device-1:main" {
t.Fatalf("items=%+v total=%d err=%v", items, total, err)
}
}
@@ -0,0 +1,35 @@
package credential
import (
"errors"
"fmt"
"gorm.io/gorm"
)
var ErrCredentialNotConfigured = errors.New("摄像头凭据尚未配置")
type Value struct {
Username string
Password string
}
// Read is an internal adapter port. HTTP handlers must never expose Value.
func Read(db *gorm.DB, deviceID, purpose string) (Value, error) {
var row DeviceCredential
if err := db.First(&row, "device_id = ? AND purpose = ?", deviceID, purpose).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return Value{}, ErrCredentialNotConfigured
}
return Value{}, fmt.Errorf("read device credential: %w", err)
}
vault, err := NewVaultFromEnvironment()
if err != nil {
return Value{}, err
}
username, password, err := vault.Decrypt(deviceID, purpose, row.Ciphertext)
if err != nil {
return Value{}, err
}
return Value{Username: username, Password: password}, nil
}
@@ -9,8 +9,10 @@ import (
const (
ModalityVideo = "video"
StatusPending = "pending"
StatusActive = "active"
StatusDisabled = "disabled"
AdapterReady = "ready"
AdapterFailed = "verification_failed"
AdapterNotReady = "adapter_not_ready"
)
+134
View File
@@ -0,0 +1,134 @@
package liveview
import (
"encoding/json"
"errors"
"html/template"
"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"
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
)
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), nil
}
func (e *API) List(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.Error(http.StatusInternalServerError, err, "实时监看服务初始化失败")
return
}
pageIndex, _ := strconv.Atoi(c.DefaultQuery("pageIndex", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
items, total, err := service.List(c.Request.Context(), PageRequest{Keyword: c.Query("keyword"), PageIndex: pageIndex, PageSize: pageSize})
if err != nil {
e.writeError(err)
return
}
e.PageOK(items, int(total), pageIndex, pageSize, "查询成功")
}
func (e *API) Create(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.Error(http.StatusInternalServerError, err, "实时监看服务初始化失败")
return
}
var request struct {
RouteID string `json:"routeId"`
}
if err = decodeJSON(c, &request); err != nil {
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
return
}
session, err := service.Create(c.Request.Context(), user.GetUserId(c), request.RouteID, c.Request)
if err != nil {
e.writeError(err)
return
}
e.OK(session, "播放会话已创建")
}
func (e *API) Get(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.Error(http.StatusInternalServerError, err, "实时监看服务初始化失败")
return
}
session, err := service.Get(c.Request.Context(), user.GetUserId(c), c.Param("id"))
if err != nil {
e.writeError(err)
return
}
e.OK(session, "查询成功")
}
var playerTemplate = template.Must(template.New("liveview-player").Parse(`<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<style>html,body,iframe{width:100%;height:100%;margin:0;border:0;background:#101419;overflow:hidden}</style></head>
<body><iframe src="{{.}}" title="Sense 实时视频" allow="autoplay; fullscreen" referrerpolicy="no-referrer"></iframe></body></html>`))
func (e *API) Player(c *gin.Context) {
target, err := NewService(nil, nil).PlayerTarget(c.Param("id"))
if err != nil {
c.Header("Cache-Control", "no-store")
c.String(http.StatusGone, "播放会话已过期,请重新连接")
return
}
parsed := template.URL(target)
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Cache-Control", "no-store")
c.Header("Referrer-Policy", "no-referrer")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "SAMEORIGIN")
c.Header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'self'; frame-src http: https:; style-src 'unsafe-inline'")
if err = playerTemplate.Execute(c.Writer, parsed); err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
}
}
func (e *API) writeError(err error) {
switch {
case errors.Is(err, ErrInvalidRequest):
e.Error(http.StatusBadRequest, err, err.Error())
case errors.Is(err, ErrRouteNotFound):
e.Error(http.StatusNotFound, err, err.Error())
case errors.Is(err, ErrSessionExpired):
e.Error(http.StatusGone, err, "播放会话已过期,请重新连接")
case strings.Contains(err.Error(), "WEBRTC_PUBLIC_BASE"), strings.Contains(err.Error(), "浏览器可访问"):
e.Error(http.StatusServiceUnavailable, err, "浏览器播放地址未正确配置")
default:
e.Error(http.StatusInternalServerError, err, "实时监看操作失败")
}
}
func decodeJSON(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, 16<<10))
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
}
@@ -0,0 +1,41 @@
package liveview
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestPlayerUsesShortLivedCapabilityAndRestrictiveHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
store := NewStore()
now := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
store.now = func() time.Time { return now }
previous := defaultStore
defaultStore = store
t.Cleanup(func() { defaultStore = previous })
store.put(sessionRecord{ID: "view_test", OwnerID: 7, RouteID: "route-1", TargetURL: "http://127.0.0.1:8889/sense_test?controls=true", ExpiresAt: now.Add(sessionTTL)})
router := gin.New()
api := &API{}
router.GET("/api/v1/liveview/player/:id", api.Player)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/liveview/player/view_test", nil))
if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" || recorder.Header().Get("X-Frame-Options") != "SAMEORIGIN" {
t.Fatalf("status=%d headers=%v", recorder.Code, recorder.Header())
}
if !strings.Contains(recorder.Body.String(), "http://127.0.0.1:8889/sense_test?controls=true") || !strings.Contains(recorder.Header().Get("Content-Security-Policy"), "frame-ancestors 'self'") {
t.Fatalf("unexpected wrapper response: %s", recorder.Body.String())
}
now = now.Add(sessionTTL)
expired := httptest.NewRecorder()
router.ServeHTTP(expired, httptest.NewRequest(http.MethodGet, "/api/v1/liveview/player/view_test", nil))
if expired.Code != http.StatusGone {
t.Fatalf("expired capability status=%d", expired.Code)
}
}
+291
View File
@@ -0,0 +1,291 @@
package liveview
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"sync"
"time"
"gorm.io/gorm"
)
var (
ErrInvalidRequest = errors.New("实时监看请求不符合要求")
ErrRouteNotFound = errors.New("可监看的视频不存在")
ErrSessionExpired = errors.New("播放会话已过期")
validMediaPath = regexp.MustCompile(`^[A-Za-z0-9_-]{1,96}$`)
validDNSHost = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
)
const sessionTTL = 2 * time.Minute
type PageRequest struct {
Keyword string
PageIndex int
PageSize int
}
type RouteResponse struct {
ID string `json:"id"`
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
DeviceLocation string `json:"deviceLocation"`
ProfileToken string `json:"profileToken"`
ProfileName string `json:"profileName"`
ProfileKind string `json:"profileKind"`
Width int `json:"width"`
Height int `json:"height"`
Encoding string `json:"encoding"`
Actual string `json:"actual"`
Detail string `json:"detail"`
Readers int `json:"readers"`
}
type SessionResponse struct {
ID string `json:"id"`
RouteID string `json:"routeId"`
PlayerURL string `json:"playerUrl"`
Status string `json:"status"`
Detail string `json:"detail"`
ExpiresAt time.Time `json:"expiresAt"`
DeviceName string `json:"deviceName"`
ProfileName string `json:"profileName"`
}
type routeRecord struct {
RouteResponse
Path string `gorm:"column:path"`
Desired string `gorm:"column:desired"`
}
type sessionRecord struct {
ID string
OwnerID int
RouteID string
TargetURL string
ExpiresAt time.Time
DeviceName string
ProfileName string
}
type Store struct {
mu sync.RWMutex
sessions map[string]sessionRecord
now func() time.Time
}
func NewStore() *Store {
return &Store{sessions: make(map[string]sessionRecord), now: time.Now}
}
var defaultStore = NewStore()
type Service struct {
db *gorm.DB
store *Store
}
func NewService(db *gorm.DB, store *Store) *Service {
if store == nil {
store = defaultStore
}
return &Service{db: db, store: store}
}
func (s *Service) List(ctx context.Context, request PageRequest) ([]RouteResponse, int64, error) {
if request.PageIndex < 1 {
request.PageIndex = 1
}
if request.PageSize < 1 {
request.PageSize = 10
}
if request.PageSize > 50 {
request.PageSize = 50
}
query := s.routeQuery(ctx).Where("r.desired = ?", "running")
keyword := strings.TrimSpace(request.Keyword)
if len([]rune(keyword)) > 128 {
return nil, 0, ErrInvalidRequest
}
if keyword != "" {
pattern := "%" + escapeLike(keyword) + "%"
query = query.Where("LOWER(d.name) LIKE LOWER(?) ESCAPE '\\' OR LOWER(d.location) LIKE LOWER(?) ESCAPE '\\' OR LOWER(p.name) LIKE LOWER(?) ESCAPE '\\'", pattern, pattern, pattern)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var records []routeRecord
if err := query.Order("d.name ASC, p.kind ASC, p.width DESC").Offset((request.PageIndex - 1) * request.PageSize).Limit(request.PageSize).Scan(&records).Error; err != nil {
return nil, 0, err
}
items := make([]RouteResponse, 0, len(records))
for _, record := range records {
items = append(items, record.RouteResponse)
}
return items, total, nil
}
func (s *Service) Create(ctx context.Context, ownerID int, routeID string, request *http.Request) (SessionResponse, error) {
if ownerID < 1 || strings.TrimSpace(routeID) == "" {
return SessionResponse{}, ErrInvalidRequest
}
record, err := s.route(ctx, routeID)
if err != nil {
return SessionResponse{}, err
}
if record.Desired != "running" || !validMediaPath.MatchString(record.Path) {
return SessionResponse{}, ErrRouteNotFound
}
base, err := playbackBase(request)
if err != nil {
return SessionResponse{}, err
}
target := *base
target.Path = strings.TrimRight(target.Path, "/") + "/" + record.Path
target.RawQuery = "controls=true&muted=true&autoplay=true"
id, err := randomID()
if err != nil {
return SessionResponse{}, err
}
now := s.store.now().UTC()
session := sessionRecord{ID: id, OwnerID: ownerID, RouteID: record.ID, TargetURL: target.String(), ExpiresAt: now.Add(sessionTTL), DeviceName: record.DeviceName, ProfileName: record.ProfileName}
s.store.put(session)
return responseFrom(session, record), nil
}
func (s *Service) Get(ctx context.Context, ownerID int, id string) (SessionResponse, error) {
session, err := s.store.get(id, ownerID, true)
if err != nil {
return SessionResponse{}, err
}
record, err := s.route(ctx, session.RouteID)
if err != nil || record.Desired != "running" {
return SessionResponse{}, ErrRouteNotFound
}
session.ExpiresAt = s.store.now().UTC().Add(sessionTTL)
s.store.put(session)
return responseFrom(session, record), nil
}
func (s *Service) PlayerTarget(id string) (string, error) {
session, err := s.store.get(id, 0, false)
if err != nil {
return "", err
}
return session.TargetURL, nil
}
func (s *Service) route(ctx context.Context, id string) (routeRecord, error) {
var record routeRecord
if err := s.routeQuery(ctx).Where("r.id = ?", id).Limit(1).Scan(&record).Error; err != nil {
return routeRecord{}, err
}
if record.ID == "" {
return routeRecord{}, ErrRouteNotFound
}
return record, nil
}
func (s *Service) routeQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).Table("sense_media_routes AS r").
Select("r.id, r.device_id, d.name AS device_name, d.location AS device_location, r.profile_token, p.name AS profile_name, p.kind AS profile_kind, p.width, p.height, p.encoding, r.actual, r.detail, r.readers, r.path, r.desired").
Joins("JOIN sense_devices AS d ON d.id = r.device_id").
Joins("JOIN sense_admission_profiles AS p ON p.device_id = r.device_id AND p.token = r.profile_token").
Where("d.status <> ? AND p.verification_status = ?", "disabled", "ready")
}
func responseFrom(session sessionRecord, route routeRecord) SessionResponse {
return SessionResponse{ID: session.ID, RouteID: session.RouteID, PlayerURL: "/api/v1/liveview/player/" + session.ID, Status: playbackStatus(route.Actual), Detail: route.Detail, ExpiresAt: session.ExpiresAt, DeviceName: session.DeviceName, ProfileName: session.ProfileName}
}
func playbackStatus(actual string) string {
switch actual {
case "ready", "waiting", "stopped":
return actual
case "credential_unavailable", "profile_unavailable":
return "authentication_failed"
case "path_missing", "apply_failed", "status_unavailable":
return "stream_not_found"
case "process_unavailable":
return "service_unavailable"
default:
return "offline"
}
}
func (s *Store) put(session sessionRecord) {
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
for id, item := range s.sessions {
if !now.Before(item.ExpiresAt) || item.OwnerID == session.OwnerID {
delete(s.sessions, id)
}
}
s.sessions[session.ID] = session
}
func (s *Store) get(id string, ownerID int, checkOwner bool) (sessionRecord, error) {
s.mu.RLock()
session, ok := s.sessions[id]
s.mu.RUnlock()
if !ok || !s.now().Before(session.ExpiresAt) || (checkOwner && session.OwnerID != ownerID) {
return sessionRecord{}, ErrSessionExpired
}
return session, nil
}
func playbackBase(request *http.Request) (*url.URL, error) {
configured := strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE"))
if configured != "" {
return validatePlaybackBase(configured)
}
if request == nil || request.Host == "" {
return nil, errors.New("无法确定浏览器可访问的视频服务地址")
}
host := request.Host
if parsedHost, _, err := net.SplitHostPort(request.Host); err == nil {
host = parsedHost
}
host = strings.Trim(host, "[]")
if host == "" || (net.ParseIP(host) == nil && host != "localhost" && !validDNSHost.MatchString(host)) {
return nil, errors.New("无效的请求主机")
}
scheme := "http"
if request.TLS != nil {
scheme = "https"
}
return validatePlaybackBase(fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(host, "8889")))
}
func validatePlaybackBase(value string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimRight(value, "/"))
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
return nil, errors.New("SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE 配置不安全")
}
return parsed, nil
}
func randomID() (string, error) {
value := make([]byte, 24)
if _, err := rand.Read(value); err != nil {
return "", err
}
return "view_" + hex.EncodeToString(value), nil
}
func escapeLike(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
value = strings.ReplaceAll(value, `%`, `\%`)
return strings.ReplaceAll(value, `_`, `\_`)
}
@@ -0,0 +1,89 @@
package liveview
import (
"context"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func testDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
statements := []string{
`CREATE TABLE sense_devices (id text primary key, name text, location text, status text)`,
`CREATE TABLE sense_admission_profiles (device_id text, token text, name text, kind text, width integer, height integer, encoding text, verification_status text, stream_uri text)`,
`CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text, path text, desired text, actual text, detail text, readers integer)`,
`INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`,
`INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流','main',1920,1080,'H264','ready','rtsp://camera.example/live')`,
`INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main','sense_012345','running','waiting','等待播放器连接并按需拉流',0)`,
}
for _, statement := range statements {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
return db
}
func TestListIsPaginatedSearchableAndSecretFree(t *testing.T) {
service := NewService(testDB(t), NewStore())
items, total, err := service.List(context.Background(), PageRequest{Keyword: "东门", PageIndex: 1, PageSize: 10})
if err != nil || total != 1 || len(items) != 1 || items[0].ProfileKind != "main" {
t.Fatalf("items=%+v total=%d err=%v", items, total, err)
}
encoded, _ := json.Marshal(items)
if strings.Contains(string(encoded), "rtsp://") || strings.Contains(string(encoded), "sense_012345") {
t.Fatalf("response leaked private media data: %s", encoded)
}
}
func TestSessionIsOwnerBoundShortLivedAndUsesBrowserHost(t *testing.T) {
store := NewStore()
now := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
store.now = func() time.Time { return now }
service := NewService(testDB(t), store)
request := httptest.NewRequest("POST", "http://192.0.2.20:18080/api/v1/liveview/sessions", nil)
session, err := service.Create(context.Background(), 7, "device-1:main", request)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(session.PlayerURL, "/api/v1/liveview/player/view_") || strings.Contains(session.PlayerURL, "sense_012345") {
t.Fatalf("unsafe player URL: %s", session.PlayerURL)
}
if _, err = service.Get(context.Background(), 8, session.ID); !errorsIs(err, ErrSessionExpired) {
t.Fatalf("another owner accessed session: %v", err)
}
target, err := service.PlayerTarget(session.ID)
if err != nil || target != "http://192.0.2.20:8889/sense_012345?controls=true&muted=true&autoplay=true" {
t.Fatalf("target=%q err=%v", target, err)
}
now = now.Add(sessionTTL)
if _, err = service.PlayerTarget(session.ID); !errorsIs(err, ErrSessionExpired) {
t.Fatalf("expired session remained valid: %v", err)
}
}
func TestPlaybackBaseRejectsCredentials(t *testing.T) {
if _, err := validatePlaybackBase("http://invalid-user@127.0.0.1:8889"); err == nil {
t.Fatal("expected credential-bearing base URL to be rejected")
}
}
func TestPlaybackStatusKeepsActionableFailuresDistinct(t *testing.T) {
for input, want := range map[string]string{"waiting": "waiting", "credential_unavailable": "authentication_failed", "path_missing": "stream_not_found", "process_unavailable": "service_unavailable", "unexpected": "offline"} {
if got := playbackStatus(input); got != want {
t.Fatalf("playbackStatus(%q)=%q want %q", input, got, want)
}
}
}
func errorsIs(err, target error) bool { return err == target }
+98
View File
@@ -0,0 +1,98 @@
package media
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
)
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 serviceFor(base.Orm), nil
}
func (e *API) List(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
items, err := service.List(c.Request.Context())
if err != nil {
e.writeError(err)
return
}
e.OK(gin.H{"list": items, "total": len(items)}, "查询成功")
}
func (e *API) Process(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
e.OK(service.ProcessState(), "查询成功")
}
func (e *API) ReconcileAll(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
if err = service.EnsureAllVerified(c.Request.Context()); err == nil {
err = service.ReconcileDue(c.Request.Context())
}
if err != nil {
e.writeError(err)
return
}
e.OK(nil, "对账完成")
}
func (e *API) Reconcile(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
item, err := service.Reconcile(c.Request.Context(), c.Param("id"))
if err != nil && item.ID == "" {
e.writeError(err)
return
}
e.OK(item, "对账完成")
}
func (e *API) Stop(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
item, err := service.StopRoute(c.Request.Context(), c.Param("id"))
if err != nil {
e.writeError(err)
return
}
e.OK(item, "媒体路径已停止")
}
func (e *API) writeError(err error) {
switch {
case errors.Is(err, ErrNotFound):
e.Error(http.StatusNotFound, err, err.Error())
case errors.Is(err, ErrRuntimeUnavailable), errors.Is(err, ErrUnsafeControlAPI):
e.Error(http.StatusServiceUnavailable, err, "视频服务运行配置不可用")
default:
e.Error(http.StatusInternalServerError, err, "视频服务操作失败")
}
}
+91
View File
@@ -0,0 +1,91 @@
package media
import (
"errors"
"fmt"
"io"
"net"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
var ErrUnsafeControlAPI = errors.New("MediaMTX Control API 必须使用本机回环地址")
type RuntimeConfig struct {
Binary string
ConfigPath string
APIBase string
PollInterval time.Duration
StartTimeout time.Duration
}
func ConfigFromEnvironment() (RuntimeConfig, error) {
c := RuntimeConfig{
Binary: strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_BINARY")), ConfigPath: strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_CONFIG")),
APIBase: strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_API")), PollInterval: 5 * time.Second, StartTimeout: 8 * time.Second,
}
if c.APIBase == "" {
c.APIBase = "http://127.0.0.1:9997"
}
if err := validateControlAPI(c.APIBase); err != nil {
return RuntimeConfig{}, err
}
if c.Binary != "" && c.ConfigPath == "" {
return RuntimeConfig{}, errors.New("SENSE_MEDIAMTX_CONFIG is required when SENSE_MEDIAMTX_BINARY is configured")
}
return c, nil
}
func validateControlAPI(value string) error {
u, err := url.Parse(value)
if err != nil || u.Scheme != "http" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" {
return ErrUnsafeControlAPI
}
host := u.Hostname()
ip := net.ParseIP(host)
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
return ErrUnsafeControlAPI
}
return nil
}
func RenderBaseConfig(w io.Writer, apiBase string) error {
if err := validateControlAPI(apiBase); err != nil {
return err
}
u, _ := url.Parse(apiBase)
_, err := fmt.Fprintf(w, "logLevel: info\napi: true\napiAddress: %s\nmetrics: false\npaths: {}\n", u.Host)
return err
}
func EnsureBaseConfig(path, apiBase string) error {
if path == "" {
return errors.New("MediaMTX config path is empty")
}
if _, err := os.Stat(path); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return err
}
temporary := path + ".tmp"
f, err := os.OpenFile(temporary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return err
}
writeErr := RenderBaseConfig(f, apiBase)
closeErr := f.Close()
if writeErr != nil || closeErr != nil {
_ = os.Remove(temporary)
return errors.Join(writeErr, closeErr)
}
if err = os.Rename(temporary, path); err != nil {
_ = os.Remove(temporary)
}
return err
}
+168
View File
@@ -0,0 +1,168 @@
package media
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
var validPath = regexp.MustCompile(`^[A-Za-z0-9_-]{1,96}$`)
type Source struct {
Path, URI, Username, Password string
}
type PathStatus struct {
Exists, Ready bool
Readers int
}
type Controller interface {
Health(context.Context) error
Apply(context.Context, Source) error
Delete(context.Context, string) error
Status(context.Context, string) (PathStatus, error)
}
type HTTPController struct {
base string
client *http.Client
}
func NewHTTPController(base string) (*HTTPController, error) {
if err := validateControlAPI(base); err != nil {
return nil, err
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
return &HTTPController{base: strings.TrimRight(base, "/"), client: &http.Client{
Timeout: 5 * time.Second, Transport: transport,
CheckRedirect: func(*http.Request, []*http.Request) error {
return errors.New("MediaMTX Control API redirect rejected")
},
}}, nil
}
func (c *HTTPController) Health(ctx context.Context) error {
return c.request(ctx, http.MethodGet, "/v3/config/global/get", nil, nil)
}
func (c *HTTPController) Apply(ctx context.Context, source Source) error {
if !validPath.MatchString(source.Path) {
return errors.New("invalid MediaMTX path")
}
u, err := url.Parse(source.URI)
if err != nil || u.User != nil || u.Host == "" || (u.Scheme != "rtsp" && u.Scheme != "rtsps") {
return errors.New("invalid credential-free RTSP source")
}
payload := map[string]any{"source": u.String(), "sourceOnDemand": true, "rtspTransport": "tcp"}
if source.Username != "" {
// MediaMTX v1.19.3 has no sourceUser/sourcePass fields. Credentials
// are assembled only for this loopback request and are never stored,
// logged, or returned by a Sense endpoint.
u.User = url.UserPassword(source.Username, source.Password)
payload["source"] = u.String()
}
data, err := json.Marshal(payload)
if err != nil {
return err
}
configured, err := c.configured(ctx, source.Path)
if err != nil {
return err
}
method, action := http.MethodPost, "add"
if configured {
method, action = http.MethodPatch, "patch"
}
return c.request(ctx, method, "/v3/config/paths/"+action+"/"+url.PathEscape(source.Path), bytes.NewReader(data), nil)
}
func (c *HTTPController) configured(ctx context.Context, path string) (bool, error) {
status := 0
err := c.request(ctx, http.MethodGet, "/v3/config/paths/get/"+url.PathEscape(path), nil, &status)
if status == http.StatusNotFound {
return false, nil
}
return err == nil, err
}
func (c *HTTPController) Delete(ctx context.Context, path string) error {
if !validPath.MatchString(path) {
return errors.New("invalid MediaMTX path")
}
status := 0
err := c.request(ctx, http.MethodDelete, "/v3/config/paths/delete/"+url.PathEscape(path), nil, &status)
if status == http.StatusNotFound {
return nil
}
return err
}
func (c *HTTPController) Status(ctx context.Context, path string) (PathStatus, error) {
statusCode := 0
var raw struct {
Ready bool `json:"ready"`
Readers []any `json:"readers"`
}
err := c.requestJSON(ctx, http.MethodGet, "/v3/paths/get/"+url.PathEscape(path), &statusCode, &raw)
if statusCode == http.StatusNotFound {
return PathStatus{}, nil
}
if err != nil {
return PathStatus{}, err
}
return PathStatus{Exists: true, Ready: raw.Ready, Readers: len(raw.Readers)}, nil
}
func (c *HTTPController) request(ctx context.Context, method, path string, body io.Reader, statusOut *int) error {
return c.requestJSON(ctx, method, path, body, statusOut, nil)
}
func (c *HTTPController) requestJSON(ctx context.Context, method, path string, args ...any) error {
var body io.Reader
var statusOut *int
var target any
for _, arg := range args {
switch value := arg.(type) {
case io.Reader:
body = value
case *int:
statusOut = value
default:
target = value
}
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, body)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("MediaMTX Control API unavailable: %w", err)
}
defer res.Body.Close()
if statusOut != nil {
*statusOut = res.StatusCode
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 1<<20))
return fmt.Errorf("MediaMTX Control API returned %d", res.StatusCode)
}
if target == nil {
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 1<<20))
return nil
}
return json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(target)
}
@@ -0,0 +1,67 @@
package media
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestControllerUsesCredentialsOnlyAtLoopbackBoundary(t *testing.T) {
var payload map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/v3/config/paths/get/sense_test":
http.NotFound(w, r)
case r.URL.Path == "/v3/config/paths/add/sense_test":
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Error(err)
}
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
controller, err := NewHTTPController(server.URL)
if err != nil {
t.Fatal(err)
}
source := Source{Path: "sense_test", URI: "rtsp://192.0.2.1/live", Username: "synthetic-user", Password: "synthetic-pass"}
if err = controller.Apply(context.Background(), source); err != nil {
t.Fatal(err)
}
if payload["source"] != "rtsp://synthetic-user:synthetic-pass@192.0.2.1/live" {
t.Fatalf("unexpected MediaMTX payload: %#v", payload)
}
if source.URI != "rtsp://192.0.2.1/live" || strings.Contains(source.URI, "synthetic") {
t.Fatalf("caller source was mutated: %#v", source)
}
}
func TestControlAPIAndGeneratedConfigAreLoopbackOnly(t *testing.T) {
if _, err := NewHTTPController("http://192.0.2.1:9997"); err != ErrUnsafeControlAPI {
t.Fatalf("error=%v", err)
}
var output strings.Builder
if err := RenderBaseConfig(&output, "http://127.0.0.1:9997"); err != nil {
t.Fatal(err)
}
if strings.Contains(strings.ToLower(output.String()), "password") || !strings.Contains(output.String(), "paths: {}") {
t.Fatalf("unsafe config: %s", output.String())
}
}
func TestExternalProcessUsesOrphanSafetyGate(t *testing.T) {
supervisor := NewSupervisor("", "")
supervisor.MarkExternal()
if err := supervisor.Stop(context.Background()); err != nil {
t.Fatal(err)
}
state := supervisor.State()
if !state.External || state.Owned || state.Phase != "running" {
t.Fatalf("unexpected state: %#v", state)
}
}
+24
View File
@@ -0,0 +1,24 @@
package media
import "time"
type RouteResponse struct {
ID string `json:"id"`
DeviceID string `json:"deviceId"`
ProfileToken string `json:"profileToken"`
Path string `json:"path"`
Desired string `json:"desired"`
Actual string `json:"actual"`
Readers int `json:"readers"`
SourceReady bool `json:"sourceReady"`
FailureCount int `json:"failureCount"`
NextRetryAt *time.Time `json:"nextRetryAt,omitempty"`
LastErrorCode string `json:"lastErrorCode,omitempty"`
Detail string `json:"detail"`
Version int64 `json:"version"`
UpdatedAt time.Time `json:"updatedAt"`
}
func routeResponse(r Route) RouteResponse {
return RouteResponse{ID: r.ID, DeviceID: r.DeviceID, ProfileToken: r.ProfileToken, Path: r.Path, Desired: r.Desired, Actual: r.Actual, Readers: r.Readers, SourceReady: r.SourceReady, FailureCount: r.FailureCount, NextRetryAt: r.NextRetryAt, LastErrorCode: r.LastErrorCode, Detail: r.Detail, Version: r.Version, UpdatedAt: r.UpdatedAt}
}
+37
View File
@@ -0,0 +1,37 @@
package media
import "time"
const (
DesiredRunning = "running"
DesiredStopped = "stopped"
)
type Route struct {
ID string `gorm:"size:96;primaryKey"`
DeviceID string `gorm:"size:36;not null;uniqueIndex:media_device_profile"`
ProfileToken string `gorm:"size:255;not null;uniqueIndex:media_device_profile"`
Path string `gorm:"size:96;not null;uniqueIndex"`
Desired string `gorm:"size:16;not null;index"`
Actual string `gorm:"size:32;not null;index"`
Readers int `gorm:"not null"`
SourceReady bool `gorm:"not null"`
FailureCount int `gorm:"not null"`
NextRetryAt *time.Time `gorm:"index"`
LastErrorCode string `gorm:"size:64;not null"`
Detail string `gorm:"size:512;not null"`
Version int64 `gorm:"not null"`
UpdatedAt time.Time
}
func (Route) TableName() string { return "sense_media_routes" }
// admissionProfile intentionally maps only non-secret fields from #66.
type admissionProfile struct {
DeviceID string `gorm:"column:device_id"`
Token string `gorm:"column:token"`
StreamURI string `gorm:"column:stream_uri"`
VerificationStatus string `gorm:"column:verification_status"`
}
func (admissionProfile) TableName() string { return "sense_admission_profiles" }
+82
View File
@@ -0,0 +1,82 @@
package media
import (
"context"
"errors"
"os"
"strings"
"sync"
"time"
"gorm.io/gorm"
)
var runtimeState struct {
sync.RWMutex
service *Service
cancel context.CancelFunc
}
func StartRuntime(parent context.Context, db *gorm.DB) error {
mode := strings.ToLower(strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_MODE")))
if mode == "disabled" {
return nil
}
if db == nil {
return errors.New("Sense database is unavailable for MediaMTX runtime")
}
config, err := ConfigFromEnvironment()
if err != nil {
return err
}
controller, err := NewHTTPController(config.APIBase)
if err != nil {
return err
}
service := NewService(db, controller, NewSupervisor(config.Binary, config.ConfigPath), config)
ctx, cancel := context.WithCancel(parent)
if mode == "managed" || mode == "external" {
if err = service.ensureControl(ctx); err != nil {
cancel()
return err
}
}
runtimeState.Lock()
if runtimeState.cancel != nil {
runtimeState.cancel()
}
runtimeState.service, runtimeState.cancel = service, cancel
runtimeState.Unlock()
go service.Run(ctx)
return nil
}
func ShutdownRuntime(ctx context.Context) error {
runtimeState.Lock()
service, cancel := runtimeState.service, runtimeState.cancel
runtimeState.service, runtimeState.cancel = nil, nil
runtimeState.Unlock()
if cancel != nil {
cancel()
}
if service != nil && service.process != nil {
return service.process.Stop(ctx)
}
return nil
}
func serviceFor(db *gorm.DB) *Service {
runtimeState.RLock()
service := runtimeState.service
runtimeState.RUnlock()
if service != nil {
return service
}
return NewService(db, nil, nil, RuntimeConfig{PollInterval: 5 * time.Second})
}
// EnsureDeviceRoutes is an internal post-admission port. It persists only
// credential-free route intent and deliberately does not fail device probing.
func EnsureDeviceRoutes(ctx context.Context, db *gorm.DB, deviceID string) error {
return serviceFor(db).EnsureDevice(ctx, deviceID)
}
@@ -0,0 +1,22 @@
package media
import (
"context"
"strings"
"testing"
)
func TestStartRuntimeAllowsExplicitDemoDisable(t *testing.T) {
t.Setenv("SENSE_MEDIAMTX_MODE", "disabled")
if err := StartRuntime(context.Background(), nil); err != nil {
t.Fatalf("disabled demo runtime must not require MediaMTX or a database: %v", err)
}
}
func TestStartRuntimeManagedModeRequiresDatabase(t *testing.T) {
t.Setenv("SENSE_MEDIAMTX_MODE", "managed")
err := StartRuntime(context.Background(), nil)
if err == nil || !strings.Contains(err.Error(), "database") {
t.Fatalf("managed runtime must fail before HTTP startup without a database: %v", err)
}
}
+305
View File
@@ -0,0 +1,305 @@
package media
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"strings"
"sync"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/reconcile"
)
var (
ErrNotFound = errors.New("媒体路由不存在")
ErrRuntimeUnavailable = errors.New("视频服务运行配置不可用")
)
type Service struct {
db *gorm.DB
controller Controller
process *Supervisor
config RuntimeConfig
now func() time.Time
mu sync.Mutex
}
func NewService(db *gorm.DB, controller Controller, process *Supervisor, config RuntimeConfig) *Service {
return &Service{db: db, controller: controller, process: process, config: config, now: time.Now}
}
func (s *Service) EnsureAllVerified(ctx context.Context) error {
var ids []string
if err := s.db.WithContext(ctx).Model(&admissionProfile{}).Where("verification_status = ?", "ready").Distinct().Pluck("device_id", &ids).Error; err != nil {
return err
}
for _, id := range ids {
if err := s.ensureDevice(ctx, id, false); err != nil {
return err
}
}
return nil
}
func (s *Service) EnsureDevice(ctx context.Context, deviceID string) error {
return s.ensureDevice(ctx, deviceID, true)
}
func (s *Service) ensureDevice(ctx context.Context, deviceID string, reactivate bool) error {
if strings.TrimSpace(deviceID) == "" {
return errors.New("device id is required")
}
var profiles []admissionProfile
if err := s.db.WithContext(ctx).Where("device_id = ? AND verification_status = ?", deviceID, "ready").Find(&profiles).Error; err != nil {
return err
}
now := s.now().UTC()
for _, profile := range profiles {
id := profile.DeviceID + ":" + profile.Token
digest := sha256.Sum256([]byte(id))
route := Route{ID: id, DeviceID: profile.DeviceID, ProfileToken: profile.Token, Path: fmt.Sprintf("sense_%x", digest[:12]), Desired: DesiredRunning, Actual: "pending", Detail: "等待视频服务对账", Version: 1, UpdatedAt: now}
if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoNothing: true}).Create(&route).Error; err != nil {
return err
}
var existing Route
if err := s.db.WithContext(ctx).First(&existing, "id = ?", id).Error; err != nil {
return err
}
if reactivate && existing.Desired != DesiredRunning {
if err := s.db.WithContext(ctx).Model(&existing).Updates(map[string]any{"desired": DesiredRunning, "actual": "pending", "detail": "等待视频服务对账", "next_retry_at": nil, "version": existing.Version + 1, "updated_at": now}).Error; err != nil {
return err
}
}
}
return nil
}
func (s *Service) Run(ctx context.Context) {
_ = s.EnsureAllVerified(ctx)
_ = s.ReconcileDue(ctx)
interval := s.config.PollInterval
if interval <= 0 {
interval = 5 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_ = s.ReconcileDue(ctx)
}
}
}
func (s *Service) ReconcileDue(ctx context.Context) error {
now := s.now().UTC()
var routes []Route
if err := s.db.WithContext(ctx).Where("desired = ? AND (next_retry_at IS NULL OR next_retry_at <= ?)", DesiredRunning, now).Order("updated_at ASC").Limit(128).Find(&routes).Error; err != nil {
return err
}
var result error
for _, route := range routes {
var err error
if route.FailureCount == 0 && (route.Actual == "waiting" || route.Actual == "ready") {
_, err = s.Refresh(ctx, route.ID)
} else {
_, err = s.Reconcile(ctx, route.ID)
}
if err != nil {
result = errors.Join(result, err)
}
}
return result
}
func (s *Service) Refresh(ctx context.Context, id string) (RouteResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
var route Route
if err := s.db.WithContext(ctx).First(&route, "id = ?", id).Error; err != nil {
return RouteResponse{}, ErrNotFound
}
if err := s.ensureControl(ctx); err != nil {
return s.saveFailure(ctx, route, "process_unavailable", "MediaMTX 未启动或 Control API 未就绪", err)
}
status, err := s.controller.Status(ctx, route.Path)
if err != nil {
return s.saveFailure(ctx, route, "status_unavailable", "尚未取得媒体路径状态", err)
}
if !status.Exists {
// A cold MediaMTX start begins with paths: {}; re-apply the route
// after releasing the service lock.
s.mu.Unlock()
response, reconcileErr := s.Reconcile(ctx, id)
s.mu.Lock()
return response, reconcileErr
}
if status.Ready {
return s.saveSuccess(ctx, route, "ready", true, status.Readers, "上游拉流正常")
}
return s.saveSuccess(ctx, route, "waiting", false, status.Readers, "等待播放器连接并按需拉流")
}
func (s *Service) Reconcile(ctx context.Context, id string) (RouteResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
var route Route
if err := s.db.WithContext(ctx).First(&route, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return RouteResponse{}, ErrNotFound
}
return RouteResponse{}, err
}
if route.Desired == DesiredStopped {
if s.controller != nil {
_ = s.controller.Delete(ctx, route.Path)
}
return s.saveSuccess(ctx, route, "stopped", false, 0, "媒体路径已停止")
}
if err := s.ensureControl(ctx); err != nil {
return s.saveFailure(ctx, route, "process_unavailable", "MediaMTX 未启动或 Control API 未就绪", err)
}
var profile admissionProfile
if err := s.db.WithContext(ctx).Where("device_id = ? AND token = ? AND verification_status = ?", route.DeviceID, route.ProfileToken, "ready").First(&profile).Error; err != nil {
return s.saveFailure(ctx, route, "profile_unavailable", "已验证 Profile 不可用", err)
}
value, err := credential.Read(s.db.WithContext(ctx), route.DeviceID, credential.PurposeRTSP)
if err != nil {
return s.saveFailure(ctx, route, "credential_unavailable", "RTSP 凭据不可用", err)
}
if err = s.controller.Apply(ctx, Source{Path: route.Path, URI: profile.StreamURI, Username: value.Username, Password: value.Password}); err != nil {
return s.saveFailure(ctx, route, "apply_failed", "媒体路径配置失败", err)
}
status, err := s.controller.Status(ctx, route.Path)
if err != nil {
return s.saveFailure(ctx, route, "status_unavailable", "尚未取得媒体路径状态", err)
}
if !status.Exists {
return s.saveFailure(ctx, route, "path_missing", "媒体路径不存在", errors.New("MediaMTX path missing after apply"))
}
if status.Ready {
return s.saveSuccess(ctx, route, "ready", true, status.Readers, "上游拉流正常")
}
return s.saveSuccess(ctx, route, "waiting", false, status.Readers, "等待播放器连接并按需拉流")
}
func (s *Service) ensureControl(ctx context.Context) error {
if s.controller == nil {
return ErrRuntimeUnavailable
}
if err := s.controller.Health(ctx); err == nil {
if s.process != nil && !s.process.State().Owned {
s.process.MarkExternal()
}
return nil
}
if s.process == nil {
return ErrRuntimeUnavailable
}
if err := EnsureBaseConfig(s.config.ConfigPath, s.config.APIBase); err != nil {
s.process.MarkFailed("MediaMTX 基础配置不可用")
return err
}
if err := s.process.Start(ctx); err != nil {
return err
}
deadline := s.now().Add(s.config.StartTimeout)
for s.now().Before(deadline) {
if err := s.controller.Health(ctx); err == nil {
s.process.MarkReady()
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
s.process.MarkFailed("MediaMTX Control API 就绪超时")
return errors.New("MediaMTX readiness timeout")
}
func (s *Service) saveSuccess(ctx context.Context, route Route, actual string, sourceReady bool, readers int, detail string) (RouteResponse, error) {
if route.Actual == actual && route.SourceReady == sourceReady && route.Readers == readers && route.FailureCount == 0 && route.NextRetryAt == nil && route.LastErrorCode == "" && route.Detail == detail {
return routeResponse(route), nil
}
now := s.now().UTC()
updates := map[string]any{"actual": actual, "source_ready": sourceReady, "readers": readers, "failure_count": 0, "next_retry_at": nil, "last_error_code": "", "detail": detail, "version": route.Version + 1, "updated_at": now}
if err := s.db.WithContext(ctx).Model(&route).Updates(updates).Error; err != nil {
return RouteResponse{}, err
}
for key, value := range updates {
switch key {
case "actual":
route.Actual = value.(string)
case "source_ready":
route.SourceReady = value.(bool)
case "readers":
route.Readers = value.(int)
case "failure_count":
route.FailureCount = value.(int)
case "last_error_code":
route.LastErrorCode = value.(string)
case "detail":
route.Detail = value.(string)
case "version":
route.Version = value.(int64)
case "updated_at":
route.UpdatedAt = value.(time.Time)
}
}
route.NextRetryAt = nil
return routeResponse(route), nil
}
func (s *Service) saveFailure(ctx context.Context, route Route, code, detail string, cause error) (RouteResponse, error) {
now := s.now().UTC()
failures := route.FailureCount + 1
next := now.Add(reconcile.Backoff(failures))
updates := map[string]any{"actual": code, "source_ready": false, "readers": 0, "failure_count": failures, "next_retry_at": &next, "last_error_code": code, "detail": detail, "version": route.Version + 1, "updated_at": now}
if err := s.db.WithContext(ctx).Model(&route).Updates(updates).Error; err != nil {
return RouteResponse{}, errors.Join(cause, err)
}
route.Actual, route.SourceReady, route.Readers, route.FailureCount, route.NextRetryAt, route.LastErrorCode, route.Detail = code, false, 0, failures, &next, code, detail
route.Version, route.UpdatedAt = route.Version+1, now
return routeResponse(route), cause
}
func (s *Service) StopRoute(ctx context.Context, id string) (RouteResponse, error) {
var route Route
if err := s.db.WithContext(ctx).First(&route, "id = ?", id).Error; err != nil {
return RouteResponse{}, ErrNotFound
}
route.Desired = DesiredStopped
if err := s.db.WithContext(ctx).Model(&route).Updates(map[string]any{"desired": DesiredStopped, "next_retry_at": nil, "version": route.Version + 1, "updated_at": s.now().UTC()}).Error; err != nil {
return RouteResponse{}, err
}
return s.Reconcile(ctx, id)
}
func (s *Service) List(ctx context.Context) ([]RouteResponse, error) {
var routes []Route
if err := s.db.WithContext(ctx).Order("updated_at DESC").Find(&routes).Error; err != nil {
return nil, err
}
items := make([]RouteResponse, 0, len(routes))
for _, route := range routes {
items = append(items, routeResponse(route))
}
return items, nil
}
func (s *Service) ProcessState() ProcessState {
if s.process == nil {
return ProcessState{Phase: "configuration_failed", Detail: "视频服务运行配置不可用", LastChanged: s.now().UTC()}
}
return s.process.State()
}
@@ -0,0 +1,128 @@
package media
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"os"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
)
type fakeController struct {
healthErr, applyErr, statusErr error
applies int
status PathStatus
}
func (f *fakeController) Health(context.Context) error { return f.healthErr }
func (f *fakeController) Apply(context.Context, Source) error { f.applies++; return f.applyErr }
func (f *fakeController) Delete(context.Context, string) error { return nil }
func (f *fakeController) Status(context.Context, string) (PathStatus, error) {
return f.status, f.statusErr
}
func mediaTestService(t *testing.T, controller Controller) (*Service, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&Route{}, &admissionProfile{}, &credential.DeviceCredential{}); err != nil {
t.Fatal(err)
}
key := make([]byte, 32)
if _, err = rand.Read(key); err != nil {
t.Fatal(err)
}
t.Setenv(credential.EnvironmentKey, base64.StdEncoding.EncodeToString(key))
vault, _ := credential.NewVault(key)
ciphertext, err := vault.Encrypt("device-1", credential.PurposeRTSP, "synthetic-user", "synthetic-password")
if err != nil {
t.Fatal(err)
}
if err = db.Create(&credential.DeviceCredential{DeviceID: "device-1", Purpose: credential.PurposeRTSP, Ciphertext: ciphertext, KeyVersion: credential.Version()}).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&admissionProfile{DeviceID: "device-1", Token: "main", StreamURI: "rtsp://192.0.2.1/live", VerificationStatus: "ready"}).Error; err != nil {
t.Fatal(err)
}
service := NewService(db, controller, nil, RuntimeConfig{})
service.now = func() time.Time { return time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC) }
return service, db
}
func TestEnsureAndReconcileAreIdempotent(t *testing.T) {
controller := &fakeController{status: PathStatus{Exists: true, Ready: true, Readers: 2}}
service, db := mediaTestService(t, controller)
if err := service.EnsureDevice(context.Background(), "device-1"); err != nil {
t.Fatal(err)
}
if err := service.EnsureDevice(context.Background(), "device-1"); err != nil {
t.Fatal(err)
}
var count int64
if err := db.Model(&Route{}).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("count=%d err=%v", count, err)
}
item, err := service.Reconcile(context.Background(), "device-1:main")
if err != nil {
t.Fatal(err)
}
if item.Actual != "ready" || item.Readers != 2 || controller.applies != 1 {
t.Fatalf("item=%#v applies=%d", item, controller.applies)
}
if err = service.ReconcileDue(context.Background()); err != nil {
t.Fatal(err)
}
items, err := service.List(context.Background())
if err != nil || controller.applies != 1 || items[0].Version != item.Version {
t.Fatalf("steady route was rewritten: items=%#v applies=%d err=%v", items, controller.applies, err)
}
}
func TestColdStartDoesNotReactivateStoppedRoute(t *testing.T) {
controller := &fakeController{status: PathStatus{Exists: true}}
service, _ := mediaTestService(t, controller)
if err := service.EnsureDevice(context.Background(), "device-1"); err != nil {
t.Fatal(err)
}
if _, err := service.StopRoute(context.Background(), "device-1:main"); err != nil {
t.Fatal(err)
}
if err := service.EnsureAllVerified(context.Background()); err != nil {
t.Fatal(err)
}
items, err := service.List(context.Background())
if err != nil || len(items) != 1 || items[0].Desired != DesiredStopped {
t.Fatalf("stopped route was reactivated: %#v err=%v", items, err)
}
}
func TestFailurePersistsBackoffWithoutChangingProfile(t *testing.T) {
controller := &fakeController{applyErr: errors.New("synthetic apply failure")}
service, db := mediaTestService(t, controller)
if err := service.EnsureDevice(context.Background(), "device-1"); err != nil {
t.Fatal(err)
}
item, err := service.Reconcile(context.Background(), "device-1:main")
if err == nil || item.Actual != "apply_failed" || item.FailureCount != 1 || item.NextRetryAt == nil {
t.Fatalf("item=%#v err=%v", item, err)
}
var profile admissionProfile
if err = db.First(&profile, "device_id = ? AND token = ?", "device-1", "main").Error; err != nil {
t.Fatal(err)
}
if profile.VerificationStatus != "ready" {
t.Fatalf("profile changed: %#v", profile)
}
if value := os.Getenv(credential.EnvironmentKey); value == "" {
t.Fatal("test key unexpectedly missing")
}
}
+149
View File
@@ -0,0 +1,149 @@
package media
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
)
type ProcessState struct {
Phase string `json:"phase"`
PID int `json:"pid,omitempty"`
Owned bool `json:"owned"`
External bool `json:"external"`
Restarts int `json:"restarts"`
Detail string `json:"detail"`
LastChanged time.Time `json:"lastChanged"`
}
type Process interface {
Start(context.Context) error
Stop(context.Context) error
State() ProcessState
}
type Supervisor struct {
binary, config string
mu sync.Mutex
command *exec.Cmd
state ProcessState
}
func NewSupervisor(binary, config string) *Supervisor {
phase, detail := "stopped", "MediaMTX 尚未启动"
if binary == "" {
phase, detail = "not_configured", "未配置 MediaMTX 二进制;可连接外部已启动实例"
}
return &Supervisor{binary: binary, config: config, state: ProcessState{Phase: phase, Detail: detail, LastChanged: time.Now().UTC()}}
}
func (s *Supervisor) MarkExternal() {
s.mu.Lock()
defer s.mu.Unlock()
if s.state.Owned {
return
}
s.state.Phase, s.state.External, s.state.Detail, s.state.LastChanged = "running", true, "检测到外部 MediaMTX;孤儿安全闸禁止 Sense 停止该进程", time.Now().UTC()
}
func (s *Supervisor) Start(_ context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.state.Owned && s.command != nil {
return nil
}
if s.binary == "" {
return errors.New("SENSE_MEDIAMTX_BINARY is not configured")
}
args := []string{}
if s.config != "" {
args = append(args, s.config)
}
cmd := exec.Command(s.binary, args...)
if s.config != "" {
cmd.Dir = filepath.Dir(s.config)
}
if err := cmd.Start(); err != nil {
s.state.Phase, s.state.Detail, s.state.LastChanged = "failed", "MediaMTX 进程启动失败", time.Now().UTC()
return err
}
if s.state.Phase == "failed" {
s.state.Restarts++
}
s.command = cmd
s.state.Phase, s.state.PID, s.state.Owned, s.state.External = "starting", cmd.Process.Pid, true, false
s.state.Detail, s.state.LastChanged = "等待 MediaMTX Control API 就绪", time.Now().UTC()
go s.wait(cmd)
return nil
}
func (s *Supervisor) MarkReady() {
s.mu.Lock()
defer s.mu.Unlock()
if s.state.Owned {
s.state.Phase, s.state.Detail, s.state.LastChanged = "running", "MediaMTX Control API 已就绪", time.Now().UTC()
}
}
func (s *Supervisor) MarkFailed(detail string) {
s.mu.Lock()
defer s.mu.Unlock()
s.state.Phase, s.state.Detail, s.state.LastChanged = "failed", detail, time.Now().UTC()
}
func (s *Supervisor) wait(cmd *exec.Cmd) {
err := cmd.Wait()
s.mu.Lock()
defer s.mu.Unlock()
if s.command != cmd {
return
}
s.command = nil
s.state.Phase, s.state.PID, s.state.Owned, s.state.External = "failed", 0, false, false
s.state.Detail = "MediaMTX 进程已退出"
if err == nil {
s.state.Phase, s.state.Detail = "stopped", "MediaMTX 进程已停止"
}
s.state.LastChanged = time.Now().UTC()
}
func (s *Supervisor) Stop(ctx context.Context) error {
s.mu.Lock()
cmd := s.command
owned := s.state.Owned
s.mu.Unlock()
if !owned || cmd == nil {
return nil
}
if err := cmd.Process.Signal(os.Interrupt); err != nil {
if killErr := cmd.Process.Kill(); killErr != nil {
return errors.Join(err, killErr)
}
}
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
_ = cmd.Process.Kill()
return ctx.Err()
case <-ticker.C:
s.mu.Lock()
finished := s.command != cmd
s.mu.Unlock()
if finished {
return nil
}
}
}
}
func (s *Supervisor) State() ProcessState {
s.mu.Lock()
defer s.mu.Unlock()
return s.state
}
+296
View File
@@ -0,0 +1,296 @@
package onvif
import (
"bytes"
"context"
"crypto/md5"
"crypto/rand"
"crypto/sha256"
"encoding/xml"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
var (
ErrAuthentication = errors.New("设备拒绝了当前凭据")
ErrRedirect = errors.New("设备返回了不允许的重定向")
)
type Credential struct{ Username, Password string }
type Profile struct {
Token string `json:"token"`
Name string `json:"name"`
Width int `json:"width"`
Height int `json:"height"`
Encoding string `json:"encoding"`
StreamURI string `json:"streamUri"`
}
type Client interface {
Profiles(context.Context, string, Credential) ([]Profile, error)
}
type HTTPClient struct {
client *http.Client
policy Policy
}
func NewHTTPClient(timeout time.Duration, policy Policy) *HTTPClient {
if timeout <= 0 {
timeout = 8 * time.Second
}
dialer := net.Dialer{Timeout: timeout}
transport := &http.Transport{Proxy: nil, DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, ErrAddressInvalid
}
_, ip, err := policy.ValidateURL(ctx, "http://"+net.JoinHostPort(host, port), "http")
if err != nil {
return nil, err
}
return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
}}
return &HTTPClient{policy: policy, client: &http.Client{Timeout: timeout, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
}
func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Credential) ([]Profile, error) {
device, _, err := c.policy.ValidateURL(ctx, address, "http", "https")
if err != nil {
return nil, err
}
capabilities, err := c.soap(ctx, device.String(), credential, `<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetCapabilities xmlns="http://www.onvif.org/ver10/device/wsdl"><Category>All</Category></GetCapabilities></s:Body></s:Envelope>`)
if err != nil {
return nil, err
}
mediaRaw, err := parseElement(capabilities, "Media", "XAddr")
if err != nil {
return nil, err
}
media, err := c.normalizeService(ctx, device, mediaRaw)
if err != nil {
return nil, err
}
data, err := c.soap(ctx, media.String(), credential, `<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetProfiles xmlns="http://www.onvif.org/ver10/media/wsdl"/></s:Body></s:Envelope>`)
if err != nil {
return nil, err
}
profiles, err := parseProfiles(data)
if err != nil {
return nil, err
}
for index := range profiles {
body := fmt.Sprintf(`<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetStreamUri xmlns="http://www.onvif.org/ver10/media/wsdl"><StreamSetup><Stream xmlns="http://www.onvif.org/ver10/schema">RTP-Unicast</Stream><Transport xmlns="http://www.onvif.org/ver10/schema"><Protocol>RTSP</Protocol></Transport></StreamSetup><ProfileToken>%s</ProfileToken></GetStreamUri></s:Body></s:Envelope>`, xmlEscape(profiles[index].Token))
response, requestErr := c.soap(ctx, media.String(), credential, body)
if requestErr != nil {
return nil, requestErr
}
raw, parseErr := parseElement(response, "", "Uri")
if parseErr != nil {
return nil, parseErr
}
profiles[index].StreamURI, err = c.normalizeStream(ctx, device, raw)
if err != nil {
return nil, err
}
}
return profiles, nil
}
func (c *HTTPClient) normalizeService(ctx context.Context, device *url.URL, raw string) (*url.URL, error) {
advertised, err := url.Parse(strings.TrimSpace(raw))
if err != nil || advertised.Hostname() == "" || advertised.User != nil || (advertised.Scheme != "http" && advertised.Scheme != "https") {
return nil, ErrAddressInvalid
}
if !strings.EqualFold(advertised.Hostname(), device.Hostname()) {
advertised.Scheme = device.Scheme
advertised.Host = device.Host
}
if _, _, err = c.policy.ValidateURL(ctx, advertised.String(), "http", "https"); err != nil {
return nil, err
}
return advertised, nil
}
func (c *HTTPClient) normalizeStream(ctx context.Context, device *url.URL, raw string) (string, error) {
stream, err := url.Parse(strings.TrimSpace(raw))
if err != nil || len(raw) > 2048 || stream.Hostname() == "" || stream.User != nil || stream.Scheme != "rtsp" {
return "", ErrAddressInvalid
}
if !strings.EqualFold(stream.Hostname(), device.Hostname()) {
port := stream.Port()
stream.Host = device.Hostname()
if port != "" {
stream.Host = net.JoinHostPort(device.Hostname(), port)
}
}
if _, _, err = c.policy.ValidateURL(ctx, stream.String(), "rtsp"); err != nil {
return "", err
}
return stream.String(), nil
}
func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Credential, body string) ([]byte, error) {
return c.soapAttempt(ctx, endpoint, credential, body, "")
}
func (c *HTTPClient) soapAttempt(ctx context.Context, endpoint string, credential Credential, body, authorization string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8")
if authorization != "" {
req.Header.Set("Authorization", authorization)
} else if credential.Username != "" {
req.SetBasicAuth(credential.Username, credential.Password)
}
res, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("onvif request: %w", err)
}
defer res.Body.Close()
data, err := io.ReadAll(io.LimitReader(res.Body, 2<<20))
if err != nil {
return nil, err
}
if res.StatusCode >= 300 && res.StatusCode < 400 {
return nil, ErrRedirect
}
if res.StatusCode == http.StatusUnauthorized {
if authorization == "" && credential.Username != "" {
challenge, challengeErr := parseDigestChallenge(res.Header.Values("WWW-Authenticate"))
if challengeErr == nil {
digest, digestErr := digestAuthorization(http.MethodPost, req.URL.RequestURI(), credential, challenge)
if digestErr != nil {
return nil, digestErr
}
return c.soapAttempt(ctx, endpoint, credential, body, digest)
}
}
return nil, ErrAuthentication
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("onvif http status %d", res.StatusCode)
}
return data, nil
}
type digestChallenge struct{ realm, nonce, opaque, algorithm, qop string }
func parseDigestChallenge(values []string) (digestChallenge, error) {
for _, value := range values {
parts := strings.SplitN(strings.TrimSpace(value), " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Digest") {
continue
}
params, err := parseAuthParameters(parts[1])
if err != nil {
return digestChallenge{}, err
}
c := digestChallenge{realm: params["realm"], nonce: params["nonce"], opaque: params["opaque"], algorithm: strings.ToUpper(params["algorithm"])}
if c.realm == "" || c.nonce == "" {
return digestChallenge{}, ErrAuthentication
}
if c.algorithm == "" {
c.algorithm = "MD5"
}
if c.algorithm != "MD5" && c.algorithm != "SHA-256" {
return digestChallenge{}, ErrAuthentication
}
for _, q := range strings.Split(params["qop"], ",") {
if strings.EqualFold(strings.TrimSpace(q), "auth") {
c.qop = "auth"
}
}
if params["qop"] != "" && c.qop == "" {
return digestChallenge{}, ErrAuthentication
}
return c, nil
}
return digestChallenge{}, ErrAuthentication
}
func parseAuthParameters(value string) (map[string]string, error) {
result := map[string]string{}
for position := 0; position < len(value); {
for position < len(value) && (value[position] == ' ' || value[position] == ',') {
position++
}
start := position
for position < len(value) && value[position] != '=' && value[position] != ',' {
position++
}
if position == start || position >= len(value) || value[position] != '=' {
return nil, ErrAuthentication
}
name := strings.ToLower(strings.TrimSpace(value[start:position]))
position++
var parameter string
if position < len(value) && value[position] == '"' {
position++
var builder strings.Builder
closed := false
for position < len(value) {
if value[position] == '"' {
position++
closed = true
break
}
if value[position] == '\\' && position+1 < len(value) {
position++
}
builder.WriteByte(value[position])
position++
}
if !closed {
return nil, ErrAuthentication
}
parameter = builder.String()
} else {
start = position
for position < len(value) && value[position] != ',' {
position++
}
parameter = strings.TrimSpace(value[start:position])
}
result[name] = parameter
}
return result, nil
}
func digestAuthorization(method, uri string, credential Credential, c digestChallenge) (string, error) {
random := make([]byte, 16)
if _, err := rand.Read(random); err != nil {
return "", err
}
cnonce := fmt.Sprintf("%x", random)
hash := func(value string) string {
if c.algorithm == "SHA-256" {
sum := sha256.Sum256([]byte(value))
return fmt.Sprintf("%x", sum)
}
sum := md5.Sum([]byte(value))
return fmt.Sprintf("%x", sum)
}
ha1 := hash(credential.Username + ":" + c.realm + ":" + credential.Password)
ha2 := hash(method + ":" + uri)
nc := "00000001"
response := hash(ha1 + ":" + c.nonce + ":" + ha2)
if c.qop != "" {
response = hash(ha1 + ":" + c.nonce + ":" + nc + ":" + cnonce + ":" + c.qop + ":" + ha2)
}
values := []string{`username=` + strconv.Quote(credential.Username), `realm=` + strconv.Quote(c.realm), `nonce=` + strconv.Quote(c.nonce), `uri=` + strconv.Quote(uri), `response=` + strconv.Quote(response), `algorithm=` + c.algorithm}
if c.opaque != "" {
values = append(values, `opaque=`+strconv.Quote(c.opaque))
}
if c.qop != "" {
values = append(values, `qop=`+c.qop, `nc=`+nc, `cnonce=`+strconv.Quote(cnonce))
}
return "Digest " + strings.Join(values, ", "), nil
}
func xmlEscape(value string) string {
var b strings.Builder
_ = xml.EscapeText(&b, []byte(value))
return b.String()
}
@@ -0,0 +1,81 @@
package onvif
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func loopbackPolicy(t *testing.T) Policy {
t.Helper()
policy, err := ParsePolicy("127.0.0.0/8")
if err != nil {
t.Fatal(err)
}
return policy
}
func TestProfilesSupportsDigestNormalizesAdvertisedHostsAndRejectsCredentials(t *testing.T) {
digestSeen := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := make([]byte, r.ContentLength)
_, _ = r.Body.Read(body)
value := string(body)
switch {
case strings.Contains(value, "GetCapabilities"):
fmt.Fprint(w, `<Envelope><Body><GetCapabilitiesResponse><Capabilities><Media><XAddr>http://unusable.invalid/onvif/media</XAddr></Media></Capabilities></GetCapabilitiesResponse></Body></Envelope>`)
case !strings.HasPrefix(r.Header.Get("Authorization"), "Digest "):
w.Header().Set("WWW-Authenticate", `Digest realm="camera", nonce="n", algorithm=MD5, qop="auth"`)
w.WriteHeader(http.StatusUnauthorized)
case strings.Contains(value, "GetProfiles"):
digestSeen = strings.HasPrefix(r.Header.Get("Authorization"), "Digest ")
fmt.Fprint(w, `<Envelope><Body><GetProfilesResponse><Profiles token="main"><Name>主码流</Name><VideoEncoderConfiguration><Encoding>H264</Encoding><Resolution><Width>1920</Width><Height>1080</Height></Resolution></VideoEncoderConfiguration></Profiles></GetProfilesResponse></Body></Envelope>`)
default:
fmt.Fprint(w, `<Envelope><Body><GetStreamUriResponse><MediaUri><Uri>rtsp://unusable.invalid:8554/live</Uri></MediaUri></GetStreamUriResponse></Body></Envelope>`)
}
}))
defer server.Close()
profiles, err := NewHTTPClient(2*time.Second, loopbackPolicy(t)).Profiles(context.Background(), server.URL+"/onvif/device", Credential{Username: "synthetic", Password: "synthetic"})
if err != nil {
t.Fatal(err)
}
if !digestSeen || len(profiles) != 1 || !strings.Contains(profiles[0].StreamURI, "127.0.0.1:8554") {
t.Fatalf("profiles=%#v digest=%v", profiles, digestSeen)
}
if _, _, err = loopbackPolicy(t).ValidateURL(context.Background(), "http://user:pass@127.0.0.1/onvif", "http"); err == nil {
t.Fatal("credential URL accepted")
}
if _, _, err = loopbackPolicy(t).ValidateURL(context.Background(), "http://127.0.0.1/onvif?access_token=synthetic", "http"); err == nil {
t.Fatal("credential-like query accepted")
}
}
func TestPolicyRequiresExplicitCIDRAndRejectsOutsideTarget(t *testing.T) {
if _, err := ParsePolicy(""); err == nil {
t.Fatal("empty policy accepted")
}
policy := loopbackPolicy(t)
if _, _, err := policy.ValidateURL(context.Background(), "http://192.0.2.1/onvif", "http"); err == nil {
t.Fatal("outside target accepted")
}
}
func TestDiscoveryRequiresApprovedInterface(t *testing.T) {
_, err := Discover(context.Background(), "", time.Millisecond, loopbackPolicy(t))
if err != ErrDiscoveryNotConfigured {
t.Fatalf("error=%v", err)
}
}
func TestClientRejectsRedirect(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Location", "http://127.0.0.1/other")
w.WriteHeader(http.StatusFound)
}))
defer server.Close()
_, err := NewHTTPClient(time.Second, loopbackPolicy(t)).Profiles(context.Background(), server.URL+"/onvif", Credential{})
if err != ErrRedirect {
t.Fatalf("error=%v", err)
}
}
+96
View File
@@ -0,0 +1,96 @@
package onvif
import (
"context"
"encoding/xml"
"errors"
"io"
"net"
"sort"
"strings"
"time"
)
var (
ErrDiscoveryNotConfigured = errors.New("未配置获准的发现网卡")
ErrDiscoveryInterface = errors.New("配置的发现地址不是本机网卡")
)
const discoveryProbe = `<?xml version="1.0"?><e:Envelope xmlns:e="http://www.w3.org/2003/05/soap-envelope" xmlns:w="http://schemas.xmlsoap.org/ws/2004/08/addressing" xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" xmlns:dn="http://www.onvif.org/ver10/network/wsdl"><e:Header><w:MessageID>uuid:sense-controlled-discovery</w:MessageID><w:To e:mustUnderstand="true">urn:schemas-xmlsoap-org:ws:2005:04:discovery</w:To><w:Action e:mustUnderstand="true">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</w:Action></e:Header><e:Body><d:Probe><d:Types>dn:NetworkVideoTransmitter</d:Types></d:Probe></e:Body></e:Envelope>`
func Discover(ctx context.Context, localIP string, timeout time.Duration, policy Policy) ([]string, error) {
ip := net.ParseIP(strings.TrimSpace(localIP))
if ip == nil {
return nil, ErrDiscoveryNotConfigured
}
approved := false
interfaces, _ := net.Interfaces()
for _, iface := range interfaces {
addresses, _ := iface.Addrs()
for _, address := range addresses {
host, _, _ := net.ParseCIDR(address.String())
if host != nil && host.Equal(ip) {
approved = true
}
}
}
if !approved {
return nil, ErrDiscoveryInterface
}
connection, err := net.ListenUDP("udp4", &net.UDPAddr{IP: ip})
if err != nil {
return nil, err
}
defer connection.Close()
if timeout <= 0 {
timeout = 3 * time.Second
}
_ = connection.SetDeadline(time.Now().Add(timeout))
if _, err = connection.WriteToUDP([]byte(discoveryProbe), &net.UDPAddr{IP: net.ParseIP("239.255.255.250"), Port: 3702}); err != nil {
return nil, err
}
found := map[string]bool{}
buffer := make([]byte, 65535)
for {
n, _, readErr := connection.ReadFromUDP(buffer)
if readErr != nil {
if e, ok := readErr.(net.Error); ok && e.Timeout() {
break
}
return nil, readErr
}
for _, candidate := range extractXAddrs(string(buffer[:n])) {
if _, _, validErr := policy.ValidateURL(ctx, candidate, "http", "https"); validErr == nil {
found[candidate] = true
}
}
}
result := make([]string, 0, len(found))
for value := range found {
result = append(result, value)
}
sort.Strings(result)
return result, nil
}
func extractXAddrs(value string) []string {
decoder := xml.NewDecoder(strings.NewReader(value))
for {
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil
}
start, ok := token.(xml.StartElement)
if !ok || start.Name.Local != "XAddrs" {
continue
}
var addresses string
if decoder.DecodeElement(&addresses, &start) == nil {
return strings.Fields(addresses)
}
}
return nil
}
+75
View File
@@ -0,0 +1,75 @@
package onvif
import (
"encoding/xml"
"errors"
"io"
"strings"
)
var ErrInvalidResponse = errors.New("ONVIF 返回内容无法识别")
type profileEnvelope struct {
Profiles []struct {
Token string `xml:"token,attr"`
Name string `xml:"Name"`
Encoder struct {
Encoding string `xml:"Encoding"`
Resolution struct {
Width int `xml:"Width"`
Height int `xml:"Height"`
} `xml:"Resolution"`
} `xml:"VideoEncoderConfiguration"`
} `xml:"Body>GetProfilesResponse>Profiles"`
}
func parseProfiles(data []byte) ([]Profile, error) {
var envelope profileEnvelope
if err := xml.Unmarshal(data, &envelope); err != nil || len(envelope.Profiles) == 0 || len(envelope.Profiles) > 128 {
return nil, ErrInvalidResponse
}
result := make([]Profile, 0, len(envelope.Profiles))
for _, p := range envelope.Profiles {
if strings.TrimSpace(p.Token) == "" || len(p.Token) > 255 || len([]rune(p.Name)) > 255 || p.Encoder.Resolution.Width <= 0 || p.Encoder.Resolution.Width > 32768 || p.Encoder.Resolution.Height <= 0 || p.Encoder.Resolution.Height > 32768 || len(p.Encoder.Encoding) > 32 {
continue
}
result = append(result, Profile{Token: p.Token, Name: p.Name, Width: p.Encoder.Resolution.Width, Height: p.Encoder.Resolution.Height, Encoding: p.Encoder.Encoding})
}
if len(result) == 0 {
return nil, ErrInvalidResponse
}
return result, nil
}
func parseElement(data []byte, parent, name string) (string, error) {
decoder := xml.NewDecoder(strings.NewReader(string(data)))
depth := 0
for {
token, err := decoder.Token()
if err != nil {
if errors.Is(err, io.EOF) {
return "", ErrInvalidResponse
}
return "", ErrInvalidResponse
}
switch value := token.(type) {
case xml.StartElement:
if parent == "" || value.Name.Local == parent {
if value.Name.Local == parent {
depth++
}
}
if (parent == "" || depth > 0) && value.Name.Local == name {
var text string
if err := decoder.DecodeElement(&text, &value); err != nil {
return "", ErrInvalidResponse
}
return strings.TrimSpace(text), nil
}
case xml.EndElement:
if value.Name.Local == parent && depth > 0 {
depth--
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
package onvif
import (
"context"
"errors"
"net"
"net/url"
"strings"
)
var (
ErrTargetNotAllowed = errors.New("目标地址不在获准网段内")
ErrAddressInvalid = errors.New("设备地址格式不正确")
)
type Policy struct{ Networks []*net.IPNet }
func ParsePolicy(value string) (Policy, error) {
var policy Policy
for _, item := range strings.Split(value, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
_, network, err := net.ParseCIDR(item)
if err != nil {
return Policy{}, ErrTargetNotAllowed
}
policy.Networks = append(policy.Networks, network)
}
if len(policy.Networks) == 0 {
return Policy{}, ErrTargetNotAllowed
}
return policy, nil
}
func (p Policy) ValidateURL(ctx context.Context, raw string, schemes ...string) (*url.URL, net.IP, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Hostname() == "" || u.User != nil || u.Fragment != "" {
return nil, nil, ErrAddressInvalid
}
for key := range u.Query() {
lower := strings.ToLower(key)
for _, sensitive := range []string{"user", "password", "passwd", "token", "auth", "credential", "secret", "key"} {
if strings.Contains(lower, sensitive) {
return nil, nil, ErrAddressInvalid
}
}
}
allowedScheme := false
for _, scheme := range schemes {
if strings.EqualFold(u.Scheme, scheme) {
allowedScheme = true
}
}
if !allowedScheme {
return nil, nil, ErrAddressInvalid
}
addresses, err := net.DefaultResolver.LookupIP(ctx, "ip", u.Hostname())
if err != nil || len(addresses) == 0 {
return nil, nil, ErrTargetNotAllowed
}
for _, address := range addresses {
ok := false
for _, network := range p.Networks {
if network.Contains(address) {
ok = true
break
}
}
if !ok {
return nil, nil, ErrTargetNotAllowed
}
}
return u, addresses[0], nil
}
@@ -0,0 +1,17 @@
package reconcile
import "time"
func Backoff(failures int) time.Duration {
if failures <= 1 {
return time.Second
}
d := time.Second
for i := 1; i < failures; i++ {
if d >= 30*time.Second {
return time.Minute
}
d *= 2
}
return d
}
@@ -0,0 +1,12 @@
package reconcile
import (
"testing"
"time"
)
func TestBackoffIsBounded(t *testing.T) {
if Backoff(1) != time.Second || Backoff(4) != 8*time.Second || Backoff(20) != time.Minute {
t.Fatal("unexpected retry backoff")
}
}
+70
View File
@@ -0,0 +1,70 @@
package rtsp
import (
"bufio"
"context"
"encoding/base64"
"fmt"
"net"
"strings"
"time"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
)
type Credential struct{ Username, Password string }
type Result struct {
Status string `json:"status"`
LatencyMS int64 `json:"latencyMs"`
Detail string `json:"detail"`
}
type Verifier interface {
Verify(context.Context, string, Credential) (Result, error)
}
type NetVerifier struct {
Timeout time.Duration
Policy onvif.Policy
}
func (v NetVerifier) Verify(ctx context.Context, raw string, credential Credential) (Result, error) {
parsed, ip, err := v.Policy.ValidateURL(ctx, raw, "rtsp")
if err != nil {
return Result{}, err
}
port := parsed.Port()
if port == "" {
port = "554"
}
timeout := v.Timeout
if timeout <= 0 {
timeout = 5 * time.Second
}
started := time.Now()
connection, err := (&net.Dialer{Timeout: timeout}).DialContext(ctx, "tcp", net.JoinHostPort(ip.String(), port))
if err != nil {
return Result{Status: "unreachable", Detail: "无法连接视频端口"}, nil
}
defer connection.Close()
_ = connection.SetDeadline(time.Now().Add(timeout))
authorization := ""
if credential.Username != "" {
authorization = "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(credential.Username+":"+credential.Password)) + "\r\n"
}
request := fmt.Sprintf("OPTIONS %s RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: YoVision-Sense\r\n%s\r\n", parsed.String(), authorization)
if _, err = connection.Write([]byte(request)); err != nil {
return Result{}, err
}
line, err := bufio.NewReader(connection).ReadString('\n')
if err != nil {
return Result{Status: "timeout", Detail: "等待视频响应超时"}, nil
}
result := Result{Status: "ready", Detail: "码流可访问", LatencyMS: time.Since(started).Milliseconds()}
if strings.Contains(line, " 401 ") {
result.Status = "authentication_failed"
result.Detail = "设备拒绝了当前 RTSP 凭据"
} else if !strings.Contains(line, " 200 ") {
result.Status = "failed"
result.Detail = "设备返回非成功状态"
}
return result, nil
}
@@ -0,0 +1,55 @@
package rtsp
import (
"bufio"
"context"
"net"
"strings"
"testing"
"time"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
)
func TestVerifierUsesCredentialHeaderWithoutPuttingItInURI(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
requestChannel := make(chan string, 1)
go func() {
connection, _ := listener.Accept()
if connection == nil {
return
}
defer connection.Close()
reader := bufio.NewReader(connection)
request, _ := reader.ReadString('\n')
headers := request
for {
line, _ := reader.ReadString('\n')
headers += line
if line == "\r\n" || line == "" {
break
}
}
requestChannel <- headers
_, _ = connection.Write([]byte("RTSP/1.0 200 OK\r\nCSeq: 1\r\n\r\n"))
}()
policy, _ := onvif.ParsePolicy("127.0.0.0/8")
result, err := (NetVerifier{Timeout: time.Second, Policy: policy}).Verify(context.Background(), "rtsp://"+listener.Addr().String()+"/live", Credential{Username: "synthetic", Password: "synthetic"})
if err != nil || result.Status != "ready" {
t.Fatalf("result=%#v err=%v", result, err)
}
request := <-requestChannel
if strings.Contains(strings.Split(request, "\r\n")[0], "synthetic") {
t.Fatal("credentials leaked into request URI")
}
if !strings.Contains(request, "Authorization: Basic ") {
t.Fatal("authorization header missing")
}
if _, err = (NetVerifier{Policy: policy}).Verify(context.Background(), "rtsp://user:pass@"+listener.Addr().String()+"/live", Credential{}); err == nil {
t.Fatal("credential-bearing URI accepted")
}
}
+19
View File
@@ -20,6 +20,7 @@ import (
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/router"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
"git.ilapage.cn/ila/yovision/Sense/server/common/database"
"git.ilapage.cn/ila/yovision/Sense/server/common/global"
common "git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
@@ -85,6 +86,19 @@ func run() error {
for _, f := range AppRouters {
f()
}
runtimeCtx, runtimeCancel := context.WithCancel(context.Background())
defer runtimeCancel()
var runtimeDBFound bool
for _, db := range sdk.Runtime.GetDb() {
runtimeDBFound = true
if err := media.StartRuntime(runtimeCtx, db); err != nil {
return fmt.Errorf("MediaMTX runtime unavailable: %w", err)
}
break
}
if !runtimeDBFound {
log.Error("MediaMTX runtime unavailable: Sense database is not initialized")
}
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
@@ -139,6 +153,10 @@ func run() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
log.Info("Shutdown Server ... ")
runtimeCancel()
if err := media.ShutdownRuntime(ctx); err != nil && !errors.Is(err, context.Canceled) {
log.Errorf("Shutdown MediaMTX runtime: %v", err)
}
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("Server Shutdown:", err)
@@ -178,5 +196,6 @@ func initRouter() {
Use(api.SetRequestLogger)
common.InitMiddleware(r)
configureWebUI(r)
}
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// configureWebUI adds an optional SPA fallback to the existing GoAdmin Gin
// engine. API and framework routes keep their normal handlers; the fallback is
// enabled only for Windows delivery packages that set SENSE_WEB_ROOT.
func configureWebUI(r *gin.Engine) {
root := strings.TrimSpace(os.Getenv("SENSE_WEB_ROOT"))
if root == "" {
return
}
absRoot, err := filepath.Abs(root)
if err != nil {
return
}
index := filepath.Join(absRoot, "index.html")
if info, statErr := os.Stat(index); statErr != nil || info.IsDir() {
return
}
r.NoRoute(func(c *gin.Context) {
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
c.Status(http.StatusNotFound)
return
}
if isBackendPath(c.Request.URL.Path) {
c.Status(http.StatusNotFound)
return
}
requested := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(c.Request.URL.Path, "/")))
if requested == "." {
requested = ""
}
candidate := filepath.Join(absRoot, requested)
if withinRoot(absRoot, candidate) {
if info, statErr := os.Stat(candidate); statErr == nil && !info.IsDir() {
c.File(candidate)
return
}
}
if filepath.Ext(requested) != "" {
c.Status(http.StatusNotFound)
return
}
c.File(index)
})
}
func withinRoot(root, candidate string) bool {
rel, err := filepath.Rel(root, candidate)
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func isBackendPath(path string) bool {
for _, prefix := range []string{"/api/", "/swagger/", "/static/", "/form-generator/"} {
if strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
)
func TestConfigureWebUIServesAssetsAndSPAFallback(t *testing.T) {
gin.SetMode(gin.TestMode)
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("sense-index"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "js"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "js", "app.js"), []byte("sense-app"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("SENSE_WEB_ROOT", root)
r := gin.New()
configureWebUI(r)
for _, tc := range []struct {
path string
code int
body string
}{
{path: "/", code: http.StatusOK, body: "sense-index"},
{path: "/device/list", code: http.StatusOK, body: "sense-index"},
{path: "/js/app.js", code: http.StatusOK, body: "sense-app"},
{path: "/js/missing.js", code: http.StatusNotFound},
{path: "/api/v1/missing", code: http.StatusNotFound},
} {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
res := httptest.NewRecorder()
r.ServeHTTP(res, req)
if res.Code != tc.code || (tc.body != "" && res.Body.String() != tc.body) {
t.Fatalf("%s: got %d %q", tc.path, res.Code, res.Body.String())
}
}
}
func TestWithinRootRejectsTraversal(t *testing.T) {
root := t.TempDir()
if withinRoot(root, filepath.Join(root, "..", "secret.txt")) {
t.Fatal("path traversal must be rejected")
}
}
@@ -1,8 +1,11 @@
package version
import (
"database/sql"
"encoding/json"
"fmt"
"runtime"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -34,6 +37,9 @@ func init() {
func migrateSenseDeviceLedger(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := prepareLegacyDeviceCapabilities(tx); err != nil {
return err
}
if err := tx.AutoMigrate(&deviceModels.Device{}, &credential.DeviceCredential{}, &deviceCasbinRule{}); err != nil {
return err
}
@@ -89,6 +95,104 @@ func migrateSenseDeviceLedger(db *gorm.DB, version string) error {
})
}
var supportedLegacyCapabilities = map[string]struct{}{
"video": {}, "radar": {}, "contact": {}, "button": {}, "wearable": {}, "other": {},
}
type legacyDeviceCapabilitiesRow struct {
ID string
Capabilities sql.NullString
}
// prepareLegacyDeviceCapabilities upgrades the pre-GoAdmin text column before
// GORM sees it. PostgreSQL cannot cast the old empty-string default to jsonb,
// and old rows stored a single capability token rather than a JSON array.
func prepareLegacyDeviceCapabilities(tx *gorm.DB) error {
if tx.Dialector.Name() != "postgres" {
return nil
}
type columnMetadata struct {
DataType string
}
var column columnMetadata
result := tx.Raw(`SELECT data_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'sense_devices'
AND column_name = 'capabilities'`).Scan(&column)
if result.Error != nil {
return fmt.Errorf("inspect sense_devices.capabilities: %w", result.Error)
}
if result.RowsAffected == 0 || column.DataType == "json" || column.DataType == "jsonb" {
return nil
}
if column.DataType != "text" && column.DataType != "character varying" {
return fmt.Errorf("sense_devices.capabilities has unsupported legacy type %q", column.DataType)
}
if err := tx.Exec(`LOCK TABLE "sense_devices" IN ACCESS EXCLUSIVE MODE`).Error; err != nil {
return fmt.Errorf("lock sense_devices for capabilities migration: %w", err)
}
var rows []legacyDeviceCapabilitiesRow
if err := tx.Raw(`SELECT id, capabilities FROM "sense_devices" ORDER BY id`).Scan(&rows).Error; err != nil {
return fmt.Errorf("read legacy device capabilities: %w", err)
}
canonical := make(map[string]string, len(rows))
invalid := 0
for _, row := range rows {
value, err := canonicalLegacyCapabilities(row.Capabilities)
if err != nil {
invalid++
continue
}
canonical[row.ID] = value
}
if invalid > 0 {
return fmt.Errorf("sense_devices.capabilities contains unsupported legacy data in %d row(s); migration rolled back", invalid)
}
if err := tx.Exec(`ALTER TABLE "sense_devices" ALTER COLUMN "capabilities" DROP DEFAULT`).Error; err != nil {
return fmt.Errorf("drop legacy capabilities default: %w", err)
}
for _, row := range rows {
if err := tx.Exec(`UPDATE "sense_devices" SET "capabilities" = ? WHERE "id" = ?`, canonical[row.ID], row.ID).Error; err != nil {
return fmt.Errorf("normalize legacy device capabilities: %w", err)
}
}
if err := tx.Exec(`ALTER TABLE "sense_devices" ALTER COLUMN "capabilities" TYPE jsonb USING "capabilities"::jsonb`).Error; err != nil {
return fmt.Errorf("convert capabilities to jsonb: %w", err)
}
if err := tx.Exec(`ALTER TABLE "sense_devices" ALTER COLUMN "capabilities" SET DEFAULT '[]'::jsonb`).Error; err != nil {
return fmt.Errorf("set jsonb capabilities default: %w", err)
}
return nil
}
func canonicalLegacyCapabilities(value sql.NullString) (string, error) {
if !value.Valid || strings.TrimSpace(value.String) == "" {
return "[]", nil
}
trimmed := strings.TrimSpace(value.String)
if _, ok := supportedLegacyCapabilities[trimmed]; ok {
encoded, _ := json.Marshal([]string{trimmed})
return string(encoded), nil
}
if !strings.HasPrefix(trimmed, "[") {
return "", fmt.Errorf("legacy capability value is not an array")
}
var values []string
if err := json.Unmarshal([]byte(trimmed), &values); err != nil || values == nil || len(values) > 16 {
return "", fmt.Errorf("legacy capability array is invalid")
}
for index, item := range values {
item = strings.TrimSpace(item)
if _, ok := supportedLegacyCapabilities[item]; !ok {
return "", fmt.Errorf("legacy capability array contains an unsupported value")
}
values[index] = item
}
encoded, _ := json.Marshal(values)
return string(encoded), nil
}
func ensureDeviceMenu(tx *gorm.DB, desired migrationModels.SysMenu) (migrationModels.SysMenu, error) {
var menu migrationModels.SysMenu
err := tx.Where("menu_name = ?", desired.MenuName).First(&menu).Error
@@ -0,0 +1,207 @@
package version
import (
"database/sql"
"os"
"strings"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func TestCanonicalLegacyCapabilities(t *testing.T) {
tests := []struct {
name string
input sql.NullString
want string
wantErr bool
}{
{name: "null", input: sql.NullString{}, want: "[]"},
{name: "blank", input: sql.NullString{String: " ", Valid: true}, want: "[]"},
{name: "single", input: sql.NullString{String: " video ", Valid: true}, want: `["video"]`},
{name: "array", input: sql.NullString{String: `["radar", "contact"]`, Valid: true}, want: `["radar","contact"]`},
{name: "empty array", input: sql.NullString{String: `[]`, Valid: true}, want: `[]`},
{name: "unknown", input: sql.NullString{String: "unknown", Valid: true}, wantErr: true},
{name: "object", input: sql.NullString{String: `{"video":true}`, Valid: true}, wantErr: true},
{name: "unknown array item", input: sql.NullString{String: `["video","unknown"]`, Valid: true}, wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := canonicalLegacyCapabilities(test.input)
if test.wantErr {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil || got != test.want {
t.Fatalf("got %q, %v; want %q", got, err, test.want)
}
})
}
}
func TestDeviceCapabilitiesMigrationOnPostgres(t *testing.T) {
dsn := os.Getenv("SENSE_DEVICE_MIGRATION_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SENSE_DEVICE_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_device_92_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") })
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)
}
t.Run("converts legacy values and remains idempotent", func(t *testing.T) {
resetLegacyDeviceTable(t, db)
for _, row := range [][2]string{{"single", "video"}, {"blank", ""}, {"array", `["radar","contact"]`}} {
if err = db.Exec(`INSERT INTO sense_devices (id, capabilities) VALUES (?, ?)`, row[0], row[1]).Error; err != nil {
t.Fatal(err)
}
}
if err = db.Transaction(prepareLegacyDeviceCapabilities); err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&deviceModels.Device{}); err != nil {
t.Fatalf("AutoMigrate after compatibility conversion: %v", err)
}
assertCapabilitiesColumn(t, db, "jsonb", "'[]'::jsonb")
var values []struct{ ID, Capabilities string }
if err = db.Raw(`SELECT id, capabilities::text AS capabilities FROM sense_devices ORDER BY id`).Scan(&values).Error; err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, value := range values {
got[value.ID] = strings.ReplaceAll(value.Capabilities, " ", "")
}
if got["single"] != `["video"]` || got["blank"] != `[]` || got["array"] != `["radar","contact"]` {
t.Fatalf("unexpected converted values: %#v", got)
}
if err = db.Transaction(prepareLegacyDeviceCapabilities); err != nil {
t.Fatalf("repeat migration: %v", err)
}
})
t.Run("rejects unknown values without partial conversion", func(t *testing.T) {
resetLegacyDeviceTable(t, db)
if err = db.Exec(`INSERT INTO sense_devices (id, capabilities) VALUES ('valid', 'video'), ('invalid', 'unknown')`).Error; err != nil {
t.Fatal(err)
}
if err = db.Transaction(prepareLegacyDeviceCapabilities); err == nil {
t.Fatal("expected unsupported legacy data error")
}
assertCapabilitiesColumn(t, db, "text", "''::text")
var value string
if err = db.Raw(`SELECT capabilities FROM sense_devices WHERE id = 'valid'`).Scan(&value).Error; err != nil {
t.Fatal(err)
}
if value != "video" {
t.Fatalf("transaction left partial data: %q", value)
}
})
t.Run("does nothing when the table is absent", func(t *testing.T) {
if err = db.Exec(`DROP TABLE sense_devices`).Error; err != nil {
t.Fatal(err)
}
if err = db.Transaction(prepareLegacyDeviceCapabilities); err != nil {
t.Fatal(err)
}
})
t.Run("completes the device migration and records its version", func(t *testing.T) {
resetLegacyDeviceTable(t, db)
if err = db.Exec(`INSERT INTO sense_devices (id, capabilities) VALUES ('legacy', 'video')`).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 migrationVersion = "2026081414000_device.go"
if err = migrateSenseDeviceLedger(db, migrationVersion); err != nil {
t.Fatal(err)
}
assertCapabilitiesColumn(t, db, "jsonb", "'[]'::jsonb")
var migratedValue string
if err = db.Raw(`SELECT capabilities::text FROM sense_devices WHERE id = 'legacy'`).Scan(&migratedValue).Error; err != nil {
t.Fatal(err)
}
if strings.ReplaceAll(migratedValue, " ", "") != `["video"]` {
t.Fatalf("unexpected full-migration value: %q", migratedValue)
}
var applied int64
if err = db.Model(&common.Migration{}).Where("version = ?", migrationVersion).Count(&applied).Error; err != nil {
t.Fatal(err)
}
if applied != 1 {
t.Fatalf("migration record count=%d", applied)
}
})
}
func resetLegacyDeviceTable(t *testing.T, db *gorm.DB) {
t.Helper()
if err := db.Exec(`DROP TABLE IF EXISTS sense_devices`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`CREATE TABLE sense_devices (
id text PRIMARY KEY,
name varchar(128) NOT NULL DEFAULT '',
location varchar(255) NOT NULL DEFAULT '',
modality varchar(32) NOT NULL DEFAULT 'video',
capabilities text NOT NULL DEFAULT '',
status varchar(32) NOT NULL DEFAULT 'active',
adapter_status varchar(32) NOT NULL DEFAULT 'ready',
rtsp_credential_same_as_onvif boolean NOT NULL DEFAULT true,
credential_updated_at timestamptz,
retry_requested_at timestamptz,
version bigint NOT NULL DEFAULT 1,
create_by bigint NOT NULL DEFAULT 0,
update_by bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT current_timestamp,
updated_at timestamptz NOT NULL DEFAULT current_timestamp,
deleted_at timestamptz
)`).Error; err != nil {
t.Fatal(err)
}
}
func assertCapabilitiesColumn(t *testing.T, db *gorm.DB, wantType, wantDefault string) {
t.Helper()
var column struct{ DataType, ColumnDefault string }
if err := db.Raw(`SELECT data_type, column_default
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'sense_devices'
AND column_name = 'capabilities'`).Scan(&column).Error; err != nil {
t.Fatal(err)
}
if column.DataType != wantType || column.ColumnDefault != wantDefault {
t.Fatalf("type/default=%q/%q; want %q/%q", column.DataType, column.ColumnDefault, wantType, wantDefault)
}
}
@@ -0,0 +1,60 @@
package version
import (
"runtime"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
"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), migrateSenseAdmission)
}
func migrateSenseAdmission(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&admission.Result{}, &admission.Profile{}); err != nil {
return err
}
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAdmission", Title: "视频接入", Icon: "video-camera", Path: "/sense/admission", MenuType: "C", Permission: "sense:admission:list", Component: "/sense/admission/index", Sort: 6, Visible: "0", IsFrame: "1"})
if err != nil {
return err
}
discover, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAdmissionDiscover", Title: "发现设备", MenuType: "F", Action: "GET", Permission: "sense:admission:discover", ParentId: page.MenuId, Sort: 1, Visible: "1", IsFrame: "1"})
if err != nil {
return err
}
probe, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAdmissionProbe", Title: "验证接入", MenuType: "F", Action: "POST", Permission: "sense:admission:probe", ParentId: page.MenuId, Sort: 2, Visible: "1", IsFrame: "1"})
if err != nil {
return err
}
for _, role := range []string{"implementation_operator", "site_admin"} {
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page, discover, probe}); err != nil {
return err
}
}
if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page}); err != nil {
return err
}
read := [][2]string{{"/api/v1/admission/devices/:id", "GET"}}
write := [][2]string{{"/api/v1/admission/discover", "GET"}, {"/api/v1/admission/devices/:id/probe", "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 {
rule := deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rule).Error; err != nil {
return err
}
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,184 @@
package version
import (
"fmt"
"runtime"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
"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), migrateSenseMedia)
}
func migrateSenseMedia(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := prepareLegacyMediaRouteSchema(tx); err != nil {
return err
}
if err := tx.AutoMigrate(&media.Route{}); err != nil {
return err
}
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseMedia", Title: "视频服务", Icon: "video-play", Path: "/sense/media", MenuType: "C", Permission: "sense:media:list", Component: "/sense/media/index", Sort: 7, Visible: "0", IsFrame: "1"})
if err != nil {
return err
}
reconcile, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseMediaReconcile", Title: "立即对账", MenuType: "F", Action: "POST", Permission: "sense:media:reconcile", ParentId: page.MenuId, Sort: 1, Visible: "1", IsFrame: "1"})
if err != nil {
return err
}
stop, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseMediaStop", Title: "停止路径", MenuType: "F", Action: "POST", Permission: "sense:media:stop", ParentId: page.MenuId, Sort: 2, Visible: "1", IsFrame: "1"})
if err != nil {
return err
}
for _, role := range []string{"implementation_operator", "site_admin"} {
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page, reconcile, stop}); err != nil {
return err
}
}
if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page}); err != nil {
return err
}
read := [][2]string{{"/api/v1/media/routes", "GET"}, {"/api/v1/media/process", "GET"}}
write := [][2]string{{"/api/v1/media/reconcile", "POST"}, {"/api/v1/media/routes/:id/reconcile", "POST"}, {"/api/v1/media/routes/:id/stop", "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
}
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
type mediaPathConstraint struct {
Name string
Columns string
}
// prepareLegacyMediaRouteSchema makes the old PostgreSQL table safe for GORM.
// Older Sense builds used a database-named UNIQUE(path) constraint and lacked
// the runtime-state columns now required by the Route model.
func prepareLegacyMediaRouteSchema(tx *gorm.DB) error {
if tx.Dialector.Name() != "postgres" {
return nil
}
var tableCount int64
if err := tx.Raw(`SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = 'sense_media_routes'`).Scan(&tableCount).Error; err != nil {
return fmt.Errorf("inspect legacy media route table: %w", err)
}
if tableCount == 0 {
return nil
}
if err := tx.Exec(`LOCK TABLE "sense_media_routes" IN ACCESS EXCLUSIVE MODE`).Error; err != nil {
return fmt.Errorf("lock sense_media_routes for legacy migration: %w", err)
}
if err := normalizeLegacyMediaPathConstraint(tx); err != nil {
return err
}
if err := initializeLegacyMediaRuntimeColumns(tx); err != nil {
return err
}
return nil
}
func normalizeLegacyMediaPathConstraint(tx *gorm.DB) error {
var constraints []mediaPathConstraint
if err := tx.Raw(`SELECT c.conname AS name,
(SELECT string_agg(a.attname, ',' ORDER BY key.ordinality)
FROM unnest(c.conkey) WITH ORDINALITY AS key(attnum, ordinality)
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key.attnum) AS columns
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = current_schema()
AND t.relname = 'sense_media_routes'
AND c.contype = 'u'
AND EXISTS (
SELECT 1
FROM unnest(c.conkey) AS key(attnum)
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key.attnum
WHERE a.attname = 'path'
)
ORDER BY c.conname`).Scan(&constraints).Error; err != nil {
return fmt.Errorf("inspect legacy media path constraints: %w", err)
}
if len(constraints) == 0 {
return nil
}
if len(constraints) != 1 || constraints[0].Columns != "path" {
return fmt.Errorf("sense_media_routes.path has unsupported legacy uniqueness structure; migration rolled back")
}
const expectedName = "uni_sense_media_routes_path"
if constraints[0].Name == expectedName {
return nil
}
var conflictingNameCount int64
if err := tx.Raw(`SELECT COUNT(*)
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = current_schema()
AND t.relname = 'sense_media_routes'
AND c.conname = ?`, expectedName).Scan(&conflictingNameCount).Error; err != nil {
return fmt.Errorf("inspect target media path constraint name: %w", err)
}
if conflictingNameCount != 0 {
return fmt.Errorf("sense_media_routes has a conflicting target constraint name; migration rolled back")
}
rename := fmt.Sprintf(
`ALTER TABLE "sense_media_routes" RENAME CONSTRAINT %s TO %s`,
quotePostgresIdentifier(constraints[0].Name),
quotePostgresIdentifier(expectedName),
)
if err := tx.Exec(rename).Error; err != nil {
return fmt.Errorf("normalize legacy media path constraint name: %w", err)
}
return nil
}
func initializeLegacyMediaRuntimeColumns(tx *gorm.DB) error {
for _, statement := range []struct {
name string
sql string
}{
{name: "add source_ready", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "source_ready" boolean`},
{name: "add failure_count", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "failure_count" bigint`},
{name: "add last_error_code", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "last_error_code" varchar(64)`},
{name: "initialize runtime state", sql: `UPDATE "sense_media_routes"
SET "source_ready" = COALESCE("source_ready", false),
"failure_count" = COALESCE("failure_count", 0),
"last_error_code" = COALESCE("last_error_code", '')
WHERE "source_ready" IS NULL
OR "failure_count" IS NULL
OR "last_error_code" IS NULL`},
{name: "require source_ready", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "source_ready" SET NOT NULL`},
{name: "require failure_count", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "failure_count" SET NOT NULL`},
{name: "require last_error_code", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "last_error_code" SET NOT NULL`},
} {
if err := tx.Exec(statement.sql).Error; err != nil {
return fmt.Errorf("%s for legacy media routes: %w", statement.name, err)
}
}
return nil
}
func quotePostgresIdentifier(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
}
@@ -0,0 +1,198 @@
package version
import (
"os"
"strings"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func TestMediaMigrationOnPostgres(t *testing.T) {
dsn := os.Getenv("SENSE_MEDIA_MIGRATION_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SENSE_MEDIA_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_media_95_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") })
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)
}
t.Run("migrates the legacy path constraint and preserves routes", func(t *testing.T) {
resetLegacyMediaMigration(t, db, `UNIQUE (path)`)
if err = db.Exec(`INSERT INTO sense_media_routes
(id, device_id, profile_token, path, desired, actual, readers, detail, version, updated_at)
VALUES
('route-1', 'device-1', 'profile-1', 'camera-1', 'running', 'stopped', 0, '', 1, now()),
('route-2', 'device-2', 'profile-2', 'camera-2', 'stopped', 'stopped', 0, '', 1, now())`).Error; err != nil {
t.Fatal(err)
}
const migrationVersion = "2026081419000_media.go"
if err = migrateSenseMedia(db, migrationVersion); err != nil {
t.Fatal(err)
}
var routes, menus, policies, applied int64
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseMedia%").Count(&menus).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/media%").Count(&policies).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&common.Migration{}).Where("version = ?", migrationVersion).Count(&applied).Error; err != nil {
t.Fatal(err)
}
if routes != 2 || menus != 3 || policies != 12 || applied != 1 {
t.Fatalf("routes=%d menus=%d policies=%d applied=%d", routes, menus, policies, applied)
}
assertMediaPathUniqueIndex(t, db)
if err = db.Exec(`UPDATE sense_media_routes SET path = 'camera-1' WHERE id = 'route-2'`).Error; err == nil {
t.Fatal("expected path uniqueness violation")
}
var runtimeState struct {
SourceReady bool
FailureCount int64
LastError string
}
if err = db.Raw(`SELECT source_ready, failure_count, last_error_code AS last_error
FROM sense_media_routes WHERE id = 'route-1'`).Scan(&runtimeState).Error; err != nil {
t.Fatal(err)
}
if runtimeState.SourceReady || runtimeState.FailureCount != 0 || runtimeState.LastError != "" {
t.Fatalf("unexpected migrated runtime state: %#v", runtimeState)
}
if err = db.Transaction(prepareLegacyMediaRouteSchema); err != nil {
t.Fatalf("repeat compatibility migration: %v", err)
}
})
t.Run("does nothing when the table is absent", func(t *testing.T) {
if err = db.Exec(`DROP TABLE IF EXISTS sense_media_routes`).Error; err != nil {
t.Fatal(err)
}
if err = db.Transaction(prepareLegacyMediaRouteSchema); err != nil {
t.Fatal(err)
}
})
t.Run("creates a fresh media table", func(t *testing.T) {
resetEmptyMediaMigration(t, db)
if err = migrateSenseMedia(db, "2026081419000_media_fresh.go"); err != nil {
t.Fatal(err)
}
var routes int64
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
t.Fatal(err)
}
if routes != 0 {
t.Fatalf("fresh route count=%d", routes)
}
assertMediaPathUniqueIndex(t, db)
})
t.Run("rejects an unsafe composite path constraint", func(t *testing.T) {
resetLegacyMediaMigration(t, db, `UNIQUE (path, device_id)`)
if err = db.Transaction(prepareLegacyMediaRouteSchema); err == nil || !strings.Contains(err.Error(), "unsupported legacy uniqueness") {
t.Fatalf("expected unsupported uniqueness error, got %v", err)
}
var constraints int64
if err = db.Raw(`SELECT COUNT(*) FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = 'sense_media_routes' AND c.contype = 'u'`).Scan(&constraints).Error; err != nil {
t.Fatal(err)
}
if constraints != 1 {
t.Fatalf("constraint rollback count=%d", constraints)
}
})
}
func resetEmptyMediaMigration(t *testing.T, db *gorm.DB) {
t.Helper()
if err := db.Exec(`DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE`).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)
}
}
}
func resetLegacyMediaMigration(t *testing.T, db *gorm.DB, pathConstraint string) {
t.Helper()
if err := db.Exec(`DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE`).Error; err != nil {
t.Fatal(err)
}
createRouteTable := `CREATE TABLE sense_media_routes (
id text PRIMARY KEY,
device_id text NOT NULL,
profile_token text NOT NULL,
path text NOT NULL,
desired text NOT NULL,
actual text NOT NULL,
readers integer NOT NULL DEFAULT 0,
detail text NOT NULL DEFAULT '',
version bigint NOT NULL,
updated_at timestamptz NOT NULL,
` + pathConstraint + `
)`
if err := db.Exec(createRouteTable).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)
}
}
}
func assertMediaPathUniqueIndex(t *testing.T, db *gorm.DB) {
t.Helper()
var indexes []struct {
IndexName string
IndexDef string
}
if err := db.Raw(`SELECT indexname AS index_name, indexdef AS index_def
FROM pg_indexes
WHERE schemaname = current_schema()
AND tablename = 'sense_media_routes'
ORDER BY indexname`).Scan(&indexes).Error; err != nil {
t.Fatal(err)
}
for _, index := range indexes {
if strings.Contains(index.IndexDef, "UNIQUE INDEX") && strings.HasSuffix(index.IndexDef, " (path)") {
return
}
}
t.Fatalf("missing unique path index: %#v", indexes)
}
@@ -0,0 +1,37 @@
package version
import (
"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), migrateSenseLiveview)
}
func migrateSenseLiveview(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseLiveview", Title: "实时监看", Icon: "eye-open", Path: "/sense/liveview", MenuType: "C", Permission: "sense:liveview:view", Component: "/sense/liveview/index", Sort: 8, Visible: "0", IsFrame: "1"})
if err != nil {
return err
}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page}); err != nil {
return err
}
for _, policy := range [][2]string{{"/api/v1/liveview/routes", "GET"}, {"/api/v1/liveview/sessions", "POST"}, {"/api/v1/liveview/sessions/: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
}
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,57 @@
package version
import (
"os"
"testing"
"gorm.io/driver/postgres"
"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 TestLiveviewMigrationOnPostgres(t *testing.T) {
dsn := os.Getenv("SENSE_LIVEVIEW_MIGRATION_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SENSE_LIVEVIEW_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_liveview_68_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") })
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 = migrateSenseLiveview(db, "2026081420000_liveview.go"); err != nil {
t.Fatal(err)
}
var menus, policies, applied int64
db.Model(&migrationModels.SysMenu{}).Where("menu_name = ?", "SenseLiveview").Count(&menus)
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/liveview%").Count(&policies)
db.Model(&common.Migration{}).Where("version = ?", "2026081420000_liveview.go").Count(&applied)
if menus != 1 || policies != 9 || applied != 1 {
t.Fatalf("menus=%d policies=%d applied=%d", menus, policies, applied)
}
}
@@ -0,0 +1,60 @@
package version
import (
"runtime"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
"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), migrateSenseArea)
}
func migrateSenseArea(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&area.Definition{}, &area.Version{}); err != nil {
return err
}
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseArea", Title: "区域与警戒线", Icon: "guide", Path: "/sense/area", MenuType: "C", Permission: "sense:area:list", Component: "/sense/area/index", Sort: 9, Visible: "0", IsFrame: "1"})
if err != nil {
return err
}
create, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAreaCreate", Title: "新增配置", MenuType: "F", Action: "POST", Permission: "sense:area:create", ParentId: page.MenuId, Sort: 1, Visible: "1", IsFrame: "1"})
if err != nil {
return err
}
update, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAreaUpdate", Title: "编辑配置", MenuType: "F", Action: "PUT", Permission: "sense:area:update", ParentId: page.MenuId, Sort: 2, Visible: "1", IsFrame: "1"})
if err != nil {
return err
}
for _, role := range []string{"implementation_operator", "site_admin"} {
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page, create, update}); err != nil {
return err
}
}
if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page}); err != nil {
return err
}
read := [][2]string{{"/api/v1/area/configurations", "GET"}, {"/api/v1/area/configurations/:id/versions", "GET"}}
write := [][2]string{{"/api/v1/area/configurations", "POST"}, {"/api/v1/area/configurations/:id", "PUT"}}
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
}
}
}
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/area"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func TestAreaMigrationOnPostgres(t *testing.T) {
dsn := os.Getenv("SENSE_AREA_MIGRATION_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SENSE_AREA_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_area_69_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") })
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 = migrateSenseArea(db, "2026081509000_area.go"); err != nil {
t.Fatal(err)
}
var menus, policies, definitions, versions, applied int64
db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseArea%").Count(&menus)
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/area%").Count(&policies)
db.Model(&area.Definition{}).Count(&definitions)
db.Model(&area.Version{}).Count(&versions)
db.Model(&common.Migration{}).Where("version = ?", "2026081509000_area.go").Count(&applied)
if menus != 3 || policies != 10 || definitions != 0 || versions != 0 || applied != 1 {
t.Fatalf("menus=%d policies=%d definitions=%d versions=%d applied=%d", menus, policies, definitions, versions, applied)
}
}
@@ -0,0 +1,85 @@
package version
import (
"fmt"
"runtime"
"gorm.io/gorm"
"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"
)
const senseLayoutMenuName = "SenseManage"
var sensePageMenus = []migrationModels.SysMenu{
{MenuName: "SenseDeviceManage", Title: "设备管理", Icon: "monitor", Path: "devices", MenuType: "C", Permission: "sense:device:list", Component: "/sense/device/index", Sort: 1, Visible: "0", IsFrame: "1"},
{MenuName: "SenseAdmission", Title: "视频接入", Icon: "video-camera", Path: "admission", MenuType: "C", Permission: "sense:admission:list", Component: "/sense/admission/index", Sort: 2, Visible: "0", IsFrame: "1"},
{MenuName: "SenseMedia", Title: "视频服务", Icon: "video-play", Path: "media", MenuType: "C", Permission: "sense:media:list", Component: "/sense/media/index", Sort: 3, Visible: "0", IsFrame: "1"},
{MenuName: "SenseLiveview", Title: "实时监看", Icon: "eye-open", Path: "liveview", MenuType: "C", Permission: "sense:liveview:view", Component: "/sense/liveview/index", Sort: 4, Visible: "0", IsFrame: "1"},
{MenuName: "SenseArea", Title: "区域与警戒线", Icon: "guide", Path: "area", MenuType: "C", Permission: "sense:area:list", Component: "/sense/area/index", Sort: 5, Visible: "0", IsFrame: "1"},
}
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseLayout)
}
func migrateSenseLayout(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := alignSenseLayout(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func alignSenseLayout(tx *gorm.DB) error {
root, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
MenuName: senseLayoutMenuName,
Title: "视频感知",
Icon: "video-camera",
Path: "/sense",
MenuType: "M",
ParentId: 0,
Component: "Layout",
Sort: 5,
Visible: "0",
IsFrame: "1",
})
if err != nil {
return fmt.Errorf("ensure Sense layout menu: %w", err)
}
for _, desired := range sensePageMenus {
desired.ParentId = root.MenuId
desired.Paths = fmt.Sprintf("/0/%d", root.MenuId)
if _, err = ensureDeviceMenu(tx, desired); err != nil {
return fmt.Errorf("align Sense page menu %s: %w", desired.MenuName, err)
}
}
if err = rebuildSenseMenuPaths(tx, root.MenuId, "/0"); err != nil {
return err
}
return nil
}
func rebuildSenseMenuPaths(tx *gorm.DB, menuID int, parentPath string) error {
path := fmt.Sprintf("%s/%d", parentPath, menuID)
if err := tx.Model(&migrationModels.SysMenu{}).Where("menu_id = ?", menuID).Update("paths", path).Error; err != nil {
return fmt.Errorf("update menu %d paths: %w", menuID, err)
}
var children []migrationModels.SysMenu
if err := tx.Where("parent_id = ?", menuID).Order("sort, menu_id").Find(&children).Error; err != nil {
return fmt.Errorf("list children of menu %d: %w", menuID, err)
}
for _, child := range children {
if err := rebuildSenseMenuPaths(tx, child.MenuId, path); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,208 @@
package version
import (
"fmt"
"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 TestAlignSenseLayoutPreservesShellAndExistingPages(t *testing.T) {
db := openSenseLayoutTestDB(t)
seedLegacySenseMenus(t, db)
associationsBefore := countRoleMenuAssociations(t, db)
if err := alignSenseLayout(db); err != nil {
t.Fatal(err)
}
// The alignment helper is intentionally idempotent because partially upgraded
// deployments may rerun the repair before the migration version is recorded.
if err := alignSenseLayout(db); err != nil {
t.Fatalf("repeat alignment: %v", err)
}
root := requireMenu(t, db, senseLayoutMenuName)
if root.ParentId != 0 || root.Component != "Layout" || root.Path != "/sense" || root.MenuType != "M" {
t.Fatalf("unexpected root menu: %#v", root)
}
if root.Paths != fmt.Sprintf("/0/%d", root.MenuId) {
t.Fatalf("root paths=%q", root.Paths)
}
for _, desired := range sensePageMenus {
page := requireMenu(t, db, desired.MenuName)
if page.ParentId != root.MenuId || page.Path != desired.Path || page.Component != desired.Component || page.Permission != desired.Permission {
t.Errorf("unexpected page %s: %#v", desired.MenuName, page)
}
wantPagePaths := fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId)
if page.Paths != wantPagePaths {
t.Errorf("page %s paths=%q want %q", desired.MenuName, page.Paths, wantPagePaths)
}
var buttons []migrationModels.SysMenu
if err := db.Where("parent_id = ? AND menu_type = ?", page.MenuId, "F").Find(&buttons).Error; err != nil {
t.Fatal(err)
}
if len(buttons) != 1 {
t.Fatalf("page %s button count=%d", desired.MenuName, len(buttons))
}
wantButtonPaths := fmt.Sprintf("%s/%d", wantPagePaths, buttons[0].MenuId)
if buttons[0].Paths != wantButtonPaths {
t.Errorf("button %s paths=%q want %q", buttons[0].MenuName, buttons[0].Paths, wantButtonPaths)
}
}
var rootCount int64
if err := db.Model(&migrationModels.SysMenu{}).Where("menu_name = ?", senseLayoutMenuName).Count(&rootCount).Error; err != nil {
t.Fatal(err)
}
if rootCount != 1 {
t.Fatalf("Sense layout menu count=%d", rootCount)
}
if associationsAfter := countRoleMenuAssociations(t, db); associationsAfter != associationsBefore {
t.Fatalf("role-menu associations changed from %d to %d", associationsBefore, associationsAfter)
}
}
func TestMigrateSenseLayoutRollsBackWhenRequiredPageIsMissing(t *testing.T) {
db := openSenseLayoutTestDB(t)
seedLegacySenseMenus(t, db)
if err := db.Where("menu_name = ?", "SenseArea").Delete(&migrationModels.SysMenu{}).Error; err != nil {
t.Fatal(err)
}
// A missing page is recreated by the repair, so force a failure when the
// migration records its version and verify the menu transaction also rolls back.
version := "2026081623100"
if err := db.Create(&common.Migration{Version: version}).Error; err != nil {
t.Fatal(err)
}
if err := migrateSenseLayout(db, version); err == nil {
t.Fatal("expected duplicate migration version error")
}
var rootCount, areaCount int64
db.Model(&migrationModels.SysMenu{}).Where("menu_name = ?", senseLayoutMenuName).Count(&rootCount)
db.Model(&migrationModels.SysMenu{}).Where("menu_name = ?", "SenseArea").Count(&areaCount)
if rootCount != 0 || areaCount != 0 {
t.Fatalf("transaction left partial menus: root=%d area=%d", rootCount, areaCount)
}
}
func openSenseLayoutTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
return db
}
func TestSenseLayoutMigrationOnPostgres(t *testing.T) {
dsn := os.Getenv("SENSE_LAYOUT_MIGRATION_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SENSE_LAYOUT_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_layout_101_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") })
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{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
seedLegacySenseMenus(t, db)
const version = "2026081623100"
if err = migrateSenseLayout(db, version); err != nil {
t.Fatal(err)
}
root := requireMenu(t, db, senseLayoutMenuName)
if root.Component != "Layout" || root.Path != "/sense" {
t.Fatalf("unexpected PostgreSQL root: %#v", root)
}
var pages, versions int64
db.Model(&migrationModels.SysMenu{}).Where("parent_id = ? AND menu_type = ?", root.MenuId, "C").Count(&pages)
db.Model(&common.Migration{}).Where("version = ?", version).Count(&versions)
if pages != int64(len(sensePageMenus)) || versions != 1 {
t.Fatalf("pages=%d versions=%d", pages, versions)
}
}
func seedLegacySenseMenus(t *testing.T, db *gorm.DB) {
t.Helper()
role := migrationModels.SysRole{RoleName: "site_admin", RoleKey: "site_admin", Status: "2"}
if err := db.Create(&role).Error; err != nil {
t.Fatal(err)
}
assigned := make([]migrationModels.SysMenu, 0, len(sensePageMenus)*2)
for index, desired := range sensePageMenus {
legacy := desired
legacy.Path = "/sense/" + desired.Path
legacy.ParentId = 0
legacy.Sort = index + 5
legacy.Paths = ""
if err := db.Create(&legacy).Error; err != nil {
t.Fatal(err)
}
button := migrationModels.SysMenu{
MenuName: desired.MenuName + "Action",
Title: "测试操作",
MenuType: "F",
Permission: desired.Permission + ":action",
ParentId: legacy.MenuId,
Paths: fmt.Sprintf("/0/%d", legacy.MenuId),
Visible: "1",
IsFrame: "1",
}
if err := db.Create(&button).Error; err != nil {
t.Fatal(err)
}
assigned = append(assigned, legacy, button)
}
if err := db.Model(&role).Association("SysMenu").Append(assigned); err != nil {
t.Fatal(err)
}
}
func countRoleMenuAssociations(t *testing.T, db *gorm.DB) int64 {
t.Helper()
var count int64
if err := db.Table("sys_role_menu").Count(&count).Error; err != nil {
t.Fatal(err)
}
return count
}
func requireMenu(t *testing.T, db *gorm.DB, name string) migrationModels.SysMenu {
t.Helper()
var menu migrationModels.SysMenu
if err := db.Where("menu_name = ?", name).First(&menu).Error; err != nil {
t.Fatal(err)
}
return menu
}
+13 -8
View File
@@ -8,16 +8,11 @@ import (
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
const defaultLoginValidity = 30 * 24 * time.Hour
// AuthInit jwt验证new
func AuthInit() (*jwt.GinJWTMiddleware, error) {
timeout := time.Hour
if config.ApplicationConfig.Mode == "dev" {
timeout = time.Duration(876010) * time.Hour
} else {
if config.JwtConfig.Timeout != 0 {
timeout = time.Duration(config.JwtConfig.Timeout) * time.Second
}
}
timeout := resolveJWTTimeout(config.ApplicationConfig.Mode, config.JwtConfig.Timeout)
return jwt.New(&jwt.GinJWTMiddleware{
Realm: "Sense",
Key: []byte(config.JwtConfig.Secret),
@@ -34,3 +29,13 @@ func AuthInit() (*jwt.GinJWTMiddleware, error) {
})
}
func resolveJWTTimeout(mode string, configuredSeconds int64) time.Duration {
if mode == "dev" {
return time.Duration(876010) * time.Hour
}
if configuredSeconds > 0 {
return time.Duration(configuredSeconds) * time.Second
}
return defaultLoginValidity
}

Some files were not shown because too many files have changed in this diff Show More