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
@@ -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> : {};
}
+6 -2
View File
@@ -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 {}
+66 -1
View File
@@ -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' };
+40 -4
View File
@@ -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)) {