commit 23d1b1274f47346a2df59fbf33461869a7f5d951 Author: Rogee Date: Fri Sep 11 17:47:03 2026 +0800 chore: initial project snapshot diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..23fba90 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Copy to .env and chmod 600 .env. Never commit real credentials. +# Generate: python3 -c 'import secrets; print(secrets.token_urlsafe(32))' +ASR_WEB_TOKEN= +ASR_WEB_PORT=18088 +# Empty means exact same-origin browser access. For a TLS reverse proxy set https://your-approved-domain. +PUBLIC_ORIGIN= + +BAILIAN_API_KEY= +FUNASR_API_KEY= +BAILIAN_ASR_MODELS=fun-asr-realtime +BAILIAN_WS=wss://dashscope.aliyuncs.com/api-ws/v1/inference/ +FUNASR_WS=wss://dashscope.aliyuncs.com/api-ws/v1/inference/ +VOLC_APP_KEY= +VOLC_APP_ID= +VOLC_ACCESS_TOKEN= +VOLC_RESOURCE_ID=volc.bigasr.sauc.duration +VOLC_WS=wss://openspeech.bytedance.com/api/v3/sauc/bigmodel + +# Optional Asterisk deployment: fill after verifying the approved image/version. +ASTERISK_IMAGE= +# ARI_PASSWORD and SIP_PRIMARY_PASSWORD/SIP_BACKUP_PASSWORD go to the renderer +# through the process environment/secret manager, not this committed example. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19405f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.env +.env.* +!.env.example +.venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +*.pyc +.codegraph/ +.local/ +deploy/state/ +deploy/asterisk/generated/ +services/asr-web/asr-web +*.test +coverage.out diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5c1a6d9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,89 @@ +# ai-call:部署与开发约束 + +## 目录与实施范围 + +- 项目根目录用于部署,`docs/` 保存需求、计划与验收资料,不挪回根目录。 +- 当前先实现 ASR Web 验证服务、Asterisk 配置和阿里云主机准备工具;不把这些当成完整外呼平台已上线。 +- 用户已决定**仅复用 voice_test 的 ASR**。禁止复制或启用该仓库的 LLM/TTS;新规范确认前,界面必须明确显示未启用。 +- SaaS 指令与所有业务结果均走 RabbitMQ;录音先上传 OSS,再通过 MQ 回传 OSS ID。接口/消息规范由用户制定,不能擅自改成 HTTP 业务回调。 + +## 固定资源与云部署规则 + +1. 区域为阿里云北京 `cn-beijing`。 +2. 我方对 SIP 服务商登记的公网出口白名单 IP 是 **123.56.71.98**。它不是 SIP 服务端地址,不得填入 trunk contact。 +3. 目标计算资源为阿里云竞价 ECS。必须先使用阿里云 CLI 查询 IP/EIP 与现有实例;确认没有可复用主机后,才允许按明确的实例规格、价格上限、磁盘/VSwitch/安全组/SSH KeyPair 创建。 +4. 固定 IP 是否为本账号可操作的 EIP 必须查询。找不到该 EIP/现有公网 IP 时停止,**禁止新分配任意公网 IP 冒充白名单地址**。若属于非 EIP 的实例公网 IP,只能复用经明确指定的原实例;回收后的地址保留不能保证。 +5. 不解绑或覆盖其他实例上的 EIP,不删除、停机或修改无关实例;多个候选实例、未知归属或未授权绑定一律停止。 +6. 工具默认只读计划;`--apply` 才执行创建/绑定。用户未确认预算、云凭据未配置、现有绑定不明时,不能实际消费或改绑。竞价价格上限不包含系统盘/EIP/流量费用。 +7. 创建使用持久化 ClientToken;创建后绑定失败保留实例ID供恢复,不自动再创建一台、不自动删除实例。竞价回收不等于自动迁移活动通话。 +8. 使用 `aliyun` CLI 创建竞价 ECS 进行测试时,测试结束必须确认测试实例已停止并回收/删除;只删除本次测试创建的机器,不删除、释放或解绑指定 IP/EIP。 +9. 不把 AK/Secret、ASR Key、ARI 密码、SSH 私钥放入代码、文档、日志或聊天。优先配置 CLI RAM Role/STS,运行时通过环境或受控文件注入。 + +## SIP 接入信息 + +以下为用户提供的首组 SIP 参数,供应商名称待补充;已记录不代表已完成真实线路验证。 + +| 项目 | 参数 | +| --- | --- | +| SIP 服务端 | `61.132.228.221:5060` | +| 主叫号码/标识 | `BD93205882` | +| 被叫前缀 | `7089` | +| 我方出口白名单 IP | `123.56.71.98` | + +- 主叫标识保留原值(包括 `BD`),不能按纯数字手机号清洗,也不能直接当成 Digest 认证用户名;具体 From/PAI 等字段映射仍需确认。 +- 业务原始被叫号码保持不变;使用该线路时按其规则构造 `7089<被叫号码>`,避免重复添加或把该前缀带到其他供应商线路。 +- 传输协议、IP/Digest 鉴权、是否注册、编解码及并发限制仍需供应商确认;当前生成器的 UDP/ulaw 基线不代表这些参数已经确认。 +- 目前只提供一组服务端地址,未提供独立备用地址。不能把同一地址重复填写成主备并宣称具备容灾;现有生成器要求 primary/backup,单线路启动支持尚待调整。 + +## 多 SIP 供应商方案(用户已确认,运行代码待实现) + +- 采用**供应商按需预接入 + 每次外呼动态选路**。先验证当前供应商,后续逐家新增,不等待所有供应商一次性接入完毕。 +- 供应商/线路层预先维护独立 trunk:服务端、协议、鉴权、注册、允许的主叫、被叫改写规则和并发额度。不同供应商不能共用并逐呼覆盖同一份线路配置。 +- 呼叫层只从服务端已配置且获授权的线路中选择,按该线路规则设置本次主叫与被叫;**不为每个号码重写 pjsip.conf、重载 Asterisk 或重建注册**。 +- MQ 指令不能让调用方任意注入 SIP 地址、认证凭据或越权主叫;线路选择对应的具体消息字段仍由用户制定,不擅自定接口。 +- 切换线路时从原始被叫重新应用目标线路规则,不能沿用上一家的前缀或主叫。重试/切换条件需按业务契约另行实现,不能默认重复拨打已接通的电话。 +- 现有生成器仅支持固定 primary/backup,尚非完整多供应商路由;后续支持单线路启动、可扩展线路列表及逐呼选路,不为凑齐主备而虚构供应商。 + +## 生产容量与网络架构基线(用户已确认,运行代码待实现) + +- 目标口径是**至少 1000 路同时已接通的完整 AI 通话**,包括 ASR/LLM/TTS;拨号、振铃、CPS 和故障冗余必须另行计入,不能把 1000 路在途呼叫当作验收结果。 +- 生产主方案为**多机器 + 多 EIP 直连**:每个语音 Cell(Asterisk、媒体适配和本地执行器)绑定固定出口 IP;调度器选择 `trunk_id + egress_pool_id + cell_id`,一通电话从建立到结束固定在同一 Cell/出口,不逐呼重写共享 SIP 配置。 +- 每个 Cell/出口要维护独立的供应商白名单、RTP 端口范围、并发上限、CPS 令牌、编解码和健康状态。供应商限制优先于本地理论容量;不能用随机轮询突破供应商配额。 +- 采用 N+1 或更高冗余:按压测得到的单 Cell **安全容量**计算 `(Cell数量 - 1) × 单Cell安全容量 >= 1000`,并额外预留发布、线路降级和突发拨号余量。未完成真实 ASR/LLM/TTS、SIP/RTP 和供应商压测前,不承诺固定机器数或规格。 +- 单 EIP+NAT 不再作为备选方案;当前及后续生产计划不设计、不实现、不验收该架构,不得以通用 SNAT 替代多 EIP 直连。 +- 调度器只分配有完整资源租约的 Cell:供应商并发/CPS、Cell媒体端口、ASR/LLM/TTS配额、出口健康和节点容量必须同时满足;租约过期或心跳失效时停止新任务,活动通话不自动接管。 +- RabbitMQ 只负责可靠传输,不作为活动通话唯一状态源;命令/结果采用持久化、发布确认、手动确认和幂等事件。业务状态、调度租约和 outbox 需要可靠持久化;重复投递不能造成重复拨号或重复业务结果。 +- 1000 路容量必须同时验证 RTP 包率/带宽、RTP 端口对数量、连接数/文件描述符、NAT/conntrack、AI 首包及持续延迟、MQ 堆积、OSS 上传速度和供应商错误率。当前 RTP `10000–10800` 仅为基线,绝不是 1000 路容量保证。 + +## 多租户队列与公平调度(用户已确认,运行代码待实现) + +- SaaS 按可信 `tenant_id` 映射向**租户独立 RabbitMQ 命令队列** PUSH,呼出应用调度器负责租户间公平调度;不再用所有租户共享的执行 FIFO,不增加 HTTP 拨号入口。业务任务/重试决策仍属于 SaaS。 +- 默认建议活跃且可调度租户等权轮询,差异化权重需按业务规则确认;每轮有界取数,租户额度耗尽或线路不可用时跳过。按租户及全局限制预取、未 ACK 和已持久化待发起窗口,不能先消费到无界内存 FIFO;重启恢复也必须公平。 +- 租户并发/CPS 配额跨所有 Cell、调度实例汇总;并发覆盖预留、拨号、振铃、接通及待对账占用,CPS 包括 FALLBACK。实际发起同时满足租户额度、供应商、Cell/出口和 AI 完整资源租约。多实例须协调调度所有权和原子额度,不能各自发放一份;未知活动通话不能仅因租约到期直接释放占用。 +- 平台负责队列创建、精确绑定、权限和安全停用/清理;路由标识由 tenant_id 受控唯一映射,必须校验正文租户与队列一致。最终用户不直接连接 broker,不允许任意指定其他租户路由。命令重试/死信恢复回原租户调度域,不绕过配额。 +- 每租户设置发布速率、队列消息数/字节和待执行窗口上限,并有全局 broker 水位保护。队列满明确拒绝发布,不丢弃队头旧命令;SaaS 持久保留未确认发布记录,使用原执行标识有限重试。独立队列不代表独享 broker 资源或无限积压。 +- 公平调度分配新执行机会,不为公平挂断已接通电话;资源满需等待释放。固定开始时限必须另确认覆盖线路/AI/Cell 的保底容量或受限借用策略,不能仅用轮询宣称保证。 +- 本轮不改变事件结果队列为一租户一队列。队列命名、轮转/预取、配额、保留与等待指标待 G0 冻结;须验收大租户积压下小租户公平、多实例配额不超额、背压不丢消息及租户路由安全。 + +## 其它环境前置 + +- 本地:Go 1.26、Python 3.11+、Node、Docker Engine/Compose;云操作另需 `aliyun` CLI 和北京区域权限。 +- 云创建权限至少涉及 ECS Describe/RunInstances、VPC DescribeEipAddresses/AssociateEipAddress;已有 VSwitch、安全组、镜像和 SSH KeyPair。不得自动开放全部端口。 +- Asterisk:镜像固定 digest;已提供的 SIP 参数见上节,其余供应商/备用地址及接入规则仍待补充。ARI 默认仅回环访问;跨机器使用管理网和受控 TLS。RTP、防火墙、NAT、编解码必须真机验证。 +- ASR Web:服务访问令牌、百炼/火山凭据;默认仅宿主机回环暴露。远程麦克风需要 HTTPS,或通过 SSH 隧道访问 localhost;不要求用户关闭浏览器安全机制。 +- 业务对接仍需 RabbitMQ VHost/队列/ACL、用户发布的消息契约、OSS 上传与 OSS ID 规范。测试台音频/识别输出不是已接入 SaaS 的 MQ 结果。 +- LLM/TTS:供应商、协议、模型、参数、取消/打断与音频契约均待用户新规范;不得静默调用旧实现或宣称完整对话已验证。 + +## 参考资源 + +- :Asterisk 调研和 Mock 底座;不能把历史测试结果当成本环境验收。 +- :仅借用 ASR 协议代码;本项目访问控制、Web页面和生命周期独立实现。 +- `docs/一期呼出应用开发计划_v1.0.md` 为阶段计划;SaaS 对接草案见 `docs/SaaS交互_OpenAPI与MQ契约规划_v0.1.md`(文件路径保留,版本见正文)。本轮运行步骤见 `docs/部署接入_运行说明.md`,旧文档只读保留。 + +## 验证与交付 + +- Python:`python3 -m unittest discover -s tests -v`。 +- Go:`cd services/asr-web && go test -race ./...`;改动后执行格式化与静态检查。 +- PCM:`node --test tests/test_pcm.cjs`;Shell:`bash -n deploy/asterisk.sh`。 +- 不用真实云账号运行自动创建测试;使用可注入的 CLI runner 测试请求/恢复逻辑。 +- 实际部署、真实 ASR、SIP、MQ/OSS、LLM/TTS测试必须分别报告,模拟测试通过不代表生产验收通过。 diff --git a/compose.asterisk.yaml b/compose.asterisk.yaml new file mode 100644 index 0000000..8d8f679 --- /dev/null +++ b/compose.asterisk.yaml @@ -0,0 +1,22 @@ +--- +name: ai-call-sip +services: + asterisk: + image: "${ASTERISK_IMAGE:?Set an approved image digest}" + network_mode: host + restart: unless-stopped + stop_grace_period: 60s + volumes: + - ./deploy/asterisk/generated/http.conf:/etc/asterisk/http.conf:ro + - ./deploy/asterisk/generated/ari.conf:/etc/asterisk/ari.conf:ro + - ./deploy/asterisk/generated/pjsip.conf:/etc/asterisk/pjsip.conf:ro + - ./deploy/asterisk/generated/rtp.conf:/etc/asterisk/rtp.conf:ro + - ./deploy/asterisk/generated/extensions.conf:/etc/asterisk/extensions.conf:ro + - recordings:/var/spool/asterisk/recording + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" +volumes: + recordings: {} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..cdf8618 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,35 @@ +--- +name: ai-call +services: + asr-web: + build: + context: ./services/asr-web + restart: unless-stopped + init: true + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + pids_limit: 128 + mem_limit: 256m + cpus: 1 + ports: + - "127.0.0.1:${ASR_WEB_PORT:-18088}:8080" + environment: + ASR_LISTEN: "0.0.0.0:8080" + ASR_WEB_TOKEN: "${ASR_WEB_TOKEN:?Set ASR_WEB_TOKEN}" + PUBLIC_ORIGIN: "${PUBLIC_ORIGIN:-}" + BAILIAN_API_KEY: "${BAILIAN_API_KEY:-}" + BAILIAN_WS: "${BAILIAN_WS:-}" + FUNASR_API_KEY: "${FUNASR_API_KEY:-}" + FUNASR_WS: "${FUNASR_WS:-}" + BAILIAN_ASR_MODELS: "${BAILIAN_ASR_MODELS:-fun-asr-realtime}" + VOLC_APP_KEY: "${VOLC_APP_KEY:-}" + VOLC_APP_ID: "${VOLC_APP_ID:-}" + VOLC_ACCESS_TOKEN: "${VOLC_ACCESS_TOKEN:-}" + VOLC_RESOURCE_ID: "${VOLC_RESOURCE_ID:-volc.bigasr.sauc.duration}" + VOLC_WS: "${VOLC_WS:-wss://openspeech.bytedance.com/api/v3/sauc/bigmodel}" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/deploy/__init__.py b/deploy/__init__.py new file mode 100644 index 0000000..f29a0c1 --- /dev/null +++ b/deploy/__init__.py @@ -0,0 +1 @@ +"""CLI deployment helpers; importing this package never changes cloud resources.""" diff --git a/deploy/aliyun.example.json b/deploy/aliyun.example.json new file mode 100644 index 0000000..41f5517 --- /dev/null +++ b/deploy/aliyun.example.json @@ -0,0 +1,15 @@ +{ + "region": "cn-beijing", + "public_ip": "123.56.71.98", + "project_tag": "ai-call", + "profile": null, + "adopt_instance_id": null, + "image_id": "", + "instance_type": "", + "vswitch_id": "", + "security_group_id": "", + "key_pair_name": "", + "spot_price_limit": null, + "system_disk_category": "cloud_essd", + "system_disk_gib": 40 +} diff --git a/deploy/aliyun_host.py b/deploy/aliyun_host.py new file mode 100644 index 0000000..18a893f --- /dev/null +++ b/deploy/aliyun_host.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 +"""Read-only by default. Reuse/prepare the fixed Beijing host via the aliyun CLI.""" + +import argparse +import fcntl +import hashlib +import json +import math +import os +import shutil +import subprocess +import sys +import time +import uuid +from pathlib import Path + +REGION = "cn-beijing" +PUBLIC_IP = "123.56.71.98" + + +class CloudError(RuntimeError): + pass + + +class AliyunCLI: + def __init__(self, profile=None): + self.binary = shutil.which("aliyun") + if not self.binary: + raise CloudError( + "aliyun CLI is not installed; configure CLI credentials locally, never paste secrets into chat" + ) + self.profile = profile + + def __call__(self, product, action, **params): + cmd = [self.binary, product, action, "--RegionId", REGION] + if self.profile: + cmd += ["--profile", self.profile] + for key, value in params.items(): + cmd += [ + "--" + key, + json.dumps(value, separators=(",", ":")) + if isinstance(value, (list, dict, bool)) + else str(value), + ] + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60, check=True + ) + data = json.loads(result.stdout) + if not isinstance(data, dict): + raise CloudError( + f"{product} {action} returned an invalid response shape" + ) + return data + except ( + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + json.JSONDecodeError, + ) as exc: + # CLI diagnostics can contain request details; don't log raw stdout/stderr or credentials. + raise CloudError( + f"{product} {action} failed; inspect the authenticated CLI locally (no mutation retry here)" + ) from exc + + +def identity(cfg): + if cfg.get("region") != REGION or cfg.get("public_ip") != PUBLIC_IP: + raise CloudError("region/IP must remain cn-beijing / 123.56.71.98") + if ( + not isinstance(cfg.get("project_tag"), str) + or not cfg["project_tag"] + or len(cfg["project_tag"]) > 64 + ): + raise CloudError("a dedicated project_tag is required") + + +def parse_count(value): + try: + count = int(value) + except (TypeError, ValueError) as exc: + raise CloudError("invalid inventory count; no mutations allowed") from exc + if isinstance(value, bool) or count < 0: + raise CloudError("invalid inventory count; no mutations allowed") + return count + + +def instances(api, **filters): + found = [] + seen = set() + for page in range(1, 21): + data = api("ecs", "DescribeInstances", PageSize=100, PageNumber=page, **filters) + rows = data.get("Instances", {}).get("Instance") + if not isinstance(rows, list): + raise CloudError("malformed instance inventory; no mutations allowed") + for row in rows: + key = row.get("InstanceId") if isinstance(row, dict) else None + if not isinstance(key, str) or not key or key in seen: + raise CloudError( + "invalid or changing instance inventory; no mutations allowed" + ) + seen.add(key) + found.extend(rows) + total = data.get("TotalCount") + if total is not None: + expected = parse_count(total) + if len(found) == expected: + return found + if len(found) > expected or not rows: + raise CloudError( + "incomplete or changing instance inventory; no mutations allowed" + ) + elif len(rows) < 100: + return found + raise CloudError( + "instance inventory exceeded the safety bound; no mutations allowed" + ) + + +def eip(api): + data = api( + "vpc", "DescribeEipAddresses", EipAddress=PUBLIC_IP, PageSize=100, PageNumber=1 + ) + rows = data.get("EipAddresses", {}).get("EipAddress") + if not isinstance(rows, list): + raise CloudError("malformed EIP inventory") + if parse_count(data.get("TotalCount", len(rows))) != len(rows): + raise CloudError("incomplete EIP inventory") + matches = [row for row in rows if row.get("IpAddress") == PUBLIC_IP] + if len(matches) > 1: + raise CloudError("ambiguous EIP ownership") + if matches and not matches[0].get("AllocationId"): + raise CloudError("EIP allocation ID missing; no creation allowed") + return matches[0] if matches else None + + +def owned(row, cfg): + tags = row.get("Tags", {}).get("Tag", []) + return row.get("InstanceId") == cfg.get("adopt_instance_id") or any( + t.get("TagKey") == "project" and t.get("TagValue") == cfg["project_tag"] + for t in tags + ) + + +def plan(cfg, api): + identity(cfg) + address = eip(api) + if address and address.get("InstanceId"): + if address.get("InstanceType") != "EcsInstance": + raise CloudError( + "fixed EIP is attached to a non-ECS resource; refusing to rebind" + ) + rows = instances(api, InstanceIds=[address["InstanceId"]]) + elif address: + if address.get("Status") != "Available": + raise CloudError( + "EIP is not available; wait/reconcile instead of creating a host" + ) + rows = instances( + api, **{"Tag.1.Key": "project", "Tag.1.Value": cfg["project_tag"]} + ) + else: + rows = instances(api, PublicIpAddresses=[PUBLIC_IP]) + rows = [ + r + for r in rows + if PUBLIC_IP in r.get("PublicIpAddress", {}).get("IpAddress", []) + ] + if not rows: + raise CloudError( + "fixed IP is neither an owned EIP nor an existing ECS public IP; cannot recreate this address" + ) + if len(rows) > 1: + raise CloudError( + "multiple candidate instances; explicit reconciliation required" + ) + if address and address.get("InstanceId") and not rows: + raise CloudError( + "EIP attachment has no visible instance; do not create or detach" + ) + row = rows[0] if rows else None + if row and not owned(row, cfg): + raise CloudError( + "existing instance is not project-owned; set adopt_instance_id only after owner approval" + ) + return { + "region": REGION, + "public_ip": PUBLIC_IP, + "action": "reuse" + if row and (not address or address.get("InstanceId")) + else ("bind" if row else "create_and_bind"), + "instance_id": row.get("InstanceId") if row else None, + "instance_status": row.get("Status") if row else None, + "instance_charge_type": row.get("InstanceChargeType") if row else None, + "spot_strategy": row.get("SpotStrategy") if row else None, + "allocation_id": address.get("AllocationId") if address else None, + "ip_kind": "eip" if address else "instance_public_ip", + "warning": "spot interruption can terminate calls; fixed instance public IP is not recoverable like an EIP", + } + + +def create_params(cfg): + required = ( + "image_id", + "instance_type", + "vswitch_id", + "security_group_id", + "key_pair_name", + ) + if any( + not isinstance(cfg.get(k), str) + or not cfg[k].strip() + or cfg[k].startswith(("CHANGE_ME", "--")) + for k in required + ): + raise CloudError( + "creation requires approved image/type/VSwitch/security group/SSH KeyPair" + ) + price = cfg.get("spot_price_limit") + if ( + isinstance(price, bool) + or not isinstance(price, (int, float)) + or not math.isfinite(price) + or price <= 0 + ): + raise CloudError( + "a positive, finite spot_price_limit approved by the owner is required" + ) + disk = cfg.get("system_disk_gib", 40) + if isinstance(disk, bool) or not isinstance(disk, int) or not 40 <= disk <= 200: + raise CloudError( + "system_disk_gib must be within the approved 40–200 GiB safety bound" + ) + return { + "ImageId": cfg["image_id"], + "InstanceType": cfg["instance_type"], + "VSwitchId": cfg["vswitch_id"], + "SecurityGroupId": cfg["security_group_id"], + "KeyPairName": cfg["key_pair_name"], + "Amount": 1, + "InstanceName": cfg["project_tag"], + "InstanceChargeType": "PostPaid", + "InternetMaxBandwidthOut": 0, + "SpotStrategy": "SpotWithPriceLimit", + "SpotPriceLimit": price, + "SystemDisk.Category": cfg.get("system_disk_category", "cloud_essd"), + "SystemDisk.Size": disk, + "Tag.1.Key": "project", + "Tag.1.Value": cfg["project_tag"], + } + + +def save_state(path, data): + temporary = path.with_suffix(".tmp") + fd = os.open( + temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600 + ) + with os.fdopen(fd, "w") as file: + json.dump(data, file) + file.flush() + os.fsync(file.fileno()) + temporary.replace(path) + directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + + +def wait_running(api, instance_id, sleep=time.sleep): + for _ in range(60): + rows = instances(api, InstanceIds=[instance_id]) + if len(rows) == 1 and rows[0].get("Status") == "Running": + return + if rows and rows[0].get("Status") in ("Stopped", "Stopping"): + raise CloudError( + f"instance {instance_id} is stopped; refusing to start or replace it automatically" + ) + sleep(5) + raise CloudError( + f"instance {instance_id} is not Running; preserve state and retry/reconcile, do not create manually" + ) + + +def apply(cfg, api, state_path, sleep=time.sleep): + state_path = Path(state_path) + state_path.parent.mkdir(parents=True, exist_ok=True) + if state_path.is_symlink(): + raise CloudError("state file must not be a symlink") + lock_fd = os.open( + str(state_path) + ".lock", os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600 + ) + with os.fdopen(lock_fd, "w") as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + raise CloudError( + "another provisioning process owns this state file" + ) from exc + current = plan(cfg, api) # Re-read immediately before mutations. + instance_id = current["instance_id"] + if current["action"] == "reuse": + if current["instance_status"] != "Running": + raise CloudError( + "existing host is not Running; no automatic start/replace" + ) + return current + if current["action"] == "create_and_bind": + params = create_params(cfg) + fingerprint = hashlib.sha256( + json.dumps([REGION, PUBLIC_IP, params], sort_keys=True).encode() + ).hexdigest() + try: + state = ( + json.loads(state_path.read_text()) if state_path.exists() else {} + ) + except (OSError, ValueError) as exc: + raise CloudError( + "state file cannot be read; no creation allowed" + ) from exc + if not isinstance(state, dict): + raise CloudError("invalid state; no creation allowed") + if state and state.get("fingerprint") != fingerprint: + raise CloudError( + "creation configuration changed; reconcile old resources/state before proceeding" + ) + if not state: + state = {"fingerprint": fingerprint, "client_token": str(uuid.uuid4())} + save_state(state_path, state) # Persist BEFORE the billable request. + try: + uuid.UUID(state.get("client_token", "")) + except (ValueError, TypeError, AttributeError) as exc: + raise CloudError( + "invalid persisted ClientToken; reconcile state before creation" + ) from exc + result = api( + "ecs", "RunInstances", ClientToken=state["client_token"], **params + ) + ids = result.get("InstanceIdSets", {}).get("InstanceIdSet", []) + if len(ids) != 1: + raise CloudError( + "unexpected creation result; preserve ClientToken and reconcile" + ) + instance_id = ids[0] + state["instance_id"] = instance_id + save_state(state_path, state) + wait_running(api, instance_id, sleep) + latest = eip(api) + if not latest or latest.get("AllocationId") != current["allocation_id"]: + raise CloudError( + "fixed EIP changed/disappeared; created host preserved, no substitute IP" + ) + if latest.get("InstanceId") and latest["InstanceId"] != instance_id: + raise CloudError( + "EIP became attached to another instance; refusing to detach it" + ) + if not latest.get("InstanceId"): + if latest.get("Status") != "Available": + raise CloudError( + "EIP is not safely bindable; preserve instance and retry later" + ) + api( + "vpc", + "AssociateEipAddress", + AllocationId=latest["AllocationId"], + InstanceId=instance_id, + InstanceType="EcsInstance", + ) + for _ in range(30): + bound = eip(api) + if ( + bound + and bound.get("InstanceId") == instance_id + and bound.get("Status") == "InUse" + ): + return dict( + current, + action="ready", + instance_id=instance_id, + instance_status="Running", + ) + sleep(2) + raise CloudError( + "EIP binding not confirmed; preserve host/state and reconcile, do not allocate another address" + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", required=True) + parser.add_argument("--state", default=".local/aliyun-host.json") + parser.add_argument( + "--apply", + action="store_true", + help="owner-approved billable creation/EIP association", + ) + args = parser.parse_args() + try: + cfg = json.loads(Path(args.config).read_text()) + identity(cfg) + api = AliyunCLI(cfg.get("profile")) + result = apply(cfg, api, args.state) if args.apply else plan(cfg, api) + print(json.dumps(result, ensure_ascii=False, indent=2)) + except (CloudError, OSError, ValueError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/asterisk.example.json b/deploy/asterisk.example.json new file mode 100644 index 0000000..e7302f6 --- /dev/null +++ b/deploy/asterisk.example.json @@ -0,0 +1,8 @@ +{ + "public_ip": "123.56.71.98", + "transport": "udp", + "local_net": "", + "ari_bind": "127.0.0.1", + "primary": {"host": "", "port": 5060, "auth_mode": "ip", "username": "", "register": false}, + "backup": {"host": "", "port": 5060, "auth_mode": "ip", "username": "", "register": false} +} diff --git a/deploy/asterisk.sh b/deploy/asterisk.sh new file mode 100644 index 0000000..f0c3c68 --- /dev/null +++ b/deploy/asterisk.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Check-only by default. Run with 'up' explicitly after host/IP/config approval. +set -euo pipefail +cd "$(dirname "$0")/.." +action="${1:-check}" +case "$action" in check | up) ;; *) + echo 'usage: bash deploy/asterisk.sh [check|up]' >&2 + exit 2 + ;; +esac +for file in http.conf ari.conf pjsip.conf rtp.conf extensions.conf; do + test -f "deploy/asterisk/generated/$file" || { + echo "Missing generated $file" >&2 + exit 1 + } +done +image="$(docker compose -f compose.asterisk.yaml config --format json | + python3 -c 'import json,sys; print(json.load(sys.stdin)["services"]["asterisk"]["image"])')" +if [[ ! "$image" =~ @sha256:[a-f0-9]{64}$ ]]; then + echo 'Asterisk image must be pinned to an approved SHA-256 digest' >&2 + exit 1 +fi +echo 'Compose, config files and image digest checked; verify container UID, network and fixed egress IP separately.' +if [[ "$action" == up ]]; then + docker compose -f compose.asterisk.yaml up -d +fi diff --git a/deploy/render_asterisk.py b/deploy/render_asterisk.py new file mode 100644 index 0000000..af17fcb --- /dev/null +++ b/deploy/render_asterisk.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Render an explicit, outbound-only UDP baseline. Does not start services.""" + +import argparse +import ipaddress +import json +import os +import re +import shutil +import sys +import tempfile +from pathlib import Path + +PUBLIC_IP = "123.56.71.98" + + +def scalar(value, name, secret=False): + if ( + not isinstance(value, str) + or not value + or len(value) > 256 + or any(c in value for c in "\r\n;[]\\") + ): + raise ValueError(f"{name} is missing or contains unsupported INI characters") + if value.startswith("CHANGE_ME"): + raise ValueError(f"{name} is still a placeholder") + if secret and len(value) < 12: + raise ValueError(f"{name} must have at least 12 characters") + return value + + +def endpoint(name, data, env): + host = scalar(data.get("host"), name + ".host") + if ( + not re.fullmatch(r"[A-Za-z0-9.-]+", host) + or host == PUBLIC_IP + or host.endswith((".invalid", ".example")) + ): + raise ValueError( + f"{name}.host must be the SIP provider IPv4/DNS address, not our whitelisted IP or a placeholder" + ) + port = data.get("port", 5060) + if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535: + raise ValueError(f"{name}.port is invalid") + mode = data.get("auth_mode") + if mode not in ("ip", "digest"): + raise ValueError(f"{name}.auth_mode must be explicitly ip or digest") + registration = data.get("register", False) + if not isinstance(registration, bool) or registration and mode != "digest": + raise ValueError("registration requires explicit digest authentication") + text = f"[{name}]\ntype=endpoint\ntransport=transport-udp\ncontext=deny-inbound\ndisallow=all\nallow=ulaw\ndirect_media=no\nrtp_symmetric=yes\nforce_rport=yes\nrewrite_contact=yes\naors={name}-aor\n" + auth = "" + if mode == "digest": + user = scalar(data.get("username"), name + ".username") + if not re.fullmatch(r"[A-Za-z0-9_.+-]+", user): + raise ValueError("SIP username must be a plain user identifier") + password_key = "SIP_" + name.removeprefix("provider-").upper() + "_PASSWORD" + password = scalar(env.get(password_key), password_key, secret=True) + text += f"outbound_auth={name}-auth\nfrom_user={user}\n" + auth = f"\n[{name}-auth]\ntype=auth\nauth_type=userpass\nusername={user}\npassword={password}\n" + if registration: + auth += f"\n[{name}-registration]\ntype=registration\ntransport=transport-udp\noutbound_auth={name}-auth\nserver_uri=sip:{host}:{port}\nclient_uri=sip:{user}@{host}:{port}\nretry_interval=60\n" + text += ( + f"\n[{name}-aor]\ntype=aor\ncontact=sip:{host}:{port}\nqualify_frequency=10\n" + ) + return text + auth + + +def render(cfg, env): + if cfg.get("public_ip") != PUBLIC_IP or cfg.get("transport") != "udp": + raise ValueError( + "fixed public IP and UDP baseline required; TCP/TLS need a reviewed configuration" + ) + try: + network = ipaddress.ip_network(cfg["local_net"], strict=True) + bind = ipaddress.ip_address(cfg.get("ari_bind", "127.0.0.1")) + except (KeyError, ValueError) as exc: + raise ValueError( + "local_net and ARI bind address must be explicit valid IP configuration" + ) from exc + if network.version != 4 or not network.is_private or network.prefixlen < 8: + raise ValueError( + "local_net must be the actual private IPv4 VPC network, not a default route" + ) + if bind.version != 4 or bind.is_unspecified or not bind.is_private: + raise ValueError( + "ARI must bind to loopback or an approved private IPv4 management address" + ) + password = scalar(env.get("ARI_PASSWORD"), "ARI_PASSWORD", secret=True) + if len(password) < 32: + raise ValueError("ARI_PASSWORD must have at least 32 characters") + primary = endpoint("provider-primary", cfg.get("primary", {}), env) + backup = endpoint("provider-backup", cfg.get("backup", {}), env) + if (cfg["primary"]["host"], cfg["primary"].get("port", 5060)) == ( + cfg["backup"]["host"], + cfg["backup"].get("port", 5060), + ): + raise ValueError( + "primary and backup targets must be distinct; shared failure domains still require validation" + ) + transport = f"[global]\ntype=global\nuser_agent=ai-call\n\n[transport-udp]\ntype=transport\nprotocol=udp\nbind=0.0.0.0:5060\nlocal_net={network}\nexternal_signaling_address={PUBLIC_IP}\nexternal_media_address={PUBLIC_IP}\n\n" + return { + "http.conf": f"[general]\nenabled=yes\nbindaddr={bind}\nbindport=8088\n", + "ari.conf": f"[general]\nenabled=yes\npretty=no\n\n[outbound]\ntype=user\nread_only=no\npassword={password}\n", + "pjsip.conf": transport + primary + "\n" + backup, + "rtp.conf": "[general]\nrtpstart=10000\nrtpend=10800\nstrictrtp=yes\n", + "extensions.conf": "[deny-inbound]\nexten => s,1,Hangup()\nexten => _.,1,Hangup()\n", + } + + +def write_config(files, output): + output = Path(output) + if output.exists() or output.is_symlink(): + raise ValueError( + "output already exists; use a new version directory and review the change before switching" + ) + output.parent.mkdir(parents=True, exist_ok=True) + temporary = Path(tempfile.mkdtemp(prefix=".asterisk-render-", dir=output.parent)) + try: + for name, content in files.items(): + path = temporary / name + path.write_text(content) + path.chmod(0o600) + temporary.chmod(0o750) + temporary.rename(output) + except Exception: + shutil.rmtree(temporary) + raise + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", required=True) + parser.add_argument("--output", default="deploy/asterisk/generated") + args = parser.parse_args() + try: + cfg = json.loads(Path(args.config).read_text()) + files = render(cfg, os.environ) + write_config(files, args.output) + print( + f"Rendered {len(files)} configuration files to {args.output}; services NOT started. Check container UID permissions before deployment." + ) + except (ValueError, OSError, KeyError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/SaaS交互_OpenAPI与MQ契约规划_v0.1.md b/docs/SaaS交互_OpenAPI与MQ契约规划_v0.1.md new file mode 100644 index 0000000..fa79a69 --- /dev/null +++ b/docs/SaaS交互_OpenAPI与MQ契约规划_v0.1.md @@ -0,0 +1,510 @@ +# SaaS 交互:OpenAPI 与 MQ 契约规划 + +**版本:** v0.3(沿用原文件路径) +**状态:** 租户独立命令队列+公平调度方案已确认;详细接口、配额数值及调度参数仍待评审,非已发布接口规范。仅更新规划,不实现服务、不部署、不拨号。 +**依据:** [一期呼出应用开发计划](一期呼出应用开发计划_v1.0.md)、项目根目录 `AGENTS.md`。 +**范围:** 现有 SaaS 与呼出应用的双向 HTTP 接口、RabbitMQ 命令/事件、OSS 录音交接及联调验收。本文细化已有六类 HTTP 接口,不建设通用开放平台。 + +> 阅读约定:第 1 节为已确认边界;其余路径、字段、状态、数值和安全方案均为建议草案,须由用户评审后冻结。出现 MUST/必须等约束,是拟发布契约应具备的要求,不表示现有运行代码已经支持。 + +**本次修订:** 补齐拨号前等待/查询、准入期限、资源原因码、发布背压和控制竞争样例;与一期计划统一 execution_id 及单线路启动。六类 HTTP 路径不变,不新增配额管理或租户队列管理接口;本版仍为待冻结草案,不升级 HTTP `/v1`。 + +## 1. 已确认的系统边界 + +1. **RabbitMQ 是唯一外呼执行指令入口**,不提供 HTTP 创建通话或重新拨号接口。 +2. **全部业务结果通过 MQ 回传**:受理/拒绝、控制生效、呼叫状态、文字、最终结果、录音就绪/失败等。HTTP 查询用于对账,不替代 MQ 事件。 +3. HTTP 仅承担任务控制、命令/通话查询、OSS 上传授权与完成确认、触发历史事件 MQ 补传。HTTP 上传完成确认是存储握手,不是业务结果回调。 +4. 录音先上传 OSS,校验成功并取得 OSS ID 后发布 `recording.ready`;MQ 不传录音二进制、Base64 或公开播放 URL。 +5. SaaS 负责客户/任务主数据、业务调度、业务重试决策、授权和长期存储;呼出应用只保存必要执行事实、控制屏障、幂等、资源租约和投递记录。 +6. 生产采用多机器、多 EIP 直连;每通电话固定 Cell/出口。SaaS 不逐呼改写共享 SIP 配置,也不能任意指定 SIP 地址、凭证或越权主叫。 +7. 目前只实现 ASR 验证基础,LLM/TTS 协议尚待新规范;本文对完整 AI 链路的描述是目标契约,不代表已启用或验收。 +8. **按租户 ID 映射到独立 RabbitMQ 命令队列,由呼出应用调度器负责租户间公平调度**。不再采用所有租户共用一个执行 FIFO;同时限制租户发布/积压、预取窗口及跨 Cell 并发/CPS。该架构已确认,命名和限值仍为草案。 + +## 2. 交付拆分与双方职责 + +| 交付物/能力 | 提供方 | 消费方 | +| --- | --- | --- | +| 呼出应用 HTTP:控制、查询、补传 | 呼出应用 | SaaS 后端 | +| SaaS HTTP:录音上传授权、完成确认 | SaaS/存储服务 | 呼出应用资产处理器 | +| `call.execute` 命令 | SaaS 业务调度器按可信租户归属发布、处理背压 | 呼出应用公平调度器按租户队列有界接收并准入 | +| 执行及资产事件 | 呼出应用 outbox 投递器 | SaaS 事件消费者 | +| MQ VHost、账号、ACL、持久队列和告警 | 运维按冻结契约配置 | 双方服务 | +| 对外发布规范、样例、错误码和验收用例 | 用户主导制定 | 双方评审实施 | + +建议冻结后交付两个 OpenAPI 3.1 文件,分别描述两个 HTTP 服务;MQ 使用 AsyncAPI 文档及 JSON Schema,不把 AMQP 消费者伪装成 HTTP Webhook。首版先有契约及样例,不引入 SDK 生成器或开发者门户。 + +## 3. 公共约定 + +### 3.1 标识与时间 + +| 字段 | 定义 | +| --- | --- | +| `tenant_id` | SaaS 租户 ID;请求中的值必须与服务身份获授权范围匹配,不能仅相信报文自报租户 | +| `task_id` / `task_item_id` | SaaS 任务/号码明细 ID;不因重试改变原明细归属 | +| `execution_id` | 建议新增:SaaS 为一次**获授权的业务外呼**分配的 ID;网络重试、消息重投和内部线路切换不变,业务重新外呼才新建 | +| `command_id` | 单条执行、控制或补传命令 ID;同一命令重试保持不变 | +| `call_id` | 呼出应用为一次业务执行分配的逻辑通话 ID;一个 `execution_id` 最多关联一个 `call_id` | +| `attempt_id` | 一次线路拨号尝试 ID;合法 FALLBACK 创建新的 attempt,但不创建第二个业务执行 | +| `event_id` | 事件身份;重试/历史补传保留原值及原内容 | +| `recording_id` / `upload_id` | 逻辑录音 ID / 上传会话 ID;授权续期不产生第二份逻辑录音 | +| `oss_id` | SaaS 存储服务确认的、不透明且稳定的资产 ID;不默认等同于 Object Key、ETag、文件名或 URL | +| `trace_id` | 链路关联信息,不用于授权或幂等 | + +- ID 均为不透明字符串,不按手机号、数字或 UUID 强制改写已有 SaaS ID;建议长度 1–128,拒绝控制字符、路径分隔符和空白首尾。最终字符集在 Schema 冻结。 +- 时间使用 RFC 3339 UTC,例如 `2026-09-11T08:00:00.000Z`;持续时间字段以 `_ms` 结尾,大小为字节,采样率为 Hz。 +- 未接通时 `answered_at` 为 `null`,不填写虚假接通时间;未完成的结果用明确状态,不用空字符串冒充成功。 +- 所有唯一键和检索均包含租户作用域。不同租户碰巧使用相同 ID,不能互相查询或命中幂等记录。 + +### 3.2 HTTP 共性 + +- 使用 HTTPS,仅服务到服务调用,不向浏览器或公网客户直接暴露内部接口。 +- 公共请求头:`Authorization: Bearer `、`X-Tenant-ID`、`X-Request-ID`;有 JSON 请求体时使用 `Content-Type: application/json`。可传播 `traceparent`。 +- Bearer 凭证优先接入双方已有服务身份体系,不另建账号平台;建议短期且限定 audience/scope/租户的令牌。跨管理网场景可增加 mTLS,具体签发/轮换方式列入 G0。 +- POST 必须提供 `Idempotency-Key`。控制/补传接口要求它等于 `command_id`;存储接口为一次逻辑操作生成稳定键。 +- 同租户、服务调用方、操作及幂等键下,相同语义请求返回原操作标识;请求内容不同返回 `409 IDEMPOTENCY_CONFLICT`。服务端对校验后的语义字段比较,不因 JSON 字段顺序产生冲突。 +- `202 Accepted` 仅代表命令与 outbox 已可靠持久化,返回查询位置;不是控制已生效或补传已完成。普通查询返回 `200`,首次创建上传会话返回 `201`,完成存储确认返回 `200`。 +- 同步校验/鉴权失败返回 HTTP 错误,不代表接受了命令;未通过鉴权的请求不产生租户业务事件。持久化受理后的业务结果必须走 MQ。 +- 成功响应直接返回资源对象;错误使用 `application/problem+json`,含 `type`、`title`、`status`、`code`、`detail`、`request_id`、`retryable`,不暴露堆栈和凭证。 + +### 3.3 重试及数据期限 + +- HTTP 超时或连接断开:先按原命令/幂等键查询或重试,不换 ID。只对网络错误、429 和约定可恢复 5xx 做有上限退避;遵守 `Retry-After`,不得无限重试业务拒绝。 +- 命令去重、控制屏障和事件保留期必须覆盖最大合法重投/恢复窗口;数据过期后不能把旧执行当作新执行。 +- 执行 ID 去重记录的清理需依赖“该授权已永久失效”的可靠依据;控制停止墓碑同理。未建立安全清理机制前,不能仅凭普通 TTL 删除防重拨屏障。 +- 已知但超出事件补传保留期返回 `410 REPLAY_EXPIRED`;不存在或无权访问资源统一返回 `404`,避免跨租户枚举。 + +## 4. HTTP OpenAPI 规划 + +两个服务分别使用自己的 Base URL,不共享服务实现;下表路径均为相对各自服务根路径。 + +| 提供方 | 方法与路径 | 最小权限 | 结果 | +| --- | --- | --- | --- | +| 呼出应用 | `POST /internal/v1/outbound/tasks/{task_id}/controls` | `outbound.control` | 202;持久化控制命令 | +| 呼出应用 | `GET /internal/v1/outbound/commands/{command_id}` | `outbound.read` | 200;命令与生效状态 | +| 呼出应用 | `GET /internal/v1/outbound/calls/{call_id}` | `outbound.read` | 200;执行事实与资产/投递快照 | +| 呼出应用 | `POST /internal/v1/outbound/calls/{call_id}/replays` | `outbound.replay` | 202;受控历史事件补传 | +| SaaS | `POST /internal/v1/outbound/recording-uploads` | `recording.upload` | 201/200;创建或取得原上传会话 | +| SaaS | `POST /internal/v1/outbound/recording-uploads/{upload_id}/complete` | `recording.complete` | 200;存储验证成功,确认 OSS ID | + +### 4.1 任务控制 + +请求字段: + +| 字段 | 必填 | 规则 | +| --- | --- | --- | +| `command_id` | 是 | 与 `Idempotency-Key` 一致 | +| `action` | 是 | `pause` / `resume` / `stop` | +| `expected_task_revision` | 是 | SaaS 认为当前应有的控制版本 | +| `task_revision` | 是 | 本次目标版本,必须等于 expected + 1 | +| `active_call_policy` | stop 时是 | `drain` / `hangup`;不设隐藏的强制挂断默认值 | +| `reason` | 是 | 有界审计原因,不放敏感客户资料 | + +响应字段:`command_id`、`task_id`、`status=accepted`、`requested_task_revision`、`accepted_at`;`Location` 指向命令查询接口。 + +建议状态与竞争规则: + +- `task_revision` 仅表示任务控制栅栏版本,不表示智能体配置版本。首版建议初始版本为 1,任务初始运行;尚未收到 execute 时的控制,也必须能为获授权任务建立持久屏障。 +- 基于服务身份及双方可信任务绑定验证归属;不能因第一次见到任意 task_id 就将其认领给请求租户。任务绑定来源在 G0 确认,可来自获授权 SaaS 主数据或其受信发布边界,不额外开放拨号入口。 +- 同任务控制串行化,提交时 CAS 校验 expected;版本冲突或另一个控制尚在生效中返回 409。重复 command_id 先走幂等,不重复推进版本。 +- 暂停/停止受理后,先禁止发放新的拨号许可,再等待所有相关 Cell 上的在途许可完成或失效;确认后才能回传 `applied`。存在状态不明节点时保持 `applying/reconciling`,不能虚报全局生效。 +- 每次真实发起和 FALLBACK 都检查当前控制版本、状态、授权期限及有效资源租约;节点失联或租约过期停止新发起。仅把数据库改为 paused 不足以形成屏障。 +- `pause`:已拨出和已接通电话继续原生命周期;未发起执行不再启动,报告阻止原因。`resume`:允许新版本指令,不自动重放旧积压。 +- 旧版本命令拒绝;未来版本命令也不自动缓存为可执行。SaaS 如需重新调度被阻止且确认未拨号的执行,先完成对账及业务授权,再生成新的执行命令,不能由消费者静默换 ID 重试。 +- `stop + drain`:停止新发起,已有电话自然结束;`stop + hangup`:额外需要 `outbound.hangup` 权限,审计后终止该任务已有通道。挂断未确认前不能宣称 hangup 控制已完全生效。 +- stopped 不允许 resume;重新开展业务使用新任务,避免迟到指令复活。控制失败后不能自行放开已经建立的暂停/停止屏障。 +- HTTP 查询和 MQ `command.result` 均区分 requested revision 与 applied revision;这项多 Cell 屏障能力为待实现项。 + +### 4.2 命令查询 + +通用返回:`command_id`、`command_type`、`task_id/call_id`(适用时)、`status`、`reason_code`、`accepted_at`、`updated_at`。 + +- execute:`accepted` / `waiting` / `rejected` / `executing` / `completed` / `reconciling`;accepted 仅表示可靠受理,waiting 表示已进入有界准入等待,executing 表示已提交拨号意图、不等于 SIP 已实际发出或接通;completed 指执行生命周期结束,不等于客户接通或业务成功。 +- control:`accepted` / `applying` / `applied` / `rejected` / `failed` / `reconciling`,另含 requested/applied revision 和当前任务状态。 +- replay:`accepted` / `running` / `completed` / `failed`,另含快照截止点、已选/已确认发布数量;completed 不代表 SaaS 已入库。 +- HTTP 校验阶段就被拒绝、未持久化的命令允许查询不到;MQ 收到的合法业务拒绝应持久化并可查询。 + +execute 查询及对应 `command.result` 建议共用以下字段,MQ 的等待/状态变化仍按 command 聚合版本合并: + +| 字段 | 规则 | +| --- | --- | +| `execution_id` | 原业务授权执行 ID,受理后始终可关联 | +| `call_id` | 首次资源准入成功并提交拨号意图时创建;此前为 `null`,不伪造通话记录 | +| `wait_reason_code` | waiting 时必填:`SCHEDULER_WAIT`、`TENANT_CONCURRENCY_EXHAUSTED`、`TENANT_CPS_EXHAUSTED`、`ROUTE_CAPACITY_EXHAUSTED`、`CAPACITY_EXHAUSTED`;非 waiting 为 `null` | +| `waiting_since` | 首次进入 waiting 的服务端时间;原因变化不重置,离开等待后保留;未等待为 `null` | +| `admission_deadline` | 受理时固定的首次发起准入截止时间;拒绝于受理前时为 `null`,重投/重启不延长 | +| `updated_at`、`aggregate_version` | 当前命令快照的更新时间和版本;HTTP 与 MQ 使用同一版本域。HTTP 为响应字段,MQ 的 aggregate_version 只在事件外壳中,不重复放入 payload | + +- waiting 的原因表示当前主要阻塞项,不是完整资源诊断或预计开始时间;多个条件同时不足时按 G0 冻结的优先级选择。原因改变可发布新版本快照,不按轮询周期重复发事件。 +- 首次发起前以 `(tenant_id, command_id)` 查询;尚在 broker 队列、未被呼出侧持久化的命令允许返回 404。404 不证明消息未发布或电话一定未发生,不能据此换 execution_id 重拨;SaaS 保留原发布记录并用原 ID 对账/有限重试。 +- 状态路径为 `accepted → waiting → executing → completed`,有资源时可跳过 waiting。尚未提交拨号意图的 accepted/waiting 可因过期、控制或准入超时进入 rejected;提交意图后出现不确定性进入 reconciling,不回退 waiting 或以“未拨号拒绝”释放未知占用。 + +### 4.3 通话查询 + +返回 `call_id`、`execution_id`、任务关联、`call_state`、`call_version`、起止时间、结果原因及以下独立信息: + +- `attempts`:每次 attempt 的状态、原因、`trunk_id`、`egress_pool_id`、`cell_id`、时间;不暴露 SIP 密码或内部管理地址。 +- `transcript`:处理状态、最终稿数量及是否仍有待完成段落;首版查询不返回整段文字,文字正文通过 MQ 到 SaaS。 +- `recordings`:录音标识、处理状态、确认后的 `oss_id` 和失败原因;不返回上传凭证或播放链接。 +- `delivery`:待投递/失败数量、最后确认发布时间。RabbitMQ confirm 仅表示 broker 接收,`saas_applied` 未有应用层证据时必须为 unknown,不能猜测为已入库。 +- 附 `snapshot_at`;查询是某一时点的执行快照,SaaS 根据版本合并,不用旧快照覆盖较新事件。 + +### 4.4 历史事件补传 + +请求:`command_id`、`event_types`(允许列表)、可选 `event_ids`、`reason`。范围始终限定到路径中的 call_id 和当前租户;event_ids 提供时与 event_types 取交集,不得跨通话。 + +- 在受理时记录固定事件截止点与选择条件;重复请求使用同一个补传任务和截止点,不把后续新事件悄悄纳入。 +- 所有被选事件必须来自原持久化事件记录;保留 event_id、原时间和原 payload,可仅在传输头标记 replay。禁止重新合成历史业务事实。 +- 只重发结果/文字/录音元数据;不执行拨号、不重放 `call.execute`、不重传录音文件、不重新调用 LLM/TTS。 +- 对补传限速并独立计量,避免挤占实时结果队列;部分成功允许重试未确认部分,重复投递由 SaaS 去重。 +- 补传命令自身结果也走 MQ `command.result`;不把本次补传生成的结果循环纳入本次选择集。 + +### 4.5 录音上传授权 + +请求:`recording_id`、`call_id`、`content_type`、`size_bytes`、`checksum_algorithm`、`checksum`、`channels`、`sample_rate_hz`、`duration_ms`。上传前须封口文件,确保大小和校验值稳定。 + +返回:`upload_id`、`recording_id`、`expires_at`、`upload_method`、`upload_url`、`required_headers`、约束及已完成会话的 `oss_id`(如有)。 + +- 首版建议单文件签名 PUT;最长录音推导的文件上限不满足单 PUT 或恢复窗口时,再增加分片协议,不在本版虚构分片接口。 +- SaaS 按 tenant/call/recording 绑定会话并分配存储位置,不接受调用方任意 bucket/object key。因 MQ 延迟尚无通话记录时,不绕过归属检查;返回可重试 `CALL_NOT_REGISTERED`,呼出侧保留本地文件。 +- 同一逻辑录音禁止因重复申请产生第二份资产;授权失效后重新请求原会话,可返回新的临时签名,但会话、对象和内容约束保持不变。更换内容必须使用明确的新录音版本。 +- URL 为受控 OSS HTTPS 目标,服务端限制可访问域名/存储端点、不跟随任意重定向,防止上传器成为 SSRF 或外传通道。 +- 凭证只允许指定对象写入,不授予列桶/删桶/任意对象权限;完成对象应不可被原授权继续覆盖,使用 OSS 禁止覆盖约束或可靠对象版本固定机制。 +- 授权响应 `Cache-Control: no-store`;签名 URL、token、required_headers 中的秘密不得进入日志或 MQ。 + +### 4.6 上传完成确认 + +请求:`recording_id`、`size_bytes`、`checksum_algorithm`、`checksum`,可带 OSS 返回的 `etag`(仅作辅助,不当作文件内容摘要)。 + +- SaaS 根据 upload_id 找到受控目标,核验调用方、对象实际存在、大小、实际内容校验及录音绑定;不能只相信客户端提交的 checksum 或对象自报元数据。 +- 校验算法需选择 OSS/存储服务能够独立验证的机制并冻结;无法独立验证时不能声称校验成功。ETag 尤其在分片/加密情况下不等于文件 MD5。 +- 对象未就绪返回可重试错误;内容冲突进入隔离/人工处置,不创建就绪资产。 +- 验证成功,幂等返回 `upload_id`、`recording_id`、`status=verified`、`oss_id`、`verified_at`。确认超时后用原幂等键重试,不能新建第二份录音。 +- 呼出侧把 verified 事实与 `recording.ready` outbox 同事务持久化,再由 MQ 投递。SaaS 只有消费 ready 后才将资产作为业务录音关联展示。 +- 任一步失败通过 `recording.failed` 回传可恢复性与原因;未取得有效 oss_id 不发布 ready。本地文件清理受恢复期限和已可靠持久化交接证据约束,不能仅收到 HTTP 200 就无条件删除。 + +## 5. RabbitMQ 拓扑与传输 + +### 5.1 最小拓扑草案 + +| 对象 | 名称建议 | 用途 | +| --- | --- | --- | +| 环境 VHost | `/ai-call-` | 测试/生产隔离,具体路径由运维配置 | +| 命令 Topic Exchange | `ai-call.commands.v1` | Routing Key 为 `tenant.{tenant_key}.call.execute`;消息类型仍为 `call.execute` | +| 租户独立命令队列 | `ai-call.executor.{tenant_key}.v1` | 一租户一队列,精确绑定该租户 Routing Key;公平调度器有界读取,共享持久去重存储 | +| 事件 Topic Exchange | `ai-call.events.v1` | 发布第 7 节允许的事件 | +| SaaS 事件队列 | `ai-call.saas.events.v1` | SaaS 消费落库 | +| 死信 Exchange | `ai-call.dead.v1` | 隔离无效消息与耗尽重试 | +| 死信队列 | `ai-call.commands.dead.v1`、`ai-call.events.dead.v1` | 分方向审计与受控恢复 | + +- `tenant_key` 是平台将 tenant_id 唯一映射为安全单段路由标识的结果,禁止点号、通配符、碰撞和调用方任意指定;原始 tenant_id 仍保留在正文。发布与消费均校验队列绑定、租户归属和正文一致,发现错配隔离告警,不向错误租户执行。 +- 本轮仅改变执行命令的租户隔离拓扑,事件队列不自动扩展为一租户一队列;事件消费和补传仍需限速及租户校验,不能由此宣称结果链路已有同等等待时延保证。 +- Exchange/Queue durable,业务消息 persistent;生产建议 quorum queue,最终以现有 RabbitMQ 版本及 HA 部署确认,不把 durable 单节点当高可用。 +- 命令/事件分别使用最小权限账号,TLS 连接;SaaS 不能消费执行器队列,呼出应用不能消费 SaaS 事件队列,也不能发布 execute 指令。 +- 所有生产者使用 publisher confirm + mandatory/不可路由检查;消费者手动 ACK,只在本地必要数据持久化成功后 ACK。 +- 队列不替代业务状态库;多消费者不承诺全局顺序。业务版本负责乱序合并,数据库约束和执行租约负责去重/准入。 +- 可信 SaaS 生产者可被授权发布多个租户;发布者身份来自 broker 账号及受控发布边界,不信任可伪造的 message header。若开放非受信租户直连 MQ,必须另做租户隔离设计,本期不开放。 +- 命令业务过期以 `not_after` 校验为准,队列 TTL 仅用于积压治理。过期命令应有可追踪的拒绝/隔离记录,不静默消失;DLQ 恢复不能绕过有效期和停止屏障。命令重试/恢复必须回到原租户调度域并受相同配额约束,不经全局重试 FIFO 直接抢占执行。共享死信队列只用于隔离审计,不是执行入口。 +- 消费瞬时故障不得无限 `nack(requeue=true)` 热循环;采用有上限的延迟重试/持久重试记录。若发布到重试队列,必须等新发布确认后再 ACK 原消息,并依靠幂等承受重复。 +- 死信路径也要验证不丢失;quorum 至少一次 dead-letter 或应用确认后转存二选一落实,不能默认普通 DLX 搬运具有端到端保证。 +- 非法 Schema/未知主版本进入隔离队列,不凭不可信字段向任意租户发送错误事件;可安全识别的合法业务拒绝发布 `command.result` 后完成处理。 + +### 5.2 传输属性 + +`content_type=application/json`、UTF-8、`delivery_mode=2`、`message_id=command_id/event_id`、`type=command_type/event_type`;`correlation_id` 可使用 command_id,正文 trace_id 负责跨环节跟踪。 + +建议消息上限 256 KiB、HTTP JSON 请求体上限 64 KiB,均待容量/样例验证;超限拒绝或按已定义的文字分段规则发送,禁止静默截断最终文字。MQ 不传文件或服务凭证。 + +### 5.3 租户公平调度(架构已确认,参数待冻结) + +```text +SaaS 持久任务/发布记录 + → 命令 Exchange:按可信 tenant_id 映射路由 + → 租户 A 命令队列 ┐ + → 租户 B 命令队列 ├→ 公平调度器 → 租户额度+完整资源租约 → 固定 Cell/出口 + → 租户 C 命令队列 ┘ +``` + +1. **责任分离:** SaaS 决定业务任务、号码和重新外呼授权,并按租户发布;呼出应用决定租户间执行机会和物理资源分配。不要求 SaaS 等待上一租户批次执行完才发布下一租户,也不复制其任务编排系统。 +2. **轮转而非排空:** 默认建议活跃且可调度租户等权轮询,差异化服务获确认后采用加权轮询。每轮每租户最多取有限条命令;租户没额度、线路不可用时跳过,不能阻塞全局循环。新活跃租户应及时加入轮转,不能等待 A 队列排空。权重由平台配置,不接受每条消息自报高优先级。 +3. **有界预取与持久待执行窗口:** 按租户及全局限制 prefetch/未 ACK 和已 ACK 未发起数量;队列不等于进程,一租户一队列不要求一套服务。禁止先把全部租户消费到同一个无界内存 FIFO;已持久化的待执行命令及重启恢复也按租户公平选取。prefetch 本身不是公平算法。 +4. **配额双重检查:** 读取前检查租户可接收窗口/资源可用性,持久化命令及去重/outbox 后 ACK;这不是提前保证拨号容量。实际发起必须原子取得租户额度并同时满足供应商并发/CPS、Cell 端口、出口健康、AI 配额和控制屏障,失败释放未使用预留。已受理命令的有限等待/拒绝按第 6.2 节处理;尚未取出的命令受队列积压上限及有效期约束。 +5. **多实例与恢复:** 多个调度器需通过租户调度所有权/租约及隔离令牌协调同一租户的轮次、窗口和额度,不能每个实例独立再分一份。公平性覆盖整个调度域,不只覆盖每个进程自己的队列。失去所有权的旧实例停止分配;已拨通但状态不明的占用不能仅因租约到期就释放,需对账,防止超额与重复拨号。具体协调实现待设计评审。 +6. **公平的边界:** 保证有可用资源和租户额度时不被另一租户积压饿死,不保证相同接通数或相同通话时长。资源已被活动通话占满时等待释放,不为公平挂断电话。若要明确开始时限,另确认不可借用保底容量或受限借用策略,覆盖线路/AI/Cell 全链路;不能把空闲份额借出后又承诺立即收回。 + +| 租户限制 | 统计范围/约束 | +| --- | --- | +| 并发占用上限 | 跨所有 Cell 汇总,包含发起预留、拨号、振铃、接通及待对账占用;从发起前占用到确认资源结束后释放 | +| CPS 上限 | 跨所有调度实例和 Cell 统计真实拨号尝试,包括 FALLBACK;切线路不重置租户额度 | +| 接收/待发起窗口 | 每租户和全局的未 ACK、已持久化未发起数量均有界;拒绝/过期处理不得绕成新的无界工作队列 | +| 发布速率、队列容量 | 每租户限制消息数/字节、发布速率并辅以全局 broker 水位保护;独立队列仍共享磁盘、内存及网络 | +| 调度权重 | 默认建议等权;只改变有竞争时新增许可份额,不绕过硬上限,不保证通话时长比例 | + +### 5.4 队列生命周期与背压 + +- 平台依据可信租户注册关系幂等创建队列、精确绑定和权限,再允许 SaaS 发布;启动/故障恢复能重新发现活跃队列,不依赖仅存在于内存的清单。租户停用先拒绝新发布并建立控制屏障,完成积压/活动通话/重试及去重保留处置后才能删除队列,不以自动过期直接删积压。 +- 本期仅可信 SaaS 发布服务连接 broker,最终用户不直连。发布服务强制执行每租户路由、速率和容量策略;AMQP 普通资源 ACL 不能替代可信发布服务的逐租户授权校验。 +- 队列满采用明确拒绝发布的溢出策略(建议验证 `reject-publish` 与 quorum 的组合),不能丢弃队头旧命令来给新命令让路。SaaS 任务/发布记录持久保留;nack、不可路由或 confirm 不确定时,有限退避后使用原 command_id/execution_id 重试,不换 ID、不转投其他租户队列。broker blocked/全局水位达到阈值时也要背压。 +- 消费端的公平轮转不能隔离发布洪峰的全部成本;大任务可长期保留在 SaaS 数据库,向本租户 MQ 分批有界推送。允许积压但不允许无限灌入;不能以独立队列为由承诺任意租户数、无限 quorum 副本或无限 broker 吞吐。 +- HTTP 暂停/停止控制保持原路径,不排在 A 的大批 execute 后;实际发起前的屏障校验仍强制执行。补传/失败重试不能绕过租户配额占满执行资源。 + +发布结果必须与呼出应用的业务受理区分: + +| AMQP 结果 | SaaS 处理 | +| --- | --- | +| confirm ACK 且未收到 mandatory return | broker 已接收并路由,不代表呼出应用已受理、已准入或已拨号 | +| mandatory return(不可路由) | 持久保留原发布记录,修复租户队列/绑定后在有效期内原 ID 有限重试;不得转投其他租户 | +| confirm NACK(含队列满时拒绝发布) | 记录发布未成功并退避;原因依 broker 诊断,不能把所有 NACK 都当成队列满 | +| confirm 超时、连接中断或 broker blocked | 状态不确定/发布受阻,实施背压;原 ID 有限重试并接受可能重复,不能假定消息未入队 | + +以上不是 HTTP 429,也不是 MQ `command.result` 业务拒绝;队列创建/绑定/配额通过受控平台配置落实,首版不新增管理 API。正文租户、路由键、队列绑定的正反例和 broker 版本相关的满队列行为必须纳入契约测试。 + +## 6. 执行命令 `call.execute` + +### 6.1 字段 + +通用外壳:`schema_version`、`command_type=call.execute`、`command_id`、`tenant_id`、`trace_id`、`issued_at`、`not_after`、`payload`。 + +payload 必需: + +| 字段 | 说明 | +| --- | --- | +| `execution_id`、`task_id`、`task_item_id` | 业务授权执行及任务归属 | +| `task_revision` | 必须等于当前生效且允许运行的控制版本 | +| `callee` | 原始业务被叫字符串,不提前拼供应商前缀 | +| `route_policy_id` | 已配置且获租户授权的线路策略引用,可只包含一个供应商 | +| `caller_profile_id` | 已授权主叫引用,不允许 arbitrary From/PAI | +| `agent_version_id` | 服务端已可解析的不可变智能体配置版本 | +| `variables` | 经字段白名单、类型/长度校验的运行变量,不是任意代码或任意 URL | +| `ring_timeout_ms`、`max_call_duration_ms` | 不超过服务端和供应商上限 | + +agent_version_id 必须对应呼出侧可用且可信的快照;配置如何同步、LLM/TTS 参数和取消/音频契约留待新规范。缺失配置明确拒绝,不私自读取旧 voice_test LLM/TTS 或任意远程 URL。 + +线路策略由呼出侧解析为授权 `trunk_id + egress_pool_id + cell_id`,实际选择写入事件。呼叫固定 Cell/出口;允许的 FALLBACK 也必须兼容同一 Cell/出口及供应商白名单,无适合备用就失败,不迁移活动通话。当前只有一家线路,不虚构 backup。 + +### 6.2 幂等与发起 + +1. 验证身份边界、Schema 和 tenant/task 关联;对已记录的 command_id/execution_id 先检查语义冲突并返回/关联原结果,不因重投时已过期或控制已变而改写原事实。新执行再检查有效期、控制版本和配置/线路授权;防重查重不绕过当前调用方的租户访问校验。 +2. 事务记录命令,使用 `(tenant_id, command_id)` 唯一键;另以 `(tenant_id, execution_id)` 保护业务执行,换 command_id 也不能绕过同一次执行去重。 +3. 同 execution_id 的语义冲突拒绝;相同执行重复只关联原结果,不能再次拨号。接受/拒绝事实及其 outbox 同事务提交,随后 ACK。 +4. 在租户公平调度下,实际拨号前原子取得跨 Cell 的租户并发/CPS 额度及供应商并发/CPS、Cell 端口/出口健康和 AI 配额等完整租约,重新检查有效期与控制屏障。已受理不等于已获得资源,FALLBACK 不绕过租户额度。 +5. 受理时固定 `admission_deadline = min(not_after, accepted_at + 服务端准入窗口)`,窗口值在 G0 冻结,不允许消息自报延长。broker 中未消费的等待不计入该窗口,但始终消耗 issued_at→not_after 的授权期限;取出时已过期直接拒绝。等待进入/原因变化及退出通过第 4.2 节查询和 MQ 事件表达。 + - 首次发起在持久化意图前检查:`now >= not_after` 优先拒绝为 `COMMAND_EXPIRED`;否则 `now >= admission_deadline` 按当前资源瓶颈拒绝,返回对应租户/线路/系统资源原因码;纯调度等待或截止时资源刚释放但尚未发起,均返回 `ADMISSION_TIMEOUT`。拒绝原因写入 `reason_code`,非 waiting 的 wait_reason_code 清空,保留 waiting_since 和 admission_deadline。到期处理不依赖资源释放或该租户再次获调度,必须有界扫描/唤醒并终结待发起命令;允许的处理延迟在 G0 冻结。 + - 已受理后拒绝需持久化并经 MQ 发布,不因原消息已 ACK 而丢失终结结果;准入失败释放已确认未使用的预留,不释放状态不明占用。是否新建获授权业务执行由 SaaS 对账后决定,不无限排队或私自重拨。 + - admission_deadline 只约束首次发起,不挂断已拨出/已接通电话;`not_after` 是每次新拨号尝试(包括 FALLBACK)的发起截止时间,不是活动通话的强制结束时间。FALLBACK 仍受总尝试/执行限制、控制屏障、租户 CPS 和完整资源准入约束,不能借其延长首次等待。 +6. ARI 发起前持久化意图、call_id/attempt_id 和固定通道关联;执行器真实发起前再次检查有效期、控制/租约及首次准入截止,不能仅凭早先提交意图越过截止。已提交意图但确认尚未发出且授权失效时,以 completed 和对应失败/取消的 call.finished 收尾,不回退为无 call_id 的 rejected;是否已发出不明时进入 reconciling。网络超时/重启先对账,已接通或旧通道未确认结束时禁止创建备用尝试。 + +## 7. MQ 事件契约 + +### 7.1 通用外壳与合并 + +必需:`schema_version`、`event_id`、`event_type`、`tenant_id`、`trace_id`、`occurred_at`、`aggregate_type`、`aggregate_id`、`aggregate_version`、`payload`。 + +- 聚合标识按 command/call/transcript_segment/recording 分域;版本由对应事实持久化事务单调递增,不依赖消息到达时间。 +- command/task/call/attempt 关联按事件类型必填;尚未生成 call_id 的拒绝不伪造 call_id。 +- SaaS 以 `(tenant_id, event_id)` 建 inbox 唯一键,并将业务更新和 inbox 同事务提交,成功后 ACK。 +- 版本比较限定同一实体及同一状态域。较旧事件不回退状态,但仍可补充尚未落库的独立 attempt/录音记录;不能用一个全局“最大版本”丢弃其他资产或文字。 +- 事件 payload 是该事件声明的状态域快照;call.finished 的终态与资产处理状态分别合并,不能覆盖后到或先到但更新的 recording.ready。 +- 不承诺端到端 exactly-once;采用至少一次传输、持久去重及外部拨号副作用对账。 + +### 7.2 事件列表 + +| Routing Key / event_type | 必需业务数据 | 合并规则 | +| --- | --- | --- | +| `command.result` | command_id、command_type、status、reason_code;适用的任务/执行/通话关联及版本;execute 增加第 4.2 节等待/准入字段 | 以 command 聚合版本更新;区分 accepted、waiting、executing、applied、completed;不把等待当成已拨号 | +| `call.status` | call_id、execution_id、任务关联、call_state、call_version、attempt_id、attempt 状态、实际线路/Cell/出口、时间/原因 | 保持 call/attempt 各自状态,不让迟到 ringing 回退 answered/ended | +| `transcript.updated` | call_id、turn_id、segment_id、role、revision、text、is_final、start_ms、end_ms、playback_state | 同 segment 较高 revision 替换;最终稿不能被迟到中间稿覆盖 | +| `call.finished` | call_id、execution_id、任务关联、call_version、outcome、起止时间、时长、原因、attempt 汇总及资产处理快照 | 固定通话终态;后处理未完成时标为 pending,不阻塞终态 | +| `recording.ready` | call_id、recording_id、oss_id、格式、声道、采样率、时长、大小及校验 | 只接收已验证资产;不包含上传凭证或公开 URL | +| `recording.failed` | call_id、recording_id、stage、reason_code、retryable、next_retry_at(若有) | 标记资产故障,不改变通话终态;后续合法 ready 可完成恢复 | +| `transcript.failed` | call_id、原因、retryable、受影响 segment(适用时) | 明确文字不完整,不能把部分文本假装最终完整记录 | +| `contact.opt_out` | call_id、task_id、task_item_id、请求时间、关联 turn/segment(若有) | SaaS 及时持久化拒绝再联系并阻止后续业务调度;不等待挂断 | + +后两类事件补齐文字失败及拒绝再联系的可追踪闭环;opt_out 的触发判定需由业务及新 AI 规范确认,不能默认靠未经验证的关键词误判。 + +文字 role 建议为 `customer` / `agent` / `system`;playback_state 使用 `not_applicable` / `generated` / `sent` / `playback_confirmed` / `cancelled` / `unknown`。只能报告实际具备的播放证据,不能将“已生成/已发送”写成“客户已听见”。超长 turn 拆成稳定 segment;最终稿是否允许修订及保留要求由 G0 冻结。 + +### 7.3 通话与资产状态 + +- call_state:`queued → dialing → ringing → answered → ended`,允许省略未发生的阶段。queued 仅表示已创建 call_id 并持久化意图但尚无实际发起证据;资源准入前的 waiting 属于命令状态,不提前创建通话。dialing/振铃/接通须有对应执行证据。失败可从任一未接通阶段进入 ended;FALLBACK 保持逻辑通话未结束并新增 attempt,不能回退成新业务通话。 +- outcome:`completed` / `busy` / `no_answer` / `rejected` / `failed` / `cancelled` 等标准值;completed 仅表示正常结束,不是营销成交。最终值及 SIP/Asterisk 映射待供应商确认。 +- 对账状态单独为 `reconciling`,不要凭本地连接断开生成虚假通话终态。 +- recording:`pending → uploading → verifying → ready`,失败另记 retryable/原因;transcript:`pending/streaming/finalized/failed`;delivery:`pending/broker_confirmed/failed`。三类后处理不改写 ended。 + +## 8. 最小交互样例 + +以下 ID 为示例,不是现网资源、真实号码或可调用配置。 + +### 8.1 暂停任务 + +```http +POST /internal/v1/outbound/tasks/task-demo/controls +Authorization: Bearer +X-Tenant-ID: tenant-demo +X-Request-ID: req-demo-01 +Idempotency-Key: cmd-pause-demo +Content-Type: application/json + +{"command_id":"cmd-pause-demo","action":"pause","expected_task_revision":1,"task_revision":2,"reason":"operator_pause"} +``` + +```json +{"command_id":"cmd-pause-demo","task_id":"task-demo","status":"accepted","requested_task_revision":2,"accepted_at":"2026-09-11T08:00:00.000Z"} +``` + +HTTP 返回 202 后,直到收到 MQ applied 或查询到 applied,SaaS 才能显示“暂停已生效”。即使收到 applied,已有通话也可能继续。 + +### 8.2 控制生效事件 + +```json +{ + "schema_version": "1.0", + "event_id": "evt-pause-demo", + "event_type": "command.result", + "tenant_id": "tenant-demo", + "trace_id": "trace-demo", + "occurred_at": "2026-09-11T08:00:01.000Z", + "aggregate_type": "command", + "aggregate_id": "cmd-pause-demo", + "aggregate_version": 2, + "payload": { + "command_id": "cmd-pause-demo", + "command_type": "task.control", + "task_id": "task-demo", + "status": "applied", + "reason_code": "CONTROL_APPLIED", + "requested_task_revision": 2, + "applied_task_revision": 2, + "task_state": "paused" + } +} +``` + +### 8.3 录音交接顺序 + +```text +呼出应用 → SaaS HTTP:申请 recording_id 绑定的上传授权 +呼出应用 → OSS:按签名授权上传封口文件 +呼出应用 → SaaS HTTP:complete;SaaS 实际校验,返回稳定 oss_id +呼出应用 → 本地数据库:verified 事实 + recording.ready outbox 同事务 +呼出应用 → RabbitMQ → SaaS:recording.ready,去重落库后 ACK +SaaS → 已授权用户:按 oss_id 提供短期鉴权播放 +``` + +### 8.4 拨号前等待及超时 + +以下是 `GET /internal/v1/outbound/commands/cmd-execute-demo` 的 execute 等待快照示例;同一事实的 `command.result` 使用第 7.1 节外壳,外壳 aggregate_version=2,payload 不重复携带该版本字段。 + +```json +{ + "command_id": "cmd-execute-demo", + "command_type": "call.execute", + "task_id": "task-demo", + "execution_id": "exec-demo", + "call_id": null, + "status": "waiting", + "reason_code": null, + "wait_reason_code": "TENANT_CONCURRENCY_EXHAUSTED", + "accepted_at": "2026-09-11T08:00:00.000Z", + "waiting_since": "2026-09-11T08:00:00.000Z", + "admission_deadline": "2026-09-11T08:00:30.000Z", + "updated_at": "2026-09-11T08:00:01.000Z", + "aggregate_version": 2 +} +``` + +示例假设服务端窗口为 30 秒、not_after 为 08:01:00Z;30 秒仅为演示,不是已冻结限值。08:00:30Z 仍无租户并发额度时,命令变为 rejected,reason_code=`TENANT_CONCURRENCY_EXHAUSTED`,wait_reason_code=`null`,版本递增并经 MQ 回传;不创建 call_id。若 not_after 更早,则以它作为 admission_deadline,到期报 `COMMAND_EXPIRED`。拒绝后原 ID 重投返回原拒绝,不重新排队。 + +### 8.5 暂停/停止与积压竞争 + +| 场景 | 契约预期 | +| --- | --- | +| execute 尚在租户队列,随后暂停/停止 | 控制不排在 execute 后;消费旧命令时检查持久屏障并拒绝。applied 表示不再允许新发起,不表示全部积压已处理完或拒绝事件已投递完 | +| execute 已 waiting,随后暂停/停止 | 未提交拨号意图的等待命令终结为 rejected,经 MQ 报 TASK_PAUSED/TASK_STOPPED;任务已恢复运行但命令仍为旧版本时用 STALE_REVISION。拒绝优先级为过期、停止、暂停、版本,再检查资源;不得被 resume 静默复活 | +| Cell 已持有在途许可,控制受理 | 先禁止新许可,等待旧许可完成或失效并确认无法再发起;状态不明维持 applying/reconciling,不提前发 applied | +| pause 已 applied,随后 resume | resume 只允许当前生效版本的新授权指令;旧版本仍拒绝,原 command_id/execution_id 重投仍命中原结果 | +| stop + drain / hangup | drain 不挂断活动电话;hangup 需额外权限并等通道结束确认,未确认不能称完全生效;停止任务不可 resume | + +## 9. 错误码与责任 + +| HTTP / MQ | code 建议 | 调用方处理 | +| --- | --- | --- | +| 400 / 拒绝 | `INVALID_ARGUMENT` | 修正字段,不原样无限重试 | +| 401 / MQ 连接鉴权失败 | `UNAUTHENTICATED` | 受控刷新/修复服务凭证,告警 | +| 403 / 拒绝 | `FORBIDDEN`、`ROUTE_NOT_AUTHORIZED` | 修复授权,不切其他租户或私自换线路 | +| 404 | `RESOURCE_NOT_FOUND` | 检查关联;含不可见资源,防枚举 | +| 409 | `IDEMPOTENCY_CONFLICT`、`REVISION_CONFLICT`、`CONTROL_IN_PROGRESS` | 查询原操作/当前版本后处理 | +| 409 | `TASK_STOPPED`、`UPLOAD_CONTENT_MISMATCH` | 停止非法操作或隔离资产,不自动覆盖 | +| 409,可重试 | `CALL_NOT_REGISTERED`、`UPLOAD_NOT_READY` | 有限等待并用原键重试,不绕过关联校验 | +| 410 | `REPLAY_EXPIRED` | 超出已保留事件范围,走人工恢复流程 | +| 413 | `PAYLOAD_TOO_LARGE` | 按合法分段/限制调整,不截断核心数据 | +| 429(仅 HTTP) | `RATE_LIMITED` | 接口限流,遵守 Retry-After 并用原幂等键有限重试;不是 AMQP 背压应答 | +| MQ 等待/准入拒绝 | `TENANT_CONCURRENCY_EXHAUSTED`、`TENANT_CPS_EXHAUSTED` | 当前租户跨 Cell 并发/CPS 不足;waiting 是暂时等待,rejected 才终结本次命令,不自动重拨 | +| MQ 等待/准入拒绝 | `ROUTE_CAPACITY_EXHAUSTED` | 授权线路无可用并发/CPS 或兼容健康出口;线路未授权仍报 ROUTE_NOT_AUTHORIZED,不混为容量不足 | +| MQ 等待/准入拒绝 | `CAPACITY_EXHAUSTED` | 系统 Cell/媒体或 AI 资源不足;已终结后由 SaaS 决定是否重新授权 | +| MQ 等待 / 准入拒绝 | `SCHEDULER_WAIT` / `ADMISSION_TIMEOUT` | 前者仅表示等待调度;纯调度等待超过准入期限用后者终结,不承诺固定开始时限 | +| 503 | `DEPENDENCY_UNAVAILABLE` | 有限退避、告警及停止接新任务保护 | +| MQ 业务拒绝 | `COMMAND_EXPIRED`、`TASK_STOPPED`、`TASK_PAUSED`、`STALE_REVISION`、`CONFIG_NOT_READY` | SaaS 决定是否重新授权调度 | +| MQ 执行状态 | `EXECUTION_UNCERTAIN` | 进入对账,不盲目重新拨号 | + +HTTP 错误码与 MQ reason_code 共用词汇但不是一一映射;MQ 没有 HTTP 状态码。完整枚举、SIP 原因映射和错误描述脱敏在冻结阶段补齐。 + +## 10. 安全、容量与可观测性 + +- 服务权限细分为读、控制、强制挂断、补传、上传和完成确认;租户/任务/通话/资产逐层校验,不凭 URL 中的 ID 直接放行。 +- 上传完成确认与事件消费均校验同租户关联,防止将其他租户 oss_id 挂到本通话。播放授权由 SaaS 按最终用户权限执行。 +- 被叫、转写和录音按敏感业务数据处理,日志脱敏;密钥、签名地址、ARI 密码、SSH 私钥不进入代码/文档/事件。DLQ 和审计也受访问与保留控制。 +- 记录 command_id/execution_id/call_id/attempt_id/event_id 关联及控制、重放、挂断操作审计;真实身份从认证上下文取得,不仅依赖 reason 文本。 +- RabbitMQ 不放在实时音频循环上;最终文字与重要状态持久化,临时中间稿可在形成事件前合并,但不能丢最终稿。积压/磁盘水位达到上限时停止新接单。 +- 容量目标仍是至少 1000 路同时已接通的完整 AI 通话,不是 HTTP QPS。MQ/OSS 负载应按每秒文字事件、每通话状态事件和录音产生速率单独估算、压测。 +- 监控 HTTP 延迟/429/5xx、命令准入时延、控制屏障耗时、outbox 最老年龄、MQ redelivery/DLQ、SaaS 消费落库延迟、OSS 校验/上传失败、暂存磁盘和资源租约耗尽。 +- 按租户统计队列深度/字节、最老命令年龄、可调度等待时间、实际新增拨号份额、并发/CPS 占用、未 ACK/待发起窗口及背压次数;分别标注配额不足、无线路资源和纯调度等待。压测 broker 队列/副本数量和全局水位,不能只看全局平均延迟掩盖 B 饥饿。 +- broker confirm 与 SaaS 应用成功分开观测;本版不新增应用收讫回调接口,端到端验收结合 SaaS inbox/入库证据。若需要运行时逐事件应用确认,另评审 MQ receipt 契约。 + +## 11. G0 冻结清单 + +| 决策 | 本文建议/待补信息 | 确认方 | +| --- | --- | --- | +| 接口归属及域名 | 两份 OpenAPI;SaaS 是否已有可复用资产服务、测试 Base URL | 用户、SaaS | +| 服务身份 | 现有服务令牌体系优先;issuer/audience/scope、租户映射、轮换和 mTLS | 双方、运维 | +| 任务绑定与版本 | 初始版本 1、CAS 控制、停止不可恢复、可信任务归属来源 | 用户、SaaS | +| 执行幂等 | 新增 execution_id,与 command_id 分离;业务重新外呼许可及去重保留 | 用户、SaaS | +| 停止语义 | drain/hangup 显式选择;挂断权限与多 Cell 生效判据 | 用户、SaaS | +| 线路/AI 配置 | route/caller/agent 引用及同步来源;LLM/TTS 新规范、失败兜底 | 用户、供应方 | +| MQ 环境 | 租户独立命令队列已确认;冻结 tenant_key 映射、精确绑定、生命周期、队列数上限、quorum/HA、ACL、重试/DLQ 与死信可靠性 | 运维、双方 | +| 租户公平与背压 | 公平调度架构已确认;冻结轮转批量/周期、活跃队列发现、权重、prefetch、接收窗口、并发/CPS、发布速率/积压上限、拒绝发布策略、多实例协调及等待指标 | 用户、双方、运维 | +| 保底与借用 | 默认不承诺固定开始时限;如需 SLA,确认保底资源、借用/归还边界及可满足的租户总承诺 | 用户、业务/运维 | +| OSS ID 和校验 | 资产 ID 权威来源、校验算法、禁止覆盖/对象版本、文件大小和格式 | 用户、SaaS/存储 | +| 保留与恢复 | 幂等/屏障/事件/inbox/临时录音保留,最长中断和人工恢复范围 | 双方、业务/运维 | +| 限制与 SLO | 256 KiB MQ、64 KiB HTTP 为候选;首次准入窗口、到期处理延迟、not_after 边界、等待原因优先级、超时/重试/退避、CPS、事件时延和上传授权有效期需填实值;准入截止不是开始 SLA | 双方、运维 | +| 文字/拒绝再联系 | 最终稿修订、分段、播放证据、opt_out 触发和生效时限 | 用户、SaaS | +| 版本演进 | HTTP 主版本 `/v1`、MQ schema_version `1.x`;兼容矩阵和旧版退役窗口 | 双方 | + +兼容建议:响应/事件允许增加可选字段;新增必填、字段语义变化或不兼容枚举按破坏性变更处理。对未知命令版本 fail closed;未知事件主版本隔离告警,不直接 ACK 丢弃。事件 payload 按 schema_version 校验,不能一边 strict 拒绝扩展一边声称任意可选字段都兼容。 + +## 12. 实施与验收顺序 + +1. **契约评审**:用户逐项确认第 11 节,发布冻结字段/状态/限值、双方负责人和变更记录。 +2. **机器可读规范**:编写双方 OpenAPI 3.1、MQ AsyncAPI/JSON Schema、完整请求/响应及正反例;验证引用、样例、权限和错误响应。 +3. **契约测试/Mock**:先验证 HTTP 控制与查询、MQ 去重和 OSS 存储握手;Mock 通过不表示真实 SIP/AI 可用。 +4. **真实单通话联调**:授权测试号码,打通 execute → 状态/终态 → OSS → ready → SaaS 页面;分别记录 ASR、LLM/TTS、SIP、MQ、OSS 证据。 +5. **恢复和容量**:重复、乱序、跨租户、重启、多 Cell 屏障、上传失败、断网与补传;新增大租户洪峰下小租户公平、背压及多调度实例配额验收,完成真实完整 AI 的容量/延迟测试后再发布生产结论。 + +| 验收项 | 必须得到的证据 | +| --- | --- | +| 唯一拨号入口 | HTTP 规范无创建/重拨接口;补传不会调用 ARI originate | +| 双层幂等 | 重复 command_id、换 command_id 但相同 execution_id、ACK 丢失/进程重启均不重复拨号 | +| 不确定发起 | ARI 超时后先对账,未确认原通道结束不再发起 | +| 等待与准入截止 | call_id 尚无时按 command_id 查询 waiting;等待事件/查询版本一致;窗口与 not_after 取较早值,重投/重启不延长;租户/线路/系统瓶颈及纯调度超时原因可区分;拒绝后不再拨号,已发起通话不因首次准入截止被挂断 | +| 控制屏障 | 多 Cell 在途竞争、节点失联、旧版本迟到和恢复后都不能越过暂停/停止;202 不等于 applied | +| 租户隔离 | 跨租户查询、控制、录音授权/确认/关联和补传均失败;伪造 tenant_key、正文租户或错误绑定不能越权执行 | +| 公平调度 | A 大量积压后 B/C 新入队,在 B/C 有额度且资源可用时,无需等 A 排空即可获调度;测量到达→入轮转→发起的分段延迟和实际份额,达到 G0 冻结指标;有界预取、已 ACK 积压及重启恢复不破坏公平 | +| 多实例配额 | 多调度器/多 Cell 同时争抢、FALLBACK、所有权切换/失联重启均不重复分配租户额度;待对账活动通话不因租约过期被误释放,不超并发/CPS | +| 背压与队列生命周期 | A 队列满/发布洪峰时明确拒绝且 SaaS 持久保留,confirm 丢失原 ID 重试不双拨、不丢旧消息;B 在约定 broker 负载范围仍可发布调度;租户创建/停用/恢复不丢积压、不误删队列 | +| 非抢占边界 | A 已占满可用资源时不为 B 强制挂断;按释放资源继续公平分配。若签订开始时限 SLA,另验证不可借用保底或明确借用约束,不用普通轮询冒充保证 | +| 乱序终态 | 迟到 ringing 不回退 ended;call.finished 不覆盖更新的 ready;旧文字不覆盖最终稿 | +| OSS 完整性 | 缺对象、错误大小/摘要、授权过期、完成后覆盖、complete 超时重试均不产生无效或重复 ready | +| MQ 可靠性 | 不可路由、confirm 丢失、SaaS 事务失败、重试队列/DLQ 故障均可追踪恢复,ACK 不早于持久化 | +| 补传边界 | 固定截止点、原 event_id/内容、无拨号、保留期过期明确报错;不得以 HTTP 结果替代 MQ | +| 完整交付 | SaaS inbox/业务落库、文字时间线和 oss_id 授权播放有证据;1000 路完整 AI 另有真实压测报告 | + +**本版完成定义:** 完整规划草案可供评审;不意味着详细规范已冻结、服务已实现、云资源已部署或生产验收已通过。 diff --git a/docs/archived/AI智能体外呼SaaS平台_三期实施评估.xlsx b/docs/archived/AI智能体外呼SaaS平台_三期实施评估.xlsx new file mode 100644 index 0000000..012ef78 Binary files /dev/null and b/docs/archived/AI智能体外呼SaaS平台_三期实施评估.xlsx differ diff --git a/docs/archived/AI智能体外呼SaaS平台_三期实施评估_团队配置版.xlsx b/docs/archived/AI智能体外呼SaaS平台_三期实施评估_团队配置版.xlsx new file mode 100644 index 0000000..62e8a4e Binary files /dev/null and b/docs/archived/AI智能体外呼SaaS平台_三期实施评估_团队配置版.xlsx differ diff --git a/docs/archived/AI智能体外呼SaaS平台_三期实施评估_团队配置版_v2.xlsx b/docs/archived/AI智能体外呼SaaS平台_三期实施评估_团队配置版_v2.xlsx new file mode 100644 index 0000000..39d5d51 Binary files /dev/null and b/docs/archived/AI智能体外呼SaaS平台_三期实施评估_团队配置版_v2.xlsx differ diff --git a/docs/archived/AI智能体外呼SaaS平台_企业级需求规格说明书_v1.0.docx b/docs/archived/AI智能体外呼SaaS平台_企业级需求规格说明书_v1.0.docx new file mode 100644 index 0000000..aa75b29 Binary files /dev/null and b/docs/archived/AI智能体外呼SaaS平台_企业级需求规格说明书_v1.0.docx differ diff --git a/docs/archived/一期呼出应用开发计划_v1.0.docx b/docs/archived/一期呼出应用开发计划_v1.0.docx new file mode 100644 index 0000000..5b08809 Binary files /dev/null and b/docs/archived/一期呼出应用开发计划_v1.0.docx differ diff --git a/docs/一期呼出应用开发计划_v1.0.md b/docs/一期呼出应用开发计划_v1.0.md new file mode 100644 index 0000000..11d8579 --- /dev/null +++ b/docs/一期呼出应用开发计划_v1.0.md @@ -0,0 +1,278 @@ +# 一期呼出应用开发计划 + +**版本:** v1.3(沿用原文件路径) +**文档状态:** 3.1 交互方案已确认:所有业务结果经 MQ 回传,录音先上传 OSS 后回传 OSS ID;接口与消息规范由本人制定。生产网络架构已确认采用多机器+多 EIP 直连,不纳入单 EIP+NAT 方案;新增确认租户独立命令队列+公平调度。具体配额、等待指标、环境和新增工作量经 G0 评审后执行,运行代码待实现。 +**适用范围:** 本人负责的一期外呼应用,以及与现有 SaaS、RabbitMQ、指定 SIP 系统的对接。 +**编制依据:** 用户最新确认的工作范围。既有 Excel 仅作背景参考,本文件独立交付,不修改 Excel,也不沿用其三期工时汇总。 + +**本次修订:** 对齐交互规划 v0.3 的 execution_id、拨号前等待/查询、准入截止与 MQ 背压语义;明确单线路可启动、无备用不虚构。六类 HTTP 路径不变,详细字段、状态和数值仍待 G0 冻结,本次不改运行代码。 + +## 1. 目标与范围 + +### 1.1 本人负责的交付 + +1. 外呼任务与基础调度的**数据契约、指令接收、受理/执行应答及状态对账**。 +2. **Asterisk 呼叫控制、指定 SIP 系统接入和 FALLBACK**。 +3. **实时语音 AI 链路的呼出应用**:衔接媒体、ASR、LLM、TTS,处理通话生命周期和打断。 +4. 将**通话状态、文字及录音数据回传 SaaS**,供 SaaS 存储和展示;负责投递失败重试、补传与可追踪性。 + +“本人负责”指上述模块的设计、开发、联调和交付,不再把核心实现默认分配给 BgB。本人是否同时为原团队 ArchA,在 G0 登记;同一人不得被当成两个并行开发资源。 + +### 1.2 系统边界 + +| 系统 | 本期职责 | 不承担的职责 | +| --- | --- | --- | +| 现有 SaaS | 租户/权限、客户与任务管理、号码选择、基础业务调度、业务重试决策;按可信租户归属分队列发布,背压时持久保留待发布任务;接收回传数据、长期存储、页面展示 | 不直接操纵 Asterisk 通道,不与呼出应用各自重复发起同一通话 | +| 本人开发的呼出应用 | 按租户队列公平接收与调度执行指令;跨 Cell 租户配额及资源准入;维护执行事实;控制 Asterisk/AI;主备尝试;应答、回传和补偿 | 不复制 SaaS 的客户库、任务编排系统、账本和管理后台 | +| RabbitMQ | 沿用 SaaS 的消息基础设施,按租户隔离执行命令队列,承载所有业务结果事件及其重试/死信 | 队列隔离不等于调度公平或资源独占;不把消息 ACK 当成接通或业务成功;录音先上传 OSS,MQ 仅回传 OSS ID 和元数据,不运输录音二进制 | +| 指定 SIP 供应商线路 | 提供各 trunk 的接入参数、呼叫能力、原因码、并发/CPS 和线路规则 | 不假定线路天然跨故障域;供应商白名单与线路能力由线路方确认 | + +呼出应用可以保存必要的执行、去重、控制屏障和投递记录,但不建立第二套 SaaS 主数据体系。 + +### 1.3 明确不做 + +- 计费、余额、支付、套餐、发票;保留基础用量指标,不实施扣费。 +- 通用开放平台、第三方开发者门户、多种回调通道同时建设。 +- RAG、流程编排、人工坐席及未经审核的开放式多线路编排;已审核的供应商 trunk 按约定策略动态选路属于本期外呼能力。 +- 自研 ASR/LLM/TTS 模型、自研完整媒体引擎、集群自动扩容。 +- SaaS 全部页面和业务模块重建。SaaS 配合改造由其对应负责人交付。 + +### 1.4 生产网络与容量基线(已确认) + +- 生产唯一网络方案为**多机器+多 EIP 直连**。每个语音 Cell 包含 Asterisk、媒体适配和本地执行器,并绑定固定出口 IP;调度器按 `trunk_id + egress_pool_id + cell_id` 分配任务。 +- 一通电话从建立到结束固定使用同一 Cell 和出口;供应商 trunk 预先配置,逐呼只选择已授权线路并应用其号码规则,不逐呼重写共享 SIP 配置、重载 Asterisk 或重建注册。 +- 每个 Cell/出口独立维护供应商白名单、RTP 端口范围、并发上限、CPS、编解码和健康状态。供应商限制优先于本地理论容量。 +- **单 EIP+NAT 不作为备选方案**,本计划不设计、不实现、不验收该架构;不得以通用 SNAT 代替多 EIP 直连。 +- 容量口径为至少 **1000 路同时已接通的完整 ASR/LLM/TTS 通话**。拨号、振铃、CPS、AI 配额、RTP/UDP 端口、带宽、文件描述符、MQ、OSS 和故障冗余必须单独计入。 +- 采用 N+1 或更高冗余。按真实压测得到的单 Cell 安全容量计算:`(Cell 数量 - 1) × 单 Cell 安全容量 >= 1000`,并额外预留发布、线路故障和突发拨号余量。该冗余保证故障后的承接能力,不保证故障 Cell 中的活动通话无损迁移。 +- 当前 RTP `10000–10800` 仅是配置基线,不是 1000 路容量保证;G4 前必须完成真实 SIP/RTP、ASR/LLM/TTS 和供应商配额压测,未压测不得承诺机器数量或规格。 + +### 1.5 多租户队列与公平调度(已确认,运行代码待实现) + +- 采用**按租户 ID 映射到独立 RabbitMQ 命令队列+呼出应用调度器公平调度**。A 的大量积压不应使 B 必须等待 A 排空后才能进入执行调度;不再采用所有租户共用一个执行 FIFO。 +- SaaS 依据可信租户归属发布;平台负责队列创建、精确路由绑定和停用/清理,不接受调用方任意指定其他租户队列。任务创建、业务重试仍由 SaaS 决定;执行公平与资源许可由呼出应用负责。 +- 活跃且可调度租户间默认建议等权轮询;有明确差异化需求后按平台权重调度。每轮有限取数、按租户及全局限制预取和已持久化待发起窗口;额度耗尽或线路不可用时跳过该租户,不阻塞其他租户。不能消费到无界内存 FIFO 后再宣称公平。 +- 租户并发/CPS 额度跨所有 Cell 和调度实例汇总;并发包括发起预留、拨号、振铃、接通和待对账占用,CPS 包括 FALLBACK。拨号前同时满足租户、供应商、Cell/出口和 AI 配额;多实例采用原子额度及调度所有权/租约协调,失效实例不能重复分配。 +- 每租户限制发布速率、队列消息数/字节及待执行窗口,辅以全局 broker 水位保护。满队列明确拒绝发布,不丢弃队头旧命令;SaaS 保留发布记录,confirm 不确定或失败时使用原执行标识有限重试。重试/死信恢复回到原租户调度域,不能绕过配额。 +- 有资源时保障公平分配新许可,不为公平强制挂断已接通电话;资源已满时需等释放。若要求固定开始时限,必须另外确认线路/AI/Cell 全链路保底或受限借用策略,不把轮询等同于 SLA 保证。 +- 队列数量、轮转参数、配额与时延数值在 G0 冻结;详情见 [SaaS 交互规划第 5 节](SaaS交互_OpenAPI与MQ契约规划_v0.1.md)。本轮不改变事件回传队列为租户独立队列,不增加 HTTP 拨号入口。 + +## 2. 分工与双方交付物 + +| 负责人 | 交付内容 | 对接对象 | +| --- | --- | --- | +| 本人:呼出应用负责人 | 制定并发布 OpenAPI、MQ 拓扑、消息/事件字段、鉴权、错误码、幂等/重试及 OSS ID 回传规范;交付执行服务、ARI/SIP/FALLBACK、AI 呼出应用、MQ 结果回传和专项测试 | SaaS 后端、部署负责人、SIP/AI 供应方 | +| BgA:建议负责 SaaS 业务接口 | 任务/号码/配置快照,租户授权校验,控制接口调用,执行状态映射及查询接入 | 本人、FeA | +| BgB:建议负责 SaaS 消息与资产接入 | RabbitMQ 按租户路由发布、发布限流/背压持久重试,回传接收与去重,文字/录音元数据落库,上传授权与补传联调 | 本人、BgA、存储负责人 | +| FeA:SaaS 展示 | 任务/通话状态、文字时间线、录音鉴权播放、失败/处理中状态、联调验收页面 | BgA、BgB | +| 原团队部署负责人/ArchA | 环境、网络、证书、密钥、租户队列生命周期/绑定/ACL/容量策略、监控、发布和回滚支持 | 本人及各服务负责人 | + +BgA/BgB 为建议配合分工,G0 由 SaaS 团队认领。不把部署负责人视为新增第五名成员;若与本人重合,需合并其工作日历。 + +## 3. 最小交互方案与执行顺序 + +### 3.1 已确认的最小方案 + +**已确认:RabbitMQ 下发呼叫执行指令,所有业务结果必须经 MQ 回传;录音先上传 OSS,上传成功并取得 OSS ID 后,通过 MQ 回传 OSS ID 及必要元数据。接口与消息规范由本人制定。** + +- 不增加第二条能够独立拨号的 HTTP 入口。OpenAPI 仅承担任务控制、查询、OSS 上传授权及完成确认;同步接口应答不替代业务结果事件,不保留 HTTP 业务回调链路。 +- 受理/拒绝、执行状态、文字、最终结果和录音就绪等事件统一走 MQ。录音文件不进入 MQ,上传失败不得发送录音就绪事件,失败状态仍经 MQ 回传。SaaS 根据 OSS ID 关联录音并提供租户授权播放。 +- 由本人制定并发布 Exchange/Queue、Routing Key、消息/事件字段、API 路径、鉴权、错误码、版本、幂等/重试以及 OSS ID 命名和取值规范;SaaS 与运维按该规范对接、验证和落地。本文具体接口/字段仍为草案,本次确认不代表详细规范已经发布。 + +### 3.2 一次外呼的交互步骤 + +1. SaaS 创建任务及号码明细,固定智能体配置版本;执行租户授权、允许时段、基础频控及拒绝再联系检查。 +2. SaaS 业务调度器为一次授权外呼生成 `execution_id`,为执行命令生成 `command_id`,按可信租户 ID 映射路由向该租户独立队列发布 `call.execute`,携带任务项、有效期及执行所需快照;受发布限流/队列背压时持久保留,两个 ID 均保持不变重试。 +3. 呼出应用在活跃租户间公平轮转、有界取数,校验队列绑定与正文租户一致、授权归属、有效期和任务控制版本;持久化命令及去重结果后 ACK。此时仅表示“已接收”,不是“已拨号”。 +4. 呼出应用通过 MQ 回传受理/拒绝应答。尚未取出的命令受租户队列容量和有效期约束;已受理但未准入时以 waiting 状态、等待原因和截止时间表达,通过 command_id 查询。准入截止取 not_after 与 accepted_at+服务端窗口的较早值,重投/重启不延长;超限区分过期、租户/线路/系统瓶颈或纯调度超时,经 MQ 终结,由 SaaS 对账后决定是否重新授权,不无限堆积。 +5. 呼出应用公平分配执行机会,原子取得跨 Cell 租户并发/CPS 额度及供应商/Cell/出口/AI 完整资源许可;首次准入成功并提交拨号意图时分配 `call_id`、`attempt_id`,此前 call_id 为 null。拨号前再次检查暂停/停止屏障与有效期;提交意图不等于已发出 SIP 或接通,状态不明先对账。 +6. 通过 Asterisk 向指定主 SIP 接入发起呼叫,通过 MQ 回传拨号、振铃、接通或失败状态。 +7. 主尝试明确未接通且符合允许的线路故障条件时,确认旧通道结束后才选择已配置、获授权且兼容同一 Cell/出口的备用线路;无适合备用则结束失败,不虚构备用。状态不明时先对账,不盲目重拨。 +8. 接通后建立媒体会话,衔接 ASR→LLM→TTS,处理客户插话、静音、超时及主动结束。 +9. 文字按中间稿/最终稿通过 MQ 回传 SaaS。回传或 SaaS 页面故障不得阻塞实时音频链路。 +10. 挂断后关闭媒体会话并清理通道,写入独立的通话终态并通过 MQ 回传;文字最终稿、录音上传等后处理继续异步执行。 +11. 获取 OSS 上传授权,将录音上传 OSS,完成校验并取得 OSS ID 后,通过 MQ 发布含 OSS ID 的录音就绪事件;SaaS 消费后关联录音并展示。上传失败通过 MQ 回传失败状态,不发送无效的录音就绪事件。 +12. 对未确认发布或未被 SaaS 应用的事件进行有限重试、MQ 补传和对账。SaaS 可查询通话及资产处理状态,查询不代替所有业务结果必须经 MQ 回传的要求。 + +## 4. G0:开发前确认清单 + +下列内容先在测试环境确认,不要求全部生产资源提前到位;未确认的关键外部依赖不能计为已经解决。 + +| 确认项 | 需要得到的结论 | 提供方 | +| --- | --- | --- | +| 工作边界 | 本人实际可投入时间;现有呼出应用、媒体组件和 SDK 可复用程度;SaaS 调度由谁实现 | 本人、SaaS 团队 | +| OpenAPI | 本人制定控制、查询、OSS 上传授权/完成确认的路径、字段、鉴权、错误码、超时、幂等和版本规则;业务结果走 MQ 已确认,不再作为待选项 | 本人制定;SaaS 后端对接 | +| RabbitMQ | 租户独立命令队列已确认;制定 tenant_id 路由映射、队列/绑定生命周期、ACL、消息/队列容量、拒绝发布背压、Confirm/ACK、按租户重试/DLQ 恢复;验证队列数量与 broker 全局资源上限 | 本人制定;SaaS/运维落地 | +| 公平调度 | 冻结轮转/权重、取数批量、活跃租户发现、未 ACK/待发起窗口、跨 Cell 租户并发/CPS、发布速率、多实例额度/所有权协调;明确等待指标及是否需要保底/借用 | 本人、SaaS、部署方 | +| 任务控制 | 暂停/恢复/停止语义;停止是否挂断已接通电话;业务重试与主备切换的边界 | SaaS、本人 | +| 指定 SIP 供应商线路 | 各 trunk 的测试账户、接入、注册或 IP 鉴权、主叫限制、拨号格式、并发/CPS、失败码、出口白名单和线路选择政策 | SIP/线路方 | +| Asterisk 与媒体 | Asterisk 版本、ARI 能力、多 Cell/EIP 网络、SIP/RTP 防火墙、编解码及采样率;测试电话双向可听 | 部署方、本人 | +| AI 接入 | 可用流式 ASR/LLM/TTS 接口、凭证、音频格式、取消能力、并发配额和超时限制 | AI 供应方、本人 | +| 文字与录音 | 中间稿展示要求;单/双声道;OSS 上传协议、校验、OSS ID 字段及取值、长期保留与播放授权;“先上传 OSS、再经 MQ 回传 OSS ID”已确认 | 本人制定回传规范;SaaS、存储/业务负责人配合 | +| 容量与验收 | 目标并发、CPS、最长通话、响应/打断延迟、回传时延、最长可恢复中断、临时磁盘上限 | 双方、部署方 | + +**G0 输出:** 本人制定并发布的接口/消息契约 v1(含 OSS ID 回传规范)、对接验证记录、测试账号与样例、参数基线、风险清单及确认后的排期。不以“CRUD 开发速度”替代实时语音接入验证。 + +## 5. 接口、消息和应答契约 + +### 5.1 OpenAPI 清单草案 + +| 方向 | 接口草案 | 职责与应答 | +| --- | --- | --- | +| SaaS→呼出应用 | `POST /internal/v1/outbound/tasks/{task_id}/controls` | 提交暂停/恢复/停止;含 `command_id`、`task_revision`、动作。返回受理结果,执行生效另有确认 | +| SaaS→呼出应用 | `GET /internal/v1/outbound/commands/{command_id}` | 查询受理、等待/原因/准入截止、执行中、已生效、拒绝或失败;call_id 尚无时也可对账;重复请求不改变状态 | +| SaaS→呼出应用 | `GET /internal/v1/outbound/calls/{call_id}` | 查询通话终态、尝试记录、文字/录音处理状态和回传进度 | +| 呼出应用→SaaS | `POST /internal/v1/outbound/recording-uploads` | 获取绑定租户/通话、格式/大小约束和有效期的 OSS 上传授权;按本人制定的存储协议返回上传地址/会话 | +| 呼出应用→SaaS | `POST /internal/v1/outbound/recording-uploads/{upload_id}/complete` | 确认 OSS 上传完成及校验结果,返回或确认 OSS ID;随后通过 MQ 发布录音就绪事件,此接口不承担业务结果回调、不传 Base64 | +| SaaS→呼出应用 | `POST /internal/v1/outbound/calls/{call_id}/replays` | 授权触发结果/文字/录音元数据经 MQ 补传;只重传数据,绝不重新拨号 | + +不提供 HTTP 业务事件接收接口;事件按类型定义 MQ Schema。接口路径及 OSS ID 字段的最终规范由本人制定并发布,SaaS 按规范对接。现有上传/资产能力可复用,但不改变“先上传 OSS,再通过 MQ 回传 OSS ID”的顺序。 + +### 5.2 RabbitMQ 执行指令 + +按租户 ID 映射独立队列,命令类型仍为 `call.execute`。具体 Exchange、带租户路由键及队列命名草案见 [SaaS 交互规划](SaaS交互_OpenAPI与MQ契约规划_v0.1.md);路由与正文 tenant_id 必须一致,重投不能改投其他租户以绕过限流。 + +`call.execute` 最小字段: + +- 通用:`schema_version`、`command_id`、`tenant_id`、`trace_id`、`issued_at`、`not_after`。 +- 业务:`execution_id`、`task_id`、`task_item_id`、`task_revision`、原始被叫 `callee`、已授权的 `caller_profile_id` / `route_policy_id`;不预拼供应商前缀,不让调用方任意指定 SIP 地址、凭据或 Cell/出口。 +- 配置:`agent_version_id`、变量及不可变配置快照,或双方确认的快照读取引用。 +- 约束:最大通话时长、振铃超时、允许的 FALLBACK 策略引用。 + +执行指令不要求尚未生成的 `call_id/attempt_id`。同一命令的网络重试保持 command_id/execution_id 不变;同时以 `(tenant_id, command_id)` 和 `(tenant_id, execution_id)` 去重,换 command_id 也不能让同一次业务执行再次拨号。内部合法 FALLBACK 仅新建 attempt_id,保持 execution_id/call_id;业务重新外呼须先确认原执行事实并取得新授权,再生成新的 execution_id/command_id,受 SaaS 重试政策限制。不得把凭证直接塞进可广泛访问的消息或日志。 + +准入字段、状态/原因、时限和查询样例以 [SaaS 交互规划第 4.2、6.2、8.4 节](SaaS交互_OpenAPI与MQ契约规划_v0.1.md) 为详细草案:未消费持久化的命令允许查不到,404 不能当成未发布/未拨号证据;等待查询及 MQ 快照使用同一命令版本域。首次准入截止只限制首次发起;not_after 限制每次新尝试(含 FALLBACK),均不作为已发起通话的强制挂断时间。 + +### 5.3 MQ 回传事件 + +所有业务结果事件统一经 MQ 回传,规范由本人制定。录音回传使用 OSS ID,字段示例为 `oss_id`,最终命名及取值由本人规范确定;不默认将其等同于文件名、Object Key、ETag 或播放 URL。 + +通用外壳:`event_id`、`event_type`、`schema_version`、`tenant_id`、`trace_id`、`occurred_at`、`payload`;按事件类型要求 `command_id/task_id/task_item_id/call_id/attempt_id`,不统一强制全填。 + +| 事件类型 | 核心数据 | SaaS 的处理 | +| --- | --- | --- | +| `command.result` | 原命令、execution_id、受理/等待/执行/生效/拒绝/失败、原因、控制版本;execute 的 wait_reason_code、waiting_since、admission_deadline 和可空 call_id | 分清受理、资源等待、提交拨号意图和实际通话状态;按命令聚合版本更新 | +| `call.status` | 通话与尝试、状态版本、实际 trunk_id/egress_pool_id/cell_id、时间、标准原因和原始 SIP/Asterisk 原因 | 幂等更新,不被迟到事件回退终态;实际选路也进入通话查询 | +| `transcript.updated` | `turn_id`、角色、文本版本/序号、文本、中间/最终标记、起止时间、播放/取消标记 | 更新同一轮文字,不把中间稿累加成重复句子 | +| `recording.ready` | `call_id`、`oss_id`(OSS ID)、格式、声道、采样率、时长、大小、校验;`recording_id`可作业务关联标识 | 仅在 OSS 上传成功并确认 OSS ID 后发布;SaaS 根据 OSS ID 关联录音并提供租户授权播放 | +| `call.finished` | 通话终态、开始/接通/结束时间、时长、原因、FALLBACK 使用情况、各资产处理状态 | 电话结束即可落业务终态;后处理允许随后补齐 | + +时间戳采用带时区的统一格式,时长单位由本人在 Schema 中明确。文本序号和状态版本分别定义作用域,不依赖 MQ 全局顺序;重试和补传保留原 `event_id`,避免重复应用。 + +### 5.4 可靠性与控制语义 + +- 命令 MQ ACK:仅在执行指令及幂等信息已持久化后确认。网络重投使用同一执行标识。 +- 发布背压:confirm ACK 且无 mandatory return 只证明 broker 接收并路由;NACK、不可路由、confirm 不确定及 broker blocked 按交互规划第 5.4 节持久保留、原 ID 有限退避/修复。满队列明确拒绝、不丢旧消息,不能将 AMQP 背压映射成虚构的 HTTP 429 或已受理业务拒绝。 +- 结果 MQ 发布:启用 Publisher Confirm 和不可路由检查;确认只代表消息进入有效队列,不代表 SaaS 已完成业务入库。 +- SaaS 消费 ACK:按 `event_id` 去重,业务数据与接收记录持久化成功后 ACK;消费失败可重试/补传,不能静默丢失。 +- 事件产生:执行事实与待投递记录同事务提交;独立投递任务负责重试。SaaS 接收侧同样去重,不依赖“恰好一次”假设。 +- 拨号副作用:调用 ARI 前持久化发起意图及固定通道关联标识;超时/进程重启后先核实 Asterisk 的实际通道,不因本地缺少成功记录就再次拨号。无法核实的执行进入待对账。 +- 重试分类:MQ 网络故障、发布未确认、不可路由或消费失败按本人制定的退避/DLQ规则处理;非法 Schema 隔离,鉴权异常告警。HTTP 的429/可恢复5xx重试仅用于控制、查询和 OSS 上传相关接口,不作为 HTTP 业务回调的备用链路。 +- 过期/停止任务:死信重放仍检查有效期、控制版本和业务许可;数据补传不得触发重新外呼。 +- 暂停:SaaS 停止发新指令;呼出应用建立覆盖 broker 积压、已持久化 waiting 和多 Cell 在途许可的控制屏障。未发起的等待执行按当前屏障/版本拒绝并经 MQ 回传;尚在队列的旧命令消费时拒绝,resume 不静默复活。待在途发起许可处理完或确认失效后才回传“已生效”;状态不明维持 applying/reconciling,applied 不代表积压全部清理或已有电话全部结束。竞争样例见交互规划第 8.5 节。 +- 停止:旧版本和迟到指令不能重新开启任务;是否终止已接通电话按 G0 确认的策略执行,强制挂断必须鉴权和审计。 +- 终态:呼叫、文字/录音后处理、回传投递分别记录状态。录音失败不改写电话已经结束的事实。 + +## 6. 开发任务步骤与工时 + +下表保留**原本人负责范围的基准工时**,包含设计、编码、自测和正常联调,不包含 SaaS 团队实现工时。已将租户队列、公平调度和专项测试并入对应工作包,但其增量工时尚未重估;原合计不能视为已覆盖新增范围的交付承诺,G0 完成增量评估后更新总工时和排期。每项先有可验证产出,再进入下一依赖阶段。 + +| WBS | 任务包 | 开发步骤及交付物 | 本人工时 | +| --- | --- | --- | --- | +| D01 | 对接契约与技术验证 | 盘点已有组件→确认双方职责→冻结 OpenAPI/消息/状态→完成样例与 Mock→验证一通测试电话和 AI 流式能力 | 24–32h | +| D02 | 环境与应用骨架 | 打通 SaaS/MQ/ARI/AI/存储及多 Cell/EIP 网络→接入服务鉴权与密钥→建立最小执行/投递存储→健康检查与日志 | 16–24h | +| D03 | 指令、公平调度、应答与控制 | 租户队列有界接收/轮转→路由/租户/时效校验→持久去重→跨 Cell 租户额度与多调度实例协调→Cell/trunk/EIP/AI 资源准入→受理应答→暂停/停止屏障→重启恢复测试 | 原 32–48h,增量待评估 | +| D04 | Asterisk 主线路呼叫 | SIP参数与号码格式→ARI发起→通道/桥/媒体关联→振铃/接通/挂断→时间和原因归一→资源清理 | 32–48h | +| D05 | FALLBACK 与状态对账 | 确认可切换失败码→旧通道结束确认→备用尝试→迟到接通/ARI断线对账→次数/期限限制→防双拨演练 | 32–48h | +| D06 | 实时 AI 呼出应用 | 对接现有媒体能力→编解码/采样率适配→ASR/LLM/TTS流式联动→打断与取消旧生成→静音/超时/退出→异步隔离 | 48–72h | +| D07 | 文字生成与回传 | 定义轮次/版本→中间稿与最终稿→角色及播放/取消标记→持久事件→断网补传→SaaS时间线核对 | 16–24h | +| D08 | 录音上传与回传 | 录音生成→封装/元数据→OSS上传授权→上传/校验→确认OSS ID→经MQ回传OSS ID及元数据→重试/临时文件清理 | 24–32h | +| D09 | 查询、投递与补偿 | 命令/通话查询→受理与终态一致性→回传重试/隔离→授权重放→资产处理独立状态→恢复对账 | 32–40h | +| D10 | 可观测与运行保障 | 按租户等待/配额/积压指标→租户队列生命周期及 broker 上限→拒绝发布/背压→临时存储保护→密钥脱敏→部署、重启和优雅停机 | 原 24–32h,增量待评估 | +| D11 | 端到端专项验收 | SaaS真实联调→多 Cell/EIP 与供应商线路故障注入→重复/乱序/过期→多租户安全/公平/背压→多调度实例配额恢复→1000 路完整 AI 容量/延迟→回传与录音恢复 | 原 36–48h,增量待评估 | +| D12 | 灰度、交接与发布 | 固定版本→小流量试运行→问题收敛→回滚演练→交付配置/操作/排障说明与验收记录 | 12–16h | +| **原基准合计** | **本人范围** | **不含未认领的 SaaS 配合开发、供应商等待及本轮增量;新合计待 G0 评估** | **328–464h(历史基准)** | + +### 6.1 关键开发约束 + +**线路与 FALLBACK:** 支持单线路启动,供应商按需预接入、逐呼从已配置且获授权的线路策略选路;不等待所有供应商接齐,也不要求凑齐主备。仅对确认的线路接入故障切换,且备用必须兼容本通话固定的 Cell/出口及白名单;当前仅一家线路,无适合备用则失败,不虚构备用或迁移活动通话。忙线、拒接、无效号码、黑名单默认不切,无人接听是否重试由 SaaS 业务策略决定。接通后不新建备用呼叫。本地媒体或 AI 故障不能直接当作 SIP 线路故障处理。状态不明进入待对账,禁止盲目重新拨号。 + +**实时 AI:** 中间结果可合并,最终文字必须可恢复;TTS 文本区分已生成、已发送/播放确认和已取消,不将“生成完成”描述成“客户已听到”。音频线程不等待 SaaS 回调或录音上传。AI 失败使用有限重试及经确认的结束/兜底策略。 + +**录音:** 文件先上传 OSS,确认成功并取得 OSS ID 后,才通过 RabbitMQ 回传 OSS ID 及必要元数据;不发送二进制/Base64,也不以公开 URL 替代 OSS ID。SaaS 管理长期存储与播放权限,呼出侧仅按确认的恢复期限暂存。SaaS 不可用时有磁盘水位、积压告警和停止接单策略,不允许无限缓存。访问录音必须按租户和用户授权,签名地址短期有效且不写入公开日志。 + +**业务安全:** SaaS 在调度入口实施授权、允许时段、基础频控及拒绝再联系拦截;呼出应用实施租户/任务关联、有效期、资源上限和停机屏障。拒绝再联系事件及时回传,不能等二期才避免重复骚扰。 + +## 7. 里程碑与排期 + +| 门禁 | 本人产出 | SaaS/外部配合 | 通过条件 | +| --- | --- | --- | --- | +| G0:对接确认 | 本人制定并发布 D01 契约、样例、技术验证和参数基线 | SaaS/运维按规范认领接口及MQ、SIP、AI、OSS测试权限 | 已确认的双向MQ和OSS ID回传方案落实为可验证契约;关键测试依赖可用 | +| G1:指令闭环 | D02–D03;D09查询/应答基础 | SaaS能按租户发布、处理背压、接收应答和调用控制接口 | 重复指令不重复执行,过期/停止指令被拒绝,暂停生效可查询;大租户积压不阻塞有额度/资源的小租户,多实例不超租户配额 | +| G2:电话闭环 | D04–D05 | 当前主线路与授权测试号码;有备用时提供独立接入及白名单 | 单线路可双向通话;无备用明确失败不虚构容灾;有适合备用时验证合法切换,迟到接通不导致双拨。备用尚缺时记录其真实验证为待验,不把单线路通过称为完整 FALLBACK 验收 | +| G3:AI与资产闭环 | D06–D08;D09补偿完善 | SaaS MQ结果消费、OSS上传授权/确认及FeA展示页面 | 多轮/打断可用;文字经MQ正确入库;OSS ID可关联录音并鉴权播放;断网可经MQ补回 | +| G4:上线验收 | D10–D11 | 监控告警、多 Cell/EIP 故障演练环境、验收参与者 | 关键验收全部通过;至少1000路已接通完整ASR/LLM/TTS,且拨号/CPS/故障冗余达到G0基线;无阻断缺陷 | +| G5:一期交付 | D12 | 发布窗口、业务试用和运维交接 | 灰度通过;回滚可执行;遗留风险有明确负责人 | + +执行顺序:D01→D02→D03/D04→D05/D06→D07/D08→D09完善→D10/D11→D12。D09/D10 的基础能力从早期开始,不到项目末尾才补幂等和日志。 + +SaaS 可在 G0 后按 Mock 并行开发;本人为单人时,上述模块不能被当成多个独立人力并行压缩工期。 + +### 7.1 工时与周期口径 + +以下为本轮调整前的历史口径;租户队列生命周期、公平调度、多实例额度协调及新增验收的增量尚未评估,不能沿用为更新后的固定交期。G0 同时复核 D01/D02 契约与环境配合影响,不擅自假定已有20%预留足以覆盖。 + +- 基准:**328–464 小时**,即 **41–58 人日**(8小时/人日)。 +- 加20%计划预留:**393.6–556.8 小时**,即 **49.2–69.6 人日**。 +- 若本人每周有40小时可投入该范围,约 **10–14个工作周**;若每周只有24小时,约 **17–24个工作周**。均不含供应商或 SaaS 外部等待。 +- 以上以可用 SIP/AI 服务及可复用媒体组件为前提,不包含自研媒体引擎。G0 验证不成立、新增供应商或接口发生破坏性变化时单独重估,不能靠20%预留覆盖全部新增范围。 +- 这不是一期全团队总工时。BgA/BgB/FeA配合量及本人兼任部署/值守的占用,由对接负责人确认后再排入日历;不虚构额外人力,也不直接套用旧 Excel 的三期周期。 + +## 8. 验收用例与记录 + +每个用例记录:版本、环境、参数、操作、预期、实际、日志/事件证据和结论。使用授权测试号码,不以随机真实客户做故障实验。 + +| 编号 | 场景 | 验收条件 | +| --- | --- | --- | +| AT-01 | 命令重复、重启和ACK丢失 | 同一授权执行不因消息重复或更换 command_id 生成第二次拨号;已接收指令重启后可查询、可恢复;call_id 尚无时通过 command_id 观察 waiting,窗口不因重投/重启延长,截止后拒绝且不拨号,原因与 MQ 事件一致 | +| AT-02 | 暂停/停止与积压竞争 | 生效确认后不再新发起;迟到旧指令和死信重放不能重新开启已停止任务 | +| AT-03 | 主线路基本通话 | 发起、振铃、接通、双向语音、挂断及原因正确;通道/桥/媒体最终清理 | +| AT-04 | 单线路及合法FALLBACK | 无备用时可启动、遇故障明确失败;有适合备用时,仅主尝试明确结束、未接通且属于允许故障才切换,保持同一 Cell/出口,记录完整 attempt 和线路信息;无独立备用时真实切换验收标待验 | +| AT-05 | 不应FALLBACK | 已接通、状态不明、忙线/拒接/无效号码、本地AI故障不会引发未经授权的备用重拨 | +| AT-06 | 迟到接通/ARI断连 | 主备竞态通过对账收敛,不形成双通;不把“未收到事件”当成“未接通” | +| AT-07 | 多轮对话与打断 | 识别、生成、播音连续可用;打断停止旧播音/生成;延迟达到G0确认指标 | +| AT-08 | 文字重复、乱序和最终稿 | 同轮不重复堆叠;最终稿覆盖中间稿;取消内容标记正确;SaaS最终内容可核对 | +| AT-09 | 录音与OSS上传失败 | 先确认OSS上传成功,再经MQ回传OSS ID;SaaS可按OSS ID鉴权播放;失败不发ready并经MQ回传失败状态;临时文件受控清理 | +| AT-10 | SaaS/MQ中断与回传重放 | 所有业务结果经MQ回传,无HTTP回调旁路;音频不被回传阻塞;恢复后幂等补齐且无重复资产;积压达到上限有告警和保护 | +| AT-11 | 租户与凭证安全 | 非授权租户无法查询/补传/播放;凭证不出现在日志;失效上传授权被拒绝 | +| AT-12 | 多 Cell/EIP 容量、时段、退出与回滚 | 至少1000路已接通完整ASR/LLM/TTS;不超供应商并发/CPS、Cell媒体端口和最长通话;单 Cell/出口故障后仍满足N+1承接能力;发布回滚不丢待投递事件、不重新拨历史通话 | +| AT-13 | 大租户积压与公平调度 | A 大量积压后 B/C 新入队;B/C 有额度和可用资源时无需等 A 排空,按约定轮次/时延获得执行机会;记录到达、加入轮转和真实发起时间。预取、已 ACK 待发起及重启恢复仍公平;A 无可用线路时不阻塞 B | +| AT-14 | 跨 Cell/多调度实例租户配额 | 竞争发起、FALLBACK、所有权切换和进程重启均不重复占额/拨号、不超租户总并发/CPS;状态不明占用不因租约过期直接释放;资源满时不强制挂断,若有保底 SLA 则独立验收 | +| AT-15 | 租户队列背压、路由与生命周期 | A 发布洪峰/满队列被明确背压、SaaS 保留待发布记录;confirm 不确定用原 ID 重试不丢旧消息/不双拨;约定负载范围内 B 仍可发布调度;租户路由错配拒绝、创建/停用/恢复不误删积压,重试/DLQ 不绕过原租户配额 | + +G4 前必须将“约定指标”填写为明确数值,包括目标并发/CPS、响应和打断延迟的测量起止点及分位值、回传完成时限、最长可恢复中断、磁盘水位和失败重试边界。多租户专项还须在 G0 冻结租户/队列数量、发布洪峰、轮转份额、等待时间起止点/分位值、资源可用前提、配额和背压阈值;区分资源耗尽等待与调度饥饿,不无条件承诺固定开始时限。未经测试,不宣称达到生产高可用或特定并发能力。 + +## 9. 发布、交付与运行责任 + +### 9.1 交付清单 + +1. 本人制定并发布的 OpenAPI v1、MQ 拓扑/消息/事件 Schema、OSS ID 回传规范、示例、错误码和对接验证记录。 +2. 呼出应用代码/构建产物、配置模板及版本说明;不随交付包提供明文生产密钥。 +3. Asterisk/SIP接入参数说明、主备切换策略及失败码映射。 +4. 测试/回放工具、AT-01~AT-15验收记录、容量与延迟测试结果(含多租户公平、背压及配额恢复)。 +5. 部署、数据库变更、升级、回滚、故障排查、死信重放和资产补传操作说明。 +6. 监控清单:活动通话、发起速率、失败/FALLBACK、AI延迟、按租户命令积压/等待/额度/背压、broker 队列及水位、回传积压、录音失败、磁盘和资源残留。 + +### 9.2 发布步骤 + +- 部署测试环境,验证配置/权限/网络及数据备份;数据库改动与旧版本兼容。 +- 先小流量、低并发灰度,观察主备尝试、对话体验、文字/录音和回传积压。 +- 异常时先停止接新指令,对在途通话按确认策略排空或处置,保留执行/投递记录后回滚。 +- 只有验收和恢复演练通过后才扩大流量,不把生产流量当作媒体链路的首次验证。 +- 上线后明确SaaS、呼出应用、SIP、AI和运维各自的故障联系人;业务页面问题与媒体线路问题分别定位。 + +## 10. 范围变更规则 + +一期以“指令有应答、真实外呼可控、单线路可启动且合法切换不重复拨号、AI可对话、文字录音能回SaaS并恢复”为交付闭环。独立备用未提供时记录真实 FALLBACK 验证为待验,不宣称已具备线路容灾。 + +新增计费、RAG、复杂路由、第二种回传通道、多供应商通用适配或集群容灾时,另建变更项,写明工作量与对本期门禁的影响,不默认混入本计划。SaaS API、SIP参数、媒体组件或本人可投入时间变化时,更新本开发计划的版本并重新确认受影响的排期。 diff --git a/docs/交互流程图_规划态.md b/docs/交互流程图_规划态.md new file mode 100644 index 0000000..46b0b9d --- /dev/null +++ b/docs/交互流程图_规划态.md @@ -0,0 +1,134 @@ +# 交互流程图(规划态) + +**版本:** v0.1 +**依据:** [一期计划 v1.3](一期呼出应用开发计划_v1.0.md)、[OpenAPI 与 MQ 契约规划 v0.3](SaaS交互_OpenAPI与MQ契约规划_v0.1.md)。 +**状态:** 目标交互,尚未完整实现;具体字段、配额、等待窗口及 AI 协议待 G0 冻结。当前仅有 ASR 验证基础,LLM/TTS 未启用。本图不新增接口或变更契约。 + +```mermaid +flowchart TB + subgraph SaaS["SaaS:业务授权与发布"] + START["任务、号码、租户授权与业务检查"] + PUB["持久化发布记录
command_id + execution_id"] + BACK["满队列拒绝/不可路由/confirm 不确定
保留原 ID,有限退避与对账"] + START --> PUB + end + + subgraph COMMANDS["RabbitMQ:唯一外呼执行入口"] + EX["call.execute
按可信 tenant_id 映射路由"] + Q["各租户独立命令队列
有界积压,不丢队头旧命令"] + EX --> Q + end + PUB --> EX + EX -. "发布受阻或结果不确定" .-> BACK + BACK -. "原租户、原 ID 重试" .-> PUB + + subgraph OUTBOUND["呼出应用:接收、控制与公平准入"] + READ["按租户有界轮转
校验 Schema、身份及正文/队列租户一致"] + VALID{"可信且合法?"} + DEAD["非法/不可信消息隔离告警
不得按伪造租户执行或回传"] + IDEM{"command_id / execution_id
是否已有记录?"} + ORIGINAL["同语义关联原结果;冲突拒绝
不重新拨号"] + CHECK{"新执行授权、时效、版本
及配置是否有效?"} + REJECT["持久化 rejected + outbox
首次意图前拒绝:call_id 为空"] + ACCEPT["事务持久化命令、去重及 accepted outbox
完成后 ACK;此时尚未拨号"] + ADMIT{"控制/期限仍有效
且完整资源准入成功?"} + WAIT["waiting + 原因 + 准入截止
call_id 为空;按 command_id 查询"] + WAKE{"到期或被控制阻止?
到期有界唤醒,不等待资源释放"} + INTENT["固定 trunk + Cell + 出口,持久化拨号意图
首次建 call_id;每次尝试建 attempt_id"] + FENCE{"执行器真实发起前
再次检查控制、期限及租约"} + READ --> VALID + VALID -- "否" --> DEAD + VALID -- "是" --> IDEM + IDEM -- "是" --> ORIGINAL + IDEM -- "否" --> CHECK + CHECK -- "否" --> REJECT + CHECK -- "是" --> ACCEPT + ACCEPT --> ADMIT + ADMIT -- "是" --> INTENT + ADMIT -- "否" --> WAIT + WAIT --> WAKE + WAKE -- "是" --> REJECT + WAKE -- "否:继续公平轮转" --> ADMIT + INTENT --> FENCE + end + Q --> READ + + subgraph VOICE["固定语音 Cell:电话与实时 AI"] + DIAL["本地执行器 → ARI / Asterisk
经本 Cell 固定 EIP 向授权 SIP 线路发起"] + OBS{"通道事实/呼叫结果"} + RECON["reconciling:先对账
不盲目重拨,不释放未知占用"] + FALL{"明确未接通且旧通道已结束
允许故障、有同 Cell/出口的授权备用?"} + LEASE{"FALLBACK:重新检查控制、期限
租户 CPS 与完整资源许可"} + AI["接通后的目标链路
媒体 ↔ ASR → LLM → TTS → 媒体
流式对话、打断与取消;LLM/TTS 待新规范"] + END["确认通道结束,清理资源
持久化 call.finished;资产可仍为 pending"] + DIAL --> OBS + OBS -- "状态不明" --> RECON + RECON -- "取得可靠证据后收敛" --> OBS + OBS -- "已接通" --> AI + OBS -- "明确未接通" --> FALL + FALL -- "是" --> LEASE + LEASE -- "许可有效;同 call_id,新 attempt_id" --> INTENT + LEASE -- "否:不盲目重试" --> END + FALL -- "否:无备用不虚构" --> END + AI -- "挂断并确认" --> END + end + FENCE -- "通过" --> DIAL + FENCE -- "未通过且确认尚未发出:失败/取消收尾" --> END + FENCE -- "是否已发出不明" --> RECON + + subgraph ASSET["异步录音交接:不阻塞实时音频或通话终态"] + FILE["封口录音文件,计算元数据与校验值"] + AUTH["呼出资产处理器 → SaaS HTTP
申请绑定租户/通话的上传授权"] + OSS["呼出资产处理器 → OSS
使用受控签名上传文件"] + COMPLETE["呼出资产处理器 → SaaS HTTP complete
SaaS 实际校验 OSS 对象"] + VERIFIED{"校验成功并取得稳定 oss_id?"} + READY["verified + recording.ready outbox
同事务持久化,事件含 oss_id"] + FAILED["recording.failed + outbox
按契约有限恢复;未验证不发 ready"] + FILE --> AUTH --> OSS --> COMPLETE --> VERIFIED + VERIFIED -- "是" --> READY + VERIFIED -- "否" --> FAILED + end + END -. "存在待交接录音时" .-> FILE + + subgraph RESULTS["所有业务结果:持久化事件 → MQ → SaaS"] + OUTBOX["执行事实与 outbox 同事务
投递器:confirm + mandatory,有限重试"] + EVENTS["RabbitMQ 事件 Exchange → SaaS 事件队列
本轮不按租户拆分事件队列"] + INBOX["SaaS:tenant_id + event_id 去重
业务更新与 inbox 同事务后 ACK
按版本合并,关联 oss_id 并授权展示"] + OUTBOX --> EVENTS --> INBOX + end + REJECT --> OUTBOX + ACCEPT -. "command.result" .-> OUTBOX + WAIT -. "状态/原因变化" .-> OUTBOX + INTENT -. "executing 不等于实际拨号" .-> OUTBOX + OBS -. "call.status" .-> OUTBOX + RECON -. "待对账状态" .-> OUTBOX + AI -. "transcript.updated 等文字事件" .-> OUTBOX + END --> OUTBOX + READY --> OUTBOX + FAILED --> OUTBOX + + CONTROL["SaaS → 呼出 HTTP:暂停/恢复/停止
202 仅受理;跨 Cell 屏障确认后才 applied"] + QUERY["SaaS → 呼出 HTTP:命令/通话查询
仅对账,不替代 MQ 业务结果"] + REPLAY["SaaS → 呼出 HTTP:历史事件补传
固定范围、原 event_id;不拨号/不调用 AI"] + CONTROL -. "禁止新许可,处理在途许可" .-> ADMIT + CONTROL -. "真实发起前校验" .-> FENCE + CONTROL -. "生效结果经 MQ" .-> OUTBOX + QUERY -. "读取持久事实与版本快照" .-> OUTBOUND + REPLAY -. "重发原持久事件" .-> OUTBOX + + classDef planned fill:#eef4ff,stroke:#4263a6,color:#172a46; + classDef pending fill:#fff4dc,stroke:#b7791f,color:#573b13; + classDef risk fill:#fff0ef,stroke:#b44c45,color:#632822; + classDef data fill:#eaf7f1,stroke:#2d8063,color:#164b39; + class START,PUB,READ,ACCEPT,INTENT,DIAL,CONTROL,QUERY,REPLAY planned; + class WAIT,AI pending; + class DEAD,REJECT,RECON,FAILED,BACK risk; + class OUTBOX,EVENTS,INBOX,OSS,READY data; +``` + +## 阅读边界 + +- 完整资源许可同时覆盖跨 Cell 的租户并发/CPS、供应商配额、Cell 媒体端口/节点容量、出口健康与 ASR/LLM/TTS 配额;每次 FALLBACK 也消耗 CPS。初次准入截止为 `min(not_after, accepted_at + 服务端窗口)`,重投/重启不延长;它不强制挂断已发起电话。 +- `accepted`、broker confirm、消费者 ACK、`executing`、实际拨号、接通及 SaaS 入库是不同事实。可信业务拒绝可回传;非法消息先隔离。重复命令关联既有事实,不创建第二通电话。 +- 暂停/停止覆盖队列积压、持久等待和多 Cell 在途许可;状态不明不宣称 applied。resume 不复活旧版本。公平不挂断已接通电话;stop 的 drain/hangup 按明确授权处理。 +- 数据补传只重发原事件;录音恢复独立处理。文字/录音失败不改写通话终态。新业务外呼由 SaaS 完成对账及授权后决定,不由消费者自动换 ID。 diff --git a/docs/交付文档/一期中间调度件与Asterisk_v1.0.zip b/docs/交付文档/一期中间调度件与Asterisk_v1.0.zip new file mode 100644 index 0000000..9bd7069 Binary files /dev/null and b/docs/交付文档/一期中间调度件与Asterisk_v1.0.zip differ diff --git a/docs/交付文档/一期中间调度件与Asterisk_v1.0/01_中间调度件与MQ回传设计.md b/docs/交付文档/一期中间调度件与Asterisk_v1.0/01_中间调度件与MQ回传设计.md new file mode 100644 index 0000000..66c6fe2 --- /dev/null +++ b/docs/交付文档/一期中间调度件与Asterisk_v1.0/01_中间调度件与MQ回传设计.md @@ -0,0 +1,276 @@ +# 第一部分:中间调度件与 MQ 回传设计 + +**版本:** v1.0;双向MQ交互方式已确认,详细契约及真实环境参数待 G0 对接评审。 +**用户确认:** RabbitMQ 下发执行指令、RabbitMQ 回传业务事件;HTTP 仅用于控制、查询、录音上传授权及完成确认。 +**范围:** 一期纯外呼、不计费、复用现有 SaaS。本文替代旧计划中的“HTTP 业务回调”草案,不修改原 Excel。 +**交付性质:** 本次交付设计和操作/验收文档,不代表中间件代码已实现,也不代表已部署到任何服务器。 + +## 1. 组件边界与最小拓扑 + +```text +SaaS:任务/号码/配置/业务调度/权限/展示/长期存储 + │ 发布 call.execute ▲ 接收 command.result / call.* / transcript.* / recording.ready + ▼ │ +RabbitMQ:command exchange event exchange → SaaS消费队列 + │ ▲ + ▼ │ +中间调度件:指令持久化 → 准入/控制 → 呼叫执行 → 数据落地/outbox → 事件投递 + │ REST + 每节点一个长期ARI WebSocket │ HTTP上传授权/上传完成/接收结果查询 + ├── Asterisk A ── 主/备用 SIP接入 └── SaaS API及授权对象存储 + └── Asterisk B ── 主/备用 SIP接入 + │ externalMedia RTP(不经过RabbitMQ) + └── 媒体/AI组件:VAD → ASR → LLM → TTS,支持取消与打断 +``` + +- SaaS 决定何时、向谁发起,以及业务层“再次拨打”的政策;中间件不复制客户管理和任务编排。 +- 中间件负责执行级准入、并发/CPS、暂停屏障、Asterisk 节点选择、SIP 尝试、执行事实、文字/录音回传及补偿。 +- Asterisk 节点无共享通道状态;双节点不是存量通话无损接管。故障节点上的已接通通话不得自动在另一节点重拨。 +- 一期采用一个活动中间件实例管理多个 Asterisk 节点,持久化后支持重启恢复。一个节点的 ARI 应用只允许一个控制者;不承诺活动中间件自动高可用。未来多实例必须按节点明确归属,不能竞争控制同一通道。 +- 每个节点维持长期 ARI 事件连接;REST 请求和事件统一进入执行状态机。不可照搬 Mock 的“每通电话各建相同 app 的连接并丢弃事件”方式。 +- 不新增 Redis、工作流引擎或独立服务拆分作为前置依赖。复用可用关系数据库存放执行与投递状态;媒体组件可以独立运行,但数据回传不能进入其音频实时线程。 + +## 2. 数据归属与最小持久化模型 + +| 数据 | 所有者与关键字段 | 约束 | +| --- | --- | --- | +| 任务、号码、智能体快照 | SaaS;task_id、task_item_id、agent_version_id、variables、task_revision | SaaS 保持业务事实;快照不可在执行中悄悄替换 | +| command_inbox | 中间件;tenant_id、command_id、execution_id、request_hash、payload、accepted_at、status | 同租户 command_id 唯一;同键不同内容拒绝,不覆盖原命令 | +| task_control | 中间件;tenant_id、task_id、revision、desired_state、effective_state | 控制版本单调递增;停止屏障不能被迟到的旧指令解除 | +| call_execution | 中间件;tenant_id、call_id、execution_id、task_item_id、状态版本、时间、终态、原因 | 同租户execution_id唯一代表一次授权执行;同租户同一任务项不得同时存在两次活动业务执行 | +| call_attempt | 中间件;attempt_id、call_id、node_id、trunk_id、channel_id、bridge_id、external_channel_id、发起意图和清理状态 | 调用 ARI 前落地关联标识;同一 execution 只允许一个活动尝试 | +| call_artifact | 中间件;call_id、文字轮次/版本、录音位置/校验/状态、保留期限 | 电话结束与资产处理分离;本地暂存不替代 SaaS 长期存储 | +| event_outbox | 中间件;event_id、payload、聚合版本、发布次数、下次重试、published/applied状态 | 业务事实与事件同事务提交;broker confirm 不等于 SaaS 已落库 | +| event_inbox | SaaS;tenant_id、event_id、payload_hash、applied_at、处理结果 | 去重与业务落库同事务;业务提交后才 ACK;优先复用 SaaS 现有机制 | + +一期单实例可以在同一进程内实现接收、执行、投递、对账和清理任务,但各自失败不能阻塞媒体。状态更新仍需事务及版本条件,不能仅靠内存字典防重复。 + +## 3. RabbitMQ 拓扑与可靠性 + +以下名称为规范草案,部署时按 SaaS 命名规则映射;业务 Schema 和语义保持一致。 + +| 对象 | 建议命名/绑定 | 用途 | +| --- | --- | --- | +| 命令交换机 | ai.outbound.command.v1,topic、durable | SaaS 发布执行命令 | +| 命令队列 | ai.outbound.execute.v1,绑定 call.execute | 中间件消费;队列满时拒绝发布/暂停生产,不静默丢弃 | +| 事件交换机 | ai.outbound.event.v1,topic、durable | 中间件回传业务事件 | +| SaaS事件队列 | saas.ai.outbound.event.v1,绑定本节规定的事件类型 | SaaS 幂等入库及触发展示 | +| 死信交换机/队列 | 复用 SaaS 标准 DLX,命令与事件分别隔离 | 超出重试预算、非法消息和不可恢复异常 | + +必须落实: + +1. 两侧生产者均启用 persistent 消息、Publisher Confirm 和 mandatory/不可路由检查;收到 confirm 但发生 basic.return 仍视为投递失败。 +2. durable 队列不等于集群高可用。若现有 RabbitMQ 支持经验证的 quorum 队列可复用;不得在未知版本/策略下直接强制变更现有队列类型。 +3. 中间件在命令及去重记录持久化后 ACK,不把整个电话挂在未 ACK 消息上;SaaS 在业务应用事件成功后 ACK。 +4. 中间件已受理命令由持久化执行记录恢复。ACK 后进程崩溃不依赖重新收到同一条消息才能继续。 +5. 基础 prefetch 从小值开始,配合数据库待执行数量上限。执行容量不足时降低/暂停拉取或给出明确拒绝结果,不能无界吸收任务。 +6. 执行准入同时受租户、节点、线路的并发/CPS和任务控制状态限制。号码/DNC/时段等业务风控由 SaaS 统一决策,延迟执行和补投前重新校验有效性。 +7. 基础设施暂时失败走有限退避;业务忙线、拒接不是 MQ 异常,不通过 nack/requeue 反复重拨。已有 SaaS 重试/DLQ 机制优先复用,不同时叠加两套无限重试。 +8. 关键最终事件不能靠短 TTL 自动淘汰。published 事件保留至 SaaS 应用确认或经授权的保留期处理;删除策略和最长可恢复中断在 G0 明确。 +9. 人工重放分为“命令恢复”和“数据补传”。命令恢复仍检查 execution_id、有效期、已接通事实和停止屏障;数据补传永不发起电话。 + +## 4. 命令、事件与应答契约 + +### 4.1 标识与公共规则 + +- command_id:一次指令;消息重投必须相同。execution_id:一次授权业务外呼;SaaS 决定再次外呼时才新建。 +- call_id:一次业务外呼的聚合标识。attempt_id:一次 Asterisk/SIP 尝试;FALLBACK 新建 attempt,但不新建业务 execution。 +- event_id:事件唯一标识;重发保持原值和内容。task_revision:任务控制版本。state_version:通话状态版本。 +- 所有时间戳为 RFC3339 UTC;字段名含 `_ms` 的持续时间以毫秒计。号码、原始错误、录音引用均视为租户敏感数据。 +- 每类消息单独定义必填字段。尚未产生通话时,不要求 command 带 call_id/attempt_id。 +- tenant_id 必须与凭证、消息来源及任务归属校验,不仅相信 payload。只接受号码/允许的线路引用,不接受任意 ARI URL、原始 dial string、任意 externalMedia 地址或凭证。 +- 下列 JSON 使用测试占位符;时间、号码和配置需由测试生成器替换,不得原样发送至生产。 + +### 4.2 call.execute + +```json +{ + "schema_version": "1.0", + "command_id": "cmd_demo_001", + "execution_id": "exec_demo_001", + "tenant_id": "tenant_test", + "trace_id": "trace_demo_001", + "issued_at": "2026-09-08T01:00:00Z", + "not_after": "2026-09-08T01:05:00Z", + "task_id": "task_demo", + "task_item_id": "item_demo", + "task_revision": 7, + "payload": { + "destination": "${AUTHORIZED_TEST_NUMBER}", + "agent_version_id": "agent_v1", + "agent_snapshot": {"prompt_version": "prompt_v1", "variables": {}}, + "line_group_id": "line_group_test", + "ring_timeout_ms": 30000, + "max_call_duration_ms": 180000, + "fallback_policy_id": "primary_backup_v1" + } +} +``` + +快照须包含执行所需数据;若采用引用,SaaS 必须提供鉴权读取接口及一致的版本校验,不默认“只有ID就能运行”。同幂等键不同 request_hash 返回 IDEMPOTENCY_CONFLICT。过期/停止/越权/无可用线路返回明确拒绝结果,MQ ACK 不应被展示为“已呼叫”。 + +### 4.3 事件模型 + +公共外壳包括 event_id、event_type、schema_version、tenant_id、trace_id、occurred_at 和 payload;业务关联ID按事件类型携带。 + +| Routing key / event_type | 必需业务字段 | SaaS 更新规则 | +| --- | --- | --- | +| command.result | command_id、execution_id或控制对象、result、reason、effective_revision | result=ACCEPTED/REJECTED/APPLIED/FAILED;ACCEPTED不等于执行完成 | +| call.status | call_id、attempt_id、state_version、state、node_id、trunk_id、时间与原因 | 版本不回退;attempt结果不直接覆盖已接通的业务终态 | +| transcript.partial | call_id、turn_id、role、revision、text、is_final=false | 可合并/限流;中间稿不是最终业务事实 | +| transcript.final | call_id、turn_id、role、revision、text、起止时间、播放/取消标记 | 同轮按版本幂等更新;必须持久和可补传 | +| recording.ready | call_id、recording_id、object_ref、格式/声道/采样率/时长/大小/checksum | 只传上传确认后的受控引用,不传二进制和长期公开URL | +| call.finished | call_id、state_version、final_state、cause、时长、attempt_count、资产处理状态 | 通话终态立即可见;录音/分析可随后完成 | + +```json +{ + "schema_version": "1.0", + "event_id": "evt_demo_status_2", + "event_type": "call.status", + "tenant_id": "tenant_test", + "trace_id": "trace_demo_001", + "occurred_at": "2026-09-08T01:00:06Z", + "command_id": "cmd_demo_001", + "execution_id": "exec_demo_001", + "task_id": "task_demo", + "task_item_id": "item_demo", + "call_id": "call_demo", + "attempt_id": "attempt_demo_1", + "payload": { + "state": "ANSWERED", + "state_version": 2, + "node_id": "ast-a", + "trunk_id": "sip-primary", + "answered_at": "2026-09-08T01:00:06Z" + } +} +``` + +```json +{ + "schema_version": "1.0", + "event_id": "evt_demo_text_1", + "event_type": "transcript.final", + "tenant_id": "tenant_test", + "trace_id": "trace_demo_001", + "occurred_at": "2026-09-08T01:00:10Z", + "call_id": "call_demo", + "payload": { + "turn_id": "turn_1", + "role": "customer", + "revision": 2, + "text": "这是授权测试。", + "is_final": true, + "start_offset_ms": 500, + "end_offset_ms": 1800, + "playback_status": "not_applicable" + } +} +``` + +AI 文字另标记 generated/sent/playback_confirmed/cancelled。播放器确认不等于能证明客户实际听到了声音;被打断而未播出的内容不能全部作为“已说出”展示。 + +### 4.4 错误与恢复语义 + +统一原因至少包含:INVALID_COMMAND、IDEMPOTENCY_CONFLICT、TENANT_FORBIDDEN、TASK_STOPPED、COMMAND_EXPIRED、CAPACITY_EXCEEDED、NO_ROUTE、SIP_UNAVAILABLE、BUSY、REJECTED、NO_ANSWER、INVALID_NUMBER、AI_ERROR、MEDIA_ERROR、CONTROL_UNCERTAIN、UPLOAD_FAILED。保留原始 SIP 状态/Q.850/Asterisk 原因用于排障,映射由真实线路验证。 + +明确拒绝的执行不进入拨号;容量等待/拒绝政策由 SaaS 与中间件统一,不能两边各自重试。CONTROL_UNCERTAIN 表示需对账,不等价于可重拨失败。 + +## 5. HTTP 边界与接口清单 + +| 方向/方法 | 接口草案 | 请求/响应要点 | +| --- | --- | --- | +| SaaS→中间件 POST | /internal/v1/tasks/{task_id}/controls | command_id、expected_revision、action=PAUSE/RESUME/STOP;force_hangup默认false且另行授权;202返回已受理 | +| SaaS→中间件 GET | /internal/v1/commands/{command_id} | 查询ACCEPTED/APPLIED/REJECTED/FAILED与实际控制版本 | +| SaaS→中间件 GET | /internal/v1/calls/{call_id} | 通话/尝试、state_version、资产和投递状态;不改状态 | +| SaaS→中间件 POST | /internal/v1/calls/{call_id}/events/replay | 仅补传已有事件,保持event_id;鉴权、审计、限流 | +| 中间件→SaaS GET | /internal/v1/outbound/executions/{execution_id}/eligibility | 返回allowed、reason、current_task_revision、valid_until;每次发起前查询当前时段/退订/授权许可,不返回凭证 | +| 中间件→SaaS POST | /internal/v1/recording-uploads | call_id、格式、大小、checksum;返回upload_id、授权上传地址、过期时间及允许参数 | +| 中间件→SaaS POST | /internal/v1/recording-uploads/{upload_id}/complete | 幂等确认文件/校验;返回recording_id和object_ref | +| 中间件→SaaS POST | /internal/v1/outbound/receipts/query | 查询最多100个event_id的APPLIED/NOT_FOUND/FAILED结果;仅用于低频对账,不是业务回调 | + +控制请求经过与命令相同的持久化/幂等机制。200/202不代表暂停已经生效;必须查询或消费 command.result(APPLIED)。非法参数400、无权限401/403、状态/版本冲突409、限流429;重复同内容请求返回原结果。 + +首次发起、延迟执行及FALLBACK之前查询当前业务许可,结合本地控制屏障再次校验;不得把命令发布时的授权永久缓存。许可查询不可用时不新拨号,进入有期限的等待/拒绝并告警;不会因此中断正在进行的媒体。授权变化与暂停生效的时间边界在G0确认。 + +鉴权沿用已批准的 SaaS 服务认证并限定租户权限;跨主机HTTPS,敏感操作审计。API路径及认证细节需对接签字后生成正式OpenAPI文件,不将示例路径当成双方已部署接口。 + +## 6. 状态机、控制与FALLBACK + +### 6.1 执行状态 + +- ACCEPTED → QUEUED → DIALING → ANSWERED → TALKING → ENDED。 +- DIALING允许直接ANSWERED;RINGING是可选观测状态,不要求每次都有振铃事件。 +- 接通前可结束为FAILED、CANCELLED、EXPIRED;已接通后以ENDED及原因结束,不退回QUEUED。 +- 控制连接断开且事实无法确定时进入RECONCILING。恢复时查询原node/channel,不能因本地没有成功回执就重拨。 +- 文字/录音状态独立使用PENDING/PROCESSING/READY/FAILED;事件状态独立使用PENDING/PUBLISHED/APPLIED。资产失败不阻止电话终态落地。 + +### 6.2 控制屏障 + +PAUSE先阻止SaaS新调度,中间件再建立版本屏障,处理完在途发起许可后确认APPLIED。STOP为终止性控制,旧revision或死信不得恢复任务。已接通通话默认排空;强制挂断需要独立授权和审计。许可检查、发起意图与控制版本关联,避免“先检查暂停再被停止但仍拨出”的竞态。 + +### 6.3 两种故障不得混淆 + +1. **节点选择失败:** 尚未提交originate时,健康/容量不合格的Asterisk节点可以换选。 +2. **已提交拨号:** 超时不代表失败。先用持久化的channel_id核实原尝试;事实不明不得换节点重拨。 +3. **SIP主备切换:** 仅在明确未接通、旧通道已结束、失败码属于允许线路故障、尚在有效期且预算允许时执行。默认最多主/备两次尝试,节点与线路切换共用总预算,不能组合放大。 +4. BUSY、REJECTED、INVALID_NUMBER、DNC拦截不切备用;NO_ANSWER由SaaS业务重试政策处理。已接通后禁止FALLBACK新拨号。 +5. AI/本地媒体故障不通过换SIP解决。两条SIP接入同属一个故障域时,主备不构成独立容灾。 +6. 故障节点已接通电话可能中断,记录原因并通知SaaS;一期不提供通话热迁移。新增呼叫转向健康节点需重新校验容量。 + +## 7. ARI、媒体、文字和录音生命周期 + +1. 建立节点长期ARI事件连接并确认应用注册;创建call/attempt、确定固定的PJSIP通道、桥和externalMedia通道标识并持久化发起意图。 +2. 创建桥和externalMedia会话,为每通电话分配独立媒体端口/关联;发起PJSIP呼叫并消费StasisStart、ChannelStateChange、ChannelDestroyed等事件,按实际状态推进。 +3. 接通后将目标通道加入桥;音频格式按SIP协商、RTP payload、采样率和AI接口要求转换,验证ASR不会把TTS回声当作客户输入。 +4. RTP音频直接进入媒体组件。VAD/ASR/LLM/TTS及打断需要独立实现或可靠组件,Mock音调发送/包统计不是AI链路。 +5. 关键文字最终稿异步持久化、写outbox并发布;中间稿可限流合并,最终稿和状态事件不可被慢消费者无限阻塞。 +6. 接通后可通过ARI的桥录音能力启动混音录音,名称包含受控call/attempt标识;本期默认单轨混音,双声道需另行验证,不把桥录音直接称为双声道。 +7. 挂断时停止/确认录音完成,等待录音文件封装完成后读取/上传。优先通过ARI stored recording接口获取,或由同节点受控进程读取持久卷,不把同一路径误当成跨主机共享文件。 +8. SaaS上传授权→文件上传→完成确认/校验→写recording.ready到outbox→MQ发布→SaaS消费入库和页面播放。 +9. 清理通道、桥、媒体会话;清理失败由补偿任务按记录核实处理。不得使用“清空所有通道”替代按call_id清理。 + +录音元数据:recording_id、call_id、attempt_id、format、channels、sample_rate_hz、duration_ms、size_bytes、checksum_sha256、object_ref、created_at。上传确认前不发ready。临时文件设置容量、水位和保留期限;未确认入库的文件不能在普通成功清理中删除,超期处置须告警和授权。 + +### 7.1 recording.ready 示例 + +```json +{ + "schema_version": "1.0", + "event_id": "evt_demo_recording_1", + "event_type": "recording.ready", + "tenant_id": "tenant_test", + "trace_id": "trace_demo_001", + "occurred_at": "2026-09-08T01:03:10Z", + "call_id": "call_demo", + "attempt_id": "attempt_demo_1", + "payload": { + "recording_id": "recording_demo_1", + "object_ref": "tenant_test/call_demo/recording_demo_1.wav", + "format": "wav", + "channels": 1, + "sample_rate_hz": 8000, + "duration_ms": 15000, + "size_bytes": 240044, + "checksum_sha256": "${SHA256_OF_UPLOADED_FILE}", + "created_at": "2026-09-08T01:03:01Z" + } +} +``` + +上传授权/完成确认是HTTP;recording.ready业务通知是MQ。例中校验值和尺寸为占位/演示,必须以实际文件计算结果为准。 + +## 8. 恢复、可观测与开发顺序 + +恢复顺序:暂停新执行→读取未完成attempt→核实节点/通道→修正执行事实→恢复未投递/未应用关键事件→清理孤儿资源→健康检查通过后恢复接单。锁或租约过期不能证明Asterisk通道已结束。 + +监控至少包括命令积压/最老年龄、拒绝原因、活动通话/线路CPS、ARI连接、未决attempt、FALLBACK、音频丢包与AI延迟、outbox积压、SaaS落库延迟、录音失败、临时磁盘和清理失败。call_id贯通日志;手机号、密钥、签名URL脱敏。 + +开发顺序与门禁: + +1. G0:真实参数、Schema/控制语义、MQ拓扑和存储协议确认;本人主责,SaaS与运维认领。 +2. G1:inbox/outbox、命令接收/应答、控制和查询;重复消息与重启恢复通过。 +3. G2:长期ARI连接、主线路通话、节点选择和安全FALLBACK;迟到接通不产生双拨。 +4. G3:AI、最终文字、录音及MQ回传;SaaS入库展示和补传可验证。 +5. G4:第三部分全部阻断用例通过;部署与回滚演练完成。 + +本人负责中间件/ARI/回传核心实现;BgA对接SaaS任务快照和控制;BgB对接SaaS MQ生产消费、事件入库与资产接口;FeA负责展示和播放;部署负责人支持环境与监控。若本人兼任ArchA,合并人力日历,不重复计算。 diff --git a/docs/交付文档/一期中间调度件与Asterisk_v1.0/02_Asterisk部署与SIP对接步骤.md b/docs/交付文档/一期中间调度件与Asterisk_v1.0/02_Asterisk部署与SIP对接步骤.md new file mode 100644 index 0000000..285a86d --- /dev/null +++ b/docs/交付文档/一期中间调度件与Asterisk_v1.0/02_Asterisk部署与SIP对接步骤.md @@ -0,0 +1,257 @@ +# 第二部分:Asterisk 部署与指定 SIP 对接步骤 + +## 1. 参考基线与使用边界 + +参考仓库:`git@git.ipao.vip:rogee/sip-research.git`。 +已审阅提交:`6a8064e53bb7eadd73f03373524953e68330976c`。 +主要依据:`asterisk/README.md`、`asterisk/deployment.md`、`asterisk/deploy/docker-compose.yml`、`conf/{ari,http,pjsip,rtp}.conf`、`scripts/{call,health}.sh`、`mock/{mock-agent,mock-provider}.py`。 + +本次只静态检查参考实现,未连接仓库文档中的历史服务器,未停用其他语音系统,未执行任何远程部署。仓库历史测试记录不能作为本次环境的验收结果。 + +| 仓库已有内容 | 可以参考的部分 | 本期仍需补齐/纠正 | +| --- | --- | --- | +| 双Asterisk节点+Mock Provider | ARI与externalMedia的最小通话路径;节点由发起方选择 | 没有中间调度件、RabbitMQ、SaaS事件回传和执行持久化 | +| Asterisk镜像latest;历史记录22.10.1 | 容器部署结构 | latest不可复现;核实实际版本及安全状态后固定镜像digest | +| 8088/8089 HTTP映射 | 宿主机访问两节点ARI | 不能对公网无保护开放;Mock Compose没有真实SIP/RTP外网接入配置 | +| mock-agent | 创建桥、externalMedia、PJSIP通道、双向RTP和资源清理示例 | 不是生产事件状态机/AI组件;会话身份、并发、取消和恢复需重新实现 | +| mock-provider | SIP UAS应答及模拟音频 | 单RTP端口、按来源地址处理媒体等简化,不能证明真实多呼叫音频隔离 | +| health.sh | ARI、OPTIONS、通道查询思路 | 会跳过未启动节点;即使缺少必需节点也可能未增加fail,必须增加预期服务集合检查 | +| rtp.conf | 10000–10800端口段 | 约200路只是历史估算,不是实测容量保证;strictrtp=no须安全复核 | +| 无录音持久卷 | 无 | 补齐录音落盘、读取、上传、失败保留和清理 | + +不得复用示例ARI密码为生产凭据。不要复制仓库的历史root登录目标、停机命令或系统调优作为当前部署授权。特别是其大额UDP内存参数必须按实际主机重新评估,初次部署先保留操作系统默认值。 + +## 2. D0:环境与参数准备 + +### 2.1 环境要求 + +- 隔离的Mock测试主机;Linux、Docker Engine、Docker Compose插件、Git、Bash、curl、Python3可用。 +- 生产建议两个独立Linux主机分别部署Asterisk A/B,同一主机只运行一个host-network Asterisk实例。 +- 可用SaaS测试租户、RabbitMQ权限、主/备SIP接入、授权测试号码、AI接口、对象存储和中间件构建产物。 +- 未完成中间件开发时,只能执行Mock部署及底座验证,不得标记完成MQ回传或一期上线。 + +### 2.2 上线前必须填写的参数 + +| 参数组 | 必填内容 | +| --- | --- | +| 版本 | Git提交、Asterisk镜像digest/实际版本、Python测试镜像digest、中间件版本、配置版本 | +| 节点 | node_id、管理网IP、SIP监听地址/端口、RTP地址/端口段、故障域、并发/CPS限额 | +| SIP | 主/备用endpoint名称、地址/端口、注册或IP鉴权、主叫/被叫格式、编解码、源IP清单、失败码 | +| 媒体 | externalMedia可达地址、每通电话端口分配方式、RTP回程、采样率、AI流式接口与配额 | +| MQ/API | VHost/队列、账号ACL/TLS、控制/查询地址、SaaS上传和接收状态查询协议 | +| 存储 | 本地录音路径/权限/配额、上传允许域名、保留期限、磁盘水位、播放授权 | +| 运行 | 允许呼叫时段、紧急停止方式、告警联系人、验收指标和发布窗口 | + +全部凭据通过密钥管理或受控文件注入,不进入Git、MQ明文业务payload或工单日志。目录/文件权限需与容器实际UID核对,不能以chmod 777解决写入问题。 + +## 3. D1:复现仓库的隔离Mock环境 + +以下命令仅供操作人员在明确指定的测试主机执行;本次编写文档没有执行它们。所有步骤失败即停止,不自动清理现有容器。 + +### 3.1 固定源代码版本并复制部署目录 + +```bash +set -euo pipefail +export SRC_DIR="$HOME/sip-research-reference" +export MOCK_DIR="$HOME/asterisk-mock-validation" + +test ! -e "$SRC_DIR" +test ! -e "$MOCK_DIR" +git clone --no-checkout git@git.ipao.vip:rogee/sip-research.git "$SRC_DIR" +git -C "$SRC_DIR" checkout --detach 6a8064e53bb7eadd73f03373524953e68330976c +mkdir -p "$MOCK_DIR" +cp -a "$SRC_DIR/asterisk/deploy/." "$MOCK_DIR/" +git -C "$SRC_DIR" rev-parse HEAD +``` + +在副本中完成以下修改并保存差异: + +1. 将asterisk1/asterisk2的image固定到批准的同一digest;Compose和scripts/call.sh中使用的Python测试镜像也固定到同一批准digest。镜像实际Asterisk版本必须记录,不把历史22.10.1等同于当前镜像版本。 +2. 把ARI端口映射分别限制为`127.0.0.1:8088:8088`和`127.0.0.1:8089:8088`;SIP/媒体保持隔离Docker网络内。 +3. 保留Compose项目名`asterisk-ari`和网络名,仓库call.sh引用了`asterisk-ari_astari`。若改名必须同步脚本,不能只改Compose。 +4. `ast1`、`ast2`、`ast-mock-provider`名称冲突时改用另一测试环境;禁止删除不属于本次实验的同名容器。 +5. 测试凭据仅能用于这一隔离Mock环境;进入共享或生产环境前替换,并同步健康检查/客户端读取方式。 + +### 3.2 启动并核查必须存在的服务 + +```bash +set -euo pipefail +cd "${MOCK_DIR:?先执行3.1并设置MOCK_DIR}" +docker version +docker compose version +docker compose --profile scale config --services +# 完成上述镜像/端口检查后才执行: +docker compose --profile scale up -d +for name in ast1 ast2 ast-mock-provider; do + test "$(docker inspect -f '{{.State.Running}}' "$name")" = "true" +done +docker exec ast1 asterisk -rx 'core show version' +docker exec ast2 asterisk -rx 'core show version' +docker exec ast1 asterisk -rx 'pjsip show contacts' +docker exec ast2 asterisk -rx 'pjsip show contacts' +bash scripts/health.sh +``` + +通过条件:三容器均运行、两节点实际版本已登记、ARI鉴权查询成功、Mock contact可用。health.sh的`fail=0`只能作为辅助证据,不能替代上述必需服务检查。若实际SIP不支持OPTIONS,生产探活需改为经验证的方式,不据此误摘除线路。 + +### 3.3 顺序验证两节点通话 + +```bash +set -euo pipefail +cd "${MOCK_DIR:?先设置MOCK_DIR}" +mkdir -p evidence +# 1001只用于仓库mock-trunk,不得将此脚本直接指向真实线路。 +bash scripts/call.sh 1001 15 1 | tee evidence/mock-node1.log +bash scripts/call.sh 1001 15 2 | tee evidence/mock-node2.log +docker logs --since 10m ast-mock-provider > evidence/mock-provider.log 2>&1 +docker exec ast1 asterisk -rx 'core show channels concise' +docker exec ast2 asterisk -rx 'core show channels concise' +``` + +通过条件:两次均出现通话应答及`bidirectional=YES`,有双向RTP计数,挂断后本次通话资源清理。脚本即使打印`bidirectional=NO`也不一定返回非零,验收必须检查结果内容,不能仅看退出码。 + +Mock多实例、固定端口和媒体关联方式存在简化,顺序成功不证明并发隔离;并发、迟到应答、真实编解码和FALLBACK在后续专项验证。 + +## 4. D2:真实SIP部署差异 + +### 4.1 生产网络拓扑 + +推荐初版:A/B分别运行在独立Linux主机,Asterisk使用host network;中间件通过管理网访问ARI,媒体组件与节点双向可达。这样避免将仓库同一Docker私网中的音频可达性误认为真实外网可达性。 + +同机运行两个host-network节点会争用5060、8088和RTP端口,不允许直接照搬。若必须同机双节点,需另行设计完整端口/地址及NAT映射,不能只改变ARI端口。 + +| 流向 | 放通与限制 | +| --- | --- | +| 中间件→ARI | 管理网HTTPS/受控代理或批准的私网链路;仅允许控制者,禁止公开8088 | +| Asterisk↔指定SIP | 供应商约定SIP协议/端口及源IP;不能只假设UDP5060 | +| Asterisk↔供应商媒体 | 协商的RTP/RTCP范围及来源;NAT需正确宣告外部地址 | +| Asterisk↔媒体组件 | 为并发会话分配独立端口/关联,双向路由和防火墙明确 | +| 中间件↔MQ/数据库/SaaS/存储 | 按最小权限开放;MQ管理端不向业务公网暴露 | + +不要清空防火墙或覆盖所有sysctl。RTP端口预算需计入PJSIP、externalMedia和实际RTCP使用;不能仅根据端口数量承诺业务并发。 + +### 4.2 单节点Compose模板 + +这是**本期新增模板,不是参考仓库原有文件**。在当前节点的DEPLOY_DIR保存为docker-compose.yml,并准备conf目录中列出的配置文件、批准镜像及权限后再启动;不包含Mock Provider。 + +```yaml +name: ai-outbound-node +services: + asterisk: + image: ${ASTERISK_IMAGE:?必须提供批准的镜像digest} + network_mode: host + restart: unless-stopped + stop_grace_period: 60s + volumes: + - ./conf/http.conf:/etc/asterisk/http.conf:ro + - ./conf/ari.conf:/etc/asterisk/ari.conf:ro + - ./conf/pjsip.conf:/etc/asterisk/pjsip.conf:ro + - ./conf/rtp.conf:/etc/asterisk/rtp.conf:ro + - ./conf/extensions.conf:/etc/asterisk/extensions.conf:ro + - recordings:/var/spool/asterisk/recording + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" +volumes: + recordings: {} +``` + +启动前通过`core show settings`和镜像说明核对实际录音目录/运行UID;若路径不同,同步修改挂载和读取方式。A/B各有本地录音卷,不是共享存储。宿主机永久损坏可能导致尚未上传的录音丢失;若业务要求该场景零丢失,必须另配可恢复存储并验收,不能由双Asterisk节点自动保证。 + +### 4.3 必改配置清单 + +| 文件/配置 | 必须处理的内容 | +| --- | --- | +| http.conf | 启用ARI所需HTTP;同机访问可绑定回环,异机绑定管理网/受控代理;不复制0.0.0.0公网暴露 | +| ari.conf | 新建独立服务账户、强密钥、限制CORS及访问来源;运行凭据不复用仓库样例 | +| pjsip.conf transport | 按供应商UDP/TCP/TLS要求、监听地址/端口、local_net和外部信令/媒体地址配置 | +| pjsip.conf endpoint/AOR | 主、备用endpoint分别配置;endpoint_id与中间件trunk_id一一对应;明确主叫/号码格式、编解码、媒体路由 | +| SIP鉴权 | IP鉴权与注册/Digest鉴权分别配置;需要注册时补auth/registration,不能只修改contact | +| 线路探活 | OPTIONS或供应商认可的探活;容量、CPS、失败码和冷却策略不能仅靠Avail决定 | +| rtp.conf | 显式端口段、来源限制;strictrtp是否开启需真实线路/媒体验证,不默认长期关闭 | +| extensions.conf | 本期新增只拒绝非预期呼入的上下文;出站endpoint引用该受限context,不开放公共拨号入口 | +| 录音卷 | 节点独立持久卷、实际UID权限、配额/清理/上传期限;不能只写容器可写层 | + +本期只做外呼,无需为了该方案提前引入Kamailio、呼入路由或共享SIP注册体系。指定SIP的名称、注册信息和号码策略未提供前,不能生成已经可用于真实拨号的生产pjsip.conf。 + +## 5. D3:部署节点并核实ARI能力 + +以下在各自节点的部署目录执行,镜像变量应为`andrius/asterisk@sha256:...`或已批准的等价镜像,不接受latest。 + +```bash +set -euo pipefail +: "${DEPLOY_DIR:?设置当前节点的独立部署目录}" +: "${ASTERISK_IMAGE:?设置已批准的镜像digest}" +cd "$DEPLOY_DIR" +case "$ASTERISK_IMAGE" in *@sha256:*) ;; *) echo '必须固定镜像digest' >&2; exit 1;; esac +for f in http.conf ari.conf pjsip.conf rtp.conf extensions.conf; do + test -f "conf/$f" +done +docker compose config --services +docker compose pull asterisk +docker compose up -d asterisk +docker compose exec -T asterisk asterisk -rx 'core show version' +docker compose exec -T asterisk asterisk -rx 'core show settings' +docker compose exec -T asterisk asterisk -rx 'module show like res_ari' +docker compose exec -T asterisk asterisk -rx 'module show like chan_rtp' +docker compose exec -T asterisk asterisk -rx 'pjsip show endpoints' +docker compose exec -T asterisk asterisk -rx 'pjsip show contacts' +docker compose exec -T asterisk asterisk -rx 'pjsip show registrations' +``` + +`pjsip show registrations`仅对注册型接入要求成功;IP鉴权没有注册条目不算失败。核对bridges/channels/recordings等ARI资源、RTP通道及WAV格式支持,缺失时停止联调,不假定仓库挂载了modules.conf或自定义Dockerfile——它们在参考路径中不存在。 + +管理网接口检查使用权限为600的netrc/受控凭据文件,禁止把密码放在URL、命令历史或截图中: + +```bash +set -euo pipefail +: "${ARI_BASE:?例如批准的管理网HTTPS地址,不含/ari}" +: "${ARI_NETRC:?设置受控netrc文件路径}" +test -f "$ARI_NETRC" +curl --fail --silent --show-error --netrc-file "$ARI_NETRC" \ + "$ARI_BASE/ari/asterisk/info" +curl --fail --silent --show-error --netrc-file "$ARI_NETRC" \ + "$ARI_BASE/ari/channels" +curl --fail --silent --show-error --netrc-file "$ARI_NETRC" \ + "$ARI_BASE/ari/bridges" +``` + +中间件启动ARI事件连接后再核对应用注册。只有REST返回200而没有有效Stasis事件连接,不能认为应用可用。禁止用忽略证书验证的方式掩盖HTTPS配置错误。 + +## 6. D4:中间件与MQ部署顺序 + +参考仓库没有中间件镜像、启动命令、数据库迁移和RabbitMQ工具脚本,因此本节是**待开发服务的发布步骤**,不虚构仓库中不存在的可执行文件。 + +1. 发布第一部分约定的数据库结构/唯一约束和向后兼容迁移;备份执行/投递数据。 +2. SaaS负责人创建/确认命令、事件、重试/DLQ及ACL;核对发布confirm、不可路由检测、持久化和消费ACK。 +3. 配置节点表、ARI凭据引用、主备线路组、媒体地址池、租户授权、并发/CPS、有效期及磁盘阈值。 +4. 配置SaaS当前业务许可查询、上传授权/完成确认、接收状态查询接口及允许的存储域名;不配置HTTP业务回调地址。 +5. 先启动SaaS事件消费者,再启动中间件的查询/恢复/投递能力;消费执行命令暂不开启。 +6. 中间件核对未决attempt、存量通道和未投递事件,建立每节点唯一ARI控制连接;健康通过后开放低并发执行。 +7. 使用授权测试租户发布一条call.execute;核对command.result、call.status、transcript.final、call.finished、recording.ready到SaaS入库和页面的完整关联。 +8. 执行第三部分故障/恢复用例,全部阻断项通过后才进入灰度。客户端SDK/框架选择不改变这条契约和门禁。 + +## 7. D5:录音专项对接 + +按已部署镜像的ARI接口核验以下能力,不直接将接口存在视为录音可用: + +| 顺序 | ARI/存储动作 | 检查点 | +| --- | --- | --- | +| 1 | POST /ari/bridges/{bridgeId}/record | 受控name、format=wav;模块/目录有权限;返回成功并收到开始事件 | +| 2 | POST /ari/recordings/live/{recordingName}/stop | 通话结束时幂等处理;等待录音完成/文件封装,不读取仍在写的文件 | +| 3 | GET /ari/recordings/stored/{recordingName}/file | 获取可播放文件;名称URL编码;跨节点按原node_id读取 | +| 4 | SaaS上传授权→上传→complete | SHA-256、大小、通话归属通过;授权过期可重新申请 | +| 5 | MQ recording.ready→SaaS消费 | 可查询应用结果、租户鉴权播放;重复事件不产生重复资产 | +| 6 | 清理暂存 | SaaS已应用且达到批准保留策略后清理;未确认的失败文件告警,不静默删除 | + +需要双声道、全程振铃录音、严格无录音损失或特殊合规策略时,单独确认;本模板仅以接通后单轨混音作为一期基线。 + +## 8. 灰度、回滚与运维交接 + +- 顺序:单节点单路→两节点顺序→真实多路隔离→已确认目标负载→小流量灰度。Mock成功不能跳过真实SIP/AI阶段。 +- 回滚前停止新指令/建立屏障,排空或经授权处置在途电话,记录未决attempt、录音和outbox状态,再回滚中间件或节点镜像。 +- 不使用`docker compose down -v`删除录音卷;不把删除数据库/队列当作恢复手段。配置和镜像回滚必须保留事件/执行事实,避免重复拨号。 +- 若发现双拨、越权、录音静默丢失或终态回退,立即停止放量;按call_id对账后再恢复。 +- 交付记录包括镜像digest、Git/配置版本、网络矩阵、密钥引用、部署命令记录、验收证据目录、故障联系人和批准的容量/恢复边界。 diff --git a/docs/交付文档/一期中间调度件与Asterisk_v1.0/03_联调步骤与验收标准.md b/docs/交付文档/一期中间调度件与Asterisk_v1.0/03_联调步骤与验收标准.md new file mode 100644 index 0000000..3c55952 --- /dev/null +++ b/docs/交付文档/一期中间调度件与Asterisk_v1.0/03_联调步骤与验收标准.md @@ -0,0 +1,140 @@ +# 第三部分:联调步骤与验收标准 + +**当前状态:全部运行用例待执行。** 本次只完成参考代码审查和文档静态校验,没有实际部署、拨打电话或连接生产MQ。不得把参考仓库的历史成功记录填写为本次通过。 + +## 1. 验收分层与准入 + +| 层级 | 验证内容 | 不能替代的验证 | +| --- | --- | --- | +| L0 静态检查 | 文档、样例、配置语法、镜像/提交/参数登记、接口评审 | 不证明容器能启动、线路可用或业务可靠 | +| L1 Mock底座 | 两节点运行、ARI、Mock SIP、顺序双向RTP、清理 | 不证明真实AI、录音、MQ回传、并发隔离或生产主备 | +| L2 真实SIP/AI | 授权号码、指定主备接入、媒体、AI、多轮/打断及录音 | 不证明SaaS业务数据一致性和异常恢复 | +| L3 SaaS闭环 | MQ下发/回传、控制应答、最终文字、录音入库/播放、补偿 | 不替代容量、故障注入和安全检查 | +| L4 发布门禁 | 阻断用例、确认后的容量/时延、恢复、回滚、交接 | 全部有证据才能签字发布 | + +开始L2前必须取得真实SIP/AI权限及测试号码授权;故障注入仅在隔离环境或批准窗口执行。不得为了验收中断无关服务、清空队列/数据库/录音卷或调整全局防火墙。 + +## 2. 建议指标与G0确认表 + +以下是**小规模一期试运行建议值,不是已测结论或合同承诺**。可根据真实供应商及需求在G0修改;正式验收单不得保留“待定”。 + +| 指标 | 建议初始目标 | 测量边界/样本 | +| --- | --- | --- | +| 真实并发/CPS | 目标10路、全局1 CPS;先1路再3路再10路,且不超过供应商限额 | 在确认后的目标负载下持续30分钟,覆盖两节点;SIP重传不算新增呼叫 | +| 控制/状态查询 | 查询P95≤500ms;控制APPLIED P95≤2s | 从SaaS请求到结果;控制时间包含发起屏障处理,不能只量HTTP202 | +| MQ命令受理 | P95≤2s | SaaS持久发布到中间件持久接收;等待拨号另计 | +| AI首音频响应 | P95≤1500ms | 客户语音结束被VAD确认到首个有效TTS音频包发往通话桥;不少于100个有效轮次 | +| 打断 | P95≤500ms | VAD确认插话到旧TTS停止向桥发送;另抽样核查客户侧体验 | +| 最终文字回传 | P95≤3s | 最终稿产生到SaaS业务库应用并可查询;UI刷新延迟另记 | +| 录音就绪 | 测试通话≤180s时,挂断后120s内SaaS可鉴权播放 | 包含录音封装、上传、校验、MQ消费;超大文件单独测 | +| 中断恢复 | SaaS消费/上传中断5分钟,恢复后10分钟内补齐本轮关键数据 | 固定目标负载、明确数据量和可用带宽;不允许通过删除积压通过 | +| 资源清理 | 正常结束后30s内释放本次通道/桥/媒体会话 | 录音可保留;异常节点须恢复后完成核对和清理 | +| 正确性底线 | 重复业务拨号0、跨租户访问0、终态非法回退0、静默丢失最终文字/录音0 | 对本轮全部execution/call/event核对;未决事实必须明确暴露 | + +并发、CPS、阈值、事件/文件保留期限、磁盘水位、最长可恢复中断由本人、SaaS和运维共同签字。若本地录音尚未上传时宿主机永久丢失,必须如实记录无法恢复的边界,不虚称双节点能够保证录音零丢失。 + +## 3. 联调步骤与双方产出 + +| 步骤 | 操作与主责 | 本步产出 | +| --- | --- | --- | +| T0 契约冻结 | 本人提供消息/状态/错误码;BgA/BgB确认SaaS接口和MQ映射;运维确认网络/密钥 | 版本化契约、示例、环境参数和G0签字 | +| T1 SaaS-MQ空链路 | SaaS发送测试指令;中间件暂不拨真实电话,只校验/受理并MQ应答;SaaS消费 | command_id全链路记录,ACK与业务结果区分正确 | +| T2 Mock底座 | 运维按第二部分部署;本人分别在A/B执行顺序Mock通话 | 容器/ARI/PJSIP、双向RTP和清理证据;标记为L1 | +| T3 真实单路 | 本人驱动指定SIP和AI链路;SaaS下发授权号码/快照 | 正常通话、多轮/打断、状态和原因映射 | +| T4 数据回传 | BgB接收MQ事件及资产;FeA展示文字/录音/状态 | event_id→入库→页面可追溯,重复消费无重复内容 | +| T5 故障与对账 | 本人与运维注入MQ/ARI/SIP/上传故障;SaaS参与恢复 | 每项预期、实际、时间、原因、attempt/event/文件证据 | +| T6 容量与灰度 | 按G0目标压测,完成发布/回滚演练 | 指标报告、遗留问题及L4签字结论 | + +“空链路”是专用测试模式,必须不能触发真实SIP;不能靠使用假号码来证明不拨号。正常SaaS调度与授权检查在L2/L3必须启用。 + +## 4. 阻断验收用例 + +下列所有用例初始状态均为“待执行”。本轮环境中任一项失败或缺证据,不得签署一期生产验收。 + +### 4.1 部署与接入 + +| 编号/场景 | 操作步骤 | 通过标准/证据 | +| --- | --- | --- | +| DEP-01 必需服务 | Mock层检查A/B及Mock服务,真实层检查批准的运行服务集合(不含Mock);停止一个必需服务后重做检查 | 缺少必需服务必须判失败,不能因health.sh输出SKIP而整体通过;保存服务清单 | +| DEP-02 ARI与Stasis | 检查鉴权REST和节点应用事件连接;断开WebSocket但保留REST | 控制者发现断连并停止不安全的新执行;REST200不能掩盖应用不可用 | +| DEP-03 网络/鉴权 | 用授权与未授权来源访问ARI/MQ;验证真实SIP/媒体双向路由 | 未授权访问被拒绝;无公网裸ARI;没有以关闭防火墙/证书校验取巧 | +| DEP-04 录音持久化 | 生成录音后重启/重建当前测试节点容器,保留卷 | 已完成本地录音仍可读取和校验;容器可写层不能成为唯一存储 | + +### 4.2 命令与MQ可靠性 + +| 编号/场景 | 操作步骤 | 通过标准/证据 | +| --- | --- | --- | +| MQ-01 正常闭环 | 发布一条合法执行;完成电话及SaaS事件入库 | command、execution、call、attempt、event关联一致;ACCEPTED不被显示为已接通 | +| MQ-02 重复命令 | 同一command_id/execution_id重发10次;关闭FALLBACK以隔离变量 | 只产生一次业务拨号;SIP同一INVITE的协议重传不计为额外执行 | +| MQ-03 同键异内容 | 同租户同command_id改变号码/快照;另保持原凭证篡改tenant_id后发布 | 冲突/越权被拒绝,原命令不被覆盖;不产生第二次拨号;合法不同租户的命令空间不冲突 | +| MQ-04 持久化/ACK竞态 | 在命令提交前、提交后ACK前、ACK后分别终止中间件并恢复 | 未提交可重投,已提交能恢复;无丢失受理事实和重复业务拨号 | +| MQ-05 发布确认竞态 | 断开网络或让交换机无有效绑定;在事件提交与confirm之间停止进程 | 事件保留并重试;不可路由不能标记APPLIED;恢复后SaaS只有一次业务应用 | +| MQ-06 SaaS提交/ACK竞态 | 在event_inbox业务事务提交前后分别中断消费者 | 失败事务不ACK;重投事件幂等;最终文字/状态不重复、不缺失 | +| MQ-07 数据库失败 | 中间件接收时或SaaS应用事件时使数据库暂时不可用 | 不提前ACK/返回成功;不继续发起缺少持久执行意图的电话;恢复可对账 | +| MQ-08 容量与停止 | 积压多条指令,执行PAUSE/STOP;等APPLIED后重放旧指令及过期指令 | 生效后不再新发起,旧revision/过期执行被拒绝;无无限requeue热循环 | +| MQ-09 业务有效性 | 已排队号码被加入拒绝再联系/禁止时段,或授权被撤销后再执行 | SaaS业务许可重新校验不通过则不拨号;不得因早先发布成功绕过当前策略 | + +### 4.3 Asterisk、SIP与FALLBACK + +| 编号/场景 | 操作步骤 | 通过标准/证据 | +| --- | --- | --- | +| SIP-01 正常/无振铃事件 | 正常通话;让有效ANSWERED到达而无RINGING观测 | 可以直接从DIALING收敛到ANSWERED;最终时间和原因正确 | +| SIP-02 合法主备 | 主线路在明确未接通时发生已批准线路故障;确认旧尝试结束后切备用 | 同一call/execution下新attempt,最多达到配置总预算;不同时保留两条活动呼叫 | +| SIP-03 禁止误切 | 分别返回busy、reject、invalid;另在已接通后制造AI/媒体故障 | 这些场景不触发未经授权的SIP备用重拨;原因分类准确 | +| SIP-04 迟到接通/ARI超时 | 原尝试已提交,阻断控制事件,再让接通迟到;同时触发超时处理 | 进入对账,不因“未收到接通”立即备用拨号;没有双通;完整记录时序 | +| SIP-05 节点故障 | 分别在提交originate前、提交后事实不明、已接通后中断节点 | 前者可选健康节点;不明状态不盲重拨;已接通中断如实结束而非声称无损迁移 | +| SIP-06 并发隔离 | 多通话播放不同标识音/不同测试词并分布两节点;抓取允许范围内的媒体关联 | 每call的通道、端口、文字、录音不串线;不能只比较总RTP包数 | +| SIP-07 清理与重启 | 正常结束、客户挂断、振铃超时及中间件重启后检查资源 | 按call_id清理且不伤其他通话;残留有补偿和告警,达到清理时限 | + +### 4.4 AI、文字、录音与安全 + +| 编号/场景 | 操作步骤 | 通过标准/证据 | +| --- | --- | --- | +| DATA-01 AI多轮/打断 | 至少100个有效轮次,覆盖播音时插话、静音和AI超时 | 达到确认的延迟基线;取消旧生成/播音;AI故障不无限阻塞电话 | +| DATA-02 文字版本 | 按乱序/重复方式回传中间稿和最终稿,再重放最终事件 | 同轮按版本收敛;最终文字完整;未播出的AI文本有正确标记 | +| DATA-03 录音闭环 | 完成电话→录音封装→上传校验→MQ ready→SaaS播放 | checksum/大小/时长可核对,事件在上传确认后产生;页面不先显示假成功 | +| DATA-04 上传故障 | 模拟上传中断、授权过期、checksum不符、完成确认丢失 | 授权/上传可重试且不产生重复资产;不提前ready;通话终态仍可展示 | +| DATA-05 SaaS中断 | 暂停SaaS消费及上传5分钟再恢复;模拟接近磁盘上限 | 音频不等待回传;有积压/磁盘保护;恢复后在目标时限补齐,不以删数据通过 | +| DATA-06 数据补传 | 按call_id触发事件重放;核对原command/attempt数量 | 仅补数据,不重新拨号;SaaS幂等消费,APPLIED状态能查询 | +| SEC-01 租户隔离 | 两租户相同业务ID或交叉引用call/recording,尝试查询、补传、播放 | 跨租户被拒绝;事件不能覆盖别的租户;播放权限独立验证 | +| SEC-02 输入/凭据 | 提交恶意dial string/媒体地址/超大消息,检查日志与签名URL | 不支持任意ARI调用/多目标注入;大小受限;密码、敏感号码和有效签名不泄露 | +| OPS-01 压测/回滚 | 按目标并发/CPS持续30分钟;停止接单并回滚版本,再恢复 | 不超限、无双拨/串线/终态回退;数据与录音卷保留;待投递事件可恢复 | + +## 5. 证据、结果与签字模板 + +每个用例至少关联一组可定位证据;业务统计不能仅依赖截图。 + +- 版本:参考提交、实际镜像digest/Asterisk版本、中间件版本、SaaS版本、配置版本。 +- 环境:node_id、网络路径、MQ策略、租户、测试号码授权、目标并发/CPS、时间同步情况。 +- 执行:command_id、execution_id、call_id、attempt_id、event_id、状态版本、时间点与失败原因。 +- 呼叫:ARI事件/查询、受控SIP信令证据、媒体会话关联、清理结果;SIP重传与真正新dialog区分。 +- 数据:SaaS event_inbox应用记录、任务/通话结果、最终文字、录音checksum和受控播放结果。 +- 恢复:注入故障、恢复动作、积压清空时间、残留/未决记录、异常告警及处置。 +- 文件:按脱敏证据目录归档,凭据和客户数据不直接打包公开。 + +单项记录模板: + +| 字段 | 填写内容 | +| --- | --- | +| 用例ID/环境/日期/执行人 | 待执行 | +| 前置条件与版本 | 待填写 | +| 输入与关联ID | 待填写 | +| 故障注入/执行动作 | 待填写 | +| 预期结果 | 引用本节对应标准 | +| 实际结果与指标 | 待填写,不能填参考仓库历史结果 | +| 证据路径与缺陷号 | 待填写 | +| 结论与复核人 | 待执行 / 通过 / 失败 / 阻塞;不得以空白代表通过 | + +发布签字由本人(中间件/ARI)、SaaS后端(指令与事件)、FeA(展示/播放)、运维(部署/恢复)及业务验收人完成。角色重合时注明,不增加隐含人力。 + +## 6. 一期最终完成定义 + +只有以下条件全部满足,才能把“设计完成”升级为“实施验收完成”: + +1. 文档契约、实际Schema/API和实现版本一致,G0参数无未确认阻断项。 +2. 必需节点、真实SIP、AI、中间件、MQ及SaaS资产链路均通过相应层级,不以Mock替代。 +3. 本文全部阻断用例有通过证据,确认后的指标达标;双拨、串线、越权、静默数据丢失为零。 +4. 配置/凭据/卷/监控/告警/补偿/保留与回滚操作已交接;已知单实例及本地录音失效边界被接受或另行修复。 +5. 一期没有计费入口或扣费行为,现有SaaS业务数据未被重复建设或破坏。 + +本次文档交付只完成L0中的参考审查与文档校验;L1–L4均待实际环境执行和签字。 diff --git a/docs/交付文档/一期中间调度件与Asterisk_v1.0/README.md b/docs/交付文档/一期中间调度件与Asterisk_v1.0/README.md new file mode 100644 index 0000000..acd77e3 --- /dev/null +++ b/docs/交付文档/一期中间调度件与Asterisk_v1.0/README.md @@ -0,0 +1,18 @@ +# 一期中间调度件、Asterisk部署与验收文档 + +## 文档入口 + +- [合并版 Word 文档](一期中间调度件_Asterisk部署及验收_v1.0.docx) +- [01 中间调度件与MQ回传设计](01_中间调度件与MQ回传设计.md):职责、数据模型、消息和接口、幂等、控制屏障、FALLBACK、文字/录音回传。 +- [02 Asterisk部署与SIP对接步骤](02_Asterisk部署与SIP对接步骤.md):固定参考版本、隔离Mock步骤、真实部署差异、节点模板、配置清单、录音及回滚。 +- [03 联调步骤与验收标准](03_联调步骤与验收标准.md):L0–L4分层、量化指标建议、29项阻断用例、证据和签字模板。 + +## 已确认与待执行 + +用户确认采用**MQ下发指令、MQ回传事件**;HTTP只用于控制、查询和录音上传相关交互。本套文档优先于旧开发计划中的HTTP业务回调草案。 + +参考仓库:`git@git.ipao.vip:rogee/sip-research.git`;固定审阅提交:`6a8064e53bb7eadd73f03373524953e68330976c`。 + +本次只审查参考代码并生成文档,没有远程部署、停用其他服务、拨打电话或修改现有Excel。仓库只有Mock通话底座,不能据此认定中间件、真实AI、MQ回传或录音链路已实现。 + +执行前补齐指定SIP主备参数、SaaS/MQ契约、镜像digest、部署地址、AI/存储凭据和验收基线。所有运行用例目前为**待执行**,文档语法校验不等于业务验收通过。 diff --git a/docs/交付文档/一期中间调度件与Asterisk_v1.0/一期中间调度件_Asterisk部署及验收_v1.0.docx b/docs/交付文档/一期中间调度件与Asterisk_v1.0/一期中间调度件_Asterisk部署及验收_v1.0.docx new file mode 100644 index 0000000..b4dee9e Binary files /dev/null and b/docs/交付文档/一期中间调度件与Asterisk_v1.0/一期中间调度件_Asterisk部署及验收_v1.0.docx differ diff --git a/docs/系统架构图_规划态.md b/docs/系统架构图_规划态.md new file mode 100644 index 0000000..f708601 --- /dev/null +++ b/docs/系统架构图_规划态.md @@ -0,0 +1,126 @@ +# 系统架构图(规划态) + +**版本:** v0.1 +**依据:** [一期计划 v1.3](一期呼出应用开发计划_v1.0.md)、[OpenAPI 与 MQ 契约规划 v0.3](SaaS交互_OpenAPI与MQ契约规划_v0.1.md)。 +**状态:** 目标逻辑架构,尚未完整实现;框图不表示已部署,也不指定机器数、数据库产品或独立微服务数量。当前仅有 ASR 验证基础,完整 AI 与生产容量均未验收。 + +```mermaid +flowchart LR + subgraph SAAS["现有 SaaS:业务主数据与授权"] + UI["租户用户/管理页面"] + BUSINESS["任务、号码、配置版本、业务授权
基础频控/拒绝再联系/重新外呼决策"] + SDB[("SaaS 持久存储
业务主数据、发布记录、inbox、资产关联")] + PUBLISH["可信发布服务
tenant_id 受控映射/限速/原 ID 重试"] + CONSUME["事件消费者
租户校验/event_id 去重/版本合并
业务与 inbox 同事务后 ACK"] + STORAGE["SaaS 存储接口
上传授权、实际校验、确认 oss_id
按租户/用户提供鉴权播放"] + UI --> BUSINESS + BUSINESS --> SDB + SDB --> PUBLISH + CONSUME --> SDB + STORAGE --> SDB + SDB --> UI + end + + subgraph BROKER["RabbitMQ:可靠传输,不是活动通话唯一状态源"] + CMDX["命令 Exchange
call.execute"] + QA["租户 A 命令队列"] + QB["租户 B 命令队列"] + QN["租户 N 命令队列"] + EVX["事件 Exchange"] + EVQ["SaaS 事件队列
本轮不按租户拆分"] + DLQ["重试/死信与隔离
受控恢复、不得绕过租户域或配额"] + CMDX --> QA & QB & QN + EVX --> EVQ + QA & QB & QN -. "异常隔离" .-> DLQ + end + PUBLISH -- "AMQP:持久消息、confirm + mandatory" --> CMDX + EVQ --> CONSUME + + subgraph APP["呼出应用:逻辑组件,部署粒度待实施"] + API["HTTP 控制/查询/历史事件补传
不提供创建通话或重新拨号接口"] + FAIR["租户公平调度器,可多实例
有界轮转、预取和持久待发起窗口"] + ADMIT["原子资源准入与路由
租户并发/CPS + 供应商并发/CPS
Cell/端口/出口健康 + AI 配额"] + STATE[("可靠持久存储/协调
命令与执行去重、控制屏障、发起意图
调度所有权、额度与租约、事件与 outbox")] + OUTBOX["outbox 投递器
所有业务结果经 MQ
有限重试/原事件补传"] + ASSET["资产处理逻辑
录音封口/有界暂存/上传与恢复
可与 Cell 共置,不假设额外文件传输服务"] + FAIR --> ADMIT + FAIR <-->|"先持久化后 ACK"| STATE + ADMIT <-->|"所有权、原子额度与隔离令牌"| STATE + API <-->|"控制屏障/查询快照/原事件"| STATE + STATE --> OUTBOX + ASSET <-->|"资产状态 + outbox 同事务"| STATE + end + QA & QB & QN --> FAIR + DLQ -. "合法命令恢复回原租户调度域" .-> FAIR + BUSINESS -. "HTTPS:控制/查询/补传" .-> API + OUTBOX -- "AMQP:状态、文字、终态、录音元数据" --> EVX + + subgraph CELLS["语音 Cell 池:多机器 + 多 EIP 直连"] + subgraph CELL1["Cell 1 · 固定出口"] + EXEC1["本地执行器
真实发起前复核屏障/期限/租约"] + AST1["Asterisk
预配置授权 trunk/通道/桥"] + MEDIA1["媒体与会话适配
流式音频、打断取消、录音暂存"] + IP1["固定 EIP 1
独立白名单/RTP 端口/健康"] + EXEC1 <-->|"ARI 控制/事件"| AST1 + AST1 <-->|"实时媒体"| MEDIA1 + AST1 <-->|"SIP / RTP"| IP1 + end + subgraph CELLN["Cell N · 同构示意,非确定机器数"] + STACKN["本地执行器 + Asterisk + 媒体适配
独立端口、容量、租约与健康"] + IPN["固定 EIP N
独立供应商白名单"] + STACKN <-->|"SIP / RTP"| IPN + end + end + ADMIT -- "trunk_id + egress_pool_id + cell_id" --> EXEC1 + ADMIT -- "同规则选择其他 Cell" --> STACKN + EXEC1 <-->|"执行事实/心跳/对账"| STATE + STACKN <-->|"执行事实/心跳/对账"| STATE + API -. "跨 Cell 控制屏障/在途许可收敛" .-> EXEC1 + API -. "跨 Cell 控制屏障/在途许可收敛" .-> STACKN + MEDIA1 -. "本 Cell 已封口录音" .-> ASSET + STACKN -. "本 Cell 已封口录音" .-> ASSET + + subgraph TELEPHONY["外部电话网络"] + SIP["已预接入并授权的 SIP 供应商
逐呼应用本线路主叫/被叫规则
支持单线路;无备用不虚构"] + CALLEE["被叫电话"] + SIP <-->|"电话接续/语音"| CALLEE + end + IP1 <-->|"SIP / RTP 直连"| SIP + IPN <-->|"SIP / RTP 直连"| SIP + + subgraph AI["外部 AI 服务:目标链路,协议与配额待冻结"] + ASR["ASR
仅有独立验证基础"] + LLM["LLM
未启用,待新规范"] + TTS["TTS
未启用,待新规范"] + ASR -->|"识别文本,经会话逻辑编排"| LLM + LLM -->|"回复文本,经会话逻辑编排"| TTS + end + MEDIA1 -- "客户流式音频" --> ASR + TTS -- "生成语音" --> MEDIA1 + STACKN <-->|"同构实时 AI 会话,非 MQ 音频传输"| AI + MEDIA1 -. "文字/播放证据等执行事实" .-> STATE + + OSS[("OSS
持久录音对象,不经 MQ 传文件")] + ASSET -. "① HTTPS 申请授权;③ complete 并取得 oss_id" .-> STORAGE + ASSET -- "② 签名 HTTPS 上传文件" --> OSS + STORAGE <-->|"独立验证对象、大小与校验值"| OSS + + classDef planned fill:#eef4ff,stroke:#4263a6,color:#172a46; + classDef pending fill:#fff4dc,stroke:#b7791f,color:#573b13; + classDef data fill:#eaf7f1,stroke:#2d8063,color:#164b39; + classDef endpoint fill:#f0edff,stroke:#7560a0,color:#3c2f5c; + class API,FAIR,ADMIT,EXEC1,AST1,MEDIA1,STACKN,OUTBOX,ASSET planned; + class ASR,LLM,TTS pending; + class SDB,STATE,OSS,EVX,EVQ,QA,QB,QN data; + class IP1,IPN,SIP,CALLEE endpoint; +``` + +## 架构约束 + +1. **业务与媒体分离:** RabbitMQ 是唯一拨号指令入口、全部业务结果回传通道;不承载实时音频。HTTP 仅控制、查询、补传及存储握手,不做业务结果回调。AI 箭头表达经 Cell 会话逻辑编排的数据顺序,不要求供应商服务互相直连。 +2. **可靠性与公平:** 多调度实例共享持久去重、额度及所有权协调。租约失效停止新任务;未知活动通话须对账,不直接释放占用。队列满明确背压,SaaS 保留原 ID;恢复仍回原租户调度域。独立队列不等于独享 broker,也不保证固定开始时限。 +3. **固定路由:** 一通电话及合法 FALLBACK 始终固定 Cell/出口;trunk 预接入,不逐呼改写共享 SIP 配置。不建设单 EIP + NAT,不自动迁移故障节点的活动通话。当前登记出口 `123.56.71.98` 不等于已确认可操作的 EIP,图中其他出口均为规划资源。 +4. **容量是目标而非结论:** 至少 1000 路同时已接通的完整 ASR/LLM/TTS 通话;N+1 或更高冗余,`(Cell 数量 - 1) × 实测单 Cell 安全容量 >= 1000`。拨号、振铃、CPS、AI 配额、媒体端口、带宽与 MQ/OSS 另行计入;未压测不承诺数量或规格。 +5. **资产闭环:** ①授权 → ②上传 OSS → ③SaaS 实际校验并确认 oss_id → ④verified 与 outbox 同事务 → ⑤MQ recording.ready → ⑥SaaS 去重关联。未验证不发 ready;失败经 MQ 回传。资产处理框是逻辑能力,不强制把录音跨节点搬到新服务。 + +服务鉴权、TLS、租户 ACL、密钥注入、监控与积压/磁盘水位保护横跨上述组件;为避免遮挡主链路,不额外画成一套管理平台。各外部依赖和真实测试状态仍以依据文档为准。 diff --git a/docs/部署接入_运行说明.md b/docs/部署接入_运行说明.md new file mode 100644 index 0000000..d7bcc72 --- /dev/null +++ b/docs/部署接入_运行说明.md @@ -0,0 +1,213 @@ +# 部署接入:本轮实现与运行说明 + +## 1. 实施边界 + +本轮依据用户选择,**仅复用 voice_test 的 ASR**,没有复制或启用其 LLM/TTS,也没有把原调研页面直接作为生产服务暴露。 + +| 内容 | 本轮状态 | +| --- | --- | +| ASR Web 服务 | 已实现:访问令牌、Origin校验、服务端模型/凭据选择、16k单声道PCM、识别结果展示 | +| ASR 连接与协议 | 已加固:断开清理、取消、写入/启动时限、最终结果背压、火山帧长度及gzip解压上限 | +| 部署底座 | 已实现:非root/read-only ASR容器、回环端口、Asterisk配置生成和显式启动检查 | +| 阿里云主机准备 | 已实现CLI驱动的只读计划、受控创建竞价实例、复用主机和绑定既有EIP;默认不修改云资源 | +| 云端实际操作 | 未执行;当前本机无aliyun CLI/云凭据,未核实固定IP归属,未创建实例或改绑IP | +| Asterisk真实接入 | 未执行;指定SIP地址/鉴权、VPC网络、镜像digest等仍需填写 | +| LLM/TTS | 未实现、未启用,等待用户新的供应商/协议/参数规范 | +| ARI业务调度、自动FALLBACK、MQ/OSS回传 | 尚未实现;仍按开发计划推进,不能把两个trunk配置当成自动切换代码 | + +当前目标是一台北京竞价ECS上的部署底座。ASR测试台与SIP媒体尚未连通;浏览器识别不是电话外呼,也不是已完成SaaS业务验收。 + +参考:`sip-research@6a8064e53bb7eadd73f03373524953e68330976c`;ASR协议源自`voice_test@6772bf4`,仅导入`asr.go/asr_bailian.go/asr_volc.go`后进行加固,不导入原main、配置页、LLM或TTS代码。 + +## 2. 目录与环境 + +```text +AGENTS.md 资源约束、安全规则、参考来源 +compose.yaml ASR Web容器 +compose.asterisk.yaml 独立Asterisk节点容器 +.env.example 服务配置占位,不含真实凭据 +services/asr-web/ ASR-only Go服务及Web页面 + deploy/aliyun_host.py 阿里云CLI只读计划/显式apply + deploy/aliyun.example.json 实例/预算/网络参数占位 + deploy/render_asterisk.py 受控配置生成 + deploy/asterisk.example.json SIP接入参数占位 + deploy/asterisk.sh 默认检查;显式up才启动 + tests/ 离线部署逻辑及PCM测试 + docs/ 需求、计划和运行文档 + .local/ 本机临时状态/验证产物,不提交 +``` + +开发检查使用Go 1.26、Python 3.11+、Node和Docker Engine/Compose。云操作另需阿里云官方CLI及已授权的本地Profile/RAM Role/STS。源码根目录是部署目录,旧Word/Excel/压缩包不随本次代码改动重新生成。 + +## 3. 固定出口IP与云资源 + +### 3.1 已知约束 + +- 区域固定为`cn-beijing`,我方SIP出口白名单公网IP为`123.56.71.98`。 +- 此IP不是SIP服务商服务器地址,不得用作trunk contact。 +- 必须先核实是本账号EIP,还是既有实例的普通公网IP。没有可操作的该IP时停止,不能新分配随机IP替代。 +- 创建只使用竞价`SpotWithPriceLimit`、单台实例和明确价格上限,新实例`InternetMaxBandwidthOut=0`,随后绑定已经确认的EIP。 +- 已有主机必须有专用project标签,或由用户明确填写`adopt_instance_id`授权复用。未知绑定、非ECS资源、多个候选、库存不完整均停止。 +- 既有主机的计费/竞价策略会在plan中展示。非竞价实例不自动转换;若不满足目标,由用户评审迁移,不能擅自停机或创建替代机。 + +### 3.2 先准备CLI与权限 + +从阿里云官方CLI发布渠道安装并验证版本,按团队密钥流程配置Profile/RAM Role/STS,不使用未经审计的`curl | bash`。不要把AK/Secret、SSH私钥或会话Token发到聊天中。 + +最少需要读取EIP和ECS的权限;实际apply还需要RunInstances和AssociateEipAddress权限。VSwitch、安全组、镜像和SSH KeyPair预先存在,脚本不会自动创建全开放安全组。 + +```bash +# 在项目根目录;已有配置文件不覆盖。 +mkdir -p .local +if [ ! -e .local/aliyun.json ]; then + install -m 600 deploy/aliyun.example.json .local/aliyun.json +fi +aliyun --help +python3 deploy/aliyun_host.py --config .local/aliyun.json +``` + +默认只读查询。第一次必须核对返回的实例ID、IP类型、状态和计费策略。若提示实例不属于项目,先由用户核实实际资源归属,再决定是否填写adopt_instance_id,不能为了绕过校验随便填写。 + +### 3.3 缺主机时按需创建 + +创建前填写:`image_id`、`instance_type`、`vswitch_id`、`security_group_id`、`key_pair_name`、正数`spot_price_limit`、系统盘类型/大小。镜像架构、实例规格与VSwitch所在可用区必须兼容。 + +价格上限是每小时竞价计算资源上限,币种以账号计费为准;不包括系统盘、EIP、流量等费用。实际费用与创建授权必须由用户确认。 + +```bash +# 只有确认只读plan、规格、网络和预算后才执行。 +python3 deploy/aliyun_host.py --config .local/aliyun.json --apply +``` + +- 没有可复用实例且固定EIP可用时才创建;RunInstances之前保存ClientToken。 +- 创建请求超时后保留同一ClientToken;绑定失败保留已创建实例ID,重试时优先复用,不另造一台。 +- 绑定前再次核对EIP;若已经被其他实例占用,停止,不解绑对方。 +- 已有Stopped实例不自动启动或替换。实例/EIP未达到确认状态会报错,不宣称部署成功。 +- `.local/aliyun-host.json`和锁文件是恢复依据,不能在出错后直接删除来强行重试。多个控制机必须共用明确的操作责任,不能各自用独立状态并行创建。 +- 该脚本只准备ECS/EIP,不安装Docker、不上传SSH私钥、不配置DNS/HTTPS、不迁移活动通话。竞价回收后的监控/自动恢复尚未实现。 + +## 4. ASR Web启动与浏览器验证 + +### 4.1 配置 + +```bash +if [ ! -e .env ]; then + install -m 600 .env.example .env +fi +# 在本机生成访问令牌,填入.env;不要贴到聊天或工单。 +python3 -c 'import secrets; print(secrets.token_urlsafe(32))' +``` + +- `ASR_WEB_TOKEN`至少32位URL-safe字符,是本测试台访问令牌,不是供应商API Key。 +- `BAILIAN_API_KEY/BAILIAN_WS`用于非fun-*百炼ASR模型;`FUNASR_API_KEY/FUNASR_WS`用于fun-*模型,未配置Fun Key时可回退到百炼Key。 +- `BAILIAN_ASR_MODELS`是服务端允许选择的ASR模型列表,默认`fun-asr-realtime`。模型是否支持当前协议需要真实供应商验证,不能把任意LLM模型放入列表。 +- 火山使用`VOLC_APP_KEY`或`VOLC_APP_ID + VOLC_ACCESS_TOKEN`,并配置对应`VOLC_RESOURCE_ID/VOLC_WS`。 +- 供应商地址只接受WSS,不从浏览器接收任意上游地址或API Key。页面只展示凭据是否配置,不展示密钥。 +- 无供应商凭据时服务可以启动,但模型禁用;`/healthz`只说明进程存活,不说明供应商、SIP或MQ可用。 + +### 4.2 启动 + +```bash +docker compose config --quiet +docker compose up -d --build +docker compose ps +curl --fail http://127.0.0.1:18088/healthz +``` + +默认仅在宿主机`127.0.0.1:18088`暴露。容器非root、只读文件系统、去除capabilities,并设置进程/内存上限。不要把测试台直接作为SaaS用户入口;目前是单一运维访问令牌,不是SaaS租户鉴权。 + +开发者也可从`services/asr-web`运行`go run .`,需提前通过环境设置ASR_WEB_TOKEN和必要上游配置;本地进程默认监听127.0.0.1:8080,不自动读取.env。 + +### 4.3 远程Web麦克风 + +优先在批准域名的TLS反向代理后访问,代理保留Host并支持WebSocket,`PUBLIC_ORIGIN=https://批准域名`用于精确Origin校验。域名和证书尚未提供,因此本轮没有部署公共HTTPS入口。 + +临时验证可用SSH隧道: + +```bash +: "${DEPLOY_SSH_USER:?设置经批准的部署账户}" +ssh -N -L 18088:127.0.0.1:18088 "${DEPLOY_SSH_USER}@123.56.71.98" +``` + +浏览器打开`http://localhost:18088`,使用本机安全上下文获取麦克风;如设置PUBLIC_ORIGIN,需要与实际访问的origin一致。不要要求用户禁用Chrome安全设置。 + +页面流程:填写服务令牌→读取模型→选择有凭据的模型→开始识别→查看中间/最终文字→结束或取消。音频为16k、单声道、PCM16LE、每包100ms;会话最长5分钟、同时最多4个连接。背压过大停止采集,不能无限缓存音频。 + +LLM/TTS固定显示“未启用/等待新规范”。用户未提供新规范前,不调用旧仓库实现,不提供隐式供应商回退。 + +## 5. Asterisk配置与启动 + +### 5.1 配置生成 + +```bash +mkdir -p .local +if [ ! -e .local/asterisk.json ]; then + install -m 600 deploy/asterisk.example.json .local/asterisk.json +fi +``` + +填写实际`local_net`、主/备SIP服务器、端口、IP或Digest鉴权方式、用户名及是否注册。当前生成器只提供经过明确限制的UDP/ulaw外呼基线,TCP/TLS及特殊号码/主叫策略需要另行确认。 + +通过受控进程环境注入: + +- `ARI_PASSWORD`:至少32位,不能使用样例密码。 +- Digest接入另需`SIP_PRIMARY_PASSWORD`、`SIP_BACKUP_PASSWORD`;仅在相应接入启用Digest时要求。 +- 为防INI注入,换行、分号、方括号等字符会被拒绝;供应商固定密码不满足时需评审正确转义方案,不能删除校验绕过。 + +```bash +python3 deploy/render_asterisk.py --config .local/asterisk.json +``` + +生成到`deploy/asterisk/generated/`,文件权限600,目录750;已有目录不会被覆盖。更新时先生成新的版本目录,核对差异、备份旧版本,再由操作人员批准切换。 + +必须核对镜像中Asterisk的实际UID/GID及配置读取权限,只向必要进程授权,不用chmod 777。ARI默认回环8088;对外信令/媒体地址固定为123.56.71.98。RTP端口段10000–10800、strictrtp=yes均需真实线路验证。 + +### 5.2 镜像与安全组 + +`.env`中的ASTERISK_IMAGE必须为批准镜像的`@sha256:`引用。参考仓库使用latest、历史记录22.10.1,不代表该镜像当前版本已经被本项目验证;本轮没有拉取或启动真实Asterisk镜像。 + +确认:SIP服务端IP/协议/端口、RTP回程、EIP/NAT、实际VPC网段、管理来源、录音卷目录和权限。安全组只按来源和用途开放;不公开裸ARI,不清空既有防火墙。 + +```bash +# 默认为检查,不启动容器。 +bash deploy/asterisk.sh +# 参数、权限、源IP和发布窗口确认后,才显式启动。 +bash deploy/asterisk.sh up +docker compose -f compose.asterisk.yaml exec -T asterisk \ + asterisk -rx 'core show version' +docker compose -f compose.asterisk.yaml exec -T asterisk \ + asterisk -rx 'pjsip show contacts' +``` + +只有一个节点采用host network;不要在同一宿主机直接启动第二套争用5060/8088/RTP的配置。主备trunk只是接入配置,自动FALLBACK、长期ARI事件连接、业务状态机和录音上传程序仍需开发。 + +开始真实外呼前,必须通过SIP供应商侧日志核对我方实际出口确为123.56.71.98;仅EIP绑定成功不能证明没有其他NAT/路由改变出口。 + +## 6. 测试与验收边界 + +```bash +python3 -m unittest discover -s tests -v +(cd services/asr-web && go test -race ./... && go vet ./...) +node --test tests/test_pcm.cjs +node --check services/asr-web/web/app.js +bash -n deploy/asterisk.sh +``` + +本轮验证分开记账: + +- 自动测试:云只读/归属/固定IP、创建幂等、失败恢复、EIP绑定竞态、库存完整性;Asterisk配置验证;ASR鉴权、取消、模拟WebSocket结果、帧边界及PCM编码。 +- 本地容器:ASR镜像构建、非root/read-only运行、回环HTTP与healthcheck;测试容器已清理。 +- 浏览器:无效令牌拒绝、正常令牌读取未配置模型、模型禁用、LLM/TTS未启用。没有采集真实麦克风,也没有发送真实供应商请求。 +- 未执行:真实阿里云查询/创建/绑定、SIP通话、真实ASR、LLM/TTS、MQ/OSS、完整外呼和竞价回收恢复。 + +本地模拟通过不等于生产可用。只有填齐资源和契约、执行既有验收文档相应场景并留存证据后,才可把阶段状态更新为真实环境验收通过。 + +## 7. 下一步需要用户提供/确认 + +1. 在本机通过安全方式配置阿里云CLI与Profile/RAM Role/STS,并确认123.56.71.98的实际归属及是否可迁移EIP。 +2. 北京实例规格、镜像、VSwitch、安全组、SSH KeyPair、竞价上限及磁盘/EIP/流量预算;现有主机是否允许复用。 +3. SIP主备真实地址、协议、鉴权、号码/主叫要求和接入限制。 +4. ASR测试凭据、批准的模型/资源ID;Web HTTPS域名/证书或SSH访问方案。 +5. 用户制定的MQ/OSS ID接口规范,以及新的LLM/TTS协议、参数与取消/打断规则。 + +以上未确认前,不创建计费资源、不改绑白名单IP、不自动拨真实号码,也不声称完成整个平台。 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..28db299 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,4 @@ +[tool.pyright] +include = ["deploy", "tests"] +extraPaths = ["."] +pythonVersion = "3.11" diff --git a/services/asr-web/.dockerignore b/services/asr-web/.dockerignore new file mode 100644 index 0000000..82fa71b --- /dev/null +++ b/services/asr-web/.dockerignore @@ -0,0 +1,5 @@ +.env +.env.* +asr-web +*.test +coverage.out diff --git a/services/asr-web/Dockerfile b/services/asr-web/Dockerfile new file mode 100644 index 0000000..8b298f2 --- /dev/null +++ b/services/asr-web/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.26.4-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/asr-web . + +FROM scratch +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=build /out/asr-web /asr-web +USER 65532:65532 +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 CMD ["/asr-web", "-healthcheck"] +ENTRYPOINT ["/asr-web"] diff --git a/services/asr-web/asr.go b/services/asr-web/asr.go new file mode 100644 index 0000000..1f51817 --- /dev/null +++ b/services/asr-web/asr.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" +) + +// ASR provider 接口 + 工厂:把浏览器麦克风 PCM 转成实时文本流。 +// 事件语义(与前端约定): +// +// type=partial text=中间结果 +// type=final text=最终句 +type asrEvent struct { + Typ string // partial | final | error + Text string + Code string + Error string +} + +type asrProvider interface { + // start 建连并启动任务,返回后即可 sendAudio。 + start(ctx context.Context) error + // sendAudio 发送一块 PCM16/16k/mono 音频。 + sendAudio(b []byte) error + // finish 发送结束指令,upstream 关闭时 events 关闭。 + finish() error + // events 返回识别结果通道。 + events() <-chan asrEvent + // close 释放资源。 + close() +} diff --git a/services/asr-web/asr_bailian.go b/services/asr-web/asr_bailian.go new file mode 100644 index 0000000..e9b5e10 --- /dev/null +++ b/services/asr-web/asr_bailian.go @@ -0,0 +1,188 @@ +package main + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" +) + +// bailianASR: 阿里云百炼实时语音识别(Qwen-Audio-3.0-ASR-Flash-Streaming / Fun-ASR)。 +// 文本事件 run-task/continue-task/finish-task,音频为裸二进制帧。 +type bailianASR struct { + model string + api agentCfg + + conn *websocket.Conn + taskID string + out chan asrEvent + wmu sync.Mutex // Audio and control messages share one writer. + done chan struct{} + doneOnce sync.Once + connOnce sync.Once + outOnce sync.Once + stopping atomic.Bool +} + +func newBailianASR(model string, api agentCfg) *bailianASR { + return &bailianASR{model: model, api: api, out: make(chan asrEvent, 64), done: make(chan struct{})} +} + +func (a *bailianASR) start(ctx context.Context) error { + dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second, Proxy: websocket.DefaultDialer.Proxy} + conn, _, err := dialer.DialContext(ctx, a.api.BailianWssBaseURL, + map[string][]string{"Authorization": {"Bearer " + a.api.BailianKey}}) + if err != nil { + return fmt.Errorf("连接百炼 ASR: %w", err) + } + a.conn = conn + conn.SetReadLimit(1 << 20) + _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + context.AfterFunc(ctx, a.close) + started := false + defer func() { + if !started { + a.closeUpstream() + } + }() + a.taskID, err = uuid() + if err != nil { + return err + } + err = a.writeJSON(map[string]any{ + "header": map[string]string{"action": "run-task", "task_id": a.taskID, "streaming": "duplex"}, + "payload": map[string]any{ + "task_group": "audio", "task": "asr", "function": "recognition", + "model": a.model, + "parameters": map[string]any{"format": "pcm", "sample_rate": 16000}, + "input": map[string]any{}, + }, + }) + if err != nil { + return fmt.Errorf("发送 run-task: %w", err) + } + var ev struct { + Header struct { + Event string `json:"event"` + ErrorCode string `json:"error_code"` + ErrorMessage string `json:"error_message"` + } `json:"header"` + } + if err := conn.ReadJSON(&ev); err != nil { + return fmt.Errorf("读取 task-started: %w", err) + } + if ev.Header.Event != "task-started" { + return fmt.Errorf("启动任务失败: %s %s", ev.Header.ErrorCode, ev.Header.ErrorMessage) + } + _ = conn.SetReadDeadline(time.Time{}) + started = true + go a.readLoop() + return nil +} + +func (a *bailianASR) readLoop() { + defer a.closeUpstream() + for { + var ev struct { + Header struct { + Event string `json:"event"` + ErrorCode string `json:"error_code"` + ErrorMessage string `json:"error_message"` + } `json:"header"` + Payload struct { + Output struct { + Sentence struct { + Text string `json:"text"` + SentenceEnd bool `json:"sentence_end"` + } `json:"sentence"` + } `json:"output"` + } `json:"payload"` + } + if err := a.conn.ReadJSON(&ev); err != nil { + if !a.stopping.Load() { + a.emit(asrEvent{Typ: "error", Error: fmt.Sprintf("连接关闭: %v", err)}) + } + return + } + switch ev.Header.Event { + case "result-generated": + s := ev.Payload.Output.Sentence + if s.Text == "" { + continue + } + typ := "partial" + if s.SentenceEnd { + typ = "final" + } + a.emit(asrEvent{Typ: typ, Text: s.Text}) + case "task-failed": + a.emit(asrEvent{Typ: "error", Code: ev.Header.ErrorCode, Error: ev.Header.ErrorMessage}) + return + case "task-finished": + return + } + } +} + +func (a *bailianASR) emit(e asrEvent) { + if e.Typ == "partial" { + select { + case a.out <- e: + default: + } // Intermediate hypotheses can be replaced. + return + } + select { + case a.out <- e: + case <-a.done: + } +} + +func (a *bailianASR) writeJSON(v any) error { + a.wmu.Lock() + defer a.wmu.Unlock() + _ = a.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + return a.conn.WriteJSON(v) +} + +func (a *bailianASR) sendAudio(b []byte) error { + a.wmu.Lock() + defer a.wmu.Unlock() + _ = a.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + return a.conn.WriteMessage(websocket.BinaryMessage, b) +} + +func (a *bailianASR) finish() error { + return a.writeJSON(map[string]any{ + "header": map[string]string{"action": "finish-task", "task_id": a.taskID, "streaming": "duplex"}, + "payload": map[string]any{"input": map[string]any{}}, + }) +} + +func (a *bailianASR) events() <-chan asrEvent { return a.out } +func (a *bailianASR) close() { + a.stopping.Store(true) + a.closeConn() +} + +func (a *bailianASR) closeConn() { + a.doneOnce.Do(func() { + if a.done != nil { + close(a.done) + } + }) + a.connOnce.Do(func() { + if a.conn != nil { + a.conn.Close() + } + }) +} + +// 只有 readLoop 退出后才能关闭事件通道,避免 close() 与 emit() 并发导致 panic。 +func (a *bailianASR) closeUpstream() { + a.closeConn() + a.outOnce.Do(func() { close(a.out) }) +} diff --git a/services/asr-web/asr_volc.go b/services/asr-web/asr_volc.go new file mode 100644 index 0000000..db20583 --- /dev/null +++ b/services/asr-web/asr_volc.go @@ -0,0 +1,245 @@ +package main + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "fmt" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" +) + +// volcASR: 火山引擎大模型流式语音识别(Doubao-Seed-ASR-Streaming / sauc bigmodel)。 +// 二进制帧: [4B header][可选4B sequence][4B payload size][payload] +// header: ver<<4|hdrUnits, msgType<<4|flags, ser<<4|comp, 0x00 +const ( + volcURL = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel" + + msgFullClient = 0x1 + msgAudioOnly = 0x2 + msgFullServer = 0x9 + msgError = 0xF + + flagNoSeq = 0x0 // full client request 用 + flagLast = 0x2 // 客户端最后一包音频(负包,无序号) + flagNegWithSeq = 0x3 // 服务端最终帧(负包+序号) + serJSON = 0x1 + compNone = 0x0 + compGzip = 0x1 +) + +type volcASR struct { + cfg agentCfg + conn *websocket.Conn + out chan asrEvent + wmu sync.Mutex + connOnce sync.Once + outOnce sync.Once + done chan struct{} + doneOnce sync.Once + stopping atomic.Bool + emitted map[int]bool // 已作为 final 发出的 utterance 下标 +} + +func newVolcASR(cfg agentCfg) *volcASR { + return &volcASR{cfg: cfg, out: make(chan asrEvent, 64), done: make(chan struct{}), emitted: map[int]bool{}} +} + +func (v *volcASR) start(ctx context.Context) error { + hdr := http.Header{} + if v.cfg.VolcAppKey != "" { // 新版控制台:单一 APP Key + hdr.Set("X-Api-Key", v.cfg.VolcAppKey) + } else if v.cfg.VolcAppID != "" && v.cfg.VolcAccessToken != "" { // 旧版控制台:APP ID + Access Token + hdr.Set("X-Api-App-Key", v.cfg.VolcAppID) + hdr.Set("X-Api-Access-Key", v.cfg.VolcAccessToken) + } else { + return fmt.Errorf("未配置火山凭证:新版控制台设 VOLC_APP_KEY,旧版设 VOLC_APP_ID + VOLC_ACCESS_TOKEN(均需在语音技术控制台获取,非 VOLCENGINE_ACCESS_KEY)") + } + requestID, err := uuid() + if err != nil { + return err + } + connectID, err := uuid() + if err != nil { + return err + } + hdr.Set("X-Api-Resource-Id", v.cfg.VolcResourceID) + hdr.Set("X-Api-Request-Id", requestID) + hdr.Set("X-Api-Connect-Id", connectID) + hdr.Set("X-Api-Sequence", "-1") + + endpoint := v.cfg.VolcWS + if endpoint == "" { + endpoint = volcURL + } + dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second, Proxy: websocket.DefaultDialer.Proxy} + conn, resp, err := dialer.DialContext(ctx, endpoint, hdr) + if err != nil { + logid := "" + if resp != nil { + logid = resp.Header.Get("X-Tt-Logid") + } + return fmt.Errorf("连接火山 ASR (401/403 多为凭证或资源未开通, logid=%s): %w", logid, err) + } + v.conn = conn + conn.SetReadLimit(1 << 20) + _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + context.AfterFunc(ctx, v.close) + started := false + defer func() { + if !started { + v.closeUpstream() + } + }() + + req := map[string]any{ + "user": map[string]any{"uid": "voice-test-agent"}, + "audio": map[string]any{"format": "pcm", "rate": 16000, "bits": 16, "channel": 1}, + "request": map[string]any{ + "model_name": "bigmodel", + "enable_itn": true, + "enable_punc": true, + "result_type": "full", + "show_utterances": true, + }, + } + body, _ := json.Marshal(req) + if err := v.conn.WriteMessage(websocket.BinaryMessage, volcFrame(msgFullClient, flagNoSeq, serJSON, compNone, body)); err != nil { + return fmt.Errorf("发送 full client request: %w", err) + } + started = true + go v.readLoop() + return nil +} + +// volcFrame 构造一帧二进制协议。 +func volcFrame(msgType, flags, ser, comp byte, payload []byte) []byte { + buf := bytes.NewBuffer(make([]byte, 0, 12+len(payload))) + buf.WriteByte(0x11) // ver=1, header=1*4B + buf.WriteByte(msgType<<4 | flags) + buf.WriteByte(ser<<4 | comp) + buf.WriteByte(0x00) + binary.Write(buf, binary.BigEndian, uint32(len(payload))) + buf.Write(payload) + return buf.Bytes() +} + +func (v *volcASR) readLoop() { + defer v.closeUpstream() + for { + mt, data, err := v.conn.ReadMessage() + if err != nil { + if !v.stopping.Load() { + v.emit(asrEvent{Typ: "error", Error: fmt.Sprintf("连接关闭: %v", err)}) + } + return + } + if mt != websocket.BinaryMessage { + continue + } + msgType, flags, code, payload, err := decodeVolc(data) + if err != nil { + v.emit(asrEvent{Typ: "error", Error: "invalid upstream ASR frame"}) + return + } + + switch msgType { + case msgFullServer: + var resp struct { + Code int `json:"code"` + Message string `json:"message"` + Result struct { + Text string `json:"text"` + Utterances []struct { + Text string `json:"text"` + Definite bool `json:"definite"` + } `json:"utterances"` + } `json:"result"` + } + if json.Unmarshal(payload, &resp) != nil { + continue + } + if resp.Code != 0 && resp.Code != 20000000 { + v.emit(asrEvent{Typ: "error", Code: fmt.Sprint(resp.Code), Error: resp.Message}) + return + } + // 句级 final:utterance 固化即发出(连续对话不依赖结束帧) + for i, u := range resp.Result.Utterances { + if u.Definite && !v.emitted[i] { + v.emitted[i] = true + v.emit(asrEvent{Typ: "final", Text: u.Text}) + } + } + // partial:最后一个未固化 utterance + if n := len(resp.Result.Utterances); n > 0 && !resp.Result.Utterances[n-1].Definite { + v.emit(asrEvent{Typ: "partial", Text: resp.Result.Utterances[n-1].Text}) + } + // 服务端最终帧(负包):流结束 + if flags == flagLast || flags == flagNegWithSeq { + return + } + case msgError: + v.emit(asrEvent{Typ: "error", Code: fmt.Sprint(code), Error: string(payload)}) + return + } + } +} + +func (v *volcASR) emit(e asrEvent) { + if e.Typ == "partial" { + select { + case v.out <- e: + default: + } + return + } + select { + case v.out <- e: + case <-v.done: + } +} + +func (v *volcASR) sendAudio(b []byte) error { + v.wmu.Lock() + defer v.wmu.Unlock() + _ = v.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + return v.conn.WriteMessage(websocket.BinaryMessage, volcFrame(msgAudioOnly, 0, 0, compNone, b)) +} + +func (v *volcASR) finish() error { + // 负包:flags=2,空 payload + v.wmu.Lock() + defer v.wmu.Unlock() + _ = v.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + return v.conn.WriteMessage(websocket.BinaryMessage, volcFrame(msgAudioOnly, flagLast, 0, compNone, nil)) +} + +func (v *volcASR) events() <-chan asrEvent { return v.out } +func (v *volcASR) close() { + v.stopping.Store(true) + v.closeConn() +} + +func (v *volcASR) closeConn() { + v.doneOnce.Do(func() { + if v.done != nil { + close(v.done) + } + }) + v.connOnce.Do(func() { + if v.conn != nil { + v.conn.Close() + } + }) +} + +// 只有 readLoop 退出后才能关闭事件通道,避免 close() 与 emit() 并发导致 panic。 +func (v *volcASR) closeUpstream() { + v.closeConn() + v.outOnce.Do(func() { close(v.out) }) +} diff --git a/services/asr-web/config.go b/services/asr-web/config.go new file mode 100644 index 0000000..f6b13f8 --- /dev/null +++ b/services/asr-web/config.go @@ -0,0 +1,77 @@ +package main + +import ( + cryptorand "crypto/rand" + "errors" + "fmt" + "net/url" + "os" + "strings" +) + +// ASR-only subset of the reference configuration. No LLM/TTS fields are imported. +type agentCfg struct { + BailianKey, BailianWssBaseURL string + VolcAppKey, VolcAppID, VolcAccessToken, VolcResourceID, VolcWS string +} + +type config struct { + bailianKey, bailianBase, funASRKey, funASRBase string + volcAppKey, volcAppID, volcToken, volcResourceID, volcWS string +} + +func env(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func loadConfig() config { + key := os.Getenv("BAILIAN_API_KEY") + return config{ + bailianKey: key, + bailianBase: env("BAILIAN_WS", "wss://dashscope.aliyuncs.com/api-ws/v1/inference/"), + funASRKey: env("FUNASR_API_KEY", key), + funASRBase: env("FUNASR_WS", "wss://dashscope.aliyuncs.com/api-ws/v1/inference/"), + volcAppKey: os.Getenv("VOLC_APP_KEY"), volcAppID: os.Getenv("VOLC_APP_ID"), + volcToken: os.Getenv("VOLC_ACCESS_TOKEN"), + volcResourceID: env("VOLC_RESOURCE_ID", "volc.bigasr.sauc.duration"), + volcWS: env("VOLC_WS", "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel"), + } +} + +func validateConfig(token, origin string, cfg config) error { + if len(token) < 32 || strings.HasPrefix(token, "CHANGE_ME") { + return errors.New("ASR_WEB_TOKEN must be at least 32 characters and not a placeholder") + } + for _, r := range token { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') { + return errors.New("ASR_WEB_TOKEN must use URL-safe alphanumeric characters") + } + } + if origin != "" { + u, err := url.Parse(origin) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" { + return errors.New("PUBLIC_ORIGIN must be an exact http(s) origin without a path") + } + } + for _, endpoint := range []string{cfg.bailianBase, cfg.funASRBase, cfg.volcWS} { + u, err := url.Parse(endpoint) + if err != nil || u.Scheme != "wss" || u.Host == "" || u.User != nil { + return errors.New("provider endpoints must use wss without embedded credentials") + } + } + return nil +} + +func uuid() (string, error) { + b := make([]byte, 16) + count, err := cryptorand.Read(b) + if err != nil || count != len(b) { + return "", errors.New("secure random unavailable") + } + b[6] = b[6]&15 | 64 + b[8] = b[8]&63 | 128 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[:4], b[4:6], b[6:8], b[8:10], b[10:]), nil +} diff --git a/services/asr-web/go.mod b/services/asr-web/go.mod new file mode 100644 index 0000000..b7501b6 --- /dev/null +++ b/services/asr-web/go.mod @@ -0,0 +1,5 @@ +module ai-call/asr-web + +go 1.26.2 + +require github.com/gorilla/websocket v1.5.3 diff --git a/services/asr-web/go.sum b/services/asr-web/go.sum new file mode 100644 index 0000000..25a9fc4 --- /dev/null +++ b/services/asr-web/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/services/asr-web/main.go b/services/asr-web/main.go new file mode 100644 index 0000000..6cece85 --- /dev/null +++ b/services/asr-web/main.go @@ -0,0 +1,338 @@ +package main + +import ( + "context" + "crypto/subtle" + "embed" + "encoding/json" + "errors" + "flag" + "io/fs" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/gorilla/websocket" +) + +//go:embed web/* +var assets embed.FS + +type server struct { + cfg config + token, origin string + slots chan struct{} + newProvider func(string) (asrProvider, error) + sessionLimit time.Duration +} + +type modelOption struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` +} + +func (s *server) bailianCredentials(model string) (string, string) { + if strings.HasPrefix(model, "fun-") { + return s.cfg.funASRKey, s.cfg.funASRBase + } + return s.cfg.bailianKey, s.cfg.bailianBase +} + +func (s *server) models() []modelOption { + out := []modelOption{} + for _, id := range strings.Split(env("BAILIAN_ASR_MODELS", "fun-asr-realtime"), ",") { + id = strings.TrimSpace(id) + if id != "" && id != "volc-bigmodel" { + key, _ := s.bailianCredentials(id) + out = append(out, modelOption{id, key != ""}) + } + } + return append(out, modelOption{"volc-bigmodel", s.cfg.volcAppKey != "" || s.cfg.volcAppID != "" && s.cfg.volcToken != ""}) +} + +func (s *server) provider(id string) (asrProvider, error) { + for _, model := range s.models() { + if model.ID != id { + continue + } + if !model.Enabled { + return nil, errors.New("provider credentials are not configured") + } + if id == "volc-bigmodel" { + return newVolcASR(agentCfg{VolcAppKey: s.cfg.volcAppKey, VolcAppID: s.cfg.volcAppID, VolcAccessToken: s.cfg.volcToken, VolcResourceID: s.cfg.volcResourceID, VolcWS: s.cfg.volcWS}), nil + } + key, endpoint := s.bailianCredentials(id) + return newBailianASR(id, agentCfg{BailianKey: key, BailianWssBaseURL: endpoint}), nil + } + return nil, errors.New("unknown ASR model") +} + +func (s *server) authorized(r *http.Request) bool { + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + for _, protocol := range websocket.Subprotocols(r) { + if strings.HasPrefix(protocol, "auth.") { + token = strings.TrimPrefix(protocol, "auth.") + } + } + return len(token) == len(s.token) && subtle.ConstantTimeCompare([]byte(token), []byte(s.token)) == 1 +} + +func (s *server) sameOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } // CLI clients still need the access token. + if s.origin != "" { + return origin == s.origin + } + u, err := url.Parse(origin) + return err == nil && u.User == nil && u.Path == "" && u.RawQuery == "" && u.Fragment == "" && (u.Scheme == "http" || u.Scheme == "https") && u.Host == r.Host +} + +func (s *server) handler(ctx context.Context) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok","scope":"asr-only","llm":false,"tts":false}`)) + }) + mux.HandleFunc("GET /api/models", func(w http.ResponseWriter, r *http.Request) { + if !s.authorized(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(s.models()) + }) + mux.HandleFunc("GET /ws", func(w http.ResponseWriter, r *http.Request) { s.serveWS(ctx, w, r) }) + web, _ := fs.Sub(assets, "web") + mux.Handle("GET /", http.FileServerFS(web)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") + mux.ServeHTTP(w, r) + }) +} + +func (s *server) serveWS(parent context.Context, w http.ResponseWriter, r *http.Request) { + if !s.authorized(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if !s.sameOrigin(r) { + http.Error(w, "origin rejected", http.StatusForbidden) + return + } + select { + case s.slots <- struct{}{}: + defer func() { <-s.slots }() + default: + http.Error(w, "session limit reached", http.StatusTooManyRequests) + return + } + up := websocket.Upgrader{Subprotocols: []string{"asr.v1"}, CheckOrigin: s.sameOrigin, HandshakeTimeout: 5 * time.Second} + conn, err := up.Upgrade(w, r, nil) + if err != nil { + return + } + limit := s.sessionLimit + if limit == 0 { + limit = 5 * time.Minute + } + ctx, cancel := context.WithTimeout(parent, limit) + defer cancel() + defer conn.Close() + conn.SetReadLimit(64 << 10) + stopClose := context.AfterFunc(ctx, func() { _ = conn.Close() }) + defer stopClose() + var writeMu sync.Mutex + var stateMu sync.Mutex + var active *asrRun + send := func(expected *asrRun, value any) { + writeMu.Lock() + defer writeMu.Unlock() + stateMu.Lock() + valid := expected == nil || active == expected + stateMu.Unlock() + if !valid || ctx.Err() != nil { + return + } + _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + if err := conn.WriteJSON(value); err != nil { + cancel() + } + } + stop := func() { + stateMu.Lock() + old := active + active = nil + stateMu.Unlock() + if old != nil { + old.cancel() + old.provider.close() + } + } + defer stop() + send(nil, map[string]any{"type": "ready", "sample_rate": 16000, "channels": 1, "format": "pcm_s16le", "max_session_seconds": int(limit.Seconds()), "llm": false, "tts": false}) + for { + kind, data, err := conn.ReadMessage() + if err != nil { + return + } + if kind == websocket.BinaryMessage { + stateMu.Lock() + run := active + stateMu.Unlock() + if run == nil { + send(nil, map[string]string{"type": "error", "error": "start ASR before sending audio"}) + continue + } + if len(data)%2 != 0 { + send(run, map[string]string{"type": "error", "error": "PCM16 must have an even byte length"}) + continue + } + if err := run.provider.sendAudio(data); err != nil { + send(run, map[string]string{"type": "error", "error": "upstream audio write failed"}) + stop() + } + continue + } + var msg struct { + Type string `json:"type"` + Model string `json:"model"` + } + if kind != websocket.TextMessage || json.Unmarshal(data, &msg) != nil { + send(nil, map[string]string{"type": "error", "error": "invalid control message"}) + continue + } + switch msg.Type { + case "start": + stop() + factory := s.newProvider + if factory == nil { + factory = s.provider + } + p, err := factory(msg.Model) + if err != nil { + send(nil, map[string]string{"type": "error", "error": err.Error()}) + continue + } + runCtx, runCancel := context.WithCancel(ctx) + run := &asrRun{provider: p, cancel: runCancel, ctx: runCtx} + if err := p.start(runCtx); err != nil { + runCancel() + p.close() + send(nil, map[string]string{"type": "error", "error": "ASR start failed; check provider credentials, model and endpoint"}) + continue + } + stateMu.Lock() + active = run + stateMu.Unlock() + send(run, map[string]string{"type": "asr-started", "model": msg.Model}) + go func() { + defer runCancel() + defer p.close() + for { + select { + case <-runCtx.Done(): + return + case ev, ok := <-p.events(): + if !ok { + send(run, map[string]string{"type": "asr-stopped"}) + stateMu.Lock() + if active == run { + active = nil + } + stateMu.Unlock() + return + } + send(run, map[string]string{"type": ev.Typ, "text": ev.Text, "code": ev.Code, "error": ev.Error}) + } + } + }() + case "finish": + stateMu.Lock() + run := active + stateMu.Unlock() + if run != nil { + run.finishOnce.Do(func() { + if err := run.provider.finish(); err != nil { + send(run, map[string]string{"type": "error", "error": "upstream finish failed"}) + stop() + return + } + go func() { + select { + case <-run.ctx.Done(): + case <-time.After(10 * time.Second): + stateMu.Lock() + same := active == run + stateMu.Unlock() + if same { + run.cancel() + run.provider.close() + send(run, map[string]string{"type": "asr-stopped"}) + } + } + }() + }) + } + case "cancel": + stop() + send(nil, map[string]string{"type": "asr-stopped"}) + default: + send(nil, map[string]string{"type": "error", "error": "unsupported command; LLM/TTS are disabled"}) + } + } +} + +type asrRun struct { + provider asrProvider + cancel context.CancelFunc + ctx context.Context + finishOnce sync.Once +} + +func main() { + health := flag.Bool("healthcheck", false, "check local process liveness") + flag.Parse() + if *health { + client := http.Client{Timeout: 2 * time.Second} + resp, err := client.Get("http://127.0.0.1:8080/healthz") + if err != nil { + os.Exit(1) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + os.Exit(1) + } + return + } + cfg := loadConfig() + token := os.Getenv("ASR_WEB_TOKEN") + origin := os.Getenv("PUBLIC_ORIGIN") + if err := validateConfig(token, origin, cfg); err != nil { + log.Fatal(err) + } + s := &server{cfg: cfg, token: token, origin: origin, slots: make(chan struct{}, 4)} + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + srv := &http.Server{Addr: env("ASR_LISTEN", "127.0.0.1:8080"), Handler: s.handler(ctx), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10} + go func() { + <-ctx.Done() + deadline, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(deadline) + }() + log.Printf("ASR-only service listening on %s; LLM/TTS disabled", srv.Addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } +} diff --git a/services/asr-web/main_test.go b/services/asr-web/main_test.go new file mode 100644 index 0000000..7530c1d --- /dev/null +++ b/services/asr-web/main_test.go @@ -0,0 +1,219 @@ +package main + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/binary" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +const testToken = "0123456789abcdef0123456789abcdef" + +func TestModelCredentialRouting(t *testing.T) { + t.Setenv("BAILIAN_ASR_MODELS", "fun-asr-realtime,qwen-audio-asr-realtime,volc-bigmodel") + s := &server{cfg: config{funASRKey: "test-fun-key", funASRBase: "wss://fun.test", bailianBase: "wss://bailian.test"}} + models := s.models() + if len(models) != 3 || !models[0].Enabled || models[1].Enabled || models[2].Enabled { + t.Fatalf("credential availability: %+v", models) + } + if _, err := s.provider("qwen-audio-asr-realtime"); err == nil { + t.Fatal("missing Bailian credentials accepted") + } + key, endpoint := s.bailianCredentials("fun-asr-realtime") + if key != "test-fun-key" || endpoint != "wss://fun.test" { + t.Fatal("Fun-ASR routing ignored explicit configuration") + } +} + +func TestHTTPAuthenticationAndCapabilities(t *testing.T) { + s := &server{token: testToken, slots: make(chan struct{}, 1)} + h := s.handler(context.Background()) + for _, path := range []string{"/", "/healthz", "/api/models"} { + r := httptest.NewRequest("GET", path, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + want := 200 + if path == "/api/models" { + want = 401 + } + if w.Code != want { + t.Fatalf("%s: %d", path, w.Code) + } + } + r := httptest.NewRequest("GET", "/api/models", nil) + r.Header.Set("Authorization", "Bearer "+testToken) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 200 { + t.Fatal(w.Code) + } + r.Header.Set("Origin", "https://evil.invalid") + if s.sameOrigin(r) { + t.Fatal("cross origin accepted") + } + if err := validateConfig("short", "", loadConfig()); err == nil { + t.Fatal("short token accepted") + } + if err := validateConfig(testToken, "https://host.invalid/path", loadConfig()); err == nil { + t.Fatal("invalid origin accepted") + } +} + +func TestWebASRPipelineAndDisconnect(t *testing.T) { + upstreamClosed := make(chan struct{}) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + up := websocket.Upgrader{} + c, err := up.Upgrade(w, r, nil) + if err != nil { + return + } + defer c.Close() + defer close(upstreamClosed) + var req map[string]any + if c.ReadJSON(&req) != nil { + return + } + if c.WriteJSON(map[string]any{"header": map[string]string{"event": "task-started"}}) != nil { + return + } + for { + kind, _, err := c.ReadMessage() + if err != nil { + return + } + if kind == websocket.BinaryMessage { + _ = c.WriteJSON(map[string]any{"header": map[string]string{"event": "result-generated"}, "payload": map[string]any{"output": map[string]any{"sentence": map[string]any{"text": "离线模拟识别", "sentence_end": true}}}}) + } + } + })) + defer upstream.Close() + s := &server{token: testToken, slots: make(chan struct{}, 1), newProvider: func(id string) (asrProvider, error) { + return newBailianASR(id, agentCfg{BailianKey: "fake", BailianWssBaseURL: "ws" + strings.TrimPrefix(upstream.URL, "http")}), nil + }} + httpServer := httptest.NewServer(s.handler(context.Background())) + defer httpServer.Close() + dialer := websocket.Dialer{Subprotocols: []string{"asr.v1", "auth." + testToken}} + url := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws" + _, response, err := dialer.Dial(url, http.Header{"Origin": []string{"https://evil.invalid"}}) + if err == nil || response.StatusCode != 403 { + t.Fatal("cross-origin websocket not rejected") + } + c, _, err := dialer.Dial(url, nil) + if err != nil { + t.Fatal(err) + } + defer c.Close() + _ = c.SetReadDeadline(time.Now().Add(3 * time.Second)) + var event map[string]any + if err = c.ReadJSON(&event); err != nil || event["type"] != "ready" { + t.Fatalf("ready: %v %v", event, err) + } + if err = c.WriteJSON(map[string]string{"type": "start", "model": "test"}); err != nil { + t.Fatal(err) + } + if err = c.ReadJSON(&event); err != nil || event["type"] != "asr-started" { + t.Fatalf("start: %v %v", event, err) + } + if err = c.WriteMessage(websocket.BinaryMessage, make([]byte, 3200)); err != nil { + t.Fatal(err) + } + if err = c.ReadJSON(&event); err != nil || event["type"] != "final" || event["text"] != "离线模拟识别" { + t.Fatalf("final: %v %v", event, err) + } + _ = c.Close() + select { + case <-upstreamClosed: + case <-time.After(2 * time.Second): + t.Fatal("disconnect leaked upstream socket") + } +} + +func TestStartCancellation(t *testing.T) { + ready := make(chan struct{}) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + up := websocket.Upgrader{} + c, err := up.Upgrade(w, r, nil) + if err != nil { + return + } + defer c.Close() + _, _, _ = c.ReadMessage() + close(ready) + _, _, _ = c.ReadMessage() + })) + defer upstream.Close() + a := newBailianASR("test", agentCfg{BailianWssBaseURL: "ws" + strings.TrimPrefix(upstream.URL, "http")}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + result := make(chan error, 1) + go func() { result <- a.start(ctx) }() + <-ready + cancel() + select { + case err := <-result: + if err == nil { + t.Fatal("canceled start succeeded") + } + case <-time.After(2 * time.Second): + t.Fatal("start ignored cancellation") + } +} + +func TestFinalEventBackpressureAndClose(t *testing.T) { + a := newBailianASR("test", agentCfg{}) + for i := 0; i < cap(a.out); i++ { + a.out <- asrEvent{Typ: "partial"} + } + done := make(chan struct{}) + go func() { a.emit(asrEvent{Typ: "final", Text: "must keep"}); close(done) }() + <-a.out + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("final delivery blocked") + } + found := false + for len(a.out) > 0 { + if (<-a.out).Typ == "final" { + found = true + } + } + if !found { + t.Fatal("final event silently dropped") + } + a.close() + a.close() + a.closeUpstream() +} + +func TestVolcFrameBounds(t *testing.T) { + frame := volcFrame(msgFullServer, flagLast, serJSON, compNone, []byte(`{"result":{}}`)) + typ, flags, _, payload, err := decodeVolc(frame) + if err != nil || typ != msgFullServer || flags != flagLast || string(payload) != `{"result":{}}` { + t.Fatal("last frame without sequence rejected", err) + } + for n := 0; n < len(frame); n++ { + if _, _, _, _, err := decodeVolc(frame[:n]); err == nil { + t.Fatalf("truncated length %d accepted", n) + } + } + bad := append([]byte(nil), frame...) + binary.BigEndian.PutUint32(bad[4:8], 0xffffffff) + if _, _, _, _, err := decodeVolc(bad); err == nil { + t.Fatal("oversized length accepted") + } + var b bytes.Buffer + w := gzip.NewWriter(&b) + _, _ = w.Write(make([]byte, (1<<20)+1)) + _ = w.Close() + if _, _, _, _, err := decodeVolc(volcFrame(msgFullServer, 0, serJSON, compGzip, b.Bytes())); err == nil { + t.Fatal("gzip expansion not bounded") + } +} diff --git a/services/asr-web/volc_frame.go b/services/asr-web/volc_frame.go new file mode 100644 index 0000000..125e5ae --- /dev/null +++ b/services/asr-web/volc_frame.go @@ -0,0 +1,60 @@ +package main + +import ( + "bytes" + "compress/gzip" + "encoding/binary" + "errors" + "io" +) + +// Reject truncated, oversized or compressed-bomb frames before slicing payloads. +func decodeVolc(data []byte) (typ, flags byte, code uint32, payload []byte, err error) { + invalid := errors.New("invalid ASR frame") + if len(data) < 8 || data[0]>>4 != 1 { + return 0, 0, 0, nil, invalid + } + typ, flags = data[1]>>4, data[1]&15 + off := int(data[0]&15) * 4 + if off < 4 || off > len(data) || (typ != msgFullServer && typ != msgError) { + return 0, 0, 0, nil, invalid + } + if flags&1 != 0 { + off += 4 + } // Bit 1 means last packet; it does not imply a sequence field. + if off > len(data) { + return 0, 0, 0, nil, invalid + } + if typ == msgError { + if off+4 > len(data) { + return 0, 0, 0, nil, invalid + } + code = binary.BigEndian.Uint32(data[off : off+4]) + off += 4 + } + if off+4 > len(data) { + return 0, 0, 0, nil, invalid + } + size := uint64(binary.BigEndian.Uint32(data[off : off+4])) + off += 4 + if size > 1<<20 || size != uint64(len(data)-off) { + return 0, 0, 0, nil, invalid + } + payload = data[off:] + switch data[2] & 15 { + case compNone: + case compGzip: + reader, e := gzip.NewReader(bytes.NewReader(payload)) + if e != nil { + return 0, 0, 0, nil, invalid + } + payload, e = io.ReadAll(io.LimitReader(reader, (1<<20)+1)) + closeErr := reader.Close() + if e != nil || closeErr != nil || len(payload) > 1<<20 { + return 0, 0, 0, nil, invalid + } + default: + return 0, 0, 0, nil, invalid + } + return typ, flags, code, payload, nil +} diff --git a/services/asr-web/web/app.js b/services/asr-web/web/app.js new file mode 100644 index 0000000..9c3f8cc --- /dev/null +++ b/services/asr-web/web/app.js @@ -0,0 +1,176 @@ +const $ = (id) => document.getElementById(id); +let current = null; +function status(text) { + $("status").textContent = text; +} +function controls(busy) { + $("start").disabled = busy || !$("model").value; + $("load").disabled = busy; + $("model").disabled = busy; + $("finish").disabled = !busy; + $("cancel").disabled = !busy; +} +async function stopMic(s) { + s.stream?.getTracks().forEach((track) => track.stop()); + s.stream = null; + s.node?.disconnect(); + s.node = null; + if (s.context && s.context.state !== "closed") + await s.context.close().catch(() => {}); +} +async function cleanup(s, message) { + if (s.closed) return; + s.closed = true; + await stopMic(s); + if (s.ws && s.ws.readyState < WebSocket.CLOSING) s.ws.close(); + if (current === s) { + current = null; + controls(false); + if (message) status(message); + } +} +$("load").addEventListener("click", async () => { + try { + const token = $("token").value.trim(); + const response = await fetch("/api/models", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) + throw new Error( + response.status === 401 ? "访问令牌无效" : "读取模型失败", + ); + const models = await response.json(); + $("model").replaceChildren(); + let available = 0; + for (const model of models) { + const option = document.createElement("option"); + option.value = model.id; + option.textContent = `${model.id}${model.enabled ? "" : "(服务端未配置凭据)"}`; + option.disabled = !model.enabled; + $("model").append(option); + if (model.enabled) available++; + } + const enabled = [...$("model").options].find((o) => !o.disabled); + $("model").value = enabled?.value || ""; + controls(false); + status( + available ? "配置已读取,可开始测试" : "没有可用凭据;LLM/TTS 未启用", + ); + } catch (error) { + status(error.message); + } +}); +$("model").addEventListener("change", () => controls(false)); +$("start").addEventListener("click", async () => { + if (current) return; + if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) { + status( + "麦克风需要 HTTPS 或 localhost。可使用 SSH 隧道,不要关闭浏览器安全设置。", + ); + return; + } + const token = $("token").value.trim(); + if (!/^[A-Za-z0-9_-]{32,}$/.test(token)) { + status("请输入有效的服务访问令牌"); + return; + } + const s = { closed: false }; + current = s; + controls(true); + status("正在请求麦克风…"); + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + }, + video: false, + }); + if (s.closed) { + stream.getTracks().forEach((track) => track.stop()); + return; + } + s.stream = stream; + s.context = new AudioContext({ sampleRate: 16000 }); + if (s.context.sampleRate !== 16000) + throw new Error("浏览器无法提供 16 kHz 音频,已停止,避免发送错误采样率"); + await s.context.audioWorklet.addModule("/pcm-worklet.js"); + if (s.closed) return; + const ws = new WebSocket( + `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/ws`, + ["asr.v1", `auth.${token}`], + ); + s.ws = ws; + ws.binaryType = "arraybuffer"; + ws.onclose = () => { + void cleanup(s, "连接已结束"); + }; + ws.onerror = () => { + void cleanup(s, "WebSocket 连接失败,请检查访问令牌、Origin 和服务状态"); + }; + ws.onopen = () => { + if (!s.closed) + ws.send(JSON.stringify({ type: "start", model: $("model").value })); + }; + ws.onmessage = async (event) => { + if (s.closed || typeof event.data !== "string") return; + try { + const data = JSON.parse(event.data); + if (data.type === "asr-started") { + s.node = new AudioWorkletNode(s.context, "pcm16"); + s.node.port.onmessage = ({ data: pcm }) => { + if (s.closed || ws.readyState !== WebSocket.OPEN) return; + if (ws.bufferedAmount > 256 * 1024) { + void cleanup(s, "网络积压过多,已停止采集"); + return; + } + ws.send(pcm); + }; + s.context.createMediaStreamSource(stream).connect(s.node); + s.node.connect(s.context.destination); + await s.context.resume(); + $("audio").textContent = + `音频:${s.context.sampleRate} Hz / 单声道 / PCM16 / 每包 100ms`; + status("正在识别(最长5分钟)"); + } else if (data.type === "partial") { + $("partial").textContent = data.text; + } else if (data.type === "final") { + const line = document.createElement("p"); + line.textContent = data.text; + $("finals").append(line); + if ($("finals").children.length > 100) + $("finals").firstElementChild.remove(); + $("partial").textContent = "等待下一句…"; + } else if (data.type === "error") { + await cleanup(s, data.error || "上游识别错误"); + } else if (data.type === "asr-stopped") { + await cleanup(s, "本轮识别结束"); + } + } catch { + await cleanup(s, "音频初始化或服务消息处理失败"); + } + }; + } catch (error) { + await cleanup(s, error.message); + } +}); +$("finish").addEventListener("click", async () => { + const s = current; + if (!s) return; + await stopMic(s); + $("finish").disabled = true; + if (s.ws?.readyState === WebSocket.OPEN) { + s.ws.send(JSON.stringify({ type: "finish" })); + status("已停止麦克风,等待最终结果…"); + } else await cleanup(s, "已取消尚未就绪的连接"); +}); +$("cancel").addEventListener("click", () => { + if (current) void cleanup(current, "已取消"); +}); +window.addEventListener("pagehide", () => { + if (current) { + current.stream?.getTracks().forEach((track) => track.stop()); + current.ws?.close(); + } +}); diff --git a/services/asr-web/web/index.html b/services/asr-web/web/index.html new file mode 100644 index 0000000..afa6733 --- /dev/null +++ b/services/asr-web/web/index.html @@ -0,0 +1,16 @@ + + +AI Call · ASR 接入验证 +
+

