Files
lisglosips/docs/TEST_PLAN_AND_CASES.md
T

2010 lines
141 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# LisgloSIPS V2 测试方案与测试用例集
## 1. 测试目标
验证 LisgloSIPS V2 在本地 KVM A/B/T 联调环境和后续阿里云迁移环境中,能够稳定完成“客户接入 - OpenSIPS 路由 - 计费话单 - 录音搬运 - Web 运营管理 - 监控与回滚”的核心闭环。
重点目标:
- 核心通话链路可用:客户 IP/SIP 注册认证、业务前缀识别、客户网关策略、落地线路组、落地网关路由、RTP 录音、CDR 生成。
- 业务数据正确:充值幂等、余额扣减、Decimal 计费、话单归属地/运营商、录音与质检关联、审计日志完整。
- 安全边界明确:登录认证、刷新令牌轮换、RBAC、敏感字段脱敏、录音文件路径安全、SIP 非法来源和超 CPS 防护。
- 运维能力可验收:健康检查、备份恢复、发布/回滚、Prometheus/Grafana、HOMER 信令排障、Worker 故障恢复。
- 前端可运营:核心页面接真实 API,权限驱动导航和按钮裁剪,加载/空状态/错误状态可用。
## 2. 测试范围
### 2.1 纳入范围
- Web:登录、Dashboard、客户、客户网关、业务前缀、充值、供应商、落地网关、落地线路组、号码库、当前通话、话单、质检、用户、角色权限、操作日志。
- API`apps/api/src/modules` 下所有已实现模块。
- WorkerCDR/Billing Worker、Recording Worker、Config Publisher。
- 数据层:Prisma/MySQL schema、Redis 热路径、Redis Stream、Outbox、幂等键。
- 通信层:Server A OpenSIPS、RTPEngine、HEPServer T SIP 模拟脚本。
- 运维层:Nginx、TLS、systemd 服务、备份恢复、发布 preflight、回滚脚本、监控告警基础设施。
### 2.2 不纳入范围
- 多 OpenSIPS 节点高可用。
- Kubernetes、跨地域容灾、复杂账单/发票/对账。
- 自研 SIP Trace UI、完整自研监控告警页面、完整系统设置页面。
- 超长时间满载压测。当前以功能闭环、小规模并发、故障恢复和上线前冒烟为主。
## 3. 测试环境
| 环境 | 用途 | 说明 |
| --- | --- | --- |
| 本地开发 | 单元、服务、API e2e、前端构建 | Windows 工作区,Node.js 22+pnpm 10.33.0Vitest |
| Server A | 历史节点/回滚参考 | S56 起不承载当前生产服务,不参与录音传输 |
| Server B | SIP/RTP/录音/Web/API/Worker/DB/监控 | OpenSIPS、RTPEngine、录音 tmpfs、Nginx、API、Redis、MySQL、Worker、HOMER、Prometheus/Grafana |
| Server T | 客户与落地模拟 | SIP 注册、呼叫、失败场景、并发呼叫脚本 |
| 阿里云迁移环境 | 上线前复验 | 生产网络、安全组、正式 TLS、数据盘、备份恢复和灰度呼叫必须重新执行 |
## 4. 测试类型与执行策略
| 类型 | 目标 | 执行方式 | 建议频率 |
| --- | --- | --- | --- |
| 静态检查 | 代码规范、类型、Prisma schema 合法性 | `pnpm lint``pnpm typecheck``pnpm prisma:validate` | 每次提交前 |
| 单元测试 | 认证、计费、CDR、录音路径、质检抽样、Dashboard 聚合等纯逻辑 | `pnpm test` 或定向 Vitest | 每次提交前 |
| API e2e | 模块接口、权限、审计、事务和错误码 | Vitest + Supertest | 每个后端变更 |
| Worker 集成 | Redis Stream、幂等、pending 重试、录音搬运校验 | Vitest + Redis/MySQL mock 或真实联调 | Worker 变更 |
| 前端功能 | 菜单、表格、表单、弹窗、权限按钮、错误状态 | 浏览器人工/自动冒烟 | 前端变更 |
| 三机端到端 | SIP 呼叫、路由、录音、CDR、计费、质检 | T 发起呼叫,A/B 核对数据 | 发布前、迁移前 |
| 性能/故障/安全 | CPS、并发、Worker/Redis/MySQL 故障、越权和重放 | 按 S29 方法执行 | 里程碑和上线前 |
| 上线验收 | 备份、preflight、灰度、回滚 | 按 S30 Runbook 执行 | 每次生产发布 |
## 5. 准入与准出标准
### 5.1 测试准入
- 需求或缺陷边界明确,已标注影响模块。
- 数据库迁移、OpenSIPS 配置、Nginx 配置、systemd 变更均有回滚点。
- `.env.example` 或部署环境变量已补齐新增配置项。
- 本地依赖可安装,`pnpm install` 或当前锁文件状态可复现。
### 5.2 发布准出
- `pnpm lint``pnpm typecheck``pnpm test``pnpm build` 通过。
- API `/api/v2/health/live``/api/v2/health/ready` 正常。
- 新 release 必须由 `pnpm release:artifact -- --release-id <release-id>` 或等价 Linux 环境命令生成,并校验 `RELEASE_MANIFEST.json``.sha256`
- B release preflight 通过,Nginx 配置测试通过,关键 systemd 服务 active。
- 至少完成一通灰度呼叫,并核对 SIP 200 OK、CDR 入库、计费、录音 READY。
- 备份目录、SHA256 校验和回滚脚本已确认。
- 无 P0/P1 缺陷;P2 缺陷有明确规避方案和负责人。
## 6. 自动化测试命令
在仓库根目录执行:
```powershell
corepack pnpm@10.33.0 lint
corepack pnpm@10.33.0 typecheck
corepack pnpm@10.33.0 test
corepack pnpm@10.33.0 build
corepack pnpm@10.33.0 prisma:validate
corepack pnpm@10.33.0 release:artifact -- --release-id sXX-name-YYYYMMDDHHmmss
```
常用定向测试:
```powershell
corepack pnpm@10.33.0 exec vitest run apps/api/src/modules/auth/auth.service.spec.ts apps/api/src/modules/auth/auth.e2e.spec.ts --hookTimeout=60000 --maxWorkers=1
corepack pnpm@10.33.0 exec vitest run packages/redis/src/cdr-stream.spec.ts --hookTimeout=60000 --maxWorkers=1
corepack pnpm@10.33.0 exec vitest run apps/worker-cdr/src/rating.spec.ts --hookTimeout=60000 --maxWorkers=1
corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts --hookTimeout=60000 --maxWorkers=1
```
## 7. 测试数据准备
| 数据 | 准备方式 | 验证点 |
| --- | --- | --- |
| 管理员用户 | Prisma seed 或既有 B 环境内置用户 | 可登录,可访问全部核心菜单 |
| 只读/质检/运营角色 | 角色权限页面或 seed | 菜单和按钮按权限裁剪 |
| 客户 | 客户管理创建 | 余额、授信、状态、删除保护 |
| 客户网关 | IP、SIP_DIGEST、MIXED 各一条 | 鉴权、匹配、密码不回显 |
| 业务前缀 | 至少两条不同优先级前缀 | 被叫前缀识别与剥离 |
| 供应商与落地网关 | 至少一条启用落地网关 | CPS、并发、禁呼时段、号码转换 |
| 落地线路组 | 绑定落地网关 | 优先级、启停、并发汇总 |
| 号码库 | 城市、区号、手机号段、运营商规则 | CDR 归属地/运营商识别 |
| 录音样本 | T 发起真实呼叫生成 | 播放、Range、质检评分 |
## 8. 测试用例集
### 8.1 认证、会话与权限
本节覆盖登录验证码、用户名密码校验、refresh cookie、access token、退出登录、后端 RBAC 和前端权限裁剪。后端接口统一以 `/api/v2` 为前缀。
公共前置数据:
- 管理员用户 `admin_auth_001`:状态 `ENABLED`,角色包含 `admin`,权限至少包含 `dashboard.view``customers.view``customers.manage``users.view``users.manage``roles.view``quality.view``quality.manage``cdr.view``recordings.play`
- 只读用户 `viewer_auth_001`:状态 `ENABLED`,仅包含 `dashboard.view``customers.view`
- 质检用户 `quality_auth_001`:状态 `ENABLED`,包含 `quality.view``quality.manage`,不包含 `users.view``users.manage`
- 禁用用户 `disabled_auth_001`:状态 `DISABLED`,密码正确但不可登录。
- 所有测试用户密码满足生产密码策略;测试结束后删除测试用户或重置为禁用状态。
#### AUTH-001 管理员登录成功
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证验证码、用户名密码、access token、refresh cookie、角色权限返回和敏感信息不泄露。 |
| 前置条件 | `admin_auth_001` 启用,密码已知;API 服务正常;`AUTH_COOKIE_SECURE=false` 时本地 HTTP 测试可读取 `Set-Cookie`,生产 HTTPS 环境应为 secure cookie。 |
| 测试数据 | 用户名 `admin_auth_001`;正确密码;通过 `GET /api/v2/auth/captcha` 获取 `captchaId`,自动化测试可使用注入的 `CaptchaService` 读取答案,人工测试使用图片验证码。 |
| 步骤 | 1. 请求 `GET /api/v2/auth/captcha`。2. 使用正确用户名、密码、`captchaId``captchaCode` 请求 `POST /api/v2/auth/login`。3. 解析响应体和 `Set-Cookie`。4. 使用返回的 access token 请求一个管理员可访问接口,例如 `GET /api/v2/users`。 |
| 预期结果 | 验证码接口返回 `captchaId``imageDataUrl``expiresAt`,不返回验证码答案;登录返回 200;响应体包含 `accessToken``user``user.roles``user.permissions` 非空且包含管理员权限;`Set-Cookie` 包含 refresh cookie。 |
| 数据检查 | `auth_sessions` 或等价会话存储新增一条未撤销会话;库中只保存 refresh token hash,不保存明文 refresh token;用户失败次数清零。 |
| 安全检查 | 响应体不包含 `passwordHash``sipHa1`、refresh token 明文、权限表以外的敏感字段;access token 为 Bearer token,不能通过 cookie 自动作为 access token 使用。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| AUTH-001-N1 | 缺少验证码 | 不传 `captchaId``captchaCode` 登录 | 返回 401,错误码 `AUTH_INVALID_CREDENTIALS``AUTH_CAPTCHA_INVALID`,不创建会话 |
| AUTH-001-N2 | 验证码错误 | 使用错误 `captchaCode` 登录 | 返回 401 `AUTH_CAPTCHA_INVALID`,不校验出具体用户名是否存在 |
| AUTH-001-N3 | 验证码复用 | 同一验证码成功登录后再次使用 | 第二次失败,验证码一次性消费 |
| AUTH-001-N4 | 禁用用户登录 | 使用 `disabled_auth_001` 正确密码登录 | 返回 401 `AUTH_INVALID_CREDENTIALS`,不暴露“用户被禁用”细节 |
#### AUTH-002 登录失败锁定与节流
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证连续错误密码会增加失败次数并锁定用户;大量失败会触发用户名/IP 维度节流。 |
| 前置条件 | `viewer_auth_001` 启用;默认配置 `AUTH_LOCK_MAX_FAILURES=5``AUTH_LOCK_WINDOW_SECONDS=900``AUTH_LOGIN_THROTTLE_MAX_FAILURES=10``AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=300`,如测试环境覆盖配置则按实际值执行。 |
| 测试数据 | 正确用户名 `viewer_auth_001`;错误密码 `WrongPassword-Auth002`;每次登录使用新的有效验证码。 |
| 步骤 | 1. 连续使用错误密码登录,次数达到锁定阈值。2. 在锁定窗口内使用正确密码登录。3. 使用不存在用户名连续失败直到节流阈值。4. 等待或模拟节流窗口过期后再次尝试。 |
| 预期结果 | 阈值前每次错误返回 401 `AUTH_INVALID_CREDENTIALS`;达到锁定阈值后用户 `lockedUntil` 被设置;锁定窗口内正确密码仍返回 401;达到节流阈值后返回 429 `AUTH_LOGIN_THROTTLED`;窗口过期后节流解除。 |
| 数据检查 | `failedLoginCount` 按失败次数增加;登录成功后失败次数清零;锁定只影响目标用户,不应锁定其他用户。 |
| 安全检查 | 错误响应不区分“用户名不存在”“密码错误”“用户禁用”“用户锁定”;日志和审计中密码、验证码答案必须脱敏。 |
补充边界用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| AUTH-002-N1 | 大小写/空格 | 用户名前后加空格或大小写变化登录 | 按系统约定归一化;不产生绕过锁定或重复账号 |
| AUTH-002-N2 | 多 IP 节流 | 同一用户名不同 IP 连续失败 | 用户名维度节流生效;IP 维度不误伤无关用户 |
| AUTH-002-N3 | 锁定恢复 | 锁定窗口过期后用正确密码登录 | 登录成功,失败次数清零,`lockedUntil` 清空 |
#### AUTH-003 刷新令牌轮换
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证 refresh token 只能使用一次,刷新时旧会话撤销并创建新会话,防止 refresh token 重放。 |
| 前置条件 | `admin_auth_001` 已成功登录,保存登录返回的 refresh cookie `cookieA`。 |
| 测试数据 | `cookieA` 为登录时 `Set-Cookie``cookieB` 为首次 refresh 后新的 `Set-Cookie`。 |
| 步骤 | 1. 携带 `cookieA` 请求 `POST /api/v2/auth/refresh`。2. 保存响应体 access token 和新的 `cookieB`。3. 再次携带旧 `cookieA` 请求 refresh。4. 携带 `cookieB` 请求 refresh。 |
| 预期结果 | 第一次 refresh 返回 200,签发新 access token 和新 refresh cookie;旧 `cookieA` 再次 refresh 返回 401`cookieB` 可以继续 refresh 并再次轮换。 |
| 数据检查 | 旧 session `revokedAt` 被设置;新 session 未撤销;refresh token hash 发生变化;不会出现多个同一 refresh hash 的活跃会话。 |
| 安全检查 | refresh 接口不接受 Bearer token 替代 cookie;响应体不返回 refresh token 明文;cookie 设置 `HttpOnly``SameSite`、合理 `Max-Age`,生产环境应包含 `Secure`。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| AUTH-003-N1 | 缺少 refresh cookie | 不带 cookie 请求 refresh | 返回 401 |
| AUTH-003-N2 | 伪造 refresh cookie | 携带随机 refresh cookie | 返回 401 |
| AUTH-003-N3 | 过期 refresh cookie | 使用已过期 session 对应 cookie | 返回 401,旧 session 不恢复 |
#### AUTH-004 退出登录
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证 logout 会撤销当前 refresh session 并清除浏览器 cookie。 |
| 前置条件 | `admin_auth_001` 已登录并持有有效 refresh cookie。 |
| 测试数据 | 登录后 refresh cookie。 |
| 步骤 | 1. 携带 refresh cookie 请求 `POST /api/v2/auth/logout`。2. 检查响应状态和 `Set-Cookie`。3. 使用同一 cookie 请求 `POST /api/v2/auth/refresh`。4. 使用旧 access token 访问接口,记录是否仍在 access token TTL 内可用。 |
| 预期结果 | logout 返回 204;响应设置 refresh cookie 过期或清空;同一 cookie refresh 返回 401;旧 access token 在过期前是否可用以系统设计为准,但不得再换取新 refresh token。 |
| 数据检查 | 当前 session `revokedAt` 被设置;其他设备/其他 session 不应被误撤销,除非产品要求单点登出。 |
| 安全检查 | 重复 logout 不应造成 500;无 cookie logout 返回 204 或 401 需与接口约定一致,但不得产生新会话。 |
#### AUTH-005 未登录访问保护接口
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证非 Public API 必须携带有效 Bearer access token。 |
| 前置条件 | API 服务正常;无需登录态。 |
| 测试数据 | 受保护接口:`GET /api/v2/users``GET /api/v2/customers``GET /api/v2/recordings`;公开接口:`GET /api/v2/auth/captcha``POST /api/v2/auth/login``POST /api/v2/auth/refresh``GET /api/v2/health/live`。 |
| 步骤 | 1. 不带 `Authorization` 请求受保护接口。2. 携带格式错误的 `Authorization: Token xxx` 请求。3. 携带伪造 JWT 请求。4. 请求公开接口。 |
| 预期结果 | 受保护接口返回 401 `AUTH_REQUIRED`;格式错误和伪造 token 也返回 401;公开接口不因缺少 Bearer token 被拒绝。 |
| 数据检查 | 未认证请求不写入业务数据;审计策略如记录失败访问,应不包含敏感 token 原文。 |
| 安全检查 | 不应返回堆栈、JWT 校验细节、用户存在性信息。 |
#### AUTH-006 权限不足访问
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证 RBAC 后端强制生效,前端隐藏按钮不能作为唯一防线。 |
| 前置条件 | `viewer_auth_001` 已登录,仅有 `dashboard.view``customers.view``quality_auth_001` 已登录,仅有质检权限。 |
| 测试数据 | viewer access tokenquality access token;管理员 access token 作为对照。 |
| 步骤 | 1. 使用 viewer token 请求 `GET /api/v2/customers`。2. 使用 viewer token 请求客户写接口,例如 `POST /api/v2/customers`。3. 使用 quality token 请求 `GET /api/v2/users` 或用户写接口。4. 使用管理员 token 请求同一接口作为对照。 |
| 预期结果 | viewer 可访问 `customers.view` 对应只读接口;viewer 写客户返回 403 `RBAC_FORBIDDEN`;quality 访问用户管理返回 403;管理员访问成功。 |
| 数据检查 | 403 请求不产生业务变更;如写操作被拒绝,不应创建客户、充值、用户或审计成功记录。 |
| 安全检查 | 403 响应只说明权限不足,不泄露所需权限全集、SQL 信息或内部角色结构。 |
补充矩阵:
| 子 ID | 账号 | 接口 | 期望 |
| --- | --- | --- | --- |
| AUTH-006-M1 | viewer | `GET /api/v2/customers` | 200 |
| AUTH-006-M2 | viewer | `POST /api/v2/customers` | 403 `RBAC_FORBIDDEN` |
| AUTH-006-M3 | viewer | `GET /api/v2/users` | 403 或 200 取决于是否授予 `users.view`;本测试用户应为 403 |
| AUTH-006-M4 | quality | `GET /api/v2/quality/recordings` 或质检列表接口 | 200 |
| AUTH-006-M5 | quality | `GET /api/v2/users` | 403 |
| AUTH-006-M6 | admin | 上述接口 | 按业务参数返回 200/201 或合法业务响应 |
#### AUTH-007 前端菜单权限裁剪
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证 Web 根据登录返回的 `user.permissions` 裁剪导航,并且手动访问无权限页面时有明确反馈。 |
| 前置条件 | Web 和 API 正常;`viewer_auth_001` 仅有 `dashboard.view``customers.view`;浏览器清空旧登录态。 |
| 测试数据 | viewer 用户、admin 用户。 |
| 步骤 | 1. 使用 viewer 登录 Web。2. 检查侧边栏/导航菜单。3. 手动修改 hash/path 或通过浏览器地址进入无权限页面,例如用户管理、供应商管理、质检中心。4. 退出后用 admin 登录,对照完整菜单。 |
| 预期结果 | viewer 只看到 Dashboard 和客户管理等有权限页面;供应商、落地网关、质检、用户、角色、操作日志等无权限菜单不可见;手动访问无权限页面显示“无权限访问”类状态或跳转到有权限页面;admin 可看到完整授权菜单。 |
| 数据检查 | viewer 登录后全局数据刷新只请求其有权限页面需要的 API,不应批量触发无权限接口 403。 |
| 前端检查 | 浏览器 console 无 uncaught error;页面无空白;刷新浏览器后权限裁剪仍生效;登录返回 permissions 为空时应显示最小可用状态而不是崩溃。 |
补充菜单矩阵:
| 权限 | 应显示菜单 |
| --- | --- |
| `dashboard.view` | 概览 Dashboard |
| `customers.view` | 客户管理 |
| `customer_gateways.view` | 客户网关管理、业务前缀管理 |
| `recharges.view` | 充值记录 |
| `vendors.view` | 供应商管理 |
| `vendor_gateways.view` | 落地网关管理 |
| `line_groups.view` | 落地线路组 |
| `number_library.view` | 号码库 |
| `active_calls.view` | 当前通话 |
| `cdr.view` | 话单中心 |
| `quality.view` | 质检中心 |
| `users.view` | 用户管理 |
| `roles.view` | 角色与权限 |
| `audit.view` | 操作日志 |
#### AUTH-008 按钮级权限裁剪
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证 Web 操作按钮根据 manage/play/review 类权限裁剪,同时后端仍强制校验。 |
| 前置条件 | Web 和 API 正常;viewer、quality、admin 三类用户可登录;系统中至少存在一个客户、一条话单、一条 READY 录音。 |
| 测试数据 | viewer 用户仅可看客户;quality 用户可看质检并评分;admin 用户全权限。 |
| 步骤 | 1. viewer 登录客户管理页面,检查新增、编辑、充值/扣款、删除、启停按钮。2. viewer 进入话单/录音相关页面,检查录音播放按钮。3. quality 登录质检中心,检查抽检规则、保存评分、播放录音按钮。4. admin 登录相同页面,对照按钮完整性。5. 使用浏览器 devtools 或 API 客户端绕过前端按钮,直接调用无权限写接口。 |
| 预期结果 | viewer 只能查看客户列表,不显示新增、编辑、充值/扣款、删除、启停按钮;无 `recordings.play` 时不显示或不可用播放入口;quality 按其权限显示质检评分/保存按钮;admin 显示完整操作按钮;绕过前端直接调用无权限写接口返回 403。 |
| 数据检查 | 无权限用户不会产生写入、删除、充值、挂断、评分等副作用;管理员操作产生对应审计记录。 |
| 前端检查 | 按钮隐藏后布局不塌陷;空状态、加载态和错误态仍可读;权限刷新后按钮状态同步更新,无需清理缓存才能生效。 |
按钮权限建议矩阵:
| 页面 | 操作 | 需要权限 | 无权限表现 |
| --- | --- | --- | --- |
| 客户管理 | 新增/编辑/启停/删除 | `customers.manage` | 按钮不可见 |
| 客户管理 | 充值/扣款 | `recharges.manage` | 按钮不可见 |
| 客户网关 | 新增/编辑/启停/删除 | `customer_gateways.manage` | 按钮不可见 |
| 业务前缀 | 新增/编辑/启停/删除 | `customer_gateways.manage` | 按钮不可见 |
| 供应商管理 | 新增/编辑/删除 | `vendors.manage` | 按钮不可见 |
| 落地网关 | 新增/编辑/启停/删除 | `vendor_gateways.manage` | 按钮不可见 |
| 落地线路组 | 新增/成员维护/重排/删除 | `line_groups.manage` | 按钮不可见 |
| 号码库 | 导入 | `number_library.manage` | 按钮不可见 |
| 当前通话 | 强制挂断 | `active_calls.manage` | 按钮不可见 |
| 话单中心 | 播放录音 | `recordings.play` | 按钮不可见或禁用 |
| 质检中心 | 播放录音 | `recordings.play` | 按钮不可见或禁用 |
| 质检中心 | 保存评分/问题标注 | `quality.manage` | 按钮不可见 |
| 用户管理 | 新增/编辑/启停/重置密码/删除 | `users.manage` | 按钮不可见 |
| 角色与权限 | 新增/编辑/删除/权限维护 | `roles.manage` | 按钮不可见 |
### 8.2 客户、充值与余额
本节覆盖客户主数据、余额展示、授信/最低余额、客户状态、受控删除、客户充值/扣款流水、幂等和金额精度。后端接口统一以 `/api/v2` 为前缀。
公共前置数据:
- 客户管理账号 `customer_admin_001`:包含 `customers.view``customers.manage``recharges.view``recharges.manage``audit.view`
- 客户只读账号 `customer_viewer_001`:仅包含 `customers.view`
- 财务账号 `finance_recharge_001`:包含 `recharges.view``recharges.manage``customers.view`,可执行人工充值和人工扣款。
- 种子客户 `cus_balance_seed_001`:状态 `ENABLED`,余额 `10.000000`,授信 `100.000000`,最低余额 `5.000000`,无删除保护关系。
- 受保护客户 `cus_protected_001`:至少关联 1 个客户网关或 1 条历史话单,用于删除保护验证。
- 所有测试金额统一按字符串传入,避免 JavaScript number 精度影响测试判断;充值/扣款接口中正数表示入金,负数表示扣款,`0` 不允许。
#### CUS-001 创建客户
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证有 `customers.manage` 权限的用户可以创建客户,默认值、金额归一化、审计和列表展示正确。 |
| 前置条件 | `customer_admin_001` 已登录;客户名 `Auto Customer CUS001`、域名 `cus001.example.test` 尚不存在。 |
| 测试数据 | `name=Auto Customer CUS001``contactName=Ops``phone=13800000001``email=ops-cus001@example.test``domain=cus001.example.test``billingMode=POSTPAID``creditLimit=88.5``minBalance=2.25``notes=created by CUS-001`。 |
| 步骤 | 1. 使用 admin token 请求 `POST /api/v2/customers`。2. 使用返回的 `id` 请求 `GET /api/v2/customers/:id`。3. 请求 `GET /api/v2/customers` 检查列表。4. 查询操作日志或审计存储。 |
| 预期结果 | 创建返回 201;客户状态为 `ENABLED``billingMode=POSTPAID``balance=0.000000` 或系统默认初始余额;`creditLimit=88.500000``minBalance=2.250000`;列表可见该客户。 |
| 数据检查 | `customers.created_by` 为当前用户;`version` 初始化正确;`deleted_at` 为空;审计存在 `module=customers``action=create``result=SUCCESS`。 |
| 安全检查 | 响应不包含数据库内部异常、无关权限信息或其他客户敏感数据。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| CUS-001-N1 | 无权限创建 | `customer_viewer_001` 请求 `POST /api/v2/customers` | 返回 403 `RBAC_FORBIDDEN`,不创建客户 |
| CUS-001-N2 | 缺少客户名 | `name` 为空、空格或非字符串 | 返回 400 `VALIDATION_ERROR` |
| CUS-001-N3 | 字段超长 | `name` 超 120、`email` 超 160、`notes` 超 500 | 返回 400 `VALIDATION_ERROR` |
| CUS-001-N4 | 非法状态 | `status=DELETED` | 返回 400 `STATUS_INVALID` |
| CUS-001-N5 | 非法计费模式 | `billingMode=MONTHLY` | 返回 400 `BILLING_MODE_INVALID` |
#### CUS-002 客户唯一性校验
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证客户名称和域名唯一性,防止重复主数据影响路由和财务归属。 |
| 前置条件 | 已存在客户 `Auto Customer CUS002`,域名 `cus002.example.test`。 |
| 测试数据 | 同名客户请求;同域名不同名称请求;大小写或前后空格变体按产品约定执行。 |
| 步骤 | 1. 创建基准客户。2. 再次使用相同 `name` 创建。3. 使用不同 `name` 但相同 `domain` 创建。4. 查询客户列表和数据库记录数。 |
| 预期结果 | 重复名称返回 409 或明确业务错误;重复域名返回 409 或明确业务错误;客户记录不重复。 |
| 数据检查 | 失败请求不写入 `customers`;不生成成功审计;如有失败审计,应标记 `result=FAILURE` 且包含安全脱敏后的错误摘要。 |
| 安全检查 | 错误信息可以提示重复字段,但不应泄露 SQL 约束名、堆栈或数据库连接信息。 |
补充边界用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| CUS-002-N1 | 名称前后空格 | 先创建 `Acme`,再创建 ` Acme ` | 按 trim 后重复处理 |
| CUS-002-N2 | 空域名重复 | 多个客户不填 `domain` | 如 schema 允许 null,应均可创建 |
| CUS-002-N3 | 软删除后同名 | 软删除客户后再次创建同名客户 | 按产品规则允许或拒绝,结果需固定并记录 |
#### CUS-003 客户启停与更新
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证客户状态变更、客户资料更新和余额只读边界。 |
| 前置条件 | 存在客户 `cus_status_001`,状态 `ENABLED`。 |
| 测试数据 | 更新 `contactName``phone``email``notes`;状态切换 `DISABLED`/`ENABLED`。 |
| 步骤 | 1. 请求 `PATCH /api/v2/customers/:id` 更新基础资料。2. 请求 `POST /api/v2/customers/:id/disable`。3. 请求 `GET /api/v2/customers/:id`。4. 请求 `POST /api/v2/customers/:id/enable`。5. 尝试在 `PATCH` 请求体中传入 `balance` 字段。 |
| 预期结果 | 资料更新成功,字段 trim/nullable 行为符合规则;禁用后状态为 `DISABLED`;启用后状态为 `ENABLED`;传入 `balance` 不应直接修改余额。 |
| 数据检查 | `updated_by``updated_at``version` 按更新变化;启停分别写入 `customers:disable``customers:enable` 审计;余额只能通过充值或计费链路变化。 |
| 安全检查 | 无 `customers.manage` 的用户更新、启停均返回 403;禁用客户不应影响其他客户。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| CUS-003-N1 | 更新不存在客户 | `PATCH /customers/not-found` | 返回 404 或明确业务错误 |
| CUS-003-N2 | 无效金额字段 | `creditLimit=-1``minBalance=1.1234567` | 返回 400 `MONEY_INVALID` |
| CUS-003-N3 | 幂等启停 | 对已禁用客户再次 disable,对已启用客户再次 enable | 不产生异常;结果状态稳定,审计按约定记录 |
#### CUS-004 客户受控删除
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证客户删除为受控操作,避免删除仍被网关、话单、充值或质检引用的客户。 |
| 前置条件 | `cus_protected_001` 已关联客户网关或历史话单;`cus_delete_free_001` 无关联网关、话单、充值等保护关系。 |
| 测试数据 | 受保护客户 ID;可删除客户 ID。 |
| 步骤 | 1. 使用 admin token 请求 `DELETE /api/v2/customers/cus_protected_001`。2. 请求 `DELETE /api/v2/customers/cus_delete_free_001`。3. 查询列表和详情。4. 查询相关关联数据是否仍完整。 |
| 预期结果 | 受保护客户删除被拒绝或只允许禁用,具体按实现返回明确业务错误;无保护客户删除成功,列表默认不再展示或标记为已删除;关联数据不被级联破坏。 |
| 数据检查 | 删除成功时设置 `deleted_at` 或等价软删除标记,不做物理删除;写入 `customers:delete` 审计;受保护删除失败不修改客户状态。 |
| 安全检查 | 无权限删除返回 403;重复删除已删除客户返回稳定结果,不应 500。 |
#### CUS-005 客户充值成功
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证客户人工充值在一个事务内完成余额增加、充值流水、幂等记录和审计。正数金额表示入金。 |
| 前置条件 | `finance_recharge_001` 已登录;客户 `cus_balance_seed_001` 当前余额 `10.000000`。 |
| 测试数据 | `amount=15.25``idempotencyKey=cus005-20260628-0001``remark=manual top up CUS-005`。 |
| 步骤 | 1. 请求 `POST /api/v2/customers/cus_balance_seed_001/recharges`。2. 请求 `GET /api/v2/customers/cus_balance_seed_001`。3. 请求 `GET /api/v2/recharges?accountType=CUSTOMER&accountId=cus_balance_seed_001&take=10`。4. 查询审计。 |
| 预期结果 | 充值返回 201`amount=15.250000``beforeBalance=10.000000``afterBalance=25.250000`;客户余额更新为 `25.250000`;充值列表包含该流水,状态 `SUCCEEDED`。 |
| 数据检查 | `customer_recharges` 或统一充值/扣款流水表新增一条;`idempotency_keys` 新增成功记录;余额和流水前后值一致;`created_by` 为财务用户。 |
| 安全检查 | 只读用户请求充值返回 403;充值/扣款接口不接受前端传入 `beforeBalance``afterBalance` 覆盖服务端计算。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| CUS-005-N1 | 不存在客户充值/扣款 | 请求 `POST /customers/not-found/recharges` | 返回 404 或明确业务错误,不创建流水 |
| CUS-005-N2 | 禁用客户充值/扣款 | 对 `DISABLED` 客户提交正数或负数金额 | 按产品规则允许或拒绝,结果需固定;若允许,客户状态不自动启用 |
| CUS-005-N3 | 缺少备注 | 不传 `remark` | 充值或扣款成功,`remark=null` |
| CUS-005-N4 | 备注超长 | `remark` 超 500 | 返回 400 `VALIDATION_ERROR` |
#### CUS-006 客户扣款成功
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证财务可通过同一充值接口提交负数金额,对客户账户执行人工扣款,并生成可追溯流水。 |
| 前置条件 | `finance_recharge_001` 已登录;客户 `cus_balance_seed_001` 当前余额 `25.250000` 或其他已知正余额;系统允许余额扣为负数或有明确最低余额规则。 |
| 测试数据 | `amount=-3.25``idempotencyKey=cus006-deduct-0001``remark=manual deduction CUS-006`。 |
| 步骤 | 1. 请求 `POST /api/v2/customers/cus_balance_seed_001/recharges`,提交负数 `amount`。2. 请求 `GET /api/v2/customers/cus_balance_seed_001`。3. 请求 `GET /api/v2/recharges?accountType=CUSTOMER&accountId=cus_balance_seed_001&take=10`。4. 查询审计。 |
| 预期结果 | 扣款返回 201`amount=-3.250000``beforeBalance` 为扣款前余额;`afterBalance=beforeBalance-3.250000`;客户余额同步减少;充值/扣款列表包含该流水,状态 `SUCCEEDED`。 |
| 数据检查 | 流水应保留负数金额,不应转成正数或另建不可追踪字段;幂等 key 新增成功记录;余额和流水前后值一致;`created_by` 为财务用户。 |
| 安全检查 | 只读用户请求扣款返回 403;负数金额不得绕过幂等、审计、权限或备注长度校验。 |
补充边界用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| CUS-006-N1 | 扣款后余额为 0 | 当前余额 `3.250000` 时提交 `amount=-3.25` | 成功,余额为 `0.000000` |
| CUS-006-N2 | 扣款后余额为负 | 当前余额不足时提交负数扣款 | 若产品允许透支则成功并显示负余额;若不允许则返回明确业务错误,规则需固定 |
| CUS-006-N3 | 负数最小精度 | 提交 `amount=-0.000001` | 成功,余额减少 `0.000001` |
| CUS-006-N4 | 负零 | 提交 `amount=-0``-0.000000` | 返回 400 `MONEY_INVALID` |
#### CUS-007 充值/扣款幂等重放
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证同一账号、同一请求体、同一 idempotencyKey 重放不会重复入金或重复扣款。 |
| 前置条件 | 客户 `cus_balance_seed_001` 可充值/扣款;幂等 key `cus007-replay-0001``cus007-replay-deduct-0001` 未被使用。 |
| 测试数据 | 入金:`amount=0.000001``idempotencyKey=cus007-replay-0001`;扣款:`amount=-0.000001``idempotencyKey=cus007-replay-deduct-0001`。 |
| 步骤 | 1. 第一次提交正数充值请求。2. 立即使用完全相同请求体再次提交。3. 第一次提交负数扣款请求。4. 立即使用完全相同请求体再次提交。5. 查询客户余额、充值/扣款流水和幂等键记录。 |
| 预期结果 | 每组两次请求均返回成功;每组第二次返回与第一次相同的流水 ID 和前后余额;客户余额对每个 key 只变化一次;正数和负数流水各只有一条。 |
| 数据检查 | 每个 idempotencyKey 只有一个成功记录;重复请求不新增成功流水,或审计标记为幂等重放,具体按审计设计固定。 |
| 安全检查 | 幂等 key 不应跨账号、跨业务类型复用成功;响应时间和错误信息不泄露其他账号充值/扣款内容。 |
补充并发用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| CUS-007-C1 | 并发充值重放 | 并发发送 5 个完全相同正数请求 | 只产生 1 条流水,最终余额只增加一次 |
| CUS-007-C2 | 并发扣款重放 | 并发发送 5 个完全相同负数请求 | 只产生 1 条流水,最终余额只减少一次 |
| CUS-007-C3 | 请求超时后重试 | 客户端超时后用同一 key 重试 | 返回已成功结果,不重复入金或扣款 |
#### CUS-008 充值/扣款幂等冲突
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证同一 idempotencyKey 不能被不同请求体、不同账号类型或不同账号复用,尤其不能用同一 key 混用入金和扣款。 |
| 前置条件 | 已使用 `idempotencyKey=cus008-conflict-0001``cus_balance_seed_001` 成功充值 `1.000000`。 |
| 测试数据 | 同 key 不同 `amount`;同 key 正负号相反;同 key 不同 `remark`;同 key 不同客户;同 key 用于供应商充值。 |
| 步骤 | 1. 使用相同 key、不同金额对同客户充值。2. 使用相同 key、`amount=-1.000000` 对同客户扣款。3. 使用相同 key、不同备注对同客户充值。4. 使用相同 key 对另一客户充值。5. 使用相同 key 对供应商充值。 |
| 预期结果 | 所有冲突请求返回 409 `IDEMPOTENCY_KEY_CONFLICT`;不修改任何账号余额;不新增充值/扣款流水。 |
| 数据检查 | 原充值记录保持不变;冲突请求可记录失败审计,但不得标记为充值或扣款成功。 |
| 安全检查 | 冲突响应不返回原请求的金额、备注、客户名等敏感业务内容。 |
#### CUS-009 金额精度校验
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证客户授信/最低余额和充值/扣款金额的格式、范围和归一化,避免浮点误差和非法金额进入财务数据。 |
| 前置条件 | 有可创建/更新客户权限和充值权限。 |
| 测试数据 | 客户金额合法值:`0``0.000001``1``1.2``99999999999999.999999`;充值/扣款合法值:`0.000001``-0.000001``1.2``-1.2``99999999999999.999999``-99999999999999.999999`;非法金额:客户金额字段中的负数、充值/扣款中的 `0``-0``0.000000``-0.000000``1.1234567``-1.1234567``100000000000000``-100000000000000``abc`、空字符串、科学计数法 `1e3`。 |
| 步骤 | 1. 创建/更新客户时分别提交合法和非法 `creditLimit``minBalance`。2. 充值/扣款时分别提交合法和非法 `amount`。3. 查询返回金额格式。 |
| 预期结果 | 客户金额字段允许非负,最多 14 位整数和 6 位小数,并归一化为 6 位小数;充值/扣款金额允许正数或负数,但不允许 0,绝对值最多 14 位整数和 6 位小数;非法金额返回 400 `MONEY_INVALID`。 |
| 数据检查 | 非法金额请求不写入客户、充值/扣款流水或幂等成功记录;合法金额在 API、数据库、列表中均以 6 位小数字符串展示,负数保留负号。 |
| 安全检查 | 不接受 NaN、Infinity、科学计数法或 JSON object/array 作为金额;服务端以 Decimal/字符串规则计算,不暴露二进制浮点误差。 |
金额矩阵:
| 场景 | 字段 | 输入 | 期望 |
| --- | --- | --- | --- |
| 客户授信 | `creditLimit` | `0` | 成功,返回 `0.000000` |
| 客户最低余额 | `minBalance` | `1.2` | 成功,返回 `1.200000` |
| 充值最小金额 | `amount` | `0.000001` | 成功,余额增加 `0.000001` |
| 扣款最小金额 | `amount` | `-0.000001` | 成功,余额减少 `0.000001` |
| 充值/扣款零金额 | `amount` | `0``-0.000000` | 400 `MONEY_INVALID` |
| 客户授信负数 | `creditLimit` | `-1` | 400 `MONEY_INVALID` |
| 客户最低余额负数 | `minBalance` | `-1` | 400 `MONEY_INVALID` |
| 小数超长 | 任意金额字段 | `1.1234567` | 400 `MONEY_INVALID` |
| 整数超长 | 任意金额字段 | `100000000000000` | 400 `MONEY_INVALID` |
#### CUS-010 客户列表、详情与充值/扣款流水查询
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证客户列表、客户详情和充值/扣款记录查询的只读权限、分页和筛选。 |
| 前置条件 | 至少存在 2 个客户、1 条客户充值、1 条客户扣款、1 条供应商充值。 |
| 测试数据 | `accountType=CUSTOMER``accountId=cus_balance_seed_001``take=10``skip=0`。 |
| 步骤 | 1. 使用 `customer_viewer_001` 请求 `GET /api/v2/customers`。2. 请求 `GET /api/v2/customers/:id`。3. 使用 `finance_recharge_001` 或充值只读用户请求 `GET /api/v2/recharges?accountType=CUSTOMER&accountId=...`。4. 检查正数充值和负数扣款流水。5. 使用非法分页和非法 `accountType` 查询。 |
| 预期结果 | 客户列表返回余额、授信、最低余额、可用余额、网关数量;客户详情字段完整;充值/扣款列表按筛选返回,包含 `items``total`;正数金额和负数金额均原样展示为 6 位小数字符串;非法 `accountType` 返回 400 `ACCOUNT_TYPE_INVALID`;非法分页返回 400 `PAGINATION_INVALID`。 |
| 数据检查 | 只读查询不产生业务变更;列表默认不展示软删除客户,除非接口明确支持。 |
| 安全检查 | 没有 `customers.view` 不能查客户;没有 `recharges.view` 不能查充值/扣款流水;列表不泄露其他模块敏感字段。 |
### 8.3 客户网关、业务前缀与配置发布
本节覆盖客户网关 IP/SIP/MIXED 鉴权、客户侧计费参数、主叫前缀、业务前缀、客户网关策略、配置 outbox、Config Publisher Redis 快照,以及 Web 业务前缀页面回归。后端接口统一以 `/api/v2` 为前缀。
公共前置数据:
- 运营账号 `gateway_ops_001`:包含 `customer_gateways.view``customer_gateways.manage``audit.view`
- 只读账号 `gateway_viewer_001`:仅包含 `customer_gateways.view`
- 客户 `cus_gateway_seed_001`:状态 `ENABLED`,余额充足,未删除。
- 线路组 `llg_gateway_seed_001`:状态 `ENABLED`,至少包含 1 个可用落地网关。
- 业务前缀 `bp_671_seed``prefix=671`,状态 `ENABLED`,优先级 `10`
- 客户网关 `cgw_ip_seed_001``authMode=IP``sourceIps=["100.93.185.30"]`,绑定 `llg_gateway_seed_001`
- Redis 配置发布测试环境可读取 `cfg:active_version``cfg:v:{version}:*` key。
#### GW-001 创建 IP 认证客户网关
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证 IP 鉴权客户网关创建、源 IP 校验、多 IP 去重、计费参数归一化、审计和配置发布 outbox。 |
| 前置条件 | `gateway_ops_001` 已登录;客户和线路组存在;待创建网关名 `Auto IP Gateway GW001` 不重复。 |
| 测试数据 | `customerId=cus_gateway_seed_001``name=Auto IP Gateway GW001``authMode=IP``sourceIps=["100.93.185.30","100.93.185.31","100.93.185.30"]``lineGroupId=llg_gateway_seed_001``billingCycleSec=6``cycleRate=0.01``callerMatchMode=ANY``calleeMatchMode=ANY`。 |
| 步骤 | 1. 请求 `POST /api/v2/customer-gateways`。2. 请求 `GET /api/v2/customer-gateways?customerId=cus_gateway_seed_001`。3. 请求 `GET /api/v2/customer-gateways/:id`。4. 查询 outbox 和审计。 |
| 预期结果 | 创建返回 201`authMode=IP``sourceIps` 去重后保留两个 IP`sourceIp` 兼容字段为第一个 IP`sipUsername``sipDomain` 为空;`billingCycleSec=6``cycleRate=0.010000`;状态默认 `ENABLED`。 |
| 数据检查 | 写入客户网关主表和网关 IP 明细;生成 `aggregateType=customer_gateway_config` 的待发布 outbox;审计存在 `module=customer_gateways``action=create``result=SUCCESS`。 |
| 安全检查 | 无 `customer_gateways.manage` 创建返回 403;响应不包含无关客户数据或内部异常。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| GW-001-N1 | IP 鉴权缺少 IP | `authMode=IP` 但不传 `sourceIp/sourceIps` | 400 `SOURCE_IP_REQUIRED` |
| GW-001-N2 | IP 格式非法 | 传 `999.1.1.1``abc` | 400 `SOURCE_IP_INVALID` |
| GW-001-N3 | IP 数量超限 | 传超过 20 个源 IP | 400 `SOURCE_IP_TOO_MANY` |
| GW-001-N4 | 非法鉴权模式 | `authMode=PASSWORD` | 400 `AUTH_MODE_INVALID` |
| GW-001-N5 | 计费周期非法 | `billingCycleSec=0``86401` | 400 `INTEGER_INVALID` |
| GW-001-N6 | 费率非法 | `cycleRate=-1``1.1234567` | 400 `MONEY_INVALID` |
#### GW-002 创建 SIP Digest 网关
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证 SIP Digest 鉴权网关创建、HA1 生成、域名归一化、密码不回显和不进入审计。 |
| 前置条件 | `gateway_ops_001` 已登录;客户和线路组存在;SIP 用户名未重复。 |
| 测试数据 | `authMode=SIP_DIGEST``sipUsername=alice-gw``sipDomain=SIP.EXAMPLE.LOCAL``sipPassword=change-me-very-strong``lineGroupId=llg_gateway_seed_001``calleeMatchMode=BUSINESS_PREFIXES``businessPrefixIds=["bp_671_seed"]`。 |
| 步骤 | 1. 请求 `POST /api/v2/customer-gateways` 创建 SIP 网关。2. 请求列表和详情。3. 查询数据库中 `sip_ha1`。4. 查询审计日志。 |
| 预期结果 | 创建返回 201`sipDomain` 归一化为小写;`hasSipCredential=true`;列表和详情不返回 `sipPassword``sipHa1``sourceIps=[]`;业务前缀绑定可见。 |
| 数据检查 | 数据库存储 HA1 摘要,值应等于 `md5(username:domain:password)`;生成配置 outbox;审计不包含明文密码。 |
| 安全检查 | 明文密码不出现在 API 响应、审计 request/response、应用日志和错误信息中。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| GW-002-N1 | 缺少 SIP 用户名 | `authMode=SIP_DIGEST` 不传 `sipUsername` | 400 `VALIDATION_ERROR` |
| GW-002-N2 | 缺少 SIP 域 | 不传 `sipDomain` | 400 `VALIDATION_ERROR` |
| GW-002-N3 | 密码过短 | `sipPassword` 少于 12 字符 | 400 `SIP_PASSWORD_WEAK` |
| GW-002-N4 | 用户名非法字符 | `sipUsername=alice space` | 400 `SIP_USERNAME_INVALID` |
| GW-002-N5 | 域名非法字符 | `sipDomain=sip_example_local!` | 400 `SIP_DOMAIN_INVALID` |
#### GW-003 创建 MIXED 鉴权网关
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证 MIXED 模式同时要求 IP 和 SIP 身份,支持同一网关兼容 IP 白名单与 SIP Digest。 |
| 前置条件 | `gateway_ops_001` 已登录;客户和线路组存在。 |
| 测试数据 | `authMode=MIXED``sourceIps=["100.93.185.40"]``sipUsername=mixed-gw``sipDomain=sip.example.local``sipPassword=change-me-very-strong`。 |
| 步骤 | 1. 创建 MIXED 网关。2. 查询详情。3. 发布配置后检查 Redis IP 和 SIP 索引。 |
| 预期结果 | 创建成功;响应同时包含 `sourceIps``sipUsername``sipDomain``hasSipCredential=true`Redis 写入 `auth:ip:{sourceIp}``auth:sip:{username}@{domain}` 两类 key。 |
| 数据检查 | 网关主表 `authMode=MIXED`;IP 明细和 SIP HA1 均存在;生成 outbox。 |
| 安全检查 | 任一鉴权要素缺失均失败,不应降级为单一模式。 |
#### GW-004 切换 SIP 身份要求新密码
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证修改 SIP 用户名或域名时必须重新提供 SIP 密码,避免旧 HA1 与新身份不匹配。 |
| 前置条件 | 已存在 SIP_DIGEST 或 MIXED 网关 `cgw_sip_seed_001`,且 `hasSipCredential=true`。 |
| 测试数据 | 新 `sipDomain=new.example.local`;新 `sipPassword=change-me-again-strong`。 |
| 步骤 | 1. 不传 `sipPassword` 修改 `sipDomain`。2. 不传 `sipPassword` 修改 `sipUsername`。3. 传入新密码修改身份。4. 切换为 `authMode=IP` 并传 `sourceIps`。 |
| 预期结果 | 前两步返回 400 `SIP_PASSWORD_REQUIRED`;传新密码后修改成功并重新生成 HA1;切换到 IP 后 `sipUsername/sipDomain` 清空、`hasSipCredential=false`。 |
| 数据检查 | 失败请求不改变 HA1;成功修改生成配置 outbox 和 update 审计;切 IP 后不再发布 SIP auth key。 |
| 安全检查 | 新旧密码均不回显、不进入审计。 |
#### GW-005 主叫前缀匹配
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证客户网关可配置主叫前缀白名单,并在配置发布后进入 Redis 热路径。 |
| 前置条件 | 已存在启用客户网关 `cgw_ip_seed_001`。 |
| 测试数据 | `callerMatchMode=PREFIXES``callerPrefixes=["021","010","+8610","021"]`;另准备同 IP 其他网关已有主叫前缀 `0219``021`。 |
| 步骤 | 1. 请求 `PATCH /api/v2/customer-gateways/:id` 更新主叫匹配。2. 查询网关详情。3. 执行 Config Publisher。4. 检查 Redis `cfg:v:{version}:customer_gateway:{id}:caller_prefixes`。5. 使用 T 发起匹配和不匹配主叫呼叫。 |
| 预期结果 | API 返回去重后的前缀列表;发布后 Redis list 包含前缀;空业务前缀场景下按最长主叫前缀选择客户网关;匹配主叫通过客户识别,不匹配主叫返回 `CALLER_PREFIX_NOT_MATCHED` 或等价失败原因。 |
| 数据检查 | `customer_gateway_caller_prefixes` 按优先级/前缀保存;生成 outboxmanifest `customerGatewayCallerPrefixCount` 增加。 |
| 安全检查 | 无权限更新返回 403;非法前缀字符返回 400 `CALLER_PREFIX_INVALID`。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| GW-005-N1 | PREFIXES 缺少前缀 | `callerMatchMode=PREFIXES` 且列表为空 | 400 `CALLER_PREFIX_REQUIRED` |
| GW-005-N2 | 前缀数量超限 | 超过 20 个主叫前缀 | 400 `LIST_TOO_LARGE` |
| GW-005-N3 | 切回 ANY | `callerMatchMode=ANY` | 前缀列表清空,发布后不写 caller_prefixes key |
| GW-005-N4 | 同网关主叫前缀覆盖 | 同一网关提交 `["021","0219"]` | 400 `CALLER_PREFIX_OVERLAP` |
| GW-005-N5 | 同 IP 跨网关主叫前缀覆盖 | 同一 source IP 下另一个启用网关已有 `021`,新建/启用网关提交 `0219` | 409 `CUSTOMER_GATEWAY_CALLER_PREFIX_OVERLAP` |
#### GW-006 业务前缀匹配
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证客户网关可绑定业务前缀,呼叫时先按业务前缀识别业务并剥离真实被叫。 |
| 前置条件 | 业务前缀 `bp_671_seed``bp_672_seed` 均启用;客户网关启用;A 侧 OpenSIPS 加载业务前缀热路径。 |
| 测试数据 | `calleeMatchMode=BUSINESS_PREFIXES``businessPrefixIds=["bp_671_seed","bp_672_seed"]`;测试被叫 `67113800138000`。 |
| 步骤 | 1. 更新客户网关绑定业务前缀。2. 发布配置。3. 检查 Redis `business_prefix_value:671``customer_gateway:{id}:business_prefixes`。4. T 发起被叫带业务前缀的呼叫。5. 同时准备一个空业务前缀/主叫前缀网关作为兜底,再次发起 `671...` 呼叫。6. 查询 CDR。 |
| 预期结果 | 网关详情展示绑定前缀;发布 manifest `businessPrefixCount``customerGatewayBusinessPrefixCount` 正确;呼叫按 `671` 优先匹配业务,实际被叫剥离为 `13800138000`;即使存在主叫前缀或空业务前缀兜底网关,也不得抢走已命中业务前缀的呼叫;CDR 记录业务前缀 ID/值和真实被叫。 |
| 数据检查 | 只发布启用且未删除业务前缀;禁用前缀不进入全局 `business_prefixes` 列表;生成 outbox。 |
| 安全检查 | 无权限绑定返回 403;跨客户绑定是否允许按产品规则固定,不得泄露其他客户敏感数据。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| GW-006-N1 | BUSINESS_PREFIXES 缺少 ID | `calleeMatchMode=BUSINESS_PREFIXES` 但不传 `businessPrefixIds` | 400 `BUSINESS_PREFIX_REQUIRED` |
| GW-006-N2 | ID 数量超限 | 超过 50 个业务前缀 ID | 400 `LIST_TOO_LARGE` |
| GW-006-N3 | 切回 ANY | `calleeMatchMode=ANY` | 绑定前缀清空,发布后不写 gateway business_prefixes key |
| GW-006-N4 | 被叫前缀不匹配 | 发起未绑定业务前缀被叫 | 呼叫被拒绝或进入明确失败路径,CDR 失败原因可追踪 |
| GW-006-N5 | 命中业务前缀但主叫不匹配 | 被叫 `671...` 命中业务前缀网关,主叫不满足该网关规则,同时存在空业务前缀兜底网关 | 返回 `CALLER_PREFIX_NOT_MATCHED`,不得退回兜底网关 |
| GW-006-N6 | 同 IP 同业务前缀重复 | 同一 source IP 下新建/启用另一个绑定 `bp_671_seed` 的网关 | 409 `CUSTOMER_GATEWAY_MATCH_CONFLICT` |
| GW-006-N7 | 同 IP 空业务前缀兜底重复 | 同一 source IP 下存在两个 `calleeMatchMode=ANY` 启用网关 | 409 `CUSTOMER_GATEWAY_MATCH_CONFLICT` |
#### GW-006A 客户网关匹配优先级与唯一性
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证最终匹配唯一,且业务前缀优先于主叫前缀、精确规则优先于空业务前缀兜底。 |
| 前置条件 | 同一测试 source IP 下准备三个候选网关:A 绑定 `bp_671_seed`B 为 `calleeMatchMode=ANY``callerPrefixes=["021"]`C 为 `calleeMatchMode=ANY``callerMatchMode=ANY`;三者绑定不同线路组以便观察命中结果。 |
| 测试数据 | 呼叫 1:主叫 `02160010001`、被叫 `67113800138000`;呼叫 2:主叫 `02160010001`、被叫 `13800138000`;呼叫 3:主叫 `01060010001`、被叫 `13800138000`。 |
| 步骤 | 1. 发布配置并确认 A 加载最新热路径。2. 分别发起三通呼叫。3. 查询 CDR、HOMER、A 日志和落地线路组。 |
| 预期结果 | 呼叫 1 必须命中业务前缀网关 A,真实被叫为 `13800138000`;呼叫 2 命中主叫前缀网关 B;呼叫 3 命中空业务前缀兜底网关 C;任一场景不得出现随机网关或按 Redis 列表顺序误选。 |
| 数据检查 | CDR 中客户网关、线路组、业务前缀、raw/real callee 与预期一致;客户侧计费归属客户网关,供应商成本归属落地网关。 |
| 安全检查 | IP 可跨客户复用,但保存配置时必须保证上述三类呼叫最终只能命中一个网关。 |
#### GW-007 业务前缀 CRUD 与优先级
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证业务前缀创建、唯一性、状态、优先级、列表筛选和受控删除。 |
| 前置条件 | `gateway_ops_001` 已登录;待创建前缀 `673` 不存在。 |
| 测试数据 | `prefix=673``name=业务673``description=for GW-007``priority=30``status=ENABLED`。 |
| 步骤 | 1. 请求 `POST /api/v2/business-prefixes` 创建。2. 请求 `GET /api/v2/business-prefixes?status=ENABLED&keyword=673`。3. 更新 `priority``description`。4. disable/enable。5. 删除未被网关引用的前缀。 |
| 预期结果 | 创建返回 201;列表按优先级和前缀展示;状态切换成功;未引用前缀可软删除;所有写操作生成配置 outbox 和审计。 |
| 数据检查 | `prefix` 唯一;`priority` 在 1-9999;删除为软删除;禁用或删除后不进入 Redis 活跃业务前缀快照。 |
| 安全检查 | 业务前缀接口读需要 `customer_gateways.view`,写需要 `customer_gateways.manage`;无权限返回 403。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| GW-007-N1 | 非法前缀字符 | `prefix=67#` 或中文 | 400 `BUSINESS_PREFIX_INVALID` |
| GW-007-N2 | 前缀超长 | 长度超过 32 | 400 `VALIDATION_ERROR``BUSINESS_PREFIX_INVALID` |
| GW-007-N3 | 优先级非法 | `priority=0``10000`、小数 | 400 `PRIORITY_INVALID` |
| GW-007-N4 | 重复前缀 | 创建已存在 `671` | 409 或明确业务错误,不生成重复记录 |
| GW-007-N5 | 删除被引用前缀 | 删除已绑定网关的前缀 | 拒绝删除或软删除后自动解除/禁用,规则需固定且不破坏网关配置 |
#### GW-008 客户网关策略 CRUD 与优先级重排
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证客户网关策略按优先级匹配主叫/被叫并指向线路组,支持创建、更新、删除和重排。 |
| 前置条件 | 已存在客户网关 `cgw_ip_seed_001`,线路组 `llg_gateway_seed_001`,另有备用线路组 `llg_backup_001`。 |
| 测试数据 | 策略 1`callerMode=PREFIX``callerValue=021``calleeMode=ANY`;策略 2`callerMode=ANY``calleeMode=EQUALS``calleeValue=13800138000`fallback 策略:`ANY/ANY`。 |
| 步骤 | 1. 请求 `POST /api/v2/customer-gateways/:id/policies` 创建多条策略。2. 请求列表确认按 `priority` 排序。3. PATCH 修改策略 match mode/value。4. 请求 `POST /api/v2/customer-gateways/:id/policies/reorder` 调整顺序。5. 删除一条策略。6. 发布配置并检查 Redis policy list 顺序。 |
| 预期结果 | 策略创建、更新、重排、删除成功;列表顺序与优先级一致;发布后 `customer_gateway:{id}:policies` 按优先级写入;呼叫策略选择符合重排结果。 |
| 数据检查 | 每次写操作生成 `customer_gateway_config` outbox;审计包含 `policy_create``policy_update``policy_reorder``policy_delete`;删除为软删除或从列表移除按实现固定。 |
| 安全检查 | 只读用户可列表不可写;无权限写返回 403。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| GW-008-N1 | 非 ANY 缺少匹配值 | `callerMode=PREFIX` 但不传 `callerValue` | 400 `VALIDATION_ERROR` |
| GW-008-N2 | 匹配值非法字符 | `calleeValue=138 001` | 400 `MATCH_VALUE_INVALID` |
| GW-008-N3 | 非法匹配模式 | `callerMode=REGEX` | 400 `MATCH_MODE_INVALID` |
| GW-008-N4 | 优先级非法 | `priority=0``10001`、字符串 | 400 `PRIORITY_INVALID` |
| GW-008-N5 | 重排列表为空 | `policyIds=[]` | 400 `POLICY_IDS_INVALID` |
| GW-008-N6 | 重排遗漏/跨网关策略 | 传入不存在或其他网关策略 ID | 返回明确业务错误或仅重排本关联网关策略,规则需固定 |
#### GW-009 配置发布成功
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证客户网关、业务前缀、策略变更产生 outbox 后,Config Publisher 能生成完整 Redis 配置版本并切换 active version。 |
| 前置条件 | 至少存在 1 条 pending `business_prefix_config``customer_gateway_config` outboxRedis 可用;MySQL 可读。 |
| 测试数据 | 启用客户、业务前缀 `671`、IP 网关、多 IP、主叫前缀、业务前缀绑定、至少 1 条策略、线路组。 |
| 步骤 | 1. 执行 Config Publisher。2. 读取 `cfg:active_version``cfg:previous_version`。3. 读取 `cfg:manifest:{version}` 或等价 manifest key。4. 检查 Redis 热路径 key。5. 检查 outbox 状态。 |
| 预期结果 | Publisher 返回 `published=true`active version 更新;manifest `schemaVersion=2` 且 counts 正确;outbox 由 `PENDING/PROCESSING` 变为 `PUBLISHED``lastError=null`。 |
| Redis 检查 | 存在 `cfg:v:{version}:auth:ip:{sourceIp}``auth:ip:{sourceIp}:gateways``auth:sip:{username}@{domain}``business_prefix_value:671``business_prefixes``customer_gateway:{id}``customer_gateway:{id}:caller_prefixes``customer_gateway:{id}:business_prefixes``customer_gateway:{id}:policies``customer_gateway:{id}:line_group`。 |
| 安全检查 | Redis 不应写入 SIP 明文密码;manifest checksum 稳定;发布失败前不应切换到半写入版本。 |
#### GW-010 配置发布失败与重试
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证 Redis 或 MySQL 异常时 outbox 状态、错误记录和后续恢复重试行为可控。 |
| 前置条件 | 存在 pending outbox;可在测试环境临时断开 Redis 或注入 Redis `multi.exec` 失败。 |
| 测试数据 | 一条客户网关配置变更 outbox。 |
| 步骤 | 1. 使 Redis 写入失败。2. 执行 Config Publisher。3. 检查 outbox 状态和 `lastError`。4. 恢复 Redis。5. 将失败 outbox 重置为可重试状态或按重试机制等待。6. 再次执行 Publisher。 |
| 预期结果 | 失败时 outbox 标记 `FAILED` 或保留可重试状态,`attempts` 增加,`lastError` 记录截断后的错误;不切换 `cfg:active_version` 到不完整版本;恢复后可重新发布成功。 |
| 数据检查 | 失败发布不丢 outbox;成功重试后 `processedAt` 设置;previous/active version 关系正确。 |
| 安全检查 | `lastError` 不包含密码、连接串密码或访问令牌。 |
#### GW-011 网关启停、删除与发布
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证客户网关启用、禁用、删除会影响热路径配置和呼叫准入。 |
| 前置条件 | 已存在启用网关 `cgw_ip_seed_001` 并已发布。 |
| 测试数据 | 网关 ID;源 IP `100.93.185.30`。 |
| 步骤 | 1. 请求 `POST /api/v2/customer-gateways/:id/disable`。2. 发布配置并发起呼叫。3. 请求 enable 后再次发布并呼叫。4. 删除未被策略/话单保护的网关并发布。 |
| 预期结果 | 禁用后网关状态为 `DISABLED`,呼叫被拒绝或无法匹配;启用后恢复;删除后默认列表不展示,Redis active 快照不再包含该网关可用路由。 |
| 数据检查 | 启停和删除均生成 outbox 和审计;删除为软删除;有关联策略时删除行为按产品规则固定。 |
| 安全检查 | 无权限启停/删除返回 403;删除不应物理清除历史 CDR 关联。 |
#### GW-012 业务前缀页面加载
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 回归 Web “业务前缀管理”页面,防止默认表单常量缺失、权限裁剪或接口错误导致页面空白。 |
| 前置条件 | Web/API 正常;`gateway_ops_001``gateway_viewer_001` 均可登录;后端至少存在 1 条业务前缀。 |
| 测试数据 | `prefix=674``name=业务674``priority=40`。 |
| 步骤 | 1. 使用 `gateway_viewer_001` 登录并进入业务前缀页面。2. 检查列表、筛选、空/错状态。3. 确认 viewer 无新增/编辑/启停/删除按钮。4. 使用 `gateway_ops_001` 登录,创建、编辑、禁用、启用一条测试前缀。5. 打开浏览器 console。 |
| 预期结果 | 页面正常渲染,无空白;viewer 只读;ops 可完成 CRUD;表单默认值正确;接口错误时显示错误提示;console 无 uncaught error。 |
| 数据检查 | 页面操作产生对应后端记录、outbox 和审计;删除测试前缀后列表状态正确。 |
| 安全检查 | 前端隐藏按钮之外,直接调用无权限写接口仍返回 403。 |
字段校验矩阵:
| 对象 | 字段 | 合法规则 | 非法示例 | 期望错误 |
| --- | --- | --- | --- | --- |
| 客户网关 | `authMode` | `IP``SIP_DIGEST``MIXED` | `PASSWORD` | `AUTH_MODE_INVALID` |
| 客户网关 | `sourceIps` | IP/MIXED 必填,最多 20 个 IPv4/IPv6 | `abc`、超过 20 个 | `SOURCE_IP_REQUIRED``SOURCE_IP_INVALID``SOURCE_IP_TOO_MANY` |
| 客户网关 | `sipUsername` | SIP/MIXED 必填,`A-Za-z0-9_.:+-` | `alice gw` | `SIP_USERNAME_INVALID` |
| 客户网关 | `sipDomain` | SIP/MIXED 必填,字母数字点横线,保存为小写 | `sip_domain!` | `SIP_DOMAIN_INVALID` |
| 客户网关 | `sipPassword` | SIP/MIXED 创建或身份变更时必填,至少 12 字符 | `short` | `SIP_PASSWORD_WEAK``SIP_PASSWORD_REQUIRED` |
| 客户网关 | `billingCycleSec` | 1-86400 整数 | `0``86401``1.5` | `INTEGER_INVALID` |
| 客户网关 | `cycleRate` | 非负,最多 14 位整数、6 位小数 | `-1``1.1234567` | `MONEY_INVALID` |
| 客户网关 | `callerPrefixes` | PREFIXES 模式必填,最多 20 个,安全字符 | 空、含空格、超过 20 个 | `CALLER_PREFIX_REQUIRED``CALLER_PREFIX_INVALID``LIST_TOO_LARGE` |
| 客户网关 | `businessPrefixIds` | BUSINESS_PREFIXES 模式必填,最多 50 个 | 空、超过 50 个 | `BUSINESS_PREFIX_REQUIRED``LIST_TOO_LARGE` |
| 客户网关 | 匹配唯一性 | 同一 IP + 同一业务前缀唯一;同一 IP 主叫前缀不互相覆盖;空业务前缀兜底唯一 | 重复 `bp_671_seed``021``0219`、两个 ANY 被叫兜底 | `CUSTOMER_GATEWAY_MATCH_CONFLICT``CUSTOMER_GATEWAY_CALLER_PREFIX_OVERLAP` |
| 业务前缀 | `prefix` | 1-32 位字母或数字,唯一 | `67#`、中文、重复 | `BUSINESS_PREFIX_INVALID` 或 409 |
| 业务前缀 | `priority` | 1-9999 整数 | `0``10000``1.5` | `PRIORITY_INVALID` |
| 策略 | `callerMode/calleeMode` | `ANY``EQUALS``PREFIX` | `REGEX` | `MATCH_MODE_INVALID` |
| 策略 | `callerValue/calleeValue` | 非 ANY 时必填,最多 64 位安全字符 | 空、`138 001` | `VALIDATION_ERROR``MATCH_VALUE_INVALID` |
### 8.4 供应商、落地网关与线路组
本节覆盖供应商主数据、供应商充值、落地网关出局参数、禁呼时段、编码、主被叫改写、主叫号码池、线路组成员、优先级、并发汇总、配置发布和真实呼叫选路。后端接口统一以 `/api/v2` 为前缀。
公共前置数据:
- 供应商管理账号 `vendor_ops_001`:包含 `vendors.view``vendors.manage``vendor_gateways.view``vendor_gateways.manage``line_groups.view``line_groups.manage``recharges.view``recharges.manage``audit.view`
- 只读账号 `vendor_viewer_001`:包含 `vendors.view``vendor_gateways.view``line_groups.view`
- 供应商 `ven_seed_001`:余额 `20.000000`,授信 `200.000000`。供应商不提供启用/禁用状态入口。
- 落地网关 `vgw_primary_001`:状态 `ENABLED`host 指向 T/UAS 或可控落地模拟端。
- 落地网关 `vgw_backup_001`:状态 `ENABLED`,用于线路组故障转移。
- 线路组 `llg_vendor_seed_001`:状态 `ENABLED`,至少包含 `vgw_primary_001``vgw_backup_001`
- 客户网关策略已指向 `llg_vendor_seed_001`,用于真实呼叫选路验证。
#### VEN-001 创建供应商
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证供应商主数据创建、默认余额/授信、可用余额、审计和权限。 |
| 前置条件 | `vendor_ops_001` 已登录;供应商名 `Auto Vendor VEN001` 不存在。 |
| 测试数据 | `name=Auto Vendor VEN001``contactName=NOC``phone=4001000001``email=noc-ven001@example.test``creditLimit=188.5``settlement=monthly``notes=created by VEN-001`。 |
| 步骤 | 1. 请求 `POST /api/v2/vendors`。2. 使用返回 ID 请求 `GET /api/v2/vendors/:id`。3. 请求 `GET /api/v2/vendors`。4. 查询审计。 |
| 预期结果 | 创建返回 201;余额为系统默认值;`creditLimit=188.500000``availableBalance=balance+creditLimit`;列表可见,`gatewayCount=0`;响应不暴露供应商启停状态。 |
| 数据检查 | 写入 `created_by``updated_at``deleted_at=null`;审计存在 `module=vendors``action=create``result=SUCCESS`。 |
| 安全检查 | `vendor_viewer_001` 创建返回 403;响应不包含内部异常或其他供应商敏感字段。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| VEN-001-N1 | 缺少名称 | `name` 为空、空格或非字符串 | 400 `VALIDATION_ERROR` |
| VEN-001-N2 | 字段超长 | `name` 超 120、`settlement` 超 80、`notes` 超 500 | 400 `VALIDATION_ERROR` |
| VEN-001-N3 | 状态字段被忽略或拒绝 | 请求携带 `status=DISABLED``status=DELETED` | 不允许通过供应商接口维护启停;响应不返回供应商状态,且不会生成启停审计 |
| VEN-001-N4 | 授信金额非法 | `creditLimit=-1``1.1234567` | 400 `MONEY_INVALID` |
| VEN-001-N5 | 重复名称 | 创建已存在供应商名称 | 409 或明确业务错误,不重复写入 |
#### VEN-002 供应商更新与删除保护
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证供应商资料更新、软删除,以及有关联落地网关时的删除保护。 |
| 前置条件 | 已存在供应商 `ven_seed_001`;另有无关联供应商 `ven_delete_free_001`。 |
| 测试数据 | 更新 `notes=primary carrier``creditLimit=200`。 |
| 步骤 | 1. PATCH 更新供应商资料。2. 请求旧的 `/vendors/:id/disable``/vendors/:id/enable`。3. 删除有关联落地网关的供应商。4. 删除无关联供应商。 |
| 预期结果 | 更新成功且金额归一化;供应商启停接口不可用;有关联供应商删除被拒绝或按产品规则受控软删除;无关联供应商可软删除。 |
| 数据检查 | `updated_by``version` 或更新时间更新;删除写审计;不会产生供应商 enable/disable 审计;删除不物理清除历史充值和网关引用。 |
| 安全检查 | 无 `vendors.manage` 的更新、删除返回 403;旧启停接口不应暴露可操作功能。 |
#### VEN-003 供应商充值
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证供应商人工充值会增加供应商余额、生成充值流水、幂等记录和审计。 |
| 前置条件 | `vendor_ops_001` 已登录;供应商 `ven_seed_001` 当前余额已知。 |
| 测试数据 | `amount=3.5``idempotencyKey=ven003-20260628-0001``remark=vendor top up VEN-003`。 |
| 步骤 | 1. 请求 `POST /api/v2/vendors/ven_seed_001/recharges`。2. 查询供应商详情。3. 请求 `GET /api/v2/recharges?accountType=VENDOR&accountId=ven_seed_001`。4. 重放相同请求。 |
| 预期结果 | 第一次返回 201`amount=3.500000``afterBalance=beforeBalance+3.500000`;供应商详情余额同步增加;重放返回同一流水 ID,不重复加钱。 |
| 数据检查 | 充值流水 `accountType=VENDOR`;幂等 key 成功记录唯一;审计存在 `module=recharges``action=vendor_recharge`。 |
| 安全检查 | 无 `recharges.manage` 返回 403;响应和审计不允许前端覆盖 `beforeBalance/afterBalance`。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| VEN-003-N1 | 不存在供应商充值 | `POST /vendors/not-found/recharges` | 404 或明确业务错误 |
| VEN-003-N2 | 幂等冲突 | 同 key 修改金额或备注 | 409 `IDEMPOTENCY_KEY_CONFLICT` |
| VEN-003-N3 | 金额非法 | `0`、负数、超 6 位小数、超长金额 | 400 `MONEY_INVALID`,不写流水 |
| VEN-003-N4 | key 非法 | 少于 8 位或含空格 | 400 `IDEMPOTENCY_KEY_INVALID` |
#### VGW-001 创建 IP 落地网关
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证基础 IP 落地网关创建、host/port/transport、CPS/并发、计费周期/费率和配置发布 outbox。 |
| 前置条件 | `vendor_ops_001` 已登录;供应商 `ven_seed_001` 存在。 |
| 测试数据 | `vendorId=ven_seed_001``name=Auto IP Vendor Gateway``authMode=IP``host=203.0.113.10``port=5060``transport=udp``cpsLimit=30``concurrencyLimit=300``billingCycleSec=6``cycleRate=0.012``landingCalleePrefix=86`。 |
| 步骤 | 1. 请求 `POST /api/v2/vendor-gateways`。2. 请求列表和详情。3. 查询 outbox 和审计。 |
| 预期结果 | 创建返回 201;host 小写/规范化;`minuteRate=cycleRate*60/billingCycleSec=0.120000`;状态默认 `ENABLED`;列表不返回 SIP HA1。 |
| 数据检查 | 写入 `vendor_gateway_config` outbox;审计存在 `module=vendor_gateways``action=create`;供应商 `gatewayCount` 增加。 |
| 安全检查 | `vendor_viewer_001` 创建返回 403;响应不包含 SIP 密钥或内部错误。 |
#### VGW-002 创建 SIP/MIXED 落地网关
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证落地侧 SIP Digest/MIXED 鉴权、HA1 生成、密码不回显和身份变更规则。 |
| 前置条件 | 供应商存在;SIP 用户名未重复。 |
| 测试数据 | `authMode=SIP_DIGEST``host=SIP.CARRIER.LOCAL``sipUsername=carrier-user``sipPassword=change-me-very-strong`。 |
| 步骤 | 1. 创建 SIP_DIGEST 落地网关。2. 查询详情和审计。3. 修改 host 或 sipUsername 但不传 `sipPassword`。4. 传新密码后修改。5. 切换为 IP。 |
| 预期结果 | 创建成功,host 归一化小写,`hasSipCredential=true`;响应/审计不含明文密码和 HA1;身份变更不传密码返回 400 `SIP_PASSWORD_REQUIRED`;切 IP 后 SIP 身份清空。 |
| 数据检查 | HA1 为 `md5(username:host:password)`;每次成功写操作生成 outbox。 |
| 安全检查 | 密码过短返回 `SIP_PASSWORD_WEAK`,但不回显密码。 |
#### VGW-003 禁呼时段校验与生效
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证落地网关可配置禁呼时段,并在呼叫选路时避开禁呼网关。 |
| 前置条件 | 落地网关 `vgw_primary_001` 存在;线路组有备用网关。 |
| 测试数据 | `forbiddenPeriods=[{weekdayMask:62,startTime:"23:00:00",endTime:"23:59:59"}]`。 |
| 步骤 | 1. PATCH 更新禁呼时段。2. 发布配置。3. 在禁呼时段内发起呼叫或模拟时间。4. 在非禁呼时段发起呼叫。 |
| 预期结果 | 禁呼时段内该网关不被选中,若有备用则转备用,否则呼叫按规则失败;非禁呼时段可选中。 |
| 数据检查 | Redis `vendor_gateway:{id}` 中包含 forbiddenPeriods;发布 manifest 版本更新。 |
| 安全检查 | 无权限更新返回 403;禁呼配置失败不应影响已发布版本。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| VGW-003-N1 | 非数组 | `forbiddenPeriods={}` | 400 `FORBIDDEN_PERIODS_INVALID` |
| VGW-003-N2 | 星期掩码非法 | `weekdayMask=0``128` | 400 `INTEGER_INVALID` |
| VGW-003-N3 | 时间格式非法 | `startTime=24:00:00``23:00` | 400 `TIME_INVALID` |
| VGW-003-N4 | 跨午夜时段 | `startTime=23:00:00``endTime=01:00:00` | 按产品规则允许或拒绝,行为需固定 |
#### VGW-004 编码与媒体能力配置
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证落地网关 codec 列表、优先级唯一性和发布快照。 |
| 前置条件 | 落地网关存在。 |
| 测试数据 | `codecs=[{codec:"PCMA",priority:1},{codec:"PCMU",priority:2},{codec:"G729",priority:3}]`。 |
| 步骤 | 1. PATCH 更新 codecs。2. 查询详情。3. 发布配置并检查 Redis。4. 发起呼叫检查 SDP/媒体协商是否符合预期。 |
| 预期结果 | API 返回 codec 大写且按优先级展示;发布快照包含 codecs;媒体协商优先使用配置内 codec。 |
| 数据检查 | codec 值唯一,priority 唯一;生成 outbox。 |
| 安全检查 | 非法 codec 不进入配置快照。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| VGW-004-N1 | codec 非数组 | `codecs="PCMA"` | 400 `CODECS_INVALID` |
| VGW-004-N2 | codec 非法字符 | `codec="PCMA!"` | 400 `CODEC_INVALID` |
| VGW-004-N3 | codec 重复 | 两条 `PCMA` | 400 `CODEC_DUPLICATE` |
| VGW-004-N4 | 优先级重复 | 两条 priority=1 | 400 `CODEC_PRIORITY_DUPLICATE` |
#### VGW-005 被叫前缀与主被叫改写
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证落地网关被叫前缀要求、主叫/被叫前缀改写规则和真实出局号码。 |
| 前置条件 | 落地网关已绑定线路组,T/UAS 可接收并记录 INVITE。 |
| 测试数据 | `landingCalleePrefix=86``prefixRules=[{direction:"CALLEE",matchPrefix:"00",replacePrefix:"+",priority:1},{direction:"CALLER",matchPrefix:"021",replacePrefix:"",priority:1}]`。 |
| 步骤 | 1. 更新落地网关前缀和改写规则。2. 发布配置。3. 发起被叫需转换的呼叫。4. 在落地侧抓取 INVITE 或检查 T 日志。5. 查询 CDR 中落地号码快照。 |
| 预期结果 | A 发往落地侧的 Request-URI/To 被叫符合 `landingCalleePrefix``CALLEE` 改写规则;主叫符合 `CALLER` 改写规则;CDR 保留原始号码和改写后号码快照。 |
| 数据检查 | Redis `vendor_gateway:{id}` 包含 prefixRules;同 direction 下 priority 唯一。 |
| 安全检查 | 非法前缀字符或重复优先级返回错误,不发布。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| VGW-005-N1 | 非法 landingCalleePrefix | `86+` 或中文 | 400 `LANDING_CALLEE_PREFIX_INVALID` |
| VGW-005-N2 | prefixRules 非数组 | `prefixRules={}` | 400 `PREFIX_RULES_INVALID` |
| VGW-005-N3 | direction 非法 | `direction=BOTH` | 400 `PREFIX_DIRECTION_INVALID` |
| VGW-005-N4 | matchPrefix 非法 | 含空格或中文 | 400 `PREFIX_INVALID` |
| VGW-005-N5 | 同方向优先级重复 | 两条 `CALLEE` priority=1 | 400 `PREFIX_RULE_PRIORITY_DUPLICATE` |
#### VGW-006 指定主叫号码池
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证落地网关可配置主叫改写号码池,呼叫时按权重/状态选择出局主叫。 |
| 前置条件 | 落地网关启用并参与线路组。 |
| 测试数据 | `callerRewritePool=[{caller:"02160010001",weight:80},{caller:"02160010002",weight:20},{caller:"02160010003",weight:10,status:"DISABLED"}]`。 |
| 步骤 | 1. 更新主叫号码池。2. 发布配置。3. 连续发起多通呼叫。4. 统计落地侧收到的 From/P-Asserted-Identity 或系统实际使用字段。 |
| 预期结果 | 只使用启用号码;权重越高被选择比例越高;禁用号码不被使用;无号码池时按默认主叫规则。 |
| 数据检查 | Redis `vendor_gateway:{id}:caller_rewrite_pool` 仅包含启用号码;manifest `callerRewriteCount` 正确。 |
| 安全检查 | 号码池最多 50 条,重复 caller 被拒绝。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| VGW-006-N1 | 非数组 | `callerRewritePool={}` | 400 `CALLER_REWRITE_POOL_INVALID` |
| VGW-006-N2 | 数量超限 | 超过 50 条 | 400 `CALLER_REWRITE_POOL_TOO_LARGE` |
| VGW-006-N3 | caller 非法 | 含 `+`、空格或中文 | 400 `CALLER_REWRITE_INVALID` |
| VGW-006-N4 | 权重非法 | `weight=0` 或超 100000 | 400 `INTEGER_INVALID` |
| VGW-006-N5 | caller 重复 | 两条相同 caller | 400 `CALLER_REWRITE_DUPLICATE` |
#### VGW-007 落地网关启停、删除与发布
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证落地网关启用、禁用、软删除会影响线路组可选成员和呼叫选路。 |
| 前置条件 | `vgw_primary_001``vgw_backup_001` 均在线并已发布。 |
| 测试数据 | 主备两个落地网关 ID。 |
| 步骤 | 1. 禁用 primary。2. 发布配置并发起呼叫。3. 启用 primary 后发布并发起呼叫。4. 删除未被保护的落地网关并发布。 |
| 预期结果 | primary 禁用后呼叫转 backup;恢复后按优先级选择 primary;删除后快照不包含该网关可用路由;列表默认不展示软删除记录。 |
| 数据检查 | 启停/删除生成 `vendor_gateway_config` outbox 和审计;历史 CDR 成本快照不被破坏。 |
| 安全检查 | 无 `vendor_gateways.manage` 返回 403;被线路组引用的网关删除行为按产品规则受控。 |
#### LLG-001 创建线路组并添加成员
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证线路组创建、成员添加、并发汇总和配置发布。 |
| 前置条件 | 至少存在两个启用落地网关。 |
| 测试数据 | 线路组 `name=Auto Line Group LLG001`;成员 A`vendorGatewayId=vgw_primary_001``priority=1``weight=2``concurrencyCap=60`;成员 B`vendorGatewayId=vgw_backup_001``priority=2``weight=1``concurrencyCap=40`。 |
| 步骤 | 1. 请求 `POST /api/v2/landing-line-groups`。2. 添加两个成员。3. 请求详情和列表。4. 发布配置并检查 Redis。 |
| 预期结果 | 线路组创建成功;`itemCount=2``enabledItemCount=2``concurrencyCapSum=100``gatewayConcurrencyLimitSum` 等于启用成员网关并发限制合计;Redis 写入 `line_group:{id}``line_group:{id}:items`。 |
| 数据检查 | 每次写操作生成 `line_group_config` outbox;审计包含 `create``item_create`。 |
| 安全检查 | 只读用户可查看不可写,写返回 403。 |
#### LLG-002 线路组成员更新与重排
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证线路组成员优先级、权重、并发上限和状态影响路由顺序。 |
| 前置条件 | 线路组 `llg_vendor_seed_001` 含 primary 和 backup 两个成员。 |
| 测试数据 | 将 backup 重排到第一位;将 primary `status=DISABLED`;调整 `concurrencyCap=20``weight=5`。 |
| 步骤 | 1. 请求 `POST /api/v2/landing-line-groups/:id/items/reorder`。2. PATCH 更新成员状态/权重/并发。3. 发布配置。4. 发起呼叫确认选路。 |
| 预期结果 | 详情中成员顺序按新 priority;禁用成员不计入 `enabledItemCount` 和并发汇总;呼叫按新顺序和状态选路。 |
| 数据检查 | Redis `line_group:{id}:items` 顺序正确;生成 `item_update``item_reorder` 审计和 outbox。 |
| 安全检查 | 重排必须使用同一线路组完整成员集合或按产品规则拒绝。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| LLG-002-N1 | 权重非法 | `weight=0``1001` | 400 `INTEGER_INVALID` |
| LLG-002-N2 | 并发上限非法 | `concurrencyCap=-1``100001` | 400 `INTEGER_INVALID` |
| LLG-002-N3 | priority 非法 | `priority=0``10001` | 400 `INTEGER_INVALID` |
| LLG-002-N4 | 重排空列表 | `itemIds=[]` | 400 `LINE_GROUP_ITEM_IDS_INVALID` |
| LLG-002-N5 | 删除成员 | `DELETE /landing-line-groups/:id/items/:gatewayId` | 成员移除,汇总和发布快照同步更新 |
#### LLG-003 删除被引用线路组
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证线路组被客户网关或策略引用时不能破坏性删除。 |
| 前置条件 | 客户网关 `cgw_ip_seed_001` 或策略引用 `llg_vendor_seed_001`。 |
| 测试数据 | 被引用线路组 ID;未引用线路组 ID。 |
| 步骤 | 1. 删除被引用线路组。2. 删除未引用线路组。3. 查询客户网关/策略引用关系和配置快照。 |
| 预期结果 | 被引用线路组删除被拒绝或只能禁用,规则固定;未引用线路组可软删除;不会产生悬空 `lineGroupId`。 |
| 数据检查 | 删除成功设置软删除标记;被拒绝时不修改引用;审计和 outbox 按结果记录。 |
| 安全检查 | 无权限删除返回 403;错误不泄露内部约束名。 |
#### LLG-004 真实呼叫选路与故障转移
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证客户策略指向线路组后,OpenSIPS 能按线路组成员优先级、状态、禁呼和并发选择落地网关。 |
| 前置条件 | A/B/T 联调环境 active;客户网关策略指向 `llg_vendor_seed_001`primary 和 backup 均可被 T/UAS 验证。 |
| 测试数据 | 一通正常呼叫;一组 primary 禁用或禁呼时段内呼叫;一组并发接近上限呼叫。 |
| 步骤 | 1. 发布当前配置。2. 发起正常呼叫,确认落到 primary。3. 禁用 primary 或设置当前禁呼,发布后再呼叫。4. 配置 primary 并发上限较低,发起多路并发。5. 查询 CDR、录音和落地网关成本。 |
| 预期结果 | 正常呼叫选 primaryprimary 不可用时转 backup;并发达到上限后新呼叫转备用或被拒绝;CDR 记录实际落地网关、供应商成本、失败原因。 |
| 数据检查 | `raw_cdrs/rated_cdrs` 中 vendorGatewayId、lineGroupId、cost 字段正确;HOMER 可看到出局 INVITE 目标。 |
| 安全检查 | 故障转移不绕过客户鉴权、余额检查、业务前缀和主叫限制。 |
字段校验矩阵:
| 对象 | 字段 | 合法规则 | 非法示例 | 期望错误 |
| --- | --- | --- | --- | --- |
| 供应商 | `name` | 必填,1-120 字符,唯一 | 空、超长、重复 | `VALIDATION_ERROR` 或 409 |
| 供应商 | `creditLimit` | 非负,最多 14 位整数、6 位小数 | `-1``1.1234567` | `MONEY_INVALID` |
| 落地网关 | `authMode` | `IP``SIP_DIGEST``MIXED` | `PASSWORD` | `AUTH_MODE_INVALID` |
| 落地网关 | `host` | IP 或小写 hostname 规则 | `bad_host!` | `HOST_INVALID` |
| 落地网关 | `port` | 1-65535 整数 | `0``65536` | `INTEGER_INVALID` |
| 落地网关 | `transport` | `udp``tcp``tls` | `ws` | `TRANSPORT_INVALID` |
| 落地网关 | `sipUsername` | SIP/MIXED 必填,安全字符 | `carrier user` | `SIP_USERNAME_INVALID` |
| 落地网关 | `sipPassword` | SIP/MIXED 创建或身份变更必填,至少 12 字符 | `short` | `SIP_PASSWORD_WEAK``SIP_PASSWORD_REQUIRED` |
| 落地网关 | `cpsLimit` | 1-10000 整数,只允许数字 | `0``10001``20 CPS` | `INTEGER_INVALID` |
| 落地网关 | `concurrencyLimit` | 0-100000 整数,0 表示不限 | `-1``100001` | `INTEGER_INVALID` |
| 落地网关 | `billingCycleSec` | 1-60 整数 | `0``61` | `INTEGER_INVALID` |
| 落地网关 | `cycleRate` | 非负,最多 14 位整数、6 位小数 | `-1``1.1234567` | `MONEY_INVALID` |
| 落地网关 | `landingCalleePrefix` | 可空,字母数字 | `86+` | `LANDING_CALLEE_PREFIX_INVALID` |
| 落地网关 | `forbiddenPeriods` | 数组,weekdayMask 1-127,时间 HH:mm:ss | 非数组、`24:00:00` | `FORBIDDEN_PERIODS_INVALID``TIME_INVALID` |
| 落地网关 | `codecs` | 数组,codec/priority 唯一 | 重复 codec 或 priority | `CODEC_DUPLICATE``CODEC_PRIORITY_DUPLICATE` |
| 落地网关 | `prefixRules` | 数组,direction 为 CALLER/CALLEE,同方向 priority 唯一 | `BOTH`、重复优先级 | `PREFIX_DIRECTION_INVALID``PREFIX_RULE_PRIORITY_DUPLICATE` |
| 落地网关 | `callerRewritePool` | 最多 50 条,caller 唯一,weight 1-100000 | 重复 caller、weight=0 | `CALLER_REWRITE_DUPLICATE``INTEGER_INVALID` |
| 线路组 | `name` | 必填,1-120 字符 | 空、超长 | `VALIDATION_ERROR` |
| 线路组成员 | `priority` | 1-10000 整数 | `0``10001` | `INTEGER_INVALID` |
| 线路组成员 | `weight` | 1-1000 整数 | `0``1001` | `INTEGER_INVALID` |
| 线路组成员 | `concurrencyCap` | 0-100000 整数,0 表示不限 | `-1``100001` | `INTEGER_INVALID` |
### 8.5 SIP 呼叫、CDR 与计费
本节覆盖客户接入、SIP 注册/鉴权、业务前缀识别、线路组出局、Redis Stream CDR、CDR Worker 计费、客户余额扣减、失败话单、幂等、号码归属地和话单查询。三机端到端用例默认在 A/B/T 联调环境执行。
公共前置数据:
- 客户 `cus_call_seed_001`:状态 `ENABLED``billingMode=PREPAID`,余额充足,授信和最低余额按测试场景设置。
- IP 客户网关 `cgw_call_ip_001``authMode=IP`T 源 IP 已在 `sourceIps`,启用,绑定业务前缀 `671` 和线路组 `llg_call_seed_001`
- SIP 客户网关 `cgw_call_sip_001``authMode=SIP_DIGEST`,账号密码已知,启用,绑定线路组。
- 线路组 `llg_call_seed_001`:启用,包含 `vgw_call_primary_001``vgw_call_backup_001`
- 落地网关 `vgw_call_primary_001`:启用,计费周期 `6` 秒,费率 `0.012000`T/UAS 可接收。
- 业务前缀 `bp_671_seed``prefix=671`,启用。
- 号码库包含 `1380013` 号段,归属地和运营商字段可预测。
- Config Publisher 已成功发布,A 侧 OpenSIPS 使用最新 `cfg:active_version`
#### CALL-001 IP 客户成功呼叫
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证 IP 鉴权客户从 T 发起呼叫时,A 能识别客户网关、匹配业务前缀、选择线路组落地,并生成完整 CDR。 |
| 前置条件 | A/B/T 服务 active`cgw_call_ip_001` 启用;T 源 IP 位于网关 `sourceIps`;落地 UAS 可接听。 |
| 测试数据 | 主叫 `02160010001`;被叫 `67113800138000`;预期真实被叫 `13800138000`Call-ID 使用唯一值 `call-001-{timestamp}`。 |
| 步骤 | 1. 从 T 使用 IP 客户脚本发起呼叫。2. 等待 100 Trying/Giving it a try、180/183 或 200 OK。3. 持话 6-10 秒后 BYE。4. 在 B 查询 Redis Stream、raw CDR、rated CDR、录音记录。5. 在 HOMER 查询 Call-ID。 |
| 预期结果 | SIP 返回 100 和 200 OKBYE 返回 200 OK;A 出局到预期落地网关;B 生成 1 条 raw CDR 和 1 条 rated CDRCDR 包含 customerId、customerGatewayId、lineGroupId、vendorGatewayId、businessPrefix、rawCallee、callee、landingCaller、landingCallee。 |
| 数据检查 | raw CDR `ratingStatus=RATED`rated CDR `billSec`、费用和余额扣减正确;`configVersion` 为当前 active versionHOMER 中可查到入局和出局信令。 |
| 安全检查 | 非白名单源 IP 不应复用该客户网关;CDR payload 不包含 SIP 密码或内部密钥。 |
#### CALL-002 SIP Digest 注册和呼叫成功
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证 SIP Digest 客户可使用正确账号注册/鉴权并完成呼叫。 |
| 前置条件 | `cgw_call_sip_001` 启用;SIP 用户名、域、密码与 A 热路径 HA1 一致;T 脚本支持 REGISTER/INVITE Digest。 |
| 测试数据 | SIP 用户名 `sip-cus-001`;域 `sip.example.local`;正确密码;被叫 `67113800138000`。 |
| 步骤 | 1. T 使用正确账号执行 REGISTER。2. 使用同一账号发起 INVITE。3. 持话后挂断。4. 查询 CDR 和客户网关识别结果。 |
| 预期结果 | REGISTER 成功或 INVITE Digest 鉴权成功;呼叫成功;CDR 归属到 `cgw_call_sip_001`source_ip 记录 T IP;业务前缀和落地路由正常。 |
| 数据检查 | raw/rated CDR 各 1 条;客户余额扣减一次;HOMER 可看到 401 challenge 后的成功鉴权流程。 |
| 安全检查 | CDR、日志、审计不记录明文 SIP 密码。 |
#### CALL-003 SIP Digest 密码错误
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证错误 SIP 密码不能注册或呼叫,且不会产生成功计费。 |
| 前置条件 | SIP 网关启用。 |
| 测试数据 | 正确用户名和域;错误密码 `WrongDigestPassword-003`。 |
| 步骤 | 1. T 使用错误密码执行 REGISTER。2. 使用错误密码发起 INVITE。3. 查询 A 日志、HOMER、CDR、客户余额。 |
| 预期结果 | REGISTER/INVITE 被 401/403 拒绝或无成功路由;不产生 rated CDR;客户余额不变;如产生失败 raw CDR,应标记 `SKIPPED` 且费用为 0。 |
| 数据检查 | 没有成功通话录音;失败原因可追踪;不产生余额扣减。 |
| 安全检查 | 响应不暴露正确 HA1、密码规则或账号存在性细节。 |
#### CALL-004 客户余额不足或低于最低余额
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证预付费客户余额不足时,热路径阻止新呼叫或按授信/最低余额规则保护。 |
| 前置条件 | 客户 `cus_low_balance_001` 预付费,余额、授信、最低余额已设置到触发保护条件;配置已发布。 |
| 测试数据 | 余额 `0.000000` 或低于 `minBalance`;被叫 `67113800138000`。 |
| 步骤 | 1. 发起呼叫。2. 查询 SIP 响应。3. 查询 raw/rated CDR、客户余额和 A 日志。 |
| 预期结果 | 呼叫被拒绝,建议返回明确 402/403/503 类业务失败响应,具体以 OpenSIPS 配置为准;不产生 rated CDR;余额不被错误扣减。 |
| 数据检查 | 如生成 raw CDR,`ratingStatus=SKIPPED``customerFee=0`;失败原因包含余额不足或策略拒绝。 |
| 安全检查 | 余额不足响应不泄露客户余额具体值给 SIP 对端。 |
#### CALL-005 禁用客户、客户网关或业务前缀呼叫
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证禁用状态会进入热路径并阻止呼叫。 |
| 前置条件 | 分别准备禁用客户、禁用客户网关、禁用业务前缀三种配置,并发布。 |
| 测试数据 | 同一主叫和被叫 `67113800138000`。 |
| 步骤 | 1. 禁用客户后发布并呼叫。2. 恢复客户,禁用客户网关后发布并呼叫。3. 恢复网关,禁用业务前缀后发布并呼叫。4. 查询 CDR 和日志。 |
| 预期结果 | 三种禁用均导致呼叫被拒绝或无法匹配;不产生成功 rated CDR;失败原因可追踪。 |
| 数据检查 | Redis active snapshot 不包含禁用业务前缀;禁用网关不应作为可用入口;客户余额不变。 |
| 安全检查 | 禁用对象恢复后必须重新发布才能生效,不应使用陈旧配置误放行。 |
#### CALL-006 落地网关禁用故障转移
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证优先级最高落地网关不可用时,线路组可转到下一可用成员。 |
| 前置条件 | 线路组包含 primary 和 backup;两者都可被 T/UAS 接听。 |
| 测试数据 | 禁用 `vgw_call_primary_001`backup `vgw_call_backup_001` 启用。 |
| 步骤 | 1. 正常呼叫确认 primary 被选中。2. 禁用 primary 并发布。3. 再次呼叫。4. 查询 CDR、HOMER 和落地侧日志。 |
| 预期结果 | 禁用前 CDR 记录 primary;禁用后新呼叫记录 backup;SIP 呼叫成功;供应商成本按 backup 费率计算。 |
| 数据检查 | CDR `vendorGatewayId``vendorId``lineGroupId` 正确;配置版本变化可追踪。 |
| 安全检查 | 故障转移不绕过客户鉴权、主叫匹配、业务前缀匹配和余额保护。 |
#### CALL-007 主叫/被叫策略不匹配
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证客户网关主叫前缀、业务前缀和策略匹配失败时不会误路由。 |
| 前置条件 | 客户网关设置 `callerMatchMode=PREFIXES` 且只允许 `021``calleeMatchMode=BUSINESS_PREFIXES` 只允许 `671`;策略设置特定主被叫。 |
| 测试数据 | 主叫 `01060010001`;被叫 `67213800138000` 或不满足策略的号码。 |
| 步骤 | 1. 发起主叫不匹配呼叫。2. 发起业务前缀不匹配呼叫。3. 发起策略不匹配呼叫。4. 查询 CDR 和日志。 |
| 预期结果 | 呼叫被拒绝或进入明确失败路径;不产生 rated CDRraw CDR 如存在,placeholder ID 归一为 null,失败原因可追踪。 |
| 数据检查 | `customer_gateway_policy_id=no_policy_match` 等占位值入库时转换为 null;余额不扣减。 |
| 安全检查 | 不匹配失败不暴露可用前缀或策略详情。 |
#### CDR-001 成功话单计费
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证成功 2xx 通话按落地网关计费周期向上取整,并扣减客户余额。 |
| 前置条件 | 落地网关 `billingCycleSec=6``cycleRate=0.012000`;客户余额 `10.000000`。 |
| 测试数据 | CDR `duration_sec=28``sip_code=200`。 |
| 步骤 | 1. 通过真实呼叫或注入 CDR Stream 生成 28 秒成功事件。2. 等待 CDR Worker 消费。3. 查询 raw/rated CDR 和客户余额。 |
| 预期结果 | `cycles=5``billSec=30``customerFee=0.060000``vendorCost=0.060000``grossProfit=0.000000`;客户余额变为 `9.940000`。 |
| 数据检查 | raw CDR `ratingStatus=RATED`rated CDR `customerRate.source=S23_MINIMAL_MIRROR_VENDOR_RATE``vendorRate.source=vendor_gateway`;客户 `updatedBy=worker-cdr`version 增加。 |
| 安全检查 | 计费不写充值流水;余额扣减在数据库事务内完成。 |
计费边界矩阵:
| durationSec | billingCycleSec | cycleRate | 期望 billSec | 期望金额 |
| --- | --- | --- | --- | --- |
| 1 | 6 | 0.012000 | 6 | 0.012000 |
| 6 | 6 | 0.012000 | 6 | 0.012000 |
| 7 | 6 | 0.012000 | 12 | 0.024000 |
| 28 | 6 | 0.012000 | 30 | 0.060000 |
| 60 | 60 | 0.120000 | 60 | 0.120000 |
#### CDR-002 失败、拒接和零时长话单
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证非 2xx 或零时长事件只落 raw CDR,不扣余额。 |
| 前置条件 | CDR Worker active;客户余额已知。 |
| 测试数据 | `sip_code=486``503``404``duration_sec=0``answered_at=none`。 |
| 步骤 | 1. 模拟忙线、拒接、无路由或注入失败 CDR。2. 等待 Worker 消费。3. 查询 raw/rated CDR 和余额。 |
| 预期结果 | raw CDR 创建成功;`ratingStatus=SKIPPED`;不创建 rated CDR 或 rated 金额为 0,按实现固定;客户余额不变。 |
| 数据检查 | `hangupReason``sipCode``startedAt/endedAt` 保存;失败 CDR 可在话单中心筛选。 |
| 安全检查 | 失败话单不触发录音播放越权,不泄露内部路由细节。 |
#### CDR-003 重复 CDR 幂等
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证同一 `event_id` 重复进入 Redis Stream 时,raw/rated CDR 和余额扣减只发生一次。 |
| 前置条件 | Redis Stream 和 CDR Worker active。 |
| 测试数据 | 两条完全相同 CDR,`event_id=evt-cdr-003-001``duration_sec=28``sip_code=200`。 |
| 步骤 | 1. 向 `stream:cdr_payload` 写入第一条事件。2. 写入重复事件。3. 等待 Worker 消费。4. 查询 raw CDR、rated CDR、客户余额、Redis ack/lock。 |
| 预期结果 | 只有 1 条 raw CDR 和 1 条 rated CDR;第二条处理结果为 duplicate 或被 Redis 幂等锁 ACK;客户余额只扣一次。 |
| 数据检查 | `raw_cdrs.event_id` 唯一;`rated_cdrs.raw_cdr_id` 唯一;Redis `lock:cdr:{event_id}` 在 TTL 内存在。 |
| 安全检查 | 重复事件不进入 deadletter;重复处理不产生告警噪音。 |
#### CDR-004 CDR Stream 格式校验和死信
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证非法 CDR Stream payload 不会阻塞队列,并进入 deadletter。 |
| 前置条件 | CDR consumer group 已创建。 |
| 测试数据 | 缺少 `event_id``schema_version=999``duration_sec=-1``sip_code=999`。 |
| 步骤 | 1. 向 Redis Stream 写入非法事件。2. 执行 CDR Worker 消费。3. 查询 deadletter stream、ACK 和主 stream pending。 |
| 预期结果 | 非法事件写入 `stream:cdr_payload:deadletter` 或配置的 deadletter stream;原消息 ACK;主消费组 pending 不增长。 |
| 数据检查 | deadletter 包含 `original_redis_id``event_id``call_id``error``payload``deadlettered_at`。 |
| 安全检查 | deadletter payload 不包含密码或访问令牌;错误信息截断且可读。 |
#### CDR-005 Worker 可恢复错误重试
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证 MySQL 短故障等可恢复错误不会 ACK 原消息,也不会误入 deadletter。 |
| 前置条件 | CDR Worker 正常;可临时停止 MySQL 或注入 `CdrRetryableError`。 |
| 测试数据 | 一条成功呼叫 CDR。 |
| 步骤 | 1. 停止 MySQL 或制造数据库连接失败。2. 写入 CDR 事件并执行 Worker。3. 检查 Redis pending 和 deadletter。4. 恢复 MySQL。5. 执行 pending reclaim/重试。 |
| 预期结果 | 故障期间消息保留 pending,幂等锁释放;不 ACK、不 deadletter;恢复后重试成功,raw/rated CDR 入库。 |
| 数据检查 | pending 从 1 回到 0deadletter 无新增;余额只扣一次。 |
| 安全检查 | 可恢复错误日志不包含数据库密码。 |
#### CDR-006 缺失计费对象
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证 CDR 中缺少客户或落地网关等关键计费对象时,系统不会错误扣费。 |
| 前置条件 | CDR Worker active。 |
| 测试数据 | `customer_id=unknown``vendor_gateway_id=unknown``customer_gateway_policy_id=no_policy_match``vendor_gateway_id` 指向不存在网关。 |
| 步骤 | 1. 分别注入上述 CDR。2. 等待 Worker 消费。3. 查询 raw/rated CDR 和余额。 |
| 预期结果 | placeholder ID 入库时归一为 null;缺少 billable customer/vendor gateway 时 raw 标记 `SKIPPED``FAILED`,按实现固定;不扣余额。 |
| 数据检查 | `none``unknown``no_active_version``no_policy_match``single_gateway` 等占位不作为外键写入;失败原因可定位。 |
| 安全检查 | 不因无效外键导致 Worker 崩溃或队列停滞。 |
#### CDR-007 号码归属地识别
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证号码库快照参与呼叫归属地和运营商识别,并写入 CDR。 |
| 前置条件 | 号码库已导入并发布;包含手机号段 `1380013` 和区号/运营商规则。 |
| 测试数据 | 被叫 `67113800138000`;预期 `normalized_callee=13800138000`。 |
| 步骤 | 1. 发起成功呼叫。2. 查询 raw CDR payload 和话单列表。3. 使用 `cityCode``carrier` 筛选话单。 |
| 预期结果 | CDR 包含 `calleeCityCode``calleeCityName``calleeProvinceName``calleeOperator``calleeNumberType`;话单筛选能命中该记录。 |
| 数据检查 | 手机号、固话、国际号码分别识别为 `MOBILE``LANDLINE``INTERNATIONAL``UNKNOWN`;未知号码不应报错。 |
| 安全检查 | 号码库未命中时降级为 UNKNOWN,不影响话单入库。 |
#### CDR-008 话单查询和详情
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证话单中心 API 查询条件、分页、详情和权限。 |
| 前置条件 | 已存在多条不同主叫、被叫、客户网关、落地网关、城市、运营商和时间的话单。 |
| 测试数据 | `caller=02160010001``callee=13800138000``customerGatewayId=cgw_call_ip_001``vendorGatewayId=vgw_call_primary_001``cityCode=340100``carrier=MOBILE``take=20``skip=0`。 |
| 步骤 | 1. 使用有 `cdr.view` 权限账号请求 `GET /api/v2/cdrs`。2. 分别组合号码、网关、城市、运营商和时间范围查询。3. 请求 `GET /api/v2/cdrs/:id`。4. 使用无权限账号请求。 |
| 预期结果 | 有权限查询返回匹配数据和分页信息;详情包含 raw/rated、录音、业务前缀、落地、费用等字段;无权限返回 403。 |
| 数据检查 | 查询默认 `take=100`,最大 500`skip` 最大 1000000;时间范围按 startedAt 筛选。 |
| 安全检查 | 非法查询不触发 SQL 注入;无权限账号不能看到话单。 |
补充负向用例:
| 子 ID | 场景 | 步骤 | 期望结果 |
| --- | --- | --- | --- |
| CDR-008-N1 | 非法运营商 | `carrier=CMCC` | 400 `CARRIER_INVALID` |
| CDR-008-N2 | 时间范围反转 | `startedFrom > startedTo` | 400 `TIME_RANGE_INVALID` |
| CDR-008-N3 | 非法日期 | `startedFrom=not-a-date` | 400 `TIME_RANGE_INVALID` |
| CDR-008-N4 | 分页非法 | `take=0``take=501``skip=-1` | 400 `QUERY_INVALID` |
| CDR-008-N5 | 查询字段超长 | caller/callee 超 64 或 gatewayId 超 32 | 400 `QUERY_INVALID` |
#### CDR-009 长 event_id 存储兼容
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证超过数据库 `event_id` 长度限制的 CDR event_id 会稳定压缩存储,不导致入库失败。 |
| 前置条件 | CDR Worker active。 |
| 测试数据 | 长度超过 64 的 `event_id`,其余字段为成功计费事件。 |
| 步骤 | 1. 注入长 event_id CDR。2. 等待 Worker 消费。3. 查询 raw CDR `eventId` 和 payload。4. 重复注入相同长 event_id。 |
| 预期结果 | raw CDR 入库成功;存储 eventId 被稳定截断加 hash 后缀;payload 保留原始 event_id;重复事件仍按同一 event_id 幂等处理。 |
| 数据检查 | 不进入 deadletter;余额只扣一次。 |
| 安全检查 | hash 截断逻辑稳定,不泄露额外敏感信息。 |
#### CDR-010 并发计费余额一致性
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证多条成功 CDR 并发处理时,客户余额通过事务锁串行扣减,不丢扣、不重复扣。 |
| 前置条件 | 客户余额足够;CDR Worker 可并发或批量消费。 |
| 测试数据 | 同一客户 5-12 条不同 `event_id` 成功 CDR,每条预期费用 `0.012000` 或固定值。 |
| 步骤 | 1. 批量写入多条 CDR。2. 启动 Worker 消费。3. 查询 rated CDR 数量和客户余额。4. 检查 Redis pending/deadletter。 |
| 预期结果 | rated CDR 数量等于成功事件数;余额减少等于费用总和;pending=0deadletter=0。 |
| 数据检查 | 每条 raw CDR event_id 唯一;客户 version 增加符合更新次数或按实现固定。 |
| 安全检查 | 并发下不产生负外键、重复 rated 或事务死锁长时间阻塞。 |
### 8.6 录音、播放与质检
本节覆盖 RTPEngine 录音 ready 文件、Recording Worker 搬运、校验、源文件清理、录音播放鉴权、Nginx X-Accel/Range、质检抽样、评分保存和审计。后端接口统一以 `/api/v2` 为前缀。
公共前置数据:
- 录音查看账号 `recording_viewer_001`:包含 `cdr.view``recordings.play`
- 质检账号 `quality_ops_001`:包含 `quality.view``quality.manage``recordings.play`
- 只读质检账号 `quality_viewer_001`:包含 `quality.view`,不包含 `quality.manage``recordings.play`
- 至少 1 条成功通话 CDR,关联 READY 录音 `rec_ready_seed_001`storageKey 指向 B 本地录音数据盘。
- 至少 1 条 PENDING 录音 `rec_pending_seed_001`,用于播放和质检不可用状态。
- B 侧 `/dev/shm/voip_rec` 可生成 ready 文件,`/data/recordings` 可写;Worker 配置为本地源模式。
#### REC-001 录音搬运成功
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证 Recording Worker 从 B 本机读取 ready 文件,校验大小/hash 后持久化,并安全删除 tmpfs 源文件。 |
| 前置条件 | B 侧存在测试录音文件和 ready 标记;本机 finalizer 与 Recording Worker active;关联 CDR/callId 可匹配。 |
| 测试数据 | 文件名包含 Call-ID;ready 内容包含相对路径和 size;文件大小固定,例如 55758 bytes。 |
| 步骤 | 1. 在 B 侧生成测试录音,由 finalizer 形成 ready 文件。2. 启动或触发 Recording Worker。3. 查询 B 持久化文件、数据库 recordings 表和 Worker 日志。4. 检查 B ready 源文件。 |
| 预期结果 | B 持久化文件存在,大小/hash 与 ready 源文件一致;recording 状态为 `READY`storageKey 为安全相对路径;B ready 源文件在校验成功后删除;日志记录成功搬运,进程树中无录音 SSH。 |
| 数据检查 | recordings 记录包含 callId、rawCdrId 或关联键、fileSize、checksum、storageKey、readyAt;状态从 PENDING 变为 READY。 |
| 安全检查 | Worker 只处理配置目录下文件;不会跟随任意绝对路径或 `../` 路径。 |
#### REC-002 本地校验失败保留源文件
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证复制后大小/hash 不一致时不删除 B 本机 ready 源文件,避免录音丢失。 |
| 前置条件 | 可构造错误 ready size、截断文件或模拟 B 写入失败。 |
| 测试数据 | ready size 与实际文件大小不一致。 |
| 步骤 | 1. 生成 size 不一致的 ready 文件。2. 执行 Recording Worker。3. 查询 B ready/持久化文件和 recordings 状态。 |
| 预期结果 | Worker 标记失败或保持待重试;B ready 源文件保留;持久化目录不产生有效成品且数据库不误标 READY;错误日志可定位。 |
| 数据检查 | recordings 不应被误标记 READY;如生成 FAILED 状态,应记录失败原因和重试信息。 |
| 安全检查 | 失败日志不泄露数据库连接凭据;Worker 不访问配置目录之外的文件。 |
#### REC-003 播放接口鉴权与权限
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证录音播放必须登录且具备播放权限。 |
| 前置条件 | 存在 READY 录音 `rec_ready_seed_001`。 |
| 测试数据 | 未登录请求;有 `quality.view` 但无 `recordings.play` 的账号;有 `recordings.play` 的账号。 |
| 步骤 | 1. 未登录请求录音播放接口。2. 使用 `quality_viewer_001` 请求播放。3. 使用 `recording_viewer_001` 请求播放。 |
| 预期结果 | 未登录返回 401 `AUTH_REQUIRED`;无播放权限返回 403;有权限返回 200/206 或 X-Accel 跳转响应,按部署方式固定。 |
| 数据检查 | 无权限请求不写播放成功审计;有权限播放可按审计策略记录访问。 |
| 安全检查 | 不允许通过猜 ID 或 storageKey 绕过权限。 |
#### REC-004 录音路径穿越防护
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证录音 storageKey 和播放接口防止路径穿越、绝对路径和特殊字符读取。 |
| 前置条件 | 有权限账号已登录。 |
| 测试数据 | 伪造 ID 或 storageKey`../etc/passwd``..\\windows\\win.ini``/etc/passwd`、空值、超长 key。 |
| 步骤 | 1. 直接请求伪造录音 ID。2. 构造数据库中异常 storageKey 的测试记录并请求播放。3. 检查响应和日志。 |
| 预期结果 | 返回 404 或安全错误;不返回目标文件内容;Nginx internal path 不被构造到录音根目录之外。 |
| 数据检查 | 安全拒绝不改变 recording 状态。 |
| 安全检查 | 响应不泄露服务器真实路径和目录结构。 |
#### REC-005 Range 播放和浏览器播放
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证录音支持浏览器播放、Range 请求和常见播放器拖动。 |
| 前置条件 | READY 录音文件存在,Nginx/X-Accel 配置已启用。 |
| 测试数据 | `Range: bytes=0-1023`,完整播放请求,超出范围请求。 |
| 步骤 | 1. 使用浏览器点击播放。2. 使用 curl 请求 Range。3. 请求完整文件。4. 请求超出文件大小的 Range。 |
| 预期结果 | 合法 Range 返回 206`Content-Range``Content-Length` 正确;完整请求返回 200 或内部跳转;浏览器可播放;超范围返回 416 或合理错误。 |
| 数据检查 | 播放不改变录音文件;访问日志可追踪 recordingId。 |
| 安全检查 | 响应头不暴露真实磁盘路径;只允许内部 X-Accel 路径。 |
#### QA-001 稳定抽样
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证同一抽样规则和录音在多次刷新中抽样结果稳定。 |
| 前置条件 | 存在多个 READY 录音;有启用抽样规则。 |
| 测试数据 | 抽样比例 0%、10%、100%;按客户或全局规则。 |
| 步骤 | 1. 创建或更新抽样规则。2. 多次请求质检列表。3. 调整规则 ID 或比例后再次请求。 |
| 预期结果 | 同一规则下同一 recording 抽样命中结果稳定;0% 不命中,100% 全命中;规则变化后可重新计算。 |
| 数据检查 | 列表返回 samplingScore、reviewStatus 或等价字段;不会随机抖动。 |
| 安全检查 | 无 `quality.view` 不能查看质检列表。 |
#### QA-002 保存质检评分
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证质检人员可保存评分、结果、标签和备注,并更新录音 review 状态。 |
| 前置条件 | `quality_ops_001` 已登录;录音 READY 且可质检。 |
| 测试数据 | `score=88.5``result=ISSUE``issueTags=["noise","script"]``remark=QA-002 review`。 |
| 步骤 | 1. 打开质检中心并选择录音。2. 播放录音。3. 提交评分、结果、标签、备注。4. 刷新列表和详情。5. 查询审计。 |
| 预期结果 | 保存成功;录音 review 状态更新;列表展示评分、结果、质检人和时间;审计写入 `quality` 模块成功记录。 |
| 数据检查 | 重复保存按更新逻辑覆盖或产生历史版本,规则需固定;录音文件不被修改。 |
| 安全检查 | 无 `quality.manage` 保存返回 403;无 `recordings.play` 不应播放但可否评分按产品规则固定。 |
#### QA-003 评分边界校验
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证评分、结果枚举、标签和备注长度校验。 |
| 前置条件 | 有可质检录音。 |
| 测试数据 | 合法评分 `0``100``88.55`;非法评分 `-1``100.01``abc`;非法 result;超长备注。 |
| 步骤 | 1. 分别提交合法和非法评分。2. 提交非法 result。3. 提交超长标签/备注。 |
| 预期结果 | 合法评分保存成功;非法评分返回 400;非法 result 返回校验错误;超长字段被拒绝。 |
| 数据检查 | 非法提交不改变上一条质检结果。 |
| 安全检查 | 错误响应不包含堆栈。 |
#### QA-004 质检列表筛选和连续播放
| 字段 | 内容 |
| --- | --- |
| 优先级 | P2 |
| 目的 | 验证质检中心按客户、时间、结果、待质检状态筛选,并支持连续播放工作流。 |
| 前置条件 | 多个客户、多条 READY 录音、部分已质检。 |
| 测试数据 | 客户 ID、时间范围、`reviewStatus=PENDING/REVIEWED`。 |
| 步骤 | 1. 按条件筛选录音。2. 打开第一条并播放。3. 保存评分后跳转下一条。4. 刷新列表。 |
| 预期结果 | 筛选结果正确;连续播放不跳过未质检录音;保存后列表状态同步。 |
| 数据检查 | 待质检数量减少;已质检数量增加。 |
| 安全检查 | 无权限账号不能看到不该访问的录音。 |
### 8.7 Dashboard、当前通话与操作日志
本节覆盖 Dashboard 聚合、上海时区日界、趋势桶、当前通话 OpenSIPS MI、挂断安全、审计查询和敏感字段脱敏。
公共前置数据:
- Dashboard 账号 `dashboard_viewer_001`:包含 `dashboard.view`
- 当前通话账号 `active_calls_ops_001`:包含 `active_calls.view``active_calls.manage`
- 审计账号 `audit_viewer_001`:包含 `audit.view`
- 数据库中存在今天、昨天和跨上海时区边界的 CDR、录音、质检、充值和审计数据。
- A 侧 OpenSIPS MI 可访问,至少可模拟或产生 1 条进行中 Dialog。
#### DASH-001 今日指标聚合
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证 Dashboard 今日通话数、接通率、消费、成本、毛利、待质检等聚合正确。 |
| 前置条件 | 存在当日成功、失败和跳过话单。 |
| 测试数据 | 今日 10 通,其中 6 通成功;客户费用 12.000000,供应商成本 8.000000,待质检 3 条。 |
| 步骤 | 1. 请求 Dashboard summary 接口或打开 Dashboard。2. 与 SQL 聚合结果对比。3. 刷新页面。 |
| 预期结果 | 通话数、接通率、消费、成本、毛利、待质检与数据库一致;金额以 6 位小数或前端格式正确展示。 |
| 数据检查 | 失败话单计入总呼叫但不计入成功费用;跳过话单费用为 0。 |
| 安全检查 | 无 `dashboard.view` 返回 403 或菜单不可见。 |
#### DASH-002 上海时区日界
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证“今日”按 Asia/Shanghai 计算,不按 UTC 错分。 |
| 前置条件 | 构造 `2026-06-28 00:00:00 +08:00` 前后数据。 |
| 测试数据 | UTC `2026-06-27T15:59:59Z``2026-06-27T16:00:00Z` 两条 CDR。 |
| 步骤 | 1. 查询今日 Dashboard。2. 查询昨日 Dashboard 或手工 SQL 对比。 |
| 预期结果 | `16:00:00Z` 之后的数据计入 2026-06-28;之前数据计入 2026-06-27。 |
| 数据检查 | 趋势和 summary 使用同一时区边界。 |
| 安全检查 | 不因服务器本地时区变化导致统计漂移。 |
#### DASH-003 趋势桶固定
| 字段 | 内容 |
| --- | --- |
| 优先级 | P2 |
| 目的 | 验证趋势图固定桶数量,空桶补 0,时间范围校验。 |
| 前置条件 | 存在多日或多小时 CDR。 |
| 测试数据 | 7 天趋势;某天无数据。 |
| 步骤 | 1. 请求趋势接口。2. 检查桶数量、顺序和空桶。3. 请求非法范围。 |
| 预期结果 | 返回固定桶数;空桶数值为 0;非法范围返回校验错误。 |
| 数据检查 | 每个桶起止时间连续不重叠。 |
| 安全检查 | 大范围查询有上限,避免拖垮数据库。 |
#### ACT-001 当前通话列表
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证当前通话从 OpenSIPS Dialog 归一化后可展示主叫、被叫、客户 IP、落地 IP、时长和 SIP 信令状态。 |
| 前置条件 | A 侧存在进行中 Dialog 或使用 MI mock。 |
| 测试数据 | 一通进行中呼叫,含 Contact、SDP、dialog ID。 |
| 步骤 | 1. 发起长持话呼叫。2. 请求当前通话接口或打开页面。3. 对比 OpenSIPS MI 输出。 |
| 预期结果 | 列表显示 callId/dialogId、caller、callee、sourceIp、landingIp、durationSec、startedAt、sipStateSIP 状态按 Dialog 归一化为 `INVITE``180 Ringing``183 Session Progress``200 OK``BYE / Terminating` 等可读状态;自动刷新不闪退。 |
| 数据检查 | 解析 IPv4/IPv6、Contact 和 SDP 中 IP 正确;无 Dialog 时返回空列表。 |
| 安全检查 | 无 `active_calls.view` 不可查看。 |
#### ACT-002 安全挂断
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证有权限用户可通过 OpenSIPS MI 挂断进行中通话,并处理已结束竞态。 |
| 前置条件 | 有进行中 Dialog;`active_calls_ops_001` 已登录。 |
| 测试数据 | 安全 dialog ID。 |
| 步骤 | 1. 点击挂断。2. 查询 SIP BYE 和当前通话列表。3. 对已结束 dialog 再次挂断。 |
| 预期结果 | 正常挂断返回成功,Dialog 从列表消失;OpenSIPS operation failed 但通话已结束时前端显示合理成功/已结束状态。 |
| 数据检查 | CDR 正常生成,hangupReason 可追踪。 |
| 安全检查 | 无 `active_calls.manage` 不能挂断。 |
#### ACT-003 非法 Dialog ID
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证挂断接口只接受安全 dialog ID,防止 MI 命令注入。 |
| 前置条件 | 有权限账号。 |
| 测试数据 | `../x``;rm -rf`、超长 ID、包含空格/换行。 |
| 步骤 | 1. 调用挂断接口传非法 ID。2. 检查 OpenSIPS MI 是否被调用。 |
| 预期结果 | 返回 400;不调用 MI;服务不崩溃。 |
| 数据检查 | 无 CDR 或 Dialog 状态被误改。 |
| 安全检查 | 错误不回显危险字符串到日志未脱敏区域。 |
#### AUD-001 审计列表查询
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证操作日志按用户、模块、动作、结果、对象、时间筛选。 |
| 前置条件 | 已执行客户创建、充值、权限拒绝、登录失败等操作。 |
| 测试数据 | `module=customers``action=create``result=SUCCESS`,时间范围。 |
| 步骤 | 1. 使用 `audit_viewer_001` 查询审计列表。2. 组合筛选条件。3. 打开审计详情。4. 无权限账号查询。 |
| 预期结果 | 返回匹配审计;详情包含 actor、module、action、objectId、result、requestId、ip、userAgent、摘要。无权限返回 403。 |
| 数据检查 | 分页正确;失败审计和成功审计均可查。 |
| 安全检查 | 审计查询本身也应被审计或至少有访问日志。 |
#### AUD-002 敏感字段脱敏
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证密码、token、secret、HA1、验证码等敏感字段在审计和日志中脱敏。 |
| 前置条件 | 执行登录失败、重置密码、创建 SIP 网关、刷新 token 等操作。 |
| 测试数据 | 明文密码 `SensitivePass-001`SIP 密码 `SipSecret-001`。 |
| 步骤 | 1. 执行敏感操作。2. 查询审计详情和应用日志。3. 全文搜索敏感明文。 |
| 预期结果 | 敏感字段显示 `[REDACTED]` 或不出现;明文密码、refresh token、sipPassword、sipHa1 不可检索。 |
| 数据检查 | 脱敏不影响非敏感字段排障。 |
| 安全检查 | 嵌套对象和数组内敏感字段也脱敏。 |
### 8.8 前端页面与交互
本节覆盖 Web 登录、菜单、页面切换、错误/空状态、表单、权限按钮、构建产物和浏览器控制台。建议使用真实 B 环境或本地 mock API 两种方式执行。
公共前置数据:
- 管理员账号:具备全部核心页面权限。
- 只读账号:仅具备 `dashboard.view``customers.view`
- 各核心列表至少有 1 条数据,同时准备空数据环境或 mock 返回空数组。
- 浏览器打开 devtools,记录 console error/warn 和 network 失败。
#### WEB-001 登录态启动
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证 Web 首屏、验证码、登录、refresh 和 Dashboard 初始化。 |
| 前置条件 | API readyWeb 静态资源可访问。 |
| 测试数据 | 管理员账号。 |
| 步骤 | 1. 清空 cookie/localStorage。2. 打开首页。3. 完成验证码和登录。4. 刷新浏览器。 |
| 预期结果 | 登录成功进入 Dashboard;刷新后通过 refresh 恢复登录态或合理回到登录页;无空白页;无 uncaught error。 |
| 数据检查 | Network 中 refresh/login 状态符合预期。 |
| 安全检查 | access token 不应出现在 URL;退出后不能继续访问受保护页面。 |
#### WEB-002 API 失败态
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证 API 500、网络失败、401/403 时页面不崩溃。 |
| 前置条件 | 可通过 mock 或临时停 API 触发错误。 |
| 测试数据 | Dashboard、客户、话单、质检页面各触发一次失败。 |
| 步骤 | 1. 打开页面。2. 让接口返回 500/超时/403。3. 点击重试或切换页面。 |
| 预期结果 | 页面显示错误提示;布局保持;可重试;401 跳登录或刷新令牌;403 显示无权限。 |
| 数据检查 | 不产生重复写请求。 |
| 安全检查 | 错误提示不展示堆栈、SQL 或 token。 |
#### WEB-003 空数据态
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证核心列表在空数据时可读、可继续创建或提示清晰。 |
| 前置条件 | mock 返回空数组或测试库清空对应模块。 |
| 测试数据 | 客户、供应商、网关、线路组、话单、质检、操作日志为空。 |
| 步骤 | 1. 逐页打开空列表。2. 检查空状态和主操作。 |
| 预期结果 | 显示空状态;有权限账号可见新增/导入按钮;只读账号不显示写按钮;无表格错位。 |
| 数据检查 | 空列表不触发异常请求循环。 |
| 安全检查 | 无权限不因空状态显示写入口。 |
#### WEB-004 表单校验
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证前端表单必填、格式、后端错误显示和提交防抖。 |
| 前置条件 | 管理员登录。 |
| 测试数据 | 客户、客户网关、业务前缀、落地网关、线路组、充值/扣款、质检评分表单。 |
| 步骤 | 1. 提交空表单。2. 输入非法金额/IP/时间/前缀。3. 提交合法数据。4. 快速重复点击提交。 |
| 预期结果 | 非法输入有明确提示;后端错误展示在表单或通知中;合法提交关闭弹窗并刷新列表;重复点击不产生重复记录。 |
| 数据检查 | 幂等接口使用 idempotencyKey;非幂等写操作避免双提交。 |
| 安全检查 | 前端校验不能替代后端校验,绕过前端仍应被后端拒绝。 |
#### WEB-005 页面导航回归
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证核心菜单逐页切换无空白、标题正确、权限裁剪有效。 |
| 前置条件 | 管理员和只读账号可登录。 |
| 测试数据 | 全部核心菜单:Dashboard、当前通话、客户、网关、业务前缀、充值、供应商、落地、线路组、号码库、话单、质检、用户、角色、操作日志。 |
| 步骤 | 1. 执行 `pnpm test:remote-web-ui`,由 Playwright 使用管理员账号登录并逐个点击核心菜单。2. 每个页面加载后点击一个安全主操作(如编辑、查看详情、刷新)以覆盖弹窗/抽屉渲染。3. 记录页面标题、可见文本长度、console error 和 pageerror。4. 只读账号登录检查菜单。5. 浏览器刷新每个关键页面。 |
| 预期结果 | 每页标题正确;菜单切换和安全主操作均无 runtime error;无空白页;不出现 `API 数据不可用`;权限菜单和按钮符合权限矩阵;刷新不丢状态;任一 console error/pageerror 均判失败。 |
| 数据检查 | 只请求当前权限允许的全局 API,避免批量 403。 |
| 安全检查 | 手动输入无权限路由显示无权限或跳转。 |
#### WEB-006 构建产物发布
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证前端 build 产物被正确发布到 B 当前 release,Nginx 首页引用新资源。 |
| 前置条件 | 本地 build 成功;B 当前 release 明确;有回滚点。 |
| 测试数据 | 新 JS/CSS hash。 |
| 步骤 | 1. 执行 `pnpm lint`,确认 `apps/web/src/**` 前端源码未出现未定义标识。2. 执行 build。3. 发布 dist。4. `curl -k https://127.0.0.1/` 检查资源引用。5. 执行 `pnpm test:remote-web-ui`。6. 浏览器强刷首页。 |
| 预期结果 | 前端 lint 覆盖源码并通过;首页引用新 JS/CSS;旧资源不被引用;Nginx 返回 200;页面可登录;核心菜单逐页切换无空白、无 console error/pageerror。 |
| 数据检查 | release 目录和 public 目录一致。 |
| 安全检查 | 发布不覆盖后端 env、node_modules 或用户上传录音。 |
#### WEB-007 慢页面统一加载反馈
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证页面请求超过 2 秒时才显示加载弹窗,完成或失败后立即隐藏。 |
| 前置条件 | 管理员已登录;浏览器可将目标接口延迟到 1.5 秒、2.5 秒和失败三种状态。 |
| 步骤 | 1. 分别以三种延迟进入 Dashboard、话单、质检、号码库。2. 观察弹窗和列表状态。3. 在请求过程中切换菜单。 |
| 预期结果 | 2 秒内无弹窗;超过 2 秒出现“页面加载中”;请求完成、失败或被页面切换取消后弹窗立即消失;加载阶段只显示骨架,不出现“暂无数据”。 |
| 数据检查 | 页面切换会取消上一页面未完成请求,不产生旧响应覆盖新页面。 |
| 安全检查 | 弹窗和错误信息不显示 token、SQL 或内部堆栈。 |
#### WEB-008 Dashboard 按页面取数
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证登录首页只请求首页概览数据,不再预加载全站接口。 |
| 前置条件 | 管理员登录,浏览器 Network 保留日志。 |
| 步骤 | 1. 清空 Network。2. 登录或刷新首页。3. 检查 `/api/v2/dashboard/overview` 及其他业务接口。4. 切换到客户、供应商后复查。 |
| 预期结果 | 首页使用一个 overview 请求并行取得 summary/trends;未进入的客户、供应商、网关、用户、角色、日志等接口不请求;进入对应页面后才按需请求。 |
| 数据检查 | 首页指标全部来自真实 overview 响应,不使用 fixture 回退。 |
| 安全检查 | 只请求当前账号具备权限的数据。 |
#### WEB-009 话单首屏性能与分页稳定性
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证话单首屏缩小到 25 条、列表和总数并行查询、同时间记录分页顺序稳定。 |
| 前置条件 | 话单表存在超过 50 条记录,其中多条 `startedAt` 相同。 |
| 步骤 | 1. 首次进入话单中心。2. 检查请求 take。3. 连续翻页并返回。4. 记录接口耗时并观察重复/遗漏。 |
| 预期结果 | 默认 `take=25`;按 `startedAt desc, id desc` 稳定排序;列表与 count 并行;主列表仅展示核心字段,完整字段在详情抽屉。 |
| 数据检查 | 前后页无重复或遗漏,total/hasMore 正确。 |
| 安全检查 | 话单筛选和详情仍遵守 `cdrs.view` 权限。 |
### 8.9 性能、故障与安全
本节覆盖小规模并发、Worker/数据服务故障、Redis 热路径故障、SIP 安全探针、API 越权/重放和恢复性。执行故障类用例前必须确认回滚点和当前环境可中断。
#### PERF-001 5 路并发呼叫
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证最小并发下 SIP、CDR、计费、录音闭环稳定。 |
| 前置条件 | A/B/T 服务 active;测试客户余额充足;T 脚本使用随机本地端口。 |
| 测试数据 | 5 个唯一 Call-ID,持话 6-10 秒。 |
| 步骤 | 1. T 并发发起 5 路呼叫。2. 等待全部挂断。3. 查询 CDR、rated、recordings。 |
| 预期结果 | 5 路均 200 OK 和 BYE 200;生成 5 raw、5 rated、5 READY 录音;无端口冲突。 |
| 数据检查 | 费用合计、余额扣减、录音大小合理。 |
| 安全检查 | 并发不绕过 CPS/并发限制。 |
#### PERF-002 12 路短突发
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证短突发下脚本、OpenSIPS、Worker 和录音搬运不丢事件。 |
| 前置条件 | CPS/并发限制允许 12 路,或预期部分被限流。 |
| 测试数据 | 12 个唯一 Call-ID,短持话。 |
| 步骤 | 1. 同时发起 12 路。2. 记录成功/失败数量。3. 查询 CDR、录音、Redis pending/deadletter。 |
| 预期结果 | 若限制允许则全部成功;若限流则失败数量和原因符合规则;无端口占用错误;pending/deadletter 正常。 |
| 数据检查 | raw CDR 数量等于成功和可记录失败事件总数。 |
| 安全检查 | 突发结束后正常呼叫恢复。 |
#### FAIL-001 Recording Worker 停止
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证 Recording Worker 停止期间通话不受影响,恢复后录音可补搬。 |
| 前置条件 | 可停止 B `lisglosips@recording-worker`。 |
| 步骤 | 1. 停止 Recording Worker。2. 发起成功呼叫。3. 确认录音仍在 A ready 目录。4. 恢复 Worker。5. 查询 recordings。 |
| 预期结果 | 通话成功;恢复后录音变 READY;A 源文件校验后删除。 |
| 数据检查 | 不重复生成录音记录。 |
| 安全检查 | 停止 Worker 不影响 API 登录和 CDR 计费。 |
#### FAIL-002 CDR Worker 停止
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证 CDR Worker 停止期间 Redis Stream 积压,恢复后自动入库计费。 |
| 前置条件 | 可停止 B `lisglosips@cdr-worker`。 |
| 步骤 | 1. 停止 CDR Worker。2. 发起呼叫。3. 查看 Redis stream lag/pending。4. 恢复 Worker。5. 查询 raw/rated 和余额。 |
| 预期结果 | 停止期间 SIP 通话成功但 MySQL 暂无 rated;恢复后 lag/pending 归零,CDR 入库,余额扣减一次。 |
| 数据检查 | deadletter 无新增。 |
| 安全检查 | 积压期间不丢 CDR。 |
#### FAIL-003 MySQL 短故障
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证 MySQL 短故障时 CDR 可重试,不误 ACK 或 deadletter。 |
| 前置条件 | 已确认停止 MySQL 的影响和恢复命令;非生产或维护窗口。 |
| 步骤 | 1. 停 MySQL。2. 发起呼叫。3. 查看 CDR Worker 日志、Redis pending/deadletter。4. 恢复 MySQL。5. 等待 pending 重试。 |
| 预期结果 | 通话可成功;CDR 暂未入库但保留 pending;恢复后入库计费;deadletter 无新增。 |
| 数据检查 | 余额只扣一次。 |
| 安全检查 | 日志不泄露数据库密码。 |
#### FAIL-004 Redis 短故障
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证 Redis 热路径不可用时 A 保护性拒绝呼叫,恢复路径明确。 |
| 前置条件 | 已确认停止 Redis 影响;A/B/T 可操作。 |
| 步骤 | 1. 停 B Redis。2. 发起呼叫。3. 恢复 Redis。4. 按 Runbook 重试,如仍失败则重启 A OpenSIPS。5. 再发起呼叫。 |
| 预期结果 | Redis 故障期间 A 返回保护性 503 或等价失败;恢复后按 Runbook 恢复;复测呼叫成功。 |
| 数据检查 | 故障期间不产生错误扣费。 |
| 安全检查 | Redis 恢复不使用过期错误配置。 |
#### SEC-001 非法来源 SIP 探针
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证非白名单来源不能进入业务路由。 |
| 前置条件 | 准备非客户网关 IP 主机。 |
| 步骤 | 1. 从非白名单 IP 向 A `15060/udp` 发送 INVITE。2. 观察响应、A 日志、HOMER、CDR。 |
| 预期结果 | 请求被静默丢弃或明确拒绝;不产生业务 CDR;不进入落地路由。 |
| 数据检查 | 无客户识别、无余额变化。 |
| 安全检查 | 不暴露可用客户 IP 或路由信息。 |
#### SEC-002 超 CPS 探针
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证客户侧或落地侧 CPS 限制生效,超限后可恢复。 |
| 前置条件 | 配置明确 CPS 限制,例如 2 CPS。 |
| 步骤 | 1. 短时间发起超过限制的呼叫。2. 记录成功/失败数量。3. 等待窗口恢复后发起正常呼叫。 |
| 预期结果 | 超限请求被限制;限流日志/指标可见;窗口后正常呼叫成功。 |
| 数据检查 | 超限失败不扣费。 |
| 安全检查 | 限流不影响其他客户。 |
#### SEC-003 API 重放和越权
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证幂等接口重放安全、非幂等接口权限保护和审计。 |
| 前置条件 | 准备只读账号、财务账号和管理员账号。 |
| 步骤 | 1. 重放客户充值/扣款 idempotencyKey。2. 使用同 key 不同 body。3. 只读账号直接 POST 写接口。4. 伪造/过期 token 请求。 |
| 预期结果 | 相同请求幂等返回同一结果;不同 body 返回 409;越权返回 403;未认证/伪造 token 返回 401。 |
| 数据检查 | 余额不重复变化;失败审计可追踪。 |
| 安全检查 | 错误响应不泄露 token 解析细节。 |
#### SEC-004 录音与文件安全专项
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证录音播放、X-Accel 和 Worker 文件处理无路径穿越。 |
| 前置条件 | READY 录音存在。 |
| 步骤 | 1. 请求伪造录音 ID。2. 构造异常 storageKey。3. 检查 Nginx internal 路径。 |
| 预期结果 | 非法路径返回 404/安全错误;无法读取录音根目录外文件。 |
| 数据检查 | 无录音状态变化。 |
| 安全检查 | 不泄露真实路径。 |
### 8.10 发布、备份与回滚
本节覆盖 release artifact、B release preflight、MySQL/Redis 备份、隔离恢复、灰度呼叫、脚本语法、应用回滚和阿里云迁移前复验。执行前必须确认当前 release、上一 release、备份目录和回滚命令。
#### OPS-000 Release artifact 生成与校验
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证 release 产物由固定脚本生成,包含部署所需 API/Web/Worker/packages/Prisma/infra 文件、manifest 和 SHA256,避免手工拼包。 |
| 前置条件 | Linux 构建环境或 B 兼容环境;本地代码已通过 lint/typecheck/test/build`releaseId` 已冻结。 |
| 步骤 | 1. 执行 `pnpm release:artifact -- --release-id <releaseId>`。2. 检查 `dist/releases/<releaseId>.tar.gz``.sha256`。3. 解包到临时目录检查 `RELEASE_MANIFEST.json``public/index.html`、API/Worker dist、`node_modules`、Prisma schema/migrations、argon2id wasm、infra 脚本。4. 执行 `sha256sum -c`。 |
| 预期结果 | artifact 生成成功;manifest 记录 releaseId、git commit、git status、Node/pnpm 版本、required entriesSHA256 校验通过;正式发布包不得由 Windows 原生 node_modules 生成。 |
| 数据检查 | 解包目录文件数量合理,关键路径完整;`.env`、私钥、明文密码、`.codex-private` 不进入 artifact。 |
| 安全检查 | 脚本输出不回显数据库密码、Redis 密码、JWT secret、SSH 密钥。 |
#### OPS-001 B preflight
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证 B 当前 release 和关键服务满足发布/验收基线。 |
| 前置条件 | B 已部署 releaseSSH 可达。 |
| 步骤 | 1. 执行 `lisglosips-release-preflight.sh`。2. 检查 systemd 服务。3. 请求 API ready 和 HTTPS 首页。 |
| 预期结果 | MySQL、Redis、Nginx、API、CDR Worker、Recording Worker、Config Publisher、HEP、Prometheus、Grafana 均 activeAPI ready 返回 okNginx 配置测试通过。 |
| 数据检查 | current symlink 指向预期 release;最近备份目录可见。 |
| 安全检查 | 输出不包含密码、token、私钥。 |
#### OPS-002 MySQL 备份校验
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证 MySQL 备份完整、可校验、元数据可追踪。 |
| 前置条件 | B MySQL 正常,有业务数据。 |
| 步骤 | 1. 触发 MySQL 备份。2. 检查备份目录、`metadata.tsv``SHA256SUMS`。3. 执行 `sha256sum -c`。 |
| 预期结果 | 全库和业务库备份文件存在;SHA256 校验通过;metadata 记录时间、主机、数据库、大小。 |
| 数据检查 | 备份包含关键表:customers、raw_cdrs、rated_cdrs、recordings、users、recharges。 |
| 安全检查 | 备份文件权限限制,避免普通用户读取敏感数据。 |
#### OPS-003 Redis 备份校验
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证 Redis RDB 备份完整并可追踪配置版本和 Stream 数据。 |
| 前置条件 | B Redis 正常,有 `cfg:active_version` 和 CDR Stream。 |
| 步骤 | 1. 触发 Redis 备份。2. 检查 `dump.rdb`、metadata、SHA256。3. 记录 DBSIZE、active version、stream 长度。 |
| 预期结果 | RDB 和 metadata 存在;SHA256 校验通过;关键 key 元数据记录完整。 |
| 数据检查 | 包含配置版本、CDR Stream、幂等锁或按备份策略记录。 |
| 安全检查 | Redis 备份权限受控。 |
#### OPS-004 隔离恢复演练
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证备份能恢复到隔离环境,且不覆盖生产数据。 |
| 前置条件 | 已有 MySQL 和 Redis 备份。 |
| 步骤 | 1. MySQL 恢复到临时库。2. Redis RDB 启动到临时端口或 Unix socket。3. 核对关键表和 key 数量。4. 删除临时库/实例。 |
| 预期结果 | 恢复成功;关键计数与备份时 metadata 匹配;演练不影响运行中 MySQL/Redis。 |
| 数据检查 | raw_cdrs、recordings、recharges、users、Redis cfg 和 stream 数量合理。 |
| 安全检查 | 临时恢复数据清理干净,权限不扩大。 |
#### OPS-005 灰度呼叫验收
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证新 release 切换后核心 SIP-CDR-计费-录音闭环可用。 |
| 前置条件 | B 已切到新 releaseA 配置和 Redis active version 正常;T 可发起呼叫。 |
| 步骤 | 1. T 发起一通灰度呼叫。2. 记录 Call-ID。3. 查询 SIP 响应、raw/rated CDR、余额扣减、录音 READY、HOMER。 |
| 预期结果 | 呼叫成功;CDR 计费正确;录音 READY;HOMER 可查;无新增错误日志。 |
| 数据检查 | 灰度数据 ID 记录到验收报告。 |
| 安全检查 | 灰度只使用测试客户,不影响真实客户。 |
#### OPS-006 回滚脚本语法
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证回滚脚本和 A 配置恢复脚本语法正确。 |
| 前置条件 | 脚本已部署。 |
| 步骤 | 1. 对 B release rollback/preflight 脚本执行 `bash -n`。2. 对 A OpenSIPS config restore 脚本执行 `bash -n`。 |
| 预期结果 | 所有脚本语法通过。 |
| 数据检查 | 语法检查不执行真实回滚。 |
| 安全检查 | 不输出敏感参数。 |
#### OPS-007 应用回滚演练
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证可将 B current 切回上一 release 并恢复服务。 |
| 前置条件 | 有上一可用 release;当前无不可逆数据库迁移或已有数据回滚方案。 |
| 步骤 | 1. 记录当前 release。2. 切换 current symlink 到上一 release。3. 重启 API/Worker。4. 执行 preflight、API ready、HTTPS 首页。5. 如演练要求,再切回新 release。 |
| 预期结果 | 回滚后服务 activeAPI ready okWeb 可访问;核心接口兼容;可再切回。 |
| 数据检查 | 无数据库 schema 不兼容;Worker 不重复处理已完成消息。 |
| 安全检查 | 回滚不覆盖备份、录音和用户上传数据。 |
#### OPS-008 OpenSIPS 配置恢复演练
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 目的 | 验证 A 侧 OpenSIPS 配置可语法检查和按 Runbook 恢复。 |
| 前置条件 | 有当前运行配置备份;维护窗口或测试环境。 |
| 步骤 | 1. 执行 `opensips -C` 检查当前配置。2. 对恢复脚本做 dry-run 或语法检查。3. 在测试窗口执行恢复并重启/ reload。4. 发起呼叫验证。 |
| 预期结果 | 配置语法通过;恢复后 OpenSIPS active;呼叫成功。 |
| 数据检查 | 配置 SHA256 与预期记录一致。 |
| 安全检查 | 不把模板占位符配置直接覆盖生产运行配置。 |
#### OPS-009 阿里云迁移前复验
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 目的 | 验证本地 KVM 结论在阿里云网络、安全组、数据盘、TLS 和正式域名下重新成立。 |
| 前置条件 | 阿里云 A/B 已部署,VPC、安全组、数据盘、DNS/TLS 准备完成。 |
| 步骤 | 1. 复查安全组端口:SIP、RTP、HTTPS、管理端口。2. 执行 B preflight。3. 执行备份恢复演练。4. 执行灰度呼叫。5. 执行故障注入最小集。 |
| 预期结果 | 正式环境全部通过;TLS 正常;RTP 双向媒体正常;备份恢复可用;灰度呼叫闭环。 |
| 数据检查 | 迁移报告记录公网/私网 IP、端口、证书、备份路径和灰度 Call-ID。 |
| 安全检查 | SSH 不对全网开放;Redis/MySQL/Prometheus/Grafana 不公网暴露;开发 CA 和本地路径不进入生产。 |
### 8.10 S57 页面布局、日期范围与号码库
#### WEB-010 页面内容顶部对齐
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 步骤 | 登录后依次打开客户网关、用户、号码库以及数据量较少的配置页,检查标题、筛选区和列表面板。 |
| 预期结果 | 所有区块按内容高度从顶部连续排列,不因视口剩余高度产生大面积空白或组件纵向拉伸。 |
#### ACT-004 B 本机 OpenSIPS MI
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 步骤 | 1. 在 B 调用 OpenSIPS HTTP MI `dlg_list`。2. 打开当前通话。3. 发起测试呼叫后刷新。 |
| 预期结果 | 无 A 服务器 SSH 依赖;空闲时快速返回空列表,呼叫中显示真实 dialog;MI 不可用时 3 秒内失败并显示受控提示。 |
#### DATE-001 三中心默认最近一周
| 字段 | 内容 |
| --- | --- |
| 优先级 | P1 |
| 步骤 | 依次打开话单、质检、操作日志,检查日期输入和请求参数;修改范围查询,再点击重置。 |
| 预期结果 | 首次和重置均为今天至前 6 天;开始日按 00:00:00、结束日按 23:59:59.999 查询;后端只返回范围内数据。 |
#### NUM-004 号码库全量与分页性能
| 字段 | 内容 |
| --- | --- |
| 优先级 | P0 |
| 步骤 | 1. 导入器先干跑并确认未匹配为 0。2. 备份后导入。3. 核对四表数量。4. 四个页签执行首屏、翻页、页大小和筛选。 |
| 预期结果 | 371 个行政城市、517,258 个有效七位号段、321 个区号、70 个前缀规则;首屏只读取 25 条;上一页/下一页与总数正确;页面加载不扫描或返回全表。 |
## 8A. 主叫号码实时分析验收设计索引(2026-08-31)
[主叫号码实时分析V1.1](CALLER_REALTIME_ANALYTICS_DESIGN.md)第10节定义CRA-001至CRA-024,覆盖180/183业务接通、正时长应答、总体应答率A/T、已接通应答率A/C、直接200/缺少100/零时长/亚秒时长、待接通与未知、线路重试、重复乱序、历史缺口、权限隔离和故障恢复。该组用例目前全部待执行,不计入既有通过数量;最终验收必须有真实SIP、API、数据库及CDR对账证据。
## 9. 缺陷分级
| 级别 | 定义 | 示例 |
| --- | --- | --- |
| P0 | 阻断核心链路或存在严重安全/数据风险 | 无法登录、呼叫失败、重复扣费、录音越权、发布后 API 不可用 |
| P1 | 核心功能受影响但有规避方案 | 某管理页面写操作失败、Worker 恢复需人工处理、关键指标错误 |
| P2 | 一般功能缺陷或体验问题 | 筛选条件不完整、提示文案不清晰、非关键统计延迟 |
| P3 | 低风险优化 | 样式细节、日志可读性、非核心字段排序 |
## 10. 回归重点
每次涉及下列模块时必须扩大回归:
- `apps/api/src/modules/auth``security``audit`:回归 AUTH、AUD、WEB 权限相关用例。
- `customer-gateways``business-prefixes``worker-config-publisher`:回归 GW、CALL 业务前缀和配置发布。
- `worker-cdr``packages/redis`:回归 CDR、充值幂等、FAIL-002/FAIL-003。
- `worker-recording``recordings``quality`:回归 REC、QA 和录音播放权限。
- `infra/server-a` OpenSIPS/RTPEngine:回归 CALL、PERF、SEC、HEP/HOMER。
- `infra/server-b` Nginx/systemd/release:回归 OPS、WEB-006、健康检查。
- `apps/web/src/App.jsx``pages``permissions.js`:回归 WEB-001 至 WEB-005、AUTH-007、AUTH-008。
## 11. 当前已知风险与补充建议
- Redis 恢复后 OpenSIPS 热路径曾出现需要重启 `opensips` 才恢复的问题。上线前应补充 Redis 断连重连专项测试,并增加明确指标/告警。
- S57 号码库采用 517,258 条有效七位号段;归属和运营商字段是公开号段库口径,携号转网后的当前运营商不能仅凭该字段断言,仍需运营商实时数据源校验。
- 真实 B 浏览器登录态下的逐页按钮、真实录音播放和 Nginx X-Accel 链路仍需人工复核。
- 阿里云迁移前必须重跑网络安全组、正式 TLS、数据盘、备份恢复、故障注入和灰度呼叫,不应直接沿用本地 KVM 结论。
- 建议后续增加 Playwright 前端冒烟:登录、菜单遍历、权限账号、关键表单、录音播放入口,降低页面空白类问题复发概率。