fix: 统一源配置契约字段命名 (#148)

This commit is contained in:
QiuSW
2026-08-31 08:53:22 +08:00
parent 67391acb16
commit 2e61167500
13 changed files with 158 additions and 158 deletions
+5 -5
View File
@@ -15,11 +15,11 @@
## 消费规则 ## 消费规则
1. 先按 JSON Schema 校验,再执行跨字段语义校验。 1. 先按 JSON Schema 校验,再执行跨字段语义校验。
2. `schema` 必须精确等于 `yovision.source-config/v1`;未知主版本不得降级猜测。 2. `schema_version` 必须精确等于 `yovision.source-config/v1`;未知主版本不得降级猜测。
3. `ruleSet.profileBinding` 必须与 `profile.id/width/height` 完全一致。 3. `rule_set.profile_binding` 必须与 `profile.id/width/height` 完全一致。
4. `ruleSet.state != active` 时不得运行任何规则;`recalibration_required` 表示 Profile 规格变化后需重新标定。 4. `rule_set.state != active` 时不得运行任何规则;`recalibration_required` 表示 Profile 规格变化后需重新标定。
5. `areas` 与 `directionalLines` 的 `id` 在同一快照内必须全局唯一;多边形必须非退化,线段起终点不得相同。 5. `areas` 与 `directional_lines` 的 `id` 在同一快照内必须全局唯一;多边形必须非退化,线段起终点不得相同。
6. `effectiveAt` 不得早于 `publishedAt`。 6. `effective_at` 不得早于 `published_at`。
7. `integrity.value` 是移除顶层 `integrity` 后,对 RFC 8785 JCS 规范化 JSON 字节计算的 SHA-256 小写十六进制摘要。生产消费者应使用合规 JCS 实现;仓库样例只使用 JCS 简单类型子集。 7. `integrity.value` 是移除顶层 `integrity` 后,对 RFC 8785 JCS 规范化 JSON 字节计算的 SHA-256 小写十六进制摘要。生产消费者应使用合规 JCS 实现;仓库样例只使用 JCS 简单类型子集。
`media.ref` 是 connector 解析的无凭据不透明引用,固定以 `media:` 开头。它不能包含 URI authority、用户名、密码、查询参数、fragment、Windows 盘符或文件系统路径。RTSP 凭据交换与机器身份不属于本契约。 `media.ref` 是 connector 解析的无凭据不透明引用,固定以 `media:` 开头。它不能包含 URI authority、用户名、密码、查询参数、fragment、Windows 盘符或文件系统路径。RTSP 凭据交换与机器身份不属于本契约。
+5 -5
View File
@@ -3,15 +3,15 @@
## 兼容规则 ## 兼容规则
- v1 发布后只允许在预留的顶层 `extensions` 对象中增加命名空间化、非秘密的可选扩展。消费者必须忽略自己不认识的扩展命名空间,但仍须拒绝当前 Schema 或语义规则标记为非法的输入;发布扩展时应同步生产者/消费者测试。v1 核心对象保持封闭,不能通过新增核心字段规避新主版本。 - v1 发布后只允许在预留的顶层 `extensions` 对象中增加命名空间化、非秘密的可选扩展。消费者必须忽略自己不认识的扩展命名空间,但仍须拒绝当前 Schema 或语义规则标记为非法的输入;发布扩展时应同步生产者/消费者测试。v1 核心对象保持封闭,不能通过新增核心字段规避新主版本。
- 删除字段、把可选改为必填、收紧已发布取值范围,或改变字段类型、单位、坐标系、Profile 绑定、revision、状态及媒体引用语义,均为破坏性变化,必须发布新主版本目录和新的 `schema` 值。 - 删除字段、把可选改为必填、收紧已发布取值范围,或改变字段类型、单位、坐标系、Profile 绑定、revision、状态及媒体引用语义,均为破坏性变化,必须发布新主版本目录和新的 `schema_version` 值。
- 未知主版本必须安全拒绝并保留最后一个已验证配置。不得把未知版本转换成 v1,也不得继续启用来自未知版本的规则。 - 未知主版本必须安全拒绝并保留最后一个已验证配置。不得把未知版本转换成 v1,也不得继续启用来自未知版本的规则。
- v1 的坐标始终是相对于 `profile.width × profile.height` 图像平面的 0–1 归一化坐标;原点在左上,x 向右、y 向下。该语义不得在 v1 内改变。 - v1 的坐标始终是相对于 `profile.width × profile.height` 图像平面的 0–1 归一化坐标;原点在左上,x 向右、y 向下。该语义不得在 v1 内改变。
## revision 与生效 ## revision 与生效
- `(configId, revision)` 唯一标识一个不可变快照;同一 `configId` 的新发布必须使用严格递增的 `revision`。 - `(config_id, revision)` 唯一标识一个不可变快照;同一 `config_id` 的新发布必须使用严格递增的 `revision`。
- 消费者仅在 Schema、语义和完整性均通过后,按 `effectiveAt` 原子切换整个快照。重复收到同一 revision 应幂等处理;更小 revision 应拒绝为陈旧配置。 - 消费者仅在 Schema、语义和完整性均通过后,按 `effective_at` 原子切换整个快照。重复收到同一 revision 应幂等处理;更小 revision 应拒绝为陈旧配置。
- Profile ID、分辨率或编码变化时,生产者必须发布新 revision。已有几何尚未按新 Profile 校准时,必须设置 `ruleSet.state = recalibration_required`;消费者不得启用其中规则。 - Profile ID、分辨率或编码变化时,生产者必须发布新 revision。已有几何尚未按新 Profile 校准时,必须设置 `rule_set.state = recalibration_required`;消费者不得启用其中规则。
- 新 revision 校验失败或未到生效时间时,消费者保留上一份已验证且仍有效的 active revision。 - 新 revision 校验失败或未到生效时间时,消费者保留上一份已验证且仍有效的 active revision。
## 支持周期 ## 支持周期
@@ -22,7 +22,7 @@
## 回退 ## 回退
1. 停止分发有问题的新主版本或新 revision。 1. 停止分发有问题的新主版本或新 revision。
2. 重新发布上一主版本的最后一个已验证快照;若仍为同一 `configId`,必须使用该主版本下新的、更大 revision,不能覆盖历史 revision。 2. 重新发布上一主版本的最后一个已验证快照;若仍为同一 `config_id`,必须使用该主版本下新的、更大 revision,不能覆盖历史 revision。
3. Brain 通过完整 Schema、语义和摘要校验后原子切回;切换前继续使用最后一个有效快照,或在没有有效快照时保持规则停用。 3. Brain 通过完整 Schema、语义和摘要校验后原子切回;切换前继续使用最后一个有效快照,或在没有有效快照时保持规则停用。
4. 记录失败版本和拒绝原因,但不得记录媒体凭据或完整客户配置。 4. 记录失败版本和拒绝原因,但不得记录媒体凭据或完整客户配置。
@@ -1,13 +1,13 @@
{ {
"schema": "yovision.source-config/v1", "schema_version": "yovision.source-config/v1",
"configId": "school-east-entry-01", "config_id": "school-east-entry-01",
"revision": 7, "revision": 7,
"publishedAt": "2026-08-31T00:10:00Z", "published_at": "2026-08-31T00:10:00Z",
"effectiveAt": "2026-08-31T00:15:00Z", "effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"}, "site": {"id": "site-east"},
"logicalDevice": {"id": "entry-camera-01"}, "logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"}, "media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
"ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [{"id": "bad-area", "version": 1, "kind": "danger_area", "enabled": true, "points": [{"x": 0, "y": 0}, {"x": 1.2, "y": 0}, {"x": 0, "y": 1}]}], "directionalLines": []}, "rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [{"id": "bad-area", "version": 1, "kind": "danger_area", "enabled": true, "points": [{"x": 0, "y": 0}, {"x": 1.2, "y": 0}, {"x": 0, "y": 1}]}], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
} }
@@ -1,13 +1,13 @@
{ {
"schema": "yovision.source-config/v1", "schema_version": "yovision.source-config/v1",
"configId": "school-east-entry-01", "config_id": "school-east-entry-01",
"revision": 7, "revision": 7,
"publishedAt": "2026-08-31T00:10:00Z", "published_at": "2026-08-31T00:10:00Z",
"effectiveAt": "2026-08-31T00:15:00Z", "effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"}, "site": {"id": "site-east"},
"logicalDevice": {"id": "entry-camera-01"}, "logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp", "password": null}, "media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp", "password": null},
"ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directionalLines": []}, "rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
} }
@@ -1,13 +1,13 @@
{ {
"schema": "yovision.source-config/v1", "schema_version": "yovision.source-config/v1",
"configId": "school-east-entry-01", "config_id": "school-east-entry-01",
"revision": 7, "revision": 7,
"publishedAt": "2026-08-31T00:10:00Z", "published_at": "2026-08-31T00:10:00Z",
"effectiveAt": "2026-08-31T00:15:00Z", "effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"}, "site": {"id": "site-east"},
"logicalDevice": {"id": "entry-camera-01"}, "logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "C:\\customers\\school-east\\camera-01", "transport": "rtsp"}, "media": {"ref": "C:\\customers\\school-east\\camera-01", "transport": "rtsp"},
"ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directionalLines": []}, "rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
} }
@@ -1,13 +1,13 @@
{ {
"schema": "yovision.source-config/v1", "schema_version": "yovision.source-config/v1",
"configId": "school-east-entry-01", "config_id": "school-east-entry-01",
"revision": 7, "revision": 7,
"publishedAt": "2026-08-31T00:10:00Z", "published_at": "2026-08-31T00:10:00Z",
"effectiveAt": "2026-08-31T00:15:00Z", "effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"}, "site": {"id": "site-east"},
"logicalDevice": {"id": "entry-camera-01"}, "logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"}, "media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
"ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1280, "height": 720}, "areas": [], "directionalLines": []}, "rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1280, "height": 720}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
} }
@@ -1,13 +1,13 @@
{ {
"schema": "yovision.source-config/v1", "schema_version": "yovision.source-config/v1",
"configId": "school-east-entry-01", "config_id": "school-east-entry-01",
"revision": 7, "revision": 7,
"publishedAt": "2026-08-31T00:10:00Z", "published_at": "2026-08-31T00:10:00Z",
"effectiveAt": "2026-08-31T00:15:00Z", "effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"}, "site": {"id": "site-east"},
"logicalDevice": {"id": "entry-camera-01"}, "logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main?token=", "transport": "rtsp"}, "media": {"ref": "media:site-east/entry-01/main?token=", "transport": "rtsp"},
"ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directionalLines": []}, "rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
} }
@@ -1,13 +1,13 @@
{ {
"schema": "yovision.source-config/v2", "schema_version": "yovision.source-config/v2",
"configId": "school-east-entry-01", "config_id": "school-east-entry-01",
"revision": 7, "revision": 7,
"publishedAt": "2026-08-31T00:10:00Z", "published_at": "2026-08-31T00:10:00Z",
"effectiveAt": "2026-08-31T00:15:00Z", "effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"}, "site": {"id": "site-east"},
"logicalDevice": {"id": "entry-camera-01"}, "logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"}, "media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
"ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directionalLines": []}, "rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
} }
@@ -1,13 +1,13 @@
{ {
"schema": "yovision.source-config/v1", "schema_version": "yovision.source-config/v1",
"configId": "school-east-entry-01", "config_id": "school-east-entry-01",
"revision": 7, "revision": 7,
"publishedAt": "2026-08-31T00:10:00Z", "published_at": "2026-08-31T00:10:00Z",
"effectiveAt": "2026-08-31T00:15:00Z", "effective_at": "2026-08-31T00:15:00Z",
"site": { "site": {
"id": "site-east" "id": "site-east"
}, },
"logicalDevice": { "logical_device": {
"id": "entry-camera-01" "id": "entry-camera-01"
}, },
"profile": { "profile": {
@@ -15,17 +15,17 @@
"width": 1920, "width": 1920,
"height": 1080, "height": 1080,
"encoding": "H264", "encoding": "H264",
"frameRate": 25 "frame_rate": 25
}, },
"media": { "media": {
"ref": "media:site-east/entry-01/main", "ref": "media:site-east/entry-01/main",
"transport": "rtsp" "transport": "rtsp"
}, },
"ruleSet": { "rule_set": {
"version": "entry-rules-7", "version": "entry-rules-7",
"state": "active", "state": "active",
"profileBinding": { "profile_binding": {
"profileId": "main-stream", "profile_id": "main-stream",
"width": 1920, "width": 1920,
"height": 1080 "height": 1080
}, },
@@ -43,7 +43,7 @@
] ]
} }
], ],
"directionalLines": [ "directional_lines": [
{ {
"id": "entry-line", "id": "entry-line",
"version": 2, "version": 2,
@@ -51,12 +51,12 @@
"enabled": true, "enabled": true,
"start": {"x": 0.2, "y": 0.5}, "start": {"x": 0.2, "y": 0.5},
"end": {"x": 0.8, "y": 0.5}, "end": {"x": 0.8, "y": 0.5},
"triggerDirection": "left_to_right" "trigger_direction": "left_to_right"
} }
] ]
}, },
"integrity": { "integrity": {
"algorithm": "sha256", "algorithm": "sha256",
"value": "b35a4f345162b059d6912cca6340a6714826b7921662f4f28a91ef29c5476cb8" "value": "3336fe595bf1401b1024ac0c95c31e1655228485465a4527900fcea2c713acfe"
} }
} }
@@ -1,13 +1,13 @@
{ {
"schema": "yovision.source-config/v1", "schema_version": "yovision.source-config/v1",
"configId": "school-east-entry-01", "config_id": "school-east-entry-01",
"revision": 8, "revision": 8,
"publishedAt": "2026-08-31T01:00:00Z", "published_at": "2026-08-31T01:00:00Z",
"effectiveAt": "2026-08-31T01:00:00Z", "effective_at": "2026-08-31T01:00:00Z",
"site": { "site": {
"id": "site-east" "id": "site-east"
}, },
"logicalDevice": { "logical_device": {
"id": "entry-camera-01" "id": "entry-camera-01"
}, },
"profile": { "profile": {
@@ -15,25 +15,25 @@
"width": 1280, "width": 1280,
"height": 720, "height": 720,
"encoding": "H265", "encoding": "H265",
"frameRate": 20 "frame_rate": 20
}, },
"media": { "media": {
"ref": "media:site-east/entry-01/main-v2", "ref": "media:site-east/entry-01/main-v2",
"transport": "rtsp" "transport": "rtsp"
}, },
"ruleSet": { "rule_set": {
"version": "entry-rules-8", "version": "entry-rules-8",
"state": "recalibration_required", "state": "recalibration_required",
"profileBinding": { "profile_binding": {
"profileId": "main-stream-v2", "profile_id": "main-stream-v2",
"width": 1280, "width": 1280,
"height": 720 "height": 720
}, },
"areas": [], "areas": [],
"directionalLines": [] "directional_lines": []
}, },
"integrity": { "integrity": {
"algorithm": "sha256", "algorithm": "sha256",
"value": "04408f00740558c70d9d1e3f9f5efbf03e6f22ccc0ba4ec6b6c8688df7b568df" "value": "a53e6df8bab5c9a4e3f2dae2e82959939db09d34529af9ae65d66f322be833ba"
} }
} }
+16 -16
View File
@@ -4,32 +4,32 @@ mapper 必须创建新的契约 DTO,不得直接序列化 Sense GORM 实体,
| 契约字段 | Sense 生产来源/规则 | Brain 消费目标/规则 | | 契约字段 | Sense 生产来源/规则 | Brain 消费目标/规则 |
|---|---|---| |---|---|---|
| `schema` | 常量 `yovision.source-config/v1` | 在任何映射前精确校验;未知主版本拒绝 | | `schema_version` | 常量 `yovision.source-config/v1` | 在任何映射前精确校验;未知主版本拒绝 |
| `configId` | 新的稳定配置聚合 ID;不是数据库行 ID 语义 | 作为配置流逻辑 ID,不解释为 Brain 内部对象 ID | | `config_id` | 新的稳定配置聚合 ID;不是数据库行 ID 语义 | 作为配置流逻辑 ID,不解释为 Brain 内部对象 ID |
| `revision` | 聚合配置变更时严格递增;不可复用 | 与 `configId` 共同做幂等、顺序和陈旧检查 | | `revision` | 聚合配置变更时严格递增;不可复用 | 与 `config_id` 共同做幂等、顺序和陈旧检查 |
| `publishedAt` / `effectiveAt` | 发布时写 UTC RFC 3339;生效不得早于发布 | 完整校验后按生效时间原子切换 | | `published_at` / `effective_at` | 发布时写 UTC RFC 3339;生效不得早于发布 | 完整校验后按生效时间原子切换 |
| `site.id` | 对外稳定站点引用;不得映射客户名或数据库主键语义 | 仅作租户隔离后的逻辑关联;v1 不提供用户身份 | | `site.id` | 对外稳定站点引用;不得映射客户名或数据库主键语义 | 仅作租户隔离后的逻辑关联;v1 不提供用户身份 |
| `logicalDevice.id` | `area.Definition.DeviceID` / `media.Route.DeviceID` 经稳定外部 ID mapper | 映射到 `BrainInputConfig.logical_device_id` | | `logical_device.id` | `area.Definition.DeviceID` / `media.Route.DeviceID` 经稳定外部 ID mapper | 映射到 `BrainInputConfig.logical_device_id` |
| `profile.id` | `area.Definition.ProfileToken` 与 `media.Route.ProfileToken` 经稳定 Profile ID mapper | 映射到 `BrainInputConfig.profile.profile_id` | | `profile.id` | `area.Definition.ProfileToken` 与 `media.Route.ProfileToken` 经稳定 Profile ID mapper | 映射到 `BrainInputConfig.profile.profile_id` |
| `profile.width/height/encoding` | `area.Definition.ProfileWidth/ProfileHeight/ProfileEncoding`;必须与当前媒体 Profile 一致 | 映射到规则 `RuleSet` 的 Profile 绑定;不一致拒绝 | | `profile.width/height/encoding` | `area.Definition.ProfileWidth/ProfileHeight/ProfileEncoding`;必须与当前媒体 Profile 一致 | 映射到规则 `RuleSet` 的 Profile 绑定;不一致拒绝 |
| `profile.frameRate` | Sense 已验证 Profile 的帧率快照 | 映射到 `BrainInputConfig.profile.fps` | | `profile.frame_rate` | Sense 已验证 Profile 的帧率快照 | 映射到 `BrainInputConfig.profile.fps` |
| `media.ref` | 由 `media.Route.ID/Path` 生成 `media:<opaque-resource>`;禁止读取或拼入 `admissionProfile.StreamURI` 及凭据 | 交给后续 connector 解析;不得当作 RTSP URL 或本地路径 | | `media.ref` | 由 `media.Route.ID/Path` 生成 `media:<opaque-resource>`;禁止读取或拼入 `admissionProfile.StreamURI` 及凭据 | 交给后续 connector 解析;不得当作 RTSP URL 或本地路径 |
| `media.transport` | 当前固定 `rtsp`,仅描述媒体传输类别 | 选择后续 connector/decode adapter;不含认证信息 | | `media.transport` | 当前固定 `rtsp`,仅描述媒体传输类别 | 选择后续 connector/decode adapter;不含认证信息 |
| `ruleSet.version` | 由一组 `area.Version` 聚合成稳定规则集版本 | 映射到 Brain `RuleSet.version` | | `rule_set.version` | 由一组 `area.Version` 聚合成稳定规则集版本 | 映射到 Brain `RuleSet.version` |
| `ruleSet.state` | `NeedsRecalibration=true` → `recalibration_required`;整体禁用 → `disabled`;否则 `active` | 只有 `active` 可构建并启用规则引擎 | | `rule_set.state` | `NeedsRecalibration=true` → `recalibration_required`;整体禁用 → `disabled`;否则 `active` | 只有 `active` 可构建并启用规则引擎 |
| `ruleSet.profileBinding` | 与本快照 `profile.id/width/height` 同源复制并交叉校验 | 必须精确等于 `profile`;之后才接受归一化几何 | | `rule_set.profile_binding` | 与本快照 `profile.id/width/height` 同源复制并交叉校验 | 必须精确等于 `profile`;之后才接受归一化几何 |
| `ruleSet.areas[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `AreaRule.rule_id`;version 用于可追溯性 | | `rule_set.areas[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `AreaRule.rule_id`;version 用于可追溯性 |
| `ruleSet.areas[].kind` | Sense `polygon` 映射为 `danger_area` | 只映射到 Brain 危险区域规则,不透传 Sense 枚举 | | `rule_set.areas[].kind` | Sense `polygon` 映射为 `danger_area` | 只映射到 Brain 危险区域规则,不透传 Sense 枚举 |
| `ruleSet.areas[].points` | `area.Version.GeometryJSON` 中 `{x,y}`;保持 0–1 | 映射到 Brain `Point(x,y)`;至少三点且非退化 | | `rule_set.areas[].points` | `area.Version.GeometryJSON` 中 `{x,y}`;保持 0–1 | 映射到 Brain `Point(x,y)`;至少三点且非退化 |
| `ruleSet.directionalLines[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `DirectionalLineRule.rule_id` | | `rule_set.directional_lines[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `DirectionalLineRule.rule_id` |
| `ruleSet.directionalLines[].start/end` | `direction_line` 几何的两个归一化点 | 映射到 Brain `Point`;相同点拒绝 | | `rule_set.directional_lines[].start/end` | `direction_line` 几何的两个归一化点 | 映射到 Brain `Point`;相同点拒绝 |
| `ruleSet.directionalLines[].triggerDirection` | Sense `forward/reverse` 必须由 mapper 根据已确认的起终点方向转换为 `left_to_right/right_to_left` | 映射到 `DirectionalLineRule.trigger_direction`;不得直接猜测枚举 | | `rule_set.directional_lines[].trigger_direction` | Sense `forward/reverse` 必须由 mapper 根据已确认的起终点方向转换为 `left_to_right/right_to_left` | 映射到 `DirectionalLineRule.trigger_direction`;不得直接猜测枚举 |
| `integrity` | 对移除 `integrity` 的 JCS 快照计算 SHA-256 | 映射前重算并常量时间比较;失败保留上一有效 revision | | `integrity` | 对移除 `integrity` 的 JCS 快照计算 SHA-256 | 映射前重算并常量时间比较;失败保留上一有效 revision |
## 测试责任 ## 测试责任
- Sense 生产者契约测试:从设备、媒体 Route、Profile 与区域版本 fixture 生成快照;断言字段映射、revision 递增、Profile 变化触发新 revision/待重校准、无秘密媒体引用、Schema/语义/摘要通过。 - Sense 生产者契约测试:从设备、媒体 Route、Profile 与区域版本 fixture 生成快照;断言字段映射、revision 递增、Profile 变化触发新 revision/待重校准、无秘密媒体引用、Schema/语义/摘要通过。
- Brain 消费者契约测试:加载本目录有效与无效样例;断言版本拒绝、幂等/陈旧处理、Profile 绑定、坐标、规则 ID、状态门禁和摘要;再映射为 Brain 内部配置,证明共享 `schema` 不等于 `brain.internal.input/v1`。 - Brain 消费者契约测试:加载本目录有效与无效样例;断言版本拒绝、幂等/陈旧处理、Profile 绑定、坐标、规则 ID、状态门禁和摘要;再映射为 Brain 内部配置,证明共享 `schema_version` 不等于 `brain.internal.input/v1`。
- 协调契约测试(本工单):校验所有样例、秘密字段/URL/本地路径拒绝、跨字段语义和摘要。产品 adapter 测试在后续 connector 工单实施。 - 协调契约测试(本工单):校验所有样例、秘密字段/URL/本地路径拒绝、跨字段语义和摘要。产品 adapter 测试在后续 connector 工单实施。
Sense 与 Brain 各自可增加内部字段,但不得将数据库主键、用户表、JWT、Cookie、摄像头凭据、客户内部路径或内部模型直接扩展进本契约。 Sense 与 Brain 各自可增加内部字段,但不得将数据库主键、用户表、JWT、Cookie、摄像头凭据、客户内部路径或内部模型直接扩展进本契约。
@@ -6,34 +6,34 @@
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": [
"schema", "schema_version",
"configId", "config_id",
"revision", "revision",
"publishedAt", "published_at",
"effectiveAt", "effective_at",
"site", "site",
"logicalDevice", "logical_device",
"profile", "profile",
"media", "media",
"ruleSet", "rule_set",
"integrity" "integrity"
], ],
"properties": { "properties": {
"schema": { "schema_version": {
"const": "yovision.source-config/v1" "const": "yovision.source-config/v1"
}, },
"configId": { "config_id": {
"$ref": "#/$defs/stableId" "$ref": "#/$defs/stable_id"
}, },
"revision": { "revision": {
"type": "integer", "type": "integer",
"minimum": 1 "minimum": 1
}, },
"publishedAt": { "published_at": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
}, },
"effectiveAt": { "effective_at": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
}, },
@@ -43,17 +43,17 @@
"required": ["id"], "required": ["id"],
"properties": { "properties": {
"id": { "id": {
"$ref": "#/$defs/stableId" "$ref": "#/$defs/stable_id"
} }
} }
}, },
"logicalDevice": { "logical_device": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["id"], "required": ["id"],
"properties": { "properties": {
"id": { "id": {
"$ref": "#/$defs/stableId" "$ref": "#/$defs/stable_id"
} }
} }
}, },
@@ -75,37 +75,37 @@
} }
} }
}, },
"ruleSet": { "rule_set": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": [
"version", "version",
"state", "state",
"profileBinding", "profile_binding",
"areas", "areas",
"directionalLines" "directional_lines"
], ],
"properties": { "properties": {
"version": { "version": {
"$ref": "#/$defs/stableId" "$ref": "#/$defs/stable_id"
}, },
"state": { "state": {
"enum": ["active", "disabled", "recalibration_required"] "enum": ["active", "disabled", "recalibration_required"]
}, },
"profileBinding": { "profile_binding": {
"$ref": "#/$defs/profileBinding" "$ref": "#/$defs/profile_binding"
}, },
"areas": { "areas": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/$defs/areaRule" "$ref": "#/$defs/area_rule"
}, },
"maxItems": 1024 "maxItems": 1024
}, },
"directionalLines": { "directional_lines": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/$defs/directionalLineRule" "$ref": "#/$defs/directional_line_rule"
}, },
"maxItems": 1024 "maxItems": 1024
} }
@@ -137,55 +137,55 @@
} }
}, },
"$defs": { "$defs": {
"stableId": { "stable_id": {
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"maxLength": 128, "maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$" "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$"
}, },
"positiveInteger": { "positive_integer": {
"type": "integer", "type": "integer",
"minimum": 1 "minimum": 1
}, },
"positiveNumber": { "positive_number": {
"type": "number", "type": "number",
"exclusiveMinimum": 0 "exclusiveMinimum": 0
}, },
"profile": { "profile": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["id", "width", "height", "encoding", "frameRate"], "required": ["id", "width", "height", "encoding", "frame_rate"],
"properties": { "properties": {
"id": { "id": {
"$ref": "#/$defs/stableId" "$ref": "#/$defs/stable_id"
}, },
"width": { "width": {
"$ref": "#/$defs/positiveInteger" "$ref": "#/$defs/positive_integer"
}, },
"height": { "height": {
"$ref": "#/$defs/positiveInteger" "$ref": "#/$defs/positive_integer"
}, },
"encoding": { "encoding": {
"enum": ["H264", "H265", "MJPEG"] "enum": ["H264", "H265", "MJPEG"]
}, },
"frameRate": { "frame_rate": {
"$ref": "#/$defs/positiveNumber" "$ref": "#/$defs/positive_number"
} }
} }
}, },
"profileBinding": { "profile_binding": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["profileId", "width", "height"], "required": ["profile_id", "width", "height"],
"properties": { "properties": {
"profileId": { "profile_id": {
"$ref": "#/$defs/stableId" "$ref": "#/$defs/stable_id"
}, },
"width": { "width": {
"$ref": "#/$defs/positiveInteger" "$ref": "#/$defs/positive_integer"
}, },
"height": { "height": {
"$ref": "#/$defs/positiveInteger" "$ref": "#/$defs/positive_integer"
} }
} }
}, },
@@ -206,16 +206,16 @@
} }
} }
}, },
"areaRule": { "area_rule": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["id", "version", "kind", "enabled", "points"], "required": ["id", "version", "kind", "enabled", "points"],
"properties": { "properties": {
"id": { "id": {
"$ref": "#/$defs/stableId" "$ref": "#/$defs/stable_id"
}, },
"version": { "version": {
"$ref": "#/$defs/positiveInteger" "$ref": "#/$defs/positive_integer"
}, },
"kind": { "kind": {
"const": "danger_area" "const": "danger_area"
@@ -233,7 +233,7 @@
} }
} }
}, },
"directionalLineRule": { "directional_line_rule": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": [
@@ -243,14 +243,14 @@
"enabled", "enabled",
"start", "start",
"end", "end",
"triggerDirection" "trigger_direction"
], ],
"properties": { "properties": {
"id": { "id": {
"$ref": "#/$defs/stableId" "$ref": "#/$defs/stable_id"
}, },
"version": { "version": {
"$ref": "#/$defs/positiveInteger" "$ref": "#/$defs/positive_integer"
}, },
"kind": { "kind": {
"const": "directional_line" "const": "directional_line"
@@ -264,7 +264,7 @@
"end": { "end": {
"$ref": "#/$defs/point" "$ref": "#/$defs/point"
}, },
"triggerDirection": { "trigger_direction": {
"enum": ["left_to_right", "right_to_left"] "enum": ["left_to_right", "right_to_left"]
} }
} }
@@ -67,7 +67,7 @@ def polygon_area(points: list[dict[str, float]]) -> float:
def validate_payload(payload: dict[str, Any]) -> None: def validate_payload(payload: dict[str, Any]) -> None:
if payload.get("schema") != "yovision.source-config/v1": if payload.get("schema_version") != "yovision.source-config/v1":
raise ValueError("unknown schema major version") raise ValueError("unknown schema major version")
reject_secrets(payload) reject_secrets(payload)
@@ -86,21 +86,21 @@ def validate_payload(payload: dict[str, Any]) -> None:
raise ValueError(f"schema validation failed at {location}: {first.message}") raise ValueError(f"schema validation failed at {location}: {first.message}")
profile = payload["profile"] profile = payload["profile"]
binding = payload["ruleSet"]["profileBinding"] binding = payload["rule_set"]["profile_binding"]
if (binding["profileId"], binding["width"], binding["height"]) != ( if (binding["profile_id"], binding["width"], binding["height"]) != (
profile["id"], profile["id"],
profile["width"], profile["width"],
profile["height"], profile["height"],
): ):
raise ValueError("profile binding does not match the media profile") raise ValueError("profile binding does not match the media profile")
published_at = datetime.fromisoformat(payload["publishedAt"].replace("Z", "+00:00")) published_at = datetime.fromisoformat(payload["published_at"].replace("Z", "+00:00"))
effective_at = datetime.fromisoformat(payload["effectiveAt"].replace("Z", "+00:00")) effective_at = datetime.fromisoformat(payload["effective_at"].replace("Z", "+00:00"))
if effective_at < published_at: if effective_at < published_at:
raise ValueError("effectiveAt precedes publishedAt") raise ValueError("effective_at precedes published_at")
rule_set = payload["ruleSet"] rule_set = payload["rule_set"]
rules = [*rule_set["areas"], *rule_set["directionalLines"]] rules = [*rule_set["areas"], *rule_set["directional_lines"]]
identifiers = [rule["id"] for rule in rules] identifiers = [rule["id"] for rule in rules]
if len(identifiers) != len(set(identifiers)): if len(identifiers) != len(set(identifiers)):
raise ValueError("rule ids must be unique across the rule set") raise ValueError("rule ids must be unique across the rule set")
@@ -110,7 +110,7 @@ def validate_payload(payload: dict[str, Any]) -> None:
for area in rule_set["areas"]: for area in rule_set["areas"]:
if polygon_area(area["points"]) <= 1e-12: if polygon_area(area["points"]) <= 1e-12:
raise ValueError(f"area {area['id']} is a degenerate polygon") raise ValueError(f"area {area['id']} is a degenerate polygon")
for line in rule_set["directionalLines"]: for line in rule_set["directional_lines"]:
if line["start"] == line["end"]: if line["start"] == line["end"]:
raise ValueError(f"directional line {line['id']} has identical endpoints") raise ValueError(f"directional line {line['id']} has identical endpoints")
@@ -121,8 +121,8 @@ def validate_payload(payload: dict[str, Any]) -> None:
def validate_transition(previous: dict[str, Any], current: dict[str, Any]) -> None: def validate_transition(previous: dict[str, Any], current: dict[str, Any]) -> None:
validate_payload(previous) validate_payload(previous)
validate_payload(current) validate_payload(current)
if previous["configId"] != current["configId"]: if previous["config_id"] != current["config_id"]:
raise ValueError("configId cannot change within one revision stream") raise ValueError("config_id cannot change within one revision stream")
if current["revision"] <= previous["revision"]: if current["revision"] <= previous["revision"]:
raise ValueError("revision must increase strictly") raise ValueError("revision must increase strictly")
@@ -134,16 +134,16 @@ def validate_transition(previous: dict[str, Any], current: dict[str, Any]) -> No
) )
previous_rule_versions = sorted( previous_rule_versions = sorted(
(rule["id"], rule["version"]) (rule["id"], rule["version"])
for rule in [*previous["ruleSet"]["areas"], *previous["ruleSet"]["directionalLines"]] for rule in [*previous["rule_set"]["areas"], *previous["rule_set"]["directional_lines"]]
) )
current_rule_versions = sorted( current_rule_versions = sorted(
(rule["id"], rule["version"]) (rule["id"], rule["version"])
for rule in [*current["ruleSet"]["areas"], *current["ruleSet"]["directionalLines"]] for rule in [*current["rule_set"]["areas"], *current["rule_set"]["directional_lines"]]
) )
if ( if (
profile_changed profile_changed
and previous_rule_versions == current_rule_versions and previous_rule_versions == current_rule_versions
and current["ruleSet"]["state"] != "recalibration_required" and current["rule_set"]["state"] != "recalibration_required"
): ):
raise ValueError("profile changed without rule recalibration state or new rule versions") raise ValueError("profile changed without rule recalibration state or new rule versions")
@@ -186,13 +186,13 @@ class SourceConfigV1ContractTests(unittest.TestCase):
def test_profile_revision_and_recalibration_semantics_are_safe(self) -> None: def test_profile_revision_and_recalibration_semantics_are_safe(self) -> None:
payload = load_json(VALID_ROOT / "active.json") payload = load_json(VALID_ROOT / "active.json")
payload["ruleSet"]["profileBinding"]["width"] = 1280 payload["rule_set"]["profile_binding"]["width"] = 1280
set_integrity(payload) set_integrity(payload)
with self.assertRaisesRegex(ValueError, "profile binding"): with self.assertRaisesRegex(ValueError, "profile binding"):
validate_payload(payload) validate_payload(payload)
payload = load_json(VALID_ROOT / "active.json") payload = load_json(VALID_ROOT / "active.json")
payload["ruleSet"]["state"] = "recalibration_required" payload["rule_set"]["state"] = "recalibration_required"
set_integrity(payload) set_integrity(payload)
with self.assertRaisesRegex(ValueError, "must not remain enabled"): with self.assertRaisesRegex(ValueError, "must not remain enabled"):
validate_payload(payload) validate_payload(payload)
@@ -207,8 +207,8 @@ class SourceConfigV1ContractTests(unittest.TestCase):
current["revision"] += 1 current["revision"] += 1
current["profile"].update({"id": "main-stream-v2", "width": 1280, "height": 720}) current["profile"].update({"id": "main-stream-v2", "width": 1280, "height": 720})
current["ruleSet"]["profileBinding"].update( current["rule_set"]["profile_binding"].update(
{"profileId": "main-stream-v2", "width": 1280, "height": 720} {"profile_id": "main-stream-v2", "width": 1280, "height": 720}
) )
set_integrity(current) set_integrity(current)
with self.assertRaisesRegex(ValueError, "without rule recalibration"): with self.assertRaisesRegex(ValueError, "without rule recalibration"):
@@ -218,7 +218,7 @@ class SourceConfigV1ContractTests(unittest.TestCase):
def test_rule_geometry_and_global_ids_are_semantically_validated(self) -> None: def test_rule_geometry_and_global_ids_are_semantically_validated(self) -> None:
payload = load_json(VALID_ROOT / "active.json") payload = load_json(VALID_ROOT / "active.json")
payload["ruleSet"]["areas"][0]["points"] = [ payload["rule_set"]["areas"][0]["points"] = [
{"x": 0, "y": 0}, {"x": 0, "y": 0},
{"x": 0.5, "y": 0.5}, {"x": 0.5, "y": 0.5},
{"x": 1, "y": 1}, {"x": 1, "y": 1},
@@ -228,14 +228,14 @@ class SourceConfigV1ContractTests(unittest.TestCase):
validate_payload(payload) validate_payload(payload)
payload = load_json(VALID_ROOT / "active.json") payload = load_json(VALID_ROOT / "active.json")
payload["ruleSet"]["directionalLines"][0]["id"] = payload["ruleSet"]["areas"][0]["id"] payload["rule_set"]["directional_lines"][0]["id"] = payload["rule_set"]["areas"][0]["id"]
set_integrity(payload) set_integrity(payload)
with self.assertRaisesRegex(ValueError, "ids must be unique"): with self.assertRaisesRegex(ValueError, "ids must be unique"):
validate_payload(payload) validate_payload(payload)
def test_effective_time_cannot_precede_publication(self) -> None: def test_effective_time_cannot_precede_publication(self) -> None:
payload = load_json(VALID_ROOT / "active.json") payload = load_json(VALID_ROOT / "active.json")
payload["effectiveAt"] = "2026-08-30T23:59:59Z" payload["effective_at"] = "2026-08-30T23:59:59Z"
set_integrity(payload) set_integrity(payload)
with self.assertRaisesRegex(ValueError, "precedes"): with self.assertRaisesRegex(ValueError, "precedes"):
validate_payload(payload) validate_payload(payload)