diff --git a/api/src/billing/billing.service.spec.ts b/api/src/billing/billing.service.spec.ts index 4c8213a..17702f5 100644 --- a/api/src/billing/billing.service.spec.ts +++ b/api/src/billing/billing.service.spec.ts @@ -7,6 +7,9 @@ function createPrismaMock() { tenant: { findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '示例企业', code: 'TENANT-1' }), }, + user: { + findMany: jest.fn().mockResolvedValue([]), + }, tenantAccount: { findMany: jest.fn(), findUnique: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })), @@ -181,9 +184,10 @@ describe('BillingService', () => { it('returns the historical balance after each manual recharge', async () => { const prisma = createPrismaMock(); prisma.rechargeOrder.findMany.mockResolvedValue([ - { id: 'order-1', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: 2000 }, + { id: 'order-1', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: 2000, operatorId: 'admin-1' }, { id: 'order-2', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: -300 }, ]); + prisma.user.findMany.mockResolvedValue([{ id: 'admin-1', displayName: '运营人员张三', username: 'admin' }]); prisma.accountTransaction.findMany.mockResolvedValue([ { relatedId: 'order-1', balanceAfter: 3000 }, { relatedId: 'order-2', balanceAfter: 2700 }, @@ -191,8 +195,8 @@ describe('BillingService', () => { const service = new BillingService(prisma as never); await expect(service.listManualRechargeRecords()).resolves.toEqual([ - expect.objectContaining({ id: 'order-1', balanceAfterCents: 3000 }), - expect.objectContaining({ id: 'order-2', balanceAfterCents: 2700 }), + expect.objectContaining({ id: 'order-1', balanceAfterCents: 3000, operatorName: '运营人员张三' }), + expect.objectContaining({ id: 'order-2', balanceAfterCents: 2700, operatorName: null }), ]); expect(prisma.accountTransaction.findMany).toHaveBeenCalledWith({ where: { @@ -201,6 +205,10 @@ describe('BillingService', () => { }, select: { relatedId: true, balanceAfter: true }, }); + expect(prisma.user.findMany).toHaveBeenCalledWith({ + where: { id: { in: ['admin-1'] } }, + select: { id: true, displayName: true, username: true }, + }); }); it('allows negative manual recharge amounts for balance correction', async () => { diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index 1857b08..df3cf62 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -163,18 +163,27 @@ export class BillingService { return orders; } - const transactions = await this.prisma.accountTransaction.findMany({ - where: { - relatedType: 'recharge_order', - relatedId: { in: orderIds }, - }, - select: { relatedId: true, balanceAfter: true }, - }); + const operatorIds = [...new Set(orders.map((order) => order.operatorId).filter((id): id is string => Boolean(id)))]; + const [transactions, operators] = await Promise.all([ + this.prisma.accountTransaction.findMany({ + where: { + relatedType: 'recharge_order', + relatedId: { in: orderIds }, + }, + select: { relatedId: true, balanceAfter: true }, + }), + operatorIds.length ? this.prisma.user.findMany({ + where: { id: { in: operatorIds } }, + select: { id: true, displayName: true, username: true }, + }) : [], + ]); const balanceAfterByOrderId = new Map(transactions.map((transaction) => [transaction.relatedId, moneyToNumber(transaction.balanceAfter)])); + const operatorNameById = new Map(operators.map((operator) => [operator.id, operator.displayName || operator.username])); return orders.map((order) => ({ ...order, balanceAfterCents: balanceAfterByOrderId.get(order.id) ?? null, + operatorName: order.operatorId ? operatorNameById.get(order.operatorId) ?? null : null, })); } @@ -195,13 +204,25 @@ export class BillingService { this.prisma.rechargeOrder.count({ where }), ]); const orderIds = orders.map((order) => order.id); - const transactions = orderIds.length ? await this.prisma.accountTransaction.findMany({ - where: { relatedType: 'recharge_order', relatedId: { in: orderIds } }, - select: { relatedId: true, balanceAfter: true }, - }) : []; + const operatorIds = [...new Set(orders.map((order) => order.operatorId).filter((id): id is string => Boolean(id)))]; + const [transactions, operators] = await Promise.all([ + orderIds.length ? this.prisma.accountTransaction.findMany({ + where: { relatedType: 'recharge_order', relatedId: { in: orderIds } }, + select: { relatedId: true, balanceAfter: true }, + }) : [], + operatorIds.length ? this.prisma.user.findMany({ + where: { id: { in: operatorIds } }, + select: { id: true, displayName: true, username: true }, + }) : [], + ]); const balances = new Map(transactions.map((item) => [item.relatedId, moneyToNumber(item.balanceAfter)])); + const operatorNames = new Map(operators.map((operator) => [operator.id, operator.displayName || operator.username])); return { - items: orders.map((order) => ({ ...order, balanceAfterCents: balances.get(order.id) ?? null })), + items: orders.map((order) => ({ + ...order, + balanceAfterCents: balances.get(order.id) ?? null, + operatorName: order.operatorId ? operatorNames.get(order.operatorId) ?? null : null, + })), total, page, pageSize, diff --git a/api/src/http-body-limits.spec.ts b/api/src/http-body-limits.spec.ts new file mode 100644 index 0000000..0100a49 --- /dev/null +++ b/api/src/http-body-limits.spec.ts @@ -0,0 +1,66 @@ +import { configureHttpBodyParsers, DEFAULT_JSON_BODY_LIMIT, IMPORT_JSON_BODY_LIMIT } from './http-body-limits'; + +const express = require('express') as () => { + use(...args: unknown[]): void; + post(path: string, handler: (request: { body?: unknown; rawBody?: Buffer }, response: { json(body: unknown): void }) => void): void; + listen(port: number, host: string, callback: () => void): { close(callback: (error?: Error) => void): void; address(): { port: number } | string | null }; +}; +const expressModule = require('express') as { json(options: { limit: string }): (...args: unknown[]) => unknown; urlencoded(options: { limit: string; extended: boolean }): (...args: unknown[]) => unknown }; +const http = require('node:http') as typeof import('node:http'); + +describe('configureHttpBodyParsers', () => { + it('keeps ordinary JSON bounded while granting only import routes a larger limit', () => { + const use = jest.fn(); + const useBodyParser = jest.fn(); + + configureHttpBodyParsers({ use, useBodyParser } as never); + + expect(DEFAULT_JSON_BODY_LIMIT).toBe('2mb'); + expect(IMPORT_JSON_BODY_LIMIT).toBe('25mb'); + expect(use).toHaveBeenCalledTimes(1); + expect(use).toHaveBeenCalledWith('/api/client/send/imports', expect.any(Function)); + expect(useBodyParser).toHaveBeenNthCalledWith(1, 'json', { limit: '2mb' }); + expect(useBodyParser).toHaveBeenNthCalledWith(2, 'urlencoded', { limit: '2mb', extended: true }); + }); + + it('accepts a 3 MiB import JSON body but rejects the same ordinary JSON body', async () => { + const serverApp = express(); + configureHttpBodyParsers({ + use: serverApp.use.bind(serverApp), + useBodyParser(type: 'json' | 'urlencoded', options: { limit: string; extended?: boolean }) { + serverApp.use(type === 'json' + ? expressModule.json({ limit: options.limit }) + : expressModule.urlencoded({ limit: options.limit, extended: options.extended ?? true })); + }, + } as never); + serverApp.post('/api/client/send/imports/preview', (request, response) => response.json({ size: request.rawBody?.length ?? 0 })); + serverApp.post('/api/ordinary', (_request, response) => response.json({ accepted: true })); + + const server = await new Promise>((resolve) => { + const listening = serverApp.listen(0, '127.0.0.1', () => resolve(listening)); + }); + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('test server did not expose a TCP port'); + const body = JSON.stringify({ content: 'x'.repeat(3 * 1024 * 1024) }); + const importResponse = await postJSON(address.port, '/api/client/send/imports/preview', body); + expect(importResponse.status).toBe(200); + expect(JSON.parse(importResponse.body)).toEqual({ size: Buffer.byteLength(body) }); + await expect(postJSON(address.port, '/api/ordinary', body)).resolves.toMatchObject({ status: 413 }); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } + }); +}); + +function postJSON(port: number, path: string, body: string) { + return new Promise<{ status: number; body: string }>((resolve, reject) => { + const request = http.request({ hostname: '127.0.0.1', port, path, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer) => chunks.push(chunk)); + response.once('end', () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })); + }); + request.once('error', reject); + request.end(body); + }); +} diff --git a/api/src/http-body-limits.ts b/api/src/http-body-limits.ts new file mode 100644 index 0000000..8208d83 --- /dev/null +++ b/api/src/http-body-limits.ts @@ -0,0 +1,25 @@ +import type { NestExpressApplication } from '@nestjs/platform-express'; + +const express = require('express') as { + json(options: { + limit: string; + verify(request: { rawBody?: Buffer }, response: unknown, buffer: Buffer): void; + }): (...args: unknown[]) => unknown; +}; + +export const DEFAULT_JSON_BODY_LIMIT = '2mb'; +export const IMPORT_JSON_BODY_LIMIT = '25mb'; + +export function configureHttpBodyParsers(app: NestExpressApplication) { + // Import preview/confirmation temporarily carries the source CSV/TSV in + // JSON. Give only these endpoints the larger boundary; keeping ordinary + // JSON at 2 MiB limits the duplicate raw-buffer + parsed-object footprint. + app.use('/api/client/send/imports', express.json({ + limit: IMPORT_JSON_BODY_LIMIT, + verify(request, _response, buffer) { + request.rawBody = buffer; + }, + })); + app.useBodyParser('json', { limit: DEFAULT_JSON_BODY_LIMIT }); + app.useBodyParser('urlencoded', { limit: DEFAULT_JSON_BODY_LIMIT, extended: true }); +} diff --git a/api/src/main.ts b/api/src/main.ts index c63d7cb..f387a5a 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -1,8 +1,10 @@ import 'reflect-metadata'; import { NestFactory } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; import { OpenApiModule } from './open-api/open-api.module'; +import { configureHttpBodyParsers } from './http-body-limits'; Object.defineProperty(BigInt.prototype, 'toJSON', { configurable: true, @@ -16,8 +18,9 @@ Object.defineProperty(BigInt.prototype, 'toJSON', { }); async function bootstrap() { - const app = await NestFactory.create(AppModule, { rawBody: true }); + const app = await NestFactory.create(AppModule, { rawBody: true, bodyParser: false }); app.setGlobalPrefix('api'); + configureHttpBodyParsers(app); const swaggerConfig = new DocumentBuilder() .setTitle('CMPP Platform API') diff --git a/docs/contracts/admin-enterprise-signatures-r4.json b/docs/contracts/admin-enterprise-signatures-r4.json index 3e889ab..ea90841 100644 --- a/docs/contracts/admin-enterprise-signatures-r4.json +++ b/docs/contracts/admin-enterprise-signatures-r4.json @@ -65,15 +65,15 @@ }, { "name": "SignatureReportModal", - "canonicalSha256": "7439104e171e7d9eeb8449f497696b6d3736b881195c0b96a1a096079ba0789b" + "canonicalSha256": "4cfec954f564c998af70da441971bede16915f4bb5ac9c10e45da15101957de5" }, { "name": "ChannelReportStatusModal", - "canonicalSha256": "d32f8d7d3025ee286ed7898ed41a22404db3d684b80d23819965b3332e0e8310" + "canonicalSha256": "e0717a89302e477fd1e57f18e3f97b993ebe3c7c42ccbfcd2761110054f6abcb" }, { "name": "DrainageReportModal", - "canonicalSha256": "716a2d480a4b652ee951de0306854f1d8f6d72759d7b2fbffbfbb600abc8a789" + "canonicalSha256": "d2b41c26eb3992bb1ff3b50ee5ef6c06e7f84d89832e3ec7446b35178520431f" }, { "name": "DrainageReportStatusModal", @@ -84,7 +84,7 @@ "canonicalSha256": "2f71bdc2f8044ca9b403affbb0cb3f31587e4c004a90e9e0836aa6143033b6da" } ], - "tableJsxSha256": "203ec16e0ca67f0dafea08d659e84db02dd8346d06a52f41378625e39a43def7", + "tableJsxSha256": "5cae4926ab10a762b26d77d0a0633b85de68fa204b7d5732ba0942e7fc276234", "apiCalls": [ "listEnterpriseSignaturesPage", "listTenants", diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index cc015cc..8dfe953 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2034,3 +2034,17 @@ 5. 跳过只表示未调用 Gateway,第一版原因包括:状态已变化、已被客户确认、已被其他任务处理、本任务已成功处理、不属于任务快照、应用或投递能力已停用、投递数据不完整、缺少原 Submit 映射。 6. 任务必须支持列表、详情、暂停、继续和终止;终止只影响尚未发送的记录。任务项以 `taskId + deliveryId` 幂等,执行前原子认领并复核状态,API 重启后可继续,成功 ACK 的项目不得再次发送。 7. 所有创建、暂停、继续、终止和自动暂停均写操作日志;任务使用真实 PostgreSQL、Gateway 和客户 ACK,不得使用 mock、静态数据或 localStorage。 +# 2026-08-13 HTTP 请求与 Gateway API 响应容量边界 + +1. Go Gateway 调用 NestJS API 时必须完整读取最多 `4 MiB` 的响应体;超过边界必须返回包含实际容量边界的明确传输错误,禁止静默截断后以 `unexpected end of JSON input` 等语法错误掩盖容量问题。该边界覆盖待投递回执恢复批次等内部响应,不改变单次恢复查询的业务分页口径。 +2. NestJS 普通 JSON 与 URL-encoded 请求体统一限制为 `2 MiB`。客户号码文件导入预览和确认接口因现阶段仍在 JSON 中携带 CSV/TSV 正文,单独限制为 `25 MiB`;业务层继续限制原始导入正文不超过 `20 MiB`,为 JSON 字段、转义字符和其他参数保留协议开销。 +3. 大容量解析器只能挂载到 `/api/client/send/imports/*`,不得把所有管理、客户和公网 HTTP API 全局放宽到 25 MiB。`rawBody` 必须在两类解析器中继续保留,确保公网 HTTP API 的验签与幂等正文哈希语义不变。 +4. 客户导入接口经 `sms.lisglo.com` 私有 API 访问,该入口的 Nginx 请求体上限必须不低于 `30 MiB`,当前标准配置 `50 MiB` 满足要求。`api.lisglo.com` 只开放单条客户 HTTP API、Swagger 和健康检查,不承载文件导入,不得为导入需求扩大其暴露路由。 +5. 容量边界必须由自动化测试覆盖:大于旧 `64 KiB` 且小于 `4 MiB` 的合法 Gateway 响应完整解析;超过 `4 MiB` 明确拒绝;同一份大于 `2 MiB` 的 JSON 仅在导入路由可被解析,普通路由返回 HTTP 413。 + +# 2026-08-13 运营端信息密度与运营商标签统一 + +1. 运营端充值记录回执必须展示该笔人工充值的操作人员姓名。姓名由充值单已保存的`operatorId`关联真实用户记录取得,优先展示姓名、姓名缺失时回退用户名;历史无操作人的系统记录展示“系统”,不得用前端静态映射伪造。 +2. 运营看板删除“今日签名发送统计”和“今日签名发送统计 - 含引流”两个明细模块;“今日活跃签名”指标仍使用当天真实发送聚合。今日消费金额的主数字必须与今日发送总量使用相同字号、字重和深色层级。 +3. 企业应用管理列表的状态、到达率、单价列在现有基础上缩窄约20%,提升大屏一次展示完整表格的概率;不得通过隐藏真实字段实现。 +4. 移动、联通、电信数据展示统一使用全局`CarrierTag`低饱和标签,包括通道与通道组、监控、报备、签名、短信审核、任务号码、发送记录及客户端发送详情等页面。筛选/表单控件的选项文案、图表图例、导出文本和业务说明仍使用纯文本,避免破坏交互、可访问性和机器可读输出;可取得运营商集合的三网通道按三个运营商标签展示,只有历史通道级字段时使用中性的“三网”标签。 diff --git a/docs/production-deployment.md b/docs/production-deployment.md index 6da05eb..abca289 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -70,6 +70,8 @@ PROD_ADMIN_PASSWORD='change-me' `API_ENABLE_SEND_WORKER=true` 是生产发送链路必填项。后续发布脚本会在构建和迁移前校验该开关以及正整数 `API_SEND_WORKER_CONCURRENCY`;缺失时直接终止发布,防止 API/Gateway 健康但 BullMQ 短信队列无人消费。 +HTTP容量边界固定为:NestJS普通JSON/URL-encoded请求体`2 MiB`,仅`/api/client/send/imports/*`使用`25 MiB` JSON解析上限,原始CSV/TSV正文继续由业务层限制为`20 MiB`;Gateway读取NestJS API响应最多`4 MiB`且超限必须明确报错。客户文件导入走`sms.lisglo.com`私有API,因此该虚拟主机的`client_max_body_size`必须不低于`30m`,标准bootstrap配置为`50m`。`api.lisglo.com`只承载单条公网HTTP API、Swagger和健康检查,不承载客户文件导入;不要为导入需求开放私有路由或把NestJS所有JSON接口统一放宽到25MiB。发布前使用`nginx -T`确认最终生效值,不能只检查仓库模板。 + Gateway 的最终 TPS 防线依赖与 API 相同的 Redis。通道连接时会写入 `rate:gateway:channel:config:` 权威上限,实际预约使用 `rate:gateway:channel:`;这些 key 不应在正常发布时清理。多 Gateway 实例必须指向同一 Redis,才能共享单通道额度。超速的 `gateway.submit.commands` 消息会保持在 consumer group pending 中等待,不应通过手工 `XACK` 或删除 Stream 处理积压;先检查通道配置、Redis key、consumer group 和 Gateway 日志。 服务重启顺序必须是 Gateway 在前、API 在后。API 启动后等待 `GATEWAY_STARTUP_RECONNECT_DELAY_MS`(默认 1 秒),从 PostgreSQL 读取全部 active 通道并重新下发真实连接命令,同时恢复 Gateway 内存连接池和 Redis 权威 TPS key;禁止沿用数据库中重启前的 connected 状态冒充当前连接。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 05d8276..2bc491f 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -4596,3 +4596,21 @@ npm run verify:phase8 | TC-DOWNSTREAM-REQUEUE-TASK-007 | 任务控制 | 待执行/执行中任务可暂停、继续、终止;终止不撤回已写出消息。 | | TC-DOWNSTREAM-REQUEUE-TASK-008 | 审计 | 创建、暂停、继续、终止和自动暂停记录操作人、筛选快照、原因和结果。 | | TC-DOWNSTREAM-PAGE-SIZE-001 | 分页数量 | 可选 10/25/50;切换回第一页,后端返回对应条数,总数和筛选条件保持一致。 | +# 2026-08-13 HTTP 与 Gateway 报文容量专项用例 + +| 用例编号 | 优先级 | 验证内容 | 预期结果 | +| --- | --- | --- | --- | +| TC-TRANSPORT-SIZE-001 | P0 | 模拟 NestJS 返回约128KiB的合法待投递回执JSON,由Gateway通用API传输方法读取并反序列化 | 响应完整解析,字段长度与服务端输出一致,不出现64KiB截断或JSON语法错误 | +| TC-TRANSPORT-SIZE-002 | P0 | 模拟NestJS返回超过4MiB的响应 | Gateway停止读取并返回`api response exceeds 4194304-byte limit`容量错误,不返回`unexpected end of JSON input`,不把不完整数据当作成功结果 | +| TC-HTTP-BODY-001 | P0 | 分别向普通JSON接口和`/api/client/send/imports/preview`提交约3MiB合法JSON | 普通接口在Controller前返回413;导入接口成功解析且保留rawBody,证明25MiB解析器只对导入路由生效 | +| TC-HTTP-BODY-002 | P1 | 分别测试普通JSON 2MiB边界、导入JSON 25MiB边界及业务层原始正文20MiB边界 | 边界内请求正常进入业务校验;超过解析器边界返回413;超过20MiB原始导入正文返回明确业务错误且不创建发送任务 | +| TC-NGINX-BODY-001 | P1 | 检查预生产有效Nginx配置及域名路由 | `sms.lisglo.com`请求体上限不低于30MiB并承载私有导入接口;`api.lisglo.com`不暴露私有导入路由且其现有上限不影响单条公网HTTP API | + +# 2026-08-13 运营端信息密度与运营商标签统一专项用例 + +| 编号 | 场景 | 步骤 | 预期结果 | +| --- | --- | --- | --- | +| TC-BILLING-RECEIPT-OPERATOR-001 | 人工充值回执展示操作人员姓名 | 使用有`operatorId`的真实人工充值记录查询充值列表并打开回执 | API按用户表返回`operatorName`,回执显示姓名而非用户ID;无操作人的历史记录显示“系统”,关联用户确已不存在时显示“未知操作人员” | +| TC-DASHBOARD-SIGNATURE-REMOVE-001 | 删除看板签名统计模块并统一金额样式 | 打开运营看板,检查指标卡与后续模块 | 两个“今日签名发送统计”模块均不存在;今日消费金额与今日发送总量主数字字号、字重和颜色一致;今日活跃签名仍来自真实接口 | +| TC-ENTERPRISE-APP-WIDTH-001 | 企业应用关键列缩窄 | 在桌面大屏打开企业应用管理并读取表头列宽 | 状态、到达率、单价列宽均约为原宽度80%,字段与操作均未隐藏,横向滚动需求减少 | +| TC-UI-CARRIER-TAG-002 | 全局运营商数据展示复用通用标签 | 抽查通道/通道组、监控、报备、企业签名、短信审核、批次号码、发送记录和客户端发送详情 | 移动、联通、电信均使用全局低饱和胶囊及统一色值;有运营商集合的三网通道显示三个标签,历史通道级字段显示中性“三网”;筛选选项、图表图例与导出文本保持纯文本 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 3014619..254eb91 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -3541,3 +3541,19 @@ git diff --check - 最小修复为将原始企业、应用字段加入`GROUP BY`,保持“正文签名 × 实际企业应用”业务口径、搜索、计数和分页不变;同步新增`TC-SIGNATURE-RETIREMENT-029`,防止测试桩只验证返回映射而遗漏真实PostgreSQL语法约束。 - 签名清退专项9/9、API TypeScript正式构建和`git diff --check`通过。热修复提交`4c70978da4e3bd22ea159313c95b36ca18d150d6`已推送,并采用API最小热发布:备份位于`/opt/cmpp-platform-backups/releases/20260812-175436-before-hotfix-4c70978d`,只替换本次API源码/编译产物并重启`cmpp-api`,未重启Gateway、Nginx或短信通道。 - 发布后运行标识已核对为`4c70978d`,API和Gateway健康通过。使用生产真实PostgreSQL执行与接口相同的完整聚合SQL成功返回1组、266条短信,未再出现`42803`;热发布后的API日志没有新增`ExceptionsHandler`或Prisma聚合异常。现有浏览器没有可接管的登录页,因此未伪造账号会话;页面可由用户直接刷新验收。 +# 2026-08-13 Gateway响应截断与NestJS请求体容量修复(本地未提交、未发布) + +- 只读复核确认Gateway通用API传输方法使用`io.LimitReader(resp.Body, 64*1024)`静默截断响应;待投递回执恢复查询单批100条时,生产真实JSON已可超过该边界并形成`unexpected end of JSON input`。本轮将完整响应上限调整为4MiB,并额外读取1字节识别超限:超过边界返回明确容量错误,不再把传输截断伪装成JSON语法错误。 +- NestJS关闭框架自动注册的默认100KiB body parser,显式注册普通JSON/URL-encoded 2MiB解析器;仅`/api/client/send/imports/*`先注册25MiB JSON解析器。两类JSON解析器继续保存`rawBody`,公网HTTP API验签和幂等正文哈希语义不变;导入业务层原始正文20MiB限制保持不变。 +- 域名链路重新核对:客户导入使用`sms.lisglo.com`私有API,其标准Nginx上限50MiB已覆盖25MiB解析需求;`api.lisglo.com`只开放单条HTTP API、客户Swagger和健康检查,不承载文件导入,因此本轮不错误扩大API专用域名或开放私有路由。 +- 新增专项自动化:Gateway可完整解析约128KiB合法响应,超过4MiB返回`api response exceeds 4194304-byte limit`且不出现截断JSON错误;同一份约3MiB JSON在导入路由返回200、保留完整rawBody,普通路由返回413。API容量专项2/2、API全量37套/460项、API TypeScript正式构建、Gateway inbound专项、Gateway全量`go test ./... -count=1`及`go vet ./...`、4份Gateway队列契约和`git diff --check`均通过。 +- 19份既有结构门禁中10份通过、9份失败;失败均来自当前`HEAD`在本轮开始前已经存在的契约漂移(下游重投/异常处置API、通道排序、签名弹窗、签名质量查询、报备导出、运营商集合、定时调度、长文本样式和签名查询),与本轮新增/修改文件无交集。本轮不为通过门禁而顺手刷新或改写其他会话业务契约,保留为既有基线问题单独治理。 +- 本轮没有发送、补发或重投短信,没有修改通道账号、密码、启停状态、企业余额、客户连接或生产数据;代码保持未提交、未推送、未部署。受保护的`api/tsconfig.build.tsbuildinfo`、`tsconfig.tsbuildinfo`、`outputs/`和空文件`=`不删除、不提交、不归因。 + +# 2026-08-13 充值回执操作人员、看板精简与运营商标签统一(待提交、未发布) + +- 充值记录列表接口使用充值单既有`operatorId`批量查询真实用户,返回`displayName`(缺失时回退`username`)作为`operatorName`;充值回执新增“操作人员”,历史无操作人的系统记录展示“系统”。未新增字段或migration,避免复制姓名造成历史数据与用户资料不一致。 +- 运营看板删除“今日签名发送统计”和“今日签名发送统计 - 含引流”两个模块及其分页状态;今日活跃签名继续使用真实发送质量接口聚合。指标文案调整为“今日消费金额”,主数字直接复用与今日发送总量相同的`metric-card strong`样式。 +- 企业应用管理的状态、到达率、单价列宽均从130px缩至104px,字段和操作未隐藏。全局审计运营商数据展示后,监控、通道/通道组、应用路由、签名和报备、清退、短信审核、批次号码、发送记录及客户端发送详情统一复用`CarrierTag`;筛选选项、图表图例、导出和说明文字保留纯文本,三网通道展开为三个标签。 +- API全量37套/460项、充值和HTTP容量专项14/14、前后端TypeScript、Vite 8.1.5生产构建、Gateway全量测试与`go vet`、4份Gateway队列契约、企业应用R11、短信记录R11、企业签名R4结构契约和`git diff --check`均通过;Vite仅保留既有约2.07MiB单chunk提示。企业签名R4哈希只按本次经验证的运营商标签JSX同步更新,未放宽模块边界。浏览器连接本地页面时既有登录会话已失效,未输入账号密码或验证码,因此页面级视觉验收未完成。 +- 本轮与此前未提交的HTTP/Gateway容量修复一并进入待提交范围,未发送、补发或重投短信,未修改通道配置、余额、客户连接或生产数据;受保护缓存、`outputs/`和空文件`=`不删除、不提交、不归因。 diff --git a/gateway/internal/inbound/transport.go b/gateway/internal/inbound/transport.go index 1c3d786..3e8c689 100644 --- a/gateway/internal/inbound/transport.go +++ b/gateway/internal/inbound/transport.go @@ -12,6 +12,8 @@ import ( "time" ) +const maxAPIResponseBodyBytes int64 = 4 * 1024 * 1024 + func (s Server) post(ctx context.Context, path string, payload any, result any) error { client := s.HTTPClient if client == nil { @@ -31,10 +33,17 @@ func (s Server) post(ctx context.Context, path string, payload any, result any) return err } defer resp.Body.Close() - responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + // Read one byte beyond the supported boundary so an oversized upstream + // response is reported explicitly. Silently cutting JSON at the boundary + // turns a transport-capacity problem into a misleading syntax error and can + // leave recoverable downstream receipts stuck indefinitely. + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBodyBytes+1)) if err != nil { return fmt.Errorf("read api response: %w", err) } + if int64(len(responseBody)) > maxAPIResponseBodyBytes { + return fmt.Errorf("api response exceeds %d-byte limit", maxAPIResponseBodyBytes) + } if resp.StatusCode < 200 || resp.StatusCode >= 300 { detail := strings.TrimSpace(string(responseBody)) if detail == "" { diff --git a/gateway/internal/inbound/transport_test.go b/gateway/internal/inbound/transport_test.go new file mode 100644 index 0000000..6faeaef --- /dev/null +++ b/gateway/internal/inbound/transport_test.go @@ -0,0 +1,46 @@ +package inbound + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPostAcceptsAPIResponseLargerThanLegacy64KiB(t *testing.T) { + payload := strings.Repeat("x", 128*1024) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"payload": payload}) + })) + defer server.Close() + + var result struct { + Payload string `json:"payload"` + } + if err := (Server{APIBaseURL: server.URL}).post(context.Background(), "/large", map[string]string{"request": "ok"}, &result); err != nil { + t.Fatalf("post response larger than 64KiB: %v", err) + } + if result.Payload != payload { + t.Fatalf("payload length = %d, want %d", len(result.Payload), len(payload)) + } +} + +func TestPostRejectsAPIResponseBeyondFourMiBWithoutTruncatedJSONError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"payload":"` + strings.Repeat("x", int(maxAPIResponseBodyBytes)) + `"}`)) + })) + defer server.Close() + + var result map[string]any + err := (Server{APIBaseURL: server.URL}).post(context.Background(), "/too-large", map[string]string{"request": "ok"}, &result) + if err == nil || !strings.Contains(err.Error(), "api response exceeds 4194304-byte limit") { + t.Fatalf("error = %v, want explicit response-size error", err) + } + if strings.Contains(err.Error(), "unexpected end of JSON input") { + t.Fatalf("oversized response must not surface as truncated JSON: %v", err) + } +} diff --git a/src/api/types/identity-config.ts b/src/api/types/identity-config.ts index 5b01a39..8ad8949 100644 --- a/src/api/types/identity-config.ts +++ b/src/api/types/identity-config.ts @@ -178,6 +178,7 @@ export type RechargeOrder = { payMethod?: string | null; paidAt?: string | null; operatorId?: string | null; + operatorName?: string | null; remark?: string | null; balanceAfterCents?: number | null; createdAt: string; diff --git a/src/apps/admin/AdminAnalyticsPage.tsx b/src/apps/admin/AdminAnalyticsPage.tsx index 9dc28b6..c5db88e 100644 --- a/src/apps/admin/AdminAnalyticsPage.tsx +++ b/src/apps/admin/AdminAnalyticsPage.tsx @@ -537,13 +537,13 @@ function SignatureQualityDrawer({ {matrixMode === 'overall' ? ( 通道名称 - {visibleCarriers.map((carrier) => {carrierLabel(carrier)})} + {visibleCarriers.map((carrier) => )} ) : ( 通道名称 引流类型 - {majorCarrierOrder.map((carrier) => {carrierLabel(carrier)})} + {majorCarrierOrder.map((carrier) => )} )} diff --git a/src/apps/admin/AdminChannelGroupsPage.tsx b/src/apps/admin/AdminChannelGroupsPage.tsx index 8fca72d..734aea8 100644 --- a/src/apps/admin/AdminChannelGroupsPage.tsx +++ b/src/apps/admin/AdminChannelGroupsPage.tsx @@ -1,17 +1,9 @@ import { useEffect, useMemo, useState } from 'react'; import { Clock3, Layers3, Pencil, Plus, RadioTower, Search, Trash2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag } from '@/components/ui'; +import { Breadcrumb, Button, CarrierTag, Input, Modal, Pagination, Select, Tag } from '@/components/ui'; import { adminApi, type AdminChannel, type ChannelGroup, type ChannelGroupDeletionImpact } from '@/api/adminApi'; -type GroupCarrier = 'mobile' | 'unicom' | 'telecom'; - -const carrierLabels: Record = { - mobile: '移动', - unicom: '联通', - telecom: '电信', -}; - function formatRetryLimit(group: ChannelGroup) { if (group.retryEnabled === false) return '已关闭'; const totalMinutes = group.retryTimeLimitMinutes ?? (group.retryTimeLimitHours ?? 12) * 60; @@ -154,7 +146,7 @@ export function AdminChannelGroupsPage() {
{group.name} - {carrierLabels[group.carrier] ?? group.carrier}通道组 + 通道组
diff --git a/src/apps/admin/AdminChannelReportPage.tsx b/src/apps/admin/AdminChannelReportPage.tsx index 97b358b..620d766 100644 --- a/src/apps/admin/AdminChannelReportPage.tsx +++ b/src/apps/admin/AdminChannelReportPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { ArrowLeft, Eye, FileSliders, Search } from 'lucide-react'; import { useNavigate, useParams } from 'react-router-dom'; import { adminApi, type AdminChannel, type ChannelReportField, type ClientSmsSignature, type DictionaryItem, type ReportRecord, type ReportTask } from '@/api/adminApi'; -import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui'; +import { Breadcrumb, Button, CarrierTag, Input, Modal, Select, Tag, Textarea } from '@/components/ui'; import { formatDateTime } from '@/utils/dateTime'; import { successRateClassName } from '@/utils/successRate'; import { ReportFieldMappingModal } from './ReportFieldMappingModal'; @@ -185,7 +185,7 @@ export function AdminChannelReportPage() { const reportedAt = task.approvedAt ?? approvedRecord(task.id)?.createdAt; return
-
{drainage ? : null}{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}{drainage ? `${String(drainage.url ?? '')} · ${formatSignatureName(signature?.name ?? task.signature?.name)}` : `${signature?.tenant?.name ?? task.tenantId} · ${task.carrier ? ({ mobile: '移动', unicom: '联通', telecom: '电信' } as const)[task.carrier] : '历史通道级(未拆分)'}`}
+
{drainage ? : null}{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}{drainage ? `${String(drainage.url ?? '')} · ${formatSignatureName(signature?.name ?? task.signature?.name)}` : <>{signature?.tenant?.name ?? task.tenantId} · {task.carrier ? : '历史通道级(未拆分)'}}
diff --git a/src/apps/admin/AdminHome.tsx b/src/apps/admin/AdminHome.tsx index b934e14..57712fb 100644 --- a/src/apps/admin/AdminHome.tsx +++ b/src/apps/admin/AdminHome.tsx @@ -12,12 +12,11 @@ import { Chart, Modal, MoneyText, - Pagination, Table, Tag, type TableColumn, } from '@/components/ui'; -import { adminApi, type DashboardResponse, type SendQualityResponse, type SignatureQualityStat } from '@/api/adminApi'; +import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi'; import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions'; import { formatAmount, moneyUnitsToYuan } from '@/utils/currency'; @@ -29,12 +28,6 @@ type EnterpriseSpendRank = { availableBalance: number; }; -type RankedSignatureQualityStat = SignatureQualityStat & { - rank: number; -}; - -const SIGNATURE_PAGE_SIZE = 10; - const balanceTone = { 充足: 'success', 紧张: 'warning', @@ -55,8 +48,6 @@ export function AdminHome() { const [quality, setQuality] = useState(null); const [error, setError] = useState(''); const [selectedEnterprise, setSelectedEnterprise] = useState(null); - const [plainSignaturePage, setPlainSignaturePage] = useState(1); - const [drainageSignaturePage, setDrainageSignaturePage] = useState(1); useEffect(() => { Promise.all([adminApi.getDashboard(), adminApi.getSendQuality()]) @@ -146,36 +137,6 @@ export function AdminHome() { }, ]; - const signatureColumns: Array> = [ - { key: 'rank', title: '排名', width: '72px', render: (record) => record.rank }, - { key: 'signatureName', title: '签名', render: (record) =>
{record.signatureName}

{record.tenantName}

}, - { key: 'total', title: '发送总数', align: 'right', render: (record) => formatCount(record.total) }, - { key: 'submitFailureCount', title: '提交失败', align: 'right', render: (record) => formatCount(record.submitFailureCount) }, - { key: 'successCount', title: '成功', align: 'right', render: (record) => formatCount(record.successCount) }, - { key: 'unknownCount', title: '未知', align: 'right', render: (record) => formatCount(record.unknownCount) }, - { key: 'failureCount', title: '失败', align: 'right', render: (record) => formatCount(record.failureCount) }, - { key: 'successRate', title: '成功率', align: 'right', render: (record) => `${record.successRate.toFixed(1)}%` }, - { key: 'averageArrivalMs', title: '平均到达', align: 'right', render: (record) => record.averageArrivalMs === null || record.averageArrivalMs === undefined ? '-' : `${(record.averageArrivalMs / 1000).toFixed(1)}秒` }, - ]; - const plainSignatureQuality = quality?.signatures ?? []; - const drainageSignatureQuality = quality?.drainageSignatures ?? []; - const plainSignatureTotalPages = Math.max(1, Math.ceil(plainSignatureQuality.length / SIGNATURE_PAGE_SIZE)); - const drainageSignatureTotalPages = Math.max(1, Math.ceil(drainageSignatureQuality.length / SIGNATURE_PAGE_SIZE)); - const pagedPlainSignatureQuality = plainSignatureQuality - .slice((plainSignaturePage - 1) * SIGNATURE_PAGE_SIZE, plainSignaturePage * SIGNATURE_PAGE_SIZE) - .map((item, index) => ({ ...item, rank: (plainSignaturePage - 1) * SIGNATURE_PAGE_SIZE + index + 1 })); - const pagedDrainageSignatureQuality = drainageSignatureQuality - .slice((drainageSignaturePage - 1) * SIGNATURE_PAGE_SIZE, drainageSignaturePage * SIGNATURE_PAGE_SIZE) - .map((item, index) => ({ ...item, rank: (drainageSignaturePage - 1) * SIGNATURE_PAGE_SIZE + index + 1 })); - - useEffect(() => { - setPlainSignaturePage((page) => Math.min(page, plainSignatureTotalPages)); - }, [plainSignatureTotalPages]); - - useEffect(() => { - setDrainageSignaturePage((page) => Math.min(page, drainageSignatureTotalPages)); - }, [drainageSignatureTotalPages]); - return (
@@ -205,8 +166,8 @@ export function AdminHome() { delivered / 今日总量
- 今日消费 - ¥{formatCurrency(todaySpend)} + 今日消费金额 + ¥{formatCurrency(todaySpend)} 来自今日消息金额聚合
@@ -230,49 +191,6 @@ export function AdminHome() {
-
-
-
-
-

今日签名发送统计

-

按签名汇总当天全部真实发送,不区分是否包含引流信息。

-
- {plainSignatureQuality.length} 个签名 -
- - = plainSignatureTotalPages} - onPrevious={() => setPlainSignaturePage((page) => Math.max(1, page - 1))} - onNext={() => setPlainSignaturePage((page) => Math.min(plainSignatureTotalPages, page + 1))} - onPageChange={setPlainSignaturePage} - /> - -
-
-
-

今日签名发送统计 - 含引流

-

只统计短信内容识别为含引流信息的发送效果。

-
- {drainageSignatureQuality.length} 个签名 -
-
- = drainageSignatureTotalPages} - onPrevious={() => setDrainageSignaturePage((page) => Math.max(1, page - 1))} - onNext={() => setDrainageSignaturePage((page) => Math.min(drainageSignatureTotalPages, page + 1))} - onPageChange={setDrainageSignaturePage} - /> - - -
diff --git a/src/apps/admin/AdminMonitorPage.tsx b/src/apps/admin/AdminMonitorPage.tsx index 71eb6ca..6093e99 100644 --- a/src/apps/admin/AdminMonitorPage.tsx +++ b/src/apps/admin/AdminMonitorPage.tsx @@ -1,12 +1,15 @@ import { useEffect, useMemo, useState } from 'react'; import { Activity } from 'lucide-react'; -import { Breadcrumb, Button, Table, Tag, type TableColumn } from '@/components/ui'; +import { Breadcrumb, Button, CarrierTag, Table, Tag, type TableColumn } from '@/components/ui'; import { adminApi, type AdminChannel } from '@/api/adminApi'; const columns: Array> = [ { key: 'id', title: '通道编号', render: (record) => record.id }, { key: 'name', title: '通道名称', render: (record) => record.name }, - { key: 'carrier', title: '运营商', render: (record) => carrierLabel(record.carrier) }, + { key: 'carrier', title: '运营商', render: (record) => { + const carriers = record.carriers?.length ? record.carriers : record.carrier === 'all' ? ['mobile', 'unicom', 'telecom'] : record.carrier ? [record.carrier] : []; + return carriers.length ? {carriers.map((carrier) => )} : '-'; + } }, { key: 'gatewayHost', title: '网关地址', render: (record) => `${record.gatewayHost}:${record.gatewayPort}` }, { key: 'rateLimitPerSecond', title: '限速', render: (record) => `${record.rateLimitPerSecond} 条/秒` }, { @@ -16,22 +19,6 @@ const columns: Array> = [ }, ]; -const carrierLabels: Record = { - mobile: '移动', - cmcc: '移动', - unicom: '联通', - cucc: '联通', - telecom: '电信', - ctcc: '电信', - all: '三网', - unknown: '未识别', -}; - -function carrierLabel(value?: string | null) { - if (!value) return '-'; - return carrierLabels[value.trim().toLowerCase()] ?? value; -} - export function AdminMonitorPage() { const [channels, setChannels] = useState([]); const [monitor, setMonitor] = useState>({}); diff --git a/src/apps/admin/AdminReportMaterialsPage.tsx b/src/apps/admin/AdminReportMaterialsPage.tsx index e43b765..a8eb032 100644 --- a/src/apps/admin/AdminReportMaterialsPage.tsx +++ b/src/apps/admin/AdminReportMaterialsPage.tsx @@ -11,6 +11,7 @@ import { import { Breadcrumb, Button, + CarrierTag, DateRangeInput, Input, Modal, @@ -263,7 +264,7 @@ export function AdminReportMaterialsPage() { setConfirmOpen(false)}>关闭 : <>} onClose={() => { if (!busy) setConfirmOpen(false); }} open={confirmOpen} size="xl" title="报备生成资格预检">
{preflightBusy ?

正在核对资料版本、应用路由、通道字段与历史批次…

: null} - {preflight ? <>
可生成 {preflight.eligibleTargetCount} 个资料通道组合跳过 {preflight.skippedTargetCount} 个组合
{preflight.items.map((item) =>
{item.name}{item.tenantName} · {item.applicationName} · V{item.materialVersion}
{item.targets.length ?
    {item.targets.map((target) =>
  • {target.name} · {target.carrier}{target.eligible ? '资格通过' : target.blockedReasons.join(';')}
  • )}
:

{item.blockedReasons.join(';')}

}
)} : null} + {preflight ? <>
可生成 {preflight.eligibleTargetCount} 个资料通道组合跳过 {preflight.skippedTargetCount} 个组合
{preflight.items.map((item) =>
{item.name}{item.tenantName} · {item.applicationName} · V{item.materialVersion}
{item.targets.length ?
    {item.targets.map((target) =>
  • {target.name} · {target.eligible ? '资格通过' : target.blockedReasons.join(';')}
  • )}
:

{item.blockedReasons.join(';')}

}
)} : null} {batchResult ?
报备批次 {batchResult.batchNo} 已处理成功 {batchResult.result.successCount} · 跳过 {batchResult.result.skippedCount} · 失败 {batchResult.result.failedCount}操作单号:{batchResult.operationId}{batchResult.replayed ? '(幂等重放)' : ''}
: null}
diff --git a/src/apps/admin/AdminReportTasksPage.tsx b/src/apps/admin/AdminReportTasksPage.tsx index bca09c1..fe93a2c 100644 --- a/src/apps/admin/AdminReportTasksPage.tsx +++ b/src/apps/admin/AdminReportTasksPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { Eye, Search } from 'lucide-react'; import { adminApi, fileDownloadUrl, type ReportTask } from '@/api/adminApi'; -import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui'; +import { Breadcrumb, Button, CarrierTag, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui'; import { formatDateTime } from '@/utils/dateTime'; const statusMeta: Record = { @@ -39,7 +39,7 @@ function TaskDetailModal({ task, onClose }: { task: ReportTask; onClose: () => v
企业{task.signature?.tenant?.name ?? task.tenantId}
企业应用{task.signature?.application?.name ?? '未指定应用'}
通道{task.channel?.name ?? task.channelId}
- {task.reportType !== 'drainage' ?
运营商{task.carrier ? ({ mobile: '移动', unicom: '联通', telecom: '电信' } as const)[task.carrier] : '历史通道级(未拆分)'}
: null} + {task.reportType !== 'drainage' ?
运营商{task.carrier ? : 历史通道级(未拆分)}
: null} {task.reportType !== 'drainage' ?
当前通过时间{task.approvedAt ? formatDateTime(task.approvedAt) : '-'}
: null}
当前状态{status.label}
创建时间{formatDateTime(task.createdAt)}
@@ -130,7 +130,7 @@ export function AdminReportTasksPage() { const columns: Array> = [ { key: 'target', title: '报备对象', render: (record) =>
{taskTargetLabel(record)}
{record.reportType === 'drainage' ? '引流信息' : '签名'} · {record.signature?.tenant?.name ?? record.tenantId}
}, { key: 'application', title: '企业应用', render: (record) => record.signature?.application?.name ?? '未指定应用' }, - { key: 'channel', title: '通道/运营商', render: (record) =>
{record.channel?.name ?? record.channelId}{record.reportType !== 'drainage' ?
{record.carrier ? ({ mobile: '移动', unicom: '联通', telecom: '电信' } as const)[record.carrier] : '历史通道级(未拆分)'}
: null}
}, + { key: 'channel', title: '通道/运营商', render: (record) =>
{record.channel?.name ?? record.channelId}{record.reportType !== 'drainage' ?
{record.carrier ? : '历史通道级(未拆分)'}
: null}
}, { key: 'batch', title: '批次/版本', render: (record) => { const source = record.exportItems?.[0]; return source ?
{source.batchItem.batch.batchNo}
V{source.batchItem.materialVersion} · 第{source.rowNumber}行
: '-'; diff --git a/src/apps/admin/AdminSignatureRetirementPage.tsx b/src/apps/admin/AdminSignatureRetirementPage.tsx index 1cad671..172000c 100644 --- a/src/apps/admin/AdminSignatureRetirementPage.tsx +++ b/src/apps/admin/AdminSignatureRetirementPage.tsx @@ -119,7 +119,7 @@ export function AdminSignatureRetirementPage() { { key: 'actions', title: '操作', align: 'right', width: '190px', render: (item) =>
{!item.isRead ? : null}{!item.suppressed ? : null}
}, ]; const suppressionColumns: Array> = [ - { key: 'dimension', title: '维度', render: (item) => `${item.dimensionType === 'enterprise' ? '企业' : '通道'} / ${carrierLabels[item.carrier]}` }, + { key: 'dimension', title: '维度', render: (item) => {item.dimensionType === 'enterprise' ? '企业' : '通道'} / }, { key: 'mode', title: '方式', render: (item) => item.mode === 'permanent' ? '永久抑制' : `临时至 ${formatDate(item.muteUntil)}` }, { key: 'reason', title: '原因', render: (item) => item.reason || '-' }, { key: 'actions', title: '操作', align: 'right', render: (item) => }, diff --git a/src/apps/admin/AdminSmsApplicationFormPage.tsx b/src/apps/admin/AdminSmsApplicationFormPage.tsx index ec606d7..570d7dd 100644 --- a/src/apps/admin/AdminSmsApplicationFormPage.tsx +++ b/src/apps/admin/AdminSmsApplicationFormPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { ArrowLeft, Globe2, Info, RadioTower, RefreshCw } from 'lucide-react'; import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication, type HttpApiConfig } from '@/api/adminApi'; -import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui'; +import { Breadcrumb, Button, CarrierTag, Input, Select, Tag } from '@/components/ui'; import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency'; import { createRandomHex } from '@/utils/randomId'; @@ -450,7 +450,7 @@ export function AdminSmsApplicationFormPage() {
- {meta.label}通道组 + 通道组 {meta.description}
{card.groupId ? '已选择' : `${available.length} 个可选`} diff --git a/src/apps/admin/AdminSmsAuditPage.tsx b/src/apps/admin/AdminSmsAuditPage.tsx index 7726ba7..835f9a0 100644 --- a/src/apps/admin/AdminSmsAuditPage.tsx +++ b/src/apps/admin/AdminSmsAuditPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Check, Eye, Search, X } from 'lucide-react'; import { adminApi, type RiskReviewTask, type RiskTaskMessagePage } from '@/api/adminApi'; -import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui'; +import { Breadcrumb, Button, CarrierTag, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui'; import { formatDateTime } from '@/utils/dateTime'; const statusLabel: Record = { @@ -263,7 +263,7 @@ export function AdminSmsAuditPage() { columns={[ { key: 'phoneNumber', title: '手机号码', render: (item) => {item.phoneNumber} }, { key: 'province', title: '号码归属地', render: (item) => item.province || '-' }, - { key: 'carrier', title: '运营商', render: (item) => item.carrier || '-' }, + { key: 'carrier', title: '运营商', render: (item) => item.carrier ? : '-' }, { key: 'status', title: '短信记录状态', render: (item) => {messageStatusLabel(item.status)} }, ]} data={phoneData.items} diff --git a/src/apps/admin/enterprise-applications/EnterpriseApplicationTable.tsx b/src/apps/admin/enterprise-applications/EnterpriseApplicationTable.tsx index 72fa447..5efe9ac 100644 --- a/src/apps/admin/enterprise-applications/EnterpriseApplicationTable.tsx +++ b/src/apps/admin/enterprise-applications/EnterpriseApplicationTable.tsx @@ -55,8 +55,8 @@ export function EnterpriseApplicationTable({ { key: 'name', title: '应用名称', width: '180px', render: (record) => {record.name} }, { key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise }, { key: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')} 条` }, - { key: 'deliveryRate', title: '到达率', width: '130px', render: (record) => `${record.deliveryRate}%` }, - { key: 'unitPrice', title: '单价', width: '130px', render: (record) => {formatAmount(record.unitPrice)} 元 }, + { key: 'deliveryRate', title: '到达率', width: '104px', render: (record) => `${record.deliveryRate}%` }, + { key: 'unitPrice', title: '单价', width: '104px', render: (record) => {formatAmount(record.unitPrice)} 元 }, { key: 'cmppStatus', title: '客户连接状态', @@ -89,7 +89,7 @@ export function EnterpriseApplicationTable({
), }, - { key: 'enabled', title: '状态', width: '130px', render: applicationStatusTag }, + { key: 'enabled', title: '状态', width: '104px', render: applicationStatusTag }, { key: 'actions', title: '操作', diff --git a/src/apps/admin/enterprise-signatures/EnterpriseSignaturesTable.tsx b/src/apps/admin/enterprise-signatures/EnterpriseSignaturesTable.tsx index 4d1fd86..7bc0ce3 100644 --- a/src/apps/admin/enterprise-signatures/EnterpriseSignaturesTable.tsx +++ b/src/apps/admin/enterprise-signatures/EnterpriseSignaturesTable.tsx @@ -1,7 +1,7 @@ import type { Dispatch, SetStateAction } from 'react'; import { ChevronDown, ChevronRight, Edit3, FileText, Plus } from 'lucide-react'; import type { ClientSmsSignature } from '@/api/adminApi'; -import { Button, DeleteRiskAction, Pagination } from '@/components/ui'; +import { Button, CarrierTag, DeleteRiskAction, Pagination } from '@/components/ui'; import { AuditStatusTag, CarrierReportTag, readDrainagePayload, signatureCardVisual } from './signature.helpers'; import type { DrainageInfo } from './signature.types'; @@ -63,9 +63,9 @@ export function EnterpriseSignaturesTable({
企业{signature.tenant?.name ?? signature.tenantId}
应用{signature.application?.name ?? '-'}
签名审核
-
移动
-
联通
-
电信
+
+
+
引流信息{payload.links.length} 条
@@ -82,9 +82,9 @@ export function EnterpriseSignaturesTable({
引流url或号码 审核状态 - 移动 - 联通 - 电信 + + + 操作
{visibleDrainageLinks.map((item) => { diff --git a/src/apps/admin/enterprise-signatures/SignatureReportModals.tsx b/src/apps/admin/enterprise-signatures/SignatureReportModals.tsx index 7b4ecff..f40748a 100644 --- a/src/apps/admin/enterprise-signatures/SignatureReportModals.tsx +++ b/src/apps/admin/enterprise-signatures/SignatureReportModals.tsx @@ -23,11 +23,11 @@ export function SignatureReportModal({ item, onClose }: { item: ClientSmsSignatu
更新时间{formatDate(item.updatedAt)}
- - - + + +
-
{(item.reportTargets ?? []).map((target) =>
{target.channel.name}({carrierLabel(target.carrier)})
)}
+
{(item.reportTargets ?? []).map((target) =>
{target.channel.name}
)}
); @@ -64,9 +64,9 @@ export function DrainageReportModal({ item, onClose, signature }: { item: Draina
引流url或号码{item.url}
引流url或号码{item.url}
-
移动
-
联通
-
电信
+
+
+
备注{item.remark || '-'}
{targets.map((target) =>
{target.channel.name}({carrierLabel(target.channel.carrier)})
)}
diff --git a/src/apps/admin/sms-records/SendDetailModal.tsx b/src/apps/admin/sms-records/SendDetailModal.tsx index 1be74ef..6bf524c 100644 --- a/src/apps/admin/sms-records/SendDetailModal.tsx +++ b/src/apps/admin/sms-records/SendDetailModal.tsx @@ -1,9 +1,8 @@ import { AlertTriangle, Info, MessageSquare } from 'lucide-react'; import type { SmsMessageRecord, SmsMessageSegmentAudit } from '@/api/adminApi'; -import { Button, Modal, Tag } from '@/components/ui'; +import { Button, CarrierTag, Modal, Tag } from '@/components/ui'; import { buildRouteRows, - getCarrierLabel, getReceiptNotice, getRecordStatus, getRecordStatusLabel, @@ -52,7 +51,7 @@ export function SendDetailModal({
回执状态{record.receiptStatus ?? '-'}
提交时间{getTime(record.queuedAt)}
发送号码{record.phoneNumber || '-'}
-
号码归属{record.province ?? '-'} / {getCarrierLabel(record.carrier)}
+
号码归属{record.province ?? '-'} / {record.carrier ? : '-'}
通道组{channelGroupNames.join(' / ') || '-'}
收到的接入号{record.clientSrcId || '-'}
发送的接入号{sentAccessNumber || '-'}
diff --git a/src/apps/admin/sms-records/SmsRecordList.tsx b/src/apps/admin/sms-records/SmsRecordList.tsx index 26b5ca6..1bbfe99 100644 --- a/src/apps/admin/sms-records/SmsRecordList.tsx +++ b/src/apps/admin/sms-records/SmsRecordList.tsx @@ -1,10 +1,9 @@ import { Download } from 'lucide-react'; import type { ReactNode } from 'react'; import type { SmsMessageRecord } from '@/api/adminApi'; -import { Button, MoneyText, Pagination } from '@/components/ui'; +import { Button, CarrierTag, MoneyText, Pagination } from '@/components/ui'; import { formatCents } from '@/utils/currency'; import { - getCarrierLabel, getClock, getDate, getRecordStatus, @@ -84,7 +83,7 @@ export function SmsRecordList({

-
接收号码{record.phoneNumber}{record.province ?? '-'} · {getCarrierLabel(record.carrier)}
+
接收号码{record.phoneNumber}{record.province ?? '-'} {record.carrier ? : '-'}
计费{record.billingUnits} 条 / ¥{formatCents(record.amountCents)}{record.content.length} 字
发送通道{record.channel?.name ?? record.channelId ?? '-'}回执 {getTime(record.deliveredAt)}
diff --git a/src/apps/admin/sms-task-progress/TaskPhoneListModal.tsx b/src/apps/admin/sms-task-progress/TaskPhoneListModal.tsx index 1cadbc3..009c312 100644 --- a/src/apps/admin/sms-task-progress/TaskPhoneListModal.tsx +++ b/src/apps/admin/sms-task-progress/TaskPhoneListModal.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react'; import { Search } from 'lucide-react'; import { adminApi, type BatchTaskMessagePage } from '@/api/adminApi'; -import { Button, Input, Modal, Pagination, Select, Table, Tag } from '@/components/ui'; -import { carrierLabels, messageStatusLabel } from './taskModel'; +import { Button, CarrierTag, Input, Modal, Pagination, Select, Table, Tag } from '@/components/ui'; +import { messageStatusLabel } from './taskModel'; import type { SmsTask } from './taskTypes'; type TaskPhoneListModalProps = { @@ -55,7 +55,7 @@ export function TaskPhoneListModal({ task, onClose, onError }: TaskPhoneListModa columns={[ { key: 'phoneNumber', title: '手机号码', render: (item) => {item.phoneNumber} }, { key: 'province', title: '号码归属地', render: (item) => item.province || '-' }, - { key: 'carrier', title: '运营商', render: (item) => carrierLabels[item.carrier ?? '']?.label ?? item.carrier ?? '-' }, + { key: 'carrier', title: '运营商', render: (item) => item.carrier ? : '-' }, { key: 'status', title: '短信记录状态', render: (item) => {messageStatusLabel(item.status)} }, ]} data={data.items} diff --git a/src/apps/client/ClientSendDetailPage.tsx b/src/apps/client/ClientSendDetailPage.tsx index 99eb223..3c4bf51 100644 --- a/src/apps/client/ClientSendDetailPage.tsx +++ b/src/apps/client/ClientSendDetailPage.tsx @@ -3,6 +3,7 @@ import { FileText, Search, Smartphone } from 'lucide-react'; import { clientApi, type SmsMessageRecord } from '@/api/adminApi'; import { DateRangeInput, + CarrierTag, Input, Pagination, QueryPanel, @@ -33,13 +34,6 @@ const statusToneMap: Record = timeout: 'danger', }; -const carrierLabelMap: Record = { - mobile: '中国移动', - unicom: '中国联通', - telecom: '中国电信', - all: '三网', -}; - function getDate(value?: string | null) { return value ? value.slice(0, 10) : ''; } @@ -180,7 +174,6 @@ export function ClientSendDetailPage() {
) : visibleRows.map((record) => { const receipt = getReceipt(record); - const carrier = record.carrier ? carrierLabelMap[record.carrier] ?? record.carrier : '-'; const region = record.province ?? '-'; return ( @@ -199,7 +192,7 @@ export function ClientSendDetailPage() { - + diff --git a/src/components/ui/CarrierTag.tsx b/src/components/ui/CarrierTag.tsx index 11c3598..153b28e 100644 --- a/src/components/ui/CarrierTag.tsx +++ b/src/components/ui/CarrierTag.tsx @@ -18,7 +18,10 @@ export function normalizeCarrierTag(value?: string | null): CarrierTagValue | nu export function CarrierTag({ carrier, className = '', ...props }: HTMLAttributes & { carrier: string }) { const normalized = normalizeCarrierTag(carrier); - if (!normalized) return {carrier || '未知'}; + if (!normalized) { + const fallbackLabel = ['all', '三网'].includes(String(carrier ?? '').trim().toLowerCase()) ? '三网' : carrier || '未知'; + return {fallbackLabel}; + } const meta = carrierMeta[normalized]; return {meta.label}; } diff --git a/src/components/ui/RechargeReceiptDialog.tsx b/src/components/ui/RechargeReceiptDialog.tsx index e481744..d8d85fd 100644 --- a/src/components/ui/RechargeReceiptDialog.tsx +++ b/src/components/ui/RechargeReceiptDialog.tsx @@ -93,6 +93,10 @@ export function RechargeReceiptDialog({
交易状态
{isCorrection ? '冲正完成' : '充值完成'}
+
+
操作人员
+
{record.operatorName || (record.operatorId ? '未知操作人员' : '系统')}
+
diff --git a/src/styles/components.css b/src/styles/components.css index d6adbe8..471ba2d 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1094,6 +1094,12 @@ .ui-carrier-tag--telecom { background: #f0ecf7; border-color: #ddd1ea; color: #73538f; } .ui-carrier-tag--neutral { background: #eceff2; color: #596474; } +.ui-carrier-tags { + display: inline-flex; + flex-wrap: wrap; + gap: 4px; +} + .ui-tabs { display: grid; gap: var(--space-4); diff --git a/src/styles/global.css b/src/styles/global.css index c8ce9d9..07c6c97 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -4231,10 +4231,6 @@ grid-template-columns: repeat(4, minmax(0, 1fr)); } -.admin-signature-rank-grid { - grid-template-columns: minmax(0, 1fr); -} - .admin-workload-grid { display: grid; gap: var(--space-3); @@ -9088,7 +9084,6 @@ .admin-dashboard-main-grid, .admin-metric-grid, -.admin-signature-rank-grid, .admin-workload-grid, .enterprise-summary-grid, .enterprise-query-grid,
暂无发送记录
{record.phoneNumber}{carrier}{record.carrier ? : '-'} {region} {statusLabelMap[record.status] ?? record.status} {receipt.status}