From 175166a5dcddbcb07edfc38182ac65cef661ed56 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Sun, 28 Jun 2026 22:16:49 +0800 Subject: [PATCH] fix phase2 gateway matching and release artifact --- IMPLEMENTATION_STATUS.md | 124 +- SOFTSWITCH_PLATFORM_DESIGN_V2.md | 23 +- .../customer-gateways.e2e.spec.ts | 40 + .../customer-gateways.service.ts | 108 +- apps/web/src/pages/BusinessPrefixesPage.jsx | 2 + apps/web/src/pages/CustomerGatewaysPage.jsx | 3 + apps/web/src/pages/RolesPage.jsx | 2 + apps/web/src/pages/UsersPage.jsx | 2 + ...SINESS_PREFIX_GATEWAY_MIGRATION_RUNBOOK.md | 13 + docs/TEST_PLAN_AND_CASES.md | 1935 +++++++++++++++++ docs/TEST_RESULTS_STEP1_BASELINE_20260628.md | 184 ++ infra/server-a/s28/lisglosips_hotpath.lua | 103 +- package.json | 1 + .../auth/vendor/argon2id/dist/no-simd.wasm | Bin 0 -> 3667 bytes packages/auth/vendor/argon2id/dist/simd.wasm | Bin 0 -> 4416 bytes scripts/build-release-artifact.mjs | 164 ++ vitest.config.ts | 1 + 17 files changed, 2670 insertions(+), 35 deletions(-) create mode 100644 docs/TEST_PLAN_AND_CASES.md create mode 100644 docs/TEST_RESULTS_STEP1_BASELINE_20260628.md create mode 100644 packages/auth/vendor/argon2id/dist/no-simd.wasm create mode 100644 packages/auth/vendor/argon2id/dist/simd.wasm create mode 100644 scripts/build-release-artifact.mjs diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md index 57075a7..bfbb9dd 100644 --- a/IMPLEMENTATION_STATUS.md +++ b/IMPLEMENTATION_STATUS.md @@ -4,11 +4,11 @@ ## 当前状态 -- 当前任务:需求修改/二期变更:权限驱动导航和按钮 -- 本次变更摘要:已完成登录/刷新响应透出 `user.permissions`,前端新增统一 `can(permission)` 权限封装;菜单按页面权限裁剪,核心真实 API 页面操作按钮按 manage/play 权限裁剪,无权限页面提供明确提示,并避免无权限启动时批量请求导致 403。 -- 总体状态:S30 已完成;本地 KVM 开发环境 V2 闭环原冻结 release 为 `s28-v2-20260621220924`;B 已切换并冻结到 `s42-phase2-business-prefix-20260624140000` 作为二期业务前缀小样本联调基线,MySQL 已补齐号码库 migration `20260624095000_number_library` 与业务前缀 migration `20260624121000_business_prefix_gateway_rewrite`,Prisma status 为 up to date,config-publisher 当前 active 配置版本为 `1782286812696`,上一版本为 `1782286145681`,B preflight 直接执行通过;A 已加载业务前缀 Lua SHA `0b7ee595cdcad2f38a670dd8ff3553387820039e`,OpenSIPS 配置 SHA-256 为 `ae1544e9a39d2db114e6981babac042c2ab6d399d59ba597a52b85d597aa5aa5`,最终 `dlg_list` 已清空;T 已完成冻结验收实呼 `s28-1782288889943-ckv83lce@lisglosips-t`,B CDR 入库并计费成功;当前仍未导入真实 80 万手机号段;阿里云迁移前需按 S30 Runbook 重新演练 -- 最后更新:2026-06-24 17:50 +08:00 -- 当前阻塞:无阻塞。遗留问题:真实 80 万手机号段尚未导入;OpenSIPS dialog 在测试呼叫结束后可能残留,冻结前已通过重启清空,后续应单独分析 dialog 生命周期和 `dlg_end_dlg Operation failed` 根因;本次权限裁剪使用本地 mock API 完成只读账号浏览器冒烟,真实 B 服务器浏览器登录态逐页按钮、真实录音文件播放和 Nginx/X-Accel 链路仍需后续人工复测。 +- 当前任务:需求修改/二期变更:客户网关匹配口径固化与 release artifact 脚本 +- 本次变更摘要:已按“业务前缀优先于主叫前缀,精确规则优先于空业务前缀兜底”更新设计文档、Runbook、客户网关编辑页提示、后端客户网关匹配唯一性校验、A 侧 Lua 热路径仓库脚本,并新增 `scripts/build-release-artifact.mjs` 固化 release artifact 生成规范。 +- 总体状态:S30 已完成;本地 KVM 开发环境 V2 闭环原冻结 release 为 `s28-v2-20260621220924`;B 二期冻结基线为 `s42-phase2-business-prefix-20260624140000`,当前已切换到 Bug 修复 release `s43-bugfix-cdr-quality-rbac-20260624184854`;切换前已生成并校验 MySQL/Redis 备份 `/data/backups/mysql/20260624T104812Z` 与 `/data/backups/redis/20260624T104812Z`;B preflight 通过,API ready 返回 ok,HTTPS 首页加载新前端资产;A 仍加载业务前缀 Lua SHA `0b7ee595cdcad2f38a670dd8ff3553387820039e`,OpenSIPS 配置 SHA-256 仍为 `ae1544e9a39d2db114e6981babac042c2ab6d399d59ba597a52b85d597aa5aa5`;T 未变更;当前仍未导入真实 80 万手机号段;阿里云迁移前需按 S30 Runbook 重新演练 +- 最后更新:2026-06-28 22:09 +08:00 +- 当前阻塞:无阻塞。遗留问题:本次未连接 A/B/T、未部署到服务器、未执行数据库 schema 或服务变更;A 侧 Lua 仅更新仓库文件,发布前需备份 A OpenSIPS/Lua、执行 `opensips -C -f` 并做 A/B/T 实呼验收;本地 Windows 未安装 Lua/luac,Lua 语法未做解释器级检查;真实 80 万手机号段尚未导入;OpenSIPS dialog 残留根因仍待单独分析;真实 B 服务器浏览器登录态逐页按钮、真实录音文件播放和 Nginx/X-Accel 链路仍需用户测试复核。 - 当前环境:A/B/T 为本地 KVM 开发服务器,通过 Tailscale 联调;开发完成后再迁移阿里云 - 私密访问资料:`.codex-private/SERVER_ACCESS.md`,只在实际连接时读取,禁止回显 @@ -90,6 +90,120 @@ ## 交接记录 +### 2026-06-28 22:09 - 需求修改/二期变更:客户网关匹配口径固化与 release artifact 脚本 + +- 状态:已完成本地代码与文档修改,未发布服务器 +- 操作服务器:本地 +- 完成内容:将产品口径固化为“业务前缀优先于主叫前缀,精确规则优先于空业务前缀兜底”;明确业务前缀只作为客户网关匹配字典,不参与计费、报表或权限隔离;明确客户网关决定客户侧计费、落地网关决定供应商侧成本;后端客户网关保存/启用时校验同一 IP + 同一业务前缀唯一、同一 IP 主叫前缀不互相覆盖、同一网关内主叫前缀不互相覆盖;A 侧热路径仓库 Lua 改为业务前缀优先、空业务前缀下再按最长主叫前缀、最后默认兜底;客户网关编辑页新增匹配规则提示;新增固定 release artifact 脚本与 npm script。 +- 修改文件:`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`docs/PHASE2_BUSINESS_PREFIX_GATEWAY_MIGRATION_RUNBOOK.md`、`apps/api/src/modules/customer-gateways/customer-gateways.service.ts`、`apps/api/src/modules/customer-gateways/customer-gateways.e2e.spec.ts`、`apps/web/src/pages/CustomerGatewaysPage.jsx`、`infra/server-a/s28/lisglosips_hotpath.lua`、`scripts/build-release-artifact.mjs`、`package.json`、`IMPLEMENTATION_STATUS.md` +- 验证结果:`node_modules/.bin/tsc.cmd -b tsconfig.build.json --pretty false` 通过;`node_modules/.bin/eslint.cmd` 针对后端/脚本文件通过,Web 页面文件因仓库 ignore 规则被跳过且无错误;客户网关 e2e 7/7 通过,覆盖重复业务前缀和主叫前缀覆盖冲突;`node scripts/build-release-artifact.mjs --release-id s-local-check --check` 通过;`apps/web/node_modules/.bin/vite.CMD build` 通过,产物 `index-BBq8iMHt.js`。 +- 回滚方式:本地回滚上述文件即可;若未来已发布服务器,B 应回滚到上一 release artifact,A 应恢复发布前备份的 `/etc/opensips/lisglosips_hotpath.lua` 并执行 `opensips -C -f /etc/opensips/opensips.cfg` 后重启 OpenSIPS。 +- 未解决问题:未连接服务器、未生成正式 Linux release artifact、未部署 A/B、未做 A/B/T 实呼验收;本地无 Lua/luac,Lua 解释器级语法检查需在 A 或装有 Lua 的环境补跑。 + +### 2026-06-28 16:45 - 修复用户管理/角色权限页面空白 + +- 状态:已完成 +- 操作服务器:B +- 完成内容: + - 修复 `apps/web/src/pages/UsersPage.jsx` 中 `emptyUserForm` 未定义导致用户管理页面空白的问题。 + - 修复 `apps/web/src/pages/RolesPage.jsx` 中 `emptyRoleForm` 未定义导致角色与权限页面空白的问题。 + - 增加一次轻量源码检查,确认 `apps/web/src/pages` 下 `empty*Form` 引用均有同文件定义。 + - 重新构建前端并发布到 B 当前 release `/opt/lisglosips/releases/s43-bugfix-cdr-quality-rbac-20260624184854` 的 `apps/web/dist` 与 `public` 目录。 +- 修改文件:`apps/web/src/pages/UsersPage.jsx`、`apps/web/src/pages/RolesPage.jsx`、`IMPLEMENTATION_STATUS.md`;B 当前 release 静态资源目录。 +- 安装软件及固定版本:未安装新软件。 +- 执行的关键命令:`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 build`、页面 `empty*Form` 引用检查、上传并解包前端 dist、`curl -k https://127.0.0.1/`。 +- 验证结果: + - lint 通过。 + - typecheck 通过。 + - build 通过。 + - 页面 `empty*Form` 引用检查无输出,未发现同类缺失定义。 + - B HTTPS 首页已引用 `/assets/index-BK00zN_M.js` 与 `/assets/index-B2uYBtS3.css`。 +- 回滚方式:恢复上述两个页面文件并重新构建发布;或将 B `public`/`apps/web/dist` 恢复为上一版前端静态资源。 +- 未解决问题:未使用真实浏览器逐页点击复测;请用户强刷后重新进入“用户管理”“角色与权限”确认。 +- 下一任务:等待用户复测反馈。 + +### 2026-06-28 16:39 - 修复业务前缀页面空白 + +- 状态:已完成 +- 操作服务器:B +- 完成内容: + - 修复 `apps/web/src/pages/BusinessPrefixesPage.jsx` 中 `emptyBusinessPrefixForm` 未定义导致业务前缀菜单页面空白的问题。 + - 重新执行本地 lint、typecheck、build,生成新前端资产 `assets/index-CVzJDPkO.js`。 + - 将 `apps/web/dist` 发布到 B 当前 release `/opt/lisglosips/releases/s43-bugfix-cdr-quality-rbac-20260624184854` 的 `apps/web/dist` 与 `public` 目录。 +- 修改文件:`apps/web/src/pages/BusinessPrefixesPage.jsx`、`IMPLEMENTATION_STATUS.md`;B 当前 release 静态资源目录。 +- 安装软件及固定版本:未安装新软件。 +- 执行的关键命令:`corepack pnpm@10.33.0 lint`、`corepack pnpm@10.33.0 typecheck`、`corepack pnpm@10.33.0 build`、上传并解包前端 dist、`curl -k https://127.0.0.1/`。 +- 验证结果: + - lint 通过。 + - typecheck 通过。 + - build 通过。 + - B HTTPS 首页已引用 `/assets/index-CVzJDPkO.js` 与 `/assets/index-B2uYBtS3.css`,不再引用旧 `index-BudcefOn.js`。 +- 回滚方式:恢复 `BusinessPrefixesPage.jsx` 本次修改并重新构建发布;或将 B `public`/`apps/web/dist` 恢复为上一版前端静态资源。 +- 未解决问题:未使用真实浏览器逐页点击复测;请用户刷新页面后重新进入“业务前缀管理”确认。 +- 下一任务:等待用户复测反馈。 + +### 2026-06-28 16:31 - 修复 B 登录 500:补齐 Argon2id wasm 发布文件 + +- 状态:已完成 +- 操作服务器:B +- 完成内容: + - 根据 B `lisglosips@api` 日志定位 `/api/v2/auth/login` 500 根因:当前 `s43` release 缺少 `packages/auth/vendor/argon2id/dist/no-simd.wasm`,导致密码校验加载 wasm 失败。 + - 从上一可用 release `/opt/lisglosips/releases/s42-phase2-business-prefix-20260624140000` 复制 `no-simd.wasm` 与 `simd.wasm` 到当前 release `/opt/lisglosips/releases/s43-bugfix-cdr-quality-rbac-20260624184854`。 + - 调整文件属主与权限为 `root:lisglosips`、`0640`,重启 `lisglosips@api`。 + - 在 B 当前 release 内执行 Argon2id hash/verify 冒烟,返回 `argon2-smoke-ok`。 + - 本地将 `packages/auth/vendor/argon2id/dist/no-simd.wasm` 与 `packages/auth/vendor/argon2id/dist/simd.wasm` 使用 `git add -f` 纳入 Git 跟踪,避免后续发布包再遗漏。 +- 修改文件:B 当前 release 新增/补齐 `packages/auth/vendor/argon2id/dist/no-simd.wasm`、`packages/auth/vendor/argon2id/dist/simd.wasm`;本地暂存上述两个 wasm 文件,并更新 `IMPLEMENTATION_STATUS.md`。 +- 安装软件及固定版本:未安装新软件。 +- 执行的关键命令:`journalctl -u lisglosips@api`、`cp -a`、`chown`、`chmod`、`systemctl restart lisglosips@api`、`curl http://127.0.0.1:3000/api/v2/health/ready`、B Argon2id hash/verify 冒烟、`git add -f packages/auth/vendor/argon2id/dist/*.wasm`。 +- 验证结果:B `lisglosips@api` active;API ready 返回 ok;Argon2id wasm 冒烟通过。用户可重新尝试 Web 登录;未使用或回显用户密码。 +- 回滚方式:如需回滚本次运行修复,可删除 B 当前 release 的两个 wasm 文件并重启 API,但会恢复登录 500;推荐回滚方式仍是将 B `current` 指回 `/opt/lisglosips/releases/s42-phase2-business-prefix-20260624140000` 后重启 API/Worker。 +- 未解决问题:`refresh 401` 在无有效登录态时仍属预期;需用户重新登录确认前端流程。 +- 下一任务:等待用户复测反馈。 + +### 2026-06-28 16:25 - 启动 A/B/T 本地联调环境供功能检查 + +- 状态:已完成 +- 操作服务器:A、B、T +- 完成内容: + - A:确认 `opensips`、`lisglosips-redis-auth-proxy`、`rtpengine`、`rtpengine-recording-daemon`、`lisglosips-node-exporter` 均为 active;未修改 A 配置。 + - B:确认当前 release 为 `/opt/lisglosips/releases/s43-bugfix-cdr-quality-rbac-20260624184854`;启动此前 inactive 的 `lisglosips@recording-worker` 与 `lisglosips@config-publisher`;确认 MySQL、Redis、Nginx、API、CDR Worker、Recording Worker、Config Publisher、HEP、Prometheus、Grafana 均为 active。 + - T:发现 `opensips` 为 failed;执行 `systemctl reset-failed opensips` 与 `systemctl restart opensips` 后恢复 active;确认 `rtpengine`、`apache2`、`mariadb`、`lisglosips-s28-uas` 均为 active。 +- 修改文件:本地更新 `IMPLEMENTATION_STATUS.md`;服务器未修改配置文件,未执行数据库 migration,未改防火墙。 +- 安装软件及固定版本:未安装新软件。 +- 执行的关键命令:`systemctl is-active`、B `systemctl start lisglosips@recording-worker lisglosips@config-publisher`、T `systemctl reset-failed opensips && systemctl restart opensips`、B `lisglosips-release-preflight.sh`、`curl http://127.0.0.1:3000/api/v2/health/ready`、`curl -k https://127.0.0.1/`、`ss -lunpt`。 +- 验证结果: + - A OpenSIPS 配置 SHA-256 仍为 `ae1544e9a39d2db114e6981babac042c2ab6d399d59ba597a52b85d597aa5aa5`,Lua SHA-1 仍为 `0b7ee595cdcad2f38a670dd8ff3553387820039e`;A 监听 `100.90.90.90:15060/udp`。 + - B preflight 通过;API ready 返回 ok;HTTPS 首页返回 200,加载 `/assets/index-BudcefOn.js` 与 `/assets/index-B2uYBtS3.css`。 + - T `opensips` active,监听 `100.93.185.30:5060/udp`;UAS 监听 `100.93.185.30:50620/udp`。 +- 回滚方式:如需停止本次启动项,可停止 B 的 `lisglosips@recording-worker`、`lisglosips@config-publisher`,以及按需停止 T `opensips`;B 应用回滚仍可将 `/opt/lisglosips/current` 指回 `/opt/lisglosips/releases/s42-phase2-business-prefix-20260624140000` 后重启服务。 +- 未解决问题:未执行真实呼叫;T OpenSIPS 曾处于 failed,已恢复但未进一步分析失败前原因。 +- 下一任务:等待用户功能检查反馈。 + +### 2026-06-24 18:58 - 需求修改/二期变更:Bug 修复发布到 B + +- 状态:已完成 +- 操作服务器:B;A 只读核对;T 未操作 +- 完成内容: + - 已将本地 Git 提交 `a86de65 feat: complete phase2 baseline cdr quality rbac` 构建并发布到 B 新 release:`/opt/lisglosips/releases/s43-bugfix-cdr-quality-rbac-20260624184854`。 + - 发布内容包含:CDR 后端/API 前端接线、质检中心真实 API、前端页面拆分、权限驱动导航和按钮裁剪。 + - 切换前 B 当前 release 为 `/opt/lisglosips/releases/s42-phase2-business-prefix-20260624140000`,已作为直接回滚点保留。 + - 切换前执行 B preflight 通过,并生成 MySQL/Redis 备份:`/data/backups/mysql/20260624T104812Z`、`/data/backups/redis/20260624T104812Z`,SHA256 校验通过。 + - 首次切换 `s43` 时发现 release 组装缺少 workspace 级 `apps/*/node_modules` 和部分 `packages/*/node_modules` 链接,服务报 `ERR_MODULE_NOT_FOUND`;已立即回滚到 `s42`,确认 API ready 200;随后补齐链接、完成模块解析冒烟后再次切换到 `s43` 成功。 + - A 侧只读核对:运行中 OpenSIPS 配置 SHA-256 仍为 `ae1544e9a39d2db114e6981babac042c2ab6d399d59ba597a52b85d597aa5aa5`,Lua SHA-1 仍为 `0b7ee595cdcad2f38a670dd8ff3553387820039e`;本次 Bug 修复不需要发布 A,且仓库 `infra/server-a/s28/opensips.cfg` 含部署模板占位符 `@@LISGLOSIPS_REDIS_URL@@`,不能直接覆盖运行中 A 配置。 +- 修改文件:本地更新 `IMPLEMENTATION_STATUS.md`;服务器新增 B release 目录 `/opt/lisglosips/releases/s43-bugfix-cdr-quality-rbac-20260624184854`,未修改数据库 schema,未执行 Prisma migration。 +- 安装软件及固定版本:未安装新软件;复用 B 既有 Node.js `v22.22.2`、pnpm 依赖和 systemd 模板。 +- 执行的关键命令:本地 `corepack pnpm@10.33.0 build`;B `lisglosips-release-preflight.sh`、`systemctl start lisglosips-backup.service`、`sha256sum -c`、创建 release、切换 `/opt/lisglosips/current`、重启 `lisglosips@api`、`lisglosips@cdr-worker`、`lisglosips@recording-worker`、`lisglosips@config-publisher`、`nginx -t`、`systemctl reload nginx`、`curl http://127.0.0.1:3000/api/v2/health/ready`、`curl -k https://127.0.0.1/`。 +- 验证结果: + - 本地 build 通过。 + - B 当前 release 为 `/opt/lisglosips/releases/s43-bugfix-cdr-quality-rbac-20260624184854`。 + - B `mysql`、`redis-server`、`nginx`、`lisglosips@api`、`lisglosips@cdr-worker`、`lisglosips@recording-worker`、`lisglosips@config-publisher` 均为 active。 + - B API ready 返回 `{"status":"ok","service":"api",...}`。 + - B preflight 通过;HTTPS 首页返回 200,并加载新前端资产 `/assets/index-BudcefOn.js` 与 `/assets/index-B2uYBtS3.css`。 + - A OpenSIPS/Lua 哈希与冻结报告一致,未执行 A 侧写操作或重启。 +- 回滚方式:在 B 执行 `sudo ln -sfn /opt/lisglosips/releases/s42-phase2-business-prefix-20260624140000 /opt/lisglosips/current`,然后重启 `lisglosips@api lisglosips@cdr-worker lisglosips@recording-worker lisglosips@config-publisher` 并执行 preflight;本次未做数据库迁移,通常不需要数据库回滚。 +- 未解决问题:未执行 A/B/T 实呼验收;用户需在 B Web 登录态逐页复测权限按钮、CDR、质检、录音播放和信令入口;真实录音文件播放与 Nginx X-Accel 链路仍需用实际文件复核。 +- 下一任务:等待用户测试反馈或指定下一项。 + ### 2026-06-24 17:50 - 需求修改/二期变更:权限驱动导航和按钮 - 状态:已完成 diff --git a/SOFTSWITCH_PLATFORM_DESIGN_V2.md b/SOFTSWITCH_PLATFORM_DESIGN_V2.md index 2a42755..0a91d6e 100644 --- a/SOFTSWITCH_PLATFORM_DESIGN_V2.md +++ b/SOFTSWITCH_PLATFORM_DESIGN_V2.md @@ -1431,7 +1431,7 @@ sudo nohup runuser -u nobody -- /usr/bin/python3 /opt/lisglosips-s28/lisglosips- 新增“业务前缀管理”菜单: -- 业务前缀是客户呼入时附加在被叫号码前面的业务识别前缀,用于区分客户业务、费率和落地线路组。 +- 业务前缀是客户呼入时附加在被叫号码前面的业务识别前缀,只作为客户网关匹配字典使用,不参与计费产品、报表维度或权限隔离。 - 示例:客户真实想呼叫 `18822190000`,平台收到的 SIP Request-URI/User 或 To 中被叫为 `67118822190000`,其中 `671` 为业务前缀,真实被叫号码为 `18822190000`。 - 业务前缀需要平台统一维护,客户网关只能从“业务前缀管理”中多选可用前缀,不能在客户网关中随意输入未登记的业务前缀。 - 业务前缀字段建议至少包括:`id`、`prefix`、`name`、`description`、`status`、`priority`、`created_at`、`updated_at`、`deleted_at`。`prefix` 允许英文和阿拉伯数字,必须唯一;匹配时按最长前缀优先,再按 `priority`。 @@ -1439,8 +1439,10 @@ sudo nohup runuser -u nobody -- /usr/bin/python3 /opt/lisglosips-s28/lisglosips- 呼叫识别与号码语义: - 平台收到 SIP 呼叫后,先用来源 IP 命中候选客户网关 IP 列表,再用客户网关的主叫前缀规则和被叫业务前缀规则确定唯一客户网关。 +- 客户网关匹配顺序固定为:业务前缀优先于主叫前缀,精确业务前缀规则优先于空业务前缀兜底。空业务前缀表示客户送入裸被叫号码。 - 被叫业务前缀匹配成功后,热路径必须剥离业务前缀,后续当前通话、CDR、计费、号码库归属地/运营商识别、地区屏蔽和落地侧转发都以“剥离后的真实被叫号码”为准。 - 当前通话和话单中写入的被叫号码必须是客户真实想呼叫的号码,即“客户送入被叫 - 被叫业务前缀”。如需排障,可额外保存原始被叫号码和命中的业务前缀。 +- 计费归属口径:客户网关决定客户侧计费,落地网关决定供应商侧成本;业务前缀本身不生成独立计费维度。 - 建议新增 CDR 字段:`raw_callee`、`business_prefix_id`、`business_prefix`、`normalized_callee`。其中 `callee` 继续表示业务剥离后的真实被叫,避免影响现有话单展示语义。 客户网关配置管理调整: @@ -1456,7 +1458,9 @@ sudo nohup runuser -u nobody -- /usr/bin/python3 /opt/lisglosips-s28/lisglosips- - `ANY`:任意主叫。 - `PREFIXES`:可配置多个自定义主叫前缀。 - 主叫前缀只是匹配条件,不改写主叫。 -- 若同一 IP 下多个客户网关的主叫/被叫规则都可命中同一呼叫,应在保存配置时阻止冲突,或者明确热路径选择规则。建议优先在 API 保存时做冲突检测,避免呼叫时不确定。 +- 客户网关必须归属一个客户;同一来源 IP 允许被不同客户复用,但最终匹配必须唯一。 +- 同一来源 IP + 同一业务前缀组合只能归属一个客户网关;同一来源 IP 下主叫前缀不能互相覆盖。 +- 同一客户需要不同业务前缀或不同路由策略时,通过配置多个客户网关分别绑定不同业务前缀/IP/线路组实现。 落地网关配置调整: @@ -1481,12 +1485,15 @@ Redis 热路径与 OpenSIPS/Lua 影响: - `cfg:v:{version}:vendor_gateway:{id}:caller_rewrite_pool` 中包含指定主叫与权重。 - OpenSIPS/Lua 需要调整顺序: 1. 读取来源 IP 候选客户网关列表。 - 2. 对每个候选客户网关检查启用状态、客户状态、主叫前缀规则、被叫业务前缀规则。 - 3. 选出唯一客户网关,得到客户、客户侧费率、落地线路组、命中的业务前缀和剥离后的真实被叫。 - 4. 用真实被叫做号码库归属地/运营商识别和地区屏蔽路由。 - 5. 选择落地网关后,按落地网关配置追加落地要求被叫前缀,并按权重选择落地要求指定主叫。 - 6. 转发给落地前,写入 Dialog 变量:原始被叫、真实被叫、业务前缀、客户网关、线路组、落地网关、落地侧最终主叫/被叫。 - 7. 当前通话与 CDR 使用真实被叫作为主展示字段;必要时详情显示原始被叫和落地侧最终被叫。 + 2. 对候选客户网关检查启用状态与客户状态。 + 3. 第一优先级匹配被叫业务前缀,命中后按最长前缀选择并剥离真实被叫;若命中业务前缀但主叫规则不通过,不允许退回主叫前缀或空业务前缀兜底。 + 4. 第二优先级匹配空业务前缀下的主叫前缀规则,按最长主叫前缀选择。 + 5. 第三优先级匹配空业务前缀兜底网关,用于裸被叫号码。 + 6. 选出唯一客户网关,得到客户、客户侧费率、落地线路组、命中的业务前缀和剥离后的真实被叫。 + 7. 用真实被叫做号码库归属地/运营商识别和地区屏蔽路由。 + 8. 选择落地网关后,按落地网关配置追加落地要求被叫前缀,并按权重选择落地要求指定主叫。 + 9. 转发给落地前,写入 Dialog 变量:原始被叫、真实被叫、业务前缀、客户网关、线路组、落地网关、落地侧最终主叫/被叫。 + 10. 当前通话与 CDR 使用真实被叫作为主展示字段;必要时详情显示原始被叫和落地侧最终被叫。 - 失败原因建议新增: - `CUSTOMER_GATEWAY_IP_MISSING` - `CUSTOMER_GATEWAY_RULE_CONFLICT` 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 296def9..e4c4a7a 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 @@ -275,6 +275,46 @@ describe('S13 customer gateways API', () => { .expect(403); }); + it('rejects ambiguous source IP and business prefix matches', async () => { + await request(app.getHttpServer()) + .post('/api/v2/customer-gateways') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ + customerId: 'cus_seed', + name: 'Duplicated Business Prefix Gateway', + authMode: 'IP', + sourceIps: ['100.93.185.30'], + lineGroupId: 'llg_seed', + callerMatchMode: 'ANY', + calleeMatchMode: 'BUSINESS_PREFIXES', + businessPrefixIds: ['bp_seed'] + }) + .expect(409) + .expect((response) => { + expect(response.body.code).toBe('CUSTOMER_GATEWAY_MATCH_CONFLICT'); + }); + }); + + it('rejects overlapping caller prefixes on the same source IP', async () => { + await request(app.getHttpServer()) + .post('/api/v2/customer-gateways') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ + customerId: 'cus_seed', + name: 'Overlapped Caller Prefix Gateway', + authMode: 'IP', + sourceIps: ['100.93.185.30'], + lineGroupId: 'llg_seed', + callerMatchMode: 'PREFIXES', + callerPrefixes: ['0211'], + calleeMatchMode: 'ANY' + }) + .expect(409) + .expect((response) => { + expect(response.body.code).toBe('CUSTOMER_GATEWAY_CALLER_PREFIX_OVERLAP'); + }); + }); + it('creates SIP digest gateway, hides the password, and writes audit', async () => { const response = await request(app.getHttpServer()) .post('/api/v2/customer-gateways') 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 d8e8504..0b05b09 100644 --- a/apps/api/src/modules/customer-gateways/customer-gateways.service.ts +++ b/apps/api/src/modules/customer-gateways/customer-gateways.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, ConflictException, Inject, Injectable } from '@nestjs/common'; import crypto from 'node:crypto'; import net from 'node:net'; import { @@ -48,6 +48,17 @@ interface UpdateCustomerGatewayDto { businessPrefixIds?: unknown; } +interface GatewayMatchingConfig { + id?: string; + sourceIps: string[]; + callerMatchMode: CustomerGatewayCallerMatchMode; + callerPrefixes: string[]; + calleeMatchMode: CustomerGatewayCalleeMatchMode; + businessPrefixIds: string[]; +} + +const EMPTY_BUSINESS_PREFIX_KEY = '__EMPTY_BUSINESS_PREFIX__'; + @Injectable() export class CustomerGatewaysService { constructor(@Inject(CUSTOMER_GATEWAYS_REPOSITORY) private readonly gateways: CustomerGatewaysRepository) {} @@ -61,7 +72,7 @@ export class CustomerGatewaysService { return this.gateways.get(gatewayId); } - create(body: CreateCustomerGatewayDto, actorId?: string): Promise { + async create(body: CreateCustomerGatewayDto, actorId?: string): Promise { const authMode = this.authMode(body.authMode); const sipIdentity = this.normalizeSipIdentity(authMode, body.sipUsername, body.sipDomain); const sipPassword = this.requiredSipPassword(authMode, body.sipPassword); @@ -88,6 +99,13 @@ export class CustomerGatewaysService { actorId }; + await this.assertMatchingUniqueness(undefined, { + sourceIps, + callerMatchMode, + callerPrefixes, + calleeMatchMode, + businessPrefixIds + }); return this.gateways.create(input); } @@ -140,10 +158,20 @@ export class CustomerGatewaysService { actorId }; + await this.assertMatchingUniqueness(gatewayId, { + id: gatewayId, + sourceIps, + callerMatchMode, + callerPrefixes, + calleeMatchMode, + businessPrefixIds + }); return this.gateways.update(gatewayId, input); } - enable(gatewayId: string, actorId?: string): Promise { + async enable(gatewayId: string, actorId?: string): Promise { + const current = await this.gateways.get(gatewayId); + await this.assertMatchingUniqueness(gatewayId, this.summaryToMatchingConfig(current)); return this.gateways.setStatus(gatewayId, 'ENABLED', actorId); } @@ -282,6 +310,15 @@ export class CustomerGatewaysService { throw new BadRequestException({ code: 'CALLER_PREFIX_INVALID', message: 'callerPrefixes contains invalid characters.' }); } } + const overlappedPrefix = prefixes.find((prefix, index) => + prefixes.slice(index + 1).some((otherPrefix) => this.prefixesOverlap(prefix, otherPrefix)) + ); + if (overlappedPrefix) { + throw new BadRequestException({ + code: 'CALLER_PREFIX_OVERLAP', + message: `Caller prefix "${overlappedPrefix}" overlaps with another caller prefix.` + }); + } return prefixes; } @@ -296,6 +333,71 @@ export class CustomerGatewaysService { return ids; } + private async assertMatchingUniqueness(ignoreGatewayId: string | undefined, proposed: GatewayMatchingConfig): Promise { + if (proposed.sourceIps.length === 0) { + return; + } + + const proposedIpSet = new Set(proposed.sourceIps); + const proposedBusinessKeys = this.businessPrefixKeys(proposed); + const proposedCallerPrefixes = proposed.callerMatchMode === 'PREFIXES' ? proposed.callerPrefixes : []; + const gateways = await this.gateways.list(); + + for (const gateway of gateways) { + if (gateway.id === ignoreGatewayId || gateway.status !== 'ENABLED') { + continue; + } + + const sharedIps = gateway.sourceIps.filter((sourceIp) => proposedIpSet.has(sourceIp)); + if (sharedIps.length === 0) { + continue; + } + + const existingBusinessKeys = this.businessPrefixKeys(this.summaryToMatchingConfig(gateway)); + const duplicatedBusinessKey = proposedBusinessKeys.find((key) => existingBusinessKeys.includes(key)); + if (duplicatedBusinessKey) { + throw new ConflictException({ + code: 'CUSTOMER_GATEWAY_MATCH_CONFLICT', + message: + duplicatedBusinessKey === EMPTY_BUSINESS_PREFIX_KEY + ? 'The same source IP can have only one empty business-prefix fallback gateway.' + : 'The same source IP and business prefix can resolve to only one customer gateway.' + }); + } + + if (gateway.callerMatchMode === 'PREFIXES' && proposedCallerPrefixes.length > 0) { + const overlappedPrefix = proposedCallerPrefixes.find((prefix) => + gateway.callerPrefixes.some((existingPrefix) => this.prefixesOverlap(prefix, existingPrefix)) + ); + if (overlappedPrefix) { + throw new ConflictException({ + code: 'CUSTOMER_GATEWAY_CALLER_PREFIX_OVERLAP', + message: `Caller prefix "${overlappedPrefix}" overlaps with another gateway on the same source IP.` + }); + } + } + } + } + + private summaryToMatchingConfig(gateway: CustomerGatewaySummary): GatewayMatchingConfig { + return { + id: gateway.id, + sourceIps: gateway.sourceIps, + callerMatchMode: gateway.callerMatchMode, + callerPrefixes: gateway.callerPrefixes, + calleeMatchMode: gateway.calleeMatchMode, + businessPrefixIds: gateway.businessPrefixes.map((item) => item.id) + }; + } + + private businessPrefixKeys(config: GatewayMatchingConfig): string[] { + return config.calleeMatchMode === 'BUSINESS_PREFIXES' ? config.businessPrefixIds : [EMPTY_BUSINESS_PREFIX_KEY]; + } + + private prefixesOverlap(left: string, right: string): boolean { + return left.startsWith(right) || right.startsWith(left); + } + private stringList(value: unknown, field: string, maxItems: number, maxLength: number): string[] { const rawItems = Array.isArray(value) ? value : typeof value === 'string' ? value.split(/[\n,,\s]+/) : []; const items = [...new Set(rawItems.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean))]; diff --git a/apps/web/src/pages/BusinessPrefixesPage.jsx b/apps/web/src/pages/BusinessPrefixesPage.jsx index 918f27d..b0dc2ff 100644 --- a/apps/web/src/pages/BusinessPrefixesPage.jsx +++ b/apps/web/src/pages/BusinessPrefixesPage.jsx @@ -4,6 +4,8 @@ import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, Simpl import { enStatus } from '../utils/formatters.js'; import { api, explainApiError } from '../api.js'; +const emptyBusinessPrefixForm = { prefix: '', name: '', description: '', priority: 100, status: 'ENABLED' }; + export function BusinessPrefixesPage({ can = () => true }) { const [rows, setRows] = useState([]); const [filters, setFilters] = useState({ keyword: '', status: 'all' }); diff --git a/apps/web/src/pages/CustomerGatewaysPage.jsx b/apps/web/src/pages/CustomerGatewaysPage.jsx index 29d699a..ba93af6 100644 --- a/apps/web/src/pages/CustomerGatewaysPage.jsx +++ b/apps/web/src/pages/CustomerGatewaysPage.jsx @@ -363,6 +363,9 @@ export function CustomerGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRo setGatewayForm({ ...gatewayForm, cycleRate: event.target.value })} placeholder="0.000000" /> + + 业务前缀优先于主叫前缀,精确规则优先于空业务前缀兜底。空业务前缀用于裸被叫号码;同一 IP 下业务前缀组合必须唯一,主叫前缀不能互相覆盖。 +