diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md index 57eec51..68eb864 100644 --- a/IMPLEMENTATION_STATUS.md +++ b/IMPLEMENTATION_STATUS.md @@ -4,11 +4,11 @@ ## 当前状态 -- 当前任务:需求修改/二期变更:客户网关匹配口径固化与 release artifact 脚本 -- 本次变更摘要:已按“业务前缀优先于主叫前缀,精确规则优先于空业务前缀兜底”更新需求、代码和测试用例;新增并修复 Linux release artifact 脚本;已提交 Git 并部署到 A/B/T 本地联调环境。 -- 总体状态:S30 已完成;本地 KVM 开发环境 V2 闭环原冻结 release 为 `s28-v2-20260621220924`;B 二期冻结基线为 `s42-phase2-business-prefix-20260624140000`,当前已切换到 release `s44-phase2-gateway-match-20260628225000`;切换前已生成并校验 MySQL/Redis 备份 `/data/backups/mysql/20260628T143121Z` 与 `/data/backups/redis/20260628T143122Z`;B preflight 通过,API ready 返回 ok,HTTPS 首页加载前端资产 `index-BBq8iMHt.js`;A 已加载二期 Lua 热路径 SHA1 `aff9995dbda2a2425e95a3e25b5d7ce194348ce7`,OpenSIPS 配置 SHA-256 为 `f163fa37f7aaa492e12ed496cb82f07d9b0f1030a6ed297855a22d2c4bf1c8a5`;T 服务 active,并已完成 1 通 S40 最小呼叫验收;当前仍未导入真实 80 万手机号段;阿里云迁移前需按 S30 Runbook 重新演练 -- 最后更新:2026-06-28 22:34 +08:00 -- 当前阻塞:无阻塞。遗留问题:本次未执行数据库 schema 变更;真实 80 万手机号段尚未导入;OpenSIPS dialog 残留根因仍待单独分析;真实 B 服务器浏览器登录态逐页按钮、真实录音文件播放和 Nginx/X-Accel 链路仍需用户测试复核;S40 呼叫时脚本内置 UAS 端口提示已占用,但现有 UAS 正常接起且通话/CDR/计费成功。 +- 当前任务:需求修改/二期变更:供应商启停移除、客户启停入口、落地网关 CPS 数字校验、当前通话 SIP 状态 +- 本次变更摘要:本地代码已移除供应商启停 API/前端状态展示;客户管理页面补充启用/禁用按钮和确认弹窗;落地网关 CPS 改为 1-10000 数字输入与后端校验;当前通话列表改展示 SIP 信令状态;同步更新测试用例和设计说明。 +- 总体状态:S30 已完成;本地 KVM 开发环境 V2 闭环原冻结 release 为 `s28-v2-20260621220924`;B 二期冻结基线为 `s42-phase2-business-prefix-20260624140000`,当前 release 为 `s44-phase2-gateway-match-20260628225000`;B preflight 通过,API ready 返回 ok,HTTPS 首页加载前端资产 `index-BBq8iMHt.js`;Redis `cfg:active_version=s28-v1`,A 当前 EVALSHA 脚本在 Redis 中存在;A 已加载二期 Lua 热路径 SHA1 `aff9995dbda2a2425e95a3e25b5d7ce194348ce7`,OpenSIPS 配置 SHA-256 为 `f163fa37f7aaa492e12ed496cb82f07d9b0f1030a6ed297855a22d2c4bf1c8a5`;A/B/T 目标服务均 active;已完成 1 通 S40 最小呼叫验收并确认 CDR/计费落库;B 登录依赖 Argon2id 冒烟通过;当前仍未导入真实 80 万手机号段;阿里云迁移前需按 S30 Runbook 重新演练 +- 最后更新:2026-06-29 11:29 +08:00 +- 当前阻塞:无阻塞。遗留问题:本次未连接服务器、未发布 A/B/T、未执行数据库 schema 变更或服务重启;真实 80 万手机号段尚未导入;OpenSIPS dialog 残留根因仍待单独分析;真实 B 服务器浏览器登录态逐页按钮、真实录音文件播放和 Nginx/X-Accel 链路仍需用户测试复核;S40 呼叫时脚本内置 UAS 端口提示已占用,但现有 UAS 正常接起且通话/CDR/计费成功;A/T 各有一个无关 `fwupd-refresh.service` failed unit,未处理。 - 当前环境:A/B/T 为本地 KVM 开发服务器,通过 Tailscale 联调;开发完成后再迁移阿里云 - 私密访问资料:`.codex-private/SERVER_ACCESS.md`,只在实际连接时读取,禁止回显 @@ -90,6 +90,40 @@ ## 交接记录 +### 2026-06-29 11:29 - 需求修改/二期变更:供应商启停移除、客户启停入口、CPS 数字校验、当前通话 SIP 状态 + +- 状态:已完成 +- 操作服务器:本地 +- 完成内容:移除供应商管理的启用/禁用 API 和前端状态列,供应商新增/编辑不再接收或返回启停字段;客户管理页面新增启用/禁用按钮、权限裁剪和确认弹窗,调用既有 `POST /customers/:id/enable|disable`;落地网关 `cpsLimit` 改为 1-10000 整数,Web 表单使用数字输入且不再接受 `120 CPS` 这类文本;当前通话 API 新增 `sipState`、`sipStatusCode`、`sipStatusText`,Web 列表改显示 SIP 信令状态;同步更新 `docs/TEST_PLAN_AND_CASES.md` 和 `SOFTSWITCH_PLATFORM_DESIGN_V2.md`。 +- 修改文件:`apps/api/src/modules/vendors/`、`apps/api/src/modules/vendor-gateways/`、`apps/api/src/modules/active-calls/`、`apps/web/src/App.jsx`、`apps/web/src/api.js`、`apps/web/src/pages/CustomersPage.jsx`、`apps/web/src/pages/VendorsPage.jsx`、`apps/web/src/pages/VendorGatewaysPage.jsx`、`apps/web/src/pages/ActiveCallsPage.jsx`、`apps/web/src/utils/formatters.js`、`apps/web/src/fixtures/devFixtures.js`、`docs/TEST_PLAN_AND_CASES.md`、`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、`IMPLEMENTATION_STATUS.md` +- 验证结果:`corepack pnpm@10.33.0 typecheck` 通过;`corepack pnpm@10.33.0 test -- apps/api/src/modules/vendors/vendors.e2e.spec.ts apps/api/src/modules/customers/customers.e2e.spec.ts apps/api/src/modules/vendor-gateways/vendor-gateways.e2e.spec.ts apps/api/src/modules/active-calls/active-calls.service.spec.ts` 实际触发 Vitest 全量选择,25 个测试文件 91 条测试通过;`corepack pnpm@10.33.0 lint` 通过;`corepack pnpm@10.33.0 build` 通过,Web 产物为 `index-CBRZZA7t.js` / `index-B2uYBtS3.css`。 +- 回滚方式:回退上述本地文件即可恢复旧行为;本次未新增数据库迁移、未连接 A/B/T、未改 Redis/OpenSIPS/RTPEngine/Worker 运行配置、未重启服务。 +- 未解决问题:本次仅完成本地代码和测试用例调整,尚未发布到 A/B/T;供应商表的数据库 `status` 字段仍保留用于历史兼容和软删除内部状态,不再作为产品启停能力暴露。 + +### 2026-06-29 10:52 - 修复 B Web 登录 500:补齐 Argon2id vendor lib + +- 状态:已完成 +- 操作服务器:B、本地 +- 完成内容:根据 B `lisglosips@api` 日志定位 `/api/v2/auth/login` 500 根因:当前 s44 release 缺少 `packages/auth/vendor/argon2id/lib/setup.js`,导致密码校验动态 import 失败;备份当前 s44 Argon2id vendor 目录到 `/var/backups/lisglosips-auth-vendor/20260629T105135`;从已验证的旧 release 复制完整 `packages/auth/vendor/argon2id` 到当前 s44 release,设置属主 `root:lisglosips`、目录 `0750`、文件 `0640`;重启 `lisglosips@api`;修复本地 `scripts/build-release-artifact.mjs`,将 release artifact 条目从仅打包 `packages/auth/vendor/argon2id/dist` 改为打包完整 `packages/auth/vendor/argon2id`。 +- 修改文件:`scripts/build-release-artifact.mjs`、`IMPLEMENTATION_STATUS.md`;B 当前 release `/opt/lisglosips/releases/s44-phase2-gateway-match-20260628225000/packages/auth/vendor/argon2id`。 +- 安装软件及固定版本:未安装新软件。 +- 执行的关键命令:B `journalctl -u lisglosips@api`、`cp -a`、`chown`、`chmod`、`systemctl restart lisglosips@api`、`curl http://127.0.0.1:3000/api/v2/health/ready`、Argon2id hash/verify 冒烟;本地 `node scripts/build-release-artifact.mjs --release-id s-local-auth-vendor-check --check`。 +- 验证结果:B API active;API ready 返回 `ok`;当前 release 内 Argon2id hash/verify 输出 `argon2-smoke-ok`;最近 API 日志未再出现 `ERR_MODULE_NOT_FOUND` 或缺少 `setup.js`;本地 release artifact check 通过,确认 39 个 required entries 均存在。 +- 回滚方式:如需撤回本次热修复,可恢复 `/var/backups/lisglosips-auth-vendor/20260629T105135` 到 `/opt/lisglosips/releases/s44-phase2-gateway-match-20260628225000/packages/auth/vendor/argon2id` 后重启 `lisglosips@api`,但会恢复登录 500;更完整的应用回滚仍按 S30 Runbook 切回上一 release。 +- 未解决问题:尚未由浏览器完成真实账号登录复测;本地脚本已修复,但 s44 已生成 tarball 本身仍是旧 artifact,需要后续重新生成 release artifact 才能让制品与热修复一致。 + +### 2026-06-29 10:45 - A/B/T 重启后恢复本地联调环境 + +- 状态:已完成 +- 操作服务器:A、B、T、本地 +- 完成内容:按 `IMPLEMENTATION_STATUS.md`、`SOFTSWITCH_PLATFORM_DESIGN_V2.md`、二期迁移 Runbook 和 S30 Runbook 建立边界;先只读检查三机状态;确认 A `opensips`、`rtpengine`、`rtpengine-recording-daemon`、`lisglosips-redis-auth-proxy`、`lisglosips-node-exporter` active,`lisglosips-redis-hotpath-load` 为 oneshot 且上次结果 success;确认 A Lua SHA1 `aff9995dbda2a2425e95a3e25b5d7ce194348ce7` 与 `opensips.cfg` EVALSHA 一致,OpenSIPS 配置 SHA-256 为 `f163fa37f7aaa492e12ed496cb82f07d9b0f1030a6ed297855a22d2c4bf1c8a5`,`opensips -C -f /etc/opensips/opensips.cfg` 通过;确认 B current 为 `/opt/lisglosips/releases/s44-phase2-gateway-match-20260628225000`,启动关机后 inactive 的 `lisglosips@recording-worker` 与 `lisglosips@config-publisher`;确认 B MySQL、Redis、Nginx、API、CDR Worker、Recording Worker、Config Publisher 均 active;确认 T `opensips`、`rtpengine`、`apache2`、`mariadb`、`lisglosips-s28-uas` active,S28/S40 脚本存在可执行。 +- 修改文件:`IMPLEMENTATION_STATUS.md` +- 安装软件及固定版本:未安装新软件。 +- 执行的关键命令:`systemctl is-active`、`systemctl --failed`、`readlink -f /opt/lisglosips/current`、B `systemctl start lisglosips@recording-worker lisglosips@config-publisher`、B `lisglosips-release-preflight.sh`、B `curl http://127.0.0.1:3000/api/v2/health/ready`、B `curl -k https://127.0.0.1/`、A/T `opensips -C -f /etc/opensips/opensips.cfg`、Redis `SCRIPT EXISTS`、T S40 单通呼叫、B 按 Call-ID 查询 `raw_cdrs`/`rated_cdrs`。 +- 验证结果:B API ready 返回 `ok`,HTTPS 首页返回 200 并加载 `assets/index-BBq8iMHt.js` 与 `assets/index-B2uYBtS3.css`;B preflight ok,最新备份目录显示 `/data/backups/mysql/20260629T013942Z` 与 `/data/backups/redis/20260629T013942Z`;Redis `cfg:active_version=s28-v1`,`SCRIPT EXISTS aff9995dbda2a2425e95a3e25b5d7ce194348ce7` 返回 `1`;A/B/T 目标服务最终均 active;S40 最小呼叫 `s40-01-1782701049649-q3u8wrqi@lisglosips-t` 收到 `200 OK` 与 `200 BYE`;B 新增 `raw_cdrs` 记录 `raw_4281ea422d1c4465964c6bf054a90115`,`rating_status=RATED`,生成 `rated_cdrs` 记录 `rated_b3e4b572d8f94fd6a0d6779a86a0294e`,`bill_sec=6`、客户费用 `0.012000`、供应商成本 `0.012000`。 +- 回滚方式:本次未切换 release、未改数据库 schema、未删除数据。若需撤回本次运行态启动,可在 B 执行 `sudo systemctl stop lisglosips@recording-worker lisglosips@config-publisher`;B current 仍指向 `/opt/lisglosips/releases/s44-phase2-gateway-match-20260628225000`。若后续需应用回滚,仍按 S30 Runbook 使用 `/opt/lisglosips/current/infra/server-b/s30/lisglosips-release-rollback.sh`。 +- 未解决问题:S40 脚本内置 UAS 端口 `50620` 仍提示已占用,但现有 UAS 正常接起;A/T 各有一个无关 `fwupd-refresh.service` failed unit,未在本次任务中处理;真实 80 万手机号段尚未导入;浏览器逐页权限按钮和真实录音播放仍需用户人工复测。 + ### 2026-06-28 22:34 - 需求修改/二期变更:提交并部署客户网关匹配规则 - 状态:已完成 diff --git a/SOFTSWITCH_PLATFORM_DESIGN_V2.md b/SOFTSWITCH_PLATFORM_DESIGN_V2.md index 0a91d6e..696001d 100644 --- a/SOFTSWITCH_PLATFORM_DESIGN_V2.md +++ b/SOFTSWITCH_PLATFORM_DESIGN_V2.md @@ -1193,12 +1193,13 @@ S30 完成本地 KVM A/B/T 闭环后,用户基于实际试用继续提出二 - 主叫、被叫 - 呼叫方 IP - 落地 IP - - 状态、开始时间、持续时长 + - SIP 信令状态、开始时间、持续时长 - 强制挂断操作 - 呼叫方 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 秒自动刷新,并保留手动刷新。 +- 当前通话列表展示的状态以 SIP 信令语义为准,由 Dialog state 归一化为 `INVITE`、`180 Ringing`、`183 Session Progress`、`200 OK`、`BYE / Terminating` 等可读状态;原始 Dialog state 仅作为后端兼容字段保留。 #### 25.5.3 列表计数、删除和启停确认 @@ -1214,6 +1215,7 @@ S30 完成本地 KVM A/B/T 闭环后,用户基于实际试用继续提出二 - 启用/禁用前必须确认。 - 供应商管理: - 列表显示落地网关数。 + - 不提供供应商启用/禁用功能,供应商是否可出局由落地网关和线路组状态控制。 - 删除供应商前必须确认。 - 若供应商关联落地网关数大于 0,则拒绝删除。 - 落地网关管理: 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 index 03e1945..eda0510 100644 --- a/apps/api/src/modules/active-calls/active-calls.service.spec.ts +++ b/apps/api/src/modules/active-calls/active-calls.service.spec.ts @@ -31,10 +31,31 @@ describe('active calls service', () => { toTag: 'to-b', caller: 'sip:1001@s21.lisglosips.test', callee: 'sip:2001@s21.lisglosips.test', - state: 'confirmed' + state: 'confirmed', + sipState: '200 OK', + sipStatusCode: 200, + sipStatusText: 'OK' }); }); + it('maps OpenSIPS dialog states to SIP signaling states', () => { + const calls = normalizeDialogs({ + Dialogs: [ + { ID: 'early-ring', callid: 'call-early', state: 2 }, + { ID: 'early-progress', callid: 'call-progress', state: 'early', to_tag: 'tag-b' }, + { ID: 'inviting', callid: 'call-invite', state: 1 }, + { ID: 'terminating', callid: 'call-bye', state: 5 } + ] + }); + + expect(calls.map((call) => [call.id, call.sipState, call.sipStatusCode, call.sipStatusText])).toEqual([ + ['early-ring', '180 Ringing', 180, 'Ringing'], + ['early-progress', '183 Session Progress', 183, 'Session Progress'], + ['inviting', 'INVITE', null, 'INVITE'], + ['terminating', 'BYE / Terminating', null, 'Terminating'] + ]); + }); + it('extracts caller and landing IPs from dialog contacts and SDP', () => { const calls = normalizeDialogs({ Dialogs: [ diff --git a/apps/api/src/modules/active-calls/active-calls.service.ts b/apps/api/src/modules/active-calls/active-calls.service.ts index 8501f98..3090551 100644 --- a/apps/api/src/modules/active-calls/active-calls.service.ts +++ b/apps/api/src/modules/active-calls/active-calls.service.ts @@ -9,6 +9,9 @@ export interface ActiveCallSummary { caller: string | null; callee: string | null; state: string | null; + sipState: string; + sipStatusCode: number | null; + sipStatusText: string; startedAt: string | null; durationSec: number | null; lifetimeSec: number | null; @@ -149,6 +152,9 @@ function toSummary(record: Record, now: Date): ActiveCallSummar const calleeSdp = stringField(record, ['callee_sdp', 'calleeSdp', 'to_sdp']) ?? (calleeRecord ? stringField(calleeRecord, ['callee_sdp', 'calleeSdp', 'to_sdp', 'sdp']) : null); + const dialogState = stringField(record, ['state', 'status']); + const sipState = sipStateFromDialog(dialogState, stringField(record, ['to_tag', 'totag', 'toTag'])); + return { id, callId, @@ -156,7 +162,10 @@ function toSummary(record: Record, now: Date): ActiveCallSummar 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']), + state: dialogState, + sipState: sipState.label, + sipStatusCode: sipState.code, + sipStatusText: sipState.text, startedAt, durationSec, lifetimeSec, @@ -178,6 +187,26 @@ function toSummary(record: Record, now: Date): ActiveCallSummar }; } +function sipStateFromDialog(state: string | null, toTag: string | null): { label: string; code: number | null; text: string } { + const normalized = (state || '').trim().toLowerCase(); + if (normalized === 'confirmed' || normalized === '3') { + return { label: '200 OK', code: 200, text: 'OK' }; + } + if (normalized === 'early' || normalized === '2') { + return { label: toTag ? '183 Session Progress' : '180 Ringing', code: toTag ? 183 : 180, text: toTag ? 'Session Progress' : 'Ringing' }; + } + if (normalized === 'unconfirmed' || normalized === '1') { + return { label: 'INVITE', code: null, text: 'INVITE' }; + } + if (normalized === 'deleted' || normalized === '4') { + return { label: 'BYE', code: null, text: 'BYE' }; + } + if (normalized === 'terminating' || normalized === '5') { + return { label: 'BYE / Terminating', code: null, text: 'Terminating' }; + } + return { label: state || 'UNKNOWN', code: null, text: state || 'UNKNOWN' }; +} + function stringField(record: Record, names: string[]): string | null { for (const name of names) { const value = findCaseInsensitive(record, name); 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 dc6705a..c767b49 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 @@ -143,7 +143,7 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository { transport: input.transport ?? 'udp', sipUsername: input.sipUsername ?? null, hasSipCredential: input.hasSipCredential ?? false, - cpsLimit: input.cpsLimit ?? 0, + cpsLimit: input.cpsLimit ?? 1, concurrencyLimit: input.concurrencyLimit ?? 0, billingCycleSec, cycleRate, @@ -325,7 +325,24 @@ describe('S16 vendor gateways API', () => { expect(JSON.stringify(audit.entries)).not.toContain('change-me-very-strong'); }); - it('validates child configuration and supports enable/disable', async () => { + it('validates CPS and child configuration and supports enable/disable', async () => { + for (const cpsLimit of [0, 10001, '20 CPS']) { + await request(app.getHttpServer()) + .post('/api/v2/vendor-gateways') + .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) + .send({ + vendorId: 'ven_seed', + name: `Invalid CPS ${cpsLimit}`, + authMode: 'IP', + host: '203.0.113.21', + cpsLimit + }) + .expect(400) + .expect((response) => { + expect(response.body.code).toBe('INTEGER_INVALID'); + }); + } + await request(app.getHttpServer()) .post('/api/v2/vendor-gateways') .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) 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 8a7fadc..0859d70 100644 --- a/apps/api/src/modules/vendor-gateways/vendor-gateways.service.ts +++ b/apps/api/src/modules/vendor-gateways/vendor-gateways.service.ts @@ -64,7 +64,7 @@ export class VendorGatewaysService { transport: body.transport === undefined ? 'udp' : this.transport(body.transport), sipUsername, sipHa1: sipPassword ? this.sipHa1(sipUsername, host, sipPassword) : undefined, - cpsLimit: body.cpsLimit === undefined ? 0 : this.integer(body.cpsLimit, 'cpsLimit', 0, 10000), + cpsLimit: body.cpsLimit === undefined ? 1 : this.integer(body.cpsLimit, 'cpsLimit', 1, 10000), concurrencyLimit: body.concurrencyLimit === undefined ? 0 : this.integer(body.concurrencyLimit, 'concurrencyLimit', 0, 100000), billingCycleSec: body.billingCycleSec === undefined ? 60 : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 60), cycleRate: body.cycleRate === undefined ? '0.000000' : this.money(body.cycleRate, 'cycleRate'), @@ -105,7 +105,7 @@ export class VendorGatewaysService { transport: body.transport === undefined ? undefined : this.transport(body.transport), sipUsername: this.requiresSip(authMode) ? sipUsername : null, sipHa1: password ? this.sipHa1(sipUsername, host, password) : this.requiresSip(authMode) ? undefined : null, - cpsLimit: body.cpsLimit === undefined ? undefined : this.integer(body.cpsLimit, 'cpsLimit', 0, 10000), + cpsLimit: body.cpsLimit === undefined ? undefined : this.integer(body.cpsLimit, 'cpsLimit', 1, 10000), concurrencyLimit: body.concurrencyLimit === undefined ? undefined : this.integer(body.concurrencyLimit, 'concurrencyLimit', 0, 100000), billingCycleSec: body.billingCycleSec === undefined ? undefined : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 60), cycleRate: body.cycleRate === undefined ? undefined : this.money(body.cycleRate, 'cycleRate'), diff --git a/apps/api/src/modules/vendors/vendors.controller.ts b/apps/api/src/modules/vendors/vendors.controller.ts index 5c8216f..6ee2a1f 100644 --- a/apps/api/src/modules/vendors/vendors.controller.ts +++ b/apps/api/src/modules/vendors/vendors.controller.ts @@ -35,20 +35,6 @@ export class VendorsController { return this.vendorsService.update(id, body as never, currentUser?.id); } - @Post(':id/enable') - @RequirePermissions('vendors.manage') - @AuditAction({ module: 'vendors', action: 'enable', objectType: 'vendor', objectIdParam: 'id' }) - enable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { - return this.vendorsService.enable(id, currentUser?.id); - } - - @Post(':id/disable') - @RequirePermissions('vendors.manage') - @AuditAction({ module: 'vendors', action: 'disable', objectType: 'vendor', objectIdParam: 'id' }) - disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) { - return this.vendorsService.disable(id, currentUser?.id); - } - @Delete(':id') @RequirePermissions('vendors.manage') @AuditAction({ module: 'vendors', action: 'delete', objectType: 'vendor', objectIdParam: 'id' }) diff --git a/apps/api/src/modules/vendors/vendors.e2e.spec.ts b/apps/api/src/modules/vendors/vendors.e2e.spec.ts index ac0ac06..dca14de 100644 --- a/apps/api/src/modules/vendors/vendors.e2e.spec.ts +++ b/apps/api/src/modules/vendors/vendors.e2e.spec.ts @@ -11,7 +11,6 @@ import { VENDORS_REPOSITORY, type CreateVendorInput, type UpdateVendorInput, - type VendorStatus, type VendorSummary, type VendorsRepository } from './vendors.repository.js'; @@ -62,7 +61,6 @@ class MemoryVendorsRepository implements VendorsRepository { contactName: input.contactName ?? null, phone: input.phone ?? null, email: input.email ?? null, - status: input.status ?? 'ENABLED', creditLimit: input.creditLimit, settlement: input.settlement ?? null, notes: input.notes ?? null @@ -79,7 +77,6 @@ class MemoryVendorsRepository implements VendorsRepository { contactName: input.contactName === undefined ? current.contactName : input.contactName, phone: input.phone === undefined ? current.phone : input.phone, email: input.email === undefined ? current.email : input.email, - status: input.status ?? current.status, creditLimit: input.creditLimit ?? current.creditLimit, availableBalance: (Number(current.balance) + Number(input.creditLimit ?? current.creditLimit)).toFixed(6), settlement: input.settlement === undefined ? current.settlement : input.settlement, @@ -90,12 +87,10 @@ class MemoryVendorsRepository implements VendorsRepository { return updated; } - async setStatus(vendorId: string, status: VendorStatus): Promise { - return this.update(vendorId, { status }); - } - async softDelete(vendorId: string): Promise { - return this.update(vendorId, { status: 'DISABLED' }); + const current = this.vendors.get(vendorId) ?? this.summary({ id: vendorId, name: 'Deleted Vendor' }); + this.vendors.set(vendorId, { ...current, updatedAt: new Date('2026-06-21T05:00:00.000Z') }); + return this.vendors.get(vendorId) as VendorSummary; } private summary(input: { @@ -104,7 +99,6 @@ class MemoryVendorsRepository implements VendorsRepository { contactName?: string | null; phone?: string | null; email?: string | null; - status?: VendorStatus; creditLimit?: string; settlement?: string | null; notes?: string | null; @@ -118,7 +112,6 @@ class MemoryVendorsRepository implements VendorsRepository { contactName: input.contactName ?? null, phone: input.phone ?? null, email: input.email ?? null, - status: input.status ?? 'ENABLED', balance, creditLimit, availableBalance: (Number(balance) + Number(creditLimit)).toFixed(6), @@ -215,7 +208,7 @@ describe('S15 vendors API', () => { .expect(403); }); - it('creates, updates, disables, enables, and soft deletes vendors with audit entries', async () => { + it('creates, updates, and soft deletes vendors with audit entries', async () => { await request(app.getHttpServer()) .post('/api/v2/vendors') .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) @@ -248,12 +241,12 @@ describe('S15 vendors API', () => { }); }); - await request(app.getHttpServer()).post('/api/v2/vendors/ven_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201); - await request(app.getHttpServer()).post('/api/v2/vendors/ven_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201); + await request(app.getHttpServer()).post('/api/v2/vendors/ven_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(404); + await request(app.getHttpServer()).post('/api/v2/vendors/ven_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(404); await request(app.getHttpServer()).delete('/api/v2/vendors/ven_created').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200); expect(audit.entries.some((entry) => entry.module === 'vendors' && entry.action === 'create' && entry.result === 'SUCCESS')).toBe(true); - expect(audit.entries.some((entry) => entry.module === 'vendors' && entry.action === 'disable' && entry.objectId === 'ven_created')).toBe(true); + expect(audit.entries.some((entry) => entry.module === 'vendors' && (entry.action === 'disable' || entry.action === 'enable'))).toBe(false); expect(audit.entries.some((entry) => entry.module === 'vendors' && entry.action === 'delete' && entry.objectId === 'ven_created')).toBe(true); }); }); diff --git a/apps/api/src/modules/vendors/vendors.repository.ts b/apps/api/src/modules/vendors/vendors.repository.ts index c5b9024..4d8e682 100644 --- a/apps/api/src/modules/vendors/vendors.repository.ts +++ b/apps/api/src/modules/vendors/vendors.repository.ts @@ -3,15 +3,12 @@ import crypto from 'node:crypto'; import { Prisma } from '@lisglosips/database'; import { PrismaService } from '../database/prisma.service.js'; -export type VendorStatus = 'ENABLED' | 'DISABLED'; - export interface VendorSummary { id: string; name: string; contactName: string | null; phone: string | null; email: string | null; - status: VendorStatus; balance: string; creditLimit: string; availableBalance: string; @@ -27,7 +24,6 @@ export interface CreateVendorInput { contactName?: string; phone?: string; email?: string; - status?: VendorStatus; creditLimit: string; settlement?: string; notes?: string; @@ -39,7 +35,6 @@ export interface UpdateVendorInput { contactName?: string | null; phone?: string | null; email?: string | null; - status?: VendorStatus; creditLimit?: string; settlement?: string | null; notes?: string | null; @@ -51,7 +46,6 @@ export interface VendorsRepository { get(vendorId: string): Promise; create(input: CreateVendorInput): Promise; update(vendorId: string, input: UpdateVendorInput): Promise; - setStatus(vendorId: string, status: VendorStatus, actorId?: string): Promise; softDelete(vendorId: string, actorId?: string): Promise; } @@ -87,7 +81,6 @@ export class PrismaVendorsRepository implements VendorsRepository { contactName: input.contactName, phone: input.phone, email: input.email, - status: input.status ?? 'ENABLED', creditLimit: new Prisma.Decimal(input.creditLimit), settlement: input.settlement, notes: input.notes, @@ -110,7 +103,6 @@ export class PrismaVendorsRepository implements VendorsRepository { contactName: input.contactName, phone: input.phone, email: input.email, - status: input.status, creditLimit: input.creditLimit === undefined ? undefined : new Prisma.Decimal(input.creditLimit), settlement: input.settlement, notes: input.notes, @@ -123,10 +115,6 @@ export class PrismaVendorsRepository implements VendorsRepository { return this.toSummary(vendor); } - async setStatus(vendorIdValue: string, status: VendorStatus, actorId?: string): Promise { - return this.update(vendorIdValue, { status, actorId }); - } - async softDelete(vendorIdValue: string, actorId?: string): Promise { const existing = await this.findActiveOrThrow(vendorIdValue); const linkedGateways = await this.prisma.vendorGateway.count({ @@ -188,7 +176,6 @@ export class PrismaVendorsRepository implements VendorsRepository { contactName: string | null; phone: string | null; email: string | null; - status: VendorStatus; balance: Prisma.Decimal; creditLimit: Prisma.Decimal; settlement: string | null; @@ -203,7 +190,6 @@ export class PrismaVendorsRepository implements VendorsRepository { contactName: vendor.contactName, phone: vendor.phone, email: vendor.email, - status: vendor.status, balance: vendor.balance.toFixed(6), creditLimit: vendor.creditLimit.toFixed(6), availableBalance: vendor.balance.plus(vendor.creditLimit).toFixed(6), diff --git a/apps/api/src/modules/vendors/vendors.service.ts b/apps/api/src/modules/vendors/vendors.service.ts index 93dd167..402bb6e 100644 --- a/apps/api/src/modules/vendors/vendors.service.ts +++ b/apps/api/src/modules/vendors/vendors.service.ts @@ -3,7 +3,6 @@ import { VENDORS_REPOSITORY, type CreateVendorInput, type UpdateVendorInput, - type VendorStatus, type VendorSummary, type VendorsRepository } from './vendors.repository.js'; @@ -13,7 +12,6 @@ interface CreateVendorDto { contactName?: unknown; phone?: unknown; email?: unknown; - status?: unknown; creditLimit?: unknown; settlement?: unknown; notes?: unknown; @@ -24,7 +22,6 @@ interface UpdateVendorDto { contactName?: unknown; phone?: unknown; email?: unknown; - status?: unknown; creditLimit?: unknown; settlement?: unknown; notes?: unknown; @@ -48,7 +45,6 @@ export class VendorsService { contactName: this.optionalString(body.contactName, 'contactName', 80), phone: this.optionalString(body.phone, 'phone', 32), email: this.optionalString(body.email, 'email', 160), - status: body.status === undefined ? 'ENABLED' : this.status(body.status), creditLimit: body.creditLimit === undefined ? '0.000000' : this.money(body.creditLimit, 'creditLimit'), settlement: this.optionalString(body.settlement, 'settlement', 80), notes: this.optionalString(body.notes, 'notes', 500), @@ -64,7 +60,6 @@ export class VendorsService { contactName: body.contactName === undefined ? undefined : this.nullableString(body.contactName, 'contactName', 80), phone: body.phone === undefined ? undefined : this.nullableString(body.phone, 'phone', 32), email: body.email === undefined ? undefined : this.nullableString(body.email, 'email', 160), - status: body.status === undefined ? undefined : this.status(body.status), creditLimit: body.creditLimit === undefined ? undefined : this.money(body.creditLimit, 'creditLimit'), settlement: body.settlement === undefined ? undefined : this.nullableString(body.settlement, 'settlement', 80), notes: body.notes === undefined ? undefined : this.nullableString(body.notes, 'notes', 500), @@ -74,14 +69,6 @@ export class VendorsService { return this.vendors.update(vendorId, input); } - enable(vendorId: string, actorId?: string): Promise { - return this.vendors.setStatus(vendorId, 'ENABLED', actorId); - } - - disable(vendorId: string, actorId?: string): Promise { - return this.vendors.setStatus(vendorId, 'DISABLED', actorId); - } - remove(vendorId: string, actorId?: string): Promise { return this.vendors.softDelete(vendorId, actorId); } @@ -115,14 +102,6 @@ export class VendorsService { return this.limitedString(value, field, maxLength); } - private status(value: unknown): VendorStatus { - if (value !== 'ENABLED' && value !== 'DISABLED') { - throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' }); - } - - return value; - } - private money(value: unknown, field: string): string { const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : ''; if (!/^\d{1,14}(?:\.\d{1,6})?$/.test(raw)) { diff --git a/apps/web/src/App.jsx b/apps/web/src/App.jsx index 6788225..89a4cd3 100644 --- a/apps/web/src/App.jsx +++ b/apps/web/src/App.jsx @@ -398,6 +398,7 @@ export default function App() { can: canPermission, onCreateCustomer: (body) => reloadAfterMutation(() => api.createCustomer(body)), onUpdateCustomer: (id, body) => reloadAfterMutation(() => api.updateCustomer(id, body)), + onToggleCustomerStatus: (row) => reloadAfterMutation(() => (row.status === '启用' ? api.disableCustomer(row.id) : api.enableCustomer(row.id))), onDeleteCustomer: (id) => reloadAfterMutation(() => api.deleteCustomer(id)), onRechargeCustomer: (id, body) => rechargeAndApply(() => api.rechargeCustomer(id, body)), } diff --git a/apps/web/src/api.js b/apps/web/src/api.js index a1583ec..084c6a0 100644 --- a/apps/web/src/api.js +++ b/apps/web/src/api.js @@ -135,6 +135,8 @@ export const api = { customers: () => request('/customers'), createCustomer: (body) => request('/customers', { method: 'POST', body: jsonBody(body) }), updateCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }), + enableCustomer: (id) => request(`/customers/${encodeURIComponent(id)}/enable`, { method: 'POST' }), + disableCustomer: (id) => request(`/customers/${encodeURIComponent(id)}/disable`, { method: 'POST' }), deleteCustomer: (id) => request(`/customers/${encodeURIComponent(id)}`, { method: 'DELETE' }), rechargeCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}/recharges`, { diff --git a/apps/web/src/fixtures/devFixtures.js b/apps/web/src/fixtures/devFixtures.js index 8ac27ac..2790f22 100644 --- a/apps/web/src/fixtures/devFixtures.js +++ b/apps/web/src/fixtures/devFixtures.js @@ -29,15 +29,15 @@ export const sipAccounts = [ ]; export const vendors = [ - { id: 'V2001', name: '供应商 A', balance: '¥42,860.00', credit: '¥300,000', gateways: 8, status: '启用', cycle: '月结', ratePlan: 'CN-Mobile-2026', contact: '陈经理', createdAt: '2026-04-02' }, - { id: 'V2002', name: '供应商 B', balance: '¥18,200.00', credit: '¥120,000', gateways: 5, status: '启用', cycle: '周结', ratePlan: 'CN-LowCost-2026', contact: '王经理', createdAt: '2026-04-16' }, + { id: 'V2001', name: '供应商 A', balance: '¥42,860.00', credit: '¥300,000', gateways: 8, cycle: '月结', ratePlan: 'CN-Mobile-2026', contact: '陈经理', createdAt: '2026-04-02' }, + { id: 'V2002', name: '供应商 B', balance: '¥18,200.00', credit: '¥120,000', gateways: 5, cycle: '周结', ratePlan: 'CN-LowCost-2026', contact: '王经理', createdAt: '2026-04-16' }, { id: 'V2003', name: '国际供应商 C', balance: '¥8,450.00', credit: '¥80,000', gateways: 3, status: '观察', cycle: '月结', ratePlan: 'Global-Std', contact: 'Lina', createdAt: '2026-05-03' }, ]; export const gateways = [ - { id: 'GW-A-01', vendor: '供应商 A', name: '华东移动主用', authMode: 'IP', ipAddress: '203.0.113.18', sipAccount: '', sipPassword: '', concurrencyLimit: 800, billingCycle: 60, cycleRate: 0.031, requestRate: '120 CPS', blockedProvinces: '新疆、西藏', callTimeLimit: '08:00-22:00', codecs: 'PCMA, PCMU', calleePrefixTransform: '13/15/18 保持原样', callerPrefixTransform: '0216001* -> 0216001*', status: '启用' }, - { id: 'GW-A-02', vendor: '供应商 A', name: '华东联通备用', authMode: 'IP', ipAddress: '203.0.113.19', sipAccount: '', sipPassword: '', concurrencyLimit: 500, billingCycle: 30, cycleRate: 0.017, requestRate: '80 CPS', blockedProvinces: '无', callTimeLimit: '00:00-23:59', codecs: 'PCMA', calleePrefixTransform: '021 保持原样', callerPrefixTransform: '0217002* -> 0217002*', status: '启用' }, - { id: 'GW-B-01', vendor: '供应商 B', name: '成本最低路由', authMode: 'SIP注册', ipAddress: '', sipAccount: 'vendor-b-main', sipPassword: '******', concurrencyLimit: 360, billingCycle: 6, cycleRate: 0.0028, requestRate: '60 CPS', blockedProvinces: '北京', callTimeLimit: '09:00-21:00', codecs: 'PCMA, G729', calleePrefixTransform: '0571 -> 0571', callerPrefixTransform: '0571888* -> 0571888*', status: '禁用' }, + { id: 'GW-A-01', vendor: '供应商 A', name: '华东移动主用', authMode: 'IP', ipAddress: '203.0.113.18', sipAccount: '', sipPassword: '', concurrencyLimit: 800, billingCycle: 60, cycleRate: 0.031, requestRate: 120, blockedProvinces: '新疆、西藏', callTimeLimit: '08:00-22:00', codecs: 'PCMA, PCMU', calleePrefixTransform: '13/15/18 保持原样', callerPrefixTransform: '0216001* -> 0216001*', status: '启用' }, + { id: 'GW-A-02', vendor: '供应商 A', name: '华东联通备用', authMode: 'IP', ipAddress: '203.0.113.19', sipAccount: '', sipPassword: '', concurrencyLimit: 500, billingCycle: 30, cycleRate: 0.017, requestRate: 80, blockedProvinces: '无', callTimeLimit: '00:00-23:59', codecs: 'PCMA', calleePrefixTransform: '021 保持原样', callerPrefixTransform: '0217002* -> 0217002*', status: '启用' }, + { id: 'GW-B-01', vendor: '供应商 B', name: '成本最低路由', authMode: 'SIP注册', ipAddress: '', sipAccount: 'vendor-b-main', sipPassword: '******', concurrencyLimit: 360, billingCycle: 6, cycleRate: 0.0028, requestRate: 60, blockedProvinces: '北京', callTimeLimit: '09:00-21:00', codecs: 'PCMA, G729', calleePrefixTransform: '0571 -> 0571', callerPrefixTransform: '0571888* -> 0571888*', status: '禁用' }, ]; export const vendorLineGroups = [ diff --git a/apps/web/src/pages/ActiveCallsPage.jsx b/apps/web/src/pages/ActiveCallsPage.jsx index 7baed03..86418ba 100644 --- a/apps/web/src/pages/ActiveCallsPage.jsx +++ b/apps/web/src/pages/ActiveCallsPage.jsx @@ -16,7 +16,7 @@ export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsEr calleeText: call.callee || '-', callerIpText: call.callerIp || '-', landingIpText: call.landingIp || '-', - stateText: call.state || '未知', + stateText: call.sipState || call.sipStatusText || call.state || '未知', startedText: formatDateTime(call.startedAt), durationText: call.durationSec === null || call.durationSec === undefined ? '-' : formatDurationText(call.durationSec), })); @@ -82,7 +82,7 @@ export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsEr { key: 'calleeText', label: '被叫' }, { key: 'callerIpText', label: '呼叫方 IP' }, { key: 'landingIpText', label: '落地 IP' }, - { key: 'stateText', label: '状态', status: true }, + { key: 'stateText', label: 'SIP状态', status: true }, { key: 'durationText', label: '持续时长' }, { key: 'startedText', label: '开始时间' }, { diff --git a/apps/web/src/pages/CustomersPage.jsx b/apps/web/src/pages/CustomersPage.jsx index deb5547..9487e8e 100644 --- a/apps/web/src/pages/CustomersPage.jsx +++ b/apps/web/src/pages/CustomersPage.jsx @@ -4,11 +4,12 @@ import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, Simpl import { gateways } from '../fixtures/devFixtures.js'; import { explainApiError } from '../api.js'; -export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord, apiLoading, apiError, refreshApi, can = () => true, onCreateCustomer, onUpdateCustomer, onDeleteCustomer, onRechargeCustomer }) { +export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord, apiLoading, apiError, refreshApi, can = () => true, onCreateCustomer, onUpdateCustomer, onToggleCustomerStatus, onDeleteCustomer, onRechargeCustomer }) { const [showCreateCustomer, setShowCreateCustomer] = useState(false); const [editingCustomer, setEditingCustomer] = useState(null); const [rechargeCustomer, setRechargeCustomer] = useState(null); const [deleteCustomerTarget, setDeleteCustomerTarget] = useState(null); + const [statusCustomerTarget, setStatusCustomerTarget] = useState(null); const [newCustomer, setNewCustomer] = useState({ name: '', contact: '', phone: '', email: '' }); const [editCustomerForm, setEditCustomerForm] = useState({ name: '', contact: '', phone: '', email: '' }); const [rechargeForm, setRechargeForm] = useState({ amount: '', remark: '' }); @@ -125,6 +126,21 @@ export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord setActionError(explainApiError(error)); } }; + const toggleCustomerStatus = async (customer) => { + setActionError(''); + try { + if (onToggleCustomerStatus) { + await onToggleCustomerStatus(customer); + } else { + setCustomerRows((rows) => rows.map((item) => ( + item.id === customer.id ? { ...item, status: item.status === '启用' ? '停用' : '启用' } : item + ))); + } + setStatusCustomerTarget(null); + } catch (error) { + setActionError(explainApiError(error)); + } + }; return ( <> @@ -136,7 +152,7 @@ export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord - +
@@ -157,6 +173,11 @@ export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord
{canManageCustomers ? : null} {canManageRecharges ? : null} + {canManageCustomers ? ( + + ) : null} {canManageCustomers ? : null} {!canManageCustomers && !canManageRecharges ? '-' : null}
@@ -238,6 +259,17 @@ export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord

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

) : null} + {statusCustomerTarget ? ( + setStatusCustomerTarget(null)} + onConfirm={() => void toggleCustomerStatus(statusCustomerTarget)} + > +

确认{statusCustomerTarget.status === '启用' ? '禁用' : '启用'}客户「{statusCustomerTarget.name}」吗?

+
+ ) : null} ); } diff --git a/apps/web/src/pages/VendorGatewaysPage.jsx b/apps/web/src/pages/VendorGatewaysPage.jsx index 4458180..ddbb981 100644 --- a/apps/web/src/pages/VendorGatewaysPage.jsx +++ b/apps/web/src/pages/VendorGatewaysPage.jsx @@ -26,9 +26,7 @@ export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows .filter((item) => item.start || item.end); return ranges.length ? ranges : [{ start: '', end: '' }]; }; - const splitRate = (value) => { - return String(value || '').trim(); - }; + const splitRate = (value) => String(value || '').replace(/[^\d]/g, ''); const formatMinuteRate = (cycle, rate) => { const billingCycle = Number(cycle); const cycleRate = Number(rate); @@ -84,10 +82,12 @@ export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows }; const submitGateway = async (event) => { event.preventDefault(); - if (!editingGateway || !gatewayForm.name.trim() || !gatewayForm.concurrencyLimit || !gatewayForm.billingCycle || !gatewayForm.cycleRate) return; + if (!editingGateway || !gatewayForm.name.trim() || !gatewayForm.concurrencyLimit || !gatewayForm.billingCycle || !gatewayForm.cycleRate || !gatewayForm.requestRate) return; const billingCycle = Number(gatewayForm.billingCycle); const cycleRate = Number(gatewayForm.cycleRate); + const cpsLimit = Number(gatewayForm.requestRate); if (!Number.isFinite(billingCycle) || billingCycle <= 0 || billingCycle > 60 || !Number.isFinite(cycleRate) || cycleRate < 0) return; + if (!Number.isInteger(cpsLimit) || cpsLimit < 1 || cpsLimit > 10000) return; const ipAddress = gatewayForm.ipAddress.trim(); const sipAccount = gatewayForm.sipAccount.trim(); const sipPassword = gatewayForm.sipPassword.trim(); @@ -105,7 +105,7 @@ export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows port: editingGateway.port || 5060, transport: editingGateway.transport || 'udp', sipUsername: sipAccount || undefined, - cpsLimit: Number(String(gatewayForm.requestRate).match(/\d+/)?.[0] || 0), + cpsLimit, concurrencyLimit: Number(gatewayForm.concurrencyLimit), billingCycleSec: billingCycle, cycleRate: String(cycleRate), @@ -139,7 +139,7 @@ export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows concurrencyLimit: body.concurrencyLimit, billingCycle, cycleRate, - requestRate: gatewayForm.requestRate.trim() || '-', + requestRate: cpsLimit, blockedProvinces: gatewayForm.blockedProvinces.length ? gatewayForm.blockedProvinces.join('、') : '无', callTimeLimit: gatewayForm.forbiddenPeriods .filter((period) => period.start || period.end) @@ -240,6 +240,7 @@ export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows { key: 'name', label: '落地网关名称', width: '168px', className: 'table-cell-compact' }, { key: 'authMode', label: '认证方式' }, { key: 'authTarget', label: 'IP/账号', render: (row) => (row.authMode === 'IP' ? row.ipAddress : row.sipAccount) }, + { key: 'requestRate', label: 'CPS' }, { key: 'concurrencyLimit', label: '并发上限' }, { key: 'minuteRate', label: '价格', render: (row) => formatMinuteRate(row.billingCycle, row.cycleRate) }, { key: 'landingCalleePrefix', label: '落地被叫前缀', render: (row) => row.landingCalleePrefix || '-' }, @@ -311,10 +312,9 @@ export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows -
- 请求速率 - setGatewayForm({ ...gatewayForm, requestRate: event.target.value })} placeholder="例如 120 CPS" /> -
+ CPS *}> + setGatewayForm({ ...gatewayForm, requestRate: event.target.value })} placeholder="1-10000" required /> +
屏蔽省份
diff --git a/apps/web/src/pages/VendorsPage.jsx b/apps/web/src/pages/VendorsPage.jsx index 4edac69..38b1838 100644 --- a/apps/web/src/pages/VendorsPage.jsx +++ b/apps/web/src/pages/VendorsPage.jsx @@ -42,7 +42,7 @@ export function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiL await onCreateVendor({ name: newVendor.name.trim(), creditLimit: '0.000000' }); } else { const nextIndex = vendorRows.length + 1; - setVendorRows((rows) => [...rows, { id: `V${String(2000 + nextIndex)}`, name: newVendor.name.trim(), balance: '¥0.00', credit: '¥0', gateways: 0, status: '启用', cycle: '待配置', ratePlan: '待配置', contact: '-', createdAt: '2026-06-19' }]); + setVendorRows((rows) => [...rows, { id: `V${String(2000 + nextIndex)}`, name: newVendor.name.trim(), balance: '¥0.00', credit: '¥0', gateways: 0, cycle: '待配置', ratePlan: '待配置', contact: '-', createdAt: '2026-06-19' }]); } setNewVendor({ name: '' }); setShowCreateVendor(false); @@ -121,7 +121,6 @@ export function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiL { key: 'balance', label: '余额' }, { key: 'credit', label: '授信额度' }, { key: 'gateways', label: '落地网关数' }, - { key: 'status', label: '状态', status: true }, { key: 'actions', label: '操作', diff --git a/apps/web/src/utils/formatters.js b/apps/web/src/utils/formatters.js index cf15147..1b8f479 100644 --- a/apps/web/src/utils/formatters.js +++ b/apps/web/src/utils/formatters.js @@ -80,7 +80,6 @@ function normalizeVendor(item) { balance: formatCurrency(item.balance), credit: formatCurrency(item.creditLimit), gateways: item.gatewayCount ?? 0, - status: zhStatus(item.status), cycle: item.settlement || '-', ratePlan: '-', contact: item.contactName || '-', @@ -133,7 +132,7 @@ function normalizeVendorGateway(item) { concurrencyLimit: item.concurrencyLimit ?? 0, billingCycle: item.billingCycleSec ?? 60, cycleRate: Number(item.cycleRate || 0), - requestRate: `${item.cpsLimit ?? 0} CPS`, + requestRate: item.cpsLimit ?? 1, blockedProvinces: '-', callTimeLimit: '-', codecs: (item.codecs || []).map((codec) => codec.codec).join(', ') || '-', diff --git a/docs/TEST_PLAN_AND_CASES.md b/docs/TEST_PLAN_AND_CASES.md index f4b648d..8164653 100644 --- a/docs/TEST_PLAN_AND_CASES.md +++ b/docs/TEST_PLAN_AND_CASES.md @@ -289,7 +289,7 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts | 客户管理 | 充值/扣款 | `recharges.manage` | 按钮不可见 | | 客户网关 | 新增/编辑/启停/删除 | `customer_gateways.manage` | 按钮不可见 | | 业务前缀 | 新增/编辑/启停/删除 | `customer_gateways.manage` | 按钮不可见 | -| 供应商管理 | 新增/编辑/启停/删除 | `vendors.manage` | 按钮不可见 | +| 供应商管理 | 新增/编辑/删除 | `vendors.manage` | 按钮不可见 | | 落地网关 | 新增/编辑/启停/删除 | `vendor_gateways.manage` | 按钮不可见 | | 落地线路组 | 新增/成员维护/重排/删除 | `line_groups.manage` | 按钮不可见 | | 号码库 | 导入 | `number_library.manage` | 按钮不可见 | @@ -783,7 +783,7 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts - 供应商管理账号 `vendor_ops_001`:包含 `vendors.view`、`vendors.manage`、`vendor_gateways.view`、`vendor_gateways.manage`、`line_groups.view`、`line_groups.manage`、`recharges.view`、`recharges.manage`、`audit.view`。 - 只读账号 `vendor_viewer_001`:包含 `vendors.view`、`vendor_gateways.view`、`line_groups.view`。 -- 供应商 `ven_seed_001`:状态 `ENABLED`,余额 `20.000000`,授信 `200.000000`。 +- 供应商 `ven_seed_001`:余额 `20.000000`,授信 `200.000000`。供应商不提供启用/禁用状态入口。 - 落地网关 `vgw_primary_001`:状态 `ENABLED`,host 指向 T/UAS 或可控落地模拟端。 - 落地网关 `vgw_backup_001`:状态 `ENABLED`,用于线路组故障转移。 - 线路组 `llg_vendor_seed_001`:状态 `ENABLED`,至少包含 `vgw_primary_001` 和 `vgw_backup_001`。 @@ -798,7 +798,7 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts | 前置条件 | `vendor_ops_001` 已登录;供应商名 `Auto Vendor VEN001` 不存在。 | | 测试数据 | `name=Auto Vendor VEN001`,`contactName=NOC`,`phone=4001000001`,`email=noc-ven001@example.test`,`creditLimit=188.5`,`settlement=monthly`,`notes=created by VEN-001`。 | | 步骤 | 1. 请求 `POST /api/v2/vendors`。2. 使用返回 ID 请求 `GET /api/v2/vendors/:id`。3. 请求 `GET /api/v2/vendors`。4. 查询审计。 | -| 预期结果 | 创建返回 201;状态默认 `ENABLED`;余额为系统默认值;`creditLimit=188.500000`;`availableBalance=balance+creditLimit`;列表可见,`gatewayCount=0`。 | +| 预期结果 | 创建返回 201;余额为系统默认值;`creditLimit=188.500000`;`availableBalance=balance+creditLimit`;列表可见,`gatewayCount=0`;响应不暴露供应商启停状态。 | | 数据检查 | 写入 `created_by`、`updated_at`、`deleted_at=null`;审计存在 `module=vendors`、`action=create`、`result=SUCCESS`。 | | 安全检查 | `vendor_viewer_001` 创建返回 403;响应不包含内部异常或其他供应商敏感字段。 | @@ -808,22 +808,22 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts | --- | --- | --- | --- | | VEN-001-N1 | 缺少名称 | `name` 为空、空格或非字符串 | 400 `VALIDATION_ERROR` | | VEN-001-N2 | 字段超长 | `name` 超 120、`settlement` 超 80、`notes` 超 500 | 400 `VALIDATION_ERROR` | -| VEN-001-N3 | 非法状态 | `status=DELETED` | 400 `STATUS_INVALID` | +| VEN-001-N3 | 状态字段被忽略或拒绝 | 请求携带 `status=DISABLED` 或 `status=DELETED` | 不允许通过供应商接口维护启停;响应不返回供应商状态,且不会生成启停审计 | | VEN-001-N4 | 授信金额非法 | `creditLimit=-1` 或 `1.1234567` | 400 `MONEY_INVALID` | | VEN-001-N5 | 重复名称 | 创建已存在供应商名称 | 409 或明确业务错误,不重复写入 | -#### VEN-002 供应商更新、启停与删除保护 +#### VEN-002 供应商更新与删除保护 | 字段 | 内容 | | --- | --- | | 优先级 | P1 | -| 目的 | 验证供应商资料更新、启停、软删除,以及有关联落地网关时的删除保护。 | +| 目的 | 验证供应商资料更新、软删除,以及有关联落地网关时的删除保护。 | | 前置条件 | 已存在供应商 `ven_seed_001`;另有无关联供应商 `ven_delete_free_001`。 | | 测试数据 | 更新 `notes=primary carrier`,`creditLimit=200`。 | -| 步骤 | 1. PATCH 更新供应商资料。2. disable 后查询状态。3. enable 后查询状态。4. 删除有关联落地网关的供应商。5. 删除无关联供应商。 | -| 预期结果 | 更新成功且金额归一化;启停状态正确;有关联供应商删除被拒绝或按产品规则受控软删除;无关联供应商可软删除。 | -| 数据检查 | `updated_by`、`version` 或更新时间更新;启停/删除均写审计;删除不物理清除历史充值和网关引用。 | -| 安全检查 | 无 `vendors.manage` 的更新、启停、删除返回 403。 | +| 步骤 | 1. PATCH 更新供应商资料。2. 请求旧的 `/vendors/:id/disable` 和 `/vendors/:id/enable`。3. 删除有关联落地网关的供应商。4. 删除无关联供应商。 | +| 预期结果 | 更新成功且金额归一化;供应商启停接口不可用;有关联供应商删除被拒绝或按产品规则受控软删除;无关联供应商可软删除。 | +| 数据检查 | `updated_by`、`version` 或更新时间更新;删除写审计;不会产生供应商 enable/disable 审计;删除不物理清除历史充值和网关引用。 | +| 安全检查 | 无 `vendors.manage` 的更新、删除返回 403;旧启停接口不应暴露可操作功能。 | #### VEN-003 供应商充值 @@ -1050,7 +1050,7 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts | 落地网关 | `transport` | `udp`、`tcp`、`tls` | `ws` | `TRANSPORT_INVALID` | | 落地网关 | `sipUsername` | SIP/MIXED 必填,安全字符 | `carrier user` | `SIP_USERNAME_INVALID` | | 落地网关 | `sipPassword` | SIP/MIXED 创建或身份变更必填,至少 12 字符 | `short` | `SIP_PASSWORD_WEAK`、`SIP_PASSWORD_REQUIRED` | -| 落地网关 | `cpsLimit` | 0-10000 整数,0 表示不限 | `-1`、`10001` | `INTEGER_INVALID` | +| 落地网关 | `cpsLimit` | 1-10000 整数,只允许数字 | `0`、`10001`、`20 CPS` | `INTEGER_INVALID` | | 落地网关 | `concurrencyLimit` | 0-100000 整数,0 表示不限 | `-1`、`100001` | `INTEGER_INVALID` | | 落地网关 | `billingCycleSec` | 1-60 整数 | `0`、`61` | `INTEGER_INVALID` | | 落地网关 | `cycleRate` | 非负,最多 14 位整数、6 位小数 | `-1`、`1.1234567` | `MONEY_INVALID` | @@ -1506,11 +1506,11 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts | 字段 | 内容 | | --- | --- | | 优先级 | P0 | -| 目的 | 验证当前通话从 OpenSIPS Dialog 归一化后可展示主叫、被叫、客户 IP、落地 IP、时长和状态。 | +| 目的 | 验证当前通话从 OpenSIPS Dialog 归一化后可展示主叫、被叫、客户 IP、落地 IP、时长和 SIP 信令状态。 | | 前置条件 | A 侧存在进行中 Dialog 或使用 MI mock。 | | 测试数据 | 一通进行中呼叫,含 Contact、SDP、dialog ID。 | | 步骤 | 1. 发起长持话呼叫。2. 请求当前通话接口或打开页面。3. 对比 OpenSIPS MI 输出。 | -| 预期结果 | 列表显示 callId/dialogId、caller、callee、sourceIp、landingIp、durationSec、startedAt;自动刷新不闪退。 | +| 预期结果 | 列表显示 callId/dialogId、caller、callee、sourceIp、landingIp、durationSec、startedAt、sipState;SIP 状态按 Dialog 归一化为 `INVITE`、`180 Ringing`、`183 Session Progress`、`200 OK`、`BYE / Terminating` 等可读状态;自动刷新不闪退。 | | 数据检查 | 解析 IPv4/IPv6、Contact 和 SDP 中 IP 正确;无 Dialog 时返回空列表。 | | 安全检查 | 无 `active_calls.view` 不可查看。 | diff --git a/scripts/build-release-artifact.mjs b/scripts/build-release-artifact.mjs index cc51efd..1404586 100644 --- a/scripts/build-release-artifact.mjs +++ b/scripts/build-release-artifact.mjs @@ -56,7 +56,7 @@ const copyEntries = [ ['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/auth/vendor/argon2id', 'packages/auth/vendor/argon2id', 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],