Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ee06e31ff | ||
|
|
10225f4613 | ||
|
|
d55026902f | ||
|
|
55d54d06db | ||
|
|
64a1d82db4 | ||
|
|
a5fc8f5490 | ||
|
|
a5d846a437 |
@@ -1,12 +0,0 @@
|
||||
# 仓库内文本统一以 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
|
||||
@@ -1,6 +1,9 @@
|
||||
## 基本信息
|
||||
|
||||
- 类型:需求 / 缺陷 / 重构
|
||||
- 任务类型:单项目 / 协同
|
||||
- 主项目:Sense / Brain / Bell / contracts / 根级
|
||||
- 主 agent:
|
||||
- 所属 Epic:#
|
||||
- 所属 MVP / 版本:#
|
||||
- 阶段:
|
||||
@@ -18,8 +21,20 @@
|
||||
- 仅影响的子项目 / 交付单元:
|
||||
- 是否跨子项目:是 / 否
|
||||
- 是否修改共享接口或契约:是 / 否;唯一事实来源:
|
||||
- write_paths:
|
||||
- 各子项目需要执行的验证:
|
||||
|
||||
## 协同接口
|
||||
|
||||
<!-- 单项目工单填写“不适用”;协同工单必须完整填写。 -->
|
||||
|
||||
- 生产者:
|
||||
- 消费者:
|
||||
- 契约/共享事实源:
|
||||
- 兼容策略:不适用 / 向后兼容 / 发布新版本
|
||||
- 被阻塞或需要适配的工单:
|
||||
- 集成顺序:
|
||||
|
||||
## 原始需求
|
||||
|
||||
- 来源:用户对话 / Gitea / 其他
|
||||
@@ -53,22 +68,6 @@
|
||||
|---|---|---|---|
|
||||
| | | | 是 / 否 |
|
||||
|
||||
## 设计与原型门禁
|
||||
|
||||
<!-- 先判断是否属于纯显示文案豁免,再选择最低成本、足以确认的设计证据。 -->
|
||||
|
||||
- 修改类型:纯显示文案 / 小范围 UI / 新组件 / 新页面或独立用户功能 / 重大交互或导航 / 非 UI / 恢复既有行为的 Bug
|
||||
- 所需设计证据:无 / 标注截图 / 低保真图 / 已确认原型 / 架构、API、数据、状态或流程设计 / 原设计或复现证据
|
||||
- 可编辑设计源、线上原型链接和访问检查:
|
||||
- 审核版本、revision、复制版本或确认日期及识别方式:
|
||||
- 本地 HTML 导出:未要求 / 用户明确要求 / 项目规则要求
|
||||
- 本地 HTML 路径、版本和资源检查(仅显式导出时填写):
|
||||
- 状态:无 / 草稿 / 已确认 / 已废弃
|
||||
- 确认人、确认时间和覆盖范围:
|
||||
- 无需 UI 原型或无需任何原型的原因:
|
||||
|
||||
<!-- 新页面、独立用户功能、重大交互或导航变化默认通过可访问且版本明确的线上原型审核;只有用户或项目规则明确要求时才导出 prototypes/<工单号>/<版本>/index.html。原型和文字需求未确认前不得编写生产代码。 -->
|
||||
|
||||
## 文档影响
|
||||
|
||||
<!-- 至少选择一项;不影响长期文档时必须写明原因。 -->
|
||||
@@ -89,15 +88,6 @@
|
||||
- [ ] 新增交付文档,受众与页面:
|
||||
- [ ] 需要目标岗位或客户代表验证:是 / 否;验证方式:
|
||||
|
||||
## 任务记录与可选快照
|
||||
|
||||
- 单次任务事实来源:当前 Gitea 工单正文与评论
|
||||
- [ ] 默认不创建任务快照
|
||||
- [ ] 用户明确要求专项快照;用途和范围:
|
||||
- [ ] 项目专用规则要求任务快照;规则入口:
|
||||
|
||||
<!-- 工单正文保存确认基线;重要变化、最终证据和验收结论通过评论追加。只有长期事实变化时才更新 Wiki 和同步镜像。 -->
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] <!-- 填写 -->
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
.codex/gitea.env
|
||||
gitea.env
|
||||
.codex/*.log
|
||||
.codex/config.toml
|
||||
__pycache__/
|
||||
|
||||
@@ -1,25 +1,8 @@
|
||||
# Agent 开发规则
|
||||
|
||||
本仓库采用 DevHarness 工作流:Gitea 工单是单次任务需求、变化、实现、测试、提交和验收的事实来源,Gitea Wiki 是长期开发文档的事实来源,Git 是代码与版本绑定资料的变更记录。`docs/` 默认保存核心 Wiki 的只读镜像,`docs/task/` 只保存人工明确要求的专项或历史兼容快照。人负责确认方案与验收,Agent 负责检查、实现、测试和留下证据。
|
||||
本仓库采用 DevHarness 工作流:Gitea 工单是任务过程的事实来源,Gitea Wiki 是长期开发文档和任务归档的事实来源,Git 是代码与版本绑定资料的变更记录,`docs/` 只保存 Wiki 的只读镜像。人负责确认方案与验收,Agent 负责检查、实现、测试和留下证据。
|
||||
|
||||
开始工作前先阅读任务涉及目录中的 `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` |
|
||||
开始工作前先阅读 [项目档案](docs/00-project-profile.md) 和任务涉及目录中的 `AGENTS.md`。目录越深的规则越具体,但不得削弱上级安全规则。
|
||||
|
||||
## 1. 永久规则
|
||||
|
||||
@@ -35,15 +18,14 @@
|
||||
|
||||
新功能、缺陷修复、重构以及任何用户可感知或改变程序行为的修改,必须先建立单元任务工单。
|
||||
|
||||
以下小改动可以直接提交,不要求工单:
|
||||
以下小改动可以直接提交,不要求工单和任务归档:
|
||||
|
||||
- 只改错别字、注释或文档措辞;
|
||||
- 只做格式化、导入排序或不跨文件的内部变量改名;
|
||||
- 补充类型标注或文档字符串且不改变行为;
|
||||
- 删除已经确认无人使用的死代码。
|
||||
- 只修改用户看到的界面显示文案,并且满足本文件“工单与设计证据双门禁”的全部豁免条件。
|
||||
|
||||
除严格符合界面显示文案豁免的修改外,只要涉及接口、数据库、状态、权限、安全、并发、用户界面,或者无法确定是否改变行为,就必须建工单。代码组件名、类名、变量、国际化键、API 字段和数据库字段不是显示文案,不适用豁免。
|
||||
只要涉及接口、数据库、状态、权限、安全、并发、用户界面,或者无法确定是否改变行为,就必须建工单。
|
||||
|
||||
## 3. 需求到实施
|
||||
|
||||
@@ -55,61 +37,24 @@
|
||||
6. 开始实施前检查分支和工作区,明确哪些现有改动不属于本任务。
|
||||
7. 严格按工单范围实现;新发现的问题先记录,不顺手混入当前任务。
|
||||
8. 执行与风险相称的测试,把关键结果和未验证部分更新到工单。实施过程中出现计划外、当前无法解除的问题时才标记“阻塞”。
|
||||
9. 只有长期事实变化时才修改 Wiki、读取确认,再运行 `python dev_scripts/harness.py sync` 导出本地镜像;没有长期文档影响时在工单说明原因并跳过 Wiki 同步。不得直接编辑镜像后反向覆盖 Wiki。
|
||||
|
||||
工单正文保存用户确认的任务基线;根因、范围、方案、风险或阻塞发生重要变化时追加评论。完成实现后用一条评论集中记录最终差异、测试、未验证内容、提交哈希和文档影响;用户验收后再追加验收时间与结论,不重复覆盖或抄写已有证据。
|
||||
9. 长期文档必须先修改 Wiki、读取确认,再运行 `python dev_scripts/sync_wiki_docs.py` 导出本地镜像;不得直接编辑 `docs/` 后反向覆盖 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`:人工触发导出指定工单已确认的原型版本,按工单和版本写入 `prototypes/`;不扩展范围、不自动提交。
|
||||
- `导出全部原型`:人工触发导出当前项目已明确范围内的全部已确认原型;不自动提交。
|
||||
- `导出任务归档`:人工触发 `python dev_scripts/harness.py export`,只导出新增或 revision 已变化的任务归档;不删除本地文件、不自动提交。
|
||||
- `导出全部任务归档`:人工触发 `python dev_scripts/harness.py export --all`,读取并导出全部线上任务归档;不删除本地文件、不自动提交。
|
||||
- `#N 验收通过`:仅在用户明确验收后,在工单追加验收结论;按需更新真实变化的长期 Wiki,推送、同步父工单并关闭任务;不创建或导出任务归档。
|
||||
- `同步文档`:读取 Wiki、导出 `docs/` 并检查一致性;不修改 Wiki、不自动提交。
|
||||
- `#N 验收通过`:仅在用户明确验收后,更新归档、同步并提交镜像、推送、同步父工单并关闭任务。
|
||||
|
||||
方案未确认或前置依赖未满足时,实施类指令必须停在对应门禁;除 `#N 验收通过` 外,快捷指令不得关闭待验收工单。原型和任务快照的导出必须由用户明确提出或项目专用规则明确要求,其他指令不得隐式执行。Gitea 工单不导出全文,`docs/task/` 只是可能不完整的专项或历史兼容快照。详细语义见 [开发工作流](docs/01-workflow.md)。
|
||||
方案未确认或前置依赖未满足时,实施类指令必须停在对应门禁;除 `#N 验收通过` 外,快捷指令不得关闭待验收工单。Gitea 工单不导出全文,本地只保存 Wiki 任务归档镜像。详细语义见 [开发工作流](docs/01-workflow.md)。
|
||||
|
||||
### 需求记录与流转
|
||||
|
||||
@@ -117,13 +62,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)。
|
||||
|
||||
### 效率与范围控制
|
||||
|
||||
本节只用于减少无关工作和重复检查,不得削弱安全规则、已确认方案、工单范围、必要测试、必要的长期文档同步、Git 提交和人工验收要求。
|
||||
本节只用于减少无关工作和重复检查,不得削弱安全规则、已确认方案、工单范围、必要测试、Wiki 同步、Git 提交和验收归档要求。
|
||||
|
||||
#### 严格控制范围
|
||||
|
||||
@@ -175,22 +120,19 @@ Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明
|
||||
- 实现提交信息引用工单号,例如:`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. 有长期文档影响时,读取确认 Wiki,运行 `python dev_scripts/harness.py sync --check` 检查核心镜像,并把页面 revision 和镜像提交哈希写回工单;没有长期文档影响时不运行 Wiki 同步。
|
||||
4. 用户验收通过后,在工单追加验收时间和结论,关闭单元工单,并同步更新 MVP 和 Epic;没有真实变化的 Wiki 不重复更新或检查。
|
||||
5. 默认不创建任务归档。只有用户明确要求专项快照或项目专用规则明确要求时,才运行 `python dev_scripts/harness.py archive <编号> "<短标题>"`;`export` 和 `export --all` 同样必须显式触发。
|
||||
3. 运行 `python dev_scripts/new_task_archive.py <编号> "<短标题>"`,先创建 Wiki 归档,再登记并导出本地镜像。
|
||||
4. 读取确认 Wiki,运行 `python dev_scripts/sync_wiki_docs.py --check`;归档镜像单独提交,并把页面、revision、路径和提交哈希写回工单。
|
||||
5. 用户验收通过后关闭单元工单,并同步更新 MVP 和 Epic。
|
||||
|
||||
MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才能关闭 MVP。Epic 的全部范围完成后才能关闭 Epic。
|
||||
|
||||
详细证据回写、可选快照和文档同步边界见 [开发工作流](docs/01-workflow.md)。
|
||||
详细归档顺序和字段见 [开发工作流](docs/01-workflow.md)。
|
||||
|
||||
## 8. 可维护性
|
||||
|
||||
@@ -211,9 +153,8 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
|
||||
|
||||
- 每个单元任务必须在工单中选择“无长期文档影响并说明原因”或列出需要更新的 Wiki 页面。
|
||||
- 启动、测试、部署、排错命令,模块入口、目录职责、主要调用路径,配置、API、数据结构、状态、业务规则、安全边界、日志位置发生变化时,必须更新对应 Wiki。
|
||||
- 部署命令变化时,有常驻服务的项目更新自己的 `Deployment-and-Operations` 页面(由 [部署文档模板](docs/templates/deployment.md) 复制建立);没有常驻服务的项目记录为无部署文档影响,不创建空的部署页。
|
||||
- 普通内部重构只有在入口、行为、配置和验证方式均未改变时,才可以记录为不影响长期文档。
|
||||
- 必需核心页面及结构以 `python dev_scripts/harness.py check --strict` 和 [新项目文档初始化](docs/07-new-project-documentation-setup.md) 为准;稳定文档与可选历史快照的分工见 [开发工作流](docs/01-workflow.md)。
|
||||
- 必需核心页面及结构以 `python dev_scripts/check_harness.py --strict` 和 [新项目文档初始化](docs/07-new-project-documentation-setup.md) 为准;稳定文档与任务归档的分工见 [开发工作流](docs/01-workflow.md)。
|
||||
|
||||
## 9. 引导提交例外
|
||||
|
||||
@@ -223,20 +164,11 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
|
||||
|
||||
## 10. 项目专用规则
|
||||
|
||||
### YoVision 分支治理
|
||||
|
||||
- `explore` 是旧实现的只读聚合快照,不接受新功能、修复或文档演进;需要恢复旧行为时从其来源提交读取证据,不在该分支续写产品。
|
||||
- `main` 是用户审核基线,禁止直接开发、直接提交或未经用户明确审核的合并。
|
||||
- 新任务分支必须从当前 `dev` 创建,完成后通过 PR 合回 `dev`;不得把功能分支直接合入 `main`。
|
||||
- `dev` 完成集成测试后仍不能自动进入 `main`;只有用户明确表示审核/验收通过,Agent 才能执行 `dev → main`。
|
||||
- 紧急修复也必须建单并取得用户对分支与合并路径的明确授权,不默认绕过 `dev`。
|
||||
|
||||
<!-- 在项目初始化时填写不可违反的技术、安全和业务约束。复杂子项目请在其目录中增加 AGENTS.md。 -->
|
||||
|
||||
- 长期开发文档以 Gitea Wiki 为事实来源,单次任务证据以 Gitea 工单为事实来源;`docs/` 默认保存显式映射生成的核心只读镜像,`docs/task/` 是人工明确要求的专项或历史兼容快照,可能不完整或不是最新状态。
|
||||
- 核心 Wiki 与镜像的固定顺序是:修改 Wiki → 读取确认 → 导出核心 `docs` → 校验差异 → 提交镜像。
|
||||
- 默认不创建任务归档;`archive`、`导出任务归档` 或 `导出全部任务归档` 必须由用户明确提出或项目专用规则明确要求,且不得自动传播删除或重命名。
|
||||
- 同步配置只允许写入 `docs/` 下的 Markdown;发现核心镜像有未提交修改时必须停止。
|
||||
- 长期开发文档以 Gitea Wiki 为事实来源,`docs/` 是显式映射生成的只读镜像。
|
||||
- Wiki 与镜像的固定顺序是:修改 Wiki → 读取确认 → 导出 `docs` → 校验差异 → 提交镜像。
|
||||
- 同步配置只允许写入 `docs/` 下的 Markdown;发现镜像有未提交修改时必须停止。
|
||||
- Gitea 凭据只通过进程环境或 MCP 安全配置提供,不得写入仓库。
|
||||
- `dev_scripts/` 只存放 DevHarness 自身工具;业务项目的通用脚本必须使用独立目录,不得混放。
|
||||
- 本仓库包含 `Sense/`、`Brain/`、`Bell/` 三个独立开发与交付单元;Sense 与 Bell 是认证、数据、部署和发布完全独立的销售产品,Brain 是无界面推理交付单元。
|
||||
@@ -262,10 +194,6 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
|
||||
|
||||
- `goadmin-baseline.json` 是 Sense、Bell 共用的 GoAdmin 上游与工具链版本事实来源;上游 URL 加完整 commit 是可复现标识,tag、`package.json` version、本机目录内容或本机已安装版本都不能替代 commit。
|
||||
- `D:\github\goadmin\go-admin`、`go-admin-ui`、`go-admin-doc` 是当前机器的只读参考副本。Agent 可以读取和验证,不得在这些目录开发、修改或提交 yovision 产品代码。
|
||||
- “基于 go-admin / go-admin-ui 二次开发”必须表现为从 `goadmin-baseline.json` 指定的两个完整上游 commit 导入或派生产品源码,并保留可核对的目录结构、框架启动链、通用认证/RBAC、菜单路由、请求封装及许可证来源;仅采用 Go、Vue、Element Plus、相似目录、页面外观或交互不算满足基线。
|
||||
- 禁止用自建 `net/http`、自建认证/RBAC/会话框架或自制管理端外壳替代 go-admin/go-admin-ui 后仍声称是 GoAdmin 二次开发。确有上游能力无法满足时,必须在独立工单中说明缺口、保留上游扩展边界并等待用户确认。
|
||||
- 每个 Sense/Bell 骨架、基础能力或框架相关工单开始实施前,必须读取任务相关的冻结 `go-admin-doc` 内容,并在工单中记录参考的文档主题或文件、继承的上游路径以及计划隐藏/禁用的默认模块。
|
||||
- 初始化验收必须包含来源证据:上游 URL/commit、实际导入路径、保留的 MIT 文件、相对上游的裁剪/修改清单和构建测试;缺少任一项不得标记为 GoAdmin 骨架完成。
|
||||
- Sense、Bell 必须从同一冻结基线分别初始化,保留独立代码、配置、构建、测试、版本和发布;不得通过共同修改参考仓库形成隐藏的共享实现。
|
||||
- 复制或派生上游代码时必须保留对应 MIT 许可证和版权声明,并记录实际复制来源的 commit。
|
||||
- `go-admin-doc` 只用于理解上游框架用法;yovision 的 Gitea Wiki、`AGENTS.md` 和 `goadmin-baseline.json` 仍是本项目事实来源。
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Copy values into the process environment. Do not commit real credentials.
|
||||
BELL_ENV=development
|
||||
BELL_HTTP_ADDRESS=127.0.0.1:8082
|
||||
BELL_DATABASE_URL=postgres://bell_app:replace-at-deploy@127.0.0.1:5432/bell?sslmode=disable
|
||||
BELL_SESSION_SECRET=generate-an-independent-random-value-at-deploy
|
||||
BELL_SHUTDOWN_SECONDS=10
|
||||
@@ -26,9 +26,6 @@
|
||||
## GoAdmin 技术基线
|
||||
|
||||
- 初始化或升级 Bell 前必须读取根 `goadmin-baseline.json`,按其中的 go-admin、go-admin-ui commit 和 Go/Node/pnpm 精确版本实施。
|
||||
- Bell 必须由冻结 go-admin 后端与 go-admin-ui 前端源码直接派生;旧 `explore` 实现只能用于业务语义对照,禁止把其中自研认证、RBAC、会话、HTTP 平台或前端外壳复制为新基础。
|
||||
- 开始每个骨架、认证权限、菜单路由、代码生成、ORM 或通用 UI 工单前,必须读取任务相关的冻结 go-admin-doc,并把参考主题/文件及采用方式写回工单。
|
||||
- 骨架验收必须证明上游来源、继承路径、许可证、裁剪清单和相对上游差异;只有相似技术栈或视觉不能通过验收。
|
||||
- `D:\github\goadmin` 下的仓库仅可只读参考;Bell 产品代码只能写入 `Bell/` 或工单授权路径,不得在参考仓库原地开发。
|
||||
- 派生上游代码必须保留 MIT 许可证和版权声明;上游 `go-admin-doc` 只作框架用法参考,不覆盖 yovision Wiki 和本目录规则。
|
||||
- 变更冻结基线必须升级为 Sense/Bell 协同工单;Bell 可以独立发布,但不得单独漂移上游或工具链版本。
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Upstream sources
|
||||
|
||||
Bell is independently derived from the frozen YoVision GoAdmin baseline.
|
||||
|
||||
| Source | Commit | License | Use |
|
||||
|---|---|---|---|
|
||||
| https://github.com/go-admin-team/go-admin.git | `f06540883b41d03782bb6b2c4150f298f328c6b6` | MIT | Backend architecture reference |
|
||||
| https://github.com/go-admin-team/go-admin-ui.git | `67d393d713877572fab0b897296a4c1d525fc81d` | MIT | Vue 3 application shell and component patterns |
|
||||
|
||||
The local copies under `D:\github\goadmin` are read-only references and are not Bell source directories.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 go-admin-team
|
||||
Copyright (c) 2020 go-admin-team
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 wenjianzhang
|
||||
Copyright (c) 2017 PanJiaChen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -0,0 +1,81 @@
|
||||
# Bell
|
||||
|
||||
Bell is YoVision's independently deployable alert intake and response product. It owns its users, roles, sessions, database, audit facts and release artifacts. Sense and Brain are not required for Bell to build or run.
|
||||
|
||||
## Frozen baseline
|
||||
|
||||
- Go `1.26.5`
|
||||
- Node.js `22.22.1`
|
||||
- pnpm `9.15.1`
|
||||
- go-admin commit `f06540883b41d03782bb6b2c4150f298f328c6b6`
|
||||
- go-admin-ui commit `67d393d713877572fab0b897296a4c1d525fc81d`
|
||||
|
||||
Exact upstream sources and retained MIT notices are in `LICENSES/`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy variable names from `.env.example` into the process environment. Replace every placeholder at deployment time. Bell intentionally has no built-in database password, session secret or administrator password.
|
||||
|
||||
`BELL_SESSION_SECRET` is mandatory, must be at least 32 characters, and must be generated independently from Sense. In production Bell marks its `bell_session` cookie as Secure, HttpOnly and SameSite=Strict.
|
||||
|
||||
The PostgreSQL role used by `BELL_DATABASE_URL` must be dedicated to Bell. Do not reuse a Sense role or database.
|
||||
|
||||
## Backend
|
||||
|
||||
```powershell
|
||||
Set-Location Bell/server
|
||||
go test ./...
|
||||
go run . migrate
|
||||
go run . serve
|
||||
```
|
||||
|
||||
Create the first administrator only through a one-time process environment value; do not place the password in shell history, source files or command arguments:
|
||||
|
||||
```powershell
|
||||
$env:BELL_BOOTSTRAP_PASSWORD = Read-Host -AsSecureString | ConvertFrom-SecureString -AsPlainText
|
||||
go run . create-admin --username bell-admin --display-name "Bell 管理员"
|
||||
Remove-Item Env:BELL_BOOTSTRAP_PASSWORD
|
||||
```
|
||||
|
||||
Passwords require at least 12 characters with upper-case, lower-case and numeric characters. Bell ships no user or default password. Administrator, operator and viewer roles are seeded with least-privilege permissions; only an administrator can read user and authentication-audit pages.
|
||||
|
||||
Health endpoints:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod http://127.0.0.1:8082/healthz
|
||||
Invoke-RestMethod http://127.0.0.1:8082/readyz
|
||||
```
|
||||
|
||||
Migrations are embedded and applied transactionally. The `migrate` command is idempotent. To roll back this initial skeleton, stop Bell and drop only the dedicated Bell test database; migrations are forward-only and never touch another product database.
|
||||
|
||||
## Web
|
||||
|
||||
```powershell
|
||||
Set-Location Bell/web
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm lint
|
||||
pnpm build:prod
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The web client listens on port 8083 in development and proxies `/api` to Bell only. The visible shell contains only Bell's product navigation; go-admin demonstration routes are neither registered nor served by the backend.
|
||||
|
||||
### Development-only synthetic events
|
||||
|
||||
When `BELL_ENV=development` or `test`, administrators can open “合成事件测试” or call the `/api/v1/synthetic/*` endpoints. Fixtures live under `server/testdata/events/`, contain only invented anonymous data, and call the same Event/Receipt service as every future producer adapter. Reusing a source event ID verifies idempotency; using the same ID with a different fixture verifies conflict handling.
|
||||
|
||||
In `BELL_ENV=production` those routes are not registered and return `404`; hiding the menu is not the security boundary.
|
||||
|
||||
### Rules and alerts
|
||||
|
||||
Administrators configure and enable rules from “规则配置”. Each accepted Event is evaluated in the same database transaction that creates its Event/Receipt. A matching Event creates or joins an open Alert correlated by rule and location; one Event may match several rules and one Alert may collect several related Events. Every evaluation records its rule version and explanation, while every match also stores the actual rule snapshot. Unmatched Events remain visible as Events and are never labelled as notified or handled.
|
||||
|
||||
Alert handling is two-step: the first authorized operator to acknowledge becomes the handler, then that handler (or an administrator) records a required site outcome before closing. PostgreSQL conditional updates select one concurrent acknowledgement winner. Later operators receive the actual handler instead of overwriting it. Successful lifecycle facts are append-only; failed, duplicate and denied attempts are also written to the security audit without tokens or passwords.
|
||||
|
||||
## Independent smoke check
|
||||
|
||||
Run PostgreSQL with an empty Bell-only database, set `BELL_DATABASE_URL`, start the backend and then the web client. Keep Sense and Brain stopped. Verify `/healthz`, `/readyz`, the workbench shell, and a direct request to an unregistered framework demo route returns `404`.
|
||||
|
||||
## Logs and secrets
|
||||
|
||||
Application logs go to standard output. Never log database URLs, passwords, session tokens, event evidence credentials, customer data or production payloads.
|
||||
@@ -0,0 +1,11 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$expectedGo = 'go version go1.26.5 windows/amd64'
|
||||
$expectedNode = 'v22.22.1'
|
||||
$expectedPnpm = '9.15.1'
|
||||
|
||||
if ((go version) -ne $expectedGo) { throw "Expected $expectedGo" }
|
||||
if ((node --version) -ne $expectedNode) { throw "Expected Node $expectedNode" }
|
||||
if ((pnpm --version) -ne $expectedPnpm) { throw "Expected pnpm $expectedPnpm" }
|
||||
|
||||
Write-Output 'Bell toolchain matches goadmin-baseline.json.'
|
||||
@@ -0,0 +1,58 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
)
|
||||
|
||||
type HTTP struct {
|
||||
Service Service
|
||||
Auth auth.HTTP
|
||||
}
|
||||
|
||||
func (h HTTP) Register(mux *http.ServeMux) {
|
||||
mux.Handle("POST /api/v1/alerts/{id}/ack", h.Auth.Require(rbac.AlertsHandle, http.HandlerFunc(h.ack)))
|
||||
mux.Handle("POST /api/v1/alerts/{id}/close", h.Auth.Require(rbac.AlertsHandle, http.HandlerFunc(h.close)))
|
||||
}
|
||||
func (h HTTP) ack(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.Service.Ack(r.Context(), r.PathValue("id"), auth.Principal(r.Context()))
|
||||
if err != nil {
|
||||
status := 500
|
||||
if errors.Is(err, ErrAlreadyHandled) {
|
||||
status = 409
|
||||
}
|
||||
writeJSON(w, status, map[string]any{"error": err.Error(), "current": result.Detail})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, result)
|
||||
}
|
||||
func (h HTTP) close(w http.ResponseWriter, r *http.Request) {
|
||||
var input CloseInput
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&input); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": "请求格式无效"})
|
||||
return
|
||||
}
|
||||
result, err := h.Service.Close(r.Context(), r.PathValue("id"), input, auth.Principal(r.Context()))
|
||||
if err != nil {
|
||||
status := 500
|
||||
if errors.Is(err, ErrOutcomeRequired) {
|
||||
status = 400
|
||||
} else if errors.Is(err, ErrInvalidTransition) {
|
||||
status = 409
|
||||
}
|
||||
writeJSON(w, status, map[string]any{"error": err.Error(), "current": result.Detail})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, result)
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/alert/model"
|
||||
alertQuery "git.ilapage.cn/ila/yovision/Bell/server/app/alert/query"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrAlreadyHandled = errors.New("预警已由其他人员开始处理")
|
||||
var ErrInvalidTransition = errors.New("当前状态不能执行此操作")
|
||||
var ErrOutcomeRequired = errors.New("请选择现场处理结果")
|
||||
|
||||
type CloseInput struct {
|
||||
Outcome string `json:"outcome"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
type Result struct {
|
||||
Detail model.Detail `json:"detail"`
|
||||
Idempotent bool `json:"idempotent"`
|
||||
}
|
||||
type Service struct {
|
||||
DB *pgxpool.Pool
|
||||
Audit audit.Store
|
||||
Query alertQuery.Service
|
||||
}
|
||||
|
||||
func hasRole(user auth.User, role string) bool {
|
||||
for _, value := range user.Roles {
|
||||
if value == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func validOutcome(value string) bool {
|
||||
return value == "danger_confirmed" || value == "false_positive" || value == "site_normal" || value == "unable_to_confirm"
|
||||
}
|
||||
|
||||
func (s Service) Ack(ctx context.Context, alertID string, actor auth.User) (Result, error) {
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
var updated string
|
||||
err = tx.QueryRow(ctx, `UPDATE bell_alerts SET status='acknowledged',acknowledged_by=$2,acknowledged_at=now(),updated_at=now() WHERE id=$1 AND status='open' RETURNING id::text`, alertID, actor.ID).Scan(&updated)
|
||||
if err == nil {
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO bell_alert_lifecycle_facts(alert_id,transition,actor_user_id) VALUES($1,'acknowledged',$2)`, alertID, actor.ID); err == nil {
|
||||
_, err = tx.Exec(ctx, `INSERT INTO bell_audit_log(actor_user_id,action,target_type,target_id,outcome,details) VALUES($1,'alert.ack','alert',$2,'success','{}')`, actor.ID, alertID)
|
||||
}
|
||||
if err == nil {
|
||||
err = tx.Commit(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return Result{}, err
|
||||
}
|
||||
detail, err := s.Query.Get(ctx, alertID)
|
||||
return Result{Detail: detail}, err
|
||||
}
|
||||
_ = tx.Rollback(ctx)
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Result{}, err
|
||||
}
|
||||
detail, getErr := s.Query.Get(ctx, alertID)
|
||||
if getErr != nil {
|
||||
return Result{}, getErr
|
||||
}
|
||||
if detail.Alert.AcknowledgedBy != nil && *detail.Alert.AcknowledgedBy == actor.ID {
|
||||
_ = s.Audit.Record(ctx, &actor.ID, "alert.ack", "alert", &alertID, "failure", map[string]any{"reason": "duplicate"})
|
||||
return Result{Detail: detail, Idempotent: true}, nil
|
||||
}
|
||||
_ = s.Audit.Record(ctx, &actor.ID, "alert.ack", "alert", &alertID, "failure", map[string]any{"reason": "already_handled"})
|
||||
return Result{Detail: detail}, ErrAlreadyHandled
|
||||
}
|
||||
|
||||
func (s Service) Close(ctx context.Context, alertID string, input CloseInput, actor auth.User) (Result, error) {
|
||||
if !validOutcome(input.Outcome) {
|
||||
_ = s.Audit.Record(ctx, &actor.ID, "alert.close", "alert", &alertID, "failure", map[string]any{"reason": "outcome_required"})
|
||||
return Result{}, ErrOutcomeRequired
|
||||
}
|
||||
current, err := s.Query.Get(ctx, alertID)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if current.Alert.Status == "closed" {
|
||||
if current.Alert.ClosedBy != nil && *current.Alert.ClosedBy == actor.ID && current.Alert.CloseOutcome != nil && *current.Alert.CloseOutcome == input.Outcome {
|
||||
return Result{Detail: current, Idempotent: true}, nil
|
||||
}
|
||||
_ = s.Audit.Record(ctx, &actor.ID, "alert.close", "alert", &alertID, "failure", map[string]any{"reason": "already_closed"})
|
||||
return Result{Detail: current}, ErrInvalidTransition
|
||||
}
|
||||
admin := hasRole(actor, "administrator")
|
||||
if current.Alert.Status != "acknowledged" || (!admin && (current.Alert.AcknowledgedBy == nil || *current.Alert.AcknowledgedBy != actor.ID)) {
|
||||
_ = s.Audit.Record(ctx, &actor.ID, "alert.close", "alert", &alertID, "failure", map[string]any{"reason": "invalid_owner_or_state"})
|
||||
return Result{Detail: current}, ErrInvalidTransition
|
||||
}
|
||||
details, _ := json.Marshal(map[string]any{"outcome": input.Outcome, "note": input.Note})
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
result, err := tx.Exec(ctx, `UPDATE bell_alerts SET status='closed',closed_by=$2,closed_at=now(),close_outcome=$3,close_note=nullif($4,''),updated_at=now() WHERE id=$1 AND status='acknowledged'`, alertID, actor.ID, input.Outcome, input.Note)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return Result{}, ErrInvalidTransition
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO bell_alert_lifecycle_facts(alert_id,transition,actor_user_id,details) VALUES($1,'closed',$2,$3::jsonb)`, alertID, actor.ID, string(details)); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO bell_audit_log(actor_user_id,action,target_type,target_id,outcome,details) VALUES($1,'alert.close','alert',$2,'success',$3::jsonb)`, actor.ID, alertID, string(details)); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
detail, err := s.Query.Get(ctx, alertID)
|
||||
return Result{Detail: detail}, err
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/alert/query"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rule"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
|
||||
)
|
||||
|
||||
func TestConcurrentAckCloseAndRestart(t *testing.T) {
|
||||
url := os.Getenv("BELL_TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("BELL_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := platform.OpenDatabase(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrations.Apply(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prefix := fmt.Sprintf("lifecycle-%d", time.Now().UnixNano())
|
||||
actors := make([]auth.User, 2)
|
||||
for i := range actors {
|
||||
var id string
|
||||
username := fmt.Sprintf("%s-%d", prefix, i)
|
||||
if err := db.QueryRow(ctx, `INSERT INTO bell_users(username,display_name,password_hash) VALUES($1,$2,'test-only-not-a-login-hash') RETURNING id::text`, username, username).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(ctx, `INSERT INTO bell_user_roles(user_id,role_id) SELECT $1,id FROM bell_roles WHERE code='operator'`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actors[i] = auth.User{ID: id, Username: username, DisplayName: username, Roles: []string{"operator"}}
|
||||
}
|
||||
eventType := prefix + "-event"
|
||||
ruleService := rule.Service{DB: db}
|
||||
createdRule, err := ruleService.Create(ctx, rule.CreateInput{Code: prefix, Name: "生命周期测试规则", EventType: &eventType, MinimumSeverity: "low"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventService := event.Service{DB: db, AfterPersist: ruleService.Match}
|
||||
accepted, err := eventService.Ingest(ctx, event.Command{ProducerID: prefix, SourceEventID: "1", EventType: eventType, OccurredAt: time.Now().UTC(), Location: "test place", Severity: "high", Attributes: map[string]any{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queryService := query.Service{DB: db}
|
||||
alerts, err := queryService.ForEvent(ctx, accepted.Event.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alertID := ""
|
||||
for _, item := range alerts {
|
||||
if item.PrimaryRuleID == createdRule.ID {
|
||||
alertID = item.ID
|
||||
}
|
||||
}
|
||||
if alertID == "" {
|
||||
t.Fatal("alert not created")
|
||||
}
|
||||
service := Service{DB: db, Audit: audit.Store{DB: db}, Query: queryService}
|
||||
const attempts = 20
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < attempts; i++ {
|
||||
wg.Add(1)
|
||||
actor := actors[i%2]
|
||||
go func() { defer wg.Done(); _, _ = service.Ack(ctx, alertID, actor) }()
|
||||
}
|
||||
wg.Wait()
|
||||
detail, err := queryService.Get(ctx, alertID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.Alert.Status != "acknowledged" || detail.Alert.AcknowledgedBy == nil {
|
||||
t.Fatalf("unexpected ack projection: %#v", detail.Alert)
|
||||
}
|
||||
var facts int
|
||||
if err := db.QueryRow(ctx, `SELECT count(*) FROM bell_alert_lifecycle_facts WHERE alert_id=$1 AND transition='acknowledged'`, alertID).Scan(&facts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if facts != 1 {
|
||||
t.Fatalf("ack facts=%d", facts)
|
||||
}
|
||||
winner := actors[0]
|
||||
loser := actors[1]
|
||||
if winner.ID != *detail.Alert.AcknowledgedBy {
|
||||
winner, loser = loser, winner
|
||||
}
|
||||
if _, err := service.Close(ctx, alertID, CloseInput{Outcome: "site_normal"}, loser); err == nil {
|
||||
t.Fatal("non-owner closed alert")
|
||||
}
|
||||
closed, err := service.Close(ctx, alertID, CloseInput{Outcome: "site_normal", Note: "现场检查正常"}, winner)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if closed.Detail.Alert.Status != "closed" {
|
||||
t.Fatal("alert not closed")
|
||||
}
|
||||
repeat, err := service.Close(ctx, alertID, CloseInput{Outcome: "site_normal", Note: "现场检查正常"}, winner)
|
||||
if err != nil || !repeat.Idempotent {
|
||||
t.Fatalf("repeat close: %#v %v", repeat, err)
|
||||
}
|
||||
if err := db.QueryRow(ctx, `SELECT count(*) FROM bell_alert_lifecycle_facts WHERE alert_id=$1`, alertID).Scan(&facts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if facts != 2 {
|
||||
t.Fatalf("lifecycle facts=%d", facts)
|
||||
}
|
||||
if _, err := db.Exec(ctx, `DELETE FROM bell_alert_lifecycle_facts WHERE alert_id=$1`, alertID); err == nil {
|
||||
t.Fatal("lifecycle deletion succeeded")
|
||||
}
|
||||
db.Close()
|
||||
reopened, err := platform.OpenDatabase(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
afterRestart, err := (query.Service{DB: reopened}).Get(ctx, alertID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if afterRestart.Alert.Status != "closed" || len(afterRestart.Timeline) != 2 {
|
||||
t.Fatalf("restart projection: %#v", afterRestart)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package model
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type Alert struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Severity string `json:"severity"`
|
||||
Summary string `json:"summary"`
|
||||
Location string `json:"location"`
|
||||
PrimaryRuleID string `json:"primary_rule_id"`
|
||||
RuleName string `json:"rule_name"`
|
||||
EventCount int `json:"event_count"`
|
||||
AcknowledgedBy *string `json:"acknowledged_by,omitempty"`
|
||||
AcknowledgedByName *string `json:"acknowledged_by_name,omitempty"`
|
||||
AcknowledgedAt *string `json:"acknowledged_at,omitempty"`
|
||||
ClosedBy *string `json:"closed_by,omitempty"`
|
||||
ClosedByName *string `json:"closed_by_name,omitempty"`
|
||||
ClosedAt *string `json:"closed_at,omitempty"`
|
||||
CloseOutcome *string `json:"close_outcome,omitempty"`
|
||||
CloseNote *string `json:"close_note,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
type LinkedEvent struct {
|
||||
ID string `json:"id"`
|
||||
EventType string `json:"event_type"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Location string `json:"location"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
type Match struct {
|
||||
EventID string `json:"event_id"`
|
||||
RuleID string `json:"rule_id"`
|
||||
RuleVersion int `json:"rule_version"`
|
||||
RuleSnapshot json.RawMessage `json:"rule_snapshot"`
|
||||
Explanation string `json:"explanation"`
|
||||
MatchedAt string `json:"matched_at"`
|
||||
}
|
||||
type Detail struct {
|
||||
Alert Alert `json:"alert"`
|
||||
Events []LinkedEvent `json:"events"`
|
||||
Matches []Match `json:"matches"`
|
||||
Timeline []TimelineEntry `json:"timeline"`
|
||||
}
|
||||
type TimelineEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Transition string `json:"transition"`
|
||||
ActorUserID string `json:"actor_user_id"`
|
||||
ActorName string `json:"actor_name"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Details json.RawMessage `json:"details"`
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type HTTP struct {
|
||||
Service Service
|
||||
Auth auth.HTTP
|
||||
}
|
||||
|
||||
func (h HTTP) Register(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/alerts", h.Auth.Require(rbac.AlertsRead, http.HandlerFunc(h.list)))
|
||||
mux.Handle("GET /api/v1/alerts/{id}", h.Auth.Require(rbac.AlertsRead, http.HandlerFunc(h.get)))
|
||||
mux.Handle("GET /api/v1/events/{id}/alerts", h.Auth.Require(rbac.EventsRead, http.HandlerFunc(h.forEvent)))
|
||||
}
|
||||
func (h HTTP) list(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
items, err := h.Service.List(r.Context(), limit, r.URL.Query().Get("before"))
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": "读取预警失败"})
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > 0 {
|
||||
next = items[len(items)-1].ID
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items, "next": next})
|
||||
}
|
||||
func (h HTTP) get(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := h.Service.Get(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
status := 500
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
status = 404
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"error": "预警不存在"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, item)
|
||||
}
|
||||
func (h HTTP) forEvent(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.Service.ForEvent(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": "读取关联预警失败"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items})
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/alert/model"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Service struct{ DB *pgxpool.Pool }
|
||||
|
||||
const base = `SELECT a.id::text,a.status,a.severity,a.summary,a.location,a.primary_rule_id::text,r.name,(SELECT count(*) FROM bell_alert_events ae WHERE ae.alert_id=a.id),a.acknowledged_by::text,ack_user.display_name,a.acknowledged_at::text,a.closed_by::text,close_user.display_name,a.closed_at::text,a.close_outcome,a.close_note,a.created_at::text,a.updated_at::text FROM bell_alerts a JOIN bell_rules r ON r.id=a.primary_rule_id LEFT JOIN bell_users ack_user ON ack_user.id=a.acknowledged_by LEFT JOIN bell_users close_user ON close_user.id=a.closed_by`
|
||||
|
||||
func scanAlert(row interface{ Scan(...any) error }) (model.Alert, error) {
|
||||
var item model.Alert
|
||||
err := row.Scan(&item.ID, &item.Status, &item.Severity, &item.Summary, &item.Location, &item.PrimaryRuleID, &item.RuleName, &item.EventCount, &item.AcknowledgedBy, &item.AcknowledgedByName, &item.AcknowledgedAt, &item.ClosedBy, &item.ClosedByName, &item.ClosedAt, &item.CloseOutcome, &item.CloseNote, &item.CreatedAt, &item.UpdatedAt)
|
||||
return item, err
|
||||
}
|
||||
func (s Service) List(ctx context.Context, limit int, before string) ([]model.Alert, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
rows, err := s.DB.Query(ctx, base+` WHERE ($2='' OR (a.created_at,a.id)<((SELECT created_at FROM bell_alerts WHERE id=nullif($2,'')::uuid),nullif($2,'')::uuid)) ORDER BY a.created_at DESC,a.id DESC LIMIT $1`, limit, before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []model.Alert{}
|
||||
for rows.Next() {
|
||||
item, err := scanAlert(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
func (s Service) Get(ctx context.Context, id string) (model.Detail, error) {
|
||||
item, err := scanAlert(s.DB.QueryRow(ctx, base+` WHERE a.id=$1`, id))
|
||||
if err != nil {
|
||||
return model.Detail{}, err
|
||||
}
|
||||
detail := model.Detail{Alert: item, Events: []model.LinkedEvent{}, Matches: []model.Match{}, Timeline: []model.TimelineEntry{}}
|
||||
rows, err := s.DB.Query(ctx, `SELECT e.id::text,e.event_type,e.occurred_at::text,e.location,e.severity FROM bell_alert_events ae JOIN bell_events e ON e.id=ae.event_id WHERE ae.alert_id=$1 ORDER BY e.occurred_at,e.id`, id)
|
||||
if err != nil {
|
||||
return model.Detail{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var linked model.LinkedEvent
|
||||
if err := rows.Scan(&linked.ID, &linked.EventType, &linked.OccurredAt, &linked.Location, &linked.Severity); err != nil {
|
||||
rows.Close()
|
||||
return model.Detail{}, err
|
||||
}
|
||||
detail.Events = append(detail.Events, linked)
|
||||
}
|
||||
rows.Close()
|
||||
rows, err = s.DB.Query(ctx, `SELECT event_id::text,rule_id::text,rule_version,rule_snapshot,explanation,matched_at::text FROM bell_rule_matches WHERE alert_id=$1 ORDER BY matched_at,event_id`, id)
|
||||
if err != nil {
|
||||
return model.Detail{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var match model.Match
|
||||
if err := rows.Scan(&match.EventID, &match.RuleID, &match.RuleVersion, &match.RuleSnapshot, &match.Explanation, &match.MatchedAt); err != nil {
|
||||
return model.Detail{}, err
|
||||
}
|
||||
detail.Matches = append(detail.Matches, match)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return model.Detail{}, err
|
||||
}
|
||||
rows.Close()
|
||||
rows, err = s.DB.Query(ctx, `SELECT f.id,f.transition,f.actor_user_id::text,u.display_name,f.occurred_at::text,f.details FROM bell_alert_lifecycle_facts f JOIN bell_users u ON u.id=f.actor_user_id WHERE f.alert_id=$1 ORDER BY f.id`, id)
|
||||
if err != nil {
|
||||
return model.Detail{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var entry model.TimelineEntry
|
||||
if err := rows.Scan(&entry.ID, &entry.Transition, &entry.ActorUserID, &entry.ActorName, &entry.OccurredAt, &entry.Details); err != nil {
|
||||
return model.Detail{}, err
|
||||
}
|
||||
detail.Timeline = append(detail.Timeline, entry)
|
||||
}
|
||||
return detail, rows.Err()
|
||||
}
|
||||
func (s Service) ForEvent(ctx context.Context, eventID string) ([]model.Alert, error) {
|
||||
rows, err := s.DB.Query(ctx, base+` JOIN bell_alert_events link ON link.alert_id=a.id WHERE link.event_id=$1 ORDER BY a.created_at,a.id`, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []model.Alert{}
|
||||
for rows.Next() {
|
||||
item, err := scanAlert(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
ActorUserID *string `json:"actor_user_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID *string `json:"target_id,omitempty"`
|
||||
Outcome string `json:"outcome"`
|
||||
Details map[string]any `json:"details"`
|
||||
}
|
||||
|
||||
type Store struct{ DB *pgxpool.Pool }
|
||||
|
||||
func (s Store) Record(ctx context.Context, actorUserID *string, action, targetType string, targetID *string, outcome string, details map[string]any) error {
|
||||
data, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode audit details: %w", err)
|
||||
}
|
||||
_, err = s.DB.Exec(ctx, `INSERT INTO bell_audit_log(actor_user_id,action,target_type,target_id,outcome,details) VALUES($1,$2,$3,$4,$5,$6)`, actorUserID, action, targetType, targetID, outcome, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("append audit entry: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Store) List(ctx context.Context, limit int) ([]Entry, error) {
|
||||
if limit < 1 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.DB.Query(ctx, `SELECT id,occurred_at::text,actor_user_id::text,action,target_type,target_id,outcome,details FROM bell_audit_log ORDER BY id DESC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
entries := make([]Entry, 0, limit)
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
var data []byte
|
||||
if err := rows.Scan(&e.ID, &e.OccurredAt, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Outcome, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal(data, &e.Details)
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
)
|
||||
|
||||
const CookieName = "bell_session"
|
||||
|
||||
type HTTP struct {
|
||||
Service Service
|
||||
Store Store
|
||||
Audit audit.Store
|
||||
SecureCookie bool
|
||||
}
|
||||
|
||||
func (h HTTP) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/v1/auth/login", h.login)
|
||||
mux.Handle("GET /api/v1/auth/me", h.authenticated(http.HandlerFunc(h.me)))
|
||||
mux.Handle("POST /api/v1/auth/logout", h.authenticated(http.HandlerFunc(h.logout)))
|
||||
mux.Handle("GET /api/v1/users", h.Require(rbac.UsersRead, http.HandlerFunc(h.users)))
|
||||
mux.Handle("POST /api/v1/users", h.Require(rbac.UsersWrite, http.HandlerFunc(h.createUser)))
|
||||
mux.Handle("PUT /api/v1/users/{id}/roles", h.Require(rbac.UsersWrite, http.HandlerFunc(h.replaceRoles)))
|
||||
mux.Handle("GET /api/v1/audit", h.Require(rbac.AuditRead, http.HandlerFunc(h.auditEntries)))
|
||||
}
|
||||
|
||||
func (h HTTP) login(w http.ResponseWriter, r *http.Request) {
|
||||
var input LoginInput
|
||||
if err := decodeJSON(w, r, &input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result, err := h.Service.Login(r.Context(), input)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
status = http.StatusUnauthorized
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.setCookie(w, result.Token, time.Now().Add(8*time.Hour))
|
||||
result.User.PasswordHash = ""
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": result.User, "expires_at": result.ExpiresAt})
|
||||
}
|
||||
|
||||
func (h HTTP) me(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, principal(r.Context()))
|
||||
}
|
||||
func (h HTTP) logout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, _ := r.Cookie(CookieName)
|
||||
token := ""
|
||||
if cookie != nil {
|
||||
token = cookie.Value
|
||||
}
|
||||
user := principal(r.Context())
|
||||
if err := h.Service.Logout(r.Context(), token, user); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "退出失败"})
|
||||
return
|
||||
}
|
||||
h.setCookie(w, "", time.Unix(0, 0))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
func (h HTTP) users(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := h.Store.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取用户失败"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": users})
|
||||
}
|
||||
func (h HTTP) createUser(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
input.Username = strings.ToLower(strings.TrimSpace(input.Username))
|
||||
if input.Username == "" || input.DisplayName == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "用户名和显示名称必填"})
|
||||
return
|
||||
}
|
||||
hash, err := hashPassword(input.Password)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
id, err := h.Store.CreateUser(r.Context(), input.Username, input.DisplayName, hash, input.Role)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "用户或角色无效"})
|
||||
return
|
||||
}
|
||||
actor := principal(r.Context())
|
||||
_ = h.Audit.Record(r.Context(), &actor.ID, "rbac.user_created", "user", &id, "success", map[string]any{"role": input.Role})
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"id": id})
|
||||
}
|
||||
func (h HTTP) replaceRoles(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &input); err != nil || len(input.Roles) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "至少选择一个角色"})
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := h.Store.ReplaceRoles(r.Context(), id, input.Roles); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "角色更新失败"})
|
||||
return
|
||||
}
|
||||
actor := principal(r.Context())
|
||||
_ = h.Audit.Record(r.Context(), &actor.ID, "rbac.roles_replaced", "user", &id, "success", map[string]any{"roles": input.Roles})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
func (h HTTP) auditEntries(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
entries, err := h.Audit.List(r.Context(), limit)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取审计失败"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": entries})
|
||||
}
|
||||
|
||||
func (h HTTP) authenticated(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(CookieName)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": ErrUnauthorized.Error()})
|
||||
return
|
||||
}
|
||||
user, err := h.Service.Authenticate(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": ErrUnauthorized.Error()})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), principalKey{}, user)))
|
||||
})
|
||||
}
|
||||
func (h HTTP) Require(permission string, next http.Handler) http.Handler {
|
||||
return h.authenticated(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user := principal(r.Context())
|
||||
if !user.Permissions.Has(permission) {
|
||||
_ = h.Audit.Record(r.Context(), &user.ID, "rbac.denied", "permission", &permission, "denied", map[string]any{})
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "没有执行此操作的权限"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
}))
|
||||
}
|
||||
|
||||
func (h HTTP) setCookie(w http.ResponseWriter, value string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: value, Path: "/", HttpOnly: true, Secure: h.SecureCookie, SameSite: http.SameSiteStrictMode, Expires: expires, MaxAge: int(time.Until(expires).Seconds())})
|
||||
}
|
||||
func principal(ctx context.Context) User { user, _ := ctx.Value(principalKey{}).(User); return user }
|
||||
func Principal(ctx context.Context) User { return principal(ctx) }
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, value any) error {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
return decoder.Decode(value)
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func validatePassword(value string) error {
|
||||
if utf8.RuneCountInString(value) < 12 {
|
||||
return errors.New("密码至少需要 12 个字符")
|
||||
}
|
||||
var lower, upper, digit bool
|
||||
for _, r := range value {
|
||||
lower = lower || unicode.IsLower(r)
|
||||
upper = upper || unicode.IsUpper(r)
|
||||
digit = digit || unicode.IsDigit(r)
|
||||
}
|
||||
if !lower || !upper || !digit {
|
||||
return errors.New("密码必须同时包含大写字母、小写字母和数字")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hashPassword(value string) (string, error) {
|
||||
if err := validatePassword(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(value), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func passwordMatches(hash, value string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(value)) == nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPasswordPolicy(t *testing.T) {
|
||||
for _, value := range []string{"short", "alllowercase123", "ALLUPPERCASE123"} {
|
||||
if validatePassword(value) == nil {
|
||||
t.Fatalf("expected %q to fail policy", value)
|
||||
}
|
||||
}
|
||||
if err := validatePassword("Bell-Safe-2026"); err != nil {
|
||||
t.Fatalf("valid password rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordHashRoundTrip(t *testing.T) {
|
||||
hash, err := hashPassword("Bell-Safe-2026")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !passwordMatches(hash, "Bell-Safe-2026") {
|
||||
t.Fatal("password did not match")
|
||||
}
|
||||
if passwordMatches(hash, "Wrong-Safe-2026") {
|
||||
t.Fatal("wrong password matched")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var ErrInvalidCredentials = errors.New("用户名或密码错误")
|
||||
var ErrUnauthorized = errors.New("会话无效或已过期")
|
||||
|
||||
type Service struct {
|
||||
Store Store
|
||||
Audit audit.Store
|
||||
Secret []byte
|
||||
SessionTTL time.Duration
|
||||
}
|
||||
|
||||
func (s Service) digest(token string) []byte {
|
||||
mac := hmac.New(sha256.New, s.Secret)
|
||||
_, _ = mac.Write([]byte(token))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func (s Service) Login(ctx context.Context, input LoginInput) (LoginResult, error) {
|
||||
username := strings.ToLower(strings.TrimSpace(input.Username))
|
||||
user, err := s.Store.UserByUsername(ctx, username)
|
||||
if err != nil || !user.Enabled || !passwordMatches(user.PasswordHash, input.Password) {
|
||||
_ = s.Audit.Record(ctx, nil, "auth.login", "user", nil, "failure", map[string]any{"reason": "invalid_credentials"})
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
expires := time.Now().UTC().Add(s.SessionTTL)
|
||||
if s.SessionTTL == 0 {
|
||||
expires = time.Now().UTC().Add(8 * time.Hour)
|
||||
}
|
||||
if err := s.Store.CreateSession(ctx, user.ID, s.digest(token), expires); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
_ = s.Audit.Record(ctx, &user.ID, "auth.login", "user", &user.ID, "success", map[string]any{})
|
||||
return LoginResult{Token: token, User: user, ExpiresAt: expires.Format(time.RFC3339)}, nil
|
||||
}
|
||||
|
||||
func (s Service) Authenticate(ctx context.Context, token string) (User, error) {
|
||||
if token == "" {
|
||||
return User{}, ErrUnauthorized
|
||||
}
|
||||
user, err := s.Store.UserBySessionDigest(ctx, s.digest(token))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrUnauthorized
|
||||
}
|
||||
return User{}, err
|
||||
}
|
||||
if !user.Enabled {
|
||||
return User{}, ErrUnauthorized
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
func (s Service) Logout(ctx context.Context, token string, user User) error {
|
||||
if token != "" {
|
||||
if err := s.Store.RevokeSession(ctx, s.digest(token)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.Audit.Record(ctx, &user.ID, "auth.logout", "user", &user.ID, "success", map[string]any{})
|
||||
}
|
||||
func (s Service) BootstrapAdministrator(ctx context.Context, username, displayName, password string) error {
|
||||
username = strings.ToLower(strings.TrimSpace(username))
|
||||
if username == "" {
|
||||
return errors.New("username is required")
|
||||
}
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := s.Store.BootstrapAdministrator(ctx, username, displayName, hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Audit.Record(ctx, &id, "rbac.bootstrap_administrator", "user", &id, "success", map[string]any{})
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Store struct{ DB *pgxpool.Pool }
|
||||
|
||||
func (s Store) UserByUsername(ctx context.Context, username string) (User, error) {
|
||||
var user User
|
||||
err := s.DB.QueryRow(ctx, `SELECT id::text,username,display_name,password_hash,enabled FROM bell_users WHERE username=$1`, username).Scan(&user.ID, &user.Username, &user.DisplayName, &user.PasswordHash, &user.Enabled)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if err = s.loadAccess(ctx, &user); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s Store) UserBySessionDigest(ctx context.Context, digest []byte) (User, error) {
|
||||
var user User
|
||||
err := s.DB.QueryRow(ctx, `SELECT u.id::text,u.username,u.display_name,u.password_hash,u.enabled FROM bell_sessions s JOIN bell_users u ON u.id=s.user_id WHERE s.token_digest=$1 AND s.revoked_at IS NULL AND s.expires_at>now()`, digest).Scan(&user.ID, &user.Username, &user.DisplayName, &user.PasswordHash, &user.Enabled)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if err = s.loadAccess(ctx, &user); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s Store) loadAccess(ctx context.Context, user *User) error {
|
||||
rows, err := s.DB.Query(ctx, `SELECT DISTINCT r.code,p.permission_code FROM bell_user_roles ur JOIN bell_roles r ON r.id=ur.role_id LEFT JOIN bell_role_permissions p ON p.role_id=r.id WHERE ur.user_id=$1 ORDER BY r.code,p.permission_code`, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
user.Permissions = rbac.Set{}
|
||||
roleSeen := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var role string
|
||||
var permission *string
|
||||
if err := rows.Scan(&role, &permission); err != nil {
|
||||
return err
|
||||
}
|
||||
if !roleSeen[role] {
|
||||
user.Roles = append(user.Roles, role)
|
||||
roleSeen[role] = true
|
||||
}
|
||||
if permission != nil {
|
||||
user.Permissions[*permission] = struct{}{}
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (s Store) CreateSession(ctx context.Context, userID string, digest []byte, expires time.Time) error {
|
||||
_, err := s.DB.Exec(ctx, `INSERT INTO bell_sessions(user_id,token_digest,expires_at) VALUES($1,$2,$3)`, userID, digest, expires)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s Store) RevokeSession(ctx context.Context, digest []byte) error {
|
||||
_, err := s.DB.Exec(ctx, `UPDATE bell_sessions SET revoked_at=COALESCE(revoked_at,now()) WHERE token_digest=$1`, digest)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s Store) BootstrapAdministrator(ctx context.Context, username, displayName, passwordHash string) (string, error) {
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var id string
|
||||
err = tx.QueryRow(ctx, `INSERT INTO bell_users(username,display_name,password_hash) VALUES($1,$2,$3) ON CONFLICT(username) DO NOTHING RETURNING id::text`, username, displayName, passwordHash).Scan(&id)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return "", fmt.Errorf("administrator %q already exists", username)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO bell_user_roles(user_id,role_id) SELECT $1,id FROM bell_roles WHERE code='administrator'`, id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s Store) ListUsers(ctx context.Context) ([]User, error) {
|
||||
rows, err := s.DB.Query(ctx, `SELECT id::text,username,display_name,enabled FROM bell_users ORDER BY username LIMIT 200`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
users := []User{}
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Enabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range users {
|
||||
if err := s.loadAccess(ctx, &users[i]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s Store) CreateUser(ctx context.Context, username, displayName, passwordHash, role string) (string, error) {
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var id string
|
||||
if err = tx.QueryRow(ctx, `INSERT INTO bell_users(username,display_name,password_hash) VALUES($1,$2,$3) RETURNING id::text`, username, displayName, passwordHash).Scan(&id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
result, err := tx.Exec(ctx, `INSERT INTO bell_user_roles(user_id,role_id) SELECT $1,id FROM bell_roles WHERE code=$2`, id, role)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return "", fmt.Errorf("unknown role %q", role)
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s Store) ReplaceRoles(ctx context.Context, userID string, roles []string) error {
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if _, err = tx.Exec(ctx, `DELETE FROM bell_user_roles WHERE user_id=$1`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, role := range roles {
|
||||
result, err := tx.Exec(ctx, `INSERT INTO bell_user_roles(user_id,role_id) SELECT $1,id FROM bell_roles WHERE code=$2`, userID, role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return fmt.Errorf("unknown role %q", role)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package auth
|
||||
|
||||
import "git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Roles []string `json:"roles"`
|
||||
Permissions rbac.Set `json:"permissions"`
|
||||
PasswordHash string `json:"-"`
|
||||
}
|
||||
|
||||
type LoginInput struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
type LoginResult struct {
|
||||
Token string
|
||||
User User
|
||||
ExpiresAt string
|
||||
}
|
||||
|
||||
type principalKey struct{}
|
||||
@@ -0,0 +1,52 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type HTTP struct {
|
||||
Service Service
|
||||
Auth auth.HTTP
|
||||
}
|
||||
|
||||
func (h HTTP) Register(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/events", h.Auth.Require(rbac.EventsRead, http.HandlerFunc(h.list)))
|
||||
mux.Handle("GET /api/v1/events/{id}", h.Auth.Require(rbac.EventsRead, http.HandlerFunc(h.get)))
|
||||
}
|
||||
func (h HTTP) list(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
items, err := h.Service.List(r.Context(), limit, r.URL.Query().Get("before"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取事件失败"})
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > 0 {
|
||||
next = items[len(items)-1].ID
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next": next})
|
||||
}
|
||||
func (h HTTP) get(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := h.Service.Get(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "事件不存在"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取事件失败"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrConflict = errors.New("幂等键已用于不同事件载荷")
|
||||
var ErrInvalid = errors.New("事件字段无效")
|
||||
|
||||
type PersistHook func(context.Context, pgx.Tx, Event) error
|
||||
type Service struct {
|
||||
DB *pgxpool.Pool
|
||||
AfterPersist PersistHook
|
||||
}
|
||||
|
||||
func normalize(command Command) ([]byte, [32]byte, error) {
|
||||
command.ProducerID = strings.TrimSpace(command.ProducerID)
|
||||
command.SourceEventID = strings.TrimSpace(command.SourceEventID)
|
||||
command.EventType = strings.TrimSpace(command.EventType)
|
||||
command.Location = strings.TrimSpace(command.Location)
|
||||
command.Severity = strings.ToLower(strings.TrimSpace(command.Severity))
|
||||
command.OccurredAt = command.OccurredAt.UTC()
|
||||
if command.ProducerID == "" || command.SourceEventID == "" || command.EventType == "" || command.Location == "" || command.OccurredAt.IsZero() {
|
||||
return nil, [32]byte{}, ErrInvalid
|
||||
}
|
||||
if command.Severity != "low" && command.Severity != "medium" && command.Severity != "high" && command.Severity != "critical" {
|
||||
return nil, [32]byte{}, ErrInvalid
|
||||
}
|
||||
if command.EvidenceRef != nil {
|
||||
value := strings.TrimSpace(*command.EvidenceRef)
|
||||
if value == "" || strings.Contains(value, "@") || strings.Contains(value, "\\") || strings.HasPrefix(strings.ToLower(value), "file:") {
|
||||
return nil, [32]byte{}, fmt.Errorf("%w: evidence_ref must be a safe logical reference", ErrInvalid)
|
||||
}
|
||||
command.EvidenceRef = &value
|
||||
}
|
||||
if command.Attributes == nil {
|
||||
command.Attributes = map[string]any{}
|
||||
}
|
||||
data, err := json.Marshal(command)
|
||||
if err != nil {
|
||||
return nil, [32]byte{}, fmt.Errorf("normalize event: %w", err)
|
||||
}
|
||||
return data, sha256.Sum256(data), nil
|
||||
}
|
||||
|
||||
func (s Service) Ingest(ctx context.Context, command Command) (Result, error) {
|
||||
payload, digest, err := normalize(command)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
var normalized Command
|
||||
if err := json.Unmarshal(payload, &normalized); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var eventID string
|
||||
err = tx.QueryRow(ctx, `INSERT INTO bell_events(producer_id,source_event_id,event_type,occurred_at,location,severity,evidence_ref,normalized_payload,payload_sha256) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT(producer_id,source_event_id) DO NOTHING RETURNING id::text`, normalized.ProducerID, normalized.SourceEventID, normalized.EventType, normalized.OccurredAt, normalized.Location, normalized.Severity, normalized.EvidenceRef, payload, digest[:]).Scan(&eventID)
|
||||
created := err == nil
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Result{}, err
|
||||
}
|
||||
if created {
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO bell_event_receipts(event_id,producer_id,source_event_id,payload_sha256) VALUES($1,$2,$3,$4)`, eventID, normalized.ProducerID, normalized.SourceEventID, digest[:]); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
}
|
||||
result, storedDigest, err := loadResult(ctx, tx, normalized.ProducerID, normalized.SourceEventID)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if !created && !equalDigest(storedDigest, digest[:]) {
|
||||
return Result{}, ErrConflict
|
||||
}
|
||||
result.Duplicate = !created
|
||||
if created && s.AfterPersist != nil {
|
||||
if err := s.AfterPersist(ctx, tx, result.Event); err != nil {
|
||||
return Result{}, fmt.Errorf("process accepted Event: %w", err)
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadResult(ctx context.Context, q interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}, producerID, sourceEventID string) (Result, []byte, error) {
|
||||
var result Result
|
||||
var digest []byte
|
||||
err := q.QueryRow(ctx, `SELECT e.id::text,e.producer_id,e.source_event_id,e.event_type,e.occurred_at::text,e.location,e.severity,e.evidence_ref,e.normalized_payload,e.created_at::text,e.payload_sha256,r.id::text,r.accepted_at::text FROM bell_events e JOIN bell_event_receipts r ON r.event_id=e.id WHERE e.producer_id=$1 AND e.source_event_id=$2`, producerID, sourceEventID).Scan(&result.Event.ID, &result.Event.ProducerID, &result.Event.SourceEventID, &result.Event.EventType, &result.Event.OccurredAt, &result.Event.Location, &result.Event.Severity, &result.Event.EvidenceRef, &result.Event.NormalizedPayload, &result.Event.CreatedAt, &digest, &result.Receipt.ID, &result.Receipt.AcceptedAt)
|
||||
result.Receipt.EventID = result.Event.ID
|
||||
result.Receipt.ProducerID = producerID
|
||||
result.Receipt.SourceEventID = sourceEventID
|
||||
return result, digest, err
|
||||
}
|
||||
func equalDigest(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
var different byte
|
||||
for i := range a {
|
||||
different |= a[i] ^ b[i]
|
||||
}
|
||||
return different == 0
|
||||
}
|
||||
|
||||
func (s Service) List(ctx context.Context, limit int, before string) ([]Event, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
rows, err := s.DB.Query(ctx, `SELECT id::text,producer_id,source_event_id,event_type,occurred_at::text,location,severity,evidence_ref,normalized_payload,created_at::text FROM bell_events WHERE ($2='' OR (created_at,id)<((SELECT created_at FROM bell_events WHERE id=nullif($2,'')::uuid),nullif($2,'')::uuid)) ORDER BY created_at DESC,id DESC LIMIT $1`, limit, before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Event{}
|
||||
for rows.Next() {
|
||||
var item Event
|
||||
if err := rows.Scan(&item.ID, &item.ProducerID, &item.SourceEventID, &item.EventType, &item.OccurredAt, &item.Location, &item.Severity, &item.EvidenceRef, &item.NormalizedPayload, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
func (s Service) Get(ctx context.Context, id string) (Event, error) {
|
||||
var item Event
|
||||
err := s.DB.QueryRow(ctx, `SELECT id::text,producer_id,source_event_id,event_type,occurred_at::text,location,severity,evidence_ref,normalized_payload,created_at::text FROM bell_events WHERE id=$1`, id).Scan(&item.ID, &item.ProducerID, &item.SourceEventID, &item.EventType, &item.OccurredAt, &item.Location, &item.Severity, &item.EvidenceRef, &item.NormalizedPayload, &item.CreatedAt)
|
||||
return item, err
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func TestNormalizeRejectsUnsafeEvidence(t *testing.T) {
|
||||
value := `file:C:\camera\secret.mp4`
|
||||
_, _, err := normalize(Command{ProducerID: "test", SourceEventID: "1", EventType: "intrusion", OccurredAt: time.Now(), Location: "gate", Severity: "high", EvidenceRef: &value})
|
||||
if !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected invalid evidence reference, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresIdempotencyAndImmutability(t *testing.T) {
|
||||
url := os.Getenv("BELL_TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("BELL_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := platform.OpenDatabase(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := migrations.Apply(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := Service{DB: db}
|
||||
key := fmt.Sprintf("event-test-%d", time.Now().UnixNano())
|
||||
command := Command{ProducerID: "integration-test", SourceEventID: key, EventType: "danger-zone", OccurredAt: time.Now().UTC().Truncate(time.Millisecond), Location: "test-zone", Severity: "high", Attributes: map[string]any{"track_id": "anonymous-1"}}
|
||||
const count = 16
|
||||
results := make(chan Result, count)
|
||||
errorsFound := make(chan error, count)
|
||||
var wg sync.WaitGroup
|
||||
for range count {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result, err := service.Ingest(ctx, command)
|
||||
if err != nil {
|
||||
errorsFound <- err
|
||||
return
|
||||
}
|
||||
results <- result
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(errorsFound)
|
||||
for err := range errorsFound {
|
||||
t.Errorf("concurrent ingest: %v", err)
|
||||
}
|
||||
var eventID, receiptID string
|
||||
seen := 0
|
||||
for result := range results {
|
||||
seen++
|
||||
if eventID == "" {
|
||||
eventID = result.Event.ID
|
||||
receiptID = result.Receipt.ID
|
||||
}
|
||||
if result.Event.ID != eventID || result.Receipt.ID != receiptID {
|
||||
t.Errorf("unstable identities: %#v", result)
|
||||
}
|
||||
}
|
||||
if seen != count {
|
||||
t.Fatalf("received %d results, want %d", seen, count)
|
||||
}
|
||||
conflict := command
|
||||
conflict.Location = "different"
|
||||
if _, err := service.Ingest(ctx, conflict); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected conflict, got %v", err)
|
||||
}
|
||||
if _, err := db.Exec(ctx, `UPDATE bell_events SET location='tampered' WHERE id=$1`, eventID); err == nil {
|
||||
t.Fatal("immutable Event accepted update")
|
||||
}
|
||||
db.Close()
|
||||
reopened, err := platform.OpenDatabase(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
afterRestart, err := (Service{DB: reopened}).Ingest(ctx, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if afterRestart.Event.ID != eventID || afterRestart.Receipt.ID != receiptID || !afterRestart.Duplicate {
|
||||
t.Fatalf("receipt changed after restart: %#v", afterRestart)
|
||||
}
|
||||
failing := command
|
||||
failing.SourceEventID = key + "-rollback"
|
||||
failedService := Service{DB: reopened, AfterPersist: func(context.Context, pgx.Tx, Event) error { return errors.New("forced matching failure") }}
|
||||
if _, err := failedService.Ingest(ctx, failing); err == nil {
|
||||
t.Fatal("expected hook failure")
|
||||
}
|
||||
var persisted int
|
||||
if err := reopened.QueryRow(ctx, `SELECT count(*) FROM bell_events WHERE producer_id=$1 AND source_event_id=$2`, failing.ProducerID, failing.SourceEventID).Scan(&persisted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if persisted != 0 {
|
||||
t.Fatal("Event committed without atomic match")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/receipt"
|
||||
)
|
||||
|
||||
type Command struct {
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Location string `json:"location"`
|
||||
Severity string `json:"severity"`
|
||||
EvidenceRef *string `json:"evidence_ref,omitempty"`
|
||||
Attributes map[string]any `json:"attributes"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Location string `json:"location"`
|
||||
Severity string `json:"severity"`
|
||||
EvidenceRef *string `json:"evidence_ref,omitempty"`
|
||||
NormalizedPayload json.RawMessage `json:"normalized_payload"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Event Event `json:"event"`
|
||||
Receipt receipt.Receipt `json:"receipt"`
|
||||
Duplicate bool `json:"duplicate"`
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package rbac
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
)
|
||||
|
||||
const (
|
||||
DashboardRead = "dashboard:read"
|
||||
AlertsRead = "alerts:read"
|
||||
AlertsHandle = "alerts:handle"
|
||||
EventsRead = "events:read"
|
||||
RulesRead = "rules:read"
|
||||
RulesWrite = "rules:write"
|
||||
UsersRead = "users:read"
|
||||
UsersWrite = "users:write"
|
||||
AuditRead = "audit:read"
|
||||
SyntheticWrite = "synthetic:write"
|
||||
)
|
||||
|
||||
type Set map[string]struct{}
|
||||
|
||||
func (s Set) Has(permission string) bool { _, ok := s[permission]; return ok }
|
||||
|
||||
func (s Set) MarshalJSON() ([]byte, error) {
|
||||
values := make([]string, 0, len(s))
|
||||
for value := range s {
|
||||
values = append(values, value)
|
||||
}
|
||||
sort.Strings(values)
|
||||
return json.Marshal(values)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package receipt
|
||||
|
||||
type Receipt struct {
|
||||
ID string `json:"id"`
|
||||
EventID string `json:"event_id"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
AcceptedAt string `json:"accepted_at"`
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
)
|
||||
|
||||
type HTTP struct {
|
||||
Service Service
|
||||
Auth auth.HTTP
|
||||
Audit audit.Store
|
||||
}
|
||||
|
||||
func (h HTTP) Register(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/rules", h.Auth.Require(rbac.RulesRead, http.HandlerFunc(h.list)))
|
||||
mux.Handle("POST /api/v1/rules", h.Auth.Require(rbac.RulesWrite, http.HandlerFunc(h.create)))
|
||||
mux.Handle("PUT /api/v1/rules/{id}/enabled", h.Auth.Require(rbac.RulesWrite, http.HandlerFunc(h.setEnabled)))
|
||||
}
|
||||
func (h HTTP) list(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.Service.List(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": "读取规则失败"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items})
|
||||
}
|
||||
func (h HTTP) create(w http.ResponseWriter, r *http.Request) {
|
||||
var input CreateInput
|
||||
if err := decode(w, r, &input); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": "请求格式无效"})
|
||||
return
|
||||
}
|
||||
item, err := h.Service.Create(r.Context(), input)
|
||||
if err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
actor := auth.Principal(r.Context())
|
||||
_ = h.Audit.Record(r.Context(), &actor.ID, "rule.created", "rule", &item.ID, "success", map[string]any{"version": item.Version})
|
||||
writeJSON(w, 201, item)
|
||||
}
|
||||
func (h HTTP) setEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := decode(w, r, &input); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": "请求格式无效"})
|
||||
return
|
||||
}
|
||||
item, err := h.Service.SetEnabled(r.Context(), r.PathValue("id"), input.Enabled)
|
||||
if err != nil {
|
||||
writeJSON(w, 404, map[string]string{"error": "规则不存在"})
|
||||
return
|
||||
}
|
||||
actor := auth.Principal(r.Context())
|
||||
_ = h.Audit.Record(r.Context(), &actor.ID, "rule.enabled_changed", "rule", &item.ID, "success", map[string]any{"enabled": item.Enabled, "version": item.Version})
|
||||
writeJSON(w, 200, item)
|
||||
}
|
||||
func decode(w http.ResponseWriter, r *http.Request, value any) error {
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
return decoder.Decode(value)
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrInvalid = errors.New("规则字段无效")
|
||||
|
||||
type Service struct{ DB *pgxpool.Pool }
|
||||
|
||||
func validSeverity(value string) bool {
|
||||
return value == "low" || value == "medium" || value == "high" || value == "critical"
|
||||
}
|
||||
func severityRank(value string) int {
|
||||
return map[string]int{"low": 1, "medium": 2, "high": 3, "critical": 4}[value]
|
||||
}
|
||||
func (s Service) Create(ctx context.Context, input CreateInput) (Rule, error) {
|
||||
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.MinimumSeverity = strings.ToLower(strings.TrimSpace(input.MinimumSeverity))
|
||||
if input.Code == "" || input.Name == "" || !validSeverity(input.MinimumSeverity) {
|
||||
return Rule{}, ErrInvalid
|
||||
}
|
||||
if input.EventType != nil {
|
||||
v := strings.TrimSpace(*input.EventType)
|
||||
if v == "" {
|
||||
input.EventType = nil
|
||||
} else {
|
||||
input.EventType = &v
|
||||
}
|
||||
}
|
||||
if input.LocationContains != nil {
|
||||
v := strings.TrimSpace(*input.LocationContains)
|
||||
if v == "" {
|
||||
input.LocationContains = nil
|
||||
} else {
|
||||
input.LocationContains = &v
|
||||
}
|
||||
}
|
||||
var result Rule
|
||||
err := s.DB.QueryRow(ctx, `INSERT INTO bell_rules(code,name,event_type,minimum_severity,location_contains) VALUES($1,$2,$3,$4,$5) RETURNING id::text,code,name,enabled,event_type,minimum_severity,location_contains,version,created_at::text,updated_at::text`, input.Code, input.Name, input.EventType, input.MinimumSeverity, input.LocationContains).Scan(&result.ID, &result.Code, &result.Name, &result.Enabled, &result.EventType, &result.MinimumSeverity, &result.LocationContains, &result.Version, &result.CreatedAt, &result.UpdatedAt)
|
||||
return result, err
|
||||
}
|
||||
func (s Service) SetEnabled(ctx context.Context, id string, enabled bool) (Rule, error) {
|
||||
var result Rule
|
||||
err := s.DB.QueryRow(ctx, `UPDATE bell_rules SET enabled=$2,version=version+1,updated_at=now() WHERE id=$1 RETURNING id::text,code,name,enabled,event_type,minimum_severity,location_contains,version,created_at::text,updated_at::text`, id, enabled).Scan(&result.ID, &result.Code, &result.Name, &result.Enabled, &result.EventType, &result.MinimumSeverity, &result.LocationContains, &result.Version, &result.CreatedAt, &result.UpdatedAt)
|
||||
return result, err
|
||||
}
|
||||
func (s Service) List(ctx context.Context) ([]Rule, error) {
|
||||
rows, err := s.DB.Query(ctx, `SELECT id::text,code,name,enabled,event_type,minimum_severity,location_contains,version,created_at::text,updated_at::text FROM bell_rules ORDER BY created_at,id LIMIT 200`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Rule{}
|
||||
for rows.Next() {
|
||||
var item Rule
|
||||
if err := rows.Scan(&item.ID, &item.Code, &item.Name, &item.Enabled, &item.EventType, &item.MinimumSeverity, &item.LocationContains, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s Service) Match(ctx context.Context, tx pgx.Tx, evt event.Event) error {
|
||||
rows, err := tx.Query(ctx, `SELECT id::text,code,name,enabled,event_type,minimum_severity,location_contains,version,created_at::text,updated_at::text FROM bell_rules WHERE enabled=true ORDER BY id`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rules := []Rule{}
|
||||
for rows.Next() {
|
||||
var item Rule
|
||||
if err := rows.Scan(&item.ID, &item.Code, &item.Name, &item.Enabled, &item.EventType, &item.MinimumSeverity, &item.LocationContains, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rules = append(rules, item)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, current := range rules {
|
||||
matched := true
|
||||
reasons := []string{}
|
||||
if current.EventType != nil && *current.EventType != evt.EventType {
|
||||
matched = false
|
||||
reasons = append(reasons, "事件类型不匹配")
|
||||
}
|
||||
if severityRank(evt.Severity) < severityRank(current.MinimumSeverity) {
|
||||
matched = false
|
||||
reasons = append(reasons, "风险等级低于阈值")
|
||||
}
|
||||
if current.LocationContains != nil && !strings.Contains(strings.ToLower(evt.Location), strings.ToLower(*current.LocationContains)) {
|
||||
matched = false
|
||||
reasons = append(reasons, "地点条件不匹配")
|
||||
}
|
||||
explanation := "全部条件命中"
|
||||
if !matched {
|
||||
explanation = strings.Join(reasons, ";")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO bell_rule_evaluations(event_id,rule_id,rule_version,matched,explanation) VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`, evt.ID, current.ID, current.Version, matched, explanation); err != nil {
|
||||
return err
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
snapshot, _ := json.Marshal(current)
|
||||
summary := fmt.Sprintf("%s:%s", current.Name, evt.EventType)
|
||||
var alertID string
|
||||
err := tx.QueryRow(ctx, `INSERT INTO bell_alerts(primary_rule_id,correlation_key,severity,summary,location) VALUES($1,$2,$3,$4,$5) ON CONFLICT(primary_rule_id,correlation_key) WHERE status='open' DO UPDATE SET updated_at=now(),severity=CASE WHEN array_position(ARRAY['low','medium','high','critical'],EXCLUDED.severity)>array_position(ARRAY['low','medium','high','critical'],bell_alerts.severity) THEN EXCLUDED.severity ELSE bell_alerts.severity END RETURNING id::text`, current.ID, strings.ToLower(evt.Location), evt.Severity, summary, evt.Location).Scan(&alertID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO bell_alert_events(alert_id,event_id) VALUES($1,$2) ON CONFLICT DO NOTHING`, alertID, evt.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO bell_rule_matches(alert_id,event_id,rule_id,rule_version,rule_snapshot,explanation) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`, alertID, evt.ID, current.ID, current.Version, snapshot, explanation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
|
||||
)
|
||||
|
||||
func TestPostgresMatchingAndNavigationShape(t *testing.T) {
|
||||
url := os.Getenv("BELL_TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("BELL_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := platform.OpenDatabase(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := migrations.Apply(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := Service{DB: db}
|
||||
prefix := fmt.Sprintf("rule-test-%d", time.Now().UnixNano())
|
||||
eventType := "rule-test-danger"
|
||||
location := "north"
|
||||
first, err := service.Create(ctx, CreateInput{Code: prefix + "-one", Name: "规则一", EventType: &eventType, MinimumSeverity: "medium", LocationContains: &location})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := service.Create(ctx, CreateInput{Code: prefix + "-two", Name: "规则二", EventType: &eventType, MinimumSeverity: "low"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventService := event.Service{DB: db, AfterPersist: service.Match}
|
||||
base := event.Command{ProducerID: prefix, SourceEventID: "1", EventType: eventType, OccurredAt: time.Now().UTC(), Location: "north gate", Severity: "high", Attributes: map[string]any{}}
|
||||
one, err := eventService.Ingest(ctx, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base.SourceEventID = "2"
|
||||
two, err := eventService.Ingest(ctx, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var alertCount, linkCount int
|
||||
if err := db.QueryRow(ctx, `SELECT count(DISTINCT m.alert_id),count(*) FROM bell_rule_matches m WHERE m.rule_id=ANY($1::uuid[])`, []string{first.ID, second.ID}).Scan(&alertCount, &linkCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if alertCount != 2 || linkCount != 4 {
|
||||
t.Fatalf("alerts=%d links=%d", alertCount, linkCount)
|
||||
}
|
||||
var shared int
|
||||
if err := db.QueryRow(ctx, `SELECT count(*) FROM bell_alert_events a JOIN bell_alert_events b ON b.alert_id=a.alert_id JOIN bell_alerts current ON current.id=a.alert_id WHERE a.event_id=$1 AND b.event_id=$2 AND current.primary_rule_id=ANY($3::uuid[])`, one.Event.ID, two.Event.ID, []string{first.ID, second.ID}).Scan(&shared); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if shared != 2 {
|
||||
t.Fatalf("expected two correlated alerts, got %d", shared)
|
||||
}
|
||||
base.SourceEventID = "3"
|
||||
base.EventType = "unmatched"
|
||||
unmatched, err := eventService.Ingest(ctx, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var evaluations, matches int
|
||||
if err := db.QueryRow(ctx, `SELECT count(*),count(*) FILTER(WHERE matched) FROM bell_rule_evaluations WHERE event_id=$1`, unmatched.Event.ID).Scan(&evaluations, &matches); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if evaluations < 2 || matches != 0 {
|
||||
t.Fatalf("evaluations=%d matches=%d", evaluations, matches)
|
||||
}
|
||||
disabled, err := service.SetEnabled(ctx, first.ID, false)
|
||||
if err != nil || disabled.Enabled || disabled.Version != 2 {
|
||||
t.Fatalf("disable: %#v %v", disabled, err)
|
||||
}
|
||||
enabled, err := service.SetEnabled(ctx, first.ID, true)
|
||||
if err != nil || !enabled.Enabled || enabled.Version != 3 {
|
||||
t.Fatalf("enable: %#v %v", enabled, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package rule
|
||||
|
||||
type Rule struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EventType *string `json:"event_type,omitempty"`
|
||||
MinimumSeverity string `json:"minimum_severity"`
|
||||
LocationContains *string `json:"location_contains,omitempty"`
|
||||
Version int `json:"version"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
type CreateInput struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
EventType *string `json:"event_type,omitempty"`
|
||||
MinimumSeverity string `json:"minimum_severity"`
|
||||
LocationContains *string `json:"location_contains,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
)
|
||||
|
||||
type HTTP struct {
|
||||
Service Service
|
||||
Auth auth.HTTP
|
||||
}
|
||||
|
||||
func (h HTTP) Register(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/synthetic/fixtures", h.Auth.Require(rbac.SyntheticWrite, http.HandlerFunc(h.fixtures)))
|
||||
mux.Handle("POST /api/v1/synthetic/events", h.Auth.Require(rbac.SyntheticWrite, http.HandlerFunc(h.inject)))
|
||||
}
|
||||
func (h HTTP) fixtures(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.Service.Fixtures()
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": "读取夹具失败"})
|
||||
return
|
||||
}
|
||||
type summary struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
ExpectedFailure bool `json:"expected_failure"`
|
||||
}
|
||||
result := make([]summary, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, summary{Name: item.Name, Label: item.Label, ExpectedFailure: item.ValidationError != ""})
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": result})
|
||||
}
|
||||
func (h HTTP) inject(w http.ResponseWriter, r *http.Request) {
|
||||
var input Input
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": "请求格式无效"})
|
||||
return
|
||||
}
|
||||
result, err := h.Service.Inject(r.Context(), input)
|
||||
if err != nil {
|
||||
status := 500
|
||||
if errors.Is(err, ErrFixture) || errors.Is(err, event.ErrInvalid) {
|
||||
status = 400
|
||||
} else if errors.Is(err, event.ErrConflict) {
|
||||
status = 409
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
status := 201
|
||||
if result.Duplicate {
|
||||
status = 200
|
||||
}
|
||||
writeJSON(w, status, result)
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
testevents "git.ilapage.cn/ila/yovision/Bell/server/testdata/events"
|
||||
)
|
||||
|
||||
var ErrFixture = errors.New("合成夹具验证失败")
|
||||
|
||||
type Service struct{ Events event.Service }
|
||||
type Input struct {
|
||||
Fixture string `json:"fixture"`
|
||||
SourceEventID string `json:"source_event_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s Service) Fixtures() ([]testevents.Fixture, error) { return testevents.All() }
|
||||
func (s Service) Inject(ctx context.Context, input Input) (event.Result, error) {
|
||||
fixture, err := testevents.Find(input.Fixture)
|
||||
if err != nil {
|
||||
return event.Result{}, err
|
||||
}
|
||||
if fixture.ValidationError != "" {
|
||||
return event.Result{}, errors.Join(ErrFixture, errors.New(fixture.ValidationError))
|
||||
}
|
||||
if value := strings.TrimSpace(input.SourceEventID); value != "" {
|
||||
fixture.Command.SourceEventID = value
|
||||
}
|
||||
return s.Events.Ingest(ctx, fixture.Command)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
|
||||
)
|
||||
|
||||
func TestInvalidFixturesAreStable(t *testing.T) {
|
||||
service := Service{}
|
||||
for _, name := range []string{"invalid-fields", "missing-evidence"} {
|
||||
if _, err := service.Inject(context.Background(), Input{Fixture: name}); !errors.Is(err, ErrFixture) {
|
||||
t.Fatalf("fixture %s: got %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestFixtureUsesEventService(t *testing.T) {
|
||||
url := os.Getenv("BELL_TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("BELL_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := platform.OpenDatabase(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := migrations.Apply(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := Service{Events: event.Service{DB: db}}
|
||||
source := fmt.Sprintf("synthetic-%d", time.Now().UnixNano())
|
||||
first, err := service.Inject(ctx, Input{Fixture: "danger-zone", SourceEventID: source})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := service.Inject(ctx, Input{Fixture: "danger-zone", SourceEventID: source})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Event.ID != second.Event.ID || !second.Duplicate {
|
||||
t.Fatalf("fixture bypassed idempotency: %#v %#v", first, second)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package bell
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
alertLifecycle "git.ilapage.cn/ila/yovision/Bell/server/app/alert/lifecycle"
|
||||
alertQuery "git.ilapage.cn/ila/yovision/Bell/server/app/alert/query"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rule"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/synthetic"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/config"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
|
||||
)
|
||||
|
||||
const Version = "0.1.0"
|
||||
|
||||
func Run(ctx context.Context, args []string) error {
|
||||
command := "serve"
|
||||
if len(args) > 0 {
|
||||
command = args[0]
|
||||
}
|
||||
if command == "version" {
|
||||
fmt.Println(Version)
|
||||
return nil
|
||||
}
|
||||
if command != "serve" && command != "migrate" && command != "create-admin" {
|
||||
return fmt.Errorf("unknown command %q (use serve, migrate, create-admin, or version)", command)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := platform.OpenDatabase(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
if err := migrations.Apply(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if command == "migrate" {
|
||||
return nil
|
||||
}
|
||||
auditStore := audit.Store{DB: db}
|
||||
authStore := auth.Store{DB: db}
|
||||
authService := auth.Service{Store: authStore, Audit: auditStore, Secret: []byte(cfg.SessionSecret), SessionTTL: 8 * time.Hour}
|
||||
if command == "create-admin" {
|
||||
username, displayName := argument(args, "--username"), argument(args, "--display-name")
|
||||
password := os.Getenv("BELL_BOOTSTRAP_PASSWORD")
|
||||
if displayName == "" {
|
||||
displayName = username
|
||||
}
|
||||
if password == "" {
|
||||
return fmt.Errorf("BELL_BOOTSTRAP_PASSWORD is required for create-admin")
|
||||
}
|
||||
return authService.BootstrapAdministrator(ctx, username, displayName, password)
|
||||
}
|
||||
app := platform.NewHTTPApp(db)
|
||||
authHTTP := auth.HTTP{Service: authService, Store: authStore, Audit: auditStore, SecureCookie: cfg.CookieSecure}
|
||||
authHTTP.Register(app.Router())
|
||||
ruleService := rule.Service{DB: db}
|
||||
eventService := event.Service{DB: db, AfterPersist: ruleService.Match}
|
||||
event.HTTP{Service: eventService, Auth: authHTTP}.Register(app.Router())
|
||||
rule.HTTP{Service: ruleService, Auth: authHTTP, Audit: auditStore}.Register(app.Router())
|
||||
alertQueryService := alertQuery.Service{DB: db}
|
||||
alertQuery.HTTP{Service: alertQueryService, Auth: authHTTP}.Register(app.Router())
|
||||
alertLifecycle.HTTP{Service: alertLifecycle.Service{DB: db, Audit: auditStore, Query: alertQueryService}, Auth: authHTTP}.Register(app.Router())
|
||||
if cfg.Environment == "development" || cfg.Environment == "test" {
|
||||
synthetic.HTTP{Service: synthetic.Service{Events: eventService}, Auth: authHTTP}.Register(app.Router())
|
||||
}
|
||||
server := &http.Server{Addr: cfg.HTTPAddress, Handler: app.Handler()}
|
||||
serverCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
go func() {
|
||||
<-serverCtx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
slog.Info("Bell listening", "address", cfg.HTTPAddress, "environment", cfg.Environment)
|
||||
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func argument(args []string, name string) string {
|
||||
for i := 1; i < len(args)-1; i++ {
|
||||
if strings.EqualFold(args[i], name) {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Environment string
|
||||
HTTPAddress string
|
||||
DatabaseURL string
|
||||
SessionSecret string
|
||||
CookieSecure bool
|
||||
ShutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Environment: value("BELL_ENV", "development"),
|
||||
HTTPAddress: value("BELL_HTTP_ADDRESS", "127.0.0.1:8082"),
|
||||
DatabaseURL: os.Getenv("BELL_DATABASE_URL"),
|
||||
SessionSecret: os.Getenv("BELL_SESSION_SECRET"),
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
}
|
||||
if raw := os.Getenv("BELL_SHUTDOWN_SECONDS"); raw != "" {
|
||||
seconds, err := strconv.Atoi(raw)
|
||||
if err != nil || seconds < 1 || seconds > 300 {
|
||||
return Config{}, fmt.Errorf("BELL_SHUTDOWN_SECONDS must be between 1 and 300")
|
||||
}
|
||||
cfg.ShutdownTimeout = time.Duration(seconds) * time.Second
|
||||
}
|
||||
if cfg.DatabaseURL == "" {
|
||||
return Config{}, fmt.Errorf("BELL_DATABASE_URL is required")
|
||||
}
|
||||
if len(cfg.SessionSecret) < 32 {
|
||||
return Config{}, fmt.Errorf("BELL_SESSION_SECRET must contain at least 32 characters")
|
||||
}
|
||||
cfg.CookieSecure = cfg.Environment == "production"
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func value(name, fallback string) string {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
module git.ilapage.cn/ila/yovision/Bell/server
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
golang.org/x/crypto v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,30 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func OpenDatabase(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
poolConfig, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse Bell database URL: %w", err)
|
||||
}
|
||||
poolConfig.MaxConns = 8
|
||||
poolConfig.MinConns = 1
|
||||
poolConfig.MaxConnLifetime = 30 * time.Minute
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open Bell database: %w", err)
|
||||
}
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := pool.Ping(pingCtx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping Bell database: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type HTTPApp struct {
|
||||
DB *pgxpool.Pool
|
||||
Mux *http.ServeMux
|
||||
}
|
||||
|
||||
func NewHTTPApp(db *pgxpool.Pool) *HTTPApp {
|
||||
app := &HTTPApp{DB: db, Mux: http.NewServeMux()}
|
||||
app.Mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "service": "bell", "time": time.Now().UTC()})
|
||||
})
|
||||
app.Mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := db.Ping(r.Context()); err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
func (a *HTTPApp) Router() *http.ServeMux { return a.Mux }
|
||||
|
||||
func (a *HTTPApp) Handler() http.Handler {
|
||||
return securityHeaders(a.Mux)
|
||||
}
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSecurityHeaders(t *testing.T) {
|
||||
h := securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }))
|
||||
recorder := httptest.NewRecorder()
|
||||
h.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if recorder.Header().Get("X-Frame-Options") != "DENY" {
|
||||
t.Fatal("missing frame protection")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Bell is derived from the frozen go-admin baseline. See Bell/LICENSES/SOURCES.md.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/cmd/bell"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := bell.Run(context.Background(), os.Args[1:]); err != nil {
|
||||
slog.Error("bell stopped", "error", err)
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE IF NOT EXISTS bell_runtime_probe (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
started_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,62 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE bell_roles (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE CHECK (code IN ('administrator','operator','viewer')),
|
||||
name text NOT NULL
|
||||
);
|
||||
CREATE TABLE bell_permissions (code text PRIMARY KEY, description text NOT NULL);
|
||||
CREATE TABLE bell_role_permissions (
|
||||
role_id uuid NOT NULL REFERENCES bell_roles(id),
|
||||
permission_code text NOT NULL REFERENCES bell_permissions(code),
|
||||
PRIMARY KEY (role_id, permission_code)
|
||||
);
|
||||
CREATE TABLE bell_users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username text NOT NULL UNIQUE CHECK (username = lower(username)),
|
||||
display_name text NOT NULL,
|
||||
password_hash text NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE bell_user_roles (
|
||||
user_id uuid NOT NULL REFERENCES bell_users(id),
|
||||
role_id uuid NOT NULL REFERENCES bell_roles(id),
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
CREATE TABLE bell_sessions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES bell_users(id),
|
||||
token_digest bytea NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
revoked_at timestamptz,
|
||||
CHECK (expires_at > created_at)
|
||||
);
|
||||
CREATE INDEX bell_sessions_user_active_idx ON bell_sessions(user_id, expires_at) WHERE revoked_at IS NULL;
|
||||
CREATE TABLE bell_audit_log (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
actor_user_id uuid REFERENCES bell_users(id),
|
||||
action text NOT NULL,
|
||||
target_type text NOT NULL,
|
||||
target_id text,
|
||||
outcome text NOT NULL CHECK (outcome IN ('success','failure','denied')),
|
||||
details jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION bell_reject_audit_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN RAISE EXCEPTION 'bell audit facts are append-only'; END $$;
|
||||
CREATE TRIGGER bell_audit_no_update BEFORE UPDATE OR DELETE ON bell_audit_log FOR EACH ROW EXECUTE FUNCTION bell_reject_audit_mutation();
|
||||
|
||||
INSERT INTO bell_roles(code,name) VALUES ('administrator','管理员'),('operator','处置员'),('viewer','只读用户');
|
||||
INSERT INTO bell_permissions(code,description) VALUES
|
||||
('dashboard:read','查看工作台'),('alerts:read','查看预警'),('alerts:handle','处置预警'),
|
||||
('events:read','查看事件'),('rules:read','查看规则'),('rules:write','管理规则'),
|
||||
('users:read','查看用户'),('users:write','管理用户与角色'),('audit:read','查看审计');
|
||||
INSERT INTO bell_role_permissions(role_id,permission_code)
|
||||
SELECT r.id,p.code FROM bell_roles r CROSS JOIN bell_permissions p WHERE r.code='administrator';
|
||||
INSERT INTO bell_role_permissions(role_id,permission_code)
|
||||
SELECT r.id,p.code FROM bell_roles r JOIN bell_permissions p ON p.code IN ('dashboard:read','alerts:read','alerts:handle','events:read','rules:read') WHERE r.code='operator';
|
||||
INSERT INTO bell_role_permissions(role_id,permission_code)
|
||||
SELECT r.id,p.code FROM bell_roles r JOIN bell_permissions p ON p.code IN ('dashboard:read','alerts:read','events:read','rules:read') WHERE r.code='viewer';
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE bell_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
producer_id text NOT NULL,
|
||||
source_event_id text NOT NULL,
|
||||
event_type text NOT NULL,
|
||||
occurred_at timestamptz NOT NULL,
|
||||
location text NOT NULL,
|
||||
severity text NOT NULL CHECK (severity IN ('low','medium','high','critical')),
|
||||
evidence_ref text,
|
||||
normalized_payload jsonb NOT NULL,
|
||||
payload_sha256 bytea NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (producer_id, source_event_id)
|
||||
);
|
||||
CREATE INDEX bell_events_created_page_idx ON bell_events(created_at DESC,id DESC);
|
||||
CREATE TABLE bell_event_receipts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id uuid NOT NULL UNIQUE REFERENCES bell_events(id),
|
||||
producer_id text NOT NULL,
|
||||
source_event_id text NOT NULL,
|
||||
payload_sha256 bytea NOT NULL,
|
||||
accepted_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (producer_id, source_event_id)
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION bell_reject_event_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN RAISE EXCEPTION 'Bell Event and Receipt facts are immutable'; END $$;
|
||||
CREATE TRIGGER bell_event_no_update BEFORE UPDATE OR DELETE ON bell_events FOR EACH ROW EXECUTE FUNCTION bell_reject_event_mutation();
|
||||
CREATE TRIGGER bell_receipt_no_update BEFORE UPDATE OR DELETE ON bell_event_receipts FOR EACH ROW EXECUTE FUNCTION bell_reject_event_mutation();
|
||||
@@ -0,0 +1,3 @@
|
||||
INSERT INTO bell_permissions(code,description) VALUES ('synthetic:write','注入开发测试合成事件');
|
||||
INSERT INTO bell_role_permissions(role_id,permission_code)
|
||||
SELECT id,'synthetic:write' FROM bell_roles WHERE code='administrator';
|
||||
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE bell_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
event_type text,
|
||||
minimum_severity text NOT NULL CHECK (minimum_severity IN ('low','medium','high','critical')),
|
||||
location_contains text,
|
||||
version integer NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE bell_alerts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
primary_rule_id uuid NOT NULL REFERENCES bell_rules(id),
|
||||
correlation_key text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','acknowledged','closed')),
|
||||
severity text NOT NULL CHECK (severity IN ('low','medium','high','critical')),
|
||||
summary text NOT NULL,
|
||||
location text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX bell_alert_open_correlation_idx ON bell_alerts(primary_rule_id,correlation_key) WHERE status='open';
|
||||
CREATE INDEX bell_alerts_page_idx ON bell_alerts(created_at DESC,id DESC);
|
||||
CREATE TABLE bell_alert_events (
|
||||
alert_id uuid NOT NULL REFERENCES bell_alerts(id),
|
||||
event_id uuid NOT NULL REFERENCES bell_events(id),
|
||||
linked_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(alert_id,event_id)
|
||||
);
|
||||
CREATE INDEX bell_alert_events_event_idx ON bell_alert_events(event_id,alert_id);
|
||||
CREATE TABLE bell_rule_matches (
|
||||
alert_id uuid NOT NULL REFERENCES bell_alerts(id),
|
||||
event_id uuid NOT NULL REFERENCES bell_events(id),
|
||||
rule_id uuid NOT NULL REFERENCES bell_rules(id),
|
||||
rule_version integer NOT NULL,
|
||||
rule_snapshot jsonb NOT NULL,
|
||||
explanation text NOT NULL,
|
||||
matched_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(event_id,rule_id)
|
||||
);
|
||||
CREATE TABLE bell_rule_evaluations (
|
||||
event_id uuid NOT NULL REFERENCES bell_events(id),
|
||||
rule_id uuid NOT NULL REFERENCES bell_rules(id),
|
||||
rule_version integer NOT NULL,
|
||||
matched boolean NOT NULL,
|
||||
explanation text NOT NULL,
|
||||
evaluated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(event_id,rule_id)
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
ALTER TABLE bell_alerts
|
||||
ADD COLUMN acknowledged_by uuid REFERENCES bell_users(id),
|
||||
ADD COLUMN acknowledged_at timestamptz,
|
||||
ADD COLUMN closed_by uuid REFERENCES bell_users(id),
|
||||
ADD COLUMN closed_at timestamptz,
|
||||
ADD COLUMN close_outcome text CHECK (close_outcome IN ('danger_confirmed','false_positive','site_normal','unable_to_confirm')),
|
||||
ADD COLUMN close_note text;
|
||||
CREATE TABLE bell_alert_lifecycle_facts (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
alert_id uuid NOT NULL REFERENCES bell_alerts(id),
|
||||
transition text NOT NULL CHECK (transition IN ('acknowledged','closed')),
|
||||
actor_user_id uuid NOT NULL REFERENCES bell_users(id),
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
details jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE(alert_id,transition)
|
||||
);
|
||||
CREATE INDEX bell_alert_lifecycle_timeline_idx ON bell_alert_lifecycle_facts(alert_id,id);
|
||||
CREATE TRIGGER bell_alert_lifecycle_no_update BEFORE UPDATE OR DELETE ON bell_alert_lifecycle_facts FOR EACH ROW EXECUTE FUNCTION bell_reject_audit_mutation();
|
||||
@@ -0,0 +1,67 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed *.sql
|
||||
var files embed.FS
|
||||
|
||||
func Apply(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
conn, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire migration connection: %w", err)
|
||||
}
|
||||
defer conn.Release()
|
||||
if _, err = conn.Exec(ctx, `SELECT pg_advisory_lock(hashtext('yovision-bell-migrations'))`); err != nil {
|
||||
return fmt.Errorf("lock migrations: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.Exec(context.Background(), `SELECT pg_advisory_unlock(hashtext('yovision-bell-migrations'))`)
|
||||
}()
|
||||
if _, err := conn.Exec(ctx, `CREATE TABLE IF NOT EXISTS bell_schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
|
||||
return fmt.Errorf("create migration ledger: %w", err)
|
||||
}
|
||||
entries, err := fs.ReadDir(files, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read embedded migrations: %w", err)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || len(entry.Name()) < 4 || entry.Name()[len(entry.Name())-4:] != ".sql" {
|
||||
continue
|
||||
}
|
||||
var applied bool
|
||||
if err := conn.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bell_schema_migrations WHERE name=$1)`, entry.Name()).Scan(&applied); err != nil {
|
||||
return err
|
||||
}
|
||||
if applied {
|
||||
continue
|
||||
}
|
||||
sqlBytes, err := files.ReadFile(entry.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, string(sqlBytes)); err == nil {
|
||||
_, err = tx.Exec(ctx, `INSERT INTO bell_schema_migrations(name) VALUES($1)`, entry.Name())
|
||||
}
|
||||
if err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return fmt.Errorf("apply migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"danger-zone","label":"危险区域进入","command":{"producer_id":"bell-synthetic","source_event_id":"fixture-danger-zone","event_type":"danger-zone-entry","occurred_at":"2026-08-12T09:00:00Z","location":"北侧围墙","severity":"high","evidence_ref":"evidence://synthetic/danger-zone","attributes":{"object":"anonymous-person","zone":"north-wall"}}}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
|
||||
bellEvent "git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
)
|
||||
|
||||
//go:embed *.json
|
||||
var files embed.FS
|
||||
|
||||
type Fixture struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
ValidationError string `json:"validation_error,omitempty"`
|
||||
Command bellEvent.Command `json:"command"`
|
||||
}
|
||||
|
||||
func All() ([]Fixture, error) {
|
||||
entries, err := fs.ReadDir(files, ".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []Fixture{}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
data, err := files.ReadFile(entry.Name())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var item Fixture
|
||||
if err := json.Unmarshal(data, &item); err != nil {
|
||||
return nil, fmt.Errorf("decode fixture %s: %w", entry.Name(), err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
|
||||
return items, nil
|
||||
}
|
||||
func Find(name string) (Fixture, error) {
|
||||
items, err := All()
|
||||
if err != nil {
|
||||
return Fixture{}, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.Name == name {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return Fixture{}, fmt.Errorf("unknown synthetic fixture %q", name)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"invalid-fields","label":"字段无效(预期失败)","validation_error":"夹具用于验证字段拒绝","command":{"producer_id":"bell-synthetic","source_event_id":"fixture-invalid","event_type":"","occurred_at":"2026-08-12T09:02:00Z","location":"测试区域","severity":"unknown","attributes":{}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"line-crossing","label":"方向越线","command":{"producer_id":"bell-synthetic","source_event_id":"fixture-line-crossing","event_type":"line-crossing","occurred_at":"2026-08-12T09:01:00Z","location":"宿舍楼东门","severity":"critical","evidence_ref":"evidence://synthetic/line-crossing","attributes":{"direction":"outside-to-inside","object":"anonymous-person"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"missing-evidence","label":"证据缺失(预期失败)","validation_error":"此测试场景要求证据引用","command":{"producer_id":"bell-synthetic","source_event_id":"fixture-missing-evidence","event_type":"danger-zone-entry","occurred_at":"2026-08-12T09:03:00Z","location":"测试区域","severity":"high","attributes":{"requires_evidence":true}}}
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { node: true },
|
||||
extends: ['plugin:vue/vue3-essential', 'eslint:recommended'],
|
||||
parserOptions: { parser: '@babel/eslint-parser', requireConfigFile: false },
|
||||
rules: { 'vue/multi-word-component-names': 'off' }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = { presets: ['@vue/cli-plugin-babel/preset'] }
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "yovision-bell-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vue-cli-service serve",
|
||||
"lint": "eslint --ext .js,.vue src",
|
||||
"build:prod": "vue-cli-service build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "2.3.2",
|
||||
"axios": "1.19.0",
|
||||
"core-js": "3.50.0",
|
||||
"element-plus": "2.14.4",
|
||||
"vue": "3.5.41",
|
||||
"vue-router": "4.6.4",
|
||||
"vuex": "4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "7.29.7",
|
||||
"@vue/cli-plugin-babel": "5.0.9",
|
||||
"@vue/cli-plugin-eslint": "5.0.9",
|
||||
"@vue/cli-service": "5.0.9",
|
||||
"@vue/compiler-sfc": "3.5.41",
|
||||
"eslint": "7.32.0",
|
||||
"eslint-plugin-vue": "9.33.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.1",
|
||||
"engines": { "node": "22.22.1", "pnpm": "9.15.1" }
|
||||
}
|
||||
+379
-7172
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Bell 预警中心</title></head>
|
||||
<body><noscript>Bell 需要启用 JavaScript。</noscript><div id="app"></div></body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
import request from '../../bootstrap/request'
|
||||
export const listAlerts = params => request.get('/api/v1/alerts', { params })
|
||||
export const getAlert = id => request.get(`/api/v1/alerts/${id}`)
|
||||
export const alertsForEvent = id => request.get(`/api/v1/events/${id}/alerts`)
|
||||
export const acknowledgeAlert = id => request.post(`/api/v1/alerts/${id}/ack`)
|
||||
export const closeAlert = (id, data) => request.post(`/api/v1/alerts/${id}/close`, data)
|
||||
@@ -0,0 +1,9 @@
|
||||
import request from '../../bootstrap/request'
|
||||
|
||||
export const login = data => request.post('/api/v1/auth/login', data)
|
||||
export const logout = () => request.post('/api/v1/auth/logout')
|
||||
export const currentUser = () => request.get('/api/v1/auth/me')
|
||||
export const listUsers = () => request.get('/api/v1/users')
|
||||
export const createUser = data => request.post('/api/v1/users', data)
|
||||
export const replaceRoles = (id, roles) => request.put(`/api/v1/users/${id}/roles`, { roles })
|
||||
export const listAudit = () => request.get('/api/v1/audit?limit=100')
|
||||
@@ -0,0 +1,3 @@
|
||||
import request from '../../bootstrap/request'
|
||||
export const listEvents = params => request.get('/api/v1/events', { params })
|
||||
export const getEvent = id => request.get(`/api/v1/events/${id}`)
|
||||
@@ -0,0 +1,4 @@
|
||||
import request from '../../bootstrap/request'
|
||||
export const listRules = () => request.get('/api/v1/rules')
|
||||
export const createRule = data => request.post('/api/v1/rules', data)
|
||||
export const setRuleEnabled = (id, enabled) => request.put(`/api/v1/rules/${id}/enabled`, { enabled })
|
||||
@@ -0,0 +1,3 @@
|
||||
import request from '../../bootstrap/request'
|
||||
export const listFixtures = () => request.get('/api/v1/synthetic/fixtures')
|
||||
export const injectSynthetic = data => request.post('/api/v1/synthetic/events', data)
|
||||
@@ -0,0 +1,6 @@
|
||||
<template><router-view v-if="route.meta.public" /><AppLayout v-else><router-view /></AppLayout></template>
|
||||
<script setup>
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppLayout from '../layout/AppLayout.vue'
|
||||
const route = useRoute()
|
||||
</script>
|
||||
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<main class="page" tabindex="-1">
|
||||
<header class="page__header"><div><p class="eyebrow">BELL</p><h1>预警工作台</h1><p>Bell 已独立运行,业务模块将按工单顺序启用。</p></div><el-tag type="success">服务就绪</el-tag></header>
|
||||
<el-row :gutter="16">
|
||||
<el-col :xs="24" :sm="12" :lg="8"><el-card><template #header>待处理预警</template><strong class="metric">0</strong><p>规则模块启用后显示。</p></el-card></el-col>
|
||||
<el-col :xs="24" :sm="12" :lg="8"><el-card><template #header>今日事件</template><strong class="metric">0</strong><p>事件入站启用后显示。</p></el-card></el-col>
|
||||
<el-col :xs="24" :sm="12" :lg="8"><el-card><template #header>运行状态</template><strong class="metric metric--small">独立</strong><p>无需 Sense 或 Brain 在线。</p></el-card></el-col>
|
||||
</el-row>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import store from './store'
|
||||
import './theme.css'
|
||||
|
||||
createApp(App).use(store).use(router).use(ElementPlus).mount('#app')
|
||||
@@ -0,0 +1,5 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const request = axios.create({ timeout: 10000, withCredentials: true, headers: { 'Content-Type': 'application/json' } })
|
||||
request.interceptors.response.use(response => response.data, error => Promise.reject(error.response?.data || { error: '无法连接 Bell 服务' }))
|
||||
export default request
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Dashboard from './Dashboard.vue'
|
||||
import Login from '../views/login/Login.vue'
|
||||
import Users from '../views/system/Users.vue'
|
||||
import Audit from '../views/system/Audit.vue'
|
||||
import Events from '../views/event/Events.vue'
|
||||
import SyntheticEvent from '../views/synthetic-event/SyntheticEvent.vue'
|
||||
import Rules from '../views/rule/Rules.vue'
|
||||
import Alerts from '../views/alert/list/Alerts.vue'
|
||||
import { listFixtures } from '../api/synthetic'
|
||||
import store from './store'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/login', name: 'login', component: Login, meta: { title: '登录', public: true } },
|
||||
{ path: '/', name: 'dashboard', component: Dashboard, meta: { title: '工作台', permission: 'dashboard:read' } },
|
||||
{ path: '/events', name: 'events', component: Events, meta: { title: '事件查询', permission: 'events:read' } },
|
||||
{ path: '/alerts', name: 'alerts', component: Alerts, meta: { title: '预警管理', permission: 'alerts:read' } },
|
||||
{ path: '/rules', name: 'rules', component: Rules, meta: { title: '规则配置', permission: 'rules:read' } },
|
||||
{ path: '/development/synthetic-events', name: 'synthetic-events', component: SyntheticEvent, meta: { title: '合成事件测试', permission: 'synthetic:write', developmentOnly: true } },
|
||||
{ path: '/system/users', name: 'users', component: Users, meta: { title: '用户与角色', permission: 'users:read' } },
|
||||
{ path: '/system/audit', name: 'audit', component: Audit, meta: { title: '认证审计', permission: 'audit:read' } }
|
||||
]
|
||||
})
|
||||
|
||||
router.beforeEach(async to => {
|
||||
if (!store.state.identity.checked) await store.dispatch('identity/restore')
|
||||
const user = store.state.identity.user
|
||||
if (to.meta.public) return user && to.name === 'login' ? { name: 'dashboard' } : true
|
||||
if (!user) return { name: 'login', query: { redirect: to.fullPath } }
|
||||
if (to.meta.permission && !user.permissions?.includes(to.meta.permission)) return { name: 'dashboard' }
|
||||
if (to.meta.developmentOnly) { try { await listFixtures() } catch (_) { return { name: 'dashboard' } } }
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createStore } from 'vuex'
|
||||
import identity from '../store/modules/identity'
|
||||
|
||||
export default createStore({ state: () => ({ product: 'Bell' }), modules: { identity } })
|
||||
@@ -0,0 +1,26 @@
|
||||
:root { --bell-accent:#409eff; --bell-bg:#f3f4f7; --bell-surface:#fff; --bell-text:#1f2329; --bell-muted:#6b7280; --bell-stroke:#e5e7eb; }
|
||||
* { box-sizing:border-box; }
|
||||
html, body, #app { min-height:100%; margin:0; }
|
||||
body { font-family:"Segoe UI Variable","Microsoft YaHei",sans-serif; color:var(--bell-text); background:var(--bell-bg); }
|
||||
.shell { min-height:100vh; display:grid; grid-template-columns:220px minmax(0,1fr); }
|
||||
.sidebar { background:#263445; color:#fff; padding:16px 12px; }
|
||||
.brand { display:flex; gap:12px; align-items:center; min-height:48px; padding:0 8px 16px; }
|
||||
.brand__mark { display:grid; place-items:center; width:36px; height:36px; border-radius:8px; background:var(--bell-accent); font-weight:700; }
|
||||
.brand small { display:block; color:#cbd5e1; margin-top:2px; }
|
||||
.nav-item { display:flex; min-height:44px; align-items:center; padding:0 12px; color:#cbd5e1; text-decoration:none; border-radius:6px; }
|
||||
.nav-item.router-link-active { color:#fff; background:#1f2d3d; }
|
||||
.workspace { min-width:0; }
|
||||
.navbar { height:50px; display:flex; align-items:center; justify-content:space-between; padding:0 20px; background:var(--bell-surface); border-bottom:1px solid var(--bell-stroke); }
|
||||
.navbar__identity { color:var(--bell-muted); display:flex; align-items:center; gap:12px; }
|
||||
.tags-view { height:34px; display:flex; align-items:end; padding:0 16px; background:#fff; border-bottom:1px solid var(--bell-stroke); }
|
||||
.tag-current { padding:6px 12px; border:1px solid var(--bell-stroke); border-bottom:2px solid var(--bell-accent); }
|
||||
.app-main { padding:20px; }
|
||||
.page { max-width:1440px; margin:0 auto; outline:none; }
|
||||
.page__header { display:flex; justify-content:space-between; gap:16px; align-items:flex-start; margin-bottom:20px; }
|
||||
.page__header h1 { margin:0 0 6px; font-size:24px; }
|
||||
.page__header p { margin:0; color:var(--bell-muted); }
|
||||
.eyebrow { color:var(--bell-accent)!important; font-size:12px; font-weight:700; letter-spacing:.12em; }
|
||||
.metric { font-size:36px; font-variant-numeric:tabular-nums; }
|
||||
.metric--small { font-size:24px; }
|
||||
@media (max-width:640px) { .shell { grid-template-columns:1fr; } .sidebar { position:static; padding:8px 12px; } .brand { padding-bottom:8px; } .sidebar nav { display:flex; } .workspace { min-width:0; } .app-main { padding:12px; } .page__header { flex-direction:column; } }
|
||||
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior:auto!important; transition-duration:.01ms!important; animation-duration:.01ms!important; } }
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="shell">
|
||||
<aside class="sidebar" aria-label="主导航">
|
||||
<div class="brand"><span class="brand__mark">B</span><div><strong>Bell</strong><small>预警中心</small></div></div>
|
||||
<nav>
|
||||
<router-link to="/" class="nav-item">工作台</router-link>
|
||||
<router-link v-if="has('alerts:read')" to="/alerts" class="nav-item">预警管理</router-link>
|
||||
<router-link v-if="has('events:read')" to="/events" class="nav-item">事件查询</router-link>
|
||||
<router-link v-if="has('rules:read')" to="/rules" class="nav-item">规则配置</router-link>
|
||||
<router-link v-if="has('users:read')" to="/system/users" class="nav-item">用户与角色</router-link>
|
||||
<router-link v-if="has('audit:read')" to="/system/audit" class="nav-item">认证审计</router-link>
|
||||
<router-link v-if="syntheticAvailable" to="/development/synthetic-events" class="nav-item">合成事件测试</router-link>
|
||||
</nav>
|
||||
</aside>
|
||||
<section class="workspace">
|
||||
<header class="navbar"><span>学校安全预警</span><div class="navbar__identity"><span>{{ user?.display_name }}</span><el-button link @click="signOut">退出</el-button></div></header>
|
||||
<div class="tags-view"><span class="tag-current">{{ route.meta.title }}</span></div>
|
||||
<div class="app-main"><slot /></div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed,onMounted,ref } from 'vue'
|
||||
import { useRoute,useRouter } from 'vue-router'
|
||||
import { useStore } from 'vuex'
|
||||
import { listFixtures } from '../api/synthetic'
|
||||
const store=useStore();const route=useRoute();const router=useRouter();const user=computed(()=>store.state.identity.user);const syntheticAvailable=ref(false);const has=permission=>store.getters['identity/has'](permission);onMounted(async()=>{if(has('synthetic:write')){try{await listFixtures();syntheticAvailable.value=true}catch(_){syntheticAvailable.value=false}}});async function signOut(){await store.dispatch('identity/logout');await router.replace('/login')}
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
import './bootstrap/main'
|
||||
@@ -0,0 +1,13 @@
|
||||
import { currentUser, login, logout } from '../../api/auth'
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state: () => ({ user: null, checked: false }),
|
||||
getters: { has: state => permission => Boolean(state.user?.permissions?.includes(permission)) },
|
||||
mutations: { setUser (state, user) { state.user = user; state.checked = true } },
|
||||
actions: {
|
||||
async restore ({ commit }) { try { commit('setUser', await currentUser()) } catch (_) { commit('setUser', null) } },
|
||||
async login ({ commit }, form) { const result = await login(form); commit('setUser', result.user); return result.user },
|
||||
async logout ({ commit }) { try { await logout() } finally { commit('setUser', null) } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<main class="page" tabindex="-1">
|
||||
<header class="page__header"><div><h1>预警管理</h1><p>先确认已看到并开始处理,再记录现场结果完成预警。</p></div></header>
|
||||
<el-table :data="items" v-loading="loading" row-key="id" @row-dblclick="open">
|
||||
<el-table-column prop="created_at" label="创建时间" min-width="180"/><el-table-column prop="summary" label="预警事项" min-width="190"/><el-table-column prop="location" label="地点" min-width="130"/>
|
||||
<el-table-column label="状态"><template #default="scope"><el-tag :type="statusType(scope.row.status)">{{statusName(scope.row.status)}}</el-tag></template></el-table-column>
|
||||
<el-table-column label="处理人" min-width="120"><template #default="scope">{{scope.row.acknowledged_by_name||'待确认'}}</template></el-table-column><el-table-column prop="event_count" label="关联事件" width="100"/>
|
||||
<el-table-column label="操作" width="100"><template #default="scope"><el-button link type="primary" @click.stop="open(scope.row)">详情</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading&&!items.length" description="尚无预警"/><div class="pagination-actions"><el-button :disabled="!next" @click="load(next)">下一页</el-button></div>
|
||||
<el-drawer v-model="drawer" title="预警详情" size="min(640px, 100%)">
|
||||
<template v-if="detail">
|
||||
<el-alert v-if="actionError" :title="actionError" type="warning" show-icon :closable="false"/>
|
||||
<el-descriptions :column="1" border><el-descriptions-item label="事项">{{detail.alert.summary}}</el-descriptions-item><el-descriptions-item label="地点">{{detail.alert.location}}</el-descriptions-item><el-descriptions-item label="规则">{{detail.alert.rule_name}}</el-descriptions-item><el-descriptions-item label="状态">{{statusName(detail.alert.status)}}</el-descriptions-item><el-descriptions-item label="处理人">{{detail.alert.acknowledged_by_name||'尚未开始处理'}}</el-descriptions-item><el-descriptions-item v-if="detail.alert.close_outcome" label="现场结果">{{outcomeName(detail.alert.close_outcome)}}</el-descriptions-item><el-descriptions-item v-if="detail.alert.close_note" label="处理说明">{{detail.alert.close_note}}</el-descriptions-item></el-descriptions>
|
||||
<div class="action-bar" v-if="canHandle"><el-button v-if="detail.alert.status==='open'" type="primary" :loading="acting" @click="ack">我已看到并开始处理</el-button><el-button v-if="canClose" type="primary" :loading="acting" @click="closeDialog=true">记录现场结果并完成</el-button></div>
|
||||
<h2>关联事件</h2><el-table :data="detail.events" row-key="id"><el-table-column prop="occurred_at" label="发生时间"/><el-table-column prop="event_type" label="类型"/><el-table-column label="操作" width="80"><template #default="scope"><el-button link @click="router.push({name:'events',query:{event:scope.row.id}})">查看</el-button></template></el-table-column></el-table>
|
||||
<h2>处理时间线</h2><el-timeline><el-timeline-item v-for="entry in detail.timeline" :key="entry.id" :timestamp="entry.occurred_at">{{entry.transition==='acknowledged'?'开始处理':'处理完成'}} · {{entry.actor_name}}</el-timeline-item><el-timeline-item v-for="match in detail.matches" :key="`${match.event_id}-${match.rule_id}`" :timestamp="match.matched_at" type="primary">规则 v{{match.rule_version}}:{{match.explanation}}</el-timeline-item></el-timeline>
|
||||
</template>
|
||||
</el-drawer>
|
||||
<el-dialog v-model="closeDialog" title="记录现场结果" width="min(520px, calc(100vw - 32px))" @closed="resetClose"><el-alert title="完成后预警进入已完成状态,原始事件不会被修改。" type="info" :closable="false"/><el-form ref="closeFormRef" :model="closeForm" :rules="closeRules" label-position="top"><el-form-item label="现场结果" prop="outcome"><el-radio-group v-model="closeForm.outcome" class="outcome-group"><el-radio value="danger_confirmed">确认有危险</el-radio><el-radio value="false_positive">误报</el-radio><el-radio value="site_normal">现场正常</el-radio><el-radio value="unable_to_confirm">无法确认</el-radio></el-radio-group></el-form-item><el-form-item label="补充说明(可选)"><el-input v-model="closeForm.note" type="textarea" :rows="3" maxlength="500" show-word-limit/></el-form-item></el-form><template #footer><el-button @click="closeDialog=false">取消</el-button><el-button type="primary" :loading="acting" @click="finish">确认结果并完成</el-button></template></el-dialog>
|
||||
</main>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed,onMounted,reactive,ref } from 'vue';import { useRouter } from 'vue-router';import { useStore } from 'vuex';import { ElMessage } from 'element-plus';import { acknowledgeAlert,closeAlert,getAlert,listAlerts } from '../../../api/alert';const router=useRouter();const store=useStore();const items=ref([]);const loading=ref(false);const acting=ref(false);const next=ref('');const drawer=ref(false);const detail=ref(null);const actionError=ref('');const closeDialog=ref(false);const closeFormRef=ref();const closeForm=reactive({outcome:'',note:''});const closeRules={outcome:[{required:true,message:'请选择现场结果',trigger:'change'}]};const canHandle=computed(()=>store.getters['identity/has']('alerts:handle'));const currentUser=computed(()=>store.state.identity.user);const canClose=computed(()=>detail.value?.alert.status==='acknowledged'&&(detail.value.alert.acknowledged_by===currentUser.value?.id||currentUser.value?.roles?.includes('administrator')));const statusName=value=>({open:'待处理',acknowledged:'处理中',closed:'已完成'}[value]||value);const statusType=value=>({open:'danger',acknowledged:'warning',closed:'success'}[value]||'info');const outcomeName=value=>({danger_confirmed:'确认有危险',false_positive:'误报',site_normal:'现场正常',unable_to_confirm:'无法确认'}[value]||value);async function load(before=''){loading.value=true;try{const result=await listAlerts({limit:25,before});items.value=result.items;next.value=result.items.length===25?result.next:''}catch(e){ElMessage.error(e.error||'读取预警失败')}finally{loading.value=false}}async function open(row){try{detail.value=await getAlert(row.id);actionError.value='';drawer.value=true}catch(e){ElMessage.error(e.error||'读取预警失败')}}async function ack(){acting.value=true;actionError.value='';try{const result=await acknowledgeAlert(detail.value.alert.id);detail.value=result.detail;ElMessage.success(result.idempotent?'您已在处理此预警':'已记录由您开始处理');await load()}catch(e){if(e.current)detail.value=e.current;actionError.value=e.error||'确认失败'}finally{acting.value=false}}async function finish(){try{await closeFormRef.value.validate();acting.value=true;actionError.value='';const result=await closeAlert(detail.value.alert.id,closeForm);detail.value=result.detail;closeDialog.value=false;ElMessage.success(result.idempotent?'该结果已记录':'预警已完成');await load()}catch(e){if(e?.current)detail.value=e.current;if(e?.error)actionError.value=e.error}finally{acting.value=false}}function resetClose(){closeForm.outcome='';closeForm.note='';closeFormRef.value?.clearValidate()}onMounted(()=>load())
|
||||
</script>
|
||||
<style scoped>.pagination-actions{display:flex;justify-content:flex-end;margin-top:16px}.action-bar{display:flex;gap:12px;margin-top:16px}h2{font-size:16px;margin-top:20px}.outcome-group{display:grid;gap:10px}</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>事件查询</h1><p>原始事件只读保存;处理结果不会改写事件。</p></div></header><el-table :data="items" v-loading="loading" row-key="id" @row-dblclick="open"><el-table-column prop="occurred_at" label="发生时间" min-width="180"/><el-table-column prop="event_type" label="事件类型" min-width="140"/><el-table-column prop="location" label="地点" min-width="150"/><el-table-column label="风险"><template #default="scope"><el-tag :type="severityType(scope.row.severity)">{{severityName(scope.row.severity)}}</el-tag></template></el-table-column><el-table-column label="操作" width="100"><template #default="scope"><el-button link type="primary" @click.stop="open(scope.row)">详情</el-button></template></el-table-column></el-table><el-empty v-if="!loading&&!items.length" description="尚无事件"/><div class="pagination-actions"><el-button :disabled="!next" @click="load(next)">下一页</el-button></div><el-drawer v-model="drawer" title="事件技术详情" size="min(560px, 100%)"><template v-if="selected"><el-descriptions :column="1" border><el-descriptions-item label="事件编号">{{selected.id}}</el-descriptions-item><el-descriptions-item label="来源">{{selected.producer_id}}</el-descriptions-item><el-descriptions-item label="来源事件编号">{{selected.source_event_id}}</el-descriptions-item><el-descriptions-item label="发生时间">{{selected.occurred_at}}</el-descriptions-item><el-descriptions-item label="证据引用">{{selected.evidence_ref||'无'}}</el-descriptions-item><el-descriptions-item label="规范化载荷"><pre>{{JSON.stringify(selected.normalized_payload,null,2)}}</pre></el-descriptions-item></el-descriptions><h2>关联预警</h2><el-table :data="selectedAlerts" row-key="id"><el-table-column prop="summary" label="事项"/><el-table-column label="操作" width="80"><template #default><el-button link @click="router.push('/alerts')">查看</el-button></template></el-table-column></el-table><el-empty v-if="!selectedAlerts.length" description="该事件未命中规则"/></template></el-drawer></main></template>
|
||||
<script setup>
|
||||
import { onMounted,ref } from 'vue';import { useRouter } from 'vue-router';import { ElMessage } from 'element-plus';import { alertsForEvent } from '../../api/alert';import { getEvent,listEvents } from '../../api/event';const router=useRouter();const items=ref([]);const loading=ref(false);const next=ref('');const drawer=ref(false);const selected=ref(null);const selectedAlerts=ref([]);const severityType=value=>({critical:'danger',high:'warning',medium:'primary',low:'info'}[value]||'info');const severityName=value=>({critical:'紧急',high:'高',medium:'中',low:'低'}[value]||value);async function load(before=''){loading.value=true;try{const result=await listEvents({limit:25,before});items.value=result.items;next.value=result.items.length===25?result.next:''}catch(e){ElMessage.error(e.error||'读取事件失败')}finally{loading.value=false}}async function open(row){try{selected.value=await getEvent(row.id);selectedAlerts.value=(await alertsForEvent(row.id)).items;drawer.value=true}catch(e){ElMessage.error(e.error||'读取事件失败')}}onMounted(()=>load())
|
||||
</script>
|
||||
<style scoped>.pagination-actions{display:flex;justify-content:flex-end;margin-top:16px}pre{white-space:pre-wrap;word-break:break-word;margin:0}h2{font-size:16px;margin-top:20px}</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<el-card class="login-card">
|
||||
<template #header><div><p class="eyebrow">BELL</p><h1>登录预警中心</h1><p>使用 Bell 独立账户。Sense 账户不能登录此系统。</p></div></template>
|
||||
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" />
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="submit">
|
||||
<el-form-item label="用户名" prop="username"><el-input v-model.trim="form.username" autocomplete="username" autofocus /></el-form-item>
|
||||
<el-form-item label="密码" prop="password"><el-input v-model="form.password" type="password" show-password autocomplete="current-password" @keyup.enter="submit" /></el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="loading" class="full-button">登录 Bell</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</main>
|
||||
</template>
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useStore } from 'vuex'
|
||||
const store=useStore();const route=useRoute();const router=useRouter();const formRef=ref();const loading=ref(false);const error=ref('');const form=reactive({username:'',password:''});const rules={username:[{required:true,message:'请输入用户名',trigger:'blur'}],password:[{required:true,message:'请输入密码',trigger:'blur'}]}
|
||||
async function submit(){if(loading.value)return;try{await formRef.value.validate();loading.value=true;error.value='';await store.dispatch('identity/login',form);await router.replace(String(route.query.redirect||'/'))}catch(e){if(e?.error)error.value=e.error}finally{loading.value=false}}
|
||||
</script>
|
||||
<style scoped>.login-page{min-height:100vh;display:grid;place-items:center;padding:20px;background:#f3f4f7}.login-card{width:min(420px,100%)}h1{margin:0 0 8px}.full-button{width:100%;min-height:44px}</style>
|
||||
@@ -0,0 +1,4 @@
|
||||
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>规则配置</h1><p>启用规则会评估新 Event;版本与实际匹配快照永久保留。</p></div><el-button v-if="canWrite" type="primary" @click="dialog=true">新建规则</el-button></header><el-table :data="items" v-loading="loading" row-key="id"><el-table-column prop="name" label="规则名称" min-width="160"/><el-table-column prop="event_type" label="事件类型"><template #default="scope">{{scope.row.event_type||'全部'}}</template></el-table-column><el-table-column prop="minimum_severity" label="最低风险"/><el-table-column prop="location_contains" label="地点包含"><template #default="scope">{{scope.row.location_contains||'不限'}}</template></el-table-column><el-table-column prop="version" label="版本" width="80"/><el-table-column label="启用" width="100"><template #default="scope"><el-switch :model-value="scope.row.enabled" :disabled="!canWrite" :aria-label="`${scope.row.name}启用状态`" @change="value=>toggle(scope.row,value)"/></template></el-table-column></el-table><el-empty v-if="!loading&&!items.length" description="尚无规则"/><el-dialog v-model="dialog" title="新建预警规则" width="min(520px, calc(100vw - 32px))" @closed="reset"><el-form ref="formRef" :model="form" :rules="rules" label-position="top"><el-form-item label="规则编码" prop="code"><el-input v-model.trim="form.code" placeholder="如 north-wall-danger"/></el-form-item><el-form-item label="规则名称" prop="name"><el-input v-model.trim="form.name"/></el-form-item><el-form-item label="事件类型(留空表示全部)"><el-input v-model.trim="form.event_type"/></el-form-item><el-form-item label="最低风险" prop="minimum_severity"><el-select v-model="form.minimum_severity" style="width:100%"><el-option label="低" value="low"/><el-option label="中" value="medium"/><el-option label="高" value="high"/><el-option label="紧急" value="critical"/></el-select></el-form-item><el-form-item label="地点包含(可选)"><el-input v-model.trim="form.location_contains"/></el-form-item></el-form><template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">创建规则</el-button></template></el-dialog></main></template>
|
||||
<script setup>
|
||||
import { computed,onMounted,reactive,ref } from 'vue';import { ElMessage } from 'element-plus';import { useStore } from 'vuex';import { createRule,listRules,setRuleEnabled } from '../../api/rule';const store=useStore();const canWrite=computed(()=>store.getters['identity/has']('rules:write'));const items=ref([]);const loading=ref(false);const saving=ref(false);const dialog=ref(false);const formRef=ref();const form=reactive({code:'',name:'',event_type:'',minimum_severity:'high',location_contains:''});const rules={code:[{required:true,message:'请输入规则编码',trigger:'blur'}],name:[{required:true,message:'请输入规则名称',trigger:'blur'}],minimum_severity:[{required:true,message:'请选择最低风险',trigger:'change'}]};async function load(){loading.value=true;try{items.value=(await listRules()).items}catch(e){ElMessage.error(e.error||'读取规则失败')}finally{loading.value=false}}async function save(){try{await formRef.value.validate();saving.value=true;const payload={...form,event_type:form.event_type||null,location_contains:form.location_contains||null};await createRule(payload);dialog.value=false;ElMessage.success('规则已创建');await load()}catch(e){if(e?.error)ElMessage.error(e.error)}finally{saving.value=false}}async function toggle(item,value){try{await setRuleEnabled(item.id,value);ElMessage.success(value?'规则已启用':'规则已停用');await load()}catch(e){ElMessage.error(e.error||'更新失败')}}function reset(){Object.assign(form,{code:'',name:'',event_type:'',minimum_severity:'high',location_contains:''});formRef.value?.clearValidate()}onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,4 @@
|
||||
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>合成事件测试</h1><p>仅开发测试环境可用;所有成功数据仍经过正式 Event/Receipt 服务。</p></div><el-tag type="warning">开发测试</el-tag></header><el-card><el-alert v-if="error" :title="error" type="error" show-icon :closable="false"/><el-form ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="submit"><el-form-item label="测试场景" prop="fixture"><el-select v-model="form.fixture" style="width:100%"><el-option v-for="item in fixtures" :key="item.name" :label="item.label" :value="item.name"><span>{{item.label}}</span><el-tag v-if="item.expected_failure" type="info" size="small" style="margin-left:8px">预期失败</el-tag></el-option></el-select></el-form-item><el-form-item label="来源事件编号" prop="source_event_id"><el-input v-model.trim="form.source_event_id"/><small>保持编号不变可验证重复;换用另一夹具可验证冲突。</small></el-form-item><el-button type="primary" native-type="submit" :loading="loading">注入合成事件</el-button></el-form></el-card><el-card v-if="result" style="margin-top:16px"><template #header>接收结果</template><el-descriptions :column="1" border><el-descriptions-item label="结果">{{result.duplicate?'重复请求,返回原收据':'首次接收'}}</el-descriptions-item><el-descriptions-item label="Event ID">{{result.event.id}}</el-descriptions-item><el-descriptions-item label="Receipt ID">{{result.receipt.id}}</el-descriptions-item></el-descriptions></el-card></main></template>
|
||||
<script setup>
|
||||
import { onMounted,reactive,ref } from 'vue';import { ElMessage } from 'element-plus';import { injectSynthetic,listFixtures } from '../../api/synthetic';const fixtures=ref([]);const formRef=ref();const loading=ref(false);const error=ref('');const result=ref(null);const form=reactive({fixture:'danger-zone',source_event_id:`ui-${Date.now()}`});const rules={fixture:[{required:true,message:'请选择场景',trigger:'change'}],source_event_id:[{required:true,message:'请输入来源事件编号',trigger:'blur'}]};onMounted(async()=>{try{fixtures.value=(await listFixtures()).items}catch(e){error.value=e.error||'测试入口不可用'}});async function submit(){if(loading.value)return;try{await formRef.value.validate();loading.value=true;error.value='';result.value=await injectSynthetic(form);ElMessage.success(result.value.duplicate?'已返回原收据':'合成事件已接收')}catch(e){if(e?.error)error.value=e.error}finally{loading.value=false}}
|
||||
</script>
|
||||
@@ -0,0 +1,4 @@
|
||||
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>认证审计</h1><p>登录、退出和权限拒绝事实仅追加保存。</p></div></header><el-table :data="items" v-loading="loading" row-key="id"><el-table-column prop="occurred_at" label="时间" min-width="180"/><el-table-column prop="action" label="动作" min-width="150"/><el-table-column prop="outcome" label="结果"><template #default="scope"><el-tag :type="scope.row.outcome==='success'?'success':'danger'">{{scope.row.outcome}}</el-tag></template></el-table-column><el-table-column prop="target_type" label="对象"/></el-table></main></template>
|
||||
<script setup>
|
||||
import { onMounted,ref } from 'vue';import { ElMessage } from 'element-plus';import { listAudit } from '../../api/auth';const items=ref([]);const loading=ref(false);onMounted(async()=>{loading.value=true;try{items.value=(await listAudit()).items}catch(e){ElMessage.error(e.error||'读取审计失败')}finally{loading.value=false}})
|
||||
</script>
|
||||
@@ -0,0 +1,4 @@
|
||||
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>用户与角色</h1><p>Bell 账户与 Sense 完全独立;按最小权限分配角色。</p></div><el-button v-if="canWrite" type="primary" @click="dialog=true">创建用户</el-button></header><el-table :data="items" v-loading="loading" row-key="id"><el-table-column prop="username" label="用户名"/><el-table-column prop="display_name" label="显示名称"/><el-table-column label="角色"><template #default="scope"><el-select v-if="canWrite" :model-value="scope.row.roles[0]" aria-label="角色" @change="role=>changeRole(scope.row,role)"><el-option v-for="option in roleOptions" :key="option.value" v-bind="option"/></el-select><span v-else>{{roleName(scope.row.roles[0])}}</span></template></el-table-column><el-table-column label="状态"><template #default="scope"><el-tag :type="scope.row.enabled?'success':'info'">{{scope.row.enabled?'启用':'停用'}}</el-tag></template></el-table-column></el-table><el-empty v-if="!loading&&!items.length" description="尚未创建用户"/><el-dialog v-model="dialog" title="创建 Bell 用户" width="min(480px, calc(100vw - 32px))" @closed="reset"><el-alert title="不会生成默认密码;密码必须由管理员安全传递。" type="info" :closable="false"/><el-form ref="formRef" :model="form" :rules="rules" label-position="top"><el-form-item label="用户名" prop="username"><el-input v-model.trim="form.username"/></el-form-item><el-form-item label="显示名称" prop="display_name"><el-input v-model.trim="form.display_name"/></el-form-item><el-form-item label="初始密码" prop="password"><el-input v-model="form.password" type="password" show-password autocomplete="new-password"/></el-form-item><el-form-item label="角色" prop="role"><el-select v-model="form.role" style="width:100%"><el-option v-for="option in roleOptions" :key="option.value" v-bind="option"/></el-select></el-form-item></el-form><template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">创建用户</el-button></template></el-dialog></main></template>
|
||||
<script setup>
|
||||
import { computed,onMounted,reactive,ref } from 'vue';import { ElMessage } from 'element-plus';import { useStore } from 'vuex';import { createUser,listUsers,replaceRoles } from '../../api/auth';const store=useStore();const items=ref([]);const loading=ref(false);const dialog=ref(false);const saving=ref(false);const formRef=ref();const form=reactive({username:'',display_name:'',password:'',role:'operator'});const canWrite=computed(()=>store.getters['identity/has']('users:write'));const roleOptions=[{value:'administrator',label:'管理员'},{value:'operator',label:'处置员'},{value:'viewer',label:'只读用户'}];const rules={username:[{required:true,message:'请输入用户名',trigger:'blur'}],display_name:[{required:true,message:'请输入显示名称',trigger:'blur'}],password:[{required:true,message:'请输入初始密码',trigger:'blur'},{min:12,message:'至少 12 个字符',trigger:'blur'}],role:[{required:true,message:'请选择角色',trigger:'change'}]};const roleName=value=>roleOptions.find(x=>x.value===value)?.label||value;async function load(){loading.value=true;try{items.value=(await listUsers()).items}catch(e){ElMessage.error(e.error||'读取用户失败')}finally{loading.value=false}}async function save(){try{await formRef.value.validate();saving.value=true;await createUser(form);ElMessage.success('用户已创建');dialog.value=false;await load()}catch(e){if(e?.error)ElMessage.error(e.error)}finally{saving.value=false}}async function changeRole(user,role){try{await replaceRoles(user.id,[role]);ElMessage.success('角色已更新');await load()}catch(e){ElMessage.error(e.error||'角色更新失败')}}function reset(){form.username='';form.display_name='';form.password='';form.role='operator';formRef.value?.clearValidate()}onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,10 @@
|
||||
const { defineConfig } = require('@vue/cli-service')
|
||||
|
||||
module.exports = defineConfig({
|
||||
transpileDependencies: true,
|
||||
outputDir: 'dist',
|
||||
devServer: {
|
||||
port: 8083,
|
||||
proxy: { '/api': { target: process.env.BELL_API_ORIGIN || 'http://127.0.0.1:8082' } }
|
||||
}
|
||||
})
|
||||
@@ -8,66 +8,4 @@ YoVision 是一个单仓多项目的智能视频事件平台,包含三个可
|
||||
|
||||
Sense 与 Bell 是账户、数据、部署和发布边界完全独立的两个销售产品;Brain 是无界面的独立推理交付单元,默认随 Sense 部署。跨项目协作只通过 `contracts/` 中的版本化契约。
|
||||
|
||||
开始工作前阅读 [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`。
|
||||
|
||||
## 分支模型
|
||||
|
||||
- `explore`:2026-08-14 前实现的只读聚合快照,用于迁移对照和回退。
|
||||
- `main`:用户审核通过的基线,禁止直接开发。
|
||||
- `dev`:集成开发与测试分支;任务分支从 `dev` 创建并通过 PR 返回 `dev`。
|
||||
- 只有用户明确审核通过,才能将 `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 客户端与镜像生成库
|
||||
```
|
||||
开始工作前阅读 [AGENTS.md](AGENTS.md) 和 [文档中心](docs/README.md)。本仓库采用 DevHarness:Gitea 工单记录任务过程,Gitea Wiki 保存长期文档,`docs/` 是 Wiki 的只读镜像。
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
server/temp/
|
||||
server/*.exe
|
||||
server/*.db
|
||||
ui/node_modules/
|
||||
ui/dist/
|
||||
dist/
|
||||
!scripts/build/
|
||||
!scripts/build/**
|
||||
.env
|
||||
*.local.yml
|
||||
@@ -26,9 +26,6 @@
|
||||
## GoAdmin 技术基线
|
||||
|
||||
- 初始化或升级 Sense 前必须读取根 `goadmin-baseline.json`,按其中的 go-admin、go-admin-ui commit 和 Go/Node/pnpm 精确版本实施。
|
||||
- Sense 必须由冻结 go-admin 后端与 go-admin-ui 前端源码直接派生;旧 `explore` 实现只能用于需求和行为对照,禁止把其中自研 HTTP、认证、RBAC、会话或前端外壳复制为新基础。
|
||||
- 开始每个骨架、认证权限、菜单路由、代码生成、ORM 或通用 UI 工单前,必须读取任务相关的冻结 go-admin-doc,并把参考主题/文件及采用方式写回工单。
|
||||
- 骨架验收必须证明上游来源、继承路径、许可证、裁剪清单和相对上游差异;只有相似技术栈或视觉不能通过验收。
|
||||
- `D:\github\goadmin` 下的仓库仅可只读参考;Sense 产品代码只能写入 `Sense/` 或工单授权路径,不得在参考仓库原地开发。
|
||||
- 派生上游代码必须保留 MIT 许可证和版权声明;上游 `go-admin-doc` 只作框架用法参考,不覆盖 yovision Wiki 和本目录规则。
|
||||
- 变更冻结基线必须升级为 Sense/Bell 协同工单;Sense 可以独立发布,但不得单独漂移上游或工具链版本。
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Sense 上游来源
|
||||
|
||||
Sense 的初始产品骨架从项目根目录 `goadmin-baseline.json` 冻结的上游源码派生,来源如下:
|
||||
|
||||
| 用途 | 上游仓库 | 完整 commit | 本地目录 | 许可证 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 后端基线 | https://github.com/go-admin-team/go-admin.git | `f06540883b41d03782bb6b2c4150f298f328c6b6` | `Sense/server/` | MIT,见 `Sense/server/LICENSE.md` |
|
||||
| 前端基线 | https://github.com/go-admin-team/go-admin-ui.git | `67d393d713877572fab0b897296a4c1d525fc81d` | `Sense/ui/` | MIT,见 `Sense/ui/LICENSE` |
|
||||
| 使用参考 | https://github.com/go-admin-team/go-admin-doc.git | `424855aacf6905f3fde860c3331385cb25529a0d` | 未复制产品代码 | MIT |
|
||||
|
||||
导入时保留上游受版本控制的源文件与许可证,排除 `.git/`、上游 CI 配置、后端示例数据库以及本机生成的 `node_modules/`、`dist/`。随后仅在 `Sense/` 内完成产品命名、安全默认值、无关路由关闭和 PostgreSQL 默认配置等派生修改。
|
||||
|
||||
`D:\github\goadmin` 下的三个仓库仅为只读参考副本,不是 Sense 的开发目录或依赖事实来源。
|
||||
@@ -1,157 +0,0 @@
|
||||
# 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。
|
||||
@@ -1,58 +0,0 @@
|
||||
# Sense
|
||||
|
||||
Sense 是从项目冻结的 GoAdmin 后端与 go-admin-ui 前端源码独立派生的视频接入管理产品。后端保留 Cobra、Gin、GORM、Casbin、JWT 和迁移框架,前端保留 Vue、Router、Store、API、Layout、权限路由和通用管理组件。
|
||||
|
||||
## 工具链
|
||||
|
||||
- Go 1.26.5
|
||||
- Node.js 22.22.1
|
||||
- pnpm 9.15.1(建议通过 `corepack pnpm@9.15.1` 调用)
|
||||
- PostgreSQL
|
||||
|
||||
完整上游来源见 `LICENSES/SOURCES.md`,版本事实来源为仓库根目录 `goadmin-baseline.json`。
|
||||
|
||||
## 本地验证入口
|
||||
|
||||
已使用 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
|
||||
cd Sense/server
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
```
|
||||
|
||||
前端:
|
||||
|
||||
```powershell
|
||||
cd Sense/ui
|
||||
corepack pnpm@9.15.1 install --frozen-lockfile
|
||||
corepack pnpm@9.15.1 lint
|
||||
corepack pnpm@9.15.1 test:unit
|
||||
corepack pnpm@9.15.1 build:prod
|
||||
```
|
||||
|
||||
## 安全配置
|
||||
|
||||
`server/config/settings.yml` 仅是无凭据模板。启动生产服务前,在仓库外复制该文件并填写 PostgreSQL 连接串和至少 32 个字符的随机 JWT 密钥,然后显式传入:
|
||||
|
||||
```powershell
|
||||
go run . server -c C:\secure-path\sense-settings.yml
|
||||
```
|
||||
|
||||
设备台账本身可以在不配置摄像头凭据的情况下使用。创建或更新 ONVIF/RTSP 凭据前,还必须在启动进程环境中设置 `SENSE_CREDENTIAL_KEY`:该值是随机 32 字节密钥的 Base64 编码,仅保存在仓库外。变量名模板见 `server/config/credential.env.example`;不要把真实值写入配置、脚本、日志或工单。密钥缺失或格式不正确时,Sense 会拒绝凭据写入,不会降级为明文存储。
|
||||
|
||||
使用 ONVIF 发现或手工接入前,还必须设置 `SENSE_ONVIF_DISCOVERY_IP` 和 `SENSE_ONVIF_ALLOWED_CIDRS`。前者只能是获准用于 WS-Discovery 的本机网卡地址;后者是获准访问的摄像头网段(多个 CIDR 用逗号分隔)。未配置时系统会给出可行动提示且不会扫描任意网卡;手工地址、Media XAddr 和 Stream URI 同样受该网段限制,并拒绝重定向或 URL 内凭据。
|
||||
|
||||
MediaMTX 保持独立二进制。配置 `SENSE_MEDIAMTX_BINARY`、`SENSE_MEDIAMTX_CONFIG` 和只允许回环地址的 `SENSE_MEDIAMTX_API`。Sense 只生成无摄像头凭据的基础配置;路径和凭据在运行时通过回环 Control API 下发。模板见 `server/config/mediamtx/mediamtx.yml.example`。
|
||||
|
||||
仓库不提供默认账号、默认密码或可用密钥。首位管理员通过受仓库外 `SENSE_BOOTSTRAP_TOKEN` 保护的一次性初始化接口创建,详细步骤以项目 Wiki 的本地开发与验证页为准。
|
||||
@@ -1,5 +0,0 @@
|
||||
logLevel: info
|
||||
api: true
|
||||
apiAddress: 127.0.0.1:9997
|
||||
metrics: false
|
||||
paths: {}
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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=
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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=
|
||||
@@ -1,5 +0,0 @@
|
||||
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.
|
||||
@@ -1,37 +0,0 @@
|
||||
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."
|
||||
@@ -1,3 +0,0 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0build-windows.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -1,117 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user