From a7a4e8d9f6aba00b8137e5b70c59bdef67ed3b57 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Wed, 15 Jul 2026 16:31:49 +0800 Subject: [PATCH] feat: polish reporting templates and shared controls --- api/src/reports/reports.controller.ts | 28 +++++- api/src/reports/reports.service.spec.ts | 14 +++ api/src/reports/reports.service.ts | 89 +++++++++++++----- api/src/sms-config/sms-config.service.spec.ts | 33 +++++-- api/src/sms-config/sms-config.service.ts | 48 ++++++++-- .../first-version-development-requirements.md | 14 ++- docs/system-functional-test-cases.md | 17 +++- docs/testing-progress.md | 16 ++++ src/api/adminApi.ts | 6 ++ src/apps/admin/AdminDrainageFieldsPage.tsx | 65 +++++++------- .../admin/AdminEnterpriseTemplatesPage.tsx | 31 +++++-- src/apps/admin/AdminProfitReportsPage.tsx | 14 ++- src/apps/admin/AdminQualityReportsPage.tsx | 14 ++- .../admin/AdminReconciliationReportsPage.tsx | 14 ++- src/apps/admin/AdminUsersPage.tsx | 4 +- src/apps/client/ClientTemplatesPage.tsx | 33 +++++-- src/apps/client/ClientUsersPage.tsx | 2 +- src/components/ui/Select.tsx | 90 +++++++++++++------ src/styles/components.css | 17 ++++ src/styles/global.css | 47 +++++++++- src/utils/smsSignature.ts | 11 +++ 21 files changed, 488 insertions(+), 119 deletions(-) create mode 100644 src/utils/smsSignature.ts diff --git a/api/src/reports/reports.controller.ts b/api/src/reports/reports.controller.ts index 3f3c463..6e3fdef 100644 --- a/api/src/reports/reports.controller.ts +++ b/api/src/reports/reports.controller.ts @@ -1,7 +1,12 @@ -import { Controller, Get, Query } from '@nestjs/common'; +import { Controller, Get, Query, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { ReportsService } from './reports.service'; +type DownloadResponse = { + setHeader(name: string, value: string): void; + send(content: string): void; +}; + @ApiTags('reports') @Controller('admin/reports') export class ReportsController { @@ -19,6 +24,11 @@ export class ReportsController { return this.reports.listReconciliation({ dateFrom, dateTo, tenantId, applicationId, page: Number(page), pageSize: Number(pageSize) }); } + @Get('reconciliation/export') + async exportReconciliation(@Query('dateFrom') dateFrom: string | undefined, @Query('dateTo') dateTo: string | undefined, @Query('tenantId') tenantId: string | undefined, @Query('applicationId') applicationId: string | undefined, @Res() response: DownloadResponse) { + this.sendCsv(response, await this.reports.exportReconciliation({ dateFrom, dateTo, tenantId, applicationId })); + } + @Get('profit') profit( @Query('dateFrom') dateFrom?: string, @@ -33,6 +43,11 @@ export class ReportsController { return this.reports.listProfit({ dateFrom, dateTo, dimensionType, tenantId, applicationId, channelId, page: Number(page), pageSize: Number(pageSize) }); } + @Get('profit/export') + async exportProfit(@Query('dateFrom') dateFrom: string | undefined, @Query('dateTo') dateTo: string | undefined, @Query('dimensionType') dimensionType: string | undefined, @Query('tenantId') tenantId: string | undefined, @Query('applicationId') applicationId: string | undefined, @Query('channelId') channelId: string | undefined, @Res() response: DownloadResponse) { + this.sendCsv(response, await this.reports.exportProfit({ dateFrom, dateTo, dimensionType, tenantId, applicationId, channelId })); + } + @Get('quality') quality( @Query('dateFrom') dateFrom?: string, @@ -46,4 +61,15 @@ export class ReportsController { ) { return this.reports.listQuality({ dateFrom, dateTo, dimensionType, tenantId, applicationId, channelId, page: Number(page), pageSize: Number(pageSize) }); } + + @Get('quality/export') + async exportQuality(@Query('dateFrom') dateFrom: string | undefined, @Query('dateTo') dateTo: string | undefined, @Query('dimensionType') dimensionType: string | undefined, @Query('tenantId') tenantId: string | undefined, @Query('applicationId') applicationId: string | undefined, @Query('channelId') channelId: string | undefined, @Res() response: DownloadResponse) { + this.sendCsv(response, await this.reports.exportQuality({ dateFrom, dateTo, dimensionType, tenantId, applicationId, channelId })); + } + + private sendCsv(response: DownloadResponse, exported: { fileName: string; content: string }) { + response.setHeader('Content-Type', 'text/csv; charset=utf-8'); + response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`); + response.send(`\uFEFF${exported.content}`); + } } diff --git a/api/src/reports/reports.service.spec.ts b/api/src/reports/reports.service.spec.ts index aa2225a..1ff0bbf 100644 --- a/api/src/reports/reports.service.spec.ts +++ b/api/src/reports/reports.service.spec.ts @@ -78,4 +78,18 @@ describe('ReportsService', () => { orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], })); }); + + it('exports complete filtered report data as escaped CSV instead of the current page', async () => { + prisma.dailyReconciliationReport.findMany.mockResolvedValueOnce([{ + id: 'recon-export', reportDate: new Date('2026-07-14'), tenantName: '示例,企业', applicationName: '应用A', + sentUnits: 12, successUnits: 10, generatedAt: new Date('2026-07-15T00:00:00Z'), + }]); + const exported = await service.exportReconciliation({ tenantId: 'tenant-1', dateFrom: '2026-07-01', dateTo: '2026-07-14' }); + expect(exported.fileName).toContain('对账单-'); + expect(exported.content).toContain('"示例,企业"'); + expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ tenantId: 'tenant-1' }), + orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], + })); + }); }); diff --git a/api/src/reports/reports.service.ts b/api/src/reports/reports.service.ts index 313e8f0..4d940cd 100644 --- a/api/src/reports/reports.service.ts +++ b/api/src/reports/reports.service.ts @@ -43,11 +43,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { async listReconciliation(query: ReportListQuery) { const { page, pageSize, skip } = pagination(query); - const where: Prisma.DailyReconciliationReportWhereInput = { - reportDate: dateFilter(query.dateFrom, query.dateTo), - tenantId: query.tenantId || undefined, - applicationId: query.applicationId || undefined, - }; + const where = reconciliationWhere(query); const [items, total] = await Promise.all([ this.prisma.dailyReconciliationReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], skip, take: pageSize }), this.prisma.dailyReconciliationReport.count({ where }), @@ -57,14 +53,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { async listProfit(query: ReportListQuery) { const { page, pageSize, skip } = pagination(query); - const dimensionType = query.dimensionType === 'channel' ? 'channel' : 'application'; - const where: Prisma.DailyProfitReportWhereInput = { - dimensionType, - reportDate: dateFilter(query.dateFrom, query.dateTo), - tenantId: dimensionType === 'application' ? query.tenantId || undefined : undefined, - applicationId: dimensionType === 'application' ? query.applicationId || undefined : undefined, - channelId: dimensionType === 'channel' ? query.channelId || undefined : undefined, - }; + const { dimensionType, where } = profitWhere(query); const [items, total] = await Promise.all([ this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }), this.prisma.dailyProfitReport.count({ where }), @@ -74,15 +63,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { async listQuality(query: ReportListQuery) { const { page, pageSize, skip } = pagination(query); - const allowedDimensions = new Set(['application', 'channel', 'signature', 'drainage']); - const dimensionType = allowedDimensions.has(String(query.dimensionType)) ? String(query.dimensionType) : 'application'; - const where: Prisma.DailyQualityReportWhereInput = { - dimensionType, - reportDate: dateFilter(query.dateFrom, query.dateTo), - tenantId: query.tenantId || undefined, - applicationId: query.applicationId || undefined, - channelId: query.channelId || undefined, - }; + const { dimensionType, where } = qualityWhere(query); const [items, total] = await Promise.all([ this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }), this.prisma.dailyQualityReport.count({ where }), @@ -90,6 +71,23 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { return { items, total, page, pageSize, dimensionType }; } + async exportReconciliation(query: ReportListQuery) { + const items = await this.prisma.dailyReconciliationReport.findMany({ where: reconciliationWhere(query), orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }] }); + return csvExport('对账单', ['发送日期', '企业', '企业应用', '日发送条数', '成功条数', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.tenantName, item.applicationName, item.sentUnits, item.successUnits, formatCsvDate(item.generatedAt)])); + } + + async exportProfit(query: ReportListQuery) { + const { dimensionType, where } = profitWhere(query); + const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] }); + return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '发送条数', '成功条数', '消费金额(分)', '成本金额(分)', '利润(分)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.sentUnits, item.successUnits, item.revenueCents, item.costCents, item.profitCents, (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)])); + } + + async exportQuality(query: ReportListQuery) { + const { dimensionType, where } = qualityWhere(query); + const items = await this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }] }); + return csvExport(`发送质量报表-${dimensionType}`, ['发送日期', '统计对象', '企业', '发送条数', '成功条数', '成功率(%)', '平均到达时长(毫秒)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.sentUnits, item.successUnits, (item.successRateBps / 100).toFixed(2), item.avgArrivalMs ?? '', formatCsvDate(item.generatedAt)])); + } + async refreshRollingWindow(now = new Date()) { const days = completedBusinessDays(now, 4); for (const day of days) await this.refreshBusinessDay(day); @@ -403,6 +401,53 @@ function pagination(query: ReportListQuery) { return { page, pageSize, skip: (page - 1) * pageSize }; } +function reconciliationWhere(query: ReportListQuery): Prisma.DailyReconciliationReportWhereInput { + return { reportDate: dateFilter(query.dateFrom, query.dateTo), tenantId: query.tenantId || undefined, applicationId: query.applicationId || undefined }; +} + +function profitWhere(query: ReportListQuery) { + const dimensionType = query.dimensionType === 'channel' ? 'channel' : 'application'; + const where: Prisma.DailyProfitReportWhereInput = { + dimensionType, + reportDate: dateFilter(query.dateFrom, query.dateTo), + tenantId: dimensionType === 'application' ? query.tenantId || undefined : undefined, + applicationId: dimensionType === 'application' ? query.applicationId || undefined : undefined, + channelId: dimensionType === 'channel' ? query.channelId || undefined : undefined, + }; + return { dimensionType, where }; +} + +function qualityWhere(query: ReportListQuery) { + const allowedDimensions = new Set(['application', 'channel', 'signature', 'drainage']); + const dimensionType = allowedDimensions.has(String(query.dimensionType)) ? String(query.dimensionType) : 'application'; + const where: Prisma.DailyQualityReportWhereInput = { + dimensionType, + reportDate: dateFilter(query.dateFrom, query.dateTo), + tenantId: query.tenantId || undefined, + applicationId: query.applicationId || undefined, + channelId: query.channelId || undefined, + }; + return { dimensionType, where }; +} + +function csvExport(name: string, headers: string[], rows: Array>) { + const content = [headers, ...rows].map((row) => row.map(csvCell).join(',')).join('\n'); + return { fileName: `${name}-${shanghaiDateKey(new Date())}.csv`, content }; +} + +function csvCell(value: string | number) { + const text = String(value); + return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +function dateKey(value: Date | string) { + return (value instanceof Date ? value.toISOString() : String(value)).slice(0, 10); +} + +function formatCsvDate(value: Date | string) { + return value instanceof Date ? value.toISOString() : String(value); +} + function positiveInteger(value: string | undefined, fallback: number) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 229a1cd..6d2c79b 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -79,7 +79,7 @@ function createPrismaMock() { drainageItems: [], reportTasks: [], }]), - findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }), + findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '签名A', auditStatus: 'pending' }), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-new', tenantId: 'tenant-1', ...data })), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', ...data })), }, @@ -121,7 +121,7 @@ function createPrismaMock() { signature: { id: 'sig-1', name: '签名A' }, variables: [{ name: 'name', required: true }], }]), - findUnique: jest.fn().mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', auditStatus: 'pending' }), + findUnique: jest.fn().mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', content: '【签名A】您好${name}', auditStatus: 'pending' }), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-1', tenantId: 'tenant-1', ...data })), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-new', ...data })), }, @@ -832,7 +832,7 @@ describe('SmsConfigService', () => { applicationId: 'app-1', signatureId: 'sig-1', name: '运营添加模板', - content: '您的验证码为${code}', + content: '【签名A】您的验证码为${code}', variables: [{ name: 'code', example: '123456', required: true }], }, { initialAuditStatus: 'approved' })).resolves.toEqual(expect.objectContaining({ id: 'tpl-new' })); @@ -842,6 +842,7 @@ describe('SmsConfigService', () => { applicationId: 'app-1', signatureId: 'sig-1', name: '运营添加模板', + content: '【签名A】您的验证码为${code}', auditStatus: 'approved', variables: { create: [{ name: 'code', example: '123456', required: true }], @@ -868,7 +869,7 @@ describe('SmsConfigService', () => { applicationId: 'app-1', signatureId: 'sig-1', name: '模板B', - content: '验证码${code}', + content: '【签名A】验证码${code}', variables: [{ name: 'code', example: '123456', required: true }], })).resolves.toEqual(expect.objectContaining({ id: 'tpl-1', name: '模板B' })); @@ -879,7 +880,7 @@ describe('SmsConfigService', () => { applicationId: 'app-1', signatureId: 'sig-1', name: '模板B', - content: '验证码${code}', + content: '【签名A】验证码${code}', variables: { create: [{ name: 'code', example: '123456', required: true }], }, @@ -887,4 +888,26 @@ describe('SmsConfigService', () => { include: { variables: true, application: true, tenant: true, signature: true }, }); }); + + it('requires the selected signature at the start of template content', async () => { + const prisma = createPrismaMock(); + const service = new SmsConfigService(prisma as never); + + await expect(service.createTemplate({ + tenantId: 'tenant-1', + applicationId: 'app-1', + name: '缺少签名模板', + content: '您的验证码为${code}', + })).rejects.toThrow('短信模板必须选择短信签名'); + + await expect(service.createTemplate({ + tenantId: 'tenant-1', + applicationId: 'app-1', + signatureId: 'sig-1', + name: '签名不匹配模板', + content: '【其他签名】您的验证码为${code}', + })).rejects.toThrow('模板内容必须以所选短信签名 【签名A】 开头'); + + expect(prisma.smsTemplate.create).not.toHaveBeenCalled(); + }); }); diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 68546e6..9ef5b85 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -95,7 +95,8 @@ export interface CreateSmsTemplateOptions { initialAuditStatus?: string; } -export type UpdateSmsTemplateDto = Partial> & { +export type UpdateSmsTemplateDto = Partial> & { + signatureId?: string | null; auditStatus?: string; }; @@ -1135,7 +1136,12 @@ export class SmsConfigService { }); } - createTemplate(data: CreateSmsTemplateDto, options: CreateSmsTemplateOptions = {}) { + async createTemplate(data: CreateSmsTemplateDto, options: CreateSmsTemplateOptions = {}) { + const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } }); + if (!application || application.tenantId !== data.tenantId) { + throw new BadRequestException('applicationId does not belong to the template tenant'); + } + await this.validateTemplateSignature(data.signatureId, data.tenantId, data.applicationId, data.content); return this.prisma.smsTemplate.create({ data: { tenantId: data.tenantId, @@ -1169,11 +1175,13 @@ export class SmsConfigService { throw new BadRequestException('applicationId does not belong to the template tenant'); } } - if (data.signatureId) { - const signature = await this.prisma.smsSignature.findUnique({ where: { id: data.signatureId }, select: { tenantId: true } }); - if (!signature || signature.tenantId !== template.tenantId) { - throw new BadRequestException('signatureId does not belong to the template tenant'); - } + if (data.signatureId !== undefined || data.applicationId !== undefined || data.content !== undefined) { + await this.validateTemplateSignature( + data.signatureId === undefined ? template.signatureId : data.signatureId, + template.tenantId, + data.applicationId ?? template.applicationId, + data.content ?? template.content, + ); } const variables = data.variables ?? (data.content ? inferTemplateVariables(data.content) : undefined); return this.prisma.$transaction(async (tx) => { @@ -1208,6 +1216,7 @@ export class SmsConfigService { if (!template) { throw new NotFoundException('Template not found'); } + await this.validateTemplateSignature(template.signatureId, template.tenantId, template.applicationId, template.content); const updated = await this.prisma.smsTemplate.update({ where: { id: templateId }, @@ -1224,6 +1233,26 @@ export class SmsConfigService { return updated; } + private async validateTemplateSignature(signatureId: string | null | undefined, tenantId: string, applicationId: string, content: string) { + if (!signatureId) { + throw new BadRequestException('短信模板必须选择短信签名'); + } + const signature = await this.prisma.smsSignature.findUnique({ + where: { id: signatureId }, + select: { tenantId: true, applicationId: true, name: true }, + }); + if (!signature || signature.tenantId !== tenantId) { + throw new BadRequestException('signatureId does not belong to the template tenant'); + } + if (signature.applicationId && signature.applicationId !== applicationId) { + throw new BadRequestException('signatureId does not belong to the template application'); + } + const signaturePrefix = normalizeSmsSignature(signature.name); + if (!signaturePrefix || !content.startsWith(signaturePrefix)) { + throw new BadRequestException(`模板内容必须以所选短信签名 ${signaturePrefix || signature.name} 开头`); + } + } + listAuditRecords(targetType?: string, targetId?: string) { return this.prisma.auditRecord.findMany({ where: { @@ -1435,6 +1464,11 @@ function inferTemplateVariables(content: string): TemplateVariableInput[] { return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true })); } +function normalizeSmsSignature(name: string) { + const innerName = name.trim().replace(/^[【\[]+|[】\]]+$/g, '').trim(); + return innerName ? `【${innerName}】` : ''; +} + function startOfToday() { const date = new Date(); date.setHours(0, 0, 0, 0); diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 19091f8..bd324df 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -137,10 +137,12 @@ ### 4.4 模板管理与审核 1. 客户端创建短信模板,填写模板名称、短信内容、变量、应用、签名。 -2. 系统校验敏感词、字数、变量格式和签名匹配。 -3. 运营端短信模板审核可通过或驳回模板。 -4. 运营端在企业模板管理中代企业添加的短信模板,保存后应直接置为已通过 `approved`;客户端自行创建并提交的模板仍按审核流程处理。 -4. 审核通过的模板才允许在短信发送中选择。 +2. 短信模板必须选择签名,模板内容必须以完整中文括号签名 `【签名】` 开头。客户端和运营端选择签名时自动把完整签名填入内容开头,切换签名时替换原前缀而不是重复追加;内容输入框明确提示该规则,字符数和计费条数按“签名 + 正文”完整内容计算。 +3. NestJS 创建、编辑和提交审核时必须校验所选签名属于模板企业/应用,且模板内容以该签名开头;不得只依赖前端自动填充。 +4. 系统校验敏感词、字数、变量格式和签名匹配。 +5. 运营端短信模板审核可通过或驳回模板。 +6. 运营端在企业模板管理中代企业添加的短信模板,保存后应直接置为已通过 `approved`;客户端自行创建并提交的模板仍按审核流程处理。 +7. 审核通过的模板才允许在短信发送中选择。 ### 4.5 短信发送 @@ -547,6 +549,10 @@ - 平均到达时长只使用成功且时间有效的短信,按 `deliveredAt - submittedAt` 计算;每个日期、每个维度组先计算 P95,剔除大于 P95 的最慢 5% 样本后再求平均。无成功或无有效时间样本时展示为空,不以 0 冒充。 - `SmsMessageRecord` 必须固化实际使用的 `drainageInfoId`。新短信在同签名已审核通过的引流信息中按正文精确包含 URL 匹配,优先最长 URL;最长 URL 出现多个同长度候选时视为歧义并不关联。历史短信使用同一规则回填,未命中或歧义统一归入“未关联引流信息”,不得把一条短信复制到签名下所有引流信息造成重复统计。 - 发送质量报表与对账、利润报表共用 T+1 及 T-4 至 T-1 滚动重算任务,每次重算在同一日期事务内重建四个质量维度。 +- 对账单、利润报表、发送质量报表均提供导出功能。导出必须由真实 API 按页面当前筛选条件查询完整结果并生成 CSV,不得只导出当前分页或在浏览器内拼接静态数据。 +- 报备字段库采用自适应卡片布局,分开展示统计概览、签名/引流信息通用字段和字段定义;卡片明确展示通道引用数及通用配置数,已被引用的字段不可删除。 +- 运营端和客户端用户管理页的新增用户按钮使用标准小尺寸操作按钮,不得占用大块页面空间。 +- 项目通用 `Select` 下拉面板默认通过页面级 Portal 渲染,不得被弹窗正文、底部操作栏、卡片或滚动容器裁剪;控件根据视口剩余空间自动向上或向下展开,跟随页面滚动和窗口尺寸变化重新定位,并继续支持名称搜索和滚动浏览全部真实 API 选项。企业签名的企业和企业应用选择等所有页面统一复用该控件,不得另写页面专用下拉实现。 ### 5.20 数据保存与清理 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index dbad8f0..e7e6fd3 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -110,12 +110,16 @@ - 优先级:P0 - 前置条件:存在 active 应用和可用签名。 - 步骤: - 1. 创建模板 `验证码为 ${code}`。 - 2. 保存后查看变量列表。 - 3. 提交模板审核。 + 1. 选择签名 `【测试签名】`,确认模板内容自动出现该前缀,再填写正文 `验证码为 ${code}`。 + 2. 切换到另一个签名,确认只替换原签名前缀且不重复追加。 + 3. 保存后查看变量列表和完整内容计费条数。 + 4. 分别用正确内容、缺少签名和错误签名前缀调用真实模板 API。 + 5. 提交模板审核。 - 预期结果: + - 客户端和运营端模板表单均提示模板必须包含签名;选择签名自动填入完整 `【签名】`,切换时保留正文并替换前缀。 - 系统识别变量 `code`。 - - 计费条数按 70/67 字规则预估。 + - 字符数和计费条数包含签名,按 70/67 字规则预估。 + - NestJS 拒绝未选择签名、签名不属于当前企业/应用或内容未以所选签名开头的请求。 - 提交后模板状态为 pending。 - 生成审核记录。 @@ -3294,6 +3298,9 @@ npm run verify:phase8 | TC-QUALITY-002 | 分别准备多个企业应用、通道、签名和引流信息的短信,并制造 accepted 补发及不同 Gateway 回执。 | 四个 Tab 分组正确;通道只使用对应 submit/receipt;每个 Tab 服务端按 sentUnits 降序,相同数量再按日期和名称稳定排序;T-4~T-1 重算同步更新四类质量行。 | | TC-QUALITY-003 | 同一签名配置短 URL、包含短 URL 的长 URL、两个同长度 URL;发送正文分别命中长 URL、唯一 URL、同长度歧义和完全未命中,再对历史记录执行 migration。 | 新短信与历史短信都优先关联唯一最长 approved URL;歧义和未命中不写伪造 ID 并归入“未关联引流信息”;每条短信在引流维度只统计一次。 | | TC-QUALITY-004 | 打开“发送质量报表”,依次切换企业应用、通道、签名、引流信息 Tab,使用日期、企业、应用、通道筛选并翻页。 | 菜单位于“报表对账”下;页面调用真实 `/admin/reports/quality`;展示发送量、成功量、成功率、P95 截尾平均时长和生成时间,不使用前端明细聚合。 | +| TC-REPORT-EXPORT-001 | 分别在对账单、利润报表、发送质量报表设置日期及维度筛选,数据超过一页后点击“导出报表”。 | 三类页面均调用各自真实 `/admin/reports/*/export` API;CSV 包含全部筛选结果而非当前页,中文可正常打开,逗号和引号正确转义。 | +| TC-ADMIN-REPORT-FIELD-UI-001 | 打开报备字段库,在宽屏与窄屏下检查概览、两类通用字段、字段卡片及筛选,并尝试删除已引用字段。 | 页面不出现横向滚动;信息区自适应排列;引用数真实展示;已引用字段删除按钮禁用;所有增删仍调用真实 API。 | +| TC-USER-BUTTON-UI-001 | 分别打开运营端和客户端用户管理页面。 | 新增用户按钮为标准小尺寸,文字与图标不换行且不挤占标题区域。 | ### 17.6 系统日志细化 @@ -3387,9 +3394,11 @@ npm run verify:phase8 | TC-MOCK-CLEAN-001 | 断开 API 或让 API 返回 500,访问客户端账户余额、充值记录、批量任务、短信发送、签名、模板页面。 | 页面展示错误态或空态;不得出现前端静态余额、任务、模板、签名或最近发送记录。 | | TC-MOCK-CLEAN-002 | 访问运营端数据统计、账务账户、发送监控、安全控制、手机号段库、报备字段库、通道组、报备任务、报备记录。 | 所有列表和卡片来自真实 API;新增动作写入数据库;后端缺失的编辑/删除能力不得用本地状态伪造。 | | TC-UI-ENTERPRISE-SELECT-001 | 逐一打开包含企业或企业应用选择的表单和筛选项,输入部分企业/应用名称。 | 下拉面板提供搜索框并实时缩小真实 API 选项范围;清空后恢复全部选项。 | +| TC-UI-SELECT-PORTAL-001 | 分别在普通页面筛选区、卡片、标准弹窗和 XL 弹窗中展开通用 Select,并改变窗口高度、滚动页面。 | 所有下拉均由通用控件渲染到页面级 Portal,不被父容器裁剪;空间不足时自动换向,滚动或缩放后仍贴合触发控件,选项选择和点击外部关闭正常。 | | TC-UI-TEMPLATE-RESPONSIVE-001 | 在 1024px、1366px 和宽屏视口打开企业短信模板页。 | 模板卡片自适应换列,页面不出现水平滚动,每张卡片的编辑和删除按钮直接可见。 | | TC-ADMIN-ENTERPRISE-TEMPLATE-RESPONSIVE-001 | 在 1024px、1366px 和宽屏视口打开运营端“企业模板管理”,查看长企业名、长模板内容和包含多变量的真实记录。 | 列表行按视口自适应重排,无水平滚动;预览、编辑、删除始终可见且可操作,内容摘要不撑破容器。 | | TC-ADMIN-ENTERPRISE-SIGNATURE-LAYOUT-001 | 在运营端“企业签名管理”打开移动/联通/电信显示“未报备(0/2)”的真实签名,分别使用 1024px 和 1366px 视口。 | 状态标签和数量可分行但各自保持完整;四个操作按钮按两列两行排列,文字不被挤成单字换行,卡片不产生水平滚动。 | +| TC-ADMIN-ENTERPRISE-SIGNATURE-SELECT-001 | 在运营端打开“添加签名”,展开企业下拉并输入部分名称,选择企业后再展开企业应用;分别使用常规高度和 600px 高视口。 | 两个下拉均通过浮层完整显示在弹窗和底部操作栏之上,可搜索、滚动并选择真实 API 选项;空间不足时自动向上展开,列表不被裁剪。 | | TC-ADMIN-REPORT-FIELD-CODE-001 | 在报备字段库分别提交 `License2026`、`license_code`、中文和空白代码,并直接调用真实新增 API 复验。 | 只有 `License2026` 写入 PostgreSQL;前端阻止非法值,API 同样返回 400,不依赖前端校验。 | | TC-ADMIN-CHANNEL-REPORT-SIGNATURE-001 | 打开包含数据库签名 `【安徽航天信息】` 的通道报备详情及签名详情弹窗。 | 两处均只显示单层 `【安徽航天信息】`,不出现重复中括号。 | | TC-MOCK-CLEAN-003 | 运营端创建企业、编辑企业、禁用/启用企业、删除企业,再刷新页面和重新登录客户端。 | Tenant 状态持久化;列表刷新后状态不丢;禁用/删除企业阻断客户端业务访问;动作写系统日志。 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 5764ee4..a4b66d0 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -1,5 +1,12 @@ # 第一版系统化测试进度 +## 2026-07-15 短信模板签名自动填充与真实校验(未提交、未部署) + +- 客户端和运营端短信模板表单将签名改为必选;选择签名时自动在模板内容开头填入规范 `【签名】`,切换签名只替换原前缀并保留正文,清空选择时移除自动前缀。 +- 两套内容输入框均明确提示“模板内容必须以所选签名开头”,字符数、变量识别和计费条数继续基于包含签名的完整内容计算。 +- NestJS 在模板创建、编辑和提交审核时校验真实签名归属及完整内容前缀,阻止绕过页面提交缺失或不匹配签名的模板。SmsConfig 定向 1 suite/33 项、API 全量 18 suites/194 项、Prisma validate/generate/migrate status(47 条 migration 已应用)、API build、前端 build、Gateway `go test ./...` 和 `git diff --check` 均通过;Jest 仍有既有 open-handle 提示,相同全量测试加 `--forceExit` 复核退出码为 0。浏览器交互结果待完成后回填。 +- 应用内浏览器可正常加载最新本地构建,页面标题、登录表单和控制台均正常;本地会话已过期并跳转图形验证码登录页,未绕过验证码进入模板表单,因此没有把目标表单交互误记为浏览器通过。自动填入/替换逻辑由共享纯函数、前端生产构建和真实后端定向/全量测试覆盖,仍建议登录后补一次可见交互复测。 + ## 2026-07-15 运营列表排序、短信批量驳回与通道成本展示(已提交、已部署) - 短信审核页增加“驳回已选”,统一填写非空原因后调用真实 `/admin/risk-review/tasks/batch/reject`;NestJS 对 id 去重、限制单批最多 100 条并逐项执行现有风控拒绝和发送链路拒绝处理,不使用前端本地状态冒充完成。 @@ -1896,3 +1903,12 @@ git diff --check - 已新增 TC-GW-ACK-005 和 Gateway 回归:首个返回包必须是 SubmitResp,随后失败回执 Deliver Msg_Id 与 SubmitResp 完全相同;另覆盖精确映射不回退、持久化 Sequence_Id 恢复和 Msg_Id=0 拒绝。API 全量 15 suites、154 项、Gateway 全量 Go 测试、Prisma validate、真实本地 PostgreSQL migration、API build、前端 build 和 `git diff --check` 均通过;前端仅有既有 Vite chunk size warning。经用户授权在应用内浏览器完成一次本地图形验证码登录,使用真实 NestJS API、PostgreSQL 和临时投递记录在 1440×1000 视口验证列表无横向挤压、无 `NaN`、console 无 error/warn,`delivered` 行的重投按钮可用且点击确实进入真实后端重投链路;因本地未运行 Gateway,请求按预期变为待重试而非伪造成功。临时投递记录和验收账号已清理。 - 功能提交 `1ce02ef2` 已 push 并部署生产。部署前 PostgreSQL 备份为 `/opt/cmpp-platform/backups/cmpp-20260714-163007.sql`(约 65MB),运行源码备份为 `/opt/cmpp-platform/backups/source-20260714-163007.tar.gz`(约 21MB);发布包本地与服务器 SHA-256 均为 `67a79f17bd2f61b5df3057ded500009d5d3ef95bf656728299f3ead0011a763a`。 - 生产 migration `20260714153000_fix_downstream_receipt_message_id` 成功应用,38 条 migration 全部完成;错误的 `receipt/status=delivered/ackMessageId=0` 已降为 0,共 9 条历史记录按真实口径纠正为 `unconfirmed`。生产 `.deployed-commit=1ce02ef2066aa1ecd995b0a3b884304218adc008`,`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均 active,`12026/17890/8090/3000` 监听,API/Gateway health、Redis、PostgreSQL 和外部首页/运营入口 HTTP 200;部署后 journal 无 error,真实 CMPP2.0 账号 `910887` 已重新连接并持续心跳。未擅自发送或重投客户短信,后续真实新提交用于验证 SubmitResp 与 Deliver 的 Msg_Id 关联。 +## 2026-07-15 报表导出与基础配置 UI 优化(工作区待提交) + +- 运营端对账单、利润报表、发送质量报表增加真实服务端 CSV 导出,复用页面日期、企业、应用、通道和统计维度筛选,导出全部筛选结果且不受当前分页影响。 +- 报备字段库重做为统计概览、签名/引流信息通用配置双栏和自适应字段卡片;继续使用真实字段库及通用字段 API,保留引用锁定删除规则。 +- 运营端、客户端用户管理新增按钮统一调整为标准小尺寸。 +- 本地真实 NestJS API 与 PostgreSQL 登录验收发现并修复两处仅构建无法暴露的布局问题:运营端新增用户按钮曾被 Grid 拉伸至 731px,现为 98×32px;报备字段库在 800px 视口的筛选区曾出现内部横向滚动,现已切换单列且页面与工具栏 `scrollWidth=clientWidth`。1280px 桌面下三类报表均加载真实聚合数据并显示唯一“导出报表”入口,页面无横向溢出、无 console error/warn。 +- 将下拉裁剪修复收敛到项目通用 `Select`:所有下拉默认使用 `document.body` Portal 和 fixed 定位,最高展示 320px 选项,并随视口、页面滚动实时重定位,页面不再逐个配置专用下拉。应用内浏览器以企业签名真实本地数据验证企业搜索、企业选择及应用联动;831px 高视口下列表完整显示在弹窗上方层级,600px 高视口自动向上展开且 `top >= 0`、`bottom <= viewport`,控制台无 error/warn。 +- 提交前整批验证:API 全量 18 suites、194 项通过,API build、前端 build、Gateway 全量 Go 测试、Prisma validate 和本地 47 条 migration status 均通过;通用 Select 另在利润报表普通筛选区确认 listbox 直接挂载于 `BODY`、使用 fixed 定位且完整处于视口内。前端仅保留既有 Vite chunk size warning,Jest 仍需 `--forceExit` 退出既有异步句柄。 +- 本批按要求仅保留工作区改动,不提交、不推送、不部署。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index a3e17f9..ca6dda3 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -1055,10 +1055,16 @@ export const adminApi = { listChannels: () => request('/admin/channels'), listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) => request>(withQuery('/admin/reports/reconciliation', query)), + exportReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string } = {}) => + requestBlob(withQuery('/admin/reports/reconciliation/export', query)), listProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) => request & { dimensionType: 'application' | 'channel' }>(withQuery('/admin/reports/profit', query)), + exportProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string } = {}) => + requestBlob(withQuery('/admin/reports/profit/export', query)), listQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) => request & { dimensionType: DailyQualityReport['dimensionType'] }>(withQuery('/admin/reports/quality', query)), + exportQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string } = {}) => + requestBlob(withQuery('/admin/reports/quality/export', query)), createChannel: (body: Partial & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) => request('/admin/channels', { method: 'POST', body: JSON.stringify(body) }), updateChannel: (id: string, body: Partial & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) => diff --git a/src/apps/admin/AdminDrainageFieldsPage.tsx b/src/apps/admin/AdminDrainageFieldsPage.tsx index 86f4b64..43504ef 100644 --- a/src/apps/admin/AdminDrainageFieldsPage.tsx +++ b/src/apps/admin/AdminDrainageFieldsPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; -import { Plus, Search, Trash2 } from 'lucide-react'; -import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui'; +import { Database, FileCheck2, Link2, Plus, Search, Trash2 } from 'lucide-react'; +import { Breadcrumb, Button, Input, Modal, Select, Textarea, Tag } from '@/components/ui'; import { adminApi, type CommonReportField, type DictionaryItem } from '@/api/adminApi'; type DrainageField = DictionaryItem & { @@ -114,25 +114,9 @@ export function AdminDrainageFieldsPage() { .catch((failure: Error) => setError(failure.message || '通用字段删除失败')); } - const columns = useMemo>>(() => [ - { key: 'code', title: '字段代码', width: '160px', render: (record) => {record.code} }, - { key: 'name', title: '字段名称', width: '190px', render: (record) => record.name ?? '-' }, - { key: 'type', title: '字段类型', width: '160px', render: (record) => {typeLabels[record.fieldType ?? ''] ?? record.fieldType} }, - { key: 'description', title: '描述', render: (record) => record.description ?? '-' }, - { key: 'required', title: '是否必填', width: '120px', render: (record) => {record.required ? '必填' : '选填'} }, - { key: 'usageCount', title: '使用通道数', width: '130px', render: (record) => 0 ? 'warning' : 'neutral'}>{record.usageCount ?? 0} }, - { key: 'commonUsageCount', title: '通用配置数', width: '130px', render: (record) => 0 ? 'info' : 'neutral'}>{record.commonUsageCount ?? 0} }, - { key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => }, - ], []); - - const commonColumns = useMemo>>(() => [ - { key: 'name', title: '字段名称', render: (record) => {String(record.drainageField.name ?? record.drainageField.code ?? '-')} }, - { key: 'code', title: '字段代码', width: '170px', render: (record) => String(record.drainageField.code ?? '-') }, - { key: 'reportType', title: '资料用途', width: '180px', render: (record) => {record.reportType === 'signature' ? '签名报备资料' : '引流信息报备资料'} }, - { key: 'fieldType', title: '字段类型', width: '130px', render: (record) => typeLabels[String(record.drainageField.fieldType ?? '')] ?? String(record.drainageField.fieldType ?? '-') }, - { key: 'required', title: '是否必填', width: '120px', render: (record) => {record.required ? '必填' : '选填'} }, - { key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => }, - ], []); + const signatureCommon = commonFields.filter((field) => field.reportType === 'signature'); + const drainageCommon = commonFields.filter((field) => field.reportType === 'drainage'); + const referencedCount = fields.filter((field) => (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0).length; return (
@@ -140,10 +124,18 @@ export function AdminDrainageFieldsPage() {

报备字段库

+

统一维护签名和引流信息的资料字段,并配置全平台通用报备要求。

+ {error ?

{error}

: null} +
+
{fields.length}

字段总数

+
{commonFields.length}

通用字段配置

+
{referencedCount}

已被引用字段

+
+
setKeyword(event.target.value)} placeholder="搜索字段名称、代码或描述..." prefix={} value={keyword} /> update('signatureId', event.target.value)} + onChange={(event) => selectSignature(event.target.value)} options={[ - { label: '不绑定签名', value: '' }, + { label: '请选择签名', value: '' }, ...tenantSignatures.map((signature) => ({ label: signature.name, value: signature.id })), ]} + required value={form.signatureId} /> update('name', event.target.value)} placeholder="请输入模板名称" required value={form.name} /> update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} />