commit 5fa1bd35e97cd21b8ffa618369ac9dd344d128af Author: hectorzhao Date: Mon Jun 22 10:56:38 2026 +0800 Initial LisgloSIPS V2 implementation diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2bd18ef --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +NODE_ENV=development +LISGLOSIPS_SERVICE_NAME=api +LISGLOSIPS_HOST=127.0.0.1 +LISGLOSIPS_PORT=3000 +LISGLOSIPS_LOG_LEVEL=info +LISGLOSIPS_REQUEST_ID_HEADER=x-request-id +DATABASE_URL=mysql://lisglosips_app:@127.0.0.1:3306/lisglosips +REDIS_URL=redis://127.0.0.1:6379/0 +AUTH_ACCESS_TOKEN_SECRET= +AUTH_ACCESS_TOKEN_TTL_SECONDS=900 +AUTH_REFRESH_TOKEN_TTL_DAYS=7 +AUTH_LOCK_MAX_FAILURES=5 +AUTH_LOCK_WINDOW_SECONDS=900 +AUTH_COOKIE_SECURE=true +AUTH_TOKEN_ISSUER=lisglosips-api +AUTH_TOKEN_AUDIENCE=lisglosips-web diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8dd18ec --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +* text=auto + +*.sh text eol=lf +*.py text eol=lf +*.service text eol=lf +*.timer text eol=lf +*.conf text eol=lf +*.cfg text eol=lf +*.nft text eol=lf +*.lua text eol=lf +*.mjs text eol=lf +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.jsx text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.md text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3919a76 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.codex-private/ +.codex-runtime/ +node_modules/ +dist/ +**/dist/ +*.tsbuildinfo +coverage/ +.env +.env.* +!.env.example +*.log +cookies.txt diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md new file mode 100644 index 0000000..07a5e9d --- /dev/null +++ b/IMPLEMENTATION_STATUS.md @@ -0,0 +1,568 @@ +# LisgloSIPS V2 实施状态 + +> 本文件用于跨 Codex 会话持续交接。每次新会话开始必须先读取本文件和 `SOFTSWITCH_PLATFORM_DESIGN_V2.md`;每次任务完成、失败或被阻塞后必须更新本文件。 + +## 当前状态 + +- 当前任务:无,S00-S30 本地 V2 闭环已完成 +- 总体状态:S30 已完成;本地 KVM 开发环境 V2 闭环冻结在 `s28-v2-20260621220924`,阿里云迁移前需按 S30 Runbook 重新演练 +- 最后更新:2026-06-22 10:35 +08:00 +- 当前阻塞:无。遗留风险为 Redis 恢复后 OpenSIPS 热路径可能需要重启 `opensips` 才恢复,已纳入 S30 Runbook,根因级修复需后续单独处理。 +- 当前环境:A/B/T 为本地 KVM 开发服务器,通过 Tailscale 联调;开发完成后再迁移阿里云 +- 私密访问资料:`.codex-private/SERVER_ACCESS.md`,只在实际连接时读取,禁止回显 + +## 服务器索引 + +| 代号 | 地址 | 用户 | 用途 | +| --- | --- | --- | --- | +| A | `100.90.90.90` | `hector` | OpenSIPS、RTPEngine、录音内存盘、HEP、Exporter | +| B | `100.90.90.91` | `hector` | API、Worker、Redis、MySQL、录音、HOMER、Prometheus/Grafana | +| T | `100.93.185.30` | `hector` | 已安装 OpenSIPS,模拟客户 SIP 注册与呼叫 | + +## 任务状态表 + +状态只能使用:`待开始`、`进行中`、`已完成`、`已阻塞`、`已跳过`。 + +| ID | 任务 | 状态 | 主要产物/验收 | +| --- | --- | --- | --- | +| S00 | SSH 安全接入与凭据迁移 | 已完成 | 三机 SSH Key、密码轮换、访问验证、访问 Runbook;后续 SSH 加固只调整管理端口并保留 Key 登录 | +| S01 | 三机只读资产盘点 | 已完成 | `docs/inventory-A.md`、`inventory-B.md`、`inventory-T.md`、汇总报告 | +| S02 | 网络与安全组验收 | 已完成 | `docs/infra-check.md`,含本地端口矩阵和未来阿里云安全组清单 | +| S03 | Server B 基础系统初始化 | 已完成 | `docs/SERVER_B_BASELINE_RUNBOOK.md`、`infra/server-b/s03/`、远端备份 | +| S04 | Server B MySQL 与 Redis | 已完成 | `docs/SERVER_B_DATA_SERVICES_RUNBOOK.md`、`infra/server-b/s04/`、备份恢复演练 | +| S05 | Server B Node.js、Nginx 与 TLS 基线 | 已完成 | `docs/SERVER_B_WEB_RUNTIME_RUNBOOK.md`、`infra/server-b/s05/`、HTTPS 与重启验收 | +| S06 | Server B HOMER 与基础监控 | 已完成 | HEP 接收、HOMER API、Prometheus、Grafana、exporters、A Node Exporter | +| S07 | V2 后端 Monorepo 骨架 | 已完成 | NestJS/Fastify/Prisma、日志、配置、健康检查、测试框架;本地和 B 均可构建 | +| S08 | 数据库 Schema 与迁移 | 已完成 | Prisma Schema、初始化迁移、内置角色/权限种子、B 临时空库迁移和备份恢复测试 | +| S09 | 登录认证基础 | 已完成 | 登录、刷新、退出、Argon2id、Session/Token 安全 | +| S10 | 用户、角色、权限与审计 | 已完成 | RBAC、内置角色保护、重置密码、审计拦截器 | +| S11 | 客户管理 API | 已完成 | 客户 CRUD、启停、余额/授信读取、单元与集成测试 | +| S12 | 充值与不可变余额流水 | 已完成 | 客户/供应商充值、幂等、事务、审计、并发测试 | +| S13 | 客户网关 API | 已完成 | IP/SIP 注册认证配置、启停、密码安全存储 | +| S14 | 客户网关策略与 Redis 配置发布 | 已完成 | 主被叫匹配、优先级、Outbox、配置版本和回滚 | +| S15 | 供应商管理 API | 已完成 | 供应商 CRUD、余额/授信、启停 | +| S16 | 落地网关 API | 已完成 | CPS/并发/时段/编码/号码转换/周期费率/启停 | +| S17 | 落地线路组 API | 已完成 | 成员增删、优先级重排、并发汇总、完整性校验 | +| S18 | Server A OpenSIPS 基线 | 已完成 | 15060、认证、防扫描、MI 本地绑定、Redis/HEP 模块 | +| S19 | Server A RTPEngine 与录音 tmpfs | 已完成 | 内核转发、RTP 端口段、3 GiB tmpfs、录音 ready 规则 | +| S20 | Server A Redis 热路径与 HEP/指标 | 已完成 | Lua 原子检查、配置读取、HEP 9060、Node Exporter | +| S21 | Server T 客户模拟配置 | 已完成 | IP/SIP 注册两套测试账号、呼叫脚本、失败场景脚本 | +| S22 | CDR Redis Stream | 已完成 | 标准事件、XADD/XREADGROUP、ACK、Pending、死信、幂等 | +| S23 | 最小计费与余额扣减 | 已完成 | Decimal 计费、客户费用/成本、重复 CDR 不重复扣费 | +| S24 | 录音搬运与播放 | 已完成 | A->B 私网搬运、校验、Range、权限、源文件安全删除 | +| S25 | 质检后端 | 已完成 | 抽检规则、稳定抽样、录音详情、质检保存和审计 | +| S26 | Dashboard 聚合 | 已完成 | 实时占位/聚合指标、趋势、失败码、异常网关、待质检 | +| S27 | React Demo 接入真实 API | 已完成 | API Client、核心运营页真实 API、错误/加载/空状态 | +| S28 | 三机端到端联调 | 已完成 | T 发起呼叫,A 转发录音,B 出 CDR/费用/信令/质检 | +| S29 | 性能、故障与安全测试 | 已完成 | CPS/并发/录音、Redis/MySQL/Worker 故障、越权与重放 | +| S30 | 备份、Runbook、灰度与上线 | 已完成 | 恢复演练、上线/回滚脚本、灰度记录、验收报告 | + +## 每次新会话的固定开场指令 + +可直接向 Codex 发送: + +```text +请先读取 SOFTSWITCH_PLATFORM_DESIGN_V2.md、IMPLEMENTATION_STATUS.md, +以及本任务关联的现有脚本/代码。只执行状态文件里的当前任务,不提前执行后续任务。 +开始前复核上次交接记录;涉及服务器写操作、服务重启、防火墙、密码、数据库迁移或数据删除时先说明影响和回滚点。 +完成后运行验收检查,并更新 IMPLEMENTATION_STATUS.md 的任务状态、产物、验证结果、遗留问题和下一任务。 +``` + +## 单任务交接记录模板 + +每完成一个任务,在本节顶部新增记录: + +```markdown +### YYYY-MM-DD HH:mm - Sxx 任务名称 + +- 状态:已完成 / 已阻塞 +- 操作服务器:A / B / T / 本地 +- 完成内容: +- 修改文件: +- 安装软件及固定版本: +- 执行的关键命令:只记录无密码版本 +- 验证结果: +- 回滚方式: +- 未解决问题: +- 下一任务:Sxx +``` + +## 交接记录 + +### 2026-06-22 10:35 - S30 备份、Runbook、灰度与上线完成 + +- 状态:已完成 +- 操作服务器:A、B、T、本地 +- 完成内容:完成 S30 本地 KVM 开发环境上线前冻结与演练。新增 S30 总控 Runbook、最终验收报告、B release preflight/rollback 脚本和 A OpenSIPS 配置恢复脚本;触发 B MySQL/Redis 新备份并完成隔离恢复演练;使用 T 发起灰度呼叫并核对 B raw CDR、rated CDR 和 READY 录音;实际运行 B preflight;A OpenSIPS 当前配置语法检查通过;三机关键服务最终复核 active。 +- 修改文件:`IMPLEMENTATION_STATUS.md`、`docs/S30_RELEASE_BACKUP_RUNBOOK.md`、`docs/S30_FINAL_ACCEPTANCE_REPORT.md`、`infra/server-b/s30/lisglosips-release-preflight.sh`、`infra/server-b/s30/lisglosips-release-rollback.sh`、`infra/server-a/s30/lisglosips-opensips-config-restore.sh` +- 安装软件及固定版本:未安装新系统软件;继续使用 OpenSIPS `3.6.x`、RTPEngine `11.5.1.18-1ubuntu1.2`、Node.js `v22.22.2`、pnpm `10.33.0`、Prisma `6.19.0`、MySQL `8.0.46`、Redis `7.0.15` +- 执行的关键命令:B `systemctl start lisglosips-backup.service`、MySQL 备份恢复到临时库 `lisglosips_s30_restore`、Redis RDB 恢复到隔离 Unix socket 临时实例、`bash -n` 检查 S30 脚本、`/tmp/lisglosips-release-preflight.sh` preflight;A `opensips -C -f /etc/opensips/opensips.cfg`;T `/opt/lisglosips-s28/lisglosips-s28-sip.py invite --timeout 12 --hold 3`。所有 sudo/数据库/API secret 均从 `.codex-private/` 或远端 env 读取并只在进程内使用,未写入文档。 +- 验证结果:MySQL 备份 `/data/backups/mysql/20260622T022811Z` 的 `all-databases.sql.gz`、`lisglosips.sql.gz`、`metadata.tsv` 校验通过,临时库恢复计数 `raw_cdrs=33`、`recordings=27`、`customer_recharges=34`、`users=2` 后已删除;Redis 备份 `/data/backups/redis/20260622T022812Z` 的 `dump.rdb`、`metadata.txt` 校验通过,隔离实例核对 `DBSIZE=51`、`stream:cdr_payload=54`、`cfg:active_version=s28-v1` 后已关闭并清理。灰度 Call-ID `s28-1782095548702-luppqjd4@lisglosips-t` 收到 SIP `100/200/BYE 200`,B 生成 raw `raw_7a6356cc76974652aa30a9d7a350041b`、rated `rated_e0eb8cbe1b90454cb3cf2d9a3bbc9290`、`billSec=6`、客户费用/供应商成本均 `0.012000`、录音 `rec_5fdf599d679f493a83fc7a2d205a128a` 为 `READY` 且 55758 bytes。B preflight 通过,A/B/T 关键服务最终 active,S30 文档/脚本未检出待补充或明文 secret。 +- 回滚方式:B 应用按 `docs/S30_RELEASE_BACKUP_RUNBOOK.md` 使用 `infra/server-b/s30/lisglosips-release-rollback.sh /opt/lisglosips/releases/` 切回上一 release;A OpenSIPS 配置按 `infra/server-a/s30/lisglosips-opensips-config-restore.sh ` 先语法检查再恢复;Redis 配置切回上一 `cfg:active_version`,不删除 Stream;数据库优先向前修复迁移,只有完整备份和维护窗口下才恢复数据。S30 产生的测试 CDR、录音和备份作为验收证据保留。 +- 未解决问题:本地开发环境不是阿里云生产环境;正式迁移前必须重新完成 VPC/安全组、正式 DNS/TLS、独立数据盘、备份恢复、故障注入和端到端呼叫验收。S29 遗留 Redis 恢复后 OpenSIPS 热路径可能需要重启 `opensips` 的根因级修复仍需后续单独处理;真实多 A、多 B、高可用和完整生产路由不属于本地 S30 闭环。 +- 下一任务:无;S00-S30 本地 V2 闭环完成,后续应按 S30 Runbook 进入阿里云迁移/生产化专项 + +### 2026-06-22 10:25 - S29 性能、故障与安全测试完成 + +- 状态:已完成 +- 操作服务器:A、B、T、本地 +- 完成内容:完成本地 KVM 开发环境内的 S29 小规模性能、故障与安全测试。5 路并发和 12 路短突发呼叫均完成 SIP 100/200/BYE 200、CDR、计费、录音 READY 入库;Recording Worker 停止期间的录音恢复、CDR Worker 停止期间的 Redis pending 恢复均通过;Redis 停止期间 A 正确返回 `503 Redis Unavailable`,恢复后发现 OpenSIPS 热路径连接不自动恢复,已通过重启 A `opensips` 做运行时恢复并复测成功;MySQL 停止期间发现 CDR 被 deadletter 的可恢复性缺陷,已修复并部署到 B 当前 release,复测 MySQL 恢复后 pending CDR 自动入库且无新增 deadletter;完成 API 未认证、RBAC 越权、录音播放保护、充值幂等重放和 SIP 非法来源探针。S29 报告已写入 `docs/S29_PERFORMANCE_FAULT_SECURITY_REPORT.md`。 +- 修改文件:`IMPLEMENTATION_STATUS.md`、`docs/S29_PERFORMANCE_FAULT_SECURITY_REPORT.md`、`infra/server-t/s28/lisglosips-s28-sip.py`、`packages/redis/src/cdr-stream.ts`、`packages/redis/src/cdr-stream.spec.ts`、`apps/worker-cdr/src/main.ts`、`apps/worker-cdr/src/rating.ts`;T 的 `/opt/lisglosips-s28/lisglosips-s28-sip.py`;B 当前 release `/opt/lisglosips/releases/s28-v2-20260621220924/apps/worker-cdr/` 与 `/opt/lisglosips/releases/s28-v2-20260621220924/packages/redis/` +- 安装软件及固定版本:未安装新系统软件;继续使用 OpenSIPS `3.6.x`、RTPEngine `11.5.1.18-1ubuntu1.2`、Node.js `v22.22.2`、pnpm `10.33.0`、Prisma `6.19.0` +- 执行的关键命令:本地 `corepack pnpm@10.33.0 exec vitest run packages/redis/src/cdr-stream.spec.ts apps/worker-cdr/src/rating.spec.ts`、`corepack pnpm@10.33.0 --filter @lisglosips/redis build`、`corepack pnpm@10.33.0 --filter @lisglosips/worker-cdr build`;T 多路 `/opt/lisglosips-s28/lisglosips-s28-sip.py invite --timeout 10 --hold 1/3`;B/A 使用 `systemctl stop/start/restart` 做受控故障注入和恢复;B 通过 API 与 Prisma 只读/小额写入探针核对 RBAC、录音播放保护和充值幂等。所有 sudo/数据库/API secret 均从 `.codex-private/` 或远端 env 读取并只在进程内使用,未写入文档。 +- 验证结果:5 路并发生成 5 条 raw CDR、5 条 rated CDR、5 条 READY 录音;12 路短突发生成 `raws=12`、`rated=12`、`recordingsReady=12`、`totalDuration=72`、录音大小 `55758-56078` bytes;Recording Worker 恢复后补搬运录音;CDR Worker 恢复后 Redis `pending=0`、`lag=0` 并入库;MySQL 故障修复后复测 Call-ID `s28-1782093477852-a8bqqohb@lisglosips-t` 自动入库 raw/rated CDR,无新增 deadletter;API `/api/v2/users` 未认证返回 `401 AUTH_REQUIRED`,质检角色访问用户管理返回 `403 RBAC_FORBIDDEN`,录音播放未认证返回 `401 AUTH_REQUIRED`,伪造录音 ID 播放返回 `404 RECORDING_NOT_READY`;充值重放两次返回同一充值 ID `rch_da0bba1c7b1d44428ab92d77a55e503d`,数据库 `customer_recharges.rows=1`、`idempotency_keys.rows=1`,余额只增加 `0.000001` 一次;B 实际 Prometheus 服务 `lisglosips-prometheus.service` active 且 ready;A/B/T 关键服务复核 active。 +- 回滚方式:T 可恢复 S29 前 `/opt/lisglosips-s28/lisglosips-s28-sip.py` 或继续使用修复后的动态端口脚本;B 可用本次部署前保留在 `/tmp/s29-worker-cdr-backup-*` 与 `/tmp/s29-redis-backup-*` 的备份恢复 worker-cdr 与 packages/redis 后重启 `lisglosips@cdr-worker`;A Redis 故障恢复只做过服务重启,无配置文件改动。S29 产生的测试 CDR、录音、充值、幂等键和测试用户作为审计证据保留,不直接删除;如需清理应单独说明影响与回滚点。 +- 未解决问题:Redis 恢复后 A/OpenSIPS 热路径不会自动从 `Redis Unavailable` 恢复,当前运行时修复是重启 A `opensips`,根因级修复需继续排查 OpenSIPS Redis 连接池/重连策略;SIP 非法来源探针表现为无响应且未进入 OpenSIPS 日志,后续建议增加明确拒绝日志/指标;S29 期间 Tailscale 管理链路出现短时高延迟/丢包,记录为开发环境风险;真实多 A、多 B 和完整生产路由仍不在本任务范围。 +- 下一任务:`S30 - 备份、Runbook、灰度与上线`,尚未开始 + +### 2026-06-22 09:32 - S28 三机端到端联调完成 + +- 状态:已完成 +- 操作服务器:A、B、T、本地 +- 完成内容:补齐 S28 剩余录音与质检闭环。T 呼叫脚本新增双向 PCMU RTP 发送,UAS 在 200 OK 后向 A/RTPEngine 回送 RTP,主叫侧在 ACK 后向应答 SDP 媒体地址发送 RTP;A 的 S28 OpenSIPS 路由启用 `record-call=on`,保持 INVITE/ACK/BYE 经原 A,仍绑定本机 RTPEngine;修正 A `/dev/shm/voip_rec` 历史 root-owned 日期目录权限;修复 A recording finalizer 过早搬运问题,跳过 0 字节、仍被打开或未稳定的录音文件;B Recording Worker 新增按 RTPEngine 文件名反解 Call-ID 的 fallback 关联逻辑,使录音可自动关联 `raw_cdrs`,从而让质检抽样按客户/线路组命中。新增 S28 质检测试用户 `usr_s28_quality`、抽检规则 `qs_s28_e2e_20260622`,并通过 API 保存最新录音质检记录。 +- 修改文件:`infra/server-t/s28/lisglosips-s28-sip.py`、`infra/server-a/s28/opensips.cfg`、`infra/server-a/s19/scripts/lisglosips-recording-finalize`、`apps/worker-recording/src/transfer.ts`、`apps/worker-recording/src/transfer.spec.ts`、`IMPLEMENTATION_STATUS.md`;A 的 `/etc/opensips/opensips.cfg`、`/usr/local/sbin/lisglosips-recording-finalize` 和 `/dev/shm/voip_rec` 目录属主/权限;T 的 `/opt/lisglosips-s28/lisglosips-s28-sip.py`;B 当前 release `/opt/lisglosips/releases/s28-v2-20260621220924/apps/worker-recording/` 的源码与 dist 产物 +- 安装软件及固定版本:未安装新系统软件;继续使用 OpenSIPS `3.6.x`、RTPEngine `11.5.1.18-1ubuntu1.2`、Node.js `v22.22.2`、pnpm `10.33.0`、Prisma `6.19.0` +- 执行的关键命令:本地 `corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts`、`corepack pnpm@10.33.0 --filter @lisglosips/worker-recording build`;A `opensips -C -f /tmp/opensips-s28.rendered.cfg` 后重启 `opensips`,更新 finalizer 后保持 timer active;T `/opt/lisglosips-s28/lisglosips-s28-sip.py invite --timeout 10 --hold 3`;B 替换 worker-recording 产物并重启 `lisglosips@recording-worker`,通过 MySQL 和 API 只读/写入测试核对。所有 sudo/数据库/API secret 均从 `.codex-private/` 或远端 env 读取并只在进程内使用,未输出或写入明文密码。 +- 验证结果:最终验收 Call-ID `s28-1782091725991-ewqrpe7w@lisglosips-t`;T 收到 `100 Giving it a try`、`200 OK`、BYE `200 OK`;B `raw_cdrs` 生成 `raw_aa6cbaf7758f41ea895a2889f558559d`,`duration_sec=6`、`rating_status=RATED`;`rated_cdrs` 对应客户费用和供应商成本均为 `0.012000`、`bill_sec=6`;B `recordings` 生成 `rec_24b56d60dfd14937ae2e4c09802a91be`,自动关联该 raw CDR,`status=READY`、`bytes=55758`、SHA-256 前缀 `e2cfaf8c7bbc6023`;API `/api/v2/health/ready` 返回 ok,`GET /api/v2/recordings/rec_24b56d60dfd14937ae2e4c09802a91be` 返回 customer/lineGroup/callId/CDR 摘要,抽样规则 `qs_s28_e2e_20260622` 命中 `selected=true`,`PUT /api/v2/recordings/:id/review` 成功保存 `qrev_9672ee20119049a1a56082c35dc42e0f`,`GET /play` 返回 `200`、`content-type: audio/wav` 和内部 `x-accel-redirect`。A `opensips`、`rtpengine-daemon`、`rtpengine-recording-daemon`、`lisglosips-recording-finalize.timer` active;B `lisglosips@api`、`lisglosips@cdr-worker`、`lisglosips@recording-worker`、`mysql`、`redis-server`、`heplify-server` active;T `lisglosips-s28-uas`、`opensips` active。 +- 回滚方式:T 回滚恢复或删除 `/opt/lisglosips-s28/lisglosips-s28-sip.py`,停止 `lisglosips-s28-uas.service`;A 回滚可用 `/var/backups/lisglosips-s28/20260621T145920Z/opensips.cfg` 恢复 `/etc/opensips/opensips.cfg` 并语法检查后重启 `opensips`,finalizer 可恢复 S24 备份 `/var/backups/lisglosips-s24/20260621T121130Z/lisglosips-recording-finalize`,但旧脚本会重现提前搬运风险;B 回滚可将 worker-recording 产物恢复到本次替换前的 release 版本或把 `/opt/lisglosips/current` 指回前一 release 后重启相关服务。测试 CDR、余额流水、录音和质检记录作为验收证据保留,不直接删除;如必须清理,应另开维护步骤并先说明影响与回滚点。 +- 未解决问题:A 当前仍是 S28 单一测试落地端最小路由,不是完整多落地网关生产路由;录音 `duration_sec` 仍为 0,后续可在 Recording Worker 中读取 WAV 时长或由媒体侧补充;S28 调试期间存在一条 0 字节早期录音 `rec_f9b1457861444d4ebbddc11adb208d13` 作为问题证据保留;多 A 真实部署和多 B 高可用仍留给后续阶段。 +- 下一任务:S29 - 性能、故障与安全测试 + +### 2026-06-21 23:06 - S28 三机端到端联调推进 + +- 状态:进行中 +- 操作服务器:A、B、T、本地 +- 完成内容:将 B 从 S05 placeholder 发布为完整 V2 release `/opt/lisglosips/releases/s28-v2-20260621220924`,`/opt/lisglosips/current` 已切换到该 release;执行业务库迁移,补齐 `20260621093000_auth_sessions` additive 迁移记录,权限/角色种子已落库;`lisglosips@api` 和 `lisglosips@cdr-worker` active,API `/api/v2/health/live` 与 `/api/v2/health/ready` 返回 ok;补齐 B 录音拉取 `/etc/lisglosips/recording/ssh_config`,`lisglosips@recording-worker` 已恢复 active。新增 S28 测试业务数据:客户 `cus_s28_t`、客户网关 `cgw_s28_t_ip`、供应商 `ven_s28_t`、落地网关 `vgw_s28_t_uas`、线路组 `llg_s28_t` 和策略 `cgp_s28_t_default`;Redis 热路径配置版本为 `s28-v1`。在 T 部署专用落地模拟服务 `lisglosips-s28-uas.service`,监听 `100.93.185.30:50620`,只终结呼叫不回送 A,避免循环。A 部署 S28 最小真实路由:Lua 热路径 allow 后执行 RTPEngine offer/answer、转发到 T 专用落地端、写成功 CDR 到 Redis Stream,并为 T 源 IP 的 ACK/BYE 添加 fallback,确保回到原 A 后转给 T 落地端。 +- 修改文件:`infra/server-t/s28/lisglosips-s28-sip.py`、`infra/server-a/s28/opensips.cfg`、`pnpm-lock.yaml`、`IMPLEMENTATION_STATUS.md`;B 的 `/opt/lisglosips/releases/s28-v2-20260621220924`、`/etc/lisglosips/api.env`、`/etc/lisglosips/cdr-worker.env`、`/etc/lisglosips/recording-worker.env`、`/etc/lisglosips/recording/ssh_config`、`/etc/systemd/system/lisglosips@recording-worker.service.d/10-s28-recording.conf`;T 的 `/opt/lisglosips-s28/lisglosips-s28-sip.py` 和 `/etc/systemd/system/lisglosips-s28-uas.service`;A 的 `/etc/opensips/opensips.cfg` +- 安装软件及固定版本:未安装新系统软件;B 继续使用 Node.js `v22.22.2`、Corepack `0.34.6`、pnpm `10.33.0`、Prisma `6.19.0` +- 执行的关键命令:本地 `corepack pnpm@10.33.0 install --lockfile-only`、`corepack pnpm@10.33.0 build`、`corepack pnpm@10.33.0 prisma:validate`、`corepack pnpm@10.33.0 fetch`;B 端离线 `pnpm install --frozen-lockfile --offline --ignore-scripts`、MySQL 迁移/种子、systemd restart/enable;A `opensips -C -f /tmp/opensips-s28.rendered.cfg` 后重启 `opensips`;T 启动 `lisglosips-s28-uas.service`。凭据均从 `.codex-private/` 读取并通过 stdin/base64 参数传递,未写入文档或输出明文密码。 +- 验证结果:最终候选呼叫 Call-ID `s28-1782054233152-f4eiz799@lisglosips-t`;T 收到 `100 Giving it a try`、`200 OK`,随后 BYE 返回 `200 OK`;A 日志显示 hotpath allow、成功 CDR XADD `1782054232944-0`、ACK/BYE 均从 T 回到 A;RTPEngine session 最终回到 0;B MySQL `raw_cdrs` 生成 `raw_2903404fa10b47a6a4b7968e3662cdd5`,`sip_code=200`、`hangup_reason=NORMAL_CLEARING`、`customer_id=cus_s28_t`、`vendor_gateway_id=vgw_s28_t_uas`、`duration_sec=6`;`rated_cdrs` 生成客户费用/供应商成本 `0.012000`、计费秒数 `6`;客户 `cus_s28_t` 余额扣减到 `99.928000`(包含本次和前面 S28 调试成功 CDR);HOMER `hep_proto_1_call` 可查到该 Call-ID 的 `INVITE`、`ACK`、`BYE`;B API ready ok,`lisglosips@api`、`lisglosips@cdr-worker`、`lisglosips@recording-worker` 均 active;T S28 UAS active。 +- 回滚方式:B 回滚先停止 `lisglosips@cdr-worker` 和 `lisglosips@recording-worker`,将 `/opt/lisglosips/current` 指回备份记录中的 S05 placeholder release,并重启 `lisglosips@api`;B 迁移前备份位于 `/var/backups/lisglosips-s28/20260621T140940Z`,如需撤回数据库写入应走备份恢复或受控反向账务处理,不直接删除 CDR/余额流水。A 回滚使用 `/var/backups/lisglosips-s28/20260621T145920Z/opensips.cfg` 恢复 `/etc/opensips/opensips.cfg`,语法检查后重启 `opensips`。T 回滚停止并删除 `lisglosips-s28-uas.service` 和 `/opt/lisglosips-s28`。Redis 配置可将 `cfg:active_version` 切回 `cfg:previous_version` 或清理 `s28-v1` 测试 keys,清理前需确认没有后续测试依赖。 +- 未解决问题:本次 T 脚本只做 SIP/SDP,不发送 RTP 音频包,因此 RTPEngine 未产生真实录音文件;Recording Worker 虽已 active,但尚未完成真实通话 `.ready` 文件搬运、`recordings` 入库和质检抽样/保存核对。S28 仍需补“RTP 音频生成 -> A 录音 ready -> B Recording Worker 入库 -> 质检接口核对”后才能标记已完成。当前 A 配置中的 S28 路由是单一测试落地端 fallback,不是完整多落地网关生产路由。 +- 下一任务:继续 `S28 - 三机端到端联调`,补齐录音和质检闭环;不得进入 S29。 + +### 2026-06-21 21:47 - S28 三机端到端联调 + +- 状态:已阻塞 +- 操作服务器:A、B、T;仅执行一次 S28 测试 INVITE 和只读核对,未修改服务器配置,未重启服务,未执行数据库迁移、数据删除或防火墙变更 +- 完成内容:按 S28 目标先做三机链路探针。T 使用 S21 脚本向 A `100.90.90.90:15060` 发起 IP 认证 INVITE,Call-ID 为 `s21-invite-1782049439554-088rul75@lisglosips-t`;A 返回 `SIP/2.0 503 Config Missing`;A OpenSIPS 日志确认热路径拒绝原因为 `CONFIG_MISSING`,并成功向 B Redis Stream 写入失败 CDR;B Redis Stream `stream:cdr_payload` 可按 Call-ID 查到该事件,字段包含 `node_id=a1`、`opensips_instance=opensips-a1`、`ingress_a_ip=100.90.90.90`、`rtpengine_node=a1`、`sip_code=503`、`hangup_reason=CONFIG_MISSING`;B HOMER `hep_proto_1_call` 最近记录的 `sid` 与 `data_header.callid` 均为该 Call-ID,`data_header.method=INVITE`,说明 HEP 信令镜像入库正常。 +- 修改文件:`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新软件 +- 执行的关键命令:T `/opt/lisglosips-s21/s21-call-a-ip`;A `journalctl -u opensips --since '10 minutes ago'` 按 Call-ID 查询;B `redis-cli XREVRANGE stream:cdr_payload` 按 Call-ID 查询;B PostgreSQL `homer_data.hep_proto_1_call` 只读查询;B 本机 API/数据库只读探针。所有凭据均从 `.codex-private/` 读取并通过 stdin 使用,未输出明文密码。 +- 验证结果:T->A SIP 输入、A->B Redis Stream 失败 CDR、A->B HOMER HEP 信令三段可按 Call-ID 串联;但未进入真实路由、媒体、录音、计费或质检闭环。B 当前 `/api/v2/health/live` 返回 HTTP 404,`lisglosips` 业务库表数为 `0`,说明 current release 仍不是完整 V2 API/Worker;A 当前 `/etc/opensips/opensips.cfg` 仍为 S20/S22 占位逻辑,配置缺失时返回 `Config Missing`,即便配置 allow 也会返回 `Routing Not Ready`,没有真实落地网关选择、RTPEngine offer/answer、t_relay 和对话内 ACK/BYE/re-INVITE 路由。 +- 回滚方式:本次未修改服务器配置和数据库结构,无需服务回滚;产生的一条测试 INVITE、HOMER 记录和 Redis Stream 失败 CDR 作为联调证据保留,不做直接删除。若后续需要清理测试数据,应先单独说明影响和回滚点。 +- 未解决问题:S28 阻塞在两个前置缺口:一是 B 需要受控发布完整 V2 API、Web 和 CDR/Recording Worker,并在空业务库上执行迁移/种子和健康检查;二是 A 需要新增真实呼叫路由配置,至少包含从 Redis 配置读取线路组/落地网关、供应商目标地址、RTPEngine offer/answer、t_relay、onreply/failure/dialog 内路由和成功/失败 CDR 事件。当前不能进入 S29。 +- 下一任务:继续 `S28 - 三机端到端联调`,先解除上述阻塞;不得提前执行 S29。 + +### 2026-06-21 21:32 - S27 React Demo 接入真实 API + +- 状态:已完成 +- 操作服务器:本地;未连接 A/B/T,未执行服务器写操作、服务重启、数据库迁移或数据删除 +- 完成内容:新增 Web API Client,默认使用 `/api/v2`,开发环境通过 Vite proxy 转发到本地 API `127.0.0.1:3000`,可选从浏览器 `localStorage` 读取 `lisglosips.accessToken` 作为 Bearer Token,不写入或输出任何明文凭据;Dashboard 接入 `dashboard/summary` 和 `dashboard/trends`;客户、供应商、充值记录、用户、角色、操作日志接入真实列表 API;客户/供应商新增、编辑、充值调用真实 API 并在成功后重新拉取数据;页面新增统一加载、错误和空状态,未登录或无权限时显示鉴权提示;顶部状态从“纯前端 Mock”改为真实 API 状态。CDR、计费、SIP 运维、监控告警、系统设置仍保留为未完成业务页面/静态运维原型,等待对应后端查询接口或后续延期页面设计,不在本任务伪造真实数据。 +- 修改文件:`apps/web/src/api.js`、`apps/web/src/App.jsx`、`apps/web/src/styles.css`、`apps/web/vite.config.js`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新软件;继续使用 React `18.2.0`、Vite `5.4.21`、pnpm `10.33.0` +- 执行的关键命令:`corepack pnpm@10.33.0 --filter @lisglosips/web build`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`、本地启动 `corepack pnpm@10.33.0 --filter @lisglosips/web dev` +- 验证结果:Web 单包 build 通过;全 workspace `typecheck`、`lint`、`build` 均通过;本地 Vite 页面 `http://127.0.0.1:5173/` 返回 HTTP 200。API 服务若未启动或未登录,前端会展示错误态而不是回退到静态运营数据。 +- 回滚方式:删除 `apps/web/src/api.js`,恢复 `apps/web/src/App.jsx` 中本次 API 数据加载、真实接口操作、空状态和顶部 API 状态改动,恢复 `apps/web/src/styles.css` 的空状态样式和 `apps/web/vite.config.js` proxy 配置,重新执行 `corepack pnpm@10.33.0 --filter @lisglosips/web build`;S27 未修改远端服务或数据库,无远端数据需回滚。 +- 未解决问题:B 当前 `/opt/lisglosips/current` 仍是 S05 placeholder release,本次未部署到 B 当前 Web/API 服务;前端尚无登录页,真实接口需要已有 Bearer Token 或同源 Cookie 才能返回业务数据;CDR 查询、计费页面、自研 SIP 运维、监控告警、系统设置仍缺少对应产品化后端页面接口或属于延期页面,S27 未提前实现。 +- 下一任务:`S28 - 三机端到端联调` + +### 2026-06-21 21:20 - S26 Dashboard 聚合 + +- 状态:已完成 +- 操作服务器:本地;未连接 A/B/T,未执行服务器写操作、服务重启、数据库迁移或数据删除 +- 完成内容:新增 `DashboardModule`,实现 `GET /api/v2/dashboard/summary` 和 `GET /api/v2/dashboard/trends`;接口统一要求 `dashboard.view` 权限;summary 按 `Asia/Shanghai` 今日窗口聚合 raw CDR、rated CDR、启用客户/网关、待质检数量、失败响应码和失败落地网关 Top 10;trends 支持 `hours=1..168`、`bucketMinutes=5|15|60`,按固定桶返回通话、接通率、时长、客户费用、供应商成本和毛利;实时在线通话和注册数当前返回 `0` 并标记 `source: "not_configured"`,等待后续接入 Redis/OpenSIPS 指标。未新增预聚合表,未做 `worker-metrics`,未进入 S27 前端接入。 +- 修改文件:`apps/api/src/modules/app.module.ts`、`apps/api/src/modules/dashboard/`、`docs/DASHBOARD_AGGREGATION_RUNBOOK.md`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新软件;继续使用既有 NestJS/Fastify、Prisma `6.19.0`、Vitest `4.0.14`、TypeScript `5.9.3` +- 执行的关键命令:`corepack pnpm@10.33.0 exec vitest run apps/api/src/modules/dashboard/dashboard.service.spec.ts`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build` +- 验证结果:S26 聚焦测试 1 个文件 4 条通过,覆盖上海自然日边界、接通率、趋势桶和参数校验;全 workspace `typecheck`、`lint`、`build` 均通过 +- 回滚方式:从 `AppModule` 移除 `DashboardModule`,删除 `apps/api/src/modules/dashboard/` 和 `docs/DASHBOARD_AGGREGATION_RUNBOOK.md`,重新执行 `corepack pnpm@10.33.0 build`;S26 未对远端数据库写入,无远端数据需清理 +- 未解决问题:B 当前 `/opt/lisglosips/current` 仍是 S05 placeholder release,本次未部署到 B 当前 API 服务;Dashboard 设计要求的真正实时在线通话/注册数仍需后续接入 Redis/OpenSIPS 指标;24 小时趋势最终应由 `worker-metrics` 或 CDR Worker 增量写入预聚合表,本次只做时间窗口受限查询,避免扫描全量 CDR。 +- 下一任务:`S27 - React Demo 接入真实 API` + +### 2026-06-21 21:05 - S25 质检后端 + +- 状态:已完成 +- 操作服务器:本地;未连接 A/B/T,未执行服务器写操作、服务重启、数据库迁移或数据删除 +- 完成内容:新增 `QualityModule`,实现 `GET/POST/PATCH/DELETE /api/v2/quality/rules` 及规则启停,支持按客户、线路组、比例、生效时间和过期时间配置抽检规则;新增稳定抽样算法 `sha256(rule_id:recording_id)`,同一规则与录音重复查询结果稳定;扩展 `RecordingsModule`,实现 `GET /api/v2/recordings`、`GET /api/v2/recordings/:id`、`PUT /api/v2/recordings/:id/review`,返回录音 CDR 摘要、最新质检、历史质检、上下条导航和抽样命中信息;保存质检结果写入 `quality_reviews`,不覆盖历史;规则变更和质检保存均接入 `AuditAction` 元数据;权限使用 `quality.view`、`quality.manage`、`recordings.play`。未做 Dashboard 聚合,未扩展 S26 内容。 +- 修改文件:`apps/api/src/modules/app.module.ts`、`apps/api/src/modules/quality/`、`apps/api/src/modules/recordings/recordings.controller.ts`、`apps/api/src/modules/recordings/recordings.repository.ts`、`apps/api/src/modules/recordings/recordings.service.ts`、`apps/api/src/modules/recordings/recordings.module.ts`、`apps/api/src/modules/recordings/recordings.service.spec.ts`、`docs/QUALITY_BACKEND_RUNBOOK.md`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新软件;继续使用既有 NestJS/Fastify、Prisma `6.19.0`、Vitest `4.0.14`、TypeScript `5.9.3` +- 执行的关键命令:`corepack pnpm@10.33.0 exec vitest run apps/api/src/modules/quality/sampling.spec.ts apps/api/src/modules/recordings/recordings.service.spec.ts`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build` +- 验证结果:S25 聚焦测试 2 个文件 6 条通过,覆盖稳定抽样可重复、比例边界、录音列表抽样状态、保存质检输入校验和 S24 内部播放路径安全;全 workspace `typecheck`、`lint`、`build` 均通过 +- 回滚方式:删除或恢复 `apps/api/src/modules/quality/`,将 `apps/api/src/modules/recordings/` 恢复到 S24 版本,从 `AppModule` 移除 `QualityModule`,重新执行 `corepack pnpm@10.33.0 build`;S25 未对远端数据库写入,无远端数据需清理;若后续已运行 API 并产生 `quality_reviews`,不直接删除历史记录,应通过审计可见的更正记录或受控维护窗口处理 +- 未解决问题:B 当前 `/opt/lisglosips/current` 仍是 S05 placeholder release,本次未部署到 B 当前 API 服务;真实录音和 raw CDR 关联仍依赖 S28 端到端闭环产生;Dashboard 的待质检数量、趋势和聚合指标留给 S26。 +- 下一任务:`S26 - Dashboard 聚合` + +### 2026-06-21 20:20 - S24 录音搬运与播放 + +- 状态:已完成 +- 操作服务器:A、B、本地;T 未操作 +- 完成内容:新增 Recording Worker 搬运服务,支持扫描 A `/dev/shm/voip_rec/ready/**/*.ready`、SSH 私网拉取、文件大小校验、SHA-256、B 端 `.part-*` 临时文件原子改名、写入 `recordings` 表、校验成功后才删除 A 源 `.ready`;新增 `GET /api/v2/recordings/:id/play`,要求 `recordings.play` 权限,状态必须为 `READY`,通过 `X-Accel-Redirect` 转发到 Nginx 内部 `/_recordings/`,不暴露真实 `/data/recordings` 路径;A 端 finalizer ready 目录改为 `0770`,使专用拉取用户可在校验后删除源文件;B 建立专用拉取 key 和 known_hosts;A 建立专用低权限用户 `lisglo-rec-pull` 并加入 `rtpengine` 组。未调整 SSH 管理端口,未禁止 SSH key 登录,未重启 OpenSIPS、RTPEngine、Nginx 或 API。 +- 修改文件:`apps/worker-recording/src/main.ts`、`apps/worker-recording/src/transfer.ts`、`apps/worker-recording/src/transfer.spec.ts`、`apps/worker-recording/package.json`、`apps/worker-recording/tsconfig.json`、`apps/api/src/modules/app.module.ts`、`apps/api/src/modules/recordings/`、`pnpm-lock.yaml`、`infra/server-a/s19/scripts/lisglosips-recording-finalize`、`docs/RECORDING_TRANSFER_PLAYBACK_RUNBOOK.md`、`IMPLEMENTATION_STATUS.md`;A 的 `/usr/local/sbin/lisglosips-recording-finalize`、`/var/lib/lisglo-rec-pull/.ssh/authorized_keys` 和 `/dev/shm/voip_rec/ready` 权限;B 的 `/etc/lisglosips/recording/` 和 `/data/recordings/s24-smoke/s24-smoke.wav` +- 安装软件及固定版本:未安装新软件;继续使用既有 OpenSSH、Node.js `v22.22.2`、pnpm `10.33.0`、Nginx `1.24.0-2ubuntu7.12`。新增系统用户 `lisglo-rec-pull`,未新增系统包。 +- 执行的关键命令:本地 `corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts apps/api/src/modules/recordings/recordings.service.spec.ts`、`corepack pnpm@10.33.0 --filter @lisglosips/worker-recording build`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`;A `bash -n /usr/local/sbin/lisglosips-recording-finalize`、创建 S24 测试 incoming 文件并运行 finalizer;B 使用 `lisglo-recorder` 通过专用 key 从 A 拉取、校验、落盘、删除测试源文件。sudo 凭据通过 `.codex-private/` 读取并经 stdin 使用,未回显。 +- 验证结果:本地 2 个测试文件 5 条测试通过,覆盖路径安全、`.ready` 转 storage key、复制/哈希/落盘/源删除、大小不匹配不删除源文件、API 内部路径穿越防护;`worker-recording` 单包 build、全 workspace `typecheck`、`lint`、`build` 均通过;A 测试文件从 `incoming` 进入 `ready`,B 私网拉取到 `/data/recordings/s24-smoke/s24-smoke.wav`,大小 `31`,属主 `lisglo-recorder:lisglosips`,SHA-256 为 `c72dd3909a317ad55817435b7ed5a0db6947455ffc66e09a2dde787fe4a8c348`,校验后 A 源 `.ready` 已删除;外部直接访问 `https://127.0.0.1/_recordings/s24-smoke/s24-smoke.wav` 返回 `404`,Nginx 内部录音 location 未暴露。 +- 回滚方式:A 备份目录 `/var/backups/lisglosips-s24/20260621T121130Z`,可恢复 `lisglosips-recording-finalize`、删除 `lisglo-rec-pull` 用户并将 `/dev/shm/voip_rec/ready` 恢复 `0750`;B 备份目录 `/var/backups/lisglosips-s24/20260621T121100Z`,可删除 `/etc/lisglosips/recording` 和 `/data/recordings/s24-smoke`;代码回滚恢复本次新增/修改文件后重新 build。详见 `docs/RECORDING_TRANSFER_PLAYBACK_RUNBOOK.md`。 +- 未解决问题:B 当前 `/opt/lisglosips/current` 仍是 S05 placeholder release,尚未切换完整 monorepo API/Worker release,因此本次未启用正式 `lisglosips@recording-worker.service`,API 播放入口完成代码与构建但未在 B 当前 release 对外提供;真实通话录音字段仍依赖后续 S28 端到端路由/媒体闭环产生。 +- 下一任务:`S25 - 质检后端` + +### 2026-06-21 20:02 - B sudo 凭据复验 + +- 状态:已完成 +- 操作服务器:B、本地;仅执行只读 SSH 与 sudo 校验,未修改服务器配置、未重启服务、未执行数据库写入或数据删除 +- 完成内容:读取 `.codex-private/` 中 DPAPI 保存的 B 凭据且未回显密码;复验 B SSH Key 登录正常;确认 `hector` 属于 `sudo` 组;使用保存的 sudo 凭据执行 `sudo -v` 成功;使用 sudo 执行只读权限检查成功 +- 修改文件:`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:无 +- 执行的关键命令:`ssh -F .codex-private\ssh\config lisglosips-b ...`;PowerShell `Import-Clixml .codex-private\SERVER_CREDENTIALS.clixml` 后通过 stdin 向远端 `sudo -S` 提供凭据,命令与输出均未包含明文密码 +- 验证结果:B 返回主机名 `YanZi`,用户 `hector` 在 `sudo` 组;`sudo -v` 退出码为 0;只读 sudo 检查退出码为 0 +- 回滚方式:无服务器变更无需回滚;如需撤销本地状态记录,恢复本文件本节和顶部阻塞说明 +- 未解决问题:S13-S17 历史任务仍未补做 B 临时库冒烟;若后续补验会涉及数据库写入,应在执行前单独说明影响与回滚点 +- 下一任务:`S24 - 录音搬运与播放` + +### 2026-06-21 19:55 - S23 最小计费与余额扣减 + +- 状态:已完成 +- 操作服务器:本地;未连接 A/B/T,未执行服务器写操作、服务重启、数据库迁移或数据删除 +- 完成内容:在 `worker-cdr` 中接入最小计费服务,Redis Stream CDR 消费后写入 `raw_cdrs`、成功通话写入 `rated_cdrs`、按落地网关周期秒数和周期费率计算费用、扣减客户余额,并用负数 `customer_recharges` 记录不可变扣费流水;同一 `event_id` 或同一 `raw_cdr_id` 不重复扣费;失败 CDR、零时长 CDR、缺少客户或落地网关 ID 的 CDR 标记 `SKIPPED` 且不扣费。当前尚无客户侧费率表,S23 采用落地网关费率临时镜像为客户费用,供应商成本相同,毛利为 0,后续真实计费版本再扩展客户费率和利润计算。 +- 修改文件:`apps/worker-cdr/src/main.ts`、`apps/worker-cdr/src/billing.ts`、`apps/worker-cdr/src/rating.ts`、`apps/worker-cdr/src/rating.spec.ts`、`apps/worker-cdr/package.json`、`apps/worker-cdr/tsconfig.json`、`apps/worker-cdr/dist/`、`docs/CDR_MINIMAL_BILLING_RUNBOOK.md`、`IMPLEMENTATION_STATUS.md`;本地因 `pnpm install` 受 registry DNS/metadata 影响,手工补齐 `apps/worker-cdr/node_modules/@lisglosips/database` 到 `packages/database` 的开发 junction +- 安装软件及固定版本:未安装新软件;继续使用既有 Node.js/pnpm、Prisma `6.19.0`、Vitest `4.0.14`、TypeScript `5.9.3` +- 执行的关键命令:`corepack pnpm@10.33.0 exec vitest run apps/worker-cdr/src/rating.spec.ts`、`corepack pnpm@10.33.0 --filter @lisglosips/worker-cdr build`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`;尝试 `corepack pnpm@10.33.0 install` 与 `install --offline` 时被 npm registry DNS/本地 metadata 问题阻塞,未作为验收依据 +- 验证结果:`apps/worker-cdr/src/rating.spec.ts` 3 条测试通过,覆盖周期计费、成功 CDR 扣费幂等、失败/零时长 CDR 不扣费;`worker-cdr` 单包 build 通过;全 workspace `typecheck`、`lint`、`build` 均通过 +- 回滚方式:恢复 `apps/worker-cdr/src/main.ts` 到 S22 只记录 CDR 的版本,删除或恢复 S23 新增的 `billing.ts`、`rating.ts`、`rating.spec.ts`,恢复 `apps/worker-cdr/package.json` 和 `tsconfig.json` 后重新执行 `corepack pnpm@10.33.0 build`;S23 未执行真实数据库写入验证,无远端数据需要清理;若后续已运行 worker 并产生扣费,必须先停止 worker,再通过受控反向流水或备份恢复处理,不直接删除余额流水 +- 未解决问题:B sudo 凭据仍不可用,本次未部署 B systemd worker、未对真实 B MySQL 执行写入式冒烟;当前最小计费暂时镜像落地网关费率为客户费用,客户费率表、利润规则、低余额拦截和 Dashboard 聚合留给后续任务;`pnpm install` 当前受 registry DNS/metadata 影响,后续若新增依赖需优先修复本地包管理器联网或缓存 +- 下一任务:`S24 - 录音搬运与播放` + +### 2026-06-21 19:34 - S22 CDR Redis Stream + +- 状态:已完成 +- 操作服务器:A、T、本地;B 仅通过 A 的 Redis AUTH proxy 写入 Redis Stream Consumer Group 元数据,未使用 B sudo,未改 B 服务 +- 完成内容:新增 CDR Stream schema v1 和可靠消费库,支持 `stream:cdr_payload`、`stream:cdr_deadletter`、`billing-workers` Consumer Group、`XREADGROUP`、`XACK`、`XAUTOCLAIM` Pending 重领、死信写入和 `lock:cdr:{event_id}` 幂等占位;`worker-cdr` 从骨架改为 Redis Stream Worker,启动时确保 Consumer Group,循环先处理 Pending 再读取新消息,当前 handler 只接收/记录原始 CDR,不做计费和余额扣减;按多 A 后续演进预留 `node_id`、`opensips_instance`、`ingress_a_ip`、`rtpengine_node` 字段,当前单 A 值为 `a1`、`opensips-a1`、`100.90.90.90`、`a1`;升级 Server A OpenSIPS `route[S20_CDR_XADD]`,从 S20 冒烟 XADD 改为 S22 正式事件字段;在 Redis 创建 `billing-workers` Consumer Group。未调整 SSH 管理端口或密钥登录策略,未写入明文密码。 +- 修改文件:`packages/redis/src/index.ts`、`packages/redis/src/cdr-stream.ts`、`packages/redis/src/cdr-stream.spec.ts`、`apps/worker-cdr/src/main.ts`、`packages/redis/dist/`、`apps/worker-cdr/dist/`、`infra/server-a/s22/opensips/opensips.cfg`、`docs/CDR_REDIS_STREAM_RUNBOOK.md`、`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md`;A 的 `/etc/opensips/opensips.cfg`;Redis `stream:cdr_payload` Consumer Group `billing-workers` +- 安装软件及固定版本:未安装新系统软件;继续使用既有 Node.js/pnpm、OpenSIPS `3.6.7-1`、Redis `7.0.15`。新增代码复用既有 `ioredis 5.8.2`。 +- 执行的关键命令:本地 `corepack pnpm@10.33.0 exec vitest run packages/redis/src/cdr-stream.spec.ts`、`corepack pnpm@10.33.0 lint/typecheck/build`;A `opensips -C -f /etc/opensips/opensips.cfg`、`systemctl restart opensips`;A 经本地 Redis proxy 执行 `XGROUP CREATE stream:cdr_payload billing-workers 0 MKSTREAM`、`XINFO GROUPS`、`XREVRANGE`;T 执行 `/opt/lisglosips-s21/lisglosips-s21-sip.py invite --timeout 8`。sudo 凭据通过 `.codex-private/` 读取且未回显。 +- 验证结果:Redis Stream 单元测试 6 条通过,覆盖发布/解析、Consumer Group 幂等创建、`XREADGROUP`/`XACK`、重复 `event_id` 幂等、死信、`XAUTOCLAIM` Pending 重领;`lint`、`typecheck`、全 workspace `build` 均通过;A OpenSIPS 配置语法检查通过并重启后 active;Redis `XINFO GROUPS stream:cdr_payload` 显示 `billing-workers`,pending 为 0;T 发 INVITE 后 A 返回 `SIP/2.0 503 Config Missing`,A 日志显示 CDR XADD 成功,Redis 最新事件包含 `schema_version=1`、`idempotency_key`、`node_id=a1`、`opensips_instance=opensips-a1`、`ingress_a_ip=100.90.90.90`、`rtpengine_node=a1`、`sip_code=503`、`hangup_reason=CONFIG_MISSING`。一次使用 S21 默认 3 秒超时的探针未收到响应,但 A 已写入 CDR;随后使用 8 秒超时复测收到预期 `503 Config Missing`。 +- 回滚方式:A 备份目录为 `/var/backups/lisglosips-s22/20260621T113157Z`,恢复其中 `opensips.cfg` 后执行 `opensips -C -f /etc/opensips/opensips.cfg` 并重启 `opensips`;代码回滚恢复本次新增/修改的 Redis/worker 文件后重新 build;Redis Consumer Group 可保留,停止 worker 即可,不删除 `stream:cdr_payload` 数据。详见 `docs/CDR_REDIS_STREAM_RUNBOOK.md`。 +- 未解决问题:S22 只完成原始 CDR Stream 契约和可靠消费基础,不写 MySQL raw/rated CDR、不计费、不扣余额,这些留给 S23;A 端当前仍未发布真实路由配置,因此测试 CDR 为 `CONFIG_MISSING` 失败事件;B sudo 凭据仍不可用,本次未执行 B 端服务部署或 systemd worker 验收;多 A 仅完成字段预留和 A1 默认值,真实 3A 部署留到单 A 闭环之后。 +- 下一任务:`S23 - 最小计费与余额扣减` + +### 2026-06-21 15:28 - S21 Server T 客户模拟配置 + +- 状态:已完成 +- 操作服务器:T、本地;A 仅接收 T 发起的 SIP 探针,未写入;B 未操作 +- 完成内容:在 Server T 建立 S21 客户模拟环境;新增测试域 `s21.lisglosips.test`;新增两套 SIP 注册测试账号 `s21-reg-1001`、`s21-reg-1002`,OpenSIPS 改为 HA1 鉴权,`subscriber.password` 为空且不保存 SIP 明文密码;新增两套 IP 认证测试条目 `s21-ip-1001`、`s21-ip-1002` 指向 T `100.93.185.30/32`;部署 `/opt/lisglosips-s21` 自包含 SIP 脚本,支持注册成功、错误鉴权、向 A 发起 IP/SIP 模拟 INVITE、本地成功/忙/拒绝/超时场景和突发 CPS 探针;收敛 T 的 MI FIFO 权限为 `0660`,TLS 私钥权限为 `0600`。未调整 SSH 管理端口或密钥登录策略,未写入明文密码。 +- 修改文件:T 的 `/etc/opensips/opensips.cfg`、OpenSIPS `opensips` 数据库 `domain/subscriber/address/location` 测试数据、`/opt/lisglosips-s21/`、`/etc/lisglosips-s21/sip-accounts.env`;本地 `infra/server-t/s21/`、`docs/SERVER_T_CUSTOMER_SIMULATION_RUNBOOK.md`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新系统软件;继续使用 T 既有 OpenSIPS `3.6.6`、RTPEngine `11.5.1.18`、MariaDB `10.11.14` 和系统 Python 3。新增 SIP 测试脚本无第三方依赖。 +- 执行的关键命令:`mysqldump --single-transaction --routines --events opensips`、`opensips -C -f /etc/opensips/opensips.cfg`、`systemctl restart opensips`、`mysql --protocol=socket -uroot`、`python3 -m py_compile`、`/opt/lisglosips-s21/s21-register-*`、`s21-local-*`、`s21-call-a-*`、`s21-over-cps-a`。sudo 凭据通过 `.codex-private/` 读取且未回显。 +- 验证结果:备份目录 `/var/backups/lisglosips-s21/20260621T072316Z` 已生成并含 OpenSIPS 配置和数据库 dump;OpenSIPS 配置语法检查通过,`opensips.service` active;`s21-register-primary` 和 `s21-register-secondary` 均先收到 `401 Unauthorized` challenge 后最终 `200 OK`,`s21-register-wrong-ha1` 最终 `401 Unauthorized`;`location` 表已有 `s21-reg-1001`、`s21-reg-1002` 注册位置;`subscriber.password` 长度为 0,`ha1` 长度为 32;本地脚本分别返回 `200 OK`、`486 Busy Here`、`603 Decline` 和 `NO RESPONSE`;从 T 到 A 的 `s21-call-a-ip`、`s21-call-a-sip` 与 5 次 `s21-over-cps-a` 均稳定输出 Call-ID 并收到 A 当前预期的 `SIP/2.0 503 Config Missing`;MI FIFO 为 `660 opensips:opensips`,TLS 私钥文件为 `600 root:root`。 +- 回滚方式:停止 T 的 `opensips`,从 `/var/backups/lisglosips-s21/20260621T072316Z/opensips` 恢复 `/etc/opensips`,用该目录 `opensips.sql` 恢复 OpenSIPS 数据库,删除 `/opt/lisglosips-s21` 和 `/etc/lisglosips-s21`,执行 `opensips -C -f /etc/opensips/opensips.cfg` 后启动 `opensips`。详见 `docs/SERVER_T_CUSTOMER_SIMULATION_RUNBOOK.md`。 +- 未解决问题:A 端尚未发布真实客户/路由配置,因此 T 到 A 的 INVITE 当前按 S20 逻辑返回 `503 Config Missing`,真实路由、双向媒体、正式 CDR 和扣费留给 S22/S28;T 的 RTPEngine 仍无内核加速,防火墙默认放行、Apache/rpcbind 暴露和克隆身份问题仍为历史风险,未在 S21 扩大处理。 +- 下一任务:`S22 - CDR Redis Stream` + +### 2026-06-21 15:05 - S20 Server A Redis 热路径与 HEP/指标 + +- 状态:已完成 +- 操作服务器:A、B、T、本地 +- 完成内容:在 Server A 部署 Redis 热路径 Lua 原子检查,基于 `cfg:active_version` 和版本化 `cfg:v:{version}:*` 配置读取客户、客户网关、IP 认证、策略与线路组;OpenSIPS `INVITE` 接入 Lua 热路径,配置缺失返回 `503 Config Missing`,Redis 不可用返回 `503 Redis Unavailable`,策略允许但路由未进入 S21/S28 前返回 `503 Routing Not Ready`;新增 CDR 冒烟 XADD 到 `stream:cdr_payload`;新增本地 Redis AUTH 代理,凭据通过 systemd encrypted credential 加载,OpenSIPS 只连接 `127.0.0.1:6380`;修正 HEP socket 为 `100.90.90.90:9061` 并发送至 B `100.90.90.91:9060`;新增 OpenSIPS Prometheus `script:` 指标和 Node Exporter textfile 指标;新增 S20 nftables 表阻断非本地 Redis 代理和非既有连接访问 HEP socket;未调整 SSH 管理端口或密钥登录策略,未写入明文密码。 +- 修改文件:A 的 `/etc/opensips/opensips.cfg`、`/etc/opensips/lisglosips_hotpath.lua`、`/usr/local/sbin/lisglosips-redis-auth-proxy`、`/usr/local/sbin/lisglosips-redis-load-hotpath`、`/usr/local/sbin/lisglosips-opensips-preprocess`、`/usr/local/sbin/lisglosips-s20-metrics`、`/etc/systemd/system/lisglosips-redis-auth-proxy.service`、`/etc/systemd/system/lisglosips-redis-hotpath-load.service`、`/etc/systemd/system/opensips.service.d/20-lisglosips-s20.conf`、`/etc/systemd/system/lisglosips-s20-metrics.service`、`/etc/systemd/system/lisglosips-s20-metrics.timer`、`/etc/default/lisglosips-node-exporter`、`/etc/nftables.d/lisglosips-s20-observability.nft`、`/etc/systemd/system/lisglosips-a-firewall.service`;本地 `infra/server-a/s20/`、`docs/SERVER_A_REDIS_HEP_METRICS_RUNBOOK.md`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新系统软件;继续使用 OpenSIPS `3.6.7-1`、Redis Server B 既有 `7.0.15`、Node Exporter `1.7.0`、RTPEngine `11.5.1.18-1ubuntu1.2`。新增 systemd encrypted credential 文件 `/etc/credstore.encrypted/lisglosips-redis-password.cred`,未写明文凭据。 +- 执行的关键命令:`opensips -C -f /etc/opensips/opensips.cfg`、`systemctl daemon-reload/enable/restart/start/stop`、`nft -c/-f`、`curl http://127.0.0.1:8888/metrics`、`curl http://100.90.90.90:9100/metrics`、从 T 发送 SIP `INVITE`,A/B Redis RESP 冒烟,A `tcpdump` 观察 HEP,B `journalctl -u heplify-server` 查询 HEP 统计。所有凭据均通过 `.codex-private/` 读取并已避免回显。 +- 验证结果:A `systemctl is-system-running` 为 `running`;`opensips`、`lisglosips-redis-auth-proxy`、`lisglosips-s20-metrics.timer`、`lisglosips-node-exporter`、`lisglosips-a-firewall`、`rtpengine-daemon`、`rtpengine-recording-daemon` 均 active,S20 新增服务均 enabled;`lisglosips-redis-hotpath-load.service` oneshot 结果为 `success`;监听端口为 `100.90.90.90:15060/udp`、`100.90.90.90:9061/udp`、`127.0.0.1:6380/tcp`、`127.0.0.1:8888/tcp`、`100.90.90.90:9100/tcp`;S20 nftables 表存在;A 到 B Redis PING/AUTH 通过;无配置版本时 T 发 INVITE 返回 `SIP/2.0 503 Config Missing`,OpenSIPS 记录热路径拒绝且 CDR XADD 成功;临时 Redis 冒烟配置下 T 发 INVITE 返回 `SIP/2.0 503 Routing Not Ready`,返回字段含配置版本、客户网关、策略和线路组,测试 Redis keys 与 CDR 事件已清理;短暂停止 Redis 代理后 T 发 INVITE 返回 `SIP/2.0 503 Redis Unavailable`,日志明确记录 Redis 热路径不可用和 CDR XADD 失败,代理与 Lua loader 随后恢复;OpenSIPS Prometheus 暴露 `lisglosips_opensips_s20_*`,B 可抓取 A Node Exporter textfile 指标 `lisglosips_redis_proxy_up`、录音 tmpfs、ready/part 文件数和 RTPEngine sessions;HEP 绑定修正后 A 侧抓包看到发往 B `9060/udp` 的 HEP,B heplify 统计显示 HEP 收包且 Error 为 0,修正后 A 无新增 HEP 发送错误;S20 本地产物敏感扫描未发现明文凭据,命中的仅为 credential 名称或历史文档中的安全术语。 +- 回滚方式:A 备份目录为 `/var/backups/lisglosips-s20/20260621T061900Z`,验证阶段的 OpenSIPS 配置副本位于该目录 `post-verify/` 下;回滚时停止 S20 metrics timer 和 Redis AUTH proxy,从备份恢复 `/etc/opensips/opensips.cfg`、S20 systemd/drop-in、Node Exporter 默认文件和 nftables 文件,执行 `opensips -C -f /etc/opensips/opensips.cfg` 后重启 `opensips`、`lisglosips-node-exporter`、`lisglosips-a-firewall`,不删除 Redis Stream 历史数据。详细步骤见 `docs/SERVER_A_REDIS_HEP_METRICS_RUNBOOK.md`。 +- 未解决问题:S20 只完成热路径检查、HEP 和指标,不实现真实落地路由、SIP 注册账号、双向媒体、正式 CDR 契约和计费;这些留给 S21/S22/S28。B sudo 凭据仍无法通过校验,本次 B 侧仅使用免 sudo 的只读 heplify 日志和已有服务凭据做验证;HOMER 数据库未稳定查到本次 OpenSIPS HEP Call-ID 行,但 heplify 已确认收包,后续 S28 端到端联调需继续核对 HOMER UI/库内 Call-ID。 +- 下一任务:`S21 - Server T 客户模拟配置` + +### 2026-06-21 14:00 - S19 Server A RTPEngine 与录音 tmpfs + +- 状态:已完成 +- 操作服务器:A、T、本地 +- 完成内容:在 Server A 安装并固定 RTPEngine `11.5.1.18-1ubuntu1.2` 及 kernel DKMS/recording 组件;加载 `xt_RTPENGINE` 内核模块,DKMS 已为 `6.17.0-22-generic` 与 `6.17.0-35-generic` 构建;配置 RTPEngine 媒体地址 `100.90.90.90`、RTP `30000-40000/udp`、本地 NG/CLI/HTTP 控制口 `127.0.0.1:2223/2224/2225`;创建 `/dev/shm/voip_rec` 3 GiB tmpfs 与 `spool/incoming/ready/failed/tmp` 目录;部署录音 finalizer,按 `incoming -> ready/*.part -> ready/*.ready` 原子规则生成 ready 文件;新增 `lisglosips-a-firewall.service` 保障 S18/S19 nftables 表重启后自动加载;新增 OpenSIPS systemd drop-in 等待 Tailscale 地址后启动;覆盖 `rtpengine-recording-daemon.service`,移除包默认 NFS/rpcbind 依赖;停止并 mask S19 不使用的 `rpcbind.service/socket`。未调整 SSH 管理端口或密钥登录策略。 +- 修改文件:A 的 `/etc/rtpengine/rtpengine.conf`、`/etc/rtpengine/rtpengine-recording.conf`、`/etc/default/rtpengine-daemon`、`/etc/default/rtpengine-recording-daemon`、`/etc/fstab`、`/etc/nftables.d/lisglosips-s19-rtpengine.nft`、`/etc/systemd/system/lisglosips-a-firewall.service`、`/etc/systemd/system/opensips.service.d/10-lisglosips-wait-tailscale.conf`、`/etc/systemd/system/rtpengine-recording-daemon.service`、`/etc/systemd/system/lisglosips-recording-finalize.service`、`/etc/systemd/system/lisglosips-recording-finalize.timer`、`/etc/tmpfiles.d/lisglosips-voip-rec.conf`、`/usr/local/sbin/lisglosips-recording-finalize`;本地 `infra/server-a/s19/`、`docs/SERVER_A_RTPENGINE_RECORDING_RUNBOOK.md`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:`rtpengine`、`rtpengine-daemon`、`rtpengine-recording-daemon`、`rtpengine-iptables`、`rtpengine-kernel-dkms`、`rtpengine-utils` 均为 `11.5.1.18-1ubuntu1.2` 并已 `apt-mark hold`;安装匹配内核 headers。未新增明文凭据。 +- 执行的关键命令:`apt-get install`、`apt-mark hold`、`modprobe xt_RTPENGINE`、`dkms status`、`nft -c/-f`、`systemctl daemon-reload/enable/restart/reboot`、`systemd-tmpfiles --create`、`rtpengine-ctl -ip 127.0.0.1 -port 2224 list numsessions/interfaces`、`tcpdump`、从 T 发送 RTP UDP 与 SIP `OPTIONS`/`REGISTER` 测试包。 +- 验证结果:A 二次重启后 `systemctl is-system-running` 为 `running` 且 0 failed unit;`opensips`、`rtpengine-daemon`、`rtpengine-recording-daemon`、`lisglosips-recording-finalize.timer`、`lisglosips-a-firewall.service`、`lisglosips-node-exporter.service` 全部 active/enabled;`/dev/shm/voip_rec` 为 3.0 GiB tmpfs,子目录均为 `rtpengine:rtpengine 0750`;`xt_RTPENGINE` 已加载,DKMS 状态 installed;`rtpengine-ctl` 显示接口 `100.90.90.90` 和端口段 `30000 - 40000`;nftables S18/S19 表重启后存在;从 T 到 A `30000/udp`、`40000/udp` 的 RTP 探针在 A `tailscale0` 抓包可见;从 T 发 SIP `OPTIONS` 返回 `SIP/2.0 200 Keepalive`,`REGISTER` 返回 `SIP/2.0 401 Authentication Required` 和 `WWW-Authenticate`;录音 finalizer 冒烟将测试 `.wav` 生成 `ready/...wav.ready`,权限 `0640`,无 `.part` 残留,测试文件已删除;本地 S19 产物敏感信息扫描未命中。 +- 回滚方式:A 备份目录为 `/var/backups/lisglosips-s19/20260621T054645Z`;回滚时停止 `rtpengine-recording-daemon`、`lisglosips-recording-finalize.timer`、`rtpengine-daemon`,从备份恢复 `/etc/rtpengine/`、`/etc/default/`、`/etc/fstab` 等配置,删除 `inet lisglosips_s19_rtpengine` nftables 表,停用并删除 S19 systemd/tmpfiles/finalizer 文件,必要时 `apt-mark unhold` 后卸载 RTPEngine 包;详细步骤见 `docs/SERVER_A_RTPENGINE_RECORDING_RUNBOOK.md`。 +- 未解决问题:S19 尚未把 OpenSIPS 呼叫路由接入 RTPEngine,真实双向音频、NAT 媒体锚定和完整通话录音需在 S20/S21/S28 完成;Tailscale APT 源在 A 上仍有 DNS 解析失败历史但当前 Tailscale 网络和服务运行正常;B sudo 凭据阻塞仍未解决,不影响本次 A 端 S19。 +- 下一任务:`S20 - Server A Redis 热路径与 HEP/指标` + +### 2026-06-21 13:29 - S18 Server A OpenSIPS 基线 + +- 状态:已完成 +- 操作服务器:A、本地 +- 完成内容:在 Server A 添加 OpenSIPS 官方 APT 源并安装固定 OpenSIPS `3.6.7-1`;安装并 hold `opensips`、`opensips-cli`、`opensips-auth-modules`、`opensips-redis-module`、`opensips-http-modules`、`opensips-json-module`、`opensips-restclient-module`、`opensips-prometheus-module`、`opensips-tlsmgm-module`、`opensips-tls-module`、`opensips-tls-openssl-module`;部署 `/etc/opensips/opensips.cfg`,OpenSIPS 仅监听 `100.90.90.90:15060/udp`,HEP transport 占位监听 `127.0.0.1:9061/udp`,MI HTTP 仅监听 `127.0.0.1:8888/tcp`;加载 `auth`、`pike`、`ratelimit`、`cachedb_redis`、`proto_hep`、`tracer`、`mi_http` 等模块;实现 S18 保守路由基线:`OPTIONS` 返回 `200 Keepalive`,`REGISTER` 返回带 Digest challenge 的 `401 Authentication Required`,`INVITE` 在 S20/S21 前返回 `503 Routing Not Ready`,避免开放中继;新增 `inet lisglosips_s18_opensips` nftables 表,只允许 T `100.93.185.30` 访问 A 的 UDP `15060`,丢弃其他 15060 来源,并阻断非本地 MI HTTP;未调整 SSH 管理端口或密钥登录策略。 +- 修改文件:A 的 `/etc/apt/sources.list.d/opensips.list`、`/etc/apt/sources.list.d/opensips-cli.list`、`/etc/opensips/opensips.cfg`、`/etc/default/opensips`、`/etc/nftables.d/lisglosips-s18-opensips.nft`;本地 `infra/server-a/s18/`、`docs/SERVER_A_OPENSIPS_BASELINE_RUNBOOK.md`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:OpenSIPS `3.6.7-1`;OpenSIPS CLI `0.4.0~20260522~570a9a9-1`;所有 S18 OpenSIPS 包已 `apt-mark hold`。未新增明文凭据。 +- 执行的关键命令:`apt-get update`、`apt-get install`、`apt-mark hold`、`opensips -C -f /etc/opensips/opensips.cfg`、`systemctl enable --now/restart opensips`、`nft -c -f /etc/nftables.d/lisglosips-s18-opensips.nft`、`nft -f /etc/nftables.d/lisglosips-s18-opensips.nft`、`curl -X POST http://127.0.0.1:8888/mi`、从 T 发送 SIP `OPTIONS`/`REGISTER` 测试包。 +- 验证结果:A 系统状态 `running`;`opensips.service` active/enabled;OpenSIPS 配置语法检查通过;监听端口为 `100.90.90.90:15060/udp`、`127.0.0.1:9061/udp`、`127.0.0.1:8888/tcp`,S06 的 `100.90.90.90:9100/tcp` 保持;MI HTTP JSON-RPC `version` 返回 `OpenSIPS (3.6.7 (x86_64/linux))`;从 T `100.93.185.30` 发往 A `15060/udp` 的 SIP `OPTIONS` 返回 `SIP/2.0 200 Keepalive`;SIP `REGISTER` 返回 `SIP/2.0 401 Authentication Required` 且包含 `WWW-Authenticate`;nftables S18 表加载成功;本地 S18 产物敏感信息扫描未命中。 +- 回滚方式:A 备份目录为 `/var/backups/lisglosips-s18/20260621T051403Z`;回滚时执行 `systemctl stop opensips`,从备份恢复 `/etc/opensips/` 和 `/etc/default/opensips`,删除 `inet lisglosips_s18_opensips` nftables 表,必要时 `apt-mark unhold` 并卸载 S18 OpenSIPS 包;详细步骤见 `docs/SERVER_A_OPENSIPS_BASELINE_RUNBOOK.md`。 +- 未解决问题:Tailscale APT 源在 A 上一度 DNS 解析失败,但 Tailscale 服务和当前连接未受影响;A 仍存在 S01 记录的本地 ens18 双地址/路由历史风险,S18 的 OpenSIPS 绑定 Tailscale 地址规避了 SIP 监听歧义;当前 HEP trace 已有目标配置但未查询 B 的 HOMER 入库,完整 HEP/Redis 热路径和指标留给 S20;RTPEngine、RTP 端口和录音 tmpfs 留给 S19。 +- 下一任务:`S19 - Server A RTPEngine 与录音 tmpfs` + +### 2026-06-21 13:06 - S17 落地线路组 API + +- 状态:已完成 +- 操作服务器:本地;B 未执行写操作 +- 完成内容:新增 LandingLineGroupsModule,提供 `GET /landing-line-groups`、`GET /landing-line-groups/:id`、`POST /landing-line-groups`、`PATCH /landing-line-groups/:id`、`POST /landing-line-groups/:id/enable`、`POST /landing-line-groups/:id/disable`、`DELETE /landing-line-groups/:id`、`POST /landing-line-groups/:id/items`、`PATCH /landing-line-groups/items/:itemId`、`DELETE /landing-line-groups/:id/items/:gatewayId`、`POST /landing-line-groups/:id/items/reorder`;支持线路组启停、软删除、成员增删改、成员优先级重排、权重、成员并发上限和成员状态;返回 `itemCount`、`enabledItemCount`、`concurrencyCapSum`、`gatewayConcurrencyLimitSum` 和 `policyCount`,用于组内并发汇总与引用可见性;新增引用校验,成员只能引用未删除落地网关,软删除线路组前阻止仍被未删除客户网关策略引用的线路组删除;读操作绑定 `line_groups.view`,写操作绑定 `line_groups.manage` 和审计动作;线路组与成员变更写入 `line_group_config` Outbox,Config Publisher 扩展线路组快照并发布 `cfg:v:{version}:line_group:*` 与 `line_group:*:items`。 +- 修改文件:`apps/api/src/modules/app.module.ts`、`apps/api/src/modules/landing-line-groups/`、`apps/worker-config-publisher/src/publisher.ts`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新系统软件;未新增 npm 包;继续使用 Node.js `v22.22.2`、Prisma `6.19.0`、pnpm `10.33.0`。 +- 执行的关键命令:`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 test -- landing-line-groups`、`corepack pnpm@10.33.0 test`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`。 +- 验证结果:typecheck 通过;S17 相关 Vitest 14 个测试文件 44 条测试通过,其中 `landing-line-groups.e2e.spec.ts` 覆盖线路组列表并发汇总、写权限拒绝、创建、成员新增/更新/删除、成员参数校验、优先级重排、启停、软删除和审计记录;全量 Vitest 14 个测试文件 44 条测试通过;lint 通过;workspace build 通过,包含 `apps/worker-config-publisher` 构建通过。 +- 回滚方式:移除 `LandingLineGroupsModule` 导入并删除 `apps/api/src/modules/landing-line-groups/`;恢复 `apps/worker-config-publisher/src/publisher.ts` 中 S17 新增的 `line_group_config` Outbox 处理和线路组快照发布逻辑。S17 未新增数据库迁移,B 端未创建临时库、未改服务、未写 Redis 或 MySQL。 +- 未解决问题:B 写入式临时库冒烟未执行,因为当前 B sudo 凭据仍无法通过校验,迁移账号也无权创建临时库;S17 不实现 OpenSIPS 呼叫路由或 Server A 配置,这些从 S18 开始。 +- 下一任务:`S18 - Server A OpenSIPS 基线` + +### 2026-06-21 11:57 - S16 落地网关 API + +- 状态:已完成 +- 操作服务器:本地;B 未执行写操作 +- 完成内容:新增 VendorGatewaysModule,提供 `GET /vendor-gateways`、`GET /vendor-gateways/:id`、`POST /vendor-gateways`、`PATCH /vendor-gateways/:id`、`POST /vendor-gateways/:id/enable`、`POST /vendor-gateways/:id/disable`;支持供应商落地网关 `IP`、`SIP_DIGEST`、`MIXED` 三种认证模式,创建和更新时校验 host/port/transport、SIP 用户名与密码强度,并以 `username:host:password` 生成 HA1,API 响应只返回 `hasSipCredential` 不返回明文密码或 HA1;支持 CPS、并发上限、禁呼时段、编码优先级、主叫/被叫号码前缀转换、计费周期秒数和周期费率,响应补充按分钟折算的 `minuteRate`;写操作绑定 `vendor_gateways.manage` 和审计动作,读操作绑定 `vendor_gateways.view`;落地网关创建、更新、启停写入 `vendor_gateway_config` Outbox,Config Publisher 扩展供应商落地网关快照并发布到 Redis 版本化配置。 +- 修改文件:`apps/api/src/modules/app.module.ts`、`apps/api/src/modules/vendor-gateways/`、`apps/worker-config-publisher/src/publisher.ts`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新系统软件;未新增 npm 包;继续使用 Node.js `v22.22.2`、Prisma `6.19.0`、pnpm `10.33.0`。 +- 执行的关键命令:`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 test -- vendor-gateways`、`corepack pnpm@10.33.0 test`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`。 +- 验证结果:typecheck 通过;S16 相关 Vitest 13 个测试文件 40 条测试通过,其中 `vendor-gateways.e2e.spec.ts` 覆盖列表脱敏、写权限拒绝、完整 SIP Digest 网关创建、审计敏感字段脱敏、切换 IP 模式清空 SIP 凭据、重复编码优先级校验和启停;全量 Vitest 13 个测试文件 40 条测试通过;lint 通过;workspace build 通过,包含 `apps/worker-config-publisher` 构建通过。 +- 回滚方式:移除 `VendorGatewaysModule` 导入并删除 `apps/api/src/modules/vendor-gateways/`;恢复 `apps/worker-config-publisher/src/publisher.ts` 中 S16 新增的 `vendor_gateway_config` Outbox 处理和落地网关快照发布逻辑。S16 未新增数据库迁移,B 端未创建临时库、未改服务、未写 Redis 或 MySQL。 +- 未解决问题:B 写入式临时库冒烟未执行,因为当前 B sudo 凭据仍无法通过校验,迁移账号也无权创建临时库;S16 不实现落地线路组成员、优先级重排或并发汇总,这些留给 S17。 +- 下一任务:`S17 - 落地线路组 API` + +### 2026-06-21 11:45 - S15 供应商管理 API + +- 状态:已完成 +- 操作服务器:本地;B 未执行写操作 +- 完成内容:新增 VendorsModule,提供 `GET /vendors`、`GET /vendors/:id`、`POST /vendors`、`PATCH /vendors/:id`、`POST /vendors/:id/enable`、`POST /vendors/:id/disable`、`DELETE /vendors/:id`;供应商返回余额、授信额度、可用余额和落地网关数量;余额保持只读,供应商充值仍通过 S12 的不可变充值流水;支持联系人、电话、邮箱、结算方式、备注、授信额度和启停状态维护;软删除前阻止仍有关联未删除落地网关的供应商删除;读操作绑定 `vendors.view`,写操作绑定 `vendors.manage` 和审计动作。 +- 修改文件:`apps/api/src/modules/app.module.ts`、`apps/api/src/modules/vendors/`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新系统软件;未新增 npm 包;继续使用 Node.js `v22.22.2`、Prisma `6.19.0`、pnpm `10.33.0`。 +- 执行的关键命令:`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 test -- vendors`、`corepack pnpm@10.33.0 test`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`。 +- 验证结果:typecheck 通过;S15 相关 Vitest 12 个测试文件 36 条测试通过;全量 Vitest 12 个测试文件 36 条测试通过,覆盖供应商余额/授信/网关数量读取、写权限拒绝、新增、编辑、启停、软删除和审计记录;lint 通过;workspace build 通过。 +- 回滚方式:移除 `VendorsModule` 导入并删除 `apps/api/src/modules/vendors/`;S15 未新增数据库迁移。B 端未创建临时库、未改服务、未写 Redis 或 MySQL。 +- 未解决问题:B 写入式临时库冒烟未执行,因为当前 B sudo 凭据仍无法通过校验,迁移账号也无权创建临时库;S15 不实现落地网关、费率、CPS/并发或号码转换,这些留给 S16。 +- 下一任务:`S16 - 落地网关 API` + +### 2026-06-21 11:39 - S14 客户网关策略与 Redis 配置发布 + +- 状态:已完成 +- 操作服务器:本地;B 未执行写操作 +- 完成内容:新增 CustomerGatewayPoliciesModule,提供 `GET /customer-gateways/:id/policies`、`POST /customer-gateways/:id/policies`、`PATCH /customer-gateway-policies/:id`、`DELETE /customer-gateway-policies/:id`、`POST /customer-gateways/:id/policies/reorder`;支持主叫/被叫 `ANY`、`EQUALS`、`PREFIX` 匹配,校验非 `ANY` 模式必须提供匹配值;支持显式优先级和自动追加优先级,重排时要求一次提交全部有效策略 ID 并用两阶段负优先级避免唯一索引冲突;客户网关创建、更新、启停以及策略创建、更新、删除、重排均在同一事务内写入 `outbox_events`,不直接双写 Redis;Config Publisher 从骨架升级为处理 `customer_gateway_config` Outbox 的 worker,生成完整 Redis 配置快照,写入 `cfg:v:{version}:customer:*`、`customer_gateway:*`、`auth:ip:*`、`auth:sip:*` 和策略列表,原子切换 `cfg:active_version`,并维护 `cfg:previous_version` 作为回滚点;新增 `rollbackActiveConfig` 函数用于切回上一版本。 +- 修改文件:`apps/api/src/modules/app.module.ts`、`apps/api/src/modules/customer-gateways/customer-gateways.repository.ts`、`apps/api/src/modules/customer-gateway-policies/`、`apps/worker-config-publisher/src/main.ts`、`apps/worker-config-publisher/src/publisher.ts`、`apps/worker-config-publisher/package.json`、`apps/worker-config-publisher/tsconfig.json`、`packages/redis/src/index.ts`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新系统软件;未新增需要联网下载的 npm 包;继续使用 Node.js `v22.22.2`、Prisma `6.19.0`、pnpm `10.33.0`、ioredis `5.8.2`(由 `@lisglosips/redis` 继续提供)。 +- 执行的关键命令:`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 test -- customer-gateway`、`corepack pnpm@10.33.0 test`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`;曾尝试 `corepack pnpm@10.33.0 install`,因本机 DNS 无法解析 `registry.npmjs.org` 失败,随后移除新增联网依赖,最终无需更新锁文件。 +- 验证结果:typecheck 通过;S14 相关 Vitest 11 个测试文件 33 条测试通过;全量 Vitest 11 个测试文件 33 条测试通过,覆盖策略列表、权限拒绝、策略创建/更新/重排/删除、审计记录和匹配值校验;lint 通过;workspace build 通过,包含 `apps/worker-config-publisher` 构建通过。 +- 回滚方式:移除 `CustomerGatewayPoliciesModule` 导入并删除 `apps/api/src/modules/customer-gateway-policies/`;恢复 `apps/api/src/modules/customer-gateways/customer-gateways.repository.ts` 中的 Outbox 写入前版本;恢复 `apps/worker-config-publisher/src/main.ts` 为骨架并删除 `publisher.ts`;恢复 `packages/redis/src/index.ts` 的 S14 新增 Redis 常量和 helper。S14 未新增数据库迁移,B 端未创建临时库、未改服务、未写 Redis。 +- 未解决问题:B 写入式临时库/Redis 冒烟未执行,因为当前 B sudo 凭据仍无法通过校验,迁移账号也无权创建临时库;Config Publisher 当前完成客户/客户网关/策略快照发布,落地网关、线路组成员和供应商侧热路径会随 S15-S17 数据模型 API 补齐后扩展到快照。 +- 下一任务:`S15 - 供应商管理 API` + +### 2026-06-21 11:28 - S13 客户网关 API + +- 状态:已完成 +- 操作服务器:本地;B 仅读取 SSH/Node/pnpm 状态并尝试临时库验收,未修改正式库或服务 +- 完成内容:新增 CustomerGatewaysModule,提供 `GET /customer-gateways`、`GET /customer-gateways/:id`、`POST /customer-gateways`、`PATCH /customer-gateways/:id`、`POST /customer-gateways/:id/enable`、`POST /customer-gateways/:id/disable`;支持 `IP`、`SIP_DIGEST`、`MIXED` 三种认证模式;创建和更新时校验来源 IP、SIP 用户名、SIP 域和密码强度;SIP 注册密码仅用于生成 OpenSIPS Digest HA1,API 响应只返回 `hasSipCredential`,不返回 `sipPassword` 或 `sipHa1`;SIP 用户名或域名变更时要求同时提交新密码,避免旧 HA1 与新身份不匹配;所有写操作绑定 `customer_gateways.manage` 和审计动作,读操作绑定 `customer_gateways.view`;日志脱敏路径新增 `sipHa1`。 +- 修改文件:`apps/api/src/modules/app.module.ts`、`apps/api/src/modules/customer-gateways/`、`packages/observability/src/index.ts`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新系统软件;继续使用 Node.js `v22.22.2`、Prisma `6.19.0`、pnpm `10.33.0`。 +- 执行的关键命令:`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 test`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`;B 端执行只读 `hostname && node --version && pnpm --version`;尝试通过 SSH 隧道连接 B MySQL 并用 `MYSQL_MIGRATE` 创建 S13 临时库,因账号无 `mysql` 系统库访问权限返回 `P1010`;B sudo 凭据校验失败,未继续执行需要 sudo 的 MySQL 创建/清理命令。 +- 验证结果:本地 typecheck 通过;Vitest 10 个测试文件 29 条测试通过,新增覆盖客户网关只读权限、写权限拒绝、IP 网关返回脱敏、SIP Digest 网关创建、审计敏感字段脱敏、SIP 身份变更强制新密码、切换 IP 模式清空 SIP 凭据、启停接口;lint 通过;workspace build 通过;B 的 Node `v22.22.2` 和 pnpm `10.33.0` 只读确认通过;B 临时库冒烟未完成,未创建临时库,SSH 隧道、本地 S13 临时 SQL 和远端 `/tmp/lisglosips-s13-create-*.sql` 已清理。 +- 回滚方式:移除 `CustomerGatewaysModule` 导入并删除 `apps/api/src/modules/customer-gateways/`;从 `packages/observability/src/index.ts` 移除 `*.sipHa1` 脱敏项;S13 未新增数据库迁移。B 端未创建临时库、未修改服务,仅使用短时 SSH 隧道和 `/tmp` 临时 SQL 文件,已清理。 +- 未解决问题:S13 未执行 B 临时库冒烟,因为当前 `.codex-private` 中的 B sudo 凭据无法通过校验,`MYSQL_MIGRATE` 也无权在 `mysql` 系统库创建临时库;后续需要修复 B sudo 凭据或提供受控临时库创建账号。S13 不实现客户网关策略、Outbox 或 Redis 发布,这些留给 S14。 +- 下一任务:`S14 - 客户网关策略与 Redis 配置发布` + +### 2026-06-21 11:14 - S12 充值与不可变余额流水 + +- 状态:已完成 +- 操作服务器:本地、B +- 完成内容:新增 RechargesModule,提供 `POST /customers/:id/recharges`、`POST /vendors/:id/recharges` 和 `GET /recharges`;客户和供应商充值在同一数据库事务内完成账户行锁定、余额读取、不可变充值流水写入、余额更新和幂等结果保存;重复相同幂等键返回原充值结果,不重复加余额;同一幂等键不同请求返回冲突;充值写操作绑定 `recharges.manage` 和审计动作,查询绑定 `recharges.view`;并发写冲突和唯一键竞争只对 Prisma `P2034`/`P2002` 做短退避重试。 +- 修改文件:`apps/api/src/modules/app.module.ts`、`apps/api/src/modules/recharges/`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新系统软件;继续使用 Node.js `v22.22.2`、Prisma `6.19.0`、pnpm `10.33.0`。 +- 执行的关键命令:`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 test`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`;B 端通过 SSH 隧道连接临时 MySQL 库执行 `prisma migrate deploy`、`db:seed`、`prisma migrate status`,并使用编译后的充值仓库执行客户/供应商充值、幂等重放、幂等冲突和并发充值冒烟。 +- 验证结果:本地 typecheck 通过;Vitest 9 个测试文件 24 条测试通过,覆盖充值权限拒绝、客户充值、供应商充值、幂等重放、幂等冲突、流水查询和审计记录;lint 通过;workspace build 通过;B 临时空库 `lisglosips_s12_verify_20260621111322` 成功应用 S08/S09 共 2 个迁移,seed 执行成功,`migrate status` 显示 schema up to date;B 临时库验证客户充值前后余额、供应商充值前后余额、重复幂等不重复入账、幂等冲突拒绝、5 路并发客户充值后余额 `15.000000`、客户流水 6 条、供应商流水 1 条、成功幂等记录 7 条;B 残留检查确认无 S12 临时库、临时用户和 `/tmp/lisglosips-s12-*` 文件。 +- 回滚方式:移除 `RechargesModule` 导入并删除 `apps/api/src/modules/recharges/`;S12 未新增数据库迁移。B 端仅创建临时库、临时用户和 SSH 隧道,已删除或关闭。 +- 未解决问题:S12 只实现手工充值入账;CDR 扣费、余额扣减和扣费幂等留给 S22/S23;供应商管理 CRUD 仍按任务顺序留给 S15。 +- 下一任务:`S13 - 客户网关 API` + +### 2026-06-21 10:57 - S11 客户管理 API + +- 状态:已完成 +- 操作服务器:本地、B +- 完成内容:新增 CustomersModule,提供客户列表、详情、新增、编辑、启用、停用和软删除 API;返回余额、授信额度、最低余额、可用余额和启用网关数量;客户余额只读,避免绕过 S12 充值流水;所有写操作均绑定 `customers.manage` 和审计动作,读操作绑定 `customers.view`;软删除前阻止仍有关联启用网关的客户删除。 +- 修改文件:`apps/api/src/modules/app.module.ts`、`apps/api/src/modules/customers/`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新系统软件;继续使用 Node.js `v22.22.2`、Prisma `6.19.0`、pnpm `10.33.0`。 +- 执行的关键命令:`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 test`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`;B 端通过 SSH 隧道连接临时 MySQL 库执行 `prisma migrate deploy`、`db:seed`、`prisma migrate status` 和客户 CRUD 冒烟脚本。 +- 验证结果:本地 typecheck 通过;Vitest 8 个测试文件 21 条测试通过,覆盖客户读权限、写权限拒绝、新增、启停、软删除、余额/授信字段和审计记录;lint 通过;workspace build 通过;B 临时空库 `lisglosips_s11_verify_20260621105537` 成功应用 S08/S09 共 2 个迁移,seed 执行成功,`migrate status` 显示 schema up to date;B 临时库客户创建、状态更新、Decimal 精度、网关计数和软删除冒烟通过;B 残留检查确认无 S11 临时库、临时用户和 `/tmp/lisglosips-s11-*` 文件。 +- 回滚方式:移除 `CustomersModule` 导入并删除 `apps/api/src/modules/customers/`;S11 未新增数据库迁移。B 端仅创建临时库、临时用户和 SSH 隧道,已删除或关闭。 +- 未解决问题:S11 不实现充值入账和余额变更事务,余额仍只能通过后续 S12 不可变流水更新;客户网关数量当前依赖后续 S13 的网关数据。 +- 下一任务:`S12 - 充值与不可变余额流水` + +### 2026-06-21 10:46 - S10 用户、角色、权限与审计 + +- 状态:已完成 +- 操作服务器:本地、B +- 完成内容:实现全局 Access Token 认证 Guard、RBAC Guard、`@Public`、`@RequirePermissions` 与当前用户注入;新增用户、角色、权限和审计日志查询 API;实现用户创建、资料更新、重置密码和角色权限维护;内置角色禁止改名、停用和变更权限;重置密码仅保存 Argon2id 哈希并要求下次改密;新增审计拦截器,按接口元数据记录 request_id、操作者、角色、IP、User-Agent、模块、动作、对象、成功/失败、错误码和脱敏后的 before/after 摘要。 +- 修改文件:`packages/auth/src/index.ts`、`packages/database/src/index.ts`、`apps/api/src/modules/app.module.ts`、`apps/api/src/modules/auth/auth.controller.ts`、`apps/api/src/modules/health/health.controller.ts`、`apps/api/src/modules/security/`、`apps/api/src/modules/audit/`、`apps/api/src/modules/audit-logs/`、`apps/api/src/modules/users/`、`apps/api/src/modules/roles/`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新系统软件;继续使用 Node.js `v22.22.2`、Prisma `6.19.0`、pnpm `10.33.0`。 +- 执行的关键命令:`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 test`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`;B 端通过 SSH 隧道连接临时 MySQL 库执行 `prisma migrate deploy`、`db:seed`、`prisma migrate status`;B 端检查并清理 `lisglosips_s10_verify_*`、`s10v_*` 和 `/tmp/lisglosips-s10-*` 临时残留。 +- 验证结果:本地 typecheck 通过;Vitest 7 个测试文件 18 条测试通过,覆盖未认证拒绝、越权拒绝、重置密码审计脱敏、内置角色保护和审计日志查询;lint 通过;workspace build 通过;B 临时空库 `lisglosips_s10_verify_20260621104300` 成功应用 S08/S09 共 2 个迁移,seed 执行成功,`migrate status` 显示 schema up to date;B 残留检查确认无 S10 临时库、临时用户和 `/tmp/lisglosips-s10-*` 文件。 +- 回滚方式:恢复上述本地文件到 S10 前版本;S10 未新增数据库迁移,若需回滚只需移除新增模块导入和相关接口代码。B 端仅创建临时库、临时用户和 `/tmp` 临时文件,已删除。 +- 未解决问题:S10 只完成后端安全与审计基础;后续业务 API 必须继续显式标注权限和审计动作,客户、充值、网关等业务模块从 S11 开始实现。 +- 下一任务:`S11 - 客户管理 API` + +### 2026-06-21 10:15 - S09 登录认证基础 + +- 状态:已完成 +- 操作服务器:本地、B +- 完成内容:实现登录、刷新和退出接口;新增 Argon2id 密码哈希与校验,优先使用 Node 原生实现并在 Node 22 上回退到 vendored WASM;实现 HMAC access token、HttpOnly refresh cookie、refresh token SHA-256 摘要入库、会话撤销与轮换、失败计数和临时锁定;新增 Prisma `user_sessions` 模型和迁移;补充 Auth API、服务层、密码/Token 原语和 schema 合同测试。 +- 修改文件:`packages/auth/src/index.ts`、`packages/auth/src/index.spec.ts`、`packages/auth/vendor/argon2id/`、`apps/api/src/modules/auth/`、`apps/api/src/modules/database/`、`apps/api/src/modules/app.module.ts`、`apps/api/src/shared/config.ts`、`apps/api/tsconfig.json`、`apps/api/package.json`、`.env.example`、`eslint.config.mjs`、`prisma/schema.prisma`、`prisma/migrations/20260621093000_auth_sessions/migration.sql`、`packages/database/src/schema-contract.spec.ts`、`pnpm-lock.yaml`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新系统软件;vendored npm 包 `argon2id` `1.0.1`,MIT 许可证,WASM 哈希记录于 `packages/auth/vendor/argon2id/UPSTREAM.md`;继续使用 Node.js `v22.22.2`、Prisma `6.19.0`、pnpm `10.33.0`。 +- 执行的关键命令:`corepack pnpm@10.33.0 prisma:validate`、`prisma:generate`、`typecheck`、`test`、`lint`、`build`;B 端通过临时 `/tmp` 包验证 Node 22 下 Argon2id WASM 哈希与校验;通过 SSH 隧道连接 B 的临时 MySQL 库执行 `prisma migrate deploy` 和 `prisma migrate status`。 +- 验证结果:本地 Prisma schema validate 通过;Prisma Client 生成通过;typecheck 通过;Vitest 6 个测试文件 15 条测试通过,覆盖 Argon2id、Token、登录成功、失败锁定、refresh 轮换、旧 refresh 拒绝、logout 后拒绝和健康检查;lint 通过;workspace build 通过;B Node `v22.22.2` 上 Argon2id WASM 校验结果为正确值通过、随机值拒绝;B 临时空库 `lisglosips_s09_verify_20260621101249` 成功应用 S08 与 S09 共 2 个迁移,`migrate status` 显示 schema up to date;B 残留检查确认临时库、临时用户和 `/tmp/lisglosips-s09-*` 文件已清理。 +- 回滚方式:恢复上述本地文件到 S09 前版本;删除 `prisma/migrations/20260621093000_auth_sessions/` 并从 `prisma/schema.prisma` 移除 `UserSession` 关系和模型。B 端仅创建临时库、临时用户和 `/tmp` 临时文件,已删除。 +- 未解决问题:生产 access token secret 必须由受控环境变量注入;当前 S09 只实现认证基础,RBAC Guard、内置角色保护、重置密码和审计拦截器留给 S10。 +- 下一任务:`S10 - 用户、角色、权限与审计` + +### 2026-06-21 09:28 - S08 数据库 Schema 与迁移 + +- 状态:已完成 +- 操作服务器:本地、B +- 完成内容:实现 V2 第 10 章核心数据模型,包括客户、客户网关、策略、供应商、落地网关、线路组、充值、CDR、录音、质检、用户、角色、权限、审计、Outbox 和幂等键;生成初始化迁移;新增内置权限/角色 seed;补充数据库 Schema Runbook 和 schema 合同测试;迁移 Prisma seed 配置到 `prisma.config.ts`。 +- 修改文件:`prisma/schema.prisma`、`prisma/migrations/20260621090000_init_v2_schema/migration.sql`、`prisma/seed.ts`、`prisma.config.ts`、`package.json`、`packages/database/src/schema-contract.spec.ts`、`docs/DATABASE_SCHEMA_RUNBOOK.md`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新系统软件;继续使用 Prisma `6.19.0`、MySQL `8.0.46`、pnpm `10.33.0`。 +- 执行的关键命令:`prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script`、`corepack pnpm@10.33.0 prisma:generate`、`prisma:validate`、`typecheck`、`test`、`lint`、`build`;通过 SSH 隧道连接 B 的临时 MySQL 库执行 `prisma migrate deploy`、`prisma db seed`、重复 seed、重复 migrate deploy、`prisma migrate status`;使用 `mysqldump --no-tablespaces` 做临时库备份恢复验证。 +- 验证结果:本地 Prisma schema validate 通过;Prisma Client 生成通过;lint 通过;typecheck 通过;Vitest 3 个测试文件 6 条测试通过;workspace build 通过;B 上临时空库 `lisglosips_s08_verify_20260621091537` 成功应用 1 个迁移;seed 重复执行安全;重复 `migrate deploy` 显示无待迁移;`migrate status` 显示 schema up to date;备份恢复后计数为 `permissions=22`、`roles=5`、`role_permissions=53`、`_prisma_migrations=1`。 +- 回滚方式:恢复上述本地文件到 S08 前版本;删除 `prisma/migrations/20260621090000_init_v2_schema/`。B 端仅创建临时库和临时用户,已删除 `lisglosips_s08_verify_*`、`s08v_*`、`/tmp/lisglosips-s08-*.sh` 和临时 dump 文件。 +- 未解决问题:生产/准生产迁移仍需在正式发布窗口前做全量备份和恢复演练;本次只验证临时空库初始化、seed 幂等和 dump/restore。 +- 下一任务:`S09 - 登录认证基础` + +### 2026-06-21 08:55 - S07 V2 后端 Monorepo 骨架完成 + +- 状态:已完成 +- 操作服务器:本地、B +- 完成内容:恢复 B 开机后完成 S07 的 B 端构建验收;补充 `scripts/pnpm-run.mjs`,让根脚本复用当前 pnpm 运行器,兼容本机 Corepack 调用和 B 上已安装的 `/usr/bin/pnpm`;在 `package.json` 中声明允许固定依赖的 pnpm build scripts,并加入 Linux x64 glibc optional dependency 支持,便于 B 端验证 Rollup/esbuild/Prisma。 +- 修改文件:`package.json`、`pnpm-lock.yaml`、`scripts/pnpm-run.mjs`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未在 B 安装新系统软件;B 使用既有 Node.js `v22.22.2`、Corepack `0.34.6`、pnpm `10.33.0`。本地和 B 的项目依赖仍按 `pnpm-lock.yaml` 固定。 +- 执行的关键命令:本地 `corepack pnpm@10.33.0 lint/typecheck/test/prisma:validate/build`;B `/tmp/lisglosips-s07-20260621081708` 中执行 `pnpm install --frozen-lockfile`、离线 store 安装、`pnpm lint`、`pnpm typecheck`、`pnpm test`、`pnpm prisma:validate`、`pnpm build`、临时 API `node apps/api/dist/main.js` 健康检查。 +- 验证结果:本地 lint、typecheck、Vitest 2 个测试文件 3 条测试、Prisma schema validate、workspace build 全部通过;B 端 lint、typecheck、Vitest 2 个测试文件 3 条测试、Prisma schema validate、workspace build 全部通过;B 本机临时 API `/api/v2/health/live` 返回 `ok`、`/api/v2/health/ready` 返回 `ok`、`/api/v2/openapi.json` 返回 HTTP 200。 +- 回滚方式:恢复本次新增/修改的 monorepo 文件;如需恢复旧前端结构,将 `apps/web/src` 移回根目录 `src`、`apps/web/index.html` 移回根目录,并恢复旧 `package.json`/`package-lock.json`。B 端仅使用 `/tmp` 临时目录和临时 pnpm store,已删除 `/tmp/lisglosips-s07-*`、`/tmp/lisglosips-s07-src-*.zip`、`/tmp/lisglosips-s07-pnpm-store-v10.tar.gz`、`/tmp/rollup-linux-x64-gnu-4.62.2.tgz` 及 API 冒烟临时文件。 +- 未解决问题:B 到 npm registry 的 TLS 连接不稳定,在线安装多次 `ECONNRESET`;本次通过本地 pnpm store 临时上传完成 B 端构建验收。后续正式部署建议使用内网 npm 缓存、制品仓库或在 CI 产出完整 release artifact。 +- 下一任务:`S08 - 数据库 Schema 与迁移` + +### 2026-06-21 08:10 - S07 V2 后端 Monorepo 骨架 + +- 状态:已阻塞 +- 操作服务器:本地;Server B 仅尝试临时源码包上传和只读 SSH 连通性检查,未确认远端执行 +- 完成内容:将现有 React Demo 纳入 pnpm monorepo 的 `apps/web`;创建 `apps/api` NestJS + Fastify 后端骨架;创建 `apps/worker-cdr`、`apps/worker-recording`、`apps/worker-config-publisher` worker 入口;创建 `packages/contracts`、`domain`、`database`、`redis`、`auth`、`observability` 公共包;建立 Prisma MySQL schema 占位、结构化日志脱敏、配置校验、OpenAPI JSON/Swagger 入口、健康检查、Vitest/Supertest 测试和 lint/typecheck/build/ci 命令。 +- 修改文件:`package.json`、`pnpm-workspace.yaml`、`pnpm-lock.yaml`、`tsconfig.base.json`、`tsconfig.build.json`、`eslint.config.mjs`、`vitest.config.ts`、`.env.example`、`.gitignore`、`apps/`、`packages/`、`prisma/schema.prisma`;原 `src/` 和 `index.html` 已移动到 `apps/web/`。 +- 安装软件及固定版本:本地通过 Corepack 使用 pnpm `10.33.0`;关键依赖固定包括 NestJS `11.1.9`、Fastify `5.6.2`、Prisma `6.19.0`、ioredis `5.8.2`、pino `9.14.0`、Vitest `4.0.14`、TypeScript `5.9.3`、Vite `5.4.21`。 +- 执行的关键命令:`corepack pnpm@10.33.0 install`、`corepack pnpm@10.33.0 prisma:generate`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 test`、`corepack pnpm@10.33.0 prisma:validate`、`corepack pnpm@10.33.0 build`、本地 API `node apps/api/dist/main.js` 冒烟检查。 +- 验证结果:本地 `lint` 通过;`typecheck` 通过;Vitest 2 个测试文件 3 条测试通过;Prisma schema validate 通过;全 workspace build 通过;本地 API 冒烟 `/api/v2/health/live` 返回 `ok`、`/api/v2/health/ready` 返回 `ok`、`/api/v2/openapi.json` 返回 HTTP 200;测试和脚本只使用无密码占位连接串。 +- 回滚方式:恢复本次新增/修改的 monorepo 文件;如需恢复旧前端结构,将 `apps/web/src` 移回根目录 `src`、`apps/web/index.html` 移回根目录,并恢复旧 `package.json`/`package-lock.json`。 +- 未解决问题:未完成 B 端构建验收。本机到 `100.90.90.91:22` SSH 超时;本机无 `tailscale` CLI;`scp` 曾尝试上传 `/tmp/lisglosips-s07-src-20260621080628.zip`,返回码为 0 但随后提示连接关闭,因 SSH 不通无法确认远端是否存在该临时文件,恢复连通后应检查并删除。 +- 下一任务:恢复 Server B SSH/Tailscale 可达性后继续完成 `S07 - V2 后端 Monorepo 骨架` 的 B 端构建验收;不要进入 S08。 + +### 2026-06-21 00:20 - SSH 策略文档修订 + +- 状态:已完成 +- 操作服务器:本地 +- 完成内容:将 SSH 加固原则调整为“只调整 SSH 管理端口和允许来源,不禁止 SSH 密钥登录”;端口号统一使用 `` 占位,待用户确认具体端口后再实施。 +- 修改文件:`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md`、`docs/SSH_ACCESS_RUNBOOK.md`、`docs/LISGLOSIPS_DEPLOYMENT_GUIDE.md`、`docs/infra-check.md`、`docs/SERVER_B_BASELINE_RUNBOOK.md` +- 安装软件及固定版本:无。 +- 执行的关键命令:`rg` 文档复查。 +- 验证结果:需求/部署文档中已无“仅 Key 登录”“禁用密码登录”作为强制策略的表述;历史盘点文件保留当时实际 `TCP 22` 快照,`docs/infra-check.md` 中历史验收项已标注后续迁移为 ``。 +- 回滚方式:恢复上述文档至本次修改前版本。 +- 未解决问题:尚未确认 `` 的具体端口号,未执行服务器侧 SSH 端口迁移。 +- 下一任务:`S07 - V2 后端 Monorepo 骨架` + +### 2026-06-20 23:20 - S06 Server B HOMER 与基础监控 + +- 状态:已完成 +- 操作服务器:A、B、T、本地 +- 完成内容:在 B 部署 PostgreSQL 16 HOMER 数据库、Heplify HEP 接收、Homer App、Prometheus、Grafana、MySQL/PostgreSQL/Redis/Node/Heplify exporters 和 Nginx 监控入口;在 A 部署 Node Exporter,并用独立 nftables 表限制仅 B 可抓取;重新签发开发证书,增加 `grafana.lisglosips.local` 与 `homer.lisglosips.local`;补充 S06 Runbook 和可重复部署脚本。 +- 修改文件:B 的 `/etc/homer/`、`/etc/lisglosips/secrets/`、`/etc/prometheus/`、`/etc/grafana/`、`/etc/nginx/sites-available/monitoring.conf`、`/etc/systemd/system/` S06 服务、`/data/homer/postgresql`;A 的 `/etc/nftables.d/lisglosips-s06-exporter.nft`、`/etc/systemd/system/lisglosips-exporter-firewall.service`、`/etc/systemd/system/lisglosips-node-exporter.service`;本地 `infra/server-b/s06/`、`infra/server-a/s06/`、`docs/SERVER_B_HOMER_MONITORING_RUNBOOK.md`、`docs/LISGLOSIPS_DEPLOYMENT_GUIDE.md`、`.codex-private/SERVICE_CREDENTIALS.clixml`。 +- 安装软件及固定版本:PostgreSQL `16.14`、Prometheus `2.45.3`、Grafana `13.0.2`、Node Exporter `1.7.0`、MySQL Exporter `0.15.0`、PostgreSQL Exporter `0.15.0`、Redis Exporter `1.86.0`;Heplify Server `v1.60.2-0.20260512101233-c74dc3d216ac`;Homer App `v0.0.0-20251021161517-9b1336352aa0`;Docker `29.1.3` 与 Compose `2.40.3` 已安装但禁用,不作为 S06 运行时。 +- 执行的关键命令:`apt-get install`、Go `install/build`、`psql`、`mysql`、`redis-cli`、`promtool check config/rules`、`nginx -t`、`systemctl daemon-reload/enable/restart/reboot`、`curl`、`nft -c/-f`、HEPv3 UDP 测试脚本。 +- 验证结果:B 重启后系统 `running` 且 0 failed unit;A 重启后系统 `running` 且 0 failed unit;B 的 PostgreSQL、MySQL、Redis、Nginx、Heplify、Homer、Grafana、Prometheus 和 4 个 exporter 全部 active;Prometheus 7 个 target 全部 `up`;A Node Exporter 仅监听 `100.90.90.90:9100`,B 抓取返回 200,T 访问超时;HEPv3 `INVITE` 从 A 发往 B 后写入 `homer_data.hep_proto_1_call`,方法和源/目标 IP 正确;Grafana HTTPS health 通过,已加载 `Prometheus`、`HOMER PostgreSQL` datasource 和 `LisgloSIPS Infrastructure Overview` dashboard;Homer HTTPS 返回 200;Homer/Grafana/MySQL Exporter 近 10 分钟无错误日志。 +- 回滚方式:B 备份位于 `/var/backups/lisglosips-s06/20260620T132440Z`,A 备份位于 `/var/backups/lisglosips-s06/20260620225517`;停止 S06 服务、恢复配置、保留并单独备份 `/data/homer/postgresql` 后再处理数据目录;A 回滚只删除 `inet lisglosips_s06` 表和两个 S06 服务,不触碰 OpenSIPS/RTP。 +- 未解决问题:外部对象存储下载被代理链路阻断,未继续采用 HOMER10 Docker 架构;当前 HOMER Web 是最小状态页,HOMER API、HEP 入库和 Grafana 可用,完整 HOMER UI 静态包待网络条件稳定后补;A 的 9100 保护只是 S06 局部规则,完整 A 防火墙留到 S18/S20。 +- 下一任务:`S07 - V2 后端 Monorepo 骨架` + +### 2026-06-20 20:44 - S05 Server B Node.js、Nginx 与 TLS 基线 + +- 状态:已完成 +- 操作服务器:B、T、本地 +- 完成内容:固定 Node.js 22 LTS、Corepack、pnpm 和 Nginx 版本;建立 `/opt/lisglosips/releases` 与原子 `current` 发布模型;部署受限 `lisglosips@.service` 模板和无依赖 API 健康占位服务;配置 Nginx 静态站点、API 代理、登录/API 限流、安全响应头、内部录音 Range 入口;签发仅供开发使用的私有 CA 证书;另建全项目独立部署指南。 +- 修改文件:B 的 `/etc/lisglosips/web-runtime-versions.env`、`/etc/lisglosips/api.env`、`/etc/lisglosips/pki/`、`/etc/nginx/` LisgloSIPS 配置、`/etc/systemd/system/lisglosips@.service`、`/usr/local/sbin/lisglosips-issue-dev-cert` 和 `/opt/lisglosips/releases/s05-placeholder-20260620`;本地 `infra/server-b/s05/`、`docs/SERVER_B_WEB_RUNTIME_RUNBOOK.md`、`docs/LISGLOSIPS_DEPLOYMENT_GUIDE.md`、`.codex-private/tls/lisglosips-dev-ca.crt`。 +- 安装软件及固定版本:Node.js `v22.22.2`(包 `22.22.2-1nodesource1`)、Corepack `0.34.6`、pnpm `10.33.0`、Nginx `1.24.0-2ubuntu7.12`;Node/Nginx 包已 hold,升级前必须复核安全公告与兼容性。 +- 执行的关键命令:`apt-get install`、`apt-mark hold`、`openssl`、`nginx -t`、`systemctl daemon-reload/enable/restart/reboot`、`curl`、`systemd-analyze security`、`sha256sum`。 +- 验证结果:内部和管理端 HTTPS 健康检查通过;证书链与 `100.90.90.91` SAN 校验通过;仅 TLS 1.2/1.3;首页 HSTS/CSP/防嵌套等安全头完整;登录限流测试出现预期 429;`/_recordings/` 外部访问为 404;应用只监听 `127.0.0.1:3000`,本地管理机和 T 均不能直连 3000,T 不能访问 443;systemd 安全评级为 OK;重启后 Nginx、API、MySQL、Redis 和备份 timer 正常,0 个 failed unit;11 个仓库配置与服务器文件哈希一致;文档敏感信息扫描通过。 +- 回滚方式:切换 `/opt/lisglosips/current` 可回滚应用;S05 变更前备份位于 `/var/backups/lisglosips-s05/20260620T123432Z`,配置/证书/服务恢复步骤见 `docs/SERVER_B_WEB_RUNTIME_RUNBOOK.md`。 +- 未解决问题:当前是开发私有 CA,迁移阿里云必须替换正式域名证书;80 已监听但仍由 nftables 拒绝,生产是否开放取决于 ACME 方案;NodeSource 在本地代理链路上偶发 TLS 握手中断,现有固定包和运行不受影响;当前 API 仅为 S05 健康占位服务,S07 替换。 +- 下一任务:`S06 - Server B HOMER 与基础监控` + +### 2026-06-20 20:22 - S04 Server B MySQL 与 Redis + +- 状态:已完成 +- 操作服务器:B、A、T、本地 +- 完成内容:在 B 安装并固定 MySQL 8.0.46 与 Redis 7.0.15;建立 `/data/mysql`、`/data/redis` bind mount;完成 MySQL binlog、UTC、utf8mb4、慢查询和最小权限账号配置;完成 Redis Tailscale 私网绑定、ACL、AOF/RDB 和 `noeviction` 配置;建立 Tailscale 地址就绪依赖,消除 Redis 开机绑定竞态;配置每日 MySQL/Redis 自动备份及 14 天保留。 +- 修改文件:B 的 `/etc/mysql/mysql.conf.d/99-lisglosips.cnf`、`/etc/redis/redis-lisglosips.conf`、`/etc/redis/users.acl`、`/etc/fstab`、相关 systemd unit/drop-in、备份脚本及 `/etc/lisglosips/secrets/`;本地 `docs/SERVER_B_DATA_SERVICES_RUNBOOK.md`、`infra/server-b/s04/`、`.codex-private/SERVICE_CREDENTIALS.clixml`。 +- 安装软件及固定版本:MySQL `8.0.46-0ubuntu0.24.04.2`;Redis `5:7.0.15-1ubuntu0.24.04.4`;相关 MySQL/Redis 包已 `apt-mark hold`,升级前须先评估安全公告与兼容性。 +- 执行的关键命令:`apt-get install`、`apt-mark hold`、`mysql`、`mysqlcheck`、`redis-cli`、`redis-check-rdb`、`systemctl`、`findmnt`、`sha256sum`、`systemctl reboot`。 +- 验证结果:MySQL 仅监听本机,应用账号 DML 通过且 DDL 被拒;Redis 未认证访问被拒,应用账号读写通过且危险命令被拒,A 可通过 Tailscale 认证访问,T 到 3306/6379 被防火墙拒绝;MySQL 与 Redis 备份哈希通过,并分别完成隔离恢复和数据一致性验证;重启后 mount、服务、数据和 timer 均正常,Redis `NRestarts=0`,0 个 failed unit;11 个仓库配置与服务器文件 SHA-256 完全一致;仓库敏感信息、私密文件忽略规则和 ACL 检查通过。 +- 回滚方式:变更前及迁移前数据保存在 B 的 `/var/backups/lisglosips-s04/20260620195847`;配置、数据目录和服务恢复步骤见 `docs/SERVER_B_DATA_SERVICES_RUNBOOK.md`。 +- 未解决问题:开发阶段 `/data` 仍位于根盘,正式上云需改为独立数据盘;固定版本需要纳入安全补丁复核;Tailscale 首次直连协商偶尔产生短时延迟,稳态访问正常。 +- 下一任务:`S05 - Server B Node.js、Nginx 与 TLS 基线` + +### 2026-06-20 19:01 - S03 Server B 基础系统初始化 + +- 状态:已完成 +- 操作服务器:B、本地 +- 完成内容:升级 101 个系统包并启用 6.17.0-35 内核;安装基础工具;建立 chrony、nftables、fail2ban、sysstat;创建三个 LisgloSIPS 系统用户和 `/data` 目录基线;修复 NFS 为 Tailscale 就绪后的 automount;完成重启验收。 +- 修改文件:B 的 `/etc/fstab`、`/etc/nftables.conf`、`/etc/nftables.d/lisglosips.nft`、`/etc/chrony/chrony.conf`、`/etc/chrony/sources.d/lisglosips.sources`、`/etc/fail2ban/jail.d/lisglosips.local`、`/etc/tmpfiles.d/lisglosips.conf`;本地 `docs/SERVER_B_BASELINE_RUNBOOK.md`、`infra/server-b/s03/`。 +- 安装软件及固定版本:chrony 4.5-1ubuntu4.2、nftables 1.0.9-1ubuntu0.1、fail2ban 1.0.2-3ubuntu0.1;其余基础工具版本见 Runbook 和远端 post-change 包清单。 +- 执行的关键命令:`apt-get dist-upgrade/install`、`chronyc`、`nft`、`fail2ban-client`、`systemd-tmpfiles`、`findmnt`、`systemctl reboot`。 +- 验证结果:新内核启动;0 个 failed unit;无待升级包、无 reboot-required;chrony Stratum 3/Leap Normal;NFS automount 重启后可访问;fail2ban sshd jail active;nftables 重启后默认拒绝,开发机与 A 允许路径通过,T 到 B 的 22/443/6379/9060 被拒绝;远端配置与仓库副本哈希一致。 +- 回滚方式:变更前备份 `/var/backups/lisglosips-s03/20260620184404`;防火墙可执行其中 `rollback-firewall.sh`;其余恢复方式见 Runbook。 +- 未解决问题:开发阶段 `/data` 仍位于根盘;桌面/CUPS/Avahi/rpcbind 尚未精简但已被防火墙隔离;ens18 双地址/双默认路由和克隆 machine-id 仍待单独维护窗口处理。 +- 下一任务:`S04 - Server B MySQL 与 Redis` + +### 2026-06-20 18:37 - S02 网络与安全组验收 + +- 状态:已完成 +- 操作服务器:A、B、T、本地 +- 完成内容:确认本地开发服务器和 Tailscale 专网边界;采集六个方向稳态 RTT;验证 SIP、RTP、Web、HEP、Redis、监控与 SSH 网络路径;形成开发最小暴露矩阵和未来阿里云安全组清单。 +- 修改文件:`docs/infra-check.md`、`docs/inventory-A.md`、`docs/inventory-B.md`、`docs/inventory-summary.md`、`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:无。 +- 执行的关键命令:`tailscale status/ping/netcheck`、`ping`、Python 短时 TCP/UDP 监听与 ACK、SIP OPTIONS、`ss`、OpenSIPS/MariaDB 只读核验。 +- 验证结果:A/B/T 六方向 0% 丢包,预热后平均 RTT 0.434-0.648 ms;1200 字节 DF 报文通过;T->A 15060/UDP 与 RTP 30000/40000 双向通过;A->T SIP 5060 返回合法 484;开发机->B 443、A->B 9060/6379、B->A 9100/22 通过;所有临时监听器均已退出。 +- 回滚方式:未修改远端持久配置;短时监听器已自动退出,无需回滚。 +- 未解决问题:主机防火墙尚未落地;Tailscale direct 路径曾短暂重新协商导致 SSH 超时;ens18 双地址/双默认路由仍存在;T 的权限与 RTPEngine 内核模块问题留待对应任务处理。 +- 测试数据:T 的 `sip_trace` 新增 1 条 S02 OPTIONS 记录,`acc` 和 `missed_calls` 未增加。 +- 下一任务:`S03 - Server B 基础系统初始化` + +### 2026-06-20 18:00 - S01 三机只读资产盘点 + +- 状态:已完成 +- 操作服务器:A、B、T、本地 +- 完成内容:只读采集三机 OS、内核、CPU、内存、磁盘、网络、路由、时间、端口、服务、软件和防火墙;检查 T 的 OpenSIPS、RTPEngine、MariaDB、录音目录、配置结构与运行统计。 +- 修改文件:`docs/inventory-A.md`、`docs/inventory-B.md`、`docs/inventory-T.md`、`docs/inventory-summary.md`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:无。 +- 执行的关键命令:`hostnamectl`、`lscpu`、`lsblk`、`ip`、`ss`、`systemctl`、`ufw status`、`nft list ruleset`、`opensips -C`、`opensips-cli`、`rtpengine-ctl`、MariaDB 只读查询。 +- 验证结果:A/B/T SSH 复验通过;T 的 OpenSIPS、RTPEngine、录音守护进程、MariaDB、Apache 均 active;OpenSIPS 主配置哈希与盘点时一致;报告完整性及敏感信息扫描通过。 +- 回滚方式:S01 未修改远端配置、数据或服务,无需回滚。 +- 未解决问题:三机克隆身份相同;均有双 IPv4/双默认路由;UFW 未启用;B 的 NFS mount 失败;T 的 TLS 私钥为 0644、MI FIFO 为 0666、RTPEngine 无内核模块;当前 T 无注册账号和路由数据。 +- 下一任务:`S02 - 网络与安全组验收` + +### 2026-06-20 15:08 - S00 SSH 安全接入与凭据迁移 + +- 状态:已完成 +- 操作服务器:A、B、T、本地 +- 完成内容:生成项目专用 Ed25519 Key;三机安装公钥;发现并修复克隆服务器共享 SSH host key;轮换 `hector` 密码;建立严格 known_hosts 和 SSH 别名。后续 SSH 变更按最新要求只调整管理端口,不禁止 SSH 密钥登录。 +- 修改文件:`.codex-private/ssh/lisglosips_codex_ed25519`、`.codex-private/ssh/config`、`.codex-private/ssh/known_hosts`、`.codex-private/SERVER_CREDENTIALS.clixml`、`docs/SSH_ACCESS_RUNBOOK.md` +- 安装软件及固定版本:本地私密目录安装 Paramiko 5.0.0,仅用于首次安全接入自动化。 +- 验证结果:A/B/T 均通过 Key 新会话;`sshd -t` 通过;SSH 密钥登录保持开启;新 sudo 密码逐台验证通过。 +- 回滚方式:见 `docs/SSH_ACCESS_RUNBOOK.md`,三台服务器 `/var/backups/lisglosips-s00/` 保留轮换前和加固前备份。 +- 未解决问题:尚未盘点三机软件、磁盘、服务和网络;容量参数与 RTP 端口段仍未确认。 +- 下一任务:`S01 - 三机只读资产盘点` + +### 2026-06-20 - 计划初始化 + +- 状态:已完成 +- 操作服务器:本地 +- 完成内容:建立 Codex 原子任务列表、服务器索引和跨会话交接规范。 +- 修改文件:`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md`、`.gitignore`、`.codex-private/SERVER_ACCESS.md` +- 验证结果:等待开始 S00。 +- 未解决问题:尚未连接服务器;尚未确认容量参数和 RTP 端口段。 +- 下一任务:`S00 - SSH 安全接入与凭据迁移` diff --git a/OPENSIPS_INSTALL_NOTES.md b/OPENSIPS_INSTALL_NOTES.md new file mode 100644 index 0000000..f7c4190 --- /dev/null +++ b/OPENSIPS_INSTALL_NOTES.md @@ -0,0 +1,836 @@ +# OpenSIPS installation notes + +中文说明:OpenSIPS 安装与修复备忘。 + +This file records the installation performed on workstation `100.93.185.30` +and the missing pieces found afterwards. Use `install-opensips-stack.sh` for a +repeatable production install. + +中文说明:这个文件记录了 `100.93.185.30` 工作站上的安装过程、后来补齐的缺失模块,以及生产环境一键安装时应复用的脚本。 + +## Target stack + +中文说明:目标软件栈。 + +- OS: Ubuntu 24.04 Noble +- OpenSIPS: 3.6.x from `https://apt.opensips.org` +- OpenSIPS Control Panel: 9.3.6 +- RTPengine: Ubuntu `rtpengine-daemon` +- Database: MariaDB +- Web server: Apache + PHP + +## Production one-shot install + +中文说明:生产环境一键安装方式。 + +Copy `install-opensips-stack.sh` to a fresh Ubuntu 24.04 server and run: + +中文说明:把 `install-opensips-stack.sh` 拷贝到一台全新的 Ubuntu 24.04 服务器上,然后执行下面命令。 + +```bash +sudo -E SIP_IP=YOUR_SERVER_IP DB_PASS='CHANGE_ME' bash install-opensips-stack.sh +``` + +Useful variables: + +中文说明:下面这些环境变量可以按生产环境修改,例如服务器 IP、数据库密码、MI 端口、RTPengine 端口范围、Monit 账号等。 + +```bash +SIP_IP=100.93.185.30 +SIP_DOMAIN=100.93.185.30 +DB_NAME=opensips +DB_USER=opensips +DB_PASS=opensipsrw +CP_VERSION=9.3.6 +MI_HTTP_IP=127.0.0.1 +MI_HTTP_PORT=8888 +RTPENGINE_NG=127.0.0.1:2223 +RTPENGINE_PORT_MIN=30000 +RTPENGINE_PORT_MAX=40000 +MONIT_IP=127.0.0.1 +MONIT_PORT=2812 +MONIT_USER=admin +MONIT_PASS=monit +``` + +After installation: + +中文说明:安装完成后,OpenSIPS-CP 管理页面和默认账号如下。 + +```text +OpenSIPS-CP: http://YOUR_SERVER_IP/cp/ +Default user: admin +Default pass: opensips +``` + +Change the OpenSIPS-CP admin password before production use. + +中文说明:生产环境必须修改 OpenSIPS-CP 默认管理员密码,避免后台被默认口令登录。 + +## Startup and boot enablement + +中文说明:服务启动和开机自启。 + +OpenSIPS itself is a systemd service: + +中文说明:OpenSIPS 本体是 systemd 服务,下面命令用于设置开机自启、重启、查看是否运行和是否启用。 + +```bash +sudo systemctl enable opensips +sudo systemctl restart opensips +systemctl is-active opensips +systemctl is-enabled opensips +``` + +Expected result: + +中文说明:期望看到 `active` 和 `enabled`,表示服务正在运行并已设置开机启动。 + +```text +active +enabled +``` + +OpenSIPS-CP is the web control panel at: + +中文说明:OpenSIPS-CP 是 Web 管理后台,通过 `/cp/` 访问。 + +```text +http://YOUR_SERVER_IP/cp/ +``` + +It is served by Apache and uses MariaDB, so there is no separate +`opensips-cp.service` to enable. Enable and start these services instead: + +中文说明:OpenSIPS-CP 不是独立服务,它依赖 Apache 提供网页、MariaDB 存配置和数据,所以要启用的是 `apache2` 和 `mariadb`。 + +```bash +sudo systemctl enable apache2 mariadb +sudo systemctl restart mariadb +sudo systemctl restart apache2 +systemctl is-active apache2 mariadb +systemctl is-enabled apache2 mariadb +``` + +Expected result: + +```text +active +active +enabled +enabled +``` + +Useful verification commands: + +中文说明:下面命令用于确认 SIP 端口、Web 端口、CP 页面和 MI HTTP 接口都正常。 + +```bash +ss -lunp | grep ':5060' +ss -ltnp | grep ':80' +curl -fsSI http://127.0.0.1/cp/ +curl -fsS -X POST http://127.0.0.1:8888/mi \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"ps"}' +``` + +On the current workstation, the public access URL is: + +中文说明:当前这台工作站的访问地址如下。 + +```text +http://100.93.185.30/cp/ +``` + +## Packages that must not be missed + +中文说明:容易漏装但必须安装的软件包。 + +Base OpenSIPS packages: + +中文说明:OpenSIPS 主程序、CLI、MySQL、认证和 TLS 相关基础包。 + +```bash +opensips +opensips-cli +opensips-mysql-module +opensips-auth-modules +opensips-tls-module +``` + +Database schema package: + +中文说明:OpenSIPS 数据库建表脚本包。 + +```bash +opensips-mysql-dbschema +``` + +This one is easy to miss. Without it, `/usr/share/opensips/mysql/*.sql` is not +available and `opensipsdbctl` may not exist on OpenSIPS 3.6 installs. + +中文说明:这个包很容易漏。没有它就找不到 `/usr/share/opensips/mysql/*.sql`,后续建表和导入模块表会很麻烦。 + +OpenSIPS-CP MI support: + +中文说明:OpenSIPS-CP 通过 MI 接口控制 OpenSIPS,下面两个包提供 HTTP MI 和 JSON 支持。 + +```bash +opensips-http-modules +opensips-json-module +``` + +These provide `httpd.so`, `mi_http.so` and JSON support. Without them, +OpenSIPS-CP actions such as `Reload on Server` will fail when configured for +`json:127.0.0.1:8888/mi`. + +中文说明:没有这些模块时,CP 页面里的 `Reload on Server` 会连不上 `127.0.0.1:8888/mi` 或提示 MI 命令不可用。 + +SIP user authentication and database registration: + +中文说明:SIP 用户认证和注册落库需要的模块。 + +```bash +opensips-auth-modules +opensips-mysql-module +``` + +Enable these modules in `opensips.cfg`: + +中文说明:在 `opensips.cfg` 中启用认证模块和位置服务模块,让用户可以用数据库账号注册到 OpenSIPS。 + +```opensips +loadmodule "auth.so" +loadmodule "auth_db.so" +modparam("auth_db", "db_url", "mysql://opensips:opensipsrw@localhost/opensips") +modparam("auth_db", "calculate_ha1", 1) + +loadmodule "usrloc.so" +modparam("usrloc", "db_url", "mysql://opensips:opensipsrw@localhost/opensips") +modparam("usrloc", "working_mode_preset", "single-instance-sql-write-through") +``` + +Then protect REGISTER requests: + +中文说明:下面这段用于保护 REGISTER 请求;没有正确账号密码的终端不能注册。 + +```opensips +if (is_method("REGISTER")) { + if (!www_authorize("$fd", "subscriber")) { + www_challenge("$fd", "auth"); + exit; + } + + if (!save("location")) + xlog("failed to register AoR $tu\n"); + + exit; +} +``` + +`calculate_ha1=1` lets OpenSIPS calculate the digest hash from the plain +`subscriber.password` column created by OpenSIPS-CP. + +中文说明:`calculate_ha1=1` 表示 OpenSIPS 可以根据 `subscriber.password` 明文密码计算 SIP Digest 认证需要的 hash。 + +Dialog tracking for the OpenSIPS-CP Dialog page: + +中文说明:Dialog 页面用于查看在线通话/会话状态,需要启用 `dialog.so`。 + +```opensips +loadmodule "dialog.so" +modparam("dialog", "db_url", "mysql://opensips:opensipsrw@localhost/opensips") +modparam("dialog", "profiles_no_value", "inbound;outbound") +modparam("dialog", "profiles_with_value", "caller;callee") +``` + +Create dialog state for initial INVITEs: + +中文说明:初始 INVITE 到来时创建 dialog 状态,并把主叫、被叫等信息放进 profile,便于 CP 页面查看。 + +```opensips +if (is_method("INVITE")) { + create_dialog(); + set_dlg_profile("outbound"); + set_dlg_profile("caller", "$fU"); + set_dlg_profile("callee", "$rU"); + do_accounting("log"); +} +``` + +Without `dialog.so`, OpenSIPS-CP Dialog pages fail with: + +中文说明:如果没加载 `dialog.so`,Dialog 页面会报下面这个 MI 方法不存在。 + +```text +MI command failed with code -32601 (Method not found) +``` + +Validation: + +中文说明:验证 Dialog MI 命令是否可用。 + +```bash +curl -X POST http://127.0.0.1:8888/mi \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"dlg_list"}' +``` + +Dispatcher support for the OpenSIPS-CP Dispatcher page: + +中文说明:Dispatcher 页面用于管理/查看后端服务器分发列表,需要启用 `dispatcher.so`。 + +```opensips +loadmodule "dispatcher.so" +modparam("dispatcher", "db_url", "mysql://opensips:opensipsrw@localhost/opensips") +modparam("dispatcher", "persistent_state", 1) +``` + +Without `dispatcher.so`, OpenSIPS-CP Dispatcher pages fail with: + +中文说明:如果没加载 `dispatcher.so`,Dispatcher 页面会报下面这个 MI 方法不存在。 + +```text +MI command failed with code -32601 (Method not found) +``` + +Validation: + +```bash +curl -X POST http://127.0.0.1:8888/mi \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"ds_list"}' + +curl -X POST http://127.0.0.1:8888/mi \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"ds_reload"}' +``` + +Dynamic Routing support for the OpenSIPS-CP Dashboard and Dynamic Routing pages: + +中文说明:Dynamic Routing 是动态路由/LCR 能力,用于管理落地网关、路由规则、网关状态,也支撑 Dashboard 的动态路由小组件。 + +```opensips +loadmodule "drouting.so" +modparam("drouting", "db_url", "mysql://opensips:opensipsrw@localhost/opensips") +modparam("drouting", "drd_table", "dr_gateways") +modparam("drouting", "drr_table", "dr_rules") +modparam("drouting", "drg_table", "dr_groups") +modparam("drouting", "drc_table", "dr_carriers") +``` + +The Dashboard `Dynamic Routing` widget calls MI commands such as +`dr_gw_status` and `dr_reload_status`. Without `drouting.so`, Dashboard may show: + +中文说明:Dashboard 上的 `Dynamic Routing` 卡片会调用 `dr_gw_status` 等命令;如果没加载 `drouting.so`,Dashboard 会出现 Method not found。 + +```text +MI command failed with code -32601 (Method not found) +``` + +Validation: + +```bash +curl -X POST http://127.0.0.1:8888/mi \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"dr_gw_status"}' + +curl -X POST http://127.0.0.1:8888/mi \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"dr_reload_status"}' +``` + +On a fresh system with no dynamic-routing gateways configured, `dr_gw_status` +should return an empty `Gateways` list, not `Method not found`. + +中文说明:新系统没有配置网关时,返回空网关列表是正常的;错误是 `Method not found`,说明模块没加载。 + +RTPengine: + +中文说明:RTPengine 是媒体代理,负责 RTP 媒体流转发、NAT 穿透,也可配合录音。 + +```bash +rtpengine-daemon +``` + +OpenSIPS-CP has two separate media relay pages: + +中文说明:OpenSIPS-CP 里 RTPProxy 和 RTPEngine 是两个不同页面,分别对应不同 OpenSIPS 模块。 + +- `RTPProxy` uses the OpenSIPS `rtpproxy.so` module and MI commands such as + `rtpproxy_show`, `rtpproxy_reload`, and `rtpproxy_enable`. +- `RTPEngine` uses the OpenSIPS `rtpengine.so` module and MI commands such as + `rtpengine_show`, `rtpengine_reload`, and `rtpengine_enable`. + +If either module is not loaded, its page fails with: + +中文说明:对应模块没加载时,页面会报 MI 命令不存在。 + +```text +MI command failed with code -32601 (Method not found) +``` + +For this workstation, RTPengine is the actual media proxy daemon. RTPProxy is +loaded only so the OpenSIPS-CP RTPProxy page can call its MI commands; without +RTPProxy sockets configured, the page should show an empty list instead of a +Method not found error. + +中文说明:当前工作站实际使用的是 RTPengine。RTPProxy 模块只是为了让 CP 的 RTPProxy 页面不报错;没有配置 RTPProxy socket 时显示空列表是正常的。 + +OpenSIPS module config: + +中文说明:下面配置同时启用 `rtpengine.so` 和 `rtpproxy.so`,分别对应两个 CP 页面。 + +```opensips +loadmodule "rtpengine.so" +modparam("rtpengine", "db_url", "mysql://opensips:opensipsrw@localhost/opensips") +modparam("rtpengine", "db_table", "rtpengine") + +loadmodule "rtpproxy.so" +modparam("rtpproxy", "db_url", "mysql://opensips:opensipsrw@localhost/opensips") +modparam("rtpproxy", "db_table", "rtpproxy_sockets") +``` + +RTPengine DB row used on the current workstation: + +中文说明:当前工作站的 RTPengine 控制 socket 是 `udp:127.0.0.1:2223`,需要写入 `rtpengine` 表。 + +```sql +INSERT INTO rtpengine (socket, set_id) +SELECT 'udp:127.0.0.1:2223', 0 +WHERE NOT EXISTS ( + SELECT 1 FROM rtpengine WHERE socket='udp:127.0.0.1:2223' AND set_id=0 +); +``` + +Validation: + +```bash +curl -X POST http://127.0.0.1:8888/mi \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"rtpengine_show"}' + +curl -X POST http://127.0.0.1:8888/mi \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"rtpproxy_show"}' +``` + +## OpenSIPS-CP SIP Trace page + +中文说明:OpenSIPS-CP 的 SIP Trace 页面。 + +The SIP Trace page displays SIP messages captured by OpenSIPS and stored in the +`sip_trace` table. It is useful for troubleshooting registration, INVITE call +setup, routing, authentication challenges, and failed SIP responses. It lets +you filter by: + +中文说明:SIP Trace 用于查看 OpenSIPS 抓到的 SIP 报文,适合排查注册失败、呼叫不通、路由错误、认证失败和 SIP 响应异常。 + +- `RegExp`: text or regex to search in captured SIP messages. +- `Call ID`: one SIP dialog/transaction identifier. +- `Traced User`: the trace attribute saved by the script, here `$fU`. +- `Start Date` / `End Date`: time range. +- `Group results by Call ID`: groups multiple SIP messages from the same call. + +If the page shows: + +中文说明:如果 SIP Trace 页面出现下面的错误,说明 `trace` MI 命令不存在。 + +```text +MI command failed with code -32601 (Method not found) +``` + +then OpenSIPS-CP called the `trace` MI command, but OpenSIPS did not load the +`tracer.so` module. + +中文说明:原因是 CP 调用了 `trace` 命令,但 OpenSIPS 没加载 `tracer.so`。 + +OpenSIPS module config: + +中文说明:下面配置开启 tracer 模块,并把 trace 数据写入 MySQL 的 `sip_trace` 表。 + +```opensips +loadmodule "tracer.so" +modparam("tracer", "trace_on", 1) +modparam("tracer", "trace_id", "[tid]uri=mysql://opensips:opensipsrw@localhost/opensips;table=sip_trace;") +``` + +Main route hook used on the current workstation: + +中文说明:仅启用模块还不会自动写入数据,路由脚本里需要调用 `trace()`。 + +```opensips +route{ + trace("tid", "m", "sip", "$fU"); +``` + +The `trace` MI command only toggles tracing on/off. The `trace()` call in the +routing script is what actually writes messages to the `sip_trace` database +table. + +中文说明:MI 的 `trace` 命令只是开关;真正把 SIP 消息写进数据库的是路由脚本中的 `trace()` 函数。 + +Validation: + +```bash +curl -X POST http://127.0.0.1:8888/mi \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"trace"}' + +mysql -D opensips -e "SELECT COUNT(*) FROM sip_trace;" +``` + +SIP Trace can grow quickly on a busy server. In production, keep it disabled by +default or clean old rows regularly. + +中文说明:生产环境要谨慎开启 SIP Trace,因为话务量大时 `sip_trace` 表会增长很快,应定期清理或只在排障时开启。 + +Web/database dependencies: + +中文说明:Web 前端和数据库依赖包。 + +```bash +mariadb-server mariadb-client +apache2 libapache2-mod-php +php php-cli php-mysql php-gd php-pear php-apcu php-curl php-xml php-mbstring +git unzip curl +``` + +Monit support for the OpenSIPS-CP Monit page: + +中文说明:Monit 页面用于查看服务器服务和系统状态,需要安装 `monit`。 + +```bash +monit +``` + +OpenSIPS-CP's Monit page is a proxy to Monit's own HTTP interface. If `monit` +is not installed or the OpenSIPS-CP box has no `monit_conn`, the page may show: + +中文说明:CP 的 Monit 页面实际是代理访问 Monit 自带 HTTP 页面;如果 Monit 没装或 CP 的 box 没配置 `monit_conn`,就会报空地址。 + +```text +Failed to parse address "" +Error: 1 +I can't connect! +``` + +Configure Monit to listen locally: + +中文说明:Monit 只监听本机 `127.0.0.1:2812`,避免暴露到外网。 + +```monit +set httpd port 2812 and + use address 127.0.0.1 + allow 127.0.0.1 + allow admin:monit +``` + +Enable it: + +```bash +sudo monit -t +sudo systemctl enable monit +sudo systemctl restart monit +``` + +Configure the OpenSIPS-CP default box: + +中文说明:把 OpenSIPS-CP 默认 box 的 Monit 连接地址、账号和密码写入数据库。 + +```sql +UPDATE ocp_boxes_config + SET monit_conn='127.0.0.1:2812', + monit_user='admin', + monit_pass='monit', + monit_ssl=0 + WHERE id=1; +``` + +Validation: + +```bash +systemctl is-active monit +systemctl is-enabled monit +ss -ltnp | grep ':2812' +curl -fsS -u admin:monit http://127.0.0.1:2812/ | grep -m1 Monit +``` + +On the current workstation, Monit is installed, enabled, and bound to +`127.0.0.1:2812`. The OpenSIPS-CP default box uses `admin` / `monit`. + +中文说明:当前工作站已经安装并启用 Monit,CP 使用 `admin / monit` 连接本机 Monit。生产环境请修改密码。 + +## OpenSIPS config added during the fix + +中文说明:安装后补加到 OpenSIPS 配置里的关键修复项。 + +The initial installation only had `mi_fifo`. OpenSIPS-CP was configured to call: + +中文说明:最初只有 `mi_fifo`,但 CP 配置的是 JSON HTTP MI,所以页面 reload 会失败。 + +```text +json:127.0.0.1:8888/mi +``` + +So `Reload on Server` failed with: + +中文说明:因此 CP 页面点击 `Reload on Server` 时会连接不上 8888 端口。 + +```text +Failed to connect to 127.0.0.1 port 8888 +``` + +The fix was to load HTTP MI: + +中文说明:修复方法是加载 `httpd.so` 和 `mi_http.so`,让 OpenSIPS 提供本地 HTTP MI 接口。 + +```opensips +loadmodule "httpd.so" +modparam("httpd", "ip", "127.0.0.1") +modparam("httpd", "port", 8888) + +loadmodule "mi_http.so" +modparam("mi_http", "root", "mi") +``` + +After that, the MI endpoint worked, but `address_reload` returned: + +中文说明:HTTP MI 通了以后,`address_reload` 又报方法不存在,这是因为还没加载权限模块。 + +```text +Method not found +``` + +That command is provided by `permissions.so`, so the next fix was: + +中文说明:`address_reload` 由 `permissions.so` 提供,所以需要加载 permissions 模块并配置数据库。 + +```opensips +loadmodule "db_mysql.so" + +loadmodule "permissions.so" +modparam("permissions", "db_url", "mysql://opensips:opensipsrw@localhost/opensips") +modparam("permissions", "address_table", "address") +``` + +Validation command: + +```bash +curl -X POST http://127.0.0.1:8888/mi \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"address_reload"}' +``` + +Expected result: + +中文说明:期望返回 OK。 + +```json +{"jsonrpc":"2.0","result":"OK","id":1} +``` + +## OpenSIPS-CP Addresses page + +中文说明:OpenSIPS-CP 的 Addresses 页面。 + +The Addresses page writes to the `address` table used by the `permissions` +module. + +中文说明:Addresses 页面配置的是 IP 访问规则/白名单类规则,写入 `permissions` 模块使用的 `address` 表。 + +It is not where the local OpenSIPS listening IP is configured. The listening IP +belongs in: + +中文说明:这里不是配置 OpenSIPS 本机监听 IP 的地方;本机监听 IP 要写在 `opensips.cfg` 的 `socket`。 + +```text +/etc/opensips/opensips.cfg +``` + +Example: + +```opensips +socket=udp:100.93.185.30:5060 +``` + +Typical Address entry: + +中文说明:典型地址规则示例如下。 + +```text +Group: 1 +IP: 47.97.162.65 +Mask: 32 +Port: 5060 +Protocol: any +Pattern: +Context Info: lisglo +``` + +Notes: + +中文说明:字段注意事项。 + +- `Group` must be numeric. +- `Pattern` is an optional wildcard pattern used by `permissions` matching. +- `Context Info` is returned to the OpenSIPS script when the rule matches. +- Click `Reload on Server` after editing Addresses. + +## OpenSIPS-CP Domains validation issue + +中文说明:OpenSIPS-CP Domains 页面表单校验问题。 + +On OpenSIPS-CP 9.3.6, the Domains page may reject both IP addresses and normal +FQDNs in the `SIP Domain` field. The symptom is a red validation icon next to +the field and the `Add New Domain` button stays disabled. + +中文说明:在 OpenSIPS-CP 9.3.6 中,Domains 页面可能错误拒绝 IP 和普通域名,表现为输入框旁边红色校验图标,添加按钮不可点。 + +This is not a SIP limitation. SIP domains may be IPs or FQDNs, and the Domains +tool itself says: + +中文说明:这不是 SIP 协议限制,SIP domain 可以是 IP,也可以是 FQDN 域名。 + +```text +A SIP Domain to be considered local by OpenSIPS - can be an IP or a FQDN +``` + +The issue is in the browser-side form validation generator: + +中文说明:问题在 CP 前端表单校验生成器。 + +```text +/var/www/html/opensips-cp/web/common/forms.php +``` + +The code uses `preg_quote()` when embedding validation regexes into JavaScript. +That escapes the regex into a literal string, so JavaScript's `RegExp()` no +longer receives the intended regex. + +中文说明:代码用 `preg_quote()` 把正则转给 JavaScript,导致正则被转义成普通字符串,浏览器端校验失效。 + +Patch: + +中文说明:下面补丁把 `preg_quote()` 改成更适合 JS 字符串的 `addslashes()`。 + +```bash +sudo cp -a /var/www/html/opensips-cp/web/common/forms.php \ + /var/www/html/opensips-cp/web/common/forms.php.bak.regexfix.$(date +%Y%m%d%H%M%S) + +sudo python3 - <<'PY' +from pathlib import Path +p = Path('/var/www/html/opensips-cp/web/common/forms.php') +s = p.read_text() +s = s.replace("preg_quote($re, '/')", "addslashes($re)") +s = s.replace("preg_quote($value['validation_regex'], '/')", "addslashes($value['validation_regex'])") +p.write_text(s) +PY + +php -l /var/www/html/opensips-cp/web/common/forms.php +``` + +On some OpenSIPS-CP 9.3.6 installs, the Domains page can still reject input +after the common form-generator patch because the Domains template ships its +own over-escaped field regex. If both IPs and normal FQDNs still show a red +validation icon, patch the Domains form itself: + +中文说明:有些安装即使修了通用表单生成器,Domains 页面自己的字段正则仍然会过度转义,需要单独修 Domains 模板。 + +```bash +sudo cp -a /var/www/html/opensips-cp/web/tools/system/domains/template/domains.form.php \ + /var/www/html/opensips-cp/web/tools/system/domains/template/domains.form.php.bak.domain-regex.$(date +%Y%m%d%H%M%S) + +sudo python3 - <<'PY' +from pathlib import Path +p = Path('/var/www/html/opensips-cp/web/tools/system/domains/template/domains.form.php') +s = p.read_text() +lines = [] +for line in s.splitlines(): + if '"domain", "n", $domain_form' in line: + indent = line[:len(line) - len(line.lstrip())] + lines.append(indent + '"domain", "n", $domain_form[\'domain\'], 128, null);') + else: + lines.append(line) +p.write_text('\n'.join(lines) + '\n') +PY + +php -l /var/www/html/opensips-cp/web/tools/system/domains/template/domains.form.php +sudo systemctl reload apache2 || sudo systemctl restart apache2 +``` + +This only disables the browser-side regex for the `SIP Domain` field; the field +remains required, and the database/OpenSIPS domain module still determine what +is actually usable. After patching, hard refresh the browser with `Ctrl+F5`. + +中文说明:这个修复只关闭 SIP Domain 字段的浏览器端正则,字段仍是必填;真正是否可用仍由数据库和 OpenSIPS domain 模块决定。修完后浏览器要 `Ctrl+F5` 强制刷新。 + +The one-shot installer already applies both equivalent patches automatically. + +中文说明:一键安装脚本已经自动包含这两个补丁。 + +For the current workstation, `100.93.185.30` was also inserted directly into the +`domain` table to unblock user creation: + +中文说明:当前工作站为了先让用户创建功能可用,已经把 `100.93.185.30` 直接写入 `domain` 表。 + +```sql +INSERT INTO domain (domain, attrs, accept_subdomain, last_modified) +SELECT '100.93.185.30', NULL, 0, NOW() +WHERE NOT EXISTS (SELECT 1 FROM domain WHERE domain='100.93.185.30'); +``` + +The `domain` runtime module was enabled: + +中文说明:同时启用了 OpenSIPS 的 `domain.so` 模块,并使用数据库模式。 + +```opensips +loadmodule "domain.so" +modparam("domain", "db_url", "mysql://opensips:opensipsrw@localhost/opensips") +modparam("domain", "db_mode", 1) +``` + +Validation: + +```bash +curl -X POST http://127.0.0.1:8888/mi \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"domain_reload"}' + +curl -X POST http://127.0.0.1:8888/mi \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"domain_dump"}' +``` + +Expected `domain_dump` output includes: + +中文说明:期望 `domain_dump` 输出中包含当前服务器域名/IP。 + +```text +100.93.185.30 +``` + +## Security notes + +中文说明:安全注意事项。 + +- Keep MI HTTP bound to `127.0.0.1`. +- Do not expose `0.0.0.0:8888` to the network. +- Change the OpenSIPS-CP admin password. +- Use a stronger database password in production. +- Restrict SIP source IPs using `permissions` / `address` rules. +- Open RTP port range only where needed, for example `30000-40000/udp`. + +## Runtime checks + +中文说明:运行状态检查命令。 + +```bash +systemctl is-active mariadb apache2 opensips rtpengine-daemon +ss -ltnup | grep -E '(:80|:5060|:8888|:2223|:2812)' +curl -I http://127.0.0.1/cp/ +curl -fsS -u admin:monit http://127.0.0.1:2812/ | grep -m1 Monit +curl -X POST http://127.0.0.1:8888/mi \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"ps"}' +``` diff --git a/SOFTSWITCH_PLATFORM_DESIGN_V1.md b/SOFTSWITCH_PLATFORM_DESIGN_V1.md new file mode 100644 index 0000000..707123b --- /dev/null +++ b/SOFTSWITCH_PLATFORM_DESIGN_V1.md @@ -0,0 +1,766 @@ +# 自研软交换运营平台 V1 产品设计文档 + +## 1. 系统定位 + +本系统定位为基于 OpenSIPS + RTPEngine 的软交换运营平台,用于支撑多客户、多供应商、多线路组、费率计费、话单查询、录音质检和 SIP 运维。 + +OpenSIPS 负责 SIP 信令控制、注册鉴权、路由选择、话单基础数据和与媒体代理的协同。RTPEngine 负责 RTP 媒体转发、NAT 穿透和录音能力。自研平台负责客户、供应商、费率、账单、质检、权限、报表等业务运营能力。 + +推荐定位: + +- OpenSIPS-CP:V1 保留,作为技术运维后台,用于排障和底层配置。 +- 自研平台:业务运营后台,用于客户、计费、线路、话单、质检和权限管理。 + +## 2. 目标用户角色 + +### 2.1 超级管理员 + +负责全局配置、租户/客户管理、供应商管理、系统参数、权限和安全策略。 + +### 2.2 运营管理员 + +负责客户开通、线路组配置、路由策略、时段控制、话单查询和日常运营。 + +### 2.3 财务人员 + +负责客户费率、供应商费率、账单、成本、利润、充值、授信和对账。 + +### 2.4 质检人员 + +负责录音抽检、录音试听、质检标注、问题分类和质检报告。 + +### 2.5 技术运维 + +负责 SIP Trace、在线通话、注册状态、网关状态、OpenSIPS/RTPEngine 监控和故障排查。 + +### 2.6 客户账号 + +V1 不包含客户登录端。客户侧能力暂不开放独立门户,仅保留内部运营后台使用。 + +## 3. 一级菜单设计 + +```text +运营平台 +├── 概览 Dashboard +├── 客户管理 +├── 供应商管理 +├── 线路与路由 +├── 费率与计费 +├── 话单中心 +├── 质检中心 +├── SIP 运维 +├── 监控告警 +├── 系统设置 +└── 权限与审计 +``` + +## 4. 页面功能设计 + +## 4.1 概览 Dashboard + +用途:展示平台整体运行状态。 + +核心卡片: + +- 今日通话数 +- 当前在线通话数 +- 今日接通率 +- 今日客户消费 +- 今日供应商成本 +- 今日毛利 +- 在线注册用户数 +- RTPengine 节点状态 +- OpenSIPS 节点状态 +- 异常网关数量 +- 质检待处理数量 + +主要图表: + +- 最近 24 小时通话量趋势 +- 最近 24 小时接通率趋势 +- 客户消费 TOP 10 +- 供应商成本 TOP 10 +- 失败响应码分布 + +OpenSIPS/RTPEngine 对接点: + +- OpenSIPS MI:`ps`、`get_statistics`、`dlg_list` +- OpenSIPS DB:CDR 表、subscriber/location 表 +- RTPEngine 控制接口:节点状态 + +## 4.2 客户管理 + +### 客户列表 + +字段: + +- 客户 ID +- 客户名称 +- SIP 域名 +- 认证方式 +- 网关数量 +- 状态 +- 余额 +- 授信额度 +- 计费模式 +- 线路组 +- 创建时间 + +操作: + +- 新增客户 +- 编辑客户 +- 禁用/启用客户 +- 配置费率 +- 配置线路组 +- 查看话单 +- 查看账单 + +### 客户详情 + +模块: + +- 基本信息 +- SIP 账号 +- 客户网关 +- IP 白名单 +- 费率方案 +- 线路组 +- 外呼时段 +- 余额/授信 +- 录音质检策略 +- 操作日志 + +### 客户网关与业务策略 + +设计要求: + +- 同一个客户可配置多个接入网关。 +- 每个客户网关可单独开启或关闭录音。 +- 同一个客户下,不同客户网关来源 IP + 不同被叫前缀,可映射到不同业务。 +- 每个业务映射项需绑定对应客户费率和落地网关组。 + +建议字段: + +- 客户 +- 客户网关 ID +- 来源 IP +- 被叫前缀 +- 业务名称 +- 录音开关 +- 客户费率方案 +- 落地网关组 +- 优先级 +- 状态 + +OpenSIPS 对接点: + +- `subscriber`:SIP 用户认证 +- `domain`:客户域名 +- `address`:客户 IP 白名单 +- `dr_groups`:客户所属路由组 + +## 4.3 供应商管理 + +### 供应商列表 + +字段: + +- 供应商 ID +- 供应商名称 +- 网关数量 +- 状态 +- 结算周期 +- 费率方案 +- 联系人 +- 创建时间 + +操作: + +- 新增供应商 +- 编辑供应商 +- 禁用/启用供应商 +- 管理网关 +- 管理供应商费率 +- 查看成本话单 + +### 落地网关 + +字段: + +- 网关 ID +- 供应商 +- SIP 地址 +- 端口 +- 协议 +- 权重 +- 优先级 +- 探测模式 +- 当前状态 +- 描述 + +### 供应商网关业务策略 + +设计要求: + +- 同一个供应商可配置多个落地网关。 +- 同一个网关下,可按不同被叫前缀区分业务。 +- 同一个网关下,也可按不同主叫号码或主叫号段区分业务。 +- 每个业务映射项需绑定对应供应商费率,用于成本结算。 + +建议字段: + +- 供应商 +- 网关 ID +- 主叫号码/号段 +- 被叫前缀 +- 业务名称 +- 供应商费率方案 +- 优先级 +- 状态 + +OpenSIPS 对接点: + +- `dr_gateways` +- `dispatcher` +- `drouting` MI:`dr_gw_status`、`dr_reload` + +## 4.4 线路与路由 + +### 线路组 + +用途:把多个供应商网关组合成一个客户可使用的落地线路池。 + +字段: + +- 线路组 ID +- 线路组名称 +- 适用客户 +- 网关列表 +- 路由策略 +- 失败重试策略 +- 状态 + +路由策略: + +- 按优先级 +- 按权重 +- 按成本最低 +- 按接通率 +- 按号码前缀 +- 按时段 + +### 路由规则 + +字段: + +- 规则 ID +- 客户/客户组 +- 号码前缀 +- 线路组 +- 优先级 +- 生效时间 +- 状态 +- 备注 + +补充规则: + +- 客户侧优先按客户网关 + 来源 IP 识别接入入口。 +- 在接入入口下继续按被叫前缀识别业务。 +- 识别出业务后绑定客户费率和落地网关组。 +- 供应商侧优先按供应商网关匹配,再按被叫前缀或主叫号码匹配成本业务和费率。 + +OpenSIPS 对接点: + +- `dr_rules` +- `dr_groups` +- `dr_carriers` +- `dialplan` + +## 4.5 费率与计费 + +### 客户费率 + +字段: + +- 客户 +- 业务名称 +- 客户网关(可选) +- 国家/地区 +- 号码前缀 +- 单价 +- 计费周期 +- 首周期 +- 生效时间 +- 失效时间 + +计费周期示例: + +- 1/1:按秒计费 +- 6/6:每 6 秒一个计费单位 +- 60/60:按分钟计费 +- 60/6:首分钟,之后每 6 秒 + +### 供应商费率 + +字段类似客户费率,但用于计算成本。 + +补充维度: + +- 供应商 +- 供应商网关(可选) +- 主叫号码/号段(可选) +- 被叫前缀 +- 业务名称 + +### Billing Worker + +职责: + +- 读取未计费 CDR +- 识别客户业务 +- 匹配客户费率 +- 识别供应商业务 +- 匹配供应商费率 +- 按计费周期计算客户费用 +- 按计费周期计算供应商成本 +- 计算毛利 +- 写入已计费话单 +- 更新余额或授信占用 + +计费公式示例: + +```text +计费秒数 = ceil(实际通话秒数 / 计费周期) * 计费周期 +客户费用 = 计费秒数 / 60 * 客户分钟单价 +供应商成本 = 计费秒数 / 60 * 供应商分钟单价 +毛利 = 客户费用 - 供应商成本 +``` + +OpenSIPS 对接点: + +- `acc` +- `dialog` +- CDR 表 +- 自研 billing 表 + +## 4.6 话单中心 + +### 原始 CDR + +字段: + +- Call-ID +- 客户 +- 客户网关 +- 来源 IP +- 主叫 +- 被叫 +- 业务名称 +- 开始时间 +- 接通时间 +- 结束时间 +- 通话时长 +- 计费时长 +- SIP 响应码 +- 供应商 +- 网关 +- 路由规则 +- 录音状态 + +### 已计费话单 + +字段: + +- 原始 CDR ID +- 客户费率 +- 供应商费率 +- 客户费用 +- 供应商成本 +- 毛利 +- 计费状态 +- 计费时间 + +操作: + +- 查询 +- 导出 CSV +- 重新计费 +- 查看 SIP Trace +- 查看录音 +- 页面内试听录音 +- 查看命中业务、客户费率、供应商费率和路由规则 + +## 4.7 质检中心 + +### 抽检规则 + +字段: + +- 客户 +- 抽检比例 +- 指定号码 +- 指定线路 +- 最小时长 +- 最大时长 +- 是否录音 +- 生效时间 + +### 录音列表 + +字段: + +- Call-ID +- 客户 +- 主叫 +- 被叫 +- 通话时间 +- 通话时长 +- 录音文件 +- 质检状态 +- 试听操作 + +### 录音详情 + +功能: + +- 播放录音 +- 查看录音基础信息 +- 标注问题 +- 打分 +- 导出质检报告 + +RTPEngine 对接点: + +- RTPEngine 录音能力 +- recording daemon +- 录音文件存储 + +外部服务: + +- 对象存储,例如 MinIO/S3 + +## 4.8 SIP 运维 + +页面: + +- 在线注册 +- 在线通话 +- SIP Trace +- 网关状态 +- Domain 管理 +- Address 白名单 +- Dispatcher +- Dynamic Routing +- RTPEngine 状态 + +用途: + +- 给技术运维排障 +- 不建议直接暴露给普通业务人员 + +OpenSIPS 对接点: + +- `usrloc` +- `dialog` +- `tracer` +- `dispatcher` +- `drouting` +- `domain` +- `permissions` +- `rtpengine` + +## 4.9 监控告警 + +监控项: + +- OpenSIPS 进程状态 +- OpenSIPS MI 是否可用 +- RTPEngine 节点是否可用 +- SIP 5060 是否监听 +- RTP 端口范围是否开放 +- 数据库连接 +- CDR 堆积 +- Billing Worker 堆积 +- 录音文件入库任务堆积 +- 磁盘空间 + +告警方式: + +- 邮件 +- 企业微信/钉钉/飞书 +- 短信 + +推荐工具: + +- Prometheus +- Grafana +- Loki 或 ELK +- Alertmanager +- Monit + +## 4.10 系统设置 + +页面: + +- SIP 域名 +- 监听地址 +- MI 连接配置 +- RTPengine 节点 +- 录音存储 +- 全局计费参数 +- 时区与时间格式 +- 安全策略 + +## 4.11 权限与审计 + +### 角色权限 + +角色示例: + +- 超级管理员 +- 运营管理员 +- 财务 +- 质检 +- 技术运维 +- 客户只读账号 + +### 操作日志 + +记录: + +- 登录 +- 新增/修改/删除客户 +- 修改费率 +- 修改路由 +- 手动重计费 +- 下载录音 +- 导出话单 + +## 5. 核心业务流程 + +## 5.1 客户开通流程 + +```text +新增客户 + -> 分配 SIP 域名或账号 + -> 配置 IP 白名单或 SIP 账号密码 + -> 配置客户网关 + -> 配置客户网关录音开关 + -> 配置客户网关业务策略 + -> 配置客户费率 + -> 配置线路组 + -> 配置外呼时段 + -> 启用客户 + -> OpenSIPS reload 对应模块 +``` + +## 5.2 外呼流程 + +```text +客户发起 INVITE + -> OpenSIPS 判断来源 IP/账号 + -> 识别客户 + -> 识别客户网关 + -> 检查客户状态和余额 + -> 检查外呼时段 + -> 按被叫前缀识别客户业务 + -> 匹配客户费率 + -> 选择落地网关组 + -> 选择供应商网关 + -> 按供应商网关 + 被叫前缀/主叫号码识别供应商业务 + -> 调用 RTPEngine 处理媒体 + -> 生成 Dialog/CDR + -> 通话结束后 Billing Worker 计费 +``` + +## 5.3 呼入分发流程 + +```text +上游呼入到 SIP 域名 + -> OpenSIPS domain 判断 + -> 根据域名/DID/客户规则匹配目标 + -> dispatcher 或 drouting 分发到后端服务器 + -> 记录 CDR +``` + +## 5.4 计费流程 + +```text +通话结束 + -> OpenSIPS 写入原始 CDR + -> Billing Worker 拉取未计费 CDR + -> 识别客户业务 + -> 匹配客户费率 + -> 识别供应商业务 + -> 匹配供应商费率 + -> 计算费用和成本 + -> 写入已计费话单 + -> 更新客户余额/授信 + -> 生成账单统计 +``` + +## 5.5 质检流程 + +```text +通话建立 + -> 判断是否命中录音规则 + -> OpenSIPS 控制 RTPEngine 录音 + -> 通话结束生成录音文件 + -> 录音入库 + -> 质检人员页面试听录音 + -> 标注问题并生成报告 +``` + +## 6. 建议数据库模块 + +业务库建议和 OpenSIPS 原生库分开,避免升级 OpenSIPS 时互相影响。 + +```text +opensips 原生库: +- subscriber +- location +- domain +- address +- dialog +- acc / cdr +- dr_gateways +- dr_rules +- dr_groups +- dispatcher +- rtpengine +- sip_trace + +业务库: +- customers +- customer_accounts +- customer_rate_plans +- customer_rates +- customer_gateways +- customer_gateway_policies +- vendors +- vendor_gateways +- vendor_rate_plans +- vendor_rates +- vendor_gateway_policies +- route_groups +- route_group_items +- outbound_time_policies +- raw_cdrs +- rated_cdrs +- invoices +- balances +- recordings +- quality_reviews +- users +- roles +- permissions +- audit_logs +``` + +## 7. 与 OpenSIPS/RTPEngine 的边界 + +OpenSIPS 负责: + +- SIP 协议 +- 鉴权 +- 注册 +- 路由 +- 通话状态 +- MI 控制 +- CDR 基础数据 + +RTPEngine 负责: + +- RTP 转发 +- NAT 穿透 +- 媒体锚定 +- 录音 + +自研平台负责: + +- 客户管理 +- 供应商管理 +- 费率 +- 计费 +- 账单 +- 质检 +- 权限 +- 报表 +- 审计 + +## 8. V1 优先级建议 + +### P0:先跑通运营闭环 + +- 客户管理 +- 供应商管理 +- 线路组 +- 客户费率 +- 供应商费率 +- 原始 CDR +- Billing Worker +- 已计费话单 +- 预付费余额实时控制 +- Dashboard 基础指标 + +### P1:提升运营能力 + +- 时段控制 +- 授信管理 +- 账单 +- 导出 +- 网关健康状态 +- SIP Trace 跳转 + +### P2:质检和录音运营 + +- 录音规则 +- 录音列表 +- 录音试听 +- 质检标注 +- 质检报表 + +### P3:高可用和企业化 + +- 多 OpenSIPS 节点 +- 多 RTPEngine 节点 +- 数据库高可用 +- 多角色权限 +- 审计日志 +- 告警中心 + +## 9. Figma 原型页面清单 + +建议优先画这些页面: + +1. Dashboard +2. 客户列表 +3. 客户详情 +4. 客户费率 +5. 供应商列表 +6. 落地网关 +7. 线路组 +8. 路由规则 +9. 原始 CDR +10. 已计费话单 +11. 账单详情 +12. 录音列表 +13. 录音详情 +14. SIP 运维概览 +15. 系统设置 + +## 10. V1 范围确认 + +已确认范围: + +- 不需要客户登录端。 +- 需要预付费余额实时控制。 +- 不需要多 OpenSIPS 节点,V1 按单节点设计。 +- 保留 OpenSIPS-CP,作为技术运维后台。 + +按以上范围进入下一阶段: + +```text +产品原型 -> 数据库设计 -> API 设计 -> OpenSIPS 脚本改造 -> Billing Worker -> 前端开发 +``` diff --git a/SOFTSWITCH_PLATFORM_DESIGN_V2.md b/SOFTSWITCH_PLATFORM_DESIGN_V2.md new file mode 100644 index 0000000..96f712a --- /dev/null +++ b/SOFTSWITCH_PLATFORM_DESIGN_V2.md @@ -0,0 +1,1163 @@ +# LisgloSIPS V2 项目设计与实施文档 + +> 中文名:聆界SIP管理平台 +> 文档版本:V2.0 +> 编制日期:2026-06-20 +> 文档状态:实施基线草案 +> 依据:服务器架构图、`SOFTSWITCH_PLATFORM_DESIGN_V1.md`、当前 React Web Demo、`OPENSIPS_INSTALL_NOTES.md` + +## 1. 文档目的 + +V2 的目标是把当前纯前端原型转化为可部署、可联调、可上线的软交换运营平台。本文不仅描述产品功能,还给出服务器部署、软件安装、后端代码、OpenSIPS/RTPEngine 对接、数据库、Redis、录音迁移、测试和上线方案。 + +本文作为下一阶段执行基线,后续数据库 DDL、OpenAPI、OpenSIPS 脚本和部署脚本应从本文拆分并版本化管理。 + +## 2. V2 范围 + +### 2.1 本期实施页面 + +以下菜单已在 Web Demo 中完成交互设计,纳入 V2 后端和联调范围: + +| 菜单 | V2 实施内容 | +| --- | --- | +| 概览 Dashboard | 通话、接通率、消费、成本、毛利、注册、节点、异常网关、质检指标 | +| 客户管理 | 客户新增、编辑、启停、余额、授信、充值、网关数量 | +| 客户网关管理 | IP/SIP 注册认证、启停、多个策略、优先级、主被叫匹配、线路组绑定 | +| 充值记录 | 客户充值、供应商充值、余额前后值、操作人、备注 | +| 供应商管理 | 供应商账户、余额、授信、充值 | +| 落地网关管理 | 认证、并发、CPS、禁呼时段、编码、号码转换、计费周期、费率、启停 | +| 落地线路组 | 线路组、组内网关、优先级、并发汇总 | +| 话单中心 | 话单查询、挂断原因、费用、录音、信令入口、详情 | +| 质检中心 | 抽检规则、录音列表、连续播放、自动播放、问题标注、评分 | +| 用户管理 | 用户新增、编辑、启停、重置密码、角色绑定 | +| 角色与权限 | 角色、权限矩阵、角色用户数、内置角色保护 | +| 操作日志 | 条件查询、结果、详情、导出 | + +### 2.2 延期页面 + +以下菜单在 Web Demo 中标记为“待设计”,本期不开发其业务页面: + +- 费率与计费 +- SIP 运维 +- 监控告警 +- 系统设置 + +说明:页面延期不代表底层能力完全取消。V2 仍需实现: + +- 最小计费引擎,用于生成话单客户费用、成本费用并更新余额。 +- Prometheus/Grafana 基础设施监控,但不接入自研“监控告警”页面。 +- OpenSIPS/RTPEngine 配置文件和环境变量管理,但不开放自研“系统设置”页面。 +- HOMER 信令查询入口,但不开发自研“SIP 运维”页面。 + +### 2.3 明确不做 + +- 客户自助门户。 +- 多 OpenSIPS 节点和通话级高可用。 +- 完整账单、发票、对账和复杂费率管理页面。 +- 自研可视化 SIP Trace、监控告警和系统配置页面。 +- Kubernetes。双机阶段使用 systemd、Nginx 和 Docker Compose 的有限组合。 + +## 3. 总体架构 + +### 3.1 服务器规划 + +环境分为两个阶段: + +- 当前开发阶段:A、B、T 均为本地 KVM 虚拟机,通过 Tailscale 专网联调。 +- 开发完成后:将 A、B 部署到阿里云,按下表建立 VPC、公网入口和安全组;T 保留为外部 SIP 测试端。 + +下表为最终阿里云生产目标,不代表当前本地开发机已经位于阿里云: + +| 服务器 | 规格 | 定位 | 公网暴露 | +| --- | --- | --- | --- | +| Server A | 阿里云计算型 4C8G | 核心通信网关 | SIP `15060/UDP`、RTP UDP 端口段 | +| Server B | 阿里云通用型 4C16G | 业务、缓存、数据库、录音、监控中心 | `443/TCP`,可选 `80/TCP` 跳转 HTTPS | + +两台服务器必须位于同一 VPC、同一可用区或低时延可用区,使用私网 IP 通信。目标私网 RTT 小于 1 ms。 + +本地开发阶段的实测网络、端口矩阵和未来阿里云安全组清单见 `docs/infra-check.md`。 + +### 3.2 逻辑拓扑 + +```mermaid +flowchart LR + UA["客户 SIP / 上游线路"] -->|"SIP 15060/UDP"| OS["Server A: OpenSIPS"] + UA <-->|"RTP UDP 端口段"| RTP["Server A: RTPEngine"] + OS <-->|"call-time Redis 查询"| REDIS["Server B: Redis"] + OS -->|"Redis Stream: CDR"| REDIS + OS -->|"HEP/9060 私网"| HEP["Server B: Heplify-server + HOMER"] + RTP --> TMP["Server A: /dev/shm/voip_rec"] + WORKER["Server B: Recording Worker"] -->|"私网拉取 ready 文件"| TMP + WORKER --> SSD["Server B: 录音数据盘"] + API["Server B: LisgloSIPS API"] --> MYSQL["Server B: MySQL"] + API --> REDIS + CDRW["Server B: CDR/Billing Worker"] --> REDIS + CDRW --> MYSQL + WEB["React Web + Nginx"] --> API + EXPORTER["Server A: Exporters"] --> PROM["Server B: Prometheus + Grafana"] +``` + +### 3.3 Server A 职责 + +- OpenSIPS:SIP 鉴权、客户识别、策略匹配、路由、Dialog/CDR 事件。 +- RTPEngine:媒体锚定、NAT 穿透、双向录音、内核态转发。 +- `/dev/shm/voip_rec`:3 GiB tmpfs 录音缓冲区。 +- HEP 发送端:向 Server B 发送信令镜像。 +- Node Exporter 和自定义 textfile 指标。 +- Fail2ban、nftables、OpenSIPS `pike`/`ratelimit` 防护。 + +“无状态”定义为不在 Server A 持久化业务主数据。OpenSIPS 运行时仍会维护事务和 Dialog 内存状态,服务重启会影响现有通话,V2 单节点接受此限制。 + +### 3.4 Server B 职责 + +- Nginx、React 静态资源、HTTPS 和 API 反向代理。 +- LisgloSIPS API、CDR Worker、Recording Worker、Config Publisher。 +- Redis:呼叫热数据、配置版本、锁、Redis Streams。 +- MySQL:业务主数据、充值流水、话单、质检、权限、审计。 +- 录音数据盘:保存迁移后的录音文件。 +- Heplify-server、HOMER UI 及其独立存储。 +- Prometheus、Grafana 及必要 exporters。 + +## 4. 架构图必须修正的两点 + +### 4.1 RTP 端口不能只开放 SIP 端口 + +架构图写明 Server A 公网仅暴露 `15060/UDP`,这不足以完成 RTP 媒体通信。若 RTPEngine 负责公网媒体锚定,安全组和主机防火墙还必须开放 RTPEngine 配置的 UDP 端口范围,例如: + +```text +SIP: 15060/UDP +RTP: 30000-40000/UDP +``` + +端口范围应根据并发测算缩小,并尽量限制来源运营商或客户 IP 段。若上游 RTP 来源不可预知,需要保留公网 UDP 范围并强化速率限制和监控。 + +### 4.2 CDR 使用 Redis Stream + +架构图中的 `queue:cdr_payload` 不应实现为无法确认消费的普通 Redis List。V2 使用 Redis Stream: + +```text +stream:cdr_payload +consumer group: billing-workers +``` + +要求支持: + +- 消息 ID 和业务幂等键。 +- `XREADGROUP` 消费。 +- MySQL 提交成功后 `XACK`。 +- Pending 消息重领。 +- 死信 Stream。 +- Redis 暂时不可用时 Server A 本地受限缓冲或告警降级。 + +## 5. 技术栈与下载清单 + +### 5.1 版本原则 + +- 服务器统一 Ubuntu 24.04 LTS。 +- OpenSIPS 使用项目已验证的 3.6.x 分支。 +- 其他软件选择受支持稳定版,禁止部署时无条件升级“最新版”。 +- 所有版本写入 `versions.env`、部署脚本或容器镜像标签。 +- 先在预生产执行安装、通话和性能验证,再用于生产。 + +### 5.2 Server A 软件 + +| 软件 | 用途 | 安装来源 | +| --- | --- | --- | +| OpenSIPS 3.6.x | SIP 核心 | https://apt.opensips.org/ | +| opensips-cli | 管理与检查 | OpenSIPS APT | +| OpenSIPS Redis/HTTP/JSON 模块 | Redis 热路径、MI、事件 | OpenSIPS APT | +| RTPEngine daemon + kernel module | RTP 转发与录音 | https://github.com/sipwise/rtpengine | +| Heplify client 或 OpenSIPS tracer/HEP | 信令镜像 | https://github.com/sipcapture/heplify | +| Node Exporter | 主机指标 | https://prometheus.io/docs/guides/node-exporter/ | +| Fail2ban、nftables | 主机防护 | Ubuntu APT | +| chrony | 时间同步 | Ubuntu APT | + +建议 OpenSIPS 包: + +```text +opensips +opensips-cli +opensips-redis-module +opensips-http-modules +opensips-json-module +opensips-tls-module +opensips-auth-modules +opensips-mysql-dbschema +``` + +实际包名以 OpenSIPS 3.6 Ubuntu 24.04 仓库为准,安装脚本执行前用 `apt-cache search opensips` 校验。 + +### 5.3 Server B 软件 + +| 软件 | 用途 | 安装来源 | +| --- | --- | --- | +| Node.js LTS | API 和 Worker | https://nodejs.org/en/about/previous-releases | +| pnpm | Node.js 包管理 | Corepack | +| Nginx | Web、HTTPS、反向代理 | https://nginx.org/en/linux_packages.html | +| Redis | 热数据与 Streams | https://redis.io/docs/latest/operate/oss_and_stack/install/install-redis/ | +| MySQL | 业务持久化 | https://dev.mysql.com/downloads/repo/apt/ | +| Heplify-server + HOMER UI | 信令接收和排障 | https://github.com/sipcapture/heplify-server | +| Prometheus | 指标存储 | https://prometheus.io/docs/prometheus/latest/installation/ | +| Grafana | 监控大盘 | https://grafana.com/docs/grafana/latest/setup-grafana/installation/debian/ | +| mysqld_exporter、redis_exporter | 数据库与缓存指标 | Prometheus Community | +| Docker Engine/Compose | 仅用于 HOMER 组合服务 | https://docs.docker.com/engine/install/ubuntu/ | +| ffmpeg | 录音格式校验与转换 | Ubuntu APT | + +HOMER 数据与业务 MySQL 必须逻辑隔离。优先使用 HOMER 官方 Compose 所要求的数据库,不把 HEP 海量信令写入业务库。 + +## 6. 网络、安全组和端口 + +### 6.1 Server A 入站 + +| 端口 | 来源 | 说明 | +| --- | --- | --- | +| `15060/UDP` | 客户/供应商允许列表 | SIP | +| `30000-40000/UDP` | RTP 对端或允许网段 | RTPEngine 媒体,最终按容量调整 | +| `/TCP` | 堡垒机或管理 VPN | SSH 管理端口,不对全网开放;保留密钥登录 | + +### 6.2 Server B 入站 + +| 端口 | 来源 | 说明 | +| --- | --- | --- | +| `443/TCP` | 管理端用户 | LisgloSIPS HTTPS | +| `80/TCP` | 可选 | 跳转 HTTPS,不承载登录 | +| `9060/UDP` | Server A 私网 IP | HEP | +| `/TCP` | 堡垒机或管理 VPN | SSH 管理端口;保留密钥登录 | + +Redis `6379`、MySQL `3306`、Prometheus `9090`、Grafana `3000`、应用端口、HOMER 内部端口不得直接暴露公网。Redis 和 MySQL至少绑定私网 IP,并通过安全组仅允许明确来源;同机服务优先使用 `127.0.0.1`。 + +### 6.3 安全要求 + +- 全部服务器启用 chrony,CDR、HOMER 和审计日志时间必须一致。 +- Web 强制 HTTPS、HSTS、SameSite Cookie 和 CSRF 防护。 +- 管理员密码使用 Argon2id;不记录明文密码。 +- SIP 注册密码按 OpenSIPS Digest 需要保存 HA1 或受控密文,不直接返回前端。 +- MI HTTP 只绑定 Server A `127.0.0.1`。 +- 所有生产 Secret 放入权限为 `0600` 的环境文件或云 Secret 服务。 +- 操作日志不得记录密码、完整 Authorization、Cookie、录音签名 URL。 + +## 7. 基础环境部署 + +### 7.1 阿里云资源 + +1. 创建 VPC 和私有子网。 +2. 创建 Server A 4C8G 计算型实例和 Server B 4C16G 通用型实例。 +3. Server B 挂载独立 ESSD 数据盘,业务数据库、录音和监控数据使用独立目录或分区。 +4. 配置安全组、EIP、DNS 和快照策略。 +5. 建议先创建同规格预生产环境,至少在上线前保留 3 天完整演练时间。 + +### 7.2 两台服务器通用初始化 + +```bash +sudo apt update && sudo apt full-upgrade -y +sudo apt install -y curl wget ca-certificates gnupg jq git unzip chrony \ + nftables fail2ban vim htop iotop sysstat lsof net-tools tcpdump +sudo timedatectl set-timezone Asia/Shanghai +sudo systemctl enable --now chrony nftables fail2ban +``` + +生产环境推荐系统内部统一存 UTC,前端按 `Asia/Shanghai` 展示;若操作系统使用上海时区,数据库字段仍需明确 UTC 语义。 + +### 7.3 Server A tmpfs + +```bash +sudo mkdir -p /dev/shm/voip_rec +sudo chown rtpengine:rtpengine /dev/shm/voip_rec +sudo chmod 0750 /dev/shm/voip_rec +``` + +`/etc/fstab`: + +```fstab +tmpfs /dev/shm/voip_rec tmpfs rw,nosuid,nodev,noexec,size=3G,mode=0750 0 0 +``` + +3 GiB 缓冲不是长期存储。按双向 G.711 原始音频约 16 KiB/s 粗略估算,只能容纳约 53 个“并发小时”,未计算容器和文件头开销。100 路持续录音时缓冲可能不到 35 分钟。Recording Worker 建议 5-15 秒扫描一次,使用量超过 70% 告警,超过 85% 时停止接受新的非强制录音或触发紧急迁移。 + +### 7.4 Server B 数据盘目录 + +```text +/data/mysql +/data/redis +/data/recordings/YYYY/MM/DD +/data/prometheus +/data/grafana +/data/homer +/data/backups +``` + +数据盘挂载参数、文件系统和 IOPS 规格需根据压测结果确认。录音目录和 MySQL 不建议共享同一个容易打满的根分区。 + +## 8. OpenSIPS 与 RTPEngine 实施 + +### 8.1 OpenSIPS 呼叫路径 + +```text +INVITE + -> 基础合法性与防扫描检查 + -> IP 或 SIP Digest 认证 + -> Redis 查询客户、客户网关、状态、余额门槛 + -> Redis 查询客户网关策略(主叫条件 AND 被叫条件,按优先级) + -> 获得落地线路组 + -> 按组内优先级、启用状态、并发/CPS 选择落地网关 + -> 执行主被叫前缀转换、时段和编码限制 + -> RTPEngine offer/answer + -> 转发到供应商 + -> BYE/失败时生成标准化 CDR 事件 +``` + +### 8.2 呼叫前原子检查 + +Redis Lua 脚本一次完成: + +- 客户是否启用。 +- 客户网关是否启用。 +- IP/账号是否匹配。 +- 余额加授信是否大于最小通话门槛。 +- 网关 CPS 和并发是否超限。 +- 黑名单是否命中。 +- 返回配置版本、线路组和路由策略。 + +金额禁止使用 Redis 浮点数,统一使用整数“微元”或其他固定精度最小单位。 + +### 8.3 RTPEngine + +- 使用内核转发模块并在启动时校验。 +- 控制接口只在本机或私网监听。 +- RTP 端口段与安全组保持一致。 +- 录音先写 `.part`,完成后原子重命名为 `.ready`。 +- 文件名包含日期、Call-ID 哈希和方向,禁止直接拼接未清洗 SIP Header。 + +### 8.4 CDR 标准事件 + +```json +{ + "event_id": "uuid", + "idempotency_key": "call_id + ended_at 或 event_id", + "call_id": "sip-call-id", + "node_id": "a1", + "opensips_instance": "opensips-a1", + "ingress_a_ip": "100.90.90.90", + "rtpengine_node": "a1", + "customer_id": "C1001", + "customer_gateway_id": "CGW-001", + "source_ip": "10.10.1.11", + "caller": "02160010001", + "callee": "13800138000", + "vendor_id": "V1001", + "vendor_gateway_id": "VGW-001", + "line_group_id": "LG-001", + "started_at": "2026-06-20T02:14:22.123Z", + "answered_at": "2026-06-20T02:14:27.010Z", + "ended_at": "2026-06-20T02:17:30.445Z", + "duration_sec": 183, + "sip_code": 200, + "hangup_reason": "NORMAL_CLEARING", + "recording_key": "2026/06/20/xxx.ready", + "config_version": 1042 +} +``` + +`event_id` 和 `call_id + ended_at` 作为幂等依据。任何重试不得重复扣费或重复写充值/余额流水。 + +## 9. Redis 设计 + +### 9.1 Key 规范 + +```text +cfg:active_version +cfg:customer:{customer_id} +cfg:customer_gateway:{gateway_id} +cfg:customer_gateway:{gateway_id}:policies +cfg:vendor_gateway:{gateway_id} +cfg:line_group:{group_id}:items +auth:ip:{ip} +auth:sip:{username} +balance:customer:{customer_id} +blacklist:caller +blacklist:callee +stream:cdr_payload +stream:cdr_deadletter +stream:recording_jobs +lock:cdr:{event_id} +``` + +### 9.2 配置发布 + +1. API 在 MySQL 事务中写业务表和 `outbox_events`。 +2. Config Publisher 读取 outbox,生成完整配置快照。 +3. 使用临时版本 Key 写入 Redis。 +4. 校验数量和哈希后原子切换 `cfg:active_version`。 +5. 发布成功后更新 outbox 状态。 +6. 保留上一个版本,故障时一键回滚。 + +禁止 API 先改 Redis、后改 MySQL。MySQL 是唯一业务事实源,Redis 是呼叫热路径副本。 + +### 9.3 Redis 持久化 + +- 启用 AOF `appendfsync everysec`。 +- 配置 RDB 快照。 +- 限制 `maxmemory`,配置前评估淘汰策略;核心配置和队列不得被随机淘汰。 +- 监控 Stream 长度、Pending 数、消费延迟、内存和连接数。 + +## 10. MySQL 数据模型 + +### 10.1 业务核心表 + +```text +customers +customer_gateways +customer_gateway_policies +customer_recharges +vendors +vendor_recharges +vendor_gateways +vendor_gateway_forbidden_periods +vendor_gateway_codecs +vendor_gateway_prefix_rules +landing_line_groups +landing_line_group_items +raw_cdrs +rated_cdrs +recordings +quality_sampling_rules +quality_reviews +users +roles +permissions +user_roles +role_permissions +audit_logs +outbox_events +idempotency_keys +``` + +### 10.2 关键约束 + +- ID 使用业务前缀 + 雪花 ID/UUID,禁止依赖前端生成。 +- 金额使用 `DECIMAL(20,6)`,应用层不得使用 JavaScript 浮点直接计算金额。 +- 充值使用不可变流水,余额变化必须与流水在同一事务提交。 +- 网关策略唯一索引至少包含 `gateway_id + priority`。 +- 线路组成员唯一索引包含 `line_group_id + vendor_gateway_id`。 +- CDR 使用 `event_id` 唯一索引。 +- 所有管理表包含 `created_at`、`updated_at`、`created_by`、`updated_by` 和乐观锁版本号。 +- 时间存 UTC;API 返回 ISO 8601。 +- 删除优先软删除;充值、CDR、审计日志不允许物理删除。 + +### 10.3 余额事务 + +充值事务: + +```text +锁定客户/供应商账户行 + -> 读取充值前余额 + -> 插入充值流水 + -> 更新余额 + -> 插入审计日志/Outbox + -> 提交 +``` + +CDR 扣费事务: + +```text +检查 event_id 幂等 + -> 写 raw_cdr + -> 计算最小计费结果 + -> 写 rated_cdr + -> 锁定客户余额 + -> 扣费并写余额流水 + -> ACK Redis Stream +``` + +## 11. 后端工程设计 + +### 11.1 技术选型 + +- TypeScript + Node.js LTS。 +- NestJS + Fastify Adapter。 +- Prisma 管理 MySQL Schema 和迁移。 +- ioredis 访问 Redis 和 Streams。 +- OpenAPI/Swagger 生成 API 契约。 +- Pino 输出结构化 JSON 日志。 +- Vitest/Jest 做单元测试,Supertest 做 API 集成测试。 +- systemd 管理 API 和 Worker;HOMER 可使用 Docker Compose。 + +### 11.2 推荐目录 + +```text +lisglosips/ +├── apps/ +│ ├── web/ +│ ├── api/ +│ ├── worker-cdr/ +│ ├── worker-recording/ +│ └── worker-config-publisher/ +├── packages/ +│ ├── contracts/ +│ ├── domain/ +│ ├── database/ +│ ├── redis/ +│ ├── auth/ +│ └── observability/ +├── prisma/ +│ ├── schema.prisma +│ └── migrations/ +├── deploy/ +│ ├── server-a/ +│ ├── server-b/ +│ ├── nginx/ +│ ├── systemd/ +│ └── homer/ +├── opensips/ +│ ├── opensips.cfg +│ ├── routes/ +│ └── scripts/ +├── docs/ +│ ├── openapi.yaml +│ ├── redis-keys.md +│ └── runbooks/ +└── tests/ +``` + +### 11.3 后端模块 + +```text +AuthModule +DashboardModule +CustomersModule +CustomerGatewaysModule +CustomerGatewayPoliciesModule +RechargesModule +VendorsModule +VendorGatewaysModule +LandingLineGroupsModule +CdrModule +RecordingsModule +QualityModule +UsersModule +RolesModule +AuditModule +ConfigPublisherModule +HealthModule +``` + +### 11.4 API 清单 + +```text +POST /api/v2/auth/login +POST /api/v2/auth/refresh +POST /api/v2/auth/logout +GET /api/v2/dashboard/summary +GET /api/v2/dashboard/trends + +GET /api/v2/customers +POST /api/v2/customers +PATCH /api/v2/customers/:id +POST /api/v2/customers/:id/recharges + +GET /api/v2/customer-gateways +POST /api/v2/customer-gateways +PATCH /api/v2/customer-gateways/:id +POST /api/v2/customer-gateways/:id/enable +POST /api/v2/customer-gateways/:id/disable +GET /api/v2/customer-gateways/:id/policies +POST /api/v2/customer-gateways/:id/policies +PATCH /api/v2/customer-gateway-policies/:id +DELETE /api/v2/customer-gateway-policies/:id +POST /api/v2/customer-gateways/:id/policies/reorder + +GET /api/v2/vendors +POST /api/v2/vendors +PATCH /api/v2/vendors/:id +POST /api/v2/vendors/:id/recharges +GET /api/v2/recharges + +GET /api/v2/vendor-gateways +POST /api/v2/vendor-gateways +PATCH /api/v2/vendor-gateways/:id +POST /api/v2/vendor-gateways/:id/enable +POST /api/v2/vendor-gateways/:id/disable + +GET /api/v2/landing-line-groups +POST /api/v2/landing-line-groups +PATCH /api/v2/landing-line-groups/:id +POST /api/v2/landing-line-groups/:id/items +DELETE /api/v2/landing-line-groups/:id/items/:gatewayId +POST /api/v2/landing-line-groups/:id/items/reorder + +GET /api/v2/cdrs +GET /api/v2/cdrs/:id +GET /api/v2/cdrs/:id/recording +GET /api/v2/cdrs/:id/signaling-link + +GET /api/v2/quality/rules +POST /api/v2/quality/rules +PATCH /api/v2/quality/rules/:id +DELETE /api/v2/quality/rules/:id +POST /api/v2/quality/rules/:id/enable +POST /api/v2/quality/rules/:id/disable +GET /api/v2/recordings +GET /api/v2/recordings/:id +PUT /api/v2/recordings/:id/review + +GET /api/v2/users +POST /api/v2/users +PATCH /api/v2/users/:id +POST /api/v2/users/:id/reset-password +GET /api/v2/roles +POST /api/v2/roles +PATCH /api/v2/roles/:id +PUT /api/v2/roles/:id/permissions +GET /api/v2/audit-logs +GET /api/v2/audit-logs/:id +``` + +### 11.5 API 规范 + +- 列表统一 `page`、`page_size`、`sort`、筛选参数。 +- 返回 `{ data, meta, request_id }`。 +- 错误返回稳定业务码,不把数据库错误直接返回前端。 +- 新增、充值、重置密码、策略重排支持 `Idempotency-Key`。 +- 写请求校验 `If-Match` 或版本字段,防止覆盖其他管理员修改。 +- 导出采用异步任务和短期下载地址,避免长请求占用 5 Mbps 公网带宽。 + +## 12. 录音迁移和质检 + +### 12.1 文件生命周期 + +```text +RTPEngine 写入 .part + -> 录音完成原子改名 .ready + -> Recording Worker 私网扫描 + -> 拉取到 Server B 临时目录 + -> 校验大小、SHA-256、音频时长 + -> 原子移动到 /data/recordings/YYYY/MM/DD + -> 写 recordings 表 + -> 删除 Server A 源文件 +``` + +任何一步失败都不得先删除 Server A 文件。Worker 使用文件锁或 Redis 锁防止重复搬运,并对 `.part`、过期 `.ready`、孤儿文件分别处理。 + +### 12.2 播放 + +- API 先校验 `recording.play` 权限。 +- Nginx `X-Accel-Redirect` 发送文件,Node.js 不直接读取整段音频到内存。 +- 支持 HTTP Range,便于拖动播放。 +- 操作日志记录播放人、录音 ID、时间和 IP。 +- Server B 公网只有 5 Mbps 时需限制并发播放;后续可迁移至阿里云 OSS,使用短期签名 URL。 + +### 12.3 抽检规则 + +V2 字段与 Web Demo 保持一致: + +- 规则名称。 +- 客户。 +- 抽检比例。 +- 指定线路。 +- 生效时间。 +- 状态。 + +Worker 在录音入库后根据客户、线路和稳定哈希进行抽样,避免重启导致随机结果变化。 + +## 13. 权限与审计 + +### 13.1 权限模型 + +权限粒度采用 `resource.action`: + +```text +customers.view +customers.manage +vendors.view +vendors.manage +gateways.view +gateways.manage +routes.view +routes.manage +recharge.manage +cdr.view +cdr.export +recording.play +quality.manage +users.manage +roles.manage +logs.view +``` + +超级管理员、运营管理员、财务、质检和技术运维为内置角色。内置角色名称和关键权限禁止普通管理员修改。 + +### 13.2 审计要求 + +审计日志至少记录: + +- `request_id`、操作人、用户账号、角色。 +- 时间、IP、User-Agent。 +- 模块、动作、对象类型、对象 ID。 +- 修改前/后摘要,敏感字段脱敏。 +- 成功/失败和业务错误码。 + +审计日志由服务端生成,前端传入的“操作人”不可信。 + +## 14. Dashboard 数据策略 + +- 实时在线通话和注册:Redis 热数据或 OpenSIPS 指标。 +- 今日通话、费用、成本、毛利:MySQL 聚合表。 +- 24 小时趋势:按 5 分钟或 1 小时预聚合,禁止首页扫描全量 CDR。 +- 失败响应码:CDR 聚合。 +- 异常网关:健康状态缓存。 +- 质检待处理:recordings/quality_reviews 聚合。 + +创建定时聚合任务 `worker-metrics` 或复用 CDR Worker 增量写统计表。 + +## 15. 最小计费实现 + +虽然“费率与计费”页面延期,V2 仍需最小计费能力支持话单费用和余额。 + +V2 首期仅支持落地网关上的: + +- 计费周期,单位秒,最大 60。 +- 周期费率。 +- 分钟费率展示值:`周期费率 * 60 / 计费周期`。 + +计算使用 Decimal: + +```text +计费周期数 = ceil(通话秒数 / 计费周期) +费用 = 计费周期数 * 周期费率 +``` + +客户侧费率在完整页面设计前可由受控配置表或迁移脚本维护,不对普通运营角色开放。所有费率变更必须留审计记录和生效时间。 + +## 16. 监控、日志和备份 + +### 16.1 基础监控 + +即使自研“监控告警”页面延期,也必须部署: + +- Server A/B CPU、内存、磁盘、网络、负载。 +- OpenSIPS 进程、SIP 请求量、响应码、活动 Dialog。 +- RTPEngine 会话、丢包、端口使用率。 +- tmpfs 使用率和最老录音文件年龄。 +- Redis 内存、连接、Stream 长度、Pending。 +- MySQL 连接、慢查询、锁等待、磁盘。 +- API P95/P99、5xx、Worker 延迟。 + +### 16.2 日志 + +- 应用日志输出 JSON,包含 `request_id`、`call_id`、`event_id`。 +- journald 保留短期日志;可选 Loki/Promtail 后续集中化。 +- 禁止在普通日志输出 SIP 密码、Web Token 和录音内容。 + +### 16.3 备份 + +- MySQL:每日全量备份 + binlog,定期恢复演练。 +- Redis:AOF + RDB;Redis 不是业务主数据唯一副本。 +- 录音:数据盘快照,后续转 OSS 生命周期管理。 +- OpenSIPS、Nginx、systemd、Prometheus 配置进入 Git。 +- 备份密钥与业务数据分开保存。 + +## 17. 开发与部署阶段 + +### 阶段 0:确认技术决策(2-3 天) + +- 确认 SIP 公网端口、RTP 端口段和运营商 IP 范围。 +- 确认预估并发、CPS、日通话量、日录音小时数。 +- 确认客户余额实时控制规则和最低余额门槛。 +- 确认录音保存周期和是否接 OSS。 +- 确认 MySQL 还是 MariaDB。本文默认 MySQL;若沿用现有 MariaDB,需先验证 Prisma 和 SQL 兼容性。 + +### 阶段 1:服务器和网络(3-4 天) + +- 创建 VPC、Server A/B、EIP、数据盘和安全组。 +- 安装基础工具、时间同步、防火墙。 +- 验证私网 RTT、带宽、磁盘吞吐和 DNS。 +- 输出 `infra-check.md` 和端口验收结果。 + +### 阶段 2:Server A 通信栈(5-7 天) + +- 安装 OpenSIPS 3.6.x、RTPEngine 和内核模块。 +- 配置 SIP 15060、RTP 端口范围和 MI 本地端口。 +- 配置 Redis 连接、HEP、录音 tmpfs、指标和安全限制。 +- 完成 IP 认证、SIP 注册认证和基础呼叫。 +- 完成单通、双通、挂断、失败响应和录音测试。 + +### 阶段 3:Server B 基础服务(4-5 天) + +- 安装 Nginx、Node.js、Redis、MySQL。 +- 部署 HOMER、Prometheus、Grafana 和 exporters。 +- 初始化数据盘、备份目录和 TLS。 +- 创建应用系统用户、systemd 服务账户和最小权限目录。 + +### 阶段 4:后端骨架(5 天) + +- 初始化 monorepo、NestJS、Prisma、OpenAPI、日志和健康检查。 +- 完成认证、JWT 刷新、RBAC、审计拦截器。 +- 建立数据库迁移、种子数据和 CI。 +- 完成 Redis 客户端、Stream 基础库和 outbox publisher。 + +### 阶段 5:运营业务 API(10-12 天) + +- 客户、客户网关、策略、充值。 +- 供应商、落地网关、落地线路组。 +- 用户、角色、权限、操作日志。 +- 接入当前 React Demo,移除 Mock 数据。 + +### 阶段 6:通话、CDR 和录音闭环(8-10 天) + +- Redis 配置发布和回滚。 +- OpenSIPS 呼叫时查询和路由。 +- CDR Stream、幂等消费、最小计费、余额扣减。 +- Recording Worker、文件校验、Range 播放。 +- HOMER Call-ID 跳转。 + +### 阶段 7:Dashboard 和质检(5-7 天) + +- Dashboard 聚合指标。 +- 抽检规则、录音列表、连续播放、质检结果。 +- 权限校验和播放审计。 + +### 阶段 8:测试和上线(7-10 天) + +- 功能、集成、故障、性能、安全测试。 +- 全量上线演练和回滚演练。 +- 小流量客户灰度。 +- 观察 24-72 小时后扩大流量。 + +预计总周期 8-10 周,建议至少配置:后端 1 人、SIP/媒体 1 人、前端 1 人、运维 0.5 人、测试 0.5 人。单人实施需要相应延长周期并优先保证呼叫、CDR、余额和录音闭环。 + +## 18. 后端代码完成标准 + +- 所有本期页面均使用真实 API,不再依赖硬编码 Mock。 +- OpenAPI 可生成前端类型或 API Client。 +- 数据库迁移可在空库一键执行,也能回滚最近一次变更。 +- 关键服务有 `/health/live` 和 `/health/ready`。 +- CDR 重复投递不会重复扣费。 +- 充值重复请求不会重复加余额。 +- 配置发布失败不会留下半套 Redis 配置。 +- 录音搬运失败不会删除源文件。 +- RBAC 在服务端生效,隐藏按钮不能替代权限校验。 +- 敏感操作全部进入审计日志。 +- 单元测试覆盖金额、优先级、主被叫条件、幂等和权限。 + +## 19. 测试与验收 + +### 19.1 功能验收 + +- IP 和 SIP 注册两种客户网关认证成功/失败路径。 +- 主叫等于、主叫前缀、被叫等于、被叫前缀及组合条件。 +- 多策略优先级和多落地网关优先级。 +- 网关启停、禁呼时段、编码、号码前缀转换。 +- 充值、余额前后值、授信和审计。 +- CDR 字段、挂断原因、客户费用、成本费用。 +- 抽检规则、录音播放、上下条、自动播放、保存质检结果。 +- 用户、角色、权限和重置密码。 + +### 19.2 性能验收 + +压测目标必须在阶段 0 根据业务量确认。最低应覆盖: + +- 目标 CPS 的 1.5 倍持续 30 分钟。 +- 目标并发的 1.2 倍持续 2 小时。 +- Redis 呼叫时查询 P99。 +- CDR Worker 堆积恢复速度。 +- 100% 录音下 tmpfs 增长和迁移速度。 +- 多用户同时查询 CDR 和播放录音时 Server B 带宽。 + +### 19.3 故障验收 + +- Redis 短暂不可用。 +- MySQL 重启和事务回滚。 +- CDR Worker 异常退出后重领 Pending。 +- Recording Worker 重复执行。 +- Server B 数据盘接近满。 +- Server A tmpfs 达 70%/85%。 +- RTPEngine 重启。 +- 配置版本回滚。 + +### 19.4 安全验收 + +- SIP 扫描、暴力注册、非法来源 IP、超 CPS。 +- 越权访问其他模块 API。 +- 重放充值请求。 +- SQL 注入、XSS、CSRF、弱密码和 Session 失效。 +- 录音 URL 过期、Range 越权和路径穿越。 + +## 20. 上线和回滚 + +### 20.1 上线顺序 + +1. 备份 MySQL、Redis、OpenSIPS 和 Nginx 配置。 +2. 部署 Server B 数据库迁移和后端,但暂不切流。 +3. 发布 Redis 配置快照并校验。 +4. 部署 Server A OpenSIPS/RTPEngine 配置。 +5. 使用测试账号完成真实呼叫、CDR、扣费和录音。 +6. 灰度一个低风险客户或一条线路。 +7. 观察指标、CDR 差异、余额和录音完整性。 +8. 逐步扩大流量。 + +### 20.2 回滚 + +- Web/API:切回上一个发布目录或镜像。 +- 数据库:使用向前兼容迁移;上线窗口不执行不可逆删列。 +- Redis 配置:切回上一个 `cfg:active_version`。 +- OpenSIPS:恢复上一版配置并执行语法检查后重载。 +- RTPEngine:恢复上一版 systemd 参数。 +- CDR:停止 Consumer,不删除 Stream;修复后重放。 +- 录音:停止删除 Server A 源文件,保留 B 端临时文件等待人工处理。 + +## 21. 主要风险 + +| 风险 | 影响 | 控制措施 | +| --- | --- | --- | +| Server A 单点 | 重启影响通话 | V2 接受;配置快速恢复;后续增加双节点 | +| 仅开放 15060 | 无 RTP 或单通 | 必须开放并验证 RTP UDP 端口段 | +| 3 GiB tmpfs 打满 | 录音丢失、进程异常 | 高频搬运、阈值告警、降级策略 | +| Server B 5 Mbps | 多录音播放卡顿 | Range、限并发、后续 OSS/CDN | +| Redis 成为呼叫热路径单点 | 新呼叫失败 | AOF/RDB、监控、后续主从/Sentinel | +| CDR 重复消费 | 重复扣费 | Stream ACK、唯一索引、幂等事务 | +| 配置半发布 | 路由不一致 | MySQL Outbox + Redis 版本原子切换 | +| HOMER 数据膨胀 | 数据盘占满 | 独立存储、保留周期、容量告警 | +| 金额浮点误差 | 对账差异 | Decimal/整数最小单位,禁止 JS Number 计费 | + +## 22. 下一步执行清单 + +### 立即执行 + +- [ ] 确认预计 CPS、并发、日话单、录音小时数。 +- [ ] 确认 Server A RTP 端口段和安全组来源范围。 +- [ ] 确认 Server B 数据盘容量、IOPS 和录音保留期。 +- [ ] 确认 MySQL/MariaDB 最终选择。 +- [ ] 申请域名和 TLS 证书。 +- [ ] 创建预生产 VPC 和两台服务器。 + +### 服务器部署完成条件 + +- [ ] Server A OpenSIPS、RTPEngine、Redis 私网连接、HEP、tmpfs、exporter 正常。 +- [ ] Server B Nginx、Node.js、Redis、MySQL、HOMER、Prometheus、Grafana 正常。 +- [ ] 安全组端口和主机防火墙验收。 +- [ ] 备份、日志轮转、时间同步和系统用户验收。 +- [ ] 完成端到端测试呼叫并在 HOMER 查询到信令。 +- [ ] 完成录音从 A 内存盘到 B 数据盘的搬运测试。 + +### 后端开发启动条件 + +- [ ] 创建 V2 monorepo 和 CI。 +- [ ] 提交 Prisma Schema V1 和初始化迁移。 +- [ ] 提交 OpenAPI V2 基线。 +- [ ] 提交 Redis Key 和 Stream 契约。 +- [ ] 完成 Auth/RBAC/Audit 骨架。 +- [ ] 先实现客户、网关、线路组和配置发布,再实现 CDR/录音。 + +## 23. V2 交付物 + +```text +1. 阿里云双机部署记录和安全组清单 +2. Server A/Server B 幂等安装脚本 +3. OpenSIPS 和 RTPEngine 配置仓库 +4. MySQL DDL/Prisma migrations +5. Redis Key/Stream 契约 +6. OpenAPI 文档 +7. LisgloSIPS API 和 Workers 源码 +8. React Web 与真实 API 集成版本 +9. Prometheus/Grafana/HOMER 部署配置 +10. 自动化测试、压测报告和安全检查报告 +11. 上线、回滚、备份恢复和故障处置 Runbook +``` + +## 24. 最终实施原则 + +- 先打通“配置发布 -> 呼叫 -> CDR -> 计费 -> 余额 -> 录音 -> 质检”闭环,再扩展页面。 +- MySQL 是业务事实源,Redis 是呼叫热路径和队列,不反向替代主库。 +- 金额、CDR、充值和录音全部按可重试、可审计、幂等设计。 +- Server A 保持精简,Server B 承担业务复杂度。 +- 待设计菜单不阻塞底层必需能力,但不得在 V2 临时扩张为未确认的产品页面。 +- 所有部署和配置必须脚本化、版本化、可回滚。 + +## 25. Codex 全流程分步实施规范 + +### 25.1 目的 + +本项目计划主要由 Codex 持续实施。为避免单次任务过大、上下文丢失、误操作和新会话重复工作,整个 V2 被拆分为 31 个原子任务 `S00-S30`。每个会话默认只完成一个任务,除非用户明确授权合并。 + +详细实时状态记录在 `IMPLEMENTATION_STATUS.md`,服务器密码只保存在被 `.gitignore` 排除的 `.codex-private/SERVER_ACCESS.md`。 + +### 25.2 服务器固定代号 + +| 代号 | IP | SSH 用户 | 职责 | +| --- | --- | --- | --- | +| Server A | `100.90.90.90` | `hector` | 核心通信网关 | +| Server B | `100.90.90.91` | `hector` | 业务、缓存与监控中心 | +| Server T | `100.93.185.30` | `hector` | 已安装 OpenSIPS,模拟 SIP 客户 | + +主文档不保存明文密码。新会话只有在用户授权当前任务连接服务器后,才读取私密访问文件,并禁止在回复或日志中回显密码。 + +### 25.3 单会话执行纪律 + +1. 先读取 `SOFTSWITCH_PLATFORM_DESIGN_V2.md` 和 `IMPLEMENTATION_STATUS.md`。 +2. 只执行状态文件中的“当前任务”。 +3. 服务器任务先做只读检查,再做写操作。 +4. 修改配置前备份,记录绝对路径、时间戳和回滚命令。 +5. 服务重启、防火墙、SSH、数据库迁移、数据删除前说明影响。 +6. 不能因安装困难随意更换技术栈或扩大公网端口。 +7. 安装软件必须记录来源、版本和校验结果。 +8. 完成后执行该任务的验收,不以“命令无报错”代替功能验证。 +9. 更新状态文件后再结束会话。 +10. 出现阻塞时停止在安全状态,不绕过安全控制继续后续任务。 + +### 25.4 原子任务顺序 + +#### S00-S02:安全接入和资产盘点 + +**S00 SSH 安全接入与凭据迁移** + +- 用户明确授权后连接 A/B/T。 +- 在本机生成项目专用 Ed25519 Key,不覆盖现有 Key。 +- 安装公钥并验证三个独立新会话。 +- 备份 `sshd_config`,确认不会锁死后只调整 SSH 管理端口,不禁止 SSH 密钥登录。 +- 轮换当前密码,私密文件同步更新或改为只记录 Key。 +- 验收:新端口 Key 登录成功;sudo 可用;现有会话保留到新会话验证完成。 + +**S01 三机只读资产盘点** + +- 只读取 OS、内核、CPU、内存、磁盘、网卡、路由、时间、端口、服务、包版本。 +- 盘点 T 上现有 OpenSIPS 配置和测试能力,不修改。 +- 输出 `docs/inventory-A.md`、`inventory-B.md`、`inventory-T.md`。 +- 验收:报告包含差异、风险和安装前备份清单。 + +**S02 网络与安全组验收** + +- 验证 A/B/T 双向可达性和实际私网 RTT。 +- 确认 A 的 SIP 15060/UDP、RTP 端口段;B 的 443/TCP、私网 HEP 9060/UDP。 +- 不通过 SSH 直接修改云安全组;生成阿里云控制台操作清单供用户确认。 +- 验收:形成端口矩阵和最小暴露方案。 + +#### S03-S06:Server B 基础设施 + +**S03 Server B 基础系统初始化** + +- 更新系统、安装基础包、chrony、nftables、fail2ban。 +- 检查并初始化数据盘目录,不格式化未知数据盘。 +- 创建应用、录音和监控系统用户与目录权限。 +- 验收:重启后时间、挂载、权限和防火墙正常。 + +**S04 MySQL 与 Redis** + +- 固定版本安装并限制监听地址。 +- 创建业务库/用户,启用 MySQL binlog、Redis AOF/RDB。 +- 配置备份脚本和健康检查。 +- 验收:重启恢复、权限隔离、备份和恢复样例通过。 + +**S05 Node.js、Nginx 与 TLS 基线** + +- 安装固定 Node.js LTS 和 pnpm。 +- 建立 `/opt/lisglosips/releases`、`current` 和 systemd 模板。 +- 配置 Nginx 静态站点、API 代理、Range、限流和 HTTPS 预案。 +- 验收:占位健康页通过 Nginx 访问,应用端口不暴露公网。 + +**S06 HOMER 与基础监控** + +- 部署 Heplify-server/HOMER、Prometheus、Grafana、exporters。 +- HOMER 数据和业务 MySQL 分离。 +- 验收:B 私网 9060 接收测试 HEP;A/B 主机指标可见。 + +#### S07-S10:后端基础 + +**S07 后端 Monorepo 骨架** + +- 创建 NestJS/Fastify、Prisma、结构化日志、配置校验、健康检查和测试框架。 +- 建立 CI 命令:lint、typecheck、test、build。 +- 验收:本地和 B 均可构建;健康检查可用。 + +**S08 数据库 Schema 与迁移** + +- 实现本文第 10 章表结构、索引、Decimal、审计列和 Outbox。 +- 提供空库迁移、种子和回滚/恢复说明。 +- 验收:空库一键初始化;重复执行安全;Schema 测试通过。 + +**S09 登录认证基础** + +- 登录、刷新、退出、Argon2id、失败锁定、Token/Cookie 安全。 +- 验收:成功、失败、过期、刷新、注销和暴力尝试路径通过。 + +**S10 用户、角色、权限与审计** + +- 实现用户、角色、权限矩阵、内置角色保护和重置密码。 +- 服务端 RBAC 和审计拦截器必须先于业务 API 完成。 +- 验收:越权被拒绝;敏感字段脱敏;审计详情可查询。 + +#### S11-S17:运营业务 API + +**S11 客户管理**:客户 CRUD、启停、余额/授信读取。 +**S12 充值流水**:客户/供应商充值、事务、幂等、并发。 +**S13 客户网关**:IP/SIP 注册认证、启停、密码安全。 +**S14 网关策略与配置发布**:主被叫条件、优先级、Outbox、Redis 版本。 +**S15 供应商管理**:供应商 CRUD、余额、授信。 +**S16 落地网关**:认证、CPS、并发、禁呼、编码、号码转换、周期费率。 +**S17 落地线路组**:成员增删、优先级、并发汇总、引用校验。 + +每个任务必须同时完成:数据库迁移、领域校验、API、权限、审计、单元测试、集成测试和 OpenAPI,不允许先堆积未测试接口后统一补测。 + +#### S18-S21:通信栈和测试客户 + +**S18 Server A OpenSIPS 基线** + +- 先备份现状,再安装/固定 OpenSIPS 3.6.x。 +- 配置 15060、认证、防扫描、MI 本地绑定和模块基线。 +- 每次改动先执行语法检查,验证失败立即回滚。 + +**S19 RTPEngine 与录音 tmpfs** + +- 配置内核转发、RTP 端口段、3 GiB tmpfs 和 `.part -> .ready` 生命周期。 +- 验收:双向音频、NAT、录音、重启和 tmpfs 阈值。 + +**S20 Redis 热路径、HEP 与指标** + +- 实现 Lua 原子检查、版本化配置读取、CDR XADD、HEP 发送和指标采集。 +- 验收:Redis 超时、配置缺失、HEP 中断均有明确降级和日志。 + +**S21 Server T 客户模拟** + +- 在不破坏 T 现有 OpenSIPS 的前提下配置测试域、IP 认证和 SIP 注册账号。 +- 编写成功呼叫、忙、拒绝、超时、错误密码、超 CPS 测试脚本。 +- 验收:所有脚本可重复执行并输出 Call-ID。 + +#### S22-S26:异步闭环 + +**S22 CDR Redis Stream**:事件契约、Consumer Group、ACK、Pending、死信、幂等。 +**S23 最小计费**:Decimal 周期计费、客户费用、成本、余额扣减。 +**S24 录音搬运与播放**:私网拉取、哈希、原子移动、Range、权限和源文件删除保护。 +**S25 质检后端**:规则、稳定抽样、连续录音查询、质检保存和审计。 +**S26 Dashboard 聚合**:实时指标和预聚合趋势,避免扫描全量 CDR。 + +#### S27-S30:联调和上线 + +**S27 React 接入真实 API** + +- 按菜单逐页替换 Mock;每次只接入一个领域。 +- 增加加载、空状态、错误、权限隐藏和并发更新提示。 +- 待设计菜单继续保留标识,不接临时 API。 + +**S28 三机端到端联调** + +- T 发起 SIP -> A 鉴权/路由/媒体/录音 -> B CDR/费用/信令/质检。 +- 按 Call-ID 核对 OpenSIPS、Redis Stream、MySQL、HOMER 和录音文件。 + +**S29 性能、故障与安全测试** + +- 执行 CPS、并发、录音、Redis/MySQL/Worker 故障和 Web/SIP 安全测试。 +- 所有失败必须形成缺陷、修复和复测记录。 + +**S30 备份、Runbook、灰度与上线** + +- 完成恢复演练、上线/回滚脚本和灰度。 +- 上线前冻结版本和数据库迁移,输出最终验收报告。 + +### 25.5 Codex 新会话启动模板 + +```text +请先读取 SOFTSWITCH_PLATFORM_DESIGN_V2.md 和 IMPLEMENTATION_STATUS.md, +只执行当前任务 Sxx,不提前执行下一任务。 +如任务需要服务器访问,读取 .codex-private/SERVER_ACCESS.md,但不要在回复、日志或文档中回显密码。 +所有服务器写操作先备份并给出回滚点;涉及重启、SSH、防火墙、数据库迁移或删除时先说明影响。 +完成后执行验收并更新 IMPLEMENTATION_STATUS.md,包括状态、产物、验证、回滚、遗留问题和下一任务。 +``` diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..3e163b4 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,30 @@ +{ + "name": "@lisglosips/api", + "version": "0.2.0", + "private": true, + "type": "module", + "main": "dist/main.js", + "scripts": { + "dev": "tsx watch src/main.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/main.js" + }, + "dependencies": { + "@fastify/helmet": "13.0.2", + "@fastify/static": "8.3.0", + "@lisglosips/auth": "workspace:*", + "@lisglosips/contracts": "workspace:*", + "@lisglosips/database": "workspace:*", + "@lisglosips/observability": "workspace:*", + "@nestjs/common": "11.1.9", + "@nestjs/config": "4.0.2", + "@nestjs/core": "11.1.9", + "@nestjs/platform-fastify": "11.1.9", + "@nestjs/swagger": "11.2.3", + "fastify": "5.6.2", + "joi": "18.0.2", + "nestjs-pino": "4.5.0", + "reflect-metadata": "0.2.2", + "rxjs": "7.8.2" + } +} diff --git a/apps/api/src/app.e2e.spec.ts b/apps/api/src/app.e2e.spec.ts new file mode 100644 index 0000000..44ea5b2 --- /dev/null +++ b/apps/api/src/app.e2e.spec.ts @@ -0,0 +1,41 @@ +import 'reflect-metadata'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import request from 'supertest'; + +describe('LisgloSIPS API health', () => { + let app: NestFastifyApplication; + + beforeAll(async () => { + process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips'; + process.env.REDIS_URL = 'redis://127.0.0.1:6379/0'; + process.env.LISGLOSIPS_LOG_LEVEL = 'silent'; + + const { AppModule } = await import('./modules/app.module.js'); + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule] + }).compile(); + + app = moduleFixture.createNestApplication(new FastifyAdapter({ logger: false })); + app.setGlobalPrefix('api/v2'); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('serves liveness', async () => { + const response = await request(app.getHttpServer()).get('/api/v2/health/live').expect(200); + + expect(response.body.status).toBe('ok'); + }); + + it('serves readiness', async () => { + const response = await request(app.getHttpServer()).get('/api/v2/health/ready').expect(200); + + expect(response.body.status).toBe('ok'); + }); +}); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..c3c5222 --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,35 @@ +import 'reflect-metadata'; +import helmet from '@fastify/helmet'; +import { NestFactory } from '@nestjs/core'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import { Logger } from 'nestjs-pino'; +import { AppModule } from './modules/app.module.js'; +import { buildOpenApiDocument } from './openapi.js'; + +async function bootstrap(): Promise { + const app = await NestFactory.create( + AppModule, + new FastifyAdapter({ + trustProxy: true, + logger: false + }), + { + bufferLogs: true + } + ); + + app.useLogger(app.get(Logger)); + app.setGlobalPrefix('api/v2'); + await app.register(helmet, { + contentSecurityPolicy: false + }); + + buildOpenApiDocument(app); + + const host = process.env.LISGLOSIPS_HOST ?? '127.0.0.1'; + const port = Number(process.env.LISGLOSIPS_PORT ?? 3000); + + await app.listen({ host, port }); +} + +void bootstrap(); diff --git a/apps/api/src/modules/app.module.ts b/apps/api/src/modules/app.module.ts new file mode 100644 index 0000000..1b6e44a --- /dev/null +++ b/apps/api/src/modules/app.module.ts @@ -0,0 +1,74 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import crypto from 'node:crypto'; +import { LoggerModule } from 'nestjs-pino'; +import { API_PREFIX } from '@lisglosips/contracts'; +import { LOG_REDACT_PATHS } from '@lisglosips/observability'; +import { appConfig, validationSchema } from '../shared/config.js'; +import { AuditLogsModule } from './audit-logs/audit-logs.module.js'; +import { AuditModule } from './audit/audit.module.js'; +import { AuthModule } from './auth/auth.module.js'; +import { CustomerGatewayPoliciesModule } from './customer-gateway-policies/customer-gateway-policies.module.js'; +import { CustomerGatewaysModule } from './customer-gateways/customer-gateways.module.js'; +import { CustomersModule } from './customers/customers.module.js'; +import { DashboardModule } from './dashboard/dashboard.module.js'; +import { DatabaseModule } from './database/database.module.js'; +import { HealthModule } from './health/health.module.js'; +import { LandingLineGroupsModule } from './landing-line-groups/landing-line-groups.module.js'; +import { RechargesModule } from './recharges/recharges.module.js'; +import { RecordingsModule } from './recordings/recordings.module.js'; +import { QualityModule } from './quality/quality.module.js'; +import { RolesModule } from './roles/roles.module.js'; +import { SecurityModule } from './security/security.module.js'; +import { UsersModule } from './users/users.module.js'; +import { VendorGatewaysModule } from './vendor-gateways/vendor-gateways.module.js'; +import { VendorsModule } from './vendors/vendors.module.js'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + cache: true, + load: [appConfig], + validationSchema, + validationOptions: { + abortEarly: false, + allowUnknown: true + } + }), + LoggerModule.forRoot({ + pinoHttp: { + level: process.env.LISGLOSIPS_LOG_LEVEL ?? 'info', + name: process.env.LISGLOSIPS_SERVICE_NAME ?? 'api', + genReqId: (request) => + request.headers[process.env.LISGLOSIPS_REQUEST_ID_HEADER ?? 'x-request-id']?.toString() ?? crypto.randomUUID(), + redact: { + paths: LOG_REDACT_PATHS, + censor: '[REDACTED]' + }, + customProps: () => ({ + apiPrefix: API_PREFIX + }) + } + }), + DatabaseModule, + SecurityModule, + AuditModule, + AuthModule, + DashboardModule, + CustomersModule, + CustomerGatewaysModule, + CustomerGatewayPoliciesModule, + RechargesModule, + VendorsModule, + VendorGatewaysModule, + LandingLineGroupsModule, + QualityModule, + RecordingsModule, + UsersModule, + RolesModule, + AuditLogsModule, + HealthModule + ] +}) +export class AppModule {} diff --git a/apps/api/src/modules/audit-logs/audit-logs.controller.ts b/apps/api/src/modules/audit-logs/audit-logs.controller.ts new file mode 100644 index 0000000..7a89e0a --- /dev/null +++ b/apps/api/src/modules/audit-logs/audit-logs.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Inject, Param, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { RequirePermissions } from '../security/security.metadata.js'; +import { AuditLogsService } from './audit-logs.service.js'; + +@ApiTags('audit') +@Controller('audit-logs') +export class AuditLogsController { + constructor(@Inject(AuditLogsService) private readonly auditLogsService: AuditLogsService) {} + + @Get() + @RequirePermissions('audit.view') + list(@Query() query: Record) { + return this.auditLogsService.list(query); + } + + @Get(':id') + @RequirePermissions('audit.view') + get(@Param('id') id: string) { + return this.auditLogsService.get(id); + } +} diff --git a/apps/api/src/modules/audit-logs/audit-logs.module.ts b/apps/api/src/modules/audit-logs/audit-logs.module.ts new file mode 100644 index 0000000..daa8821 --- /dev/null +++ b/apps/api/src/modules/audit-logs/audit-logs.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { AuditLogsController } from './audit-logs.controller.js'; +import { PrismaAuditLogsRepository, AUDIT_LOGS_REPOSITORY } from './audit-logs.repository.js'; +import { AuditLogsService } from './audit-logs.service.js'; + +@Module({ + controllers: [AuditLogsController], + providers: [ + AuditLogsService, + PrismaAuditLogsRepository, + { + provide: AUDIT_LOGS_REPOSITORY, + useExisting: PrismaAuditLogsRepository + } + ] +}) +export class AuditLogsModule {} diff --git a/apps/api/src/modules/audit-logs/audit-logs.repository.ts b/apps/api/src/modules/audit-logs/audit-logs.repository.ts new file mode 100644 index 0000000..e3d615c --- /dev/null +++ b/apps/api/src/modules/audit-logs/audit-logs.repository.ts @@ -0,0 +1,80 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service.js'; + +export interface AuditLogQuery { + module?: string; + action?: string; + userId?: string; + objectType?: string; + objectId?: string; + result?: 'SUCCESS' | 'FAILURE'; + take: number; + skip: number; +} + +export interface AuditLogSummary { + id: string; + requestId: string; + userId: string | null; + username: string | null; + roleNames: string | null; + ip: string | null; + userAgent: string | null; + module: string; + action: string; + objectType: string; + objectId: string | null; + result: 'SUCCESS' | 'FAILURE'; + errorCode: string | null; + createdAt: Date; +} + +export interface AuditLogDetail extends AuditLogSummary { + beforeSummary: unknown; + afterSummary: unknown; +} + +export interface AuditLogsRepository { + list(query: AuditLogQuery): Promise<{ items: AuditLogSummary[]; total: number }>; + get(id: string): Promise; +} + +export const AUDIT_LOGS_REPOSITORY = Symbol('AUDIT_LOGS_REPOSITORY'); + +@Injectable() +export class PrismaAuditLogsRepository implements AuditLogsRepository { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async list(query: AuditLogQuery): Promise<{ items: AuditLogSummary[]; total: number }> { + const where = { + module: query.module, + action: query.action, + userId: query.userId, + objectType: query.objectType, + objectId: query.objectId, + result: query.result + }; + + const [items, total] = await this.prisma.$transaction([ + this.prisma.auditLog.findMany({ + where, + orderBy: [{ createdAt: 'desc' }], + take: query.take, + skip: query.skip + }), + this.prisma.auditLog.count({ where }) + ]); + + return { items, total }; + } + + async get(id: string): Promise { + const auditLog = await this.prisma.auditLog.findUnique({ where: { id } }); + + if (!auditLog) { + throw new NotFoundException({ code: 'AUDIT_LOG_NOT_FOUND', message: 'Audit log not found.' }); + } + + return auditLog; + } +} diff --git a/apps/api/src/modules/audit-logs/audit-logs.service.ts b/apps/api/src/modules/audit-logs/audit-logs.service.ts new file mode 100644 index 0000000..cf78662 --- /dev/null +++ b/apps/api/src/modules/audit-logs/audit-logs.service.ts @@ -0,0 +1,59 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { AUDIT_LOGS_REPOSITORY, type AuditLogQuery, type AuditLogsRepository } from './audit-logs.repository.js'; + +@Injectable() +export class AuditLogsService { + constructor(@Inject(AUDIT_LOGS_REPOSITORY) private readonly auditLogs: AuditLogsRepository) {} + + list(rawQuery: Record) { + const query: AuditLogQuery = { + module: this.optionalString(rawQuery.module), + action: this.optionalString(rawQuery.action), + userId: this.optionalString(rawQuery.userId), + objectType: this.optionalString(rawQuery.objectType), + objectId: this.optionalString(rawQuery.objectId), + result: rawQuery.result === undefined ? undefined : this.result(rawQuery.result), + take: this.positiveInt(rawQuery.take, 50, 100), + skip: this.positiveInt(rawQuery.skip, 0, 10_000) + }; + + return this.auditLogs.list(query); + } + + get(id: string) { + return this.auditLogs.get(id); + } + + private optionalString(value: unknown): string | undefined { + if (value === undefined) { + return undefined; + } + + if (typeof value !== 'string' || value.trim().length === 0) { + throw new BadRequestException({ code: 'QUERY_INVALID', message: 'Query parameter is invalid.' }); + } + + return value.trim(); + } + + private result(value: unknown): 'SUCCESS' | 'FAILURE' { + if (value !== 'SUCCESS' && value !== 'FAILURE') { + throw new BadRequestException({ code: 'AUDIT_RESULT_INVALID', message: 'Audit result is invalid.' }); + } + + return value; + } + + private positiveInt(value: unknown, defaultValue: number, max: number): number { + if (value === undefined) { + return defaultValue; + } + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > max) { + throw new BadRequestException({ code: 'QUERY_INVALID', message: 'Query parameter is invalid.' }); + } + + return parsed; + } +} diff --git a/apps/api/src/modules/audit/audit.interceptor.ts b/apps/api/src/modules/audit/audit.interceptor.ts new file mode 100644 index 0000000..7a93c05 --- /dev/null +++ b/apps/api/src/modules/audit/audit.interceptor.ts @@ -0,0 +1,81 @@ +import { CallHandler, ExecutionContext, Inject, Injectable, NestInterceptor } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { FastifyRequest } from 'fastify'; +import { catchError, from, map, mergeMap, Observable, of, throwError } from 'rxjs'; +import { AUDIT_METADATA_KEY, type AuditMetadata } from './audit.metadata.js'; +import { AuditService } from './audit.service.js'; +import { redactSensitive } from './redact.js'; + +@Injectable() +export class AuditInterceptor implements NestInterceptor { + constructor( + @Inject(Reflector) private readonly reflector: Reflector, + @Inject(AuditService) private readonly auditService: AuditService + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const metadata = this.reflector.getAllAndOverride(AUDIT_METADATA_KEY, [context.getHandler(), context.getClass()]); + if (!metadata) { + return next.handle(); + } + + const request = context.switchToHttp().getRequest(); + const params = request.params as Record | undefined; + const objectId = metadata.objectIdParam ? params?.[metadata.objectIdParam] : undefined; + const beforeSummary = { + method: request.method, + url: request.url, + params: request.params, + query: request.query, + body: request.body + }; + + return next.handle().pipe( + mergeMap((responseBody) => + from( + this.auditService.write({ + ...this.auditService.baseFromRequest(request), + module: metadata.module, + action: metadata.action, + objectType: metadata.objectType, + objectId, + beforeSummary, + afterSummary: redactSensitive(responseBody), + result: 'SUCCESS' + }) + ).pipe( + catchError(() => of(undefined)), + map(() => responseBody) + ) + ), + catchError((error: unknown) => { + const writeFailure = this.auditService.write({ + ...this.auditService.baseFromRequest(request), + module: metadata.module, + action: metadata.action, + objectType: metadata.objectType, + objectId, + beforeSummary, + result: 'FAILURE', + errorCode: this.errorCode(error) + }); + + return from(writeFailure).pipe( + catchError(() => of(undefined)), + mergeMap(() => throwError(() => error)) + ); + }) + ); + } + + private errorCode(error: unknown): string { + if (error && typeof error === 'object' && 'response' in error) { + const response = (error as { response?: unknown }).response; + if (response && typeof response === 'object' && 'code' in response) { + return String((response as { code: unknown }).code); + } + } + + return error instanceof Error ? error.name : 'UNKNOWN_ERROR'; + } +} diff --git a/apps/api/src/modules/audit/audit.metadata.ts b/apps/api/src/modules/audit/audit.metadata.ts new file mode 100644 index 0000000..09e9201 --- /dev/null +++ b/apps/api/src/modules/audit/audit.metadata.ts @@ -0,0 +1,12 @@ +import { SetMetadata } from '@nestjs/common'; + +export const AUDIT_METADATA_KEY = 'lisglosips:audit'; + +export interface AuditMetadata { + module: string; + action: string; + objectType: string; + objectIdParam?: string; +} + +export const AuditAction = (metadata: AuditMetadata) => SetMetadata(AUDIT_METADATA_KEY, metadata); diff --git a/apps/api/src/modules/audit/audit.module.ts b/apps/api/src/modules/audit/audit.module.ts new file mode 100644 index 0000000..d80929b --- /dev/null +++ b/apps/api/src/modules/audit/audit.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { AuditInterceptor } from './audit.interceptor.js'; +import { PrismaAuditRepository, AUDIT_REPOSITORY } from './audit.repository.js'; +import { AuditService } from './audit.service.js'; + +@Module({ + providers: [ + AuditService, + PrismaAuditRepository, + { + provide: AUDIT_REPOSITORY, + useExisting: PrismaAuditRepository + }, + { + provide: APP_INTERCEPTOR, + useClass: AuditInterceptor + } + ], + exports: [AuditService, AUDIT_REPOSITORY] +}) +export class AuditModule {} diff --git a/apps/api/src/modules/audit/audit.repository.ts b/apps/api/src/modules/audit/audit.repository.ts new file mode 100644 index 0000000..520e7d3 --- /dev/null +++ b/apps/api/src/modules/audit/audit.repository.ts @@ -0,0 +1,70 @@ +import { Inject, Injectable } from '@nestjs/common'; +import crypto from 'node:crypto'; +import { Prisma } from '@lisglosips/database'; +import { PrismaService } from '../database/prisma.service.js'; + +export interface AuditEntryInput { + requestId: string; + userId?: string; + username?: string; + roleNames?: string; + ip?: string; + userAgent?: string; + module: string; + action: string; + objectType: string; + objectId?: string; + beforeSummary?: unknown; + afterSummary?: unknown; + result: 'SUCCESS' | 'FAILURE'; + errorCode?: string; +} + +export interface AuditRepository { + write(input: AuditEntryInput): Promise; +} + +export const AUDIT_REPOSITORY = Symbol('AUDIT_REPOSITORY'); + +function auditId(): string { + return `aud_${crypto.randomUUID().replaceAll('-', '')}`; +} + +@Injectable() +export class PrismaAuditRepository implements AuditRepository { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async write(input: AuditEntryInput): Promise { + await this.prisma.auditLog.create({ + data: { + id: auditId(), + requestId: input.requestId, + userId: input.userId, + username: input.username, + roleNames: input.roleNames, + ip: input.ip, + userAgent: input.userAgent, + module: input.module, + action: input.action, + objectType: input.objectType, + objectId: input.objectId, + beforeSummary: this.json(input.beforeSummary), + afterSummary: this.json(input.afterSummary), + result: input.result, + errorCode: input.errorCode + } + }); + } + + private json(value: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull | undefined { + if (value === undefined) { + return undefined; + } + + if (value === null) { + return Prisma.JsonNull; + } + + return value as Prisma.InputJsonValue; + } +} diff --git a/apps/api/src/modules/audit/audit.service.ts b/apps/api/src/modules/audit/audit.service.ts new file mode 100644 index 0000000..411af9b --- /dev/null +++ b/apps/api/src/modules/audit/audit.service.ts @@ -0,0 +1,32 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { FastifyRequest } from 'fastify'; +import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from './audit.repository.js'; +import { redactSensitive } from './redact.js'; +import type { AuthenticatedRequest } from '../security/security.metadata.js'; + +@Injectable() +export class AuditService { + constructor(@Inject(AUDIT_REPOSITORY) private readonly repository: AuditRepository) {} + + async write(input: AuditEntryInput): Promise { + await this.repository.write({ + ...input, + beforeSummary: redactSensitive(input.beforeSummary), + afterSummary: redactSensitive(input.afterSummary) + }); + } + + baseFromRequest(request: FastifyRequest): Pick { + const authRequest = request as AuthenticatedRequest; + const requestIdHeader = request.headers['x-request-id']; + + return { + requestId: requestIdHeader?.toString() ?? request.id, + userId: authRequest.currentUser?.id, + username: authRequest.currentUser?.username, + roleNames: authRequest.currentUser?.roles.join(','), + ip: request.ip, + userAgent: request.headers['user-agent'] + }; + } +} diff --git a/apps/api/src/modules/audit/redact.ts b/apps/api/src/modules/audit/redact.ts new file mode 100644 index 0000000..0e5c6c3 --- /dev/null +++ b/apps/api/src/modules/audit/redact.ts @@ -0,0 +1,23 @@ +const SENSITIVE_KEY_PATTERN = /password|token|authorization|cookie|secret|ha1|hash/i; + +export function redactSensitive(value: unknown, depth = 0): unknown { + if (depth > 5) { + return '[TRUNCATED]'; + } + + if (Array.isArray(value)) { + return value.map((item) => redactSensitive(item, depth + 1)); + } + + if (value && typeof value === 'object') { + const result: Record = {}; + + for (const [key, nested] of Object.entries(value as Record)) { + result[key] = SENSITIVE_KEY_PATTERN.test(key) ? '[REDACTED]' : redactSensitive(nested, depth + 1); + } + + return result; + } + + return value; +} diff --git a/apps/api/src/modules/auth/auth.controller.ts b/apps/api/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..8198d85 --- /dev/null +++ b/apps/api/src/modules/auth/auth.controller.ts @@ -0,0 +1,93 @@ +import { Body, Controller, HttpCode, Inject, Post, Req, Res, UnauthorizedException } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { Public } from '../security/security.metadata.js'; +import { AuthService } from './auth.service.js'; +import { parseCookie, serializeCookie } from './cookie.js'; + +interface LoginBody { + username?: unknown; + password?: unknown; +} + +@ApiTags('auth') +@Public() +@Controller('auth') +export class AuthController { + constructor(@Inject(AuthService) private readonly authService: AuthService) {} + + @Post('login') + @HttpCode(200) + @ApiOperation({ summary: 'Login with username and password' }) + async login(@Body() body: LoginBody, @Req() request: FastifyRequest, @Res({ passthrough: true }) reply: FastifyReply) { + const { username, password } = this.readCredentials(body); + const result = await this.authService.login(username, password, this.requestContext(request)); + + this.setRefreshCookie(reply, result.tokens.refreshToken, result.tokens.refreshMaxAgeSeconds); + + return result.response; + } + + @Post('refresh') + @HttpCode(200) + @ApiOperation({ summary: 'Rotate refresh token and issue a new access token' }) + async refresh(@Req() request: FastifyRequest, @Res({ passthrough: true }) reply: FastifyReply) { + const refreshToken = parseCookie(request.headers.cookie, this.authService.getRefreshCookieName()); + const result = await this.authService.refresh(refreshToken, this.requestContext(request)); + + this.setRefreshCookie(reply, result.tokens.refreshToken, result.tokens.refreshMaxAgeSeconds); + + return result.response; + } + + @Post('logout') + @HttpCode(204) + @ApiOperation({ summary: 'Revoke the current refresh session' }) + async logout(@Req() request: FastifyRequest, @Res({ passthrough: true }) reply: FastifyReply): Promise { + const refreshToken = parseCookie(request.headers.cookie, this.authService.getRefreshCookieName()); + + await this.authService.logout(refreshToken); + reply.header( + 'Set-Cookie', + serializeCookie(this.authService.getRefreshCookieName(), '', { + maxAgeSeconds: 0, + secure: this.cookieSecure() + }) + ); + } + + private readCredentials(body: LoginBody): { username: string; password: string } { + if (typeof body.username !== 'string' || typeof body.password !== 'string' || !body.username.trim() || !body.password) { + throw new UnauthorizedException({ + code: 'AUTH_INVALID_CREDENTIALS', + message: 'Invalid username or password.' + }); + } + + return { + username: body.username, + password: body.password + }; + } + + private requestContext(request: FastifyRequest): { ip?: string; userAgent?: string } { + return { + ip: request.ip, + userAgent: request.headers['user-agent'] + }; + } + + private setRefreshCookie(reply: FastifyReply, refreshToken: string, maxAgeSeconds: number): void { + reply.header( + 'Set-Cookie', + serializeCookie(this.authService.getRefreshCookieName(), refreshToken, { + maxAgeSeconds, + secure: this.cookieSecure() + }) + ); + } + + private cookieSecure(): boolean { + return process.env.AUTH_COOKIE_SECURE !== 'false'; + } +} diff --git a/apps/api/src/modules/auth/auth.e2e.spec.ts b/apps/api/src/modules/auth/auth.e2e.spec.ts new file mode 100644 index 0000000..c402fce --- /dev/null +++ b/apps/api/src/modules/auth/auth.e2e.spec.ts @@ -0,0 +1,132 @@ +import 'reflect-metadata'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import crypto from 'node:crypto'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import request from 'supertest'; +import { hashPasswordArgon2id, sha256Token } from '@lisglosips/auth'; +import { AUTH_REPOSITORY, type AuthRepository, type AuthSessionRecord, type AuthUserRecord, type CreateSessionInput } from './auth.types.js'; + +class E2eAuthRepository implements AuthRepository { + user: AuthUserRecord; + sessions = new Map(); + + private constructor(passwordHash: string) { + this.user = { + id: 'usr_e2e', + username: 'operator', + displayName: 'Operator', + passwordHash, + passwordAlgo: 'argon2id', + status: 'ENABLED', + failedLoginCount: 0, + lockedUntil: null, + requirePasswordChange: false, + roles: ['admin'] + }; + } + + static async create(secret: string): Promise { + return new E2eAuthRepository(await hashPasswordArgon2id(secret, { memoryKiB: 1024, passes: 1 })); + } + + async findUserByUsername(username: string): Promise { + return username === this.user.username ? { ...this.user, roles: [...this.user.roles] } : null; + } + + async markLoginSuccess(): Promise { + this.user.failedLoginCount = 0; + this.user.lockedUntil = null; + } + + async markLoginFailure(_userId: string, failedLoginCount: number, lockedUntil: Date | null): Promise { + this.user.failedLoginCount = failedLoginCount; + this.user.lockedUntil = lockedUntil; + } + + async createSession(input: CreateSessionInput): Promise { + const session: AuthSessionRecord = { + id: `ses_e2e_${this.sessions.size + 1}`, + userId: input.userId, + refreshTokenHash: input.refreshTokenHash, + expiresAt: input.expiresAt, + revokedAt: null, + user: { ...this.user, roles: [...this.user.roles] } + }; + + this.sessions.set(session.id, session); + return session; + } + + async findActiveSessionByRefreshTokenHash(refreshTokenHash: string, now: Date): Promise { + return ( + [...this.sessions.values()].find( + (session) => session.refreshTokenHash === refreshTokenHash && !session.revokedAt && session.expiresAt > now + ) ?? null + ); + } + + async revokeSession(sessionId: string, revokedAt: Date): Promise { + const session = this.sessions.get(sessionId); + if (session) { + session.revokedAt = revokedAt; + } + } +} + +describe('LisgloSIPS Auth API', () => { + let app: NestFastifyApplication; + let repo: E2eAuthRepository; + let secret: string; + + beforeAll(async () => { + process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips'; + process.env.REDIS_URL = 'redis://127.0.0.1:6379/0'; + process.env.LISGLOSIPS_LOG_LEVEL = 'silent'; + process.env.AUTH_COOKIE_SECURE = 'false'; + process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes'; + + secret = crypto.randomUUID(); + repo = await E2eAuthRepository.create(secret); + + const { AppModule } = await import('../app.module.js'); + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule] + }) + .overrideProvider(AUTH_REPOSITORY) + .useValue(repo) + .compile(); + + app = moduleFixture.createNestApplication(new FastifyAdapter({ logger: false })); + app.setGlobalPrefix('api/v2'); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('logs in, refreshes with cookie rotation, and logs out', async () => { + const login = await request(app.getHttpServer()) + .post('/api/v2/auth/login') + .send({ username: 'operator', password: secret }) + .expect(200); + const loginCookie = login.headers['set-cookie'][0]; + const firstRefreshToken = /lisglosips_refresh=([^;]+)/.exec(loginCookie)?.[1] ?? ''; + + expect(login.body.accessToken).toBeTypeOf('string'); + expect(loginCookie).toContain('HttpOnly'); + expect([...repo.sessions.values()][0].refreshTokenHash).toBe(sha256Token(decodeURIComponent(firstRefreshToken))); + + const refresh = await request(app.getHttpServer()).post('/api/v2/auth/refresh').set('Cookie', loginCookie).expect(200); + const refreshCookie = refresh.headers['set-cookie'][0]; + + expect(refresh.body.accessToken).toBeTypeOf('string'); + expect([...repo.sessions.values()][0].revokedAt).toBeInstanceOf(Date); + await request(app.getHttpServer()).post('/api/v2/auth/refresh').set('Cookie', loginCookie).expect(401); + + await request(app.getHttpServer()).post('/api/v2/auth/logout').set('Cookie', refreshCookie).expect(204); + await request(app.getHttpServer()).post('/api/v2/auth/refresh').set('Cookie', refreshCookie).expect(401); + }); +}); diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..bec72b1 --- /dev/null +++ b/apps/api/src/modules/auth/auth.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { AuthController } from './auth.controller.js'; +import { PrismaAuthRepository } from './auth.repository.js'; +import { AuthService } from './auth.service.js'; +import { AUTH_REPOSITORY } from './auth.types.js'; + +@Module({ + controllers: [AuthController], + providers: [ + AuthService, + PrismaAuthRepository, + { + provide: AUTH_REPOSITORY, + useExisting: PrismaAuthRepository + } + ], + exports: [AuthService] +}) +export class AuthModule {} diff --git a/apps/api/src/modules/auth/auth.repository.ts b/apps/api/src/modules/auth/auth.repository.ts new file mode 100644 index 0000000..d18429c --- /dev/null +++ b/apps/api/src/modules/auth/auth.repository.ts @@ -0,0 +1,163 @@ +import { Inject, Injectable } from '@nestjs/common'; +import crypto from 'node:crypto'; +import { PrismaService } from '../database/prisma.service.js'; +import type { AuthRepository, AuthSessionRecord, AuthUserRecord, CreateSessionInput } from './auth.types.js'; + +function sessionId(): string { + return `ses_${crypto.randomUUID().replaceAll('-', '')}`; +} + +@Injectable() +export class PrismaAuthRepository implements AuthRepository { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async findUserByUsername(username: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { username }, + include: { + userRoles: { + include: { + role: true + } + } + } + }); + + if (!user || user.deletedAt) { + return null; + } + + return { + id: user.id, + username: user.username, + displayName: user.displayName, + passwordHash: user.passwordHash, + passwordAlgo: user.passwordAlgo, + status: user.status, + failedLoginCount: user.failedLoginCount, + lockedUntil: user.lockedUntil, + requirePasswordChange: user.requirePasswordChange, + roles: user.userRoles.map((userRole) => userRole.role.name) + }; + } + + async markLoginSuccess(userId: string, ip?: string): Promise { + await this.prisma.user.update({ + where: { id: userId }, + data: { + failedLoginCount: 0, + lockedUntil: null, + lastLoginAt: new Date(), + lastLoginIp: ip + } + }); + } + + async markLoginFailure(userId: string, failedLoginCount: number, lockedUntil: Date | null): Promise { + await this.prisma.user.update({ + where: { id: userId }, + data: { + failedLoginCount, + lockedUntil + } + }); + } + + async createSession(input: CreateSessionInput): Promise { + const session = await this.prisma.userSession.create({ + data: { + id: sessionId(), + userId: input.userId, + refreshTokenHash: input.refreshTokenHash, + userAgent: input.userAgent, + ip: input.ip, + expiresAt: input.expiresAt, + rotatedFromId: input.rotatedFromId + }, + include: { + user: { + include: { + userRoles: { + include: { + role: true + } + } + } + } + } + }); + + return { + id: session.id, + userId: session.userId, + refreshTokenHash: session.refreshTokenHash, + expiresAt: session.expiresAt, + revokedAt: session.revokedAt, + user: { + id: session.user.id, + username: session.user.username, + displayName: session.user.displayName, + passwordHash: session.user.passwordHash, + passwordAlgo: session.user.passwordAlgo, + status: session.user.status, + failedLoginCount: session.user.failedLoginCount, + lockedUntil: session.user.lockedUntil, + requirePasswordChange: session.user.requirePasswordChange, + roles: session.user.userRoles.map((userRole) => userRole.role.name) + } + }; + } + + async findActiveSessionByRefreshTokenHash(refreshTokenHash: string, now: Date): Promise { + const session = await this.prisma.userSession.findUnique({ + where: { refreshTokenHash }, + include: { + user: { + include: { + userRoles: { + include: { + role: true + } + } + } + } + } + }); + + if (!session || session.revokedAt || session.expiresAt <= now || session.user.deletedAt) { + return null; + } + + return { + id: session.id, + userId: session.userId, + refreshTokenHash: session.refreshTokenHash, + expiresAt: session.expiresAt, + revokedAt: session.revokedAt, + user: { + id: session.user.id, + username: session.user.username, + displayName: session.user.displayName, + passwordHash: session.user.passwordHash, + passwordAlgo: session.user.passwordAlgo, + status: session.user.status, + failedLoginCount: session.user.failedLoginCount, + lockedUntil: session.user.lockedUntil, + requirePasswordChange: session.user.requirePasswordChange, + roles: session.user.userRoles.map((userRole) => userRole.role.name) + } + }; + } + + async revokeSession(sessionId: string, revokedAt: Date): Promise { + await this.prisma.userSession.updateMany({ + where: { + id: sessionId, + revokedAt: null + }, + data: { + revokedAt + } + }); + } +} diff --git a/apps/api/src/modules/auth/auth.service.spec.ts b/apps/api/src/modules/auth/auth.service.spec.ts new file mode 100644 index 0000000..1b63c0e --- /dev/null +++ b/apps/api/src/modules/auth/auth.service.spec.ts @@ -0,0 +1,145 @@ +import 'reflect-metadata'; +import { beforeEach, describe, expect, it } from 'vitest'; +import crypto from 'node:crypto'; +import { hashPasswordArgon2id, sha256Token } from '@lisglosips/auth'; +import { AuthService } from './auth.service.js'; +import type { AuthRepository, AuthSessionRecord, AuthUserRecord, CreateSessionInput } from './auth.types.js'; + +class MemoryAuthRepository implements AuthRepository { + user: AuthUserRecord; + sessions = new Map(); + + private constructor(passwordHash: string) { + this.user = { + id: 'usr_test', + username: 'operator', + displayName: 'Operator', + passwordHash, + passwordAlgo: 'argon2id', + status: 'ENABLED', + failedLoginCount: 0, + lockedUntil: null, + requirePasswordChange: false, + roles: ['admin'] + }; + } + + static async create(secret: string): Promise { + return new MemoryAuthRepository( + await hashPasswordArgon2id(secret, { + memoryKiB: 1024, + passes: 1 + }) + ); + } + + async findUserByUsername(username: string): Promise { + return username === this.user.username ? { ...this.user, roles: [...this.user.roles] } : null; + } + + async markLoginSuccess(_userId: string, _ip?: string): Promise { + this.user.failedLoginCount = 0; + this.user.lockedUntil = null; + } + + async markLoginFailure(_userId: string, failedLoginCount: number, lockedUntil: Date | null): Promise { + this.user.failedLoginCount = failedLoginCount; + this.user.lockedUntil = lockedUntil; + } + + async createSession(input: CreateSessionInput): Promise { + const session: AuthSessionRecord = { + id: `ses_${this.sessions.size + 1}`, + userId: input.userId, + refreshTokenHash: input.refreshTokenHash, + expiresAt: input.expiresAt, + revokedAt: null, + user: { ...this.user, roles: [...this.user.roles] } + }; + + this.sessions.set(session.id, session); + return session; + } + + async findActiveSessionByRefreshTokenHash(refreshTokenHash: string, now: Date): Promise { + return ( + [...this.sessions.values()].find( + (session) => session.refreshTokenHash === refreshTokenHash && !session.revokedAt && session.expiresAt > now + ) ?? null + ); + } + + async revokeSession(sessionId: string, revokedAt: Date): Promise { + const session = this.sessions.get(sessionId); + if (session) { + session.revokedAt = revokedAt; + } + } +} + +function config() { + const values = new Map([ + ['auth.accessTokenSecret', 'test-only-access-token-secret-min-32-bytes'], + ['auth.accessTokenTtlSeconds', 900], + ['auth.refreshTokenTtlDays', 7], + ['auth.lockMaxFailures', 3], + ['auth.lockWindowSeconds', 60], + ['auth.tokenIssuer', 'lisglosips-api'], + ['auth.tokenAudience', 'lisglosips-web'] + ]); + + return { + get: (key: string) => values.get(key) + }; +} + +describe('AuthService', () => { + let repo: MemoryAuthRepository; + let service: AuthService; + let secret: string; + + beforeEach(async () => { + secret = crypto.randomUUID(); + repo = await MemoryAuthRepository.create(secret); + service = new AuthService(repo, config() as never); + }); + + it('logs in and stores only the refresh token digest', async () => { + const result = await service.login(' OPERATOR ', secret, { ip: '127.0.0.1', userAgent: 'vitest' }); + const session = [...repo.sessions.values()][0]; + + expect(result.response.user.username).toBe('operator'); + expect(result.response.accessToken.split('.')).toHaveLength(3); + expect(session.refreshTokenHash).toBe(sha256Token(result.tokens.refreshToken)); + expect(session.refreshTokenHash).not.toBe(result.tokens.refreshToken); + }); + + it('locks the user after repeated failures', async () => { + await expect(service.login('operator', crypto.randomUUID())).rejects.toMatchObject({ status: 401 }); + await expect(service.login('operator', crypto.randomUUID())).rejects.toMatchObject({ status: 401 }); + await expect(service.login('operator', crypto.randomUUID())).rejects.toMatchObject({ status: 401 }); + + expect(repo.user.failedLoginCount).toBe(3); + expect(repo.user.lockedUntil).toBeInstanceOf(Date); + await expect(service.login('operator', secret)).rejects.toMatchObject({ status: 401 }); + }); + + it('rotates refresh sessions and rejects reuse', async () => { + const login = await service.login('operator', secret); + const refresh = await service.refresh(login.tokens.refreshToken); + const [firstSession, secondSession] = [...repo.sessions.values()]; + + expect(firstSession.revokedAt).toBeInstanceOf(Date); + expect(secondSession.refreshTokenHash).toBe(sha256Token(refresh.tokens.refreshToken)); + await expect(service.refresh(login.tokens.refreshToken)).rejects.toMatchObject({ status: 401 }); + }); + + it('revokes refresh session on logout', async () => { + const login = await service.login('operator', secret); + + await service.logout(login.tokens.refreshToken); + + expect([...repo.sessions.values()][0].revokedAt).toBeInstanceOf(Date); + await expect(service.refresh(login.tokens.refreshToken)).rejects.toMatchObject({ status: 401 }); + }); +}); diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..322b8ca --- /dev/null +++ b/apps/api/src/modules/auth/auth.service.ts @@ -0,0 +1,184 @@ +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + ACCESS_TOKEN_TYPE, + PASSWORD_ALGO_ARGON2ID, + REFRESH_COOKIE_NAME, + randomToken, + sha256Token, + signAccessToken, + verifyPasswordArgon2id +} from '@lisglosips/auth'; +import type { RuntimeConfig } from '../../shared/config.js'; +import { AUTH_REPOSITORY, type AuthRepository, type AuthUserRecord } from './auth.types.js'; + +export interface AuthRequestContext { + ip?: string; + userAgent?: string; +} + +export interface AuthTokens { + accessToken: string; + refreshToken: string; + refreshMaxAgeSeconds: number; +} + +export interface AuthResponse { + tokenType: typeof ACCESS_TOKEN_TYPE; + accessToken: string; + expiresIn: number; + user: { + id: string; + username: string; + displayName: string; + roles: string[]; + requirePasswordChange: boolean; + }; +} + +@Injectable() +export class AuthService { + constructor( + @Inject(AUTH_REPOSITORY) private readonly repository: AuthRepository, + @Inject(ConfigService) private readonly config: ConfigService + ) {} + + async login(username: string, password: string, context: AuthRequestContext = {}): Promise<{ response: AuthResponse; tokens: AuthTokens }> { + const normalizedUsername = username.trim().toLowerCase(); + const user = await this.repository.findUserByUsername(normalizedUsername); + const now = new Date(); + + if (!user) { + throw this.invalidCredentials(); + } + + if (this.isLocked(user, now) || user.status !== 'ENABLED' || !user.passwordHash || user.passwordAlgo !== PASSWORD_ALGO_ARGON2ID) { + await this.registerFailure(user, now); + throw this.invalidCredentials(); + } + + if (!(await verifyPasswordArgon2id(password, user.passwordHash))) { + await this.registerFailure(user, now); + throw this.invalidCredentials(); + } + + await this.repository.markLoginSuccess(user.id, context.ip); + const tokens = await this.issueTokens(user, context); + + return { + response: this.buildResponse(user, tokens.accessToken), + tokens + }; + } + + async refresh(refreshToken: string | null, context: AuthRequestContext = {}): Promise<{ response: AuthResponse; tokens: AuthTokens }> { + if (!refreshToken) { + throw this.invalidCredentials(); + } + + const now = new Date(); + const session = await this.repository.findActiveSessionByRefreshTokenHash(sha256Token(refreshToken), now); + + if (!session || session.user.status !== 'ENABLED') { + throw this.invalidCredentials(); + } + + await this.repository.revokeSession(session.id, now); + const tokens = await this.issueTokens(session.user, context, session.id); + + return { + response: this.buildResponse(session.user, tokens.accessToken), + tokens + }; + } + + async logout(refreshToken: string | null): Promise { + if (!refreshToken) { + return; + } + + const now = new Date(); + const session = await this.repository.findActiveSessionByRefreshTokenHash(sha256Token(refreshToken), now); + + if (session) { + await this.repository.revokeSession(session.id, now); + } + } + + getRefreshCookieName(): string { + return REFRESH_COOKIE_NAME; + } + + private async issueTokens(user: AuthUserRecord, context: AuthRequestContext, rotatedFromId?: string): Promise { + const accessTtlSeconds = this.config.get('auth.accessTokenTtlSeconds', { infer: true }); + const refreshTtlDays = this.config.get('auth.refreshTokenTtlDays', { infer: true }); + const refreshMaxAgeSeconds = refreshTtlDays * 24 * 60 * 60; + const refreshToken = randomToken(); + const expiresAt = new Date(Date.now() + refreshMaxAgeSeconds * 1000); + + await this.repository.createSession({ + userId: user.id, + refreshTokenHash: sha256Token(refreshToken), + userAgent: context.userAgent, + ip: context.ip, + expiresAt, + rotatedFromId + }); + + const accessToken = signAccessToken( + { + sub: user.id, + username: user.username, + roles: user.roles, + typ: 'access' + }, + { + secret: this.config.get('auth.accessTokenSecret', { infer: true }), + issuer: this.config.get('auth.tokenIssuer', { infer: true }), + audience: this.config.get('auth.tokenAudience', { infer: true }), + ttlSeconds: accessTtlSeconds + } + ); + + return { + accessToken, + refreshToken, + refreshMaxAgeSeconds + }; + } + + private buildResponse(user: AuthUserRecord, accessToken: string): AuthResponse { + return { + tokenType: ACCESS_TOKEN_TYPE, + accessToken, + expiresIn: this.config.get('auth.accessTokenTtlSeconds', { infer: true }), + user: { + id: user.id, + username: user.username, + displayName: user.displayName, + roles: user.roles, + requirePasswordChange: user.requirePasswordChange + } + }; + } + + private async registerFailure(user: AuthUserRecord, now: Date): Promise { + const maxFailures = this.config.get('auth.lockMaxFailures', { infer: true }); + const lockWindowSeconds = this.config.get('auth.lockWindowSeconds', { infer: true }); + const failedLoginCount = user.failedLoginCount + 1; + const lockedUntil = failedLoginCount >= maxFailures ? new Date(now.getTime() + lockWindowSeconds * 1000) : null; + + await this.repository.markLoginFailure(user.id, failedLoginCount, lockedUntil); + } + + private isLocked(user: AuthUserRecord, now: Date): boolean { + return Boolean(user.lockedUntil && user.lockedUntil > now); + } + + private invalidCredentials(): UnauthorizedException { + return new UnauthorizedException({ + code: 'AUTH_INVALID_CREDENTIALS', + message: 'Invalid username or password.' + }); + } +} diff --git a/apps/api/src/modules/auth/auth.types.ts b/apps/api/src/modules/auth/auth.types.ts new file mode 100644 index 0000000..861cb0f --- /dev/null +++ b/apps/api/src/modules/auth/auth.types.ts @@ -0,0 +1,41 @@ +export interface AuthUserRecord { + id: string; + username: string; + displayName: string; + passwordHash: string | null; + passwordAlgo: string | null; + status: 'ENABLED' | 'DISABLED'; + failedLoginCount: number; + lockedUntil: Date | null; + requirePasswordChange: boolean; + roles: string[]; +} + +export interface AuthSessionRecord { + id: string; + userId: string; + refreshTokenHash: string; + expiresAt: Date; + revokedAt: Date | null; + user: AuthUserRecord; +} + +export interface CreateSessionInput { + userId: string; + refreshTokenHash: string; + userAgent?: string; + ip?: string; + expiresAt: Date; + rotatedFromId?: string; +} + +export interface AuthRepository { + findUserByUsername(username: string): Promise; + markLoginSuccess(userId: string, ip?: string): Promise; + markLoginFailure(userId: string, failedLoginCount: number, lockedUntil: Date | null): Promise; + createSession(input: CreateSessionInput): Promise; + findActiveSessionByRefreshTokenHash(refreshTokenHash: string, now: Date): Promise; + revokeSession(sessionId: string, revokedAt: Date): Promise; +} + +export const AUTH_REPOSITORY = Symbol('AUTH_REPOSITORY'); diff --git a/apps/api/src/modules/auth/cookie.ts b/apps/api/src/modules/auth/cookie.ts new file mode 100644 index 0000000..455722d --- /dev/null +++ b/apps/api/src/modules/auth/cookie.ts @@ -0,0 +1,42 @@ +export function parseCookie(header: string | undefined, name: string): string | null { + if (!header) { + return null; + } + + for (const part of header.split(';')) { + const [rawKey, ...rawValue] = part.trim().split('='); + if (rawKey === name) { + return decodeURIComponent(rawValue.join('=')); + } + } + + return null; +} + +export function serializeCookie( + name: string, + value: string, + options: { + maxAgeSeconds?: number; + httpOnly?: boolean; + secure?: boolean; + sameSite?: 'Strict' | 'Lax'; + path?: string; + } = {} +): string { + const segments = [`${name}=${encodeURIComponent(value)}`, `Path=${options.path ?? '/api/v2/auth'}`, 'SameSite=' + (options.sameSite ?? 'Strict')]; + + if (options.maxAgeSeconds !== undefined) { + segments.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`); + } + + if (options.httpOnly ?? true) { + segments.push('HttpOnly'); + } + + if (options.secure ?? true) { + segments.push('Secure'); + } + + return segments.join('; '); +} diff --git a/apps/api/src/modules/customer-gateway-policies/customer-gateway-policies.controller.ts b/apps/api/src/modules/customer-gateway-policies/customer-gateway-policies.controller.ts new file mode 100644 index 0000000..a20c00c --- /dev/null +++ b/apps/api/src/modules/customer-gateway-policies/customer-gateway-policies.controller.ts @@ -0,0 +1,45 @@ +import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuditAction } from '../audit/audit.metadata.js'; +import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js'; +import { CustomerGatewayPoliciesService } from './customer-gateway-policies.service.js'; + +@ApiTags('customer-gateway-policies') +@Controller() +export class CustomerGatewayPoliciesController { + constructor(@Inject(CustomerGatewayPoliciesService) private readonly policiesService: CustomerGatewayPoliciesService) {} + + @Get('customer-gateways/:id/policies') + @RequirePermissions('customer_gateways.view') + list(@Param('id') gatewayId: string) { + return this.policiesService.list(gatewayId); + } + + @Post('customer-gateways/:id/policies') + @RequirePermissions('customer_gateways.manage') + @AuditAction({ module: 'customer_gateways', action: 'policy_create', objectType: 'customer_gateway', objectIdParam: 'id' }) + create(@Param('id') gatewayId: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.policiesService.create(gatewayId, body as never, currentUser?.id); + } + + @Patch('customer-gateway-policies/:id') + @RequirePermissions('customer_gateways.manage') + @AuditAction({ module: 'customer_gateways', action: 'policy_update', objectType: 'customer_gateway_policy', objectIdParam: 'id' }) + update(@Param('id') policyId: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.policiesService.update(policyId, body as never, currentUser?.id); + } + + @Delete('customer-gateway-policies/:id') + @RequirePermissions('customer_gateways.manage') + @AuditAction({ module: 'customer_gateways', action: 'policy_delete', objectType: 'customer_gateway_policy', objectIdParam: 'id' }) + remove(@Param('id') policyId: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.policiesService.remove(policyId, currentUser?.id); + } + + @Post('customer-gateways/:id/policies/reorder') + @RequirePermissions('customer_gateways.manage') + @AuditAction({ module: 'customer_gateways', action: 'policy_reorder', objectType: 'customer_gateway', objectIdParam: 'id' }) + reorder(@Param('id') gatewayId: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.policiesService.reorder(gatewayId, body as never, currentUser?.id); + } +} diff --git a/apps/api/src/modules/customer-gateway-policies/customer-gateway-policies.e2e.spec.ts b/apps/api/src/modules/customer-gateway-policies/customer-gateway-policies.e2e.spec.ts new file mode 100644 index 0000000..578f5c1 --- /dev/null +++ b/apps/api/src/modules/customer-gateway-policies/customer-gateway-policies.e2e.spec.ts @@ -0,0 +1,256 @@ +import 'reflect-metadata'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import request from 'supertest'; +import { signAccessToken, type PermissionKey } from '@lisglosips/auth'; +import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js'; +import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js'; +import type { CurrentUser } from '../security/security.metadata.js'; +import { + CUSTOMER_GATEWAY_POLICIES_REPOSITORY, + type CreatePolicyInput, + type CustomerGatewayPoliciesRepository, + type CustomerGatewayPolicySummary, + type UpdatePolicyInput +} from './customer-gateway-policies.repository.js'; + +class MemoryIdentityRepository implements IdentityRepository { + users = new Map(); + + async findCurrentUserById(userId: string): Promise { + return this.users.get(userId) ?? null; + } +} + +class MemoryAuditRepository implements AuditRepository { + entries: AuditEntryInput[] = []; + + async write(input: AuditEntryInput): Promise { + this.entries.push(input); + } +} + +class MemoryPoliciesRepository implements CustomerGatewayPoliciesRepository { + policies: CustomerGatewayPolicySummary[] = [ + this.summary({ id: 'cgp_1', name: 'Mobile prefix', priority: 1, callerMode: 'PREFIX', callerValue: '021' }), + this.summary({ id: 'cgp_2', name: 'Fallback', priority: 2 }) + ]; + outboxEvents = 0; + + async list(gatewayId: string): Promise { + return this.policies.filter((policy) => policy.gatewayId === gatewayId).sort((left, right) => left.priority - right.priority); + } + + async create(input: CreatePolicyInput): Promise { + const policy = this.summary({ + id: 'cgp_created', + gatewayId: input.gatewayId, + lineGroupId: input.lineGroupId, + name: input.name, + priority: input.priority ?? 3, + callerMode: input.callerMode, + callerValue: input.callerValue ?? null, + calleeMode: input.calleeMode, + calleeValue: input.calleeValue ?? null, + status: input.status + }); + this.policies.push(policy); + this.outboxEvents += 1; + return policy; + } + + async update(policyId: string, input: UpdatePolicyInput): Promise { + const index = this.policies.findIndex((policy) => policy.id === policyId); + const current = this.policies[index] ?? this.summary({ id: policyId, name: 'Missing', priority: 999 }); + const updated = { + ...current, + lineGroupId: input.lineGroupId ?? current.lineGroupId, + name: input.name ?? current.name, + priority: input.priority ?? current.priority, + callerMode: input.callerMode ?? current.callerMode, + callerValue: input.callerValue === undefined ? current.callerValue : input.callerValue, + calleeMode: input.calleeMode ?? current.calleeMode, + calleeValue: input.calleeValue === undefined ? current.calleeValue : input.calleeValue, + status: input.status ?? current.status + }; + this.policies[index] = updated; + this.outboxEvents += 1; + return updated; + } + + async softDelete(policyId: string): Promise { + const policy = await this.update(policyId, { status: 'DISABLED' }); + this.policies = this.policies.filter((item) => item.id !== policyId); + return policy; + } + + async reorder(gatewayId: string, policyIds: string[]): Promise { + this.policies = this.policies.map((policy) => { + const index = policyIds.indexOf(policy.id); + return policy.gatewayId === gatewayId && index >= 0 ? { ...policy, priority: index + 1 } : policy; + }); + this.outboxEvents += 1; + return this.list(gatewayId); + } + + private summary(input: { + id: string; + gatewayId?: string; + lineGroupId?: string; + name: string; + priority: number; + callerMode?: 'ANY' | 'EQUALS' | 'PREFIX'; + callerValue?: string | null; + calleeMode?: 'ANY' | 'EQUALS' | 'PREFIX'; + calleeValue?: string | null; + status?: 'ENABLED' | 'DISABLED'; + }): CustomerGatewayPolicySummary { + return { + id: input.id, + customerId: 'cus_seed', + gatewayId: input.gatewayId ?? 'cgw_seed', + lineGroupId: input.lineGroupId ?? 'lg_seed', + name: input.name, + priority: input.priority, + callerMode: input.callerMode ?? 'ANY', + callerValue: input.callerValue ?? null, + calleeMode: input.calleeMode ?? 'ANY', + calleeValue: input.calleeValue ?? null, + status: input.status ?? 'ENABLED', + createdAt: new Date('2026-06-21T04:00:00.000Z'), + updatedAt: new Date('2026-06-21T04:00:00.000Z') + }; + } +} + +describe('S14 customer gateway policies API', () => { + let app: NestFastifyApplication; + let audit: MemoryAuditRepository; + let repository: MemoryPoliciesRepository; + + const tokenFor = (userId: string) => + signAccessToken( + { + sub: userId, + username: userId, + roles: ['test'], + typ: 'access' + }, + { + secret: 'test-only-access-token-secret-min-32-bytes', + issuer: 'lisglosips-api', + audience: 'lisglosips-web', + ttlSeconds: 900 + } + ); + + beforeAll(async () => { + process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips'; + process.env.REDIS_URL = 'redis://127.0.0.1:6379/0'; + process.env.LISGLOSIPS_LOG_LEVEL = 'silent'; + process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes'; + + const identities = new MemoryIdentityRepository(); + audit = new MemoryAuditRepository(); + repository = new MemoryPoliciesRepository(); + + identities.users.set('usr_ops', { + id: 'usr_ops', + username: 'ops', + roles: ['运营管理员'], + permissions: ['customer_gateways.view', 'customer_gateways.manage'] as PermissionKey[] + }); + identities.users.set('usr_viewer', { + id: 'usr_viewer', + username: 'viewer', + roles: ['只读'], + permissions: ['customer_gateways.view'] as PermissionKey[] + }); + + const { AppModule } = await import('../app.module.js'); + const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(IDENTITY_REPOSITORY) + .useValue(identities) + .overrideProvider(AUDIT_REPOSITORY) + .useValue(audit) + .overrideProvider(CUSTOMER_GATEWAY_POLICIES_REPOSITORY) + .useValue(repository) + .compile(); + + app = moduleFixture.createNestApplication(new FastifyAdapter({ logger: false })); + app.setGlobalPrefix('api/v2'); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('lists policies ordered by priority for viewers', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v2/customer-gateways/cgw_seed/policies') + .set('Authorization', `Bearer ${tokenFor('usr_viewer')}`) + .expect(200); + + expect(response.body.map((policy: { id: string }) => policy.id)).toEqual(['cgp_1', 'cgp_2']); + }); + + it('rejects policy writes without manage permission', async () => { + await request(app.getHttpServer()) + .post('/api/v2/customer-gateways/cgw_seed/policies') + .set('Authorization', `Bearer ${tokenFor('usr_viewer')}`) + .send({ lineGroupId: 'lg_seed', name: 'Denied' }) + .expect(403); + }); + + it('creates, updates, reorders, and deletes policies with audit and outbox intent', async () => { + await request(app.getHttpServer()) + .post('/api/v2/customer-gateways/cgw_seed/policies') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ + lineGroupId: 'lg_seed', + name: 'Callee exact', + priority: 3, + callerMode: 'ANY', + calleeMode: 'EQUALS', + calleeValue: '13800138000' + }) + .expect(201) + .expect((response) => { + expect(response.body).toMatchObject({ + id: 'cgp_created', + calleeMode: 'EQUALS', + calleeValue: '13800138000' + }); + }); + + await request(app.getHttpServer()) + .patch('/api/v2/customer-gateway-policies/cgp_created') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ callerMode: 'PREFIX', callerValue: '010' }) + .expect(200); + + const reordered = await request(app.getHttpServer()) + .post('/api/v2/customer-gateways/cgw_seed/policies/reorder') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ policyIds: ['cgp_created', 'cgp_2', 'cgp_1'] }) + .expect(201); + expect(reordered.body.map((policy: { id: string }) => policy.id)).toEqual(['cgp_created', 'cgp_2', 'cgp_1']); + + await request(app.getHttpServer()).delete('/api/v2/customer-gateway-policies/cgp_2').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200); + + expect(repository.outboxEvents).toBeGreaterThanOrEqual(4); + expect(audit.entries.some((entry) => entry.module === 'customer_gateways' && entry.action === 'policy_create')).toBe(true); + expect(audit.entries.some((entry) => entry.module === 'customer_gateways' && entry.action === 'policy_reorder')).toBe(true); + }); + + it('validates match values for non-ANY modes', async () => { + await request(app.getHttpServer()) + .post('/api/v2/customer-gateways/cgw_seed/policies') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ lineGroupId: 'lg_seed', name: 'Invalid', callerMode: 'PREFIX' }) + .expect(400); + }); +}); diff --git a/apps/api/src/modules/customer-gateway-policies/customer-gateway-policies.module.ts b/apps/api/src/modules/customer-gateway-policies/customer-gateway-policies.module.ts new file mode 100644 index 0000000..1cc0377 --- /dev/null +++ b/apps/api/src/modules/customer-gateway-policies/customer-gateway-policies.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { CustomerGatewayPoliciesController } from './customer-gateway-policies.controller.js'; +import { CUSTOMER_GATEWAY_POLICIES_REPOSITORY, PrismaCustomerGatewayPoliciesRepository } from './customer-gateway-policies.repository.js'; +import { CustomerGatewayPoliciesService } from './customer-gateway-policies.service.js'; + +@Module({ + controllers: [CustomerGatewayPoliciesController], + providers: [ + CustomerGatewayPoliciesService, + { + provide: CUSTOMER_GATEWAY_POLICIES_REPOSITORY, + useClass: PrismaCustomerGatewayPoliciesRepository + } + ], + exports: [CustomerGatewayPoliciesService] +}) +export class CustomerGatewayPoliciesModule {} diff --git a/apps/api/src/modules/customer-gateway-policies/customer-gateway-policies.repository.ts b/apps/api/src/modules/customer-gateway-policies/customer-gateway-policies.repository.ts new file mode 100644 index 0000000..242f032 --- /dev/null +++ b/apps/api/src/modules/customer-gateway-policies/customer-gateway-policies.repository.ts @@ -0,0 +1,290 @@ +import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import crypto from 'node:crypto'; +import { Prisma } from '@lisglosips/database'; +import { PrismaService } from '../database/prisma.service.js'; + +export type PolicyStatus = 'ENABLED' | 'DISABLED'; +export type PolicyMatchMode = 'ANY' | 'EQUALS' | 'PREFIX'; + +export interface CustomerGatewayPolicySummary { + id: string; + customerId: string; + gatewayId: string; + lineGroupId: string; + name: string; + priority: number; + callerMode: PolicyMatchMode; + callerValue: string | null; + calleeMode: PolicyMatchMode; + calleeValue: string | null; + status: PolicyStatus; + createdAt: Date; + updatedAt: Date; +} + +export interface CreatePolicyInput { + gatewayId: string; + lineGroupId: string; + name: string; + priority?: number; + callerMode: PolicyMatchMode; + callerValue?: string | null; + calleeMode: PolicyMatchMode; + calleeValue?: string | null; + status: PolicyStatus; + actorId?: string; +} + +export interface UpdatePolicyInput { + lineGroupId?: string; + name?: string; + priority?: number; + callerMode?: PolicyMatchMode; + callerValue?: string | null; + calleeMode?: PolicyMatchMode; + calleeValue?: string | null; + status?: PolicyStatus; + actorId?: string; +} + +export interface CustomerGatewayPoliciesRepository { + list(gatewayId: string): Promise; + create(input: CreatePolicyInput): Promise; + update(policyId: string, input: UpdatePolicyInput): Promise; + softDelete(policyId: string, actorId?: string): Promise; + reorder(gatewayId: string, policyIds: string[], actorId?: string): Promise; +} + +export const CUSTOMER_GATEWAY_POLICIES_REPOSITORY = Symbol('CUSTOMER_GATEWAY_POLICIES_REPOSITORY'); + +function policyId(): string { + return `cgp_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`; +} + +function outboxId(): string { + return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`; +} + +@Injectable() +export class PrismaCustomerGatewayPoliciesRepository implements CustomerGatewayPoliciesRepository { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async list(gatewayId: string): Promise { + await this.ensureGateway(gatewayId); + const policies = await this.prisma.customerGatewayPolicy.findMany({ + where: { gatewayId, deletedAt: null }, + orderBy: [{ priority: 'asc' }] + }); + return policies.map((policy) => this.toSummary(policy)); + } + + async create(input: CreatePolicyInput): Promise { + const gateway = await this.ensureGateway(input.gatewayId); + await this.ensureLineGroup(input.lineGroupId); + const priority = input.priority ?? (await this.nextPriority(input.gatewayId)); + + try { + const created = await this.prisma.$transaction(async (tx) => { + const policy = await tx.customerGatewayPolicy.create({ + data: { + id: policyId(), + customerId: gateway.customerId, + gatewayId: input.gatewayId, + lineGroupId: input.lineGroupId, + name: input.name, + priority, + callerMode: input.callerMode, + callerValue: input.callerValue, + calleeMode: input.calleeMode, + calleeValue: input.calleeValue, + status: input.status, + createdBy: input.actorId, + updatedBy: input.actorId + } + }); + await this.enqueueConfigOutbox(tx, input.gatewayId, 'customer_gateway_policy.changed'); + return policy; + }); + return this.toSummary(created); + } catch (error) { + this.handleUniqueConflict(error); + throw error; + } + } + + async update(policyIdValue: string, input: UpdatePolicyInput): Promise { + const existing = await this.findActiveOrThrow(policyIdValue); + if (input.lineGroupId) { + await this.ensureLineGroup(input.lineGroupId); + } + + try { + const updated = await this.prisma.$transaction(async (tx) => { + const policy = await tx.customerGatewayPolicy.update({ + where: { id: policyIdValue }, + data: { + lineGroupId: input.lineGroupId, + name: input.name, + priority: input.priority, + callerMode: input.callerMode, + callerValue: input.callerValue, + calleeMode: input.calleeMode, + calleeValue: input.calleeValue, + status: input.status, + updatedBy: input.actorId, + version: { increment: 1 } + } + }); + await this.enqueueConfigOutbox(tx, existing.gatewayId, 'customer_gateway_policy.changed'); + return policy; + }); + return this.toSummary(updated); + } catch (error) { + this.handleUniqueConflict(error); + throw error; + } + } + + async softDelete(policyIdValue: string, actorId?: string): Promise { + const existing = await this.findActiveOrThrow(policyIdValue); + const deleted = await this.prisma.$transaction(async (tx) => { + const policy = await tx.customerGatewayPolicy.update({ + where: { id: policyIdValue }, + data: { + status: 'DISABLED', + deletedAt: new Date(), + updatedBy: actorId, + version: { increment: 1 } + } + }); + await this.enqueueConfigOutbox(tx, existing.gatewayId, 'customer_gateway_policy.changed'); + return policy; + }); + return this.toSummary(deleted); + } + + async reorder(gatewayId: string, policyIds: string[], actorId?: string): Promise { + await this.ensureGateway(gatewayId); + const current = await this.prisma.customerGatewayPolicy.findMany({ + where: { gatewayId, deletedAt: null }, + select: { id: true } + }); + const currentIds = current.map((policy) => policy.id).sort(); + const requestedIds = [...policyIds].sort(); + if (currentIds.length !== requestedIds.length || currentIds.some((id, index) => id !== requestedIds[index])) { + throw new BadRequestException({ code: 'POLICY_REORDER_SET_MISMATCH', message: 'Reorder must include every active policy exactly once.' }); + } + if (new Set(policyIds).size !== policyIds.length) { + throw new BadRequestException({ code: 'POLICY_REORDER_DUPLICATE', message: 'Policy ids must be unique.' }); + } + + await this.prisma.$transaction(async (tx) => { + for (let index = 0; index < policyIds.length; index += 1) { + await tx.customerGatewayPolicy.update({ + where: { id: policyIds[index] }, + data: { + priority: -(index + 1), + updatedBy: actorId, + version: { increment: 1 } + } + }); + } + for (let index = 0; index < policyIds.length; index += 1) { + await tx.customerGatewayPolicy.update({ + where: { id: policyIds[index] }, + data: { priority: index + 1 } + }); + } + await this.enqueueConfigOutbox(tx, gatewayId, 'customer_gateway_policy.reordered'); + }); + + return this.list(gatewayId); + } + + private async ensureGateway(gatewayId: string): Promise<{ id: string; customerId: string }> { + const gateway = await this.prisma.customerGateway.findUnique({ + where: { id: gatewayId }, + select: { id: true, customerId: true, deletedAt: true } + }); + if (!gateway || gateway.deletedAt) { + throw new NotFoundException({ code: 'CUSTOMER_GATEWAY_NOT_FOUND', message: 'Customer gateway not found.' }); + } + return gateway; + } + + private async ensureLineGroup(lineGroupId: string): Promise { + const lineGroup = await this.prisma.landingLineGroup.findUnique({ + where: { id: lineGroupId }, + select: { id: true, deletedAt: true } + }); + if (!lineGroup || lineGroup.deletedAt) { + throw new NotFoundException({ code: 'LINE_GROUP_NOT_FOUND', message: 'Landing line group not found.' }); + } + } + + private async findActiveOrThrow(policyIdValue: string) { + const policy = await this.prisma.customerGatewayPolicy.findUnique({ where: { id: policyIdValue } }); + if (!policy || policy.deletedAt) { + throw new NotFoundException({ code: 'CUSTOMER_GATEWAY_POLICY_NOT_FOUND', message: 'Customer gateway policy not found.' }); + } + return policy; + } + + private async nextPriority(gatewayId: string): Promise { + const aggregate = await this.prisma.customerGatewayPolicy.aggregate({ + where: { gatewayId, deletedAt: null }, + _max: { priority: true } + }); + return (aggregate._max.priority ?? 0) + 1; + } + + private async enqueueConfigOutbox(tx: Prisma.TransactionClient, gatewayId: string, eventType: string): Promise { + await tx.outboxEvent.create({ + data: { + id: outboxId(), + aggregateType: 'customer_gateway_config', + aggregateId: gatewayId, + eventType, + payload: { gatewayId, eventType } + } + }); + } + + private toSummary(policy: { + id: string; + customerId: string; + gatewayId: string; + lineGroupId: string; + name: string; + priority: number; + callerMode: PolicyMatchMode; + callerValue: string | null; + calleeMode: PolicyMatchMode; + calleeValue: string | null; + status: PolicyStatus; + createdAt: Date; + updatedAt: Date; + }): CustomerGatewayPolicySummary { + return { + id: policy.id, + customerId: policy.customerId, + gatewayId: policy.gatewayId, + lineGroupId: policy.lineGroupId, + name: policy.name, + priority: policy.priority, + callerMode: policy.callerMode, + callerValue: policy.callerValue, + calleeMode: policy.calleeMode, + calleeValue: policy.calleeValue, + status: policy.status, + createdAt: policy.createdAt, + updatedAt: policy.updatedAt + }; + } + + private handleUniqueConflict(error: unknown): void { + if (error && typeof error === 'object' && 'code' in error && (error as { code?: unknown }).code === 'P2002') { + throw new ConflictException({ code: 'CUSTOMER_GATEWAY_POLICY_CONFLICT', message: 'Customer gateway policy priority already exists.' }); + } + } +} diff --git a/apps/api/src/modules/customer-gateway-policies/customer-gateway-policies.service.ts b/apps/api/src/modules/customer-gateway-policies/customer-gateway-policies.service.ts new file mode 100644 index 0000000..aa72567 --- /dev/null +++ b/apps/api/src/modules/customer-gateway-policies/customer-gateway-policies.service.ts @@ -0,0 +1,145 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { + CUSTOMER_GATEWAY_POLICIES_REPOSITORY, + type CreatePolicyInput, + type CustomerGatewayPoliciesRepository, + type CustomerGatewayPolicySummary, + type PolicyMatchMode, + type PolicyStatus, + type UpdatePolicyInput +} from './customer-gateway-policies.repository.js'; + +interface CreatePolicyDto { + lineGroupId?: unknown; + name?: unknown; + priority?: unknown; + callerMode?: unknown; + callerValue?: unknown; + calleeMode?: unknown; + calleeValue?: unknown; + status?: unknown; +} + +interface UpdatePolicyDto { + lineGroupId?: unknown; + name?: unknown; + priority?: unknown; + callerMode?: unknown; + callerValue?: unknown; + calleeMode?: unknown; + calleeValue?: unknown; + status?: unknown; +} + +interface ReorderDto { + policyIds?: unknown; +} + +@Injectable() +export class CustomerGatewayPoliciesService { + constructor(@Inject(CUSTOMER_GATEWAY_POLICIES_REPOSITORY) private readonly policies: CustomerGatewayPoliciesRepository) {} + + list(gatewayId: string): Promise { + return this.policies.list(gatewayId); + } + + create(gatewayId: string, body: CreatePolicyDto, actorId?: string): Promise { + const callerMode = body.callerMode === undefined ? 'ANY' : this.matchMode(body.callerMode, 'callerMode'); + const calleeMode = body.calleeMode === undefined ? 'ANY' : this.matchMode(body.calleeMode, 'calleeMode'); + const input: CreatePolicyInput = { + gatewayId, + lineGroupId: this.limitedString(body.lineGroupId, 'lineGroupId', 32), + name: this.limitedString(body.name, 'name', 120), + priority: body.priority === undefined ? undefined : this.priority(body.priority), + callerMode, + callerValue: this.matchValue(callerMode, body.callerValue, 'callerValue'), + calleeMode, + calleeValue: this.matchValue(calleeMode, body.calleeValue, 'calleeValue'), + status: body.status === undefined ? 'ENABLED' : this.status(body.status), + actorId + }; + return this.policies.create(input); + } + + update(policyId: string, body: UpdatePolicyDto, actorId?: string): Promise { + const callerMode = body.callerMode === undefined ? undefined : this.matchMode(body.callerMode, 'callerMode'); + const calleeMode = body.calleeMode === undefined ? undefined : this.matchMode(body.calleeMode, 'calleeMode'); + const input: UpdatePolicyInput = { + lineGroupId: body.lineGroupId === undefined ? undefined : this.limitedString(body.lineGroupId, 'lineGroupId', 32), + name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120), + priority: body.priority === undefined ? undefined : this.priority(body.priority), + callerMode, + callerValue: callerMode === undefined ? (body.callerValue === undefined ? undefined : this.nullableString(body.callerValue, 'callerValue', 64)) : this.matchValue(callerMode, body.callerValue, 'callerValue'), + calleeMode, + calleeValue: calleeMode === undefined ? (body.calleeValue === undefined ? undefined : this.nullableString(body.calleeValue, 'calleeValue', 64)) : this.matchValue(calleeMode, body.calleeValue, 'calleeValue'), + status: body.status === undefined ? undefined : this.status(body.status), + actorId + }; + return this.policies.update(policyId, input); + } + + remove(policyId: string, actorId?: string): Promise { + return this.policies.softDelete(policyId, actorId); + } + + reorder(gatewayId: string, body: ReorderDto, actorId?: string): Promise { + if (!Array.isArray(body.policyIds) || body.policyIds.length === 0) { + throw new BadRequestException({ code: 'POLICY_IDS_INVALID', message: 'policyIds must be a non-empty array.' }); + } + return this.policies.reorder( + gatewayId, + body.policyIds.map((id) => this.limitedString(id, 'policyIds', 32)), + actorId + ); + } + + private limitedString(value: unknown, field: string, maxLength: number): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` }); + } + const trimmed = value.trim(); + if (trimmed.length > maxLength) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is too long.` }); + } + return trimmed; + } + + private nullableString(value: unknown, field: string, maxLength: number): string | null { + if (value === null) { + return null; + } + return this.limitedString(value, field, maxLength); + } + + private matchMode(value: unknown, field: string): PolicyMatchMode { + if (value !== 'ANY' && value !== 'EQUALS' && value !== 'PREFIX') { + throw new BadRequestException({ code: 'MATCH_MODE_INVALID', message: `${field} is invalid.` }); + } + return value; + } + + private matchValue(mode: PolicyMatchMode, value: unknown, field: string): string | null { + if (mode === 'ANY') { + return null; + } + const text = this.limitedString(value, field, 64); + if (!/^[0-9A-Za-z+*#.-]+$/.test(text)) { + throw new BadRequestException({ code: 'MATCH_VALUE_INVALID', message: `${field} contains invalid characters.` }); + } + return text; + } + + private priority(value: unknown): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 10000) { + throw new BadRequestException({ code: 'PRIORITY_INVALID', message: 'priority must be an integer from 1 to 10000.' }); + } + return value; + } + + private status(value: unknown): PolicyStatus { + if (value !== 'ENABLED' && value !== 'DISABLED') { + throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' }); + } + return value; + } +} diff --git a/apps/api/src/modules/customer-gateways/customer-gateways.controller.ts b/apps/api/src/modules/customer-gateways/customer-gateways.controller.ts new file mode 100644 index 0000000..3254355 --- /dev/null +++ b/apps/api/src/modules/customer-gateways/customer-gateways.controller.ts @@ -0,0 +1,51 @@ +import { Body, Controller, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuditAction } from '../audit/audit.metadata.js'; +import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js'; +import { CustomerGatewaysService } from './customer-gateways.service.js'; + +@ApiTags('customer-gateways') +@Controller('customer-gateways') +export class CustomerGatewaysController { + constructor(@Inject(CustomerGatewaysService) private readonly customerGatewaysService: CustomerGatewaysService) {} + + @Get() + @RequirePermissions('customer_gateways.view') + list(@Query() query: unknown) { + return this.customerGatewaysService.list(query as never); + } + + @Get(':id') + @RequirePermissions('customer_gateways.view') + get(@Param('id') id: string) { + return this.customerGatewaysService.get(id); + } + + @Post() + @RequirePermissions('customer_gateways.manage') + @AuditAction({ module: 'customer_gateways', action: 'create', objectType: 'customer_gateway' }) + create(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.customerGatewaysService.create(body as never, currentUser?.id); + } + + @Patch(':id') + @RequirePermissions('customer_gateways.manage') + @AuditAction({ module: 'customer_gateways', action: 'update', objectType: 'customer_gateway', objectIdParam: 'id' }) + update(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.customerGatewaysService.update(id, body as never, currentUser?.id); + } + + @Post(':id/enable') + @RequirePermissions('customer_gateways.manage') + @AuditAction({ module: 'customer_gateways', action: 'enable', objectType: 'customer_gateway', objectIdParam: 'id' }) + enable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.customerGatewaysService.enable(id, currentUser?.id); + } + + @Post(':id/disable') + @RequirePermissions('customer_gateways.manage') + @AuditAction({ module: 'customer_gateways', action: 'disable', objectType: 'customer_gateway', objectIdParam: 'id' }) + disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.customerGatewaysService.disable(id, currentUser?.id); + } +} diff --git a/apps/api/src/modules/customer-gateways/customer-gateways.e2e.spec.ts b/apps/api/src/modules/customer-gateways/customer-gateways.e2e.spec.ts new file mode 100644 index 0000000..8b19134 --- /dev/null +++ b/apps/api/src/modules/customer-gateways/customer-gateways.e2e.spec.ts @@ -0,0 +1,273 @@ +import 'reflect-metadata'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import request from 'supertest'; +import { signAccessToken, type PermissionKey } from '@lisglosips/auth'; +import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js'; +import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js'; +import type { CurrentUser } from '../security/security.metadata.js'; +import { + CUSTOMER_GATEWAYS_REPOSITORY, + type CreateCustomerGatewayInput, + type CustomerGatewayStatus, + type CustomerGatewaySummary, + type CustomerGatewaysRepository, + type UpdateCustomerGatewayInput +} from './customer-gateways.repository.js'; + +class MemoryIdentityRepository implements IdentityRepository { + users = new Map(); + + async findCurrentUserById(userId: string): Promise { + return this.users.get(userId) ?? null; + } +} + +class MemoryAuditRepository implements AuditRepository { + entries: AuditEntryInput[] = []; + + async write(input: AuditEntryInput): Promise { + this.entries.push(input); + } +} + +class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository { + private readonly gateways = new Map(); + + constructor() { + this.gateways.set( + 'cgw_seed', + this.summary({ + id: 'cgw_seed', + customerId: 'cus_seed', + name: 'Seed IP Gateway', + authMode: 'IP', + sourceIp: '100.93.185.30', + hasSipCredential: false, + policyCount: 1 + }) + ); + } + + async list(customerId?: string): Promise { + return [...this.gateways.values()].filter((gateway) => !customerId || gateway.customerId === customerId); + } + + async get(gatewayId: string): Promise { + return this.gateways.get(gatewayId) ?? this.summary({ id: gatewayId, name: 'Missing Gateway' }); + } + + async create(input: CreateCustomerGatewayInput): Promise { + const gateway = this.summary({ + id: 'cgw_created', + customerId: input.customerId, + name: input.name, + authMode: input.authMode, + sourceIp: input.sourceIp ?? null, + sipUsername: input.sipUsername ?? null, + sipDomain: input.sipDomain ?? null, + hasSipCredential: Boolean(input.sipHa1) + }); + this.gateways.set(gateway.id, gateway); + return gateway; + } + + async update(gatewayId: string, input: UpdateCustomerGatewayInput): Promise { + const current = await this.get(gatewayId); + const updated: CustomerGatewaySummary = { + ...current, + customerId: input.customerId ?? current.customerId, + name: input.name ?? current.name, + authMode: input.authMode ?? current.authMode, + sourceIp: input.sourceIp === undefined ? current.sourceIp : input.sourceIp, + sipUsername: input.sipUsername === undefined ? current.sipUsername : input.sipUsername, + sipDomain: input.sipDomain === undefined ? current.sipDomain : input.sipDomain, + hasSipCredential: input.sipHa1 === undefined ? current.hasSipCredential : Boolean(input.sipHa1), + updatedAt: new Date('2026-06-21T03:00:00.000Z') + }; + this.gateways.set(gatewayId, updated); + return updated; + } + + async setStatus(gatewayId: string, status: CustomerGatewayStatus): Promise { + const current = await this.get(gatewayId); + const updated = { ...current, status }; + this.gateways.set(gatewayId, updated); + return updated; + } + + private summary(input: { + id: string; + customerId?: string; + name: string; + authMode?: 'IP' | 'SIP_DIGEST' | 'MIXED'; + sourceIp?: string | null; + sipUsername?: string | null; + sipDomain?: string | null; + hasSipCredential?: boolean; + status?: CustomerGatewayStatus; + policyCount?: number; + }): CustomerGatewaySummary { + return { + id: input.id, + customerId: input.customerId ?? 'cus_seed', + customerName: 'Seed Customer', + name: input.name, + authMode: input.authMode ?? 'IP', + sourceIp: input.sourceIp ?? null, + sipUsername: input.sipUsername ?? null, + sipDomain: input.sipDomain ?? null, + hasSipCredential: input.hasSipCredential ?? false, + status: input.status ?? 'ENABLED', + policyCount: input.policyCount ?? 0, + createdAt: new Date('2026-06-21T02:30:00.000Z'), + updatedAt: new Date('2026-06-21T02:30:00.000Z') + }; + } +} + +describe('S13 customer gateways API', () => { + let app: NestFastifyApplication; + let audit: MemoryAuditRepository; + + const tokenFor = (userId: string) => + signAccessToken( + { + sub: userId, + username: userId, + roles: ['test'], + typ: 'access' + }, + { + secret: 'test-only-access-token-secret-min-32-bytes', + issuer: 'lisglosips-api', + audience: 'lisglosips-web', + ttlSeconds: 900 + } + ); + + beforeAll(async () => { + process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips'; + process.env.REDIS_URL = 'redis://127.0.0.1:6379/0'; + process.env.LISGLOSIPS_LOG_LEVEL = 'silent'; + process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes'; + + const identities = new MemoryIdentityRepository(); + audit = new MemoryAuditRepository(); + + identities.users.set('usr_ops', { + id: 'usr_ops', + username: 'ops', + roles: ['运营管理员'], + permissions: ['customer_gateways.view', 'customer_gateways.manage'] as PermissionKey[] + }); + identities.users.set('usr_viewer', { + id: 'usr_viewer', + username: 'viewer', + roles: ['只读'], + permissions: ['customer_gateways.view'] as PermissionKey[] + }); + + const { AppModule } = await import('../app.module.js'); + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule] + }) + .overrideProvider(IDENTITY_REPOSITORY) + .useValue(identities) + .overrideProvider(AUDIT_REPOSITORY) + .useValue(audit) + .overrideProvider(CUSTOMER_GATEWAYS_REPOSITORY) + .useValue(new MemoryCustomerGatewaysRepository()) + .compile(); + + app = moduleFixture.createNestApplication(new FastifyAdapter({ logger: false })); + app.setGlobalPrefix('api/v2'); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('lists gateways without exposing SIP secrets', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v2/customer-gateways?customerId=cus_seed') + .set('Authorization', `Bearer ${tokenFor('usr_viewer')}`) + .expect(200); + + expect(response.body[0]).toMatchObject({ + id: 'cgw_seed', + authMode: 'IP', + sourceIp: '100.93.185.30', + policyCount: 1 + }); + expect(JSON.stringify(response.body)).not.toContain('sipPassword'); + expect(JSON.stringify(response.body)).not.toContain('sipHa1'); + }); + + it('rejects writes without customer_gateways.manage', async () => { + await request(app.getHttpServer()) + .post('/api/v2/customer-gateways') + .set('Authorization', `Bearer ${tokenFor('usr_viewer')}`) + .send({ customerId: 'cus_seed', name: 'Denied', authMode: 'IP', sourceIp: '100.93.185.31' }) + .expect(403); + }); + + it('creates SIP digest gateway, hides the password, and writes audit', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v2/customer-gateways') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ + customerId: 'cus_seed', + name: 'SIP Digest Gateway', + authMode: 'SIP_DIGEST', + sipUsername: 'alice-gw', + sipDomain: 'SIP.EXAMPLE.LOCAL', + sipPassword: 'change-me-very-strong' + }) + .expect(201); + + expect(response.body).toMatchObject({ + id: 'cgw_created', + authMode: 'SIP_DIGEST', + sipUsername: 'alice-gw', + sipDomain: 'sip.example.local', + sourceIp: null, + hasSipCredential: true + }); + expect(response.body.sipPassword).toBeUndefined(); + expect(response.body.sipHa1).toBeUndefined(); + expect(audit.entries.some((entry) => entry.module === 'customer_gateways' && entry.action === 'create' && entry.result === 'SUCCESS')).toBe(true); + expect(JSON.stringify(audit.entries)).not.toContain('change-me-very-strong'); + }); + + it('requires a new SIP password when SIP identity changes', async () => { + await request(app.getHttpServer()) + .patch('/api/v2/customer-gateways/cgw_created') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ sipDomain: 'new.example.local' }) + .expect(400); + }); + + it('switches to IP auth and supports enable/disable', async () => { + await request(app.getHttpServer()) + .patch('/api/v2/customer-gateways/cgw_created') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ authMode: 'IP', sourceIp: '100.93.185.32' }) + .expect(200) + .expect((response) => { + expect(response.body).toMatchObject({ + authMode: 'IP', + sourceIp: '100.93.185.32', + sipUsername: null, + sipDomain: null, + hasSipCredential: false + }); + }); + + await request(app.getHttpServer()).post('/api/v2/customer-gateways/cgw_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201); + await request(app.getHttpServer()).post('/api/v2/customer-gateways/cgw_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201); + }); +}); diff --git a/apps/api/src/modules/customer-gateways/customer-gateways.module.ts b/apps/api/src/modules/customer-gateways/customer-gateways.module.ts new file mode 100644 index 0000000..05dd977 --- /dev/null +++ b/apps/api/src/modules/customer-gateways/customer-gateways.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { CustomerGatewaysController } from './customer-gateways.controller.js'; +import { CUSTOMER_GATEWAYS_REPOSITORY, PrismaCustomerGatewaysRepository } from './customer-gateways.repository.js'; +import { CustomerGatewaysService } from './customer-gateways.service.js'; + +@Module({ + controllers: [CustomerGatewaysController], + providers: [ + CustomerGatewaysService, + { + provide: CUSTOMER_GATEWAYS_REPOSITORY, + useClass: PrismaCustomerGatewaysRepository + } + ], + exports: [CustomerGatewaysService] +}) +export class CustomerGatewaysModule {} diff --git a/apps/api/src/modules/customer-gateways/customer-gateways.repository.ts b/apps/api/src/modules/customer-gateways/customer-gateways.repository.ts new file mode 100644 index 0000000..28a329d --- /dev/null +++ b/apps/api/src/modules/customer-gateways/customer-gateways.repository.ts @@ -0,0 +1,265 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import crypto from 'node:crypto'; +import { Prisma } from '@lisglosips/database'; +import { PrismaService } from '../database/prisma.service.js'; + +export type CustomerGatewayStatus = 'ENABLED' | 'DISABLED'; +export type CustomerGatewayAuthMode = 'IP' | 'SIP_DIGEST' | 'MIXED'; + +export interface CustomerGatewaySummary { + id: string; + customerId: string; + customerName: string; + name: string; + authMode: CustomerGatewayAuthMode; + sourceIp: string | null; + sipUsername: string | null; + sipDomain: string | null; + hasSipCredential: boolean; + status: CustomerGatewayStatus; + policyCount: number; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateCustomerGatewayInput { + customerId: string; + name: string; + authMode: CustomerGatewayAuthMode; + sourceIp?: string | null; + sipUsername?: string | null; + sipDomain?: string | null; + sipHa1?: string; + actorId?: string; +} + +export interface UpdateCustomerGatewayInput { + customerId?: string; + name?: string; + authMode?: CustomerGatewayAuthMode; + sourceIp?: string | null; + sipUsername?: string | null; + sipDomain?: string | null; + sipHa1?: string | null; + actorId?: string; +} + +export interface CustomerGatewaysRepository { + list(customerId?: string): Promise; + get(gatewayId: string): Promise; + create(input: CreateCustomerGatewayInput): Promise; + update(gatewayId: string, input: UpdateCustomerGatewayInput): Promise; + setStatus(gatewayId: string, status: CustomerGatewayStatus, actorId?: string): Promise; +} + +export const CUSTOMER_GATEWAYS_REPOSITORY = Symbol('CUSTOMER_GATEWAYS_REPOSITORY'); + +function gatewayId(): string { + return `cgw_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`; +} + +function outboxId(): string { + return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`; +} + +@Injectable() +export class PrismaCustomerGatewaysRepository implements CustomerGatewaysRepository { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async list(customerId?: string): Promise { + const gateways = await this.prisma.customerGateway.findMany({ + where: { + deletedAt: null, + customerId + }, + orderBy: [{ createdAt: 'desc' }], + include: this.includeSummary() + }); + + return gateways.map((gateway) => this.toSummary(gateway)); + } + + async get(gatewayIdValue: string): Promise { + return this.toSummary(await this.findActiveOrThrow(gatewayIdValue)); + } + + async create(input: CreateCustomerGatewayInput): Promise { + await this.ensureCustomerExists(input.customerId); + + try { + const gateway = await this.prisma.$transaction(async (tx) => { + const created = await tx.customerGateway.create({ + data: { + id: gatewayId(), + customerId: input.customerId, + name: input.name, + authMode: input.authMode, + sourceIp: input.sourceIp, + sipUsername: input.sipUsername, + sipDomain: input.sipDomain, + sipHa1: input.sipHa1, + createdBy: input.actorId, + updatedBy: input.actorId + }, + include: this.includeSummary() + }); + await this.enqueueConfigOutbox(tx, created.id, 'customer_gateway.changed'); + return created; + }); + + return this.toSummary(gateway); + } catch (error) { + this.handleUniqueConflict(error); + throw error; + } + } + + async update(gatewayIdValue: string, input: UpdateCustomerGatewayInput): Promise { + await this.findActiveOrThrow(gatewayIdValue); + if (input.customerId) { + await this.ensureCustomerExists(input.customerId); + } + + try { + const gateway = await this.prisma.$transaction(async (tx) => { + const updated = await tx.customerGateway.update({ + where: { id: gatewayIdValue }, + data: { + customerId: input.customerId, + name: input.name, + authMode: input.authMode, + sourceIp: input.sourceIp, + sipUsername: input.sipUsername, + sipDomain: input.sipDomain, + sipHa1: input.sipHa1, + updatedBy: input.actorId, + version: { increment: 1 } + }, + include: this.includeSummary() + }); + await this.enqueueConfigOutbox(tx, updated.id, 'customer_gateway.changed'); + return updated; + }); + + return this.toSummary(gateway); + } catch (error) { + this.handleUniqueConflict(error); + throw error; + } + } + + async setStatus(gatewayIdValue: string, status: CustomerGatewayStatus, actorId?: string): Promise { + await this.findActiveOrThrow(gatewayIdValue); + + const gateway = await this.prisma.$transaction(async (tx) => { + const updated = await tx.customerGateway.update({ + where: { id: gatewayIdValue }, + data: { + status, + updatedBy: actorId, + version: { increment: 1 } + }, + include: this.includeSummary() + }); + await this.enqueueConfigOutbox(tx, updated.id, 'customer_gateway.changed'); + return updated; + }); + + return this.toSummary(gateway); + } + + private async enqueueConfigOutbox(tx: Prisma.TransactionClient, aggregateId: string, eventType: string): Promise { + await tx.outboxEvent.create({ + data: { + id: outboxId(), + aggregateType: 'customer_gateway_config', + aggregateId, + eventType, + payload: { + aggregateId, + eventType + } + } + }); + } + + private async ensureCustomerExists(customerId: string): Promise { + const customer = await this.prisma.customer.findUnique({ + where: { id: customerId }, + select: { id: true, deletedAt: true } + }); + + if (!customer || customer.deletedAt) { + throw new NotFoundException({ code: 'CUSTOMER_NOT_FOUND', message: 'Customer not found.' }); + } + } + + private async findActiveOrThrow(gatewayIdValue: string) { + const gateway = await this.prisma.customerGateway.findUnique({ + where: { id: gatewayIdValue }, + include: this.includeSummary() + }); + + if (!gateway || gateway.deletedAt) { + throw new NotFoundException({ code: 'CUSTOMER_GATEWAY_NOT_FOUND', message: 'Customer gateway not found.' }); + } + + return gateway; + } + + private includeSummary() { + return { + customer: { + select: { + id: true, + name: true + } + }, + _count: { + select: { + policies: { + where: { deletedAt: null } + } + } + } + } satisfies Prisma.CustomerGatewayInclude; + } + + private toSummary(gateway: { + id: string; + customerId: string; + name: string; + authMode: CustomerGatewayAuthMode; + sourceIp: string | null; + sipUsername: string | null; + sipDomain: string | null; + sipHa1: string | null; + status: CustomerGatewayStatus; + createdAt: Date; + updatedAt: Date; + customer: { name: string }; + _count: { policies: number }; + }): CustomerGatewaySummary { + return { + id: gateway.id, + customerId: gateway.customerId, + customerName: gateway.customer.name, + name: gateway.name, + authMode: gateway.authMode, + sourceIp: gateway.sourceIp, + sipUsername: gateway.sipUsername, + sipDomain: gateway.sipDomain, + hasSipCredential: Boolean(gateway.sipHa1), + status: gateway.status, + policyCount: gateway._count.policies, + createdAt: gateway.createdAt, + updatedAt: gateway.updatedAt + }; + } + + private handleUniqueConflict(error: unknown): void { + if (error && typeof error === 'object' && 'code' in error && (error as { code?: unknown }).code === 'P2002') { + throw new ConflictException({ code: 'CUSTOMER_GATEWAY_CONFLICT', message: 'Customer gateway name or SIP identity already exists.' }); + } + } +} diff --git a/apps/api/src/modules/customer-gateways/customer-gateways.service.ts b/apps/api/src/modules/customer-gateways/customer-gateways.service.ts new file mode 100644 index 0000000..87697de --- /dev/null +++ b/apps/api/src/modules/customer-gateways/customer-gateways.service.ts @@ -0,0 +1,196 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import crypto from 'node:crypto'; +import net from 'node:net'; +import { + CUSTOMER_GATEWAYS_REPOSITORY, + type CreateCustomerGatewayInput, + type CustomerGatewayAuthMode, + type CustomerGatewaySummary, + type CustomerGatewaysRepository, + type UpdateCustomerGatewayInput +} from './customer-gateways.repository.js'; + +interface CreateCustomerGatewayDto { + customerId?: unknown; + name?: unknown; + authMode?: unknown; + sourceIp?: unknown; + sipUsername?: unknown; + sipDomain?: unknown; + sipPassword?: unknown; +} + +interface UpdateCustomerGatewayDto { + customerId?: unknown; + name?: unknown; + authMode?: unknown; + sourceIp?: unknown; + sipUsername?: unknown; + sipDomain?: unknown; + sipPassword?: unknown; +} + +@Injectable() +export class CustomerGatewaysService { + constructor(@Inject(CUSTOMER_GATEWAYS_REPOSITORY) private readonly gateways: CustomerGatewaysRepository) {} + + list(query: { customerId?: unknown } = {}): Promise { + const customerId = query.customerId === undefined ? undefined : this.limitedString(query.customerId, 'customerId', 32); + return this.gateways.list(customerId); + } + + get(gatewayId: string): Promise { + return this.gateways.get(gatewayId); + } + + create(body: CreateCustomerGatewayDto, actorId?: string): Promise { + const authMode = this.authMode(body.authMode); + const sipIdentity = this.normalizeSipIdentity(authMode, body.sipUsername, body.sipDomain); + const sipPassword = this.requiredSipPassword(authMode, body.sipPassword); + const input: CreateCustomerGatewayInput = { + customerId: this.limitedString(body.customerId, 'customerId', 32), + name: this.limitedString(body.name, 'name', 120), + authMode, + sourceIp: this.normalizeSourceIp(authMode, body.sourceIp), + sipUsername: sipIdentity.sipUsername, + sipDomain: sipIdentity.sipDomain, + sipHa1: sipPassword ? this.sipHa1(sipIdentity.sipUsername, sipIdentity.sipDomain, sipPassword) : undefined, + actorId + }; + + return this.gateways.create(input); + } + + async update(gatewayId: string, body: UpdateCustomerGatewayDto, actorId?: string): Promise { + const current = await this.gateways.get(gatewayId); + const authMode = body.authMode === undefined ? current.authMode : this.authMode(body.authMode); + const requestedSipUsername = body.sipUsername === undefined ? current.sipUsername : this.nullableString(body.sipUsername, 'sipUsername', 120); + const requestedSipDomain = body.sipDomain === undefined ? current.sipDomain : this.nullableString(body.sipDomain, 'sipDomain', 160); + const sipIdentity = this.normalizeSipIdentity(authMode, requestedSipUsername, requestedSipDomain); + const sourceIp = body.sourceIp === undefined ? current.sourceIp : this.nullableString(body.sourceIp, 'sourceIp', 45); + const normalizedSourceIp = this.normalizeSourceIp(authMode, sourceIp); + const password = body.sipPassword === undefined ? undefined : this.requiredSipPassword(authMode, body.sipPassword); + + if (this.requiresSip(authMode)) { + const identityChanged = sipIdentity.sipUsername !== current.sipUsername || sipIdentity.sipDomain !== current.sipDomain; + if (!password && (identityChanged || !current.hasSipCredential)) { + throw new BadRequestException({ + code: 'SIP_PASSWORD_REQUIRED', + message: 'sipPassword is required when creating or changing SIP identity.' + }); + } + } + + const input: UpdateCustomerGatewayInput = { + customerId: body.customerId === undefined ? undefined : this.limitedString(body.customerId, 'customerId', 32), + name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120), + authMode, + sourceIp: normalizedSourceIp, + sipUsername: sipIdentity.sipUsername, + sipDomain: sipIdentity.sipDomain, + sipHa1: password ? this.sipHa1(sipIdentity.sipUsername, sipIdentity.sipDomain, password) : authMode === 'IP' ? null : undefined, + actorId + }; + + return this.gateways.update(gatewayId, input); + } + + enable(gatewayId: string, actorId?: string): Promise { + return this.gateways.setStatus(gatewayId, 'ENABLED', actorId); + } + + disable(gatewayId: string, actorId?: string): Promise { + return this.gateways.setStatus(gatewayId, 'DISABLED', actorId); + } + + private limitedString(value: unknown, field: string, maxLength: number): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` }); + } + + const trimmed = value.trim(); + if (trimmed.length > maxLength) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is too long.` }); + } + + return trimmed; + } + + private nullableString(value: unknown, field: string, maxLength: number): string | null { + if (value === null) { + return null; + } + + return this.limitedString(value, field, maxLength); + } + + private authMode(value: unknown): CustomerGatewayAuthMode { + if (value !== 'IP' && value !== 'SIP_DIGEST' && value !== 'MIXED') { + throw new BadRequestException({ code: 'AUTH_MODE_INVALID', message: 'Auth mode is invalid.' }); + } + + return value; + } + + private normalizeSourceIp(authMode: CustomerGatewayAuthMode, value: unknown): string | null { + if (!this.requiresIp(authMode)) { + return null; + } + + const sourceIp = this.limitedString(value, 'sourceIp', 45); + if (net.isIP(sourceIp) === 0) { + throw new BadRequestException({ code: 'SOURCE_IP_INVALID', message: 'sourceIp must be an IPv4 or IPv6 address.' }); + } + + return sourceIp; + } + + private normalizeSipIdentity(authMode: CustomerGatewayAuthMode, usernameValue: unknown, domainValue: unknown) { + if (!this.requiresSip(authMode)) { + return { + sipUsername: null, + sipDomain: null + }; + } + + const sipUsername = this.limitedString(usernameValue, 'sipUsername', 120); + const sipDomain = this.limitedString(domainValue, 'sipDomain', 160).toLowerCase(); + if (!/^[A-Za-z0-9_.:+-]+$/.test(sipUsername)) { + throw new BadRequestException({ code: 'SIP_USERNAME_INVALID', message: 'sipUsername contains invalid characters.' }); + } + if (!/^[A-Za-z0-9.-]+$/.test(sipDomain)) { + throw new BadRequestException({ code: 'SIP_DOMAIN_INVALID', message: 'sipDomain contains invalid characters.' }); + } + + return { sipUsername, sipDomain }; + } + + private requiredSipPassword(authMode: CustomerGatewayAuthMode, value: unknown): string | undefined { + if (!this.requiresSip(authMode)) { + return undefined; + } + + const password = this.limitedString(value, 'sipPassword', 128); + if (password.length < 12) { + throw new BadRequestException({ code: 'SIP_PASSWORD_WEAK', message: 'sipPassword must be at least 12 characters.' }); + } + + return password; + } + + private sipHa1(username: string | null, domain: string | null, password: string): string { + if (!username || !domain) { + throw new BadRequestException({ code: 'SIP_IDENTITY_REQUIRED', message: 'SIP identity is required.' }); + } + + return crypto.createHash('md5').update(`${username}:${domain}:${password}`, 'utf8').digest('hex'); + } + + private requiresIp(authMode: CustomerGatewayAuthMode): boolean { + return authMode === 'IP' || authMode === 'MIXED'; + } + + private requiresSip(authMode: CustomerGatewayAuthMode): boolean { + return authMode === 'SIP_DIGEST' || authMode === 'MIXED'; + } +} diff --git a/apps/api/src/modules/customers/customers.controller.ts b/apps/api/src/modules/customers/customers.controller.ts new file mode 100644 index 0000000..b74610a --- /dev/null +++ b/apps/api/src/modules/customers/customers.controller.ts @@ -0,0 +1,58 @@ +import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuditAction } from '../audit/audit.metadata.js'; +import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js'; +import { CustomersService } from './customers.service.js'; + +@ApiTags('customers') +@Controller('customers') +export class CustomersController { + constructor(@Inject(CustomersService) private readonly customersService: CustomersService) {} + + @Get() + @RequirePermissions('customers.view') + list() { + return this.customersService.list(); + } + + @Get(':id') + @RequirePermissions('customers.view') + get(@Param('id') id: string) { + return this.customersService.get(id); + } + + @Post() + @RequirePermissions('customers.manage') + @AuditAction({ module: 'customers', action: 'create', objectType: 'customer' }) + create(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.customersService.create(body as never, currentUser?.id); + } + + @Patch(':id') + @RequirePermissions('customers.manage') + @AuditAction({ module: 'customers', action: 'update', objectType: 'customer', objectIdParam: 'id' }) + update(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.customersService.update(id, body as never, currentUser?.id); + } + + @Post(':id/enable') + @RequirePermissions('customers.manage') + @AuditAction({ module: 'customers', action: 'enable', objectType: 'customer', objectIdParam: 'id' }) + enable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.customersService.enable(id, currentUser?.id); + } + + @Post(':id/disable') + @RequirePermissions('customers.manage') + @AuditAction({ module: 'customers', action: 'disable', objectType: 'customer', objectIdParam: 'id' }) + disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.customersService.disable(id, currentUser?.id); + } + + @Delete(':id') + @RequirePermissions('customers.manage') + @AuditAction({ module: 'customers', action: 'delete', objectType: 'customer', objectIdParam: 'id' }) + remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.customersService.remove(id, currentUser?.id); + } +} diff --git a/apps/api/src/modules/customers/customers.e2e.spec.ts b/apps/api/src/modules/customers/customers.e2e.spec.ts new file mode 100644 index 0000000..ff907ae --- /dev/null +++ b/apps/api/src/modules/customers/customers.e2e.spec.ts @@ -0,0 +1,256 @@ +import 'reflect-metadata'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import request from 'supertest'; +import { signAccessToken, type PermissionKey } from '@lisglosips/auth'; +import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js'; +import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js'; +import type { CurrentUser } from '../security/security.metadata.js'; +import { + CUSTOMERS_REPOSITORY, + type CreateCustomerInput, + type CustomerStatus, + type CustomerSummary, + type CustomersRepository, + type UpdateCustomerInput +} from './customers.repository.js'; + +class MemoryIdentityRepository implements IdentityRepository { + users = new Map(); + + async findCurrentUserById(userId: string): Promise { + return this.users.get(userId) ?? null; + } +} + +class MemoryAuditRepository implements AuditRepository { + entries: AuditEntryInput[] = []; + + async write(input: AuditEntryInput): Promise { + this.entries.push(input); + } +} + +class MemoryCustomersRepository implements CustomersRepository { + private readonly customers = new Map(); + + constructor() { + this.customers.set( + 'cus_seed', + this.summary({ + id: 'cus_seed', + name: 'Seed Customer', + status: 'ENABLED', + creditLimit: '100.000000', + minBalance: '5.000000', + gatewayCount: 2 + }) + ); + } + + async list(): Promise { + return [...this.customers.values()]; + } + + async get(customerId: string): Promise { + return this.customers.get(customerId) ?? this.summary({ id: customerId, name: 'Missing' }); + } + + async create(input: CreateCustomerInput): Promise { + const customer = this.summary({ + id: 'cus_created', + name: input.name, + contactName: input.contactName ?? null, + phone: input.phone ?? null, + email: input.email ?? null, + domain: input.domain ?? null, + status: input.status ?? 'ENABLED', + billingMode: input.billingMode, + creditLimit: input.creditLimit, + minBalance: input.minBalance, + notes: input.notes ?? null + }); + this.customers.set(customer.id, customer); + return customer; + } + + async update(customerId: string, input: UpdateCustomerInput): Promise { + const current = this.customers.get(customerId) ?? this.summary({ id: customerId, name: 'Updated Customer' }); + const updated: CustomerSummary = { + ...current, + name: input.name ?? current.name, + contactName: input.contactName === undefined ? current.contactName : input.contactName, + phone: input.phone === undefined ? current.phone : input.phone, + email: input.email === undefined ? current.email : input.email, + domain: input.domain === undefined ? current.domain : input.domain, + status: input.status ?? current.status, + billingMode: input.billingMode ?? current.billingMode, + creditLimit: input.creditLimit ?? current.creditLimit, + minBalance: input.minBalance ?? current.minBalance, + notes: input.notes === undefined ? current.notes : input.notes, + updatedAt: new Date('2026-06-21T01:00:00.000Z') + }; + this.customers.set(customerId, updated); + return updated; + } + + async setStatus(customerId: string, status: CustomerStatus): Promise { + return this.update(customerId, { status }); + } + + async softDelete(customerId: string): Promise { + return this.update(customerId, { status: 'DISABLED' }); + } + + private summary(input: { + id: string; + name: string; + contactName?: string | null; + phone?: string | null; + email?: string | null; + domain?: string | null; + status?: CustomerStatus; + billingMode?: 'PREPAID' | 'POSTPAID'; + creditLimit?: string; + minBalance?: string; + notes?: string | null; + gatewayCount?: number; + }): CustomerSummary { + return { + id: input.id, + name: input.name, + contactName: input.contactName ?? null, + phone: input.phone ?? null, + email: input.email ?? null, + domain: input.domain ?? null, + status: input.status ?? 'ENABLED', + billingMode: input.billingMode ?? 'PREPAID', + balance: '10.000000', + creditLimit: input.creditLimit ?? '0.000000', + minBalance: input.minBalance ?? '0.000000', + availableBalance: (10 + Number(input.creditLimit ?? '0')).toFixed(6), + notes: input.notes ?? null, + gatewayCount: input.gatewayCount ?? 0, + createdAt: new Date('2026-06-21T00:00:00.000Z'), + updatedAt: new Date('2026-06-21T00:00:00.000Z') + }; + } +} + +describe('S11 customers API', () => { + let app: NestFastifyApplication; + let audit: MemoryAuditRepository; + + const tokenFor = (userId: string) => + signAccessToken( + { + sub: userId, + username: userId, + roles: ['test'], + typ: 'access' + }, + { + secret: 'test-only-access-token-secret-min-32-bytes', + issuer: 'lisglosips-api', + audience: 'lisglosips-web', + ttlSeconds: 900 + } + ); + + beforeAll(async () => { + process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips'; + process.env.REDIS_URL = 'redis://127.0.0.1:6379/0'; + process.env.LISGLOSIPS_LOG_LEVEL = 'silent'; + process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes'; + + const identities = new MemoryIdentityRepository(); + audit = new MemoryAuditRepository(); + + identities.users.set('usr_admin', { + id: 'usr_admin', + username: 'admin', + roles: ['超级管理员'], + permissions: ['customers.view', 'customers.manage', 'audit.view'] as PermissionKey[] + }); + identities.users.set('usr_readonly', { + id: 'usr_readonly', + username: 'readonly', + roles: ['只读'], + permissions: ['customers.view'] as PermissionKey[] + }); + + const { AppModule } = await import('../app.module.js'); + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule] + }) + .overrideProvider(IDENTITY_REPOSITORY) + .useValue(identities) + .overrideProvider(AUDIT_REPOSITORY) + .useValue(audit) + .overrideProvider(CUSTOMERS_REPOSITORY) + .useValue(new MemoryCustomersRepository()) + .compile(); + + app = moduleFixture.createNestApplication(new FastifyAdapter({ logger: false })); + app.setGlobalPrefix('api/v2'); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('returns customer balance, credit, and gateway count to viewers', async () => { + const response = await request(app.getHttpServer()).get('/api/v2/customers').set('Authorization', `Bearer ${tokenFor('usr_readonly')}`).expect(200); + + expect(response.body[0]).toMatchObject({ + id: 'cus_seed', + balance: '10.000000', + creditLimit: '100.000000', + minBalance: '5.000000', + availableBalance: '110.000000', + gatewayCount: 2 + }); + }); + + it('rejects write operations without customers.manage', async () => { + await request(app.getHttpServer()) + .post('/api/v2/customers') + .set('Authorization', `Bearer ${tokenFor('usr_readonly')}`) + .send({ name: 'Rejected Customer' }) + .expect(403); + }); + + it('creates, disables, enables, and soft deletes customers with audit entries', async () => { + await request(app.getHttpServer()) + .post('/api/v2/customers') + .set('Authorization', `Bearer ${tokenFor('usr_admin')}`) + .send({ + name: 'Acme Telecom', + contactName: 'Ops', + creditLimit: '88.5', + minBalance: '2.25', + billingMode: 'POSTPAID' + }) + .expect(201) + .expect((response) => { + expect(response.body).toMatchObject({ + id: 'cus_created', + name: 'Acme Telecom', + creditLimit: '88.500000', + minBalance: '2.250000', + billingMode: 'POSTPAID' + }); + }); + + await request(app.getHttpServer()).post('/api/v2/customers/cus_created/disable').set('Authorization', `Bearer ${tokenFor('usr_admin')}`).expect(201); + await request(app.getHttpServer()).post('/api/v2/customers/cus_created/enable').set('Authorization', `Bearer ${tokenFor('usr_admin')}`).expect(201); + await request(app.getHttpServer()).delete('/api/v2/customers/cus_created').set('Authorization', `Bearer ${tokenFor('usr_admin')}`).expect(200); + + expect(audit.entries.some((entry) => entry.module === 'customers' && entry.action === 'create' && entry.result === 'SUCCESS')).toBe(true); + expect(audit.entries.some((entry) => entry.module === 'customers' && entry.action === 'disable' && entry.objectId === 'cus_created')).toBe(true); + expect(audit.entries.some((entry) => entry.module === 'customers' && entry.action === 'delete' && entry.objectId === 'cus_created')).toBe(true); + }); +}); diff --git a/apps/api/src/modules/customers/customers.module.ts b/apps/api/src/modules/customers/customers.module.ts new file mode 100644 index 0000000..3c5cd26 --- /dev/null +++ b/apps/api/src/modules/customers/customers.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { CustomersController } from './customers.controller.js'; +import { CUSTOMERS_REPOSITORY, PrismaCustomersRepository } from './customers.repository.js'; +import { CustomersService } from './customers.service.js'; + +@Module({ + controllers: [CustomersController], + providers: [ + CustomersService, + { + provide: CUSTOMERS_REPOSITORY, + useClass: PrismaCustomersRepository + } + ], + exports: [CustomersService] +}) +export class CustomersModule {} diff --git a/apps/api/src/modules/customers/customers.repository.ts b/apps/api/src/modules/customers/customers.repository.ts new file mode 100644 index 0000000..50532a7 --- /dev/null +++ b/apps/api/src/modules/customers/customers.repository.ts @@ -0,0 +1,264 @@ +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import crypto from 'node:crypto'; +import { Prisma } from '@lisglosips/database'; +import { PrismaService } from '../database/prisma.service.js'; + +export type CustomerStatus = 'ENABLED' | 'DISABLED'; +export type CustomerBillingMode = 'PREPAID' | 'POSTPAID'; + +export interface CustomerSummary { + id: string; + name: string; + contactName: string | null; + phone: string | null; + email: string | null; + domain: string | null; + status: CustomerStatus; + billingMode: CustomerBillingMode; + balance: string; + creditLimit: string; + minBalance: string; + availableBalance: string; + notes: string | null; + gatewayCount: number; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateCustomerInput { + name: string; + contactName?: string; + phone?: string; + email?: string; + domain?: string; + status?: CustomerStatus; + billingMode: CustomerBillingMode; + creditLimit: string; + minBalance: string; + notes?: string; + actorId?: string; +} + +export interface UpdateCustomerInput { + name?: string; + contactName?: string | null; + phone?: string | null; + email?: string | null; + domain?: string | null; + status?: CustomerStatus; + billingMode?: CustomerBillingMode; + creditLimit?: string; + minBalance?: string; + notes?: string | null; + actorId?: string; +} + +export interface CustomersRepository { + list(): Promise; + get(customerId: string): Promise; + create(input: CreateCustomerInput): Promise; + update(customerId: string, input: UpdateCustomerInput): Promise; + setStatus(customerId: string, status: CustomerStatus, actorId?: string): Promise; + softDelete(customerId: string, actorId?: string): Promise; +} + +export const CUSTOMERS_REPOSITORY = Symbol('CUSTOMERS_REPOSITORY'); + +function customerId(): string { + return `cus_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`; +} + +@Injectable() +export class PrismaCustomersRepository implements CustomersRepository { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async list(): Promise { + const customers = await this.prisma.customer.findMany({ + where: { deletedAt: null }, + orderBy: [{ createdAt: 'desc' }], + include: { + _count: { + select: { + gateways: { + where: { deletedAt: null } + } + } + } + } + }); + + return customers.map((customer) => this.toSummary(customer)); + } + + async get(customerIdValue: string): Promise { + return this.toSummary(await this.findActiveOrThrow(customerIdValue)); + } + + async create(input: CreateCustomerInput): Promise { + const customer = await this.prisma.customer.create({ + data: { + id: customerId(), + name: input.name, + contactName: input.contactName, + phone: input.phone, + email: input.email, + domain: input.domain, + status: input.status ?? 'ENABLED', + billingMode: input.billingMode, + creditLimit: new Prisma.Decimal(input.creditLimit), + minBalance: new Prisma.Decimal(input.minBalance), + notes: input.notes, + createdBy: input.actorId, + updatedBy: input.actorId + }, + include: { + _count: { + select: { + gateways: { + where: { deletedAt: null } + } + } + } + } + }); + + return this.toSummary(customer); + } + + async update(customerIdValue: string, input: UpdateCustomerInput): Promise { + await this.ensureExists(customerIdValue); + + const customer = await this.prisma.customer.update({ + where: { id: customerIdValue }, + data: { + name: input.name, + contactName: input.contactName, + phone: input.phone, + email: input.email, + domain: input.domain, + status: input.status, + billingMode: input.billingMode, + creditLimit: input.creditLimit === undefined ? undefined : new Prisma.Decimal(input.creditLimit), + minBalance: input.minBalance === undefined ? undefined : new Prisma.Decimal(input.minBalance), + notes: input.notes, + updatedBy: input.actorId, + version: { increment: 1 } + }, + include: { + _count: { + select: { + gateways: { + where: { deletedAt: null } + } + } + } + } + }); + + return this.toSummary(customer); + } + + async setStatus(customerIdValue: string, status: CustomerStatus, actorId?: string): Promise { + return this.update(customerIdValue, { status, actorId }); + } + + async softDelete(customerIdValue: string, actorId?: string): Promise { + const existing = await this.findActiveOrThrow(customerIdValue); + const linkedGateways = await this.prisma.customerGateway.count({ + where: { + customerId: customerIdValue, + deletedAt: null + } + }); + + if (linkedGateways > 0) { + throw new BadRequestException({ + code: 'CUSTOMER_HAS_GATEWAYS', + message: 'Customer with active gateways cannot be deleted.' + }); + } + + const customer = await this.prisma.customer.update({ + where: { id: existing.id }, + data: { + status: 'DISABLED', + deletedAt: new Date(), + updatedBy: actorId, + version: { increment: 1 } + }, + include: { + _count: { + select: { + gateways: { + where: { deletedAt: null } + } + } + } + } + }); + + return this.toSummary(customer); + } + + private async ensureExists(customerIdValue: string): Promise { + await this.findActiveOrThrow(customerIdValue); + } + + private async findActiveOrThrow(customerIdValue: string) { + const customer = await this.prisma.customer.findUnique({ + where: { id: customerIdValue }, + include: { + _count: { + select: { + gateways: { + where: { deletedAt: null } + } + } + } + } + }); + + if (!customer || customer.deletedAt) { + throw new NotFoundException({ code: 'CUSTOMER_NOT_FOUND', message: 'Customer not found.' }); + } + + return customer; + } + + private toSummary(customer: { + id: string; + name: string; + contactName: string | null; + phone: string | null; + email: string | null; + domain: string | null; + status: CustomerStatus; + billingMode: CustomerBillingMode; + balance: Prisma.Decimal; + creditLimit: Prisma.Decimal; + minBalance: Prisma.Decimal; + notes: string | null; + createdAt: Date; + updatedAt: Date; + _count: { gateways: number }; + }): CustomerSummary { + return { + id: customer.id, + name: customer.name, + contactName: customer.contactName, + phone: customer.phone, + email: customer.email, + domain: customer.domain, + status: customer.status, + billingMode: customer.billingMode, + balance: customer.balance.toFixed(6), + creditLimit: customer.creditLimit.toFixed(6), + minBalance: customer.minBalance.toFixed(6), + availableBalance: customer.balance.plus(customer.creditLimit).toFixed(6), + notes: customer.notes, + gatewayCount: customer._count.gateways, + createdAt: customer.createdAt, + updatedAt: customer.updatedAt + }; + } +} diff --git a/apps/api/src/modules/customers/customers.service.ts b/apps/api/src/modules/customers/customers.service.ts new file mode 100644 index 0000000..41a22be --- /dev/null +++ b/apps/api/src/modules/customers/customers.service.ts @@ -0,0 +1,152 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { + CUSTOMERS_REPOSITORY, + type CreateCustomerInput, + type CustomerBillingMode, + type CustomerStatus, + type CustomerSummary, + type CustomersRepository, + type UpdateCustomerInput +} from './customers.repository.js'; + +interface CreateCustomerDto { + name?: unknown; + contactName?: unknown; + phone?: unknown; + email?: unknown; + domain?: unknown; + status?: unknown; + billingMode?: unknown; + creditLimit?: unknown; + minBalance?: unknown; + notes?: unknown; +} + +interface UpdateCustomerDto { + name?: unknown; + contactName?: unknown; + phone?: unknown; + email?: unknown; + domain?: unknown; + status?: unknown; + billingMode?: unknown; + creditLimit?: unknown; + minBalance?: unknown; + notes?: unknown; +} + +@Injectable() +export class CustomersService { + constructor(@Inject(CUSTOMERS_REPOSITORY) private readonly customers: CustomersRepository) {} + + list(): Promise { + return this.customers.list(); + } + + get(customerId: string): Promise { + return this.customers.get(customerId); + } + + create(body: CreateCustomerDto, actorId?: string): Promise { + const input: CreateCustomerInput = { + name: this.limitedString(body.name, 'name', 120), + contactName: this.optionalString(body.contactName, 'contactName', 80), + phone: this.optionalString(body.phone, 'phone', 32), + email: this.optionalString(body.email, 'email', 160), + domain: this.optionalString(body.domain, 'domain', 160), + status: body.status === undefined ? 'ENABLED' : this.status(body.status), + billingMode: body.billingMode === undefined ? 'PREPAID' : this.billingMode(body.billingMode), + creditLimit: body.creditLimit === undefined ? '0.000000' : this.money(body.creditLimit, 'creditLimit'), + minBalance: body.minBalance === undefined ? '0.000000' : this.money(body.minBalance, 'minBalance'), + notes: this.optionalString(body.notes, 'notes', 500), + actorId + }; + + return this.customers.create(input); + } + + update(customerId: string, body: UpdateCustomerDto, actorId?: string): Promise { + const input: UpdateCustomerInput = { + name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120), + contactName: body.contactName === undefined ? undefined : this.nullableString(body.contactName, 'contactName', 80), + phone: body.phone === undefined ? undefined : this.nullableString(body.phone, 'phone', 32), + email: body.email === undefined ? undefined : this.nullableString(body.email, 'email', 160), + domain: body.domain === undefined ? undefined : this.nullableString(body.domain, 'domain', 160), + status: body.status === undefined ? undefined : this.status(body.status), + billingMode: body.billingMode === undefined ? undefined : this.billingMode(body.billingMode), + creditLimit: body.creditLimit === undefined ? undefined : this.money(body.creditLimit, 'creditLimit'), + minBalance: body.minBalance === undefined ? undefined : this.money(body.minBalance, 'minBalance'), + notes: body.notes === undefined ? undefined : this.nullableString(body.notes, 'notes', 500), + actorId + }; + + return this.customers.update(customerId, input); + } + + enable(customerId: string, actorId?: string): Promise { + return this.customers.setStatus(customerId, 'ENABLED', actorId); + } + + disable(customerId: string, actorId?: string): Promise { + return this.customers.setStatus(customerId, 'DISABLED', actorId); + } + + remove(customerId: string, actorId?: string): Promise { + return this.customers.softDelete(customerId, actorId); + } + + private limitedString(value: unknown, field: string, maxLength: number): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` }); + } + + const trimmed = value.trim(); + if (trimmed.length > maxLength) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is too long.` }); + } + + return trimmed; + } + + private optionalString(value: unknown, field: string, maxLength: number): string | undefined { + if (value === undefined) { + return undefined; + } + + return this.limitedString(value, field, maxLength); + } + + private nullableString(value: unknown, field: string, maxLength: number): string | null { + if (value === null) { + return null; + } + + return this.limitedString(value, field, maxLength); + } + + private status(value: unknown): CustomerStatus { + if (value !== 'ENABLED' && value !== 'DISABLED') { + throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' }); + } + + return value; + } + + private billingMode(value: unknown): CustomerBillingMode { + if (value !== 'PREPAID' && value !== 'POSTPAID') { + throw new BadRequestException({ code: 'BILLING_MODE_INVALID', message: 'Billing mode is invalid.' }); + } + + return value; + } + + private money(value: unknown, field: string): string { + const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : ''; + if (!/^\d{1,14}(?:\.\d{1,6})?$/.test(raw)) { + throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be a non-negative decimal with up to 6 places.` }); + } + + const [integerPart, fractionPart = ''] = raw.split('.'); + return `${integerPart}.${fractionPart.padEnd(6, '0')}`; + } +} diff --git a/apps/api/src/modules/dashboard/dashboard.controller.ts b/apps/api/src/modules/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..f0ec895 --- /dev/null +++ b/apps/api/src/modules/dashboard/dashboard.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Inject, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { RequirePermissions } from '../security/security.metadata.js'; +import { DashboardService } from './dashboard.service.js'; + +@ApiTags('dashboard') +@Controller('dashboard') +export class DashboardController { + constructor(@Inject(DashboardService) private readonly dashboardService: DashboardService) {} + + @Get('summary') + @RequirePermissions('dashboard.view') + summary() { + return this.dashboardService.summary(); + } + + @Get('trends') + @RequirePermissions('dashboard.view') + trends(@Query() query: { hours?: string; bucketMinutes?: string }) { + return this.dashboardService.trends(query); + } +} diff --git a/apps/api/src/modules/dashboard/dashboard.module.ts b/apps/api/src/modules/dashboard/dashboard.module.ts new file mode 100644 index 0000000..da137a5 --- /dev/null +++ b/apps/api/src/modules/dashboard/dashboard.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { DashboardController } from './dashboard.controller.js'; +import { PrismaDashboardRepository, DASHBOARD_REPOSITORY } from './dashboard.repository.js'; +import { DashboardService } from './dashboard.service.js'; + +@Module({ + controllers: [DashboardController], + providers: [ + DashboardService, + { + provide: DASHBOARD_REPOSITORY, + useClass: PrismaDashboardRepository + } + ] +}) +export class DashboardModule {} diff --git a/apps/api/src/modules/dashboard/dashboard.repository.ts b/apps/api/src/modules/dashboard/dashboard.repository.ts new file mode 100644 index 0000000..888f8d2 --- /dev/null +++ b/apps/api/src/modules/dashboard/dashboard.repository.ts @@ -0,0 +1,194 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@lisglosips/database'; +import { PrismaService } from '../database/prisma.service.js'; + +export interface DashboardCdrRow { + startedAt: Date; + durationSec: number; + sipCode: number; +} + +export interface DashboardRatedRow { + startedAt: Date; + customerFee: string; + vendorCost: string; + grossProfit: string; +} + +export interface DashboardFailureCodeRow { + sipCode: number; + count: number; +} + +export interface DashboardGatewayFailureRow { + vendorGatewayId: string; + vendorGatewayName: string; + vendorName: string | null; + host: string; + port: number; + status: string; + failedCalls: number; + totalCalls: number; +} + +export interface DashboardSummarySnapshot { + cdrs: DashboardCdrRow[]; + ratedCdrs: DashboardRatedRow[]; + failureCodes: DashboardFailureCodeRow[]; + abnormalGateways: DashboardGatewayFailureRow[]; + activeCustomers: number; + activeCustomerGateways: number; + activeVendorGateways: number; + pendingQuality: number; +} + +export const DASHBOARD_REPOSITORY = Symbol('DASHBOARD_REPOSITORY'); + +export interface DashboardRepository { + summaryWindow(start: Date, end: Date): Promise; + trendWindow(start: Date, end: Date): Promise<{ cdrs: DashboardCdrRow[]; ratedCdrs: DashboardRatedRow[] }>; +} + +@Injectable() +export class PrismaDashboardRepository implements DashboardRepository { + constructor(private readonly prisma: PrismaService) {} + + async summaryWindow(start: Date, end: Date): Promise { + const [cdrs, ratedCdrs, activeCustomers, activeCustomerGateways, activeVendorGateways, pendingQuality] = await Promise.all([ + this.prisma.rawCdr.findMany({ + where: { startedAt: { gte: start, lt: end } }, + select: { startedAt: true, durationSec: true, sipCode: true } + }), + this.prisma.ratedCdr.findMany({ + where: { rawCdr: { startedAt: { gte: start, lt: end } } }, + select: { + customerFee: true, + vendorCost: true, + grossProfit: true, + rawCdr: { select: { startedAt: true } } + } + }), + this.prisma.customer.count({ where: { status: 'ENABLED', deletedAt: null } }), + this.prisma.customerGateway.count({ where: { status: 'ENABLED', deletedAt: null } }), + this.prisma.vendorGateway.count({ where: { status: 'ENABLED', deletedAt: null } }), + this.prisma.recording.count({ where: { status: 'READY', reviews: { none: {} } } }) + ]); + + const failureCodes = failureCodesFrom(cdrs); + const abnormalGateways = await this.gatewayFailures(start, end); + + return { + cdrs, + ratedCdrs: ratedCdrs.map((row) => ({ + startedAt: row.rawCdr.startedAt, + customerFee: decimalString(row.customerFee), + vendorCost: decimalString(row.vendorCost), + grossProfit: decimalString(row.grossProfit) + })), + failureCodes, + abnormalGateways, + activeCustomers, + activeCustomerGateways, + activeVendorGateways, + pendingQuality + }; + } + + async trendWindow(start: Date, end: Date): Promise<{ cdrs: DashboardCdrRow[]; ratedCdrs: DashboardRatedRow[] }> { + const [cdrs, ratedCdrs] = await Promise.all([ + this.prisma.rawCdr.findMany({ + where: { startedAt: { gte: start, lt: end } }, + select: { startedAt: true, durationSec: true, sipCode: true } + }), + this.prisma.ratedCdr.findMany({ + where: { rawCdr: { startedAt: { gte: start, lt: end } } }, + select: { + customerFee: true, + vendorCost: true, + grossProfit: true, + rawCdr: { select: { startedAt: true } } + } + }) + ]); + + return { + cdrs, + ratedCdrs: ratedCdrs.map((row) => ({ + startedAt: row.rawCdr.startedAt, + customerFee: decimalString(row.customerFee), + vendorCost: decimalString(row.vendorCost), + grossProfit: decimalString(row.grossProfit) + })) + }; + } + + private async gatewayFailures(start: Date, end: Date): Promise { + const cdrs = await this.prisma.rawCdr.findMany({ + where: { startedAt: { gte: start, lt: end }, vendorGatewayId: { not: null } }, + select: { + vendorGatewayId: true, + sipCode: true, + vendorGateway: { + select: { + id: true, + name: true, + host: true, + port: true, + status: true, + vendor: { select: { name: true } } + } + } + } + }); + + const grouped = new Map }>(); + for (const cdr of cdrs) { + if (!cdr.vendorGatewayId || !cdr.vendorGateway) { + continue; + } + const current = grouped.get(cdr.vendorGatewayId) ?? { failedCalls: 0, totalCalls: 0, gateway: cdr.vendorGateway }; + current.totalCalls += 1; + if (!isAnswered(cdr.sipCode)) { + current.failedCalls += 1; + } + grouped.set(cdr.vendorGatewayId, current); + } + + return [...grouped.entries()] + .map(([vendorGatewayId, item]) => ({ + vendorGatewayId, + vendorGatewayName: item.gateway.name, + vendorName: item.gateway.vendor?.name ?? null, + host: item.gateway.host, + port: item.gateway.port, + status: item.gateway.status, + failedCalls: item.failedCalls, + totalCalls: item.totalCalls + })) + .filter((item) => item.failedCalls > 0) + .sort((left, right) => right.failedCalls - left.failedCalls || right.totalCalls - left.totalCalls) + .slice(0, 10); + } +} + +function failureCodesFrom(cdrs: DashboardCdrRow[]): DashboardFailureCodeRow[] { + const counts = new Map(); + for (const cdr of cdrs) { + if (isAnswered(cdr.sipCode)) { + continue; + } + counts.set(cdr.sipCode, (counts.get(cdr.sipCode) ?? 0) + 1); + } + return [...counts.entries()] + .map(([sipCode, count]) => ({ sipCode, count })) + .sort((left, right) => right.count - left.count || left.sipCode - right.sipCode) + .slice(0, 10); +} + +function decimalString(value: Prisma.Decimal): string { + return value.toFixed(6); +} + +function isAnswered(sipCode: number): boolean { + return sipCode >= 200 && sipCode < 300; +} diff --git a/apps/api/src/modules/dashboard/dashboard.service.spec.ts b/apps/api/src/modules/dashboard/dashboard.service.spec.ts new file mode 100644 index 0000000..8600d23 --- /dev/null +++ b/apps/api/src/modules/dashboard/dashboard.service.spec.ts @@ -0,0 +1,78 @@ +import { BadRequestException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; +import { buildTrendBuckets, callMetrics, DashboardService, startOfShanghaiDayUtc } from './dashboard.service.js'; +import type { DashboardRepository } from './dashboard.repository.js'; + +describe('dashboard service', () => { + it('uses Asia/Shanghai day boundary for today summary', async () => { + const calls: Array<{ start: Date; end: Date }> = []; + const repository = { + summaryWindow: async (start: Date, end: Date) => { + calls.push({ start, end }); + return { + cdrs: [], + ratedCdrs: [], + failureCodes: [], + abnormalGateways: [], + activeCustomers: 0, + activeCustomerGateways: 0, + activeVendorGateways: 0, + pendingQuality: 0 + }; + } + } as unknown as DashboardRepository; + const service = new DashboardService(repository); + const now = new Date('2026-06-21T15:30:00.000Z'); + + await service.summary(now); + + expect(calls[0]?.start.toISOString()).toBe('2026-06-20T16:00:00.000Z'); + expect(startOfShanghaiDayUtc(now).toISOString()).toBe('2026-06-20T16:00:00.000Z'); + }); + + it('computes call metrics and answer rate without floating point money exposure', () => { + expect( + callMetrics([ + { startedAt: new Date('2026-06-21T00:00:00.000Z'), durationSec: 10, sipCode: 200 }, + { startedAt: new Date('2026-06-21T00:01:00.000Z'), durationSec: 0, sipCode: 486 }, + { startedAt: new Date('2026-06-21T00:02:00.000Z'), durationSec: 0, sipCode: 503 } + ]) + ).toEqual({ + totalCalls: 3, + answeredCalls: 1, + failedCalls: 2, + answerRate: '0.3333', + totalDurationSec: 10 + }); + }); + + it('builds fixed trend buckets', () => { + const buckets = buildTrendBuckets( + new Date('2026-06-21T00:00:00.000Z'), + new Date('2026-06-21T01:00:00.000Z'), + 30, + [ + { startedAt: new Date('2026-06-21T00:10:00.000Z'), durationSec: 30, sipCode: 200 }, + { startedAt: new Date('2026-06-21T00:45:00.000Z'), durationSec: 0, sipCode: 480 } + ], + [ + { startedAt: new Date('2026-06-21T00:10:00.000Z'), customerFee: '0.100000', vendorCost: '0.030000', grossProfit: '0.070000' } + ] + ); + + expect(buckets).toHaveLength(2); + expect(buckets[0]).toMatchObject({ + calls: { totalCalls: 1, answeredCalls: 1, failedCalls: 0 }, + money: { customerFee: '0.100000', vendorCost: '0.030000', grossProfit: '0.070000' } + }); + expect(buckets[1]).toMatchObject({ + calls: { totalCalls: 1, answeredCalls: 0, failedCalls: 1 } + }); + }); + + it('validates trend query bounds', async () => { + const service = new DashboardService({} as DashboardRepository); + await expect(service.trends({ hours: '0' })).rejects.toThrow(BadRequestException); + await expect(service.trends({ bucketMinutes: '10' })).rejects.toThrow(BadRequestException); + }); +}); diff --git a/apps/api/src/modules/dashboard/dashboard.service.ts b/apps/api/src/modules/dashboard/dashboard.service.ts new file mode 100644 index 0000000..e3f682d --- /dev/null +++ b/apps/api/src/modules/dashboard/dashboard.service.ts @@ -0,0 +1,177 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { + DASHBOARD_REPOSITORY, + type DashboardCdrRow, + type DashboardRatedRow, + type DashboardRepository +} from './dashboard.repository.js'; + +const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000; +const DEFAULT_TREND_HOURS = 24; +const DEFAULT_BUCKET_MINUTES = 60; +const ALLOWED_BUCKET_MINUTES = new Set([5, 15, 60]); + +export interface DashboardCallMetrics { + totalCalls: number; + answeredCalls: number; + failedCalls: number; + answerRate: string; + totalDurationSec: number; +} + +export interface DashboardMoneyMetrics { + customerFee: string; + vendorCost: string; + grossProfit: string; +} + +export interface DashboardTrendBucket { + start: string; + end: string; + calls: DashboardCallMetrics; + money: DashboardMoneyMetrics; +} + +@Injectable() +export class DashboardService { + constructor(@Inject(DASHBOARD_REPOSITORY) private readonly dashboard: DashboardRepository) {} + + async summary(now = new Date()) { + const start = startOfShanghaiDayUtc(now); + const snapshot = await this.dashboard.summaryWindow(start, now); + return { + generatedAt: now.toISOString(), + window: { start: start.toISOString(), end: now.toISOString(), timezone: 'Asia/Shanghai' }, + calls: callMetrics(snapshot.cdrs), + money: moneyMetrics(snapshot.ratedCdrs), + realtime: { + onlineCalls: 0, + registeredUsers: 0, + source: 'not_configured' + }, + entities: { + activeCustomers: snapshot.activeCustomers, + activeCustomerGateways: snapshot.activeCustomerGateways, + activeVendorGateways: snapshot.activeVendorGateways + }, + quality: { + pendingReviews: snapshot.pendingQuality + }, + failureCodes: snapshot.failureCodes, + abnormalGateways: snapshot.abnormalGateways + }; + } + + async trends(query: { hours?: unknown; bucketMinutes?: unknown } = {}, now = new Date()) { + const hours = query.hours === undefined ? DEFAULT_TREND_HOURS : this.integer(query.hours, 'hours', 1, 168); + const bucketMinutes = + query.bucketMinutes === undefined ? DEFAULT_BUCKET_MINUTES : this.integer(query.bucketMinutes, 'bucketMinutes', 5, 60); + if (!ALLOWED_BUCKET_MINUTES.has(bucketMinutes)) { + throw new BadRequestException({ code: 'DASHBOARD_BUCKET_INVALID', message: 'bucketMinutes must be one of 5, 15 or 60.' }); + } + + const bucketMs = bucketMinutes * 60 * 1000; + const end = new Date(Math.ceil(now.getTime() / bucketMs) * bucketMs); + const start = new Date(end.getTime() - hours * 60 * 60 * 1000); + const rows = await this.dashboard.trendWindow(start, end); + + return { + generatedAt: now.toISOString(), + window: { start: start.toISOString(), end: end.toISOString(), hours, bucketMinutes }, + buckets: buildTrendBuckets(start, end, bucketMinutes, rows.cdrs, rows.ratedCdrs) + }; + } + + private integer(value: unknown, field: string, min: number, max: number): number { + const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number.parseInt(value, 10) : NaN; + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new BadRequestException({ code: 'INTEGER_INVALID', message: `${field} must be an integer from ${min} to ${max}.` }); + } + return parsed; + } +} + +export function startOfShanghaiDayUtc(now: Date): Date { + const shifted = new Date(now.getTime() + SHANGHAI_OFFSET_MS); + shifted.setUTCHours(0, 0, 0, 0); + return new Date(shifted.getTime() - SHANGHAI_OFFSET_MS); +} + +export function buildTrendBuckets( + start: Date, + end: Date, + bucketMinutes: number, + cdrs: DashboardCdrRow[], + ratedCdrs: DashboardRatedRow[] +): DashboardTrendBucket[] { + const bucketMs = bucketMinutes * 60 * 1000; + const bucketCount = Math.ceil((end.getTime() - start.getTime()) / bucketMs); + const buckets = Array.from({ length: bucketCount }, (_, index) => { + const bucketStart = new Date(start.getTime() + index * bucketMs); + return { + start: bucketStart, + end: new Date(bucketStart.getTime() + bucketMs), + cdrs: [] as DashboardCdrRow[], + ratedCdrs: [] as DashboardRatedRow[] + }; + }); + + for (const cdr of cdrs) { + const index = Math.floor((cdr.startedAt.getTime() - start.getTime()) / bucketMs); + if (index >= 0 && index < buckets.length) { + buckets[index].cdrs.push(cdr); + } + } + + for (const ratedCdr of ratedCdrs) { + const index = Math.floor((ratedCdr.startedAt.getTime() - start.getTime()) / bucketMs); + if (index >= 0 && index < buckets.length) { + buckets[index].ratedCdrs.push(ratedCdr); + } + } + + return buckets.map((bucket) => ({ + start: bucket.start.toISOString(), + end: bucket.end.toISOString(), + calls: callMetrics(bucket.cdrs), + money: moneyMetrics(bucket.ratedCdrs) + })); +} + +export function callMetrics(cdrs: DashboardCdrRow[]): DashboardCallMetrics { + const answeredCalls = cdrs.filter((cdr) => isAnswered(cdr.sipCode)).length; + const totalCalls = cdrs.length; + return { + totalCalls, + answeredCalls, + failedCalls: totalCalls - answeredCalls, + answerRate: ratioString(answeredCalls, totalCalls), + totalDurationSec: cdrs.reduce((sum, cdr) => sum + cdr.durationSec, 0) + }; +} + +export function moneyMetrics(rows: DashboardRatedRow[]): DashboardMoneyMetrics { + const customerFee = rows.reduce((sum, row) => sum + Number.parseFloat(row.customerFee), 0); + const vendorCost = rows.reduce((sum, row) => sum + Number.parseFloat(row.vendorCost), 0); + const grossProfit = rows.reduce((sum, row) => sum + Number.parseFloat(row.grossProfit), 0); + return { + customerFee: moneyString(customerFee), + vendorCost: moneyString(vendorCost), + grossProfit: moneyString(grossProfit) + }; +} + +function ratioString(numerator: number, denominator: number): string { + if (denominator === 0) { + return '0.0000'; + } + return (numerator / denominator).toFixed(4); +} + +function moneyString(value: number): string { + return value.toFixed(6); +} + +function isAnswered(sipCode: number): boolean { + return sipCode >= 200 && sipCode < 300; +} diff --git a/apps/api/src/modules/database/database.module.ts b/apps/api/src/modules/database/database.module.ts new file mode 100644 index 0000000..b97a851 --- /dev/null +++ b/apps/api/src/modules/database/database.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service.js'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService] +}) +export class DatabaseModule {} diff --git a/apps/api/src/modules/database/prisma.service.ts b/apps/api/src/modules/database/prisma.service.ts new file mode 100644 index 0000000..b5197b7 --- /dev/null +++ b/apps/api/src/modules/database/prisma.service.ts @@ -0,0 +1,9 @@ +import { Injectable, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@lisglosips/database'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleDestroy { + async onModuleDestroy(): Promise { + await this.$disconnect(); + } +} diff --git a/apps/api/src/modules/health/health.controller.ts b/apps/api/src/modules/health/health.controller.ts new file mode 100644 index 0000000..011c5b6 --- /dev/null +++ b/apps/api/src/modules/health/health.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, Inject } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import type { HealthCheckResponse } from '@lisglosips/contracts'; +import { Public } from '../security/security.metadata.js'; +import { HealthService } from './health.service.js'; + +@ApiTags('health') +@Public() +@Controller('health') +export class HealthController { + constructor(@Inject(HealthService) private readonly healthService: HealthService) {} + + @Get('live') + @ApiOkResponse({ description: 'Process liveness check.' }) + live(): HealthCheckResponse { + return this.healthService.live(); + } + + @Get('ready') + @ApiOkResponse({ description: 'Configuration and dependency readiness check.' }) + ready(): HealthCheckResponse { + return this.healthService.ready(); + } +} diff --git a/apps/api/src/modules/health/health.module.ts b/apps/api/src/modules/health/health.module.ts new file mode 100644 index 0000000..dd1dcc4 --- /dev/null +++ b/apps/api/src/modules/health/health.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller.js'; +import { HealthService } from './health.service.js'; + +@Module({ + controllers: [HealthController], + providers: [HealthService] +}) +export class HealthModule {} diff --git a/apps/api/src/modules/health/health.service.spec.ts b/apps/api/src/modules/health/health.service.spec.ts new file mode 100644 index 0000000..a96d815 --- /dev/null +++ b/apps/api/src/modules/health/health.service.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { ConfigService } from '@nestjs/config'; +import { HealthService } from './health.service.js'; + +describe('HealthService', () => { + it('reports ready when required connection strings are configured', () => { + const config = new ConfigService({ + service: { name: 'api-test' }, + database: { url: 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips' }, + redis: { url: 'redis://127.0.0.1:6379/0' } + }); + + const result = new HealthService(config).ready(); + + expect(result.status).toBe('ok'); + expect(result.service).toBe('api-test'); + expect(result.checks.database).toBe('ok'); + expect(result.checks.redis).toBe('ok'); + }); +}); diff --git a/apps/api/src/modules/health/health.service.ts b/apps/api/src/modules/health/health.service.ts new file mode 100644 index 0000000..16e1cec --- /dev/null +++ b/apps/api/src/modules/health/health.service.ts @@ -0,0 +1,34 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { HealthCheckResponse, HealthStatus } from '@lisglosips/contracts'; + +@Injectable() +export class HealthService { + constructor(@Inject(ConfigService) private readonly configService: ConfigService) {} + + live(): HealthCheckResponse { + return this.response('ok', { + process: 'ok' + }); + } + + ready(): HealthCheckResponse { + const hasDatabaseUrl = Boolean(this.configService.get('database.url')); + const hasRedisUrl = Boolean(this.configService.get('redis.url')); + + return this.response(hasDatabaseUrl && hasRedisUrl ? 'ok' : 'degraded', { + config: 'ok', + database: hasDatabaseUrl ? 'ok' : 'degraded', + redis: hasRedisUrl ? 'ok' : 'degraded' + }); + } + + private response(status: HealthStatus, checks: Record): HealthCheckResponse { + return { + status, + service: this.configService.get('service.name') ?? 'api', + timestamp: new Date().toISOString(), + checks + }; + } +} diff --git a/apps/api/src/modules/landing-line-groups/landing-line-groups.controller.ts b/apps/api/src/modules/landing-line-groups/landing-line-groups.controller.ts new file mode 100644 index 0000000..1fcd6ec --- /dev/null +++ b/apps/api/src/modules/landing-line-groups/landing-line-groups.controller.ts @@ -0,0 +1,86 @@ +import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuditAction } from '../audit/audit.metadata.js'; +import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js'; +import { LandingLineGroupsService } from './landing-line-groups.service.js'; + +@ApiTags('landing-line-groups') +@Controller('landing-line-groups') +export class LandingLineGroupsController { + constructor(@Inject(LandingLineGroupsService) private readonly lineGroupsService: LandingLineGroupsService) {} + + @Get() + @RequirePermissions('line_groups.view') + list() { + return this.lineGroupsService.list(); + } + + @Get(':id') + @RequirePermissions('line_groups.view') + get(@Param('id') id: string) { + return this.lineGroupsService.get(id); + } + + @Post() + @RequirePermissions('line_groups.manage') + @AuditAction({ module: 'line_groups', action: 'create', objectType: 'landing_line_group' }) + create(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.lineGroupsService.create(body as never, currentUser?.id); + } + + @Patch(':id') + @RequirePermissions('line_groups.manage') + @AuditAction({ module: 'line_groups', action: 'update', objectType: 'landing_line_group', objectIdParam: 'id' }) + update(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.lineGroupsService.update(id, body as never, currentUser?.id); + } + + @Post(':id/enable') + @RequirePermissions('line_groups.manage') + @AuditAction({ module: 'line_groups', action: 'enable', objectType: 'landing_line_group', objectIdParam: 'id' }) + enable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.lineGroupsService.enable(id, currentUser?.id); + } + + @Post(':id/disable') + @RequirePermissions('line_groups.manage') + @AuditAction({ module: 'line_groups', action: 'disable', objectType: 'landing_line_group', objectIdParam: 'id' }) + disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.lineGroupsService.disable(id, currentUser?.id); + } + + @Delete(':id') + @RequirePermissions('line_groups.manage') + @AuditAction({ module: 'line_groups', action: 'delete', objectType: 'landing_line_group', objectIdParam: 'id' }) + remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.lineGroupsService.remove(id, currentUser?.id); + } + + @Post(':id/items') + @RequirePermissions('line_groups.manage') + @AuditAction({ module: 'line_groups', action: 'item_create', objectType: 'landing_line_group', objectIdParam: 'id' }) + addItem(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.lineGroupsService.addItem(id, body as never, currentUser?.id); + } + + @Patch('items/:itemId') + @RequirePermissions('line_groups.manage') + @AuditAction({ module: 'line_groups', action: 'item_update', objectType: 'landing_line_group_item', objectIdParam: 'itemId' }) + updateItem(@Param('itemId') itemId: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.lineGroupsService.updateItem(itemId, body as never, currentUser?.id); + } + + @Delete(':id/items/:gatewayId') + @RequirePermissions('line_groups.manage') + @AuditAction({ module: 'line_groups', action: 'item_delete', objectType: 'landing_line_group', objectIdParam: 'id' }) + removeItem(@Param('id') id: string, @Param('gatewayId') gatewayId: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.lineGroupsService.removeItem(id, gatewayId, currentUser?.id); + } + + @Post(':id/items/reorder') + @RequirePermissions('line_groups.manage') + @AuditAction({ module: 'line_groups', action: 'item_reorder', objectType: 'landing_line_group', objectIdParam: 'id' }) + reorderItems(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.lineGroupsService.reorderItems(id, body as never, currentUser?.id); + } +} diff --git a/apps/api/src/modules/landing-line-groups/landing-line-groups.e2e.spec.ts b/apps/api/src/modules/landing-line-groups/landing-line-groups.e2e.spec.ts new file mode 100644 index 0000000..fe13f34 --- /dev/null +++ b/apps/api/src/modules/landing-line-groups/landing-line-groups.e2e.spec.ts @@ -0,0 +1,354 @@ +import 'reflect-metadata'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import request from 'supertest'; +import { signAccessToken, type PermissionKey } from '@lisglosips/auth'; +import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js'; +import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js'; +import type { CurrentUser } from '../security/security.metadata.js'; +import { + LANDING_LINE_GROUPS_REPOSITORY, + type AddLineGroupItemInput, + type LandingLineGroupsRepository, + type LineGroupItemSummary, + type LineGroupStatus, + type LineGroupSummary, + type UpdateLineGroupInput, + type UpdateLineGroupItemInput +} from './landing-line-groups.repository.js'; + +class MemoryIdentityRepository implements IdentityRepository { + users = new Map(); + + async findCurrentUserById(userId: string): Promise { + return this.users.get(userId) ?? null; + } +} + +class MemoryAuditRepository implements AuditRepository { + entries: AuditEntryInput[] = []; + + async write(input: AuditEntryInput): Promise { + this.entries.push(input); + } +} + +class MemoryLandingLineGroupsRepository implements LandingLineGroupsRepository { + private readonly groups = new Map(); + outboxEvents = 0; + + constructor() { + this.groups.set( + 'llg_seed', + this.summary({ + id: 'llg_seed', + name: 'Seed Group', + items: [ + this.item({ id: 'item_a', lineGroupId: 'llg_seed', vendorGatewayId: 'vgw_a', priority: 1, concurrencyCap: 80 }), + this.item({ id: 'item_b', lineGroupId: 'llg_seed', vendorGatewayId: 'vgw_b', priority: 2, concurrencyCap: 20, status: 'DISABLED' }) + ] + }) + ); + } + + async list(): Promise { + return [...this.groups.values()]; + } + + async get(lineGroupId: string): Promise { + return this.groups.get(lineGroupId) ?? this.summary({ id: lineGroupId, name: 'Missing Group' }); + } + + async create(input: { name: string; status: LineGroupStatus; notes?: string | null }): Promise { + const group = this.summary({ + id: 'llg_created', + name: input.name, + status: input.status, + notes: input.notes ?? null + }); + this.groups.set(group.id, group); + this.outboxEvents += 1; + return group; + } + + async update(lineGroupId: string, input: UpdateLineGroupInput): Promise { + const current = await this.get(lineGroupId); + const updated = this.summary({ + ...current, + name: input.name ?? current.name, + status: input.status ?? current.status, + notes: input.notes === undefined ? current.notes : input.notes, + items: current.items + }); + this.groups.set(lineGroupId, updated); + this.outboxEvents += 1; + return updated; + } + + async setStatus(lineGroupId: string, status: LineGroupStatus): Promise { + return this.update(lineGroupId, { status }); + } + + async softDelete(lineGroupId: string): Promise { + return this.update(lineGroupId, { status: 'DISABLED' }); + } + + async addItem(input: AddLineGroupItemInput): Promise { + const current = await this.get(input.lineGroupId); + const item = this.item({ + id: `item_${current.items.length + 1}`, + lineGroupId: input.lineGroupId, + vendorGatewayId: input.vendorGatewayId, + priority: input.priority ?? current.items.length + 1, + weight: input.weight, + concurrencyCap: input.concurrencyCap, + status: input.status + }); + const updated = this.summary({ ...current, items: [...current.items, item] }); + this.groups.set(input.lineGroupId, updated); + this.outboxEvents += 1; + return updated; + } + + async updateItem(itemId: string, input: UpdateLineGroupItemInput): Promise { + for (const group of this.groups.values()) { + const item = group.items.find((candidate) => candidate.id === itemId); + if (!item) { + continue; + } + const updatedItem = this.item({ + ...item, + priority: input.priority ?? item.priority, + weight: input.weight ?? item.weight, + concurrencyCap: input.concurrencyCap ?? item.concurrencyCap, + status: input.status ?? item.status + }); + const updated = this.summary({ + ...group, + items: group.items.map((candidate) => (candidate.id === itemId ? updatedItem : candidate)) + }); + this.groups.set(group.id, updated); + this.outboxEvents += 1; + return updatedItem; + } + return this.item({ id: itemId, lineGroupId: 'missing', vendorGatewayId: 'missing', priority: 1 }); + } + + async removeItem(lineGroupId: string, vendorGatewayId: string): Promise { + const current = await this.get(lineGroupId); + const updated = this.summary({ + ...current, + items: current.items.filter((item) => item.vendorGatewayId !== vendorGatewayId) + }); + this.groups.set(lineGroupId, updated); + this.outboxEvents += 1; + return updated; + } + + async reorderItems(lineGroupId: string, itemIds: string[]): Promise { + const current = await this.get(lineGroupId); + const currentIds = current.items.map((item) => item.id).sort(); + const requestedIds = [...itemIds].sort(); + if (currentIds.length !== requestedIds.length || currentIds.some((id, index) => id !== requestedIds[index])) { + throw new Error('test repository reorder mismatch'); + } + const itemsById = new Map(current.items.map((item) => [item.id, item])); + const updated = this.summary({ + ...current, + items: itemIds.map((id, index) => this.item({ ...itemsById.get(id)!, priority: index + 1 })) + }); + this.groups.set(lineGroupId, updated); + this.outboxEvents += 1; + return updated; + } + + private summary(input: Partial & { id: string; name: string; items?: LineGroupItemSummary[] }): LineGroupSummary { + const items = input.items ?? []; + const enabledItems = items.filter((item) => item.status === 'ENABLED'); + return { + id: input.id, + name: input.name, + status: input.status ?? 'ENABLED', + notes: input.notes ?? null, + itemCount: items.length, + enabledItemCount: enabledItems.length, + concurrencyCapSum: enabledItems.reduce((sum, item) => sum + item.concurrencyCap, 0), + gatewayConcurrencyLimitSum: enabledItems.reduce((sum, item) => sum + item.vendorGatewayConcurrencyLimit, 0), + policyCount: input.policyCount ?? 0, + items, + createdAt: new Date('2026-06-21T07:00:00.000Z'), + updatedAt: new Date('2026-06-21T07:00:00.000Z') + }; + } + + private item(input: Partial & { id: string; lineGroupId: string; vendorGatewayId: string; priority: number }): LineGroupItemSummary { + return { + id: input.id, + lineGroupId: input.lineGroupId, + vendorGatewayId: input.vendorGatewayId, + vendorGatewayName: input.vendorGatewayName ?? `Gateway ${input.vendorGatewayId}`, + vendorId: input.vendorId ?? 'ven_seed', + vendorName: input.vendorName ?? 'Seed Vendor', + vendorGatewayStatus: input.vendorGatewayStatus ?? 'ENABLED', + vendorGatewayConcurrencyLimit: input.vendorGatewayConcurrencyLimit ?? 100, + vendorGatewayCpsLimit: input.vendorGatewayCpsLimit ?? 10, + priority: input.priority, + weight: input.weight ?? 1, + concurrencyCap: input.concurrencyCap ?? 0, + status: input.status ?? 'ENABLED', + createdAt: new Date('2026-06-21T07:00:00.000Z'), + updatedAt: new Date('2026-06-21T07:00:00.000Z') + }; + } +} + +describe('S17 landing line groups API', () => { + let app: NestFastifyApplication; + let audit: MemoryAuditRepository; + let repository: MemoryLandingLineGroupsRepository; + + const tokenFor = (userId: string) => + signAccessToken( + { + sub: userId, + username: userId, + roles: ['test'], + typ: 'access' + }, + { + secret: 'test-only-access-token-secret-min-32-bytes', + issuer: 'lisglosips-api', + audience: 'lisglosips-web', + ttlSeconds: 900 + } + ); + + beforeAll(async () => { + process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips'; + process.env.REDIS_URL = 'redis://127.0.0.1:6379/0'; + process.env.LISGLOSIPS_LOG_LEVEL = 'silent'; + process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes'; + + const identities = new MemoryIdentityRepository(); + audit = new MemoryAuditRepository(); + repository = new MemoryLandingLineGroupsRepository(); + + identities.users.set('usr_ops', { + id: 'usr_ops', + username: 'ops', + roles: ['运营管理员'], + permissions: ['line_groups.view', 'line_groups.manage'] as PermissionKey[] + }); + identities.users.set('usr_viewer', { + id: 'usr_viewer', + username: 'viewer', + roles: ['只读'], + permissions: ['line_groups.view'] as PermissionKey[] + }); + + const { AppModule } = await import('../app.module.js'); + const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(IDENTITY_REPOSITORY) + .useValue(identities) + .overrideProvider(AUDIT_REPOSITORY) + .useValue(audit) + .overrideProvider(LANDING_LINE_GROUPS_REPOSITORY) + .useValue(repository) + .compile(); + + app = moduleFixture.createNestApplication(new FastifyAdapter({ logger: false })); + app.setGlobalPrefix('api/v2'); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('lists line groups with member counts and concurrency summaries', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v2/landing-line-groups') + .set('Authorization', `Bearer ${tokenFor('usr_viewer')}`) + .expect(200); + + expect(response.body[0]).toMatchObject({ + id: 'llg_seed', + itemCount: 2, + enabledItemCount: 1, + concurrencyCapSum: 80, + gatewayConcurrencyLimitSum: 100 + }); + }); + + it('rejects writes without line_groups.manage', async () => { + await request(app.getHttpServer()) + .post('/api/v2/landing-line-groups') + .set('Authorization', `Bearer ${tokenFor('usr_viewer')}`) + .send({ name: 'Denied Group' }) + .expect(403); + }); + + it('creates and maintains group members with audit entries', async () => { + await request(app.getHttpServer()) + .post('/api/v2/landing-line-groups') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ name: 'Primary Routing', notes: 'core vendors' }) + .expect(201) + .expect((response) => { + expect(response.body).toMatchObject({ id: 'llg_created', name: 'Primary Routing', itemCount: 0 }); + }); + + await request(app.getHttpServer()) + .post('/api/v2/landing-line-groups/llg_created/items') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ vendorGatewayId: 'vgw_primary', priority: 1, weight: 2, concurrencyCap: 60 }) + .expect(201) + .expect((response) => { + expect(response.body).toMatchObject({ itemCount: 1, concurrencyCapSum: 60 }); + }); + + await request(app.getHttpServer()) + .patch('/api/v2/landing-line-groups/items/item_1') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ concurrencyCap: 40, status: 'DISABLED' }) + .expect(200) + .expect((response) => { + expect(response.body).toMatchObject({ id: 'item_1', concurrencyCap: 40, status: 'DISABLED' }); + }); + + await request(app.getHttpServer()) + .delete('/api/v2/landing-line-groups/llg_created/items/vgw_primary') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .expect(200) + .expect((response) => { + expect(response.body).toMatchObject({ itemCount: 0 }); + }); + + expect(repository.outboxEvents).toBeGreaterThanOrEqual(4); + expect(audit.entries.some((entry) => entry.module === 'line_groups' && entry.action === 'create')).toBe(true); + expect(audit.entries.some((entry) => entry.module === 'line_groups' && entry.action === 'item_delete')).toBe(true); + }); + + it('validates members and supports reorder, enable, disable, and delete', async () => { + await request(app.getHttpServer()) + .post('/api/v2/landing-line-groups/llg_seed/items') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ vendorGatewayId: 'vgw_bad', weight: 0 }) + .expect(400); + + await request(app.getHttpServer()) + .post('/api/v2/landing-line-groups/llg_seed/items/reorder') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ itemIds: ['item_b', 'item_a'] }) + .expect(201) + .expect((response) => { + expect(response.body.items.map((item: { id: string }) => item.id)).toEqual(['item_b', 'item_a']); + }); + + await request(app.getHttpServer()).post('/api/v2/landing-line-groups/llg_seed/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201); + await request(app.getHttpServer()).post('/api/v2/landing-line-groups/llg_seed/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201); + await request(app.getHttpServer()).delete('/api/v2/landing-line-groups/llg_seed').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200); + }); +}); diff --git a/apps/api/src/modules/landing-line-groups/landing-line-groups.module.ts b/apps/api/src/modules/landing-line-groups/landing-line-groups.module.ts new file mode 100644 index 0000000..9f9021f --- /dev/null +++ b/apps/api/src/modules/landing-line-groups/landing-line-groups.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { LandingLineGroupsController } from './landing-line-groups.controller.js'; +import { LANDING_LINE_GROUPS_REPOSITORY, PrismaLandingLineGroupsRepository } from './landing-line-groups.repository.js'; +import { LandingLineGroupsService } from './landing-line-groups.service.js'; + +@Module({ + controllers: [LandingLineGroupsController], + providers: [ + LandingLineGroupsService, + { + provide: LANDING_LINE_GROUPS_REPOSITORY, + useClass: PrismaLandingLineGroupsRepository + } + ], + exports: [LandingLineGroupsService] +}) +export class LandingLineGroupsModule {} diff --git a/apps/api/src/modules/landing-line-groups/landing-line-groups.repository.ts b/apps/api/src/modules/landing-line-groups/landing-line-groups.repository.ts new file mode 100644 index 0000000..5766182 --- /dev/null +++ b/apps/api/src/modules/landing-line-groups/landing-line-groups.repository.ts @@ -0,0 +1,474 @@ +import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import crypto from 'node:crypto'; +import { Prisma } from '@lisglosips/database'; +import { PrismaService } from '../database/prisma.service.js'; + +export type LineGroupStatus = 'ENABLED' | 'DISABLED'; + +export interface LineGroupItemSummary { + id: string; + lineGroupId: string; + vendorGatewayId: string; + vendorGatewayName: string; + vendorId: string; + vendorName: string; + vendorGatewayStatus: LineGroupStatus; + vendorGatewayConcurrencyLimit: number; + vendorGatewayCpsLimit: number; + priority: number; + weight: number; + concurrencyCap: number; + status: LineGroupStatus; + createdAt: Date; + updatedAt: Date; +} + +export interface LineGroupSummary { + id: string; + name: string; + status: LineGroupStatus; + notes: string | null; + itemCount: number; + enabledItemCount: number; + concurrencyCapSum: number; + gatewayConcurrencyLimitSum: number; + policyCount: number; + items: LineGroupItemSummary[]; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateLineGroupInput { + name: string; + status: LineGroupStatus; + notes?: string | null; + actorId?: string; +} + +export interface UpdateLineGroupInput { + name?: string; + status?: LineGroupStatus; + notes?: string | null; + actorId?: string; +} + +export interface AddLineGroupItemInput { + lineGroupId: string; + vendorGatewayId: string; + priority?: number; + weight: number; + concurrencyCap: number; + status: LineGroupStatus; + actorId?: string; +} + +export interface UpdateLineGroupItemInput { + priority?: number; + weight?: number; + concurrencyCap?: number; + status?: LineGroupStatus; + actorId?: string; +} + +export interface LandingLineGroupsRepository { + list(): Promise; + get(lineGroupId: string): Promise; + create(input: CreateLineGroupInput): Promise; + update(lineGroupId: string, input: UpdateLineGroupInput): Promise; + setStatus(lineGroupId: string, status: LineGroupStatus, actorId?: string): Promise; + softDelete(lineGroupId: string, actorId?: string): Promise; + addItem(input: AddLineGroupItemInput): Promise; + updateItem(itemId: string, input: UpdateLineGroupItemInput): Promise; + removeItem(lineGroupId: string, vendorGatewayId: string, actorId?: string): Promise; + reorderItems(lineGroupId: string, itemIds: string[], actorId?: string): Promise; +} + +export const LANDING_LINE_GROUPS_REPOSITORY = Symbol('LANDING_LINE_GROUPS_REPOSITORY'); + +type LineGroupItemRecord = { + id: string; + lineGroupId: string; + vendorGatewayId: string; + priority: number; + weight: number; + concurrencyCap: number; + status: LineGroupStatus; + createdAt: Date; + updatedAt: Date; + vendorGateway: { + id: string; + vendorId: string; + name: string; + status: LineGroupStatus; + concurrencyLimit: number; + cpsLimit: number; + vendor: { name: string }; + }; +}; + +type LineGroupRecord = { + id: string; + name: string; + status: LineGroupStatus; + notes: string | null; + createdAt: Date; + updatedAt: Date; + items: LineGroupItemRecord[]; + _count: { policies: number }; +}; + +function lineGroupId(): string { + return `llg_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`; +} + +function lineGroupItemId(): string { + return `llgi_${crypto.randomUUID().replaceAll('-', '').slice(0, 27)}`; +} + +function outboxId(): string { + return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`; +} + +@Injectable() +export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepository { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async list(): Promise { + const groups = await this.prisma.landingLineGroup.findMany({ + where: { deletedAt: null }, + orderBy: [{ createdAt: 'desc' }], + include: this.includeSummary() + }); + return groups.map((group) => this.toSummary(group)); + } + + async get(lineGroupIdValue: string): Promise { + return this.toSummary(await this.findActiveOrThrow(lineGroupIdValue)); + } + + async create(input: CreateLineGroupInput): Promise { + try { + const group = await this.prisma.$transaction(async (tx) => { + const created = await tx.landingLineGroup.create({ + data: { + id: lineGroupId(), + name: input.name, + status: input.status, + notes: input.notes, + createdBy: input.actorId, + updatedBy: input.actorId + }, + include: this.includeSummary() + }); + await this.enqueueConfigOutbox(tx, created.id, 'line_group.changed'); + return created; + }); + return this.toSummary(group); + } catch (error) { + this.handleUniqueConflict(error); + throw error; + } + } + + async update(lineGroupIdValue: string, input: UpdateLineGroupInput): Promise { + await this.findActiveOrThrow(lineGroupIdValue); + try { + const group = await this.prisma.$transaction(async (tx) => { + const updated = await tx.landingLineGroup.update({ + where: { id: lineGroupIdValue }, + data: { + name: input.name, + status: input.status, + notes: input.notes, + updatedBy: input.actorId, + version: { increment: 1 } + }, + include: this.includeSummary() + }); + await this.enqueueConfigOutbox(tx, updated.id, 'line_group.changed'); + return updated; + }); + return this.toSummary(group); + } catch (error) { + this.handleUniqueConflict(error); + throw error; + } + } + + async setStatus(lineGroupIdValue: string, status: LineGroupStatus, actorId?: string): Promise { + return this.update(lineGroupIdValue, { status, actorId }); + } + + async softDelete(lineGroupIdValue: string, actorId?: string): Promise { + await this.findActiveOrThrow(lineGroupIdValue); + const linkedPolicies = await this.prisma.customerGatewayPolicy.count({ + where: { lineGroupId: lineGroupIdValue, deletedAt: null } + }); + if (linkedPolicies > 0) { + throw new BadRequestException({ code: 'LINE_GROUP_IN_USE', message: 'Line group referenced by active policies cannot be deleted.' }); + } + + const group = await this.prisma.$transaction(async (tx) => { + const deleted = await tx.landingLineGroup.update({ + where: { id: lineGroupIdValue }, + data: { + status: 'DISABLED', + deletedAt: new Date(), + updatedBy: actorId, + version: { increment: 1 } + }, + include: this.includeSummary() + }); + await this.enqueueConfigOutbox(tx, deleted.id, 'line_group.deleted'); + return deleted; + }); + return this.toSummary(group); + } + + async addItem(input: AddLineGroupItemInput): Promise { + await this.findActiveOrThrow(input.lineGroupId); + await this.ensureVendorGateway(input.vendorGatewayId); + const priority = input.priority ?? (await this.nextPriority(input.lineGroupId)); + + try { + await this.prisma.$transaction(async (tx) => { + await tx.landingLineGroupItem.create({ + data: { + id: lineGroupItemId(), + lineGroupId: input.lineGroupId, + vendorGatewayId: input.vendorGatewayId, + priority, + weight: input.weight, + concurrencyCap: input.concurrencyCap, + status: input.status, + createdBy: input.actorId, + updatedBy: input.actorId + } + }); + await this.enqueueConfigOutbox(tx, input.lineGroupId, 'line_group_item.changed'); + }); + return this.get(input.lineGroupId); + } catch (error) { + this.handleUniqueConflict(error); + throw error; + } + } + + async updateItem(itemId: string, input: UpdateLineGroupItemInput): Promise { + const existing = await this.findItemOrThrow(itemId); + try { + const item = await this.prisma.$transaction(async (tx) => { + const updated = await tx.landingLineGroupItem.update({ + where: { id: itemId }, + data: { + priority: input.priority, + weight: input.weight, + concurrencyCap: input.concurrencyCap, + status: input.status, + updatedBy: input.actorId, + version: { increment: 1 } + }, + include: this.includeItem() + }); + await this.enqueueConfigOutbox(tx, existing.lineGroupId, 'line_group_item.changed'); + return updated; + }); + return this.toItemSummary(item); + } catch (error) { + this.handleUniqueConflict(error); + throw error; + } + } + + async removeItem(lineGroupIdValue: string, vendorGatewayId: string, actorId?: string): Promise { + await this.findActiveOrThrow(lineGroupIdValue); + const item = await this.prisma.landingLineGroupItem.findUnique({ + where: { lineGroupId_vendorGatewayId: { lineGroupId: lineGroupIdValue, vendorGatewayId } }, + select: { id: true } + }); + if (!item) { + throw new NotFoundException({ code: 'LINE_GROUP_ITEM_NOT_FOUND', message: 'Line group item not found.' }); + } + + await this.prisma.$transaction(async (tx) => { + await tx.landingLineGroupItem.delete({ where: { id: item.id } }); + await tx.landingLineGroup.update({ + where: { id: lineGroupIdValue }, + data: { updatedBy: actorId, version: { increment: 1 } } + }); + await this.enqueueConfigOutbox(tx, lineGroupIdValue, 'line_group_item.deleted'); + }); + return this.get(lineGroupIdValue); + } + + async reorderItems(lineGroupIdValue: string, itemIds: string[], actorId?: string): Promise { + await this.findActiveOrThrow(lineGroupIdValue); + const current = await this.prisma.landingLineGroupItem.findMany({ + where: { lineGroupId: lineGroupIdValue }, + select: { id: true } + }); + const currentIds = current.map((item) => item.id).sort(); + const requestedIds = [...itemIds].sort(); + if (currentIds.length !== requestedIds.length || currentIds.some((id, index) => id !== requestedIds[index])) { + throw new BadRequestException({ code: 'LINE_GROUP_ITEM_REORDER_SET_MISMATCH', message: 'Reorder must include every item exactly once.' }); + } + if (new Set(itemIds).size !== itemIds.length) { + throw new BadRequestException({ code: 'LINE_GROUP_ITEM_REORDER_DUPLICATE', message: 'Item ids must be unique.' }); + } + + await this.prisma.$transaction(async (tx) => { + for (let index = 0; index < itemIds.length; index += 1) { + await tx.landingLineGroupItem.update({ + where: { id: itemIds[index] }, + data: { + priority: -(index + 1), + updatedBy: actorId, + version: { increment: 1 } + } + }); + } + for (let index = 0; index < itemIds.length; index += 1) { + await tx.landingLineGroupItem.update({ + where: { id: itemIds[index] }, + data: { priority: index + 1 } + }); + } + await tx.landingLineGroup.update({ + where: { id: lineGroupIdValue }, + data: { updatedBy: actorId, version: { increment: 1 } } + }); + await this.enqueueConfigOutbox(tx, lineGroupIdValue, 'line_group_items.reordered'); + }); + return this.get(lineGroupIdValue); + } + + private includeItem() { + return { + vendorGateway: { + include: { + vendor: { select: { name: true } } + } + } + } satisfies Prisma.LandingLineGroupItemInclude; + } + + private includeSummary() { + return { + items: { + orderBy: [{ priority: 'asc' }], + include: this.includeItem() + }, + _count: { + select: { + policies: { + where: { deletedAt: null } + } + } + } + } satisfies Prisma.LandingLineGroupInclude; + } + + private async ensureVendorGateway(vendorGatewayId: string): Promise { + const gateway = await this.prisma.vendorGateway.findUnique({ + where: { id: vendorGatewayId }, + select: { id: true, deletedAt: true } + }); + if (!gateway || gateway.deletedAt) { + throw new NotFoundException({ code: 'VENDOR_GATEWAY_NOT_FOUND', message: 'Vendor gateway not found.' }); + } + } + + private async findActiveOrThrow(lineGroupIdValue: string) { + const group = await this.prisma.landingLineGroup.findUnique({ + where: { id: lineGroupIdValue }, + include: this.includeSummary() + }); + if (!group || group.deletedAt) { + throw new NotFoundException({ code: 'LINE_GROUP_NOT_FOUND', message: 'Landing line group not found.' }); + } + return group; + } + + private async findItemOrThrow(itemId: string) { + const item = await this.prisma.landingLineGroupItem.findUnique({ + where: { id: itemId }, + include: this.includeItem() + }); + if (!item) { + throw new NotFoundException({ code: 'LINE_GROUP_ITEM_NOT_FOUND', message: 'Line group item not found.' }); + } + const group = await this.prisma.landingLineGroup.findUnique({ + where: { id: item.lineGroupId }, + select: { deletedAt: true } + }); + if (!group || group.deletedAt) { + throw new NotFoundException({ code: 'LINE_GROUP_NOT_FOUND', message: 'Landing line group not found.' }); + } + return item; + } + + private async nextPriority(lineGroupIdValue: string): Promise { + const aggregate = await this.prisma.landingLineGroupItem.aggregate({ + where: { lineGroupId: lineGroupIdValue }, + _max: { priority: true } + }); + return (aggregate._max.priority ?? 0) + 1; + } + + private async enqueueConfigOutbox(tx: Prisma.TransactionClient, lineGroupIdValue: string, eventType: string): Promise { + await tx.outboxEvent.create({ + data: { + id: outboxId(), + aggregateType: 'line_group_config', + aggregateId: lineGroupIdValue, + eventType, + payload: { lineGroupId: lineGroupIdValue, eventType } + } + }); + } + + private toSummary(group: LineGroupRecord): LineGroupSummary { + const items = group.items.map((item) => this.toItemSummary(item)); + const enabledItems = items.filter((item) => item.status === 'ENABLED'); + return { + id: group.id, + name: group.name, + status: group.status, + notes: group.notes, + itemCount: items.length, + enabledItemCount: enabledItems.length, + concurrencyCapSum: enabledItems.reduce((sum, item) => sum + item.concurrencyCap, 0), + gatewayConcurrencyLimitSum: enabledItems.reduce((sum, item) => sum + item.vendorGatewayConcurrencyLimit, 0), + policyCount: group._count.policies, + items, + createdAt: group.createdAt, + updatedAt: group.updatedAt + }; + } + + private toItemSummary(item: LineGroupItemRecord): LineGroupItemSummary { + return { + id: item.id, + lineGroupId: item.lineGroupId, + vendorGatewayId: item.vendorGatewayId, + vendorGatewayName: item.vendorGateway.name, + vendorId: item.vendorGateway.vendorId, + vendorName: item.vendorGateway.vendor.name, + vendorGatewayStatus: item.vendorGateway.status, + vendorGatewayConcurrencyLimit: item.vendorGateway.concurrencyLimit, + vendorGatewayCpsLimit: item.vendorGateway.cpsLimit, + priority: item.priority, + weight: item.weight, + concurrencyCap: item.concurrencyCap, + status: item.status, + createdAt: item.createdAt, + updatedAt: item.updatedAt + }; + } + + private handleUniqueConflict(error: unknown): void { + if (error && typeof error === 'object' && 'code' in error && (error as { code?: unknown }).code === 'P2002') { + throw new ConflictException({ code: 'LINE_GROUP_CONFLICT', message: 'Line group name, member gateway, or priority already exists.' }); + } + } +} diff --git a/apps/api/src/modules/landing-line-groups/landing-line-groups.service.ts b/apps/api/src/modules/landing-line-groups/landing-line-groups.service.ts new file mode 100644 index 0000000..d2969ce --- /dev/null +++ b/apps/api/src/modules/landing-line-groups/landing-line-groups.service.ts @@ -0,0 +1,166 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { + LANDING_LINE_GROUPS_REPOSITORY, + type AddLineGroupItemInput, + type CreateLineGroupInput, + type LandingLineGroupsRepository, + type LineGroupItemSummary, + type LineGroupStatus, + type LineGroupSummary, + type UpdateLineGroupInput, + type UpdateLineGroupItemInput +} from './landing-line-groups.repository.js'; + +interface CreateLineGroupDto { + name?: unknown; + status?: unknown; + notes?: unknown; +} + +interface UpdateLineGroupDto { + name?: unknown; + status?: unknown; + notes?: unknown; +} + +interface AddItemDto { + vendorGatewayId?: unknown; + priority?: unknown; + weight?: unknown; + concurrencyCap?: unknown; + status?: unknown; +} + +interface UpdateItemDto { + priority?: unknown; + weight?: unknown; + concurrencyCap?: unknown; + status?: unknown; +} + +interface ReorderItemsDto { + itemIds?: unknown; +} + +@Injectable() +export class LandingLineGroupsService { + constructor(@Inject(LANDING_LINE_GROUPS_REPOSITORY) private readonly lineGroups: LandingLineGroupsRepository) {} + + list(): Promise { + return this.lineGroups.list(); + } + + get(lineGroupId: string): Promise { + return this.lineGroups.get(lineGroupId); + } + + create(body: CreateLineGroupDto, actorId?: string): Promise { + const input: CreateLineGroupInput = { + name: this.limitedString(body.name, 'name', 120), + status: body.status === undefined ? 'ENABLED' : this.status(body.status), + notes: this.optionalString(body.notes, 'notes', 500), + actorId + }; + return this.lineGroups.create(input); + } + + update(lineGroupId: string, body: UpdateLineGroupDto, actorId?: string): Promise { + const input: UpdateLineGroupInput = { + name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120), + status: body.status === undefined ? undefined : this.status(body.status), + notes: body.notes === undefined ? undefined : this.nullableString(body.notes, 'notes', 500), + actorId + }; + return this.lineGroups.update(lineGroupId, input); + } + + enable(lineGroupId: string, actorId?: string): Promise { + return this.lineGroups.setStatus(lineGroupId, 'ENABLED', actorId); + } + + disable(lineGroupId: string, actorId?: string): Promise { + return this.lineGroups.setStatus(lineGroupId, 'DISABLED', actorId); + } + + remove(lineGroupId: string, actorId?: string): Promise { + return this.lineGroups.softDelete(lineGroupId, actorId); + } + + addItem(lineGroupId: string, body: AddItemDto, actorId?: string): Promise { + const input: AddLineGroupItemInput = { + lineGroupId, + vendorGatewayId: this.limitedString(body.vendorGatewayId, 'vendorGatewayId', 32), + priority: body.priority === undefined ? undefined : this.positiveInteger(body.priority, 'priority', 1, 10000), + weight: body.weight === undefined ? 1 : this.positiveInteger(body.weight, 'weight', 1, 1000), + concurrencyCap: body.concurrencyCap === undefined ? 0 : this.positiveInteger(body.concurrencyCap, 'concurrencyCap', 0, 100000), + status: body.status === undefined ? 'ENABLED' : this.status(body.status), + actorId + }; + return this.lineGroups.addItem(input); + } + + updateItem(itemId: string, body: UpdateItemDto, actorId?: string): Promise { + const input: UpdateLineGroupItemInput = { + priority: body.priority === undefined ? undefined : this.positiveInteger(body.priority, 'priority', 1, 10000), + weight: body.weight === undefined ? undefined : this.positiveInteger(body.weight, 'weight', 1, 1000), + concurrencyCap: body.concurrencyCap === undefined ? undefined : this.positiveInteger(body.concurrencyCap, 'concurrencyCap', 0, 100000), + status: body.status === undefined ? undefined : this.status(body.status), + actorId + }; + return this.lineGroups.updateItem(itemId, input); + } + + removeItem(lineGroupId: string, vendorGatewayId: string, actorId?: string): Promise { + return this.lineGroups.removeItem(lineGroupId, vendorGatewayId, actorId); + } + + reorderItems(lineGroupId: string, body: ReorderItemsDto, actorId?: string): Promise { + if (!Array.isArray(body.itemIds) || body.itemIds.length === 0) { + throw new BadRequestException({ code: 'LINE_GROUP_ITEM_IDS_INVALID', message: 'itemIds must be a non-empty array.' }); + } + return this.lineGroups.reorderItems( + lineGroupId, + body.itemIds.map((id) => this.limitedString(id, 'itemIds', 32)), + actorId + ); + } + + private limitedString(value: unknown, field: string, maxLength: number): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` }); + } + const trimmed = value.trim(); + if (trimmed.length > maxLength) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is too long.` }); + } + return trimmed; + } + + private optionalString(value: unknown, field: string, maxLength: number): string | undefined { + if (value === undefined) { + return undefined; + } + return this.limitedString(value, field, maxLength); + } + + private nullableString(value: unknown, field: string, maxLength: number): string | null { + if (value === null) { + return null; + } + return this.limitedString(value, field, maxLength); + } + + private positiveInteger(value: unknown, field: string, min: number, max: number): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) { + throw new BadRequestException({ code: 'INTEGER_INVALID', message: `${field} must be an integer from ${min} to ${max}.` }); + } + return value; + } + + private status(value: unknown): LineGroupStatus { + if (value !== 'ENABLED' && value !== 'DISABLED') { + throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' }); + } + return value; + } +} diff --git a/apps/api/src/modules/quality/quality.controller.ts b/apps/api/src/modules/quality/quality.controller.ts new file mode 100644 index 0000000..45cac89 --- /dev/null +++ b/apps/api/src/modules/quality/quality.controller.ts @@ -0,0 +1,58 @@ +import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuditAction } from '../audit/audit.metadata.js'; +import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js'; +import { QualityService } from './quality.service.js'; + +@ApiTags('quality') +@Controller('quality') +export class QualityController { + constructor(@Inject(QualityService) private readonly qualityService: QualityService) {} + + @Get('rules') + @RequirePermissions('quality.view') + listRules() { + return this.qualityService.listRules(); + } + + @Get('rules/:id') + @RequirePermissions('quality.view') + getRule(@Param('id') id: string) { + return this.qualityService.getRule(id); + } + + @Post('rules') + @RequirePermissions('quality.manage') + @AuditAction({ module: 'quality', action: 'rule_create', objectType: 'quality_sampling_rule' }) + createRule(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.qualityService.createRule(body as never, currentUser?.id); + } + + @Patch('rules/:id') + @RequirePermissions('quality.manage') + @AuditAction({ module: 'quality', action: 'rule_update', objectType: 'quality_sampling_rule', objectIdParam: 'id' }) + updateRule(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.qualityService.updateRule(id, body as never, currentUser?.id); + } + + @Post('rules/:id/enable') + @RequirePermissions('quality.manage') + @AuditAction({ module: 'quality', action: 'rule_enable', objectType: 'quality_sampling_rule', objectIdParam: 'id' }) + enableRule(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.qualityService.enableRule(id, currentUser?.id); + } + + @Post('rules/:id/disable') + @RequirePermissions('quality.manage') + @AuditAction({ module: 'quality', action: 'rule_disable', objectType: 'quality_sampling_rule', objectIdParam: 'id' }) + disableRule(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.qualityService.disableRule(id, currentUser?.id); + } + + @Delete('rules/:id') + @RequirePermissions('quality.manage') + @AuditAction({ module: 'quality', action: 'rule_delete', objectType: 'quality_sampling_rule', objectIdParam: 'id' }) + deleteRule(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.qualityService.deleteRule(id, currentUser?.id); + } +} diff --git a/apps/api/src/modules/quality/quality.module.ts b/apps/api/src/modules/quality/quality.module.ts new file mode 100644 index 0000000..710e942 --- /dev/null +++ b/apps/api/src/modules/quality/quality.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { QualityController } from './quality.controller.js'; +import { PrismaQualityRepository, QUALITY_REPOSITORY } from './quality.repository.js'; +import { QualityService } from './quality.service.js'; + +@Module({ + controllers: [QualityController], + providers: [ + QualityService, + { + provide: QUALITY_REPOSITORY, + useClass: PrismaQualityRepository + } + ], + exports: [QualityService] +}) +export class QualityModule {} diff --git a/apps/api/src/modules/quality/quality.repository.ts b/apps/api/src/modules/quality/quality.repository.ts new file mode 100644 index 0000000..d3a73a8 --- /dev/null +++ b/apps/api/src/modules/quality/quality.repository.ts @@ -0,0 +1,231 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import crypto from 'node:crypto'; +import { Prisma } from '@lisglosips/database'; +import { PrismaService } from '../database/prisma.service.js'; + +export type QualityRuleStatus = 'ENABLED' | 'DISABLED'; + +export interface QualityRuleSummary { + id: string; + name: string; + customerId: string | null; + customerName: string | null; + lineGroupId: string | null; + lineGroupName: string | null; + ratio: string; + status: QualityRuleStatus; + effectiveAt: Date; + expiresAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateQualityRuleInput { + name: string; + customerId?: string | null; + lineGroupId?: string | null; + ratio: string; + status: QualityRuleStatus; + effectiveAt?: Date; + expiresAt?: Date | null; + actorId?: string; +} + +export interface UpdateQualityRuleInput { + name?: string; + customerId?: string | null; + lineGroupId?: string | null; + ratio?: string; + status?: QualityRuleStatus; + effectiveAt?: Date; + expiresAt?: Date | null; + actorId?: string; +} + +export interface ActiveQualityRule { + id: string; + name: string; + customerId: string | null; + lineGroupId: string | null; + ratio: string; +} + +export const QUALITY_REPOSITORY = Symbol('QUALITY_REPOSITORY'); + +export interface QualityRepository { + listRules(): Promise; + getRule(ruleId: string): Promise; + createRule(input: CreateQualityRuleInput): Promise; + updateRule(ruleId: string, input: UpdateQualityRuleInput): Promise; + setRuleStatus(ruleId: string, status: QualityRuleStatus, actorId?: string): Promise; + softDeleteRule(ruleId: string, actorId?: string): Promise; + listActiveRules(now: Date): Promise; +} + +type QualityRuleRecord = { + id: string; + name: string; + customerId: string | null; + lineGroupId: string | null; + ratio: Prisma.Decimal; + status: QualityRuleStatus; + effectiveAt: Date; + expiresAt: Date | null; + createdAt: Date; + updatedAt: Date; + customer: { name: string } | null; + lineGroup: { name: string } | null; +}; + +@Injectable() +export class PrismaQualityRepository implements QualityRepository { + constructor(private readonly prisma: PrismaService) {} + + async listRules(): Promise { + const rules = await this.prisma.qualitySamplingRule.findMany({ + where: { deletedAt: null }, + orderBy: [{ createdAt: 'desc' }], + include: this.includeRule() + }); + return rules.map((rule) => this.toRuleSummary(rule)); + } + + async getRule(ruleId: string): Promise { + return this.toRuleSummary(await this.findRuleOrThrow(ruleId)); + } + + async createRule(input: CreateQualityRuleInput): Promise { + try { + const created = await this.prisma.qualitySamplingRule.create({ + data: { + id: qualityRuleId(), + name: input.name, + customerId: input.customerId, + lineGroupId: input.lineGroupId, + ratio: input.ratio, + status: input.status, + effectiveAt: input.effectiveAt, + expiresAt: input.expiresAt, + createdBy: input.actorId, + updatedBy: input.actorId + }, + include: this.includeRule() + }); + return this.toRuleSummary(created); + } catch (error) { + this.handleForeignKey(error); + throw error; + } + } + + async updateRule(ruleId: string, input: UpdateQualityRuleInput): Promise { + await this.findRuleOrThrow(ruleId); + try { + const updated = await this.prisma.qualitySamplingRule.update({ + where: { id: ruleId }, + data: { + name: input.name, + customerId: input.customerId, + lineGroupId: input.lineGroupId, + ratio: input.ratio, + status: input.status, + effectiveAt: input.effectiveAt, + expiresAt: input.expiresAt, + updatedBy: input.actorId, + version: { increment: 1 } + }, + include: this.includeRule() + }); + return this.toRuleSummary(updated); + } catch (error) { + this.handleForeignKey(error); + throw error; + } + } + + setRuleStatus(ruleId: string, status: QualityRuleStatus, actorId?: string): Promise { + return this.updateRule(ruleId, { status, actorId }); + } + + async softDeleteRule(ruleId: string, actorId?: string): Promise { + await this.findRuleOrThrow(ruleId); + const deleted = await this.prisma.qualitySamplingRule.update({ + where: { id: ruleId }, + data: { + status: 'DISABLED', + deletedAt: new Date(), + updatedBy: actorId, + version: { increment: 1 } + }, + include: this.includeRule() + }); + return this.toRuleSummary(deleted); + } + + async listActiveRules(now: Date): Promise { + const rules = await this.prisma.qualitySamplingRule.findMany({ + where: { + deletedAt: null, + status: 'ENABLED', + effectiveAt: { lte: now }, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] + }, + select: { + id: true, + name: true, + customerId: true, + lineGroupId: true, + ratio: true + } + }); + return rules.map((rule) => ({ + ...rule, + ratio: rule.ratio.toFixed(2) + })); + } + + private includeRule() { + return { + customer: { select: { name: true } }, + lineGroup: { select: { name: true } } + } satisfies Prisma.QualitySamplingRuleInclude; + } + + private async findRuleOrThrow(ruleId: string): Promise { + const rule = await this.prisma.qualitySamplingRule.findUnique({ + where: { id: ruleId }, + include: this.includeRule() + }); + if (!rule || rule.deletedAt) { + throw new NotFoundException({ code: 'QUALITY_RULE_NOT_FOUND', message: 'Quality sampling rule not found.' }); + } + return rule; + } + + private toRuleSummary(rule: QualityRuleRecord): QualityRuleSummary { + return { + id: rule.id, + name: rule.name, + customerId: rule.customerId, + customerName: rule.customer?.name ?? null, + lineGroupId: rule.lineGroupId, + lineGroupName: rule.lineGroup?.name ?? null, + ratio: rule.ratio.toFixed(2), + status: rule.status, + effectiveAt: rule.effectiveAt, + expiresAt: rule.expiresAt, + createdAt: rule.createdAt, + updatedAt: rule.updatedAt + }; + } + + private handleForeignKey(error: unknown): void { + if (error && typeof error === 'object' && 'code' in error && (error as { code?: unknown }).code === 'P2003') { + throw new ConflictException({ code: 'QUALITY_RULE_REFERENCE_INVALID', message: 'Referenced customer or line group does not exist.' }); + } + } +} + +function qualityRuleId(): string { + return `qsr_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`; +} diff --git a/apps/api/src/modules/quality/quality.service.ts b/apps/api/src/modules/quality/quality.service.ts new file mode 100644 index 0000000..8887ecf --- /dev/null +++ b/apps/api/src/modules/quality/quality.service.ts @@ -0,0 +1,182 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { + QUALITY_REPOSITORY, + type ActiveQualityRule, + type CreateQualityRuleInput, + type QualityRepository, + type QualityRuleStatus, + type QualityRuleSummary, + type UpdateQualityRuleInput +} from './quality.repository.js'; +import { stableSamplingDecision } from './sampling.js'; + +interface QualityRuleBody { + name?: unknown; + customerId?: unknown; + lineGroupId?: unknown; + ratio?: unknown; + status?: unknown; + effectiveAt?: unknown; + expiresAt?: unknown; +} + +export interface RecordingSamplingContext { + recordingId: string; + customerId: string | null; + lineGroupId: string | null; +} + +export interface RecordingSamplingMatch { + ruleId: string; + ruleName: string; + ratio: string; + score: number; + selected: boolean; +} + +@Injectable() +export class QualityService { + constructor(@Inject(QUALITY_REPOSITORY) private readonly quality: QualityRepository) {} + + listRules(): Promise { + return this.quality.listRules(); + } + + getRule(ruleId: string): Promise { + return this.quality.getRule(ruleId); + } + + createRule(body: QualityRuleBody, actorId?: string): Promise { + const effectiveAt = body.effectiveAt === undefined ? undefined : this.date(body.effectiveAt, 'effectiveAt'); + const expiresAt = body.expiresAt === undefined ? undefined : this.nullableDate(body.expiresAt, 'expiresAt'); + this.ensureDateRange(effectiveAt, expiresAt); + const input: CreateQualityRuleInput = { + name: this.limitedString(body.name, 'name', 120), + customerId: this.nullableId(body.customerId, 'customerId', 32), + lineGroupId: this.nullableId(body.lineGroupId, 'lineGroupId', 32), + ratio: this.ratio(body.ratio), + status: body.status === undefined ? 'ENABLED' : this.status(body.status), + effectiveAt, + expiresAt, + actorId + }; + return this.quality.createRule(input); + } + + updateRule(ruleId: string, body: QualityRuleBody, actorId?: string): Promise { + const effectiveAt = body.effectiveAt === undefined ? undefined : this.date(body.effectiveAt, 'effectiveAt'); + const expiresAt = body.expiresAt === undefined ? undefined : this.nullableDate(body.expiresAt, 'expiresAt'); + this.ensureDateRange(effectiveAt, expiresAt); + const input: UpdateQualityRuleInput = { + name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120), + customerId: body.customerId === undefined ? undefined : this.nullableId(body.customerId, 'customerId', 32), + lineGroupId: body.lineGroupId === undefined ? undefined : this.nullableId(body.lineGroupId, 'lineGroupId', 32), + ratio: body.ratio === undefined ? undefined : this.ratio(body.ratio), + status: body.status === undefined ? undefined : this.status(body.status), + effectiveAt, + expiresAt, + actorId + }; + return this.quality.updateRule(ruleId, input); + } + + enableRule(ruleId: string, actorId?: string): Promise { + return this.quality.setRuleStatus(ruleId, 'ENABLED', actorId); + } + + disableRule(ruleId: string, actorId?: string): Promise { + return this.quality.setRuleStatus(ruleId, 'DISABLED', actorId); + } + + deleteRule(ruleId: string, actorId?: string): Promise { + return this.quality.softDeleteRule(ruleId, actorId); + } + + async samplingFor(context: RecordingSamplingContext, now = new Date()): Promise<{ selected: boolean; matches: RecordingSamplingMatch[] }> { + const rules = await this.quality.listActiveRules(now); + const matches = this.matchRules(rules, context).map((rule) => { + const decision = stableSamplingDecision(rule.id, context.recordingId, rule.ratio); + return { + ruleId: rule.id, + ruleName: rule.name, + ratio: rule.ratio, + score: decision.score, + selected: decision.selected + }; + }); + return { + selected: matches.some((match) => match.selected), + matches + }; + } + + private matchRules(rules: ActiveQualityRule[], context: RecordingSamplingContext): ActiveQualityRule[] { + return rules.filter((rule) => { + const customerMatches = !rule.customerId || rule.customerId === context.customerId; + const lineGroupMatches = !rule.lineGroupId || rule.lineGroupId === context.lineGroupId; + return customerMatches && lineGroupMatches; + }); + } + + private limitedString(value: unknown, field: string, maxLength: number): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` }); + } + const trimmed = value.trim(); + if (trimmed.length > maxLength) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is too long.` }); + } + return trimmed; + } + + private nullableId(value: unknown, field: string, maxLength: number): string | null { + if (value === undefined || value === null || value === '') { + return null; + } + return this.limitedString(value, field, maxLength); + } + + private ratio(value: unknown): string { + const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : ''; + if (!/^(?:100(?:\.0{1,2})?|\d{1,2}(?:\.\d{1,2})?)$/.test(raw)) { + throw new BadRequestException({ code: 'QUALITY_RATIO_INVALID', message: 'ratio must be from 0 to 100 with up to 2 decimals.' }); + } + const numeric = Number.parseFloat(raw); + if (!Number.isFinite(numeric) || numeric < 0 || numeric > 100) { + throw new BadRequestException({ code: 'QUALITY_RATIO_INVALID', message: 'ratio must be from 0 to 100 with up to 2 decimals.' }); + } + const [integerPart, fractionPart = ''] = raw.split('.'); + return `${integerPart}.${fractionPart.padEnd(2, '0')}`; + } + + private status(value: unknown): QualityRuleStatus { + if (value !== 'ENABLED' && value !== 'DISABLED') { + throw new BadRequestException({ code: 'STATUS_INVALID', message: 'status is invalid.' }); + } + return value; + } + + private date(value: unknown, field: string): Date { + if (typeof value !== 'string') { + throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} must be an ISO date string.` }); + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} must be an ISO date string.` }); + } + return parsed; + } + + private nullableDate(value: unknown, field: string): Date | null { + if (value === null || value === '') { + return null; + } + return this.date(value, field); + } + + private ensureDateRange(effectiveAt?: Date, expiresAt?: Date | null): void { + if (effectiveAt && expiresAt && expiresAt <= effectiveAt) { + throw new BadRequestException({ code: 'QUALITY_RULE_DATE_RANGE_INVALID', message: 'expiresAt must be after effectiveAt.' }); + } + } +} diff --git a/apps/api/src/modules/quality/sampling.spec.ts b/apps/api/src/modules/quality/sampling.spec.ts new file mode 100644 index 0000000..0b77eb4 --- /dev/null +++ b/apps/api/src/modules/quality/sampling.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { stableSamplingDecision, stableSamplingScore } from './sampling.js'; + +describe('stable quality sampling', () => { + it('returns a stable score for the same rule and recording', () => { + const first = stableSamplingScore('rule-a', 'rec-a'); + const second = stableSamplingScore('rule-a', 'rec-a'); + expect(first).toBe(second); + expect(first).toBeGreaterThanOrEqual(0); + expect(first).toBeLessThan(100); + }); + + it('honors ratio boundaries', () => { + expect(stableSamplingDecision('rule-a', 'rec-a', '0.00').selected).toBe(false); + expect(stableSamplingDecision('rule-a', 'rec-a', '100.00').selected).toBe(true); + }); +}); diff --git a/apps/api/src/modules/quality/sampling.ts b/apps/api/src/modules/quality/sampling.ts new file mode 100644 index 0000000..372b5f6 --- /dev/null +++ b/apps/api/src/modules/quality/sampling.ts @@ -0,0 +1,24 @@ +import crypto from 'node:crypto'; + +export interface SamplingDecision { + score: number; + selected: boolean; +} + +export function stableSamplingDecision(ruleId: string, recordingId: string, ratio: string | number): SamplingDecision { + const normalizedRatio = typeof ratio === 'number' ? ratio : Number.parseFloat(ratio); + if (!Number.isFinite(normalizedRatio) || normalizedRatio <= 0) { + return { score: stableSamplingScore(ruleId, recordingId), selected: false }; + } + if (normalizedRatio >= 100) { + return { score: stableSamplingScore(ruleId, recordingId), selected: true }; + } + const score = stableSamplingScore(ruleId, recordingId); + return { score, selected: score < normalizedRatio }; +} + +export function stableSamplingScore(ruleId: string, recordingId: string): number { + const digest = crypto.createHash('sha256').update(`${ruleId}:${recordingId}`, 'utf8').digest(); + const value = digest.readUInt32BE(0); + return Number(((value / 0x1_0000_0000) * 100).toFixed(4)); +} diff --git a/apps/api/src/modules/recharges/recharges.controller.ts b/apps/api/src/modules/recharges/recharges.controller.ts new file mode 100644 index 0000000..5dbdf22 --- /dev/null +++ b/apps/api/src/modules/recharges/recharges.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Get, Inject, Param, Post, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuditAction } from '../audit/audit.metadata.js'; +import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js'; +import { RechargesService } from './recharges.service.js'; + +@ApiTags('recharges') +@Controller() +export class RechargesController { + constructor(@Inject(RechargesService) private readonly rechargesService: RechargesService) {} + + @Get('recharges') + @RequirePermissions('recharges.view') + list(@Query() query: unknown) { + return this.rechargesService.list(query as never); + } + + @Post('customers/:id/recharges') + @RequirePermissions('recharges.manage') + @AuditAction({ module: 'recharges', action: 'customer_recharge', objectType: 'customer', objectIdParam: 'id' }) + rechargeCustomer(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.rechargesService.rechargeCustomer(id, body as never, currentUser?.id); + } + + @Post('vendors/:id/recharges') + @RequirePermissions('recharges.manage') + @AuditAction({ module: 'recharges', action: 'vendor_recharge', objectType: 'vendor', objectIdParam: 'id' }) + rechargeVendor(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.rechargesService.rechargeVendor(id, body as never, currentUser?.id); + } +} diff --git a/apps/api/src/modules/recharges/recharges.e2e.spec.ts b/apps/api/src/modules/recharges/recharges.e2e.spec.ts new file mode 100644 index 0000000..8f805c4 --- /dev/null +++ b/apps/api/src/modules/recharges/recharges.e2e.spec.ts @@ -0,0 +1,228 @@ +import 'reflect-metadata'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { ConflictException } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import request from 'supertest'; +import { signAccessToken, type PermissionKey } from '@lisglosips/auth'; +import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js'; +import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js'; +import type { CurrentUser } from '../security/security.metadata.js'; +import { + RECHARGES_REPOSITORY, + type RechargeInput, + type RechargeListQuery, + type RechargeListResult, + type RechargeSummary, + type RechargesRepository +} from './recharges.repository.js'; + +class MemoryIdentityRepository implements IdentityRepository { + users = new Map(); + + async findCurrentUserById(userId: string): Promise { + return this.users.get(userId) ?? null; + } +} + +class MemoryAuditRepository implements AuditRepository { + entries: AuditEntryInput[] = []; + + async write(input: AuditEntryInput): Promise { + this.entries.push(input); + } +} + +class MemoryRechargesRepository implements RechargesRepository { + private readonly items: RechargeSummary[] = []; + private readonly balances = new Map([ + ['CUSTOMER:cus_seed', 10], + ['VENDOR:ven_seed', 20] + ]); + private readonly idempotency = new Map(); + + async list(query: RechargeListQuery): Promise { + const items = this.items.filter((item) => { + if (query.accountType && item.accountType !== query.accountType) { + return false; + } + + return !query.accountId || item.accountId === query.accountId; + }); + + return { + items: items.slice(query.skip, query.skip + query.take), + total: items.length + }; + } + + async rechargeCustomer(input: RechargeInput): Promise { + return this.recharge('CUSTOMER', 'customer_recharge', input, 'Seed Customer'); + } + + async rechargeVendor(input: RechargeInput): Promise { + return this.recharge('VENDOR', 'vendor_recharge', input, 'Seed Vendor'); + } + + private async recharge(accountType: 'CUSTOMER' | 'VENDOR', scope: string, input: RechargeInput, accountName: string): Promise { + const existing = this.idempotency.get(input.idempotencyKey); + if (existing) { + if (existing.scope !== scope || existing.hash !== input.requestHash) { + throw new ConflictException({ code: 'IDEMPOTENCY_KEY_CONFLICT', message: 'Idempotency key was used by another request.' }); + } + + return existing.response; + } + + const key = `${accountType}:${input.accountId}`; + const before = this.balances.get(key) ?? 0; + const amount = Number(input.amount); + const after = before + amount; + this.balances.set(key, after); + + const response: RechargeSummary = { + id: `rch_${this.items.length + 1}`, + accountType, + accountId: input.accountId, + accountName, + amount: amount.toFixed(6), + beforeBalance: before.toFixed(6), + afterBalance: after.toFixed(6), + idempotencyKey: input.idempotencyKey, + remark: input.remark ?? null, + status: 'SUCCEEDED', + occurredAt: new Date('2026-06-21T02:00:00.000Z'), + createdAt: new Date('2026-06-21T02:00:00.000Z'), + createdBy: input.actorId ?? null + }; + this.items.unshift(response); + this.idempotency.set(input.idempotencyKey, { scope, hash: input.requestHash, response }); + return response; + } +} + +describe('S12 recharges API', () => { + let app: NestFastifyApplication; + let audit: MemoryAuditRepository; + + const tokenFor = (userId: string) => + signAccessToken( + { + sub: userId, + username: userId, + roles: ['test'], + typ: 'access' + }, + { + secret: 'test-only-access-token-secret-min-32-bytes', + issuer: 'lisglosips-api', + audience: 'lisglosips-web', + ttlSeconds: 900 + } + ); + + beforeAll(async () => { + process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips'; + process.env.REDIS_URL = 'redis://127.0.0.1:6379/0'; + process.env.LISGLOSIPS_LOG_LEVEL = 'silent'; + process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes'; + + const identities = new MemoryIdentityRepository(); + audit = new MemoryAuditRepository(); + + identities.users.set('usr_finance', { + id: 'usr_finance', + username: 'finance', + roles: ['财务'], + permissions: ['recharges.view', 'recharges.manage', 'customers.view', 'vendors.view'] as PermissionKey[] + }); + identities.users.set('usr_viewer', { + id: 'usr_viewer', + username: 'viewer', + roles: ['只读'], + permissions: ['recharges.view'] as PermissionKey[] + }); + + const { AppModule } = await import('../app.module.js'); + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule] + }) + .overrideProvider(IDENTITY_REPOSITORY) + .useValue(identities) + .overrideProvider(AUDIT_REPOSITORY) + .useValue(audit) + .overrideProvider(RECHARGES_REPOSITORY) + .useValue(new MemoryRechargesRepository()) + .compile(); + + app = moduleFixture.createNestApplication(new FastifyAdapter({ logger: false })); + app.setGlobalPrefix('api/v2'); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('rejects recharge without recharges.manage', async () => { + await request(app.getHttpServer()) + .post('/api/v2/customers/cus_seed/recharges') + .set('Authorization', `Bearer ${tokenFor('usr_viewer')}`) + .send({ amount: '10', idempotencyKey: 'viewer-denied-001' }) + .expect(403); + }); + + it('creates customer recharge once and replays idempotent duplicate', async () => { + const body = { amount: '15.25', idempotencyKey: 'customer-rch-001', remark: 'manual top up' }; + const first = await request(app.getHttpServer()) + .post('/api/v2/customers/cus_seed/recharges') + .set('Authorization', `Bearer ${tokenFor('usr_finance')}`) + .send(body) + .expect(201); + + const duplicate = await request(app.getHttpServer()) + .post('/api/v2/customers/cus_seed/recharges') + .set('Authorization', `Bearer ${tokenFor('usr_finance')}`) + .send(body) + .expect(201); + + expect(first.body).toMatchObject({ + accountType: 'CUSTOMER', + accountId: 'cus_seed', + amount: '15.250000', + beforeBalance: '10.000000', + afterBalance: '25.250000' + }); + expect(duplicate.body.id).toBe(first.body.id); + expect(audit.entries.some((entry) => entry.module === 'recharges' && entry.action === 'customer_recharge' && entry.result === 'SUCCESS')).toBe(true); + }); + + it('creates vendor recharge, lists ledgers, and rejects idempotency conflicts', async () => { + await request(app.getHttpServer()) + .post('/api/v2/vendors/ven_seed/recharges') + .set('Authorization', `Bearer ${tokenFor('usr_finance')}`) + .send({ amount: '3.5', idempotencyKey: 'vendor-rch-001' }) + .expect(201) + .expect((response) => { + expect(response.body).toMatchObject({ + accountType: 'VENDOR', + accountId: 'ven_seed', + beforeBalance: '20.000000', + afterBalance: '23.500000' + }); + }); + + await request(app.getHttpServer()) + .post('/api/v2/vendors/ven_seed/recharges') + .set('Authorization', `Bearer ${tokenFor('usr_finance')}`) + .send({ amount: '4.5', idempotencyKey: 'vendor-rch-001' }) + .expect(409); + + const list = await request(app.getHttpServer()).get('/api/v2/recharges?take=10').set('Authorization', `Bearer ${tokenFor('usr_viewer')}`).expect(200); + + expect(list.body.total).toBe(2); + expect(list.body.items.some((item: { accountType: string }) => item.accountType === 'CUSTOMER')).toBe(true); + expect(list.body.items.some((item: { accountType: string }) => item.accountType === 'VENDOR')).toBe(true); + }); +}); diff --git a/apps/api/src/modules/recharges/recharges.module.ts b/apps/api/src/modules/recharges/recharges.module.ts new file mode 100644 index 0000000..9c0f7ec --- /dev/null +++ b/apps/api/src/modules/recharges/recharges.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { RechargesController } from './recharges.controller.js'; +import { RECHARGES_REPOSITORY, PrismaRechargesRepository } from './recharges.repository.js'; +import { RechargesService } from './recharges.service.js'; + +@Module({ + controllers: [RechargesController], + providers: [ + RechargesService, + { + provide: RECHARGES_REPOSITORY, + useClass: PrismaRechargesRepository + } + ], + exports: [RechargesService] +}) +export class RechargesModule {} diff --git a/apps/api/src/modules/recharges/recharges.repository.ts b/apps/api/src/modules/recharges/recharges.repository.ts new file mode 100644 index 0000000..a084ad1 --- /dev/null +++ b/apps/api/src/modules/recharges/recharges.repository.ts @@ -0,0 +1,355 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import crypto from 'node:crypto'; +import { Prisma } from '@lisglosips/database'; +import { PrismaService } from '../database/prisma.service.js'; + +export type RechargeAccountType = 'CUSTOMER' | 'VENDOR'; + +export interface RechargeInput { + accountId: string; + amount: string; + idempotencyKey: string; + requestHash: string; + remark?: string; + actorId?: string; +} + +export interface RechargeSummary { + id: string; + accountType: RechargeAccountType; + accountId: string; + accountName: string; + amount: string; + beforeBalance: string; + afterBalance: string; + idempotencyKey: string; + remark: string | null; + status: 'SUCCEEDED' | 'FAILED' | 'REVERSED'; + occurredAt: Date | string; + createdAt: Date | string; + createdBy: string | null; +} + +export interface RechargeListQuery { + accountType?: RechargeAccountType; + accountId?: string; + take: number; + skip: number; +} + +export interface RechargeListResult { + items: RechargeSummary[]; + total: number; +} + +export interface RechargesRepository { + list(query: RechargeListQuery): Promise; + rechargeCustomer(input: RechargeInput): Promise; + rechargeVendor(input: RechargeInput): Promise; +} + +export const RECHARGES_REPOSITORY = Symbol('RECHARGES_REPOSITORY'); + +function rechargeId(): string { + return `rch_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`; +} + +function idempotencyId(): string { + return `idem_${crypto.randomUUID().replaceAll('-', '').slice(0, 35)}`; +} + +@Injectable() +export class PrismaRechargesRepository implements RechargesRepository { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async list(query: RechargeListQuery): Promise { + const [customerItems, customerTotal] = + query.accountType === 'VENDOR' ? [[], 0] : await this.listCustomerRecharges(query.accountId, query.take, query.skip); + const [vendorItems, vendorTotal] = + query.accountType === 'CUSTOMER' ? [[], 0] : await this.listVendorRecharges(query.accountId, query.take, query.skip); + + const items = [...customerItems, ...vendorItems] + .sort((left, right) => this.timeOf(right.occurredAt) - this.timeOf(left.occurredAt)) + .slice(0, query.take); + + return { + items, + total: customerTotal + vendorTotal + }; + } + + async rechargeCustomer(input: RechargeInput): Promise { + return this.runIdempotent('customer_recharge', input, async (tx) => { + const [locked] = await tx.$queryRaw< + { + id: string; + name: string; + balance: Prisma.Decimal; + deleted_at: Date | null; + }[] + >`SELECT id, name, balance, deleted_at FROM customers WHERE id = ${input.accountId} FOR UPDATE`; + + if (!locked || locked.deleted_at) { + throw new NotFoundException({ code: 'CUSTOMER_NOT_FOUND', message: 'Customer not found.' }); + } + + const amount = new Prisma.Decimal(input.amount); + const afterBalance = locked.balance.plus(amount); + const created = await tx.customerRecharge.create({ + data: { + id: rechargeId(), + customerId: locked.id, + amount, + beforeBalance: locked.balance, + afterBalance, + idempotencyKey: input.idempotencyKey, + remark: input.remark, + createdBy: input.actorId + } + }); + + await tx.customer.update({ + where: { id: locked.id }, + data: { + balance: afterBalance, + updatedBy: input.actorId, + version: { increment: 1 } + } + }); + + return this.customerSummary(created, locked.name); + }); + } + + async rechargeVendor(input: RechargeInput): Promise { + return this.runIdempotent('vendor_recharge', input, async (tx) => { + const [locked] = await tx.$queryRaw< + { + id: string; + name: string; + balance: Prisma.Decimal; + deleted_at: Date | null; + }[] + >`SELECT id, name, balance, deleted_at FROM vendors WHERE id = ${input.accountId} FOR UPDATE`; + + if (!locked || locked.deleted_at) { + throw new NotFoundException({ code: 'VENDOR_NOT_FOUND', message: 'Vendor not found.' }); + } + + const amount = new Prisma.Decimal(input.amount); + const afterBalance = locked.balance.plus(amount); + const created = await tx.vendorRecharge.create({ + data: { + id: rechargeId(), + vendorId: locked.id, + amount, + beforeBalance: locked.balance, + afterBalance, + idempotencyKey: input.idempotencyKey, + remark: input.remark, + createdBy: input.actorId + } + }); + + await tx.vendor.update({ + where: { id: locked.id }, + data: { + balance: afterBalance, + updatedBy: input.actorId, + version: { increment: 1 } + } + }); + + return this.vendorSummary(created, locked.name); + }); + } + + private async runIdempotent( + scope: string, + input: RechargeInput, + operation: (tx: Prisma.TransactionClient) => Promise + ): Promise { + for (let attempt = 1; attempt <= 8; attempt += 1) { + try { + return await this.prisma.$transaction( + async (tx) => { + const existing = await tx.idempotencyKey.findUnique({ + where: { key: input.idempotencyKey } + }); + + if (existing) { + if (existing.scope !== scope || existing.requestHash !== input.requestHash) { + throw new ConflictException({ code: 'IDEMPOTENCY_KEY_CONFLICT', message: 'Idempotency key was used by another request.' }); + } + + if (existing.status === 'SUCCEEDED' && existing.responseBody) { + return existing.responseBody as unknown as RechargeSummary; + } + + throw new ConflictException({ code: 'IDEMPOTENCY_IN_PROGRESS', message: 'Idempotent request is still in progress.' }); + } + + await tx.idempotencyKey.create({ + data: { + id: idempotencyId(), + key: input.idempotencyKey, + scope, + requestHash: input.requestHash, + status: 'IN_PROGRESS', + lockedUntil: new Date(Date.now() + 60_000), + expiresAt: new Date(Date.now() + 86_400_000) + } + }); + + const response = await operation(tx); + + await tx.idempotencyKey.update({ + where: { key: input.idempotencyKey }, + data: { + status: 'SUCCEEDED', + responseStatus: 201, + responseBody: this.toJson(response), + lockedUntil: null + } + }); + + return response; + }, + { + isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, + timeout: 30000 + } + ); + } catch (error) { + if (attempt < 8 && this.isRetryableTransactionError(error)) { + await this.delay(50 * attempt); + continue; + } + + throw error; + } + } + + throw new ConflictException({ code: 'RECHARGE_RETRY_EXHAUSTED', message: 'Recharge transaction retry exhausted.' }); + } + + private async listCustomerRecharges(accountId: string | undefined, take: number, skip: number): Promise<[RechargeSummary[], number]> { + const where = accountId ? { customerId: accountId } : {}; + const [items, total] = await this.prisma.$transaction([ + this.prisma.customerRecharge.findMany({ + where, + take, + skip, + orderBy: [{ occurredAt: 'desc' }], + include: { customer: true } + }), + this.prisma.customerRecharge.count({ where }) + ]); + + return [items.map((item) => this.customerSummary(item, item.customer.name)), total]; + } + + private async listVendorRecharges(accountId: string | undefined, take: number, skip: number): Promise<[RechargeSummary[], number]> { + const where = accountId ? { vendorId: accountId } : {}; + const [items, total] = await this.prisma.$transaction([ + this.prisma.vendorRecharge.findMany({ + where, + take, + skip, + orderBy: [{ occurredAt: 'desc' }], + include: { vendor: true } + }), + this.prisma.vendorRecharge.count({ where }) + ]); + + return [items.map((item) => this.vendorSummary(item, item.vendor.name)), total]; + } + + private customerSummary( + recharge: { + id: string; + customerId: string; + amount: Prisma.Decimal; + beforeBalance: Prisma.Decimal; + afterBalance: Prisma.Decimal; + idempotencyKey: string; + remark: string | null; + status: 'SUCCEEDED' | 'FAILED' | 'REVERSED'; + occurredAt: Date; + createdAt: Date; + createdBy: string | null; + }, + accountName: string + ): RechargeSummary { + return { + id: recharge.id, + accountType: 'CUSTOMER', + accountId: recharge.customerId, + accountName, + amount: recharge.amount.toFixed(6), + beforeBalance: recharge.beforeBalance.toFixed(6), + afterBalance: recharge.afterBalance.toFixed(6), + idempotencyKey: recharge.idempotencyKey, + remark: recharge.remark, + status: recharge.status, + occurredAt: recharge.occurredAt, + createdAt: recharge.createdAt, + createdBy: recharge.createdBy + }; + } + + private vendorSummary( + recharge: { + id: string; + vendorId: string; + amount: Prisma.Decimal; + beforeBalance: Prisma.Decimal; + afterBalance: Prisma.Decimal; + idempotencyKey: string; + remark: string | null; + status: 'SUCCEEDED' | 'FAILED' | 'REVERSED'; + occurredAt: Date; + createdAt: Date; + createdBy: string | null; + }, + accountName: string + ): RechargeSummary { + return { + id: recharge.id, + accountType: 'VENDOR', + accountId: recharge.vendorId, + accountName, + amount: recharge.amount.toFixed(6), + beforeBalance: recharge.beforeBalance.toFixed(6), + afterBalance: recharge.afterBalance.toFixed(6), + idempotencyKey: recharge.idempotencyKey, + remark: recharge.remark, + status: recharge.status, + occurredAt: recharge.occurredAt, + createdAt: recharge.createdAt, + createdBy: recharge.createdBy + }; + } + + private toJson(response: RechargeSummary): Prisma.InputJsonValue { + return { + ...response, + occurredAt: response.occurredAt instanceof Date ? response.occurredAt.toISOString() : response.occurredAt, + createdAt: response.createdAt instanceof Date ? response.createdAt.toISOString() : response.createdAt + }; + } + + private timeOf(value: Date | string): number { + return value instanceof Date ? value.getTime() : Date.parse(value); + } + + private isRetryableTransactionError(error: unknown): boolean { + return Boolean(error && typeof error === 'object' && 'code' in error && ((error as { code?: unknown }).code === 'P2034' || (error as { code?: unknown }).code === 'P2002')); + } + + private delay(milliseconds: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); + } +} diff --git a/apps/api/src/modules/recharges/recharges.service.ts b/apps/api/src/modules/recharges/recharges.service.ts new file mode 100644 index 0000000..af69d14 --- /dev/null +++ b/apps/api/src/modules/recharges/recharges.service.ts @@ -0,0 +1,139 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import crypto from 'node:crypto'; +import { + RECHARGES_REPOSITORY, + type RechargeAccountType, + type RechargeListResult, + type RechargeSummary, + type RechargesRepository +} from './recharges.repository.js'; + +interface RechargeDto { + amount?: unknown; + idempotencyKey?: unknown; + remark?: unknown; +} + +interface ListRechargesQueryDto { + accountType?: unknown; + accountId?: unknown; + take?: unknown; + skip?: unknown; +} + +@Injectable() +export class RechargesService { + constructor(@Inject(RECHARGES_REPOSITORY) private readonly recharges: RechargesRepository) {} + + list(query: ListRechargesQueryDto): Promise { + return this.recharges.list({ + accountType: query.accountType === undefined ? undefined : this.accountType(query.accountType), + accountId: query.accountId === undefined ? undefined : this.limitedString(query.accountId, 'accountId', 40), + take: this.pageNumber(query.take, 50, 100), + skip: this.pageNumber(query.skip, 0, 10000) + }); + } + + rechargeCustomer(customerId: string, body: RechargeDto, actorId?: string): Promise { + const amount = this.money(body.amount, 'amount'); + const idempotencyKey = this.idempotencyKey(body.idempotencyKey); + const remark = this.optionalString(body.remark, 'remark', 500); + + return this.recharges.rechargeCustomer({ + accountId: customerId, + amount, + idempotencyKey, + requestHash: this.requestHash({ accountType: 'CUSTOMER', accountId: customerId, amount, remark }), + remark, + actorId + }); + } + + rechargeVendor(vendorId: string, body: RechargeDto, actorId?: string): Promise { + const amount = this.money(body.amount, 'amount'); + const idempotencyKey = this.idempotencyKey(body.idempotencyKey); + const remark = this.optionalString(body.remark, 'remark', 500); + + return this.recharges.rechargeVendor({ + accountId: vendorId, + amount, + idempotencyKey, + requestHash: this.requestHash({ accountType: 'VENDOR', accountId: vendorId, amount, remark }), + remark, + actorId + }); + } + + private accountType(value: unknown): RechargeAccountType { + if (value !== 'CUSTOMER' && value !== 'VENDOR') { + throw new BadRequestException({ code: 'ACCOUNT_TYPE_INVALID', message: 'Account type is invalid.' }); + } + + return value; + } + + private idempotencyKey(value: unknown): string { + const key = this.limitedString(value, 'idempotencyKey', 128); + if (key.length < 8 || !/^[A-Za-z0-9._:-]+$/.test(key)) { + throw new BadRequestException({ + code: 'IDEMPOTENCY_KEY_INVALID', + message: 'Idempotency key must be 8-128 safe characters.' + }); + } + + return key; + } + + private optionalString(value: unknown, field: string, maxLength: number): string | undefined { + if (value === undefined) { + return undefined; + } + + return this.limitedString(value, field, maxLength); + } + + private limitedString(value: unknown, field: string, maxLength: number): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` }); + } + + const trimmed = value.trim(); + if (trimmed.length > maxLength) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is too long.` }); + } + + return trimmed; + } + + private money(value: unknown, field: string): string { + const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : ''; + if (!/^(?:0|[1-9]\d{0,13})(?:\.\d{1,6})?$/.test(raw)) { + throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be a positive decimal with up to 6 places.` }); + } + + const [integerPart, fractionPart = ''] = raw.split('.'); + const normalized = `${integerPart}.${fractionPart.padEnd(6, '0')}`; + if (normalized === '0.000000') { + throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be greater than zero.` }); + } + + return normalized; + } + + private pageNumber(value: unknown, defaultValue: number, max: number): number { + if (value === undefined) { + return defaultValue; + } + + const parsed = typeof value === 'string' ? Number(value) : typeof value === 'number' ? value : Number.NaN; + if (!Number.isInteger(parsed) || parsed < 0 || parsed > max) { + throw new BadRequestException({ code: 'PAGINATION_INVALID', message: 'Pagination is invalid.' }); + } + + return parsed; + } + + private requestHash(value: Record): string { + return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex'); + } +} diff --git a/apps/api/src/modules/recordings/recordings.controller.ts b/apps/api/src/modules/recordings/recordings.controller.ts new file mode 100644 index 0000000..e0e6f79 --- /dev/null +++ b/apps/api/src/modules/recordings/recordings.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Get, Header, Inject, Param, Put, Query, Res } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import type { FastifyReply } from 'fastify'; +import { AuditAction } from '../audit/audit.metadata.js'; +import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js'; +import { RecordingsService } from './recordings.service.js'; + +@ApiTags('recordings') +@Controller('recordings') +export class RecordingsController { + constructor(@Inject(RecordingsService) private readonly recordingsService: RecordingsService) {} + + @Get() + @RequirePermissions('quality.view') + list(@Query() query: Record) { + return this.recordingsService.list(query); + } + + @Get(':id') + @RequirePermissions('quality.view') + get(@Param('id') id: string) { + return this.recordingsService.get(id); + } + + @Get(':id/play') + @RequirePermissions('recordings.play') + @AuditAction({ module: 'recordings', action: 'play', objectType: 'recording', objectIdParam: 'id' }) + @Header('Cache-Control', 'private, no-store') + async play(@Param('id') id: string, @Res() reply: FastifyReply): Promise { + const playback = await this.recordingsService.playback(id); + reply + .header('X-Accel-Redirect', playback.internalPath) + .header('Content-Type', playback.contentType) + .header('Content-Length', playback.recording.bytes.toString()) + .header('X-Recording-Sha256', playback.recording.sha256 ?? '') + .header('Content-Disposition', `inline; filename="${sanitizeFileName(playback.fileName)}"`) + .send(); + } + + @Put(':id/review') + @RequirePermissions('quality.manage') + @AuditAction({ module: 'quality', action: 'review_save', objectType: 'recording', objectIdParam: 'id' }) + saveReview(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.recordingsService.saveReview(id, body, currentUser?.id); + } +} + +function sanitizeFileName(fileName: string): string { + return fileName.replaceAll(/[^A-Za-z0-9._-]/g, '_'); +} diff --git a/apps/api/src/modules/recordings/recordings.module.ts b/apps/api/src/modules/recordings/recordings.module.ts new file mode 100644 index 0000000..44f3a32 --- /dev/null +++ b/apps/api/src/modules/recordings/recordings.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { QualityModule } from '../quality/quality.module.js'; +import { RecordingsController } from './recordings.controller.js'; +import { PrismaRecordingsRepository, RECORDINGS_REPOSITORY } from './recordings.repository.js'; +import { RecordingsService } from './recordings.service.js'; + +@Module({ + imports: [QualityModule], + controllers: [RecordingsController], + providers: [ + RecordingsService, + { + provide: RECORDINGS_REPOSITORY, + useClass: PrismaRecordingsRepository + } + ] +}) +export class RecordingsModule {} diff --git a/apps/api/src/modules/recordings/recordings.repository.ts b/apps/api/src/modules/recordings/recordings.repository.ts new file mode 100644 index 0000000..c896c5a --- /dev/null +++ b/apps/api/src/modules/recordings/recordings.repository.ts @@ -0,0 +1,260 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import crypto from 'node:crypto'; +import { Prisma } from '@lisglosips/database'; +import { PrismaService } from '../database/prisma.service.js'; + +export interface RecordingPlayback { + id: string; + storageKey: string; + storagePath: string; + sha256: string | null; + bytes: bigint; + durationSec: number; + status: 'PENDING' | 'READY' | 'FAILED' | 'DELETED'; +} + +export type ReviewResult = 'PASS' | 'ISSUE' | 'ESCALATED'; + +export interface RecordingReviewSummary { + id: string; + reviewerId: string; + reviewerName: string; + score: number | null; + result: ReviewResult; + issueTags: Prisma.JsonValue | null; + notes: string | null; + reviewedAt: Date; +} + +export interface RecordingListItem { + id: string; + rawCdrId: string | null; + callId: string | null; + customerId: string | null; + customerName: string | null; + lineGroupId: string | null; + lineGroupName: string | null; + caller: string | null; + callee: string | null; + startedAt: Date | null; + durationSec: number; + storageKey: string; + sha256: string | null; + bytes: string; + status: string; + movedAt: Date | null; + createdAt: Date; + latestReview: RecordingReviewSummary | null; +} + +export interface RecordingDetail extends RecordingListItem { + storagePath: string; + reviews: RecordingReviewSummary[]; + previousId: string | null; + nextId: string | null; +} + +export interface SaveReviewInput { + recordingId: string; + reviewerId: string; + score?: number | null; + result: ReviewResult; + issueTags?: Prisma.InputJsonValue | null; + notes?: string | null; +} + +export const RECORDINGS_REPOSITORY = Symbol('RECORDINGS_REPOSITORY'); + +export interface RecordingsRepository { + list(query?: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number }): Promise; + getDetail(id: string): Promise; + getReadyForPlayback(id: string): Promise; + saveReview(input: SaveReviewInput): Promise; +} + +type RecordingRecord = Prisma.RecordingGetPayload<{ + include: { + rawCdr: { + include: { + customer: { select: { name: true } }; + lineGroup: { select: { name: true } }; + }; + }; + reviews: { + orderBy: [{ reviewedAt: 'desc' }]; + include: { + reviewer: { select: { displayName: true; username: true } }; + }; + }; + }; +}>; + +type ReviewRecord = Prisma.QualityReviewGetPayload<{ + include: { + reviewer: { select: { displayName: true; username: true } }; + }; +}>; + +@Injectable() +export class PrismaRecordingsRepository implements RecordingsRepository { + constructor(private readonly prisma: PrismaService) {} + + async list(query: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number } = {}): Promise { + const take = Math.min(query.limit ?? 100, 500); + const recordings = await this.prisma.recording.findMany({ + where: { + status: query.status ? (query.status as never) : 'READY', + reviews: query.reviewStatus === 'PENDING' ? { none: {} } : query.reviewStatus === 'REVIEWED' ? { some: {} } : undefined + }, + orderBy: [{ createdAt: 'desc' }], + take, + include: this.includeRecording() + }); + return recordings.map((recording) => this.toListItem(recording)); + } + + async getDetail(id: string): Promise { + const recording = await this.findRecordingOrThrow(id); + const [previous, next] = await Promise.all([ + this.prisma.recording.findFirst({ + where: { status: 'READY', createdAt: { gt: recording.createdAt } }, + orderBy: [{ createdAt: 'asc' }], + select: { id: true } + }), + this.prisma.recording.findFirst({ + where: { status: 'READY', createdAt: { lt: recording.createdAt } }, + orderBy: [{ createdAt: 'desc' }], + select: { id: true } + }) + ]); + const item = this.toListItem(recording); + return { + ...item, + storagePath: recording.storagePath, + reviews: recording.reviews.map((review) => this.toReviewSummary(review)), + previousId: previous?.id ?? null, + nextId: next?.id ?? null + }; + } + + async getReadyForPlayback(id: string): Promise { + const recording = await this.prisma.recording.findUnique({ + where: { id }, + select: { + id: true, + storageKey: true, + storagePath: true, + sha256: true, + bytes: true, + durationSec: true, + status: true + } + }); + + if (!recording || recording.status !== 'READY') { + throw new NotFoundException({ + code: 'RECORDING_NOT_READY', + message: 'Recording is not available for playback.' + }); + } + + return recording; + } + + async saveReview(input: SaveReviewInput): Promise { + const recording = await this.prisma.recording.findUnique({ + where: { id: input.recordingId }, + select: { id: true, status: true } + }); + if (!recording || recording.status !== 'READY') { + throw new NotFoundException({ + code: 'RECORDING_NOT_READY', + message: 'Recording is not available for review.' + }); + } + + const review = await this.prisma.qualityReview.create({ + data: { + id: qualityReviewId(), + recordingId: input.recordingId, + reviewerId: input.reviewerId, + score: input.score, + result: input.result, + issueTags: input.issueTags === null ? Prisma.JsonNull : input.issueTags, + notes: input.notes + }, + include: { + reviewer: { select: { displayName: true, username: true } } + } + }); + return this.toReviewSummary(review as ReviewRecord); + } + + private includeRecording() { + return { + rawCdr: { + include: { + customer: { select: { name: true } }, + lineGroup: { select: { name: true } } + } + }, + reviews: { + orderBy: [{ reviewedAt: 'desc' }], + include: { + reviewer: { select: { displayName: true, username: true } } + } + } + } satisfies Prisma.RecordingInclude; + } + + private async findRecordingOrThrow(id: string): Promise { + const recording = await this.prisma.recording.findUnique({ + where: { id }, + include: this.includeRecording() + }); + if (!recording) { + throw new NotFoundException({ code: 'RECORDING_NOT_FOUND', message: 'Recording not found.' }); + } + return recording; + } + + private toListItem(recording: RecordingRecord): RecordingListItem { + return { + id: recording.id, + rawCdrId: recording.rawCdrId, + callId: recording.rawCdr?.callId ?? null, + customerId: recording.rawCdr?.customerId ?? null, + customerName: recording.rawCdr?.customer?.name ?? null, + lineGroupId: recording.rawCdr?.lineGroupId ?? null, + lineGroupName: recording.rawCdr?.lineGroup?.name ?? null, + caller: recording.rawCdr?.caller ?? null, + callee: recording.rawCdr?.callee ?? null, + startedAt: recording.rawCdr?.startedAt ?? null, + durationSec: recording.durationSec, + storageKey: recording.storageKey, + sha256: recording.sha256, + bytes: recording.bytes.toString(), + status: recording.status, + movedAt: recording.movedAt, + createdAt: recording.createdAt, + latestReview: recording.reviews[0] ? this.toReviewSummary(recording.reviews[0]) : null + }; + } + + private toReviewSummary(review: ReviewRecord): RecordingReviewSummary { + return { + id: review.id, + reviewerId: review.reviewerId, + reviewerName: review.reviewer.displayName || review.reviewer.username, + score: review.score, + result: review.result, + issueTags: review.issueTags, + notes: review.notes, + reviewedAt: review.reviewedAt + }; + } +} + +function qualityReviewId(): string { + return `qrev_${crypto.randomUUID().replaceAll('-', '').slice(0, 32)}`; +} diff --git a/apps/api/src/modules/recordings/recordings.service.spec.ts b/apps/api/src/modules/recordings/recordings.service.spec.ts new file mode 100644 index 0000000..4fabaf4 --- /dev/null +++ b/apps/api/src/modules/recordings/recordings.service.spec.ts @@ -0,0 +1,64 @@ +import { BadRequestException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; +import { internalRecordingPath, RecordingsService } from './recordings.service.js'; +import type { RecordingsRepository } from './recordings.repository.js'; +import type { QualityService } from '../quality/quality.service.js'; + +describe('recordings service', () => { + it('builds nginx internal recording paths', () => { + expect(internalRecordingPath('2026/06/21/call one.wav')).toBe('/_recordings/2026/06/21/call%20one.wav'); + }); + + it('rejects unsafe storage keys', () => { + expect(() => internalRecordingPath('../secret.wav')).toThrow(BadRequestException); + expect(() => internalRecordingPath('/../../secret.wav')).toThrow(BadRequestException); + }); + + it('adds stable sampling and review status to recording list rows', async () => { + const recordings = { + list: async () => [ + { + id: 'rec_1', + rawCdrId: 'raw_1', + callId: 'call-1', + customerId: 'cust_1', + customerName: 'Customer', + lineGroupId: 'lg_1', + lineGroupName: 'Line group', + caller: '1001', + callee: '1002', + startedAt: new Date('2026-06-21T00:00:00.000Z'), + durationSec: 12, + storageKey: '2026/06/21/call.wav', + sha256: null, + bytes: '12', + status: 'READY', + movedAt: null, + createdAt: new Date('2026-06-21T00:00:00.000Z'), + latestReview: null + } + ] + } as RecordingsRepository; + const quality = { + samplingFor: async () => ({ + selected: true, + matches: [{ ruleId: 'rule_1', ruleName: 'All', ratio: '100.00', score: 1, selected: true }] + }) + } as unknown as QualityService; + const service = new RecordingsService(recordings, quality); + + await expect(service.list()).resolves.toMatchObject([ + { + id: 'rec_1', + reviewStatus: 'PENDING', + sampling: { selected: true } + } + ]); + }); + + it('validates quality review payloads', async () => { + const service = new RecordingsService({} as RecordingsRepository, {} as QualityService); + await expect(service.saveReview('rec_1', { result: 'BAD' }, 'user_1')).rejects.toThrow(BadRequestException); + await expect(service.saveReview('rec_1', { result: 'PASS', score: 101 }, 'user_1')).rejects.toThrow(BadRequestException); + }); +}); diff --git a/apps/api/src/modules/recordings/recordings.service.ts b/apps/api/src/modules/recordings/recordings.service.ts new file mode 100644 index 0000000..fa646b8 --- /dev/null +++ b/apps/api/src/modules/recordings/recordings.service.ts @@ -0,0 +1,149 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { Prisma } from '@lisglosips/database'; +import path from 'node:path'; +import { QualityService } from '../quality/quality.service.js'; +import { + RECORDINGS_REPOSITORY, + type RecordingDetail, + type RecordingListItem, + type RecordingPlayback, + type RecordingReviewSummary, + type RecordingsRepository, + type ReviewResult +} from './recordings.repository.js'; + +export interface RecordingPlaybackResponse { + recording: RecordingPlayback; + internalPath: string; + contentType: string; + fileName: string; +} + +@Injectable() +export class RecordingsService { + constructor( + @Inject(RECORDINGS_REPOSITORY) private readonly recordings: RecordingsRepository, + @Inject(QualityService) private readonly qualityService: QualityService + ) {} + + async list(query: { status?: unknown; reviewStatus?: unknown; limit?: unknown } = {}) { + const recordings = await this.recordings.list({ + status: query.status === undefined ? undefined : this.recordingStatus(query.status), + reviewStatus: query.reviewStatus === undefined ? undefined : this.reviewStatus(query.reviewStatus), + limit: query.limit === undefined ? undefined : this.integer(query.limit, 'limit', 1, 500) + }); + return Promise.all(recordings.map((recording) => this.withSampling(recording))); + } + + async get(id: string) { + return this.withSampling(await this.recordings.getDetail(id)); + } + + async playback(id: string): Promise { + const recording = await this.recordings.getReadyForPlayback(id); + return { + recording, + internalPath: internalRecordingPath(recording.storageKey), + contentType: contentTypeFor(recording.storageKey), + fileName: path.posix.basename(recording.storageKey) + }; + } + + async saveReview(id: string, body: unknown, reviewerId?: string): Promise { + if (!reviewerId) { + throw new BadRequestException({ code: 'REVIEWER_REQUIRED', message: 'Current user is required to save review.' }); + } + const data = body as Record; + return this.recordings.saveReview({ + recordingId: id, + reviewerId, + score: data.score === undefined || data.score === null ? null : this.integer(data.score, 'score', 0, 100), + result: this.reviewResult(data.result), + issueTags: data.issueTags === undefined ? null : this.issueTags(data.issueTags), + notes: data.notes === undefined || data.notes === null ? null : this.optionalString(data.notes, 'notes', 1000) + }); + } + + private async withSampling(recording: T) { + const sampling = await this.qualityService.samplingFor({ + recordingId: recording.id, + customerId: recording.customerId, + lineGroupId: recording.lineGroupId + }); + return { + ...recording, + reviewStatus: recording.latestReview ? 'REVIEWED' : 'PENDING', + sampling + }; + } + + private recordingStatus(value: unknown): string { + if (value !== 'PENDING' && value !== 'READY' && value !== 'FAILED' && value !== 'DELETED') { + throw new BadRequestException({ code: 'RECORDING_STATUS_INVALID', message: 'status is invalid.' }); + } + return value; + } + + private reviewStatus(value: unknown): 'PENDING' | 'REVIEWED' { + if (value !== 'PENDING' && value !== 'REVIEWED') { + throw new BadRequestException({ code: 'REVIEW_STATUS_INVALID', message: 'reviewStatus is invalid.' }); + } + return value; + } + + private reviewResult(value: unknown): ReviewResult { + if (value !== 'PASS' && value !== 'ISSUE' && value !== 'ESCALATED') { + throw new BadRequestException({ code: 'QUALITY_REVIEW_RESULT_INVALID', message: 'result is invalid.' }); + } + return value; + } + + private issueTags(value: unknown): Prisma.InputJsonValue { + if (!Array.isArray(value) || value.some((tag) => typeof tag !== 'string' || tag.trim().length === 0 || tag.length > 60)) { + throw new BadRequestException({ code: 'QUALITY_ISSUE_TAGS_INVALID', message: 'issueTags must be an array of short strings.' }); + } + return value.map((tag) => tag.trim()); + } + + private optionalString(value: unknown, field: string, maxLength: number): string { + if (typeof value !== 'string') { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} must be a string.` }); + } + const trimmed = value.trim(); + if (trimmed.length > maxLength) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is too long.` }); + } + return trimmed; + } + + private integer(value: unknown, field: string, min: number, max: number): number { + const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number.parseInt(value, 10) : NaN; + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new BadRequestException({ code: 'INTEGER_INVALID', message: `${field} must be an integer from ${min} to ${max}.` }); + } + return parsed; + } +} + +export function internalRecordingPath(storageKey: string): string { + const normalized = storageKey.replaceAll('\\', '/').replace(/^\/+/, ''); + const parts = normalized.split('/').filter(Boolean); + if (parts.length === 0 || parts.some((part) => part === '.' || part === '..' || part.includes('\0'))) { + throw new BadRequestException({ + code: 'RECORDING_STORAGE_KEY_INVALID', + message: 'Recording storage key is invalid.' + }); + } + return `/_recordings/${parts.map(encodeURIComponent).join('/')}`; +} + +function contentTypeFor(storageKey: string): string { + const extension = path.posix.extname(storageKey).toLowerCase(); + if (extension === '.wav') { + return 'audio/wav'; + } + if (extension === '.mp3') { + return 'audio/mpeg'; + } + return 'application/octet-stream'; +} diff --git a/apps/api/src/modules/roles/roles.controller.ts b/apps/api/src/modules/roles/roles.controller.ts new file mode 100644 index 0000000..f42daf0 --- /dev/null +++ b/apps/api/src/modules/roles/roles.controller.ts @@ -0,0 +1,37 @@ +import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuditAction } from '../audit/audit.metadata.js'; +import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js'; +import { RolesService } from './roles.service.js'; + +@ApiTags('roles') +@Controller() +export class RolesController { + constructor(@Inject(RolesService) private readonly rolesService: RolesService) {} + + @Get('roles') + @RequirePermissions('roles.view') + listRoles() { + return this.rolesService.listRoles(); + } + + @Post('roles') + @RequirePermissions('roles.manage') + @AuditAction({ module: 'roles', action: 'create', objectType: 'role' }) + createRole(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.rolesService.createRole(body as never, currentUser?.id); + } + + @Patch('roles/:id') + @RequirePermissions('roles.manage') + @AuditAction({ module: 'roles', action: 'update', objectType: 'role', objectIdParam: 'id' }) + updateRole(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.rolesService.updateRole(id, body as never, currentUser?.id); + } + + @Get('permissions') + @RequirePermissions('roles.view') + listPermissions() { + return this.rolesService.listPermissions(); + } +} diff --git a/apps/api/src/modules/roles/roles.module.ts b/apps/api/src/modules/roles/roles.module.ts new file mode 100644 index 0000000..152a9f1 --- /dev/null +++ b/apps/api/src/modules/roles/roles.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { RolesController } from './roles.controller.js'; +import { PrismaRolesRepository, ROLES_REPOSITORY } from './roles.repository.js'; +import { RolesService } from './roles.service.js'; + +@Module({ + controllers: [RolesController], + providers: [ + RolesService, + PrismaRolesRepository, + { + provide: ROLES_REPOSITORY, + useExisting: PrismaRolesRepository + } + ] +}) +export class RolesModule {} diff --git a/apps/api/src/modules/roles/roles.repository.ts b/apps/api/src/modules/roles/roles.repository.ts new file mode 100644 index 0000000..ea04419 --- /dev/null +++ b/apps/api/src/modules/roles/roles.repository.ts @@ -0,0 +1,211 @@ +import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import crypto from 'node:crypto'; +import { PrismaService } from '../database/prisma.service.js'; + +export interface PermissionSummary { + id: string; + module: string; + action: string; + description: string | null; +} + +export interface RoleSummary { + id: string; + name: string; + description: string | null; + builtIn: boolean; + status: 'ENABLED' | 'DISABLED'; + permissionIds: string[]; + userCount: number; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateRoleInput { + name: string; + description?: string; + permissionIds: string[]; + actorId?: string; +} + +export interface UpdateRoleInput { + name?: string; + description?: string | null; + status?: 'ENABLED' | 'DISABLED'; + permissionIds?: string[]; + actorId?: string; +} + +export interface RolesRepository { + listRoles(): Promise; + listPermissions(): Promise; + createRole(input: CreateRoleInput): Promise; + updateRole(roleId: string, input: UpdateRoleInput): Promise; +} + +export const ROLES_REPOSITORY = Symbol('ROLES_REPOSITORY'); + +function roleId(): string { + return `rol_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`; +} + +@Injectable() +export class PrismaRolesRepository implements RolesRepository { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async listRoles(): Promise { + const roles = await this.prisma.role.findMany({ + where: { deletedAt: null }, + orderBy: [{ builtIn: 'desc' }, { name: 'asc' }], + include: { + permissions: true, + _count: { + select: { userRoles: true } + } + } + }); + + return roles.map((role) => this.toSummary(role)); + } + + async listPermissions(): Promise { + return this.prisma.permission.findMany({ + orderBy: [{ module: 'asc' }, { action: 'asc' }] + }); + } + + async createRole(input: CreateRoleInput): Promise { + await this.ensurePermissions(input.permissionIds); + + const role = await this.prisma.$transaction(async (tx) => { + const created = await tx.role.create({ + data: { + id: roleId(), + name: input.name, + description: input.description, + builtIn: false, + createdBy: input.actorId, + updatedBy: input.actorId + } + }); + + for (const permissionId of input.permissionIds) { + await tx.rolePermission.create({ + data: { + roleId: created.id, + permissionId, + createdBy: input.actorId + } + }); + } + + return tx.role.findUniqueOrThrow({ + where: { id: created.id }, + include: { + permissions: true, + _count: { + select: { userRoles: true } + } + } + }); + }); + + return this.toSummary(role); + } + + async updateRole(roleIdValue: string, input: UpdateRoleInput): Promise { + if (input.permissionIds) { + await this.ensurePermissions(input.permissionIds); + } + + const role = await this.prisma.$transaction(async (tx) => { + const existing = await tx.role.findUnique({ where: { id: roleIdValue } }); + if (!existing || existing.deletedAt) { + throw new NotFoundException({ code: 'ROLE_NOT_FOUND', message: 'Role not found.' }); + } + + if (existing.builtIn && (input.name !== undefined || input.status !== undefined || input.permissionIds !== undefined)) { + throw new ForbiddenException({ + code: 'BUILT_IN_ROLE_PROTECTED', + message: 'Built-in role name, status, and permissions are protected.' + }); + } + + await tx.role.update({ + where: { id: roleIdValue }, + data: { + name: input.name, + description: input.description, + status: input.status, + updatedBy: input.actorId, + version: { increment: 1 } + } + }); + + if (input.permissionIds) { + await tx.rolePermission.deleteMany({ where: { roleId: roleIdValue } }); + for (const permissionId of input.permissionIds) { + await tx.rolePermission.create({ + data: { + roleId: roleIdValue, + permissionId, + createdBy: input.actorId + } + }); + } + } + + return tx.role.findUniqueOrThrow({ + where: { id: roleIdValue }, + include: { + permissions: true, + _count: { + select: { userRoles: true } + } + } + }); + }); + + return this.toSummary(role); + } + + private async ensurePermissions(permissionIds: string[]): Promise { + const uniquePermissionIds = [...new Set(permissionIds)]; + const count = await this.prisma.permission.count({ + where: { + id: { in: uniquePermissionIds } + } + }); + + if (count !== uniquePermissionIds.length) { + throw new BadRequestException({ + code: 'PERMISSION_INVALID', + message: 'One or more permissions are invalid.' + }); + } + } + + private toSummary(role: { + id: string; + name: string; + description: string | null; + builtIn: boolean; + status: 'ENABLED' | 'DISABLED'; + createdAt: Date; + updatedAt: Date; + permissions: { permissionId: string }[]; + _count: { userRoles: number }; + }): RoleSummary { + return { + id: role.id, + name: role.name, + description: role.description, + builtIn: role.builtIn, + status: role.status, + permissionIds: role.permissions.map((permission) => permission.permissionId).sort(), + userCount: role._count.userRoles, + createdAt: role.createdAt, + updatedAt: role.updatedAt + }; + } +} diff --git a/apps/api/src/modules/roles/roles.service.ts b/apps/api/src/modules/roles/roles.service.ts new file mode 100644 index 0000000..aa70b21 --- /dev/null +++ b/apps/api/src/modules/roles/roles.service.ts @@ -0,0 +1,91 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { ROLES_REPOSITORY, type CreateRoleInput, type RolesRepository, type UpdateRoleInput } from './roles.repository.js'; + +interface CreateRoleDto { + name?: unknown; + description?: unknown; + permissionIds?: unknown; +} + +interface UpdateRoleDto { + name?: unknown; + description?: unknown; + status?: unknown; + permissionIds?: unknown; +} + +@Injectable() +export class RolesService { + constructor(@Inject(ROLES_REPOSITORY) private readonly roles: RolesRepository) {} + + listRoles() { + return this.roles.listRoles(); + } + + listPermissions() { + return this.roles.listPermissions(); + } + + createRole(body: CreateRoleDto, actorId?: string) { + const input: CreateRoleInput = { + name: this.requiredString(body.name, 'name').trim(), + description: this.optionalString(body.description), + permissionIds: this.permissionIds(body.permissionIds), + actorId + }; + + return this.roles.createRole(input); + } + + updateRole(roleId: string, body: UpdateRoleDto, actorId?: string) { + const input: UpdateRoleInput = { + name: body.name === undefined ? undefined : this.requiredString(body.name, 'name').trim(), + description: body.description === undefined ? undefined : this.nullableString(body.description), + status: body.status === undefined ? undefined : this.status(body.status), + permissionIds: body.permissionIds === undefined ? undefined : this.permissionIds(body.permissionIds), + actorId + }; + + return this.roles.updateRole(roleId, input); + } + + private requiredString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` }); + } + + return value; + } + + private optionalString(value: unknown): string | undefined { + if (value === undefined) { + return undefined; + } + + return this.requiredString(value, 'description').trim(); + } + + private nullableString(value: unknown): string | null { + if (value === null) { + return null; + } + + return this.requiredString(value, 'description').trim(); + } + + private permissionIds(value: unknown): string[] { + if (!Array.isArray(value) || !value.every((permissionId) => typeof permissionId === 'string' && permissionId.length > 0)) { + throw new BadRequestException({ code: 'PERMISSION_IDS_INVALID', message: 'Permission ids are invalid.' }); + } + + return [...new Set(value as string[])]; + } + + private status(value: unknown): 'ENABLED' | 'DISABLED' { + if (value !== 'ENABLED' && value !== 'DISABLED') { + throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' }); + } + + return value; + } +} diff --git a/apps/api/src/modules/security/auth.guard.ts b/apps/api/src/modules/security/auth.guard.ts new file mode 100644 index 0000000..10c9341 --- /dev/null +++ b/apps/api/src/modules/security/auth.guard.ts @@ -0,0 +1,64 @@ +import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Reflector } from '@nestjs/core'; +import { verifyAccessToken } from '@lisglosips/auth'; +import type { RuntimeConfig } from '../../shared/config.js'; +import { IDENTITY_REPOSITORY, type IdentityRepository } from './identity.repository.js'; +import { IS_PUBLIC_KEY, type AuthenticatedRequest } from './security.metadata.js'; + +@Injectable() +export class AccessTokenGuard implements CanActivate { + constructor( + @Inject(Reflector) private readonly reflector: Reflector, + @Inject(ConfigService) private readonly config: ConfigService, + @Inject(IDENTITY_REPOSITORY) private readonly identities: IdentityRepository + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]); + if (isPublic) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const token = this.extractBearerToken(request.headers.authorization); + + if (!token) { + throw this.unauthorized(); + } + + const payload = verifyAccessToken(token, { + secret: this.config.get('auth.accessTokenSecret', { infer: true }), + issuer: this.config.get('auth.tokenIssuer', { infer: true }), + audience: this.config.get('auth.tokenAudience', { infer: true }) + }); + + if (!payload) { + throw this.unauthorized(); + } + + const user = await this.identities.findCurrentUserById(payload.sub); + if (!user) { + throw this.unauthorized(); + } + + request.currentUser = user; + return true; + } + + private extractBearerToken(header: string | undefined): string | null { + if (!header) { + return null; + } + + const [type, token] = header.split(' '); + return type === 'Bearer' && token ? token : null; + } + + private unauthorized(): UnauthorizedException { + return new UnauthorizedException({ + code: 'AUTH_REQUIRED', + message: 'Authentication is required.' + }); + } +} diff --git a/apps/api/src/modules/security/identity.repository.ts b/apps/api/src/modules/security/identity.repository.ts new file mode 100644 index 0000000..9474667 --- /dev/null +++ b/apps/api/src/modules/security/identity.repository.ts @@ -0,0 +1,52 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { PermissionKey } from '@lisglosips/auth'; +import { PrismaService } from '../database/prisma.service.js'; +import type { CurrentUser } from './security.metadata.js'; + +export interface IdentityRepository { + findCurrentUserById(userId: string): Promise; +} + +export const IDENTITY_REPOSITORY = Symbol('IDENTITY_REPOSITORY'); + +@Injectable() +export class PrismaIdentityRepository implements IdentityRepository { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async findCurrentUserById(userId: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { + userRoles: { + include: { + role: { + include: { + permissions: true + } + } + } + } + } + }); + + if (!user || user.deletedAt || user.status !== 'ENABLED') { + return null; + } + + const enabledRoles = user.userRoles.map((userRole) => userRole.role).filter((role) => role.status === 'ENABLED' && !role.deletedAt); + const permissions = new Set(); + + for (const role of enabledRoles) { + for (const rolePermission of role.permissions) { + permissions.add(rolePermission.permissionId as PermissionKey); + } + } + + return { + id: user.id, + username: user.username, + roles: enabledRoles.map((role) => role.name), + permissions: [...permissions].sort() + }; + } +} diff --git a/apps/api/src/modules/security/rbac.guard.ts b/apps/api/src/modules/security/rbac.guard.ts new file mode 100644 index 0000000..120457f --- /dev/null +++ b/apps/api/src/modules/security/rbac.guard.ts @@ -0,0 +1,34 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { PermissionKey } from '@lisglosips/auth'; +import { IS_PUBLIC_KEY, REQUIRED_PERMISSIONS_KEY, type AuthenticatedRequest } from './security.metadata.js'; + +@Injectable() +export class RbacGuard implements CanActivate { + constructor(@Inject(Reflector) private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]); + if (isPublic) { + return true; + } + + const required = this.reflector.getAllAndOverride(REQUIRED_PERMISSIONS_KEY, [context.getHandler(), context.getClass()]) ?? []; + if (required.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const granted = new Set(request.currentUser?.permissions ?? []); + const allowed = required.every((permission) => granted.has(permission)); + + if (!allowed) { + throw new ForbiddenException({ + code: 'RBAC_FORBIDDEN', + message: 'Permission denied.' + }); + } + + return true; + } +} diff --git a/apps/api/src/modules/security/security-rbac-audit.e2e.spec.ts b/apps/api/src/modules/security/security-rbac-audit.e2e.spec.ts new file mode 100644 index 0000000..6848711 --- /dev/null +++ b/apps/api/src/modules/security/security-rbac-audit.e2e.spec.ts @@ -0,0 +1,249 @@ +import 'reflect-metadata'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import crypto from 'node:crypto'; +import { ForbiddenException } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import request from 'supertest'; +import { signAccessToken, type PermissionKey } from '@lisglosips/auth'; +import { AUDIT_LOGS_REPOSITORY, type AuditLogDetail, type AuditLogsRepository } from '../audit-logs/audit-logs.repository.js'; +import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js'; +import { IDENTITY_REPOSITORY, type IdentityRepository } from './identity.repository.js'; +import type { CurrentUser } from './security.metadata.js'; +import { ROLES_REPOSITORY, type CreateRoleInput, type RoleSummary, type RolesRepository, type UpdateRoleInput } from '../roles/roles.repository.js'; +import { USERS_REPOSITORY, type CreateUserInput, type UpdateUserInput, type UserSummary, type UsersRepository } from '../users/users.repository.js'; + +class MemoryIdentityRepository implements IdentityRepository { + users = new Map(); + + async findCurrentUserById(userId: string): Promise { + return this.users.get(userId) ?? null; + } +} + +class MemoryAuditRepository implements AuditRepository { + entries: AuditEntryInput[] = []; + + async write(input: AuditEntryInput): Promise { + this.entries.push(input); + } +} + +class MemoryAuditLogsRepository implements AuditLogsRepository { + constructor(private readonly audit: MemoryAuditRepository) {} + + async list() { + return { + items: this.audit.entries.map((entry, index) => this.toDetail(entry, index)), + total: this.audit.entries.length + }; + } + + async get(id: string): Promise { + const index = Number(id.replace('aud_test_', '')); + return this.toDetail(this.audit.entries[index], index); + } + + private toDetail(entry: AuditEntryInput, index: number): AuditLogDetail { + return { + id: `aud_test_${index}`, + requestId: entry.requestId, + userId: entry.userId ?? null, + username: entry.username ?? null, + roleNames: entry.roleNames ?? null, + ip: entry.ip ?? null, + userAgent: entry.userAgent ?? null, + module: entry.module, + action: entry.action, + objectType: entry.objectType, + objectId: entry.objectId ?? null, + beforeSummary: entry.beforeSummary, + afterSummary: entry.afterSummary, + result: entry.result, + errorCode: entry.errorCode ?? null, + createdAt: new Date('2026-06-21T00:00:00.000Z') + }; + } +} + +class MemoryUsersRepository implements UsersRepository { + async list(): Promise { + return []; + } + + async create(input: CreateUserInput): Promise { + return this.summary('usr_created', input.username, input.roleIds); + } + + async update(userId: string, input: UpdateUserInput): Promise { + return this.summary(userId, 'operator', input.roleIds ?? ['ROLE_SUPER_ADMIN']); + } + + async resetPassword(userId: string): Promise { + return this.summary(userId, 'operator', ['ROLE_SUPER_ADMIN'], true); + } + + private summary(id: string, username: string, roleIds: string[], requirePasswordChange = false): UserSummary { + return { + id, + username, + displayName: 'Operator', + phone: null, + email: null, + status: 'ENABLED', + requirePasswordChange, + lastLoginAt: null, + roles: roleIds, + roleIds, + createdAt: new Date('2026-06-21T00:00:00.000Z'), + updatedAt: new Date('2026-06-21T00:00:00.000Z') + }; + } +} + +class MemoryRolesRepository implements RolesRepository { + async listRoles(): Promise { + return [this.role('ROLE_SUPER_ADMIN', true, ['users.manage', 'roles.manage'])]; + } + + async listPermissions() { + return [{ id: 'users.manage', module: 'users', action: 'manage', description: 'Manage users' }]; + } + + async createRole(input: CreateRoleInput): Promise { + return this.role('rol_created', false, input.permissionIds, input.name); + } + + async updateRole(roleId: string, input: UpdateRoleInput): Promise { + if (roleId === 'ROLE_SUPER_ADMIN' && (input.name !== undefined || input.status !== undefined || input.permissionIds !== undefined)) { + throw new ForbiddenException({ + code: 'BUILT_IN_ROLE_PROTECTED', + message: 'Built-in role name, status, and permissions are protected.' + }); + } + + return this.role(roleId, roleId === 'ROLE_SUPER_ADMIN', input.permissionIds ?? ['users.manage']); + } + + private role(id: string, builtIn: boolean, permissionIds: string[], name = 'role'): RoleSummary { + return { + id, + name, + description: null, + builtIn, + status: 'ENABLED', + permissionIds, + userCount: 0, + createdAt: new Date('2026-06-21T00:00:00.000Z'), + updatedAt: new Date('2026-06-21T00:00:00.000Z') + }; + } +} + +describe('S10 security, RBAC, and audit API', () => { + let app: NestFastifyApplication; + let identities: MemoryIdentityRepository; + let audit: MemoryAuditRepository; + + const tokenFor = (userId: string) => + signAccessToken( + { + sub: userId, + username: userId, + roles: ['test'], + typ: 'access' + }, + { + secret: 'test-only-access-token-secret-min-32-bytes', + issuer: 'lisglosips-api', + audience: 'lisglosips-web', + ttlSeconds: 900 + } + ); + + beforeAll(async () => { + process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips'; + process.env.REDIS_URL = 'redis://127.0.0.1:6379/0'; + process.env.LISGLOSIPS_LOG_LEVEL = 'silent'; + process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes'; + + identities = new MemoryIdentityRepository(); + audit = new MemoryAuditRepository(); + const auditLogs = new MemoryAuditLogsRepository(audit); + + identities.users.set('usr_admin', { + id: 'usr_admin', + username: 'admin', + roles: ['超级管理员'], + permissions: ['users.view', 'users.manage', 'roles.view', 'roles.manage', 'audit.view'] as PermissionKey[] + }); + identities.users.set('usr_readonly', { + id: 'usr_readonly', + username: 'readonly', + roles: ['只读'], + permissions: ['users.view'] as PermissionKey[] + }); + + const { AppModule } = await import('../app.module.js'); + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule] + }) + .overrideProvider(IDENTITY_REPOSITORY) + .useValue(identities) + .overrideProvider(AUDIT_REPOSITORY) + .useValue(audit) + .overrideProvider(AUDIT_LOGS_REPOSITORY) + .useValue(auditLogs) + .overrideProvider(USERS_REPOSITORY) + .useValue(new MemoryUsersRepository()) + .overrideProvider(ROLES_REPOSITORY) + .useValue(new MemoryRolesRepository()) + .compile(); + + app = moduleFixture.createNestApplication(new FastifyAdapter({ logger: false })); + app.setGlobalPrefix('api/v2'); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('rejects missing and insufficient permissions', async () => { + await request(app.getHttpServer()).get('/api/v2/users').expect(401); + await request(app.getHttpServer()).post('/api/v2/users/usr_target/reset-password').set('Authorization', `Bearer ${tokenFor('usr_readonly')}`).send({ + password: crypto.randomUUID() + crypto.randomUUID() + }).expect(403); + }); + + it('audits reset password with sensitive body redacted', async () => { + const generatedPassword = crypto.randomUUID() + crypto.randomUUID(); + + await request(app.getHttpServer()) + .post('/api/v2/users/usr_target/reset-password') + .set('Authorization', `Bearer ${tokenFor('usr_admin')}`) + .send({ password: generatedPassword }) + .expect(201); + + const entry = audit.entries.find((item) => item.action === 'reset_password'); + + expect(entry?.userId).toBe('usr_admin'); + expect(entry?.result).toBe('SUCCESS'); + expect(JSON.stringify(entry?.beforeSummary)).not.toContain(generatedPassword); + expect(JSON.stringify(entry?.beforeSummary)).toContain('[REDACTED]'); + }); + + it('protects built-in role permissions and exposes audit details', async () => { + await request(app.getHttpServer()) + .patch('/api/v2/roles/ROLE_SUPER_ADMIN') + .set('Authorization', `Bearer ${tokenFor('usr_admin')}`) + .send({ permissionIds: ['users.view'] }) + .expect(403); + + const logs = await request(app.getHttpServer()).get('/api/v2/audit-logs').set('Authorization', `Bearer ${tokenFor('usr_admin')}`).expect(200); + + expect(logs.body.total).toBeGreaterThanOrEqual(1); + expect(logs.body.items.some((item: { action: string }) => item.action === 'reset_password')).toBe(true); + }); +}); diff --git a/apps/api/src/modules/security/security.metadata.ts b/apps/api/src/modules/security/security.metadata.ts new file mode 100644 index 0000000..3e28629 --- /dev/null +++ b/apps/api/src/modules/security/security.metadata.ts @@ -0,0 +1,26 @@ +import { SetMetadata, createParamDecorator, type ExecutionContext } from '@nestjs/common'; +import type { PermissionKey } from '@lisglosips/auth'; +import type { FastifyRequest } from 'fastify'; + +export const IS_PUBLIC_KEY = 'lisglosips:is_public'; +export const REQUIRED_PERMISSIONS_KEY = 'lisglosips:required_permissions'; + +export interface CurrentUser { + id: string; + username: string; + roles: string[]; + permissions: PermissionKey[]; +} + +export type AuthenticatedRequest = FastifyRequest & { + currentUser?: CurrentUser; +}; + +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); + +export const RequirePermissions = (...permissions: PermissionKey[]) => SetMetadata(REQUIRED_PERMISSIONS_KEY, permissions); + +export const CurrentUserParam = createParamDecorator((_data: unknown, context: ExecutionContext): CurrentUser | undefined => { + const request = context.switchToHttp().getRequest(); + return request.currentUser; +}); diff --git a/apps/api/src/modules/security/security.module.ts b/apps/api/src/modules/security/security.module.ts new file mode 100644 index 0000000..3d128e1 --- /dev/null +++ b/apps/api/src/modules/security/security.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { AccessTokenGuard } from './auth.guard.js'; +import { PrismaIdentityRepository, IDENTITY_REPOSITORY } from './identity.repository.js'; +import { RbacGuard } from './rbac.guard.js'; + +@Module({ + providers: [ + PrismaIdentityRepository, + { + provide: IDENTITY_REPOSITORY, + useExisting: PrismaIdentityRepository + }, + { + provide: APP_GUARD, + useClass: AccessTokenGuard + }, + { + provide: APP_GUARD, + useClass: RbacGuard + } + ], + exports: [IDENTITY_REPOSITORY] +}) +export class SecurityModule {} diff --git a/apps/api/src/modules/users/users.controller.ts b/apps/api/src/modules/users/users.controller.ts new file mode 100644 index 0000000..c8cf776 --- /dev/null +++ b/apps/api/src/modules/users/users.controller.ts @@ -0,0 +1,38 @@ +import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuditAction } from '../audit/audit.metadata.js'; +import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js'; +import { UsersService } from './users.service.js'; + +@ApiTags('users') +@Controller('users') +export class UsersController { + constructor(@Inject(UsersService) private readonly usersService: UsersService) {} + + @Get() + @RequirePermissions('users.view') + list() { + return this.usersService.list(); + } + + @Post() + @RequirePermissions('users.manage') + @AuditAction({ module: 'users', action: 'create', objectType: 'user' }) + create(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.usersService.create(body as never, currentUser?.id); + } + + @Patch(':id') + @RequirePermissions('users.manage') + @AuditAction({ module: 'users', action: 'update', objectType: 'user', objectIdParam: 'id' }) + update(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.usersService.update(id, body as never, currentUser?.id); + } + + @Post(':id/reset-password') + @RequirePermissions('users.manage') + @AuditAction({ module: 'users', action: 'reset_password', objectType: 'user', objectIdParam: 'id' }) + resetPassword(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.usersService.resetPassword(id, body as never, currentUser?.id); + } +} diff --git a/apps/api/src/modules/users/users.module.ts b/apps/api/src/modules/users/users.module.ts new file mode 100644 index 0000000..6333045 --- /dev/null +++ b/apps/api/src/modules/users/users.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { PrismaUsersRepository, USERS_REPOSITORY } from './users.repository.js'; +import { UsersController } from './users.controller.js'; +import { UsersService } from './users.service.js'; + +@Module({ + controllers: [UsersController], + providers: [ + UsersService, + PrismaUsersRepository, + { + provide: USERS_REPOSITORY, + useExisting: PrismaUsersRepository + } + ] +}) +export class UsersModule {} diff --git a/apps/api/src/modules/users/users.repository.ts b/apps/api/src/modules/users/users.repository.ts new file mode 100644 index 0000000..f1fc076 --- /dev/null +++ b/apps/api/src/modules/users/users.repository.ts @@ -0,0 +1,239 @@ +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import crypto from 'node:crypto'; +import { PrismaService } from '../database/prisma.service.js'; + +export interface UserSummary { + id: string; + username: string; + displayName: string; + phone: string | null; + email: string | null; + status: 'ENABLED' | 'DISABLED'; + requirePasswordChange: boolean; + lastLoginAt: Date | null; + roles: string[]; + roleIds: string[]; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateUserInput { + username: string; + displayName: string; + phone?: string; + email?: string; + passwordHash: string; + passwordAlgo: string; + requirePasswordChange: boolean; + roleIds: string[]; + actorId?: string; +} + +export interface UpdateUserInput { + displayName?: string; + phone?: string | null; + email?: string | null; + status?: 'ENABLED' | 'DISABLED'; + roleIds?: string[]; + actorId?: string; +} + +export interface UsersRepository { + list(): Promise; + create(input: CreateUserInput): Promise; + update(userId: string, input: UpdateUserInput): Promise; + resetPassword(userId: string, passwordHash: string, passwordAlgo: string, actorId?: string): Promise; +} + +export const USERS_REPOSITORY = Symbol('USERS_REPOSITORY'); + +function userId(): string { + return `usr_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`; +} + +@Injectable() +export class PrismaUsersRepository implements UsersRepository { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async list(): Promise { + const users = await this.prisma.user.findMany({ + where: { deletedAt: null }, + orderBy: [{ createdAt: 'desc' }], + include: { + userRoles: { + include: { + role: true + } + } + } + }); + + return users.map((user) => this.toSummary(user)); + } + + async create(input: CreateUserInput): Promise { + await this.ensureRoles(input.roleIds); + + const user = await this.prisma.$transaction(async (tx) => { + const created = await tx.user.create({ + data: { + id: userId(), + username: input.username, + displayName: input.displayName, + phone: input.phone, + email: input.email, + passwordHash: input.passwordHash, + passwordAlgo: input.passwordAlgo, + requirePasswordChange: input.requirePasswordChange, + createdBy: input.actorId, + updatedBy: input.actorId + } + }); + + for (const roleId of input.roleIds) { + await tx.userRole.create({ + data: { + userId: created.id, + roleId, + createdBy: input.actorId + } + }); + } + + return tx.user.findUniqueOrThrow({ + where: { id: created.id }, + include: { + userRoles: { + include: { + role: true + } + } + } + }); + }); + + return this.toSummary(user); + } + + async update(userIdValue: string, input: UpdateUserInput): Promise { + if (input.roleIds) { + await this.ensureRoles(input.roleIds); + } + + const user = await this.prisma.$transaction(async (tx) => { + const exists = await tx.user.findUnique({ where: { id: userIdValue } }); + if (!exists || exists.deletedAt) { + throw new NotFoundException({ code: 'USER_NOT_FOUND', message: 'User not found.' }); + } + + await tx.user.update({ + where: { id: userIdValue }, + data: { + displayName: input.displayName, + phone: input.phone, + email: input.email, + status: input.status, + updatedBy: input.actorId, + version: { increment: 1 } + } + }); + + if (input.roleIds) { + await tx.userRole.deleteMany({ where: { userId: userIdValue } }); + for (const roleId of input.roleIds) { + await tx.userRole.create({ + data: { + userId: userIdValue, + roleId, + createdBy: input.actorId + } + }); + } + } + + return tx.user.findUniqueOrThrow({ + where: { id: userIdValue }, + include: { + userRoles: { + include: { + role: true + } + } + } + }); + }); + + return this.toSummary(user); + } + + async resetPassword(userIdValue: string, passwordHash: string, passwordAlgo: string, actorId?: string): Promise { + const user = await this.prisma.user.update({ + where: { id: userIdValue }, + data: { + passwordHash, + passwordAlgo, + requirePasswordChange: true, + failedLoginCount: 0, + lockedUntil: null, + updatedBy: actorId, + version: { increment: 1 } + }, + include: { + userRoles: { + include: { + role: true + } + } + } + }); + + return this.toSummary(user); + } + + private async ensureRoles(roleIds: string[]): Promise { + const uniqueRoleIds = [...new Set(roleIds)]; + const count = await this.prisma.role.count({ + where: { + id: { in: uniqueRoleIds }, + status: 'ENABLED', + deletedAt: null + } + }); + + if (count !== uniqueRoleIds.length) { + throw new BadRequestException({ + code: 'ROLE_INVALID', + message: 'One or more roles are invalid.' + }); + } + } + + private toSummary(user: { + id: string; + username: string; + displayName: string; + phone: string | null; + email: string | null; + status: 'ENABLED' | 'DISABLED'; + requirePasswordChange: boolean; + lastLoginAt: Date | null; + createdAt: Date; + updatedAt: Date; + userRoles: { roleId: string; role: { name: string } }[]; + }): UserSummary { + return { + id: user.id, + username: user.username, + displayName: user.displayName, + phone: user.phone, + email: user.email, + status: user.status, + requirePasswordChange: user.requirePasswordChange, + lastLoginAt: user.lastLoginAt, + roleIds: user.userRoles.map((userRole) => userRole.roleId), + roles: user.userRoles.map((userRole) => userRole.role.name), + createdAt: user.createdAt, + updatedAt: user.updatedAt + }; + } +} diff --git a/apps/api/src/modules/users/users.service.ts b/apps/api/src/modules/users/users.service.ts new file mode 100644 index 0000000..8b724fb --- /dev/null +++ b/apps/api/src/modules/users/users.service.ts @@ -0,0 +1,119 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { PASSWORD_ALGO_ARGON2ID, hashPasswordArgon2id } from '@lisglosips/auth'; +import { USERS_REPOSITORY, type UpdateUserInput, type UserSummary, type UsersRepository } from './users.repository.js'; + +interface CreateUserDto { + username?: unknown; + displayName?: unknown; + phone?: unknown; + email?: unknown; + password?: unknown; + requirePasswordChange?: unknown; + roleIds?: unknown; +} + +interface UpdateUserDto { + displayName?: unknown; + phone?: unknown; + email?: unknown; + status?: unknown; + roleIds?: unknown; +} + +interface ResetPasswordDto { + password?: unknown; +} + +@Injectable() +export class UsersService { + constructor(@Inject(USERS_REPOSITORY) private readonly users: UsersRepository) {} + + list(): Promise { + return this.users.list(); + } + + async create(body: CreateUserDto, actorId?: string): Promise { + const username = this.requiredString(body.username, 'username').trim().toLowerCase(); + const displayName = this.requiredString(body.displayName, 'displayName').trim(); + const password = this.password(body.password); + const roleIds = this.roleIds(body.roleIds); + + return this.users.create({ + username, + displayName, + phone: this.optionalString(body.phone), + email: this.optionalString(body.email), + passwordHash: await hashPasswordArgon2id(password), + passwordAlgo: PASSWORD_ALGO_ARGON2ID, + requirePasswordChange: body.requirePasswordChange === undefined ? true : body.requirePasswordChange === true, + roleIds, + actorId + }); + } + + update(userId: string, body: UpdateUserDto, actorId?: string): Promise { + const input: UpdateUserInput = { + displayName: body.displayName === undefined ? undefined : this.requiredString(body.displayName, 'displayName').trim(), + phone: body.phone === undefined ? undefined : this.nullableString(body.phone), + email: body.email === undefined ? undefined : this.nullableString(body.email), + status: body.status === undefined ? undefined : this.status(body.status), + roleIds: body.roleIds === undefined ? undefined : this.roleIds(body.roleIds), + actorId + }; + + return this.users.update(userId, input); + } + + async resetPassword(userId: string, body: ResetPasswordDto, actorId?: string): Promise { + return this.users.resetPassword(userId, await hashPasswordArgon2id(this.password(body.password)), PASSWORD_ALGO_ARGON2ID, actorId); + } + + private requiredString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` }); + } + + return value; + } + + private optionalString(value: unknown): string | undefined { + if (value === undefined) { + return undefined; + } + + return this.requiredString(value, 'value').trim(); + } + + private nullableString(value: unknown): string | null { + if (value === null) { + return null; + } + + return this.requiredString(value, 'value').trim(); + } + + private password(value: unknown): string { + const password = this.requiredString(value, 'password'); + if (password.length < 12) { + throw new BadRequestException({ code: 'PASSWORD_TOO_SHORT', message: 'Password must be at least 12 characters.' }); + } + + return password; + } + + private roleIds(value: unknown): string[] { + if (!Array.isArray(value) || value.length === 0 || !value.every((roleId) => typeof roleId === 'string' && roleId.length > 0)) { + throw new BadRequestException({ code: 'ROLE_IDS_REQUIRED', message: 'At least one role is required.' }); + } + + return [...new Set(value as string[])]; + } + + private status(value: unknown): 'ENABLED' | 'DISABLED' { + if (value !== 'ENABLED' && value !== 'DISABLED') { + throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' }); + } + + return value; + } +} diff --git a/apps/api/src/modules/vendor-gateways/vendor-gateways.controller.ts b/apps/api/src/modules/vendor-gateways/vendor-gateways.controller.ts new file mode 100644 index 0000000..56e75f3 --- /dev/null +++ b/apps/api/src/modules/vendor-gateways/vendor-gateways.controller.ts @@ -0,0 +1,51 @@ +import { Body, Controller, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuditAction } from '../audit/audit.metadata.js'; +import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js'; +import { VendorGatewaysService } from './vendor-gateways.service.js'; + +@ApiTags('vendor-gateways') +@Controller('vendor-gateways') +export class VendorGatewaysController { + constructor(@Inject(VendorGatewaysService) private readonly vendorGatewaysService: VendorGatewaysService) {} + + @Get() + @RequirePermissions('vendor_gateways.view') + list(@Query() query: unknown) { + return this.vendorGatewaysService.list(query as never); + } + + @Get(':id') + @RequirePermissions('vendor_gateways.view') + get(@Param('id') id: string) { + return this.vendorGatewaysService.get(id); + } + + @Post() + @RequirePermissions('vendor_gateways.manage') + @AuditAction({ module: 'vendor_gateways', action: 'create', objectType: 'vendor_gateway' }) + create(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.vendorGatewaysService.create(body as never, currentUser?.id); + } + + @Patch(':id') + @RequirePermissions('vendor_gateways.manage') + @AuditAction({ module: 'vendor_gateways', action: 'update', objectType: 'vendor_gateway', objectIdParam: 'id' }) + update(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.vendorGatewaysService.update(id, body as never, currentUser?.id); + } + + @Post(':id/enable') + @RequirePermissions('vendor_gateways.manage') + @AuditAction({ module: 'vendor_gateways', action: 'enable', objectType: 'vendor_gateway', objectIdParam: 'id' }) + enable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.vendorGatewaysService.enable(id, currentUser?.id); + } + + @Post(':id/disable') + @RequirePermissions('vendor_gateways.manage') + @AuditAction({ module: 'vendor_gateways', action: 'disable', objectType: 'vendor_gateway', objectIdParam: 'id' }) + disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.vendorGatewaysService.disable(id, currentUser?.id); + } +} diff --git a/apps/api/src/modules/vendor-gateways/vendor-gateways.e2e.spec.ts b/apps/api/src/modules/vendor-gateways/vendor-gateways.e2e.spec.ts new file mode 100644 index 0000000..8d06ddc --- /dev/null +++ b/apps/api/src/modules/vendor-gateways/vendor-gateways.e2e.spec.ts @@ -0,0 +1,317 @@ +import 'reflect-metadata'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import request from 'supertest'; +import { signAccessToken, type PermissionKey } from '@lisglosips/auth'; +import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js'; +import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js'; +import type { CurrentUser } from '../security/security.metadata.js'; +import { + VENDOR_GATEWAYS_REPOSITORY, + type CreateVendorGatewayInput, + type UpdateVendorGatewayInput, + type VendorGatewayStatus, + type VendorGatewaySummary, + type VendorGatewaysRepository +} from './vendor-gateways.repository.js'; + +class MemoryIdentityRepository implements IdentityRepository { + users = new Map(); + + async findCurrentUserById(userId: string): Promise { + return this.users.get(userId) ?? null; + } +} + +class MemoryAuditRepository implements AuditRepository { + entries: AuditEntryInput[] = []; + + async write(input: AuditEntryInput): Promise { + this.entries.push(input); + } +} + +class MemoryVendorGatewaysRepository implements VendorGatewaysRepository { + private readonly gateways = new Map(); + outboxEvents = 0; + + constructor() { + this.gateways.set( + 'vgw_seed', + this.summary({ + id: 'vgw_seed', + vendorId: 'ven_seed', + name: 'Seed Gateway', + host: 'carrier.example.local', + cpsLimit: 20, + concurrencyLimit: 200, + cycleRate: '0.120000', + billingCycleSec: 60 + }) + ); + } + + async list(vendorId?: string): Promise { + return [...this.gateways.values()].filter((gateway) => !vendorId || gateway.vendorId === vendorId); + } + + async get(gatewayId: string): Promise { + return this.gateways.get(gatewayId) ?? this.summary({ id: gatewayId, name: 'Missing Gateway' }); + } + + async create(input: CreateVendorGatewayInput): Promise { + const gateway = this.summary({ + id: 'vgw_created', + vendorId: input.vendorId, + name: input.name, + authMode: input.authMode, + host: input.host, + port: input.port, + transport: input.transport, + sipUsername: input.sipUsername ?? null, + hasSipCredential: Boolean(input.sipHa1), + cpsLimit: input.cpsLimit, + concurrencyLimit: input.concurrencyLimit, + billingCycleSec: input.billingCycleSec, + cycleRate: input.cycleRate, + forbiddenPeriods: input.forbiddenPeriods.map((period, index) => ({ id: `period_${index}`, ...period })), + codecs: input.codecs.map((codec, index) => ({ id: `codec_${index}`, ...codec })), + prefixRules: input.prefixRules.map((rule, index) => ({ id: `rule_${index}`, ...rule })) + }); + this.gateways.set(gateway.id, gateway); + this.outboxEvents += 1; + return gateway; + } + + async update(gatewayId: string, input: UpdateVendorGatewayInput): Promise { + const current = await this.get(gatewayId); + const updated = this.summary({ + ...current, + vendorId: input.vendorId ?? current.vendorId, + name: input.name ?? current.name, + authMode: input.authMode ?? current.authMode, + host: input.host ?? current.host, + port: input.port ?? current.port, + transport: input.transport ?? current.transport, + sipUsername: input.sipUsername === undefined ? current.sipUsername : input.sipUsername, + hasSipCredential: input.sipHa1 === undefined ? current.hasSipCredential : Boolean(input.sipHa1), + cpsLimit: input.cpsLimit ?? current.cpsLimit, + concurrencyLimit: input.concurrencyLimit ?? current.concurrencyLimit, + billingCycleSec: input.billingCycleSec ?? current.billingCycleSec, + cycleRate: input.cycleRate ?? current.cycleRate, + status: input.status ?? current.status, + forbiddenPeriods: input.forbiddenPeriods === undefined ? current.forbiddenPeriods : input.forbiddenPeriods.map((period, index) => ({ id: `period_updated_${index}`, ...period })), + codecs: input.codecs === undefined ? current.codecs : input.codecs.map((codec, index) => ({ id: `codec_updated_${index}`, ...codec })), + prefixRules: input.prefixRules === undefined ? current.prefixRules : input.prefixRules.map((rule, index) => ({ id: `rule_updated_${index}`, ...rule })) + }); + this.gateways.set(gatewayId, updated); + this.outboxEvents += 1; + return updated; + } + + async setStatus(gatewayId: string, status: VendorGatewayStatus): Promise { + return this.update(gatewayId, { status }); + } + + private summary(input: Partial & { id: string; name: string }): VendorGatewaySummary { + const cycleRate = input.cycleRate ?? '0.000000'; + const billingCycleSec = input.billingCycleSec ?? 60; + return { + id: input.id, + vendorId: input.vendorId ?? 'ven_seed', + vendorName: input.vendorName ?? 'Seed Vendor', + name: input.name, + authMode: input.authMode ?? 'IP', + host: input.host ?? 'carrier.example.local', + port: input.port ?? 5060, + transport: input.transport ?? 'udp', + sipUsername: input.sipUsername ?? null, + hasSipCredential: input.hasSipCredential ?? false, + cpsLimit: input.cpsLimit ?? 0, + concurrencyLimit: input.concurrencyLimit ?? 0, + billingCycleSec, + cycleRate, + minuteRate: (Number(cycleRate) * 60 / billingCycleSec).toFixed(6), + status: input.status ?? 'ENABLED', + forbiddenPeriods: input.forbiddenPeriods ?? [], + codecs: input.codecs ?? [], + prefixRules: input.prefixRules ?? [], + createdAt: new Date('2026-06-21T06:00:00.000Z'), + updatedAt: new Date('2026-06-21T06:00:00.000Z') + }; + } +} + +describe('S16 vendor gateways API', () => { + let app: NestFastifyApplication; + let audit: MemoryAuditRepository; + let repository: MemoryVendorGatewaysRepository; + + const tokenFor = (userId: string) => + signAccessToken( + { + sub: userId, + username: userId, + roles: ['test'], + typ: 'access' + }, + { + secret: 'test-only-access-token-secret-min-32-bytes', + issuer: 'lisglosips-api', + audience: 'lisglosips-web', + ttlSeconds: 900 + } + ); + + beforeAll(async () => { + process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips'; + process.env.REDIS_URL = 'redis://127.0.0.1:6379/0'; + process.env.LISGLOSIPS_LOG_LEVEL = 'silent'; + process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes'; + + const identities = new MemoryIdentityRepository(); + audit = new MemoryAuditRepository(); + repository = new MemoryVendorGatewaysRepository(); + + identities.users.set('usr_ops', { + id: 'usr_ops', + username: 'ops', + roles: ['运营管理员'], + permissions: ['vendor_gateways.view', 'vendor_gateways.manage'] as PermissionKey[] + }); + identities.users.set('usr_viewer', { + id: 'usr_viewer', + username: 'viewer', + roles: ['只读'], + permissions: ['vendor_gateways.view'] as PermissionKey[] + }); + + const { AppModule } = await import('../app.module.js'); + const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(IDENTITY_REPOSITORY) + .useValue(identities) + .overrideProvider(AUDIT_REPOSITORY) + .useValue(audit) + .overrideProvider(VENDOR_GATEWAYS_REPOSITORY) + .useValue(repository) + .compile(); + + app = moduleFixture.createNestApplication(new FastifyAdapter({ logger: false })); + app.setGlobalPrefix('api/v2'); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('lists vendor gateways with limits and derived minute rate', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v2/vendor-gateways?vendorId=ven_seed') + .set('Authorization', `Bearer ${tokenFor('usr_viewer')}`) + .expect(200); + + expect(response.body[0]).toMatchObject({ + id: 'vgw_seed', + cpsLimit: 20, + concurrencyLimit: 200, + cycleRate: '0.120000', + minuteRate: '0.120000' + }); + expect(JSON.stringify(response.body)).not.toContain('sipHa1'); + }); + + it('rejects writes without vendor_gateways.manage', async () => { + await request(app.getHttpServer()) + .post('/api/v2/vendor-gateways') + .set('Authorization', `Bearer ${tokenFor('usr_viewer')}`) + .send({ vendorId: 'ven_seed', name: 'Denied', authMode: 'IP', host: '1.2.3.4' }) + .expect(403); + }); + + it('creates and updates a full vendor gateway configuration', async () => { + await request(app.getHttpServer()) + .post('/api/v2/vendor-gateways') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ + vendorId: 'ven_seed', + name: 'Carrier SIP', + authMode: 'SIP_DIGEST', + host: 'SIP.CARRIER.LOCAL', + port: 5060, + transport: 'udp', + sipUsername: 'carrier-user', + sipPassword: 'change-me-very-strong', + cpsLimit: 30, + concurrencyLimit: 300, + billingCycleSec: 6, + cycleRate: '0.012', + forbiddenPeriods: [{ weekdayMask: 62, startTime: '23:00:00', endTime: '23:59:59' }], + codecs: [ + { codec: 'PCMA', priority: 1 }, + { codec: 'PCMU', priority: 2 } + ], + prefixRules: [{ direction: 'CALLEE', matchPrefix: '00', replacePrefix: '+', priority: 1 }] + }) + .expect(201) + .expect((response) => { + expect(response.body).toMatchObject({ + id: 'vgw_created', + authMode: 'SIP_DIGEST', + host: 'sip.carrier.local', + hasSipCredential: true, + billingCycleSec: 6, + cycleRate: '0.012000', + minuteRate: '0.120000' + }); + expect(response.body.sipHa1).toBeUndefined(); + }); + + await request(app.getHttpServer()) + .patch('/api/v2/vendor-gateways/vgw_created') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ + authMode: 'IP', + host: '203.0.113.10', + codecs: [{ codec: 'G729', priority: 1 }], + prefixRules: [{ direction: 'CALLER', matchPrefix: '+86', replacePrefix: '0', priority: 1 }] + }) + .expect(200) + .expect((response) => { + expect(response.body).toMatchObject({ + authMode: 'IP', + host: '203.0.113.10', + sipUsername: null, + hasSipCredential: false + }); + expect(response.body.codecs).toHaveLength(1); + expect(response.body.prefixRules[0]).toMatchObject({ direction: 'CALLER', matchPrefix: '+86' }); + }); + + expect(repository.outboxEvents).toBeGreaterThanOrEqual(2); + expect(audit.entries.some((entry) => entry.module === 'vendor_gateways' && entry.action === 'create')).toBe(true); + expect(JSON.stringify(audit.entries)).not.toContain('change-me-very-strong'); + }); + + it('validates child configuration and supports enable/disable', async () => { + await request(app.getHttpServer()) + .post('/api/v2/vendor-gateways') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ + vendorId: 'ven_seed', + name: 'Invalid Codec', + authMode: 'IP', + host: '203.0.113.20', + codecs: [ + { codec: 'PCMA', priority: 1 }, + { codec: 'PCMU', priority: 1 } + ] + }) + .expect(400); + + await request(app.getHttpServer()).post('/api/v2/vendor-gateways/vgw_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201); + await request(app.getHttpServer()).post('/api/v2/vendor-gateways/vgw_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201); + }); +}); diff --git a/apps/api/src/modules/vendor-gateways/vendor-gateways.module.ts b/apps/api/src/modules/vendor-gateways/vendor-gateways.module.ts new file mode 100644 index 0000000..bc6fa03 --- /dev/null +++ b/apps/api/src/modules/vendor-gateways/vendor-gateways.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { VENDOR_GATEWAYS_REPOSITORY, PrismaVendorGatewaysRepository } from './vendor-gateways.repository.js'; +import { VendorGatewaysController } from './vendor-gateways.controller.js'; +import { VendorGatewaysService } from './vendor-gateways.service.js'; + +@Module({ + controllers: [VendorGatewaysController], + providers: [ + VendorGatewaysService, + { + provide: VENDOR_GATEWAYS_REPOSITORY, + useClass: PrismaVendorGatewaysRepository + } + ], + exports: [VendorGatewaysService] +}) +export class VendorGatewaysModule {} diff --git a/apps/api/src/modules/vendor-gateways/vendor-gateways.repository.ts b/apps/api/src/modules/vendor-gateways/vendor-gateways.repository.ts new file mode 100644 index 0000000..78cdf4d --- /dev/null +++ b/apps/api/src/modules/vendor-gateways/vendor-gateways.repository.ts @@ -0,0 +1,375 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import crypto from 'node:crypto'; +import { Prisma } from '@lisglosips/database'; +import { PrismaService } from '../database/prisma.service.js'; + +export type VendorGatewayStatus = 'ENABLED' | 'DISABLED'; +export type VendorGatewayAuthMode = 'IP' | 'SIP_DIGEST' | 'MIXED'; +export type PrefixDirection = 'CALLER' | 'CALLEE'; + +export interface ForbiddenPeriodSummary { + id: string; + weekdayMask: number; + startTime: string; + endTime: string; +} + +export interface CodecSummary { + id: string; + codec: string; + priority: number; +} + +export interface PrefixRuleSummary { + id: string; + direction: PrefixDirection; + matchPrefix: string; + replacePrefix: string; + priority: number; +} + +export interface VendorGatewaySummary { + id: string; + vendorId: string; + vendorName: string; + name: string; + authMode: VendorGatewayAuthMode; + host: string; + port: number; + transport: string; + sipUsername: string | null; + hasSipCredential: boolean; + cpsLimit: number; + concurrencyLimit: number; + billingCycleSec: number; + cycleRate: string; + minuteRate: string; + status: VendorGatewayStatus; + forbiddenPeriods: ForbiddenPeriodSummary[]; + codecs: CodecSummary[]; + prefixRules: PrefixRuleSummary[]; + createdAt: Date; + updatedAt: Date; +} + +export interface ForbiddenPeriodInput { + weekdayMask: number; + startTime: string; + endTime: string; +} + +export interface CodecInput { + codec: string; + priority: number; +} + +export interface PrefixRuleInput { + direction: PrefixDirection; + matchPrefix: string; + replacePrefix: string; + priority: number; +} + +export interface CreateVendorGatewayInput { + vendorId: string; + name: string; + authMode: VendorGatewayAuthMode; + host: string; + port: number; + transport: string; + sipUsername?: string | null; + sipHa1?: string | null; + cpsLimit: number; + concurrencyLimit: number; + billingCycleSec: number; + cycleRate: string; + status: VendorGatewayStatus; + forbiddenPeriods: ForbiddenPeriodInput[]; + codecs: CodecInput[]; + prefixRules: PrefixRuleInput[]; + actorId?: string; +} + +export type UpdateVendorGatewayInput = Partial> & { + actorId?: string; +}; + +export interface VendorGatewaysRepository { + list(vendorId?: string): Promise; + get(gatewayId: string): Promise; + create(input: CreateVendorGatewayInput): Promise; + update(gatewayId: string, input: UpdateVendorGatewayInput): Promise; + setStatus(gatewayId: string, status: VendorGatewayStatus, actorId?: string): Promise; +} + +export const VENDOR_GATEWAYS_REPOSITORY = Symbol('VENDOR_GATEWAYS_REPOSITORY'); + +function gatewayId(): string { + return `vgw_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`; +} + +function childId(prefix: string): string { + return `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`; +} + +function outboxId(): string { + return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`; +} + +@Injectable() +export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async list(vendorId?: string): Promise { + const gateways = await this.prisma.vendorGateway.findMany({ + where: { + deletedAt: null, + vendorId + }, + orderBy: [{ createdAt: 'desc' }], + include: this.includeSummary() + }); + return gateways.map((gateway) => this.toSummary(gateway)); + } + + async get(gatewayIdValue: string): Promise { + return this.toSummary(await this.findActiveOrThrow(gatewayIdValue)); + } + + async create(input: CreateVendorGatewayInput): Promise { + await this.ensureVendor(input.vendorId); + try { + const gateway = await this.prisma.$transaction(async (tx) => { + const created = await tx.vendorGateway.create({ + data: { + id: gatewayId(), + vendorId: input.vendorId, + name: input.name, + authMode: input.authMode, + host: input.host, + port: input.port, + transport: input.transport, + sipUsername: input.sipUsername, + sipHa1: input.sipHa1, + cpsLimit: input.cpsLimit, + concurrencyLimit: input.concurrencyLimit, + billingCycleSec: input.billingCycleSec, + cycleRate: new Prisma.Decimal(input.cycleRate), + status: input.status, + createdBy: input.actorId, + updatedBy: input.actorId, + forbiddenPeriods: { create: input.forbiddenPeriods.map((period) => this.forbiddenCreate(period, input.actorId)) }, + codecs: { create: input.codecs.map((codec) => this.codecCreate(codec, input.actorId)) }, + prefixRules: { create: input.prefixRules.map((rule) => this.prefixRuleCreate(rule, input.actorId)) } + }, + include: this.includeSummary() + }); + await this.enqueueConfigOutbox(tx, created.id, 'vendor_gateway.changed'); + return created; + }); + return this.toSummary(gateway); + } catch (error) { + this.handleUniqueConflict(error); + throw error; + } + } + + async update(gatewayIdValue: string, input: UpdateVendorGatewayInput): Promise { + const existing = await this.findActiveOrThrow(gatewayIdValue); + if (input.vendorId) { + await this.ensureVendor(input.vendorId); + } + + try { + const gateway = await this.prisma.$transaction(async (tx) => { + if (input.forbiddenPeriods) { + await tx.vendorGatewayForbiddenPeriod.deleteMany({ where: { vendorGatewayId: gatewayIdValue } }); + } + if (input.codecs) { + await tx.vendorGatewayCodec.deleteMany({ where: { vendorGatewayId: gatewayIdValue } }); + } + if (input.prefixRules) { + await tx.vendorGatewayPrefixRule.deleteMany({ where: { vendorGatewayId: gatewayIdValue } }); + } + + const updated = await tx.vendorGateway.update({ + where: { id: gatewayIdValue }, + data: { + vendorId: input.vendorId, + name: input.name, + authMode: input.authMode, + host: input.host, + port: input.port, + transport: input.transport, + sipUsername: input.sipUsername, + sipHa1: input.sipHa1, + cpsLimit: input.cpsLimit, + concurrencyLimit: input.concurrencyLimit, + billingCycleSec: input.billingCycleSec, + cycleRate: input.cycleRate === undefined ? undefined : new Prisma.Decimal(input.cycleRate), + status: input.status, + updatedBy: input.actorId, + version: { increment: 1 }, + forbiddenPeriods: input.forbiddenPeriods ? { create: input.forbiddenPeriods.map((period) => this.forbiddenCreate(period, input.actorId)) } : undefined, + codecs: input.codecs ? { create: input.codecs.map((codec) => this.codecCreate(codec, input.actorId)) } : undefined, + prefixRules: input.prefixRules ? { create: input.prefixRules.map((rule) => this.prefixRuleCreate(rule, input.actorId)) } : undefined + }, + include: this.includeSummary() + }); + await this.enqueueConfigOutbox(tx, existing.id, 'vendor_gateway.changed'); + return updated; + }); + return this.toSummary(gateway); + } catch (error) { + this.handleUniqueConflict(error); + throw error; + } + } + + async setStatus(gatewayIdValue: string, status: VendorGatewayStatus, actorId?: string): Promise { + await this.findActiveOrThrow(gatewayIdValue); + const gateway = await this.prisma.$transaction(async (tx) => { + const updated = await tx.vendorGateway.update({ + where: { id: gatewayIdValue }, + data: { + status, + updatedBy: actorId, + version: { increment: 1 } + }, + include: this.includeSummary() + }); + await this.enqueueConfigOutbox(tx, updated.id, 'vendor_gateway.changed'); + return updated; + }); + return this.toSummary(gateway); + } + + private async ensureVendor(vendorId: string): Promise { + const vendor = await this.prisma.vendor.findUnique({ + where: { id: vendorId }, + select: { id: true, deletedAt: true } + }); + if (!vendor || vendor.deletedAt) { + throw new NotFoundException({ code: 'VENDOR_NOT_FOUND', message: 'Vendor not found.' }); + } + } + + private async findActiveOrThrow(gatewayIdValue: string) { + const gateway = await this.prisma.vendorGateway.findUnique({ + where: { id: gatewayIdValue }, + include: this.includeSummary() + }); + if (!gateway || gateway.deletedAt) { + throw new NotFoundException({ code: 'VENDOR_GATEWAY_NOT_FOUND', message: 'Vendor gateway not found.' }); + } + return gateway; + } + + private forbiddenCreate(period: ForbiddenPeriodInput, actorId?: string) { + return { + id: childId('vgfp'), + weekdayMask: period.weekdayMask, + startTime: period.startTime, + endTime: period.endTime, + createdBy: actorId, + updatedBy: actorId + }; + } + + private codecCreate(codec: CodecInput, actorId?: string) { + return { + id: childId('vgc'), + codec: codec.codec, + priority: codec.priority, + createdBy: actorId, + updatedBy: actorId + }; + } + + private prefixRuleCreate(rule: PrefixRuleInput, actorId?: string) { + return { + id: childId('vgpr'), + direction: rule.direction, + matchPrefix: rule.matchPrefix, + replacePrefix: rule.replacePrefix, + priority: rule.priority, + createdBy: actorId, + updatedBy: actorId + }; + } + + private includeSummary() { + return { + vendor: { select: { name: true } }, + forbiddenPeriods: { orderBy: [{ weekdayMask: 'asc' }, { startTime: 'asc' }] }, + codecs: { orderBy: [{ priority: 'asc' }] }, + prefixRules: { orderBy: [{ direction: 'asc' }, { priority: 'asc' }] } + } satisfies Prisma.VendorGatewayInclude; + } + + private async enqueueConfigOutbox(tx: Prisma.TransactionClient, gatewayIdValue: string, eventType: string): Promise { + await tx.outboxEvent.create({ + data: { + id: outboxId(), + aggregateType: 'vendor_gateway_config', + aggregateId: gatewayIdValue, + eventType, + payload: { gatewayId: gatewayIdValue, eventType } + } + }); + } + + private toSummary(gateway: { + id: string; + vendorId: string; + name: string; + authMode: VendorGatewayAuthMode; + host: string; + port: number; + transport: string; + sipUsername: string | null; + sipHa1: string | null; + cpsLimit: number; + concurrencyLimit: number; + billingCycleSec: number; + cycleRate: Prisma.Decimal; + status: VendorGatewayStatus; + createdAt: Date; + updatedAt: Date; + vendor: { name: string }; + forbiddenPeriods: Array<{ id: string; weekdayMask: number; startTime: string; endTime: string }>; + codecs: Array<{ id: string; codec: string; priority: number }>; + prefixRules: Array<{ id: string; direction: PrefixDirection; matchPrefix: string; replacePrefix: string; priority: number }>; + }): VendorGatewaySummary { + const minuteRate = gateway.cycleRate.mul(new Prisma.Decimal(60)).div(gateway.billingCycleSec); + return { + id: gateway.id, + vendorId: gateway.vendorId, + vendorName: gateway.vendor.name, + name: gateway.name, + authMode: gateway.authMode, + host: gateway.host, + port: gateway.port, + transport: gateway.transport, + sipUsername: gateway.sipUsername, + hasSipCredential: Boolean(gateway.sipHa1), + cpsLimit: gateway.cpsLimit, + concurrencyLimit: gateway.concurrencyLimit, + billingCycleSec: gateway.billingCycleSec, + cycleRate: gateway.cycleRate.toFixed(6), + minuteRate: minuteRate.toFixed(6), + status: gateway.status, + forbiddenPeriods: gateway.forbiddenPeriods, + codecs: gateway.codecs, + prefixRules: gateway.prefixRules, + createdAt: gateway.createdAt, + updatedAt: gateway.updatedAt + }; + } + + private handleUniqueConflict(error: unknown): void { + if (error && typeof error === 'object' && 'code' in error && (error as { code?: unknown }).code === 'P2002') { + throw new ConflictException({ code: 'VENDOR_GATEWAY_CONFLICT', message: 'Vendor gateway name or child priority already exists.' }); + } + } +} diff --git a/apps/api/src/modules/vendor-gateways/vendor-gateways.service.ts b/apps/api/src/modules/vendor-gateways/vendor-gateways.service.ts new file mode 100644 index 0000000..ba0f74d --- /dev/null +++ b/apps/api/src/modules/vendor-gateways/vendor-gateways.service.ts @@ -0,0 +1,324 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import crypto from 'node:crypto'; +import net from 'node:net'; +import { + VENDOR_GATEWAYS_REPOSITORY, + type CodecInput, + type CreateVendorGatewayInput, + type ForbiddenPeriodInput, + type PrefixDirection, + type PrefixRuleInput, + type UpdateVendorGatewayInput, + type VendorGatewayAuthMode, + type VendorGatewayStatus, + type VendorGatewaySummary, + type VendorGatewaysRepository +} from './vendor-gateways.repository.js'; + +interface VendorGatewayDto { + vendorId?: unknown; + name?: unknown; + authMode?: unknown; + host?: unknown; + port?: unknown; + transport?: unknown; + sipUsername?: unknown; + sipPassword?: unknown; + cpsLimit?: unknown; + concurrencyLimit?: unknown; + billingCycleSec?: unknown; + cycleRate?: unknown; + status?: unknown; + forbiddenPeriods?: unknown; + codecs?: unknown; + prefixRules?: unknown; +} + +@Injectable() +export class VendorGatewaysService { + constructor(@Inject(VENDOR_GATEWAYS_REPOSITORY) private readonly gateways: VendorGatewaysRepository) {} + + list(query: { vendorId?: unknown } = {}): Promise { + const vendorId = query.vendorId === undefined ? undefined : this.limitedString(query.vendorId, 'vendorId', 32); + return this.gateways.list(vendorId); + } + + get(gatewayId: string): Promise { + return this.gateways.get(gatewayId); + } + + create(body: VendorGatewayDto, actorId?: string): Promise { + const authMode = this.authMode(body.authMode); + const host = this.host(body.host); + const sipUsername = this.normalizeSipUsername(authMode, body.sipUsername); + const sipPassword = this.requiredSipPassword(authMode, body.sipPassword); + const input: CreateVendorGatewayInput = { + vendorId: this.limitedString(body.vendorId, 'vendorId', 32), + name: this.limitedString(body.name, 'name', 120), + authMode, + host, + port: body.port === undefined ? 5060 : this.integer(body.port, 'port', 1, 65535), + transport: body.transport === undefined ? 'udp' : this.transport(body.transport), + sipUsername, + sipHa1: sipPassword ? this.sipHa1(sipUsername, host, sipPassword) : undefined, + cpsLimit: body.cpsLimit === undefined ? 0 : this.integer(body.cpsLimit, 'cpsLimit', 0, 10000), + concurrencyLimit: body.concurrencyLimit === undefined ? 0 : this.integer(body.concurrencyLimit, 'concurrencyLimit', 0, 100000), + billingCycleSec: body.billingCycleSec === undefined ? 60 : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 60), + cycleRate: body.cycleRate === undefined ? '0.000000' : this.money(body.cycleRate, 'cycleRate'), + status: body.status === undefined ? 'ENABLED' : this.status(body.status), + forbiddenPeriods: this.forbiddenPeriods(body.forbiddenPeriods), + codecs: this.codecs(body.codecs), + prefixRules: this.prefixRules(body.prefixRules), + actorId + }; + return this.gateways.create(input); + } + + async update(gatewayId: string, body: VendorGatewayDto, actorId?: string): Promise { + const current = await this.gateways.get(gatewayId); + const authMode = body.authMode === undefined ? current.authMode : this.authMode(body.authMode); + const host = body.host === undefined ? current.host : this.host(body.host); + const sipUsername = body.sipUsername === undefined ? current.sipUsername : this.nullableSipUsername(authMode, body.sipUsername); + const password = body.sipPassword === undefined ? undefined : this.requiredSipPassword(authMode, body.sipPassword); + + if (this.requiresSip(authMode)) { + const identityChanged = sipUsername !== current.sipUsername || host !== current.host; + if (!password && (identityChanged || !current.hasSipCredential)) { + throw new BadRequestException({ + code: 'SIP_PASSWORD_REQUIRED', + message: 'sipPassword is required when creating or changing SIP identity.' + }); + } + } + + const input: UpdateVendorGatewayInput = { + vendorId: body.vendorId === undefined ? undefined : this.limitedString(body.vendorId, 'vendorId', 32), + name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120), + authMode, + host, + port: body.port === undefined ? undefined : this.integer(body.port, 'port', 1, 65535), + transport: body.transport === undefined ? undefined : this.transport(body.transport), + sipUsername: this.requiresSip(authMode) ? sipUsername : null, + sipHa1: password ? this.sipHa1(sipUsername, host, password) : this.requiresSip(authMode) ? undefined : null, + cpsLimit: body.cpsLimit === undefined ? undefined : this.integer(body.cpsLimit, 'cpsLimit', 0, 10000), + concurrencyLimit: body.concurrencyLimit === undefined ? undefined : this.integer(body.concurrencyLimit, 'concurrencyLimit', 0, 100000), + billingCycleSec: body.billingCycleSec === undefined ? undefined : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 60), + cycleRate: body.cycleRate === undefined ? undefined : this.money(body.cycleRate, 'cycleRate'), + status: body.status === undefined ? undefined : this.status(body.status), + forbiddenPeriods: body.forbiddenPeriods === undefined ? undefined : this.forbiddenPeriods(body.forbiddenPeriods), + codecs: body.codecs === undefined ? undefined : this.codecs(body.codecs), + prefixRules: body.prefixRules === undefined ? undefined : this.prefixRules(body.prefixRules), + actorId + }; + return this.gateways.update(gatewayId, input); + } + + enable(gatewayId: string, actorId?: string): Promise { + return this.gateways.setStatus(gatewayId, 'ENABLED', actorId); + } + + disable(gatewayId: string, actorId?: string): Promise { + return this.gateways.setStatus(gatewayId, 'DISABLED', actorId); + } + + private limitedString(value: unknown, field: string, maxLength: number): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` }); + } + const trimmed = value.trim(); + if (trimmed.length > maxLength) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is too long.` }); + } + return trimmed; + } + + private authMode(value: unknown): VendorGatewayAuthMode { + if (value !== 'IP' && value !== 'SIP_DIGEST' && value !== 'MIXED') { + throw new BadRequestException({ code: 'AUTH_MODE_INVALID', message: 'Auth mode is invalid.' }); + } + return value; + } + + private status(value: unknown): VendorGatewayStatus { + if (value !== 'ENABLED' && value !== 'DISABLED') { + throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' }); + } + return value; + } + + private host(value: unknown): string { + const host = this.limitedString(value, 'host', 160).toLowerCase(); + if (net.isIP(host) === 0 && !/^[a-z0-9.-]+$/.test(host)) { + throw new BadRequestException({ code: 'HOST_INVALID', message: 'host must be an IP address or hostname.' }); + } + return host; + } + + private transport(value: unknown): string { + const transport = this.limitedString(value, 'transport', 16).toLowerCase(); + if (transport !== 'udp' && transport !== 'tcp' && transport !== 'tls') { + throw new BadRequestException({ code: 'TRANSPORT_INVALID', message: 'transport must be udp, tcp, or tls.' }); + } + return transport; + } + + private integer(value: unknown, field: string, min: number, max: number): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) { + throw new BadRequestException({ code: 'INTEGER_INVALID', message: `${field} must be an integer from ${min} to ${max}.` }); + } + return value; + } + + private money(value: unknown, field: string): string { + const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : ''; + if (!/^\d{1,14}(?:\.\d{1,6})?$/.test(raw)) { + throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be a non-negative decimal with up to 6 places.` }); + } + const [integerPart, fractionPart = ''] = raw.split('.'); + return `${integerPart}.${fractionPart.padEnd(6, '0')}`; + } + + private normalizeSipUsername(authMode: VendorGatewayAuthMode, value: unknown): string | null { + if (!this.requiresSip(authMode)) { + return null; + } + return this.sipUsername(value); + } + + private nullableSipUsername(authMode: VendorGatewayAuthMode, value: unknown): string | null { + if (!this.requiresSip(authMode)) { + return null; + } + return this.sipUsername(value); + } + + private sipUsername(value: unknown): string { + const username = this.limitedString(value, 'sipUsername', 120); + if (!/^[A-Za-z0-9_.:+-]+$/.test(username)) { + throw new BadRequestException({ code: 'SIP_USERNAME_INVALID', message: 'sipUsername contains invalid characters.' }); + } + return username; + } + + private requiredSipPassword(authMode: VendorGatewayAuthMode, value: unknown): string | undefined { + if (!this.requiresSip(authMode)) { + return undefined; + } + const password = this.limitedString(value, 'sipPassword', 128); + if (password.length < 12) { + throw new BadRequestException({ code: 'SIP_PASSWORD_WEAK', message: 'sipPassword must be at least 12 characters.' }); + } + return password; + } + + private sipHa1(username: string | null, host: string, password: string): string { + if (!username) { + throw new BadRequestException({ code: 'SIP_IDENTITY_REQUIRED', message: 'SIP identity is required.' }); + } + return crypto.createHash('md5').update(`${username}:${host}:${password}`, 'utf8').digest('hex'); + } + + private forbiddenPeriods(value: unknown): ForbiddenPeriodInput[] { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + throw new BadRequestException({ code: 'FORBIDDEN_PERIODS_INVALID', message: 'forbiddenPeriods must be an array.' }); + } + return value.map((item, index) => { + const period = item as Record; + return { + weekdayMask: this.integer(period.weekdayMask, `forbiddenPeriods[${index}].weekdayMask`, 1, 127), + startTime: this.time(period.startTime, `forbiddenPeriods[${index}].startTime`), + endTime: this.time(period.endTime, `forbiddenPeriods[${index}].endTime`) + }; + }); + } + + private codecs(value: unknown): CodecInput[] { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + throw new BadRequestException({ code: 'CODECS_INVALID', message: 'codecs must be an array.' }); + } + const codecs = value.map((item, index) => { + const codec = item as Record; + return { + codec: this.codec(codec.codec, `codecs[${index}].codec`), + priority: this.integer(codec.priority, `codecs[${index}].priority`, 1, 1000) + }; + }); + this.ensureUnique(codecs.map((codec) => codec.codec), 'CODEC_DUPLICATE', 'Codec values must be unique.'); + this.ensureUnique(codecs.map((codec) => String(codec.priority)), 'CODEC_PRIORITY_DUPLICATE', 'Codec priorities must be unique.'); + return codecs; + } + + private prefixRules(value: unknown): PrefixRuleInput[] { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + throw new BadRequestException({ code: 'PREFIX_RULES_INVALID', message: 'prefixRules must be an array.' }); + } + const rules = value.map((item, index) => { + const rule = item as Record; + return { + direction: this.direction(rule.direction, `prefixRules[${index}].direction`), + matchPrefix: this.prefix(rule.matchPrefix, `prefixRules[${index}].matchPrefix`), + replacePrefix: this.prefix(rule.replacePrefix, `prefixRules[${index}].replacePrefix`, true), + priority: this.integer(rule.priority, `prefixRules[${index}].priority`, 1, 1000) + }; + }); + this.ensureUnique( + rules.map((rule) => `${rule.direction}:${rule.priority}`), + 'PREFIX_RULE_PRIORITY_DUPLICATE', + 'Prefix rule priorities must be unique per direction.' + ); + return rules; + } + + private time(value: unknown, field: string): string { + const text = this.limitedString(value, field, 8); + if (!/^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$/.test(text)) { + throw new BadRequestException({ code: 'TIME_INVALID', message: `${field} must be HH:mm:ss.` }); + } + return text; + } + + private codec(value: unknown, field: string): string { + const text = this.limitedString(value, field, 32).toUpperCase(); + if (!/^[A-Z0-9_-]+$/.test(text)) { + throw new BadRequestException({ code: 'CODEC_INVALID', message: `${field} contains invalid characters.` }); + } + return text; + } + + private direction(value: unknown, field: string): PrefixDirection { + if (value !== 'CALLER' && value !== 'CALLEE') { + throw new BadRequestException({ code: 'PREFIX_DIRECTION_INVALID', message: `${field} is invalid.` }); + } + return value; + } + + private prefix(value: unknown, field: string, allowEmpty = false): string { + if (allowEmpty && value === '') { + return ''; + } + const text = this.limitedString(value, field, 32); + if (!/^[0-9+*#.-]*$/.test(text)) { + throw new BadRequestException({ code: 'PREFIX_INVALID', message: `${field} contains invalid characters.` }); + } + return text; + } + + private ensureUnique(values: string[], code: string, message: string): void { + if (new Set(values).size !== values.length) { + throw new BadRequestException({ code, message }); + } + } + + private requiresSip(authMode: VendorGatewayAuthMode): boolean { + return authMode === 'SIP_DIGEST' || authMode === 'MIXED'; + } +} diff --git a/apps/api/src/modules/vendors/vendors.controller.ts b/apps/api/src/modules/vendors/vendors.controller.ts new file mode 100644 index 0000000..5c8216f --- /dev/null +++ b/apps/api/src/modules/vendors/vendors.controller.ts @@ -0,0 +1,58 @@ +import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuditAction } from '../audit/audit.metadata.js'; +import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js'; +import { VendorsService } from './vendors.service.js'; + +@ApiTags('vendors') +@Controller('vendors') +export class VendorsController { + constructor(@Inject(VendorsService) private readonly vendorsService: VendorsService) {} + + @Get() + @RequirePermissions('vendors.view') + list() { + return this.vendorsService.list(); + } + + @Get(':id') + @RequirePermissions('vendors.view') + get(@Param('id') id: string) { + return this.vendorsService.get(id); + } + + @Post() + @RequirePermissions('vendors.manage') + @AuditAction({ module: 'vendors', action: 'create', objectType: 'vendor' }) + create(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.vendorsService.create(body as never, currentUser?.id); + } + + @Patch(':id') + @RequirePermissions('vendors.manage') + @AuditAction({ module: 'vendors', action: 'update', objectType: 'vendor', objectIdParam: 'id' }) + update(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.vendorsService.update(id, body as never, currentUser?.id); + } + + @Post(':id/enable') + @RequirePermissions('vendors.manage') + @AuditAction({ module: 'vendors', action: 'enable', objectType: 'vendor', objectIdParam: 'id' }) + enable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.vendorsService.enable(id, currentUser?.id); + } + + @Post(':id/disable') + @RequirePermissions('vendors.manage') + @AuditAction({ module: 'vendors', action: 'disable', objectType: 'vendor', objectIdParam: 'id' }) + disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.vendorsService.disable(id, currentUser?.id); + } + + @Delete(':id') + @RequirePermissions('vendors.manage') + @AuditAction({ module: 'vendors', action: 'delete', objectType: 'vendor', objectIdParam: 'id' }) + remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.vendorsService.remove(id, currentUser?.id); + } +} diff --git a/apps/api/src/modules/vendors/vendors.e2e.spec.ts b/apps/api/src/modules/vendors/vendors.e2e.spec.ts new file mode 100644 index 0000000..ac0ac06 --- /dev/null +++ b/apps/api/src/modules/vendors/vendors.e2e.spec.ts @@ -0,0 +1,259 @@ +import 'reflect-metadata'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import request from 'supertest'; +import { signAccessToken, type PermissionKey } from '@lisglosips/auth'; +import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js'; +import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js'; +import type { CurrentUser } from '../security/security.metadata.js'; +import { + VENDORS_REPOSITORY, + type CreateVendorInput, + type UpdateVendorInput, + type VendorStatus, + type VendorSummary, + type VendorsRepository +} from './vendors.repository.js'; + +class MemoryIdentityRepository implements IdentityRepository { + users = new Map(); + + async findCurrentUserById(userId: string): Promise { + return this.users.get(userId) ?? null; + } +} + +class MemoryAuditRepository implements AuditRepository { + entries: AuditEntryInput[] = []; + + async write(input: AuditEntryInput): Promise { + this.entries.push(input); + } +} + +class MemoryVendorsRepository implements VendorsRepository { + private readonly vendors = new Map(); + + constructor() { + this.vendors.set( + 'ven_seed', + this.summary({ + id: 'ven_seed', + name: 'Seed Vendor', + creditLimit: '200.000000', + gatewayCount: 2 + }) + ); + } + + async list(): Promise { + return [...this.vendors.values()]; + } + + async get(vendorId: string): Promise { + return this.vendors.get(vendorId) ?? this.summary({ id: vendorId, name: 'Missing Vendor' }); + } + + async create(input: CreateVendorInput): Promise { + const vendor = this.summary({ + id: 'ven_created', + name: input.name, + contactName: input.contactName ?? null, + phone: input.phone ?? null, + email: input.email ?? null, + status: input.status ?? 'ENABLED', + creditLimit: input.creditLimit, + settlement: input.settlement ?? null, + notes: input.notes ?? null + }); + this.vendors.set(vendor.id, vendor); + return vendor; + } + + async update(vendorId: string, input: UpdateVendorInput): Promise { + const current = this.vendors.get(vendorId) ?? this.summary({ id: vendorId, name: 'Updated Vendor' }); + const updated: VendorSummary = { + ...current, + name: input.name ?? current.name, + contactName: input.contactName === undefined ? current.contactName : input.contactName, + phone: input.phone === undefined ? current.phone : input.phone, + email: input.email === undefined ? current.email : input.email, + status: input.status ?? current.status, + creditLimit: input.creditLimit ?? current.creditLimit, + availableBalance: (Number(current.balance) + Number(input.creditLimit ?? current.creditLimit)).toFixed(6), + settlement: input.settlement === undefined ? current.settlement : input.settlement, + notes: input.notes === undefined ? current.notes : input.notes, + updatedAt: new Date('2026-06-21T05:00:00.000Z') + }; + this.vendors.set(vendorId, updated); + return updated; + } + + async setStatus(vendorId: string, status: VendorStatus): Promise { + return this.update(vendorId, { status }); + } + + async softDelete(vendorId: string): Promise { + return this.update(vendorId, { status: 'DISABLED' }); + } + + private summary(input: { + id: string; + name: string; + contactName?: string | null; + phone?: string | null; + email?: string | null; + status?: VendorStatus; + creditLimit?: string; + settlement?: string | null; + notes?: string | null; + gatewayCount?: number; + }): VendorSummary { + const balance = '20.000000'; + const creditLimit = input.creditLimit ?? '0.000000'; + return { + id: input.id, + name: input.name, + contactName: input.contactName ?? null, + phone: input.phone ?? null, + email: input.email ?? null, + status: input.status ?? 'ENABLED', + balance, + creditLimit, + availableBalance: (Number(balance) + Number(creditLimit)).toFixed(6), + settlement: input.settlement ?? null, + notes: input.notes ?? null, + gatewayCount: input.gatewayCount ?? 0, + createdAt: new Date('2026-06-21T05:00:00.000Z'), + updatedAt: new Date('2026-06-21T05:00:00.000Z') + }; + } +} + +describe('S15 vendors API', () => { + let app: NestFastifyApplication; + let audit: MemoryAuditRepository; + + const tokenFor = (userId: string) => + signAccessToken( + { + sub: userId, + username: userId, + roles: ['test'], + typ: 'access' + }, + { + secret: 'test-only-access-token-secret-min-32-bytes', + issuer: 'lisglosips-api', + audience: 'lisglosips-web', + ttlSeconds: 900 + } + ); + + beforeAll(async () => { + process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips'; + process.env.REDIS_URL = 'redis://127.0.0.1:6379/0'; + process.env.LISGLOSIPS_LOG_LEVEL = 'silent'; + process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes'; + + const identities = new MemoryIdentityRepository(); + audit = new MemoryAuditRepository(); + + identities.users.set('usr_ops', { + id: 'usr_ops', + username: 'ops', + roles: ['运营管理员'], + permissions: ['vendors.view', 'vendors.manage', 'recharges.view'] as PermissionKey[] + }); + identities.users.set('usr_viewer', { + id: 'usr_viewer', + username: 'viewer', + roles: ['只读'], + permissions: ['vendors.view'] as PermissionKey[] + }); + + const { AppModule } = await import('../app.module.js'); + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule] + }) + .overrideProvider(IDENTITY_REPOSITORY) + .useValue(identities) + .overrideProvider(AUDIT_REPOSITORY) + .useValue(audit) + .overrideProvider(VENDORS_REPOSITORY) + .useValue(new MemoryVendorsRepository()) + .compile(); + + app = moduleFixture.createNestApplication(new FastifyAdapter({ logger: false })); + app.setGlobalPrefix('api/v2'); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('returns vendor balance, credit, and gateway count to viewers', async () => { + const response = await request(app.getHttpServer()).get('/api/v2/vendors').set('Authorization', `Bearer ${tokenFor('usr_viewer')}`).expect(200); + + expect(response.body[0]).toMatchObject({ + id: 'ven_seed', + balance: '20.000000', + creditLimit: '200.000000', + availableBalance: '220.000000', + gatewayCount: 2 + }); + }); + + it('rejects write operations without vendors.manage', async () => { + await request(app.getHttpServer()) + .post('/api/v2/vendors') + .set('Authorization', `Bearer ${tokenFor('usr_viewer')}`) + .send({ name: 'Denied Vendor' }) + .expect(403); + }); + + it('creates, updates, disables, enables, and soft deletes vendors with audit entries', async () => { + await request(app.getHttpServer()) + .post('/api/v2/vendors') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ + name: 'Carrier One', + contactName: 'NOC', + creditLimit: '188.5', + settlement: 'monthly' + }) + .expect(201) + .expect((response) => { + expect(response.body).toMatchObject({ + id: 'ven_created', + name: 'Carrier One', + creditLimit: '188.500000', + availableBalance: '208.500000', + settlement: 'monthly' + }); + }); + + await request(app.getHttpServer()) + .patch('/api/v2/vendors/ven_created') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ notes: 'primary carrier', creditLimit: '200' }) + .expect(200) + .expect((response) => { + expect(response.body).toMatchObject({ + notes: 'primary carrier', + creditLimit: '200.000000' + }); + }); + + await request(app.getHttpServer()).post('/api/v2/vendors/ven_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201); + await request(app.getHttpServer()).post('/api/v2/vendors/ven_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201); + await request(app.getHttpServer()).delete('/api/v2/vendors/ven_created').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200); + + expect(audit.entries.some((entry) => entry.module === 'vendors' && entry.action === 'create' && entry.result === 'SUCCESS')).toBe(true); + expect(audit.entries.some((entry) => entry.module === 'vendors' && entry.action === 'disable' && entry.objectId === 'ven_created')).toBe(true); + expect(audit.entries.some((entry) => entry.module === 'vendors' && entry.action === 'delete' && entry.objectId === 'ven_created')).toBe(true); + }); +}); diff --git a/apps/api/src/modules/vendors/vendors.module.ts b/apps/api/src/modules/vendors/vendors.module.ts new file mode 100644 index 0000000..8fe8069 --- /dev/null +++ b/apps/api/src/modules/vendors/vendors.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { VENDORS_REPOSITORY, PrismaVendorsRepository } from './vendors.repository.js'; +import { VendorsController } from './vendors.controller.js'; +import { VendorsService } from './vendors.service.js'; + +@Module({ + controllers: [VendorsController], + providers: [ + VendorsService, + { + provide: VENDORS_REPOSITORY, + useClass: PrismaVendorsRepository + } + ], + exports: [VendorsService] +}) +export class VendorsModule {} diff --git a/apps/api/src/modules/vendors/vendors.repository.ts b/apps/api/src/modules/vendors/vendors.repository.ts new file mode 100644 index 0000000..c5b9024 --- /dev/null +++ b/apps/api/src/modules/vendors/vendors.repository.ts @@ -0,0 +1,217 @@ +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import crypto from 'node:crypto'; +import { Prisma } from '@lisglosips/database'; +import { PrismaService } from '../database/prisma.service.js'; + +export type VendorStatus = 'ENABLED' | 'DISABLED'; + +export interface VendorSummary { + id: string; + name: string; + contactName: string | null; + phone: string | null; + email: string | null; + status: VendorStatus; + balance: string; + creditLimit: string; + availableBalance: string; + settlement: string | null; + notes: string | null; + gatewayCount: number; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateVendorInput { + name: string; + contactName?: string; + phone?: string; + email?: string; + status?: VendorStatus; + creditLimit: string; + settlement?: string; + notes?: string; + actorId?: string; +} + +export interface UpdateVendorInput { + name?: string; + contactName?: string | null; + phone?: string | null; + email?: string | null; + status?: VendorStatus; + creditLimit?: string; + settlement?: string | null; + notes?: string | null; + actorId?: string; +} + +export interface VendorsRepository { + list(): Promise; + get(vendorId: string): Promise; + create(input: CreateVendorInput): Promise; + update(vendorId: string, input: UpdateVendorInput): Promise; + setStatus(vendorId: string, status: VendorStatus, actorId?: string): Promise; + softDelete(vendorId: string, actorId?: string): Promise; +} + +export const VENDORS_REPOSITORY = Symbol('VENDORS_REPOSITORY'); + +function vendorId(): string { + return `ven_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`; +} + +@Injectable() +export class PrismaVendorsRepository implements VendorsRepository { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async list(): Promise { + const vendors = await this.prisma.vendor.findMany({ + where: { deletedAt: null }, + orderBy: [{ createdAt: 'desc' }], + include: this.includeSummary() + }); + + return vendors.map((vendor) => this.toSummary(vendor)); + } + + async get(vendorIdValue: string): Promise { + return this.toSummary(await this.findActiveOrThrow(vendorIdValue)); + } + + async create(input: CreateVendorInput): Promise { + const vendor = await this.prisma.vendor.create({ + data: { + id: vendorId(), + name: input.name, + contactName: input.contactName, + phone: input.phone, + email: input.email, + status: input.status ?? 'ENABLED', + creditLimit: new Prisma.Decimal(input.creditLimit), + settlement: input.settlement, + notes: input.notes, + createdBy: input.actorId, + updatedBy: input.actorId + }, + include: this.includeSummary() + }); + + return this.toSummary(vendor); + } + + async update(vendorIdValue: string, input: UpdateVendorInput): Promise { + await this.findActiveOrThrow(vendorIdValue); + + const vendor = await this.prisma.vendor.update({ + where: { id: vendorIdValue }, + data: { + name: input.name, + contactName: input.contactName, + phone: input.phone, + email: input.email, + status: input.status, + creditLimit: input.creditLimit === undefined ? undefined : new Prisma.Decimal(input.creditLimit), + settlement: input.settlement, + notes: input.notes, + updatedBy: input.actorId, + version: { increment: 1 } + }, + include: this.includeSummary() + }); + + return this.toSummary(vendor); + } + + async setStatus(vendorIdValue: string, status: VendorStatus, actorId?: string): Promise { + return this.update(vendorIdValue, { status, actorId }); + } + + async softDelete(vendorIdValue: string, actorId?: string): Promise { + const existing = await this.findActiveOrThrow(vendorIdValue); + const linkedGateways = await this.prisma.vendorGateway.count({ + where: { + vendorId: vendorIdValue, + deletedAt: null + } + }); + + if (linkedGateways > 0) { + throw new BadRequestException({ + code: 'VENDOR_HAS_GATEWAYS', + message: 'Vendor with active gateways cannot be deleted.' + }); + } + + const vendor = await this.prisma.vendor.update({ + where: { id: existing.id }, + data: { + status: 'DISABLED', + deletedAt: new Date(), + updatedBy: actorId, + version: { increment: 1 } + }, + include: this.includeSummary() + }); + + return this.toSummary(vendor); + } + + private includeSummary() { + return { + _count: { + select: { + gateways: { + where: { deletedAt: null } + } + } + } + } satisfies Prisma.VendorInclude; + } + + private async findActiveOrThrow(vendorIdValue: string) { + const vendor = await this.prisma.vendor.findUnique({ + where: { id: vendorIdValue }, + include: this.includeSummary() + }); + + if (!vendor || vendor.deletedAt) { + throw new NotFoundException({ code: 'VENDOR_NOT_FOUND', message: 'Vendor not found.' }); + } + + return vendor; + } + + private toSummary(vendor: { + id: string; + name: string; + contactName: string | null; + phone: string | null; + email: string | null; + status: VendorStatus; + balance: Prisma.Decimal; + creditLimit: Prisma.Decimal; + settlement: string | null; + notes: string | null; + createdAt: Date; + updatedAt: Date; + _count: { gateways: number }; + }): VendorSummary { + return { + id: vendor.id, + name: vendor.name, + contactName: vendor.contactName, + phone: vendor.phone, + email: vendor.email, + status: vendor.status, + balance: vendor.balance.toFixed(6), + creditLimit: vendor.creditLimit.toFixed(6), + availableBalance: vendor.balance.plus(vendor.creditLimit).toFixed(6), + settlement: vendor.settlement, + notes: vendor.notes, + gatewayCount: vendor._count.gateways, + createdAt: vendor.createdAt, + updatedAt: vendor.updatedAt + }; + } +} diff --git a/apps/api/src/modules/vendors/vendors.service.ts b/apps/api/src/modules/vendors/vendors.service.ts new file mode 100644 index 0000000..93dd167 --- /dev/null +++ b/apps/api/src/modules/vendors/vendors.service.ts @@ -0,0 +1,135 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { + VENDORS_REPOSITORY, + type CreateVendorInput, + type UpdateVendorInput, + type VendorStatus, + type VendorSummary, + type VendorsRepository +} from './vendors.repository.js'; + +interface CreateVendorDto { + name?: unknown; + contactName?: unknown; + phone?: unknown; + email?: unknown; + status?: unknown; + creditLimit?: unknown; + settlement?: unknown; + notes?: unknown; +} + +interface UpdateVendorDto { + name?: unknown; + contactName?: unknown; + phone?: unknown; + email?: unknown; + status?: unknown; + creditLimit?: unknown; + settlement?: unknown; + notes?: unknown; +} + +@Injectable() +export class VendorsService { + constructor(@Inject(VENDORS_REPOSITORY) private readonly vendors: VendorsRepository) {} + + list(): Promise { + return this.vendors.list(); + } + + get(vendorId: string): Promise { + return this.vendors.get(vendorId); + } + + create(body: CreateVendorDto, actorId?: string): Promise { + const input: CreateVendorInput = { + name: this.limitedString(body.name, 'name', 120), + contactName: this.optionalString(body.contactName, 'contactName', 80), + phone: this.optionalString(body.phone, 'phone', 32), + email: this.optionalString(body.email, 'email', 160), + status: body.status === undefined ? 'ENABLED' : this.status(body.status), + creditLimit: body.creditLimit === undefined ? '0.000000' : this.money(body.creditLimit, 'creditLimit'), + settlement: this.optionalString(body.settlement, 'settlement', 80), + notes: this.optionalString(body.notes, 'notes', 500), + actorId + }; + + return this.vendors.create(input); + } + + update(vendorId: string, body: UpdateVendorDto, actorId?: string): Promise { + const input: UpdateVendorInput = { + name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120), + contactName: body.contactName === undefined ? undefined : this.nullableString(body.contactName, 'contactName', 80), + phone: body.phone === undefined ? undefined : this.nullableString(body.phone, 'phone', 32), + email: body.email === undefined ? undefined : this.nullableString(body.email, 'email', 160), + status: body.status === undefined ? undefined : this.status(body.status), + creditLimit: body.creditLimit === undefined ? undefined : this.money(body.creditLimit, 'creditLimit'), + settlement: body.settlement === undefined ? undefined : this.nullableString(body.settlement, 'settlement', 80), + notes: body.notes === undefined ? undefined : this.nullableString(body.notes, 'notes', 500), + actorId + }; + + return this.vendors.update(vendorId, input); + } + + enable(vendorId: string, actorId?: string): Promise { + return this.vendors.setStatus(vendorId, 'ENABLED', actorId); + } + + disable(vendorId: string, actorId?: string): Promise { + return this.vendors.setStatus(vendorId, 'DISABLED', actorId); + } + + remove(vendorId: string, actorId?: string): Promise { + return this.vendors.softDelete(vendorId, actorId); + } + + private limitedString(value: unknown, field: string, maxLength: number): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` }); + } + + const trimmed = value.trim(); + if (trimmed.length > maxLength) { + throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is too long.` }); + } + + return trimmed; + } + + private optionalString(value: unknown, field: string, maxLength: number): string | undefined { + if (value === undefined) { + return undefined; + } + + return this.limitedString(value, field, maxLength); + } + + private nullableString(value: unknown, field: string, maxLength: number): string | null { + if (value === null) { + return null; + } + + return this.limitedString(value, field, maxLength); + } + + private status(value: unknown): VendorStatus { + if (value !== 'ENABLED' && value !== 'DISABLED') { + throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' }); + } + + return value; + } + + private money(value: unknown, field: string): string { + const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : ''; + if (!/^\d{1,14}(?:\.\d{1,6})?$/.test(raw)) { + throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be a non-negative decimal with up to 6 places.` }); + } + + const [integerPart, fractionPart = ''] = raw.split('.'); + return `${integerPart}.${fractionPart.padEnd(6, '0')}`; + } +} diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts new file mode 100644 index 0000000..cd9fcc2 --- /dev/null +++ b/apps/api/src/openapi.ts @@ -0,0 +1,16 @@ +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import type { NestFastifyApplication } from '@nestjs/platform-fastify'; + +export function buildOpenApiDocument(app: NestFastifyApplication): void { + const config = new DocumentBuilder() + .setTitle('LisgloSIPS API') + .setDescription('LisgloSIPS V2 management platform API contract.') + .setVersion('0.2.0') + .addBearerAuth() + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/v2/docs', app, document, { + jsonDocumentUrl: 'api/v2/openapi.json' + }); +} diff --git a/apps/api/src/shared/config.ts b/apps/api/src/shared/config.ts new file mode 100644 index 0000000..e141a94 --- /dev/null +++ b/apps/api/src/shared/config.ts @@ -0,0 +1,86 @@ +import Joi from 'joi'; + +export interface RuntimeConfig { + service: { + name: string; + }; + http: { + host: string; + port: number; + }; + logging: { + level: string; + requestIdHeader: string; + }; + database: { + url: string; + }; + redis: { + url: string; + }; + auth: { + accessTokenSecret: string; + accessTokenTtlSeconds: number; + refreshTokenTtlDays: number; + lockMaxFailures: number; + lockWindowSeconds: number; + cookieSecure: boolean; + tokenIssuer: string; + tokenAudience: string; + }; +} + +export const validationSchema = Joi.object({ + NODE_ENV: Joi.string().valid('development', 'test', 'production').default('development'), + LISGLOSIPS_SERVICE_NAME: Joi.string().default('api'), + LISGLOSIPS_HOST: Joi.string().hostname().default('127.0.0.1'), + LISGLOSIPS_PORT: Joi.number().port().default(3000), + LISGLOSIPS_LOG_LEVEL: Joi.string().valid('trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent').default('info'), + LISGLOSIPS_REQUEST_ID_HEADER: Joi.string().default('x-request-id'), + DATABASE_URL: Joi.string().uri({ scheme: ['mysql'] }).required(), + REDIS_URL: Joi.string().uri({ scheme: ['redis', 'rediss'] }).required(), + AUTH_ACCESS_TOKEN_SECRET: Joi.string().min(32).when('NODE_ENV', { + is: 'production', + then: Joi.required(), + otherwise: Joi.string().default('dev-only-change-this-auth-secret-32-bytes-min') + }), + AUTH_ACCESS_TOKEN_TTL_SECONDS: Joi.number().integer().min(60).max(86_400).default(900), + AUTH_REFRESH_TOKEN_TTL_DAYS: Joi.number().integer().min(1).max(30).default(7), + AUTH_LOCK_MAX_FAILURES: Joi.number().integer().min(3).max(20).default(5), + AUTH_LOCK_WINDOW_SECONDS: Joi.number().integer().min(60).max(86_400).default(900), + AUTH_COOKIE_SECURE: Joi.boolean().truthy('true').falsy('false').default(true), + AUTH_TOKEN_ISSUER: Joi.string().default('lisglosips-api'), + AUTH_TOKEN_AUDIENCE: Joi.string().default('lisglosips-web') +}); + +export function appConfig(): RuntimeConfig { + return { + service: { + name: process.env.LISGLOSIPS_SERVICE_NAME ?? 'api' + }, + http: { + host: process.env.LISGLOSIPS_HOST ?? '127.0.0.1', + port: Number(process.env.LISGLOSIPS_PORT ?? 3000) + }, + logging: { + level: process.env.LISGLOSIPS_LOG_LEVEL ?? 'info', + requestIdHeader: process.env.LISGLOSIPS_REQUEST_ID_HEADER ?? 'x-request-id' + }, + database: { + url: process.env.DATABASE_URL ?? '' + }, + redis: { + url: process.env.REDIS_URL ?? '' + }, + auth: { + accessTokenSecret: process.env.AUTH_ACCESS_TOKEN_SECRET ?? 'dev-only-change-this-auth-secret-32-bytes-min', + accessTokenTtlSeconds: Number(process.env.AUTH_ACCESS_TOKEN_TTL_SECONDS ?? 900), + refreshTokenTtlDays: Number(process.env.AUTH_REFRESH_TOKEN_TTL_DAYS ?? 7), + lockMaxFailures: Number(process.env.AUTH_LOCK_MAX_FAILURES ?? 5), + lockWindowSeconds: Number(process.env.AUTH_LOCK_WINDOW_SECONDS ?? 900), + cookieSecure: (process.env.AUTH_COOKIE_SECURE ?? 'true') !== 'false', + tokenIssuer: process.env.AUTH_TOKEN_ISSUER ?? 'lisglosips-api', + tokenAudience: process.env.AUTH_TOKEN_AUDIENCE ?? 'lisglosips-web' + } + }; +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..04ac9c2 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "references": [ + { "path": "../../packages/auth" }, + { "path": "../../packages/contracts" }, + { "path": "../../packages/database" }, + { "path": "../../packages/observability" } + ], + "include": ["src/**/*.ts"] +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..13f0639 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + LisgloSIPS - 聆界SIP管理平台 + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..1b79b79 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,19 @@ +{ + "name": "@lisglosips/web", + "version": "0.2.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vite build", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "react": "18.2.0", + "react-dom": "18.2.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "4.7.0", + "vite": "5.4.21" + } +} diff --git a/apps/web/src/App.jsx b/apps/web/src/App.jsx new file mode 100644 index 0000000..fdfe109 --- /dev/null +++ b/apps/web/src/App.jsx @@ -0,0 +1,2694 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Badge, + Button, + Checkbox, + Field, + Input, + Progress, + Select, + Switch, + Tabs, + Textarea, +} from './components/ui.jsx'; +import { api, explainApiError } from './api.js'; + +const selectedBlue = '#2563EB'; + +function formatCurrency(value, digits = 2) { + const numeric = Number(value || 0); + return `¥${numeric.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits })}`; +} + +function formatDate(value) { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + return date.toLocaleDateString('zh-CN'); +} + +function formatDateTime(value) { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + return date.toLocaleString('zh-CN', { hour12: false }); +} + +function zhStatus(status) { + return status === 'ENABLED' ? '启用' : status === 'DISABLED' ? '停用' : status === 'SUCCEEDED' ? '成功' : status || '-'; +} + +function enStatus(status) { + return status === '启用' ? 'ENABLED' : status === '停用' || status === '禁用' ? 'DISABLED' : status; +} + +function billingModeLabel(value) { + return value === 'POSTPAID' ? '后付费' : value === 'PREPAID' ? '预付费' : '-'; +} + +function normalizeCustomer(item) { + return { + id: item.id, + name: item.name, + contact: item.contactName || '-', + phone: item.phone || '-', + email: item.email || '-', + domain: item.domain || '-', + auth: '真实 API', + status: zhStatus(item.status), + balance: formatCurrency(item.balance), + credit: formatCurrency(item.creditLimit), + billing: billingModeLabel(item.billingMode), + routeGroup: '-', + gateways: item.gatewayCount ?? 0, + createdAt: formatDate(item.createdAt), + }; +} + +function normalizeVendor(item) { + return { + id: item.id, + name: item.name, + balance: formatCurrency(item.balance), + credit: formatCurrency(item.creditLimit), + gateways: item.gatewayCount ?? 0, + status: zhStatus(item.status), + cycle: item.settlement || '-', + ratePlan: '-', + contact: item.contactName || '-', + createdAt: formatDate(item.createdAt), + }; +} + +function normalizeRecharge(item) { + return { + id: item.id, + type: item.accountType === 'VENDOR' ? 'vendor' : 'customer', + owner: item.accountName, + amount: formatCurrency(item.amount), + beforeBalance: formatCurrency(item.beforeBalance), + afterBalance: formatCurrency(item.afterBalance), + remark: item.remark || '-', + operator: item.createdBy || '系统', + time: formatDateTime(item.occurredAt), + status: zhStatus(item.status), + }; +} + +function normalizeUser(item) { + return { + id: item.id, + username: item.username, + name: item.displayName, + phone: item.phone || '-', + email: item.email || '-', + roleId: item.roleIds?.[0] || '', + status: zhStatus(item.status), + lastLogin: formatDateTime(item.lastLoginAt), + lastIp: '-', + }; +} + +function normalizeRole(item) { + return { + id: item.id, + name: item.name, + description: item.description || '-', + status: zhStatus(item.status), + builtIn: Boolean(item.builtIn), + permissions: item.permissionIds || [], + }; +} + +function normalizeAuditLog(item) { + return { + id: item.id, + time: formatDateTime(item.createdAt), + user: item.username || '系统', + username: item.username || '-', + module: item.module, + action: item.action, + object: item.objectId ? `${item.objectType}:${item.objectId}` : item.objectType, + result: item.result === 'SUCCESS' ? '成功' : '失败', + ip: item.ip || '-', + summary: item.errorCode || `${item.module}.${item.action}`, + userAgent: item.userAgent || '-', + }; +} + +const navGroups = [ + { + title: '运营', + items: [ + { key: 'dashboard', label: '概览 Dashboard' }, + { key: 'customers', label: '客户管理' }, + { key: 'customerGateways', label: '客户网关管理' }, + { key: 'rechargeRecords', label: '充值记录' }, + { key: 'vendors', label: '供应商管理' }, + { key: 'vendorGateways', label: '落地网关管理' }, + { key: 'vendorLineGroups', label: '落地线路组' }, + { key: 'billing', label: '费率与计费', pending: true }, + ], + }, + { + title: '业务', + items: [ + { key: 'cdr', label: '话单中心' }, + { key: 'quality', label: '质检中心' }, + { key: 'sipops', label: 'SIP 运维', pending: true }, + { key: 'monitoring', label: '监控告警', pending: true }, + ], + }, + { + title: '系统', + items: [ + { key: 'settings', label: '系统设置', pending: true }, + { key: 'users', label: '用户管理' }, + { key: 'roles', label: '角色与权限' }, + { key: 'operationLogs', label: '操作日志' }, + ], + }, +]; + +const metrics = [ + { label: '今日通话数', value: '128,640', delta: '+12.4%', tone: 'up' }, + { label: '当前在线通话', value: '1,284', delta: '+86', tone: 'up' }, + { label: '今日接通率', value: '92.8%', delta: '-1.1%', tone: 'down' }, + { label: '客户消费', value: '¥86,420', delta: '+9.7%', tone: 'up' }, + { label: '供应商成本', value: '¥52,180', delta: '+7.8%', tone: 'up' }, + { label: '今日毛利', value: '¥34,240', delta: '39.6%', tone: 'neutral' }, + { label: '在线注册用户', value: '18,920', delta: '+312', tone: 'up' }, + { label: 'RTPEngine 节点', value: '1/1', delta: '正常', tone: 'up' }, + { label: 'OpenSIPS 节点', value: '1/1', delta: '单节点 V1', tone: 'neutral' }, + { label: '异常网关', value: '2', delta: '需关注', tone: 'warn' }, + { label: '质检待处理', value: '248', delta: 'P1', tone: 'warn' }, +]; + +const callTrend = [82, 96, 74, 118, 136, 162, 148, 176, 158, 190, 214, 188]; +const answerTrend = [90, 91, 88, 92, 94, 93, 91, 95, 94, 92, 93, 92]; + +const customers = [ + { id: 'C1001', name: '上海示例通信', contact: '张经理', phone: '13800010001', email: 'ops-sh@example.net', domain: 'sh-voice.example.net', auth: 'IP 白名单', status: '启用', balance: '¥56,820.40', credit: '¥200,000', billing: '预付费', routeGroup: '华东优先线路', gateways: 3, createdAt: '2026-05-18' }, + { id: 'C1002', name: '杭州云呼叫中心', contact: '李主管', phone: '13900020002', email: 'contact-hz@example.net', domain: 'hz-call.example.net', auth: 'SIP 账号', status: '启用', balance: '¥18,445.80', credit: '¥80,000', billing: '后付费', routeGroup: '成本最低线路', gateways: 2, createdAt: '2026-05-22' }, + { id: 'C1003', name: '深圳跨境业务部', contact: '王经理', phone: '13700030003', email: 'global-sz@example.net', domain: 'sz-global.example.net', auth: '混合认证', status: '观察', balance: '¥6,921.00', credit: '¥50,000', billing: '预付费', routeGroup: '国际备线池', gateways: 4, createdAt: '2026-06-01' }, + { id: 'C1004', name: '北京金融外呼', contact: '赵经理', phone: '13600040004', email: 'bj-fin@example.net', domain: 'bj-fin.example.net', auth: 'IP 白名单', status: '停用', balance: '¥0.00', credit: '¥100,000', billing: '后付费', routeGroup: '合规外呼线路', gateways: 1, createdAt: '2026-06-08' }, +]; + +const sipAccounts = [ + { user: '10010001', domain: 'sh-voice.example.net', register: '在线', contact: '10.12.8.21:5060', expires: '274s' }, + { user: '10010002', domain: 'sh-voice.example.net', register: '在线', contact: '10.12.8.22:5060', expires: '280s' }, + { user: '10010003', domain: 'sh-voice.example.net', register: '离线', contact: '-', expires: '-' }, +]; + +const vendors = [ + { id: 'V2001', name: '供应商 A', balance: '¥42,860.00', credit: '¥300,000', gateways: 8, status: '启用', cycle: '月结', ratePlan: 'CN-Mobile-2026', contact: '陈经理', createdAt: '2026-04-02' }, + { id: 'V2002', name: '供应商 B', balance: '¥18,200.00', credit: '¥120,000', gateways: 5, status: '启用', cycle: '周结', ratePlan: 'CN-LowCost-2026', contact: '王经理', createdAt: '2026-04-16' }, + { id: 'V2003', name: '国际供应商 C', balance: '¥8,450.00', credit: '¥80,000', gateways: 3, status: '观察', cycle: '月结', ratePlan: 'Global-Std', contact: 'Lina', createdAt: '2026-05-03' }, +]; + +const gateways = [ + { id: 'GW-A-01', vendor: '供应商 A', name: '华东移动主用', authMode: 'IP', ipAddress: '203.0.113.18', sipAccount: '', sipPassword: '', concurrencyLimit: 800, billingCycle: 60, cycleRate: 0.031, requestRate: '120 CPS', blockedProvinces: '新疆、西藏', callTimeLimit: '08:00-22:00', codecs: 'PCMA, PCMU', calleePrefixTransform: '13/15/18 保持原样', callerPrefixTransform: '0216001* -> 0216001*', status: '启用' }, + { id: 'GW-A-02', vendor: '供应商 A', name: '华东联通备用', authMode: 'IP', ipAddress: '203.0.113.19', sipAccount: '', sipPassword: '', concurrencyLimit: 500, billingCycle: 30, cycleRate: 0.017, requestRate: '80 CPS', blockedProvinces: '无', callTimeLimit: '00:00-23:59', codecs: 'PCMA', calleePrefixTransform: '021 保持原样', callerPrefixTransform: '0217002* -> 0217002*', status: '启用' }, + { id: 'GW-B-01', vendor: '供应商 B', name: '成本最低路由', authMode: 'SIP注册', ipAddress: '', sipAccount: 'vendor-b-main', sipPassword: '******', concurrencyLimit: 360, billingCycle: 6, cycleRate: 0.0028, requestRate: '60 CPS', blockedProvinces: '北京', callTimeLimit: '09:00-21:00', codecs: 'PCMA, G729', calleePrefixTransform: '0571 -> 0571', callerPrefixTransform: '0571888* -> 0571888*', status: '禁用' }, +]; + +const vendorLineGroups = [ + { id: 'VLG-01', name: '华东主备线路组', gatewayIds: ['GW-A-01', 'GW-A-02'] }, + { id: 'VLG-02', name: '低成本线路组', gatewayIds: ['GW-B-01', 'GW-A-02'] }, + { id: 'VLG-03', name: '移动优先线路组', gatewayIds: ['GW-A-01'] }, +]; + +const customerGatewayPolicies = [ + { id: 'CGP-001', gateway: 'C-GW-SH-01', name: '上海移动外呼', callerMode: 'prefix', callerValue: '0216001', calleeMode: 'prefix', calleeValue: '13/15/18', routeGroup: '华东优先线路', priority: 10, status: '启用' }, + { id: 'CGP-002', gateway: 'C-GW-SH-01', name: '上海本地回访', callerMode: 'prefix', callerValue: '0217002', calleeMode: 'prefix', calleeValue: '021', routeGroup: '华东优先线路', priority: 20, status: '启用' }, + { id: 'CGP-003', gateway: 'C-GW-SH-01', name: '重点号码专线', callerMode: 'equals', callerValue: '02160010001', calleeMode: 'equals', calleeValue: '13800138000', routeGroup: '成本最低线路', priority: 30, status: '启用' }, + { id: 'CGP-004', gateway: 'C-GW-SH-02', name: '固话回访', callerMode: 'any', callerValue: '', calleeMode: 'prefix', calleeValue: '021', routeGroup: '华东优先线路', priority: 10, status: '启用' }, + { id: 'CGP-005', gateway: 'C-GW-HZ-01', name: '浙江固话业务', callerMode: 'prefix', callerValue: '0571', calleeMode: 'prefix', calleeValue: '0571/0574', routeGroup: '成本最低线路', priority: 10, status: '启用' }, + { id: 'CGP-006', gateway: 'C-GW-HZ-01', name: '杭州移动外呼', callerMode: 'any', callerValue: '', calleeMode: 'prefix', calleeValue: '13/15/18', routeGroup: '华东优先线路', priority: 20, status: '启用' }, + { id: 'CGP-007', gateway: 'C-GW-SZ-03', name: '国际直拨', callerMode: 'prefix', callerValue: '0755', calleeMode: 'prefix', calleeValue: '00', routeGroup: '国际备线池', priority: 10, status: '启用' }, + { id: 'CGP-008', gateway: 'C-GW-SZ-03', name: '美国方向', callerMode: 'any', callerValue: '', calleeMode: 'prefix', calleeValue: '001', routeGroup: '国际备线池', priority: 20, status: '启用' }, + { id: 'CGP-009', gateway: 'C-GW-SZ-03', name: '英国方向', callerMode: 'any', callerValue: '', calleeMode: 'prefix', calleeValue: '0044', routeGroup: '国际备线池', priority: 30, status: '启用' }, + { id: 'CGP-010', gateway: 'C-GW-SZ-03', name: '指定客户号码', callerMode: 'equals', callerValue: '075588801111', calleeMode: 'any', calleeValue: '', routeGroup: '成本最低线路', priority: 40, status: '停用' }, +]; + +const customerGateways = [ + { id: 'C-GW-SH-01', name: '上海主接入网关', authMode: 'IP', ipAddress: '10.10.1.11', sipAccount: '', sipPassword: '', routePolicyCount: 3, status: '启用' }, + { id: 'C-GW-SH-02', name: '上海固话回访入口', authMode: 'IP', ipAddress: '10.10.1.12', sipAccount: '', sipPassword: '', routePolicyCount: 1, status: '启用' }, + { id: 'C-GW-HZ-01', name: '杭州坐席接入', authMode: 'SIP注册', ipAddress: '', sipAccount: 'hz-seat-1001', sipPassword: '******', routePolicyCount: 2, status: '启用' }, + { id: 'C-GW-SZ-03', name: '深圳国际业务入口', authMode: 'SIP注册', ipAddress: '', sipAccount: 'sz-global-3003', sipPassword: '******', routePolicyCount: 4, status: '停用' }, +]; + +const initialRechargeRecords = [ + { id: 'RCG-20260618-003', type: 'customer', owner: '上海示例通信', amount: '¥10,000.00', beforeBalance: '¥46,820.40', afterBalance: '¥56,820.40', remark: '月初预存', operator: '财务', time: '2026-06-18 09:42:16', status: '成功' }, + { id: 'RCG-20260617-012', type: 'customer', owner: '杭州云呼叫中心', amount: '¥5,000.00', beforeBalance: '¥13,445.80', afterBalance: '¥18,445.80', remark: '补充测试额度', operator: '运营管理员', time: '2026-06-17 16:18:33', status: '成功' }, + { id: 'RCG-20260616-006', type: 'customer', owner: '深圳跨境业务部', amount: '¥2,000.00', beforeBalance: '¥4,921.00', afterBalance: '¥6,921.00', remark: '-', operator: '财务', time: '2026-06-16 11:07:25', status: '成功' }, + { id: 'RCG-20260618-V02', type: 'vendor', owner: '供应商 A', amount: '¥20,000.00', beforeBalance: '¥22,860.00', afterBalance: '¥42,860.00', remark: '线路预存款', operator: '财务', time: '2026-06-18 10:22:43', status: '成功' }, + { id: 'RCG-20260617-V01', type: 'vendor', owner: '国际供应商 C', amount: '¥5,000.00', beforeBalance: '¥3,450.00', afterBalance: '¥8,450.00', remark: '国际线路补款', operator: '运营管理员', time: '2026-06-17 15:39:08', status: '成功' }, +]; + +const vendorGatewayPolicies = [ + { vendor: '供应商 A', gateway: 'GW-A-01', caller: '0216001*', calleePrefix: '13/15/18', business: '国内移动成本线', vendorRate: 'A-Mobile-60/6', priority: 10, status: '启用' }, + { vendor: '供应商 A', gateway: 'GW-A-02', caller: '0217002*', calleePrefix: '021', business: '上海固话成本线', vendorRate: 'A-SH-Local-6/6', priority: 20, status: '启用' }, + { vendor: '供应商 B', gateway: 'GW-B-01', caller: '0571888*', calleePrefix: '0571', business: '杭州本地固话', vendorRate: 'B-HZ-Local-6/6', priority: 30, status: '观察' }, + { vendor: '国际供应商 C', gateway: 'GW-C-01', caller: '0755888*', calleePrefix: '001/0044', business: '国际长途', vendorRate: 'C-Global-60/60', priority: 10, status: '启用' }, +]; + +const routeGroups = [ + { id: 'RG-01', name: '华东优先线路', customers: '上海示例通信', gateways: 'GW-A-01, GW-A-02', strategy: '按优先级', retry: '503/408 自动重试', status: '启用' }, + { id: 'RG-02', name: '成本最低线路', customers: '杭州云呼叫中心', gateways: 'GW-B-01, GW-A-02', strategy: '按成本最低', retry: '失败换供应商', status: '启用' }, + { id: 'RG-03', name: '国际备线池', customers: '深圳跨境业务部', gateways: 'GW-C-01', strategy: '按号码前缀', retry: '不重试', status: '观察' }, +]; + +const routeRules = [ + { id: 'DR-001', customer: '上海示例通信', prefix: '13,15,18', routeGroup: '华东优先线路', priority: 10, window: '00:00-23:59', status: '生效', remark: '国内移动优先' }, + { id: 'DR-002', customer: '杭州云呼叫中心', prefix: '0571', routeGroup: '成本最低线路', priority: 20, window: '08:00-21:00', status: '生效', remark: '本地固话' }, + { id: 'DR-003', customer: '深圳跨境业务部', prefix: '00', routeGroup: '国际备线池', priority: 30, window: '00:00-23:59', status: '暂停', remark: '国际前缀' }, +]; + +const rates = [ + { type: '客户费率', owner: '上海示例通信', business: '国内移动外呼', gateway: 'C-GW-SH-01', caller: '-', region: '中国大陆-移动', prefix: '13/15/18', price: '¥0.052/分钟', cycle: '60/6', first: '60s', start: '2026-06-01', end: '-' }, + { type: '客户费率', owner: '杭州云呼叫中心', business: '浙江固话业务', gateway: 'C-GW-HZ-01', caller: '-', region: '中国大陆-固话', prefix: '0571', price: '¥0.038/分钟', cycle: '6/6', first: '6s', start: '2026-06-01', end: '-' }, + { type: '供应商费率', owner: '供应商 A', business: '国内移动成本线', gateway: 'GW-A-01', caller: '0216001*', region: '中国大陆-移动', prefix: '13/15/18', price: '¥0.031/分钟', cycle: '60/6', first: '60s', start: '2026-05-15', end: '-' }, + { type: '供应商费率', owner: '国际供应商 C', business: '国际长途', gateway: 'GW-C-01', caller: '0755888*', region: '美国', prefix: '001', price: '¥0.120/分钟', cycle: '60/60', first: '60s', start: '2026-05-21', end: '-' }, +]; + +const cdrs = [ + { callId: '8b4d-1001-202606151014', customer: '上海示例通信', customerGateway: 'C-GW-SH-01', sourceIp: '10.10.1.11', caller: '02160010001', callee: '13800138000', business: '国内移动外呼', started: '10:14:22', answered: '10:14:27', ended: '10:17:30', duration: 183, billSec: 186, code: 200, hangupReason: '正常挂断', vendor: '供应商 A', gateway: 'GW-A-01', route: 'DR-001', customerRate: 'CN-Mobile-60/6', vendorRate: 'A-Mobile-60/6', record: '已录音' }, + { callId: '8b4d-1002-202606151018', customer: '杭州云呼叫中心', customerGateway: 'C-GW-HZ-01', sourceIp: '10.20.8.21', caller: '057188812345', callee: '02160018888', business: '浙江固话业务', started: '10:18:03', answered: '-', ended: '10:18:08', duration: 0, billSec: 0, code: 486, hangupReason: '用户忙', vendor: '供应商 B', gateway: 'GW-B-01', route: 'DR-002', customerRate: '-', vendorRate: '-', record: '无' }, + { callId: '8b4d-1003-202606151021', customer: '深圳跨境业务部', customerGateway: 'C-GW-SZ-03', sourceIp: '10.30.5.31', caller: '075588801111', callee: '0014155550198', business: '国际直拨', started: '10:21:39', answered: '10:21:45', ended: '10:28:46', duration: 421, billSec: 480, code: 200, hangupReason: '正常挂断', vendor: '国际供应商 C', gateway: 'GW-C-01', route: 'DR-003', customerRate: 'Global-60/60', vendorRate: 'C-Global-60/60', record: '已录音' }, +]; + +const samplingRules = [ + { id: 'QR-001', name: '上海移动业务日常抽检', customer: '上海示例通信', ratio: 8, route: '华东优先线路', start: '2026-06-01', status: '启用' }, + { id: 'QR-002', name: '杭州坐席服务抽检', customer: '杭州云呼叫中心', ratio: 5, route: '成本最低线路', start: '2026-06-01', status: '启用' }, + { id: 'QR-003', name: '深圳国际业务专项抽检', customer: '深圳跨境业务部', ratio: 12, route: '国际备线池', start: '2026-06-10', status: '禁用' }, +]; + +const ratedCdrs = [ + { id: 'RCDR-88120', raw: '8b4d-1001', customerRate: 'CN-Mobile-60/6', vendorRate: 'A-Mobile-60/6', fee: '¥0.1612', cost: '¥0.0961', profit: '¥0.0651', status: '已计费', ratedAt: '10:17:41' }, + { id: 'RCDR-88121', raw: '8b4d-1002', customerRate: '-', vendorRate: '-', fee: '¥0.0000', cost: '¥0.0000', profit: '¥0.0000', status: '失败不计费', ratedAt: '10:18:08' }, + { id: 'RCDR-88122', raw: '8b4d-1003', customerRate: 'US-60/60', vendorRate: 'Global-60/60', fee: '¥0.9600', cost: '¥0.7200', profit: '¥0.2400', status: '已计费', ratedAt: '10:29:52' }, +]; + +const recordings = [ + { callId: '8b4d-1001-202606151014', customer: '上海示例通信', caller: '02160010001', callee: '13800138000', business: '国内移动外呼', time: '10:14:22', duration: '03:03', file: 'rec/2026/06/15/1001.wav', play: '可试听', review: '待质检', score: '-' }, + { callId: '8b4d-1003-202606151021', customer: '深圳跨境业务部', caller: '075588801111', callee: '0014155550198', business: '国际直拨', time: '10:21:39', duration: '07:01', file: 'rec/2026/06/15/1003.wav', play: '可试听', review: '未开始', score: '-' }, + { callId: '8b4d-0977-202606150938', customer: '上海示例通信', caller: '02160010009', callee: '13900139000', business: '国内移动外呼', time: '09:38:10', duration: '05:44', file: 'rec/2026/06/15/0977.wav', play: '可试听', review: '已完成', score: '92' }, +]; + +const opsItems = [ + { name: 'OpenSIPS 主节点', target: '10.0.2.11:8080/mi', status: '正常', value: 'ps/get_statistics 可用' }, + { name: 'RTPEngine 节点 A', target: '10.0.2.21:2223', status: '正常', value: '会话 824' }, + { name: 'SIP 5060 监听', target: 'udp:0.0.0.0:5060', status: '正常', value: '监听中' }, + { name: 'Billing Worker 堆积', target: 'billing.queue', status: '观察', value: '1,284 条' }, + { name: '录音文件入库队列', target: 'recording.queue', status: '告警', value: '428 条' }, +]; + +const auditLogs = [ + { time: '2026-06-15 10:31:44', user: '运营管理员', action: '新增客户', object: '杭州云呼叫中心', ip: '10.1.8.32' }, + { time: '2026-06-15 10:24:09', user: '财务', action: '修改费率', object: 'CN-Mobile-60/6', ip: '10.1.8.45' }, + { time: '2026-06-15 09:58:51', user: '技术运维', action: 'dr_reload', object: 'OpenSIPS 主节点', ip: '10.1.8.88' }, + { time: '2026-06-15 09:42:18', user: '质检', action: '下载录音', object: '8b4d-0977', ip: '10.1.8.62' }, +]; + +const initialAdminUsers = [ + { id: 'U1001', username: 'admin', name: '系统管理员', phone: '13800000001', email: 'admin@lisglosips.local', roleId: 'R001', status: '启用', lastLogin: '2026-06-15 10:36:12', lastIp: '10.1.8.10' }, + { id: 'U1002', username: 'operator.li', name: '李运营', phone: '13800000012', email: 'operator@lisglosips.local', roleId: 'R002', status: '启用', lastLogin: '2026-06-15 10:31:44', lastIp: '10.1.8.32' }, + { id: 'U1003', username: 'finance.zhou', name: '周财务', phone: '13800000023', email: 'finance@lisglosips.local', roleId: 'R003', status: '启用', lastLogin: '2026-06-15 10:24:09', lastIp: '10.1.8.45' }, + { id: 'U1004', username: 'quality.wang', name: '王质检', phone: '13800000034', email: 'quality@lisglosips.local', roleId: 'R004', status: '启用', lastLogin: '2026-06-15 09:42:18', lastIp: '10.1.8.62' }, + { id: 'U1005', username: 'ops.chen', name: '陈运维', phone: '13800000045', email: 'ops@lisglosips.local', roleId: 'R005', status: '禁用', lastLogin: '2026-06-14 22:18:09', lastIp: '10.1.8.88' }, +]; + +const permissionGroups = [ + { name: '客户与供应商', permissions: [{ key: 'customers.view', label: '查看客户' }, { key: 'customers.manage', label: '管理客户' }, { key: 'vendors.view', label: '查看供应商' }, { key: 'vendors.manage', label: '管理供应商' }] }, + { name: '网关与路由', permissions: [{ key: 'gateways.view', label: '查看网关' }, { key: 'gateways.manage', label: '管理网关' }, { key: 'routes.view', label: '查看线路与路由' }, { key: 'routes.manage', label: '配置线路与路由' }] }, + { name: '计费与财务', permissions: [{ key: 'billing.view', label: '查看费率与账单' }, { key: 'billing.manage', label: '管理费率' }, { key: 'recharge.manage', label: '客户/供应商充值' }, { key: 'billing.export', label: '导出财务数据' }] }, + { name: '话单与质检', permissions: [{ key: 'cdr.view', label: '查看话单' }, { key: 'cdr.export', label: '导出话单' }, { key: 'recording.play', label: '播放录音' }, { key: 'quality.manage', label: '质检与评分' }] }, + { name: '运维监控', permissions: [{ key: 'sipops.view', label: '查看 SIP 运维' }, { key: 'sipops.manage', label: '执行运维操作' }, { key: 'monitoring.view', label: '查看监控告警' }, { key: 'monitoring.manage', label: '管理告警规则' }] }, + { name: '系统管理', permissions: [{ key: 'settings.manage', label: '管理系统设置' }, { key: 'users.manage', label: '管理用户' }, { key: 'roles.manage', label: '管理角色权限' }, { key: 'logs.view', label: '查看操作日志' }] }, +]; + +const allPermissionKeys = permissionGroups.flatMap((group) => group.permissions.map((permission) => permission.key)); + +const initialRoles = [ + { id: 'R001', name: '超级管理员', description: '拥有平台全部功能与数据权限', status: '启用', builtIn: true, permissions: allPermissionKeys }, + { id: 'R002', name: '运营管理员', description: '负责客户、网关、线路与日常运营', status: '启用', builtIn: true, permissions: ['customers.view', 'customers.manage', 'vendors.view', 'gateways.view', 'gateways.manage', 'routes.view', 'routes.manage', 'cdr.view', 'cdr.export', 'monitoring.view'] }, + { id: 'R003', name: '财务', description: '负责费率、充值、账单、成本与导出', status: '启用', builtIn: true, permissions: ['customers.view', 'vendors.view', 'billing.view', 'billing.manage', 'recharge.manage', 'billing.export', 'cdr.view'] }, + { id: 'R004', name: '质检', description: '负责录音抽检、质检评分与报告', status: '启用', builtIn: true, permissions: ['customers.view', 'cdr.view', 'recording.play', 'quality.manage'] }, + { id: 'R005', name: '技术运维', description: '负责 SIP 运维、监控和底层排障', status: '启用', builtIn: true, permissions: ['gateways.view', 'routes.view', 'cdr.view', 'sipops.view', 'sipops.manage', 'monitoring.view', 'monitoring.manage', 'logs.view'] }, +]; + +const operationLogRows = [ + { id: 'LOG-20260615-001', time: '2026-06-15 10:31:44', user: '李运营', username: 'operator.li', module: '客户管理', action: '新增客户', object: '杭州云呼叫中心(C1002)', result: '成功', ip: '10.1.8.32', summary: '创建客户并初始化默认账户配置', userAgent: 'Chrome 137 / Windows 11' }, + { id: 'LOG-20260615-002', time: '2026-06-15 10:24:09', user: '周财务', username: 'finance.zhou', module: '费率与计费', action: '修改费率', object: 'CN-Mobile-60/6', result: '成功', ip: '10.1.8.45', summary: '周期费率由 ¥0.036 调整为 ¥0.038', userAgent: 'Edge 137 / Windows 11' }, + { id: 'LOG-20260615-003', time: '2026-06-15 09:58:51', user: '陈运维', username: 'ops.chen', module: 'SIP 运维', action: '重载路由', object: 'OpenSIPS 主节点', result: '成功', ip: '10.1.8.88', summary: '执行 dr_reload,动态路由表重载完成', userAgent: 'Chrome 136 / Windows 10' }, + { id: 'LOG-20260615-004', time: '2026-06-15 09:42:18', user: '王质检', username: 'quality.wang', module: '质检中心', action: '播放录音', object: '8b4d-0977-202606150940', result: '成功', ip: '10.1.8.62', summary: '试听话单录音用于质检评分', userAgent: 'Chrome 137 / macOS 15' }, + { id: 'LOG-20260615-005', time: '2026-06-15 09:17:03', user: '李运营', username: 'operator.li', module: '线路与路由', action: '删除策略', object: '夜间国际备用线路', result: '失败', ip: '10.1.8.32', summary: '策略仍被客户网关引用,系统拒绝删除', userAgent: 'Chrome 137 / Windows 11' }, + { id: 'LOG-20260615-006', time: '2026-06-15 08:55:26', user: '系统管理员', username: 'admin', module: '用户管理', action: '禁用用户', object: 'ops.chen(U1005)', result: '成功', ip: '10.1.8.10', summary: '因账号交接临时禁用技术运维账号', userAgent: 'Chrome 137 / Windows 11' }, + { id: 'LOG-20260614-007', time: '2026-06-14 22:18:09', user: '陈运维', username: 'ops.chen', module: '认证', action: '用户登录', object: 'LisgloSIPS 运营端', result: '成功', ip: '10.1.8.88', summary: '密码认证成功', userAgent: 'Chrome 136 / Windows 10' }, +]; + +function toneForStatus(status) { + if (['启用', '在线', '正常', '生效', '已计费', '完成', '已完成', '开启', '可试听', '成功'].includes(status)) return 'success'; + if (['观察', '抖动', '待质检', '处理中', '失败不计费'].includes(status)) return 'warning'; + if (['停用', '禁用', '暂停', '告警', '关闭', '失败'].includes(status)) return 'danger'; + return 'neutral'; +} + +function Icon({ type }) { + const common = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' }; + const paths = { + plus: , + search: <>, + export: <>, + reload: <>, + collapse: <>, + expand: <>, + }; + return {paths[type]}; +} + +function PageTitle({ title, desc, actions }) { + return ( +
+
+

{title}

+

{desc}

+
+
{actions}
+
+ ); +} + +function Toolbar({ children }) { + return
{children}
; +} + +function Panel({ title, aside, children, className = '' }) { + return ( +
+
+

{title}

+ {aside} +
+ {children} +
+ ); +} + +function ApiNotice({ loading, error, onRetry }) { + if (loading) { + return 正在从 LisgloSIPS API 拉取页面数据。; + } + if (error) { + return ( + + {error} + {onRetry ? : null} + + ); + } + return null; +} + +function EmptyState({ title = '暂无数据', children = '当前筛选条件下没有可展示的数据。' }) { + return ( +
+ {title} + {children} +
+ ); +} + +function Modal({ title, aside, children, onClose, size = 'lg' }) { + return ( +
+
event.stopPropagation()}> +
+
+

{title}

+ {aside} +
+ +
+
{children}
+
+
+ ); +} + +function Drawer({ title, aside, children, onClose }) { + return ( +
+ +
+ ); +} + +function StatusBadge({ children }) { + return {children}; +} + +function SimpleTable({ columns, rows, onRowClick, selectedKey }) { + return ( +
+ + + + {columns.map((column) => )} + + + + {rows.length === 0 ? ( + + + + ) : rows.map((row, index) => ( + onRowClick(row) : undefined} + > + {columns.map((column) => ( + + ))} + + ))} + +
{column.label}
+ +
+ {column.status ? {row[column.key]} : column.render ? column.render(row) : row[column.key]} +
+
+ ); +} + +function MiniBarChart({ data, color = selectedBlue }) { + const max = Math.max(1, ...data); + return ( +
+ {data.map((value, index) => ( + + ))} +
+ ); +} + +function LineChart({ data }) { + const points = data.map((value, index) => `${(index / Math.max(1, data.length - 1)) * 100},${100 - value}`).join(' '); + return ( + + + + ); +} + +function KeyValue({ label, value }) { + return ( +
+ {label} + {value} +
+ ); +} + +function dashboardMetrics(summary) { + if (!summary) { + return metrics; + } + return [ + { label: '今日通话数', value: String(summary.calls.totalCalls), delta: '真实 API', tone: 'neutral' }, + { label: '当前在线通话', value: String(summary.realtime.onlineCalls), delta: summary.realtime.source, tone: 'neutral' }, + { label: '今日接通率', value: `${(Number(summary.calls.answerRate) * 100).toFixed(2)}%`, delta: `${summary.calls.answeredCalls}/${summary.calls.totalCalls}`, tone: 'neutral' }, + { label: '客户消费', value: formatCurrency(summary.money.customerFee), delta: '今日', tone: 'neutral' }, + { label: '供应商成本', value: formatCurrency(summary.money.vendorCost), delta: '今日', tone: 'neutral' }, + { label: '今日毛利', value: formatCurrency(summary.money.grossProfit), delta: '今日', tone: 'neutral' }, + { label: '在线注册用户', value: String(summary.realtime.registeredUsers), delta: summary.realtime.source, tone: 'neutral' }, + { label: '活跃客户', value: String(summary.entities.activeCustomers), delta: '启用', tone: 'neutral' }, + { label: '活跃落地网关', value: String(summary.entities.activeVendorGateways), delta: '启用', tone: 'neutral' }, + { label: '异常网关', value: String(summary.abnormalGateways.length), delta: '失败 Top', tone: summary.abnormalGateways.length ? 'warn' : 'neutral' }, + { label: '质检待处理', value: String(summary.quality.pendingReviews), delta: '录音', tone: summary.quality.pendingReviews ? 'warn' : 'neutral' }, + ]; +} + +function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi, customerRows }) { + const trendBuckets = dashboardTrends?.buckets || []; + const callTrendData = trendBuckets.length ? trendBuckets.map((bucket) => bucket.calls.totalCalls) : callTrend; + const answerTrendData = trendBuckets.length ? trendBuckets.map((bucket) => Math.round(Number(bucket.calls.answerRate) * 100)) : answerTrend; + const failureCodes = dashboardSummary?.failureCodes?.length ? dashboardSummary.failureCodes : []; + return ( + <> + } onClick={refreshApi}>刷新指标} + /> + +
+ {dashboardMetrics(dashboardSummary).map((metric) => ( +
+ {metric.label} + {metric.value} + {metric.delta} +
+ ))} +
+
+ OpenSIPS acc/CDR}> + + + dialog statistics}> + + + +
+ {customerRows.length ? customerRows.slice(0, 3).map((item, index) => ( +
{index + 1}{item.name}{item.balance}
+ )) : 客户 API 返回空列表。} +
+
+ +
+ {failureCodes.length ? failureCodes.map((code) => ( +
{code.count}{code.sipCode}
+ )) : 今日没有失败 CDR 或 API 尚未返回数据。} +
+
+
+ + ); +} + +function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord, apiLoading, apiError, refreshApi, onCreateCustomer, onUpdateCustomer, onRechargeCustomer }) { + const [showCreateCustomer, setShowCreateCustomer] = useState(false); + const [editingCustomer, setEditingCustomer] = useState(null); + const [rechargeCustomer, setRechargeCustomer] = useState(null); + const [newCustomer, setNewCustomer] = useState({ name: '', contact: '', phone: '', email: '' }); + const [editCustomerForm, setEditCustomerForm] = useState({ name: '', contact: '', phone: '', email: '' }); + const [rechargeForm, setRechargeForm] = useState({ amount: '', remark: '' }); + const [actionError, setActionError] = useState(''); + const openEditCustomer = (customer) => { + setEditingCustomer(customer); + setEditCustomerForm({ + name: customer.name || '', + contact: customer.contact === '-' ? '' : customer.contact || '', + phone: customer.phone === '-' ? '' : customer.phone || '', + email: customer.email === '-' ? '' : customer.email || '', + }); + }; + const closeEditCustomer = () => { + setEditingCustomer(null); + setEditCustomerForm({ name: '', contact: '', phone: '', email: '' }); + }; + const openRechargeCustomer = (customer) => { + setRechargeCustomer(customer); + setRechargeForm({ amount: '', remark: '' }); + }; + const closeRechargeCustomer = () => { + setRechargeCustomer(null); + setRechargeForm({ amount: '', remark: '' }); + }; + const parseMoney = (value) => Number(String(value).replace(/[^\d.-]/g, '')) || 0; + const formatMoney = (value) => `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + const submitCustomer = async (event) => { + event.preventDefault(); + if (!newCustomer.name.trim()) return; + setActionError(''); + try { + if (onCreateCustomer) { + await onCreateCustomer({ + name: newCustomer.name.trim(), + contactName: newCustomer.contact.trim() || undefined, + phone: newCustomer.phone.trim() || undefined, + email: newCustomer.email.trim() || undefined, + billingMode: 'PREPAID', + creditLimit: '0.000000', + minBalance: '0.000000', + }); + } else { + const nextIndex = customerRows.length + 1; + setCustomerRows([...customerRows, { id: `C${String(1000 + nextIndex)}`, name: newCustomer.name.trim(), contact: newCustomer.contact.trim() || '-', phone: newCustomer.phone.trim() || '-', email: newCustomer.email.trim() || '-', domain: '-', auth: '待配置', status: '启用', balance: '¥0.00', credit: '¥0', billing: '待配置', routeGroup: '待配置', gateways: 0, createdAt: '2026-06-18' }]); + } + setNewCustomer({ name: '', contact: '', phone: '', email: '' }); + setShowCreateCustomer(false); + } catch (error) { + setActionError(explainApiError(error)); + } + }; + const submitEditCustomer = async (event) => { + event.preventDefault(); + if (!editCustomerForm.name.trim() || !editingCustomer) return; + setActionError(''); + try { + if (onUpdateCustomer) { + await onUpdateCustomer(editingCustomer.id, { + name: editCustomerForm.name.trim(), + contactName: editCustomerForm.contact.trim() || null, + phone: editCustomerForm.phone.trim() || null, + email: editCustomerForm.email.trim() || null, + }); + } else { + setCustomerRows(customerRows.map((customer) => ( + customer.id === editingCustomer.id ? { ...customer, name: editCustomerForm.name.trim(), contact: editCustomerForm.contact.trim() || '-', phone: editCustomerForm.phone.trim() || '-', email: editCustomerForm.email.trim() || '-' } : customer + ))); + } + closeEditCustomer(); + } catch (error) { + setActionError(explainApiError(error)); + } + }; + const submitRecharge = async (event) => { + event.preventDefault(); + const amount = Number(rechargeForm.amount); + if (!rechargeCustomer || !Number.isFinite(amount) || amount <= 0) return; + setActionError(''); + try { + if (onRechargeCustomer) { + await onRechargeCustomer(rechargeCustomer.id, { + amount: amount.toFixed(2), + remark: rechargeForm.remark.trim() || undefined, + }); + } else { + const beforeBalance = parseMoney(rechargeCustomer.balance); + const afterBalance = beforeBalance + amount; + setCustomerRows(customerRows.map((customer) => ( + customer.id === rechargeCustomer.id ? { ...customer, balance: formatMoney(afterBalance) } : customer + ))); + addRechargeRecord({ id: `RCG-${Date.now()}`, type: 'customer', owner: rechargeCustomer.name, amount: formatMoney(amount), beforeBalance: formatMoney(beforeBalance), afterBalance: formatMoney(afterBalance), remark: rechargeForm.remark.trim() || '-', operator: '运营管理员', time: new Date().toLocaleString('zh-CN', { hour12: false }), status: '成功' }); + } + closeRechargeCustomer(); + } catch (error) { + setActionError(explainApiError(error)); + } + }; + + return ( + <> + } onClick={() => setShowCreateCustomer(true)}>新增客户} + /> + + + + + + +
+ + ( +
+ + +
+ ), + }, + ]} + /> +
+
+ {showCreateCustomer ? ( + setShowCreateCustomer(false)} size="sm"> +
+ 客户名称 *}> + setNewCustomer({ ...newCustomer, name: event.target.value })} placeholder="请输入企业名称" required /> + + + setNewCustomer({ ...newCustomer, contact: event.target.value })} placeholder="请输入联系人" /> + + + setNewCustomer({ ...newCustomer, phone: event.target.value })} placeholder="请输入联系电话" /> + + + setNewCustomer({ ...newCustomer, email: event.target.value })} placeholder="请输入邮箱" /> + +
+ + +
+
+
+ ) : null} + {rechargeCustomer ? ( + +
+ + 充值金额 *}> + setRechargeForm({ ...rechargeForm, amount: event.target.value })} placeholder="请输入充值金额" required /> + + +