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

900 lines
48 KiB
Markdown
Raw Blame History

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