feat: polish reporting templates and shared controls

This commit is contained in:
hectorzhao
2026-07-15 16:31:49 +08:00
parent 28fb8e9038
commit a7a4e8d9f6
21 changed files with 488 additions and 119 deletions
+27 -1
View File
@@ -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}`);
}
}
+14
View File
@@ -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' }],
}));
});
});
+67 -22
View File
@@ -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<Array<string | number>>) {
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;
+28 -5
View File
@@ -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();
});
});
+41 -7
View File
@@ -95,7 +95,8 @@ export interface CreateSmsTemplateOptions {
initialAuditStatus?: string;
}
export type UpdateSmsTemplateDto = Partial<Omit<CreateSmsTemplateDto, 'tenantId'>> & {
export type UpdateSmsTemplateDto = Partial<Omit<CreateSmsTemplateDto, 'tenantId' | 'signatureId'>> & {
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);