diff --git a/api/src/operations/admin-operations.controller.ts b/api/src/operations/admin-operations.controller.ts index 0617ebd..fece270 100644 --- a/api/src/operations/admin-operations.controller.ts +++ b/api/src/operations/admin-operations.controller.ts @@ -99,6 +99,11 @@ export class AdminOperationsController { response.send(`\uFEFF${exported.content}`); } + @Get('messages/:id') + getMessage(@Param('id') id: string) { + return this.operations.getMessage(id); + } + @Get('message-segment-audits') messageSegmentAudits( @Query('messageId') messageId?: string, diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index 3f52fc7..4b26d84 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -18,6 +18,7 @@ function createPrismaMock() { }, smsMessageRecord: { findMany: jest.fn().mockResolvedValue([{ messageId: 'MSG-1' }]), + findUnique: jest.fn().mockResolvedValue({ id: 'message-1', messageId: 'MSG-1', submitRecords: [], receiptRecords: [], downstreamDeliveries: [] }), count: jest.fn().mockResolvedValue(51), groupBy: jest.fn().mockResolvedValue([{ status: 'delivered', _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }]), aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }), @@ -311,7 +312,7 @@ describe('OperationsService', () => { })); }); - it('paginates message records in PostgreSQL and limits heavy relations to the requested page', async () => { + it('paginates message summaries without preloading detail relations', async () => { const prisma = createPrismaMock(); const service = new OperationsService(prisma as never); @@ -326,7 +327,17 @@ describe('OperationsService', () => { skip: 25, take: 25, orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }], + select: expect.objectContaining({ + id: true, + content: true, + hasDrainageContent: true, + tenant: { select: { id: true, name: true } }, + }), })); + const call = prisma.smsMessageRecord.findMany.mock.calls.at(-1)?.[0]; + expect(call.select).not.toHaveProperty('submitRecords'); + expect(call.select).not.toHaveProperty('receiptRecords'); + expect(call.select).not.toHaveProperty('downstreamDeliveries'); expect(prisma.smsMessageRecord.count).toHaveBeenCalledWith({ where: expect.objectContaining({ tenantId: 'tenant-1' }), }); @@ -359,6 +370,21 @@ describe('OperationsService', () => { }); }); + it('loads heavy message relations only for one requested detail', async () => { + const prisma = createPrismaMock(); + const service = new OperationsService(prisma as never); + + await expect(service.getMessage('message-1')).resolves.toEqual(expect.objectContaining({ id: 'message-1' })); + expect(prisma.smsMessageRecord.findUnique).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'message-1' }, + include: expect.objectContaining({ + submitRecords: expect.any(Object), + receiptRecords: expect.any(Object), + downstreamDeliveries: expect.any(Object), + }), + })); + }); + it('returns the matched message record and the distinct uplink gateway message id to the client view', async () => { const prisma = createPrismaMock(); prisma.smsUplinkMessage.findMany.mockResolvedValue([{ diff --git a/api/src/operations/operations.service.ts b/api/src/operations/operations.service.ts index b6da780..1cb34df 100644 --- a/api/src/operations/operations.service.ts +++ b/api/src/operations/operations.service.ts @@ -48,6 +48,10 @@ export class OperationsService { return this.messagesQueries.listMessagesPage(query); } + async getMessage(id: string) { + return this.messagesQueries.getMessage(id); + } + async exportMessages(query: MessageQuery) { return this.messagesQueries.exportMessages(query); } diff --git a/api/src/operations/queries/messages.queries.ts b/api/src/operations/queries/messages.queries.ts index a60bda4..add4f59 100644 --- a/api/src/operations/queries/messages.queries.ts +++ b/api/src/operations/queries/messages.queries.ts @@ -45,44 +45,26 @@ async listMessagesPage(query: MessageQuery) { const [items, total] = await Promise.all([ this.prisma.smsMessageRecord.findMany({ where, - include: { + select: { + id: true, + tenantId: true, + applicationId: true, + channelId: true, + messageId: true, + phoneNumber: true, + carrier: true, + province: true, + content: true, + hasDrainageContent: true, + drainageDetection: true, + billingUnits: true, + amountCents: true, + status: true, + submitStatus: true, + queuedAt: true, tenant: { select: { id: true, name: true } }, application: { select: { id: true, name: true } }, - channel: { select: { id: true, name: true, srcId: true } }, - submitRecords: { - select: { - id: true, - submitId: true, - channelId: true, - channelGroupId: true, - channelGroupName: true, - gatewayMessageId: true, - submitStatus: true, - submittedAt: true, - createdAt: true, - channel: { select: { id: true, name: true } }, - channelGroup: { select: { id: true, name: true } }, - }, - }, - receiptRecords: { - select: { - id: true, - messageId: true, - gatewayMessageId: true, - receiptStatus: true, - rawStatus: true, - errorCode: true, - errorMessage: true, - deliveredAt: true, - createdAt: true, - channelId: true, - channel: { select: { id: true, name: true } }, - }, - }, - downstreamDeliveries: { - where: { deliveryType: 'receipt' }, - select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true }, - }, + channel: { select: { id: true, name: true } }, }, orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * pageSize, @@ -92,6 +74,53 @@ async listMessagesPage(query: MessageQuery) { ]); return { items, total, page, pageSize }; } + +async getMessage(id: string) { + const item = await this.prisma.smsMessageRecord.findUnique({ + where: { id }, + include: { + tenant: { select: { id: true, name: true } }, + application: { select: { id: true, name: true } }, + channel: { select: { id: true, name: true, srcId: true } }, + submitRecords: { + select: { + id: true, + submitId: true, + channelId: true, + channelGroupId: true, + channelGroupName: true, + gatewayMessageId: true, + submitStatus: true, + submittedAt: true, + createdAt: true, + channel: { select: { id: true, name: true } }, + channelGroup: { select: { id: true, name: true } }, + }, + }, + receiptRecords: { + select: { + id: true, + messageId: true, + gatewayMessageId: true, + receiptStatus: true, + rawStatus: true, + errorCode: true, + errorMessage: true, + deliveredAt: true, + createdAt: true, + channelId: true, + channel: { select: { id: true, name: true } }, + }, + }, + downstreamDeliveries: { + where: { deliveryType: 'receipt' }, + select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true }, + }, + }, + }); + if (!item) throw new NotFoundException('Message record not found'); + return item; + } async exportMessages(query: MessageQuery) { const items = await this.prisma.smsMessageRecord.findMany({ where: messageWhere(query), diff --git a/api/src/sms-config/admin-sms-config.controller.ts b/api/src/sms-config/admin-sms-config.controller.ts index e99e902..1b0a1d1 100644 --- a/api/src/sms-config/admin-sms-config.controller.ts +++ b/api/src/sms-config/admin-sms-config.controller.ts @@ -82,6 +82,21 @@ export class AdminSmsConfigController { return this.smsConfig.listSignatureOptions(tenantId); } + @Get('enterprise-signatures/:id') + getSignature(@Param('id') id: string) { + return this.smsConfig.getSignature(id); + } + + @Get('enterprise-signatures/:id/report-targets') + getSignatureReportTargets(@Param('id') id: string) { + return this.smsConfig.getSignatureReportTargets(id); + } + + @Get('drainage-infos/:id/report-targets') + getDrainageReportTargets(@Param('id') id: string) { + return this.smsConfig.getDrainageReportTargets(id); + } + @Post('enterprise-signatures') createSignature(@Body() body: CreateSmsSignatureDto) { return this.smsConfig.createSignature(body, { initialAuditStatus: 'approved' }); diff --git a/api/src/sms-config/signature.service.ts b/api/src/sms-config/signature.service.ts index fd876a3..2c36f9b 100644 --- a/api/src/sms-config/signature.service.ts +++ b/api/src/sms-config/signature.service.ts @@ -72,12 +72,13 @@ export class SmsSignatureService { private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService, ) {} - async listSignatures(queryOrTenantId?: string | SignatureListQuery) { + async listSignatures(queryOrTenantId?: string | SignatureListQuery, summaryOnly = false) { const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : (queryOrTenantId ?? {}); const signatureSort = query.signatureSort === 'asc' || query.signatureSort === 'desc' ? query.signatureSort : undefined; const signatures = await this.prisma.smsSignature.findMany({ where: { + id: query.signatureId, tenantId: query.tenantId, auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, @@ -105,12 +106,54 @@ export class SmsSignatureService { ] : undefined, }, - include: { - materials: true, - tenant: true, - application: true, - drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } }, - reportTasks: { include: { channel: true, drainageInfo: true } }, + select: { + id: true, + tenantId: true, + applicationId: true, + name: true, + purpose: true, + drainageInfo: true, + auditStatus: true, + reportStatus: true, + rejectReason: true, + materialVersion: true, + pendingReport: true, + reportChangedAt: true, + createdAt: true, + updatedAt: true, + materials: !summaryOnly, + tenant: { select: { id: true, name: true, code: true, status: true } }, + application: { select: { id: true, tenantId: true, name: true, status: true } }, + drainageItems: { + where: { auditStatus: { not: 'deleted' } }, + orderBy: { updatedAt: 'desc' }, + select: { + id: true, + siteName: true, + url: true, + remark: true, + reportValues: !summaryOnly, + auditStatus: true, + rejectReason: true, + submittedAt: true, + reviewedAt: true, + createdAt: true, + updatedAt: true, + }, + }, + reportTasks: { + select: { + id: true, + signatureId: true, + channelId: true, + carrier: true, + status: true, + approvedAt: true, + approvalScope: true, + reportType: true, + drainageItemId: true, + }, + }, reportBatchItems: { where: { batch: { status: { in: ['completed', 'partial_failed'] } } }, select: { reportType: true, materialVersion: true, snapshot: true }, @@ -130,7 +173,28 @@ export class SmsSignatureService { const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({ where: { applicationId: { in: applicationIds }, status: 'active' }, - include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } }, + select: { + applicationId: true, + group: { + select: { + status: true, + items: { + select: { + channel: { + select: { + id: true, + name: true, + carrier: true, + carriers: true, + status: true, + reportFields: { select: { status: true, reportType: true } }, + }, + }, + }, + }, + }, + }, + }, }) : []; const hasCommonDrainageFields = await this.prisma.commonReportField @@ -208,7 +272,7 @@ export class SmsSignatureService { createdAt: item.createdAt.toISOString(), updatedAt: item.updatedAt.toISOString(), })); - return { + const view = { ...signatureView, name: normalizeSmsSignature(signature.name), drainageInfo: { ...legacyPayload, links: drainageLinks }, @@ -330,6 +394,20 @@ export class SmsSignatureService { }), ), }; + if (!summaryOnly) return view; + const { + materials: _materials, + reportTasks: _reportTasks, + reportTargets: _reportTargets, + drainageReportTargets: _drainageReportTargets, + ...summary + } = view; + return { + ...summary, + drainageInfo: { + links: drainageLinks.map(({ reportValues: _reportValues, ...link }) => link), + }, + }; }); } @@ -365,12 +443,33 @@ export class SmsSignatureService { : undefined, }; const [items, total] = await Promise.all([ - this.listSignatures({ ...query, page, pageSize }), + this.listSignatures({ ...query, page, pageSize }, true), this.prisma.smsSignature.count({ where }), ]); return { items, total, page, pageSize }; } + async getSignature(id: string) { + const [item] = await this.listSignatures({ signatureId: id }); + if (!item || item.auditStatus === 'deleted') throw new NotFoundException('Signature not found'); + return item; + } + + async getSignatureReportTargets(id: string) { + const item = await this.getSignature(id); + return 'reportTargets' in item ? item.reportTargets ?? [] : []; + } + + async getDrainageReportTargets(id: string) { + const drainage = await this.prisma.smsDrainageInfo.findUnique({ + where: { id }, + select: { id: true, signatureId: true, auditStatus: true }, + }); + if (!drainage || drainage.auditStatus === 'deleted') throw new NotFoundException('Drainage info not found'); + const signature = await this.getSignature(drainage.signatureId); + return 'drainageReportTargets' in signature ? signature.drainageReportTargets?.[id] ?? [] : []; + } + listSignatureOptions(tenantId?: string) { return this.prisma.smsSignature.findMany({ where: { tenantId, auditStatus: { not: 'deleted' } }, diff --git a/api/src/sms-config/sms-config.contracts.ts b/api/src/sms-config/sms-config.contracts.ts index 7174686..2f183c6 100644 --- a/api/src/sms-config/sms-config.contracts.ts +++ b/api/src/sms-config/sms-config.contracts.ts @@ -136,6 +136,7 @@ export interface ApplicationListQuery { } export interface SignatureListQuery { + signatureId?: string; tenantId?: string; keyword?: string; status?: string; diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index a2aad5b..32e05b6 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -796,12 +796,12 @@ describe('SmsConfigService', () => { name: { contains: '签名' }, drainageItems: expect.objectContaining({ some: expect.objectContaining({ OR: expect.any(Array) }) }), }), - include: expect.objectContaining({ + select: expect.objectContaining({ materials: true, - tenant: true, - application: true, - drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } }, - reportTasks: { include: { channel: true, drainageInfo: true } }, + tenant: { select: { id: true, name: true, code: true, status: true } }, + application: { select: { id: true, tenantId: true, name: true, status: true } }, + drainageItems: expect.objectContaining({ where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } }), + reportTasks: expect.objectContaining({ select: expect.any(Object) }), reportBatchItems: expect.any(Object), }), orderBy: [{ name: 'asc' }, { id: 'asc' }], @@ -1215,6 +1215,21 @@ describe('SmsConfigService', () => { expect(prisma.smsDrainageInfo.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ submittedAt: expectedRange }) })); }); + it('returns a signature page summary without edit materials or report target arrays', async () => { + const prisma = createPrismaMock(); + prisma.smsSignature.count.mockResolvedValue(1); + const service = new SmsConfigService(prisma as never); + + const result = await service.listSignaturesPage({ page: 1, pageSize: 10 }); + + expect(result.total).toBe(1); + expect(result.items[0]).toEqual(expect.objectContaining({ id: 'sig-1', name: '【签名A】' })); + expect(result.items[0]).not.toHaveProperty('materials'); + expect(result.items[0]).not.toHaveProperty('reportTasks'); + expect(result.items[0]).not.toHaveProperty('reportTargets'); + expect(result.items[0]).not.toHaveProperty('drainageReportTargets'); + }); + it.each([ '【带 空格】', ' 【外部空格】', diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index acef63a..248dc71 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -123,6 +123,18 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy { return this.signatures.listSignaturesPage(query); } + async getSignature(id: string) { + return this.signatures.getSignature(id); + } + + async getSignatureReportTargets(id: string) { + return this.signatures.getSignatureReportTargets(id); + } + + async getDrainageReportTargets(id: string) { + return this.signatures.getDrainageReportTargets(id); + } + listSignatureOptions(tenantId?: string) { return this.signatures.listSignatureOptions(tenantId); } diff --git a/api/src/tenants/tenants.controller.ts b/api/src/tenants/tenants.controller.ts index 44df7b5..48b97fe 100644 --- a/api/src/tenants/tenants.controller.ts +++ b/api/src/tenants/tenants.controller.ts @@ -18,6 +18,11 @@ export class TenantsController { return this.tenants.listManagementRows(); } + @Get('options') + listOptions() { + return this.tenants.listOptions(); + } + @Get(':id') get(@Param('id') id: string) { return this.tenants.get(id); diff --git a/api/src/tenants/tenants.service.spec.ts b/api/src/tenants/tenants.service.spec.ts index 1b9fca5..532f257 100644 --- a/api/src/tenants/tenants.service.spec.ts +++ b/api/src/tenants/tenants.service.spec.ts @@ -44,6 +44,19 @@ function createPrismaMock() { } describe('TenantsService', () => { + it('returns lightweight non-deleted tenant options', async () => { + const prisma = createPrismaMock(); + const service = new TenantsService(prisma as never); + + await service.listOptions(); + + expect(prisma.tenant.findMany).toHaveBeenCalledWith({ + where: { status: { not: 'deleted' } }, + select: { id: true, name: true, code: true, status: true }, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + }); + }); + it('creates tenants with a real enterprise profile', async () => { const prisma = createPrismaMock(); const service = new TenantsService(prisma as never); diff --git a/api/src/tenants/tenants.service.ts b/api/src/tenants/tenants.service.ts index 2470f64..a7d8adb 100644 --- a/api/src/tenants/tenants.service.ts +++ b/api/src/tenants/tenants.service.ts @@ -44,6 +44,14 @@ export class TenantsService { }).then((items) => items.map(withEnterpriseProfile)); } + listOptions() { + return this.prisma.tenant.findMany({ + where: { status: { not: 'deleted' } }, + select: { id: true, name: true, code: true, status: true }, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + }); + } + async listManagementRows() { const sinceToday = startOfToday(); const [tenants, accounts, todaySpendGroups, todayRefundGroups] = await Promise.all([ diff --git a/docs/enterprise-signature-loading-optimization-plan-20260902.md b/docs/enterprise-signature-loading-optimization-plan-20260902.md new file mode 100644 index 0000000..87e09f4 --- /dev/null +++ b/docs/enterprise-signature-loading-optimization-plan-20260902.md @@ -0,0 +1,485 @@ +# 企业签名及高频查询页面加载性能整改方案 + +日期:2026-09-02 +范围:运营端与客户端高频查询、分页列表及详情加载页面 +状态:代码整改已实施,待真实测试环境性能验收与发布 +原则:先复测基线、再修改;页面继续使用真实 API、PostgreSQL 和报备数据,不引入 mock、静态数据或 localStorage 兜底。 + +## 1. 问题与诊断结论 + +用户可见现象是点击“查询”或“重置”后需要等待数秒,尤其从非第一页重置时更明显。 + +本次只读诊断确认: + +- 页面每次加载签名列表时,通过同一个 `Promise.all` 同时请求企业签名分页、全部企业、全部企业应用选项;列表必须等待三者全部完成后才更新。 +- “查询”和“重置”会直接调用一次 `loadData`,同时修改 `page`;当页码从非 1 变为 1 时,监听 `page` 的 effect 还会再次调用 `loadData`,存在一项用户操作触发两整组请求的路径。 +- 测试环境 Nginx 访问日志已经出现同一秒两次相同签名分页请求、两次企业请求和两次应用选项请求的真实记录。 +- HTTP 304 只能减少响应体传输,后端仍需完成鉴权、查询、序列化和 ETag 判断,不能消除重复请求造成的服务端工作。 +- 测试环境当前只有 26 条签名、114 条报备任务、18 个应用、2 个企业和 51 条路由规则;`pg_stat_statements` 中相关 SQL 平均约 0.02~0.35 ms,当前没有证据表明 PostgreSQL 或数据量是数秒等待的主因。 +- 当前签名列表接口仍有过度取数和重复内存筛选,现阶段不是首要瓶颈,但随签名、引流项、通道和报备任务增长会放大,应作为第二阶段治理。 + +因此,本轮性能问题按优先级归因为: + +1. 前端重复触发和无关选项重复加载; +2. 列表、选项的成功/失败与 loading 状态耦合,整体等待最慢请求; +3. 列表接口返回并计算了列表首屏不需要的完整关联数据; +4. 缺少请求级耗时观测,现有 Nginx 默认日志只能按秒观察完成时间,不能直接分解网关、应用和数据库等待。 + +## 2. 优化目标 + +### 2.1 功能目标 + +- 保持企业名称、企业应用、签名、引流信息四项查询语义不变。 +- 保持签名排序、分页、展开引流信息、编辑、删除和两类报备状态弹窗使用真实后端数据。 +- 不改变三网报备状态及“已通过通道数/总通道数”的业务口径。 +- 不因性能优化丢失“放弃报备”、待生成明细、动态报备字段、审核状态或删除过滤。 + +### 2.2 性能与请求目标 + +- 首次进入页面:最多请求一次签名分页、一次企业选项、一次应用选项,不出现重复调用。 +- 查询、重置、翻页和排序:每次只请求一次签名分页接口,不重新请求企业/应用选项。 +- 从任意页重置到第一页:只产生一次第一页签名请求。 +- 在测试环境稳定网络下,签名分页 API 服务端 P95 不高于 500 ms,按钮操作到列表稳定显示 P95 不高于 1 s;若网络耗时本身超过目标,报告必须拆分服务端时间与传输时间。 +- 列表页每页 10 条时,响应体不再包含完整报备任务、完整通道、报备字段、材料明细和报备状态弹窗目标集合。 + +以上数值是验收目标,不以单次最快结果代替 P95,也不以 PostgreSQL 单条 SQL 耗时代替端到端耗时。 + +## 3. 第一阶段:前端请求收敛 + +### 3.1 拆分列表数据与表单选项 + +将当前 `loadData` 拆为两个职责: + +- `loadSignaturePage(queryState)`:只加载分页签名列表; +- `loadFormOptions()`:只加载企业和应用选项。 + +`loadFormOptions` 在页面首次挂载时执行一次。查询、重置、翻页、签名排序、签名/引流保存后的列表刷新均不得重新请求选项。 + +若页面长时间打开后需要保证新增企业或应用立即可选,采用以下任一显式策略,而不是绑在每次列表刷新上: + +- 打开“添加签名”弹窗时按 TTL 判断是否刷新选项;或 +- 企业/应用发生创建、删除、状态变更后,通过统一缓存失效事件刷新。 + +### 3.2 建立单一列表请求入口 + +推荐使用一个已应用查询状态作为列表请求的唯一事实来源: + +```ts +type SignatureListState = { + filters: { + enterpriseKeyword: string; + applicationKeyword: string; + signatureKeyword: string; + drainageKeyword: string; + }; + page: number; + signatureSort: 'asc' | 'desc'; + revision: number; +}; +``` + +列表 effect 只依赖该状态。各操作只更新状态,不再同时手工调用加载函数: + +- 查询:写入 trim 后的 filters、`page=1`,并递增 revision; +- 重置:清空输入和已应用 filters、`page=1`,并递增 revision; +- 翻页:只更新 page; +- 排序:更新 signatureSort、`page=1`,并递增 revision; +- 保存、删除、报备状态修改成功:只递增 revision。 + +revision 用于解决“当前已经在第一页,重复点击查询/重置仍需主动刷新”的场景。这样可以避免 `setPage(1)` 与直接 `loadData()` 并存造成双重触发。 + +### 3.3 处理竞态与加载状态 + +- 每次列表请求分配递增序号或使用 `AbortController`;只允许最后一次请求更新 `signatures`、`total` 和错误状态。 +- 组件卸载、查询条件变化或连续点击时取消旧请求;如果现有 HTTP 封装暂不支持 AbortSignal,至少先实现 latest-request-wins 序号保护。 +- 将 `listLoading`、`optionsLoading` 和 mutation loading 分开;选项加载失败不能清空已成功返回的签名列表。 +- 查询/重置发出后可以禁用对应按钮或合并同一状态请求,避免用户连续点击制造并发风暴。 +- 列表请求失败时保留上一次成功数据并显示明确错误;不得回退到本地静态数据。 + +### 3.4 选项接口瘦身 + +现有应用选项接口已经只查询 `id/tenantId/name/status`,可以保留其 URL,但前端响应类型应改为专用 `EnterpriseApplicationOption`,避免继续声明成完整应用对象。 + +企业选项不得继续复用返回企业认证材料和完整企业资料的 `/admin/tenants`。新增只读轻量接口,例如: + +```http +GET /api/admin/tenant-options +``` + +建议响应字段仅包含: + +```json +{ + "id": "tenant-id", + "name": "企业名称", + "code": "enterprise-code", + "status": "active" +} +``` + +服务端直接过滤 `status != deleted`,前端不再下载后自行过滤。该接口只用于选择器,不替代企业管理详情接口。 + +## 4. 第二阶段:精简签名列表接口 + +### 4.1 拆分列表、编辑详情和报备目标 + +当前 `/admin/enterprise-signatures?page=...` 同时承担列表展示、编辑表单和报备状态弹窗所需数据,导致列表查询加载完整关联树。建议拆为: + +1. `GET /api/admin/enterprise-signatures`:分页列表摘要; +2. `GET /api/admin/enterprise-signatures/:id`:签名编辑详情,在点击“编辑”时加载; +3. `GET /api/admin/enterprise-signatures/:id/report-targets`:签名通道报备目标,在打开签名“报备状态”弹窗时加载; +4. `GET /api/admin/drainage-infos/:id/report-targets`:单条引流信息的通道报备目标,在打开引流“报备状态”弹窗时加载。 + +新接口先以兼容方式增加,前端切换完成并通过真实回归后,再移除列表响应中的重字段,避免前后端一次性强耦合发布。 + +### 4.2 列表摘要 DTO + +列表每条记录只保留表格和展开行直接需要的字段: + +- 签名:`id`、`tenantId`、`applicationId`、`name`、`auditStatus`; +- 待生成信息:`pendingReportDetailCount`,必要时保留 `pendingReport`、`pendingReportMaterialVersion`、`pendingReportBlockedReason`; +- 企业:`tenant: { id, name }`; +- 应用:`application: { id, name, status } | null`; +- 签名三网汇总:`carrierReportSummary.mobile/unicom/telecom`,每项仅 `status/approved/total`; +- 引流列表:`id`、`url`、`auditStatus`,以及编辑/错误提示确实要展示的最小字段; +- 引流三网汇总:按引流 ID 返回 `status/approved/total`。 + +列表响应明确不返回: + +- `materials` 完整数组; +- `reportTasks` 原始任务数组; +- `reportTargets` 和 `drainageReportTargets`; +- 完整 `SmsChannel`、`ChannelReportField` 或 `SmsApplication` 对象; +- 报备状态弹窗才使用的 `approvedAt`、任务 ID 和通道详情; +- 编辑表单才需要的签名/引流 `reportValues` 和文件材料明细。 + +如果产品确认展开引流行必须直接进入编辑且不能接受一次详情请求,则可以在列表保留引流 `remark/reportValues`;但必须通过响应体积和端到端时间对比证明这一取舍。默认方案是点击编辑时按 ID 加载详情。 + +### 4.3 Prisma 查询投影 + +签名分页查询由 `include: true` 改成显式 `select`,避免列扩张后接口无意自动变重。原则如下: + +- `SmsSignature` 只选择列表 DTO 使用的标量字段; +- `tenant` 只选择 `id/name`; +- `application` 只选择 `id/name/status`; +- `drainageItems` 只选择列表展开行字段,并继续在数据库过滤 `auditStatus != deleted`; +- 报备任务只选择汇总计算所需的 `signatureId/channelId/carrier/status/approvalScope/reportType/drainageItemId`; +- 路由只选择 `applicationId`、有效通道 ID、通道名称/状态和运营商集合;仅在判断引流适用范围确有必要时选择活动报备字段的 `reportType`,不读取完整报备字段对象; +- 待生成明细计算若依赖报备批次快照,只选择当前资料版本、已完成/部分失败批次中的 `reportType/materialVersion/businessKeys`,不得返回整个 snapshot 给前端。 + +分页 `items` 与 `count` 可以并行,但二者必须共用同一个 where 构造函数,防止列表数量与 total 条件漂移。 + +### 4.4 消除内存中的重复扫描 + +当前汇总逻辑会针对每条签名、每个引流项和每个运营商反复对 routes、channels、reportTasks 执行 `filter/find`。改为一次建立索引: + +- `channelsByApplicationId`; +- `signatureTaskBySignatureChannelCarrier`; +- `drainageTasksBySignatureAndItem`; +- `applicableDrainageChannelsByApplicationId`; +- `generatedTargetsBySignatureId`。 + +随后按签名线性组装列表 DTO,目标复杂度从多层重复扫描收敛为 O(签名 + 路由 + 任务 + 引流项)。汇总函数仍复用统一的 `summarizeReportStatuses`,不能复制一套不同的状态规则。 + +“某运营商下所有通道均为放弃报备,则汇总为放弃报备”的现有口径必须纳入专项回归,不允许因查询改写退回未报备或不适用。 + +### 4.5 数据库索引策略 + +当前数据量和统计不支持“缺索引导致数秒等待”的结论,因此第一版不得盲目新增索引。接口投影完成后,用接近目标规模的隔离数据执行 `EXPLAIN (ANALYZE, BUFFERS)`,只在出现顺序扫描或排序落盘且确实影响 P95 时增加索引。 + +候选组合仅作为验证方向: + +- `SmsSignature(auditStatus, name, id)`,以及既有 tenant/application 外键索引; +- `SmsDrainageInfo(signatureId, auditStatus, updatedAt)`; +- `ChannelSignatureReportTask(signatureId, reportType, drainageItemId, channelId, carrier)`; +- `ChannelRouteRule(applicationId, status)`。 + +实施前必须先核实现有索引、唯一约束和写入频率;任何新增索引都需要 migration、回滚说明和写入开销评估。 + +## 5. 可观测性补充 + +为避免以后只能从整秒日志推断,测试环境应增加不含敏感参数的耗时观测: + +- Nginx access log 增加 `$request_time` 和 `$upstream_response_time`; +- API 记录路由模板、状态码、总耗时和请求 ID,不记录 Cookie、Authorization、签名内容、引流号码或材料内容; +- 对签名分页服务分段记录数据库查询、汇总计算和序列化耗时; +- 浏览器验收记录 Resource Timing 中的 TTFB、下载和总耗时。 + +日志格式调整属于部署配置变更,实施前仍需按环境边界建立并验证恢复资产。若不希望修改全局 Nginx 格式,可先在测试环境通过临时只读压测客户端和 API 计时日志完成基线。 + +## 6. 测试方案 + +### 6.1 前端自动化 + +新增请求计数与竞态用例: + +| 场景 | 预期 | +| --- | --- | +| 首次进入页面 | 签名分页、企业选项、应用选项各 1 次 | +| 第一页点击查询 | 仅签名分页 1 次 | +| 第三页点击重置 | 仅第一页签名分页 1 次,不产生 effect 重复请求 | +| 第一页重复点击重置 | 每次操作至多产生 1 次签名分页请求 | +| 翻页或切换签名排序 | 仅签名分页 1 次,沿用已应用筛选条件 | +| 快速连续执行两次查询 | 仅最后一次响应可更新列表 | +| 企业选项加载失败 | 签名列表仍可展示,添加/编辑弹窗明确提示选项失败 | +| 保存、删除、修改报备状态成功 | 只刷新签名分页,不刷新企业/应用选项 | + +测试 stub 仅用于自动化回归,不作为真实功能验收结果。 + +### 6.2 API 契约与服务测试 + +- 分页摘要 DTO 只包含白名单字段,明确断言不返回完整材料、原始任务和通道关联树。 +- 新旧实现对相同真实数据的签名三网汇总、引流三网汇总、approved/total 和 total 完全一致。 +- 详情接口按 ID 返回编辑所需动态字段值;不存在、已删除和无权限对象返回正确状态码。 +- 两个 report-targets 接口返回真实目标通道和任务状态,保存仍写入统一报备任务/记录链路。 +- 企业和应用选项接口过滤 deleted,字段严格为专用 option DTO。 +- 查询条件、分页、排序、引流命中展开和删除过滤全部保持原行为。 + +### 6.3 真实后端与浏览器验收 + +- 使用测试环境真实登录态和真实 API 操作查询、重置、分页、排序、展开、编辑入口和报备状态入口;不执行保存、审核、删除或短信发送,除非另行授权。 +- 浏览器 Network 验证请求数量、URL 参数、先后顺序、响应大小和耗时;Console 无 error/warn。 +- PostgreSQL 核对列表数量及三网汇总抽样,确认页面与数据库/报备任务一致。 +- 分别记录冷加载、热加载、第一页重置、非第一页重置各至少 20 次,输出 P50/P95,不以单次结果验收。 +- 使用隔离测试数据库或可完整回滚的事务构造放大数据;禁止向共享业务库注入无法清理的假企业、签名、通道或报备任务。 + +## 7. 实施顺序与提交拆分 + +建议拆成三个可独立回退的提交: + +1. `perf: deduplicate enterprise signature list requests` + - 前端列表/选项加载拆分、单一触发入口、竞态保护及请求计数测试。 +2. `perf: add lightweight enterprise signature query contracts` + - 企业选项接口、签名列表摘要 DTO、详情和报备目标接口、API 契约测试。 +3. `perf: switch enterprise signature page to lazy details` + - 页面接入轻量列表与按需详情,补真实浏览器和性能回归记录。 + +不建议把前端去重、接口契约变更和数据库索引迁移混在一个提交中。每个提交都应更新 `docs/system-functional-test-cases.md` 和 `docs/testing-progress.md`,但测试进度只能记录实际执行并取得证据的结果。 + +## 8. 发布与回退 + +- 本方案本身不授权部署。实施后是否部署测试或预生产,继续以用户当次明确指令为准。 +- 测试环境部署前必须重新建立独立恢复资产,并通过 `pg_restore --list`、tar 可读性和 `sha256sum -c`;不得复用旧恢复点充当本次发布资产。 +- 第一阶段无数据库结构变更,可通过回退前端/API运行目录恢复。 +- 第二阶段先增加兼容接口、后切换前端;回退时可以先回退前端而保留新增只读接口。 +- 如最终新增数据库索引,migration 必须可单独识别;回退前确认索引回退不会长时间锁表。 +- 发布后检查 `.deployed-commit`、相关 systemd 服务、API/Gateway health、Redis Stream pending/lag、发布窗口错误日志、实际前端资源哈希以及浏览器控制台和真实页面效果。 +- 全程禁止发送、补发或重投短信;禁止修改余额、通道和客户配置,除非另有明确授权。 + +## 9. 完成标准 + +以下条件全部满足后,才可把优化标记为完成: + +- 请求计数自动化和真实浏览器 Network 证据均证明查询/重置/翻页/排序每次只有一个签名分页请求; +- 企业和应用选项不再随列表操作重复加载; +- 快速连续操作不存在旧响应覆盖新条件; +- 列表响应字段和体积符合白名单,编辑及报备状态改为按需加载且业务能力完整; +- 新旧汇总结果在真实测试数据上一致,放弃报备口径专项通过; +- 端到端 P95 达到目标,或对未达到部分提供可复现的服务端/网络分段证据; +- 自动化、真实 API/PostgreSQL、浏览器页面和控制台验收完成; +- 测试用例、测试进度和部署证据同步更新,没有把构建通过或 mock 测试冒充线上功能完成。 + +## 10. 高频查询页面扩展检查 + +### 10.1 检查范围和证据边界 + +在企业签名问题定位后,对 `src/apps/admin`、`src/apps/client` 以及相关 API 查询服务进行了同模式静态检查,重点寻找: + +- `setPage(1)` 与手工 `loadData/loadXxx` 同时存在,导致分页 effect 再请求一次; +- 筛选控件直接进入 effect 依赖,导致输入或选择改变即请求; +- 查询按钮在自动请求后再次调用相同接口; +- 列表请求与企业、应用、通道、统计、详情等低频数据绑定在同一个 `Promise.all`; +- 简单列表加载完整关联对象或为每行追加请求; +- 缺少 applied filters、请求取消或 latest-request-wins,导致未确认条件生效、并发覆盖或 loading 抖动。 + +2026-09-02 测试环境 Nginx 日志只捕获到企业签名页面的真实重复请求,同一秒内存在两次相同签名分页、两次企业和两次应用选项请求。其他页面下述结论来自当前代码路径和数据库统计,属于实施候选;修改前必须使用真实登录态逐页复现和计时,不能把静态审计直接当作线上慢请求实测。 + +### 10.2 P0:优先整改页面 + +| 页面 | 当前问题 | 请求放大或数据风险 | 整改方向 | 验收重点 | +| --- | --- | --- | --- | --- | +| 运营端企业签名管理 | 列表、企业和应用选项耦合;查询/重置同时改页码并手工加载 | 非第一页最多两组、共 6 个请求 | 按第 3~4 节实施 | 查询/重置/翻页/排序各 1 个列表请求 | +| 运营端企业模板管理 | 模板列表、企业、应用、签名选项置于同一 `Promise.all`;查询/重置存在分页双触发 | 常规 4 个请求,非第一页最多 8 个 | 选项首次加载;列表单独刷新;模板列表摘要与编辑详情按需拆分 | 非首次查询只允许 1 个模板分页请求 | +| Gateway 提交异常 | `keyword/status/applicationId/channelId/page` 都进入加载 effect;每次加载还重新获取全部应用和通道;查询按钮再次调用 | 每次输入或选择最多 3 个接口,连续输入形成请求风暴 | 引入 draft/applied filters;选项首次加载;查询按钮只更新 applied state;取消旧请求 | 输入和控件改变不发请求,点击查询后只发 1 个异常分页请求 | +| 下游投递记录 | 所有筛选条件直接触发 effect;查询按钮又直接加载;统计、记录、应用、企业绑定 | 每次筛选 4 个接口;另有独立 3 秒重投任务轮询 | 记录、统计、选项三类请求拆分;查询只触发记录和必要统计;轮询仅更新重投任务 | 输入不请求;查询最多 1 个记录和 1 个统计请求;轮询不刷新主列表/选项 | +| 通道管理 | 分页列表与全局发送质量绑定;返回当前页后逐通道查询连接状态 | 每页 10 条时约 12 个请求,分页双触发时可能约 24 个 | 后端为当前页批量返回连接摘要和质量摘要,或提供批量摘要接口;消除逐行请求 | 每次列表操作不超过 2 个请求,不允许 N+1 | +| 运营端短信记录 | 前端触发基本正常,但分页列表为每条短信加载提交记录、回执记录及下游回执投递 | 真实主表约 11.95 万条,关联表合计约 37.6 万条;主记录查询历史最大约 575 ms | 按第 10.3 节拆分列表摘要和详情 | 列表响应不含详情数组;点击一条记录才加载该记录详情 | +| 通道报备详情 | 每次查询/分页同时加载通道、任务、全部报备记录、全部签名、通道字段和字段库 | 一次最多 6 个接口,且包含全量签名等重接口 | 页面基础数据首次加载;报备任务分页独立;记录和材料点击时按需加载 | 翻页/查询只发 1 个报备任务分页请求 | + +对应主要代码位置: + +- `src/apps/admin/AdminEnterpriseTemplatesPage.tsx` +- `src/apps/admin/AdminGatewaySubmitExceptionsPage.tsx` +- `src/apps/admin/AdminDownstreamDeliveriesPage.tsx` +- `src/apps/admin/AdminChannelsPage.tsx` +- `src/apps/admin/AdminSmsRecordsPage.tsx` +- `api/src/operations/queries/messages.queries.ts` +- `src/apps/admin/AdminChannelReportPage.tsx` + +### 10.3 短信记录列表专项整改 + +测试环境只读统计基线: + +| 数据表 | 估算行数 | 表及索引总大小 | +| --- | ---: | ---: | +| `SmsMessageRecord` | 119,510 | 162 MB | +| `SmsSubmitRecord` | 130,769 | 148 MB | +| `SmsReceiptRecord` | 124,629 | 117 MB | +| `CmppDownstreamDelivery` | 120,607 | 180 MB | + +当前 `listMessagesPage` 对每页 25 条短信同时加载: + +- 企业、应用和通道; +- 全部 `submitRecords` 及其通道、通道组; +- 全部 `receiptRecords` 及其通道; +- 回执类型的 `downstreamDeliveries`。 + +详情弹窗另外按需加载分片审计,但其余大部分详情已经由列表提前取得。整改为: + +1. `GET /api/admin/operations/messages` 只返回列表 DTO:消息 ID、企业/应用/通道摘要、提交时间、手机号、地区、运营商、计费条数、金额、最终状态、含引流标识,以及列表确实展示的少量状态字段; +2. 新增 `GET /api/admin/operations/messages/:id`,只在打开详情时返回短信正文、引流检测位置、提交记录、回执记录、最终回执时间和下游投递结果; +3. 分片审计可以保留独立接口,也可以与详情并行加载,但不能让分片审计失败阻断基本详情; +4. 列表和详情使用不同 DTO,禁止继续用一个含全部可选字段的 `SmsMessageRecord` 类型掩盖过度返回; +5. 列表查询使用显式 Prisma `select`,详情关联按 `messageRecordId` 精确查询; +6. 打开不同记录时取消上一条详情请求或使用请求序号保护;关闭弹窗后不得把迟到响应写入下一条记录; +7. 通过 `EXPLAIN (ANALYZE, BUFFERS)` 核验日期倒序分页及企业、应用、通道、手机号、状态、运营商、含引流条件。索引只按执行计划增加,不凭表规模猜测; +8. 导出继续使用专用字段投影和流式/分批策略,不复用详情 DTO,不因列表瘦身改变导出业务字段。 + +短信记录专项验收: + +- 查询、重置、翻页各只有 1 个分页请求; +- 每页 25 条响应不含 `submitRecords/receiptRecords/downstreamDeliveries` 数组; +- 打开详情只请求当前消息详情和分片审计; +- 含引流高亮、最终回执时间、提交/回执链路与改造前一致; +- 使用真实 PostgreSQL 分别抽查成功、失败、无回执、多分片、含引流和历史未检测记录; +- 记录改造前后响应字节数、API P50/P95、数据库 buffers 和浏览器可交互时间。 + +### 10.4 P1:分页双触发和全量刷新页面 + +| 页面 | 当前问题 | 整改要求 | +| --- | --- | --- | +| 企业应用管理 | 查询/重置同时 `setPage(1)` 和手工加载;非第一页可能重复 | 使用单一 applied query state;每次只请求 1 次应用分页 | +| 运营端上行短信 | 查询/重置无页码分支,非第一页会手工加载并由 effect 再加载 | 改为 applied filters + 单一 effect,参考客户端上行短信 | +| 充值记录 | 每次查询同时重新加载全部企业、全部账户和充值分页;非第一页还可能双触发 | 企业和账户首次/失效时加载,查询只请求充值分页 | +| 企业黑名单 | 每次查询都重新加载企业、应用选项 | 选项首次加载,黑名单列表独立刷新 | +| 通道组管理 | 查询按钮重新获取全部通道组和通道,但筛选本身在前端完成 | 明确改为服务端分页查询,或保留客户端筛选并移除无意义查询按钮请求;不能两套语义并存 | +| 运营端数据分析 | 查询签名质量时同时刷新热力图和未报备签名 | 三个数据块独立加载、独立错误和 loading,筛选哪个区域只刷新哪个接口 | + +上述页面统一使用第 3.2 节的 applied state/revision 模式,禁止通过到处增加 `if (page !== 1)` 继续堆叠分支。 + +### 10.5 P1:控件改变即请求页面 + +以下页面的筛选控件直接进入请求 effect,查询按钮不能真正控制请求时机: + +| 页面 | 当前行为 | 整改要求 | +| --- | --- | --- | +| 签名审核 | 关键词每次输入、状态和日期改变即请求完整未分页签名接口;查询按钮还会再请求 | 拆分 draft/applied;改用审核分页摘要接口;详情按需加载 | +| 模板审核 | 关键词、状态、日期改变即请求未分页模板接口;可见查询按钮没有独立触发语义 | 查询/重置显式触发;模板审核列表分页化;详情按需加载 | +| 利润报表 | 日期、维度、企业、应用、通道改变即查询;查询按钮重复调用 | 控件只修改 draft;点击查询后更新 applied;选项继续首次加载 | +| 质量报表 | 同上 | 同上,并保持统计汇总与分页条件一致 | +| 对账报表 | 同上 | 同上,并避免企业选择后应用列表更新触发业务查询 | +| 客户端签名 | 关键词/应用改变后 300 ms 自动请求,并重复加载应用选项 | 按现有平台统一“查询+重置”;应用选项首次加载;列表单请求 | +| 客户端模板 | 关键词改变后 300 ms 同时加载应用、模板和签名选项 | 查询+重置显式触发;选项首次加载;模板单请求 | + +如果个别页面产品上确实需要即时搜索,应单独批准并满足:最少字符数、明确 debounce、取消旧请求、选项不重载、相同参数请求合并。不得把当前偶然的 effect 行为视为已确认产品需求。 + +### 10.6 当前相对规范页面 + +下列页面已经采用输入条件/已应用条件分离,或通过明确页码分支避免同型双请求,可作为整改参考,但仍需补充请求取消和真实计时: + +- 运营端短信记录的前端查询触发; +- 运营端短信任务进度; +- 客户端批量任务; +- 客户端短信发送详情; +- 客户端上行短信; +- 运营端系统日志和通讯交互日志; +- 手机号段库; +- 用户管理。 + +此处“相对规范”只表示没有发现本轮同型触发问题,不代表其后端查询、索引和响应体已经完成性能验收。 + +### 10.7 同步发现的查询/重置功能缺口 + +以下问题不属于“查询慢”,但在统一查询组件时应一并修复并增加用例: + +- 运营端报备任务:第一页点击重置只清空控件,不重新加载默认结果; +- 运营端报备记录:第一页点击重置只清空控件,不重新加载默认结果; +- 运营端报备批次:重置只清空控件,不保证重新加载第一页; +- 全局黑名单、敏感词等页面也存在重置只改输入、不明确重新应用默认查询的语义差异。 + +整改后所有带“查询+重置”的列表页统一满足:输入控件不立即请求;查询应用当前条件并回第一页;重置清空输入和已应用条件并只加载一次第一页;分页只使用上次确认的 applied filters。 + +## 11. 全平台实施批次 + +为降低回归和发布风险,建议按以下批次实施: + +### 批次 A:共同基础能力 + +- 提供共享的分页查询状态模式或 hook; +- HTTP 客户端支持 `AbortSignal`,或统一 latest-request-wins; +- 建立企业、应用、通道轻量选项 DTO/接口; +- 建立请求数量测试辅助工具,不修改真实产品为 mock。 + +### 批次 B:客户管理高频页 + +- 企业签名; +- 企业模板; +- 企业应用; +- 企业黑名单; +- 充值记录。 + +### 批次 C:数据详单与运行处置页 + +- 短信记录列表/详情拆分; +- 上行短信; +- Gateway 提交异常; +- 下游投递记录; +- 通道管理 N+1。 + +### 批次 D:报备和审核页 + +- 通道报备详情; +- 签名审核; +- 模板审核; +- 报备任务、记录、批次的重置语义。 + +### 批次 E:报表与客户端页 + +- 利润、质量、对账报表; +- 数据分析各区块解耦; +- 客户端签名、模板查询交互统一。 + +每个批次独立提交、测试、建立恢复资产和发布,不把所有页面一次性混为不可回退的大版本。 + +## 12. 全平台验收矩阵 + +每个整改页面至少记录以下数据: + +| 指标 | 必须记录的内容 | +| --- | --- | +| 请求数量 | 首次加载、查询、重置、非第一页重置、翻页、排序/切换筛选各自产生的接口数 | +| 浏览器耗时 | 至少 20 次的 P50/P95、TTFB、下载和列表稳定显示时间 | +| API 耗时 | 路由总耗时、状态码、响应字节数,区分列表与详情 | +| PostgreSQL | 真实查询的执行时间、rows、buffers、执行计划和表规模 | +| 正确性 | total、当前页数据、排序、删除过滤、状态汇总与数据库一致 | +| 竞态 | 快速连续查询、切页后查询、关闭/切换详情时旧响应不得覆盖新状态 | +| 错误隔离 | 选项、列表、统计、详情中的单项失败不应无差别清空其他成功区域 | +| 浏览器质量 | Console 无新增 error/warn,页面无长时间空白、闪烁或错误 loading | + +全平台完成标准是在真实测试环境通过上述矩阵;静态代码检查、单元测试替身、HTTP 304、单条 SQL 很快或一次浏览器截图均不能单独证明查询性能整改完成。 + +## 13. 2026-09-02 实施记录 + +本轮已完成代码侧整改: + +- 企业签名、企业模板、企业应用、充值记录、运营端上行、Gateway提交异常、下游投递、企业黑名单和客户端签名/模板已拆分列表与低频选项请求,并消除已确认的非第一页重复请求或控件即时查询路径;列表竞态采用latest-request-wins保护。 +- 新增`GET /api/admin/tenants/options`轻量企业选项;高频选择器改用企业/应用专用options接口,deleted由后端过滤。 +- 企业签名分页响应移除材料、原始报备任务、签名/引流报备目标和编辑用动态字段;新增签名详情、签名报备目标、引流报备目标接口,页面在点击编辑或报备状态后按ID加载真实数据。 +- 短信记录分页改为显式摘要投影,不再加载三类详情关联;新增单条详情接口,详情与分片审计并行按需加载,并使用请求序号阻止切换/关闭后的迟到响应污染。 +- 通道管理移除逐通道连接查询,直接使用分页接口已有的`connectionStates`;通道报备详情将基础配置与任务列表解耦,查询、重置和翻页只刷新分页任务。 +- 签名/模板审核、利润/质量/对账报表和三类报备页面完成draft/applied筛选语义与重置修正。 +- 批量导入映射方案动作和企业签名两级操作按钮完成本次UI整改。 + +自动化证据:前端11文件51项、API 51套590项通过;前端TypeScript、API TypeScript构建、Vite生产构建和`git diff --check`通过。Vite仍只有既有Chart分块超过500kB提示。 + +未完成项属于环境验收而非代码完成:本轮未获部署授权,未建立恢复资产、未部署测试/预生产,也未以真实登录态采集20次P50/P95、响应字节、Resource Timing或数据库执行计划。第2.2、6.3、12节的真实环境指标必须在后续明确授权部署后执行,当前不得标记为线上性能验收通过。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 4989631..13c8473 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5019,3 +5019,18 @@ npm run verify:phase8 | TC-REPORT-WORKBENCH-009 | 导入命中已有签名且用途列未映射或为空 | 识别为补资料;未提供字段保持原值,提供的动态字段覆盖同名值并追加新字段;用途不得被空字符串清空;审核前不改真实签名 | | TC-REPORT-WORKBENCH-010 | 在状态记录按批次、操作人、状态、入口、对象和时间搜索 | 返回真实状态记录及操作人;可追溯人工修改来源;分页、空数据、失败和历史无入口记录均正确展示 | | TC-REPORT-WORKBENCH-011 | 桌面及390px窄屏查看四页和状态弹窗 | 桌面表格可扫描;窄屏核心主体、状态、今日发送和操作可访问,无按钮遮挡;控制台无新增错误,所有业务数据来自真实API/PostgreSQL | + +## TC-HIGH-FREQUENCY-QUERY-20260902 高频查询与按需详情 + +| 用例ID | 场景 | 预期 | +| --- | --- | --- | +| TC-HFQ-001 | 企业签名页首次进入、第一页查询、非第一页重置、翻页和排序 | 首次各加载一次签名分页/企业选项/应用选项;后续每项操作只请求一次签名分页,旧响应不得覆盖新条件 | +| TC-HFQ-002 | 打开签名编辑、签名报备状态、引流编辑和引流报备状态 | 分页摘要不含材料、原始任务和目标数组;四个入口分别按ID请求真实详情或目标,保存仍进入原真实后端流程 | +| TC-HFQ-003 | 短信记录查询后打开详情并快速切换或关闭 | 分页响应无submitRecords/receiptRecords/downstreamDeliveries;仅当前消息加载详情与分片审计,迟到响应不得写入下一条或已关闭弹窗 | +| TC-HFQ-004 | 通道列表加载10条数据 | 分页响应自带连接摘要;页面只请求通道分页和发送质量,不再逐行请求连接状态 | +| TC-HFQ-005 | Gateway异常、下游投递、客户端签名/模板、审核和三类报表修改筛选控件 | 控件变化不发业务查询;点击查询应用条件,重置恢复默认,分页沿用上次确认条件 | +| TC-HFQ-006 | 报备任务、状态记录和报备批次在第一页及非第一页点击重置 | 清空草稿及已应用条件并只加载一次第一页,不保留旧筛选结果 | +| TC-HFQ-007 | 通道报备详情首次进入、查询、重置和翻页 | 基础通道/字段/字段库只在进入时加载;查询、重置、翻页只请求一次报备任务分页,不下载全量签名和状态记录 | +| TC-HFQ-008 | 获取企业筛选选项 | 使用轻量options接口,仅返回id/name/code/status且后端过滤deleted,不返回企业认证材料 | +| TC-HFQ-009 | 批量导入解析后切换“保存为可复用映射方案” | 控件使用通用按钮外观、图标和清晰选中态;aria-pressed随状态切换,选中后展示方案名称输入框 | +| TC-HFQ-010 | 查看签名及引流两级操作按钮 | 报备状态、编辑、删除均使用通用sm按钮高度,删除按钮不再高低不齐 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 61e3f1e..3e59cef 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4281,3 +4281,13 @@ git diff --check - 企业签名保存结果新增资料变化标识;页面仅在真实报备资料变化后提示前往报备资料池。企业签名列表新增最新资料版本尚待生成的通道×运营商明细数及下钻入口。状态记录增加批次号、操作人、变更后状态和修改入口查询。 - 本轮未新增数据库迁移,未改变部署架构。定向报备材料12项、签名配置68项和企业签名组件2项通过;全量API 51套587项、前端10文件50项通过,前后端TypeScript、定向ESLint(仅既有Hook依赖警告)、Vite构建、依赖安全、部署契约、结构质量、包体积及`git diff --check`通过。Vite仍只有既有Chart分块超过500kB提示,入口gzip约107.51KiB,符合250KiB预算。 - 发布边界仅为测试环境`100.93.204.60`,不推送远端、不访问预生产、不发送/补发/重投短信、不修改余额、通道或客户配置。测试机健康接口和SSH端口已恢复可达;部署结果、恢复资产、运行标记、服务/Stream/日志及真实页面验收在完成测试机认证后补记。 + +## 2026-09-02 高频查询整改及企业签名按钮优化(仅本地代码) + +- 重新核验`main / 9b8196e`、最近提交、工作区及本文件末段后实施;开始时源码干净,但4份既有修改文档、4项未跟踪文件继续保留。本轮没有reset、覆盖或暂存其他会话内容,依赖工具意外生成的pnpm锁文件已在确认位于工作区且为本轮产物后删除,`pnpm-workspace.yaml`恢复原内容。 +- 企业签名批量导入弹窗将“保存为可复用映射方案”改为通用Button,增加加号/完成图标、阴影、选中描边及`aria-pressed`;选中态文案为“本次将保存/更新映射方案”。签名和引流操作区的报备状态、编辑、删除统一使用通用`sm`高度,`DeleteRiskAction`支持显式size/className。 +- 按整改文档收敛高频请求:企业签名/模板、企业应用、充值、上行、Gateway异常、下游投递、企业黑名单、客户端签名/模板、签名/模板审核、利润/质量/对账、报备任务/记录/批次均改为列表与选项分离、draft/applied条件或单请求页码分支;输入及选择变化不再触发业务查询。 +- 新增轻量企业选项接口`/api/admin/tenants/options`;企业签名分页剔除材料、原始任务、目标数组及编辑动态字段,编辑和两类报备状态按ID懒加载;短信记录分页不再预取提交/回执/下游投递数组,详情与分片审计按单条并行加载并防止迟到响应污染。 +- 通道页移除逐行连接状态N+1,复用分页接口真实`connectionStates`;通道报备详情将基础配置与任务分页拆分,查询/重置/翻页只刷新1次任务分页。所有功能仍使用真实API和数据库契约,没有增加mock、静态数据或localStorage产品兜底。 +- 自动化:前端11文件51项、API 51套590项通过;新增映射按钮选中态、通用按钮高度、轻量企业选项、短信摘要/详情拆分、签名摘要白名单契约回归。前端TypeScript、API TypeScript构建、Vite生产构建及`git diff --check`通过;Vite仅保留既有Chart分块超过500kB提示。 +- 本轮没有部署授权,因此未建立恢复资产、未访问或部署测试/预生产,未执行真实浏览器Network的20次P50/P95、响应字节和PostgreSQL执行计划验收;这些明确留待后续授权发布,不能以单元测试或构建结果冒充线上性能完成。未发送、补发或重投短信,未修改余额、通道或客户配置。 diff --git a/src/api/admin/governance.api.ts b/src/api/admin/governance.api.ts index 5bd43fb..a6acd84 100644 --- a/src/api/admin/governance.api.ts +++ b/src/api/admin/governance.api.ts @@ -44,6 +44,9 @@ export const adminGovernanceApi = { request>(withQuery('/admin/enterprise-signatures', query)), listEnterpriseSignatureOptions: (query: { tenantId?: string } = {}) => request(withQuery('/admin/enterprise-signature-options', query)), + getEnterpriseSignature: (id: string) => request(`/admin/enterprise-signatures/${id}`), + getEnterpriseSignatureReportTargets: (id: string) => request>(`/admin/enterprise-signatures/${id}/report-targets`), + getDrainageInfoReportTargets: (id: string) => request[string]>(`/admin/drainage-infos/${id}/report-targets`), createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record }) => request('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }), updateEnterpriseSignature: (id: string, body: { applicationId?: string | null; name?: string; purpose?: string; auditStatus?: string; drainageInfo?: Record }) => diff --git a/src/api/admin/identity.api.ts b/src/api/admin/identity.api.ts index 6000893..00f3b4e 100644 --- a/src/api/admin/identity.api.ts +++ b/src/api/admin/identity.api.ts @@ -16,6 +16,7 @@ export const adminIdentityApi = { changeOwnPassword: (body: { currentPassword: string; password: string }) => portalSessionApi.changeOwnPassword('admin', body), listTenants: () => request('/admin/tenants'), + listTenantOptions: () => request('/admin/tenants/options'), listTenantManagementRows: () => request('/admin/tenants/management-list'), getTenant: (id: string) => request(`/admin/tenants/${id}`), createTenant: (body: { name: string; code?: string; status?: string; creditCode?: string; province?: string; city?: string; address?: string; contactName?: string; contactIdCard?: string; contactPhone?: string; contactEmail?: string; photoFileObjectId?: string }) => diff --git a/src/api/admin/operations.api.ts b/src/api/admin/operations.api.ts index 74ca569..0b3b49c 100644 --- a/src/api/admin/operations.api.ts +++ b/src/api/admin/operations.api.ts @@ -40,6 +40,7 @@ export const adminOperationsApi = { request(withQuery('/admin/operations/message-segment-audits', query)), listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; hasDrainage?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}) => request>(withQuery('/admin/operations/messages', query)), + getOperationMessage: (id: string) => request(`/admin/operations/messages/${id}`), exportOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; hasDrainage?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) => requestBlob(withQuery('/admin/operations/messages/export', query)), listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) => diff --git a/src/apps/admin/AdminChannelReportPage.tsx b/src/apps/admin/AdminChannelReportPage.tsx index 7c3d0e9..355df21 100644 --- a/src/apps/admin/AdminChannelReportPage.tsx +++ b/src/apps/admin/AdminChannelReportPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { ArrowLeft, Download, Eye, FileSliders, Search } from 'lucide-react'; import { useNavigate, useParams } from 'react-router-dom'; import { @@ -7,7 +7,6 @@ import { type ChannelReportField, type ClientSmsSignature, type DictionaryItem, - type ReportRecord, type ReportTask, type SingleReportMaterialDetail, } from '@/api/adminApi'; @@ -50,13 +49,6 @@ function formatSignatureName(value?: string | null) { return `【${name || '-'}】`; } -function drainageItems(signature?: ClientSmsSignature) { - const payload = asRecord(signature?.drainageInfo); - return Array.isArray(payload.links) - ? payload.links.filter((item): item is DrainageItem => Boolean(item) && typeof item === 'object') - : []; -} - function DateTime({ value }: { value?: unknown }) { return value ? ( {formatDateTime(String(value))} @@ -203,8 +195,6 @@ export function AdminChannelReportPage() { const { channelId = '' } = useParams(); const [channel, setChannel] = useState(); const [tasks, setTasks] = useState([]); - const [records, setRecords] = useState([]); - const [signatures, setSignatures] = useState([]); const [fields, setFields] = useState([]); const [libraryFields, setLibraryFields] = useState([]); const [keyword, setKeyword] = useState(''); @@ -214,6 +204,7 @@ export function AdminChannelReportPage() { const [todaySendMax, setTodaySendMax] = useState(''); const [page, setPage] = useState(1); const [total, setTotal] = useState(0); + const [appliedFilters, setAppliedFilters] = useState({ keyword: '', status: 'all', carrier: 'all', todaySendMin: '', todaySendMax: '' }); const pageSize = 10; const [material, setMaterial] = useState(); const [detail, setDetail] = useState<{ @@ -228,41 +219,37 @@ export function AdminChannelReportPage() { const [configType, setConfigType] = useState(); const [error, setError] = useState(''); - function loadData() { - Promise.all([ - adminApi.listChannels(), - adminApi.listReportTasksPage({ + function loadData(targetPage = page, filters = appliedFilters) { + adminApi.listReportTasksPage({ channelId, - keyword: keyword.trim() || undefined, - status: status === 'all' ? undefined : status, - carrier: carrier === 'all' ? undefined : carrier, - todaySendMin: todaySendMin ? Number(todaySendMin) : undefined, - todaySendMax: todaySendMax ? Number(todaySendMax) : undefined, + keyword: filters.keyword || undefined, + status: filters.status === 'all' ? undefined : filters.status, + carrier: filters.carrier === 'all' ? undefined : filters.carrier, + todaySendMin: filters.todaySendMin ? Number(filters.todaySendMin) : undefined, + todaySendMax: filters.todaySendMax ? Number(filters.todaySendMax) : undefined, sort: 'todaySendDesc', - page, + page: targetPage, pageSize, - }), - adminApi.listReportRecords({ channelId }), - adminApi.listEnterpriseSignatures(), - adminApi.listChannelReportFields(channelId), - adminApi.listDrainageFields(), - ]) - .then(([channelItems, taskPage, recordItems, signatureItems, fieldItems, libraryItems]) => { - setChannel(channelItems.find((item) => item.id === channelId)); + }) + .then((taskPage) => { setTasks(taskPage.items); setTotal(taskPage.total); - setRecords(recordItems); - setSignatures(signatureItems); - setFields(fieldItems); - setLibraryFields(libraryItems.filter((item) => item.status === 'active')); setError(''); }) .catch((failure: Error) => setError(failure.message || '通道报备详情加载失败')); } - useEffect(loadData, [channelId, page]); + useEffect(() => { + void Promise.all([adminApi.listChannels(), adminApi.listChannelReportFields(channelId), adminApi.listDrainageFields()]) + .then(([channelItems, fieldItems, libraryItems]) => { + setChannel(channelItems.find((item) => item.id === channelId)); + setFields(fieldItems); + setLibraryFields(libraryItems.filter((item) => item.status === 'active')); + }) + .catch((failure: Error) => setError(failure.message || '通道报备配置加载失败')); + }, [channelId]); - const signatureMap = useMemo(() => new Map(signatures.map((item) => [item.id, item])), [signatures]); + useEffect(() => { loadData(page); }, [channelId, page]); const visibleTasks = tasks; async function openMaterial(task: ReportTask) { @@ -303,13 +290,10 @@ export function AdminChannelReportPage() { } } - function approvedRecord(taskId: string) { - return records.find((record) => record.taskId === taskId && record.statusAfter === 'approved'); - } - async function saveFieldMapping(nextFields: Parameters[2]) { if (!configType) return; await adminApi.replaceChannelReportFields(channelId, configType, nextFields); + setFields(await adminApi.listChannelReportFields(channelId)); loadData(); } @@ -420,6 +404,10 @@ export function AdminChannelReportPage() { setCarrier('all'); setTodaySendMin(''); setTodaySendMax(''); + const filters = { keyword: '', status: 'all', carrier: 'all', todaySendMin: '', todaySendMax: '' }; + setAppliedFilters(filters); + if (page !== 1) setPage(1); + else loadData(1, filters); }} variant="ghost" > @@ -428,8 +416,10 @@ export function AdminChannelReportPage() { - + + diff --git a/src/apps/admin/AdminDownstreamDeliveriesPage.tsx b/src/apps/admin/AdminDownstreamDeliveriesPage.tsx index fb073ef..c185500 100644 --- a/src/apps/admin/AdminDownstreamDeliveriesPage.tsx +++ b/src/apps/admin/AdminDownstreamDeliveriesPage.tsx @@ -23,6 +23,9 @@ export function AdminDownstreamDeliveriesPage() { const [detail, setDetail] = useState(null); const [selectedIds, setSelectedIds] = useState([]); const [dateRange, setDateRange] = useState(recentSevenDays); + const [appliedFilters, setAppliedFilters] = useState(() => ({ + keyword: '', status: 'all', deliveryType: 'all', applicationId: 'all', tenantId: 'all', dateRange: recentSevenDays(), + })); const [requeueTarget, setRequeueTarget] = useState(null); const [requeueBusy, setRequeueBusy] = useState(false); const [requeueResult, setRequeueResult] = useState(null); @@ -45,14 +48,14 @@ export function AdminDownstreamDeliveriesPage() { const [taskItemTotal, setTaskItemTotal] = useState(0); const currentTaskFilter = useCallback(() => ({ - keyword: keyword || undefined, - status, - deliveryType, - tenantId, - applicationId, - createdAtFrom: dateRange.start, - createdAtTo: dateRange.end, - }), [applicationId, dateRange.end, dateRange.start, deliveryType, keyword, status, tenantId]); + keyword: appliedFilters.keyword || undefined, + status: appliedFilters.status, + deliveryType: appliedFilters.deliveryType, + tenantId: appliedFilters.tenantId, + applicationId: appliedFilters.applicationId, + createdAtFrom: appliedFilters.dateRange.start, + createdAtTo: appliedFilters.dateRange.end, + }), [appliedFilters]); const loadRequeueTasks = useCallback(() => { adminApi.listDownstreamRequeueTasks({ status: requeueTaskStatus, page: requeueTaskPage, pageSize: 10 }) @@ -64,43 +67,53 @@ export function AdminDownstreamDeliveriesPage() { setLoading(true); Promise.all([ adminApi.getDownstreamDeliveryDashboard({ - applicationId, - tenantId, - deliveryType, - createdAtFrom: dateRange.start, - createdAtTo: dateRange.end, + applicationId: appliedFilters.applicationId, + tenantId: appliedFilters.tenantId, + deliveryType: appliedFilters.deliveryType, + createdAtFrom: appliedFilters.dateRange.start, + createdAtTo: appliedFilters.dateRange.end, }), adminApi.listDownstreamDeliveries({ - keyword, - status, - deliveryType, - applicationId, - tenantId, + keyword: appliedFilters.keyword, + status: appliedFilters.status, + deliveryType: appliedFilters.deliveryType, + applicationId: appliedFilters.applicationId, + tenantId: appliedFilters.tenantId, page, pageSize, - createdAtFrom: dateRange.start, - createdAtTo: dateRange.end, + createdAtFrom: appliedFilters.dateRange.start, + createdAtTo: appliedFilters.dateRange.end, }), - adminApi.listEnterpriseApplications(), - adminApi.listTenants(), ]) - .then(([dashboardResponse, response, apps, tenantOptions]) => { + .then(([dashboardResponse, response]) => { setDashboard(dashboardResponse); setRecords(response.items); setTotal(response.total); - setApplications(apps); - setTenants(tenantOptions); setSelectedIds((current) => current.filter((id) => response.items.some((item) => item.id === id))); setError(''); }) .catch((failure: Error) => setError(failure.message || '下游投递记录加载失败')) .finally(() => setLoading(false)); - }, [applicationId, dateRange.end, dateRange.start, deliveryType, keyword, page, pageSize, status, tenantId]); + }, [appliedFilters, page, pageSize]); useEffect(() => { loadData(); }, [loadData]); + useEffect(() => { + let cancelled = false; + void Promise.all([adminApi.listEnterpriseApplicationOptions(), adminApi.listTenantOptions()]) + .then(([apps, tenantOptions]) => { + if (cancelled) return; + setApplications(apps); + setTenants(tenantOptions); + }) + .catch((failure: Error) => { + if (!cancelled) setError(failure.message || '企业及应用选项加载失败'); + }); + return () => { cancelled = true; }; + }, []); + useEffect(() => { loadRequeueTasks(); const timer = window.setInterval(loadRequeueTasks, 3000); @@ -273,12 +286,12 @@ export function AdminDownstreamDeliveriesPage() {
setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} /> - { setDateRange(value); setPage(1); }} value={dateRange} /> + { setStatus(event.target.value); - setPage(1); }} /> { setApplicationId(event.target.value); - setPage(1); }} />
- +
diff --git a/src/apps/admin/AdminGatewaySubmitExceptionsPage.tsx b/src/apps/admin/AdminGatewaySubmitExceptionsPage.tsx index 1d77793..47ad9c7 100644 --- a/src/apps/admin/AdminGatewaySubmitExceptionsPage.tsx +++ b/src/apps/admin/AdminGatewaySubmitExceptionsPage.tsx @@ -191,6 +191,7 @@ function GatewaySubmitExceptionPanel() { const [status, setStatus] = useState('all'); const [applicationId, setApplicationId] = useState('all'); const [channelId, setChannelId] = useState('all'); + const [appliedFilters, setAppliedFilters] = useState({ keyword: '', status: 'all', applicationId: 'all', channelId: 'all' }); const [page, setPage] = useState(1); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); @@ -204,17 +205,11 @@ function GatewaySubmitExceptionPanel() { const loadData = useCallback(() => { setLoading(true); - Promise.all([ - adminApi.listGatewaySubmitExceptions({ keyword, status, applicationId, channelId, page, pageSize }), - adminApi.listEnterpriseApplications(), - adminApi.listChannels(), - ]) - .then(([response, appItems, channelItems]) => { + adminApi.listGatewaySubmitExceptions({ ...appliedFilters, page, pageSize }) + .then((response) => { setItems(response.items); setTotal(response.total); setSummary({ ...response.summary, oldestPendingAt: response.summary.oldestPendingAt ?? null }); - setApplications(appItems); - setChannels(channelItems.filter((item) => item.status !== 'deleted')); setError(''); }) .catch((failure: Error) => { @@ -223,10 +218,24 @@ function GatewaySubmitExceptionPanel() { setError(failure.message || '提交异常加载失败'); }) .finally(() => setLoading(false)); - }, [applicationId, channelId, keyword, page, status]); + }, [appliedFilters, page]); useEffect(() => { loadData(); }, [loadData]); + useEffect(() => { + let cancelled = false; + void Promise.all([adminApi.listEnterpriseApplications(), adminApi.listChannels()]) + .then(([appItems, channelItems]) => { + if (cancelled) return; + setApplications(appItems); + setChannels(channelItems.filter((item) => item.status !== 'deleted')); + }) + .catch((failure: Error) => { + if (!cancelled) setError(failure.message || '应用及通道选项加载失败'); + }); + return () => { cancelled = true; }; + }, []); + const columns = useMemo>>(() => [ { key: 'createdAt', title: '异常时间', width: '170px', render: (record) => formatTime(record.createdAt) }, { key: 'messageId', title: '消息编号', width: '170px', render: (record) => {record.messageId ?? '-'} }, @@ -289,11 +298,11 @@ function GatewaySubmitExceptionPanel() {
已处理{summary.resolved}已取得明确Gateway提交结果。
- { setKeyword(event.target.value); setPage(1); }} placeholder="MessageId、SubmitId、失败原因" value={keyword} /> - ({ label: item.name, value: item.id }))]} value={applicationId} onChange={(event) => { setApplicationId(event.target.value); setPage(1); }} /> - setKeyword(event.target.value)} placeholder="MessageId、SubmitId、失败原因" value={keyword} /> + ({ label: item.name, value: item.id }))]} value={applicationId} onChange={(event) => setApplicationId(event.target.value)} /> + { setDimensionType(event.target.value as 'application' | 'channel'); setTenantId(''); setApplicationId(''); setChannelId(''); setPage(1); }} options={[{ label: '按企业应用', value: 'application' }, { label: '按通道', value: 'channel' }]} value={dimensionType} /> - {dimensionType === 'application' ? { setChannelId(event.target.value); setPage(1); }} options={[{ label: '全部通道', value: '' }, ...channels.map((channel) => ({ label: channel.name, value: channel.id }))]} value={channelId} />} - {dimensionType === 'application' ? { setDimensionType(event.target.value as 'application' | 'channel'); setTenantId(''); setApplicationId(''); setChannelId(''); }} options={[{ label: '按企业应用', value: 'application' }, { label: '按通道', value: 'channel' }]} value={dimensionType} /> + {dimensionType === 'application' ? setChannelId(event.target.value)} options={[{ label: '全部通道', value: '' }, ...channels.map((channel) => ({ label: channel.name, value: channel.id }))]} value={channelId} />} + {dimensionType === 'application' ? { setChannelId(event.target.value); setPage(1); }} options={[{ label: '全部通道', value: '' }, ...channels.map((channel) => ({ label: channel.name, value: channel.id }))]} value={channelId} /> : { setApplicationId(event.target.value); setPage(1); }} options={[{ label: '全部应用', value: '' }, ...availableApplications.map((application) => ({ label: application.name, value: application.id }))]} value={applicationId} />} - + + {dimension === 'channel' ? { setTenantId(event.target.value); setApplicationId(''); }} options={[{ label: '全部企业', value: '' }, ...tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))]} value={tenantId} />} + {dimension === 'channel' ?
: setEnterpriseKeyword(event.target.value)} value={enterpriseKeyword} />
- +
diff --git a/src/apps/admin/AdminReconciliationReportsPage.tsx b/src/apps/admin/AdminReconciliationReportsPage.tsx index ef81d84..5f72e27 100644 --- a/src/apps/admin/AdminReconciliationReportsPage.tsx +++ b/src/apps/admin/AdminReconciliationReportsPage.tsx @@ -5,6 +5,7 @@ import { Breadcrumb, Button, DateRangeInput, Pagination, Select, Tag, type DateR import { formatDateTime } from '@/utils/dateTime'; const pageSize = 20; +type ReconciliationFilters = { dateRange: DateRangeValue; tenantId: string; applicationId: string }; export function AdminReconciliationReportsPage() { const [rows, setRows] = useState([]); @@ -13,6 +14,7 @@ export function AdminReconciliationReportsPage() { const [dateRange, setDateRange] = useState(defaultDateRange()); const [tenantId, setTenantId] = useState(''); const [applicationId, setApplicationId] = useState(''); + const [appliedFilters, setAppliedFilters] = useState(() => ({ dateRange: defaultDateRange(), tenantId: '', applicationId: '' })); const [page, setPage] = useState(1); const [total, setTotal] = useState(0); const [summary, setSummary] = useState(emptyVolumeSummary); @@ -21,23 +23,23 @@ export function AdminReconciliationReportsPage() { const [exporting, setExporting] = useState(false); useEffect(() => { - Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications()]) + Promise.all([adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions()]) .then(([nextTenants, nextApplications]) => { setTenants(nextTenants); setApplications(nextApplications); }) .catch(() => { setTenants([]); setApplications([]); }); }, []); - useEffect(() => { void loadData(); }, [page, dateRange.start, dateRange.end, tenantId, applicationId]); + useEffect(() => { void loadData(appliedFilters, page); }, [page, appliedFilters]); - async function loadData() { + async function loadData(filters: ReconciliationFilters, targetPage: number) { setLoading(true); setError(''); try { const response = await adminApi.listReconciliationReports({ - dateFrom: dateRange.start, - dateTo: dateRange.end, - tenantId: tenantId || undefined, - applicationId: applicationId || undefined, - page, + dateFrom: filters.dateRange.start, + dateTo: filters.dateRange.end, + tenantId: filters.tenantId || undefined, + applicationId: filters.applicationId || undefined, + page: targetPage, pageSize, }); setRows(response.items); @@ -55,7 +57,7 @@ export function AdminReconciliationReportsPage() { async function exportData() { setExporting(true); setError(''); - try { downloadBlob(await adminApi.exportReconciliationReports({ dateFrom: dateRange.start, dateTo: dateRange.end, tenantId: tenantId || undefined, applicationId: applicationId || undefined }), '对账单.csv'); } + try { downloadBlob(await adminApi.exportReconciliationReports({ dateFrom: appliedFilters.dateRange.start, dateTo: appliedFilters.dateRange.end, tenantId: appliedFilters.tenantId || undefined, applicationId: appliedFilters.applicationId || undefined }), '对账单.csv'); } catch (failure) { setError(failure instanceof Error ? failure.message : '对账单导出失败'); } finally { setExporting(false); } } @@ -74,10 +76,10 @@ export function AdminReconciliationReportsPage() {
- { setDateRange(value); setPage(1); }} value={dateRange} /> - { setApplicationId(event.target.value); setPage(1); }} options={[{ label: '全部应用', value: '' }, ...availableApplications.map((application) => ({ label: application.name, value: application.id }))]} value={applicationId} /> - + + setApplicationId(event.target.value)} options={[{ label: '全部应用', value: '' }, ...availableApplications.map((application) => ({ label: application.name, value: application.id }))]} value={applicationId} /> +
diff --git a/src/apps/admin/AdminReportBatchesPage.tsx b/src/apps/admin/AdminReportBatchesPage.tsx index be51aa1..3c16e07 100644 --- a/src/apps/admin/AdminReportBatchesPage.tsx +++ b/src/apps/admin/AdminReportBatchesPage.tsx @@ -37,6 +37,7 @@ export function AdminReportBatchesPage() { const [dateRange, setDateRange] = useState({}); const [page, setPage] = useState(1); const [total, setTotal] = useState(0); + const [appliedFilters, setAppliedFilters] = useState({ keyword: '', dateRange: {} as DateRangeValue }); const [detail, setDetail] = useState(); const [tasks, setTasks] = useState([]); const [selected, setSelected] = useState>(new Set()); @@ -45,12 +46,12 @@ export function AdminReportBatchesPage() { const [error, setError] = useState(''); const pageSize = 20; - function load(target = page) { + function load(target = page, filters = appliedFilters) { adminApi .listReportMaterialBatches({ - keyword: keyword.trim() || undefined, - startAt: dateRange.start, - endAt: dateRange.end, + keyword: filters.keyword || undefined, + startAt: filters.dateRange.start, + endAt: filters.dateRange.end, page: target, pageSize, }) @@ -263,8 +264,10 @@ export function AdminReportBatchesPage() { }, + { key: 'actions', title: '操作', align: 'right', render: (record) =>
loadData()} targetId={record.id} targetType="signature" />
}, ], []); return

短信签名审核

{error ?

{error}

: null}
setKeyword(event.target.value)} placeholder="搜索企业、应用或签名" prefix={} value={keyword} /> setKeyword(event.target.value)} placeholder="搜索企业、应用或签名" prefix={} value={keyword} /> setPhoneKeyword(event.target.value)} prefix={} value={phoneKeyword} /> setContentKeyword(event.target.value)} value={contentKeyword} />
- +
diff --git a/src/apps/admin/AdminTemplateAuditPage.tsx b/src/apps/admin/AdminTemplateAuditPage.tsx index 5b12494..1b8392e 100644 --- a/src/apps/admin/AdminTemplateAuditPage.tsx +++ b/src/apps/admin/AdminTemplateAuditPage.tsx @@ -23,13 +23,16 @@ export function AdminTemplateAuditPage() { const [keyword, setKeyword] = useState(''); const [status, setStatus] = useState('pending'); const [submittedDateRange, setSubmittedDateRange] = useState({}); + const [appliedFilters, setAppliedFilters] = useState({ keyword: '', status: 'pending', submittedDateRange: {} as DateRangeValue }); const [detail, setDetail] = useState(); - useEffect(() => { - adminApi.listTemplateAudits({ keyword, status, submittedAtFrom: submittedDateRange.start, submittedAtTo: submittedDateRange.end }) + function loadData(filters = appliedFilters) { + return adminApi.listTemplateAudits({ keyword: filters.keyword, status: filters.status, submittedAtFrom: filters.submittedDateRange.start, submittedAtTo: filters.submittedDateRange.end }) .then(setAudits) .catch(() => setAudits([])); - }, [keyword, status, submittedDateRange.end, submittedDateRange.start]); + } + + useEffect(() => { void loadData(); }, [appliedFilters]); async function rejectTemplate(id: string) { const updated = await adminApi.rejectTemplate(id); @@ -58,7 +61,7 @@ export function AdminTemplateAuditPage() { render: (record) => (
- adminApi.listTemplateAudits({ keyword, status, submittedAtFrom: submittedDateRange.start, submittedAtTo: submittedDateRange.end }).then(setAudits)} targetId={record.id} targetType="template" /> + void loadData()} targetId={record.id} targetType="template" />
+
+ + {saveProfile ? setProfileName(event.target.value)} placeholder="例如:海南移动签名资料模板" value={profileName} /> : null} +
{analysis.rows.length ?
查看前 {analysis.rows.length} 行解析预览
{JSON.stringify(analysis.rows, null, 2)}
: null}
: null} {error ?

{error}

: null} diff --git a/src/apps/admin/enterprise-signatures/EnterpriseSignaturesTable.test.tsx b/src/apps/admin/enterprise-signatures/EnterpriseSignaturesTable.test.tsx index 94fb421..07db18f 100644 --- a/src/apps/admin/enterprise-signatures/EnterpriseSignaturesTable.test.tsx +++ b/src/apps/admin/enterprise-signatures/EnterpriseSignaturesTable.test.tsx @@ -43,13 +43,14 @@ function renderTable(overrides: Partial { expect(screen.getAllByRole('button', { name: '报备状态' })).toHaveLength(2); expect(screen.getAllByRole('button', { name: '编辑' })).toHaveLength(2); expect(screen.getAllByRole('button', { name: '删除' })).toHaveLength(2); + expect(screen.getAllByRole('button', { name: /报备状态|编辑|删除/ })).toHaveLength(6); + screen.getAllByRole('button', { name: /报备状态|编辑|删除/ }).forEach((button) => { + expect(button).toHaveClass('ui-button--sm', 'enterprise-signature-action-button'); + }); expect(screen.getByRole('button', { name: '4 条' })).toBeVisible(); }); diff --git a/src/apps/admin/enterprise-signatures/EnterpriseSignaturesTable.tsx b/src/apps/admin/enterprise-signatures/EnterpriseSignaturesTable.tsx index b1639ef..be56194 100644 --- a/src/apps/admin/enterprise-signatures/EnterpriseSignaturesTable.tsx +++ b/src/apps/admin/enterprise-signatures/EnterpriseSignaturesTable.tsx @@ -18,14 +18,15 @@ type EnterpriseSignaturesTableProps = { expandedSignatureId: string; filteredSignatures: ClientSmsSignature[]; loadData: () => Promise; + onAddDrainage: (signature: ClientSmsSignature) => void; setDeleteTarget: Dispatch>; - setDrainageModal: Dispatch>; - setDrainageStatusTarget: Dispatch>; + onEditDrainage: (signature: ClientSmsSignature, item: DrainageInfo) => void; + onOpenDrainageReport: (signature: ClientSmsSignature, item: DrainageInfo) => void; setExpandedSignatureId: Dispatch>; setPage: Dispatch>; setSignatureSort: (sort: 'asc' | 'desc') => void; - setReportStatusTarget: Dispatch>; - setSignatureModal: Dispatch>; + onEditSignature: (signature: ClientSmsSignature) => void; + onOpenSignatureReport: (signature: ClientSmsSignature) => void; signatureSort: 'asc' | 'desc'; total: number; totalPages: number; @@ -38,14 +39,15 @@ export function EnterpriseSignaturesTable({ expandedSignatureId, filteredSignatures, loadData, + onAddDrainage, setDeleteTarget, - setDrainageModal, - setDrainageStatusTarget, + onEditDrainage, + onOpenDrainageReport, setExpandedSignatureId, setPage, setSignatureSort, - setReportStatusTarget, - setSignatureModal, + onEditSignature, + onOpenSignatureReport, signatureSort, total, totalPages, @@ -160,22 +162,25 @@ export function EnterpriseSignaturesTable({
void loadData()} portal="admin" targetId={signature.id} @@ -209,21 +214,24 @@ export function EnterpriseSignaturesTable({ + +
{loading ?

正在加载短信模板...

: null} diff --git a/src/components/ui/DeleteRiskAction.tsx b/src/components/ui/DeleteRiskAction.tsx index bf8682d..d9fe2da 100644 --- a/src/components/ui/DeleteRiskAction.tsx +++ b/src/components/ui/DeleteRiskAction.tsx @@ -6,7 +6,7 @@ import { Button } from './Button'; import { Modal } from './Modal'; import { Textarea } from './Textarea'; -export function DeleteRiskAction({ portal, targetType, targetId, children = '删除', icon = , disabled, onCompleted }: { +export function DeleteRiskAction({ portal, targetType, targetId, children = '删除', icon = , disabled, onCompleted, className, size = 'sm' }: { portal: 'admin' | 'client'; targetType: DeletionTargetType; targetId: string; @@ -14,6 +14,8 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删 icon?: ReactNode; disabled?: boolean; onCompleted?: (result: DeletionResult) => void; + className?: string; + size?: 'sm' | 'md' | 'lg'; }) { const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); @@ -81,7 +83,7 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删 ; return <> - + 0} footer={({ requestClose }) => footer(requestClose)} onClose={close} open={open} title="删除资格与影响确认">
{result ? ( diff --git a/src/styles/global.css b/src/styles/global.css index 1679df5..31c7f00 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -2021,8 +2021,11 @@ width: 224px; } -.admin-enterprise-signature-list .signature-actions .ui-button { +.admin-enterprise-signature-list .signature-actions .ui-button, +.admin-enterprise-signature-list .drainage-row-actions .ui-button { + height: var(--control-height-sm); justify-content: center; + min-height: var(--control-height-sm); min-width: 0; padding-left: 8px; padding-right: 8px; @@ -5998,8 +6001,8 @@ .report-import-mapping-row > span { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; } .report-import-mapping-row small { width: 100%; color: var(--text-muted); } .report-import-profile { display: flex; align-items: end; gap: 12px; } -.report-import-profile button { border: 1px solid var(--border); border-radius: 8px; background: var(--surface); padding: 9px 12px; display: inline-flex; gap: 6px; align-items: center; cursor: pointer; } -.report-import-profile button.is-active { border-color: var(--primary); color: var(--primary); } +.report-import-profile__toggle { min-width: 238px; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); } +.report-import-profile__toggle[aria-pressed="true"] { box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); } .report-import-profile .field { min-width: 320px; } .report-import-preview pre { max-height: 240px; overflow: auto; padding: 12px; background: #111827; color: #d1fae5; border-radius: 8px; font-size: 11px; }