Consolidate current SaaS contract and retire redundant documents

This commit is contained in:
2026-10-01 11:12:07 +08:00
parent 163e233419
commit 444937d6fe
95 changed files with 82 additions and 4774 deletions
+7 -1
View File
@@ -2,7 +2,13 @@
这里保留已被当前基线替代、但仍有追溯价值的历史记录;归档不等于删除。
## 本次归档
## 当前接口整理的归档
- [`sources/README.md`](sources/README.md):当前机器合同所需的历史提案/计划原件,以及使用者此前修改的两份旧对接文档,原字节和 SHA-256 均有记录。
- [`upstream/README.md`](upstream/README.md):已退役上游 v1 全量原件,保留原始清单、以归档路径离线校验,但不再嵌入当前运行代码。
- [`plan-saas-dispatcher-completed.md`](plan-saas-dispatcher-completed.md):已完成的 P01–P08 阶段计划,不是现行接口说明;现行规范只有 [`../thirds/saas-dispatcher.md`](../thirds/saas-dispatcher.md)。
## 先前归档
- [`plan-0918.md`](plan-0918.md):原 W00–W16 项目总计划,按仓库 HEAD **逐字恢复**后归档。归档原因:用户要求改用[本轮新计划](../plan-config-read-v0.1.md)单写 F00–F06 状态;旧计划的批准和本地证据仍可追溯,不能代签新接口通过。为保留原文,旧计划内相对链接仍按归档前位置书写,不作为现行导航。
- `evidence/20260920-acceptance-status.md`:MQ-only 修订前的验收状态摘要。
@@ -0,0 +1,31 @@
# SaaS↔Dispatcher:当前实施与验收入口
本项目的**唯一现行业务通信说明**为 [`thirds/saas-dispatcher.md`](thirds/saas-dispatcher.md),唯一项目内机器契约为 [`../contracts/local/`](../contracts/local/) 的 Schema、拓扑、正反例与 `manifest.json`。Agent 内部 RPC 见 [`../proto/agent/agent.proto`](../proto/agent/agent.proto)。本文只记录实施和验收进度,不另立一套字段或队列。
## 范围和来源
- 用户已确认 K01–K16,并明确批准实施 P01–P08;已取代原计划 §1.2“仅修订计划”的旧范围,不重复审批。固定 MQ `v1` 通信名、HTTP `/internal/v1/dispatcher/...` 路径和有业务含义的 revision 不属于自有实现代次,保持不变。
- [`plan-saas-dispatcher-v05-v0.1.md`](plan-saas-dispatcher-v05-v0.1.md) 是本轮合同的**不可变历史来源**,其原路径及 SHA-256 已写入当前合同 `manifest.json`;为保护来源事实,不改名、不改字节、不作为另一个当前运行入口。其他旧方案、旧 MQ-only 合同与旧验收数字均为历史。
- 当前仅验收单节点、单 Dispatcher、单 Agent、单 Cell、单租户的**隔离 Mock**。真实 SaaS/MQ/OSS/AI/Asterisk/SIP/ECS、容量及生产切换没有在本轮验证;样例、Hash 和本机测试均不是拨号或生产授权。
- 不迁移、清空或自动处置现存 SQLite、Agent 恢复文件、旧 spool 和 outbox;发现旧执行/上传状态时拒绝启动并保留原文件,按事实另行确认处置。
## 工作项
| 阶段 | 本地状态及核验入口 |
| --- | --- |
| P01 契约 | 已建立当前 HTTP/MQ Schema、拓扑、正反例和来源/hash;见 `contracts/local/manifest.json` 及 [`evidence/saas-dispatcher-implementation.md`](evidence/saas-dispatcher-implementation.md)。 |
| P02 单入口与 Proto | 根命令只注册现行 Mock Agent/Dispatcher;八个 Agent RPC 方法、生成物与 `proto/manifest.json` 已验证。 |
| P03 五类只读 HTTP | SIP、任务、发现、租户额度及供应商配置只读;租户/任务归属和新鲜度均经隔离测试。 |
| P04 MQ/SQLite | SaaS 预建队列、独立 Dispatcher 身份、持久 inbox/outbox、控制屏障及共享结果队列有隔离 MQ 测试。Confirm 不等于 SaaS 已处理。 |
| P05 AI/媒体 | 不可变获批 AI 快照,ASR-only、ASR+LLM+TTS、关键词、16 kHz 媒体及失败边界仅经本地 Mock 验证。 |
| P06 录音/OSS/结果 | 隔离双向 TLS 录音、一次 HTTPS PUT、失败与未知结果恢复、48 小时边界和唯一最终 MQ 结果经本机测试。 |
| P07 唯一当前入口 | **项目内已核查**:旧执行/配置/MQ/AI/Proto/Schema/脚本路径已分批清除或逐字节归档,当前文档/引用/hash 及合法历史例外逐项核对,实际数据保留;详见 [`evidence/saas-dispatcher-p07-audit.md`](evidence/saas-dispatcher-p07-audit.md)。真实外部兼容与主机诊断不在此项签收内。 |
| P08 验收交付 | **项目内隔离验收已核查**:P01–P08、A01–A12、K01–K16 的证据、故障/重启与会话切换、72.0% 手写业务覆盖率及诊断门禁假工具负例见 [`evidence/saas-dispatcher-p08-acceptance.md`](evidence/saas-dispatcher-p08-acceptance.md)。真实现场诊断、SaaS/management/供应商及生产均未验。 |
每批变更、测试命令、正反例和未验证项详见 [`evidence/saas-dispatcher-implementation.md`](evidence/saas-dispatcher-implementation.md);最终项目内对照见 [`evidence/saas-dispatcher-p08-acceptance.md`](evidence/saas-dispatcher-p08-acceptance.md)。发布包 `production_approval=false`;`make check`、`make release-check-local` 仅检验本机隔离制品与行为,不运行真实服务。
## 交付门禁
- 必须执行格式、当前合同及 Proto 来源/hash 检查,`go vet ./...`、`go test -race ./...`、构建及三项**实际通过**的隔离 RabbitMQ 集成测试;旧测试筛选式匹配零项不得算通过。
- 对照原计划 A01–A12 与 K01–K16 逐项记录本地证据和缺口,业务代码单元测试覆盖率须达到 65%;故障、重投、重启和约十分钟 Agent 会话过期边界不可遗漏。
- 实际非生产主机验证另须执行 [`../deploys/test/nonprod-call-evidence.sh`](../deploys/test/nonprod-call-evidence.sh) 规定的资源/状态核查和拨号前受限抓包;本地 Mock、打包、证书与哈希均不能替代真实 Agent/Asterisk 加载、SaaS 应用收讫或供应商验收。未经单独安排不进行云操作或真实呼叫。
+12
View File
@@ -0,0 +1,12 @@
# 不可变历史来源
这里只有追溯当前本地合同及保存使用者原有修改所需的原件,**不是**当前接口版本。唯一现行说明见 [`../../thirds/saas-dispatcher.md`](../../thirds/saas-dispatcher.md);机器合同见 `contracts/local/manifest.json`。移动时保持原文件字节不变,下面的 SHA-256 对应归档后文件。
| 原路径 | 归档文件 | SHA-256 | 用途 |
| --- | --- | --- | --- |
| `docs/thirds/v0.5-proposal.md` | [`v0.5-proposal.md`](v0.5-proposal.md) | `612fdaee50aff6aa7fbef16c2d469d99857646c6d2235617d0e67f6098cd7ada` | 当前合同的不可变提案来源;清单仍可离线校验。 |
| `docs/plan-saas-dispatcher-v05-v0.1.md` | [`plan-saas-dispatcher-v05-v0.1.md`](plan-saas-dispatcher-v05-v0.1.md) | `666f39e56ea9f4b55661efcac82edd6f9729848e2d60e5f24cdf5aa3ac97ee87` | P01–P08 历史批准及来源。 |
| `docs/thirds/v0.4.md` | [`v0.4.md`](v0.4.md) | `27c7070b0cfec90dded2db5e2083bb23e6dd1c7ddf2e1346f54159835b8c928d` | 使用者未提交的版本导航已随完整文件原样保存。 |
| `docs/thirds/第三方对接事件与请求消费顺序_v0.1.md` | [`第三方对接事件与请求消费顺序_v0.1.md`](第三方对接事件与请求消费顺序_v0.1.md) | `e774c39c91673302095c666a57609cbaae623e55fe73a3d280eff02d381affa4` | 使用者未提交的版本导航已随完整文件原样保存。 |
历史文档内部的相对链接保持原文,不能当作现行导航;未迁移其旧语义或据此宣称真实 SaaS、供应商或生产已通过。
@@ -0,0 +1,161 @@
# SaaS↔Dispatcher 通信调整与去版本标识计划
## 1. 已确认范围与完成定义
### 1.1 依据及优先级
1. 用户已确认 [`docs/thirds/v0.5-proposal.md`](thirds/v0.5-proposal.md) 是与 SaaS 负责人最终协商的数据通信结构。文件名中的 `proposal` 不再表示整个方向待批准;现有实现、历史计划和旧 Schema 与之冲突时,以该文档为准。本轮逐项补充确认以 §3.1–§3.2 为最新规则;原始通信文档本轮不修改,后续由 P01 同步这些已确认内容,不重新审批已解决问题。
2. 用户补充确认:**项目尚未发版,移除自有代码、文件名及目录名中用于区分实现代次的 Vx 标识,禁止版本迭代。** 不再新增另一代实现、另一套版本化契约、兼容入口或自动回退;保留唯一现行实现。
3. **通信名称与实现代次分开处理**:MQ exchange、queue、routing/binding key 中已约定的版本标识固定为 `v1`,HTTP `/internal/v1/dispatcher/...` 路径保持双方约定。它们不随文件、代码或 Schema 的变化递增,也不能在清理代码名称时被误删。
4. 第三方依赖及其 module/import 路径版本、Go/工具链版本、HTTP/AMQP/UUID 等标准中的版本标识,以及业务 `revision`、配置身份、消息身份和数据迁移顺序号,不属于实现代次清理。保留这些值不等于允许项目版本迭代。
5. 文档内部真正互相矛盾或不足以确定执行行为的部分,按 §3 定点确认;不能以“当前代码需要”为由要求 SaaS 恢复已取消字段,也不重新审批已确认方向。
### 1.2 本轮与后续实施的边界
- **本轮仅审查、修订本计划**;不修改代码、Schema、样例、配置、其他文档或已有数据,不执行任何重命名。当前计划文件名保留,后续去版本标识时统一改名。
- 后续实施覆盖 MQ、五类只读 HTTP、任务/控制/结果处理、Agent 执行快照,以及所有相关自有文件和标识;不是只替换 MQ 字符串。
- 单节点、单 Agent、单 Cell、单租户仍是本地验收范围。真实 SaaS/management、真实 MQ、供应商、ECS、生产切换和真实拨号分别授权、验收,不能用 Mock 结果代替。
- 后续完成条件:受影响的协议歧义已有明确答案,唯一现行契约与实现一致,§5 工作包完成,§6 验收通过。已读文档、已有 Mock、改完名称均不等于完成通信调整。
## 2. 审查结论与计划纠正
以下是对原计划的审查结果;“纠正”指本计划已修订,**不表示实现已修复**。
| 编号 | 级别 | 原计划问题 | 本计划的纠正 |
| --- | --- | --- | --- |
| R01 | 高 | §1/P01 仍要求“独立版本”“版本化机器契约”,并讨论将来版本升级,与未发版、禁止版本迭代冲突。 | 使用唯一无代次名称的当前契约、类型、校验器和清单;直接修正当前内容并更新来源/hash,不创建下一版本,不按版本号分支解析。 |
| R02 | 高 | 只列 MQ `.v3`→`.v1`,没有覆盖 Go 符号、文件/目录、Proto、生成物、Schema 引用、SQL、脚本及文档名称。 | 增加 §4 全范围清理和 §6 残留检查;通信固定 `v1` 是明确例外,不是保留 `V3Broker` 等代码名的理由。 |
| R03 | 高 | C02–C05/P03/P05 将旧实现要求的 `tenant_key`、`agent_version_id`、SIP revision、授权期限等缺失一概当成对方应补齐的字段;还将明文 `credential` 误设计为凭据引用。 | 按最终字段重整解析、持久绑定和 Agent 交付。用户已确认 `credential` 直接返回明文凭据,按供应商 SDK 要求使用,不需要 ref、引用解析、凭据交换服务或额外安全架构。旧字段不再是隐含入站必填项;既有归属与实际加载检查不变,不伪造缺失值或新增对外字段。 |
| R04 | 高 | 对 `call.execute` 回执、`call.execute.result`、`recording.uploaded` 的处理仍偏向保留旧路径,缺少明确的对外事件替换边界。 | 以最终文档确定唯一对外事件清单,调整信封及关联方式;旧 `command.result`、`call.result` 和分散事件不能作为别名或并行通知继续外发。录音上传与待交付事实仍须可靠保存。 |
| R05 | 中 | C01–C09 全部绑定“双方确认”,把 JSON 排版错误、Mock 引用值不一致、旧字段删除,与真实协议歧义混为同一总阻塞。 | 已确认规则列入 §3.1–§3.2,不再列为待批准;本轮待确认问题已按 §3.3 关闭,不将整项工作重新置于待批准状态;未来发现新的真实缺口才定点确认。 |
| R06 | 中 | P02 把启动被动检查描述成可以核验全部绑定。当前 AMQP 被动声明不能枚举或证明完整绑定关系。 | 区分 exchange/queue 存在性检查、实际消息路由测试和 mandatory/return/confirm;不能把 exchange 存在或 confirm 成功写成指定队列已收到。 |
| R07 | 高 | 未处理改名碰撞、嵌入/生成/hash 引用及持久数据,直接去后缀可能覆盖现有同名实现或使恢复入口失效。 | 大规模清理前建分支、做旧→新映射并按职责合并;不覆盖同名文件,不自动清库,不删除未交付执行/上传/outbox 记录;见 §4.3。 |
## 3. 已确认规则与确认状态
### 3.1 已确认,不再重复询问
| 编号 | 事项 | 明确规则及实现边界 |
| --- | --- | --- |
| K01 队列组织 | 外呼队列与原来一致;SaaS 共用一个结果队列。 | 沿用现有每 D 独立控制队列、按任务消费外呼的组织方式,外呼路由 `d.<D>.task.<task_id>.in`、队列 `agent-call.d.<D>.task.<task_id>.v1`;不改成按租户混合消费。取消“每 D 独立结果队列”的目标,SaaS 将结果收进同一个队列。D 身份、任务归属和精确路由仍保留;SaaS 管理队列/绑定,D 不建队。所有版本后缀固定 v1。 |
| K02 身份与结构字段 | tenant_id 使用数字;schema_version 删除。 | HTTP/MQ 相应字段统一校验数字类型,不再接受字符串租户 ID 作为新接口格式;移除 schema_version 字段及其版本选择分支,不替换成另一个固定版本值。消息身份仍保留,不能因删除结构版本字段而破坏内部幂等。 |
| K03 任务发现 | 逐页读取返回的 cursor;带 cursor 且任务数量为零的响应表示已取全。 | 不因非空页数量较少就判断结束;非空页持久成功后才使用返回 cursor 继续查询。首次/重启沿用全量初始化和控制积压优先的流程,不恢复旧代次持久游标;失败关新准入,控制和结果恢复照常处理。不强加旧 snapshot_id/watermark/mode=snapshot。 |
| K04 任务范围 | 任务不会删除,只会启用、停用、暂停;不考虑切换 Dispatcher。停用对应终止 stop,同一 task_id 不允许再次启用;需要恢复的任务使用 pause/resume。 | 不设计任务删除通知、移出 tombstone、归属迁移、跨 D 接管或终止后恢复。状态变化持久保存,HTTP 旧 running 或 resume 不能解除已终止状态;暂停才可经最新配置核验后恢复。 |
| K05 控制缺省与回执 | active_call_policy 未提供时默认 hangup;回执在调度到 Agent 后返回。 | 缺省挂断不再当成非法配置;显式不合法值仍拒绝。回执表示已调度,不等同于活动通话已全部挂断/排空;未成功调度不能回成功。实际通话终结与释放资源仍独立核实。 |
| K06 外呼回执与关联 | dispatched 表示已发出呼叫指令;最终结果 payload 中 task_id+手机号即可对应。 | 对外按 task_id/callee 关联,不强加 call_id/source_command_id 等新字段。内部继续保留每次执行的事件身份、消息身份和幂等记录,不把 task_id+callee 擅自改成永久禁止同号码再次执行的唯一键。回执不表示已接通。 |
| K07 SIP 变更 | 全量 SIP 接口补充 revision 字段。 | 读取和加载核验使用该 revision,与 sip.config 通知对齐;不再以“全量缺少 revision”阻塞计划。业务 revision 保留,不属于自有实现代次清理;仍须证明实际加载,不能只更新数据库中的编号。 |
| K08 凭据 | credential 直接返回明文,不需要 ref。 | D 将凭据交给 Agent 的对应 SDK,不引入凭据引用解析、交换服务或额外安全架构。provider_ref 仅用于选择 provider。真实凭据不写日志/样例的既有要求不变。 |
| K09 挂断关键词 | 仅用户侧 ASR 最终识别文本包含任意配置关键词时挂断。 | 按字面包含匹配,等最终识别文本,不使用中间结果触发;不引入语义模型或额外归一化。助手回复、提示词、开场白和 TTS 内容不得触发;重复识别/通知不得造成重复执行终结操作。 |
| K10 录音与最终结果 | OSS 只上传录音。正常上传成功即回报,不生成本地录音或外呼记录文件;只有 OSS 上传失败,才把录音及结果恢复信息保存本地。48 小时从首次失败后的两类恢复文件均保存完成时起算;重试间隔 1 分钟起、逐次翻倍、最长每小时一次。 | 有有效录音时,上传成功后才回最终结果;录音生成失败按 K15 的明确例外回报。48 小时耗尽仍上传失败则停止自动重试,保留文件,暂不回报,等待人工处理。本地结果信息只用于恢复 MQ 回报,不上传 OSS。此最新确认取代“所有通话先落盘”的旧表述;完整流程见 §3.2。 |
| K11 错误码表达 | 有真实 SIP 状态码则 reason_code 返回原数字;没有 SIP 状态码时 reason_code=null,reason_message 说明原因。 | 不新建本地数字错误码,不把本地拒绝、SDK HTTP 错误、录音或 OSS 错误伪装成 SIP 状态码;文字说明保留必要原因,但不带真实凭据或原始敏感报文。通话事件/状态见 K13–K14,录音生成失败见 K15,失败恢复文件无法保存见 K16;这些处理规则均已确认。 |
| K12 规则不满足时等待 | 不在允许时段等调度规则不满足时,暂停该任务的呼叫调度,直到规则允许后继续;不把暂时不能调度直接判成呼叫失败。 | 保留尚未执行的外呼,不因等待而丢弃、伪造结果或重复拨号;恢复前重新检查规则。规则等待与人工 pause/stop 分开,不能自动解除人工暂停或终止。单号码问题按 K13 处理;本规则不授权真实拨号或放宽现行真实门禁。 |
| K13 单号码错误 | 某号码不在白名单或格式错误,返回 call.execute 拒绝回执:status=rejected、reason_code=null、reason_message 说明原因;不发 call.execute.result,不暂停整个任务。 | 不拨号、不创建录音/上传任务,不发 dispatched;继续处理其他正常号码。保留处理与消息身份记录,不无记录丢弃,不通过清洗/替换号码绕过校验。此规则不用于 K12 的时段/额度等待。 |
| K14 通话结果状态 | 已发出指令后的最终 outcome 使用 answered、no_answer、failed:确实接通过为 answered;已发起但忙线/拒接/无人接听且确认结束为 no_answer;Agent/Asterisk 等执行故障、已确认未接通且执行结束为 failed。 | 后续异常不抹掉已接通事实;SIP 码/文字原因按 K11。仍不能确认是否接通或结束时继续核实,不伪造最终结果、不重拨、不释放未知占用。录音上传失败不改变通话 outcome,按 K10 等待上传/人工处理。 |
| K15 录音生成失败例外 | 已确认通话结束,但预期录音无法生成时,仍回报真实通话结果,recording={},reason_message 明确说明录音生成失败。 | 不把正常接通改成 failed,不伪造录音/OSS 路径或 SIP 错误码;没有可上传内容,不进入 OSS 上传或 48 小时重试,不为此新建本地业务文件。此例外只针对录音生成失败,不用于已有录音的 OSS 上传失败或重试超期。 |
| K16 上传失败且无法保存恢复文件 | 已有录音,但 OSS 上传失败后本地恢复文件也无法完整保存时,明确报错,暂不返回最终结果,等待人工修复。 | 保留已有内容,不伪称已有完整恢复副本;恢复文件完整保存后才能进入 K10 的重试流程并开始计时。未落盘内容不能保证跨进程退出恢复;不得擅自套用 K15 的空录音回报例外或无副本地宣称已开始可靠重试。 |
### 3.2 录音上传、失败落盘与回报顺序
1. **正常路径不落业务文件**:OSS 只上传录音内容;正常情况下直接上传,成功后回报单份最终通话结果,不生成本地录音文件或外呼记录信息文件。本地结果 JSON 不上传 OSS,也不新增该类资产/通知。不得先写临时文件再删除,并声称“没有落盘”。
2. **仅上传失败才落盘**:OSS 上传失败后,将录音和用于恢复 MQ 回报的外呼记录信息一起保存本地。录音路径与原 OSS bucket/对象路径对应,结果恢复信息关联同一执行和目标;保持原文件、原对象目标和原消息身份,不因重试新建资产。若恢复文件无法完整保存,按 K16 明确报错、保留已有内容、暂不返回最终结果,等待人工修复;不能声称已有完整可恢复副本,也不启动依赖该副本的 48 小时重试。
3. **确认通话终结**:释放已经确认结束的通话资源及占用,不等待 OSS 或最终结果发布。仍未确认终结的执行不能因上传窗口到期而释放;派发回执按 K05/K06 正常发送,不跟随最终结果等待录音。
4. **失败上传重试**:间隔依次为 **1、2、4、8、16、32、60 分钟,之后保持每 60 分钟一次**;**从首次 OSS 上传失败后,录音和结果恢复信息均保存完成的时刻起算 48 小时**。起点固定记录一次,后续失败、修改重试进度、等待或重启均不延长截止时间。恢复记录保存原目标、尝试进度、窗口时间和下次尝试信息;不能用 SDK 默认重试覆盖此节奏。
5. **上传成功后回报**:经 D 的既有可靠 MQ 交付返回最终结果;D 的 SQLite 状态/outbox 持久化不因“正常路径不落业务文件”而取消。MQ 发送失败只恢复原消息的交付,不再次上传已确认成功的录音、不重新拨号,也不把 MQ 故障当成 OSS 上传失败来生成录音缓存。48 小时不是删除待交付 MQ 结果的期限。
6. **满 48 小时仍未成功**:停止自动上传重试;本地录音、结果恢复信息及进度继续保留,标记待人工处理;**不返回最终通话结果、不伪造 uploaded、不自动发 unavailable、不删除文件、不自动开启下一个 48 小时窗口**。SaaS 在人工处理并确认上传成功前收不到该最终结果。人工处置入口另行明确,不自动创建管理后台或补传协议。
7. **取代旧行为**:删除“所有通话先写录音/结果文件”的前提;对已失败落盘的上传,取代旧 `call.ended_at + 15m` 自动收口/返回 unavailable 的逻辑,不留旧超时兜底。上传授权的有效期与重试窗口分开;Agent 仍经现有 D↔A RPC 显式取得有效上传授权,目标不变,不向 SaaS 申请 OSS TOKEN。
8. **分清失败来源**:SIP 响应不能替录音生成、失败落盘或 OSS 上传报告状态。上传失败不把正常接通的通话改成呼叫失败;没有真实 SIP 状态码时按 K11 使用 reason_code=null 并说明原因,不编造状态码。录音无法生成按 K15 返回真实通话结果、recording={},并说明生成失败;上传失败后的恢复文件也写不出时按 K16 报错待人工、暂不回报,不套用空录音例外。
9. **无录音路径与恢复边界**:单号码白名单/格式错误只按 K13 回拒绝回执,不进入最终通话结果或录音流程。已发起但无应答且未生成录音,按 recording 空对象返回最终结果,不等待不存在的文件上传,也不生成本地业务文件。正常路径不落盘意味着在上传成功或失败恢复文件保存完成前,进程异常退出时不能保证恢复内存中的录音;文档和验收不得承诺此阶段录音零丢失,也不能为掩盖限制偷偷恢复预写盘。上传重试不授权重拨、换线或真实测试。
### 3.3 本轮确认已完成
本轮列出的待确认问题已经逐项答复,当前清单没有剩余待用户确认项。K01–K16 为本轮明确规则,不再重复审批。
待完成的是 P01–P08 的契约同步、代码调整和验收,不是再次确认这些业务方向;本轮仍只修改计划,原始通信文档、Schema 和代码尚未同步。人工处理是明确报错并保留现有内容,不表示已实现新的人工恢复工具、后台或额外补传协议。
后续实施若发现新的真实字段冲突或现有组件无法满足要求,应带证据单独确认,只阻塞依赖该答案的部分;不能猜默认值、静默降级或重新启用废弃路径。
实施处理规则:
- 注释、Markdown 粗体键、重复冒号和缺逗号等先整理为合法 JSON,保持原业务含义,保留来源记录;这些排版问题不各自形成外部审批门槛。本轮不修改原文。
- `provider_ref: "mock"` 与 provider 清单示例不匹配时,在隔离样例中提供一致且按角色可解析的引用;不能把一个示例值硬编码为生产默认值。
- `hangup_keywords` 按 K09 检查用户最终识别文本是否包含关键词;对象、方式和时机均已确认,不再列为待批准。必须验证真实控制行为,不把“字段能解析”当成“功能已实现”。
- 最终文档没有列出的旧对外事件不默认保留。确有额外必需事件时,必须明确修正同一份当前约定,不能通过兼容层暗中外发。
- 每项答复直接修正唯一现行契约和测试;不产生下一版文件,不扩展为对已确认方向的再次审批。
### 3.4 已确定的字段变化,不再列为待批准事项
| 部分 | 必须调整的内容 |
| --- | --- |
| MQ 信封 | 按消息类型使用最终文档的 event_id/event_type、身份与时间字段;tenant_id 统一为数字,schema_version 删除。不能沿用旧信封统一要求的 tenant_key/trace_id/aggregate_*。例如 sip.config 示例没有 tenant_id/issued_at,不能因旧解码器需要而强迫添加。外呼最终 payload 按 task_id/callee 对应,不强加新的执行关联字段;内部追踪和幂等身份仍保留,但不擅自外发。 |
| 任务与额度 | 删除对外 `agent_version_id` 等已移除字段依赖;保留真实业务 `task_revision/quota_revision`,它们不是实现代次。`agent.immutable` 及任务内完整 AI 配置必须实际绑定;ASR-only 不能被旧 full_ai 必填字段拒绝。 |
| AI provider | 新增全量 `providers` 读取,按 `provider_ref/role/enabled/adapter/endpoint/credential` 解析;`credential` 是 SaaS 直接返回的明文凭据字符串,传给对应供应商 SDK,不改成对象或 `credential_ref`,不增加引用解析、凭据交换或密钥管理服务。`provider_ref` 仍仅用于选择 provider,不是凭据引用。禁用、缺失或角色不符的 provider 不可执行;真实凭据不写日志/样例的既有要求不变。 |
| SIP 读取与未知值 | 全量响应新增业务 revision,与变更通知及实际加载核验对应。transport/auth_mode/registration_required/max_concurrent_calls 的 null 按原文表示尚未确认;读取可以表达未知,执行不能据此默认 UDP、免鉴权、不注册或无限并发。 |
| 回执与最终事件 | 任务回执仍是 `task.control`,外呼开始回执仍是 `call.execute`,结果是 `call.execute.result`;保留各自事件 ID 语义,不沿用旧 `command.result/call.result` 信封。最终时间字段为 `issued_at`,不是旧 `occurred_at`。 |
| 结果字段 | 当前 `call_result_v01.go` 使用字符串 `reason_code`,最终文档为 `null` 或数值(无应答示例为 `480`),并出现 `reason_message`;须调整类型及校验。无应答按例发送空 `transcript`、`opt_out: false`、`recording: {}`,不能强制套旧 `not_created` 结构;上传成功按约定输出 OSS 字段及哈希,旧本地执行/录音 ID 不强加到对外消息。 |
## 4. 去版本标识的完整范围
### 4.1 清理对象与改名原则
初筛已发现 235 个含代次式路径的候选文件,分布在 `internal/`、`contracts/`、`docs/`、`proto/`、生成目录等;这是候选清单,不能据此机械删除。还须检查无版本文件名内部的 Go 符号、JSON 标识、嵌入路径、SQL 和脚本引用。
| 范围 | 当前已核实的例子 | 目标及同步检查 |
| --- | --- | --- |
| Go 源码、测试及符号 | `internal/mq/amqp_v3.go`、`V3Broker`、`internal/dispatcher/task_queue_v3.go`、`task_control_v3.go`、`task_runtime_v3.go`、`LocalV01Runtime`、`call_result_v01.go`、`internal/configread/discovery_v04.go`;store 的代次式方法和测试 | 使用职责名,如 `Broker`、`TaskRuntime`、`task_queue.go`、`task_control.go`、`call_result.go`、`discovery.go`。先处理现存同名文件/符号,再统一调用点、测试、错误/日志标签;不保留类型别名或转发包装。 |
| 当前契约与加载入口 | `contracts/upstream/v1/`、`docs/contracts/*-v0.x*.schema.json`、`local-contract-manifest-v0.x.json`、`contracts/contracts.go`、`internal/contract/contract.go` 的加载/校验路径 | 按领域维护唯一无代次文件,例如 `call-result.schema.json`、`config-read.schema.json`、`local-contract-manifest.json`。同步 `$id/$ref`、示例、Schema 选择器、编译缓存键、`go:embed` 和来源/hash;删除按旧版本加载的分支。来源事实中的原始外部版本不伪造、不改写。 |
| 自有 Proto 与生成物 | `proto/agent/v1/agent.proto`、`gen/go/agent/v1/` | 目标为 `proto/agent/agent.proto`、`gen/go/agent/` 等无代次路径;同步自有 package、`go_package`、服务全名、导入、生成配置和 `scripts/check-proto.sh`。必须重新生成,不手改生成文件;D/A 同步构建,不能保留旧服务别名。去名本身不随意改变字段号及业务语义。 |
| SQLite、恢复文件与检查脚本 | `internal/store/migrations/019_local_v04_discovery.sql`、相关 store 方法、状态/结果恢复入口,以及引用这些路径的脚本 | 清理迁移文件名称、确含实现代次的自有表/列/索引/记录类型及测试标识;保留有实际含义的迁移顺序号、业务 revision 和幂等身份。不得因为改名而丢失已执行事实或重做拨号/上传。 |
| 部署、构建及验收入口 | Makefile、脚本、CI、配置样例、夹具、测试选择条件、hash 清单中的旧路径/命令 | 同步更新可执行引用;普通构建、检查和隔离验收不读取旧代次路径,不因缺文件跳过检查。各模式必须显式,不用改名引入 Mock/real 回退。 |
| 文档、计划和归档文件名 | 本计划、`docs/thirds/v0.5-proposal.md`、相关计划、契约、验收证据和 `AGENTS.md` 中的引用 | 本计划后续改为 `docs/plan-saas-dispatcher.md`,当前通信标准改为 `docs/thirds/saas-dispatcher.md`;同步所有引用及权威入口。多份同主题材料只保留一个当前入口;有必要的历史证据按主题/事实日期归档,不再按版本并行维护,也不再参与运行校验。 |
### 4.2 必须保留的例外
- 双方已确认的 MQ 固定 `v1` 名称、HTTP 固定 `/internal/v1/...` 路径。它们是外部通信约定;自有 Go 类型/函数和文件不得因此命名为 `V1...`。
- 第三方依赖、标准协议和工具链的合法版本,例如 SDK module 路径中的 `/v2`、Go 1.27.1、UUID v4、IPv4/IPv6;真实供应商 endpoint 和 model ID 也必须保留原值。禁止为了通过名称扫描而改依赖路径、供应商请求参数、协议行为或工具链基线。自有 Mock 的 `mock-chat-v1/mock-tts-v1` 等代次式名称不借此豁免。
- 真实业务 revision、配置/任务身份、哈希、消息 ID、迁移顺序号,以及外部原始来源记录。它们不得成为“仍保留多代实现”的借口;自有历史实现代次不能冒充业务字段。
- 例外必须按具体用途核实;不能将整个目录、所有 `v1` 字符串或所有测试一律放行。
### 4.3 改名与数据保留边界
1. 大规模清理前新建分支,记录工作树和可追溯基线;不得暂存、提交、移动或清理与本任务无关的用户改动。
2. 建立路径/符号的旧→新映射,覆盖大小写、`V01/V04`、`v0_1`、`v0.1` 等形式。当前同时有 `amqp.go` 与 `amqp_v3.go`,不能简单去后缀覆盖;先保留必要行为、移除废弃路径,再统一职责名。
3. 每批改名同步修正代码调用、嵌入、生成、构建和测试引用;改路径后的 hash 清单必须重新核验,不能只改条目文字。
4. 不为旧实现保留兼容层、备用消费路径、旧 Schema 解析器或自动切换机制。有效的可靠性测试转到唯一现行实现,不能通过删测试消除失败。
5. **不自动清空或重建现有 SQLite/spool/outbox。** 新结构先在独立空目录中验收;若现有持久记录受名称变化影响,须在受控停机前明确其状态及处置。存在未完成执行、未知占用、待交付结果或上传恢复记录时,未有获授权且验证可行的处理方式不得切换;不以项目未发版为由假定数据可丢。
6. 当前源码、文件名、目录名和运行入口清理结束后,再做全仓残留检查;仅搜 MQ 后缀或仅看 `git diff` 不能作为完成证明。
## 5. 调整工作包与逐项验收
| 工作包 | 调整内容和依赖 | 验收标准 |
| --- | --- | --- |
| P01 唯一通信契约 | 将原始通信文档与 §3.1–§3.2 的最新确认同步到唯一字段/事件/路由清单;规范化 JSON,形成无代次 Schema、正反例和来源/hash。采用共享结果队列、数字 tenant_id、删除 schema_version、全量 SIP revision 及已确认回报顺序;不新增下一版本。 | 已确认事项不再列为待批准;旧必填字段不混入新接口;未知/非法字段拒绝;K01–K16 同步到当前唯一契约和测试;实现中新发现的问题按 §3.3 带证据单独确认,不能猜默认值。 |
| P02 无代次骨架与引用整理 | 建分支,完成 §4 路径/符号清单及碰撞处理;按职责统一核心接口、存储调用和 Proto/生成路径。依赖已明确的契约部分;大范围改动须分批回归。 | 无覆盖同名文件、旧类型别名、重复服务或失效导入;生成物可重复生成,内部 RPC 参数/语义检查通过;旧运行代次入口关闭而非自动回退。 |
| P03 五类 HTTP 与新数据模型 | 调整 SIP、任务、任务发现、租户额度和 ai-providers 五类读取;tenant_id 使用数字,去掉 schema_version/agent_version_id 的旧要求;SIP 全量读取增加 revision。按 agent/provider_ref/明文 credential 交付,保持 HTTP Header 大小写无关语义。 | 五类读取均有正反例;字符串租户 ID 不再作为新格式接纳;schema_version 不再是选择器或必填项;全量与通知 revision 能核对;0/false、数组保真;错误归属、HTTP/结构错误不能回退旧 MQ 配置或过期数据。 |
| P04 MQ、任务发现与控制接纳 | 保留原外呼队列并改用 SaaS 共享结果队列;SaaS 建队,D 只消费/发布。发现翻页到带 cursor 的空清单;不开发删除/跨 D 迁移。按 K12 对规则不满足的任务暂停调度、保留待执行呼叫,条件允许后继续;K13 的单号码错误只回 call.execute rejected,不发最终结果、不暂停任务。policy 缺省 hangup,业务回执在调度 Agent 后返回,dispatched 须已发出呼叫指令。 | 持久 inbox 后才做 RabbitMQ ACK;规则等待不能借 ACK 丢掉未执行呼叫,业务回执不冒充已拨出。控制积压未清不准入,非空短页不提前结束;恢复前核对当前规则,不用过期许可放行。自动恢复只针对规则等待,不能覆盖人工 pause/stop;已发出或未知的执行不再次拨号。停用同 ID 不可再启用,不擅加 CAS/控制去重。 |
| P05 Agent 配置与实际执行 | 明文 credential 经 D 交付 Agent 并用于 SDK,无 ref 查找/凭据交换;适配新快照,移除旧实现版本依赖。ASR-only 不强迫带 LLM/TTS;完整 AI 配置实传 SDK/控制器;opening 与 hangup_keywords 有实际行为,后者仅响应用户文本。SIP 变更关准入、排空并核验 revision 对应的实际加载。 | 两种 AI 场景运行;已接纳呼叫固定任务/provider 快照;同 task_revision 异内容拒绝;缺失/禁用/角色错误 provider 或无效凭据拒绝;只有用户最终识别文本包含关键词才触发挂断;中间文本、TTS/助手文字不触发;无真实加载证据不放行真实执行。 |
| P06 正常直传、失败落盘与结果 | OSS 只接收录音;正常上传成功直接回报,不写录音/结果业务文件;调整现有录音及上传入口对文件路径的依赖,复用现有库/SDK,不另写协议。仅 OSS 上传失败时保存录音和结果恢复信息,路径对应原目标,并按 §3.2 启动 48 小时重试。窗口耗尽保留待人工,不发最终结果。D 状态/outbox 同事务;移除旧事件别名、总是预写盘及旧 15 分钟 unavailable 路径。 | 正常路径与无录音路径无业务文件写入,不能靠先写后删达标;OSS 不接收结果 JSON。失败落盘后可重启恢复原目标/消息/窗口;MQ 失败不重新 PUT。满 48 小时停止自动重试,不删文件、不自动回失败结果;资源按实际通话结束释放,派发回执不等上传。最终 outcome 按 K14 区分,未知执行不伪造结果;录音生成失败按 K15 回真实结果、空录音和明确原因;恢复文件写入失败按 K16 报错、保留内容、待人工且不回最终结果;不宣称未落盘录音能跨崩溃恢复。 |
| P07 全仓清理与当前入口更新 | 随 P02–P06 每批处理 §4 改名/引用;删除废弃运行路径、旧 Schema 选择器和重复清单;同步当前契约、AGENTS、脚本、Makefile、嵌入与 hash,特别纠正旧“每 D 结果队列”和“15 分钟自动收口”等冲突规则。 | 当前入口唯一,无未批准代次残留;合法例外明确;普通构建/测试不读旧路径。记录已确认的新规则,不把本地实现或文档更新写成真实外部联调通过。 |
| P08 端到端及交付验证 | 按已确认规则更新隔离 SaaS/MQ Mock 与验收入口,执行 §6;使用独立目录、可控时间和既有测试资源,不修改现存数据。 | 正常直传回报、录音生成失败空录音回报、OSS 失败落盘后重试成功、48 小时耗尽待人工不回报、恢复文件写入失败待人工不回报五条路径均通过;不靠真实等待 48 小时或真实 OSS 消费验收。部署/诊断门禁照常;区分本地通过、外部未验证及阻塞。 |
依赖顺序:P01 明确契约 → P02 建立无代次入口 → P03/P04/P05/P06 按接口依赖逐项完成 → P07 全仓收口 → P08 总验收。P07 的引用修正随每批工作同步完成,不留到最后才修编译和测试。本轮业务确认已完成;未来确有新增问题时按 §3.3 定点确认,不因已解决的问题重复停工,也不开不确定的执行准入。
## 6. 验收清单与完成证据
以下均为后续实施要求;本轮文档修改不将任何一项标成实现通过。
| 编号 | 检查 | 必须留存的结果 |
| --- | --- | --- |
| A01 契约一致性 | 对照最终正文逐项检查五类 HTTP、MQ 命令、控制/执行回执、最终结果、provider/agent 新字段;Schema 正反例全部执行。 | 字段/消息→Schema→测试映射;JSON 可解析;没有仅因旧代码需要而保留的必填字段、未经约定的新字段或版本分支。 |
| A02 无代次名称 | 扫描全部自有源码、符号、注释/日志标签、文件/目录、SQL、脚本、样例和当前文档引用,覆盖 `V1/V2/V3/V01/V04/v0.x/v0_1` 等形式;人工核对例外与误报。 | 候选清单、旧→新映射、删除/保留理由及复扫结果;未批准的实现代次残留为零。不能靠把命名改成另一种代次格式或放行整个目录达标。 |
| A03 固定通信名称 | 检查实际 publish/consume 的 exchange、queue、routing/binding key 和 HTTP 路径;以不同内部配置/Schema 内容运行同一流程。 | 始终使用约定固定 `v1`;不从代码/文件/Schema 版本推导拓扑,不存在 `.v3` 等旧拓扑的自动回退。 |
| A04 引用及生成一致性 | 检查 import、Proto package/服务名、go_package、嵌入、Schema 引用、生成配置、脚本、文档链接和 manifest hash。 | 构建和重新生成成功;生成后无意外差异;无悬空引用、覆盖冲突或旧路径依赖。外部来源事实与本地整理产物的 hash 各自可核验。 |
| A05 配置读取与失效 | 覆盖数字/字符串 tenant_id、删除 schema_version、SIP 全量 revision 与通知对应、错误归属/字段、未知或禁用 provider、角色错配、无效凭据、网络失败、0/false 和合法 null。用虚构 credential 验证原值直达 SDK,无 ref 查找服务。 | 合法且满足准入条件的新结构可执行;字符串租户 ID、旧 schema_version 等不作为兼容格式放行;未知配置不默认可执行;无旧 MQ/过期缓存回退;日志/样例/证据不含真实密钥、TOKEN 或完整音频/对话。 |
| A06 队列所有权与路由 | 验证原外呼/控制队列组织及 SaaS 单个共享结果队列;D 无 configure 权限;故障覆盖缺资源、错绑定、不可路由、return、confirm 丢失和重连。 | D 不建队/绑定/删除,不要求每 D 独立结果队列;来源身份仍能区分;以实际接收证明路由,不把 exchange 存在或 confirm 当目的队列收到。D1/D2 仅作隔离夹具,不冒充多 D 业务运行。 |
| A07 控制与发现竞态 | 覆盖带 cursor 的空页完成、非空短页继续、读取/持久化失败、重启全量与控制积压;覆盖 policy 缺省挂断、显式 drain/hangup、业务回执调度时机、旧 running 页、重投/乱序及 SIP revision 变更。 | 无任务删除/跨 D 迁移流程;非空页不能提前完成;未调度 Agent 不回成功,回执不冒充活动通话已结束;HTTP 不覆盖已应用控制,不删 SaaS 队列。stop 后同 ID resume 或 HTTP running 均不能恢复,只有暂停任务可以恢复。 |
| A08 执行与 AI 行为 | 覆盖重复/历史执行消息、同号码不同呼叫、白名单/时段/额度/期限、两类 AI 场景、开场白及 SDK 实参;挂断词分别输入用户最终文本、仅中间结果命中、助手文本和 TTS 内容,核对字面包含规则与重复通知行为。 | 接纳与实际发出前门禁有效;任务级规则不满足时等待而非丢弃,恢复时重新校验,不自动解除人工 pause/stop;单号码白名单/格式错误只回 call.execute rejected、不发最终结果,不阻塞后续正常号码;dispatched 确已发出呼叫指令但不代表接通。已发出/未知执行不重拨,无自动换线或旧音频重播;仅用户最终文本可触发关键词。真实路径仍受 09:00(含)–20:00(不含)Asia/Shanghai 限制,Mock 不授权真实拨号。 |
| A09 结果与录音 | 分别验证正常上传成功不写业务文件、OSS 失败才保存录音/结果恢复信息、无录音直接回报;OSS 接收端断言只有录音内容。用可控时间核对 1、2、4、8、16、32、60 分钟节奏及封顶,48 小时从首次失败后的两类恢复文件保存完成起算。验证 K14 的 answered/no_answer/failed 及未知时不发最终结果;验证 K15 录音生成失败仍回真实结果、recording={} 并注明原因;验证 K16 恢复文件写入失败明确报错、保留内容、待人工且不发最终结果,不假装已开始可靠重试。 | 文件写入观测证明正常路径没有先写后删;有有效录音时上传成功后才发最终结果,无录音及 K15 生成失败例外不等待上传;派发回执不等上传;满 48 小时停止自动上传、保留待人工、不发 unavailable/失败结果,不恢复旧 15 分钟逻辑。MQ 失败不触发录音落盘/重复 PUT;无应答无录音不等待上传。 |
| A10 数据保护与恢复 | 对失败后已落盘的录音/结果信息、重试窗口/进度,以及既有 inbox、未知占用、上传成功事实和 outbox 注入故障;检查 D 的可靠消息持久化没有被正常路径不落业务文件的规则取消。 | 失败落盘后重启不重置 48 小时、不换对象、不重拨;授权过期经现有 D 接口取有效授权且目标不变。已持久到 D 的状态/outbox 同事务,MQ 失败恢复原消息。满 48 小时不删缓存,不给 MQ 交付加删除期限;未落盘录音的进程崩溃不可恢复边界明确,不伪称零丢失。资源按通话终结释放,现有数据未经明确处置不清库。 |
| A11 测试与构建 | 按 TDD 开发;执行格式化检查、现有 contract/proto 检查、`go vet ./...`、`go test -race ./...`、构建及隔离端到端。 | 核验 Go 1.27.1;单元测试覆盖率 ≥65%,报告注明范围,不靠排除修改代码达标;每批合并后回归,无跳过必需部署/诊断步骤。 |
| A12 交付边界 | 对照 P01–P08、§3 答复和上述检查逐项填写证据,不沿用旧版本通过记录代签。 | 当前文档/代码入口唯一;本地验收、真实 SaaS/MQ、真实 Agent/Asterisk 加载及供应商/生产验证分开列明。未获授权不执行真实切换、消费或拨号。 |
本轮文档交付核验:只修改本文件;检查文字规则、表格、现有引用及工作树差异。不运行或宣称完成上述代码测试、部署和通信验收。
+37
View File
@@ -0,0 +1,37 @@
# SaaS ↔ Dispatcher:任务清单、控制与临时状态 v0.4(项目内目标草案)
**项目内 v0.4 Schema、隔离 Mock、运行代码及本地验收已完成(见 [`dispatcher-v04-local-acceptance.md`](../evidence/dispatcher-v04-local-acceptance.md));真实 SaaS 尚未签收或联调。** 已发布外部合同仍以 `contracts/upstream/v1/` 为基线;v0.3 历史发现见 [`v0.3`](v0.3.md),其余历史规则见[第三方对接顺序 v0.1](第三方对接事件与请求消费顺序_v0.1.md)。本文件不授权真实呼叫或部署。实施步骤与待冻字段见 [`plan-dispatcher-state-v0.1.md`](../plan-dispatcher-state-v0.1.md)。
**后续需求(仅文档评估,尚无 Schema/代码)**:AI 服务商全量接口、任务挂机关键词及 SIP 控制队列变更通知见 [下一版提案](v0.5-proposal.md)。其中移除 `agent_version_id` 与现行严格任务 Schema 冲突,不属于 v0.4 已通过的本地验收。
## 1. 不变的两类 SaaS → D 队列
- 每个 Dispatcher 一条 SaaS 预建、独占的**控制队列**,接收 `task.control` 的 pause/resume/stop;每个归属任务另有一条 SaaS 预建**任务队列**,接收 `call.execute`。D 只消费,不能创建、绑定或删除。D→SaaS 的控制/命令回执和 `call.result` 仍走既有 per-D 结果路由,不能把结果队列误认为入站任务队列。
- 控制须先于任务新接纳生效,SaaS 先持久任务状态再按每任务顺序发布控制;控制回执是应用事实,RabbitMQ publisher confirm 不是 SaaS 已处理。不可将控制积压按 task 只保留最后一条:中间挂断、stop 排空及逐条回执仍须执行。
- SaaS 先停止向被 stop 的任务发布,D 持久屏障、处理在途执行并静默 ACK 所有未接纳积压;仅在原任务队列排空后回 stopped/applied,SaaS 确认回执后退役任务队列。无回执不得提前删队列。`resume` 不得重新启用本地已 stopped 的同一 task ID。
## 2. 重启全量、运行中增量(项目内已替换 v0.3 §2.5)
- **每次 D 进程启动/重启**,在关闭新执行的状态下读取 SaaS 对此 D 的完整归属清单;如需多页,各页必须属同一个有界、一致的快照。清单包含仍需排空的 stopped 任务,不能仅列 running;明确撤销后的任务不得被当成仍归属。全量页不完整、身份冲突、归属缺失或列表不可用时保持关闭,不能拿旧 SQLite 游标跳过全量。所有页核验完成后才应用任务归属,继续处理重启期间积压的控制;控制队列在发现失败时也不得静默停摆。
- 项目内拟定同一路径 `GET /internal/v1/dispatcher/tasks?mode=snapshot` 返回 `schema_version=task-discovery.v0.4-proposal`、`mode=snapshot`、`dispatcher_id`、UUID v4 `snapshot_id`、十进制事件 `watermark`、`tasks[]`、`next_page_token`(最后一页 `null`)。后续用 `?snapshot_id=<snapshot_id>&page_token=<token>` 取同一快照下一页;每页 ≤256、全 D 当前归属/待退役任务合计 ≤256(不能重复/跳项),各页 `snapshot_id/watermark` 不得变化;空页有非空 token 或有新 token 却零任务都按不完整失败。快照不可继续时返回 HTTP 409 + `snapshot_unavailable`,丢弃整个未提交快照并从头重取,**不**回退旧游标;page token 不受客户端解析。起始快照的水位覆盖快照生成前所有任务归属事件,不用任何旧 v0.3 SQLite 游标。
- 在线 `GET /internal/v1/dispatcher/tasks?after=<watermark-or-current_cursor>` 返回 `schema_version=task-discovery.v0.4-proposal`、`mode=changes`、`dispatcher_id`、`tasks[]` 和 `next_cursor`。非空页严格前进,空页等于本次请求游标;SaaS 保证每 D 事件连续、分页完整、有序、不得提前丢失未消费增量,无法满足即显式报错并关闭新执行、重新全量。HTTP 400 不合法请求、403 D 无归属、503 服务不可用;错误响应严格见 Schema,**不复用** v0.2 `changes[]`/410。具体身份验证仍按现有只读 HTTP 配置合同。
- **仅在本次进程运行期间**按发现水位定时查询增量,用于新增任务归属、撤销/退役和必要身份校验。水位不是最大 task_id;异常页/缺页不得推进游标或视为空变更。重启重新全量,不要求从上一次进程的永久事件游标续读;旧 v0.3 的 `after=0` 事件回放和 SQLite 持久游标不得被当作新全量响应。
- 任务列表的任务状态可以作为启动快照的初始状态及增量身份/一致性校验,**运行中 pause/resume/stop 由独立 MQ 控制队列生效**;增量页 `running` 不能自动解除暂停或不可逆停止。若列表状态与已应用的 MQ 控制矛盾,关闭该任务新接纳并报错、等待受控恢复,不按 HTTP 到达顺序偷偷切换状态。`GET /internal/v1/dispatcher/task/:task_id` 的授权配置和 resume 的新鲜状态核验仍保留,不等同于恢复“发现页控制状态”机制。
- 项目内发现字段已在 [`task-discovery-v0.4-proposal.schema.json`](../contracts/task-discovery-v0.4-proposal.schema.json) 独立严格声明,任务条目与游标等通用定义在 v0.4 Schema 内完整声明,不再依赖历史 v0.3 Schema;控制入站严格格式见 [`task-control-v0.4-proposal.schema.json`](../contracts/task-control-v0.4-proposal.schema.json)。机器 Schema 只约束消息结构;多页相同水位、完整性和快照与 MQ 积压的交接顺序须另由 Mock/代码验证。真实 SaaS 的这些字段、应用收讫、增量保留期限**仍未签收**,不能以项目内 Schema/Mock 自证兼容;不直接引用 v0.2 的 `changes`/410 或原地复用 v0.3 的严格 Schema。
## 3. 控制、通话与积压命令边界
- 新控制入站使用 `schema_version=task-control.v0.4-proposal`、原始信封 `dispatcher_id/tenant_id/tenant_key/trace_id/issued_at/command_type` 与严格 `payload={task_id,action,reason}`;**不再携带 `active_call_policy`**,逐条回执格式沿用现行 `command.result` v0.1,不能把旧 v0.1 控制请求误认为新请求。**有效 pause**:立即持久关闭该任务新接纳,停止消费任务队列并退回已交付但未接纳消息;向该任务所有已接纳且仍在途的执行发挂断。**有效 stop**:不可逆关闭新接纳,挂断全部在途执行,同时按 §1 排空未接纳积压;已接纳呼叫按原身份形成最终结果。两者不再提供 `drain` 行为;`resume` 仅在新鲜任务状态为 running 且授权有效、本地未 stopped 时恢复原任务队列,不挂断。
- 在途挂断的“已发命令”“Agent 已应用”和“实际通话终结”是三个不同事实;不因 RPC 成功伪称已终结。失败或终态未知时维持关闭、保留未知占用与可追查错误,何时回控制的 applied/failed、终结最长等待及 retry 边界须由新控制合同与 Agent 能力验证后冻结。不得因自动重投二次 originate。
- 两类 SaaS→D 入站命令 `task.control`、`call.execute` 均**不带 `not_after`,不因积压时间拒绝**。历史 pause/resume/stop 逐条按控制队列顺序处理,不合并、延迟或悄悄丢弃;stop 依 §1 排空未接纳积压并回逐条结果。新版外呼请求严格见 [`call-execute-v0.4-proposal.schema.json`](../contracts/call-execute-v0.4-proposal.schema.json),旧 v0.1 入站请求不能当作新版请求;已有出站 `command.result` 仍按其原合同校验。
- 未来 `issued_at` 的 `call.execute` **不得提前接纳**,这是时间先后校验而不是命令到期。历史外呼仍须在持久接纳及实际拨号前核对当前任务运行状态、原值号码白名单、任务及选中线路允许时段、SaaS 授权与额度、租户及供应商份额、任务与 AI 中较小通话时限;任何条件缺失、过期或不确定均拒绝新执行,不等下一窗口、不换线、不自动重拨。删除 MQ 消息期限不放宽这些独立授权期限。
## 4. Dispatcher SQLite 与日志
- SQLite 是单 D **临时可靠事务账本**,不要求多节点高可用,也不是 SaaS 的永久业务库。执行中仍需短期保存命令幂等、未知占用、执行固定快照、上传/最终结果以及未交付 outbox;文件日志只能排查,不能取代这些用于恢复与防重复拨号的事实。
- 本地只提供**显式且有条件**的已终止任务配置副本删除:仍有未知执行/额度、待上传或未发布 outbox 时拒绝;保留任务归属、执行快照、幂等命令、终结事实、上传、占用及 outbox。无自动扫描或迁移删除现有 SQLite 数据。SaaS 应用层回执/可查询恢复事实与最长重投期限尚未冻结;MQ publisher confirm **不等于** SaaS 收讫,不能据此删除依赖应用收讫的历史和防重证据。阻断原因、实际删除数量及 SQLite 错误可追查。
- 文件系统日志记录原值任务 ID、执行 ID、号码、状态、时间、错误及清理阶段;**不写入密钥、密码、私钥、完整用户音频或完整对话**。用户另提“全部内容无需脱敏”,与项目现行日志禁令冲突,不作为本版本合同已批准项。
## 5. 版本与验收边界
项目内 F07 的发现、控制与外呼严格 Schema、正反例、来源与 SHA-256 已校验;隔离 Mock C 与 TDD 回归已覆盖重启多页、积压控制/外呼、挂断失败、stop 排空、未来 `issued_at`、独立拨号门禁、条件清理及复投。已发布外部合同、真实单任务配置字段、SaaS 可恢复/应用回执与重投期限仍待 F01/F07 外部核对;`v0.3` 和 v0.1 历史证据不自动升级成 v0.4。真实 SaaS、生产、云和拨号仍须单独授权与验证。
+446
View File
@@ -0,0 +1,446 @@
SaaS Dispatcher 对接文档 v0.5
相关占位说明:
`/internal/v1/dispatcher/xxxxx` 路由按系统当前架构路由进行定义即可
`X-DISPATCHER-ID`/`X-DISPATCHER-SECRET-KEY` HEADER 头KEY 获取忽略大小写
Dispatcher 角色下称 D
触发顺序
1.1 MQ 发布消费规则
RabbitMQ 有发布入口 exchange → 发布时指定的 routing key → 预先绑定的 queue → D 消费四步;
```Plain Text
SaaS→D exchange: agent-call.dispatchers.v1
routing key: d.<dispatcher_id>.t.<tenant_id>.in
binding key: d.<dispatcher_id>.t.<tenant_id>.in
queue: agent-call.d.<dispatcher_id>.t.<tenant_id>.v1
consumer: 对应 Dispatcher
D→SaaS exchange: agent-call.saas.v1
routing key: d.<dispatcher_id>.t.<tenant_id>.out
queue: agent-call.saas.events.v1
consumer: SaaS
```
1.2 本轮任务队列与事件路由
硬边界:所有 exchange/queue/binding 均由 SaaS 创建、维护和退役;D 只消费 SaaS 创建的任务/控制队列,并向 SaaS 创建的结果 exchange 发布,不声明、绑定或删除队列。
```Plain Text
SaaS 创建并绑定:
exchange: agent-call.dispatchers.v1 (topic, durable)
task routing: d.<dispatcher_id>.task.<task_id>.in
task queue: agent-call.d.<dispatcher_id>.task.<task_id>.v1
control route: d.<dispatcher_id>.control.in
control queue: agent-call.d.<dispatcher_id>.control.v1
dead-letter: agent-call.dead-letter.v1
D -> SaaS exchange: agent-call.saas.v1 (topic, durable)
result route: d.<dispatcher_id>.out
SaaS result queue: agent-call.saas.d.<dispatcher_id>.v1
```
HTTP请求
SIP列表配置
```HTTP
GET /internal/v1/dispatcher/sip HTTP/1.1
Host: <SaaS 服务地址>
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
X-DISPATCHER-SECRET-KEY: <密钥>
```
响应:
```JSON
{
"resource": "sip_config", // 资源类型:SIP 配置
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", // 这份配置所属的 Dispatcher
"trunks": [ // 该 Dispatcher 获批的线路列表
{
"trunk_id": "trunk-mock", // 线路的唯一标识
"provider_id": "provider-mock", // SIP 服务商标识
"codec": "PCMA", // 线路使用的语音编码
"dial_prefix": "", // 该线路拨号时添加的被叫前缀;空串表示不添加
"enabled": true, // 是否启用这条线路
"server_host": "sip.example.invalid", // SIP 服务端地址;此处是 Mock 地址
"server_port": 5060, // SIP 服务端端口
"transport": null, // 传输方式:udp、tcp、tls;null 表示尚未确认
"auth_mode": null, // 鉴权方式:ip、digest、none;null 表示尚未确认
"registration_required": null, // 是否需要 SIP 注册;null 表示尚未确认
"max_concurrent_calls": null, // 分配给该 Dispatcher 的线路并发上限;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": [] // 周日不允许外呼
} }
} ]
}
```
AI 服务商全量读取
`GET /internal/v1/dispatcher/ai-providers`
```JSON
{
"resource": "ai_providers",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"providers": [
{
"provider_ref": "asr-provider-a",
"role": "asr",
"enabled": true,
"adapter": "volcengine_asr",
"endpoint": "https://asr.example.invalid",
"credential": "managed-asr-a"
},
{
"provider_ref": "llm-provider-a",
"role": "llm",
"enabled": true,
"adapter": "openai_compatible",
"endpoint": "https://llm.example.invalid/v1",
"credential": "managed-llm-a"
},
{
"provider_ref": "tts-provider-a",
"role": "tts",
"enabled": true,
"adapter": "volcengine_tts",
"endpoint": "https://tts.example.invalid",
"credential": "managed-tts-a"
}
]
}
```
任务配置
```HTTP
GET /internal/v1/dispatcher/task/{task_id} HTTP/1.1
Host: <SaaS 服务地址>
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
X-DISPATCHER-SECRET-KEY: <密钥>
```
响应体:
仅 ASR 模式(Agent配置仅返回 ASR即可)
ASR + LLM + TTS 模式
```JSON
{
**"resource"**: **"task_config"**,
**"dispatcher_id"**: **"c046b893-8628-4589-ae50-619d049248a6"**,
**"tenant_id"**: **"tenant-id-mock"**,
**"task_id"**: **"task-mock"**,
**"task_revision"**: **2**,
**"status"**: **"running"**,
**"name"**: **"Mock task"**,
**"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"**: {
**"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**,
**"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"**: **"开场白"**,
**"hangup_keywords"**: [
**"不用了"**,
**"请挂机"**
],
**"allow_interrupt"**: **true**,
**"silence_timeout_ms"**: **3000**,
**"max_duration_ms"**: **120000**,
**"max_turns"**: **20**,
**"sentence_max_chars"**: **80**,
**"max_pending_audio_chunks"**: **32**
}
}
}
```
任务列表
```HTTP
GET /internal/v1/dispatcher/tasks HTTP/1.1
Host: <SaaS 内网地址>
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
X-DISPATCHER-SECRET-KEY: <密钥>
```
增量请求
```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: <密钥>
```
响应:
```JSON
{
"schema_version": "task-discovery.v0.2-proposal",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"cursor": "1042",
"tasks": [
{
"task_id": "task-a",
"tenant_id": 1001,
"status": "running",
"task_revision": 1
},
{
"task_id": "task-old",
"tenant_id": 1001,
"status": "stopped",
"task_revision": 3
}
]
}
```
按租户 ID 获取配额信息
```HTTP
GET /internal/v1/dispatcher/tenant/{tenant-id}/quota HTTP/1.1
Host: <SaaS 内网地址>
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
X-DISPATCHER-SECRET-KEY: <密钥>
```
```JSON
{
"resource": "tenant_quota",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": "tenant-id-mock",
"quota_revision": 1,
"max_concurrent_calls": 3
}
```
控制事件
> 队列:agent\-call\.d\.\<dispatcher\_id\>\.control\.v1
>
>
SIP 线路变更
```JSON
{
"event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb",
"event_type": "sip.config",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"payload": {
"trunk_id": "trunk-mock",
"change_type": "disabled", // enabled/removed/created
"revision": 8
}
}
```
任务控制
```JSON
{
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": 1001,
"issued_at": "2026-09-21T00:00:00Z",
"event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb",
"event_type":: "task.control",
"payload": {
"task_id": "task-a",
"action": "pause", // resume , stop
"reason": "local-test",
"options": { // 附加控制参数
"active_call_policy": "drain", // 活跃通话策略,仅stop,pause事件生效 [drain|hangup]
}
}
}
```
任务控制处理回执
> 队列 agent\-call\.saas\.v1
>
>
```JSON
{
"event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb",
"event_type":: "task.control",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": 1001,
"payload": {
"status": "applied"
}
}
```
外呼任务队列
> 队列:agent\-call\.d\.\<dispatcher\_id\>\.t\.\<tenant\_id\>\.v1
>
>
呼出
```JSON
{
"event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb",
"event_type":: "call.execute",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": 1001,
"issued_at": "2026-09-18T10:00:00+08:00"
"payload": {
"task_id": "task-a",
"callee": "15003164745"
}
}
```
回执
> 队列 agent\-call\.saas\.v1
>
>
开始调度执行
```JSON
{
"event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb",
"event_type":: "call.execute",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": 1001,
"issued_at": "2026-09-18T10:00:00+08:00"
"payload": {
"status": "dispatched",
}
}
```
完成结果上报
成功
```JSON
{
"event_id": "call-result-001",
"event_type": "call.execute.result",
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
"tenant_id": 1001,
"issued_at": "2026-09-18T10:10:15+08:00",
"payload": {
"task_id": "task-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",
"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"
}
}
}
```
无应答
```JSON
{
// ...
"payload": {
//...
"outcome": "no_answer",
"reason_code": 480,
"reason_message": "SIP ring timeout Reason",
"transcript": [],
"opt_out": false,
"recording": {}
}
}
```
File diff suppressed because it is too large Load Diff