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
+921 -9
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -24,6 +24,7 @@
"bullmq": "^5.79.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"exceljs": "^4.4.0",
"ioredis": "^5.11.1",
"minio": "^8.0.7",
"pg": "^8.22.0",
@@ -41,5 +42,13 @@
},
"overrides": {
"@hono/node-server": "1.19.13"
},
"overrides": {
"exceljs": {
"uuid": "11.1.1"
},
"@prisma/dev": {
"@hono/node-server": "1.19.13"
}
}
}
@@ -0,0 +1,134 @@
ALTER TABLE "SmsSignature"
ADD COLUMN "materialVersion" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "pendingReport" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "reportChangedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE "SmsDrainageInfo"
ADD COLUMN "materialVersion" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "pendingReport" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "reportChangedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE "ChannelReportField"
ADD COLUMN "exportName" TEXT,
ADD COLUMN "columnWidth" INTEGER NOT NULL DEFAULT 18,
ADD COLUMN "imageWidth" INTEGER NOT NULL DEFAULT 120,
ADD COLUMN "imageHeight" INTEGER NOT NULL DEFAULT 80,
ADD COLUMN "defaultValue" TEXT,
ADD COLUMN "transform" TEXT;
ALTER TABLE "ReportExportFile"
ALTER COLUMN "taskId" DROP NOT NULL,
ADD COLUMN "batchId" TEXT,
ADD COLUMN "channelId" TEXT;
CREATE TABLE "ReportMaterialBatch" (
"id" TEXT NOT NULL,
"batchNo" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'generating',
"createdById" TEXT,
"selectedCount" INTEGER NOT NULL DEFAULT 0,
"channelCount" INTEGER NOT NULL DEFAULT 0,
"fileCount" INTEGER NOT NULL DEFAULT 0,
"errorMessage" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMP(3),
CONSTRAINT "ReportMaterialBatch_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ReportMaterialBatchItem" (
"id" TEXT NOT NULL,
"batchId" TEXT NOT NULL,
"signatureId" TEXT NOT NULL,
"drainageItemId" TEXT,
"reportType" TEXT NOT NULL,
"materialVersion" INTEGER NOT NULL,
"snapshot" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ReportMaterialBatchItem_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ReportExportFileItem" (
"id" TEXT NOT NULL,
"exportFileId" TEXT NOT NULL,
"batchItemId" TEXT NOT NULL,
"taskId" TEXT NOT NULL,
"rowNumber" INTEGER NOT NULL,
CONSTRAINT "ReportExportFileItem_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ReportMaterialImportProfile" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"reportType" TEXT NOT NULL,
"tenantId" TEXT,
"applicationId" TEXT,
"sheetName" TEXT,
"headerRowCount" INTEGER NOT NULL DEFAULT 1,
"dataStartRow" INTEGER NOT NULL DEFAULT 2,
"status" TEXT NOT NULL DEFAULT 'active',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ReportMaterialImportProfile_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ReportMaterialImportProfileColumn" (
"id" TEXT NOT NULL,
"profileId" TEXT NOT NULL,
"sourceHeader" TEXT NOT NULL,
"sourceHeaderPath" TEXT,
"sourceColumnIndex" INTEGER NOT NULL,
"targetFieldCode" TEXT NOT NULL,
"targetKind" TEXT NOT NULL DEFAULT 'dynamic',
"fieldType" TEXT NOT NULL DEFAULT 'string',
"required" BOOLEAN NOT NULL DEFAULT false,
"transform" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 100,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ReportMaterialImportProfileColumn_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ReportMaterialImportBatch" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT,
"profileId" TEXT,
"fileObjectId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"reportType" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'analyzed',
"sheetName" TEXT NOT NULL,
"headerRowCount" INTEGER NOT NULL DEFAULT 1,
"dataStartRow" INTEGER NOT NULL DEFAULT 2,
"mapping" JSONB NOT NULL,
"preview" JSONB,
"result" JSONB,
"rowCount" INTEGER NOT NULL DEFAULT 0,
"successCount" INTEGER NOT NULL DEFAULT 0,
"failedCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMP(3),
CONSTRAINT "ReportMaterialImportBatch_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ReportMaterialBatch_batchNo_key" ON "ReportMaterialBatch"("batchNo");
CREATE INDEX "ReportMaterialBatch_status_createdAt_idx" ON "ReportMaterialBatch"("status", "createdAt");
CREATE INDEX "ReportMaterialBatchItem_batchId_reportType_idx" ON "ReportMaterialBatchItem"("batchId", "reportType");
CREATE INDEX "ReportMaterialBatchItem_signatureId_drainageItemId_idx" ON "ReportMaterialBatchItem"("signatureId", "drainageItemId");
CREATE UNIQUE INDEX "ReportExportFileItem_exportFileId_batchItemId_key" ON "ReportExportFileItem"("exportFileId", "batchItemId");
CREATE INDEX "ReportExportFileItem_taskId_idx" ON "ReportExportFileItem"("taskId");
CREATE INDEX "ReportExportFile_batchId_channelId_idx" ON "ReportExportFile"("batchId", "channelId");
CREATE INDEX "ReportMaterialImportProfile_reportType_status_idx" ON "ReportMaterialImportProfile"("reportType", "status");
CREATE INDEX "ReportMaterialImportProfile_tenantId_applicationId_idx" ON "ReportMaterialImportProfile"("tenantId", "applicationId");
CREATE UNIQUE INDEX "ReportMaterialImportProfileColumn_profileId_sourceColumnIndex_key" ON "ReportMaterialImportProfileColumn"("profileId", "sourceColumnIndex");
CREATE INDEX "ReportMaterialImportProfileColumn_profileId_sortOrder_idx" ON "ReportMaterialImportProfileColumn"("profileId", "sortOrder");
CREATE INDEX "ReportMaterialImportBatch_tenantId_createdAt_idx" ON "ReportMaterialImportBatch"("tenantId", "createdAt");
CREATE INDEX "ReportMaterialImportBatch_status_createdAt_idx" ON "ReportMaterialImportBatch"("status", "createdAt");
ALTER TABLE "ReportExportFile" ADD CONSTRAINT "ReportExportFile_batchId_fkey" FOREIGN KEY ("batchId") REFERENCES "ReportMaterialBatch"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ReportMaterialBatchItem" ADD CONSTRAINT "ReportMaterialBatchItem_batchId_fkey" FOREIGN KEY ("batchId") REFERENCES "ReportMaterialBatch"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ReportMaterialBatchItem" ADD CONSTRAINT "ReportMaterialBatchItem_signatureId_fkey" FOREIGN KEY ("signatureId") REFERENCES "SmsSignature"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "ReportMaterialBatchItem" ADD CONSTRAINT "ReportMaterialBatchItem_drainageItemId_fkey" FOREIGN KEY ("drainageItemId") REFERENCES "SmsDrainageInfo"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ReportExportFileItem" ADD CONSTRAINT "ReportExportFileItem_exportFileId_fkey" FOREIGN KEY ("exportFileId") REFERENCES "ReportExportFile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ReportExportFileItem" ADD CONSTRAINT "ReportExportFileItem_batchItemId_fkey" FOREIGN KEY ("batchItemId") REFERENCES "ReportMaterialBatchItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ReportExportFileItem" ADD CONSTRAINT "ReportExportFileItem_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "ChannelSignatureReportTask"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ReportMaterialImportProfileColumn" ADD CONSTRAINT "ReportMaterialImportProfileColumn_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "ReportMaterialImportProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,18 @@
DROP INDEX IF EXISTS "ChannelReportField_channelId_code_key";
INSERT INTO "ChannelReportField" (
"id", "channelId", "drainageFieldId", "reportType", "code", "name", "exportName", "fieldType",
"required", "description", "sortOrder", "columnWidth", "imageWidth", "imageHeight", "defaultValue",
"transform", "status", "createdAt", "updatedAt"
)
SELECT
"id" || '_drainage', "channelId", "drainageFieldId", 'drainage', "code", "name", "exportName", "fieldType",
"required", "description", "sortOrder", "columnWidth", "imageWidth", "imageHeight", "defaultValue",
"transform", "status", "createdAt", "updatedAt"
FROM "ChannelReportField"
WHERE "reportType" = 'both';
UPDATE "ChannelReportField" SET "reportType" = 'signature' WHERE "reportType" = 'both';
CREATE UNIQUE INDEX "ChannelReportField_channelId_code_reportType_key"
ON "ChannelReportField"("channelId", "code", "reportType");
@@ -0,0 +1,4 @@
-- Existing rows predate the pending-material workflow and must not be treated as newly changed.
-- New rows continue to use the schema default (true), while later edits explicitly set true.
UPDATE "SmsSignature" SET "pendingReport" = false;
UPDATE "SmsDrainageInfo" SET "pendingReport" = false;
@@ -0,0 +1 @@
ALTER TABLE "SmsChannelGroupItem" DROP COLUMN "rateLimitPerSecond";
+140 -4
View File
@@ -417,6 +417,9 @@ model SmsSignature {
auditStatus String @default("draft")
reportStatus String @default("waiting_material")
rejectReason String?
materialVersion Int @default(1)
pendingReport Boolean @default(true)
reportChangedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -429,6 +432,7 @@ model SmsSignature {
drainageItems SmsDrainageInfo[]
reportTasks ChannelSignatureReportTask[]
messageRecords SmsMessageRecord[]
reportBatchItems ReportMaterialBatchItem[]
@@index([tenantId, auditStatus])
@@index([tenantId, reportStatus])
@@ -445,6 +449,9 @@ model SmsDrainageInfo {
reportValues Json?
auditStatus String @default("pending")
rejectReason String?
materialVersion Int @default(1)
pendingReport Boolean @default(true)
reportChangedAt DateTime @default(now())
submittedAt DateTime @default(now())
reviewedAt DateTime?
createdAt DateTime @default(now())
@@ -455,6 +462,7 @@ model SmsDrainageInfo {
application SmsApplication? @relation(fields: [applicationId], references: [id])
reportTasks ChannelSignatureReportTask[]
messageRecords SmsMessageRecord[]
reportBatchItems ReportMaterialBatchItem[]
@@index([tenantId, auditStatus, updatedAt])
@@index([signatureId, auditStatus])
@@ -651,7 +659,6 @@ model SmsChannelGroupItem {
priority Int @default(100)
weight Int @default(1)
isBackup Boolean @default(false)
rateLimitPerSecond Int?
createdAt DateTime @default(now())
group SmsChannelGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@ -707,10 +714,16 @@ model ChannelReportField {
reportType String @default("both")
code String
name String
exportName String?
fieldType String
required Boolean @default(false)
description String?
sortOrder Int @default(100)
columnWidth Int @default(18)
imageWidth Int @default(120)
imageHeight Int @default(80)
defaultValue String?
transform String?
status String @default("active")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -718,7 +731,7 @@ model ChannelReportField {
channel SmsChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
drainageField DrainageField? @relation(fields: [drainageFieldId], references: [id])
@@unique([channelId, code])
@@unique([channelId, code, reportType])
@@index([drainageFieldId, reportType])
}
@@ -791,6 +804,7 @@ model ChannelSignatureReportTask {
records ChannelSignatureReportRecord[]
exportFiles ReportExportFile[]
receiptImports ReportReceiptImport[]
exportItems ReportExportFileItem[]
@@index([tenantId, status])
@@index([signatureId, channelId])
@@ -819,14 +833,136 @@ model ChannelSignatureReportRecord {
model ReportExportFile {
id String @id @default(cuid())
taskId String
taskId String?
batchId String?
channelId String?
fileObjectId String?
fileName String
rowCount Int @default(0)
status String @default("generated")
createdAt DateTime @default(now())
task ChannelSignatureReportTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
task ChannelSignatureReportTask? @relation(fields: [taskId], references: [id], onDelete: Cascade)
batch ReportMaterialBatch? @relation(fields: [batchId], references: [id], onDelete: Cascade)
items ReportExportFileItem[]
@@index([batchId, channelId])
}
model ReportMaterialBatch {
id String @id @default(cuid())
batchNo String @unique
status String @default("generating")
createdById String?
selectedCount Int @default(0)
channelCount Int @default(0)
fileCount Int @default(0)
errorMessage String?
createdAt DateTime @default(now())
completedAt DateTime?
items ReportMaterialBatchItem[]
exportFiles ReportExportFile[]
@@index([status, createdAt])
}
model ReportMaterialBatchItem {
id String @id @default(cuid())
batchId String
signatureId String
drainageItemId String?
reportType String
materialVersion Int
snapshot Json
createdAt DateTime @default(now())
batch ReportMaterialBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
signature SmsSignature @relation(fields: [signatureId], references: [id])
drainageInfo SmsDrainageInfo? @relation(fields: [drainageItemId], references: [id])
exportItems ReportExportFileItem[]
@@index([batchId, reportType])
@@index([signatureId, drainageItemId])
}
model ReportExportFileItem {
id String @id @default(cuid())
exportFileId String
batchItemId String
taskId String
rowNumber Int
exportFile ReportExportFile @relation(fields: [exportFileId], references: [id], onDelete: Cascade)
batchItem ReportMaterialBatchItem @relation(fields: [batchItemId], references: [id], onDelete: Cascade)
task ChannelSignatureReportTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
@@unique([exportFileId, batchItemId])
@@index([taskId])
}
model ReportMaterialImportProfile {
id String @id @default(cuid())
name String
reportType String
tenantId String?
applicationId String?
sheetName String?
headerRowCount Int @default(1)
dataStartRow Int @default(2)
status String @default("active")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
columns ReportMaterialImportProfileColumn[]
@@index([reportType, status])
@@index([tenantId, applicationId])
}
model ReportMaterialImportProfileColumn {
id String @id @default(cuid())
profileId String
sourceHeader String
sourceHeaderPath String?
sourceColumnIndex Int
targetFieldCode String
targetKind String @default("dynamic")
fieldType String @default("string")
required Boolean @default(false)
transform String?
sortOrder Int @default(100)
createdAt DateTime @default(now())
profile ReportMaterialImportProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@unique([profileId, sourceColumnIndex])
@@index([profileId, sortOrder])
}
model ReportMaterialImportBatch {
id String @id @default(cuid())
tenantId String
applicationId String?
profileId String?
fileObjectId String
fileName String
reportType String
status String @default("analyzed")
sheetName String
headerRowCount Int @default(1)
dataStartRow Int @default(2)
mapping Json
preview Json?
result Json?
rowCount Int @default(0)
successCount Int @default(0)
failedCount Int @default(0)
createdAt DateTime @default(now())
completedAt DateTime?
@@index([tenantId, createdAt])
@@index([status, createdAt])
}
model ReportReceiptImport {
+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 },
});