feat: 异步解析大文件WPS报备资料
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
"test:watch": "jest --watch",
|
||||
"start": "node dist/main.js",
|
||||
"start:dev": "ts-node src/main.ts",
|
||||
"start:report-material-worker": "node dist/report-material-analysis-worker.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate:dev": "prisma migrate dev",
|
||||
"prisma:migrate:deploy": "prisma migrate deploy"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE "ReportMaterialImportBatch"
|
||||
ADD COLUMN "progress" INTEGER NOT NULL DEFAULT 100,
|
||||
ADD COLUMN "progressStage" TEXT,
|
||||
ADD COLUMN "errorMessage" TEXT,
|
||||
ADD COLUMN "startedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "heartbeatAt" TIMESTAMP(3);
|
||||
|
||||
CREATE INDEX "ReportMaterialImportBatch_status_heartbeatAt_idx"
|
||||
ON "ReportMaterialImportBatch"("status", "heartbeatAt");
|
||||
@@ -1395,6 +1395,11 @@ model ReportMaterialImportBatch {
|
||||
fileName String
|
||||
reportType String
|
||||
status String @default("analyzed")
|
||||
progress Int @default(100)
|
||||
progressStage String?
|
||||
errorMessage String?
|
||||
startedAt DateTime?
|
||||
heartbeatAt DateTime?
|
||||
sheetName String
|
||||
headerRowCount Int @default(1)
|
||||
dataStartRow Int @default(2)
|
||||
@@ -1413,6 +1418,7 @@ model ReportMaterialImportBatch {
|
||||
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([status, createdAt])
|
||||
@@index([status, heartbeatAt])
|
||||
}
|
||||
|
||||
model ReportMaterialImportItem {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { assertUploadSize } from './files.service';
|
||||
|
||||
describe('FilesService report-material upload limits', () => {
|
||||
const workbook = {
|
||||
originalname: '行业报备.xlsx',
|
||||
mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
|
||||
it('allows report-material imports up to 100MB without raising the generic file limit', () => {
|
||||
expect(() =>
|
||||
assertUploadSize('report_material_import', { ...workbook, size: 40 * 1024 * 1024 }),
|
||||
).not.toThrow();
|
||||
expect(() => assertUploadSize('other', { ...workbook, size: 40 * 1024 * 1024 })).toThrow('10MB');
|
||||
expect(() =>
|
||||
assertUploadSize('report_material_import', { ...workbook, size: 101 * 1024 * 1024 }),
|
||||
).toThrow('100MB');
|
||||
});
|
||||
});
|
||||
@@ -40,10 +40,12 @@ export class FilesService {
|
||||
) {}
|
||||
|
||||
list(tenantId?: string) {
|
||||
return this.prisma.fileObject.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}).then((items) => items.map(serializeFileObject));
|
||||
return this.prisma.fileObject
|
||||
.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
.then((items) => items.map(serializeFileObject));
|
||||
}
|
||||
|
||||
create(data: CreateFileObjectDto) {
|
||||
@@ -71,7 +73,7 @@ export class FilesService {
|
||||
}
|
||||
|
||||
async upload(data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) {
|
||||
assertUploadSize(file);
|
||||
assertUploadSize(data.purpose, file);
|
||||
const fileName = normalizeMultipartFileName(file.originalname);
|
||||
const safeName = fileName.replace(/[^\w.\-\u4e00-\u9fa5]/g, '_');
|
||||
const objectKey = `${data.prefix ?? data.purpose}/${Date.now()}-${randomUUID()}-${safeName}`;
|
||||
@@ -87,7 +89,11 @@ export class FilesService {
|
||||
});
|
||||
}
|
||||
|
||||
async uploadForClient(userId: string | undefined, data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) {
|
||||
async uploadForClient(
|
||||
userId: string | undefined,
|
||||
data: UploadFileDto,
|
||||
file: { originalname: string; mimetype: string; size: number; buffer: Buffer },
|
||||
) {
|
||||
const tenantId = await this.resolveClientTenantId(userId);
|
||||
const prefixRule = CLIENT_UPLOAD_RULES[data.purpose];
|
||||
if (!prefixRule || !data.prefix || !prefixRule.test(data.prefix)) {
|
||||
@@ -135,18 +141,33 @@ export class FilesService {
|
||||
|
||||
const IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const FILE_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const REPORT_MATERIAL_IMPORT_MAX_BYTES = 100 * 1024 * 1024;
|
||||
const IMAGE_FILE_EXTENSION = /\.(?:avif|bmp|gif|heic|heif|jpe?g|png|svg|webp)$/i;
|
||||
|
||||
function assertUploadSize(file: { originalname: string; mimetype: string; size: number }) {
|
||||
export function assertUploadSize(purpose: string, file: { originalname: string; mimetype: string; size: number }) {
|
||||
const image = file.mimetype.toLowerCase().startsWith('image/') || IMAGE_FILE_EXTENSION.test(file.originalname);
|
||||
const limit = image ? IMAGE_UPLOAD_MAX_BYTES : FILE_UPLOAD_MAX_BYTES;
|
||||
const reportMaterialImport = purpose === 'report_material_import';
|
||||
const limit = reportMaterialImport
|
||||
? REPORT_MATERIAL_IMPORT_MAX_BYTES
|
||||
: image
|
||||
? IMAGE_UPLOAD_MAX_BYTES
|
||||
: FILE_UPLOAD_MAX_BYTES;
|
||||
if (file.size > limit) {
|
||||
throw new BadRequestException(image ? '图片大小不能超过 2MB' : '文件大小不能超过 10MB');
|
||||
throw new BadRequestException(
|
||||
reportMaterialImport
|
||||
? '报备资料文件大小不能超过 100MB'
|
||||
: image
|
||||
? '图片大小不能超过 2MB'
|
||||
: '文件大小不能超过 10MB',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMultipartFileName(value: string) {
|
||||
if (![...value].some((character) => character.charCodeAt(0) > 0x7f) || [...value].some((character) => character.charCodeAt(0) > 0xff)) {
|
||||
if (
|
||||
![...value].some((character) => character.charCodeAt(0) > 0x7f) ||
|
||||
[...value].some((character) => character.charCodeAt(0) > 0xff)
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { ReportMaterialsModule } from './report-materials/report-materials.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true, envFilePath: ['.env.local', '.env'] }),
|
||||
PrismaModule,
|
||||
ReportMaterialsModule,
|
||||
],
|
||||
})
|
||||
export class ReportMaterialAnalysisWorkerModule {}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ReportMaterialAnalysisWorkerModule } from './report-material-analysis-worker.module';
|
||||
import { ReportMaterialsService } from './report-materials/report-materials.service';
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
|
||||
async function bootstrap() {
|
||||
if ((process.env.CMPP_PROCESS_ROLE?.trim() || 'report-material-worker') !== 'report-material-worker') {
|
||||
throw new Error('report-material-analysis-worker requires CMPP_PROCESS_ROLE=report-material-worker');
|
||||
}
|
||||
const app = await NestFactory.createApplicationContext(ReportMaterialAnalysisWorkerModule, {
|
||||
logger: ['log', 'warn', 'error'],
|
||||
});
|
||||
app.enableShutdownHooks();
|
||||
const reports = app.get(ReportMaterialsService);
|
||||
await reports.recoverStaleAnalysisJobs();
|
||||
let stopping = false;
|
||||
const stop = () => {
|
||||
stopping = true;
|
||||
};
|
||||
process.once('SIGTERM', stop);
|
||||
process.once('SIGINT', stop);
|
||||
while (!stopping) {
|
||||
const worked = await reports.processNextAnalysisJob();
|
||||
if (!worked) await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
}
|
||||
await app.close();
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { extname } from 'node:path';
|
||||
import { FilesService } from '../files/files.service';
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
clamp,
|
||||
cellText,
|
||||
} from './report-materials.helpers';
|
||||
import { compatibleImages, loadCompatibleWorkbook } from './workbook-compatibility';
|
||||
import { compatibleImagePositions, loadCompatibleWorkbook } from './workbook-compatibility';
|
||||
|
||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||
export class ReportImportParserService {
|
||||
@@ -63,14 +63,123 @@ export class ReportImportParserService {
|
||||
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);
|
||||
await this.validateImport(file, options);
|
||||
const analysis = await this.parseAnalysis(file.buffer, options);
|
||||
const sourceFile = await this.files.upload(
|
||||
{ tenantId: options.tenantId, purpose: 'report_material_import', prefix: 'report-material-imports' },
|
||||
file,
|
||||
);
|
||||
const batch = await this.createAnalyzedBatch(sourceFile, options, analysis);
|
||||
await this.logAnalyzed(batch.id, sourceFile.fileName, options, analysis.sheetName, analysis.rows.length);
|
||||
return { ...batch, sourceFile, ...analysis };
|
||||
}
|
||||
|
||||
async enqueueImport(
|
||||
file: { originalname: string; mimetype: string; size: number; buffer: Buffer },
|
||||
options: AnalyzeImportOptions,
|
||||
) {
|
||||
await this.validateImport(file, options);
|
||||
const sourceFile = await this.files.upload(
|
||||
{ tenantId: options.tenantId, purpose: 'report_material_import', prefix: 'report-material-imports' },
|
||||
file,
|
||||
);
|
||||
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,
|
||||
status: 'queued',
|
||||
progress: 10,
|
||||
progressStage: '文件已上传,等待解析',
|
||||
sheetName: options.sheetName ?? '',
|
||||
headerRowCount: clamp(options.headerRowCount, 1, 5),
|
||||
dataStartRow: Math.max(options.dataStartRow, clamp(options.headerRowCount, 1, 5) + 1),
|
||||
mapping: [] as Prisma.InputJsonValue,
|
||||
result: { operatorId: options.operatorId } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return this.analysisStatus(batch);
|
||||
}
|
||||
|
||||
async getAnalysisStatus(batchId: string, tenantId: string) {
|
||||
if (!tenantId) throw new BadRequestException('tenantId is required');
|
||||
const batch = await this.prisma.reportMaterialImportBatch.findFirst({ where: { id: batchId, tenantId } });
|
||||
if (!batch) throw new NotFoundException('导入解析任务不存在');
|
||||
return this.analysisStatus(batch);
|
||||
}
|
||||
|
||||
async processQueuedBatch(batchId: string) {
|
||||
const batch = await this.prisma.reportMaterialImportBatch.findUnique({ where: { id: batchId } });
|
||||
if (!batch || batch.status !== 'analyzing') return false;
|
||||
try {
|
||||
await this.progress(batchId, 25, '正在读取工作簿');
|
||||
const { content } = await this.files.getDownload(batch.fileObjectId);
|
||||
const resultData = (batch.result && typeof batch.result === 'object' ? batch.result : {}) as {
|
||||
operatorId?: string;
|
||||
};
|
||||
const options: AnalyzeImportOptions = {
|
||||
tenantId: batch.tenantId,
|
||||
applicationId: batch.applicationId ?? '',
|
||||
reportType: batch.reportType as 'signature' | 'drainage',
|
||||
sheetName: batch.sheetName || undefined,
|
||||
headerRowCount: batch.headerRowCount,
|
||||
dataStartRow: batch.dataStartRow,
|
||||
profileId: batch.profileId ?? undefined,
|
||||
operatorId: resultData.operatorId,
|
||||
};
|
||||
await this.progress(batchId, 30, '正在解析表格与图片位置');
|
||||
const analysis = await this.parseAnalysis(content, options, async (progress, stage) => {
|
||||
await this.progress(batchId, progress, stage);
|
||||
});
|
||||
const updated = await this.prisma.reportMaterialImportBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
status: 'analyzed',
|
||||
progress: 100,
|
||||
progressStage: '解析完成',
|
||||
errorMessage: null,
|
||||
heartbeatAt: new Date(),
|
||||
sheetName: analysis.sheetName,
|
||||
mapping: analysis.suggestedMappings as Prisma.InputJsonValue,
|
||||
preview: {
|
||||
sheets: analysis.sheets,
|
||||
columns: analysis.columns,
|
||||
rows: analysis.rows,
|
||||
imageCount: analysis.imageCount,
|
||||
} as Prisma.InputJsonValue,
|
||||
result: Prisma.JsonNull,
|
||||
rowCount: analysis.rowCount,
|
||||
},
|
||||
});
|
||||
await this.logAnalyzed(batchId, batch.fileName, options, analysis.sheetName, analysis.rows.length);
|
||||
return this.analysisStatus(updated);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '文件解析失败';
|
||||
await this.prisma.reportMaterialImportBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
progressStage: '解析失败',
|
||||
errorMessage: message.slice(0, 1000),
|
||||
heartbeatAt: new Date(),
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async parseAnalysis(
|
||||
buffer: Buffer,
|
||||
options: AnalyzeImportOptions,
|
||||
onProgress?: (progress: number, stage: string) => void | Promise<void>,
|
||||
) {
|
||||
const { workbook, wpsImagesBySheet } = await loadCompatibleWorkbook(buffer, {
|
||||
includeImageData: false,
|
||||
onProgress,
|
||||
});
|
||||
const profile = options.profileId
|
||||
? await this.prisma.reportMaterialImportProfile.findUnique({
|
||||
where: { id: options.profileId },
|
||||
@@ -82,7 +191,7 @@ export class ReportImportParserService {
|
||||
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 images = compatibleImagePositions(workbook, worksheet, wpsImagesBySheet);
|
||||
const columnCount = Math.min(worksheet.columnCount, 200);
|
||||
const columns = Array.from({ length: columnCount }, (_, offset) => {
|
||||
const sourceColumnIndex = offset + 1;
|
||||
@@ -110,10 +219,6 @@ export class ReportImportParserService {
|
||||
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,
|
||||
@@ -128,7 +233,24 @@ export class ReportImportParserService {
|
||||
const suggestedMappings = profileMappings?.length
|
||||
? remapProfileColumns(profileMappings, columns)
|
||||
: suggestMappings(columns, options.reportType);
|
||||
const batch = await this.prisma.reportMaterialImportBatch.create({
|
||||
await onProgress?.(95, '已生成字段映射与预览');
|
||||
return {
|
||||
sheetName: worksheet.name,
|
||||
sheets: workbook.worksheets.map((sheet) => sheet.name),
|
||||
columns,
|
||||
rows: previewRows,
|
||||
imageCount: images.length,
|
||||
suggestedMappings,
|
||||
rowCount: Math.max(0, worksheet.rowCount - dataStartRow + 1),
|
||||
};
|
||||
}
|
||||
|
||||
private createAnalyzedBatch(
|
||||
sourceFile: { id: string; fileName: string },
|
||||
options: AnalyzeImportOptions,
|
||||
analysis: Awaited<ReturnType<ReportImportParserService['parseAnalysis']>>,
|
||||
) {
|
||||
return this.prisma.reportMaterialImportBatch.create({
|
||||
data: {
|
||||
tenantId: options.tenantId,
|
||||
applicationId: options.applicationId,
|
||||
@@ -136,42 +258,91 @@ export class ReportImportParserService {
|
||||
fileObjectId: sourceFile.id,
|
||||
fileName: sourceFile.fileName,
|
||||
reportType: options.reportType,
|
||||
sheetName: worksheet.name,
|
||||
headerRowCount,
|
||||
dataStartRow,
|
||||
mapping: suggestedMappings as Prisma.InputJsonValue,
|
||||
sheetName: analysis.sheetName,
|
||||
headerRowCount: clamp(options.headerRowCount, 1, 5),
|
||||
dataStartRow: Math.max(options.dataStartRow, clamp(options.headerRowCount, 1, 5) + 1),
|
||||
mapping: analysis.suggestedMappings as Prisma.InputJsonValue,
|
||||
preview: {
|
||||
sheets: workbook.worksheets.map((sheet) => sheet.name),
|
||||
columns,
|
||||
rows: previewRows,
|
||||
imageCount: images.length,
|
||||
sheets: analysis.sheets,
|
||||
columns: analysis.columns,
|
||||
rows: analysis.rows,
|
||||
imageCount: analysis.imageCount,
|
||||
} as Prisma.InputJsonValue,
|
||||
rowCount: Math.max(0, worksheet.rowCount - dataStartRow + 1),
|
||||
rowCount: analysis.rowCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async validateImport(
|
||||
file: { originalname: 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 文件');
|
||||
}
|
||||
|
||||
private progress(batchId: string, progress: number, progressStage: string) {
|
||||
return this.prisma.reportMaterialImportBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { progress, progressStage, heartbeatAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private analysisStatus(batch: {
|
||||
id: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
progressStage: string | null;
|
||||
errorMessage: string | null;
|
||||
sheetName: string;
|
||||
preview: Prisma.JsonValue | null;
|
||||
mapping: Prisma.JsonValue;
|
||||
}) {
|
||||
const preview =
|
||||
batch.preview && typeof batch.preview === 'object' && !Array.isArray(batch.preview)
|
||||
? (batch.preview as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
id: batch.id,
|
||||
status: batch.status,
|
||||
progress: batch.progress,
|
||||
progressStage: batch.progressStage,
|
||||
errorMessage: batch.errorMessage,
|
||||
sheetName: batch.sheetName || undefined,
|
||||
sheets: preview.sheets ?? [],
|
||||
columns: preview.columns ?? [],
|
||||
rows: preview.rows ?? [],
|
||||
imageCount: preview.imageCount ?? 0,
|
||||
suggestedMappings: batch.mapping ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
private async logAnalyzed(
|
||||
batchId: string,
|
||||
fileName: string,
|
||||
options: AnalyzeImportOptions,
|
||||
sheetName: string,
|
||||
previewCount: number,
|
||||
) {
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: options.tenantId,
|
||||
userId: options.operatorId,
|
||||
action: 'report_material.import_analyzed',
|
||||
resource: 'report_material_import',
|
||||
resourceId: batch.id,
|
||||
resourceId: batchId,
|
||||
detail: {
|
||||
fileName: sourceFile.fileName,
|
||||
filters: { applicationId: options.applicationId, reportType: options.reportType, sheetName: worksheet.name },
|
||||
successCount: previewRows.length,
|
||||
fileName,
|
||||
filters: { applicationId: options.applicationId, reportType: options.reportType, sheetName },
|
||||
successCount: previewCount,
|
||||
failedCount: 0,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return {
|
||||
...batch,
|
||||
sourceFile,
|
||||
sheets: workbook.worksheets.map((sheet) => sheet.name),
|
||||
columns,
|
||||
rows: previewRows,
|
||||
imageCount: images.length,
|
||||
suggestedMappings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,5 +105,6 @@ export type AnalyzeImportOptions = {
|
||||
};
|
||||
|
||||
export type EmbeddedImage = { row: number; column: number; extension: string; buffer: Buffer };
|
||||
export type EmbeddedImageMetadata = { row: number; column: number; extension: string; size: number; target: string };
|
||||
|
||||
export type ReportWorkbookFormat = 'excel_drawing' | 'wps_cell_image';
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
@@ -93,6 +94,7 @@ export class ReportMaterialsController {
|
||||
}
|
||||
|
||||
@Post('imports/analyze')
|
||||
@HttpCode(202)
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', { limits: { fileSize: 100 * 1024 * 1024, files: 1, fields: 12, parts: 13 } }),
|
||||
)
|
||||
@@ -102,7 +104,7 @@ export class ReportMaterialsController {
|
||||
@CurrentSessionUserId() operatorId?: string,
|
||||
) {
|
||||
if (!file) throw new BadRequestException('请选择 XLSX 文件');
|
||||
return this.service.analyzeImport(file, {
|
||||
return this.service.enqueueImport(file, {
|
||||
tenantId: body.tenantId,
|
||||
applicationId: body.applicationId,
|
||||
reportType: body.reportType as 'signature' | 'drainage',
|
||||
@@ -114,6 +116,11 @@ export class ReportMaterialsController {
|
||||
});
|
||||
}
|
||||
|
||||
@Get('imports/:id/analysis-status')
|
||||
getImportAnalysisStatus(@Param('id') id: string, @Query('tenantId') tenantId: string) {
|
||||
return this.service.getAnalysisStatus(id, tenantId);
|
||||
}
|
||||
|
||||
@Put('imports/:id/commit')
|
||||
@RequireRecentAuthentication()
|
||||
commitImport(@Param('id') id: string, @Body() body: ImportCommitDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
|
||||
@@ -4,6 +4,75 @@ import { ReportMaterialsService } from './report-materials.service';
|
||||
import { mappedCorePatchValue, signatureIdentityReportValues } from './report-materials.helpers';
|
||||
|
||||
describe('ReportMaterialsService', () => {
|
||||
it('stores a valid workbook as a durable queued analysis job before parsing it', async () => {
|
||||
const prisma = {
|
||||
reportMaterialImportBatch: {
|
||||
create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) =>
|
||||
Promise.resolve({ id: 'job-1', preview: null, errorMessage: null, ...data }),
|
||||
),
|
||||
},
|
||||
};
|
||||
const files = { upload: jest.fn().mockResolvedValue({ id: 'file-1', fileName: '行业报备.xlsx' }) };
|
||||
const service = new ReportMaterialsService(
|
||||
prisma as never,
|
||||
files as never,
|
||||
{ getApplication: jest.fn().mockResolvedValue({ id: 'app-1' }) } as never,
|
||||
);
|
||||
const result = await service.enqueueImport(
|
||||
{ originalname: '行业报备.xlsx', mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: 2, buffer: Buffer.from('PK') },
|
||||
{ tenantId: 'tenant-1', applicationId: 'app-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2 },
|
||||
);
|
||||
expect(files.upload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ purpose: 'report_material_import', tenantId: 'tenant-1' }),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(prisma.reportMaterialImportBatch.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ status: 'queued', progress: 10, applicationId: 'app-1' }),
|
||||
});
|
||||
expect(result).toMatchObject({ id: 'job-1', status: 'queued', progress: 10 });
|
||||
});
|
||||
|
||||
it('only returns analysis progress inside the requested tenant', async () => {
|
||||
const findFirst = jest.fn().mockResolvedValue(null);
|
||||
const service = new ReportMaterialsService(
|
||||
{ reportMaterialImportBatch: { findFirst } } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
await expect(service.getAnalysisStatus('job-1', 'tenant-2')).rejects.toThrow('导入解析任务不存在');
|
||||
expect(findFirst).toHaveBeenCalledWith({ where: { id: 'job-1', tenantId: 'tenant-2' } });
|
||||
});
|
||||
|
||||
it('claims only a queued analysis job before handing it to the worker parser', async () => {
|
||||
const prisma = {
|
||||
reportMaterialImportBatch: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'job-1' }),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
|
||||
await expect(service.processNextAnalysisJob()).resolves.toBe(true);
|
||||
expect(prisma.reportMaterialImportBatch.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'job-1', status: 'queued' },
|
||||
data: expect.objectContaining({ status: 'analyzing', progress: 15 }),
|
||||
});
|
||||
});
|
||||
|
||||
it('requeues stale analyzing jobs so a restarted worker can resume them', async () => {
|
||||
const updateMany = jest.fn().mockResolvedValue({ count: 2 });
|
||||
const service = new ReportMaterialsService(
|
||||
{ reportMaterialImportBatch: { updateMany } } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
await expect(service.recoverStaleAnalysisJobs()).resolves.toEqual({ count: 2 });
|
||||
expect(updateMany).toHaveBeenCalledWith({
|
||||
where: { status: 'analyzing', OR: [{ heartbeatAt: null }, { heartbeatAt: { lt: expect.any(Date) } }] },
|
||||
data: { status: 'queued', progress: 10, progressStage: '等待重新解析', errorMessage: null },
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
|
||||
@@ -34,7 +34,11 @@ export class ReportMaterialsService {
|
||||
private readonly channelExport: ReportChannelExportService;
|
||||
private readonly batchGeneration: ReportBatchGenerationService;
|
||||
|
||||
constructor(prisma: PrismaService, files: FilesService, smsConfig: SmsConfigService) {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
files: FilesService,
|
||||
smsConfig: SmsConfigService,
|
||||
) {
|
||||
this.pendingQuery = new ReportPendingQueryService(prisma, files, smsConfig);
|
||||
this.officialExport = new ReportOfficialExportService(prisma, files, smsConfig, this.pendingQuery);
|
||||
this.importParser = new ReportImportParserService(prisma, files, smsConfig);
|
||||
@@ -82,6 +86,48 @@ export class ReportMaterialsService {
|
||||
return this.importParser.analyzeImport(file, options);
|
||||
}
|
||||
|
||||
async enqueueImport(
|
||||
file: { originalname: string; mimetype: string; size: number; buffer: Buffer },
|
||||
options: AnalyzeImportOptions,
|
||||
) {
|
||||
return this.importParser.enqueueImport(file, options);
|
||||
}
|
||||
|
||||
async getAnalysisStatus(batchId: string, tenantId: string) {
|
||||
return this.importParser.getAnalysisStatus(batchId, tenantId);
|
||||
}
|
||||
|
||||
async recoverStaleAnalysisJobs() {
|
||||
const staleBefore = new Date(Date.now() - 5 * 60 * 1000);
|
||||
return this.prisma.reportMaterialImportBatch.updateMany({
|
||||
where: { status: 'analyzing', OR: [{ heartbeatAt: null }, { heartbeatAt: { lt: staleBefore } }] },
|
||||
data: { status: 'queued', progress: 10, progressStage: '等待重新解析', errorMessage: null },
|
||||
});
|
||||
}
|
||||
|
||||
async processNextAnalysisJob() {
|
||||
const candidate = await this.prisma.reportMaterialImportBatch.findFirst({
|
||||
where: { status: 'queued' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!candidate) return false;
|
||||
const claimed = await this.prisma.reportMaterialImportBatch.updateMany({
|
||||
where: { id: candidate.id, status: 'queued' },
|
||||
data: {
|
||||
status: 'analyzing',
|
||||
progress: 15,
|
||||
progressStage: '后台解析已开始',
|
||||
startedAt: new Date(),
|
||||
heartbeatAt: new Date(),
|
||||
errorMessage: null,
|
||||
},
|
||||
});
|
||||
if (!claimed.count) return true;
|
||||
await this.importParser.processQueuedBatch(candidate.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
async commitImport(batchId: string, data: ImportCommitDto) {
|
||||
return this.importReview.commitImport(batchId, data);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
import JSZip from 'jszip';
|
||||
import { compatibleImages, convertWorkbookOutput, loadCompatibleWorkbook } from './workbook-compatibility';
|
||||
import {
|
||||
compatibleImagePositions,
|
||||
compatibleImages,
|
||||
convertWorkbookOutput,
|
||||
loadCompatibleWorkbook,
|
||||
} from './workbook-compatibility';
|
||||
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZrYQAAAAASUVORK5CYII=',
|
||||
@@ -39,4 +44,15 @@ describe('WPS workbook compatibility', () => {
|
||||
const content = Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
await expect(loadCompatibleWorkbook(content)).rejects.toThrow('不允许的公式');
|
||||
});
|
||||
|
||||
it('reads WPS image positions without inflating image buffers during analysis', async () => {
|
||||
const converted = await convertWorkbookOutput(await standardWorkbook(), 'wps_cell_image');
|
||||
const loaded = await loadCompatibleWorkbook(converted, { includeImageData: false });
|
||||
const wpsImage = loaded.wpsImagesBySheet.get('签名报备')?.[0];
|
||||
expect(wpsImage).toMatchObject({ row: 2, column: 1, extension: 'png', size: PNG.length });
|
||||
expect(wpsImage).not.toHaveProperty('buffer');
|
||||
expect(
|
||||
compatibleImagePositions(loaded.workbook, loaded.workbook.getWorksheet('签名报备')!, loaded.wpsImagesBySheet),
|
||||
).toEqual([{ row: 2, column: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 type { EmbeddedImage, EmbeddedImageMetadata, ReportWorkbookFormat } from './report-materials.contracts';
|
||||
import { normalizeImageExtension, readEmbeddedImages } from './report-materials.helpers';
|
||||
|
||||
const MAX_WORKBOOK_BYTES = 100 * 1024 * 1024;
|
||||
@@ -72,9 +72,14 @@ function validateWorkbookValues(workbook: ExcelJS.Workbook, allowedWpsIds: Set<s
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectWpsImages(zip: JSZip) {
|
||||
async function inspectWpsImages(zip: JSZip, includeImageData: boolean) {
|
||||
const cellImagesXml = await text(zip, 'xl/cellimages.xml');
|
||||
if (!cellImagesXml) return { allowedIds: new Set<string>(), imagesBySheet: new Map<string, EmbeddedImage[]>() };
|
||||
if (!cellImagesXml)
|
||||
return {
|
||||
allowedIds: new Set<string>(),
|
||||
imagesBySheet: new Map<string, Array<EmbeddedImage | EmbeddedImageMetadata>>(),
|
||||
mediaTargets: new Set<string>(),
|
||||
};
|
||||
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)) {
|
||||
@@ -99,13 +104,14 @@ async function inspectWpsImages(zip: JSZip) {
|
||||
sheetRelTargets.set(attrs.Id, packagePath(attrs.Target));
|
||||
}
|
||||
const allowedIds = new Set<string>();
|
||||
const imagesBySheet = new Map<string, EmbeddedImage[]>();
|
||||
const imagesBySheet = new Map<string, Array<EmbeddedImage | EmbeddedImageMetadata>>();
|
||||
let totalImageBytes = 0;
|
||||
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[] = [];
|
||||
const images: Array<EmbeddedImage | EmbeddedImageMetadata> = [];
|
||||
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;
|
||||
@@ -118,20 +124,31 @@ async function inspectWpsImages(zip: JSZip) {
|
||||
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 });
|
||||
const size = Number(
|
||||
(imageEntry as unknown as { _data?: { uncompressedSize?: number } })._data?.uncompressedSize ?? 0,
|
||||
);
|
||||
if (size > MAX_IMAGE_BYTES) throw new BadRequestException('单张WPS图片不能超过20MB');
|
||||
totalImageBytes += size;
|
||||
if (includeImageData) {
|
||||
const imageBuffer = await imageEntry.async('nodebuffer');
|
||||
if (!validImageSignature(extension, imageBuffer))
|
||||
throw new BadRequestException('WPS单元格图片内容与格式不匹配');
|
||||
images.push({ ...cellPosition, extension, buffer: imageBuffer });
|
||||
} else {
|
||||
images.push({ ...cellPosition, extension, size, target });
|
||||
}
|
||||
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 };
|
||||
return { allowedIds, imagesBySheet, mediaTargets: new Set(imageTargets.values()) };
|
||||
}
|
||||
|
||||
export async function loadCompatibleWorkbook(buffer: Buffer) {
|
||||
export async function loadCompatibleWorkbook(
|
||||
buffer: Buffer,
|
||||
options: { includeImageData?: boolean; onProgress?: (progress: number, stage: string) => void | Promise<void> } = {},
|
||||
) {
|
||||
if (buffer.length > MAX_WORKBOOK_BYTES) throw new BadRequestException('导入文件不能超过100MB');
|
||||
let zip: JSZip;
|
||||
try {
|
||||
@@ -139,6 +156,7 @@ export async function loadCompatibleWorkbook(buffer: Buffer) {
|
||||
} catch {
|
||||
throw new BadRequestException('仅支持有效的 XLSX 文件');
|
||||
}
|
||||
await options.onProgress?.(35, '已读取工作簿压缩包');
|
||||
const expandedBytes = Object.values(zip.files).reduce(
|
||||
(sum, entry) =>
|
||||
sum + Number((entry as unknown as { _data?: { uncompressedSize?: number } })._data?.uncompressedSize ?? 0),
|
||||
@@ -148,17 +166,32 @@ export async function loadCompatibleWorkbook(buffer: Buffer) {
|
||||
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 wps = await inspectWpsImages(zip, options.includeImageData !== false);
|
||||
await options.onProgress?.(55, '已定位WPS单元格图片');
|
||||
let excelJsInput = buffer;
|
||||
if (options.includeImageData === false && wps.mediaTargets.size) {
|
||||
for (const target of wps.mediaTargets) zip.remove(target);
|
||||
zip.remove('xl/cellimages.xml');
|
||||
zip.remove('xl/_rels/cellimages.xml.rels');
|
||||
excelJsInput = await zip.generateAsync({
|
||||
type: 'nodebuffer',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: { level: 1 },
|
||||
});
|
||||
await options.onProgress?.(70, '已剥离解析阶段无需加载的WPS图片数据');
|
||||
}
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer as never);
|
||||
await workbook.xlsx.load(excelJsInput as never);
|
||||
await options.onProgress?.(80, '已解析工作表内容');
|
||||
validateWorkbookValues(workbook, wps.allowedIds);
|
||||
await options.onProgress?.(90, '已完成工作簿安全校验');
|
||||
return { workbook, wpsImagesBySheet: wps.imagesBySheet };
|
||||
}
|
||||
|
||||
export function compatibleImages(
|
||||
workbook: ExcelJS.Workbook,
|
||||
worksheet: ExcelJS.Worksheet,
|
||||
wpsImagesBySheet: Map<string, EmbeddedImage[]>,
|
||||
wpsImagesBySheet: Map<string, Array<EmbeddedImage | EmbeddedImageMetadata>>,
|
||||
) {
|
||||
const merged = new Map<string, EmbeddedImage>();
|
||||
const wpsImages = wpsImagesBySheet.get(worksheet.name) ?? [];
|
||||
@@ -169,10 +202,29 @@ export function compatibleImages(
|
||||
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);
|
||||
for (const image of wpsImages) {
|
||||
if (!('buffer' in image)) throw new BadRequestException('图片元数据不能用于提交导入');
|
||||
merged.set(`${image.row}:${image.column}`, image);
|
||||
}
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
export function compatibleImagePositions(
|
||||
workbook: ExcelJS.Workbook,
|
||||
worksheet: ExcelJS.Worksheet,
|
||||
wpsImagesBySheet: Map<string, Array<EmbeddedImage | EmbeddedImageMetadata>>,
|
||||
) {
|
||||
const wpsImages = wpsImagesBySheet.get(worksheet.name) ?? [];
|
||||
const positions = new Map<string, { row: number; column: number }>();
|
||||
for (const image of readEmbeddedImages(workbook, worksheet)) {
|
||||
if (wpsImages.length && image.buffer.length <= 128) continue;
|
||||
positions.set(`${image.row}:${image.column}`, { row: image.row, column: image.column });
|
||||
}
|
||||
for (const image of wpsImages)
|
||||
positions.set(`${image.row}:${image.column}`, { row: image.row, column: image.column });
|
||||
return [...positions.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('不支持的报备文件格式');
|
||||
|
||||
Reference in New Issue
Block a user