Files
go-sip/docs/thirds/saas-dispatcher.md
T

58 lines
15 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.
# 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 通过取代拨号授权。