Files
lislgosms/api/src/sms-config/review-governance.service.ts
T

189 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}