58 lines
15 KiB
Markdown
58 lines
15 KiB
Markdown
# SaaS ↔ Dispatcher:项目内唯一现行通信约定
|
||
|
||
> 本文是 SaaS↔Dispatcher **唯一当前人类可读规范**,合并了已确认的业务规则、第三方交互及本地验收边界。字段、路由、正反例的唯一可执行依据仍为 [`contracts/local/`](../../contracts/local/);内部 RPC 以 [`proto/agent/agent.proto`](../../proto/agent/agent.proto) 为准,不在 Markdown 复制第二套 Schema。历史提案与批准记录保留在 [`archive/sources/`](../archive/sources/README.md),不构成并行版本。**P01–P08 仅完成项目内隔离 Mock 验证;真实 SaaS/management、供应商与生产均未签收,本文不授权真实外呼。**
|
||
|
||
## HTTP:五类只读配置
|
||
|
||
只由归属 D 使用 `X-DISPATCHER-ID` 和 `X-DISPATCHER-SECRET-KEY` 读取;HTTP header 名大小写无关。返回中的 `dispatcher_id` 须等于本 D,任务/配额的 `tenant_id` 是正整数;不接受旧字符串租户 ID。不在日志、样例或证据中打印真实凭据。业务正文只使用当前 Schema,不接受 `schema_version`、旧版 `tenant_key`、`agent_version_id`、授权期限或内容摘要等删除字段;也不根据字段做版本分支或从 HTTP 失败回退旧 MQ 配置。
|
||
|
||
| 资源 | 方法和路径 | 应用规则 | 正例 |
|
||
| --- | --- | --- | --- |
|
||
| SIP 全量 | `GET /internal/v1/dispatcher/sip` | `revision` 是实际加载版本核对依据;未知 `transport/auth_mode/registration_required/max_concurrent_calls` 为 `null`,不能作为可执行线路默认值;变更时关准入、排空并核验 Agent/Asterisk 实际加载 | [`sip`](../../contracts/local/examples/config-read-sip.json) |
|
||
| AI provider 全量 | `GET /internal/v1/dispatcher/ai-providers` | 启动时一次读取全局列表,本进程全部任务共用到下一次重启;运行中 start/resume 不重复拉取,服务商变更须重启后才生效。`provider_ref` 只标识供应商;将明文 `credential` 原值交给选定角色的已核验适配器,不引入 ref 查找或交换;缺失、禁用、角色不匹配、无效凭据拒绝执行 | [`providers`](../../contracts/local/examples/config-read-providers.json) |
|
||
| 任务 | `GET /internal/v1/dispatcher/task/{task_id}` | 归属、状态和 `task_revision` 校验后持久绑定同一 `agent`/provider 快照;不可变摘要由本项目计算。同一 revision 内容不同拒绝;ASR-only 只需 ASR,不强制 LLM/TTS;0 和 false 原样保留 | [`ASR-only`](../../contracts/local/examples/config-read-task-asr.json) · [`full AI`](../../contracts/local/examples/config-read-task-full.json) |
|
||
| 任务列表 | `GET /internal/v1/dispatcher/tasks`,后续 `?after=<cursor>` | 首次/重启完整读到 **带 cursor 且 tasks=[]** 的终止页;非空短页不能提前结束。非空页持久成功后才使用下一 cursor;控制队列积压处理前不开新任务准入。不使用旧 snapshot_id/watermark/mode=snapshot | [`page`](../../contracts/local/examples/task-discovery-page.json) · [`end`](../../contracts/local/examples/task-discovery-end.json) |
|
||
| 租户额度 | `GET /internal/v1/dispatcher/tenant/{tenant_id}/quota` | `quota_revision` 为业务修订号;未知占用不得算成已释放 | [`quota`](../../contracts/local/examples/config-read-quota.json) |
|
||
|
||
HTTP 非 200、正文不合法、缺字段、归属冲突、缓存失效或读取失败时拒绝新的相关准入并记录脱敏错误;已持久接纳的执行继续使用原快照。错误体示例 [`error`](../../contracts/local/examples/config-read-error.json),不能当成功配置解析。任务仅有运行/暂停/终止;终止是不可逆 stop,同一 `task_id` 不再启用。任务配置只能在暂停或停止后修改;没有 `edit` 事件,也不定时刷新任务列表。启动/重启时完整读取任务列表及独立的全量 SIP;运行中新建任务由 `task.control` 的 `start` 触发读取,人工暂停后修改配置由 `resume` 触发重读任务和租户配额,两者沿用启动时的 AI 服务商列表和已生效的 SIP 快照,不另行读取或向 Agent 核验全量 SIP。HTTP 旧 running 不得覆盖已持久的 pause/stop;resume 经最新任务配置核验后只能解除人工暂停。SIP 无常规定时拉取;`sip.config` 持久关准入并触发 SIP 全量读取,等待已接纳呼叫结束及 Agent 加载核验后,将新 SIP 绑定到本地任务快照(不重读任务列表),然后才可恢复准入。通知待处理时可重试,读取或校验失败不回退旧 SIP,也不开准入。
|
||
|
||
## MQ:固定 v1 传输,唯一事件清单
|
||
|
||
RabbitMQ 是 Topic,**SaaS 独占创建、绑定、退役 exchange/queue,D 只消费/发布,并仅在 stop 时对自己负责的任务队列执行 purge;不创建、删除或绑定队列,无 configure 权限**。机器拓扑见 [`mq-topology.json`](../../contracts/local/mq-topology.json)。外呼维持每 D、每任务独立的 `d.<dispatcher_id>.task.<task_id>.in` 路由及 `agent-call.d.<dispatcher_id>.task.<task_id>.v1` 队列;每 D 单独控制路由 `d.<dispatcher_id>.control.in` 及队列 `agent-call.d.<dispatcher_id>.control.v1`。SaaS 将所有 D 的输出 `d.<dispatcher_id>.out` **精确绑定至同一个结果队列**(本地 Mock 名 `agent-call.saas.events.v1`,不是强制 SaaS 实际队列名)。入站 exchange `agent-call.dispatchers.v1`;出站 exchange `agent-call.saas.v1`;死信 exchange `agent-call.dead-letter.v1`。不广播后靠正文过滤,不使用独立通配段,不把被动声明/发布 confirm 冒充指定队列已收到:须以实际路由、mandatory return 与 confirm 联合验证。
|
||
|
||
`event_id` 是入站/回执关联和内部防重复处理的消息身份;`dispatcher_id` 是 D 身份,`tenant_id` 是正整数。**不是所有事件共用一个统一必填信封**:`sip.config` 没有 tenant_id/issued_at;回执按其示例字段,最终结果使用 `issued_at` 而非 `occurred_at`。正文的 `schema_version` 已移除。只接受以下外发事件及必要入站事件;所有字段直接按 [`mq.schema.json`](../../contracts/local/mq.schema.json) 和正例校验:
|
||
|
||
| 事件 | 方向和处理规则 | 正例 |
|
||
| --- | --- | --- |
|
||
| `sip.config` | SaaS→D;`revision` 触发全量重新读取和实际加载核验,不用通知正文代替全量 | [`notification`](../../contracts/local/examples/mq-sip-change.json) |
|
||
| `task.control` | SaaS→D;start/pause/resume/stop,无控制去重/CAS 字段。start 读取新任务配置及租户配额并持久绑定,无 Agent 控制动作;resume 重读最新任务配置和额度后解除人工暂停;均沿用启动时 AI 服务商列表及已生效 SIP 快照,不重复拉取全局服务商或 SIP,也不额外核验 SIP;pause/stop 发给 Agent,省略 `active_call_policy` 默认 **hangup**,显式仅 drain/hangup。配置读取、归属或 AI 核验失败时不接纳,SIP 待处理时新呼叫仍关闭;stop 先停止该任务消费者(使在途未确认消息回队列),持久关闭任务准入并抑制本地待执行指令,再对该任务队列执行 purge 清除当时待投递消息;purge 失败不发送成功回执、保持准入关闭并重试原控制消息。SaaS 须停止继续投递已 stop 的任务;purge 不阻止之后新投递,这些消息不执行。成功操作后才回应用回执,回执不是已完成活跃通话排空/挂断、SaaS 已停止投递或未来消息不存在的证据;stop 同 ID 不可恢复 | [`start`](../../contracts/local/examples/mq-control-start.json) · [`control`](../../contracts/local/examples/mq-control.json) · [`ack`](../../contracts/local/examples/mq-control-ack.json) |
|
||
| `call.execute` | SaaS→D 仅 `{task_id,callee}`;一次指令保留独立消息/执行身份,路由/主叫/AI/时限从绑定任务读取。`dispatched` 表示已发出呼叫指令,**不表示接通**;白名单/单号码格式不合规则回 `rejected,reason_code:null,reason_message`、不拨号不发最终结果、不暂停整任务 | [`execute`](../../contracts/local/examples/mq-execute.json) · [`dispatched`](../../contracts/local/examples/mq-execute-ack.json) · [`rejected`](../../contracts/local/examples/mq-execute-rejected.json) |
|
||
| `call.execute.result` | D→SaaS;按 `task_id` + 原号码关联,每次呼叫仅一份最终结果;不新增外部 call_id/source_command_id;真正终结且录音成功上传、无录音或预期录音生成失败后才发送 | [`uploaded`](../../contracts/local/examples/mq-result-uploaded.json) · [`empty`](../../contracts/local/examples/mq-result-no-recording.json) |
|
||
|
||
旧 `command.result`、`call.result`、分散通话/转写/拒联事件、`recording.uploaded` 不再作为对外并行通知或兼容别名。D 在 inbox 持久后 ACK;状态/outbox 同事务;结果发布使用原消息身份可靠交付;confirm 不是 SaaS 应用收讫。消息年龄不让旧命令绕过准入;未来 issued_at 不提前接纳。重复投递/未知执行不触发再次拨号。
|
||
|
||
## 调度、AI 与真实结果(K01–K09、K11–K14)
|
||
|
||
- 当前 TTS 唯一获批适配器是 `bailian_tts`:任务快照须明确提供 `qwen3-tts-flash`、`Cherry`、`Chinese`、速度 `1` 和单声道 16 kHz PCM16 目标格式;使用已核验的 provider 凭据及生成端点。每段仅发起一次生成请求,下载返回的短期音频引用后转换为电话可用的 PCM16;不可用、超时、缺少转换工具或参数不支持时显式失败,不回退旧火山 TTS、不隐式重试或记录签名音频 URL。历史测试凭据不是任务授权,本地转换 Mock 不构成真实百炼/通话验收。
|
||
- 白名单仅含 `15003164745`、`15830461047` 原值;已选 SIP trunk、任务与线路每周时段、任务排除日期、任务/租户/线路额度、任务与 AI 较小通话时限均在接纳及实际发呼叫指令前检查。线路字段未知则 fail-closed;选线后固定、不自动重拨/换线。隔离 Mock 中规则暂不满足时保留待执行指令、暂停该任务的调度,规则允许后重验;与人工 pause/stop 分离,不能自动解除人为停止。本规则**不**放宽真实路径 Asia/Shanghai `09:00`–`20:00` 固定门禁或授权真实拨号。
|
||
- 接通事实为真时 `outcome=answered`(后续异常不抹掉接通);已发起但忙线、拒接、无人接听且确定结束为 `no_answer`;确认未接通并由 Agent/Asterisk 执行故障终结为 `failed`;未知状态保持未知占用,不能伪造结束、结果或自动重拨。真实 SIP 状态码原样数字写入 `reason_code`,无真实 SIP 码则 `null` 并以 `reason_message` 说明;禁止本地虚构数字错误码。无应答且没有录音时 `transcript=[]`、`opt_out=false`、`recording={}`。
|
||
- 只有**用户侧 ASR 最终识别文本**包含任一 `hangup_keywords` 字面字符串才挂断;中间识别、助手回复、开场白、TTS 均不能触发;重复结果不可反复终结。同一任务 revision 不同内容拒绝准入;provider 禁用/角色不符不可调用。Mock 参数验证不等于真实供应商验收。
|
||
|
||
## 录音与最终结果(K10、K15、K16)
|
||
|
||
1. 有效录音先直接 PUT 至 OSS,**只上传录音**;成功即持久入 D 的最终结果/outbox,正常路径不生成录音或结果业务文件(不得先写临时文件再删)。无应答无录音直接发真实结果,`recording={}`;录音**生成失败**仍报告真实通话 outcome、空录音与明确 `reason_message`,不走 OSS/重试,不伪造 SIP 码。
|
||
2. OSS 上传首次失败时,**录音和结果恢复信息两者均保存成功**后才建立固定重试起点/48 小时截止,保持原 bucket、object_key 和消息身份。本地结果文件仅用于恢复 MQ 回报,不上传 OSS。任一恢复文件不能完整保存:显式报错,保留已有内容,暂不发送最终结果、也不宣称有可恢复副本,等待人工修复。
|
||
3. 从首次两文件完整保存起重试:间隔为 1、2、4、8、16、32、60 分钟,其后每 60 分钟一次;重启/失败不重置起点,SDK 默认自动重试不得改变节奏。每次经 D↔A RPC 显式领取有效上传授权,不换对象/不向 SaaS 申请 TOKEN。48 小时仍不成功:停止自动重试、保留两文件和进度、标记待人工,**不发**伪造 uploaded/unavailable 或最终结果,也不自动重开窗口。
|
||
4. 上传成功后仅恢复原最终结果消息的 MQ 可靠交付,MQ 失败不重新 PUT、不新建资产、不重拨;48 小时是 OSS 重试窗口,**不是** MQ outbox 的清除期限。已确认结束的通话资源及时释放,不等待 OSS/MQ;未知执行不释放。进程在正常上传尚未成功、失败恢复两文件尚未完整保存前退出,内存录音可能丢失,不能声称零丢失,也不能为了隐藏限制悄悄预写盘。
|
||
|
||
## Agent RPC、会话与恢复边界
|
||
|
||
- D↔A 只采用预绑定身份和双向 TLS Unary gRPC;当前八个方法见 [`agent.proto`](../../proto/agent/agent.proto)。Agent 不直连 SaaS、没有业务数据库;D 的 SQLite 持久保存任务/额度/inbox/outbox,Agent 执行及上传恢复文件私有。旧 SQLite/Agent 状态发现后只读失败关闭,不自动删除或迁移;未知通话占用不得因超时、新 boot 或重启自行释放。
|
||
- Agent 会话提前续期;仅对**已验证 mTLS、同 Agent/Cell/boot/Dispatcher epoch、相邻且仍有效的代际拒绝**,`RequestRecordingUpload`、`ReportCallEnded`、`ReportCallResult` 可在六秒或调用方更早期限内,沿原事件/operation/幂等键重报原事实。其它拒绝、超时及结果不明停止重报,不自动重拨、重传不确定 OSS PUT 或放宽最新代际栅栏;详细错误边界见 [`proto/ERRORS.md`](../../proto/ERRORS.md)。
|
||
|
||
## 校验、来源与验收界限
|
||
|
||
- 当前字段及结构:[`config-read.schema.json`](../../contracts/local/config-read.schema.json)、[`task-discovery.schema.json`](../../contracts/local/task-discovery.schema.json)、[`mq.schema.json`](../../contracts/local/mq.schema.json);正反例在 `contracts/local/examples/`,来源路径和 SHA-256 在 [`manifest.json`](../../contracts/local/manifest.json)。历史原件按原字节存入 [`archive/sources/`](../archive/sources/README.md),旧上游 v1 存入 [`archive/upstream/`](../archive/upstream/README.md);它们不嵌入当前运行合同,不提供回退入口。`make check` 校验当前合同、历史来源、Proto、格式、race、vet、构建及隔离 MQ 实际收件。
|
||
- 已完成的 P01–P08、A01–A12、K01–K16 项目内证据见 [`P08 对照`](../evidence/saas-dispatcher-p08-acceptance.md);本地手写代码覆盖率为 72.0%,发布清单 `production_approval=false`。本地 Mock、假工具诊断和 SHA-256 不证明真实 SaaS/management/MQ 应用收讫、OSS/AI/SIP/Asterisk/ECS、现场抓包或生产已通过。外部真实接入、部署和拨号均需另行授权、签收和验证。
|
||
- JSON 样例是隔离 Mock 虚构数据;`example-only-not-a-real-secret` **不是凭据**。严禁将真实凭据、完整用户音频或完整对话放入源码/日志/证据。运行时须按接入方权限与实际加载事实再核验,不以机器 Schema 通过取代拨号授权。
|