feat: harden platform workflows and UI governance
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { ReviewDecisionDto, ReviewGovernanceService } from './review-governance.service';
|
||||
import { DeleteTargetDto, DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
|
||||
import { CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, SmsConfigService, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.service';
|
||||
|
||||
@ApiTags('admin-sms-config')
|
||||
@Controller('admin')
|
||||
export class AdminSmsConfigController {
|
||||
constructor(private readonly smsConfig: SmsConfigService) {}
|
||||
constructor(private readonly smsConfig: SmsConfigService, private readonly reviewGovernance: ReviewGovernanceService, private readonly deletions: DeletionGovernanceService) {}
|
||||
|
||||
@Get('enterprise-applications')
|
||||
listApplications(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('status') status?: string) {
|
||||
@@ -126,8 +129,8 @@ export class AdminSmsConfigController {
|
||||
|
||||
@Post('signatures/:id/approve')
|
||||
@RequireRecentAuthentication()
|
||||
approveSignature(@Param('id') signatureId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.approveSignature(signatureId, body);
|
||||
approveSignature(@Param('id') signatureId: string, @Body() body: ReviewDecisionDto, @CurrentSessionUserId() reviewerId?: string) {
|
||||
return this.reviewGovernance.decide('signature', signatureId, { ...body, decision: 'approve', reviewerId });
|
||||
}
|
||||
|
||||
@Post('signatures/:id/reject')
|
||||
@@ -138,8 +141,8 @@ export class AdminSmsConfigController {
|
||||
|
||||
@Post('templates/:id/approve')
|
||||
@RequireRecentAuthentication()
|
||||
approveTemplate(@Param('id') templateId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.approveTemplate(templateId, body);
|
||||
approveTemplate(@Param('id') templateId: string, @Body() body: ReviewDecisionDto, @CurrentSessionUserId() reviewerId?: string) {
|
||||
return this.reviewGovernance.decide('template', templateId, { ...body, decision: 'approve', reviewerId });
|
||||
}
|
||||
|
||||
@Post('templates/:id/reject')
|
||||
@@ -156,13 +159,15 @@ export class AdminSmsConfigController {
|
||||
|
||||
@Post('enterprise-signatures/:id/status')
|
||||
@RequireRecentAuthentication()
|
||||
changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto) {
|
||||
changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto & DeleteTargetDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
if (body.status === 'deleted') return this.deletions.delete('signature', signatureId, { ...body, operatorId });
|
||||
return this.smsConfig.changeSignatureStatus(signatureId, body);
|
||||
}
|
||||
|
||||
@Post('enterprise-templates/:id/status')
|
||||
@RequireRecentAuthentication()
|
||||
changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto) {
|
||||
changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto & DeleteTargetDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
if (body.status === 'deleted') return this.deletions.delete('template', templateId, { ...body, operatorId });
|
||||
return this.smsConfig.changeTemplateStatus(templateId, body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { DeleteTargetDto, DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
|
||||
import {
|
||||
CreateSignatureMaterialDto,
|
||||
CreateSmsApplicationDto,
|
||||
@@ -18,7 +20,7 @@ import {
|
||||
@ApiTags('client-sms-config')
|
||||
@Controller('client')
|
||||
export class ClientSmsConfigController {
|
||||
constructor(private readonly smsConfig: SmsConfigService) {}
|
||||
constructor(private readonly smsConfig: SmsConfigService, private readonly deletions: DeletionGovernanceService) {}
|
||||
|
||||
@Get('applications')
|
||||
listApplications(@TenantId() tenantId?: string) {
|
||||
@@ -115,7 +117,8 @@ export class ClientSmsConfigController {
|
||||
}
|
||||
|
||||
@Post('signatures/:id/status')
|
||||
async changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) {
|
||||
async changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto & DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) {
|
||||
if (body.status === 'deleted') return this.deletions.delete('signature', signatureId, { ...body, operatorId }, tenantId);
|
||||
await this.smsConfig.changeSignatureStatus(signatureId, body, tenantId);
|
||||
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
|
||||
}
|
||||
@@ -132,7 +135,7 @@ export class ClientSmsConfigController {
|
||||
|
||||
@Put('templates/:id')
|
||||
updateTemplate(@Param('id') templateId: string, @Body() body: UpdateSmsTemplateDto, @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.updateTemplate(templateId, body, tenantId);
|
||||
return this.smsConfig.updateClientTemplate(templateId, body, tenantId);
|
||||
}
|
||||
|
||||
@Post('templates/:id/submit')
|
||||
@@ -141,7 +144,8 @@ export class ClientSmsConfigController {
|
||||
}
|
||||
|
||||
@Post('templates/:id/status')
|
||||
changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) {
|
||||
changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto & DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) {
|
||||
if (body.status === 'deleted') return this.deletions.delete('template', templateId, { ...body, operatorId }, tenantId);
|
||||
return this.smsConfig.changeTemplateStatus(templateId, body, tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { ReviewDecisionDto, ReviewGovernanceService, ReviewTargetType } from './review-governance.service';
|
||||
|
||||
@ApiTags('review-governance')
|
||||
@Controller('admin/reviews')
|
||||
export class ReviewGovernanceController {
|
||||
constructor(private readonly reviews: ReviewGovernanceService) {}
|
||||
|
||||
@Get(':type/:id/preflight')
|
||||
preflight(@Param('type') type: ReviewTargetType, @Param('id') id: string) {
|
||||
return this.reviews.preflight(type, id);
|
||||
}
|
||||
|
||||
@Post(':type/:id/decision')
|
||||
@RequireRecentAuthentication()
|
||||
decide(
|
||||
@Param('type') type: ReviewTargetType,
|
||||
@Param('id') id: string,
|
||||
@Body() body: ReviewDecisionDto,
|
||||
@CurrentSessionUserId() reviewerId?: string,
|
||||
) {
|
||||
return this.reviews.decide(type, id, { ...body, reviewerId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { ReviewGovernanceService } from './review-governance.service';
|
||||
|
||||
function signature(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '签名A', purpose: null,
|
||||
drainageInfo: { signatureProfile: { companyName: '企业A', creditCode: '9133', legalPersonName: '法人', responsibleName: '责任人', responsiblePhone: '13800000000', credentialFile: { fileObjectId: 'file-1' } } },
|
||||
auditStatus: 'pending', reportStatus: 'waiting_material', rejectReason: null, materialVersion: 1,
|
||||
pendingReport: true, reportChangedAt: new Date(), createdAt: new Date(), updatedAt: new Date('2026-07-21T08:00:00.000Z'),
|
||||
tenant: { id: 'tenant-1', name: '企业A' }, application: { id: 'app-1', name: '应用A' }, materials: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function prismaMock() {
|
||||
const current = signature();
|
||||
const prisma: Record<string, any> = {
|
||||
smsSignature: {
|
||||
findUnique: jest.fn().mockResolvedValue(current),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
smsTemplate: { findUnique: jest.fn(), updateMany: jest.fn() },
|
||||
auditRecord: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockResolvedValue({ id: 'audit-1', statusAfter: 'approved' }),
|
||||
},
|
||||
};
|
||||
prisma.$transaction = jest.fn(async (callback: (tx: typeof prisma) => unknown) => callback(prisma));
|
||||
return prisma;
|
||||
}
|
||||
|
||||
describe('ReviewGovernanceService', () => {
|
||||
it('blocks approval when required signature qualification is incomplete', async () => {
|
||||
const prisma = prismaMock();
|
||||
prisma.smsSignature.findUnique.mockResolvedValue(signature({ applicationId: null, application: null, drainageInfo: {}, materials: [] }));
|
||||
const service = new ReviewGovernanceService(prisma as never);
|
||||
|
||||
const result = await service.preflight('signature', 'sig-1');
|
||||
|
||||
expect(result.allowedActions).toEqual(['reject']);
|
||||
expect(result.blockedReasons).toEqual(expect.arrayContaining(['未绑定短信应用', '缺少公司名称', '缺少资质文件']));
|
||||
});
|
||||
|
||||
it('atomically approves the expected version and returns an audit operation id', async () => {
|
||||
const prisma = prismaMock();
|
||||
const service = new ReviewGovernanceService(prisma as never);
|
||||
|
||||
await expect(service.decide('signature', 'sig-1', {
|
||||
decision: 'approve', expectedUpdatedAt: '2026-07-21T08:00:00.000Z', idempotencyKey: 'review:sig-1:key-1', reviewerId: 'admin-1',
|
||||
})).resolves.toEqual(expect.objectContaining({ operationId: 'audit-1', replayed: false, status: 'approved' }));
|
||||
expect(prisma.smsSignature.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'sig-1', auditStatus: 'pending', updatedAt: new Date('2026-07-21T08:00:00.000Z') },
|
||||
data: { auditStatus: 'approved', rejectReason: null },
|
||||
});
|
||||
expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ reviewerId: 'admin-1', action: 'approve', statusBefore: 'pending', statusAfter: 'approved' }) });
|
||||
});
|
||||
|
||||
it('rejects a concurrent stale decision without overwriting the winner', async () => {
|
||||
const prisma = prismaMock();
|
||||
prisma.smsSignature.updateMany.mockResolvedValue({ count: 0 });
|
||||
const service = new ReviewGovernanceService(prisma as never);
|
||||
|
||||
await expect(service.decide('signature', 'sig-1', {
|
||||
decision: 'approve', expectedUpdatedAt: '2026-07-21T08:00:00.000Z', idempotencyKey: 'review:sig-1:key-2', reviewerId: 'admin-1',
|
||||
})).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(prisma.auditRecord.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replays a completed idempotency key without a second status update', async () => {
|
||||
const prisma = prismaMock();
|
||||
prisma.auditRecord.findFirst.mockResolvedValue({ id: 'audit-existing', action: 'approve', statusAfter: 'approved' });
|
||||
const service = new ReviewGovernanceService(prisma as never);
|
||||
|
||||
await expect(service.decide('signature', 'sig-1', {
|
||||
decision: 'approve', expectedUpdatedAt: '2026-07-21T08:00:00.000Z', idempotencyKey: 'review:sig-1:same', reviewerId: 'admin-1',
|
||||
})).resolves.toEqual(expect.objectContaining({ operationId: 'audit-existing', replayed: true }));
|
||||
expect(prisma.smsSignature.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires a valid server session reviewer and rejects malformed idempotency keys', async () => {
|
||||
const service = new ReviewGovernanceService(prismaMock() as never);
|
||||
await expect(service.decide('signature', 'sig-1', { decision: 'approve', expectedUpdatedAt: new Date().toISOString(), idempotencyKey: '../bad' }))
|
||||
.rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -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> : {};
|
||||
}
|
||||
@@ -2,10 +2,14 @@ import { Module } from '@nestjs/common';
|
||||
import { AdminSmsConfigController } from './admin-sms-config.controller';
|
||||
import { ClientSmsConfigController } from './client-sms-config.controller';
|
||||
import { SmsConfigService } from './sms-config.service';
|
||||
import { ReviewGovernanceController } from './review-governance.controller';
|
||||
import { ReviewGovernanceService } from './review-governance.service';
|
||||
import { DeletionGovernanceModule } from '../deletion-governance/deletion-governance.module';
|
||||
|
||||
@Module({
|
||||
controllers: [ClientSmsConfigController, AdminSmsConfigController],
|
||||
providers: [SmsConfigService],
|
||||
imports: [DeletionGovernanceModule],
|
||||
controllers: [ClientSmsConfigController, AdminSmsConfigController, ReviewGovernanceController],
|
||||
providers: [SmsConfigService, ReviewGovernanceService],
|
||||
exports: [SmsConfigService],
|
||||
})
|
||||
export class SmsConfigModule {}
|
||||
|
||||
@@ -543,7 +543,16 @@ describe('SmsConfigService', () => {
|
||||
}),
|
||||
});
|
||||
expect(prisma.cmppDownstreamConnection.deleteMany).toHaveBeenCalledWith({
|
||||
where: { status: 'connected', lastHeartbeatAt: { lt: new Date('2026-07-11T10:58:30.000Z') } },
|
||||
where: {
|
||||
status: 'connected',
|
||||
OR: [
|
||||
{ lastHeartbeatAt: { lt: new Date('2026-07-11T10:58:30.000Z') } },
|
||||
{
|
||||
lastHeartbeatAt: null,
|
||||
connectedAt: { lt: new Date('2026-07-11T10:58:30.000Z') },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
@@ -554,6 +563,27 @@ describe('SmsConfigService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('prunes expired downstream connections whose heartbeat was never recorded', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
const now = new Date('2026-07-21T04:00:00.000Z');
|
||||
|
||||
await service.markTimedOutDownstreamConnections(now);
|
||||
|
||||
expect(prisma.cmppDownstreamConnection.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
status: 'connected',
|
||||
OR: [
|
||||
{ lastHeartbeatAt: { lt: new Date('2026-07-21T03:58:30.000Z') } },
|
||||
{
|
||||
lastHeartbeatAt: null,
|
||||
connectedAt: { lt: new Date('2026-07-21T03:58:30.000Z') },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects connection heartbeats after an IP allowlist change or connection-limit reduction', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsApplication.findUnique.mockResolvedValue({
|
||||
@@ -866,6 +896,41 @@ describe('SmsConfigService', () => {
|
||||
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets an approved signature to pending when key content is changed', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSignature.findUnique.mockResolvedValue({
|
||||
id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '【旧签名】',
|
||||
purpose: '通知', drainageInfo: {}, auditStatus: 'approved',
|
||||
});
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await service.updateSignature('sig-1', { name: '【新签名】' });
|
||||
|
||||
expect(prisma.smsSignature.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ auditStatus: 'pending', rejectReason: null }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('resets an approved template to pending when key content is changed', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsTemplate.findUnique.mockResolvedValue({
|
||||
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1',
|
||||
name: '模板A', content: '【签名A】验证码${code}', category: '验证码', auditStatus: 'approved',
|
||||
});
|
||||
const tx = {
|
||||
templateVariable: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }) },
|
||||
smsTemplate: { update: jest.fn().mockResolvedValue({ id: 'tpl-1', auditStatus: 'pending' }) },
|
||||
};
|
||||
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await service.updateTemplate('tpl-1', { content: '【签名A】您的验证码为${code}' });
|
||||
|
||||
expect(tx.smsTemplate.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ auditStatus: 'pending' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('creates real drainage materials and channel tasks after operations approval', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const pendingItem = { id: 'drainage-1', tenantId: 'tenant-1', signatureId: 'sig-1', applicationId: 'app-1', siteName: '官网', url: 'https://example.com', reportValues: { site_owner: '企业A' }, auditStatus: 'pending' };
|
||||
|
||||
@@ -729,7 +729,13 @@ export class SmsConfigService {
|
||||
const timeoutMs = getPositiveIntegerEnv('CMPP_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS', DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS);
|
||||
const cutoff = new Date(now.getTime() - timeoutMs);
|
||||
return this.prisma.cmppDownstreamConnection.deleteMany({
|
||||
where: { status: 'connected', lastHeartbeatAt: { lt: cutoff } },
|
||||
where: {
|
||||
status: 'connected',
|
||||
OR: [
|
||||
{ lastHeartbeatAt: { lt: cutoff } },
|
||||
{ lastHeartbeatAt: null, connectedAt: { lt: cutoff } },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1005,14 +1011,19 @@ export class SmsConfigService {
|
||||
const drainageInfo = data.drainageInfo
|
||||
? await this.withReportRequirementSnapshot(applicationId, data.drainageInfo)
|
||||
: undefined;
|
||||
const materialChanged = (data.applicationId !== undefined && data.applicationId !== signature.applicationId)
|
||||
|| (data.name !== undefined && normalizeSmsSignature(data.name) !== normalizeSmsSignature(signature.name))
|
||||
|| (data.purpose !== undefined && data.purpose !== signature.purpose)
|
||||
|| (data.drainageInfo !== undefined && JSON.stringify(data.drainageInfo) !== JSON.stringify(signature.drainageInfo ?? null));
|
||||
const auditStatus = materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus;
|
||||
const updated = await this.prisma.smsSignature.update({
|
||||
where: { id: signatureId },
|
||||
data: {
|
||||
applicationId: data.applicationId,
|
||||
name: data.name,
|
||||
purpose: data.purpose,
|
||||
auditStatus: data.auditStatus,
|
||||
rejectReason: data.auditStatus === 'pending' ? null : undefined,
|
||||
auditStatus,
|
||||
rejectReason: auditStatus === 'pending' ? null : undefined,
|
||||
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
||||
materialVersion: { increment: 1 },
|
||||
pendingReport: true,
|
||||
@@ -1376,6 +1387,12 @@ export class SmsConfigService {
|
||||
const variables = data.content !== undefined || data.variables !== undefined
|
||||
? validateAndNormalizeTemplateVariables(data.content ?? template.content, data.variables)
|
||||
: undefined;
|
||||
const materialChanged = (data.applicationId !== undefined && data.applicationId !== template.applicationId)
|
||||
|| (data.signatureId !== undefined && data.signatureId !== template.signatureId)
|
||||
|| (data.content !== undefined && data.content !== template.content)
|
||||
|| (data.category !== undefined && data.category !== template.category)
|
||||
|| data.variables !== undefined;
|
||||
const auditStatus = materialChanged && template.auditStatus === 'approved' ? 'pending' : data.auditStatus;
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (variables) {
|
||||
await tx.templateVariable.deleteMany({ where: { templateId } });
|
||||
@@ -1388,7 +1405,8 @@ export class SmsConfigService {
|
||||
name: data.name,
|
||||
content: data.content,
|
||||
category: data.category,
|
||||
auditStatus: data.auditStatus,
|
||||
auditStatus,
|
||||
rejectReason: auditStatus === 'pending' ? null : undefined,
|
||||
billingUnits: data.content ? estimateBillingUnits(data.content) : undefined,
|
||||
variables: variables ? {
|
||||
create: variables.map((variable) => ({
|
||||
@@ -1403,6 +1421,24 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateClientTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) {
|
||||
const current = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Template not found');
|
||||
if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) {
|
||||
throw new BadRequestException('当前审核状态不允许修改模板');
|
||||
}
|
||||
const updated = await this.updateTemplate(templateId, { ...data, auditStatus: 'pending' }, tenantId);
|
||||
await this.createAuditRecord({
|
||||
tenantId: current.tenantId,
|
||||
targetType: 'sms_template',
|
||||
targetId: templateId,
|
||||
action: 'client_update_submit',
|
||||
statusBefore: current.auditStatus,
|
||||
statusAfter: 'pending',
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async submitTemplate(templateId: string, tenantId?: string) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template || (tenantId && template.tenantId !== tenantId)) {
|
||||
|
||||
Reference in New Issue
Block a user