From 7057fd3c421d9ce0d060b030a1b5d9d4df7a50a6 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Wed, 24 Jun 2026 11:39:32 +0800 Subject: [PATCH] feat: add number library routing and cdr location support --- IMPLEMENTATION_STATUS.md | 247 ++- SOFTSWITCH_PLATFORM_DESIGN_V2.md | 296 +++- .../active-calls/active-calls.controller.ts | 24 + .../active-calls/active-calls.module.ts | 10 + .../active-calls/active-calls.service.spec.ts | 103 ++ .../active-calls/active-calls.service.ts | 282 ++++ .../active-calls/opensips-mi.client.ts | 108 ++ apps/api/src/modules/app.module.ts | 6 + apps/api/src/modules/auth/auth.controller.ts | 47 +- apps/api/src/modules/auth/auth.e2e.spec.ts | 16 +- apps/api/src/modules/auth/auth.module.ts | 4 +- .../api/src/modules/auth/auth.service.spec.ts | 14 +- apps/api/src/modules/auth/auth.service.ts | 65 +- apps/api/src/modules/auth/captcha.service.ts | 102 ++ apps/api/src/modules/cdrs/cdrs.controller.ts | 22 + apps/api/src/modules/cdrs/cdrs.module.ts | 17 + apps/api/src/modules/cdrs/cdrs.repository.ts | 167 ++ .../api/src/modules/cdrs/cdrs.service.spec.ts | 77 + apps/api/src/modules/cdrs/cdrs.service.ts | 55 + .../customer-gateways.controller.ts | 9 +- .../customer-gateways.e2e.spec.ts | 9 + .../customer-gateways.repository.ts | 30 + .../customer-gateways.service.ts | 4 + .../landing-line-groups.e2e.spec.ts | 1 + .../landing-line-groups.repository.ts | 43 +- .../number-library.controller.ts | 63 + .../number-library/number-library.e2e.spec.ts | 240 +++ .../number-library/number-library.module.ts | 17 + .../number-library.repository.ts | 310 ++++ .../number-library/number-library.service.ts | 186 +++ .../modules/recharges/recharges.repository.ts | 5 +- .../api/src/modules/roles/roles.controller.ts | 9 +- .../api/src/modules/roles/roles.repository.ts | 49 + apps/api/src/modules/roles/roles.service.ts | 4 + .../security/security-rbac-audit.e2e.spec.ts | 8 + .../modules/users-roles-delete.e2e.spec.ts | 289 ++++ .../api/src/modules/users/users.controller.ts | 9 +- .../api/src/modules/users/users.repository.ts | 68 +- apps/api/src/modules/users/users.service.ts | 4 + .../vendor-gateways.controller.ts | 9 +- .../vendor-gateways.e2e.spec.ts | 10 + .../vendor-gateways.repository.ts | 35 +- .../vendor-gateways.service.ts | 4 + apps/api/src/shared/config.ts | 28 +- apps/web/src/App.jsx | 1372 +++++++++++++++-- apps/web/src/api.js | 89 +- apps/web/src/styles.css | 183 +++ apps/worker-cdr/src/rating.spec.ts | 17 +- apps/worker-cdr/src/rating.ts | 56 +- .../src/publisher.spec.ts | 159 ++ apps/worker-config-publisher/src/publisher.ts | 131 +- infra/server-a/s28/lisglosips_hotpath.lua | 256 +++ infra/server-a/s28/opensips.cfg | 66 +- infra/server-a/s32/lisglosips-call-control | 55 + .../nginx/lisglosips-security-headers.conf | 3 +- .../s40/lisglosips-s40-virtual-calls.py | 388 +++++ packages/auth/src/index.ts | 4 + packages/database/src/schema-contract.spec.ts | 18 +- packages/redis/src/cdr-stream.spec.ts | 41 + packages/redis/src/cdr-stream.ts | 20 + .../migration.sql | 139 ++ prisma/schema.prisma | 812 ++++++---- prisma/seed.ts | 13 +- 63 files changed, 6361 insertions(+), 566 deletions(-) create mode 100644 apps/api/src/modules/active-calls/active-calls.controller.ts create mode 100644 apps/api/src/modules/active-calls/active-calls.module.ts create mode 100644 apps/api/src/modules/active-calls/active-calls.service.spec.ts create mode 100644 apps/api/src/modules/active-calls/active-calls.service.ts create mode 100644 apps/api/src/modules/active-calls/opensips-mi.client.ts create mode 100644 apps/api/src/modules/auth/captcha.service.ts create mode 100644 apps/api/src/modules/cdrs/cdrs.controller.ts create mode 100644 apps/api/src/modules/cdrs/cdrs.module.ts create mode 100644 apps/api/src/modules/cdrs/cdrs.repository.ts create mode 100644 apps/api/src/modules/cdrs/cdrs.service.spec.ts create mode 100644 apps/api/src/modules/cdrs/cdrs.service.ts create mode 100644 apps/api/src/modules/number-library/number-library.controller.ts create mode 100644 apps/api/src/modules/number-library/number-library.e2e.spec.ts create mode 100644 apps/api/src/modules/number-library/number-library.module.ts create mode 100644 apps/api/src/modules/number-library/number-library.repository.ts create mode 100644 apps/api/src/modules/number-library/number-library.service.ts create mode 100644 apps/api/src/modules/users-roles-delete.e2e.spec.ts create mode 100644 apps/worker-config-publisher/src/publisher.spec.ts create mode 100644 infra/server-a/s28/lisglosips_hotpath.lua create mode 100644 infra/server-a/s32/lisglosips-call-control create mode 100644 infra/server-t/s40/lisglosips-s40-virtual-calls.py create mode 100644 prisma/migrations/20260624095000_number_library/migration.sql diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md index 07a5e9d..b40d3e8 100644 --- a/IMPLEMENTATION_STATUS.md +++ b/IMPLEMENTATION_STATUS.md @@ -4,10 +4,10 @@ ## 当前状态 -- 当前任务:无,S00-S30 本地 V2 闭环已完成 -- 总体状态:S30 已完成;本地 KVM 开发环境 V2 闭环冻结在 `s28-v2-20260621220924`,阿里云迁移前需按 S30 Runbook 重新演练 -- 最后更新:2026-06-22 10:35 +08:00 -- 当前阻塞:无。遗留风险为 Redis 恢复后 OpenSIPS 热路径可能需要重启 `opensips` 才恢复,已纳入 S30 Runbook,根因级修复需后续单独处理。 +- 当前任务:需求修改/二期变更:号码库第 6、7 步本地实现并提交 Git +- 总体状态:S30 已完成;本地 KVM 开发环境 V2 闭环原冻结 release 为 `s28-v2-20260621220924`,当前 B release 仍为 `s33-active-calls-20260623094000`,已在该 release 上覆盖发布 s41 用户/角色删除小改;A `/etc/opensips/opensips.cfg` 已将 `fr_inv_timeout` 从 30 临时调为 95 并重启;T 已新增 `/opt/lisglosips-s40/lisglosips-s40-virtual-calls.py` 并启动 10 路虚拟呼叫场景;号码库第 1-7 步已完成本地代码基线,尚未发布到 B/A、尚未执行 MySQL migration、尚未导入真实 80 万号段;阿里云迁移前需按 S30 Runbook 重新演练 +- 最后更新:2026-06-24 11:41 +08:00 +- 当前阻塞:无。遗留风险为 Redis 恢复后 OpenSIPS 热路径可能需要重启 `opensips` 才恢复,已纳入 S30 Runbook;本次发现并补齐 B `config-publisher.env`,当前 `lisglosips@config-publisher` 已 active。 - 当前环境:A/B/T 为本地 KVM 开发服务器,通过 Tailscale 联调;开发完成后再迁移阿里云 - 私密访问资料:`.codex-private/SERVER_ACCESS.md`,只在实际连接时读取,禁止回显 @@ -89,6 +89,245 @@ ## 交接记录 +### 2026-06-24 11:41 - 需求修改/二期变更:号码库第 6、7 步本地实现 + +- 状态:已完成本地代码;准备提交 Git;未发布到 B/A,未执行数据库迁移,未重启服务 +- 操作服务器:本地 +- 完成内容:CDR Stream 契约新增可选号码识别快照字段,并保持旧 Stream/pending 消息兼容;CDR Worker 入库 `raw_cdrs` 时写入 `calleeCityCode`、`calleeCityName`、`calleeProvinceName`、`calleeOperator`、`calleeNumberType`。新增 `CdrsModule`,提供 `GET /api/v2/cdrs` 与 `GET /api/v2/cdrs/:id`,复用 `cdr.view` 权限,支持主叫、被叫、客户网关、落地网关、地级市代码、运营商分页筛选,并返回客户/网关/线路组名称与 rated CDR 费用。Web 话单中心改为读取真实 CDR API,列表和详情展示地级市、运营商、号码类型、SIP 状态码、客户费用、成本和毛利。 +- 修改文件:`packages/redis/src/cdr-stream.ts`、`packages/redis/src/cdr-stream.spec.ts`、`apps/worker-cdr/src/rating.ts`、`apps/worker-cdr/src/rating.spec.ts`、`apps/api/src/modules/app.module.ts`、`apps/api/src/modules/cdrs/cdrs.module.ts`、`apps/api/src/modules/cdrs/cdrs.controller.ts`、`apps/api/src/modules/cdrs/cdrs.service.ts`、`apps/api/src/modules/cdrs/cdrs.repository.ts`、`apps/api/src/modules/cdrs/cdrs.service.spec.ts`、`apps/web/src/api.js`、`apps/web/src/App.jsx`、`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新软件;继续使用 pnpm `10.33.0`、Prisma `6.19.0`。 +- 执行的关键命令:`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 vitest run packages/redis/src/cdr-stream.spec.ts apps/worker-cdr/src/rating.spec.ts apps/api/src/modules/cdrs/cdrs.service.spec.ts`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 test`、`corepack pnpm@10.33.0 build` +- 验证结果:typecheck 通过;目标测试 3 个文件、13 条测试通过;lint 通过;workspace build 通过;第一次将全量 test 与 build 并行执行时 3 个依赖本地服务的 e2e `beforeAll` 因资源争用超时,随后单独重跑 `corepack pnpm@10.33.0 test` 通过,25 个测试文件、85 条测试全部通过。 +- 回滚方式:恢复上述文件即可;本次未连接服务器、未发布 B/A、未写 Redis、未执行 MySQL migration、未重启 OpenSIPS,因此无远端回滚项。后续若发布,B 发布前需备份 MySQL 和当前 release;A 发布仍需先备份 OpenSIPS 配置/Lua 并执行 `opensips -C -f`。 +- 未解决问题:真实 80 万号段文件导入、Redis 发布容量评估、落地网关屏蔽地区 API/Web 管理入口、A/B/T 地区屏蔽端到端验收仍未实施;当前 CDR API 已能显示新字段,但历史话单在迁移前/旧消息场景下会显示 `UNKNOWN` 或 `-`。 +- 下一任务:提交 Git 后,建议优先补落地网关屏蔽地区管理入口和大批量号码库导入 Runbook,再安排 B/A 发布与 A/B/T 验收。 + +### 2026-06-24 11:31 - 需求修改/二期变更:号码库第 4、5 步本地实现 + +- 状态:已完成本地代码;未发布到 B/A,未执行数据库迁移,未重启服务 +- 操作服务器:本地 +- 完成内容:Config Publisher 快照新增地级市、手机 7 位号段、固话区号、运营商前缀和落地网关屏蔽地区;Redis 写入版本化 `geo_city`、`phone_segment`、`area_code`、`carrier_prefix` key,以及 `vendor_gateway:{id}:blocked_city_codes`/`blocked_province_codes` set。号码库导入新增 `number_library_config` outbox,导入后可触发发布器生成新快照。S28 OpenSIPS Lua 热路径新增被叫号码规范化、手机 7 位号段解析、固话区号解析、运营商前缀解析、落地网关城市/省份屏蔽跳过逻辑;全部可用网关都被地区屏蔽时返回 `NO_VENDOR_ROUTE_REGION_BLOCKED`。S28 `opensips.cfg` 更新 Lua SHA1、读取追加返回字段,并在成功/失败 CDR Stream 中携带 `callee_city_code`、`callee_city_name`、`callee_province_name`、`callee_operator`、`callee_number_type`。 +- 修改文件:`apps/worker-config-publisher/src/publisher.ts`、`apps/worker-config-publisher/src/publisher.spec.ts`、`apps/api/src/modules/number-library/number-library.repository.ts`、`infra/server-a/s28/lisglosips_hotpath.lua`、`infra/server-a/s28/opensips.cfg`、`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新软件;继续使用 pnpm `10.33.0`、Prisma `6.19.0`。 +- 执行的关键命令:`corepack pnpm@10.33.0 vitest run apps/worker-config-publisher/src/publisher.spec.ts apps/api/src/modules/number-library/number-library.e2e.spec.ts`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 test`、`corepack pnpm@10.33.0 build`、`Get-FileHash -Algorithm SHA1 infra/server-a/s28/lisglosips_hotpath.lua` +- 验证结果:目标测试 2 个文件、4 条测试通过;typecheck 通过;lint 通过;全量 Vitest 24 个测试文件、82 条测试通过;workspace build 通过;本机无 `lua`、无 `opensips` 命令,因此未在本地执行 Lua 解释器语法检查和 `opensips -C`。 +- 回滚方式:恢复上述本地文件即可;本次未连接服务器、未发布 B/A、未写 Redis、未执行 MySQL migration、未重启 OpenSIPS,因此无远端回滚项。后续若发布:B 需先备份 MySQL/Redis 和当前 release,回滚 Redis 可切回 `cfg:previous_version`;A 需先备份 `/etc/opensips/opensips.cfg` 与 `/etc/opensips/lisglosips_hotpath.lua`,回滚时恢复备份、执行 `opensips -C -f /etc/opensips/opensips.cfg` 通过后重启 OpenSIPS。 +- 未解决问题:第 6 步 CDR Worker 尚未把 `callee_city_code`、`callee_city_name`、`callee_province_name`、`callee_operator`、`callee_number_type` 入库到 `raw_cdrs` 新字段;落地网关屏蔽地区仍缺少 Web/API 管理入口;真实 80 万号段文件导入、Redis 发布容量评估、A/B/T 端到端地区屏蔽验收仍未执行。 +- 下一任务:建议第 6 步改造 CDR Stream/Worker 入库号码识别字段,并补落地网关屏蔽地区的 API/Web 管理入口后,再安排 B/A 发布和 A/B/T 验收。 + +### 2026-06-24 11:15 - 需求修改/二期变更:号码库第 3 步 Web 本地实现 + +- 状态:已完成本地代码;未发布到 B,未执行数据库迁移 +- 操作服务器:本地 +- 完成内容:新增 Web “号码库”运营菜单,页面包含地级市字典、手机号码库、城市区号、运营商号码段规则四个 Tab。每个 Tab 接入 `/number-library/*` API,支持服务端查询入口、筛选、刷新、总数显示和批量 JSON 导入弹窗;导入成功后刷新当前 Tab。同步更新 V2 设计文档,将号码库菜单从三个 Tab 调整为四个 Tab,并补充第 3 步本地实施状态。 +- 修改文件:`apps/web/src/api.js`、`apps/web/src/App.jsx`、`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新软件;继续使用 pnpm `10.33.0`。 +- 执行的关键命令:`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`、`corepack pnpm@10.33.0 test` +- 验证结果:typecheck 通过;lint 通过;workspace build 通过;第一次将 test 与 build 并行执行时 5 个依赖本地服务的 e2e `beforeAll` 因资源争用超时,随后单独重跑 `corepack pnpm@10.33.0 test` 通过,23 个测试文件、81 条测试全部通过。 +- 回滚方式:恢复 `apps/web/src/api.js`、`apps/web/src/App.jsx`、`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md` 本次改动即可;本次未连接服务器、未发布 B、未执行 MySQL migration、未写入 Redis、未重启服务,因此无远端回滚项。 +- 未解决问题:号码库前端当前使用批量 JSON 导入,只适合小批验证和接口联调;真实 80 万号段仍需文件导入/后台任务/进度与失败明细。号码解析服务、Config Publisher Redis 快照、OpenSIPS/Lua 屏蔽地区跳转、CDR Worker 新字段入库、话单中心展示与 A/B/T 端到端验收仍未实施。 +- 下一任务:建议第 4 步实现 Config Publisher/Redis 热路径快照前,先补大批量导入任务 Runbook 和文件导入接口,避免把 80 万号段通过普通 JSON 表单导入。 + +### 2026-06-24 11:07 - 需求修改/二期变更:号码库第 1、2 步本地实现 + +- 状态:已完成本地代码;未发布到 B,未执行数据库迁移 +- 操作服务器:本地 +- 完成内容:完成号码库第 1、2 步本地基线。Prisma Schema 和迁移新增 `geo_cities`、`phone_number_segments`、`phone_area_codes`、`carrier_prefix_rules`、`vendor_gateway_blocked_regions`,并在 `raw_cdrs` 预留被叫地级市、省份、运营商和号码类型快照字段。新增 `NumberLibraryModule`,提供城市、手机 7 位号段、固话区号、运营商前缀规则的分页查询与批量 upsert 导入 API,单批限制 1000 条,接入 RBAC 和审计。新增权限 `number_library.view`、`number_library.manage` 并写入 seed;更新 schema contract 和号码库 e2e 测试。 +- 修改文件:`prisma/schema.prisma`、`prisma/migrations/20260624095000_number_library/migration.sql`、`apps/api/src/modules/app.module.ts`、`apps/api/src/modules/number-library/number-library.module.ts`、`apps/api/src/modules/number-library/number-library.controller.ts`、`apps/api/src/modules/number-library/number-library.service.ts`、`apps/api/src/modules/number-library/number-library.repository.ts`、`apps/api/src/modules/number-library/number-library.e2e.spec.ts`、`packages/auth/src/index.ts`、`packages/database/src/schema-contract.spec.ts`、`prisma/seed.ts`、`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新软件;继续使用 Prisma `6.19.0`、pnpm `10.33.0`。 +- 执行的关键命令:`corepack pnpm@10.33.0 prisma format`、`corepack pnpm@10.33.0 prisma:validate`、`corepack pnpm@10.33.0 prisma:generate`、`corepack pnpm@10.33.0 vitest run apps/api/src/modules/number-library/number-library.e2e.spec.ts packages/database/src/schema-contract.spec.ts`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 test`、`corepack pnpm@10.33.0 build` +- 验证结果:Prisma format/validate/generate 通过;号码库 e2e 与 schema contract 共 8 条测试通过;typecheck 通过;lint 通过;全量 Vitest 23 个测试文件、81 条测试通过;workspace build 通过。 +- 回滚方式:恢复上述本地文件改动即可;因为未连接服务器、未发布 B、未执行 MySQL migration、未导入真实数据,所以无远端回滚项。若后续发布到 B,执行迁移前必须先备份 B MySQL;回滚需恢复迁移前备份或执行经验证的反向迁移,并清理新增权限/seed 变更。 +- 未解决问题:真实 80 万号段文件导入、导入任务进度/失败明细、号码解析服务、Config Publisher Redis 发布、OpenSIPS/Lua 屏蔽地区跳转、CDR Worker 入库新字段、话单中心展示与筛选、A/B/T 端到端验收仍未实施。 +- 下一任务:建议第 3 步 Web “号码库”菜单和三个 Tab;或先实现第 4 步 Config Publisher/Redis 快照结构前的导入任务 Runbook。 + +### 2026-06-24 09:48 - 需求修改/二期变更:号码库与屏蔽地区路由需求评估 + +- 状态:已完成 +- 操作服务器:本地 +- 完成内容:评估新增“号码库”菜单及手机号码库、城市区号、运营商号码段规则三个 Tab 的影响范围;明确需要新增地级市字典 `geo_cities`,用于手机 7 位号段、固话区号、落地网关屏蔽地区和 CDR 快照统一引用;明确每通话单需要保存被叫地级市、省份、运营商和号码类型快照;明确落地网关屏蔽地区应在 OpenSIPS/Lua 热路径中跳过当前网关并尝试同一线路组下一落地网关。已将需求、数据模型建议、Redis 热路径结构、CDR 字段、路由规则、风险和 8 步实施拆分写入 `SOFTSWITCH_PLATFORM_DESIGN_V2.md`,文档版本升级为 V2.2。 +- 修改文件:`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新软件。 +- 执行的关键命令:`rg` 检查现有 Schema、CDR Stream、OpenSIPS Lua 和 Web 中的地区/运营商/屏蔽地区相关实现;文档更新后用 `rg` 校验 V2.2、号码库、`geo_cities`、`phone_number_segments`、`carrier_prefix_rules`、`vendor_gateway_blocked_regions`、`NO_VENDOR_ROUTE_REGION_BLOCKED` 等关键条目。 +- 验证结果:本次为需求评估和 Markdown 文档更新,未运行 typecheck/lint/test/build;未连接服务器,未做数据库迁移、服务重启或数据导入。 +- 回滚方式:恢复 `SOFTSWITCH_PLATFORM_DESIGN_V2.md` 和 `IMPLEMENTATION_STATUS.md` 本次文档改动即可;不涉及 B release、MySQL、Redis、OpenSIPS、RTPEngine、Worker 或 Web 发布回滚。 +- 未解决问题:真实实施前仍需确认号码库数据来源、更新频率、导入格式、行政区划编码口径、运营商枚举口径、省级屏蔽是否需要、以及 80 万号段导入对 MySQL/Redis 内存和发布耗时的影响。 +- 下一任务:如确认实施,建议从“数据建模与迁移 + 导入 Runbook”单独开工,不要与 OpenSIPS 热路径改造混在同一次发布。 + +### 2026-06-24 09:31 - 需求修改/二期变更:用户与角色删除 + +- 状态:已完成 +- 操作服务器:本地、B +- 完成内容:用户管理新增红色“删除”按钮和自研确认弹窗,确认后调用 `DELETE /api/v2/users/:id`;角色与权限新增红色“删除”按钮和自研确认弹窗,内置角色前端禁用删除,后端同时保护内置角色并在角色仍有关联用户时拒绝删除。后端用户删除为软删除:禁用账号、写入 `deletedAt`、清理用户角色关联以释放角色用户数;角色删除为软删除:禁用角色并写入 `deletedAt`。两个删除接口均接入 RBAC `users.manage`/`roles.manage` 和审计日志。未改 Prisma Schema,未执行数据库迁移。 +- 修改文件:`apps/api/src/modules/users/users.controller.ts`、`apps/api/src/modules/users/users.service.ts`、`apps/api/src/modules/users/users.repository.ts`、`apps/api/src/modules/roles/roles.controller.ts`、`apps/api/src/modules/roles/roles.service.ts`、`apps/api/src/modules/roles/roles.repository.ts`、`apps/api/src/modules/security/security-rbac-audit.e2e.spec.ts`、`apps/api/src/modules/users-roles-delete.e2e.spec.ts`、`apps/web/src/App.jsx`、`apps/web/src/api.js`、`IMPLEMENTATION_STATUS.md`;B 当前 release `/opt/lisglosips/releases/s33-active-calls-20260623094000` 中已覆盖 API/Web dist。 +- 安装软件及固定版本:未安装新软件。 +- 执行的关键命令:本地 `corepack pnpm@10.33.0 vitest run apps/api/src/modules/users-roles-delete.e2e.spec.ts`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 test`、`corepack pnpm@10.33.0 build`;B `tar --no-same-owner -xzf` 覆盖当前 release API/Web dist、`systemctl restart lisglosips@api`、`nginx -t`、`systemctl reload nginx`、`curl http://127.0.0.1:3000/api/v2/health/ready`。 +- 验证结果:新增用户/角色删除 E2E 3 条通过;typecheck 通过;lint 通过;全量 Vitest 22 个测试文件、77 条测试通过;workspace build 通过。B `lisglosips@api` 与 `nginx` active,API ready 返回 `config/database/redis` 均 ok;服务日志确认已映射 `DELETE /api/v2/users/:id` 与 `DELETE /api/v2/roles/:id`;Web 首页 `https://100.90.90.91/` 返回 200 并引用新构建资源。 +- 回滚方式:B 已备份覆盖前 dist 到 `/var/backups/lisglosips-s41-user-role-delete/20260624T092950/api-dist` 与 `/var/backups/lisglosips-s41-user-role-delete/20260624T092950/web-dist`;如需回滚,将两目录恢复到 `/opt/lisglosips/releases/s33-active-calls-20260623094000/apps/api/dist` 与 `/opt/lisglosips/releases/s33-active-calls-20260623094000/apps/web/dist`,重启 `lisglosips@api` 并执行 `nginx -t && systemctl reload nginx`。本次无数据库 schema migration;若已通过新功能误删用户/角色,删除为软删除,需要在维护窗口受控恢复对应 `deleted_at/status`,用户还需按审计和业务确认恢复 `user_roles` 关联。 +- 未解决问题:用户管理、角色与权限的新增/编辑/启停仍沿用旧的前端本地态实现,未纳入本次需求重构;未做 A/B/T SIP 端到端,因为本次只涉及 B API/Web 管理面,不影响 Redis 热路径、OpenSIPS、RTPEngine、Worker 或三机呼叫链路。 +- 下一任务:等待下一个二期需求。 + +### 2026-06-23 22:07 - 需求修改/二期变更:S30 后需求写入 V2 设计文档 + +- 状态:已完成 +- 操作服务器:本地 +- 完成内容:将 S30 以后用户新增需求整理进 `SOFTSWITCH_PLATFORM_DESIGN_V2.md`,文档版本从 V2.0 调整为 V2.1,新增“25.5 S30 后二期需求变更”章节;同步更新 V2 范围表,纳入当前通话、受控删除、网关/线路组计数、人工充值记录语义、待设计菜单隐藏等内容;补充登录验证码、当前通话和强制挂断、删除/启停确认、充值与计费流水语义、动态路由和 CDR 补齐、前端交互、10 路虚拟呼叫测试工具、二期回归要求;修正 S27 描述中“待设计菜单继续保留标识”的旧表述。 +- 修改文件:`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新软件。 +- 执行关键命令:`rg` 检查 V2 文档关键章节和条目;`git diff -- SOFTSWITCH_PLATFORM_DESIGN_V2.md --stat` 复核文档差异。 +- 验证结果:文档已包含 V2.1 状态、当前通话实施范围、待设计菜单隐藏说明、“25.5 S30 后二期需求变更”及 25.5.1-25.5.8 子章节;本次为纯 Markdown 文档更新,未运行 typecheck/lint/build。 +- 回滚方式:恢复 `SOFTSWITCH_PLATFORM_DESIGN_V2.md` 和 `IMPLEMENTATION_STATUS.md` 本次文档改动即可;不涉及服务器、数据库、Redis、OpenSIPS、RTPEngine 或 Worker 回滚。 +- 未解决问题:S40 10 路虚拟呼叫完整场景此前仍在 T 后台运行,本文档更新未等待其全程结束;若需确认最终结果,应继续查看 `/tmp/lisglosips-s40-virtual-calls-20260623T215430.log`。 + +### 2026-06-23 21:51 - 需求修改/二期变更:10 路虚拟呼叫测试工具与当前通话自动刷新 + +- 状态:已完成工具部署,完整 10 路场景已启动并运行中 +- 操作服务器:A、B、T、本地 +- 完成内容:当前通话页面新增默认开启的 5 秒自动刷新开关,保留手动刷新;T 新增 `/opt/lisglosips-s40/lisglosips-s40-virtual-calls.py`,可按 15 秒间隔发起 10 路虚拟呼叫,前 5 路先回 `180 Ringing`、45 秒后 `200 OK`、保持 600 秒后 BYE,后 5 路先回 `180 Ringing`、70 秒后 `480 Temporarily Unavailable`。脚本默认使用当前已打通策略的主叫 `s36-1001`、被叫 `13800136036`,并按 Call-ID 序号区分接通/不接通场景。首次完整场景暴露 A `fr_inv_timeout=30` 会在 45 秒接通前返回 408,已将 A OpenSIPS `fr_inv_timeout` 临时调为 95 并重启后重新启动场景。 +- 修改文件:`apps/web/src/App.jsx`、`infra/server-t/s40/lisglosips-s40-virtual-calls.py`、`infra/server-a/s28/opensips.cfg`、`IMPLEMENTATION_STATUS.md`;B 当前 release `/opt/lisglosips/releases/s33-active-calls-20260623094000` 中已覆盖 `apps/web/dist`;T 已部署 `/opt/lisglosips-s40`;A 已就地修改 `/etc/opensips/opensips.cfg`。 +- 安装软件及固定版本:未安装新软件;继续使用 Node.js `v22.22.2`、pnpm `10.33.0`、Vite `5.4.21`、T 既有 Python 3.12。 +- 执行关键命令:本地 `corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`;B `tar -xzf` 覆盖 web dist、`nginx -t`、`systemctl reload nginx`、`curl -sk https://127.0.0.1/`;T `python3 -m py_compile /opt/lisglosips-s40/lisglosips-s40-virtual-calls.py`、短烟测 `--total 1 --answer-ring 2 --hold 3`、后台启动完整场景;A `opensips -C -f /etc/opensips/opensips.cfg`、`systemctl restart opensips`。 +- 验证结果:本地 typecheck/lint/build 均通过;B nginx 配置测试通过且 active,首页 HTML 返回新资源 `/assets/index-DwIXfg91.js` 和 `/assets/index-TA3aiKvG.css`;T 脚本 Python 编译通过。短烟测先发现 `50620` 被旧 S28 UAS 占用且新号码无策略,已调整为临时停旧 UAS、固定主叫/被叫并按 Call-ID 分流;最终 1 路短烟测通过:收到 `100 Giving it a try`、`180 Ringing`、延迟后 `200 OK`、3 秒后 BYE 且 UAS 返回 `200 OK`。首次完整场景因 A `fr_inv_timeout=30` 导致 45 秒接通前被 A 返回 408,已停止并恢复旧 UAS;A 超时调为 95 后重新启动完整场景,PID `32120`,日志 `/tmp/lisglosips-s40-virtual-calls-20260623T215430.log`,已验证第 1、2 路均在 45 秒后收到 `200 OK` 并进入 `connected hold=600.0s`。 +- 回滚方式:B 前端回滚可从 `/var/backups/lisglosips-s40-active-calls-autorefresh/20260623T214444/web-dist` 恢复当前 release 的 `apps/web/dist`,再执行 `nginx -t && systemctl reload nginx`。A 超时回滚可从 `/var/backups/lisglosips-s40-fr-inv-timeout/20260623T215330/opensips.cfg` 恢复 `/etc/opensips/opensips.cfg`,再执行 `opensips -C -f /etc/opensips/opensips.cfg && systemctl restart opensips`。T 脚本回滚可删除 `/opt/lisglosips-s40`,或从 `/var/backups/lisglosips-s40-virtual-calls/20260623T214907/lisglosips-s40` 恢复上一版。若需提前停止完整场景:在 T 执行 `sudo pkill -f /opt/lisglosips-s40/lisglosips-s40-virtual-calls.py`,然后确认 `100.93.185.30:50620` 有旧 S28 UAS;若未恢复,执行 `sudo nohup runuser -u nobody -- /usr/bin/python3 /opt/lisglosips-s28/lisglosips-s28-sip.py uas --host 100.93.185.30 --port 50620 >/tmp/lisglosips-s28-uas.log 2>&1 &`。 +- 未解决问题:完整 10 路场景仍在运行中,尚未等待约 12 分钟全程结束;测试会产生真实本地开发环境呼叫事件和少量计费/失败 CDR,使用的是现有 S36 测试客户网关/路由。当前通话页面观察地址为 `https://100.90.90.91/`。 + +### 2026-06-23 18:38 - 需求修改/二期变更:话单详情弹窗布局重排 + +- 状态:已完成 +- 操作服务器:B、本地 +- 完成内容:话单中心“查看详情”抽屉内部从单列 KeyValue 堆叠调整为主被叫概览、通话摘要、链路信息、时间轴和录音/信令操作分区;新增专用 `cdr-detail` 系列样式,移动端自动切为单列;补充本地 `Icon` 的 `arrow` 图标用于主叫到被叫方向展示。 +- 修改文件:`apps/web/src/App.jsx`、`apps/web/src/styles.css`、`IMPLEMENTATION_STATUS.md`;B 当前 release `/opt/lisglosips/releases/s33-active-calls-20260623094000` 中已覆盖 `apps/web/dist`。 +- 安装软件及固定版本:未安装新软件;继续使用 Node.js `v22.22.2`、pnpm `10.33.0`、Vite `5.4.21`。 +- 执行关键命令:`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`、B `tar -xzf` 覆盖 web dist、`nginx -t`、`systemctl reload nginx`、`curl -sk https://127.0.0.1/`。 +- 验证结果:本地 typecheck/lint/build 均通过;B nginx 配置测试通过且 active;首页 HTML 返回新资源 `/assets/index-CYrIkAAk.js` 和 `/assets/index-TA3aiKvG.css`。 +- 回滚方式:从 B `/var/backups/lisglosips-s39-cdr-detail-layout/20260623T183751/web-dist` 恢复当前 release 的 `apps/web/dist`,再执行 `nginx -t && systemctl reload nginx`;本地恢复 `apps/web/src/App.jsx`、`apps/web/src/styles.css` 的本次改动。 +- 未解决问题:未做浏览器截图交互验证;本次为纯前端抽屉布局调整,不涉及 API、数据库、Redis 热路径、OpenSIPS、RTPEngine 或 Worker。 + +### 2026-06-23 18:29 - 需求修改/二期变更:列表列宽优化与隐藏待设计菜单 + +- 状态:已完成 +- 操作服务器:B、本地 +- 完成内容:增强 `SimpleTable` 支持可选列宽和单元格 class;客户管理、客户网关管理、充值记录、供应商管理、落地网关管理页面的 ID/名称类列已适当收窄,并对长文本使用省略号避免撑宽列表;左侧菜单渲染时过滤 `pending` 项,隐藏“费率与计费”“SIP 运维”“监控告警”“系统设置”等待设计菜单。 +- 修改文件:`apps/web/src/App.jsx`、`apps/web/src/styles.css`、`IMPLEMENTATION_STATUS.md`;B 当前 release `/opt/lisglosips/releases/s33-active-calls-20260623094000` 中已覆盖 `apps/web/dist`。 +- 安装软件及固定版本:未安装新软件;继续使用 Node.js `v22.22.2`、pnpm `10.33.0`、Vite `5.4.21`。 +- 执行关键命令:`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`、B `tar -xzf` 覆盖 web dist、`nginx -t`、`systemctl reload nginx`、`curl -sk https://127.0.0.1/`。 +- 验证结果:本地 typecheck/lint/build 均通过;B nginx 配置测试通过且 active;首页 HTML 返回新资源 `/assets/index-N60ySjGq.js` 和 `/assets/index-uhdBdVZH.css`。 +- 回滚方式:从 B `/var/backups/lisglosips-s38-web-ui-layout/20260623T182343/web-dist` 恢复当前 release 的 `apps/web/dist`,再执行 `nginx -t && systemctl reload nginx`;本地恢复 `apps/web/src/App.jsx`、`apps/web/src/styles.css` 的本次改动。 +- 未解决问题:未做浏览器截图交互验证;本次为纯前端布局/菜单渲染调整,不涉及 API、数据库、Redis 热路径、OpenSIPS、RTPEngine 或 Worker。 + +### 2026-06-23 17:47 - 需求修改/二期变更:自研确认弹窗与删除按钮警示色 + +- 状态:已完成 +- 操作服务器:B、本地 +- 完成内容:新增通用 `ConfirmDialog`,按现有 Modal/UI 风格替换原生 `window.confirm` 和旧确认弹窗;当前通话强制挂断、客户/供应商/客户网关/落地网关/落地线路组删除、客户网关/落地网关启停、策略删除、抽检规则删除、重置密码确认均使用自研弹窗;所有删除按钮改为 `danger` 红色警示态,包括表格删除、编辑抽屉中的删除行按钮以及线路组移除网关按钮。 +- 修改文件:`apps/web/src/App.jsx`、`apps/web/src/styles.css`、`IMPLEMENTATION_STATUS.md`;B 当前 release `/opt/lisglosips/releases/s33-active-calls-20260623094000` 中已覆盖 `apps/web/dist`。 +- 安装软件及固定版本:未安装新软件;继续使用 Node.js `v22.22.2`、pnpm `10.33.0`、Vite `5.4.21`。 +- 执行关键命令:`rg 'window\.confirm' apps/web/src/App.jsx`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`、B `tar -xzf` 覆盖 web dist、`nginx -t`、`systemctl reload nginx`、`curl -sk https://127.0.0.1/`。 +- 验证结果:本地 typecheck/lint/build 均通过;`rg 'window\.confirm' apps/web/src/App.jsx` 无匹配;B nginx 配置测试通过且 active;首页 HTML 返回新资源 `/assets/index-CWnw3J5O.js` 和 `/assets/index-BuNsq8w9.css`。 +- 回滚方式:从 B `/var/backups/lisglosips-s37-confirm-dialog-ui/20260623T094434/web-dist` 恢复当前 release 的 `apps/web/dist`,再执行 `nginx -t && systemctl reload nginx`;本地恢复 `apps/web/src/App.jsx`、`apps/web/src/styles.css` 的本次改动。 +- 未解决问题:未做浏览器交互截图验证;功能行为已通过构建和页面资源烟测。 + +### 2026-06-23 16:20 - 需求修改/二期变更:充值记录过滤、充值性能与新建链路通话测试 + +- 状态:已完成 +- 操作服务器:A、B、T、本地 +- 完成内容:客户话单消费不再写入 `customer_recharges`,CDR worker 仅写 raw/rated CDR 并扣客户余额;充值记录 API 过滤历史 `worker-cdr`/`cdr:`/`CDR_CHARGE:` 客户消费流水;Web 客户/供应商充值成功后改为局部更新余额与充值列表,不再触发全量 `refreshApi()`,避免供应商充值后长时间停留在“拉取API”。完整流程测试时发现 A 热路径仍硬编码成功 CDR 和落地网关,已新增 S28 Lua 动态选择线路组首个启用落地网关,并让 OpenSIPS 成功 CDR 从 dialog 变量写入动态 customer/gateway/policy/vendorGateway/lineGroup;补齐 B `/etc/lisglosips/config-publisher.env` 并启动 `lisglosips@config-publisher`;修复 CDR worker 对超大 `config_version` 写 MySQL INT 越界的问题。 +- 修改文件:`apps/worker-cdr/src/rating.ts`、`apps/worker-cdr/src/rating.spec.ts`、`apps/api/src/modules/recharges/recharges.repository.ts`、`apps/web/src/App.jsx`、`infra/server-a/s28/opensips.cfg`、`infra/server-a/s28/lisglosips_hotpath.lua`、`IMPLEMENTATION_STATUS.md`;B 当前 release 的 `apps/api/dist`、`apps/web/dist`、`apps/worker-cdr/dist` 已覆盖部署;A `/etc/opensips/opensips.cfg` 与 `/usr/local/lib/lisglosips/lisglosips_hotpath.lua` 已更新;B 新增 `/etc/lisglosips/config-publisher.env`。 +- 验证结果:本地 `corepack pnpm@10.33.0 typecheck` 通过;`corepack pnpm@10.33.0 exec vitest run apps/worker-cdr/src/rating.spec.ts apps/api/src/modules/recharges/recharges.e2e.spec.ts --maxWorkers=1` 通过;全量 `corepack pnpm@10.33.0 exec vitest run --maxWorkers=1` 21 个测试文件 74 条测试通过;最终 `lint` 与全 workspace `build` 通过。B API `/api/v2/health/ready` 正常;客户充值记录按测试客户查询仅 1 条人工充值,未出现 CDR 消费充值;供应商充值接口实测约 818ms,客户充值约 1063ms。A `opensips -C` 通过并 active;Redis active config 版本 `1782201896870` 下 `auth:ip:100.93.185.30` 命中新建 `cgw_zz_s36_20260623160046`。完整流程测试创建供应商、落地网关、落地线路组、客户、客户网关、策略、客户充值、供应商充值后,由 T 发起 `s36-1001 -> 13800136036`,当前通话 API 显示 `callerIp=100.93.185.30`、`landingIp=100.93.185.30`,强制挂断返回 `HANGUP_IN_PROGRESS`,随后当前通话列表清空;最终 CDR `raw_66b73108dc1e485ab17c6ac52a9464c1` 为 `RATED`,rated CDR `customer_fee=0.012000`、`vendor_cost=0.012000`,测试客户余额从 `10.000000` 扣为 `9.988000`。 +- 回滚方式:B 应用回滚可从 `/var/backups/lisglosips-s36-recharge-flow/20260623T074955` 恢复 `api-dist`、`web-dist`、`worker-cdr-dist` 并重启 `lisglosips@api`、`lisglosips@cdr-worker`;worker 后续补丁可从 `/var/backups/lisglosips-s36-worker-cdr-config-version/20260623T081403` 恢复。A OpenSIPS 热路径回滚点包括 `/var/backups/lisglosips-s36-hotpath/20260623T080010` 和 `/var/backups/lisglosips-s36-dialog-cdr/20260623T080956`,恢复 `/etc/opensips/opensips.cfg` 后执行 `opensips -C -f /etc/opensips/opensips.cfg` 并重启 `opensips`;B config-publisher env 回滚点为 `/var/backups/lisglosips-s36-config-publisher-env/20260623T080456`。测试业务数据使用前缀 `S36 Flow 20260623160046`,如需清理,应按 API 软删除客户网关/策略、线路组、落地网关、客户和供应商,不直接删除充值/话单审计记录。 +- 遗留问题:测试实体当前保留启用,T 源 IP 会命中新建测试客户网关;若后续要恢复 S28 原始测试路由,应软删除或停用该测试客户网关并等待 config-publisher 发布。两次早期测试呼叫因 A 成功 CDR 动态变量和 worker `config_version` 越界进入 `stream:cdr_deadletter`,未进入 raw/rated CDR,已作为本次排障证据保留。T 在测试中曾出现一次 SSH banner 超时和一次 ping 丢包,重试后恢复。 + +### 2026-06-23 15:36 - 需求修改/二期变更:列表计数、删除功能和启停确认 + +- 状态:已完成 +- 操作服务器:B、本地 +- 完成内容:供应商列表显示落地网关数;客户管理、供应商管理前端增加删除按钮和确认弹窗,后端沿用已有软删除并分别在客户网关数/落地网关数大于 0 时拒绝删除。客户网关、落地网关后端新增 `DELETE` 软删除接口并纳入审计;客户网关删除会同步软删该网关下的路由策略并发布配置 outbox;落地网关删除在仍被未删除落地线路组引用时拒绝,避免热配置断链。落地线路组 API 新增 `customerGatewayCount`,按正在使用该线路组的去重客户网关数统计;删除落地线路组时若该数大于 0 则拒绝。Web 客户网关、落地网关启用/禁用前增加确认弹窗;客户网关、落地网关、落地线路组页面增加删除按钮和确认弹窗,并接入真实 API 列表/操作。 +- 修改文件:`IMPLEMENTATION_STATUS.md`、`apps/api/src/modules/customer-gateways/customer-gateways.controller.ts`、`apps/api/src/modules/customer-gateways/customer-gateways.service.ts`、`apps/api/src/modules/customer-gateways/customer-gateways.repository.ts`、`apps/api/src/modules/customer-gateways/customer-gateways.e2e.spec.ts`、`apps/api/src/modules/vendor-gateways/vendor-gateways.controller.ts`、`apps/api/src/modules/vendor-gateways/vendor-gateways.service.ts`、`apps/api/src/modules/vendor-gateways/vendor-gateways.repository.ts`、`apps/api/src/modules/vendor-gateways/vendor-gateways.e2e.spec.ts`、`apps/api/src/modules/landing-line-groups/landing-line-groups.repository.ts`、`apps/api/src/modules/landing-line-groups/landing-line-groups.e2e.spec.ts`、`apps/web/src/api.js`、`apps/web/src/App.jsx`;B 当前 release `/opt/lisglosips/releases/s33-active-calls-20260623094000` 中覆盖 API/Web dist。 +- 安装软件及固定版本:未安装新软件;继续使用 B 既有 Node.js `v22.22.2`、pnpm `10.33.0`、NestJS、Vite、Prisma、Nginx。 +- 执行的关键命令:本地 `corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`、`corepack pnpm@10.33.0 exec vitest run apps/api/src/modules/customer-gateways/customer-gateways.e2e.spec.ts apps/api/src/modules/vendor-gateways/vendor-gateways.e2e.spec.ts apps/api/src/modules/landing-line-groups/landing-line-groups.e2e.spec.ts apps/api/src/modules/customers/customers.e2e.spec.ts --maxWorkers=1`、`corepack pnpm@10.33.0 exec vitest run apps/api/src/modules/vendors/vendors.e2e.spec.ts --maxWorkers=1`;B `tar -xzf` 覆盖当前 release API/Web dist、`systemctl restart lisglosips@api`、`nginx -t`、`systemctl reload nginx`、`curl http://127.0.0.1:3000/api/v2/health/ready` +- 验证结果:typecheck、lint、build 均通过。相关 e2e 验证通过:客户、供应商、客户网关、落地网关、落地线路组;一次组合运行中 `vendors.e2e` 因 Nest 多模块启动超过 10 秒 hookTimeout 被跳过/失败,随后单独运行 `vendors.e2e` 3 条测试全部通过。最终全量 Vitest 单 worker 模式 21 个测试文件、74 条测试通过。B `lisglosips@api` 与 `nginx` active,API ready 返回 `config/database/redis` 均 ok。B 只读烟测通过:`/vendors` 返回 `gatewayCount`,`/landing-line-groups` 返回 `customerGatewayCount`;当前本地数据示例 `ven_s28_t.gatewayCount=1`、`llg_s28_t.customerGatewayCount=1`。 +- 回滚方式:B 已备份覆盖前 dist 到 `/var/backups/lisglosips-s35-delete-count-confirm/20260623T153500/api-dist` 与 `/var/backups/lisglosips-s35-delete-count-confirm/20260623T153500/web-dist`;如需回滚,将两目录恢复到 `/opt/lisglosips/releases/s33-active-calls-20260623094000/apps/api/dist` 与 `/opt/lisglosips/releases/s33-active-calls-20260623094000/apps/web/dist`,重启 `lisglosips@api` 并 `nginx -t && systemctl reload nginx`。本次无数据库 schema migration;如已通过新功能执行业务删除,删除为软删除,需要按具体对象在维护窗口受控恢复 `deleted_at/status` 与相关 outbox。 +- 未解决问题:落地网关删除目前在被落地线路组引用时会拒绝,比本次需求更保守,目的是避免线路组热配置引用已删除网关;如果后续希望“删除落地网关时自动从线路组移除”,需单独设计优先级重排和配置发布语义。 +- 下一任务:继续等待下一项二期需求;阿里云迁移仍不要直接开始。 + +### 2026-06-23 15:12 - 需求修改/二期变更:当前通话增加呼叫方 IP 与落地 IP + +- 状态:已完成 +- 操作服务器:B、T、本地 +- 完成内容:在当前通话 API 归一化结果中新增 `callerIp` 与 `landingIp` 字段;解析优先级为 SIP Contact、SDP `c=IN IP4/IP6`、SIP URI、bind addr 兜底。Web “当前通话 / 实时呼叫列表”新增“呼叫方 IP”和“落地 IP”两列。该变更不涉及数据库 schema、Redis 热路径、OpenSIPS 配置、RTPEngine 或 Worker。 +- 修改文件:`IMPLEMENTATION_STATUS.md`、`apps/api/src/modules/active-calls/active-calls.service.ts`、`apps/api/src/modules/active-calls/active-calls.service.spec.ts`、`apps/web/src/App.jsx`;B 当前 release `/opt/lisglosips/releases/s33-active-calls-20260623094000` 中覆盖 API/Web dist。 +- 安装软件及固定版本:未安装新软件;继续使用 B 既有 Node.js `v22.22.2`、pnpm `10.33.0`、NestJS、Vite、Nginx。 +- 执行的关键命令:本地 `corepack pnpm@10.33.0 exec vitest run apps/api/src/modules/active-calls/active-calls.service.spec.ts --maxWorkers=1`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 build`;B `tar -xzf` 覆盖当前 release API/Web dist、`systemctl restart lisglosips@api`、`nginx -t`、`systemctl reload nginx`、`curl http://127.0.0.1:3000/api/v2/health/ready`;T `/opt/lisglosips-s28/lisglosips-s28-sip.py invite --timeout 20 --hold 60` +- 验证结果:当前通话单测 5 条通过;typecheck、lint、build 均通过。B `lisglosips@api` 与 `nginx` active,API ready 返回 `config/database/redis` 均 ok。A/B/T 端到端验证:T 发起保持呼叫后,B API 查询到当前通话并返回 `callerIp=100.93.185.30`、`landingIp=100.93.185.30`;当前本地 T 同时模拟主叫与落地 UAS,因此两者相同符合测试拓扑。API 挂断返回 `HANGUP_IN_PROGRESS`,等待 OpenSIPS 删除延迟后当前通话列表清空。 +- 回滚方式:B 已备份覆盖前 dist 到 `/var/backups/lisglosips-s34-active-call-ips/20260623T150800/api-dist` 与 `/var/backups/lisglosips-s34-active-call-ips/20260623T150800/web-dist`;如需回滚,将两目录恢复到 `/opt/lisglosips/releases/s33-active-calls-20260623094000/apps/api/dist` 与 `/opt/lisglosips/releases/s33-active-calls-20260623094000/apps/web/dist`,重启 `lisglosips@api` 并 `nginx -t && systemctl reload nginx`。无数据库、A/OpenSIPS、Redis、RTPEngine 回滚项。 +- 未解决问题:真实生产落地网关若不返回 SIP Contact 或 SDP 连接地址,`landingIp` 会退回到可解析的 URI/bind 地址,可能显示为 OpenSIPS 本地地址;后续可在路由侧显式写入 dialog 变量以获得更精确的落地网关 IP。 +- 下一任务:继续等待下一项二期需求;阿里云迁移仍不要直接开始。 + +### 2026-06-23 10:15 - 需求修改/二期变更:新增“当前通话”菜单与强制挂断 + +- 状态:已完成 +- 操作服务器:A、B、T、本地 +- 完成内容:新增 Web “业务 / 当前通话”菜单,可刷新查看 OpenSIPS 当前 Dialog 列表,展示主叫、被叫、Call-ID、状态、时长、联系方式,并提供确认后的“挂断”按钮。B API 新增 `GET /api/v2/active-calls` 与 `POST /api/v2/active-calls/:id/hangup`,使用新增权限 `active_calls.view`、`active_calls.manage` 和审计拦截器;超级管理员具备全部权限,技术运维具备查看/挂断,运营管理员仅查看。A 启用 OpenSIPS `dialog.so` 并在 INVITE 路径执行 `create_dialog()`;新增受限 SSH forced-command `/usr/local/sbin/lisglosips-call-control`,仅允许 `dlg_list` 与 `dlg_end_dlg`,MI HTTP 仍绑定本机 `127.0.0.1:8888`。B 新增专用 `lisglosips` 服务用户出站 SSH key 与 `/etc/lisglosips/call-control/ssh_config`,API 通过该受限通道访问 A;修复 `AUTH_COOKIE_SECURE` 和 `ACTIVE_CALLS_SSH_HOST` 空值导致的 B API 启动/调用兼容问题。 +- 修改文件:`IMPLEMENTATION_STATUS.md`、`apps/api/src/modules/active-calls/active-calls.controller.ts`、`apps/api/src/modules/active-calls/active-calls.module.ts`、`apps/api/src/modules/active-calls/active-calls.service.ts`、`apps/api/src/modules/active-calls/active-calls.service.spec.ts`、`apps/api/src/modules/active-calls/opensips-mi.client.ts`、`apps/api/src/modules/app.module.ts`、`apps/api/src/shared/config.ts`、`apps/web/src/App.jsx`、`apps/web/src/api.js`、`packages/auth/src/index.ts`、`prisma/seed.ts`、`infra/server-a/s28/opensips.cfg`、`infra/server-a/s32/lisglosips-call-control`;A `/etc/opensips/opensips.cfg`、A `/usr/local/sbin/lisglosips-call-control`、B `/etc/lisglosips/api.env`、B `/etc/lisglosips/call-control/*`、B release `/opt/lisglosips/releases/s33-active-calls-20260623094000` +- 安装软件及固定版本:未安装新软件;使用 A 既有 OpenSIPS `3.4.11` dialog 模块、B 既有 Node.js `v22.22.2`、pnpm `10.33.0`、NestJS、Vite、Prisma、MySQL、Redis、Nginx。 +- 执行的关键命令:本地 `corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 exec vitest run --maxWorkers=1`、`corepack pnpm@10.33.0 build`;A `opensips -C -f`、`systemctl restart opensips`、`systemctl is-active opensips rtpengine`;B `systemctl start lisglosips-backup.service`、`corepack pnpm@10.33.0 db:seed`、`systemctl restart lisglosips@api`、`nginx -t`、`systemctl reload nginx`、`curl http://127.0.0.1:3000/api/v2/health/ready`;T `/opt/lisglosips-s28/lisglosips-s28-sip.py invite --timeout 20 --hold 60` +- 验证结果:本地 lint 通过;typecheck 通过;全量 Vitest 单 worker 模式 21 个测试文件、73 条测试通过;workspace build 通过。A OpenSIPS 配置语法检查通过并已重启,`opensips`、`rtpengine` active;B `lisglosips@api`、`nginx`、`mysql`、`redis-server` active,API ready 返回 `config/database/redis` 均 ok。未登录访问 `/api/v2/active-calls` 返回 401;登录态访问空闲列表返回 `source=opensips-mi,total=0`。A/B/T 端到端验收:T 发起保持呼叫后,B API 查询到 1 条当前通话;调用 B API 挂断返回 `HANGUP_IN_PROGRESS`,OpenSIPS Dialog 状态进入 `5`,等待删除延迟后 B API 当前通话列表清空。 +- 回滚方式:B 应用回滚可将 `/opt/lisglosips/current` 切回上一 release `/opt/lisglosips/releases/s32-captcha-admin-20260622124659`,重启 `lisglosips@api` 并 reload Nginx;如需还原 B 环境变量,使用 `/var/backups/lisglosips-s33-active-calls-b/20260623T014402Z/api.env`。A 回滚可从 `/var/backups/lisglosips-s33-active-calls/20260623T014315Z` 恢复 `/etc/opensips/opensips.cfg` 后重启 `opensips`,并移除 `/usr/local/sbin/lisglosips-call-control`、`lisglo-callctl` 用户/authorized_keys 以及 B `/etc/lisglosips/call-control` 专用 key。数据库无 schema migration;新增权限/角色授权来自 seed,可保留,若需清理需维护窗口执行受控 SQL。B 发布前 MySQL/Redis 备份为 `/data/backups/mysql/20260623T014114Z` 与 `/data/backups/redis/20260623T014114Z`。 +- 未解决问题:OpenSIPS HTTP MI 的 `dlg_end_dlg` 在实际终止 Dialog 时会返回 `500 Operation failed`,但 Dialog 会进入 state `5` 并随后清空;API 已按“500 后复查消失或 state 5 则视为已受理”处理。当前通话列表依赖 OpenSIPS dialog 模块,只覆盖本地 KVM A 单节点;未来多 OpenSIPS 节点需按节点聚合。强制挂断会影响被选中的实时呼叫,操作已纳入 RBAC 与审计,生产迁移前需再次演练。 +- 下一任务:继续等待下一项二期需求;阿里云迁移仍不要直接开始。 + +### 2026-06-22 14:20 - 需求修改/二期变更:修复登录验证码刷新后不显示 + +- 状态:已完成,等待用户浏览器复验 +- 操作服务器:B、本地 +- 完成内容:定位验证码接口返回的是 `data:image/svg+xml;base64,...` SVG 图片,而 B Nginx 安全头 `Content-Security-Policy` 未显式允许 `img-src data:`,浏览器会拦截验证码图片渲染,表现为“验证码刷新不出来”。已将仓库和 B 当前 Nginx 安全头更新为 `default-src 'self'; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; object-src 'none'`,保留原有同源、禁止嵌入和禁止 object 的安全基线。 +- 修改文件:`IMPLEMENTATION_STATUS.md`、`infra/server-b/s05/nginx/lisglosips-security-headers.conf`;B `/etc/nginx/snippets/lisglosips-security-headers.conf` +- 安装软件及固定版本:未安装新软件;继续使用 B 既有 Nginx `1.24.0-2ubuntu7.12` 和当前 release `/opt/lisglosips/releases/s32-captcha-admin-20260622124659` +- 执行的关键命令:本地 `corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 prisma:validate`、`corepack pnpm@10.33.0 exec vitest run --maxWorkers=1`、`corepack pnpm@10.33.0 build`;B `cp -a` 备份 Nginx 安全头、`nginx -t`、`systemctl reload nginx`、`curl -k -sI https://127.0.0.1/`、`curl http://127.0.0.1:3000/api/v2/auth/captcha` +- 验证结果:本地 lint、typecheck、Prisma validate 均通过;全量 Vitest 单 worker 模式 20 个测试文件、69 条测试通过;workspace build 通过。B 当前 release 确认为 `/opt/lisglosips/releases/s32-captcha-admin-20260622124659`;`nginx -t` 通过并已 reload;HTTPS 首页响应头已返回 `Content-Security-Policy: default-src 'self'; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; object-src 'none'`;`GET http://127.0.0.1:3000/api/v2/auth/captcha` 返回新的 `captchaId`、`data:image/svg+xml;base64,...` 图片和过期时间;B `nginx`、`lisglosips@api`、`lisglosips@cdr-worker`、`lisglosips@recording-worker` 均 active。本次未做 A/B/T SIP 呼叫链路,因为变更只涉及 B Web 安全头,不影响 Redis 热路径、OpenSIPS、RTPEngine、CDR 或录音链路。 +- 回滚方式:B 已备份原安全头到 `/var/backups/lisglosips-s32-captcha-csp/20260622T061944Z/lisglosips-security-headers.conf`;如需回滚,恢复该文件到 `/etc/nginx/snippets/lisglosips-security-headers.conf`,执行 `nginx -t` 通过后 `systemctl reload nginx`。仓库侧回滚 `infra/server-b/s05/nginx/lisglosips-security-headers.conf` 的本次一行 CSP 修改即可。无数据库、Redis、服务 release、OpenSIPS 或 RTPEngine 回滚项。 +- 未解决问题:尚未由用户在真实浏览器中点击验证码刷新并确认图片可见;验证码仍为 API 单进程内存存储,后续多 API 实例/生产高可用建议迁移到 Redis 或共享缓存。 +- 下一任务:用户浏览器复验 B 登录页验证码刷新;继续等待下一项二期需求。 + +### 2026-06-22 12:54 - 需求修改/二期变更发布到 B:登录图形验证码与 admin 初始账号 + +- 状态:已完成,等待用户验收 +- 操作服务器:B、本地 +- 完成内容:在登录接口新增一次性图形验证码,`GET /api/v2/auth/captcha` 生成 5 位 SVG 验证码并只返回 `captchaId`、图片 data URL 和过期时间;`POST /api/v2/auth/login` 必须携带 `captchaId` 和 `captchaCode`,验证码错误返回 `401 AUTH_CAPTCHA_INVALID`,缺少登录字段继续返回统一认证失败以降低账号枚举风险。Web 登录页新增验证码图片、输入框和刷新按钮,登录失败后自动刷新验证码。B 数据库已新增/更新用户名 `admin`,启用状态,授予超级管理员角色,并按用户提供的初始口令写入 Argon2id 哈希;未在文档或命令输出中记录明文口令。发布为 B 新 release `/opt/lisglosips/releases/s32-captcha-admin-20260622124659`,`/opt/lisglosips/current` 已切换到该 release。 +- 修改文件:`IMPLEMENTATION_STATUS.md`、`apps/api/src/modules/auth/auth.controller.ts`、`apps/api/src/modules/auth/auth.module.ts`、`apps/api/src/modules/auth/captcha.service.ts`、`apps/api/src/modules/auth/auth.e2e.spec.ts`、`apps/web/src/App.jsx`、`apps/web/src/api.js`、`apps/web/src/styles.css`;B 新 release 中覆盖 API/Web dist 与对应源码。延续上一轮登录防爆破相关文件:`apps/api/src/modules/auth/auth.service.ts`、`apps/api/src/modules/auth/auth.service.spec.ts`、`apps/api/src/shared/config.ts`。 +- 安装软件及固定版本:未安装新软件;继续使用 B 既有 Node.js `v22.22.2`、pnpm `10.33.0`、NestJS、Vite、Prisma、Nginx、Grafana `13.0.2`、Prometheus `2.45.3`。 +- 执行的关键命令:本地 `corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 prisma:validate`、`corepack pnpm@10.33.0 exec vitest run --maxWorkers=1`、`corepack pnpm@10.33.0 build`、`tar -czf` 打包、`scp` 上传;B `systemctl start lisglosips-backup.service`、`cp -a` 创建新 release、`tar --no-same-owner -xzf` 覆盖产物、`ln -sfn` 切换 current、`systemctl restart lisglosips@api`、`nginx -t`、`systemctl reload nginx`、`/tmp/lisglosips-release-preflight.sh`;B 通过临时脚本按用户提供口令 upsert `admin` 用户并撤销旧会话。 +- 验证结果:本地 lint、typecheck、Prisma validate、build 均通过;全量 Vitest 单 worker 模式 20 个测试文件、69 条测试通过,新增认证 e2e 覆盖 captcha 接口不泄露答案、无验证码登录被拒绝、携带正确验证码后可登录。B 发布前备份目录为 MySQL `/data/backups/mysql/20260622T044834Z`、Redis `/data/backups/redis/20260622T044834Z`,两者 `SHA256SUMS` 校验通过。B preflight 通过:MySQL、Redis、Nginx、API、CDR Worker、Recording Worker、Heplify、Prometheus、Grafana 均 active,API ready 返回 ok,Nginx 配置测试成功。`GET http://127.0.0.1:3000/api/v2/auth/captcha` 返回 captcha JSON 与 SVG data URL 且不含答案;使用错误验证码调用登录返回 `401 AUTH_CAPTCHA_INVALID`;`https://127.0.0.1/` 返回新前端构建资产 `index-B6ms7F5U.js` 与 `index-C60rDiFV.css`。 +- 回滚方式:如需回滚应用,按 `docs/S30_RELEASE_BACKUP_RUNBOOK.md` 将 B `/opt/lisglosips/current` 切回上一稳定 release `/opt/lisglosips/releases/s31-auth-login-20260622115851` 或原冻结 release `/opt/lisglosips/releases/s28-v2-20260621220924`,重启 `lisglosips@api` 并 reload Nginx。数据库只新增/更新 `admin` 用户密码哈希和会话撤销记录;如需回滚账号变更,可在维护窗口按 B 发布前 MySQL 备份 `/data/backups/mysql/20260622T044834Z` 恢复,或单独执行受控账号禁用/密码重置脚本,执行前需再次说明影响和回滚点。 +- 未解决问题:尚未由用户在浏览器中完成真实登录验收;验证码当前为单 API 进程内存存储,适配当前 B 单实例开发环境,后续多 API 实例/生产高可用应迁移到 Redis 或共享缓存。登录防爆破仍为进程内存级节流,生产多实例同样建议升级为 Redis/网关级共享限流。本次未做 A/B/T SIP 呼叫链路,因为变更只涉及 Web/API 登录鉴权和 B 用户数据,不影响 Redis 热路径、OpenSIPS、RTPEngine、CDR 或录音链路。 +- 下一任务:用户验收 B 登录页;继续等待下一项二期需求。 + +### 2026-06-22 12:04 - 需求修改/二期变更发布到 B:登录页与登录防爆破 + +- 状态:已完成,等待用户验收 +- 操作服务器:B、本地 +- 完成内容:将本地登录页与登录防爆破变更发布为 B 新 release `/opt/lisglosips/releases/s31-auth-login-20260622115851`,`/opt/lisglosips/current` 已切换到该 release。发布方式为复制上一 release `/opt/lisglosips/releases/s28-v2-20260621220924` 后覆盖本次 API/Web 构建产物和相关源码;未执行数据库迁移,未改 Redis 配置,未触碰 A/T、OpenSIPS 或 RTPEngine。发布前触发 B MySQL/Redis 备份并校验哈希。 +- 修改文件:本地 `IMPLEMENTATION_STATUS.md`;B 新 release 中的 `apps/api/dist/`、`apps/api/src/modules/auth/auth.service.ts`、`apps/api/src/modules/auth/auth.service.spec.ts`、`apps/api/src/shared/config.ts`、`apps/web/dist/`、`apps/web/src/App.jsx`、`apps/web/src/api.js`、`apps/web/src/styles.css` +- 安装软件及固定版本:未安装新软件;继续使用 B 既有 Node.js `v22.22.2`、pnpm `10.33.0`、NestJS、Vite、Prisma、Nginx、Grafana `13.0.2`、Prometheus `2.45.3` +- 执行的关键命令:本地 `corepack pnpm@10.33.0 build`、`tar -czf` 打包发布覆盖包、`scp` 上传;B `systemctl start lisglosips-backup.service`、`cp -a` 创建新 release、`tar --no-same-owner -xzf` 覆盖产物、`ln -sfn` 切换 current、`systemctl restart lisglosips@api`、`nginx -t`、`systemctl reload nginx`、`/tmp/lisglosips-release-preflight.sh` +- 验证结果:发布前备份目录为 MySQL `/data/backups/mysql/20260622T040339Z`、Redis `/data/backups/redis/20260622T040339Z`,两者 `SHA256SUMS` 校验通过。B preflight 通过:MySQL、Redis、Nginx、API、CDR Worker、Recording Worker、Heplify、Prometheus、Grafana 均 active,API ready 返回 ok,Nginx 配置测试成功。`GET http://127.0.0.1:3000/api/v2/health/ready` 返回 ok;未登录访问 `/api/v2/dashboard/summary` 返回 `401 AUTH_REQUIRED`;`https://127.0.0.1/` 返回新前端构建资产 `index-7KkZS1Fz.js` 与 `index-ChBVfysf.css`。Grafana health `http://127.0.0.1:3001/api/health` 返回 database ok/version `13.0.2`,Prometheus `/-/ready` 返回 ready。 +- 回滚方式:B 应用可执行 `sudo /tmp/lisglosips-release-preflight.sh` 复核状态;如需回滚,按 `docs/S30_RELEASE_BACKUP_RUNBOOK.md` 将 `/opt/lisglosips/current` 切回 `/opt/lisglosips/releases/s28-v2-20260621220924`,重启 `lisglosips@api` 并 reload Nginx;数据库和 Redis 未变更,备份目录仅作为发布前恢复点保留。 +- 未解决问题:尚未由用户在浏览器中完成真实登录验收;本次未做 A/B/T SIP 呼叫链路,因为变更只涉及 Web/API 登录鉴权,不影响 Redis 热路径、OpenSIPS、RTPEngine、CDR 或录音链路。后续多 API 实例/生产环境仍建议把登录节流升级为 Redis 或网关级共享限流。 +- 下一任务:用户验收登录页面;继续等待下一项二期需求。 + +### 2026-06-22 11:53 - 需求修改/二期变更:登录页与登录防爆破 + +- 状态:已完成 +- 操作服务器:本地 +- 完成内容:完善 Web 登录闭环,打开应用时先尝试 `/api/v2/auth/refresh` 恢复会话,未认证时只显示登录页面;输入用户名和密码调用 `/api/v2/auth/login`,登录成功后保存 access token 并加载业务数据;顶部显示当前登录用户并支持退出,退出时调用 `/api/v2/auth/logout`、清理本地 token 和页面数据。后端在既有 Argon2id、用户失败次数和账号锁定基础上,新增内存级登录节流,按用户名与来源 IP 统计失败次数;即使用户名不存在,连续失败也会触发 `429 AUTH_LOGIN_THROTTLED`,避免撞库/爆破时泄露账号存在性。新增运行时配置 `AUTH_LOGIN_THROTTLE_MAX_FAILURES` 和 `AUTH_LOGIN_THROTTLE_WINDOW_SECONDS`。 +- 修改文件:`IMPLEMENTATION_STATUS.md`、`apps/web/src/App.jsx`、`apps/web/src/api.js`、`apps/web/src/styles.css`、`apps/api/src/shared/config.ts`、`apps/api/src/modules/auth/auth.service.ts`、`apps/api/src/modules/auth/auth.service.spec.ts` +- 安装软件及固定版本:未安装新软件;继续使用 Node.js/pnpm、NestJS、Vite、Vitest、Prisma 既有版本。 +- 执行的关键命令:本地 `corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 exec vitest run apps/api/src/modules/auth/auth.service.spec.ts apps/api/src/modules/auth/auth.e2e.spec.ts`、`corepack pnpm@10.33.0 exec vitest run --maxWorkers=1`、`corepack pnpm@10.33.0 prisma:validate`、`corepack pnpm@10.33.0 build` +- 验证结果:lint 通过;typecheck 通过;认证单测/e2e 6 条通过;全量 Vitest 使用 `--maxWorkers=1` 后 20 个测试文件、69 条测试全部通过;Prisma schema validate 通过;workspace build 通过。首次全量并行测试曾出现多个 Nest e2e `beforeAll` 超时,改为单 worker 后全部通过,判断为本地并行资源/初始化竞争导致的假失败。 +- 回滚方式:恢复上述 7 个文件到本次修改前版本即可;无数据库 schema 变更、无服务器写操作、无 Redis/OpenSIPS/RTPEngine 配置变更。若已发布到 B,可按 `docs/S30_RELEASE_BACKUP_RUNBOOK.md` 使用上一 release 切回并重启 API/Web 相关服务。 +- 未解决问题:本次只完成本地代码修改,尚未发布到 B 当前冻结 release,也未做浏览器视觉截图或 A/B/T 端到端验收;登录节流为单进程内存级,足以覆盖当前单 API 进程开发环境,后续多 API 实例或生产环境建议迁移到 Redis/网关级限流以跨进程共享计数。 +- 下一任务:等待下一个二期需求;如需上线本变更,应按 S30 发布流程创建新 B release 并做 preflight/登录冒烟。 + ### 2026-06-22 10:35 - S30 备份、Runbook、灰度与上线完成 - 状态:已完成 diff --git a/SOFTSWITCH_PLATFORM_DESIGN_V2.md b/SOFTSWITCH_PLATFORM_DESIGN_V2.md index 96f712a..212b08e 100644 --- a/SOFTSWITCH_PLATFORM_DESIGN_V2.md +++ b/SOFTSWITCH_PLATFORM_DESIGN_V2.md @@ -1,9 +1,9 @@ # LisgloSIPS V2 项目设计与实施文档 > 中文名:聆界SIP管理平台 -> 文档版本:V2.0 +> 文档版本:V2.2 > 编制日期:2026-06-20 -> 文档状态:实施基线草案 +> 文档状态:S30 后二期需求变更持续补充 > 依据:服务器架构图、`SOFTSWITCH_PLATFORM_DESIGN_V1.md`、当前 React Web Demo、`OPENSIPS_INSTALL_NOTES.md` ## 1. 文档目的 @@ -21,12 +21,14 @@ V2 的目标是把当前纯前端原型转化为可部署、可联调、可上 | 菜单 | V2 实施内容 | | --- | --- | | 概览 Dashboard | 通话、接通率、消费、成本、毛利、注册、节点、异常网关、质检指标 | -| 客户管理 | 客户新增、编辑、启停、余额、授信、充值、网关数量 | -| 客户网关管理 | IP/SIP 注册认证、启停、多个策略、优先级、主被叫匹配、线路组绑定 | -| 充值记录 | 客户充值、供应商充值、余额前后值、操作人、备注 | -| 供应商管理 | 供应商账户、余额、授信、充值 | -| 落地网关管理 | 认证、并发、CPS、禁呼时段、编码、号码转换、计费周期、费率、启停 | -| 落地线路组 | 线路组、组内网关、优先级、并发汇总 | +| 客户管理 | 客户新增、编辑、启停、余额、授信、充值、网关数量、受控删除 | +| 客户网关管理 | IP/SIP 注册认证、启停、删除、多个策略、优先级、主被叫匹配、线路组绑定 | +| 充值记录 | 客户充值、供应商充值、余额前后值、操作人、备注;仅记录页面人工充值,不记录话单消费扣费 | +| 供应商管理 | 供应商账户、余额、授信、充值、落地网关数量、受控删除 | +| 落地网关管理 | 认证、并发、CPS、禁呼时段、编码、号码转换、计费周期、费率、启停、删除 | +| 落地线路组 | 线路组、组内网关、优先级、并发汇总、使用客户网关数量、受控删除 | +| 号码库 | 手机号码库、城市区号、运营商号码段规则;为话单归属地/运营商和落地网关屏蔽地区提供基础数据 | +| 当前通话 | OpenSIPS Dialog 实时列表、呼叫方 IP、落地 IP、自动刷新、强制挂断 | | 话单中心 | 话单查询、挂断原因、费用、录音、信令入口、详情 | | 质检中心 | 抽检规则、录音列表、连续播放、自动播放、问题标注、评分 | | 用户管理 | 用户新增、编辑、启停、重置密码、角色绑定 | @@ -35,7 +37,7 @@ V2 的目标是把当前纯前端原型转化为可部署、可联调、可上 ### 2.2 延期页面 -以下菜单在 Web Demo 中标记为“待设计”,本期不开发其业务页面: +以下菜单在 Web Demo 中曾标记为“待设计”,S30 后二期变更要求在导航中隐藏,不向运营用户展示入口: - 费率与计费 - SIP 运维 @@ -299,7 +301,8 @@ INVITE -> Redis 查询客户、客户网关、状态、余额门槛 -> Redis 查询客户网关策略(主叫条件 AND 被叫条件,按优先级) -> 获得落地线路组 - -> 按组内优先级、启用状态、并发/CPS 选择落地网关 + -> 识别被叫号码归属地级市和运营商 + -> 按组内优先级、启用状态、并发/CPS、屏蔽地区选择落地网关 -> 执行主被叫前缀转换、时段和编码限制 -> RTPEngine offer/answer -> 转发到供应商 @@ -344,6 +347,10 @@ Redis Lua 脚本一次完成: "source_ip": "10.10.1.11", "caller": "02160010001", "callee": "13800138000", + "callee_city_code": "310000", + "callee_city_name": "上海", + "callee_province_name": "上海", + "callee_operator": "MOBILE", "vendor_id": "V1001", "vendor_gateway_id": "VGW-001", "line_group_id": "LG-001", @@ -412,11 +419,15 @@ customer_recharges vendors vendor_recharges vendor_gateways +vendor_gateway_blocked_regions vendor_gateway_forbidden_periods vendor_gateway_codecs vendor_gateway_prefix_rules landing_line_groups landing_line_group_items +geo_cities +phone_number_segments +carrier_prefix_rules raw_cdrs rated_cdrs recordings @@ -440,6 +451,7 @@ idempotency_keys - 网关策略唯一索引至少包含 `gateway_id + priority`。 - 线路组成员唯一索引包含 `line_group_id + vendor_gateway_id`。 - CDR 使用 `event_id` 唯一索引。 +- CDR 需要保存被叫号码解析出的地级市、省份和运营商快照,避免后续号码库更新影响历史话单解释。 - 所有管理表包含 `created_at`、`updated_at`、`created_by`、`updated_by` 和乐观锁版本号。 - 时间存 UTC;API 返回 ISO 8601。 - 删除优先软删除;充值、CDR、审计日志不允许物理删除。 @@ -1135,7 +1147,7 @@ V2 首期仅支持落地网关上的: - 按菜单逐页替换 Mock;每次只接入一个领域。 - 增加加载、空状态、错误、权限隐藏和并发更新提示。 -- 待设计菜单继续保留标识,不接临时 API。 +- 待设计菜单不接临时 API;S30 后二期变更要求导航中隐藏这些入口。 **S28 三机端到端联调** @@ -1152,7 +1164,267 @@ V2 首期仅支持落地网关上的: - 完成恢复演练、上线/回滚脚本和灰度。 - 上线前冻结版本和数据库迁移,输出最终验收报告。 -### 25.5 Codex 新会话启动模板 +### 25.5 S30 后二期需求变更 + +S30 完成本地 KVM A/B/T 闭环后,用户基于实际试用继续提出二期变更。二期变更不重做 S00-S30 基线,按“在既有架构上最小增量、可验证、可回滚”的原则落地。当前本地冻结基线仍为 `s28-v2-20260621220924`,B 当前运行 release 为 `s33-active-calls-20260623094000`,多次二期前端/API/通信配置小改覆盖在该 release 上。阿里云迁移仍不得自动开始,迁移前必须按 S30 Runbook 重新演练。 + +#### 25.5.1 登录与验证码 + +- 登录页必须使用后端图形验证码,验证码刷新失败时应有明确错误提示,登录失败后必须刷新验证码。 +- 登录防爆破、Session/Refresh Token、Cookie 安全配置仍归属 S09/S10 安全基线。 +- 禁止在文档、日志或页面中输出明文密码;初始账号、服务器凭据只允许通过私密目录和受控流程读取。 + +#### 25.5.2 当前通话与强制挂断 + +新增“当前通话”能力,定位为实时运维视图,不作为 CDR 或计费真相源。 + +- Web 在“业务”菜单下提供“当前通话”页面,展示 OpenSIPS 当前 Dialog 列表。 +- B API 提供: + - `GET /api/v2/active-calls` + - `POST /api/v2/active-calls/:id/hangup` +- 权限新增: + - `active_calls.view` + - `active_calls.manage` +- 超级管理员具备查看和挂断权限;技术运维具备查看和挂断权限;运营管理员默认仅查看。 +- API 通过 B 上的受限 SSH key 调用 A 上的 forced-command `/usr/local/sbin/lisglosips-call-control`,只允许 `dlg_list` 和 `dlg_end_dlg`。OpenSIPS MI HTTP 仍只绑定 `127.0.0.1:8888`。 +- 当前通话列表字段至少包括: + - Dialog ID / Call-ID + - 主叫、被叫 + - 呼叫方 IP + - 落地 IP + - 状态、开始时间、持续时长 + - 强制挂断操作 +- 呼叫方 IP 和落地 IP 的解析优先级为 SIP Contact、SDP `c=IN IP4/IP6`、SIP URI、bind 地址兜底。生产上若落地网关不提供 Contact 或 SDP 连接地址,落地 IP 可能退回为 OpenSIPS 本地地址;后续可在路由侧写入 Dialog 变量提高精度。 +- 强制挂断必须弹出自研确认弹窗;操作必须进入 RBAC 和审计。 +- OpenSIPS `dlg_end_dlg` 可能返回 `500 Operation failed`,但 Dialog 进入 state `5` 并随后清空时,API 可按“已受理/处理中”处理。 +- 当前通话页面默认支持 5 秒自动刷新,并保留手动刷新。 + +#### 25.5.3 列表计数、删除和启停确认 + +二期增加删除能力时,优先使用软删除和引用保护,不允许破坏热路径配置一致性。 + +- 客户管理: + - 列表显示客户网关数。 + - 删除客户前必须确认。 + - 若客户关联客户网关数大于 0,则拒绝删除。 +- 客户网关管理: + - 支持删除,删除前必须确认。 + - 删除客户网关时,其关联路由策略应同步停止生效或软删除,并发布配置 Outbox。 + - 启用/禁用前必须确认。 +- 供应商管理: + - 列表显示落地网关数。 + - 删除供应商前必须确认。 + - 若供应商关联落地网关数大于 0,则拒绝删除。 +- 落地网关管理: + - 支持删除,删除前必须确认。 + - 启用/禁用前必须确认。 + - 若落地网关仍被未删除落地线路组引用,默认拒绝删除,避免线路组热配置引用已删除网关。若未来要支持自动从线路组移除,需单独设计成员优先级重排和配置发布语义。 +- 落地线路组: + - 列表显示正在使用该线路组的去重客户网关数。 + - 删除线路组前必须确认。 + - 若有客户网关策略正在使用该线路组,则拒绝删除。 +- 所有删除按钮使用红色警示态;所有确认弹窗使用自研 UI,不再使用浏览器原生 `window.confirm`。 + +#### 25.5.4 充值记录与计费流水语义 + +充值记录页面只展示人工充值,不承载话单消费扣费记录。 + +- 页面人工充值写入 `customer_recharges` / `vendor_recharges` 或等价充值流水。 +- CDR Worker 的话单消费扣费只写 raw/rated CDR 和余额扣减结果,不再写客户充值记录。 +- 充值记录 API 需要过滤历史由 `worker-cdr`、`cdr:`、`CDR_CHARGE:` 等来源产生的消费类流水,避免在“充值记录”中混入话单扣费。 +- Web 充值成功后只局部更新对应余额和充值记录列表,不应触发整页 `refreshApi()`,避免供应商充值后长时间停留在“拉取 API”。 +- 话单消费、客户费用、供应商成本仍以 CDR/rated CDR 和余额变更为准。 + +#### 25.5.5 动态路由、CDR 和配置发布补齐 + +完整新建链路测试暴露出 S28 早期热路径仍有硬编码成功 CDR 和落地网关的问题。二期要求热路径使用配置发布结果动态选择路由。 + +- OpenSIPS Lua 热路径按客户网关策略匹配线路组,并选择线路组内首个启用落地网关。 +- OpenSIPS 成功 CDR 从 Dialog 变量写入动态 customer/gateway/policy/vendorGateway/lineGroup,不再写硬编码对象。 +- B 必须提供 `config-publisher.env` 并保持 `lisglosips@config-publisher` active。 +- CDR Worker 对 `config_version` 等大数版本字段必须避免 MySQL INT 越界。 +- 端到端验收流程应覆盖: + 1. 新建供应商 + 2. 新建落地网关 + 3. 新建落地线路组 + 4. 新建客户 + 5. 新建客户网关 + 6. 配置客户网关策略指向新线路组 + 7. 给客户充值 + 8. 给供应商充值 + 9. 打通电话 + 10. 当前通话页面查看并强制挂断 + 11. 核对 raw/rated CDR、客户余额、供应商成本和审计记录 + +#### 25.5.6 前端信息架构和交互细化 + +二期 UI 调整遵循当前控制台风格,不引入新的视觉体系。 + +- “待设计”菜单在导航中隐藏,包括费率与计费、SIP 运维、监控告警、系统设置。底层最小计费、HOMER、Prometheus/Grafana、配置文件能力仍按 S00-S30 保留。 +- 客户管理、客户网关管理、充值记录、供应商管理、落地网关管理等页面中,ID 和名称类列宽应适当收窄,长文本使用省略号,避免挤占金额、状态和操作列。 +- 话单详情抽屉应分区展示: + - 顶部主叫到被叫概览 + - 通话摘要:时长、挂断原因、客户费用、成本费用 + - 链路信息:客户网关、呼叫 IP、落地网关、线路 IP + - 时间轴:呼叫、接通、结束时间 + - 录音播放和信令查看入口 +- 当前通话、删除、启停、重置密码、策略删除、质检规则删除等确认场景统一使用自研确认弹窗。 + +#### 25.5.7 10 路虚拟呼叫测试工具 + +为观察当前通话页面的实时变化,二期新增 T 侧虚拟呼叫编排工具。 + +- 工具路径:`/opt/lisglosips-s40/lisglosips-s40-virtual-calls.py`,源码纳入 `infra/server-t/s40/`。 +- 默认测试参数: + - 总计 10 路虚拟呼叫。 + - 每隔 15 秒发起 1 路。 + - 前 5 路先返回 `180 Ringing`,45 秒后返回 `200 OK`,通话保持 600 秒后 BYE。 + - 后 5 路先返回 `180 Ringing`,70 秒后返回 `480 Temporarily Unavailable`。 + - 使用当前本地测试策略的主叫 `s36-1001` 和被叫 `13800136036`,按 Call-ID 序号区分接通/不接通场景。 +- T 上旧 S28 UAS 占用 `100.93.185.30:50620`,完整测试时需要临时停旧 UAS,由 S40 UAS 接管;测试结束或提前停止后必须恢复旧 S28 UAS。 +- 首次测试暴露 A `fr_inv_timeout=30` 会在 45 秒接通前返回 `408 Request Timeout`。为了支持 45 秒接通和 70 秒未接场景,本地 KVM A 已将 `/etc/opensips/opensips.cfg` 中 `modparam("tm", "fr_inv_timeout", 30)` 临时调整为 `95` 并重启 OpenSIPS。该调整属于测试窗口配置,迁移生产前必须重新评估运营侧真实振铃超时策略。 +- 停止完整测试命令: + +```bash +sudo pkill -f /opt/lisglosips-s40/lisglosips-s40-virtual-calls.py +``` + +- 若 T 旧 UAS 未恢复,可执行: + +```bash +sudo nohup runuser -u nobody -- /usr/bin/python3 /opt/lisglosips-s28/lisglosips-s28-sip.py uas --host 100.93.185.30 --port 50620 >/tmp/lisglosips-s28-uas.log 2>&1 & +``` + +#### 25.5.8 二期变更的回归要求 + +每次二期变更完成后至少执行: + +- 相关单元测试或 e2e 测试。 +- `corepack pnpm@10.33.0 typecheck` +- `corepack pnpm@10.33.0 lint` +- `corepack pnpm@10.33.0 build` +- 若涉及 A/B/T 三机链路,则执行端到端验收并按 Call-ID 记录结果。 +- 若涉及 OpenSIPS 配置,必须先备份、执行 `opensips -C -f /etc/opensips/opensips.cfg`,再重启或 reload,并记录回滚点。 +- 若涉及 Web 发布,必须备份 B 当前 release 的 `apps/web/dist`,覆盖后执行 `nginx -t` 和 reload。 +- 每次完成后更新 `IMPLEMENTATION_STATUS.md`,写明修改内容、验证结果、回滚方式和遗留问题。 + +#### 25.5.9 号码库、归属地运营商识别与屏蔽地区路由 + +新增“号码库”菜单,定位为呼叫路由和话单归属地的基础数据管理,不属于普通费率页面。菜单包含四个 Tab: + +- 地级市字典:维护稳定的地级市编码、省份、地级市名称、状态和生效期,供手机号码库、城市区号、落地网关屏蔽地区和话单快照统一引用。 +- 手机号码库:通过手机号前 7 位号段匹配地级市,预计约 80 万条号段。 +- 城市区号:保存全国固话区号,例如 `0551` 对应合肥、`021` 对应上海,维度到地级市。 +- 运营商号码段规则:通过手机号前 3-4 位匹配归属运营商,用于快速判断中国移动/中国联通/中国电信/广电/虚拟运营商等。 + +是否增加地级市字典:需要增加。原因是手机号码库和城市区号都要统一落到地级市维度,落地网关屏蔽地区也需要引用稳定地区编码;如果只在号段表中保存城市文本,会造成同名、改名、直辖市、省市归属和历史变更难以维护。建议新增 `geo_cities` 字典,字段至少包括: + +- `code`:行政区划码或项目稳定编码,作为主键或唯一键。 +- `province_code`、`province_name` +- `city_code`、`city_name` +- `city_level`:地级市、直辖市、地区、自治州等。 +- `status`、`effective_from`、`effective_to` +- 审计列和软删除列。 + +号码库数据模型建议: + +- `phone_number_segments` + - `segment7`:手机号前 7 位,唯一。 + - `city_code`:关联 `geo_cities`。 + - `province_name`、`city_name` 可冗余快照,便于导入校验和快速展示。 + - `carrier` 可选;若和运营商规则冲突,运营商以 `carrier_prefix_rules` 为准,并记录数据质量告警。 + - `source`、`batch_id`、`effective_from`、`effective_to`、`updated_at`。 +- `carrier_prefix_rules` + - `prefix`:手机号前 3-4 位,唯一或按生效期唯一。 + - `carrier`:`MOBILE`、`UNICOM`、`TELECOM`、`BROADCAST`、`MVNO`、`UNKNOWN` 等枚举。 + - `priority`:前缀重叠时按最长前缀和优先级匹配。 + - `effective_from`、`effective_to`。 +- `phone_area_codes` + - `area_code`:固话区号,例如 `021`、`0551`。 + - `city_code`:关联 `geo_cities`。 + - `province_name`、`city_name` 展示冗余。 +- `vendor_gateway_blocked_regions` + - `vendor_gateway_id` + - `city_code`,必要时支持省级屏蔽可通过 `region_scope=PROVINCE/CITY` 建模。 + - `created_at`、`created_by`。 + +匹配规则: + +- 手机号归属地:优先取规范化后的被叫号码前 7 位匹配 `phone_number_segments.segment7`。 +- 手机号运营商:按被叫号码前 4 位、前 3 位依次匹配 `carrier_prefix_rules`,优先最长前缀。 +- 固话归属地:对被叫号码做号码规范化后匹配城市区号;区号需要支持 `0xx`、`0xxx`,并注意去掉外呼前缀、国家码 `+86/0086` 后再判断。 +- 无法识别时,城市和运营商写 `UNKNOWN`,呼叫不应仅因号码库缺失被拒绝,除非客户或全局策略明确要求。 + +每通话单必须保存号码识别快照: + +- `callee_city_code` +- `callee_city_name` +- `callee_province_name` +- `callee_operator` +- 可选保存 `callee_number_type`:`MOBILE`、`LANDLINE`、`INTERNATIONAL`、`UNKNOWN`。 + +落地网关屏蔽地区路由要求: + +- Config Publisher 发布线路组时,需要把线路组成员落地网关的屏蔽地区一起写入 Redis 热路径配置。 +- OpenSIPS/Lua 在选中客户网关策略和线路组后,先识别被叫地级市,再按线路组成员优先级选择落地网关。 +- 若某落地网关屏蔽该地级市或其所在省份,则跳过该网关,继续尝试同一线路组中的下一落地网关。 +- 若同一线路组所有可用落地网关都被屏蔽或不可用,返回明确失败原因,例如 `NO_VENDOR_ROUTE_REGION_BLOCKED`,并写入失败 CDR。 +- 成功 CDR 需要写入最终实际选中的 `vendor_gateway_id` 和号码识别快照;不能只记录第一次被跳过的网关。 +- 当前 V2 单节点 OpenSIPS 热路径必须保持可预测和低延迟,80 万手机号段不应在每通电话中扫描 MySQL。推荐路径是 B 侧导入 MySQL 后,由 Config Publisher 生成 Redis 查找结构: + - `cfg:v:{version}:phone_segment:{segment7}` -> city/operator 快照。 + - `cfg:v:{version}:area_code:{areaCode}` -> city 快照。 + - `cfg:v:{version}:carrier_prefix:{prefix}` -> carrier。 + - `cfg:v:{version}:vendor_gateway:{id}:blocked_regions` -> city/province set 或紧凑 JSON。 + +实施拆分建议: + +1. 数据建模与迁移:新增地级市字典、手机号码库、城市区号、运营商前缀规则、落地网关屏蔽地区和 raw CDR 号码识别字段。此步骤涉及数据库 schema migration,执行前必须备份 B MySQL,说明回滚点;80 万号段导入需要单独评估索引和迁移时间。 +2. 导入与管理 API:实现号码库导入、分页查询、按号段/区号/城市检索、批次校验、重复号段冲突报告;大批量导入应走文件/后台任务,不建议通过普通 JSON 表单一次提交。 +3. Web 菜单:新增“号码库”菜单和四个 Tab(地级市字典、手机号码库、城市区号、运营商号码段规则),先支持查询、导入结果查看和基础维护;超大号段列表必须服务端分页和筛选,不做前端全量加载。 +4. Config Publisher 与 Redis 热路径:把号码库快照、运营商规则、城市区号和落地网关屏蔽地区发布到版本化 Redis key;保留上一版本用于一键回滚。 +5. OpenSIPS/Lua 路由:在当前动态路由基础上增加号码规范化、地级市/运营商解析、屏蔽地区跳过下一落地网关、失败原因写入;改动前必须备份 A `/etc/opensips/opensips.cfg` 和 Lua,执行 `opensips -C -f` 后再重启。 +6. CDR Stream 与 Worker:升级 CDR event schema,解析并入库归属地/运营商字段;保持向后兼容旧 schema,避免旧 Stream 或 pending 消息死信。 +7. 话单中心展示:列表/详情增加地级市、运营商展示和筛选;历史无字段话单显示 `UNKNOWN` 或 `-`。 +8. 端到端验收:构造同一线路组两个落地网关,其中第一个屏蔽目标地级市,第二个允许;发起测试呼叫,验证当前通话/成功 CDR 使用第二个网关,并保存正确地级市和运营商;再验证全部网关屏蔽时失败 CDR 原因为 `NO_VENDOR_ROUTE_REGION_BLOCKED`。 + +风险与约束: + +- 80 万手机号段属于大批量主数据,导入、索引、Redis 发布和回滚都需要独立 Runbook,不能夹在普通前端小改中发布。 +- 手机号段和行政区划会变更,必须保留数据来源、批次和生效期,避免“更新号码库”改变历史 CDR 解释。 +- Redis 热路径内 JSON 字符串解析能力有限,复杂匹配逻辑应提前在 Config Publisher 生成适合 Lua 快速读取的结构。 +- 地区屏蔽属于路由策略,必须与并发/CPS、禁呼时段、编码限制的优先级关系固定下来:推荐先过滤状态/禁呼/地区屏蔽,再做并发/CPS 占用。 + +本地实施状态: + +- 2026-06-24 已完成第 1、2 步本地代码基线:Prisma Schema、迁移文件、号码库 API、RBAC/seed、审计和后端测试已完成;尚未发布到 B,尚未执行 MySQL migration,尚未导入真实 80 万号段。 +- 2026-06-24 已完成第 3 步 Web 本地代码基线:运营菜单新增“号码库”,页面包含地级市字典、手机号码库、城市区号、运营商号码段规则四个 Tab;每个 Tab 支持服务端分页查询入口、筛选、刷新和批量 JSON 导入弹窗。该导入弹窗只适合小批校验和后台接口联调,真实 80 万号段仍需按后续导入任务/Runbook 走文件或后台任务。 +- 2026-06-24 已完成第 4、5 步本地代码基线:Config Publisher 会把地级市、手机 7 位号段、固话区号、运营商前缀和落地网关屏蔽地区发布为版本化 Redis key;号码库导入会写入 `number_library_config` outbox 触发新快照。OpenSIPS S28 Lua 热路径会解析被叫号码,命中屏蔽城市/省份时跳过当前落地网关并尝试同一线路组下一网关,全部被屏蔽时返回 `NO_VENDOR_ROUTE_REGION_BLOCKED`;成功/失败 CDR Stream 已携带归属地和运营商字段。当前仅完成本地文件,尚未发布 B/A;发布 A 前必须重新加载 Lua、替换 `/etc/opensips/opensips.cfg` 前备份,并在 A 上执行 `opensips -C -f /etc/opensips/opensips.cfg` 后再重启。 +- 2026-06-24 已完成第 6、7 步本地代码基线:CDR Stream 解析兼容新增 `callee_city_code`、`callee_city_name`、`callee_province_name`、`callee_operator`、`callee_number_type`,旧 Stream 消息缺字段时仍可处理;CDR Worker 会把这些字段写入 `raw_cdrs` 号码识别快照列。新增 `GET /api/v2/cdrs` 与 `GET /api/v2/cdrs/:id`,复用 `cdr.view` 权限,支持主叫、被叫、客户网关、落地网关、地级市代码和运营商筛选。Web 话单中心已接入真实 CDR API,列表和详情展示地级市、运营商、号码类型、SIP 状态码和费用字段。 + +第 1、2 步已完成本地代码基线: + +- Prisma Schema 和迁移新增: + - `geo_cities` + - `phone_number_segments` + - `phone_area_codes` + - `carrier_prefix_rules` + - `vendor_gateway_blocked_regions` + - `raw_cdrs` 被叫地级市、省份、运营商、号码类型快照字段 +- API 模块新增 `NumberLibraryModule`: + - `GET /api/v2/number-library/cities` + - `POST /api/v2/number-library/cities/import` + - `GET /api/v2/number-library/phone-segments` + - `POST /api/v2/number-library/phone-segments/import` + - `GET /api/v2/number-library/area-codes` + - `POST /api/v2/number-library/area-codes/import` + - `GET /api/v2/number-library/carrier-prefix-rules` + - `POST /api/v2/number-library/carrier-prefix-rules/import` +- 权限新增: + - `number_library.view` + - `number_library.manage` +- 当前导入接口采用单批 `items` upsert,单次限制 1000 条,适合脚本/后台任务分批调用。80 万号段的真实文件导入、进度表、失败明细和 Redis 发布仍属于后续步骤,不在本次第 1、2 步中直接执行。 + +### 25.6 Codex 新会话启动模板 ```text 请先读取 SOFTSWITCH_PLATFORM_DESIGN_V2.md 和 IMPLEMENTATION_STATUS.md, diff --git a/apps/api/src/modules/active-calls/active-calls.controller.ts b/apps/api/src/modules/active-calls/active-calls.controller.ts new file mode 100644 index 0000000..4b562f8 --- /dev/null +++ b/apps/api/src/modules/active-calls/active-calls.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, Inject, Param, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuditAction } from '../audit/audit.metadata.js'; +import { RequirePermissions } from '../security/security.metadata.js'; +import { ActiveCallsService } from './active-calls.service.js'; + +@ApiTags('active-calls') +@Controller('active-calls') +export class ActiveCallsController { + constructor(@Inject(ActiveCallsService) private readonly activeCallsService: ActiveCallsService) {} + + @Get() + @RequirePermissions('active_calls.view') + list() { + return this.activeCallsService.list(); + } + + @Post(':id/hangup') + @RequirePermissions('active_calls.manage') + @AuditAction({ module: 'active_calls', action: 'hangup', objectType: 'dialog', objectIdParam: 'id' }) + hangup(@Param('id') id: string) { + return this.activeCallsService.hangup(id); + } +} diff --git a/apps/api/src/modules/active-calls/active-calls.module.ts b/apps/api/src/modules/active-calls/active-calls.module.ts new file mode 100644 index 0000000..bc74250 --- /dev/null +++ b/apps/api/src/modules/active-calls/active-calls.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ActiveCallsController } from './active-calls.controller.js'; +import { ActiveCallsService } from './active-calls.service.js'; +import { OpenSipsMiClient } from './opensips-mi.client.js'; + +@Module({ + controllers: [ActiveCallsController], + providers: [ActiveCallsService, OpenSipsMiClient] +}) +export class ActiveCallsModule {} diff --git a/apps/api/src/modules/active-calls/active-calls.service.spec.ts b/apps/api/src/modules/active-calls/active-calls.service.spec.ts new file mode 100644 index 0000000..03e1945 --- /dev/null +++ b/apps/api/src/modules/active-calls/active-calls.service.spec.ts @@ -0,0 +1,103 @@ +import { BadGatewayException, BadRequestException } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; +import { ActiveCallsService, normalizeDialogs } from './active-calls.service.js'; +import type { OpenSipsMiClient } from './opensips-mi.client.js'; + +describe('active calls service', () => { + it('normalizes OpenSIPS dialog list payloads', () => { + const calls = normalizeDialogs( + { + Dialogs: [ + { + ID: '6ae.4b38d013', + callid: 'call-1@lisglosips-t', + from_tag: 'from-a', + to_tag: 'to-b', + from_uri: 'sip:1001@s21.lisglosips.test', + to_uri: 'sip:2001@s21.lisglosips.test', + state: 'confirmed', + start_time: '1782100000' + } + ] + }, + new Date('2026-06-22T08:00:10.000Z') + ); + + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + id: '6ae.4b38d013', + callId: 'call-1@lisglosips-t', + fromTag: 'from-a', + toTag: 'to-b', + caller: 'sip:1001@s21.lisglosips.test', + callee: 'sip:2001@s21.lisglosips.test', + state: 'confirmed' + }); + }); + + it('extracts caller and landing IPs from dialog contacts and SDP', () => { + const calls = normalizeDialogs({ + Dialogs: [ + { + ID: 'fd4.a9a0c987', + callid: 'call-2@lisglosips-t', + from_uri: 'sip:s28-ip-1001@s28.customer.local', + to_uri: 'sip:13800138000@100.90.90.90:15060', + caller_contact: 'sip:s28-ip-1001@100.93.185.30:47200', + caller_sdp: 'v=0\r\nc=IN IP4 100.93.185.30\r\n', + CALLEES: [ + { + callee_sdp: 'v=0\r\nc=IN IP4 192.0.2.80\r\n' + } + ] + } + ] + }); + + expect(calls[0]).toMatchObject({ + callerIp: '100.93.185.30', + landingIp: '192.0.2.80' + }); + }); + + it('requests hangup for safe dialog identifiers', async () => { + const miClient = { + endDialog: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OpenSipsMiClient; + const service = new ActiveCallsService(miClient); + + await expect(service.hangup('call-1%40lisglosips-t')).resolves.toMatchObject({ + dialogId: 'call-1@lisglosips-t', + status: 'HANGUP_REQUESTED' + }); + expect(miClient.endDialog).toHaveBeenCalledWith('call-1@lisglosips-t'); + }); + + it('accepts OpenSIPS operation-failed hangup when the dialog is terminating', async () => { + const miClient = { + endDialog: vi.fn().mockRejectedValue( + new BadGatewayException({ + code: 'ACTIVE_CALLS_MI_ERROR', + message: 'Operation failed', + detail: { code: 500, message: 'Operation failed' } + }) + ), + listDialogs: vi.fn().mockResolvedValue({ + Dialogs: [{ ID: '6ae.4b38d013', callid: 'call-1@lisglosips-t', state: 5 }] + }) + } as unknown as OpenSipsMiClient; + const service = new ActiveCallsService(miClient); + + await expect(service.hangup('6ae.4b38d013')).resolves.toMatchObject({ + dialogId: '6ae.4b38d013', + status: 'HANGUP_IN_PROGRESS', + state: '5' + }); + }); + + it('rejects unsafe dialog identifiers', async () => { + const service = new ActiveCallsService({ endDialog: vi.fn() } as unknown as OpenSipsMiClient); + + await expect(service.hangup('../../etc/passwd')).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/apps/api/src/modules/active-calls/active-calls.service.ts b/apps/api/src/modules/active-calls/active-calls.service.ts new file mode 100644 index 0000000..8501f98 --- /dev/null +++ b/apps/api/src/modules/active-calls/active-calls.service.ts @@ -0,0 +1,282 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { OpenSipsMiClient } from './opensips-mi.client.js'; + +export interface ActiveCallSummary { + id: string; + callId: string; + fromTag: string | null; + toTag: string | null; + caller: string | null; + callee: string | null; + state: string | null; + startedAt: string | null; + durationSec: number | null; + lifetimeSec: number | null; + callerIp: string | null; + landingIp: string | null; + callerContact: string | null; + calleeContact: string | null; + raw: Record; +} + +const SAFE_DIALOG_ID = /^[A-Za-z0-9@._:%+\-=]{1,220}$/; + +@Injectable() +export class ActiveCallsService { + constructor(@Inject(OpenSipsMiClient) private readonly miClient: OpenSipsMiClient) {} + + async list(now = new Date()) { + const result = await this.miClient.listDialogs(); + const calls = normalizeDialogs(result, now); + return { + generatedAt: now.toISOString(), + source: 'opensips-mi', + total: calls.length, + items: calls + }; + } + + async hangup(dialogId: string) { + const normalized = this.dialogId(dialogId); + try { + const result = await this.miClient.endDialog(normalized); + return { + dialogId: normalized, + status: 'HANGUP_REQUESTED', + source: 'opensips-mi', + result + }; + } catch (error) { + if (!isOpenSipsOperationFailed(error)) { + throw error; + } + + await sleep(800); + const calls = normalizeDialogs(await this.miClient.listDialogs()); + const current = calls.find((call) => call.id === normalized || call.callId === normalized); + if (!current) { + return { + dialogId: normalized, + status: 'HANGUP_CONFIRMED', + source: 'opensips-mi' + }; + } + if (current.state === '5') { + return { + dialogId: normalized, + status: 'HANGUP_IN_PROGRESS', + source: 'opensips-mi', + state: current.state + }; + } + + throw error; + } + } + + private dialogId(value: string): string { + const decoded = decodeURIComponent(value).trim(); + if (!SAFE_DIALOG_ID.test(decoded)) { + throw new BadRequestException({ + code: 'ACTIVE_CALL_ID_INVALID', + message: 'Active call id is invalid.' + }); + } + return decoded; + } +} + +function isOpenSipsOperationFailed(error: unknown): boolean { + if (!error || typeof error !== 'object' || !('getResponse' in error) || typeof error.getResponse !== 'function') { + return false; + } + const response = error.getResponse() as unknown; + if (!response || typeof response !== 'object') { + return false; + } + const record = response as { code?: unknown; detail?: { code?: unknown; message?: unknown } }; + return record.code === 'ACTIVE_CALLS_MI_ERROR' && record.detail?.code === 500 && record.detail.message === 'Operation failed'; +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function normalizeDialogs(result: unknown, now = new Date()): ActiveCallSummary[] { + const records = collectDialogRecords(result); + return records + .map((record) => toSummary(record, now)) + .filter((call): call is ActiveCallSummary => call !== null) + .sort((left, right) => (right.durationSec ?? 0) - (left.durationSec ?? 0)); +} + +function collectDialogRecords(value: unknown): Record[] { + if (Array.isArray(value)) { + return value.flatMap((item) => collectDialogRecords(item)); + } + if (!isRecord(value)) { + return []; + } + + if (looksLikeDialog(value)) { + return [value]; + } + + return Object.values(value).flatMap((item) => collectDialogRecords(item)); +} + +function looksLikeDialog(record: Record): boolean { + const keys = Object.keys(record).map((key) => key.toLowerCase().replaceAll(/[^a-z0-9]/g, '')); + return keys.some((key) => key === 'id' || key === 'dialogid' || key === 'dlgdid' || key === 'did') && keys.some((key) => key === 'callid'); +} + +function toSummary(record: Record, now: Date): ActiveCallSummary | null { + const callId = stringField(record, ['callid', 'call_id', 'Call-ID', 'call-id']); + const id = stringField(record, ['ID', 'id', 'dialog_id', 'dialogId', 'dlg_id', 'dlgId', 'DID', 'did']) ?? callId; + if (!id || !callId) { + return null; + } + + const createdRaw = stringField(record, ['start_time', 'startTime', 'created', 'creation_time', 'init_ts', 'timestamp']); + const startedAt = normalizeTime(createdRaw); + const lifetimeSec = numberField(record, ['lifetime', 'duration', 'timeout']); + const durationSec = startedAt ? Math.max(0, Math.floor((now.getTime() - new Date(startedAt).getTime()) / 1000)) : lifetimeSec; + const calleeRecord = firstRecordField(record, ['CALLEES', 'callees', 'callee']); + const callerContact = stringField(record, ['caller_contact', 'from_contact', 'fromContact']); + const calleeContact = + stringField(record, ['callee_contact', 'to_contact', 'toContact']) ?? (calleeRecord ? stringField(calleeRecord, ['callee_contact', 'to_contact', 'toContact']) : null); + const callerSdp = stringField(record, ['caller_sdp', 'callerSdp', 'from_sdp', 'sdp']); + const calleeSdp = + stringField(record, ['callee_sdp', 'calleeSdp', 'to_sdp']) ?? (calleeRecord ? stringField(calleeRecord, ['callee_sdp', 'calleeSdp', 'to_sdp', 'sdp']) : null); + + return { + id, + callId, + fromTag: stringField(record, ['from_tag', 'fromtag', 'fromTag']), + toTag: stringField(record, ['to_tag', 'totag', 'toTag']), + caller: stringField(record, ['from_uri', 'fromUri', 'caller', 'caller_uri']), + callee: stringField(record, ['to_uri', 'toUri', 'callee', 'callee_uri']), + state: stringField(record, ['state', 'status']), + startedAt, + durationSec, + lifetimeSec, + callerIp: firstNonNull([ + hostFromSipAddress(callerContact), + ipFromSdp(callerSdp), + ipFromSipAddress(stringField(record, ['from_uri', 'fromUri', 'caller', 'caller_uri'])), + ipFromSipAddress(stringField(record, ['caller_bind_addr', 'from_bind_addr'])) + ]), + landingIp: firstNonNull([ + hostFromSipAddress(calleeContact), + ipFromSdp(calleeSdp), + ipFromSipAddress(stringField(record, ['to_uri', 'toUri', 'callee', 'callee_uri'])), + ipFromSipAddress(stringField(record, ['callee_bind_addr', 'to_bind_addr']) ?? (calleeRecord ? stringField(calleeRecord, ['callee_bind_addr', 'to_bind_addr']) : null)) + ]), + callerContact, + calleeContact, + raw: record + }; +} + +function stringField(record: Record, names: string[]): string | null { + for (const name of names) { + const value = findCaseInsensitive(record, name); + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + } + return null; +} + +function numberField(record: Record, names: string[]): number | null { + for (const name of names) { + const value = findCaseInsensitive(record, name); + const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number.parseInt(value, 10) : NaN; + if (Number.isSafeInteger(parsed) && parsed >= 0) { + return parsed; + } + } + return null; +} + +function findCaseInsensitive(record: Record, name: string): unknown { + const wanted = normalizeKey(name); + const found = Object.entries(record).find(([key]) => normalizeKey(key) === wanted); + return found?.[1]; +} + +function firstRecordField(record: Record, names: string[]): Record | null { + for (const name of names) { + const value = findCaseInsensitive(record, name); + if (isRecord(value)) { + return value; + } + if (Array.isArray(value)) { + const found = value.find(isRecord); + if (found) { + return found; + } + } + } + return null; +} + +function firstNonNull(values: Array): string | null { + return values.find((value): value is string => !!value) ?? null; +} + +function hostFromSipAddress(value: string | null): string | null { + if (!value) { + return null; + } + const match = value.match(/^(?:sips?:)?(?:[^@;\s]+@)?\[?([A-Fa-f0-9:.]+)\]?/); + return match ? normalizeIp(match[1]) : null; +} + +function ipFromSipAddress(value: string | null): string | null { + return normalizeIp(hostFromSipAddress(value)); +} + +function ipFromSdp(value: string | null): string | null { + if (!value) { + return null; + } + const match = value.match(/^c=IN IP[46] ([^\r\n\s]+)/m); + return match ? normalizeIp(match[1]) : null; +} + +function normalizeIp(value: string | null): string | null { + if (!value) { + return null; + } + const trimmed = value.trim().replace(/^\[/, '').replace(/\]$/, ''); + if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(trimmed) || /^[A-Fa-f0-9:]+$/.test(trimmed)) { + return trimmed; + } + return null; +} + +function normalizeKey(value: string): string { + return value.toLowerCase().replaceAll(/[^a-z0-9]/g, ''); +} + +function normalizeTime(value: string | null): string | null { + if (!value) { + return null; + } + if (/^\d+$/.test(value)) { + const seconds = Number.parseInt(value, 10); + if (Number.isSafeInteger(seconds) && seconds > 0) { + return new Date(seconds * 1000).toISOString(); + } + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} diff --git a/apps/api/src/modules/active-calls/opensips-mi.client.ts b/apps/api/src/modules/active-calls/opensips-mi.client.ts new file mode 100644 index 0000000..752fb39 --- /dev/null +++ b/apps/api/src/modules/active-calls/opensips-mi.client.ts @@ -0,0 +1,108 @@ +import { BadGatewayException, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { spawn } from 'node:child_process'; +import type { RuntimeConfig } from '../../shared/config.js'; + +export interface MiResponse { + jsonrpc?: string; + result?: T; + error?: { + code?: number; + message?: string; + }; + id?: number | string | null; +} + +export interface CommandExecutor { + run(args: string[], timeoutMs: number): Promise; +} + +@Injectable() +export class SshCommandExecutor implements CommandExecutor { + run(args: string[], timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + const timer = setTimeout(() => { + child.kill('SIGTERM'); + reject(new Error(`ssh command timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + child.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + child.on('close', (code) => { + clearTimeout(timer); + if (code === 0) { + resolve(Buffer.concat(stdout).toString('utf8')); + return; + } + reject(new Error(Buffer.concat(stderr).toString('utf8') || `ssh exited with ${code}`)); + }); + }); + } +} + +@Injectable() +export class OpenSipsMiClient { + private readonly executor: CommandExecutor = new SshCommandExecutor(); + + constructor(private readonly configService: ConfigService) {} + + async listDialogs(): Promise { + return this.call('dlg_list'); + } + + async endDialog(dialogId: string): Promise { + return this.call('dlg_end_dlg', [dialogId]); + } + + private async call(method: string, params: string[] = []): Promise { + const config = this.configService.get('activeCalls', { infer: true }); + const remoteCommand = [config.remoteCommand, method, ...params].map(shellQuote).join(' '); + const args = []; + if (config.sshConfig) { + args.push('-F', config.sshConfig); + } + args.push(config.sshHost, remoteCommand); + + let output: string; + try { + output = await this.executor.run(args, config.timeoutMs); + } catch (error) { + throw new BadGatewayException({ + code: 'ACTIVE_CALLS_MI_UNAVAILABLE', + message: 'OpenSIPS control plane is unavailable.', + detail: error instanceof Error ? error.message : String(error) + }); + } + + let response: MiResponse; + try { + response = JSON.parse(output) as MiResponse; + } catch { + throw new BadGatewayException({ + code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE', + message: 'OpenSIPS control plane returned invalid JSON.' + }); + } + + if (response.error) { + throw new BadGatewayException({ + code: 'ACTIVE_CALLS_MI_ERROR', + message: response.error.message ?? 'OpenSIPS MI command failed.', + detail: response.error + }); + } + + return response.result; + } +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/apps/api/src/modules/app.module.ts b/apps/api/src/modules/app.module.ts index 1b6e44a..c49ac96 100644 --- a/apps/api/src/modules/app.module.ts +++ b/apps/api/src/modules/app.module.ts @@ -7,7 +7,9 @@ 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 { ActiveCallsModule } from './active-calls/active-calls.module.js'; import { AuthModule } from './auth/auth.module.js'; +import { CdrsModule } from './cdrs/cdrs.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'; @@ -15,6 +17,7 @@ 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 { NumberLibraryModule } from './number-library/number-library.module.js'; import { RechargesModule } from './recharges/recharges.module.js'; import { RecordingsModule } from './recordings/recordings.module.js'; import { QualityModule } from './quality/quality.module.js'; @@ -55,6 +58,8 @@ import { VendorsModule } from './vendors/vendors.module.js'; SecurityModule, AuditModule, AuthModule, + ActiveCallsModule, + CdrsModule, DashboardModule, CustomersModule, CustomerGatewaysModule, @@ -63,6 +68,7 @@ import { VendorsModule } from './vendors/vendors.module.js'; VendorsModule, VendorGatewaysModule, LandingLineGroupsModule, + NumberLibraryModule, QualityModule, RecordingsModule, UsersModule, diff --git a/apps/api/src/modules/auth/auth.controller.ts b/apps/api/src/modules/auth/auth.controller.ts index 8198d85..261b204 100644 --- a/apps/api/src/modules/auth/auth.controller.ts +++ b/apps/api/src/modules/auth/auth.controller.ts @@ -1,26 +1,50 @@ -import { Body, Controller, HttpCode, Inject, Post, Req, Res, UnauthorizedException } from '@nestjs/common'; +import { Body, Controller, Get, 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 { CaptchaService } from './captcha.service.js'; import { parseCookie, serializeCookie } from './cookie.js'; interface LoginBody { username?: unknown; password?: unknown; + captchaId?: unknown; + captchaCode?: unknown; } @ApiTags('auth') @Public() @Controller('auth') export class AuthController { - constructor(@Inject(AuthService) private readonly authService: AuthService) {} + constructor( + @Inject(AuthService) private readonly authService: AuthService, + @Inject(CaptchaService) private readonly captchaService: CaptchaService + ) {} + + @Get('captcha') + @ApiOperation({ summary: 'Create a one-time image captcha for login' }) + captcha() { + const challenge = this.captchaService.createChallenge(); + + return { + captchaId: challenge.id, + imageDataUrl: challenge.imageDataUrl, + expiresAt: challenge.expiresAt.toISOString() + }; + } @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 { username, password, captchaId, captchaCode } = this.readCredentials(body); + if (!this.captchaService.verify(captchaId, captchaCode)) { + throw new UnauthorizedException({ + code: 'AUTH_CAPTCHA_INVALID', + message: 'Captcha verification failed.' + }); + } const result = await this.authService.login(username, password, this.requestContext(request)); this.setRefreshCookie(reply, result.tokens.refreshToken, result.tokens.refreshMaxAgeSeconds); @@ -56,8 +80,17 @@ export class AuthController { ); } - private readCredentials(body: LoginBody): { username: string; password: string } { - if (typeof body.username !== 'string' || typeof body.password !== 'string' || !body.username.trim() || !body.password) { + private readCredentials(body: LoginBody): { username: string; password: string; captchaId: string; captchaCode: string } { + if ( + typeof body.username !== 'string' || + typeof body.password !== 'string' || + typeof body.captchaId !== 'string' || + typeof body.captchaCode !== 'string' || + !body.username.trim() || + !body.password || + !body.captchaId || + !body.captchaCode.trim() + ) { throw new UnauthorizedException({ code: 'AUTH_INVALID_CREDENTIALS', message: 'Invalid username or password.' @@ -66,7 +99,9 @@ export class AuthController { return { username: body.username, - password: body.password + password: body.password, + captchaId: body.captchaId, + captchaCode: body.captchaCode }; } diff --git a/apps/api/src/modules/auth/auth.e2e.spec.ts b/apps/api/src/modules/auth/auth.e2e.spec.ts index c402fce..ad7a9a7 100644 --- a/apps/api/src/modules/auth/auth.e2e.spec.ts +++ b/apps/api/src/modules/auth/auth.e2e.spec.ts @@ -5,6 +5,7 @@ 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 { CaptchaService } from './captcha.service.js'; import { AUTH_REPOSITORY, type AuthRepository, type AuthSessionRecord, type AuthUserRecord, type CreateSessionInput } from './auth.types.js'; class E2eAuthRepository implements AuthRepository { @@ -77,6 +78,7 @@ class E2eAuthRepository implements AuthRepository { describe('LisgloSIPS Auth API', () => { let app: NestFastifyApplication; let repo: E2eAuthRepository; + let captchaService: CaptchaService; let secret: string; beforeAll(async () => { @@ -101,6 +103,7 @@ describe('LisgloSIPS Auth API', () => { app.setGlobalPrefix('api/v2'); await app.init(); await app.getHttpAdapter().getInstance().ready(); + captchaService = app.get(CaptchaService); }); afterAll(async () => { @@ -108,9 +111,20 @@ describe('LisgloSIPS Auth API', () => { }); it('logs in, refreshes with cookie rotation, and logs out', async () => { - const login = await request(app.getHttpServer()) + const publicCaptcha = await request(app.getHttpServer()).get('/api/v2/auth/captcha').expect(200); + expect(publicCaptcha.body.captchaId).toBeTypeOf('string'); + expect(publicCaptcha.body.imageDataUrl).toMatch(/^data:image\/svg\+xml;base64,/); + expect(publicCaptcha.body.answer).toBeUndefined(); + + await request(app.getHttpServer()) .post('/api/v2/auth/login') .send({ username: 'operator', password: secret }) + .expect(401); + + const captcha = captchaService.createChallenge(); + const login = await request(app.getHttpServer()) + .post('/api/v2/auth/login') + .send({ username: 'operator', password: secret, captchaId: captcha.id, captchaCode: captcha.answer }) .expect(200); const loginCookie = login.headers['set-cookie'][0]; const firstRefreshToken = /lisglosips_refresh=([^;]+)/.exec(loginCookie)?.[1] ?? ''; diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts index bec72b1..b0248cd 100644 --- a/apps/api/src/modules/auth/auth.module.ts +++ b/apps/api/src/modules/auth/auth.module.ts @@ -2,18 +2,20 @@ import { Module } from '@nestjs/common'; import { AuthController } from './auth.controller.js'; import { PrismaAuthRepository } from './auth.repository.js'; import { AuthService } from './auth.service.js'; +import { CaptchaService } from './captcha.service.js'; import { AUTH_REPOSITORY } from './auth.types.js'; @Module({ controllers: [AuthController], providers: [ AuthService, + CaptchaService, PrismaAuthRepository, { provide: AUTH_REPOSITORY, useExisting: PrismaAuthRepository } ], - exports: [AuthService] + exports: [AuthService, CaptchaService] }) export class AuthModule {} diff --git a/apps/api/src/modules/auth/auth.service.spec.ts b/apps/api/src/modules/auth/auth.service.spec.ts index 1b63c0e..31c3d15 100644 --- a/apps/api/src/modules/auth/auth.service.spec.ts +++ b/apps/api/src/modules/auth/auth.service.spec.ts @@ -77,16 +77,19 @@ class MemoryAuthRepository implements AuthRepository { } } -function config() { +function config(overrides: Record = {}) { const values = new Map([ ['auth.accessTokenSecret', 'test-only-access-token-secret-min-32-bytes'], ['auth.accessTokenTtlSeconds', 900], ['auth.refreshTokenTtlDays', 7], ['auth.lockMaxFailures', 3], ['auth.lockWindowSeconds', 60], + ['auth.loginThrottleMaxFailures', 10], + ['auth.loginThrottleWindowSeconds', 300], ['auth.tokenIssuer', 'lisglosips-api'], ['auth.tokenAudience', 'lisglosips-web'] ]); + Object.entries(overrides).forEach(([key, value]) => values.set(key, value)); return { get: (key: string) => values.get(key) @@ -124,6 +127,15 @@ describe('AuthService', () => { await expect(service.login('operator', secret)).rejects.toMatchObject({ status: 401 }); }); + it('throttles repeated login attempts even when the username is unknown', async () => { + service = new AuthService(repo, config({ 'auth.loginThrottleMaxFailures': 3 }) as never); + + await expect(service.login('missing', crypto.randomUUID(), { ip: '10.0.0.10' })).rejects.toMatchObject({ status: 401 }); + await expect(service.login('missing', crypto.randomUUID(), { ip: '10.0.0.10' })).rejects.toMatchObject({ status: 401 }); + await expect(service.login('missing', crypto.randomUUID(), { ip: '10.0.0.10' })).rejects.toMatchObject({ status: 401 }); + await expect(service.login('missing', crypto.randomUUID(), { ip: '10.0.0.10' })).rejects.toMatchObject({ status: 429 }); + }); + it('rotates refresh sessions and rejects reuse', async () => { const login = await service.login('operator', secret); const refresh = await service.refresh(login.tokens.refreshToken); diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index 322b8ca..fc862c7 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { HttpException, HttpStatus, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ACCESS_TOKEN_TYPE, @@ -36,8 +36,15 @@ export interface AuthResponse { }; } +interface LoginThrottleRecord { + count: number; + resetAt: number; +} + @Injectable() export class AuthService { + private readonly loginThrottle = new Map(); + constructor( @Inject(AUTH_REPOSITORY) private readonly repository: AuthRepository, @Inject(ConfigService) private readonly config: ConfigService @@ -45,23 +52,30 @@ export class AuthService { 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(); + this.assertLoginAllowed(normalizedUsername, context.ip, now); + + const user = await this.repository.findUserByUsername(normalizedUsername); + if (!user) { + this.registerLoginThrottleFailure(normalizedUsername, context.ip, now); throw this.invalidCredentials(); } if (this.isLocked(user, now) || user.status !== 'ENABLED' || !user.passwordHash || user.passwordAlgo !== PASSWORD_ALGO_ARGON2ID) { await this.registerFailure(user, now); + this.registerLoginThrottleFailure(normalizedUsername, context.ip, now); throw this.invalidCredentials(); } if (!(await verifyPasswordArgon2id(password, user.passwordHash))) { await this.registerFailure(user, now); + this.registerLoginThrottleFailure(normalizedUsername, context.ip, now); throw this.invalidCredentials(); } + this.clearLoginThrottleForUser(normalizedUsername); await this.repository.markLoginSuccess(user.id, context.ip); const tokens = await this.issueTokens(user, context); @@ -175,6 +189,53 @@ export class AuthService { return Boolean(user.lockedUntil && user.lockedUntil > now); } + private assertLoginAllowed(username: string, ip: string | undefined, now: Date): void { + const maxFailures = this.config.get('auth.loginThrottleMaxFailures', { infer: true }); + const nowMs = now.getTime(); + + for (const key of this.throttleKeys(username, ip)) { + const record = this.loginThrottle.get(key); + if (!record) { + continue; + } + if (record.resetAt <= nowMs) { + this.loginThrottle.delete(key); + continue; + } + if (record.count >= maxFailures) { + throw new HttpException( + { + code: 'AUTH_LOGIN_THROTTLED', + message: 'Too many login attempts. Please try again later.' + }, + HttpStatus.TOO_MANY_REQUESTS + ); + } + } + } + + private registerLoginThrottleFailure(username: string, ip: string | undefined, now: Date): void { + const windowSeconds = this.config.get('auth.loginThrottleWindowSeconds', { infer: true }); + const resetAt = now.getTime() + windowSeconds * 1000; + + for (const key of this.throttleKeys(username, ip)) { + const record = this.loginThrottle.get(key); + if (!record || record.resetAt <= now.getTime()) { + this.loginThrottle.set(key, { count: 1, resetAt }); + } else { + this.loginThrottle.set(key, { count: record.count + 1, resetAt: record.resetAt }); + } + } + } + + private clearLoginThrottleForUser(username: string): void { + this.loginThrottle.delete(`user:${username}`); + } + + private throttleKeys(username: string, ip: string | undefined): string[] { + return [`user:${username}`, `ip:${ip || 'unknown'}`]; + } + private invalidCredentials(): UnauthorizedException { return new UnauthorizedException({ code: 'AUTH_INVALID_CREDENTIALS', diff --git a/apps/api/src/modules/auth/captcha.service.ts b/apps/api/src/modules/auth/captcha.service.ts new file mode 100644 index 0000000..394f050 --- /dev/null +++ b/apps/api/src/modules/auth/captcha.service.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@nestjs/common'; +import crypto from 'node:crypto'; + +export interface CaptchaChallenge { + id: string; + answer: string; + imageDataUrl: string; + expiresAt: Date; +} + +interface StoredCaptcha { + answer: string; + expiresAt: number; +} + +const CAPTCHA_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; +const CAPTCHA_LENGTH = 5; +const CAPTCHA_TTL_MS = 5 * 60 * 1000; + +@Injectable() +export class CaptchaService { + private readonly challenges = new Map(); + + createChallenge(now = new Date()): CaptchaChallenge { + this.cleanup(now.getTime()); + const id = crypto.randomUUID(); + const answer = this.randomAnswer(); + const expiresAt = new Date(now.getTime() + CAPTCHA_TTL_MS); + + this.challenges.set(id, { + answer, + expiresAt: expiresAt.getTime() + }); + + return { + id, + answer, + expiresAt, + imageDataUrl: this.renderSvgDataUrl(answer, id) + }; + } + + verify(id: string, answer: string, now = new Date()): boolean { + this.cleanup(now.getTime()); + const challenge = this.challenges.get(id); + this.challenges.delete(id); + + if (!challenge || challenge.expiresAt <= now.getTime()) { + return false; + } + + const normalized = answer.trim().toUpperCase(); + return normalized.length === challenge.answer.length && crypto.timingSafeEqual(Buffer.from(normalized), Buffer.from(challenge.answer)); + } + + private randomAnswer(): string { + let answer = ''; + for (let index = 0; index < CAPTCHA_LENGTH; index += 1) { + answer += CAPTCHA_ALPHABET[crypto.randomInt(0, CAPTCHA_ALPHABET.length)]; + } + return answer; + } + + private renderSvgDataUrl(answer: string, id: string): string { + const shortId = id.replaceAll('-', '').slice(0, 8); + const noise = Array.from({ length: 8 }, (_, index) => { + const x1 = crypto.randomInt(0, 132); + const y1 = crypto.randomInt(8, 42); + const x2 = crypto.randomInt(0, 132); + const y2 = crypto.randomInt(8, 42); + const opacity = index % 2 === 0 ? '0.24' : '0.16'; + return ``; + }).join(''); + const chars = answer.split('').map((char, index) => { + const x = 18 + index * 21; + const y = 32 + crypto.randomInt(-3, 4); + const rotate = crypto.randomInt(-12, 13); + return `${char}`; + }).join(''); + const dots = Array.from({ length: 20 }, () => { + const cx = crypto.randomInt(4, 128); + const cy = crypto.randomInt(5, 43); + return ``; + }).join(''); + const svg = ` + + ${noise} + ${dots} + ${chars} + `; + + return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`; + } + + private cleanup(nowMs: number): void { + for (const [id, challenge] of this.challenges.entries()) { + if (challenge.expiresAt <= nowMs) { + this.challenges.delete(id); + } + } + } +} diff --git a/apps/api/src/modules/cdrs/cdrs.controller.ts b/apps/api/src/modules/cdrs/cdrs.controller.ts new file mode 100644 index 0000000..16b05d9 --- /dev/null +++ b/apps/api/src/modules/cdrs/cdrs.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Inject, Param, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { RequirePermissions } from '../security/security.metadata.js'; +import { CdrsService } from './cdrs.service.js'; + +@ApiTags('cdrs') +@Controller('cdrs') +export class CdrsController { + constructor(@Inject(CdrsService) private readonly cdrsService: CdrsService) {} + + @Get() + @RequirePermissions('cdr.view') + list(@Query() query: Record) { + return this.cdrsService.list(query); + } + + @Get(':id') + @RequirePermissions('cdr.view') + get(@Param('id') id: string) { + return this.cdrsService.get(id); + } +} diff --git a/apps/api/src/modules/cdrs/cdrs.module.ts b/apps/api/src/modules/cdrs/cdrs.module.ts new file mode 100644 index 0000000..5078adb --- /dev/null +++ b/apps/api/src/modules/cdrs/cdrs.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { CdrsController } from './cdrs.controller.js'; +import { CdrsService } from './cdrs.service.js'; +import { CDRS_REPOSITORY, PrismaCdrsRepository } from './cdrs.repository.js'; + +@Module({ + controllers: [CdrsController], + providers: [ + CdrsService, + PrismaCdrsRepository, + { + provide: CDRS_REPOSITORY, + useExisting: PrismaCdrsRepository + } + ] +}) +export class CdrsModule {} diff --git a/apps/api/src/modules/cdrs/cdrs.repository.ts b/apps/api/src/modules/cdrs/cdrs.repository.ts new file mode 100644 index 0000000..8e42333 --- /dev/null +++ b/apps/api/src/modules/cdrs/cdrs.repository.ts @@ -0,0 +1,167 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@lisglosips/database'; +import { PrismaService } from '../database/prisma.service.js'; + +export type CdrCarrier = 'MOBILE' | 'UNICOM' | 'TELECOM' | 'BROADCAST' | 'MVNO' | 'UNKNOWN'; + +export interface CdrQuery { + caller?: string; + callee?: string; + customerGatewayId?: string; + vendorGatewayId?: string; + cityCode?: string; + carrier?: CdrCarrier; + take: number; + skip: number; +} + +export interface CdrListItem { + id: string; + eventId: string; + callId: string; + sourceIp: string | null; + caller: string; + callee: string; + calleeCityCode: string | null; + calleeCityName: string | null; + calleeProvinceName: string | null; + calleeOperator: CdrCarrier; + calleeNumberType: string; + customerId: string | null; + customerName: string | null; + customerGatewayId: string | null; + customerGatewayName: string | null; + vendorId: string | null; + vendorName: string | null; + vendorGatewayId: string | null; + vendorGatewayName: string | null; + vendorGatewayHost: string | null; + vendorGatewayPort: number | null; + lineGroupId: string | null; + lineGroupName: string | null; + startedAt: Date; + answeredAt: Date | null; + endedAt: Date; + durationSec: number; + sipCode: number; + hangupReason: string | null; + recordingKey: string | null; + configVersion: number | null; + ratingStatus: string; + customerFee: string | null; + vendorCost: string | null; + grossProfit: string | null; + billSec: number | null; +} + +export interface CdrsRepository { + list(query: CdrQuery): Promise<{ items: CdrListItem[]; total: number }>; + get(id: string): Promise; +} + +export const CDRS_REPOSITORY = Symbol('CDRS_REPOSITORY'); + +type RawCdrRecord = Prisma.RawCdrGetPayload<{ + include: { + customer: { select: { name: true } }; + customerGateway: { select: { name: true } }; + vendor: { select: { name: true } }; + vendorGateway: { select: { name: true; host: true; port: true } }; + lineGroup: { select: { name: true } }; + ratedCdr: { select: { customerFee: true; vendorCost: true; grossProfit: true; billSec: true } }; + }; +}>; + +@Injectable() +export class PrismaCdrsRepository implements CdrsRepository { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async list(query: CdrQuery): Promise<{ items: CdrListItem[]; total: number }> { + const where = this.where(query); + const [items, total] = await this.prisma.$transaction([ + this.prisma.rawCdr.findMany({ + where, + orderBy: [{ startedAt: 'desc' }], + take: query.take, + skip: query.skip, + include: this.includeCdr() + }), + this.prisma.rawCdr.count({ where }) + ]); + return { items: items.map((item) => this.toItem(item)), total }; + } + + async get(id: string): Promise { + const item = await this.prisma.rawCdr.findUnique({ + where: { id }, + include: this.includeCdr() + }); + if (!item) { + throw new NotFoundException({ code: 'CDR_NOT_FOUND', message: 'CDR not found.' }); + } + return this.toItem(item); + } + + private where(query: CdrQuery): Prisma.RawCdrWhereInput { + return { + caller: query.caller ? { contains: query.caller } : undefined, + callee: query.callee ? { contains: query.callee } : undefined, + customerGatewayId: query.customerGatewayId, + vendorGatewayId: query.vendorGatewayId, + calleeCityCode: query.cityCode, + calleeOperator: query.carrier + }; + } + + private includeCdr() { + return { + customer: { select: { name: true } }, + customerGateway: { select: { name: true } }, + vendor: { select: { name: true } }, + vendorGateway: { select: { name: true, host: true, port: true } }, + lineGroup: { select: { name: true } }, + ratedCdr: { select: { customerFee: true, vendorCost: true, grossProfit: true, billSec: true } } + } satisfies Prisma.RawCdrInclude; + } + + private toItem(item: RawCdrRecord): CdrListItem { + return { + id: item.id, + eventId: item.eventId, + callId: item.callId, + sourceIp: item.sourceIp, + caller: item.caller, + callee: item.callee, + calleeCityCode: item.calleeCityCode, + calleeCityName: item.calleeCityName, + calleeProvinceName: item.calleeProvinceName, + calleeOperator: item.calleeOperator, + calleeNumberType: item.calleeNumberType, + customerId: item.customerId, + customerName: item.customer?.name ?? null, + customerGatewayId: item.customerGatewayId, + customerGatewayName: item.customerGateway?.name ?? null, + vendorId: item.vendorId, + vendorName: item.vendor?.name ?? null, + vendorGatewayId: item.vendorGatewayId, + vendorGatewayName: item.vendorGateway?.name ?? null, + vendorGatewayHost: item.vendorGateway?.host ?? null, + vendorGatewayPort: item.vendorGateway?.port ?? null, + lineGroupId: item.lineGroupId, + lineGroupName: item.lineGroup?.name ?? null, + startedAt: item.startedAt, + answeredAt: item.answeredAt, + endedAt: item.endedAt, + durationSec: item.durationSec, + sipCode: item.sipCode, + hangupReason: item.hangupReason, + recordingKey: item.recordingKey, + configVersion: item.configVersion, + ratingStatus: item.ratingStatus, + customerFee: item.ratedCdr?.customerFee.toFixed(6) ?? null, + vendorCost: item.ratedCdr?.vendorCost.toFixed(6) ?? null, + grossProfit: item.ratedCdr?.grossProfit.toFixed(6) ?? null, + billSec: item.ratedCdr?.billSec ?? null + }; + } +} diff --git a/apps/api/src/modules/cdrs/cdrs.service.spec.ts b/apps/api/src/modules/cdrs/cdrs.service.spec.ts new file mode 100644 index 0000000..181a962 --- /dev/null +++ b/apps/api/src/modules/cdrs/cdrs.service.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { BadRequestException } from '@nestjs/common'; +import { CdrsService } from './cdrs.service.js'; +import type { CdrListItem, CdrQuery, CdrsRepository } from './cdrs.repository.js'; + +class MemoryCdrsRepository implements CdrsRepository { + lastQuery: CdrQuery | null = null; + + async list(query: CdrQuery) { + this.lastQuery = query; + return { items: [], total: 0 }; + } + + async get(id: string): Promise { + return { + id, + eventId: 'evt_1', + callId: 'call_1', + sourceIp: null, + caller: '1001', + callee: '13800138000', + calleeCityCode: '340100', + calleeCityName: '合肥市', + calleeProvinceName: '安徽省', + calleeOperator: 'MOBILE', + calleeNumberType: 'MOBILE', + customerId: null, + customerName: null, + customerGatewayId: null, + customerGatewayName: null, + vendorId: null, + vendorName: null, + vendorGatewayId: null, + vendorGatewayName: null, + vendorGatewayHost: null, + vendorGatewayPort: null, + lineGroupId: null, + lineGroupName: null, + startedAt: new Date(), + answeredAt: null, + endedAt: new Date(), + durationSec: 0, + sipCode: 503, + hangupReason: 'NO_VENDOR_ROUTE_REGION_BLOCKED', + recordingKey: null, + configVersion: null, + ratingStatus: 'SKIPPED', + customerFee: null, + vendorCost: null, + grossProfit: null, + billSec: null + }; + } +} + +describe('CDR service', () => { + it('normalizes filters for city and carrier queries', async () => { + const repository = new MemoryCdrsRepository(); + const service = new CdrsService(repository); + + await service.list({ caller: '1001', cityCode: '340100', carrier: 'MOBILE', take: '20', skip: '5' }); + + expect(repository.lastQuery).toMatchObject({ + caller: '1001', + cityCode: '340100', + carrier: 'MOBILE', + take: 20, + skip: 5 + }); + }); + + it('rejects invalid carrier filters', async () => { + const service = new CdrsService(new MemoryCdrsRepository()); + + expect(() => service.list({ carrier: 'BAD' })).toThrow(BadRequestException); + }); +}); diff --git a/apps/api/src/modules/cdrs/cdrs.service.ts b/apps/api/src/modules/cdrs/cdrs.service.ts new file mode 100644 index 0000000..242ba95 --- /dev/null +++ b/apps/api/src/modules/cdrs/cdrs.service.ts @@ -0,0 +1,55 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { CDRS_REPOSITORY, type CdrCarrier, type CdrQuery, type CdrsRepository } from './cdrs.repository.js'; + +@Injectable() +export class CdrsService { + constructor(@Inject(CDRS_REPOSITORY) private readonly cdrs: CdrsRepository) {} + + list(rawQuery: Record) { + const query: CdrQuery = { + caller: this.optionalString(rawQuery.caller, 64), + callee: this.optionalString(rawQuery.callee, 64), + customerGatewayId: this.optionalString(rawQuery.customerGatewayId, 32), + vendorGatewayId: this.optionalString(rawQuery.vendorGatewayId, 32), + cityCode: this.optionalString(rawQuery.cityCode, 12), + carrier: rawQuery.carrier === undefined ? undefined : this.carrier(rawQuery.carrier), + take: this.int(rawQuery.take, 100, 1, 500), + skip: this.int(rawQuery.skip, 0, 0, 1_000_000) + }; + return this.cdrs.list(query); + } + + get(id: string) { + return this.cdrs.get(id); + } + + private optionalString(value: unknown, maxLength: number): string | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + if (typeof value !== 'string') { + throw new BadRequestException({ code: 'QUERY_INVALID', message: 'Query parameter is invalid.' }); + } + const trimmed = value.trim(); + if (!trimmed || trimmed.length > maxLength) { + throw new BadRequestException({ code: 'QUERY_INVALID', message: 'Query parameter is invalid.' }); + } + return trimmed; + } + + private carrier(value: unknown): CdrCarrier { + if (value === 'MOBILE' || value === 'UNICOM' || value === 'TELECOM' || value === 'BROADCAST' || value === 'MVNO' || value === 'UNKNOWN') { + return value; + } + throw new BadRequestException({ code: 'CARRIER_INVALID', message: 'Carrier is invalid.' }); + } + + private int(value: unknown, defaultValue: number, min: number, max: number): number { + if (value === undefined) return defaultValue; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new BadRequestException({ code: 'QUERY_INVALID', message: 'Query parameter is invalid.' }); + } + return parsed; + } +} diff --git a/apps/api/src/modules/customer-gateways/customer-gateways.controller.ts b/apps/api/src/modules/customer-gateways/customer-gateways.controller.ts index 3254355..188727a 100644 --- a/apps/api/src/modules/customer-gateways/customer-gateways.controller.ts +++ b/apps/api/src/modules/customer-gateways/customer-gateways.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, 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'; @@ -48,4 +48,11 @@ export class CustomerGatewaysController { disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { return this.customerGatewaysService.disable(id, currentUser?.id); } + + @Delete(':id') + @RequirePermissions('customer_gateways.manage') + @AuditAction({ module: 'customer_gateways', action: 'delete', objectType: 'customer_gateway', objectIdParam: 'id' }) + remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.customerGatewaysService.remove(id, currentUser?.id); + } } diff --git a/apps/api/src/modules/customer-gateways/customer-gateways.e2e.spec.ts b/apps/api/src/modules/customer-gateways/customer-gateways.e2e.spec.ts index 8b19134..2ea230a 100644 --- a/apps/api/src/modules/customer-gateways/customer-gateways.e2e.spec.ts +++ b/apps/api/src/modules/customer-gateways/customer-gateways.e2e.spec.ts @@ -97,6 +97,13 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository { return updated; } + async softDelete(gatewayId: string): Promise { + const current = await this.get(gatewayId); + const deleted = { ...current, status: 'DISABLED' as CustomerGatewayStatus, policyCount: 0 }; + this.gateways.delete(gatewayId); + return deleted; + } + private summary(input: { id: string; customerId?: string; @@ -269,5 +276,7 @@ describe('S13 customer gateways API', () => { 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); + await request(app.getHttpServer()).delete('/api/v2/customer-gateways/cgw_created').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200); + expect(audit.entries.some((entry) => entry.module === 'customer_gateways' && entry.action === 'delete' && entry.result === 'SUCCESS')).toBe(true); }); }); diff --git a/apps/api/src/modules/customer-gateways/customer-gateways.repository.ts b/apps/api/src/modules/customer-gateways/customer-gateways.repository.ts index 28a329d..45c5c3e 100644 --- a/apps/api/src/modules/customer-gateways/customer-gateways.repository.ts +++ b/apps/api/src/modules/customer-gateways/customer-gateways.repository.ts @@ -50,6 +50,7 @@ export interface CustomerGatewaysRepository { create(input: CreateCustomerGatewayInput): Promise; update(gatewayId: string, input: UpdateCustomerGatewayInput): Promise; setStatus(gatewayId: string, status: CustomerGatewayStatus, actorId?: string): Promise; + softDelete(gatewayId: string, actorId?: string): Promise; } export const CUSTOMER_GATEWAYS_REPOSITORY = Symbol('CUSTOMER_GATEWAYS_REPOSITORY'); @@ -168,6 +169,35 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit return this.toSummary(gateway); } + async softDelete(gatewayIdValue: string, actorId?: string): Promise { + await this.findActiveOrThrow(gatewayIdValue); + + const gateway = await this.prisma.$transaction(async (tx) => { + await tx.customerGatewayPolicy.updateMany({ + where: { gatewayId: gatewayIdValue, deletedAt: null }, + data: { + status: 'DISABLED', + deletedAt: new Date(), + updatedBy: actorId + } + }); + const deleted = await tx.customerGateway.update({ + where: { id: gatewayIdValue }, + data: { + status: 'DISABLED', + deletedAt: new Date(), + updatedBy: actorId, + version: { increment: 1 } + }, + include: this.includeSummary() + }); + await this.enqueueConfigOutbox(tx, deleted.id, 'customer_gateway.deleted'); + return deleted; + }); + + return this.toSummary(gateway); + } + private async enqueueConfigOutbox(tx: Prisma.TransactionClient, aggregateId: string, eventType: string): Promise { await tx.outboxEvent.create({ data: { diff --git a/apps/api/src/modules/customer-gateways/customer-gateways.service.ts b/apps/api/src/modules/customer-gateways/customer-gateways.service.ts index 87697de..c203cd5 100644 --- a/apps/api/src/modules/customer-gateways/customer-gateways.service.ts +++ b/apps/api/src/modules/customer-gateways/customer-gateways.service.ts @@ -103,6 +103,10 @@ export class CustomerGatewaysService { return this.gateways.setStatus(gatewayId, 'DISABLED', actorId); } + remove(gatewayId: string, actorId?: string): Promise { + return this.gateways.softDelete(gatewayId, 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.` }); diff --git a/apps/api/src/modules/landing-line-groups/landing-line-groups.e2e.spec.ts b/apps/api/src/modules/landing-line-groups/landing-line-groups.e2e.spec.ts index fe13f34..ab1208a 100644 --- a/apps/api/src/modules/landing-line-groups/landing-line-groups.e2e.spec.ts +++ b/apps/api/src/modules/landing-line-groups/landing-line-groups.e2e.spec.ts @@ -176,6 +176,7 @@ class MemoryLandingLineGroupsRepository implements LandingLineGroupsRepository { concurrencyCapSum: enabledItems.reduce((sum, item) => sum + item.concurrencyCap, 0), gatewayConcurrencyLimitSum: enabledItems.reduce((sum, item) => sum + item.vendorGatewayConcurrencyLimit, 0), policyCount: input.policyCount ?? 0, + customerGatewayCount: input.customerGatewayCount ?? 0, items, createdAt: new Date('2026-06-21T07:00:00.000Z'), updatedAt: new Date('2026-06-21T07:00:00.000Z') diff --git a/apps/api/src/modules/landing-line-groups/landing-line-groups.repository.ts b/apps/api/src/modules/landing-line-groups/landing-line-groups.repository.ts index 5766182..cc22284 100644 --- a/apps/api/src/modules/landing-line-groups/landing-line-groups.repository.ts +++ b/apps/api/src/modules/landing-line-groups/landing-line-groups.repository.ts @@ -33,6 +33,7 @@ export interface LineGroupSummary { concurrencyCapSum: number; gatewayConcurrencyLimitSum: number; policyCount: number; + customerGatewayCount: number; items: LineGroupItemSummary[]; createdAt: Date; updatedAt: Date; @@ -139,11 +140,14 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos orderBy: [{ createdAt: 'desc' }], include: this.includeSummary() }); - return groups.map((group) => this.toSummary(group)); + const usageCounts = await this.customerGatewayUsageCounts(groups.map((group) => group.id)); + return groups.map((group) => this.toSummary(group, usageCounts.get(group.id) ?? 0)); } async get(lineGroupIdValue: string): Promise { - return this.toSummary(await this.findActiveOrThrow(lineGroupIdValue)); + const group = await this.findActiveOrThrow(lineGroupIdValue); + const usageCounts = await this.customerGatewayUsageCounts([lineGroupIdValue]); + return this.toSummary(group, usageCounts.get(lineGroupIdValue) ?? 0); } async create(input: CreateLineGroupInput): Promise { @@ -201,11 +205,10 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos async softDelete(lineGroupIdValue: string, actorId?: string): Promise { await this.findActiveOrThrow(lineGroupIdValue); - const linkedPolicies = await this.prisma.customerGatewayPolicy.count({ - where: { lineGroupId: lineGroupIdValue, deletedAt: null } - }); - if (linkedPolicies > 0) { - throw new BadRequestException({ code: 'LINE_GROUP_IN_USE', message: 'Line group referenced by active policies cannot be deleted.' }); + const usageCounts = await this.customerGatewayUsageCounts([lineGroupIdValue]); + const linkedCustomerGateways = usageCounts.get(lineGroupIdValue) ?? 0; + if (linkedCustomerGateways > 0) { + throw new BadRequestException({ code: 'LINE_GROUP_IN_USE', message: 'Line group referenced by active customer gateways cannot be deleted.' }); } const group = await this.prisma.$transaction(async (tx) => { @@ -222,7 +225,7 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos await this.enqueueConfigOutbox(tx, deleted.id, 'line_group.deleted'); return deleted; }); - return this.toSummary(group); + return this.toSummary(group, 0); } async addItem(input: AddLineGroupItemInput): Promise { @@ -342,6 +345,27 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos return this.get(lineGroupIdValue); } + private async customerGatewayUsageCounts(lineGroupIds: string[]): Promise> { + if (lineGroupIds.length === 0) { + return new Map(); + } + const rows = await this.prisma.customerGatewayPolicy.groupBy({ + by: ['lineGroupId', 'gatewayId'], + where: { + lineGroupId: { in: lineGroupIds }, + deletedAt: null + } + }); + const counts = new Map(); + for (const row of rows) { + if (!row.lineGroupId) { + continue; + } + counts.set(row.lineGroupId, (counts.get(row.lineGroupId) ?? 0) + 1); + } + return counts; + } + private includeItem() { return { vendorGateway: { @@ -427,7 +451,7 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos }); } - private toSummary(group: LineGroupRecord): LineGroupSummary { + private toSummary(group: LineGroupRecord, customerGatewayCount = 0): LineGroupSummary { const items = group.items.map((item) => this.toItemSummary(item)); const enabledItems = items.filter((item) => item.status === 'ENABLED'); return { @@ -440,6 +464,7 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos concurrencyCapSum: enabledItems.reduce((sum, item) => sum + item.concurrencyCap, 0), gatewayConcurrencyLimitSum: enabledItems.reduce((sum, item) => sum + item.vendorGatewayConcurrencyLimit, 0), policyCount: group._count.policies, + customerGatewayCount, items, createdAt: group.createdAt, updatedAt: group.updatedAt diff --git a/apps/api/src/modules/number-library/number-library.controller.ts b/apps/api/src/modules/number-library/number-library.controller.ts new file mode 100644 index 0000000..8d972a9 --- /dev/null +++ b/apps/api/src/modules/number-library/number-library.controller.ts @@ -0,0 +1,63 @@ +import { Body, Controller, Get, Inject, 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 { NumberLibraryService } from './number-library.service.js'; + +@ApiTags('number-library') +@Controller('number-library') +export class NumberLibraryController { + constructor(@Inject(NumberLibraryService) private readonly numberLibraryService: NumberLibraryService) {} + + @Get('cities') + @RequirePermissions('number_library.view') + listCities(@Query() query: unknown) { + return this.numberLibraryService.listCities(query as never); + } + + @Post('cities/import') + @RequirePermissions('number_library.manage') + @AuditAction({ module: 'number_library', action: 'import_cities', objectType: 'geo_city' }) + importCities(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.numberLibraryService.importCities(body as never, currentUser?.id); + } + + @Get('phone-segments') + @RequirePermissions('number_library.view') + listPhoneSegments(@Query() query: unknown) { + return this.numberLibraryService.listPhoneSegments(query as never); + } + + @Post('phone-segments/import') + @RequirePermissions('number_library.manage') + @AuditAction({ module: 'number_library', action: 'import_phone_segments', objectType: 'phone_number_segment' }) + importPhoneSegments(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.numberLibraryService.importPhoneSegments(body as never, currentUser?.id); + } + + @Get('area-codes') + @RequirePermissions('number_library.view') + listAreaCodes(@Query() query: unknown) { + return this.numberLibraryService.listAreaCodes(query as never); + } + + @Post('area-codes/import') + @RequirePermissions('number_library.manage') + @AuditAction({ module: 'number_library', action: 'import_area_codes', objectType: 'phone_area_code' }) + importAreaCodes(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.numberLibraryService.importAreaCodes(body as never, currentUser?.id); + } + + @Get('carrier-prefix-rules') + @RequirePermissions('number_library.view') + listCarrierPrefixRules(@Query() query: unknown) { + return this.numberLibraryService.listCarrierPrefixRules(query as never); + } + + @Post('carrier-prefix-rules/import') + @RequirePermissions('number_library.manage') + @AuditAction({ module: 'number_library', action: 'import_carrier_prefix_rules', objectType: 'carrier_prefix_rule' }) + importCarrierPrefixRules(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { + return this.numberLibraryService.importCarrierPrefixRules(body as never, currentUser?.id); + } +} diff --git a/apps/api/src/modules/number-library/number-library.e2e.spec.ts b/apps/api/src/modules/number-library/number-library.e2e.spec.ts new file mode 100644 index 0000000..ddf2ee4 --- /dev/null +++ b/apps/api/src/modules/number-library/number-library.e2e.spec.ts @@ -0,0 +1,240 @@ +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 { + NUMBER_LIBRARY_REPOSITORY, + type AreaCodeInput, + type CarrierPrefixInput, + type CityInput, + type ImportResult, + type NumberCarrier, + type NumberLibraryRepository, + type PageQuery, + type PhoneSegmentInput +} from './number-library.repository.js'; + +class MemoryIdentityRepository implements IdentityRepository { + users = new Map(); + + async findCurrentUserById(userId: string): Promise { + return this.users.get(userId) ?? null; + } +} + +class MemoryAuditRepository implements AuditRepository { + entries: AuditEntryInput[] = []; + + async write(input: AuditEntryInput): Promise { + this.entries.push(input); + } +} + +class MemoryNumberLibraryRepository implements NumberLibraryRepository { + cities: CityInput[] = []; + segments: PhoneSegmentInput[] = []; + areaCodes: AreaCodeInput[] = []; + carrierRules: CarrierPrefixInput[] = []; + + async listCities(query: PageQuery): Promise<{ items: unknown[]; total: number }> { + return this.page(this.cities.filter((city) => !query.keyword || city.cityName.includes(query.keyword) || city.provinceName.includes(query.keyword)), query); + } + + async importCities(items: CityInput[]): Promise { + this.upsert(this.cities, items, (item) => item.code); + return { imported: items.length }; + } + + async listPhoneSegments(query: PageQuery & { segment7?: string; cityCode?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> { + return this.page( + this.segments.filter( + (item) => + (!query.segment7 || item.segment7.startsWith(query.segment7)) && + (!query.cityCode || item.cityCode === query.cityCode) && + (!query.carrier || item.carrier === query.carrier) + ), + query + ); + } + + async importPhoneSegments(items: PhoneSegmentInput[]): Promise { + this.upsert(this.segments, items, (item) => item.segment7); + return { imported: items.length }; + } + + async listAreaCodes(query: PageQuery & { areaCode?: string; cityCode?: string }): Promise<{ items: unknown[]; total: number }> { + return this.page( + this.areaCodes.filter((item) => (!query.areaCode || item.areaCode.startsWith(query.areaCode)) && (!query.cityCode || item.cityCode === query.cityCode)), + query + ); + } + + async importAreaCodes(items: AreaCodeInput[]): Promise { + this.upsert(this.areaCodes, items, (item) => item.areaCode); + return { imported: items.length }; + } + + async listCarrierPrefixRules(query: PageQuery & { prefix?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> { + return this.page( + this.carrierRules.filter((item) => (!query.prefix || item.prefix.startsWith(query.prefix)) && (!query.carrier || item.carrier === query.carrier)), + query + ); + } + + async importCarrierPrefixRules(items: CarrierPrefixInput[]): Promise { + this.upsert(this.carrierRules, items, (item) => item.prefix); + return { imported: items.length }; + } + + private upsert(target: T[], items: T[], key: (item: T) => string): void { + for (const item of items) { + const index = target.findIndex((existing) => key(existing) === key(item)); + if (index >= 0) { + target[index] = item; + } else { + target.push(item); + } + } + } + + private page(items: unknown[], query: PageQuery): { items: unknown[]; total: number } { + return { items: items.slice(query.skip, query.skip + query.take), total: items.length }; + } +} + +describe('number library 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: ['number_library.view', 'number_library.manage'] as PermissionKey[] + }); + identities.users.set('usr_viewer', { + id: 'usr_viewer', + username: 'viewer', + roles: ['只读'], + permissions: ['number_library.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(NUMBER_LIBRARY_REPOSITORY) + .useValue(new MemoryNumberLibraryRepository()) + .compile(); + + app = moduleFixture.createNestApplication(new FastifyAdapter({ logger: false })); + app.setGlobalPrefix('api/v2'); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }, 30000); + + afterAll(async () => { + await app?.close(); + }); + + it('rejects imports without number_library.manage', async () => { + await request(app.getHttpServer()) + .post('/api/v2/number-library/cities/import') + .set('Authorization', `Bearer ${tokenFor('usr_viewer')}`) + .send({ items: [] }) + .expect(403); + }); + + it('imports and lists cities, phone segments, area codes, and carrier rules', async () => { + await request(app.getHttpServer()) + .post('/api/v2/number-library/cities/import') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ items: [{ code: '340100', provinceCode: '340000', provinceName: '安徽', cityCode: '340100', cityName: '合肥', cityLevel: '地级市' }] }) + .expect(201) + .expect((response) => expect(response.body).toEqual({ imported: 1 })); + + await request(app.getHttpServer()) + .post('/api/v2/number-library/phone-segments/import') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ items: [{ segment7: '1385510', cityCode: '340100', provinceName: '安徽', cityName: '合肥', carrier: 'MOBILE', batchId: 'batch_test' }] }) + .expect(201); + + await request(app.getHttpServer()) + .post('/api/v2/number-library/area-codes/import') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ items: [{ areaCode: '0551', cityCode: '340100', provinceName: '安徽', cityName: '合肥' }] }) + .expect(201); + + await request(app.getHttpServer()) + .post('/api/v2/number-library/carrier-prefix-rules/import') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ items: [{ prefix: '138', carrier: 'MOBILE', priority: 10 }] }) + .expect(201); + + await request(app.getHttpServer()) + .get('/api/v2/number-library/phone-segments?segment7=138&carrier=MOBILE') + .set('Authorization', `Bearer ${tokenFor('usr_viewer')}`) + .expect(200) + .expect((response) => { + expect(response.body.total).toBe(1); + expect(response.body.items[0]).toMatchObject({ segment7: '1385510', cityName: '合肥', carrier: 'MOBILE' }); + }); + + await request(app.getHttpServer()) + .get('/api/v2/number-library/area-codes?areaCode=0551') + .set('Authorization', `Bearer ${tokenFor('usr_viewer')}`) + .expect(200) + .expect((response) => expect(response.body.items[0]).toMatchObject({ areaCode: '0551', cityName: '合肥' })); + + await request(app.getHttpServer()) + .get('/api/v2/number-library/carrier-prefix-rules?prefix=138') + .set('Authorization', `Bearer ${tokenFor('usr_viewer')}`) + .expect(200) + .expect((response) => expect(response.body.items[0]).toMatchObject({ prefix: '138', carrier: 'MOBILE' })); + + expect(audit.entries.some((entry) => entry.module === 'number_library' && entry.action === 'import_cities')).toBe(true); + }); + + it('validates import batch shape and segment digits', async () => { + await request(app.getHttpServer()) + .post('/api/v2/number-library/phone-segments/import') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ items: [{ segment7: '13855', cityCode: '340100', provinceName: '安徽', cityName: '合肥' }] }) + .expect(400) + .expect((response) => expect(response.body).toMatchObject({ code: 'DIGITS_INVALID' })); + }); +}); diff --git a/apps/api/src/modules/number-library/number-library.module.ts b/apps/api/src/modules/number-library/number-library.module.ts new file mode 100644 index 0000000..9d28e1e --- /dev/null +++ b/apps/api/src/modules/number-library/number-library.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { NumberLibraryController } from './number-library.controller.js'; +import { NUMBER_LIBRARY_REPOSITORY, PrismaNumberLibraryRepository } from './number-library.repository.js'; +import { NumberLibraryService } from './number-library.service.js'; + +@Module({ + controllers: [NumberLibraryController], + providers: [ + NumberLibraryService, + { + provide: NUMBER_LIBRARY_REPOSITORY, + useClass: PrismaNumberLibraryRepository + } + ], + exports: [NumberLibraryService] +}) +export class NumberLibraryModule {} diff --git a/apps/api/src/modules/number-library/number-library.repository.ts b/apps/api/src/modules/number-library/number-library.repository.ts new file mode 100644 index 0000000..e97bd9d --- /dev/null +++ b/apps/api/src/modules/number-library/number-library.repository.ts @@ -0,0 +1,310 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import crypto from 'node:crypto'; +import { Prisma } from '@lisglosips/database'; +import { PrismaService } from '../database/prisma.service.js'; + +export type NumberCarrier = 'MOBILE' | 'UNICOM' | 'TELECOM' | 'BROADCAST' | 'MVNO' | 'UNKNOWN'; + +export interface PageQuery { + keyword?: string; + take: number; + skip: number; +} + +export interface CityInput { + code: string; + provinceCode: string; + provinceName: string; + cityCode: string; + cityName: string; + cityLevel: string; + status?: 'ENABLED' | 'DISABLED'; + effectiveFrom?: Date | null; + effectiveTo?: Date | null; + actorId?: string; +} + +export interface PhoneSegmentInput { + segment7: string; + cityCode: string; + provinceName: string; + cityName: string; + carrier?: NumberCarrier; + source?: string | null; + batchId?: string | null; + effectiveFrom?: Date | null; + effectiveTo?: Date | null; + actorId?: string; +} + +export interface AreaCodeInput { + areaCode: string; + cityCode: string; + provinceName: string; + cityName: string; + source?: string | null; + batchId?: string | null; + actorId?: string; +} + +export interface CarrierPrefixInput { + prefix: string; + carrier: NumberCarrier; + priority: number; + source?: string | null; + batchId?: string | null; + effectiveFrom?: Date | null; + effectiveTo?: Date | null; + actorId?: string; +} + +export interface ImportResult { + imported: number; +} + +export const NUMBER_LIBRARY_REPOSITORY = Symbol('NUMBER_LIBRARY_REPOSITORY'); + +function outboxId(): string { + return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`; +} + +export interface NumberLibraryRepository { + listCities(query: PageQuery): Promise<{ items: unknown[]; total: number }>; + importCities(items: CityInput[]): Promise; + listPhoneSegments(query: PageQuery & { segment7?: string; cityCode?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }>; + importPhoneSegments(items: PhoneSegmentInput[]): Promise; + listAreaCodes(query: PageQuery & { areaCode?: string; cityCode?: string }): Promise<{ items: unknown[]; total: number }>; + importAreaCodes(items: AreaCodeInput[]): Promise; + listCarrierPrefixRules(query: PageQuery & { prefix?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }>; + importCarrierPrefixRules(items: CarrierPrefixInput[]): Promise; +} + +@Injectable() +export class PrismaNumberLibraryRepository implements NumberLibraryRepository { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async listCities(query: PageQuery): Promise<{ items: unknown[]; total: number }> { + const where: Prisma.GeoCityWhereInput = { + deletedAt: null, + OR: query.keyword + ? [ + { provinceName: { contains: query.keyword } }, + { cityName: { contains: query.keyword } }, + { cityCode: { contains: query.keyword } }, + { provinceCode: { contains: query.keyword } } + ] + : undefined + }; + const [items, total] = await this.prisma.$transaction([ + this.prisma.geoCity.findMany({ where, orderBy: [{ provinceCode: 'asc' }, { cityCode: 'asc' }], take: query.take, skip: query.skip }), + this.prisma.geoCity.count({ where }) + ]); + return { items, total }; + } + + async importCities(items: CityInput[]): Promise { + await this.prisma.$transaction(async (tx) => { + for (const item of items) { + await tx.geoCity.upsert({ + where: { code: item.code }, + update: { + provinceCode: item.provinceCode, + provinceName: item.provinceName, + cityCode: item.cityCode, + cityName: item.cityName, + cityLevel: item.cityLevel, + status: item.status ?? 'ENABLED', + effectiveFrom: item.effectiveFrom, + effectiveTo: item.effectiveTo, + updatedBy: item.actorId, + version: { increment: 1 }, + deletedAt: null + }, + create: { + code: item.code, + provinceCode: item.provinceCode, + provinceName: item.provinceName, + cityCode: item.cityCode, + cityName: item.cityName, + cityLevel: item.cityLevel, + status: item.status ?? 'ENABLED', + effectiveFrom: item.effectiveFrom, + effectiveTo: item.effectiveTo, + createdBy: item.actorId, + updatedBy: item.actorId + } + }); + } + await this.enqueueConfigOutbox(tx, 'geo_city.changed', items.length); + }); + return { imported: items.length }; + } + + async listPhoneSegments(query: PageQuery & { segment7?: string; cityCode?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> { + const where: Prisma.PhoneNumberSegmentWhereInput = { + deletedAt: null, + segment7: query.segment7 ? { startsWith: query.segment7 } : undefined, + cityCode: query.cityCode, + carrier: query.carrier, + OR: query.keyword ? [{ provinceName: { contains: query.keyword } }, { cityName: { contains: query.keyword } }] : undefined + }; + const [items, total] = await this.prisma.$transaction([ + this.prisma.phoneNumberSegment.findMany({ where, orderBy: [{ segment7: 'asc' }], take: query.take, skip: query.skip }), + this.prisma.phoneNumberSegment.count({ where }) + ]); + return { items, total }; + } + + async importPhoneSegments(items: PhoneSegmentInput[]): Promise { + await this.ensureCities(items.map((item) => item.cityCode)); + await this.prisma.$transaction(async (tx) => { + for (const item of items) { + await tx.phoneNumberSegment.upsert({ + where: { segment7: item.segment7 }, + update: { + cityCode: item.cityCode, + provinceName: item.provinceName, + cityName: item.cityName, + carrier: item.carrier ?? 'UNKNOWN', + source: item.source, + batchId: item.batchId, + effectiveFrom: item.effectiveFrom, + effectiveTo: item.effectiveTo, + updatedBy: item.actorId, + version: { increment: 1 }, + deletedAt: null + }, + create: { + segment7: item.segment7, + cityCode: item.cityCode, + provinceName: item.provinceName, + cityName: item.cityName, + carrier: item.carrier ?? 'UNKNOWN', + source: item.source, + batchId: item.batchId, + effectiveFrom: item.effectiveFrom, + effectiveTo: item.effectiveTo, + createdBy: item.actorId, + updatedBy: item.actorId + } + }); + } + await this.enqueueConfigOutbox(tx, 'phone_number_segment.changed', items.length); + }); + return { imported: items.length }; + } + + async listAreaCodes(query: PageQuery & { areaCode?: string; cityCode?: string }): Promise<{ items: unknown[]; total: number }> { + const where: Prisma.PhoneAreaCodeWhereInput = { + deletedAt: null, + areaCode: query.areaCode ? { startsWith: query.areaCode } : undefined, + cityCode: query.cityCode, + OR: query.keyword ? [{ provinceName: { contains: query.keyword } }, { cityName: { contains: query.keyword } }] : undefined + }; + const [items, total] = await this.prisma.$transaction([ + this.prisma.phoneAreaCode.findMany({ where, orderBy: [{ areaCode: 'asc' }], take: query.take, skip: query.skip }), + this.prisma.phoneAreaCode.count({ where }) + ]); + return { items, total }; + } + + async importAreaCodes(items: AreaCodeInput[]): Promise { + await this.ensureCities(items.map((item) => item.cityCode)); + await this.prisma.$transaction(async (tx) => { + for (const item of items) { + await tx.phoneAreaCode.upsert({ + where: { areaCode: item.areaCode }, + update: { + cityCode: item.cityCode, + provinceName: item.provinceName, + cityName: item.cityName, + source: item.source, + batchId: item.batchId, + updatedBy: item.actorId, + version: { increment: 1 }, + deletedAt: null + }, + create: { + areaCode: item.areaCode, + cityCode: item.cityCode, + provinceName: item.provinceName, + cityName: item.cityName, + source: item.source, + batchId: item.batchId, + createdBy: item.actorId, + updatedBy: item.actorId + } + }); + } + await this.enqueueConfigOutbox(tx, 'phone_area_code.changed', items.length); + }); + return { imported: items.length }; + } + + async listCarrierPrefixRules(query: PageQuery & { prefix?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> { + const where: Prisma.CarrierPrefixRuleWhereInput = { + deletedAt: null, + prefix: query.prefix ? { startsWith: query.prefix } : undefined, + carrier: query.carrier + }; + const [items, total] = await this.prisma.$transaction([ + this.prisma.carrierPrefixRule.findMany({ where, orderBy: [{ prefix: 'asc' }], take: query.take, skip: query.skip }), + this.prisma.carrierPrefixRule.count({ where }) + ]); + return { items, total }; + } + + async importCarrierPrefixRules(items: CarrierPrefixInput[]): Promise { + await this.prisma.$transaction(async (tx) => { + for (const item of items) { + await tx.carrierPrefixRule.upsert({ + where: { prefix: item.prefix }, + update: { + carrier: item.carrier, + priority: item.priority, + source: item.source, + batchId: item.batchId, + effectiveFrom: item.effectiveFrom, + effectiveTo: item.effectiveTo, + updatedBy: item.actorId, + version: { increment: 1 }, + deletedAt: null + }, + create: { + prefix: item.prefix, + carrier: item.carrier, + priority: item.priority, + source: item.source, + batchId: item.batchId, + effectiveFrom: item.effectiveFrom, + effectiveTo: item.effectiveTo, + createdBy: item.actorId, + updatedBy: item.actorId + } + }); + } + await this.enqueueConfigOutbox(tx, 'carrier_prefix_rule.changed', items.length); + }); + return { imported: items.length }; + } + + private async enqueueConfigOutbox(tx: Prisma.TransactionClient, eventType: string, itemCount: number): Promise { + await tx.outboxEvent.create({ + data: { + id: outboxId(), + aggregateType: 'number_library_config', + aggregateId: 'number-library', + eventType, + payload: { eventType, itemCount } + } + }); + } + + private async ensureCities(cityCodes: string[]): Promise { + const uniqueCodes = [...new Set(cityCodes)]; + const count = await this.prisma.geoCity.count({ where: { cityCode: { in: uniqueCodes }, deletedAt: null } }); + if (count !== uniqueCodes.length) { + throw new BadRequestException({ code: 'CITY_NOT_FOUND', message: 'One or more city codes are invalid.' }); + } + } +} diff --git a/apps/api/src/modules/number-library/number-library.service.ts b/apps/api/src/modules/number-library/number-library.service.ts new file mode 100644 index 0000000..d3df639 --- /dev/null +++ b/apps/api/src/modules/number-library/number-library.service.ts @@ -0,0 +1,186 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { + NUMBER_LIBRARY_REPOSITORY, + type AreaCodeInput, + type CarrierPrefixInput, + type CityInput, + type NumberCarrier, + type NumberLibraryRepository, + type PageQuery, + type PhoneSegmentInput +} from './number-library.repository.js'; + +const MAX_IMPORT_ITEMS = 1000; + +type QueryRecord = Record; + +@Injectable() +export class NumberLibraryService { + constructor(@Inject(NUMBER_LIBRARY_REPOSITORY) private readonly numberLibrary: NumberLibraryRepository) {} + + listCities(query: QueryRecord) { + return this.numberLibrary.listCities(this.page(query)); + } + + importCities(body: QueryRecord, actorId?: string) { + const items = this.items(body).map((item) => ({ + code: this.limitedString(item.code, 'code', 12), + provinceCode: this.limitedString(item.provinceCode, 'provinceCode', 12), + provinceName: this.limitedString(item.provinceName, 'provinceName', 80), + cityCode: this.limitedString(item.cityCode, 'cityCode', 12), + cityName: this.limitedString(item.cityName, 'cityName', 80), + cityLevel: this.limitedString(item.cityLevel, 'cityLevel', 32), + status: item.status === undefined ? 'ENABLED' : this.status(item.status), + effectiveFrom: this.optionalDate(item.effectiveFrom, 'effectiveFrom'), + effectiveTo: this.optionalDate(item.effectiveTo, 'effectiveTo'), + actorId + })) satisfies CityInput[]; + return this.numberLibrary.importCities(items); + } + + listPhoneSegments(query: QueryRecord) { + return this.numberLibrary.listPhoneSegments({ + ...this.page(query), + segment7: query.segment7 === undefined ? undefined : this.digits(query.segment7, 'segment7', 1, 7), + cityCode: query.cityCode === undefined ? undefined : this.limitedString(query.cityCode, 'cityCode', 12), + carrier: query.carrier === undefined ? undefined : this.carrier(query.carrier) + }); + } + + importPhoneSegments(body: QueryRecord, actorId?: string) { + const items = this.items(body).map((item) => ({ + segment7: this.digits(item.segment7, 'segment7', 7, 7), + cityCode: this.limitedString(item.cityCode, 'cityCode', 12), + provinceName: this.limitedString(item.provinceName, 'provinceName', 80), + cityName: this.limitedString(item.cityName, 'cityName', 80), + carrier: item.carrier === undefined ? 'UNKNOWN' : this.carrier(item.carrier), + source: this.optionalLimitedString(item.source, 'source', 120), + batchId: this.optionalLimitedString(item.batchId, 'batchId', 64), + effectiveFrom: this.optionalDate(item.effectiveFrom, 'effectiveFrom'), + effectiveTo: this.optionalDate(item.effectiveTo, 'effectiveTo'), + actorId + })) satisfies PhoneSegmentInput[]; + return this.numberLibrary.importPhoneSegments(items); + } + + listAreaCodes(query: QueryRecord) { + return this.numberLibrary.listAreaCodes({ + ...this.page(query), + areaCode: query.areaCode === undefined ? undefined : this.digits(query.areaCode, 'areaCode', 2, 8), + cityCode: query.cityCode === undefined ? undefined : this.limitedString(query.cityCode, 'cityCode', 12) + }); + } + + importAreaCodes(body: QueryRecord, actorId?: string) { + const items = this.items(body).map((item) => ({ + areaCode: this.digits(item.areaCode, 'areaCode', 2, 8), + cityCode: this.limitedString(item.cityCode, 'cityCode', 12), + provinceName: this.limitedString(item.provinceName, 'provinceName', 80), + cityName: this.limitedString(item.cityName, 'cityName', 80), + source: this.optionalLimitedString(item.source, 'source', 120), + batchId: this.optionalLimitedString(item.batchId, 'batchId', 64), + actorId + })) satisfies AreaCodeInput[]; + return this.numberLibrary.importAreaCodes(items); + } + + listCarrierPrefixRules(query: QueryRecord) { + return this.numberLibrary.listCarrierPrefixRules({ + ...this.page(query), + prefix: query.prefix === undefined ? undefined : this.digits(query.prefix, 'prefix', 1, 4), + carrier: query.carrier === undefined ? undefined : this.carrier(query.carrier) + }); + } + + importCarrierPrefixRules(body: QueryRecord, actorId?: string) { + const items = this.items(body).map((item) => ({ + prefix: this.digits(item.prefix, 'prefix', 3, 4), + carrier: this.carrier(item.carrier), + priority: this.integer(item.priority ?? 100, 'priority', 1, 10_000), + source: this.optionalLimitedString(item.source, 'source', 120), + batchId: this.optionalLimitedString(item.batchId, 'batchId', 64), + effectiveFrom: this.optionalDate(item.effectiveFrom, 'effectiveFrom'), + effectiveTo: this.optionalDate(item.effectiveTo, 'effectiveTo'), + actorId + })) satisfies CarrierPrefixInput[]; + return this.numberLibrary.importCarrierPrefixRules(items); + } + + private page(query: QueryRecord): PageQuery { + return { + keyword: query.keyword === undefined ? undefined : this.limitedString(query.keyword, 'keyword', 80), + take: this.integer(query.take ?? 50, 'take', 1, 500), + skip: this.integer(query.skip ?? 0, 'skip', 0, 1_000_000) + }; + } + + private items(body: QueryRecord): QueryRecord[] { + if (!Array.isArray(body.items) || body.items.length === 0 || body.items.length > MAX_IMPORT_ITEMS) { + throw new BadRequestException({ code: 'IMPORT_ITEMS_INVALID', message: `items must contain 1-${MAX_IMPORT_ITEMS} records.` }); + } + if (!body.items.every((item) => item && typeof item === 'object' && !Array.isArray(item))) { + throw new BadRequestException({ code: 'IMPORT_ITEMS_INVALID', message: 'items must be objects.' }); + } + return body.items as QueryRecord[]; + } + + 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 optionalLimitedString(value: unknown, field: string, maxLength: number): string | null | undefined { + if (value === undefined) return undefined; + if (value === null) return null; + return this.limitedString(value, field, maxLength); + } + + private digits(value: unknown, field: string, minLength: number, maxLength: number): string { + const text = this.limitedString(value, field, maxLength); + if (!new RegExp(`^\\d{${minLength},${maxLength}}$`).test(text)) { + throw new BadRequestException({ code: 'DIGITS_INVALID', message: `${field} must contain ${minLength}-${maxLength} digits.` }); + } + return text; + } + + private integer(value: unknown, field: string, min: number, max: number): number { + const number = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : Number.NaN; + if (!Number.isInteger(number) || number < min || number > max) { + throw new BadRequestException({ code: 'INTEGER_INVALID', message: `${field} is invalid.` }); + } + return number; + } + + private carrier(value: unknown): NumberCarrier { + if (value === 'MOBILE' || value === 'UNICOM' || value === 'TELECOM' || value === 'BROADCAST' || value === 'MVNO' || value === 'UNKNOWN') { + return value; + } + throw new BadRequestException({ code: 'CARRIER_INVALID', message: 'Carrier is invalid.' }); + } + + private status(value: unknown): 'ENABLED' | 'DISABLED' { + if (value === 'ENABLED' || value === 'DISABLED') { + return value; + } + throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' }); + } + + private optionalDate(value: unknown, field: string): Date | null | undefined { + if (value === undefined) return undefined; + if (value === null) return null; + if (typeof value !== 'string') { + throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` }); + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` }); + } + return date; + } +} diff --git a/apps/api/src/modules/recharges/recharges.repository.ts b/apps/api/src/modules/recharges/recharges.repository.ts index a084ad1..cd2c95a 100644 --- a/apps/api/src/modules/recharges/recharges.repository.ts +++ b/apps/api/src/modules/recharges/recharges.repository.ts @@ -234,7 +234,10 @@ export class PrismaRechargesRepository implements RechargesRepository { } private async listCustomerRecharges(accountId: string | undefined, take: number, skip: number): Promise<[RechargeSummary[], number]> { - const where = accountId ? { customerId: accountId } : {}; + const where: Prisma.CustomerRechargeWhereInput = { + ...(accountId ? { customerId: accountId } : {}), + NOT: [{ createdBy: 'worker-cdr' }, { idempotencyKey: { startsWith: 'cdr:' } }, { remark: { startsWith: 'CDR_CHARGE:' } }] + }; const [items, total] = await this.prisma.$transaction([ this.prisma.customerRecharge.findMany({ where, diff --git a/apps/api/src/modules/roles/roles.controller.ts b/apps/api/src/modules/roles/roles.controller.ts index f42daf0..b33f642 100644 --- a/apps/api/src/modules/roles/roles.controller.ts +++ b/apps/api/src/modules/roles/roles.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common'; +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'; @@ -29,6 +29,13 @@ export class RolesController { return this.rolesService.updateRole(id, body as never, currentUser?.id); } + @Delete('roles/:id') + @RequirePermissions('roles.manage') + @AuditAction({ module: 'roles', action: 'delete', objectType: 'role', objectIdParam: 'id' }) + removeRole(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.rolesService.removeRole(id, currentUser?.id); + } + @Get('permissions') @RequirePermissions('roles.view') listPermissions() { diff --git a/apps/api/src/modules/roles/roles.repository.ts b/apps/api/src/modules/roles/roles.repository.ts index ea04419..353eead 100644 --- a/apps/api/src/modules/roles/roles.repository.ts +++ b/apps/api/src/modules/roles/roles.repository.ts @@ -41,6 +41,7 @@ export interface RolesRepository { listPermissions(): Promise; createRole(input: CreateRoleInput): Promise; updateRole(roleId: string, input: UpdateRoleInput): Promise; + softDeleteRole(roleId: string, actorId?: string): Promise; } export const ROLES_REPOSITORY = Symbol('ROLES_REPOSITORY'); @@ -169,6 +170,54 @@ export class PrismaRolesRepository implements RolesRepository { return this.toSummary(role); } + async softDeleteRole(roleIdValue: string, actorId?: string): Promise { + 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) { + throw new ForbiddenException({ + code: 'BUILT_IN_ROLE_PROTECTED', + message: 'Built-in roles are protected.' + }); + } + + const linkedUsers = await tx.userRole.count({ + where: { + roleId: roleIdValue, + user: { deletedAt: null } + } + }); + + if (linkedUsers > 0) { + throw new BadRequestException({ + code: 'ROLE_HAS_USERS', + message: 'Role with active users cannot be deleted.' + }); + } + + return tx.role.update({ + where: { id: roleIdValue }, + data: { + status: 'DISABLED', + deletedAt: new Date(), + updatedBy: actorId, + version: { increment: 1 } + }, + include: { + permissions: true, + _count: { + select: { userRoles: true } + } + } + }); + }); + + return this.toSummary(role); + } + private async ensurePermissions(permissionIds: string[]): Promise { const uniquePermissionIds = [...new Set(permissionIds)]; const count = await this.prisma.permission.count({ diff --git a/apps/api/src/modules/roles/roles.service.ts b/apps/api/src/modules/roles/roles.service.ts index aa70b21..34dafc8 100644 --- a/apps/api/src/modules/roles/roles.service.ts +++ b/apps/api/src/modules/roles/roles.service.ts @@ -49,6 +49,10 @@ export class RolesService { return this.roles.updateRole(roleId, input); } + removeRole(roleId: string, actorId?: string) { + return this.roles.softDeleteRole(roleId, 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.` }); diff --git a/apps/api/src/modules/security/security-rbac-audit.e2e.spec.ts b/apps/api/src/modules/security/security-rbac-audit.e2e.spec.ts index 6848711..fd8257d 100644 --- a/apps/api/src/modules/security/security-rbac-audit.e2e.spec.ts +++ b/apps/api/src/modules/security/security-rbac-audit.e2e.spec.ts @@ -83,6 +83,10 @@ class MemoryUsersRepository implements UsersRepository { return this.summary(userId, 'operator', ['ROLE_SUPER_ADMIN'], true); } + async softDelete(userId: string): Promise { + return this.summary(userId, 'operator', []); + } + private summary(id: string, username: string, roleIds: string[], requirePasswordChange = false): UserSummary { return { id, @@ -125,6 +129,10 @@ class MemoryRolesRepository implements RolesRepository { return this.role(roleId, roleId === 'ROLE_SUPER_ADMIN', input.permissionIds ?? ['users.manage']); } + async softDeleteRole(roleId: string): Promise { + return this.role(roleId, false, []); + } + private role(id: string, builtIn: boolean, permissionIds: string[], name = 'role'): RoleSummary { return { id, diff --git a/apps/api/src/modules/users-roles-delete.e2e.spec.ts b/apps/api/src/modules/users-roles-delete.e2e.spec.ts new file mode 100644 index 0000000..9bfc6c9 --- /dev/null +++ b/apps/api/src/modules/users-roles-delete.e2e.spec.ts @@ -0,0 +1,289 @@ +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 { BadRequestException, ForbiddenException } from '@nestjs/common'; +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 { + ROLES_REPOSITORY, + type CreateRoleInput, + type PermissionSummary, + type RoleSummary, + type RolesRepository, + type UpdateRoleInput +} from './roles/roles.repository.js'; +import { + USERS_REPOSITORY, + type CreateUserInput, + type UpdateUserInput, + type UserSummary, + type UsersRepository +} from './users/users.repository.js'; + +class MemoryIdentityRepository implements IdentityRepository { + users = new Map(); + + async findCurrentUserById(userId: string): Promise { + return this.users.get(userId) ?? null; + } +} + +class MemoryAuditRepository implements AuditRepository { + entries: AuditEntryInput[] = []; + + async write(input: AuditEntryInput): Promise { + this.entries.push(input); + } +} + +class MemoryRolesRepository implements RolesRepository { + private readonly roles = new Map(); + private readonly permissions: PermissionSummary[] = [{ id: 'users.manage', module: 'users', action: 'manage', description: null }]; + + constructor() { + this.roles.set('rol_builtin', this.summary({ id: 'rol_builtin', name: '系统管理员', builtIn: true, userCount: 1 })); + this.roles.set('rol_used', this.summary({ id: 'rol_used', name: '运营', userCount: 1 })); + this.roles.set('rol_blocked', this.summary({ id: 'rol_blocked', name: '仍在使用', userCount: 1 })); + this.roles.set('rol_empty', this.summary({ id: 'rol_empty', name: '空角色', userCount: 0 })); + } + + async listRoles(): Promise { + return [...this.roles.values()]; + } + + async listPermissions(): Promise { + return this.permissions; + } + + async createRole(input: CreateRoleInput): Promise { + const role = this.summary({ id: 'rol_created', name: input.name, description: input.description ?? null }); + this.roles.set(role.id, role); + return role; + } + + async updateRole(roleId: string, input: UpdateRoleInput): Promise { + const current = this.roles.get(roleId) ?? this.summary({ id: roleId, name: 'Missing Role' }); + const role: RoleSummary = { + ...current, + name: input.name ?? current.name, + description: input.description === undefined ? current.description : input.description, + status: input.status ?? current.status, + permissionIds: input.permissionIds ?? current.permissionIds, + updatedAt: new Date('2026-06-24T08:00:00.000Z') + }; + this.roles.set(role.id, role); + return role; + } + + async softDeleteRole(roleId: string): Promise { + const role = this.roles.get(roleId); + if (!role) { + throw new BadRequestException({ code: 'ROLE_NOT_FOUND', message: 'Role not found.' }); + } + if (role.builtIn) { + throw new ForbiddenException({ code: 'BUILT_IN_ROLE_PROTECTED', message: 'Built-in roles are protected.' }); + } + if (role.userCount > 0) { + throw new BadRequestException({ code: 'ROLE_HAS_USERS', message: 'Role with active users cannot be deleted.' }); + } + const deleted: RoleSummary = { ...role, status: 'DISABLED', updatedAt: new Date('2026-06-24T08:00:00.000Z') }; + this.roles.delete(roleId); + return deleted; + } + + decrementUserCount(roleIds: string[]) { + for (const roleId of roleIds) { + const role = this.roles.get(roleId); + if (role) { + this.roles.set(roleId, { ...role, userCount: Math.max(0, role.userCount - 1) }); + } + } + } + + private summary(input: { id: string; name: string; description?: string | null; builtIn?: boolean; userCount?: number }): RoleSummary { + return { + id: input.id, + name: input.name, + description: input.description ?? null, + builtIn: input.builtIn ?? false, + status: 'ENABLED', + permissionIds: ['users.manage'], + userCount: input.userCount ?? 0, + createdAt: new Date('2026-06-24T07:00:00.000Z'), + updatedAt: new Date('2026-06-24T07:00:00.000Z') + }; + } +} + +class MemoryUsersRepository implements UsersRepository { + private readonly users = new Map(); + + constructor(private readonly roles: MemoryRolesRepository) { + this.users.set('usr_seed', this.summary({ id: 'usr_seed', username: 'seed', displayName: 'Seed User', roleIds: ['rol_used'], roles: ['运营'] })); + } + + async list(): Promise { + return [...this.users.values()]; + } + + async create(input: CreateUserInput): Promise { + const user = this.summary({ id: 'usr_created', username: input.username, displayName: input.displayName, roleIds: input.roleIds }); + this.users.set(user.id, user); + return user; + } + + async update(userId: string, input: UpdateUserInput): Promise { + const current = this.users.get(userId) ?? this.summary({ id: userId, username: 'missing', displayName: 'Missing' }); + const updated: UserSummary = { + ...current, + displayName: input.displayName ?? current.displayName, + phone: input.phone === undefined ? current.phone : input.phone, + email: input.email === undefined ? current.email : input.email, + status: input.status ?? current.status, + roleIds: input.roleIds ?? current.roleIds, + updatedAt: new Date('2026-06-24T08:00:00.000Z') + }; + this.users.set(userId, updated); + return updated; + } + + async resetPassword(userId: string): Promise { + return this.users.get(userId) ?? this.summary({ id: userId, username: 'missing', displayName: 'Missing' }); + } + + async softDelete(userId: string): Promise { + const user = this.users.get(userId) ?? this.summary({ id: userId, username: 'missing', displayName: 'Missing' }); + const deleted: UserSummary = { ...user, status: 'DISABLED', roleIds: [], roles: [], updatedAt: new Date('2026-06-24T08:00:00.000Z') }; + this.users.delete(userId); + this.roles.decrementUserCount(user.roleIds); + return deleted; + } + + private summary(input: { id: string; username: string; displayName: string; roleIds?: string[]; roles?: string[] }): UserSummary { + return { + id: input.id, + username: input.username, + displayName: input.displayName, + phone: null, + email: null, + status: 'ENABLED', + requirePasswordChange: false, + lastLoginAt: null, + roles: input.roles ?? [], + roleIds: input.roleIds ?? [], + createdAt: new Date('2026-06-24T07:00:00.000Z'), + updatedAt: new Date('2026-06-24T07:00:00.000Z') + }; + } +} + +describe('S40 users and roles delete 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(); + const roles = new MemoryRolesRepository(); + + identities.users.set('usr_ops', { + id: 'usr_ops', + username: 'ops', + roles: ['运营管理员'], + permissions: ['users.view', 'users.manage', 'roles.view', 'roles.manage'] as PermissionKey[] + }); + identities.users.set('usr_viewer', { + id: 'usr_viewer', + username: 'viewer', + roles: ['只读'], + permissions: ['users.view', 'roles.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(ROLES_REPOSITORY) + .useValue(roles) + .overrideProvider(USERS_REPOSITORY) + .useValue(new MemoryUsersRepository(roles)) + .compile(); + + app = moduleFixture.createNestApplication(new FastifyAdapter({ logger: false })); + app.setGlobalPrefix('api/v2'); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }, 30000); + + afterAll(async () => { + await app?.close(); + }); + + it('rejects delete operations without manage permissions', async () => { + await request(app.getHttpServer()).delete('/api/v2/users/usr_seed').set('Authorization', `Bearer ${tokenFor('usr_viewer')}`).expect(403); + await request(app.getHttpServer()).delete('/api/v2/roles/rol_empty').set('Authorization', `Bearer ${tokenFor('usr_viewer')}`).expect(403); + }); + + it('soft deletes users with an audit entry', async () => { + await request(app.getHttpServer()) + .delete('/api/v2/users/usr_seed') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .expect(200) + .expect((response) => { + expect(response.body).toMatchObject({ id: 'usr_seed', status: 'DISABLED', roleIds: [] }); + }); + + expect(audit.entries.some((entry) => entry.module === 'users' && entry.action === 'delete' && entry.objectId === 'usr_seed')).toBe(true); + }); + + it('protects built-in and still-used roles, then deletes an empty custom role with audit', async () => { + await request(app.getHttpServer()).delete('/api/v2/roles/rol_builtin').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(403); + + await request(app.getHttpServer()) + .delete('/api/v2/roles/rol_blocked') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .expect(400) + .expect((response) => { + expect(response.body).toMatchObject({ code: 'ROLE_HAS_USERS' }); + }); + + await request(app.getHttpServer()) + .delete('/api/v2/roles/rol_empty') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .expect(200) + .expect((response) => { + expect(response.body).toMatchObject({ id: 'rol_empty', status: 'DISABLED' }); + }); + + expect(audit.entries.some((entry) => entry.module === 'roles' && entry.action === 'delete' && entry.objectId === 'rol_empty')).toBe(true); + }); +}); diff --git a/apps/api/src/modules/users/users.controller.ts b/apps/api/src/modules/users/users.controller.ts index c8cf776..0cb85dc 100644 --- a/apps/api/src/modules/users/users.controller.ts +++ b/apps/api/src/modules/users/users.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common'; +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'; @@ -35,4 +35,11 @@ export class UsersController { resetPassword(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) { return this.usersService.resetPassword(id, body as never, currentUser?.id); } + + @Delete(':id') + @RequirePermissions('users.manage') + @AuditAction({ module: 'users', action: 'delete', objectType: 'user', objectIdParam: 'id' }) + remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.usersService.remove(id, currentUser?.id); + } } diff --git a/apps/api/src/modules/users/users.repository.ts b/apps/api/src/modules/users/users.repository.ts index f1fc076..3294576 100644 --- a/apps/api/src/modules/users/users.repository.ts +++ b/apps/api/src/modules/users/users.repository.ts @@ -43,6 +43,7 @@ export interface UsersRepository { create(input: CreateUserInput): Promise; update(userId: string, input: UpdateUserInput): Promise; resetPassword(userId: string, passwordHash: string, passwordAlgo: string, actorId?: string): Promise; + softDelete(userId: string, actorId?: string): Promise; } export const USERS_REPOSITORY = Symbol('USERS_REPOSITORY'); @@ -167,24 +168,61 @@ export class PrismaUsersRepository implements UsersRepository { } async resetPassword(userIdValue: string, passwordHash: string, passwordAlgo: string, actorId?: string): Promise { - const user = await this.prisma.user.update({ - where: { id: userIdValue }, - data: { - passwordHash, - passwordAlgo, - requirePasswordChange: true, - failedLoginCount: 0, - lockedUntil: null, - updatedBy: actorId, - version: { increment: 1 } - }, - include: { - userRoles: { - include: { - role: true + 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.' }); + } + + return tx.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); + } + + async softDelete(userIdValue: string, actorId?: string): Promise { + 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.userRole.deleteMany({ where: { userId: userIdValue } }); + + return tx.user.update({ + where: { id: userIdValue }, + data: { + status: 'DISABLED', + deletedAt: new Date(), + updatedBy: actorId, + version: { increment: 1 } + }, + include: { + userRoles: { + include: { + role: true + } + } + } + }); }); return this.toSummary(user); diff --git a/apps/api/src/modules/users/users.service.ts b/apps/api/src/modules/users/users.service.ts index 8b724fb..6537df5 100644 --- a/apps/api/src/modules/users/users.service.ts +++ b/apps/api/src/modules/users/users.service.ts @@ -68,6 +68,10 @@ export class UsersService { return this.users.resetPassword(userId, await hashPasswordArgon2id(this.password(body.password)), PASSWORD_ALGO_ARGON2ID, actorId); } + remove(userId: string, actorId?: string): Promise { + return this.users.softDelete(userId, 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.` }); diff --git a/apps/api/src/modules/vendor-gateways/vendor-gateways.controller.ts b/apps/api/src/modules/vendor-gateways/vendor-gateways.controller.ts index 56e75f3..fb07689 100644 --- a/apps/api/src/modules/vendor-gateways/vendor-gateways.controller.ts +++ b/apps/api/src/modules/vendor-gateways/vendor-gateways.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, 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'; @@ -48,4 +48,11 @@ export class VendorGatewaysController { disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { return this.vendorGatewaysService.disable(id, currentUser?.id); } + + @Delete(':id') + @RequirePermissions('vendor_gateways.manage') + @AuditAction({ module: 'vendor_gateways', action: 'delete', objectType: 'vendor_gateway', objectIdParam: 'id' }) + remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { + return this.vendorGatewaysService.remove(id, currentUser?.id); + } } diff --git a/apps/api/src/modules/vendor-gateways/vendor-gateways.e2e.spec.ts b/apps/api/src/modules/vendor-gateways/vendor-gateways.e2e.spec.ts index 8d06ddc..d0b11ec 100644 --- a/apps/api/src/modules/vendor-gateways/vendor-gateways.e2e.spec.ts +++ b/apps/api/src/modules/vendor-gateways/vendor-gateways.e2e.spec.ts @@ -114,6 +114,14 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository { return this.update(gatewayId, { status }); } + async softDelete(gatewayId: string): Promise { + const current = await this.get(gatewayId); + const deleted = this.summary({ ...current, status: 'DISABLED' }); + this.gateways.delete(gatewayId); + this.outboxEvents += 1; + return deleted; + } + private summary(input: Partial & { id: string; name: string }): VendorGatewaySummary { const cycleRate = input.cycleRate ?? '0.000000'; const billingCycleSec = input.billingCycleSec ?? 60; @@ -313,5 +321,7 @@ describe('S16 vendor gateways API', () => { 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); + await request(app.getHttpServer()).delete('/api/v2/vendor-gateways/vgw_created').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200); + expect(audit.entries.some((entry) => entry.module === 'vendor_gateways' && entry.action === 'delete' && entry.result === 'SUCCESS')).toBe(true); }); }); diff --git a/apps/api/src/modules/vendor-gateways/vendor-gateways.repository.ts b/apps/api/src/modules/vendor-gateways/vendor-gateways.repository.ts index 78cdf4d..ba03536 100644 --- a/apps/api/src/modules/vendor-gateways/vendor-gateways.repository.ts +++ b/apps/api/src/modules/vendor-gateways/vendor-gateways.repository.ts @@ -1,4 +1,4 @@ -import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +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'; @@ -100,6 +100,7 @@ export interface VendorGatewaysRepository { create(input: CreateVendorGatewayInput): Promise; update(gatewayId: string, input: UpdateVendorGatewayInput): Promise; setStatus(gatewayId: string, status: VendorGatewayStatus, actorId?: string): Promise; + softDelete(gatewayId: string, actorId?: string): Promise; } export const VENDOR_GATEWAYS_REPOSITORY = Symbol('VENDOR_GATEWAYS_REPOSITORY'); @@ -244,6 +245,38 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository return this.toSummary(gateway); } + async softDelete(gatewayIdValue: string, actorId?: string): Promise { + await this.findActiveOrThrow(gatewayIdValue); + const linkedLineGroups = await this.prisma.landingLineGroupItem.count({ + where: { + vendorGatewayId: gatewayIdValue, + lineGroup: { deletedAt: null } + } + }); + if (linkedLineGroups > 0) { + throw new BadRequestException({ + code: 'VENDOR_GATEWAY_IN_LINE_GROUP', + message: 'Vendor gateway referenced by active line groups cannot be deleted.' + }); + } + + const gateway = await this.prisma.$transaction(async (tx) => { + const deleted = await tx.vendorGateway.update({ + where: { id: gatewayIdValue }, + data: { + status: 'DISABLED', + deletedAt: new Date(), + updatedBy: actorId, + version: { increment: 1 } + }, + include: this.includeSummary() + }); + await this.enqueueConfigOutbox(tx, deleted.id, 'vendor_gateway.deleted'); + return deleted; + }); + return this.toSummary(gateway); + } + private async ensureVendor(vendorId: string): Promise { const vendor = await this.prisma.vendor.findUnique({ where: { id: vendorId }, diff --git a/apps/api/src/modules/vendor-gateways/vendor-gateways.service.ts b/apps/api/src/modules/vendor-gateways/vendor-gateways.service.ts index ba0f74d..8ab9075 100644 --- a/apps/api/src/modules/vendor-gateways/vendor-gateways.service.ts +++ b/apps/api/src/modules/vendor-gateways/vendor-gateways.service.ts @@ -121,6 +121,10 @@ export class VendorGatewaysService { return this.gateways.setStatus(gatewayId, 'DISABLED', actorId); } + remove(gatewayId: string, actorId?: string): Promise { + return this.gateways.softDelete(gatewayId, 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.` }); diff --git a/apps/api/src/shared/config.ts b/apps/api/src/shared/config.ts index e141a94..f1b7ff3 100644 --- a/apps/api/src/shared/config.ts +++ b/apps/api/src/shared/config.ts @@ -18,12 +18,20 @@ export interface RuntimeConfig { redis: { url: string; }; + activeCalls: { + sshHost: string; + sshConfig?: string; + remoteCommand: string; + timeoutMs: number; + }; auth: { accessTokenSecret: string; accessTokenTtlSeconds: number; refreshTokenTtlDays: number; lockMaxFailures: number; lockWindowSeconds: number; + loginThrottleMaxFailures: number; + loginThrottleWindowSeconds: number; cookieSecure: boolean; tokenIssuer: string; tokenAudience: string; @@ -39,6 +47,10 @@ export const validationSchema = Joi.object({ LISGLOSIPS_REQUEST_ID_HEADER: Joi.string().default('x-request-id'), DATABASE_URL: Joi.string().uri({ scheme: ['mysql'] }).required(), REDIS_URL: Joi.string().uri({ scheme: ['redis', 'rediss'] }).required(), + ACTIVE_CALLS_SSH_HOST: Joi.string().allow('').default('lisglosips-a'), + ACTIVE_CALLS_SSH_CONFIG: Joi.string().allow('').optional(), + ACTIVE_CALLS_REMOTE_COMMAND: Joi.string().default('/usr/local/sbin/lisglosips-call-control'), + ACTIVE_CALLS_TIMEOUT_MS: Joi.number().integer().min(1000).max(30000).default(5000), AUTH_ACCESS_TOKEN_SECRET: Joi.string().min(32).when('NODE_ENV', { is: 'production', then: Joi.required(), @@ -48,12 +60,16 @@ export const validationSchema = Joi.object({ AUTH_REFRESH_TOKEN_TTL_DAYS: Joi.number().integer().min(1).max(30).default(7), AUTH_LOCK_MAX_FAILURES: Joi.number().integer().min(3).max(20).default(5), AUTH_LOCK_WINDOW_SECONDS: Joi.number().integer().min(60).max(86_400).default(900), - AUTH_COOKIE_SECURE: Joi.boolean().truthy('true').falsy('false').default(true), + AUTH_LOGIN_THROTTLE_MAX_FAILURES: Joi.number().integer().min(3).max(100).default(10), + AUTH_LOGIN_THROTTLE_WINDOW_SECONDS: Joi.number().integer().min(60).max(86_400).default(300), + AUTH_COOKIE_SECURE: Joi.any().default(true), AUTH_TOKEN_ISSUER: Joi.string().default('lisglosips-api'), AUTH_TOKEN_AUDIENCE: Joi.string().default('lisglosips-web') }); export function appConfig(): RuntimeConfig { + const cookieSecureValue = (process.env.AUTH_COOKIE_SECURE ?? 'true').trim().replace(/^['"]|['"]$/g, '').toLowerCase(); + return { service: { name: process.env.LISGLOSIPS_SERVICE_NAME ?? 'api' @@ -72,13 +88,21 @@ export function appConfig(): RuntimeConfig { redis: { url: process.env.REDIS_URL ?? '' }, + activeCalls: { + sshHost: process.env.ACTIVE_CALLS_SSH_HOST || 'lisglosips-a', + sshConfig: process.env.ACTIVE_CALLS_SSH_CONFIG || undefined, + remoteCommand: process.env.ACTIVE_CALLS_REMOTE_COMMAND ?? '/usr/local/sbin/lisglosips-call-control', + timeoutMs: Number(process.env.ACTIVE_CALLS_TIMEOUT_MS ?? 5000) + }, auth: { accessTokenSecret: process.env.AUTH_ACCESS_TOKEN_SECRET ?? 'dev-only-change-this-auth-secret-32-bytes-min', accessTokenTtlSeconds: Number(process.env.AUTH_ACCESS_TOKEN_TTL_SECONDS ?? 900), refreshTokenTtlDays: Number(process.env.AUTH_REFRESH_TOKEN_TTL_DAYS ?? 7), lockMaxFailures: Number(process.env.AUTH_LOCK_MAX_FAILURES ?? 5), lockWindowSeconds: Number(process.env.AUTH_LOCK_WINDOW_SECONDS ?? 900), - cookieSecure: (process.env.AUTH_COOKIE_SECURE ?? 'true') !== 'false', + loginThrottleMaxFailures: Number(process.env.AUTH_LOGIN_THROTTLE_MAX_FAILURES ?? 10), + loginThrottleWindowSeconds: Number(process.env.AUTH_LOGIN_THROTTLE_WINDOW_SECONDS ?? 300), + cookieSecure: cookieSecureValue !== 'false' && cookieSecureValue !== '0', tokenIssuer: process.env.AUTH_TOKEN_ISSUER ?? 'lisglosips-api', tokenAudience: process.env.AUTH_TOKEN_AUDIENCE ?? 'lisglosips-web' } diff --git a/apps/web/src/App.jsx b/apps/web/src/App.jsx index fdfe109..ffdfe2c 100644 --- a/apps/web/src/App.jsx +++ b/apps/web/src/App.jsx @@ -35,10 +35,32 @@ function formatDateTime(value) { return date.toLocaleString('zh-CN', { hour12: false }); } +function formatDurationText(seconds) { + const value = Math.max(0, Number(seconds) || 0); + const hours = Math.floor(value / 3600); + const minutes = Math.floor((value % 3600) / 60); + const remainSeconds = value % 60; + return hours > 0 + ? `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(remainSeconds).padStart(2, '0')}` + : `${String(minutes).padStart(2, '0')}:${String(remainSeconds).padStart(2, '0')}`; +} + function zhStatus(status) { return status === 'ENABLED' ? '启用' : status === 'DISABLED' ? '停用' : status === 'SUCCEEDED' ? '成功' : status || '-'; } +function carrierLabel(value) { + const labels = { + MOBILE: '移动', + UNICOM: '联通', + TELECOM: '电信', + BROADCAST: '广电', + MVNO: '虚拟运营商', + UNKNOWN: '未知', + }; + return labels[value] || value || '-'; +} + function enStatus(status) { return status === '启用' ? 'ENABLED' : status === '停用' || status === '禁用' ? 'DISABLED' : status; } @@ -81,6 +103,65 @@ function normalizeVendor(item) { }; } +function authModeLabel(value) { + if (value === 'SIP_DIGEST') return 'SIP注册'; + if (value === 'MIXED') return '混合认证'; + return value || 'IP'; +} + +function normalizeCustomerGateway(item) { + return { + id: item.id, + customerId: item.customerId, + customer: item.customerName || '-', + name: item.name, + authMode: authModeLabel(item.authMode), + ipAddress: item.sourceIp || '', + sipAccount: item.sipUsername || '', + sipPassword: item.hasSipCredential ? '******' : '', + routePolicyCount: item.policyCount ?? 0, + status: zhStatus(item.status), + createdAt: formatDate(item.createdAt), + }; +} + +function normalizeVendorGateway(item) { + return { + id: item.id, + vendorId: item.vendorId, + vendor: item.vendorName || '-', + name: item.name, + authMode: authModeLabel(item.authMode), + ipAddress: item.host || '', + sipAccount: item.sipUsername || '', + sipPassword: item.hasSipCredential ? '******' : '', + concurrencyLimit: item.concurrencyLimit ?? 0, + billingCycle: item.billingCycleSec ?? 60, + cycleRate: Number(item.cycleRate || 0), + requestRate: `${item.cpsLimit ?? 0} CPS`, + blockedProvinces: '-', + callTimeLimit: '-', + codecs: (item.codecs || []).map((codec) => codec.codec).join(', ') || '-', + calleePrefixTransform: (item.prefixRules || []).filter((rule) => rule.direction === 'CALLEE').map((rule) => `${rule.matchPrefix} -> ${rule.replacePrefix || rule.matchPrefix}`).join('\n') || '-', + callerPrefixTransform: (item.prefixRules || []).filter((rule) => rule.direction === 'CALLER').map((rule) => `${rule.matchPrefix} -> ${rule.replacePrefix || rule.matchPrefix}`).join('\n') || '-', + status: zhStatus(item.status), + createdAt: formatDate(item.createdAt), + }; +} + +function normalizeLandingLineGroup(item) { + return { + id: item.id, + name: item.name, + status: zhStatus(item.status), + customerGatewayCount: item.customerGatewayCount ?? 0, + policyCount: item.policyCount ?? 0, + gatewayIds: (item.items || []).map((line) => line.vendorGatewayId), + items: item.items || [], + createdAt: formatDate(item.createdAt), + }; +} + function normalizeRecharge(item) { return { id: item.id, @@ -118,6 +199,7 @@ function normalizeRole(item) { status: zhStatus(item.status), builtIn: Boolean(item.builtIn), permissions: item.permissionIds || [], + userCount: item.userCount ?? 0, }; } @@ -148,12 +230,14 @@ const navGroups = [ { key: 'vendors', label: '供应商管理' }, { key: 'vendorGateways', label: '落地网关管理' }, { key: 'vendorLineGroups', label: '落地线路组' }, + { key: 'numberLibrary', label: '号码库' }, { key: 'billing', label: '费率与计费', pending: true }, ], }, { title: '业务', items: [ + { key: 'activeCalls', label: '当前通话' }, { key: 'cdr', label: '话单中心' }, { key: 'quality', label: '质检中心' }, { key: 'sipops', label: 'SIP 运维', pending: true }, @@ -363,8 +447,11 @@ function Icon({ type }) { search: <>, export: <>, reload: <>, + arrow: <>, + phoneOff: <>, collapse: <>, expand: <>, + logout: <>, }; return {paths[type]}; } @@ -438,6 +525,30 @@ function Modal({ title, aside, children, onClose, size = 'lg' }) { ); } +function ConfirmDialog({ + title, + children, + confirmLabel = '确认', + confirmVariant = 'primary', + busy = false, + onCancel, + onConfirm, +}) { + return ( + {} : onCancel} size="sm"> +
+ {typeof children === 'string' ?

{children}

: children} +
+
+ + +
+
+ ); +} + function Drawer({ title, aside, children, onClose }) { return (
@@ -463,9 +574,14 @@ function SimpleTable({ columns, rows, onRowClick, selectedKey }) { return (
+ {columns.some((column) => column.width) ? ( + + {columns.map((column) => )} + + ) : null} - {columns.map((column) => )} + {columns.map((column) => )} @@ -482,7 +598,7 @@ function SimpleTable({ columns, rows, onRowClick, selectedKey }) { onClick={onRowClick ? () => onRowClick(row) : undefined} > {columns.map((column) => ( - ))} @@ -590,10 +706,128 @@ function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, apiError ); } -function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord, apiLoading, apiError, refreshApi, onCreateCustomer, onUpdateCustomer, onRechargeCustomer }) { +function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsError, refreshActiveCalls, onHangupActiveCall }) { + const [busyId, setBusyId] = useState(''); + const [hangupTarget, setHangupTarget] = useState(null); + const [autoRefresh, setAutoRefresh] = useState(true); + + const rows = activeCalls.map((call) => ({ + ...call, + callerText: call.caller || '-', + calleeText: call.callee || '-', + callerIpText: call.callerIp || '-', + landingIpText: call.landingIp || '-', + stateText: call.state || '未知', + startedText: formatDateTime(call.startedAt), + durationText: call.durationSec === null || call.durationSec === undefined ? '-' : formatDurationText(call.durationSec), + })); + + const hangup = async (call) => { + setBusyId(call.id); + try { + await onHangupActiveCall(call.id); + } finally { + setBusyId(''); + setHangupTarget(null); + } + }; + + useEffect(() => { + if (!autoRefresh) { + return undefined; + } + const timer = window.setInterval(() => { + if (!activeCallsLoading) { + void refreshActiveCalls(); + } + }, 5000); + return () => window.clearInterval(timer); + }, [autoRefresh, activeCallsLoading, refreshActiveCalls]); + + return ( + <> + + + + + )} + /> + +
+
+ 当前通话数 + {rows.length} + OpenSIPS MI +
+
+ 最长通话 + {rows.length ? rows[0].durationText : '00:00'} + 按持续时长排序 +
+
+ 控制面 + {activeCallsError ? '异常' : '就绪'} + MI 受控访问 +
+
+ {rows.length} 路} className="wide-panel"> + ( + + ) + }, + ]} /> + + {hangupTarget ? ( + setHangupTarget(null)} + onConfirm={() => void hangup(hangupTarget)} + > +

确认强制挂断当前通话?

+

{hangupTarget.callId || hangupTarget.id}

+
+ ) : null} + + ); +} + +function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord, apiLoading, apiError, refreshApi, onCreateCustomer, onUpdateCustomer, onDeleteCustomer, onRechargeCustomer }) { const [showCreateCustomer, setShowCreateCustomer] = useState(false); const [editingCustomer, setEditingCustomer] = useState(null); const [rechargeCustomer, setRechargeCustomer] = useState(null); + const [deleteCustomerTarget, setDeleteCustomerTarget] = useState(null); const [newCustomer, setNewCustomer] = useState({ name: '', contact: '', phone: '', email: '' }); const [editCustomerForm, setEditCustomerForm] = useState({ name: '', contact: '', phone: '', email: '' }); const [rechargeForm, setRechargeForm] = useState({ amount: '', remark: '' }); @@ -692,6 +926,22 @@ function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord, apiLo setActionError(explainApiError(error)); } }; + const deleteCustomer = async (customer) => { + setActionError(''); + try { + if (onDeleteCustomer) { + await onDeleteCustomer(customer.id); + } else { + if ((customer.gateways ?? 0) > 0) { + throw new Error('该客户仍有关联客户网关,不能删除。'); + } + setCustomerRows((rows) => rows.filter((item) => item.id !== customer.id)); + } + setDeleteCustomerTarget(null); + } catch (error) { + setActionError(explainApiError(error)); + } + }; return ( <> @@ -711,8 +961,8 @@ function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord, apiLo + ), }, @@ -792,6 +1043,17 @@ function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord, apiLo ) : null} + {deleteCustomerTarget ? ( + setDeleteCustomerTarget(null)} + onConfirm={() => void deleteCustomer(deleteCustomerTarget)} + > +

确认删除客户「{deleteCustomerTarget.name}」吗?删除后该客户将不再出现在客户列表中。

+
+ ) : null} ); } @@ -804,8 +1066,8 @@ function RechargeRecordsPage({ rechargeRows, apiLoading, apiError, refreshApi })
{ + const toggleGatewayStatus = async (gateway) => { + if (onToggleGatewayStatus) { + await onToggleGatewayStatus(gateway); + return; + } setGatewayRows((rows) => rows.map((item) => ( item.id === gateway.id ? { ...item, status: item.status === '启用' ? '停用' : '启用' } : item ))); }; + const deleteGateway = async (gateway) => { + if (onDeleteGateway) { + await onDeleteGateway(gateway.id); + return; + } + setGatewayRows((rows) => rows.filter((item) => item.id !== gateway.id)); + setPolicyRows((rows) => rows.filter((policy) => policy.gateway !== gateway.id)); + }; const openPolicyDrawer = (gatewayId) => { setStrategyGateway(gatewayId); setPolicyModalOpen(false); @@ -1032,11 +1309,12 @@ function CustomerGatewaysPage() { desc="独立管理客户接入网关、认证方式、路由策略数量和启停状态。" actions={} /> +
(row.authMode === 'IP' ? row.ipAddress : row.sipAccount) }, { key: 'routePolicyCount', label: '路由策略数', render: (row) => policyRows.filter((policy) => policy.gateway === row.id).length }, @@ -1047,10 +1325,11 @@ function CustomerGatewaysPage() { render: (row) => (
- +
), }, @@ -1093,6 +1372,25 @@ function CustomerGatewaysPage() { ) : null} + {gatewayConfirm ? ( + setGatewayConfirm(null)} + onConfirm={() => { + const { type, row } = gatewayConfirm; + setGatewayConfirm(null); + return type === 'delete' ? void deleteGateway(row) : void toggleGatewayStatus(row); + }} + > + {gatewayConfirm.type === 'delete' ? ( +

确认删除客户网关「{gatewayConfirm.row.name}」吗?删除后该网关的路由策略也会停止生效。

+ ) : ( +

确认{gatewayConfirm.row.status === '启用' ? '禁用' : '启用'}客户网关「{gatewayConfirm.row.name}」吗?

+ )} +
+ ) : null} {strategyGatewayData ? ( {strategyGatewayData.id}} onClose={closePolicyDrawer}>
@@ -1120,7 +1418,7 @@ function CustomerGatewaysPage() { - +
), }, @@ -1180,24 +1478,25 @@ function CustomerGatewaysPage() { ) : null} {deletePolicyTarget ? ( - setDeletePolicyTarget(null)} size="sm"> -
-

确认删除策略「{deletePolicyTarget.name}」吗?删除后当前网关的策略优先级会自动重排。

-
-
- - -
-
+ setDeletePolicyTarget(null)} + onConfirm={() => deletePolicy(deletePolicyTarget.id)} + > +

