Files
yovision/docs/tasks/T-011.md
T

123 lines
13 KiB
Markdown
Raw Normal View History

---
id: T-011
title: 实现 Sense Control API v1 与 PostgreSQL 一致性边界
phase: 2
deps: [T-010]
status: DONE
created: 2026-08-07
2026-08-07 20:12:21 +08:00
issue: 39
2026-08-07 20:14:37 +08:00
context_ref: 6402d4384d5c2b1c0f3852858c3cf0d63d948262
claim_branch: claims/T-011
work_branch: agent/codex/T-011
write_paths:
- docs/tasks/T-011.md
- docs/contracts/
- deploy/postgres/
- scripts/test_postgres.ps1
- init.ps1
- init.sh
- Sense/api/
- Sense/cmd/sense-api/
- Sense/internal/auth/
- Sense/internal/config/
- Sense/internal/controlapi/
- Sense/internal/device/
- Sense/internal/mtx/
- Sense/internal/reconcile/
- Sense/internal/store/
- Sense/README.md
2026-08-07 20:39:59 +08:00
- docs/00-ai-start-here.md
- docs/03-tech-stack.md
- docs/04-architecture.md
- docs/06-tasks.md
- docs/api.md
- docs/current-state.md
- tests/test_postgres_contract.py
- tests/test_sense_control_implementation.py
---
## 问题 / 背景
T-008 已冻结 Sense Control API v1 的 7 个 endpoint,T-009/T-010 已提供 PostgreSQL Site 配额、Area 策略、设备准入和本地审计事务基础;当前 `sense-api` 仍只暴露 `/healthz`、`/readyz`,没有认证 tenant 上下文、HTTP handler、幂等收据、并发版本、稳定游标或批量 operation。若继续让实验室工具直写 repository,Bell 和受控集成方没有可发布、可审计的生产控制入口。
现有调和器还只选择 enabled 设备,停用写入不会删除该设备的已知 MediaMTX path。公共启停 API 上线前必须补齐这一条确定性收敛路径;它只删除台账中该设备的精确 path,不扩大为孤儿枚举或批量清理。
## 关联需求与交互(如适用)
- 用户故事:US-001、US-002、US-008、US-009、US-010。
- 交互清单:IX-001~IX-004、IX-013~IX-016、IX-019、IX-020;本任务实现已确认 Sense 原型所依赖的设备控制后端,不修改 HTML 页面布局。
- 公共契约:`docs/contracts/sense-control-v1.openapi.json`;投影 v1 与审计 v1 保持兼容。
## 方案
1. 使用已冻结的 `oapi-codegen v2.8.0` 从仓库 OpenAPI 生成 Go 类型和 `net/http` server glue,生成物进入标准漂移门禁;业务校验、认证和事务逻辑留在手写层,不修改生成文件。
2. Control API 通过 `SENSE_CONTROL_API_ENABLED=true` 显式开启,且只允许 `SENSE_DB_DRIVER=postgres`。默认 SQLite 和无认证的 `/healthz`、`/readyz` 继续可用,但不得注册 `/api/v1` 业务路由。
3. 定义可替换 `Authenticator` port;首期 `static-sha256` 适配器从仓库外 JSON 文件加载主体、tenant、Site scope、`sense.devices.read|write` 权限和 Bearer token SHA-256,不保存、记录或比较明文 token。原始文件只在启动时加载,变更需受控重启;JWT/OIDC/Bell 会话验证属于后续适配器。
4. Control API 开启时必须从独立仓库外文件读取至少 32 字节的 cursor HMAC key。游标绑定 tenant、Site、全部过滤条件和最后一项 `(created_at,id)`,使用 URL-safe 编码和 HMAC-SHA256;篡改、跨筛选或跨作用域重放返回 `400 invalid_request`。
5. PostgreSQL 增量 migration 为设备增加 write-only `profile_token` 和独立 `resource_version`,并创建 24 小时幂等收据、批量 operation/逐项结果表。ETag 是由设备 ID 与 `resource_version` 生成的强 opaque 值;只在配置或期望态表示实际改变时增加,后台 actual/reconcile 更新不使操作员写入无故冲突。所有设备响应设置 `Cache-Control: no-store`。
6. 创建和批量请求的作用域固定为认证主体 + tenant + Site + `operationId` + `Idempotency-Key`;数据库只保存作用域摘要、规范化请求摘要和脱敏响应快照。同作用域同 body 在至少 24 小时内返回首次状态码、body、ETag/Location 和 trace ID;同 key 不同 body 返回 `409 idempotency_conflict`。收据与首个业务结果同事务提交,过期记录只做有界机会清理。
7. 实现列表、创建、读取、merge patch、单项期望态、最多 128 项批量期望态和 operation 查询。tenant 只来自认证上下文;超出主体 Site scope、跨 tenant 和不存在统一 `404`,已知作用域内缺权限返回 `403`。列表按 `created_at ASC,id ASC`,默认 50、最大 100;读取只回显 configured 布尔值,永不回显 endpoint、credential 或 profile token。
8. PATCH 与期望态更新在行锁内比较 `If-Match`:缺失 `428`、格式非法 `400`、不匹配 `412`。Area 变更对成像设备重新执行 Area 准入;启用继续按 Area→Site 固定锁顺序检查策略和配额。重复提交相同期望态不增加 generation/resource version,但仍写一条独立审计事实。
9. 批量请求内所有重复 `device_id` 对应项均拒绝为 `invalid_request`,其他项继续;每项用 savepoint 隔离预期业务失败,成功项持久化。首版在请求事务内完成“期望态受理”并返回已完成 operation,`succeeded` 只表示期望态和审计已持久化,不表示媒体实际态已经收敛。
10. 保留审计 v1 文件不变,新增向后兼容的设备审计 v2 契约,增加 `device.configuration.accepted` 脱敏事实;PATCH 与该事实同事务,payload 只记录安全的 changed-fields/Area 逻辑 ID。创建和期望态既有事件继续同时满足 v2;Outbox relay 仍不在本任务实现。
11. 调和器对 disabled 视频设备执行其精确 `path_name` 的幂等删除,再把 observed generation 标记为当前代、actual state 置为 offline;不枚举 MediaMTX、不删除未知 path、不新增设备删除 API。
## 不可变约束
- 数值边界:设备列表默认 50、最大 100;批量最少 1、最多 128;请求 body 最大 1 MiB;幂等收据至少保存 24 小时;Site 视频配额默认 16、有效范围 1~128。
- 认证与隔离:Bearer token 至少 128 bit;静态文件只存 64 位小写十六进制 SHA-256;摘要比较使用 constant-time;tenant 不接受 path/query/body 自报。Site scope 不匹配、跨 tenant 和不存在不得产生可区分响应。
- 幂等与并发:规范化 body 使用确定性 JSON;收据、首个业务写和 trace/响应快照原子提交。ETag 只作为写并发令牌,不作为可缓存快照;`If-Match: *` 和多 ETag 不接受。
- 资源模型:设备 ID 与 operation ID 由服务端生成并满足冻结 pattern;modality/capabilities/serial 在 v1 PATCH 中不可修改。视频或 `video_capture` 创建设备必须有 endpoint 与 credential ref;endpoint 禁止 userinfo。
- 投影失败:Area 缺失/非法/版本回退统一为 `503 area_policy_unavailable`,策略拒绝为 `422 area_policy_denied`;配额缺失/非法/回退分别使用冻结错误。失败只阻止相关写,不改变已有设备或流。
- 秘密边界:token、cursor key、endpoint、credential、profile token、DSN、完整流 URI 和 MediaMTX 配置不得进入 Git、Issue/PR、普通响应、Problem、日志、游标、幂等作用域明文或审计 payload。静态认证和 cursor key 文件必须位于仓库外。
- 既有契约:不修改 Sense Control OpenAPI v1 的路径、字段、状态码和错误枚举;不原地扩展严格审计 v1 枚举,新增 v2 文件。Bell 继续拥有 Tenant/Site/Area/RBAC/配额;Sense 不读取 Bell 源表或写 Bell schema。
- 恢复:新 migration 前向可重放,不提供自动破坏性 down;新代码部署前先装 migration,回滚旧二进制可忽略新增列/表。公共 API 可通过关闭 feature flag 回退,已写设备/Outbox/operation/收据不得被自动删除。
## 验收要点
- 任务相关验证:Go 单元/HTTP 测试覆盖认证失败、权限、tenant/Site 隐藏、7 个 endpoint、严格 JSON、敏感字段不回显、cursor 篡改/绑定、ETag 428/412、创建和批量幂等、重复项、稳定错误与 disabled path 删除;`./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 覆盖真实 migration、收据原子性、并发重复请求、逐项 savepoint、operation 可见性、审计 v2 和权限。
- 静态契约:`python -m unittest discover -s tests -p "test_sense_control_implementation.py"` 与既有全部契约测试通过;生成后工作树无漂移,OpenAPI v1 和审计 v1 指纹语义不被改写。
- 完整门禁:`./init.ps1`、`python scripts/validate_agent_context.py`、`python -m unittest discover -s tests -p "test_*.py"`、`python scripts/validate_harness_governance.py`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...`、`git diff --check` 全部通过。
- 人工 / 设备验收:不需要摄像头或 UI;使用临时 PostgreSQL 和 fake MediaMTX/adapter 完成。本任务不解除 T-007 真实多路现场门禁,也不形成 16/128 路容量承诺。
- 构建产物:生成的 Control API glue、认证/handler/store、增量 migration、审计 v2 schema、外部配置示例(只含占位符)、本机隔离集成测试和同步运维文档。
## 边界(不改什么)
不实现 Bell 管理服务或 UI、JWT/OIDC、静态主体热加载、TLS termination、WAF/公网暴露、Outbox relay/签名/留存、设备删除、孤儿枚举、证据 API、Brain、WireGuard、容量压测或 T-007 现场验收;不把 Control API 下放到 SQLite,不修改已确认 Sense/Bell HTML 原型。
## 协作约束
- 责任 Agent:由 dispatcher 分配。
- 唯一写入者:同责任 Agent。
- 委派:默认不启用。
- Gitea:任务文件先进入默认分支,再创建唯一 Issue 并回填编号;领取时记录 `context_ref`、claim / 工作分支和全部允许写路径。
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
## 执行记录
### 2026-08-07 完成 Sense Control API v1
- 使用冻结的 `oapi-codegen v2.8.0` 从 T-008 OpenAPI 生成 Go 1.22+ `net/http` server glue(生成 SHA-256 `39a52e4f54bb1a742f58b64dcf15ce98359385236da5ccc0dae77e8ded2b218b`),实现全部 7 个 endpoint、严格 JSON/1 MiB body、稳定 Problem、write-only 字段脱敏和 `Cache-Control: no-store`;生成漂移已进入 `init.ps1`/`init.sh`。
- 新增可替换认证 port 与 `static-sha256` 私有部署适配器:外部注册表只保存 token 摘要、主体、tenant、Site scope 与两项权限,摘要 constant-time 比较;业务路由默认关闭,只能在 PostgreSQL v4 schema 与外部 32 字节 HMAC cursor key 有效时开启。SQLite 默认路径继续只暴露探针。
- 新增 `008`/`009` migration:设备 `resource_version`/write-only profile token、24 小时摘要幂等收据、持久化 batch operation/逐项结果与最小权限。创建收据与设备/审计同事务;batch 以 savepoint 隔离逐项失败,并先按设备 ID 排序锁定目标,避免相反请求顺序死锁。ETag 只在配置/期望态实际变化时前进;相同期望态仍审计但不增加 generation/resource version。
- 保留严格审计 v1 文件不变,新增 v2 后继和 `device.configuration.accepted` 脱敏事实;Area 修改重新准入。停用调和只对台账中的精确 path 做幂等删除,成功后 observed generation 收敛且 actual state 为 offline,不枚举或清理未知 path。
- `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 通过:`001`~`009` 连续重放、SQL 权限断言和 25 个 `TestPostgres*` 全绿,覆盖列表稳定分页/过滤、创建并发幂等、收据脱敏/冲突、ETag/PATCH、审计 v2、逐项 batch、operation tenant 隐藏、相反顺序批量锁与 PUBLIC 权限负例;随机回环临时集群已停止并清理,现有 `D:\pgsql17\data`/5432 listener 未读取、停止或修改。
- `./init.ps1` 通过;Python 契约/治理测试共 47 项通过;`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...`、生成漂移和 `git diff --check` 均通过;`go test -race ./internal/auth ./internal/controlapi ./internal/reconcile ./internal/store` 通过。不需要摄像头或 UI,本结果不解除 T-007,也不形成 16/128 路容量承诺。
2026-08-07 20:14:37 +08:00
### 2026-08-07 领取任务
- dispatcher `ila` 将 Issue #39 分配给 `codex`;`context_ref` 为 `6402d4384d5c2b1c0f3852858c3cf0d63d948262`,claim 为 `claims/T-011`,工作分支为 `agent/codex/T-011`。
- 已读回 Issue `status/doing`、assignee、dispatcher 发布的结构化 CLAIM 与两个分支 SHA;接受 frontmatter 全部写路径。T-007 仍为 waiting,当前没有活跃写路径冲突。
2026-08-07 20:39:59 +08:00
- 实现中发现标准生成入口还需同步 `docs/00-ai-start-here.md`;dispatcher 复查无活跃冲突后发布完整 CLAIM RENEWAL,本任务据此增加该精确路径。
2026-08-07 20:14:37 +08:00
2026-08-07 20:12:21 +08:00
### 2026-08-07 Gitea 映射
- 任务规格先合入默认分支,再创建唯一主 Issue #39;本提交只回填双向映射,映射合入并读回前不领取任务。
### 2026-08-07 任务定义
- 项目负责人要求创建并实施 T-011;依赖 T-010 已完成,Gitea 当前只有 T-007 因真实设备条件处于 waiting,不占用写路径。
- 选择 PostgreSQL 一致性实现而不是进程内幂等/operation 缓存;选择外部静态 SHA-256 注册表作为私有部署首版认证适配器,同时保留未来 Bell/JWT/OIDC port。
- 公共停用 API 上线前补齐精确 path 删除;该变更不扩大为孤儿清理,也不修改 T-007 门禁。