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
@@ -0,0 +1,158 @@
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 profile = asRecord(payload.signatureProfile);
const missing: string[] = [];
if (!item.applicationId) missing.push('未绑定短信应用');
if (!String(profile.companyName ?? '').trim()) missing.push('缺少公司名称');
if (!String(profile.creditCode ?? '').trim()) missing.push('缺少统一社会信用代码');
if (!String(profile.legalPersonName ?? '').trim()) missing.push('缺少法人姓名');
if (!String(profile.responsibleName ?? '').trim()) missing.push('缺少责任人姓名');
if (!String(profile.responsiblePhone ?? '').trim()) missing.push('缺少责任人手机号');
const profileHasFile = Object.values(profile).some((value) => Boolean(asRecord(value).fileObjectId));
if (!profileHasFile && item.materials.length === 0) missing.push('缺少资质文件');
return reviewPreflight('signature', item, {
identity: { name: item.name, id: item.id, tenant: item.tenant.name, application: item.application?.name ?? '未绑定应用' },
blockedReasons: missing,
impacts: ['通过后签名将进入报备资格链路', '已绑定模板和后续发送资格可能受此决定影响'],
materialSummary: { qualificationFiles: item.materials.length + (profileHasFile ? 1 : 0), 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> : {};
}