feat: harden CMPP delivery and platform workflows
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { CreateImportProfileDto, CreateReportBatchDto, ImportCommitDto, ReportMaterialsService } from './report-materials.service';
|
||||
|
||||
type UploadedWorkbook = { originalname: string; mimetype: string; size: number; buffer: Buffer };
|
||||
type DownloadResponse = { setHeader(name: string, value: string): void; send(content: Buffer): void };
|
||||
|
||||
@ApiTags('report-materials')
|
||||
@Controller('admin/report-materials')
|
||||
@@ -16,6 +18,17 @@ export class ReportMaterialsController {
|
||||
return this.service.listPending({ reportType, tenantId, applicationId });
|
||||
}
|
||||
|
||||
@Get('templates/:reportType')
|
||||
async downloadTemplate(@Param('reportType') reportType: 'signature' | 'drainage', @CurrentSessionUserId() operatorId: string | undefined, @Res() response: DownloadResponse) {
|
||||
if (!['signature', 'drainage'].includes(reportType)) throw new BadRequestException('reportType must be signature or drainage');
|
||||
this.sendWorkbook(response, await this.service.buildOfficialTemplate(reportType, operatorId));
|
||||
}
|
||||
|
||||
@Get('pending/export')
|
||||
async exportPending(@Query('reportType') reportType: 'signature' | 'drainage' | undefined, @Query('tenantId') tenantId: string | undefined, @Query('applicationId') applicationId: string | undefined, @CurrentSessionUserId() operatorId: string | undefined, @Res() response: DownloadResponse) {
|
||||
this.sendWorkbook(response, await this.service.exportPending({ reportType, tenantId, applicationId }, operatorId));
|
||||
}
|
||||
|
||||
@Get('import-profiles')
|
||||
listImportProfiles(@Query('reportType') reportType?: 'signature' | 'drainage') {
|
||||
return this.service.listImportProfiles(reportType);
|
||||
@@ -28,8 +41,8 @@ export class ReportMaterialsController {
|
||||
}
|
||||
|
||||
@Post('imports/analyze')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 100 * 1024 * 1024, files: 1, fields: 12, parts: 13 } }))
|
||||
analyzeImport(@UploadedFile() file: UploadedWorkbook, @Body() body: Record<string, string>) {
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 12, parts: 13 } }))
|
||||
analyzeImport(@UploadedFile() file: UploadedWorkbook, @Body() body: Record<string, string>, @CurrentSessionUserId() operatorId?: string) {
|
||||
if (!file) throw new BadRequestException('请选择 XLSX 文件');
|
||||
return this.service.analyzeImport(file, {
|
||||
tenantId: body.tenantId,
|
||||
@@ -39,13 +52,14 @@ export class ReportMaterialsController {
|
||||
headerRowCount: Number(body.headerRowCount || 1),
|
||||
dataStartRow: Number(body.dataStartRow || 2),
|
||||
profileId: body.profileId || undefined,
|
||||
operatorId,
|
||||
});
|
||||
}
|
||||
|
||||
@Put('imports/:id/commit')
|
||||
@RequireRecentAuthentication()
|
||||
commitImport(@Param('id') id: string, @Body() body: ImportCommitDto) {
|
||||
return this.service.commitImport(id, body);
|
||||
commitImport(@Param('id') id: string, @Body() body: ImportCommitDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
return this.service.commitImport(id, { ...body, operatorId });
|
||||
}
|
||||
|
||||
@Get('batches')
|
||||
@@ -55,7 +69,14 @@ export class ReportMaterialsController {
|
||||
|
||||
@Post('batches')
|
||||
@RequireRecentAuthentication()
|
||||
createBatch(@Body() body: CreateReportBatchDto) {
|
||||
return this.service.createBatch(body);
|
||||
createBatch(@Body() body: CreateReportBatchDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
return this.service.createBatch({ ...body, createdById: operatorId });
|
||||
}
|
||||
|
||||
|
||||
private sendWorkbook(response: DownloadResponse, exported: { fileName: string; content: Buffer }) {
|
||||
response.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`);
|
||||
response.send(exported.content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,33 @@ import ExcelJS from 'exceljs';
|
||||
import { ReportMaterialsService } from './report-materials.service';
|
||||
|
||||
describe('ReportMaterialsService', () => {
|
||||
it('builds an official XLSX import template with documented signature columns', async () => {
|
||||
const operationLog = { create: jest.fn().mockResolvedValue({ id: 'log-template' }) };
|
||||
const service = new ReportMaterialsService({ operationLog } as never, {} as never, {} as never);
|
||||
const exported = await service.buildOfficialTemplate('signature', 'operator-1');
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(exported.content as never);
|
||||
expect(exported.fileName).toContain('签名报备资料官方模板');
|
||||
expect(workbook.worksheets[0].getRow(1).values).toEqual(expect.arrayContaining(['短信签名', '用途说明']));
|
||||
expect(operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ userId: 'operator-1' }) });
|
||||
});
|
||||
|
||||
it('rejects formula cells before storing or importing a workbook', async () => {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('签名资料');
|
||||
sheet.addRow(['短信签名']);
|
||||
sheet.getCell('A2').value = { formula: 'HYPERLINK("https://invalid.example","click")', result: 'click' };
|
||||
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
const files = { upload: jest.fn() };
|
||||
const service = new ReportMaterialsService({ reportMaterialImportProfile: { findUnique: jest.fn() } } as never, files as never, {} as never);
|
||||
|
||||
await expect(service.analyzeImport(
|
||||
{ originalname: 'unsafe.xlsx', mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer },
|
||||
{ tenantId: 'tenant-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2 },
|
||||
)).rejects.toThrow('公式或可执行单元格');
|
||||
expect(files.upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('detects WPS-compatible embedded images and source columns during XLSX analysis', async () => {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('签名资料');
|
||||
@@ -13,6 +40,7 @@ describe('ReportMaterialsService', () => {
|
||||
const prisma = {
|
||||
reportMaterialImportProfile: { findUnique: jest.fn().mockResolvedValue({ sheetName: '签名资料', columns: [{ sourceHeader: '短信签名', sourceHeaderPath: '短信签名', sourceColumnIndex: 9, targetFieldCode: 'signature_name', targetKind: 'signatureName', fieldType: 'string', required: true, sortOrder: 10 }] }) },
|
||||
reportMaterialImportBatch: { create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'import-1', ...data })) },
|
||||
operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-1' }) },
|
||||
};
|
||||
const files = { upload: jest.fn().mockResolvedValue({ id: 'source-1', fileName: '签名资料.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) };
|
||||
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface CreateImportProfileDto {
|
||||
export interface ImportCommitDto {
|
||||
mappings: ImportMapping[];
|
||||
profile?: CreateImportProfileDto;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface CreateReportBatchDto {
|
||||
@@ -50,6 +51,7 @@ type AnalyzeImportOptions = {
|
||||
headerRowCount: number;
|
||||
dataStartRow: number;
|
||||
profileId?: string;
|
||||
operatorId?: string;
|
||||
};
|
||||
|
||||
type EmbeddedImage = { row: number; column: number; extension: string; buffer: Buffer };
|
||||
@@ -62,6 +64,48 @@ export class ReportMaterialsService {
|
||||
private readonly smsConfig: SmsConfigService,
|
||||
) {}
|
||||
|
||||
async buildOfficialTemplate(reportType: 'signature' | 'drainage', operatorId?: string) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'CMPP短信平台';
|
||||
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名资料' : '引流信息', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
const headers = reportType === 'signature'
|
||||
? ['短信签名', '用途说明', '营业执照图片', '授权书图片', '备注']
|
||||
: ['短信签名', '站点名称', 'URL', '备注', '网站截图'];
|
||||
sheet.addRow(headers);
|
||||
sheet.addRow(reportType === 'signature'
|
||||
? ['示例签名', '验证码通知', '请在本单元格插入图片', '请在本单元格插入图片', '示例行,导入前请删除']
|
||||
: ['示例签名', '官方站点', 'https://example.com', '示例行,导入前请删除', '请在本单元格插入图片']);
|
||||
styleHeader(sheet.getRow(1));
|
||||
sheet.columns.forEach((column) => { column.width = 24; });
|
||||
sheet.getRow(2).height = 48;
|
||||
const content = Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
const fileName = `${reportType === 'signature' ? '签名' : '引流信息'}报备资料官方模板.xlsx`;
|
||||
await this.prisma.operationLog.create({ data: {
|
||||
userId: operatorId, action: 'report_material.template_downloaded', resource: 'report_material',
|
||||
detail: { fileName, filters: { reportType }, successCount: 1, failedCount: 0 } as Prisma.InputJsonValue,
|
||||
} });
|
||||
return { fileName, content };
|
||||
}
|
||||
|
||||
async exportPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }, operatorId?: string) {
|
||||
const items = await this.listPending(query);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('待报备资料', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
sheet.addRow(['资料类型', '企业', '企业应用', '签名/站点', '详情', '变更时间']);
|
||||
styleHeader(sheet.getRow(1));
|
||||
for (const item of items) sheet.addRow([
|
||||
item.reportType === 'signature' ? '签名' : '引流信息', safeSpreadsheetText(item.tenant?.name),
|
||||
safeSpreadsheetText(item.application?.name), safeSpreadsheetText(item.name), safeSpreadsheetText(item.detail), item.changedAt,
|
||||
]);
|
||||
sheet.columns.forEach((column, index) => { column.width = index === 4 ? 42 : 22; });
|
||||
const fileName = `待报备资料-${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
await this.prisma.operationLog.create({ data: {
|
||||
tenantId: query.tenantId, userId: operatorId, action: 'report_material.pending_export', resource: 'report_material',
|
||||
detail: { fileName, filters: query, successCount: items.length, failedCount: 0 } as Prisma.InputJsonValue,
|
||||
} });
|
||||
return { fileName, content: Buffer.from(await workbook.xlsx.writeBuffer()) };
|
||||
}
|
||||
|
||||
async listPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }) {
|
||||
const [signatures, drainageInfos] = await Promise.all([
|
||||
query.reportType === 'drainage' ? Promise.resolve([]) : this.prisma.smsSignature.findMany({
|
||||
@@ -119,6 +163,7 @@ export class ReportMaterialsService {
|
||||
if (!['signature', 'drainage'].includes(options.reportType)) throw new BadRequestException('reportType must be signature or drainage');
|
||||
if (extname(file.originalname).toLowerCase() !== '.xlsx' || file.buffer[0] !== 0x50 || file.buffer[1] !== 0x4b) throw new BadRequestException('仅支持有效的 XLSX 文件');
|
||||
const workbook = await loadWorkbook(file.buffer);
|
||||
assertSafeWorkbook(workbook);
|
||||
const profile = options.profileId ? await this.prisma.reportMaterialImportProfile.findUnique({ where: { id: options.profileId }, include: { columns: { orderBy: { sortOrder: 'asc' } } } }) : null;
|
||||
const selectedSheetName = options.sheetName || profile?.sheetName || undefined;
|
||||
const worksheet = selectedSheetName ? workbook.getWorksheet(selectedSheetName) : workbook.worksheets[0];
|
||||
@@ -174,6 +219,10 @@ export class ReportMaterialsService {
|
||||
rowCount: Math.max(0, worksheet.rowCount - dataStartRow + 1),
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({ data: {
|
||||
tenantId: options.tenantId, userId: options.operatorId, action: 'report_material.import_analyzed', resource: 'report_material_import', resourceId: batch.id,
|
||||
detail: { fileName: sourceFile.fileName, filters: { applicationId: options.applicationId, reportType: options.reportType, sheetName: worksheet.name }, successCount: previewRows.length, failedCount: 0 } as Prisma.InputJsonValue,
|
||||
} });
|
||||
return { ...batch, sourceFile, sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length, suggestedMappings };
|
||||
}
|
||||
|
||||
@@ -184,6 +233,7 @@ export class ReportMaterialsService {
|
||||
if (data.profile) await this.saveImportProfile({ ...data.profile, reportType: batch.reportType as 'signature' | 'drainage', columns: data.mappings });
|
||||
const { content } = await this.files.getDownload(batch.fileObjectId);
|
||||
const workbook = await loadWorkbook(content);
|
||||
assertSafeWorkbook(workbook);
|
||||
const worksheet = workbook.getWorksheet(batch.sheetName);
|
||||
if (!worksheet) throw new BadRequestException('导入工作表不存在');
|
||||
const images = readEmbeddedImages(workbook, worksheet);
|
||||
@@ -218,7 +268,7 @@ export class ReportMaterialsService {
|
||||
failures.push({ rowNumber, reason: error instanceof Error ? error.message : '导入失败' });
|
||||
}
|
||||
}
|
||||
return this.prisma.reportMaterialImportBatch.update({
|
||||
const updated = await this.prisma.reportMaterialImportBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
status: failures.length ? (successCount ? 'partial_failed' : 'failed') : 'completed',
|
||||
@@ -229,6 +279,11 @@ export class ReportMaterialsService {
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({ data: {
|
||||
tenantId: batch.tenantId, userId: data.operatorId, action: 'report_material.import_committed', resource: 'report_material_import', resourceId: batch.id,
|
||||
detail: { fileName: batch.fileName, filters: { applicationId: batch.applicationId, reportType: batch.reportType, sheetName: batch.sheetName }, successCount, failedCount: failures.length } as Prisma.InputJsonValue,
|
||||
} });
|
||||
return updated;
|
||||
}
|
||||
|
||||
listBatches() {
|
||||
@@ -402,6 +457,26 @@ async function loadWorkbook(buffer: Buffer) {
|
||||
return workbook;
|
||||
}
|
||||
|
||||
function assertSafeWorkbook(workbook: ExcelJS.Workbook) {
|
||||
for (const worksheet of workbook.worksheets) {
|
||||
worksheet.eachRow((row) => row.eachCell((cell) => {
|
||||
const value = cell.value;
|
||||
if (value && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) {
|
||||
throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`);
|
||||
}
|
||||
const text = typeof value === 'string' ? value.trimStart() : '';
|
||||
if (/^[=+@]/.test(text) || /^-[^\d.]/.test(text)) {
|
||||
throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`);
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function safeSpreadsheetText(value: unknown) {
|
||||
const text = value == null ? '' : String(value);
|
||||
return /^[=+@]/.test(text) || /^-[^\d.]/.test(text) ? `'${text}` : text;
|
||||
}
|
||||
|
||||
function readEmbeddedImages(workbook: ExcelJS.Workbook, worksheet: ExcelJS.Worksheet): EmbeddedImage[] {
|
||||
const getImages = (worksheet as unknown as { getImages?: () => Array<{ imageId: number; range: { tl: { nativeRow?: number; nativeCol?: number; row?: number; col?: number } } }> }).getImages;
|
||||
if (!getImages) return [];
|
||||
|
||||
Reference in New Issue
Block a user