901 lines
48 KiB
Markdown
901 lines
48 KiB
Markdown
- 本文是新版项目内字段与状态语义的说明;v0.1 文档及证据仅留历史,不作为新版运行契约。四条拟定 GET 的响应字段、路径和外部兼容性尚待真实 SaaS 核对。SIP 新版机器校验为[配置读取 v0.2 Schema](../contracts/config-read-v0.2.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.2",
|
||
"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",
|
||
"egress_pool_id": "egress-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.2`、资源 `sip_config`;`dispatcher_id`:只能与发起请求的 D 相同。
|
||
- `revision/approved_at`:本 D 获批的完整 SIP 线路版本和批准时间。每次更新均须递增 revision;同版本内容不得变化。D 持久核验同版内容不漂移,不接纳倒退版本。
|
||
- `trunks[]`:唯一的线路列表;`trunk_id/provider_id/egress_pool_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,不能把旧通过记录当作本版通过。外部正式版本、部署与切换仍是独立事实和授权门禁。
|