Initial LisgloSIPS V2 implementation

This commit is contained in:
hectorzhao
2026-06-22 10:56:38 +08:00
commit 5fa1bd35e9
303 changed files with 35644 additions and 0 deletions
+16
View File
@@ -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
+19
View File
@@ -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
View File
@@ -0,0 +1,12 @@
.codex-private/
.codex-runtime/
node_modules/
dist/
**/dist/
*.tsbuildinfo
coverage/
.env
.env.*
!.env.example
*.log
cookies.txt
+568
View File
@@ -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 preflightA 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` preflightA `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>` 切回上一 releaseA 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` bytesRecording Worker 恢复后补搬运录音;CDR Worker 恢复后 Redis `pending=0``lag=0` 并入库;MySQL 故障修复后复测 Call-ID `s28-1782093477852-a8bqqohb@lisglosips-t` 自动入库 raw/rated CDR,无新增 deadletterAPI `/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 且 readyA/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 媒体地址发送 RTPA 的 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 activeT `/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` activeB `lisglosips@api``lisglosips@cdr-worker``lisglosips@recording-worker``mysql``redis-server``heplify-server` activeT `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` activeAPI `/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/enableA `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 回到 ARTPEngine session 最终回到 0B 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` 均 activeT 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 认证 INVITECall-ID 为 `s21-invite-1782049439554-088rul75@lisglosips-t`A 返回 `SIP/2.0 503 Config Missing`;A OpenSIPS 日志确认热路径拒绝原因为 `CONFIG_MISSING`,并成功向 B Redis Stream 写入失败 CDRB 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/WorkerA 当前 `/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_hostsA 建立专用低权限用户 `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 文件并运行 finalizerB 使用 `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 为 0T 发 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 文件后重新 buildRedis 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 配置和数据库 dumpOpenSIPS 配置语法检查通过,`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/S28T 的 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` 观察 HEPB `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` 均 activeS20 新增服务均 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 sessionsHEP 绑定修正后 A 侧抓包看到发往 B `9060/udp` 的 HEPB 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/enabledOpenSIPS 配置语法检查通过;监听端口为 `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 热路径和指标留给 S20RTPEngine、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` OutboxConfig 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` 生成 HA1API 响应只返回 `hasSipCredential` 不返回明文密码或 HA1;支持 CPS、并发上限、禁呼时段、编码优先级、主叫/被叫号码前缀转换、计费周期秒数和周期费率,响应补充按分钟折算的 `minuteRate`;写操作绑定 `vendor_gateways.manage` 和审计动作,读操作绑定 `vendor_gateways.view`;落地网关创建、更新、启停写入 `vendor_gateway_config` OutboxConfig 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`,不直接双写 RedisConfig 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 HA1API 响应只返回 `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 dateB 临时库客户创建、状态更新、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 dateB 残留检查确认无 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 unitA 重启后系统 `running` 且 0 failed unitB 的 PostgreSQL、MySQL、Redis、Nginx、Heplify、Homer、Grafana、Prometheus 和 4 个 exporter 全部 activePrometheus 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` dashboardHomer HTTPS 返回 200Homer/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 unit11 个仓库配置与服务器文件 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-requiredchrony Stratum 3/Leap NormalNFS automount 重启后可访问;fail2ban sshd jail activenftables 重启后默认拒绝,开发机与 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 ms1200 字节 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 均 activeOpenSIPS 主配置哈希与盘点时一致;报告完整性及敏感信息扫描通过。
- 回滚方式: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 安全接入与凭据迁移`
+836
View File
@@ -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"}'
```
+766
View File
@@ -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 DBCDR 表、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
+30
View File
@@ -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"
}
}
+41
View File
@@ -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');
});
});
+35
View File
@@ -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();
+74
View File
@@ -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']
};
}
}
+23
View File
@@ -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';
}
}
+132
View File
@@ -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);
});
});
+19
View File
@@ -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 });
});
});
+184
View File
@@ -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.'
});
}
}
+41
View File
@@ -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');
+42
View File
@@ -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('; ');
}
@@ -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 {}
@@ -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);
});
});
+24
View File
@@ -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
};
}
}
+119
View File
@@ -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';
}
}
+58
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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