66 KiB
SaaS 交互:OpenAPI 与 MQ 契约规划
版本: v1.0(沿用原文件路径)
状态: 用户已接受方案,作为契约驱动 Mock 开发依据;尚未生成机器可读规范或实现全部接口,非生产验收报告。外部资料由协调取得,真实参数须验证。
依据: 最终开发、部署、监控与验收计划、项目根目录 AGENTS.md。
范围: 双方七条业务 HTTP 路径、RabbitMQ 命令/事件、OSS 录音交接;包含按源命令补传,不建设通用开放平台。
阅读约定:授权撤销、租户绑定/传输超限、按命令补传、应用确认等方案已经接受。正文中的首期“建议”按最终计划的已接受方案实施,不再重新拍板;G0 完成 Schema/Mock 配置与可验证用例,未提供的真实供应商、预算、保留/恢复等参数仍需落实。Mock 成功不代表真实接口、供应商或生产容量通过。
本次修订: 方案从建议转为已接受的开发基线,七条路径保持不变;外部依赖按 OpenAPI/MQ Schema 及各自流式/SIP协议 Mock。部署、监控、测试数值、交付门禁统一引用最终计划;不新增拨号入口,不升级 HTTP /v1。
1. 已确认的系统边界
- RabbitMQ 是唯一外呼执行指令入口,不提供 HTTP 创建通话或重新拨号接口。
- 全部业务结果通过 MQ 回传:受理/拒绝、控制生效、呼叫状态、文字、最终结果、录音就绪/失败等。HTTP 查询用于对账,不替代 MQ 事件。
- HTTP 仅承担任务控制、命令/通话查询、OSS 上传授权与完成确认、触发历史事件 MQ 补传。HTTP 上传完成确认是存储握手,不是业务结果回调。
- 录音先上传 OSS,校验成功并取得 OSS ID 后发布
recording.ready;MQ 不传录音二进制、Base64 或公开播放 URL。 - SaaS 负责客户/任务主数据、业务调度、业务重试决策、授权和长期存储;呼出应用只保存必要执行事实、控制屏障、幂等、资源租约和投递记录。
- 生产采用多机器、多 EIP 直连;每通电话固定 Cell/出口。SaaS 不逐呼改写共享 SIP 配置,也不能任意指定 SIP 地址、凭证或越权主叫。
- 目前只实现 ASR 验证基础,LLM/TTS 协议尚待新规范;本文对完整 AI 链路的描述是目标契约,不代表已启用或验收。
- 按 SaaS 提供的
tenant_key绑定到独立 RabbitMQ 命令队列,由呼出应用调度器负责租户间公平调度。不再采用所有租户共用一个执行 FIFO;同时限制租户发布/积压、预取窗口及跨 Cell 并发/CPS。tenant_key是 SaaS 业务数据,命令、队列路由和后续 MQ 回调均原样使用;命名规则已统一,运行参数和限值仍待 G0 冻结。
2. 交付拆分与双方职责
| 交付物/能力 | 提供方 | 消费方 |
|---|---|---|
| 呼出应用 HTTP:控制、查询、补传 | 呼出应用 | SaaS 后端 |
| SaaS HTTP:录音上传授权、完成确认 | SaaS/存储服务 | 呼出应用资产处理器 |
call.execute 命令 |
SaaS 业务调度器按可信租户归属发布、处理背压 | 呼出应用公平调度器按租户队列有界接收并准入 |
| 执行及资产事件 | 呼出应用 outbox 投递器 | SaaS 事件消费者 |
| MQ VHost、账号、ACL、持久队列和告警 | 运维按冻结契约配置 | 双方服务 |
| 对外发布规范、样例、错误码和验收用例 | 用户主导制定 | 双方评审实施 |
本文是标识、字段、HTTP 路径、MQ 拓扑/事件和状态语义的唯一维护来源;计划与交付文档只引用,完整样例集中在第 8 节。冻结后交付两个 OpenAPI 3.1 文件及共用 MQ JSON Schema,MQ 拓扑沿用本文表格;AsyncAPI 等有明确工具消费需求再补,不作为首期门禁。D01 以共用 Schema 校验全部示例,区分命令、事件和 HTTP 响应;静态必填核对不冒充完整 Schema 验收,不引入 SDK 生成器或开发者门户。
3. 公共约定
3.1 标识与时间
| 字段 | 定义 |
|---|---|
tenant_id |
SaaS 租户 ID;请求中的值必须与服务身份获授权范围匹配,不能仅相信报文自报租户 |
tenant_key |
SaaS 提供的租户业务数据;命令、队列路由和后续 MQ 回调原样保留,不由呼出应用清洗、编码、截断或重命名 |
task_id / task_item_id |
SaaS 任务/号码明细 ID;不因重试改变原明细归属 |
execution_id |
建议新增:SaaS 为一次获授权的业务外呼分配的 ID;网络重试、消息重投和内部线路切换不变,业务重新外呼才新建 |
command_id |
单条执行、控制或补传命令 ID;同一命令重试保持不变 |
call_id |
呼出应用为一次业务执行分配的逻辑通话 ID;一个 execution_id 最多关联一个 call_id |
attempt_id |
一次线路拨号尝试 ID;合法 FALLBACK 创建新的 attempt,但不创建第二个业务执行 |
event_id |
事件身份;重试/历史补传保留原值及原内容 |
recording_id / upload_id |
逻辑录音 ID / 上传会话 ID;授权续期不产生第二份逻辑录音 |
oss_id |
SaaS 存储服务确认的、不透明且稳定的资产 ID;不默认等同于 Object Key、ETag、文件名或 URL |
trace_id |
链路关联信息,不用于授权或幂等 |
- 除
tenant_key外,ID 均为不透明字符串,不按手机号、数字或 UUID 强制改写已有 SaaS ID;建议长度 1–128,拒绝控制字符、路径分隔符和空白首尾。最终字符集在 Schema 冻结。 tenant_key不适用上述本地 ID 格式限制;呼出应用不清洗、编码、截断或重命名,正文、队列及路由绑定值须精确一致。该原值一致性规则不免除服务身份/可信归属检查;传输字段可承载性另见第 5.1.1 节建议。- 时间使用 RFC 3339 UTC,例如
2026-09-11T08:00:00.000Z;持续时间字段以_ms结尾,大小为字节,采样率为 Hz。 - 未接通时
answered_at为null,不填写虚假接通时间;未完成的结果用明确状态,不用空字符串冒充成功。 - 所有唯一键和检索均包含租户作用域。不同租户碰巧使用相同 ID,不能互相查询或命中幂等记录。
3.1.1 租户绑定与原值生命周期(已确认)
- 建议由 SaaS 维护可信的
tenant_id ↔ tenant_key一对一注册关系,平台据此创建/发现队列;业务身份、配额、公平份额及去重始终按 tenant_id 聚合,不按队列数量另发额度。正文/队列/routing key 精确一致仍须同时满足可信注册归属,不能让自报值认领租户。 - tenant_key 是原始字符串;JSON 转义、UTF-8 传输、管理 API 路径转义及 ACL 正则字面量转义仅是协议表示,解码后的业务值必须完全相同。不能把原值直接拼成 ACL 正则、Shell、文件路径或 SQL。这里不增加字符清洗、大小写转换或业务格式限制。
- 建议首期不支持原地变更 key;保留期内不向别的租户复用旧绑定。迁移须另行评审停发、屏障、队列排空、去重/事件保留与恢复规则,不自动创建第二队列来绕过额度。
- 命令、任务、执行和 outbox 持久保存当时的 tenant_key;历史回传使用原快照,不能按最新配置重新写值。幂等键仍为
(tenant_id, command_id)、(tenant_id, execution_id)、(tenant_id, event_id),不加入 tenant_key;同 event_id 异内容应隔离告警而非重复应用。 - HTTP 控制可早于首条 execute:tenant_key 从服务身份获授权的可信注册关系解析并保存,而不是从首次执行猜测。绑定不可用或不一致时明确拒绝/报依赖不可用,不确认 accepted,更不能伪造 key 发送事件;任务归属另按第 4.1 节验证。
3.2 HTTP 共性
- 使用 HTTPS,仅服务到服务调用,不向浏览器或公网客户直接暴露内部接口。
- 公共请求头:
Authorization: Bearer <service-token>、X-Tenant-ID、X-Request-ID;有 JSON 请求体时使用Content-Type: application/json。可传播traceparent。 - Bearer 凭证优先接入双方已有服务身份体系,不另建账号平台;建议短期且限定 audience/scope/租户的令牌。跨管理网场景可增加 mTLS,具体签发/轮换方式列入 G0。
- POST 必须提供
Idempotency-Key。控制/补传接口要求它等于command_id;存储接口为一次逻辑操作生成稳定键。 - 同租户、服务调用方、操作及幂等键下,相同语义请求返回原操作标识;请求内容不同返回
409 IDEMPOTENCY_CONFLICT。服务端对校验后的语义字段比较,不因 JSON 字段顺序产生冲突。 202 Accepted仅代表命令与 outbox 已可靠持久化,返回查询位置;不是控制已生效或补传已完成。普通查询返回200,首次创建上传会话返回201,完成存储确认返回200。- 同步校验/鉴权失败返回 HTTP 错误,不代表接受了命令;未通过鉴权的请求不产生租户业务事件。持久化受理后的业务结果必须走 MQ。
- 成功响应直接返回资源对象;错误使用
application/problem+json,含type、title、status、code、detail、request_id、retryable,不暴露堆栈和凭证。
3.3 重试及数据期限
- HTTP 超时或连接断开:先按原命令/幂等键查询或重试,不换 ID。只对网络错误、429 和约定可恢复 5xx 做有上限退避;遵守
Retry-After,不得无限重试业务拒绝。 - 命令去重、控制屏障和事件保留期必须覆盖最大合法重投/恢复窗口;数据过期后不能把旧执行当作新执行。
- 执行 ID 去重记录的清理需依赖“该授权已永久失效”的可靠依据;控制停止墓碑同理。未建立安全清理机制前,不能仅凭普通 TTL 删除防重拨屏障。
- 已知但超出事件补传保留期返回
410 REPLAY_EXPIRED;不存在或无权访问资源统一返回404,避免跨租户枚举。
4. HTTP OpenAPI 规划
两个服务分别使用自己的 Base URL,不共享服务实现;下表路径均为相对各自服务根路径。
| 提供方 | 方法与路径 | 最小权限 | 结果 |
|---|---|---|---|
| 呼出应用 | POST /internal/v1/outbound/tasks/{task_id}/controls |
outbound.control |
202;持久化控制命令 |
| 呼出应用 | GET /internal/v1/outbound/commands/{command_id} |
outbound.read |
200;命令与生效状态 |
| 呼出应用 | GET /internal/v1/outbound/calls/{call_id} |
outbound.read |
200;执行事实与资产/投递快照 |
| 呼出应用 | POST /internal/v1/outbound/calls/{call_id}/replays |
outbound.replay |
202;受控历史事件补传 |
| 呼出应用 | POST /internal/v1/outbound/commands/{source_command_id}/replays |
outbound.replay |
202;无 call_id 的原命令结果补传 |
| SaaS | POST /internal/v1/outbound/recording-uploads |
recording.upload |
201/200;创建或取得原上传会话 |
| SaaS | POST /internal/v1/outbound/recording-uploads/{upload_id}/complete |
recording.complete |
200;存储验证成功,确认 OSS ID |
4.1 任务控制
请求字段:
| 字段 | 必填 | 规则 |
|---|---|---|
command_id |
是 | 与 Idempotency-Key 一致 |
action |
是 | pause / resume / stop |
expected_task_revision |
是 | SaaS 认为当前应有的控制版本;CAS 成功后服务端生成目标版本 expected + 1 |
active_call_policy |
stop 时是 | drain / hangup;不设隐藏的强制挂断默认值 |
reason |
是 | 有界审计原因,不放敏感客户资料 |
响应字段:command_id、task_id、status=accepted、requested_task_revision、accepted_at;Location 指向命令查询接口。
建议状态与竞争规则:
task_revision仅表示任务控制栅栏版本,不表示智能体配置版本。首版建议初始版本为 1,任务初始运行;尚未收到 execute 时的控制,也必须能为获授权任务建立持久屏障。- 基于服务身份及双方可信任务绑定验证归属;不能因第一次见到任意 task_id 就将其认领给请求租户。任务绑定来源在 G0 确认,可来自获授权 SaaS 主数据或其受信发布边界,不额外开放拨号入口。
- 同任务控制串行化,提交时 CAS 校验 expected,服务端在同一事务生成并保存目标版本 expected + 1;控制请求不接受 task_revision,响应/查询/MQ 仍区分 requested_task_revision 与 applied_task_revision。版本冲突或另一个控制尚在生效中返回 409;重复 command_id 先走幂等并返回原目标版本,不重复递增。执行命令的 task_revision 不变,仍必须使用已生效版本。
- 暂停/停止受理后,先禁止发放新的拨号许可,再等待所有相关 Cell 上的在途许可完成或失效;确认后才能回传
applied。存在状态不明节点时保持applying/reconciling,不能虚报全局生效。 - 每次真实发起和 FALLBACK 都检查当前控制版本、状态、授权期限及有效资源租约;节点失联或租约过期停止新发起。仅把数据库改为 paused 不足以形成屏障。
pause:已拨出和已接通电话继续原生命周期;未发起执行不再启动,报告阻止原因。resume:允许新版本指令,不自动重放旧积压。- 旧版本命令拒绝;未来版本命令也不自动缓存为可执行。SaaS 如需重新调度被阻止且确认未拨号的执行,先完成对账及业务授权,再生成新的执行命令,不能由消费者静默换 ID 重试。
stop + drain:停止新发起,已有电话自然结束;stop + hangup:额外需要outbound.hangup权限,审计后终止该任务已有通道。挂断未确认前不能宣称 hangup 控制已完全生效。- stopped 不允许 resume;重新开展业务使用新任务,避免迟到指令复活。控制失败后不能自行放开已经建立的暂停/停止屏障。
- HTTP 查询和 MQ
command.result均区分 requested revision 与 applied revision;这项多 Cell 屏障能力为待实现项。
4.1.1 已排队执行的授权撤销(已确认)
首期建议复用任务级 pause/stop 屏障,允许为安全暂停整个受影响任务,暂不增加号码级控制接口;不能仅发布另一条 call.execute 或仅停止新增发布来撤销旧命令。
- SaaS 持久化拒绝再联系/撤销标记,并与任务生成及发布出队的授权检查协调,立即阻止该业务对象产生新的授权执行。持久追踪所有受影响任务及待发布、发布不确定、已发布和已受理的执行;不能只查 broker 中可见消息。
- 对所有受影响且未停止的任务提交现有 pause 控制,保存各任务的 command_id/revision。控制必须覆盖 broker 积压、waiting、已提交但尚未真实发起的意图以及 FALLBACK;不能通过改写/删除共享队列中的号码消息实现撤销。
- 全部相关任务屏障 applied、在途许可已确认不能再发起后,SaaS 才标记执行侧撤销生效。HTTP 超时、CAS 冲突、已有控制处理中或 Cell 失联时保持 pending/reconciling,原 ID 重试或对账,不虚报成功、不放开发布。已发出的电话按既定生命周期继续,强制挂断须另有授权。
- 后续若恢复受影响任务,必须再次过滤撤销对象、完成旧执行事实对账,并使用当前已生效版本的新授权执行;旧 command_id/execution_id 原样重投仍命中原事实,不静默复活。stopped 任务不恢复。
- SaaS 只在允许拨号时段内发布,not_after 不晚于本次允许时段结束;时段/授权在发布后被提前收紧也触发上述屏障。单靠原 not_after 不能感知新撤销。
contact.opt_out 经 MQ 到达 SaaS 后触发上述流程。须分别测量客户提出→事件落库→SaaS 禁止新发布→各任务屏障 applied 的时间;MQ 延迟期间不能宣称已全局撤销。G0 冻结生效时限、关联任务枚举依据及整任务暂停的业务代价;若要求立即生效或必须逐号码隔离,需另评审撤销机制,不擅自降低 MQ-09 验收标准。
4.2 命令查询
通用返回:command_id、command_type、task_id/call_id(适用时)、status、reason_code、accepted_at、updated_at。
- execute:
accepted/waiting/rejected/executing/completed/reconciling;accepted 仅表示可靠受理,waiting 表示已进入有界准入等待,executing 表示已提交拨号意图、不等于 SIP 已实际发出或接通;completed 指执行生命周期结束,不等于客户接通或业务成功。 - control:
accepted/applying/applied/rejected/failed/reconciling,另含 requested/applied revision 和当前任务状态。 - replay:
accepted/running/completed/failed,另含快照截止点、已选/已确认发布数量;completed 不代表 SaaS 已入库。 - HTTP 校验阶段就被拒绝、未持久化的命令允许查询不到;MQ 收到的合法业务拒绝应持久化并可查询。
execute 查询及对应 command.result 建议共用以下字段,MQ 的等待/状态变化仍按 command 聚合版本合并:
| 字段 | 规则 |
|---|---|
execution_id |
原业务授权执行 ID,受理后始终可关联 |
call_id |
首次资源准入成功并提交拨号意图时创建;此前为 null,不伪造通话记录 |
wait_reason_code |
waiting 时必填:SCHEDULER_WAIT、TENANT_CONCURRENCY_EXHAUSTED、TENANT_CPS_EXHAUSTED、ROUTE_CAPACITY_EXHAUSTED、CAPACITY_EXHAUSTED;非 waiting 为 null |
waiting_since |
首次进入 waiting 的服务端时间;原因变化不重置,离开等待后保留;未等待为 null |
admission_deadline |
受理时固定的首次发起准入截止时间;拒绝于受理前时为 null,重投/重启不延长 |
updated_at、aggregate_version |
当前命令快照的更新时间和版本;HTTP 与 MQ 使用同一版本域。HTTP 为响应字段,MQ 的 aggregate_version 只在事件外壳中,不重复放入 payload |
- waiting 的原因表示当前主要阻塞项,不是完整资源诊断或预计开始时间;多个条件同时不足时按 G0 冻结的优先级选择。原因改变可发布新版本快照,不按轮询周期重复发事件。
- 首次发起前以
(tenant_id, command_id)查询;尚在 broker 队列、未被呼出侧持久化的命令允许返回 404。404 不证明消息未发布或电话一定未发生,不能据此换 execution_id 重拨;SaaS 保留原发布记录并用原 ID 对账/有限重试。 - 状态路径为
accepted → waiting → executing → completed,有资源时可跳过 waiting。尚未提交拨号意图的 accepted/waiting 可因过期、控制或准入超时进入 rejected;提交意图后出现不确定性进入 reconciling,不回退 waiting 或以“未拨号拒绝”释放未知占用。
4.3 通话查询
返回 call_id、execution_id、任务关联、call_state、call_version、起止时间、结果原因及以下独立信息:
attempts:每次 attempt 的状态、原因、trunk_id、egress_pool_id、cell_id、时间;不暴露 SIP 密码或内部管理地址。transcript:处理状态、最终稿数量及是否仍有待完成段落;首版查询不返回整段文字,文字正文通过 MQ 到 SaaS。recordings:录音标识、处理状态、确认后的oss_id和失败原因;不返回上传凭证或播放链接。delivery:待投递/失败数量、最后确认发布时间。RabbitMQ confirm 仅表示 broker 接收,saas_applied未有应用层证据时必须为 unknown,不能猜测为已入库。- 附
snapshot_at;查询是某一时点的执行快照,SaaS 根据版本合并,不用旧快照覆盖较新事件。
4.4 历史事件补传
请求体仅包含 command_id(本次补传操作 ID)和 reason;Idempotency-Key 等于本次 command_id。首期不支持事件类型/ID 筛选,额外筛选字段按 INVALID_ARGUMENT 拒绝,不能静默忽略后扩大重放范围。
POST /internal/v1/outbound/calls/{call_id}/replays:整体补传当前租户、该 call_id 关联的已保留历史业务事件,包含通话/文字/录音及关联执行命令结果;不跨通话、不包含补传操作自身结果。POST /internal/v1/outbound/commands/{source_command_id}/replays:整体补传当前租户、aggregate_type=command、aggregate_id=source_command_id的原 command.result;无 call_id 也可恢复。source_command_id 不得等于本次 command_id。两条路径均校验outbound.replay及资源归属;不存在/无权访问返回404,已知保留期过期返回410。- 两种补传均返回 202 及命令查询 Location;本次状态为 accepted/running/completed/failed,completed 仅表示范围内消息完成 broker 确认,不表示 SaaS 应用。
- 受理时持久化租户、资源范围和固定事件截止点;重复请求复用原任务/截止点,不纳入后来新事件。执行时分批读取,不因“整体补传”一次加载全部历史到内存。
- 所有被选事件必须来自原持久化事件记录;保留 event_id、原时间和原 payload,可仅在传输头标记 replay。禁止重新合成历史业务事实。
- 只重发结果/文字/录音元数据;不执行拨号、不重放
call.execute、不重传录音文件、不重新调用 LLM/TTS。 - 对补传限速并独立计量,避免挤占实时结果队列;部分成功允许重试未确认部分,重复投递由 SaaS 去重。
- 补传命令自身结果也走 MQ
command.result;不把本次补传生成的结果循环纳入本次选择集。
4.5 录音上传授权
请求:recording_id、call_id、content_type、size_bytes、checksum_algorithm、checksum、channels、sample_rate_hz、duration_ms。上传前须封口文件,确保大小和校验值稳定。
返回:upload_id、recording_id、expires_at、upload_method、upload_url、required_headers、约束及已完成会话的 oss_id(如有)。
- 首版建议单文件签名 PUT;最长录音推导的文件上限不满足单 PUT 或恢复窗口时,再增加分片协议,不在本版虚构分片接口。
- SaaS 按 tenant/call/recording 绑定会话并分配存储位置,不接受调用方任意 bucket/object key。因 MQ 延迟尚无通话记录时,不绕过归属检查;返回可重试
CALL_NOT_REGISTERED,呼出侧保留本地文件。 - 同一逻辑录音禁止因重复申请产生第二份资产;授权失效后重新请求原会话,可返回新的临时签名,但会话、对象和内容约束保持不变。更换内容必须使用明确的新录音版本。
- URL 为受控 OSS HTTPS 目标,服务端限制可访问域名/存储端点、不跟随任意重定向,防止上传器成为 SSRF 或外传通道。
- 凭证只允许指定对象写入,不授予列桶/删桶/任意对象权限;完成对象应不可被原授权继续覆盖,使用 OSS 禁止覆盖约束或可靠对象版本固定机制。
- 授权响应
Cache-Control: no-store;签名 URL、token、required_headers 中的秘密不得进入日志或 MQ。
4.6 上传完成确认
请求:recording_id、size_bytes、checksum_algorithm、checksum,可带 OSS 返回的 etag(仅作辅助,不当作文件内容摘要)。
- SaaS 根据 upload_id 找到受控目标,核验调用方、对象实际存在、大小、实际内容校验及录音绑定;不能只相信客户端提交的 checksum 或对象自报元数据。
- 校验算法需选择 OSS/存储服务能够独立验证的机制并冻结;无法独立验证时不能声称校验成功。ETag 尤其在分片/加密情况下不等于文件 MD5。
- 对象未就绪返回可重试错误;内容冲突进入隔离/人工处置,不创建就绪资产。
- 验证成功,幂等返回
upload_id、recording_id、status=verified、oss_id、verified_at。确认超时后用原幂等键重试,不能新建第二份录音。 - 呼出侧把 verified 事实与
recording.readyoutbox 同事务持久化,再由 MQ 投递。SaaS 只有消费 ready 后才将资产作为业务录音关联展示。 - 任一步失败通过
recording.failed回传可恢复性与原因;未取得有效 oss_id 不发布 ready。本地文件清理受恢复期限和已可靠持久化交接证据约束,不能仅收到 HTTP 200 就无条件删除。
5. RabbitMQ 拓扑与传输
5.1 最小拓扑与命名规则
| 对象 | 名称/绑定 | 用途 |
|---|---|---|
| 环境 VHost | /agent-call-<env> |
测试/生产隔离,具体路径由运维配置 |
| 命令 Direct Exchange | agent-call.commands.v1 |
direct、durable;Routing Key 为 agent-call.tenant.{tenant_key}.call.execute,消息类型仍为 call.execute |
| 租户独立命令队列 | agent-call.executor.{tenant_key}.v1 |
一租户一队列,精确绑定 agent-call.tenant.{tenant_key}.call.execute;公平调度器有界读取,共享持久去重存储 |
| 事件 Topic Exchange | agent-call.events.v1 |
发布第 7 节允许的事件;Routing Key 为 agent-call.{event_type} |
| SaaS 事件队列 | agent-call.saas.events.v1 |
SaaS 消费落库 |
| 死信 Exchange | agent-call.dead.v1 |
隔离无效消息与耗尽重试 |
| 死信队列 | agent-call.commands.dead.v1、agent-call.events.dead.v1 |
分方向审计与受控恢复 |
agent-call是 VHost、Exchange、Queue 和 Routing Key 的固定命名空间。命令键固定为agent-call.tenant.{tenant_key}.call.execute;事件键固定为agent-call.{event_type}。AMQPtype字段仍使用无前缀的call.execute或事件类型。tenant_key是 SaaS 提供的原始业务数据,不是呼出应用重新生成的路由标识;不做清洗、编码、大小写转换、截断或其它业务格式限制。命令、队列路由、消息正文和所有后续 MQ 回调原样使用;正文值与队列/路由绑定值须精确一致,同时验证可信归属;传输上限处理见第 5.1.1 节,不擅自改写业务值。- 命令 exchange 使用
direct,使tenant_key中的点号、*、#等字符不会被当作 topic 通配语义;不得将命令 exchange 改回 topic 并依赖格式限制规避冲突。 - 本轮仅改变执行命令的租户隔离拓扑,事件队列不自动扩展为一租户一队列;事件消费和补传仍需限速及租户校验,不能由此宣称结果链路已有同等等待时延保证。
- Exchange/Queue durable,业务消息 persistent;生产建议 quorum queue,最终以现有 RabbitMQ 版本及 HA 部署确认,不把 durable 单节点当高可用。
- 命令/事件分别使用最小权限账号,TLS 连接;SaaS 不能消费执行器队列,呼出应用不能消费 SaaS 事件队列,也不能发布 execute 指令。
- 所有生产者使用 publisher confirm + mandatory/不可路由检查;消费者手动 ACK,只在本地必要数据持久化成功后 ACK。
- 队列不替代业务状态库;多消费者不承诺全局顺序。业务版本负责乱序合并,数据库约束和执行租约负责去重/准入。
- 可信 SaaS 生产者可被授权发布多个租户;发布者身份来自 broker 账号及受控发布边界,不信任可伪造的 message header。若开放非受信租户直连 MQ,必须另做租户隔离设计,本期不开放。
- 命令业务过期以
not_after校验为准,队列 TTL 仅用于积压治理。过期命令应有可追踪的拒绝/隔离记录,不静默消失;DLQ 恢复不能绕过有效期和停止屏障。命令重试/恢复必须回到原租户调度域并受相同配额约束,不经全局重试 FIFO 直接抢占执行。共享死信队列只用于隔离审计,不是执行入口。 - 消费瞬时故障不得无限
nack(requeue=true)热循环;采用有上限的延迟重试/持久重试记录。若发布到重试队列,必须等新发布确认后再 ACK 原消息,并依靠幂等承受重复。 - 死信路径也要验证不丢失;quorum 至少一次 dead-letter 或应用确认后转存二选一落实,不能默认普通 DLX 搬运具有端到端保证。
- 非法 Schema/未知主版本进入隔离队列,不凭不可信字段向任意租户发送错误事件;可安全识别的合法业务拒绝发布
command.result后完成处理。
5.1.1 tenant_key 的传输可承载性(已确认)
原样透传不等于 RabbitMQ 可承载任意长度。AMQP 0-9-1 routing key 使用 shortstr,最多 255 字节;当前 agent-call.tenant. + .call.execute 固定占 31 字节,tenant_key 可用预算为 224 个 UTF-8 字节。队列名也受 broker 字节上限约束,应分别检查完整队列名与路由键;direct 只消除 topic 通配语义,不消除长度限制。
建议注册/发布前做完整传输字段的可承载性检查,不清洗、不截断、不替换原值。超限时停止为该绑定创建队列和发送执行命令,SaaS 持久保留原始任务并明确标记传输不支持、告警;未发布/未受理的状态不是呼出侧 MQ 业务拒绝,不凭空发 command.result,也不无限重试相同不适配值。其它租户不受该注册失败阻断。
超限停发且保留原任务的方案已接受;接入时用实际 SaaS 数据和 broker/client 版本验证承载能力。若业务后来要求必须接收更长 key,再走传输标识分离的变更评审。本版不擅自引入哈希/编码映射,也不宣称已支持任意长度。契约测试覆盖 224/225 字节、中文多字节、点号、星号、井号及 ACL 正则特殊字符;允许值在命令和所有回传中逐值相等。
5.2 传输属性
content_type=application/json、UTF-8、delivery_mode=2、message_id=command_id/event_id、type=command_type/event_type;correlation_id 可使用 command_id,正文 trace_id 负责跨环节跟踪。
建议消息上限 256 KiB、HTTP JSON 请求体上限 64 KiB,均待容量/样例验证;超限拒绝或按已定义的文字分段规则发送,禁止静默截断最终文字。MQ 不传文件或服务凭证。
5.3 租户公平调度(架构已确认,参数待冻结)
SaaS 持久任务/发布记录
→ 命令 Exchange:按 SaaS tenant_key 原样路由
→ 租户 A 命令队列 ┐
→ 租户 B 命令队列 ├→ 公平调度器 → 租户额度+完整资源租约 → 固定 Cell/出口
→ 租户 C 命令队列 ┘
- 责任分离: SaaS 决定业务任务、号码和重新外呼授权,并按租户发布;呼出应用决定租户间执行机会和物理资源分配。不要求 SaaS 等待上一租户批次执行完才发布下一租户,也不复制其任务编排系统。
- 轮转而非排空: 默认建议活跃且可调度租户等权轮询,差异化服务获确认后采用加权轮询。每轮每租户最多取有限条命令;租户没额度、线路不可用时跳过,不能阻塞全局循环。新活跃租户应及时加入轮转,不能等待 A 队列排空。权重由平台配置,不接受每条消息自报高优先级。
- 有界预取与持久待执行窗口: 按租户及全局限制 prefetch/未 ACK 和已 ACK 未发起数量;队列不等于进程,一租户一队列不要求一套服务。禁止先把全部租户消费到同一个无界内存 FIFO;已持久化的待执行命令及重启恢复也按租户公平选取。prefetch 本身不是公平算法。
- 配额双重检查: 读取前检查租户可接收窗口/资源可用性,持久化命令及去重/outbox 后 ACK;这不是提前保证拨号容量。实际发起必须原子取得租户额度并同时满足供应商并发/CPS、Cell 端口、出口健康、AI 配额和控制屏障,失败释放未使用预留。已受理命令的有限等待/拒绝按第 6.2 节处理;尚未取出的命令受队列积压上限及有效期约束。
- 多实例与恢复: 多个调度器需通过租户调度所有权/租约及隔离令牌协调同一租户的轮次、窗口和额度,不能每个实例独立再分一份。公平性覆盖整个调度域,不只覆盖每个进程自己的队列。失去所有权的旧实例停止分配;已拨通但状态不明的占用不能仅因租约到期就释放,需对账,防止超额与重复拨号。具体协调实现待设计评审。
- 公平的边界: 保证有可用资源和租户额度时不被另一租户积压饿死,不保证相同接通数或相同通话时长。资源已被活动通话占满时等待释放,不为公平挂断电话。若要明确开始时限,另确认不可借用保底容量或受限借用策略,覆盖线路/AI/Cell 全链路;不能把空闲份额借出后又承诺立即收回。
| 租户限制 | 统计范围/约束 |
|---|---|
| 并发占用上限 | 跨所有 Cell 汇总,包含发起预留、拨号、振铃、接通及待对账占用;从发起前占用到确认资源结束后释放 |
| CPS 上限 | 跨所有调度实例和 Cell 统计真实拨号尝试,包括 FALLBACK;切线路不重置租户额度 |
| 接收/待发起窗口 | 每租户和全局的未 ACK、已持久化未发起数量均有界;拒绝/过期处理不得绕成新的无界工作队列 |
| 发布速率、队列容量 | 每租户限制消息数/字节、发布速率并辅以全局 broker 水位保护;独立队列仍共享磁盘、内存及网络 |
| 调度权重 | 默认建议等权;只改变有竞争时新增许可份额,不绕过硬上限,不保证通话时长比例 |
5.4 队列生命周期与背压
- 平台依据可信租户注册关系幂等创建队列、精确绑定和权限,再允许 SaaS 发布;启动/故障恢复能重新发现活跃队列,不依赖仅存在于内存的清单。租户停用先拒绝新发布并建立控制屏障,完成积压/活动通话/重试及去重保留处置后才能删除队列,不以自动过期直接删积压。
- 本期仅可信 SaaS 发布服务连接 broker,最终用户不直连。发布服务强制执行每租户路由、速率和容量策略;AMQP 普通资源 ACL 不能替代可信发布服务的逐租户授权校验。
- 队列满采用明确拒绝发布的溢出策略(建议验证
reject-publish与 quorum 的组合),不能丢弃队头旧命令来给新命令让路。SaaS 任务/发布记录持久保留;nack、不可路由或 confirm 不确定时,有限退避后使用原 command_id/execution_id 重试,不换 ID、不转投其他租户队列。broker blocked/全局水位达到阈值时也要背压。 - 消费端的公平轮转不能隔离发布洪峰的全部成本;大任务可长期保留在 SaaS 数据库,向本租户 MQ 分批有界推送。允许积压但不允许无限灌入;不能以独立队列为由承诺任意租户数、无限 quorum 副本或无限 broker 吞吐。
- HTTP 暂停/停止控制保持原路径,不排在 A 的大批 execute 后;实际发起前的屏障校验仍强制执行。补传/失败重试不能绕过租户配额占满执行资源。
发布结果必须与呼出应用的业务受理区分:
| AMQP 结果 | SaaS 处理 |
|---|---|
| confirm ACK 且未收到 mandatory return | broker 已接收并路由,不代表呼出应用已受理、已准入或已拨号 |
| mandatory return(不可路由) | 持久保留原发布记录,修复租户队列/绑定后在有效期内原 ID 有限重试;不得转投其他租户 |
| confirm NACK(含队列满时拒绝发布) | 记录发布未成功并退避;原因依 broker 诊断,不能把所有 NACK 都当成队列满 |
| confirm 超时、连接中断或 broker blocked | 状态不确定/发布受阻,实施背压;原 ID 有限重试并接受可能重复,不能假定消息未入队 |
以上不是 HTTP 429,也不是 MQ command.result 业务拒绝;队列创建/绑定/配额通过受控平台配置落实,首版不新增管理 API。正文租户、路由键、队列绑定的正反例和 broker 版本相关的满队列行为必须纳入契约测试。
6. 执行命令 call.execute
6.1 字段
通用外壳:schema_version、command_type=call.execute、command_id、tenant_id、tenant_key、trace_id、issued_at、not_after、payload。
payload 必需:
| 字段 | 说明 |
|---|---|
execution_id、task_id、task_item_id |
业务授权执行及任务归属 |
task_revision |
必须等于当前生效且允许运行的控制版本 |
callee |
原始业务被叫字符串,不提前拼供应商前缀 |
route_policy_id |
已配置且获租户授权的线路策略引用,可只包含一个供应商 |
caller_profile_id |
已授权主叫引用,不允许 arbitrary From/PAI |
agent_version_id |
服务端已可解析的不可变智能体配置版本 |
variables |
经字段白名单、类型/长度校验的运行变量,不是任意代码或任意 URL |
ring_timeout_ms、max_call_duration_ms |
不超过服务端和供应商上限 |
agent_version_id 必须对应呼出侧可用且可信的快照;配置如何同步、LLM/TTS 参数和取消/音频契约留待新规范。缺失配置明确拒绝,不私自读取旧 voice_test LLM/TTS 或任意远程 URL。
线路策略由呼出侧解析为授权 trunk_id + egress_pool_id + cell_id,实际选择写入事件。呼叫固定 Cell/出口;允许的 FALLBACK 也必须兼容同一 Cell/出口及供应商白名单,无适合备用就失败,不迁移活动通话。当前只有一家线路,不虚构 backup。
6.2 幂等与发起
- 验证身份边界、Schema 和 tenant/task 关联;对已记录的 command_id/execution_id 先检查语义冲突并返回/关联原结果,不因重投时已过期或控制已变而改写原事实。新执行再检查有效期、控制版本和配置/线路授权;防重查重不绕过当前调用方的租户访问校验。
- 事务记录命令,使用
(tenant_id, command_id)唯一键;另以(tenant_id, execution_id)保护业务执行,换 command_id 也不能绕过同一次执行去重。 - 同 execution_id 的语义冲突拒绝;相同执行重复只关联原结果,不能再次拨号。接受/拒绝事实及其 outbox 同事务提交,随后 ACK。
- 在租户公平调度下,实际拨号前原子取得跨 Cell 的租户并发/CPS 额度及供应商并发/CPS、Cell 端口/出口健康和 AI 配额等完整租约,重新检查有效期与控制屏障。已受理不等于已获得资源,FALLBACK 不绕过租户额度。
- 受理时固定
admission_deadline = min(not_after, accepted_at + 服务端准入窗口),窗口值在 G0 冻结,不允许消息自报延长。broker 中未消费的等待不计入该窗口,但始终消耗 issued_at→not_after 的授权期限;取出时已过期直接拒绝。等待进入/原因变化及退出通过第 4.2 节查询和 MQ 事件表达。- 首次发起在持久化意图前检查:
now >= not_after优先拒绝为COMMAND_EXPIRED;否则now >= admission_deadline按当前资源瓶颈拒绝,返回对应租户/线路/系统资源原因码;纯调度等待或截止时资源刚释放但尚未发起,均返回ADMISSION_TIMEOUT。拒绝原因写入reason_code,非 waiting 的 wait_reason_code 清空,保留 waiting_since 和 admission_deadline。到期处理不依赖资源释放或该租户再次获调度,必须有界扫描/唤醒并终结待发起命令;允许的处理延迟在 G0 冻结。 - 已受理后拒绝需持久化并经 MQ 发布,不因原消息已 ACK 而丢失终结结果;准入失败释放已确认未使用的预留,不释放状态不明占用。是否新建获授权业务执行由 SaaS 对账后决定,不无限排队或私自重拨。
- admission_deadline 只约束首次发起,不挂断已拨出/已接通电话;
not_after是每次新拨号尝试(包括 FALLBACK)的发起截止时间,不是活动通话的强制结束时间。FALLBACK 仍受总尝试/执行限制、控制屏障、租户 CPS 和完整资源准入约束,不能借其延长首次等待。
- 首次发起在持久化意图前检查:
- ARI 发起前持久化意图、call_id/attempt_id 和固定通道关联;执行器真实发起前再次检查有效期、控制/租约及首次准入截止,不能仅凭早先提交意图越过截止。已提交意图但确认尚未发出且授权失效时,以 completed 和对应失败/取消的 call.finished 收尾,不回退为无 call_id 的 rejected;是否已发出不明时进入 reconciling。网络超时/重启先对账,已接通或旧通道未确认结束时禁止创建备用尝试。
7. MQ 事件契约
7.1 通用外壳与合并
必需:schema_version、event_id、event_type、tenant_id、tenant_key、trace_id、occurred_at、aggregate_type、aggregate_id、aggregate_version、payload。
- 聚合标识按 command/call/transcript_segment/recording 分域;版本由对应事实持久化事务单调递增,不依赖消息到达时间。
- command/task/call/attempt 关联按事件类型必填;尚未生成 call_id 的拒绝不伪造 call_id。
- SaaS 以
(tenant_id, event_id)建 inbox 唯一键,并将业务更新和 inbox 同事务提交,成功后 ACK。 - 版本比较限定同一实体及同一状态域。较旧事件不回退状态,但仍可补充尚未落库的独立 attempt/录音记录;不能用一个全局“最大版本”丢弃其他资产或文字。
- 事件 payload 是该事件声明的状态域快照;call.finished 的终态与资产处理状态分别合并,不能覆盖后到或先到但更新的 recording.ready。
- 不承诺端到端 exactly-once;采用至少一次传输、持久去重及外部拨号副作用对账。
7.2 事件列表
| Routing Key / event_type | 必需业务数据 | 合并规则 |
|---|---|---|
agent-call.command.result / command.result |
command_id、command_type、status、reason_code;适用的任务/执行/通话关联及版本;execute 增加第 4.2 节等待/准入字段 | 以 command 聚合版本更新;区分 accepted、waiting、executing、applied、completed;不把等待当成已拨号 |
agent-call.call.status / call.status |
call_id、execution_id、任务关联、call_state、call_version、attempt_id、attempt 状态、实际线路/Cell/出口、时间/原因 | 保持 call/attempt 各自状态,不让迟到 ringing 回退 answered/ended |
agent-call.transcript.updated / transcript.updated |
call_id、turn_id、segment_id、role、revision、text、is_final、start_ms、end_ms、playback_state | 同 segment 较高 revision 替换;最终稿不能被迟到中间稿覆盖 |
agent-call.call.finished / call.finished |
call_id、execution_id、任务关联、call_version、outcome、起止时间、时长、原因、attempt 汇总及资产处理快照 | 固定通话终态;后处理未完成时标为 pending,不阻塞终态 |
agent-call.recording.ready / recording.ready |
call_id、recording_id、oss_id、格式、声道、采样率、时长、大小及校验 | 只接收已验证资产;不包含上传凭证或公开 URL |
agent-call.recording.failed / recording.failed |
call_id、recording_id、stage、reason_code、retryable、next_retry_at(若有) | 标记资产故障,不改变通话终态;后续合法 ready 可完成恢复 |
agent-call.transcript.failed / transcript.failed |
call_id、原因、retryable、受影响 segment(适用时) | 明确文字不完整,不能把部分文本假装最终完整记录 |
agent-call.contact.opt_out / contact.opt_out |
call_id、task_id、task_item_id、请求时间、关联 turn/segment(若有) | SaaS 及时持久化拒绝再联系并阻止后续业务调度;不等待挂断 |
后两类事件补齐文字失败及拒绝再联系的可追踪闭环;opt_out 的触发判定需由业务及新 AI 规范确认,不能默认靠未经验证的关键词误判。
文字 role 建议为 customer / agent / system;playback_state 使用 not_applicable / generated / sent / playback_confirmed / cancelled / unknown。只能报告实际具备的播放证据,不能将“已生成/已发送”写成“客户已听见”。超长 turn 拆成稳定 segment;最终稿是否允许修订及保留要求由 G0 冻结。
7.3 通话与资产状态
- call_state:
queued → dialing → ringing → answered → ended,允许省略未发生的阶段。queued 仅表示已创建 call_id 并持久化意图但尚无实际发起证据;资源准入前的 waiting 属于命令状态,不提前创建通话。dialing/振铃/接通须有对应执行证据。失败可从任一未接通阶段进入 ended;FALLBACK 保持逻辑通话未结束并新增 attempt,不能回退成新业务通话。 - outcome:
completed/busy/no_answer/rejected/failed/cancelled等标准值;completed 仅表示正常结束,不是营销成交。最终值及 SIP/Asterisk 映射待供应商确认。 - 对账状态单独为
reconciling,不要凭本地连接断开生成虚假通话终态。 - recording:
pending → uploading → verifying → ready,失败另记 retryable/原因;transcript:pending/streaming/finalized/failed;delivery:pending/broker_confirmed/failed。三类后处理不改写 ended。
8. 最小交互样例
以下 ID 为示例,不是现网资源、真实号码或可调用配置。
8.1 暂停任务
POST /internal/v1/outbound/tasks/task-demo/controls
Authorization: Bearer <service-token>
X-Tenant-ID: tenant-demo
X-Request-ID: req-demo-01
Idempotency-Key: cmd-pause-demo
Content-Type: application/json
{"command_id":"cmd-pause-demo","action":"pause","expected_task_revision":1,"reason":"operator_pause"}
{"command_id":"cmd-pause-demo","tenant_id":"tenant-demo","tenant_key":"tenant-demo-key","task_id":"task-demo","status":"accepted","requested_task_revision":2,"accepted_at":"2026-09-11T08:00:00.000Z"}
HTTP 返回 202 后,直到收到 MQ applied 或查询到 applied,SaaS 才能显示“暂停已生效”。即使收到 applied,已有通话也可能继续。
8.2 控制生效事件
{
"schema_version": "1.0",
"event_id": "evt-pause-demo",
"event_type": "command.result",
"tenant_id": "tenant-demo",
"tenant_key": "tenant-demo-key",
"trace_id": "trace-demo",
"occurred_at": "2026-09-11T08:00:01.000Z",
"aggregate_type": "command",
"aggregate_id": "cmd-pause-demo",
"aggregate_version": 2,
"payload": {
"command_id": "cmd-pause-demo",
"command_type": "task.control",
"task_id": "task-demo",
"status": "applied",
"reason_code": "CONTROL_APPLIED",
"requested_task_revision": 2,
"applied_task_revision": 2,
"task_state": "paused"
}
}
8.3 录音交接顺序
呼出应用 → SaaS HTTP:申请 recording_id 绑定的上传授权
呼出应用 → OSS:按签名授权上传封口文件
呼出应用 → SaaS HTTP:complete;SaaS 实际校验,返回稳定 oss_id
呼出应用 → 本地数据库:verified 事实 + recording.ready outbox 同事务
呼出应用 → RabbitMQ → SaaS:recording.ready,去重落库后 ACK
SaaS → 已授权用户:按 oss_id 提供短期鉴权播放
8.4 拨号前等待及超时
以下是 GET /internal/v1/outbound/commands/cmd-execute-demo 的 execute 等待快照示例;同一事实的 command.result 使用第 7.1 节外壳,外壳 aggregate_version=2,payload 不重复携带该版本字段。
{
"command_id": "cmd-execute-demo",
"command_type": "call.execute",
"tenant_id": "tenant-demo",
"tenant_key": "tenant-demo-key",
"task_id": "task-demo",
"execution_id": "exec-demo",
"call_id": null,
"status": "waiting",
"reason_code": null,
"wait_reason_code": "TENANT_CONCURRENCY_EXHAUSTED",
"accepted_at": "2026-09-11T08:00:00.000Z",
"waiting_since": "2026-09-11T08:00:00.000Z",
"admission_deadline": "2026-09-11T08:00:30.000Z",
"updated_at": "2026-09-11T08:00:01.000Z",
"aggregate_version": 2
}
示例假设服务端窗口为 30 秒、not_after 为 08:01:00Z;30 秒仅为演示,不是已冻结限值。08:00:30Z 仍无租户并发额度时,命令变为 rejected,reason_code=TENANT_CONCURRENCY_EXHAUSTED,wait_reason_code=null,版本递增并经 MQ 回传;不创建 call_id。若 not_after 更早,则以它作为 admission_deadline,到期报 COMMAND_EXPIRED。拒绝后原 ID 重投返回原拒绝,不重新排队。
8.5 暂停/停止与积压竞争
| 场景 | 契约预期 |
|---|---|
| execute 尚在租户队列,随后暂停/停止 | 控制不排在 execute 后;消费旧命令时检查持久屏障并拒绝。applied 表示不再允许新发起,不表示全部积压已处理完或拒绝事件已投递完 |
| execute 已 waiting,随后暂停/停止 | 未提交拨号意图的等待命令终结为 rejected,经 MQ 报 TASK_PAUSED/TASK_STOPPED;任务已恢复运行但命令仍为旧版本时用 STALE_REVISION。拒绝优先级为过期、停止、暂停、版本,再检查资源;不得被 resume 静默复活 |
| Cell 已持有在途许可,控制受理 | 先禁止新许可,等待旧许可完成或失效并确认无法再发起;状态不明维持 applying/reconciling,不提前发 applied |
| pause 已 applied,随后 resume | resume 只允许当前生效版本的新授权指令;旧版本仍拒绝,原 command_id/execution_id 重投仍命中原结果 |
| stop + drain / hangup | drain 不挂断活动电话;hangup 需额外权限并等通道结束确认,未确认不能称完全生效;停止任务不可 resume |
8.6 执行命令与事件样例
以下四个样例从交付设计移入此处统一维护,字段按第 6、7 节校验;测试时间、号码、配置和文件校验占位符须替换,不得原样发至生产。执行命令保留 task_revision,表示当前已生效版本,不是控制请求的目标版本。
call.execute:
{
"schema_version": "1.0",
"command_type": "call.execute",
"command_id": "cmd_demo_001",
"tenant_id": "tenant_test",
"tenant_key": "tenant_test",
"trace_id": "trace_demo_001",
"issued_at": "2026-09-08T01:00:00Z",
"not_after": "2026-09-08T01:05:00Z",
"payload": {
"execution_id": "exec_demo_001",
"task_id": "task_demo",
"task_item_id": "item_demo",
"task_revision": 7,
"callee": "${AUTHORIZED_TEST_NUMBER}",
"route_policy_id": "route_policy_test",
"caller_profile_id": "caller_profile_test",
"agent_version_id": "agent_v1",
"variables": {},
"ring_timeout_ms": 30000,
"max_call_duration_ms": 180000
}
}
call.status:
{
"schema_version": "1.0",
"event_id": "evt_demo_status_2",
"event_type": "call.status",
"tenant_id": "tenant_test",
"tenant_key": "tenant_test",
"trace_id": "trace_demo_001",
"occurred_at": "2026-09-08T01:00:06Z",
"aggregate_type": "call",
"aggregate_id": "call_demo",
"aggregate_version": 2,
"payload": {
"command_id": "cmd_demo_001",
"execution_id": "exec_demo_001",
"task_id": "task_demo",
"task_item_id": "item_demo",
"call_id": "call_demo",
"attempt_id": "attempt_demo_1",
"call_state": "answered",
"call_version": 2,
"cell_id": "cell-demo-a",
"egress_pool_id": "egress-demo-a",
"attempt_state": "answered",
"trunk_id": "sip-primary",
"answered_at": "2026-09-08T01:00:06Z"
}
}
transcript.updated:
{
"schema_version": "1.0",
"event_id": "evt_demo_text_1",
"event_type": "transcript.updated",
"tenant_id": "tenant_test",
"tenant_key": "tenant_test",
"trace_id": "trace_demo_001",
"occurred_at": "2026-09-08T01:00:10Z",
"aggregate_type": "transcript_segment",
"aggregate_id": "segment_demo_1",
"aggregate_version": 2,
"payload": {
"call_id": "call_demo",
"turn_id": "turn_1",
"segment_id": "segment_demo_1",
"role": "customer",
"revision": 2,
"text": "这是授权测试。",
"is_final": true,
"start_ms": 500,
"end_ms": 1800,
"playback_state": "not_applicable"
}
}
recording.ready:
{
"schema_version": "1.0",
"event_id": "evt_demo_recording_1",
"event_type": "recording.ready",
"tenant_id": "tenant_test",
"tenant_key": "tenant_test",
"trace_id": "trace_demo_001",
"occurred_at": "2026-09-08T01:03:10Z",
"aggregate_type": "recording",
"aggregate_id": "recording_demo_1",
"aggregate_version": 1,
"payload": {
"call_id": "call_demo",
"recording_id": "recording_demo_1",
"oss_id": "oss_demo_1",
"format": "wav",
"channels": 1,
"sample_rate_hz": 8000,
"duration_ms": 15000,
"size_bytes": 240044,
"checksum_sha256": "${SHA256_OF_UPLOADED_FILE}",
"created_at": "2026-09-08T01:03:01Z"
}
}
录音校验值和尺寸仅为演示,实际算法与独立校验能力须按第 4.6 节冻结;AI 文字的播放标记只表示可验证的播放器事实,不证明客户实际听到。
9. 错误码与责任
| HTTP / MQ | code 建议 | 调用方处理 |
|---|---|---|
| 400 / 拒绝 | INVALID_ARGUMENT |
修正字段,不原样无限重试 |
| 401 / MQ 连接鉴权失败 | UNAUTHENTICATED |
受控刷新/修复服务凭证,告警 |
| 403 / 拒绝 | FORBIDDEN、ROUTE_NOT_AUTHORIZED |
修复授权,不切其他租户或私自换线路 |
| 404 | RESOURCE_NOT_FOUND |
检查关联;含不可见资源,防枚举 |
| 409 | IDEMPOTENCY_CONFLICT、REVISION_CONFLICT、CONTROL_IN_PROGRESS |
查询原操作/当前版本后处理 |
| 409 | TASK_STOPPED、UPLOAD_CONTENT_MISMATCH |
停止非法操作或隔离资产,不自动覆盖 |
| 409,可重试 | CALL_NOT_REGISTERED、UPLOAD_NOT_READY |
有限等待并用原键重试,不绕过关联校验 |
| 410 | REPLAY_EXPIRED |
超出已保留事件范围,走人工恢复流程 |
| 413 | PAYLOAD_TOO_LARGE |
按合法分段/限制调整,不截断核心数据 |
| 429(仅 HTTP) | RATE_LIMITED |
接口限流,遵守 Retry-After 并用原幂等键有限重试;不是 AMQP 背压应答 |
| MQ 等待/准入拒绝 | TENANT_CONCURRENCY_EXHAUSTED、TENANT_CPS_EXHAUSTED |
当前租户跨 Cell 并发/CPS 不足;waiting 是暂时等待,rejected 才终结本次命令,不自动重拨 |
| MQ 等待/准入拒绝 | ROUTE_CAPACITY_EXHAUSTED |
授权线路无可用并发/CPS 或兼容健康出口;线路未授权仍报 ROUTE_NOT_AUTHORIZED,不混为容量不足 |
| MQ 等待/准入拒绝 | CAPACITY_EXHAUSTED |
系统 Cell/媒体或 AI 资源不足;已终结后由 SaaS 决定是否重新授权 |
| MQ 等待 / 准入拒绝 | SCHEDULER_WAIT / ADMISSION_TIMEOUT |
前者仅表示等待调度;纯调度等待超过准入期限用后者终结,不承诺固定开始时限 |
| 503 | DEPENDENCY_UNAVAILABLE |
有限退避、告警及停止接新任务保护 |
| MQ 业务拒绝 | COMMAND_EXPIRED、TASK_STOPPED、TASK_PAUSED、STALE_REVISION、CONFIG_NOT_READY |
SaaS 决定是否重新授权调度 |
| MQ 执行状态 | EXECUTION_UNCERTAIN |
进入对账,不盲目重新拨号 |
HTTP 错误码与 MQ reason_code 共用词汇但不是一一映射;MQ 没有 HTTP 状态码。完整枚举、SIP 原因映射和错误描述脱敏在冻结阶段补齐。
10. 安全、容量与可观测性
- 服务权限细分为读、控制、强制挂断、补传、上传和完成确认;租户/任务/通话/资产逐层校验,不凭 URL 中的 ID 直接放行。
- 上传完成确认与事件消费均校验同租户关联,防止将其他租户 oss_id 挂到本通话。播放授权由 SaaS 按最终用户权限执行。
- 被叫、转写和录音按敏感业务数据处理,日志脱敏;密钥、签名地址、ARI 密码、SSH 私钥不进入代码/文档/事件。DLQ 和审计也受访问与保留控制。
- 记录 command_id/execution_id/call_id/attempt_id/event_id 关联及控制、重放、挂断操作审计;真实身份从认证上下文取得,不仅依赖 reason 文本。
- RabbitMQ 不放在实时音频循环上;最终文字与重要状态持久化,临时中间稿可在形成事件前合并,但不能丢最终稿。积压/磁盘水位达到上限时停止新接单。
- 容量目标仍是至少 1000 路同时已接通的完整 AI 通话,不是 HTTP QPS。MQ/OSS 负载应按每秒文字事件、每通话状态事件和录音产生速率单独估算、压测。
- 监控 HTTP 延迟/429/5xx、命令准入时延、控制屏障耗时、outbox 最老年龄、MQ redelivery/DLQ、SaaS 消费落库延迟、OSS 校验/上传失败、暂存磁盘和资源租约耗尽。
- 按租户统计队列深度/字节、最老命令年龄、可调度等待时间、实际新增拨号份额、并发/CPS 占用、未 ACK/待发起窗口及背压次数;分别标注配额不足、无线路资源和纯调度等待。压测 broker 队列/副本数量和全局水位,不能只看全局平均延迟掩盖 B 饥饿。
- broker confirm 与 SaaS 应用成功分开观测;应用确认与清理建议见第 10.1 节,不推断 unknown 为成功或失败。
10.1 投递责任、应用证据与清理(已确认方案)
- 首期不增加 MQ receipt 或 HTTP 收讫接口。呼出侧自动负责 outbox→broker 的确认、不可路由及有限重试;SaaS 负责消费事务、inbox 去重、失败重试/DLQ 告警和应用对账。呼出侧不能自动识别具体哪些已确认发布事件仍未被应用,saas_applied 默认 unknown。
- SaaS 根据自身 inbox/业务状态与命令/通话查询发现缺口,受控触发第 4.4 节 MQ 补传;查询只是诊断,不替代业务事件。published 不变成 applied;replay completed 也不等于应用成功。unknown 本身不触发无限重复发布。
- 事件和录音清理按 G0 确认的保留/交接策略执行:事件有可靠 broker 确认、仍可覆盖约定恢复窗口;录音需 OSS verified、ready outbox 持久化并确认发布、对象保留期覆盖恢复。普通清理还须无已知隔离/恢复任务并达到批准期限,不能仅按 HTTP 200 或“估计 SaaS 已应用”删除。未确认/失败数据到水位先背压告警;超期删除需明确授权及审计,不以 unknown 永久无限保存。
- 端到端验收必须取得 SaaS inbox 和资产关联证据。若后续业务变更要求自动逐事件应用确认,再单独评审 MQ receipt 的幂等/重试/保留契约及工时;首期不暗加 HTTP 业务回调或收讫机制。
11. 契约落地与外部参数核验清单
方案已接受;下表不再阻塞 Mock 开发。D01按最终计划生成可验证规范,运行数值使用其第5节测试基线;涉及真实 SaaS/供应商/预算/保留与恢复的内容仍需外部落实,并在真实替换前验证。
| 核验项 | 已接受规则/待核验参数 | 责任方 |
|---|---|---|
| 接口归属及域名 | 两份 OpenAPI;SaaS 是否已有可复用资产服务、测试 Base URL | 用户、SaaS |
| 服务身份 | 现有服务令牌体系优先;issuer/audience/scope、租户映射、轮换和 mTLS | 双方、运维 |
| 任务绑定与版本 | 初始版本 1、CAS 控制、停止不可恢复、可信任务归属来源 | 用户、SaaS |
| 租户绑定与传输(已确认) | 一对一/不原地变更/保留期不复用,224字节预算及超限停发;Mock注册可先开发,真实租户资料和传输能力接入时核验 | SaaS、运维 |
| 授权撤销(已确认方案) | 第 4.1.1 节:复用全部受影响任务屏障、SaaS 发布侧串行授权检查、枚举依据、在途收敛、生效时限及整任务暂停代价 | 用户、SaaS |
| 无 call_id 补传(已确认) | 第 4.4 节第七条 HTTP 路径、原命令范围、权限与固定截止点;不增加拨号入口 | 用户、双方 |
| 应用证据与清理(已确认方案) | 第 10.1 节:首期只自动确认 broker,SaaS 对账补传;保留/交接依据,若必须自动应用确认则另选 MQ receipt | 用户、双方、运维 |
| 执行幂等 | 新增 execution_id,与 command_id 分离;业务重新外呼许可及去重保留 | 用户、SaaS |
| 停止语义 | drain/hangup 显式选择;挂断权限与多 Cell 生效判据 | 用户、SaaS |
| 线路/AI 配置 | route/caller/agent 引用及同步来源;LLM/TTS 新规范、失败兜底 | 用户、供应方 |
| MQ 环境 | 租户独立命令队列已确认;采用 agent-call 命名空间、direct 命令 exchange、agent-call.tenant.{tenant_key}.call.execute 路由及 tenant_key 原样透传;冻结精确绑定、生命周期、队列数上限、quorum/HA、ACL、重试/DLQ 与死信可靠性 |
运维、双方 |
| 租户公平与背压 | 公平调度架构已确认;冻结轮转批量/周期、活跃队列发现、权重、prefetch、接收窗口、并发/CPS、发布速率/积压上限、拒绝发布策略、多实例协调及等待指标 | 用户、双方、运维 |
| 保底与借用 | 默认不承诺固定开始时限;如需 SLA,确认保底资源、借用/归还边界及可满足的租户总承诺 | 用户、业务/运维 |
| OSS ID 和校验 | 资产 ID 权威来源、校验算法、禁止覆盖/对象版本、文件大小和格式 | 用户、SaaS/存储 |
| 保留与恢复 | 幂等/屏障/事件/inbox/临时录音保留,最长中断和人工恢复范围 | 双方、业务/运维 |
| 限制与 SLO | 使用最终计划第5节DEV/SCALE-MOCK初始基线;生产CPS、租户额度、保留与恢复等实际数值在真实替换前登记,准入截止不是开始 SLA | 双方、运维 |
| 文字/拒绝再联系 | 最终稿首期不修订;分段与播放证据、明确opt_out来源及生效时延按最终计划验证,真实判定规则外部核验 | SaaS、供应方 |
| 版本演进 | HTTP 主版本 /v1、MQ schema_version 1.x;兼容矩阵和旧版退役窗口 |
双方 |
兼容建议:响应/事件允许增加可选字段;新增必填、字段语义变化或不兼容枚举按破坏性变更处理。对未知命令版本 fail closed;未知事件主版本隔离告警,不直接 ACK 丢弃。事件 payload 按 schema_version 校验,不能一边 strict 拒绝扩展一边声称任意可选字段都兼容。
12. 实施与验收顺序
- 契约落地:方案已接受,按最终计划D01生成字段/状态/权限规范及Mock profile;第11节真实资料并行协调,不重复等待已接受方案拍板。
- 机器可读规范:编写双方 OpenAPI 3.1、共用 MQ JSON Schema,复用本文拓扑表和集中样例;验证引用、权限及正反例,不要求首期交付 AsyncAPI。
- 契约测试/Mock:先验证 HTTP 控制与查询、MQ 去重和 OSS 存储握手;Mock 通过不表示真实 SIP/AI 可用。
- 真实单通话联调:授权测试号码,打通 execute → 状态/终态 → OSS → ready → SaaS 页面;分别记录 ASR、LLM/TTS、SIP、MQ、OSS 证据。
- 恢复和容量:重复、乱序、跨租户、重启、多 Cell 屏障、上传失败、断网与补传;新增大租户洪峰下小租户公平、背压及多调度实例配额验收,完成真实完整 AI 的容量/延迟测试后再发布生产结论。
| 验收项 | 必须得到的证据 |
|---|---|
| 唯一拨号入口 | HTTP 规范无创建/重拨接口;补传不会调用 ARI originate |
| 双层幂等 | 重复 command_id、换 command_id 但相同 execution_id、ACK 丢失/进程重启均不重复拨号 |
| 不确定发起 | ARI 超时后先对账,未确认原通道结束不再发起 |
| 等待与准入截止 | call_id 尚无时按 command_id 查询 waiting;等待事件/查询版本一致;窗口与 not_after 取较早值,重投/重启不延长;租户/线路/系统瓶颈及纯调度超时原因可区分;拒绝后不再拨号,已发起通话不因首次准入截止被挂断 |
| 控制屏障 | 仅提交 expected 版本,服务端 CAS 递增;重复命令不再递增,冲突不推进版本,执行仍用已生效 task_revision;多 Cell 竞争/失联/旧版本不能越过屏障,202 不等于 applied |
| 租户隔离 | 跨租户查询、控制、录音授权/确认/关联和补传均失败;命令路由、队列绑定与正文 tenant_key 不一致时隔离,不能越权执行或回调错误租户 |
| 公平调度 | A 大量积压后 B/C 新入队,在 B/C 有额度且资源可用时,无需等 A 排空即可获调度;测量到达→入轮转→发起的分段延迟和实际份额,达到 G0 冻结指标;有界预取、已 ACK 积压及重启恢复不破坏公平 |
| 多实例配额 | 多调度器/多 Cell 同时争抢、FALLBACK、所有权切换/失联重启均不重复分配租户额度;待对账活动通话不因租约过期被误释放,不超并发/CPS |
| 背压与队列生命周期 | A 队列满/发布洪峰时明确拒绝且 SaaS 持久保留,confirm 丢失原 ID 重试不双拨、不丢旧消息;B 在约定 broker 负载范围仍可发布调度;租户创建/停用/恢复不丢积压、不误删队列 |
| 非抢占边界 | A 已占满可用资源时不为 B 强制挂断;按释放资源继续公平分配。若签订开始时限 SLA,另验证不可借用保底或明确借用约束,不用普通轮询冒充保证 |
| 乱序终态 | 迟到 ringing 不回退 ended;call.finished 不覆盖更新的 ready;旧文字不覆盖最终稿 |
| OSS 完整性 | 缺对象、错误大小/摘要、授权过期、完成后覆盖、complete 超时重试均不产生无效或重复 ready |
| MQ 可靠性 | 不可路由、confirm 丢失、SaaS 事务失败、重试队列/DLQ 故障均可追踪恢复,ACK 不早于持久化 |
| 补传边界 | 单通话/命令整体补传,固定截止点、原事件、分批限速;无 call_id 的结果可恢复;非法筛选字段及跨租户资源拒绝,保留期过期报错;本次 replay 结果不入范围,不拨号,completed 不代表 SaaS 应用 |
| 撤销闭环 | broker 积压、waiting、发起前、FALLBACK 的号码被禁用/授权撤销;覆盖关联多任务、发布竞争、CAS 冲突、失联及恢复;全部屏障 applied 后不再新发起,之前不得显示全局生效 |
| 租户绑定与传输 | 无 execute 的合法控制仍能原样回传 key;正文/绑定/身份错配拒绝;旧 key 不被别的租户复用;224/225 UTF-8 字节及特殊字符正反例;不通过多 key 绕过额度或幂等 |
| 应用证据 | broker confirm 后暂停 SaaS 消费,saas_applied 仍 unknown;无无限重投;SaaS 恢复后凭 inbox 核对并按需补传;清理不伪称已入库,未确认失败数据不可静默删除 |
| 完整交付 | SaaS inbox/业务落库、文字时间线和 oss_id 授权播放有证据;1000 路完整 AI 另有真实压测报告 |
本版完成定义: 已接受的文本契约可用于D01机器可读规范与Mock实现;不代表OpenAPI/Schema已生成、服务已实现或生产通过。最终开发/部署/监控/验收及数值范围以最终计划为准。