import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma, type SmsSignature, type SmsTemplate } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; export type ReviewTargetType = 'signature' | 'template'; export type ReviewDecision = 'approve' | 'reject'; export interface ReviewDecisionDto { decision: ReviewDecision; expectedUpdatedAt: string; idempotencyKey: string; reason?: string; reviewerId?: string; } @Injectable() export class ReviewGovernanceService { constructor(private readonly prisma: PrismaService) {} async preflight(type: ReviewTargetType, id: string) { if (type === 'signature') return this.signaturePreflight(id); if (type === 'template') return this.templatePreflight(id); throw new BadRequestException('Unsupported review target'); } async decide(type: ReviewTargetType, id: string, data: ReviewDecisionDto) { const key = normalizeIdempotencyKey(data.idempotencyKey); const reason = data.reason?.trim(); if (!data.reviewerId) throw new BadRequestException('Reviewer session is required'); if (data.decision === 'reject' && !reason) throw new BadRequestException('驳回时必须填写原因'); const marker = `[idempotency:${key}]`; const targetType = type === 'signature' ? 'sms_signature' : 'sms_template'; const replay = await this.prisma.auditRecord.findFirst({ where: { targetType, targetId: id, reason: { startsWith: marker } }, orderBy: { createdAt: 'desc' }, }); if (replay) { if (replay.action !== data.decision) { throw new ConflictException({ code: 'IDEMPOTENCY_KEY_REUSED', message: '该幂等键已用于不同审核决定' }); } return { operationId: replay.id, replayed: true, decision: replay.action as ReviewDecision, status: replay.statusAfter, item: await this.readTarget(type, id), }; } const preflight = await this.preflight(type, id); if (!preflight.allowedActions.includes(data.decision)) { throw new BadRequestException({ code: 'REVIEW_NOT_ELIGIBLE', message: preflight.blockedReasons.join(';') || '当前对象不可执行该审核动作', preflight }); } const expectedUpdatedAt = new Date(data.expectedUpdatedAt); if (Number.isNaN(expectedUpdatedAt.getTime())) throw new BadRequestException('Invalid expectedUpdatedAt'); const statusAfter = data.decision === 'approve' ? 'approved' : 'rejected'; const auditReason = `${marker}${reason ? ` ${reason}` : ' 审核资料及影响摘要已确认'}`; return this.prisma.$transaction(async (tx) => { const model = type === 'signature' ? tx.smsSignature : tx.smsTemplate; const changed = await (model.updateMany as unknown as (args: unknown) => Promise<{ count: number }>)({ where: { id, auditStatus: 'pending', updatedAt: expectedUpdatedAt }, data: { auditStatus: statusAfter, rejectReason: data.decision === 'reject' ? reason : null }, }); if (changed.count !== 1) { throw new ConflictException({ code: 'REVIEW_VERSION_CONFLICT', message: '审核对象已被其他操作更新,请刷新后重试' }); } const audit = await tx.auditRecord.create({ data: { tenantId: preflight.tenantId, targetType, targetId: id, action: data.decision, statusBefore: preflight.status, statusAfter, reason: auditReason, reviewerId: data.reviewerId, }, }); const item = type === 'signature' ? await tx.smsSignature.findUnique({ where: { id }, include: { tenant: true, application: true, materials: true } }) : await tx.smsTemplate.findUnique({ where: { id }, include: { tenant: true, application: true, signature: true } }); return { operationId: audit.id, replayed: false, decision: data.decision, status: statusAfter, item }; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); } private async signaturePreflight(id: string) { const item = await this.prisma.smsSignature.findUnique({ where: { id }, include: { tenant: true, application: true, materials: true }, }); if (!item) throw new NotFoundException('Signature not found'); const payload = asRecord(item.drainageInfo); const missing: string[] = []; if (!item.applicationId) missing.push('未绑定短信应用'); const signatureValues = asRecord(payload.signatureReportValues); const requiredFields = reportRequirementFields(payload) .filter((field) => field.required && field.reportTypes.some((type) => type === 'signature' || type === 'both')); for (const field of requiredFields) { if (!hasReportValue(signatureValues[field.code])) missing.push(`缺少${field.name}`); } return reviewPreflight('signature', item, { identity: { name: item.name, id: item.id, tenant: item.tenant.name, application: item.application?.name ?? '未绑定应用' }, blockedReasons: missing, impacts: ['通过后签名将进入报备资格链路', '已绑定模板和后续发送资格可能受此决定影响'], materialSummary: { configuredRequiredFields: requiredFields.length, submittedFields: Object.values(signatureValues).filter(hasReportValue).length, qualificationFiles: item.materials.length + Object.values(signatureValues).filter((value) => Boolean(asRecord(value).fileObjectId)).length, missingCount: missing.length, }, }); } private async templatePreflight(id: string) { const item = await this.prisma.smsTemplate.findUnique({ where: { id }, include: { tenant: true, application: true, signature: true }, }); if (!item) throw new NotFoundException('Template not found'); const missing: string[] = []; if (!item.content.trim()) missing.push('模板内容为空'); if (!item.signatureId) missing.push('未绑定短信签名'); else if (item.signature?.auditStatus !== 'approved') missing.push('绑定签名尚未审核通过'); return reviewPreflight('template', item, { identity: { name: item.name, id: item.id, tenant: item.tenant.name, application: item.application.name }, blockedReasons: missing, impacts: ['通过后模板将进入客户端可发送资源候选', '实际发送仍需通过应用、签名、路由和余额校验'], materialSummary: { contentLength: item.content.length, signature: item.signature?.name ?? '未绑定' }, }); } private readTarget(type: ReviewTargetType, id: string): Promise { return type === 'signature' ? this.prisma.smsSignature.findUnique({ where: { id } }) : this.prisma.smsTemplate.findUnique({ where: { id } }); } } function reviewPreflight(type: ReviewTargetType, item: SmsSignature | SmsTemplate, detail: { identity: Record; blockedReasons: string[]; impacts: string[]; materialSummary: Record }) { const statusBlocked = item.auditStatus !== 'pending' ? [`当前状态为${item.auditStatus},仅待审核对象可决策`] : []; const blockedReasons = [...statusBlocked, ...detail.blockedReasons]; return { type, id: item.id, tenantId: item.tenantId, status: item.auditStatus, expectedUpdatedAt: item.updatedAt.toISOString(), identity: detail.identity, impacts: detail.impacts, materialSummary: detail.materialSummary, blockedReasons, allowedActions: item.auditStatus === 'pending' ? (detail.blockedReasons.length ? ['reject'] : ['approve', 'reject']) : [], }; } function normalizeIdempotencyKey(value: string) { const key = value?.trim(); if (!key || key.length > 100 || !/^[a-zA-Z0-9:_-]+$/.test(key)) throw new BadRequestException('Invalid idempotencyKey'); return key; } function asRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } function reportRequirementFields(payload: Record) { const snapshot = asRecord(payload.reportRequirementSnapshot); if (!Array.isArray(snapshot.fields)) return []; return snapshot.fields.flatMap((value) => { const field = asRecord(value); const code = typeof field.code === 'string' ? field.code.trim() : ''; if (!code) return []; const reportTypes = Array.isArray(field.reportTypes) ? field.reportTypes.filter((type): type is string => typeof type === 'string') : []; return [{ code, name: typeof field.name === 'string' && field.name.trim() ? field.name.trim() : code, required: field.required === true, reportTypes, }]; }); } function hasReportValue(value: unknown) { if (value && typeof value === 'object' && !Array.isArray(value)) { const record = asRecord(value); return Boolean(record.fileObjectId || record.fieldValue || record.value); } return value !== undefined && value !== null && String(value).trim().length > 0; }