Initial LisgloSIPS V2 implementation
This commit is contained in:
@@ -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:<PASSWORD>@127.0.0.1:3306/lisglosips
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
|
AUTH_ACCESS_TOKEN_SECRET=<MIN_32_CHAR_RANDOM_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
|
||||||
@@ -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
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
.codex-private/
|
||||||
|
.codex-runtime/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
**/dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
coverage/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
cookies.txt
|
||||||
@@ -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/<previous-release>` 切回上一 release;A OpenSIPS 配置按 `infra/server-a/s30/lisglosips-opensips-config-restore.sh <backup-opensips.cfg>` 先语法检查再恢复;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 密钥登录”;端口号统一使用 `<SSH_ADMIN_PORT>` 占位,待用户确认具体端口后再实施。
|
||||||
|
- 修改文件:`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_ADMIN_PORT>`。
|
||||||
|
- 回滚方式:恢复上述文档至本次修改前版本。
|
||||||
|
- 未解决问题:尚未确认 `<SSH_ADMIN_PORT>` 的具体端口号,未执行服务器侧 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 安全接入与凭据迁移`
|
||||||
@@ -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"}'
|
||||||
|
```
|
||||||
@@ -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 -> 前端开发
|
||||||
|
```
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<NestFastifyApplication>(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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
|
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();
|
||||||
@@ -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 {}
|
||||||
@@ -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<string, unknown>) {
|
||||||
|
return this.auditLogsService.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermissions('audit.view')
|
||||||
|
get(@Param('id') id: string) {
|
||||||
|
return this.auditLogsService.get(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<AuditLogDetail>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AuditLogDetail> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<unknown> {
|
||||||
|
const metadata = this.reflector.getAllAndOverride<AuditMetadata>(AUDIT_METADATA_KEY, [context.getHandler(), context.getClass()]);
|
||||||
|
if (!metadata) {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||||
|
const params = request.params as Record<string, string> | 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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 {}
|
||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
await this.repository.write({
|
||||||
|
...input,
|
||||||
|
beforeSummary: redactSensitive(input.beforeSummary),
|
||||||
|
afterSummary: redactSensitive(input.afterSummary)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
baseFromRequest(request: FastifyRequest): Pick<AuditEntryInput, 'requestId' | 'userId' | 'username' | 'roleNames' | 'ip' | 'userAgent'> {
|
||||||
|
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']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
result[key] = SENSITIVE_KEY_PATTERN.test(key) ? '[REDACTED]' : redactSensitive(nested, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, AuthSessionRecord>();
|
||||||
|
|
||||||
|
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<E2eAuthRepository> {
|
||||||
|
return new E2eAuthRepository(await hashPasswordArgon2id(secret, { memoryKiB: 1024, passes: 1 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUserByUsername(username: string): Promise<AuthUserRecord | null> {
|
||||||
|
return username === this.user.username ? { ...this.user, roles: [...this.user.roles] } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markLoginSuccess(): Promise<void> {
|
||||||
|
this.user.failedLoginCount = 0;
|
||||||
|
this.user.lockedUntil = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markLoginFailure(_userId: string, failedLoginCount: number, lockedUntil: Date | null): Promise<void> {
|
||||||
|
this.user.failedLoginCount = failedLoginCount;
|
||||||
|
this.user.lockedUntil = lockedUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(input: CreateSessionInput): Promise<AuthSessionRecord> {
|
||||||
|
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<AuthSessionRecord | null> {
|
||||||
|
return (
|
||||||
|
[...this.sessions.values()].find(
|
||||||
|
(session) => session.refreshTokenHash === refreshTokenHash && !session.revokedAt && session.expiresAt > now
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeSession(sessionId: string, revokedAt: Date): Promise<void> {
|
||||||
|
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<NestFastifyApplication>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {}
|
||||||
@@ -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<AuthUserRecord | null> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
failedLoginCount,
|
||||||
|
lockedUntil
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(input: CreateSessionInput): Promise<AuthSessionRecord> {
|
||||||
|
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<AuthSessionRecord | null> {
|
||||||
|
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<void> {
|
||||||
|
await this.prisma.userSession.updateMany({
|
||||||
|
where: {
|
||||||
|
id: sessionId,
|
||||||
|
revokedAt: null
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
revokedAt
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, AuthSessionRecord>();
|
||||||
|
|
||||||
|
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<MemoryAuthRepository> {
|
||||||
|
return new MemoryAuthRepository(
|
||||||
|
await hashPasswordArgon2id(secret, {
|
||||||
|
memoryKiB: 1024,
|
||||||
|
passes: 1
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUserByUsername(username: string): Promise<AuthUserRecord | null> {
|
||||||
|
return username === this.user.username ? { ...this.user, roles: [...this.user.roles] } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markLoginSuccess(_userId: string, _ip?: string): Promise<void> {
|
||||||
|
this.user.failedLoginCount = 0;
|
||||||
|
this.user.lockedUntil = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markLoginFailure(_userId: string, failedLoginCount: number, lockedUntil: Date | null): Promise<void> {
|
||||||
|
this.user.failedLoginCount = failedLoginCount;
|
||||||
|
this.user.lockedUntil = lockedUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(input: CreateSessionInput): Promise<AuthSessionRecord> {
|
||||||
|
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<AuthSessionRecord | null> {
|
||||||
|
return (
|
||||||
|
[...this.sessions.values()].find(
|
||||||
|
(session) => session.refreshTokenHash === refreshTokenHash && !session.revokedAt && session.expiresAt > now
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeSession(sessionId: string, revokedAt: Date): Promise<void> {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (session) {
|
||||||
|
session.revokedAt = revokedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function config() {
|
||||||
|
const values = new Map<string, unknown>([
|
||||||
|
['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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<RuntimeConfig, true>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<AuthTokens> {
|
||||||
|
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<void> {
|
||||||
|
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.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AuthUserRecord | null>;
|
||||||
|
markLoginSuccess(userId: string, ip?: string): Promise<void>;
|
||||||
|
markLoginFailure(userId: string, failedLoginCount: number, lockedUntil: Date | null): Promise<void>;
|
||||||
|
createSession(input: CreateSessionInput): Promise<AuthSessionRecord>;
|
||||||
|
findActiveSessionByRefreshTokenHash(refreshTokenHash: string, now: Date): Promise<AuthSessionRecord | null>;
|
||||||
|
revokeSession(sessionId: string, revokedAt: Date): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTH_REPOSITORY = Symbol('AUTH_REPOSITORY');
|
||||||
@@ -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('; ');
|
||||||
|
}
|
||||||
+45
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, CurrentUser>();
|
||||||
|
|
||||||
|
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||||
|
return this.users.get(userId) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryAuditRepository implements AuditRepository {
|
||||||
|
entries: AuditEntryInput[] = [];
|
||||||
|
|
||||||
|
async write(input: AuditEntryInput): Promise<void> {
|
||||||
|
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<CustomerGatewayPolicySummary[]> {
|
||||||
|
return this.policies.filter((policy) => policy.gatewayId === gatewayId).sort((left, right) => left.priority - right.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreatePolicyInput): Promise<CustomerGatewayPolicySummary> {
|
||||||
|
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<CustomerGatewayPolicySummary> {
|
||||||
|
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<CustomerGatewayPolicySummary> {
|
||||||
|
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<CustomerGatewayPolicySummary[]> {
|
||||||
|
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<NestFastifyApplication>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {}
|
||||||
+290
@@ -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<CustomerGatewayPolicySummary[]>;
|
||||||
|
create(input: CreatePolicyInput): Promise<CustomerGatewayPolicySummary>;
|
||||||
|
update(policyId: string, input: UpdatePolicyInput): Promise<CustomerGatewayPolicySummary>;
|
||||||
|
softDelete(policyId: string, actorId?: string): Promise<CustomerGatewayPolicySummary>;
|
||||||
|
reorder(gatewayId: string, policyIds: string[], actorId?: string): Promise<CustomerGatewayPolicySummary[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<CustomerGatewayPolicySummary[]> {
|
||||||
|
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<CustomerGatewayPolicySummary> {
|
||||||
|
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<CustomerGatewayPolicySummary> {
|
||||||
|
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<CustomerGatewayPolicySummary> {
|
||||||
|
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<CustomerGatewayPolicySummary[]> {
|
||||||
|
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<void> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
||||||
|
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.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CustomerGatewayPolicySummary[]> {
|
||||||
|
return this.policies.list(gatewayId);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(gatewayId: string, body: CreatePolicyDto, actorId?: string): Promise<CustomerGatewayPolicySummary> {
|
||||||
|
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<CustomerGatewayPolicySummary> {
|
||||||
|
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<CustomerGatewayPolicySummary> {
|
||||||
|
return this.policies.softDelete(policyId, actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
reorder(gatewayId: string, body: ReorderDto, actorId?: string): Promise<CustomerGatewayPolicySummary[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, CurrentUser>();
|
||||||
|
|
||||||
|
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||||
|
return this.users.get(userId) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryAuditRepository implements AuditRepository {
|
||||||
|
entries: AuditEntryInput[] = [];
|
||||||
|
|
||||||
|
async write(input: AuditEntryInput): Promise<void> {
|
||||||
|
this.entries.push(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
|
||||||
|
private readonly gateways = new Map<string, CustomerGatewaySummary>();
|
||||||
|
|
||||||
|
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<CustomerGatewaySummary[]> {
|
||||||
|
return [...this.gateways.values()].filter((gateway) => !customerId || gateway.customerId === customerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(gatewayId: string): Promise<CustomerGatewaySummary> {
|
||||||
|
return this.gateways.get(gatewayId) ?? this.summary({ id: gatewayId, name: 'Missing Gateway' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateCustomerGatewayInput): Promise<CustomerGatewaySummary> {
|
||||||
|
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<CustomerGatewaySummary> {
|
||||||
|
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<CustomerGatewaySummary> {
|
||||||
|
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<NestFastifyApplication>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {}
|
||||||
@@ -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<CustomerGatewaySummary[]>;
|
||||||
|
get(gatewayId: string): Promise<CustomerGatewaySummary>;
|
||||||
|
create(input: CreateCustomerGatewayInput): Promise<CustomerGatewaySummary>;
|
||||||
|
update(gatewayId: string, input: UpdateCustomerGatewayInput): Promise<CustomerGatewaySummary>;
|
||||||
|
setStatus(gatewayId: string, status: CustomerGatewayStatus, actorId?: string): Promise<CustomerGatewaySummary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<CustomerGatewaySummary[]> {
|
||||||
|
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<CustomerGatewaySummary> {
|
||||||
|
return this.toSummary(await this.findActiveOrThrow(gatewayIdValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateCustomerGatewayInput): Promise<CustomerGatewaySummary> {
|
||||||
|
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<CustomerGatewaySummary> {
|
||||||
|
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<CustomerGatewaySummary> {
|
||||||
|
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<void> {
|
||||||
|
await tx.outboxEvent.create({
|
||||||
|
data: {
|
||||||
|
id: outboxId(),
|
||||||
|
aggregateType: 'customer_gateway_config',
|
||||||
|
aggregateId,
|
||||||
|
eventType,
|
||||||
|
payload: {
|
||||||
|
aggregateId,
|
||||||
|
eventType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCustomerExists(customerId: string): Promise<void> {
|
||||||
|
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.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CustomerGatewaySummary[]> {
|
||||||
|
const customerId = query.customerId === undefined ? undefined : this.limitedString(query.customerId, 'customerId', 32);
|
||||||
|
return this.gateways.list(customerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(gatewayId: string): Promise<CustomerGatewaySummary> {
|
||||||
|
return this.gateways.get(gatewayId);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(body: CreateCustomerGatewayDto, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||||
|
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<CustomerGatewaySummary> {
|
||||||
|
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<CustomerGatewaySummary> {
|
||||||
|
return this.gateways.setStatus(gatewayId, 'ENABLED', actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
disable(gatewayId: string, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, CurrentUser>();
|
||||||
|
|
||||||
|
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||||
|
return this.users.get(userId) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryAuditRepository implements AuditRepository {
|
||||||
|
entries: AuditEntryInput[] = [];
|
||||||
|
|
||||||
|
async write(input: AuditEntryInput): Promise<void> {
|
||||||
|
this.entries.push(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryCustomersRepository implements CustomersRepository {
|
||||||
|
private readonly customers = new Map<string, CustomerSummary>();
|
||||||
|
|
||||||
|
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<CustomerSummary[]> {
|
||||||
|
return [...this.customers.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(customerId: string): Promise<CustomerSummary> {
|
||||||
|
return this.customers.get(customerId) ?? this.summary({ id: customerId, name: 'Missing' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateCustomerInput): Promise<CustomerSummary> {
|
||||||
|
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<CustomerSummary> {
|
||||||
|
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<CustomerSummary> {
|
||||||
|
return this.update(customerId, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
async softDelete(customerId: string): Promise<CustomerSummary> {
|
||||||
|
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<NestFastifyApplication>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {}
|
||||||
@@ -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<CustomerSummary[]>;
|
||||||
|
get(customerId: string): Promise<CustomerSummary>;
|
||||||
|
create(input: CreateCustomerInput): Promise<CustomerSummary>;
|
||||||
|
update(customerId: string, input: UpdateCustomerInput): Promise<CustomerSummary>;
|
||||||
|
setStatus(customerId: string, status: CustomerStatus, actorId?: string): Promise<CustomerSummary>;
|
||||||
|
softDelete(customerId: string, actorId?: string): Promise<CustomerSummary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<CustomerSummary[]> {
|
||||||
|
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<CustomerSummary> {
|
||||||
|
return this.toSummary(await this.findActiveOrThrow(customerIdValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateCustomerInput): Promise<CustomerSummary> {
|
||||||
|
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<CustomerSummary> {
|
||||||
|
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<CustomerSummary> {
|
||||||
|
return this.update(customerIdValue, { status, actorId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async softDelete(customerIdValue: string, actorId?: string): Promise<CustomerSummary> {
|
||||||
|
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<void> {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CustomerSummary[]> {
|
||||||
|
return this.customers.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
get(customerId: string): Promise<CustomerSummary> {
|
||||||
|
return this.customers.get(customerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(body: CreateCustomerDto, actorId?: string): Promise<CustomerSummary> {
|
||||||
|
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<CustomerSummary> {
|
||||||
|
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<CustomerSummary> {
|
||||||
|
return this.customers.setStatus(customerId, 'ENABLED', actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
disable(customerId: string, actorId?: string): Promise<CustomerSummary> {
|
||||||
|
return this.customers.setStatus(customerId, 'DISABLED', actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(customerId: string, actorId?: string): Promise<CustomerSummary> {
|
||||||
|
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')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<DashboardSummarySnapshot>;
|
||||||
|
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<DashboardSummarySnapshot> {
|
||||||
|
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<DashboardGatewayFailureRow[]> {
|
||||||
|
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<string, { failedCalls: number; totalCalls: number; gateway: NonNullable<(typeof cdrs)[number]['vendorGateway']> }>();
|
||||||
|
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<number, number>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<void> {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string>('database.url'));
|
||||||
|
const hasRedisUrl = Boolean(this.configService.get<string>('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<string, HealthStatus>): HealthCheckResponse {
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
service: this.configService.get<string>('service.name') ?? 'api',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
checks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, CurrentUser>();
|
||||||
|
|
||||||
|
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||||
|
return this.users.get(userId) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryAuditRepository implements AuditRepository {
|
||||||
|
entries: AuditEntryInput[] = [];
|
||||||
|
|
||||||
|
async write(input: AuditEntryInput): Promise<void> {
|
||||||
|
this.entries.push(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryLandingLineGroupsRepository implements LandingLineGroupsRepository {
|
||||||
|
private readonly groups = new Map<string, LineGroupSummary>();
|
||||||
|
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<LineGroupSummary[]> {
|
||||||
|
return [...this.groups.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(lineGroupId: string): Promise<LineGroupSummary> {
|
||||||
|
return this.groups.get(lineGroupId) ?? this.summary({ id: lineGroupId, name: 'Missing Group' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: { name: string; status: LineGroupStatus; notes?: string | null }): Promise<LineGroupSummary> {
|
||||||
|
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<LineGroupSummary> {
|
||||||
|
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<LineGroupSummary> {
|
||||||
|
return this.update(lineGroupId, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
async softDelete(lineGroupId: string): Promise<LineGroupSummary> {
|
||||||
|
return this.update(lineGroupId, { status: 'DISABLED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async addItem(input: AddLineGroupItemInput): Promise<LineGroupSummary> {
|
||||||
|
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<LineGroupItemSummary> {
|
||||||
|
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<LineGroupSummary> {
|
||||||
|
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<LineGroupSummary> {
|
||||||
|
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<LineGroupSummary> & { 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<LineGroupItemSummary> & { 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<NestFastifyApplication>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {}
|
||||||
@@ -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<LineGroupSummary[]>;
|
||||||
|
get(lineGroupId: string): Promise<LineGroupSummary>;
|
||||||
|
create(input: CreateLineGroupInput): Promise<LineGroupSummary>;
|
||||||
|
update(lineGroupId: string, input: UpdateLineGroupInput): Promise<LineGroupSummary>;
|
||||||
|
setStatus(lineGroupId: string, status: LineGroupStatus, actorId?: string): Promise<LineGroupSummary>;
|
||||||
|
softDelete(lineGroupId: string, actorId?: string): Promise<LineGroupSummary>;
|
||||||
|
addItem(input: AddLineGroupItemInput): Promise<LineGroupSummary>;
|
||||||
|
updateItem(itemId: string, input: UpdateLineGroupItemInput): Promise<LineGroupItemSummary>;
|
||||||
|
removeItem(lineGroupId: string, vendorGatewayId: string, actorId?: string): Promise<LineGroupSummary>;
|
||||||
|
reorderItems(lineGroupId: string, itemIds: string[], actorId?: string): Promise<LineGroupSummary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<LineGroupSummary[]> {
|
||||||
|
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<LineGroupSummary> {
|
||||||
|
return this.toSummary(await this.findActiveOrThrow(lineGroupIdValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateLineGroupInput): Promise<LineGroupSummary> {
|
||||||
|
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<LineGroupSummary> {
|
||||||
|
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<LineGroupSummary> {
|
||||||
|
return this.update(lineGroupIdValue, { status, actorId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async softDelete(lineGroupIdValue: string, actorId?: string): Promise<LineGroupSummary> {
|
||||||
|
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<LineGroupSummary> {
|
||||||
|
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<LineGroupItemSummary> {
|
||||||
|
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<LineGroupSummary> {
|
||||||
|
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<LineGroupSummary> {
|
||||||
|
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<void> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
||||||
|
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.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LineGroupSummary[]> {
|
||||||
|
return this.lineGroups.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
get(lineGroupId: string): Promise<LineGroupSummary> {
|
||||||
|
return this.lineGroups.get(lineGroupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(body: CreateLineGroupDto, actorId?: string): Promise<LineGroupSummary> {
|
||||||
|
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<LineGroupSummary> {
|
||||||
|
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<LineGroupSummary> {
|
||||||
|
return this.lineGroups.setStatus(lineGroupId, 'ENABLED', actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
disable(lineGroupId: string, actorId?: string): Promise<LineGroupSummary> {
|
||||||
|
return this.lineGroups.setStatus(lineGroupId, 'DISABLED', actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(lineGroupId: string, actorId?: string): Promise<LineGroupSummary> {
|
||||||
|
return this.lineGroups.softDelete(lineGroupId, actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
addItem(lineGroupId: string, body: AddItemDto, actorId?: string): Promise<LineGroupSummary> {
|
||||||
|
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<LineGroupItemSummary> {
|
||||||
|
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<LineGroupSummary> {
|
||||||
|
return this.lineGroups.removeItem(lineGroupId, vendorGatewayId, actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
reorderItems(lineGroupId: string, body: ReorderItemsDto, actorId?: string): Promise<LineGroupSummary> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<QualityRuleSummary[]>;
|
||||||
|
getRule(ruleId: string): Promise<QualityRuleSummary>;
|
||||||
|
createRule(input: CreateQualityRuleInput): Promise<QualityRuleSummary>;
|
||||||
|
updateRule(ruleId: string, input: UpdateQualityRuleInput): Promise<QualityRuleSummary>;
|
||||||
|
setRuleStatus(ruleId: string, status: QualityRuleStatus, actorId?: string): Promise<QualityRuleSummary>;
|
||||||
|
softDeleteRule(ruleId: string, actorId?: string): Promise<QualityRuleSummary>;
|
||||||
|
listActiveRules(now: Date): Promise<ActiveQualityRule[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<QualityRuleSummary[]> {
|
||||||
|
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<QualityRuleSummary> {
|
||||||
|
return this.toRuleSummary(await this.findRuleOrThrow(ruleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRule(input: CreateQualityRuleInput): Promise<QualityRuleSummary> {
|
||||||
|
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<QualityRuleSummary> {
|
||||||
|
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<QualityRuleSummary> {
|
||||||
|
return this.updateRule(ruleId, { status, actorId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async softDeleteRule(ruleId: string, actorId?: string): Promise<QualityRuleSummary> {
|
||||||
|
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<ActiveQualityRule[]> {
|
||||||
|
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<QualityRuleRecord> {
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
@@ -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<QualityRuleSummary[]> {
|
||||||
|
return this.quality.listRules();
|
||||||
|
}
|
||||||
|
|
||||||
|
getRule(ruleId: string): Promise<QualityRuleSummary> {
|
||||||
|
return this.quality.getRule(ruleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
createRule(body: QualityRuleBody, actorId?: string): Promise<QualityRuleSummary> {
|
||||||
|
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<QualityRuleSummary> {
|
||||||
|
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<QualityRuleSummary> {
|
||||||
|
return this.quality.setRuleStatus(ruleId, 'ENABLED', actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
disableRule(ruleId: string, actorId?: string): Promise<QualityRuleSummary> {
|
||||||
|
return this.quality.setRuleStatus(ruleId, 'DISABLED', actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRule(ruleId: string, actorId?: string): Promise<QualityRuleSummary> {
|
||||||
|
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.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, CurrentUser>();
|
||||||
|
|
||||||
|
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||||
|
return this.users.get(userId) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryAuditRepository implements AuditRepository {
|
||||||
|
entries: AuditEntryInput[] = [];
|
||||||
|
|
||||||
|
async write(input: AuditEntryInput): Promise<void> {
|
||||||
|
this.entries.push(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryRechargesRepository implements RechargesRepository {
|
||||||
|
private readonly items: RechargeSummary[] = [];
|
||||||
|
private readonly balances = new Map<string, number>([
|
||||||
|
['CUSTOMER:cus_seed', 10],
|
||||||
|
['VENDOR:ven_seed', 20]
|
||||||
|
]);
|
||||||
|
private readonly idempotency = new Map<string, { scope: string; hash: string; response: RechargeSummary }>();
|
||||||
|
|
||||||
|
async list(query: RechargeListQuery): Promise<RechargeListResult> {
|
||||||
|
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<RechargeSummary> {
|
||||||
|
return this.recharge('CUSTOMER', 'customer_recharge', input, 'Seed Customer');
|
||||||
|
}
|
||||||
|
|
||||||
|
async rechargeVendor(input: RechargeInput): Promise<RechargeSummary> {
|
||||||
|
return this.recharge('VENDOR', 'vendor_recharge', input, 'Seed Vendor');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recharge(accountType: 'CUSTOMER' | 'VENDOR', scope: string, input: RechargeInput, accountName: string): Promise<RechargeSummary> {
|
||||||
|
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<NestFastifyApplication>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {}
|
||||||
@@ -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<RechargeListResult>;
|
||||||
|
rechargeCustomer(input: RechargeInput): Promise<RechargeSummary>;
|
||||||
|
rechargeVendor(input: RechargeInput): Promise<RechargeSummary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<RechargeListResult> {
|
||||||
|
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<RechargeSummary> {
|
||||||
|
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<RechargeSummary> {
|
||||||
|
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<RechargeSummary>
|
||||||
|
): Promise<RechargeSummary> {
|
||||||
|
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<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, milliseconds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RechargeListResult> {
|
||||||
|
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<RechargeSummary> {
|
||||||
|
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<RechargeSummary> {
|
||||||
|
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, unknown>): string {
|
||||||
|
return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown>) {
|
||||||
|
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<void> {
|
||||||
|
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, '_');
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<RecordingListItem[]>;
|
||||||
|
getDetail(id: string): Promise<RecordingDetail>;
|
||||||
|
getReadyForPlayback(id: string): Promise<RecordingPlayback>;
|
||||||
|
saveReview(input: SaveReviewInput): Promise<RecordingReviewSummary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<RecordingListItem[]> {
|
||||||
|
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<RecordingDetail> {
|
||||||
|
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<RecordingPlayback> {
|
||||||
|
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<RecordingReviewSummary> {
|
||||||
|
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<RecordingRecord> {
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<RecordingPlaybackResponse> {
|
||||||
|
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<RecordingReviewSummary> {
|
||||||
|
if (!reviewerId) {
|
||||||
|
throw new BadRequestException({ code: 'REVIEWER_REQUIRED', message: 'Current user is required to save review.' });
|
||||||
|
}
|
||||||
|
const data = body as Record<string, unknown>;
|
||||||
|
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<T extends RecordingListItem | RecordingDetail>(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';
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<RoleSummary[]>;
|
||||||
|
listPermissions(): Promise<PermissionSummary[]>;
|
||||||
|
createRole(input: CreateRoleInput): Promise<RoleSummary>;
|
||||||
|
updateRole(roleId: string, input: UpdateRoleInput): Promise<RoleSummary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<RoleSummary[]> {
|
||||||
|
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<PermissionSummary[]> {
|
||||||
|
return this.prisma.permission.findMany({
|
||||||
|
orderBy: [{ module: 'asc' }, { action: 'asc' }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRole(input: CreateRoleInput): Promise<RoleSummary> {
|
||||||
|
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<RoleSummary> {
|
||||||
|
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<void> {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RuntimeConfig, true>,
|
||||||
|
@Inject(IDENTITY_REPOSITORY) private readonly identities: IdentityRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]);
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||||
|
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.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CurrentUser | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<CurrentUser | null> {
|
||||||
|
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<PermissionKey>();
|
||||||
|
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<boolean>(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]);
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const required = this.reflector.getAllAndOverride<PermissionKey[]>(REQUIRED_PERMISSIONS_KEY, [context.getHandler(), context.getClass()]) ?? [];
|
||||||
|
if (required.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, CurrentUser>();
|
||||||
|
|
||||||
|
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||||
|
return this.users.get(userId) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryAuditRepository implements AuditRepository {
|
||||||
|
entries: AuditEntryInput[] = [];
|
||||||
|
|
||||||
|
async write(input: AuditEntryInput): Promise<void> {
|
||||||
|
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<AuditLogDetail> {
|
||||||
|
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<UserSummary[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateUserInput): Promise<UserSummary> {
|
||||||
|
return this.summary('usr_created', input.username, input.roleIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(userId: string, input: UpdateUserInput): Promise<UserSummary> {
|
||||||
|
return this.summary(userId, 'operator', input.roleIds ?? ['ROLE_SUPER_ADMIN']);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(userId: string): Promise<UserSummary> {
|
||||||
|
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<RoleSummary[]> {
|
||||||
|
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<RoleSummary> {
|
||||||
|
return this.role('rol_created', false, input.permissionIds, input.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRole(roleId: string, input: UpdateRoleInput): Promise<RoleSummary> {
|
||||||
|
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<NestFastifyApplication>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<AuthenticatedRequest>();
|
||||||
|
return request.currentUser;
|
||||||
|
});
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<UserSummary[]>;
|
||||||
|
create(input: CreateUserInput): Promise<UserSummary>;
|
||||||
|
update(userId: string, input: UpdateUserInput): Promise<UserSummary>;
|
||||||
|
resetPassword(userId: string, passwordHash: string, passwordAlgo: string, actorId?: string): Promise<UserSummary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<UserSummary[]> {
|
||||||
|
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<UserSummary> {
|
||||||
|
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<UserSummary> {
|
||||||
|
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<UserSummary> {
|
||||||
|
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<void> {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<UserSummary[]> {
|
||||||
|
return this.users.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(body: CreateUserDto, actorId?: string): Promise<UserSummary> {
|
||||||
|
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<UserSummary> {
|
||||||
|
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<UserSummary> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, CurrentUser>();
|
||||||
|
|
||||||
|
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||||
|
return this.users.get(userId) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryAuditRepository implements AuditRepository {
|
||||||
|
entries: AuditEntryInput[] = [];
|
||||||
|
|
||||||
|
async write(input: AuditEntryInput): Promise<void> {
|
||||||
|
this.entries.push(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
|
||||||
|
private readonly gateways = new Map<string, VendorGatewaySummary>();
|
||||||
|
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<VendorGatewaySummary[]> {
|
||||||
|
return [...this.gateways.values()].filter((gateway) => !vendorId || gateway.vendorId === vendorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(gatewayId: string): Promise<VendorGatewaySummary> {
|
||||||
|
return this.gateways.get(gatewayId) ?? this.summary({ id: gatewayId, name: 'Missing Gateway' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateVendorGatewayInput): Promise<VendorGatewaySummary> {
|
||||||
|
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<VendorGatewaySummary> {
|
||||||
|
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<VendorGatewaySummary> {
|
||||||
|
return this.update(gatewayId, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
private summary(input: Partial<VendorGatewaySummary> & { 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<NestFastifyApplication>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {}
|
||||||
@@ -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<Omit<CreateVendorGatewayInput, 'actorId'>> & {
|
||||||
|
actorId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface VendorGatewaysRepository {
|
||||||
|
list(vendorId?: string): Promise<VendorGatewaySummary[]>;
|
||||||
|
get(gatewayId: string): Promise<VendorGatewaySummary>;
|
||||||
|
create(input: CreateVendorGatewayInput): Promise<VendorGatewaySummary>;
|
||||||
|
update(gatewayId: string, input: UpdateVendorGatewayInput): Promise<VendorGatewaySummary>;
|
||||||
|
setStatus(gatewayId: string, status: VendorGatewayStatus, actorId?: string): Promise<VendorGatewaySummary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<VendorGatewaySummary[]> {
|
||||||
|
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<VendorGatewaySummary> {
|
||||||
|
return this.toSummary(await this.findActiveOrThrow(gatewayIdValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateVendorGatewayInput): Promise<VendorGatewaySummary> {
|
||||||
|
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<VendorGatewaySummary> {
|
||||||
|
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<VendorGatewaySummary> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<VendorGatewaySummary[]> {
|
||||||
|
const vendorId = query.vendorId === undefined ? undefined : this.limitedString(query.vendorId, 'vendorId', 32);
|
||||||
|
return this.gateways.list(vendorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(gatewayId: string): Promise<VendorGatewaySummary> {
|
||||||
|
return this.gateways.get(gatewayId);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(body: VendorGatewayDto, actorId?: string): Promise<VendorGatewaySummary> {
|
||||||
|
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<VendorGatewaySummary> {
|
||||||
|
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<VendorGatewaySummary> {
|
||||||
|
return this.gateways.setStatus(gatewayId, 'ENABLED', actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
disable(gatewayId: string, actorId?: string): Promise<VendorGatewaySummary> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+259
@@ -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<string, CurrentUser>();
|
||||||
|
|
||||||
|
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
||||||
|
return this.users.get(userId) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryAuditRepository implements AuditRepository {
|
||||||
|
entries: AuditEntryInput[] = [];
|
||||||
|
|
||||||
|
async write(input: AuditEntryInput): Promise<void> {
|
||||||
|
this.entries.push(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryVendorsRepository implements VendorsRepository {
|
||||||
|
private readonly vendors = new Map<string, VendorSummary>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.vendors.set(
|
||||||
|
'ven_seed',
|
||||||
|
this.summary({
|
||||||
|
id: 'ven_seed',
|
||||||
|
name: 'Seed Vendor',
|
||||||
|
creditLimit: '200.000000',
|
||||||
|
gatewayCount: 2
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<VendorSummary[]> {
|
||||||
|
return [...this.vendors.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(vendorId: string): Promise<VendorSummary> {
|
||||||
|
return this.vendors.get(vendorId) ?? this.summary({ id: vendorId, name: 'Missing Vendor' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateVendorInput): Promise<VendorSummary> {
|
||||||
|
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<VendorSummary> {
|
||||||
|
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<VendorSummary> {
|
||||||
|
return this.update(vendorId, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
async softDelete(vendorId: string): Promise<VendorSummary> {
|
||||||
|
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<NestFastifyApplication>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+17
@@ -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 {}
|
||||||
+217
@@ -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<VendorSummary[]>;
|
||||||
|
get(vendorId: string): Promise<VendorSummary>;
|
||||||
|
create(input: CreateVendorInput): Promise<VendorSummary>;
|
||||||
|
update(vendorId: string, input: UpdateVendorInput): Promise<VendorSummary>;
|
||||||
|
setStatus(vendorId: string, status: VendorStatus, actorId?: string): Promise<VendorSummary>;
|
||||||
|
softDelete(vendorId: string, actorId?: string): Promise<VendorSummary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<VendorSummary[]> {
|
||||||
|
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<VendorSummary> {
|
||||||
|
return this.toSummary(await this.findActiveOrThrow(vendorIdValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateVendorInput): Promise<VendorSummary> {
|
||||||
|
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<VendorSummary> {
|
||||||
|
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<VendorSummary> {
|
||||||
|
return this.update(vendorIdValue, { status, actorId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async softDelete(vendorIdValue: string, actorId?: string): Promise<VendorSummary> {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+135
@@ -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<VendorSummary[]> {
|
||||||
|
return this.vendors.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
get(vendorId: string): Promise<VendorSummary> {
|
||||||
|
return this.vendors.get(vendorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(body: CreateVendorDto, actorId?: string): Promise<VendorSummary> {
|
||||||
|
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<VendorSummary> {
|
||||||
|
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<VendorSummary> {
|
||||||
|
return this.vendors.setStatus(vendorId, 'ENABLED', actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
disable(vendorId: string, actorId?: string): Promise<VendorSummary> {
|
||||||
|
return this.vendors.setStatus(vendorId, 'DISABLED', actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(vendorId: string, actorId?: string): Promise<VendorSummary> {
|
||||||
|
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')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user