Files
go-sip/docs/thirds/v0.2.md
T

49 KiB
Raw Blame History

  • 本文是新版项目内字段与状态语义的说明;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 410 cursor_expired、不得截断。cursor 是不透明变更水位;只有完整响应和任务归属已原子持久化后才推进。游标过期返回 HTTP 410 和 cursor_expired,D 关闭新准入并重新取完整快照。轮询周期 30 秒不是端到端发现 SLA。

1. 触发顺序

顺序 请求与触发 SaaS 处理/返回
1 D 启动或配置到期,带 X-DISPATCHER-id/X-DISPATCHER-SECRET-KEY 请求 /internal/v1/dispatcher/sip(拟定 HTTP GET)。 SaaS 返回本 D 唯一获批版本;D 核验后才能接受新执行。
2 D 启动/重启 GET /internal/v1/dispatcher/tasks 取得本 D 的任务全量快照与变更游标,运行中每 30 秒(暂定) GET /internal/v1/dispatcher/tasks?after=<cursor>;SaaS 创建任务时先建好任务队列/绑定再发布。 D 发现新任务后仅消费 SaaS 已创建的队列;D 离线期间消息可留在队列,任务 ID 的更新/停止也能由变更游标发现
3 SaaS 将任务固定分配给一个 D,向该 D 投递 call.execute(下一版精简 payload,非现行 Schema)。 D 按消息中的任务 ID 请求 GET /internal/v1/dispatcher/task/:task_id,核验归属 D 的任务快照(拟定 HTTP);未接纳任务可受约 60 秒配置缓存延迟影响,已接纳执行固定原快照。
4 D 从任务取得 tenant_id,请求拟定 GET /internal/v1/dispatcher/tenant/:tenant_id/quota,与同租户其他任务共享额度后判定接纳。 额度缺失/过期不接新呼叫;普通接纳/拒绝有 command.result,已停止任务的未接纳积压仅消费并 ACK,无逐条回传。
按需 SaaS 投递 task.control 暂停、恢复或停止(下一版草案)。 控制本身有回执;暂停保留积压,恢复消费原队列;停止持久生效后静默消费并 ACK 未接纳积压,不拨号、不向 SaaS 回传这些消息的结果。已在途通话仍按策略处理并给最终结果。
5 通话终结且录音已上传 OSS,D 投递一条本地目标事件 call.result。 SaaS 只处理这条最终的通话详情,按 event_id 去重;录音以 bucket/object_key 关联,不接收文件、不提供上传会话或验证结果。上传失败/超时按 §0 和 §4.2 的 15 分钟规则收口。

1.1 现行 MQ 地址与 JSON 字段不是一回事

RabbitMQ 有发布入口 exchange → 发布时指定的 routing key → 预先绑定的 queue → D 消费四步;

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 校验获批版本与线路完整性并自行核对生效,不能仅凭 HTTP 200 就认为已加载。

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 与 AI conversation.max_duration_ms 的较小值,执行与 AI 控制器一致,不改原授权配置。精简命令不带这些业务值。
  • schedule.time_zone/starts_at/ends_at 定义时区和可空的起止时间;weekly_windows 按星期列出每日多个左闭右开 {start,end},空数组禁呼;excluded_dates[] 为按 Asia/Shanghai 日期优先排除的日子。任务时段还须与线路时段相交。
  • agent.agent_version_id:不可变智能体版本,必须与 agent.config.agent_version_id 对应;authorization_id/authorization_expires_at 为授权身份和截止时间,到期不得由过期缓存继续放行。不返回 content_sha256,同一版本内容变化必须拒绝并要求新版本。
  • agent.config.immutable/mode:不可变标记及 full_ai 模式。llm.provider_ref/model/temperature/max_tokens/timeout_ms 为供应商引用、模型、采样、输出上限和超时;prompt.text/allowed_variables/max_bytes 为提示词、允许的变量和字节上限;tts.provider_ref/model/voice/speed/format/timeout_ms 为语音供应商引用、模型、声音、速度、音频格式与超时;asr.provider_ref/model/language/input/interim/timeout_ms 为识别供应商、可选模型、语种、输入格式、是否给出中间转写与超时;音频 encoding/sample_rate_hz/channels/sample_width_bytes 定义编码、采样率、声道和样本宽度;conversation.opening/allow_interrupt/silence_timeout_ms/max_duration_ms/max_turns/sentence_max_chars/max_pending_audio_chunks 控制开场、打断、静默时限、总时限、轮次及缓存上限。
  • 未接纳呼叫在有效缓存窗口可能仍用旧批准版;已接纳呼叫固定原快照。新版呼叫命令只给任务 ID 与被叫号码,D 须从有效任务配置取得固定版本和任务级超时,不从命令猜值;现行严格 Schema 仍是旧结构。

2.3 任务配置:200,仅 ASR 模式(独立情况)

{
  "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/contracts Schema/正反例/MQ 拓扑是本轮 P1 Go/Mock 的项目内契约。完成 F01/F07 版本、来源/hash、严格校验和 Mock SaaS 端到端 C 后,可直接进入本地实现;不要求真实 SaaS、management 或供应商签收/连通。
  • contracts/upstream/v1/ 与现行外部 MQ v2 继续作为真实 SaaS 的既有基线。本地 v3 路由和消息不得混入 v2,也不得把 Mock 通过写成 SaaS、management 或生产验收。
  • 本地 MQ 采用 v3 durable topic/queue,任务和 D 结果队列均由 SaaS 创建维护;D 只消费任务/控制并发布结果。命令用 command_id 持久去重防止二次 originate;任务控制无 command_id/expected_task_revision、不按消息去重,乱序/过期失败关闭。
  • 对外只保留必要命令/控制回执和每个已接纳通话唯一的最终 call.result。不保留 query/replay、实时转写/拒联/通话进度/录音拆分事件;stopped 任务未接纳积压只静默 ACK,不产生逐条结果。正常无录音立即以 not_created 收口;预期录音失败最晚在 call.ended_at + 15m 以 unavailable 收口;每个 upload_id 最多一次 PUT,重投复用原 event_id,不重新上传。
  • 所有 MQ JSON 正文上限为 8,388,608 bytes。超限消息留在持久 outbox 并显式阻塞,不截断、不拆分、不丢弃。该上限仅为本地 v0.1 规则;真实 SaaS 与 broker 的兼容性需另行验证。
  • 四条 GET、严格 Schema、任务发现单次完整响应/游标过期恢复、队列退役握手和 call.result 正反例均按本文及对应 schema 验证。旧 v0.1 分页契约及本地证据仅为历史;采用本版须重测 F03/F09,不能把旧通过记录当作本版通过。外部正式版本、部署与切换仍是独立事实和授权门禁。