From 1c6314310611ddb13432806fd9850cfad64d35a9 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 7 Aug 2026 16:57:28 +0800 Subject: [PATCH 1/2] chore(task): claim T-008 --- docs/tasks/T-008.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/tasks/T-008.md b/docs/tasks/T-008.md index 0a78b81..539d6cf 100644 --- a/docs/tasks/T-008.md +++ b/docs/tasks/T-008.md @@ -3,12 +3,12 @@ id: T-008 title: 冻结 Sense 设备管理 API 与 Bell 配额只读投影契约 phase: 2 deps: [T-006] -status: TODO +status: DOING created: 2026-08-07 issue: 27 -context_ref: null -claim_branch: null -work_branch: null +context_ref: cc47e1463849151bee3c349cc53971181e262043 +claim_branch: claims/T-008 +work_branch: agent/codex/T-008 write_paths: - docs/tasks/T-008.md - docs/contracts/ @@ -70,6 +70,11 @@ T-006 已证明 Sense 能用 SQLite 期望态驱动 1 路真实摄像头与 4 ## 执行记录 +### 2026-08-07 领取任务 + +- dispatcher `ila` 将任务分配给 `codex`;`context_ref` 为 `cc47e1463849151bee3c349cc53971181e262043`,claim 为 `claims/T-008`,工作分支为 `agent/codex/T-008`。 +- 接受既有写路径:`docs/tasks/T-008.md`、`docs/contracts/`、`docs/api.md`、`docs/04-architecture.md`、`docs/06-tasks.md`、`docs/current-state.md`、`tests/test_sense_control_contract.py`。 + ### 2026-08-07 任务定义 - 项目负责人要求落成并立即实施 T-008;任务按 M2 第一项契约门禁建立,依赖已完成的 T-006。 From b70df147ead5b85910f45ea1cc11407615a4aac3 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 7 Aug 2026 17:12:15 +0800 Subject: [PATCH 2/2] docs(contract): freeze Sense control API v1 [T-008] --- docs/04-architecture.md | 11 +- docs/06-tasks.md | 3 +- docs/api.md | 40 +- docs/contracts/README.md | 69 ++ docs/contracts/sense-control-v1.openapi.json | 691 +++++++++++++++++++ docs/contracts/site-quota-v1.sql | 32 + docs/current-state.md | 5 +- docs/tasks/T-008.md | 12 +- tests/test_sense_control_contract.py | 302 ++++++++ 9 files changed, 1144 insertions(+), 21 deletions(-) create mode 100644 docs/contracts/README.md create mode 100644 docs/contracts/sense-control-v1.openapi.json create mode 100644 docs/contracts/site-quota-v1.sql create mode 100644 tests/test_sense_control_contract.py diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 3c80696..22a2ebb 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -45,7 +45,7 @@ Sense ── 视频流/触发信号 ──> Brain 主流程: -1. Bell 持有站点、Area、配额与 `capture_policy`;Sense 在新增/启用设备时通过版本化内部 API 或只读投影校验,并记录所用投影版本。 +1. Bell 持有站点、Area、配额与 `capture_policy`;首期在同一 PostgreSQL 实例内发布版本化只读视图 `bell.site_quota_v1`,Sense 在新增/启用设备的同一写路径校验并记录所用 `source_version`。未来分库必须发布新版本契约,不能静默改变 v1 语义。 2. Sense 维护设备期望态,通过 MediaMTX API 和对账器收敛实际态。 3. Brain 消费视频与触发信号,产生符合 v0.1 的事件。 4. Bell 做 schema 与代码级断言,生成平台 ULID,保存不可变事件。 @@ -63,11 +63,11 @@ Sense ── 视频流/触发信号 ──> Brain 4. 平台事件 ULID 由 Bell 生成;Brain 只填 `source_event_id`。 5. 一个 PostgreSQL 实例,`sense`/`bell` schema 分离;Brain 无业务 schema。 6. 16/128 都不是单机保证;媒体与推理按独立分片横向扩展。 -7. Bell 拥有 `site.max_video_channels`,Sense 在设备写路径执行;不跨 schema 直接写。 +7. Bell 拥有 `site.max_video_channels` 并拥有 `bell.site_quota_v1`;Sense 角色只获得视图 `SELECT`,在设备写路径执行准入,不能写 Bell schema 或读取 Bell 源表。 8. 事件片段写入客户侧 MinIO/S3,常态录像留在客户 NVR;元数据/审计、人脸和训练样本使用独立生命周期。 9. 投递状态机只依赖 Bell provider 接口,不直接依赖某家短信或语音 SDK;生产前至少两条独立路径并能故障切换。 10. 设备领域模型使用 `modality + capabilities`,页面不以摄像头作为唯一根实体;未实现协议适配器明确为 `adapter_not_ready`,不得用模拟遥测伪装交付。 -11. Tenant/Site/Area/RBAC、配额、`capture_policy` 与全局审计属于 Bell;Sense 只读消费版本化投影并在设备写路径执行,投影不可用时只阻断相关新变更,不静默切断已有链路。 +11. Tenant/Site/Area/RBAC、配额、`capture_policy` 与全局审计属于 Bell;Sense Control API v1 只管理 Device 期望态与收敛查询,Sense 只读消费版本化投影并在设备写路径执行,投影不可用时只阻断相关新变更,不静默切断已有链路。 12. Sense 的设备操作审计先写本地持久化 outbox,再由幂等 relay 异步送入 Bell 全局审计;不得使用“先执行高风险操作、再尽力入队”的顺序。具体字段、签名、重放与留存契约必须由独立 API/契约任务冻结后实现。 ## 6. 容量架构 @@ -84,7 +84,7 @@ Sense ── 视频流/触发信号 ──> Brain - PostgreSQL 是期望态真相源;MediaMTX、推理 worker 和对象存储是可对账的实际态。 - 对账器水平触发、幂等、指数退避、限制并发;部分失败不做跨系统回滚,只持续收敛。 - 孤儿删除必须有 10% 安全闸和人工可观察指标。 -- 配额读取失败只阻止新增/启用,不中断已有流。 +- `bell.site_quota_v1` 行缺失、数值越界、版本回退或读取失败只阻止视频设备新增/启用,不中断已有流;降低配额导致超限时不自动停用,后续准入返回稳定错误并产生运维信号。 - Area 策略投影读取失败只阻止相关设备新增/启用;已有设备保持原状态并产生运维告警。策略变更与已有成像设备冲突时由 Bell 管理端显式处置。 - 高风险设备操作在本地事务内同时写期望态与审计 outbox;异步 relay 可重试、幂等投递到 Bell。T-004 只验证交互归属,不定义或实现接口契约。 - Brain 投递失败落本地队列重试,不阻塞实时推理主链路。 @@ -94,7 +94,8 @@ Sense ── 视频流/触发信号 ──> Brain ## 8. 数据与契约 -- Bell 核心实体:Tenant → Site → Area(含 `capture_policy`)以及 Role/Binding/Quota/Audit;Sense 核心实体:Device(含 `modality + capabilities`)→ StreamBinding/Zone,以及带 `source_version`/`synced_at` 的 SiteQuota/AreaPolicyProjection。两个 schema 以稳定逻辑 ID 关联,不跨 schema 写入。 +- Bell 核心实体:Tenant → Site → Area(含 `capture_policy`)以及 Role/Binding/Quota/Audit;Sense 核心实体:Device(含 `modality + capabilities`)→ StreamBinding/Zone,以及带 `source_version`/`synced_at` 的 SiteQuota/AreaPolicyProjection。两个 schema 以稳定逻辑 ID 关联,不跨 schema 写入;配额 v1 的跨 schema 列签名限定为 `tenant_id/site_id/max_video_channels/source_version/source_updated_at`。 +- Sense Control API v1 使用站点作用域路径、认证上下文 tenant、cursor 分页、幂等键与 ETag;敏感连接引用只写不读。正式签名和兼容规则以 [`contracts/`](contracts/) 为准,当前契约冻结不等于 handler 或 PostgreSQL migration 已实现。 - 业务实体:Rule → Event → Alert → DeliveryAttempt/Ack;Event 与 Alert 不合并。 - Bell 通知域分为三个聚合:Contact/Team 保存身份、成员关系和已验证通道;OnCallSchedule/ScheduleVersion/ShiftException 保存时区、轮换与例外;EscalationPolicy/Step 通过 `person / team / on_call_schedule` 类型化 `target_ref` 引用目标。三者共享逻辑 ID,不复制手机号、班次或轮换字段。 - 每个 DeliveryAttempt 创建时解析当时生效的排班版本,并保存实际收件人、通道、`schedule_version` 和解析时间快照;之后联系人或排班修改不得回写既有投递事实。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 096e69a..42a0d2b 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -22,8 +22,9 @@ 出口:10 个站点试点,至少一个站点完成 16 路开通/停用;`unconverged = 0` 稳定。 +- T-008:冻结 Sense Control API v1 与 Bell `site_quota_v1` 只读投影契约;只建立接口和测试门禁,不等于 handler、Bell 表或 migration 已实现。 +- 按 T-008 契约实现认证 tenant 上下文、设备管理 handler、PostgreSQL repository、Bell 配额源表/migration 与只读投影。 - 对账器幂等/退避/并发/10% 安全闸。 -- Bell 站点配额投影到 Sense 的版本化读取契约。 - WireGuard 边缘隧道与断网恢复。 - 16 路批量开通、停用和容量基准。 diff --git a/docs/api.md b/docs/api.md index c70ea8b..ef776c4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,6 +1,6 @@ # API 与契约 -> 事件契约 v0.1 已冻结;其他 API 仍在设计阶段。不得把本文的“待定”自行具体化为公共契约。 +> Brain → Bell 事件契约 v0.1、Sense Control API v1 与 Bell 站点配额只读投影 v1 已冻结;其他 API 仍在设计阶段。不得把本文的“待定”自行具体化为公共契约。 ## 1. 已冻结:Brain → Bell 事件契约 @@ -17,17 +17,35 @@ - 证据文件名只含事件 ID 与日期目录,不含 IP、端口、凭据或客户名。 - `sensors` 中恰有一个 primary,且其 `device_id` 与顶层一致。 -## 2. 待冻结的内部接口 +## 2. 跨系统接口状态 | 调用方 → 提供方 | 用途 | 当前约束 | 状态 | | --- | --- | --- | --- | -| Sense → Bell | 读取站点视频配额 | 版本化;默认 16、最大 128;失败时拒绝新增/启用但不影响已有流 | 待 M2 设计 | +| Sense → Bell | 读取站点视频配额 | 同一 PostgreSQL 实例内只读 `bell.site_quota_v1`;默认 16、最大 128;失败时拒绝新增/启用但不影响已有流 | T-008 已冻结 | | Bell → Sense | 请求事件证据/pre-roll 切片 | 幂等、按租户授权、异步结果、不得暴露原始凭据 | 待 M3 设计 | | Bell → Brain | outcome/误报反馈 | 原事件不可变;反馈可重试、去重、审计 | 待 M3 设计 | | Sense → Brain | 流绑定与设备型触发 | 分片可路由,触发入口与流控制解耦 | 待 M2/M3 设计 | | Worker → 控制面 | 注册、心跳、容量 | `max_sources` 来自 profile/压测,不固定为 16 | 待 M3 设计 | -## 3. 待冻结的 Bell 公共 API +冻结签名和失败语义见 [`contracts/README.md`](contracts/README.md) 与 [`contracts/site-quota-v1.sql`](contracts/site-quota-v1.sql)。Bell 拥有源数据和视图,Sense 数据库角色只有 `SELECT`;未来分库必须发布新版本,不能在 v1 下把本地视图静默替换为网络调用。 + +## 3. 已冻结:Sense Control API v1 + +- OpenAPI:[`contracts/sense-control-v1.openapi.json`](contracts/sense-control-v1.openapi.json) +- 语义、资源所有权、幂等、并发与兼容规则:[`contracts/README.md`](contracts/README.md) +- 范围:设备分页查询、创建、读取、修改、单项启停、最多 128 项的批量启停和批量操作查询。 + +关键规则: + +- 所有业务路径使用 `/api/v1` 和 Bearer 认证;tenant 只来自认证上下文,跨租户访问与不存在统一为 `404 not_found`。 +- Site、Area、RBAC、配额和全局审计仍由 Bell 持有;Sense 只管理 Device 期望态和收敛状态,不提供这些 Bell 资源的 CRUD。 +- 列表使用稳定顺序和不透明 cursor,默认 50、最大 100;创建与批量写要求 `Idempotency-Key`,资源修改与单项期望态写要求 `If-Match`。 +- `endpoint_ref`、`credential_ref`、`profile_token` 只写不读;设备 ID 由服务端生成。普通响应和错误不得包含凭据、完整流 URI、token 或 MediaMTX 内部配置。 +- v1 不提供删除设备;停用设备保留历史。写入受理只表示期望态已持久化,不能表示实际态已收敛。 + +T-008 只冻结契约和仓库门禁,尚未实现 Sense HTTP handler、认证中间件、Bell 表或 PostgreSQL migration。 + +## 4. 待冻结的 Bell 公共 API 资源范围预计包括:租户、站点、设备只读投影、规则、事件、预警、ack、处置、误报反馈、审计和报表。设计时必须满足: @@ -38,11 +56,11 @@ - 错误体包含稳定错误码、可读消息和 trace ID,不返回内部堆栈或凭据。 - 人脸功能未授权时表现为能力不存在,而非仅按钮置灰。 -## 4. MediaMTX 接口边界 +## 5. MediaMTX 接口边界 Sense 使用 MediaMTX 官方 OpenAPI 生成客户端并加薄封装。业务代码不得散落硬编码 path API;生成代码不可手改。MediaMTX path 不是租户/站点/设备的业务真相源。 -### 4.1 T-003 已实现的内部适配契约 +### 5.1 T-003 已实现的内部适配契约 以下是 Sense 内部 Go port,不是 Bell 或第三方可依赖的公共 HTTP API: @@ -54,22 +72,22 @@ Sense 使用 MediaMTX 官方 OpenAPI 生成客户端并加薄封装。业务代 MediaMTX 薄封装调用同版官方 OpenAPI 的 `/v3/config/paths/get|add|patch|delete/{name}` 与 `/v3/paths/get/{name}`。生成源、版本和 SHA-256 见 `docs/03-tech-stack.md`;业务包不得直接 import 生成包。 -### 4.2 设备台账语义 +### 5.2 设备台账语义 - 设备类型由 `modality` 表达物理类别,由多值 `capabilities` 表达视频采集、音频、空间规则或遥测能力,避免把“摄像头”固化为唯一设备模型。 - 视频配额只统计 `desired_state=enabled` 且具有 `video_capture` capability 的设备;站点默认 16、可配置 1~128。禁用设备和非视频传感器不占视频路数。 - SQLite 表使用 `sense_` 前缀对应未来 PostgreSQL `sense` schema:`sense_sites`、`sense_devices`、`sense_device_capabilities`、`sense_reconcile_state`。标识、唯一性、状态与时间字段语义保持一致;本地表名前缀不是跨系统公共契约。 - 摄像头密码不进入设备普通字段。`credential_ref` 只保存外部密钥引用;ONVIF 返回的 stream URI 只在内存中传给 MediaMTX,不写入设备台账或日志。 -### 4.3 Sense 进程 HTTP 面 +### 5.3 Sense 进程 HTTP 面 -T-003 只提供运维探针:`GET /healthz` 表示进程存活,`GET /readyz` 表示配置、SQLite 打开及 migration 已完成。两者返回 JSON,均不等价于摄像头、MediaMTX path 或 M1 里程碑健康。设备管理、认证、分页、幂等键与并发控制尚未冻结,因此本任务不暴露 `/api/v1/devices` 等临时接口。 +T-003 只实现了运维探针:`GET /healthz` 表示进程存活,`GET /readyz` 表示配置、SQLite 打开及 migration 已完成。两者返回 JSON,均不等价于摄像头、MediaMTX path 或 M1 里程碑健康。T-008 已冻结站点作用域的 `/api/v1/sites/{site_id}/devices` 等设备管理契约,但尚未实现对应 handler;当前可运行进程仍只暴露探针,后续实现不得发布 `/api/v1/devices` 等无站点边界的临时接口。 -## 5. 变更流程 +## 6. 变更流程 1. 在对应任务文件写清调用方、提供方、数据所有者、失败语义、幂等与兼容策略。 2. 更新本文和 schema/OpenAPI。 3. 同步生产者、消费者、契约测试和示例。 4. 记录迁移、回滚与版本废弃策略。 -事件 v0.1 的破坏性变化必须发布新版本,不能原地修改已被 M3 生产者/消费者使用的契约。 +事件 v0.1、Sense Control API v1 或站点配额投影 v1 的破坏性变化必须发布新版本,不能原地修改已被生产者/消费者使用的契约。 diff --git a/docs/contracts/README.md b/docs/contracts/README.md new file mode 100644 index 0000000..c96ebd6 --- /dev/null +++ b/docs/contracts/README.md @@ -0,0 +1,69 @@ +# Sense 控制面与站点配额契约 v1 + +> 冻结日期:2026-08-07。契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC 与配额的所有者。本文冻结接口,不表示 HTTP handler、Bell 表或 PostgreSQL migration 已实现。 + +## 契约文件 + +| 文件 | 生产者 / 所有者 | 消费者 | 用途 | +| --- | --- | --- | --- | +| [`sense-control-v1.openapi.json`](sense-control-v1.openapi.json) | Sense | Bell 管理面、受控集成方 | 设备查询、创建、修改、启停与批量操作 | +| [`site-quota-v1.sql`](site-quota-v1.sql) | Bell | Sense | 单 PostgreSQL 实例内的站点视频配额只读投影 | + +OpenAPI 的 `/api/v1` 路径是公共控制面边界;`/healthz`、`/readyz` 仍是非业务运维探针。v1 不提供设备删除:停用设备使用期望态接口,保留设备、操作和审计历史。Site、Area、配额、RBAC 和审计聚合不由 Sense 提供 CRUD。 + +## HTTP 资源与操作 + +| 操作 | 路径 | 关键约束 | +| --- | --- | --- | +| 列出 / 创建设备 | `GET/POST /api/v1/sites/{site_id}/devices` | 列表使用不透明 cursor,默认 50、最大 100;创建要求 `Idempotency-Key`,设备 ID 由服务端生成 | +| 读取 / 修改设备 | `GET/PATCH /api/v1/sites/{site_id}/devices/{device_id}` | 修改要求 `If-Match`,版本不匹配返回 `412 etag_mismatch` | +| 修改期望态 | `PUT /api/v1/sites/{site_id}/devices/{device_id}/desired-state` | 要求 `If-Match`;受理不表示实际态已经收敛 | +| 批量修改期望态 | `POST /api/v1/sites/{site_id}/devices:batchDesiredState` | 要求 `Idempotency-Key`,最多 128 项,异步返回逐项结果 | +| 查询批量操作 | `GET /api/v1/operations/{operation_id}` | 只返回当前租户和站点可见的操作 | + +列表按 `created_at ASC, id ASC` 稳定排序,cursor 是服务端生成的不透明位置标记。客户端不得解析或拼接 cursor;服务端可以在兼容范围内改变编码。批量不是跨设备全有或全无事务:每项独立接受或拒绝,成功项继续收敛,失败项带稳定错误码。请求内重复 `device_id` 视为对应项 `invalid_request`,不得用“最后一项覆盖”。重试失败项应使用新幂等键;原样重放整个请求必须返回原收据。 + +## 身份、租户与敏感信息 + +- 所有 `/api/v1` 操作都需要 Bearer 认证;具体 token 格式由认证任务冻结。`tenant_id` 只从认证上下文取得,body、query 和 path 均不能自报 tenant。 +- 跨租户访问与资源不存在都返回 `404 not_found`,不得用状态码、消息或耗时泄露资源是否存在。授权范围不足但不涉及资源枚举时返回 `403 forbidden`。 +- `endpoint_ref`、`credential_ref` 和 `profile_token` 是 write-only 输入。不允许 userinfo 形式的完整 RTSP URI;响应、错误、日志与示例不得包含这些引用、密码、token、完整连接串或 MediaMTX 内部配置。 +- 创建设备显式提交 `modality + capabilities`;`video` 模态必须包含 `video_capture`,任何请求了 `video_capture` 的设备都必须同时提供 endpoint 与 credential 引用。服务端仍须由适配器验证能力,不能把客户端声明当作探测成功。 +- `ETag` 表示设备资源版本;`If-Match` 缺失返回 `428 precondition_required`,过期版本返回 `412 etag_mismatch`。相同期望态重复提交不增加 generation,但每次受理仍可产生审计记录。 + +`Idempotency-Key` 的作用域是“认证主体 + tenant + site + operation + key”,服务端至少保存 24 小时。同作用域、同请求体重放返回首次状态码和响应;同 key 不同请求体返回 `409 idempotency_conflict`。幂等收据不等价于实际态完成。 + +## 配额投影与准入 + +Bell migration 最终创建 `bell.site_quota_v1`,列顺序和含义固定如下: + +| 列 | 含义 | +| --- | --- | +| `tenant_id`、`site_id` | 与两个 schema 共享的稳定逻辑 ID | +| `max_video_channels` | 默认 16,有效范围 1~128 | +| `source_version` | 站点投影每次变更后单调递增的版本 | +| `source_updated_at` | Bell 源记录的更新时间 | + +视图由 `bell_app` 拥有;`sense_app` 只有 Bell schema 的 `USAGE` 和该视图的 `SELECT`,没有 `INSERT`、`UPDATE`、`DELETE` 或 Bell 源表权限。Sense 不得通过任何旁路写 Bell schema。首期使用同一 PostgreSQL 实例及 `sense`/`bell` schema;如果未来分库,必须发布新版本的网络契约,不能在 v1 下静默改变一致性和失败语义。 + +配额只统计 `desired_state=enabled` 且 capabilities 含 `video_capture` 的设备。创建已启用视频设备或把视频设备启用时,Sense 必须在同一设备写路径读取并记录所用 `source_version`,同时验证 Area 策略投影。降低配额不会自动停用已有设备;若当前占用已超限,后续创建/启用返回 `409 quota_exceeded`。配额行缺失、越界、版本回退或投影不可读时返回 `503 quota_projection_unavailable`,只阻止相关创建/启用,读取、非准入属性修改和停用仍允许,已有流保持运行。 + +Area/capture policy 的投影形态不在 T-008 中冻结;Sense v1 仍保留 `area_policy_unavailable` 与 `area_policy_denied` 稳定错误语义,后续契约不得放宽同写路径校验要求。 + +## 兼容与废弃 + +- v1 可增加不改变已有语义的可选响应字段和新错误细节;客户端必须忽略未知响应字段。 +- 删除/重命名字段、收紧已接受输入、改变状态码/幂等作用域/配额计数或把只读视图改为远程调用,均属于破坏性变化,必须发布新版本。 +- 废弃版本应先在 OpenAPI 标记并公告迁移窗口;服务端在所有已声明消费者完成迁移前继续提供旧版本。 +- OpenAPI 中的稳定错误码用于程序判断,`message` 只用于人读,不得依赖其字面内容。 + +## 验证 + +从仓库根目录执行: + +```powershell +python -m json.tool docs/contracts/sense-control-v1.openapi.json | Out-Null +python -m unittest discover -s tests -p "test_sense_control_contract.py" +``` + +测试校验本仓库依赖的 OpenAPI 结构与安全不变量,并不替代后续实现任务对完整 OpenAPI 标准验证器、HTTP handler 和 PostgreSQL migration 的验证。 diff --git a/docs/contracts/sense-control-v1.openapi.json b/docs/contracts/sense-control-v1.openapi.json new file mode 100644 index 0000000..4869e01 --- /dev/null +++ b/docs/contracts/sense-control-v1.openapi.json @@ -0,0 +1,691 @@ +{ + "openapi": "3.1.0", + "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", + "info": { + "title": "YoVision Sense Control API", + "version": "1.0.0", + "description": "受控的设备期望态与收敛查询契约。Tenant 只从认证上下文确定;Site、Area、RBAC 和配额真相仍由 Bell 持有。" + }, + "servers": [ + { + "url": "/", + "description": "由部署入口提供 scheme、host 和认证终止" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "tags": [ + { + "name": "Devices", + "description": "设备台账、期望态和收敛状态" + }, + { + "name": "Operations", + "description": "批量写入的逐项结果" + } + ], + "paths": { + "/api/v1/sites/{site_id}/devices": { + "get": { + "tags": ["Devices"], + "operationId": "listDevices", + "summary": "分页查询当前认证租户下的站点设备", + "parameters": [ + {"$ref": "#/components/parameters/SiteID"}, + {"$ref": "#/components/parameters/Cursor"}, + {"$ref": "#/components/parameters/Limit"}, + {"$ref": "#/components/parameters/ModalityFilter"}, + {"$ref": "#/components/parameters/CapabilityFilter"}, + {"$ref": "#/components/parameters/DesiredStateFilter"}, + {"$ref": "#/components/parameters/ActualStateFilter"} + ], + "responses": { + "200": { + "description": "按 created_at、id 稳定升序返回的一页设备", + "headers": {"X-Trace-ID": {"$ref": "#/components/headers/TraceID"}}, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/DevicePage"}} + } + }, + "401": {"$ref": "#/components/responses/ErrorResponse"}, + "404": {"$ref": "#/components/responses/ErrorResponse"}, + "500": {"$ref": "#/components/responses/ErrorResponse"} + } + }, + "post": { + "tags": ["Devices"], + "operationId": "createDevice", + "summary": "创建待探测设备", + "parameters": [ + {"$ref": "#/components/parameters/SiteID"}, + {"$ref": "#/components/parameters/IdempotencyKey"} + ], + "requestBody": { + "required": true, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/DeviceCreate"}} + } + }, + "responses": { + "201": {"$ref": "#/components/responses/CreatedDevice"}, + "400": {"$ref": "#/components/responses/ErrorResponse"}, + "401": {"$ref": "#/components/responses/ErrorResponse"}, + "404": {"$ref": "#/components/responses/ErrorResponse"}, + "409": {"$ref": "#/components/responses/ErrorResponse"}, + "422": {"$ref": "#/components/responses/ErrorResponse"}, + "503": {"$ref": "#/components/responses/ErrorResponse"} + } + } + }, + "/api/v1/sites/{site_id}/devices/{device_id}": { + "get": { + "tags": ["Devices"], + "operationId": "getDevice", + "summary": "读取设备期望态、实际态和收敛进度", + "parameters": [ + {"$ref": "#/components/parameters/SiteID"}, + {"$ref": "#/components/parameters/DeviceID"} + ], + "responses": { + "200": {"$ref": "#/components/responses/CurrentDevice"}, + "401": {"$ref": "#/components/responses/ErrorResponse"}, + "404": {"$ref": "#/components/responses/ErrorResponse"}, + "500": {"$ref": "#/components/responses/ErrorResponse"} + } + }, + "patch": { + "tags": ["Devices"], + "operationId": "updateDevice", + "summary": "按 ETag 修改设备可编辑配置", + "parameters": [ + {"$ref": "#/components/parameters/SiteID"}, + {"$ref": "#/components/parameters/DeviceID"}, + {"$ref": "#/components/parameters/IfMatch"} + ], + "requestBody": { + "required": true, + "content": { + "application/merge-patch+json": {"schema": {"$ref": "#/components/schemas/DevicePatch"}} + } + }, + "responses": { + "202": {"$ref": "#/components/responses/AcceptedMutation"}, + "400": {"$ref": "#/components/responses/ErrorResponse"}, + "401": {"$ref": "#/components/responses/ErrorResponse"}, + "404": {"$ref": "#/components/responses/ErrorResponse"}, + "409": {"$ref": "#/components/responses/ErrorResponse"}, + "412": {"$ref": "#/components/responses/ErrorResponse"}, + "422": {"$ref": "#/components/responses/ErrorResponse"}, + "428": {"$ref": "#/components/responses/ErrorResponse"}, + "503": {"$ref": "#/components/responses/ErrorResponse"} + } + } + }, + "/api/v1/sites/{site_id}/devices/{device_id}/desired-state": { + "put": { + "tags": ["Devices"], + "operationId": "setDeviceDesiredState", + "summary": "启用或停用设备接入期望态", + "description": "成功只表示期望态已持久化并进入对账,不表示实际态已经收敛。重复提交不会重复改变 generation,但每次请求均可产生审计事实。", + "parameters": [ + {"$ref": "#/components/parameters/SiteID"}, + {"$ref": "#/components/parameters/DeviceID"}, + {"$ref": "#/components/parameters/IfMatch"} + ], + "requestBody": { + "required": true, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/DesiredStateChange"}} + } + }, + "responses": { + "202": {"$ref": "#/components/responses/AcceptedMutation"}, + "400": {"$ref": "#/components/responses/ErrorResponse"}, + "401": {"$ref": "#/components/responses/ErrorResponse"}, + "404": {"$ref": "#/components/responses/ErrorResponse"}, + "412": {"$ref": "#/components/responses/ErrorResponse"}, + "422": {"$ref": "#/components/responses/ErrorResponse"}, + "428": {"$ref": "#/components/responses/ErrorResponse"}, + "503": {"$ref": "#/components/responses/ErrorResponse"} + } + } + }, + "/api/v1/sites/{site_id}/devices:batchDesiredState": { + "post": { + "tags": ["Devices"], + "operationId": "batchSetDeviceDesiredState", + "summary": "批量提交最多 128 个设备期望态变更", + "parameters": [ + {"$ref": "#/components/parameters/SiteID"}, + {"$ref": "#/components/parameters/IdempotencyKey"} + ], + "requestBody": { + "required": true, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/BatchDesiredStateRequest"}} + } + }, + "responses": { + "202": { + "description": "请求已接收;响应包含已知的逐项接受/拒绝结果,后续状态从 operation 读取", + "headers": { + "Location": {"$ref": "#/components/headers/OperationLocation"}, + "X-Trace-ID": {"$ref": "#/components/headers/TraceID"} + }, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/BatchOperation"}} + } + }, + "400": {"$ref": "#/components/responses/ErrorResponse"}, + "401": {"$ref": "#/components/responses/ErrorResponse"}, + "404": {"$ref": "#/components/responses/ErrorResponse"}, + "409": {"$ref": "#/components/responses/ErrorResponse"}, + "422": {"$ref": "#/components/responses/ErrorResponse"}, + "503": {"$ref": "#/components/responses/ErrorResponse"} + } + } + }, + "/api/v1/operations/{operation_id}": { + "get": { + "tags": ["Operations"], + "operationId": "getOperation", + "summary": "查询批量写入和逐项结果", + "parameters": [ + {"$ref": "#/components/parameters/OperationID"} + ], + "responses": { + "200": { + "description": "批量操作状态", + "headers": {"X-Trace-ID": {"$ref": "#/components/headers/TraceID"}}, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/BatchOperation"}} + } + }, + "401": {"$ref": "#/components/responses/ErrorResponse"}, + "404": {"$ref": "#/components/responses/ErrorResponse"}, + "500": {"$ref": "#/components/responses/ErrorResponse"} + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "opaque", + "description": "认证实现与 claim 格式不属于 v1 契约;服务端必须从已验证主体确定 tenant 与权限。" + } + }, + "parameters": { + "SiteID": { + "name": "site_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/LogicalID"}, + "description": "当前认证租户中的稳定站点 ID" + }, + "DeviceID": { + "name": "device_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/LogicalID"} + }, + "OperationID": { + "name": "operation_id", + "in": "path", + "required": true, + "schema": {"type": "string", "pattern": "^op_[0-9A-HJKMNP-TV-Z]{26}$"} + }, + "Cursor": { + "name": "cursor", + "in": "query", + "required": false, + "schema": {"type": "string", "minLength": 16, "maxLength": 512}, + "description": "不透明游标;客户端不得解析或构造" + }, + "Limit": { + "name": "limit", + "in": "query", + "required": false, + "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50} + }, + "ModalityFilter": { + "name": "modality", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/Modality"} + }, + "CapabilityFilter": { + "name": "capability", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/Capability"} + }, + "DesiredStateFilter": { + "name": "desired_state", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/DesiredState"} + }, + "ActualStateFilter": { + "name": "actual_state", + "in": "query", + "required": false, + "schema": {"$ref": "#/components/schemas/ActualState"} + }, + "IdempotencyKey": { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "minLength": 16, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "description": "同一认证主体、站点、operationId、key 和规范化 body 在 24 小时内返回同一结果;key 相同而 body 不同返回 idempotency_conflict。" + }, + "IfMatch": { + "name": "If-Match", + "in": "header", + "required": true, + "schema": {"type": "string", "minLength": 3, "maxLength": 128}, + "description": "使用最近一次 GET/写响应的 opaque ETag;不匹配返回 412 etag_mismatch。" + } + }, + "headers": { + "ETag": { + "description": "设备资源的不透明并发版本", + "schema": {"type": "string"} + }, + "TraceID": { + "description": "不含租户、主机或凭据的排查 ID", + "schema": {"type": "string", "minLength": 8, "maxLength": 128} + }, + "DeviceLocation": { + "description": "新建设备资源的相对路径", + "schema": {"type": "string", "pattern": "^/api/v1/sites/[^/]+/devices/[^/]+$"} + }, + "OperationLocation": { + "description": "批量操作资源的相对路径", + "schema": {"type": "string", "pattern": "^/api/v1/operations/op_[0-9A-HJKMNP-TV-Z]{26}$"} + } + }, + "responses": { + "CurrentDevice": { + "description": "设备当前表示;endpoint 与 credential ref 永不回显", + "headers": { + "ETag": {"$ref": "#/components/headers/ETag"}, + "X-Trace-ID": {"$ref": "#/components/headers/TraceID"} + }, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/Device"}} + } + }, + "CreatedDevice": { + "description": "设备已创建;启用设备仍需等待对账收敛", + "headers": { + "ETag": {"$ref": "#/components/headers/ETag"}, + "Location": {"$ref": "#/components/headers/DeviceLocation"}, + "X-Trace-ID": {"$ref": "#/components/headers/TraceID"} + }, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/Device"}} + } + }, + "AcceptedMutation": { + "description": "期望态/配置写入已接受,实际态异步收敛", + "headers": { + "ETag": {"$ref": "#/components/headers/ETag"}, + "X-Trace-ID": {"$ref": "#/components/headers/TraceID"} + }, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/MutationReceipt"}} + } + }, + "ErrorResponse": { + "description": "稳定错误;不返回内部堆栈、资源存在性或连接秘密", + "headers": {"X-Trace-ID": {"$ref": "#/components/headers/TraceID"}}, + "content": { + "application/problem+json": {"schema": {"$ref": "#/components/schemas/Problem"}} + } + } + }, + "schemas": { + "LogicalID": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$" + }, + "Modality": { + "type": "string", + "enum": ["video", "radar", "contact", "button", "wearable", "other"] + }, + "Capability": { + "type": "string", + "enum": ["video_capture", "audio_capture", "spatial_rule", "telemetry"] + }, + "DesiredState": { + "type": "string", + "enum": ["disabled", "enabled"] + }, + "ActualState": { + "type": "string", + "enum": ["pending", "online", "offline", "failed"] + }, + "AdapterStatus": { + "type": "string", + "enum": ["pending", "ready", "adapter_not_ready", "authentication_failed", "unavailable"] + }, + "ErrorCode": { + "type": "string", + "enum": [ + "invalid_request", + "unauthenticated", + "forbidden", + "not_found", + "conflict", + "precondition_required", + "etag_mismatch", + "idempotency_conflict", + "duplicate_serial_number", + "quota_exceeded", + "quota_projection_unavailable", + "quota_projection_invalid", + "area_policy_denied", + "area_policy_unavailable", + "adapter_not_ready", + "authentication_failed", + "endpoint_credentials_forbidden", + "batch_too_large", + "service_unavailable", + "internal_error" + ] + }, + "DeviceCreate": { + "type": "object", + "additionalProperties": false, + "required": ["serial_number", "name", "modality", "capabilities", "area_id"], + "properties": { + "serial_number": {"type": "string", "minLength": 1, "maxLength": 128}, + "name": {"type": "string", "minLength": 1, "maxLength": 200}, + "modality": {"$ref": "#/components/schemas/Modality"}, + "capabilities": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "uniqueItems": true, + "items": {"$ref": "#/components/schemas/Capability"}, + "description": "请求接入的能力集合;适配器探测不支持时以稳定错误拒绝或标记 adapter_not_ready。" + }, + "area_id": {"$ref": "#/components/schemas/LogicalID"}, + "endpoint_ref": { + "type": "string", + "format": "uri", + "minLength": 1, + "maxLength": 2048, + "writeOnly": true, + "description": "绝对 URI,禁止 userinfo;只在受控写路径使用。" + }, + "credential_ref": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "writeOnly": true, + "description": "不透明密钥引用,不是用户名、密码或 token。" + }, + "profile_token": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "writeOnly": true + }, + "desired_state": { + "allOf": [{"$ref": "#/components/schemas/DesiredState"}], + "default": "disabled" + } + }, + "allOf": [ + { + "if": { + "required": ["modality"], + "properties": {"modality": {"const": "video"}} + }, + "then": { + "properties": { + "capabilities": {"contains": {"const": "video_capture"}, "minContains": 1} + } + } + }, + { + "if": { + "required": ["capabilities"], + "properties": { + "capabilities": {"contains": {"const": "video_capture"}, "minContains": 1} + } + }, + "then": {"required": ["endpoint_ref", "credential_ref"]} + } + ] + }, + "DevicePatch": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 200}, + "area_id": {"$ref": "#/components/schemas/LogicalID"}, + "endpoint_ref": { + "type": "string", + "format": "uri", + "minLength": 1, + "maxLength": 2048, + "writeOnly": true, + "description": "绝对 URI,禁止 userinfo。" + }, + "credential_ref": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "writeOnly": true + }, + "profile_token": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "writeOnly": true + } + } + }, + "DesiredStateChange": { + "type": "object", + "additionalProperties": false, + "required": ["desired_state", "reason"], + "properties": { + "desired_state": {"$ref": "#/components/schemas/DesiredState"}, + "reason": {"type": "string", "minLength": 1, "maxLength": 500} + } + }, + "Device": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", "tenant_id", "site_id", "serial_number", "name", "modality", + "capabilities", "area_id", "desired_state", "actual_state", "adapter_status", + "endpoint_configured", "credential_configured", "generation", "observed_generation", + "converged", "failure_count", "projection_versions", "created_at", "updated_at" + ], + "properties": { + "id": {"$ref": "#/components/schemas/LogicalID"}, + "tenant_id": { + "allOf": [{"$ref": "#/components/schemas/LogicalID"}], + "readOnly": true, + "description": "只用于回显已授权上下文,不得由客户端写入。" + }, + "site_id": {"$ref": "#/components/schemas/LogicalID"}, + "serial_number": {"type": "string", "minLength": 1, "maxLength": 128}, + "name": {"type": "string", "minLength": 1, "maxLength": 200}, + "modality": {"$ref": "#/components/schemas/Modality"}, + "capabilities": { + "type": "array", + "items": {"$ref": "#/components/schemas/Capability"}, + "uniqueItems": true, + "maxItems": 16, + "readOnly": true + }, + "area_id": {"$ref": "#/components/schemas/LogicalID"}, + "desired_state": {"$ref": "#/components/schemas/DesiredState"}, + "actual_state": {"$ref": "#/components/schemas/ActualState"}, + "adapter_status": {"$ref": "#/components/schemas/AdapterStatus"}, + "endpoint_configured": {"type": "boolean", "readOnly": true}, + "credential_configured": {"type": "boolean", "readOnly": true}, + "generation": {"type": "integer", "format": "int64", "minimum": 1, "readOnly": true}, + "observed_generation": {"type": "integer", "format": "int64", "minimum": 0, "readOnly": true}, + "converged": { + "type": "boolean", + "readOnly": true, + "description": "期望代已被观察且当前无待重试;不等价于所有外部系统健康。" + }, + "failure_count": {"type": "integer", "minimum": 0, "readOnly": true}, + "next_attempt_at": {"type": ["string", "null"], "format": "date-time", "readOnly": true}, + "last_error_code": {"type": ["string", "null"], "maxLength": 128, "readOnly": true}, + "projection_versions": {"$ref": "#/components/schemas/ProjectionVersions"}, + "created_at": {"type": "string", "format": "date-time", "readOnly": true}, + "updated_at": {"type": "string", "format": "date-time", "readOnly": true} + } + }, + "ProjectionVersions": { + "type": "object", + "additionalProperties": false, + "required": ["quota_source_version", "area_policy_source_version", "synced_at"], + "properties": { + "quota_source_version": {"type": ["integer", "null"], "format": "int64", "minimum": 1, "readOnly": true}, + "area_policy_source_version": {"type": ["integer", "null"], "format": "int64", "minimum": 1, "readOnly": true}, + "synced_at": {"type": ["string", "null"], "format": "date-time", "readOnly": true} + } + }, + "SiteQuotaStatus": { + "type": "object", + "additionalProperties": false, + "required": ["status", "used_video_channels", "max_video_channels", "available_video_channels", "over_limit", "source_version", "synced_at"], + "properties": { + "status": {"type": "string", "enum": ["current", "unavailable", "invalid"]}, + "used_video_channels": {"type": "integer", "minimum": 0, "maximum": 128}, + "max_video_channels": {"type": ["integer", "null"], "minimum": 1, "maximum": 128}, + "available_video_channels": {"type": ["integer", "null"], "minimum": 0, "maximum": 128}, + "over_limit": {"type": "boolean"}, + "source_version": {"type": ["integer", "null"], "format": "int64", "minimum": 1}, + "synced_at": {"type": ["string", "null"], "format": "date-time"} + } + }, + "PageInfo": { + "type": "object", + "additionalProperties": false, + "required": ["limit", "has_more", "next_cursor"], + "properties": { + "limit": {"type": "integer", "minimum": 1, "maximum": 100}, + "has_more": {"type": "boolean"}, + "next_cursor": {"type": ["string", "null"], "maxLength": 512} + } + }, + "DevicePage": { + "type": "object", + "additionalProperties": false, + "required": ["items", "page", "quota"], + "properties": { + "items": {"type": "array", "maxItems": 100, "items": {"$ref": "#/components/schemas/Device"}}, + "page": {"$ref": "#/components/schemas/PageInfo"}, + "quota": {"$ref": "#/components/schemas/SiteQuotaStatus"} + } + }, + "MutationReceipt": { + "type": "object", + "additionalProperties": false, + "required": ["device", "accepted_at", "trace_id"], + "properties": { + "device": {"$ref": "#/components/schemas/Device"}, + "accepted_at": {"type": "string", "format": "date-time"}, + "trace_id": {"type": "string", "minLength": 8, "maxLength": 128} + } + }, + "BatchDesiredStateItem": { + "type": "object", + "additionalProperties": false, + "required": ["device_id", "etag", "desired_state"], + "properties": { + "device_id": {"$ref": "#/components/schemas/LogicalID"}, + "etag": {"type": "string", "minLength": 3, "maxLength": 128}, + "desired_state": {"$ref": "#/components/schemas/DesiredState"} + } + }, + "BatchDesiredStateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["items", "reason"], + "properties": { + "items": { + "type": "array", + "minItems": 1, + "maxItems": 128, + "items": {"$ref": "#/components/schemas/BatchDesiredStateItem"} + }, + "reason": {"type": "string", "minLength": 1, "maxLength": 500} + } + }, + "BatchItemResult": { + "type": "object", + "additionalProperties": false, + "required": ["device_id", "status", "error_code", "message", "generation"], + "properties": { + "device_id": {"$ref": "#/components/schemas/LogicalID"}, + "status": {"type": "string", "enum": ["accepted", "rejected", "succeeded", "failed"]}, + "error_code": {"oneOf": [{"$ref": "#/components/schemas/ErrorCode"}, {"type": "null"}]}, + "message": {"type": ["string", "null"], "maxLength": 500}, + "generation": {"type": ["integer", "null"], "format": "int64", "minimum": 1} + } + }, + "BatchOperation": { + "type": "object", + "additionalProperties": false, + "required": ["id", "status", "submitted_at", "completed_at", "results", "trace_id"], + "properties": { + "id": {"type": "string", "pattern": "^op_[0-9A-HJKMNP-TV-Z]{26}$"}, + "status": {"type": "string", "enum": ["queued", "running", "succeeded", "partially_succeeded", "failed"]}, + "submitted_at": {"type": "string", "format": "date-time"}, + "completed_at": {"type": ["string", "null"], "format": "date-time"}, + "results": {"type": "array", "maxItems": 128, "items": {"$ref": "#/components/schemas/BatchItemResult"}}, + "trace_id": {"type": "string", "minLength": 8, "maxLength": 128} + } + }, + "FieldError": { + "type": "object", + "additionalProperties": false, + "required": ["field", "code", "message"], + "properties": { + "field": {"type": "string", "maxLength": 256}, + "code": {"type": "string", "maxLength": 128}, + "message": {"type": "string", "maxLength": 500} + } + }, + "Problem": { + "type": "object", + "additionalProperties": false, + "required": ["type", "title", "status", "code", "message", "trace_id", "field_errors"], + "properties": { + "type": {"type": "string", "format": "uri-reference"}, + "title": {"type": "string", "maxLength": 200}, + "status": {"type": "integer", "minimum": 400, "maximum": 599}, + "code": {"$ref": "#/components/schemas/ErrorCode"}, + "message": {"type": "string", "maxLength": 1000}, + "trace_id": {"type": "string", "minLength": 8, "maxLength": 128}, + "field_errors": {"type": "array", "maxItems": 100, "items": {"$ref": "#/components/schemas/FieldError"}} + } + } + } + } +} diff --git a/docs/contracts/site-quota-v1.sql b/docs/contracts/site-quota-v1.sql new file mode 100644 index 0000000..347fad5 --- /dev/null +++ b/docs/contracts/site-quota-v1.sql @@ -0,0 +1,32 @@ +-- YoVision Bell -> Sense site quota projection contract v1. +-- +-- This file freezes the PostgreSQL object signature and privileges. It is not a +-- migration: Bell must create the source table and install this view in a later +-- implementation task. The source table must enforce a default of 16 and a +-- CHECK constraint that keeps max_video_channels between 1 and 128 inclusive. +-- source_version must increase monotonically for each logical site whenever a +-- projected value changes. + +CREATE VIEW bell.site_quota_v1 ( + tenant_id, + site_id, + max_video_channels, + source_version, + source_updated_at +) AS +SELECT + site.tenant_id, + site.id, + site.max_video_channels, + site.version, + site.updated_at +FROM bell.sites AS site +WHERE site.deleted_at IS NULL; + +COMMENT ON VIEW bell.site_quota_v1 IS + 'v1 read-only site video quota projection owned by Bell and consumed by Sense'; + +ALTER VIEW bell.site_quota_v1 OWNER TO bell_app; +REVOKE ALL PRIVILEGES ON TABLE bell.site_quota_v1 FROM PUBLIC; +GRANT USAGE ON SCHEMA bell TO sense_app; +GRANT SELECT ON TABLE bell.site_quota_v1 TO sense_app; diff --git a/docs/current-state.md b/docs/current-state.md index ced0a63..39551d6 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -4,7 +4,7 @@ ## 当前阶段 -- 阶段:M0 指定摄像头型号准入已完成;M1 的“一实机 + 四合成源”实验室软件闭环已通过,但五条独立真实上游和生产 SLA 尚未验收。 +- 阶段:M0 指定摄像头型号准入已完成;M1 的“一实机 + 四合成源”实验室软件闭环已通过;M2 已完成首个控制面/配额契约门禁,但五条独立真实上游和生产 SLA 尚未验收。 - 生产代码:Sense 已包含可构建进程、SQLite 台账、标准 ONVIF SOAP/WS-Security adapter、凭据引用、MediaMTX 生成客户端、对账、探活和 MediaMTX 重启重建;公共设备管理 API 与生产部署能力仍未实现。 - 默认容量:16 路;单站点本阶段上限 128 路,必须横向分片。 @@ -17,6 +17,7 @@ - T-006 正式使用 1 台准入实机和 4 个独立合成 publisher 连续观察 `1806.6 s` / 180 次采样,四类恢复均通过,最大与最终 `unconverged` 均为 0;详细证据见 `docs/research/sense-5-stream-integration.md`。 - `docs/raw/01`~`08` 已记录需求、分析、方案、客户场景、事件比对和三系统职责。 - `docs/raw/contracts/event-v0.1.schema.json` 已冻结,并有多份示例与语义说明。 +- `docs/contracts/sense-control-v1.openapi.json` 已冻结站点作用域的设备查询、创建、修改、启停与最多 128 项批量操作;`site-quota-v1.sql` 已冻结 Bell 所有、Sense 只读的配额视图签名。两者当前只有契约测试,HTTP handler、认证、Bell 表和 migration 尚未实现。 - harness coding 文档、上下文清单、Gitea Issue/PR 模板和治理脚本已接入。 - Gitea 已初始化 12 个协作标签;`status/waiting` 用于依赖或外部条件未满足的未领取任务,实时可领取状态必须从 Gitea 查询,不在本文复制。 - T-002 已关闭架构影响型问题:首期 S2 民办寄宿学校、客户侧私有部署、ONVIF/RTSP、NVIDIA 主路径、自研 Bell、Web/H5 与客户侧证据存储等边界已批准。 @@ -60,7 +61,7 @@ Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz` 与 `/readyz` 运维探 ## 下一步 -下一步按 Gitea 依赖关系选择后续 M1 任务;客户授权、借用或租赁条件具备后执行 T-007 五条独立真实上游现场门禁。T-006 的合成结果不解除 T-007,也不形成容量或生产 SLA 承诺。实时领取状态仍以 Gitea 为准。 +下一步先按 Gitea 流程建立并领取 T-008 的实现后续任务:实现认证 tenant 上下文、Sense 设备管理 handler/PostgreSQL repository,以及 Bell 配额源表、migration 和 `bell.site_quota_v1`。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁。T-006 的合成结果不解除 T-007,也不形成容量或生产 SLA 承诺。实时领取状态仍以 Gitea 为准。 ## 已知风险 diff --git a/docs/tasks/T-008.md b/docs/tasks/T-008.md index 539d6cf..ef854e1 100644 --- a/docs/tasks/T-008.md +++ b/docs/tasks/T-008.md @@ -3,7 +3,7 @@ id: T-008 title: 冻结 Sense 设备管理 API 与 Bell 配额只读投影契约 phase: 2 deps: [T-006] -status: DOING +status: DONE created: 2026-08-07 issue: 27 context_ref: cc47e1463849151bee3c349cc53971181e262043 @@ -50,7 +50,7 @@ T-006 已证明 Sense 能用 SQLite 期望态驱动 1 路真实摄像头与 4 ## 验收要点 -- 任务相关验证:OpenAPI/SQL/说明互相引用且无悬空路径;运行 `python -m unittest tests.test_sense_control_contract`,正例全通过,关键约束负例均产生预期错误;JSON 可由标准库解析。 +- 任务相关验证:OpenAPI/SQL/说明互相引用且无悬空路径;运行 `python -m unittest discover -s tests -p "test_sense_control_contract.py"`,正例全通过,关键约束负例均产生预期错误;JSON 可由标准库解析。 - 完整门禁:运行 `./init.ps1`、`python scripts/validate_agent_context.py`、`python -m unittest discover -s tests -p "test_*.py"`、`python scripts/validate_harness_governance.py` 和 `git diff --check`,全部通过。 - 人工 / 设备验收:不需要摄像头或 UI 人工验收;由架构/后端负责人核对资源所有权、失败语义、幂等/并发、隐私和未来分库退出路线。 - 构建产物:`docs/contracts/sense-control-v1.openapi.json`、`docs/contracts/site-quota-v1.sql`、`docs/contracts/README.md`;本任务不生成部署二进制或数据库 migration。 @@ -70,6 +70,14 @@ T-006 已证明 Sense 能用 SQLite 期望态驱动 1 路真实摄像头与 4 ## 执行记录 +### 2026-08-07 完成契约冻结 + +- 冻结 OpenAPI 3.1 `1.0.0`:7 个站点作用域/批量操作 endpoint,tenant 来自认证上下文,列表默认 50/最大 100,批量最大 128,并明确 Bearer、幂等键、ETag、逐项结果和稳定错误码。 +- 设备创建使用 `modality + capabilities` 且 ID 由服务端生成;连接和凭据引用只写不读,v1 不暴露删除或 Site/Area/RBAC/配额 CRUD,也不把期望态受理误报为实际态收敛。 +- 冻结 `bell.site_quota_v1` 的 5 列签名、`bell_app` 所有权及 `sense_app` 的最小 `USAGE + SELECT` 权限;配额默认 16、范围 1~128,投影失败只阻断相关新增/启用,已有链路保持不变。 +- 增加 8 个标准库契约测试,正例覆盖 OpenAPI/SQL/说明一致性,负例证明缺失认证、客户端自报 tenant、101 页上限、129 项批量、敏感字段回显和 Bell 写权限都会被拒绝。 +- `./init.ps1` 通过;`python -m unittest discover -s tests -p "test_*.py"` 共 26 项通过;JSON 标准库解析、上下文校验、治理校验和 `git diff --check` 均通过。本任务不需要摄像头或 UI 人工验收。 + ### 2026-08-07 领取任务 - dispatcher `ila` 将任务分配给 `codex`;`context_ref` 为 `cc47e1463849151bee3c349cc53971181e262043`,claim 为 `claims/T-008`,工作分支为 `agent/codex/T-008`。 diff --git a/tests/test_sense_control_contract.py b/tests/test_sense_control_contract.py new file mode 100644 index 0000000..fbfa249 --- /dev/null +++ b/tests/test_sense_control_contract.py @@ -0,0 +1,302 @@ +"""Repository-local invariants for the frozen Sense control-plane contract.""" + +from __future__ import annotations + +import copy +import json +import re +import unittest +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +OPENAPI_PATH = ROOT / "docs" / "contracts" / "sense-control-v1.openapi.json" +QUOTA_SQL_PATH = ROOT / "docs" / "contracts" / "site-quota-v1.sql" +CONTRACT_README_PATH = ROOT / "docs" / "contracts" / "README.md" + +HTTP_METHODS = {"get", "post", "put", "patch", "delete", "options", "head", "trace"} +EXPECTED_OPERATIONS = { + ("get", "/api/v1/sites/{site_id}/devices"), + ("post", "/api/v1/sites/{site_id}/devices"), + ("get", "/api/v1/sites/{site_id}/devices/{device_id}"), + ("patch", "/api/v1/sites/{site_id}/devices/{device_id}"), + ("put", "/api/v1/sites/{site_id}/devices/{device_id}/desired-state"), + ("post", "/api/v1/sites/{site_id}/devices:batchDesiredState"), + ("get", "/api/v1/operations/{operation_id}"), +} + +EXPECTED_ENUMS = { + "Modality": ["video", "radar", "contact", "button", "wearable", "other"], + "Capability": ["video_capture", "audio_capture", "spatial_rule", "telemetry"], + "DesiredState": ["disabled", "enabled"], + "ActualState": ["pending", "online", "offline", "failed"], +} + +REQUIRED_ERROR_CODES = { + "invalid_request", + "unauthenticated", + "forbidden", + "not_found", + "precondition_required", + "etag_mismatch", + "idempotency_conflict", + "duplicate_serial_number", + "quota_exceeded", + "quota_projection_unavailable", + "quota_projection_invalid", + "area_policy_denied", + "area_policy_unavailable", + "adapter_not_ready", + "endpoint_credentials_forbidden", + "batch_too_large", +} + + +def load_openapi() -> dict[str, Any]: + return json.loads(OPENAPI_PATH.read_text(encoding="utf-8")) + + +def resolve_local_ref(document: dict[str, Any], value: dict[str, Any]) -> dict[str, Any]: + """Resolve one local JSON pointer; the contract only needs direct component refs.""" + ref = value.get("$ref") + if not ref: + return value + if not ref.startswith("#/"): + raise ValueError(f"external ref is not allowed here: {ref}") + current: Any = document + for token in ref[2:].split("/"): + current = current[token.replace("~1", "/").replace("~0", "~")] + if not isinstance(current, dict): + raise ValueError(f"ref does not resolve to an object: {ref}") + return current + + +def operation_parameters( + document: dict[str, Any], operation: dict[str, Any] +) -> dict[str, dict[str, Any]]: + result: dict[str, dict[str, Any]] = {} + for value in operation.get("parameters", []): + parameter = resolve_local_ref(document, value) + result[parameter.get("name", "")] = parameter + return result + + +def validate_openapi(document: dict[str, Any]) -> list[str]: + errors: list[str] = [] + if document.get("openapi") != "3.1.0": + errors.append("OpenAPI version must be 3.1.0") + if document.get("info", {}).get("version") != "1.0.0": + errors.append("contract version must be 1.0.0") + + top_security = document.get("security") + if not top_security or not any("bearerAuth" in item for item in top_security): + errors.append("top-level bearerAuth is required") + + operations: dict[tuple[str, str], dict[str, Any]] = {} + operation_ids: list[str] = [] + for path, path_item in document.get("paths", {}).items(): + if not path.startswith("/api/v1/"): + errors.append(f"unversioned path: {path}") + for method, operation in path_item.items(): + if method not in HTTP_METHODS: + continue + key = (method, path) + operations[key] = operation + operation_id = operation.get("operationId") + if not operation_id: + errors.append(f"missing operationId: {method.upper()} {path}") + else: + operation_ids.append(operation_id) + if not operation.get("responses"): + errors.append(f"missing responses: {method.upper()} {path}") + effective_security = operation.get("security", top_security) + if not effective_security or not any( + "bearerAuth" in item for item in effective_security + ): + errors.append(f"bearerAuth required: {method.upper()} {path}") + + missing_operations = EXPECTED_OPERATIONS - set(operations) + unexpected_operations = set(operations) - EXPECTED_OPERATIONS + if missing_operations: + errors.append(f"missing operations: {sorted(missing_operations)}") + if unexpected_operations: + errors.append(f"unexpected operations: {sorted(unexpected_operations)}") + if len(operation_ids) != len(set(operation_ids)): + errors.append("operationId values must be unique") + + list_operation = operations.get(("get", "/api/v1/sites/{site_id}/devices"), {}) + list_parameters = operation_parameters(document, list_operation) + if "cursor" not in list_parameters: + errors.append("device list requires cursor pagination") + limit = list_parameters.get("limit", {}).get("schema", {}) + if limit.get("default") != 50 or limit.get("maximum") != 100: + errors.append("device list limit must default to 50 and have maximum 100") + + header_requirements = { + ("post", "/api/v1/sites/{site_id}/devices"): "Idempotency-Key", + ("post", "/api/v1/sites/{site_id}/devices:batchDesiredState"): "Idempotency-Key", + ("patch", "/api/v1/sites/{site_id}/devices/{device_id}"): "If-Match", + ("put", "/api/v1/sites/{site_id}/devices/{device_id}/desired-state"): "If-Match", + } + for key, header in header_requirements.items(): + parameters = operation_parameters(document, operations.get(key, {})) + parameter = parameters.get(header, {}) + if parameter.get("in") != "header" or parameter.get("required") is not True: + errors.append(f"{key[0].upper()} {key[1]} requires header {header}") + + for key in ( + ("patch", "/api/v1/sites/{site_id}/devices/{device_id}"), + ("put", "/api/v1/sites/{site_id}/devices/{device_id}/desired-state"), + ): + responses = operations.get(key, {}).get("responses", {}) + if "412" not in responses or "428" not in responses: + errors.append(f"{key[0].upper()} {key[1]} must define 412 and 428") + + schemas = document.get("components", {}).get("schemas", {}) + batch_items = ( + schemas.get("BatchDesiredStateRequest", {}) + .get("properties", {}) + .get("items", {}) + ) + if batch_items.get("maxItems") != 128: + errors.append("batch desired-state request must have maximum 128 items") + + client_schemas = ("DeviceCreate", "DevicePatch", "DesiredStateChange", "BatchDesiredStateRequest") + for schema_name in client_schemas: + if "tenant_id" in schemas.get(schema_name, {}).get("properties", {}): + errors.append(f"tenant_id must not be client-controlled in {schema_name}") + + create_properties = schemas.get("DeviceCreate", {}).get("properties", {}) + if "id" in create_properties: + errors.append("device id must be generated by the service") + required_create = set(schemas.get("DeviceCreate", {}).get("required", [])) + if not {"modality", "capabilities"}.issubset(required_create): + errors.append("device create requires modality and capabilities") + + for schema_name in ("DeviceCreate", "DevicePatch"): + properties = schemas.get(schema_name, {}).get("properties", {}) + for field in ("endpoint_ref", "credential_ref", "profile_token"): + if properties.get(field, {}).get("writeOnly") is not True: + errors.append(f"{schema_name}.{field} must be writeOnly") + + output_properties = schemas.get("Device", {}).get("properties", {}) + tenant = output_properties.get("tenant_id", {}) + if tenant.get("readOnly") is not True: + errors.append("Device.tenant_id must be readOnly") + forbidden_output_fields = { + "endpoint_ref", + "credential_ref", + "profile_token", + "active_profile", + "stream_uri", + "password", + "token", + } + leaked_fields = forbidden_output_fields & set(output_properties) + if leaked_fields: + errors.append(f"Device leaks sensitive fields: {sorted(leaked_fields)}") + + for schema_name, expected in EXPECTED_ENUMS.items(): + actual = schemas.get(schema_name, {}).get("enum") + if actual != expected: + errors.append(f"{schema_name} enum drift: {actual!r}") + + error_codes = set(schemas.get("ErrorCode", {}).get("enum", [])) + missing_error_codes = REQUIRED_ERROR_CODES - error_codes + if missing_error_codes: + errors.append(f"missing stable error codes: {sorted(missing_error_codes)}") + return errors + + +def validate_quota_sql(sql: str) -> list[str]: + errors: list[str] = [] + normalized = re.sub(r"\s+", " ", sql.lower()).strip() + expected_signature = ( + "create view bell.site_quota_v1 ( tenant_id, site_id, " + "max_video_channels, source_version, source_updated_at ) as" + ) + if expected_signature not in normalized: + errors.append("bell.site_quota_v1 column signature is not frozen as expected") + if "alter view bell.site_quota_v1 owner to bell_app" not in normalized: + errors.append("Bell role must own the quota view") + if "grant usage on schema bell to sense_app" not in normalized: + errors.append("Sense role needs schema USAGE") + if "grant select on table bell.site_quota_v1 to sense_app" not in normalized: + errors.append("Sense role needs SELECT on only the quota view") + if "revoke all privileges on table bell.site_quota_v1 from public" not in normalized: + errors.append("PUBLIC privileges must be revoked") + if re.search( + r"grant\s+(?:all(?:\s+privileges)?|insert|update|delete|truncate|references|trigger)\b", + normalized, + ): + errors.append("quota view grants a forbidden write or broad privilege") + if re.search(r"grant\s+select\s+on\s+table\s+bell\.sites\b", normalized): + errors.append("Sense must not receive SELECT on the Bell source table") + return errors + + +class SenseControlContractTests(unittest.TestCase): + def setUp(self) -> None: + self.document = load_openapi() + self.quota_sql = QUOTA_SQL_PATH.read_text(encoding="utf-8") + + def test_frozen_openapi_invariants(self) -> None: + self.assertEqual([], validate_openapi(self.document)) + + def test_frozen_quota_projection_invariants(self) -> None: + self.assertEqual([], validate_quota_sql(self.quota_sql)) + + def test_contract_narrative_records_failure_and_compatibility_semantics(self) -> None: + text = CONTRACT_README_PATH.read_text(encoding="utf-8") + for marker in ( + "默认 16", + "1~128", + "至少保存 24 小时", + "404 not_found", + "已有流保持运行", + "必须发布新版本", + ): + self.assertIn(marker, text) + + def test_validator_rejects_missing_authentication(self) -> None: + changed = copy.deepcopy(self.document) + changed.pop("security", None) + self.assertTrue(any("bearerAuth" in item for item in validate_openapi(changed))) + + def test_validator_rejects_client_controlled_tenant(self) -> None: + changed = copy.deepcopy(self.document) + changed["components"]["schemas"]["DeviceCreate"]["properties"]["tenant_id"] = { + "type": "string" + } + self.assertTrue(any("tenant_id" in item for item in validate_openapi(changed))) + + def test_validator_rejects_unbounded_list_or_batch(self) -> None: + changed = copy.deepcopy(self.document) + changed["components"]["parameters"]["Limit"]["schema"]["maximum"] = 101 + changed["components"]["schemas"]["BatchDesiredStateRequest"]["properties"][ + "items" + ]["maxItems"] = 129 + errors = validate_openapi(changed) + self.assertTrue(any("maximum 100" in item for item in errors)) + self.assertTrue(any("maximum 128" in item for item in errors)) + + def test_validator_rejects_sensitive_output_or_writable_credential_ref(self) -> None: + changed = copy.deepcopy(self.document) + changed["components"]["schemas"]["DevicePatch"]["properties"]["credential_ref"][ + "writeOnly" + ] = False + changed["components"]["schemas"]["Device"]["properties"]["stream_uri"] = { + "type": "string" + } + errors = validate_openapi(changed) + self.assertTrue(any("credential_ref" in item for item in errors)) + self.assertTrue(any("sensitive fields" in item for item in errors)) + + def test_validator_rejects_write_privilege(self) -> None: + changed = self.quota_sql + "\nGRANT ALL PRIVILEGES ON bell.site_quota_v1 TO sense_app;\n" + self.assertTrue(any("forbidden" in item for item in validate_quota_sql(changed))) + + +if __name__ == "__main__": + unittest.main()