AI CALL / 参数验证

ASR 语音接入测试

麦克风 → 16 kHz 单声道 PCM → ASR。此页面不是电话外呼,也不代表 MQ/OSS 已对接。

+

连接参数

+ + + +

供应商密钥与上游地址只在服务端配置。使用 HTTPS 或 localhost;不要关闭浏览器安全机制。

+
+

未连接

音频:未采集

+
+

识别结果

等待语音…

+

尚未启用

LLM / TTS:等待用户新规范,未沿用调研仓库实现,也不会自动发起相关请求。

+
diff --git a/services/asr-web/web/pcm-worklet.js b/services/asr-web/web/pcm-worklet.js new file mode 100644 index 0000000..e8d4f4c --- /dev/null +++ b/services/asr-web/web/pcm-worklet.js @@ -0,0 +1,28 @@ +class PCM16Processor extends AudioWorkletProcessor { + constructor() { + super(); + this.buffer = new ArrayBuffer(3200); + this.view = new DataView(this.buffer); + this.offset = 0; + } + process(inputs) { + const samples = inputs[0]?.[0]; + if (!samples) return true; + for (const sample of samples) { + const value = Math.max(-1, Math.min(1, sample)); + this.view.setInt16( + this.offset * 2, + value < 0 ? value * 32768 : value * 32767, + true, + ); + if (++this.offset === 1600) { + this.port.postMessage(this.buffer, [this.buffer]); + this.buffer = new ArrayBuffer(3200); + this.view = new DataView(this.buffer); + this.offset = 0; + } + } + return true; // Output stays silent; no microphone feedback to the speakers. + } +} +registerProcessor("pcm16", PCM16Processor); diff --git a/services/asr-web/web/style.css b/services/asr-web/web/style.css new file mode 100644 index 0000000..baffcfd --- /dev/null +++ b/services/asr-web/web/style.css @@ -0,0 +1 @@ +:root{font:16px/1.6 system-ui,sans-serif;color:#16263c;background:#f3f6fa}*{box-sizing:border-box}body{margin:0}main{max-width:840px;margin:40px auto;padding:0 20px}header{margin-bottom:28px}.eyebrow{font-size:12px;letter-spacing:.18em;color:#376791}h1{font-size:32px;margin:8px 0}h2{font-size:19px;margin:0 0 16px}section{background:white;border:1px solid #dce4ed;border-radius:12px;padding:24px;margin:18px 0}label{display:block;margin:16px 0 6px}input,select{width:100%;padding:11px;border:1px solid #aebdce;border-radius:6px;font:inherit}button{background:#245c96;color:white;border:0;border-radius:6px;padding:10px 16px;margin:12px 8px 0 0;cursor:pointer;font:inherit}button:disabled{opacity:.45;cursor:not-allowed}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #e59a26;outline-offset:3px}.note,#audio{font-size:13px;color:#586b82}#status{font-weight:600}#partial{color:#45698c;min-height:28px}#finals p{padding:10px 0;border-top:1px solid #e5ebf2;overflow-wrap:anywhere}@media(max-width:520px){main{margin:20px auto}section{padding:18px}h1{font-size:26px}} diff --git a/tests/test_deployment.py b/tests/test_deployment.py new file mode 100644 index 0000000..fdc30fa --- /dev/null +++ b/tests/test_deployment.py @@ -0,0 +1,275 @@ +import copy +import json +import tempfile +import unittest +from pathlib import Path + +from deploy import aliyun_host as cloud +from deploy import render_asterisk as ast + + +def config(): + return { + "region": cloud.REGION, + "public_ip": cloud.PUBLIC_IP, + "project_tag": "ai-call", + "image_id": "m-test", + "instance_type": "ecs.test", + "vswitch_id": "vsw-test", + "security_group_id": "sg-test", + "key_pair_name": "test-key", + "spot_price_limit": 0.1, + } + + +def instance(id="i-test", tagged=True): + return { + "InstanceId": id, + "Status": "Running", + "Tags": { + "Tag": [{"TagKey": "project", "TagValue": "ai-call"}] if tagged else [] + }, + } + + +class FakeCloud: + def __init__(self): + self.address = { + "IpAddress": cloud.PUBLIC_IP, + "AllocationId": "eip-test", + "Status": "Available", + "InstanceId": "", + "InstanceType": "EcsInstance", + } + self.rows = [] + self.calls = [] + self.fail_bind = False + self.timeout_create = False + + def __call__(self, product, action, **params): + self.calls.append((action, params)) + if action == "DescribeEipAddresses": + rows = [copy.deepcopy(self.address)] if self.address else [] + return {"TotalCount": len(rows), "EipAddresses": {"EipAddress": rows}} + if action == "DescribeInstances": + rows = copy.deepcopy(self.rows) + if "InstanceIds" in params: + rows = [r for r in rows if r["InstanceId"] in params["InstanceIds"]] + return {"TotalCount": len(rows), "Instances": {"Instance": rows}} + if action == "RunInstances": + if self.timeout_create: + raise cloud.CloudError("simulated transport timeout") + self.rows = [instance("i-created")] + return {"InstanceIdSets": {"InstanceIdSet": ["i-created"]}} + if action == "AssociateEipAddress": + if self.fail_bind: + raise cloud.CloudError("simulated association failure") + self.address.update(InstanceId=params["InstanceId"], Status="InUse") + return {} + raise AssertionError("Unexpected cloud mutation: " + action) + + def mutations(self): + return [name for name, _ in self.calls if not name.startswith("Describe")] + + +class CloudTests(unittest.TestCase): + def test_plan_is_read_only(self): + api = FakeCloud() + self.assertEqual(cloud.plan(config(), api)["action"], "create_and_bind") + self.assertEqual(api.mutations(), []) + + def test_reuse_owned_bound_instance(self): + api = FakeCloud() + api.rows = [instance()] + api.address.update(InstanceId="i-test", Status="InUse") + with tempfile.TemporaryDirectory() as d: + result = cloud.apply(config(), api, Path(d) / "state.json") + self.assertEqual(result["action"], "reuse") + self.assertEqual(api.mutations(), []) + + def test_foreign_attachment_is_never_stolen(self): + api = FakeCloud() + api.rows = [instance(tagged=False)] + api.address.update(InstanceId="i-test", Status="InUse") + with self.assertRaises(cloud.CloudError): + cloud.plan(config(), api) + self.assertEqual(api.mutations(), []) + + def test_no_fixed_ip_means_no_creation(self): + api = FakeCloud() + api.address = {} + with self.assertRaises(cloud.CloudError): + cloud.plan(config(), api) + self.assertEqual(api.mutations(), []) + + def test_existing_non_eip_needs_explicit_adoption(self): + api = FakeCloud() + api.address = {} + row = instance(tagged=False) + row["PublicIpAddress"] = {"IpAddress": [cloud.PUBLIC_IP]} + api.rows = [row] + cfg = config() + with self.assertRaises(cloud.CloudError): + cloud.plan(cfg, api) + cfg["adopt_instance_id"] = "i-test" + self.assertEqual(cloud.plan(cfg, api)["ip_kind"], "instance_public_ip") + + def test_duplicate_candidates_stop(self): + api = FakeCloud() + api.rows = [instance("i-one"), instance("i-two")] + with self.assertRaises(cloud.CloudError): + cloud.plan(config(), api) + + def test_create_spot_without_new_public_ip_then_bind(self): + api = FakeCloud() + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "state.json" + result = cloud.apply(config(), api, path, sleep=lambda _: None) + state = json.loads(path.read_text()) + self.assertEqual(state["instance_id"], "i-created") + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + self.assertEqual(result["action"], "ready") + params = next(params for name, params in api.calls if name == "RunInstances") + self.assertEqual(params["Amount"], 1) + self.assertEqual(params["InternetMaxBandwidthOut"], 0) + self.assertEqual(params["SpotStrategy"], "SpotWithPriceLimit") + self.assertEqual(params["SpotPriceLimit"], 0.1) + self.assertEqual(api.mutations(), ["RunInstances", "AssociateEipAddress"]) + + def test_create_timeout_reuses_client_token(self): + api = FakeCloud() + api.timeout_create = True + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "state.json" + for _ in range(2): + with self.assertRaises(cloud.CloudError): + cloud.apply(config(), api, path, sleep=lambda _: None) + tokens = [p["ClientToken"] for name, p in api.calls if name == "RunInstances"] + self.assertEqual(len(tokens), 2) + self.assertEqual(tokens[0], tokens[1]) + + def test_bind_failure_reuses_created_host(self): + api = FakeCloud() + api.fail_bind = True + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "state.json" + with self.assertRaises(cloud.CloudError): + cloud.apply(config(), api, path, sleep=lambda _: None) + api.fail_bind = False + result = cloud.apply(config(), api, path, sleep=lambda _: None) + self.assertEqual(result["action"], "ready") + self.assertEqual(api.mutations().count("RunInstances"), 1) + + def test_stopped_host_and_bad_budget_do_not_create(self): + api = FakeCloud() + cfg = config() + cfg["spot_price_limit"] = None + with tempfile.TemporaryDirectory() as d, self.assertRaises(cloud.CloudError): + cloud.apply(cfg, api, Path(d) / "state.json", sleep=lambda _: None) + self.assertEqual(api.mutations(), []) + api.rows = [instance()] + api.rows[0]["Status"] = "Stopped" + with tempfile.TemporaryDirectory() as d, self.assertRaises(cloud.CloudError): + cloud.apply(config(), api, Path(d) / "state.json", sleep=lambda _: None) + self.assertEqual(api.mutations(), []) + + def test_incomplete_inventory_does_not_create(self): + api = FakeCloud() + + def incomplete(product, action, **params): + if action == "DescribeInstances": + return {"TotalCount": 2, "Instances": {"Instance": []}} + return api(product, action, **params) + + with self.assertRaises(cloud.CloudError): + cloud.plan(config(), incomplete) + self.assertEqual(api.mutations(), []) + + def test_missing_allocation_id_stops_before_creation(self): + api = FakeCloud() + api.address.pop("AllocationId") + with self.assertRaises(cloud.CloudError): + cloud.plan(config(), api) + self.assertEqual(api.mutations(), []) + + def test_binding_race_does_not_detach_foreign_host(self): + api = FakeCloud() + reads = 0 + + def raced(product, action, **params): + nonlocal reads + if action == "DescribeEipAddresses": + reads += 1 + if reads == 2: + api.address.update(InstanceId="i-foreign", Status="InUse") + return api(product, action, **params) + + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "state.json" + with self.assertRaises(cloud.CloudError): + cloud.apply(config(), raced, path, sleep=lambda _: None) + self.assertEqual(json.loads(path.read_text())["instance_id"], "i-created") + self.assertEqual(api.mutations(), ["RunInstances"]) + + def test_region_mismatch(self): + cfg = config() + cfg["region"] = "cn-hangzhou" + with self.assertRaises(cloud.CloudError): + cloud.plan(cfg, FakeCloud()) + + +class AsteriskTests(unittest.TestCase): + def cfg(self): + return { + "public_ip": cloud.PUBLIC_IP, + "transport": "udp", + "local_net": "10.1.0.0/16", + "primary": {"host": "sip-a.test", "auth_mode": "ip"}, + "backup": {"host": "sip-b.test", "auth_mode": "ip"}, + } + + def test_private_ari_fixed_nat_and_recording_config(self): + files = ast.render(self.cfg(), {"ARI_PASSWORD": "x" * 32}) + self.assertEqual(len(files), 5) + self.assertIn("bindaddr=127.0.0.1", files["http.conf"]) + self.assertIn("external_media_address=123.56.71.98", files["pjsip.conf"]) + self.assertIn("context=deny-inbound", files["pjsip.conf"]) + self.assertIn("strictrtp=yes", files["rtp.conf"]) + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "generated" + ast.write_config(files, path) + self.assertEqual((path / "ari.conf").stat().st_mode & 0o777, 0o600) + with self.assertRaises(ValueError): + ast.write_config(files, path) + + def test_reject_injection_public_ari_and_our_ip_as_provider(self): + for update in ( + {"ari_bind": "0.0.0.0"}, + {"ari_bind": "8.8.8.8"}, + {"local_net": "0.0.0.0/0"}, + ): # noqa: S104 — negative fixtures; renderer must reject them. + cfg = self.cfg() + cfg.update(update) + with self.assertRaises(ValueError): + ast.render(cfg, {"ARI_PASSWORD": "x" * 32}) + for host in ("123.56.71.98", "host\n[evil]", ""): + cfg = self.cfg() + cfg["primary"]["host"] = host + with self.assertRaises(ValueError): + ast.render(cfg, {"ARI_PASSWORD": "x" * 32}) + + def test_digest_registration(self): + cfg = self.cfg() + cfg["primary"].update( + auth_mode="digest", username="approved-user", register=True + ) + files = ast.render( + cfg, + {"ARI_PASSWORD": "x" * 32, "SIP_PRIMARY_PASSWORD": "vendor-pass-123456"}, + ) + self.assertIn("outbound_auth=provider-primary-auth", files["pjsip.conf"]) + self.assertIn("type=registration", files["pjsip.conf"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pcm.cjs b/tests/test_pcm.cjs new file mode 100644 index 0000000..68cabb1 --- /dev/null +++ b/tests/test_pcm.cjs @@ -0,0 +1,40 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const vm = require("node:vm"); +const path = require("node:path"); + +test("AudioWorklet emits independent 100ms little-endian PCM16 packets", () => { + const packets = []; + let Processor; + vm.runInNewContext( + fs.readFileSync( + path.join(__dirname, "../services/asr-web/web/pcm-worklet.js"), + "utf8", + ), + { + AudioWorkletProcessor: class { + constructor() { + this.port = { postMessage: (buffer) => packets.push(buffer) }; + } + }, + registerProcessor: (_name, implementation) => { + Processor = implementation; + }, + }, + ); + const processor = new Processor(); + assert.equal(processor.process([]), true); + const samples = new Float32Array(3200); + samples[0] = -1; + samples[1] = 1; + samples[2] = 0.5; + processor.process([[samples]]); + assert.equal(packets.length, 2); + assert.notEqual(packets[0], packets[1]); + assert.equal(packets[0].byteLength, 3200); + const view = new DataView(packets[0]); + assert.equal(view.getInt16(0, true), -32768); + assert.equal(view.getInt16(2, true), 32767); + assert.equal(view.getInt16(4, true), 16383); +});