确认删除策略「{deletePolicyTarget.name}」吗?删除后当前网关的策略优先级会自动重排。

+
) : null} ); } -function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiLoading, apiError, refreshApi, onCreateVendor, onUpdateVendor, onRechargeVendor }) { +function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiLoading, apiError, refreshApi, onCreateVendor, onUpdateVendor, onDeleteVendor, onRechargeVendor }) { const [showCreateVendor, setShowCreateVendor] = useState(false); const [editingVendor, setEditingVendor] = useState(null); const [rechargeVendor, setRechargeVendor] = useState(null); + const [deleteVendorTarget, setDeleteVendorTarget] = useState(null); const [newVendor, setNewVendor] = useState({ name: '' }); const [editVendorForm, setEditVendorForm] = useState({ name: '' }); const [rechargeForm, setRechargeForm] = useState({ amount: '', remark: '' }); @@ -1275,6 +1574,22 @@ function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiLoading, setActionError(explainApiError(error)); } }; + const deleteVendor = async (vendor) => { + setActionError(''); + try { + if (onDeleteVendor) { + await onDeleteVendor(vendor.id); + } else { + if ((vendor.gateways ?? 0) > 0) { + throw new Error('该供应商仍有关联落地网关,不能删除。'); + } + setVendorRows((rows) => rows.filter((item) => item.id !== vendor.id)); + } + setDeleteVendorTarget(null); + } catch (error) { + setActionError(explainApiError(error)); + } + }; return ( <> @@ -1287,10 +1602,12 @@ function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiLoading,
+ ), }, @@ -1347,13 +1665,29 @@ function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiLoading, ) : null} + {deleteVendorTarget ? ( + setDeleteVendorTarget(null)} + onConfirm={() => void deleteVendor(deleteVendorTarget)} + > +

确认删除供应商「{deleteVendorTarget.name}」吗?删除后该供应商将不再出现在供应商列表中。

+
+ ) : null} ); } -function VendorGatewaysPage() { - const [gatewayRows, setGatewayRows] = useState(gateways); +function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows: setApiGatewayRows, vendorRows: apiVendorRows, apiLoading, apiError, refreshApi, onToggleGatewayStatus, onDeleteGateway }) { + const [localGatewayRows, setLocalGatewayRows] = useState(gateways); + const gatewayRows = Array.isArray(apiGatewayRows) ? apiGatewayRows : localGatewayRows; + const setGatewayRows = setApiGatewayRows || setLocalGatewayRows; + const vendorOptions = apiVendorRows?.length ? apiVendorRows : vendors; + const [actionError, setActionError] = useState(''); const [editingGateway, setEditingGateway] = useState(null); + const [gatewayConfirm, setGatewayConfirm] = useState(null); const provinceOptions = ['北京', '上海', '广东', '浙江', '江苏', '新疆', '西藏', '港澳台', '海外']; const codecOptions = ['PCMA', 'PCMU', 'G729', 'G722', 'OPUS']; const splitList = (value) => (value && !['无', '-'].includes(value) ? value.split(/[,,、]/).map((item) => item.trim()).filter(Boolean) : []); @@ -1392,7 +1726,7 @@ function VendorGatewaysPage() { return text || '-'; }; const emptyGatewayForm = { - vendor: vendors[0].name, + vendor: vendorOptions[0]?.name || '', name: '', authMode: 'IP', ipAddress: '', @@ -1510,25 +1844,47 @@ function VendorGatewaysPage() { forbiddenPeriods: form.forbiddenPeriods.length > 1 ? form.forbiddenPeriods.filter((_, periodIndex) => periodIndex !== index) : [{ start: '', end: '' }], })); }; - const toggleVendorGatewayStatus = (gateway) => { - setGatewayRows((rows) => rows.map((item) => ( - item.id === gateway.id ? { ...item, status: item.status === '启用' ? '禁用' : '启用' } : item - ))); + const toggleVendorGatewayStatus = async (gateway) => { + setActionError(''); + try { + if (onToggleGatewayStatus) { + await onToggleGatewayStatus(gateway); + return; + } + setGatewayRows((rows) => rows.map((item) => ( + item.id === gateway.id ? { ...item, status: item.status === '启用' ? '禁用' : '启用' } : item + ))); + } catch (error) { + setActionError(explainApiError(error)); + } + }; + const deleteVendorGateway = async (gateway) => { + setActionError(''); + try { + if (onDeleteGateway) { + await onDeleteGateway(gateway.id); + return; + } + setGatewayRows((rows) => rows.filter((item) => item.id !== gateway.id)); + } catch (error) { + setActionError(explainApiError(error)); + } }; return ( <> }>新增落地网关} /> + - +
(row.authMode === 'IP' ? row.ipAddress : row.sipAccount) }, { key: 'concurrencyLimit', label: '并发上限' }, @@ -1540,9 +1896,10 @@ function VendorGatewaysPage() { render: (row) => (
- +
), }, @@ -1555,7 +1912,7 @@ function VendorGatewaysPage() {
供应商名称 *}> 落地网关名称 *}> @@ -1621,7 +1978,7 @@ function VendorGatewaysPage() { updateForbiddenPeriod(index, 'start', event.target.value)} /> updateForbiddenPeriod(index, 'end', event.target.value)} /> - +
))} @@ -1645,7 +2002,7 @@ function VendorGatewaysPage() { updateTransform('calleeTransforms', index, 'from', event.target.value)} placeholder="原前缀,如 00" /> 转换为 updateTransform('calleeTransforms', index, 'to', event.target.value)} placeholder="目标前缀,如 011" /> - + ))} @@ -1661,7 +2018,7 @@ function VendorGatewaysPage() { updateTransform('callerTransforms', index, 'from', event.target.value)} placeholder="原前缀,如 021" /> 转换为 updateTransform('callerTransforms', index, 'to', event.target.value)} placeholder="目标前缀,如 8621" /> - + ))} @@ -1674,17 +2031,41 @@ function VendorGatewaysPage() { ) : null} + {gatewayConfirm ? ( + setGatewayConfirm(null)} + onConfirm={() => { + const { type, row } = gatewayConfirm; + setGatewayConfirm(null); + return type === 'delete' ? void deleteVendorGateway(row) : void toggleVendorGatewayStatus(row); + }} + > + {gatewayConfirm.type === 'delete' ? ( +

确认删除落地网关「{gatewayConfirm.row.name}」吗?删除后该网关将不再参与落地。

+ ) : ( +

确认{gatewayConfirm.row.status === '启用' ? '禁用' : '启用'}落地网关「{gatewayConfirm.row.name}」吗?

+ )} +
+ ) : null} ); } -function VendorLineGroupsPage() { - const [lineGroupRows, setLineGroupRows] = useState(vendorLineGroups); +function VendorLineGroupsPage({ lineGroupRows: apiLineGroupRows, setLineGroupRows: setApiLineGroupRows, gatewayRows: apiGatewayRows, apiLoading, apiError, refreshApi, onDeleteLineGroup }) { + const [localLineGroupRows, setLocalLineGroupRows] = useState(vendorLineGroups); + const lineGroupRows = Array.isArray(apiLineGroupRows) ? apiLineGroupRows : localLineGroupRows; + const setLineGroupRows = setApiLineGroupRows || setLocalLineGroupRows; + const availableGateways = apiGatewayRows?.length ? apiGatewayRows : gateways; + const [actionError, setActionError] = useState(''); const [editingLineGroup, setEditingLineGroup] = useState(null); + const [deleteLineGroupTarget, setDeleteLineGroupTarget] = useState(null); const [lineGroupForm, setLineGroupForm] = useState({ name: '', gatewayIds: [] }); const [showAddGatewayModal, setShowAddGatewayModal] = useState(false); const [addGatewayForm, setAddGatewayForm] = useState({ gatewayId: '' }); - const findGateway = (gatewayId) => gateways.find((gateway) => gateway.id === gatewayId); + const findGateway = (gatewayId) => availableGateways.find((gateway) => gateway.id === gatewayId); const getLineGroupConcurrency = (group) => group.gatewayIds.reduce((total, gatewayId) => total + (findGateway(gatewayId)?.concurrencyLimit || 0), 0); const openEditLineGroup = (group) => { setEditingLineGroup(group); @@ -1725,10 +2106,29 @@ function VendorLineGroupsPage() { ))); closeEditLineGroup(); }; + const deleteLineGroup = async (group) => { + if ((group.customerGatewayCount ?? 0) > 0) { + setActionError(`线路组「${group.name}」仍被 ${group.customerGatewayCount} 个客户网关使用,不能删除。`); + return; + } + setActionError(''); + try { + if (onDeleteLineGroup) { + await onDeleteLineGroup(group.id); + setDeleteLineGroupTarget(null); + return; + } + setLineGroupRows((rows) => rows.filter((item) => item.id !== group.id)); + setDeleteLineGroupTarget(null); + } catch (error) { + setActionError(explainApiError(error)); + } + }; return ( <> }>新增线路组} /> + @@ -1738,6 +2138,7 @@ function VendorLineGroupsPage() { row.gatewayIds.length }, + { key: 'customerGatewayCount', label: '使用客户网关数' }, { key: 'concurrencyLimit', label: '并发上限', render: (row) => getLineGroupConcurrency(row) }, { key: 'actions', @@ -1745,6 +2146,7 @@ function VendorLineGroupsPage() { render: (row) => (
+
), }, @@ -1785,7 +2187,7 @@ function VendorLineGroupsPage() {
- +
), }, @@ -1803,7 +2205,7 @@ function VendorLineGroupsPage() { 落地网关 *}> @@ -1815,6 +2217,362 @@ function VendorLineGroupsPage() { ) : null} + {deleteLineGroupTarget ? ( + setDeleteLineGroupTarget(null)} + onConfirm={() => void deleteLineGroup(deleteLineGroupTarget)} + > +

确认删除落地线路组「{deleteLineGroupTarget.name}」吗?

+
+ ) : null} + + ); +} + +const numberLibraryTabs = [ + { value: 'cities', label: '地级市字典' }, + { value: 'phoneSegments', label: '手机号码库' }, + { value: 'areaCodes', label: '城市区号' }, + { value: 'carrierPrefixRules', label: '运营商号码段规则' }, +]; + +const numberLibraryImportExamples = { + cities: [ + { code: '340100', provinceCode: '340000', provinceName: '安徽省', cityName: '合肥市', cityLevel: 'PREFECTURE' }, + ], + phoneSegments: [ + { segment7: '1380013', provinceName: '北京市', cityCode: '110100', cityName: '北京市', carrier: 'MOBILE' }, + ], + areaCodes: [ + { areaCode: '0551', provinceName: '安徽省', cityCode: '340100', cityName: '合肥市' }, + ], + carrierPrefixRules: [ + { prefix: '138', carrier: 'MOBILE', priority: 100 }, + ], +}; + +const emptyNumberLibraryRows = { + cities: [], + phoneSegments: [], + areaCodes: [], + carrierPrefixRules: [], +}; + +const emptyNumberLibraryTotals = { + cities: 0, + phoneSegments: 0, + areaCodes: 0, + carrierPrefixRules: 0, +}; + +function normalizeNumberLibraryList(payload, mapItem) { + const items = Array.isArray(payload?.items) ? payload.items : []; + return { + rows: items.map(mapItem), + total: payload?.total ?? items.length, + }; +} + +function NumberLibraryPage() { + const [activeTab, setActiveTab] = useState('cities'); + const [rows, setRows] = useState(emptyNumberLibraryRows); + const [totals, setTotals] = useState(emptyNumberLibraryTotals); + const [filters, setFilters] = useState({ + cities: { keyword: '' }, + phoneSegments: { segment7: '', cityCode: '', carrier: 'all' }, + areaCodes: { areaCode: '', cityCode: '' }, + carrierPrefixRules: { prefix: '', carrier: 'all' }, + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [message, setMessage] = useState(''); + const [importTarget, setImportTarget] = useState(null); + const [importText, setImportText] = useState(''); + const [importing, setImporting] = useState(false); + + const readTab = async (tab) => { + const params = filters[tab] || {}; + if (tab === 'cities') { + const payload = await api.numberLibraryCities(params); + return normalizeNumberLibraryList(payload, (item) => ({ + id: item.code, + code: item.code, + provinceName: item.provinceName, + cityName: item.cityName, + cityLevel: item.cityLevel || '-', + status: zhStatus(item.status), + effectiveFrom: formatDate(item.effectiveFrom), + effectiveTo: formatDate(item.effectiveTo), + })); + } + if (tab === 'phoneSegments') { + const payload = await api.numberLibraryPhoneSegments(params); + return normalizeNumberLibraryList(payload, (item) => ({ + id: item.segment7, + segment7: item.segment7, + provinceName: item.provinceName, + cityCode: item.cityCode, + cityName: item.cityName, + carrier: carrierLabel(item.carrier), + numberType: item.numberType || '-', + source: item.source || '-', + batchId: item.batchId || '-', + })); + } + if (tab === 'areaCodes') { + const payload = await api.numberLibraryAreaCodes(params); + return normalizeNumberLibraryList(payload, (item) => ({ + id: item.areaCode, + areaCode: item.areaCode, + provinceName: item.provinceName, + cityCode: item.cityCode, + cityName: item.cityName, + source: item.source || '-', + batchId: item.batchId || '-', + })); + } + const payload = await api.numberLibraryCarrierPrefixRules(params); + return normalizeNumberLibraryList(payload, (item) => ({ + id: item.prefix, + prefix: item.prefix, + carrier: carrierLabel(item.carrier), + priority: item.priority, + source: item.source || '-', + batchId: item.batchId || '-', + effectiveFrom: formatDate(item.effectiveFrom), + effectiveTo: formatDate(item.effectiveTo), + })); + }; + + const loadTab = async (tab = activeTab) => { + setLoading(true); + setError(''); + try { + const result = await readTab(tab); + setRows((current) => ({ ...current, [tab]: result.rows })); + setTotals((current) => ({ ...current, [tab]: result.total })); + } catch (loadError) { + setError(explainApiError(loadError)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadTab('cities'); + void loadTab('phoneSegments'); + void loadTab('areaCodes'); + void loadTab('carrierPrefixRules'); + }, []); + + const updateFilter = (key, value) => { + setFilters((current) => ({ + ...current, + [activeTab]: { ...current[activeTab], [key]: value }, + })); + }; + + const openImport = (tab) => { + setImportTarget(tab); + setImportText(JSON.stringify(numberLibraryImportExamples[tab], null, 2)); + setMessage(''); + setError(''); + }; + + const submitImport = async (event) => { + event.preventDefault(); + if (!importTarget) return; + setImporting(true); + setError(''); + try { + const parsed = JSON.parse(importText); + const items = Array.isArray(parsed) ? parsed : parsed.items; + if (!Array.isArray(items) || items.length === 0) { + throw new Error('导入内容必须是非空数组。'); + } + const importers = { + cities: api.importNumberLibraryCities, + phoneSegments: api.importNumberLibraryPhoneSegments, + areaCodes: api.importNumberLibraryAreaCodes, + carrierPrefixRules: api.importNumberLibraryCarrierPrefixRules, + }; + const result = await importers[importTarget](items); + setMessage(`导入完成:新增 ${result?.created ?? 0} 条,更新 ${result?.updated ?? 0} 条。`); + setImportTarget(null); + setImportText(''); + await loadTab(importTarget); + } catch (submitError) { + setError(explainApiError(submitError)); + } finally { + setImporting(false); + } + }; + + const renderFilters = () => { + const current = filters[activeTab]; + if (activeTab === 'cities') { + return ( + <> + updateFilter('keyword', event.target.value)} placeholder="输入省份或城市" /> + + + ); + } + if (activeTab === 'phoneSegments') { + return ( + <> + updateFilter('segment7', event.target.value)} placeholder="如 1380013" /> + updateFilter('cityCode', event.target.value)} placeholder="如 340100" /> + + + + + + ); + } + if (activeTab === 'areaCodes') { + return ( + <> + updateFilter('areaCode', event.target.value)} placeholder="如 0551" /> + updateFilter('cityCode', event.target.value)} placeholder="如 340100" /> + + + ); + } + return ( + <> + updateFilter('prefix', event.target.value)} placeholder="如 138" /> + + + + + + ); + }; + + const renderTable = (tab) => { + if (tab === 'cities') { + return ( + + ); + } + if (tab === 'phoneSegments') { + return ( + + ); + } + if (tab === 'areaCodes') { + return ( + + ); + } + return ( + + ); + }; + + const importTitle = importTarget ? numberLibraryTabs.find((tab) => tab.value === importTarget)?.label : ''; + + return ( + <> + } onClick={() => openImport(activeTab)}>批量导入} + /> + void loadTab(activeTab)} /> + {message ? {message} : null} + { + setActiveTab(value); + if (rows[value].length === 0) { + void loadTab(value); + } + }} + tabs={numberLibraryTabs.map((tab) => ({ + ...tab, + label: `${tab.label}(${totals[tab.value]})`, + content: ( + } onClick={() => void loadTab(tab.value)}>刷新} + > + {tab.value === activeTab ? ( + + {renderFilters()} + + ) : null} + {renderTable(tab.value)} + + ), + }))} + /> + {importTarget ? ( + {} : () => setImportTarget(null)}> +
+ +
{column.label}{column.label}
+ {column.status ? {row[column.key]} : column.render ? column.render(row) : row[column.key]}