feat: harden platform workflows and UI governance

This commit is contained in:
hectorzhao
2026-07-22 14:14:55 +08:00
parent ef957f7daa
commit 0f223f7f91
80 changed files with 4958 additions and 764 deletions
@@ -1,7 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { randomUUID } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
@@ -40,9 +40,26 @@ export interface ImportCommitDto {
export interface CreateReportBatchDto {
createdById?: string;
items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string }>;
idempotencyKey?: string;
items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }>;
}
type ReportBatchTarget = { id: string; name: string; carrier: string; businessKey: string; eligible: boolean; blockedReasons: string[]; duplicateBatchId?: string };
type ReportBatchInspection = {
id: string;
reportType: 'signature' | 'drainage';
signatureId: string;
drainageItemId?: string;
materialVersion: number;
name: string;
tenantName: string;
applicationId?: string;
applicationName: string;
eligible: boolean;
blockedReasons: string[];
targets: ReportBatchTarget[];
};
type AnalyzeImportOptions = {
tenantId: string;
applicationId?: string;
@@ -296,13 +313,33 @@ export class ReportMaterialsService {
async createBatch(data: CreateReportBatchDto) {
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
const idempotencyKey = normalizeBatchIdempotencyKey(data.idempotencyKey);
const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()];
const fingerprint = createHash('sha256').update(JSON.stringify(uniqueItems.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? null, materialVersion: item.materialVersion ?? null })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))).digest('hex');
const claimed = await this.claimBatchOperation(idempotencyKey, fingerprint, data.createdById);
if (claimed.replayed) return claimed.result;
let preflight: Awaited<ReturnType<ReportMaterialsService['preflightBatch']>>;
try {
preflight = await this.preflightBatch({ items: uniqueItems });
} catch (error) {
await this.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '报备资格预检失败');
throw error;
}
if (preflight.eligibleTargetCount === 0) {
await this.failBatchOperation(claimed.operationId, '没有可生成的报备目标');
throw new BadRequestException({ code: 'REPORT_BATCH_NOT_ELIGIBLE', message: '所选资料没有可生成的通道,请按资格检查补充后重试', preflight });
}
const eligibleInspections = preflight.items.filter((item) => item.targets.some((target) => target.eligible));
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 },
data: { batchNo: `RB${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}${randomUUID().slice(0, 4).toUpperCase()}`, createdById: data.createdById, selectedCount: eligibleInspections.length },
});
try {
const prepared = [];
for (const selected of uniqueItems) prepared.push(await this.prepareBatchItem(batch.id, selected));
for (const inspection of eligibleInspections) {
const selected = uniqueItems.find((item) => item.reportType === inspection.reportType && (item.drainageItemId ?? item.signatureId) === (inspection.drainageItemId ?? inspection.signatureId))!;
prepared.push(await this.prepareBatchItem(batch.id, selected, inspection));
}
const channelMap = new Map<string, Array<(typeof prepared)[number]>>();
for (const item of prepared) {
for (const channel of item.channels) {
@@ -313,9 +350,11 @@ export class ReportMaterialsService {
}
const exportedFiles = [];
const incomplete = new Set<string>(prepared.filter((item) => item.channels.length === 0).map((item) => item.batchItem.id));
let failedTargetCount = 0;
for (const [channelId, items] of channelMap) {
const result = await this.exportChannelBatch(batch.id, channelId, items);
exportedFiles.push(result.file);
failedTargetCount += result.incompleteBatchItemIds.length;
for (const itemId of result.incompleteBatchItemIds) incomplete.add(itemId);
}
for (const item of prepared) {
@@ -323,13 +362,46 @@ export class ReportMaterialsService {
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 } });
const completed = await 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 } });
const result = {
...completed,
operationId: claimed.operationId,
replayed: false,
result: {
successCount: preflight.eligibleTargetCount - failedTargetCount,
skippedCount: preflight.skippedTargetCount,
failedCount: failedTargetCount,
items: preflight.items,
},
};
await this.completeBatchOperation(claimed.operationId, batch.id, result);
return result;
} catch (error) {
await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: 'failed', errorMessage: error instanceof Error ? error.message : '生成报备批次失败', completedAt: new Date() } });
await this.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '生成报备批次失败', batch.id);
throw error;
}
}
async preflightBatch(data: Pick<CreateReportBatchDto, 'items'>) {
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
for (const item of data.items) {
if (!['signature', 'drainage'].includes(item.reportType) || !item.signatureId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '每条报备资料必须包含有效的资料类型和签名ID' });
if (item.reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '引流资料必须包含引流资料ID' });
}
const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()];
const items = await Promise.all(uniqueItems.map((item) => this.inspectBatchItem(item)));
return {
checkedAt: new Date().toISOString(),
eligible: items.some((item) => item.eligible),
eligibleItemCount: items.filter((item) => item.eligible).length,
blockedItemCount: items.filter((item) => !item.eligible).length,
eligibleTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => target.eligible).length, 0),
skippedTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => !target.eligible).length, 0),
items,
};
}
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('缺少短信签名');
@@ -354,7 +426,7 @@ export class ReportMaterialsService {
return this.smsConfig.createDrainageInfo(signature.id, { siteName, url, remark, reportValues }, { initialAuditStatus: 'approved' });
}
private async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number]) {
private async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number], inspection: ReportBatchInspection) {
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
@@ -365,15 +437,121 @@ export class ReportMaterialsService {
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 eligibleChannelIds = new Set(inspection.targets.filter((target) => target.eligible).map((target) => target.id));
const channels = [...new Map(routes.flatMap((route) => route.group.items.map((entry) => entry.channel)).filter((channel) => channel.status === 'active' && eligibleChannelIds.has(channel.id)).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) };
? { reportType: 'signature', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), 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', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), 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 inspectBatchItem(selected: CreateReportBatchDto['items'][number]): Promise<ReportBatchInspection> {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } });
if (!signature) throw new NotFoundException('签名不存在');
const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId
? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null;
const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo?.materialVersion ?? 0;
const blockedReasons: string[] = [];
if (signature.auditStatus !== 'approved') blockedReasons.push('签名尚未审核通过');
if (!signature.pendingReport) blockedReasons.push('该签名版本已不在待报备池');
if (!signature.applicationId || !signature.application) blockedReasons.push('未绑定短信应用');
else if (signature.application.status !== 'active') blockedReasons.push('短信应用未启用');
if (selected.materialVersion !== undefined && selected.materialVersion !== materialVersion) blockedReasons.push(`资料版本已变化(当前 V${materialVersion}`);
if (selected.reportType === 'drainage') {
if (!drainageInfo || drainageInfo.signatureId !== signature.id) blockedReasons.push('引流资料不存在或不属于当前签名');
else {
if (drainageInfo.auditStatus !== 'approved') blockedReasons.push('引流资料尚未审核通过');
if (!drainageInfo.pendingReport) blockedReasons.push('该引流资料版本已不在待报备池');
}
}
const snapshot = selected.reportType === 'signature'
? { signature: { name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) }
: { signature: { name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { siteName: drainageInfo?.siteName, url: drainageInfo?.url, remark: drainageInfo?.remark }, values: jsonRecord(drainageInfo?.reportValues) };
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 channelCarriers = new Map<string, { channel: { id: string; name: string; status: string; carrier?: string | null }; carriers: Set<string> }>();
for (const route of routes) {
if (route.group.status !== 'active') continue;
for (const entry of route.group.items) {
if (entry.channel.status !== 'active') continue;
const current = channelCarriers.get(entry.channel.id) ?? { channel: entry.channel, carriers: new Set<string>() };
current.carriers.add(route.carrier || entry.carrier || entry.channel.carrier || 'all');
channelCarriers.set(entry.channel.id, current);
}
}
if (blockedReasons.length === 0 && channelCarriers.size === 0) blockedReasons.push('当前应用没有启用且可路由的通道');
const previous = await this.prisma.reportMaterialBatchItem.findMany({
where: { signatureId: signature.id, drainageItemId: selected.reportType === 'drainage' ? drainageInfo?.id : null, reportType: selected.reportType, materialVersion, batch: { status: { in: ['completed', 'partial_failed'] } } },
select: { batchId: true, snapshot: true, exportItems: { select: { exportFile: { select: { channelId: true } } } } },
orderBy: { createdAt: 'desc' },
});
const priorKeys = new Map<string, string>();
for (const item of previous) {
const exportedChannelIds = new Set(item.exportItems.map((entry) => entry.exportFile.channelId).filter((value): value is string => Boolean(value)));
for (const key of jsonStringArray(jsonRecord(item.snapshot).businessKeys)) {
if ([...exportedChannelIds].some((channelId) => key.includes(`:channel:${channelId}:`)) && !priorKeys.has(key)) priorKeys.set(key, item.batchId);
}
}
const targets: ReportBatchTarget[] = [];
for (const { channel, carriers } of channelCarriers.values()) {
const carrier = [...carriers].sort().join(',');
const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`;
const targetReasons = [...blockedReasons];
const fields = await this.prisma.channelReportField.findMany({ where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
if (fields.length === 0) targetReasons.push('通道未配置当前资料类型的报备字段');
else {
const missing = fields.filter((field) => field.required && !hasValue(resolveExportValue(snapshot, field.code, field.name) ?? field.defaultValue));
if (missing.length) targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`);
}
const duplicateBatchId = priorKeys.get(businessKey);
if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`);
targets.push({ id: channel.id, name: channel.name, carrier, businessKey, eligible: targetReasons.length === 0, blockedReasons: targetReasons, duplicateBatchId });
}
return {
id: `${selected.reportType}:${selected.drainageItemId ?? signature.id}`,
reportType: selected.reportType,
signatureId: signature.id,
drainageItemId: drainageInfo?.id,
materialVersion,
name: selected.reportType === 'signature' ? signature.name : drainageInfo?.siteName ?? '引流资料',
tenantName: signature.tenant.name,
applicationId: signature.applicationId ?? undefined,
applicationName: signature.application?.name ?? '未指定应用',
eligible: targets.some((target) => target.eligible),
blockedReasons: targets.length ? [...new Set(targets.flatMap((target) => target.blockedReasons))] : blockedReasons,
targets,
};
}
private async claimBatchOperation(idempotencyKey: string, fingerprint: string, userId?: string) {
return this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${idempotencyKey}))`;
const existing = await tx.operationLog.findFirst({ where: { action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey }, orderBy: { createdAt: 'desc' } });
if (existing) {
const detail = jsonRecord(existing.detail);
if (detail.fingerprint !== fingerprint) throw new ConflictException({ code: 'IDEMPOTENCY_KEY_REUSED', message: '该幂等键已用于不同的报备范围' });
if (detail.status === 'completed' && detail.result) return { operationId: existing.id, replayed: true as const, result: { ...jsonRecord(detail.result), replayed: true, operationId: existing.id } };
throw new ConflictException({ code: 'REPORT_BATCH_IN_PROGRESS', message: detail.status === 'failed' ? '上次生成失败,请使用新的操作单重试' : '该报备操作正在处理中,请勿重复提交' });
}
const operation = await tx.operationLog.create({ data: { userId, action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey, detail: { status: 'processing', fingerprint } } });
return { operationId: operation.id, replayed: false as const, result: null };
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}
private async completeBatchOperation(operationId: string, batchId: string, result: Record<string, unknown>) {
await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { status: 'completed', batchId, fingerprint: jsonRecord((await this.prisma.operationLog.findUnique({ where: { id: operationId } }))?.detail).fingerprint, result: jsonSafe(result) } as Prisma.InputJsonValue } });
}
private async failBatchOperation(operationId: string, message: string, batchId?: string) {
const operation = await this.prisma.operationLog.findUnique({ where: { id: operationId } });
await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { ...jsonRecord(operation?.detail), status: 'failed', batchId, message } as Prisma.InputJsonValue } });
}
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('通道不存在');
@@ -602,3 +780,17 @@ function styleHeader(row: ExcelJS.Row) {
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) || '通道报备'; }
function normalizeBatchIdempotencyKey(value?: string) {
const key = value?.trim();
if (!key || key.length > 128 || !/^[A-Za-z0-9._:-]{8,128}$/.test(key)) throw new BadRequestException({ code: 'IDEMPOTENCY_KEY_INVALID', message: 'idempotencyKey 必填且长度为8至128位' });
return key;
}
function jsonStringArray(value: unknown) {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
}
function jsonSafe(value: unknown): Prisma.InputJsonValue {
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
}