feat: support WPS report material workbooks

This commit is contained in:
hectorzhao
2026-09-04 17:10:04 +08:00
parent 48d0363920
commit fb39c8b606
29 changed files with 2737 additions and 734 deletions
@@ -2,6 +2,8 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import JSZip from 'jszip';
import { FilesService } from '../files/files.service';
import { safeFileName } from './report-materials.helpers';
import type { ReportWorkbookFormat } from './report-materials.contracts';
import { convertWorkbookOutput } from './workbook-compatibility';
type BatchDownloadSource = {
batchNo: string;
@@ -29,7 +31,11 @@ type DownloadedBatchArtifact = {
export class ReportBatchDownloadService {
constructor(private readonly files: FilesService) {}
async exportFile(batch: BatchDownloadSource, fileId: string): Promise<DownloadedBatchArtifact> {
async exportFile(
batch: BatchDownloadSource,
fileId: string,
outputFormat: ReportWorkbookFormat = 'excel_drawing',
): Promise<DownloadedBatchArtifact> {
const brief = batch.briefs.find((item) => item.fileId === fileId);
const exportFile = batch.exportFiles.find((item) => item.id === fileId);
if (!brief || !exportFile?.fileObjectId) throw new NotFoundException('批次报备文件不存在');
@@ -37,11 +43,14 @@ export class ReportBatchDownloadService {
return {
fileName: `${this.entryBaseName(batch, brief.channelName)}.xlsx`,
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
content,
content: await convertWorkbookOutput(content, outputFormat),
};
}
async exportBundle(batch: BatchDownloadSource): Promise<DownloadedBatchArtifact> {
async exportBundle(
batch: BatchDownloadSource,
outputFormat: ReportWorkbookFormat = 'excel_drawing',
): Promise<DownloadedBatchArtifact> {
if (!batch.briefs.length) throw new BadRequestException('该批次暂无可导出的通道文件或简报');
if (batch.briefs.length > 100) throw new BadRequestException('单次最多打包100个通道文件');
const exportFileById = new Map(batch.exportFiles.map((item) => [item.id, item]));
@@ -50,7 +59,10 @@ export class ReportBatchDownloadService {
const exportFile = exportFileById.get(brief.fileId);
if (!exportFile?.fileObjectId) throw new BadRequestException(`通道“${brief.channelName}”缺少报备文件`);
const workbook = await this.files.getDownload(exportFile.fileObjectId);
return { brief, workbook };
return {
brief,
workbook: { ...workbook, content: await convertWorkbookOutput(workbook.content, outputFormat) },
};
}),
);
const totalBytes = downloaded.reduce((sum, item) => sum + item.workbook.content.length, 0);
@@ -1,45 +1,12 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import type {
AnalyzeImportOptions,
CreateImportProfileDto,
CreateReportBatchDto,
EmbeddedImage,
ImportCommitDto,
ImportMapping,
PagedQuery,
ReportBatchInspection,
ReportBatchTarget,
ReviewImportItemsDto,
SingleReportMaterialDto,
} from './report-materials.contracts';
import type { SingleReportMaterialDto } from './report-materials.contracts';
import {
profileData,
validateProfile,
loadWorkbook,
assertSafeWorkbook,
safeSpreadsheetText,
readEmbeddedImages,
suggestMappings,
remapProfileColumns,
signatureCoreMapping,
drainageCoreMapping,
normalizeHeader,
normalizeFieldCode,
clamp,
normalizePage,
normalizePageSize,
dateRange,
cellText,
transformValue,
mappedCoreValue,
dynamicValues,
jsonRecord,
hasValue,
isFileRef,
@@ -47,12 +14,9 @@ import {
applyExportTransform,
styleHeader,
normalizeImageExtension,
imageContentType,
safeFileName,
normalizeBatchIdempotencyKey,
jsonStringArray,
jsonSafe,
} from './report-materials.helpers';
import { convertWorkbookOutput } from './workbook-compatibility';
import type { ReportBatchGenerationService } from './batch-generation.service';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
@@ -97,7 +61,7 @@ export class ReportChannelExportService {
);
const smsContentField = fields.find((field) => field.name.trim() === '短信内容');
const smsContentValue = smsContentField
? resolveExportValue(item.snapshot, smsContentField.code, smsContentField.name) ?? ''
? (resolveExportValue(item.snapshot, smsContentField.code, smsContentField.name) ?? '')
: '';
const smsContent = isFileRef(smsContentValue) ? '' : String(smsContentValue ?? '');
const missing = fields.filter((field, index) => field.required && !hasValue(values[index]));
@@ -292,6 +256,11 @@ export class ReportChannelExportService {
});
const configuredCodes = new Set(fields.map((field) => field.code));
const values = jsonRecord(snapshot.values);
const historicalCodes = Object.keys(values).filter((code) => !configuredCodes.has(code));
const historicalDefinitions = historicalCodes.length
? await this.prisma.drainageField.findMany({ where: { code: { in: historicalCodes } } })
: [];
const historicalDefinitionByCode = new Map(historicalDefinitions.map((field) => [field.code, field]));
const materialFields = fields.map((field) => {
const submittedValue = resolveExportValue(snapshot, field.code, field.name);
const value = hasValue(submittedValue) ? submittedValue : field.defaultValue;
@@ -312,9 +281,9 @@ export class ReportChannelExportService {
};
});
const historicalFields = Object.entries(values)
.filter(([code, value]) => !configuredCodes.has(code) && hasValue(value))
.filter(([code]) => !configuredCodes.has(code) && historicalDefinitionByCode.get(code)?.status !== 'deleted')
.sort(([left], [right]) => left.localeCompare(right, 'zh-CN'))
.map(([code, value]) => ({ code, name: code, value }));
.map(([code, value]) => ({ code, name: historicalDefinitionByCode.get(code)?.name ?? code, value }));
return {
reportType,
signatureId: signature.id,
@@ -380,7 +349,7 @@ export class ReportChannelExportService {
}
row.height = targetHeight;
const fileName = `${safeFileName(detail.channel.name)}-${safeFileName(detail.signatureName)}-V${detail.materialVersion}.xlsx`;
const content = Buffer.from(await workbook.xlsx.writeBuffer());
const content = await convertWorkbookOutput(Buffer.from(await workbook.xlsx.writeBuffer()), data.outputFormat);
await this.prisma.operationLog.create({
data: {
tenantId: detail.tenant.id,
+161 -101
View File
@@ -1,117 +1,177 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
import type { AnalyzeImportOptions, CreateImportProfileDto, ImportMapping } from './report-materials.contracts';
import {
profileData,
validateProfile,
suggestMappings,
remapProfileColumns,
clamp,
cellText,
} from './report-materials.helpers';
import { compatibleImages, loadCompatibleWorkbook } from './workbook-compatibility';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportImportParserService {
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
constructor(
private readonly prisma: PrismaService,
private readonly files: FilesService,
private readonly smsConfig: SmsConfigService,
) {}
listImportProfiles(reportType?: 'signature' | 'drainage') {
return this.prisma.reportMaterialImportProfile.findMany({
where: { reportType, status: 'active' },
include: { columns: { orderBy: [{ sortOrder: 'asc' }, { sourceColumnIndex: 'asc' }] } },
orderBy: { updatedAt: 'desc' },
});
}
return this.prisma.reportMaterialImportProfile.findMany({
where: { reportType, status: 'active' },
include: { columns: { orderBy: [{ sortOrder: 'asc' }, { sourceColumnIndex: 'asc' }] } },
orderBy: { updatedAt: 'desc' },
});
}
async saveImportProfile(data: CreateImportProfileDto) {
validateProfile(data);
return this.prisma.$transaction(async (tx) => {
const profile = data.id
? await tx.reportMaterialImportProfile.update({ where: { id: data.id }, data: profileData(data) })
: await tx.reportMaterialImportProfile.create({ data: profileData(data) });
await tx.reportMaterialImportProfileColumn.deleteMany({ where: { profileId: profile.id } });
await tx.reportMaterialImportProfileColumn.createMany({
data: data.columns.map((column, index) => ({
profileId: profile.id,
sourceHeader: column.sourceHeader,
sourceHeaderPath: column.sourceHeaderPath,
sourceColumnIndex: column.sourceColumnIndex,
targetFieldCode: column.targetFieldCode,
targetKind: column.targetKind,
fieldType: column.fieldType,
required: column.required ?? false,
transform: column.transform,
sortOrder: column.sortOrder ?? (index + 1) * 10,
})),
});
return tx.reportMaterialImportProfile.findUnique({ where: { id: profile.id }, include: { columns: { orderBy: { sortOrder: 'asc' } } } });
validateProfile(data);
return this.prisma.$transaction(async (tx) => {
const profile = data.id
? await tx.reportMaterialImportProfile.update({ where: { id: data.id }, data: profileData(data) })
: await tx.reportMaterialImportProfile.create({ data: profileData(data) });
await tx.reportMaterialImportProfileColumn.deleteMany({ where: { profileId: profile.id } });
await tx.reportMaterialImportProfileColumn.createMany({
data: data.columns.map((column, index) => ({
profileId: profile.id,
sourceHeader: column.sourceHeader,
sourceHeaderPath: column.sourceHeaderPath,
sourceColumnIndex: column.sourceColumnIndex,
targetFieldCode: column.targetFieldCode,
targetKind: column.targetKind,
fieldType: column.fieldType,
required: column.required ?? false,
transform: column.transform,
sortOrder: column.sortOrder ?? (index + 1) * 10,
})),
});
}
return tx.reportMaterialImportProfile.findUnique({
where: { id: profile.id },
include: { columns: { orderBy: { sortOrder: 'asc' } } },
});
});
}
async analyzeImport(file: { originalname: string; mimetype: string; size: number; buffer: Buffer }, options: AnalyzeImportOptions) {
if (!options.tenantId) throw new BadRequestException('tenantId is required');
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];
if (!worksheet) throw new BadRequestException('工作簿没有可读取的工作表');
const headerRowCount = clamp(options.headerRowCount, 1, 5);
const dataStartRow = Math.max(options.dataStartRow, headerRowCount + 1);
const images = readEmbeddedImages(workbook, worksheet);
const columnCount = Math.min(worksheet.columnCount, 200);
const columns = Array.from({ length: columnCount }, (_, offset) => {
const sourceColumnIndex = offset + 1;
const parts = Array.from({ length: headerRowCount }, (_, headerOffset) => cellText(worksheet.getCell(headerOffset + 1, sourceColumnIndex))).filter(Boolean);
const sourceHeaderPath = [...new Set(parts)].join('/');
return {
sourceColumnIndex,
columnLetter: worksheet.getColumn(sourceColumnIndex).letter,
sourceHeader: parts.at(-1) || `${sourceColumnIndex}`,
sourceHeaderPath,
imageCount: images.filter((image) => image.column === sourceColumnIndex).length,
};
}).filter((column) => column.sourceHeaderPath || column.imageCount > 0);
const previewRows = [];
for (let rowNumber = dataStartRow; rowNumber <= Math.min(worksheet.rowCount, dataStartRow + 9); rowNumber += 1) {
const values = Object.fromEntries(columns.map((column) => [String(column.sourceColumnIndex), cellText(worksheet.getCell(rowNumber, column.sourceColumnIndex))]));
const imageColumns = images.filter((image) => image.row === rowNumber).map((image) => image.column);
if (Object.values(values).some(Boolean) || imageColumns.length) previewRows.push({ rowNumber, values, imageColumns });
}
const sourceFile = await this.files.upload({ tenantId: options.tenantId, purpose: 'report_material_import', prefix: 'report-material-imports' }, file);
const profileMappings = profile?.columns.map((column) => ({
sourceHeader: column.sourceHeader,
sourceHeaderPath: column.sourceHeaderPath ?? undefined,
sourceColumnIndex: column.sourceColumnIndex,
targetFieldCode: column.targetFieldCode,
targetKind: column.targetKind as ImportMapping['targetKind'],
fieldType: column.fieldType as ImportMapping['fieldType'],
required: column.required,
transform: column.transform ?? undefined,
sortOrder: column.sortOrder,
}));
const suggestedMappings = profileMappings?.length ? remapProfileColumns(profileMappings, columns) : suggestMappings(columns, options.reportType);
const batch = await this.prisma.reportMaterialImportBatch.create({
data: {
tenantId: options.tenantId,
applicationId: options.applicationId,
profileId: options.profileId,
fileObjectId: sourceFile.id,
fileName: sourceFile.fileName,
reportType: options.reportType,
sheetName: worksheet.name,
headerRowCount,
dataStartRow,
mapping: suggestedMappings as Prisma.InputJsonValue,
preview: { sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length } as Prisma.InputJsonValue,
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 };
async analyzeImport(
file: { originalname: string; mimetype: string; size: number; buffer: Buffer },
options: AnalyzeImportOptions,
) {
if (!options.tenantId) throw new BadRequestException('tenantId is required');
if (!options.applicationId) throw new BadRequestException('applicationId is required');
await this.smsConfig.getApplication(options.applicationId, options.tenantId);
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, wpsImagesBySheet } = await loadCompatibleWorkbook(file.buffer);
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];
if (!worksheet) throw new BadRequestException('工作簿没有可读取的工作表');
const headerRowCount = clamp(options.headerRowCount, 1, 5);
const dataStartRow = Math.max(options.dataStartRow, headerRowCount + 1);
const images = compatibleImages(workbook, worksheet, wpsImagesBySheet);
const columnCount = Math.min(worksheet.columnCount, 200);
const columns = Array.from({ length: columnCount }, (_, offset) => {
const sourceColumnIndex = offset + 1;
const parts = Array.from({ length: headerRowCount }, (_, headerOffset) =>
cellText(worksheet.getCell(headerOffset + 1, sourceColumnIndex)),
).filter(Boolean);
const sourceHeaderPath = [...new Set(parts)].join('/');
return {
sourceColumnIndex,
columnLetter: worksheet.getColumn(sourceColumnIndex).letter,
sourceHeader: parts.at(-1) || `${sourceColumnIndex}`,
sourceHeaderPath,
imageCount: images.filter((image) => image.column === sourceColumnIndex).length,
};
}).filter((column) => column.sourceHeaderPath || column.imageCount > 0);
const previewRows = [];
for (let rowNumber = dataStartRow; rowNumber <= Math.min(worksheet.rowCount, dataStartRow + 9); rowNumber += 1) {
const values = Object.fromEntries(
columns.map((column) => [
String(column.sourceColumnIndex),
cellText(worksheet.getCell(rowNumber, column.sourceColumnIndex)),
]),
);
const imageColumns = images.filter((image) => image.row === rowNumber).map((image) => image.column);
if (Object.values(values).some(Boolean) || imageColumns.length)
previewRows.push({ rowNumber, values, imageColumns });
}
const sourceFile = await this.files.upload(
{ tenantId: options.tenantId, purpose: 'report_material_import', prefix: 'report-material-imports' },
file,
);
const profileMappings = profile?.columns.map((column) => ({
sourceHeader: column.sourceHeader,
sourceHeaderPath: column.sourceHeaderPath ?? undefined,
sourceColumnIndex: column.sourceColumnIndex,
targetFieldCode: column.targetFieldCode,
targetKind: column.targetKind as ImportMapping['targetKind'],
fieldType: column.fieldType as ImportMapping['fieldType'],
required: column.required,
transform: column.transform ?? undefined,
sortOrder: column.sortOrder,
}));
const suggestedMappings = profileMappings?.length
? remapProfileColumns(profileMappings, columns)
: suggestMappings(columns, options.reportType);
const batch = await this.prisma.reportMaterialImportBatch.create({
data: {
tenantId: options.tenantId,
applicationId: options.applicationId,
profileId: options.profileId,
fileObjectId: sourceFile.id,
fileName: sourceFile.fileName,
reportType: options.reportType,
sheetName: worksheet.name,
headerRowCount,
dataStartRow,
mapping: suggestedMappings as Prisma.InputJsonValue,
preview: {
sheets: workbook.worksheets.map((sheet) => sheet.name),
columns,
rows: previewRows,
imageCount: images.length,
} as Prisma.InputJsonValue,
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,
};
}
}
@@ -1,37 +1,10 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import type {
AnalyzeImportOptions,
CreateImportProfileDto,
CreateReportBatchDto,
EmbeddedImage,
ImportCommitDto,
ImportMapping,
PagedQuery,
ReportBatchInspection,
ReportBatchTarget,
ReviewImportItemsDto,
} from './report-materials.contracts';
import type { ImportCommitDto, ImportMapping, PagedQuery, ReviewImportItemsDto } from './report-materials.contracts';
import {
profileData,
validateProfile,
loadWorkbook,
assertSafeWorkbook,
safeSpreadsheetText,
readEmbeddedImages,
suggestMappings,
remapProfileColumns,
signatureCoreMapping,
drainageCoreMapping,
normalizeHeader,
normalizeFieldCode,
clamp,
normalizePage,
normalizePageSize,
dateRange,
@@ -42,18 +15,11 @@ import {
dynamicValues,
jsonRecord,
hasValue,
isFileRef,
resolveExportValue,
applyExportTransform,
styleHeader,
normalizeImageExtension,
imageContentType,
safeFileName,
normalizeBatchIdempotencyKey,
jsonStringArray,
jsonSafe,
} from './report-materials.helpers';
import { ReportImportParserService } from './import-parser.service';
import { compatibleImages, loadCompatibleWorkbook } from './workbook-compatibility';
/** R4 report-materials domain service composed behind ReportMaterialsService. */
export class ReportImportReviewService {
@@ -76,11 +42,10 @@ export class ReportImportReviewService {
columns: data.mappings,
});
const { content } = await this.files.getDownload(batch.fileObjectId);
const workbook = await loadWorkbook(content);
assertSafeWorkbook(workbook);
const { workbook, wpsImagesBySheet } = await loadCompatibleWorkbook(content);
const worksheet = workbook.getWorksheet(batch.sheetName);
if (!worksheet) throw new BadRequestException('导入工作表不存在');
const images = readEmbeddedImages(workbook, worksheet);
const images = compatibleImages(workbook, worksheet, wpsImagesBySheet);
const imageByCell = new Map(images.map((image) => [`${image.row}:${image.column}`, image]));
let successCount = 0;
const failures: Array<{ rowNumber: number; reason: string }> = [];
@@ -64,6 +64,7 @@ export interface SingleReportMaterialDto {
reportType?: 'signature' | 'drainage';
drainageItemId?: string;
batchItemId?: string;
outputFormat?: ReportWorkbookFormat;
}
export type ReportBatchTarget = {
@@ -94,7 +95,7 @@ export type ReportBatchInspection = {
export type AnalyzeImportOptions = {
tenantId: string;
applicationId?: string;
applicationId: string;
reportType: 'signature' | 'drainage';
sheetName?: string;
headerRowCount: number;
@@ -104,3 +105,5 @@ export type AnalyzeImportOptions = {
};
export type EmbeddedImage = { row: number; column: number; extension: string; buffer: Buffer };
export type ReportWorkbookFormat = 'excel_drawing' | 'wps_cell_image';
@@ -93,7 +93,9 @@ export class ReportMaterialsController {
}
@Post('imports/analyze')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 12, parts: 13 } }))
@UseInterceptors(
FileInterceptor('file', { limits: { fileSize: 100 * 1024 * 1024, files: 1, fields: 12, parts: 13 } }),
)
analyzeImport(
@UploadedFile() file: UploadedWorkbook,
@Body() body: Record<string, string>,
@@ -102,7 +104,7 @@ export class ReportMaterialsController {
if (!file) throw new BadRequestException('请选择 XLSX 文件');
return this.service.analyzeImport(file, {
tenantId: body.tenantId,
applicationId: body.applicationId || undefined,
applicationId: body.applicationId,
reportType: body.reportType as 'signature' | 'drainage',
sheetName: body.sheetName || undefined,
headerRowCount: Number(body.headerRowCount || 1),
@@ -161,13 +163,25 @@ export class ReportMaterialsController {
}
@Get('batches/:id/download')
async downloadBatch(@Param('id') id: string, @Res() response: DownloadResponse) {
this.sendDownload(response, await this.batchDownloads.exportBundle(await this.service.getBatch(id)));
async downloadBatch(
@Param('id') id: string,
@Query('outputFormat') outputFormat: 'excel_drawing' | 'wps_cell_image' | undefined,
@Res() response: DownloadResponse,
) {
this.sendDownload(response, await this.batchDownloads.exportBundle(await this.service.getBatch(id), outputFormat));
}
@Get('batches/:id/files/:fileId/download')
async downloadBatchFile(@Param('id') id: string, @Param('fileId') fileId: string, @Res() response: DownloadResponse) {
this.sendDownload(response, await this.batchDownloads.exportFile(await this.service.getBatch(id), fileId));
async downloadBatchFile(
@Param('id') id: string,
@Param('fileId') fileId: string,
@Query('outputFormat') outputFormat: 'excel_drawing' | 'wps_cell_image' | undefined,
@Res() response: DownloadResponse,
) {
this.sendDownload(
response,
await this.batchDownloads.exportFile(await this.service.getBatch(id), fileId, outputFormat),
);
}
@Get('batches/:id')
@@ -4,6 +4,22 @@ import { ReportMaterialsService } from './report-materials.service';
import { mappedCorePatchValue } from './report-materials.helpers';
describe('ReportMaterialsService', () => {
it('requires an enterprise application before parsing an import workbook', async () => {
const service = new ReportMaterialsService({} as never, { upload: jest.fn() } as never, {} as never);
await expect(
service.analyzeImport(
{ originalname: '资料.xlsx', mimetype: '', size: 2, buffer: Buffer.from('PK') },
{
tenantId: 'tenant-1',
applicationId: '',
reportType: 'signature',
headerRowCount: 1,
dataStartRow: 2,
},
),
).rejects.toThrow('applicationId is required');
});
it('rejects an unparsed single-export body as a readable 400 instead of throwing a TypeError', async () => {
const service = new ReportMaterialsService({} as never, {} as never, {} as never);
await expect(service.exportSingleMaterial(undefined, 'operator-1')).rejects.toThrow('导出参数不能为空');
@@ -59,7 +75,7 @@ describe('ReportMaterialsService', () => {
const service = new ReportMaterialsService(
{ reportMaterialImportProfile: { findUnique: jest.fn() } } as never,
files as never,
{} as never,
{ getApplication: jest.fn().mockResolvedValue({ id: 'app-1' }) } as never,
);
await expect(
@@ -70,9 +86,9 @@ describe('ReportMaterialsService', () => {
size: buffer.length,
buffer,
},
{ tenantId: 'tenant-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2 },
{ tenantId: 'tenant-1', applicationId: 'app-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2 },
),
).rejects.toThrow('公式或可执行单元格');
).rejects.toThrow('公式');
expect(files.upload).not.toHaveBeenCalled();
});
@@ -90,23 +106,21 @@ describe('ReportMaterialsService', () => {
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
const prisma = {
reportMaterialImportProfile: {
findUnique: jest
.fn()
.mockResolvedValue({
sheetName: '签名资料',
columns: [
{
sourceHeader: '短信签名',
sourceHeaderPath: '短信签名',
sourceColumnIndex: 9,
targetFieldCode: 'signature_name',
targetKind: 'signatureName',
fieldType: 'string',
required: true,
sortOrder: 10,
},
],
}),
findUnique: jest.fn().mockResolvedValue({
sheetName: '签名资料',
columns: [
{
sourceHeader: '短信签名',
sourceHeaderPath: '短信签名',
sourceColumnIndex: 9,
targetFieldCode: 'signature_name',
targetKind: 'signatureName',
fieldType: 'string',
required: true,
sortOrder: 10,
},
],
}),
},
reportMaterialImportBatch: {
create: jest
@@ -118,15 +132,17 @@ describe('ReportMaterialsService', () => {
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',
}),
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);
const service = new ReportMaterialsService(
prisma as never,
files as never,
{ getApplication: jest.fn().mockResolvedValue({ id: 'app-1' }) } as never,
);
const result = await service.analyzeImport(
{
@@ -181,42 +197,38 @@ describe('ReportMaterialsService', () => {
),
},
smsSignature: {
findUnique: jest
.fn()
.mockResolvedValue({
id: 'signature-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
name: '测试签名',
purpose: '验证码',
auditStatus: 'approved',
pendingReport: true,
materialVersion: 3,
drainageInfo: {
signatureReportValues: {
license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' },
sms_content_primary: '第一条短信内容',
sms_content_secondary: '第二条短信内容',
},
findUnique: jest.fn().mockResolvedValue({
id: 'signature-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
name: '测试签名',
purpose: '验证码',
auditStatus: 'approved',
pendingReport: true,
materialVersion: 3,
drainageInfo: {
signatureReportValues: {
license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' },
sms_content_primary: '第一条短信内容',
sms_content_secondary: '第二条短信内容',
},
tenant: { name: '测试企业' },
application: { name: '测试应用', status: 'active' },
}),
},
tenant: { name: '测试企业' },
application: { name: '测试应用', status: 'active' },
}),
update: jest.fn().mockResolvedValue({}),
},
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
channelRouteRule: {
findMany: jest
.fn()
.mockResolvedValue([
{
carrier: 'mobile',
group: {
status: 'active',
items: channels.map((channel, index) => ({ priority: index, carrier: 'mobile', channel })),
},
findMany: jest.fn().mockResolvedValue([
{
carrier: 'mobile',
group: {
status: 'active',
items: channels.map((channel, index) => ({ priority: index, carrier: 'mobile', channel })),
},
]),
},
]),
},
reportMaterialBatchItem: {
findMany: jest.fn().mockResolvedValue([]),
@@ -231,52 +243,54 @@ describe('ReportMaterialsService', () => {
),
},
channelReportField: {
findMany: jest.fn().mockImplementation(({ where }: { where: { channelId: string } }) => Promise.resolve([
{
code: 'sign',
name: '短信签名',
exportName: '通道签名',
required: true,
columnWidth: 18,
imageWidth: 120,
imageHeight: 80,
transform: null,
defaultValue: null,
},
{
code: 'license',
name: '营业执照',
exportName: '营业执照图片',
required: true,
columnWidth: 24,
imageWidth: 120,
imageHeight: 80,
transform: null,
defaultValue: null,
},
{
code: where.channelId === 'channel-b' ? 'sms_content_missing' : 'sms_content_primary',
name: '短信内容',
exportName: '短信内容',
required: false,
columnWidth: 24,
imageWidth: 120,
imageHeight: 80,
transform: null,
defaultValue: '第一条短信内容',
},
{
code: 'sms_content_secondary',
name: '短信内容',
exportName: '短信内容',
required: false,
columnWidth: 24,
imageWidth: 120,
imageHeight: 80,
transform: null,
defaultValue: '第二条短信内容',
},
])),
findMany: jest.fn().mockImplementation(({ where }: { where: { channelId: string } }) =>
Promise.resolve([
{
code: 'sign',
name: '短信签名',
exportName: '通道签名',
required: true,
columnWidth: 18,
imageWidth: 120,
imageHeight: 80,
transform: null,
defaultValue: null,
},
{
code: 'license',
name: '营业执照',
exportName: '营业执照图片',
required: true,
columnWidth: 24,
imageWidth: 120,
imageHeight: 80,
transform: null,
defaultValue: null,
},
{
code: where.channelId === 'channel-b' ? 'sms_content_missing' : 'sms_content_primary',
name: '短信内容',
exportName: '短信内容一',
required: false,
columnWidth: 24,
imageWidth: 120,
imageHeight: 80,
transform: null,
defaultValue: '第一条短信内容',
},
{
code: 'sms_content_secondary',
name: '短信内容',
exportName: '短信内容二',
required: false,
columnWidth: 24,
imageWidth: 120,
imageHeight: 80,
transform: null,
defaultValue: '第二条短信内容',
},
]),
),
},
channelSignatureReportTask: {
findMany: jest.fn().mockResolvedValue([]),
@@ -299,15 +313,13 @@ describe('ReportMaterialsService', () => {
reportExportFileItem: { createMany: jest.fn().mockResolvedValue({ count: 1 }) },
};
const files = {
getDownload: jest
.fn()
.mockResolvedValue({
fileObject: { fileName: 'license.png', contentType: 'image/png' },
content: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZsmAAAAAASUVORK5CYII=',
'base64',
),
}),
getDownload: jest.fn().mockResolvedValue({
fileObject: { fileName: 'license.png', contentType: 'image/png' },
content: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZsmAAAAAASUVORK5CYII=',
'base64',
),
}),
upload: jest
.fn()
.mockImplementation((_options: unknown, file: { originalname: string; mimetype: string; buffer: Buffer }) => {
@@ -370,35 +382,31 @@ describe('ReportMaterialsService', () => {
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve(data)),
},
smsSignature: {
findUnique: jest
.fn()
.mockResolvedValue({
id: 'signature-2',
tenantId: 'tenant-1',
applicationId: 'app-1',
name: '测试签名',
auditStatus: 'approved',
pendingReport: true,
materialVersion: 1,
drainageInfo: {},
tenant: { name: '企业' },
application: { name: '应用', status: 'active' },
}),
findUnique: jest.fn().mockResolvedValue({
id: 'signature-2',
tenantId: 'tenant-1',
applicationId: 'app-1',
name: '测试签名',
auditStatus: 'approved',
pendingReport: true,
materialVersion: 1,
drainageInfo: {},
tenant: { name: '企业' },
application: { name: '应用', status: 'active' },
}),
update: jest.fn(),
},
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
channelRouteRule: {
findMany: jest
.fn()
.mockResolvedValue([
{
carrier: 'mobile',
group: {
status: 'active',
items: [{ carrier: 'mobile', channel: { id: 'channel-a', name: '通道A', status: 'active' } }],
},
findMany: jest.fn().mockResolvedValue([
{
carrier: 'mobile',
group: {
status: 'active',
items: [{ carrier: 'mobile', channel: { id: 'channel-a', name: '通道A', status: 'active' } }],
},
]),
},
]),
},
reportMaterialBatchItem: {
findMany: jest.fn().mockResolvedValue([]),
@@ -420,13 +428,11 @@ describe('ReportMaterialsService', () => {
reportExportFileItem: { createMany: jest.fn() },
};
const files = {
upload: jest
.fn()
.mockResolvedValue({
id: 'file-2',
fileName: 'empty.xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}),
upload: jest.fn().mockResolvedValue({
id: 'file-2',
fileName: 'empty.xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}),
};
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
@@ -446,21 +452,19 @@ describe('ReportMaterialsService', () => {
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
$executeRaw: jest.fn().mockResolvedValue(1),
operationLog: {
findFirst: jest
.fn()
.mockResolvedValue({
id: 'operation-existing',
detail: {
findFirst: jest.fn().mockResolvedValue({
id: 'operation-existing',
detail: {
status: 'completed',
fingerprint: expect.anything(),
result: {
id: 'batch-existing',
batchNo: 'RB-EXISTING',
status: 'completed',
fingerprint: expect.anything(),
result: {
id: 'batch-existing',
batchNo: 'RB-EXISTING',
status: 'completed',
result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] },
},
result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] },
},
}),
},
}),
},
reportMaterialBatch: { create: jest.fn() },
};
@@ -607,10 +611,7 @@ describe('ReportMaterialsService', () => {
batchNo: 'RB-STATS-1',
exportFiles: [
{
items: [
{ task: { id: 'task-1', status: 'approved' } },
{ task: { id: 'task-2', status: 'rejected' } },
],
items: [{ task: { id: 'task-1', status: 'approved' } }, { task: { id: 'task-2', status: 'rejected' } }],
},
{
items: [
@@ -0,0 +1,42 @@
import ExcelJS from 'exceljs';
import JSZip from 'jszip';
import { compatibleImages, convertWorkbookOutput, loadCompatibleWorkbook } from './workbook-compatibility';
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZrYQAAAAASUVORK5CYII=',
'base64',
);
async function standardWorkbook() {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('签名报备');
sheet.getCell('A1').value = '营业执照';
sheet.getCell('A2').value = 'license.png';
const imageId = workbook.addImage({ buffer: PNG as never, extension: 'png' });
sheet.addImage(imageId, { tl: { col: 0, row: 1 }, br: { col: 0.9, row: 1.9 }, editAs: 'oneCell' } as never);
return Buffer.from(await workbook.xlsx.writeBuffer());
}
describe('WPS workbook compatibility', () => {
it('converts a standard Drawing image to DISPIMG and reads it back from cellimages.xml', async () => {
const converted = await convertWorkbookOutput(await standardWorkbook(), 'wps_cell_image');
const zip = await JSZip.loadAsync(converted);
await expect(zip.file('xl/cellimages.xml')!.async('string')).resolves.toContain('ID_');
const loaded = await loadCompatibleWorkbook(converted);
const images = compatibleImages(
loaded.workbook,
loaded.workbook.getWorksheet('签名报备')!,
loaded.wpsImagesBySheet,
);
expect(images).toHaveLength(1);
expect(images[0]).toMatchObject({ row: 2, column: 1, extension: 'png' });
expect(images[0].buffer.equals(PNG)).toBe(true);
});
it('still rejects ordinary formulas instead of weakening spreadsheet safety', async () => {
const workbook = new ExcelJS.Workbook();
workbook.addWorksheet('危险').getCell('A1').value = { formula: 'HYPERLINK("https://example.com")' };
const content = Buffer.from(await workbook.xlsx.writeBuffer());
await expect(loadCompatibleWorkbook(content)).rejects.toThrow('不允许的公式');
});
});
@@ -0,0 +1,284 @@
import { BadRequestException } from '@nestjs/common';
import ExcelJS from 'exceljs';
import JSZip from 'jszip';
import { randomUUID } from 'node:crypto';
import type { EmbeddedImage, ReportWorkbookFormat } from './report-materials.contracts';
import { normalizeImageExtension, readEmbeddedImages } from './report-materials.helpers';
const MAX_WORKBOOK_BYTES = 100 * 1024 * 1024;
const MAX_EXPANDED_BYTES = 500 * 1024 * 1024;
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
const MAX_TOTAL_IMAGE_BYTES = 300 * 1024 * 1024;
const DISPIMG_FORMULA = /^_xlfn\.DISPIMG\(["'](ID_[A-F0-9]{32})["'],1\)$/i;
function decodeXml(value: string) {
return value
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
function attributes(source: string) {
return Object.fromEntries(
[...source.matchAll(/([\w:-]+)="([^"]*)"/g)].map((match) => [match[1], decodeXml(match[2])]),
);
}
function packagePath(target: string) {
return `xl/${target.replace(/^\/?xl\//, '').replace(/^\//, '')}`.replace(/\\/g, '/');
}
function coordinates(address: string) {
const match = /^([A-Z]+)(\d+)$/.exec(address.toUpperCase());
if (!match) return null;
let column = 0;
for (const character of match[1]) column = column * 26 + character.charCodeAt(0) - 64;
return { row: Number(match[2]), column };
}
async function text(zip: JSZip, path: string) {
return zip.file(path)?.async('string') ?? '';
}
function validImageSignature(extension: string, buffer: Buffer) {
if (extension === 'png')
return buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
if (extension === 'jpeg') return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
if (extension === 'gif') return buffer.subarray(0, 3).toString('ascii') === 'GIF';
return false;
}
function validateWorkbookValues(workbook: ExcelJS.Workbook, allowedWpsIds: Set<string>) {
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)) {
const formula =
typeof (value as { formula?: unknown }).formula === 'string'
? (value as { formula: string }).formula.trim()
: '';
const match = DISPIMG_FORMULA.exec(formula);
if (!match || !allowedWpsIds.has(match[1]))
throw new BadRequestException(`工作表 ${worksheet.name} 包含不允许的公式`);
}
const valueText = typeof value === 'string' ? value.trimStart() : '';
if (/^[=+@]/.test(valueText) || /^-[^\d.]/.test(valueText))
throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`);
}),
);
}
}
async function inspectWpsImages(zip: JSZip) {
const cellImagesXml = await text(zip, 'xl/cellimages.xml');
if (!cellImagesXml) return { allowedIds: new Set<string>(), imagesBySheet: new Map<string, EmbeddedImage[]>() };
const relXml = await text(zip, 'xl/_rels/cellimages.xml.rels');
const relTargets = new Map<string, string>();
for (const match of relXml.matchAll(/<Relationship\b([^>]*)\/?>(?:<\/Relationship>)?/g)) {
const attrs = attributes(match[1]);
if (attrs.Id && attrs.Target && /\/image$/.test(attrs.Type ?? ''))
relTargets.set(attrs.Id, packagePath(attrs.Target));
}
const imageTargets = new Map<string, string>();
for (const match of cellImagesXml.matchAll(
/<etc:cellImage\b[^>]*>[\s\S]*?<xdr:cNvPr\b([^>]*)\/?>(?:[\s\S]*?)<a:blip\b([^>]*)\/?>(?:[\s\S]*?)<\/etc:cellImage>/g,
)) {
const id = attributes(match[1]).name;
const target = relTargets.get(attributes(match[2])['r:embed']);
if (id && target) imageTargets.set(id, target);
}
const workbookXml = await text(zip, 'xl/workbook.xml');
const workbookRelsXml = await text(zip, 'xl/_rels/workbook.xml.rels');
const sheetRelTargets = new Map<string, string>();
for (const match of workbookRelsXml.matchAll(/<Relationship\b([^>]*)\/?>(?:<\/Relationship>)?/g)) {
const attrs = attributes(match[1]);
if (attrs.Id && attrs.Target && /\/worksheet$/.test(attrs.Type ?? ''))
sheetRelTargets.set(attrs.Id, packagePath(attrs.Target));
}
const allowedIds = new Set<string>();
const imagesBySheet = new Map<string, EmbeddedImage[]>();
for (const match of workbookXml.matchAll(/<sheet\b([^>]*)\/?>(?:<\/sheet>)?/g)) {
const attrs = attributes(match[1]);
const path = sheetRelTargets.get(attrs['r:id']);
if (!attrs.name || !path) continue;
const sheetXml = await text(zip, path);
const images: EmbeddedImage[] = [];
for (const cell of sheetXml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
const formulaText = decodeXml(cell[2].match(/<f(?:\s[^>]*)?>([\s\S]*?)<\/f>/)?.[1]?.trim() ?? '');
if (!formulaText) continue;
const formula = DISPIMG_FORMULA.exec(formulaText);
if (!formula) throw new BadRequestException(`工作簿包含不允许的公式:${formulaText.slice(0, 80)}`);
const target = imageTargets.get(formula[1]);
const cellPosition = coordinates(attributes(cell[1]).r);
if (!target || !cellPosition) throw new BadRequestException('WPS单元格图片关系不完整');
const imageEntry = zip.file(target);
if (!imageEntry) throw new BadRequestException('WPS单元格图片文件缺失');
const extension = normalizeImageExtension(target.split('.').pop() ?? 'png');
if (!['png', 'jpeg', 'gif'].includes(extension)) throw new BadRequestException('WPS单元格图片格式不受支持');
const imageBuffer = await imageEntry.async('nodebuffer');
if (imageBuffer.length > MAX_IMAGE_BYTES) throw new BadRequestException('单张WPS图片不能超过20MB');
if (!validImageSignature(extension, imageBuffer)) throw new BadRequestException('WPS单元格图片内容与格式不匹配');
images.push({ ...cellPosition, extension, buffer: imageBuffer });
allowedIds.add(formula[1]);
}
imagesBySheet.set(attrs.name, images);
}
const totalImageBytes = [...imagesBySheet.values()].flat().reduce((sum, image) => sum + image.buffer.length, 0);
if (totalImageBytes > MAX_TOTAL_IMAGE_BYTES) throw new BadRequestException('WPS图片总量不能超过300MB');
return { allowedIds, imagesBySheet };
}
export async function loadCompatibleWorkbook(buffer: Buffer) {
if (buffer.length > MAX_WORKBOOK_BYTES) throw new BadRequestException('导入文件不能超过100MB');
let zip: JSZip;
try {
zip = await JSZip.loadAsync(buffer);
} catch {
throw new BadRequestException('仅支持有效的 XLSX 文件');
}
const expandedBytes = Object.values(zip.files).reduce(
(sum, entry) =>
sum + Number((entry as unknown as { _data?: { uncompressedSize?: number } })._data?.uncompressedSize ?? 0),
0,
);
if (Object.keys(zip.files).length > 3000) throw new BadRequestException('工作簿ZIP条目数量超过安全限制');
if (expandedBytes > MAX_EXPANDED_BYTES) throw new BadRequestException('工作簿解压后超过500MB安全限制');
if (Object.keys(zip.files).some((name) => /(^|\/)(vbaProject|externalLinks|embeddings|activeX)(\/|\.)/i.test(name)))
throw new BadRequestException('工作簿包含不允许的外部对象或宏');
const wps = await inspectWpsImages(zip);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer as never);
validateWorkbookValues(workbook, wps.allowedIds);
return { workbook, wpsImagesBySheet: wps.imagesBySheet };
}
export function compatibleImages(
workbook: ExcelJS.Workbook,
worksheet: ExcelJS.Worksheet,
wpsImagesBySheet: Map<string, EmbeddedImage[]>,
) {
const merged = new Map<string, EmbeddedImage>();
const wpsImages = wpsImagesBySheet.get(worksheet.name) ?? [];
for (const image of readEmbeddedImages(workbook, worksheet)) {
if (wpsImages.length && image.buffer.length <= 128) continue;
const extension = normalizeImageExtension(image.extension);
if (image.buffer.length > MAX_IMAGE_BYTES) throw new BadRequestException('单张工作簿图片不能超过20MB');
if (!validImageSignature(extension, image.buffer)) throw new BadRequestException('工作簿图片内容与格式不匹配');
merged.set(`${image.row}:${image.column}`, image);
}
for (const image of wpsImages) merged.set(`${image.row}:${image.column}`, image);
return [...merged.values()];
}
export async function convertWorkbookOutput(content: Buffer, outputFormat: ReportWorkbookFormat = 'excel_drawing') {
if (outputFormat === 'excel_drawing') return content;
if (outputFormat !== 'wps_cell_image') throw new BadRequestException('不支持的报备文件格式');
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(content as never);
const pictures: Array<{
sheetName: string;
address: string;
extension: string;
buffer: Buffer;
id: string;
relId: string;
mediaPath: string;
}> = [];
for (const sheet of workbook.worksheets) {
for (const image of readEmbeddedImages(workbook, sheet)) {
const extension = normalizeImageExtension(image.extension);
pictures.push({
sheetName: sheet.name,
address: `${sheet.getColumn(image.column).letter}${image.row}`,
extension,
buffer: image.buffer,
id: `ID_${randomUUID().replace(/-/g, '').toUpperCase()}`,
relId: `rId${pictures.length + 1}`,
mediaPath: `xl/media/wps-cell-image-${pictures.length + 1}.${extension === 'jpeg' ? 'jpg' : extension}`,
});
}
}
if (!pictures.length) return content;
const zip = await JSZip.loadAsync(content);
Object.keys(zip.files)
.filter((name) => /^xl\/media\//.test(name))
.forEach((name) => zip.remove(name));
const workbookXml = await text(zip, 'xl/workbook.xml');
let workbookRels = await text(zip, 'xl/_rels/workbook.xml.rels');
const relTargets = new Map<string, string>();
for (const match of workbookRels.matchAll(/<Relationship\b([^>]*)\/?>(?:<\/Relationship>)?/g)) {
const attrs = attributes(match[1]);
if (attrs.Id && attrs.Target && /\/worksheet$/.test(attrs.Type ?? ''))
relTargets.set(attrs.Id, packagePath(attrs.Target));
}
const sheetTargets = new Map<string, string>();
for (const match of workbookXml.matchAll(/<sheet\b([^>]*)\/?>(?:<\/sheet>)?/g)) {
const attrs = attributes(match[1]);
const target = relTargets.get(attrs['r:id']);
if (attrs.name && target) sheetTargets.set(attrs.name, target);
}
for (const [sheetName, path] of sheetTargets) {
const sheetPictures = pictures.filter((picture) => picture.sheetName === sheetName);
if (!sheetPictures.length) continue;
let sheetXml = await text(zip, path);
sheetXml = sheetXml.replace(/<drawing\b[^>]*\/?>(?:<\/drawing>)?/g, '');
for (const picture of sheetPictures) {
const fullCell = new RegExp(`<c\\b([^>]*\\br="${picture.address}"[^>]*)>[\\s\\S]*?<\\/c>`);
const emptyCell = new RegExp(`<c\\b([^>]*\\br="${picture.address}"[^>]*)\\/>`);
const replaceCell = (opening: string) =>
`<c${opening.replace(/\s+t="[^"]*"/g, '')} t="str"><f>_xlfn.DISPIMG(&quot;${picture.id}&quot;,1)</f><v>=DISPIMG(&quot;${picture.id}&quot;,1)</v></c>`;
if (fullCell.test(sheetXml))
sheetXml = sheetXml.replace(fullCell, (_match, opening: string) => replaceCell(opening));
else if (emptyCell.test(sheetXml))
sheetXml = sheetXml.replace(emptyCell, (_match, opening: string) => replaceCell(opening));
else throw new BadRequestException(`无法生成WPS单元格图片:${sheetName}!${picture.address}`);
zip.file(picture.mediaPath, picture.buffer);
}
zip.file(path, sheetXml);
const sheetRelsPath = path.replace(/\/([^/]+)$/, '/_rels/$1.rels');
const sheetRels = await text(zip, sheetRelsPath);
if (sheetRels)
zip.file(
sheetRelsPath,
sheetRels.replace(/<Relationship\b[^>]*Type="[^"]*\/drawing"[^>]*\/?>(?:<\/Relationship>)?/g, ''),
);
}
Object.keys(zip.files)
.filter((name) => /^xl\/drawings\//.test(name))
.forEach((name) => zip.remove(name));
const cellImages = pictures
.map(
(picture, index) =>
`<etc:cellImage><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="${index + 1}" name="${picture.id}"/><xdr:cNvPicPr><a:picLocks noChangeAspect="1"/></xdr:cNvPicPr></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="${picture.relId}"/><a:stretch><a:fillRect/></a:stretch></xdr:blipFill><xdr:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln w="9525"><a:noFill/></a:ln></xdr:spPr></xdr:pic></etc:cellImage>`,
)
.join('');
zip.file(
'xl/cellimages.xml',
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><etc:cellImages xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:etc="http://www.wps.cn/officeDocument/2017/etCustomData">${cellImages}</etc:cellImages>`,
);
zip.file(
'xl/_rels/cellimages.xml.rels',
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${pictures.map((picture) => `<Relationship Id="${picture.relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="${picture.mediaPath.replace(/^xl\//, '')}"/>`).join('')}</Relationships>`,
);
const nextRel = Math.max(0, ...[...workbookRels.matchAll(/Id="rId(\d+)"/g)].map((match) => Number(match[1]))) + 1;
workbookRels = workbookRels.replace(
'</Relationships>',
`<Relationship Id="rId${nextRel}" Type="http://www.wps.cn/officeDocument/2020/cellImage" Target="cellimages.xml"/></Relationships>`,
);
zip.file('xl/_rels/workbook.xml.rels', workbookRels);
let contentTypes = await text(zip, '[Content_Types].xml');
contentTypes = contentTypes.replace(
/<Override\b[^>]*PartName="\/xl\/drawings\/[^"]+"[^>]*\/?>(?:<\/Override>)?/g,
'',
);
if (!contentTypes.includes('/xl/cellimages.xml'))
contentTypes = contentTypes.replace(
'</Types>',
'<Override PartName="/xl/cellimages.xml" ContentType="application/vnd.wps-officedocument.cellimage+xml"/></Types>',
);
zip.file('[Content_Types].xml', contentTypes);
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } });
}