From fb39c8b606a644e1a2907a372aba4a82d1968f47 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Fri, 4 Sep 2026 17:10:04 +0800 Subject: [PATCH] feat: support WPS report material workbooks --- .../batch-download.service.ts | 20 +- .../channel-export.service.ts | 55 +-- .../report-materials/import-parser.service.ts | 262 ++++++---- .../report-materials/import-review.service.ts | 45 +- .../report-materials.contracts.ts | 5 +- .../report-materials.controller.ts | 26 +- .../report-materials.service.spec.ts | 323 ++++++------ .../workbook-compatibility.spec.ts | 42 ++ .../workbook-compatibility.ts | 284 +++++++++++ docs/production-deployment.md | 2 +- docs/system-functional-test-cases.md | 8 + docs/testing-progress.md | 12 + ...-cell-image-import-export-plan-20260904.md | 463 ++++++++++++++++++ src/api/admin/channels-reports.api.ts | 15 +- src/apps/admin/AdminChannelReportPage.tsx | 93 +++- .../admin/AdminDrainageFieldsPage.test.tsx | 80 ++- src/apps/admin/AdminDrainageFieldsPage.tsx | 372 ++++++++++++-- src/apps/admin/AdminHome.tsx | 243 ++++----- src/apps/admin/AdminReportBatchesPage.tsx | 17 +- src/apps/admin/AdminReportTasksPage.tsx | 150 +++++- src/apps/admin/ReportExportFormatModal.tsx | 56 +++ .../admin/ReportFieldMappingModal.test.tsx | 32 ++ src/apps/admin/ReportFieldMappingModal.tsx | 323 +++++++++--- .../admin/ReportMaterialImportModal.test.tsx | 19 +- src/apps/admin/ReportMaterialImportModal.tsx | 395 ++++++++++++--- src/apps/admin/ReportWorkbenchPages.test.tsx | 70 ++- src/styles/global.css | 48 ++ src/utils/fileUpload.ts | 9 +- tools/deploy/production-bootstrap.sh | 2 +- 29 files changed, 2737 insertions(+), 734 deletions(-) create mode 100644 api/src/report-materials/workbook-compatibility.spec.ts create mode 100644 api/src/report-materials/workbook-compatibility.ts create mode 100644 docs/wps-cell-image-import-export-plan-20260904.md create mode 100644 src/apps/admin/ReportExportFormatModal.tsx create mode 100644 src/apps/admin/ReportFieldMappingModal.test.tsx diff --git a/api/src/report-materials/batch-download.service.ts b/api/src/report-materials/batch-download.service.ts index 5f8e56e..6902d0c 100644 --- a/api/src/report-materials/batch-download.service.ts +++ b/api/src/report-materials/batch-download.service.ts @@ -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 { + async exportFile( + batch: BatchDownloadSource, + fileId: string, + outputFormat: ReportWorkbookFormat = 'excel_drawing', + ): Promise { 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 { + async exportBundle( + batch: BatchDownloadSource, + outputFormat: ReportWorkbookFormat = 'excel_drawing', + ): Promise { 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); diff --git a/api/src/report-materials/channel-export.service.ts b/api/src/report-materials/channel-export.service.ts index 9b6ecb4..caeed04 100644 --- a/api/src/report-materials/channel-export.service.ts +++ b/api/src/report-materials/channel-export.service.ts @@ -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, diff --git a/api/src/report-materials/import-parser.service.ts b/api/src/report-materials/import-parser.service.ts index e76e449..2fcb734 100644 --- a/api/src/report-materials/import-parser.service.ts +++ b/api/src/report-materials/import-parser.service.ts @@ -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, + }; + } } diff --git a/api/src/report-materials/import-review.service.ts b/api/src/report-materials/import-review.service.ts index 3f190eb..ad0b6d4 100644 --- a/api/src/report-materials/import-review.service.ts +++ b/api/src/report-materials/import-review.service.ts @@ -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 }> = []; diff --git a/api/src/report-materials/report-materials.contracts.ts b/api/src/report-materials/report-materials.contracts.ts index d25bc8d..329f9b1 100644 --- a/api/src/report-materials/report-materials.contracts.ts +++ b/api/src/report-materials/report-materials.contracts.ts @@ -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'; diff --git a/api/src/report-materials/report-materials.controller.ts b/api/src/report-materials/report-materials.controller.ts index 7e76d87..517f4ae 100644 --- a/api/src/report-materials/report-materials.controller.ts +++ b/api/src/report-materials/report-materials.controller.ts @@ -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, @@ -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') diff --git a/api/src/report-materials/report-materials.service.spec.ts b/api/src/report-materials/report-materials.service.spec.ts index 547a693..e89c336 100644 --- a/api/src/report-materials/report-materials.service.spec.ts +++ b/api/src/report-materials/report-materials.service.spec.ts @@ -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 }) => 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: [ diff --git a/api/src/report-materials/workbook-compatibility.spec.ts b/api/src/report-materials/workbook-compatibility.spec.ts new file mode 100644 index 0000000..206ff9d --- /dev/null +++ b/api/src/report-materials/workbook-compatibility.spec.ts @@ -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('不允许的公式'); + }); +}); diff --git a/api/src/report-materials/workbook-compatibility.ts b/api/src/report-materials/workbook-compatibility.ts new file mode 100644 index 0000000..7938063 --- /dev/null +++ b/api/src/report-materials/workbook-compatibility.ts @@ -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(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/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) { + 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(), imagesBySheet: new Map() }; + const relXml = await text(zip, 'xl/_rels/cellimages.xml.rels'); + const relTargets = new Map(); + for (const match of relXml.matchAll(/]*)\/?>(?:<\/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(); + for (const match of cellImagesXml.matchAll( + /]*>[\s\S]*?]*)\/?>(?:[\s\S]*?)]*)\/?>(?:[\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(); + for (const match of workbookRelsXml.matchAll(/]*)\/?>(?:<\/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(); + const imagesBySheet = new Map(); + for (const match of workbookXml.matchAll(/]*)\/?>(?:<\/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(/]*)>([\s\S]*?)<\/c>/g)) { + const formulaText = decodeXml(cell[2].match(/]*)?>([\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, +) { + const merged = new Map(); + 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(); + for (const match of workbookRels.matchAll(/]*)\/?>(?:<\/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(); + for (const match of workbookXml.matchAll(/]*)\/?>(?:<\/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>)?/g, ''); + for (const picture of sheetPictures) { + const fullCell = new RegExp(`]*\\br="${picture.address}"[^>]*)>[\\s\\S]*?<\\/c>`); + const emptyCell = new RegExp(`]*\\br="${picture.address}"[^>]*)\\/>`); + const replaceCell = (opening: string) => + `_xlfn.DISPIMG("${picture.id}",1)=DISPIMG("${picture.id}",1)`; + 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(/]*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) => + ``, + ) + .join(''); + zip.file( + 'xl/cellimages.xml', + `${cellImages}`, + ); + zip.file( + 'xl/_rels/cellimages.xml.rels', + `${pictures.map((picture) => ``).join('')}`, + ); + const nextRel = Math.max(0, ...[...workbookRels.matchAll(/Id="rId(\d+)"/g)].map((match) => Number(match[1]))) + 1; + workbookRels = workbookRels.replace( + '', + ``, + ); + zip.file('xl/_rels/workbook.xml.rels', workbookRels); + let contentTypes = await text(zip, '[Content_Types].xml'); + contentTypes = contentTypes.replace( + /]*PartName="\/xl\/drawings\/[^"]+"[^>]*\/?>(?:<\/Override>)?/g, + '', + ); + if (!contentTypes.includes('/xl/cellimages.xml')) + contentTypes = contentTypes.replace( + '', + '', + ); + zip.file('[Content_Types].xml', contentTypes); + return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } }); +} diff --git a/docs/production-deployment.md b/docs/production-deployment.md index c139dbc..b37d55b 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -146,7 +146,7 @@ PROD_ADMIN_PASSWORD='change-me' `API_ENABLE_SEND_WORKER=true` 是生产发送链路必填项。后续发布脚本会在构建和迁移前校验该开关以及正整数 `API_SEND_WORKER_CONCURRENCY`;缺失时直接终止发布,防止 API/Gateway 健康但 BullMQ 短信队列无人消费。 -HTTP容量边界固定为:NestJS普通JSON/URL-encoded请求体`2 MiB`,仅`/api/client/send/imports/*`使用`25 MiB` JSON解析上限,原始CSV/TSV正文继续由业务层限制为`20 MiB`;Gateway读取NestJS API响应最多`4 MiB`且超限必须明确报错。客户文件导入走`sms.lisglo.com`私有API,因此该虚拟主机的`client_max_body_size`必须不低于`30m`,标准bootstrap配置为`50m`。`api.lisglo.com`只承载单条公网HTTP API、Swagger和健康检查,不承载客户文件导入;不要为导入需求开放私有路由或把NestJS所有JSON接口统一放宽到25MiB。发布前使用`nginx -T`确认最终生效值,不能只检查仓库模板。 +HTTP容量边界固定为:NestJS普通JSON/URL-encoded请求体`2 MiB`,仅`/api/client/send/imports/*`使用`25 MiB` JSON解析上限,原始CSV/TSV正文继续由业务层限制为`20 MiB`;Gateway读取NestJS API响应最多`4 MiB`且超限必须明确报错。客户文件导入和运营端报备资料导入走`sms.lisglo.com`私有API;报备XLSX允许最大100MiB并另设500MiB解压后总量限制,因此该虚拟主机的`client_max_body_size`必须不低于`110m`(包含multipart开销),标准bootstrap配置为`110m`。`api.lisglo.com`只承载单条公网HTTP API、Swagger和健康检查,不承载文件导入;不要为导入需求开放私有路由或把NestJS所有JSON接口统一放宽。发布前使用`nginx -T`确认最终生效值,不能只检查仓库模板。 Gateway 的最终 TPS 防线依赖与 API 相同的 Redis。通道连接时会写入 `rate:gateway:channel:config:` 权威上限,实际预约使用 `rate:gateway:channel:`;这些 key 不应在正常发布时清理。多 Gateway 实例必须指向同一 Redis,才能共享单通道额度。超速的 `gateway.submit.commands` 消息会保持在 consumer group pending 中等待,不应通过手工 `XACK` 或删除 Stream 处理积压;先检查通道配置、Redis key、consumer group 和 Gateway 日志。V2起Submit Worker使用持续补位有界池并逐条ACK,`GATEWAY_SUBMIT_WORKER_CONCURRENCY`缺省64、最大1024;调整前必须同时核对供应商连接数、窗口、TPS限制、Gateway RSS和`cmpp_gateway_submit_worker_slots`,不能用放大并发绕过通道限速。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 9798d79..20c3cc5 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5062,3 +5062,11 @@ npm run verify:phase8 | TC-REPORT-FIELD-EDIT-002 | 编辑已被通道或通用字段引用的字段 | 页面锁定代码和类型,允许修改名称和说明;绕过前端直接修改代码或类型时API返回400,既有通道映射和历史资料不受影响 | | TC-REPORT-FIELD-ORDER-001 | 分别在签名、引流通用字段中点击上移/下移并刷新 | 仅在当前资料类型内整体重排,真实`sortOrder`按新顺序持久化;并发导致集合变化时明确失败,不产生部分更新 | | TC-REPORT-WORKBENCH-025 | 在通道报备明细点击单条导出,并分别模拟空请求体和缺失必填资料 | 正常请求以JSON Content-Type提交并下载真实XLSX;空请求体返回可读400而不是500;资料错误明确返回且不静默生成空文件,不改变报备状态或触发短信链路 | +| TC-REPORT-WPS-001 | 上传`行业报备.xlsx`等使用`_xlfn.DISPIMG`、`xl/cellimages.xml`及关系文件的WPS XLSX | 仅精确白名单内且图片关系完整的DISPIMG公式可解析;图片按真实单元格映射进入预览与审核,透明Drawing占位图不计入;普通公式、宏、外部对象、缺失关系和非法图片格式均明确拒绝 | +| TC-REPORT-WPS-002 | 分别上传10MB以上且不超过100MB、超过100MB、解压后超过500MB的报备XLSX | 第一类可进入解析;后两类分别在上传层或解包层明确拒绝;Nginx私有站点允许100MB业务文件及multipart开销,不扩大公网单条API边界 | +| TC-REPORT-WPS-003 | 在批次和单条报备导出选择“系统 Excel 文件”或“WPS 单元格图片文件” | 默认保持ExcelJS Drawing格式;WPS选项生成DISPIMG与cellimages关系且图片按对应单元格可回读;两种格式均来自同一真实资料快照,不改变报备状态或短信链路 | +| TC-REPORT-IMPORT-APPLICATION-001 | 未选择企业应用、选择其他企业的应用、选择当前企业应用后解析导入 | 前两种前后端均阻止导入;合法应用可解析并把applicationId写入导入批次,后续补资料只作用于该企业应用范围 | +| TC-REPORT-FIELD-MODAL-001 | 打开签名或引流字段配置弹窗,添加字段并检查通用字段与移除按钮 | 左侧字段卡片高度不因添加改变;右侧默认包含同资料类型的通用字段;移除按钮为通用宽度、无红色填充,保存仍调用真实通道字段接口 | +| TC-REPORT-MATERIAL-DETAIL-001 | 查看包含当前图片字段、未删除历史字段及旧图片引用的报备资料 | 当前字段展示名称、代码、导出名及图片预览;未删除历史字段继续展示且同时显示字段名称和代码;图片可内联查看并保留下载入口,缺失内容显示明确占位 | +| TC-REPORT-CHANNEL-IDENTITY-001 | 打开短信通道管理的报备详情 | 页面同时明确展示“通道名称”和“通道编号”,列表、筛选、状态修改和窄屏布局不受影响 | +| TC-DASHBOARD-METRIC-ORDER-001 | 打开运营看板并按从左到右、从上到下读取指标 | 顺序为发送总量、消息分片数、总体成功率、到达率、活跃签名、消费金额、返还金额、计收金额、利润、利润率;所有数值继续来自真实API口径 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 76ec74a..6a22bae 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4438,3 +4438,15 @@ git diff --check - Browser插件本轮不可用,按前端调试流程使用工作区Playwright Chromium验证本地生产构建。1600×1000运营看板和质量矩阵、390×844运营看板页面身份、非空、错误层、控制台及横向溢出检查通过;菜单项上下内边距实测7px,矩阵完整保留通道提交、成功率、平均到达和提交失败信息。截图数据只用于布局验证,不冒充真实业务数据。 - 本地真实PostgreSQL启动后,新看板聚合代码直接执行成功,返回当日零短信下分片数、到达率、计收、利润和利润率均为0,确认SQL语法、表关联和零分母处理可运行;本地API健康HTTP 200。Redis未启动时API持续输出连接拒绝,故未将该不完整本地栈作为页面功能验收,验证后已关闭本地API、预览和PostgreSQL。 - 本轮只做本地提交,不推送、不部署,不访问或修改测试/预生产业务数据;不发送、补发、重投或重新入队短信,不修改余额、通道或客户配置。本节与源码、测试用例一并纳入本轮本地提交。 + +## 2026-09-04 WPS报备资料兼容与工作台修复(本地验证完成,待测试环境发布) + +- 以本地`main`的`48d0363`为基线实施,本地相对`origin/main`领先2个提交且未落后;没有pull、切分支或回退。既有未跟踪`docs/report-material-pool-remediation-plan-20260903.md`保持原样,本轮方案文档单独纳入精确提交范围。 +- WPS导入新增原始OOXML解析:精确识别`_xlfn.DISPIMG("ID_...",1)`,经`xl/cellimages.xml`及关系文件定位媒体,再与ExcelJS标准Drawing图片按单元格合并;普通公式、宏/外部对象、不完整关系和非白名单图片继续拒绝,没有整体放宽公式安全校验。 +- 真实样本`C:\Users\hectorzhao\Downloads\行业报备.xlsx`为20,976,525字节,解析得到工作表“行业”、17行、13列、43张业务图片,图片总字节20,672,756;A1的84字节透明Drawing占位图被排除。该只读样本未写回或覆盖。 +- 报备导入前端和API均将企业应用改为必选,并由后端校验应用属于所选企业;XLSX压缩文件上限调整为100MiB,增加500MiB解压总量限制。Nginx私有站点模板同步调整为110m以容纳multipart开销;这会提高单请求资源峰值,因此仍保留文件数、部件数、压缩/解压体积和格式安全限制。 +- 批次报备文件弹窗与两个单条导出入口均可选择“系统Excel Drawing”或“WPS单元格图片”;默认保持现有Excel格式,WPS格式由同一真实资料快照生成并可被新解析器回读,不改变数据库资料、报备状态或短信链路。 +- 报备字段库“添加字段”移入字段定义区域;通道字段配置弹窗加载真实通用字段作为右侧默认项,固定左侧卡片最小高度,移除按钮改为非危险填充的通用宽度。通道详情同时展示名称和编号;资料弹窗展示图片、字段名称/代码/导出名,并为未删除历史字段回填字段库名称。 +- 运营看板指标按业务阅读顺序调整为发送总量、分片数、总体成功率、到达率、活跃签名、消费、返还、计收、利润和利润率;只调整排列,不改变上一提交新增的真实聚合口径。 +- 定向前端4文件12项、前端全量13文件63项、定向API3套20项、API全量53套608项通过;前后端TypeScript、Vite生产构建、依赖安全、部署契约、结构质量、增量ESLint/Prettier、入口包体积和`git diff --check`通过。ESLint仅保留3条既有Hook依赖warning;Vite仅保留既有Chart分块超过500kB提示,入口gzip 107.80KiB,低于250KiB预算。 +- Browser插件及工作区Playwright依赖在当前会话不可用,尚未把组件测试或本地构建冒充真实页面验收;测试环境部署后的登录页、真实API/服务、控制台和登录后交互仍需继续核验。测试机当前网络可达,但已有非交互密钥认证返回`Permission denied (publickey,password)`,正在复用工作站既有安全认证方式,不在命令或日志中写入密码。 diff --git a/docs/wps-cell-image-import-export-plan-20260904.md b/docs/wps-cell-image-import-export-plan-20260904.md new file mode 100644 index 0000000..ee7b16c --- /dev/null +++ b/docs/wps-cell-image-import-export-plan-20260904.md @@ -0,0 +1,463 @@ +# WPS 单元格图片导入与双格式导出实现方案 + +日期:2026-09-04 +状态:已按方案实施并通过本地验证 +范围:报备资料导入、单条报备资料导出、批次通道文件下载和批次 ZIP 下载 + +## 1. 目标 + +在不改变现有报备资料、材料版本、审核、批次和通道报备状态逻辑的前提下: + +1. 导入自动识别并解析两类 `.xlsx` 图片: + - ExcelJS 当前支持的标准 Drawing 图片; + - WPS `DISPIMG + xl/cellimages.xml` 单元格图片。 +2. 导出时让用户选择: + - Excel 通用格式:保持系统现有标准 Drawing 图片格式; + - WPS 单元格图片格式:生成与样本相同机制的 `DISPIMG` 单元格图片文件。 +3. 保持公式安全检查严格有效。只允许与包内受控图片一一对应的 WPS `DISPIMG`,不得直接放宽为允许任意公式。 +4. 两种格式使用相同的真实 PostgreSQL 资料、批次快照和 MinIO 文件,格式选择不得改变业务数据、任务状态或历史批次内容。 + +本方案不涉及短信发送、Redis Stream、队列、计费、余额、通道连接参数或部署架构调整。 + +## 2. 样本核验结果 + +样本:`C:\Users\hectorzhao\Downloads\行业报备.xlsx` + +| 项目 | 实测结果 | +| --- | --- | +| 文件大小 | 20,976,525 字节 | +| SHA-256 | `D5B5029B044EF5B8A86A68D4FE33E4F80D3AAB3A9B547D228145158D4B1518C1` | +| 工作表 | `行业` | +| 使用范围 | `A1:M17`,1 行表头、16 行数据 | +| WPS 图片公式 | 43 个,位于 H2:K17 的非空图片单元格 | +| `cellimages.xml` 图片项 | 43 个 | +| 图片关系 | 43 个,和公式 ID 一一对应 | +| 业务图片 | `xl/media/image2.png` 至 `image44.png`,均为 PNG | +| 业务图片原始字节合计 | 20,672,756 字节,约占整个文件 98.55% | +| 图片像素范围 | 宽 461~1226,高 276~840 | +| 标准 Drawing | 3 个,均指向 A1 的同一张 1×1、84 字节 PNG 占位图 | + +样本中每个图片单元格保存的不是标准 Drawing 锚点,而是以下公式: + +```text +_xlfn.DISPIMG("ID_7C706627234641A7BCEA9177C8A3EACB",1) +``` + +其引用链为: + +```text +sheet1.xml 中的单元格公式 + -> 公式内 ID_xxx + -> xl/cellimages.xml 中 xdr:cNvPr@name + -> a:blip@r:embed + -> xl/_rels/cellimages.xml.rels + -> xl/media/imageN.png +``` + +`xl/_rels/workbook.xml.rels` 还包含 WPS 专用关系: + +```text +Type="http://www.wps.cn/officeDocument/2020/cellImage" +Target="cellimages.xml" +``` + +`[Content_Types].xml` 包含: + +```text +ContentType="application/vnd.wps-officedocument.cellimage+xml" +PartName="/xl/cellimages.xml" +``` + +样本中的图片显示尺寸较小,但 PNG 原始像素和原始字节仍完整保存在 XLSX 中。调整单元格显示尺寸不等于压缩图片,WPS 格式选择也不应被描述为压缩功能。 + +## 3. 与系统现有图片处理方式的差异 + +### 3.1 当前导入 + +当前实现使用 ExcelJS 加载工作簿,然后: + +1. `assertSafeWorkbook` 拒绝任何公式或疑似公式文本; +2. `readEmbeddedImages` 只读取 `worksheet.getImages()`; +3. 根据 Drawing 左上角锚点换算图片所在行列; +4. 提交导入时将图片上传至真实 MinIO,并把 `fileObjectId/fileName/contentType` 写入待审核资料。 + +对本次样本的实际结果是: + +- ExcelJS 将 43 个 `DISPIMG` 识别为公式,因此现有安全检查会直接拒绝文件; +- ExcelJS `worksheet.getImages()` 只返回 3 个 A1 的 1×1 占位 Drawing; +- 43 个真实业务图片虽然进入 ExcelJS 的媒体集合,但没有单元格锚点,当前代码无法关联到 H2:K17; +- 即使简单放宽公式检查,图片列仍会被识别为无图片,单元格文本还可能变成 `=DISPIMG(...)`,不能得到真实文件对象; +- 当前上传上限为 10 MiB,而样本约 20.0 MiB,会在工作簿解析前被上传中间件拒绝。 + +### 3.2 当前导出 + +当前导出使用 ExcelJS: + +1. 从真实 MinIO 下载 PNG/JPEG/GIF 原文件; +2. `workbook.addImage` 注册媒体; +3. `worksheet.addImage` 以 `oneCell` Drawing 锚点覆盖到目标单元格; +4. 按通道配置调整列宽、图片显示范围和行高; +5. 图片单元格本身仍保留文件名文本。 + +该格式是标准 DrawingML 图片:Excel 和 WPS 通常都能打开,兼容范围更广;但图片本质是浮动绘图对象,不是 WPS 的单元格图片值。 + +### 3.3 差异结论 + +| 对比项 | 系统现有 Excel 格式 | 样本 WPS 格式 | +| --- | --- | --- | +| 单元格内容 | 文件名文本 | `DISPIMG` 公式 | +| 图片定位 | 工作表 Drawing 锚点 | 公式 ID 关联 `cellimages.xml` | +| 图片关系文件 | `xl/drawings/*.xml(.rels)` | `xl/cellimages.xml` 和专用 rels | +| ExcelJS 直接读取 | 支持 | 不支持单元格映射 | +| Microsoft Excel 兼容性 | 较好 | 取决于 Excel 版本,可能显示 `_xlfn.DISPIMG` 或不显示图片 | +| WPS 行/列语义 | 浮动对象 | WPS 原生单元格图片 | +| 图片原始大小 | 默认保留原图 | 样本同样保留原图 | + +因此不能用“允许 DISPIMG 公式”代替 WPS 图片支持,也不能把两种格式合并为同一解析路径。 + +## 4. 产品交互方案 + +### 4.1 导入 + +导入不要求用户预先选择格式。上传后由后端自动检测: + +- `excel_drawing`:仅存在标准 Drawing 图片; +- `wps_cell_image`:存在有效 `cellimages.xml` 和匹配的 `DISPIMG`; +- `mixed`:两种图片同时存在; +- `none`:没有图片。 + +解析结果页显示只读提示,例如: + +```text +已识别:WPS 单元格图片格式,43 张图片 +``` + +混合格式按单元格合并。若同一单元格同时存在 WPS 单元格图片和标准 Drawing,视为冲突并要求用户修正,不静默选择其中一个。 + +### 4.2 单条报备资料导出 + +“导出报备资料”点击后打开格式选择弹窗: + +- `Excel 通用格式`,默认选中;说明“图片为标准 Excel 图片,兼容 Excel 和 WPS”; +- `WPS 单元格图片格式`;说明“图片作为 WPS 单元格图片,Microsoft Excel 兼容性取决于版本”。 + +确认后才请求导出。关闭弹窗不发请求,生成中禁止重复提交,失败保留选择并展示后端真实错误。 + +### 4.3 报备批次导出 + +现有“报备文件导出”已经是弹窗,不再叠加第二层弹窗。在弹窗顶部增加同一格式选择: + +- 下载单个通道文件时应用当前选择; +- “全部下载”时,ZIP 内所有 XLSX 使用同一种选择格式; +- TXT 简报不受影响。 + +Excel 通用格式继续使用现有文件名。WPS 文件增加 `_WPS` 后缀以免用户混淆,例如: + +```text +2026-09-04_通道名_RBxxxx_WPS.xlsx +``` + +默认值始终为 Excel 通用格式,不改变既有用户的下载结果和接口行为。 + +## 5. 后端实现设计 + +### 5.1 增加统一格式枚举 + +```ts +type ReportWorkbookFormat = 'excel_drawing' | 'wps_cell_image'; +``` + +所有入口统一校验该枚举,缺省为 `excel_drawing`。不接受任意字符串、文件扩展名或由前端提交的 OOXML 片段。 + +建议接口调整: + +```text +POST /api/admin/report-materials/single-export +body.outputFormat = excel_drawing | wps_cell_image + +GET /api/admin/report-materials/batches/:id/files/:fileId/download +?outputFormat=excel_drawing|wps_cell_image + +GET /api/admin/report-materials/batches/:id/download +?outputFormat=excel_drawing|wps_cell_image +``` + +权限、租户、批次、通道和文件归属校验保持现有逻辑;格式参数不参与业务对象定位。 + +### 5.2 导入前置 OOXML 检查器 + +在 ExcelJS 之前增加 `WorkbookPackageInspector`,使用项目已有 `jszip` 读取原始 XLSX 包,只做受限结构解析: + +1. 验证 ZIP 路径规范化,拒绝绝对路径、`..` 路径穿越和重复关键部件; +2. 拒绝宏、ActiveX、OLE、外部链接、外部关系和非预期可执行部件; +3. 读取 workbook、worksheet、关系文件、`cellimages.xml` 和媒体清单; +4. 从原始 worksheet XML 提取真实公式单元格,避免 ExcelJS 因合并单元格复制公式造成重复图片; +5. 建立 `sheetName + row + column -> image` 映射; +6. 输出检测格式、图片数、冲突和安全诊断,再交给现有 ExcelJS 文本/样式解析流程。 + +本次样本在原始 XML 中是 43 个公式;ExcelJS 模型中会因合并单元格显示为 49 个公式。因此 WPS 图片定位必须以原始 worksheet XML 为准。 + +### 5.3 WPS 单元格图片解析器 + +新增 `WpsCellImageReader`,按以下顺序解析: + +1. 从 workbook relationships 找到且仅找到一个 WPS cellImage 部件; +2. 解析 `cellimages.xml` 中每个 `xdr:cNvPr@name` 和 `a:blip@r:embed`; +3. 通过 `cellimages.xml.rels` 解析内部媒体路径; +4. 只接受包内图片关系,不允许 `TargetMode="External"`; +5. 从每个原始工作表单元格提取严格格式的 `DISPIMG` ID; +6. 校验公式 ID、cellImage ID、relationship ID 和媒体对象一一可解析; +7. 返回与现有 `EmbeddedImage` 相同的 `{row, column, extension, buffer}`,让后续映射、MinIO 上传和审核流程继续复用。 + +不把 `cellimages.xml` 中的 `a:xfrm` 坐标当作单元格地址。样本的业务单元格位置来自公式本身,ID 才是图片关联键。 + +### 5.4 公式安全策略 + +保留现有“默认拒绝所有公式”原则,仅为已验证的 WPS 图片单元格建立精确白名单。允许条件必须全部满足: + +1. 原始公式完整匹配: + + ```text + ^_xlfn\.DISPIMG\("ID_[A-F0-9]{32}",1\)$ + ``` + +2. 公式所在工作表和单元格已由 OOXML 检查器登记; +3. ID 在 `cellimages.xml` 中唯一存在; +4. 图片关系是包内图片且目标文件通过类型、大小和签名校验; +5. 一个公式只引用一张图片,一个 ID 不允许被不受控地复用到多个业务单元格; +6. 除该单元格外,工作簿不存在其他公式、共享公式、数组公式、外部链接或疑似公式文本。 + +不要修改为“公式名包含 DISPIMG 即放行”,也不要只依赖 ExcelJS 解析后的 `cell.value.result`。 + +### 5.5 标准 Drawing 与 WPS 图片统一 + +`readEmbeddedImages` 调整为接收前置检查结果: + +```text +标准 Drawing 解析结果 ++ WPS cell image 解析结果 +-> 按 sheet/row/column 合并 +-> 冲突检测 +-> 统一 EmbeddedImage[] +``` + +样本中 A1 的 3 个 1×1 Drawing 是占位对象,不应当被当作 H2:K17 的图片,也不应以“图片数量大于零”证明 WPS 图片已解析。是否在 WPS 导出中生成类似占位 Drawing,应通过最小原型在目标 WPS 版本中验证后决定;第一版不盲目复制样本中的冗余占位对象。 + +### 5.6 上传大小与资源保护 + +样本超过当前 10 MiB 上限。按最终业务要求将报备 XLSX 单文件上限调整为 100 MiB,同时增加解包级限制,而不是只提高 Multer 上限: + +- ZIP 压缩文件最大 100 MiB; +- 解压后总大小最大 500 MiB; +- ZIP 项目数最大 1,000; +- 单张图片最大 10 MiB; +- 单工作簿图片最大 500 张; +- 工作表最大 20 张、单表最大 20,000 行和 200 列; +- XML 单部件最大 10 MiB; +- 图片必须校验文件签名和实际 MIME,不能只信扩展名或 relationship; +- 分析和提交阶段均执行同一检查,不能只在预览阶段检查; +- 超限返回明确错误码和当前限制,不发生部分 MinIO 上传或部分数据库落库。 + +100 MiB 文件在 ExcelJS、JSZip 和图片 Buffer 并存时会产生数倍内存峰值。实施时需要记录 1、5、20、50、100 MiB 样本的解析耗时和 Node RSS,必要时限制并发分析数;不能仅根据压缩文件大小估算内存。 + +### 5.7 WPS 导出生成器 + +保留 ExcelJS 作为工作簿表格、样式、列宽、行高和标准格式的唯一生成入口,再增加 `WpsCellImageWorkbookTransformer` 对生成结果做受控 OOXML 后处理: + +1. 先按现有代码生成标准 XLSX; +2. 根据标准 Drawing 锚点确定每张报备图片的目标单元格; +3. 为每张图片生成唯一 `ID_` 加 32 位大写十六进制标识; +4. 创建 `xl/cellimages.xml`; +5. 创建 `xl/_rels/cellimages.xml.rels`; +6. 在 workbook relationships 增加 WPS cellImage 关系; +7. 在 `[Content_Types].xml` 增加 WPS cellImage 类型; +8. 将目标单元格改写为严格的 `_xlfn.DISPIMG("ID",1)` 公式及缓存显示值; +9. 移除已经转换的 Drawing 图片锚点和失去引用的媒体关系; +10. 保留表头、文本、列宽、行高、冻结窗格、批次快照内容和未转换对象; +11. 重新打包并再次执行包结构、关系完整性和公式安全自检。 + +这样可以复用现有成熟导出逻辑,避免为 WPS 另写一套字段取值、转换、图片下载和样式代码。 + +### 5.8 当前批次文件的处理 + +格式选择发生在下载时,不修改历史批次和 MinIO 原文件: + +- Excel 通用格式:直接返回现有 MinIO XLSX,字节和哈希保持不变; +- WPS 单元格图片格式:读取该批次现有 XLSX,在内存或受控临时目录中转换后返回; +- 批次 ZIP:逐通道转换 XLSX,再和原 TXT 简报一起打包;任一文件转换失败则整个 ZIP 明确失败,不输出不完整包; +- 第一版不缓存 WPS 派生文件,不新增数据库记录,也不覆盖原 `ReportExportFile`;如真实性能证明需要缓存,再单独设计派生文件生命周期。 + +单条导出则先按现有逻辑生成标准工作簿,再根据选择决定是否转换为 WPS 格式。 + +## 6. 前端修改范围 + +预计涉及: + +- `src/apps/admin/AdminReportTasksPage.tsx`:单条导出格式弹窗; +- `src/apps/admin/AdminChannelReportPage.tsx`:单条导出格式弹窗; +- `src/apps/admin/AdminReportBatchesPage.tsx`:现有导出弹窗增加格式选择; +- `src/api/admin/channels-reports.api.ts`:传递 `outputFormat`; +- 报备导入分析弹窗:显示自动识别的图片格式和数量,展示不支持/冲突原因; +- 共用一个格式选择组件和类型,不在三个页面复制状态及文案。 + +桌面端和 390px 窄屏均需验证弹窗选项、说明、生成中、失败重试和下载行为。格式选择不写入 localStorage,重新打开默认回到 Excel 通用格式。 + +## 7. 后端修改范围 + +预计涉及: + +- `api/src/report-materials/report-materials.controller.ts`:上传上限和导出格式参数; +- `api/src/report-materials/report-materials.contracts.ts`:格式枚举及导入诊断类型; +- `api/src/report-materials/report-materials.helpers.ts`:保留通用 helper,公式检查改为接收精确白名单; +- `api/src/report-materials/import-parser.service.ts`、`import-review.service.ts`:分析及提交阶段统一解析; +- `api/src/report-materials/channel-export.service.ts`:单条导出选择; +- `api/src/report-materials/batch-download.service.ts`:批次单文件和 ZIP 选择; +- 新增独立 OOXML 包检查、WPS 图片读取和 WPS 输出转换服务; +- 继续复用 `FilesService`、MinIO、操作日志和现有字段映射。 + +不要把 ZIP/XML 细节继续堆入已经较大的 report-materials service。解析器和转换器应是无数据库副作用的纯服务,方便使用合成工作簿做完整安全测试。 + +## 8. 数据库、MinIO 和兼容性判断 + +### 8.1 数据库 + +第一版不需要 Prisma migration: + +- 导入格式、图片数量和诊断可写入现有导入批次 `preview` JSON; +- 导入后的图片仍是现有 `FileObject`; +- 导出格式是一次下载请求参数,不改变材料版本和批次记录。 + +若以后要求“记住每个通道默认格式”或缓存 WPS 派生文件,才需要单独设计配置或文件记录,不在本次顺带增加。 + +### 8.2 MinIO + +- 导入后继续逐张保存真实图片对象; +- 原始导入 XLSX 继续按现有流程保存; +- WPS 导出第一版不永久保存,不覆盖现有批次文件; +- 任何解析失败都不得留下无法关联的部分文件。若提交阶段已上传部分行图片后某行失败,应沿用现有逐行结果并增加可追踪清理策略测试。 + +### 8.3 格式兼容 + +- `excel_drawing` 是跨 Excel/WPS 的默认格式; +- `wps_cell_image` 明确标注为 WPS 优先格式; +- 两者扩展名均为 `.xlsx`,但内部结构不同; +- 不承诺旧版 Microsoft Excel 原生显示 WPS `DISPIMG`; +- WPS 目标版本、Windows Excel 365 和 LibreOffice 至少各做一次打开结果记录,不能只用 ExcelJS 回读判定可交付。 + +## 9. 错误处理 + +建议增加稳定错误码: + +| 错误码 | 含义 | +| --- | --- | +| `WORKBOOK_TOO_LARGE` | 压缩文件超过上限 | +| `WORKBOOK_EXPANDED_TOO_LARGE` | 解包总量超过上限 | +| `WORKBOOK_UNSAFE_PART` | 宏、外链、OLE 或危险部件 | +| `WORKBOOK_FORMULA_NOT_ALLOWED` | 存在非白名单公式 | +| `WPS_CELL_IMAGE_RELATION_INVALID` | ID、关系或媒体缺失/重复 | +| `WPS_CELL_IMAGE_CONFLICT` | 同一单元格存在两种图片 | +| `WORKBOOK_IMAGE_TYPE_UNSUPPORTED` | 图片真实类型不支持 | +| `WORKBOOK_IMAGE_LIMIT_EXCEEDED` | 图片数量或单图大小超限 | +| `WPS_EXPORT_CONVERSION_FAILED` | 标准文件转 WPS 失败 | + +错误信息要指出工作表、单元格或部件,但不得回显原始图片内容、服务器路径或敏感文件对象信息。 + +## 10. 测试方案 + +### 10.1 解析器单元测试 + +使用代码生成的小型 OOXML fixture,不把包含真实企业和个人信息的样本提交到仓库: + +1. 2 张标准 Drawing 图片; +2. 2 张有效 WPS 单元格图片; +3. 标准/WPS 混合但不冲突; +4. 同单元格冲突; +5. 公式 ID 不存在; +6. cellImage ID 重复; +7. relationship 缺失、外链、路径穿越; +8. 图片扩展名与文件签名不一致; +9. 普通公式、共享公式、数组公式和伪造 `DISPIMG`; +10. 合并单元格下不重复计数; +11. ZIP 项目数、单图、总解包大小超限; +12. PNG、JPEG、GIF 的支持结果。 + +### 10.2 当前样本回归 + +只在本地受控测试中使用当前样本,验收: + +- 自动识别 `wps_cell_image`; +- 识别 1 个工作表、16 条数据、43 张业务图片; +- 43 个公式 ID、cellImage 和媒体关系全部匹配; +- 不把 A1 的 3 个 1×1 占位 Drawing 计入业务字段; +- 图片列、行号和预览映射正确; +- 提交前后图片哈希一致; +- 非图片字段和手机号等文本/数字读取不被 WPS 解析器改变。 + +### 10.3 真实后端集成测试 + +在明确授权的测试环境使用真实 API、PostgreSQL 和 MinIO: + +1. 分析样本但不审核应用,确认原业务签名不变化; +2. 使用专用测试企业/应用提交少量合成行,确认 FileObject、导入批次和待审核项一致; +3. 审核通过后确认图片字段落入真实材料、材料版本只按既有规则变化一次; +4. 失败行不清空已有补资料字段; +5. 删除测试数据前单独确认清理边界,不触碰真实客户资料。 + +### 10.4 双格式导出测试 + +同一批次快照分别导出两种格式: + +- 文本、字段顺序、默认值、转换、行数和图片内容哈希一致; +- Excel 格式仍使用标准 Drawing,既有文件字节不被修改; +- WPS 格式中每个图片单元格的公式、ID、关系和媒体一一对应; +- WPS 中图片按单元格显示,排序、筛选、调整行高后行为符合目标版本; +- Excel 365 打开两种格式并记录 WPS 格式的实际兼容结果; +- 单通道下载和全部 ZIP 下载均验证文件名、数量、内容和失败原子性; +- 没有图片的工作簿两种格式内容一致,WPS 格式不生成空的 cellImage 部件。 + +### 10.5 性能和资源 + +记录 1、5、20、50、100 MiB 文件的: + +- 上传和分析总耗时; +- JSZip 检查耗时; +- ExcelJS 加载耗时; +- 峰值 RSS; +- 43、100、500 张图片时的处理时间; +- 批次 ZIP 多通道转换的总耗时和临时空间。 + +达到上限时应快速、明确失败,不使 API 进程因并发大文件出现长时间无响应。 + +### 10.6 前端和门禁 + +- 导入自动识别提示、映射预览、空数据、失败、权限和超限状态; +- 三个导出入口的弹窗、默认选项、取消、生成中、失败重试和成功下载; +- 1600px 桌面和 390px 窄屏; +- 浏览器 Network 参数、响应文件名和 Console; +- API 定向与全量测试、前后端 TypeScript、Vite 构建、Prisma validate、依赖安全、部署契约和 `git diff --check`; +- 同步 `docs/system-functional-test-cases.md` 和 `docs/testing-progress.md`。 + +构建、ExcelJS 回读或合成 fixture 通过都不能代替真实 WPS 打开和真实 API/PostgreSQL/MinIO 验收。 + +## 11. 实施顺序 + +1. 先做无业务依赖的最小 WPS OOXML 读写原型,使用 2 行、PNG/JPEG/GIF 小图在目标 WPS 和 Excel 365 实际打开。 +2. 固化包安全限制和 `DISPIMG` 精确白名单。 +3. 实现 `WorkbookPackageInspector` 与 `WpsCellImageReader`,接入分析和提交两阶段。 +4. 调整上传上限并加入解包、图片和内存保护。 +5. 实现标准 XLSX 到 WPS 单元格图片的纯转换器。 +6. 接入单条导出、批次单文件和批次 ZIP 三个后端入口。 +7. 实现共用格式选择 UI 和导入识别提示。 +8. 完成合成 fixture、当前样本、真实 MinIO、真实数据库、浏览器及 WPS/Excel 客户端验收。 +9. 更新测试用例和测试进度;是否提交、推送、部署分别等待明确授权。 + +## 12. 影响与风险结论 + +- 前端:中等,涉及三个导出入口和导入提示,但不改变报备业务表格主流程。 +- 后端:较高,涉及不受 ExcelJS 支持的 WPS 私有 OOXML 扩展、ZIP 安全、内存峰值和批量转换。 +- 数据库:预计无 migration。 +- MinIO:复用现有对象;WPS 派生文件第一版不持久化。 +- 短信链路:无影响,不应触发发送、补发、重投或重新入队。 +- 最大风险不是 UI,而是错误放宽公式安全检查、ZIP 资源消耗、WPS 与 Excel 客户端兼容差异,以及把占位 Drawing 误认成业务图片。 + +最小充分范围是“自动读取 WPS 单元格图片 + 保留现有 Excel 导出 + 按需生成 WPS 变体”。不在本次加入图片压缩、通道默认格式、派生文件缓存、历史数据回写或更多办公格式。 diff --git a/src/api/admin/channels-reports.api.ts b/src/api/admin/channels-reports.api.ts index e5113dd..b7b4ce0 100644 --- a/src/api/admin/channels-reports.api.ts +++ b/src/api/admin/channels-reports.api.ts @@ -162,7 +162,7 @@ export const adminChannelsReportsApi = { file: File, body: { tenantId: string; - applicationId?: string; + applicationId: string; reportType: 'signature' | 'drainage'; sheetName?: string; headerRowCount?: number; @@ -170,7 +170,7 @@ export const adminChannelsReportsApi = { profileId?: string; }, ) => { - assertUploadFileSize(file); + assertUploadFileSize(file, { bytes: 100 * 1024 * 1024, message: '报备资料文件大小不能超过 100MB' }); const form = new FormData(); form.set('file', file); Object.entries(body).forEach(([key, value]) => { @@ -223,9 +223,13 @@ export const adminChannelsReportsApi = { query: { keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}, ) => request>(withQuery('/admin/report-materials/batches', query)), getReportMaterialBatch: (id: string) => request(`/admin/report-materials/batches/${id}`), - downloadReportMaterialBatch: (id: string) => requestBlob(`/admin/report-materials/batches/${id}/download`), - downloadReportMaterialBatchFile: (id: string, fileId: string) => - requestBlob(`/admin/report-materials/batches/${id}/files/${fileId}/download`), + downloadReportMaterialBatch: (id: string, outputFormat: 'excel_drawing' | 'wps_cell_image' = 'excel_drawing') => + requestBlob(withQuery(`/admin/report-materials/batches/${id}/download`, { outputFormat })), + downloadReportMaterialBatchFile: ( + id: string, + fileId: string, + outputFormat: 'excel_drawing' | 'wps_cell_image' = 'excel_drawing', + ) => requestBlob(withQuery(`/admin/report-materials/batches/${id}/files/${fileId}/download`, { outputFormat })), listReportMaterialBatchTasks: ( id: string, query: { @@ -314,6 +318,7 @@ export const adminChannelsReportsApi = { carrier?: 'mobile' | 'unicom' | 'telecom'; drainageItemId?: string; batchItemId?: string; + outputFormat?: 'excel_drawing' | 'wps_cell_image'; }) => requestBlob('/admin/report-materials/single-export', { method: 'POST', body: JSON.stringify(body) }), createReportTask: (body: { tenantId: string; diff --git a/src/apps/admin/AdminChannelReportPage.tsx b/src/apps/admin/AdminChannelReportPage.tsx index 355df21..2919e58 100644 --- a/src/apps/admin/AdminChannelReportPage.tsx +++ b/src/apps/admin/AdminChannelReportPage.tsx @@ -3,9 +3,11 @@ import { ArrowLeft, Download, Eye, FileSliders, Search } from 'lucide-react'; import { useNavigate, useParams } from 'react-router-dom'; import { adminApi, + fileDownloadUrl, type AdminChannel, type ChannelReportField, type ClientSmsSignature, + type CommonReportField, type DictionaryItem, type ReportTask, type SingleReportMaterialDetail, @@ -14,6 +16,7 @@ import { Breadcrumb, Button, CarrierTag, Input, Modal, Pagination, Select, Tag, import { formatDateTime } from '@/utils/dateTime'; import { successRateClassName } from '@/utils/successRate'; import { ReportFieldMappingModal } from './ReportFieldMappingModal'; +import { ReportExportFormatModal, type ReportWorkbookFormat } from './ReportExportFormatModal'; type ReportType = 'signature' | 'drainage'; type DrainageItem = Record & { @@ -62,6 +65,24 @@ function ReportStatus({ value }: { value?: string }) { return {meta.label}; } +function MaterialFieldValue({ value }: { value: unknown }) { + const file = asRecord(value); + const fileObjectId = String(file.fileObjectId ?? ''); + const fileName = String(file.fileName ?? fileObjectId ?? '-'); + const isImage = + String(file.contentType ?? '').startsWith('image/') || /\.(?:png|jpe?g|gif|webp|bmp)$/i.test(fileName); + if (fileObjectId && isImage) { + return ( +
+ {fileName} + {fileName} +
+ ); + } + if (fileObjectId) return {fileName}; + return <>{String(value ?? '-')}; +} + function DeliveryStats({ task }: { task: ReportTask }) { const stats = task.deliveryStats ?? { submitFailureCount: 0, @@ -197,6 +218,7 @@ export function AdminChannelReportPage() { const [tasks, setTasks] = useState([]); const [fields, setFields] = useState([]); const [libraryFields, setLibraryFields] = useState([]); + const [commonFields, setCommonFields] = useState([]); const [keyword, setKeyword] = useState(''); const [status, setStatus] = useState('all'); const [carrier, setCarrier] = useState('all'); @@ -204,7 +226,13 @@ export function AdminChannelReportPage() { const [todaySendMax, setTodaySendMax] = useState(''); const [page, setPage] = useState(1); const [total, setTotal] = useState(0); - const [appliedFilters, setAppliedFilters] = useState({ keyword: '', status: 'all', carrier: 'all', todaySendMin: '', todaySendMax: '' }); + const [appliedFilters, setAppliedFilters] = useState({ + keyword: '', + status: 'all', + carrier: 'all', + todaySendMin: '', + todaySendMax: '', + }); const pageSize = 10; const [material, setMaterial] = useState(); const [detail, setDetail] = useState<{ @@ -218,9 +246,12 @@ export function AdminChannelReportPage() { const [statusReason, setStatusReason] = useState(''); const [configType, setConfigType] = useState(); const [error, setError] = useState(''); + const [exportTask, setExportTask] = useState(); + const [exportBusy, setExportBusy] = useState(false); function loadData(targetPage = page, filters = appliedFilters) { - adminApi.listReportTasksPage({ + adminApi + .listReportTasksPage({ channelId, keyword: filters.keyword || undefined, status: filters.status === 'all' ? undefined : filters.status, @@ -240,16 +271,24 @@ export function AdminChannelReportPage() { } useEffect(() => { - void Promise.all([adminApi.listChannels(), adminApi.listChannelReportFields(channelId), adminApi.listDrainageFields()]) - .then(([channelItems, fieldItems, libraryItems]) => { + void Promise.all([ + adminApi.listChannels(), + adminApi.listChannelReportFields(channelId), + adminApi.listDrainageFields(), + adminApi.listCommonReportFields(), + ]) + .then(([channelItems, fieldItems, libraryItems, commonItems]) => { setChannel(channelItems.find((item) => item.id === channelId)); setFields(fieldItems); setLibraryFields(libraryItems.filter((item) => item.status === 'active')); + setCommonFields(commonItems); }) .catch((failure: Error) => setError(failure.message || '通道报备配置加载失败')); }, [channelId]); - useEffect(() => { loadData(page); }, [channelId, page]); + useEffect(() => { + loadData(page); + }, [channelId, page]); const visibleTasks = tasks; async function openMaterial(task: ReportTask) { @@ -269,8 +308,9 @@ export function AdminChannelReportPage() { } } - async function exportMaterial(task: ReportTask) { + async function exportMaterial(task: ReportTask, outputFormat: ReportWorkbookFormat) { try { + setExportBusy(true); const blob = await adminApi.exportSingleReportMaterial({ reportType: task.reportType, signatureId: task.signatureId, @@ -278,6 +318,7 @@ export function AdminChannelReportPage() { carrier: task.carrier ?? undefined, drainageItemId: task.drainageItemId ?? undefined, batchItemId: task.exportItems?.[0]?.batchItem.id, + outputFormat, }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); @@ -285,8 +326,11 @@ export function AdminChannelReportPage() { anchor.download = `${task.signature?.name ?? '签名'}-${task.channel?.name ?? '通道'}.xlsx`; anchor.click(); URL.revokeObjectURL(url); + setExportTask(undefined); } catch (failure) { setError(failure instanceof Error ? failure.message : '单条资料导出失败'); + } finally { + setExportBusy(false); } } @@ -330,7 +374,7 @@ export function AdminChannelReportPage() { -

{channel?.name ?? '通道报备详情'}

+

通道报备详情

- 通道编号:{channel?.code ?? channelId} · 已配置字段 {fields.length} 个 + 通道名称:{channel?.name ?? '-'} · 通道编号:{channel?.code ?? channelId} · 已配置字段 {fields.length} 个
{error ?

{error}

: null} @@ -445,9 +489,7 @@ export function AdminChannelReportPage() { visibleTasks.map((task) => { const signature = task.signature as ClientSmsSignature | undefined; const drainage = - task.reportType === 'drainage' - ? task.drainageInfo as DrainageItem | undefined - : undefined; + task.reportType === 'drainage' ? (task.drainageInfo as DrainageItem | undefined) : undefined; const reportedAt = task.approvedAt; return (
{task.reportType !== 'drainage' ? ( - @@ -540,9 +582,9 @@ export function AdminChannelReportPage() {
- 通道/版本 + 通道名称 / 编号 / 版本 - {material.channel.name} · V{material.materialVersion} + {material.channel.name} · {material.channel.code} · V{material.materialVersion}
@@ -550,20 +592,23 @@ export function AdminChannelReportPage() { {material.fields.map((field) => (
- {field.exportName || field.name} + {field.name}({field.code}) + {field.exportName && field.exportName !== field.name ? ` · 导出为“${field.exportName}”` : ''} {field.required ? ' *' : ''} - {typeof field.value === 'object' - ? String((field.value as Record)?.fileName ?? '-') - : String(field.value ?? '-')} +
))} {material.historicalFields.map((field) => (
- {field.name}(历史字段) - {String(field.value ?? '-')} + + {field.name}({field.code},历史字段) + + + +
))} @@ -608,12 +653,20 @@ export function AdminChannelReportPage() { {configType ? ( setConfigType(undefined)} onSave={saveFieldMapping} reportType={configType} /> ) : null} + {exportTask ? ( + setExportTask(undefined)} + onConfirm={(format) => void exportMaterial(exportTask, format)} + /> + ) : null} ); } diff --git a/src/apps/admin/AdminDrainageFieldsPage.test.tsx b/src/apps/admin/AdminDrainageFieldsPage.test.tsx index 9d535ce..d19f50c 100644 --- a/src/apps/admin/AdminDrainageFieldsPage.test.tsx +++ b/src/apps/admin/AdminDrainageFieldsPage.test.tsx @@ -2,7 +2,17 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AdminDrainageFieldsPage } from './AdminDrainageFieldsPage'; -const { adminApi } = vi.hoisted(() => ({ adminApi: { listDrainageFields: vi.fn(), listCommonReportFields: vi.fn(), reorderCommonReportFields: vi.fn(), updateCommonReportField: vi.fn(), updateDrainageField: vi.fn(), createDrainageField: vi.fn(), deleteDrainageField: vi.fn() } })); +const { adminApi } = vi.hoisted(() => ({ + adminApi: { + listDrainageFields: vi.fn(), + listCommonReportFields: vi.fn(), + reorderCommonReportFields: vi.fn(), + updateCommonReportField: vi.fn(), + updateDrainageField: vi.fn(), + createDrainageField: vi.fn(), + deleteDrainageField: vi.fn(), + }, +})); vi.mock('@/api/adminApi', () => ({ adminApi })); describe('common reporting configuration', () => { @@ -12,8 +22,22 @@ describe('common reporting configuration', () => { const secondField = { id: 'field-2', code: 'smsContent', name: '短信内容', fieldType: 'string', status: 'active' }; adminApi.listDrainageFields.mockResolvedValue([field, secondField]); adminApi.listCommonReportFields.mockResolvedValue([ - { id: 'common-1', drainageFieldId: 'field-1', reportType: 'signature', required: false, sortOrder: 10, drainageField: field }, - { id: 'common-2', drainageFieldId: 'field-2', reportType: 'signature', required: false, sortOrder: 20, drainageField: secondField }, + { + id: 'common-1', + drainageFieldId: 'field-1', + reportType: 'signature', + required: false, + sortOrder: 10, + drainageField: field, + }, + { + id: 'common-2', + drainageFieldId: 'field-2', + reportType: 'signature', + required: false, + sortOrder: 20, + drainageField: secondField, + }, ]); adminApi.reorderCommonReportFields.mockResolvedValue([]); adminApi.updateCommonReportField.mockResolvedValue({}); @@ -26,7 +50,13 @@ describe('common reporting configuration', () => { fireEvent.click(screen.getByRole('button', { name: '是否必填' })); fireEvent.click(screen.getByRole('option', { name: '必填' })); fireEvent.click(screen.getByRole('button', { name: '保存' })); - await waitFor(() => expect(adminApi.updateCommonReportField).toHaveBeenCalledWith('common-1', { drainageFieldId: 'field-1', reportType: 'signature', required: true })); + await waitFor(() => + expect(adminApi.updateCommonReportField).toHaveBeenCalledWith('common-1', { + drainageFieldId: 'field-1', + reportType: 'signature', + required: true, + }), + ); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(adminApi.listCommonReportFields).toHaveBeenCalledTimes(2); }); @@ -34,16 +64,26 @@ describe('common reporting configuration', () => { it('moves a common field within its own material type through the reorder API', async () => { render(); fireEvent.click(await screen.findByRole('button', { name: '下移通用字段主体证明' })); - await waitFor(() => expect(adminApi.reorderCommonReportFields).toHaveBeenCalledWith({ - reportType: 'signature', - ids: ['common-2', 'common-1'], - })); + await waitFor(() => + expect(adminApi.reorderCommonReportFields).toHaveBeenCalledWith({ + reportType: 'signature', + ids: ['common-2', 'common-1'], + }), + ); await waitFor(() => expect(adminApi.listCommonReportFields).toHaveBeenCalledTimes(2)); }); it('edits a field definition and locks mapping keys for a referenced field', async () => { adminApi.listDrainageFields.mockResolvedValueOnce([ - { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', status: 'active', usageCount: 1, description: '旧说明' }, + { + id: 'field-1', + code: 'license', + name: '主体证明', + fieldType: 'file', + status: 'active', + usageCount: 1, + description: '旧说明', + }, ]); render(); fireEvent.click(await screen.findByRole('button', { name: '编辑主体证明' })); @@ -53,8 +93,24 @@ describe('common reporting configuration', () => { fireEvent.change(screen.getByLabelText('字段名称'), { target: { value: '企业主体证明' } }); fireEvent.change(screen.getByLabelText('描述'), { target: { value: '新说明' } }); fireEvent.click(screen.getByRole('button', { name: '保存' })); - await waitFor(() => expect(adminApi.updateDrainageField).toHaveBeenCalledWith('field-1', { - code: 'license', name: '企业主体证明', fieldType: 'file', description: '新说明', - })); + await waitFor(() => + expect(adminApi.updateDrainageField).toHaveBeenCalledWith('field-1', { + code: 'license', + name: '企业主体证明', + fieldType: 'file', + description: '新说明', + }), + ); + }); + + it('places the add-field action inside the field definition section', async () => { + render(); + const heading = await screen.findByRole('heading', { name: '字段定义' }); + expect(heading.closest('.admin-drainage-section__heading')).toContainElement( + screen.getByRole('button', { name: '添加字段' }), + ); + expect(document.querySelector('.page-heading')).not.toContainElement( + screen.getByRole('button', { name: '添加字段' }), + ); }); }); diff --git a/src/apps/admin/AdminDrainageFieldsPage.tsx b/src/apps/admin/AdminDrainageFieldsPage.tsx index 5373a24..da3a727 100644 --- a/src/apps/admin/AdminDrainageFieldsPage.tsx +++ b/src/apps/admin/AdminDrainageFieldsPage.tsx @@ -65,11 +65,16 @@ export function AdminDrainageFieldsPage() { }, []); const filteredFields = useMemo( - () => fields.filter((field) => { - const matchesKeyword = !appliedKeyword || [field.code, field.name, field.fieldType, field.description].some((value) => String(value ?? '').includes(appliedKeyword)); - const matchesType = appliedType === 'all' || field.fieldType === appliedType; - return matchesKeyword && matchesType; - }), + () => + fields.filter((field) => { + const matchesKeyword = + !appliedKeyword || + [field.code, field.name, field.fieldType, field.description].some((value) => + String(value ?? '').includes(appliedKeyword), + ); + const matchesType = appliedType === 'all' || field.fieldType === appliedType; + return matchesKeyword && matchesType; + }), [appliedKeyword, appliedType, fields], ); @@ -109,7 +114,8 @@ export function AdminDrainageFieldsPage() { function deleteField() { if (!deleteTarget || (deleteTarget.usageCount ?? 0) > 0 || (deleteTarget.commonUsageCount ?? 0) > 0) return; - adminApi.deleteDrainageField(deleteTarget.id) + adminApi + .deleteDrainageField(deleteTarget.id) .then(() => { setDeleteTarget(null); loadData(); @@ -122,7 +128,9 @@ export function AdminDrainageFieldsPage() { setCommonSaving(true); setError(''); const body = { drainageFieldId: commonFieldId, reportType: commonReportType, required: commonRequired }; - const request = editingCommonId ? adminApi.updateCommonReportField(editingCommonId, body) : adminApi.createCommonReportField(body); + const request = editingCommonId + ? adminApi.updateCommonReportField(editingCommonId, body) + : adminApi.createCommonReportField(body); request .then(() => { setCommonFieldId(''); @@ -146,7 +154,8 @@ export function AdminDrainageFieldsPage() { function deleteCommonField() { if (!commonDeleteTarget) return; - adminApi.deleteCommonReportField(commonDeleteTarget.id) + adminApi + .deleteCommonReportField(commonDeleteTarget.id) .then(() => { setCommonDeleteTarget(null); loadData(); @@ -176,7 +185,9 @@ export function AdminDrainageFieldsPage() { const signatureCommon = commonFields.filter((field) => field.reportType === 'signature'); const drainageCommon = commonFields.filter((field) => field.reportType === 'drainage'); - const referencedCount = fields.filter((field) => (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0).length; + const referencedCount = fields.filter( + (field) => (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0, + ).length; return (
@@ -186,22 +197,68 @@ export function AdminDrainageFieldsPage() {

报备字段库

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

- {error ?

{error}

: null}
-
{fields.length}

字段总数

-
{commonFields.length}

通用字段配置

-
{referencedCount}

已被引用字段

+
+ + + +
+ {fields.length} +

字段总数

+
+
+
+ + + +
+ {commonFields.length} +

通用字段配置

+
+
+
+ + + +
+ {referencedCount} +

已被引用字段

+
+
- setKeyword(event.target.value)} placeholder="搜索字段名称、代码或描述..." prefix={} value={keyword} /> + setKeyword(event.target.value)} + placeholder="搜索字段名称、代码或描述..." + prefix={} + value={keyword} + /> setCommonFieldId(event.target.value)} options={[{ label: '请选择字段', value: '' }, ...fields.filter((field) => field.status !== 'deleted').map((field) => ({ label: `${field.name}(${field.code})`, value: field.id }))]} value={commonFieldId} /> - setCommonRequired(event.target.value === 'true')} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(commonRequired)} /> + setCommonReportType(event.target.value as 'signature' | 'drainage')} + options={[ + { label: '签名报备资料', value: 'signature' }, + { label: '引流信息报备资料', value: 'drainage' }, + ]} + value={commonReportType} + /> + 0 || (editingField.commonUsageCount ?? 0) > 0))} error={codeError} label="字段代码" onChange={(event) => setCode(event.target.value)} placeholder="仅允许数字和英文字母" value={code} /> + 0 || (editingField.commonUsageCount ?? 0) > 0), + )} + error={codeError} + label="字段代码" + onChange={(event) => setCode(event.target.value)} + placeholder="仅允许数字和英文字母" + value={code} + /> setName(event.target.value)} value={name} />