fix phase2 gateway matching and release artifact

This commit is contained in:
hectorzhao
2026-06-28 22:16:49 +08:00
parent a86de6545f
commit 175166a5dc
17 changed files with 2670 additions and 35 deletions
+119 -5
View File
@@ -4,11 +4,11 @@
## 当前状态 ## 当前状态
- 当前任务:需求修改/二期变更:权限驱动导航和按钮 - 当前任务:需求修改/二期变更:客户网关匹配口径固化与 release artifact 脚本
- 本次变更摘要:已完成登录/刷新响应透出 `user.permissions`,前端新增统一 `can(permission)` 权限封装;菜单按页面权限裁剪,核心真实 API 页面操作按钮按 manage/play 权限裁剪,无权限页面提供明确提示,并避免无权限启动时批量请求导致 403 - 本次变更摘要:已按“业务前缀优先于主叫前缀,精确规则优先于空业务前缀兜底”更新设计文档、Runbook、客户网关编辑页提示、后端客户网关匹配唯一性校验、A 侧 Lua 热路径仓库脚本,并新增 `scripts/build-release-artifact.mjs` 固化 release artifact 生成规范
- 总体状态: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 dateconfig-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 重新演练 - 总体状态: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 返回 okHTTPS 首页加载新前端资产A 加载业务前缀 Lua SHA `0b7ee595cdcad2f38a670dd8ff3553387820039e`OpenSIPS 配置 SHA-256 `ae1544e9a39d2db114e6981babac042c2ab6d399d59ba597a52b85d597aa5aa5`T 未变更;当前仍未导入真实 80 万手机号段;阿里云迁移前需按 S30 Runbook 重新演练
- 最后更新:2026-06-24 17:50 +08:00 - 最后更新:2026-06-28 22:09 +08:00
- 当前阻塞:无阻塞。遗留问题:真实 80 万手机号段尚未导入;OpenSIPS dialog 在测试呼叫结束后可能残留,冻结前已通过重启清空,后续应单独分析 dialog 生命周期和 `dlg_end_dlg Operation failed` 根因;本次权限裁剪使用本地 mock API 完成只读账号浏览器冒烟,真实 B 服务器浏览器登录态逐页按钮、真实录音文件播放和 Nginx/X-Accel 链路仍需后续人工复测 - 当前阻塞:无阻塞。遗留问题:本次未连接 A/B/T、未部署到服务器、未执行数据库 schema 或服务变更;A 侧 Lua 仅更新仓库文件,发布前需备份 A OpenSIPS/Lua、执行 `opensips -C -f` 并做 A/B/T 实呼验收;本地 Windows 未安装 Lua/luacLua 语法未做解释器级检查;真实 80 万手机号段尚未导入;OpenSIPS dialog 残留根因仍待单独分析;真实 B 服务器浏览器登录态逐页按钮、真实录音文件播放和 Nginx/X-Accel 链路仍需用户测试复核
- 当前环境:A/B/T 为本地 KVM 开发服务器,通过 Tailscale 联调;开发完成后再迁移阿里云 - 当前环境:A/B/T 为本地 KVM 开发服务器,通过 Tailscale 联调;开发完成后再迁移阿里云
- 私密访问资料:`.codex-private/SERVER_ACCESS.md`,只在实际连接时读取,禁止回显 - 私密访问资料:`.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 artifactA 应恢复发布前备份的 `/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` activeAPI ready 返回 okArgon2id 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 返回 okHTTPS 首页返回 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 - 需求修改/二期变更:权限驱动导航和按钮 ### 2026-06-24 17:50 - 需求修改/二期变更:权限驱动导航和按钮
- 状态:已完成 - 状态:已完成
+15 -8
View File
@@ -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` - 示例:客户真实想呼叫 `18822190000`,平台收到的 SIP Request-URI/User 或 To 中被叫为 `67118822190000`,其中 `671` 为业务前缀,真实被叫号码为 `18822190000`
- 业务前缀需要平台统一维护,客户网关只能从“业务前缀管理”中多选可用前缀,不能在客户网关中随意输入未登记的业务前缀。 - 业务前缀需要平台统一维护,客户网关只能从“业务前缀管理”中多选可用前缀,不能在客户网关中随意输入未登记的业务前缀。
- 业务前缀字段建议至少包括:`id``prefix``name``description``status``priority``created_at``updated_at``deleted_at``prefix` 允许英文和阿拉伯数字,必须唯一;匹配时按最长前缀优先,再按 `priority` - 业务前缀字段建议至少包括:`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 列表,再用客户网关的主叫前缀规则和被叫业务前缀规则确定唯一客户网关。 - 平台收到 SIP 呼叫后,先用来源 IP 命中候选客户网关 IP 列表,再用客户网关的主叫前缀规则和被叫业务前缀规则确定唯一客户网关。
- 客户网关匹配顺序固定为:业务前缀优先于主叫前缀,精确业务前缀规则优先于空业务前缀兜底。空业务前缀表示客户送入裸被叫号码。
- 被叫业务前缀匹配成功后,热路径必须剥离业务前缀,后续当前通话、CDR、计费、号码库归属地/运营商识别、地区屏蔽和落地侧转发都以“剥离后的真实被叫号码”为准。 - 被叫业务前缀匹配成功后,热路径必须剥离业务前缀,后续当前通话、CDR、计费、号码库归属地/运营商识别、地区屏蔽和落地侧转发都以“剥离后的真实被叫号码”为准。
- 当前通话和话单中写入的被叫号码必须是客户真实想呼叫的号码,即“客户送入被叫 - 被叫业务前缀”。如需排障,可额外保存原始被叫号码和命中的业务前缀。 - 当前通话和话单中写入的被叫号码必须是客户真实想呼叫的号码,即“客户送入被叫 - 被叫业务前缀”。如需排障,可额外保存原始被叫号码和命中的业务前缀。
- 计费归属口径:客户网关决定客户侧计费,落地网关决定供应商侧成本;业务前缀本身不生成独立计费维度。
- 建议新增 CDR 字段:`raw_callee``business_prefix_id``business_prefix``normalized_callee`。其中 `callee` 继续表示业务剥离后的真实被叫,避免影响现有话单展示语义。 - 建议新增 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`:任意主叫。 - `ANY`:任意主叫。
- `PREFIXES`:可配置多个自定义主叫前缀。 - `PREFIXES`:可配置多个自定义主叫前缀。
- 主叫前缀只是匹配条件,不改写主叫。 - 主叫前缀只是匹配条件,不改写主叫。
- 若同一 IP 下多个客户网关的主叫/被叫规则都可命中同一呼叫,应在保存配置时阻止冲突,或者明确热路径选择规则。建议优先在 API 保存时做冲突检测,避免呼叫时不确定 - 客户网关必须归属一个客户;同一来源 IP 允许被不同客户复用,但最终匹配必须唯一
- 同一来源 IP + 同一业务前缀组合只能归属一个客户网关;同一来源 IP 下主叫前缀不能互相覆盖。
- 同一客户需要不同业务前缀或不同路由策略时,通过配置多个客户网关分别绑定不同业务前缀/IP/线路组实现。
落地网关配置调整: 落地网关配置调整:
@@ -1481,12 +1485,15 @@ Redis 热路径与 OpenSIPS/Lua 影响:
- `cfg:v:{version}:vendor_gateway:{id}:caller_rewrite_pool` 中包含指定主叫与权重。 - `cfg:v:{version}:vendor_gateway:{id}:caller_rewrite_pool` 中包含指定主叫与权重。
- OpenSIPS/Lua 需要调整顺序: - OpenSIPS/Lua 需要调整顺序:
1. 读取来源 IP 候选客户网关列表。 1. 读取来源 IP 候选客户网关列表。
2.每个候选客户网关检查启用状态客户状态、主叫前缀规则、被叫业务前缀规则 2. 对候选客户网关检查启用状态客户状态。
3. 选出唯一客户网关,得到客户、客户侧费率、落地线路组、命中的业务前缀和剥离后的真实被叫 3. 第一优先级匹配被叫业务前缀,命中后按最长前缀选择并剥离真实被叫;若命中业务前缀但主叫规则不通过,不允许退回主叫前缀或空业务前缀兜底
4. 用真实被叫做号码库归属地/运营商识别和地区屏蔽路由 4. 第二优先级匹配空业务前缀下的主叫前缀规则,按最长主叫前缀选择
5. 选择落地网关后,按落地网关配置追加落地要求被叫前缀,并按权重选择落地要求指定主叫 5. 第三优先级匹配空业务前缀兜底网关,用于裸被叫号码
6. 转发给落地前,写入 Dialog 变量:原始被叫、真实被叫、业务前缀、客户网关、线路组、落地网关、落地侧最终主叫/被叫。 6. 选出唯一客户网关,得到客户、客户侧费率、落地线路组、命中的业务前缀和剥离后的真实被叫。
7. 当前通话与 CDR 使用真实被叫作为主展示字段;必要时详情显示原始被叫和落地侧最终被叫 7. 用真实被叫做号码库归属地/运营商识别和地区屏蔽路由
8. 选择落地网关后,按落地网关配置追加落地要求被叫前缀,并按权重选择落地要求指定主叫。
9. 转发给落地前,写入 Dialog 变量:原始被叫、真实被叫、业务前缀、客户网关、线路组、落地网关、落地侧最终主叫/被叫。
10. 当前通话与 CDR 使用真实被叫作为主展示字段;必要时详情显示原始被叫和落地侧最终被叫。
- 失败原因建议新增: - 失败原因建议新增:
- `CUSTOMER_GATEWAY_IP_MISSING` - `CUSTOMER_GATEWAY_IP_MISSING`
- `CUSTOMER_GATEWAY_RULE_CONFLICT` - `CUSTOMER_GATEWAY_RULE_CONFLICT`
@@ -275,6 +275,46 @@ describe('S13 customer gateways API', () => {
.expect(403); .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 () => { it('creates SIP digest gateway, hides the password, and writes audit', async () => {
const response = await request(app.getHttpServer()) const response = await request(app.getHttpServer())
.post('/api/v2/customer-gateways') .post('/api/v2/customer-gateways')
@@ -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 crypto from 'node:crypto';
import net from 'node:net'; import net from 'node:net';
import { import {
@@ -48,6 +48,17 @@ interface UpdateCustomerGatewayDto {
businessPrefixIds?: unknown; 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() @Injectable()
export class CustomerGatewaysService { export class CustomerGatewaysService {
constructor(@Inject(CUSTOMER_GATEWAYS_REPOSITORY) private readonly gateways: CustomerGatewaysRepository) {} constructor(@Inject(CUSTOMER_GATEWAYS_REPOSITORY) private readonly gateways: CustomerGatewaysRepository) {}
@@ -61,7 +72,7 @@ export class CustomerGatewaysService {
return this.gateways.get(gatewayId); return this.gateways.get(gatewayId);
} }
create(body: CreateCustomerGatewayDto, actorId?: string): Promise<CustomerGatewaySummary> { async create(body: CreateCustomerGatewayDto, actorId?: string): Promise<CustomerGatewaySummary> {
const authMode = this.authMode(body.authMode); const authMode = this.authMode(body.authMode);
const sipIdentity = this.normalizeSipIdentity(authMode, body.sipUsername, body.sipDomain); const sipIdentity = this.normalizeSipIdentity(authMode, body.sipUsername, body.sipDomain);
const sipPassword = this.requiredSipPassword(authMode, body.sipPassword); const sipPassword = this.requiredSipPassword(authMode, body.sipPassword);
@@ -88,6 +99,13 @@ export class CustomerGatewaysService {
actorId actorId
}; };
await this.assertMatchingUniqueness(undefined, {
sourceIps,
callerMatchMode,
callerPrefixes,
calleeMatchMode,
businessPrefixIds
});
return this.gateways.create(input); return this.gateways.create(input);
} }
@@ -140,10 +158,20 @@ export class CustomerGatewaysService {
actorId actorId
}; };
await this.assertMatchingUniqueness(gatewayId, {
id: gatewayId,
sourceIps,
callerMatchMode,
callerPrefixes,
calleeMatchMode,
businessPrefixIds
});
return this.gateways.update(gatewayId, input); return this.gateways.update(gatewayId, input);
} }
enable(gatewayId: string, actorId?: string): Promise<CustomerGatewaySummary> { async enable(gatewayId: string, actorId?: string): Promise<CustomerGatewaySummary> {
const current = await this.gateways.get(gatewayId);
await this.assertMatchingUniqueness(gatewayId, this.summaryToMatchingConfig(current));
return this.gateways.setStatus(gatewayId, 'ENABLED', actorId); 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.' }); 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; return prefixes;
} }
@@ -296,6 +333,71 @@ export class CustomerGatewaysService {
return ids; return ids;
} }
private async assertMatchingUniqueness(ignoreGatewayId: string | undefined, proposed: GatewayMatchingConfig): Promise<void> {
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[] { 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 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))]; const items = [...new Set(rawItems.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean))];
@@ -4,6 +4,8 @@ import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, Simpl
import { enStatus } from '../utils/formatters.js'; import { enStatus } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js'; import { api, explainApiError } from '../api.js';
const emptyBusinessPrefixForm = { prefix: '', name: '', description: '', priority: 100, status: 'ENABLED' };
export function BusinessPrefixesPage({ can = () => true }) { export function BusinessPrefixesPage({ can = () => true }) {
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
const [filters, setFilters] = useState({ keyword: '', status: 'all' }); const [filters, setFilters] = useState({ keyword: '', status: 'all' });
@@ -363,6 +363,9 @@ export function CustomerGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRo
<Field label="周期内费率"> <Field label="周期内费率">
<Input value={gatewayForm.cycleRate} onChange={(event) => setGatewayForm({ ...gatewayForm, cycleRate: event.target.value })} placeholder="0.000000" /> <Input value={gatewayForm.cycleRate} onChange={(event) => setGatewayForm({ ...gatewayForm, cycleRate: event.target.value })} placeholder="0.000000" />
</Field> </Field>
<Alert tone="info" title="匹配规则">
业务前缀优先于主叫前缀精确规则优先于空业务前缀兜底空业务前缀用于裸被叫号码同一 IP 下业务前缀组合必须唯一主叫前缀不能互相覆盖
</Alert>
<Field label="主叫匹配"> <Field label="主叫匹配">
<Select value={gatewayForm.callerMatchMode} onChange={(event) => setGatewayForm({ ...gatewayForm, callerMatchMode: event.target.value, callerPrefixes: '' })}> <Select value={gatewayForm.callerMatchMode} onChange={(event) => setGatewayForm({ ...gatewayForm, callerMatchMode: event.target.value, callerPrefixes: '' })}>
<option value="ANY">任意号码</option> <option value="ANY">任意号码</option>
+2
View File
@@ -4,6 +4,8 @@ import { Icon, PageTitle, Panel, ApiNotice, Modal, ConfirmDialog, Drawer, Simple
import { permissionGroups } from '../fixtures/devFixtures.js'; import { permissionGroups } from '../fixtures/devFixtures.js';
import { explainApiError } from '../api.js'; import { explainApiError } from '../api.js';
const emptyRoleForm = { name: '', description: '', status: '启用' };
export function RolesPage({ roleRows, setRoleRows, userRows, apiLoading, apiError, refreshApi, can = () => true, onDeleteRole }) { export function RolesPage({ roleRows, setRoleRows, userRows, apiLoading, apiError, refreshApi, can = () => true, onDeleteRole }) {
const [editingRole, setEditingRole] = useState(undefined); const [editingRole, setEditingRole] = useState(undefined);
const [roleForm, setRoleForm] = useState(emptyRoleForm); const [roleForm, setRoleForm] = useState(emptyRoleForm);
+2
View File
@@ -3,6 +3,8 @@ import { Alert, Badge, Button, Field, Input, Select } from '../components/ui.jsx
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx'; import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
import { explainApiError } from '../api.js'; import { explainApiError } from '../api.js';
const emptyUserForm = { username: '', name: '', phone: '', email: '', roleId: 'R002', status: '启用' };
export function UsersPage({ userRows, setUserRows, roleRows, apiLoading, apiError, refreshApi, can = () => true, onDeleteUser }) { export function UsersPage({ userRows, setUserRows, roleRows, apiLoading, apiError, refreshApi, can = () => true, onDeleteUser }) {
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [roleFilter, setRoleFilter] = useState('all'); const [roleFilter, setRoleFilter] = useState('all');
@@ -146,12 +146,25 @@ sudo systemctl restart opensips
sudo systemctl is-active opensips sudo systemctl is-active opensips
``` ```
## Release Artifact 规范
发布包必须由固定脚本生成,禁止临时手工复制目录作为 release:
```bash
pnpm release:artifact -- --release-id sXX-name-YYYYMMDDHHmmss
```
脚本会执行构建、校验发布必需目录、生成 `RELEASE_MANIFEST.json`,并输出 `dist/releases/<release-id>.tar.gz``.sha256`。正式给 B 机使用的 artifact 必须在 Linux 环境生成;在本地 Windows 仅允许使用 `--check` 或带 `--allow-non-linux` 生成检查包,不能作为服务器最终发布包。
## A/B/T 验收清单 ## A/B/T 验收清单
至少验证以下呼叫: 至少验证以下呼叫:
- 命中业务前缀:例如客户送 `67118822190000`,话单 `callee=18822190000``raw_callee=67118822190000``business_prefix=671` - 命中业务前缀:例如客户送 `67118822190000`,话单 `callee=18822190000``raw_callee=67118822190000``business_prefix=671`
- 任意被叫规则:客户网关 `calleeMatchMode=ANY` 时,旧无业务前缀呼叫仍可路由。 - 任意被叫规则:客户网关 `calleeMatchMode=ANY` 时,旧无业务前缀呼叫仍可路由。
- 匹配优先级:业务前缀优先于主叫前缀,精确规则优先于空业务前缀兜底;命中业务前缀但主叫不通过时,不应退回裸号兜底网关。
- 唯一性约束:同一 IP + 同一业务前缀组合只能归属一个客户网关;同一 IP 下主叫前缀不能互相覆盖;IP 可跨客户复用但最终匹配必须唯一。
- 计费口径:客户网关决定客户侧计费,落地网关决定供应商侧成本,业务前缀不参与计费产品、报表维度或权限隔离。
- 主叫前缀命中:配置 `callerPrefixes` 后,命中前缀允许呼叫。 - 主叫前缀命中:配置 `callerPrefixes` 后,命中前缀允许呼叫。
- 主叫前缀不命中:返回 `CALLER_PREFIX_NOT_MATCHED` - 主叫前缀不命中:返回 `CALLER_PREFIX_NOT_MATCHED`
- 业务前缀不命中:返回 `BUSINESS_PREFIX_NOT_MATCHED` - 业务前缀不命中:返回 `BUSINESS_PREFIX_NOT_MATCHED`
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,184 @@
# 第 1 步测试结果:本地静态与自动化基线
执行时间:2026-06-28 18:44:25 +08:00
执行范围:
- ESLint 静态检查
- TypeScript 项目引用类型检查
- 全量 Vitest
- Monorepo 构建
- Prisma schema 校验
## 1. 结果总览
| 检查项 | 命令 | 结果 |
| --- | --- | --- |
| Lint | `corepack pnpm@10.33.0 lint` | 通过 |
| Typecheck | `corepack pnpm@10.33.0 typecheck` | 通过 |
| Test | `corepack pnpm@10.33.0 test` | 未通过 |
| Build | `corepack pnpm@10.33.0 build` | 通过 |
| Prisma validate | `corepack pnpm@10.33.0 prisma:validate` | 通过 |
整体结论:首次执行第 1 步基线未完全通过,阻断项为全量 Vitest 中 7 个 API e2e suite 的 `beforeAll` hook 默认 10 秒超时。其余静态检查、类型检查、构建和 Prisma schema 校验均通过。
复测结论:已在 `vitest.config.ts` 显式设置 `hookTimeout: 60000`,随后重新执行裸 `corepack pnpm@10.33.0 test`,25 个测试文件、88 个测试用例全部通过。该结果说明首次失败是全 AppModule e2e 初始化超过默认 hook timeout 导致,不是业务断言失败。
## 2. 详细结果
### 2.1 Lint
命令:
```powershell
corepack pnpm@10.33.0 lint
```
结果:通过。
摘要:
- 执行 `eslint .`
- 未发现 ESLint 报错。
### 2.2 Typecheck
命令:
```powershell
corepack pnpm@10.33.0 typecheck
```
结果:通过。
摘要:
- 执行 `tsc -b tsconfig.build.json --pretty`
- TypeScript 项目引用检查通过。
### 2.3 Test
命令:
```powershell
corepack pnpm@10.33.0 test
```
结果:未通过。
Vitest 汇总:
- Test Files7 failed18 passed,共 25。
- Tests68 passed20 skipped,共 88。
- Duration45.57s。
失败原因:
7 个 API e2e suite 在 `beforeAll` 阶段触发默认 10000ms hook timeout
| 失败文件 | 失败位置 | 摘要 |
| --- | --- | --- |
| `apps/api/src/app.e2e.spec.ts` | `beforeAll` line 10 | API health e2e 初始化超时 |
| `apps/api/src/modules/auth/auth.e2e.spec.ts` | `beforeAll` line 85 | Auth API e2e 初始化超时 |
| `apps/api/src/modules/customers/customers.e2e.spec.ts` | `beforeAll` line 161 | Customers API e2e 初始化超时 |
| `apps/api/src/modules/landing-line-groups/landing-line-groups.e2e.spec.ts` | `beforeAll` line 228 | Landing line groups API e2e 初始化超时 |
| `apps/api/src/modules/recharges/recharges.e2e.spec.ts` | `beforeAll` line 124 | Recharges API e2e 初始化超时 |
| `apps/api/src/modules/security/security-rbac-audit.e2e.spec.ts` | `beforeAll` line 172 | Security/RBAC/Audit API e2e 初始化超时 |
| `apps/api/src/modules/vendor-gateways/vendor-gateways.e2e.spec.ts` | `beforeAll` line 184 | Vendor gateways API e2e 初始化超时 |
已通过的测试文件包括:
- `apps/api/src/modules/active-calls/active-calls.service.spec.ts`
- `apps/api/src/modules/auth/auth.service.spec.ts`
- `apps/worker-recording/src/transfer.spec.ts`
- `packages/auth/src/index.spec.ts`
- `apps/api/src/modules/customer-gateway-policies/customer-gateway-policies.e2e.spec.ts`
- `apps/api/src/modules/customer-gateways/customer-gateways.e2e.spec.ts`
- `apps/api/src/modules/vendors/vendors.e2e.spec.ts`
- `apps/api/src/modules/users-roles-delete.e2e.spec.ts`
- `apps/api/src/modules/number-library/number-library.e2e.spec.ts`
- `apps/worker-cdr/src/rating.spec.ts`
- `packages/database/src/schema-contract.spec.ts`
- `apps/api/src/modules/dashboard/dashboard.service.spec.ts`
- `packages/redis/src/cdr-stream.spec.ts`
- `apps/api/src/modules/cdrs/cdrs.service.spec.ts`
- `apps/worker-config-publisher/src/publisher.spec.ts`
- `apps/api/src/modules/recordings/recordings.service.spec.ts`
- `apps/api/src/modules/quality/sampling.spec.ts`
- `apps/api/src/modules/health/health.service.spec.ts`
初步判断:
- 失败集中在 API e2e `beforeAll` 初始化阶段,错误均为默认 10 秒 hook timeout。
- 结合历史执行记录,本项目 e2e 曾使用 `--hookTimeout=60000 --maxWorkers=1` 通过;本次按第 1 步基线命令执行裸 `pnpm test`,因此更可能是本地 e2e 初始化耗时或并发资源争用导致的基线命令超时,而不是业务断言失败。
- 仍需在下一轮复测中用扩展 hook timeout 和单 worker 复跑,确认是否存在真实数据库/Redis/API 初始化问题。
### 2.4 Build
命令:
```powershell
corepack pnpm@10.33.0 build
```
结果:通过。
摘要:
- `packages/auth``packages/contracts``packages/database``packages/domain``packages/observability``packages/redis` 构建通过。
- `apps/api``apps/worker-cdr``apps/worker-config-publisher``apps/worker-recording` 构建通过。
- `apps/web` Vite 构建通过,生成产物:
- `dist/index.html`
- `dist/assets/index-B2uYBtS3.css`
- `dist/assets/index-BK00zN_M.js`
### 2.5 Prisma Validate
命令:
```powershell
corepack pnpm@10.33.0 prisma:validate
```
结果:通过。
摘要:
- Prisma config`prisma.config.ts`
- Schema`prisma/schema.prisma`
- 结果:schema valid。
## 3. 当前风险
- 首次 `pnpm test` 失败集中在 e2e 初始化超时,不应直接按业务缺陷处理。
- 已通过配置层修复验证:`vitest.config.ts` 增加 `hookTimeout: 60000` 后,`pnpm test` 通过。
- 若后续在低性能 CI 或数据库连接较慢环境中再次出现初始化超时,可再考虑降低 e2e 并发,例如使用 `--maxWorkers=1`
## 4. 复测记录
执行时间:2026-06-28 18:53:16 +08:00
变更:
```typescript
// vitest.config.ts
hookTimeout: 60000
```
复测命令:
```powershell
corepack pnpm@10.33.0 test
```
复测结果:
- Test Files25 passed,共 25。
- Tests88 passed,共 88。
- Duration12.56s。
## 5. 建议下一步
1. 保留 `vitest.config.ts` 中的 `hookTimeout: 60000`
2. 如要继续第 1 步完整闭环,可再次执行 `lint``typecheck``test``build``prisma:validate` 全套命令并更新最终状态。
3. 进入第 2 步定向单元/API e2e 回归时,可直接使用当前 Vitest 配置。
+84 -19
View File
@@ -209,17 +209,22 @@ end
local function match_caller(gateway_id, gateway_json) local function match_caller(gateway_id, gateway_json)
local mode = field(gateway_json, 'callerMatchMode') or 'ANY' local mode = field(gateway_json, 'callerMatchMode') or 'ANY'
if mode == 'ANY' then if mode == 'ANY' then
return true return true, 0
end end
if mode == 'PREFIXES' then if mode == 'PREFIXES' then
local prefixes = redis.call('LRANGE', prefix .. ':customer_gateway:' .. gateway_id .. ':caller_prefixes', 0, -1) local prefixes = redis.call('LRANGE', prefix .. ':customer_gateway:' .. gateway_id .. ':caller_prefixes', 0, -1)
local best_len = 0
for _, caller_prefix in ipairs(prefixes) do for _, caller_prefix in ipairs(prefixes) do
if starts_with(caller, caller_prefix) then local prefix_len = string.len(caller_prefix)
return true if starts_with(caller, caller_prefix) and prefix_len > best_len then
best_len = prefix_len
end end
end end
if best_len > 0 then
return true, best_len
end
end end
return false return false, 0
end end
local function match_callee(gateway_id, gateway_json) local function match_callee(gateway_id, gateway_json)
@@ -334,6 +339,23 @@ local function legacy_policy_route(gateway_id, customer_id)
return nil return nil
end end
local function route_customer_gateway(gateway_id, gateway_json, customer_id, real_callee, business_prefix_id, business_prefix)
local line_group_id = field(gateway_json, 'lineGroupId')
if line_group_id and line_group_id ~= '' then
local vendor_route, route_reason = first_vendor_route(line_group_id, real_callee)
if vendor_route then
return result('allow', 'OK', gateway_id, active, line_group_id, 'single_gateway', customer_id, vendor_route[1], vendor_route[2], vendor_route[3], vendor_route[4], real_callee, business_prefix_id, business_prefix, vendor_route[5], vendor_route[6])
end
return result('reject', route_reason, gateway_id, active, line_group_id, 'single_gateway', customer_id, 'none', 'none', 'none', 'none', real_callee, business_prefix_id, business_prefix, caller, real_callee)
end
local legacy = legacy_policy_route(gateway_id, customer_id)
if legacy then
return legacy
end
return result('reject', 'LINE_GROUP_MISSING', gateway_id, active, 'none', 'single_gateway', customer_id, 'none', 'none', 'none', 'none', real_callee, business_prefix_id, business_prefix, caller, real_callee)
end
local candidates = redis.call('LRANGE', prefix .. ':auth:ip:' .. source_ip .. ':gateways', 0, -1) local candidates = redis.call('LRANGE', prefix .. ':auth:ip:' .. source_ip .. ':gateways', 0, -1)
if #candidates == 0 then if #candidates == 0 then
local legacy_gateway_id = redis.call('GET', prefix .. ':auth:ip:' .. source_ip) local legacy_gateway_id = redis.call('GET', prefix .. ':auth:ip:' .. source_ip)
@@ -350,6 +372,21 @@ local saw_gateway = false
local saw_customer = false local saw_customer = false
local saw_caller = false local saw_caller = false
local saw_business_prefix_required = false local saw_business_prefix_required = false
local saw_business_prefix_match = false
local selected_business_prefix_gateway = nil
local selected_business_prefix_gateway_json = nil
local selected_business_prefix_customer_id = nil
local selected_business_prefix_callee = nil
local selected_business_prefix_id = 'none'
local selected_business_prefix = 'none'
local selected_business_prefix_length = -1
local selected_caller_gateway = nil
local selected_caller_gateway_json = nil
local selected_caller_customer_id = nil
local selected_caller_length = -1
local selected_fallback_gateway = nil
local selected_fallback_gateway_json = nil
local selected_fallback_customer_id = nil
for _, gateway_id in ipairs(candidates) do for _, gateway_id in ipairs(candidates) do
local gateway_json = redis.call('GET', prefix .. ':customer_gateway:' .. gateway_id) local gateway_json = redis.call('GET', prefix .. ':customer_gateway:' .. gateway_id)
@@ -358,27 +395,42 @@ for _, gateway_id in ipairs(candidates) do
local customer_id, customer_reason, customer_marker = customer_ok(gateway_id, gateway_json) local customer_id, customer_reason, customer_marker = customer_ok(gateway_id, gateway_json)
if customer_id then if customer_id then
saw_customer = true saw_customer = true
if match_caller(gateway_id, gateway_json) then local caller_ok, caller_prefix_length = match_caller(gateway_id, gateway_json)
saw_caller = true local callee_mode = field(gateway_json, 'calleeMatchMode') or 'ANY'
if (field(gateway_json, 'calleeMatchMode') or 'ANY') == 'BUSINESS_PREFIXES' then if callee_mode == 'BUSINESS_PREFIXES' then
saw_business_prefix_required = true saw_business_prefix_required = true
if caller_ok then
saw_caller = true
end end
local real_callee, business_prefix_id, business_prefix = match_callee(gateway_id, gateway_json) local real_callee, business_prefix_id, business_prefix = match_callee(gateway_id, gateway_json)
if real_callee then if real_callee then
local line_group_id = field(gateway_json, 'lineGroupId') saw_business_prefix_match = true
if line_group_id and line_group_id ~= '' then if caller_ok then
local vendor_route, route_reason = first_vendor_route(line_group_id, real_callee) local business_prefix_length = string.len(business_prefix or '')
if vendor_route then if business_prefix_length > selected_business_prefix_length then
return result('allow', 'OK', gateway_id, active, line_group_id, 'single_gateway', customer_id, vendor_route[1], vendor_route[2], vendor_route[3], vendor_route[4], real_callee, business_prefix_id, business_prefix, vendor_route[5], vendor_route[6]) selected_business_prefix_gateway = gateway_id
selected_business_prefix_gateway_json = gateway_json
selected_business_prefix_customer_id = customer_id
selected_business_prefix_callee = real_callee
selected_business_prefix_id = business_prefix_id
selected_business_prefix = business_prefix
selected_business_prefix_length = business_prefix_length
end end
return result('reject', route_reason, gateway_id, active, line_group_id, 'single_gateway', customer_id, 'none', 'none', 'none', 'none', real_callee, business_prefix_id, business_prefix, caller, real_callee)
end end
end
local legacy = legacy_policy_route(gateway_id, customer_id) elseif caller_ok then
if legacy then saw_caller = true
return legacy if caller_prefix_length > 0 then
if caller_prefix_length > selected_caller_length then
selected_caller_gateway = gateway_id
selected_caller_gateway_json = gateway_json
selected_caller_customer_id = customer_id
selected_caller_length = caller_prefix_length
end end
return result('reject', 'LINE_GROUP_MISSING', gateway_id, active, 'none', 'single_gateway', customer_id, 'none', 'none', 'none', 'none', real_callee, business_prefix_id, business_prefix, caller, real_callee) elseif not selected_fallback_gateway then
selected_fallback_gateway = gateway_id
selected_fallback_gateway_json = gateway_json
selected_fallback_customer_id = customer_id
end end
end end
else else
@@ -387,6 +439,19 @@ for _, gateway_id in ipairs(candidates) do
end end
end end
if selected_business_prefix_gateway then
return route_customer_gateway(selected_business_prefix_gateway, selected_business_prefix_gateway_json, selected_business_prefix_customer_id, selected_business_prefix_callee, selected_business_prefix_id, selected_business_prefix)
end
if saw_business_prefix_match then
return result('reject', 'CALLER_PREFIX_NOT_MATCHED', candidates[1] or 'none', active, 'none', 'none', 'caller_prefix_not_matched', 'none', 'none', 'none', 'none', raw_callee, 'none', 'none', caller, raw_callee)
end
if selected_caller_gateway then
return route_customer_gateway(selected_caller_gateway, selected_caller_gateway_json, selected_caller_customer_id, raw_callee, 'none', 'none')
end
if selected_fallback_gateway then
return route_customer_gateway(selected_fallback_gateway, selected_fallback_gateway_json, selected_fallback_customer_id, raw_callee, 'none', 'none')
end
if not saw_gateway then if not saw_gateway then
return result('reject', 'GATEWAY_MISSING', candidates[1] or 'none', active, 'none', 'none', 'gateway_missing', 'none', 'none', 'none', 'none', raw_callee, 'none', 'none', caller, raw_callee) return result('reject', 'GATEWAY_MISSING', candidates[1] or 'none', active, 'none', 'none', 'gateway_missing', 'none', 'none', 'none', 'none', raw_callee, 'none', 'none', caller, raw_callee)
end end
+1
View File
@@ -38,6 +38,7 @@
"typecheck": "tsc -b tsconfig.build.json --pretty", "typecheck": "tsc -b tsconfig.build.json --pretty",
"test": "vitest run", "test": "vitest run",
"ci": "node scripts/pnpm-run.mjs lint && node scripts/pnpm-run.mjs typecheck && node scripts/pnpm-run.mjs test && node scripts/pnpm-run.mjs build", "ci": "node scripts/pnpm-run.mjs lint && node scripts/pnpm-run.mjs typecheck && node scripts/pnpm-run.mjs test && node scripts/pnpm-run.mjs build",
"release:artifact": "node scripts/build-release-artifact.mjs",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:validate": "cross-env DATABASE_URL=mysql://lisglosips_app@127.0.0.1:3306/lisglosips prisma validate", "prisma:validate": "cross-env DATABASE_URL=mysql://lisglosips_app@127.0.0.1:3306/lisglosips prisma validate",
"db:seed": "prisma db seed" "db:seed": "prisma db seed"
Binary file not shown.
Binary file not shown.
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const root = process.cwd();
const args = process.argv.slice(2);
function argValue(name) {
const index = args.indexOf(name);
return index >= 0 ? args[index + 1] : undefined;
}
function hasArg(name) {
return args.includes(name);
}
if (hasArg('--help') || hasArg('-h')) {
console.log(`Usage: node scripts/build-release-artifact.mjs --release-id <id> [--out-dir dist/releases] [--skip-build] [--check] [--allow-non-linux]`);
process.exit(0);
}
const releaseId = argValue('--release-id') || process.env.RELEASE_ID;
if (!releaseId || !/^[A-Za-z0-9._-]+$/.test(releaseId)) {
throw new Error('Missing or invalid release id. Use --release-id <letters-numbers-dot-underscore-dash>.');
}
const outDir = path.resolve(root, argValue('--out-dir') || 'dist/releases');
const stagingDir = path.resolve(root, '.release-artifacts', 'staging', releaseId);
const checkOnly = hasArg('--check');
const skipBuild = hasArg('--skip-build');
const allowNonLinux = hasArg('--allow-non-linux');
const copyEntries = [
['package.json', 'package.json', true],
['pnpm-lock.yaml', 'pnpm-lock.yaml', true],
['node_modules', 'node_modules', true],
['prisma/schema.prisma', 'prisma/schema.prisma', true],
['prisma/migrations', 'prisma/migrations', true],
['apps/api/package.json', 'apps/api/package.json', true],
['apps/api/dist', 'apps/api/dist', true],
['apps/api/node_modules', 'apps/api/node_modules', true],
['apps/web/package.json', 'apps/web/package.json', true],
['apps/web/dist', 'public', true],
['apps/web/node_modules', 'apps/web/node_modules', true],
['apps/worker-cdr/package.json', 'apps/worker-cdr/package.json', true],
['apps/worker-cdr/dist', 'apps/worker-cdr/dist', true],
['apps/worker-cdr/node_modules', 'apps/worker-cdr/node_modules', true],
['apps/worker-recording/package.json', 'apps/worker-recording/package.json', true],
['apps/worker-recording/dist', 'apps/worker-recording/dist', true],
['apps/worker-recording/node_modules', 'apps/worker-recording/node_modules', true],
['apps/worker-config-publisher/package.json', 'apps/worker-config-publisher/package.json', true],
['apps/worker-config-publisher/dist', 'apps/worker-config-publisher/dist', true],
['apps/worker-config-publisher/node_modules', 'apps/worker-config-publisher/node_modules', true],
['packages/auth/package.json', 'packages/auth/package.json', true],
['packages/auth/dist', 'packages/auth/dist', true],
['packages/auth/vendor/argon2id/dist', 'packages/auth/vendor/argon2id/dist', true],
['packages/contracts/package.json', 'packages/contracts/package.json', true],
['packages/contracts/dist', 'packages/contracts/dist', true],
['packages/database/package.json', 'packages/database/package.json', true],
['packages/database/dist', 'packages/database/dist', true],
['packages/database/node_modules', 'packages/database/node_modules', true],
['packages/domain/package.json', 'packages/domain/package.json', true],
['packages/domain/dist', 'packages/domain/dist', true],
['packages/observability/package.json', 'packages/observability/package.json', true],
['packages/observability/dist', 'packages/observability/dist', true],
['packages/observability/node_modules', 'packages/observability/node_modules', true],
['packages/redis/package.json', 'packages/redis/package.json', true],
['packages/redis/dist', 'packages/redis/dist', true],
['packages/redis/node_modules', 'packages/redis/node_modules', true],
['infra/server-b/s30', 'infra/server-b/s30', true],
['infra/server-a/s28/lisglosips_hotpath.lua', 'infra/server-a/s28/lisglosips_hotpath.lua', true],
['scripts/phase2-gateway-migration.mjs', 'scripts/phase2-gateway-migration.mjs', true]
];
function run(command, commandArgs, options = {}) {
const result = spawnSync(command, commandArgs, { cwd: root, stdio: options.stdio || 'pipe', encoding: 'utf8' });
if (result.status !== 0) {
const output = [result.stdout, result.stderr].filter(Boolean).join('\n');
throw new Error(`${command} ${commandArgs.join(' ')} failed\n${output}`);
}
return (result.stdout || '').trim();
}
async function sha256(filePath) {
const hash = createHash('sha256');
hash.update(await fs.readFile(filePath));
return hash.digest('hex');
}
async function copyEntry(source, target) {
const sourcePath = path.resolve(root, source);
const targetPath = path.resolve(stagingDir, target);
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.cp(sourcePath, targetPath, { recursive: true, verbatimSymlinks: true });
}
async function collectFiles(dir, prefixPath = '') {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const relative = path.join(prefixPath, entry.name);
if (entry.isDirectory()) {
files.push(...await collectFiles(dir, relative));
} else if (entry.isFile()) {
files.push(relative.replaceAll(path.sep, '/'));
}
}
return files;
}
if (process.platform !== 'linux' && !allowNonLinux && !checkOnly) {
throw new Error('Release artifacts should be built on Linux for B-server runtime compatibility. Re-run with --allow-non-linux only for a local inspection artifact.');
}
if (checkOnly) {
const missing = copyEntries.filter(([source, , required]) => required && !existsSync(path.resolve(root, source)));
if (missing.length > 0) {
throw new Error(`Missing required release paths:\n${missing.map(([source]) => `- ${source}`).join('\n')}`);
}
console.log(`Release artifact check passed for ${releaseId}. ${copyEntries.length} required entries are present.`);
process.exit(0);
}
if (!skipBuild) {
run(process.execPath, ['scripts/pnpm-run.mjs', '-r', 'build'], { stdio: 'inherit' });
}
const missing = copyEntries.filter(([source, , required]) => required && !existsSync(path.resolve(root, source)));
if (missing.length > 0) {
throw new Error(`Missing required release paths:\n${missing.map(([source]) => `- ${source}`).join('\n')}`);
}
await fs.rm(stagingDir, { recursive: true, force: true });
await fs.mkdir(stagingDir, { recursive: true });
for (const [source, target] of copyEntries) {
await copyEntry(source, target);
}
const manifest = {
releaseId,
createdAt: new Date().toISOString(),
gitCommit: run('git', ['rev-parse', 'HEAD']),
gitStatus: run('git', ['status', '--short']).split('\n').filter(Boolean),
node: process.version,
pnpm: run(process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', ['--version']),
platform: process.platform,
arch: process.arch,
requiredEntries: copyEntries.map(([source, target]) => ({ source, target }))
};
await fs.writeFile(path.join(stagingDir, 'RELEASE_MANIFEST.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
await fs.mkdir(outDir, { recursive: true });
const tarPath = path.join(outDir, `${releaseId}.tar.gz`);
run('tar', ['-czf', tarPath, '-C', stagingDir, '.'], { stdio: 'inherit' });
const files = await collectFiles(stagingDir);
const artifactHash = await sha256(tarPath);
await fs.writeFile(path.join(outDir, `${releaseId}.sha256`), `${artifactHash} ${path.basename(tarPath)}\n`, 'utf8');
console.log(`Release artifact created: ${tarPath}`);
console.log(`SHA256: ${artifactHash}`);
console.log(`Files staged: ${files.length}`);
+1
View File
@@ -5,6 +5,7 @@ export default defineConfig({
globals: true, globals: true,
include: ['apps/**/*.spec.ts', 'packages/**/*.spec.ts', 'tests/**/*.spec.ts'], include: ['apps/**/*.spec.ts', 'packages/**/*.spec.ts', 'tests/**/*.spec.ts'],
environment: 'node', environment: 'node',
hookTimeout: 60000,
testTimeout: 10000 testTimeout: 10000
} }
}); });