49 KiB
- 本文是新版项目内字段与状态语义的说明;v0.1 文档及证据仅留历史,不作为新版运行契约。四条拟定 GET 的响应字段、路径和外部兼容性尚待真实 SaaS 核对。机器校验文件为配置读取 Schema、任务发现 Schema、命令/控制 Schema、最终结果 Schema,队列拓扑为MQ 拓扑文件。它们均为项目内版本,不修改现行上游 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 410cursor_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 消费四步;
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。
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 实现确认):
GET /internal/v1/dispatcher/sip HTTP/1.1
Host: <SaaS 服务地址>
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
完整 200 响应体:
{
"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 校验获批版本与线路完整性并自行核对生效,不能仅凭 HTTP200就认为已加载。
2.2 任务配置:200,ASR + LLM + TTS 模式
请求(task_id 示例为 task-mock):
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 响应体(仅一种智能体模式):
{
"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与 AIconversation.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 模式(独立情况)
{
"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,项目内规则)
{
"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,本地目标)
GET /internal/v1/dispatcher/tasks HTTP/1.1
Host: <SaaS 内网地址>
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
完整 200 响应体:
{
"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,本地目标)
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 响应体(包含任务退役):
{
"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 响应
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: <受控注入,不展示实际密钥>
{
"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(独立情况)
{
"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,项目内规则)
{
"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
{
"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严格校验并由本地 C 验证;真实 SaaS 兼容性和从现行 v2 切换未验证,不属于本地通过证据。
3.2 暂停任务:task.control / pause
{
"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
{
"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
{
"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:外呼命令处理回执
{
"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:任务控制处理回执
{
"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严格校验,不属于现行外部 MQ v2。
4.1 录音已上传 OSS:最终成功结果
{
"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 分钟内收口为最终异常结果
{
"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 正常未产生录音:无应答结果
{
"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/contractsSchema/正反例/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,不能把旧通过记录当作本版通过。外部正式版本、部署与切换仍是独立事实和授权门禁。