feat: strengthen risk controls and review workflows

This commit is contained in:
hectorzhao
2026-07-26 13:08:12 +08:00
parent b461532075
commit 2ce682c3fc
34 changed files with 2167 additions and 390 deletions
+256 -13
View File
@@ -1,4 +1,4 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomInt, randomUUID } from 'node:crypto';
import { isIpAllowed } from '../common/ip-allowlist';
@@ -24,7 +24,6 @@ export interface CreateSmsApplicationDto {
dailyLimit?: number;
customerUnitPrice?: number;
queuePriority?: string;
maxPhonesPerTask?: number;
templateMismatchMode?: string;
downstreamReceiptRetryEnabled?: boolean;
downstreamUplinkRetryEnabled?: boolean;
@@ -112,6 +111,7 @@ export interface StatusChangeDto {
status?: string;
operatorId?: string;
reason?: string;
force?: boolean;
}
export interface TemplateListQuery {
@@ -159,11 +159,31 @@ type ApplicationQueuePriority = typeof APPLICATION_QUEUE_PRIORITIES[number];
const APPLICATION_INTERFACE_TYPES = ['cmpp20'] as const;
type ApplicationInterfaceType = typeof APPLICATION_INTERFACE_TYPES[number];
const DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS = 90_000;
const APPLICATION_DISABLE_GRACE_MS = 72 * 60 * 60 * 1_000;
const DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS = 60_000;
const UNRESOLVED_DOWNSTREAM_STATUSES = ['pending', 'awaiting_ack', 'failed', 'manual_requeueing'] as const;
@Injectable()
export class SmsConfigService {
export class SmsConfigService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(SmsConfigService.name);
private applicationDisableTimer?: ReturnType<typeof setInterval>;
private applicationDisableScanRunning = false;
constructor(private readonly prisma: PrismaService) {}
onModuleInit() {
this.applicationDisableTimer = setInterval(
() => void this.runApplicationDisableScan(),
getPositiveIntegerEnv('APPLICATION_DISABLE_SCAN_INTERVAL_MS', DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS),
);
this.applicationDisableTimer.unref?.();
void this.runApplicationDisableScan();
}
onModuleDestroy() {
if (this.applicationDisableTimer) clearInterval(this.applicationDisableTimer);
}
async listApplications(queryOrTenantId?: string | ApplicationListQuery) {
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
if (query.includeConnections) {
@@ -202,6 +222,9 @@ export class SmsConfigService {
_count: { _all: true },
}),
]);
const disablingDetails = new Map((await Promise.all(applications
.filter((application) => application.status === 'disabling')
.map(async (application) => [application.id, await this.getApplicationDeactivationPreview(application.id)] as const))));
return applications.map((application) => {
const appConnections = connections.filter((connection) => connection.applicationId === application.id);
const appStats = messageStats.filter((item) => item.applicationId === application.id);
@@ -213,6 +236,7 @@ export class SmsConfigService {
cmppStatus: normalizeApplicationCmppStatus(appConnections, application.status),
sentToday: todayTotal,
deliveryRate: todayTotal > 0 ? Number(((delivered / todayTotal) * 100).toFixed(1)) : 0,
deactivation: disablingDetails.get(application.id) ?? null,
};
}).sort((left, right) => right.sentToday - left.sentToday
|| left.name.localeCompare(right.name, 'zh-CN')
@@ -389,7 +413,6 @@ export class SmsConfigService {
dailyLimit: getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'),
customerUnitPrice: data.customerUnitPrice ?? 0,
queuePriority,
maxPhonesPerTask: data.maxPhonesPerTask ?? 10000,
templateMismatchMode: data.templateMismatchMode ?? 'reject',
downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled ?? true,
downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled ?? true,
@@ -459,7 +482,6 @@ export class SmsConfigService {
dailyLimit: data.dailyLimit === undefined ? undefined : getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'),
customerUnitPrice: data.customerUnitPrice,
queuePriority,
maxPhonesPerTask: data.maxPhonesPerTask,
templateMismatchMode: data.templateMismatchMode,
downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled,
downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled,
@@ -567,13 +589,108 @@ export class SmsConfigService {
throw new NotFoundException('Application not found');
}
const status = data.status ?? 'disabled';
const updated = await this.prisma.smsApplication.update({ where: { id: applicationId }, data: { status } });
await this.writeOperationLog(application.tenantId, data.operatorId, `sms_application.${status}`, 'sms_application', applicationId, {
statusBefore: application.status,
statusAfter: status,
reason: data.reason,
if (status === 'active') {
const updated = await this.prisma.smsApplication.update({
where: { id: applicationId },
data: { status: 'active', disablingAt: null, autoDisableAt: null, disableReason: null },
});
await this.writeApplicationStatusLog(application, data, 'active', {});
return updated;
}
if (!['disabled', 'disabling', 'deleted'].includes(status)) {
throw new BadRequestException(`不支持的企业应用状态:${status}`);
}
const preview = await this.getApplicationDeactivationPreview(applicationId);
if ((status === 'disabling' || status === 'disabled') && preview.totalOutstanding > 0 && !data.force) {
const disablingAt = new Date();
const autoDisableAt = new Date(disablingAt.getTime() + APPLICATION_DISABLE_GRACE_MS);
const updated = await this.prisma.smsApplication.update({
where: { id: applicationId },
data: {
status: 'disabling',
disablingAt,
autoDisableAt,
disableReason: data.reason?.trim() || '等待未完成回执清算',
},
});
await this.writeApplicationStatusLog(application, data, 'disabling', { preview, disablingAt, autoDisableAt });
return { ...updated, deactivation: { ...preview, disablingAt, autoDisableAt } };
}
const finalStatus = status === 'deleted' ? 'deleted' : 'disabled';
const abandonReason = status === 'deleted'
? '企业应用已删除,放弃剩余下游投递'
: data.force
? '运营强制停用企业应用,放弃剩余下游投递'
: '企业应用无待清算数据,完成停用';
const abandoned = await this.abandonApplicationDeliveries(applicationId, abandonReason);
const updated = await this.prisma.smsApplication.update({
where: { id: applicationId },
data: {
status: finalStatus,
disablingAt: null,
autoDisableAt: null,
disableReason: data.reason?.trim() || abandonReason,
},
});
return updated;
const disconnect = await this.disconnectDownstreamAccount(application.cmppAccount, abandonReason);
await this.writeApplicationStatusLog(application, data, finalStatus, { preview, abandoned, disconnect });
return { ...updated, deactivation: null, abandoned, disconnect };
}
async getApplicationDeactivationPreview(applicationId: string) {
const application = await this.prisma.smsApplication.findUnique({
where: { id: applicationId },
select: {
id: true,
status: true,
disablingAt: true,
autoDisableAt: true,
disableReason: true,
},
});
if (!application) throw new NotFoundException('Application not found');
const [
awaitingSupplierReceipt,
waitingToSend,
awaitingClientAck,
retryableFailures,
pendingUplinks,
activeConnections,
] = await Promise.all([
this.prisma.smsMessageRecord.count({
where: { applicationId, status: { in: ['submitted', 'unknown'] }, receiptStatus: null },
}),
this.prisma.cmppDownstreamDelivery.count({
where: { applicationId, deliveryType: 'receipt', status: { in: ['pending', 'manual_requeueing'] } },
}),
this.prisma.cmppDownstreamDelivery.count({
where: { applicationId, deliveryType: 'receipt', status: 'awaiting_ack' },
}),
this.prisma.cmppDownstreamDelivery.count({
where: { applicationId, deliveryType: 'receipt', status: 'failed', retryEnabled: true },
}),
this.prisma.cmppDownstreamDelivery.count({
where: { applicationId, deliveryType: 'uplink', status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } },
}),
this.prisma.cmppDownstreamConnection.count({
where: { applicationId, status: 'connected' },
}),
]);
return {
status: application.status,
reason: application.disableReason,
disablingAt: application.disablingAt,
autoDisableAt: application.autoDisableAt,
awaitingSupplierReceipt,
waitingToSend,
awaitingClientAck,
retryableFailures,
pendingUplinks,
activeConnections,
totalOutstanding: awaitingSupplierReceipt + waitingToSend + awaitingClientAck + retryableFailures + pendingUplinks,
};
}
async listApplicationConnections(applicationId: string) {
@@ -690,7 +807,7 @@ export class SmsConfigService {
});
return { connectionId: data.connectionId, status: 'disconnected', deleted: Boolean(existing) };
}
if (!application.interfaceEnabled || application.status !== 'active') {
if (!application.interfaceEnabled || !['active', 'disabling'].includes(application.status)) {
throw new ForbiddenException('CMPP interface is disabled for this application');
}
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
@@ -1658,6 +1775,132 @@ export class SmsConfigService {
return this.prisma.auditRecord.create({ data });
}
private async abandonApplicationDeliveries(applicationId: string, reason: string) {
const deliveries = await this.prisma.cmppDownstreamDelivery.findMany({
where: { applicationId, status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } },
select: { id: true },
});
const deliveryIds = deliveries.map((delivery) => delivery.id);
if (deliveryIds.length === 0) return 0;
await this.prisma.cmppDownstreamDeliveryAttempt.updateMany({
where: { deliveryId: { in: deliveryIds }, status: { in: ['awaiting_ack', 'sent'] } },
data: {
status: 'abandoned',
ackDeadlineAt: null,
failureType: 'application_disabled',
errorMessage: reason,
},
});
const updated = await this.prisma.cmppDownstreamDelivery.updateMany({
where: { id: { in: deliveryIds }, status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } },
data: {
status: 'abandoned',
retryEnabled: false,
nextRetryAt: null,
ackDeadlineAt: null,
lastError: reason,
},
});
return updated.count;
}
private async disconnectDownstreamAccount(account: string, reason: string) {
const baseUrl = process.env.GATEWAY_CONTROL_URL?.trim() || 'http://127.0.0.1:8090';
try {
const response = await fetch(`${baseUrl}/downstream/connections/disconnect`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ account, reason }),
signal: AbortSignal.timeout(10_000),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(`Gateway returned ${response.status}: ${responseText}`);
}
return responseText ? JSON.parse(responseText) as { account: string; disconnected: number } : { account, disconnected: 0 };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to disconnect downstream CMPP account ${account}: ${message}`);
return { account, disconnected: 0, error: message };
}
}
private writeApplicationStatusLog(
application: { id: string; tenantId: string; status: string },
data: StatusChangeDto,
statusAfter: string,
detail: Record<string, unknown>,
) {
return this.writeOperationLog(
application.tenantId,
data.operatorId,
`sms_application.${statusAfter}`,
'sms_application',
application.id,
{
statusBefore: application.status,
statusAfter,
reason: data.reason,
force: Boolean(data.force),
...JSON.parse(JSON.stringify(detail)) as Record<string, unknown>,
},
);
}
private async runApplicationDisableScan() {
if (this.applicationDisableScanRunning) return;
this.applicationDisableScanRunning = true;
try {
const applications = await this.prisma.smsApplication.findMany({
where: { status: 'disabling' },
select: { id: true, tenantId: true, cmppAccount: true, status: true, autoDisableAt: true },
take: 500,
});
const now = new Date();
for (const application of applications) {
const preview = await this.getApplicationDeactivationPreview(application.id);
if (preview.totalOutstanding === 0) {
await this.finalizeDisablingApplication(application, false, '待处理回执已清算完成,系统自动停用', preview);
} else if (application.autoDisableAt && application.autoDisableAt <= now) {
await this.finalizeDisablingApplication(application, true, '进入停用中状态已满72小时,系统自动放弃剩余回执', preview);
}
}
} catch (error) {
this.logger.error(`Application disabling scan failed: ${error instanceof Error ? error.message : String(error)}`);
} finally {
this.applicationDisableScanRunning = false;
}
}
private async finalizeDisablingApplication(
application: { id: string; tenantId: string; cmppAccount: string; status: string },
abandonOutstanding: boolean,
reason: string,
preview: Awaited<ReturnType<SmsConfigService['getApplicationDeactivationPreview']>>,
) {
const claimed = await this.prisma.smsApplication.updateMany({
where: { id: application.id, status: 'disabling' },
data: {
status: 'disabled',
disablingAt: null,
autoDisableAt: null,
disableReason: reason,
},
});
if (claimed.count !== 1) return false;
const abandoned = abandonOutstanding
? await this.abandonApplicationDeliveries(application.id, reason)
: 0;
const disconnect = await this.disconnectDownstreamAccount(application.cmppAccount, reason);
await this.writeApplicationStatusLog(application, { reason, force: abandonOutstanding }, 'disabled', {
preview,
abandoned,
disconnect,
automatic: true,
});
return true;
}
private writeOperationLog(
tenantId: string,
userId: string | undefined,
@@ -1837,7 +2080,7 @@ function getPositiveInteger(value: number | undefined, fallback: number, fieldNa
}
function normalizeApplicationCmppStatus(connections: Array<{ status: string }>, applicationStatus: string) {
if (applicationStatus !== 'active') {
if (!['active', 'disabling'].includes(applicationStatus)) {
return 'inactive';
}
if (connections.some((connection) => connection.status === 'connected')) {