feat: implement local P1 contracts and Mock call flow

This commit is contained in:
2026-09-25 16:38:22 +08:00
parent ef84a0663d
commit 0b4b901d85
174 changed files with 18044 additions and 1740 deletions
+936
View File
@@ -0,0 +1,936 @@
- 本文是新版项目内字段与状态语义的说明;v0.1 文档及证据仅留历史,不作为新版运行契约。四条拟定 GET 的响应字段、路径和外部兼容性尚待真实 SaaS 核对。机器校验文件为[配置读取 Schema](../contracts/config-read-v0.1.schema.json)、[任务发现 Schema](../contracts/task-discovery-v0.2-proposal.schema.json)、[命令/控制 Schema](../contracts/command-next-v0.1-proposal.schema.json)、[最终结果 Schema](../contracts/call-result-v0.1-proposal.schema.json),队列拓扑为[MQ 拓扑文件](../contracts/mq-topology-v0.1-proposal.json)。它们均为项目内版本,不修改现行上游 v1 契约。
- 每个 SaaS↔D JSON 消息体按 UTF-8 序列化后最多 **8,388,608 bytes**。超限结果保留在持久 outbox,标记 `blocked_payload_too_large` 并记录 event_id/字节数/SHA-256;不发布、不截断、不拆分、不丢弃,需由显式版本变更处理。
- 对已接纳且预期有录音的通话,上传阶段最迟在 `call.ended_at + 15m` 收口;OSS 成功发送 `uploaded`,明确 PUT 失败立即发送 `unavailable`,仍无确定结果则到期发送 `unavailable`。授权固定 15 分钟;每个录音/upload_id/object_key 组合最多一次 PUT。授权在 PUT 前过期时,Agent 可在上述截止时间内显式向 D 为同一 upload_id/object_key 重新申请授权;不自动续期或创建第二份资产,任何已发起 PUT 都不得重试。确认未产生录音的 `not_created` 立即收口。`call.result` 只生成一次,MQ 重投复用原 event_id。
- 控制按 task 串行处理,并核对最新任务状态:pause 只接受权威状态 `paused`,resume 只接受 `running` 且本地未 stopped,stop 只接受 `stopped`;乱序/不一致时保持准入关闭并拒绝,stopped 不可逆。控制本身无 command_id/expected revision,不按消息身份去重,重复动作只保持状态幂等。
- SaaS 先持久 stopped 并停止向任务队列发布,再发 stop。D 持久屏障后静默 ACK 全部未接纳积压,已接纳通话继续按策略收口;仅在任务队列排空后发 `task.control` 的 stopped/applied 回执。SaaS 收到该回执后才可删除队列/绑定;离线或无回执时保留队列。队列只由 SaaS 创建/删除,D 不声明、不绑定、不删除;任务发现 `removed` 只在该退役顺序之后发出,D 清配置但保留执行恢复和结果 outbox。
- 每个 D 最多允许 256 个仍归属或正在退役的任务队列;task_id 仅允许 ASCII `[A-Za-z0-9_-]{1,128}`,精确命名与最大字节数见 MQ 拓扑文件。`tasks` 每次完整返回,不分页,单 D 最多 256 个归属或正在退役的任务;增量变更一次完整返回且有相同的 256 条上限,超限返回 HTTP 410 `cursor_expired`、不得截断。`cursor` 是不透明变更水位;只有完整响应和任务归属已原子持久化后才推进。游标过期返回 HTTP 410 和 `cursor_expired`,D 关闭新准入并重新取完整快照。轮询周期 30 秒不是端到端发现 SLA。
## 1. 触发顺序
| 顺序 | 请求与触发 | SaaS 处理/返回 |
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| 1 | D 启动或配置到期,带 `X-DISPATCHER-id`/`X-DISPATCHER-SECRET-KEY` 请求 `/internal/v1/dispatcher/sip`(拟定 HTTP GET)。 | SaaS 返回本 D 唯一获批版本;D 核验后才能接受新执行。 |
| 2 | D 启动/重启 `GET /internal/v1/dispatcher/tasks` 取得本 D 的任务全量快照与变更游标,运行中每 30 秒(暂定) `GET /internal/v1/dispatcher/tasks?after=<cursor>`;SaaS 创建任务时先建好任务队列/绑定再发布。 | D 发现新任务后仅消费 SaaS 已创建的队列;D 离线期间消息可留在队列,任务 ID 的更新/停止也能由变更游标发现 |
| 3 | SaaS 将任务固定分配给一个 D,向该 D 投递 `call.execute`(下一版精简 payload,**非现行 Schema**)。 | D 按消息中的任务 ID 请求 `GET /internal/v1/dispatcher/task/:task_id`,核验归属 D 的任务快照(拟定 HTTP);未接纳任务可受约 60 秒配置缓存延迟影响,已接纳执行固定原快照。 |
| 4 | D 从任务取得 `tenant_id`,请求拟定 `GET /internal/v1/dispatcher/tenant/:tenant_id/quota`,与同租户其他任务共享额度后判定接纳。 | 额度缺失/过期不接新呼叫;普通接纳/拒绝有 `command.result`,**已停止任务的未接纳积压仅消费并 ACK,无逐条回传**。 |
| 按需 | SaaS 投递 `task.control` 暂停、恢复或停止(下一版草案)。 | 控制本身有回执;暂停保留积压,恢复消费原队列;停止持久生效后静默消费并 ACK 未接纳积压,不拨号、不向 SaaS 回传这些消息的结果。已在途通话仍按策略处理并给最终结果。 |
| 5 | 通话终结且录音已上传 OSS,D 投递一条本地目标事件 `call.result`。 | SaaS 只处理这条最终的通话详情,按 `event_id` 去重;录音以 `bucket/object_key` 关联,不接收文件、不提供上传会话或验证结果。上传失败/超时按 §0 和 §4.2 的 15 分钟规则收口。 |
### 1.1 现行 MQ 地址与 JSON 字段不是一回事
RabbitMQ 有**发布入口 exchange → 发布时指定的 routing key → 预先绑定的 queue → D 消费**四步;
```text
SaaS→D exchange: agent-call.dispatchers.v2
routing key: d.<dispatcher_id>.t.<tenant_key>.in
binding key: d.<dispatcher_id>.t.<tenant_key>.in
queue: agent-call.d.<dispatcher_id>.t.<tenant_key>.v2
consumer: 对应 Dispatcher
D→SaaS exchange: agent-call.saas.v2
routing key: d.<dispatcher_id>.t.<tenant_key>.out
queue: agent-call.saas.events.v2
consumer: SaaS
```
例如 §3.1 的 JSON 带 `dispatcher_id=c046b893-8628-4589-ae50-619d049248a6`、`tenant_key=tenant-a`,SaaS 的**MQ 发布参数**就对应 `d.c046b893-8628-4589-ae50-619d049248a6.t.tenant-a.in`;D 消费队列 `agent-call.d.c046b893-8628-4589-ae50-619d049248a6.t.tenant-a.v2`。exchange、routing key、queue 和 binding **不在 JSON 的 `payload` 中**;
### 1.2 本轮任务队列与事件路由
**硬边界:所有 exchange/queue/binding 均由 SaaS 创建、维护和退役;D 只消费 SaaS 创建的任务/控制队列,并向 SaaS 创建的结果 exchange 发布,不声明、绑定或删除队列。** 现行外部 MQ v2 拓扑保持原样;本轮本地目标使用 v3 名称,完整机器拓扑见 [MQ topology](../contracts/mq-topology-v0.1-proposal.json)。
```text
SaaS 创建并绑定:
exchange: agent-call.dispatchers.v3 (topic, durable)
task routing: d.<dispatcher_id>.task.<task_id>.in
task queue: agent-call.d.<dispatcher_id>.task.<task_id>.v3
control route: d.<dispatcher_id>.control.in
control queue: agent-call.d.<dispatcher_id>.control.v3
dead-letter: agent-call.dead-letter.v3
D -> SaaS exchange: agent-call.saas.v3 (topic, durable)
result route: d.<dispatcher_id>.out
SaaS result queue: agent-call.saas.d.<dispatcher_id>.v3
```
所有业务队列 durable、非 exclusive、非 auto-delete;发布消息设 persistent、mandatory,并启用 publisher confirm。SaaS 必须先确认目标队列及精确 binding 已就绪再发布;未路由或 confirm 不成功时保留原消息,恢复后以相同身份/正文重发。D 持久 inbox 与状态提交成功后才 ACK;`call.execute.command_id` 去重并禁止二次 originate。D 的结果 outbox 只有在无 mandatory return 且收到 positive confirm 后才标记已交付;confirm 仅证明 broker 接收,不代表 SaaS 应用处理。Schema/JSON 错误在记录脱敏事实后 `nack(requeue=false)`,由 SaaS 配置的 dead-letter binding 接收;不得静默 ACK 丢弃或无限 requeue。
`dispatcher_id` 是小写 canonical UUID v4;`task_id` 全局唯一且仅允许 ASCII `[A-Za-z0-9_-]{1,128}`,不含点号、通配符或分隔符。每个 D 最多 256 个尚未退役的任务队列(含 stopped/draining);`tenant_key` 不进入 queue/routing key,仍按原值保留在消息中并用于额度归属。AMQP routing key 和 queue name 上限均为 255 bytes;上述 task routing key 最长 175 bytes、task queue 最长 186 bytes。`task.control` 走独立 D 控制队列;`command.result`/`call.result` 统一走 per-D result route。消息不设置 broker TTL,`not_after` 由 D 校验并明确拒绝过期命令;stopped 任务积压仍由 D 静默 ACK。
## 2. D ← SaaS:只读配置与任务发现
四个 GET 均**无请求 JSON 体**,统一使用 `X-DISPATCHER-ID`(全局唯一 D UUID)和 `X-DISPATCHER-SECRET-KEY`(HEADER 头统一转小写判定匹配)。本地 Mock 使用隔离测试凭据;真实 SaaS 地址、认证实现及轮换未验证,不阻塞本地开发。SIP 返回本 D 全量;单任务按路径中的 `task_id` 查询,SaaS 必须核对归属 D 与原值 `tenant_key`,任务发现则按 D 返回归属清单。**不使用 ETag、If-None-Match 或 304**:任务与 SIP 配置约 60 秒缓存到期时 GET 完整 200 响应,失败只停新准入,已接纳执行保持绑定快照;MQ 控制不等待配置缓存。`tasks` 的每 30 秒增量轮询另见 §2.5。
### 2.1 SIP 配置:200,返回本 D 的完整获批快照
请求(地址/Header 仍待 SaaS 实现确认):
```http
GET /internal/v1/dispatcher/sip HTTP/1.1
Host: <SaaS 服务地址>
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
```
完整 200 响应体:
```json
{
"schema_version": "config-read.v0.1",
"resource": "sip_config",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"revision": 1,
"snapshot_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"approved_at": "2026-09-21T08:00:00+08:00",
"artifact": {
"artifact_id": "artifact-cell-mock-1",
"source_release": "mock-release-1",
"source_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"approval_reference": "mock-approval-1",
"cell_id": "cell-mock",
"revision": 1,
"config_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"mode": "mock",
"allowed_targets": [
"15003164745",
"15830461047"
],
"trunks": [
{
"trunk_id": "trunk-mock",
"provider_id": "provider-mock",
"egress_pool_id": "egress-mock",
"codec": "PCMA",
"caller_profile_ids": [
"caller-profile-mock"
],
"dial_prefix": "",
"enabled": true,
"sip_endpoint_ref": "sip-endpoint-mock",
"credential_ref": null,
"media_profile_id": "pcma-8k"
}
],
"media_profiles": {
"pcma-8k": {
"format": "alaw",
"sample_rate_hz": 8000,
"channels": 1,
"payload_type": 8
}
},
"load_evidence": null
},
"trunk_details": [
{
"trunk_id": "trunk-mock",
"server_host": "sip.example.invalid",
"server_port": 5060,
"transport": null,
"auth_mode": null,
"registration_required": null,
"max_concurrent_calls": null,
"caller_profiles": [
{
"caller_profile_id": "caller-profile-mock",
"caller_id": "BD00000000"
}
],
"schedule": {
"time_zone": "Asia/Shanghai",
"weekly_windows": {
"monday": [
{
"start": "09:00",
"end": "20:00"
}
],
"tuesday": [
{
"start": "09:00",
"end": "20:00"
}
],
"wednesday": [
{
"start": "09:00",
"end": "20:00"
}
],
"thursday": [
{
"start": "09:00",
"end": "20:00"
}
],
"friday": [
{
"start": "09:00",
"end": "20:00"
}
],
"saturday": [],
"sunday": []
}
}
}
]
}
```
**字段说明/消费动作:**
> Cell:一个外呼应用Asterisk实例(当前阶段不扩展复杂分布式,写死单实例数据,仅填充Trunk 数据列表,后期根据需求调整分布式架构);
> Trunk: 一条外呼线路;
- `schema_version/resource`:草案版本 `config-read.v0.1`、资源 `sip_config`;`dispatcher_id`:只能与发起请求的 D 相同。
- `revision/snapshot_sha256/approved_at`:整份获批快照的修订、摘要、批准时间;摘要生成规则和版本来源仍待双方确定,示例摘要仅为占位值。 **(预留,暂不验证)**
- `artifact`:静态 Cell 制品;`artifact_id/source_release/source_digest/approval_reference` 标识制品、来源版本/摘要和批准引用;`cell_id/revision/config_sha256` 标识执行单元及制品版本;`mode` 是 mock/real 范围;`allowed_targets` 是允许的原始号码;`load_evidence` 为可空加载证据。`trunks[]` 中 `trunk_id/provider_id/egress_pool_id` 定义线路、供应商、出口;`codec` 为 PCMA;`caller_profile_ids` 为主叫引用;`dial_prefix` 仅本线路前缀;`enabled` 是否启用;`sip_endpoint_ref/credential_ref/media_profile_id` 为连接、凭据和媒体配置引用,不传实际密码。`media_profiles` 下 `format/sample_rate_hz/channels/payload_type` 定义媒体格式。
- `trunk_details[]`:每项的 `trunk_id` 必须与 `artifact.trunks[]` 一一对应;`server_host/server_port` 为 SIP 服务端;`transport/auth_mode/registration_required` 是传输、认证和注册方式;`max_concurrent_calls` 是分配到该 D 的线路额度;`null` 代表未知,不可用于真实外呼。`caller_profiles[].caller_profile_id/caller_id` 给出主叫引用/原始标识(如含 `BD`),不可清洗成纯数字。
- `schedule.time_zone/weekly_windows`:Asia/Shanghai 的周一至周日多时段,`start/end` 是每日左闭右开时间;空日禁止外呼。D 校验获批版本与线路完整性并自行核对生效,不能仅凭 HTTP `200` 就认为已加载。
### 2.2 任务配置:200,ASR + LLM + TTS 模式
请求(`task_id` 示例为 `task-mock`):
```http
GET /internal/v1/dispatcher/task/task-mock HTTP/1.1
Host: <SaaS 服务地址>
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
```
完整 200 响应体(仅一种智能体模式):
```json
{
"schema_version": "config-read.v0.1",
"resource": "task_config",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": "tenant-id-mock",
"tenant_key": "tenant-mock",
"task_id": "task-mock",
"task_revision": 2,
"status": "running",
"name": "Mock task",
"group_id": null,
"max_concurrent_calls": 2,
"ring_timeout_ms": 30000,
"max_call_duration_ms": 120000,
"route_policy_id": "route-mock",
"caller_profile_id": "caller-profile-mock",
"allowed_trunk_ids": [
"trunk-mock"
],
"schedule": {
"time_zone": "Asia/Shanghai",
"starts_at": "2026-09-21T00:00:00+08:00",
"ends_at": null,
"weekly_windows": {
"monday": [
{
"start": "09:00",
"end": "11:00"
},
{
"start": "14:00",
"end": "18:00"
}
],
"tuesday": [
{
"start": "09:00",
"end": "18:00"
}
],
"wednesday": [
{
"start": "09:00",
"end": "18:00"
}
],
"thursday": [
{
"start": "09:00",
"end": "18:00"
}
],
"friday": [
{
"start": "09:00",
"end": "18:00"
}
],
"saturday": [],
"sunday": []
},
"excluded_dates": [
"2026-10-01",
"2026-10-02"
]
},
"agent": {
"agent_version_id": "agent-version-mock",
"authorization_id": "auth-mock",
"authorization_expires_at": "2026-09-21T18:00:00+08:00",
"config": {
"agent_version_id": "agent-version-mock",
"immutable": true,
"mode": "full_ai",
"llm": {
"provider_ref": "mock",
"model": "mock-chat-v1",
"temperature": 0.2,
"max_tokens": 256,
"timeout_ms": 5000
},
"prompt": {
"text": "Mock prompt for an isolated test.",
"allowed_variables": [],
"max_bytes": 32768
},
"tts": {
"provider_ref": "mock",
"model": "mock-tts-v1",
"voice": "mock-neutral",
"speed": 1.0,
"format": {
"encoding": "pcm_s16le",
"sample_rate_hz": 16000,
"channels": 1
},
"timeout_ms": 5000
},
"asr": {
"provider_ref": "mock",
"language": "zh-CN",
"input": {
"encoding": "pcm_s16le",
"sample_rate_hz": 16000,
"channels": 1,
"sample_width_bytes": 2
},
"interim": true,
"timeout_ms": 5000
},
"conversation": {
"opening": "",
"allow_interrupt": true,
"silence_timeout_ms": 3000,
"max_duration_ms": 120000,
"max_turns": 20,
"sentence_max_chars": 80,
"max_pending_audio_chunks": 32
}
}
}
}
```
**字段说明/消费动作:**
- `schema_version/resource/dispatcher_id/tenant_id/tenant_key/task_id`:版本、资源 `task_config`、归属 D、租户 ID、原值租户键和单任务 ID;D 必须验证请求归属并按 `tenant_id` 取得 §2.6 的租户额度。`task_revision` 是任务修订,`status` 为拟定 `running/paused/stopped/finished`;非 running 不接新呼叫。
- `name/group_id` 是名称及可空分组;`max_concurrent_calls` 是本任务额度,不等于跨任务/跨 D 总额度;`ring_timeout_ms/max_call_duration_ms` 是任务级振铃/最长通话毫秒上限;`route_policy_id` 标识这份任务路由;`allowed_trunk_ids[]` 依次列出候选优先级,选择首条已加载、时段/额度有效且支持任务 `caller_profile_id` 的线路;`caller_profile_id` 明确主叫引用,不默认取首个主叫。无匹配项不接纳,选定后固定、拨号失败不自动换线重拨。有效通话上限取任务 `max_call_duration_ms` 与 AI `conversation.max_duration_ms` 的较小值,执行与 AI 控制器一致,不改原授权配置。精简命令不带这些业务值。
- `schedule.time_zone/starts_at/ends_at` 定义时区和可空的起止时间;`weekly_windows` 按星期列出每日多个左闭右开 `{start,end}`,空数组禁呼;`excluded_dates[]` 为按 Asia/Shanghai 日期优先排除的日子。任务时段还须与线路时段相交。
- `agent.agent_version_id`:不可变智能体版本,必须与 `agent.config.agent_version_id` 对应;`authorization_id/authorization_expires_at` 为授权身份和截止时间,到期不得由过期缓存继续放行。不返回 `content_sha256`,同一版本内容变化必须拒绝并要求新版本。
- `agent.config.immutable/mode`:不可变标记及 `full_ai` 模式。`llm.provider_ref/model/temperature/max_tokens/timeout_ms` 为供应商引用、模型、采样、输出上限和超时;`prompt.text/allowed_variables/max_bytes` 为提示词、允许的变量和字节上限;`tts.provider_ref/model/voice/speed/format/timeout_ms` 为语音供应商引用、模型、声音、速度、音频格式与超时;`asr.provider_ref/model/language/input/interim/timeout_ms` 为识别供应商、可选模型、语种、输入格式、是否给出中间转写与超时;音频 `encoding/sample_rate_hz/channels/sample_width_bytes` 定义编码、采样率、声道和样本宽度;`conversation.opening/allow_interrupt/silence_timeout_ms/max_duration_ms/max_turns/sentence_max_chars/max_pending_audio_chunks` 控制开场、打断、静默时限、总时限、轮次及缓存上限。
- 未接纳呼叫在有效缓存窗口可能仍用旧批准版;已接纳呼叫固定原快照。新版呼叫命令只给任务 ID 与被叫号码,D 须从有效任务配置取得固定版本和任务级超时,不从命令猜值;现行严格 Schema 仍是旧结构。
### 2.3 任务配置:200,仅 ASR 模式(独立情况)
```json
{
"schema_version": "config-read.v0.1",
"resource": "task_config",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": "tenant-id-mock",
"tenant_key": "tenant-mock",
"task_id": "task-mock",
"task_revision": 2,
"status": "running",
"name": "Mock task",
"group_id": null,
"max_concurrent_calls": 2,
"ring_timeout_ms": 30000,
"max_call_duration_ms": 120000,
"route_policy_id": "route-mock",
"caller_profile_id": "caller-profile-mock",
"allowed_trunk_ids": [
"trunk-mock"
],
"schedule": {
"time_zone": "Asia/Shanghai",
"starts_at": "2026-09-21T00:00:00+08:00",
"ends_at": null,
"weekly_windows": {
"monday": [
{
"start": "09:00",
"end": "11:00"
},
{
"start": "14:00",
"end": "18:00"
}
],
"tuesday": [
{
"start": "09:00",
"end": "18:00"
}
],
"wednesday": [
{
"start": "09:00",
"end": "18:00"
}
],
"thursday": [
{
"start": "09:00",
"end": "18:00"
}
],
"friday": [
{
"start": "09:00",
"end": "18:00"
}
],
"saturday": [],
"sunday": []
},
"excluded_dates": [
"2026-10-01",
"2026-10-02"
]
},
"agent": {
"agent_version_id": "agent_asr_v1",
"authorization_id": "auth-mock",
"authorization_expires_at": "2026-09-21T18:00:00+08:00",
"config": {
"agent_version_id": "agent_asr_v1",
"immutable": true,
"mode": "asr_only",
"asr": {
"provider_ref": "mock",
"model": "mock-asr-v1",
"language": "zh-CN",
"input": {
"encoding": "pcm_s16le",
"sample_rate_hz": 16000,
"channels": 1,
"sample_width_bytes": 2
},
"interim": true,
"timeout_ms": 5000
},
"conversation": {
"allow_interrupt": false,
"silence_timeout_ms": 3000,
"max_duration_ms": 120000,
"max_turns": 20,
"sentence_max_chars": 80,
"max_pending_audio_chunks": 32
}
}
}
}
```
**字段说明/消费动作:** 字段与 2.2 相同,但 `agent.config.mode=asr_only`,**没有** LLM、提示词或 TTS 对象;配置内外 `agent_version_id` 必须一致。只能按授权的识别配置执行,不应将未提供的字段填成默认值。
### 2.4 SIP 或任务:错误返回(`resource_not_found`,HTTP 404,项目内规则)
```json
{
"schema_version": "config-read.v0.1",
"resource": "error",
"error": {
"code": "resource_not_found",
"message": "Task is not assigned to this Dispatcher."
}
}
```
**字段说明/消费动作:**`schema_version/resource` 标识项目内错误对象;`error.code` 是机器可读错误代码(`resource_not_found` 同时表示任务不存在或不归此 D),`error.message` 是可读说明,不含密钥。本地将此错误映射为 HTTP 404;真实 SaaS 是否采用相同状态码尚未验证。D 不得将失败当作空任务/无限制或使用过期配置接新呼叫;不能自动回退至 MQ 配置通道。
### 2.5 D ← SaaS:动态任务发现
第三条只读 HTTP 接口是 `GET /internal/v1/dispatcher/tasks`。D 启动/重启时不带 `after` 读取**一致全量快照 + 游标**,运行中**每 30 秒** `GET /internal/v1/dispatcher/tasks?after=<cursor>` 读取针对本 D 的变更。`after` 是 SaaS 的变更水位,**不是最大 `task_id`**;旧任务的暂停、停止、改派也会返回。单次返回完整快照或完整变更集,不分页;全量超出 256 个归属任务时明确失败;增量变更超出单次返回上限时返回 HTTP 410 `cursor_expired`,重新取全量,不以部分成功跳过变更。
### 2.5.1 启动或重启:全量快照(HTTP 200,本地目标)
```http
GET /internal/v1/dispatcher/tasks HTTP/1.1
Host: <SaaS 内网地址>
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
```
完整 200 响应体:
```json
{
"schema_version": "task-discovery.v0.2-proposal",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"cursor": "1042",
"tasks": [
{
"task_id": "task-a",
"tenant_id": "tenant-a",
"tenant_key": "tenant-a",
"status": "running",
"task_revision": 1
},
{
"task_id": "task-old",
"tenant_id": "tenant-a",
"tenant_key": "tenant-a",
"status": "stopped",
"task_revision": 3
}
]
}
```
**字段说明/消费动作:**`dispatcher_id` 是被授权的目标 D;`cursor` 是此快照覆盖的 SaaS 任务变更水位(示例数字只是**不透明字符串**,D 不按大小比较任务 ID);`tasks[]` 列出本 D 全部归属任务及**已停止但队列仍有积压的任务**;`tenant_id` 用于读取 §2.6 额度,`tenant_key` 保留原值并与 tenant_id 一对一核验,用于同租户所有任务共享并发额度;`task_revision/status` 是任务版本和状态;队列地址按 §1.2 双方已确定的 D/task 命名规则推导,不在响应正文重复。D 只能消费 SaaS 已创建/绑定的队列,不能声明或绑定。
### 2.5.2 每 30 秒:增量变化(HTTP 200,本地目标)
```http
GET /internal/v1/dispatcher/tasks?after=1042 HTTP/1.1
Host: <SaaS 内网地址>
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
```
完整 200 响应体(包含任务退役):
```json
{
"schema_version": "task-discovery.v0.2-proposal",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"next_cursor": "1045",
"changes": [
{
"cursor": "1043",
"operation": "assigned",
"task_id": "task-b",
"tenant_id": "tenant-a",
"tenant_key": "tenant-a",
"status": "running",
"task_revision": 1
},
{
"cursor": "1044",
"operation": "updated",
"task_id": "task-a",
"tenant_id": "tenant-a",
"tenant_key": "tenant-a",
"status": "stopped",
"task_revision": 2
},
{
"cursor": "1045",
"operation": "removed",
"task_id": "task-old",
"tenant_id": "tenant-a",
"tenant_key": "tenant-a"
}
]
}
```
**增量与退役:**`next_cursor` 是本次完整变更集持久化后的下次 `after`,不按数值或 task_id 比较;`changes[]` 按 SaaS 顺序应用。没有变更时 `changes: []` 且 `next_cursor` 等于请求的 `after`;`removed` 项只带 `cursor/operation/task_id/tenant_id/tenant_key`,须在 §0 停止发布、排空、回执及 SaaS 退役队列/绑定之后发送,不等同于 stopped。D 收到后停止消费、清任务配置,但保留执行恢复和 outbox。错误正文为 `schema_version/resource:error/error:{code,message}`;400 `invalid_cursor`、401 `unauthorized`、403 `dispatcher_not_authorized`、410 `cursor_expired`、503 `service_unavailable`,均不推进游标且关闭新准入;410 重取并原子持久化全量快照后才恢复。任务队列由 SaaS 创建/维护/退役,响应不提供地址;30 秒轮询不能代替 MQ 即时控制。以上均为项目内规则,尚未获真实 SaaS 确认。
### 2.6 D ← SaaS:按租户 ID 获取并发额度(新增项目草案)
D 从任务清单/单任务配置取得 `tenant_id`、原值 `tenant_key` 并核对外呼信封后,再请求额度;不能把任务额度当租户总额。以下路径和字段为**项目提案,尚未由 SaaS 发布**。
#### 2.6.1 有可用额度:请求与完整 200 响应
```http
GET /internal/v1/dispatcher/tenant/tenant-id-mock/quota HTTP/1.1
Host: <SaaS 内网地址>
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
```
```json
{
"schema_version": "config-read.v0.1",
"resource": "tenant_quota",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": "tenant-id-mock",
"tenant_key": "tenant-mock",
"quota_revision": 1,
"max_concurrent_calls": 3,
"valid_until": "2026-09-21T18:00:00+08:00"
}
```
**字段说明/消费动作:**三个身份字段必须与任务及请求一致;`quota_revision` 为额度版本;`max_concurrent_calls` 是 SaaS **分给本 D 的租户份额**,同租户所有任务共同占用,不是每任务各得3路;`valid_until` 是有效截止。成功核验后最多缓存约60秒且不超过截止时间;同租户任务复用一份额度/占用,D 同一事务核查并预留租户+任务+线路等额度。未知通话继续计数;未来多D需份额之和≤总额,不各拿一份全额。
#### 2.6.2 降额或额度为零:200(独立情况)
```json
{
"schema_version": "config-read.v0.1",
"resource": "tenant_quota",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": "tenant-id-mock",
"tenant_key": "tenant-mock",
"quota_revision": 2,
"max_concurrent_calls": 0,
"valid_until": "2026-09-21T18:00:00+08:00"
}
```
**字段说明/消费动作:** 0明确禁止新准入,不是无限额。降额时不强挂已有通话、不清未知占用,等占用低于新上限且授权有效才再接新。stop静默排空与控制不需要通话额度,不能因额度0卡住停止任务。
#### 2.6.3 无可用租户额度:错误(HTTP 503,项目内规则)
```json
{
"schema_version": "config-read.v0.1",
"resource": "error",
"error": {
"code": "tenant_quota_unavailable",
"message": "No valid tenant allocation is available for this dispatcher."
}
}
```
**字段说明/消费动作:** 服务端无法提供有效租户份额(缺失、过期或暂不可用)时,本地返回 HTTP 503 与 `tenant_quota_unavailable`;收到 `200` 但身份与请求/任务不符时,D 拒绝并关闭该租户新准入。不得用任务额度或无限额兜底;已有执行依原快照处理。核实通话终结并释放执行资源就释放通话额度,**不等待录音上传或最终结果 MQ 确认**;未知通话不能释放。
## 3. SaaS → D:下一版精简业务命令
以下 JSON 是本项目 F07 冻结的完整下一版 MQ 请求;`schema_version=command-next.v0.1-proposal` 标识项目内版本,不是现行外部 `2.0`。`dispatcher_id/tenant_id/tenant_key` 确定 D 和租户,MQ 发布参数另按 §1 任务 key 精确路由;`issued_at/not_after` 限定有效期;`command_type` 区分呼叫或控制。**仅** `call.execute` 仍带 `command_id`,用来识别不可重复的外呼执行;三个 `task.control` 均不带 `command_id`、`expected_task_revision`,本地不设计控制命令去重。控制的乱序、重投及处理回执按本节规则和本地 Schema/Mock 测试处理;真实 SaaS 兼容性及从现行 v2 切换仍未验证,不属于本地 C 的外部验收证据。
### 3.1 发起外呼:call.execute
```json
{
"schema_version": "command-next.v0.1-proposal",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": "tenant-a",
"tenant_key": "tenant-a",
"trace_id": "trace-v2",
"issued_at": "2026-09-18T10:00:00+08:00",
"command_id": "command-a",
"command_type": "call.execute",
"not_after": "2026-09-18T10:15:00+08:00",
"payload": {
"task_id": "task-a",
"callee": "15003164745"
}
}
```
**字段说明/消费动作:**`payload` **只有** `task_id`(SaaS 任务身份)及 `callee`(原始被叫号码,不带线路前缀);租户归属从信封及 `/internal/v1/dispatcher/task/:task_id` 的授权结果核对。路由/主叫/智能体版本和任务级 `ring_timeout_ms/max_call_duration_ms` 全由有效任务配置取得,D 接纳时绑定不可漂移的执行快照;生成 `execution_id` 是 D 内部事实,不由 SaaS 逐呼提供。信封 `command_id` 仅用于外呼命令身份:重投不能第二次拨号。本例15分钟有效期仅示意,不是默认值;SaaS 须覆盖其允许的轮询/配置/额度准备及排队时间。队列ready不代表D已消费;离线或暂停不延长not_after,恢复仅执行仍有效者,过期非stopped消息明确拒绝、不自动重建命令,stopped积压静默ACK。此为项目内 v0.1 payload,由[命令/控制 Schema](../contracts/command-next-v0.1-proposal.schema.json)严格校验并由本地 C 验证;真实 SaaS 兼容性和从现行 v2 切换未验证,不属于本地通过证据。
### 3.2 暂停任务:task.control / pause
```json
{
"schema_version": "command-next.v0.1-proposal",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": "tenant-a",
"tenant_key": "tenant-a",
"trace_id": "trace-v2",
"issued_at": "2026-09-21T00:00:00Z",
"command_type": "task.control",
"not_after": "2026-09-21T00:00:30Z",
"payload": {
"task_id": "task-a",
"action": "pause",
"active_call_policy": "drain",
"reason": "local-test"
}
}
```
**字段说明/消费动作:**`task_id` 定位任务;`action=pause` 停止新呼叫准入;`active_call_policy=drain` 允许在途通话自然结束;`reason` 是原因说明。控制**无 `command_id`、无 `expected_task_revision`,不做按消息去重**。D 持久暂停屏障、停止该队列消费,并将已预取但未接纳的消息 `nack(requeue=true)` 回原队列;不 ACK 丢弃、不搬入本地待拨队列。已接纳通话按 `drain/hangup` 执行;控制回执在屏障持久且未接纳投递已退回后发送,不等待通话结束。SaaS 按每任务状态变更顺序发布控制,D 每任务串行处理。
### 3.3 恢复任务:task.control / resume
```json
{
"schema_version": "command-next.v0.1-proposal",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": "tenant-a",
"tenant_key": "tenant-a",
"trace_id": "trace-v2",
"issued_at": "2026-09-21T00:00:00Z",
"command_type": "task.control",
"not_after": "2026-09-21T00:00:30Z",
"payload": {
"task_id": "task-a",
"action": "resume",
"reason": "operator-resume"
}
}
```
**字段说明/消费动作:**resume 成功就是恢复消费**原任务队列的积压**,不是等待 SaaS 重发。D 必须绕过缓存读取最新任务;仅当权威状态为 `running`、D/租户归属有效且本地从未 stopped 时解除 paused。每条旧命令仍校验 `not_after`,过期明确拒绝,不延长期限或等待次日。已停止任务不可恢复。控制无编号/修订,不去重;重复 resume 对状态幂等,但每次实际投递都可有独立回执。
### 3.4 停止任务:task.control / stop
```json
{
"schema_version": "command-next.v0.1-proposal",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": "tenant-a",
"tenant_key": "tenant-a",
"trace_id": "trace-v2",
"issued_at": "2026-09-21T00:00:00Z",
"command_type": "task.control",
"not_after": "2026-09-21T00:00:30Z",
"payload": {
"task_id": "task-a",
"action": "stop",
"active_call_policy": "hangup",
"reason": "operator-stop"
}
}
```
**字段说明/消费动作:**`stop` 持久终止任务准入,SaaS 先停止该队列发布并确认已有发布处理完,再投递 stop。D 持久 stopped 屏障后静默 ACK 所有未接纳积压,不拨号、不发逐条 `command.result`/`call.result`、不申请额度;不是 purge/delete,也不影响其他任务。D 取消普通 consumer,结清已预取消息后用 `basic.get` 排空队列至空;仅在无未 ACK 投递且确认空队列后发送 stopped/applied 回执。SaaS 收到回执后才可删除队列/绑定并在任务发现中发 `removed`。ACK 丢失、重启、额度 0 或配置失效不改变排空规则;保留本地计数/错误。已接纳/在途通话按 `hangup` 或 `drain` 处理并照常发最终结果;stopped 同任务 ID 不可 resume。
**任务发现与控制状态规则(项目内 v0.1):** SaaS 先持久变更权威任务状态,再按每任务顺序发布控制;D 对同任务串行处理。stopped 不可逆;paused 只能由新鲜任务 GET 确认 `running` 的 resume 解锁。D 不允许旧 running 配置/快照覆盖更高 `task_revision` 或清除本地 stopped 屏障;重启恢复持久屏障,全量快照只能收紧准入,不能自行重开。pause/stop 先持久关闭准入;action 与最新任务状态不一致、读取失败或出现乱序冲突时保持关闭并返回 `state_mismatch`/`task_unavailable`。重复 pause/resume/stop 只对状态幂等,不做控制消息去重;每次处理都可产生独立 `event_id` 回执,回执自身重投复用原 event_id。MQ 发布成功不等于控制已应用。
### 3.5 D → SaaS:外呼命令处理回执
```json
{
"schema_version": "command-next.v0.1-proposal",
"event_id": "command-result-a",
"event_type": "command.result",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": "tenant-a",
"tenant_key": "tenant-a",
"trace_id": "trace-v2",
"occurred_at": "2026-09-18T10:00:01+08:00",
"aggregate_type": "command",
"aggregate_id": "command-a",
"aggregate_version": 1,
"payload": {
"command_id": "command-a",
"command_type": "call.execute",
"status": "accepted",
"reason_code": "accepted",
"execution_id": "execution-a"
}
}
```
**字段说明/消费动作:**`payload.command_id` 仅指向 §3.1 的外呼命令;`status` 区分接纳/拒绝,`execution_id` 是 D 接纳后生成的执行身份。已停止任务的未接纳积压**不发送此回执**;其他未接纳拒绝只有命令回执、不伪造通话。MQ 回执**不代表已拨号或已完成通话**;按 `command_id` 持久去重,未知执行不得靠重投产生第二次呼叫。字段由项目内 Schema 校验。
### 3.6 D → SaaS:任务控制处理回执
```json
{
"schema_version": "command-next.v0.1-proposal",
"event_id": "control-result-a",
"event_type": "command.result",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": "tenant-a",
"tenant_key": "tenant-a",
"trace_id": "trace-v2",
"occurred_at": "2026-09-18T10:00:01+08:00",
"aggregate_type": "task",
"aggregate_id": "task-a",
"aggregate_version": 2,
"payload": {
"command_type": "task.control",
"task_id": "task-a",
"action": "pause",
"status": "applied",
"reason_code": "applied",
"task_state": "paused"
}
}
```
**字段说明/消费动作:** 控制请求不带 `command_id/expected_task_revision`,回执以 `task_id/action/status/reason_code/task_state` 说明处理事实,不提供按控制编号一对一关联,也不把 `event_id` 用作控制去重身份。D 按最新任务状态和本地终态屏障处理乱序;对同一状态的重复动作可重复回执。回执丢失时 SaaS 以最新任务 GET 和后续状态发现收敛,不能把 MQ 发布成功当控制已生效。
## 4. D → SaaS:唯一通话结果(项目内 v0.1 `call.result` 契约)
同一次通话只发布一种业务反馈 `call.result`:通话状态、最终转写、拒联结果、录音资产一次返回;不再将通话进度、实时文字、拒联、通话结束、录音成功/失败各自发布对外事件。**本轮按该简化实现和验收**,不要求 SaaS 在最终结果前收到实时文字或拒联;真实 SaaS 消费兼容性未验证。停止任务未接纳积压不产生通话事件;其它已接纳执行的消息可靠入队,断线后按原事件身份重投;这不是对外“补传命令”。结果结构由[最终结果 Schema](../contracts/call-result-v0.1-proposal.schema.json)严格校验,不属于现行外部 MQ v2。
### 4.1 录音已上传 OSS:最终成功结果
```json
{
"schema_version": "call-result.v0.1-proposal",
"event_id": "call-result-001",
"event_type": "call.result",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": "tenant-a",
"tenant_key": "tenant-a",
"trace_id": "trace-v2",
"occurred_at": "2026-09-18T10:10:15+08:00",
"aggregate_type": "call",
"aggregate_id": "call-a",
"aggregate_version": 1,
"payload": {
"source_command_id": "command-a",
"execution_id": "execution-a",
"call_id": "call-a",
"task_id": "task-a",
"task_revision": 1,
"agent_version_id": "version-a",
"route_policy_id": "route-a",
"caller_profile_id": "caller-a",
"callee": "15003164745",
"trunk_id": "trunk-a",
"started_at": "2026-09-18T10:00:00+08:00",
"ended_at": "2026-09-18T10:10:00+08:00",
"duration_ms": 600000,
"outcome": "answered",
"reason_code": null,
"transcript": [
{
"turn_id": "turn-1",
"segment_id": "segment-1",
"role": "user",
"text": "示例转写内容",
"start_ms": 1000,
"end_ms": 2500
}
],
"opt_out": false,
"recording": {
"status": "uploaded",
"recording_id": "recording-a",
"upload_id": "upload-a",
"bucket": "example-bucket",
"object_key": "calls/tenant-a/call-a.wav",
"format": "wav",
"channels": 1,
"sample_rate_hz": 8000,
"duration_ms": 600000,
"size_bytes": 9600000,
"checksum_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
}
}
```
**字段说明/消费动作:**`schema_version/event_type` 是项目内 v0.1 的单一通话结果类型,严格由本地 Schema 校验;现行外部 v2 Schema 保持不变,不能混用。`event_id` 是固定的事件身份,重复入队须相同;`dispatcher_id/tenant_id/tenant_key/trace_id` 限定来源和归属;`aggregate_type/aggregate_id/aggregate_version/occurred_at` 为呼叫聚合、版本和完成时间。
`payload.source_command_id/execution_id/call_id/task_id/task_revision/agent_version_id` 绑定原外呼命令、D 生成的执行/呼叫及从任务快照绑定的固定版本;`route_policy_id/caller_profile_id/trunk_id/callee` 为路由策略、主叫配置、实际线路及原始被叫;`started_at/ended_at/duration_ms/outcome/reason_code` 给出起止、时长、结果和可空原因。`transcript[]` 中 `turn_id/segment_id/role/text/start_ms/end_ms` 是仅随最终结果发送的转写片段及时间;`opt_out` 表示通话中的拒联事实,只在最终消息里可见。`recording.status/recording_id/upload_id/bucket/object_key/format/channels/sample_rate_hz/duration_ms/size_bytes/checksum_sha256` 描述已成功上传的资产,不包含文件、TOKEN 或签名 URL。SaaS 使用 `call_id` 关联、`event_id` 去重并按固定 `upload_id` 避免重复资产。
### 4.2 录音上传未完成:15 分钟内收口为最终异常结果
```json
{
"schema_version": "call-result.v0.1-proposal",
"event_id": "call-result-002",
"event_type": "call.result",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": "tenant-a",
"tenant_key": "tenant-a",
"trace_id": "trace-v2",
"occurred_at": "2026-09-18T10:10:15+08:00",
"aggregate_type": "call",
"aggregate_id": "call-b",
"aggregate_version": 1,
"payload": {
"source_command_id": "execute-b",
"execution_id": "execution-b",
"call_id": "call-b",
"task_id": "task-a",
"task_revision": 1,
"agent_version_id": "version-a",
"route_policy_id": "route-a",
"caller_profile_id": "caller-a",
"callee": "15003164745",
"trunk_id": "trunk-a",
"started_at": "2026-09-18T10:00:00+08:00",
"ended_at": "2026-09-18T10:10:00+08:00",
"duration_ms": 600000,
"outcome": "answered",
"reason_code": null,
"transcript": [],
"opt_out": false,
"recording": {
"status": "unavailable",
"error_code": "upload_timeout",
"recording_id": "recording-b",
"upload_id": "upload-b",
"bucket": null,
"object_key": null,
"format": "wav",
"channels": 1,
"sample_rate_hz": 8000,
"duration_ms": 600000,
"size_bytes": null,
"checksum_sha256": null
}
}
}
```
**字段说明/消费动作:** 若录音预期存在但授权/PUT 明确失败,立即以 `recording.status=unavailable` 收口;若仍无确定结果,最迟于 `call.ended_at + 15m` 收口,`bucket/object_key/size_bytes/checksum_sha256=null`,`recording.error_code` 仅可为 `upload_authorization_failed`、`upload_authorization_expired`、`upload_failed`、`upload_timeout`、`deadline_exceeded` 或 `checksum_mismatch`,分别记录授权、PUT、总期限或校验阶段;呼叫自身 `reason_code` 保持通话事实。不能谎称上传成功或默默丢弃最终结果。`outcome` 必须反映**通话本身**而非上传成败;已接通/正常结束不得因录音失败改成 `failed`。同一录音最多一次 PUT;超时/结果未知不重试 PUT。
### 4.3 正常未产生录音:无应答结果
```json
{
"schema_version": "call-result.v0.1-proposal",
"event_id": "call-result-003",
"event_type": "call.result",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": "tenant-a",
"tenant_key": "tenant-a",
"trace_id": "trace-c",
"occurred_at": "2026-09-18T10:00:30+08:00",
"aggregate_type": "call",
"aggregate_id": "call-c",
"aggregate_version": 1,
"payload": {
"source_command_id": "command-c",
"execution_id": "execution-c",
"call_id": "call-c",
"task_id": "task-a",
"task_revision": 1,
"agent_version_id": "version-a",
"route_policy_id": "route-a",
"caller_profile_id": "caller-a",
"callee": "15003164745",
"trunk_id": "trunk-a",
"started_at": "2026-09-18T10:00:00+08:00",
"ended_at": "2026-09-18T10:00:30+08:00",
"duration_ms": 30000,
"outcome": "no_answer",
"reason_code": "ring_timeout",
"transcript": [],
"opt_out": false,
"recording": {
"status": "not_created",
"reason_code": "no_answer",
"recording_id": null,
"upload_id": null,
"bucket": null,
"object_key": null,
"format": null,
"channels": null,
"sample_rate_hz": null,
"duration_ms": null,
"size_bytes": null,
"checksum_sha256": null
}
}
}
```
**字段说明/消费动作:** 这是已接纳、已尝试但无人接听且未产生录音的呼叫;`started_at/duration_ms` 此例表示呼叫尝试起点和尝试耗时,不冒称已接通时长。正常无录音用 `not_created`,资产字段为null,确认终结后即可发送,不申请/等待上传;忙线等正常无录音同类处理,原因须与事实一致。录音本应生成却失败应为 `unavailable` 加明确阶段原因,不伪装正常无录音。普通未接纳拒绝仅有命令回执;stopped未接纳积压无回执也无最终结果,不能虚构call_id。
**额度与文件交付分离:** 确认通话终结、执行资源释放就释放通话额度,不等待 OSS 或最终通知确认,未知仍占额。已产生录音才按 4.1/4.2 收口;每条 JSON 消息体上限 8,388,608 bytes,超限持久阻塞 outbox,不截断、不拆分、不恢复实时事件。
## 5. 本地实现与外部验收边界
- 本文及链接的 `docs/contracts` Schema/正反例/MQ 拓扑是本轮 P1 Go/Mock 的项目内契约。完成 F01/F07 版本、来源/hash、严格校验和 Mock SaaS 端到端 C 后,可直接进入本地实现;不要求真实 SaaS、management 或供应商签收/连通。
- `contracts/upstream/v1/` 与现行外部 MQ v2 继续作为真实 SaaS 的既有基线。本地 v3 路由和消息不得混入 v2,也不得把 Mock 通过写成 SaaS、management 或生产验收。
- 本地 MQ 采用 v3 durable topic/queue,任务和 D 结果队列均由 SaaS 创建维护;D 只消费任务/控制并发布结果。命令用 `command_id` 持久去重防止二次 originate;任务控制无 `command_id/expected_task_revision`、不按消息去重,乱序/过期失败关闭。
- 对外只保留必要命令/控制回执和每个已接纳通话唯一的最终 `call.result`。不保留 query/replay、实时转写/拒联/通话进度/录音拆分事件;stopped 任务未接纳积压只静默 ACK,不产生逐条结果。正常无录音立即以 `not_created` 收口;预期录音失败最晚在 `call.ended_at + 15m` 以 `unavailable` 收口;每个 upload_id 最多一次 PUT,重投复用原 event_id,不重新上传。
- 所有 MQ JSON 正文上限为 8,388,608 bytes。超限消息留在持久 outbox 并显式阻塞,不截断、不拆分、不丢弃。该上限仅为本地 v0.1 规则;真实 SaaS 与 broker 的兼容性需另行验证。
- 四条 GET、严格 Schema、任务发现单次完整响应/游标过期恢复、队列退役握手和 `call.result` 正反例均按本文及对应 schema 验证。旧 v0.1 分页契约及本地证据仅为历史;采用本版须重测 F03/F09,不能把旧通过记录当作本版通过。外部正式版本、部署与切换仍是独立事实和授权门禁。
@@ -1,17 +1,28 @@
# SaaS ↔ Dispatcher:请求与通话结果消费顺序 v0.1
本文只描述 **SaaS 与 Dispatcher(D)**。四个配置/任务发现/租户额度 GET 与简化 `call.execute`、无 `command_id` 的 `task.control`/控制回执均是**下一版待签收草案**;现行 MQ v2 仍要求旧字段;下文“唯一通话结果 `call.result`”是**下一版本提案**,现行 Schema 和程序**尚不支持**。不能把本文的新旧示例拼接成已经可运行的单一版本。示例为非生产数据,JSON 代码块均为完整请求或返回体;字段说明写在块外。
本文是本轮 P1 Go/Mock 实现使用的**项目内 SaaS 对接契约**。F01/F07 按本文和链接的机器 Schema 冻结后,直接用于本地实现和 Mock SaaS 验证;不等待外部 SaaS/management 签收或连通。下文“草案/拟定/待签收”仅表示真实 SaaS/management 兼容性未验证,不阻塞本地实现。真实 SaaS 当前仍可能运行 `contracts/upstream/v1`,其兼容性未验证;Mock 通过只证明本地契约,不代表真实 SaaS、management 或生产验收。不要把现行 v1 与本轮契约拼成一个线上协议。示例为非生产数据,JSON 代码块均为完整请求或返回体;字段说明写在块外。
路径来源:`/internal/v1/dispatcher/sip`、`/internal/v1/dispatcher/task/:task_id`、`/internal/v1/dispatcher/tasks`(含 `?after=<cursor>`)为用户给定路径;`/internal/v1/dispatcher/tenant/:tenant_id/quota` 与 `route_policy_id`、`caller_profile_id`、`allowed_trunk_ids` 是项目定义的新增路径/字段,按本文实现并在真实对接时记录兼容状态。
## 0. 本轮项目内实施规则
- 本文是字段与状态语义的唯一说明;机器校验文件为[配置读取 Schema](../contracts/config-read-v0.1.schema.json)、[任务发现 Schema](../contracts/task-discovery-v0.1-proposal.schema.json)、[命令/控制 Schema](../contracts/command-next-v0.1-proposal.schema.json)、[最终结果 Schema](../contracts/call-result-v0.1-proposal.schema.json),队列拓扑为[MQ 拓扑文件](../contracts/mq-topology-v0.1-proposal.json)。它们均为项目内版本,不修改现行上游 v1 契约。
- 每个 SaaS↔D JSON 消息体按 UTF-8 序列化后最多 **8,388,608 bytes**。超限结果保留在持久 outbox,标记 `blocked_payload_too_large` 并记录 event_id/字节数/SHA-256;不发布、不截断、不拆分、不丢弃,需由显式版本变更处理。
- 对已接纳且预期有录音的通话,上传阶段最迟在 `call.ended_at + 15m` 收口;OSS 成功发送 `uploaded`,明确 PUT 失败立即发送 `unavailable`,仍无确定结果则到期发送 `unavailable`。授权固定 15 分钟;每个录音/upload_id/object_key 组合最多一次 PUT。授权在 PUT 前过期时,Agent 可在上述截止时间内显式向 D 为同一 upload_id/object_key 重新申请授权;不自动续期或创建第二份资产,任何已发起 PUT 都不得重试。确认未产生录音的 `not_created` 立即收口。`call.result` 只生成一次,MQ 重投复用原 event_id。
- 控制按 task 串行处理,并核对最新任务状态:pause 只接受权威状态 `paused`,resume 只接受 `running` 且本地未 stopped,stop 只接受 `stopped`;乱序/不一致时保持准入关闭并拒绝,stopped 不可逆。控制本身无 command_id/expected revision,不按消息身份去重,重复动作只保持状态幂等。
- SaaS 先持久 stopped 并停止向任务队列发布,再发 stop。D 持久屏障后静默 ACK 全部未接纳积压,已接纳通话继续按策略收口;仅在任务队列排空后发 `task.control` 的 stopped/applied 回执。SaaS 收到该回执后才可删除队列/绑定;离线或无回执时保留队列。队列只由 SaaS 创建/删除,D 不声明、不绑定、不删除;任务发现 `removed` 只在该退役顺序之后发出,D 清配置但保留执行恢复和结果 outbox。
- 每个 D 最多允许 256 个仍归属或正在退役的任务队列;task_id 仅允许 ASCII `[A-Za-z0-9_-]{1,128}`,精确命名与最大字节数见 MQ 拓扑文件。`tasks` 每页最多 100 条;分页期间固定 snapshot/window,只有全部页面成功持久化后才推进游标。`cursor`/page token 均为不透明值;过期返回 HTTP 410 和 `cursor_expired`/`snapshot_expired`,D 重新取全量快照。轮询周期 30 秒不是端到端发现 SLA。
## 1. 触发顺序
| 顺序 | 请求与触发 | SaaS 处理/返回 |
| --- | --- | --- |
| 1 | D 启动或配置到期,带 `X-DISPATCHER-id`/`X-DISPATCHER-SECRET-KEY` 请求 `/internal/v1/dispatcher/sip`(拟定 HTTP GET)。 | SaaS 返回本 D 唯一获批版本;D 核验后才能接受新执行。 |
| 2(下一版拟定) | D 启动/重启 `GET /internal/v1/dispatcher/tasks` 取得本 D 的任务全量快照与变更游标,运行中每 30 秒 `GET /internal/v1/dispatcher/tasks?after=<cursor>`;SaaS 创建任务时先建好任务队列/绑定再发布。 | D 发现新任务后仅消费 SaaS 已创建的队列;D 离线期间消息可留在队列,较小任务 ID 的更新/停止也能由变更游标发现。路径、字段、30 秒时限尚待 SaaS 签收。 |
| 2(本地目标契约) | D 启动/重启 `GET /internal/v1/dispatcher/tasks` 取得本 D 的任务全量快照与变更游标,运行中每 30 秒 `GET /internal/v1/dispatcher/tasks?after=<cursor>`;SaaS 创建任务时先建好任务队列/绑定再发布。 | D 发现新任务后仅消费 SaaS 已创建的队列;D 离线期间消息可留在队列,较小任务 ID 的更新/停止也能由变更游标发现。30 秒是轮询周期,不是发现 SLA;真实 SaaS 兼容性未验证。 |
| 3 | SaaS 将任务固定分配给一个 D,向该 D 投递 `call.execute`(下一版精简 payload,**非现行 Schema**)。 | D 按消息中的任务 ID 请求 `GET /internal/v1/dispatcher/task/:task_id`,核验归属 D 的任务快照(拟定 HTTP);未接纳任务可受约 60 秒配置缓存延迟影响,已接纳执行固定原快照。 |
| 4 | D 从任务取得 `tenant_id`,请求拟定 `GET /internal/v1/dispatcher/tenant/:tenant_id/quota`,与同租户其他任务共享额度后判定接纳。 | 额度缺失/过期不接新呼叫;普通接纳/拒绝有 `command.result`,**已停止任务的未接纳积压仅消费并 ACK,无逐条回传**。 |
| 按需 | SaaS 投递 `task.control` 暂停、恢复或停止(下一版草案)。 | 控制本身有回执;暂停保留积压,恢复消费原队列;停止持久生效后静默消费并 ACK 未接纳积压,不拨号、不向 SaaS 回传这些消息的结果。已在途通话仍按策略处理并给最终结果。 |
| 5 | 通话终结且录音已上传 OSS,D 投递一条 `call.result`(**拟定 MQ 最终事件**)。 | SaaS 只处理这条最终的通话详情,按 `event_id` 去重;录音以 `bucket/object_key` 关联,不接收文件、不提供上传会话或验证结果。上传失败/超时的最终收口见 §4.2,时限尚未签收。 |
| 5 | 通话终结且录音已上传 OSS,D 投递一条本地目标事件 `call.result`。 | SaaS 只处理这条最终的通话详情,按 `event_id` 去重;录音以 `bucket/object_key` 关联,不接收文件、不提供上传会话或验证结果。上传失败/超时按 §0 和 §4.2 的 15 分钟规则收口。 |
### 1.1 现行 MQ 地址与 JSON 字段不是一回事
@@ -31,24 +42,30 @@ D→SaaS exchange: agent-call.saas.v2
例如 §3.1 的 JSON 带 `dispatcher_id=c046b893-8628-4589-ae50-619d049248a6`、`tenant_key=tenant-a`,SaaS 的**MQ 发布参数**就对应 `d.c046b893-8628-4589-ae50-619d049248a6.t.tenant-a.in`;D 消费队列 `agent-call.d.c046b893-8628-4589-ae50-619d049248a6.t.tenant-a.v2`。exchange、routing key、queue 和 binding **不在 JSON 的 `payload` 中**;JSON 的 `dispatcher_id/tenant_key` 是接收后核验身份。当前消费者启动需要 `--tenant-key` 且由 D 声明租户队列;这不是动态任务发现能力。即使 D 离线,只要队列/绑定已由有权一方预先创建并且消息持久入队,重启后仍可消费;若发布时队列不存在,事后建队**不能倒灌旧消息**,SaaS 要保留原消息并确认就绪后重投,mandatory 返回与 publisher confirm 应同时核对。`dispatcher_id` 是唯一 UUID v4;`tenant_key` 保留原值。仅 publisher confirm **不等于** SaaS 已处理。
### 1.2 下一版任务队列 KEY(项目示意,尚无发布的机器合同)
### 1.2 本轮任务队列与事件路由(项目内 v0.1 契约)
**硬边界:任务队列与绑定只由 SaaS 创建/维护/退役;D 只消费,不声明、创建、绑定或删除。** SaaS 必须先确认持久队列/精确绑定就绪,再发布 persistent 命令。下面的 `v3-draft` 名称仅展示规则,**不是现网 exchange/queue,也不是可直接上线的名字**:
**硬边界:所有 exchange/queue/binding 均由 SaaS 创建、维护和退役;D 只消费 SaaS 创建的任务/控制队列,并向 SaaS 创建的结果 exchange 发布,不声明、绑定或删除队列。** 现行外部 MQ v2 拓扑保持原样;本轮本地目标使用 v3 名称,完整机器拓扑见 [MQ topology](../contracts/mq-topology-v0.1-proposal.json)。
```text
SaaS 创建并绑定:
exchange: agent-call.dispatchers.v3-draft
routing key: d.<dispatcher_id>.task.<task_id>.in
binding key: d.<dispatcher_id>.task.<task_id>.in
queue: agent-call.d.<dispatcher_id>.task.<task_id>.v3-draft
D 仅按 SaaS /tasks 清单中的 queue 名称开始消费。
exchange: agent-call.dispatchers.v3 (topic, durable)
task routing: d.<dispatcher_id>.task.<task_id>.in
task queue: agent-call.d.<dispatcher_id>.task.<task_id>.v3
control route: d.<dispatcher_id>.control.in
control queue: agent-call.d.<dispatcher_id>.control.v3
dead-letter: agent-call.dead-letter.v3
D -> SaaS exchange: agent-call.saas.v3 (topic, durable)
result route: d.<dispatcher_id>.out
SaaS result queue: agent-call.saas.d.<dispatcher_id>.v3
```
`dispatcher_id` 选唯一 D,`task_id` 选该 D 的**一项任务**,`.in` 区分入站;`tenant_key` **不再参与任务队列 KEY**,仍在消息 JSON 和 `/tasks` 结果中,用于租户归属核验及跨任务汇总并发。若 task_id 有重复、点号/通配符或超长,不能直接照拼:ID 唯一性、段格式、完整 key/queue 长度及最终版本名必须先由 F07 冻结。`task.control` 另走 SaaS 创建的 **D 专用控制队列**,不能排在某个任务的呼叫积压之后;命令回执和最终结果回 SaaS 的具体新路由同样待 F07 发布,不套用这些示意名称。暂停/停止靠任务状态,**不是 KEY 中有 task_id 就会自动清空队列**;暂停不丢积压,恢复后继续消费;已停止任务的未接纳旧命令静默消费并 ACK,不产生逐条回执/最终结果,但本地计数和错误可查。
所有业务队列 durable、非 exclusive、非 auto-delete;发布消息设 persistent、mandatory,并启用 publisher confirm。SaaS 必须先确认目标队列及精确 binding 已就绪再发布;未路由或 confirm 不成功时保留原消息,恢复后以相同身份/正文重发。D 持久 inbox 与状态提交成功后才 ACK;`call.execute.command_id` 去重并禁止二次 originate。D 的结果 outbox 只有在无 mandatory return 且收到 positive confirm 后才标记已交付;confirm 仅证明 broker 接收,不代表 SaaS 应用处理。Schema/JSON 错误在记录脱敏事实后 `nack(requeue=false)`,由 SaaS 配置的 dead-letter binding 接收;不得静默 ACK 丢弃或无限 requeue。
## 2. D ← SaaS:只读配置与任务发现(拟定,非现网)
`dispatcher_id` 是小写 canonical UUID v4;`task_id` 全局唯一且仅允许 ASCII `[A-Za-z0-9_-]{1,128}`,不含点号、通配符或分隔符。每个 D 最多 256 个尚未退役的任务队列(含 stopped/draining);`tenant_key` 不进入 queue/routing key,仍按原值保留在消息中并用于额度归属。AMQP routing key 和 queue name 上限均为 255 bytes;上述 task routing key 最长 175 bytes、task queue 最长 186 bytes。`task.control` 走独立 D 控制队列;`command.result`/`call.result` 统一走 per-D result route。消息不设置 broker TTL,`not_after` 由 D 校验并明确拒绝过期命令;stopped 任务积压仍由 D 静默 ACK。
拟定的四个 GET 均**无请求 JSON 体**,统一使用 `X-DISPATCHER-id`(全局唯一 D UUID)和 `X-DISPATCHER-SECRET-KEY`(受控密钥,绝不写入文档/日志)。具体 SaaS 地址及 Header 校验/轮换仍待签收。SIP 返回本 D 全量;单任务按路径中的 `task_id` 查询,SaaS 必须核对归属 D 与原值 `tenant_key`,任务发现则按 D 返回归属清单。**不使用 ETag、If-None-Match 或 304**:任务与 SIP 配置约 60 秒缓存到期时 GET 完整 200 响应,失败只停新准入,已接纳执行保持绑定快照;MQ 控制不等待配置缓存。`tasks` 的每 30 秒增量轮询另见 §2.5。
## 2. D ← SaaS:只读配置与任务发现(本地目标契约,非现网接口)
四个 GET 均**无请求 JSON 体**,统一使用 `X-DISPATCHER-id`(全局唯一 D UUID)和 `X-DISPATCHER-SECRET-KEY`(受控密钥,绝不写入文档/日志)。本地 Mock 使用隔离测试凭据;真实 SaaS 地址、认证实现及轮换未验证,不阻塞本地开发。SIP 返回本 D 全量;单任务按路径中的 `task_id` 查询,SaaS 必须核对归属 D 与原值 `tenant_key`,任务发现则按 D 返回归属清单。**不使用 ETag、If-None-Match 或 304**:任务与 SIP 配置约 60 秒缓存到期时 GET 完整 200 响应,失败只停新准入,已接纳执行保持绑定快照;MQ 控制不等待配置缓存。`tasks` 的每 30 秒增量轮询另见 §2.5。
### 2.1 SIP 配置:200,返回本 D 的完整获批快照
@@ -427,26 +444,26 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
**字段说明/消费动作:**字段与 2.2 相同,但 `agent.config.mode=asr_only`,**没有** LLM、提示词或 TTS 对象;配置内外 `agent_version_id` 必须一致。只能按授权的识别配置执行,不应将未提供的字段填成默认值。
### 2.4 SIP 或任务:错误返回(拟定;HTTP 状态码未定)
### 2.4 SIP 或任务:错误返回(`resource_not_found`,HTTP 404,项目内规则)
```json
{
"schema_version": "config-read.v0.1",
"resource": "error",
"error": {
"code": "not_assigned",
"code": "resource_not_found",
"message": "Task is not assigned to this Dispatcher."
}
}
```
**字段说明/消费动作:**`schema_version/resource` 标识草案错误对象;`error.code` 是机器可读错误代码(示例 `not_assigned` 表示该任务不归此 D),`error.message` 是可读说明,不含密钥。D 不得将失败当作空任务/无限制或使用过期配置接新呼叫;不能自动回退至 MQ 配置通道。
**字段说明/消费动作:**`schema_version/resource` 标识项目内错误对象;`error.code` 是机器可读错误代码(`resource_not_found` 同时表示任务不存在或不归此 D),`error.message` 是可读说明,不含密钥。本地将此错误映射为 HTTP 404;真实 SaaS 是否采用相同状态码尚未验证。D 不得将失败当作空任务/无限制或使用过期配置接新呼叫;不能自动回退至 MQ 配置通道。
### 2.5 D ← SaaS:动态任务发现(**下一版草案,现行无此接口/Schema**)
### 2.5 D ← SaaS:动态任务发现(本轮项目内契约;现行外部接口未验证)
第三条只读 HTTP 接口是 `GET /internal/v1/dispatcher/tasks`,与 §2.1/§2.2 两条配置响应分开。D 启动/重启时不带 `after` 读取**一致全量快照 + 游标**,运行中**每 30 秒** `GET /internal/v1/dispatcher/tasks?after=<cursor>` 读取针对本 D 的变更。`after` 是 SaaS 的变更水位,**不是最大 `task_id`**;旧任务的暂停、停止、改派也会返回。路径和两个 Header 作为本轮需求输入,响应 JSON、错误码/分页仍是项目草案,待 SaaS/F07 签收;30 秒是轮询频率,不是端到端 30 秒发现保证,也不同于单任务配置约 60 秒缓存。所有 GET 无请求体,密钥只由受控部署注入。
第三条只读 HTTP 接口是 `GET /internal/v1/dispatcher/tasks`,与 §2.1/§2.2 两条配置响应分开;机器约束见[任务发现 Schema](../contracts/task-discovery-v0.1-proposal.schema.json)。D 启动/重启时不带 `after` 读取**一致全量快照 + 游标**,运行中**每 30 秒** `GET /internal/v1/dispatcher/tasks?after=<cursor>` 读取针对本 D 的变更。`after` 是 SaaS 的变更水位,**不是最大 `task_id`**;旧任务的暂停、停止、改派也会返回。每页最多 100 条;全量续页使用 `?snapshot_id=<snapshot_id>&page_token=<opaque_token>`,增量续页使用 `?after=<原始游标>&page_token=<opaque_token>`。全量所有页的 `snapshot_id/cursor` 必须固定;同一增量窗口的 `from_cursor/next_cursor` 必须固定,变更按 SaaS 返回顺序应用。只有全部页已持久化后才能原子推进游标。所有 GET 无请求体,密钥只由受控部署注入。成功为 HTTP 200;无效游标/page token 为 400,身份无效为 401、D 无权为 403,`cursor_expired`/`snapshot_expired` 为 410,临时不可用为 503。遇到 410 或分页连续性错误时不得推进游标,暂停新接纳并重取完整快照。30 秒是轮询频率,不是端到端发现保证,也不同于单任务配置约 60 秒缓存。当前正文 JSON 块是项目正例,额外/未知字段拒绝例见[非法队列字段样例](../contracts/examples/task-discovery-invalid-queue-property-v0.1.json);外部兼容性未验证。
### 2.5.1 启动或重启:全量快照(200,拟定)
### 2.5.1 启动或重启:全量快照(HTTP 200,本地目标)
```http
GET /internal/v1/dispatcher/tasks HTTP/1.1
@@ -472,10 +489,10 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
"status": "running",
"task_revision": 1,
"queue": {
"exchange": "agent-call.dispatchers.v3-draft",
"exchange": "agent-call.dispatchers.v3",
"routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in",
"binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in",
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.v3-draft"
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.v3"
}
},
{
@@ -485,10 +502,10 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
"status": "stopped",
"task_revision": 3,
"queue": {
"exchange": "agent-call.dispatchers.v3-draft",
"exchange": "agent-call.dispatchers.v3",
"routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-old.in",
"binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-old.in",
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-old.v3-draft"
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-old.v3"
}
}
],
@@ -496,9 +513,9 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
}
```
**字段说明/消费动作:**`dispatcher_id` 是被授权的目标 D;`snapshot_id` 锁定同一次全量读取,跨页不得混杂新旧状态;`cursor` 是此快照覆盖的 SaaS 任务变更水位(示例数字只是**不透明字符串**,D 不按大小比较任务 ID);`tasks[]` 列出本 D 全部归属任务及**已停止但队列仍有积压的任务**;`tenant_id` 用于读取 §2.6 额度,`tenant_key` 保留原值并与 tenant_id 一对一核验,用于同租户所有任务共享并发额度;`task_revision/status` 是任务版本和状态;`queue` 是**SaaS 已创建/绑定**的消费地址,D 只能读取,不能自行声明。`next_page_token` 非空时须在同一个 `snapshot_id` 下读完所有页再应用快照/水位,分页传递机制待签收。
**字段说明/消费动作:**`dispatcher_id` 是被授权的目标 D;`snapshot_id` 锁定同一次全量读取,跨页不得混杂新旧状态;`cursor` 是此快照覆盖的 SaaS 任务变更水位(示例数字只是**不透明字符串**,D 不按大小比较任务 ID);`tasks[]` 列出本 D 全部归属任务及**已停止但队列仍有积压的任务**;`tenant_id` 用于读取 §2.6 额度,`tenant_key` 保留原值并与 tenant_id 一对一核验,用于同租户所有任务共享并发额度;`task_revision/status` 是任务版本和状态;`queue` 是**SaaS 已创建/绑定**的消费地址,D 只能读取,不能自行声明。`next_page_token` 非空时,按 `?snapshot_id=<snapshot_id>&page_token=<next_page_token>` 续读同一快照;读完所有页后再应用快照/水位。
### 2.5.2 每 30 秒:增量变化(200,拟定)
### 2.5.2 每 30 秒:增量变化(HTTP 200,本地目标)
```http
GET /internal/v1/dispatcher/tasks?after=1042 HTTP/1.1
@@ -526,10 +543,10 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
"status": "running",
"task_revision": 1,
"queue": {
"exchange": "agent-call.dispatchers.v3-draft",
"exchange": "agent-call.dispatchers.v3",
"routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-b.in",
"binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-b.in",
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-b.v3-draft"
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-b.v3"
}
},
{
@@ -541,10 +558,10 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
"status": "stopped",
"task_revision": 2,
"queue": {
"exchange": "agent-call.dispatchers.v3-draft",
"exchange": "agent-call.dispatchers.v3",
"routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in",
"binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in",
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.v3-draft"
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.v3"
}
}
],
@@ -554,7 +571,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
**字段说明/消费动作:**`from_cursor` 对应请求的 `after`,`changes[].cursor` 是 SaaS 为本 D 变更生成的顺序水位,`next_cursor` 是成功处理整份回复后的下一次 `after`;`assigned` 为新归属、`updated` 为旧任务版本/状态变化。**任务 `task-a` 的 ID 比新任务旧,却仍被增量返回**,这正是不能用最大任务 ID 当游标的原因。先由 SaaS 创建/绑定任务队列并确认 ready,才能把 `assigned` 返回且开始发布;D 只消费。`stopped` 持久生效后 D 继续读该任务未接纳积压、静默 ACK,不拨号、不逐条回传,也不删除队列;本地日志/计数保留。暂停/停止命令另走 SaaS 创建的 D 控制队列;30 秒任务清单轮询**不能代替即时控制**。同一租户 `task-a`、`task-b` 共同占用 `tenant-a` 额度。D 持久应用变更后才持久推进游标;分页时读完连续页,不得跳过未处理页。
### 2.5.3 任务改派/退役(200,拟定;与停止不同)
### 2.5.3 任务改派/退役(HTTP 200,本地目标;与停止不同)
```json
{
@@ -576,9 +593,9 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
}
```
**字段说明/消费动作:**`removed` 是 SaaS 确认此 D 不再消费该任务的撤销记录(tombstone),**不是** `stop` 一到就立刻删除队列。必须已停止新发布、旧队列积压和未 ACK 消息处理完毕,且改派时确认旧 D 没有未知执行后再终结旧所有权;具体握手/退役合同待签收。D 只停止消费,不负责删队列;队列生命周期仍归 SaaS。
**字段说明/消费动作:**`removed` 是 SaaS 确认此 D 不再消费该任务的撤销记录(tombstone),**不是** `stop` 一到就立刻删除队列。必须已停止新发布、旧队列积压和未 ACK 消息处理完毕,且改派时确认旧 D 没有未知执行后再终结旧所有权;停止回执与 SaaS 删除队列的顺序按 §0 执行。D 只停止消费,不负责删队列;队列生命周期仍归 SaaS。
### 2.5.4 没有变更(200,拟定)
### 2.5.4 没有变更(HTTP 200,本地目标)
```json
{
@@ -594,7 +611,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
**字段说明/消费动作:**SaaS 没有新变更时水位不动;D 等下一个 30 秒周期,不因空列表删除已有消费关系。
### 2.5.5 游标失效或缺页(错误,HTTP 状态待签收)
### 2.5.5 游标失效或缺页(HTTP 410,项目内规则)
```json
{
@@ -607,7 +624,11 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
}
```
**字段说明/消费动作:**`cursor_expired` 表示 SaaS 已不能提供从旧游标起的连续变更;缺页、断续或快照分页不一致也应中止增量。D 不推进错误游标,停受影响任务的新接纳并重新拉一致全量快照;不能把错误当无变更或盲目根据 RabbitMQ 队列列表发现任务。真正的错误码、游标保留期/分页格式须 F07 签收。
**字段说明/消费动作:**`cursor_expired` 表示 SaaS 已不能提供从旧游标起的连续变更;缺页、断续或快照分页不一致也应中止增量。HTTP 410 的 `cursor_expired` 或 `snapshot_expired` 均不推进游标;D 暂停新接纳,重新拉取并完整持久化一致快照后恢复。不能把错误当无变更或盲目根据 RabbitMQ 队列列表发现任务。游标保留多久由 SaaS 决定,超出时必须返回上述 410 错误,不得返回部分成功。
### 2.5.6 其他发现错误的 HTTP 状态(项目内规则)
`invalid_cursor` 和 `invalid_page_token` 返回 HTTP 400;`unauthorized` 返回 401;`dispatcher_not_authorized` 返回 403;`service_unavailable` 返回 503。HTTP 状态不写入 JSON body,错误 body 严格符合任务发现 Schema。任一失败均不得推进游标或视为空变更;D 保留已持久状态并关闭新准入,410 按 §2.5.5 重新获取并完整持久化全量快照后才能恢复。该映射是项目内规则,真实 SaaS 兼容性未验证。
### 2.6 D ← SaaS:按租户 ID 获取并发额度(新增项目草案)
@@ -654,7 +675,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
**字段说明/消费动作:**0明确禁止新准入,不是无限额。降额时不强挂已有通话、不清未知占用,等占用低于新上限且授权有效才再接新。stop静默排空与控制不需要通话额度,不能因额度0卡住停止任务。
#### 2.6.3 缺失或失败:错误(HTTP 状态待签收)
#### 2.6.3 无可用租户额度:错误(HTTP 503,项目内规则)
```json
{
@@ -667,11 +688,11 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
}
```
**字段说明/消费动作:**缺失、身份不符、过期或刷新失败关闭该租户新准入,不用任务额度或无限额兜底;已有执行依原快照处理。核实通话终结并释放执行资源就释放通话额度,**不等待录音上传或最终结果 MQ 确认**;未知通话不能释放。
**字段说明/消费动作:**服务端无法提供有效租户份额(缺失、过期或暂不可用)时,本地返回 HTTP 503 与 `tenant_quota_unavailable`;收到 `200` 但身份与请求/任务不符时,D 拒绝并关闭该租户新准入。不得用任务额度或无限额兜底;已有执行依原快照处理。核实通话终结并释放执行资源就释放通话额度,**不等待录音上传或最终结果 MQ 确认**;未知通话不能释放。
## 3. SaaS → D:下一版精简业务命令(**草案,现行严格 MQ Schema 不支持**)
## 3. SaaS → D:下一版精简业务命令(**项目内 F07 v0.1 规则;现行严格 MQ Schema 不支持**)
以下 JSON 均是**完整的拟定下一版 MQ 请求**,`schema_version=command-next.v0.1-proposal` 是草案标记,不是现行 `2.0`。`dispatcher_id/tenant_id/tenant_key` 确定 D 和租户,MQ 发布参数另按 §1 任务 key 精确路由;`issued_at/not_after` 限定有效期;`command_type` 区分呼叫或控制。**仅** `call.execute` 仍带 `command_id`,用来识别不可重复的外呼执行;三个 `task.control` 均不带 `command_id`、`expected_task_revision`,本阶段不设计控制命令去重。没有这些字段后,控制的乱序、重投以及处理回执如何关联必须在 F07 由 SaaS 签收并如实验收,不能冒称当前协议已经支持。
以下 JSON 是本项目 F07 冻结的完整下一版 MQ 请求;`schema_version=command-next.v0.1-proposal` 标识项目内版本,不是现行外部 `2.0`。`dispatcher_id/tenant_id/tenant_key` 确定 D 和租户,MQ 发布参数另按 §1 任务 key 精确路由;`issued_at/not_after` 限定有效期;`command_type` 区分呼叫或控制。**仅** `call.execute` 仍带 `command_id`,用来识别不可重复的外呼执行;三个 `task.control` 均不带 `command_id`、`expected_task_revision`,本地不设计控制命令去重。控制的乱序、重投及处理回执按本节规则和本地 Schema/Mock 测试处理;真实 SaaS 兼容性及从现行 v2 切换仍未验证,不属于本地 C 的外部验收证据。
### 3.1 发起外呼:call.execute
@@ -693,7 +714,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
}
```
**字段说明/消费动作:**`payload` **只有** `task_id`(SaaS 任务身份)及 `callee`(原始被叫号码,不带线路前缀);租户归属从信封及 `/internal/v1/dispatcher/task/:task_id` 的授权结果核对。路由/主叫/智能体版本和任务级 `ring_timeout_ms/max_call_duration_ms` 全由有效任务配置取得,D 接纳时绑定不可漂移的执行快照;生成 `execution_id` 是 D 内部事实,不由 SaaS 逐呼提供。信封 `command_id` 仅用于外呼命令身份:重投不能第二次拨号。本例15分钟有效期仅示意,不是默认值;SaaS 须覆盖其允许的轮询/配置/额度准备及排队时间。队列ready不代表D已消费;离线或暂停不延长not_after,恢复仅执行仍有效者,过期非stopped消息明确拒绝、不自动重建命令,stopped积压静默ACK。现行 MQ `2.0` 仍要求旧 payload,新版严格 Schema 和 SaaS 发布端未签收/修改前**不能直接用此消息上线**。
**字段说明/消费动作:**`payload` **只有** `task_id`(SaaS 任务身份)及 `callee`(原始被叫号码,不带线路前缀);租户归属从信封及 `/internal/v1/dispatcher/task/:task_id` 的授权结果核对。路由/主叫/智能体版本和任务级 `ring_timeout_ms/max_call_duration_ms` 全由有效任务配置取得,D 接纳时绑定不可漂移的执行快照;生成 `execution_id` 是 D 内部事实,不由 SaaS 逐呼提供。信封 `command_id` 仅用于外呼命令身份:重投不能第二次拨号。本例15分钟有效期仅示意,不是默认值;SaaS 须覆盖其允许的轮询/配置/额度准备及排队时间。队列ready不代表D已消费;离线或暂停不延长not_after,恢复仅执行仍有效者,过期非stopped消息明确拒绝、不自动重建命令,stopped积压静默ACK。此为项目内 v0.1 payload,由[命令/控制 Schema](../contracts/command-next-v0.1-proposal.schema.json)严格校验并由本地 C 验证;真实 SaaS 兼容性和从现行 v2 切换未验证,不属于本地通过证据。
### 3.2 暂停任务:task.control / pause
@@ -716,7 +737,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
}
```
**字段说明/消费动作:**`task_id` 定位任务;`action=pause` 停止新呼叫准入;`active_call_policy=drain` 允许在途通话自然结束;`reason` 是原因说明。此版**无 `command_id`、无 `expected_task_revision`、不定义控制去重**。D 保存暂停屏障并停止消费该任务新执行,已经交付但未接纳的有界消息退回原队列,不能ACK丢弃或搬入无界本地待拨队列;恢复会继续消费原积压,无需SaaS重发。已有执行按所选策略处理,暂停控制本身有回执。
**字段说明/消费动作:**`task_id` 定位任务;`action=pause` 停止新呼叫准入;`active_call_policy=drain` 允许在途通话自然结束;`reason` 是原因说明。控制**无 `command_id`、无 `expected_task_revision`,不做按消息去重**。D 持久暂停屏障、停止该队列消费,并将已预取但未接纳的消息 `nack(requeue=true)` 回原队列;不 ACK 丢弃、不搬入本地待拨队列。已接纳通话按 `drain/hangup` 执行;控制回执在屏障持久且未接纳投递已退回后发送,不等待通话结束。SaaS 按每任务状态变更顺序发布控制,D 每任务串行处理。
### 3.3 恢复任务:task.control / resume
@@ -738,7 +759,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
}
```
**字段说明/消费动作:**resume 成功就是恢复消费**原任务队列的积压**,不是等待 SaaS 重发。D 必须绕过缓存读取最新任务,状态running且归属/授权/额度/时段有效、本地未stopped,才能解除paused;每条旧命令仍校验not_after,过期明确拒绝,不延长期限或等待次日。已停止任务不可恢复。请求无编号/修订、不设计控制去重,乱序时按下方状态优先级处理。
**字段说明/消费动作:**resume 成功就是恢复消费**原任务队列的积压**,不是等待 SaaS 重发。D 必须绕过缓存读取最新任务;仅当权威状态为 `running`、D/租户归属有效且本地从未 stopped 时解除 paused。每条旧命令仍校验 `not_after`,过期明确拒绝,不延长期限或等待次日。已停止任务不可恢复。控制无编号/修订,不去重;重复 resume 对状态幂等,但每次实际投递都可有独立回执。
### 3.4 停止任务:task.control / stop
@@ -761,11 +782,11 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
}
```
**字段说明/消费动作:**`stop` 持久终止任务准入,SaaS 同步停止继续发布;D 小批量**静默消费并ACK所有尚未接纳积压**,不拨号、不发逐条 `command.result` 或 `call.result`,不申请通话额度。不是purge/delete队列,也不影响其他任务。ACK丢失、重启、额度0、配置失效后依旧排空且不补发结果;保留本地计数/错误。**停止控制本身仍有回执**;已接纳/在途通话按 `hangup` 或 `drain` 处理,并照常给真实最终结果,不能因“静默”丢弃它们。stopped 同任务ID不能resume。
**字段说明/消费动作:**`stop` 持久终止任务准入,SaaS 先停止该队列发布并确认已有发布处理完,再投递 stop。D 持久 stopped 屏障后静默 ACK 所有未接纳积压,不拨号、不发逐条 `command.result`/`call.result`、不申请额度;不是 purge/delete,也不影响其他任务。D 取消普通 consumer,结清已预取消息后用 `basic.get` 排空队列至空;仅在无未 ACK 投递且确认空队列后发送 stopped/applied 回执。SaaS 收到回执后才可删除队列/绑定并在任务发现中发 `removed`。ACK 丢失、重启、额度 0 或配置失效不改变排空规则;保留本地计数/错误。已接纳/在途通话按 `hangup` 或 `drain` 处理并照常发最终结果;stopped 同任务 ID 不可 resume。
**配置、任务发现和控制的状态优先级(拟定):**SaaS 先持久变更权威任务状态,再发控制;D 对同任务串行处理控制/接纳。stopped不可逆,paused只能由上述有效resume解锁,旧running配置/清单不能解锁,低于已知task_revision的状态不可覆盖新状态。重启恢复本地屏障及全量时取更严格者;快照可关准入、不能擅自重开。pause/stop先关准入,最新权威状态不符/读取失败或迟到控制产生冲突时保守保持关闭并返回明确失败;需有效新resume才能恢复。不设计控制消息去重,可能多次回执;不能把MQ发布成功当控制已应用。该规则须F07双方签收,不是现行代码已保证。
**任务发现与控制状态规则(项目内 v0.1):**SaaS 先持久变更权威任务状态,再按每任务顺序发布控制;D 对同任务串行处理。stopped 不可逆;paused 只能由新鲜任务 GET 确认 `running` 的 resume 解锁。D 不允许旧 running 配置/快照覆盖更高 `task_revision` 或清除本地 stopped 屏障;重启恢复持久屏障,全量快照只能收紧准入,不能自行重开。pause/stop 先持久关闭准入;action 与最新任务状态不一致、读取失败或出现乱序冲突时保持关闭并返回 `state_mismatch`/`task_unavailable`。重复 pause/resume/stop 只对状态幂等,不做控制消息去重;每次处理都可产生独立 `event_id` 回执,回执自身重投复用原 event_id。MQ 发布成功不等于控制已应用。
### 3.5 D → SaaS:外呼命令处理回执(草案,不是通话结果)
### 3.5 D → SaaS:外呼命令处理回执(项目内 v0.1,不是通话结果)
```json
{
@@ -790,9 +811,9 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
}
```
**字段说明/消费动作:**`payload.command_id` 仅指向 §3.1 的外呼命令;`status` 区分接纳/拒绝,`execution_id` 是 D 接纳后生成的执行身份。已停止任务的未接纳积压**不发送此回执**;其他未接纳拒绝只有命令回执、不伪造通话。MQ 回执**不代表已拨号或已完成通话**,未知执行不得靠重投产生第二次呼叫;下一版字段/版本仍待 F07 签收。
**字段说明/消费动作:**`payload.command_id` 仅指向 §3.1 的外呼命令;`status` 区分接纳/拒绝,`execution_id` 是 D 接纳后生成的执行身份。已停止任务的未接纳积压**不发送此回执**;其他未接纳拒绝只有命令回执、不伪造通话。MQ 回执**不代表已拨号或已完成通话**;按 `command_id` 持久去重,未知执行不得靠重投产生第二次呼叫。字段由项目内 Schema 校验。
### 3.6 D → SaaS:任务控制处理回执(草案;与外呼回执分开)
### 3.6 D → SaaS:任务控制处理回执(项目内 v0.1;与外呼回执分开)
```json
{
@@ -818,11 +839,11 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
}
```
**字段说明/消费动作:**控制请求不带 `command_id/expected_task_revision`,回执以 `task_id/action/status/task_state` 说明任务和实际处理结果,不提供按原控制编号一对一关联,也不把 `event_id` 用作控制去重身份。SaaS 仅能据已收到的事实更新展示;对控制并发/乱序、丢失回执和重投后的最终状态判定需要 F07 明确,不能把 MQ 发布成功当控制已生效。
**字段说明/消费动作:**控制请求不带 `command_id/expected_task_revision`,回执以 `task_id/action/status/reason_code/task_state` 说明处理事实,不提供按控制编号一对一关联,也不把 `event_id` 用作控制去重身份。D 按最新任务状态和本地终态屏障处理乱序;对同一状态的重复动作可重复回执。回执丢失时 SaaS 以最新任务 GET 和后续状态发现收敛,不能把 MQ 发布成功当控制已生效。
## 4. D → SaaS:唯一通话结果(**拟定新 MQ 合同,尚无已发布 Schema**)
## 4. D → SaaS:唯一通话结果(项目内 v0.1 `call.result` 契约)
同一次通话只发布一种业务反馈 `call.result`:通话状态、最终转写、拒联结果、录音资产一次返回;不再将通话进度、实时文字、拒联、通话结束、录音成功/失败各自发布对外事件。**这会改变现有“实时文字/即时拒联”的产品要求,必须在新版合同与验收中明确批准;SaaS 在最终结果到达前不会获得这些反馈。**停止任务未接纳积压不产生通话事件;其它真实执行的消息仍应可靠入队,断线后按同一事件身份重投;这不是对外“补传命令”。以下三个结构均为待签收提案,不能用现行 MQ/event Schema 校验,也不能作为已上线接口。
同一次通话只发布一种业务反馈 `call.result`:通话状态、最终转写、拒联结果、录音资产一次返回;不再将通话进度、实时文字、拒联、通话结束、录音成功/失败各自发布对外事件。**本轮按该简化实现和验收**,不要求 SaaS 在最终结果前收到实时文字或拒联;真实 SaaS 消费兼容性未验证。停止任务未接纳积压不产生通话事件;其它已接纳执行的消息可靠入队,断线后按原事件身份重投;这不是对外“补传命令”。结果结构由[最终结果 Schema](../contracts/call-result-v0.1-proposal.schema.json)严格校验,不属于现行外部 MQ v2。
### 4.1 录音已上传 OSS:最终成功结果
@@ -883,10 +904,10 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
}
```
**字段说明/消费动作:**`schema_version/event_type` 是**待签收的新版本及单一通话结果类型**,现行 Schema 不认此值;`event_id` 是固定的事件身份,重复入队须相同;`dispatcher_id/tenant_id/tenant_key/trace_id` 限定来源和归属;`aggregate_type/aggregate_id/aggregate_version/occurred_at` 为呼叫聚合、版本和完成时间。
`payload.source_command_id/execution_id/call_id/task_id/task_revision/agent_version_id` 绑定原外呼命令、D 生成的执行/呼叫及从任务快照绑定的固定版本;`route_policy_id/caller_profile_id/trunk_id/callee` 为路由策略、主叫配置、实际线路及原始被叫;`started_at/ended_at/duration_ms/outcome/reason_code` 给出起止、时长、结果和可空原因。`transcript[]` 中 `turn_id/segment_id/role/text/start_ms/end_ms` 是最终转写片段及时间(完整用户文本是否允许外传须由本阶段业务合同确定);`opt_out` 表示通话中的拒联事实,只在最终消息里可见。`recording.status/recording_id/upload_id/bucket/object_key/format/channels/sample_rate_hz/duration_ms/size_bytes/checksum_sha256` 描述已成功上传的资产,不包含文件、TOKEN 或签名 URL。SaaS 使用 `call_id` 关联、`event_id` 去重并按固定 `upload_id` 避免重复资产。
**字段说明/消费动作:**`schema_version/event_type` 是项目内 v0.1 的单一通话结果类型,严格由本地 Schema 校验;现行外部 v2 Schema 保持不变,不能混用。`event_id` 是固定的事件身份,重复入队须相同;`dispatcher_id/tenant_id/tenant_key/trace_id` 限定来源和归属;`aggregate_type/aggregate_id/aggregate_version/occurred_at` 为呼叫聚合、版本和完成时间。
`payload.source_command_id/execution_id/call_id/task_id/task_revision/agent_version_id` 绑定原外呼命令、D 生成的执行/呼叫及从任务快照绑定的固定版本;`route_policy_id/caller_profile_id/trunk_id/callee` 为路由策略、主叫配置、实际线路及原始被叫;`started_at/ended_at/duration_ms/outcome/reason_code` 给出起止、时长、结果和可空原因。`transcript[]` 中 `turn_id/segment_id/role/text/start_ms/end_ms` 是仅随最终结果发送的转写片段及时间;`opt_out` 表示通话中的拒联事实,只在最终消息里可见。`recording.status/recording_id/upload_id/bucket/object_key/format/channels/sample_rate_hz/duration_ms/size_bytes/checksum_sha256` 描述已成功上传的资产,不包含文件、TOKEN 或签名 URL。SaaS 使用 `call_id` 关联、`event_id` 去重并按固定 `upload_id` 避免重复资产。
### 4.2 录音上传未完成:最终异常结果(是否启用及截止时间待签收)
### 4.2 录音上传未完成:15 分钟内收口为最终异常结果
```json
{
@@ -937,9 +958,9 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
}
```
**字段说明/消费动作:**这是**防止录音永远未上传时通话结果永久消失的待定方案**:经合同规定的有限截止时间或确知不可恢复后,`recording.status=unavailable` 且 `bucket/object_key/size_bytes/checksum_sha256=null`,`recording.error_code` 表示未得到录音资产,呼叫自身的 `reason_code` 仍为 null;不能谎称上传成功,也不能默默丢弃最终结果。`outcome` 必须反映**通话本身**而非上传成败;若通话已接通/正常结束,不得仅因录音失败就把 `outcome` 改成 `failed`。具体结果字段、期限、未上传时是否仍发一次最终结果待 SaaS 签收;未签收前不能实施或用无限等待代替错误处理。
**字段说明/消费动作:**若录音预期存在但授权/PUT 明确失败,立即以 `recording.status=unavailable` 收口;若仍无确定结果,最迟于 `call.ended_at + 15m` 收口,`bucket/object_key/size_bytes/checksum_sha256=null`,`recording.error_code` 仅可为 `upload_authorization_failed`、`upload_authorization_expired`、`upload_failed`、`upload_timeout`、`deadline_exceeded` 或 `checksum_mismatch`,分别记录授权、PUT、总期限或校验阶段;呼叫自身 `reason_code` 保持通话事实。不能谎称上传成功或默默丢弃最终结果。`outcome` 必须反映**通话本身**而非上传成败;已接通/正常结束不得因录音失败改成 `failed`。同一录音最多一次 PUT;超时/结果未知不重试 PUT。
### 4.3 正常未产生录音:无应答结果(完整独立例,拟定)
### 4.3 正常未产生录音:无应答结果(完整独立例,项目内规则)
```json
{
@@ -992,12 +1013,13 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
**字段说明/消费动作:**这是已接纳、已尝试但无人接听且未产生录音的呼叫;`started_at/duration_ms` 此例表示呼叫尝试起点和尝试耗时,不冒称已接通时长。正常无录音用 `not_created`,资产字段为null,确认终结后即可发送,不申请/等待上传;忙线等正常无录音同类处理,原因须与事实一致。录音本应生成却失败应为 `unavailable` 加明确阶段原因,不伪装正常无录音。普通未接纳拒绝仅有命令回执;stopped未接纳积压无回执也无最终结果,不能虚构call_id。
**额度与文件交付分离:**确认通话终结、执行资源释放就释放通话额度,不等待OSS或最终通知确认,未知仍占额。已产生录音才按4.1/4.2收口,上传失败有限截止时间仍待签收;完整文字汇总可能超过原MQ大小上限,F07须明确预算和失败处理,不能偷偷截断或恢复被移除的实时事件。
**额度与文件交付分离:**确认通话终结、执行资源释放就释放通话额度,不等待 OSS 或最终通知确认,未知仍占额。已产生录音才按 4.1/4.2 收口;每条 JSON 消息体上限 8,388,608 bytes,超限持久阻塞 outbox,不截断、不拆分、不恢复实时事件。
## 5. 下一版本签收前不得误用
## 5. 本地实现与外部验收边界
- 本文 §3/§4 都是**待签收的下一版 MQ 消息草案**,均不能直接混入现行 v2 合同;先由 SaaS 与本项目发布严格新版 Schema、正反例及新队列拓扑,再实施两端。外呼命令必须有可靠执行身份防重复拨号;控制不带编号/修订,不设计控制去重,其并发/乱序与回执关联后果必须在 F07 明确。
- 取消对外查询与补传命令不取消 D 的持久化恢复、同一身份重投、故障对账和**未知是否已拨号时绝不重拨**。没有核实状态的内部恢复能力不得发布新版本。
- 已产生录音的通话在上传OSS后发布含资产的最终结果;正常未产生录音用not_created并在确认终结后直接回传,不等不存在的上传。应有录音却失败/上传超时的有限期限及unavailable结构必须先签收,不无限等待;通话占用释放独立于文件上传。
- SaaS 不再实时得知拒联及转写,会影响跨任务、跨 D 停呼与实时展示。新目标与既有产品要求冲突,须取得业务签收并修订原有验收,不能凭本文视为既有验收已通过。
- SIP、任务及新增租户额度 HTTP 响应以[项目草案 Schema](../contracts/config-read-v0.1.schema.json)校验;`tasks` 接口、精简 `call.execute`、无编号任务控制、两种回执及新 `call.result` 均**尚无已发布 Schema**。现行 v2 仍以[`mq.schema.json`](../../contracts/upstream/v1/mq.schema.json)、[`event-payloads.schema.json`](../../contracts/upstream/v1/event-payloads.schema.json)为准,不能拿新示例冒充当前可投消息。
- 本文及链接的 `docs/contracts` Schema/正反例/MQ 拓扑是本轮 P1 Go/Mock 的项目内契约。完成 F01/F07 版本、来源/hash、严格校验和 Mock SaaS 端到端 C 后,可直接进入本地实现;不要求真实 SaaS、management 或供应商签收/连通。
- `contracts/upstream/v1/` 与现行外部 MQ v2 继续作为真实 SaaS 的既有基线。本地 v3 路由和消息不得混入 v2,也不得把 Mock 通过写成 SaaS、management 或生产验收。
- 本地 MQ 采用 v3 durable topic/queue,任务和 D 结果队列均由 SaaS 创建维护;D 只消费任务/控制并发布结果。命令用 `command_id` 持久去重防止二次 originate;任务控制无 `command_id/expected_task_revision`、不按消息去重,乱序/过期失败关闭。
- 对外只保留必要命令/控制回执和每个已接纳通话唯一的最终 `call.result`。不保留 query/replay、实时转写/拒联/通话进度/录音拆分事件;stopped 任务未接纳积压只静默 ACK,不产生逐条结果。正常无录音立即以 `not_created` 收口;预期录音失败最晚在 `call.ended_at + 15m` 以 `unavailable` 收口;每个 upload_id 最多一次 PUT,重投复用原 event_id,不重新上传。
- 所有 MQ JSON 正文上限为 8,388,608 bytes。超限消息留在持久 outbox 并显式阻塞,不截断、不拆分、不丢弃。该上限仅为本地 v0.1 规则;真实 SaaS 与 broker 的兼容性需另行验证。
- 四条 GET、严格 Schema、任务发现分页/游标错误、队列退役握手和 `call.result` 正反例均按本文及对应 schema 验证。外部正式版本、部署与切换仍是独立事实和授权门禁。