Compare commits

..
Author SHA1 Message Date
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
100 changed files with 5076 additions and 642 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 是无界面推理交付单元。
+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。
+9
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
+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
}
@@ -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)
}
}
}
+12
View File
@@ -3,6 +3,8 @@ package media
import (
"context"
"errors"
"os"
"strings"
"sync"
"time"
@@ -16,6 +18,10 @@ var runtimeState struct {
}
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")
}
@@ -29,6 +35,12 @@ func StartRuntime(parent context.Context, db *gorm.DB) error {
}
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()
@@ -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)
}
}
+2 -1
View File
@@ -92,7 +92,7 @@ func run() error {
for _, db := range sdk.Runtime.GetDb() {
runtimeDBFound = true
if err := media.StartRuntime(runtimeCtx, db); err != nil {
log.Errorf("MediaMTX runtime unavailable: %v", err)
return fmt.Errorf("MediaMTX runtime unavailable: %w", err)
}
break
}
@@ -196,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)
}
}
@@ -1,7 +1,9 @@
package version
import (
"fmt"
"runtime"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -19,6 +21,9 @@ func init() {
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
}
@@ -58,3 +63,122 @@ func migrateSenseMedia(db *gorm.DB, version string) error {
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, `"`, `""`) + `"`
}
@@ -2,6 +2,7 @@ package version
import (
"os"
"strings"
"testing"
"gorm.io/driver/postgres"
@@ -21,34 +22,177 @@ func TestMediaMigrationOnPostgres(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
const schema = "sense_media_95_test"
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
db.Exec("DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE")
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 {
if err := db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
t.Fatal(err)
}
}
if err = migrateSenseMedia(db, "2026081419000_media.go"); err != nil {
}
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)
}
var routes, menus, policies, applied int64
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
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.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseMedia%").Count(&menus).Error; err != nil {
if err := db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); 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 = ?", "2026081419000_media.go").Count(&applied).Error; err != nil {
t.Fatal(err)
}
if routes != 0 || menus != 3 || policies != 12 || applied != 1 {
t.Fatalf("routes=%d menus=%d policies=%d applied=%d", routes, menus, policies, applied)
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,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
}
@@ -0,0 +1,57 @@
package middleware
import (
"testing"
"time"
"github.com/go-admin-team/go-admin-core/sdk/config"
)
func TestResolveJWTTimeoutDefaultsToThirtyDays(t *testing.T) {
for _, mode := range []string{"prod", "test", "demo"} {
if got := resolveJWTTimeout(mode, 0); got != 30*24*time.Hour {
t.Errorf("mode %s timeout=%s, want 720h", mode, got)
}
}
}
func TestResolveJWTTimeoutHonorsExplicitConfiguration(t *testing.T) {
if got := resolveJWTTimeout("prod", 3600); got != time.Hour {
t.Fatalf("timeout=%s, want 1h", got)
}
}
func TestResolveJWTTimeoutKeepsUpstreamDevelopmentBehavior(t *testing.T) {
if got := resolveJWTTimeout("dev", 1); got != 876010*time.Hour {
t.Fatalf("development timeout=%s", got)
}
}
func TestAuthInitSignsTokenExpiringAfterThirtyDays(t *testing.T) {
oldMode := config.ApplicationConfig.Mode
oldTimeout := config.JwtConfig.Timeout
oldSecret := config.JwtConfig.Secret
t.Cleanup(func() {
config.ApplicationConfig.Mode = oldMode
config.JwtConfig.Timeout = oldTimeout
config.JwtConfig.Secret = oldSecret
})
config.ApplicationConfig.Mode = "prod"
config.JwtConfig.Timeout = 0
config.JwtConfig.Secret = "task-104-test-secret-not-for-production"
auth, err := AuthInit()
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 17, 10, 0, 0, 0, time.UTC)
auth.TimeFunc = func() time.Time { return now }
_, expiresAt, err := auth.TokenGenerator(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
if got := expiresAt.Sub(now); got != 30*24*time.Hour {
t.Fatalf("signed token validity=%s, want 720h", got)
}
}
+1 -11
View File
@@ -7,9 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/captcha"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
@@ -82,21 +80,13 @@ func Authenticator(c *gin.Context) (interface{}, error) {
return nil, jwt.ErrMissingLoginValues
}
if config.ApplicationConfig.Mode != "dev" {
if !captcha.Verify(loginVals.UUID, loginVals.Code, true) {
username = loginVals.Username
msg = "验证码错误"
status = "1"
return nil, jwt.ErrInvalidVerificationode
}
}
sysUser, role, e := loginVals.GetUser(db)
if e == nil {
username = loginVals.Username
return map[string]interface{}{"user": sysUser, "role": role}, nil
} else {
username = loginVals.Username
msg = "登录失败"
status = "1"
log.Warnf("%s login failed!", loginVals.Username)
@@ -9,8 +9,6 @@ import (
type Login struct {
Username string `form:"UserName" json:"username" binding:"required"`
Password string `form:"Password" json:"password" binding:"required"`
Code string `form:"Code" json:"code" binding:"required"`
UUID string `form:"UUID" json:"uuid" binding:"required"`
}
func (u *Login) GetUser(tx *gorm.DB) (user SysUser, role SysRole, err error) {
@@ -0,0 +1,35 @@
package handler
import (
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestLoginAcceptsCredentialsWithoutCaptcha(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest("POST", "/api/v1/login", strings.NewReader(`{"username":"operator","password":"valid-password"}`))
ctx.Request.Header.Set("Content-Type", "application/json")
var login Login
if err := ctx.ShouldBindJSON(&login); err != nil {
t.Fatalf("bind credentials-only login: %v", err)
}
if login.Username != "operator" || login.Password != "valid-password" {
t.Fatalf("unexpected login payload: username=%q", login.Username)
}
typeOfLogin := reflect.TypeOf(login)
if typeOfLogin.NumField() != 2 {
t.Fatalf("login payload must only expose username and password, got %d fields", typeOfLogin.NumField())
}
for _, removed := range []string{"Code", "UUID"} {
if _, ok := typeOfLogin.FieldByName(removed); ok {
t.Fatalf("captcha field %s must not be part of the login payload", removed)
}
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ settings:
# JWT加密字符串
secret: ""
# 过期时间单位:秒
timeout: 3600
timeout: 2592000
database:
# 数据库名称
name: dbname
+1 -1
View File
@@ -16,7 +16,7 @@ settings:
frontpath: ../ui/src
jwt:
secret: ""
timeout: 3600
timeout: 2592000
logger:
# 日志存放路径
path: temp/logs
+1 -1
View File
@@ -34,7 +34,7 @@ settings:
# token 密钥,生产环境时及的修改
secret: ""
# token 过期时间 单位:秒
timeout: 3600
timeout: 2592000
database:
# Sense 仅支持 PostgreSQL;连接信息由仓库外配置提供。
driver: postgres
+1 -1
View File
@@ -25,7 +25,7 @@ settings:
# token 密钥,生产环境时及的修改
secret: ""
# token 过期时间 单位:秒
timeout: 3600
timeout: 2592000
database:
# 文件名为上游兼容名称;Sense 仍只支持 PostgreSQL。
driver: postgres
+1 -1
View File
@@ -25,7 +25,7 @@ settings:
# 必填。生产环境至少 32 个字符;不得提交真实值。
secret: ""
# token 过期时间 单位:秒
timeout: 3600
timeout: 2592000
database:
# 数据库类型 mysql, sqlite3, postgres, sqlserver
# sqlserver: sqlserver://用户名:密码@地址?database=数据库名
+1 -9
View File
@@ -3798,23 +3798,15 @@ const docTemplateadmin = `{
"handler.Login": {
"type": "object",
"required": [
"code",
"password",
"username",
"uuid"
"username"
],
"properties": {
"code": {
"type": "string"
},
"password": {
"type": "string"
},
"username": {
"type": "string"
},
"uuid": {
"type": "string"
}
}
},
+2 -10
View File
@@ -3789,23 +3789,15 @@
"handler.Login": {
"type": "object",
"required": [
"code",
"password",
"username",
"uuid"
"username"
],
"properties": {
"code": {
"type": "string"
},
"password": {
"type": "string"
},
"username": {
"type": "string"
},
"uuid": {
"type": "string"
}
}
},
@@ -4345,4 +4337,4 @@
"in": "header"
}
}
}
}
@@ -685,19 +685,13 @@ definitions:
type: object
handler.Login:
properties:
code:
type: string
password:
type: string
username:
type: string
uuid:
type: string
required:
- code
- password
- username
- uuid
type: object
models.SysApi:
properties:
+17
View File
@@ -0,0 +1,17 @@
@echo off
setlocal
set "PACKAGE_LAUNCHER=%~dp0dist\sense-windows-amd64\start-sense.bat"
if not exist "%PACKAGE_LAUNCHER%" (
echo [ERROR] Sense Windows delivery package was not found.
echo Expected launcher: "%PACKAGE_LAUNCHER%"
echo Build it first with: "%~dp0scripts\build\build-windows.bat"
endlocal
exit /b 2
)
call "%PACKAGE_LAUNCHER%" %*
set "SENSE_EXIT_CODE=%ERRORLEVEL%"
endlocal & exit /b %SENSE_EXIT_CODE%
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0run-tests.ps1"
exit /b %errorlevel%
+98
View File
@@ -0,0 +1,98 @@
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$senseRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
. (Join-Path $senseRoot 'scripts\runtime\sense-common.ps1')
$script:passed = 0
function Assert-True([bool]$Condition, [string]$Message) {
if (-not $Condition) { throw "ASSERT FAILED: $Message" }
$script:passed++
}
function Assert-Equal($Expected, $Actual, [string]$Message) {
if ($Expected -cne $Actual) { throw "ASSERT FAILED: $Message; expected [$Expected], got [$Actual]" }
$script:passed++
}
function Assert-Throws([scriptblock]$Action, [string]$Pattern, [string]$Message) {
try { & $Action; throw "ASSERT FAILED: $Message; no error was raised" } catch {
if ($_.Exception.Message -notmatch $Pattern) { throw "ASSERT FAILED: $Message; unexpected error: $($_.Exception.Message)" }
}
$script:passed++
}
$temporary = Join-Path ([IO.Path]::GetTempPath()) ("sense-package-tests-" + [guid]::NewGuid().ToString('N'))
$listener = $null
$oldValues = @{}
foreach ($name in $script:SenseAllowedEnvironment) {
$oldValues[$name] = [Environment]::GetEnvironmentVariable($name, 'Process')
[Environment]::SetEnvironmentVariable($name, $null, 'Process')
}
try {
New-Item -ItemType Directory -Path (Join-Path $temporary 'config'), (Join-Path $temporary 'web'), (Join-Path $temporary 'web\js'), (Join-Path $temporary 'bin') | Out-Null
$webIndex = '<div id="app"></div><script src="/js/runtime.fixture.js"></script>'
[IO.File]::WriteAllText((Join-Path $temporary 'web\index.html'), $webIndex, (New-Object Text.UTF8Encoding($false)))
[IO.File]::WriteAllText((Join-Path $temporary 'web\js\runtime.fixture.js'), 'fixture', (New-Object Text.UTF8Encoding($false)))
[IO.File]::WriteAllText((Join-Path $temporary 'bin\mediamtx.exe'), 'fixture', (New-Object Text.UTF8Encoding($false)))
[IO.File]::WriteAllText((Join-Path $temporary 'config\mediamtx.yml'), 'api: true', (New-Object Text.UTF8Encoding($false)))
$webAssetAudit = Join-Path $senseRoot 'scripts\build\assert-web-assets.ps1'
& $webAssetAudit -WebRoot (Join-Path $temporary 'web')
Assert-True $true 'web asset audit must accept existing local references'
Remove-Item -LiteralPath (Join-Path $temporary 'web\js\runtime.fixture.js')
Assert-Throws { & $webAssetAudit -WebRoot (Join-Path $temporary 'web') } 'missing local asset' 'web asset audit must reject missing runtime files'
[IO.File]::WriteAllText((Join-Path $temporary 'web\js\runtime.fixture.js'), 'fixture', (New-Object Text.UTF8Encoding($false)))
$listener = New-Object Net.Sockets.TcpListener([Net.IPAddress]::Loopback, 0)
$listener.Start()
$dbPort = ([Net.IPEndPoint]$listener.LocalEndpoint).Port
$marker = Join-Path $temporary 'must-not-exist.txt'
$envText = @(
'SENSE_HOST=127.0.0.1',
'SENSE_PORT=18070',
"SENSE_DATABASE_URL=host=127.0.0.1 port=$dbPort user=sense password=p#&;=x dbname=sense sslmode=disable",
"SENSE_JWT_SECRET=`$(Set-Content -LiteralPath '$marker' hacked)-literal-secret-1234567890",
'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'
) -join "`n"
[IO.File]::WriteAllText((Join-Path $temporary 'config\sense.env'), $envText, (New-Object Text.UTF8Encoding($false)))
[IO.File]::WriteAllText((Join-Path $temporary 'config\sense.demo.env'), $envText.Replace('SENSE_DATABASE_URL=', 'SENSE_DEMO_DATABASE_URL=').Replace('dbname=sense ', 'dbname=sense_demo '), (New-Object Text.UTF8Encoding($false)))
[Environment]::SetEnvironmentVariable('SENSE_PORT', '18071', 'Process')
$state = Initialize-SenseRuntime -PackageRoot $temporary -Mode production
Assert-Equal 18071 $state.Port 'non-empty process environment must override sense.env'
Assert-True (-not (Test-Path -LiteralPath $marker)) 'sense.env content must never execute'
$generatedSettings = Get-Content -LiteralPath $state.SettingsPath -Raw
Assert-True $generatedSettings.Contains('timeout: 2592000') 'generated runtime settings must keep login valid for 30 days'
Assert-True ((Get-SenseEnvironmentValue -Name 'SENSE_DATABASE_URL').Contains('p#&;=x')) 'special characters must survive env parsing'
Assert-True ($generatedSettings.Contains('password=p#\u0026;=x')) 'special characters must be safely JSON-escaped in YAML'
Assert-True (-not ((Get-SenseDatabaseInfo (Get-SenseEnvironmentValue -Name 'SENSE_DATABASE_URL')).Sanitized.Contains('password='))) 'PostgreSQL tool arguments must not contain password'
[Environment]::SetEnvironmentVariable('SENSE_PORT', $null, 'Process')
foreach ($name in @('SENSE_DATABASE_URL','SENSE_JWT_SECRET','SENSE_MEDIAMTX_MODE','SENSE_MEDIAMTX_BINARY','SENSE_MEDIAMTX_CONFIG','SENSE_MEDIAMTX_API','SENSE_WEB_ROOT')) { [Environment]::SetEnvironmentVariable($name, $null, 'Process') }
$demo = Initialize-SenseRuntime -PackageRoot $temporary -Mode demo
Assert-Equal 'sense_demo' $demo.Database.Database 'demo must use its dedicated database variable'
$bad = Join-Path $temporary 'config\bad.env'
[IO.File]::WriteAllText($bad, 'SENSE_UNKNOWN=value', (New-Object Text.UTF8Encoding($false)))
Assert-Throws { Import-SenseEnvironment -Path $bad } 'Unsupported Sense configuration key' 'unknown keys must be rejected'
[IO.File]::WriteAllText((Join-Path $temporary 'config\sense.demo.env'), $envText.Replace('SENSE_DATABASE_URL=', 'SENSE_DEMO_DATABASE_URL='), (New-Object Text.UTF8Encoding($false)))
foreach ($name in $script:SenseAllowedEnvironment) { [Environment]::SetEnvironmentVariable($name, $null, 'Process') }
Assert-Throws { Initialize-SenseRuntime -PackageRoot $temporary -Mode demo } 'database name containing demo or test' 'demo must reject production database names'
foreach ($file in Get-ChildItem -LiteralPath (Join-Path $senseRoot 'scripts') -Recurse -Filter '*.ps1') {
[void][scriptblock]::Create((Get-Content -LiteralPath $file.FullName -Raw))
$script:passed++
}
Write-Host "Sense package tests passed: $script:passed assertions."
} finally {
if ($listener) { $listener.Stop() }
foreach ($name in $script:SenseAllowedEnvironment) { [Environment]::SetEnvironmentVariable($name, $oldValues[$name], 'Process') }
if (Test-Path -LiteralPath $temporary) {
$resolved = [IO.Path]::GetFullPath($temporary)
if (-not $resolved.StartsWith([IO.Path]::GetTempPath(), [StringComparison]::OrdinalIgnoreCase)) { throw "Unsafe temporary test path: $resolved" }
Remove-Item -LiteralPath $resolved -Recurse -Force
}
}
+32
View File
@@ -0,0 +1,32 @@
param([Parameter(Mandatory = $true)][string]$PackageRoot)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath($PackageRoot)
$start = Join-Path $root 'start-sense.bat'
$stop = Join-Path $root 'stop-sense.bat'
$port = [int]$env:SENSE_PORT
$launcher = Start-Process -FilePath 'cmd.exe' -ArgumentList @('/d', '/c', "`"$start`" -SkipMigration") -WorkingDirectory (Split-Path $root -Parent) -WindowStyle Hidden -PassThru
try {
$ready = $false
for ($attempt = 0; $attempt -lt 60; $attempt++) {
$client = New-Object Net.Sockets.TcpClient
try {
$task = $client.ConnectAsync('127.0.0.1', $port)
if ($task.Wait(500) -and $client.Connected) { $ready = $true; break }
} catch {} finally { $client.Dispose() }
Start-Sleep -Milliseconds 500
}
if (-not $ready) { throw 'start-sense.bat did not open the configured HTTP port.' }
& $stop
if ($LASTEXITCODE -ne 0) { throw 'stop-sense.bat failed.' }
Start-Sleep -Seconds 1
$probe = New-Object Net.Sockets.TcpClient
try {
$task = $probe.ConnectAsync('127.0.0.1', $port)
if ($task.Wait(500) -and $probe.Connected) { throw 'Sense port is still open after stop-sense.bat.' }
} catch [Net.Sockets.SocketException] {} finally { $probe.Dispose() }
Write-Host 'Sense start/stop wrapper smoke passed from an external working directory.'
} finally {
if (-not $launcher.HasExited) { & taskkill.exe /PID $launcher.Id /T /F | Out-Null }
}
@@ -0,0 +1,53 @@
param([Parameter(Mandatory = $true)][string]$PackageRoot)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath($PackageRoot)
$port = [int]$env:SENSE_PORT
$mediaUri = [Uri]$env:SENSE_MEDIAMTX_API
$stdout = Join-Path ([IO.Path]::GetTempPath()) ("sense-smoke-$PID.out")
$stderr = Join-Path ([IO.Path]::GetTempPath()) ("sense-smoke-$PID.err")
$server = $null
$succeeded = $false
try {
& (Join-Path $root 'migrate-sense.bat')
if ($LASTEXITCODE -ne 0) { throw 'Package migration entry failed.' }
if (-not [IO.Path]::IsPathRooted($env:SENSE_MEDIAMTX_BINARY)) { $env:SENSE_MEDIAMTX_BINARY = [IO.Path]::GetFullPath((Join-Path $root $env:SENSE_MEDIAMTX_BINARY)) }
if (-not [IO.Path]::IsPathRooted($env:SENSE_MEDIAMTX_CONFIG)) { $env:SENSE_MEDIAMTX_CONFIG = [IO.Path]::GetFullPath((Join-Path $root $env:SENSE_MEDIAMTX_CONFIG)) }
if (-not [IO.Path]::IsPathRooted($env:SENSE_WEB_ROOT)) { $env:SENSE_WEB_ROOT = [IO.Path]::GetFullPath((Join-Path $root $env:SENSE_WEB_ROOT)) }
$settings = Join-Path $root 'data\runtime\settings.yml'
$server = Start-Process -FilePath (Join-Path $root 'sense.exe') -ArgumentList 'server', '-c', $settings -WorkingDirectory $root -RedirectStandardOutput $stdout -RedirectStandardError $stderr -WindowStyle Hidden -PassThru
$response = $null
for ($attempt = 0; $attempt -lt 80; $attempt++) {
$server.Refresh()
if ($server.HasExited) {
Get-Content -LiteralPath $stdout -Tail 120 -ErrorAction SilentlyContinue | Out-Host
Get-Content -LiteralPath $stderr -Tail 120 -ErrorAction SilentlyContinue | Out-Host
throw "Sense exited before HTTP readiness with code $($server.ExitCode)."
}
try {
$response = Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:$port/" -TimeoutSec 1
if ($response.StatusCode -eq 200 -and $response.Content.Contains('<title>')) { break }
} catch {}
Start-Sleep -Milliseconds 500
}
if (-not $response -or $response.StatusCode -ne 200 -or -not $response.Content.Contains('<title>')) {
throw 'Sense package did not serve the GoAdmin UI before the smoke timeout.'
}
$media = $null
for ($attempt = 0; $attempt -lt 20; $attempt++) {
try {
$media = Invoke-RestMethod -Uri "http://$($mediaUri.Host):$($mediaUri.Port)/v3/config/global/get" -TimeoutSec 1
if ($null -ne $media) { break }
} catch {}
Start-Sleep -Milliseconds 500
}
if ($null -eq $media) { throw 'MediaMTX Control API returned no data.' }
Write-Host "Sense package smoke passed: web=200, SPA=true, MediaMTX=true, port=$port."
$succeeded = $true
} finally {
if ($server -and -not $server.HasExited) {
& taskkill.exe /PID $server.Id /T /F | Out-Null
}
Remove-Item -LiteralPath $stdout, $stderr -Force -ErrorAction SilentlyContinue
}
+2 -1
View File
@@ -1,13 +1,14 @@
import Cookies from 'js-cookie'
const TokenKey = 'Sense-Admin-Token'
const LoginValidityDays = 30
export function getToken() {
return Cookies.get(TokenKey)
}
export function setToken(token) {
return Cookies.set(TokenKey, token)
return Cookies.set(TokenKey, token, { expires: LoginValidityDays })
}
export function removeToken() {
+4 -85
View File
@@ -102,29 +102,6 @@
</el-input>
</el-form-item>
<el-form-item label="验证码" prop="code">
<div class="captcha-row">
<el-input
v-model="loginForm.code"
placeholder="请输入验证码"
name="code"
type="text"
tabindex="3"
maxlength="5"
autocomplete="off"
size="large"
:prefix-icon="Key"
@keyup.enter="handleLogin"
/>
<div class="captcha-wrap" title="点击刷新" @click="getCode">
<img v-if="codeUrl" :src="codeUrl" class="captcha-img" alt="验证码">
<div v-else class="captcha-placeholder">
<el-icon class="is-loading"><Loading /></el-icon>
</div>
</div>
</div>
</el-form-item>
<el-button
:loading="loading"
type="primary"
@@ -142,27 +119,22 @@
</template>
<script>
import { getCodeImg } from '@/api/login'
import { User, Lock, Key, View, Hide, Monitor, Loading } from '@element-plus/icons-vue'
import { User, Lock, View, Hide, Monitor } from '@element-plus/icons-vue'
export default {
name: 'LoginPage',
setup() {
return { User, Lock, Key, View, Hide, Monitor, Loading }
return { User, Lock, View, Hide, Monitor }
},
data() {
return {
codeUrl: '',
loginForm: {
username: '',
password: '',
code: '',
uuid: ''
password: ''
},
loginRules: {
username: [{ required: true, trigger: 'blur', message: '用户名不能为空' }],
password: [{ required: true, trigger: 'blur', message: '密码不能为空' }],
code: [{ required: true, trigger: 'change', message: '验证码不能为空' }]
password: [{ required: true, trigger: 'blur', message: '密码不能为空' }]
},
passwordType: 'password',
capsTooltip: false,
@@ -185,7 +157,6 @@ export default {
}
},
created() {
this.getCode()
this.getSystemSetting()
},
mounted() {
@@ -202,15 +173,6 @@ export default {
document.title = ret.sys_app_name
})
},
getCode() {
this.codeUrl = ''
getCodeImg().then((res) => {
if (res !== undefined) {
this.codeUrl = res.data
this.loginForm.uuid = res.id
}
})
},
checkCapslock({ shiftKey, key } = {}) {
if (key && key.length === 1) {
if ((shiftKey && key >= 'a' && key <= 'z') || (!shiftKey && key >= 'A' && key <= 'Z')) {
@@ -238,7 +200,6 @@ export default {
})
.catch(() => {
this.loading = false
this.getCode()
})
}
})
@@ -562,48 +523,6 @@ export default {
}
}
/* ── 验证码 ── */
.captcha-row {
display: flex;
gap: 10px;
align-items: center;
.el-input {
flex: 1;
}
}
.captcha-wrap {
width: 110px;
height: 40px;
flex-shrink: 0;
border-radius: 8px;
border: 1px solid #e5e7eb;
overflow: hidden;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: #f9fafb;
transition: border-color 0.2s;
&:hover {
border-color: #3b82f6;
}
}
.captcha-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.captcha-placeholder {
color: #c1c7d0;
font-size: 18px;
}
/* ── 登录按钮 ── */
.submit-btn {
width: 100%;
@@ -0,0 +1,15 @@
import LoginPage from '@/views/login/index.vue'
describe('Sense login page', () => {
it('uses username and password without a captcha challenge', () => {
const state = LoginPage.data()
const getSystemSetting = jest.fn()
LoginPage.created.call({ getSystemSetting })
expect(Object.keys(state.loginForm)).toEqual(['username', 'password'])
expect(Object.keys(state.loginRules)).toEqual(['username', 'password'])
expect(LoginPage.methods.getCode).toBeUndefined()
expect(getSystemSetting).toHaveBeenCalledTimes(1)
})
})
+32
View File
@@ -0,0 +1,32 @@
import Cookies from 'js-cookie'
import { getToken, removeToken, setToken } from '@/utils/auth'
jest.mock('js-cookie', () => ({
get: jest.fn(),
set: jest.fn(),
remove: jest.fn()
}))
describe('Sense login token cookie', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('persists the token for 30 days', () => {
setToken('test-token')
expect(Cookies.set).toHaveBeenCalledWith(
'Sense-Admin-Token',
'test-token',
{ expires: 30 }
)
})
it('reads and removes the same product-specific cookie', () => {
getToken()
removeToken()
expect(Cookies.get).toHaveBeenCalledWith('Sense-Admin-Token')
expect(Cookies.remove).toHaveBeenCalledWith('Sense-Admin-Token')
})
})
+7 -8
View File
@@ -107,14 +107,6 @@ module.exports = {
config
.when(process.env.NODE_ENV !== 'development',
config => {
config
.plugin('ScriptExtHtmlWebpackPlugin')
.after('html')
.use('script-ext-html-webpack-plugin', [{
// `runtime` must same as runtimeChunk name. default is `runtime`
inline: /runtime\..*\.js$/
}])
.end()
config
.optimization.splitChunks({
chunks: 'all',
@@ -145,6 +137,13 @@ module.exports = {
},
css: {
loaderOptions: {
css: {
// Preserve GoAdmin's :export variables as JavaScript values with css-loader 6.
// ICSS mode does not rename ordinary global or component class selectors.
modules: {
mode: 'icss'
}
},
less: {
modifyVars: {
// less vars,customize ant design theme
@@ -1,15 +1,38 @@
"""检查 DevHarness 必需文件、核心文档和任务归档的基本结构。"""
"""DevHarness 单一命令行入口。
子命令:
check 检查必需文件、核心文档和已有任务快照结构
sync 从 Gitea Wiki 单向导出或校验核心 docs 镜像
archive 显式在 Gitea Wiki 创建可选任务快照
export 人工按需把已有 Wiki 任务快照导出到 docs/task
各子命令的实现逻辑取自原来的 check_harness.py、sync_wiki_docs.py、
new_task_archive.py 和 export_task_archives.py,行为未改变。
"""
from __future__ import annotations
import argparse
import json
import re
from datetime import date
from pathlib import Path
from typing import Any
from wiki_docs import WikiDocsError, load_config, parse_mirror
from wiki_docs import (
DEFAULT_CONFIG,
WikiClient,
WikiDocsError,
dirty_paths,
load_config,
parse_mirror,
sync_all,
write_mirror,
)
# ---------------------------------------------------------------- 结构检查
ROOT = Path(__file__).resolve().parents[1]
CORE_PAGE_PATHS = {
"Home": "docs/README.md",
@@ -28,10 +51,20 @@ CORE_PAGE_PATHS = {
"Existing-Project-Adoption-Guide": (
"docs/08-existing-project-adoption.md"
),
"Product-Requirements": (
"docs/09-product-requirements.md"
),
"Requirements-Migration-Matrix": "docs/10-requirements-migration-matrix.md",
"Multi-Agent-Collaboration": "docs/11-multi-agent-collaboration.md",
"Product-Roadmap": "docs/12-product-roadmap.md",
"Delivery-Documentation-Guide": "docs/delivery/README.md",
"Audience-Document-Template": (
"docs/delivery/audience-document-template.md"
),
"Deployment-and-Operations": (
"docs/delivery/deployment-and-operations.md"
),
"Deployment-Template": "docs/templates/deployment.md",
"Task-Archive-Template": "docs/templates/task-archive.md",
}
CORE_DOCUMENT_REQUIREMENTS = {
@@ -43,6 +76,7 @@ CORE_DOCUMENT_REQUIREMENTS = {
),
"docs/00-project-profile.md": (
"## 基本信息",
"## DevHarness 来源与基线",
"## 子项目与交付单元",
"## 技术栈与运行环境",
"## 阅读入口",
@@ -51,11 +85,17 @@ CORE_DOCUMENT_REQUIREMENTS = {
"## Sense/Bell GoAdmin 固定技术基线",
),
"docs/01-workflow.md": (
"## Gitea 交互与工单最小读取",
"## 新项目 Wiki 初始化门禁",
"## 工单与设计证据双门禁",
"### 先判断是否需要工单",
"### 再判断设计证据",
"### 线上原型审核与按需导出",
"### 记录和重新确认",
"## 面向初级维护者的修改边界",
"## go-admin / go-admin-ui 开发约束",
"## 每个任务的文档影响",
"## 需求记录与流转",
"## 稳定文档与任务归档",
"## 稳定文档与可选历史快照",
"## 自然语言快捷指令",
"## 效率与范围控制",
"### 严格控制范围",
@@ -77,6 +117,7 @@ CORE_DOCUMENT_REQUIREMENTS = {
),
"docs/04-local-development-and-verification.md": (
"## 环境要求",
"## Windows PowerShell 与 UTF-8",
"## Sense/Bell 固定工具链与只读参考源",
"## 第一次运行",
"## 常用调试方式",
@@ -94,8 +135,13 @@ CORE_DOCUMENT_REQUIREMENTS = {
),
"docs/07-new-project-documentation-setup.md": (
"## 初始化顺序",
"### 2. 识别子项目与交付单元",
"### 6. 确定交付对象和文档",
"#### 需求总览启用条件",
"### 2. 选择建设基线",
"#### 工程基线裁剪",
"#### 判断案例",
"### 3. 识别子项目与交付单元",
"### 7. 确定交付对象和文档",
"#### 在线创建与回读门禁",
"## 完成标准",
),
"docs/08-existing-project-adoption.md": (
@@ -107,6 +153,9 @@ CORE_DOCUMENT_REQUIREMENTS = {
"### 可以考虑拆仓",
"### 保持单仓库时的最小规则",
"## 增量接入顺序",
"## 后续升级",
"### 升级步骤",
"### 可复制升级指令",
"## 冲突处理和停止条件",
"## 可复制 Agent 指令",
"### 只分析",
@@ -114,6 +163,19 @@ CORE_DOCUMENT_REQUIREMENTS = {
"## 最小验收清单",
"## 回退原则",
),
"docs/09-product-requirements.md": (
"## 本页用途",
"## 事实来源边界",
"## 当前需求索引",
"## 登记规则",
"## 原型与设计资产",
"### 原型门禁",
"### 线上原型与按需 HTML 快照",
"### 原型确认记录",
"## 状态规则",
"## 更新时机",
"## 最小验收清单",
),
"docs/delivery/README.md": (
"## 什么时候需要交付文档",
"## 受众与文档选择",
@@ -140,12 +202,12 @@ REQUIRED_FILES = (
"README.md",
"docs/00-project-profile.md",
"docs/01-workflow.md",
"docs/templates/deployment.md",
"docs/templates/task-archive.md",
*CORE_DOCUMENT_REQUIREMENTS,
"wiki-docs.json",
"goadmin-baseline.json",
"dev_scripts/wiki_docs.py",
"dev_scripts/sync_wiki_docs.py",
"dev_scripts/harness.py",
".gitea/issue_template/epic.md",
".gitea/issue_template/mvp.md",
".gitea/issue_template/task.md",
@@ -181,6 +243,15 @@ def check_archives(errors: list[str]) -> None:
if not re.match(r"^\d+-.+\.md$", path.name):
errors.append(f"归档文件名不符合 <编号>-<标题>.md:{path.name}")
content = path.read_text(encoding="utf-8")
try:
metadata, _ = parse_mirror(content)
except WikiDocsError as exc:
errors.append(f"{path.name} 的任务镜像无效:{exc}")
continue
if re.fullmatch(r"Task-\d+-.+", metadata.get("wiki_page", "")) is None:
errors.append(f"{path.name} 的 wiki_page 不是任务归档页面")
if re.fullmatch(r"[0-9a-f]{40,64}", metadata.get("wiki_revision", "")) is None:
errors.append(f"{path.name} 的 wiki_revision 无效")
for heading in ARCHIVE_HEADINGS:
if heading not in content:
errors.append(f"{path.name} 缺少章节:{heading}")
@@ -213,9 +284,6 @@ def check_task_template(errors: list[str], root: Path = ROOT) -> None:
return
content = path.read_text(encoding="utf-8")
required = (
"- 任务类型:单项目 / 协同",
"- 主项目:Sense / Brain / Bell / contracts / 根级",
"- 主 agent:",
"## 依赖与并行",
"- 前置工单:无 / #编号",
"- 是否允许与前置工单并行:是 / 否",
@@ -224,21 +292,23 @@ def check_task_template(errors: list[str], root: Path = ROOT) -> None:
"- 仅影响的子项目 / 交付单元:",
"- 是否跨子项目:是 / 否",
"- 是否修改共享接口或契约:是 / 否;唯一事实来源:",
"- write_paths:",
"- 各子项目需要执行的验证:",
"## 协同接口",
"- 生产者:",
"- 消费者:",
"- 契约/共享事实源:",
"- 兼容策略:不适用 / 向后兼容 / 发布新版本",
"- 被阻塞或需要适配的工单:",
"- 集成顺序:",
"## 原始需求",
"- 来源:用户对话 / Gitea / 其他",
"- 提出时间:",
"- 关键原话或脱敏摘要:",
"## 需求变化记录",
"| 日期 | 变化内容 | 原因 | 用户确认 |",
"## 设计与原型门禁",
"- 修改类型:纯显示文案 / 小范围 UI / 新组件 / 新页面或独立用户功能 / 重大交互或导航 / 非 UI / 恢复既有行为的 Bug",
"- 所需设计证据:无 / 标注截图 / 低保真图 / 已确认原型 / 架构、API、数据、状态或流程设计 / 原设计或复现证据",
"- 可编辑设计源、线上原型链接和访问检查:",
"- 审核版本、revision、复制版本或确认日期及识别方式:",
"- 本地 HTML 导出:未要求 / 用户明确要求 / 项目规则要求",
"- 本地 HTML 路径、版本和资源检查(仅显式导出时填写):",
"- 状态:无 / 草稿 / 已确认 / 已废弃",
"- 确认人、确认时间和覆盖范围:",
"- 无需 UI 原型或无需任何原型的原因:",
"## 文档影响",
"- [ ] 不影响长期文档,原因:",
"- [ ] 更新架构与代码地图",
@@ -249,6 +319,11 @@ def check_task_template(errors: list[str], root: Path = ROOT) -> None:
"- [ ] 更新已有交付文档,受众与页面:",
"- [ ] 新增交付文档,受众与页面:",
"- [ ] 需要目标岗位或客户代表验证:是 / 否;验证方式:",
"## 任务记录与可选快照",
"- 单次任务事实来源:当前 Gitea 工单正文与评论",
"- [ ] 默认不创建任务快照",
"- [ ] 用户明确要求专项快照;用途和范围:",
"- [ ] 项目专用规则要求任务快照;规则入口:",
)
for section in missing_sections(content, required):
errors.append(f"单元任务模板缺少:{section}")
@@ -268,12 +343,30 @@ def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None:
"单元任务是唯一正式实施单位",
"高风险修改必须停止",
"用户没有明确验收通过前不得关闭",
"长期文档必须先修改 Wiki",
"只有长期事实变化时才修改 Wiki",
"Gitea 工单是单次任务需求、变化、实现、测试、提交和验收的事实来源",
"默认不创建任务归档",
"### Gitea 交互与工单最小读取",
"查询、创建、更新、评论、状态变更及关闭操作",
"优先关注当前状态、最新评论和首个未完成步骤",
"连接器不支持评论分页或增量读取时允许读取完整工单",
"不得为规避完整读取而新增本地工单、缓存或第二事实来源",
"### 新项目 Wiki 初始化门禁",
"`Home` 不存在时必须先创建 `Home`",
"不得把模板自带的本地 `docs/` 当作新项目 Wiki 已初始化的证据",
"提交只包含当前工单相关文件",
"不得仅为设置编码重复启动一层 PowerShell",
"文件解码和控制台输出分别处理",
"不得默认使用 `-ExecutionPolicy Bypass`",
"### 工单与设计证据双门禁",
"新页面、独立用户功能、重大交互或导航变化",
"`prototypes/<工单号>/<版本>/index.html`",
"默认直接通过 Quant-UX 或其他设计工具的线上链接审核",
"已确认的本地快照不得原位覆盖",
"`导出原型 #N`",
"`导出全部原型`",
"代码组件名、类名、变量、国际化键、API 字段和数据库字段不是显示文案",
"### 自然语言快捷指令",
"### 三项目并行建单顺序",
"建单顺序不等于实施顺序",
"主 agent / dispatcher",
"`只分析`",
"`建工单`",
"`执行工单 #N`",
@@ -281,6 +374,10 @@ def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None:
"`继续工单 #N`",
"`检查工单 #N`",
"`同步文档`",
"`导出原型 #N`",
"`导出全部原型`",
"`导出任务归档`",
"`导出全部任务归档`",
"`#N 验收通过`",
"### 需求记录与流转",
"不得臆造用户原话",
@@ -291,6 +388,31 @@ def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None:
errors.append(f"AGENTS.md 缺少:{section}")
def check_repository_readme(errors: list[str], root: Path = ROOT) -> None:
"""检查快速开始包含线上 Wiki 初始化顺序和产品编码门禁。"""
path = root / "README.md"
if not path.is_file():
return
content = path.read_text(encoding="utf-8")
required = (
"创建 Gitea 远端仓库并推送当前引导提交,启用工单和 Wiki",
"优先使用已配置的 Gitea MCP",
"`Home` 不存在时先创建并回读 `Home`",
"本地 `docs/` 的存在不能证明线上 Wiki 已初始化",
"python dev_scripts/harness.py sync --verify",
"Gitea 工单是单次任务唯一事实来源",
"默认不创建任务归档",
)
for section in missing_sections(content, required):
errors.append(f"README.md 缺少:{section}")
remote_index = content.find("创建 Gitea 远端仓库")
wiki_index = content.find("`Home` 不存在时先创建")
if remote_index < 0 or wiki_index < 0 or remote_index > wiki_index:
errors.append("README.md 必须先创建 Gitea 远端,再创建 Wiki Home")
def check_go_admin_ui_rules(errors: list[str], root: Path = ROOT) -> None:
"""检查 Sense、Bell 共用的框架精简和组件复用规则。"""
@@ -455,6 +577,7 @@ def check_goadmin_baseline(errors: list[str], root: Path = ROOT) -> None:
errors.append(f"{relative_path} 缺少 GoAdmin 基线规则:{section}")
def check_claude_code_entry(errors: list[str], root: Path = ROOT) -> None:
"""检查 Claude Code 入口直接复用共同 Agent 规则。"""
@@ -471,14 +594,11 @@ def check_claude_code_entry(errors: list[str], root: Path = ROOT) -> None:
"只修改 `AGENTS.md`",
"## 模型路由",
"## Agent 交接",
"## 三项目角色路由",
"## Haiku 只读约束",
"当前模型足以完成任务时不升级模型",
"Opus 输出方案后必须等待用户确认",
"不让 Haiku 决定最终根因",
"只读必须通过子 Agent 工具权限实现",
"主 Claude 作为 dispatcher",
"coordination agent",
)
for section in missing_sections(content, required):
errors.append(f"CLAUDE.md 缺少:{section}")
@@ -495,7 +615,7 @@ def core_mapping_errors(configured_mappings: dict[str, str]) -> list[str]:
def check_wiki_mirrors(errors: list[str]) -> None:
"""检查每份本地文档都有显式映射和可追踪的镜像头。"""
"""检查核心映射与镜像头;任务快照由 check_archives 单独检查。"""
try:
config = load_config()
@@ -509,6 +629,7 @@ def check_wiki_mirrors(errors: list[str]) -> None:
mapped_paths = {mapping.path for mapping in config.mappings}
actual_paths = {
path.relative_to(ROOT).as_posix() for path in (ROOT / "docs").rglob("*.md")
if path.parent != ROOT / "docs" / "task"
}
for path in sorted(actual_paths - mapped_paths):
errors.append(f"docs 中存在未登记的 Wiki 镜像:{path}")
@@ -534,15 +655,121 @@ def check_wiki_mirrors(errors: list[str]) -> None:
errors.append(f"{mapping.path} 缺少 synchronized_at")
def main() -> int:
parser = argparse.ArgumentParser(description="检查 DevHarness 项目结构")
parser.add_argument(
"--strict",
action="store_true",
help="项目档案有占位内容时返回失败",
)
args = parser.parse_args()
# ---------------------------------------------------------------- 任务归档
def safe_title(title: str) -> str:
"""把标题转换为适合 Wiki 页面名和 Windows 文件名的短文本。"""
cleaned = re.sub(r'[<>:"/\\|?*]', "-", title.strip())
cleaned = re.sub(r"\s+", "-", cleaned)
cleaned = re.sub(r"-+", "-", cleaned)
return cleaned.strip(".-")
def build_archive(
template: str,
issue_number: str,
title: str,
page_name: str,
issue_url: str,
) -> str:
content = template.replace("<工单号>", issue_number, 1)
content = content.replace("<标题>", title.strip(), 1)
content = content.replace("YYYY-MM-DD", date.today().isoformat(), 1)
content = content.replace("<链接>", issue_url, 1)
return content.replace("<页面名>", page_name, 1)
# ---------------------------------------------------------------- 归档导出
TASK_PAGE_PATTERN = re.compile(r"^Task-(?P<number>\d+)-(?P<title>.+)$")
def task_revision(metadata: dict[str, Any], page_name: str) -> str:
last_commit = metadata.get("last_commit")
revision = last_commit.get("sha") if isinstance(last_commit, dict) else None
if not isinstance(revision, str) or not revision:
raise WikiDocsError(f"Wiki 页面缺少 revision:{page_name}")
return revision
def existing_task_mirrors(root: Path = ROOT) -> dict[str, Path]:
"""按镜像头匹配已有文件,兼容历史自定义文件名。"""
mirrors: dict[str, Path] = {}
task_dir = root / "docs" / "task"
if not task_dir.is_dir():
return mirrors
for path in task_dir.glob("*.md"):
try:
metadata, _ = parse_mirror(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, WikiDocsError) as exc:
raise WikiDocsError(f"已有任务镜像无效 {path.name}:{exc}") from exc
page_name = metadata.get("wiki_page", "")
if not TASK_PAGE_PATTERN.fullmatch(page_name):
raise WikiDocsError(f"已有任务镜像页面名无效 {path.name}:{page_name}")
if page_name in mirrors:
raise WikiDocsError(f"任务页面存在重复本地镜像:{page_name}")
mirrors[page_name] = path
return mirrors
def task_target(page_name: str, root: Path = ROOT) -> Path:
match = TASK_PAGE_PATTERN.fullmatch(page_name)
if match is None:
raise WikiDocsError(f"不是任务归档页面:{page_name}")
title = safe_title(match.group("title"))
if not title:
raise WikiDocsError(f"任务归档标题无效:{page_name}")
return root / "docs" / "task" / f"{match.group('number')}-{title}.md"
def export_task_archives(
client: WikiClient, *, export_all: bool = False, root: Path = ROOT
) -> list[str]:
"""增量或全量读取任务归档;绝不删除本地文件。"""
dirty = dirty_paths(["docs/task"], root)
if dirty:
raise WikiDocsError(
"本地任务镜像存在未提交改动,已停止以防覆盖:\n" + "\n".join(dirty)
)
existing = existing_task_mirrors(root)
pages = []
for metadata in client.list_pages():
title = metadata.get("title")
if isinstance(title, str) and TASK_PAGE_PATTERN.fullmatch(title):
pages.append((int(title.split("-", 2)[1]), title, metadata))
pages.sort(key=lambda item: (item[0], item[1]))
messages: list[str] = []
targets: set[Path] = set()
for _, page_name, metadata in pages:
target = existing.get(page_name, task_target(page_name, root))
if target in targets:
raise WikiDocsError(f"多个任务页面映射到同一本地路径:{target.name}")
targets.add(target)
revision = task_revision(metadata, page_name)
if not export_all and target.is_file():
local_metadata, _ = parse_mirror(target.read_text(encoding="utf-8"))
if (
local_metadata.get("wiki_page") == page_name
and local_metadata.get("wiki_revision") == revision
):
messages.append(f"跳过:{target.relative_to(root)} <- {page_name}@{revision[:12]}")
continue
page = client.get_page_from_metadata(metadata, page_name)
changed = write_mirror(target, page)
action = "已导出" if changed else "无变化"
messages.append(f"{action}:{target.relative_to(root)} <- {page_name}@{revision[:12]}")
return messages
# ---------------------------------------------------------------- 子命令入口
def run_check(args: argparse.Namespace) -> int:
errors: list[str] = []
warnings: list[str] = []
check_required_files(errors)
@@ -551,6 +778,7 @@ def main() -> int:
check_core_documents(errors)
check_task_template(errors)
check_agent_efficiency_rules(errors)
check_repository_readme(errors)
check_go_admin_ui_rules(errors)
check_goadmin_baseline(errors)
check_claude_code_entry(errors)
@@ -568,5 +796,132 @@ def main() -> int:
return 0
def run_sync(args: argparse.Namespace) -> int:
"""--verify 依次执行导出、结构检查和一致性校验,替代原来的三条命令。"""
if args.verify:
steps = (
("同步", lambda: run_sync(
argparse.Namespace(check=False, verify=False, config=args.config))),
("结构检查", lambda: run_check(argparse.Namespace(strict=True))),
("一致性校验", lambda: run_sync(
argparse.Namespace(check=True, verify=False, config=args.config))),
)
for name, step in steps:
code = step()
if code != 0:
print(f"错误:{name}未通过,已停止")
return code
return 0
try:
config = load_config(Path(args.config).resolve())
messages = sync_all(config, WikiClient(config), check=args.check)
except WikiDocsError as exc:
print(f"错误:{exc}")
return 1
for message in messages:
print(message)
print("Wiki 镜像检查通过" if args.check else "Wiki 镜像同步完成")
return 0
def run_archive(args: argparse.Namespace) -> int:
short_title = safe_title(args.title)
if not args.issue_number.isdigit():
print("错误:工单号必须是数字")
return 1
if not short_title:
print("错误:标题不能为空")
return 1
try:
config = load_config(Path(args.config).resolve())
page_name = f"Task-{args.issue_number}-{short_title}"
client = WikiClient(config)
if any(item.get("title") == page_name for item in client.list_pages()):
raise WikiDocsError(f"任务归档已经存在:{page_name}")
template = client.get_page("Task-Archive-Template").text
issue_url = (
f"{config.gitea_url}/{config.owner}/{config.repository}/issues/"
f"{args.issue_number}"
)
content = build_archive(
template, args.issue_number, args.title, page_name, issue_url
)
page = client.create_page(
page_name,
content,
f"docs: 创建任务 #{args.issue_number} 归档草稿",
)
except WikiDocsError as exc:
print(f"错误:{exc}")
return 1
print(f"已创建 Wiki:{page.html_url}")
print("未导出本地任务归档;需要时运行 harness.py export")
return 0
def run_export(args: argparse.Namespace) -> int:
try:
config = load_config(Path(args.config).resolve())
messages = export_task_archives(WikiClient(config), export_all=args.all)
except WikiDocsError as exc:
print(f"错误:{exc}")
return 1
for message in messages:
print(message)
print("任务归档全量导出完成" if args.all else "任务归档增量导出完成")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="DevHarness 检查、同步与归档工具")
sub = parser.add_subparsers(dest="command", required=True)
p_check = sub.add_parser("check", help="检查 DevHarness 项目结构")
p_check.add_argument(
"--strict", action="store_true", help="项目档案有占位内容时返回失败"
)
p_check.set_defaults(func=run_check)
p_sync = sub.add_parser("sync", help="从 Gitea Wiki 单向同步核心 docs 镜像")
p_sync.add_argument(
"--check", action="store_true", help="只检查 Wiki 与镜像是否一致,不写文件"
)
p_sync.add_argument(
"--verify",
action="store_true",
help="依次执行导出、check --strict 和一致性校验",
)
p_sync.add_argument(
"--config", default=str(DEFAULT_CONFIG), help="Wiki 页面映射 JSON 文件"
)
p_sync.set_defaults(func=run_sync)
p_archive = sub.add_parser("archive", help="显式在 Gitea Wiki 创建可选任务快照")
p_archive.add_argument("issue_number", help="Gitea 工单号,例如 123")
p_archive.add_argument("title", help="简短任务标题")
p_archive.add_argument(
"--config", default=str(DEFAULT_CONFIG), help="Wiki 映射配置"
)
p_archive.set_defaults(func=run_archive)
p_export = sub.add_parser("export", help="人工按需导出 Gitea Wiki 任务归档")
p_export.add_argument(
"--all",
action="store_true",
help="全量读取全部线上任务归档;默认按 revision 增量",
)
p_export.add_argument(
"--config", default=str(DEFAULT_CONFIG), help="核心 Wiki 映射配置"
)
p_export.set_defaults(func=run_export)
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
-101
View File
@@ -1,101 +0,0 @@
"""先在 Gitea Wiki 创建任务归档,再登记并导出本地镜像。"""
from __future__ import annotations
import argparse
import re
from datetime import date
from pathlib import Path
from wiki_docs import (
DEFAULT_CONFIG,
Mapping,
WikiClient,
WikiDocsError,
append_mapping,
load_config,
sync_all,
)
def safe_title(title: str) -> str:
"""把标题转换为适合 Wiki 页面名和 Windows 文件名的短文本。"""
cleaned = re.sub(r'[<>:"/\\|?*]', "-", title.strip())
cleaned = re.sub(r"\s+", "-", cleaned)
cleaned = re.sub(r"-+", "-", cleaned)
return cleaned.strip(".-")
def build_archive(
template: str,
issue_number: str,
title: str,
page_name: str,
issue_url: str,
) -> str:
content = template.replace("<工单号>", issue_number, 1)
content = content.replace("<标题>", title.strip(), 1)
content = content.replace("YYYY-MM-DD", date.today().isoformat(), 1)
content = content.replace("<链接>", issue_url, 1)
return content.replace("<页面名>", page_name, 1)
def main() -> int:
parser = argparse.ArgumentParser(
description="在 Gitea Wiki 创建任务归档并导出 docs/task 镜像"
)
parser.add_argument("issue_number", help="Gitea 工单号,例如 123")
parser.add_argument("title", help="简短任务标题")
parser.add_argument("--config", default=str(DEFAULT_CONFIG), help="Wiki 映射配置")
args = parser.parse_args()
short_title = safe_title(args.title)
if not args.issue_number.isdigit():
print("错误:工单号必须是数字")
return 1
if not short_title:
print("错误:标题不能为空")
return 1
try:
config = load_config(Path(args.config).resolve())
page_name = f"Task-{args.issue_number}-{short_title}"
local_path = f"docs/task/{args.issue_number}-{short_title}.md"
mapping = Mapping(page=page_name, path=local_path)
if any(
item.page == mapping.page or item.path == mapping.path
for item in config.mappings
):
raise WikiDocsError(f"任务归档已经登记:{page_name}")
client = WikiClient(config)
template = client.get_page("Task-Archive-Template").text
issue_url = (
f"{config.gitea_url}/{config.owner}/{config.repository}/issues/"
f"{args.issue_number}"
)
content = build_archive(
template, args.issue_number, args.title, page_name, issue_url
)
page = client.create_page(
page_name,
content,
f"docs: 创建任务 #{args.issue_number} 归档草稿",
)
append_mapping(config, mapping)
updated_config = load_config(config.path)
messages = sync_all(updated_config, client)
except WikiDocsError as exc:
print(f"错误:{exc}")
return 1
print(f"已创建 Wiki:{page.html_url}")
for message in messages:
print(message)
print(f"已登记镜像:{local_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-33
View File
@@ -1,33 +0,0 @@
"""从 Gitea Wiki 单向导出本地 docs 镜像。"""
from __future__ import annotations
import argparse
from pathlib import Path
from wiki_docs import DEFAULT_CONFIG, WikiClient, WikiDocsError, load_config, sync_all
def main() -> int:
parser = argparse.ArgumentParser(description="从 Gitea Wiki 单向同步 docs 镜像")
parser.add_argument(
"--check", action="store_true", help="只检查 Wiki 与镜像是否一致,不写文件"
)
parser.add_argument(
"--config", default=str(DEFAULT_CONFIG), help="Wiki 页面映射 JSON 文件"
)
args = parser.parse_args()
try:
config = load_config(Path(args.config).resolve())
messages = sync_all(config, WikiClient(config), check=args.check)
except WikiDocsError as exc:
print(f"错误:{exc}")
return 1
for message in messages:
print(message)
print("Wiki 镜像检查通过" if args.check else "Wiki 镜像同步完成")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+34 -6
View File
@@ -210,6 +210,14 @@ class WikiClient:
raise WikiDocsError(
f"Wiki 页面不存在:{page_name};不会自动删除或重命名本地镜像"
)
return self.get_page_from_metadata(metadata, page_name)
def get_page_from_metadata(
self, metadata: dict[str, Any], page_name: str | None = None
) -> WikiPage:
"""使用页面列表元数据读取正文,避免重复获取完整页面列表。"""
resolved_name = page_name or _required_string(metadata, "title")
sub_url = _required_string(metadata, "sub_url")
page = self._request(
"GET",
@@ -218,20 +226,22 @@ class WikiClient:
f"{quote(sub_url, safe='%')}",
)
if not isinstance(page, dict):
raise WikiDocsError(f"Wiki 页面响应格式无效:{page_name}")
raise WikiDocsError(f"Wiki 页面响应格式无效:{resolved_name}")
encoded_content = page.get("content_base64")
if not isinstance(encoded_content, str):
raise WikiDocsError(f"Wiki 页面没有 content_base64:{page_name}")
raise WikiDocsError(f"Wiki 页面没有 content_base64:{resolved_name}")
try:
text = base64.b64decode(encoded_content, validate=True).decode("utf-8")
except (ValueError, UnicodeDecodeError) as exc:
raise WikiDocsError(f"Wiki 页面不是有效的 UTF-8 Markdown:{page_name}") from exc
raise WikiDocsError(
f"Wiki 页面不是有效的 UTF-8 Markdown:{resolved_name}"
) from exc
last_commit = page.get("last_commit")
revision = last_commit.get("sha") if isinstance(last_commit, dict) else None
if not isinstance(revision, str) or not revision:
raise WikiDocsError(f"Wiki 页面缺少 revision:{page_name}")
raise WikiDocsError(f"Wiki 页面缺少 revision:{resolved_name}")
title = page.get("title")
resolved_title = title if isinstance(title, str) and title else page_name
resolved_title = title if isinstance(title, str) and title else resolved_name
html_url = (
f"{self.config.gitea_url}/{quote(self.config.owner, safe='')}/"
f"{quote(self.config.repository, safe='')}/wiki/{quote(sub_url, safe='%')}"
@@ -303,7 +313,14 @@ def render_mirror(page: WikiPage, existing: str | None = None) -> str:
def dirty_mirror_paths(config: Config, root: Path = ROOT) -> list[str]:
paths = [mapping.path for mapping in config.mappings]
return dirty_paths([mapping.path for mapping in config.mappings], root)
def dirty_paths(paths: list[str], root: Path = ROOT) -> list[str]:
"""返回指定路径中已有、修改或未跟踪的工作区条目。"""
if not paths:
return []
result = subprocess.run(
["git", "status", "--porcelain", "--", *paths],
cwd=root,
@@ -329,6 +346,17 @@ def _write_atomic(path: Path, content: str) -> None:
raise
def write_mirror(path: Path, page: WikiPage) -> bool:
"""写入一份 Wiki 镜像;内容无变化时返回 False。"""
existing = path.read_text(encoding="utf-8") if path.is_file() else None
rendered = render_mirror(page, existing)
if existing == rendered:
return False
_write_atomic(path, rendered)
return True
def check_mirror(mapping: Mapping, page: WikiPage, path: Path) -> list[str]:
if not path.is_file():
return [f"缺少镜像:{mapping.path}"]
+24 -4
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Project-Profile.-
wiki_revision: d32f00a86bb3127485b8ad4da8436bf2117352e0
synchronized_at: 2026-08-14T01:06:22Z
wiki_revision: 5e6d18991d69aad15311aae811f35206aa0b5ee6
synchronized_at: 2026-08-27T09:04:26Z
<!-- gitea-wiki-mirror:end -->
# 项目档案
@@ -22,6 +22,18 @@ synchronized_at: 2026-08-14T01:06:22Z
| 当前阶段 | 进入 GoAdmin 源码派生重建:旧实现归档于 `explore`,`main` 为审核基线,`dev` 为集成开发分支 |
| 历史来源 | `D:\OPC\yovision_old`,只读追溯 |
## DevHarness 来源与基线
| 项目 | 内容 |
|---|---|
| 上游仓库 | `https://git.ilapage.cn/OPC/dev_harness` |
| 本次升级前基线 | `f23c2cf81f9792495f696d49e88d79b16cf29810` |
| 当前目标基线 | `4bbacf4d7fb265984396bb5589c544105043fa0b` |
| 升级日期 | 2026-08-27 |
| 识别方式 | 初始导入文件 blob 与上游历史逐项对照;完整 commit 是基线标识 |
YoVision 采用 DevHarness 的共同工作流、统一 `harness.py` 命令、Gitea 工单/Wiki 事实源边界和模板;项目适配保留三项目并行与写路径所有权、`explore/main/dev` 分支治理、Sense/Bell GoAdmin 固定基线、UI 精简复用门禁和现有产品文档结构。升级不得整页覆盖项目事实,也不得复制 DevHarness 自身任务状态。
## 子项目与交付单元
| 单元 | 职责 | 技术栈目标 | 独立构建/测试/发布 | 规则入口 | 共享边界 |
@@ -57,9 +69,9 @@ synchronized_at: 2026-08-14T01:06:22Z
当前初始化阶段可执行:
```powershell
python dev_scripts/check_harness.py --strict
python dev_scripts/harness.py check --strict
python -m unittest discover -s tests -v
python dev_scripts/sync_wiki_docs.py --check
python dev_scripts/harness.py sync --check
git diff --check
```
@@ -112,3 +124,11 @@ Sense、Bell 共用的可复现技术基线记录在仓库根 `goadmin-baseline.
- `main`:用户审核通过的最小/发布基线;禁止直接开发和未经用户明确审核的合并。
- `dev`:集成开发与测试分支;功能分支从 `dev` 派生,并通过 PR 合回 `dev`。
- 只有用户明确审核通过,才能把 `dev` 合入 `main`。
<!-- sense-root-launcher:start -->
## Sense Windows 项目目录启动入口
工单 #90 在 `Sense/start_sense.bat` 提供项目目录快捷入口。它只使用脚本自身路径定位 `Sense/dist/sense-windows-amd64/start-sense.bat`,透传 production、demo 和其他包内启动参数,并保留包内脚本退出码;不读取配置、不自动构建,也不直接启动 Go 或 Node 开发服务。
使用前必须先按 Windows 交付流程生成 `Sense/dist/sense-windows-amd64`。从仓库根目录可运行 `Sense\start_sense.bat`,进入 Sense 目录后可运行 `start_sense.bat` 或 `start_sense.bat demo`。交付包不存在时脚本返回非零并提示执行 `Sense\scripts\build\build-windows.bat`。
<!-- sense-root-launcher:end -->
+134 -26
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Development-Workflow
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Development-Workflow.-
wiki_revision: 2bb60b79073439babfda05140ce813d197de81d9
synchronized_at: 2026-08-14T01:06:22Z
wiki_revision: b377b601b04074b834c4c9f6fbd37434b3d19d22
synchronized_at: 2026-08-27T09:04:39Z
<!-- gitea-wiki-mirror:end -->
# 开发工作流
@@ -11,10 +11,94 @@ synchronized_at: 2026-08-14T01:06:22Z
## 事实来源边界
- Gitea 工单记录任务状态、讨论、阻塞、方案变化、验证和验收。
- Gitea Wiki 保存架构说明、开发规范、操作手册和完成后的任务归档。
- Gitea Wiki 保存长期架构、契约、业务规则、开发规范、操作手册和稳定需求;默认不重复保存单次任务归档。
- Git 保存源码、与特定代码版本强绑定的文档,以及 Wiki 的本地镜像。
- 本地 `docs/` 仅供浏览和审查,不是长期文档编辑入口。
## Gitea 交互与工单最小读取
- 所有 Gitea 工单和 Wiki 的查询、创建、更新、评论、状态变更及关闭操作,优先使用项目已配置的 Gitea MCP。
- MCP 不可用或不支持所需操作时才回退 Gitea API,并在当前工单记录回退原因;初始化阶段尚无工单时记录到初始化工单草稿,建单后补回。凭据只从环境或 MCP 安全配置读取。
- 首次接手任务时读取工单确认基线和完成当前判断所需的评论,不因节省 Token 跳过范围、依赖、安全、验收或重要变更。
- 同一任务、同一会话且关键前提未变化时,复用仍有效的工单事实,优先关注当前状态、最新评论和首个未完成步骤,不重复分析已经确认且仍有效的内容。
- 会话、代码、配置、依赖、凭据、远端状态或关键前提变化,任务基线不清楚,或最新评论声明历史需求、方案、范围、风险或验收发生变化时,重新读取必要历史;无法判断影响范围时读取完整工单。
- 连接器不支持评论分页或增量读取时允许读取完整工单,但不得把“已读取全文”误当成需要重新分析全部历史,也不得为规避完整读取而新增本地工单、缓存或第二事实来源。
- 正确性、安全规则和已确认范围优先于 Token 优化;读取边界存在不确定时补读必要证据。
## 新项目 Wiki 初始化门禁
从 DevHarness 创建新项目时,本地 `docs/` 即使完整存在,也只能证明模板镜像存在,不能证明新项目的线上 Wiki 已初始化。开始任何产品代码前必须完成以下闭环:
1. 先创建 Gitea 远端仓库并启用 Wiki,再把 `wiki-docs.json` 指向该仓库。
2. 优先使用项目已配置的 Gitea MCP 查询 Wiki 页面;MCP 不可用或不支持所需写操作时,才使用 Gitea API,并在初始化工单记录回退原因。凭据只从环境或 MCP 安全配置读取。
3. 查询线上页面列表;没有 `Home` 时先创建 `Home`,回读正文并记录 revision,然后再创建或更新其他核心映射页面。
4. 每个核心页面写入后都要在线回读;页面可读取且取得 revision 才算创建成功,不能用本地 `docs/` 文件替代这项证据。
5. 运行 `python dev_scripts/harness.py sync --verify`。任一映射页面不存在、无法回读或镜像不一致时,停止产品编码并完成初始化。
Gitea 暂时不可用时可以准备工单和 Wiki 草稿,但不得把本地草稿宣称为线上事实,也不得绕过此门禁开始产品功能开发。
## 工单与设计证据双门禁
开始正式实现前依次判断“是否需要工单”和“需要什么设计证据”。原型确认不能代替方案、工单、安全检查或技术验证;工单存在也不能绕过原型确认。
### 先判断是否需要工单
只有纯界面显示文案同时满足以下全部条件时,才可以免工单、免原型:
- 只修改用户看到的组件显示名称、按钮文字、标题、提示语或其他文案;
- 不改变业务含义、操作流程、权限、状态、接口、数据和验收结果;
- 不涉及法律条款、安全提示、支付、金额、单位或其他高风险含义;
- 不修改国际化键、代码组件名、类名、变量、API 字段、数据库字段或其他程序标识符;
- 不造成明显布局、截断、换行、可访问性或支持平台问题;
- 有任何不确定时不使用豁免。
豁免修改只执行与受影响界面相称的最小检查,确认文字正确且没有明显布局或可访问性问题,然后停止。只要任一条件不满足,或涉及用户行为、样式布局、交互和导航,就建立单元任务工单。
### 再判断设计证据
| 修改类型 | 最低设计证据 | 正式编码门禁 |
|---|---|---|
| 纯显示文案且满足全部豁免条件 | 无原型 | 完成最小界面检查即可 |
| 现有界面的小范围样式或布局调整 | 标注截图或低保真线框图;没有设计不确定性时说明复用的现有规范 | 工单确认设计证据后编码 |
| 新组件但复用现有设计体系 | 组件状态、错误和边界说明;按需提供低保真图 | 工单确认状态和复用边界后编码 |
| 新页面、独立用户功能、重大交互或导航变化 | Quant-UX 或其他合适工具制作的可审阅原型 | 用户确认原型和文字需求后才能编写生产代码 |
| 后端、接口、数据处理或定时任务 | 架构、API、数据、状态或流程设计 | 用户确认技术方案后编码,不制作无意义的 UI 原型 |
| 恢复既有确认行为的 Bug | 原设计、已确认截图、复现步骤或现有验收证据 | 确认是恢复而不是改变行为后修复 |
采用最低成本、足以让用户确认的证据,不为了形式制作高保真原型。草稿原型可以用于需求讨论;草稿需要写入 Git/Wiki、多人协作或单独实施时,应建立设计任务。草稿原型和临时技术验证都不能直接作为生产实现。
### 线上原型审核与按需导出
新页面、独立用户功能、重大交互或导航变化使用 Quant-UX 或等效工具形成待审核版本后,默认直接通过线上原型审核,不要求每次导出本地 HTML:
- 可编辑设计源保存在 Quant-UX 或原设计工具;工单和 Wiki 只保存链接、版本与确认记录,不复制为第二份可编辑事实来源。
- 线上链接必须能被确认人访问,并能通过版本、revision、复制版本或确认日期识别本次审核对象;无法访问或无法区分版本时停止审核,等待用户确认等效方案。
- 提交审核前检查主要页面、流程、状态和交互可访问,并删除令牌、真实账号、个人信息和生产数据。
- 页面结构、主要流程、状态、权限、异常处理或验收结果变化时,更新线上原型并重新确认;不得用旧确认覆盖新版本。
- 纯显示文案、小范围现有 UI 调整、非 UI 需求和恢复既有行为的 Bug 仍只使用双门禁表规定的最低证据,不强制建立完整线上原型。
只有用户明确发出 `导出原型 #N`、`导出全部原型`,或项目专用规则明确要求离线交付时,才导出本地 HTML:
- 指定工单的快照放入 `prototypes/<工单号>/<版本>/index.html`;全部导出时也按工单和版本分目录,先在工单明确导出范围。
- 图片、样式、脚本和字体使用版本目录内的相对路径;需要网络资源才能显示时不得标记为可离线浏览。
- 已确认的本地快照不得原位覆盖;新版本使用新目录,已有快照继续作为历史审核证据。
- 导出后检查入口、主要交互和资源完整性;浏览器限制直接打开时,在工单记录最小本地静态服务命令和访问地址,不新增项目专用服务脚本。
- 导出指令只生成或更新请求范围内的快照并报告结果,不自动提交;用户未明确要求时不得顺带导出其他原型。
- 设计工具无法生成用户要求的可用 HTML 时,在工单记录限制并停止该导出或离线交付,等待用户确认等效方案;线上原型仍可访问且版本明确时,不因此阻塞线上审核。
### 记录和重新确认
需要设计证据的工单必须记录:
- 原型或设计的线上链接、对应事实来源,以及链接可访问性;
- 版本、revision、复制版本或确认日期,以及审核版本的识别方式;
- 状态:无、草稿、已确认或已废弃;
- 只有显式导出时才记录本地 HTML 路径、版本和资源检查结果;
- 确认人和确认时间;
- 本次确认覆盖的页面、组件、流程和边界;
- 不需要 UI 原型时采用的技术设计,或无需任何原型的原因。
页面结构、主要流程、状态、权限、异常处理或验收结果变化时,先更新原型或文字需求并重新确认,再继续正式编码。只读技术检查可以在确认前进行;确需可行性代码验证时,必须由用户明确同意,隔离为不可进入生产的技术验证,不得悄悄扩展成正式实现。
## 一次任务怎样完成
### 1. 讨论
@@ -67,35 +151,50 @@ Agent 检查分支和工作区,只修改工单范围内的文件。发现新
- Git 提交哈希;
- 相关 Wiki 页面及 revision。
长期文档遵循唯一顺序:
工单正文保存用户确认的任务基线;根因、范围、方案、风险或阻塞发生重要变化时追加评论。完成实现后用一条评论集中记录最终差异、测试、未验证内容、提交哈希和长期文档影响,保留可追溯时间线,不在 Wiki 重抄同一份任务结果。
只有长期事实发生变化时才执行核心文档闭环:
```text
修改 Wiki → 读取确认 → 导出 docs → 校验差异 → 提交镜像
修改 Wiki → 读取确认 → 导出核心 docs → 校验差异 → 提交镜像
```
不得先编辑 `docs/` 再反向覆盖 Wiki。
没有长期文档影响时,在工单写明原因并跳过 Wiki 更新和核心镜像同步;默认任务流程不创建任务归档。长期文档仍不得先编辑本地镜像再反向覆盖 Wiki。
### 4. 待验收
实现和测试完成后,Agent 提交实现代码并将工单更新为“待验收”。用户验收前工单保持开启。
### 5. 归档和关闭
### 5. 待验收和关闭
使用以下命令在 Wiki 创建任务归档页、登记显式映射并导出本地镜像:
实现、必要测试和提交完成后,在工单追加一条最终证据评论并保持“待验收”。评论至少记录最终差异、测试结果、未验证内容、提交哈希,以及长期 Wiki 页面和 revision,或“无长期文档影响”及原因。
用户明确验收通过后:
1. 在工单追加验收时间和结论,不重复抄写已有测试与提交证据;
2. 关闭单元工单并勾选所属 MVP/Epic 子任务;
3. 只有验收结论改变长期需求状态或其他 Wiki 事实时,才更新 Wiki 并执行同步闭环;没有变化时不重复检查 Wiki;
4. 默认不创建或导出任务归档。
任务归档只保留为显式兼容能力。只有用户明确要求专项快照,或项目专用规则明确要求时才运行:
```powershell
python dev_scripts/new_task_archive.py 123 "修复登录超时"
python dev_scripts/harness.py archive 123 "修复登录超时"
python dev_scripts/harness.py export # 增量导出已有归档
python dev_scripts/harness.py export --all # 全量导出已有归档
```
归档内容以 Wiki 页面为主源;本地 `docs/task/<编号>-<短标题>.md` 是镜像。归档镜像单独提交,再把 Wiki 页面、revision、镜像路径和提交哈希写回工单。用户明确验收通过后,关闭单元工单并勾选父工单中的任务。
可选归档不得成为第二个日常维护入口;创建时以工单中的最终证据为来源,并记录工单链接。既有 Wiki 归档和 `docs/task/` 快照不自动删除、重命名或补齐。
## 文档同步规则
- 映射保存在 `wiki-docs.json`,每个 Wiki 页面对应唯一仓库路径。
- 同步脚本只实现 Wiki → `docs/`,不提供反向同步。
- 核心页面映射保存在 `wiki-docs.json`;普通同步只处理这些核心长期文档。
- 可选任务归档不逐页登记映射;显式执行归档导出时,工具根据 `Task-<编号>-<标题>` 动态发现,已有镜像优先按镜像头匹配原页面。
- 所有同步和导出只实现 Wiki → `docs/`,不提供反向同步。
- 镜像头必须记录页面名、页面地址、revision 和同步时间。
- 已跟踪镜像存在未提交改动时,同步必须停止;确认改动来源后再处理。
- `--check` 只检查,不写文件;页面缺失、revision 不一致或正文不一致均失败。
- 核心同步的 `--check` 只检查核心镜像,不要求线上任务归档全部存在于本地。
- 已经导出的任务镜像仍必须具有来源页面、revision 和同步时间,并通过 Harness 格式检查。
- 页面删除和重命名不会自动传播,必须先更新工单并人工确认映射变化。
- Wiki 更新成功而导出失败时,在工单记录部分完成状态,不得把任务标为完成。
- 与具体代码版本强绑定的接口或迁移资料可直接随代码维护,但必须在 Wiki 提供入口或适用版本说明。
@@ -129,6 +228,8 @@ python dev_scripts/new_task_archive.py 123 "修复登录超时"
- 业务规则、安全边界或权限变化;
- 日志位置、错误定位或常见处理方式变化。
部署命令的落点:有常驻服务的项目更新自己的 `Deployment-and-Operations` 页面(由[部署文档模板](Deployment-Template.-)复制建立);没有常驻服务的项目在工单记录“无部署文档影响”及原因,不要创建空的部署页。
普通内部重构如果入口、行为、配置和验证方式均未改变,可以记录“不影响长期文档”及原因。
## 需求记录与流转
@@ -149,20 +250,20 @@ python dev_scripts/new_task_archive.py 123 "修复登录超时"
| 关键原始需求、确认后的单次任务需求 | Gitea 单元任务工单 | 无 |
| 讨论、决定和需求变化 | Gitea 工单正文或评论 | 无 |
| 长期有效的产品需求、业务规则和系统边界 | 对应 Gitea Wiki 主题页 | `docs/` |
| 完成后的实现、验证和遗留问题 | Wiki 任务归档 | `docs/task/` |
| 完成后的实现、验证、遗留问题和验收 | Gitea 单元任务工单正文与评论 | 无;用户明确要求时可创建专项 Wiki 快照 |
任务产生长期结论时,先更新对应 Wiki 主题页,再导出本地镜像。Gitea 工单全文不导出到仓库,避免形成第二份任务过程记录。
## 稳定文档与任务归档
## 稳定文档与可选历史快照
- Home、项目档案、代码地图、业务规则、开发验证、常见修改和故障排查描述项目现在怎样工作。
- 工单和任务归档解释某次为什么修改、实际改了什么以及如何验证。
- 新人先读稳定主题页,只有追查历史原因时才读任务归档。
- 任务产生的长期结论必须合并到主题页,不能只留在归档。
- 工单正文和评论解释某次为什么修改、实际改了什么、如何验证以及怎样验收。
- 新人先读稳定主题页,只有追查历史原因时才读工单;可选 Wiki 快照和本地任务快照只是专项或历史兼容资料,不是默认事实来源。
- 任务产生的长期结论必须合并到对应主题页,不能只留在工单或可选快照。
## 效率与范围控制
本节用于减少无关工作和重复检查,不得削弱安全规则、已确认方案、工单范围、必要测试、Wiki 同步、Git 提交和验收归档要求。
本节用于减少无关工作和重复检查,不得削弱安全规则、已确认方案、工单范围、必要测试、必要的长期文档同步、Git 提交和人工验收要求。
### 严格控制范围
@@ -200,20 +301,26 @@ python dev_scripts/new_task_archive.py 123 "修复登录超时"
|---|---|---|
| `只分析` | 只读检查需求、代码、日志和文档,区分事实与假设并给出方案 | 输出方案并等待确认;不建单、不修改 |
| `建工单` | 根据已经确认的方案创建单元任务工单 | 工单创建并记录完成;不修改代码 |
| `执行工单 #N` | 读取工单和前置依赖,实施、测试、提交、更新 Wiki、导出镜像、推送并回写证据 | 工单保持“待验收” |
| `执行工单 #N` | 读取工单和前置依赖,实施、测试、提交并回写证据;仅有长期文档影响时更新 Wiki 和镜像 | 工单保持“待验收” |
| `建工单并做` | 依次执行“建工单”和“执行工单”;`建工单,做`、`建工单,做` 含义相同 | 工单保持“待验收” |
| `继续工单 #N` | 核对工单、Git 和 Wiki 证据,从首个未完成步骤继续,不重复仍然有效的检查 | 到达该工单当前流程的停止条件 |
| `检查工单 #N` | 只读对照范围、验收标准、测试和证据,报告通过项、缺失项及未验证部分 | 输出检查报告;不自动修复 |
| `同步文档` | 读取 Wiki,导出已映射的 `docs/` 镜像并检查一致性 | 显示结果和差异;不修改 Wiki、不自动提交 |
| `#N 验收通过` | 记录明确验收,更新 Wiki 归档为“已完成”,导出并提交镜像,推送、同步父工单并关闭任务 | 工单“已完成”并关闭 |
| `同步文档` | 读取 Wiki,导出核心长期文档镜像并检查一致性,不处理任务归档 | 显示结果和差异;不修改 Wiki、不自动提交 |
| `导出原型 #N` | 人工触发导出指定工单已确认的原型版本;按工单和版本写入 `prototypes/` | 显示路径和检查结果;不扩展范围、不自动提交 |
| `导出全部原型` | 人工触发导出当前项目明确范围内的全部已确认原型 | 显示导出范围和结果;不自动提交 |
| `导出任务归档` | 人工触发增量导出,只写入新增或 revision 已变化的任务归档 | 显示导出或跳过结果;不删除本地文件、不自动提交 |
| `导出全部任务归档` | 人工触发全量读取并导出线上全部任务归档 | 显示导出结果;不删除本地文件、不自动提交 |
| `#N 验收通过` | 在工单追加验收结论,按需更新真实变化的长期 Wiki,推送、同步父工单并关闭任务;不创建或导出任务归档 | 工单“已完成”并关闭 |
补充边界:
- 方案未确认时,`建工单`、`建工单并做` 和 `执行工单 #N` 不得绕过确认;Agent 应停在方案确认。
- 前置依赖未满足且不允许并行时,实施类指令停在“待实施”。
- `#N 验收通过` 必须来自用户明确表达;其他快捷指令不得关闭待验收工单。
- `同步文档` 发现镜像有未提交改动时停止,不覆盖现有修改。
- Gitea 工单保留讨论和过程,不把工单全文导出到本地;`docs/task/` 只保存 Wiki 最终任务归档的镜像。
- `同步文档` 或任务归档导出发现目标镜像有未提交改动时停止,不覆盖现有修改。
- `导出原型 #N` 和 `导出全部原型` 必须由用户明确提出或项目专用规则明确要求;其他指令不隐式导出原型。
- `导出任务归档` 和 `导出全部任务归档` 必须由用户明确提出,其他快捷指令不隐式执行。
- Gitea 工单是单次任务唯一事实来源,不导出全文;`docs/task/` 只保存人工明确要求的专项或历史兼容快照。
## 什么时候重新确认方案
@@ -235,8 +342,9 @@ python dev_scripts/new_task_archive.py 123 "修复登录超时"
| 讨论过程和临时方案 | 是 | 否 | 否 |
| 实施进度和阻塞 | 是 | 否 | 否 |
| 长期有效的最终方案 | 链接 | 是 | 镜像 |
| 测试结果与未验证内容 | 是 | 任务归档 | 镜像 |
| 提交哈希 | 是 | 任务归档 | 镜像 |
| 测试结果与未验证内容 | 是 | 否 | 否 |
| 提交哈希和验收结论 | 是 | 否 | 否 |
| 用户明确要求的任务专项快照 | 提供来源 | 可选 | 可选导出 |
| 与具体代码版本绑定的说明 | 可链接 | 提供入口 | 是 |
## go-admin / go-admin-ui 开发约束
+25 -5
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
wiki_revision: 0ba2909431bd04320a9b31121126b59b1824e3dc
synchronized_at: 2026-08-15T01:13:02Z
wiki_revision: ee5b5507bf8a8a834e07f223f1a90c2f67a70d6f
synchronized_at: 2026-08-27T09:14:31Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -35,9 +35,9 @@ Bell 也可接收合成事件、传感器平台或第三方系统事件;Sense
| Brain 推理 | `Brain/` | `Brain/AGENTS.md`;后续包入口 | `Brain/` 内测试与契约测试 | 高:模型、GPU、隐私、事件语义 |
| Bell 产品 | `Bell/` | `Bell/AGENTS.md`;待 GoAdmin 派生工单建立 README/入口 | 待新骨架建立 | 高:GoAdmin 派生、认证、事件与告警状态机 |
| 共享契约 | `contracts/` | `contracts/AGENTS.md` | 三端消费者/生产者测试 | 高:兼容性与跨项目影响 |
| Harness | `dev_scripts/` | `check_harness.py` | `tests/` | 中 |
| Harness | `dev_scripts/` | `harness.py check --strict` | `tests/` | 中 |
| 工单模板 | `.gitea/issue_template/` | `task.md` | Harness 严格检查 | 中 |
| Wiki 镜像 | `docs/` | `docs/README.md` | `sync_wiki_docs.py --check` | 低;禁止直接编辑 |
| Wiki 镜像 | `docs/` | `docs/README.md` | `harness.py sync --check` | 低;禁止直接编辑 |
旧 Sense、Bell 入口仅存在于 `explore` 快照,不是 `main` / `dev` 当前代码地图。三个项目的新入口必须随新骨架工单建立;不得把旧自研基础框架复制回 `dev`。
@@ -85,9 +85,11 @@ Sense/Brain 生成事件
Sense 已由工单 #61 从冻结 go-admin/go-admin-ui 源码建立:后端入口为 `Sense/server/main.go`,前端入口为 `Sense/ui/src/main.js`,来源和完整 commit 记录在 `Sense/LICENSES/SOURCES.md`。后端保留 Cobra、Gin、GORM、Casbin、JWT 和迁移体系,前端保留 Router、Store、API、Layout、动态菜单与权限指令。上游作业、代码生成、监控等源码暂留用于升级追溯,但路由及用户入口不启用。
Sense 业务菜单必须遵循冻结 GoAdmin 的路由树:顶层目录使用 `MenuType=M`、`Component=Layout`,设备管理、视频接入、视频服务、实时监看、区域与警戒线使用相对路径作为 `MenuType=C` 子页面,操作权限继续作为页面下的 `MenuType=F` 节点。这样动态路由只替换 Layout 的右侧内容区,左侧导航、顶部栏和标签页始终保留;旧数据库由 `Sense/server/cmd/migrate/migration/version/2026081623100_sense_layout.go` 新增迁移修正父子关系和完整 `paths`,页面 URL 与权限标识不变。
工单 #64 在该基线上重建 Sense 独立身份能力:`Sense/server/app/admin/apis/identity_bootstrap.go` 提供受外部高熵令牌保护的一次性首位管理员初始化,数据库迁移固定建立 `admin`、`implementation_operator`、`site_admin`、`viewer` 四个角色及最小 Casbin 权限;前端继续复用 go-admin-ui 动态菜单、权限按钮、请求封装与 Layout。仓库仍不提供默认账号、默认密码或可用 JWT 密钥。
Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Token`,后端仅接受标准 Authorization Bearer 或独立的 `sense_session` Cookie,不接受查询参数令牌,也不得与 Bell 共享 JWT 密钥、Cookie 或账户库。登录成功/失败、登出、密码变更和鉴权拒绝写入身份审计;审计内容必须剔除密码、令牌、Cookie、验证码和其他秘密。配置、接口管理等非产品必要路由不注册,即使管理员直接调用也返回 404。
Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Token`,后端仅接受标准 Authorization Bearer 或独立的 `sense_session` Cookie,不接受查询参数令牌,也不得与 Bell 共享 JWT 密钥、Cookie 或账户库。Sense 默认登录有效期为固定 30 天:后端 JWT 使用 2,592,000 秒,前端 Token Cookie 使用 30 天持久化期限;不做滑动续期、Refresh Token 或服务端单 Token 撤销,退出只删除本机 Cookie。部署新版本后已有 Token 不会自动延长,用户必须重新登录取得新有效期。登录成功/失败、登出、密码变更和鉴权拒绝写入身份审计;审计内容必须剔除密码、令牌、Cookie、验证码和其他秘密。配置管理 CRUD、configKey、set-config、接口管理等非产品必要路由不注册,即使管理员直接调用也返回 404。登录外壳必需的匿名只读 `GET /api/v1/app-config` 是唯一例外:它复用 GoAdmin `SysConfig.Get2SysApp`,只投影标记为前端可见的配置,不提供写入或管理能力。
工单 #65 新增设备台账入口:后端按 `models → dto → service → api → router` 分层位于 `Sense/server/app/sense/device/`,管理路由在 `Sense/server/app/admin/router/sense_device.go`,前端页面位于 `Sense/ui/src/views/sense/device/index.vue`。设备凭据由 `Sense/server/app/sense/credential/` 独立存储和 AES-256-GCM 加密,HTTP 只返回是否已配置,不提供凭据读取接口。
@@ -130,3 +132,21 @@ ONVIF 支持 Basic 与 MD5/SHA-256 Digest challenge,Profile 与无凭据 Strea
认证 API 为 `/api/v1/area/configurations` 及其版本子资源,接入 GoAdmin JWT、Casbin、动态菜单和操作权限。API 只返回设备/Profile 展示字段、规格、归一化坐标和版本信息,不返回 RTSP URI、摄像头凭据或 MediaMTX 内部路径。
<!-- sense-area:end -->
<!-- sense-windows-delivery:start -->
## Sense Windows 交付运行链
工单 #70 在 GoAdmin 派生入口上建立 Windows amd64 交付链。构建入口为 `Sense/scripts/build/build-windows.ps1`;运行包入口为 `start-sense.bat`,它先调用现有 `sense.exe migrate -c data\runtime\settings.yml`,迁移成功后再调用 `sense.exe server -c ...`。前端生产构建复制到包内 `web/`,后端只在显式配置 `SENSE_WEB_ROOT` 时提供同源静态资源和 SPA fallback,API 与健康检查不会被 fallback 覆盖。
运行脚本将 `config\sense.env` 当作数据解析,只接受白名单 `SENSE_*` 字段,不执行文件内容;同名非空进程环境变量优先。生成的 `data\runtime\settings.yml` 含运行秘密,只能留在部署目录。production 固定使用 PostgreSQL,要求至少 32 字符 JWT secret,且 MediaMTX 只能为 `managed` 或 `external`;`managed` 在 HTTP 启动前拉起包内二进制,`external` 在 HTTP 启动前确认回环 Control API 可达。Demo 使用独立配置和名称含 demo/test 的隔离数据库,默认禁用 MediaMTX,绝不回退 production 数据库。
交付包同时提供检查、迁移、管理员初始化、停止、备份和恢复入口。停止脚本只操作当前包且监听配置端口的 Sense 进程树;备份密码只进入子进程环境;恢复要求数据库名和二次短语确认。包不包含 PostgreSQL、生产数据、默认管理员、默认密码或客户秘密。
<!-- sense-windows-delivery:end -->
## DevHarness 执行路径
- `dev_scripts/harness.py check --strict` 检查核心结构、YoVision GoAdmin 基线和项目规则。
- `dev_scripts/harness.py sync` 只从 Gitea Wiki 导出 `wiki-docs.json` 映射的核心镜像;`sync --check` 只检查一致性。
- `archive`、`export` 和 `export --all` 只在人工明确要求时处理可选任务快照,任务快照不进入核心映射。
- `dev_scripts/wiki_docs.py` 负责 Gitea Wiki 读取、revision、镜像头、脏文件保护和安全路径校验。
+10 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
wiki_revision: 2bcd90c508180eb9a2f9fe37b62115d7d9207e18
synchronized_at: 2026-08-15T01:13:04Z
wiki_revision: 96fca4d2aa411725282bd3911c133efc75f7146a
synchronized_at: 2026-08-27T09:04:59Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -146,3 +146,11 @@ synchronized_at: 2026-08-15T01:13:04Z
- 鼠标可点击/拖动顶点;键盘必须能添加、移动和删除顶点。错误在绘制区域附近以可被辅助技术感知的文字给出,并提供撤销、清空和未保存关闭确认。
- 区域配置是 Sense 内部事实;#69 不发布 Brain 契约。后续 Sense→Brain 配置协议必须由独立协调工单从当前版本投影生成,不能共享数据库模型。
<!-- sense-area:end -->
## DevHarness 任务证据边界
- Gitea 工单正文与评论是单次任务的需求、变化、实现、测试、提交和验收事实来源。
- Gitea Wiki 只保存长期有效的项目事实;核心页面由 `wiki-docs.json` 显式映射。
- `docs/task/` 是按人工明确要求形成的专项或历史兼容快照,可能不完整或不是最新状态,不得替代工单。
- 默认不创建、导出或更新任务快照;导出过程不自动删除本地历史文件。
+72 -9
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
wiki_revision: 31cad04ab68ee653f2ec3ca6d7297a6bef768f54
synchronized_at: 2026-08-15T01:13:07Z
wiki_revision: 7ede95108304cec08572d9da5ce1f41116e4970d
synchronized_at: 2026-08-27T09:05:09Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -19,6 +19,12 @@ synchronized_at: 2026-08-15T01:13:07Z
Sense/Bell 的 Go、Node 与 pnpm 基线已冻结并记录于下文;Brain 的 Python/CUDA 以及 PostgreSQL、MediaMTX 精确版本仍将在对应骨架工单冻结。旧仓库环境不自动成为新项目事实。
## Windows PowerShell 与 UTF-8
- Windows 环境优先使用当前已配置的 PowerShell;可选择时优先 PowerShell 7 `pwsh.exe`,不为设置编码重复启动一层 PowerShell。
- 文本文件读写在命令支持时显式指定 UTF-8。文件解码与控制台输出分别处理,只有出现真实乱码或已知宿主非 UTF-8 时才调整当前进程输出编码或 Python UTF-8 环境变量。
- 不默认使用 `-ExecutionPolicy Bypass`。只有可信脚本确实被策略阻止且没有更小替代方案时,才对该次进程使用并在工单记录原因。
## 第一次运行
1. 检查工作区:
@@ -32,7 +38,7 @@ Sense/Bell 的 Go、Node 与 pnpm 基线已冻结并记录于下文;Brain 的
2. 检查 Harness:
```powershell
python dev_scripts/check_harness.py --strict
python dev_scripts/harness.py check --strict
```
预期:输出“DevHarness 检查通过”。
@@ -48,7 +54,7 @@ Sense/Bell 的 Go、Node 与 pnpm 基线已冻结并记录于下文;Brain 的
4. 对照 Wiki:
```powershell
python dev_scripts/sync_wiki_docs.py --check
python dev_scripts/harness.py sync --check
```
预期:全部映射一致。
@@ -81,8 +87,8 @@ Sense/Bell 的 Go、Node 与 pnpm 基线已冻结并记录于下文;Brain 的
```powershell
python -m unittest discover -s tests -v
python dev_scripts/check_harness.py --strict
python dev_scripts/sync_wiki_docs.py --check
python dev_scripts/harness.py check --strict
python dev_scripts/harness.py sync --check
git diff --check
git status --short
```
@@ -146,7 +152,9 @@ Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:<端口>/api/v1/bootstrap"
Remove-Variable bootstrapToken, bootstrapBody
```
初始化成功后停止服务,从启动环境中执行 `Remove-Item Env:SENSE_BOOTSTRAP_TOKEN`,再按正常生产方式启动。已有任一用户时初始化接口会拒绝请求。生产登录需要先调用验证码接口并提交验证码;自动化集成验证不得通过关闭生产安全约束来冒充生产结果。
初始化成功后停止服务,从启动环境中执行 `Remove-Item Env:SENSE_BOOTSTRAP_TOKEN`,再按正常生产方式启动。已有任一用户时初始化接口会拒绝请求。Sense 在 production、test、dev 模式均只提交账号和密码,不显示、不请求也不校验验证码;`/api/v1/captcha` 暂时保留作上游兼容接口,但登录页和登录 API 不依赖它。登录成功、错误密码和未认证拒绝仍必须写入脱敏身份审计,密码继续执行 6–72 字节策略。
Sense 默认登录有效期为固定 30 天。仓库配置和 Windows 运行脚本生成的 `jwt.timeout` 均为 `2592000` 秒,前端 `Sense-Admin-Token` Cookie 使用 30 天持久化期限。更新该版本后必须重新登录,已有 Token 不会自动延长。该有效期不是滑动续期;退出登录会删除本机 Cookie,但当前无状态 JWT 架构不提供服务端单 Token 撤销。如需立即使全部已签发 Token 失效,应在受控维护窗口轮换仓库外 JWT secret,并明确通知所有用户重新登录。
身份回归至少覆盖:admin 可管理账户及查看审计;implementation_operator 只能查看实施所需日志和字典支撑数据;site_admin 可维护账户并读取角色、部门、岗位、字典,但不能修改角色或菜单;viewer 不能访问管理接口。还要验证配置/接口管理路由返回 404、短密码被拒绝、6 位全小写密码可用,以及登录/登出/改密/拒绝审计中不含密码、令牌、Cookie 或验证码。身份审计直接写入 PostgreSQL,不依赖通用操作日志数据库开关。
@@ -230,9 +238,38 @@ corepack pnpm@9.15.1 build:prod
<!-- bell-runtime:end -->
<!-- sense-windows-package:start -->
## Sense Windows 打包状态
## Sense Windows 打包与验证
旧 Sense Windows 包脚本只属于 `explore` 快照。新的打包命令必须在 GoAdmin 派生骨架和业务迁移完成后由独立工单重新建立、验证和记录。
在仓库根目录使用冻结工具链构建:
```powershell
Sense\scripts\build\build-windows.ps1 -MediaMTXPath D:\approved\mediamtx.exe
```
构建脚本严格检查 Go 1.26.5、Node 22.22.1 和 pnpm 9.15.1,执行前端生产构建与 Windows 后端构建,并生成 `Sense\dist\sense-windows-amd64\` 和同名 ZIP。未传 `-MediaMTXPath` 时只生成占位说明,交付前必须另外提供已审核的 Windows amd64 MediaMTX。构建末尾会执行包审计,并清理源码目录的 `Sense/ui/node_modules` 与 `Sense/ui/dist`。
提交前验证:
```powershell
cd Sense\server
go test ./...
go vet ./...
go build ./...
go test -race ./app/sense/media ./cmd/api
cd ..\..
Sense\scripts\build\test-package.ps1 -PackageRoot Sense\dist\sense-windows-amd64
```
包内验证从解压目录执行:
```bat
check-sense.bat
start-sense.bat
stop-sense.bat
```
检查项至少覆盖配置解析与进程环境优先级、特殊字符不被执行、production/demo 数据库隔离、迁移失败不启动服务、首页 SPA fallback、`/healthz`、MediaMTX Control API、包外工作目录启动与停止、PostgreSQL custom-format 备份及恢复到独立数据库。真实摄像机、目标客户数据库账号、目标浏览器与干净客户机器仍须在授权交付环境验收。
<!-- sense-windows-package:end -->
@@ -282,3 +319,29 @@ corepack pnpm@9.15.1 build:prod
浏览器 smoke 至少覆盖:鼠标添加和拖动顶点;键盘 Enter 添加、方向键移动、Delete 删除;错误文字可见并具有 aria-live/alert 语义;刷新后版本、启停和重新校准状态仍可追溯。真实摄像机校准只使用明确授权设备,不记录地址、URI、凭据或视频内容。Brain、Bell 不启动时必须能独立保存、读取和预览。
<!-- sense-area:end -->
<!-- sense-supervisor:start -->
## Sense 本机 Supervisor 托管
本机开发/演示环境可由 `D:\supervisor` 托管已经构建的 Sense Windows 交付包。实例配置位于仓库外的 `D:\supervisor\programs\yovision.conf`,实例名为 `yovision-sense`;工作目录固定为 `D:\OPC\yovision\Sense\dist\sense-windows-amd64`。
Supervisor 配置只调用包内 `scripts\runtime\start-sense.ps1`,运行参数继续从包内 `config\sense.env` 读取。不得把数据库连接、JWT 密钥、摄像头凭据或其他秘密复制到 Supervisor 配置或工单。
常用命令:
```powershell
D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf status yovision-sense
D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf restart yovision-sense
D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf stop yovision-sense
D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf start yovision-sense
Get-Content D:\supervisor\logs\yovision-sense.log -Tail 100
```
新增或修改 `programs/*.conf` 后执行:
```powershell
D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf reload
```
当前 Go Supervisor 的 `reload` 会重新读取独立配置;实际受影响实例必须以命令输出和 reload 前后 PID 为准。切换托管前先停止占用 Sense 端口的非 Supervisor 实例,防止自动启动进入 Backoff。验证至少包含 Supervisor 状态为 Running、`http://127.0.0.1:18080/health` 与首页返回 200,以及受控重启后 Sense 和受管 MediaMTX PID 均更新。
<!-- sense-supervisor:end -->
+6 -6
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Common-Changes
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Common-Changes.-
wiki_revision: edfcd6dd2cd254d31e88e980558e467b5fe758a3
synchronized_at: 2026-08-11T10:30:43Z
wiki_revision: becaa9f08fd07397da516c31e3d93362626df050
synchronized_at: 2026-08-27T09:05:21Z
<!-- gitea-wiki-mirror:end -->
# 常见修改指南
@@ -20,15 +20,15 @@ synchronized_at: 2026-08-11T10:30:43Z
| 中风险 | API、配置、依赖、跨模块逻辑、数据结构 | 由 Agent 实现,程序员理解差异并执行验证 |
| 高风险 | 权限、安全、并发、迁移、支付、删除数据、不可逆操作 | 停止修改,由 Agent 分析并等待人工确认 |
“代码行数少”不等于低风险。
“代码行数少”不等于低风险。风险等级只决定由谁实施和验证,不改变建单门禁:新功能、缺陷修复、重构及行为变化仍需工单;只有 AGENTS.md 明确列出的非行为修改和纯显示文案豁免可以直接提交。
## 修改 Wiki 文案
1. 在相关工单确认目标。
2. 读取线上 Wiki 页面和当前 revision。
3. 修改线上 Wiki,不直接编辑 `docs/`。
4. 运行 `python dev_scripts/sync_wiki_docs.py`。
5. 运行 `python dev_scripts/sync_wiki_docs.py --check`。
4. 运行 `python dev_scripts/harness.py sync`。
5. 运行 `python dev_scripts/harness.py sync --check`。
6. 审查本地镜像差异并提交。
停止条件:页面需要删除、重命名或改变事实源边界。
@@ -45,7 +45,7 @@ synchronized_at: 2026-08-11T10:30:43Z
## 调整 Harness 检查
1. 从 `dev_scripts/check_harness.py` 的 `main()` 开始读。
1. 从 `dev_scripts/harness.py` 的 `run_check()` 开始读。
2. 新检查应输出具体文件和缺失内容。
3. 检查结构事实,不声称自动判断文档语义质量。
4. 在 `tests/` 添加成功和失败用例。
+47 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
wiki_revision: 3523b7cd1f126ebc3ef77414cb6d658103f2491d
synchronized_at: 2026-08-15T01:13:11Z
wiki_revision: 046624c1b2d7963ef733b4c9687d5dec14771388
synchronized_at: 2026-08-27T09:05:32Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -116,3 +116,48 @@ synchronized_at: 2026-08-15T01:13:11Z
| viewer 看得到页面但不能保存 | 符合只读权限;由 implementation_operator、site_admin 或 admin 完成配置。 |
| 键盘无法操作顶点 | Tab 聚焦画布或编号顶点;Enter/Space 添加中心点,方向键移动,Delete/Backspace 删除。检查浏览器焦点轮廓是否可见。 |
<!-- sense-area:end -->
<!-- sense-capabilities-jsonb:start -->
## Sense 旧设备能力字段迁移排错
启动迁移出现 `字段 "capabilities" 的默认值不能转换成类型 jsonb (SQLSTATE 42804)`,表示数据库仍保留旧版 `sense_devices.capabilities text DEFAULT ''`,而当前 GoAdmin 派生模型要求 JSONB。不要跳过迁移、删除设备记录或只手工删除默认值;旧单值数据仍可能在下一步转换失败。
工单 #92 的兼容迁移会在同一事务内锁定设备表并先验证全部旧值:空值转为 `[]`,`video`、`radar`、`contact`、`button`、`wearable`、`other` 等旧单值转为 JSON 数组,合法 JSON 数组保持数组。未知值或非数组 JSON 会拒绝迁移并整体回滚,不输出具体业务值。
处理步骤:
1. 停止所有连接该 Sense 数据库的服务实例。
2. 使用 `backup-sense.bat` 创建 PostgreSQL custom-format 备份,并确认备份文件可读取。
3. 部署包含 #92 的新 `sense.exe` 后重新运行 `migrate-sense.bat` 或正常启动。
4. 若提示“unsupported legacy data”,不要直接改表;保留错误、恢复测试副本并由维护人员确认旧能力语义。
5. 迁移成功后确认设备仍存在、能力标签正确,再启动其他实例。
正式数据库未备份时不得执行该结构迁移。需要回退版本时停止服务并从迁移前备份恢复,不把 JSONB 反向猜测为旧文本。
<!-- sense-capabilities-jsonb:end -->
<!-- sense-media-path-constraint:start -->
## Sense 旧媒体路由迁移排错
旧库迁移出现 `约束 "uni_sense_media_routes_path" 不存在 (SQLSTATE 42704)`,表示旧 `sense_media_routes.path` 由 PostgreSQL 自动命名的唯一约束保护,而新 GORM 模型准备改用唯一索引;GORM 按推导名称删除旧约束时找不到实际名称。修正该名称后若继续出现 `source_ready ... contains null values (SQLSTATE 23502)`,表示非空旧表还缺少当前模型要求的运行态列。
工单 #95 的兼容迁移只在 PostgreSQL 旧表存在时执行:取得 ACCESS EXCLUSIVE 表锁,确认只有一个单列 `UNIQUE(path)` 约束,将实际约束名规范为 GORM 可识别名称;同时为旧路由初始化保守运行态 `source_ready=false`、`failure_count=0`、`last_error_code=''`,再继续 AutoMigrate。迁移不会把旧路由伪装成已就绪,服务启动后仍由对账恢复真实状态。复合约束、多重 path 约束或其他无法确认的唯一性结构会拒绝迁移并整体回滚。
处理步骤:
1. 停止连接该数据库的全部 Sense 实例,并确认 Sense 与 MediaMTX 相关端口已释放。
2. 使用 `backup-sense.bat` 生成 PostgreSQL custom-format 备份;非标准 PostgreSQL 安装目录需通过 `SENSE_POSTGRES_BIN` 指向包含 `pg_dump.exe`、`pg_restore.exe` 的目录。
3. 使用 `pg_restore --list <备份文件>` 确认备份可读取,再部署包含 #95 的 Windows 包。
4. 先运行 `migrate-sense.bat`;成功后确认旧路由数量不变、运行态列无空值、`path` 仍有唯一索引。
5. 再启动 Sense,检查首页、`/healthz`、MediaMTX Control API 和视频服务对账;验证完成后使用 `stop-sense.bat` 停止。
如果迁移报告不支持的唯一性结构,不要手工删除约束或路由;在备份副本中核对实际约束和业务数据。正式迁移失败时保留错误并从迁移前备份恢复,不通过关闭唯一性绕过迁移。
<!-- sense-media-path-constraint:end -->
## DevHarness 同步排错
1. 先运行 `python dev_scripts/harness.py check --strict` 定位结构或项目规则问题。
2. 镜像不一致时运行 `python dev_scripts/harness.py sync --check`;不得直接修改 `docs/` 后反向覆盖 Wiki。
3. 若同步提示本地镜像有未提交修改,先核对改动归属并停止覆盖。
4. Wiki 页面缺失、没有 revision、MCP/API 凭据不可用或映射准备删除/重命名时停止,由工单确认后处理。
5. PowerShell 显示乱码时先区分文件编码和控制台输出编码,不默认另起 PowerShell 或使用 `-ExecutionPolicy Bypass`。
+118 -27
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: New-Project-Documentation-Setup
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/New-Project-Documentation-Setup.-
wiki_revision: 8ac3aaf5a1cc5479f2a37a07fec6510b6dcc9cab
synchronized_at: 2026-08-11T10:30:50Z
wiki_revision: 66d63c1e43de4dc54bb7c16ec6eb462a07bab15c
synchronized_at: 2026-08-27T09:05:42Z
<!-- gitea-wiki-mirror:end -->
# 新项目文档初始化
@@ -26,7 +26,81 @@ synchronized_at: 2026-08-11T10:30:50Z
把项目专用红线写入根目录或子目录 `AGENTS.md`。
### 2. 识别子项目与交付单元
#### 需求总览启用条件
从模板创建项目时保留 Product-Requirements-Overview 这一核心页面。仅有探索性想法时可以只记录已确认目标和待确认项;形成 MVP、长期需求超过少量工单或开始制作原型时,必须建立并持续维护需求索引,把需求领域、状态、主题 Wiki、工单、原型和验收入口关联起来。不要复制完整工单或聊天记录。
### 2. 选择建设基线
确定技术方案前,优先评估是否存在功能和架构匹配、持续维护、许可证兼容且工程流程完善的开源项目。这里要求的是“先评估”,不是强制采用开源项目,也不能只根据知名度、Star 数量或演示效果决定。
至少检查:
- 核心功能、架构和支持平台是否匹配,哪些能力可以直接保留;
- 许可证是否允许预期的使用、修改、分发和商业模式;不确定时交由负责人或法律专业人员确认;
- 最近维护活跃度、发布频率、Issue 处理和社区或维护团队的持续性;
- 已知安全问题、依赖健康度、供应链风险和安全响应方式;
- 自动化测试、CI、发布、升级、回退和文档是否足以支持长期维护;
- 定制、学习、迁移和后续跟踪上游的总成本是否低于从零开发;
- 是否能够固定上游仓库和基线版本,并建立合并上游更新、兼容验证和退出方案。
满足适配、许可证、安全、维护和总成本条件时,优先在该基线上二次开发。不存在合适基线,或引入后会增加不可接受的许可证、安全、架构或维护风险时,可以从零开发,但必须记录排除候选项目和选择从零开发的主要原因。
采用开源基线时,在 Project-Profile 的“技术栈与运行环境”记录上游项目名称、仓库地址、基线版本或提交、许可证、保留能力、定制范围和上游升级策略。尚未确认的候选和取舍先写入首个技术方案工单,不得把假设写成项目事实。
#### 工程基线裁剪
所有项目采用最小工程基线,不按项目规模免除事实和验收要求:
- 用完整 Git commit 和核验日期固定“当前事实”的代码基线;
- 分开记录“当前已经实现什么”和“目标规范要求什么”,不得用目标描述宣称现有能力;
- 写明证据路径、可确认行为、未覆盖范围和证据不能证明什么;
- 明确目标、非目标、安全边界和可判定的验收标准;
- 跨子项目接口或契约指定唯一事实来源和各端验证命令。
当前事实以指定 commit 的代码、可执行测试和运行证据为依据;目标行为以人工批准的契约、ADR 和业务规则为依据。两者冲突时登记为带编号的差距或缺陷,不允许现有错误实现覆盖目标规范,也不允许目标设计冒充当前实现。
出现跨团队或跨仓库协作、外部交付、接口或状态机复杂、权限安全、迁移并发、明显文档漂移等情况时,采用增强工程基线:按需增加 GAP-ID 差距表、带状态的 ADR、接口与数据契约、需求追踪测试矩阵,以及 PR、RC、Definition of Done 分层门禁。SRS、SAD、安全、运维和测试文档按风险与读者选择,不强制小型单人项目建立完整文档集。
#### 判断案例
以下案例用于说明判断方式,不代表必须选择某种技术或具体开源项目。
##### 案例一:适合基于成熟项目二次开发
计划开发企业内部管理系统。候选项目已经具备用户、权限、审计日志、基础数据管理和自动化测试;功能与目标架构基本匹配,许可证允许预期使用,项目持续维护,发布与升级流程完整,预计只需修改业务模块和界面。
- 结论:优先基于该项目二次开发。
- 原因:可以减少通用功能的开发和验证成本,定制范围可控。
- 记录:上游仓库、基线版本、许可证、保留功能、定制模块和上游升级方式。
##### 案例二:项目成熟但许可证不兼容
候选项目功能完整、维护活跃、文档充分,但许可证与当前产品的闭源分发、商业模式或交付条件不兼容。
- 结论:不采用该项目作为建设基线。
- 原因:技术成熟度不能消除许可证风险;不确定结论必须交由负责人或法律专业人员确认。
- 记录:候选项目、许可证限制、确认人员和排除原因。
##### 案例三:功能相似但改造成本过高
候选项目表面上覆盖大部分功能,但数据模型、权限体系和部署结构与目标项目差异很大,需要大量删除模块、重写主要接口,并长期维护上游冲突。
- 结论:不直接基于完整项目二次开发,可以评估只复用合适的组件或设计思路。
- 原因:二次开发的总成本、理解成本和长期维护风险已经高于自主实现核心业务。
- 记录:主要结构差异、改造估算、长期维护风险和最终选择。
##### 案例四:只复用成熟框架或组件
没有功能高度匹配的完整开源产品,但存在成熟的应用框架、更新组件、日志组件或通信库。
- 结论:从零开发业务功能,同时复用经过评估的成熟框架或组件。
- 原因:复用基础能力不等于必须采用完整产品,可以避免被不匹配的业务架构绑定。
- 记录:每个依赖的用途、版本、许可证、安全边界、升级方式和可替换方案。
每个案例的实际评估都必须记录候选项目、判断依据、最终选择、未采用原因,以及升级或退出方式。
### 3. 识别子项目与交付单元
先判断仓库中有几个应用、服务、客户端、库或其他可独立交付的部分。对每个部分确认:
@@ -40,19 +114,21 @@ synchronized_at: 2026-08-11T10:30:50Z
把结果写入 Project-Profile 的“子项目与交付单元”。单应用项目只填写一个交付单元;多应用单仓库为规则不同的目录增加子目录 `AGENTS.md`,但不因为技术栈不同自动拆仓,也不强制统一版本和发布周期。
### 3. 建立 Gitea
### 4. 建立 Gitea
创建远端仓库并完成允许的初始引导提交。开启工单和 Wiki。任何产品功能开发在引导提交后都必须先有单元任务工单。
创建远端仓库并完成允许的初始引导提交,开启工单和 Wiki;必须先有远端仓库,才能填写该仓库的线上 Wiki。配置项目已有的 Gitea MCP 和安全凭据;优先使用 MCP,MCP 不可用或不支持所需写操作时才回退到 Gitea API,并在初始化工单记录原因。凭据只通过环境或 MCP 安全配置提供。
### 4. 修改镜像配置
任何产品功能开发在引导提交后都必须先有单元任务工单,并且必须通过第 8 步的线上 Wiki 初始化门禁。
把 `wiki-docs.json` 中的地址、owner 和 repository 改成新项目;核心主题映射保留。
### 5. 修改镜像配置
确认当前目录确实是新项目副本、且 DevHarness 历史归档不需要保留后,移除属于 DevHarness 的任务归档映射和对应 `docs/task/` 镜像。不要在原 DevHarness 仓库或已有业务项目中执行这项清理。
把 `wiki-docs.json` 中的地址、owner 和 repository 改成新项目;只保留核心主题映射。可选任务快照不逐页登记,默认任务流程不创建。
确认当前目录确实是新项目副本、且 DevHarness 历史归档快照不需要保留后,可以移除对应 `docs/task/` 文件。不要在原 DevHarness 仓库或已有业务项目中执行这项清理。
不要把 PAT 写入配置。
### 5. Agent 检查项目事实
### 6. Agent 检查项目事实
Agent 只读检查:
@@ -65,7 +141,7 @@ Agent 只读检查:
区分“代码中确认的事实”“负责人确认的业务规则”和“仍待确认的假设”。
### 6. 确定交付对象和文档
### 7. 确定交付对象和文档
由项目负责人确认哪些岗位或客户会实际使用、部署、管理、支持、集成或验收产品,并为每类对象确定:
@@ -77,25 +153,36 @@ Agent 只读检查:
按照[交付文档指南](Delivery-Documentation-Guide.-)选择文档,使用[岗位文档模板](Audience-Document-Template.-)按需创建。没有明确读者的文档不创建,不预建空白的用户手册、管理员手册或运维手册。
### 7. 先创建线上 Wiki
### 8. 先创建线上 Wiki
#### 在线创建与回读门禁
1. 使用配置好的 Gitea MCP 查询目标仓库的 Wiki 页面列表;MCP 不可用时使用 Gitea API,并记录回退原因。
2. 如果 `Home` 不存在,先创建 `Home`。创建后立即在线回读正文并记录 revision;`Home` 可读取后才能继续。
3. 依照 `wiki-docs.json` 逐页创建或更新其他核心页面。每页写入后在线回读正文,记录页面名和 revision。
4. 本地 `docs/` 是模板或 Wiki 镜像;本地文件存在、标题完整或 `harness.py check --strict` 通过,都不能单独证明线上 Wiki 已初始化。
5. 页面缺失、回读失败或没有 revision 时停止初始化,不得开始产品代码;Gitea 恢复后从首个失败页面继续。
至少创建或填写:
1. Home;
2. Project-Profile;
3. Architecture-and-Code-Map;
4. Business-Rules-and-Glossary;
5. Local-Development-and-Verification;
6. Common-Changes;
7. Troubleshooting;
8. Development-Workflow;
9. Delivery-Documentation-Guide;
10. Audience-Document-Template;
11. Task-Archive-Template。
3. Product-Requirements-Overview;
4. Architecture-and-Code-Map;
5. Business-Rules-and-Glossary;
6. Local-Development-and-Verification;
7. Common-Changes;
8. Troubleshooting;
9. Development-Workflow;
10. Delivery-Documentation-Guide;
11. Audience-Document-Template;
12. Task-Archive-Template。
Home 给出建议阅读顺序;每个命令必须有预期结果;代码地图必须指出入口和测试位置。具体岗位文档仅按第 5 步确认的受众创建。
Home 给出建议阅读顺序;每个命令必须有预期结果;代码地图必须指出入口和测试位置。具体岗位文档仅按第 7 步确认的受众创建。
### 8. 人工确认
部署页按需创建,不属于必需核心页面:项目负责人确认存在需要部署的常驻服务时,复制[部署文档模板](Deployment-Template.-)在本项目 Wiki 建立 `Deployment-and-Operations` 页面,并在本项目 `wiki-docs.json` 增加映射(建议镜像到 `docs/10-deployment-and-operations.md`);确认没有常驻服务时,在初始化工单记录原因,不创建该页面。
### 9. 人工确认
项目负责人至少确认:
@@ -103,29 +190,33 @@ Home 给出建议阅读顺序;每个命令必须有预期结果;代码地图
- 关键业务规则和状态;
- 权限、安全和数据边界;
- 真实运行、测试和部署命令;
- 本项目是否有需要部署的常驻服务;
- 哪些修改属于高风险;
- 交付对象、文档可见范围和外部信息边界。
### 9. 导出镜像并检查
### 10. 导出镜像并检查
```powershell
python dev_scripts/sync_wiki_docs.py
python dev_scripts/check_harness.py --strict
python dev_scripts/sync_wiki_docs.py --check
python dev_scripts/harness.py sync --verify
python -m unittest discover -s tests -v
```
只有线上 Wiki 确认后才导出 `docs/`。旧项目的任务归档不能带入新项目历史。
只有线上 Wiki 确认后才导出核心 `docs/`。默认不创建或导出任务归档;用户明确要求专项快照时才运行 `archive`、`export` 或 `export --all`。旧项目的任务归档快照不能带入新项目历史。
`harness.py sync --check` 会在线读取全部显式映射页面;任一页面不存在、无法读取或 revision 与镜像不一致时,初始化不通过。只有上述命令全部成功后才允许开始产品代码。
## 完成标准
初级程序员应能仅依靠 Home 和链接页面回答:
- 项目解决什么问题;
- 当前有哪些长期需求、状态如何,详细规则、工单、原型和验收入口在哪里;
- 怎样启动和运行测试;
- 常用功能从哪个目录和入口开始读;
- 一个简单修改通常要改哪里、验证什么;
- 哪些情况必须停止并交给 Agent 或负责人;
- 项目采用哪个建设基线,为什么适合二次开发,或者为什么选择从零开发;
- 采用开源基线时,上游仓库、基线版本、许可证、定制范围和升级策略是什么;
- 项目包含哪些子项目和独立交付单元,各自怎样构建、测试和发布;
- 跨子项目共享什么接口或契约,其唯一事实来源在哪里;
- 项目需要向哪些岗位交付什么文档,以及哪些内容不能对外提供。
+43 -8
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Existing-Project-Adoption-Guide
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Existing-Project-Adoption-Guide.-
wiki_revision: dfb92b4551c317a08f49e68d0262258157da6d04
synchronized_at: 2026-08-11T10:30:53Z
wiki_revision: 54e7c774b679f582d210c08179de31d6e12ed9b4
synchronized_at: 2026-08-27T09:05:54Z
<!-- gitea-wiki-mirror:end -->
# 已有项目接入 DevHarness 指南
@@ -23,7 +23,7 @@ synchronized_at: 2026-08-11T10:30:53Z
| 文档 | 创建核心主题页 | 逐页判断保留、迁移、合并或停止维护 |
| 工单和 Wiki | 新建并开始使用 | 先检查已有工单、Wiki 和状态体系 |
| Git 历史 | 允许一次引导提交 | 保留全部历史,不使用引导提交例外 |
| 任务归档 | 从新项目任务开始 | 不复制 DevHarness 或其他项目的历史归档 |
| 任务证据 | Gitea 工单;任务快照仅显式按需创建 | 保留已有工单;不复制 DevHarness 或其他项目的历史归档 |
| 接入方式 | 一次建立最小骨架 | 分阶段增量接入并逐步验收 |
从模板创建全新仓库时使用[新项目文档初始化](New-Project-Documentation-Setup.-);项目已有业务提交、用户或维护历史时使用本页。
@@ -71,7 +71,7 @@ Agent 在提出方案前只读检查:
- 多个应用共同完成一条产品或业务链路;
- 由同一团队维护,仓库权限基本一致;
- 接口变更需要在一个工单中同步修改或验证多端;
- 共享契约、业务规则和任务归档放在一起更容易保持一致;
- 共享契约和业务规则由同一项目维护并指定唯一事实来源;
- 仓库体积、测试时间和工具性能尚未明显影响开发;
- 初级维护者和 Agent 能通过目录、子目录 `AGENTS.md` 和文档入口清楚定位。
@@ -131,7 +131,42 @@ Agent 在提出方案前只读检查:
### 7. 提交和验收
提交只包含当前接入工单相关文件。记录测试、未验证部分、Wiki revision 和提交哈希,创建任务归档并保持工单“待验收”,等待用户明确验收后再关闭。
提交只包含当前接入工单相关文件。在工单评论集中记录测试、未验证部分、提交哈希,以及真实变化的 Wiki revision 或“无长期文档影响”;保持工单“待验收”,等待用户明确验收后再关闭。默认不创建任务归档。
## 后续升级
已接入的项目必须以 Project-Profile 中记录的 DevHarness 来源和当前基线为起点升级,不得重新复制整个模板,也不得用“最新版本”代替可复现的目标提交。
### 升级步骤
1. 读取目标项目的 Project-Profile,确认 DevHarness 来源仓库、当前基线完整提交、最后升级日期和项目适配说明;字段缺失时先补齐可验证事实,无法确认则停止。
2. 选择一个明确、已审阅的 DevHarness 目标提交,记录旧基线和新基线。先比较两个上游提交之间的变化,再判断这些变化如何作用于目标项目。
3. 只读比较与 Harness 有关的 `AGENTS.md`、`CLAUDE.md`、工单模板、`dev_scripts/`、Harness 测试和核心 Wiki 结构,把差异分为“直接采用、按项目改写、冲突待确认、不采用”。不得把 DevHarness 的项目事实、工单或任务归档带入目标项目。
4. 在目标项目建立单元任务工单,写明升级范围、差异分类、项目专用规则、风险、回退、验证和文档影响。会改变产品行为的内容必须拆成独立任务。
5. 按工单最小合并,保留目标项目更具体的业务、安全、权限和目录规则,以及 Git 历史和无关工作区修改。无法判断哪一方规则有效时停止并等待负责人确认。
6. 长期文档先更新目标项目 Wiki,读取确认后再同步目标项目的核心 `docs/` 镜像;不得用 DevHarness 的本地镜像覆盖目标项目文档。
7. 执行目标项目规定的必要检查和受影响测试,提交并回写证据。工单保持“待验收”。
8. 用户验收通过后,确认目标项目 Project-Profile 已记录新 DevHarness 基线完整提交和升级日期,再关闭工单。升级失败或回退时保留旧基线。
### 升级停止条件
除本页已有的冲突停止条件外,来源仓库与记录不一致、旧基线不存在、目标提交未明确、差异跨越过大而无法可靠分类,或升级需要覆盖项目专用安全规则时,都必须停止并请求确认。可以把升级拆成多个单元任务,但每个任务都要声明最终采用的同一目标基线。
### 可复制升级指令
```text
请把当前项目从 Project-Profile 记录的 DevHarness 基线升级到
<DevHarness 目标完整提交哈希>。
先只读比较来源仓库中“旧基线..目标基线”的 Harness 变化和当前项目
适配,列出直接采用、按项目改写、冲突待确认和不采用的内容,以及
风险、回退、验证和文档影响。不要覆盖项目专用规则、业务文档、Git
历史或无关改动,不复制 DevHarness 工单和任务归档。方案确认后在
当前项目建单并实施;长期文档先改当前项目 Wiki,再同步本地镜像。
工单保持待验收,验收通过后确认 Project-Profile 已记录新基线。
```
路径和目标完整提交哈希必须替换为真实值;目标提交未明确时只分析,不实施。
## 冲突处理和停止条件
@@ -175,8 +210,8 @@ Agent 在提出方案前只读检查:
严格按工单范围增量接入 DevHarness,保留当前项目已有规则、历史、
任务状态和无关改动。长期文档先更新 Gitea Wiki,读取确认后再导出
本地 docs 镜像。执行必要测试,提交实现和任务归档,然后把工单保持
为“待验收”;未经我明确验收,不关闭工单。
本地 docs 镜像。执行必要测试,提交实现并把最终证据回写工单,然后
保持“待验收”;默认不创建任务归档,未经我明确验收不关闭工单。
```
路径、仓库地址和项目名称必须替换为当前环境的真实值。第二段指令只有在第一段方案已经明确确认后使用。
@@ -192,7 +227,7 @@ Agent 在提出方案前只读检查:
- [ ] 已为每类长期文档明确事实来源和迁移状态。
- [ ] Wiki-first 页面已经读取确认并具有显式镜像映射。
- [ ] Harness 检查已按目标项目调整并通过。
- [ ] 必要测试、未验证部分、提交和归档证据已记录。
- [ ] 必要测试、未验证部分、提交和最终证据已记录。
- [ ] 工单处于待验收,未提前关闭。
## 回退原则
+54 -3
View File
@@ -2,12 +2,63 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Product-Requirements.-
wiki_revision: f0d16a60406eed6fd6968c6555c599554bbd1fae
synchronized_at: 2026-08-11T10:30:56Z
wiki_revision: e912a1ca1410e01680a0f11f6199ccb42cd8fe8f
synchronized_at: 2026-08-27T09:06:08Z
<!-- gitea-wiki-mirror:end -->
# 产品需求
## 本页用途
本页同时承担 YoVision 产品需求正文与需求总览索引,保留已经确认的 Sense、Brain、Bell 和跨项目要求,不因 DevHarness 升级重命名或拆分事实源。
## 事实来源边界
- 长期产品目标、边界和稳定需求记录在本页。
- 单次任务的范围、变化、实现和验收记录在对应 Gitea 工单。
- 原型用于确认页面、流程与交互,不替代正式需求和验收标准。
## 当前需求索引
- 产品边界:PR-BND-001~PR-BND-003。
- Sense:SEN-001 起,按 P0、P1/P2 管理。
- Brain:BRN-001 起,按 P0、P1/P2 管理。
- Bell:BEL-001 起,按 P0、P1/P2 管理。
- 跨项目与非功能要求:见本页第 6 节及对应协调工单。
## 登记规则
新增或变化的长期需求必须有来源、状态、所属产品、优先级和验收边界;会改变已确认结果时先更新工单并重新取得用户确认。
## 原型与设计资产
### 原型门禁
新页面、独立用户功能、重大交互或导航变化必须先形成可审阅原型;小范围 UI 使用标注截图、低保真图或明确复用规范;非 UI 任务使用架构、API、数据、状态或流程设计。
### 线上原型与按需 HTML 快照
默认使用可访问且版本明确的线上原型审核。只有用户明确要求或项目规则要求时,才导出 `prototypes/<工单号>/<版本>/index.html`;已确认快照不得原位覆盖。
### 原型确认记录
实现工单记录设计链接或路径、版本/revision、访问检查、确认人、确认时间和覆盖范围。结构、流程、状态、权限或异常处理发生实质变化时必须重新确认。
## 状态规则
需求使用拟议、已确认、实施中、已交付、已废弃等状态;工单状态仍按待确认、待实施、进行中、阻塞、待验收、已完成管理,两者不得混用。
## 更新时机
产品边界、长期业务规则、需求优先级或验收边界变化时更新本页;单次实现细节、测试日志和提交哈希只写工单。
## 最小验收清单
- 需求有稳定编号、所属产品、优先级、来源和验收边界。
- 跨项目需求只有一个契约或协调事实源。
- 需要原型的变更已有可访问、可识别版本并完成确认。
- 不包含密码、令牌、生产数据或完整聊天记录。
## 1. 产品范围与优先级
YoVision 首个可交付目标是在民办寄宿学校以默认 16 路高风险点位形成完整闭环:
@@ -48,7 +99,7 @@ YoVision 首个可交付目标是在民办寄宿学校以默认 16 路高风险
### P0
- **SEN-001 独立登录与权限**:Sense 自有用户、角色、菜单、会话和审计;至少覆盖管理员、实施/运维、站点管理员和只读边界。
- **SEN-001 独立登录与权限**:Sense 自有用户、角色、菜单、会话和审计;至少覆盖管理员、实施/运维、站点管理员和只读边界。首期采用账号密码直接登录,所有运行模式均不使用验证码;密码保持 6–72 字节且不强制字符复杂度,登录成功与失败必须记录不含秘密的身份审计。默认登录有效期为固定 30 天,后端 JWT 与浏览器持久 Cookie 必须一致;版本更新后已有 Token 不自动延长,不提供滑动续期或服务端单 Token 撤销。
- **SEN-002 设备台账**:以 Device 为根实体,通过 `modality` 和 `capabilities` 表达 video/radar/contact/button/wearable/other;首期只完整实现 video,未实现适配器显示 `adapter_not_ready`。
- **SEN-003 ONVIF/RTSP 接入**:支持发现或手工添加、Profiles、StreamUri、主/子码流、校时、认证失败、重新探测和凭据更新。
- **SEN-004 批量开通**:默认 16 路可导入、预校验、待激活、逐项成功/失败、仅重试失败项;部分成功不做整体回滚。
+70 -25
View File
@@ -2,55 +2,100 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Home
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Home
wiki_revision: 1a2dadc746ce76e182150ca93fc4a1ec192c6b6a
synchronized_at: 2026-08-11T10:30:24Z
wiki_revision: 8ba2aba01f287ec5ab9cafda3d719bfd9e7fc459
synchronized_at: 2026-08-27T09:04:13Z
<!-- gitea-wiki-mirror:end -->
# YoVision 文档中心
YoVision 是单仓三项目的智能视频事件平台。Sense 负责设备与媒体,Brain 负责推理与事件生成,Bell 负责事件预警和处置。Sense 与 Bell 是可独立销售、部署和验收的产品;Brain 是独立构建的推理交付单元。
DevHarness 是一个以 Gitea 工单管理开发任务、以 Wiki 管理长期开发文档、以 Git 记录代码变更的 AI 辅助开发模板。目标是让初级程序员能够理解项目、运行验证,并在 Claude/Codex Agent 协助下处理简单 Bug 和需求。
## 第一次阅读
1. [项目档案](Project-Profile.-):目标、子项目、技术栈和边界。
2. [产品需求](Product-Requirements.-):功能、质量、安全和非目标。
3. [架构与代码地图](Architecture-and-Code-Map.-):职责、数据流和代码入口。
4. [业务规则与术语](Business-Rules-and-Glossary.-):不可破坏的领域规则。
5. [需求迁移矩阵](Requirements-Migration-Matrix.-):旧项目需求怎样进入新仓库。
6. [多 Agent 协作](Multi-Agent-Collaboration.-):三名主 agent 和协调任务的写路径规则。
7. [本地开发与验证](Local-Development-and-Verification.-):当前可执行命令和待补门禁。
8. [开发工作流](Development-Workflow.-):建单、确认、实施、验收和归档。
建议按以下顺序,用 10~20 分钟建立整体认识:
1. [项目档案](Project-Profile.-):项目目标、环境、命令和目录边界。
2. [产品需求总览](Product-Requirements-Overview.-):长期需求、状态、原型和验收入口。
3. [架构与代码地图](Architecture-and-Code-Map.-):功能从哪里开始读、测试在哪里。
4. [业务规则与术语](Business-Rules-and-Glossary.-):重要名词、状态和不能破坏的规则。
5. [本地开发与验证](Local-Development-and-Verification.-):怎样运行、测试和排错。
6. [常见修改指南](Common-Changes.-):简单修改的步骤和停止条件。
7. [故障排查](Troubleshooting):遇到错误时按什么顺序检查。
8. [开发工作流](Development-Workflow.-):完整建单、实施、验收和可选快照流程。
从模板创建新项目时先阅读[新项目文档初始化](New-Project-Documentation-Setup.-);向已有项目增量接入本流程时阅读[已有项目接入 DevHarness 指南](Existing-Project-Adoption-Guide.-)。需要为客户或其他岗位准备说明时,阅读[交付文档指南](Delivery-Documentation-Guide.-),再按需使用[岗位文档模板](Audience-Document-Template.-)。
## 五分钟开始
在仓库根目录执行:
```powershell
git status --short --branch
python dev_scripts/check_harness.py --strict
python dev_scripts/harness.py check --strict
python -m unittest discover -s tests -v
python dev_scripts/sync_wiki_docs.py --check
python dev_scripts/harness.py sync --check
```
预期:工作区变更归属清楚,Harness 与测试通过,Wiki 镜像一致。业务代码尚未初始化时,不应臆造 Sense、Brain 或 Bell 的构建命令。
预期结果:
- 工作区没有不属于当前任务的修改;
- Harness 输出“DevHarness 检查通过”;
- 所有单元测试通过;
- 所有 Wiki 映射显示“一致”。
如果失败,先看[故障排查](Troubleshooting),不要直接重置工作区或覆盖本地文档。
## 简单修改从哪里开始
| 修改类型 | 先读 | 主要验证 |
| 想做什么 | 先读哪里 | 主要验证 |
|---|---|---|
| Sense 单项目 | Project-Profile、`Sense/AGENTS.md` | Sense 自身测试 |
| Brain 单项目 | Product-Requirements、`Brain/AGENTS.md` | Brain 自身测试与事件契约测试 |
| Bell 单项目 | Business-Rules、`Bell/AGENTS.md` | Bell 自身测试 |
| 共享契约 | Multi-Agent-Collaboration、`contracts/AGENTS.md` | 三端消费者/生产者契约测试 |
| 长期文档 | 对应 Wiki 页面 | Wiki 同步检查 |
| 修改文档 | 对应 Wiki 页面、Common-Changes | Wiki 同步检查 |
| 查看或更新产品需求 | Product-Requirements-Overview、对应主题 Wiki 和工单 | 状态、链接和事实来源核对 |
| 接入已有项目 | Existing-Project-Adoption-Guide | 只读盘点、差异确认和分阶段验证 |
| 准备交付文档 | Delivery-Documentation-Guide、Audience-Document-Template | 目标岗位验证和 Wiki 同步检查 |
| 调整工单字段 | `.gitea/issue_template/`、Development-Workflow | Harness 严格检查 |
| 修改同步行为 | `dev_scripts/wiki_docs.py`、Architecture-and-Code-Map | 单元测试和真实 Wiki 检查 |
| 增加结构检查 | `dev_scripts/harness.py` | 成功与失败测试 |
| 排查运行错误 | Troubleshooting、项目档案 | 最小复现命令 |
权限、安全、并发、迁移、支付、删除数据或不可逆操作不属于简单修改,必须停止并交给 Agent 分析、等待人工确认。
## 事实来源
| 信息 | 事实来源 |
|---|---|
| 实时任务状态、方案确认和验收过程 | Gitea 工单 |
| 长期需求、架构、规则、操作与归档 | Gitea Wiki |
| API/Schema/迁移及代码版本事实 | Git 仓库 |
| 离线文档 | `docs/` Wiki 只读镜像 |
| 历史需求和旧证据 | `D:\OPC\yovision_old`,只读参考,不是当前状态 |
| 任务状态、讨论、阻塞、验收过程 | Gitea 工单 |
| 长期产品需求的统一导航和状态 | Gitea Wiki 的 Product-Requirements-Overview |
| 架构、业务规则、开发规范、操作手册和交付文档 | Gitea Wiki |
| 源码和与特定代码版本强绑定的文档 | Git 仓库 |
| 核心长期文档的离线浏览副本 | Git 仓库中的 `docs/` Wiki 镜像 |
| 单次任务需求、实现、测试和验收 | Gitea 工单;专项 Wiki 快照仅在人工明确要求时创建 |
本地 `docs/` 不是编辑入口。长期文档必须先修改 Wiki,读取确认后再导出镜像。
## 项目入口
- [Gitea 工单](https://git.ilapage.cn/OPC/dev_harness/issues)
- [产品需求总览](Product-Requirements-Overview.-)
- [代码仓库](https://git.ilapage.cn/OPC/dev_harness)
- [已有项目接入 DevHarness 指南](Existing-Project-Adoption-Guide.-)
- [交付文档指南](Delivery-Documentation-Guide.-)
- [岗位文档模板](Audience-Document-Template.-)
- [可选任务归档模板(兼容)](Task-Archive-Template.-)
## 同步原则
```text
修改 Wiki → 读取确认 → 导出 docs → 校验差异 → 提交镜像
```
- 核心页面和本地路径通过仓库中的 `wiki-docs.json` 显式映射;普通同步不处理任务归档。
- 默认不创建任务归档;只有用户明确要求专项快照或项目专用规则要求时,才创建 Wiki 归档并按需导出到 `docs/task/`。
- 镜像头记录来源页面、Wiki revision 和同步时间。
- 已映射镜像存在未提交修改时同步必须停止。
- 页面删除、重命名和映射变更必须人工确认。
- 长期事实发生变化而核心 Wiki 或必要同步失败时,相关任务不能标记为完成;没有长期文档变化时不运行 Wiki 同步,未请求可选归档不阻止任务完成。
- 凭据、个人数据和生产数据不得进入 Wiki 或镜像。
## 项目入口
+15 -8
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Delivery-Documentation-Guide
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Delivery-Documentation-Guide.-
wiki_revision: 45c86f2a0e4d3252e8042df5ee725e633dd497c1
synchronized_at: 2026-08-14T09:47:31Z
wiki_revision: 5b295898612f86ac2301ea243df76fb284912317
synchronized_at: 2026-08-27T09:06:55Z
<!-- gitea-wiki-mirror:end -->
# 交付文档指南
@@ -79,7 +79,6 @@ synchronized_at: 2026-08-14T09:47:31Z
开发任务怎样建单、实施和归档见[开发工作流](Development-Workflow.-);代码结构和维护入口见[架构与代码地图](Architecture-and-Code-Map.-)。本页不规定市场宣传、合同、法务或商务承诺。
<!-- sense-mvp:start -->
## Sense MVP 岗位操作路径
Sense 面向网管、实施人员和非技术现场人员,菜单按日常任务组织:工作台 → 设备管理 → 视频接入 → 视频服务 → 实时监看 → 区域与警戒线。普通操作优先展示中文状态与下一步,不要求用户理解 ONVIF、RTSP 或 MediaMTX 内部模型。
@@ -97,12 +96,16 @@ Sense 面向网管、实施人员和非技术现场人员,菜单按日常任
<!-- sense-windows-package:start -->
## Sense Windows 运行包交付
交付对象为实施和运维人员。`sense-windows-amd64.zip` 包含后端程序、已构建前端、空值示例配置、上游许可证、启动脚本与包内说明;不包含 PostgreSQL、MediaMTX、Windows 服务、生产数据或秘密。
交付对象为实施和运维人员。以 `sense-windows-amd64.zip` 交付后端程序、已构建前端、空值示例配置、迁移基线、许可证、启动/检查/停止/备份/恢复脚本与包内说明;不包含 PostgreSQL、生产数据、默认管理员、默认密码或客户秘密。可按交付决定是否包含已审核的 `bin\mediamtx.exe`。
- 临时查看必须显式运行 `start-sense.bat demo`,其内存数据在进程结束后丢失,不能当作生产部署。
- 生产配置可由运维写入解压目录的 `config\sense.env`,或通过 Windows 进程环境安全注入;进程环境优先。先运行 `start-sense.bat check` 检查必填项,再运行 `start-sense.bat`。
- 交付时记录 ZIP SHA-256,并至少验证 `/healthz` 与首页;真实 PostgreSQL、MediaMTX、摄像机和目标浏览器仍需在获准环境验收。
- 包内 `README-WINDOWS.md` 是现场操作入口;真实 `config\sense.env` 只留在具体部署目录,不得提交 Git 或重新打入交付 ZIP,交付 ZIP 只保留 `sense.env.example`。
- production 先复制 `config\sense.env.example` 为 `config\sense.env`,填写 PostgreSQL、至少 32 字符 JWT secret、凭据密钥、获准 ONVIF 网络和 MediaMTX 模式。进程环境中的同名非空值优先,脚本不执行 env 文件内容,也不打印秘密。
- 先运行 `check-sense.bat`,再运行 `start-sense.bat`。启动会先迁移,失败时不会开放 HTTP;`managed` MediaMTX 在 Sense HTTP 前启动,`external` 必须已有可达的回环 Control API,production 禁止 `disabled`。
- 首位管理员通过至少 32 字符的一次性 bootstrap token 和 `initialize-admin.bat -Username admin` 创建,密码由隐藏提示输入;成功后立即清空 token 并重启。仓库与交付包均无默认账号密码。
- 临时演示必须显式运行 `start-sense.bat demo`,使用独立 `config\sense.demo.env` 和名称含 demo/test 的数据库;不会回退 production 数据库,也不能作为生产部署。
- 日常停止优先在启动窗口按 Ctrl+C;窗口丢失或进程无响应时使用 `stop-sense.bat`。脚本会验证端口与可执行文件归属,拒绝停止其他程序。
- 备份使用 `backup-sense.bat` 生成 PostgreSQL custom-format 文件。恢复前停止服务并再次备份,执行 `restore-sense.bat` 时需确认目标数据库名及 `RESTORE-数据库名`,随后重新迁移。
- 交付时保存 ZIP 和 `MANIFEST.sha256` 的 SHA-256,至少验证首页、`/healthz`、数据库迁移、MediaMTX API、启动/停止和备份/恢复。真实摄像机、目标浏览器与客户数据库账号仍在授权现场验证。
- 包内 `README-WINDOWS.md` 是现场事实入口;真实 `config\sense.env`、运行生成的 `data\runtime\settings.yml`、日志和备份不得提交 Git 或重新打入 ZIP。
<!-- sense-windows-package:end -->
<!-- sense-device-ledger:start -->
@@ -130,3 +133,7 @@ Sense 面向网管、实施人员和非技术现场人员,菜单按日常任
<!-- sense-media:end -->
<!-- sense-admission:end -->
## 部署与运维文档
YoVision 已有 Sense Windows 交付包和本机 Supervisor 常驻实例,因此维护 `Deployment-and-Operations` 页面。部署命令、服务身份、配置来源、端口、日志、启动停止、回退或升级方式变化时必须先更新该 Wiki 页面,再同步本地镜像。
@@ -0,0 +1,82 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Deployment-and-Operations.-
wiki_revision: 367c6aed6eaeece793622431c7d1b11b1b9dbec5
synchronized_at: 2026-08-27T09:07:20Z
<!-- gitea-wiki-mirror:end -->
# YoVision 部署与运维
## 文档信息
- 系统:YoVision
- 当前常驻实例:Sense
- 适用环境:Windows 本机开发/验收与 Sense Windows 交付包
- 维护入口:工单 #108 后由长期 Wiki 持续维护
- 安全边界:本文不记录数据库密码、管理员密码、摄像头凭据、JWT/会话密钥或 Gitea token
## 部署范围与边界
当前可核对的常驻服务是 Sense。Brain 尚未初始化,Bell 新 GoAdmin 基线尚未完成,因此不在本页提供虚构的生产部署命令。三个交付单元保持独立配置、数据、身份、版本和发布边界;跨项目编排必须另建协调工单。
## 目录与入口
- Sense 项目目录启动入口:`Sense\start_sense.bat`
- Windows 交付包:`Sense\dist\sense-windows-amd64`
- 包内启动入口:`Sense\dist\sense-windows-amd64\start-sense.bat`
- 包内配置:`Sense\dist\sense-windows-amd64\config\sense.env`
- 项目配置源:`Sense\config\sense.env`
- 构建入口:`Sense\scripts\build\build-windows.bat`
- 本机 Supervisor 根目录:`D:\supervisor`
`Sense\start_sense.bat` 只定位并调用交付包入口、透传参数和退出码;它不读取配置、不自动构建,也不直接启动 Go 或 Node 开发服务。MediaMTX 是否随包启动由 Sense 包内运行脚本和配置控制。
## 配置与秘密
- 生产模式必须提供有效 PostgreSQL 连接和应用安全配置;变量名及无敏感示例以项目或交付包中的 `.env.example` 为准。
- 配置文件和进程环境中的秘密不得提交到 Git、工单、Wiki、日志或示例。
- Sense、Bell 必须使用不同数据库角色、用户库、JWT/会话密钥和 Cookie;Brain 使用独立机器身份。
- 修改服务账号、权限、端口、数据库、媒体二进制或持久化目录前必须建立相应工单并说明回退。
## 构建与启动
从 Sense 目录生成 Windows 包:
```powershell
Sense\scripts\build\build-windows.bat
```
生成包并正确配置后,可从仓库根运行:
```powershell
Sense\start_sense.bat
```
临时演示模式可透传 `demo` 参数;演示数据随进程停止而丢失,不得作为生产部署。
## Supervisor 常驻实例
本机 Supervisor 的 YoVision Sense 实例由工单 #106 建立。Supervisor 配置、启动停止命令、工作目录、环境文件和日志位置以 `D:\supervisor` 中的当前实例配置为准。修改该外部目录前必须建立工单并确认精确目标;仓库不得复制其中的秘密。
## 健康检查与日志
- 默认 Sense 访问地址以当前配置为准;已验收的本机默认地址为 `http://127.0.0.1:18080/`。
- 先确认端口监听和 HTTP 页面,再检查 Sense 结构化日志、PostgreSQL 连接、数据库迁移以及 MediaMTX 进程和路径状态。
- 日志不得输出数据库密码、摄像头凭据、会话 Cookie、JWT secret 或 token。
- 具体视频、迁移和登录故障按 `Troubleshooting` 页面处理。
## 停止、升级与回退
- 手工前台启动时在原控制台正常终止进程;Supervisor 托管时使用该实例的受控停止方式,避免同时启动第二个占用相同端口的进程。
- 升级前记录当前 Git 提交、交付包版本、数据库备份/恢复方案和配置差异。
- 数据库迁移、凭据、权限或不可逆操作属于高风险,必须另建工单并等待确认。
- 回退优先恢复上一已验收交付包和对应配置;涉及数据库迁移时只能使用该迁移工单确认的回退方案。
## 最小验收
- 启动入口、工作目录、配置来源和端口与实际一致。
- Sense 页面与必要 API 可访问,数据库迁移成功。
- MediaMTX 启用时进程、路径和播放链路状态可定位。
- Supervisor 不会与手工进程重复占用端口。
- 日志和文档没有秘密;未验证的 Brain、Bell、真机或生产行为明确标注。
+107
View File
@@ -0,0 +1,107 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-101-Sense-GoAdmin-应用外壳
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-101-Sense-GoAdmin-%E5%BA%94%E7%94%A8%E5%A4%96%E5%A3%B3.-
wiki_revision: a32fd29a7b8c4fa42396e9f249bcdd99d0c09e74
synchronized_at: 2026-08-27T07:52:48Z
<!-- gitea-wiki-mirror:end -->
# 101 Sense-GoAdmin-应用外壳
- 类型:缺陷修复
- 所属 Epic:#7
- 所属 MVP / 版本:#8 / Sense 首个独立纵切
- 状态:已完成
- 日期:2026-08-16
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/101
- Wiki 页面:Task-101-Sense-GoAdmin-应用外壳
- Wiki revision:见本地镜像头
## 背景与目标
Sense 五个业务菜单原来都以顶层业务组件注册。go-admin-ui 只有在动态路由组件为 `Layout` 时才保留侧栏、顶部导航和标签页,因此点击设备管理等入口会用业务页面替换整个 GoAdmin 外壳。
本任务恢复冻结 GoAdmin 的标准父子菜单结构:应用外壳保持不变,模块切换只替换右侧内容区域。
## 最终方案
新增迁移 `2026081623100_sense_layout.go`,创建唯一的“视频感知”顶层目录:
- 顶层目录:`MenuType=M`、`Path=/sense`、`Component=Layout`。
- 设备管理、视频接入、视频服务、实时监看、区域与警戒线:作为相对路径的 `MenuType=C` 子页面。
- 页面下既有 `MenuType=F` 操作权限保持不变。
- 递归重算父目录、页面和按钮的完整 `paths`。
- 页面 URL、组件、权限标识、角色关联和业务 API 均不改变。
- 使用新增事务迁移兼容已经执行旧迁移的 PostgreSQL 数据库,不修改历史迁移。
与建单方案一致,没有修改 go-admin-ui 公共 Layout 或动态路由生成器。
## 修改文件
- `Sense/server/cmd/migrate/migration/version/2026081623100_sense_layout.go`:新增 GoAdmin Layout 菜单兼容迁移。
- `Sense/server/cmd/migrate/migration/version/2026081623100_sense_layout_test.go`:覆盖菜单层级、相对路径、完整 paths、角色关联保持、重复对齐、事务回滚和 PostgreSQL。
- `docs/02-architecture-and-code-map.md`:同步架构 Wiki 中的 Sense 动态菜单长期规则。
- `wiki-docs.json`、`docs/task/101-Sense-GoAdmin-应用外壳.md`:任务归档映射与镜像。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 五个业务页位于 Layout 父路由下 | 通过 |
| 设备管理进入 `#/sense/devices`,标题与路径不串位 | 通过 |
| 切换和刷新后侧栏、顶部栏、标签页保留 | 通过 |
| 页面组件、权限标识和角色关联保持 | 通过 |
| 已有 PostgreSQL 数据库迁移无重复菜单 | 通过 |
| 重复对齐安全、失败事务回滚 | 通过 |
| 后端、前端和 Windows 包验证 | 通过 |
| Wiki、归档、PR 和证据 | 通过,用户已验收 |
## 测试
- `go test ./cmd/migrate/migration/version -run 'Test(AlignSenseLayout|MigrateSenseLayout|SenseLayout)' -count=1`:通过。
- 真实 PostgreSQL 隔离 schema `TestSenseLayoutMigrationOnPostgres`:通过并清理测试 schema。
- `go test ./...`:通过。
- `go vet ./...`、后端构建:通过。
- `pnpm lint`:0 error,32 条冻结上游/既有 warning。
- `pnpm test:unit -- --runInBand`:17 suites、46 tests 全部通过。
- `pnpm build:prod`:通过,4 条既有构建 warning。
- Windows PowerShell 5.1 包测试:21 assertions 通过;包审计通过。
- 本机生产数据库迁移及启动:通过。
- Headless Edge:依次打开五个菜单及刷新区域页面,`.app-wrapper`、侧栏、navbar、TagsView 全程可见,URL 均正确。
- Windows ZIP:`Sense/dist/sense-windows-amd64.zip`,SHA-256 `BAAA973F8502BB5B0BC080F11931E60419B25EFA8FF722A6B9F9B98364399642`,源码提交 `4423d528b1dc6ebe8b6f7b8272ca5111032a1608`。
- **未验证部分**:客户全新 Windows 主机及 implementation_operator/site_admin/viewer 三种岗位的人工视觉验收;本机使用 admin 完成真实浏览器回归,用户已确认验收通过。
## 遗留问题
#99、#104 与 #101 均已由用户验收;#101 经 PR #102 合入 `dev`,`main` 保持不变。#90 的项目档案 Wiki 更新未混入本任务提交;客户全新 Windows 主机及三个非 admin 岗位的人工视觉验收仍未覆盖。
## 相关提交
- `4423d528b1dc6ebe8b6f7b8272ca5111032a1608` 修复 Sense GoAdmin 应用外壳。
- `e652109` 记录 Sense Layout 菜单长期架构。
## #99 合入后的组合验收包
- 用户验收 #99 后,PR #100 已合入 `dev`;本任务分支通过合并提交 `42dc77b1f904032131c51b3864184c4ccaea6b8f` 更新到该基线。
- 冲突处理保留 Wiki 事实源中 #99 的匿名只读 `app-config` 边界、#101 的 GoAdmin Layout 菜单规则,以及两张任务归档映射。
- 重新生成 `Sense/dist/sense-windows-amd64.zip`;包内 `source_commit=42dc77b1f904032131c51b3864184c4ccaea6b8f`。
- 新 ZIP SHA-256:`65AF5DD1CFB5D4F8339DCCC53717354495259F13630FBC0C6A4AFC1ACD794B26`。
- 固定工具链 production build、包审计、Windows PowerShell 5.1 包测试 21 项、真实 PostgreSQL 迁移/启动及 `TestRegisterBaseRouterExposesOnlyFrontendAppConfig` 均通过。
- 现场配置在构建前备份、ZIP 生成后恢复;ZIP 仍只包含模板配置。Sense 当前监听 `127.0.0.1:18080`。
## #104 合入后的最终组合验收包
- #104 已由用户验收并经 PR #105 合入 `dev@46c3232da40528d031367225b09d4f28c85a8312`;#101 分支通过合并提交 `8cdd3f61cbd8f5bbc1a972525da91aaf49a39bb0` 更新到该基线。
- 唯一合并冲突是 `Architecture-and-Code-Map` 镜像头;读取 Wiki revision `8df12aa3e136b1faee9c2dffb58ca39b045bc49f` 确认页面同时包含 #101 Layout 菜单规则和 #104 30 天登录规则后,以该事实源解决。业务代码无冲突。
- `go test ./...`、`go vet ./...`、`go build ./...` 全部通过;前端 lint 0 error(32 条既有 warning),18 suites / 48 tests 通过,production build 通过(4 条既有 warning)。
- Windows 包资源审计、包审计和 22 assertions 通过;使用现场 PostgreSQL 完成幂等迁移与生产启动,`data/runtime/settings.yml` 确认 `timeout: 2592000`。
- Chrome 真实回归依次验证设备管理、视频接入、视频服务、实时监看、区域与警戒线五个入口;URL 分别为 `#/sense/devices`、`#/sense/admission`、`#/sense/media`、`#/sense/liveview`、`#/sense/area`,侧栏、Navbar、TagsView 全程可见。直接刷新区域页面后外壳与选中标签保持,控制台无错误。
- 本机直连验证 `/health`、`/api/v1/app-config` 和 `/` 均返回 200;首次命令行请求受开发机 HTTP 代理影响,明确绕过代理后通过,不属于 Sense 服务错误。
- 组合 ZIP:`Sense/dist/sense-windows-amd64-101-104.zip`,56,461,005 bytes,SHA-256 `46B2B5654EE3F61788CA4E51F5CB66C1A7C7D1088EFFD39C0281E86E84F964A2`,包内 `source_commit=8cdd3f61cbd8f5bbc1a972525da91aaf49a39bb0`。标准 ZIP 内容相同且仅含模板配置;现场配置只恢复到解压目录。
- 当前组合服务监听 `127.0.0.1:18080`。临时现场配置备份已删除,源码目录 `node_modules` 已清理。
- **未验证部分**:客户全新 Windows 主机及 implementation_operator/site_admin/viewer 三种岗位的人工视觉验收;用户已确认当前交付验收通过。
## 验收确认
- 2026-08-27,用户明确确认“#101通过验收”。
- PR #102 按规定合入 `dev`,`main` 未变更。
- 工单 #101 状态更新为“已完成”并关闭;所属 MVP #8 的子工单索引同步勾选。
@@ -0,0 +1,80 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-104-Sense-30天登录有效期
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-104-Sense-30%E5%A4%A9%E7%99%BB%E5%BD%95%E6%9C%89%E6%95%88%E6%9C%9F.-
wiki_revision: 2b4df23ec321de868840da61d87d8d6fce8537a7
synchronized_at: 2026-08-17T03:46:13Z
<!-- gitea-wiki-mirror:end -->
# 104 Sense-30天登录有效期
- 类型:需求
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:已完成
- 日期:2026-08-17
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/104
- Wiki 页面:Task-104-Sense-30天登录有效期
- Wiki revision:见本地镜像头
## 背景与目标
Sense 原生产、demo、SQLite 和完整配置默认 JWT 有效期为 3600 秒,前端 Token 使用浏览器会话 Cookie。按用户确认,将默认登录有效期统一调整为固定 30 天,使浏览器重启后在 Token 未过期时仍可保持登录。
## 最终方案
- 保留 go-admin JWT 中间件和 go-admin-ui `js-cookie` 封装;非开发模式未显式配置时默认使用 30 天,合法显式配置仍优先,开发模式保持上游行为。
- 生产、demo、SQLite、full 与 Windows 运行时生成配置统一使用 2,592,000 秒。
- `Sense-Admin-Token` Cookie 使用 `expires: 30`;退出登录仍删除 Cookie。
- 不是滑动续期,不增加 Refresh Token、服务端会话表或 Token 黑名单。已有 Token 不自动延长,部署后需重新登录。
- Token 泄露窗口扩大到 30 天;紧急失效需要轮换 JWT secret,这会使全部现有登录失效。
## 修改文件
- `Sense/server/common/middleware/auth.go`、`auth_test.go`:实现并验证默认 30 天 JWT。
- `Sense/server/config/settings*.yml`、`READMEN.md`:统一配置值和示例。
- `Sense/scripts/runtime/sense-common.ps1`、`Sense/tests/package/run-tests.ps1`:统一并验证 Windows 运行时配置。
- `Sense/ui/src/utils/auth.js`、`Sense/ui/tests/unit/utils/auth.spec.js`:持久化 Cookie 30 天并验证设置、读取和删除。
- `docs/02-architecture-and-code-map.md`、`docs/04-local-development-and-verification.md`、`docs/09-product-requirements.md`:由 Wiki 同步的长期文档镜像。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 非开发模式签发 JWT 的到期时间为签发后 30 天 | 通过 |
| Token Cookie 使用 30 天持久化期限,退出仍删除 | 通过 |
| 浏览器关闭后重新打开仍保持登录 | 通过(用户验收) |
| 所有运行配置及 Windows 生成值为 2,592,000 秒 | 通过 |
| Go、Vue、Windows 包测试与 PostgreSQL 启动回归 | 通过 |
| ZIP 仅含模板配置,不包含现场秘密或数据 | 通过 |
| 已有 Token 不自动延长并记录重新登录要求 | 通过 |
## 测试
- `go test ./...`:通过。
- `go vet ./...`:通过。
- `go build ./...`:通过。
- `pnpm run lint`:0 error,32 条上游既有 warning。
- `pnpm exec vue-cli-service test:unit --runInBand`:18 suites、48 tests 通过。
- `Sense/tests/package/run-tests.bat -PackageRoot Sense/dist/sense-windows-amd64`:22 assertions 通过。
- Windows amd64 生产构建、资源审计、包审计:通过;4 条既有构建 warning。
- 独立 #104 包使用现场 PostgreSQL 完成迁移和启动,`127.0.0.1:18080` 监听成功,生成配置确认 `timeout: 2592000`。
- 包:`Sense/dist/sense-windows-amd64-104.zip`,56,457,546 bytes,SHA-256 `BCE3EDD3F5BD79C75A953CB7673D1C9867CE3BB89755D6A188C7CA1226D05CE3`,来源提交 `adc227f110bf0c456d1523eac151ed1d8360cc83`。
- 一次错误的 Vue 测试参数把 `--runInBand` 识别为模式而未找到测试,已改用正确命令并通过;一次临时运行数据恢复把日志目录复制成同名文件,修正验证环境后从失败点重试并通过,均非产品代码缺陷。
- **未验证部分**:无法用真实等待 30 天验证自然到期;以固定时钟 JWT 测试和 Cookie 参数单测覆盖。用户已于 2026-08-17 明确验收通过,浏览器关闭/重开行为由人工确认。#104 独立包不包含仍待验收的 #101;两项进入 `dev` 后才能生成最终组合包。
## 遗留问题
- 当前服务已恢复为用户原有的 #101 验证包,避免应用外壳回退;#104 独立包保留为具名 ZIP,未覆盖当前标准包。
- 当前架构没有服务端单 Token 撤销能力,这是确认范围内的安全限制。
## 验收确认
- 用户于 2026-08-17 明确回复“#104 验收通过”。
- PR #105 按仓库门禁合入 `dev`;`main` 不变。
## 相关提交
- `857ba45` feat: 将 Sense 登录有效期调整为30天 (#104)
- `a843121` docs: 记录 Sense 30天登录有效期 (#104)
- `adc227f` test: 验证 Sense 30天 JWT 到期时间 (#104)
@@ -0,0 +1,77 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-106-Sense-本机-Supervisor-实例
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-106-Sense-%E6%9C%AC%E6%9C%BA-Supervisor-%E5%AE%9E%E4%BE%8B.-
wiki_revision: fcff45a700cba3db939819cfc5deb1cc97b8155b
synchronized_at: 2026-08-27T08:17:20Z
<!-- gitea-wiki-mirror:end -->
# 106 Sense-本机-Supervisor-实例
- 类型:运维配置
- 所属 Epic:#7
- 所属 MVP / 版本:#8 / Sense 首个独立纵切
- 状态:已完成
- 日期:2026-08-27
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/106
- Wiki 页面:Task-106-Sense-本机-Supervisor-实例
- Wiki revision:见本地镜像头
## 背景与目标
本机 Sense 原由独立终端启动,不受 `D:\supervisor` 管理。目标是在不复制现场秘密、不修改 Sense 业务代码的前提下,让 Supervisor 托管现有 Windows 交付包,并提供自动启动、异常重启、进程组停止和独立日志。
当前 Brain、Bell 只有规则文件,没有可运行交付物,因此本任务只创建 `yovision-sense`,不创建空实例。
## 最终方案
- 在仓库外新增 `D:\supervisor\programs\yovision.conf`,实例名为 `yovision-sense`。
- 工作目录使用 `D:\OPC\yovision\Sense\dist\sense-windows-amd64`。
- Supervisor 直接调用包内 `scripts/runtime/start-sense.ps1`,配置仍由 `config/sense.env` 读取。
- 配置启用 autostart、autorestart、启动重试、进程组停止和 50 MB × 5 的日志轮转。
- 日志写入 `D:\supervisor\logs\yovision-sense.log`;Supervisor 配置不含 environment、数据库连接、令牌、密码或摄像头凭据。
- 使用包内停止脚本核对并停止原 Sense PID 28440,释放 18080 后执行 Supervisor reload。
原计划按最坏情况说明 reload 会重启全部实例;实际命令返回 `Added Groups: yovision-sense`。dsh/goauto PID 未变化,原先处于 Backoff 的三个 Chorus 实例在 reload 后恢复 Running,因此没有观察到既有 Running 实例被重启。
## 修改文件
- `D:\supervisor\programs\yovision.conf`:新增本机 Supervisor 实例配置;该文件位于仓库外。
- `Local-Development-and-Verification` Wiki:记录实例位置、常用命令、秘密边界和验证方式。
- `docs/04-local-development-and-verification.md`:上述 Wiki 的只读镜像。
- `wiki-docs.json`、`docs/task/106-Sense-本机-Supervisor-实例.md`:任务归档登记与镜像。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| `yovision-sense` 稳定为 Running | 通过 |
| Sense 监听 18080,健康检查与首页返回 200 | 通过 |
| Sense 管理的 MediaMTX 随单实例重启更新 PID | 通过 |
| Supervisor 配置不包含现场秘密 | 通过 |
| dsh/goauto 保持运行,Chorus 从既有 Backoff 恢复 | 通过 |
| Wiki、归档、提交、PR 和证据 | 通过,用户已验收 |
## 测试
- `supervisord.exe ctl /c supervisord.conf reload`:返回新增 `yovision-sense` 配置组。
- `supervisord.exe ctl /c supervisord.conf status`:七个实例均为 Running;`yovision-sense` Supervisor PID 28000。
- `GET /health`、`GET /healthz`、`GET /`:均返回 200。
- 受控执行 `restart yovision-sense`:Sense PID 从 38408 更新为 43916,MediaMTX PID 从 27880 更新为 27212,重启后健康检查与首页仍为 200。
- 外部配置 SHA-256:`7C5BDDD574384ED6038F0C2DAA8CE364FB1EA996B256831595ABF4E22EDA4769`。
- 配置敏感字段扫描:未发现 password、token、secret、`SENSE_DATABASE_URL` 或 `environment=`。
- **未验证部分**:未通过重启 Windows 验证开机后的整体 Supervisor 自启动;未故意崩溃 Sense 验证异常自动重启,已用 Supervisor 受控重启验证停止与拉起链路。
## 遗留问题
Supervisor 管理端口当前监听 `0.0.0.0:9009` 且未配置认证,这是任务开始前已存在的安全风险,本任务按确认范围未修改,建议另建安全工单处理。
## 相关提交
- `0646f5effd53536ae3f608c5e5dffe7fe950cb8f` 记录 Sense Supervisor 托管方式。
## 验收确认
- 2026-08-27,用户明确确认“#106 验收通过”。
- PR #107 按规定合入 `dev`,`main` 未变更。
- 工单 #106 状态更新为“已完成”并关闭;所属 MVP #8 的子工单索引同步勾选。
+10 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-66-Sense视频接入与Profile
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-66-Sense%E8%A7%86%E9%A2%91%E6%8E%A5%E5%85%A5%E4%B8%8EProfile.-
wiki_revision: 322feb232fc03c3a9ba22f65504cf3e151fb0e57
synchronized_at: 2026-08-14T09:15:26Z
wiki_revision: 87b3a59f3f27df1f1fb357f6c13a1e31cdde5e05
synchronized_at: 2026-08-27T09:13:37Z
<!-- gitea-wiki-mirror:end -->
# 66 Sense视频接入与Profile
@@ -32,6 +32,13 @@ synchronized_at: 2026-08-14T09:15:26Z
- 复用 GoAdmin JWT/Casbin/操作审计、迁移和 go-admin-ui BasicLayout、Element Plus Form/Dialog/Table/Tag、动态菜单与权限按钮。
- implementation_operator、site_admin 可发现和探测,viewer 只读保存结果。
## 修改文件
- 后端入口与业务:`Sense/server/app/admin/router/sense_admission.go`、`Sense/server/app/sense/admission/**`、`Sense/server/app/sense/onvif/**`、`Sense/server/app/sense/rtsp/**`。
- 数据与验证:`Sense/server/cmd/migrate/migration/version/2026081417000_profile.go`、`Sense/server/tests/admission/postgres_test.go`。
- 前端:`Sense/ui/src/api/sense/admission.js`、`Sense/ui/src/views/sense/admission/**`、`Sense/ui/src/views/sense/device/index.vue` 及对应单元测试。
- 完整文件清单以实现提交 `2bb1614` 和 PR #84 的 Git diff 为准。
## 验收结果
| 标准 | 结果 |
@@ -54,7 +61,7 @@ synchronized_at: 2026-08-14T09:15:26Z
- PostgreSQL 17:迁移与重复迁移通过;migration=1、tables=2、menus=3、policies=7。
- SENSE_ADMISSION_TEST_DATABASE_URL 隔离测试:写入 Profile、重开连接、读取主子码流通过。
- Wiki 镜像检查:通过。
- 未验证部分:未连接客户或实验室真实摄像机;真实厂商 Digest/RTSP 兼容、网络 ACL、设备校时和目标浏览器留待授权现场验收,不声称已通过。
**未验证部分**:未连接客户或实验室真实摄像机;真实厂商 Digest/RTSP 兼容、网络 ACL、设备校时和目标浏览器留待授权现场验收,不声称已通过。
## 回退
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-67-Sense视频服务生命周期与状态对账
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-67-Sense%E8%A7%86%E9%A2%91%E6%9C%8D%E5%8A%A1%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F%E4%B8%8E%E7%8A%B6%E6%80%81%E5%AF%B9%E8%B4%A6.-
wiki_revision: 4642924705d7a3406874ef532579fb2d6a25a88b
synchronized_at: 2026-08-14T10:13:23Z
wiki_revision: 718765b6b8eff5f4c81612ea8720272c97cd1a25
synchronized_at: 2026-08-27T09:13:38Z
<!-- gitea-wiki-mirror:end -->
# 67 Sense视频服务生命周期与状态对账
@@ -30,6 +30,13 @@ synchronized_at: 2026-08-14T10:13:23Z
- 接入成功后幂等建立路由;冷启动恢复 desired=running,明确 stopped 路径保持停止;稳定路径只刷新状态,不重复下发或增加版本。
- 复用 GoAdmin JWT/Casbin/操作审计、迁移、动态菜单和 go-admin-ui BasicLayout、Element Plus Descriptions/Table/Tag/Button/MessageBox。
## 修改文件
- 后端入口与业务:`Sense/server/app/admin/router/sense_media.go`、`Sense/server/app/sense/media/**`、`Sense/server/app/sense/reconcile/**`。
- 数据、配置与验证:`Sense/server/cmd/migrate/migration/version/2026081419000_media*`、`Sense/server/config/mediamtx/mediamtx.yml.example`、`Sense/server/tests/media/**`。
- 前端:`Sense/ui/src/api/sense/media.js`、`Sense/ui/src/views/sense/media/**` 及对应单元测试。
- 完整文件清单以实现提交 `19f9bfa` 和 PR #85 的 Git diff 为准。
## 验收结果
| 标准 | 结果 |
@@ -53,7 +60,7 @@ synchronized_at: 2026-08-14T10:13:23Z
- PostgreSQL 17 定向迁移:3 个菜单、12 条角色策略、1 条迁移记录通过。
- Wiki 镜像检查:通过。
- 已处理测试安全问题:MediaMTX 自动 TLS 文件的工作目录已固定到外部配置目录;临时证书/私钥未进入最终提交或远端。
- 未验证部分:未连接客户真实摄像机和现场网络;真实上游持续拉流、reader 变化、端口 ACL 和目标浏览器留待授权现场验收。
**未验证部分**:未连接客户真实摄像机和现场网络;真实上游持续拉流、reader 变化、端口 ACL 和目标浏览器留待授权现场验收。
- 已知相邻问题:空白 PostgreSQL 执行完整上游迁移链时,在到达 #67 前被旧 `sys_config` 初始化字段长度问题中止;#67 定向迁移已通过,空库安装链应由 #70/#71 单独复核,不在本工单混改上游初始化。
## 回退
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-69-Sense多边形区域与方向警戒线配置
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-69-Sense%E5%A4%9A%E8%BE%B9%E5%BD%A2%E5%8C%BA%E5%9F%9F%E4%B8%8E%E6%96%B9%E5%90%91%E8%AD%A6%E6%88%92%E7%BA%BF%E9%85%8D%E7%BD%AE.-
wiki_revision: 107d58783efef0d9876f082d5e61ed2b5bc6045d
synchronized_at: 2026-08-15T01:24:21Z
wiki_revision: c679c3c2b41a190480732cd28cc52083c76843e5
synchronized_at: 2026-08-15T01:36:30Z
<!-- gitea-wiki-mirror:end -->
# 69 Sense 多边形区域与方向警戒线配置
@@ -11,7 +11,7 @@ synchronized_at: 2026-08-15T01:24:21Z
- 类型:需求
- 所属 Epic:#7
- 所属 MVP:#8
- 状态:待验收
- 状态:已完成
- 日期:2026-08-15
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/69
- 评审 PR:https://git.ilapage.cn/ila/yovision/pulls/87
@@ -71,7 +71,14 @@ synchronized_at: 2026-08-15T01:24:21Z
- Sense→Brain 的版本化区域配置发布属于后续共享契约工单,本工单未写入 `contracts/`,不阻塞 Sense 独立配置能力。
## 人工验收
- 用户于 2026-08-15 明确确认“#69 验收通过”。
- PR #87 已合并到 `dev`,合并提交:`61b79db9f72db3ca8b7187898b34763d9cab806c`。
- 工单在归档与父工单同步完成后关闭;本次未合入 `main`。
## 相关提交
- `17bd383` feat: 重建 Sense 区域与警戒线配置 (#69)
- `f82dd51` docs: 记录 Sense 区域配置架构 (#69)
- `61b79db9` Merge pull request '#87' from feature/69-sense-area into dev
@@ -0,0 +1,110 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-70-Sense-Windows配置启动与打包交付
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-70-Sense-Windows%E9%85%8D%E7%BD%AE%E5%90%AF%E5%8A%A8%E4%B8%8E%E6%89%93%E5%8C%85%E4%BA%A4%E4%BB%98.-
wiki_revision: 9eb390dfd691acc089656a838dd3f12e24f97884
synchronized_at: 2026-08-16T11:33:28Z
<!-- gitea-wiki-mirror:end -->
# 70 Sense Windows配置启动与打包交付
- 类型:需求
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:已完成
- 日期:2026-08-15
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/70
- Wiki 页面:Task-70-Sense-Windows配置启动与打包交付
- Wiki revision:见本地镜像头
## 背景与目标
在 #61–#69 完成冻结 GoAdmin 基线及 Sense 独立业务纵切后,重建 Windows amd64 前后端单包交付能力。交付必须继续使用 GoAdmin Cobra 的迁移与服务入口,并覆盖包内配置、PostgreSQL、MediaMTX、管理员初始化、停止、备份和恢复;不得回用 explore 中的自研运行框架,也不得包含默认密码或客户秘密。
## 最终方案
- `Sense/scripts/build/build-windows.ps1` 严格检查 Go 1.26.5、Node 22.22.1、pnpm 9.15.1,执行前后端生产构建、许可证和迁移基线复制、内容审计并生成目录与 ZIP。
- `Sense/scripts/runtime` 提供白名单 env 解析、check/start/stop/migrate/bootstrap/backup/restore。配置文件只作为数据读取,同名非空进程环境优先,日志不打印秘密。
- production 只接受 PostgreSQL,要求至少 32 字符 JWT secret,MediaMTX 使用 managed 或 external;启动前完成迁移与媒体服务就绪检查,失败不开放 HTTP。Demo 使用独立配置和名称含 demo/test 的数据库。
- 继续调用现有 `sense.exe migrate -c ...` 与 `sense.exe server -c ...`。在现有 Gin Engine 上增加可选同源 SPA fallback;未配置 `SENSE_WEB_ROOT` 时保持上游行为。MediaMTX 显式模式增加启动前就绪门禁,未配置模式保留既有惰性行为。
- 管理员初始化无默认账户密码;密码经隐藏提示输入。停止脚本验证端口与可执行文件归属;备份密码只通过子进程环境;恢复要求目标库名称和二次短语确认。
- 空白 PostgreSQL 17 已成功执行完整迁移,未复现 #67 曾记录的旧 `sys_config` 字段长度问题,因此未修改上游迁移。
## 修改文件
- `Sense/scripts/build/**`、`Sense/tests/package/**`:固定工具链构建、包审计与配置/失败路径自动化。
- `Sense/scripts/runtime/**`:Windows 配置、检查、迁移、启动停止、初始化、备份恢复入口。
- `Sense/config/**`、`Sense/package/**`、`Sense/README-WINDOWS.md`:空值示例、MediaMTX 基线和现场说明。
- `Sense/server/cmd/api/server.go`、`web.go`、`web_test.go`:现有 GoAdmin Gin 服务的可选 SPA 托管。
- `Sense/server/app/sense/media/runtime.go`、`runtime_test.go`:显式 MediaMTX 模式的启动前就绪门禁。
- `Sense/.gitignore`:忽略可重建交付产物并允许版本化构建脚本。
- Wiki `Architecture-and-Code-Map`、`Local-Development-and-Verification`、`Delivery-Documentation-Guide` 及对应 `docs/` 镜像:运行链、构建验证和现场交付说明。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 干净环境按单一命令生成 Windows amd64 交付包 | 通过;固定工具链构建目录和 ZIP |
| 包不包含 node_modules、构建缓存、真实秘密或客户数据 | 通过;包审计及源码残留检查通过 |
| start 读取 config/sense.env,进程环境优先且不执行内容 | 通过;自动化覆盖优先级、特殊字符和注入非执行 |
| production 检查 PostgreSQL、迁移、端口和 MediaMTX | 通过;隔离 PostgreSQL 17 与 MediaMTX 包 smoke 通过 |
| demo 与 production 明确隔离 | 通过;独立配置且数据库名必须含 demo/test |
| 提供安全初始化、密码修改、停止、备份和恢复步骤 | 通过;脚本、包内说明与长期 Wiki 已更新 |
最终 ZIP:`Sense/dist/sense-windows-amd64.zip`,大小 56,455,903 字节,SHA-256 `B500982BD566005DC8876A418C26A75BCABE41F498D10E3DAD286A71C92F0241`。包内 `VERSION.txt` 记录实现提交 `b66c39724c42b9e63891d06a41fb4a87c8c3f6c7`,包含 #92、#95 的旧库兼容修复、白屏修复及已验收 #97 的免验证码登录。
## 测试
- `go test ./...`、`go vet ./...`、`go build ./...`:通过。
- `go test -race ./app/sense/media ./cmd/api`:通过。
- PowerShell 包测试:21 项断言通过,覆盖配置注入不执行、特殊字符、环境优先级、demo 数据库隔离、不支持字段拒绝、HTML 本地资源正反例及全部脚本语法。
- `Sense/scripts/build/build-windows.ps1 -MediaMTXPath <已审核本机路径>`:通过;前端剩余 4 条非阻塞构建 warning;导致启动失败的 runtime 与 SCSS 导出 warning 已消除。
- `Sense/scripts/build/test-package.ps1 -PackageRoot Sense/dist/sense-windows-amd64`:通过。
- 隔离 PostgreSQL 17:空库 8 个迁移通过;首页、SPA fallback、`/healthz`、MediaMTX Control API、包外目录启动停止通过;119,630 字节 custom-format 备份及恢复到另一数据库通过。
- `git diff --check`:通过。
- `python dev_scripts/check_harness.py --strict`:未通过,原因仅为既存 `docs/task/66`、`docs/task/67` 缺少当前模板要求的“修改文件/未验证”章节;#70 未修改这两个既有归档,也未发现 #70 新增问题。
- **未验证部分**:尚未在全新客户 Windows 机器、客户生产 PostgreSQL 账号、目标浏览器和真实获准摄像机上验收;Windows 服务化不在本工单范围。
## 旧库交付回归
- 真实迁移前已生成仓库外 PostgreSQL custom-format 备份与配置副本,备份通过 `pg_restore --list` 校验。
- #92 成功把旧设备 `capabilities` 转为 JSONB;#95 继续兼容旧媒体路由 `path` 唯一约束和缺失运行态列。
- 真实库 2 条媒体路由完整保留,运行态列无空值,`idx_sense_media_routes_path` 与设备/Profile 组合唯一索引均有效,媒体迁移版本已登记。
- Web 首页、SPA、`/healthz`、MediaMTX Control API 均返回 200;停止脚本成功且 Sense/MediaMTX 监听端口全部清空。
- PowerShell `Invoke-WebRequest` 在本机受代理环境影响而无法访问 loopback;使用明确绕过代理的本机 HTTP 客户端确认服务正常,该现象不属于 Sense 服务失败。
## 白屏验收反馈修复
- 用户运行发布包后访问生产入口出现白屏。只读诊断确认首页 HTML 返回 200,但现代浏览器请求的 `runtime.daef9028.js` 不在包内并返回 404;修复 runtime 内联配置后,浏览器继续暴露 GoAdmin `:export` 主题变量在 css-loader 6 下没有 JavaScript 导出的启动错误。
- 删除不可靠的 runtime 内联插件配置,使现代与 legacy runtime 都作为独立文件进入产物;为 css-loader 启用不改写普通类名的 ICSS mode,保留 GoAdmin 原有 SCSS `:export` 变量模式。
- 新增 `assert-web-assets.ps1`,构建阶段逐项核对 `index.html` 引用的本地 JS/CSS;缺失 runtime 的反例会直接使包构建失败。
- Chromium 最终打开 `http://127.0.0.1:18080/` 并进入账号登录页;首屏 7 个 JS/CSS 全部返回 200,白屏和阻止 Vue 挂载的错误消失。测试完成后停止 Sense 与 MediaMTX,18080 无监听。
- 浏览器仍观察到不阻塞首屏的既有 `/api/v1/app-config` 404 和上游默认百度统计请求;不属于本次白屏修复范围,未混入当前提交。
## 遗留问题
- Harness 严格检查的 #66/#67 既有归档格式问题需独立处理,不阻塞 #70 产品代码和交付包验证。
- 客户环境验收需由实施人员使用脱敏测试账户和获准设备完成。
## 相关提交
- `6b79478` 建立 Sense Windows 交付包。
- `e4544a0` 记录 Sense Windows 交付流程。
- `4ca4abf` 修复 Windows 包白屏并增加静态资源闭环审计。
## #97 集成与最终重打包(2026-08-16)
- 将 `dev@116318df748ff0d46d3fe5f8a4f41a6507567eec` 合入 #70 分支,发布包现已包含 #97 的账号密码直接登录;登录页和登录载荷不再包含验证码字段或请求,兼容 captcha API 保留。
- Windows PowerShell 构建在生成清单时暴露 `Get-FileHash` 模块自动加载依赖;改用 .NET `SHA256` 流式计算,避免客户构建环境因模块加载差异失败。实现提交:`b66c39724c42b9e63891d06a41fb4a87c8c3f6c7`。
- 固定工具链构建通过;21 项包测试、Go 全量 test/vet、65 个 ZIP 清单文件逐项哈希、模板配置/无 node_modules 审计通过。
- 使用仓库外配置备份完成真实 PostgreSQL 迁移、首页/SPA、MediaMTX、外部目录 start/stop smoke;测试后 18080/9997 无监听。本地解压目录恢复现场 `sense.env`,ZIP 内仍只含无秘密模板。
- 首次在 PowerShell 7 下调用 smoke 的 `Invoke-WebRequest` 出现 loopback 超时;同一服务用 curl 返回 200,按交付目标的 Windows PowerShell 5.1 正式入口复测全部通过,确认不是 Sense 服务阻塞。
- 新 ZIP:56,455,903 字节;SHA-256 `B500982BD566005DC8876A418C26A75BCABE41F498D10E3DAD286A71C92F0241`。
## 人工验收
- 2026-08-16:用户明确验收通过 #70。
- #95 已先合入 `dev`,随后按依赖顺序合并 PR #89;`main` 保持不变。
@@ -0,0 +1,80 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-90-Sense项目根目录Windows启动脚本
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-90-Sense%E9%A1%B9%E7%9B%AE%E6%A0%B9%E7%9B%AE%E5%BD%95Windows%E5%90%AF%E5%8A%A8%E8%84%9A%E6%9C%AC.-
wiki_revision: cdfab02efe085b8e40cc19b0941e5c3d3c1ae7aa
synchronized_at: 2026-08-27T08:40:20Z
<!-- gitea-wiki-mirror:end -->
# 90 Sense项目根目录Windows启动脚本
- 类型:需求
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:已完成
- 日期:2026-08-15
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/90
- Wiki 页面:Task-90-Sense项目根目录Windows启动脚本
- Wiki revision:见本地镜像头
## 背景与目标
#70 建立的 Windows 交付包入口位于 `Sense/dist/sense-windows-amd64/start-sense.bat`,用户从项目目录启动时需要进入多层目录。工单 #90 增加项目根目录快捷入口,同时保持包内脚本为配置、迁移和服务编排的唯一事实源。
## 最终方案
新增 `Sense/start_sense.bat`。脚本使用 `%~dp0` 定位同一项目下的交付包,不依赖调用者当前工作目录;通过 `call ... %*` 原样透传 production、demo 和开关参数,并保存子脚本退出码。交付包入口不存在时输出预期路径和构建命令,返回退出码 2。
脚本不读取 `sense.env`、不处理密码或 token、不自动构建,也不直接启动 Go/Node 开发服务。项目 README 与 Wiki Project-Profile 记录快捷命令;#70 的包内说明和运行脚本保持不变。
## 修改文件
- `Sense/start_sense.bat`:项目根目录 Windows 启动入口。
- `Sense/README.md`:增加 production/demo 快捷启动说明与职责边界。
- Wiki `Project-Profile`、`docs/00-project-profile.md`:记录长期启动入口。
- `wiki-docs.json`、`docs/task/90-Sense项目根目录Windows启动脚本.md`:登记任务归档镜像。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 从任意工作目录定位包内入口 | 通过;从 `C:\Windows` 使用隔离夹具和当前本地交付包验证 |
| 参数和退出码原样传递 | 通过;`demo -SkipMigration "two words"` 原样到达假包内脚本,退出码 17 保持 |
| 缺失交付包时明确失败 | 通过;输出预期路径和构建命令,退出码 2 |
| 不包含秘密、不解析配置、不复制启动实现 | 通过;脚本仅 17 行定位、检查、调用和退出码逻辑 |
| 项目说明和 Wiki 镜像一致 | 通过;受影响页面定向同步与检查通过 |
## 测试
- 隔离缺失包夹具:从 `C:\Windows` 调用,错误信息可行动,退出码 2。
- 隔离假包内脚本:参数输出为 `demo -SkipMigration "two words"`,子脚本退出码 17 被根入口保留。
- 当前 #70 本地交付包:从 `C:\Windows` 分别调用包内入口和根入口并传入安全的无效模式,两者均返回参数校验退出码 1,证明真实路径连接和退出码一致。
- 敏感关键词扫描:脚本不含 password、token、secret、database URL 或 credential。
- `git diff --check`:通过。
- Wiki 受影响页面定向 `sync_wiki_docs.py --check --config .tmp-wiki-90.json`:通过。
- **未验证部分**:未通过根入口实际启动 production/demo 服务,以避免在本工单重复操作数据库和服务进程;真实启动链已由 #70 验证。完整 Wiki 全量检查暂受待验收 PR #89 已更新但尚未合入 `dev` 的三个 #70 镜像影响,#90 只对不冲突页面做定向一致性检查。
## 遗留问题
- `dev` 合入 #70 后才包含根入口所调用的版本化包内构建和启动脚本;在此之前根入口会按设计提示先构建且返回 2。
## 相关提交
- `a865dda` 增加 Sense 根目录启动脚本。
- `06d982d` 记录 Sense 根目录启动方式。
## 最新 dev 组合回归(2026-08-27)
- 分支合并 `dev@15142157299936231fc4dda9aa6624bba34aeed3`,合并提交为 `404e25584e41e4c3ba4e8850b4aa07452ae3f7af`。
- 唯一冲突位于 `wiki-docs.json`:#90 与后续 #70/#95/#97/#99/#101/#104/#106 同时新增任务归档映射;解决时保留全部映射。Sense 业务代码和启动脚本无冲突。
- 在仓库外隔离夹具中从 `D:\` 调用根脚本,`demo -SkipMigration "two words"` 原样到达包内脚本,退出码 17 保持。
- 缺失交付包时输出预期路径与构建命令并返回 2;当前真实交付包入口存在于约定路径。
- 根脚本敏感关键词扫描、`git diff --check`、Harness 31 项单测通过;全量 Wiki 镜像检查通过。
- `check_harness.py --strict` 仍仅被既有 #66/#67 归档缺少模板章节的 4 项问题阻断,与 #90 无关。
- 为避免与当前 Supervisor 托管的生产实例争用 18080,本轮没有通过根入口重复启动服务;`yovision-sense` 保持 Running,健康检查返回 200。
## 验收确认
- 2026-08-27,用户明确确认“#90 验收通过”。
- PR #91 按规定合入 `dev`,`main` 未变更。
- 工单 #90 状态更新为“已完成”并关闭;所属 MVP #8 的子工单索引同步勾选。
@@ -0,0 +1,80 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-92-Sense旧设备能力JSONB兼容迁移
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-92-Sense%E6%97%A7%E8%AE%BE%E5%A4%87%E8%83%BD%E5%8A%9BJSONB%E5%85%BC%E5%AE%B9%E8%BF%81%E7%A7%BB.-
wiki_revision: cbdcc65c2b7da74048713d49dcb4b49b47cec17e
synchronized_at: 2026-08-15T07:30:56Z
<!-- gitea-wiki-mirror:end -->
# 92 Sense旧设备能力JSONB兼容迁移
- 类型:缺陷
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:已完成
- 日期:2026-08-15
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/92
- Wiki 页面:Task-92-Sense旧设备能力JSONB兼容迁移
- Wiki revision:见本地镜像头
## 背景与目标
用户通过 Windows 包启动 Sense 时,设备迁移报 PostgreSQL `SQLSTATE 42804`。只读诊断确认旧数据库的 `sense_devices.capabilities` 为 `text NOT NULL DEFAULT ''::text`,一条旧记录保存受支持的单值能力但不是 JSON;新模型要求 JSONB,GORM AutoMigrate 无法直接转换旧默认值和数据。
目标是在不删除设备、不跳过迁移、不修改当前用户数据库的前提下,为旧 schema 提供确定性、可回滚的 JSONB 兼容迁移。
## 最终方案
在现有 `2026081414000` 设备迁移事务开头执行 PostgreSQL 专用兼容步骤。受影响数据库尚未登记该迁移版本,后置新版本无法越过失败点,因此兼容逻辑必须放在原失败迁移内。
兼容步骤只处理既有 text/varchar 列:取得 ACCESS EXCLUSIVE 表锁,读取并验证全部旧值后才开始改变默认值和数据。空值转为 `[]`;六种受支持旧单值转为单元素 JSON 数组;合法字符串数组规范化后保持语义。未知单值、对象、非字符串数组、未知数组元素和超过 16 项的数组会返回不含业务值的错误,整个事务回滚。
全部验证通过后删除旧 text 默认值,参数化更新规范 JSON,使用显式 `USING capabilities::jsonb` 转型并设置 `'[]'::jsonb` 默认值,再继续原有 GORM AutoMigrate、菜单、权限和迁移版本登记。非 PostgreSQL、表/列不存在以及已是 json/jsonb 时不执行旧值转换。
## 修改文件
- `Sense/server/cmd/migrate/migration/version/2026081414000_device.go`:事务内旧 text 能力验证、转换、锁表和 JSONB 默认值处理。
- `Sense/server/cmd/migrate/migration/version/2026081414000_device_test.go`:纯函数和隔离 PostgreSQL 17 回归,包括完整设备迁移与版本登记。
- Wiki `Troubleshooting`、`docs/06-troubleshooting.md`:备份、重试、未知旧值和回退说明。
- `wiki-docs.json`、`docs/task/92-Sense旧设备能力JSONB兼容迁移.md`:任务归档登记和镜像。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 旧 text 默认值不再触发 SQLSTATE 42804 | 通过;隔离 PostgreSQL 重现结构成功转为 jsonb |
| 旧单值无损转换为 JSONB 数组 | 通过;`video` 转为单元素数组 |
| 空值与合法数组正确处理 | 通过;空字符串转空数组,合法数组保持顺序与值 |
| 未知值拒绝且无半成品 | 通过;类型、默认值和已验证行均保持旧状态 |
| 新库、jsonb 和重复执行兼容 | 通过;无表跳过、jsonb 重复调用无操作 |
| 全量 Go 和迁移回归通过 | 通过 |
| 当前用户数据库未被修改 | 通过;仅执行只读诊断,写测试使用独立数据库并在结束后删除 |
| Wiki 与归档一致 | 受影响页面定向检查通过 |
## 测试
- `go test ./cmd/migrate/migration/version -run TestCanonicalLegacyCapabilities -count=1 -v`:通过。
- 隔离 PostgreSQL 17 `TestDeviceCapabilitiesMigrationOnPostgres`:旧单值/空值/合法数组、未知值事务回滚、无表、重复调用、完整设备迁移和 `sys_migration` 登记全部通过。
- 隔离数据库 `sense92_migration_test` 测试前确认不存在,测试后确认计数为 0。
- `go test ./...`、`go vet ./...`、`go build ./...`:通过。
- `go test -race ./cmd/migrate/migration/version -run TestCanonicalLegacyCapabilities -count=1`:通过。
- `git diff --check`:通过。
- 受影响 Wiki 定向同步与检查:通过。
- **未验证部分**:按工单安全边界未在当前用户 `sense` 数据库执行写迁移,也未替换当前 `Sense/dist` 中的待验收 #70 二进制;需先备份,再部署包含 #92 的新包进行最终启动验收。全量 Wiki 检查仍会先发现待验收 PR #89 的 #70 镜像尚未合入 `dev`。
## 用户验收
- 用户于 2026-08-15 明确确认 `#92 验收通过`。
- 实现 PR #93 已合入 `dev`,合并提交为 `eaa6ae081542b0b9c74cc2d92a9138639fdbe530`。
- #92 已完成;真实数据库备份、交付包重建与启动回归继续在 #70 的交付闭环中执行。
## 遗留问题
- 当前交付包仍含 #92 修复前的 `sense.exe`。#92 合入 `dev` 后需让 #70 交付分支吸收该提交并重新打包,用户备份数据库后再运行迁移。
- Harness strict 的 #66/#67 既有归档格式问题不属于本工单。
## 相关提交
- `68b436a` 兼容旧设备能力字段迁移。
- `fe3badf` 覆盖旧设备完整迁移链。
- `aee5f45` 记录设备能力迁移排错。
@@ -0,0 +1,80 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-95-Sense旧媒体路由唯一约束兼容迁移
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-95-Sense%E6%97%A7%E5%AA%92%E4%BD%93%E8%B7%AF%E7%94%B1%E5%94%AF%E4%B8%80%E7%BA%A6%E6%9D%9F%E5%85%BC%E5%AE%B9%E8%BF%81%E7%A7%BB.-
wiki_revision: 5f3bdf3786f2a72dac5d29236ff466743e2912b5
synchronized_at: 2026-08-16T11:31:47Z
<!-- gitea-wiki-mirror:end -->
# 95 Sense旧媒体路由唯一约束兼容迁移
- 类型:缺陷
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:已完成
- 日期:2026-08-15
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/95
- Wiki 页面:Task-95-Sense旧媒体路由唯一约束兼容迁移
- Wiki revision:见本地镜像头
## 背景与目标
#92 修复进入真实旧库后,设备能力字段已成功转换为 JSONB;下一条媒体迁移因 GORM 尝试删除不存在的推导约束名 `uni_sense_media_routes_path` 而报 SQLSTATE 42704。只读核对确认旧 `sense_media_routes.path` 实际由 PostgreSQL 自动命名约束 `sense_media_routes_path_key` 保证唯一,且表中已有 2 条路由。
隔离回归越过约束错误后进一步确认,旧非空表缺少当前模型要求的运行态列,直接新增 `source_ready NOT NULL` 会报 SQLSTATE 23502。目标是在不删除路由、不削弱 path 唯一性、不伪造媒体已就绪的前提下完成旧表迁移。
## 最终方案
在现有 `2026081419000` 媒体迁移事务开头执行 PostgreSQL 专用兼容步骤。仅当旧表存在时取得 ACCESS EXCLUSIVE 锁,从 pg_catalog 读取包含 path 的唯一约束;只接受唯一的单列 `UNIQUE(path)`,复合、多重或冲突结构拒绝迁移并整体回滚。
确认结构后,把数据库实际约束名规范为 GORM 能识别和移除的名称,让 AutoMigrate 转换为模型的 `idx_sense_media_routes_path` 唯一索引。锁在整个迁移事务提交前持续有效,因此约束切换期间没有并发写入窗口。
兼容步骤同时为旧路由添加并回填当前模型要求的运行态列:`source_ready=false`、`failure_count=0`、`last_error_code=''`,随后设为 NOT NULL。保守初值表示服务启动后必须重新对账,不把旧路由冒充为已经就绪;`next_retry_at` 保持可空并由 AutoMigrate 建立。
## 修改文件
- `Sense/server/cmd/migrate/migration/version/2026081419000_media.go`:旧约束识别、规范化、锁表和运行态列兼容。
- `Sense/server/cmd/migrate/migration/version/2026081419000_media_test.go`:隔离 PostgreSQL 旧表、非空路由、空库、无表、重复执行、唯一性和不安全结构回滚测试。
- Wiki `Troubleshooting`、`docs/06-troubleshooting.md`:错误含义、备份、迁移、验证和回退步骤。
- `wiki-docs.json`、`docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md`:任务归档登记和镜像。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 旧约束名不再触发 SQLSTATE 42704 | 通过;隔离和真实 PostgreSQL 均完成媒体迁移 |
| 既有媒体路由完整保留 | 通过;真实库迁移前后均为 2 条 |
| path 始终具有唯一性保护 | 通过;迁移后 `idx_sense_media_routes_path` 唯一索引有效,重复 path 写入被拒绝 |
| 无表、新库、已迁移库和重复兼容 | 通过 |
| 不安全结构拒绝并回滚 | 通过;复合 path 约束夹具未发生部分变更 |
| Go 全量与隔离 PostgreSQL 回归 | 通过 |
| #70 Windows 包真实迁移与启动 smoke | 通过;Web/SPA/health/MediaMTX 200,停止后端口清空 |
| Wiki 镜像与任务归档一致 | 通过 |
## 测试
- 隔离 PostgreSQL 17 `TestMediaMigrationOnPostgres`:旧 2 路由、旧约束、运行态回填、空库、无表、重复兼容、唯一性冲突和复合约束回滚全部通过;独立测试数据库每次运行后删除。
- `go test ./...`、`go vet ./...`、`go build ./...`:通过。
- `go test -race ./cmd/migrate/migration/version -run TestMediaMigrationOnPostgres -count=1`:使用隔离 PostgreSQL 通过。
- #70 固定 Go 1.26.5、Node 22.22.1、pnpm 9.15.1 production build 与包审计通过。
- 迁移前 PostgreSQL custom-format 备份通过 `pg_restore --list`;真实迁移完成,2 条旧路由保留、运行态列无空值、媒体迁移版本登记、唯一索引有效。
- Web 首页、SPA、`/healthz` 与 MediaMTX Control API 均返回 200;`stop-sense.bat` 后相关端口无监听。
- `git diff --check`:通过。
- **未验证部分**:尚未在客户全新 Windows 主机、客户生产 PostgreSQL 账号和真实获准摄像机上验收;当前回归使用本机 PostgreSQL、脱敏业务计数和已配置测试摄像机环境。Harness strict 仍只受既存 #66/#67 归档格式影响。
## 遗留问题
- PowerShell `Invoke-WebRequest` 在本机受代理环境影响,访问 loopback 时失败;明确绕过代理的本机 HTTP 请求验证服务正常,不属于 Sense 服务端故障。
- #95 需先经用户验收并合入 `dev`,随后 #70 才能按依赖顺序完成合并。
## 相关提交
- `3dd4890` 兼容旧媒体路由约束和运行态列迁移。
- `6257859` 记录旧媒体路由迁移排错。
- `c088caf` #70 集成 #95 后用于 Windows 包真实回归。
## 人工验收
- 2026-08-16:用户明确验收通过 #95。
- 按依赖顺序先将 PR #96 合入 `dev`;`main` 保持不变。
@@ -0,0 +1,85 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-97-Sense免验证码登录与管理员密码重置
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-97-Sense%E5%85%8D%E9%AA%8C%E8%AF%81%E7%A0%81%E7%99%BB%E5%BD%95%E4%B8%8E%E7%AE%A1%E7%90%86%E5%91%98%E5%AF%86%E7%A0%81%E9%87%8D%E7%BD%AE.-
wiki_revision: 486ebcca10e4cef0bd905df59b928c17006db17e
synchronized_at: 2026-08-16T11:04:57Z
<!-- gitea-wiki-mirror:end -->
# 97 Sense免验证码登录与管理员密码重置
- 类型:安全行为调整 / 缺陷修复
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:已完成
- 日期:2026-08-15
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/97
- Wiki 页面:Task-97-Sense免验证码登录与管理员密码重置
- Wiki revision:见本地镜像头
## 背景与目标
GoAdmin 重建后的 Sense 登录页和生产后端重新启用了验证码,与用户确认的账号密码直接登录流程不一致。用户要求所有运行模式恢复免验证码登录,并把当前本地 PostgreSQL 的管理员账号设置为用户指定、满足现行 6–72 字节策略的密码;密码明文不得进入仓库、工单、Wiki 或日志。
## 最终方案
- 保留冻结 GoAdmin 的 Gin/GORM/JWT/Casbin `Authenticator`、登录路由、Vuex 登录动作、Element Plus 表单和同步身份审计。
- 登录 DTO 只保留 `username/password`;production、test、dev 均不再校验验证码。
- 登录页移除验证码字段、规则、图标、接口请求、失败刷新和样式;兼容保留 `/api/v1/captcha` 端点及上游存储初始化,便于回退。
- Swagger 登录载荷同步为只要求账号和密码。
- 修复失败认证分支未把用户名写入审计的问题,使错误密码尝试可按账号追踪。
- 目标数据库起初没有任何用户,因此没有执行不安全的直接插入;使用一次性高熵进程令牌走既有 `/api/v1/bootstrap` 安全初始化路径创建 `admin`,密码只在进程内传递。随后验证正确密码成功、错误密码拒绝和成功/失败审计。
- 密码最少 6 位、最多 72 字节且不强制字符复杂度的既有策略保持不变;JWT、RBAC、Cookie 和会话有效期未修改。
## 修改文件
- `Sense/server/common/middleware/handler/auth.go`:移除验证码校验并补齐失败登录用户名审计。
- `Sense/server/common/middleware/handler/login.go`:登录载荷只保留账号和密码。
- `Sense/server/common/middleware/handler/login_test.go`:覆盖无验证码登录载荷。
- `Sense/server/docs/admin/admin_docs.go`、`admin_swagger.json`、`admin_swagger.yaml`:同步登录接口模型。
- `Sense/ui/src/views/login/index.vue`:移除验证码 UI、请求和状态。
- `Sense/ui/tests/unit/login/loginPage.spec.js`:覆盖登录页只使用账号密码。
- Wiki `Product-Requirements`、`Local-Development-and-Verification` 及镜像:记录免验证码登录、安全审计和密码策略边界。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 登录页不显示验证码且不请求验证码接口 | 通过:组件状态、规则与方法均只含账号密码;生产构建通过 |
| 所有模式接受仅账号密码的登录载荷 | 通过:后端不再按模式进入 captcha 校验,DTO 定向测试通过 |
| 正确密码成功、错误密码拒绝并有脱敏审计 | 通过:本地 production/PostgreSQL smoke 成功;成功与失败审计均可按 admin 查询 |
| JWT、RBAC、未登录拒绝不变 | 通过:未认证管理路由返回 401,全量后端测试通过 |
| 密码策略仍为 6–72 字节 | 通过:现有密码策略测试通过,相关代码未修改 |
| 管理员密码安全设置且仓库无明文 | 通过:空用户库经一次性 bootstrap 创建,跟踪差异秘密扫描无泄漏 |
| 前后端测试和 production build | 通过 |
## 测试
- `go test ./...`:通过。
- `go vet ./...`:通过。
- `go build ./...`:通过。
- `corepack pnpm@9.15.1 exec eslint src/views/login/index.vue tests/unit/login/loginPage.spec.js`:通过。
- 前端全量单测:17 个 suite、46 个 test 通过。
- `corepack pnpm@9.15.1 run build:prod`:通过;存在既有 Sass、SCSS export、代码生成器和体积 warning,未由本工单引入。
- production/PostgreSQL/MediaMTX smoke:安全初始化成功、免验证码登录成功、错误密码拒绝、未认证接口返回 401;身份审计包含首个管理员创建、登录成功和登录失败记录。
- `python -m unittest discover -s tests -v`:31 项通过。
- `python dev_scripts/check_harness.py --strict`:只因既有 #66/#67 归档缺少当前模板章节失败 4 项,与本工单修改无关。
- `git diff --check` 与跟踪差异密码扫描:通过。
- 测试结束后 Sense、MediaMTX 均已停止,18080/9997 无监听;`Sense/ui/node_modules` 与 `Sense/ui/dist` 已清理。
- **未验证部分**:尚未在 #70 最终 Windows ZIP、客户全新 Windows 主机和客户目标浏览器中重新打包验收;#70 与 #97 合入 `dev` 后需重新生成发布包。
## 遗留问题
- 验证码 API 为上游兼容而保留但登录链不使用;若未来永久删除,需单独清理工单核对依赖和回退。
- 取消验证码降低自动化暴力尝试阻力;本工单按用户确认保留失败登录审计,但未引入未确认的限流或锁定体系。
## 相关提交
- `0bbdb27f6db4f9d08445ca02abae9061c81598b8` 恢复免验证码登录并补充测试。
- `bc1a01848d1769250b706f207d542b63fee2afa6` 更新 Sense 登录安全边界镜像。
## 人工验收
- 2026-08-16:用户明确验收通过 #97。
- 按工作流将 PR #98 合入 `dev`;`main` 保持不变。
@@ -0,0 +1,79 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-99-Sense登录页只读配置接口
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-99-Sense%E7%99%BB%E5%BD%95%E9%A1%B5%E5%8F%AA%E8%AF%BB%E9%85%8D%E7%BD%AE%E6%8E%A5%E5%8F%A3.-
wiki_revision: 7d0dd9b216e443b00a97f021d49678b848006db9
synchronized_at: 2026-08-16T15:41:39Z
<!-- gitea-wiki-mirror:end -->
# 99 Sense登录页只读配置接口
- 类型:缺陷修复 / GoAdmin 路由精简回归
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:已完成
- 日期:2026-08-16
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/99
- Wiki 页面:Task-99-Sense登录页只读配置接口
- Wiki revision:见本地镜像头
## 背景与目标
用户启动 #70 Windows 发布包后,登录页弹出 `Request failed with status code 404`。只读诊断确认首页、runtime JS 和兼容 captcha 端点均为 200,唯一失败请求是登录页在 created 阶段发出的 `GET /api/v1/app-config`。
冻结 go-admin-ui 使用该匿名接口取得系统名称等登录外壳展示配置;Sense 后端保留了 `SysConfig.Get2SysApp` handler,但在最小化默认模块时没有注册此路由。目标是恢复这一个只读端点,同时继续禁用系统配置管理和写接口。
## 最终方案
- 在现有 `registerBaseRouter` 中单独注册匿名 `GET /api/v1/app-config`,直接复用 GoAdmin `SysConfig.Get2SysApp`。
- 不调用上游完整 `registerSysConfigRouter`,因此 `/api/v1/config`、`/api/v1/configKey` 和 `/api/v1/set-config` 不会随之开放。
- handler 继续只查询 `is_frontend=1` 的配置并返回标准响应;没有前端配置时返回空对象和业务码 200。
- 保持 #97 的免验证码登录、JWT、RBAC、Cookie、密码策略和 captcha 兼容端点不变。
- 基于实现提交重新生成 Windows 包,恢复仓库外备份的现场配置,并重新启动 Sense/MediaMTX。
## 修改文件
- `Sense/server/app/admin/router/sys_router.go`:恢复登录外壳必需的匿名只读 app-config 路由。
- `Sense/server/app/admin/router/sys_router_test.go`:锁定公开端点和八个仍禁用的配置管理路由。
- Wiki `Architecture-and-Code-Map` 及镜像:记录匿名只读例外与配置管理禁用边界。
- `wiki-docs.json` 与本任务镜像:登记任务归档。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 匿名 app-config 返回 HTTP 200 和标准结构 | 通过:HTTP 200、业务码 200 |
| 登录页不再出现该 404 | 通过:真实 Edge 页面 app-config 200,错误消息数 0 |
| 配置 CRUD/configKey/set-config 未启用 | 通过:GET/PUT 定向 smoke 均为 404 |
| 免验证码登录与认证边界不变 | 通过:登录页仅账号和密码,无验证码文本;后端全量测试通过 |
| Go 与 Windows 包测试 | 通过 |
| ZIP 无秘密、node_modules 或客户数据 | 通过:构建内置包审计通过,ZIP 使用模板配置 |
| Wiki、归档与证据完整 | 通过 |
## 测试
- `go test ./app/admin/router -run TestRegisterBaseRouterExposesOnlyFrontendAppConfig -count=1`:通过。
- `go test ./...`、`go vet ./...`、`go build ./...`:通过。
- Windows PowerShell 包测试:21 项断言通过。
- 固定 Go 1.26.5、Node 22.22.1、pnpm 9.15.1 production build 与 11 个 HTML 本地资源审计通过;存在既有 4 条非阻塞构建 warning。
- 真实运行:`GET /api/v1/app-config` 为 HTTP 200/业务码 200;配置 CRUD、configKey、set-config GET/PUT 均为 404。
- Headless Edge:进入 `/#/login?redirect=/dashboard`;app-config 200;错误消息 0;仅账号、密码两个输入;无验证码文本、失败请求或页面异常。
- 新 ZIP:56,457,323 字节;SHA-256 `A98EB70B709BBA546F4968723E4F89CE44DAF412FCC426869820FB7454CB3CC7`;包内 `source_commit=713c9e4e3003088e29aafcf1fdd3bd87078ec394`。
- 测试后 Sense 与 MediaMTX 已使用恢复的现场配置重新启动,监听 18080/9997。
- `git diff --check` 与 Wiki 定向同步检查:通过。
- **未验证部分**:尚未在客户全新 Windows 主机、客户生产 PostgreSQL 账号和客户目标浏览器中验收;本轮使用当前机器 PostgreSQL、Microsoft Edge 和现有脱敏配置验证。
## 遗留问题
- 无本工单阻塞项。
## 相关提交
- `713c9e4e3003088e29aafcf1fdd3bd87078ec394` 恢复登录页只读配置接口并增加负向路由测试。
- `08b4b61` 记录登录外壳只读配置边界。
## 人工验收
- 用户于 2026-08-16 明确回复“#99 验收通过”。
- PR #100 已合入 `dev`,合并提交:`34ee5ed619d122a8670dc50039578686a7a9ef66`。
- 工单已按验收流程关闭;MVP #8 与 Epic #7 的子工单索引同步为完成。
+282
View File
@@ -0,0 +1,282 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-Template
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Deployment-Template.-
wiki_revision: 75bd653ab2f3314a37eb9086e18152129941befa
synchronized_at: 2026-08-27T09:07:38Z
<!-- gitea-wiki-mirror:end -->
# 部署文档模板
> 使用说明:本页是 DevHarness 模板,不描述任何真实服务。有常驻服务的项目复制本页,在自己的 Gitea Wiki 创建 `Deployment-and-Operations` 页面,并在本项目 `wiki-docs.json` 增加映射(建议镜像到 `docs/10-deployment-and-operations.md`)。填写时删除全部说明性文字,不得保留“待填写”后直接交付。没有常驻服务的项目不要创建部署页,在初始化工单记录原因即可。
>
> 本模板面向项目内部维护者。面向客户或外部运维岗位的部署说明属于交付文档,使用[岗位文档模板](Audience-Document-Template.-)。
>
> 标准栈为 nginx 反向代理 + supervisor 进程托管,示例按 Python 服务(gunicorn / uvicorn)编写。实际技术栈不同时替换命令,但保留章节结构和“每条命令写明预期结果”的要求。
## 本页用途
让维护者能够在一台干净的服务器上完成首次部署、日常运维、健康检查和回滚。所有命令默认以具备 sudo 权限的账号在服务器上执行,示例以 Linux 为主。
## 安全边界
- 本页只写配置项的**名称和来源**,不写任何真实密码、令牌、私钥、证书内容、生产数据库地址或个人数据。
- 需要凭据的步骤写明“从哪里取”,例如运维密码库条目名或环境变量名。
- 涉及删除数据、数据库迁移和不可逆操作的步骤必须给出醒目警告、影响范围和回退条件。
## 服务概览
| 项目 | 内容 |
|---|---|
| 服务名(supervisor program) | `<myapp>` |
| 代码部署目录 | `/srv/<myapp>` |
| 运行账号 | `<myapp>` |
| 运行时 | Python `<3.x>` |
| 应用服务器 | gunicorn / uvicorn |
| 本地监听地址 | `127.0.0.1:<8000>` |
| 进程数 | `<n>` |
| 对外域名与路径 | `https://<example.com>/` |
| 依赖的外部服务 | 数据库 / 缓存 / 对象存储 / 无 |
| 日志目录 | `/var/log/<myapp>/` |
服务只监听 `127.0.0.1`,不直接对外暴露端口;所有外部访问经 nginx 转发。
## 环境要求
| 组件 | 版本要求 | 检查命令 | 预期结果 |
|---|---|---|---|
| 操作系统 | `<Ubuntu 22.04>` | `cat /etc/os-release` | 输出与要求一致 |
| Python | `<3.11+>` | `python3 --version` | 输出版本号且不低于要求 |
| nginx | `<1.18+>` | `nginx -v` | 输出版本号 |
| supervisor | `<4.2+>` | `supervisord --version` | 输出版本号 |
未安装时:
```bash
sudo apt update
sudo apt install -y nginx supervisor python3-venv
```
**预期结果**:`systemctl status nginx` 与 `systemctl status supervisor` 均为 `active (running)`。
## 首次部署
### 1. 创建运行账号与目录
```bash
sudo useradd --system --home /srv/<myapp> --shell /usr/sbin/nologin <myapp>
sudo mkdir -p /srv/<myapp> /var/log/<myapp>
sudo chown -R <myapp>:<myapp> /srv/<myapp> /var/log/<myapp>
```
**预期结果**:`id <myapp>` 输出该账号;两个目录存在且属主为 `<myapp>`。
服务账号使用 `nologin`,不允许直接登录。
### 2. 取得代码
```bash
sudo -u <myapp> git clone <仓库地址> /srv/<myapp>/app
cd /srv/<myapp>/app && sudo -u <myapp> git rev-parse HEAD
```
**预期结果**:输出本次部署的完整提交哈希,记录到部署记录中。
### 3. 安装依赖
```bash
sudo -u <myapp> python3 -m venv /srv/<myapp>/venv
sudo -u <myapp> /srv/<myapp>/venv/bin/pip install -r /srv/<myapp>/app/requirements.txt
```
**预期结果**:pip 以 `Successfully installed ...` 结束,无 ERROR。
### 4. 落位配置文件
```bash
sudo install -o <myapp> -g <myapp> -m 600 /dev/null /srv/<myapp>/app.env
sudo -u <myapp> vi /srv/<myapp>/app.env
```
**预期结果**:`ls -l /srv/<myapp>/app.env` 显示权限 `-rw-------` 且属主为 `<myapp>`。
配置项清单见下方“配置与凭据来源”。配置文件不进入 Git。
### 5. 数据库初始化或迁移
<!-- 无数据库时删除本节。 -->
> **注意**:迁移可能不可逆。执行前必须先备份,并确认回退方式。
```bash
sudo -u <myapp> /srv/<myapp>/venv/bin/python -m <myapp>.manage migrate
```
**预期结果**:输出全部迁移已应用,无失败项。
## supervisor 配置
写入 `/etc/supervisor/conf.d/<myapp>.conf`:
```ini
[program:<myapp>]
command=/srv/<myapp>/venv/bin/gunicorn <myapp>.wsgi:application --workers <n> --bind 127.0.0.1:<8000> --timeout 60
directory=/srv/<myapp>/app
user=<myapp>
environment=PATH="/srv/<myapp>/venv/bin",APP_ENV_FILE="/srv/<myapp>/app.env"
autostart=true
autorestart=true
startsecs=5
stopasgroup=true
killasgroup=true
stopwaitsecs=30
stdout_logfile=/var/log/<myapp>/stdout.log
stderr_logfile=/var/log/<myapp>/stderr.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=5
```
> 异步框架使用 uvicorn 时把 `command` 换成:
> `/srv/<myapp>/venv/bin/uvicorn <myapp>.asgi:app --host 127.0.0.1 --port <8000> --workers <n>`
不要在 `environment` 里写明文密码或令牌;敏感值放在 `app.env`,由应用读取。
加载配置并启动:
```bash
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start <myapp>
sudo supervisorctl status <myapp>
```
**预期结果**:`reread` 输出 `<myapp>: available`;`status` 显示 `RUNNING` 且 uptime 持续增长。出现 `BACKOFF` 或 `FATAL` 时查看 `stderr.log`。
## nginx 配置
写入 `/etc/nginx/sites-available/<myapp>.conf` 并软链到 `sites-enabled`:
```nginx
server {
listen 80;
server_name <example.com>;
access_log /var/log/nginx/<myapp>.access.log;
error_log /var/log/nginx/<myapp>.error.log;
client_max_body_size <20m>;
location /static/ {
alias /srv/<myapp>/app/static/;
expires 7d;
}
location / {
proxy_pass http://127.0.0.1:<8000>;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout <60s>;
}
}
```
<!-- WebSocket 需求存在时,在对应 location 增加 Upgrade 与 Connection 头;无此需求时删除本注释。 -->
启用并生效:
```bash
sudo ln -sf /etc/nginx/sites-available/<myapp>.conf /etc/nginx/sites-enabled/<myapp>.conf
sudo nginx -t
sudo systemctl reload nginx
```
**预期结果**:`nginx -t` 输出 `syntax is ok` 与 `test is successful`;reload 无输出且 `systemctl status nginx` 仍为 `active (running)`。
`nginx -t` 未通过时不要 reload,先修正配置。
<!-- 需要 HTTPS 时在此记录证书来源、签发方式和续期检查命令;证书内容和私钥不得写入本页。 -->
## 配置与凭据来源
| 配置项 | 用途 | 来源 | 是否敏感 |
|---|---|---|---|
| `APP_ENV_FILE` | 指向配置文件路径 | supervisor 配置 | 否 |
| `<DATABASE_URL>` | 数据库连接 | `/srv/<myapp>/app.env`,值取自运维密码库条目 `<条目名>` | 是 |
| `<SECRET_KEY>` | 会话与签名 | 同上 | 是 |
| `<LOG_LEVEL>` | 日志级别 | `/srv/<myapp>/app.env` | 否 |
敏感值只记录取用位置,不在本页、工单、日志和提交中出现真实内容。
## 日常运维
| 操作 | 命令 | 预期结果 |
|---|---|---|
| 查看状态 | `sudo supervisorctl status <myapp>` | `RUNNING`,uptime 持续增长 |
| 重启服务 | `sudo supervisorctl restart <myapp>` | 输出 `stopped` 后 `started` |
| 停止服务 | `sudo supervisorctl stop <myapp>` | 输出 `stopped` |
| 实时日志 | `sudo supervisorctl tail -f <myapp> stderr` | 持续输出应用日志 |
| 应用日志 | `sudo tail -n 200 /var/log/<myapp>/stderr.log` | 输出最近日志 |
| 接入层日志 | `sudo tail -n 200 /var/log/nginx/<myapp>.error.log` | 输出 nginx 错误 |
| 重载 nginx | `sudo nginx -t && sudo systemctl reload nginx` | 测试通过后无中断生效 |
修改 supervisor 配置后必须 `reread` + `update`,只 `restart` 不会加载新配置。
## 健康检查
每次部署、重启和回滚后必须全部执行:
```bash
sudo supervisorctl status <myapp>
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:<8000><健康检查路径>
curl -sS -o /dev/null -w "%{http_code}\n" https://<example.com><健康检查路径>
sudo tail -n 50 /var/log/<myapp>/stderr.log
```
**预期结果**:状态为 `RUNNING`;两个 `curl` 均返回 `200`;日志无新增异常堆栈。
任何一项不符合时不视为部署成功,按“升级与回滚”处理。
## 升级与回滚
### 升级
```bash
cd /srv/<myapp>/app
sudo -u <myapp> git rev-parse HEAD # 记录当前提交,回滚需要
sudo -u <myapp> git fetch --all
sudo -u <myapp> git checkout <目标提交或标签>
sudo -u <myapp> /srv/<myapp>/venv/bin/pip install -r requirements.txt
sudo -u <myapp> /srv/<myapp>/venv/bin/python -m <myapp>.manage migrate # 无数据库时删除
sudo supervisorctl restart <myapp>
```
**预期结果**:restart 后 `status` 为 `RUNNING`,随后健康检查全部通过。
升级前必须记录当前提交哈希;涉及数据库迁移时必须先备份。
### 回滚
```bash
cd /srv/<myapp>/app
sudo -u <myapp> git checkout <升级前记录的提交>
sudo -u <myapp> /srv/<myapp>/venv/bin/pip install -r requirements.txt
sudo supervisorctl restart <myapp>
```
**预期结果**:健康检查全部通过。
> **注意**:已执行的数据库迁移通常不能通过切回代码撤销。存在迁移时必须先确认迁移是否向后兼容;不兼容时按备份恢复流程处理,并停止自行操作、联系负责人。
### 备份与恢复
<!-- 记录备份对象、频率、保存位置、保留期和恢复步骤;无持久化数据时说明原因。 -->
## 已知限制
<!-- 记录本环境无法验证的部分,例如未做过真实回滚演练、未验证高并发表现、灰度或多机部署尚未支持。不得留空,无限制时写“无”。 -->
- <!-- 填写 -->
+166 -70
View File
@@ -3,21 +3,25 @@ from __future__ import annotations
import sys
import tempfile
import unittest
import unittest.mock
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "dev_scripts"))
from check_harness import ( # noqa: E402
import harness # noqa: E402
from harness import ( # noqa: E402
CORE_DOCUMENT_REQUIREMENTS,
CORE_PAGE_PATHS,
REQUIRED_FILES,
check_claude_code_entry,
check_required_files,
check_core_documents,
check_agent_efficiency_rules,
check_go_admin_ui_rules,
check_goadmin_baseline,
check_repository_readme,
check_task_template,
core_mapping_errors,
missing_sections,
@@ -45,6 +49,12 @@ class CoreDocumentTests(unittest.TestCase):
for page, path in CORE_PAGE_PATHS.items():
self.assertEqual(mappings.get(page), path)
def test_task_archives_are_not_core_mappings(self) -> None:
config = load_config()
self.assertFalse(
any(mapping.path.startswith("docs/task/") for mapping in config.mappings)
)
def test_existing_project_adoption_guide_is_core_document(self) -> None:
path = "docs/08-existing-project-adoption.md"
self.assertEqual(
@@ -54,6 +64,102 @@ class CoreDocumentTests(unittest.TestCase):
self.assertIn(path, CORE_DOCUMENT_REQUIREMENTS)
self.assertIn("## 可复制 Agent 指令", CORE_DOCUMENT_REQUIREMENTS[path])
def test_product_requirements_overview_is_core_document(self) -> None:
path = "docs/09-product-requirements.md"
self.assertEqual(
CORE_PAGE_PATHS.get("Product-Requirements"),
path,
)
required = CORE_DOCUMENT_REQUIREMENTS[path]
self.assertIn("## 当前需求索引", required)
self.assertIn("## 原型与设计资产", required)
self.assertIn("### 原型门禁", required)
self.assertIn("### 线上原型与按需 HTML 快照", required)
self.assertIn("### 原型确认记录", required)
self.assertIn("## 更新时机", required)
def test_workflow_requires_ticket_and_design_evidence_gates(self) -> None:
required = CORE_DOCUMENT_REQUIREMENTS["docs/01-workflow.md"]
self.assertIn("## 工单与设计证据双门禁", required)
self.assertIn("### 先判断是否需要工单", required)
self.assertIn("### 再判断设计证据", required)
self.assertIn("### 线上原型审核与按需导出", required)
self.assertIn("### 记录和重新确认", required)
def test_workflow_requires_online_wiki_initialization_gate(self) -> None:
workflow = CORE_DOCUMENT_REQUIREMENTS["docs/01-workflow.md"]
setup = CORE_DOCUMENT_REQUIREMENTS[
"docs/07-new-project-documentation-setup.md"
]
self.assertIn("## 新项目 Wiki 初始化门禁", workflow)
self.assertIn("#### 在线创建与回读门禁", setup)
def test_workflow_requires_issue_only_task_record(self) -> None:
workflow = CORE_DOCUMENT_REQUIREMENTS["docs/01-workflow.md"]
setup = CORE_DOCUMENT_REQUIREMENTS[
"docs/07-new-project-documentation-setup.md"
]
self.assertIn("## 稳定文档与可选历史快照", workflow)
self.assertIn("#### 工程基线裁剪", setup)
def test_workflow_requires_gitea_mcp_and_minimal_issue_reading(self) -> None:
workflow = CORE_DOCUMENT_REQUIREMENTS["docs/01-workflow.md"]
self.assertIn("## Gitea 交互与工单最小读取", workflow)
errors: list[str] = []
check_agent_efficiency_rules(errors)
self.assertEqual(errors, [])
def test_existing_project_adoption_requires_upgrade_process(self) -> None:
required = CORE_DOCUMENT_REQUIREMENTS[
"docs/08-existing-project-adoption.md"
]
self.assertIn("## 后续升级", required)
self.assertIn("### 升级步骤", required)
self.assertIn("### 可复制升级指令", required)
def test_project_profile_requires_dev_harness_baseline(self) -> None:
required = CORE_DOCUMENT_REQUIREMENTS["docs/00-project-profile.md"]
self.assertIn("## DevHarness 来源与基线", required)
def test_new_project_setup_requires_baseline_selection(self) -> None:
required = CORE_DOCUMENT_REQUIREMENTS[
"docs/07-new-project-documentation-setup.md"
]
self.assertIn("### 2. 选择建设基线", required)
self.assertIn("#### 判断案例", required)
self.assertIn("### 3. 识别子项目与交付单元", required)
self.assertIn("#### 需求总览启用条件", required)
def test_local_development_requires_powershell_utf8_boundaries(self) -> None:
required = CORE_DOCUMENT_REQUIREMENTS[
"docs/04-local-development-and-verification.md"
]
self.assertIn("## Windows PowerShell 与 UTF-8", required)
errors: list[str] = []
check_agent_efficiency_rules(errors)
self.assertEqual(errors, [])
def test_deployment_template_is_required_and_mapped(self) -> None:
path = "docs/templates/deployment.md"
self.assertIn(path, REQUIRED_FILES)
config = load_config()
mappings = {mapping.page: mapping.path for mapping in config.mappings}
self.assertEqual(mappings.get("Deployment-Template"), path)
# 部署页按项目需要创建,不强制每个项目产出实例页。
self.assertNotIn(path, CORE_DOCUMENT_REQUIREMENTS)
def test_missing_deployment_template_is_reported(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
with unittest.mock.patch.object(harness, "ROOT", root):
errors: list[str] = []
check_required_files(errors)
self.assertTrue(
any("docs/templates/deployment.md" in error for error in errors)
)
def test_missing_or_wrong_core_mapping_is_reported(self) -> None:
errors = core_mapping_errors({"Home": "docs/wrong.md"})
self.assertTrue(any("Home -> docs/README.md" in error for error in errors))
@@ -99,6 +205,42 @@ class TaskTemplateTests(unittest.TestCase):
errors,
)
def test_task_template_requires_design_and_prototype_gate(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
template = root / ".gitea" / "issue_template" / "task.md"
template.parent.mkdir(parents=True)
template.write_text("## 基本信息\n", encoding="utf-8")
errors: list[str] = []
check_task_template(errors, root)
self.assertIn("单元任务模板缺少:## 设计与原型门禁", errors)
self.assertIn(
"单元任务模板缺少:- 可编辑设计源、线上原型链接和访问检查:",
errors,
)
self.assertIn(
"单元任务模板缺少:- 本地 HTML 导出:未要求 / 用户明确要求 / 项目规则要求",
errors,
)
self.assertIn(
"单元任务模板缺少:- 本地 HTML 路径、版本和资源检查(仅显式导出时填写):",
errors,
)
def test_task_template_requires_optional_snapshot_policy(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
template = root / ".gitea" / "issue_template" / "task.md"
template.parent.mkdir(parents=True)
template.write_text("## 基本信息\n", encoding="utf-8")
errors: list[str] = []
check_task_template(errors, root)
self.assertIn("单元任务模板缺少:## 任务记录与可选快照", errors)
self.assertIn(
"单元任务模板缺少:- [ ] 默认不创建任务快照",
errors,
)
def test_task_template_requires_subproject_impact(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
@@ -117,23 +259,6 @@ class TaskTemplateTests(unittest.TestCase):
errors,
)
def test_task_template_requires_coordination_fields(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
template = root / ".gitea" / "issue_template" / "task.md"
template.parent.mkdir(parents=True)
template.write_text("## 基本信息\n", encoding="utf-8")
errors: list[str] = []
check_task_template(errors, root)
self.assertIn(
"单元任务模板缺少:- 任务类型:单项目 / 协同",
errors,
)
self.assertIn("单元任务模板缺少:- 主 agent:", errors)
self.assertIn("单元任务模板缺少:## 协同接口", errors)
self.assertIn("单元任务模板缺少:- 生产者:", errors)
self.assertIn("单元任务模板缺少:- 消费者:", errors)
self.assertIn("单元任务模板缺少:- write_paths:", errors)
def test_task_template_requires_dependency_fields(self) -> None:
with tempfile.TemporaryDirectory() as directory:
@@ -176,73 +301,44 @@ class TaskTemplateTests(unittest.TestCase):
self.assertIn("单元任务模板缺少:## 需求变化记录", errors)
class AgentRuleTests(unittest.TestCase):
def test_required_agent_rules_are_present(self) -> None:
errors: list[str] = []
check_agent_efficiency_rules(errors)
self.assertEqual(errors, [])
def test_claude_code_entry_imports_shared_rules(self) -> None:
errors: list[str] = []
check_claude_code_entry(errors)
self.assertEqual(errors, [])
def test_go_admin_ui_rules_are_present(self) -> None:
class YoVisionBaselineTests(unittest.TestCase):
def test_go_admin_ui_rules_are_preserved(self) -> None:
errors: list[str] = []
check_go_admin_ui_rules(errors)
self.assertEqual(errors, [])
def test_go_admin_ui_rules_require_each_scope(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "Sense").mkdir()
(root / "Bell").mkdir()
(root / "AGENTS.md").write_text("", encoding="utf-8")
(root / "Sense" / "AGENTS.md").write_text("", encoding="utf-8")
(root / "Bell" / "AGENTS.md").write_text("", encoding="utf-8")
errors: list[str] = []
check_go_admin_ui_rules(errors, root)
self.assertTrue(any(error.startswith("AGENTS.md ") for error in errors))
self.assertTrue(any(error.startswith("Sense/AGENTS.md ") for error in errors))
self.assertTrue(any(error.startswith("Bell/AGENTS.md ") for error in errors))
def test_go_admin_ui_rules_report_missing_scope_file(self) -> None:
with tempfile.TemporaryDirectory() as directory:
errors: list[str] = []
check_go_admin_ui_rules(errors, Path(directory))
self.assertIn("缺少 go-admin 规则文件:AGENTS.md", errors)
self.assertIn("缺少 go-admin 规则文件:Sense/AGENTS.md", errors)
self.assertIn("缺少 go-admin 规则文件:Bell/AGENTS.md", errors)
def test_goadmin_baseline_is_frozen(self) -> None:
errors: list[str] = []
check_goadmin_baseline(errors)
self.assertEqual(errors, [])
def test_goadmin_baseline_rejects_unpinned_source(self) -> None:
class AgentRuleTests(unittest.TestCase):
def test_required_agent_rules_are_present(self) -> None:
errors: list[str] = []
check_agent_efficiency_rules(errors)
self.assertEqual(errors, [])
def test_repository_readme_requires_online_wiki_gate(self) -> None:
errors: list[str] = []
check_repository_readme(errors)
self.assertEqual(errors, [])
def test_repository_readme_rejects_missing_gate(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "goadmin-baseline.json").write_text(
'{"authority":"tag","runtimes":{},'
'"sources":{"go-admin":{}},"rules":{}}',
(root / "README.md").write_text(
"# 项目\n创建 Gitea 远端仓库\n",
encoding="utf-8",
)
errors: list[str] = []
check_goadmin_baseline(errors, root)
self.assertTrue(any("go-admin.commit" in error for error in errors))
self.assertIn(
"GoAdmin 技术基线必须以上游 URL 和 commit 为权威标识",
errors,
)
check_repository_readme(errors, root)
self.assertTrue(any("README.md 缺少" in error for error in errors))
def test_goadmin_baseline_reports_missing_file(self) -> None:
with tempfile.TemporaryDirectory() as directory:
errors: list[str] = []
check_goadmin_baseline(errors, Path(directory))
self.assertEqual(
errors,
["缺少 GoAdmin 技术基线:goadmin-baseline.json"],
)
def test_claude_code_entry_imports_shared_rules(self) -> None:
errors: list[str] = []
check_claude_code_entry(errors)
self.assertEqual(errors, [])
def test_claude_code_entry_requires_exact_import_line(self) -> None:
with tempfile.TemporaryDirectory() as directory:
+156 -1
View File
@@ -12,7 +12,14 @@ from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "dev_scripts"))
from new_task_archive import build_archive, safe_title # noqa: E402
from harness import ( # noqa: E402
build_archive,
existing_task_mirrors,
export_task_archives,
main as harness_main,
safe_title,
task_target,
)
from wiki_docs import ( # noqa: E402
Config,
Mapping,
@@ -158,6 +165,154 @@ class ArchiveTests(unittest.TestCase):
self.assertIn("Task-12-login", result)
self.assertNotIn("YYYY-MM-DD", result)
@patch("harness.WikiClient")
def test_create_archive_does_not_change_core_mapping(self, client_class) -> None:
with tempfile.TemporaryDirectory() as directory:
config_path = Path(directory) / "wiki-docs.json"
original = json.dumps(
{
"schema_version": 1,
"gitea_url": "http://gitea.example",
"owner": "o",
"repository": "r",
"mappings": [
{"page": "Home", "path": "docs/README.md"}
],
}
)
config_path.write_text(original, encoding="utf-8")
client = client_class.return_value
client.list_pages.return_value = []
client.get_page.return_value = WikiPage(
title="Task-Archive-Template",
sub_url="Task-Archive-Template.-",
text="# <工单号> <标题>\nYYYY-MM-DD\n<链接>\n<页面名>\n",
revision="a" * 40,
html_url="http://gitea.example/wiki/template",
)
client.create_page.return_value = WikiPage(
title="Task-14-按需导出",
sub_url="Task-14.-",
text="# 14 按需导出\n",
revision="b" * 40,
html_url="http://gitea.example/wiki/task-14",
)
with patch.object(
sys,
"argv",
[
"harness.py",
"archive",
"14",
"按需导出",
"--config",
str(config_path),
],
):
result = harness_main()
self.assertEqual(config_path.read_text(encoding="utf-8"), original)
self.assertEqual(result, 0)
client.create_page.assert_called_once()
def test_task_target_uses_stable_safe_name(self) -> None:
with tempfile.TemporaryDirectory() as directory:
target = task_target("Task-14-修复:导出", Path(directory))
self.assertEqual(target.name, "14-修复-导出.md")
def test_existing_mirror_keeps_historical_custom_filename(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
path = root / "docs" / "task" / "2-初级维护者文档体系.md"
path.parent.mkdir(parents=True)
page = WikiPage(
title="Task-2-Junior-Maintainer-Docs",
sub_url="Task-2-Junior-Maintainer-Docs.-",
text="# 2 文档\n",
revision="c" * 40,
html_url="http://gitea.example/wiki/task-2",
)
path.write_text(render_mirror(page), encoding="utf-8")
mirrors = existing_task_mirrors(root)
self.assertEqual(
mirrors["Task-2-Junior-Maintainer-Docs"].name,
"2-初级维护者文档体系.md",
)
@patch("harness.dirty_paths", return_value=[])
def test_incremental_export_skips_same_revision(self, _dirty) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
path = root / "docs" / "task" / "14-按需导出.md"
path.parent.mkdir(parents=True)
page = WikiPage(
title="Task-14-按需导出",
sub_url="Task-14.-",
text="# 14 按需导出\n",
revision="d" * 40,
html_url="http://gitea.example/wiki/task-14",
)
path.write_text(render_mirror(page), encoding="utf-8")
client = Mock()
client.list_pages.return_value = [
{
"title": page.title,
"sub_url": page.sub_url,
"last_commit": {"sha": page.revision},
}
]
messages = export_task_archives(client, root=root)
self.assertTrue(messages[0].startswith("跳过:"))
client.get_page_from_metadata.assert_not_called()
@patch(
"harness.dirty_paths",
return_value=[" M docs/task/14-按需导出.md"],
)
def test_export_stops_before_wiki_read_when_task_mirror_is_dirty(
self, _dirty
) -> None:
client = Mock()
with self.assertRaisesRegex(WikiDocsError, "未提交改动"):
export_task_archives(client)
client.list_pages.assert_not_called()
@patch("harness.dirty_paths", return_value=[])
def test_full_export_reads_all_and_never_deletes_extra_file(self, _dirty) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
task_dir = root / "docs" / "task"
task_dir.mkdir(parents=True)
extra = task_dir / "99-历史快照.md"
extra_page = WikiPage(
title="Task-99-历史快照",
sub_url="Task-99.-",
text="# 99 历史快照\n",
revision="e" * 40,
html_url="http://gitea.example/wiki/task-99",
)
extra.write_text(render_mirror(extra_page), encoding="utf-8")
page = WikiPage(
title="Task-14-按需导出",
sub_url="Task-14.-",
text="# 14 按需导出\n",
revision="f" * 40,
html_url="http://gitea.example/wiki/task-14",
)
client = Mock()
metadata = {
"title": page.title,
"sub_url": page.sub_url,
"last_commit": {"sha": page.revision},
}
client.list_pages.return_value = [metadata]
client.get_page_from_metadata.return_value = page
messages = export_task_archives(client, export_all=True, root=root)
exported = root / "docs" / "task" / "14-按需导出.md"
self.assertTrue(exported.is_file())
self.assertTrue(extra.is_file())
self.assertTrue(messages[0].startswith("已导出:"))
client.get_page_from_metadata.assert_called_once_with(metadata, page.title)
if __name__ == "__main__":
unittest.main()
+8 -56
View File
@@ -68,65 +68,17 @@
"page": "Audience-Document-Template",
"path": "docs/delivery/audience-document-template.md"
},
{
"page": "Deployment-and-Operations",
"path": "docs/delivery/deployment-and-operations.md"
},
{
"page": "Deployment-Template",
"path": "docs/templates/deployment.md"
},
{
"page": "Task-Archive-Template",
"path": "docs/templates/task-archive.md"
},
{
"page": "Task-1-三项目并行建单与协同工单顺序",
"path": "docs/task/1-三项目并行建单与协同工单顺序.md"
},
{
"page": "Task-3-GoAdmin默认模块精简与UI组件复用",
"path": "docs/task/3-GoAdmin默认模块精简与UI组件复用.md"
},
{
"page": "Task-5-冻结Sense与Bell-GoAdmin技术基线",
"path": "docs/task/5-冻结Sense与Bell-GoAdmin技术基线.md"
},
{
"page": "Task-58-建立explore-main-dev分支治理并重置GoAdmin开发基线",
"path": "docs/task/58-建立explore-main-dev分支治理并重置GoAdmin开发基线.md"
},
{
"page": "Task-28-三项目当前MVP交互原型",
"path": "docs/task/28-三项目当前MVP交互原型.md"
},
{
"page": "Task-30-Sense原型对齐GoAdmin与Element-Plus",
"path": "docs/task/30-Sense原型对齐GoAdmin与Element-Plus.md"
},
{
"page": "Task-32-Sense网管与非技术人员原型",
"path": "docs/task/32-Sense网管与非技术人员原型.md"
},
{
"page": "Task-61-Sense冻结GoAdmin源码产品骨架",
"path": "docs/task/61-Sense冻结GoAdmin源码产品骨架.md"
},
{
"page": "Task-64-Sense登录RBAC与审计",
"path": "docs/task/64-Sense登录RBAC与审计.md"
},
{
"page": "Task-65-Sense设备台账与凭据边界",
"path": "docs/task/65-Sense设备台账与凭据边界.md"
},
{
"page": "Task-66-Sense视频接入与Profile",
"path": "docs/task/66-Sense视频接入与Profile.md"
},
{
"page": "Task-67-Sense视频服务生命周期与状态对账",
"path": "docs/task/67-Sense视频服务生命周期与状态对账.md"
},
{
"page": "Task-68-Sense单路实时监看与播放状态反馈",
"path": "docs/task/68-Sense单路实时监看与播放状态反馈.md"
},
{
"page": "Task-69-Sense多边形区域与方向警戒线配置",
"path": "docs/task/69-Sense多边形区域与方向警戒线配置.md"
}
]
}