189 lines
8.9 KiB
TypeScript
189 lines
8.9 KiB
TypeScript
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<SmsSignature | SmsTemplate | null> {
|
||
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<string, string>; blockedReasons: string[]; impacts: string[]; materialSummary: Record<string, string | number> }) {
|
||
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<string, unknown> {
|
||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||
}
|
||
|
||
function reportRequirementFields(payload: Record<string, unknown>) {
|
||
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;
|
||
}
|