docs: organize third-party contracts and MQ messages

This commit is contained in:
2026-09-23 09:57:28 +08:00
parent 7fd594a2a4
commit 7ad0a139bf
6 changed files with 347 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
# Dispatcher → MQ → SaaS 消息定义与 Topic 规则
本文定义 Dispatcher 发往 RabbitMQ、供 SaaS 消费的数据结构,以及当前 Dispatcher broker 使用的交换机、队列和 routing key。消息为 UTF-8 JSON,`schema_version` 固定 `2.0`,单条消息体上限 256 KiB。未知字段和不符合对应 payload 结构的消息拒绝。
SaaS 发往 Dispatcher 的命令、查询及 AI 配置响应见 [`saas-mq.md`](saas-mq.md)。
## Dispatcher 向 SaaS 发送的数据
当前有三类输出:业务事件、Service request、Service response。
| 类别 | `event_type` / `message_type` | 当前处理情况 |
| --- | --- | --- |
| 业务事件 | `command.result` | 任务接收、任务控制和重放结果 |
| 业务事件 | `call.status`、`call.finished` | 通话状态及结束事实 |
| 业务事件 | `transcript.updated`、`transcript.failed` | 实时文字更新及失败事实 |
| 业务事件 | `contact.opt_out` | 用户拒绝联系事实 |
| 业务事件 | `recording.uploaded` | OSS 上传事实的可靠 MQ 通知 |
| 业务事件 | `recording.failed` | 结构可校验、通话查询快照可保留;当前业务路径未生成该事件 |
| Service request | `ai.config.request` | 结构及持久化入口存在,但当前仅测试调用,正常运行链路未接通 |
| Service response | `command.query.result`、`call.query.result` | 分别响应 SaaS 的 `command.query`、`call.query` |
### 业务事件信封
所有事件字段必填:
```json
{
"schema_version": "2.0",
"event_id": "<event-id>",
"event_type": "recording.uploaded",
"dispatcher_id": "<canonical-lowercase-uuid-v4>",
"tenant_id": "<id>",
"tenant_key": "<original-tenant-key>",
"trace_id": "<id>",
"occurred_at": "<RFC3339 timestamp>",
"aggregate_type": "call",
"aggregate_id": "<id>",
"aggregate_version": 1,
"payload": {}
}
```
| 字段 | 类型 / 规则 |
| --- | --- |
| `schema_version` | 固定 `2.0` |
| `event_id`、`tenant_id`、`trace_id`、`aggregate_id` | 非空 ID,最多 128 个字符;事件 ID 同时作为 AMQP `message_id` |
| `event_type` | 下列八种之一 |
| `dispatcher_id` | 规范小写 UUID v4,由 Dispatcher 填入 |
| `tenant_key` | 原值保留;非空有效 UTF-8,最多 196 字节;不得含独立的 `*` 或 `#` topic 段 |
| `occurred_at` | RFC3339 时间,由 Dispatcher 生成 |
| `aggregate_type` | 非空字符串,最多 64 字符 |
| `aggregate_version` | 正整数,由 Dispatcher 持久状态递增;不能由 Agent 指定 |
| `payload` | 按 `event_type` 使用下述结构;不接受额外字段 |
### Event payload
未列为必填的字段为可选;每种 payload 都拒绝未定义字段。ID 为 JSON string,最多 128 个字符;时间字段为 RFC3339 JSON string;计数、版本、时长及偏移量为 JSON integer,标志位为 JSON boolean;枚举字段为 JSON string。
| `event_type` | 必填字段 | 可选字段 / 规则 |
| --- | --- | --- |
| `command.result` | `command_id`、`command_type`、`status`、`reason_code`(非空,最多 128 字符) | `task_id`、`task_item_id`、`execution_id`、`call_id`、`requested_task_revision`、`applied_task_revision`、`admission_state`、`resource_reservation_id`、`permit_id`。`command_type` 为 `call.execute`、`task.control`、`call.replay`、`command.replay`;`status` 为 `accepted`、`waiting`、`applied`、`rejected`、`failed`、`unknown`;revision 非负;`admission_state` 为 `open`、`closed`、`draining`、`quarantined`、`unknown` |
| `call.status` | `call_id`、`execution_id`、`call_state`、`call_version`、`attempt_id`、`attempt_state` | `task_id`、`task_item_id`、`route_policy_id`、`caller_profile_id`、`trunk_id`、`cell_id`、`egress_pool_id`、`observed_at`、`reason_code`。`call_state` 为 `queued`、`dialing`、`ringing`、`answered`、`ended`;`attempt_state` 为 `pending`、`active`、`ended`、`unknown`;`call_version >= 1`;`reason_code` 最多 128 字符 |
| `call.finished` | `call_id`、`execution_id`、`call_version`、`outcome`、`started_at`、`ended_at`、`duration_ms`、`reason_code` | `task_id`、`task_item_id`、`attempt_summary`(最多 32 项)、`asset_state`。`outcome` 为 `answered`、`no_answer`、`busy`、`failed`、`opt_out`、`cancelled`、`unknown`;`duration_ms >= 0`;`asset_state` 为 `pending`、`complete`、`failed`、`unknown`。每个 `attempt_summary` 必填 `attempt_id`、`state`(`pending`、`active`、`ended`、`unknown`),可带 `trunk_id`、`cell_id`、`reason_code` |
| `transcript.updated` | `call_id`、`turn_id`、`segment_id`、`role`、`revision`、`text`、`is_final`、`start_ms`、`end_ms`、`playback_state` | `execution_id`。`role` 为 `customer`、`agent`、`system`;`revision >= 1`;`text` 最多 32768 字符;时间偏移非负;`playback_state` 为 `not_applicable`、`generated`、`sent`、`playback_confirmed`、`cancelled`、`unknown` |
| `transcript.failed` | `call_id`、`reason_code`(非空,最多 128 字符)、`retryable` | `segment_id` 或 `affected_segments`(最多 256 个 ID) |
| `recording.uploaded` | `call_id`、`recording_id`、`format`、`channels`、`sample_rate_hz`、`duration_ms`、`size_bytes`、`checksum_sha256`、`upload_id`、`bucket`、`object_key` | 无。`format` 为 `wav`、`raw_pcm`、`pcma`;`channels=1`;采样率 8000–48000 Hz;`duration_ms >= 0`、`size_bytes >= 1`;SHA-256 为小写 64 位十六进制;`bucket` 长度 1–63,`object_key` 长度 1–1024 |
| `recording.failed` | `call_id`、`recording_id`、`stage`、`reason_code`、`retryable` | `next_retry_at`。`stage` 为 `seal`、`request`、`upload`、`complete`、`verify`、`cleanup`;`reason_code` 非空且最多 128 字符;`next_retry_at` 为 RFC3339 时间 |
| `contact.opt_out` | `call_id`、`task_id`、`task_item_id`、`requested_at` | `turn_id`、`segment_id` |
`recording.uploaded` 只报告 Agent 已直传 OSS 的既成事实,包含 bucket/object key、格式、大小及校验和;不含 TOKEN、密钥或签名 URL。入队成功不代表 SaaS 已处理,也不等待 `verified`、OSS ID 或 SaaS 回执。
### Service request:`ai.config.request`
当前代码定义并能持久化的请求信封如下;正常运行链路尚无生产调用方:
```json
{
"schema_version": "2.0",
"message_type": "ai.config.request",
"message_id": "<request-id>",
"dispatcher_id": "<canonical-lowercase-uuid-v4>",
"tenant_id": "<id>",
"tenant_key": "<original-tenant-key>",
"trace_id": "<id>",
"issued_at": "<RFC3339 timestamp>",
"not_after": "<RFC3339 timestamp>",
"payload": { "agent_version_id": "<id>" }
}
```
所有字段必填;payload 仅含 `agent_version_id`。对应 SaaS 响应 `ai.config.result` 的结构见 [`saas-mq.md`](saas-mq.md)。
### Service response:查询结果
查询响应共用信封;`not_after` 不出现在响应中:
```json
{
"schema_version": "2.0",
"message_type": "command.query.result",
"message_id": "<response-id>",
"dispatcher_id": "<canonical-lowercase-uuid-v4>",
"tenant_id": "<id>",
"tenant_key": "<original-tenant-key>",
"trace_id": "<id>",
"issued_at": "<RFC3339 timestamp>",
"correlation_id": "<original-request-message-id>",
"status": "ok",
"reason_code": "ok",
"payload": {}
}
```
`message_type` 为 `command.query.result` 或 `call.query.result`;`correlation_id` 等于原请求的 `message_id`。`status` 可为 `ok`、`pending`、`rejected`:
- `ok`:`reason_code=ok`,payload 为查询结果。
- `pending`:`reason_code=waiting`,payload 是空 object;当前查询处理路径不生成该状态。
- `rejected`:原因是 `invalid_request`、`not_found`、`conflict`、`expired`、`not_authorized`、`unavailable`、`unsupported` 之一;payload 为必填 `{ "detail": "...", "retryable": true|false }`。
#### `command.query.result` payload
必填:`command_id`、`command_type`、`tenant_id`、`tenant_key`、`status`、`aggregate_version`、`updated_at`。
可选:`task_id`、`execution_id`、`call_id`、`reason_code`、`wait_reason_code`、`accepted_at`、`waiting_since`、`admission_deadline`、`requested_task_revision`、`applied_task_revision`、`task_state`。`aggregate_version >= 1`。当前查询实现返回命令类型、租户绑定、状态、版本及更新时间;有对应事实时附带 `reason_code`、`accepted_at`。
#### `call.query.result` payload
必填:
- `call_id`、`execution_id`、`call_state`、`call_version`。
- `attempts`:`call.status` 事件信封数组。
- `transcript.events`:`transcript.updated` / `transcript.failed` 事件信封数组。
- `recordings`:`recording.uploaded` / `recording.failed` 事件信封数组。
- `delivery`:`pending`、`retry`、`dispatching`、`published` 四种 outbox 状态的非负计数。
- `snapshot_at`:快照生成时间。
可选:`task_id`、`task_item_id`、`reason_code`、`outcome`、`started_at`、`ended_at`、`duration_ms`。查询快照按原事件信封返回明细;超过单条 MQ 大小限制时返回 `unavailable`,不返回部分快照。
## MQ Topic 与队列规则
### 交换机
| 名称 | 类型 | 用途 |
| --- | --- | --- |
| `agent-call.dispatchers.v2` | durable topic | SaaS → Dispatcher 命令、查询和 AI 配置响应 |
| `agent-call.saas.v2` | durable topic | Dispatcher → SaaS 事件、查询响应及 AI 配置请求 |
| `agent-call.dead-letter.v2` | durable topic | Dispatcher 入站队列的死信交换机 |
三个交换机均不自动删除。
### 队列与精确 routing key
| 名称 / 模板 | 用途 | 绑定 |
| --- | --- | --- |
| `agent-call.d.<dispatcher_id>.owner.v2` | Dispatcher 身份占用标记;非 durable、exclusive,连接断开后释放 | 无 |
| `agent-call.d.<dispatcher_id>.t.<tenant_key>.v2` | 该 Dispatcher、该租户的 SaaS→D durable inbox | `agent-call.dispatchers.v2`:`d.<dispatcher_id>.t.<tenant_key>.in` |
| `agent-call.d.<dispatcher_id>.t.<tenant_key>.dlq.v2` | 对应 inbox 的 durable dead-letter queue | `agent-call.dead-letter.v2`:同一条 `.in` routing key |
| `agent-call.saas.events.v2` | D→SaaS durable outbound queue | `agent-call.saas.v2`:每个 D/租户各自的 `d.<dispatcher_id>.t.<tenant_key>.out` |
- `<dispatcher_id>` 由部署提供,同一 Dispatcher 重启时复用,格式必须是规范小写 UUID v4;部署需保证全局唯一。独占 owner 队列可阻止同一 RabbitMQ broker 上并发重复占用该 ID,但不能证明跨 broker 全局唯一。SaaS 必须按目标 Dispatcher 的 ID 投递,不能广播后再靠消息正文筛选。
- `<tenant_key>` 保留原值,不 trim、不编码、不截断。必须是非空有效 UTF-8,最多 196 字节;topic key 中独立的 `*`、`#` 段禁止。完整 AMQP routing key 和 queue 名称不得超过 255 字节。
- 输入 routing key 必须精确匹配 `.in`;输出 routing key 必须精确匹配 `.out`。每个租户独立绑定,不使用通配绑定替代身份隔离。
- 当前 broker 只允许向 `agent-call.saas.v2` 发布;发布前要求该租户的 `.out` route 已声明。SaaS 队列不存在或消息未路由时,发布不能算成功。
### 投递与确认
- 发布使用 `application/json`、persistent delivery、mandatory routing,并等待 publisher confirm;mandatory return、负确认、超时或连接异常均视为未确认交付。
- Dispatcher 先将事件/响应写入 SQLite outbox;只有收到正向 publisher confirm 且未发生 return 后才标记为已发布。重试沿用原消息身份和内容。
- publisher confirm 只表示 RabbitMQ 接受了消息,不表示 SaaS 业务已消费或处理。
- SaaS→D 使用 durable inbox 和手动 ACK。处理成功并完成持久化后 ACK;暂时错误 requeue;永久错误 reject,由 inbox 的 dead-letter 配置送入该租户 DLQ。
- 默认 prefetch 为 1。单条消息必须是 UTF-8 JSON,大小为 1–262144 字节。
+176
View File
@@ -0,0 +1,176 @@
# SaaS → MQ → Dispatcher 消息定义
本文定义 SaaS 发往 RabbitMQ、由 Dispatcher 消费的当前消息结构。消息使用 UTF-8 JSON,`schema_version` 固定为 `2.0`;封闭的信封和 payload 拒绝未知字段,未定义的消息类型拒绝处理。明确开放的区域只有 `call.execute.variables`、AI 配置 `metadata` 及 AI 快照外层的扩展字段。单条消息体上限为 256 KiB。命令和 Service 信封中的普通 ID 为非空 JSON string,最多 128 个字符,且不得含空白、`/` 或 `\\`;时间为 RFC3339 JSON string。`dispatcher_id`、`tenant_key` 使用各自规则。
## 消息类型
| 顶层类型 | `command_type` / `message_type` | Dispatcher 处理 |
| --- | --- | --- |
| 命令 | `call.execute` | 创建并接收外呼任务 |
| 命令 | `task.control` | 暂停、恢复或停止任务 |
| 命令 | `call.replay` | 重发指定通话的已持久化业务事件 |
| 命令 | `command.replay` | 根据源命令重发已持久化业务事件 |
| Service request | `command.query` | 查询已接收命令状态,回复 `command.query.result` |
| Service request | `call.query` | 查询通话快照,回复 `call.query.result` |
| Service response | `ai.config.result` | 接收与原请求绑定的不可变 AI 配置快照及授权 |
所有 SaaS→Dispatcher 消息都必须发往目标 Dispatcher 和租户的精确 `.in` routing key;交换机、队列和 routing key 见 [`dispatcher-mq.md`](dispatcher-mq.md)。
## 命令信封
```json
{
"schema_version": "2.0",
"command_type": "call.execute",
"command_id": "<id>",
"dispatcher_id": "<canonical-lowercase-uuid-v4>",
"tenant_id": "<id>",
"tenant_key": "<original-tenant-key>",
"trace_id": "<id>",
"issued_at": "<RFC3339 timestamp>",
"not_after": "<RFC3339 timestamp>",
"payload": {}
}
```
| 字段 | 类型 / 规则 |
| --- | --- |
| `schema_version` | 固定 `2.0` |
| `command_type` | `call.execute`、`task.control`、`call.replay`、`command.replay` 之一 |
| `command_id`、`tenant_id`、`trace_id` | 非空 ID,最多 128 个字符;不得含空白、`/` 或 `\\` |
| `dispatcher_id` | 规范小写 UUID v4;必须与目标 Dispatcher 一致 |
| `tenant_key` | 原值透传;非空有效 UTF-8,最多 196 字节;不得含独立的 `*` 或 `#` topic 段 |
| `issued_at`、`not_after` | RFC3339 时间。`now >= not_after` 时消息过期,不执行 |
| `payload` | 随 `command_type` 使用下列唯一结构;不接受额外字段 |
### `call.execute`
以下字段全部必填:
| 字段 | 类型 / 规则 |
| --- | --- |
| `execution_id`、`task_id`、`task_item_id` | ID |
| `task_revision` | 整数,`>= 1` |
| `callee` | 非空字符串,最多 256 字符;Dispatcher 保留原值 |
| `route_policy_id`、`caller_profile_id`、`agent_version_id` | ID |
| `variables` | JSON object;业务变量字段由任务内容决定 |
| `ring_timeout_ms`、`max_call_duration_ms` | 整数,`>= 1` |
### `task.control`
| 字段 | 必填 | 类型 / 规则 |
| --- | --- | --- |
| `task_id` | 是 | ID |
| `action` | 是 | `pause`、`resume`、`stop` |
| `expected_task_revision` | 是 | 整数,`>= 1`;按该版本做 CAS 校验 |
| `reason` | 是 | 非空字符串,最多 512 字符 |
| `active_call_policy` | 否 | `drain` 或 `hangup` |
### `call.replay`
- `call_id`:必填 ID。
- `reason`:必填非空字符串,最多 512 字符。
### `command.replay`
- `source_command_id`:必填 ID。
- `reason`:必填非空字符串,最多 512 字符。
重放只恢复原业务事件的交付,不创建新的执行、通话或录音资产,也不改变原事件身份和消息体。
## Service request 信封
`command.query` 和 `call.query` 共用以下信封,所有字段必填:
```json
{
"schema_version": "2.0",
"message_type": "command.query",
"message_id": "<id>",
"dispatcher_id": "<canonical-lowercase-uuid-v4>",
"tenant_id": "<id>",
"tenant_key": "<original-tenant-key>",
"trace_id": "<id>",
"issued_at": "<RFC3339 timestamp>",
"not_after": "<RFC3339 timestamp>",
"payload": {}
}
```
| `message_type` | `payload` 必填字段 |
| --- | --- |
| `command.query` | `command_id`:ID |
| `call.query` | `call_id`:ID |
Dispatcher 回复的 `*.result` 信封和 payload 定义见 [`dispatcher-mq.md`](dispatcher-mq.md)。相同 `message_id` 的重复请求必须保持消息体一致;相同 ID 不同内容会被拒绝。查询响应先持久化后确认输入消息。
## `ai.config.result`
这是对 Dispatcher 原始 `ai.config.request` 的响应。所有字段必填;该响应信封不含 `not_after`:
```json
{
"schema_version": "2.0",
"message_type": "ai.config.result",
"message_id": "<response-id>",
"dispatcher_id": "<canonical-lowercase-uuid-v4>",
"tenant_id": "<id>",
"tenant_key": "<original-tenant-key>",
"trace_id": "<id>",
"issued_at": "<RFC3339 timestamp>",
"correlation_id": "<original-request-message-id>",
"status": "ok",
"reason_code": "ok",
"payload": {}
}
```
- `status`:`ok`、`pending`、`rejected`。
- `correlation_id`:必须等于原 `ai.config.request.message_id`;`dispatcher_id`、`tenant_id`、`tenant_key` 也必须与原请求一致。
- `pending`:`reason_code` 固定 `waiting`,`payload` 必须为空 object。
- `rejected`:`reason_code` 为 `invalid_request`、`not_found`、`conflict`、`expired`、`not_authorized`、`unavailable`、`unsupported` 之一;`payload` 必须为 `{ "detail": "...", "retryable": true|false }`。
- `ok`:`reason_code` 固定 `ok`,payload 必须同时包含 `snapshot` 和 `authorization`。
### `ok` 的 `snapshot`
| 字段 | 类型 / 规则 |
| --- | --- |
| `tenant_id` | 与信封租户一致 |
| `agent_version_id` | Agent 配置版本 ID,须与原请求一致 |
| `status` | `published` 或 `reused` |
| `immutable` | 固定 `true` |
| `content_sha256` | 小写 64 位 SHA-256;须与配置规范化摘要一致 |
| `config` | 下述 AI 配置对象 |
`config` 必填 `agent_version_id`、`immutable`、`asr`、`conversation`;`immutable` 固定 `true`。支持两种配置形态:
- Full AI:必填 `llm`、`prompt`、`tts`,且 `conversation.opening` 必填;若提供 `mode`,值为 `full_ai`。
- ASR-only:`mode` 必填且为 `asr_only`;不得提供 `llm`、`prompt`、`tts`。
| 对象 | 必填字段 | 可选字段与约束 |
| --- | --- | --- |
| `asr` | `provider_ref`、`language`、`input` | `credential_ref`、`model`、`interim`、`timeout_ms`;`input` 必须含 `encoding=pcm_s16le`、`sample_rate_hz`(8000–48000)、`channels=1`、`sample_width_bytes=2` |
| `llm` | `provider_ref`、`model` | `credential_ref`、`temperature`(0–2)、`max_tokens`(正整数)、`timeout_ms`(正整数) |
| `prompt` | `text`、`allowed_variables` | `text` 非空且最多 32768 字符;变量最多 32 个,名称匹配 `[A-Za-z_][A-Za-z0-9_]*`;`max_bytes` 为 1–32768 |
| `tts` | `provider_ref`、`model`、`voice`、`format` | `credential_ref`、`speed`(0.25–3)、`timeout_ms`;`format.encoding` 为 `pcm_s16le` 或 `pcma`,`sample_rate_hz` 为 8000–48000,`channels=1` |
| `conversation` | `allow_interrupt`、`silence_timeout_ms`、`max_duration_ms`、`max_turns`、`sentence_max_chars`、`max_pending_audio_chunks` | `opening`;`max_duration_ms` 为 1–3600000,`max_turns` 为 1–1000,其余整数下限为 1 |
| `metadata` | — | 可选 JSON object;不是业务参数透传通道 |
除明确开放的 `metadata` object 外,各 AI 配置对象均拒绝未定义字段;AI 快照外层允许扩展字段,但这些字段不构成业务参数。`provider_ref`、`credential_ref` 是受控引用,不是凭据本身;MQ 消息不得携带密钥或访问令牌。
### `ok` 的 `authorization`
必填:`authorization_id`、`tenant_id`、`tenant_key`、`agent_version_id`、`config_sha256`、`mode`、`issued_at`、`expires_at`、`source`、`revoked`。
- `mode`:`full_ai` 或 `asr_only`;`source`:`saas` 或 `mock-saas`。
- `config_sha256`:小写 64 位 SHA-256;须与快照配置摘要一致。
- `credential_refs` 可选,包含 `asr`、`llm`、`tts` 的受控引用。
- `allowed_egress_pool_ids` 可选;提供时至少一个且不得重复。
- `revoked=true` 时必须提供 `revocation_reason`(最多 256 字符)。
- 授权中的租户、版本、配置摘要、有效期与快照及原请求必须匹配;拒绝已撤销或超出请求时间窗的响应。
目前 Dispatcher 已接入 `ai.config.result` 的消费和校验;正常运行链路尚无调用方发出 `ai.config.request`,因此两者当前不构成已接通的生产往返流程。
## 消费与重复投递
输入消息由 Dispatcher 按消息身份及租户范围校验。业务状态和所需响应/事件先持久化,处理成功后才 ACK;暂时性错误重入队,永久错误进入对应死信队列。重复消息只能恢复原身份的处理结果,不能重复创建拨号或业务资产。