feat: add report material workflows and gateway safeguards

This commit is contained in:
hectorzhao
2026-07-15 18:23:48 +08:00
parent cf9f4ce4cd
commit 7091a8bed4
41 changed files with 3606 additions and 71 deletions
+2
View File
@@ -14,6 +14,7 @@ import { OperationsModule } from './operations/operations.module';
import { PrismaModule } from './prisma/prisma.module';
import { RiskReviewModule } from './risk-review/risk-review.module';
import { ReportsModule } from './reports/reports.module';
import { ReportMaterialsModule } from './report-materials/report-materials.module';
import { SendChainModule } from './send-chain/send-chain.module';
import { SmsConfigModule } from './sms-config/sms-config.module';
import { TenantsModule } from './tenants/tenants.module';
@@ -38,6 +39,7 @@ import { UsersModule } from './users/users.module';
ChannelsModule,
RiskReviewModule,
ReportsModule,
ReportMaterialsModule,
SendChainModule,
OperationsModule,
],
+7
View File
@@ -13,6 +13,7 @@ import {
CreateReportFieldDto,
CreateReportMaterialDto,
CreateReportTaskDto,
ReplaceReportFieldsDto,
ChangeReportTaskStatusesDto,
CreateRouteRuleDto,
TestChannelDto,
@@ -147,6 +148,12 @@ export class ChannelsController {
return this.channels.createReportField(body);
}
@Put('channels/:channelId/report-fields/:reportType')
@RequireRecentAuthentication()
replaceReportFields(@Param('channelId') channelId: string, @Param('reportType') reportType: 'signature' | 'drainage', @Body() body: ReplaceReportFieldsDto) {
return this.channels.replaceReportFields(channelId, reportType, body);
}
@Get('signature-report-materials')
listReportMaterials(@Query('signatureId') signatureId?: string, @Query('channelId') channelId?: string) {
return this.channels.listReportMaterials(signatureId, channelId);
+25
View File
@@ -96,6 +96,7 @@ function createPrismaMock() {
},
drainageField: {
findUnique: jest.fn().mockResolvedValue({ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件' }),
findMany: jest.fn().mockResolvedValue([{ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件' }]),
},
signatureReportMaterial: {
findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]),
@@ -178,6 +179,26 @@ describe('ChannelsService', () => {
});
});
it('replaces one report type while preserving legacy both fields for the opposite type', async () => {
const prisma = createPrismaMock();
const legacyBoth = { id: 'legacy-1', channelId: 'channel-1', drainageFieldId: 'library-1', reportType: 'both', code: 'license', name: '营业执照', exportName: '旧表头', fieldType: 'file', required: true, description: null, sortOrder: 10, columnWidth: 18, imageWidth: 120, imageHeight: 80, defaultValue: null, transform: null, status: 'active', createdAt: new Date(), updatedAt: new Date() };
const tx = {
channelReportField: {
findMany: jest.fn().mockResolvedValueOnce([legacyBoth]).mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 'signature-field' }]),
deleteMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `created-${data.reportType}`, ...data })),
},
};
prisma.$transaction.mockImplementation((callback) => callback(tx));
const service = new ChannelsService(prisma as never);
await service.replaceReportFields('channel-1', 'signature', { fields: [{ drainageFieldId: 'library-1', exportName: '新签名表头', required: true }] });
expect(tx.channelReportField.deleteMany).toHaveBeenCalledWith({ where: { channelId: 'channel-1', reportType: { in: ['signature', 'both'] } } });
expect(tx.channelReportField.create).toHaveBeenCalledWith({ data: expect.objectContaining({ channelId: 'channel-1', code: 'license', reportType: 'drainage', exportName: '旧表头' }) });
expect(tx.channelReportField.create).toHaveBeenCalledWith({ data: expect.objectContaining({ channelId: 'channel-1', code: 'license', reportType: 'signature', exportName: '新签名表头' }) });
});
it('lists report tasks for one real channel', async () => {
const prisma = createPrismaMock();
prisma.channelSignatureReportTask.findMany.mockResolvedValue([]);
@@ -460,6 +481,7 @@ describe('ChannelsService', () => {
expect(prisma.smsChannelGroupItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({ groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', province: '山东' }),
});
expect(prisma.smsChannelGroupItem.create.mock.calls[0][0].data).not.toHaveProperty('rateLimitPerSecond');
await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-1', carrier: 'unicom' }))
.rejects.toThrow('Channel group items must use the same carrier');
@@ -545,6 +567,9 @@ describe('ChannelsService', () => {
where: { id: 'group-1' },
data: expect.objectContaining({ retryTimeLimitHours: 13, retryTimeLimitMinutes: 750 }),
});
for (const item of tx.smsChannelGroupItem.createMany.mock.calls[0][0].data) {
expect(item).not.toHaveProperty('rateLimitPerSecond');
}
await expect(service.updateGroup('group-1', {
carrier: 'mobile',
+69 -3
View File
@@ -47,7 +47,6 @@ export interface CreateChannelGroupItemDto {
priority?: number;
weight?: number;
isBackup?: boolean;
rateLimitPerSecond?: number;
}
export interface UpdateChannelGroupDto {
@@ -83,9 +82,19 @@ export interface CreateReportFieldDto {
required?: boolean;
description?: string;
sortOrder?: number;
exportName?: string;
columnWidth?: number;
imageWidth?: number;
imageHeight?: number;
defaultValue?: string;
transform?: string;
status?: string;
}
export interface ReplaceReportFieldsDto {
fields: Array<Omit<CreateReportFieldDto, 'channelId' | 'reportType'>>;
}
export interface CreateReportMaterialDto {
signatureId: string;
channelId: string;
@@ -787,7 +796,6 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
priority: data.priority ?? 100,
weight: data.weight ?? 1,
isBackup: data.isBackup ?? false,
rateLimitPerSecond: data.rateLimitPerSecond,
},
});
}
@@ -834,7 +842,6 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
priority: item.priority ?? 100,
weight: item.weight ?? 1,
isBackup: item.isBackup ?? false,
rateLimitPerSecond: item.rateLimitPerSecond,
})),
});
}
@@ -928,15 +935,69 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
reportType,
code: field.code,
name: field.name,
exportName: data.exportName?.trim() || field.name,
fieldType: field.fieldType,
required: data.required ?? field.required,
description: data.description ?? field.description,
sortOrder: data.sortOrder ?? 100,
columnWidth: normalizeSpreadsheetSize(data.columnWidth, 18, 6, 80),
imageWidth: normalizeSpreadsheetSize(data.imageWidth, 120, 24, 600),
imageHeight: normalizeSpreadsheetSize(data.imageHeight, 80, 24, 600),
defaultValue: data.defaultValue,
transform: data.transform,
status: data.status ?? 'active',
},
});
}
async replaceReportFields(channelId: string, reportType: 'signature' | 'drainage', data: ReplaceReportFieldsDto) {
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
if (!channel) throw new NotFoundException('Channel not found');
const ids = data.fields.map((field) => field.drainageFieldId);
if (new Set(ids).size !== ids.length) throw new BadRequestException('同一通道报备类型不能重复配置字段');
const libraryFields = await this.prisma.drainageField.findMany({ where: { id: { in: ids }, status: 'active' } });
if (libraryFields.length !== ids.length) throw new BadRequestException('报备字段库字段不存在或已停用');
const fieldById = new Map(libraryFields.map((field) => [field.id, field]));
return this.prisma.$transaction(async (tx) => {
const oppositeType = reportType === 'signature' ? 'drainage' : 'signature';
const [legacyBoth, oppositeFields] = await Promise.all([
tx.channelReportField.findMany({ where: { channelId, reportType: 'both' } }),
tx.channelReportField.findMany({ where: { channelId, reportType: oppositeType } }),
]);
const oppositeCodes = new Set(oppositeFields.map((field) => field.code));
await tx.channelReportField.deleteMany({ where: { channelId, reportType: { in: [reportType, 'both'] } } });
for (const legacy of legacyBoth) {
if (oppositeCodes.has(legacy.code)) continue;
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy;
await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } });
}
for (const [index, configured] of data.fields.entries()) {
const field = fieldById.get(configured.drainageFieldId)!;
await tx.channelReportField.create({
data: {
channelId,
drainageFieldId: field.id,
reportType,
code: field.code,
name: field.name,
exportName: configured.exportName?.trim() || field.name,
fieldType: field.fieldType,
required: configured.required ?? field.required,
description: configured.description ?? field.description,
sortOrder: configured.sortOrder ?? (index + 1) * 10,
columnWidth: normalizeSpreadsheetSize(configured.columnWidth, 18, 6, 80),
imageWidth: normalizeSpreadsheetSize(configured.imageWidth, 120, 24, 600),
imageHeight: normalizeSpreadsheetSize(configured.imageHeight, 80, 24, 600),
defaultValue: configured.defaultValue,
transform: configured.transform,
status: configured.status ?? 'active',
},
});
}
return tx.channelReportField.findMany({ where: { channelId, reportType }, include: { drainageField: true }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
});
}
listReportMaterials(signatureId?: string, channelId?: string) {
return this.prisma.signatureReportMaterial.findMany({
where: {
@@ -1652,6 +1713,11 @@ function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: numb
return value;
}
function normalizeSpreadsheetSize(value: number | undefined, fallback: number, minimum: number, maximum: number) {
if (value === undefined || !Number.isFinite(value)) return fallback;
return Math.min(maximum, Math.max(minimum, Math.round(value)));
}
function normalizeBusinessCarrier(carrier?: string | null) {
const normalized = normalizeChannelCarrier(carrier);
if (!['mobile', 'unicom', 'telecom'].includes(normalized)) {
@@ -1,5 +1,7 @@
import { Body, Controller, Get, Param, Post, Query, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { SendChainService } from '../send-chain/send-chain.service';
import { OperationsService } from './operations.service';
@@ -145,8 +147,13 @@ export class AdminOperationsController {
}
@Post('gateway-submit-dead-letters/:id/requeue')
requeueGatewaySubmitDeadLetter(@Param('id') id: string) {
return this.sendChain.requeueGatewaySubmitDeadLetter(id);
@RequireRecentAuthentication()
requeueGatewaySubmitDeadLetter(
@Param('id') id: string,
@Body() body: { confirmedNotSubmitted?: boolean; reason?: string },
@CurrentSessionUserId() operatorId?: string,
) {
return this.sendChain.requeueGatewaySubmitDeadLetter(id, { ...body, operatorId });
}
@Get('downstream-deliveries')
+17 -1
View File
@@ -77,11 +77,15 @@ function createPrismaMock() {
status: 'pending',
failureCode: 'SUBMIT_PROCESSING_FAILED',
failureMessage: 'network down',
rawPayload: '{"upstream":{"passwordCipher":"secret"}}',
commandPayload: { upstream: { account: 'sp', passwordCipher: 'secret' } },
tenant: { name: '租户A' },
application: { name: '应用A' },
channel: { code: 'CMPP-A' },
}]),
count: jest.fn().mockResolvedValue(1),
groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]),
findFirst: jest.fn().mockResolvedValue({ createdAt: new Date('2026-07-08T12:00:00.000Z') }),
},
gatewayDownstreamRecoveryStatus: {
findMany: jest.fn().mockResolvedValue([{
@@ -408,10 +412,22 @@ describe('OperationsService', () => {
page: 1,
pageSize: 10,
})).resolves.toEqual({
items: [expect.objectContaining({ id: 'dead-1', status: 'pending' })],
items: [expect.objectContaining({
id: 'dead-1',
status: 'pending',
rawPayloadAvailable: true,
commandPayload: { upstream: { account: 'sp', passwordCipher: '[REDACTED]' } },
})],
total: 1,
page: 1,
pageSize: 10,
summary: {
pending: 1,
requeueing: 0,
requeued: 0,
resolved: 0,
oldestPendingAt: new Date('2026-07-08T12:00:00.000Z'),
},
});
expect(prisma.gatewaySubmitDeadLetter.findMany).toHaveBeenCalledWith({
+82 -4
View File
@@ -372,11 +372,10 @@ export class OperationsService {
async listGatewaySubmitDeadLetters(query: GatewaySubmitDeadLetterQuery) {
const page = Math.max(1, Number(query.page ?? 1));
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
const where: Prisma.GatewaySubmitDeadLetterWhereInput = {
const baseWhere: Prisma.GatewaySubmitDeadLetterWhereInput = {
tenantId: query.tenantId,
applicationId: query.applicationId,
channelId: query.channelId,
status: query.status && query.status !== 'all' ? query.status : undefined,
OR: query.keyword ? [
{ streamMessageId: { contains: query.keyword } },
{ traceId: { contains: query.keyword } },
@@ -386,7 +385,11 @@ export class OperationsService {
{ failureMessage: { contains: query.keyword } },
] : undefined,
};
const [items, total] = await Promise.all([
const where: Prisma.GatewaySubmitDeadLetterWhereInput = {
...baseWhere,
status: query.status && query.status !== 'all' ? query.status : undefined,
};
const [items, total, statusGroups, oldestPending] = await Promise.all([
this.prisma.gatewaySubmitDeadLetter.findMany({
where,
include: { tenant: true, application: true, channel: true },
@@ -395,8 +398,39 @@ export class OperationsService {
take: pageSize,
}),
this.prisma.gatewaySubmitDeadLetter.count({ where }),
this.prisma.gatewaySubmitDeadLetter.groupBy({
by: ['status'],
where: baseWhere,
_count: { _all: true },
}),
this.prisma.gatewaySubmitDeadLetter.findFirst({
where: { ...baseWhere, status: 'pending' },
orderBy: { createdAt: 'asc' },
select: { createdAt: true },
}),
]);
return { items, total, page, pageSize };
const statusCounts = new Map(statusGroups.map((item) => [item.status, item._count._all]));
const messageIds = items.map((item) => item.messageId).filter((value): value is string => Boolean(value));
const messageStates = messageIds.length > 0
? await this.prisma.smsMessageRecord.findMany({
where: { messageId: { in: messageIds } },
select: { messageId: true, status: true, submitStatus: true, receiptStatus: true, phoneNumber: true, content: true },
})
: [];
const messageStateById = new Map(messageStates.map((item) => [item.messageId, item]));
return {
items: items.map((item) => sanitizeGatewaySubmitException(item, item.messageId ? messageStateById.get(item.messageId) : undefined)),
total,
page,
pageSize,
summary: {
pending: statusCounts.get('pending') ?? 0,
requeueing: statusCounts.get('requeueing') ?? 0,
requeued: statusCounts.get('requeued') ?? 0,
resolved: statusCounts.get('resolved') ?? 0,
oldestPendingAt: oldestPending?.createdAt ?? null,
},
};
}
async listDownstreamDeliveries(query: DownstreamDeliveryQuery) {
@@ -1119,3 +1153,47 @@ function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { t
userAgent: log.userAgent ?? '',
};
}
function sanitizeGatewaySubmitException(
item: Prisma.GatewaySubmitDeadLetterGetPayload<{ include: { tenant: true; application: true; channel: true } }>,
messageState?: { status: string; submitStatus: string | null; receiptStatus: string | null; phoneNumber: string; content: string },
) {
const { rawPayload, commandPayload, tenant, application, channel, ...record } = item;
return {
...record,
tenant: tenant ? { id: tenant.id, name: tenant.name, code: tenant.code, status: tenant.status } : null,
application: application ? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status } : null,
channel: channel ? {
id: channel.id,
code: channel.code,
name: channel.name,
status: channel.status,
carrier: channel.carrier,
sendRegion: channel.sendRegion,
rateLimitPerSecond: channel.rateLimitPerSecond,
} : null,
rawPayloadAvailable: Boolean(rawPayload),
commandPayload: redactGatewayCommandValue(commandPayload),
messageState: messageState ?? null,
};
}
function redactGatewayCommandValue(value: Prisma.JsonValue | null): Prisma.JsonValue | null {
if (Array.isArray(value)) {
return value.map((item) => redactGatewayCommandValue(item));
}
if (value && typeof value === 'object') {
const redacted: Record<string, Prisma.JsonValue | null> = {};
for (const [key, child] of Object.entries(value)) {
const normalizedKey = key.toLowerCase();
redacted[key] = [
'password', 'passwordcipher', 'secret', 'secrethash', 'authsource',
'token', 'apikey', 'accesskey', 'secretkey',
].includes(normalizedKey)
? '[REDACTED]'
: redactGatewayCommandValue(child as Prisma.JsonValue);
}
return redacted;
}
return value;
}
@@ -0,0 +1,61 @@
import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags } from '@nestjs/swagger';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CreateImportProfileDto, CreateReportBatchDto, ImportCommitDto, ReportMaterialsService } from './report-materials.service';
type UploadedWorkbook = { originalname: string; mimetype: string; size: number; buffer: Buffer };
@ApiTags('report-materials')
@Controller('admin/report-materials')
export class ReportMaterialsController {
constructor(private readonly service: ReportMaterialsService) {}
@Get('pending')
listPending(@Query('reportType') reportType?: 'signature' | 'drainage', @Query('tenantId') tenantId?: string, @Query('applicationId') applicationId?: string) {
return this.service.listPending({ reportType, tenantId, applicationId });
}
@Get('import-profiles')
listImportProfiles(@Query('reportType') reportType?: 'signature' | 'drainage') {
return this.service.listImportProfiles(reportType);
}
@Post('import-profiles')
@RequireRecentAuthentication()
saveImportProfile(@Body() body: CreateImportProfileDto) {
return this.service.saveImportProfile(body);
}
@Post('imports/analyze')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 100 * 1024 * 1024, files: 1, fields: 12, parts: 13 } }))
analyzeImport(@UploadedFile() file: UploadedWorkbook, @Body() body: Record<string, string>) {
if (!file) throw new BadRequestException('请选择 XLSX 文件');
return this.service.analyzeImport(file, {
tenantId: body.tenantId,
applicationId: body.applicationId || undefined,
reportType: body.reportType as 'signature' | 'drainage',
sheetName: body.sheetName || undefined,
headerRowCount: Number(body.headerRowCount || 1),
dataStartRow: Number(body.dataStartRow || 2),
profileId: body.profileId || undefined,
});
}
@Put('imports/:id/commit')
@RequireRecentAuthentication()
commitImport(@Param('id') id: string, @Body() body: ImportCommitDto) {
return this.service.commitImport(id, body);
}
@Get('batches')
listBatches() {
return this.service.listBatches();
}
@Post('batches')
@RequireRecentAuthentication()
createBatch(@Body() body: CreateReportBatchDto) {
return this.service.createBatch(body);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { FilesModule } from '../files/files.module';
import { SmsConfigModule } from '../sms-config/sms-config.module';
import { ReportMaterialsController } from './report-materials.controller';
import { ReportMaterialsService } from './report-materials.service';
@Module({
imports: [FilesModule, SmsConfigModule],
controllers: [ReportMaterialsController],
providers: [ReportMaterialsService],
exports: [ReportMaterialsService],
})
export class ReportMaterialsModule {}
@@ -0,0 +1,103 @@
import ExcelJS from 'exceljs';
import { ReportMaterialsService } from './report-materials.service';
describe('ReportMaterialsService', () => {
it('detects WPS-compatible embedded images and source columns during XLSX analysis', async () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('签名资料');
sheet.addRow(['短信签名', '营业执照']);
sheet.addRow(['测试签名', '']);
const imageId = workbook.addImage({ base64: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZsmAAAAAASUVORK5CYII=', extension: 'png' });
sheet.addImage(imageId, { tl: { col: 1, row: 1 }, ext: { width: 80, height: 60 } });
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 }] }) },
reportMaterialImportBatch: { create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'import-1', ...data })) },
};
const files = { upload: jest.fn().mockResolvedValue({ id: 'source-1', fileName: '签名资料.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) };
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
const result = await service.analyzeImport({ originalname: '签名资料.xlsx', mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer }, { tenantId: 'tenant-1', applicationId: 'app-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2, profileId: 'profile-1' });
expect(result.imageCount).toBe(1);
expect(result.columns).toEqual(expect.arrayContaining([expect.objectContaining({ sourceHeader: '营业执照', imageCount: 1 })]));
expect(result.rows).toEqual([expect.objectContaining({ rowNumber: 2, imageColumns: [2] })]);
expect(result.suggestedMappings).toEqual([expect.objectContaining({ sourceColumnIndex: 1, targetKind: 'signatureName' })]);
});
it('expands one selected signature to every routed channel and embeds images in each XLSX', async () => {
const uploadedWorkbooks: Buffer[] = [];
let batchItemSequence = 0;
let exportSequence = 0;
const channels = [{ id: 'channel-a', name: '通道A', status: 'active' }, { id: 'channel-b', name: '通道B', status: 'active' }];
const prisma = {
reportMaterialBatch: {
create: jest.fn().mockResolvedValue({ id: 'batch-1', batchNo: 'RB001' }),
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'batch-1', batchNo: 'RB001', ...data })),
},
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'signature-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', purpose: '验证码', auditStatus: 'approved', materialVersion: 3, drainageInfo: { signatureReportValues: { license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' } } }, tenant: { name: '测试企业' }, application: { name: '测试应用' } }),
update: jest.fn().mockResolvedValue({}),
},
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: channels.map((channel, index) => ({ priority: index, channel })) } }]) },
reportMaterialBatchItem: { create: jest.fn().mockImplementation(() => Promise.resolve({ id: `batch-item-${++batchItemSequence}` })) },
smsChannel: { findUnique: jest.fn().mockImplementation(({ where }: { where: { id: string } }) => Promise.resolve(channels.find((channel) => channel.id === where.id))) },
channelReportField: { findMany: jest.fn().mockResolvedValue([
{ 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 },
]) },
channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: `task-${String(data.channelId)}`, ...data })), update: jest.fn() },
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({}) },
reportExportFile: { create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: `export-${++exportSequence}`, ...data })) },
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') }),
upload: jest.fn().mockImplementation((_options: unknown, file: { originalname: string; mimetype: string; buffer: Buffer }) => {
uploadedWorkbooks.push(file.buffer);
return Promise.resolve({ id: `file-${uploadedWorkbooks.length}`, fileName: file.originalname, contentType: file.mimetype });
}),
};
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
const result = await service.createBatch({ items: [{ reportType: 'signature', signatureId: 'signature-1' }] });
expect(result).toMatchObject({ status: 'completed', channelCount: 2, fileCount: 2 });
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledTimes(2);
expect(prisma.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'signature-1' }, data: { pendingReport: false } });
expect(uploadedWorkbooks).toHaveLength(2);
for (const buffer of uploadedWorkbooks) {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer as never);
const sheet = workbook.getWorksheet('签名报备');
expect(sheet?.getCell('A1').text).toBe('通道签名');
expect(sheet?.getCell('A2').text).toBe('测试签名');
expect(sheet?.getImages()).toHaveLength(1);
}
});
it('keeps material pending and marks the task waiting when the channel has no field configuration', async () => {
const prisma = {
reportMaterialBatch: { create: jest.fn().mockResolvedValue({ id: 'batch-2' }), update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve(data)) },
smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'signature-2', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', auditStatus: 'approved', materialVersion: 1, drainageInfo: {}, tenant: { name: '企业' }, application: { name: '应用' } }), update: jest.fn() },
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: [{ channel: { id: 'channel-a', name: '通道A', status: 'active' } }] } }]) },
reportMaterialBatchItem: { create: jest.fn().mockResolvedValue({ id: 'batch-item-2' }) },
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-a', name: '通道A' }) },
channelReportField: { findMany: jest.fn().mockResolvedValue([]) },
channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'task-2', ...data })) },
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({}) },
reportExportFile: { create: jest.fn().mockResolvedValue({ id: 'export-2' }) },
reportExportFileItem: { createMany: jest.fn() },
};
const files = { 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);
const result = await service.createBatch({ items: [{ reportType: 'signature', signatureId: 'signature-2' }] });
expect(result).toMatchObject({ status: 'partial_failed' });
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'waiting_material', reason: '通道未配置当前资料类型的报备字段' }) });
expect(prisma.smsSignature.update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,529 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { 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';
export type ImportMapping = {
sourceHeader: string;
sourceHeaderPath?: string;
sourceColumnIndex: number;
targetFieldCode: string;
targetKind: 'signatureName' | 'purpose' | 'siteName' | 'url' | 'remark' | 'dynamic';
fieldType: 'string' | 'image' | 'file';
required?: boolean;
transform?: string;
sortOrder?: number;
};
export interface CreateImportProfileDto {
id?: string;
name: string;
reportType: 'signature' | 'drainage';
tenantId?: string;
applicationId?: string;
sheetName?: string;
headerRowCount?: number;
dataStartRow?: number;
status?: string;
columns: ImportMapping[];
}
export interface ImportCommitDto {
mappings: ImportMapping[];
profile?: CreateImportProfileDto;
}
export interface CreateReportBatchDto {
createdById?: string;
items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string }>;
}
type AnalyzeImportOptions = {
tenantId: string;
applicationId?: string;
reportType: 'signature' | 'drainage';
sheetName?: string;
headerRowCount: number;
dataStartRow: number;
profileId?: string;
};
type EmbeddedImage = { row: number; column: number; extension: string; buffer: Buffer };
@Injectable()
export class ReportMaterialsService {
constructor(
private readonly prisma: PrismaService,
private readonly files: FilesService,
private readonly smsConfig: SmsConfigService,
) {}
async listPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }) {
const [signatures, drainageInfos] = await Promise.all([
query.reportType === 'drainage' ? Promise.resolve([]) : this.prisma.smsSignature.findMany({
where: { pendingReport: true, auditStatus: 'approved', tenantId: query.tenantId, applicationId: query.applicationId },
include: { tenant: true, application: true },
orderBy: { reportChangedAt: 'desc' },
}),
query.reportType === 'signature' ? Promise.resolve([]) : this.prisma.smsDrainageInfo.findMany({
where: { pendingReport: true, auditStatus: 'approved', tenantId: query.tenantId, applicationId: query.applicationId },
include: { tenant: true, application: true, signature: true },
orderBy: { reportChangedAt: 'desc' },
}),
]);
return [
...signatures.map((item) => ({ id: `signature:${item.id}`, reportType: 'signature', signatureId: item.id, drainageItemId: null, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.name, detail: item.purpose, tenant: item.tenant, application: item.application })),
...drainageInfos.map((item) => ({ id: `drainage:${item.id}`, reportType: 'drainage', signatureId: item.signatureId, drainageItemId: item.id, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.siteName, detail: item.url, signatureName: item.signature.name, tenant: item.tenant, application: item.application })),
].sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime());
}
listImportProfiles(reportType?: 'signature' | 'drainage') {
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' } } } });
});
}
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);
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),
},
});
return { ...batch, sourceFile, sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length, suggestedMappings };
}
async commitImport(batchId: string, data: ImportCommitDto) {
const batch = await this.prisma.reportMaterialImportBatch.findUnique({ where: { id: batchId } });
if (!batch) throw new NotFoundException('导入批次不存在');
if (!data.mappings?.length) throw new BadRequestException('请至少配置一个导入字段映射');
if (data.profile) await this.saveImportProfile({ ...data.profile, reportType: batch.reportType as 'signature' | 'drainage', columns: data.mappings });
const { content } = await this.files.getDownload(batch.fileObjectId);
const workbook = await loadWorkbook(content);
const worksheet = workbook.getWorksheet(batch.sheetName);
if (!worksheet) throw new BadRequestException('导入工作表不存在');
const images = readEmbeddedImages(workbook, worksheet);
const imageByCell = new Map(images.map((image) => [`${image.row}:${image.column}`, image]));
let successCount = 0;
const failures: Array<{ rowNumber: number; reason: string }> = [];
for (let rowNumber = batch.dataStartRow; rowNumber <= worksheet.rowCount; rowNumber += 1) {
try {
const values: Record<string, unknown> = {};
for (const mapping of data.mappings) {
const image = imageByCell.get(`${rowNumber}:${mapping.sourceColumnIndex}`);
if (image && mapping.fieldType !== 'string') {
const uploaded = await this.files.upload({ tenantId: batch.tenantId, purpose: 'report_material', prefix: `report-materials/import-${batch.id}` }, {
originalname: `${mapping.targetFieldCode}-row-${rowNumber}.${normalizeImageExtension(image.extension)}`,
mimetype: imageContentType(image.extension),
size: image.buffer.length,
buffer: image.buffer,
});
values[mapping.targetFieldCode] = { fileObjectId: uploaded.id, fileName: uploaded.fileName, contentType: uploaded.contentType };
} else {
values[mapping.targetFieldCode] = transformValue(cellText(worksheet.getCell(rowNumber, mapping.sourceColumnIndex)), mapping.transform);
}
}
if (!Object.values(values).some(hasValue)) continue;
for (const mapping of data.mappings.filter((item) => item.required)) {
if (!hasValue(values[mapping.targetFieldCode])) throw new Error(`缺少必填字段:${mapping.sourceHeader}`);
}
if (batch.reportType === 'signature') await this.importSignatureRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values);
else await this.importDrainageRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values);
successCount += 1;
} catch (error) {
failures.push({ rowNumber, reason: error instanceof Error ? error.message : '导入失败' });
}
}
return this.prisma.reportMaterialImportBatch.update({
where: { id: batchId },
data: {
status: failures.length ? (successCount ? 'partial_failed' : 'failed') : 'completed',
mapping: data.mappings as Prisma.InputJsonValue,
result: { failures } as Prisma.InputJsonValue,
successCount,
failedCount: failures.length,
completedAt: new Date(),
},
});
}
listBatches() {
return this.prisma.reportMaterialBatch.findMany({
include: { exportFiles: true, items: true },
orderBy: { createdAt: 'desc' },
take: 100,
});
}
async createBatch(data: CreateReportBatchDto) {
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()];
const batch = await this.prisma.reportMaterialBatch.create({
data: { batchNo: `RB${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}${randomUUID().slice(0, 4).toUpperCase()}`, createdById: data.createdById, selectedCount: uniqueItems.length },
});
try {
const prepared = [];
for (const selected of uniqueItems) prepared.push(await this.prepareBatchItem(batch.id, selected));
const channelMap = new Map<string, Array<(typeof prepared)[number]>>();
for (const item of prepared) {
for (const channel of item.channels) {
const current = channelMap.get(channel.id) ?? [];
current.push({ ...item, channels: [channel] });
channelMap.set(channel.id, current);
}
}
const exportedFiles = [];
const incomplete = new Set<string>(prepared.filter((item) => item.channels.length === 0).map((item) => item.batchItem.id));
for (const [channelId, items] of channelMap) {
const result = await this.exportChannelBatch(batch.id, channelId, items);
exportedFiles.push(result.file);
for (const itemId of result.incompleteBatchItemIds) incomplete.add(itemId);
}
for (const item of prepared) {
if (incomplete.has(item.batchItem.id) || item.channels.length === 0) continue;
if (item.reportType === 'signature') await this.prisma.smsSignature.update({ where: { id: item.signature.id }, data: { pendingReport: false } });
else await this.prisma.smsDrainageInfo.update({ where: { id: item.drainageInfo!.id }, data: { pendingReport: false } });
}
return this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: incomplete.size ? 'partial_failed' : 'completed', channelCount: channelMap.size, fileCount: exportedFiles.length, completedAt: new Date() }, include: { exportFiles: true, items: true } });
} catch (error) {
await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: 'failed', errorMessage: error instanceof Error ? error.message : '生成报备批次失败', completedAt: new Date() } });
throw error;
}
}
private async importSignatureRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
const name = mappedCoreValue(mappings, values, 'signatureName');
if (!name) throw new Error('缺少短信签名');
const purpose = mappedCoreValue(mappings, values, 'purpose');
const signatureReportValues = dynamicValues(mappings, values);
const existing = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } } });
if (existing) return this.smsConfig.updateSignature(existing.id, { applicationId, name, purpose, drainageInfo: { ...jsonRecord(existing.drainageInfo), signatureReportValues } });
return this.smsConfig.createSignature({ tenantId, applicationId, name, purpose, drainageInfo: { signatureReportValues } }, { initialAuditStatus: 'approved' });
}
private async importDrainageRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
const signatureName = mappedCoreValue(mappings, values, 'signatureName');
const siteName = mappedCoreValue(mappings, values, 'siteName');
const url = mappedCoreValue(mappings, values, 'url');
if (!signatureName || !siteName || !url) throw new Error('引流信息必须包含短信签名、站点名称和URL');
const signature = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name: signatureName, auditStatus: 'approved' } });
if (!signature) throw new Error(`未找到已审核签名:${signatureName}`);
const remark = mappedCoreValue(mappings, values, 'remark');
const reportValues = dynamicValues(mappings, values);
const existing = await this.prisma.smsDrainageInfo.findFirst({ where: { signatureId: signature.id, url, auditStatus: { not: 'deleted' } } });
if (existing) return this.smsConfig.updateDrainageInfo(existing.id, { siteName, url, remark, reportValues }, { initialAuditStatus: 'approved' });
return this.smsConfig.createDrainageInfo(signature.id, { siteName, url, remark, reportValues }, { initialAuditStatus: 'approved' });
}
private async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number]) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } });
if (!signature || signature.auditStatus !== 'approved') throw new BadRequestException('签名不存在或未审核通过');
const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId
? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null;
if (selected.reportType === 'drainage' && (!drainageInfo || drainageInfo.signatureId !== signature.id || drainageInfo.auditStatus !== 'approved')) throw new BadRequestException('引流信息不存在或未审核通过');
const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({
where: { applicationId: signature.applicationId, status: 'active' },
include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
orderBy: { priority: 'asc' },
}) : [];
const channels = [...new Map(routes.flatMap((route) => route.group.items.map((entry) => entry.channel)).filter((channel) => channel.status === 'active').map((channel) => [channel.id, channel])).values()];
const snapshot = selected.reportType === 'signature'
? { reportType: 'signature', signature: { id: signature.id, name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) }
: { reportType: 'drainage', signature: { id: signature.id, name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { id: drainageInfo!.id, siteName: drainageInfo!.siteName, url: drainageInfo!.url, remark: drainageInfo!.remark }, values: jsonRecord(drainageInfo!.reportValues) };
const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo!.materialVersion;
const batchItem = await this.prisma.reportMaterialBatchItem.create({ data: { batchId, signatureId: signature.id, drainageItemId: drainageInfo?.id, reportType: selected.reportType, materialVersion, snapshot: snapshot as Prisma.InputJsonValue } });
return { batchItem, signature, drainageInfo, reportType: selected.reportType, snapshot, channels };
}
private async exportChannelBatch(batchId: string, channelId: string, items: Array<Awaited<ReturnType<ReportMaterialsService['prepareBatchItem']>>>) {
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
if (!channel) throw new NotFoundException('通道不存在');
const reportTypes = [...new Set(items.map((item) => item.reportType))];
const workbook = new ExcelJS.Workbook();
const fileRows: Array<{ item: (typeof items)[number]; taskId: string; rowNumber: number }> = [];
const incompleteBatchItemIds: string[] = [];
let totalRows = 0;
for (const reportType of reportTypes) {
const fields = await this.prisma.channelReportField.findMany({ where: { channelId, status: 'active', reportType: { in: [reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名报备' : '引流信息报备', { views: [{ state: 'frozen', ySplit: 1 }] });
sheet.properties.defaultRowHeight = 22;
sheet.columns = fields.map((field) => ({ header: field.exportName || field.name, key: field.code, width: field.columnWidth }));
styleHeader(sheet.getRow(1));
for (const item of items.filter((current) => current.reportType === reportType)) {
const values = fields.map((field) => resolveExportValue(item.snapshot, field.code, field.name) ?? field.defaultValue ?? '');
const missing = fields.filter((field, index) => field.required && !hasValue(values[index]));
const missingReason = fields.length === 0 ? '通道未配置当前资料类型的报备字段' : missing.length ? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}` : null;
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ where: { signatureId: item.signature.id, channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null } });
const task = existingTask
? await this.prisma.channelSignatureReportTask.update({ where: { id: existingTask.id }, data: { status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } })
: await this.prisma.channelSignatureReportTask.create({ data: { tenantId: item.signature.tenantId, signatureId: item.signature.id, channelId, reportType, drainageItemId: item.drainageInfo?.id, status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } });
if (missingReason) {
incompleteBatchItemIds.push(item.batchItem.id);
await this.recordTask(task.id, channelId, existingTask?.status, 'waiting_material', task.reason ?? undefined);
continue;
}
const row = sheet.addRow(values.map((value, index) => isFileRef(value) ? value.fileName : applyExportTransform(value, fields[index]?.transform)));
totalRows += 1;
let targetHeight = 22;
for (const [index, value] of values.entries()) {
if (!isFileRef(value)) continue;
const downloaded = await this.files.getDownload(value.fileObjectId);
if (!downloaded.fileObject.contentType.startsWith('image/')) continue;
const extension = normalizeImageExtension(extname(downloaded.fileObject.fileName).slice(1) || downloaded.fileObject.contentType.split('/')[1]);
if (!['png', 'jpeg', 'gif'].includes(extension)) continue;
const imageId = workbook.addImage({ base64: `data:${downloaded.fileObject.contentType};base64,${downloaded.content.toString('base64')}`, extension: extension as 'png' | 'jpeg' | 'gif' });
const widthCells = Math.max(0.8, fields[index].imageWidth / Math.max(60, fields[index].columnWidth * 7));
const heightRows = Math.max(0.8, fields[index].imageHeight / 20);
sheet.addImage(imageId, { tl: { col: index + 0.08, row: row.number - 1 + 0.08 }, br: { col: index + Math.min(0.95, widthCells), row: row.number - 1 + Math.min(0.95, heightRows) }, editAs: 'oneCell' } as never);
targetHeight = Math.max(targetHeight, fields[index].imageHeight * 0.75 + 8);
}
row.height = targetHeight;
fileRows.push({ item, taskId: task.id, rowNumber: row.number });
await this.recordTask(task.id, channelId, existingTask?.status, 'exporting');
}
}
if (workbook.worksheets.every((sheet) => sheet.rowCount <= 1)) {
for (const sheet of [...workbook.worksheets]) workbook.removeWorksheet(sheet.id);
const empty = workbook.addWorksheet('无可导出数据');
empty.getCell('A1').value = '所选资料缺少当前通道必填字段,请补充后重新生成。';
empty.getColumn(1).width = 64;
}
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
const fileName = `${safeFileName(channel.name)}-${batchId.slice(-8)}.xlsx`;
const uploaded = await this.files.upload({ purpose: 'report_export', prefix: `report-exports/${batchId}` }, { originalname: fileName, mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer });
const file = await this.prisma.reportExportFile.create({ data: { batchId, channelId, fileObjectId: uploaded.id, fileName, rowCount: totalRows } });
if (fileRows.length) await this.prisma.reportExportFileItem.createMany({ data: fileRows.map((entry) => ({ exportFileId: file.id, batchItemId: entry.item.batchItem.id, taskId: entry.taskId, rowNumber: entry.rowNumber })) });
return { file: { ...file, fileObject: uploaded }, incompleteBatchItemIds };
}
private recordTask(taskId: string, channelId: string, statusBefore: string | undefined, statusAfter: string, reason?: string) {
return this.prisma.channelSignatureReportRecord.create({ data: { taskId, channelId, action: 'batch_export', statusBefore, statusAfter, reason, sourceEntry: 'report_task' } });
}
}
function profileData(data: CreateImportProfileDto) {
return { name: data.name.trim(), reportType: data.reportType, tenantId: data.tenantId, applicationId: data.applicationId, sheetName: data.sheetName, headerRowCount: clamp(data.headerRowCount ?? 1, 1, 5), dataStartRow: Math.max(data.dataStartRow ?? 2, 2), status: data.status ?? 'active' };
}
function validateProfile(data: CreateImportProfileDto) {
if (!data.name?.trim()) throw new BadRequestException('映射模板名称不能为空');
if (!data.columns?.length) throw new BadRequestException('映射模板至少包含一个字段');
const indexes = data.columns.map((column) => column.sourceColumnIndex);
if (new Set(indexes).size !== indexes.length) throw new BadRequestException('同一源列不能重复映射');
}
async function loadWorkbook(buffer: Buffer) {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer as never);
return workbook;
}
function readEmbeddedImages(workbook: ExcelJS.Workbook, worksheet: ExcelJS.Worksheet): EmbeddedImage[] {
const getImages = (worksheet as unknown as { getImages?: () => Array<{ imageId: number; range: { tl: { nativeRow?: number; nativeCol?: number; row?: number; col?: number } } }> }).getImages;
if (!getImages) return [];
return getImages.call(worksheet).flatMap((drawing) => {
const image = (workbook as unknown as { getImage?: (id: number) => { buffer?: Buffer; base64?: string; extension?: string } }).getImage?.(drawing.imageId);
if (!image) return [];
const row = (drawing.range.tl.nativeRow ?? drawing.range.tl.row ?? 0) + 1;
const column = (drawing.range.tl.nativeCol ?? drawing.range.tl.col ?? 0) + 1;
const buffer = image.buffer ?? (image.base64 ? Buffer.from(image.base64.replace(/^data:[^;]+;base64,/, ''), 'base64') : undefined);
return buffer ? [{ row, column, extension: image.extension ?? 'png', buffer }] : [];
});
}
function suggestMappings(columns: Array<{ sourceColumnIndex: number; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>, reportType: 'signature' | 'drainage'): ImportMapping[] {
return columns.flatMap((column, index) => {
const normalized = normalizeHeader(`${column.sourceHeaderPath}/${column.sourceHeader}`);
const core = reportType === 'signature' ? signatureCoreMapping(normalized) : drainageCoreMapping(normalized);
if (!core && !column.imageCount) return [];
return [{ sourceHeader: column.sourceHeader, sourceHeaderPath: column.sourceHeaderPath, sourceColumnIndex: column.sourceColumnIndex, targetFieldCode: core?.code ?? normalizeFieldCode(column.sourceHeader), targetKind: core?.kind ?? 'dynamic', fieldType: column.imageCount ? 'image' : 'string', required: Boolean(core?.required), sortOrder: (index + 1) * 10 }];
});
}
function remapProfileColumns(profileColumns: ImportMapping[], sourceColumns: Array<{ sourceColumnIndex: number; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>): ImportMapping[] {
const used = new Set<number>();
return profileColumns.flatMap((profileColumn) => {
const headerPath = normalizeHeader(profileColumn.sourceHeaderPath || profileColumn.sourceHeader);
const header = normalizeHeader(profileColumn.sourceHeader);
const source = sourceColumns.find((column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeaderPath) === headerPath)
?? sourceColumns.find((column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeader) === header);
if (!source) return [];
used.add(source.sourceColumnIndex);
return [{ ...profileColumn, sourceColumnIndex: source.sourceColumnIndex, sourceHeader: source.sourceHeader, sourceHeaderPath: source.sourceHeaderPath, fieldType: source.imageCount > 0 && profileColumn.fieldType === 'string' ? 'image' : profileColumn.fieldType }];
});
}
function signatureCoreMapping(header: string): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined {
if (/短信签名|签名名称|签名/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true };
if (/用途|签名依据/.test(header)) return { code: 'purpose', kind: 'purpose' };
return undefined;
}
function drainageCoreMapping(header: string): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined {
if (/短信签名|签名名称/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true };
if (/站点|网站名称/.test(header)) return { code: 'site_name', kind: 'siteName', required: true };
if (/引流地址|网址|url|链接/.test(header)) return { code: 'url', kind: 'url', required: true };
if (/备注|说明/.test(header)) return { code: 'remark', kind: 'remark' };
return undefined;
}
function normalizeHeader(value: string) { return value.toLowerCase().replace(/[\s**::()()_-]/g, ''); }
function normalizeFieldCode(value: string) { return `import_${value.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_').slice(0, 40) || randomUUID().slice(0, 8)}`; }
function clamp(value: number, minimum: number, maximum: number) { return Math.min(maximum, Math.max(minimum, Number.isFinite(value) ? Math.round(value) : minimum)); }
function cellText(cell: ExcelJS.Cell) {
const value = cell.value;
if (value === null || value === undefined) return '';
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(value);
if (typeof value === 'string' || typeof value === 'boolean') return String(value).trim();
if ('result' in value && value.result !== undefined) return String(value.result ?? '').trim();
if ('richText' in value) return value.richText.map((item) => item.text).join('').trim();
if ('text' in value) return String(value.text).trim();
return cell.text.trim();
}
function transformValue(value: string, transform?: string) {
if (!transform || transform === 'trim') return value.trim();
if (transform === 'digits') return value.replace(/\D/g, '');
if (transform === 'uppercase') return value.trim().toUpperCase();
if (transform === 'lowercase') return value.trim().toLowerCase();
return value.trim();
}
function mappedCoreValue(mappings: ImportMapping[], values: Record<string, unknown>, kind: ImportMapping['targetKind']) {
const mapping = mappings.find((item) => item.targetKind === kind);
return mapping ? String(values[mapping.targetFieldCode] ?? '').trim() : '';
}
function dynamicValues(mappings: ImportMapping[], values: Record<string, unknown>) {
return Object.fromEntries(mappings.filter((item) => item.targetKind === 'dynamic').map((item) => [item.targetFieldCode, values[item.targetFieldCode]]));
}
function jsonRecord(value: unknown): Record<string, unknown> { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
function hasValue(value: unknown) { return isFileRef(value) ? Boolean(value.fileObjectId) : value !== null && value !== undefined && String(value).trim().length > 0; }
function isFileRef(value: unknown): value is { fileObjectId: string; fileName: string; contentType?: string } { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && typeof (value as Record<string, unknown>).fileObjectId === 'string'; }
function resolveExportValue(snapshot: Record<string, unknown>, code: string, name?: string) {
const values = jsonRecord(snapshot.values);
if (hasValue(values[code])) return values[code];
const signature = jsonRecord(snapshot.signature);
const drainage = jsonRecord(snapshot.drainage);
const aliases: Record<string, unknown> = {
signature_name: signature.name, sign_name: signature.name, signatureName: signature.name,
purpose: signature.purpose, enterprise_name: signature.tenantName, company_name: signature.tenantName,
application_name: signature.applicationName, site_name: drainage.siteName, url: drainage.url, remark: drainage.remark,
};
if (hasValue(aliases[code])) return aliases[code];
const semantic = normalizeHeader(`${code}/${name ?? ''}`);
if (/短信签名|签名名称|signaturename|sms(?:signature|sign)|^sign$/.test(semantic)) return signature.name;
if (/签名用途|签名依据|purpose/.test(semantic)) return signature.purpose;
if (/企业名称|公司名称|enterprisename|companyname/.test(semantic)) return signature.tenantName;
if (/应用名称|applicationname|appname/.test(semantic)) return signature.applicationName;
if (/站点名称|网站名称|sitename/.test(semantic)) return drainage.siteName;
if (/引流地址|网址|链接|url/.test(semantic)) return drainage.url;
if (/备注|说明|remark/.test(semantic)) return drainage.remark;
return undefined;
}
function applyExportTransform(value: unknown, transform?: string | null) {
const text = value === null || value === undefined ? '' : String(value);
return transformValue(text, transform ?? undefined);
}
function styleHeader(row: ExcelJS.Row) {
row.height = 28;
row.eachCell((cell) => {
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2563EB' } };
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFD1D5DB' } } };
});
}
function normalizeImageExtension(value: string) { const normalized = value.toLowerCase().replace(/^\./, ''); return normalized === 'jpg' ? 'jpeg' : normalized; }
function imageContentType(extension: string) { const normalized = normalizeImageExtension(extension); return normalized === 'jpeg' ? 'image/jpeg' : normalized === 'gif' ? 'image/gif' : 'image/png'; }
function safeFileName(value: string) { return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 80) || '通道报备'; }
+35 -2
View File
@@ -224,9 +224,12 @@ function createPrismaMock() {
findUnique: jest.fn().mockResolvedValue({
id: 'dead-1',
tenantId: 'tenant-1',
channelId: 'channel-1',
streamMessageId: '1710000000000-0',
submitId: 'SUB-1',
messageId: 'MSG-1',
status: 'pending',
manualRetryCount: 0,
commandPayload: {
schemaVersion: 'v1',
messageType: 'SubmitCommand',
@@ -1517,7 +1520,7 @@ describe('SendChainService', () => {
});
});
it('records gateway submit dead letters and allows manual requeue', async () => {
it('records gateway submit exceptions and safely allows manual requeue', async () => {
const { service, prisma } = createService();
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue('1710000001000-0');
@@ -1551,9 +1554,17 @@ describe('SendChainService', () => {
}),
});
await service.requeueGatewaySubmitDeadLetter('dead-1');
await service.requeueGatewaySubmitDeadLetter('dead-1', {
confirmedNotSubmitted: true,
reason: '确认通道连接失败且运营商未收到该短信',
operatorId: 'user-1',
});
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(expect.objectContaining({ submitId: 'SUB-1' }));
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
where: { id: 'dead-1', status: 'pending' },
data: { status: 'requeueing' },
});
expect(prisma.gatewaySubmitDeadLetter.update).toHaveBeenCalledWith({
where: { id: 'dead-1' },
data: expect.objectContaining({
@@ -1567,10 +1578,32 @@ describe('SendChainService', () => {
action: 'gateway.submit_dead_letter_requeue',
resource: 'gateway_submit_dead_letter',
resourceId: 'dead-1',
userId: 'user-1',
detail: expect.objectContaining({
reason: '确认通道连接失败且运营商未收到该短信',
confirmedNotSubmitted: true,
}),
}),
});
});
it('blocks submit exception requeue when the upstream result may already be accepted', async () => {
const { service, prisma } = createService();
prisma.smsMessageRecord.findUnique.mockResolvedValueOnce({
id: 'record-1',
messageId: 'MSG-1',
status: 'submitted',
submitStatus: 'accepted',
receiptStatus: null,
});
await expect(service.requeueGatewaySubmitDeadLetter('dead-1', {
confirmedNotSubmitted: true,
reason: '尝试重新发送这条短信',
})).rejects.toThrow('为避免重复发送,禁止重新入队');
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
});
it('records gateway downstream recovery statuses', async () => {
const { service, prisma } = createService();
+68 -5
View File
@@ -158,6 +158,12 @@ export interface GatewaySubmitDeadLetterDto {
deadLetteredAt?: string;
}
export interface RequeueGatewaySubmitExceptionDto {
confirmedNotSubmitted?: boolean;
reason?: string;
operatorId?: string;
}
export interface GatewayDownstreamRecoveryStatusDto {
account: string;
gatewayInstanceId?: string;
@@ -1244,15 +1250,69 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return updated;
}
async requeueGatewaySubmitDeadLetter(id: string) {
async requeueGatewaySubmitDeadLetter(id: string, data: RequeueGatewaySubmitExceptionDto = {}) {
const deadLetter = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } });
if (!deadLetter) {
throw new NotFoundException('Gateway submit dead letter not found');
throw new NotFoundException('Gateway提交异常记录不存在');
}
if (!deadLetter.commandPayload || typeof deadLetter.commandPayload !== 'object') {
throw new BadRequestException('该死信缺少可重放的 SubmitCommand');
if (deadLetter.status !== 'pending') {
throw new BadRequestException('该提交异常当前状态不允许重新入队');
}
if (!data.confirmedNotSubmitted) {
throw new BadRequestException('请确认运营商未接收该短信后再重新入队');
}
const reason = String(data.reason ?? '').trim();
if (reason.length < 5 || reason.length > 500) {
throw new BadRequestException('请填写5至500字的重新入队原因');
}
if (!deadLetter.commandPayload || !isObjectRecord(deadLetter.commandPayload)) {
throw new BadRequestException('该提交异常缺少可重新入队的SubmitCommand');
}
if (deadLetter.manualRetryCount >= 3) {
throw new BadRequestException('该提交异常已达到人工重新入队次数上限');
}
const message = deadLetter.messageId
? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: deadLetter.messageId } })
: null;
if (message && (
message.submitStatus === 'accepted'
|| ['submitted', 'delivered', 'unknown'].includes(message.status)
|| ['delivered', 'unknown'].includes(message.receiptStatus ?? '')
)) {
throw new BadRequestException('该短信已有成功或不确定的上游结果,为避免重复发送,禁止重新入队');
}
const commandChannelId = String(deadLetter.commandPayload.channelId ?? deadLetter.channelId ?? '').trim();
if (!commandChannelId) {
throw new BadRequestException('该提交异常缺少通道信息');
}
const channel = await this.prisma.smsChannel.findUnique({
where: { id: commandChannelId },
include: { connectionStates: true },
});
if (!channel || channel.status !== 'active') {
throw new BadRequestException('原通道不存在或已停用,不能重新入队');
}
if (!channel.connectionStates.some((state) => state.status === 'connected' && state.currentConnections > 0)) {
throw new BadRequestException('原通道当前没有可用CMPP连接,请先恢复通道');
}
const claimed = await this.prisma.gatewaySubmitDeadLetter.updateMany({
where: { id, status: 'pending' },
data: { status: 'requeueing' },
});
if (claimed.count !== 1) {
throw new BadRequestException('该提交异常已被其他操作处理,请刷新后重试');
}
let retryStreamMessageId: string;
try {
const publishedStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload);
if (!publishedStreamMessageId) {
throw new Error('Gateway提交异常重新入队未返回Stream消息编号');
}
retryStreamMessageId = publishedStreamMessageId;
} catch (error) {
await this.prisma.gatewaySubmitDeadLetter.update({ where: { id }, data: { status: 'pending' } });
throw error;
}
const retryStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload);
const updated = await this.prisma.gatewaySubmitDeadLetter.update({
where: { id },
data: {
@@ -1265,6 +1325,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
await this.prisma.operationLog.create({
data: {
tenantId: updated.tenantId ?? undefined,
userId: data.operatorId,
action: 'gateway.submit_dead_letter_requeue',
resource: 'gateway_submit_dead_letter',
resourceId: updated.id,
@@ -1273,6 +1334,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
retryStreamMessageId,
submitId: updated.submitId,
messageId: updated.messageId,
reason,
confirmedNotSubmitted: true,
},
},
});
+6
View File
@@ -853,6 +853,9 @@ export class SmsConfigService {
purpose: data.purpose,
auditStatus: data.auditStatus,
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
materialVersion: { increment: 1 },
pendingReport: true,
reportChangedAt: new Date(),
},
include: { materials: true, tenant: true, application: true },
});
@@ -935,6 +938,9 @@ export class SmsConfigService {
rejectReason: null,
submittedAt: new Date(),
reviewedAt: auditStatus === 'approved' ? new Date() : null,
materialVersion: { increment: 1 },
pendingReport: true,
reportChangedAt: new Date(),
},
include: { tenant: true, signature: true, application: true },
});