feat: implement local P1 contracts and Mock call flow
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/proposals/call-result-v0.1-proposal.schema.json",
|
||||
"title": "Project-local contract: single final call.result event; external compatibility unverified",
|
||||
"$comment": "Project-local contract derived from docs/thirds/第三方对接事件与请求消费顺序_v0.1.md; not the published MQ v2 contract. Runtime caps each serialized UTF-8 JSON body at 8,388,608 bytes, without compression, truncation, or event splitting. Expected recording finalizes no later than call.ended_at + 15m; at most one PUT per upload_id and one immutable call.result event.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "event_id", "event_type", "dispatcher_id", "tenant_id", "tenant_key", "trace_id", "occurred_at", "aggregate_type", "aggregate_id", "aggregate_version", "payload"],
|
||||
"properties": {
|
||||
"schema_version": {"const": "call-result.v0.1-proposal"},
|
||||
"event_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
|
||||
"event_type": {"const": "call.result"},
|
||||
"dispatcher_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/dispatcherId"},
|
||||
"tenant_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
|
||||
"tenant_key": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/tenantKey"},
|
||||
"trace_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
|
||||
"occurred_at": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/time"},
|
||||
"aggregate_type": {"const": "call"},
|
||||
"aggregate_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
|
||||
"aggregate_version": {"type": "integer", "minimum": 1},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["source_command_id", "execution_id", "call_id", "task_id", "task_revision", "agent_version_id", "route_policy_id", "caller_profile_id", "callee", "trunk_id", "started_at", "ended_at", "duration_ms", "outcome", "reason_code", "transcript", "opt_out", "recording"],
|
||||
"properties": {
|
||||
"source_command_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
|
||||
"execution_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
|
||||
"call_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
|
||||
"task_id": {"$ref": "#/$defs/task_id"},
|
||||
"task_revision": {"type": "integer", "minimum": 1},
|
||||
"agent_version_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
|
||||
"route_policy_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
|
||||
"caller_profile_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
|
||||
"callee": {"type": "string", "minLength": 1},
|
||||
"trunk_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
|
||||
"started_at": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/time"},
|
||||
"ended_at": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/time"},
|
||||
"duration_ms": {"type": "integer", "minimum": 0},
|
||||
"outcome": {"type": "string", "minLength": 1},
|
||||
"reason_code": {"type": ["string", "null"], "minLength": 1},
|
||||
"transcript": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["turn_id", "segment_id", "role", "text", "start_ms", "end_ms"],
|
||||
"properties": {
|
||||
"turn_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
|
||||
"segment_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
|
||||
"role": {"type": "string", "minLength": 1},
|
||||
"text": {"type": "string"},
|
||||
"start_ms": {"type": "integer", "minimum": 0},
|
||||
"end_ms": {"type": "integer", "minimum": 0}
|
||||
}
|
||||
}
|
||||
},
|
||||
"opt_out": {"type": "boolean"},
|
||||
"recording": {
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/recording_uploaded"},
|
||||
{"$ref": "#/$defs/recording_unavailable"},
|
||||
{"$ref": "#/$defs/recording_not_created"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"$comment": "aggregate_id must equal payload.call_id; timestamps and duration must agree. Runtime enforces the 8 MiB serialized-body budget. If too large, retain the exact result in durable outbox and expose blocked_payload_too_large; never truncate, split, or discard it."
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
|
||||
"recording_asset_fields": {
|
||||
"type": "object",
|
||||
"required": ["recording_id", "upload_id", "bucket", "object_key", "format", "channels", "sample_rate_hz", "duration_ms", "size_bytes", "checksum_sha256"],
|
||||
"properties": {
|
||||
"recording_id": {"type": ["string", "null"], "minLength": 1},
|
||||
"upload_id": {"type": ["string", "null"], "minLength": 1},
|
||||
"bucket": {"type": ["string", "null"], "minLength": 1},
|
||||
"object_key": {"type": ["string", "null"], "minLength": 1},
|
||||
"format": {"type": ["string", "null"], "minLength": 1},
|
||||
"channels": {"type": ["integer", "null"], "minimum": 1},
|
||||
"sample_rate_hz": {"type": ["integer", "null"], "minimum": 1},
|
||||
"duration_ms": {"type": ["integer", "null"], "minimum": 0},
|
||||
"size_bytes": {"type": ["integer", "null"], "minimum": 0},
|
||||
"checksum_sha256": {"type": ["string", "null"], "pattern": "^[a-f0-9]{64}$"}
|
||||
}
|
||||
},
|
||||
"recording_uploaded": {
|
||||
"allOf": [
|
||||
{"$ref": "#/$defs/recording_asset_fields"},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["status"],
|
||||
"properties": {
|
||||
"status": {"const": "uploaded"},
|
||||
"recording_id": {"type": "string", "minLength": 1},
|
||||
"upload_id": {"type": "string", "minLength": 1},
|
||||
"bucket": {"type": "string", "minLength": 1},
|
||||
"object_key": {"type": "string", "minLength": 1},
|
||||
"format": {"type": "string", "minLength": 1},
|
||||
"channels": {"type": "integer", "minimum": 1},
|
||||
"sample_rate_hz": {"type": "integer", "minimum": 1},
|
||||
"duration_ms": {"type": "integer", "minimum": 0},
|
||||
"size_bytes": {"type": "integer", "minimum": 0},
|
||||
"checksum_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"unevaluatedProperties": false
|
||||
},
|
||||
"recording_unavailable": {
|
||||
"allOf": [
|
||||
{"$ref": "#/$defs/recording_asset_fields"},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["status", "error_code"],
|
||||
"properties": {
|
||||
"status": {"const": "unavailable"},
|
||||
"error_code": {"enum": ["upload_authorization_failed", "upload_authorization_expired", "upload_failed", "upload_timeout", "deadline_exceeded", "checksum_mismatch"]},
|
||||
"recording_id": {"type": "string", "minLength": 1},
|
||||
"upload_id": {"type": "string", "minLength": 1},
|
||||
"bucket": {"type": "null"},
|
||||
"object_key": {"type": "null"},
|
||||
"format": {"type": "string", "minLength": 1},
|
||||
"channels": {"type": "integer", "minimum": 1},
|
||||
"sample_rate_hz": {"type": "integer", "minimum": 1},
|
||||
"duration_ms": {"type": "integer", "minimum": 0},
|
||||
"size_bytes": {"type": "null"},
|
||||
"checksum_sha256": {"type": "null"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"unevaluatedProperties": false
|
||||
},
|
||||
"recording_not_created": {
|
||||
"allOf": [
|
||||
{"$ref": "#/$defs/recording_asset_fields"},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["status", "reason_code"],
|
||||
"properties": {
|
||||
"status": {"const": "not_created"},
|
||||
"reason_code": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"},
|
||||
"recording_id": {"type": "null"},
|
||||
"upload_id": {"type": "null"},
|
||||
"bucket": {"type": "null"},
|
||||
"object_key": {"type": "null"},
|
||||
"format": {"type": "null"},
|
||||
"channels": {"type": "null"},
|
||||
"sample_rate_hz": {"type": "null"},
|
||||
"duration_ms": {"type": "null"},
|
||||
"size_bytes": {"type": "null"},
|
||||
"checksum_sha256": {"type": "null"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"unevaluatedProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/proposals/command-next-v0.1-proposal.schema.json",
|
||||
"title": "Project-local contract: SaaS command and command-result messages; external compatibility unverified",
|
||||
"$comment": "Project-local contract derived from docs/thirds/第三方对接事件与请求消费顺序_v0.1.md; not the published MQ v2 contract. Serialized UTF-8 JSON bodies are capped at 8,388,608 bytes by runtime validation.",
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/call_execute"},
|
||||
{"$ref": "#/$defs/control_pause"},
|
||||
{"$ref": "#/$defs/control_resume"},
|
||||
{"$ref": "#/$defs/control_stop"},
|
||||
{"$ref": "#/$defs/command_result_call"},
|
||||
{"$ref": "#/$defs/command_result_control"}
|
||||
],
|
||||
"$defs": {
|
||||
"id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"},
|
||||
"dispatcher_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/dispatcherId"},
|
||||
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
|
||||
"tenant_key": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/tenantKey"},
|
||||
"time": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/time"},
|
||||
"command_base": {
|
||||
"type": "object",
|
||||
"required": ["schema_version", "dispatcher_id", "tenant_id", "tenant_key", "trace_id", "issued_at", "not_after"],
|
||||
"properties": {
|
||||
"schema_version": {"const": "command-next.v0.1-proposal"},
|
||||
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
|
||||
"tenant_id": {"$ref": "#/$defs/id"},
|
||||
"tenant_key": {"$ref": "#/$defs/tenant_key"},
|
||||
"trace_id": {"$ref": "#/$defs/id"},
|
||||
"issued_at": {"$ref": "#/$defs/time"},
|
||||
"not_after": {"$ref": "#/$defs/time"}
|
||||
}
|
||||
},
|
||||
"call_execute": {
|
||||
"allOf": [
|
||||
{"$ref": "#/$defs/command_base"},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["command_id", "command_type", "payload"],
|
||||
"properties": {
|
||||
"command_id": {"$ref": "#/$defs/id"},
|
||||
"command_type": {"const": "call.execute"},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["task_id", "callee"],
|
||||
"properties": {
|
||||
"task_id": {"$ref": "#/$defs/task_id"},
|
||||
"callee": {"type": "string", "minLength": 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"unevaluatedProperties": false,
|
||||
"$comment": "Check not_after against current time and bind the task/tenant snapshot before originate; not_after must be later than issued_at."
|
||||
},
|
||||
"control_pause": {
|
||||
"allOf": [
|
||||
{"$ref": "#/$defs/command_base"},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["command_type", "payload"],
|
||||
"properties": {
|
||||
"command_type": {"const": "task.control"},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["task_id", "action", "active_call_policy", "reason"],
|
||||
"properties": {
|
||||
"task_id": {"$ref": "#/$defs/task_id"},
|
||||
"action": {"const": "pause"},
|
||||
"active_call_policy": {"enum": ["drain", "hangup"]},
|
||||
"reason": {"type": "string", "minLength": 1, "maxLength": 512}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"unevaluatedProperties": false
|
||||
},
|
||||
"control_resume": {
|
||||
"allOf": [
|
||||
{"$ref": "#/$defs/command_base"},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["command_type", "payload"],
|
||||
"properties": {
|
||||
"command_type": {"const": "task.control"},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["task_id", "action", "reason"],
|
||||
"properties": {
|
||||
"task_id": {"$ref": "#/$defs/task_id"},
|
||||
"action": {"const": "resume"},
|
||||
"reason": {"type": "string", "minLength": 1, "maxLength": 512}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"unevaluatedProperties": false
|
||||
},
|
||||
"control_stop": {
|
||||
"allOf": [
|
||||
{"$ref": "#/$defs/command_base"},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["command_type", "payload"],
|
||||
"properties": {
|
||||
"command_type": {"const": "task.control"},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["task_id", "action", "active_call_policy", "reason"],
|
||||
"properties": {
|
||||
"task_id": {"$ref": "#/$defs/task_id"},
|
||||
"action": {"const": "stop"},
|
||||
"active_call_policy": {"enum": ["drain", "hangup"]},
|
||||
"reason": {"type": "string", "minLength": 1, "maxLength": 512}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"unevaluatedProperties": false
|
||||
},
|
||||
"event_base": {
|
||||
"type": "object",
|
||||
"required": ["schema_version", "event_id", "event_type", "dispatcher_id", "tenant_id", "tenant_key", "trace_id", "occurred_at", "aggregate_type", "aggregate_id", "aggregate_version"],
|
||||
"properties": {
|
||||
"schema_version": {"const": "command-next.v0.1-proposal"},
|
||||
"event_id": {"$ref": "#/$defs/id"},
|
||||
"event_type": {"const": "command.result"},
|
||||
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
|
||||
"tenant_id": {"$ref": "#/$defs/id"},
|
||||
"tenant_key": {"$ref": "#/$defs/tenant_key"},
|
||||
"trace_id": {"$ref": "#/$defs/id"},
|
||||
"occurred_at": {"$ref": "#/$defs/time"},
|
||||
"aggregate_type": {"enum": ["command", "task"]},
|
||||
"aggregate_id": {"$ref": "#/$defs/id"},
|
||||
"aggregate_version": {"type": "integer", "minimum": 1}
|
||||
}
|
||||
},
|
||||
"command_result_call": {
|
||||
"allOf": [
|
||||
{"$ref": "#/$defs/event_base"},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["aggregate_type", "payload"],
|
||||
"properties": {
|
||||
"aggregate_type": {"const": "command"},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["command_id", "command_type", "status", "reason_code"],
|
||||
"properties": {
|
||||
"command_id": {"$ref": "#/$defs/id"},
|
||||
"command_type": {"const": "call.execute"},
|
||||
"status": {"enum": ["accepted", "rejected"]},
|
||||
"reason_code": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"},
|
||||
"execution_id": {"$ref": "#/$defs/id"}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {"properties": {"status": {"const": "accepted"}}},
|
||||
"then": {"required": ["execution_id"]}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"unevaluatedProperties": false
|
||||
},
|
||||
"command_result_control": {
|
||||
"allOf": [
|
||||
{"$ref": "#/$defs/event_base"},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["aggregate_type", "payload"],
|
||||
"properties": {
|
||||
"aggregate_type": {"const": "task"},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["command_type", "task_id", "action", "status", "reason_code", "task_state"],
|
||||
"properties": {
|
||||
"command_type": {"const": "task.control"},
|
||||
"task_id": {"$ref": "#/$defs/task_id"},
|
||||
"action": {"enum": ["pause", "resume", "stop"]},
|
||||
"status": {"enum": ["applied", "rejected"]},
|
||||
"reason_code": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"},
|
||||
"task_state": {"enum": ["running", "paused", "stopped", "finished"]}
|
||||
},
|
||||
"allOf": [
|
||||
{"if": {"properties": {"action": {"const": "pause"}, "status": {"const": "applied"}}}, "then": {"properties": {"task_state": {"const": "paused"}}}},
|
||||
{"if": {"properties": {"action": {"const": "resume"}, "status": {"const": "applied"}}}, "then": {"properties": {"task_state": {"const": "running"}}}},
|
||||
{"if": {"properties": {"action": {"const": "stop"}, "status": {"const": "applied"}}}, "then": {"properties": {"task_state": {"const": "stopped"}}}}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"unevaluatedProperties": false,
|
||||
"$comment": "No control command_id or request dedupe identity exists. event_id identifies a receipt event, not a control command."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
# 只读配置与租户额度接口:任务、智能体、SIP 字段与返回结构 v0.1(项目提案)
|
||||
# 只读配置与租户额度接口:任务、智能体、SIP 字段与返回结构 v0.1(项目内 F01 规范)
|
||||
|
||||
**状态:项目自定义草案,非 SaaS 已有接口/实际 JSON、非已发布契约、非实现/验收。** 用户同意:截图可见的业务含义先映射为**项目定义的字段名**;截图没有但需求明确的结构由本项目设计。即使字段名与现有项目 Schema 或历史 OpenAPI 相同,也**不能**据此声称它是当前 SaaS 页面原有的后端键。SaaS 和 management 在 W01 签收前不得据此开始真实配置发布/拨号。
|
||||
**状态:项目内 F01 字段规范;不是 SaaS 已有接口/实际 JSON,也不是外部发布契约。** 本地字段由本文件与[第三方对接契约](../thirds/第三方对接事件与请求消费顺序_v0.1.md)定义,使用严格 Schema、正反例、来源/hash 和 Mock 验证;不等待 SaaS/management 外部签收即可完成本地 C。截图可见的业务含义映射为**项目定义的字段名**;截图没有但需求明确的结构由本项目设计。即使字段名与现有项目 Schema 或历史 OpenAPI 相同,也**不能**据此声称它是当前 SaaS 页面原有后端键。真实配置发布、拨号或外部切换仍需另行授权和验证。
|
||||
|
||||
## 1. 来源、交付边界
|
||||
|
||||
- **P = 页面观察:**[SaaS 截图分析](../references/saas-page-snapshot-analysis.md) §2–4;只证明表单/列表可见,尤其§7的 **MQ 配置建议是已被新 HTTP 方向取代的历史方案**,不作为新合同。
|
||||
- **C = 本项目现有合同:**[AI 配置](../../contracts/upstream/v1/ai-config.schema.json)、[静态 Cell/SIP 制品](../../contracts/upstream/v1/static-cell-artifact.schema.json)及[当前 MQ 消息](../../contracts/upstream/v1/mq.schema.json)。字段语义可复用,但**不是 SaaS 当前 HTTP 响应证据**。旧 [OpenAPI/MQ 只读索引](../references/OpenAPI与MQ字段索引_v0.1.md) 也仅为历史参考。
|
||||
- **C = 现行外部项目合同:**[AI 配置](../../contracts/upstream/v1/ai-config.schema.json)、[静态 Cell/SIP 制品](../../contracts/upstream/v1/static-cell-artifact.schema.json)及[当前 MQ 消息](../../contracts/upstream/v1/mq.schema.json)。字段语义可复用,但**不是 SaaS 当前 HTTP 响应证据**。本地 HTTP 请求/错误/分页语义以[第三方对接契约](../thirds/第三方对接事件与请求消费顺序_v0.1.md)为准。旧 [OpenAPI/MQ 只读索引](../references/OpenAPI与MQ字段索引_v0.1.md) 也仅为历史参考。
|
||||
- **N = 新项目字段:**任务每周多时段/排除日期、SIP 线路时段、任务与单 D 绑定、缓存/版本/错误返回等,由本提案定义;实际 SaaS 接口不存在已验证响应。
|
||||
|
||||
交付物:[机器可读响应草案](config-read-v0.1.schema.json)、[mock SIP 成功示例](examples/config-read-sip-v0.1.json)、[mock 任务成功示例](examples/config-read-task-v0.1.json)、[mock 租户额度示例](examples/config-read-tenant-quota-v0.1.json)。前两条接口为**拟定的只读 HTTP 配置读取**:`GET /internal/v1/dispatcher/sip` 与 `GET /internal/v1/dispatcher/task/:task_id`;下一轮另拟 `GET /internal/v1/dispatcher/tasks` 和 `?after=<cursor>` 发现归属任务;新增项目拟定 `GET /internal/v1/dispatcher/tenant/:tenant_id/quota` 按任务的租户 ID 读取本 D 的租户份额。四条请求携带 `X-DISPATCHER-id`(D UUID)及 `X-DISPATCHER-SECRET-KEY`(受控密钥),无请求体;实际 SaaS 实现仍待签收。业务呼叫/控制及回执/最终结果继续走 MQ,目标版本移除对外查询/补传;不留旧 AI/SIP 配置 MQ 回退。**HTTP 响应错误码、SIP 快照规范及 SaaS/management 实际来源仍需 W01/F07 冻结**,本文件不冒充外部权威发布物。
|
||||
路径来源:`/internal/v1/dispatcher/sip`、`/internal/v1/dispatcher/task/:task_id`、`/internal/v1/dispatcher/tasks`(含 `?after=<cursor>`)为用户给定路径;`/internal/v1/dispatcher/tenant/:tenant_id/quota` 与任务路由字段 `route_policy_id`、`caller_profile_id`、`allowed_trunk_ids` 为本地项目定义。它们不是截图/现网接口已验证的响应键;本地 Mock 按本契约验证。
|
||||
|
||||
交付物:[机器可读响应 Schema](config-read-v0.1.schema.json)、[mock SIP 成功示例](examples/config-read-sip-v0.1.json)、[mock 任务成功示例](examples/config-read-task-v0.1.json)、[mock 租户额度示例](examples/config-read-tenant-quota-v0.1.json)、[mock 错误响应示例](examples/config-read-error-v0.1.json)及[预期被 Schema 拒绝的非法示例](examples/config-read-invalid-extra-property-v0.1.json)。四条只读 GET 为本地目标:`/internal/v1/dispatcher/sip`、`/internal/v1/dispatcher/task/:task_id`、`/internal/v1/dispatcher/tasks?after=<cursor>` 与 `/internal/v1/dispatcher/tenant/:tenant_id/quota`。均携带 `X-DISPATCHER-id`(D UUID)及 `X-DISPATCHER-SECRET-KEY`(受控密钥),无请求体;真实 SaaS 实现和字段兼容性未验证。呼叫/控制/回执/最终结果走 MQ,目标移除对外 query/replay,不留旧 AI/SIP 配置 MQ 回退。错误状态和 code 按本文件 §2 与第三方对接契约定义并由 Mock 验证;本地 Schema 不冒充外部权威发布物。
|
||||
|
||||
## 2. 请求与共同响应
|
||||
|
||||
Dispatcher 仅用 `X-DISPATCHER-id`(全局唯一 UUID)和部署受控的 `X-DISPATCHER-SECRET-KEY` 请求归属资源;不记录密钥或在日志中打印配置提示词。接口只读、无控制副作用;SaaS 必须核验任务归属。Header 的准确校验、密钥轮换时序、HTTP 错误状态仍需 SaaS 签收。`GET /internal/v1/dispatcher/tasks`(含 `?after=<cursor>`)是**第三条待 F07 签收的任务发现接口**,其快照/变更示例见[第三方对接 §2.5](../thirds/第三方对接事件与请求消费顺序_v0.1.md),不混入本文件的配置响应 Schema;第四条租户额度响应属于本文件新增草案,路径/字段未获 SaaS 签收。
|
||||
Dispatcher 仅用 `X-DISPATCHER-id`(全局唯一 UUID)和部署受控的 `X-DISPATCHER-SECRET-KEY` 请求归属资源;不记录密钥或在日志中打印配置提示词。接口只读、无控制副作用;服务端必须核验任务归属。Header 的本地错误约定为 HTTP 401 `unauthorized`、403 `dispatcher_not_authorized`;请求格式错误为 400 `invalid_request`,资源缺失/未归属为 404 `resource_not_found`,租户额度不可用为 503 `tenant_quota_unavailable`,临时服务故障为 503 `service_unavailable`。`GET /internal/v1/dispatcher/tasks` 的游标和分页错误按第三方契约 §2.5:400 `invalid_cursor`/`invalid_page_token`、410 `cursor_expired`/`snapshot_expired`。任务发现严格结构见[任务发现 Schema](task-discovery-v0.1-proposal.schema.json),正例见[第三方对接 §2.5](../thirds/第三方对接事件与请求消费顺序_v0.1.md),不混入本文件的配置响应 Schema。以上仅为本地 Mock/Go 契约,不代表外部 SaaS 状态码。
|
||||
|
||||
| 请求 | `200` 返回类型 | 何时读取 | 错误处理 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -22,7 +24,7 @@ Dispatcher 仅用 `X-DISPATCHER-id`(全局唯一 UUID)和部署受控的 `X-
|
||||
|
||||
`200` 响应中的 `schema_version` 固定 `config-read.v0.1`,`resource` 区分 SIP、任务及租户额度结构,`dispatcher_id` 必须等于 Header 中的 D。**不使用条件请求、ETag 或 `304`**:到期时重新 GET 完整响应;只有收到、验证并重新确认授权有效后才更新缓存。SaaS 变更到 D 的目标延迟约 60 秒;缓存到期且刷新失败,**不可无限期沿用旧版本发起新呼叫**。MQ 停/暂停不等待这 60 秒。已接纳/已接通呼叫固定自己的快照,不因缓存过期而漂移。
|
||||
|
||||
非 `200` 返回 `resource=error`、`error.code`、`error.message` 的脱敏 JSON(HTTP 状态及 code 逐项待 SaaS 确认),不得吞成旧配置/空任务。下一版 `call.execute.payload` **只有 `task_id` 与 `callee`**;D 从已批准的任务快照读取路由/主叫/智能体版本及任务级 `ring_timeout_ms/max_call_duration_ms`,接纳前持久绑定完整快照,不能从精简命令中猜值或悄悄采用过期缓存。现行严格 MQ Schema 尚未修改。
|
||||
非 `200` 返回 `resource=error`、`error.code`、`error.message` 的脱敏 JSON(HTTP 状态与 code 按本节约定;真实 SaaS 是否一致尚未验证),不得吞成旧配置/空任务。下一版 `call.execute.payload` **只有 `task_id` 与 `callee`**;D 从已批准的任务快照读取路由/主叫/智能体版本及任务级 `ring_timeout_ms/max_call_duration_ms`,接纳前持久绑定完整快照,不能从精简命令中猜值或悄悄采用过期缓存。现行严格 MQ Schema 尚未修改。
|
||||
|
||||
## 3. 任务成功响应:字段与来源
|
||||
|
||||
@@ -76,7 +78,7 @@ Dispatcher 仅用 `X-DISPATCHER-id`(全局唯一 UUID)和部署受控的 `X-
|
||||
|
||||
静态制品中的端点**引用**与此提案补充的对端**值**属于同一获批版本;若 SaaS 与 management 并非同一配置来源,必须证明它们同步一致,且 Agent/Asterisk 实际加载的版本/摘要匹配。`artifact.load_evidence` 为 `null` 只表示 HTTP 获得配置,**不代表 Asterisk 已加载**;D 必须另从实际执行侧核验。样例 `mode=mock`、`transport/auth_mode/registration_required/max_concurrent_calls=null` 是故意保留的供应商待确认项;**不满足 real 放行**。只提供SaaS读接口却不能取得已批准的端点及线路时段,不得声称已经提供了完整 SIP 配置。
|
||||
|
||||
## 4.1 租户额度响应(新增字段,N,未签收)
|
||||
## 4.1 租户额度响应(项目内新增字段 N,外部未签收)
|
||||
|
||||
| 字段 | 类型/约束 | 业务语义 |
|
||||
| --- | --- | --- |
|
||||
@@ -88,13 +90,13 @@ Dispatcher 仅用 `X-DISPATCHER-id`(全局唯一 UUID)和部署受控的 `X-
|
||||
|
||||
D 在同一事务预留租户/任务/线路等占用,未知继续计入;降额不强挂、占用低于新上限才再接新。额度缺失、过期/错身份、刷新失败关闭新准入,不能以任务额度代替。已确认通话终结/执行资源释放即可释放通话额度,不等录音上传或MQ确认;stop静默ACK不需申请通话名额。多D须由SaaS分份额,累计不超过租户总额;本轮只验证单D。
|
||||
|
||||
## 5. 需冻结的语义与验收前置
|
||||
## 5. 外部待核事项(不阻塞本地 F01/C)
|
||||
|
||||
1. **来源:**确认 SaaS 的任务、智能体是该服务的权威配置;management 仍是唯一 SIP 编辑/审批方。确认 SaaS 分发的是已批准制品与线路补充字段同一版本,不能出现两份可写配置。
|
||||
2. **身份和响应:**约定请求头 `X-DISPATCHER-id` 与 `X-DISPATCHER-SECRET-KEY`;只读取归属 D 的 `/internal/v1/dispatcher/sip`、`/internal/v1/dispatcher/tasks` 、`/internal/v1/dispatcher/task/:task_id` 和新增拟定 `/internal/v1/dispatcher/tenant/:tenant_id/quota`,不在日志/示例保存真实密钥。具体身份校验、状态码和生效时间仍须 SaaS 签收;不采用 `ETag`/`304`。
|
||||
3. **窗口与版本:**缓存成功核验起约 60 秒;SaaS 变更对未接纳呼叫最多约 60 秒延迟,过期重新 GET 完整数据失败就停止新准入,已接纳保留原快照。更新/SIP 加载期间停执行队列,不停控制 MQ;停/暂停不等缓存。没有 `304` 延长授权的通道。跨日窗口、重叠段、当日排除、时间边界及任务与线路交集须在合同冻结。
|
||||
1. **外部来源与审批:**真实 SaaS/management 联调前,确认 SaaS 的任务、智能体是该服务的权威配置;management 仍是唯一 SIP 编辑/审批方。证明 SaaS 分发的是已批准制品与线路补充字段同一版本,不能出现两份可写配置。此项不阻塞本地 Mock。
|
||||
2. **身份和响应:**本地请求头为 `X-DISPATCHER-id` 与 `X-DISPATCHER-SECRET-KEY`;只读取归属 D 的 `/internal/v1/dispatcher/sip`、`/internal/v1/dispatcher/tasks`、`/internal/v1/dispatcher/task/:task_id` 和 `/internal/v1/dispatcher/tenant/:tenant_id/quota`,不在日志/示例保存真实密钥。身份校验、状态码与生效时间按本文件和第三方对接契约作为本地规则;真实 SaaS 兼容性未验证。不采用 `ETag`/`304`。
|
||||
3. **窗口与版本:**本地缓存成功核验起约 60 秒;SaaS 变更对未接纳呼叫最多约 60 秒延迟,过期重新 GET 完整数据失败就停止新准入,已接纳保留原快照。更新/SIP 加载期间停执行队列,不停控制 MQ;停/暂停不等缓存。没有 `304` 延长授权的通道。跨日窗口、重叠段、当日排除、时间边界及任务与线路交集按本地 Schema/业务测试执行;真实 SaaS 行为未验证。
|
||||
4. **一致性:**`agent_version_id` 与 AI 授权一致且有效,同版内容漂移必须拒绝;任务归属/修订/route policy 以已绑定任务快照为准,MQ 命令不得覆盖;`artifact.trunks` 与 `trunk_details` 一一对应;线路 status、主叫、前缀、媒体、线路/租户/供应商额度来源和 Agent/Asterisk 实际加载不可依赖 JSON Schema 单独判断。供应商未知传输/鉴权/注册不得默认允许 real。
|
||||
5. **消费状态:**pause保留原队列积压,resume最新配置/授权/额度有效才继续消费,无需SaaS重新投递;暂停不延长not_after。stop后未接纳积压静默消费ACK,不拨号、不发逐条回执/最终结果;控制本身与已在途通话结果仍回传,本地计数/错误不静默。状态优先级及例外按总计划§3.3,不加控制去重。
|
||||
6. **退出与过渡:**新接口上线前现行 MQ-only/单 D/固定时段/静态 SIP 仍有效。切到新版本后配置只走 HTTP,旧 `ai.config.request/result` 停用,不做 HTTP→MQ 回退;业务 MQ 正常运行。多 D 任务归属/共享额度份额、旧命令/缓存/恢复记录和控制屏障须单独验证;任务结束只删除配置缓存,不删除未决执行与消息事实。
|
||||
|
||||
**验证状态:**本地 JSON Schema 草案覆盖 SIP、任务和新增租户额度 `200` 示例及错误/非法样例;它**不能**证明真实 SaaS 接口字段名、管理平台签收、hash 规范、Agent SDK 映射、SIP 实际加载或任何生产外呼验收。
|
||||
**验证状态:**本地 JSON Schema 草案包含 SIP、任务、租户额度 `200` 成功响应、有效错误响应及一个额外字段非法样例;只有 Schema 校验通过的有效示例可作为正例,非法样例必须被拒绝。此离线校验**不能**证明真实 SaaS 接口字段名、management 签收、hash 规范、Agent SDK 映射、SIP 实际加载或任何生产外呼验收。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/proposals/config-read-v0.1.schema.json",
|
||||
"title": "PROPOSAL: project-defined SaaS read-only configuration responses; NOT a published SaaS contract",
|
||||
"title": "Project-local F01 contract: read-only configuration responses; external SaaS compatibility unverified",
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/sip_response"},
|
||||
{"$ref": "#/$defs/task_response"},
|
||||
@@ -36,8 +36,8 @@
|
||||
"resource": {"const": "task_config"},
|
||||
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
|
||||
"tenant_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||
"tenant_key": {"type": "string", "minLength": 1, "maxLength": 196, "$comment": "Also validate <=196 UTF-8 bytes and routing word boundaries using the project-owned tenant contract."},
|
||||
"task_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||
"tenant_key": {"type": "string", "minLength": 1, "maxLength": 196, "$comment": "Validate <=196 UTF-8 bytes in business logic; preserve the original value and do not place tenant_key in queue/routing names."},
|
||||
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
|
||||
"task_revision": {"type": "integer", "minimum": 1},
|
||||
"status": {"enum": ["running", "paused", "stopped", "finished"]},
|
||||
"name": {"type": "string", "minLength": 1, "maxLength": 256},
|
||||
@@ -65,7 +65,7 @@
|
||||
"max_concurrent_calls": {"type": "integer", "minimum": 0},
|
||||
"valid_until": {"type": "string", "format": "date-time"}
|
||||
},
|
||||
"$comment": "PROPOSAL: SaaS-assigned share for this Dispatcher; aggregate all tasks of the tenant. Validate tenant_id/tenant_key mapping and expiry in business checks."
|
||||
"$comment": "Project-local response: assigned share for this Dispatcher, aggregated across all tasks of the tenant. Validate tenant_id/tenant_key mapping and valid_until in business logic."
|
||||
},
|
||||
"error_response": {
|
||||
"type": "object",
|
||||
@@ -78,7 +78,7 @@
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["code", "message"],
|
||||
"properties": {
|
||||
"code": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"},
|
||||
"code": {"enum": ["invalid_request", "unauthorized", "dispatcher_not_authorized", "resource_not_found", "tenant_quota_unavailable", "service_unavailable"]},
|
||||
"message": {"type": "string", "minLength": 1, "maxLength": 256}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,7 @@
|
||||
"sunday": {"$ref": "#/$defs/windows"}
|
||||
}
|
||||
},
|
||||
"windows": {"type": "array", "items": {"$ref": "#/$defs/window"}},
|
||||
"windows": {"type": "array", "items": {"$ref": "#/$defs/window"}, "$comment": "Each window is left-closed/right-open, start < end, and windows within a day must not overlap. Cross-midnight windows are split across two weekdays; 24:00 is allowed only as end."},
|
||||
"window": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["start", "end"],
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"schema_version": "call-result.v0.1-proposal",
|
||||
"event_id": "call-result-invalid-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": [],
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"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",
|
||||
"not_after": "2026-09-21T00:00:30Z",
|
||||
"command_id": "control-must-not-have-id",
|
||||
"expected_task_revision": 2,
|
||||
"command_type": "task.control",
|
||||
"payload": {
|
||||
"task_id": "task-a",
|
||||
"action": "pause",
|
||||
"active_call_policy": "drain",
|
||||
"reason": "local-test"
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"schema_version": "config-read.v0.1",
|
||||
"resource": "error",
|
||||
"error": {
|
||||
"code": "not_assigned",
|
||||
"code": "resource_not_found",
|
||||
"message": "Task is not assigned to this Dispatcher."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"fixture_version": "config-read-http-statuses.v0.1",
|
||||
"responses": [
|
||||
{
|
||||
"status": 400,
|
||||
"body": {
|
||||
"schema_version": "config-read.v0.1",
|
||||
"resource": "error",
|
||||
"error": {
|
||||
"code": "invalid_request",
|
||||
"message": "Request parameters are invalid."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": 401,
|
||||
"body": {
|
||||
"schema_version": "config-read.v0.1",
|
||||
"resource": "error",
|
||||
"error": {
|
||||
"code": "unauthorized",
|
||||
"message": "Dispatcher credentials are invalid."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": 403,
|
||||
"body": {
|
||||
"schema_version": "config-read.v0.1",
|
||||
"resource": "error",
|
||||
"error": {
|
||||
"code": "dispatcher_not_authorized",
|
||||
"message": "Dispatcher is not authorized for this resource."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": 404,
|
||||
"body": {
|
||||
"schema_version": "config-read.v0.1",
|
||||
"resource": "error",
|
||||
"error": {
|
||||
"code": "resource_not_found",
|
||||
"message": "The requested task or configuration resource was not found."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": 503,
|
||||
"body": {
|
||||
"schema_version": "config-read.v0.1",
|
||||
"resource": "error",
|
||||
"error": {
|
||||
"code": "tenant_quota_unavailable",
|
||||
"message": "A current tenant quota is unavailable."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": 503,
|
||||
"body": {
|
||||
"schema_version": "config-read.v0.1",
|
||||
"resource": "error",
|
||||
"error": {
|
||||
"code": "service_unavailable",
|
||||
"message": "Configuration service is temporarily unavailable."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"schema_version": "config-read.v0.1",
|
||||
"resource": "error",
|
||||
"error": {
|
||||
"code": "resource_not_found",
|
||||
"message": "Task is not assigned to this Dispatcher."
|
||||
},
|
||||
"unexpected": true
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schema_version": "local-mock-recording-failure.v0.1",
|
||||
"upload_id": "upload-a",
|
||||
"recording_id": "recording-a",
|
||||
"error_code": "upload_authorization_expired"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"schema_version": "local-mock-recording-failure.v0.1",
|
||||
"upload_id": "upload-a",
|
||||
"recording_id": "recording-a",
|
||||
"error_code": "upload_failed",
|
||||
"put_url": "https://example.invalid/never-a-real-token"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schema_version": "local-mock-recording-failure.v0.1",
|
||||
"upload_id": "upload-a",
|
||||
"recording_id": "recording-a",
|
||||
"error_code": "upload_timeout"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schema_version": "local-mock-recording-failure.v0.1",
|
||||
"upload_id": "upload-a",
|
||||
"recording_id": "recording-a",
|
||||
"error_code": "upload_failed"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"schema_version": "task-discovery.v0.2-proposal",
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
|
||||
"next_cursor": "opaque-watermark-004",
|
||||
"changes": [
|
||||
{
|
||||
"cursor": "opaque-watermark-002",
|
||||
"operation": "assigned",
|
||||
"task_id": "task-new",
|
||||
"tenant_id": "tenant-id-mock",
|
||||
"tenant_key": "tenant-mock",
|
||||
"status": "running",
|
||||
"task_revision": 1
|
||||
},
|
||||
{
|
||||
"cursor": "opaque-watermark-003",
|
||||
"operation": "updated",
|
||||
"task_id": "task-mock",
|
||||
"tenant_id": "tenant-id-mock",
|
||||
"tenant_key": "tenant-mock",
|
||||
"status": "stopped",
|
||||
"task_revision": 3
|
||||
},
|
||||
{
|
||||
"cursor": "opaque-watermark-004",
|
||||
"operation": "removed",
|
||||
"task_id": "task-old",
|
||||
"tenant_id": "tenant-id-mock",
|
||||
"tenant_key": "tenant-mock"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"fixture_version": "task-discovery-http-statuses.v0.1",
|
||||
"responses": [
|
||||
{
|
||||
"status": 400,
|
||||
"body": {
|
||||
"schema_version": "task-discovery.v0.1-proposal",
|
||||
"resource": "error",
|
||||
"error": {
|
||||
"code": "invalid_cursor",
|
||||
"message": "The cursor is invalid."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": 400,
|
||||
"body": {
|
||||
"schema_version": "task-discovery.v0.1-proposal",
|
||||
"resource": "error",
|
||||
"error": {
|
||||
"code": "invalid_page_token",
|
||||
"message": "The page token is invalid."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": 401,
|
||||
"body": {
|
||||
"schema_version": "task-discovery.v0.1-proposal",
|
||||
"resource": "error",
|
||||
"error": {
|
||||
"code": "unauthorized",
|
||||
"message": "Dispatcher credentials are invalid."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": 403,
|
||||
"body": {
|
||||
"schema_version": "task-discovery.v0.1-proposal",
|
||||
"resource": "error",
|
||||
"error": {
|
||||
"code": "dispatcher_not_authorized",
|
||||
"message": "Dispatcher is not authorized for this resource."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": 410,
|
||||
"body": {
|
||||
"schema_version": "task-discovery.v0.1-proposal",
|
||||
"resource": "error",
|
||||
"error": {
|
||||
"code": "cursor_expired",
|
||||
"message": "The cursor expired; a full snapshot is required."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": 410,
|
||||
"body": {
|
||||
"schema_version": "task-discovery.v0.1-proposal",
|
||||
"resource": "error",
|
||||
"error": {
|
||||
"code": "snapshot_expired",
|
||||
"message": "The snapshot expired; a full snapshot is required."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": 503,
|
||||
"body": {
|
||||
"schema_version": "task-discovery.v0.1-proposal",
|
||||
"resource": "error",
|
||||
"error": {
|
||||
"code": "service_unavailable",
|
||||
"message": "Task discovery is temporarily unavailable."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"fixture_version": "task-discovery-http-statuses.v0.2",
|
||||
"responses": [
|
||||
{"status": 400, "body": {"schema_version": "task-discovery.v0.2-proposal", "resource": "error", "error": {"code": "invalid_cursor", "message": "The cursor is invalid."}}},
|
||||
{"status": 401, "body": {"schema_version": "task-discovery.v0.2-proposal", "resource": "error", "error": {"code": "unauthorized", "message": "Dispatcher credentials are invalid."}}},
|
||||
{"status": 403, "body": {"schema_version": "task-discovery.v0.2-proposal", "resource": "error", "error": {"code": "dispatcher_not_authorized", "message": "Dispatcher is not authorized for this resource."}}},
|
||||
{"status": 410, "body": {"schema_version": "task-discovery.v0.2-proposal", "resource": "error", "error": {"code": "cursor_expired", "message": "A full task snapshot is required."}}},
|
||||
{"status": 503, "body": {"schema_version": "task-discovery.v0.2-proposal", "resource": "error", "error": {"code": "service_unavailable", "message": "Task discovery is temporarily unavailable."}}}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"schema_version": "task-discovery.v0.2-proposal",
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
|
||||
"next_cursor": "opaque-watermark-004",
|
||||
"changes": [],
|
||||
"next_page_token": "obsolete"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"schema_version": "task-discovery.v0.1-proposal",
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
|
||||
"mode": "snapshot",
|
||||
"snapshot_id": "snapshot-1042",
|
||||
"cursor": "1042",
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": "task-a",
|
||||
"tenant_id": "tenant-a",
|
||||
"tenant_key": "tenant-a",
|
||||
"status": "running",
|
||||
"task_revision": 1,
|
||||
"queue": {
|
||||
"exchange": "agent-call.dispatchers.v3",
|
||||
"routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in",
|
||||
"binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in",
|
||||
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.v3",
|
||||
"unexpected": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"next_page_token": null
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "task-discovery.v0.2-proposal",
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
|
||||
"cursor": "opaque-watermark-001",
|
||||
"tasks": [{
|
||||
"task_id": "task-mock",
|
||||
"tenant_id": "tenant-id-mock",
|
||||
"tenant_key": "tenant-mock",
|
||||
"status": "running",
|
||||
"task_revision": 2,
|
||||
"queue": {"queue_name": "not-allowed-in-response"}
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schema_version": "task-discovery.v0.2-proposal",
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
|
||||
"next_cursor": "opaque-watermark-004",
|
||||
"changes": []
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"schema_version": "task-discovery.v0.1-proposal",
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
|
||||
"mode": "snapshot",
|
||||
"snapshot_id": "snapshot-001",
|
||||
"cursor": "change-watermark-001",
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": "task-mock",
|
||||
"tenant_id": "tenant-id-mock",
|
||||
"tenant_key": "tenant-mock",
|
||||
"status": "running",
|
||||
"task_revision": 2,
|
||||
"queue": {
|
||||
"exchange": "agent-call.dispatchers.v3",
|
||||
"routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-mock.in",
|
||||
"binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-mock.in",
|
||||
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-mock.v3"
|
||||
}
|
||||
}
|
||||
],
|
||||
"next_page_token": null
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"schema_version": "task-discovery.v0.2-proposal",
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
|
||||
"cursor": "opaque-watermark-001",
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": "task-mock",
|
||||
"tenant_id": "tenant-id-mock",
|
||||
"tenant_key": "tenant-mock",
|
||||
"status": "running",
|
||||
"task_revision": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"manifest_version": "local-contract-manifest.v0.1",
|
||||
"hash_algorithm": "SHA-256",
|
||||
"scope": "Project-local F01/F07 contracts; not external SaaS or management acceptance.",
|
||||
"source": {
|
||||
"path": "docs/thirds/第三方对接事件与请求消费顺序_v0.1.md",
|
||||
"sha256": "788c36a86f3d5bc34639db5ac42b7c7f565169c696c0901737c240ee0da89411"
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"path": "docs/contracts/call-result-v0.1-proposal.schema.json",
|
||||
"sha256": "8068508cfd05e35d06b4c1c06bee826104be7d176fd07b6822714704d321bf35"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/command-next-v0.1-proposal.schema.json",
|
||||
"sha256": "fcd3ec1d56baa69a22fac76363a533e252658fb3b7a4fe7020f4322d965716de"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/config-read-fields-v0.1-proposal.md",
|
||||
"sha256": "14f89655b3565d3e2607e1509b5cfd272f090e7266ba352e16a8cccdd43aef32"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/config-read-v0.1.schema.json",
|
||||
"sha256": "d3fbf066295916b5322fff44c98a9e847de592135885ddff057f7f089fa4dfea"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/examples/call-result-invalid-missing-checksum-v0.1.json",
|
||||
"sha256": "9117dc78b53513e82f059816aea07b97d8980f3f8fc5b8ae02939450dedf09ac"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/examples/call-result-uploaded-v0.1.json",
|
||||
"sha256": "616c3f9e6b1ce77f98f365bc398e53177ce3dd50148db61327b4277bed6edf17"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/examples/command-next-invalid-control-id-v0.1.json",
|
||||
"sha256": "6c4fa5ff9e2992ac4c0a18357e16c2bfb4ae8b580b2afefa5cc219725059f5ce"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/examples/config-read-error-v0.1.json",
|
||||
"sha256": "90e95ab65820baa15ee44fafb7b8bdef3a09b62fba27ef178e4519467dafcfc3"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/examples/config-read-http-statuses-v0.1.json",
|
||||
"sha256": "2918407fbe722bffee4cbb638c4581f2f3530a8a2639a8c74085ed8ad95d2294"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/examples/config-read-invalid-extra-property-v0.1.json",
|
||||
"sha256": "6ad2d17767b22e28a47a88149a590e1a22b5f0f8ed036acdc6ab42f779def319"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/examples/config-read-sip-v0.1.json",
|
||||
"sha256": "7ff720634d3e190d91df44e9eaf3548be8c302be3b81d3d33f16b32ad0b034d4"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/examples/config-read-task-v0.1.json",
|
||||
"sha256": "571c1fe3c9e18108bf23b6053929ee23b12ff2f611b7194f38fb63e84003bd1c"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/examples/config-read-tenant-quota-v0.1.json",
|
||||
"sha256": "b95e06550a920238e9160b5bda8e305abcf0b5feef3db43f8a25b5e1b568e87d"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/examples/task-discovery-http-statuses-v0.1.json",
|
||||
"sha256": "67b80bd351373ad95b56e0200951b5aaebf24bbf7d58f2bbc826fc4e68fd2472"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/examples/task-discovery-invalid-queue-property-v0.1.json",
|
||||
"sha256": "bee48e2edddab074652bcaf8a81ca55770a725282add0ae13e458a9d31df94ef"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/examples/task-discovery-snapshot-v0.1.json",
|
||||
"sha256": "1b2389e58ea025097c38aaed72cf0f322c4e427d51f04b380d3e4c8173e9b2b5"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/mq-topology-v0.1-proposal.json",
|
||||
"sha256": "20c0f69057e283df81823f7ff333e2c1cc7d756a68c10a22fc7d50ad793d53c4"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/task-discovery-v0.1-proposal.schema.json",
|
||||
"sha256": "ff0d5e292272bd66654af91a5a3927c736a550e5766029f4c00c552125743b8e"
|
||||
}
|
||||
],
|
||||
"note": "Hashes cover the exact raw bytes of source and artifacts. This manifest does not hash itself."
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"manifest_version": "local-contract-manifest.v0.2",
|
||||
"hash_algorithm": "SHA-256",
|
||||
"source": {"path": "docs/thirds/v0.2.md", "sha256": "5358eaaecf944704975feab9150dcae8224ea89247a1086c68965ea46c253e31"},
|
||||
"artifacts": [
|
||||
{"path": "docs/contracts/config-read-v0.1.schema.json", "sha256": "d3fbf066295916b5322fff44c98a9e847de592135885ddff057f7f089fa4dfea"},
|
||||
{"path": "docs/contracts/command-next-v0.1-proposal.schema.json", "sha256": "fcd3ec1d56baa69a22fac76363a533e252658fb3b7a4fe7020f4322d965716de"},
|
||||
{"path": "docs/contracts/call-result-v0.1-proposal.schema.json", "sha256": "8068508cfd05e35d06b4c1c06bee826104be7d176fd07b6822714704d321bf35"},
|
||||
{"path": "docs/contracts/mq-topology-v0.1-proposal.json", "sha256": "20c0f69057e283df81823f7ff333e2c1cc7d756a68c10a22fc7d50ad793d53c4"},
|
||||
{"path": "docs/contracts/task-discovery-v0.2-proposal.schema.json", "sha256": "aef9fa5c4d7e37edda5d6f0bd8fdbc4aac37c09b6fc8f38a3bc52ca544c52c34"},
|
||||
{"path": "docs/contracts/examples/task-discovery-snapshot-v0.2.json", "sha256": "1640a04a75dfe53f5f221a7be22ee4f55e3501f029948fb27e50ef5efb72592d"},
|
||||
{"path": "docs/contracts/examples/task-discovery-changes-v0.2.json", "sha256": "58bb5743fdd35b502a643422d302284a2f6060b07dc9a4d4700df70b089a62e3"},
|
||||
{"path": "docs/contracts/examples/task-discovery-no-change-v0.2.json", "sha256": "45abb87f7e8c0ea1efa58a94f15e0da7ceef2a7f10ed7cc74ddb88abcee24177"},
|
||||
{"path": "docs/contracts/examples/task-discovery-http-statuses-v0.2.json", "sha256": "e72979c2de0951fcd58ee8914b70a7133625365497c94503161a52751ba19710"},
|
||||
{"path": "docs/contracts/examples/task-discovery-invalid-queue-v0.2.json", "sha256": "44bc481f9a8022b2d5172b5052cf937dc44038363e6303feb32fa76ab2b60b92"},
|
||||
{"path": "docs/contracts/examples/task-discovery-invalid-page-token-v0.2.json", "sha256": "e370ba50597efa4c129a4ba6d87288189343b901e1233699616f657a82a809d6"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"manifest_version": "local-mock-recording-failure-manifest.v0.1",
|
||||
"hash_algorithm": "SHA-256",
|
||||
"source": {
|
||||
"path": "docs/contracts/local-mock-recording-failure-v0.1.md",
|
||||
"sha256": "741e58d1eda31a0bc88d14bf0aae34daa824e129cc252b4cda99200f71e1ecf4"
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"path": "docs/contracts/local-mock-recording-failure-v0.1.schema.json",
|
||||
"sha256": "3f0b58aa8b0047282d9bd9215cfc9220aaa4cc5635636e13041ab58d16fa7c87"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/examples/local-mock-recording-failure-expired-v0.1.json",
|
||||
"sha256": "18f48df76ef633e33dfcf1564d69a85b4a2608b00df81ad8fcf46fd4b843ee54"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/examples/local-mock-recording-failure-invalid-extra-v0.1.json",
|
||||
"sha256": "9321796140b42b51677fd6d24903fd9510ff37e05f6e36551b5551b7c9dc7eff"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/examples/local-mock-recording-failure-invalid-timeout-v0.1.json",
|
||||
"sha256": "6eb0ebf2e4042e3f059f83d430d68fcd89cf34cf36568e47050133730a80a094"
|
||||
},
|
||||
{
|
||||
"path": "docs/contracts/examples/local-mock-recording-failure-upload-failed-v0.1.json",
|
||||
"sha256": "cd1028f02ac66fa9518c026a260b0cceec9a1016c4be5afab4d174d910f3746c"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Local Mock 录音失败事实 v0.1(项目内合同)
|
||||
|
||||
仅供单节点 P1 本地 Mock 验证;用户已批准此内部事实,不代表 SaaS/management、mixed/real 或生产协议已签收。SaaS 的 v0.1 合同及 `contracts/upstream/v1/` 不变。
|
||||
|
||||
## 消息与身份
|
||||
|
||||
- 复用同一 mTLS AgentControl listener 的 `ReportExecutionEvent`,`ExecutionFact.kind=FACT_KIND_RECORDING_PROGRESS`;Mock V3 拒绝其它旧分散执行事件。`payload_json` 必须满足 `local-mock-recording-failure-v0.1.schema.json`,编码后最多 4096 字节;不得包含上传 TOKEN、签名 URL、凭据、音频、完整对话或供应商响应正文。
|
||||
- `fact_id` 为一次生成、写入 Agent 文件日志的 UUID v4;`content_sha256` 是原始 `payload_json` 字节的 SHA-256。`source_boot_id`、`source_sequence`(正整数)及观察时间和事实一起持久化。重启后不重建事实身份或时间;每次请求的 `RequestMeta` 使用**当前已激活**的 Agent/Cell/boot/Dispatcher epoch/session generation。旧 `source_boot_id` 可与新请求的 boot 不同,不能因此丢弃原事实;无当前会话则保留事实、拒绝上报。
|
||||
- Dispatcher 先核验 mTLS、允许的 Agent、当前未过期节点会话、已发唯一外呼决定及已确认结束的 Mock 通话,再核对任务绑定、`recording_id` 与 `upload_id`。签收前将事实身份与 `unavailable` 结果在同一 SQLite 事务持久化,并使唯一 `call.result` 可靠入 outbox;途中失败由原事实/结果恢复,不重拨、不重 PUT。相同事实幂等,不同事实或矛盾状态拒绝。事实签收不等于 MQ publisher confirm,更不等于 SaaS 应用签收。
|
||||
|
||||
## 失败与期限
|
||||
|
||||
- Agent 仅上报明确失败:授权无效/过期、已确认的 PUT 拒绝、录音文件明确缺失或已证实的 checksum 不匹配。HTTP 401/403 → `upload_authorization_failed`,明确的其它 4xx(不含 408/429)→ `upload_failed`;本地授权过期 → `upload_authorization_expired`,checksum 不匹配 → `checksum_mismatch`。发送前先持久记录;报告失败或回执不匹配只重送同一事实,不重 PUT。
|
||||
- 传输未知、HTTP 408/429/5xx 不冒称明确失败;保留未知占用/录音等待,超过通话结束后 15 分钟由 Dispatcher 以 `upload_timeout` 收口。首次到达截止点后的新失败事实拒绝;截止点前已持久化的同一事实在重启后仍可幂等重送。`upload_timeout` 与 `deadline_exceeded` 只由 Dispatcher 生成,不允许 Agent 上报。
|
||||
- 通话确认结束即释放执行额度,不等待上传或 MQ;未知通话仍占用。外发只有一份最终 `call.result`,不得并行生成 `recording.uploaded` 或旧分散通话事件。8,388,608 字节以上的最终消息完整保留、持久阻塞并记录 event_id、字节数和 SHA-256,不截断或拆分。
|
||||
|
||||
当前默认 Mock originator 只模拟无应答,不生成真实录音;已上传及明确失败由隔离 Mock 测试注入,不据此宣称真实 OSS、供应商或 SaaS 验证通过。
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/proposals/local-mock-recording-failure-v0.1.schema.json",
|
||||
"title": "Local Mock recording failure fact v0.1 (project proposal only)",
|
||||
"description": "Payload of AgentControl.ReportExecutionEvent FACT_KIND_RECORDING_PROGRESS for an explicitly failed Mock recording upload. Maximum encoded payload: 4096 bytes. Not a SaaS event or mixed/real contract.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "upload_id", "recording_id", "error_code"],
|
||||
"properties": {
|
||||
"schema_version": {"const": "local-mock-recording-failure.v0.1"},
|
||||
"upload_id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9._:-]+$"},
|
||||
"recording_id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9._:-]+$"},
|
||||
"error_code": {
|
||||
"type": "string",
|
||||
"enum": ["upload_authorization_failed", "upload_authorization_expired", "upload_failed", "checksum_mismatch"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"contract_version": "project-saas-dispatcher.v0.1",
|
||||
"amqp_protocol": "0-9-1",
|
||||
"exchanges": {
|
||||
"commands": {"name": "agent-call.dispatchers.v3", "type": "topic", "durable": true},
|
||||
"results": {"name": "agent-call.saas.v3", "type": "topic", "durable": true},
|
||||
"dead_letter": {"name": "agent-call.dead-letter.v3", "type": "topic", "durable": true}
|
||||
},
|
||||
"queues": {
|
||||
"task": {
|
||||
"owner": "saas",
|
||||
"exchange": "agent-call.dispatchers.v3",
|
||||
"routing_key": "d.<dispatcher_id>.task.<task_id>.in",
|
||||
"binding_key": "same as routing_key",
|
||||
"queue_name": "agent-call.d.<dispatcher_id>.task.<task_id>.v3",
|
||||
"durable": true,
|
||||
"exclusive": false,
|
||||
"auto_delete": false,
|
||||
"dead_letter_exchange": "agent-call.dead-letter.v3",
|
||||
"dead_letter_routing_key": "d.<dispatcher_id>.dead-letter"
|
||||
},
|
||||
"control": {
|
||||
"owner": "saas",
|
||||
"exchange": "agent-call.dispatchers.v3",
|
||||
"routing_key": "d.<dispatcher_id>.control.in",
|
||||
"binding_key": "same as routing_key",
|
||||
"queue_name": "agent-call.d.<dispatcher_id>.control.v3",
|
||||
"durable": true,
|
||||
"exclusive": false,
|
||||
"auto_delete": false,
|
||||
"dead_letter_exchange": "agent-call.dead-letter.v3",
|
||||
"dead_letter_routing_key": "d.<dispatcher_id>.dead-letter"
|
||||
},
|
||||
"result": {
|
||||
"owner": "saas",
|
||||
"exchange": "agent-call.saas.v3",
|
||||
"routing_key": "d.<dispatcher_id>.out",
|
||||
"binding_key": "same as routing_key",
|
||||
"queue_name": "agent-call.saas.d.<dispatcher_id>.v3",
|
||||
"durable": true,
|
||||
"exclusive": false,
|
||||
"auto_delete": false
|
||||
},
|
||||
"dead_letter": {
|
||||
"owner": "saas",
|
||||
"exchange": "agent-call.dead-letter.v3",
|
||||
"routing_key": "d.<dispatcher_id>.dead-letter",
|
||||
"binding_key": "same as routing_key",
|
||||
"queue_name": "agent-call.d.<dispatcher_id>.dead-letter.v3",
|
||||
"durable": true,
|
||||
"exclusive": false,
|
||||
"auto_delete": false
|
||||
}
|
||||
},
|
||||
"limits": {
|
||||
"task_id_pattern": "^[A-Za-z0-9_-]{1,128}$",
|
||||
"dispatcher_id_format": "lowercase canonical UUID v4",
|
||||
"max_task_queues_per_dispatcher_including_draining": 256,
|
||||
"max_routing_key_bytes": 255,
|
||||
"max_queue_name_bytes": 255,
|
||||
"task_routing_key_max_bytes": 175,
|
||||
"task_queue_name_max_bytes": 186,
|
||||
"json_message_body_max_bytes": 8388608
|
||||
},
|
||||
"publishing": {
|
||||
"messages_persistent": true,
|
||||
"mandatory": true,
|
||||
"publisher_confirms": true,
|
||||
"mark_outbox_delivered_only_after_no_return_and_positive_confirm": true,
|
||||
"positive_confirm_is_saas_application_receipt": false,
|
||||
"retry_reuses_same_identity_and_exact_body": true
|
||||
},
|
||||
"ownership": {
|
||||
"saas_creates_binds_and_retires_all_queues": true,
|
||||
"dispatcher_may_consume_task_and_control_queues": true,
|
||||
"dispatcher_may_declare_bind_or_delete_queues": false
|
||||
},
|
||||
"inbound_processing": {
|
||||
"ack_after_durable_inbox_and_state_commit": true,
|
||||
"expired_not_after_is_application_rejection_not_broker_ttl": true,
|
||||
"invalid_json_or_schema_nack_requeue_false_to_dead_letter_queue": true,
|
||||
"redelivered_call_execute_must_not_originate_again": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/proposals/task-discovery-v0.1-proposal.schema.json",
|
||||
"title": "Project-local contract: Dispatcher task discovery responses; external compatibility unverified",
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/snapshot_response"},
|
||||
{"$ref": "#/$defs/changes_response"},
|
||||
{"$ref": "#/$defs/error_response"}
|
||||
],
|
||||
"$defs": {
|
||||
"dispatcher_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
},
|
||||
"cursor": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512,
|
||||
"$comment": "Opaque SaaS change watermark; never compare to task_id or infer numeric order."
|
||||
},
|
||||
"task_fields": {
|
||||
"type": "object",
|
||||
"required": ["task_id", "tenant_id", "tenant_key", "status", "task_revision"],
|
||||
"properties": {
|
||||
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
|
||||
"tenant_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||
"tenant_key": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 196,
|
||||
"$comment": "Also validate the existing UTF-8 byte limit and tenant_id mapping in business logic; tenant_key is not part of any queue or routing key."
|
||||
},
|
||||
"status": {"enum": ["running", "paused", "stopped", "finished"]},
|
||||
"task_revision": {"type": "integer", "minimum": 1}
|
||||
}
|
||||
},
|
||||
"queue": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["exchange", "routing_key", "binding_key", "queue_name"],
|
||||
"properties": {
|
||||
"exchange": {"const": "agent-call.dispatchers.v3"},
|
||||
"routing_key": {"type": "string", "maxLength": 175, "pattern": "^d\\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\.task\\.[A-Za-z0-9_-]{1,128}\\.in$"},
|
||||
"binding_key": {"type": "string", "maxLength": 175, "pattern": "^d\\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\.task\\.[A-Za-z0-9_-]{1,128}\\.in$"},
|
||||
"queue_name": {"type": "string", "maxLength": 186, "pattern": "^agent-call\\.d\\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\.task\\.[A-Za-z0-9_-]{1,128}\\.v3$"}
|
||||
},
|
||||
"$comment": "SaaS-created and managed queue address; D only consumes. Runtime derives names from dispatcher_id/task_id, requires binding_key = routing_key, and rejects mismatches. Max 256 assigned or draining task queues per D."
|
||||
},
|
||||
"snapshot_task": {
|
||||
"allOf": [
|
||||
{"$ref": "#/$defs/task_fields"},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["queue"],
|
||||
"properties": {"queue": {"$ref": "#/$defs/queue"}}
|
||||
}
|
||||
],
|
||||
"unevaluatedProperties": false
|
||||
},
|
||||
"changed_task": {
|
||||
"allOf": [
|
||||
{"$ref": "#/$defs/task_fields"},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["cursor", "operation", "queue"],
|
||||
"properties": {
|
||||
"cursor": {"$ref": "#/$defs/cursor"},
|
||||
"operation": {"enum": ["assigned", "updated"]},
|
||||
"queue": {"$ref": "#/$defs/queue"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"unevaluatedProperties": false
|
||||
},
|
||||
"removed_task": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["cursor", "operation", "task_id", "tenant_id", "tenant_key"],
|
||||
"properties": {
|
||||
"cursor": {"$ref": "#/$defs/cursor"},
|
||||
"operation": {"const": "removed"},
|
||||
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
|
||||
"tenant_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||
"tenant_key": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 196,
|
||||
"$comment": "Also validate the existing UTF-8 byte limit and tenant_id mapping in business logic."
|
||||
}
|
||||
}
|
||||
},
|
||||
"snapshot_response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "dispatcher_id", "mode", "snapshot_id", "cursor", "tasks", "next_page_token"],
|
||||
"properties": {
|
||||
"schema_version": {"const": "task-discovery.v0.1-proposal"},
|
||||
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
|
||||
"mode": {"const": "snapshot"},
|
||||
"snapshot_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||
"cursor": {"$ref": "#/$defs/cursor"},
|
||||
"tasks": {"type": "array", "maxItems": 100, "items": {"$ref": "#/$defs/snapshot_task"}},
|
||||
"next_page_token": {"type": ["string", "null"], "minLength": 1, "maxLength": 512}
|
||||
},
|
||||
"$comment": "Each page has at most 100 tasks. All pages retain the first page's snapshot_id/cursor; stage and persist all pages before atomically replacing the active snapshot and advancing the cursor."
|
||||
},
|
||||
"changes_response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "dispatcher_id", "mode", "from_cursor", "next_cursor", "changes", "next_page_token"],
|
||||
"properties": {
|
||||
"schema_version": {"const": "task-discovery.v0.1-proposal"},
|
||||
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
|
||||
"mode": {"const": "changes"},
|
||||
"from_cursor": {"$ref": "#/$defs/cursor"},
|
||||
"next_cursor": {"$ref": "#/$defs/cursor"},
|
||||
"changes": {
|
||||
"type": "array",
|
||||
"maxItems": 100,
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/changed_task"},
|
||||
{"$ref": "#/$defs/removed_task"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"next_page_token": {"type": ["string", "null"], "minLength": 1, "maxLength": 512}
|
||||
},
|
||||
"$comment": "Each page has at most 100 changes. All pages retain from_cursor/next_cursor for the same window; apply changes in response order and persist all pages before atomically advancing next_cursor. Cursor and page-token continuity are cross-response semantics."
|
||||
},
|
||||
"error_response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "resource", "error"],
|
||||
"properties": {
|
||||
"schema_version": {"const": "task-discovery.v0.1-proposal"},
|
||||
"resource": {"const": "error"},
|
||||
"error": {
|
||||
"type": "object",
|
||||
"$comment": "Local HTTP mapping: invalid_cursor/invalid_page_token=400, unauthorized=401, dispatcher_not_authorized=403, cursor_expired/snapshot_expired=410, service_unavailable=503. Status is transport metadata and is not part of the JSON body; external SaaS compatibility is unverified.",
|
||||
"additionalProperties": false,
|
||||
"required": ["code", "message"],
|
||||
"properties": {
|
||||
"code": {"enum": ["invalid_cursor", "cursor_expired", "snapshot_expired", "invalid_page_token", "unauthorized", "dispatcher_not_authorized", "service_unavailable"]},
|
||||
"message": {"type": "string", "minLength": 1, "maxLength": 256}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/proposals/task-discovery-v0.2-proposal.schema.json",
|
||||
"title": "Project-local single-response Dispatcher task discovery; external compatibility unverified",
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/snapshot_response"},
|
||||
{"$ref": "#/$defs/changes_response"},
|
||||
{"$ref": "#/$defs/error_response"}
|
||||
],
|
||||
"$defs": {
|
||||
"dispatcher_id": {
|
||||
"type": "string", "format": "uuid",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
},
|
||||
"cursor": {
|
||||
"type": "string", "minLength": 1, "maxLength": 512,
|
||||
"$comment": "Opaque SaaS change watermark; never compare numerically or derive from task_id."
|
||||
},
|
||||
"task": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["task_id", "tenant_id", "tenant_key", "status", "task_revision"],
|
||||
"properties": {
|
||||
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
|
||||
"tenant_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||
"tenant_key": {"type": "string", "minLength": 1, "maxLength": 196,
|
||||
"$comment": "Also enforce the UTF-8 byte limit and tenant_id mapping in business logic."},
|
||||
"status": {"enum": ["running", "paused", "stopped", "finished"]},
|
||||
"task_revision": {"type": "integer", "minimum": 1}
|
||||
}
|
||||
},
|
||||
"change": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["cursor", "operation", "task_id", "tenant_id", "tenant_key", "status", "task_revision"],
|
||||
"properties": {
|
||||
"cursor": {"$ref": "#/$defs/cursor"},
|
||||
"operation": {"enum": ["assigned", "updated"]},
|
||||
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
|
||||
"tenant_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||
"tenant_key": {"type": "string", "minLength": 1, "maxLength": 196},
|
||||
"status": {"enum": ["running", "paused", "stopped", "finished"]},
|
||||
"task_revision": {"type": "integer", "minimum": 1}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["cursor", "operation", "task_id", "tenant_id", "tenant_key"],
|
||||
"properties": {
|
||||
"cursor": {"$ref": "#/$defs/cursor"},
|
||||
"operation": {"const": "removed"},
|
||||
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
|
||||
"tenant_id": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||
"tenant_key": {"type": "string", "minLength": 1, "maxLength": 196}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"snapshot_response": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["schema_version", "dispatcher_id", "cursor", "tasks"],
|
||||
"properties": {
|
||||
"schema_version": {"const": "task-discovery.v0.2-proposal"},
|
||||
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
|
||||
"cursor": {"$ref": "#/$defs/cursor"},
|
||||
"tasks": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/task"}}
|
||||
},
|
||||
"$comment": "No pagination or queue address in the body. Persist the entire consistent response before advancing the cursor."
|
||||
},
|
||||
"changes_response": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["schema_version", "dispatcher_id", "next_cursor", "changes"],
|
||||
"properties": {
|
||||
"schema_version": {"const": "task-discovery.v0.2-proposal"},
|
||||
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
|
||||
"next_cursor": {"$ref": "#/$defs/cursor"},
|
||||
"changes": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/change"}}
|
||||
},
|
||||
"$comment": "No change: changes=[] and next_cursor=request after. An unrepresentable complete change set requires HTTP 410 cursor_expired, never a partial 200."
|
||||
},
|
||||
"error_response": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["schema_version", "resource", "error"],
|
||||
"properties": {
|
||||
"schema_version": {"const": "task-discovery.v0.2-proposal"},
|
||||
"resource": {"const": "error"},
|
||||
"error": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["code", "message"],
|
||||
"properties": {
|
||||
"code": {"enum": ["invalid_cursor", "cursor_expired", "unauthorized", "dispatcher_not_authorized", "service_unavailable"]},
|
||||
"message": {"type": "string", "minLength": 1, "maxLength": 256}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
## 1. 范围、权威与状态
|
||||
|
||||
本文件保留现有外部命令/事件与内部职责目录,并明确本轮适用范围:**P1为1 Agent/1 Asterisk/单 Cell、至少3家SIP trunk 的契约与协议 fixture、单租户、静态配置、ASR-only与完整AI双模式;真实 SaaS/MQ 联调、双节点、第二 Cell、第二租户和生产切换延期第二阶段。** 全量目录不等于本轮全部实现;当前只交付设计,运行通过记录另见验收证据。
|
||||
本文件保留现有外部命令/事件与内部职责目录,并明确本轮适用范围:**P1为1 Agent/1 Asterisk/单 Cell、至少3家SIP trunk 的契约与协议 fixture、单租户、静态配置、ASR-only与完整AI双模式;真实 SaaS/MQ 联调、双节点、第二 Cell、第二租户和生产切换延期第二阶段。** 本地 P1 实施按配置读取计划推进;全量目录不等于本轮全部实现,运行通过记录另见验收证据。
|
||||
|
||||
- **本轮用户已确认SaaS↔Dispatcher全部交互只经RabbitMQ专用Topic,禁止双方HTTP。每个D有全局唯一ID及独立接收Topic/队列,不能共享队列抢收指定D的消息。** 精确身份/拓扑/消息/关联待新版合同冻结,见[现行 MQ 机器契约](../../contracts/upstream/v1/mq.schema.json)及[第三方时序](../thirds/第三方对接事件与请求消费顺序_v0.1.md)与[归档计划§1.2/§8.2](../archive/plan-0918.md)。用户后续批准的**配置只读 HTTP**目标仅见[新计划](../plan-config-read-v0.1.md),尚未替换本现行契约。P1仍单活D;D1/D2仅用于本地路由隔离fixture,不开发多D协调。
|
||||
- **现行外部已发布 SaaS↔Dispatcher 契约仍只经 RabbitMQ 专用 Topic,禁止双方 HTTP。每个 D 有全局唯一 ID 及独立接收 Topic/队列,不能共享队列抢收指定 D 的消息。** 精确身份/拓扑/消息/关联见[现行 MQ 机器契约](../../contracts/upstream/v1/mq.schema.json)和[归档计划§1.2/§8.2] (../archive/plan-0918.md)。本轮 P1 项目内目标由[新计划](../plan-config-read-v0.1.md)与[第三方对接契约](../thirds/第三方对接事件与请求消费顺序_v0.1.md)定义:四条只读 GET、简化命令/控制及单份最终 `call.result`;F01/F07 Schema/正反例/hash 与 Mock C 通过后即可本地实施,不等待外部签收/连通。真实 SaaS 兼容性仍未验证;本地 C 不改变现行外部契约,也不代表生产验收。P1仍单活 D;D1/D2仅用于本地路由隔离 fixture,不开发多 D 协调。
|
||||
- 旧外部业务字段以《SaaS交互_OpenAPI与MQ契约规划_v0.1.md》正文v1.0及固定包记录为语义来源;其中HTTP传输和旧租户路由已被MQ-only修订替代。旧OpenAPI/哈希只作对照,不手改源包或只读索引,不把中文MQ语义当已发布字段。
|
||||
- [OpenAPI与MQ字段索引](../references/OpenAPI与MQ字段索引_v0.1.md) 是5份OpenAPI、42个HTTP操作、115个命名组件及2份JSON Schema的只读机器提取快照,记录源哈希,不是第二套手写Schema。
|
||||
- 下文 **“现有契约”** 不允许自行改字段/语义;**“内部草案”** 是待批准的gRPC方法/数据模型,不冒充已有OpenAPI;**“缺口”** 明确阻塞相应实现/验收。
|
||||
@@ -29,10 +29,10 @@
|
||||
|
||||
### 1.2 分期与本轮传输修订
|
||||
|
||||
- P1保留call.execute、控制/查询/整体补传/录音协调的既有业务语义及8种业务事件;旧7条HTTP路径全部废弃为接入方式,对应请求响应改经MQ。整体补传恢复原结果,不重新投call.execute执行。
|
||||
- **现行外部基线**保留旧 call.execute、控制/查询/整体补传/录音协调语义及8种业务事件;旧7条HTTP路径不再作为现行接入方式,对应请求响应经MQ。此项记录现行 v1,不是本轮本地目标。
|
||||
- 42操作/115组件仅为上游目录;不把管理平台30操作移入Dispatcher。按实际入口及引用闭包生成校验,来源包/只读索引仍完整留存,不通过删Schema缩小范围。
|
||||
- 单租户仅指启用策略:保留tenant_key精确路由、租户独立队列/复合幂等键、有界窗口和单 Cell 全局配额;双租户公平、第二 Cell 汇总和多Dispatcher协调另立第二阶段。
|
||||
- P1不新增任务MQ模式字段或临时接口。MQ-only所需的控制/查询/配置/上传请求响应必须经GAP-10正式发布,不能伪装为现有8类事件或宽松透传。ASR-only表达沿GAP-08;R04/R06在线改配延后,但最后许可、控制、静态维护屏障和持久恢复不能延后。
|
||||
- 本轮本地目标按第三方对接契约使用四条只读配置/发现 GET;命令/控制/必要回执与单份最终 `call.result` 仍走 MQ,不恢复 query/replay、拆分实时文字/拒联/录音事件,也不新增任务 mode 字段。旧 MQ-only GAP-10 只描述外部 v1 基线;本地 C 不等待外部发布,真实兼容性另行记录。ASR-only表达沿GAP-08;R04/R06在线改配延后,但最后许可、控制、静态维护屏障和持久恢复不能延后。
|
||||
|
||||
## 2. 角色、传输和可靠性边界
|
||||
|
||||
|
||||
@@ -0,0 +1,936 @@
|
||||
- 本文是新版项目内字段与状态语义的说明;v0.1 文档及证据仅留历史,不作为新版运行契约。四条拟定 GET 的响应字段、路径和外部兼容性尚待真实 SaaS 核对。机器校验文件为[配置读取 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 的完整获批快照
|
||||
|
||||
请求(地址/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.1",
|
||||
"resource": "sip_config",
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
|
||||
"revision": 1,
|
||||
"snapshot_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"approved_at": "2026-09-21T08:00:00+08:00",
|
||||
"artifact": {
|
||||
"artifact_id": "artifact-cell-mock-1",
|
||||
"source_release": "mock-release-1",
|
||||
"source_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"approval_reference": "mock-approval-1",
|
||||
"cell_id": "cell-mock",
|
||||
"revision": 1,
|
||||
"config_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"mode": "mock",
|
||||
"allowed_targets": [
|
||||
"15003164745",
|
||||
"15830461047"
|
||||
],
|
||||
"trunks": [
|
||||
{
|
||||
"trunk_id": "trunk-mock",
|
||||
"provider_id": "provider-mock",
|
||||
"egress_pool_id": "egress-mock",
|
||||
"codec": "PCMA",
|
||||
"caller_profile_ids": [
|
||||
"caller-profile-mock"
|
||||
],
|
||||
"dial_prefix": "",
|
||||
"enabled": true,
|
||||
"sip_endpoint_ref": "sip-endpoint-mock",
|
||||
"credential_ref": null,
|
||||
"media_profile_id": "pcma-8k"
|
||||
}
|
||||
],
|
||||
"media_profiles": {
|
||||
"pcma-8k": {
|
||||
"format": "alaw",
|
||||
"sample_rate_hz": 8000,
|
||||
"channels": 1,
|
||||
"payload_type": 8
|
||||
}
|
||||
},
|
||||
"load_evidence": null
|
||||
},
|
||||
"trunk_details": [
|
||||
{
|
||||
"trunk_id": "trunk-mock",
|
||||
"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.1`、资源 `sip_config`;`dispatcher_id`:只能与发起请求的 D 相同。
|
||||
- `revision/snapshot_sha256/approved_at`:整份获批快照的修订、摘要、批准时间;摘要生成规则和版本来源仍待双方确定,示例摘要仅为占位值。 **(预留,暂不验证)**
|
||||
- `artifact`:静态 Cell 制品;`artifact_id/source_release/source_digest/approval_reference` 标识制品、来源版本/摘要和批准引用;`cell_id/revision/config_sha256` 标识执行单元及制品版本;`mode` 是 mock/real 范围;`allowed_targets` 是允许的原始号码;`load_evidence` 为可空加载证据。`trunks[]` 中 `trunk_id/provider_id/egress_pool_id` 定义线路、供应商、出口;`codec` 为 PCMA;`caller_profile_ids` 为主叫引用;`dial_prefix` 仅本线路前缀;`enabled` 是否启用;`sip_endpoint_ref/credential_ref/media_profile_id` 为连接、凭据和媒体配置引用,不传实际密码。`media_profiles` 下 `format/sample_rate_hz/channels/payload_type` 定义媒体格式。
|
||||
- `trunk_details[]`:每项的 `trunk_id` 必须与 `artifact.trunks[]` 一一对应;`server_host/server_port` 为 SIP 服务端;`transport/auth_mode/registration_required` 是传输、认证和注册方式;`max_concurrent_calls` 是分配到该 D 的线路额度;`null` 代表未知,不可用于真实外呼。`caller_profiles[].caller_profile_id/caller_id` 给出主叫引用/原始标识(如含 `BD`),不可清洗成纯数字。
|
||||
- `schedule.time_zone/weekly_windows`:Asia/Shanghai 的周一至周日多时段,`start/end` 是每日左闭右开时间;空日禁止外呼。D 校验获批版本与线路完整性并自行核对生效,不能仅凭 HTTP `200` 就认为已加载。
|
||||
|
||||
### 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,不能把旧通过记录当作本版通过。外部正式版本、部署与切换仍是独立事实和授权门禁。
|
||||
@@ -1,17 +1,28 @@
|
||||
# SaaS ↔ Dispatcher:请求与通话结果消费顺序 v0.1
|
||||
|
||||
本文只描述 **SaaS 与 Dispatcher(D)**。四个配置/任务发现/租户额度 GET 与简化 `call.execute`、无 `command_id` 的 `task.control`/控制回执均是**下一版待签收草案**;现行 MQ v2 仍要求旧字段;下文“唯一通话结果 `call.result`”是**下一版本提案**,现行 Schema 和程序**尚不支持**。不能把本文的新旧示例拼接成已经可运行的单一版本。示例为非生产数据,JSON 代码块均为完整请求或返回体;字段说明写在块外。
|
||||
本文是本轮 P1 Go/Mock 实现使用的**项目内 SaaS 对接契约**。F01/F07 按本文和链接的机器 Schema 冻结后,直接用于本地实现和 Mock SaaS 验证;不等待外部 SaaS/management 签收或连通。下文“草案/拟定/待签收”仅表示真实 SaaS/management 兼容性未验证,不阻塞本地实现。真实 SaaS 当前仍可能运行 `contracts/upstream/v1`,其兼容性未验证;Mock 通过只证明本地契约,不代表真实 SaaS、management 或生产验收。不要把现行 v1 与本轮契约拼成一个线上协议。示例为非生产数据,JSON 代码块均为完整请求或返回体;字段说明写在块外。
|
||||
|
||||
路径来源:`/internal/v1/dispatcher/sip`、`/internal/v1/dispatcher/task/:task_id`、`/internal/v1/dispatcher/tasks`(含 `?after=<cursor>`)为用户给定路径;`/internal/v1/dispatcher/tenant/:tenant_id/quota` 与 `route_policy_id`、`caller_profile_id`、`allowed_trunk_ids` 是项目定义的新增路径/字段,按本文实现并在真实对接时记录兼容状态。
|
||||
|
||||
## 0. 本轮项目内实施规则
|
||||
|
||||
- 本文是字段与状态语义的唯一说明;机器校验文件为[配置读取 Schema](../contracts/config-read-v0.1.schema.json)、[任务发现 Schema](../contracts/task-discovery-v0.1-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` 每页最多 100 条;分页期间固定 snapshot/window,只有全部页面成功持久化后才推进游标。`cursor`/page token 均为不透明值;过期返回 HTTP 410 和 `cursor_expired`/`snapshot_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 的更新/停止也能由变更游标发现。路径、字段、30 秒时限尚待 SaaS 签收。 |
|
||||
| 2(本地目标契约) | D 启动/重启 `GET /internal/v1/dispatcher/tasks` 取得本 D 的任务全量快照与变更游标,运行中每 30 秒 `GET /internal/v1/dispatcher/tasks?after=<cursor>`;SaaS 创建任务时先建好任务队列/绑定再发布。 | D 发现新任务后仅消费 SaaS 已创建的队列;D 离线期间消息可留在队列,较小任务 ID 的更新/停止也能由变更游标发现。30 秒是轮询周期,不是发现 SLA;真实 SaaS 兼容性未验证。 |
|
||||
| 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`(**拟定 MQ 最终事件**)。 | SaaS 只处理这条最终的通话详情,按 `event_id` 去重;录音以 `bucket/object_key` 关联,不接收文件、不提供上传会话或验证结果。上传失败/超时的最终收口见 §4.2,时限尚未签收。 |
|
||||
| 5 | 通话终结且录音已上传 OSS,D 投递一条本地目标事件 `call.result`。 | SaaS 只处理这条最终的通话详情,按 `event_id` 去重;录音以 `bucket/object_key` 关联,不接收文件、不提供上传会话或验证结果。上传失败/超时按 §0 和 §4.2 的 15 分钟规则收口。 |
|
||||
|
||||
### 1.1 现行 MQ 地址与 JSON 字段不是一回事
|
||||
|
||||
@@ -31,24 +42,30 @@ D→SaaS exchange: agent-call.saas.v2
|
||||
|
||||
例如 §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` 中**;JSON 的 `dispatcher_id/tenant_key` 是接收后核验身份。当前消费者启动需要 `--tenant-key` 且由 D 声明租户队列;这不是动态任务发现能力。即使 D 离线,只要队列/绑定已由有权一方预先创建并且消息持久入队,重启后仍可消费;若发布时队列不存在,事后建队**不能倒灌旧消息**,SaaS 要保留原消息并确认就绪后重投,mandatory 返回与 publisher confirm 应同时核对。`dispatcher_id` 是唯一 UUID v4;`tenant_key` 保留原值。仅 publisher confirm **不等于** SaaS 已处理。
|
||||
|
||||
### 1.2 下一版任务队列 KEY(项目示意,尚无发布的机器合同)
|
||||
### 1.2 本轮任务队列与事件路由(项目内 v0.1 契约)
|
||||
|
||||
**硬边界:任务队列与绑定只由 SaaS 创建/维护/退役;D 只消费,不声明、创建、绑定或删除。** SaaS 必须先确认持久队列/精确绑定就绪,再发布 persistent 命令。下面的 `v3-draft` 名称仅展示规则,**不是现网 exchange/queue,也不是可直接上线的名字**:
|
||||
**硬边界:所有 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-draft
|
||||
routing key: d.<dispatcher_id>.task.<task_id>.in
|
||||
binding key: d.<dispatcher_id>.task.<task_id>.in
|
||||
queue: agent-call.d.<dispatcher_id>.task.<task_id>.v3-draft
|
||||
D 仅按 SaaS /tasks 清单中的 queue 名称开始消费。
|
||||
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
|
||||
```
|
||||
|
||||
`dispatcher_id` 选唯一 D,`task_id` 选该 D 的**一项任务**,`.in` 区分入站;`tenant_key` **不再参与任务队列 KEY**,仍在消息 JSON 和 `/tasks` 结果中,用于租户归属核验及跨任务汇总并发。若 task_id 有重复、点号/通配符或超长,不能直接照拼:ID 唯一性、段格式、完整 key/queue 长度及最终版本名必须先由 F07 冻结。`task.control` 另走 SaaS 创建的 **D 专用控制队列**,不能排在某个任务的呼叫积压之后;命令回执和最终结果回 SaaS 的具体新路由同样待 F07 发布,不套用这些示意名称。暂停/停止靠任务状态,**不是 KEY 中有 task_id 就会自动清空队列**;暂停不丢积压,恢复后继续消费;已停止任务的未接纳旧命令静默消费并 ACK,不产生逐条回执/最终结果,但本地计数和错误可查。
|
||||
所有业务队列 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。
|
||||
|
||||
## 2. D ← SaaS:只读配置与任务发现(拟定,非现网)
|
||||
`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。
|
||||
|
||||
拟定的四个 GET 均**无请求 JSON 体**,统一使用 `X-DISPATCHER-id`(全局唯一 D UUID)和 `X-DISPATCHER-SECRET-KEY`(受控密钥,绝不写入文档/日志)。具体 SaaS 地址及 Header 校验/轮换仍待签收。SIP 返回本 D 全量;单任务按路径中的 `task_id` 查询,SaaS 必须核对归属 D 与原值 `tenant_key`,任务发现则按 D 返回归属清单。**不使用 ETag、If-None-Match 或 304**:任务与 SIP 配置约 60 秒缓存到期时 GET 完整 200 响应,失败只停新准入,已接纳执行保持绑定快照;MQ 控制不等待配置缓存。`tasks` 的每 30 秒增量轮询另见 §2.5。
|
||||
## 2. D ← SaaS:只读配置与任务发现(本地目标契约,非现网接口)
|
||||
|
||||
四个 GET 均**无请求 JSON 体**,统一使用 `X-DISPATCHER-id`(全局唯一 D UUID)和 `X-DISPATCHER-SECRET-KEY`(受控密钥,绝不写入文档/日志)。本地 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 的完整获批快照
|
||||
|
||||
@@ -427,26 +444,26 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
|
||||
**字段说明/消费动作:**字段与 2.2 相同,但 `agent.config.mode=asr_only`,**没有** LLM、提示词或 TTS 对象;配置内外 `agent_version_id` 必须一致。只能按授权的识别配置执行,不应将未提供的字段填成默认值。
|
||||
|
||||
### 2.4 SIP 或任务:错误返回(拟定;HTTP 状态码未定)
|
||||
### 2.4 SIP 或任务:错误返回(`resource_not_found`,HTTP 404,项目内规则)
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "config-read.v0.1",
|
||||
"resource": "error",
|
||||
"error": {
|
||||
"code": "not_assigned",
|
||||
"code": "resource_not_found",
|
||||
"message": "Task is not assigned to this Dispatcher."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明/消费动作:**`schema_version/resource` 标识草案错误对象;`error.code` 是机器可读错误代码(示例 `not_assigned` 表示该任务不归此 D),`error.message` 是可读说明,不含密钥。D 不得将失败当作空任务/无限制或使用过期配置接新呼叫;不能自动回退至 MQ 配置通道。
|
||||
**字段说明/消费动作:**`schema_version/resource` 标识项目内错误对象;`error.code` 是机器可读错误代码(`resource_not_found` 同时表示任务不存在或不归此 D),`error.message` 是可读说明,不含密钥。本地将此错误映射为 HTTP 404;真实 SaaS 是否采用相同状态码尚未验证。D 不得将失败当作空任务/无限制或使用过期配置接新呼叫;不能自动回退至 MQ 配置通道。
|
||||
|
||||
### 2.5 D ← SaaS:动态任务发现(**下一版草案,现行无此接口/Schema**)
|
||||
### 2.5 D ← SaaS:动态任务发现(本轮项目内契约;现行外部接口未验证)
|
||||
|
||||
第三条只读 HTTP 接口是 `GET /internal/v1/dispatcher/tasks`,与 §2.1/§2.2 两条配置响应分开。D 启动/重启时不带 `after` 读取**一致全量快照 + 游标**,运行中**每 30 秒** `GET /internal/v1/dispatcher/tasks?after=<cursor>` 读取针对本 D 的变更。`after` 是 SaaS 的变更水位,**不是最大 `task_id`**;旧任务的暂停、停止、改派也会返回。路径和两个 Header 作为本轮需求输入,响应 JSON、错误码/分页仍是项目草案,待 SaaS/F07 签收;30 秒是轮询频率,不是端到端 30 秒发现保证,也不同于单任务配置约 60 秒缓存。所有 GET 无请求体,密钥只由受控部署注入。
|
||||
第三条只读 HTTP 接口是 `GET /internal/v1/dispatcher/tasks`,与 §2.1/§2.2 两条配置响应分开;机器约束见[任务发现 Schema](../contracts/task-discovery-v0.1-proposal.schema.json)。D 启动/重启时不带 `after` 读取**一致全量快照 + 游标**,运行中**每 30 秒** `GET /internal/v1/dispatcher/tasks?after=<cursor>` 读取针对本 D 的变更。`after` 是 SaaS 的变更水位,**不是最大 `task_id`**;旧任务的暂停、停止、改派也会返回。每页最多 100 条;全量续页使用 `?snapshot_id=<snapshot_id>&page_token=<opaque_token>`,增量续页使用 `?after=<原始游标>&page_token=<opaque_token>`。全量所有页的 `snapshot_id/cursor` 必须固定;同一增量窗口的 `from_cursor/next_cursor` 必须固定,变更按 SaaS 返回顺序应用。只有全部页已持久化后才能原子推进游标。所有 GET 无请求体,密钥只由受控部署注入。成功为 HTTP 200;无效游标/page token 为 400,身份无效为 401、D 无权为 403,`cursor_expired`/`snapshot_expired` 为 410,临时不可用为 503。遇到 410 或分页连续性错误时不得推进游标,暂停新接纳并重取完整快照。30 秒是轮询频率,不是端到端发现保证,也不同于单任务配置约 60 秒缓存。当前正文 JSON 块是项目正例,额外/未知字段拒绝例见[非法队列字段样例](../contracts/examples/task-discovery-invalid-queue-property-v0.1.json);外部兼容性未验证。
|
||||
|
||||
### 2.5.1 启动或重启:全量快照(200,拟定)
|
||||
### 2.5.1 启动或重启:全量快照(HTTP 200,本地目标)
|
||||
|
||||
```http
|
||||
GET /internal/v1/dispatcher/tasks HTTP/1.1
|
||||
@@ -472,10 +489,10 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
"status": "running",
|
||||
"task_revision": 1,
|
||||
"queue": {
|
||||
"exchange": "agent-call.dispatchers.v3-draft",
|
||||
"exchange": "agent-call.dispatchers.v3",
|
||||
"routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in",
|
||||
"binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in",
|
||||
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.v3-draft"
|
||||
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.v3"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -485,10 +502,10 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
"status": "stopped",
|
||||
"task_revision": 3,
|
||||
"queue": {
|
||||
"exchange": "agent-call.dispatchers.v3-draft",
|
||||
"exchange": "agent-call.dispatchers.v3",
|
||||
"routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-old.in",
|
||||
"binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-old.in",
|
||||
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-old.v3-draft"
|
||||
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-old.v3"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -496,9 +513,9 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明/消费动作:**`dispatcher_id` 是被授权的目标 D;`snapshot_id` 锁定同一次全量读取,跨页不得混杂新旧状态;`cursor` 是此快照覆盖的 SaaS 任务变更水位(示例数字只是**不透明字符串**,D 不按大小比较任务 ID);`tasks[]` 列出本 D 全部归属任务及**已停止但队列仍有积压的任务**;`tenant_id` 用于读取 §2.6 额度,`tenant_key` 保留原值并与 tenant_id 一对一核验,用于同租户所有任务共享并发额度;`task_revision/status` 是任务版本和状态;`queue` 是**SaaS 已创建/绑定**的消费地址,D 只能读取,不能自行声明。`next_page_token` 非空时须在同一个 `snapshot_id` 下读完所有页再应用快照/水位,分页传递机制待签收。
|
||||
**字段说明/消费动作:**`dispatcher_id` 是被授权的目标 D;`snapshot_id` 锁定同一次全量读取,跨页不得混杂新旧状态;`cursor` 是此快照覆盖的 SaaS 任务变更水位(示例数字只是**不透明字符串**,D 不按大小比较任务 ID);`tasks[]` 列出本 D 全部归属任务及**已停止但队列仍有积压的任务**;`tenant_id` 用于读取 §2.6 额度,`tenant_key` 保留原值并与 tenant_id 一对一核验,用于同租户所有任务共享并发额度;`task_revision/status` 是任务版本和状态;`queue` 是**SaaS 已创建/绑定**的消费地址,D 只能读取,不能自行声明。`next_page_token` 非空时,按 `?snapshot_id=<snapshot_id>&page_token=<next_page_token>` 续读同一快照;读完所有页后再应用快照/水位。
|
||||
|
||||
### 2.5.2 每 30 秒:增量变化(200,拟定)
|
||||
### 2.5.2 每 30 秒:增量变化(HTTP 200,本地目标)
|
||||
|
||||
```http
|
||||
GET /internal/v1/dispatcher/tasks?after=1042 HTTP/1.1
|
||||
@@ -526,10 +543,10 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
"status": "running",
|
||||
"task_revision": 1,
|
||||
"queue": {
|
||||
"exchange": "agent-call.dispatchers.v3-draft",
|
||||
"exchange": "agent-call.dispatchers.v3",
|
||||
"routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-b.in",
|
||||
"binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-b.in",
|
||||
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-b.v3-draft"
|
||||
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-b.v3"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -541,10 +558,10 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
"status": "stopped",
|
||||
"task_revision": 2,
|
||||
"queue": {
|
||||
"exchange": "agent-call.dispatchers.v3-draft",
|
||||
"exchange": "agent-call.dispatchers.v3",
|
||||
"routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in",
|
||||
"binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in",
|
||||
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.v3-draft"
|
||||
"queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.v3"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -554,7 +571,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
|
||||
**字段说明/消费动作:**`from_cursor` 对应请求的 `after`,`changes[].cursor` 是 SaaS 为本 D 变更生成的顺序水位,`next_cursor` 是成功处理整份回复后的下一次 `after`;`assigned` 为新归属、`updated` 为旧任务版本/状态变化。**任务 `task-a` 的 ID 比新任务旧,却仍被增量返回**,这正是不能用最大任务 ID 当游标的原因。先由 SaaS 创建/绑定任务队列并确认 ready,才能把 `assigned` 返回且开始发布;D 只消费。`stopped` 持久生效后 D 继续读该任务未接纳积压、静默 ACK,不拨号、不逐条回传,也不删除队列;本地日志/计数保留。暂停/停止命令另走 SaaS 创建的 D 控制队列;30 秒任务清单轮询**不能代替即时控制**。同一租户 `task-a`、`task-b` 共同占用 `tenant-a` 额度。D 持久应用变更后才持久推进游标;分页时读完连续页,不得跳过未处理页。
|
||||
|
||||
### 2.5.3 任务改派/退役(200,拟定;与停止不同)
|
||||
### 2.5.3 任务改派/退役(HTTP 200,本地目标;与停止不同)
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -576,9 +593,9 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明/消费动作:**`removed` 是 SaaS 确认此 D 不再消费该任务的撤销记录(tombstone),**不是** `stop` 一到就立刻删除队列。必须已停止新发布、旧队列积压和未 ACK 消息处理完毕,且改派时确认旧 D 没有未知执行后再终结旧所有权;具体握手/退役合同待签收。D 只停止消费,不负责删队列;队列生命周期仍归 SaaS。
|
||||
**字段说明/消费动作:**`removed` 是 SaaS 确认此 D 不再消费该任务的撤销记录(tombstone),**不是** `stop` 一到就立刻删除队列。必须已停止新发布、旧队列积压和未 ACK 消息处理完毕,且改派时确认旧 D 没有未知执行后再终结旧所有权;停止回执与 SaaS 删除队列的顺序按 §0 执行。D 只停止消费,不负责删队列;队列生命周期仍归 SaaS。
|
||||
|
||||
### 2.5.4 没有变更(200,拟定)
|
||||
### 2.5.4 没有变更(HTTP 200,本地目标)
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -594,7 +611,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
|
||||
**字段说明/消费动作:**SaaS 没有新变更时水位不动;D 等下一个 30 秒周期,不因空列表删除已有消费关系。
|
||||
|
||||
### 2.5.5 游标失效或缺页(错误,HTTP 状态待签收)
|
||||
### 2.5.5 游标失效或缺页(HTTP 410,项目内规则)
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -607,7 +624,11 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明/消费动作:**`cursor_expired` 表示 SaaS 已不能提供从旧游标起的连续变更;缺页、断续或快照分页不一致也应中止增量。D 不推进错误游标,停受影响任务的新接纳并重新拉一致全量快照;不能把错误当无变更或盲目根据 RabbitMQ 队列列表发现任务。真正的错误码、游标保留期/分页格式须 F07 签收。
|
||||
**字段说明/消费动作:**`cursor_expired` 表示 SaaS 已不能提供从旧游标起的连续变更;缺页、断续或快照分页不一致也应中止增量。HTTP 410 的 `cursor_expired` 或 `snapshot_expired` 均不推进游标;D 暂停新接纳,重新拉取并完整持久化一致快照后恢复。不能把错误当无变更或盲目根据 RabbitMQ 队列列表发现任务。游标保留多久由 SaaS 决定,超出时必须返回上述 410 错误,不得返回部分成功。
|
||||
|
||||
### 2.5.6 其他发现错误的 HTTP 状态(项目内规则)
|
||||
|
||||
`invalid_cursor` 和 `invalid_page_token` 返回 HTTP 400;`unauthorized` 返回 401;`dispatcher_not_authorized` 返回 403;`service_unavailable` 返回 503。HTTP 状态不写入 JSON body,错误 body 严格符合任务发现 Schema。任一失败均不得推进游标或视为空变更;D 保留已持久状态并关闭新准入,410 按 §2.5.5 重新获取并完整持久化全量快照后才能恢复。该映射是项目内规则,真实 SaaS 兼容性未验证。
|
||||
|
||||
### 2.6 D ← SaaS:按租户 ID 获取并发额度(新增项目草案)
|
||||
|
||||
@@ -654,7 +675,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
|
||||
**字段说明/消费动作:**0明确禁止新准入,不是无限额。降额时不强挂已有通话、不清未知占用,等占用低于新上限且授权有效才再接新。stop静默排空与控制不需要通话额度,不能因额度0卡住停止任务。
|
||||
|
||||
#### 2.6.3 缺失或失败:错误(HTTP 状态待签收)
|
||||
#### 2.6.3 无可用租户额度:错误(HTTP 503,项目内规则)
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -667,11 +688,11 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明/消费动作:**缺失、身份不符、过期或刷新失败关闭该租户新准入,不用任务额度或无限额兜底;已有执行依原快照处理。核实通话终结并释放执行资源就释放通话额度,**不等待录音上传或最终结果 MQ 确认**;未知通话不能释放。
|
||||
**字段说明/消费动作:**服务端无法提供有效租户份额(缺失、过期或暂不可用)时,本地返回 HTTP 503 与 `tenant_quota_unavailable`;收到 `200` 但身份与请求/任务不符时,D 拒绝并关闭该租户新准入。不得用任务额度或无限额兜底;已有执行依原快照处理。核实通话终结并释放执行资源就释放通话额度,**不等待录音上传或最终结果 MQ 确认**;未知通话不能释放。
|
||||
|
||||
## 3. SaaS → D:下一版精简业务命令(**草案,现行严格 MQ Schema 不支持**)
|
||||
## 3. SaaS → D:下一版精简业务命令(**项目内 F07 v0.1 规则;现行严格 MQ Schema 不支持**)
|
||||
|
||||
以下 JSON 均是**完整的拟定下一版 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`,本阶段不设计控制命令去重。没有这些字段后,控制的乱序、重投以及处理回执如何关联必须在 F07 由 SaaS 签收并如实验收,不能冒称当前协议已经支持。
|
||||
以下 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
|
||||
|
||||
@@ -693,7 +714,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明/消费动作:**`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。现行 MQ `2.0` 仍要求旧 payload,新版严格 Schema 和 SaaS 发布端未签收/修改前**不能直接用此消息上线**。
|
||||
**字段说明/消费动作:**`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
|
||||
|
||||
@@ -716,7 +737,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明/消费动作:**`task_id` 定位任务;`action=pause` 停止新呼叫准入;`active_call_policy=drain` 允许在途通话自然结束;`reason` 是原因说明。此版**无 `command_id`、无 `expected_task_revision`、不定义控制去重**。D 保存暂停屏障并停止消费该任务新执行,已经交付但未接纳的有界消息退回原队列,不能ACK丢弃或搬入无界本地待拨队列;恢复会继续消费原积压,无需SaaS重发。已有执行按所选策略处理,暂停控制本身有回执。
|
||||
**字段说明/消费动作:**`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
|
||||
|
||||
@@ -738,7 +759,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明/消费动作:**resume 成功就是恢复消费**原任务队列的积压**,不是等待 SaaS 重发。D 必须绕过缓存读取最新任务,状态running且归属/授权/额度/时段有效、本地未stopped,才能解除paused;每条旧命令仍校验not_after,过期明确拒绝,不延长期限或等待次日。已停止任务不可恢复。请求无编号/修订、不设计控制去重,乱序时按下方状态优先级处理。
|
||||
**字段说明/消费动作:**resume 成功就是恢复消费**原任务队列的积压**,不是等待 SaaS 重发。D 必须绕过缓存读取最新任务;仅当权威状态为 `running`、D/租户归属有效且本地从未 stopped 时解除 paused。每条旧命令仍校验 `not_after`,过期明确拒绝,不延长期限或等待次日。已停止任务不可恢复。控制无编号/修订,不去重;重复 resume 对状态幂等,但每次实际投递都可有独立回执。
|
||||
|
||||
### 3.4 停止任务:task.control / stop
|
||||
|
||||
@@ -761,11 +782,11 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明/消费动作:**`stop` 持久终止任务准入,SaaS 同步停止继续发布;D 小批量**静默消费并ACK所有尚未接纳积压**,不拨号、不发逐条 `command.result` 或 `call.result`,不申请通话额度。不是purge/delete队列,也不影响其他任务。ACK丢失、重启、额度0、配置失效后依旧排空且不补发结果;保留本地计数/错误。**停止控制本身仍有回执**;已接纳/在途通话按 `hangup` 或 `drain` 处理,并照常给真实最终结果,不能因“静默”丢弃它们。stopped 同任务ID不能resume。
|
||||
**字段说明/消费动作:**`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。
|
||||
|
||||
**配置、任务发现和控制的状态优先级(拟定):**SaaS 先持久变更权威任务状态,再发控制;D 对同任务串行处理控制/接纳。stopped不可逆,paused只能由上述有效resume解锁,旧running配置/清单不能解锁,低于已知task_revision的状态不可覆盖新状态。重启恢复本地屏障及全量时取更严格者;快照可关准入、不能擅自重开。pause/stop先关准入,最新权威状态不符/读取失败或迟到控制产生冲突时保守保持关闭并返回明确失败;需有效新resume才能恢复。不设计控制消息去重,可能多次回执;不能把MQ发布成功当控制已应用。该规则须F07双方签收,不是现行代码已保证。
|
||||
**任务发现与控制状态规则(项目内 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:外呼命令处理回执(草案,不是通话结果)
|
||||
### 3.5 D → SaaS:外呼命令处理回执(项目内 v0.1,不是通话结果)
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -790,9 +811,9 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明/消费动作:**`payload.command_id` 仅指向 §3.1 的外呼命令;`status` 区分接纳/拒绝,`execution_id` 是 D 接纳后生成的执行身份。已停止任务的未接纳积压**不发送此回执**;其他未接纳拒绝只有命令回执、不伪造通话。MQ 回执**不代表已拨号或已完成通话**,未知执行不得靠重投产生第二次呼叫;下一版字段/版本仍待 F07 签收。
|
||||
**字段说明/消费动作:**`payload.command_id` 仅指向 §3.1 的外呼命令;`status` 区分接纳/拒绝,`execution_id` 是 D 接纳后生成的执行身份。已停止任务的未接纳积压**不发送此回执**;其他未接纳拒绝只有命令回执、不伪造通话。MQ 回执**不代表已拨号或已完成通话**;按 `command_id` 持久去重,未知执行不得靠重投产生第二次呼叫。字段由项目内 Schema 校验。
|
||||
|
||||
### 3.6 D → SaaS:任务控制处理回执(草案;与外呼回执分开)
|
||||
### 3.6 D → SaaS:任务控制处理回执(项目内 v0.1;与外呼回执分开)
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -818,11 +839,11 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明/消费动作:**控制请求不带 `command_id/expected_task_revision`,回执以 `task_id/action/status/task_state` 说明任务和实际处理结果,不提供按原控制编号一对一关联,也不把 `event_id` 用作控制去重身份。SaaS 仅能据已收到的事实更新展示;对控制并发/乱序、丢失回执和重投后的最终状态判定需要 F07 明确,不能把 MQ 发布成功当控制已生效。
|
||||
**字段说明/消费动作:**控制请求不带 `command_id/expected_task_revision`,回执以 `task_id/action/status/reason_code/task_state` 说明处理事实,不提供按控制编号一对一关联,也不把 `event_id` 用作控制去重身份。D 按最新任务状态和本地终态屏障处理乱序;对同一状态的重复动作可重复回执。回执丢失时 SaaS 以最新任务 GET 和后续状态发现收敛,不能把 MQ 发布成功当控制已生效。
|
||||
|
||||
## 4. D → SaaS:唯一通话结果(**拟定新 MQ 合同,尚无已发布 Schema**)
|
||||
## 4. D → SaaS:唯一通话结果(项目内 v0.1 `call.result` 契约)
|
||||
|
||||
同一次通话只发布一种业务反馈 `call.result`:通话状态、最终转写、拒联结果、录音资产一次返回;不再将通话进度、实时文字、拒联、通话结束、录音成功/失败各自发布对外事件。**这会改变现有“实时文字/即时拒联”的产品要求,必须在新版合同与验收中明确批准;SaaS 在最终结果到达前不会获得这些反馈。**停止任务未接纳积压不产生通话事件;其它真实执行的消息仍应可靠入队,断线后按同一事件身份重投;这不是对外“补传命令”。以下三个结构均为待签收提案,不能用现行 MQ/event Schema 校验,也不能作为已上线接口。
|
||||
同一次通话只发布一种业务反馈 `call.result`:通话状态、最终转写、拒联结果、录音资产一次返回;不再将通话进度、实时文字、拒联、通话结束、录音成功/失败各自发布对外事件。**本轮按该简化实现和验收**,不要求 SaaS 在最终结果前收到实时文字或拒联;真实 SaaS 消费兼容性未验证。停止任务未接纳积压不产生通话事件;其它已接纳执行的消息可靠入队,断线后按原事件身份重投;这不是对外“补传命令”。结果结构由[最终结果 Schema](../contracts/call-result-v0.1-proposal.schema.json)严格校验,不属于现行外部 MQ v2。
|
||||
|
||||
### 4.1 录音已上传 OSS:最终成功结果
|
||||
|
||||
@@ -883,10 +904,10 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明/消费动作:**`schema_version/event_type` 是**待签收的新版本及单一通话结果类型**,现行 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` 避免重复资产。
|
||||
**字段说明/消费动作:**`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 录音上传未完成:最终异常结果(是否启用及截止时间待签收)
|
||||
### 4.2 录音上传未完成:15 分钟内收口为最终异常结果
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -937,9 +958,9 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明/消费动作:**这是**防止录音永远未上传时通话结果永久消失的待定方案**:经合同规定的有限截止时间或确知不可恢复后,`recording.status=unavailable` 且 `bucket/object_key/size_bytes/checksum_sha256=null`,`recording.error_code` 表示未得到录音资产,呼叫自身的 `reason_code` 仍为 null;不能谎称上传成功,也不能默默丢弃最终结果。`outcome` 必须反映**通话本身**而非上传成败;若通话已接通/正常结束,不得仅因录音失败就把 `outcome` 改成 `failed`。具体结果字段、期限、未上传时是否仍发一次最终结果待 SaaS 签收;未签收前不能实施或用无限等待代替错误处理。
|
||||
**字段说明/消费动作:**若录音预期存在但授权/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 正常未产生录音:无应答结果(完整独立例,拟定)
|
||||
### 4.3 正常未产生录音:无应答结果(完整独立例,项目内规则)
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -992,12 +1013,13 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥>
|
||||
|
||||
**字段说明/消费动作:**这是已接纳、已尝试但无人接听且未产生录音的呼叫;`started_at/duration_ms` 此例表示呼叫尝试起点和尝试耗时,不冒称已接通时长。正常无录音用 `not_created`,资产字段为null,确认终结后即可发送,不申请/等待上传;忙线等正常无录音同类处理,原因须与事实一致。录音本应生成却失败应为 `unavailable` 加明确阶段原因,不伪装正常无录音。普通未接纳拒绝仅有命令回执;stopped未接纳积压无回执也无最终结果,不能虚构call_id。
|
||||
|
||||
**额度与文件交付分离:**确认通话终结、执行资源释放就释放通话额度,不等待OSS或最终通知确认,未知仍占额。已产生录音才按4.1/4.2收口,上传失败有限截止时间仍待签收;完整文字汇总可能超过原MQ大小上限,F07须明确预算和失败处理,不能偷偷截断或恢复被移除的实时事件。
|
||||
**额度与文件交付分离:**确认通话终结、执行资源释放就释放通话额度,不等待 OSS 或最终通知确认,未知仍占额。已产生录音才按 4.1/4.2 收口;每条 JSON 消息体上限 8,388,608 bytes,超限持久阻塞 outbox,不截断、不拆分、不恢复实时事件。
|
||||
|
||||
## 5. 下一版本签收前不得误用
|
||||
## 5. 本地实现与外部验收边界
|
||||
|
||||
- 本文 §3/§4 都是**待签收的下一版 MQ 消息草案**,均不能直接混入现行 v2 合同;先由 SaaS 与本项目发布严格新版 Schema、正反例及新队列拓扑,再实施两端。外呼命令必须有可靠执行身份防重复拨号;控制不带编号/修订,不设计控制去重,其并发/乱序与回执关联后果必须在 F07 明确。
|
||||
- 取消对外查询与补传命令不取消 D 的持久化恢复、同一身份重投、故障对账和**未知是否已拨号时绝不重拨**。没有核实状态的内部恢复能力不得发布新版本。
|
||||
- 已产生录音的通话在上传OSS后发布含资产的最终结果;正常未产生录音用not_created并在确认终结后直接回传,不等不存在的上传。应有录音却失败/上传超时的有限期限及unavailable结构必须先签收,不无限等待;通话占用释放独立于文件上传。
|
||||
- SaaS 不再实时得知拒联及转写,会影响跨任务、跨 D 停呼与实时展示。新目标与既有产品要求冲突,须取得业务签收并修订原有验收,不能凭本文视为既有验收已通过。
|
||||
- SIP、任务及新增租户额度 HTTP 响应以[项目草案 Schema](../contracts/config-read-v0.1.schema.json)校验;`tasks` 接口、精简 `call.execute`、无编号任务控制、两种回执及新 `call.result` 均**尚无已发布 Schema**。现行 v2 仍以[`mq.schema.json`](../../contracts/upstream/v1/mq.schema.json)、[`event-payloads.schema.json`](../../contracts/upstream/v1/event-payloads.schema.json)为准,不能拿新示例冒充当前可投消息。
|
||||
- 本文及链接的 `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 验证。外部正式版本、部署与切换仍是独立事实和授权门禁。
|
||||
|
||||
Reference in New Issue
Block a user