From f7e87395a3e8dfb7cba51b9795aaccc73fd1ada7 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 23 Sep 2026 16:34:24 +0800 Subject: [PATCH] docs: clarify SaaS Dispatcher message examples --- .../第三方对接事件与请求消费顺序_v0.1.md | 1270 +++++++++++++++-- 1 file changed, 1156 insertions(+), 114 deletions(-) diff --git a/docs/thirds/第三方对接事件与请求消费顺序_v0.1.md b/docs/thirds/第三方对接事件与请求消费顺序_v0.1.md index 09bc569..55ca349 100644 --- a/docs/thirds/第三方对接事件与请求消费顺序_v0.1.md +++ b/docs/thirds/第三方对接事件与请求消费顺序_v0.1.md @@ -1,151 +1,1193 @@ -# SaaS ↔ Dispatcher 第三方对接:请求、事件与消费顺序 v0.1 +# SaaS ↔ Dispatcher:请求、返回与事件消费顺序 v0.1 -**用途:SaaS 与 Dispatcher 的第三方联调说明,不是新增权威 Schema 或生产验收。** 本文按一项任务的一通外呼说明:**谁请求 → 发往何处 → 收到什么 → 消费后做什么**。Dispatcher 接收的内部通话反馈仅体现为它回传 SaaS 的业务事件;内部执行接口、媒体及资产上传操作不在本对接范围。字段结构以[现行 MQ Schema](../../contracts/upstream/v1/mq.schema.json)、[事件 Schema](../../contracts/upstream/v1/event-payloads.schema.json)、[拓扑](../../contracts/upstream/v1/mq-topology.json)为准。两条配置 HTTP 只读接口目前只有[项目自定义字段提案及样例](../contracts/config-read-fields-v0.1-proposal.md),路径、凭据传递和真实 SaaS 响应**未经 SaaS/management 签收**。本轮步骤/验收见[新计划](../plan-config-read-v0.1.md)。 +本文只描述 **SaaS 与 Dispatcher(下称 D)之间的数据交互**。D 回传的通话、转写和录音结果均以 SaaS 收到的消息表示;内部执行及文件上传接口不在此范围。 -> **版本门禁:**现行可核对的 MQ 消息为 `schema_version=2.0`、单 D/单租户本地路径;**现行运行合同仍为全 MQ、静态 SIP、固定 Asia/Shanghai `[09:00,20:00)`、排队任务固定 AI 版本**。用户批准的新目标是配置读 HTTP、业务走 MQ、缓存约 60 秒、新任务×线路时间窗口、有界消费及控制分道;这些**尚未发布/实现**。下文以 `现行`、`拟定` 标识,不能把拟定步骤当作现网可调用接口。原 `docs/thirds/` 六份分散说明已由用户有意删除,本文不引用它们。 +**接口版本边界:**第 2 节两条只读 HTTP 配置接口是**待 SaaS 签收的项目草案,不是现网接口**,路径、身份信息的传递位置和 HTTP 错误状态码尚未确定;第 3–5 节的业务 MQ 格式是**现行 `2.0` 合同**。容量受限时不接纳、配置缓存约 60 秒及执行/控制分通道是目标行为,**现行部署不保证**。下方示例使用虚构身份,不包含生产凭据;JSON 块是完整消息/返回体,字段说明写在块外,以便复制校验。 -## 1. 参与者和通道 +## 1. 通道与消费顺序 -| 对接方 | 职责与接收范围 | -| --- | --- | -| SaaS | 保存租户、任务、智能体授权和固定 `(tenant_key,task_id)→dispatcher_id` 归属;向该 D 发送 MQ 业务命令/查询,消费 D 回传的业务事件;拟定的 SIP HTTP 响应只能分发已经批准的配置,不能自行修改原始 SIP 配置。 | -| Dispatcher(D) | 每实例有独立 UUID 和专属 MQ 接收队列;拟定通过只读 HTTP 查询本 D 的 SIP 全量及归属任务(含智能体)配置;接收 MQ 命令、回传命令结果/通话反馈/录音事实。D 与其内部执行系统的交互不属于 SaaS 第三方接口。 | +| 顺序 | 方向/触发 | 返回与消费动作 | +| --- | --- | --- | +| 1 | D 启动或配置到期:读取本 D 的 SIP 全量配置(拟定 HTTP) | SaaS 返回获批快照;`200` 核验后使用,`304` 无体且授权仍有效才沿用;错误或过期停新准入。配置变更后由 D 自行确认生效。 | +| 2 | SaaS 将一项任务固定归属一个 D;SaaS 发布 `call.execute`(现行 MQ) | MQ publisher confirm 仅证明发布;D 根据租户/归属、期限、配置及配额决定是否接纳。 | +| 3 | D 查询归属任务及内嵌智能体(拟定 HTTP) | 有效 `200` 或满足授权的 `304` 形成候选配置;已接纳执行仍使用原快照。候选执行如何采用新版本须新合同冻结,不能自行修改原 MQ 命令。 | +| 4 | D 处理命令,回传 `command.result`(现行 MQ) | SaaS 按命令 ID 识别接纳、等待、应用、拒绝或未知;D 持久化后才 ACK 入站,ACK/响应丢失不能触发重拨。容量受限时不接纳、其余仍留 MQ 为拟定行为。 | +| 5 | D 回传 `call.status`、`transcript.updated/failed`、`contact.opt_out`(现行 MQ) | SaaS 按事件 ID 去重、按同一聚合版本归并;拒联不得等待配置缓存到期。不同聚合的事件没有全局到达顺序。 | +| 6 | D 回传 `call.finished`,之后可能回传 `recording.uploaded`(现行 MQ) | 结束事件不证明录音已上传;SaaS 按 `call_id/recording_id/upload_id` 关联事实,重复通知不生成第二份资产。 | +| 按需 | SaaS 发布 `task.control`、查询或补传命令(现行 MQ) | 与上述异步步骤并行,控制须及时处理;查询返回快照,补传不新建呼叫。执行/控制分队列尚待签收,不擅自改变现行路由。 | -**现行 MQ 路由(不可按任务随意创建队列):**SaaS→指定 D 发到 durable Topic Exchange `agent-call.dispatchers.v2`,routing key `d..t..in`,D 消费 durable 队列 `agent-call.d..t..v2`;D→SaaS 发到 `agent-call.saas.v2`,key `d..t..out`,SaaS 消费 `agent-call.saas.events.v2`。永久拒绝消息走 `agent-call.dead-letter.v2`/该 D/租户 `.dlq.v2`;`agent-call.d..owner.v2` 仅防止多个进程占同一 D 身份,不是业务队列。业务消息 persistent、mandatory 发布、publisher confirm,单消息 ≤262144 字节;`tenant_key` **保留原值**,≤196 UTF-8 字节且不能出现独立 `*`/`#` 路由段。一个任务只投其归属 D,**单任务单 D 不自动解决多个任务跨 D 的租户/供应商总额度**。 +**MQ 路由(现行):**SaaS→D 发到 `agent-call.dispatchers.v2`,key `d..t..in`,D 从 `agent-call.d..t..v2` 消费;D→SaaS 发到 `agent-call.saas.v2`,key `d..t..out`,SaaS 从 `agent-call.saas.events.v2` 消费。`dispatcher_id` 为唯一 UUID v4;`tenant_key` 保留原值(≤196 UTF-8 字节,不能含独立 `*`/`#` 路由段)。消息 persistent、发布 mandatory 并等待 confirm;**confirm 不等于对端已消费或已执行**。同一任务只投归属 D,不能忙时自动换 D。 -**拟定变化:**配置 GET(SIP 全量、任务内含智能体)不再走 MQ;呼叫/控制/查询/补传/结果/上传事实依旧只走 MQ,**没有配置 HTTP→MQ 故障回退**。执行与控制分队列及原 AI 配置 MQ 消息停用仍待版本化合同;不能对现行 `.in` 键擅加新绑定。SaaS 与 D 需分别配置其对端可达性;UUID+SECRETKEY 的具体 HTTP 传递位置、URL 路径、错误状态码和生效规则尚待 SaaS 确认,不提供可直接照抄上线的 curl。 +## 2. D ← SaaS:只读配置响应(拟定,非现网) -## 2. 正常流程:严格按因果顺序,异步事件不保证跨队列到达顺序 +两接口均为 GET、**无请求 JSON 体**。D 使用自身 UUID 与 SECRETKEY,SIP 读本 D 全量,任务读原值 `tenant_key` + `task_id` 对应的单任务;实际 URL、请求头/参数、密钥承载方式待 SaaS 签收,本文**不虚构 HTTP 报文**。条件读取拟使用 `ETag/If-None-Match`,有效缓存约 60 秒;过期/请求失败只停新执行准入,既有执行保持已绑定快照,不妨碍 MQ 控制/查询。 -| 步骤/状态 | 发起 → 接收;请求或事件结构 | 返回结构 | 消费后必须触发的动作 | -| --- | --- | --- | --- | -| 0. SaaS 准备 SIP 配置(拟定) | SaaS 确保本 D 可读取的是唯一获批准的 SIP 全量配置;配置从何处进入 SaaS 属上游前置,不在本接口规定交互步骤。 | 配置须有获批版本/摘要及适用 D 范围;这不代表 D 已加载。 | 来源不明或版本不一致时,SaaS 不得将草稿作为可供 D 外呼的正式快照。 | -| 1. D 启动/恢复,读 SIP(拟定) | D 带自身 UUID+SECRETKEY 调“取**本 D SIP 全量**”只读 HTTP;无请求体,实际 URL/凭据承载未定。新 D 不依赖已错过的 MQ 广播。运行期间约每 60 秒条件校验版本。 | `200` SIP 全量;版本未变且仍批准可 `304`(**无 JSON 体**);错误返回脱敏 `resource=error`(具体 HTTP 状态待定)。完整结构见 §3.1 及[Schema](../contracts/config-read-v0.1.schema.json)。 | 校验 D、批准版本/摘要、线路与时间/媒体数据,关执行准入、排空/对账旧执行,D 内部核验对应版本**确已生效**后方能消费**新执行**;不停止控制/查询。到期 HTTP 失败不沿用过期配置继续新拨。发现 SIP 变化约 60 秒≠新版必在 60 秒内生效。 | -| 2. SaaS 创建任务/指派 D(拟定) | SaaS 持久保存 `(tenant_key,task_id)→dispatcher_id`,同任务 `call.execute`/`task.control` 只发给这个 D。此内部记录不是新的 MQ 广播。 | D 尚未收呼叫,无同步任务列表返回;已接纳执行继续用原不可变快照。 | 同租户多任务可以归不同 D;跨 D 共享总额度必须预先分配各 D 份额,不能每 D 各按全局上限放行。不因 D 忙碌悄悄改投别的 D。 | -| 3. SaaS 发候选呼叫(现行 MQ 消息/新消费语义拟定) | 发布 `command_type=call.execute` 命令信封,`payload` 见 §4;发到该 D/租户 `.in` key;`command_id` 及源 execution/task item 身份保持稳定。 | **MQ publisher confirm 不是 D 接受执行结果**;随后 D 发 `command.result` 异步事件(§5)。 | D 校验身份、路由、期限/Schema及重复身份。有名额才接纳;新目标无名额时其余消息留 MQ,不批量搬进 SQLite。**当前代码仍先持久化后 ACK,尚未实现按空位停消费**。不能 Nack 热循环。 | -| 4. D 读任务+智能体(拟定) | D 在候选执行可接纳时读“取归属任务配置”只读 HTTP;可用经核验的租户+任务缓存,约 60 秒到期主动校验,支持条件 `ETag/If-None-Match`;不逐呼下载完整内容。 | `200` 返回**一项任务+内嵌获授权智能体**,`304` 无体表示仍可复用原配置,错误返回 `resource=error`;结构见 §3.2。不是旧独立 AI GET,也不是现行 `ai.config.request/result` MQ。 | 检查任务归属、状态、有效授权、时间窗、线路选择及现行命令的版本引用;调用失败/过期**不接新呼叫**,但先前已接纳/通话中保留原快照。允许 SaaS 更新后最多约 60 秒使用原有效配置;停/暂停 MQ 命令不能等缓存失效。`call.execute.task_revision/agent_version_id` 如何合法换版需新合同签收。 | -| 5. D 准入与 ACK(业务语义拟定) | 无新对外请求;D 原子记录 inbox、任务/AI/SIP **本次执行快照**、任务/租户/线路等已批准份额、许可与 `command.result` outbox;再 ACK 原 MQ 消息。 | 成功后 D→SaaS 异步 `command.result`;失败/未知不得谎称已接纳。现行 `command.result.status` 可为 `accepted/waiting/applied/rejected/failed/unknown`;新容量规则尚须冻结。 | 任务/线路时间交集、排除日期、号码白名单、Cell/AI/供应商配额均需满足;缓存 TTL 不是拨号授权。即使 ACK 丢失重投,按原命令/执行身份恢复,不得发起第二次拨号。 | -| 6. D 回传呼叫反馈(现行 MQ 事件) | D 按已经取得的呼叫事实向 `agent-call.saas.v2` 依次或异步发布 `call.status`、`transcript.updated`、`transcript.failed`、`contact.opt_out`;正文见 §5。内部事实如何产生不属于 SaaS 对接接口。 | SaaS 从该 D/租户 `.out` 绑定队列收到 `event_id/event_type/aggregate_*` 及对应 payload;`transcript.updated` **没有 `call.transcript` 别名**。 | SaaS 按事件 ID 幂等、按同一聚合版本归并;转写可修订,拒联及时阻止后续不该拨打的任务项。不能把确认入 MQ 当作 SaaS 已处理,也不能假设跨事件类型全局顺序。 | -| 7. D 回传最终通话结果(现行 MQ 事件) | D 发布 `call.finished`;完整 payload 见 §5。 | SaaS 收通话最终结果及其可选 `asset_state`;**通话结束不等于录音上传完成**。 | SaaS 更新本任务/通话结果;按同一 `call_id` 关联之后可能抵达的录音事实,不能因反馈迟到重发呼叫。 | -| 8. D 回传录音上传事实(现行 MQ 事件) | D 在确认已有完整资产事实后向 SaaS 发布 `recording.uploaded`;SaaS **不需要提供上传 TOKEN、上传会话或 complete/verified 接口**。 | SaaS 只收 `call_id/recording_id/upload_id/bucket/object_key/format/channels/sample_rate_hz/duration_ms/size_bytes/checksum_sha256`(§5);**不含 TOKEN/密钥/签名 URL**。 | persistent、进入 durable 队列、mandatory 无 return 且 publisher confirm 成功只证明 MQ 交付,**不证明 SaaS 已消费/验证**;确认丢失按同一 `upload_id` 去重,不因重复消息产生第二份资产。 | -| 9. 任务配置更新/终结(拟定 HTTP) | 活跃且有未接纳执行的任务约每 60 秒由 D 校验 HTTP;SIP 也按期校验;任务终结无新呼叫时不再请求该任务配置。 | SaaS 新版本在成功读取后影响尚未接纳的执行,最多约 60 秒延迟;已接纳执行不漂移。 | D 到期读不到配置就暂停新执行准入;仅删除可重新读取的配置缓存,不删未决执行/幂等与未交付 MQ 结果。 | - -**重要:**步骤 0/1/2/4/5/9 中的配置读取及新消费语义属于**计划中的新功能**;现行 SaaS↔D MQ 合同没有两条 HTTP 配置接口,也没有“配置版本改变后任意重写旧 `call.execute`”的许可。第三方可据此核对拟议协议和阻塞项,**不能据此擅自上线新接口**。 - -## 3. 两个配置读取请求/返回及字段含义(项目自拟,非现网) - -### 3.1 SIP 全量配置 - -**请求:**D 用自己的 UUID+SECRETKEY 请求本 D 的 SIP 全量快照;只读 GET、无 JSON 请求体。路径、参数/头及 HTTP 错误状态未冻结,禁止凭本文创造真实 SaaS 路径。启动时必须成功,后续成功核对起约 60 秒有效;D 只能读取独占执行资源分区。 - -**`200` 结构:** +### 2.1 SIP 配置:200,返回本 D 的完整获批快照 ```json { "schema_version": "config-read.v0.1", "resource": "sip_config", - "dispatcher_id": "<该D的UUIDv4>", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", "revision": 1, - "snapshot_sha256": "<整份已批准快照的64位十六进制摘要>", - "approved_at": "<批准时间,含时区偏移>", - "artifact": { "...": "现有静态Cell制品;详见机器Schema和Mock示例" }, - "trunk_details": [ { "...": "各trunk连接、主叫及时段;详见下表" } ] + "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": [] + } + } + } + ] } ``` -此代码块是**结构示意,不是可校验样例**;完整可校验 Mock 见[示例](../contracts/examples/config-read-sip-v0.1.json)。 +**字段说明/消费动作:**- `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` 就认为已加载。 -| 返回字段 | 含义 / 第三方必须保证 | -| --- | --- | -| `schema_version`, `resource` | 草案版本与固定资源标记 `sip_config`;新版本不能和现行 MQ 的 `schema_version=2.0` 混写。 | -| `dispatcher_id` | 收配置的唯一 D 身份,不是 `agent_id`/租户 ID;错 D 的响应必须拒绝。 | -| `revision`, `snapshot_sha256`, `approved_at` | **整份**管理面获批配置的递增版号、摘要与批准时间;摘要算法、版本来源尚待管理面/SaaS 签收;Mock 摘要是占位,不是验证通过。 | -| `artifact` | [项目现有静态 Cell 制品](../../contracts/upstream/v1/static-cell-artifact.schema.json):`artifact_id/source_release/source_digest/approval_reference/cell_id/revision/config_sha256/mode/allowed_targets/trunks`,可能含 `media_profiles/ari/media/recording/load_evidence`;字段属于**项目既有契约**,不是页面截图的 SaaS 原字段。 | -| `artifact.trunks[]` | 一项独立线路:`trunk_id` 唯一标识、`provider_id` 供应商、`egress_pool_id` 出口、`codec=PCMA` 编码、`caller_profile_ids` 可用主叫引用、`dial_prefix` 仅该线路的前缀、`enabled` 是否启用、`sip_endpoint_ref/credential_ref` 受控引用及 `media_profile_id` 媒体参数 ID;不能把主叫当 Digest 用户名、把同一服务器当备用。 | -| `trunk_details[].trunk_id` | 必须与制品中的 `trunk_id` 一一对应,确保确为**全量**、无漏删;JSON Schema 不能单独验证两数组一一对应,接入方需另校验。 | -| `server_host/server_port` | SIP 服务端地址/端口;mock 示例域名不可用于 real。服务商实际传输、注册和鉴权仍待核验。 | -| `transport/auth_mode/registration_required` | SIP 传输方式、IP/Digest/无认证、是否注册。未确认用 `null`,**real 不得把 null 当默认 UDP/免认证**;不含密码。 | -| `max_concurrent_calls` | 供应商/该 D 获分线路额度;`null` 表示未知,不得让各 D 各按共享上限放行。跨 D 份额总和须由权威源保证。 | -| `caller_profiles[].caller_profile_id/caller_id` | 主叫配置的引用与原样 SIP 主叫标识;不能按纯数字手机号清洗(可含 `BD`),From/PAI 映射待供应商确认。 | -| `schedule.time_zone/weekly_windows` | Asia/Shanghai;周一至周日逐日零或多个左闭右开时段,某日空数组即该日不允许呼叫。与任务时段取交集;未加载/未知窗口不可放行。 | +### 2.2 任务配置:200,ASR + LLM + TTS 模式 -`304` 空体可复用原完整快照前提是原批准状态仍有效;到期或接口失败则关闭新执行准入。SIP 变更须由 D 停止新准入、对账旧通话并自行确认新制品实际生效;仅 `200` 或 MQ publisher confirm 不是生效证据。 +```json +{ + "schema_version": "config-read.v0.1", + "resource": "task_config", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_key": "tenant-mock", + "task_id": "task-mock", + "task_revision": 2, + "status": "running", + "name": "Mock task", + "group_id": null, + "max_concurrent_calls": 2, + "route_policy_id": "route-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", + "content_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "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 + } + } + } +} +``` -### 3.2 任务+智能体配置 +**字段说明/消费动作:**- `schema_version/resource/dispatcher_id/tenant_key/task_id`:版本、资源 `task_config`、归属 D、原值租户键和单任务 ID;D 必须验证请求归属。`task_revision` 是任务修订,`status` 为拟定 `running/paused/stopped/finished`;非 running 不接新呼叫。 +- `name/group_id` 是名称及可空分组;`max_concurrent_calls` 是本任务额度,不等于跨任务/跨 D 总额度;`route_policy_id/allowed_trunk_ids[]` 是路由引用及可用线路列表。 +- `schedule.time_zone/starts_at/ends_at` 定义时区和可空的起止时间;`weekly_windows` 按星期列出每日多个左闭右开 `{start,end}`,空数组禁呼;`excluded_dates[]` 为按 Asia/Shanghai 日期优先排除的日子。任务时段还须与线路时段相交。 +- `agent.agent_version_id/content_sha256`:不可变智能体版本及内容摘要,必须与 `agent.config.agent_version_id` 对应;`authorization_id/authorization_expires_at` 为授权身份和截止时间,到期不得由缓存/304 复活。 +- `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` 控制开场、打断、静默时限、总时限、轮次及缓存上限。 +- 未接纳呼叫在有效缓存窗口可能仍用旧批准版;已接纳呼叫固定原快照。现行 `call.execute` 已带 `task_revision/agent_version_id`,两者如何合法换版必须另行签收。 -**请求:**D 使用自身 UUID+SECRETKEY,以归属租户原值 `tenant_key` 和 `task_id` 查**该单任务**;GET 无 JSON 请求体,具体 URL/头待冻。只对尚未接纳的候选执行使用配置缓存;请求不携带号码列表或原始录音。 +### 2.3 任务配置:200,仅 ASR 模式(独立情况) -**`200` 层级:** `schema_version/resource=task_config/dispatcher_id/tenant_key/task_id/task_revision/status/name/group_id/max_concurrent_calls/route_policy_id/allowed_trunk_ids/schedule/agent`。完整可校验的 [Mock 示例](../contracts/examples/config-read-task-v0.1.json)及[Schema](../contracts/config-read-v0.1.schema.json)为准。 +```json +{ + "schema_version": "config-read.v0.1", + "resource": "task_config", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_key": "tenant-mock", + "task_id": "task-mock", + "task_revision": 2, + "status": "running", + "name": "Mock task", + "group_id": null, + "max_concurrent_calls": 2, + "route_policy_id": "route-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", + "content_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "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 + } + } + } +} +``` -| 返回字段 | 含义 / 消费者动作 | -| --- | --- | -| `dispatcher_id/tenant_key/task_id` | SaaS 保存的固定 `(tenant_key,task_id)→D`;D 验证租户原值、任务及自己身份,不能把一任务同时发给多个 D。 | -| `task_revision` | 任务配置修订号,与原 `call.execute.task_revision` 可能不同;如何让未接纳旧命令换用新版,需新版合同明定并发冻结点,D 不能私改旧消息。 | -| `status` | 项目自定义 `running/paused/stopped/finished` 枚举,不声称是 SaaS 现网枚举。`paused/stopped/finished` 不允许新发;MQ `task.control` 必须及时生效,不能等缓存。 | -| `name/group_id` | 任务名称与可空分组引用(页面可见语义);不作为权限或拨号依据。 | -| `max_concurrent_calls` | **该任务**的并发上限;与租户、该 D 获分供应商额度和 Cell/AI 许可同时限制,不代表同租户多个任务的全局上限。 | -| `route_policy_id/allowed_trunk_ids[]` | 路由版本引用/获准线路集合;D 只能从已加载且当前可用的线路中按获批准策略选择,不能为绕时段静默换线。 | -| `schedule.time_zone/starts_at/ends_at` | 时区固定 Asia/Shanghai,起/止日期时间可为 `null` 表示未设界限,不用 SDK 默认值填业务值。 | -| `schedule.weekly_windows` | 七个星期键对应多个 `{start,end}`(`HH:MM`);同一天可多段,左闭右开,跨午夜按合同拆段;空数组=该日禁呼。 | -| `schedule.excluded_dates[]` | 用户新增、截图未见的多日期排除;`YYYY-MM-DD`,按 Asia/Shanghai 日历判断,**优先于**所有星期时段。 | -| `agent.agent_version_id/content_sha256` | 任务所引用的不可变智能体版本和内容摘要;与 `agent.config.agent_version_id` 必须一致,同版本不同内容拒绝。具体哈希生成规则待签收。 | -| `agent.authorization_id/authorization_expires_at` | 对该 D/租户/智能体配置的有效授权标识及期限;过期不准新执行,即使 `ETag`/`304` 命中也不能复活授权。 | -| `agent.config` | [现有 AI 严格 Schema](../../contracts/upstream/v1/ai-config.schema.json),包括 `mode`、ASR 的 provider/model/input/识别参数、LLM 的 provider/model/temperature/max_tokens/timeout、`prompt.text`及变量、TTS 的 voice/speed/format、conversation 控制参数。`full_ai/asr_only` 仅按对应模式的已验证 SDK 参数执行,不把 UI 的 Top-P/情绪/话后分析等未经签收字段偷放 `metadata`。 | +**字段说明/消费动作:**字段与 2.2 相同,但 `agent.config.mode=asr_only`,**没有** LLM、提示词或 TTS 对象;配置内外 `agent_version_id` 必须一致。只能按授权的识别配置执行,不应将未提供的字段填成默认值。 -**错误与缓存:**`200` 可含新批准内容;`304` **无体**且此前授权仍能覆盖新的缓存有效期,方可沿用;其它返回按拟定 `{schema_version:"config-read.v0.1",resource:"error",error:{code,message}}`([Mock](../contracts/examples/config-read-error-v0.1.json))失败处理,具体状态和错误码待 SaaS 签收。成功核验起任务缓存约 60 秒,配置变更最多约 60 秒后影响未接纳呼叫;到期读不到即停新准入,已接纳原快照仍可完成。任务结束且没有未知/在途引用时,仅删除可再读的**配置缓存**,不清除幂等与 outbox。 +### 2.4 SIP 或任务:304,内容未变(拟定) -## 4. MQ 请求与控制:按入站发生时处理 +```http +HTTP/1.1 304 Not Modified +``` -**现行命令信封(SaaS→D)必有**: +**响应体:无。**D 仅在已有完整且仍获批准的对应配置快照、授权仍有效时沿用;`304` 不代表智能体授权延期,也不证明 SIP 配置已经生效。具体 ETag、续期与错误状态码待签收。 -| 字段 | 定义 | -| --- | --- | -| `schema_version` | 固定 `2.0`,与 HTTP 配置草案版本独立。 | -| `command_type/command_id` | `call.execute/task.control/call.replay/command.replay`;`command_id` 是本次命令的稳定去重身份,重复投递不能产生新的执行。 | -| `dispatcher_id` | 目标 D 全局唯一 UUID v4,不等于运行时 `dispatcher_epoch`。SaaS 必须发到该 D 的精确 routing key。 | -| `tenant_id/tenant_key` | 租户内部 ID 与**原值**路由键;不得清洗、截断或广播后靠消息体过滤。 | -| `trace_id/issued_at/not_after` | 全链路关联标识、生成时间、消息截止时间;超期不得等下个外呼窗口自动拨。 | -| `payload` | 按 `command_type` 严格选择以下**唯一**正文;不允许添加未批准字段。 | +### 2.5 SIP 或任务:错误返回(拟定;HTTP 状态码未定) -| `command_type` | payload **必有字段**与含义 | 触发后果/异步返回 | -| --- | --- | --- | -| `call.execute` | `execution_id` 本次执行稳定身份;`task_id` 所属任务;`task_item_id` 任务内号码项;`task_revision` 下发时任务修订;`callee` 原始被叫(不加线路前缀);`route_policy_id` 路由引用;`caller_profile_id` 主叫引用;`agent_version_id` 智能体版本;`variables` prompt变量对象;`ring_timeout_ms` 振铃时限;`max_call_duration_ms` 最大通话时长。 | D 校验/接纳后发 `command.result`。同命令或执行重复不重新 originate;`variables` 虽在当前 Schema 允许 object,不能因此注入未批准的 AI 字段。 | -| `task.control` | `task_id` 目标任务;`action=pause/resume/stop`;`expected_task_revision` CAS 期望版;`reason` 原因;可选 `active_call_policy=drain/hangup` 控制在途通话(具体 action/策略组合以合同为准)。 | 结果由 `command.result` 的 requested/applied revision 区分;`stop` 后不可按普通 resume 重启。控制先于尚在 MQ 的 `call.execute` 到达时,新版本必须有拦截屏障;**现行共享队列/任务不存在返回 `not_found` 的路径尚未满足新目标**。 | -| `call.replay` | `call_id` + `reason`。 | 只补传**已有呼叫**事件,不新建执行、不重拨;结果按原身份去重。 | -| `command.replay` | `source_command_id` + `reason`。 | 只补传已有命令响应,不能当第二次 originate;不是 SaaS 任意补任务/执行。 | +```json +{ + "schema_version": "config-read.v0.1", + "resource": "error", + "error": { + "code": "not_assigned", + "message": "Task is not assigned to this Dispatcher." + } +} +``` -**现行 MQ 查询(两向异步,不是 HTTP 业务接口):**SaaS→D `message_type=command.query` 的 `payload={command_id}` 或 `call.query` 的 `payload={call_id}`;请求必有 `schema_version/message_type/message_id/dispatcher_id/tenant_id/tenant_key/trace_id/issued_at/not_after/payload`,`message_id` 是回包关联身份。D→SaaS 回同身份范围的 `command.query.result`/`call.query.result`:必有 `schema_version/message_type/message_id/dispatcher_id/tenant_id/tenant_key/trace_id/issued_at/correlation_id/status/reason_code/payload`,其中 `correlation_id` 指向原请求 `message_id`,`status=ok/pending/rejected`;`pending` 的 `reason_code=waiting` 且 payload 空对象;`rejected` 的 payload 为 `{detail,retryable}`,reason_code 必须使用[现行 Schema](../../contracts/upstream/v1/mq.schema.json)规定的值。 +**字段说明/消费动作:**`schema_version/resource` 标识草案错误对象;`error.code` 是机器可读错误代码(示例 `not_assigned` 表示该任务不归此 D),`error.message` 是可读说明,不含密钥。D 不得将失败当作空任务/无限制或使用过期配置接新呼叫;不能自动回退至 MQ 配置通道。 -- `command.query.result` 的 `ok.payload` 是 [`executor_Command`](../../contracts/upstream/v1/mq.schema.json):`command_id/command_type/tenant_id/tenant_key/status/aggregate_version` 必有,`task_id/execution_id/call_id` 是关联对象,`reason_code/wait_reason_code` 为具体处置或等待原因,`requested_task_revision/applied_task_revision/task_state` 表示控制 CAS 与任务状态,`accepted_at/waiting_since/admission_deadline/updated_at` 为相应时间。**某字段可空或缺省以机器 Schema 为准**;不是另建 SaaS 状态库。 -- `call.query.result` 的 `ok.payload` 是 [`executor_Call`](../../contracts/upstream/v1/mq.schema.json):`call_id/execution_id/call_state/call_version/attempts/transcript/recordings/delivery/snapshot_at` 必有;可含 `task_id/task_item_id/reason_code/outcome/started_at/ended_at/duration_ms`。`attempts[]` 是 `call.status` 事件信封数组;`transcript.events[]` 是 `transcript.updated/failed` 事件信封数组;`recordings[]` 是 `recording.uploaded/failed` 事件信封数组;`delivery={pending,retry,dispatching,published}` 是非负消息计数,**不是 SaaS 已处理数**;`snapshot_at` 是本次视图时间。不把查询快照或 confirm 冒充 SaaS 已处理。 -- **现行旧配置 MQ 消息** `ai.config.request/result` 仍在[现行 Schema](../../contracts/upstream/v1/mq.schema.json),但新接口签收/切换后**停止使用,不作为 HTTP 失败回退**。第三方新实现不需再为新配置另外做 MQ AI 请求或 SIP 快照 MQ 通道。 +## 3. SaaS → D:MQ 命令和查询(现行合同) -## 5. D→SaaS 业务事件:每次都是异步通知 +所有 JSON 块都是**完整 MQ 消息**。命令公共字段:`schema_version=2.0`;`dispatcher_id` 是目标 D;`tenant_id/tenant_key` 是租户 ID 与保留原值的队列路由键;`trace_id` 贯穿请求与反馈;`issued_at/not_after` 为下发与截止时间;`command_id` 为稳定去重键,`command_type` 选定 payload 结构。到期不能等待次日再拨。D 持久化入站事实后才 ACK,重投不能新增执行。 -**现行事件信封必有**:`schema_version=2.0`、`event_id`(重投去重)、`event_type`、`dispatcher_id`、`tenant_id/tenant_key`、`trace_id`、`occurred_at`、`aggregate_type/aggregate_id/aggregate_version`(同一对象的类型/身份/版本)及按类型严格校验的 `payload`。`aggregate_version` 用于**同一聚合**归并,不能假定不同呼叫或不同队列之间全局有序;唯一权威为[事件 Schema](../../contracts/upstream/v1/event-payloads.schema.json)。 +### 3.1 发起呼叫:call.execute -| `event_type` | payload **必有字段**:含义 | 可选字段/收到后处理 | -| --- | --- | --- | -| `command.result` | `command_id/command_type` 源命令身份/类别;`status` 为 `accepted/waiting/applied/rejected/failed/unknown`;`reason_code` 原因。 | 可含 `task_id/task_item_id/execution_id/call_id`、`requested_task_revision/applied_task_revision`、`admission_state`、`resource_reservation_id/permit_id`。SaaS 根据关联身份和 revision 判定命令是否已应用;**MQ confirm 不等于这条事件已被 SaaS 消费**。 | -| `call.status` | `call_id/execution_id` 通话/执行;`call_state=queued/dialing/ringing/answered/ended`、`call_version` 状态版本、`attempt_id` 当次尝试、`attempt_state=pending/active/ended/unknown`。 | 可含 `task_id/task_item_id/route_policy_id/caller_profile_id/trunk_id/cell_id/egress_pool_id/observed_at/reason_code`;SaaS 按版本/身份归并,不因超时重试已接通/未知呼叫。 | -| `transcript.updated` | `call_id/turn_id/segment_id` 通话、轮次和分段;`role=customer/agent/system`;`revision` 片段修订、`text` 实时文字、`is_final` 是否最终段;`start_ms/end_ms` 段时刻、`playback_state` AI 声音生成/发送/播放/取消状态。 | 可含 `execution_id`;同段更高 revision 更新,不能把 OSS 文字资产替代实时文字;正文/音频不进入长期诊断和外部聊天。 | -| `transcript.failed` | `call_id`、`reason_code`、`retryable`:识别/转写失败原因及可重试性。 | 可含 `segment_id/affected_segments`;失败不是空字幕“成功”。 | -| `contact.opt_out` | `call_id/task_id/task_item_id/requested_at`:联系人拒联事实及发生时间。 | 可含 `turn_id/segment_id`;SaaS 须停止后续不该拨出的任务项,不能等一分钟配置缓存后才处理。 | -| `call.finished` | `call_id/execution_id/call_version/outcome/started_at/ended_at/duration_ms/reason_code`:通话最终结局、时长和原因。 | 可含 `task_id/task_item_id/attempt_summary/asset_state`,其中 `asset_state=pending/complete/failed/unknown` 是当时资产状态;录音上传仍可能稍后完成。 | -| `recording.uploaded` | `call_id/recording_id` 通话/录音身份;`upload_id` 本次上传事实 ID;`bucket/object_key` OSS 目标;`format=wav/raw_pcm/pcma`、`channels=1`、`sample_rate_hz`、`duration_ms`、`size_bytes` 描述实际资产;`checksum_sha256` 文件内容的 SHA-256。 | SaaS 按原身份/哈希去重、再按自身流程处理;D 只保证已持久 MQ 入队,不返回 SaaS verified/OSS ID。**不含 TOKEN、授权 Header、签名 URL 或完整音频**。 | -| `recording.failed` | Schema 存在:`call_id/recording_id/stage/reason_code/retryable`,可含 `next_retry_at`。 | **不要当作当前上传流程必定发出的事件**;其 `verify/complete` 等旧 stage 尚在严格 Schema 中,目标流程只负责直传及可靠 `recording.uploaded`,失败须保留可追溯事实和恢复证据,需新版合同明确是否发布此事件。 | +```json +{ + "schema_version": "2.0", + "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_id": "command-a", + "command_type": "call.execute", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "execution_id": "execution-a", + "task_id": "task-a", + "task_item_id": "item-a", + "task_revision": 1, + "callee": "15003164745", + "route_policy_id": "route-a", + "caller_profile_id": "caller-a", + "agent_version_id": "version-a", + "variables": {}, + "ring_timeout_ms": 1000, + "max_call_duration_ms": 10000 + } +} +``` -SaaS 从 `.out` 队列持久消费并在自己处理成功后 ACK;D 本地 outbox 写入不是 MQ 交付,MQ publisher confirm 也不是 SaaS 应用收讫。D 重启/confirm 丢失可能重发**同一** `event_id` 或 `upload_id`,SaaS 去重,不为重复 `recording.uploaded` 生成第二个资产。事件时间线由 `occurred_at`、聚合版本与实际事实确定,**不能把上表顺序当成逐消息的全局顺序**。 +**字段说明/消费动作:**`execution_id` 是本次执行身份,`task_id/task_item_id` 定位任务号码项,`task_revision` 是下发时版本;`callee` 是**原始号码**、不带线路前缀;`route_policy_id/caller_profile_id/agent_version_id` 是路由、主叫和智能体版本引用;`variables` 为获准提示词变量对象;`ring_timeout_ms/max_call_duration_ms` 是振铃与通话时限。D 校验版本、有效期及准入后异步回传 `command.result`,publisher confirm 不是接纳回执。真实外呼仍须满足独立授权和当前窗口。 -## 6. 联调前双方要补齐的合同与验收清单 +### 3.2 暂停任务:task.control / pause -| 待决项 | 第三方需确认/提供什么;否则不得声称对接完成 | -| --- | --- | -| 两条 HTTP 配置只读接口 | SaaS 签收真实路径、UUID+SECRETKEY 传递方式、归属/权限范围、`200/304` 及错误状态/字段、ETag 粒度、授权续期、是否真能把管理面获批 SIP **完整**分发;当前英文键是项目自定义草案,**非现网原字段**。 | -| SIP 配置来源与生效 | SaaS 提供的 SIP 只读响应必须对应管理面唯一获批版本/摘要及完整线路对端/传输/鉴权/注册/主叫/前缀/额度/时段;D 自行验证配置生效后才接新任务。现有 mock 示例的 `null` 供应商参数不可用于 real。 | -| 任务版本与配置变化 | 现行 `call.execute` 已带 `task_revision/agent_version_id/route_policy_id`;用户新目标容许尚未接纳呼叫在最多约 60 秒内沿用旧批准配置,合同须明确旧命令如何获授权新绑定及并发冻结点,已接纳不变。排除日期、任务×SIP时段的类型/界限也须签收。 | -| 控制可达与容量保护 | 现行每 D/租户只有一个 `.in` 队列;目标拟分执行/控制通道,需版控 routing/binding、未入库任务的 stop 屏障、MQ 满队列发布失败保留与配额边界。没有新协议不得自行拆现有队列、广播抢收或各 D 重复放行共享额度。 | -| 录音事实与收讫 | SaaS 不申请录音会话、不下发 OSS TOKEN;D 可靠发布 `recording.uploaded` 仅代表通知进队列。SaaS 需要的应用收讫/后处理在自身流程定义,不能让 D 伪造 verified 或 OSS ID。 | -| 当前与新版本验收 | 新 HTTP 配置、缓存、单任务单 D、多 D 份额、D/A 时段门禁及经纪代理拓扑均未通过真实 SaaS/生产验收。按[新计划 §4–§8](../plan-config-read-v0.1.md)分别记录 C/L/M;Mock、现行消息字段或本文不代签新协议。 | +```json +{ + "schema_version": "2.0", + "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_id": "task.control-a", + "command_type": "task.control", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "task_id": "task-a", + "action": "pause", + "expected_task_revision": 1, + "active_call_policy": "drain", + "reason": "local-test" + } +} +``` -**不做:**本文不授权真实拨号、云资源/网络变更、生产发布、二套管理后台、HTTP 业务回退或新的 MQ 配置订阅。运行版本不同不得混用新 HTTP 草案与现行 `ai.config.request/result` 再说“已对接”;外部拿到本文后首先确认 §6,再安排隔离合同/Mock 测试。 +**字段说明/消费动作:**`task_id` 是目标任务;`action=pause` 暂停新执行;`expected_task_revision` 为任务修订的比较条件;`active_call_policy=drain` 保持在途通话自然结束;`reason` 是控制理由。结果须看 `command.result` 中实际应用版本,不以 MQ 发布成功为准。 + +### 3.3 恢复任务:task.control / resume + +```json +{ + "schema_version": "2.0", + "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_id": "task.resume-a", + "command_type": "task.control", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "task_id": "task-a", + "action": "resume", + "expected_task_revision": 2, + "reason": "operator-resume" + } +} +``` + +**字段说明/消费动作:**`resume` 针对可恢复的 paused 任务;须提供当期 `expected_task_revision`,且新配置/授权仍有效。`stopped` 不可按普通 resume 恢复。 + +### 3.4 停止任务:task.control / stop + +```json +{ + "schema_version": "2.0", + "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_id": "task.stop-a", + "command_type": "task.control", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "task_id": "task-a", + "action": "stop", + "expected_task_revision": 2, + "active_call_policy": "hangup", + "reason": "operator-stop" + } +} +``` + +**字段说明/消费动作:**`stop` 禁止再接新执行;`active_call_policy=hangup` 表示需结束在途通话,另一策略 `drain` 表示只排空;这两种处理方式不能混用。对尚未接纳、仍在队列中的呼叫也须防止后续误拨;现行共享队列尚未完成该目标屏障。 + +### 3.5 补传已有呼叫事件:call.replay + +```json +{ + "schema_version": "2.0", + "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_id": "call.replay-a", + "command_type": "call.replay", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "call_id": "call-a", + "reason": "local-test" + } +} +``` + +**字段说明/消费动作:**`call_id` 指已有呼叫,`reason` 为补传原因;只补发已有事实,不新建任务/执行、不再次拨号。 + +### 3.6 补传已有命令结果:command.replay + +```json +{ + "schema_version": "2.0", + "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_id": "command.replay-a", + "command_type": "command.replay", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "source_command_id": "command-a", + "reason": "local-test" + } +} +``` + +**字段说明/消费动作:**`source_command_id` 指原命令,`reason` 为补传原因;按原命令身份补结果,不产生第二次外呼。 + +查询消息同样走 MQ,`message_type` 选 `command.query` 或 `call.query`;`message_id` 是本次查询标识,回复 `correlation_id` 指向它。查询请求还有同命令的 `schema_version/dispatcher_id/tenant_id/tenant_key/trace_id/issued_at/not_after`;不是 HTTP 业务查询。 + +### 3.7 查询命令:command.query 请求 + +```json +{ + "schema_version": "2.0", + "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", + "message_id": "command.query-a", + "message_type": "command.query", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "command_id": "command-a" + } +} +``` + +**字段说明/消费动作:**`payload.command_id` 是需要查证的源命令 ID。D 返回 4.1/4.3/4.4 中的一种结果,不发起新执行。 + +### 3.8 查询呼叫:call.query 请求 + +```json +{ + "schema_version": "2.0", + "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", + "message_id": "call.query-a", + "message_type": "call.query", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "call_id": "call-a" + } +} +``` + +**字段说明/消费动作:**`payload.call_id` 是已有呼叫 ID。D 返回 4.2/4.5/4.6 中的一种结果,查询不能触发重拨。 + +## 4. D → SaaS:MQ 查询结果(现行合同) + +响应公共字段:`schema_version/message_type/message_id` 是合同版本、回复类型及本次回复 ID;`dispatcher_id/tenant_id/tenant_key/trace_id` 维持身份范围与链路;`issued_at` 为回复时间;`correlation_id` **必须等于原查询的 `message_id`**;`status/reason_code` 决定结果。`ok` 的 `payload` 为该查询的完整视图,`pending` 的 payload 为空对象,`rejected` 的 payload 为拒绝详情。收到后按 `correlation_id` 匹配请求,不能把快照当成新的执行命令。 + +### 4.1 命令查询成功:command.query.result / ok + +```json +{ + "schema_version": "2.0", + "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", + "message_id": "query-reply-a", + "message_type": "command.query.result", + "correlation_id": "command.query-a", + "status": "ok", + "reason_code": "ok", + "payload": { + "command_id": "command-a", + "command_type": "call.execute", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "status": "accepted", + "aggregate_version": 1 + } +} +``` + +**字段说明/消费动作:**`payload.command_id/command_type` 是原命令;`tenant_id/tenant_key` 是租户范围;`status` 是命令状态;`aggregate_version` 是该命令的视图版本。可选 `task_id/execution_id/call_id` 表示关联对象;`reason_code/wait_reason_code` 是处理/等待原因;`requested_task_revision/applied_task_revision/task_state` 是控制期望版、实际应用版和任务状态;`accepted_at/waiting_since/admission_deadline/updated_at` 是各阶段时间。只展示本次已经存在的事实。 + +### 4.2 呼叫查询成功:call.query.result / ok(含反馈视图) + +```json +{ + "schema_version": "2.0", + "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", + "message_id": "call-query-reply-a", + "message_type": "call.query.result", + "correlation_id": "call.query-a", + "status": "ok", + "reason_code": "ok", + "payload": { + "call_id": "call-a", + "execution_id": "execution-1", + "call_state": "answered", + "call_version": 1, + "attempts": [ + { + "schema_version": "2.0", + "event_id": "call.status-event-a", + "event_type": "call.status", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:01Z", + "aggregate_type": "call", + "aggregate_id": "call-a", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "execution_id": "execution-1", + "task_id": "task-1", + "task_item_id": "item-1", + "call_state": "answered", + "call_version": 1, + "attempt_id": "attempt-1", + "attempt_state": "active", + "route_policy_id": "route-1", + "caller_profile_id": "caller-1", + "trunk_id": "trunk-1", + "cell_id": "cell-1", + "egress_pool_id": "egress-1", + "observed_at": "2026-09-18T00:00:01Z" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + } + ], + "transcript": { + "events": [ + { + "schema_version": "2.0", + "event_id": "transcript.updated-event-a", + "event_type": "transcript.updated", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:01Z", + "aggregate_type": "transcript_segment", + "aggregate_id": "segment-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "turn_id": "turn-1", + "segment_id": "segment-1", + "role": "customer", + "revision": 1, + "text": "您好", + "is_final": true, + "start_ms": 0, + "end_ms": 600, + "playback_state": "not_applicable" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + }, + { + "schema_version": "2.0", + "event_id": "transcript.failed-event-a", + "event_type": "transcript.failed", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:05Z", + "aggregate_type": "transcript", + "aggregate_id": "call-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "reason_code": "asr_timeout", + "retryable": false, + "segment_id": "segment-1", + "affected_segments": [ + "segment-1" + ] + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + } + ] + }, + "recordings": [ + { + "schema_version": "2.0", + "event_id": "recording.uploaded-event-a", + "event_type": "recording.uploaded", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:02Z", + "aggregate_type": "recording", + "aggregate_id": "recording-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "recording_id": "recording-1", + "format": "wav", + "channels": 1, + "sample_rate_hz": 16000, + "duration_ms": 1000, + "size_bytes": 32000, + "checksum_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "upload_id": "upload-a", + "bucket": "example-bucket", + "object_key": "recordings/recording-a.wav" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + }, + { + "schema_version": "2.0", + "event_id": "recording.failed-event-a", + "event_type": "recording.failed", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:04Z", + "aggregate_type": "recording", + "aggregate_id": "recording-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "recording_id": "recording-1", + "stage": "upload", + "reason_code": "temporary_oss_unavailable", + "retryable": true, + "next_retry_at": "2026-09-18T00:01:00Z" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + } + ], + "delivery": { + "pending": 1, + "retry": 0, + "dispatching": 0, + "published": 0 + }, + "snapshot_at": "2026-09-21T00:00:00Z" + } +} +``` + +**字段说明/消费动作:**`payload.call_id/execution_id/call_state/call_version` 表示呼叫身份及状态版本;`attempts[]` 为完整 `call.status` 事件信封;`transcript.events[]` 为完整 `transcript.updated/failed` 事件信封;`recordings[]` 为完整 `recording.uploaded/failed` 事件信封;每个事件的字段解释见 §5。`delivery.pending/retry/dispatching/published` 为消息投递计数,**不是 SaaS 应用收讫**;`snapshot_at` 为视图生成时间。可选 `task_id/task_item_id/reason_code/outcome/started_at/ended_at/duration_ms` 为业务关联和结局。示例包含不同时间的反馈,列表只代表查询时已知事实,不表示事件在真实链路中依次抵达。 + +### 4.3 命令查询待定:command.query.result / pending + +```json +{ + "schema_version": "2.0", + "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", + "message_id": "query-reply-a", + "message_type": "command.query.result", + "correlation_id": "command.query-a", + "status": "pending", + "reason_code": "waiting", + "payload": {} +} +``` + +**字段说明/消费动作:**`status=pending`、`reason_code=waiting`、`payload={}` 表示当下尚无最终结果;SaaS 等待或再次查询,不能据此产生第二次发起命令。 + +### 4.4 命令查询被拒:command.query.result / rejected + +```json +{ + "schema_version": "2.0", + "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", + "message_id": "query-reply-a", + "message_type": "command.query.result", + "correlation_id": "command.query-a", + "status": "rejected", + "reason_code": "not_found", + "payload": { + "detail": "command not found", + "retryable": false + } +} +``` + +**字段说明/消费动作:**`reason_code=not_found` 给出拒绝原因;`payload.detail` 是可读说明,`retryable` 是是否可再次查询,不是重拨许可。其他原因必须使用合同允许的代码。 + +### 4.5 呼叫查询待定:call.query.result / pending + +```json +{ + "schema_version": "2.0", + "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", + "message_id": "call-query-reply-pending", + "message_type": "call.query.result", + "correlation_id": "call.query-a", + "status": "pending", + "reason_code": "waiting", + "payload": {} +} +``` + +**字段说明/消费动作:**与 4.3 同结构,但 `message_type` 和 `correlation_id` 属于呼叫查询;不把 `pending` 解释为呼叫不存在。 + +### 4.6 呼叫查询被拒:call.query.result / rejected + +```json +{ + "schema_version": "2.0", + "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", + "message_id": "call-query-reply-rejected", + "message_type": "call.query.result", + "correlation_id": "call.query-a", + "status": "rejected", + "reason_code": "not_found", + "payload": { + "detail": "call not found", + "retryable": false + } +} +``` + +**字段说明/消费动作:**与 4.4 同结构,`payload.detail/retryable` 是拒绝描述/再查询提示;不自动创建新的呼叫。 + +## 5. D → SaaS:业务事件(现行合同) + +下列每个 JSON 块都是**完整事件信封**,不只是 `payload`:`schema_version` 是 MQ 合同版本;`event_id` 是重复投递的去重键;`event_type` 选择业务正文;`dispatcher_id/tenant_id/tenant_key/trace_id` 给出来源和租户范围;`occurred_at` 是事实时间;`aggregate_type/aggregate_id/aggregate_version` 是同一对象的类型、ID、递增版本;`payload` 是具体业务数据。SaaS 持久消费并在**自身处理完成后** ACK;不同聚合没有全局顺序。同一身份重发要幂等处理。 + +### 5.1 命令已接纳:command.result / accepted + +```json +{ + "schema_version": "2.0", + "event_id": "command.result-event-a", + "event_type": "command.result", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:00Z", + "aggregate_type": "command", + "aggregate_id": "command-1", + "aggregate_version": 1, + "payload": { + "command_id": "command-1", + "command_type": "call.execute", + "status": "accepted", + "reason_code": "accepted", + "execution_id": "execution-1" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} +``` + +**字段说明/消费动作:**`payload.command_id/command_type` 对应源命令;`status` 可为 `accepted/waiting/applied/rejected/failed/unknown`,本例 `accepted`;`reason_code` 为处置原因;`execution_id` 为可选关联执行。控制事件还可能带 `task_id/task_item_id/call_id/requested_task_revision/applied_task_revision/admission_state/resource_reservation_id/permit_id`;判断实际控制结果要核对修订,不靠发布确认。 + +### 5.2 命令暂待:command.result / waiting + +```json +{ + "schema_version": "2.0", + "event_id": "command.result-waiting-a", + "event_type": "command.result", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:00Z", + "aggregate_type": "command", + "aggregate_id": "command-1", + "aggregate_version": 1, + "payload": { + "command_id": "command-1", + "command_type": "call.execute", + "status": "waiting", + "reason_code": "waiting" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} +``` + +**字段说明/消费动作:**候选命令尚未得到最终结果,SaaS 按原 `command_id` 等待或查询;本事件**不是**已接纳、更不是拨号证明。容量受限时不搬入本地待队列是目标消费语义,尚未实现。 + +### 5.3 命令被拒:command.result / rejected + +```json +{ + "schema_version": "2.0", + "event_id": "command.result-rejected-a", + "event_type": "command.result", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:00Z", + "aggregate_type": "command", + "aggregate_id": "command-1", + "aggregate_version": 1, + "payload": { + "command_id": "command-1", + "command_type": "call.execute", + "status": "rejected", + "reason_code": "not_found" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} +``` + +**字段说明/消费动作:**`rejected` 给出本次命令未被接受的原因;不要自动改投别的 D 或对未知状态盲目重发。`failed/unknown/applied` 的处理同样必须以实际 `status/reason_code` 为准。 + +### 5.4 通话进度:call.status + +```json +{ + "schema_version": "2.0", + "event_id": "call.status-event-a", + "event_type": "call.status", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:01Z", + "aggregate_type": "call", + "aggregate_id": "call-a", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "execution_id": "execution-1", + "task_id": "task-1", + "task_item_id": "item-1", + "call_state": "answered", + "call_version": 1, + "attempt_id": "attempt-1", + "attempt_state": "active", + "route_policy_id": "route-1", + "caller_profile_id": "caller-1", + "trunk_id": "trunk-1", + "cell_id": "cell-1", + "egress_pool_id": "egress-1", + "observed_at": "2026-09-18T00:00:01Z" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} +``` + +**字段说明/消费动作:**`call_id/execution_id` 是通话/执行;`call_state` 可为 `queued/dialing/ringing/answered/ended`;`call_version` 是通话版本;`attempt_id/attempt_state` 是本次尝试及其 `pending/active/ended/unknown` 状态。可选 `task_id/task_item_id/route_policy_id/caller_profile_id/trunk_id/cell_id/egress_pool_id/observed_at/reason_code` 为关联/线路/观察时间/原因。SaaS 以呼叫身份和版本更新进度,不因超时对接通或未知呼叫重拨。 + +### 5.5 实时转写:transcript.updated + +```json +{ + "schema_version": "2.0", + "event_id": "transcript.updated-event-a", + "event_type": "transcript.updated", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:01Z", + "aggregate_type": "transcript_segment", + "aggregate_id": "segment-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "turn_id": "turn-1", + "segment_id": "segment-1", + "role": "customer", + "revision": 1, + "text": "您好", + "is_final": true, + "start_ms": 0, + "end_ms": 600, + "playback_state": "not_applicable" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} +``` + +**字段说明/消费动作:**`call_id/turn_id/segment_id` 定位呼叫/轮次/分段;`role` 标明说话方;`revision` 是该段修订,`text` 为文字,`is_final` 表示定稿;`start_ms/end_ms` 是段相对时间;`playback_state` 是播放状态。可选 `execution_id`。同段更高修订替换旧文本,不存在 `call.transcript` 别名。 + +### 5.6 转写失败:transcript.failed + +```json +{ + "schema_version": "2.0", + "event_id": "transcript.failed-event-a", + "event_type": "transcript.failed", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:05Z", + "aggregate_type": "transcript", + "aggregate_id": "call-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "reason_code": "asr_timeout", + "retryable": false, + "segment_id": "segment-1", + "affected_segments": [ + "segment-1" + ] + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} +``` + +**字段说明/消费动作:**`call_id/reason_code/retryable` 标明失败的呼叫、原因和可重试性;可选 `segment_id/affected_segments` 指定受影响片段。不能把失败当空文字成功。 + +### 5.7 拒绝联系:contact.opt_out + +```json +{ + "schema_version": "2.0", + "event_id": "contact.opt_out-event-a", + "event_type": "contact.opt_out", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:06Z", + "aggregate_type": "contact", + "aggregate_id": "contact-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "task_id": "task-1", + "task_item_id": "item-1", + "requested_at": "2026-09-18T00:00:06Z", + "turn_id": "turn-1", + "segment_id": "segment-1" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} +``` + +**字段说明/消费动作:**`call_id/task_id/task_item_id/requested_at` 标明拒联的来源、任务项及时间;可选 `turn_id/segment_id` 定位对应对话。SaaS 应阻止不应再拨出的任务项,不能等待约 60 秒配置缓存到期。 + +### 5.8 通话结束:call.finished + +```json +{ + "schema_version": "2.0", + "event_id": "call.finished-event-a", + "event_type": "call.finished", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:03Z", + "aggregate_type": "call", + "aggregate_id": "call-a", + "aggregate_version": 2, + "payload": { + "call_id": "call-a", + "execution_id": "execution-1", + "task_id": "task-1", + "task_item_id": "item-1", + "call_version": 1, + "outcome": "answered", + "started_at": "2026-09-18T00:00:00Z", + "ended_at": "2026-09-18T00:00:03Z", + "duration_ms": 3000, + "reason_code": "normal_clearing", + "asset_state": "complete", + "attempt_summary": [ + { + "attempt_id": "attempt-1", + "state": "ended", + "trunk_id": "trunk-1", + "cell_id": "cell-1" + } + ] + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} +``` + +**字段说明/消费动作:**`call_id/execution_id/call_version` 标识结束的执行和呼叫版本;`outcome` 是接通/无应答/忙/失败/拒联/取消/未知之一;`started_at/ended_at/duration_ms/reason_code` 是开始、结束、时长和结束原因;可选 `task_id/task_item_id`、`attempt_summary[]`(各尝试身份、状态、线路和执行单元)、`asset_state`(`pending/complete/failed/unknown` 的当时资产状态)。仅此事件不保证录音通知已经送达。 + +### 5.9 录音可用事实:recording.uploaded + +```json +{ + "schema_version": "2.0", + "event_id": "recording.uploaded-event-a", + "event_type": "recording.uploaded", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:02Z", + "aggregate_type": "recording", + "aggregate_id": "recording-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "recording_id": "recording-1", + "format": "wav", + "channels": 1, + "sample_rate_hz": 16000, + "duration_ms": 1000, + "size_bytes": 32000, + "checksum_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "upload_id": "upload-a", + "bucket": "example-bucket", + "object_key": "recordings/recording-a.wav" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} +``` + +**字段说明/消费动作:**`call_id/recording_id/upload_id` 为关联呼叫、录音及上传事实身份;`bucket/object_key` 为资产位置;`format` 可为 `wav/raw_pcm/pcma`;`channels=1/sample_rate_hz/duration_ms/size_bytes` 为声道、采样率、时长及字节数;`checksum_sha256` 是文件内容摘要。SaaS 按事实身份去重并自行后处理;D 不提供完整音频、签名 URL、TOKEN 或已验证 OSS ID。D 的 MQ 入队确认不代表 SaaS 已处理。 + +### 5.10 录音失败:recording.failed(合同中存在,不能假定必发) + +```json +{ + "schema_version": "2.0", + "event_id": "recording.failed-event-a", + "event_type": "recording.failed", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:04Z", + "aggregate_type": "recording", + "aggregate_id": "recording-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "recording_id": "recording-1", + "stage": "upload", + "reason_code": "temporary_oss_unavailable", + "retryable": true, + "next_retry_at": "2026-09-18T00:01:00Z" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} +``` + +**字段说明/消费动作:**`call_id/recording_id` 定位资产;`stage/reason_code/retryable` 表示失败阶段/原因/是否可恢复;可选 `next_retry_at` 为后续处理时间。现行严格 Schema 仍列有旧 `complete/verify` 等阶段,不能据此要求 SaaS 提供上传会话或 verified 接口;是否实际发布此事件需新版合同确认。 + +## 6. 对接尚需确认的部分 + +1. **拟定 HTTP 配置:**两条真实路径、UUID+SECRETKEY 的实际承载、GET 条件读取和 ETag、`200/304` 与错误状态、完整字段、审批来源/摘要及同时修改时的冻结点。本文的英文配置键是项目提案,不是 SaaS 现网字段;确认前不能直接上线。 +2. **版本关联:**`call.execute` 带既有 `task_revision/agent_version_id/route_policy_id`;未接纳执行如何在约 60 秒缓存窗口使用新版配置,须明确合法的版本绑定,不可默改旧命令;已接纳始终用原快照。 +3. **容量和控制:**控制在执行队列拥塞时仍能到达、未接纳任务的 stop 屏障与新队列路由需要版本化 MQ 合同。现行 `.in` 路由不代表目标分队列已建成。 +4. **录音通知:**SaaS 接收 `recording.uploaded` 并自行确认处理;不申请上传会话,不向 D 下发 OSS TOKEN,也不把 publisher confirm 视为业务收讫。 + +现行 MQ 字段以 [`mq.schema.json`](../../contracts/upstream/v1/mq.schema.json)、[`event-payloads.schema.json`](../../contracts/upstream/v1/event-payloads.schema.json)、[`mq-topology.json`](../../contracts/upstream/v1/mq-topology.json) 为准;拟定 HTTP 字段以[配置读取草案](../contracts/config-read-v0.1.schema.json)为待确认结构。