docs(contract): freeze Sense control API v1 [T-008]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled

This commit is contained in:
QiuSW
2026-08-07 17:12:15 +08:00
parent 1c63143106
commit b70df147ea
9 changed files with 1144 additions and 21 deletions
+69
View File
@@ -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 的验证。
@@ -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"}}
}
}
}
}
}
+32
View File
@@ -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;