feat: add phone frequency controls and modularize codebase
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
||||
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||
import { ChannelConnectionService } from './channel-connection.service';
|
||||
|
||||
/** R5 channel domain service composed behind ChannelsService. */
|
||||
export class ChannelConfigurationService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {}
|
||||
|
||||
listChannels() {
|
||||
return this.prisma.smsChannel.findMany({
|
||||
include: { connectionStates: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async listChannelsPage(query: { keyword?: string; carrier?: string; status?: string; page?: number; pageSize?: number }) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||
const where: Prisma.SmsChannelWhereInput = {
|
||||
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
carrier: query.carrier && query.carrier !== 'all' ? query.carrier : undefined,
|
||||
name: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.smsChannel.findMany({
|
||||
where,
|
||||
include: { connectionStates: true },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.smsChannel.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async createChannel(data: CreateChannelDto) {
|
||||
assertMoneyUnits(data.unitPrice ?? 0, '通道单价');
|
||||
const missingFields = ['code', 'name', 'gatewayHost', 'account', 'passwordCipher', 'srcId'].filter((field) => {
|
||||
const value = data[field as keyof CreateChannelDto];
|
||||
return value === undefined || value === null || value === '';
|
||||
});
|
||||
if (missingFields.length > 0) {
|
||||
throw new BadRequestException(`Missing required channel fields: ${missingFields.join(', ')}`);
|
||||
}
|
||||
const gatewayPort = Number(data.gatewayPort ?? 7890);
|
||||
if (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535) {
|
||||
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
|
||||
}
|
||||
const cmppVersion = normalizeCmppVersion(data.cmppVersion);
|
||||
const config = normalizeChannelRuntimeConfig(
|
||||
undefined,
|
||||
data.config,
|
||||
data.desiredConnections,
|
||||
data.windowSize,
|
||||
data.heartbeatIntervalSeconds,
|
||||
data.heartbeatMissThreshold,
|
||||
);
|
||||
const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond);
|
||||
const channel = await this.prisma.smsChannel.create({
|
||||
data: {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
carrier: data.carrier,
|
||||
sendRegion: data.sendRegion ?? '全国',
|
||||
protocol: 'CMPP',
|
||||
gatewayHost: data.gatewayHost,
|
||||
gatewayPort,
|
||||
enterpriseCode: data.enterpriseCode,
|
||||
account: data.account,
|
||||
passwordCipher: data.passwordCipher,
|
||||
srcId: data.srcId,
|
||||
cmppVersion,
|
||||
rateLimitPerSecond,
|
||||
unitPrice: data.unitPrice ?? 0,
|
||||
status: data.status ?? 'active',
|
||||
config: config as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
if (channel.status === 'active') {
|
||||
await this.connection.requestChannelConnection(channel, 'channel_created');
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
async updateChannel(channelId: string, data: UpdateChannelDto) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
if (data.unitPrice !== undefined) {
|
||||
assertMoneyUnits(data.unitPrice, '通道单价');
|
||||
}
|
||||
const gatewayPort = data.gatewayPort === undefined ? undefined : Number(data.gatewayPort);
|
||||
if (gatewayPort !== undefined && (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535)) {
|
||||
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
|
||||
}
|
||||
const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion);
|
||||
const config = data.config !== undefined
|
||||
|| data.desiredConnections !== undefined
|
||||
|| data.windowSize !== undefined
|
||||
|| data.heartbeatIntervalSeconds !== undefined
|
||||
|| data.heartbeatMissThreshold !== undefined
|
||||
? normalizeChannelRuntimeConfig(
|
||||
channel.config,
|
||||
data.config,
|
||||
data.desiredConnections,
|
||||
data.windowSize,
|
||||
data.heartbeatIntervalSeconds,
|
||||
data.heartbeatMissThreshold,
|
||||
)
|
||||
: undefined;
|
||||
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
|
||||
? undefined
|
||||
: normalizeChannelRateLimit(data.rateLimitPerSecond);
|
||||
const connectionConfigChanged = channelConnectionSettingsChanged(channel, {
|
||||
gatewayHost: data.gatewayHost ?? channel.gatewayHost,
|
||||
gatewayPort: gatewayPort ?? channel.gatewayPort,
|
||||
account: data.account ?? channel.account,
|
||||
passwordCipher: data.passwordCipher ?? channel.passwordCipher,
|
||||
cmppVersion: cmppVersion ?? channel.cmppVersion,
|
||||
config: config ?? channel.config,
|
||||
});
|
||||
const updated = await this.prisma.smsChannel.update({
|
||||
where: { id: channelId },
|
||||
data: {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
carrier: data.carrier,
|
||||
sendRegion: data.sendRegion,
|
||||
protocol: 'CMPP',
|
||||
gatewayHost: data.gatewayHost,
|
||||
gatewayPort,
|
||||
enterpriseCode: data.enterpriseCode,
|
||||
account: data.account,
|
||||
passwordCipher: data.passwordCipher,
|
||||
srcId: data.srcId,
|
||||
cmppVersion,
|
||||
rateLimitPerSecond,
|
||||
unitPrice: data.unitPrice,
|
||||
status: data.status,
|
||||
config: config as Prisma.InputJsonValue | undefined,
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
action: 'sms_channel.update',
|
||||
resource: 'sms_channel',
|
||||
resourceId: channelId,
|
||||
detail: {
|
||||
before: {
|
||||
code: channel.code,
|
||||
name: channel.name,
|
||||
carrier: channel.carrier,
|
||||
sendRegion: channel.sendRegion,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: channel.gatewayPort,
|
||||
enterpriseCode: channel.enterpriseCode,
|
||||
account: channel.account,
|
||||
srcId: channel.srcId,
|
||||
unitPrice: moneyToNumber(channel.unitPrice),
|
||||
},
|
||||
after: data,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
const updatedStatus = data.status ?? channel.status;
|
||||
if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) {
|
||||
await this.connection.requestChannelConnection(updated, 'channel_updated');
|
||||
} else if (updatedStatus !== 'active' && channel.status === 'active') {
|
||||
await this.connection.requestChannelDisconnection(updated, 'channel_disabled');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async changeChannelStatus(channelId: string, data: ChangeChannelStatusDto) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
const updated = await this.prisma.smsChannel.update({ where: { id: channelId }, data: { status: data.status } });
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
userId: data.operatorId,
|
||||
action: `sms_channel.${data.status}`,
|
||||
resource: 'sms_channel',
|
||||
resourceId: channelId,
|
||||
detail: {
|
||||
statusBefore: channel.status,
|
||||
statusAfter: data.status,
|
||||
reason: data.reason,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
if (data.status === 'active') {
|
||||
await this.connection.requestChannelConnection(updated, 'channel_enabled', data.operatorId);
|
||||
} else if (channel.status === 'active' || data.status === 'deleted') {
|
||||
await this.connection.requestChannelDisconnection(
|
||||
updated,
|
||||
data.status === 'deleted' ? 'channel_deleted' : 'channel_disabled',
|
||||
data.operatorId,
|
||||
);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
||||
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||
|
||||
|
||||
/** R5 channel domain service composed behind ChannelsService. */
|
||||
export class ChannelConnectionService {
|
||||
private readonly logger = new Logger(ChannelConnectionService.name);
|
||||
private gatewayConnectionQueue?: Queue;
|
||||
private gatewaySubmitQueue?: Queue;
|
||||
private redis?: IORedis;
|
||||
private connectionTimeoutTimer?: ReturnType<typeof setInterval>;
|
||||
private gatewayStartupReconnectTimer?: ReturnType<typeof setTimeout>;
|
||||
private gatewayReconcileTimer?: ReturnType<typeof setInterval>;
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (process.env.GATEWAY_CONNECTING_TIMEOUT_SCANNER_DISABLED !== 'true') {
|
||||
this.connectionTimeoutTimer = setInterval(() => {
|
||||
void this.markTimedOutConnectingChannels().catch((error) => {
|
||||
this.logger.error(`Failed to mark timed-out CMPP connections: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}, getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_SCAN_MS', DEFAULT_CONNECTING_TIMEOUT_SCAN_MS));
|
||||
this.connectionTimeoutTimer.unref?.();
|
||||
}
|
||||
this.gatewayStartupReconnectTimer = setTimeout(() => {
|
||||
void this.reconnectActiveChannelsAfterGatewayRestart();
|
||||
}, getPositiveIntegerEnv('GATEWAY_STARTUP_RECONNECT_DELAY_MS', DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS));
|
||||
this.gatewayStartupReconnectTimer.unref?.();
|
||||
if (process.env.GATEWAY_CONNECTION_RECONCILER_DISABLED !== 'true') {
|
||||
this.gatewayReconcileTimer = setInterval(() => {
|
||||
void this.reconcileGatewayConnections().catch((error) => {
|
||||
this.logger.error(`Failed to reconcile supplier connections: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}, getPositiveIntegerEnv('GATEWAY_CONNECTION_RECONCILE_INTERVAL_MS', DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS));
|
||||
this.gatewayReconcileTimer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.connectionTimeoutTimer) {
|
||||
clearInterval(this.connectionTimeoutTimer);
|
||||
}
|
||||
if (this.gatewayStartupReconnectTimer) {
|
||||
clearTimeout(this.gatewayStartupReconnectTimer);
|
||||
}
|
||||
if (this.gatewayReconcileTimer) {
|
||||
clearInterval(this.gatewayReconcileTimer);
|
||||
}
|
||||
await this.gatewayConnectionQueue?.close();
|
||||
await this.gatewaySubmitQueue?.close();
|
||||
this.redis?.disconnect();
|
||||
}
|
||||
|
||||
listChannelMetrics(channelId: string) {
|
||||
return this.prisma.channelHealthMetric.findMany({
|
||||
where: { channelId },
|
||||
orderBy: { windowStart: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
listChannelConnections(channelId: string) {
|
||||
return this.prisma.cmppConnectionState.findMany({
|
||||
where: { channelId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async listChannelConnectionLogs(channelId: string) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId }, select: { id: true } });
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
const [connectionStates, logs] = await Promise.all([
|
||||
this.prisma.cmppConnectionState.findMany({
|
||||
where: { channelId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 50,
|
||||
}),
|
||||
this.prisma.operationLog.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ resource: 'sms_channel', resourceId: channelId },
|
||||
{ resource: 'cmpp_connection', resourceId: { startsWith: `${channelId}:` } },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
channelId,
|
||||
connectionStates,
|
||||
logs: logs.map((log) => ({
|
||||
id: log.id,
|
||||
time: log.createdAt,
|
||||
event: normalizeLinkEvent(log.action),
|
||||
action: log.action,
|
||||
resourceId: log.resourceId,
|
||||
detail: log.detail,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
listTenantConnections(tenantId: string) {
|
||||
return this.prisma.cmppConnectionState.findMany({
|
||||
where: { tenantId },
|
||||
include: { channel: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async upsertConnectionState(data: UpsertConnectionStateDto) {
|
||||
const rawStatus = data.status;
|
||||
const status = normalizeGatewayConnectionStatus(rawStatus);
|
||||
if (data.applicationId) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } });
|
||||
if (!application) {
|
||||
throw new BadRequestException('applicationId does not reference an existing application');
|
||||
}
|
||||
if (data.tenantId && data.tenantId !== application.tenantId) {
|
||||
throw new BadRequestException('applicationId does not belong to tenantId');
|
||||
}
|
||||
data.tenantId = application.tenantId;
|
||||
}
|
||||
const payload = {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
status,
|
||||
desiredConnections: data.desiredConnections ?? 1,
|
||||
currentConnections: data.currentConnections ?? (status === 'connected' ? 1 : 0),
|
||||
lastConnectedAt: data.lastConnectedAt ? new Date(data.lastConnectedAt) : undefined,
|
||||
lastDisconnectedAt: data.lastDisconnectedAt ? new Date(data.lastDisconnectedAt) : undefined,
|
||||
lastHeartbeatAt: data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : undefined,
|
||||
reconnectCount: data.reconnectCount ?? 0,
|
||||
lastReconnectAttemptAt: data.lastReconnectAttemptAt ? new Date(data.lastReconnectAttemptAt) : undefined,
|
||||
nextReconnectAt: data.nextReconnectAt ? new Date(data.nextReconnectAt) : status === 'connected' ? null : undefined,
|
||||
lastErrorCategory: status === 'connected' ? null : data.lastErrorCategory,
|
||||
lastError: status === 'connected' ? null : data.lastError,
|
||||
};
|
||||
const existing = await this.prisma.cmppConnectionState.findFirst({
|
||||
where: {
|
||||
applicationId: data.applicationId ?? null,
|
||||
channelId: data.channelId,
|
||||
connectionId: data.connectionId,
|
||||
},
|
||||
});
|
||||
let state;
|
||||
if (existing) {
|
||||
state = await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data: payload });
|
||||
} else {
|
||||
try {
|
||||
state = await this.prisma.cmppConnectionState.create({
|
||||
data: {
|
||||
channelId: data.channelId,
|
||||
connectionId: data.connectionId,
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code !== 'P2002') {
|
||||
throw error;
|
||||
}
|
||||
const concurrent = await this.prisma.cmppConnectionState.findFirst({
|
||||
where: {
|
||||
applicationId: data.applicationId ?? null,
|
||||
channelId: data.channelId,
|
||||
connectionId: data.connectionId,
|
||||
},
|
||||
});
|
||||
if (!concurrent) {
|
||||
throw error;
|
||||
}
|
||||
state = await this.prisma.cmppConnectionState.update({ where: { id: concurrent.id }, data: payload });
|
||||
}
|
||||
}
|
||||
const action = normalizeConnectionAction(
|
||||
['heartbeat', 'active_test'].includes(rawStatus.toLowerCase()) ? rawStatus : status,
|
||||
);
|
||||
const heartbeatObservedAt = data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : new Date();
|
||||
const shouldWriteAudit = action !== 'heartbeat'
|
||||
|| !existing?.lastHeartbeatAt
|
||||
|| heartbeatObservedAt.getTime() - existing.lastHeartbeatAt.getTime() >= HEARTBEAT_AUDIT_INTERVAL_MS;
|
||||
if (shouldWriteAudit) {
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
action: `cmpp_connection.${action}`,
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${data.channelId}:${data.connectionId}`,
|
||||
detail: {
|
||||
status,
|
||||
applicationId: state.applicationId,
|
||||
desiredConnections: state.desiredConnections,
|
||||
currentConnections: state.currentConnections,
|
||||
lastError: state.lastError,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async markTimedOutConnectingChannels(now = new Date()) {
|
||||
const timeoutMs = getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_MS', DEFAULT_CONNECTING_TIMEOUT_MS);
|
||||
const cutoff = new Date(now.getTime() - timeoutMs);
|
||||
const lastError = `${CONNECTING_TIMEOUT_ERROR} after ${Math.round(timeoutMs / 1000)} seconds`;
|
||||
const states = await this.prisma.cmppConnectionState.findMany({
|
||||
where: {
|
||||
status: 'connecting',
|
||||
updatedAt: { lte: cutoff },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
applicationId: true,
|
||||
channelId: true,
|
||||
connectionId: true,
|
||||
desiredConnections: true,
|
||||
currentConnections: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
let failed = 0;
|
||||
for (const state of states) {
|
||||
const result = await this.prisma.cmppConnectionState.updateMany({
|
||||
where: {
|
||||
id: state.id,
|
||||
status: 'connecting',
|
||||
updatedAt: { lte: cutoff },
|
||||
},
|
||||
data: {
|
||||
status: 'failed',
|
||||
currentConnections: 0,
|
||||
lastDisconnectedAt: now,
|
||||
nextReconnectAt: now,
|
||||
lastErrorCategory: 'timeout',
|
||||
lastError,
|
||||
},
|
||||
});
|
||||
if (result.count === 0) {
|
||||
continue;
|
||||
}
|
||||
failed += result.count;
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: state.tenantId,
|
||||
action: 'cmpp_connection.failed',
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${state.channelId}:${state.connectionId}`,
|
||||
detail: {
|
||||
reason: 'connect_timeout',
|
||||
applicationId: state.applicationId,
|
||||
status: 'failed',
|
||||
previousStatus: 'connecting',
|
||||
desiredConnections: state.desiredConnections,
|
||||
currentConnectionsBefore: state.currentConnections,
|
||||
currentConnections: 0,
|
||||
timeoutMs,
|
||||
lastError,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { checked: states.length, failed };
|
||||
}
|
||||
|
||||
async requestChannelConnection(
|
||||
channel: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
gatewayHost: string;
|
||||
gatewayPort: number;
|
||||
account: string;
|
||||
passwordCipher: string;
|
||||
srcId: string;
|
||||
cmppVersion: string;
|
||||
rateLimitPerSecond: number;
|
||||
config?: Prisma.JsonValue | null;
|
||||
},
|
||||
reason: 'channel_created' | 'channel_enabled' | 'channel_updated' | 'gateway_restarted' | 'automatic_reconnect',
|
||||
operatorId?: string,
|
||||
) {
|
||||
const desiredConnections = getDesiredConnections(channel.config);
|
||||
const connectionId = defaultChannelConnectionId(channel.id);
|
||||
const existing = await this.prisma.cmppConnectionState.findFirst({
|
||||
where: {
|
||||
applicationId: null,
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
},
|
||||
});
|
||||
const data = {
|
||||
applicationId: null,
|
||||
status: 'connecting',
|
||||
desiredConnections,
|
||||
currentConnections: 0,
|
||||
lastError: null,
|
||||
lastReconnectAttemptAt: new Date(),
|
||||
nextReconnectAt: new Date(Date.now() + getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_MS', DEFAULT_CONNECTING_TIMEOUT_MS)),
|
||||
};
|
||||
let state;
|
||||
if (existing) {
|
||||
state = await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data });
|
||||
} else {
|
||||
try {
|
||||
state = await this.prisma.cmppConnectionState.create({
|
||||
data: {
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code !== 'P2002') {
|
||||
throw error;
|
||||
}
|
||||
const concurrent = await this.prisma.cmppConnectionState.findFirst({
|
||||
where: { applicationId: null, channelId: channel.id, connectionId },
|
||||
});
|
||||
if (!concurrent) {
|
||||
throw error;
|
||||
}
|
||||
state = await this.prisma.cmppConnectionState.update({ where: { id: concurrent.id }, data });
|
||||
}
|
||||
}
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
userId: operatorId,
|
||||
action: 'cmpp_connection.connect_requested',
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${channel.id}:${connectionId}`,
|
||||
detail: {
|
||||
reason,
|
||||
status: state.status,
|
||||
desiredConnections: state.desiredConnections,
|
||||
currentConnections: state.currentConnections,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
const command = {
|
||||
schemaVersion: 'v1',
|
||||
messageType: 'ConnectChannel',
|
||||
traceId: randomUUID(),
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
createdAt: new Date().toISOString(),
|
||||
reason,
|
||||
desiredConnections,
|
||||
channel: {
|
||||
code: channel.code,
|
||||
name: channel.name,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: channel.gatewayPort,
|
||||
account: channel.account,
|
||||
passwordCipher: channel.passwordCipher,
|
||||
srcId: channel.srcId,
|
||||
cmppVersion: channel.cmppVersion,
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
|
||||
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||
'heartbeatIntervalSeconds',
|
||||
),
|
||||
heartbeatMissThreshold: getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatMissThreshold'),
|
||||
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
|
||||
'heartbeatMissThreshold',
|
||||
),
|
||||
},
|
||||
};
|
||||
const queuedJob = await this.getGatewayConnectionQueue().add('connect-channel', command, {
|
||||
jobId: `gateway-connect-${channel.id}-${command.traceId}`,
|
||||
removeOnComplete: 1000,
|
||||
removeOnFail: 1000,
|
||||
}).catch((error) => {
|
||||
this.logger.warn(`Gateway connect marker enqueue failed; continuing with direct control request: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return undefined;
|
||||
});
|
||||
try {
|
||||
await this.notifyGatewayConnect(command);
|
||||
} finally {
|
||||
if (queuedJob) {
|
||||
await queuedJob.remove().catch((error) => {
|
||||
this.logger.warn(`Failed to remove delivered Gateway connect marker ${queuedJob.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async reconnectActiveChannelsAfterGatewayRestart() {
|
||||
const channels = await this.prisma.smsChannel.findMany({ where: { status: 'active' } });
|
||||
const results = await Promise.allSettled(
|
||||
channels.map((channel) => this.requestChannelConnection(channel, 'gateway_restarted')),
|
||||
);
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
const channel = channels[index];
|
||||
this.logger.error(
|
||||
`Failed to restore active CMPP channel ${channel?.code ?? channel?.id ?? index}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async reconcileGatewayConnections(now = new Date()) {
|
||||
const channels = await this.prisma.smsChannel.findMany({
|
||||
where: { status: { in: ['active', 'disabled', 'deleted'] } },
|
||||
include: {
|
||||
connectionStates: {
|
||||
where: { applicationId: null },
|
||||
},
|
||||
},
|
||||
take: 200,
|
||||
});
|
||||
let reconnectRequested = 0;
|
||||
let disconnectRequested = 0;
|
||||
for (const channel of channels) {
|
||||
const state = channel.connectionStates.find((item) => item.connectionId === defaultChannelConnectionId(channel.id));
|
||||
if (channel.status !== 'active') {
|
||||
if (state && (state.currentConnections > 0 || ['connected', 'connecting', 'reconnecting'].includes(state.status))) {
|
||||
await this.withGatewayReconcileLock(channel.id, async () => {
|
||||
await this.requestChannelDisconnection(channel, 'inactive_channel_reconcile');
|
||||
disconnectRequested++;
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const desiredConnections = getDesiredConnections(channel.config);
|
||||
const heartbeatIntervalSeconds = getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||
'heartbeatIntervalSeconds',
|
||||
);
|
||||
const heartbeatMissThreshold = getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatMissThreshold'),
|
||||
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
|
||||
'heartbeatMissThreshold',
|
||||
);
|
||||
const heartbeatCutoff = new Date(now.getTime() - heartbeatIntervalSeconds * (heartbeatMissThreshold + 1) * 1000);
|
||||
const connectedAndFresh = state?.status === 'connected'
|
||||
&& state.currentConnections >= desiredConnections
|
||||
&& Boolean(state.lastHeartbeatAt && state.lastHeartbeatAt > heartbeatCutoff);
|
||||
const retryDue = !state?.nextReconnectAt || state.nextReconnectAt <= now;
|
||||
if (!connectedAndFresh && retryDue) {
|
||||
await this.withGatewayReconcileLock(channel.id, async () => {
|
||||
await this.requestChannelConnection(channel, 'automatic_reconnect');
|
||||
reconnectRequested++;
|
||||
});
|
||||
}
|
||||
}
|
||||
return { scanned: channels.length, reconnectRequested, disconnectRequested };
|
||||
}
|
||||
|
||||
async withGatewayReconcileLock(channelId: string, action: () => Promise<void>) {
|
||||
const redis = this.getRedis();
|
||||
const key = `cmpp:gateway:reconcile:${channelId}`;
|
||||
const token = randomUUID();
|
||||
const acquired = await redis.set(key, token, 'PX', DEFAULT_CONNECTING_TIMEOUT_MS, 'NX');
|
||||
if (acquired !== 'OK') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
await redis.eval(
|
||||
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end',
|
||||
1,
|
||||
key,
|
||||
token,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async requestChannelDisconnection(
|
||||
channel: { id: string },
|
||||
reason: 'channel_disabled' | 'channel_deleted' | 'inactive_channel_reconcile',
|
||||
operatorId?: string,
|
||||
) {
|
||||
const connectionId = defaultChannelConnectionId(channel.id);
|
||||
const now = new Date();
|
||||
await this.prisma.cmppConnectionState.updateMany({
|
||||
where: {
|
||||
applicationId: null,
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
},
|
||||
data: {
|
||||
status: 'disconnected',
|
||||
currentConnections: 0,
|
||||
lastDisconnectedAt: now,
|
||||
nextReconnectAt: null,
|
||||
lastErrorCategory: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
userId: operatorId,
|
||||
action: 'cmpp_connection.disconnect_requested',
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${channel.id}:${connectionId}`,
|
||||
detail: { reason } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
const command = {
|
||||
schemaVersion: 'v1',
|
||||
messageType: 'DisconnectChannel',
|
||||
traceId: randomUUID(),
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
createdAt: now.toISOString(),
|
||||
reason,
|
||||
};
|
||||
const queuedJob = await this.getGatewayConnectionQueue().add('disconnect-channel', command, {
|
||||
jobId: `gateway-disconnect-${channel.id}-${command.traceId}`,
|
||||
removeOnComplete: 1000,
|
||||
removeOnFail: 1000,
|
||||
}).catch((error) => {
|
||||
this.logger.warn(`Gateway disconnect marker enqueue failed; continuing with direct control request: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return undefined;
|
||||
});
|
||||
try {
|
||||
await this.notifyGatewayDisconnect(command);
|
||||
} finally {
|
||||
if (queuedJob) {
|
||||
await queuedJob.remove().catch((error) => {
|
||||
this.logger.warn(`Failed to remove delivered Gateway disconnect marker ${queuedJob.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getGatewayConnectionQueue() {
|
||||
this.gatewayConnectionQueue ??= new Queue(GATEWAY_CONNECTION_QUEUE, { connection: bullmqConnection() });
|
||||
return this.gatewayConnectionQueue;
|
||||
}
|
||||
|
||||
getGatewaySubmitQueue() {
|
||||
this.gatewaySubmitQueue ??= new Queue(GATEWAY_SUBMIT_QUEUE, { connection: bullmqConnection() });
|
||||
return this.gatewaySubmitQueue;
|
||||
}
|
||||
|
||||
getRedis() {
|
||||
if (!this.redis) {
|
||||
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
}
|
||||
return this.redis;
|
||||
}
|
||||
|
||||
async publishGatewaySubmitCommand(command: unknown) {
|
||||
return this.getRedis().xadd(
|
||||
process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM,
|
||||
'*',
|
||||
'messageType',
|
||||
'SubmitCommand',
|
||||
'data',
|
||||
JSON.stringify(command),
|
||||
);
|
||||
}
|
||||
|
||||
async notifyGatewayConnect(command: Record<string, unknown>) {
|
||||
const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? DEFAULT_GATEWAY_CONTROL_URL).replace(/\/+$/, '');
|
||||
let response: { ok: boolean; status: number; text: () => Promise<string> };
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/connections/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(command),
|
||||
signal: AbortSignal.timeout(getPositiveIntegerEnv('GATEWAY_CONTROL_TIMEOUT_MS', DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS)),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Gateway connect request failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new BadRequestException(`Gateway connect request failed: ${response.status} ${responseText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async notifyGatewayDisconnect(command: Record<string, unknown>) {
|
||||
const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? DEFAULT_GATEWAY_CONTROL_URL).replace(/\/+$/, '');
|
||||
let response: { ok: boolean; status: number; text: () => Promise<string> };
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/connections/disconnect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(command),
|
||||
signal: AbortSignal.timeout(getPositiveIntegerEnv('GATEWAY_CONTROL_TIMEOUT_MS', DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS)),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Gateway disconnect request failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new BadRequestException(`Gateway disconnect request failed: ${response.status} ${responseText}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
||||
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||
|
||||
|
||||
/** R5 channel domain service composed behind ChannelsService. */
|
||||
export class ChannelCopyService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async copyChannel(channelId: string, data: CopyChannelDto = {}) {
|
||||
const source = await this.prisma.smsChannel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: { reportFields: true },
|
||||
});
|
||||
if (!source) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
|
||||
const suffix = Date.now().toString(36).toUpperCase();
|
||||
const nextName = data.name ?? `${source.name}副本`;
|
||||
const nextCode = data.code ?? `${source.code}-COPY-${suffix}`;
|
||||
|
||||
const copied = await this.prisma.$transaction(async (tx) => {
|
||||
const nextChannel = await tx.smsChannel.create({
|
||||
data: {
|
||||
code: nextCode,
|
||||
name: nextName,
|
||||
carrier: source.carrier,
|
||||
protocol: source.protocol,
|
||||
gatewayHost: source.gatewayHost,
|
||||
gatewayPort: source.gatewayPort,
|
||||
enterpriseCode: source.enterpriseCode,
|
||||
account: source.account,
|
||||
passwordCipher: source.passwordCipher,
|
||||
srcId: source.srcId,
|
||||
sendRegion: source.sendRegion,
|
||||
cmppVersion: source.cmppVersion,
|
||||
rateLimitPerSecond: source.rateLimitPerSecond,
|
||||
unitPrice: source.unitPrice,
|
||||
status: 'disabled',
|
||||
config: source.config as Prisma.InputJsonValue | undefined,
|
||||
reportFields: {
|
||||
create: source.reportFields.map((field) => ({
|
||||
drainageFieldId: field.drainageFieldId,
|
||||
reportType: field.reportType,
|
||||
code: field.code,
|
||||
name: field.name,
|
||||
fieldType: field.fieldType,
|
||||
required: field.required,
|
||||
description: field.description,
|
||||
sortOrder: field.sortOrder,
|
||||
status: field.status,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { reportFields: true },
|
||||
});
|
||||
|
||||
const reportMaterials = await tx.signatureReportMaterial.findMany({ where: { channelId } });
|
||||
if (reportMaterials.length > 0) {
|
||||
await tx.signatureReportMaterial.createMany({
|
||||
data: reportMaterials.map((material) => ({
|
||||
signatureId: material.signatureId,
|
||||
channelId: nextChannel.id,
|
||||
fieldCode: material.fieldCode,
|
||||
fieldValue: material.fieldValue,
|
||||
fileObjectId: material.fileObjectId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
userId: data.operatorId,
|
||||
action: 'sms_channel.copy',
|
||||
resource: 'sms_channel',
|
||||
resourceId: nextChannel.id,
|
||||
detail: {
|
||||
sourceChannelId: source.id,
|
||||
sourceCode: source.code,
|
||||
sourceStatus: source.status,
|
||||
copiedStatus: 'disabled',
|
||||
copiedReportFields: source.reportFields.length,
|
||||
copiedReportMaterials: reportMaterials.length,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return nextChannel;
|
||||
});
|
||||
|
||||
return copied;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
||||
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||
import { ChannelConfigurationService } from './channel-configuration.service';
|
||||
|
||||
/** R5 channel domain service composed behind ChannelsService. */
|
||||
export class ChannelDeletionService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly configuration: ChannelConfigurationService) {}
|
||||
|
||||
async deleteChannel(channelId: string, data: ChangeChannelStatusDto = { status: 'deleted' }) {
|
||||
return this.configuration.changeChannelStatus(channelId, { ...data, status: 'deleted' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
||||
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||
|
||||
|
||||
/** R5 channel domain service composed behind ChannelsService. */
|
||||
export class ChannelGroupRoutingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listGroups() {
|
||||
return this.prisma.smsChannelGroup.findMany({
|
||||
include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
createGroup(data: CreateChannelGroupDto) {
|
||||
const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes(data.retryTimeLimitMinutes, data.retryTimeLimitHours, 720);
|
||||
const carrier = normalizeBusinessCarrier(data.carrier);
|
||||
return this.prisma.smsChannelGroup.create({
|
||||
data: {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
carrier,
|
||||
description: data.description,
|
||||
status: data.status ?? 'active',
|
||||
retryEnabled: data.retryEnabled ?? true,
|
||||
retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
|
||||
retryTimeLimitMinutes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async addGroupItem(data: CreateChannelGroupItemDto) {
|
||||
const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: data.groupId } });
|
||||
if (!group) {
|
||||
throw new NotFoundException('Channel group not found');
|
||||
}
|
||||
const groupCarrier = normalizeBusinessCarrier(group.carrier);
|
||||
const itemCarrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : groupCarrier;
|
||||
if (itemCarrier !== groupCarrier) {
|
||||
throw new BadRequestException('Channel group items must use the same carrier as the channel group');
|
||||
}
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) {
|
||||
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
|
||||
}
|
||||
if (data.province && !isRegionCompatible(channel.sendRegion, data.province)) {
|
||||
throw new BadRequestException('Province route must use a channel with the same sendRegion');
|
||||
}
|
||||
const existing = await this.prisma.smsChannelGroupItem.findFirst({
|
||||
where: { groupId: data.groupId, channelId: data.channelId },
|
||||
});
|
||||
if (existing) {
|
||||
throw new BadRequestException('通道组内不能重复配置同一通道');
|
||||
}
|
||||
if (data.province) {
|
||||
const existingProvince = await this.prisma.smsChannelGroupItem.findFirst({
|
||||
where: { groupId: data.groupId, province: data.province },
|
||||
});
|
||||
if (existingProvince) {
|
||||
throw new BadRequestException('同一通道组内同一省份只能配置一个通道');
|
||||
}
|
||||
} else {
|
||||
const existingPriority = await this.prisma.smsChannelGroupItem.findFirst({
|
||||
where: { groupId: data.groupId, province: null, priority: data.priority ?? 100 },
|
||||
});
|
||||
if (existingPriority) {
|
||||
throw new BadRequestException('同一通道组内全国通道优先级不能重复');
|
||||
}
|
||||
}
|
||||
return this.prisma.smsChannelGroupItem.create({
|
||||
data: {
|
||||
groupId: data.groupId,
|
||||
channelId: data.channelId,
|
||||
carrier: itemCarrier,
|
||||
province: data.province,
|
||||
priority: data.priority ?? 100,
|
||||
weight: data.weight ?? 1,
|
||||
isBackup: data.isBackup ?? false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateGroup(groupId: string, data: UpdateChannelGroupDto) {
|
||||
const current = await this.prisma.smsChannelGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||
});
|
||||
if (!current) {
|
||||
throw new NotFoundException('Channel group not found');
|
||||
}
|
||||
const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes(
|
||||
data.retryTimeLimitMinutes,
|
||||
data.retryTimeLimitHours,
|
||||
current.retryTimeLimitMinutes ?? current.retryTimeLimitHours * 60,
|
||||
);
|
||||
const carrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : normalizeBusinessCarrier(current.carrier);
|
||||
const items = data.items ?? [];
|
||||
const channelIds = [...new Set(items.map((item) => item.channelId))];
|
||||
const channels = await this.prisma.smsChannel.findMany({ where: { id: { in: channelIds } } });
|
||||
const channelById = new Map(channels.map((channel) => [channel.id, channel]));
|
||||
validateGroupItems(carrier, items, channelById);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.smsChannelGroupItem.deleteMany({ where: { groupId } });
|
||||
await tx.smsChannelGroup.update({
|
||||
where: { id: groupId },
|
||||
data: {
|
||||
code: data.code ?? current.code,
|
||||
name: data.name ?? current.name,
|
||||
carrier,
|
||||
description: data.description,
|
||||
status: data.status ?? current.status,
|
||||
retryEnabled: data.retryEnabled ?? current.retryEnabled,
|
||||
retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
|
||||
retryTimeLimitMinutes,
|
||||
},
|
||||
});
|
||||
if (items.length > 0) {
|
||||
await tx.smsChannelGroupItem.createMany({
|
||||
data: items.map((item) => ({
|
||||
groupId,
|
||||
channelId: item.channelId,
|
||||
carrier,
|
||||
province: item.province,
|
||||
priority: item.priority ?? 100,
|
||||
weight: item.weight ?? 1,
|
||||
isBackup: item.isBackup ?? false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
const updated = await tx.smsChannelGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||
});
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
action: 'sms_channel_group.update',
|
||||
resource: 'sms_channel_group',
|
||||
resourceId: groupId,
|
||||
detail: {
|
||||
before: channelGroupAuditSnapshot(current),
|
||||
after: updated ? channelGroupAuditSnapshot(updated) : null,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteGroup(groupId: string) {
|
||||
const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } });
|
||||
if (!group) {
|
||||
throw new NotFoundException('Channel group not found');
|
||||
}
|
||||
const boundRoute = await this.prisma.channelRouteRule.findFirst({
|
||||
where: {
|
||||
groupId,
|
||||
status: 'active',
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (boundRoute) {
|
||||
throw new BadRequestException('Channel group is used by application route rules and cannot be deleted');
|
||||
}
|
||||
await this.prisma.smsChannelGroupItem.deleteMany({ where: { groupId } });
|
||||
return this.prisma.smsChannelGroup.delete({ where: { id: groupId } });
|
||||
}
|
||||
|
||||
listRouteRules() {
|
||||
return this.prisma.channelRouteRule.findMany({
|
||||
include: { group: true, channel: true },
|
||||
orderBy: [{ priority: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async createRouteRule(data: CreateRouteRuleDto) {
|
||||
if (!data.applicationId) {
|
||||
throw new BadRequestException('applicationId is required for channel group routing');
|
||||
}
|
||||
if (!data.carrier) {
|
||||
throw new BadRequestException('carrier is required for application channel group routing');
|
||||
}
|
||||
const carrier = normalizeBusinessCarrier(data.carrier);
|
||||
if (data.channelId) {
|
||||
throw new BadRequestException('Route rules can only bind channel groups, not single channels');
|
||||
}
|
||||
if (data.province) {
|
||||
throw new BadRequestException('Province routing must be configured inside the channel group');
|
||||
}
|
||||
const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: data.groupId } });
|
||||
if (!group) {
|
||||
throw new NotFoundException('Channel group not found');
|
||||
}
|
||||
if (normalizeBusinessCarrier(group.carrier) !== carrier) {
|
||||
throw new BadRequestException('Route rule carrier must match the channel group carrier');
|
||||
}
|
||||
return this.prisma.channelRouteRule.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
groupId: data.groupId,
|
||||
channelId: undefined,
|
||||
carrier,
|
||||
province: undefined,
|
||||
priority: data.priority ?? 100,
|
||||
status: data.status ?? 'active',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
||||
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||
|
||||
|
||||
/** R5 channel domain service composed behind ChannelsService. */
|
||||
export class ChannelReportingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listReportFields(channelId?: string) {
|
||||
return this.prisma.channelReportField.findMany({
|
||||
where: channelId ? { channelId } : undefined,
|
||||
include: { drainageField: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async createReportField(data: CreateReportFieldDto) {
|
||||
if (!data.drainageFieldId) throw new BadRequestException('drainageFieldId is required');
|
||||
const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } });
|
||||
if (!field || field.status !== 'active') {
|
||||
throw new BadRequestException('报备字段库字段不存在或已停用');
|
||||
}
|
||||
const reportType = normalizeReportType(data.reportType);
|
||||
return this.prisma.channelReportField.create({
|
||||
data: {
|
||||
channelId: data.channelId,
|
||||
drainageFieldId: field.id,
|
||||
reportType,
|
||||
code: field.code,
|
||||
name: field.name,
|
||||
exportName: data.exportName?.trim() || field.name,
|
||||
fieldType: field.fieldType,
|
||||
required: data.required ?? field.required,
|
||||
description: data.description ?? field.description,
|
||||
sortOrder: data.sortOrder ?? 100,
|
||||
columnWidth: normalizeSpreadsheetSize(data.columnWidth, 18, 6, 80),
|
||||
imageWidth: normalizeSpreadsheetSize(data.imageWidth, 120, 24, 600),
|
||||
imageHeight: normalizeSpreadsheetSize(data.imageHeight, 80, 24, 600),
|
||||
defaultValue: data.defaultValue,
|
||||
transform: data.transform,
|
||||
status: data.status ?? 'active',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async replaceReportFields(channelId: string, reportType: 'signature' | 'drainage', data: ReplaceReportFieldsDto) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
|
||||
if (!channel) throw new NotFoundException('Channel not found');
|
||||
const ids = data.fields.map((field) => field.drainageFieldId);
|
||||
if (new Set(ids).size !== ids.length) throw new BadRequestException('同一通道报备类型不能重复配置字段');
|
||||
const libraryFields = await this.prisma.drainageField.findMany({ where: { id: { in: ids }, status: 'active' } });
|
||||
if (libraryFields.length !== ids.length) throw new BadRequestException('报备字段库字段不存在或已停用');
|
||||
const fieldById = new Map(libraryFields.map((field) => [field.id, field]));
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const oppositeType = reportType === 'signature' ? 'drainage' : 'signature';
|
||||
const [legacyBoth, oppositeFields] = await Promise.all([
|
||||
tx.channelReportField.findMany({ where: { channelId, reportType: 'both' } }),
|
||||
tx.channelReportField.findMany({ where: { channelId, reportType: oppositeType } }),
|
||||
]);
|
||||
const oppositeCodes = new Set(oppositeFields.map((field) => field.code));
|
||||
await tx.channelReportField.deleteMany({ where: { channelId, reportType: { in: [reportType, 'both'] } } });
|
||||
for (const legacy of legacyBoth) {
|
||||
if (oppositeCodes.has(legacy.code)) continue;
|
||||
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy;
|
||||
await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } });
|
||||
}
|
||||
for (const [index, configured] of data.fields.entries()) {
|
||||
const field = fieldById.get(configured.drainageFieldId)!;
|
||||
await tx.channelReportField.create({
|
||||
data: {
|
||||
channelId,
|
||||
drainageFieldId: field.id,
|
||||
reportType,
|
||||
code: field.code,
|
||||
name: field.name,
|
||||
exportName: configured.exportName?.trim() || field.name,
|
||||
fieldType: field.fieldType,
|
||||
required: configured.required ?? field.required,
|
||||
description: configured.description ?? field.description,
|
||||
sortOrder: configured.sortOrder ?? (index + 1) * 10,
|
||||
columnWidth: normalizeSpreadsheetSize(configured.columnWidth, 18, 6, 80),
|
||||
imageWidth: normalizeSpreadsheetSize(configured.imageWidth, 120, 24, 600),
|
||||
imageHeight: normalizeSpreadsheetSize(configured.imageHeight, 80, 24, 600),
|
||||
defaultValue: configured.defaultValue,
|
||||
transform: configured.transform,
|
||||
status: configured.status ?? 'active',
|
||||
},
|
||||
});
|
||||
}
|
||||
return tx.channelReportField.findMany({ where: { channelId, reportType }, include: { drainageField: true }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
|
||||
});
|
||||
}
|
||||
|
||||
listReportMaterials(signatureId?: string, channelId?: string) {
|
||||
return this.prisma.signatureReportMaterial.findMany({
|
||||
where: {
|
||||
signatureId,
|
||||
channelId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
upsertReportMaterial(data: CreateReportMaterialDto) {
|
||||
return this.prisma.signatureReportMaterial.upsert({
|
||||
where: {
|
||||
signatureId_channelId_fieldCode: {
|
||||
signatureId: data.signatureId,
|
||||
channelId: data.channelId,
|
||||
fieldCode: data.fieldCode,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
fieldValue: data.fieldValue,
|
||||
fileObjectId: data.fileObjectId,
|
||||
},
|
||||
create: {
|
||||
signatureId: data.signatureId,
|
||||
channelId: data.channelId,
|
||||
fieldCode: data.fieldCode,
|
||||
fieldValue: data.fieldValue,
|
||||
fileObjectId: data.fileObjectId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async listReportTasks(tenantId?: string, status?: string, channelId?: string, reportType?: string) {
|
||||
const tasks = await this.prisma.channelSignatureReportTask.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
status,
|
||||
channelId,
|
||||
reportType,
|
||||
signature: { auditStatus: { not: 'deleted' } },
|
||||
},
|
||||
include: {
|
||||
signature: { include: { tenant: true, application: true } },
|
||||
channel: true,
|
||||
drainageInfo: true,
|
||||
exportItems: {
|
||||
include: { exportFile: true, batchItem: { include: { batch: true } } },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
records: { orderBy: { createdAt: 'desc' }, take: 20 },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (tasks.length === 0) {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
const channelIds = [...new Set(tasks.map((task) => task.channelId))];
|
||||
const signatureIds = [...new Set(tasks.map((task) => task.signatureId))];
|
||||
const day = currentShanghaiDayRange();
|
||||
const rows = await this.prisma.$queryRaw<ChannelReportDeliveryRow[]>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
submit."channelId" AS channel_id,
|
||||
message."signatureId" AS signature_id,
|
||||
message."drainageInfoId" AS drainage_info_id,
|
||||
submit."submitStatus" AS submit_status,
|
||||
COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at,
|
||||
CASE
|
||||
WHEN segment_summary.segment_count > 0
|
||||
AND segment_summary.delivered_count = segment_summary.segment_count
|
||||
THEN segment_summary.completed_at
|
||||
WHEN segment_summary.segment_count = 0 THEN delivered_receipt.delivered_at
|
||||
END AS successful_at,
|
||||
CASE
|
||||
WHEN submit."submitStatus" <> 'accepted' THEN 'submit_failed'
|
||||
WHEN segment_summary.segment_count > 0 AND segment_summary.failure_count > 0 THEN 'failure'
|
||||
WHEN segment_summary.segment_count > 0
|
||||
AND segment_summary.delivered_count = segment_summary.segment_count THEN 'success'
|
||||
WHEN segment_summary.segment_count = 0 AND failed_receipt.failed_at IS NOT NULL THEN 'failure'
|
||||
WHEN segment_summary.segment_count = 0 AND delivered_receipt.delivered_at IS NOT NULL THEN 'success'
|
||||
ELSE 'unknown'
|
||||
END AS delivery_status
|
||||
FROM "SmsSubmitRecord" submit
|
||||
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*)::integer AS segment_count,
|
||||
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count,
|
||||
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count,
|
||||
MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at
|
||||
FROM "SmsMessageSegmentAudit" segment
|
||||
WHERE segment."submitRecordId" = submit.id
|
||||
) segment_summary ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS delivered_at
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) delivered_receipt ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS failed_at
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'undelivered'
|
||||
) failed_receipt ON TRUE
|
||||
WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
|
||||
AND submit."channelId" IN (${Prisma.join(channelIds)})
|
||||
AND message."signatureId" IN (${Prisma.join(signatureIds)})
|
||||
)
|
||||
SELECT
|
||||
channel_id AS "channelId",
|
||||
signature_id AS "signatureId",
|
||||
drainage_info_id AS "drainageInfoId",
|
||||
COUNT(*) FILTER (
|
||||
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
||||
)::integer AS total,
|
||||
COUNT(*) FILTER (
|
||||
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
||||
AND submit_status = 'accepted'
|
||||
)::integer AS "acceptedCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
||||
AND delivery_status = 'submit_failed'
|
||||
)::integer AS "submitFailureCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
||||
AND delivery_status = 'success'
|
||||
)::integer AS "successCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
||||
AND delivery_status = 'unknown'
|
||||
)::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
||||
AND delivery_status = 'failure'
|
||||
)::integer AS "failureCount",
|
||||
MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt"
|
||||
FROM base
|
||||
GROUP BY channel_id, signature_id, drainage_info_id
|
||||
`);
|
||||
|
||||
return tasks.map((task) => {
|
||||
const taskRows = rows.filter((row) => (
|
||||
row.channelId === task.channelId
|
||||
&& row.signatureId === task.signatureId
|
||||
&& ((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId)
|
||||
));
|
||||
const deliveryStats = summarizeChannelReportDelivery(taskRows);
|
||||
return {
|
||||
...task,
|
||||
deliveryStats,
|
||||
lastSuccessfulSentAt: latestDate(taskRows.map((row) => row.lastSuccessfulSentAt)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async listReportTasksPage(query: {
|
||||
tenantId?: string;
|
||||
status?: string;
|
||||
channelId?: string;
|
||||
reportType?: string;
|
||||
keyword?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||
const keyword = query.keyword?.trim();
|
||||
const where: Prisma.ChannelSignatureReportTaskWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
status: query.status,
|
||||
channelId: query.channelId,
|
||||
reportType: query.reportType,
|
||||
signature: { auditStatus: { not: 'deleted' } },
|
||||
createdAt: query.createdAtFrom || query.createdAtTo ? {
|
||||
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
|
||||
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
|
||||
} : undefined,
|
||||
OR: keyword ? [
|
||||
{ id: { contains: keyword } },
|
||||
{ channel: { name: { contains: keyword } } },
|
||||
{ signature: { name: { contains: keyword } } },
|
||||
{ signature: { tenant: { name: { contains: keyword } } } },
|
||||
{ signature: { application: { name: { contains: keyword } } } },
|
||||
{ drainageInfo: { siteName: { contains: keyword } } },
|
||||
{ drainageInfo: { url: { contains: keyword } } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.channelSignatureReportTask.findMany({
|
||||
where,
|
||||
include: {
|
||||
signature: { include: { tenant: true, application: true } },
|
||||
channel: true,
|
||||
drainageInfo: true,
|
||||
exportItems: {
|
||||
include: { exportFile: true, batchItem: { include: { batch: true } } },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
records: { orderBy: { createdAt: 'desc' }, take: 20 },
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.channelSignatureReportTask.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async createReportTask(data: CreateReportTaskDto) {
|
||||
const reportType = data.reportType ?? 'signature';
|
||||
if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required');
|
||||
if (reportType === 'drainage') {
|
||||
const drainageInfo = await this.prisma.smsDrainageInfo.findUnique({ where: { id: data.drainageItemId! } });
|
||||
if (!drainageInfo || drainageInfo.signatureId !== data.signatureId) throw new NotFoundException('Drainage info not found');
|
||||
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
|
||||
throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成');
|
||||
}
|
||||
const task = await this.prisma.channelSignatureReportTask.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
signatureId: data.signatureId,
|
||||
channelId: data.channelId,
|
||||
reportType,
|
||||
drainageItemId: undefined,
|
||||
createdById: data.createdById,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
await this.recordReportTask(task.id, task.channelId, 'create', undefined, 'pending');
|
||||
return task;
|
||||
}
|
||||
|
||||
async changeReportTaskStatuses(data: ChangeReportTaskStatusesDto) {
|
||||
if (!data.items.length) throw new BadRequestException('items is required');
|
||||
const allowed = new Set(['pending', 'waiting_material', 'reporting', 'approved', 'failed', 'rejected', 'abandoned']);
|
||||
for (const item of data.items) {
|
||||
if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status');
|
||||
}
|
||||
const sourceEntry = data.sourceEntry ?? 'report_task';
|
||||
if (!['enterprise_signature', 'report_task', 'channel_report'].includes(sourceEntry)) {
|
||||
throw new BadRequestException('unsupported report task source entry');
|
||||
}
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const signatureIds = [...new Set(data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId))];
|
||||
const drainageResults: Array<{ signatureId: string; reportType: 'drainage'; drainageItemId: string; channelId: string; status: string }> = [];
|
||||
for (const item of data.items) {
|
||||
const reportType = item.reportType ?? 'signature';
|
||||
if (reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException('drainageItemId is required');
|
||||
const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } });
|
||||
const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } });
|
||||
if (!signature || !channel) throw new NotFoundException('Signature or channel not found');
|
||||
if (reportType === 'drainage') {
|
||||
const drainageInfo = await tx.smsDrainageInfo.findUnique({ where: { id: item.drainageItemId! } });
|
||||
if (!drainageInfo || drainageInfo.signatureId !== item.signatureId) throw new NotFoundException('Drainage info not found');
|
||||
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
|
||||
}
|
||||
const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : null } });
|
||||
if (reportType === 'drainage' && !existing) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
|
||||
const task = existing
|
||||
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason } })
|
||||
: await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : undefined, status: item.status, reason: data.reason, createdById: data.operatorId } });
|
||||
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId, sourceEntry } });
|
||||
if (reportType === 'drainage') drainageResults.push({ signatureId: item.signatureId, reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, status: item.status });
|
||||
}
|
||||
const summaries = [];
|
||||
for (const signatureId of signatureIds) summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId));
|
||||
return [...summaries, ...drainageResults];
|
||||
});
|
||||
}
|
||||
|
||||
async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) {
|
||||
const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature) throw new NotFoundException('Signature not found');
|
||||
const routes = signature.applicationId ? await tx.channelRouteRule.findMany({
|
||||
where: { applicationId: signature.applicationId, status: 'active' },
|
||||
include: { group: { include: { items: { include: { channel: true } } } } },
|
||||
}) : [];
|
||||
const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
|
||||
const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } });
|
||||
const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel);
|
||||
const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
|
||||
const taskByChannel = new Map(tasks.map((task) => [task.channelId, task]));
|
||||
const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
|
||||
const targets = uniqueChannels.filter((channel) => channel.carrier === carrier || channel.carrier === 'all');
|
||||
const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
|
||||
return [carrier, summarizeReportStatuses(statuses)];
|
||||
}));
|
||||
const allStatuses = uniqueChannels.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
|
||||
const reportStatus = summarizeReportStatuses(allStatuses).status;
|
||||
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
|
||||
return { signatureId, reportStatus, carrierReportSummary };
|
||||
}
|
||||
|
||||
async createReportExport(taskId: string, data: CreateReportExportDto) {
|
||||
const task = await this.getReportTaskOrThrow(taskId);
|
||||
const exported = await this.prisma.reportExportFile.create({
|
||||
data: {
|
||||
taskId,
|
||||
fileObjectId: data.fileObjectId,
|
||||
fileName: data.fileName,
|
||||
rowCount: data.rowCount ?? 0,
|
||||
},
|
||||
});
|
||||
await this.updateReportTaskStatus(taskId, task.channelId, task.status, 'exporting', 'export');
|
||||
return exported;
|
||||
}
|
||||
|
||||
async importReportReceipt(taskId: string, data: CreateReceiptImportDto) {
|
||||
const task = await this.getReportTaskOrThrow(taskId);
|
||||
const parsed = data.fileContent ? parseReceiptContent(data.fileContent, data.delimiter) : undefined;
|
||||
const rowCount = data.rowCount ?? parsed?.rowCount ?? 0;
|
||||
const successCount = data.successCount ?? parsed?.successCount ?? 0;
|
||||
const failedCount = data.failedCount ?? parsed?.failedCount ?? 0;
|
||||
const statusAfter = data.statusAfter ?? deriveReceiptStatus(rowCount, successCount, failedCount);
|
||||
const imported = await this.prisma.reportReceiptImport.create({
|
||||
data: {
|
||||
taskId,
|
||||
fileObjectId: data.fileObjectId,
|
||||
fileName: data.fileName,
|
||||
rowCount,
|
||||
successCount,
|
||||
failedCount,
|
||||
status: 'imported',
|
||||
result: (data.result ?? parsed?.result) as Prisma.InputJsonValue | undefined,
|
||||
},
|
||||
});
|
||||
await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason);
|
||||
if ((task.reportType ?? 'signature') === 'signature') {
|
||||
await this.recomputeSignatureReportSummary(this.prisma as unknown as Prisma.TransactionClient, task.signatureId);
|
||||
}
|
||||
return imported;
|
||||
}
|
||||
|
||||
listReportRecords(taskId?: string, channelId?: string) {
|
||||
return this.prisma.channelSignatureReportRecord.findMany({
|
||||
where: { taskId, channelId },
|
||||
include: { channel: true, task: { include: { signature: true, drainageInfo: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async listReportRecordsPage(query: {
|
||||
taskId?: string;
|
||||
channelId?: string;
|
||||
keyword?: string;
|
||||
reportType?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||
const keyword = query.keyword?.trim();
|
||||
const where: Prisma.ChannelSignatureReportRecordWhereInput = {
|
||||
taskId: query.taskId,
|
||||
channelId: query.channelId,
|
||||
task: query.reportType ? { reportType: query.reportType } : undefined,
|
||||
createdAt: query.createdAtFrom || query.createdAtTo ? {
|
||||
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
|
||||
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
|
||||
} : undefined,
|
||||
OR: keyword ? [
|
||||
{ taskId: { contains: keyword } },
|
||||
{ action: { contains: keyword } },
|
||||
{ reason: { contains: keyword } },
|
||||
{ channel: { name: { contains: keyword } } },
|
||||
{ task: { signature: { name: { contains: keyword } } } },
|
||||
{ task: { drainageInfo: { siteName: { contains: keyword } } } },
|
||||
{ task: { drainageInfo: { url: { contains: keyword } } } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.channelSignatureReportRecord.findMany({
|
||||
where,
|
||||
include: { channel: true, task: { include: { signature: true, drainageInfo: true } } },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.channelSignatureReportRecord.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getReportTaskOrThrow(taskId: string) {
|
||||
const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, include: { drainageInfo: true } });
|
||||
if (!task) {
|
||||
throw new NotFoundException('Report task not found');
|
||||
}
|
||||
if (task.reportType === 'drainage' && task.drainageInfo?.auditStatus !== 'approved') {
|
||||
throw new BadRequestException('引流信息审核通过后才能处理通道报备任务');
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
async updateReportTaskStatus(
|
||||
taskId: string,
|
||||
channelId: string,
|
||||
statusBefore: string,
|
||||
statusAfter: string,
|
||||
action: string,
|
||||
reason?: string,
|
||||
) {
|
||||
await this.prisma.channelSignatureReportTask.update({
|
||||
where: { id: taskId },
|
||||
data: { status: statusAfter, reason },
|
||||
});
|
||||
await this.recordReportTask(taskId, channelId, action, statusBefore, statusAfter, reason);
|
||||
}
|
||||
|
||||
recordReportTask(
|
||||
taskId: string,
|
||||
channelId: string,
|
||||
action: string,
|
||||
statusBefore: string | undefined,
|
||||
statusAfter: string,
|
||||
reason?: string,
|
||||
) {
|
||||
return this.prisma.channelSignatureReportRecord.create({
|
||||
data: {
|
||||
taskId,
|
||||
channelId,
|
||||
action,
|
||||
statusBefore,
|
||||
statusAfter,
|
||||
reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
||||
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
|
||||
import { ChannelConnectionService } from './channel-connection.service';
|
||||
|
||||
/** R5 channel domain service composed behind ChannelsService. */
|
||||
export class ChannelTestService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {}
|
||||
|
||||
async testChannel(channelId: string, data: TestChannelDto = {}) {
|
||||
const phoneNumbers = normalizeTestPhones(data);
|
||||
const content = normalizeTestContent(data.content);
|
||||
const channel = await this.prisma.smsChannel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: { connectionStates: true },
|
||||
});
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
if (channel.status !== 'active') {
|
||||
throw new BadRequestException('通道未启用,不能发送测试短信');
|
||||
}
|
||||
const connectedState = channel.connectionStates.find((state) =>
|
||||
normalizeGatewayConnectionStatus(state.status) === 'connected' && (state.currentConnections ?? 0) > 0,
|
||||
);
|
||||
if (!connectedState) {
|
||||
throw new BadRequestException('通道当前没有可用 CMPP 连接,请先连接成功后再测试发送');
|
||||
}
|
||||
|
||||
const createdAt = new Date();
|
||||
const testNo = `CHTEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const results = [];
|
||||
for (const [index, phoneNumber] of phoneNumbers.entries()) {
|
||||
const messageId = `MSG-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const submitId = `SUB-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const session = await this.prisma.cmppSubmitSession.upsert({
|
||||
where: { sessionNo: `OPEN-${channel.id}` },
|
||||
update: { submitTotal: { increment: 1 } },
|
||||
create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 },
|
||||
});
|
||||
const messageRecord = await this.prisma.smsMessageRecord.create({
|
||||
data: {
|
||||
messageId,
|
||||
phoneNumber,
|
||||
content,
|
||||
billingUnits: calculateBillingUnits(content),
|
||||
unitPrice: 0,
|
||||
amountCents: 0,
|
||||
queuePriority: 'normal',
|
||||
channelId: channel.id,
|
||||
submitId,
|
||||
status: 'submit_queued',
|
||||
submitStatus: 'queued',
|
||||
},
|
||||
});
|
||||
await this.prisma.smsSubmitRecord.create({
|
||||
data: {
|
||||
messageRecordId: messageRecord.id,
|
||||
channelId: channel.id,
|
||||
sessionId: session.id,
|
||||
submitId,
|
||||
submitStatus: 'queued',
|
||||
costUnitPrice: channel.unitPrice,
|
||||
costAmountCents: moneyToNumber(channel.unitPrice) * messageRecord.billingUnits,
|
||||
},
|
||||
});
|
||||
const command = buildChannelTestSubmitCommand({
|
||||
channel,
|
||||
content,
|
||||
phoneNumber,
|
||||
messageId,
|
||||
submitId,
|
||||
testNo,
|
||||
attempt: index,
|
||||
accessNo: data.accessNo,
|
||||
});
|
||||
await this.connection.getGatewaySubmitQueue().add('submit-command', command);
|
||||
const streamMessageId = await this.connection.publishGatewaySubmitCommand(command);
|
||||
results.push({
|
||||
phoneNumber,
|
||||
messageRecordId: messageRecord.id,
|
||||
submitId,
|
||||
streamMessageId,
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
userId: data.operatorId,
|
||||
action: 'sms_channel.test_submit',
|
||||
resource: 'sms_channel',
|
||||
resourceId: channel.id,
|
||||
detail: {
|
||||
testNo,
|
||||
phoneTotal: phoneNumbers.length,
|
||||
messageRecordIds: results.map((item) => item.messageRecordId),
|
||||
connectionId: connectedState.connectionId,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
channelId,
|
||||
status: 'submit_queued',
|
||||
testNo,
|
||||
submitted: results.length,
|
||||
messages: results,
|
||||
queuedAt: createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/** Stable request contracts shared by the channel controller and R5 domains. */
|
||||
|
||||
export interface CreateChannelDto {
|
||||
code: string;
|
||||
name: string;
|
||||
carrier?: string;
|
||||
sendRegion?: string;
|
||||
protocol?: string;
|
||||
gatewayHost: string;
|
||||
gatewayPort?: number;
|
||||
enterpriseCode?: string;
|
||||
account: string;
|
||||
passwordCipher: string;
|
||||
srcId: string;
|
||||
cmppVersion?: string;
|
||||
rateLimitPerSecond?: number;
|
||||
unitPrice?: number;
|
||||
status?: string;
|
||||
desiredConnections?: number;
|
||||
windowSize?: number;
|
||||
heartbeatIntervalSeconds?: number;
|
||||
heartbeatMissThreshold?: number;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type UpdateChannelDto = Partial<CreateChannelDto>;
|
||||
|
||||
export interface CreateChannelGroupDto {
|
||||
code: string;
|
||||
name: string;
|
||||
carrier: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
retryEnabled?: boolean;
|
||||
retryTimeLimitHours?: number;
|
||||
retryTimeLimitMinutes?: number;
|
||||
}
|
||||
|
||||
export interface CreateChannelGroupItemDto {
|
||||
groupId: string;
|
||||
channelId: string;
|
||||
carrier?: string;
|
||||
province?: string;
|
||||
priority?: number;
|
||||
weight?: number;
|
||||
isBackup?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateChannelGroupDto {
|
||||
code?: string;
|
||||
name?: string;
|
||||
carrier?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
retryEnabled?: boolean;
|
||||
retryTimeLimitHours?: number;
|
||||
retryTimeLimitMinutes?: number;
|
||||
items?: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>;
|
||||
}
|
||||
|
||||
export interface CreateRouteRuleDto {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
groupId: string;
|
||||
channelId?: string;
|
||||
carrier?: string;
|
||||
province?: string;
|
||||
priority?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface CreateReportFieldDto {
|
||||
channelId: string;
|
||||
drainageFieldId: string;
|
||||
reportType: 'signature' | 'drainage' | 'both';
|
||||
code?: string;
|
||||
name?: string;
|
||||
fieldType?: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
sortOrder?: number;
|
||||
exportName?: string;
|
||||
columnWidth?: number;
|
||||
imageWidth?: number;
|
||||
imageHeight?: number;
|
||||
defaultValue?: string;
|
||||
transform?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ReplaceReportFieldsDto {
|
||||
fields: Array<Omit<CreateReportFieldDto, 'channelId' | 'reportType'>>;
|
||||
}
|
||||
|
||||
export interface CreateReportMaterialDto {
|
||||
signatureId: string;
|
||||
channelId: string;
|
||||
fieldCode: string;
|
||||
fieldValue?: string;
|
||||
fileObjectId?: string;
|
||||
}
|
||||
|
||||
export interface CreateReportTaskDto {
|
||||
tenantId: string;
|
||||
signatureId: string;
|
||||
channelId: string;
|
||||
reportType?: 'signature' | 'drainage';
|
||||
drainageItemId?: string;
|
||||
createdById?: string;
|
||||
}
|
||||
|
||||
export interface ChangeReportTaskStatusesDto {
|
||||
items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>;
|
||||
reason?: string;
|
||||
operatorId?: string;
|
||||
sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report';
|
||||
}
|
||||
|
||||
export interface CreateReportExportDto {
|
||||
fileObjectId?: string;
|
||||
fileName: string;
|
||||
rowCount?: number;
|
||||
}
|
||||
|
||||
export interface CreateReceiptImportDto {
|
||||
fileObjectId?: string;
|
||||
fileName: string;
|
||||
fileContent?: string;
|
||||
delimiter?: ',' | '\t';
|
||||
rowCount?: number;
|
||||
successCount?: number;
|
||||
failedCount?: number;
|
||||
statusAfter?: string;
|
||||
reason?: string;
|
||||
result?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpsertConnectionStateDto {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId: string;
|
||||
connectionId: string;
|
||||
status: string;
|
||||
desiredConnections?: number;
|
||||
currentConnections?: number;
|
||||
lastConnectedAt?: string;
|
||||
lastDisconnectedAt?: string;
|
||||
lastHeartbeatAt?: string;
|
||||
reconnectCount?: number;
|
||||
lastReconnectAttemptAt?: string;
|
||||
nextReconnectAt?: string;
|
||||
lastErrorCategory?: string;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface ChangeChannelStatusDto {
|
||||
status: string;
|
||||
operatorId?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CopyChannelDto {
|
||||
name?: string;
|
||||
code?: string;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface TestChannelDto {
|
||||
phoneNumber?: string;
|
||||
phones?: string[] | string;
|
||||
content?: string;
|
||||
accessNo?: string;
|
||||
operatorId?: string;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { RequireRecentAuthentication } from '../auth/require-recent-authenticati
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { DeleteTargetDto, DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
|
||||
import {
|
||||
ChannelsService,
|
||||
ChangeChannelStatusDto,
|
||||
CopyChannelDto,
|
||||
CreateChannelDto,
|
||||
@@ -22,7 +21,8 @@ import {
|
||||
UpsertConnectionStateDto,
|
||||
UpdateChannelDto,
|
||||
UpdateChannelGroupDto,
|
||||
} from './channels.service';
|
||||
} from './channels.contracts';
|
||||
import { ChannelsService } from './channels.service';
|
||||
|
||||
@ApiTags('channels')
|
||||
@Controller('admin')
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
||||
|
||||
/** Constants and pure validation/normalization helpers shared by R5 domains. */
|
||||
export const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.commands';
|
||||
|
||||
export const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue';
|
||||
|
||||
export const GATEWAY_SUBMIT_STREAM = 'gateway.submit.commands';
|
||||
|
||||
export const DEFAULT_GATEWAY_CONTROL_URL = 'http://127.0.0.1:8090';
|
||||
|
||||
export const DEFAULT_CHANNEL_CONNECTION_ID = 'primary';
|
||||
|
||||
export const DEFAULT_CONNECTING_TIMEOUT_MS = 30_000;
|
||||
|
||||
export const DEFAULT_CONNECTING_TIMEOUT_SCAN_MS = 5_000;
|
||||
|
||||
export const DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS = 1_000;
|
||||
|
||||
export const DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS = 30_000;
|
||||
|
||||
export const DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS = 10_000;
|
||||
|
||||
export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 30;
|
||||
|
||||
export const DEFAULT_HEARTBEAT_MISS_THRESHOLD = 3;
|
||||
|
||||
export const HEARTBEAT_AUDIT_INTERVAL_MS = 5 * 60_000;
|
||||
|
||||
export const CONNECTING_TIMEOUT_ERROR = 'Gateway connection request timed out';
|
||||
|
||||
export const DEFAULT_CMPP_VERSION = '2.0';
|
||||
|
||||
export function normalizeTestPhones(data: TestChannelDto) {
|
||||
const rawPhones = Array.isArray(data.phones)
|
||||
? data.phones
|
||||
: String(data.phoneNumber ?? data.phones ?? '').split(/[,\n,\s]+/u);
|
||||
const phones = rawPhones.map((phone) => String(phone).trim()).filter(Boolean);
|
||||
const uniquePhones = Array.from(new Set(phones));
|
||||
if (uniquePhones.length === 0) {
|
||||
throw new BadRequestException('请填写测试手机号');
|
||||
}
|
||||
if (uniquePhones.length > 10) {
|
||||
throw new BadRequestException('测试手机号最多允许 10 个');
|
||||
}
|
||||
for (const phone of uniquePhones) {
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
throw new BadRequestException(`手机号格式不正确:${phone}`);
|
||||
}
|
||||
}
|
||||
return uniquePhones;
|
||||
}
|
||||
|
||||
export function normalizeTestContent(content?: string) {
|
||||
const normalized = (content ?? '').trim();
|
||||
if (!normalized) {
|
||||
throw new BadRequestException('请填写测试短信内容');
|
||||
}
|
||||
if (normalized.length > 1000) {
|
||||
throw new BadRequestException('测试短信内容不能超过 1000 字符');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function calculateBillingUnits(content: string) {
|
||||
return Math.max(1, Math.ceil([...content].length / 67));
|
||||
}
|
||||
|
||||
export function buildChannelTestSubmitCommand({
|
||||
channel,
|
||||
content,
|
||||
phoneNumber,
|
||||
messageId,
|
||||
submitId,
|
||||
testNo,
|
||||
attempt,
|
||||
accessNo,
|
||||
}: {
|
||||
channel: {
|
||||
id: string;
|
||||
code: string;
|
||||
gatewayHost: string;
|
||||
gatewayPort: number;
|
||||
account: string;
|
||||
passwordCipher: string;
|
||||
srcId: string;
|
||||
cmppVersion: string;
|
||||
rateLimitPerSecond: number;
|
||||
config?: Prisma.JsonValue | null;
|
||||
};
|
||||
content: string;
|
||||
phoneNumber: string;
|
||||
messageId: string;
|
||||
submitId: string;
|
||||
testNo: string;
|
||||
attempt: number;
|
||||
accessNo?: string;
|
||||
}) {
|
||||
const srcId = accessNo?.trim() ? `${channel.srcId}${accessNo.trim()}` : channel.srcId;
|
||||
return {
|
||||
schemaVersion: 'v1',
|
||||
messageType: 'SubmitCommand',
|
||||
traceId: randomUUID(),
|
||||
messageId,
|
||||
channelId: channel.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
tenantId: 'platform-channel-test',
|
||||
applicationId: 'admin-channel-test',
|
||||
taskId: testNo,
|
||||
submitId,
|
||||
queuePriority: 'normal',
|
||||
phoneNumber,
|
||||
content,
|
||||
signature: 'CHANNEL_TEST',
|
||||
templateId: 'admin-channel-test',
|
||||
billingUnits: calculateBillingUnits(content),
|
||||
route: {
|
||||
channelCode: channel.code,
|
||||
cmppAccountCode: channel.account,
|
||||
priority: attempt,
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
},
|
||||
cmpp: {
|
||||
serviceId: getStringConfigValue(channel.config, 'serviceId', 'SMS'),
|
||||
srcId,
|
||||
extensionDigits: normalizeExtensionDigits(getConfigValue(channel.config, 'extensionDigits')),
|
||||
registeredDelivery: 1,
|
||||
msgFmt: 8,
|
||||
},
|
||||
upstream: {
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: channel.gatewayPort,
|
||||
account: channel.account,
|
||||
passwordCipher: channel.passwordCipher,
|
||||
cmppVersion: channel.cmppVersion,
|
||||
desiredConnections: getPositiveRuntimeInteger(getConfigValue(channel.config, 'desiredConnections'), 1, 'desiredConnections'),
|
||||
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
|
||||
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||
'heartbeatIntervalSeconds',
|
||||
),
|
||||
heartbeatMissThreshold: getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatMissThreshold'),
|
||||
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
|
||||
'heartbeatMissThreshold',
|
||||
),
|
||||
},
|
||||
retry: { attempt: 0, maxAttempts: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
export function getConfigValue(config: Prisma.JsonValue | null | undefined, key: string) {
|
||||
if (config && typeof config === 'object' && !Array.isArray(config) && key in config) {
|
||||
return config[key as keyof typeof config];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getStringConfigValue(config: Prisma.JsonValue | null | undefined, key: string, fallback: string) {
|
||||
const value = getConfigValue(config, key);
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function normalizeConnectionAction(status: string) {
|
||||
const normalized = status.toLowerCase();
|
||||
if (normalized === 'connected') {
|
||||
return 'connected';
|
||||
}
|
||||
if (['heartbeat', 'active_test'].includes(normalized)) {
|
||||
return 'heartbeat';
|
||||
}
|
||||
if (['reconnecting', 'reconnect'].includes(normalized)) {
|
||||
return 'reconnecting';
|
||||
}
|
||||
if (['offline', 'closed', 'disconnected'].includes(normalized)) {
|
||||
return 'disconnected';
|
||||
}
|
||||
if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) {
|
||||
return 'failed';
|
||||
}
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
export function normalizeCmppVersion(version?: string) {
|
||||
const normalized = (version ?? DEFAULT_CMPP_VERSION).trim();
|
||||
if (normalized === '2.0' || normalized === '3.0') {
|
||||
return normalized;
|
||||
}
|
||||
throw new BadRequestException('cmppVersion must be 2.0 or 3.0');
|
||||
}
|
||||
|
||||
export function normalizeGatewayConnectionStatus(status: string) {
|
||||
const normalized = status.toLowerCase();
|
||||
if (['online', 'open', 'connected', 'heartbeat', 'active_test'].includes(normalized)) {
|
||||
return 'connected';
|
||||
}
|
||||
if (['connecting', 'connect_requested'].includes(normalized)) {
|
||||
return 'connecting';
|
||||
}
|
||||
if (['reconnecting', 'reconnect'].includes(normalized)) {
|
||||
return 'reconnecting';
|
||||
}
|
||||
if (['offline', 'closed', 'disconnected'].includes(normalized)) {
|
||||
return 'disconnected';
|
||||
}
|
||||
if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) {
|
||||
return 'failed';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function defaultChannelConnectionId(channelId: string) {
|
||||
return `${channelId}:${DEFAULT_CHANNEL_CONNECTION_ID}`;
|
||||
}
|
||||
|
||||
export function getDesiredConnections(config?: Prisma.JsonValue | null) {
|
||||
if (config && typeof config === 'object' && !Array.isArray(config) && 'desiredConnections' in config) {
|
||||
const value = Number(config.desiredConnections);
|
||||
if (Number.isInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
export type ChannelConnectionSettings = {
|
||||
gatewayHost: string;
|
||||
gatewayPort: number;
|
||||
account: string;
|
||||
passwordCipher: string;
|
||||
cmppVersion: string;
|
||||
config?: Prisma.JsonValue | Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function getRuntimeConfigInteger(
|
||||
config: Prisma.JsonValue | Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
fallback: number,
|
||||
) {
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) return fallback;
|
||||
const value = Number((config as Record<string, unknown>)[key]);
|
||||
return Number.isInteger(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
export function channelConnectionSettingsChanged(
|
||||
before: ChannelConnectionSettings,
|
||||
after: ChannelConnectionSettings,
|
||||
) {
|
||||
return before.gatewayHost !== after.gatewayHost
|
||||
|| before.gatewayPort !== after.gatewayPort
|
||||
|| before.account !== after.account
|
||||
|| before.passwordCipher !== after.passwordCipher
|
||||
|| before.cmppVersion !== after.cmppVersion
|
||||
|| getRuntimeConfigInteger(before.config, 'desiredConnections', 1)
|
||||
!== getRuntimeConfigInteger(after.config, 'desiredConnections', 1)
|
||||
|| getRuntimeConfigInteger(before.config, 'windowSize', 16)
|
||||
!== getRuntimeConfigInteger(after.config, 'windowSize', 16)
|
||||
|| getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
|
||||
!== getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
|
||||
|| getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD)
|
||||
!== getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD);
|
||||
}
|
||||
|
||||
export function channelGroupAuditSnapshot(group: {
|
||||
code: string;
|
||||
name: string;
|
||||
carrier: string;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
retryEnabled: boolean;
|
||||
retryTimeLimitMinutes: number;
|
||||
items?: Array<{
|
||||
channelId: string;
|
||||
carrier?: string | null;
|
||||
province?: string | null;
|
||||
priority: number;
|
||||
weight: number;
|
||||
isBackup: boolean;
|
||||
channel?: { code?: string; name?: string } | null;
|
||||
}>;
|
||||
}) {
|
||||
return {
|
||||
code: group.code,
|
||||
name: group.name,
|
||||
carrier: group.carrier,
|
||||
description: group.description ?? null,
|
||||
status: group.status,
|
||||
retryEnabled: group.retryEnabled,
|
||||
retryTimeLimitMinutes: group.retryTimeLimitMinutes,
|
||||
items: (group.items ?? []).map((item) => ({
|
||||
channelId: item.channelId,
|
||||
channelCode: item.channel?.code ?? null,
|
||||
channelName: item.channel?.name ?? null,
|
||||
carrier: item.carrier ?? null,
|
||||
province: item.province ?? null,
|
||||
priority: item.priority,
|
||||
weight: item.weight,
|
||||
isBackup: item.isBackup,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeChannelRuntimeConfig(
|
||||
existingConfig?: Prisma.JsonValue | Record<string, unknown> | null,
|
||||
incomingConfig?: Record<string, unknown> | null,
|
||||
desiredConnections?: number,
|
||||
windowSize?: number,
|
||||
heartbeatIntervalSeconds?: number,
|
||||
heartbeatMissThreshold?: number,
|
||||
) {
|
||||
const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
|
||||
? existingConfig as Record<string, unknown>
|
||||
: {};
|
||||
const incoming = incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig)
|
||||
? incomingConfig
|
||||
: {};
|
||||
const base = { ...existing, ...incoming };
|
||||
base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections');
|
||||
base.windowSize = getPositiveRuntimeInteger(windowSize ?? base.windowSize, 16, 'windowSize');
|
||||
base.heartbeatIntervalSeconds = getPositiveRuntimeInteger(
|
||||
heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds,
|
||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||
'heartbeatIntervalSeconds',
|
||||
);
|
||||
base.heartbeatMissThreshold = getPositiveRuntimeInteger(
|
||||
heartbeatMissThreshold ?? base.heartbeatMissThreshold,
|
||||
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
|
||||
'heartbeatMissThreshold',
|
||||
);
|
||||
base.extensionDigits = normalizeExtensionDigits(base.extensionDigits);
|
||||
base.serviceId = normalizeCmppServiceId(base.serviceId);
|
||||
return base;
|
||||
}
|
||||
|
||||
export function normalizeCmppServiceId(value: unknown) {
|
||||
const normalized = String(value ?? 'SMS').trim() || 'SMS';
|
||||
if (!/^[\x20-\x7E]{1,10}$/.test(normalized)) {
|
||||
throw new BadRequestException('serviceId must contain 1 to 10 ASCII characters');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeChannelRateLimit(value: unknown) {
|
||||
const normalized = getPositiveRuntimeInteger(value, 100, 'rateLimitPerSecond');
|
||||
if (normalized > 2000) {
|
||||
throw new BadRequestException('rateLimitPerSecond must be between 1 and 2000');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeExtensionDigits(value: unknown) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return 0;
|
||||
}
|
||||
const normalized = Number(value);
|
||||
if (!Number.isInteger(normalized) || normalized < 0 || normalized > 20) {
|
||||
throw new BadRequestException('extensionDigits must be an integer between 0 and 20');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getPositiveRuntimeInteger(value: unknown, fallback: number, fieldName: string) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
const normalized = Number(value);
|
||||
if (!Number.isInteger(normalized) || normalized <= 0) {
|
||||
throw new BadRequestException(`${fieldName} must be a positive integer`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function bullmqConnection() {
|
||||
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
|
||||
return {
|
||||
host: redisUrl.hostname,
|
||||
port: Number(redisUrl.port || 6379),
|
||||
username: redisUrl.username || undefined,
|
||||
password: redisUrl.password || undefined,
|
||||
maxRetriesPerRequest: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPositiveIntegerEnv(name: string, fallback: number) {
|
||||
const value = Number(process.env[name]);
|
||||
if (Number.isInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
|
||||
const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
throw new BadRequestException('Receipt file is empty');
|
||||
}
|
||||
const separator = delimiter ?? (lines[0].includes('\t') ? '\t' : ',');
|
||||
const firstCells = splitReceiptLine(lines[0], separator);
|
||||
const hasHeader = firstCells.some((cell) => ['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase()));
|
||||
const header = hasHeader ? firstCells : [];
|
||||
const rows = hasHeader ? lines.slice(1) : lines;
|
||||
const statusIndex = findReceiptStatusIndex(header);
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
const resultRows = rows.map((line, index) => {
|
||||
const cells = splitReceiptLine(line, separator);
|
||||
const rawStatus = cells[statusIndex] ?? cells[cells.length - 1] ?? '';
|
||||
const normalizedStatus = normalizeReceiptStatus(rawStatus);
|
||||
if (normalizedStatus === 'success') {
|
||||
successCount += 1;
|
||||
} else {
|
||||
failedCount += 1;
|
||||
}
|
||||
return {
|
||||
rowNumber: (hasHeader ? index + 2 : index + 1),
|
||||
phone: cells[0] ?? '',
|
||||
status: normalizedStatus,
|
||||
rawStatus,
|
||||
raw: cells,
|
||||
};
|
||||
});
|
||||
return {
|
||||
rowCount: resultRows.length,
|
||||
successCount,
|
||||
failedCount,
|
||||
result: {
|
||||
delimiter: separator === '\t' ? 'tab' : 'comma',
|
||||
hasHeader,
|
||||
rows: resultRows,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function splitReceiptLine(line: string, delimiter: ',' | '\t') {
|
||||
if (delimiter === '\t') {
|
||||
return line.split('\t').map((cell) => stripReceiptCell(cell));
|
||||
}
|
||||
const cells: string[] = [];
|
||||
let current = '';
|
||||
let quoted = false;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
const next = line[index + 1];
|
||||
if (char === '"' && quoted && next === '"') {
|
||||
current += '"';
|
||||
index += 1;
|
||||
} else if (char === '"') {
|
||||
quoted = !quoted;
|
||||
} else if (char === ',' && !quoted) {
|
||||
cells.push(stripReceiptCell(current));
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
cells.push(stripReceiptCell(current));
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function stripReceiptCell(value: string) {
|
||||
return value.trim().replace(/^"|"$/g, '').trim();
|
||||
}
|
||||
|
||||
export function findReceiptStatusIndex(header: string[]) {
|
||||
if (header.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
const index = header.findIndex((cell) => ['status', 'result', '状态', '结果'].includes(cell.toLowerCase()));
|
||||
return index >= 0 ? index : Math.max(0, header.length - 1);
|
||||
}
|
||||
|
||||
export function normalizeReceiptStatus(value: string) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['success', 'succeeded', 'approved', 'completed', 'ok', 'pass', 'passed', '通过', '成功', '已完成', '报备成功'].includes(normalized)) {
|
||||
return 'success';
|
||||
}
|
||||
if (['failed', 'fail', 'rejected', 'reject', 'error', 'no', 'denied', '驳回', '失败', '不通过', '拒绝', '报备失败'].includes(normalized)) {
|
||||
return 'failed';
|
||||
}
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
export function deriveReceiptStatus(rowCount: number, successCount: number, failedCount: number) {
|
||||
if (rowCount <= 0 || successCount <= 0) {
|
||||
return 'failed';
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
return 'partial';
|
||||
}
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
export type ChannelReportDeliveryRow = {
|
||||
channelId: string;
|
||||
signatureId: string;
|
||||
drainageInfoId: string | null;
|
||||
total: number;
|
||||
acceptedCount: number;
|
||||
submitFailureCount: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
lastSuccessfulSentAt: Date | null;
|
||||
};
|
||||
|
||||
export function summarizeChannelReportDelivery(rows: ChannelReportDeliveryRow[]) {
|
||||
const total = sumReportDelivery(rows, 'total');
|
||||
const acceptedCount = sumReportDelivery(rows, 'acceptedCount');
|
||||
const submitFailureCount = sumReportDelivery(rows, 'submitFailureCount');
|
||||
const successCount = sumReportDelivery(rows, 'successCount');
|
||||
const unknownCount = sumReportDelivery(rows, 'unknownCount');
|
||||
const failureCount = sumReportDelivery(rows, 'failureCount');
|
||||
return {
|
||||
total,
|
||||
acceptedCount,
|
||||
submitFailureCount,
|
||||
submitFailureRate: percentage(submitFailureCount, total),
|
||||
successCount,
|
||||
successRate: percentage(successCount, acceptedCount),
|
||||
unknownCount,
|
||||
unknownRate: percentage(unknownCount, acceptedCount),
|
||||
failureCount,
|
||||
failureRate: percentage(failureCount, acceptedCount),
|
||||
};
|
||||
}
|
||||
|
||||
export function sumReportDelivery(rows: ChannelReportDeliveryRow[], key: keyof Pick<
|
||||
ChannelReportDeliveryRow,
|
||||
'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount'
|
||||
>) {
|
||||
return rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
|
||||
}
|
||||
|
||||
export function percentage(count: number, total: number) {
|
||||
return total > 0 ? Number(((count * 100) / total).toFixed(1)) : 0;
|
||||
}
|
||||
|
||||
export function latestDate(values: Array<Date | null>) {
|
||||
const timestamps = values.filter((value): value is Date => Boolean(value)).map((value) => value.getTime());
|
||||
return timestamps.length > 0 ? new Date(Math.max(...timestamps)) : null;
|
||||
}
|
||||
|
||||
export function currentShanghaiDayRange(now = new Date()) {
|
||||
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1_000);
|
||||
const localDate = shifted.toISOString().slice(0, 10);
|
||||
const startAt = new Date(`${localDate}T00:00:00+08:00`);
|
||||
return { startAt, endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1_000) };
|
||||
}
|
||||
|
||||
export function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: number | undefined, fallbackMinutes: number) {
|
||||
const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60);
|
||||
if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) {
|
||||
throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeSpreadsheetSize(value: number | undefined, fallback: number, minimum: number, maximum: number) {
|
||||
if (value === undefined || !Number.isFinite(value)) return fallback;
|
||||
return Math.min(maximum, Math.max(minimum, Math.round(value)));
|
||||
}
|
||||
|
||||
export function normalizeBusinessCarrier(carrier?: string | null) {
|
||||
const normalized = normalizeChannelCarrier(carrier);
|
||||
if (!['mobile', 'unicom', 'telecom'].includes(normalized)) {
|
||||
throw new BadRequestException('carrier must be mobile, unicom, or telecom');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeChannelCarrier(carrier?: string | null) {
|
||||
const value = String(carrier ?? '').trim().toLowerCase();
|
||||
if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile';
|
||||
if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom';
|
||||
if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom';
|
||||
if (['all', 'tri', '三网', '全网'].includes(value)) return 'all';
|
||||
return value;
|
||||
}
|
||||
|
||||
export function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string) {
|
||||
const normalized = normalizeChannelCarrier(channelCarrier);
|
||||
return normalized === 'all' || normalized === groupCarrier;
|
||||
}
|
||||
|
||||
export function normalizeRegion(region?: string | null) {
|
||||
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
|
||||
}
|
||||
|
||||
export function isRegionCompatible(channelRegion: string | null | undefined, itemProvince: string) {
|
||||
return normalizeRegion(channelRegion) === normalizeRegion(itemProvince);
|
||||
}
|
||||
|
||||
export function validateGroupItems(
|
||||
groupCarrier: string,
|
||||
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
|
||||
channels: Map<string, { id: string; carrier?: string | null; sendRegion?: string | null }>,
|
||||
) {
|
||||
const channelIds = new Set<string>();
|
||||
const provinces = new Set<string>();
|
||||
const nationalPriorities = new Set<number>();
|
||||
for (const item of items) {
|
||||
const itemCarrier = item.carrier ? normalizeBusinessCarrier(item.carrier) : groupCarrier;
|
||||
if (itemCarrier !== groupCarrier) {
|
||||
throw new BadRequestException('Channel group items must use the same carrier as the channel group');
|
||||
}
|
||||
const channel = channels.get(item.channelId);
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
if (channelIds.has(item.channelId)) {
|
||||
throw new BadRequestException('通道组内不能重复配置同一通道');
|
||||
}
|
||||
channelIds.add(item.channelId);
|
||||
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) {
|
||||
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
|
||||
}
|
||||
if (item.province) {
|
||||
const province = normalizeRegion(item.province);
|
||||
if (provinces.has(province)) {
|
||||
throw new BadRequestException('同一通道组内同一省份只能配置一个通道');
|
||||
}
|
||||
provinces.add(province);
|
||||
if (!isRegionCompatible(channel.sendRegion, item.province)) {
|
||||
throw new BadRequestException('Province route must use a channel with the same sendRegion');
|
||||
}
|
||||
} else {
|
||||
const priority = item.priority ?? 100;
|
||||
if (nationalPriorities.has(priority)) {
|
||||
throw new BadRequestException('同一通道组内全国通道优先级不能重复');
|
||||
}
|
||||
nationalPriorities.add(priority);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeReportType(value?: string) {
|
||||
if (value === 'signature' || value === 'drainage' || value === 'both') return value;
|
||||
throw new BadRequestException('reportType must be signature, drainage or both');
|
||||
}
|
||||
|
||||
export function summarizeReportStatuses(statuses: string[]) {
|
||||
if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 };
|
||||
const approved = statuses.filter((status) => status === 'approved').length;
|
||||
let status = 'pending';
|
||||
if (approved === statuses.length) status = 'approved';
|
||||
else if (statuses.some((item) => ['failed', 'rejected'].includes(item))) status = 'failed';
|
||||
else if (statuses.some((item) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(item)) || approved > 0) status = 'reporting';
|
||||
else if (statuses.some((item) => item === 'waiting_material')) status = 'waiting_material';
|
||||
return { status, approved, total: statuses.length };
|
||||
}
|
||||
|
||||
export function normalizeLinkEvent(action: string) {
|
||||
if (action.includes('connect_requested')) {
|
||||
return '连接请求';
|
||||
}
|
||||
if (action.includes('connected')) {
|
||||
return '连接成功';
|
||||
}
|
||||
if (action.includes('heartbeat')) {
|
||||
return '心跳';
|
||||
}
|
||||
if (action.includes('reconnecting')) {
|
||||
return '重连';
|
||||
}
|
||||
if (action.includes('disconnected')) {
|
||||
return '断开';
|
||||
}
|
||||
if (action.includes('failed')) {
|
||||
return '连接失败';
|
||||
}
|
||||
if (action.includes('copy')) {
|
||||
return '复制';
|
||||
}
|
||||
if (action.includes('deleted')) {
|
||||
return '删除';
|
||||
}
|
||||
return '更新';
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
// Stable controller/query contracts extracted in R2.
|
||||
|
||||
export interface MessageQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId?: string;
|
||||
channelKeyword?: string;
|
||||
taskId?: string;
|
||||
messageId?: string;
|
||||
phoneNumber?: string;
|
||||
contentKeyword?: string;
|
||||
carrier?: string;
|
||||
status?: string;
|
||||
queuedAtFrom?: string;
|
||||
queuedAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface TraceQuery extends MessageQuery {
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
export interface OperationLogQuery {
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
keyword?: string;
|
||||
level?: string;
|
||||
module?: string;
|
||||
range?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface GatewaySubmitDeadLetterQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface DownstreamDeliveryQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
deliveryType?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
}
|
||||
|
||||
export interface DownstreamDeliveryDashboardQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
deliveryType?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
}
|
||||
|
||||
export interface DownstreamRecoveryStatusQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
state?: string;
|
||||
failureCategory?: string;
|
||||
keyword?: string;
|
||||
updatedAtFrom?: string;
|
||||
updatedAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface MessageSegmentAuditQuery {
|
||||
messageId?: string;
|
||||
messageRecordId?: string;
|
||||
}
|
||||
|
||||
export interface SignatureQualityQuery {
|
||||
date?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from './operations.contracts';
|
||||
|
||||
// Pure query builders and response mappers shared by the R2 query domains.
|
||||
export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
|
||||
const statusWhere = query.status === 'submit_failed'
|
||||
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
|
||||
: query.status === 'failed'
|
||||
? { status: 'failed', submitStatus: 'accepted' }
|
||||
: query.status
|
||||
? { status: query.status }
|
||||
: {};
|
||||
return {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
channelId: query.channelId,
|
||||
batchTaskId: query.taskId,
|
||||
messageId: query.messageId,
|
||||
phoneNumber: query.phoneNumber,
|
||||
...carrierWhere(query.carrier),
|
||||
...statusWhere,
|
||||
...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}),
|
||||
...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}),
|
||||
...(query.queuedAtFrom || query.queuedAtTo ? {
|
||||
queuedAt: {
|
||||
...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}),
|
||||
...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}),
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export const recognizedCarrierValues = [
|
||||
'mobile', 'cmcc', '移动', '中国移动',
|
||||
'unicom', 'cucc', '联通', '中国联通',
|
||||
'telecom', 'ctcc', '电信', '中国电信',
|
||||
];
|
||||
export function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInput {
|
||||
if (!carrier) return {};
|
||||
// Keep historical aliases queryable while treating null and future/nonstandard values as unrecognized.
|
||||
if (carrier === 'unknown') {
|
||||
return {
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ carrier: null },
|
||||
{ carrier: { notIn: recognizedCarrierValues } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const valuesByCarrier: Record<string, string[]> = {
|
||||
mobile: ['mobile', 'cmcc', '移动', '中国移动'],
|
||||
unicom: ['unicom', 'cucc', '联通', '中国联通'],
|
||||
telecom: ['telecom', 'ctcc', '电信', '中国电信'],
|
||||
};
|
||||
return valuesByCarrier[carrier] ? { carrier: { in: valuesByCarrier[carrier] } } : {};
|
||||
}
|
||||
export function startOfShanghaiDay(value: string) {
|
||||
return new Date(`${value}T00:00:00+08:00`);
|
||||
}
|
||||
export function endOfShanghaiDay(value: string) {
|
||||
return new Date(`${value}T23:59:59.999+08:00`);
|
||||
}
|
||||
export function qualityBusinessDay(value?: string) {
|
||||
const key = value || shanghaiDateKey();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) {
|
||||
throw new BadRequestException('统计日期格式必须为 YYYY-MM-DD');
|
||||
}
|
||||
const startAt = startOfShanghaiDay(key);
|
||||
if (Number.isNaN(startAt.getTime()) || shanghaiDateKey(startAt) !== key) {
|
||||
throw new BadRequestException('统计日期无效');
|
||||
}
|
||||
return {
|
||||
key,
|
||||
startAt,
|
||||
endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
export function shanghaiDateKey(value = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(value);
|
||||
const byType = new Map(parts.map((part) => [part.type, part.value]));
|
||||
return `${byType.get('year')}-${byType.get('month')}-${byType.get('day')}`;
|
||||
}
|
||||
export function normalizeGroupBy(groupBy?: string) {
|
||||
if (groupBy === 'tenant' || groupBy === 'tenantId') {
|
||||
return 'tenantId';
|
||||
}
|
||||
if (groupBy === 'application' || groupBy === 'applicationId') {
|
||||
return 'applicationId';
|
||||
}
|
||||
return 'channelId';
|
||||
}
|
||||
export function returnedTransactionWhere(since: Date, tenantId?: string): Prisma.AccountTransactionWhereInput {
|
||||
return {
|
||||
tenantId,
|
||||
createdAt: { gte: since },
|
||||
OR: [
|
||||
{ transactionType: 'refunded' },
|
||||
{ transactionType: 'released', relatedType: 'sms_message_record' },
|
||||
],
|
||||
};
|
||||
}
|
||||
export function createdAtRange(range?: string): Prisma.DateTimeFilter | undefined {
|
||||
if (!range || range === 'all') {
|
||||
return undefined;
|
||||
}
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
if (range === '7d') {
|
||||
date.setDate(date.getDate() - 6);
|
||||
} else if (range === '30d') {
|
||||
date.setDate(date.getDate() - 29);
|
||||
}
|
||||
return { gte: date };
|
||||
}
|
||||
export function downstreamAlertPendingMinutes() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_ALERT_PENDING_MINUTES ?? 10);
|
||||
return Number.isFinite(value) && value > 0 ? value : 10;
|
||||
}
|
||||
export function downstreamAlertRecentFailedHours() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_ALERT_RECENT_FAILED_HOURS ?? 1);
|
||||
return Number.isFinite(value) && value > 0 ? value : 1;
|
||||
}
|
||||
export function downstreamAlertWindows(now = new Date()) {
|
||||
return {
|
||||
now,
|
||||
stalledPendingAt: new Date(now.getTime() - downstreamAlertPendingMinutes() * 60_000),
|
||||
recentFailedAt: new Date(now.getTime() - downstreamAlertRecentFailedHours() * 60 * 60_000),
|
||||
};
|
||||
}
|
||||
export function downstreamAlertWhere(
|
||||
scopedWhere: Prisma.CmppDownstreamDeliveryWhereInput,
|
||||
window: ReturnType<typeof downstreamAlertWindows>,
|
||||
): Prisma.CmppDownstreamDeliveryWhereInput {
|
||||
return {
|
||||
AND: [
|
||||
scopedWhere,
|
||||
{
|
||||
OR: [
|
||||
stalledPendingWhere(window.stalledPendingAt),
|
||||
{ status: 'awaiting_ack', ackDeadlineAt: { lte: window.now } },
|
||||
{ status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: window.recentFailedAt } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
export function stalledPendingWhere(cutoff: Date): Prisma.CmppDownstreamDeliveryWhereInput {
|
||||
return {
|
||||
status: 'pending',
|
||||
OR: [
|
||||
{ lastRetriedAt: null, createdAt: { lte: cutoff } },
|
||||
{ lastRetriedAt: { lte: cutoff } },
|
||||
],
|
||||
};
|
||||
}
|
||||
export function downstreamDeliveryScopedWhere(query: DownstreamDeliveryDashboardQuery): Prisma.CmppDownstreamDeliveryWhereInput {
|
||||
const createdAtFrom = parseDateBoundary(query.createdAtFrom, false);
|
||||
const createdAtTo = parseDateBoundary(query.createdAtTo, true);
|
||||
return {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
deliveryType: query.deliveryType && query.deliveryType !== 'all' ? query.deliveryType : undefined,
|
||||
createdAt: createdAtFrom || createdAtTo ? { gte: createdAtFrom, lte: createdAtTo } : undefined,
|
||||
};
|
||||
}
|
||||
export function parseDateBoundary(value?: string, endOfDay = false) {
|
||||
if (!value) return undefined;
|
||||
const parsed = new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}+08:00`);
|
||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
||||
}
|
||||
export function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) {
|
||||
const updatedAtFrom = parseDateBoundary(query.updatedAtFrom, false);
|
||||
const updatedAtTo = parseDateBoundary(query.updatedAtTo, true);
|
||||
return {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
state: query.state && query.state !== 'all' ? query.state : undefined,
|
||||
failureCategory: query.failureCategory && query.failureCategory !== 'all' ? query.failureCategory : undefined,
|
||||
updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ account: { contains: query.keyword } },
|
||||
{ gatewayInstanceId: { contains: query.keyword } },
|
||||
{ lastError: { contains: query.keyword } },
|
||||
{ lastSkipReason: { contains: query.keyword } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
};
|
||||
}
|
||||
export function escapeCsvCell(value: string) {
|
||||
let normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
if (/^[=+\-@]/.test(normalized)) {
|
||||
normalized = `'${normalized}`;
|
||||
}
|
||||
if (normalized.includes(',') || normalized.includes('"') || normalized.includes('\n')) {
|
||||
return `"${normalized.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
export function formatCsvDate(value?: Date | string | null) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
return value instanceof Date ? value.toISOString() : value;
|
||||
}
|
||||
export function formatExportTimestamp(date: Date) {
|
||||
const parts = [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
String(date.getHours()).padStart(2, '0'),
|
||||
String(date.getMinutes()).padStart(2, '0'),
|
||||
String(date.getSeconds()).padStart(2, '0'),
|
||||
];
|
||||
return `${parts[0]}${parts[1]}${parts[2]}-${parts[3]}${parts[4]}${parts[5]}`;
|
||||
}
|
||||
export function clientApplicationView(application?: Record<string, any> | null) {
|
||||
if (!application) return null;
|
||||
return { id: application.id, name: application.name };
|
||||
}
|
||||
export function clientReceiptView(receipt: Record<string, any>) {
|
||||
return {
|
||||
id: receipt.id,
|
||||
messageId: receipt.messageId,
|
||||
receiptStatus: receipt.receiptStatus,
|
||||
rawStatus: receipt.rawStatus,
|
||||
errorCode: receipt.errorCode ?? null,
|
||||
errorMessage: receipt.errorMessage ?? null,
|
||||
deliveredAt: receipt.deliveredAt,
|
||||
createdAt: receipt.createdAt,
|
||||
};
|
||||
}
|
||||
export function clientMessageView(message: Record<string, any>) {
|
||||
return {
|
||||
id: message.id,
|
||||
batchTaskId: message.batchTaskId ?? null,
|
||||
applicationId: message.applicationId ?? null,
|
||||
messageId: message.messageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
carrier: message.carrier ?? null,
|
||||
province: message.province ?? null,
|
||||
content: message.content,
|
||||
billingUnits: message.billingUnits,
|
||||
amountCents: moneyToNumber(message.amountCents),
|
||||
status: message.status,
|
||||
submitStatus: message.submitStatus ?? null,
|
||||
receiptStatus: message.receiptStatus ?? null,
|
||||
errorCode: message.errorCode ?? null,
|
||||
errorMessage: message.errorMessage ?? null,
|
||||
queuedAt: message.queuedAt,
|
||||
submittedAt: message.submittedAt ?? null,
|
||||
deliveredAt: message.deliveredAt ?? null,
|
||||
application: clientApplicationView(message.application),
|
||||
receiptRecords: Array.isArray(message.receiptRecords) ? message.receiptRecords.map(clientReceiptView) : [],
|
||||
};
|
||||
}
|
||||
export function clientBatchTaskView(task: Record<string, any>) {
|
||||
return {
|
||||
id: task.id,
|
||||
taskNo: task.taskNo,
|
||||
applicationId: task.applicationId ?? null,
|
||||
templateId: task.templateId ?? null,
|
||||
content: task.content,
|
||||
category: task.category ?? null,
|
||||
phoneTotal: task.phoneTotal,
|
||||
status: task.status,
|
||||
auditStatus: task.auditStatus ?? null,
|
||||
reviewReason: task.reviewReason ?? null,
|
||||
rejectReason: task.rejectReason ?? null,
|
||||
progressTotal: task.progressTotal,
|
||||
progressSent: task.progressSent ?? 0,
|
||||
progressDelivered: task.progressDelivered ?? 0,
|
||||
progressFailed: task.progressFailed ?? 0,
|
||||
submittedTotal: task.submittedTotal ?? 0,
|
||||
successTotal: task.successTotal ?? 0,
|
||||
failedTotal: task.failedTotal ?? 0,
|
||||
unknownTotal: task.unknownTotal ?? 0,
|
||||
timeoutTotal: task.timeoutTotal ?? 0,
|
||||
scheduledAt: task.scheduledAt ?? null,
|
||||
canceledAt: task.canceledAt ?? null,
|
||||
createdAt: task.createdAt,
|
||||
application: clientApplicationView(task.application),
|
||||
messages: Array.isArray(task.messages) ? task.messages.map(clientMessageView) : [],
|
||||
};
|
||||
}
|
||||
export function clientUplinkView(message: Record<string, any>) {
|
||||
return {
|
||||
id: message.id,
|
||||
applicationId: message.applicationId ?? null,
|
||||
messageRecordId: message.messageRecordId ?? null,
|
||||
messageId: message.messageId ?? null,
|
||||
phoneNumber: message.phoneNumber,
|
||||
destId: message.destId,
|
||||
content: message.content,
|
||||
matchStatus: message.matchStatus,
|
||||
matchReason: message.matchReason ?? null,
|
||||
receivedAt: message.receivedAt,
|
||||
createdAt: message.createdAt,
|
||||
application: clientApplicationView(message.application),
|
||||
messageRecord: message.messageRecord ? clientMessageView(message.messageRecord) : null,
|
||||
};
|
||||
}
|
||||
export function clientAccountView(account: Record<string, any>) {
|
||||
return {
|
||||
id: account.id,
|
||||
tenantId: account.tenantId,
|
||||
balanceCents: moneyToNumber(account.balanceCents),
|
||||
creditCents: moneyToNumber(account.creditCents),
|
||||
status: account.status,
|
||||
updatedAt: account.updatedAt,
|
||||
tenant: account.tenant ? { id: account.tenant.id, name: account.tenant.name, status: account.tenant.status } : null,
|
||||
};
|
||||
}
|
||||
export function clientRechargeView(order: Record<string, any>) {
|
||||
return {
|
||||
id: order.id,
|
||||
orderNo: order.orderNo,
|
||||
amountCents: moneyToNumber(order.amountCents),
|
||||
status: order.status,
|
||||
payMethod: order.payMethod,
|
||||
remark: order.remark ?? null,
|
||||
createdAt: order.createdAt,
|
||||
completedAt: order.completedAt ?? null,
|
||||
};
|
||||
}
|
||||
export function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | bigint | null; billingUnits: number | null } }>) {
|
||||
return groups.reduce(
|
||||
(summary, group) => {
|
||||
const count = group._count._all;
|
||||
summary.total += count;
|
||||
summary.amountCents += moneyToNumber(group._sum.amountCents);
|
||||
summary.billingUnits += group._sum.billingUnits ?? 0;
|
||||
if (group.status === 'delivered') {
|
||||
summary.delivered += count;
|
||||
} else if (['undelivered', 'submit_failed', 'timeout', 'failed', 'rejected'].includes(group.status)) {
|
||||
summary.failed += count;
|
||||
} else if (group.status === 'unknown') {
|
||||
summary.unknown += count;
|
||||
}
|
||||
return summary;
|
||||
},
|
||||
{ total: 0, delivered: 0, failed: 0, unknown: 0, amountCents: 0, billingUnits: 0 },
|
||||
);
|
||||
}
|
||||
export function groupDownstreamByType(
|
||||
groups: Array<{ deliveryType: string; status: string; _count: { _all: number } }>,
|
||||
) {
|
||||
return groups.reduce<Record<string, { total: number; pending: number; awaitingAck: number; delivered: number; failed: number; unconfirmed: number; rejected: number }>>((accumulator, item) => {
|
||||
const current = accumulator[item.deliveryType] ?? { total: 0, pending: 0, awaitingAck: 0, delivered: 0, failed: 0, unconfirmed: 0, rejected: 0 };
|
||||
current.total += item._count._all;
|
||||
if (item.status === 'pending') {
|
||||
current.pending += item._count._all;
|
||||
} else if (item.status === 'awaiting_ack') {
|
||||
current.awaitingAck += item._count._all;
|
||||
} else if (item.status === 'delivered') {
|
||||
current.delivered += item._count._all;
|
||||
} else if (item.status === 'failed') {
|
||||
current.failed += item._count._all;
|
||||
} else if (item.status === 'unconfirmed') {
|
||||
current.unconfirmed += item._count._all;
|
||||
} else if (item.status === 'rejected') {
|
||||
current.rejected += item._count._all;
|
||||
}
|
||||
accumulator[item.deliveryType] = current;
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
export function groupDownstreamByApplication(
|
||||
groups: Array<{ applicationId: string; status: string; _count: { _all: number } }>,
|
||||
applicationMap: Map<string, string>,
|
||||
applicationAlertMap: Map<string, number>,
|
||||
) {
|
||||
const summaryMap = new Map<string, { applicationId: string; name: string; pending: number; awaitingAck: number; failed: number; unconfirmed: number; rejected: number; delivered: number; alertCount: number }>();
|
||||
groups.forEach((item) => {
|
||||
const current = summaryMap.get(item.applicationId) ?? {
|
||||
applicationId: item.applicationId,
|
||||
name: applicationMap.get(item.applicationId) ?? item.applicationId,
|
||||
pending: 0,
|
||||
awaitingAck: 0,
|
||||
failed: 0,
|
||||
unconfirmed: 0,
|
||||
rejected: 0,
|
||||
delivered: 0,
|
||||
alertCount: 0,
|
||||
};
|
||||
if (item.status === 'pending') {
|
||||
current.pending += item._count._all;
|
||||
} else if (item.status === 'awaiting_ack') {
|
||||
current.awaitingAck += item._count._all;
|
||||
} else if (item.status === 'failed') {
|
||||
current.failed += item._count._all;
|
||||
} else if (item.status === 'unconfirmed') {
|
||||
current.unconfirmed += item._count._all;
|
||||
} else if (item.status === 'rejected') {
|
||||
current.rejected += item._count._all;
|
||||
} else if (item.status === 'delivered') {
|
||||
current.delivered += item._count._all;
|
||||
}
|
||||
current.alertCount = applicationAlertMap.get(item.applicationId) ?? 0;
|
||||
summaryMap.set(item.applicationId, current);
|
||||
});
|
||||
return [...summaryMap.values()];
|
||||
}
|
||||
export function positiveInteger(value: number | undefined, fallback: number) {
|
||||
const normalized = Number(value);
|
||||
return Number.isInteger(normalized) && normalized > 0 ? normalized : fallback;
|
||||
}
|
||||
export function operationLogLevelWhere(level: string): Prisma.OperationLogWhereInput {
|
||||
const error: Prisma.OperationLogWhereInput = {
|
||||
OR: [
|
||||
{ action: { contains: 'failed' } },
|
||||
{ action: { contains: 'reject' } },
|
||||
{ detail: { path: ['result'], string_contains: 'fail' } },
|
||||
{ detail: { path: ['status'], string_contains: 'fail' } },
|
||||
],
|
||||
};
|
||||
const warning: Prisma.OperationLogWhereInput = {
|
||||
OR: [
|
||||
{ action: { contains: 'warning' } },
|
||||
{ action: { contains: 'risk' } },
|
||||
],
|
||||
};
|
||||
const success: Prisma.OperationLogWhereInput = {
|
||||
OR: [
|
||||
{ action: { contains: 'approve' } },
|
||||
{ action: { contains: 'recharge' } },
|
||||
{ action: { contains: 'connected' } },
|
||||
],
|
||||
};
|
||||
if (level === 'error') {
|
||||
return error;
|
||||
}
|
||||
if (level === 'warning') {
|
||||
return { AND: [{ NOT: error }, warning] };
|
||||
}
|
||||
if (level === 'success') {
|
||||
return { AND: [{ NOT: error }, { NOT: warning }, success] };
|
||||
}
|
||||
if (level === 'info') {
|
||||
return { NOT: { OR: [error, warning, success] } };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
export function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) {
|
||||
const detail = (log.detail ?? {}) as Record<string, unknown>;
|
||||
const result = String(detail.result ?? detail.status ?? '');
|
||||
const level = result.includes('fail') || log.action.includes('failed') || log.action.includes('reject')
|
||||
? 'error'
|
||||
: log.action.includes('warning') || log.action.includes('risk')
|
||||
? 'warning'
|
||||
: log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected')
|
||||
? 'success'
|
||||
: 'info';
|
||||
return {
|
||||
id: log.id,
|
||||
time: log.createdAt,
|
||||
level,
|
||||
tenant: log.tenant?.name ?? (log.tenantId ? log.tenantId : '平台'),
|
||||
module: log.resource,
|
||||
operator: log.user?.displayName ?? log.user?.username ?? log.userId ?? 'system',
|
||||
action: log.action,
|
||||
resourceId: log.resourceId ?? '',
|
||||
detail,
|
||||
ip: log.ipAddress ?? '',
|
||||
userAgent: log.userAgent ?? '',
|
||||
};
|
||||
}
|
||||
export function sanitizeGatewaySubmitException(
|
||||
item: Prisma.GatewaySubmitDeadLetterGetPayload<{ include: { tenant: true; application: true; channel: true } }>,
|
||||
messageState?: { status: string; submitStatus: string | null; receiptStatus: string | null; phoneNumber: string; content: string },
|
||||
) {
|
||||
const { rawPayload, commandPayload, tenant, application, channel, ...record } = item;
|
||||
return {
|
||||
...record,
|
||||
tenant: tenant ? { id: tenant.id, name: tenant.name, code: tenant.code, status: tenant.status } : null,
|
||||
application: application ? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status } : null,
|
||||
channel: channel ? {
|
||||
id: channel.id,
|
||||
code: channel.code,
|
||||
name: channel.name,
|
||||
status: channel.status,
|
||||
carrier: channel.carrier,
|
||||
sendRegion: channel.sendRegion,
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
} : null,
|
||||
rawPayloadAvailable: Boolean(rawPayload),
|
||||
commandPayload: redactGatewayCommandValue(commandPayload),
|
||||
messageState: messageState ?? null,
|
||||
};
|
||||
}
|
||||
export function redactGatewayCommandValue(value: Prisma.JsonValue | null): Prisma.JsonValue | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => redactGatewayCommandValue(item));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const redacted: Record<string, Prisma.JsonValue | null> = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
redacted[key] = [
|
||||
'password', 'passwordcipher', 'secret', 'secrethash', 'authsource',
|
||||
'token', 'apikey', 'accesskey', 'secretkey',
|
||||
].includes(normalizedKey)
|
||||
? '[REDACTED]'
|
||||
: redactGatewayCommandValue(child as Prisma.JsonValue);
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -6,6 +6,9 @@ function createPrismaMock() {
|
||||
user: {
|
||||
findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }),
|
||||
},
|
||||
tenant: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '企业A' }),
|
||||
},
|
||||
smsBatchTask: {
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', taskNo: 'BATCH-1' }]),
|
||||
count: jest.fn().mockResolvedValue(3),
|
||||
@@ -50,6 +53,7 @@ function createPrismaMock() {
|
||||
},
|
||||
enterpriseCertification: {
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'certification-1' }),
|
||||
},
|
||||
smsApplication: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
@@ -388,6 +392,22 @@ describe('OperationsService', () => {
|
||||
|
||||
expect(dashboard.gatewayConnections).toEqual([]);
|
||||
expect(dashboard.recentTasks).toEqual([expect.objectContaining({ id: 'task-1', taskNo: 'BATCH-1' })]);
|
||||
expect(dashboard.clientOverview).toEqual({
|
||||
enterpriseName: '企业A',
|
||||
certificationStatus: 'certified',
|
||||
signatureCount: 1,
|
||||
pendingBatchTaskCount: 3,
|
||||
});
|
||||
expect(prisma.smsBatchTask.count).toHaveBeenCalledWith({
|
||||
where: { tenantId: 'tenant-1', sourceType: 'client', status: 'pending_review' },
|
||||
});
|
||||
expect(prisma.enterpriseCertification.findFirst).toHaveBeenCalledWith({
|
||||
where: { tenantId: 'tenant-1', status: 'approved' },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(prisma.smsSignature.count).toHaveBeenCalledWith({
|
||||
where: { tenantId: 'tenant-1', auditStatus: { notIn: ['deleted', 'disabled'] } },
|
||||
});
|
||||
expect(JSON.stringify(dashboard)).not.toMatch(/passwordCipher|supplier|cipher|unitPrice/);
|
||||
});
|
||||
|
||||
@@ -480,6 +500,10 @@ describe('OperationsService', () => {
|
||||
updatedAt: { gte: expect.any(Date) },
|
||||
},
|
||||
});
|
||||
const hourlyTrendQuery = prisma.$queryRaw.mock.calls[1]?.[0] as { sql?: string };
|
||||
expect(hourlyTrendQuery.sql).toContain(
|
||||
`HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'`,
|
||||
);
|
||||
expect(prisma.accountTransaction.aggregate).toHaveBeenCalledWith({
|
||||
where: {
|
||||
tenantId: 'tenant-1',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,377 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { moneyToNumber } from '../../common/money';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
|
||||
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
|
||||
|
||||
// R2 dashboard query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
||||
export class OperationsDashboardQueries {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async dashboard(query: { tenantId?: string }) {
|
||||
const businessDay = qualityBusinessDay();
|
||||
const sinceToday = businessDay.startAt;
|
||||
const downstreamAlertWindow = downstreamAlertWindows();
|
||||
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
|
||||
const todayMessageWhereClause = {
|
||||
...messageWhereClause,
|
||||
queuedAt: { gte: sinceToday, lt: businessDay.endAt },
|
||||
};
|
||||
const [
|
||||
taskCount,
|
||||
messageGroups,
|
||||
todayMessageGroups,
|
||||
uplinkCount,
|
||||
billingAggregate,
|
||||
transactionAggregate,
|
||||
connectionGroups,
|
||||
pendingAudits,
|
||||
tenantAccounts,
|
||||
recentTasks,
|
||||
recentRecharges,
|
||||
enterpriseSpendRows,
|
||||
downstreamPendingCount,
|
||||
downstreamFailedCount,
|
||||
downstreamDeliveredCount,
|
||||
downstreamStalledPendingCount,
|
||||
downstreamStalledAckCount,
|
||||
downstreamRecentFailedCount,
|
||||
hourlySendRows,
|
||||
auditSpeedRows,
|
||||
] = await Promise.all([
|
||||
this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
|
||||
this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['status'],
|
||||
where: messageWhereClause,
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
}),
|
||||
this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['status'],
|
||||
where: todayMessageWhereClause,
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
}),
|
||||
this.prisma.smsUplinkMessage.count({ where: { tenantId: query.tenantId } }),
|
||||
this.prisma.smsBillingRecord.aggregate({
|
||||
where: { tenantId: query.tenantId },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.accountTransaction.aggregate({
|
||||
where: returnedTransactionWhere(sinceToday, query.tenantId),
|
||||
_sum: { amountCents: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.cmppConnectionState.groupBy({
|
||||
by: ['status'],
|
||||
where: { tenantId: query.tenantId },
|
||||
_count: { _all: true },
|
||||
_sum: { currentConnections: true, desiredConnections: true },
|
||||
}),
|
||||
this.countPendingAudits(query.tenantId),
|
||||
this.prisma.tenantAccount.findMany({
|
||||
where: query.tenantId ? { tenantId: query.tenantId } : undefined,
|
||||
include: { tenant: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
this.prisma.smsBatchTask.findMany({
|
||||
where: query.tenantId ? { tenantId: query.tenantId } : undefined,
|
||||
include: { application: true, messages: { take: 1, include: { channel: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
this.prisma.rechargeOrder.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
payMethod: 'manual_topup',
|
||||
},
|
||||
include: { tenant: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
this.prisma.$queryRaw<Array<{
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
todaySpendCents: bigint;
|
||||
balanceCents: bigint;
|
||||
creditCents: bigint;
|
||||
}>>(Prisma.sql`
|
||||
SELECT
|
||||
tenant.id AS "tenantId",
|
||||
tenant.name AS "tenantName",
|
||||
COALESCE(SUM(billing."amountCents") FILTER (WHERE billing."billingStatus" = 'charged'), 0)::bigint AS "todaySpendCents",
|
||||
account."balanceCents" AS "balanceCents",
|
||||
account."creditCents" AS "creditCents"
|
||||
FROM "TenantAccount" account
|
||||
JOIN "Tenant" tenant ON tenant.id = account."tenantId"
|
||||
LEFT JOIN "SmsBillingRecord" billing
|
||||
ON billing."tenantId" = tenant.id
|
||||
AND billing."createdAt" >= ${businessDay.startAt}
|
||||
AND billing."createdAt" < ${businessDay.endAt}
|
||||
WHERE tenant.status <> 'deleted'
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR tenant.id = ${query.tenantId ?? null})
|
||||
GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents"
|
||||
ORDER BY "todaySpendCents" DESC, tenant.name ASC
|
||||
`),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { tenantId: query.tenantId, status: 'pending' },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { tenantId: query.tenantId, status: 'failed' },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { tenantId: query.tenantId, status: 'delivered' },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
...stalledPendingWhere(downstreamAlertWindow.stalledPendingAt),
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
status: 'awaiting_ack',
|
||||
ackDeadlineAt: { lte: downstreamAlertWindow.now },
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
status: { in: ['failed', 'unconfirmed', 'rejected'] },
|
||||
updatedAt: { gte: downstreamAlertWindow.recentFailedAt },
|
||||
},
|
||||
}),
|
||||
this.prisma.$queryRaw<Array<{
|
||||
hour: number;
|
||||
submittedCount: bigint;
|
||||
successCount: bigint;
|
||||
}>>(Prisma.sql`
|
||||
SELECT
|
||||
EXTRACT(
|
||||
HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'
|
||||
)::integer AS hour,
|
||||
COUNT(*)::bigint AS "submittedCount",
|
||||
COUNT(*) FILTER (WHERE message.status = 'delivered')::bigint AS "successCount"
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."queuedAt" >= ${businessDay.startAt}
|
||||
AND message."queuedAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
`),
|
||||
// Signature/template tables have no review timestamps, so their latest pending audit is paired with the review audit.
|
||||
this.prisma.$queryRaw<Array<{
|
||||
category: string;
|
||||
count: bigint;
|
||||
averageProcessingMs: bigint | null;
|
||||
}>>(Prisma.sql`
|
||||
WITH review_samples AS (
|
||||
SELECT
|
||||
'enterpriseCertifications'::text AS category,
|
||||
certification."submittedAt" AS "submittedAt",
|
||||
certification."reviewedAt" AS "reviewedAt"
|
||||
FROM "EnterpriseCertification" certification
|
||||
WHERE certification."reviewedAt" >= ${businessDay.startAt}
|
||||
AND certification."reviewedAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR certification."tenantId" = ${query.tenantId ?? null})
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'smsAudits'::text,
|
||||
task."createdAt",
|
||||
task."reviewedAt"
|
||||
FROM "SmsSendTask" task
|
||||
WHERE task."reviewedAt" >= ${businessDay.startAt}
|
||||
AND task."reviewedAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR task."tenantId" = ${query.tenantId ?? null})
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'drainageInfos'::text,
|
||||
drainage."submittedAt",
|
||||
drainage."reviewedAt"
|
||||
FROM "SmsDrainageInfo" drainage
|
||||
WHERE drainage."reviewedAt" >= ${businessDay.startAt}
|
||||
AND drainage."reviewedAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR drainage."tenantId" = ${query.tenantId ?? null})
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
CASE review."targetType"
|
||||
WHEN 'sms_signature' THEN 'signatures'
|
||||
WHEN 'sms_template' THEN 'templates'
|
||||
END,
|
||||
submission."createdAt",
|
||||
review."createdAt"
|
||||
FROM "AuditRecord" review
|
||||
JOIN LATERAL (
|
||||
SELECT pending."createdAt"
|
||||
FROM "AuditRecord" pending
|
||||
WHERE pending."targetType" = review."targetType"
|
||||
AND pending."targetId" = review."targetId"
|
||||
AND pending."statusAfter" = 'pending'
|
||||
AND pending."createdAt" <= review."createdAt"
|
||||
ORDER BY pending."createdAt" DESC
|
||||
LIMIT 1
|
||||
) submission ON true
|
||||
WHERE review."targetType" IN ('sms_signature', 'sms_template')
|
||||
AND review."statusBefore" = 'pending'
|
||||
AND review."statusAfter" IN ('approved', 'rejected')
|
||||
AND review."createdAt" >= ${businessDay.startAt}
|
||||
AND review."createdAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR review."tenantId" = ${query.tenantId ?? null})
|
||||
)
|
||||
SELECT
|
||||
category,
|
||||
COUNT(*)::bigint AS count,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM ("reviewedAt" - "submittedAt")) * 1000))::bigint AS "averageProcessingMs"
|
||||
FROM review_samples
|
||||
WHERE "reviewedAt" >= "submittedAt"
|
||||
GROUP BY category
|
||||
`),
|
||||
]);
|
||||
const todayTotals = summarizeMessageGroups(todayMessageGroups);
|
||||
const hourlyRowsByHour = new Map(hourlySendRows.map((row) => [Number(row.hour), row]));
|
||||
// Always return all 24 Shanghai-time buckets so the line chart does not imply missing hours are missing data.
|
||||
const hourlySendTrend = Array.from({ length: 24 }, (_, hour) => {
|
||||
const row = hourlyRowsByHour.get(hour);
|
||||
return {
|
||||
hour,
|
||||
label: `${String(hour).padStart(2, '0')}:00`,
|
||||
submittedCount: Number(row?.submittedCount ?? 0),
|
||||
successCount: Number(row?.successCount ?? 0),
|
||||
};
|
||||
});
|
||||
const auditSpeedByCategory = new Map(auditSpeedRows.map((row) => [row.category, row]));
|
||||
const auditProcessingSpeed = [
|
||||
['enterpriseCertifications', '企业认证'],
|
||||
['smsAudits', '短信审核'],
|
||||
['templates', '模板'],
|
||||
['signatures', '签名'],
|
||||
['drainageInfos', '引流信息'],
|
||||
].map(([category, label]) => {
|
||||
const row = auditSpeedByCategory.get(category);
|
||||
return {
|
||||
category,
|
||||
label,
|
||||
count: Number(row?.count ?? 0),
|
||||
averageProcessingMs: row?.averageProcessingMs == null ? null : Number(row.averageProcessingMs),
|
||||
};
|
||||
});
|
||||
const downstreamAlertCount = downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount;
|
||||
return {
|
||||
taskCount,
|
||||
messageStatus: messageGroups,
|
||||
today: {
|
||||
sent: todayTotals.total,
|
||||
delivered: todayTotals.delivered,
|
||||
failed: todayTotals.failed,
|
||||
unknown: todayTotals.unknown,
|
||||
successRate: todayTotals.total > 0 ? Number(((todayTotals.delivered / todayTotals.total) * 100).toFixed(1)) : 0,
|
||||
spendCents: todayTotals.amountCents,
|
||||
returnedCents: moneyToNumber(transactionAggregate._sum.amountCents),
|
||||
billingUnits: todayTotals.billingUnits,
|
||||
},
|
||||
uplinkCount,
|
||||
billing: billingAggregate,
|
||||
transactions: transactionAggregate,
|
||||
gatewayConnections: connectionGroups,
|
||||
pendingAuditCount: pendingAudits.total,
|
||||
pendingAudits,
|
||||
hourlySendTrend,
|
||||
auditProcessingSpeed,
|
||||
downstreamDeliverySummary: {
|
||||
pending: downstreamPendingCount,
|
||||
failed: downstreamFailedCount,
|
||||
delivered: downstreamDeliveredCount,
|
||||
stalledPending: downstreamStalledPendingCount,
|
||||
stalledAck: downstreamStalledAckCount,
|
||||
recentFailed: downstreamRecentFailedCount,
|
||||
alertCount: downstreamAlertCount,
|
||||
},
|
||||
accounts: tenantAccounts,
|
||||
enterpriseSpendRanks: enterpriseSpendRows.map((row) => ({
|
||||
tenantId: row.tenantId,
|
||||
tenantName: row.tenantName,
|
||||
todaySpendCents: moneyToNumber(row.todaySpendCents),
|
||||
balanceCents: moneyToNumber(row.balanceCents),
|
||||
creditCents: moneyToNumber(row.creditCents),
|
||||
})),
|
||||
recentTasks,
|
||||
recentRecharges,
|
||||
};
|
||||
}
|
||||
async clientDashboard(query: { tenantId?: string }) {
|
||||
const tenantId = query.tenantId;
|
||||
const [dashboard, tenant, approvedCertification, signatureCount, pendingBatchTaskCount] = await Promise.all([
|
||||
this.dashboard(query),
|
||||
tenantId
|
||||
? this.prisma.tenant.findUnique({ where: { id: tenantId }, select: { id: true, name: true } })
|
||||
: Promise.resolve(null),
|
||||
tenantId
|
||||
? this.prisma.enterpriseCertification.findFirst({
|
||||
where: { tenantId, status: 'approved' },
|
||||
select: { id: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
tenantId
|
||||
? this.prisma.smsSignature.count({
|
||||
where: { tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
tenantId
|
||||
? this.prisma.smsBatchTask.count({
|
||||
where: { tenantId, sourceType: 'client', status: 'pending_review' },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
return {
|
||||
taskCount: dashboard.taskCount,
|
||||
messageStatus: dashboard.messageStatus,
|
||||
today: dashboard.today,
|
||||
uplinkCount: dashboard.uplinkCount,
|
||||
billing: dashboard.billing,
|
||||
transactions: dashboard.transactions,
|
||||
gatewayConnections: [],
|
||||
pendingAuditCount: dashboard.pendingAuditCount,
|
||||
pendingAudits: dashboard.pendingAudits,
|
||||
hourlySendTrend: dashboard.hourlySendTrend,
|
||||
auditProcessingSpeed: dashboard.auditProcessingSpeed,
|
||||
downstreamDeliverySummary: dashboard.downstreamDeliverySummary,
|
||||
accounts: dashboard.accounts.map(clientAccountView),
|
||||
enterpriseSpendRanks: dashboard.enterpriseSpendRanks,
|
||||
recentTasks: dashboard.recentTasks.map(clientBatchTaskView),
|
||||
recentRecharges: dashboard.recentRecharges.map(clientRechargeView),
|
||||
clientOverview: {
|
||||
enterpriseName: tenant?.name ?? null,
|
||||
certificationStatus: approvedCertification ? 'certified' : 'uncertified',
|
||||
signatureCount,
|
||||
pendingBatchTaskCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
private countPendingAudits(tenantId?: string) {
|
||||
return Promise.all([
|
||||
this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||
this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||
this.prisma.smsDrainageInfo.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||
this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }),
|
||||
this.prisma.smsSendTask.count({ where: { tenantId, status: 'pending_review' } }),
|
||||
]).then(([templates, signatures, drainageInfos, enterpriseCertifications, smsAudits]) => ({
|
||||
templates,
|
||||
signatures,
|
||||
drainageInfos,
|
||||
enterpriseCertifications,
|
||||
smsAudits,
|
||||
total: templates + signatures + drainageInfos + enterpriseCertifications + smsAudits,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { moneyToNumber } from '../../common/money';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
|
||||
import { recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
|
||||
|
||||
// R2 downstream query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
||||
export class OperationsDownstreamQueries {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listGatewaySubmitDeadLetters(query: GatewaySubmitDeadLetterQuery) {
|
||||
const page = Math.max(1, Number(query.page ?? 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
|
||||
const baseWhere: Prisma.GatewaySubmitDeadLetterWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
channelId: query.channelId,
|
||||
OR: query.keyword ? [
|
||||
{ streamMessageId: { contains: query.keyword } },
|
||||
{ traceId: { contains: query.keyword } },
|
||||
{ messageId: { contains: query.keyword } },
|
||||
{ submitId: { contains: query.keyword } },
|
||||
{ failureCode: { contains: query.keyword } },
|
||||
{ failureMessage: { contains: query.keyword } },
|
||||
] : undefined,
|
||||
};
|
||||
const where: Prisma.GatewaySubmitDeadLetterWhereInput = {
|
||||
...baseWhere,
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
};
|
||||
const [items, total, statusGroups, oldestPending] = await Promise.all([
|
||||
this.prisma.gatewaySubmitDeadLetter.findMany({
|
||||
where,
|
||||
include: { tenant: true, application: true, channel: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.gatewaySubmitDeadLetter.count({ where }),
|
||||
this.prisma.gatewaySubmitDeadLetter.groupBy({
|
||||
by: ['status'],
|
||||
where: baseWhere,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.gatewaySubmitDeadLetter.findFirst({
|
||||
where: { ...baseWhere, status: 'pending' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
]);
|
||||
const statusCounts = new Map(statusGroups.map((item) => [item.status, item._count._all]));
|
||||
const messageIds = items.map((item) => item.messageId).filter((value): value is string => Boolean(value));
|
||||
const messageStates = messageIds.length > 0
|
||||
? await this.prisma.smsMessageRecord.findMany({
|
||||
where: { messageId: { in: messageIds } },
|
||||
select: { messageId: true, status: true, submitStatus: true, receiptStatus: true, phoneNumber: true, content: true },
|
||||
})
|
||||
: [];
|
||||
const messageStateById = new Map(messageStates.map((item) => [item.messageId, item]));
|
||||
return {
|
||||
items: items.map((item) => sanitizeGatewaySubmitException(item, item.messageId ? messageStateById.get(item.messageId) : undefined)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
summary: {
|
||||
pending: statusCounts.get('pending') ?? 0,
|
||||
requeueing: statusCounts.get('requeueing') ?? 0,
|
||||
requeued: statusCounts.get('requeued') ?? 0,
|
||||
resolved: statusCounts.get('resolved') ?? 0,
|
||||
oldestPendingAt: oldestPending?.createdAt ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
async listDownstreamDeliveries(query: DownstreamDeliveryQuery) {
|
||||
const page = Math.max(1, Number(query.page ?? 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
|
||||
const where: Prisma.CmppDownstreamDeliveryWhereInput = {
|
||||
...downstreamDeliveryScopedWhere(query),
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ messageId: { contains: query.keyword } },
|
||||
{ payload: { path: ['account'], string_contains: query.keyword } },
|
||||
{ payload: { path: ['phoneNumber'], string_contains: query.keyword } },
|
||||
{ lastError: { contains: query.keyword } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where,
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
messageRecord: true,
|
||||
attempts: { orderBy: [{ attemptNo: 'desc' }, { createdAt: 'desc' }] },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
async downstreamDeliveryDashboard(query: DownstreamDeliveryDashboardQuery) {
|
||||
const scopedWhere = downstreamDeliveryScopedWhere(query);
|
||||
const downstreamAlertWindow = downstreamAlertWindows();
|
||||
const [total, pending, awaitingAck, delivered, failed, unconfirmed, rejected, stalledPending, stalledAck, recentFailed, typeGroups, applicationGroups, applicationAlertGroups, retryZero, retryLow, retryHigh] = await Promise.all([
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: scopedWhere }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'pending' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'awaiting_ack' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'delivered' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'failed' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'unconfirmed' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'rejected' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
...stalledPendingWhere(downstreamAlertWindow.stalledPendingAt),
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { ...scopedWhere, status: 'awaiting_ack', ackDeadlineAt: { lte: downstreamAlertWindow.now } },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: { in: ['failed', 'unconfirmed', 'rejected'] },
|
||||
updatedAt: { gte: downstreamAlertWindow.recentFailedAt },
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.groupBy({
|
||||
by: ['deliveryType', 'status'],
|
||||
where: scopedWhere,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.groupBy({
|
||||
by: ['applicationId', 'status'],
|
||||
where: scopedWhere,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.groupBy({
|
||||
by: ['applicationId'],
|
||||
where: downstreamAlertWhere(scopedWhere, downstreamAlertWindow),
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] },
|
||||
retryCount: 0,
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] },
|
||||
retryCount: { gte: 1, lte: 3 },
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] },
|
||||
retryCount: { gte: 4 },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const applicationIds = [...new Set(applicationGroups.map((item) => item.applicationId).filter((value): value is string => Boolean(value)))];
|
||||
const applications: Array<{ id: string; name: string }> = applicationIds.length > 0
|
||||
? await this.prisma.smsApplication.findMany({
|
||||
where: { id: { in: applicationIds } },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: [];
|
||||
const applicationMap = new Map<string, string>(applications.map((item) => [item.id, item.name]));
|
||||
const applicationAlertMap = new Map<string, number>(
|
||||
applicationAlertGroups.map((item) => [item.applicationId, item._count._all]),
|
||||
);
|
||||
const groupedByType = groupDownstreamByType(typeGroups);
|
||||
const groupedByApplication = groupDownstreamByApplication(applicationGroups, applicationMap, applicationAlertMap);
|
||||
|
||||
return {
|
||||
summary: {
|
||||
total,
|
||||
pending,
|
||||
awaitingAck,
|
||||
delivered,
|
||||
failed,
|
||||
unconfirmed,
|
||||
rejected,
|
||||
stalledPending,
|
||||
stalledAck,
|
||||
recentFailed,
|
||||
alertCount: stalledPending + stalledAck + recentFailed,
|
||||
},
|
||||
typeBreakdown: ['receipt', 'uplink'].map((deliveryType) => ({
|
||||
deliveryType,
|
||||
total: groupedByType[deliveryType]?.total ?? 0,
|
||||
pending: groupedByType[deliveryType]?.pending ?? 0,
|
||||
awaitingAck: groupedByType[deliveryType]?.awaitingAck ?? 0,
|
||||
delivered: groupedByType[deliveryType]?.delivered ?? 0,
|
||||
failed: groupedByType[deliveryType]?.failed ?? 0,
|
||||
unconfirmed: groupedByType[deliveryType]?.unconfirmed ?? 0,
|
||||
rejected: groupedByType[deliveryType]?.rejected ?? 0,
|
||||
})),
|
||||
retryBuckets: [
|
||||
{ label: '0次', count: retryZero },
|
||||
{ label: '1-3次', count: retryLow },
|
||||
{ label: '4次及以上', count: retryHigh },
|
||||
],
|
||||
topApplications: groupedByApplication
|
||||
.sort((left, right) => (
|
||||
right.alertCount - left.alertCount
|
||||
|| right.failed - left.failed
|
||||
|| right.pending - left.pending
|
||||
|| left.name.localeCompare(right.name, 'zh-CN')
|
||||
))
|
||||
.slice(0, 5),
|
||||
};
|
||||
}
|
||||
async listDownstreamRecoveryStatuses(query: DownstreamRecoveryStatusQuery) {
|
||||
const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate();
|
||||
const page = Math.max(1, Number(query.page ?? 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
|
||||
const where = downstreamRecoveryStatusWhere(query);
|
||||
const now = new Date();
|
||||
const [items, total, runningCount, successCount, failedCount, waitingConnectionCount, backoffCount, categoryGroups] = await Promise.all([
|
||||
recoveryStatuses.findMany({
|
||||
where,
|
||||
include: { tenant: true, application: true },
|
||||
orderBy: [{ updatedAt: 'desc' }, { account: 'asc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
recoveryStatuses.count({ where }),
|
||||
recoveryStatuses.count({ where: { ...where, state: 'running' } }),
|
||||
recoveryStatuses.count({ where: { ...where, state: 'success' } }),
|
||||
recoveryStatuses.count({ where: { ...where, state: 'failed' } }),
|
||||
recoveryStatuses.count({ where: { ...where, state: 'waiting_connection' } }),
|
||||
recoveryStatuses.count({
|
||||
where: {
|
||||
...where,
|
||||
nextRetryAt: { gt: now },
|
||||
},
|
||||
}),
|
||||
recoveryStatuses.groupBy({
|
||||
by: ['failureCategory'],
|
||||
where,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
summary: {
|
||||
total,
|
||||
running: runningCount,
|
||||
success: successCount,
|
||||
failed: failedCount,
|
||||
waitingConnection: waitingConnectionCount,
|
||||
backoff: backoffCount,
|
||||
failureCategories: categoryGroups
|
||||
.filter((item) => item.failureCategory)
|
||||
.map((item) => ({
|
||||
category: String(item.failureCategory),
|
||||
count: item._count?._all ?? 0,
|
||||
}))
|
||||
.sort((left, right) => right.count - left.count || left.category.localeCompare(right.category)),
|
||||
},
|
||||
};
|
||||
}
|
||||
async listMessageSegmentAudits(query: MessageSegmentAuditQuery) {
|
||||
const segmentAudits = (this.prisma as PrismaService & {
|
||||
smsMessageSegmentAudit: {
|
||||
findMany: (args: Record<string, unknown>) => Promise<any[]>;
|
||||
};
|
||||
}).smsMessageSegmentAudit;
|
||||
if (!query.messageId && !query.messageRecordId) {
|
||||
return [];
|
||||
}
|
||||
return segmentAudits.findMany({
|
||||
where: {
|
||||
messageRecordId: query.messageRecordId,
|
||||
messageRecord: query.messageId ? { messageId: query.messageId } : undefined,
|
||||
},
|
||||
include: { channel: true, submitRecord: true },
|
||||
orderBy: [{ createdAt: 'asc' }, { segmentIndex: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
}
|
||||
async getDownstreamRecoveryStatus(id: string) {
|
||||
const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate();
|
||||
const item = await recoveryStatuses.findUnique({
|
||||
where: { id },
|
||||
include: { tenant: true, application: true },
|
||||
});
|
||||
if (!item) {
|
||||
throw new NotFoundException('Recovery status not found');
|
||||
}
|
||||
return item;
|
||||
}
|
||||
async exportDownstreamRecoveryStatuses(query: DownstreamRecoveryStatusQuery) {
|
||||
const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate();
|
||||
const where = downstreamRecoveryStatusWhere(query);
|
||||
const items = await recoveryStatuses.findMany({
|
||||
where,
|
||||
include: { tenant: true, application: true },
|
||||
orderBy: [{ updatedAt: 'desc' }, { account: 'asc' }],
|
||||
take: 5000,
|
||||
});
|
||||
const rows = [
|
||||
[
|
||||
'账号',
|
||||
'企业',
|
||||
'应用',
|
||||
'Gateway实例',
|
||||
'恢复状态',
|
||||
'锁持有实例',
|
||||
'锁过期时间',
|
||||
'失败分类',
|
||||
'尝试次数',
|
||||
'最后尝试时间',
|
||||
'恢复成功时间',
|
||||
'恢复失败时间',
|
||||
'下次恢复时间',
|
||||
'最后错误',
|
||||
'最后跳过原因',
|
||||
'创建时间',
|
||||
'更新时间',
|
||||
],
|
||||
...items.map((item) => [
|
||||
item.account ?? '',
|
||||
item.tenant?.name ?? '',
|
||||
item.application?.name ?? '',
|
||||
item.gatewayInstanceId ?? '',
|
||||
item.state ?? '',
|
||||
(item as { lockOwner?: string | null }).lockOwner ?? '',
|
||||
formatCsvDate((item as { lockExpiresAt?: Date | string | null }).lockExpiresAt),
|
||||
(item as { failureCategory?: string | null }).failureCategory ?? '',
|
||||
String(item.attemptCount ?? 0),
|
||||
formatCsvDate(item.lastAttemptAt),
|
||||
formatCsvDate(item.lastSuccessAt),
|
||||
formatCsvDate(item.lastFailureAt),
|
||||
formatCsvDate(item.nextRetryAt),
|
||||
item.lastError ?? '',
|
||||
item.lastSkipReason ?? '',
|
||||
formatCsvDate(item.createdAt),
|
||||
formatCsvDate(item.updatedAt),
|
||||
]),
|
||||
];
|
||||
|
||||
return {
|
||||
fileName: `gateway-downstream-recovery-statuses-${formatExportTimestamp(new Date())}.csv`,
|
||||
content: rows.map((row) => row.map(escapeCsvCell).join(',')).join('\n'),
|
||||
total: items.length,
|
||||
};
|
||||
}
|
||||
private gatewayDownstreamRecoveryStatusDelegate() {
|
||||
return (this.prisma as PrismaService & {
|
||||
gatewayDownstreamRecoveryStatus: {
|
||||
findMany: (args: Record<string, unknown>) => Promise<any[]>;
|
||||
count: (args: Record<string, unknown>) => Promise<number>;
|
||||
findUnique: (args: Record<string, unknown>) => Promise<any | null>;
|
||||
groupBy: (args: Record<string, unknown>) => Promise<any[]>;
|
||||
};
|
||||
}).gatewayDownstreamRecoveryStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { moneyToNumber } from '../../common/money';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
|
||||
import { recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
|
||||
|
||||
// R2 logs query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
||||
export class OperationsLogQueries {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) {
|
||||
const page = positiveInteger(query.page, 1);
|
||||
const pageSize = Math.min(100, positiveInteger(query.pageSize, 20));
|
||||
const where: Prisma.OperationLogWhereInput = { tenantId: query.tenantId, userId: query.userId };
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.operationLog.findMany({
|
||||
where,
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.operationLog.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
async systemLogs(query: OperationLogQuery) {
|
||||
const page = positiveInteger(query.page, 1);
|
||||
const pageSize = Math.min(100, positiveInteger(query.pageSize, 10));
|
||||
const where: Prisma.OperationLogWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
userId: query.userId,
|
||||
createdAt: createdAtRange(query.range),
|
||||
resource: query.module && query.module !== 'all' ? query.module : undefined,
|
||||
AND: query.level && query.level !== 'all' ? operationLogLevelWhere(query.level) : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ action: { contains: query.keyword } },
|
||||
{ resource: { contains: query.keyword } },
|
||||
{ resourceId: { contains: query.keyword } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
{ user: { displayName: { contains: query.keyword } } },
|
||||
{ user: { username: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total, modules] = await Promise.all([
|
||||
this.prisma.operationLog.findMany({
|
||||
where,
|
||||
include: { tenant: true, user: true },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.operationLog.count({ where }),
|
||||
this.prisma.operationLog.groupBy({
|
||||
by: ['resource'],
|
||||
where: { tenantId: query.tenantId },
|
||||
_count: { _all: true },
|
||||
orderBy: { resource: 'asc' },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
items: items.map((item) => normalizeOperationLog(item)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
modules: modules.map((item) => item.resource),
|
||||
};
|
||||
}
|
||||
async exportSystemLogs(query: OperationLogQuery, clientUserId?: string) {
|
||||
const clientTenantId = clientUserId ? await this.resolveClientTenantId(clientUserId) : undefined;
|
||||
const effectiveQuery = { ...query, tenantId: clientTenantId ?? query.tenantId };
|
||||
const where: Prisma.OperationLogWhereInput = {
|
||||
tenantId: effectiveQuery.tenantId,
|
||||
userId: effectiveQuery.userId,
|
||||
createdAt: createdAtRange(effectiveQuery.range),
|
||||
resource: effectiveQuery.module && effectiveQuery.module !== 'all' ? effectiveQuery.module : undefined,
|
||||
AND: effectiveQuery.level && effectiveQuery.level !== 'all' ? operationLogLevelWhere(effectiveQuery.level) : undefined,
|
||||
OR: effectiveQuery.keyword ? [
|
||||
{ action: { contains: effectiveQuery.keyword } },
|
||||
{ resource: { contains: effectiveQuery.keyword } },
|
||||
{ resourceId: { contains: effectiveQuery.keyword } },
|
||||
{ tenant: { name: { contains: effectiveQuery.keyword } } },
|
||||
{ user: { displayName: { contains: effectiveQuery.keyword } } },
|
||||
{ user: { username: { contains: effectiveQuery.keyword } } },
|
||||
] : undefined,
|
||||
};
|
||||
const rows = await this.prisma.operationLog.findMany({
|
||||
where,
|
||||
include: { tenant: true, user: true },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: 10_001,
|
||||
});
|
||||
const truncated = rows.length > 10_000;
|
||||
const exportedRows = rows.slice(0, 10_000).map(normalizeOperationLog);
|
||||
const clientExport = Boolean(clientUserId);
|
||||
const headers = clientExport
|
||||
? ['时间', '级别', '模块', '操作人', '动作', '资源ID']
|
||||
: ['时间', '级别', '企业', '模块', '操作人', '动作', '资源ID', '详情', 'IP'];
|
||||
const values = exportedRows.map((item) => clientExport
|
||||
? [item.time, item.level, item.module, item.operator, item.action, item.resourceId]
|
||||
: [item.time, item.level, item.tenant, item.module, item.operator, item.action, item.resourceId, JSON.stringify(item.detail), item.ip]);
|
||||
return {
|
||||
operationId: randomUUID(),
|
||||
status: 'completed' as const,
|
||||
fileName: `system-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`,
|
||||
recordCount: exportedRows.length,
|
||||
truncated,
|
||||
content: [headers, ...values].map((row) => row.map((cell) => escapeCsvCell(String(cell ?? ''))).join(',')).join('\n'),
|
||||
filters: { keyword: effectiveQuery.keyword, level: effectiveQuery.level, module: effectiveQuery.module, range: effectiveQuery.range },
|
||||
};
|
||||
}
|
||||
private async resolveClientTenantId(userId: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: userId, status: 'active', deletedAt: null, tenantId: { not: null } },
|
||||
select: { tenantId: true },
|
||||
});
|
||||
if (!user?.tenantId) throw new NotFoundException('Client tenant not found');
|
||||
return user.tenantId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { moneyToNumber } from '../../common/money';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
|
||||
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
|
||||
|
||||
// R2 messages query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
||||
export class OperationsMessageQueries {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listBatchTasks(query: { tenantId?: string; status?: string }) {
|
||||
return this.prisma.smsBatchTask.findMany({
|
||||
where: { tenantId: query.tenantId, status: query.status, sourceType: 'client' },
|
||||
include: { apiRequests: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
async listClientBatchTasks(query: { tenantId?: string; status?: string }) {
|
||||
const items = await this.listBatchTasks(query);
|
||||
return items.map(clientBatchTaskView);
|
||||
}
|
||||
listMessages(query: MessageQuery) {
|
||||
return this.prisma.smsMessageRecord.findMany({
|
||||
where: messageWhere(query),
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
channel: true,
|
||||
submitRecords: { include: { channel: true, channelGroup: true } },
|
||||
receiptRecords: { include: { channel: true } },
|
||||
downstreamDeliveries: {
|
||||
where: { deliveryType: 'receipt' },
|
||||
select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true },
|
||||
},
|
||||
},
|
||||
orderBy: { queuedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
async listMessagesPage(query: MessageQuery) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 25)));
|
||||
const where = messageWhere(query);
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.smsMessageRecord.findMany({
|
||||
where,
|
||||
include: {
|
||||
tenant: { select: { id: true, name: true } },
|
||||
application: { select: { id: true, name: true } },
|
||||
channel: { select: { id: true, name: true, srcId: true } },
|
||||
submitRecords: {
|
||||
select: {
|
||||
id: true,
|
||||
submitId: true,
|
||||
channelId: true,
|
||||
channelGroupId: true,
|
||||
channelGroupName: true,
|
||||
gatewayMessageId: true,
|
||||
submitStatus: true,
|
||||
submittedAt: true,
|
||||
createdAt: true,
|
||||
channel: { select: { id: true, name: true } },
|
||||
channelGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
receiptRecords: {
|
||||
select: {
|
||||
id: true,
|
||||
messageId: true,
|
||||
gatewayMessageId: true,
|
||||
receiptStatus: true,
|
||||
rawStatus: true,
|
||||
errorCode: true,
|
||||
errorMessage: true,
|
||||
deliveredAt: true,
|
||||
createdAt: true,
|
||||
channelId: true,
|
||||
channel: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
downstreamDeliveries: {
|
||||
where: { deliveryType: 'receipt' },
|
||||
select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.smsMessageRecord.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
async exportMessages(query: MessageQuery) {
|
||||
const items = await this.prisma.smsMessageRecord.findMany({
|
||||
where: messageWhere(query),
|
||||
select: {
|
||||
messageId: true,
|
||||
queuedAt: true,
|
||||
phoneNumber: true,
|
||||
province: true,
|
||||
carrier: true,
|
||||
billingUnits: true,
|
||||
amountCents: true,
|
||||
status: true,
|
||||
submitStatus: true,
|
||||
deliveredAt: true,
|
||||
content: true,
|
||||
tenant: { select: { name: true } },
|
||||
application: { select: { name: true } },
|
||||
channel: { select: { name: true } },
|
||||
},
|
||||
orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
const rows = [
|
||||
['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '回执时间', '短信内容'],
|
||||
...items.map((item) => [
|
||||
item.messageId,
|
||||
item.tenant?.name ?? '',
|
||||
item.application?.name ?? '',
|
||||
item.queuedAt.toISOString(),
|
||||
item.phoneNumber,
|
||||
item.province ?? '',
|
||||
item.carrier ?? '',
|
||||
String(item.billingUnits),
|
||||
String(moneyToNumber(item.amountCents)),
|
||||
item.channel?.name ?? '',
|
||||
item.status === 'submit_failed' || ['rejected', 'timeout'].includes(item.submitStatus ?? '') ? 'submit_failed' : item.status,
|
||||
item.deliveredAt?.toISOString() ?? '',
|
||||
item.content,
|
||||
]),
|
||||
];
|
||||
return {
|
||||
fileName: `sms-records-${formatExportTimestamp(new Date())}.csv`,
|
||||
content: rows.map((row) => row.map((cell) => escapeCsvCell(String(cell))).join(',')).join('\n'),
|
||||
};
|
||||
}
|
||||
async listClientMessages(query: MessageQuery) {
|
||||
const items = await this.listMessages(query);
|
||||
return items.map(clientMessageView);
|
||||
}
|
||||
async listClientMessagesPage(query: MessageQuery) {
|
||||
const result = await this.listMessagesPage(query);
|
||||
return { ...result, items: result.items.map(clientMessageView) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { moneyToNumber } from '../../common/money';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
|
||||
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
|
||||
|
||||
// R2 quality query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
||||
export class OperationsQualityQueries {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async statistics(query: { tenantId?: string; groupBy?: string }) {
|
||||
const groupBy = normalizeGroupBy(query.groupBy);
|
||||
if (groupBy === 'tenantId') {
|
||||
return this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['tenantId'],
|
||||
where: messageWhere({ tenantId: query.tenantId }),
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
});
|
||||
}
|
||||
if (groupBy === 'applicationId') {
|
||||
return this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['applicationId'],
|
||||
where: messageWhere({ tenantId: query.tenantId }),
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
});
|
||||
}
|
||||
return this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['channelId'],
|
||||
where: messageWhere({ tenantId: query.tenantId }),
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
});
|
||||
}
|
||||
async sendQuality(date?: string) {
|
||||
const day = qualityBusinessDay(date);
|
||||
const [channels, signatures, summaryRows, applications] = await Promise.all([
|
||||
this.prisma.$queryRaw<Array<{
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
total: number;
|
||||
acceptedCount: number;
|
||||
submitFailureCount: number;
|
||||
submitFailureRate: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
unknownRate: number;
|
||||
failureRate: number;
|
||||
averageArrivalMs: number | null;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
submit."channelId" AS channel_id,
|
||||
channel.name AS channel_name,
|
||||
submit."submitStatus" AS submit_status,
|
||||
receipt."deliveredAt" AS delivered_at,
|
||||
failed_receipt."failedAt" AS failed_at,
|
||||
COALESCE(segment_summary.segment_count, 0) AS segment_count,
|
||||
COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count,
|
||||
COALESCE(segment_summary.failure_count, 0) AS segment_failure_count,
|
||||
CASE
|
||||
WHEN segment_summary.segment_count > 0
|
||||
AND segment_summary.delivered_count = segment_summary.segment_count
|
||||
AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt")
|
||||
THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
|
||||
WHEN segment_summary.segment_count = 0
|
||||
AND receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt")
|
||||
THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
|
||||
END AS arrival_ms
|
||||
FROM "SmsSubmitRecord" submit
|
||||
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*)::integer AS segment_count,
|
||||
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count,
|
||||
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count,
|
||||
MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at
|
||||
FROM "SmsMessageSegmentAudit" segment
|
||||
WHERE segment."submitRecordId" = submit.id
|
||||
) segment_summary ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS "deliveredAt"
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) receipt ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS "failedAt"
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'undelivered'
|
||||
) failed_receipt ON TRUE
|
||||
WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
|
||||
), classified AS (
|
||||
SELECT
|
||||
*,
|
||||
CASE
|
||||
WHEN submit_status <> 'accepted' THEN 'submit_failed'
|
||||
WHEN segment_count > 0 AND segment_failure_count > 0 THEN 'failure'
|
||||
WHEN segment_count > 0 AND segment_delivered_count = segment_count THEN 'success'
|
||||
WHEN segment_count = 0 AND failed_at IS NOT NULL THEN 'failure'
|
||||
WHEN segment_count = 0 AND delivered_at IS NOT NULL THEN 'success'
|
||||
ELSE 'unknown'
|
||||
END AS delivery_status
|
||||
FROM base
|
||||
)
|
||||
SELECT
|
||||
channel_id AS "channelId",
|
||||
MAX(channel_name) AS "channelName",
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount",
|
||||
CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'submit_failed') * 100.0 / COUNT(*), 1)::double precision END AS "submitFailureRate",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount",
|
||||
CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'success') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "successRate",
|
||||
CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'unknown') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "unknownRate",
|
||||
CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'failure') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "failureRate",
|
||||
ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
|
||||
FROM classified
|
||||
GROUP BY channel_id
|
||||
ORDER BY COUNT(*) DESC, channel_id
|
||||
`),
|
||||
this.prisma.$queryRaw<Array<{
|
||||
id: string;
|
||||
signatureId: string;
|
||||
signatureName: string;
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
hasDrainage: boolean;
|
||||
total: number;
|
||||
acceptedCount: number;
|
||||
submitFailureCount: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
averageArrivalMs: number | null;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
message."signatureId" AS signature_id,
|
||||
(message."drainageInfoId" IS NOT NULL) AS has_drainage,
|
||||
message.status,
|
||||
message."submitStatus" AS submit_status,
|
||||
message."receiptStatus" AS receipt_status,
|
||||
CASE
|
||||
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||
AND message."submittedAt" IS NOT NULL
|
||||
AND message."deliveredAt" >= message."submittedAt"
|
||||
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000
|
||||
END AS arrival_ms
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."signatureId" IS NOT NULL
|
||||
AND message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
)
|
||||
SELECT
|
||||
signature.id || ':' || CASE WHEN base.has_drainage THEN 'drainage' ELSE 'plain' END AS id,
|
||||
signature.id AS "signatureId",
|
||||
signature.name AS "signatureName",
|
||||
tenant.id AS "tenantId",
|
||||
tenant.name AS "tenantName",
|
||||
base.has_drainage AS "hasDrainage",
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
)::integer AS "acceptedCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE base.status = 'submit_failed'
|
||||
OR base.submit_status IN ('rejected', 'timeout')
|
||||
)::integer AS "submitFailureCount",
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "failureCount",
|
||||
CASE
|
||||
WHEN COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
|
||||
* 100.0
|
||||
/ COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
),
|
||||
1
|
||||
)::double precision
|
||||
END AS "successRate",
|
||||
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
|
||||
FROM base
|
||||
JOIN "SmsSignature" signature ON signature.id = base.signature_id
|
||||
JOIN "Tenant" tenant ON tenant.id = signature."tenantId"
|
||||
GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage
|
||||
ORDER BY "successCount" DESC, total DESC, signature.name
|
||||
`),
|
||||
this.prisma.$queryRaw<Array<{
|
||||
total: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT message.status, message."receiptStatus" AS receipt_status
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
AND COALESCE(message.status, '') <> 'rejected'
|
||||
)
|
||||
SELECT
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "failureCount",
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
|
||||
* 100.0 / COUNT(*),
|
||||
1
|
||||
)::double precision
|
||||
END AS "successRate"
|
||||
FROM base
|
||||
`),
|
||||
this.prisma.$queryRaw<Array<{
|
||||
applicationId: string;
|
||||
applicationName: string;
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
total: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
message."applicationId" AS application_id,
|
||||
message.status,
|
||||
message."receiptStatus" AS receipt_status
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."applicationId" IS NOT NULL
|
||||
AND message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
AND COALESCE(message.status, '') <> 'rejected'
|
||||
)
|
||||
SELECT
|
||||
application.id AS "applicationId",
|
||||
application.name AS "applicationName",
|
||||
tenant.id AS "tenantId",
|
||||
tenant.name AS "tenantName",
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "failureCount",
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
|
||||
* 100.0 / COUNT(*),
|
||||
1
|
||||
)::double precision
|
||||
END AS "successRate"
|
||||
FROM base
|
||||
JOIN "SmsApplication" application ON application.id = base.application_id
|
||||
JOIN "Tenant" tenant ON tenant.id = application."tenantId"
|
||||
GROUP BY application.id, application.name, tenant.id, tenant.name
|
||||
ORDER BY total DESC, application.name
|
||||
`),
|
||||
]);
|
||||
const summary = summaryRows[0] ?? {
|
||||
total: 0,
|
||||
successCount: 0,
|
||||
unknownCount: 0,
|
||||
failureCount: 0,
|
||||
successRate: 0,
|
||||
};
|
||||
return { date: day.key, summary, channels, signatures, applications };
|
||||
}
|
||||
async signatureQuality(query: SignatureQualityQuery) {
|
||||
const day = qualityBusinessDay(query.date);
|
||||
const page = positiveInteger(query.page, 1);
|
||||
const pageSize = Math.min(50, positiveInteger(query.pageSize, 10));
|
||||
const keyword = query.keyword?.trim() || null;
|
||||
const keywordPattern = keyword ? `%${keyword}%` : null;
|
||||
const summaries = await this.prisma.$queryRaw<Array<{
|
||||
signatureId: string;
|
||||
signatureName: string;
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
applicationNames: string | null;
|
||||
total: number;
|
||||
acceptedCount: number;
|
||||
submitFailureCount: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
averageArrivalMs: number | null;
|
||||
rowCount: number;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
message."signatureId" AS signature_id,
|
||||
message."applicationId" AS application_id,
|
||||
message.status,
|
||||
message."submitStatus" AS submit_status,
|
||||
message."receiptStatus" AS receipt_status,
|
||||
CASE
|
||||
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||
AND message."submittedAt" IS NOT NULL
|
||||
AND message."deliveredAt" >= message."submittedAt"
|
||||
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000
|
||||
END AS arrival_ms
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."signatureId" IS NOT NULL
|
||||
AND message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
)
|
||||
SELECT
|
||||
signature.id AS "signatureId",
|
||||
signature.name AS "signatureName",
|
||||
tenant.id AS "tenantId",
|
||||
tenant.name AS "tenantName",
|
||||
STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames",
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
)::integer AS "acceptedCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE base.status = 'submit_failed'
|
||||
OR base.submit_status IN ('rejected', 'timeout')
|
||||
)::integer AS "submitFailureCount",
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "failureCount",
|
||||
CASE
|
||||
WHEN COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
|
||||
* 100.0
|
||||
/ COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
),
|
||||
1
|
||||
)::double precision
|
||||
END AS "successRate",
|
||||
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs",
|
||||
COUNT(*) OVER()::integer AS "rowCount"
|
||||
FROM base
|
||||
JOIN "SmsSignature" signature ON signature.id = base.signature_id
|
||||
JOIN "Tenant" tenant ON tenant.id = signature."tenantId"
|
||||
LEFT JOIN "SmsApplication" application ON application.id = base.application_id
|
||||
WHERE (
|
||||
${keyword}::text IS NULL
|
||||
OR signature.name ILIKE ${keywordPattern}
|
||||
OR tenant.name ILIKE ${keywordPattern}
|
||||
OR application.name ILIKE ${keywordPattern}
|
||||
)
|
||||
GROUP BY signature.id, signature.name, tenant.id, tenant.name
|
||||
ORDER BY total DESC, signature.name
|
||||
LIMIT ${pageSize}
|
||||
OFFSET ${(page - 1) * pageSize}
|
||||
`);
|
||||
const signatureIds = summaries.map((item) => item.signatureId);
|
||||
const breakdowns = signatureIds.length === 0
|
||||
? []
|
||||
: await this.prisma.$queryRaw<Array<{
|
||||
signatureId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
carrier: string;
|
||||
total: number;
|
||||
acceptedCount: number;
|
||||
submitFailureCount: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
averageArrivalMs: number | null;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
message."signatureId" AS signature_id,
|
||||
submit."channelId" AS channel_id,
|
||||
channel.name AS channel_name,
|
||||
COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier,
|
||||
submit."submitStatus" AS submit_status,
|
||||
receipt."deliveredAt" AS delivered_at,
|
||||
failed_receipt."failedAt" AS failed_at,
|
||||
COALESCE(segment_summary.segment_count, 0) AS segment_count,
|
||||
COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count,
|
||||
COALESCE(segment_summary.failure_count, 0) AS segment_failure_count,
|
||||
CASE
|
||||
WHEN segment_summary.segment_count > 0
|
||||
AND segment_summary.delivered_count = segment_summary.segment_count
|
||||
AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt")
|
||||
THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
|
||||
WHEN segment_summary.segment_count = 0
|
||||
AND receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt")
|
||||
THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
|
||||
END AS arrival_ms
|
||||
FROM "SmsSubmitRecord" submit
|
||||
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
|
||||
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*)::integer AS segment_count,
|
||||
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count,
|
||||
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count,
|
||||
MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at
|
||||
FROM "SmsMessageSegmentAudit" segment
|
||||
WHERE segment."submitRecordId" = submit.id
|
||||
) segment_summary ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS "deliveredAt"
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) receipt ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS "failedAt"
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'undelivered'
|
||||
) failed_receipt ON TRUE
|
||||
WHERE message."signatureId" IN (${Prisma.join(signatureIds)})
|
||||
AND submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
|
||||
), classified AS (
|
||||
SELECT
|
||||
*,
|
||||
CASE
|
||||
WHEN submit_status <> 'accepted' THEN 'submit_failed'
|
||||
WHEN segment_count > 0 AND segment_failure_count > 0 THEN 'failure'
|
||||
WHEN segment_count > 0 AND segment_delivered_count = segment_count THEN 'success'
|
||||
WHEN segment_count = 0 AND failed_at IS NOT NULL THEN 'failure'
|
||||
WHEN segment_count = 0 AND delivered_at IS NOT NULL THEN 'success'
|
||||
ELSE 'unknown'
|
||||
END AS delivery_status
|
||||
FROM base
|
||||
)
|
||||
SELECT
|
||||
signature_id AS "signatureId",
|
||||
channel_id AS "channelId",
|
||||
MAX(channel_name) AS "channelName",
|
||||
carrier,
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount",
|
||||
CASE
|
||||
WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'success')
|
||||
* 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'),
|
||||
1
|
||||
)::double precision
|
||||
END AS "successRate",
|
||||
ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
|
||||
FROM classified
|
||||
GROUP BY signature_id, channel_id, carrier
|
||||
ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier
|
||||
`);
|
||||
const carrierOverview = signatureIds.length === 0
|
||||
? []
|
||||
: await this.prisma.$queryRaw<Array<{
|
||||
signatureId: string;
|
||||
carrier: string;
|
||||
businessMessageCount: number;
|
||||
finalSuccessCount: number;
|
||||
finalSuccessRate: number;
|
||||
averageArrivalMs: number | null;
|
||||
}>>(Prisma.sql`
|
||||
SELECT
|
||||
message."signatureId" AS "signatureId",
|
||||
COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier,
|
||||
COUNT(*)::integer AS "businessMessageCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE message.status = 'delivered'
|
||||
OR message."receiptStatus" = 'delivered'
|
||||
)::integer AS "finalSuccessCount",
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
COUNT(*) FILTER (
|
||||
WHERE message.status = 'delivered'
|
||||
OR message."receiptStatus" = 'delivered'
|
||||
) * 100.0 / COUNT(*),
|
||||
1
|
||||
)::double precision
|
||||
END AS "finalSuccessRate",
|
||||
ROUND(AVG(
|
||||
CASE
|
||||
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||
AND message."submittedAt" IS NOT NULL
|
||||
AND message."deliveredAt" >= message."submittedAt"
|
||||
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000
|
||||
END
|
||||
))::integer AS "averageArrivalMs"
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."signatureId" IN (${Prisma.join(signatureIds)})
|
||||
AND message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
GROUP BY message."signatureId", COALESCE(NULLIF(message.carrier, ''), 'unknown')
|
||||
ORDER BY message."signatureId", COUNT(*) DESC, carrier
|
||||
`);
|
||||
const items = summaries.map(({ rowCount: _rowCount, ...summary }) => {
|
||||
const signatureBreakdowns = breakdowns.filter((item) => item.signatureId === summary.signatureId);
|
||||
return {
|
||||
...summary,
|
||||
channelSubmitTotal: signatureBreakdowns.reduce((sum, item) => sum + item.total, 0),
|
||||
carrierOverview: carrierOverview.filter((item) => item.signatureId === summary.signatureId),
|
||||
breakdowns: signatureBreakdowns,
|
||||
};
|
||||
});
|
||||
return {
|
||||
date: day.key,
|
||||
items,
|
||||
total: summaries[0]?.rowCount ?? 0,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { moneyToNumber } from '../../common/money';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
|
||||
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
|
||||
|
||||
// R2 trace query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
||||
export class OperationsTraceQueries {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
auditSummary(query: { tenantId?: string }) {
|
||||
return this.prisma.operationLog.groupBy({
|
||||
by: ['action', 'resource'],
|
||||
where: { tenantId: query.tenantId },
|
||||
_count: { _all: true },
|
||||
orderBy: { _count: { action: 'desc' } },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
async trace(query: TraceQuery) {
|
||||
const messages = await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
...messageWhere(query),
|
||||
messageId: query.messageId,
|
||||
},
|
||||
include: {
|
||||
batchTask: { include: { apiRequests: true } },
|
||||
submitRecords: { include: { session: true } },
|
||||
receiptRecords: true,
|
||||
},
|
||||
orderBy: { queuedAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
const messageIds = messages.map((message) => message.messageId);
|
||||
const [billingRecords, uplinks] = await Promise.all([
|
||||
this.prisma.smsBillingRecord.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
taskId: query.taskId,
|
||||
messageId: messageIds.length > 0 ? { in: messageIds } : undefined,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.smsUplinkMessage.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
messageId: messageIds.length > 0 ? { in: messageIds } : undefined,
|
||||
},
|
||||
orderBy: { receivedAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
return { messages, billingRecords, uplinks };
|
||||
}
|
||||
async reconciliation(query: { tenantId?: string; taskId?: string }) {
|
||||
const [messages, billing, transactions] = await Promise.all([
|
||||
this.prisma.smsMessageRecord.aggregate({
|
||||
where: messageWhere({ tenantId: query.tenantId, taskId: query.taskId }),
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
}),
|
||||
this.prisma.smsBillingRecord.aggregate({
|
||||
where: { tenantId: query.tenantId, taskId: query.taskId },
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
}),
|
||||
this.prisma.accountTransaction.aggregate({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
relatedType: query.taskId ? { in: ['sms_batch_task', 'sms_message_record'] } : undefined,
|
||||
relatedId: query.taskId,
|
||||
},
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true },
|
||||
}),
|
||||
]);
|
||||
const messageAmount = moneyToNumber(messages._sum.amountCents);
|
||||
const billingAmount = moneyToNumber(billing._sum.amountCents);
|
||||
const transactionAmount = moneyToNumber(transactions._sum.amountCents);
|
||||
return {
|
||||
messages,
|
||||
billing,
|
||||
transactions,
|
||||
diff: {
|
||||
messageVsBillingAmountCents: messageAmount - billingAmount,
|
||||
billingVsTransactionAmountCents: billingAmount + transactionAmount,
|
||||
messageVsBillingUnits: (messages._sum.billingUnits ?? 0) - (billing._sum.billingUnits ?? 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { moneyToNumber } from '../../common/money';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
|
||||
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
|
||||
|
||||
// R2 uplink query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
||||
export class OperationsUplinkQueries {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) {
|
||||
return this.prisma.smsUplinkMessage.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
channelId: query.channelId,
|
||||
applicationId: query.applicationId,
|
||||
phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined,
|
||||
content: query.keyword ? { contains: query.keyword } : undefined,
|
||||
receivedAt: query.startTime || query.endTime ? { gte: query.startTime ? new Date(query.startTime) : undefined, lte: query.endTime ? new Date(query.endTime) : undefined } : undefined,
|
||||
},
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
channel: true,
|
||||
messageRecord: { include: { application: true } },
|
||||
matchCandidates: {
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
messageRecord: { include: { application: true, tenant: true, channel: true } },
|
||||
},
|
||||
orderBy: [{ status: 'asc' }, { confidence: 'desc' }, { createdAt: 'asc' }],
|
||||
},
|
||||
},
|
||||
orderBy: { receivedAt: 'desc' },
|
||||
skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined,
|
||||
take: query.pageSize ?? 500,
|
||||
});
|
||||
}
|
||||
async listClientUplinkMessages(query: { tenantId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) {
|
||||
const items = await this.listUplinkMessages(query);
|
||||
return items.map(clientUplinkView);
|
||||
}
|
||||
async listUplinkMessagesPage(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }, clientView = false) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||
const where: Prisma.SmsUplinkMessageWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
channelId: query.channelId,
|
||||
applicationId: query.applicationId,
|
||||
phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined,
|
||||
content: query.keyword ? { contains: query.keyword } : undefined,
|
||||
receivedAt: query.startTime || query.endTime ? {
|
||||
gte: query.startTime ? new Date(query.startTime) : undefined,
|
||||
lte: query.endTime ? new Date(query.endTime) : undefined,
|
||||
} : undefined,
|
||||
};
|
||||
const [rawItems, total] = await Promise.all([
|
||||
this.listUplinkMessages({ ...query, page, pageSize }),
|
||||
this.prisma.smsUplinkMessage.count({ where }),
|
||||
]);
|
||||
return {
|
||||
items: clientView ? rawItems.map(clientUplinkView) : rawItems,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
async monitor(query: { tenantId?: string; channelId?: string }) {
|
||||
const where = messageWhere(query);
|
||||
const [byStatus, recentMessages, recentReceipts, recentUplinks] = await Promise.all([
|
||||
this.prisma.smsMessageRecord.groupBy({ by: ['status'], where, _count: { _all: true } }),
|
||||
this.prisma.smsMessageRecord.findMany({
|
||||
where,
|
||||
include: { submitRecords: true, receiptRecords: true },
|
||||
orderBy: { queuedAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
this.prisma.smsReceiptRecord.findMany({
|
||||
where: { tenantId: query.tenantId, channelId: query.channelId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
this.listUplinkMessages({ tenantId: query.tenantId, channelId: query.channelId }),
|
||||
]);
|
||||
return {
|
||||
byStatus,
|
||||
recentMessages,
|
||||
recentReceipts,
|
||||
recentUplinks: recentUplinks.slice(0, 20),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { extname } from 'node:path';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
|
||||
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
|
||||
import { ReportBatchOperationService } from './batch-operation.service';
|
||||
import { ReportChannelExportService } from './channel-export.service';
|
||||
|
||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||
export class ReportBatchGenerationService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly operations: ReportBatchOperationService, private readonly channelExport: ReportChannelExportService) {}
|
||||
|
||||
async listBatches(query: PagedQuery = {}) {
|
||||
const page = normalizePage(query.page);
|
||||
const pageSize = normalizePageSize(query.pageSize);
|
||||
const where: Prisma.ReportMaterialBatchWhereInput = {
|
||||
createdAt: dateRange(query.startAt, query.endAt),
|
||||
batchNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
|
||||
};
|
||||
const [batches, total] = await Promise.all([
|
||||
this.prisma.reportMaterialBatch.findMany({
|
||||
where,
|
||||
include: {
|
||||
exportFiles: {
|
||||
include: {
|
||||
items: { include: { task: { select: { id: true, status: true } } } },
|
||||
},
|
||||
},
|
||||
items: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.reportMaterialBatch.count({ where }),
|
||||
]);
|
||||
return {
|
||||
items: batches.map((batch) => {
|
||||
const reportItems = batch.exportFiles.flatMap((file) => file.items);
|
||||
const reportTotal = reportItems.length;
|
||||
const successCount = reportItems.filter((item) => item.task.status === 'approved').length;
|
||||
return {
|
||||
...batch,
|
||||
reportTotal,
|
||||
successCount,
|
||||
successRate: reportTotal ? successCount / reportTotal : 0,
|
||||
};
|
||||
}),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async createBatch(data: CreateReportBatchDto) {
|
||||
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
|
||||
const idempotencyKey = normalizeBatchIdempotencyKey(data.idempotencyKey);
|
||||
const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()];
|
||||
const fingerprint = createHash('sha256').update(JSON.stringify(uniqueItems.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? null, materialVersion: item.materialVersion ?? null })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))).digest('hex');
|
||||
const claimed = await this.operations.claimBatchOperation(idempotencyKey, fingerprint, data.createdById);
|
||||
if (claimed.replayed) return claimed.result;
|
||||
|
||||
let preflight: Awaited<ReturnType<ReportBatchGenerationService['preflightBatch']>>;
|
||||
try {
|
||||
preflight = await this.preflightBatch({ items: uniqueItems });
|
||||
} catch (error) {
|
||||
await this.operations.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '报备资格预检失败');
|
||||
throw error;
|
||||
}
|
||||
if (preflight.eligibleTargetCount === 0) {
|
||||
await this.operations.failBatchOperation(claimed.operationId, '没有可生成的报备目标');
|
||||
throw new BadRequestException({ code: 'REPORT_BATCH_NOT_ELIGIBLE', message: '所选资料没有可生成的通道,请按资格检查补充后重试', preflight });
|
||||
}
|
||||
const eligibleInspections = preflight.items.filter((item) => item.targets.some((target) => target.eligible));
|
||||
const batch = await this.prisma.reportMaterialBatch.create({
|
||||
data: { batchNo: `RB${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}${randomUUID().slice(0, 4).toUpperCase()}`, createdById: data.createdById, selectedCount: eligibleInspections.length },
|
||||
});
|
||||
try {
|
||||
const prepared = [];
|
||||
for (const inspection of eligibleInspections) {
|
||||
const selected = uniqueItems.find((item) => item.reportType === inspection.reportType && (item.drainageItemId ?? item.signatureId) === (inspection.drainageItemId ?? inspection.signatureId))!;
|
||||
prepared.push(await this.prepareBatchItem(batch.id, selected, inspection));
|
||||
}
|
||||
const channelMap = new Map<string, Array<(typeof prepared)[number]>>();
|
||||
for (const item of prepared) {
|
||||
for (const channel of item.channels) {
|
||||
const current = channelMap.get(channel.id) ?? [];
|
||||
current.push({ ...item, channels: [channel] });
|
||||
channelMap.set(channel.id, current);
|
||||
}
|
||||
}
|
||||
const exportedFiles = [];
|
||||
const incomplete = new Set<string>(prepared.filter((item) => item.channels.length === 0).map((item) => item.batchItem.id));
|
||||
let failedTargetCount = 0;
|
||||
for (const [channelId, items] of channelMap) {
|
||||
const result = await this.channelExport.exportChannelBatch(batch.id, channelId, items);
|
||||
exportedFiles.push(result.file);
|
||||
failedTargetCount += result.incompleteBatchItemIds.length;
|
||||
for (const itemId of result.incompleteBatchItemIds) incomplete.add(itemId);
|
||||
}
|
||||
for (const item of prepared) {
|
||||
if (incomplete.has(item.batchItem.id) || item.channels.length === 0) continue;
|
||||
if (item.reportType === 'signature') await this.prisma.smsSignature.update({ where: { id: item.signature.id }, data: { pendingReport: false } });
|
||||
else await this.prisma.smsDrainageInfo.update({ where: { id: item.drainageInfo!.id }, data: { pendingReport: false } });
|
||||
}
|
||||
const completed = await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: incomplete.size ? 'partial_failed' : 'completed', channelCount: channelMap.size, fileCount: exportedFiles.length, completedAt: new Date() }, include: { exportFiles: true, items: true } });
|
||||
const result = {
|
||||
...completed,
|
||||
operationId: claimed.operationId,
|
||||
replayed: false,
|
||||
result: {
|
||||
successCount: preflight.eligibleTargetCount - failedTargetCount,
|
||||
skippedCount: preflight.skippedTargetCount,
|
||||
failedCount: failedTargetCount,
|
||||
items: preflight.items,
|
||||
},
|
||||
};
|
||||
await this.operations.completeBatchOperation(claimed.operationId, batch.id, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: 'failed', errorMessage: error instanceof Error ? error.message : '生成报备批次失败', completedAt: new Date() } });
|
||||
await this.operations.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '生成报备批次失败', batch.id);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async preflightBatch(data: Pick<CreateReportBatchDto, 'items'>) {
|
||||
if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息');
|
||||
for (const item of data.items) {
|
||||
if (!['signature', 'drainage'].includes(item.reportType) || !item.signatureId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '每条报备资料必须包含有效的资料类型和签名ID' });
|
||||
if (item.reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '引流资料必须包含引流资料ID' });
|
||||
}
|
||||
const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()];
|
||||
const items = await Promise.all(uniqueItems.map((item) => this.inspectBatchItem(item)));
|
||||
return {
|
||||
checkedAt: new Date().toISOString(),
|
||||
eligible: items.some((item) => item.eligible),
|
||||
eligibleItemCount: items.filter((item) => item.eligible).length,
|
||||
blockedItemCount: items.filter((item) => !item.eligible).length,
|
||||
eligibleTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => target.eligible).length, 0),
|
||||
skippedTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => !target.eligible).length, 0),
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number], inspection: ReportBatchInspection) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } });
|
||||
if (!signature || signature.auditStatus !== 'approved') throw new BadRequestException('签名不存在或未审核通过');
|
||||
const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId
|
||||
? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null;
|
||||
if (selected.reportType === 'drainage' && (!drainageInfo || drainageInfo.signatureId !== signature.id || drainageInfo.auditStatus !== 'approved')) throw new BadRequestException('引流信息不存在或未审核通过');
|
||||
const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({
|
||||
where: { applicationId: signature.applicationId, status: 'active' },
|
||||
include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
|
||||
orderBy: { priority: 'asc' },
|
||||
}) : [];
|
||||
const eligibleChannelIds = new Set(inspection.targets.filter((target) => target.eligible).map((target) => target.id));
|
||||
const channels = [...new Map(routes.flatMap((route) => route.group.items.map((entry) => entry.channel)).filter((channel) => channel.status === 'active' && eligibleChannelIds.has(channel.id)).map((channel) => [channel.id, channel])).values()];
|
||||
const snapshot = selected.reportType === 'signature'
|
||||
? { reportType: 'signature', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), signature: { id: signature.id, name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) }
|
||||
: { reportType: 'drainage', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), signature: { id: signature.id, name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { id: drainageInfo!.id, siteName: drainageInfo!.siteName, url: drainageInfo!.url, remark: drainageInfo!.remark }, values: jsonRecord(drainageInfo!.reportValues) };
|
||||
const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo!.materialVersion;
|
||||
const batchItem = await this.prisma.reportMaterialBatchItem.create({ data: { batchId, signatureId: signature.id, drainageItemId: drainageInfo?.id, reportType: selected.reportType, materialVersion, snapshot: snapshot as Prisma.InputJsonValue } });
|
||||
return { batchItem, signature, drainageInfo, reportType: selected.reportType, snapshot, channels };
|
||||
}
|
||||
|
||||
async inspectBatchItem(selected: CreateReportBatchDto['items'][number]): Promise<ReportBatchInspection> {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } });
|
||||
if (!signature) throw new NotFoundException('签名不存在');
|
||||
const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId
|
||||
? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null;
|
||||
const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo?.materialVersion ?? 0;
|
||||
const blockedReasons: string[] = [];
|
||||
if (signature.auditStatus !== 'approved') blockedReasons.push('签名尚未审核通过');
|
||||
if (!signature.pendingReport) blockedReasons.push('该签名版本已不在待报备池');
|
||||
if (!signature.applicationId || !signature.application) blockedReasons.push('未绑定短信应用');
|
||||
else if (signature.application.status !== 'active') blockedReasons.push('短信应用未启用');
|
||||
if (selected.materialVersion !== undefined && selected.materialVersion !== materialVersion) blockedReasons.push(`资料版本已变化(当前 V${materialVersion})`);
|
||||
if (selected.reportType === 'drainage') {
|
||||
if (!drainageInfo || drainageInfo.signatureId !== signature.id) blockedReasons.push('引流资料不存在或不属于当前签名');
|
||||
else {
|
||||
if (drainageInfo.auditStatus !== 'approved') blockedReasons.push('引流资料尚未审核通过');
|
||||
if (!drainageInfo.pendingReport) blockedReasons.push('该引流资料版本已不在待报备池');
|
||||
}
|
||||
}
|
||||
const snapshot = selected.reportType === 'signature'
|
||||
? { signature: { name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) }
|
||||
: { signature: { name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { siteName: drainageInfo?.siteName, url: drainageInfo?.url, remark: drainageInfo?.remark }, values: jsonRecord(drainageInfo?.reportValues) };
|
||||
const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({
|
||||
where: { applicationId: signature.applicationId, status: 'active' },
|
||||
include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } },
|
||||
orderBy: { priority: 'asc' },
|
||||
}) : [];
|
||||
const channelCarriers = new Map<string, { channel: { id: string; name: string; status: string; carrier?: string | null }; carriers: Set<string> }>();
|
||||
for (const route of routes) {
|
||||
if (route.group.status !== 'active') continue;
|
||||
for (const entry of route.group.items) {
|
||||
if (entry.channel.status !== 'active') continue;
|
||||
const current = channelCarriers.get(entry.channel.id) ?? { channel: entry.channel, carriers: new Set<string>() };
|
||||
current.carriers.add(route.carrier || entry.carrier || entry.channel.carrier || 'all');
|
||||
channelCarriers.set(entry.channel.id, current);
|
||||
}
|
||||
}
|
||||
if (blockedReasons.length === 0 && channelCarriers.size === 0) blockedReasons.push('当前应用没有启用且可路由的通道');
|
||||
const previous = await this.prisma.reportMaterialBatchItem.findMany({
|
||||
where: { signatureId: signature.id, drainageItemId: selected.reportType === 'drainage' ? drainageInfo?.id : null, reportType: selected.reportType, materialVersion, batch: { status: { in: ['completed', 'partial_failed'] } } },
|
||||
select: { batchId: true, snapshot: true, exportItems: { select: { exportFile: { select: { channelId: true } } } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const priorKeys = new Map<string, string>();
|
||||
for (const item of previous) {
|
||||
const exportedChannelIds = new Set(item.exportItems.map((entry) => entry.exportFile.channelId).filter((value): value is string => Boolean(value)));
|
||||
for (const key of jsonStringArray(jsonRecord(item.snapshot).businessKeys)) {
|
||||
if ([...exportedChannelIds].some((channelId) => key.includes(`:channel:${channelId}:`)) && !priorKeys.has(key)) priorKeys.set(key, item.batchId);
|
||||
}
|
||||
}
|
||||
const targets: ReportBatchTarget[] = [];
|
||||
for (const { channel, carriers } of channelCarriers.values()) {
|
||||
const carrier = [...carriers].sort().join(',');
|
||||
const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`;
|
||||
const targetReasons = [...blockedReasons];
|
||||
const fields = await this.prisma.channelReportField.findMany({ where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
|
||||
if (fields.length === 0) targetReasons.push('通道未配置当前资料类型的报备字段');
|
||||
else {
|
||||
const missing = fields.filter((field) => field.required && !hasValue(resolveExportValue(snapshot, field.code, field.name) ?? field.defaultValue));
|
||||
if (missing.length) targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`);
|
||||
}
|
||||
const duplicateBatchId = priorKeys.get(businessKey);
|
||||
if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`);
|
||||
targets.push({ id: channel.id, name: channel.name, carrier, businessKey, eligible: targetReasons.length === 0, blockedReasons: targetReasons, duplicateBatchId });
|
||||
}
|
||||
return {
|
||||
id: `${selected.reportType}:${selected.drainageItemId ?? signature.id}`,
|
||||
reportType: selected.reportType,
|
||||
signatureId: signature.id,
|
||||
drainageItemId: drainageInfo?.id,
|
||||
materialVersion,
|
||||
name: selected.reportType === 'signature' ? signature.name : drainageInfo?.siteName ?? '引流资料',
|
||||
tenantName: signature.tenant.name,
|
||||
applicationId: signature.applicationId ?? undefined,
|
||||
applicationName: signature.application?.name ?? '未指定应用',
|
||||
eligible: targets.some((target) => target.eligible),
|
||||
blockedReasons: targets.length ? [...new Set(targets.flatMap((target) => target.blockedReasons))] : blockedReasons,
|
||||
targets,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { extname } from 'node:path';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
|
||||
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
|
||||
|
||||
|
||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||
export class ReportBatchOperationService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
|
||||
|
||||
async claimBatchOperation(idempotencyKey: string, fingerprint: string, userId?: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${idempotencyKey}))`;
|
||||
const existing = await tx.operationLog.findFirst({ where: { action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey }, orderBy: { createdAt: 'desc' } });
|
||||
if (existing) {
|
||||
const detail = jsonRecord(existing.detail);
|
||||
if (detail.fingerprint !== fingerprint) throw new ConflictException({ code: 'IDEMPOTENCY_KEY_REUSED', message: '该幂等键已用于不同的报备范围' });
|
||||
if (detail.status === 'completed' && detail.result) return { operationId: existing.id, replayed: true as const, result: { ...jsonRecord(detail.result), replayed: true, operationId: existing.id } };
|
||||
throw new ConflictException({ code: 'REPORT_BATCH_IN_PROGRESS', message: detail.status === 'failed' ? '上次生成失败,请使用新的操作单重试' : '该报备操作正在处理中,请勿重复提交' });
|
||||
}
|
||||
const operation = await tx.operationLog.create({ data: { userId, action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey, detail: { status: 'processing', fingerprint } } });
|
||||
return { operationId: operation.id, replayed: false as const, result: null };
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
|
||||
}
|
||||
|
||||
async completeBatchOperation(operationId: string, batchId: string, result: Record<string, unknown>) {
|
||||
await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { status: 'completed', batchId, fingerprint: jsonRecord((await this.prisma.operationLog.findUnique({ where: { id: operationId } }))?.detail).fingerprint, result: jsonSafe(result) } as Prisma.InputJsonValue } });
|
||||
}
|
||||
|
||||
async failBatchOperation(operationId: string, message: string, batchId?: string) {
|
||||
const operation = await this.prisma.operationLog.findUnique({ where: { id: operationId } });
|
||||
await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { ...jsonRecord(operation?.detail), status: 'failed', batchId, message } as Prisma.InputJsonValue } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { extname } from 'node:path';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
|
||||
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
|
||||
import type { ReportBatchGenerationService } from './batch-generation.service';
|
||||
|
||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||
export class ReportChannelExportService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
|
||||
|
||||
async exportChannelBatch(batchId: string, channelId: string, items: Array<Awaited<ReturnType<ReportBatchGenerationService['prepareBatchItem']>>>) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
|
||||
if (!channel) throw new NotFoundException('通道不存在');
|
||||
const reportTypes = [...new Set(items.map((item) => item.reportType))];
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const fileRows: Array<{ item: (typeof items)[number]; taskId: string; rowNumber: number }> = [];
|
||||
const incompleteBatchItemIds: string[] = [];
|
||||
let totalRows = 0;
|
||||
for (const reportType of reportTypes) {
|
||||
const fields = await this.prisma.channelReportField.findMany({ where: { channelId, status: 'active', reportType: { in: [reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
|
||||
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名报备' : '引流信息报备', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
sheet.properties.defaultRowHeight = 22;
|
||||
sheet.columns = fields.map((field) => ({ header: field.exportName || field.name, key: field.code, width: field.columnWidth }));
|
||||
styleHeader(sheet.getRow(1));
|
||||
for (const item of items.filter((current) => current.reportType === reportType)) {
|
||||
const values = fields.map((field) => resolveExportValue(item.snapshot, field.code, field.name) ?? field.defaultValue ?? '');
|
||||
const missing = fields.filter((field, index) => field.required && !hasValue(values[index]));
|
||||
const missingReason = fields.length === 0 ? '通道未配置当前资料类型的报备字段' : missing.length ? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}` : null;
|
||||
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ where: { signatureId: item.signature.id, channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null } });
|
||||
const task = existingTask
|
||||
? await this.prisma.channelSignatureReportTask.update({ where: { id: existingTask.id }, data: { status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } })
|
||||
: await this.prisma.channelSignatureReportTask.create({ data: { tenantId: item.signature.tenantId, signatureId: item.signature.id, channelId, reportType, drainageItemId: item.drainageInfo?.id, status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } });
|
||||
if (missingReason) {
|
||||
incompleteBatchItemIds.push(item.batchItem.id);
|
||||
await this.recordTask(task.id, channelId, existingTask?.status, 'waiting_material', task.reason ?? undefined);
|
||||
continue;
|
||||
}
|
||||
const row = sheet.addRow(values.map((value, index) => isFileRef(value) ? value.fileName : applyExportTransform(value, fields[index]?.transform)));
|
||||
totalRows += 1;
|
||||
let targetHeight = 22;
|
||||
for (const [index, value] of values.entries()) {
|
||||
if (!isFileRef(value)) continue;
|
||||
const downloaded = await this.files.getDownload(value.fileObjectId);
|
||||
if (!downloaded.fileObject.contentType.startsWith('image/')) continue;
|
||||
const extension = normalizeImageExtension(extname(downloaded.fileObject.fileName).slice(1) || downloaded.fileObject.contentType.split('/')[1]);
|
||||
if (!['png', 'jpeg', 'gif'].includes(extension)) continue;
|
||||
const imageId = workbook.addImage({ base64: `data:${downloaded.fileObject.contentType};base64,${downloaded.content.toString('base64')}`, extension: extension as 'png' | 'jpeg' | 'gif' });
|
||||
const widthCells = Math.max(0.8, fields[index].imageWidth / Math.max(60, fields[index].columnWidth * 7));
|
||||
const heightRows = Math.max(0.8, fields[index].imageHeight / 20);
|
||||
sheet.addImage(imageId, { tl: { col: index + 0.08, row: row.number - 1 + 0.08 }, br: { col: index + Math.min(0.95, widthCells), row: row.number - 1 + Math.min(0.95, heightRows) }, editAs: 'oneCell' } as never);
|
||||
targetHeight = Math.max(targetHeight, fields[index].imageHeight * 0.75 + 8);
|
||||
}
|
||||
row.height = targetHeight;
|
||||
fileRows.push({ item, taskId: task.id, rowNumber: row.number });
|
||||
await this.recordTask(task.id, channelId, existingTask?.status, 'exporting');
|
||||
}
|
||||
}
|
||||
if (workbook.worksheets.every((sheet) => sheet.rowCount <= 1)) {
|
||||
for (const sheet of [...workbook.worksheets]) workbook.removeWorksheet(sheet.id);
|
||||
const empty = workbook.addWorksheet('无可导出数据');
|
||||
empty.getCell('A1').value = '所选资料缺少当前通道必填字段,请补充后重新生成。';
|
||||
empty.getColumn(1).width = 64;
|
||||
}
|
||||
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
const fileName = `${safeFileName(channel.name)}-${batchId.slice(-8)}.xlsx`;
|
||||
const uploaded = await this.files.upload({ purpose: 'report_export', prefix: `report-exports/${batchId}` }, { originalname: fileName, mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer });
|
||||
const file = await this.prisma.reportExportFile.create({ data: { batchId, channelId, fileObjectId: uploaded.id, fileName, rowCount: totalRows } });
|
||||
if (fileRows.length) await this.prisma.reportExportFileItem.createMany({ data: fileRows.map((entry) => ({ exportFileId: file.id, batchItemId: entry.item.batchItem.id, taskId: entry.taskId, rowNumber: entry.rowNumber })) });
|
||||
return { file: { ...file, fileObject: uploaded }, incompleteBatchItemIds };
|
||||
}
|
||||
|
||||
recordTask(taskId: string, channelId: string, statusBefore: string | undefined, statusAfter: string, reason?: string) {
|
||||
return this.prisma.channelSignatureReportRecord.create({ data: { taskId, channelId, action: 'batch_export', statusBefore, statusAfter, reason, sourceEntry: 'report_task' } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { extname } from 'node:path';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
|
||||
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
|
||||
|
||||
|
||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||
export class ReportImportParserService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
|
||||
|
||||
listImportProfiles(reportType?: 'signature' | 'drainage') {
|
||||
return this.prisma.reportMaterialImportProfile.findMany({
|
||||
where: { reportType, status: 'active' },
|
||||
include: { columns: { orderBy: [{ sortOrder: 'asc' }, { sourceColumnIndex: 'asc' }] } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async saveImportProfile(data: CreateImportProfileDto) {
|
||||
validateProfile(data);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const profile = data.id
|
||||
? await tx.reportMaterialImportProfile.update({ where: { id: data.id }, data: profileData(data) })
|
||||
: await tx.reportMaterialImportProfile.create({ data: profileData(data) });
|
||||
await tx.reportMaterialImportProfileColumn.deleteMany({ where: { profileId: profile.id } });
|
||||
await tx.reportMaterialImportProfileColumn.createMany({
|
||||
data: data.columns.map((column, index) => ({
|
||||
profileId: profile.id,
|
||||
sourceHeader: column.sourceHeader,
|
||||
sourceHeaderPath: column.sourceHeaderPath,
|
||||
sourceColumnIndex: column.sourceColumnIndex,
|
||||
targetFieldCode: column.targetFieldCode,
|
||||
targetKind: column.targetKind,
|
||||
fieldType: column.fieldType,
|
||||
required: column.required ?? false,
|
||||
transform: column.transform,
|
||||
sortOrder: column.sortOrder ?? (index + 1) * 10,
|
||||
})),
|
||||
});
|
||||
return tx.reportMaterialImportProfile.findUnique({ where: { id: profile.id }, include: { columns: { orderBy: { sortOrder: 'asc' } } } });
|
||||
});
|
||||
}
|
||||
|
||||
async analyzeImport(file: { originalname: string; mimetype: string; size: number; buffer: Buffer }, options: AnalyzeImportOptions) {
|
||||
if (!options.tenantId) throw new BadRequestException('tenantId is required');
|
||||
if (!['signature', 'drainage'].includes(options.reportType)) throw new BadRequestException('reportType must be signature or drainage');
|
||||
if (extname(file.originalname).toLowerCase() !== '.xlsx' || file.buffer[0] !== 0x50 || file.buffer[1] !== 0x4b) throw new BadRequestException('仅支持有效的 XLSX 文件');
|
||||
const workbook = await loadWorkbook(file.buffer);
|
||||
assertSafeWorkbook(workbook);
|
||||
const profile = options.profileId ? await this.prisma.reportMaterialImportProfile.findUnique({ where: { id: options.profileId }, include: { columns: { orderBy: { sortOrder: 'asc' } } } }) : null;
|
||||
const selectedSheetName = options.sheetName || profile?.sheetName || undefined;
|
||||
const worksheet = selectedSheetName ? workbook.getWorksheet(selectedSheetName) : workbook.worksheets[0];
|
||||
if (!worksheet) throw new BadRequestException('工作簿没有可读取的工作表');
|
||||
const headerRowCount = clamp(options.headerRowCount, 1, 5);
|
||||
const dataStartRow = Math.max(options.dataStartRow, headerRowCount + 1);
|
||||
const images = readEmbeddedImages(workbook, worksheet);
|
||||
const columnCount = Math.min(worksheet.columnCount, 200);
|
||||
const columns = Array.from({ length: columnCount }, (_, offset) => {
|
||||
const sourceColumnIndex = offset + 1;
|
||||
const parts = Array.from({ length: headerRowCount }, (_, headerOffset) => cellText(worksheet.getCell(headerOffset + 1, sourceColumnIndex))).filter(Boolean);
|
||||
const sourceHeaderPath = [...new Set(parts)].join('/');
|
||||
return {
|
||||
sourceColumnIndex,
|
||||
columnLetter: worksheet.getColumn(sourceColumnIndex).letter,
|
||||
sourceHeader: parts.at(-1) || `第${sourceColumnIndex}列`,
|
||||
sourceHeaderPath,
|
||||
imageCount: images.filter((image) => image.column === sourceColumnIndex).length,
|
||||
};
|
||||
}).filter((column) => column.sourceHeaderPath || column.imageCount > 0);
|
||||
const previewRows = [];
|
||||
for (let rowNumber = dataStartRow; rowNumber <= Math.min(worksheet.rowCount, dataStartRow + 9); rowNumber += 1) {
|
||||
const values = Object.fromEntries(columns.map((column) => [String(column.sourceColumnIndex), cellText(worksheet.getCell(rowNumber, column.sourceColumnIndex))]));
|
||||
const imageColumns = images.filter((image) => image.row === rowNumber).map((image) => image.column);
|
||||
if (Object.values(values).some(Boolean) || imageColumns.length) previewRows.push({ rowNumber, values, imageColumns });
|
||||
}
|
||||
const sourceFile = await this.files.upload({ tenantId: options.tenantId, purpose: 'report_material_import', prefix: 'report-material-imports' }, file);
|
||||
const profileMappings = profile?.columns.map((column) => ({
|
||||
sourceHeader: column.sourceHeader,
|
||||
sourceHeaderPath: column.sourceHeaderPath ?? undefined,
|
||||
sourceColumnIndex: column.sourceColumnIndex,
|
||||
targetFieldCode: column.targetFieldCode,
|
||||
targetKind: column.targetKind as ImportMapping['targetKind'],
|
||||
fieldType: column.fieldType as ImportMapping['fieldType'],
|
||||
required: column.required,
|
||||
transform: column.transform ?? undefined,
|
||||
sortOrder: column.sortOrder,
|
||||
}));
|
||||
const suggestedMappings = profileMappings?.length ? remapProfileColumns(profileMappings, columns) : suggestMappings(columns, options.reportType);
|
||||
const batch = await this.prisma.reportMaterialImportBatch.create({
|
||||
data: {
|
||||
tenantId: options.tenantId,
|
||||
applicationId: options.applicationId,
|
||||
profileId: options.profileId,
|
||||
fileObjectId: sourceFile.id,
|
||||
fileName: sourceFile.fileName,
|
||||
reportType: options.reportType,
|
||||
sheetName: worksheet.name,
|
||||
headerRowCount,
|
||||
dataStartRow,
|
||||
mapping: suggestedMappings as Prisma.InputJsonValue,
|
||||
preview: { sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length } as Prisma.InputJsonValue,
|
||||
rowCount: Math.max(0, worksheet.rowCount - dataStartRow + 1),
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({ data: {
|
||||
tenantId: options.tenantId, userId: options.operatorId, action: 'report_material.import_analyzed', resource: 'report_material_import', resourceId: batch.id,
|
||||
detail: { fileName: sourceFile.fileName, filters: { applicationId: options.applicationId, reportType: options.reportType, sheetName: worksheet.name }, successCount: previewRows.length, failedCount: 0 } as Prisma.InputJsonValue,
|
||||
} });
|
||||
return { ...batch, sourceFile, sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length, suggestedMappings };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { extname } from 'node:path';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
|
||||
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
|
||||
import { ReportImportParserService } from './import-parser.service';
|
||||
|
||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||
export class ReportImportReviewService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly importParser: ReportImportParserService) {}
|
||||
|
||||
async commitImport(batchId: string, data: ImportCommitDto) {
|
||||
const batch = await this.prisma.reportMaterialImportBatch.findUnique({ where: { id: batchId } });
|
||||
if (!batch) throw new NotFoundException('导入批次不存在');
|
||||
if (batch.status !== 'analyzed') throw new ConflictException('该导入批次已提交审核,不能重复导入');
|
||||
if (!data.mappings?.length) throw new BadRequestException('请至少配置一个导入字段映射');
|
||||
if (data.profile) await this.importParser.saveImportProfile({ ...data.profile, reportType: batch.reportType as 'signature' | 'drainage', columns: data.mappings });
|
||||
const { content } = await this.files.getDownload(batch.fileObjectId);
|
||||
const workbook = await loadWorkbook(content);
|
||||
assertSafeWorkbook(workbook);
|
||||
const worksheet = workbook.getWorksheet(batch.sheetName);
|
||||
if (!worksheet) throw new BadRequestException('导入工作表不存在');
|
||||
const images = readEmbeddedImages(workbook, worksheet);
|
||||
const imageByCell = new Map(images.map((image) => [`${image.row}:${image.column}`, image]));
|
||||
let successCount = 0;
|
||||
const failures: Array<{ rowNumber: number; reason: string }> = [];
|
||||
const stagedItems: Prisma.ReportMaterialImportItemCreateManyInput[] = [];
|
||||
for (let rowNumber = batch.dataStartRow; rowNumber <= worksheet.rowCount; rowNumber += 1) {
|
||||
const values: Record<string, unknown> = {};
|
||||
try {
|
||||
for (const mapping of data.mappings) {
|
||||
const image = imageByCell.get(`${rowNumber}:${mapping.sourceColumnIndex}`);
|
||||
if (image && mapping.fieldType !== 'string') {
|
||||
const uploaded = await this.files.upload({ tenantId: batch.tenantId, purpose: 'report_material', prefix: `report-materials/import-${batch.id}` }, {
|
||||
originalname: `${mapping.targetFieldCode}-row-${rowNumber}.${normalizeImageExtension(image.extension)}`,
|
||||
mimetype: imageContentType(image.extension),
|
||||
size: image.buffer.length,
|
||||
buffer: image.buffer,
|
||||
});
|
||||
values[mapping.targetFieldCode] = { fileObjectId: uploaded.id, fileName: uploaded.fileName, contentType: uploaded.contentType };
|
||||
} else {
|
||||
values[mapping.targetFieldCode] = transformValue(cellText(worksheet.getCell(rowNumber, mapping.sourceColumnIndex)), mapping.transform);
|
||||
}
|
||||
}
|
||||
if (!Object.values(values).some(hasValue)) continue;
|
||||
for (const mapping of data.mappings.filter((item) => item.required)) {
|
||||
if (!hasValue(values[mapping.targetFieldCode])) throw new Error(`缺少必填字段:${mapping.sourceHeader}`);
|
||||
}
|
||||
const staged = batch.reportType === 'signature'
|
||||
? await this.stageSignatureRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values)
|
||||
: await this.stageDrainageRow(batch.tenantId, batch.applicationId ?? undefined, data.mappings, values);
|
||||
stagedItems.push({
|
||||
batchId,
|
||||
rowNumber,
|
||||
reportType: batch.reportType,
|
||||
operation: staged.operation,
|
||||
targetId: staged.targetId,
|
||||
status: 'pending_review',
|
||||
payload: staged.payload as Prisma.InputJsonValue,
|
||||
originalSnapshot: staged.originalSnapshot as Prisma.InputJsonValue | undefined,
|
||||
});
|
||||
successCount += 1;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : '导入失败';
|
||||
failures.push({ rowNumber, reason });
|
||||
stagedItems.push({
|
||||
batchId,
|
||||
rowNumber,
|
||||
reportType: batch.reportType,
|
||||
operation: 'invalid',
|
||||
status: 'invalid',
|
||||
payload: values as Prisma.InputJsonValue,
|
||||
errorMessage: reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
if (stagedItems.length) await tx.reportMaterialImportItem.createMany({ data: stagedItems });
|
||||
return tx.reportMaterialImportBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
status: successCount ? 'pending_review' : 'failed',
|
||||
mapping: data.mappings as Prisma.InputJsonValue,
|
||||
result: { failures } as Prisma.InputJsonValue,
|
||||
successCount,
|
||||
failedCount: failures.length,
|
||||
},
|
||||
include: { items: { orderBy: { rowNumber: 'asc' } } },
|
||||
});
|
||||
});
|
||||
await this.prisma.operationLog.create({ data: {
|
||||
tenantId: batch.tenantId, userId: data.operatorId, action: 'report_material.import_committed', resource: 'report_material_import', resourceId: batch.id,
|
||||
detail: { fileName: batch.fileName, filters: { applicationId: batch.applicationId, reportType: batch.reportType, sheetName: batch.sheetName }, successCount, failedCount: failures.length } as Prisma.InputJsonValue,
|
||||
} });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async listImportReviewBatches(query: PagedQuery & { reportType?: 'signature' | 'drainage'; status?: string } = {}) {
|
||||
const page = normalizePage(query.page);
|
||||
const pageSize = normalizePageSize(query.pageSize);
|
||||
const where: Prisma.ReportMaterialImportBatchWhereInput = {
|
||||
reportType: query.reportType,
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
createdAt: dateRange(query.startAt, query.endAt),
|
||||
OR: query.keyword?.trim() ? [
|
||||
{ fileName: { contains: query.keyword.trim() } },
|
||||
{ id: { contains: query.keyword.trim() } },
|
||||
] : undefined,
|
||||
};
|
||||
const [batches, total] = await Promise.all([
|
||||
this.prisma.reportMaterialImportBatch.findMany({
|
||||
where,
|
||||
include: { items: { orderBy: { rowNumber: 'asc' } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.reportMaterialImportBatch.count({ where }),
|
||||
]);
|
||||
const tenantIds = [...new Set(batches.map((batch) => batch.tenantId))];
|
||||
const applicationIds = [...new Set(batches.map((batch) => batch.applicationId).filter((id): id is string => Boolean(id)))];
|
||||
const reviewerIds = [...new Set(batches.flatMap((batch) => [
|
||||
batch.reviewedById,
|
||||
...batch.items.map((item) => item.reviewedById),
|
||||
]).filter((id): id is string => Boolean(id)))];
|
||||
const [tenants, applications, reviewers] = await Promise.all([
|
||||
tenantIds.length ? this.prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, name: true } }) : [],
|
||||
applicationIds.length ? this.prisma.smsApplication.findMany({ where: { id: { in: applicationIds } }, select: { id: true, name: true } }) : [],
|
||||
reviewerIds.length ? this.prisma.user.findMany({ where: { id: { in: reviewerIds } }, select: { id: true, username: true, displayName: true } }) : [],
|
||||
]);
|
||||
const tenantById = new Map(tenants.map((item) => [item.id, item]));
|
||||
const applicationById = new Map(applications.map((item) => [item.id, item]));
|
||||
const reviewerById = new Map(reviewers.map((item) => [item.id, item]));
|
||||
return {
|
||||
items: batches.map((batch) => ({
|
||||
...batch,
|
||||
tenant: tenantById.get(batch.tenantId) ?? null,
|
||||
application: batch.applicationId ? applicationById.get(batch.applicationId) ?? null : null,
|
||||
reviewer: batch.reviewedById ? reviewerById.get(batch.reviewedById) ?? null : null,
|
||||
items: batch.items.map((item) => ({
|
||||
...item,
|
||||
reviewer: item.reviewedById ? reviewerById.get(item.reviewedById) ?? null : null,
|
||||
})),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async reviewImportItems(batchId: string, data: ReviewImportItemsDto) {
|
||||
if (!data.reviewerId) throw new BadRequestException('Reviewer session is required');
|
||||
if (!['approve', 'reject'].includes(data.decision)) throw new BadRequestException('Unsupported import review decision');
|
||||
const batch = await this.prisma.reportMaterialImportBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
include: { items: { where: { id: data.itemIds?.length ? { in: data.itemIds } : undefined, status: 'pending_review' }, orderBy: { rowNumber: 'asc' } } },
|
||||
});
|
||||
if (!batch) throw new NotFoundException('导入审核批次不存在');
|
||||
if (!batch.items.length) throw new BadRequestException('没有可审核的导入明细');
|
||||
let approvedCount = 0;
|
||||
let rejectedCount = 0;
|
||||
const failures: Array<{ itemId: string; rowNumber: number; reason: string }> = [];
|
||||
for (const item of batch.items) {
|
||||
if (data.decision === 'reject') {
|
||||
await this.prisma.reportMaterialImportItem.update({
|
||||
where: { id: item.id },
|
||||
data: { status: 'rejected', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date() },
|
||||
});
|
||||
rejectedCount += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const targetId = await this.applyImportItem(batch, item, data.reviewerId);
|
||||
await this.prisma.reportMaterialImportItem.update({
|
||||
where: { id: item.id },
|
||||
data: { targetId, status: 'approved', reviewReason: data.reason?.trim(), reviewedById: data.reviewerId, reviewedAt: new Date(), errorMessage: null },
|
||||
});
|
||||
approvedCount += 1;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : '导入审核应用失败';
|
||||
failures.push({ itemId: item.id, rowNumber: item.rowNumber, reason });
|
||||
await this.prisma.reportMaterialImportItem.update({
|
||||
where: { id: item.id },
|
||||
data: { status: 'invalid', errorMessage: reason, reviewedById: data.reviewerId, reviewedAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
const counts = await this.prisma.reportMaterialImportItem.groupBy({
|
||||
by: ['status'],
|
||||
where: { batchId },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const countByStatus = new Map(counts.map((item) => [item.status, item._count._all]));
|
||||
const pendingCount = countByStatus.get('pending_review') ?? 0;
|
||||
const totalApproved = countByStatus.get('approved') ?? 0;
|
||||
const totalRejected = countByStatus.get('rejected') ?? 0;
|
||||
const totalInvalid = countByStatus.get('invalid') ?? 0;
|
||||
const status = pendingCount
|
||||
? 'partially_reviewed'
|
||||
: totalApproved && (totalRejected || totalInvalid)
|
||||
? 'partially_approved'
|
||||
: totalApproved
|
||||
? 'approved'
|
||||
: totalRejected
|
||||
? 'rejected'
|
||||
: 'failed';
|
||||
await this.prisma.reportMaterialImportBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
status,
|
||||
reviewedById: pendingCount ? undefined : data.reviewerId,
|
||||
reviewedAt: pendingCount ? undefined : new Date(),
|
||||
completedAt: pendingCount ? undefined : new Date(),
|
||||
},
|
||||
});
|
||||
return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures };
|
||||
}
|
||||
|
||||
async stageSignatureRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
|
||||
const name = mappedCoreValue(mappings, values, 'signatureName');
|
||||
if (!name) throw new Error('缺少短信签名');
|
||||
const purpose = mappedCoreValue(mappings, values, 'purpose');
|
||||
const signatureReportValues = dynamicValues(mappings, values);
|
||||
const existing = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } } });
|
||||
return {
|
||||
operation: existing ? 'update' : 'create',
|
||||
targetId: existing?.id,
|
||||
payload: {
|
||||
tenantId,
|
||||
applicationId,
|
||||
name,
|
||||
purpose,
|
||||
drainageInfo: { ...jsonRecord(existing?.drainageInfo), signatureReportValues },
|
||||
},
|
||||
originalSnapshot: existing ? {
|
||||
id: existing.id,
|
||||
applicationId: existing.applicationId,
|
||||
name: existing.name,
|
||||
purpose: existing.purpose,
|
||||
drainageInfo: existing.drainageInfo,
|
||||
auditStatus: existing.auditStatus,
|
||||
updatedAt: existing.updatedAt,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async stageDrainageRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
|
||||
const signatureName = mappedCoreValue(mappings, values, 'signatureName');
|
||||
const siteName = mappedCoreValue(mappings, values, 'siteName');
|
||||
const url = mappedCoreValue(mappings, values, 'url');
|
||||
if (!signatureName || !siteName || !url) throw new Error('引流信息必须包含短信签名、站点名称和URL');
|
||||
const signature = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name: signatureName, auditStatus: 'approved' } });
|
||||
if (!signature) throw new Error(`未找到已审核签名:${signatureName}`);
|
||||
const remark = mappedCoreValue(mappings, values, 'remark');
|
||||
const reportValues = dynamicValues(mappings, values);
|
||||
const existing = await this.prisma.smsDrainageInfo.findFirst({ where: { signatureId: signature.id, url, auditStatus: { not: 'deleted' } } });
|
||||
return {
|
||||
operation: existing ? 'update' : 'create',
|
||||
targetId: existing?.id,
|
||||
payload: { tenantId, applicationId, signatureId: signature.id, signatureName, siteName, url, remark, reportValues },
|
||||
originalSnapshot: existing ? {
|
||||
id: existing.id,
|
||||
siteName: existing.siteName,
|
||||
url: existing.url,
|
||||
remark: existing.remark,
|
||||
reportValues: existing.reportValues,
|
||||
auditStatus: existing.auditStatus,
|
||||
updatedAt: existing.updatedAt,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async applyImportItem(
|
||||
batch: { tenantId: string; applicationId: string | null; reportType: string },
|
||||
item: { reportType: string; targetId: string | null; payload: Prisma.JsonValue },
|
||||
reviewerId: string,
|
||||
) {
|
||||
const payload = jsonRecord(item.payload);
|
||||
if (item.reportType === 'signature') {
|
||||
const name = String(payload.name ?? '');
|
||||
const applicationId = typeof payload.applicationId === 'string' ? payload.applicationId : undefined;
|
||||
const body = {
|
||||
applicationId,
|
||||
name,
|
||||
purpose: typeof payload.purpose === 'string' ? payload.purpose : undefined,
|
||||
drainageInfo: jsonRecord(payload.drainageInfo),
|
||||
};
|
||||
let targetId = item.targetId;
|
||||
if (targetId) {
|
||||
const current = await this.prisma.smsSignature.findUnique({ where: { id: targetId } });
|
||||
if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改');
|
||||
await this.smsConfig.updateSignature(targetId, body, batch.tenantId);
|
||||
} else {
|
||||
const duplicate = await this.prisma.smsSignature.findFirst({
|
||||
where: { tenantId: batch.tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } },
|
||||
});
|
||||
if (duplicate) {
|
||||
targetId = duplicate.id;
|
||||
await this.smsConfig.updateSignature(targetId, body, batch.tenantId);
|
||||
} else {
|
||||
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...body });
|
||||
targetId = created.id;
|
||||
}
|
||||
}
|
||||
await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` });
|
||||
return targetId;
|
||||
}
|
||||
const signatureId = String(payload.signatureId ?? '');
|
||||
const siteName = String(payload.siteName ?? '');
|
||||
const url = String(payload.url ?? '');
|
||||
const body = {
|
||||
siteName,
|
||||
url,
|
||||
remark: typeof payload.remark === 'string' ? payload.remark : undefined,
|
||||
reportValues: jsonRecord(payload.reportValues),
|
||||
};
|
||||
let targetId = item.targetId;
|
||||
if (targetId) {
|
||||
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: targetId } });
|
||||
if (!current || current.auditStatus === 'deleted') throw new Error('原引流信息已删除,不能应用导入修改');
|
||||
await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
|
||||
} else {
|
||||
const duplicate = await this.prisma.smsDrainageInfo.findFirst({
|
||||
where: { signatureId, url, auditStatus: { not: 'deleted' } },
|
||||
});
|
||||
if (duplicate) {
|
||||
targetId = duplicate.id;
|
||||
await this.smsConfig.updateDrainageInfo(targetId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
|
||||
} else {
|
||||
const created = await this.smsConfig.createDrainageInfo(signatureId, body, { initialAuditStatus: 'pending' }, batch.tenantId);
|
||||
targetId = created.id;
|
||||
}
|
||||
}
|
||||
await this.smsConfig.approveDrainageInfo(targetId, { reviewerId, reason: `批量导入审核通过:${siteName || url}` });
|
||||
return targetId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { extname } from 'node:path';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
|
||||
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
|
||||
import { ReportPendingQueryService } from './pending-query.service';
|
||||
|
||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||
export class ReportOfficialExportService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService, private readonly pending: ReportPendingQueryService) {}
|
||||
|
||||
async buildOfficialTemplate(reportType: 'signature' | 'drainage', operatorId?: string) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'CMPP短信平台';
|
||||
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名资料' : '引流信息', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
const headers = reportType === 'signature'
|
||||
? ['短信签名', '用途说明', '营业执照图片', '授权书图片', '备注']
|
||||
: ['短信签名', '站点名称', 'URL', '备注', '网站截图'];
|
||||
sheet.addRow(headers);
|
||||
sheet.addRow(reportType === 'signature'
|
||||
? ['示例签名', '验证码通知', '请在本单元格插入图片', '请在本单元格插入图片', '示例行,导入前请删除']
|
||||
: ['示例签名', '官方站点', 'https://example.com', '示例行,导入前请删除', '请在本单元格插入图片']);
|
||||
styleHeader(sheet.getRow(1));
|
||||
sheet.columns.forEach((column) => { column.width = 24; });
|
||||
sheet.getRow(2).height = 48;
|
||||
const content = Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
const fileName = `${reportType === 'signature' ? '签名' : '引流信息'}报备资料官方模板.xlsx`;
|
||||
await this.prisma.operationLog.create({ data: {
|
||||
userId: operatorId, action: 'report_material.template_downloaded', resource: 'report_material',
|
||||
detail: { fileName, filters: { reportType }, successCount: 1, failedCount: 0 } as Prisma.InputJsonValue,
|
||||
} });
|
||||
return { fileName, content };
|
||||
}
|
||||
|
||||
async exportPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }, operatorId?: string) {
|
||||
const items = await this.pending.findPendingItems(query);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('待报备资料', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
sheet.addRow(['资料类型', '企业', '企业应用', '签名/站点', '详情', '变更时间']);
|
||||
styleHeader(sheet.getRow(1));
|
||||
for (const item of items) sheet.addRow([
|
||||
item.reportType === 'signature' ? '签名' : '引流信息', safeSpreadsheetText(item.tenant?.name),
|
||||
safeSpreadsheetText(item.application?.name), safeSpreadsheetText(item.name), safeSpreadsheetText(item.detail), item.changedAt,
|
||||
]);
|
||||
sheet.columns.forEach((column, index) => { column.width = index === 4 ? 42 : 22; });
|
||||
const fileName = `待报备资料-${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
await this.prisma.operationLog.create({ data: {
|
||||
tenantId: query.tenantId, userId: operatorId, action: 'report_material.pending_export', resource: 'report_material',
|
||||
detail: { fileName, filters: query, successCount: items.length, failedCount: 0 } as Prisma.InputJsonValue,
|
||||
} });
|
||||
return { fileName, content: Buffer.from(await workbook.xlsx.writeBuffer()) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { extname } from 'node:path';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
|
||||
import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers';
|
||||
|
||||
|
||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||
export class ReportPendingQueryService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
|
||||
|
||||
async listPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) {
|
||||
const items = await this.findPendingItems(query);
|
||||
const page = normalizePage(query.page);
|
||||
const pageSize = normalizePageSize(query.pageSize);
|
||||
return {
|
||||
items: items.slice((page - 1) * pageSize, page * pageSize),
|
||||
total: items.length,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findPendingItems(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } & PagedQuery) {
|
||||
const changedAt = dateRange(query.startAt, query.endAt);
|
||||
const keyword = query.keyword?.trim();
|
||||
const [signatures, drainageInfos] = await Promise.all([
|
||||
query.reportType === 'drainage' ? Promise.resolve([]) : this.prisma.smsSignature.findMany({
|
||||
where: {
|
||||
pendingReport: true,
|
||||
auditStatus: 'approved',
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
reportChangedAt: changedAt,
|
||||
OR: keyword ? [
|
||||
{ name: { contains: keyword } },
|
||||
{ tenant: { name: { contains: keyword } } },
|
||||
{ application: { name: { contains: keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { tenant: true, application: true },
|
||||
orderBy: { reportChangedAt: 'desc' },
|
||||
}),
|
||||
query.reportType === 'signature' ? Promise.resolve([]) : this.prisma.smsDrainageInfo.findMany({
|
||||
where: {
|
||||
pendingReport: true,
|
||||
auditStatus: 'approved',
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
reportChangedAt: changedAt,
|
||||
OR: keyword ? [
|
||||
{ siteName: { contains: keyword } },
|
||||
{ url: { contains: keyword } },
|
||||
{ signature: { name: { contains: keyword } } },
|
||||
{ tenant: { name: { contains: keyword } } },
|
||||
{ application: { name: { contains: keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { tenant: true, application: true, signature: true },
|
||||
orderBy: { reportChangedAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
return [
|
||||
...signatures.map((item) => ({ id: `signature:${item.id}`, reportType: 'signature', signatureId: item.id, drainageItemId: null, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.name, detail: item.purpose, tenant: item.tenant, application: item.application })),
|
||||
...drainageInfos.map((item) => ({ id: `drainage:${item.id}`, reportType: 'drainage', signatureId: item.signatureId, drainageItemId: item.id, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.siteName, detail: item.url, signatureName: item.signature.name, tenant: item.tenant, application: item.application })),
|
||||
].sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/** Stable request, query and internal data contracts for report-material domains. */
|
||||
|
||||
export type ImportMapping = {
|
||||
sourceHeader: string;
|
||||
sourceHeaderPath?: string;
|
||||
sourceColumnIndex: number;
|
||||
targetFieldCode: string;
|
||||
targetKind: 'signatureName' | 'purpose' | 'siteName' | 'url' | 'remark' | 'dynamic';
|
||||
fieldType: 'string' | 'image' | 'file';
|
||||
required?: boolean;
|
||||
transform?: string;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export interface CreateImportProfileDto {
|
||||
id?: string;
|
||||
name: string;
|
||||
reportType: 'signature' | 'drainage';
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
sheetName?: string;
|
||||
headerRowCount?: number;
|
||||
dataStartRow?: number;
|
||||
status?: string;
|
||||
columns: ImportMapping[];
|
||||
}
|
||||
|
||||
export interface ImportCommitDto {
|
||||
mappings: ImportMapping[];
|
||||
profile?: CreateImportProfileDto;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface ReviewImportItemsDto {
|
||||
decision: 'approve' | 'reject';
|
||||
itemIds?: string[];
|
||||
reason?: string;
|
||||
reviewerId?: string;
|
||||
}
|
||||
|
||||
export type PagedQuery = {
|
||||
keyword?: string;
|
||||
startAt?: string;
|
||||
endAt?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export interface CreateReportBatchDto {
|
||||
createdById?: string;
|
||||
idempotencyKey?: string;
|
||||
items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }>;
|
||||
}
|
||||
|
||||
export type ReportBatchTarget = { id: string; name: string; carrier: string; businessKey: string; eligible: boolean; blockedReasons: string[]; duplicateBatchId?: string };
|
||||
|
||||
export type ReportBatchInspection = {
|
||||
id: string;
|
||||
reportType: 'signature' | 'drainage';
|
||||
signatureId: string;
|
||||
drainageItemId?: string;
|
||||
materialVersion: number;
|
||||
name: string;
|
||||
tenantName: string;
|
||||
applicationId?: string;
|
||||
applicationName: string;
|
||||
eligible: boolean;
|
||||
blockedReasons: string[];
|
||||
targets: ReportBatchTarget[];
|
||||
};
|
||||
|
||||
export type AnalyzeImportOptions = {
|
||||
tenantId: string;
|
||||
applicationId?: string;
|
||||
reportType: 'signature' | 'drainage';
|
||||
sheetName?: string;
|
||||
headerRowCount: number;
|
||||
dataStartRow: number;
|
||||
profileId?: string;
|
||||
operatorId?: string;
|
||||
};
|
||||
|
||||
export type EmbeddedImage = { row: number; column: number; extension: string; buffer: Buffer };
|
||||
@@ -3,7 +3,8 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { CreateImportProfileDto, CreateReportBatchDto, ImportCommitDto, ReportMaterialsService, ReviewImportItemsDto } from './report-materials.service';
|
||||
import { ReportMaterialsService } from './report-materials.service';
|
||||
import { CreateImportProfileDto, CreateReportBatchDto, ImportCommitDto, ReviewImportItemsDto } from './report-materials.contracts';
|
||||
|
||||
type UploadedWorkbook = { originalname: string; mimetype: string; size: number; buffer: Buffer };
|
||||
type DownloadResponse = { setHeader(name: string, value: string): void; send(content: Buffer): void };
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { CreateImportProfileDto, EmbeddedImage, ImportMapping } from './report-materials.contracts';
|
||||
|
||||
/** Pure workbook, mapping, pagination and export helpers shared by R4 domains. */
|
||||
export function profileData(data: CreateImportProfileDto) {
|
||||
return { name: data.name.trim(), reportType: data.reportType, tenantId: data.tenantId, applicationId: data.applicationId, sheetName: data.sheetName, headerRowCount: clamp(data.headerRowCount ?? 1, 1, 5), dataStartRow: Math.max(data.dataStartRow ?? 2, 2), status: data.status ?? 'active' };
|
||||
}
|
||||
|
||||
export function validateProfile(data: CreateImportProfileDto) {
|
||||
if (!data.name?.trim()) throw new BadRequestException('映射模板名称不能为空');
|
||||
if (!data.columns?.length) throw new BadRequestException('映射模板至少包含一个字段');
|
||||
const indexes = data.columns.map((column) => column.sourceColumnIndex);
|
||||
if (new Set(indexes).size !== indexes.length) throw new BadRequestException('同一源列不能重复映射');
|
||||
}
|
||||
|
||||
export async function loadWorkbook(buffer: Buffer) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer as never);
|
||||
return workbook;
|
||||
}
|
||||
|
||||
export function assertSafeWorkbook(workbook: ExcelJS.Workbook) {
|
||||
for (const worksheet of workbook.worksheets) {
|
||||
worksheet.eachRow((row) => row.eachCell((cell) => {
|
||||
const value = cell.value;
|
||||
if (value && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) {
|
||||
throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`);
|
||||
}
|
||||
const text = typeof value === 'string' ? value.trimStart() : '';
|
||||
if (/^[=+@]/.test(text) || /^-[^\d.]/.test(text)) {
|
||||
throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`);
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export function safeSpreadsheetText(value: unknown) {
|
||||
const text = value == null ? '' : String(value);
|
||||
return /^[=+@]/.test(text) || /^-[^\d.]/.test(text) ? `'${text}` : text;
|
||||
}
|
||||
|
||||
export function readEmbeddedImages(workbook: ExcelJS.Workbook, worksheet: ExcelJS.Worksheet): EmbeddedImage[] {
|
||||
const getImages = (worksheet as unknown as { getImages?: () => Array<{ imageId: number; range: { tl: { nativeRow?: number; nativeCol?: number; row?: number; col?: number } } }> }).getImages;
|
||||
if (!getImages) return [];
|
||||
return getImages.call(worksheet).flatMap((drawing) => {
|
||||
const image = (workbook as unknown as { getImage?: (id: number) => { buffer?: Buffer; base64?: string; extension?: string } }).getImage?.(drawing.imageId);
|
||||
if (!image) return [];
|
||||
const row = (drawing.range.tl.nativeRow ?? drawing.range.tl.row ?? 0) + 1;
|
||||
const column = (drawing.range.tl.nativeCol ?? drawing.range.tl.col ?? 0) + 1;
|
||||
const buffer = image.buffer ?? (image.base64 ? Buffer.from(image.base64.replace(/^data:[^;]+;base64,/, ''), 'base64') : undefined);
|
||||
return buffer ? [{ row, column, extension: image.extension ?? 'png', buffer }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function suggestMappings(columns: Array<{ sourceColumnIndex: number; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>, reportType: 'signature' | 'drainage'): ImportMapping[] {
|
||||
return columns.flatMap((column, index) => {
|
||||
const normalized = normalizeHeader(`${column.sourceHeaderPath}/${column.sourceHeader}`);
|
||||
const core = reportType === 'signature' ? signatureCoreMapping(normalized) : drainageCoreMapping(normalized);
|
||||
if (!core && !column.imageCount) return [];
|
||||
return [{ sourceHeader: column.sourceHeader, sourceHeaderPath: column.sourceHeaderPath, sourceColumnIndex: column.sourceColumnIndex, targetFieldCode: core?.code ?? normalizeFieldCode(column.sourceHeader), targetKind: core?.kind ?? 'dynamic', fieldType: column.imageCount ? 'image' : 'string', required: Boolean(core?.required), sortOrder: (index + 1) * 10 }];
|
||||
});
|
||||
}
|
||||
|
||||
export function remapProfileColumns(profileColumns: ImportMapping[], sourceColumns: Array<{ sourceColumnIndex: number; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>): ImportMapping[] {
|
||||
const used = new Set<number>();
|
||||
return profileColumns.flatMap((profileColumn) => {
|
||||
const headerPath = normalizeHeader(profileColumn.sourceHeaderPath || profileColumn.sourceHeader);
|
||||
const header = normalizeHeader(profileColumn.sourceHeader);
|
||||
const source = sourceColumns.find((column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeaderPath) === headerPath)
|
||||
?? sourceColumns.find((column) => !used.has(column.sourceColumnIndex) && normalizeHeader(column.sourceHeader) === header);
|
||||
if (!source) return [];
|
||||
used.add(source.sourceColumnIndex);
|
||||
return [{ ...profileColumn, sourceColumnIndex: source.sourceColumnIndex, sourceHeader: source.sourceHeader, sourceHeaderPath: source.sourceHeaderPath, fieldType: source.imageCount > 0 && profileColumn.fieldType === 'string' ? 'image' : profileColumn.fieldType }];
|
||||
});
|
||||
}
|
||||
|
||||
export function signatureCoreMapping(header: string): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined {
|
||||
if (/短信签名|签名名称|签名/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true };
|
||||
if (/用途|签名依据/.test(header)) return { code: 'purpose', kind: 'purpose' };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function drainageCoreMapping(header: string): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined {
|
||||
if (/短信签名|签名名称/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true };
|
||||
if (/站点|网站名称/.test(header)) return { code: 'site_name', kind: 'siteName', required: true };
|
||||
if (/引流地址|网址|url|链接/.test(header)) return { code: 'url', kind: 'url', required: true };
|
||||
if (/备注|说明/.test(header)) return { code: 'remark', kind: 'remark' };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeHeader(value: string) { return value.toLowerCase().replace(/[\s**::()()_-]/g, ''); }
|
||||
|
||||
export function normalizeFieldCode(value: string) { return `import_${value.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_').slice(0, 40) || randomUUID().slice(0, 8)}`; }
|
||||
|
||||
export function clamp(value: number, minimum: number, maximum: number) { return Math.min(maximum, Math.max(minimum, Number.isFinite(value) ? Math.round(value) : minimum)); }
|
||||
|
||||
export function normalizePage(value?: number) { return Math.max(1, Math.floor(Number(value) || 1)); }
|
||||
|
||||
export function normalizePageSize(value?: number) { return Math.min(100, Math.max(1, Math.floor(Number(value) || 20))); }
|
||||
|
||||
export function dateRange(startAt?: string, endAt?: string) {
|
||||
const start = startAt ? new Date(`${startAt}T00:00:00+08:00`) : undefined;
|
||||
const end = endAt ? new Date(`${endAt}T23:59:59.999+08:00`) : undefined;
|
||||
if (start && Number.isNaN(start.getTime())) throw new BadRequestException('开始日期无效');
|
||||
if (end && Number.isNaN(end.getTime())) throw new BadRequestException('结束日期无效');
|
||||
return start || end ? { gte: start, lte: end } : undefined;
|
||||
}
|
||||
|
||||
export function cellText(cell: ExcelJS.Cell) {
|
||||
const value = cell.value;
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(value);
|
||||
if (typeof value === 'string' || typeof value === 'boolean') return String(value).trim();
|
||||
if ('result' in value && value.result !== undefined) return String(value.result ?? '').trim();
|
||||
if ('richText' in value) return value.richText.map((item) => item.text).join('').trim();
|
||||
if ('text' in value) return String(value.text).trim();
|
||||
return cell.text.trim();
|
||||
}
|
||||
|
||||
export function transformValue(value: string, transform?: string) {
|
||||
if (!transform || transform === 'trim') return value.trim();
|
||||
if (transform === 'digits') return value.replace(/\D/g, '');
|
||||
if (transform === 'uppercase') return value.trim().toUpperCase();
|
||||
if (transform === 'lowercase') return value.trim().toLowerCase();
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function mappedCoreValue(mappings: ImportMapping[], values: Record<string, unknown>, kind: ImportMapping['targetKind']) {
|
||||
const mapping = mappings.find((item) => item.targetKind === kind);
|
||||
return mapping ? String(values[mapping.targetFieldCode] ?? '').trim() : '';
|
||||
}
|
||||
|
||||
export function dynamicValues(mappings: ImportMapping[], values: Record<string, unknown>) {
|
||||
return Object.fromEntries(mappings.filter((item) => item.targetKind === 'dynamic').map((item) => [item.targetFieldCode, values[item.targetFieldCode]]));
|
||||
}
|
||||
|
||||
export function jsonRecord(value: unknown): Record<string, unknown> { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
|
||||
|
||||
export function hasValue(value: unknown) { return isFileRef(value) ? Boolean(value.fileObjectId) : value !== null && value !== undefined && String(value).trim().length > 0; }
|
||||
|
||||
export function isFileRef(value: unknown): value is { fileObjectId: string; fileName: string; contentType?: string } { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && typeof (value as Record<string, unknown>).fileObjectId === 'string'; }
|
||||
|
||||
export function resolveExportValue(snapshot: Record<string, unknown>, code: string, name?: string) {
|
||||
const values = jsonRecord(snapshot.values);
|
||||
if (hasValue(values[code])) return values[code];
|
||||
const signature = jsonRecord(snapshot.signature);
|
||||
const drainage = jsonRecord(snapshot.drainage);
|
||||
const aliases: Record<string, unknown> = {
|
||||
signature_name: signature.name, sign_name: signature.name, signatureName: signature.name,
|
||||
purpose: signature.purpose, enterprise_name: signature.tenantName, company_name: signature.tenantName,
|
||||
application_name: signature.applicationName, site_name: drainage.siteName, url: drainage.url, remark: drainage.remark,
|
||||
};
|
||||
if (hasValue(aliases[code])) return aliases[code];
|
||||
const semantic = normalizeHeader(`${code}/${name ?? ''}`);
|
||||
if (/短信签名|签名名称|signaturename|sms(?:signature|sign)|^sign$/.test(semantic)) return signature.name;
|
||||
if (/签名用途|签名依据|purpose/.test(semantic)) return signature.purpose;
|
||||
if (/企业名称|公司名称|enterprisename|companyname/.test(semantic)) return signature.tenantName;
|
||||
if (/应用名称|applicationname|appname/.test(semantic)) return signature.applicationName;
|
||||
if (/站点名称|网站名称|sitename/.test(semantic)) return drainage.siteName;
|
||||
if (/引流地址|网址|链接|url/.test(semantic)) return drainage.url;
|
||||
if (/备注|说明|remark/.test(semantic)) return drainage.remark;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function applyExportTransform(value: unknown, transform?: string | null) {
|
||||
const text = value === null || value === undefined ? '' : String(value);
|
||||
return transformValue(text, transform ?? undefined);
|
||||
}
|
||||
|
||||
export function styleHeader(row: ExcelJS.Row) {
|
||||
row.height = 28;
|
||||
row.eachCell((cell) => {
|
||||
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2563EB' } };
|
||||
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
|
||||
cell.border = { bottom: { style: 'thin', color: { argb: 'FFD1D5DB' } } };
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeImageExtension(value: string) { const normalized = value.toLowerCase().replace(/^\./, ''); return normalized === 'jpg' ? 'jpeg' : normalized; }
|
||||
|
||||
export function imageContentType(extension: string) { const normalized = normalizeImageExtension(extension); return normalized === 'jpeg' ? 'image/jpeg' : normalized === 'gif' ? 'image/gif' : 'image/png'; }
|
||||
|
||||
export function safeFileName(value: string) { return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 80) || '通道报备'; }
|
||||
|
||||
export function normalizeBatchIdempotencyKey(value?: string) {
|
||||
const key = value?.trim();
|
||||
if (!key || key.length > 128 || !/^[A-Za-z0-9._:-]{8,128}$/.test(key)) throw new BadRequestException({ code: 'IDEMPOTENCY_KEY_INVALID', message: 'idempotencyKey 必填且长度为8至128位' });
|
||||
return key;
|
||||
}
|
||||
|
||||
export function jsonStringArray(value: unknown) {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
||||
}
|
||||
|
||||
export function jsonSafe(value: unknown): Prisma.InputJsonValue {
|
||||
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { SendChainService } from '../send-chain/send-chain.service';
|
||||
@@ -8,11 +8,20 @@ import {
|
||||
ReviewSmsTaskDto,
|
||||
RiskReviewService,
|
||||
} from './risk-review.service';
|
||||
import {
|
||||
CreatePhoneFrequencyWhitelistDto,
|
||||
PhoneFrequencyService,
|
||||
UpdatePhoneFrequencyWhitelistDto,
|
||||
} from './phone-frequency.service';
|
||||
|
||||
@ApiTags('risk-review')
|
||||
@Controller('admin/risk-review')
|
||||
export class AdminRiskReviewController {
|
||||
constructor(private readonly riskReview: RiskReviewService, private readonly sendChain: SendChainService) {}
|
||||
constructor(
|
||||
private readonly riskReview: RiskReviewService,
|
||||
private readonly sendChain: SendChainService,
|
||||
private readonly phoneFrequency: PhoneFrequencyService,
|
||||
) {}
|
||||
|
||||
@Get('rules')
|
||||
listRules(@Query('applicationId') applicationId?: string) {
|
||||
@@ -34,6 +43,85 @@ export class AdminRiskReviewController {
|
||||
return this.riskReview.listHits(tenantId, taskId);
|
||||
}
|
||||
|
||||
@Get('phone-frequency-hits')
|
||||
listPhoneFrequencyHits(
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('phoneNumber') phoneNumber?: string,
|
||||
@Query('status') status?: 'active' | 'expired' | 'released',
|
||||
@Query('createdAtFrom') createdAtFrom?: string,
|
||||
@Query('createdAtTo') createdAtTo?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.phoneFrequency.listHits({
|
||||
tenantId,
|
||||
applicationId,
|
||||
phoneNumber,
|
||||
status,
|
||||
createdAtFrom,
|
||||
createdAtTo,
|
||||
page: Number(page ?? 1),
|
||||
pageSize: Number(pageSize ?? 20),
|
||||
});
|
||||
}
|
||||
|
||||
@Post('phone-frequency-hits/:id/release')
|
||||
releasePhoneFrequencyHit(
|
||||
@Param('id') hitId: string,
|
||||
@Body() body: { reason?: string },
|
||||
@CurrentSessionUserId() reviewerId?: string,
|
||||
) {
|
||||
return this.phoneFrequency.releaseHit(hitId, reviewerId, body.reason);
|
||||
}
|
||||
|
||||
@Get('phone-frequency-whitelist')
|
||||
listPhoneFrequencyWhitelist(
|
||||
@Query('phoneNumber') phoneNumber?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('status') status?: 'active' | 'inactive' | 'deleted',
|
||||
@Query('updatedAtFrom') updatedAtFrom?: string,
|
||||
@Query('updatedAtTo') updatedAtTo?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.phoneFrequency.listWhitelist({
|
||||
phoneNumber,
|
||||
keyword,
|
||||
status,
|
||||
updatedAtFrom,
|
||||
updatedAtTo,
|
||||
page: Number(page ?? 1),
|
||||
pageSize: Number(pageSize ?? 20),
|
||||
});
|
||||
}
|
||||
|
||||
@Post('phone-frequency-whitelist')
|
||||
createPhoneFrequencyWhitelist(
|
||||
@Body() body: CreatePhoneFrequencyWhitelistDto,
|
||||
@CurrentSessionUserId() operatorId?: string,
|
||||
) {
|
||||
return this.phoneFrequency.createWhitelist(body, operatorId);
|
||||
}
|
||||
|
||||
@Put('phone-frequency-whitelist/:id')
|
||||
updatePhoneFrequencyWhitelist(
|
||||
@Param('id') id: string,
|
||||
@Body() body: UpdatePhoneFrequencyWhitelistDto,
|
||||
@CurrentSessionUserId() operatorId?: string,
|
||||
) {
|
||||
return this.phoneFrequency.updateWhitelist(id, body, operatorId);
|
||||
}
|
||||
|
||||
@Delete('phone-frequency-whitelist/:id')
|
||||
deletePhoneFrequencyWhitelist(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { reason?: string },
|
||||
@CurrentSessionUserId() operatorId?: string,
|
||||
) {
|
||||
return this.phoneFrequency.deleteWhitelist(id, operatorId, body.reason);
|
||||
}
|
||||
|
||||
@Get('tasks')
|
||||
listTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string) {
|
||||
return this.riskReview.listTasks(tenantId, status);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { PhoneFrequencyService, fixedShanghaiWindow } from './phone-frequency.service';
|
||||
|
||||
describe('PhoneFrequencyService', () => {
|
||||
it('aligns five-minute cycles and natural days in Asia/Shanghai', () => {
|
||||
const requestedAt = new Date('2026-07-30T16:07:42.000Z');
|
||||
|
||||
expect(fixedShanghaiWindow(requestedAt, 300)).toEqual({
|
||||
startAt: new Date('2026-07-30T16:05:00.000Z'),
|
||||
endAt: new Date('2026-07-30T16:10:00.000Z'),
|
||||
});
|
||||
expect(fixedShanghaiWindow(requestedAt, 86400)).toEqual({
|
||||
startAt: new Date('2026-07-30T16:00:00.000Z'),
|
||||
endAt: new Date('2026-07-31T16:00:00.000Z'),
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a persistent hit and rejects the threshold-exceeding phone only', async () => {
|
||||
const tx = {
|
||||
$queryRaw: jest.fn()
|
||||
.mockResolvedValueOnce([{
|
||||
id: 'state-24h',
|
||||
phoneNumber: '13800000001',
|
||||
count: 2,
|
||||
generation: 0,
|
||||
activeHitId: null,
|
||||
windowStartedAt: new Date('2026-07-29T16:00:00.000Z'),
|
||||
windowEndsAt: new Date('2026-07-30T16:00:00.000Z'),
|
||||
}])
|
||||
.mockResolvedValueOnce([{
|
||||
id: 'state-5m',
|
||||
phoneNumber: '13800000001',
|
||||
count: 6,
|
||||
generation: 0,
|
||||
activeHitId: null,
|
||||
windowStartedAt: new Date('2026-07-30T01:00:00.000Z'),
|
||||
windowEndsAt: new Date('2026-07-30T01:05:00.000Z'),
|
||||
}]),
|
||||
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||
phoneFrequencyHit: {
|
||||
createMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
phoneFrequencyWhitelist: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
riskRule: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'rule-24h',
|
||||
applicationId: null,
|
||||
code: 'PHONE_FREQUENCY_24H',
|
||||
name: '单号码24小时发送频次',
|
||||
thresholdValue: 10,
|
||||
action: 'block',
|
||||
priority: 40,
|
||||
config: { periodSeconds: 86400 },
|
||||
},
|
||||
{
|
||||
id: 'rule-5m',
|
||||
applicationId: null,
|
||||
code: 'PHONE_FREQUENCY_5M',
|
||||
name: '单号码5分钟发送频次',
|
||||
thresholdValue: 5,
|
||||
action: 'block',
|
||||
priority: 50,
|
||||
config: { periodSeconds: 300 },
|
||||
},
|
||||
]),
|
||||
},
|
||||
$transaction: jest.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||
};
|
||||
const riskReview = { ensureDefaultRules: jest.fn().mockResolvedValue(undefined) };
|
||||
const service = new PhoneFrequencyService(prisma as never, riskReview as never);
|
||||
|
||||
const rejected = await service.reserve(
|
||||
'tenant-1',
|
||||
'application-1',
|
||||
['13800000001'],
|
||||
'client',
|
||||
new Date('2026-07-30T01:03:00.000Z'),
|
||||
);
|
||||
|
||||
expect(rejected.get('13800000001')).toEqual(expect.objectContaining({
|
||||
code: 'PHONE_FREQUENCY_LIMIT',
|
||||
reason: expect.stringContaining('当前第6条'),
|
||||
}));
|
||||
expect(tx.phoneFrequencyHit.createMany).toHaveBeenCalledWith({
|
||||
data: [expect.objectContaining({
|
||||
applicationId: 'application-1',
|
||||
phoneNumber: '13800000001',
|
||||
ruleCode: 'PHONE_FREQUENCY_5M',
|
||||
thresholdValue: 5,
|
||||
actualValue: 6,
|
||||
})],
|
||||
});
|
||||
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bypasses both frequency rules for active platform-level whitelist phones', async () => {
|
||||
const tx = {
|
||||
phoneFrequencyWhitelist: {
|
||||
findMany: jest.fn().mockResolvedValue([{ phoneNumber: '13800000001' }]),
|
||||
},
|
||||
$queryRaw: jest.fn(),
|
||||
};
|
||||
const prisma = {
|
||||
riskRule: {
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
id: 'rule-5m',
|
||||
applicationId: null,
|
||||
code: 'PHONE_FREQUENCY_5M',
|
||||
name: '单号码5分钟发送频次',
|
||||
thresholdValue: 5,
|
||||
action: 'block',
|
||||
priority: 50,
|
||||
config: { periodSeconds: 300 },
|
||||
}]),
|
||||
},
|
||||
$transaction: jest.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||
};
|
||||
const riskReview = { ensureDefaultRules: jest.fn().mockResolvedValue(undefined) };
|
||||
const service = new PhoneFrequencyService(prisma as never, riskReview as never);
|
||||
|
||||
const rejected = await service.reserve(
|
||||
'tenant-1',
|
||||
'application-1',
|
||||
['13800000001'],
|
||||
'client',
|
||||
new Date('2026-07-30T01:03:00.000Z'),
|
||||
);
|
||||
|
||||
expect(rejected.size).toBe(0);
|
||||
expect(tx.phoneFrequencyWhitelist.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
phoneNumber: { in: ['13800000001'] },
|
||||
status: 'active',
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { phoneNumber: true },
|
||||
});
|
||||
expect(tx.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,690 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RiskReviewService } from './risk-review.service';
|
||||
|
||||
const PHONE_FREQUENCY_RULE_CODES = ['PHONE_FREQUENCY_24H', 'PHONE_FREQUENCY_5M'] as const;
|
||||
const FREQUENCY_WRITE_CHUNK_SIZE = 1000;
|
||||
|
||||
type FrequencyRule = {
|
||||
id: string;
|
||||
applicationId: string | null;
|
||||
code: string;
|
||||
name: string;
|
||||
thresholdValue: number;
|
||||
action: string;
|
||||
priority: number;
|
||||
config: unknown;
|
||||
};
|
||||
|
||||
type FrequencyStateRow = {
|
||||
id: string;
|
||||
phoneNumber: string;
|
||||
count: number;
|
||||
generation: number;
|
||||
activeHitId: string | null;
|
||||
windowStartedAt: Date;
|
||||
windowEndsAt: Date;
|
||||
};
|
||||
|
||||
export interface PhoneFrequencyHitQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
phoneNumber?: string;
|
||||
status?: 'active' | 'expired' | 'released';
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface PhoneFrequencyWhitelistQuery {
|
||||
phoneNumber?: string;
|
||||
keyword?: string;
|
||||
status?: 'active' | 'inactive' | 'deleted';
|
||||
updatedAtFrom?: string;
|
||||
updatedAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface CreatePhoneFrequencyWhitelistDto {
|
||||
phoneNumber: string;
|
||||
reason: string;
|
||||
remark?: string;
|
||||
status?: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
export type UpdatePhoneFrequencyWhitelistDto = Partial<CreatePhoneFrequencyWhitelistDto>;
|
||||
|
||||
export interface PhoneFrequencyRejection {
|
||||
code: 'PHONE_FREQUENCY_LIMIT';
|
||||
reason: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PhoneFrequencyService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly riskReview: RiskReviewService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 为一次初始业务短信提交占用频次。调用方只传尚未被格式或黑名单拒绝的号码;
|
||||
* 长短信分片、通道重试和补发不会进入这里,因此同一业务号码只计一次。
|
||||
*/
|
||||
async reserve(
|
||||
tenantId: string,
|
||||
applicationId: string | undefined,
|
||||
phones: string[],
|
||||
sourceType?: string,
|
||||
requestedAt = new Date(),
|
||||
) {
|
||||
if (!applicationId) return new Map<string, PhoneFrequencyRejection>();
|
||||
const normalizedPhones = [...new Set(phones.map((phone) => phone.trim()).filter(Boolean))].sort();
|
||||
if (normalizedPhones.length === 0) return new Map<string, PhoneFrequencyRejection>();
|
||||
|
||||
await this.riskReview.ensureDefaultRules();
|
||||
const rules = await this.effectiveRules(applicationId);
|
||||
if (rules.length === 0) return new Map<string, PhoneFrequencyRejection>();
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const rejected = new Map<string, PhoneFrequencyRejection>();
|
||||
// 平台级白名单只截断号码频控链路;调用 reserve 之前已执行的格式、黑名单等校验不受影响。
|
||||
const whitelistedPhones = await this.findActiveWhitelistedPhones(tx, normalizedPhones);
|
||||
const controlledPhones = normalizedPhones.filter((phone) => !whitelistedPhones.has(phone));
|
||||
if (controlledPhones.length === 0) return rejected;
|
||||
for (const rule of rules) {
|
||||
const window = fixedShanghaiWindow(requestedAt, readPeriodSeconds(rule));
|
||||
// 分块限制 SQL 参数数量,但两条规则的全部分块仍在同一事务中提交或回滚。
|
||||
for (const phoneChunk of chunks(controlledPhones, FREQUENCY_WRITE_CHUNK_SIZE)) {
|
||||
const states = await this.upsertStates(tx, {
|
||||
tenantId,
|
||||
applicationId,
|
||||
phones: phoneChunk,
|
||||
rule,
|
||||
window,
|
||||
});
|
||||
const newTriggers = states.filter((state) => state.activeHitId === null && state.count > rule.thresholdValue);
|
||||
const hitByStateId = new Map<string, string>();
|
||||
if (newTriggers.length > 0) {
|
||||
const hitRows = newTriggers.map((state) => {
|
||||
const hitId = randomUUID();
|
||||
hitByStateId.set(state.id, hitId);
|
||||
return {
|
||||
id: hitId,
|
||||
tenantId,
|
||||
applicationId,
|
||||
ruleId: rule.id,
|
||||
ruleCode: rule.code,
|
||||
ruleName: rule.name,
|
||||
phoneNumber: state.phoneNumber,
|
||||
thresholdValue: Math.floor(rule.thresholdValue),
|
||||
actualValue: state.count,
|
||||
windowStartedAt: state.windowStartedAt,
|
||||
windowEndsAt: state.windowEndsAt,
|
||||
generation: state.generation,
|
||||
action: 'block',
|
||||
sourceType,
|
||||
};
|
||||
});
|
||||
await tx.phoneFrequencyHit.createMany({ data: hitRows });
|
||||
await this.attachActiveHits(tx, hitByStateId);
|
||||
}
|
||||
|
||||
for (const state of states) {
|
||||
if (state.activeHitId === null && state.count <= rule.thresholdValue) continue;
|
||||
const reason = `${rule.name}命中:本周期最多${Math.floor(rule.thresholdValue)}条,当前第${state.count}条,周期${formatWindow(state.windowStartedAt, state.windowEndsAt)}`;
|
||||
const existing = rejected.get(state.phoneNumber);
|
||||
rejected.set(state.phoneNumber, {
|
||||
code: 'PHONE_FREQUENCY_LIMIT',
|
||||
reason: existing ? `${existing.reason};${reason}` : reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return rejected;
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
|
||||
}
|
||||
|
||||
async listHits(query: PhoneFrequencyHitQuery) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 20)));
|
||||
const now = new Date();
|
||||
if (query.status && !['active', 'expired', 'released'].includes(query.status)) {
|
||||
throw new BadRequestException('号码频次触发记录状态无效');
|
||||
}
|
||||
const createdAtFrom = parseOptionalDate(query.createdAtFrom, '开始时间');
|
||||
const createdAtTo = parseOptionalDate(query.createdAtTo, '结束时间');
|
||||
if (createdAtFrom && createdAtTo && createdAtFrom > createdAtTo) {
|
||||
throw new BadRequestException('开始时间不能晚于结束时间');
|
||||
}
|
||||
const where: Prisma.PhoneFrequencyHitWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
phoneNumber: query.phoneNumber?.trim() ? { contains: query.phoneNumber.trim() } : undefined,
|
||||
createdAt: createdAtFrom || createdAtTo ? {
|
||||
gte: createdAtFrom,
|
||||
lte: createdAtTo,
|
||||
} : undefined,
|
||||
...(query.status === 'active' ? { releasedAt: null, windowEndsAt: { gt: now } } : {}),
|
||||
...(query.status === 'expired' ? { releasedAt: null, windowEndsAt: { lte: now } } : {}),
|
||||
...(query.status === 'released' ? { releasedAt: { not: null } } : {}),
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.phoneFrequencyHit.findMany({
|
||||
where,
|
||||
include: {
|
||||
tenant: { select: { id: true, name: true } },
|
||||
application: { select: { id: true, name: true } },
|
||||
releasedBy: { select: { id: true, username: true, displayName: true } },
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.phoneFrequencyHit.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async releaseHit(hitId: string, reviewerId: string | undefined, reason?: string) {
|
||||
const normalizedReason = reason?.trim();
|
||||
if (!reviewerId) throw new BadRequestException('解除操作需要有效的运营登录会话');
|
||||
if (!normalizedReason) throw new BadRequestException('解除并清零时必须填写原因');
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// 与并发 reserve 串行:解除先锁住当前活跃状态,再同时清零计数和断开命中关联。
|
||||
const [lockedState] = await tx.$queryRaw<Array<{ id: string }>>(Prisma.sql`
|
||||
SELECT state.id
|
||||
FROM "PhoneFrequencyState" state
|
||||
WHERE state."activeHitId" = ${hitId}
|
||||
FOR UPDATE
|
||||
`);
|
||||
const hit = await tx.phoneFrequencyHit.findUnique({ where: { id: hitId } });
|
||||
if (!hit) throw new NotFoundException('号码频次触发记录不存在');
|
||||
if (hit.releasedAt) {
|
||||
return tx.phoneFrequencyHit.findUnique({
|
||||
where: { id: hitId },
|
||||
include: { tenant: true, application: true, releasedBy: true },
|
||||
});
|
||||
}
|
||||
const releasedAt = new Date();
|
||||
if (lockedState) {
|
||||
await tx.phoneFrequencyState.update({
|
||||
where: { id: lockedState.id },
|
||||
data: {
|
||||
count: 0,
|
||||
generation: { increment: 1 },
|
||||
activeHitId: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
const released = await tx.phoneFrequencyHit.update({
|
||||
where: { id: hitId },
|
||||
data: {
|
||||
releasedAt,
|
||||
releasedById: reviewerId,
|
||||
releaseReason: normalizedReason,
|
||||
},
|
||||
include: {
|
||||
tenant: { select: { id: true, name: true } },
|
||||
application: { select: { id: true, name: true } },
|
||||
releasedBy: { select: { id: true, username: true, displayName: true } },
|
||||
},
|
||||
});
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
tenantId: hit.tenantId,
|
||||
userId: reviewerId,
|
||||
action: 'phone_frequency.release',
|
||||
resource: 'phone_frequency_hit',
|
||||
resourceId: hitId,
|
||||
detail: {
|
||||
applicationId: hit.applicationId,
|
||||
phoneNumber: hit.phoneNumber,
|
||||
ruleCode: hit.ruleCode,
|
||||
countReset: Boolean(lockedState),
|
||||
reason: normalizedReason,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return released;
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
|
||||
}
|
||||
|
||||
async listWhitelist(query: PhoneFrequencyWhitelistQuery) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 20)));
|
||||
if (query.status && !['active', 'inactive', 'deleted'].includes(query.status)) {
|
||||
throw new BadRequestException('号码频控白名单状态无效');
|
||||
}
|
||||
const updatedAtFrom = parseOptionalDate(query.updatedAtFrom, '开始时间');
|
||||
const updatedAtTo = parseOptionalDate(query.updatedAtTo, '结束时间');
|
||||
if (updatedAtFrom && updatedAtTo && updatedAtFrom > updatedAtTo) {
|
||||
throw new BadRequestException('开始时间不能晚于结束时间');
|
||||
}
|
||||
const keyword = query.keyword?.trim();
|
||||
const phoneNumber = query.phoneNumber?.trim();
|
||||
const where: Prisma.PhoneFrequencyWhitelistWhereInput = {
|
||||
status: query.status ?? { not: 'deleted' },
|
||||
phoneNumber: phoneNumber ? { contains: phoneNumber } : undefined,
|
||||
updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined,
|
||||
OR: keyword ? [
|
||||
{ phoneNumber: { contains: keyword } },
|
||||
{ reason: { contains: keyword, mode: 'insensitive' } },
|
||||
{ remark: { contains: keyword, mode: 'insensitive' } },
|
||||
] : undefined,
|
||||
};
|
||||
const include = {
|
||||
createdBy: { select: { id: true, username: true, displayName: true } },
|
||||
updatedBy: { select: { id: true, username: true, displayName: true } },
|
||||
} as const;
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.phoneFrequencyWhitelist.findMany({
|
||||
where,
|
||||
include,
|
||||
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.phoneFrequencyWhitelist.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async createWhitelist(data: CreatePhoneFrequencyWhitelistDto, operatorId: string | undefined) {
|
||||
const normalized = normalizeWhitelistInput(data, false);
|
||||
if (!operatorId) throw new BadRequestException('白名单操作需要有效的运营登录会话');
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.phoneFrequencyWhitelist.findUnique({
|
||||
where: { phoneNumber: normalized.phoneNumber },
|
||||
});
|
||||
if (existing && existing.status !== 'deleted') {
|
||||
throw new BadRequestException('该号码已存在于号码频控白名单');
|
||||
}
|
||||
const entry = existing
|
||||
? await tx.phoneFrequencyWhitelist.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...normalized,
|
||||
deletedAt: null,
|
||||
updatedById: operatorId,
|
||||
},
|
||||
})
|
||||
: await tx.phoneFrequencyWhitelist.create({
|
||||
data: {
|
||||
...normalized,
|
||||
createdById: operatorId,
|
||||
updatedById: operatorId,
|
||||
},
|
||||
});
|
||||
const reset = normalized.status === 'active'
|
||||
? await this.resetFrequencyStates(tx, [normalized.phoneNumber], operatorId, '号码加入平台级频控白名单')
|
||||
: { stateCount: 0, hitCount: 0 };
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
userId: operatorId,
|
||||
action: existing ? 'phone_frequency_whitelist.restore' : 'phone_frequency_whitelist.create',
|
||||
resource: 'phone_frequency_whitelist',
|
||||
resourceId: entry.id,
|
||||
detail: {
|
||||
after: normalized,
|
||||
reset,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return tx.phoneFrequencyWhitelist.findUnique({
|
||||
where: { id: entry.id },
|
||||
include: whitelistUserInclude,
|
||||
});
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
|
||||
}
|
||||
|
||||
async updateWhitelist(
|
||||
id: string,
|
||||
data: UpdatePhoneFrequencyWhitelistDto,
|
||||
operatorId: string | undefined,
|
||||
) {
|
||||
if (!operatorId) throw new BadRequestException('白名单操作需要有效的运营登录会话');
|
||||
if (!data || Object.keys(data).length === 0) throw new BadRequestException('没有需要修改的白名单字段');
|
||||
const normalized = normalizeWhitelistInput(data, true);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw(Prisma.sql`
|
||||
SELECT id FROM "PhoneFrequencyWhitelist" WHERE id = ${id} FOR UPDATE
|
||||
`);
|
||||
const existing = await tx.phoneFrequencyWhitelist.findUnique({ where: { id } });
|
||||
if (!existing || existing.status === 'deleted') {
|
||||
throw new NotFoundException('号码频控白名单记录不存在');
|
||||
}
|
||||
const nextPhone = normalized.phoneNumber ?? existing.phoneNumber;
|
||||
const nextStatus = normalized.status ?? existing.status;
|
||||
if (nextPhone !== existing.phoneNumber) {
|
||||
const duplicate = await tx.phoneFrequencyWhitelist.findUnique({ where: { phoneNumber: nextPhone } });
|
||||
if (duplicate && duplicate.id !== id) {
|
||||
throw new BadRequestException(
|
||||
duplicate.status === 'deleted'
|
||||
? '该号码存在已删除的白名单历史记录,请直接重新新增该号码以恢复记录'
|
||||
: '该号码已存在于号码频控白名单',
|
||||
);
|
||||
}
|
||||
}
|
||||
const shouldReset = nextPhone !== existing.phoneNumber || nextStatus !== existing.status;
|
||||
const reset = shouldReset
|
||||
? await this.resetFrequencyStates(
|
||||
tx,
|
||||
[existing.phoneNumber, nextPhone],
|
||||
operatorId,
|
||||
'平台级频控白名单号码或状态发生变更',
|
||||
)
|
||||
: { stateCount: 0, hitCount: 0 };
|
||||
const entry = await tx.phoneFrequencyWhitelist.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...normalized,
|
||||
updatedById: operatorId,
|
||||
},
|
||||
include: whitelistUserInclude,
|
||||
});
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
userId: operatorId,
|
||||
action: 'phone_frequency_whitelist.update',
|
||||
resource: 'phone_frequency_whitelist',
|
||||
resourceId: id,
|
||||
detail: {
|
||||
before: whitelistAuditValue(existing),
|
||||
after: whitelistAuditValue(entry),
|
||||
reset,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return entry;
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
|
||||
}
|
||||
|
||||
async deleteWhitelist(id: string, operatorId: string | undefined, reason?: string) {
|
||||
if (!operatorId) throw new BadRequestException('白名单操作需要有效的运营登录会话');
|
||||
const normalizedReason = reason?.trim();
|
||||
if (!normalizedReason) throw new BadRequestException('删除白名单时必须填写原因');
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw(Prisma.sql`
|
||||
SELECT id FROM "PhoneFrequencyWhitelist" WHERE id = ${id} FOR UPDATE
|
||||
`);
|
||||
const existing = await tx.phoneFrequencyWhitelist.findUnique({ where: { id } });
|
||||
if (!existing || existing.status === 'deleted') {
|
||||
throw new NotFoundException('号码频控白名单记录不存在');
|
||||
}
|
||||
const reset = await this.resetFrequencyStates(
|
||||
tx,
|
||||
[existing.phoneNumber],
|
||||
operatorId,
|
||||
`删除平台级频控白名单:${normalizedReason}`,
|
||||
);
|
||||
const entry = await tx.phoneFrequencyWhitelist.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'deleted',
|
||||
deletedAt: new Date(),
|
||||
updatedById: operatorId,
|
||||
},
|
||||
include: whitelistUserInclude,
|
||||
});
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
userId: operatorId,
|
||||
action: 'phone_frequency_whitelist.delete',
|
||||
resource: 'phone_frequency_whitelist',
|
||||
resourceId: id,
|
||||
detail: {
|
||||
phoneNumber: existing.phoneNumber,
|
||||
reason: normalizedReason,
|
||||
reset,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return entry;
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
|
||||
}
|
||||
|
||||
private async findActiveWhitelistedPhones(tx: Prisma.TransactionClient, phones: string[]) {
|
||||
const result = new Set<string>();
|
||||
for (const phoneChunk of chunks(phones, FREQUENCY_WRITE_CHUNK_SIZE)) {
|
||||
const rows = await tx.phoneFrequencyWhitelist.findMany({
|
||||
where: { phoneNumber: { in: phoneChunk }, status: 'active', deletedAt: null },
|
||||
select: { phoneNumber: true },
|
||||
});
|
||||
for (const row of rows) result.add(row.phoneNumber);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async resetFrequencyStates(
|
||||
tx: Prisma.TransactionClient,
|
||||
phones: string[],
|
||||
operatorId: string,
|
||||
releaseReason: string,
|
||||
) {
|
||||
// 白名单状态变化按号码跨应用清零;历史命中保留,只解除当前仍与状态关联的活跃命中。
|
||||
const normalizedPhones = [...new Set(phones)].sort();
|
||||
const states = await tx.phoneFrequencyState.findMany({
|
||||
where: { phoneNumber: { in: normalizedPhones } },
|
||||
select: { id: true, activeHitId: true },
|
||||
});
|
||||
const activeHitIds = states.flatMap((state) => state.activeHitId ? [state.activeHitId] : []);
|
||||
const releasedAt = new Date();
|
||||
const released = activeHitIds.length > 0
|
||||
? await tx.phoneFrequencyHit.updateMany({
|
||||
where: { id: { in: activeHitIds }, releasedAt: null },
|
||||
data: { releasedAt, releasedById: operatorId, releaseReason },
|
||||
})
|
||||
: { count: 0 };
|
||||
const reset = states.length > 0
|
||||
? await tx.phoneFrequencyState.updateMany({
|
||||
where: { id: { in: states.map((state) => state.id) } },
|
||||
data: { count: 0, generation: { increment: 1 }, activeHitId: null },
|
||||
})
|
||||
: { count: 0 };
|
||||
return { stateCount: reset.count, hitCount: released.count };
|
||||
}
|
||||
|
||||
private async effectiveRules(applicationId: string): Promise<FrequencyRule[]> {
|
||||
const rules = await this.prisma.riskRule.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
code: { in: [...PHONE_FREQUENCY_RULE_CODES] },
|
||||
OR: [{ applicationId: null }, { applicationId }],
|
||||
},
|
||||
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
const byCode = new Map<string, FrequencyRule>();
|
||||
for (const rule of rules) {
|
||||
if (rule.applicationId || !byCode.has(rule.code)) byCode.set(rule.code, rule);
|
||||
}
|
||||
return [...byCode.values()].sort((left, right) => left.priority - right.priority);
|
||||
}
|
||||
|
||||
private upsertStates(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
phones: string[];
|
||||
rule: FrequencyRule;
|
||||
window: { startAt: Date; endAt: Date };
|
||||
},
|
||||
) {
|
||||
const values = input.phones.map((phone) => Prisma.sql`(${randomUUID()}, ${phone})`);
|
||||
// ON CONFLICT 对同一应用、规则、号码取得行锁,保证并发越过阈值时只有一个首次命中者。
|
||||
return tx.$queryRaw<FrequencyStateRow[]>(Prisma.sql`
|
||||
WITH input("id", "phoneNumber") AS (
|
||||
VALUES ${Prisma.join(values)}
|
||||
)
|
||||
INSERT INTO "PhoneFrequencyState" (
|
||||
"id", "tenantId", "applicationId", "ruleId", "ruleCode", "phoneNumber",
|
||||
"windowStartedAt", "windowEndsAt", "count", "generation", "createdAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
input.id, ${input.tenantId}, ${input.applicationId}, ${input.rule.id}, ${input.rule.code},
|
||||
input."phoneNumber", ${input.window.startAt}, ${input.window.endAt}, 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM input
|
||||
ON CONFLICT ("applicationId", "ruleCode", "phoneNumber")
|
||||
DO UPDATE SET
|
||||
"ruleId" = EXCLUDED."ruleId",
|
||||
"windowStartedAt" = EXCLUDED."windowStartedAt",
|
||||
"windowEndsAt" = EXCLUDED."windowEndsAt",
|
||||
"count" = CASE
|
||||
WHEN "PhoneFrequencyState"."windowStartedAt" <> EXCLUDED."windowStartedAt" THEN 1
|
||||
WHEN "PhoneFrequencyState"."activeHitId" IS NOT NULL THEN "PhoneFrequencyState"."count"
|
||||
ELSE "PhoneFrequencyState"."count" + 1
|
||||
END,
|
||||
"generation" = CASE
|
||||
WHEN "PhoneFrequencyState"."windowStartedAt" <> EXCLUDED."windowStartedAt" THEN 0
|
||||
ELSE "PhoneFrequencyState"."generation"
|
||||
END,
|
||||
"activeHitId" = CASE
|
||||
WHEN "PhoneFrequencyState"."windowStartedAt" <> EXCLUDED."windowStartedAt" THEN NULL
|
||||
ELSE "PhoneFrequencyState"."activeHitId"
|
||||
END,
|
||||
"updatedAt" = CURRENT_TIMESTAMP
|
||||
RETURNING
|
||||
"id", "phoneNumber", "count", "generation", "activeHitId", "windowStartedAt", "windowEndsAt"
|
||||
`);
|
||||
}
|
||||
|
||||
private async attachActiveHits(tx: Prisma.TransactionClient, hitByStateId: Map<string, string>) {
|
||||
if (hitByStateId.size === 0) return;
|
||||
const values = [...hitByStateId].map(([stateId, hitId]) => Prisma.sql`(${stateId}, ${hitId})`);
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
UPDATE "PhoneFrequencyState" state
|
||||
SET "activeHitId" = updates."hitId", "updatedAt" = CURRENT_TIMESTAMP
|
||||
FROM (VALUES ${Prisma.join(values)}) AS updates("stateId", "hitId")
|
||||
WHERE state.id = updates."stateId"
|
||||
AND state."activeHitId" IS NULL
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
const whitelistUserInclude = {
|
||||
createdBy: { select: { id: true, username: true, displayName: true } },
|
||||
updatedBy: { select: { id: true, username: true, displayName: true } },
|
||||
} as const;
|
||||
|
||||
function normalizeWhitelistInput(
|
||||
input: CreatePhoneFrequencyWhitelistDto | UpdatePhoneFrequencyWhitelistDto,
|
||||
partial: boolean,
|
||||
) {
|
||||
const result: {
|
||||
phoneNumber?: string;
|
||||
reason?: string;
|
||||
remark?: string | null;
|
||||
status?: 'active' | 'inactive';
|
||||
} = {};
|
||||
if (!partial || input.phoneNumber !== undefined) {
|
||||
const phoneNumber = normalizeMainlandPhone(input.phoneNumber);
|
||||
if (!phoneNumber) throw new BadRequestException('请输入有效的中国大陆11位手机号码');
|
||||
result.phoneNumber = phoneNumber;
|
||||
}
|
||||
if (!partial || input.reason !== undefined) {
|
||||
const reason = input.reason?.trim();
|
||||
if (!reason) throw new BadRequestException('白名单用途说明不能为空');
|
||||
if (reason.length > 200) throw new BadRequestException('白名单用途说明不能超过200个字符');
|
||||
result.reason = reason;
|
||||
}
|
||||
if (input.remark !== undefined) {
|
||||
const remark = input.remark?.trim() ?? '';
|
||||
if (remark.length > 500) throw new BadRequestException('白名单备注不能超过500个字符');
|
||||
result.remark = remark || null;
|
||||
}
|
||||
const status = input.status ?? (partial ? undefined : 'active');
|
||||
if (status !== undefined && !['active', 'inactive'].includes(status)) {
|
||||
throw new BadRequestException('白名单状态无效');
|
||||
}
|
||||
if (status) result.status = status;
|
||||
return result as {
|
||||
phoneNumber: string;
|
||||
reason: string;
|
||||
remark?: string | null;
|
||||
status: 'active' | 'inactive';
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMainlandPhone(value: string | undefined) {
|
||||
const compact = value?.trim().replace(/[\s-]/g, '') ?? '';
|
||||
const withoutCountryCode = compact.startsWith('+86')
|
||||
? compact.slice(3)
|
||||
: compact.startsWith('86') && compact.length === 13
|
||||
? compact.slice(2)
|
||||
: compact;
|
||||
return /^1\d{10}$/.test(withoutCountryCode) ? withoutCountryCode : undefined;
|
||||
}
|
||||
|
||||
function whitelistAuditValue(entry: {
|
||||
phoneNumber: string;
|
||||
status: string;
|
||||
reason: string;
|
||||
remark: string | null;
|
||||
deletedAt: Date | null;
|
||||
}) {
|
||||
return {
|
||||
phoneNumber: entry.phoneNumber,
|
||||
status: entry.status,
|
||||
reason: entry.reason,
|
||||
remark: entry.remark,
|
||||
deletedAt: entry.deletedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function readPeriodSeconds(rule: FrequencyRule) {
|
||||
const config = rule.config && typeof rule.config === 'object' && !Array.isArray(rule.config)
|
||||
? rule.config as Record<string, unknown>
|
||||
: {};
|
||||
const fallback = rule.code === 'PHONE_FREQUENCY_24H' ? 24 * 60 * 60 : 5 * 60;
|
||||
const value = Number(config.periodSeconds ?? fallback);
|
||||
return Number.isInteger(value) && value >= 60 && value <= 24 * 60 * 60 && 24 * 60 * 60 % value === 0
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function fixedShanghaiWindow(value: Date, periodSeconds: number) {
|
||||
const shanghaiOffsetMs = 8 * 60 * 60 * 1000;
|
||||
const shifted = value.getTime() + shanghaiOffsetMs;
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const localDayStart = Math.floor(shifted / dayMs) * dayMs;
|
||||
const periodMs = periodSeconds * 1000;
|
||||
const localWindowStart = localDayStart + Math.floor((shifted - localDayStart) / periodMs) * periodMs;
|
||||
return {
|
||||
startAt: new Date(localWindowStart - shanghaiOffsetMs),
|
||||
endAt: new Date(localWindowStart - shanghaiOffsetMs + periodMs),
|
||||
};
|
||||
}
|
||||
|
||||
function formatWindow(startAt: Date, endAt: Date) {
|
||||
const formatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
return `${formatter.format(startAt)}至${formatter.format(endAt)}`;
|
||||
}
|
||||
|
||||
function chunks<T>(items: T[], size: number) {
|
||||
const result: T[][] = [];
|
||||
for (let index = 0; index < items.length; index += size) {
|
||||
result.push(items.slice(index, index + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseOptionalDate(value: string | undefined, label: string) {
|
||||
if (!value) return undefined;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new BadRequestException(`${label}格式无效`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -3,12 +3,13 @@ import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { AdminRiskReviewController } from './admin-risk-review.controller';
|
||||
import { ClientRiskReviewController } from './client-risk-review.controller';
|
||||
import { RiskReviewService } from './risk-review.service';
|
||||
import { PhoneFrequencyService } from './phone-frequency.service';
|
||||
import { SendChainModule } from '../send-chain/send-chain.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, forwardRef(() => SendChainModule)],
|
||||
controllers: [AdminRiskReviewController, ClientRiskReviewController],
|
||||
providers: [RiskReviewService],
|
||||
exports: [RiskReviewService],
|
||||
providers: [RiskReviewService, PhoneFrequencyService],
|
||||
exports: [RiskReviewService, PhoneFrequencyService],
|
||||
})
|
||||
export class RiskReviewModule {}
|
||||
|
||||
@@ -61,6 +61,18 @@ function createPrismaMock(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe('RiskReviewService', () => {
|
||||
it('keeps phone-frequency periods fixed and rejects manual-review actions', () => {
|
||||
const service = new RiskReviewService(createPrismaMock() as never);
|
||||
|
||||
expect(() => service['normalizeRuleConfig']('PHONE_FREQUENCY_5M', { periodSeconds: 600 }))
|
||||
.toThrow('号码频次周期首版固定为24小时自然日或5分钟,不允许修改');
|
||||
expect(() => service['validateRuleInput']({
|
||||
code: 'PHONE_FREQUENCY_24H',
|
||||
thresholdValue: 10,
|
||||
action: 'manual_review',
|
||||
})).toThrow('号码频次阈值必须是大于0的整数,首版处理动作固定为直接拒绝');
|
||||
});
|
||||
|
||||
it('includes the sending enterprise and application in SMS review rows', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSendTask.findMany.mockResolvedValue([]);
|
||||
|
||||
@@ -87,6 +87,26 @@ const DEFAULT_RULES: CreateRiskRuleDto[] = [
|
||||
action: 'manual_review',
|
||||
priority: 30,
|
||||
},
|
||||
{
|
||||
code: 'PHONE_FREQUENCY_24H',
|
||||
name: '单号码24小时发送频次',
|
||||
description: '同一企业应用下,单个号码在北京时间自然日内最多允许10条业务短信。',
|
||||
metric: 'phoneFrequencyCount',
|
||||
thresholdValue: 10,
|
||||
action: 'block',
|
||||
priority: 40,
|
||||
config: { periodSeconds: 24 * 60 * 60, timeZone: 'Asia/Shanghai', alignment: 'fixed' },
|
||||
},
|
||||
{
|
||||
code: 'PHONE_FREQUENCY_5M',
|
||||
name: '单号码5分钟发送频次',
|
||||
description: '同一企业应用下,单个号码在固定5分钟周期内最多允许5条业务短信。',
|
||||
metric: 'phoneFrequencyCount',
|
||||
thresholdValue: 5,
|
||||
action: 'block',
|
||||
priority: 50,
|
||||
config: { periodSeconds: 5 * 60, timeZone: 'Asia/Shanghai', alignment: 'fixed' },
|
||||
},
|
||||
];
|
||||
|
||||
const RULE_DEFINITIONS = new Map(DEFAULT_RULES.map((rule) => [rule.code, rule]));
|
||||
@@ -129,7 +149,7 @@ export class RiskReviewService {
|
||||
description: definition.description,
|
||||
metric: definition.metric!,
|
||||
thresholdValue: data.thresholdValue,
|
||||
action: data.action ?? 'manual_review',
|
||||
action: isPhoneFrequencyRule(data.code) ? 'block' : data.action ?? 'manual_review',
|
||||
status: data.status ?? 'active',
|
||||
priority: data.priority ?? definition.priority ?? 100,
|
||||
config: this.normalizeRuleConfig(data.code, data.config ?? definition.config) as Prisma.InputJsonValue | undefined,
|
||||
@@ -468,7 +488,7 @@ export class RiskReviewService {
|
||||
return rejected;
|
||||
}
|
||||
|
||||
private async ensureDefaultRules() {
|
||||
async ensureDefaultRules() {
|
||||
for (const rule of DEFAULT_RULES) {
|
||||
const exists = await this.prisma.riskRule.findFirst({
|
||||
where: { applicationId: null, code: rule.code, status: { not: 'deleted' } },
|
||||
@@ -556,6 +576,12 @@ export class RiskReviewService {
|
||||
if (!Number.isFinite(data.thresholdValue) || data.thresholdValue < 0) {
|
||||
throw new BadRequestException('风控阈值必须是大于等于0的有效数字');
|
||||
}
|
||||
if (
|
||||
isPhoneFrequencyRule(data.code)
|
||||
&& (!Number.isInteger(data.thresholdValue) || data.thresholdValue < 1 || data.action === 'manual_review')
|
||||
) {
|
||||
throw new BadRequestException('号码频次阈值必须是大于0的整数,首版处理动作固定为直接拒绝');
|
||||
}
|
||||
if (data.action && !['block', 'manual_review'].includes(data.action)) {
|
||||
throw new BadRequestException('风控处理动作无效');
|
||||
}
|
||||
@@ -581,6 +607,18 @@ export class RiskReviewService {
|
||||
}
|
||||
|
||||
private normalizeRuleConfig(code: string, config?: Record<string, unknown> | null) {
|
||||
if (code === 'PHONE_FREQUENCY_24H' || code === 'PHONE_FREQUENCY_5M') {
|
||||
const defaultPeriodSeconds = code === 'PHONE_FREQUENCY_24H' ? 24 * 60 * 60 : 5 * 60;
|
||||
const periodSeconds = Number(config?.periodSeconds ?? defaultPeriodSeconds);
|
||||
if (periodSeconds !== defaultPeriodSeconds) {
|
||||
throw new BadRequestException('号码频次周期首版固定为24小时自然日或5分钟,不允许修改');
|
||||
}
|
||||
return {
|
||||
periodSeconds: defaultPeriodSeconds,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
alignment: 'fixed',
|
||||
};
|
||||
}
|
||||
if (code !== 'NON_WORKING_MARKETING_BULK') {
|
||||
return config ?? undefined;
|
||||
}
|
||||
@@ -599,6 +637,10 @@ export class RiskReviewService {
|
||||
}
|
||||
}
|
||||
|
||||
function isPhoneFrequencyRule(code: string) {
|
||||
return code === 'PHONE_FREQUENCY_24H' || code === 'PHONE_FREQUENCY_5M';
|
||||
}
|
||||
|
||||
function ratio(count: number, total: number) {
|
||||
if (total <= 0) {
|
||||
return 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { SendChainService, TimeoutUnknownDto } from './send-chain.service';
|
||||
import { TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { SendChainService } from './send-chain.service';
|
||||
|
||||
@ApiTags('send-chain')
|
||||
@Controller('admin/send')
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { BadRequestException, Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { ConfirmImportDto, CreateBatchTaskDto, ImportPreviewDto, SendChainService } from './send-chain.service';
|
||||
import { ConfirmImportDto, CreateBatchTaskDto, ImportPreviewDto } from './send-chain.contracts';
|
||||
import { SendChainService } from './send-chain.service';
|
||||
|
||||
@ApiTags('client-send-chain')
|
||||
@Controller('client/send')
|
||||
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
GatewaySubmitResultDto,
|
||||
GatewaySubmitSegmentResultDto,
|
||||
GatewayUplinkEventDto,
|
||||
SendChainService,
|
||||
} from './send-chain.service';
|
||||
import { GatewayDownstreamConnectionEventDto, SmsConfigService } from '../sms-config/sms-config.service';
|
||||
} from './send-chain.contracts';
|
||||
import { SendChainService } from './send-chain.service';
|
||||
import { GatewayDownstreamConnectionEventDto } from '../sms-config/sms-config.contracts';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import { ProtocolLogsService, type ProtocolLogInput } from '../protocol-logs/protocol-logs.service';
|
||||
|
||||
@ApiTags('gateway-events')
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
|
||||
|
||||
/**
|
||||
* R10 accounting implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendAccountingService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async chargeAcceptedMessage(message: {
|
||||
tenantId: string;
|
||||
applicationId?: string | null;
|
||||
batchTaskId: string;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
unitPrice: number | bigint;
|
||||
amountCents: number | bigint;
|
||||
}) {
|
||||
const amountCents = moneyToNumber(message.amountCents);
|
||||
const unitPrice = moneyToNumber(message.unitPrice);
|
||||
const billingUnits = message.billingUnits ?? 0;
|
||||
const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } });
|
||||
if (exists?.billingStatus === 'charged') {
|
||||
return;
|
||||
}
|
||||
if (amountCents > 0) {
|
||||
await this.billing.release({
|
||||
tenantId: message.tenantId,
|
||||
amountCents,
|
||||
idempotencyKey: `sms-charge-release:${message.messageId}`,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: message.batchTaskId,
|
||||
remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`,
|
||||
});
|
||||
}
|
||||
const transaction = await this.billing.charge({
|
||||
tenantId: message.tenantId,
|
||||
amountCents,
|
||||
idempotencyKey: `sms-charge:${message.messageId}`,
|
||||
relatedType: 'sms_message_record',
|
||||
relatedId: message.messageId,
|
||||
remark: '提交成功扣费',
|
||||
});
|
||||
const data = {
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId ?? undefined,
|
||||
taskId: message.batchTaskId,
|
||||
messageId: message.messageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
contentLength: [...message.content].length,
|
||||
billingUnits,
|
||||
unitPrice,
|
||||
amountCents,
|
||||
billingStatus: 'charged',
|
||||
transactionId: transaction.id,
|
||||
};
|
||||
if (exists) {
|
||||
await this.prisma.smsBillingRecord.update({ where: { id: exists.id }, data });
|
||||
return;
|
||||
}
|
||||
await this.prisma.smsBillingRecord.create({ data });
|
||||
}
|
||||
|
||||
async releaseMessageReservation(
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
const amountCents = moneyToNumber(message.amountCents);
|
||||
if (amountCents <= 0) {
|
||||
return;
|
||||
}
|
||||
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } });
|
||||
if (charged) {
|
||||
return;
|
||||
}
|
||||
const released = await this.prisma.accountTransaction.findFirst({
|
||||
where: { relatedType: 'sms_message_record', relatedId: message.messageId, transactionType: 'released' },
|
||||
});
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
await this.billing.release({
|
||||
tenantId: message.tenantId,
|
||||
amountCents,
|
||||
idempotencyKey: `sms-reservation-release:${message.messageId}`,
|
||||
relatedType: 'sms_message_record',
|
||||
relatedId: message.messageId,
|
||||
remark: `${remark}: ${message.messageId}`,
|
||||
});
|
||||
}
|
||||
|
||||
async refundMessage(
|
||||
message: { tenantId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
const amountCents = moneyToNumber(message.amountCents);
|
||||
if (amountCents <= 0) {
|
||||
return;
|
||||
}
|
||||
const refunded = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'refunded' } });
|
||||
if (refunded) {
|
||||
return;
|
||||
}
|
||||
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } });
|
||||
if (!charged) {
|
||||
return;
|
||||
}
|
||||
const transaction = await this.billing.refund({
|
||||
tenantId: message.tenantId,
|
||||
amountCents,
|
||||
idempotencyKey: `sms-refund:${message.messageId}`,
|
||||
relatedType: 'sms_message_record',
|
||||
relatedId: message.messageId,
|
||||
remark,
|
||||
});
|
||||
await this.prisma.smsBillingRecord.updateMany({
|
||||
where: { messageId: message.messageId },
|
||||
data: { billingStatus: 'refunded', transactionId: transaction.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { isIpAllowed } from '../common/ip-allowlist';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
|
||||
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
|
||||
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
|
||||
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
||||
|
||||
/**
|
||||
* R9 batchEntry implementation. Cross-method calls return through the stable SendChainService seam.
|
||||
*/
|
||||
export class SendBatchEntryService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly riskReview: RiskReviewService,
|
||||
private readonly phoneFrequency: PhoneFrequencyService,
|
||||
private readonly phoneRouting: PhoneRoutingLookupService,
|
||||
private readonly facade: SendSubmissionService,
|
||||
private readonly callbacks: SendSubmissionCallbacks,
|
||||
) {}
|
||||
|
||||
private releaseMessageReservation(
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
return this.callbacks.releaseMessageReservation(message, remark);
|
||||
}
|
||||
|
||||
private recordCmppFailureReceipt(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
applicationId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
cmppSubmitSequenceId?: string | null;
|
||||
cmppSubmitGroupMessageId?: string | null;
|
||||
},
|
||||
errorCode: string,
|
||||
reason: string,
|
||||
) {
|
||||
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
|
||||
}
|
||||
|
||||
|
||||
async createBatchTask(data: CreateBatchTaskDto) {
|
||||
const phones = [...new Set(data.phones ?? [])];
|
||||
const schedule = parseSchedule(data);
|
||||
await this.facade.validateSendResources(data.tenantId, data.applicationId, data.templateId);
|
||||
const phoneRejections = await this.facade.classifyRejectedPhones(data.tenantId, data.applicationId, phones);
|
||||
let sendablePhones = phones.filter((phone) => !phoneRejections.has(phone));
|
||||
const [messageClassification, unitPrice, queuePriority, accessNumber] = await Promise.all([
|
||||
this.facade.resolveTemplateMessageClassification(data.tenantId, data.applicationId, data.templateId, data.content),
|
||||
this.facade.resolveUnitPrice(data.tenantId, data.applicationId),
|
||||
this.facade.resolveQueuePriority(data.tenantId, data.applicationId),
|
||||
this.facade.resolveApplicationAccessNumber(data.tenantId, data.applicationId),
|
||||
]);
|
||||
const risk = messageClassification.rejectionReason
|
||||
? { status: 'rejected', reason: messageClassification.rejectionReason, task: null }
|
||||
: await this.riskReview.evaluateTask({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
templateId: data.templateId,
|
||||
content: data.content,
|
||||
category: data.category,
|
||||
phones,
|
||||
variables: messageClassification.variables ?? data.variables,
|
||||
createdById: data.createdById,
|
||||
sourceType: data.sourceType ?? 'client',
|
||||
});
|
||||
let frequencyRejectedAll = false;
|
||||
let frequencyBatchReason: string | undefined;
|
||||
if (risk.status !== 'rejected' && sendablePhones.length > 0) {
|
||||
const frequencyRejections = await this.phoneFrequency.reserve(
|
||||
data.tenantId,
|
||||
data.applicationId,
|
||||
sendablePhones,
|
||||
data.sourceType ?? 'client',
|
||||
);
|
||||
for (const [phone, rejection] of frequencyRejections) {
|
||||
phoneRejections.set(phone, rejection);
|
||||
}
|
||||
sendablePhones = sendablePhones.filter((phone) => !frequencyRejections.has(phone));
|
||||
frequencyRejectedAll = frequencyRejections.size > 0 && sendablePhones.length === 0;
|
||||
frequencyBatchReason = frequencyRejectedAll
|
||||
? [...frequencyRejections.values()][0]?.reason
|
||||
: undefined;
|
||||
}
|
||||
if (frequencyRejectedAll && risk.status === 'pending_review' && risk.task?.id) {
|
||||
await this.prisma.smsSendTask.update({
|
||||
where: { id: risk.task.id },
|
||||
data: {
|
||||
status: 'rejected',
|
||||
riskDecision: 'block',
|
||||
reviewReason: null,
|
||||
rejectReason: frequencyBatchReason,
|
||||
},
|
||||
});
|
||||
}
|
||||
const billing = this.billing.estimateSmsCost({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
taskId: risk.task?.id,
|
||||
content: data.content,
|
||||
phoneCount: sendablePhones.length,
|
||||
unitPrice,
|
||||
});
|
||||
const batchStatus = frequencyRejectedAll
|
||||
? 'rejected'
|
||||
: risk.status === 'approved' && sendablePhones.length === 0
|
||||
? 'failed'
|
||||
: statusFromRisk(risk.status, Boolean(schedule.scheduledAt));
|
||||
const shouldReserveBalance = batchStatus === 'ready';
|
||||
if (risk.status === 'approved') {
|
||||
const accountCheck = await this.billing.checkAccount({
|
||||
tenantId: data.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
});
|
||||
if (!accountCheck.canSend) {
|
||||
throw new BadRequestException('企业账户余额不足');
|
||||
}
|
||||
}
|
||||
if (data.applicationId && risk.status !== 'rejected' && sendablePhones.length > 0) {
|
||||
await this.facade.reserveDailySendQuota(data.applicationId, sendablePhones.length);
|
||||
}
|
||||
const task = await this.prisma.smsBatchTask.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
templateId: data.templateId,
|
||||
taskNo: `BT-${Date.now()}-${randomUUID().slice(0, 8)}`,
|
||||
sourceType: data.sourceType ?? 'client',
|
||||
content: data.content,
|
||||
category: data.category,
|
||||
phoneTotal: phones.length,
|
||||
status: batchStatus,
|
||||
riskTaskId: risk.task?.id,
|
||||
auditStatus: frequencyRejectedAll || risk.status === 'rejected' ? 'rejected' : risk.status === 'pending_review' ? 'pending' : 'approved',
|
||||
reviewReason: !frequencyRejectedAll && risk.status === 'pending_review' ? risk.reason : null,
|
||||
rejectReason: frequencyRejectedAll ? frequencyBatchReason : risk.status === 'rejected' ? risk.reason : null,
|
||||
progressTotal: phones.length,
|
||||
scheduledAt: schedule.scheduledAt,
|
||||
createdById: data.createdById,
|
||||
},
|
||||
});
|
||||
if (shouldReserveBalance && billing.amountCents > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: data.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: '发送任务创建冻结',
|
||||
});
|
||||
}
|
||||
await this.prisma.smsApiRequest.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
batchTaskId: task.id,
|
||||
requestId: `REQ-${Date.now()}-${randomUUID().slice(0, 8)}`,
|
||||
sourceIp: data.sourceIp,
|
||||
userAgent: data.userAgent,
|
||||
payloadSummary: {
|
||||
phoneTotal: phones.length,
|
||||
contentLength: [...data.content].length,
|
||||
category: data.category,
|
||||
sendMode: schedule.scheduledAt ? 'scheduled' : 'immediate',
|
||||
scheduledAt: schedule.scheduledAt?.toISOString(),
|
||||
},
|
||||
status: ['rejected', 'failed'].includes(batchStatus) ? 'rejected' : 'accepted',
|
||||
},
|
||||
});
|
||||
if (phones.length > 0) {
|
||||
await this.prisma.smsMessageRecord.createMany({
|
||||
data: phones.map((phone) => {
|
||||
const rejection = phoneRejections.get(phone);
|
||||
const status = rejection
|
||||
? 'submit_failed'
|
||||
: batchStatus === 'ready'
|
||||
? 'queued'
|
||||
: batchStatus === 'scheduled'
|
||||
? 'scheduled'
|
||||
: batchStatus;
|
||||
return {
|
||||
tenantId: data.tenantId,
|
||||
batchTaskId: task.id,
|
||||
applicationId: data.applicationId,
|
||||
templateId: data.templateId,
|
||||
signatureId: messageClassification.signatureId,
|
||||
drainageInfoId: messageClassification.drainageInfoId,
|
||||
reviewTaskId: !rejection && risk.status === 'pending_review' ? risk.task?.id : undefined,
|
||||
messageId: `MSG-${randomUUID()}`,
|
||||
clientMessageId: data.clientMessageId,
|
||||
phoneNumber: phone,
|
||||
content: data.content,
|
||||
billingUnits: billing.billingUnitsPerMessage,
|
||||
unitPrice: rejection ? 0 : billing.unitPrice,
|
||||
amountCents: rejection ? 0 : billing.billingUnitsPerMessage * billing.unitPrice,
|
||||
queuePriority,
|
||||
clientSrcId: accessNumber.clientSrcId,
|
||||
applicationExtension: accessNumber.applicationExtension,
|
||||
status,
|
||||
submitStatus: rejection ? 'rejected' : undefined,
|
||||
errorCode: rejection?.code,
|
||||
errorMessage: rejection?.reason ?? (risk.status === 'rejected' ? risk.reason ?? undefined : undefined),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (batchStatus === 'ready' && sendablePhones.length > 0) {
|
||||
await this.facade.enqueueBatchTask(task.id);
|
||||
} else if (batchStatus === 'failed') {
|
||||
await this.facade.refreshTaskProgress(task.id);
|
||||
}
|
||||
return this.facade.getBatchTask(task.id, undefined, data.sourceType ?? 'client');
|
||||
}
|
||||
|
||||
async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
|
||||
if (!data.applicationId) {
|
||||
throw new BadRequestException('公开 HTTP 发送必须关联企业应用');
|
||||
}
|
||||
const template = await this.facade.resolveInboundTemplateCandidate(data.applicationId, data.content);
|
||||
if (!template || template.auditStatus !== 'approved' || template.signature?.auditStatus !== 'approved') {
|
||||
throw new BadRequestException('短信内容未匹配当前应用已审核通过的签名和模板');
|
||||
}
|
||||
const variables = matchTemplateContent(template.content, data.content);
|
||||
if (variables === null) {
|
||||
throw new BadRequestException('短信内容与已审核模板不匹配');
|
||||
}
|
||||
return this.facade.createBatchTask({
|
||||
...data,
|
||||
templateId: template.id,
|
||||
variables,
|
||||
sourceType: 'api',
|
||||
});
|
||||
}
|
||||
|
||||
async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
|
||||
const task = await this.prisma.smsBatchTask.findFirst({
|
||||
where: { id: taskId, tenantId, sourceType },
|
||||
include: { apiRequests: true, messages: { take: 20, orderBy: { queuedAt: 'asc' } } },
|
||||
});
|
||||
if (!task) {
|
||||
throw new NotFoundException('SMS batch task not found');
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
async previewImport(data: ImportPreviewDto) {
|
||||
const sizeBytes = Buffer.byteLength(data.content, 'utf8');
|
||||
if (sizeBytes > 20 * 1024 * 1024) {
|
||||
throw new BadRequestException('导入文件不能超过 20MB');
|
||||
}
|
||||
const rows = parseImportRows(data.content, data.delimiter);
|
||||
const phones: string[] = [];
|
||||
const errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }> = [];
|
||||
const requiredVariables = data.requiredVariables ?? [];
|
||||
const enterpriseBlacklist = data.applicationId ? await this.prisma.enterpriseBlacklist.findMany({
|
||||
where: { tenantId: data.tenantId, applicationId: data.applicationId, status: 'active' },
|
||||
select: { phoneNumber: true },
|
||||
}) : [];
|
||||
const globalBlacklist = await this.prisma.globalBlacklist.findMany({
|
||||
where: { status: 'active' },
|
||||
select: { phoneNumber: true },
|
||||
});
|
||||
const blacklist = new Set([...enterpriseBlacklist, ...globalBlacklist].map((item) => item.phoneNumber));
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (!row.phoneNumber) {
|
||||
errors.push({ rowNumber: row.rowNumber, reason: '缺少手机号' });
|
||||
continue;
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(row.phoneNumber)) {
|
||||
errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '手机号格式非法' });
|
||||
continue;
|
||||
}
|
||||
if (seen.has(row.phoneNumber)) {
|
||||
errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '重复号码' });
|
||||
continue;
|
||||
}
|
||||
if (blacklist.has(row.phoneNumber)) {
|
||||
errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '命中黑名单' });
|
||||
continue;
|
||||
}
|
||||
const missingVariables = requiredVariables.filter((name) => !row.variables[name]);
|
||||
if (missingVariables.length > 0) {
|
||||
errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: `变量列缺失:${missingVariables.join(',')}` });
|
||||
continue;
|
||||
}
|
||||
seen.add(row.phoneNumber);
|
||||
phones.push(row.phoneNumber);
|
||||
}
|
||||
return {
|
||||
fileName: data.fileName,
|
||||
encoding: data.encoding ?? 'utf8',
|
||||
totalRows: rows.length,
|
||||
validCount: phones.length,
|
||||
errorCount: errors.length,
|
||||
phones,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
async confirmImport(data: ConfirmImportDto) {
|
||||
const preview = await this.facade.previewImport({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
content: data.importContent,
|
||||
requiredVariables: data.requiredVariables,
|
||||
});
|
||||
if (preview.validCount === 0) {
|
||||
throw new BadRequestException('导入文件没有可发送号码');
|
||||
}
|
||||
return this.facade.createBatchTask({ ...data, phones: preview.phones });
|
||||
}
|
||||
|
||||
async resolveUnitPrice(tenantId: string, applicationId?: string) {
|
||||
if (!applicationId) {
|
||||
return 0;
|
||||
}
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
select: { tenantId: true, customerUnitPrice: true },
|
||||
});
|
||||
if (!application || application.tenantId !== tenantId) {
|
||||
return 0;
|
||||
}
|
||||
return moneyToNumber(application.customerUnitPrice);
|
||||
}
|
||||
|
||||
async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
|
||||
if (!applicationId) {
|
||||
return 'normal';
|
||||
}
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
select: { tenantId: true, queuePriority: true },
|
||||
});
|
||||
if (!application || application.tenantId !== tenantId) {
|
||||
return 'normal';
|
||||
}
|
||||
return normalizeQueuePriority(application.queuePriority);
|
||||
}
|
||||
|
||||
async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
|
||||
if (!applicationId) {
|
||||
return { clientSrcId: null, applicationExtension: null };
|
||||
}
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
select: { tenantId: true, cmppClientSrcId: true, cmppApplicationExtension: true },
|
||||
});
|
||||
if (!application || application.tenantId !== tenantId) {
|
||||
return { clientSrcId: null, applicationExtension: null };
|
||||
}
|
||||
return {
|
||||
clientSrcId: application.cmppClientSrcId,
|
||||
applicationExtension: application.cmppApplicationExtension,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveTemplateMessageClassification(
|
||||
tenantId: string,
|
||||
applicationId: string | undefined,
|
||||
templateId: string | undefined,
|
||||
content: string,
|
||||
) {
|
||||
if (templateId) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({
|
||||
where: { id: templateId },
|
||||
include: { signature: true },
|
||||
});
|
||||
if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId
|
||||
|| template.auditStatus !== 'approved' || template.signature?.auditStatus !== 'approved') {
|
||||
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
|
||||
}
|
||||
const variables = matchTemplateContent(template.content, content);
|
||||
if (variables === null) {
|
||||
throw new BadRequestException('短信内容与选定的审核模板不匹配');
|
||||
}
|
||||
const drainage = await this.facade.resolveDrainageInfoMatch(template.signatureId, content);
|
||||
return {
|
||||
signatureId: template.signatureId,
|
||||
drainageInfoId: drainage?.id,
|
||||
variables,
|
||||
rejectionReason: drainageRejectionReason(drainage),
|
||||
};
|
||||
}
|
||||
|
||||
if (!applicationId) {
|
||||
throw new BadRequestException('自由内容短信必须关联企业应用');
|
||||
}
|
||||
const [application, signature] = await Promise.all([
|
||||
this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
select: { tenantId: true, templateMismatchMode: true },
|
||||
}),
|
||||
this.facade.resolveInboundSignatureCandidate(applicationId, content),
|
||||
]);
|
||||
if (!application || application.tenantId !== tenantId) {
|
||||
throw new BadRequestException('短信应用不存在或不属于当前企业');
|
||||
}
|
||||
if (!signature) {
|
||||
throw new BadRequestException('短信内容未以当前应用已审核通过的签名开头');
|
||||
}
|
||||
if (application.templateMismatchMode !== 'direct_send') {
|
||||
throw new BadRequestException('当前应用未允许无模板自由内容直接发送');
|
||||
}
|
||||
const drainage = await this.facade.resolveDrainageInfoMatch(signature.id, content);
|
||||
return {
|
||||
signatureId: signature.id,
|
||||
drainageInfoId: drainage?.id,
|
||||
variables: undefined,
|
||||
rejectionReason: drainageRejectionReason(drainage),
|
||||
};
|
||||
}
|
||||
|
||||
async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) {
|
||||
const rejected = new Map<string, { code: string; reason: string }>();
|
||||
for (const phone of phones) {
|
||||
if (!/^1\d{10}$/.test(phone)) {
|
||||
rejected.set(phone, { code: 'INVALID_PHONE', reason: '手机号码必须是1开头的11位数字' });
|
||||
}
|
||||
}
|
||||
const validPhones = phones.filter((phone) => !rejected.has(phone));
|
||||
if (validPhones.length === 0) {
|
||||
return rejected;
|
||||
}
|
||||
const [globalHits, enterpriseHits] = await Promise.all([
|
||||
this.prisma.globalBlacklist.findMany({
|
||||
where: { phoneNumber: { in: validPhones }, status: 'active' },
|
||||
select: { phoneNumber: true, reason: true },
|
||||
}),
|
||||
applicationId
|
||||
? this.prisma.enterpriseBlacklist.findMany({
|
||||
where: { tenantId, applicationId, phoneNumber: { in: validPhones }, status: 'active' },
|
||||
select: { phoneNumber: true, reason: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
for (const hit of globalHits) {
|
||||
rejected.set(hit.phoneNumber, {
|
||||
code: 'GLOBAL_BLACKLIST',
|
||||
reason: hit.reason?.trim() || '号码命中平台黑名单',
|
||||
});
|
||||
}
|
||||
for (const hit of enterpriseHits) {
|
||||
rejected.set(hit.phoneNumber, {
|
||||
code: 'ENTERPRISE_BLACKLIST',
|
||||
reason: hit.reason?.trim() || '号码命中企业应用黑名单',
|
||||
});
|
||||
}
|
||||
return rejected;
|
||||
}
|
||||
|
||||
async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
|
||||
if (!tenant || tenant.status !== 'active') {
|
||||
throw new BadRequestException('企业客户不存在或已停用');
|
||||
}
|
||||
if (tenant.certificationStatus !== 'approved') {
|
||||
throw new BadRequestException('企业认证未通过,不能发送短信');
|
||||
}
|
||||
if (!applicationId) {
|
||||
return;
|
||||
}
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
||||
if (!application || application.tenantId !== tenantId || application.status !== 'active') {
|
||||
throw new BadRequestException('短信应用不存在或已停用');
|
||||
}
|
||||
if (!application.interfaceEnabled) {
|
||||
throw new BadRequestException('短信应用接口未开通,不能发送短信');
|
||||
}
|
||||
if (!templateId) {
|
||||
return;
|
||||
}
|
||||
const template = await this.prisma.smsTemplate.findUnique({
|
||||
where: { id: templateId },
|
||||
include: { signature: true },
|
||||
});
|
||||
if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId || template.auditStatus !== 'approved') {
|
||||
throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用');
|
||||
}
|
||||
if (!template.signature || template.signature.auditStatus !== 'approved') {
|
||||
throw new BadRequestException('短信签名未审核通过');
|
||||
}
|
||||
}
|
||||
|
||||
async reserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
const result = await this.facade.tryReserveDailySendQuota(applicationId, requestedCount);
|
||||
if (!result.reserved) {
|
||||
throw new HttpException({
|
||||
code: 'DAILY_SEND_LIMIT_EXCEEDED',
|
||||
message: `应用当日发送上限${result.dailyLimit}条,本次${requestedCount}条超出剩余配额`,
|
||||
dailyLimit: result.dailyLimit,
|
||||
requestedCount,
|
||||
}, HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async tryReserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
if (!Number.isInteger(requestedCount) || requestedCount <= 0) {
|
||||
throw new BadRequestException('发送号码数量必须为正整数');
|
||||
}
|
||||
const usageDate = shanghaiDateKey();
|
||||
const reservationId = randomUUID();
|
||||
const rows = await this.prisma.$queryRaw<Array<{ dailyLimit: number; usedCount: number | null }>>(Prisma.sql`
|
||||
WITH application_limit AS (
|
||||
SELECT id, COALESCE("dailyLimit", 100000)::integer AS "dailyLimit"
|
||||
FROM "SmsApplication"
|
||||
WHERE id = ${applicationId}
|
||||
), reservation AS (
|
||||
INSERT INTO "SmsApplicationDailyUsage" (
|
||||
id, "applicationId", "usageDate", "usedCount", "createdAt", "updatedAt"
|
||||
)
|
||||
SELECT ${reservationId}, id, ${usageDate}::date, ${requestedCount}, NOW(), NOW()
|
||||
FROM application_limit
|
||||
WHERE ${requestedCount} <= "dailyLimit"
|
||||
ON CONFLICT ("applicationId", "usageDate") DO UPDATE
|
||||
SET "usedCount" = "SmsApplicationDailyUsage"."usedCount" + EXCLUDED."usedCount",
|
||||
"updatedAt" = NOW()
|
||||
WHERE "SmsApplicationDailyUsage"."usedCount" + EXCLUDED."usedCount"
|
||||
<= (SELECT "dailyLimit" FROM application_limit)
|
||||
RETURNING "usedCount"
|
||||
)
|
||||
SELECT application_limit."dailyLimit", reservation."usedCount"
|
||||
FROM application_limit
|
||||
LEFT JOIN reservation ON TRUE
|
||||
`);
|
||||
if (rows.length === 0) {
|
||||
throw new NotFoundException('短信应用不存在');
|
||||
}
|
||||
return {
|
||||
dailyLimit: Number(rows[0].dailyLimit),
|
||||
usedCount: rows[0].usedCount == null ? null : Number(rows[0].usedCount),
|
||||
reserved: rows[0].usedCount != null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// R8 contract-only declarations. Runtime behavior remains in SendChainService.
|
||||
|
||||
export interface CreateBatchTaskDto {
|
||||
tenantId: string;
|
||||
applicationId?: string;
|
||||
templateId?: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
phones: string[];
|
||||
sendMode?: 'immediate' | 'scheduled';
|
||||
scheduledAt?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
createdById?: string;
|
||||
sourceIp?: string;
|
||||
userAgent?: string;
|
||||
sourceType?: 'client' | 'api' | 'cmpp';
|
||||
clientMessageId?: string;
|
||||
}
|
||||
|
||||
export type CreateHttpBatchTaskDto = Omit<CreateBatchTaskDto, 'templateId' | 'variables' | 'sourceType'>;
|
||||
|
||||
export interface GatewayInboundAuthDto {
|
||||
account: string;
|
||||
password?: string;
|
||||
authSource?: string;
|
||||
timestamp?: number;
|
||||
remoteIp?: string;
|
||||
}
|
||||
|
||||
export interface GatewayInboundSubmitDto {
|
||||
account: string;
|
||||
phoneNumber?: string;
|
||||
phoneNumbers?: string[];
|
||||
content: string;
|
||||
srcId?: string;
|
||||
destId?: string;
|
||||
sequenceId?: number;
|
||||
remoteIp?: string;
|
||||
longMessage?: {
|
||||
reference: number;
|
||||
total: number;
|
||||
index: number;
|
||||
format: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GatewayInboundSingleSubmitResult {
|
||||
accepted: boolean;
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
taskId: string;
|
||||
messageId: string;
|
||||
messageRecordId: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface GatewaySubmitResultDto {
|
||||
traceId?: string;
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
submitId?: string;
|
||||
sequenceId?: number;
|
||||
gatewayMessageId: string;
|
||||
submitStatus: 'accepted' | 'rejected' | 'timeout';
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
submittedAt?: string;
|
||||
segments?: Array<{
|
||||
segmentTotal?: number;
|
||||
segmentIndex?: number;
|
||||
sequenceId?: number;
|
||||
gatewayMessageId?: string;
|
||||
submitStatus?: 'accepted' | 'rejected' | 'timeout' | string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
submittedAt?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GatewaySubmitSegmentResultDto {
|
||||
traceId?: string;
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
submitId?: string;
|
||||
segmentTotal: number;
|
||||
segmentIndex: number;
|
||||
sequenceId?: number;
|
||||
gatewayMessageId?: string;
|
||||
submitStatus: 'accepted' | 'rejected' | 'timeout' | string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
submittedAt?: string;
|
||||
}
|
||||
|
||||
export interface GatewayReceiptEventDto {
|
||||
traceId?: string;
|
||||
messageId?: string;
|
||||
channelId: string;
|
||||
sequenceId?: number;
|
||||
gatewayMessageId: string;
|
||||
phoneNumber?: string;
|
||||
receiptStatus: 'delivered' | 'undelivered' | 'unknown';
|
||||
rawStatus: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
deliveredAt?: string;
|
||||
connectionId?: string;
|
||||
}
|
||||
|
||||
export interface GatewayUplinkEventDto {
|
||||
traceId?: string;
|
||||
messageId?: string;
|
||||
channelId: string;
|
||||
sequenceId?: number;
|
||||
phoneNumber: string;
|
||||
destId: string;
|
||||
content: string;
|
||||
receivedAt?: string;
|
||||
}
|
||||
|
||||
export type UplinkMatchCandidateInput = {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
messageRecordId?: string;
|
||||
matchSource: 'access_number' | 'phone_window';
|
||||
confidence: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export interface GatewayPendingDeliveryQueryDto {
|
||||
account: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface GatewayDownstreamSentDto {
|
||||
id: string;
|
||||
connectionId?: string;
|
||||
sequenceId?: string;
|
||||
messageId?: string;
|
||||
sentAt?: string;
|
||||
ackDeadlineAt?: string;
|
||||
}
|
||||
|
||||
export interface GatewayDownstreamAcknowledgedDto extends GatewayDownstreamSentDto {
|
||||
result: number;
|
||||
acknowledgedAt?: string;
|
||||
}
|
||||
|
||||
export type GatewayDownstreamFailureType =
|
||||
| 'send_failed'
|
||||
| 'ack_timeout'
|
||||
| 'ack_rejected'
|
||||
| 'ack_invalid'
|
||||
| 'connection_lost'
|
||||
| 'unrecoverable'
|
||||
| 'queue_timeout';
|
||||
|
||||
export type GatewayControlDeliveryResult = {
|
||||
sent?: boolean;
|
||||
delivered?: boolean;
|
||||
retryable?: boolean;
|
||||
reasonCode?: string;
|
||||
errorMessage?: string;
|
||||
connectionId?: string;
|
||||
sequenceId?: string;
|
||||
messageId?: string;
|
||||
sentAt?: string;
|
||||
ackDeadlineAt?: string;
|
||||
};
|
||||
|
||||
export interface GatewaySubmitDeadLetterDto {
|
||||
streamMessageId: string;
|
||||
traceId?: string;
|
||||
messageId?: string;
|
||||
channelId?: string;
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
submitId?: string;
|
||||
failureCode: string;
|
||||
failureMessage: string;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
commandPayload?: Record<string, unknown>;
|
||||
rawPayload?: string;
|
||||
deadLetteredAt?: string;
|
||||
}
|
||||
|
||||
export interface RequeueGatewaySubmitExceptionDto {
|
||||
confirmedNotSubmitted?: boolean;
|
||||
reason?: string;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface GatewayDownstreamRecoveryStatusDto {
|
||||
account: string;
|
||||
gatewayInstanceId?: string;
|
||||
state: string;
|
||||
lockOwner?: string;
|
||||
lockExpiresAt?: string;
|
||||
lastAttemptAt?: string;
|
||||
lastSuccessAt?: string;
|
||||
lastFailureAt?: string;
|
||||
nextRetryAt?: string;
|
||||
attemptCount?: number;
|
||||
failureCategory?: string;
|
||||
lastError?: string;
|
||||
lastSkipReason?: string;
|
||||
}
|
||||
|
||||
export interface TimeoutUnknownDto {
|
||||
olderThanHours?: number;
|
||||
}
|
||||
|
||||
export interface ImportPreviewDto {
|
||||
tenantId: string;
|
||||
applicationId?: string;
|
||||
content: string;
|
||||
fileName?: string;
|
||||
encoding?: 'utf8' | 'gbk';
|
||||
delimiter?: ',' | '\t';
|
||||
requiredVariables?: string[];
|
||||
}
|
||||
|
||||
export interface ConfirmImportDto extends CreateBatchTaskDto {
|
||||
importContent: string;
|
||||
requiredVariables?: string[];
|
||||
}
|
||||
|
||||
export interface SendJob {
|
||||
messageRecordId: string;
|
||||
}
|
||||
|
||||
export type QueuePriority = 'normal' | 'priority';
|
||||
|
||||
export type RoutedChannel = {
|
||||
channel: {
|
||||
id: string;
|
||||
code: string;
|
||||
account: string;
|
||||
srcId: string;
|
||||
rateLimitPerSecond: number;
|
||||
unitPrice: number;
|
||||
status: string;
|
||||
carrier?: string | null;
|
||||
sendRegion: string;
|
||||
gatewayHost: string;
|
||||
gatewayPort: number;
|
||||
passwordCipher: string;
|
||||
cmppVersion: string;
|
||||
config?: unknown;
|
||||
};
|
||||
carrier: string;
|
||||
province?: string | null;
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
routeScope: 'province' | 'national';
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
aggregateReceiptSegmentState,
|
||||
isSameUpstreamEndpointIdentity,
|
||||
receiptEventKey,
|
||||
selectChannelCandidate,
|
||||
} from './send-chain.helpers';
|
||||
|
||||
const connected = [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }];
|
||||
|
||||
describe('send-chain pure policies', () => {
|
||||
it('prefers an approved online province channel while preserving priority order', () => {
|
||||
const items = [
|
||||
{ channelId: 'national', carrier: 'mobile', province: null, channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected } },
|
||||
{ channelId: 'province', carrier: 'mobile', province: '安徽省', channel: { carrier: 'mobile', sendRegion: '安徽', status: 'active', connectionStates: connected } },
|
||||
];
|
||||
|
||||
expect(selectChannelCandidate(items, {
|
||||
carrier: 'mobile',
|
||||
province: '安徽省',
|
||||
excludedChannelIds: new Set(),
|
||||
approvedChannelIds: new Set(['national', 'province']),
|
||||
})?.channelId).toBe('province');
|
||||
});
|
||||
|
||||
it('falls back to an approved online national channel', () => {
|
||||
const items = [
|
||||
{ channelId: 'offline', carrier: 'mobile', province: '安徽', channel: { carrier: 'mobile', sendRegion: '安徽', status: 'active', connectionStates: [] } },
|
||||
{ channelId: 'national', carrier: 'mobile', province: null, channel: { carrier: 'all', sendRegion: '全国', status: 'active', connectionStates: connected } },
|
||||
];
|
||||
|
||||
expect(selectChannelCandidate(items, {
|
||||
carrier: 'mobile',
|
||||
province: '安徽',
|
||||
excludedChannelIds: new Set(),
|
||||
approvedChannelIds: new Set(['offline', 'national']),
|
||||
})?.channelId).toBe('national');
|
||||
});
|
||||
|
||||
it('does not select excluded or unreported channels', () => {
|
||||
const items = [
|
||||
{ channelId: 'excluded', carrier: 'mobile', province: null, channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected } },
|
||||
{ channelId: 'unreported', carrier: 'mobile', province: null, channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected } },
|
||||
];
|
||||
|
||||
expect(selectChannelCandidate(items, {
|
||||
carrier: 'mobile',
|
||||
excludedChannelIds: new Set(['excluded']),
|
||||
approvedChannelIds: new Set(['excluded']),
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a segmented message non-terminal until all receipts arrive', () => {
|
||||
const result = aggregateReceiptSegmentState(
|
||||
[{ segmentTotal: 2, receiptStatus: 'delivered', deliveredAt: new Date('2026-07-31T00:00:00Z') }],
|
||||
2,
|
||||
{ channelId: 'channel-1', gatewayMessageId: 'gw-1', receiptStatus: 'delivered', rawStatus: 'DELIVRD' },
|
||||
new Date('2026-07-31T00:01:00Z'),
|
||||
);
|
||||
expect(result).toMatchObject({ terminal: false, segmentTotal: 2, status: 'submitted' });
|
||||
});
|
||||
|
||||
it('marks all delivered segments successful at the latest receipt time', () => {
|
||||
const latest = new Date('2026-07-31T00:02:00Z');
|
||||
const result = aggregateReceiptSegmentState(
|
||||
[
|
||||
{ segmentTotal: 2, receiptStatus: 'delivered', deliveredAt: new Date('2026-07-31T00:01:00Z') },
|
||||
{ segmentTotal: 2, receiptStatus: 'delivered', deliveredAt: latest },
|
||||
],
|
||||
2,
|
||||
{ channelId: 'channel-1', gatewayMessageId: 'gw-2', receiptStatus: 'delivered', rawStatus: 'DELIVRD' },
|
||||
latest,
|
||||
);
|
||||
expect(result).toMatchObject({ terminal: true, segmentTotal: 2, status: 'delivered', deliveredAt: latest });
|
||||
});
|
||||
|
||||
it('lets a failed segment decide the terminal message result', () => {
|
||||
const result = aggregateReceiptSegmentState(
|
||||
[
|
||||
{ segmentTotal: 2, receiptStatus: 'delivered' },
|
||||
{ segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'REJECTD', errorCode: 'ERR' },
|
||||
],
|
||||
2,
|
||||
{ channelId: 'channel-1', gatewayMessageId: 'gw-3', receiptStatus: 'undelivered', rawStatus: 'REJECTD' },
|
||||
new Date('2026-07-31T00:03:00Z'),
|
||||
);
|
||||
expect(result).toMatchObject({ terminal: true, status: 'failed', receiptStatus: 'undelivered', errorCode: 'ERR' });
|
||||
});
|
||||
|
||||
it('normalizes upstream endpoint identity without weakening port or version equality', () => {
|
||||
expect(isSameUpstreamEndpointIdentity(
|
||||
{ account: ' acct ', gatewayHost: 'SMSC.EXAMPLE', gatewayPort: 7890, protocol: 'cmpp', cmppVersion: '2.0' },
|
||||
{ account: 'acct', gatewayHost: 'smsc.example', gatewayPort: 7890, protocol: 'CMPP', cmppVersion: '2.0' },
|
||||
)).toBe(true);
|
||||
expect(isSameUpstreamEndpointIdentity(
|
||||
{ account: 'acct', gatewayHost: 'smsc.example', gatewayPort: 7890, protocol: 'CMPP', cmppVersion: '2.0' },
|
||||
{ account: 'acct', gatewayHost: 'smsc.example', gatewayPort: 7891, protocol: 'CMPP', cmppVersion: '2.0' },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('generates a stable receipt event key and changes it with logical channel identity', () => {
|
||||
const event = {
|
||||
channelId: 'physical',
|
||||
gatewayMessageId: 'gw-4',
|
||||
phoneNumber: '13800000000',
|
||||
receiptStatus: 'delivered' as const,
|
||||
rawStatus: 'DELIVRD',
|
||||
};
|
||||
expect(receiptEventKey(event, 'logical')).toBe(receiptEventKey({ ...event }, 'logical'));
|
||||
expect(receiptEventKey(event, 'logical')).not.toBe(receiptEventKey(event, 'other'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,602 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { CreateBatchTaskDto, GatewayControlDeliveryResult, GatewayDownstreamRecoveryStatusDto, GatewayDownstreamSentDto, GatewayInboundAuthDto, GatewayReceiptEventDto, GatewaySubmitResultDto, QueuePriority } from './send-chain.contracts';
|
||||
|
||||
// R8 pure policies and deterministic key/status helpers. No database, queue or network access.
|
||||
|
||||
export const SEND_QUEUE = 'sms.send.queue';
|
||||
|
||||
export const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue';
|
||||
|
||||
export const GATEWAY_SUBMIT_STREAM = 'gateway.submit.commands';
|
||||
|
||||
export const DEFAULT_DOWNSTREAM_RETRY_DELAY_MS = 60_000;
|
||||
|
||||
export const DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS = 30 * 60_000;
|
||||
|
||||
export const DEFAULT_DOWNSTREAM_MAX_RETRIES = 10;
|
||||
|
||||
export const DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS = 72;
|
||||
|
||||
export const DEFAULT_RECEIPT_TIMEOUT_HOURS = 72;
|
||||
|
||||
export const DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS = 5 * 60_000;
|
||||
|
||||
export const RECEIPT_TIMEOUT_INITIAL_DELAY_MS = 60_000;
|
||||
|
||||
export const DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = 5_000;
|
||||
|
||||
export const DEFAULT_SCHEDULED_DISPATCH_STALE_MS = 2 * 60_000;
|
||||
|
||||
export const SCHEDULED_DISPATCH_INITIAL_DELAY_MS = 1_000;
|
||||
|
||||
export const DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS = 2 * 60_000;
|
||||
|
||||
export const DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS = 2 * 60_000;
|
||||
|
||||
export const DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS = 60_000;
|
||||
|
||||
export const INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS = 10_000;
|
||||
|
||||
export const DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS = 30;
|
||||
|
||||
export const DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS = 5_000;
|
||||
|
||||
export const UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS = 1_000;
|
||||
|
||||
export const DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS = 2 * 60_000;
|
||||
|
||||
export const DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS = 30;
|
||||
|
||||
export const DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS = 72;
|
||||
|
||||
export const GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||
|
||||
export const BULLMQ_PRIORITY: Record<QueuePriority, number> = {
|
||||
priority: 1,
|
||||
normal: 100,
|
||||
};
|
||||
|
||||
export function gatewaySubmitRequeueKey(deadLetterId: string, attempt: number) {
|
||||
return `gateway:submit:requeue:${deadLetterId}:${attempt}`;
|
||||
}
|
||||
|
||||
export function drainageRejectionReason(drainage?: { id: string; auditStatus: string }) {
|
||||
if (!drainage || drainage.auditStatus === 'approved') return undefined;
|
||||
return `短信内容匹配的引流资料 ${drainage.id} 当前为 ${drainage.auditStatus},必须审核通过后才能发送`;
|
||||
}
|
||||
|
||||
export function statusFromRisk(status: string, scheduled: boolean) {
|
||||
if (status === 'rejected') {
|
||||
return 'rejected';
|
||||
}
|
||||
if (status === 'pending_review') {
|
||||
return 'pending_review';
|
||||
}
|
||||
if (scheduled) {
|
||||
return 'scheduled';
|
||||
}
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
export function parseSchedule(data: CreateBatchTaskDto) {
|
||||
if (data.sendMode !== 'scheduled' && !data.scheduledAt) {
|
||||
return { scheduledAt: null };
|
||||
}
|
||||
if (!data.scheduledAt) {
|
||||
throw new BadRequestException('定时发送必须提供 scheduledAt');
|
||||
}
|
||||
const scheduledAt = new Date(data.scheduledAt);
|
||||
if (Number.isNaN(scheduledAt.getTime())) {
|
||||
throw new BadRequestException('scheduledAt 时间格式无效');
|
||||
}
|
||||
if (scheduledAt.getTime() <= Date.now()) {
|
||||
throw new BadRequestException('scheduledAt 必须晚于当前时间');
|
||||
}
|
||||
return { scheduledAt };
|
||||
}
|
||||
|
||||
export function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function asDateOrNull(value?: string | null) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
export function downstreamRetryDelayMs(retryCount = 1) {
|
||||
const base = downstreamRetryBaseDelayMs();
|
||||
const max = downstreamRetryMaxDelayMs();
|
||||
const attempt = Math.max(1, Math.floor(retryCount));
|
||||
const delay = base * Math.pow(2, Math.max(0, attempt - 1));
|
||||
return Math.min(delay, max);
|
||||
}
|
||||
|
||||
export function downstreamAckTimeoutMs() {
|
||||
const configured = Number(process.env.CMPP_DOWNSTREAM_ACK_TIMEOUT_SECONDS ?? 30);
|
||||
return Math.max(5, Number.isFinite(configured) ? configured : 30) * 1000;
|
||||
}
|
||||
|
||||
export function downstreamRetryBaseDelayMs() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS ?? DEFAULT_DOWNSTREAM_RETRY_DELAY_MS);
|
||||
return Number.isFinite(value) && value > 0 ? value : DEFAULT_DOWNSTREAM_RETRY_DELAY_MS;
|
||||
}
|
||||
|
||||
export function downstreamRetryMaxDelayMs() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS ?? DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS);
|
||||
return Number.isFinite(value) && value > 0 ? value : DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS;
|
||||
}
|
||||
|
||||
export function downstreamMaxRetries() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_MAX_RETRIES ?? DEFAULT_DOWNSTREAM_MAX_RETRIES);
|
||||
return Number.isFinite(value) && value > 0 ? Math.floor(value) : DEFAULT_DOWNSTREAM_MAX_RETRIES;
|
||||
}
|
||||
|
||||
export function downstreamPendingTimeoutHours() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_PENDING_TIMEOUT_HOURS ?? DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS);
|
||||
return Number.isFinite(value) && value > 0 ? value : DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS;
|
||||
}
|
||||
|
||||
export function downstreamControlFailureMessage(result: GatewayControlDeliveryResult) {
|
||||
const reason = String(result.errorMessage ?? '').trim();
|
||||
const code = String(result.reasonCode ?? '').trim();
|
||||
if (reason && code) return `${reason} (${code})`;
|
||||
if (reason) return reason;
|
||||
if (code) return `Gateway 未完成下游投递 (${code})`;
|
||||
return 'Gateway 未完成下游投递,等待自动重试';
|
||||
}
|
||||
|
||||
export function parseImportRows(content: string, delimiter?: ',' | '\t') {
|
||||
const normalized = content.replace(/^\uFEFF/, '');
|
||||
const lines = normalized.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||
if (lines.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const firstDelimiter = delimiter ?? (lines[0].includes(',') ? ',' : '\t');
|
||||
const firstCells = splitImportLine(lines[0], firstDelimiter);
|
||||
const hasHeader = firstCells.some((cell) => ['phone', 'phoneNumber', 'mobile', '手机号'].includes(cell));
|
||||
const headers = hasHeader ? firstCells : ['phoneNumber'];
|
||||
const dataLines = hasHeader ? lines.slice(1) : lines;
|
||||
return dataLines.map((line, index) => {
|
||||
const cells = splitImportLine(line, firstDelimiter);
|
||||
const row: { rowNumber: number; phoneNumber?: string; variables: Record<string, string> } = {
|
||||
rowNumber: (hasHeader ? index + 2 : index + 1),
|
||||
phoneNumber: hasHeader ? cellByHeader(headers, cells, ['phone', 'phoneNumber', 'mobile', '手机号']) : cells[0],
|
||||
variables: {},
|
||||
};
|
||||
headers.forEach((header, cellIndex) => {
|
||||
if (!['phone', 'phoneNumber', 'mobile', '手机号'].includes(header)) {
|
||||
row.variables[header] = cells[cellIndex] ?? '';
|
||||
}
|
||||
});
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
export function splitImportLine(line: string, delimiter: ',' | '\t') {
|
||||
return line.split(delimiter).map((cell) => cell.trim().replace(/^"|"$/g, ''));
|
||||
}
|
||||
|
||||
export function cellByHeader(headers: string[], cells: string[], candidates: string[]) {
|
||||
const index = headers.findIndex((header) => candidates.includes(header));
|
||||
return index >= 0 ? cells[index] : undefined;
|
||||
}
|
||||
|
||||
export function normalizeCarrier(carrier?: string | null) {
|
||||
const value = String(carrier ?? '').trim().toLowerCase();
|
||||
if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile';
|
||||
if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom';
|
||||
if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom';
|
||||
if (['all', 'tri', '三网', '全网'].includes(value)) return 'all';
|
||||
return value || 'mobile';
|
||||
}
|
||||
|
||||
export function normalizeQueuePriority(queuePriority?: string | null): QueuePriority {
|
||||
return queuePriority === 'priority' ? 'priority' : 'normal';
|
||||
}
|
||||
|
||||
export function getPositiveConfigInteger(config: unknown, key: string, fallback: number) {
|
||||
if (config && typeof config === 'object' && !Array.isArray(config) && key in config) {
|
||||
const value = Number((config as Record<string, unknown>)[key]);
|
||||
if (Number.isInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function getNonNegativeConfigInteger(config: unknown, key: string, fallback: number) {
|
||||
if (config && typeof config === 'object' && !Array.isArray(config) && key in config) {
|
||||
const value = Number((config as Record<string, unknown>)[key]);
|
||||
if (Number.isInteger(value) && value >= 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string) {
|
||||
const normalized = normalizeCarrier(channelCarrier);
|
||||
return normalized === 'all' || normalized === targetCarrier;
|
||||
}
|
||||
|
||||
export function normalizeRegion(region?: string | null) {
|
||||
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
|
||||
}
|
||||
|
||||
export function matchTemplateContent(templateContent: string, actualContent: string) {
|
||||
if (templateContent === actualContent) {
|
||||
return {} as Record<string, string>;
|
||||
}
|
||||
const tokenPattern = /\$\{([a-zA-Z0-9_]+)\}/g;
|
||||
const names: string[] = [];
|
||||
let cursor = 0;
|
||||
let pattern = '^';
|
||||
for (const match of templateContent.matchAll(tokenPattern)) {
|
||||
const index = match.index ?? 0;
|
||||
pattern += escapeRegularExpression(templateContent.slice(cursor, index));
|
||||
pattern += '([\\s\\S]+?)';
|
||||
names.push(match[1]);
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
if (names.length === 0) {
|
||||
return null;
|
||||
}
|
||||
pattern += `${escapeRegularExpression(templateContent.slice(cursor))}$`;
|
||||
const matched = new RegExp(pattern, 'u').exec(actualContent);
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
const variables: Record<string, string> = {};
|
||||
for (let index = 0; index < names.length; index += 1) {
|
||||
const name = names[index];
|
||||
const value = matched[index + 1];
|
||||
if (variables[name] !== undefined && variables[name] !== value) {
|
||||
return null;
|
||||
}
|
||||
variables[name] = value;
|
||||
}
|
||||
return variables;
|
||||
}
|
||||
|
||||
export function escapeRegularExpression(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function isNationalChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }) {
|
||||
const itemProvince = normalizeRegion(item.province);
|
||||
const sendRegion = normalizeRegion(item.channel.sendRegion);
|
||||
return !itemProvince || itemProvince === '全国' || !sendRegion || sendRegion === '全国';
|
||||
}
|
||||
|
||||
export function isProvinceChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }, province?: string | null) {
|
||||
if (!province) {
|
||||
return false;
|
||||
}
|
||||
const target = normalizeRegion(province);
|
||||
const itemProvince = normalizeRegion(item.province);
|
||||
const sendRegion = normalizeRegion(item.channel.sendRegion);
|
||||
return itemProvince === target || sendRegion === target;
|
||||
}
|
||||
|
||||
export function validateInboundApplicationSrcId(
|
||||
srcId: string | undefined,
|
||||
application: {
|
||||
cmppApplicationExtension?: string | null;
|
||||
cmppAccessNumberFillEnabled?: boolean | null;
|
||||
cmppAccessNumberFillPrefix?: string | null;
|
||||
cmppClientSrcId?: string | null;
|
||||
},
|
||||
) {
|
||||
const submittedSrcId = srcId?.trim() ?? '';
|
||||
const applicationExtension = application.cmppApplicationExtension?.trim() ?? '';
|
||||
if (!applicationExtension) {
|
||||
return submittedSrcId || null;
|
||||
}
|
||||
|
||||
const fillPrefix = application.cmppAccessNumberFillEnabled
|
||||
? application.cmppAccessNumberFillPrefix?.trim() ?? ''
|
||||
: '';
|
||||
const expectedSrcId = application.cmppClientSrcId?.trim() || `${fillPrefix}${applicationExtension}`;
|
||||
if (!submittedSrcId || submittedSrcId !== expectedSrcId) {
|
||||
throw new BadRequestException(`CMPP Src_Id must equal the access number assigned to this application: ${expectedSrcId}`);
|
||||
}
|
||||
return submittedSrcId;
|
||||
}
|
||||
|
||||
export function composeUpstreamSrcId(baseSrcId: string, applicationExtension?: string | null) {
|
||||
const upstreamSrcId = `${baseSrcId.trim()}${applicationExtension?.trim() ?? ''}`;
|
||||
if (upstreamSrcId.length > 21) {
|
||||
throw new BadRequestException('channel base access number plus application extension must not exceed 21 digits');
|
||||
}
|
||||
return upstreamSrcId;
|
||||
}
|
||||
|
||||
export function positiveInteger(value: string | undefined, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function parseOptionalSequenceId(value: string | null | undefined) {
|
||||
if (!value) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 0xffffffff ? parsed : undefined;
|
||||
}
|
||||
|
||||
export function normalizeSubmitStatus(value: string): GatewaySubmitResultDto['submitStatus'] {
|
||||
return value === 'accepted' || value === 'rejected' || value === 'timeout' ? value : 'timeout';
|
||||
}
|
||||
|
||||
export function normalizeReceiptStatus(value: string): GatewayReceiptEventDto['receiptStatus'] {
|
||||
return value === 'delivered' || value === 'undelivered' || value === 'unknown' ? value : 'unknown';
|
||||
}
|
||||
|
||||
export function downstreamDeliveryAttemptKey(data: GatewayDownstreamSentDto) {
|
||||
return createHash('sha256').update([
|
||||
data.id,
|
||||
data.connectionId ?? '',
|
||||
data.sequenceId ?? '',
|
||||
data.messageId ?? '',
|
||||
data.sequenceId ? '' : data.sentAt ?? '',
|
||||
].join('\u0000')).digest('hex');
|
||||
}
|
||||
|
||||
export function shanghaiDateKey(now = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(now);
|
||||
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||
return `${values.year}-${values.month}-${values.day}`;
|
||||
}
|
||||
|
||||
export function bullmqConnection() {
|
||||
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
|
||||
return {
|
||||
host: redisUrl.hostname,
|
||||
port: Number(redisUrl.port || 6379),
|
||||
username: redisUrl.username || undefined,
|
||||
password: redisUrl.password || undefined,
|
||||
maxRetriesPerRequest: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function matchesApplicationSecret(data: GatewayInboundAuthDto, secretHash: string) {
|
||||
if (data.authSource && data.timestamp !== undefined) {
|
||||
const expected = createHash('md5')
|
||||
.update(Buffer.concat([
|
||||
Buffer.from(octetString(data.account, 6), 'binary'),
|
||||
Buffer.alloc(9),
|
||||
Buffer.from(secretHash),
|
||||
Buffer.from(String(data.timestamp).padStart(10, '0')),
|
||||
]))
|
||||
.digest('base64');
|
||||
return expected === data.authSource;
|
||||
}
|
||||
if (!data.password) {
|
||||
return false;
|
||||
}
|
||||
return data.password === secretHash || createHash('sha256').update(data.password).digest('hex') === secretHash;
|
||||
}
|
||||
|
||||
export function octetString(value: string, fixedLength: number) {
|
||||
if (value.length === fixedLength) {
|
||||
return value;
|
||||
}
|
||||
if (value.length > fixedLength) {
|
||||
return value.slice(value.length - fixedLength);
|
||||
}
|
||||
return value + '\0'.repeat(fixedLength - value.length);
|
||||
}
|
||||
|
||||
export function hasRecoveryAuditStateChanged(
|
||||
previous: Record<string, unknown> | null,
|
||||
current: Record<string, unknown>,
|
||||
) {
|
||||
if (!previous) {
|
||||
return true;
|
||||
}
|
||||
return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason']
|
||||
.some((key) => (previous[key] ?? null) !== (current[key] ?? null));
|
||||
}
|
||||
|
||||
export function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) {
|
||||
const explicit = String(data.failureCategory ?? '').trim();
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
if (data.state === 'success' || data.state === 'running') {
|
||||
return null;
|
||||
}
|
||||
if (data.lastSkipReason === 'backoff') {
|
||||
return 'backoff';
|
||||
}
|
||||
if (data.lastSkipReason === 'locked') {
|
||||
return 'lock_contended';
|
||||
}
|
||||
if (data.lastSkipReason === 'lock_lost') {
|
||||
return 'lock_lost';
|
||||
}
|
||||
if (data.state === 'waiting_connection') {
|
||||
return 'client_disconnected';
|
||||
}
|
||||
if (data.state === 'partial') {
|
||||
return 'partial_delivery_failed';
|
||||
}
|
||||
if (data.state === 'failed' && data.lastError) {
|
||||
return 'flush_failed';
|
||||
}
|
||||
return data.state ? 'unknown' : null;
|
||||
}
|
||||
|
||||
export type ChannelCandidate = {
|
||||
channelId: string;
|
||||
carrier?: string | null;
|
||||
province?: string | null;
|
||||
channel: {
|
||||
carrier?: string | null;
|
||||
sendRegion?: string | null;
|
||||
status: string;
|
||||
connectionStates?: Array<{ status: string; currentConnections: number; desiredConnections: number }>;
|
||||
};
|
||||
};
|
||||
|
||||
export function isChannelSendAvailable(channel: ChannelCandidate['channel']) {
|
||||
if (channel.status !== 'active') {
|
||||
return false;
|
||||
}
|
||||
return (channel.connectionStates ?? []).some((connection) =>
|
||||
connection.desiredConnections > 0 && connection.currentConnections > 0 && connection.status === 'connected',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve database priority order while preferring matching province routes
|
||||
* over national fallbacks. Filtering remains deterministic and side-effect free.
|
||||
*/
|
||||
export function selectChannelCandidate<T extends ChannelCandidate>(
|
||||
items: T[],
|
||||
options: {
|
||||
carrier: string;
|
||||
province?: string | null;
|
||||
forceNational?: boolean;
|
||||
excludedChannelIds: ReadonlySet<string>;
|
||||
approvedChannelIds: ReadonlySet<string>;
|
||||
},
|
||||
) {
|
||||
const eligible = items.filter((item) =>
|
||||
!options.excludedChannelIds.has(item.channelId)
|
||||
&& options.approvedChannelIds.has(item.channelId)
|
||||
&& normalizeCarrier(item.carrier) === options.carrier
|
||||
&& isCarrierCompatible(item.channel.carrier, options.carrier),
|
||||
);
|
||||
const provinceCandidates = options.forceNational
|
||||
? []
|
||||
: eligible.filter((item) => isProvinceChannel(item, options.province));
|
||||
const nationalCandidates = eligible.filter((item) => isNationalChannel(item));
|
||||
return [...provinceCandidates, ...nationalCandidates].find((item) => isChannelSendAvailable(item.channel));
|
||||
}
|
||||
|
||||
export type ReceiptSegmentAudit = {
|
||||
segmentTotal?: number | null;
|
||||
receiptStatus?: string | null;
|
||||
rawStatus?: string | null;
|
||||
errorCode?: string | null;
|
||||
errorMessage?: string | null;
|
||||
deliveredAt?: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate one message's terminal state without reading or writing storage.
|
||||
* A failed segment wins; success requires every expected segment to be delivered.
|
||||
*/
|
||||
export function aggregateReceiptSegmentState(
|
||||
audits: ReceiptSegmentAudit[],
|
||||
billingUnits: number | null | undefined,
|
||||
data: GatewayReceiptEventDto,
|
||||
deliveredAt: Date,
|
||||
) {
|
||||
if (audits.length === 0) {
|
||||
const status = data.receiptStatus === 'delivered'
|
||||
? 'delivered'
|
||||
: data.receiptStatus === 'unknown'
|
||||
? 'unknown'
|
||||
: 'failed';
|
||||
return {
|
||||
terminal: true,
|
||||
segmentTotal: 1,
|
||||
status,
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
deliveredAt,
|
||||
};
|
||||
}
|
||||
|
||||
const segmentTotal = Math.max(
|
||||
1,
|
||||
Number(billingUnits ?? 1),
|
||||
...audits.map((audit) => Number(audit.segmentTotal ?? 1)),
|
||||
);
|
||||
const received = audits.filter((audit) => Boolean(audit.receiptStatus));
|
||||
const failed = received.find((audit) => !['delivered', 'unknown'].includes(audit.receiptStatus ?? ''));
|
||||
if (failed) {
|
||||
return {
|
||||
terminal: true,
|
||||
segmentTotal,
|
||||
status: 'failed',
|
||||
receiptStatus: failed.receiptStatus ?? 'undelivered',
|
||||
rawStatus: failed.rawStatus ?? data.rawStatus,
|
||||
errorCode: failed.errorCode ?? data.errorCode,
|
||||
errorMessage: failed.errorMessage ?? data.errorMessage,
|
||||
deliveredAt: failed.deliveredAt ?? deliveredAt,
|
||||
};
|
||||
}
|
||||
const delivered = received.filter((audit) => audit.receiptStatus === 'delivered');
|
||||
if (delivered.length >= segmentTotal) {
|
||||
const latest = delivered.reduce((current, audit) =>
|
||||
(audit.deliveredAt?.getTime() ?? 0) > (current.deliveredAt?.getTime() ?? 0) ? audit : current);
|
||||
return {
|
||||
terminal: true,
|
||||
segmentTotal,
|
||||
status: 'delivered',
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: latest.rawStatus ?? data.rawStatus,
|
||||
errorCode: latest.errorCode ?? undefined,
|
||||
errorMessage: undefined,
|
||||
deliveredAt: latest.deliveredAt ?? deliveredAt,
|
||||
};
|
||||
}
|
||||
if (received.length >= segmentTotal) {
|
||||
const latest = received[received.length - 1];
|
||||
return {
|
||||
terminal: true,
|
||||
segmentTotal,
|
||||
status: 'unknown',
|
||||
receiptStatus: 'unknown',
|
||||
rawStatus: latest.rawStatus ?? data.rawStatus,
|
||||
errorCode: latest.errorCode ?? data.errorCode,
|
||||
errorMessage: latest.errorMessage ?? data.errorMessage,
|
||||
deliveredAt: latest.deliveredAt ?? deliveredAt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
terminal: false,
|
||||
segmentTotal,
|
||||
status: 'submitted',
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
deliveredAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function isSameUpstreamEndpointIdentity(
|
||||
left: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
right: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
) {
|
||||
return left.account.trim() === right.account.trim()
|
||||
&& left.gatewayHost.trim().toLowerCase() === right.gatewayHost.trim().toLowerCase()
|
||||
&& left.gatewayPort === right.gatewayPort
|
||||
&& left.protocol.trim().toUpperCase() === right.protocol.trim().toUpperCase()
|
||||
&& left.cmppVersion.trim() === right.cmppVersion.trim();
|
||||
}
|
||||
|
||||
export function receiptEventKey(data: GatewayReceiptEventDto, channelId = data.channelId) {
|
||||
return createHash('sha256').update([
|
||||
channelId,
|
||||
data.gatewayMessageId,
|
||||
data.phoneNumber?.trim() ?? '',
|
||||
data.receiptStatus,
|
||||
data.rawStatus.trim(),
|
||||
data.errorCode ?? '',
|
||||
].join('\u0000')).digest('hex');
|
||||
}
|
||||
@@ -111,6 +111,7 @@ function createPrismaMock() {
|
||||
},
|
||||
smsSendTask: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue({ id: 'review-task-1', status: 'rejected' }),
|
||||
},
|
||||
smsBatchTask: {
|
||||
create: jest.fn().mockResolvedValue(task),
|
||||
@@ -374,10 +375,19 @@ function createService(
|
||||
reviewReason: '企业应用已配置模板不匹配进入人工审核',
|
||||
}),
|
||||
} as unknown as RiskReviewService;
|
||||
const service = new SendChainService(prisma as never, billing, riskReview, openApi as never);
|
||||
const phoneFrequency = {
|
||||
reserve: jest.fn().mockResolvedValue(new Map()),
|
||||
};
|
||||
const service = new SendChainService(
|
||||
prisma as never,
|
||||
billing,
|
||||
riskReview,
|
||||
phoneFrequency as never,
|
||||
openApi as never,
|
||||
);
|
||||
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
||||
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue(undefined);
|
||||
return { service, prisma, billing, riskReview };
|
||||
return { service, prisma, billing, riskReview, phoneFrequency };
|
||||
}
|
||||
|
||||
describe('SendChainService', () => {
|
||||
@@ -451,6 +461,94 @@ describe('SendChainService', () => {
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
it('rejects only phones that hit application frequency rules and excludes them from billing', async () => {
|
||||
const { service, prisma, billing, phoneFrequency } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
phoneFrequency.reserve.mockResolvedValue(new Map([
|
||||
['13800000002', {
|
||||
code: 'PHONE_FREQUENCY_LIMIT',
|
||||
reason: '单号码5分钟发送频次命中:本周期最多5条,当前第6条',
|
||||
}],
|
||||
]));
|
||||
(billing.estimateSmsCost as jest.Mock).mockReturnValue({
|
||||
billingUnitsPerMessage: 1,
|
||||
totalBillingUnits: 1,
|
||||
unitPrice: 3,
|
||||
amountCents: 3,
|
||||
});
|
||||
|
||||
await service.createBatchTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001', '13800000002'],
|
||||
});
|
||||
|
||||
expect(phoneFrequency.reserve).toHaveBeenCalledWith(
|
||||
'tenant-1',
|
||||
'app-1',
|
||||
['13800000001', '13800000002'],
|
||||
'client',
|
||||
);
|
||||
expect(billing.estimateSmsCost).toHaveBeenCalledWith(expect.objectContaining({ phoneCount: 1 }));
|
||||
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', amountCents: 3 }),
|
||||
expect.objectContaining({
|
||||
phoneNumber: '13800000002',
|
||||
status: 'submit_failed',
|
||||
submitStatus: 'rejected',
|
||||
errorCode: 'PHONE_FREQUENCY_LIMIT',
|
||||
amountCents: 0,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3 }));
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
it('marks a batch and its pending review task rejected when every phone hits frequency rules', async () => {
|
||||
const { service, prisma, riskReview, phoneFrequency } = createService();
|
||||
(riskReview.evaluateTask as jest.Mock).mockResolvedValue({
|
||||
status: 'pending_review',
|
||||
reason: '命中人工审核规则',
|
||||
task: { id: 'review-task-1' },
|
||||
});
|
||||
phoneFrequency.reserve.mockResolvedValue(new Map([
|
||||
['13800000001', {
|
||||
code: 'PHONE_FREQUENCY_LIMIT',
|
||||
reason: '单号码5分钟发送频次命中:本周期最多5条,当前第6条',
|
||||
}],
|
||||
]));
|
||||
|
||||
await service.createBatchTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001'],
|
||||
});
|
||||
|
||||
expect(prisma.smsSendTask.update).toHaveBeenCalledWith({
|
||||
where: { id: 'review-task-1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'rejected',
|
||||
riskDecision: 'block',
|
||||
reviewReason: null,
|
||||
rejectReason: expect.stringContaining('单号码5分钟发送频次命中'),
|
||||
}),
|
||||
});
|
||||
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
status: 'rejected',
|
||||
auditStatus: 'rejected',
|
||||
reviewReason: null,
|
||||
rejectReason: expect.stringContaining('单号码5分钟发送频次命中'),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('persists the review task id on every message waiting for manual review', async () => {
|
||||
const { service, prisma, riskReview } = createService();
|
||||
(riskReview.evaluateTask as jest.Mock).mockResolvedValue({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,323 @@
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { downstreamPendingTimeoutHours } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import { SendAccountingService } from './send-accounting.service';
|
||||
import { SendDownstreamDeliveryService } from './send-downstream-delivery.service';
|
||||
import { SendDownstreamStateService } from './send-downstream-state.service';
|
||||
import { SendGatewayResultService } from './send-gateway-result.service';
|
||||
import { SendReceiptService } from './send-receipt.service';
|
||||
import { SendRetryService } from './send-retry.service';
|
||||
import { SendTimeoutService } from './send-timeout.service';
|
||||
|
||||
|
||||
export type SendCompletionCallbacks = Record<string, never>;
|
||||
export type SendCompletionFacade = SendCompletionService & SendSubmissionService;
|
||||
|
||||
/**
|
||||
* R10 internal compatibility facade. SendChainService remains the only public NestJS provider.
|
||||
*/
|
||||
export class SendCompletionService {
|
||||
private readonly gatewayResult: SendGatewayResultService;
|
||||
private readonly receipt: SendReceiptService;
|
||||
private readonly retry: SendRetryService;
|
||||
private readonly accounting: SendAccountingService;
|
||||
private readonly downstreamState: SendDownstreamStateService;
|
||||
private readonly downstreamDelivery: SendDownstreamDeliveryService;
|
||||
private readonly timeout: SendTimeoutService;
|
||||
|
||||
constructor(
|
||||
prisma: PrismaService,
|
||||
billing: BillingService,
|
||||
openApi: OpenApiService | undefined,
|
||||
facade: SendCompletionFacade,
|
||||
callbacks: SendCompletionCallbacks = {},
|
||||
) {
|
||||
this.gatewayResult = new SendGatewayResultService(prisma, billing, openApi, facade, callbacks);
|
||||
this.receipt = new SendReceiptService(prisma, billing, openApi, facade, callbacks);
|
||||
this.retry = new SendRetryService(prisma, billing, openApi, facade, callbacks);
|
||||
this.accounting = new SendAccountingService(prisma, billing, openApi, facade, callbacks);
|
||||
this.downstreamState = new SendDownstreamStateService(prisma, billing, openApi, facade, callbacks);
|
||||
this.downstreamDelivery = new SendDownstreamDeliveryService(prisma, billing, openApi, facade, callbacks);
|
||||
this.timeout = new SendTimeoutService(prisma, billing, openApi, facade, callbacks);
|
||||
}
|
||||
|
||||
async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) {
|
||||
return this.gatewayResult.handleSubmitSegmentResult(data);
|
||||
}
|
||||
|
||||
async resolveSubmitRecordForGatewaySegmentResult(
|
||||
messageRecordId: string,
|
||||
data: GatewaySubmitSegmentResultDto,
|
||||
) {
|
||||
return this.gatewayResult.resolveSubmitRecordForGatewaySegmentResult(messageRecordId, data);
|
||||
}
|
||||
|
||||
async handleSubmitResult(data: GatewaySubmitResultDto) {
|
||||
return this.gatewayResult.handleSubmitResult(data);
|
||||
}
|
||||
|
||||
async resolveSubmitRecordForGatewayResult(messageRecordId: string, data: GatewaySubmitResultDto) {
|
||||
return this.gatewayResult.resolveSubmitRecordForGatewayResult(messageRecordId, data);
|
||||
}
|
||||
|
||||
smsMessageSegmentAuditDelegate() {
|
||||
return this.gatewayResult.smsMessageSegmentAuditDelegate();
|
||||
}
|
||||
|
||||
async recordSubmitSegments(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
channelId?: string | null;
|
||||
submitId?: string | null;
|
||||
billingUnits?: number | null;
|
||||
},
|
||||
data: GatewaySubmitResultDto,
|
||||
submittedAt: Date,
|
||||
) {
|
||||
return this.gatewayResult.recordSubmitSegments(message, data, submittedAt);
|
||||
}
|
||||
|
||||
async findMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
|
||||
return this.gatewayResult.findMessageByGatewayEvent(messageId, gatewayMessageId);
|
||||
}
|
||||
|
||||
async requireMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
|
||||
return this.gatewayResult.requireMessageByGatewayEvent(messageId, gatewayMessageId);
|
||||
}
|
||||
|
||||
async intakeReceipt(data: GatewayReceiptEventDto) {
|
||||
return this.receipt.intakeReceipt(data);
|
||||
}
|
||||
|
||||
async processPendingUpstreamReceiptInbox(limit = 100) {
|
||||
return this.receipt.processPendingUpstreamReceiptInbox(limit);
|
||||
}
|
||||
|
||||
async processUpstreamReceiptInboxRecord(id: string) {
|
||||
return this.receipt.processUpstreamReceiptInboxRecord(id);
|
||||
}
|
||||
|
||||
async runUpstreamReceiptInboxScan() {
|
||||
return this.receipt.runUpstreamReceiptInboxScan();
|
||||
}
|
||||
|
||||
async handleReceipt(
|
||||
data: GatewayReceiptEventDto,
|
||||
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
) {
|
||||
return this.receipt.handleReceipt(data, incomingIdentity);
|
||||
}
|
||||
|
||||
async recordReceiptSegment(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
channelId?: string | null;
|
||||
submitId?: string | null;
|
||||
billingUnits?: number | null;
|
||||
},
|
||||
data: GatewayReceiptEventDto,
|
||||
deliveredAt: Date,
|
||||
submitRecordId?: string,
|
||||
) {
|
||||
return this.receipt.recordReceiptSegment(message, data, deliveredAt, submitRecordId);
|
||||
}
|
||||
|
||||
async aggregateReceiptSegments(
|
||||
message: {
|
||||
id: string;
|
||||
billingUnits?: number | null;
|
||||
},
|
||||
data: GatewayReceiptEventDto,
|
||||
deliveredAt: Date,
|
||||
submitRecordId?: string,
|
||||
submitId?: string,
|
||||
) {
|
||||
return this.receipt.aggregateReceiptSegments(message, data, deliveredAt, submitRecordId, submitId);
|
||||
}
|
||||
|
||||
async resolveReceiptMessage(
|
||||
data: GatewayReceiptEventDto,
|
||||
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
) {
|
||||
return this.receipt.resolveReceiptMessage(data, incomingIdentity);
|
||||
}
|
||||
|
||||
async recordGatewaySubmitDeadLetter(data: GatewaySubmitDeadLetterDto) {
|
||||
return this.retry.recordGatewaySubmitDeadLetter(data);
|
||||
}
|
||||
|
||||
async requeueGatewaySubmitDeadLetter(id: string, data: RequeueGatewaySubmitExceptionDto = {}) {
|
||||
return this.retry.requeueGatewaySubmitDeadLetter(id, data);
|
||||
}
|
||||
|
||||
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
|
||||
return this.retry.recoverStaleGatewaySubmitRequeues(now);
|
||||
}
|
||||
|
||||
async retryMessageIfAllowed(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
batchTaskId: string;
|
||||
applicationId?: string | null;
|
||||
templateId?: string | null;
|
||||
signatureId?: string | null;
|
||||
submitId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
queuedAt?: Date;
|
||||
clientSrcId?: string | null;
|
||||
applicationExtension?: string | null;
|
||||
carrier?: string | null;
|
||||
province?: string | null;
|
||||
},
|
||||
reason: string,
|
||||
sourceSubmitRecordId?: string,
|
||||
) {
|
||||
return this.retry.retryMessageIfAllowed(message, reason, sourceSubmitRecordId);
|
||||
}
|
||||
|
||||
async chargeAcceptedMessage(message: {
|
||||
tenantId: string;
|
||||
applicationId?: string | null;
|
||||
batchTaskId: string;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
unitPrice: number | bigint;
|
||||
amountCents: number | bigint;
|
||||
}) {
|
||||
return this.accounting.chargeAcceptedMessage(message);
|
||||
}
|
||||
|
||||
async releaseMessageReservation(
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
return this.accounting.releaseMessageReservation(message, remark);
|
||||
}
|
||||
|
||||
async refundMessage(
|
||||
message: { tenantId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
return this.accounting.refundMessage(message, remark);
|
||||
}
|
||||
|
||||
async listPendingDownstreamDeliveries(data: GatewayPendingDeliveryQueryDto) {
|
||||
return this.downstreamState.listPendingDownstreamDeliveries(data);
|
||||
}
|
||||
|
||||
async markDownstreamDeliveryDelivered(id: string) {
|
||||
return this.downstreamState.markDownstreamDeliveryDelivered(id);
|
||||
}
|
||||
|
||||
async markDownstreamDeliverySent(data: GatewayDownstreamSentDto) {
|
||||
return this.downstreamState.markDownstreamDeliverySent(data);
|
||||
}
|
||||
|
||||
async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) {
|
||||
return this.downstreamState.acknowledgeDownstreamDelivery(data);
|
||||
}
|
||||
|
||||
async markDownstreamDeliveryFailed(
|
||||
id: string,
|
||||
errorMessage?: string,
|
||||
failureType: GatewayDownstreamFailureType = 'send_failed',
|
||||
attempt?: GatewayDownstreamSentDto,
|
||||
) {
|
||||
return this.downstreamState.markDownstreamDeliveryFailed(id, errorMessage, failureType, attempt);
|
||||
}
|
||||
|
||||
async recordGatewayDownstreamRecoveryStatus(data: GatewayDownstreamRecoveryStatusDto) {
|
||||
return this.downstreamState.recordGatewayDownstreamRecoveryStatus(data);
|
||||
}
|
||||
|
||||
async requeueDownstreamDelivery(id: string) {
|
||||
return this.downstreamState.requeueDownstreamDelivery(id);
|
||||
}
|
||||
|
||||
async recoverStaleDownstreamManualRequeues(now = new Date()) {
|
||||
return this.downstreamState.recoverStaleDownstreamManualRequeues(now);
|
||||
}
|
||||
|
||||
async batchRequeueDownstreamDeliveries(ids: string[]) {
|
||||
return this.downstreamState.batchRequeueDownstreamDeliveries(ids);
|
||||
}
|
||||
|
||||
async handleUplink(data: GatewayUplinkEventDto) {
|
||||
return this.downstreamDelivery.handleUplink(data);
|
||||
}
|
||||
|
||||
async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) {
|
||||
return this.downstreamDelivery.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId);
|
||||
}
|
||||
|
||||
async queueAndTryDownstreamDelivery(data: {
|
||||
tenantId: string;
|
||||
applicationId?: string | null;
|
||||
messageRecordId?: string | null;
|
||||
messageId?: string | null;
|
||||
deliveryType: 'receipt' | 'uplink';
|
||||
payload: Record<string, unknown>;
|
||||
}) {
|
||||
return this.downstreamDelivery.queueAndTryDownstreamDelivery(data);
|
||||
}
|
||||
|
||||
async resolveUplinkMatch(
|
||||
data: GatewayUplinkEventDto,
|
||||
channel: { id: string; srcId?: string | null },
|
||||
): Promise<{
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
messageRecordId?: string;
|
||||
matchStatus: string;
|
||||
matchReason: string;
|
||||
candidates: UplinkMatchCandidateInput[];
|
||||
}> {
|
||||
return this.downstreamDelivery.resolveUplinkMatch(data, channel);
|
||||
}
|
||||
|
||||
async recordCmppFailureReceipt(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
applicationId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
cmppSubmitSequenceId?: string | null;
|
||||
cmppSubmitGroupMessageId?: string | null;
|
||||
},
|
||||
errorCode: string,
|
||||
reason: string,
|
||||
) {
|
||||
return this.downstreamDelivery.recordCmppFailureReceipt(message, errorCode, reason);
|
||||
}
|
||||
|
||||
async postGatewayControl(path: string, payload: unknown) {
|
||||
return this.downstreamDelivery.postGatewayControl(path, payload);
|
||||
}
|
||||
|
||||
async markUnknownTimeout(data: TimeoutUnknownDto) {
|
||||
return this.timeout.markUnknownTimeout(data);
|
||||
}
|
||||
|
||||
async markExpiredDownstreamDeliveries(olderThanHours = downstreamPendingTimeoutHours()) {
|
||||
return this.timeout.markExpiredDownstreamDeliveries(olderThanHours);
|
||||
}
|
||||
|
||||
async runReceiptTimeoutScan() {
|
||||
return this.timeout.runReceiptTimeoutScan();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
|
||||
|
||||
/**
|
||||
* R10 downstreamDelivery implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendDownstreamDeliveryService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async handleUplink(data: GatewayUplinkEventDto) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
|
||||
if (!channel) {
|
||||
throw new NotFoundException('SMS channel not found');
|
||||
}
|
||||
const match = await this.facade.resolveUplinkMatch(data, channel);
|
||||
const record = await this.prisma.smsUplinkMessage.create({
|
||||
data: {
|
||||
tenantId: match.tenantId,
|
||||
applicationId: match.applicationId,
|
||||
messageRecordId: match.messageRecordId,
|
||||
channelId: data.channelId,
|
||||
messageId: data.messageId,
|
||||
sequenceId: data.sequenceId,
|
||||
phoneNumber: data.phoneNumber,
|
||||
destId: data.destId,
|
||||
content: data.content,
|
||||
matchStatus: match.matchStatus,
|
||||
matchReason: match.matchReason,
|
||||
receivedAt: data.receivedAt ? new Date(data.receivedAt) : new Date(),
|
||||
},
|
||||
});
|
||||
if (match.candidates.length > 0) {
|
||||
await this.prisma.smsUplinkMatchCandidate.createMany({
|
||||
data: match.candidates.map((candidate) => ({
|
||||
uplinkMessageId: record.id,
|
||||
tenantId: candidate.tenantId,
|
||||
applicationId: candidate.applicationId,
|
||||
messageRecordId: candidate.messageRecordId,
|
||||
matchSource: candidate.matchSource,
|
||||
confidence: candidate.confidence,
|
||||
reason: candidate.reason,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
if (match.tenantId && match.applicationId) {
|
||||
await this.facade.queueAndTryDownstreamDelivery({
|
||||
tenantId: match.tenantId,
|
||||
applicationId: match.applicationId,
|
||||
messageRecordId: match.messageRecordId,
|
||||
messageId: data.messageId,
|
||||
deliveryType: 'uplink',
|
||||
payload: {
|
||||
messageId: data.messageId,
|
||||
applicationId: match.applicationId,
|
||||
phoneNumber: data.phoneNumber,
|
||||
destId: data.destId,
|
||||
content: data.content,
|
||||
receivedAt: record.receivedAt.toISOString(),
|
||||
uplinkMessageId: record.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) {
|
||||
const candidate = await this.prisma.smsUplinkMatchCandidate.findFirst({
|
||||
where: { id: candidateId, uplinkMessageId },
|
||||
include: {
|
||||
application: { select: { id: true, name: true, cmppAccount: true } },
|
||||
messageRecord: { select: { id: true, messageId: true, content: true } },
|
||||
uplinkMessage: true,
|
||||
},
|
||||
});
|
||||
if (!candidate) {
|
||||
throw new NotFoundException('Uplink match candidate not found');
|
||||
}
|
||||
if (candidate.status === 'rejected') {
|
||||
throw new BadRequestException('该候选已被排除,不能认领');
|
||||
}
|
||||
if (candidate.uplinkMessage.matchStatus === 'matched' && candidate.status !== 'claimed') {
|
||||
throw new BadRequestException('该上行记录已完成匹配,不能重复认领');
|
||||
}
|
||||
const claimedAt = new Date();
|
||||
const messageId = candidate.uplinkMessage.messageId ?? candidate.messageRecord?.messageId ?? null;
|
||||
const [updatedUplink] = await this.prisma.$transaction([
|
||||
this.prisma.smsUplinkMessage.update({
|
||||
where: { id: uplinkMessageId },
|
||||
data: {
|
||||
tenantId: candidate.tenantId,
|
||||
applicationId: candidate.applicationId,
|
||||
messageRecordId: candidate.messageRecordId,
|
||||
messageId,
|
||||
matchStatus: 'matched',
|
||||
matchReason: `人工认领:${candidate.reason ?? candidate.matchSource}`,
|
||||
},
|
||||
}),
|
||||
this.prisma.smsUplinkMatchCandidate.updateMany({
|
||||
where: {
|
||||
uplinkMessageId,
|
||||
id: { not: candidate.id },
|
||||
status: 'pending',
|
||||
},
|
||||
data: { status: 'rejected' },
|
||||
}),
|
||||
this.prisma.smsUplinkMatchCandidate.update({
|
||||
where: { id: candidate.id },
|
||||
data: {
|
||||
status: 'claimed',
|
||||
claimedAt,
|
||||
claimedById: operatorId,
|
||||
},
|
||||
}),
|
||||
this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: candidate.tenantId,
|
||||
userId: operatorId,
|
||||
action: 'gateway.uplink_manual_claim',
|
||||
resource: 'sms_uplink_message',
|
||||
resourceId: uplinkMessageId,
|
||||
detail: {
|
||||
candidateId: candidate.id,
|
||||
applicationId: candidate.applicationId,
|
||||
applicationName: candidate.application.name,
|
||||
messageRecordId: candidate.messageRecordId,
|
||||
messageId,
|
||||
matchSource: candidate.matchSource,
|
||||
phoneNumber: candidate.uplinkMessage.phoneNumber,
|
||||
destId: candidate.uplinkMessage.destId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await this.facade.queueAndTryDownstreamDelivery({
|
||||
tenantId: candidate.tenantId,
|
||||
applicationId: candidate.applicationId,
|
||||
messageRecordId: candidate.messageRecordId,
|
||||
messageId,
|
||||
deliveryType: 'uplink',
|
||||
payload: {
|
||||
messageId,
|
||||
applicationId: candidate.applicationId,
|
||||
phoneNumber: candidate.uplinkMessage.phoneNumber,
|
||||
destId: candidate.uplinkMessage.destId,
|
||||
content: candidate.uplinkMessage.content,
|
||||
receivedAt: candidate.uplinkMessage.receivedAt.toISOString(),
|
||||
manualClaim: true,
|
||||
uplinkMessageId,
|
||||
},
|
||||
});
|
||||
return this.prisma.smsUplinkMessage.findUnique({
|
||||
where: { id: updatedUplink.id },
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
channel: true,
|
||||
messageRecord: { include: { application: true } },
|
||||
matchCandidates: {
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
messageRecord: { include: { application: true, tenant: true, channel: true } },
|
||||
},
|
||||
orderBy: [{ status: 'asc' }, { confidence: 'desc' }, { createdAt: 'asc' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async queueAndTryDownstreamDelivery(data: {
|
||||
tenantId: string;
|
||||
applicationId?: string | null;
|
||||
messageRecordId?: string | null;
|
||||
messageId?: string | null;
|
||||
deliveryType: 'receipt' | 'uplink';
|
||||
payload: Record<string, unknown>;
|
||||
}) {
|
||||
if (!data.applicationId) {
|
||||
return null;
|
||||
}
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: data.applicationId },
|
||||
select: {
|
||||
cmppAccount: true,
|
||||
interfaceEnabled: true,
|
||||
status: true,
|
||||
downstreamReceiptRetryEnabled: true,
|
||||
downstreamUplinkRetryEnabled: true,
|
||||
httpConfig: true,
|
||||
},
|
||||
});
|
||||
const deliveryAllowed = application?.status === 'active' || application?.status === 'disabling';
|
||||
if (deliveryAllowed) {
|
||||
try {
|
||||
await this.openApi?.queueWebhookEvent({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
messageRecordId: data.messageRecordId,
|
||||
messageId: data.messageId,
|
||||
uplinkMessageId: typeof data.payload.uplinkMessageId === 'string' ? data.payload.uplinkMessageId : undefined,
|
||||
eventType: data.deliveryType,
|
||||
payload: data.payload,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
if (application?.interfaceEnabled !== true) {
|
||||
return null;
|
||||
}
|
||||
const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload };
|
||||
const dedupeKey = data.deliveryType === 'receipt' && data.messageRecordId
|
||||
? `receipt:${data.messageRecordId}`
|
||||
: data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string'
|
||||
? `uplink:${data.payload.uplinkMessageId}`
|
||||
: null;
|
||||
let delivery;
|
||||
try {
|
||||
delivery = await this.prisma.cmppDownstreamDelivery.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
messageRecordId: data.messageRecordId,
|
||||
messageId: data.messageId,
|
||||
dedupeKey,
|
||||
deliveryType: data.deliveryType,
|
||||
payload,
|
||||
retryEnabled: deliveryAllowed && (data.deliveryType === 'uplink'
|
||||
? application?.downstreamUplinkRetryEnabled ?? true
|
||||
: application?.downstreamReceiptRetryEnabled ?? true),
|
||||
status: deliveryAllowed ? 'pending' : 'abandoned',
|
||||
lastError: deliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
dedupeKey
|
||||
&& error instanceof Prisma.PrismaClientKnownRequestError
|
||||
&& error.code === 'P2002'
|
||||
) {
|
||||
const existing = await this.prisma.cmppDownstreamDelivery.findUnique({
|
||||
where: { dedupeKey },
|
||||
});
|
||||
if (existing) {
|
||||
this.logger.warn(`downstream_delivery_deduplicated ${JSON.stringify({
|
||||
deliveryType: data.deliveryType,
|
||||
messageRecordId: data.messageRecordId,
|
||||
messageId: data.messageId,
|
||||
dedupeKey,
|
||||
deliveryId: existing.id,
|
||||
})}`);
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!deliveryAllowed) {
|
||||
return delivery;
|
||||
}
|
||||
try {
|
||||
const result = await this.facade.postGatewayControl(
|
||||
data.deliveryType === 'receipt' ? '/downstream/receipt' : '/downstream/uplink',
|
||||
{ deliveryId: delivery.id, ...payload },
|
||||
) as GatewayControlDeliveryResult;
|
||||
if (result.sent || result.delivered) {
|
||||
await this.facade.markDownstreamDeliverySent({ id: delivery.id, ...result });
|
||||
} else if (result.reasonCode === 'SUBMIT_RESPONSE_PENDING') {
|
||||
return delivery;
|
||||
} else {
|
||||
await this.facade.markDownstreamDeliveryFailed(
|
||||
delivery.id,
|
||||
downstreamControlFailureMessage(result),
|
||||
result.retryable === false ? 'unrecoverable' : 'send_failed',
|
||||
{ id: delivery.id, ...result },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
await this.facade.markDownstreamDeliveryFailed(delivery.id, error instanceof Error ? error.message : 'Gateway control delivery failed');
|
||||
}
|
||||
return delivery;
|
||||
}
|
||||
|
||||
async resolveUplinkMatch(
|
||||
data: GatewayUplinkEventDto,
|
||||
channel: { id: string; srcId?: string | null },
|
||||
): Promise<{
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
messageRecordId?: string;
|
||||
matchStatus: string;
|
||||
matchReason: string;
|
||||
candidates: UplinkMatchCandidateInput[];
|
||||
}> {
|
||||
if (data.messageId) {
|
||||
const message = await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } });
|
||||
if (message?.tenantId) {
|
||||
return {
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId ?? undefined,
|
||||
messageRecordId: message.id,
|
||||
matchStatus: message.applicationId ? 'matched' : 'unmatched',
|
||||
matchReason: message.applicationId ? 'messageId 精确匹配' : 'messageId 匹配到下发记录但无应用',
|
||||
candidates: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const accessNumber = data.destId || channel.srcId || '';
|
||||
const accessRoutes = accessNumber
|
||||
? await this.prisma.channelRouteRule.findMany({
|
||||
where: {
|
||||
applicationId: { not: null },
|
||||
status: 'active',
|
||||
group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } },
|
||||
},
|
||||
select: { applicationId: true },
|
||||
take: 10,
|
||||
})
|
||||
: [];
|
||||
const accessApplicationIds = [...new Set(accessRoutes.map((route) => route.applicationId).filter((value): value is string => Boolean(value)))];
|
||||
const accessApplications = accessApplicationIds.length > 0
|
||||
? await this.prisma.smsApplication.findMany({
|
||||
where: { id: { in: accessApplicationIds }, status: 'active' },
|
||||
select: { id: true, tenantId: true, name: true },
|
||||
})
|
||||
: [];
|
||||
if (accessApplications.length === 1) {
|
||||
return {
|
||||
tenantId: accessApplications[0].tenantId,
|
||||
applicationId: accessApplications[0].id,
|
||||
matchStatus: 'matched',
|
||||
matchReason: '接入号唯一匹配应用',
|
||||
candidates: [],
|
||||
};
|
||||
}
|
||||
if (accessApplications.length > 1) {
|
||||
return {
|
||||
matchStatus: 'ambiguous',
|
||||
matchReason: '接入号匹配多个应用',
|
||||
candidates: accessApplications.map((application) => ({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
matchSource: 'access_number',
|
||||
confidence: 70,
|
||||
reason: `接入号 ${accessNumber} 可匹配应用 ${application.name}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const windowHours = Number(process.env.UPLINK_MATCH_WINDOW_HOURS ?? 72);
|
||||
const since = new Date(Date.now() - Math.max(1, windowHours) * 60 * 60 * 1000);
|
||||
const recentMessages = await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
phoneNumber: data.phoneNumber,
|
||||
tenantId: { not: null },
|
||||
applicationId: { not: null },
|
||||
submittedAt: { gte: since },
|
||||
},
|
||||
orderBy: { submittedAt: 'desc' },
|
||||
take: 2,
|
||||
});
|
||||
const matchableRecentMessages = recentMessages.filter((message) => message.tenantId && message.applicationId);
|
||||
if (matchableRecentMessages.length === 1) {
|
||||
return {
|
||||
tenantId: matchableRecentMessages[0].tenantId ?? undefined,
|
||||
applicationId: matchableRecentMessages[0].applicationId ?? undefined,
|
||||
messageRecordId: matchableRecentMessages[0].id,
|
||||
matchStatus: 'matched',
|
||||
matchReason: `手机号 ${windowHours} 小时窗口唯一匹配`,
|
||||
candidates: [],
|
||||
};
|
||||
}
|
||||
if (matchableRecentMessages.length > 1) {
|
||||
return {
|
||||
matchStatus: 'ambiguous',
|
||||
matchReason: `手机号 ${windowHours} 小时窗口匹配多条下发记录`,
|
||||
candidates: matchableRecentMessages
|
||||
.map((message) => ({
|
||||
tenantId: String(message.tenantId),
|
||||
applicationId: String(message.applicationId),
|
||||
messageRecordId: message.id,
|
||||
matchSource: 'phone_window',
|
||||
confidence: 55,
|
||||
reason: `手机号 ${windowHours} 小时窗口候选下发 ${message.messageId}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { matchStatus: 'unmatched', matchReason: '未匹配到应用或下发记录', candidates: [] };
|
||||
}
|
||||
|
||||
async recordCmppFailureReceipt(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
applicationId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
cmppSubmitSequenceId?: string | null;
|
||||
cmppSubmitGroupMessageId?: string | null;
|
||||
},
|
||||
errorCode: string,
|
||||
reason: string,
|
||||
) {
|
||||
if (!message.tenantId || !message.applicationId) return null;
|
||||
const existing = await this.prisma.smsReceiptRecord.findFirst({
|
||||
where: { messageRecordId: message.id, gatewayMessageId: `PLATFORM:${message.messageId}` },
|
||||
});
|
||||
if (existing) return existing;
|
||||
const deliveredAt = new Date();
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { status: 'failed', receiptStatus: 'undelivered', receiptRawStatus: 'REJECTD', errorCode, errorMessage: reason, deliveredAt },
|
||||
});
|
||||
const gatewayMessageId = `PLATFORM:${message.messageId}`;
|
||||
const receipt = await this.prisma.smsReceiptRecord.create({
|
||||
data: {
|
||||
tenantId: message.tenantId,
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
receiptKey: createHash('sha256').update(`platform\u0000${gatewayMessageId}\u0000${message.phoneNumber}\u0000undelivered\u0000REJECTD\u0000${errorCode}`).digest('hex'),
|
||||
messageId: message.messageId,
|
||||
gatewayMessageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
receiptStatus: 'undelivered',
|
||||
rawStatus: 'REJECTD',
|
||||
errorCode,
|
||||
errorMessage: reason,
|
||||
deliveredAt,
|
||||
},
|
||||
});
|
||||
await this.facade.queueAndTryDownstreamDelivery({
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId,
|
||||
messageRecordId: message.id,
|
||||
messageId: message.messageId,
|
||||
deliveryType: 'receipt',
|
||||
payload: {
|
||||
messageId: message.messageId,
|
||||
gatewayMessageId: `PLATFORM:${message.messageId}`,
|
||||
phoneNumber: message.phoneNumber,
|
||||
receiptStatus: 'undelivered',
|
||||
rawStatus: 'REJECTD',
|
||||
errorCode,
|
||||
errorMessage: reason,
|
||||
submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined,
|
||||
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? undefined,
|
||||
deliveredAt: deliveredAt.toISOString(),
|
||||
},
|
||||
});
|
||||
if (message.batchTaskId) await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
async postGatewayControl(path: string, payload: unknown) {
|
||||
const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? 'http://127.0.0.1:8090').replace(/\/$/, '');
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`Gateway control ${path} returned ${response.status}${body ? `: ${body}` : ''}`);
|
||||
}
|
||||
return response.json().catch(() => ({}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
|
||||
|
||||
/**
|
||||
* R10 downstreamState implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendDownstreamStateService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async listPendingDownstreamDeliveries(data: GatewayPendingDeliveryQueryDto) {
|
||||
const application = await this.facade.findInboundApplication(data.account);
|
||||
if (!application) {
|
||||
throw new BadRequestException('CMPP account is invalid');
|
||||
}
|
||||
const expiredAcknowledgements = await this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: { applicationId: application.id, status: 'awaiting_ack', ackDeadlineAt: { lte: new Date() } },
|
||||
select: { id: true },
|
||||
take: 500,
|
||||
});
|
||||
for (const expired of expiredAcknowledgements) {
|
||||
await this.facade.markDownstreamDeliveryFailed(expired.id, 'CMPP_DELIVER_RESP timeout recovered after Gateway restart', 'ack_timeout');
|
||||
}
|
||||
return this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: {
|
||||
applicationId: application.id,
|
||||
status: 'pending',
|
||||
OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }],
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: Math.min(Math.max(data.limit ?? 100, 1), 500),
|
||||
});
|
||||
}
|
||||
|
||||
async markDownstreamDeliveryDelivered(id: string) {
|
||||
return this.prisma.cmppDownstreamDelivery.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'delivered',
|
||||
deliveredAt: new Date(),
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async markDownstreamDeliverySent(data: GatewayDownstreamSentDto) {
|
||||
const sentAt = asDateOrNull(data.sentAt) ?? new Date();
|
||||
const ackDeadlineAt = asDateOrNull(data.ackDeadlineAt) ?? new Date(sentAt.getTime() + downstreamAckTimeoutMs());
|
||||
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
|
||||
if (!delivery) {
|
||||
throw new NotFoundException('Downstream delivery not found');
|
||||
}
|
||||
const attemptKey = downstreamDeliveryAttemptKey(data);
|
||||
await this.prisma.cmppDownstreamDeliveryAttempt.upsert({
|
||||
where: { attemptKey },
|
||||
update: {
|
||||
connectionId: data.connectionId,
|
||||
sequenceId: data.sequenceId,
|
||||
messageId: data.messageId,
|
||||
sentAt,
|
||||
ackDeadlineAt,
|
||||
},
|
||||
create: {
|
||||
deliveryId: data.id,
|
||||
attemptKey,
|
||||
attemptNo: delivery.retryCount + 1,
|
||||
connectionId: data.connectionId,
|
||||
sequenceId: data.sequenceId,
|
||||
messageId: data.messageId,
|
||||
status: 'awaiting_ack',
|
||||
sentAt,
|
||||
ackDeadlineAt,
|
||||
},
|
||||
});
|
||||
await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: data.id, status: { not: 'delivered' } },
|
||||
data: {
|
||||
status: 'awaiting_ack',
|
||||
sentAt,
|
||||
ackDeadlineAt,
|
||||
ackSequenceId: data.sequenceId,
|
||||
ackMessageId: data.messageId,
|
||||
connectionId: data.connectionId,
|
||||
nextRetryAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
|
||||
}
|
||||
|
||||
async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) {
|
||||
const acknowledgedAt = asDateOrNull(data.acknowledgedAt) ?? new Date();
|
||||
const acknowledgedMessageId = String(data.messageId ?? '').trim();
|
||||
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
|
||||
if (!delivery) {
|
||||
throw new NotFoundException('Downstream delivery not found');
|
||||
}
|
||||
const attemptKey = downstreamDeliveryAttemptKey(data);
|
||||
const acknowledgementAccepted = data.result === 0 && acknowledgedMessageId !== '' && acknowledgedMessageId !== '0';
|
||||
await this.prisma.cmppDownstreamDeliveryAttempt.upsert({
|
||||
where: { attemptKey },
|
||||
update: {
|
||||
status: acknowledgementAccepted ? 'acknowledged' : 'rejected',
|
||||
connectionId: data.connectionId,
|
||||
sequenceId: data.sequenceId,
|
||||
messageId: data.messageId,
|
||||
acknowledgedAt,
|
||||
ackResult: data.result,
|
||||
ackDeadlineAt: null,
|
||||
failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected',
|
||||
errorMessage: acknowledgementAccepted
|
||||
? null
|
||||
: data.result === 0
|
||||
? 'CMPP_DELIVER_RESP Msg_Id=0'
|
||||
: `CMPP_DELIVER_RESP result=${data.result}`,
|
||||
},
|
||||
create: {
|
||||
deliveryId: data.id,
|
||||
attemptKey,
|
||||
attemptNo: delivery.retryCount + 1,
|
||||
connectionId: data.connectionId,
|
||||
sequenceId: data.sequenceId,
|
||||
messageId: data.messageId,
|
||||
status: acknowledgementAccepted ? 'acknowledged' : 'rejected',
|
||||
acknowledgedAt,
|
||||
ackResult: data.result,
|
||||
failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected',
|
||||
errorMessage: acknowledgementAccepted
|
||||
? null
|
||||
: data.result === 0
|
||||
? 'CMPP_DELIVER_RESP Msg_Id=0'
|
||||
: `CMPP_DELIVER_RESP result=${data.result}`,
|
||||
},
|
||||
});
|
||||
if (acknowledgementAccepted) {
|
||||
await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: data.id, status: { not: 'delivered' } },
|
||||
data: {
|
||||
status: 'delivered',
|
||||
acknowledgedAt,
|
||||
deliveredAt: acknowledgedAt,
|
||||
ackDeadlineAt: null,
|
||||
ackResult: data.result,
|
||||
ackSequenceId: data.sequenceId,
|
||||
ackMessageId: data.messageId,
|
||||
connectionId: data.connectionId,
|
||||
nextRetryAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
|
||||
}
|
||||
await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: data.id, status: { not: 'delivered' } },
|
||||
data: {
|
||||
acknowledgedAt,
|
||||
ackResult: data.result,
|
||||
ackSequenceId: data.sequenceId,
|
||||
ackMessageId: data.messageId,
|
||||
connectionId: data.connectionId,
|
||||
},
|
||||
});
|
||||
if (data.result === 0) {
|
||||
return this.facade.markDownstreamDeliveryFailed(data.id, 'CMPP_DELIVER_RESP Msg_Id=0,客户端仅确认协议收包,无法关联原短信', 'ack_invalid');
|
||||
}
|
||||
return this.facade.markDownstreamDeliveryFailed(data.id, `downstream CMPP_DELIVER_RESP result=${data.result}`, 'ack_rejected');
|
||||
}
|
||||
|
||||
async markDownstreamDeliveryFailed(
|
||||
id: string,
|
||||
errorMessage?: string,
|
||||
failureType: GatewayDownstreamFailureType = 'send_failed',
|
||||
attempt?: GatewayDownstreamSentDto,
|
||||
) {
|
||||
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id } });
|
||||
if (!delivery) {
|
||||
throw new NotFoundException('Downstream delivery not found');
|
||||
}
|
||||
if (delivery.status === 'delivered') {
|
||||
return delivery;
|
||||
}
|
||||
if (failureType === 'queue_timeout' && delivery.status !== 'pending') {
|
||||
return delivery;
|
||||
}
|
||||
const retryCount = (delivery.retryCount ?? 0) + 1;
|
||||
const acknowledgementFailure = failureType === 'ack_timeout' || failureType === 'ack_rejected' || failureType === 'ack_invalid' || failureType === 'connection_lost';
|
||||
const retryAllowed = !acknowledgementFailure || delivery.retryEnabled !== false;
|
||||
const nonRetryableFailure = failureType === 'unrecoverable' || failureType === 'queue_timeout';
|
||||
const finalFailure = nonRetryableFailure || !retryAllowed || retryCount >= downstreamMaxRetries();
|
||||
const finalStatus = failureType === 'ack_rejected' ? 'rejected' : acknowledgementFailure ? 'unconfirmed' : 'failed';
|
||||
if (attempt && (attempt.connectionId || attempt.sequenceId || attempt.messageId)) {
|
||||
const attemptKey = downstreamDeliveryAttemptKey({ ...attempt, id });
|
||||
await this.prisma.cmppDownstreamDeliveryAttempt.upsert({
|
||||
where: { attemptKey },
|
||||
update: {
|
||||
status: 'failed',
|
||||
connectionId: attempt.connectionId,
|
||||
sequenceId: attempt.sequenceId,
|
||||
messageId: attempt.messageId,
|
||||
failureType,
|
||||
errorMessage: errorMessage ?? 'downstream delivery failed',
|
||||
ackDeadlineAt: null,
|
||||
},
|
||||
create: {
|
||||
deliveryId: id,
|
||||
attemptKey,
|
||||
attemptNo: delivery.retryCount + 1,
|
||||
connectionId: attempt.connectionId,
|
||||
sequenceId: attempt.sequenceId,
|
||||
messageId: attempt.messageId,
|
||||
status: 'failed',
|
||||
sentAt: asDateOrNull(attempt.sentAt),
|
||||
failureType,
|
||||
errorMessage: errorMessage ?? 'downstream delivery failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
const updated = await this.prisma.cmppDownstreamDelivery.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: finalFailure ? finalStatus : 'pending',
|
||||
retryCount,
|
||||
nextRetryAt: finalFailure ? null : new Date(Date.now() + downstreamRetryDelayMs(retryCount)),
|
||||
ackDeadlineAt: null,
|
||||
lastError: errorMessage ?? 'downstream delivery failed',
|
||||
},
|
||||
});
|
||||
if (finalFailure) {
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: updated.tenantId,
|
||||
action: 'gateway.downstream_delivery_failed',
|
||||
resource: 'cmpp_downstream_delivery',
|
||||
resourceId: updated.id,
|
||||
detail: {
|
||||
deliveryType: updated.deliveryType,
|
||||
applicationId: updated.applicationId,
|
||||
messageId: updated.messageId,
|
||||
retryCount,
|
||||
failureType,
|
||||
retryEnabled: updated.retryEnabled,
|
||||
errorMessage: updated.lastError,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async recordGatewayDownstreamRecoveryStatus(data: GatewayDownstreamRecoveryStatusDto) {
|
||||
const account = String(data.account ?? '').trim();
|
||||
if (!account) {
|
||||
throw new BadRequestException('account is required');
|
||||
}
|
||||
const recoveryStatuses = (this.prisma as PrismaService & {
|
||||
gatewayDownstreamRecoveryStatus: {
|
||||
findUnique: (args: Record<string, unknown>) => Promise<any>;
|
||||
upsert: (args: Record<string, unknown>) => Promise<any>;
|
||||
};
|
||||
}).gatewayDownstreamRecoveryStatus;
|
||||
const previous = await recoveryStatuses.findUnique({
|
||||
where: { account },
|
||||
select: {
|
||||
state: true,
|
||||
gatewayInstanceId: true,
|
||||
lockOwner: true,
|
||||
failureCategory: true,
|
||||
lastError: true,
|
||||
lastSkipReason: true,
|
||||
},
|
||||
});
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { cmppAccount: account },
|
||||
select: { id: true, tenantId: true, name: true },
|
||||
});
|
||||
const failureCategory = normalizeRecoveryFailureCategory(data);
|
||||
const updated = await recoveryStatuses.upsert({
|
||||
where: { account },
|
||||
update: {
|
||||
tenantId: application?.tenantId ?? null,
|
||||
applicationId: application?.id ?? null,
|
||||
gatewayInstanceId: data.gatewayInstanceId ?? null,
|
||||
state: data.state,
|
||||
lockOwner: data.lockOwner ?? null,
|
||||
lockExpiresAt: asDateOrNull(data.lockExpiresAt),
|
||||
lastAttemptAt: asDateOrNull(data.lastAttemptAt),
|
||||
lastSuccessAt: asDateOrNull(data.lastSuccessAt),
|
||||
lastFailureAt: asDateOrNull(data.lastFailureAt),
|
||||
nextRetryAt: asDateOrNull(data.nextRetryAt),
|
||||
attemptCount: Number.isFinite(Number(data.attemptCount)) ? Number(data.attemptCount) : 0,
|
||||
failureCategory,
|
||||
lastError: data.lastError ?? null,
|
||||
lastSkipReason: data.lastSkipReason ?? null,
|
||||
},
|
||||
create: {
|
||||
account,
|
||||
tenantId: application?.tenantId,
|
||||
applicationId: application?.id,
|
||||
gatewayInstanceId: data.gatewayInstanceId,
|
||||
state: data.state,
|
||||
lockOwner: data.lockOwner,
|
||||
lockExpiresAt: asDateOrNull(data.lockExpiresAt),
|
||||
lastAttemptAt: asDateOrNull(data.lastAttemptAt),
|
||||
lastSuccessAt: asDateOrNull(data.lastSuccessAt),
|
||||
lastFailureAt: asDateOrNull(data.lastFailureAt),
|
||||
nextRetryAt: asDateOrNull(data.nextRetryAt),
|
||||
attemptCount: Number.isFinite(Number(data.attemptCount)) ? Number(data.attemptCount) : 0,
|
||||
failureCategory,
|
||||
lastError: data.lastError,
|
||||
lastSkipReason: data.lastSkipReason,
|
||||
},
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
},
|
||||
});
|
||||
const normalizedUpdated = updated as typeof updated & {
|
||||
failureCategory?: string | null;
|
||||
lockOwner?: string | null;
|
||||
lockExpiresAt?: Date | null;
|
||||
};
|
||||
if (hasRecoveryAuditStateChanged(previous, updated)) {
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: updated.tenantId ?? undefined,
|
||||
action: 'gateway.downstream_recovery_status_changed',
|
||||
resource: 'gateway_downstream_recovery_status',
|
||||
resourceId: updated.id,
|
||||
detail: {
|
||||
account,
|
||||
previousState: previous?.state ?? null,
|
||||
state: updated.state,
|
||||
gatewayInstanceId: updated.gatewayInstanceId,
|
||||
lockOwner: normalizedUpdated.lockOwner,
|
||||
attemptCount: updated.attemptCount,
|
||||
nextRetryAt: updated.nextRetryAt,
|
||||
failureCategory: normalizedUpdated.failureCategory,
|
||||
applicationId: updated.applicationId,
|
||||
applicationName: application?.name,
|
||||
lastError: updated.lastError,
|
||||
lastSkipReason: updated.lastSkipReason,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async requeueDownstreamDelivery(id: string) {
|
||||
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({
|
||||
where: { id },
|
||||
include: { application: { select: { cmppAccount: true } } },
|
||||
});
|
||||
if (!delivery) {
|
||||
throw new NotFoundException('Downstream delivery not found');
|
||||
}
|
||||
if (delivery.status === 'awaiting_ack') {
|
||||
throw new BadRequestException('该记录正在等待客户端确认,不允许并发重投');
|
||||
}
|
||||
const payload = isObjectRecord(delivery.payload) ? { ...delivery.payload } : null;
|
||||
if (!payload) {
|
||||
throw new BadRequestException('下游投递记录缺少可重放 payload');
|
||||
}
|
||||
const path =
|
||||
delivery.deliveryType === 'receipt'
|
||||
? '/downstream/receipt'
|
||||
: delivery.deliveryType === 'uplink'
|
||||
? '/downstream/uplink'
|
||||
: null;
|
||||
if (!path) {
|
||||
throw new BadRequestException(`Unsupported downstream delivery type ${delivery.deliveryType}`);
|
||||
}
|
||||
|
||||
const requestPayload = {
|
||||
deliveryId: delivery.id,
|
||||
account: String(payload.account ?? delivery.application?.cmppAccount ?? ''),
|
||||
...payload,
|
||||
};
|
||||
const retriedAt = new Date();
|
||||
const claimed = await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: {
|
||||
id: delivery.id,
|
||||
status: delivery.status,
|
||||
updatedAt: delivery.updatedAt,
|
||||
},
|
||||
data: {
|
||||
status: 'manual_requeueing',
|
||||
retryCount: 0,
|
||||
manualRetryCount: { increment: 1 },
|
||||
lastRetriedAt: retriedAt,
|
||||
nextRetryAt: null,
|
||||
sentAt: null,
|
||||
acknowledgedAt: null,
|
||||
ackDeadlineAt: null,
|
||||
ackResult: null,
|
||||
ackSequenceId: null,
|
||||
ackMessageId: null,
|
||||
connectionId: null,
|
||||
deliveredAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1) {
|
||||
throw new BadRequestException('该下游投递记录已被其他操作处理,请刷新后重试');
|
||||
}
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: delivery.tenantId,
|
||||
action: 'gateway.downstream_delivery_requeue',
|
||||
resource: 'cmpp_downstream_delivery',
|
||||
resourceId: delivery.id,
|
||||
detail: {
|
||||
deliveryType: delivery.deliveryType,
|
||||
applicationId: delivery.applicationId,
|
||||
messageId: delivery.messageId,
|
||||
previousStatus: delivery.status,
|
||||
previousRetryCount: delivery.retryCount,
|
||||
manualRetryCount: (delivery.manualRetryCount ?? 0) + 1,
|
||||
lastRetriedAt: retriedAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await this.facade.postGatewayControl(path, requestPayload) as GatewayControlDeliveryResult;
|
||||
if (result.sent || result.delivered) {
|
||||
return this.facade.markDownstreamDeliverySent({ id: delivery.id, ...result });
|
||||
}
|
||||
return this.facade.markDownstreamDeliveryFailed(
|
||||
delivery.id,
|
||||
downstreamControlFailureMessage(result),
|
||||
result.retryable === false ? 'unrecoverable' : 'send_failed',
|
||||
);
|
||||
} catch (error) {
|
||||
return this.facade.markDownstreamDeliveryFailed(
|
||||
delivery.id,
|
||||
error instanceof Error ? error.message : 'Gateway control delivery failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async recoverStaleDownstreamManualRequeues(now = new Date()) {
|
||||
const staleCutoff = new Date(now.getTime() - positiveInteger(
|
||||
process.env.CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
|
||||
DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
|
||||
));
|
||||
const stale = await this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: { status: 'manual_requeueing', updatedAt: { lt: staleCutoff } },
|
||||
select: { id: true, updatedAt: true },
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: 500,
|
||||
});
|
||||
let recovered = 0;
|
||||
for (const delivery of stale) {
|
||||
const updated = await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: delivery.id, status: 'manual_requeueing', updatedAt: delivery.updatedAt },
|
||||
data: {
|
||||
status: 'pending',
|
||||
nextRetryAt: null,
|
||||
lastError: '人工重投进程中断,已恢复为待投递',
|
||||
},
|
||||
});
|
||||
recovered += updated.count;
|
||||
}
|
||||
return { recovered };
|
||||
}
|
||||
|
||||
async batchRequeueDownstreamDeliveries(ids: string[]) {
|
||||
const uniqueIds = [...new Set(ids.filter(Boolean))];
|
||||
if (uniqueIds.length === 0) {
|
||||
throw new BadRequestException('请选择至少一条下游投递记录');
|
||||
}
|
||||
const results: Array<{ id: string; status: 'success' | 'failed'; errorMessage?: string }> = [];
|
||||
for (const id of uniqueIds) {
|
||||
try {
|
||||
await this.facade.requeueDownstreamDelivery(id);
|
||||
results.push({ id, status: 'success' });
|
||||
} catch (error) {
|
||||
results.push({
|
||||
id,
|
||||
status: 'failed',
|
||||
errorMessage: error instanceof Error ? error.message : '批量重投失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: uniqueIds.length,
|
||||
successCount: results.filter((item) => item.status === 'success').length,
|
||||
failedCount: results.filter((item) => item.status === 'failed').length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
|
||||
|
||||
/**
|
||||
* R10 gatewayResult implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendGatewayResultService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) {
|
||||
const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
|
||||
const submitRecord = await this.facade.resolveSubmitRecordForGatewaySegmentResult(message.id, data);
|
||||
const effectiveSubmitId = submitRecord.submitId;
|
||||
const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date();
|
||||
await this.facade.recordSubmitSegments(message, {
|
||||
messageId: data.messageId,
|
||||
channelId: data.channelId,
|
||||
submitId: effectiveSubmitId,
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId ?? '',
|
||||
submitStatus: normalizeSubmitStatus(data.submitStatus),
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt: submittedAt.toISOString(),
|
||||
segments: [{
|
||||
segmentTotal: data.segmentTotal,
|
||||
segmentIndex: data.segmentIndex,
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: data.submitStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt: submittedAt.toISOString(),
|
||||
}],
|
||||
}, submittedAt);
|
||||
if (data.gatewayMessageId) {
|
||||
await this.prisma.smsSubmitRecord.updateMany({
|
||||
where: {
|
||||
id: submitRecord.id,
|
||||
gatewayMessageId: null,
|
||||
},
|
||||
data: {
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submittedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
async resolveSubmitRecordForGatewaySegmentResult(
|
||||
messageRecordId: string,
|
||||
data: GatewaySubmitSegmentResultDto,
|
||||
) {
|
||||
if (data.submitId) {
|
||||
const exact = await this.prisma.smsSubmitRecord.findUnique({
|
||||
where: { submitId: data.submitId },
|
||||
});
|
||||
if (
|
||||
!exact ||
|
||||
(exact.messageRecordId && exact.messageRecordId !== messageRecordId) ||
|
||||
(exact.channelId && exact.channelId !== data.channelId)
|
||||
) {
|
||||
this.logger.error(`gateway_submit_segment_result_unmatched ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
submitId: data.submitId,
|
||||
channelId: data.channelId,
|
||||
segmentIndex: data.segmentIndex,
|
||||
})}`);
|
||||
throw new BadRequestException(
|
||||
'Gateway SubmitSegmentResult submitId does not match the SMS message and channel',
|
||||
);
|
||||
}
|
||||
return exact;
|
||||
}
|
||||
|
||||
const candidates = await this.prisma.smsSubmitRecord.findMany({
|
||||
where: {
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 2,
|
||||
});
|
||||
if (candidates.length !== 1) {
|
||||
this.logger.error(`gateway_submit_segment_result_unmatched ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
segmentIndex: data.segmentIndex,
|
||||
candidateCount: candidates.length,
|
||||
})}`);
|
||||
throw new BadRequestException(
|
||||
'Gateway SubmitSegmentResult without submitId cannot be matched uniquely',
|
||||
);
|
||||
}
|
||||
this.logger.warn(`gateway_submit_segment_result_legacy_match ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
segmentIndex: data.segmentIndex,
|
||||
submitId: candidates[0].submitId,
|
||||
})}`);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
async handleSubmitResult(data: GatewaySubmitResultDto) {
|
||||
const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
|
||||
const submitRecord = await this.facade.resolveSubmitRecordForGatewayResult(message.id, data);
|
||||
const effectiveData = { ...data, submitId: submitRecord.submitId };
|
||||
const batchTask = message.batchTaskId
|
||||
? await this.prisma.smsBatchTask.findUnique({ where: { id: message.batchTaskId }, select: { sourceType: true } })
|
||||
: null;
|
||||
const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date();
|
||||
await this.prisma.smsSubmitRecord.updateMany({
|
||||
where: { id: submitRecord.id },
|
||||
data: {
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: data.submitStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt,
|
||||
},
|
||||
});
|
||||
await this.facade.recordSubmitSegments(message, effectiveData, submittedAt);
|
||||
const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId;
|
||||
if (message.submitId && effectiveData.submitId !== message.submitId) {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const status = data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed';
|
||||
if (data.submitStatus === 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
await this.facade.chargeAcceptedMessage(businessMessage);
|
||||
const latest = await this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
if (latest?.status === 'failed') {
|
||||
await this.facade.refundMessage(businessMessage, '先到失败回执补偿退款');
|
||||
}
|
||||
} else if (data.submitStatus !== 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
const retried = await this.facade.retryMessageIfAllowed(
|
||||
businessMessage,
|
||||
data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发',
|
||||
submitRecord.id,
|
||||
);
|
||||
if (retried) {
|
||||
await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
|
||||
return retried;
|
||||
}
|
||||
await this.facade.releaseMessageReservation(businessMessage, data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结');
|
||||
}
|
||||
const protectedTerminalStatuses = ['delivered', 'failed', 'unknown'];
|
||||
const updated = await this.prisma.smsMessageRecord.updateMany({
|
||||
where: data.submitStatus === 'accepted'
|
||||
? { id: message.id, status: { notIn: protectedTerminalStatuses } }
|
||||
: { id: message.id, status: { not: 'delivered' } },
|
||||
data: {
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: data.submitStatus,
|
||||
status,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt,
|
||||
timeoutAt: data.submitStatus === 'timeout' ? submittedAt : undefined,
|
||||
},
|
||||
});
|
||||
if (updated.count === 0 && data.submitStatus === 'accepted') {
|
||||
await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { id: message.id, gatewayMessageId: null },
|
||||
data: {
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submittedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (data.submitStatus !== 'accepted' && batchTask?.sourceType === 'cmpp' && message.tenantId && message.applicationId) {
|
||||
await this.facade.recordCmppFailureReceipt(
|
||||
message as typeof message & { tenantId: string; applicationId: string; batchTaskId: string },
|
||||
data.errorCode || 'SUBMIT',
|
||||
data.errorMessage || (data.submitStatus === 'timeout' ? '上游提交超时' : '上游拒绝短信'),
|
||||
);
|
||||
}
|
||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: {
|
||||
status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] },
|
||||
OR: [
|
||||
{ submitId: effectiveData.submitId },
|
||||
data.messageId ? { messageId: data.messageId } : undefined,
|
||||
].filter(Boolean) as Array<{ submitId?: string; messageId?: string }>,
|
||||
},
|
||||
data: {
|
||||
status: 'resolved',
|
||||
resolvedAt: submittedAt,
|
||||
resolvedStatus: data.submitStatus,
|
||||
},
|
||||
});
|
||||
if (message.batchTaskId) {
|
||||
await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||
}
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
|
||||
async resolveSubmitRecordForGatewayResult(messageRecordId: string, data: GatewaySubmitResultDto) {
|
||||
if (data.submitId) {
|
||||
const exact = await this.prisma.smsSubmitRecord.findUnique({ where: { submitId: data.submitId } });
|
||||
if (!exact
|
||||
|| (exact.messageRecordId && exact.messageRecordId !== messageRecordId)
|
||||
|| (exact.channelId && exact.channelId !== data.channelId)) {
|
||||
throw new BadRequestException('Gateway SubmitResult submitId does not match the SMS message and channel');
|
||||
}
|
||||
return exact;
|
||||
}
|
||||
const candidates = await this.prisma.smsSubmitRecord.findMany({
|
||||
where: {
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
OR: [
|
||||
data.gatewayMessageId ? { gatewayMessageId: data.gatewayMessageId } : undefined,
|
||||
{ gatewayMessageId: null },
|
||||
].filter(Boolean) as Array<{ gatewayMessageId?: string | null }>,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 2,
|
||||
});
|
||||
if (candidates.length !== 1) {
|
||||
this.logger.error(`gateway_submit_result_unmatched ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
candidateCount: candidates.length,
|
||||
})}`);
|
||||
throw new BadRequestException('Gateway SubmitResult without submitId cannot be matched uniquely');
|
||||
}
|
||||
this.logger.warn(`gateway_submit_result_legacy_match ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitId: candidates[0].submitId,
|
||||
})}`);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
smsMessageSegmentAuditDelegate() {
|
||||
return (this.prisma as PrismaService & {
|
||||
smsMessageSegmentAudit: {
|
||||
upsert: (args: Record<string, unknown>) => Promise<any>;
|
||||
updateMany: (args: Record<string, unknown>) => Promise<{ count: number }>;
|
||||
findFirst: (args: Record<string, unknown>) => Promise<any | null>;
|
||||
findMany: (args: Record<string, unknown>) => Promise<any[]>;
|
||||
};
|
||||
}).smsMessageSegmentAudit;
|
||||
}
|
||||
|
||||
async recordSubmitSegments(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
channelId?: string | null;
|
||||
submitId?: string | null;
|
||||
billingUnits?: number | null;
|
||||
},
|
||||
data: GatewaySubmitResultDto,
|
||||
submittedAt: Date,
|
||||
) {
|
||||
const segmentAudits = this.facade.smsMessageSegmentAuditDelegate();
|
||||
const submitRecord = await this.prisma.smsSubmitRecord.findFirst({
|
||||
where: {
|
||||
messageRecordId: message.id,
|
||||
OR: [
|
||||
data.submitId ? { submitId: data.submitId } : undefined,
|
||||
data.gatewayMessageId ? { gatewayMessageId: data.gatewayMessageId } : undefined,
|
||||
].filter(Boolean) as Array<{ submitId?: string; gatewayMessageId?: string }>,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const submitId = data.submitId ?? submitRecord?.submitId ?? message.submitId ?? `SUB-AUDIT-${message.id}`;
|
||||
const attempt = submitRecord
|
||||
? Math.max(0, await this.prisma.smsSubmitRecord.count({
|
||||
where: {
|
||||
messageRecordId: message.id,
|
||||
createdAt: { lte: submitRecord.createdAt },
|
||||
},
|
||||
}) - 1)
|
||||
: 0;
|
||||
const fallbackSegments = [{
|
||||
segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)),
|
||||
segmentIndex: 1,
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: data.submitStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt: data.submittedAt,
|
||||
}];
|
||||
const segments = data.segments && data.segments.length > 0 ? data.segments : fallbackSegments;
|
||||
const segmentTotal = Math.max(1, ...segments.map((item) => Number(item.segmentTotal ?? segments.length ?? 1)));
|
||||
await Promise.all(segments.map((segment, index) => {
|
||||
const segmentIndex = Math.max(1, Number(segment.segmentIndex ?? index + 1));
|
||||
const status = segment.submitStatus ?? data.submitStatus;
|
||||
return segmentAudits.upsert({
|
||||
where: {
|
||||
messageRecordId_submitId_segmentIndex: {
|
||||
messageRecordId: message.id,
|
||||
submitId,
|
||||
segmentIndex,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
submitRecordId: submitRecord?.id ?? null,
|
||||
channelId: data.channelId ?? message.channelId ?? null,
|
||||
attempt,
|
||||
segmentTotal,
|
||||
sequenceId: segment.sequenceId ?? data.sequenceId ?? null,
|
||||
gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null,
|
||||
submitStatus: status,
|
||||
errorCode: segment.errorCode ?? data.errorCode ?? null,
|
||||
errorMessage: segment.errorMessage ?? data.errorMessage ?? null,
|
||||
submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt,
|
||||
},
|
||||
create: {
|
||||
tenantId: message.tenantId,
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
submitRecordId: submitRecord?.id ?? null,
|
||||
channelId: data.channelId ?? message.channelId ?? null,
|
||||
submitId,
|
||||
attempt,
|
||||
segmentTotal,
|
||||
segmentIndex,
|
||||
sequenceId: segment.sequenceId ?? data.sequenceId ?? null,
|
||||
gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null,
|
||||
submitStatus: status,
|
||||
compensationType: submitRecord && submitRecord.submitId !== message.submitId ? 'retry_submit' : null,
|
||||
errorCode: segment.errorCode ?? data.errorCode ?? null,
|
||||
errorMessage: segment.errorMessage ?? data.errorMessage ?? null,
|
||||
submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt,
|
||||
},
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
async findMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
|
||||
const conditions = [{ messageId }, gatewayMessageId ? { gatewayMessageId } : undefined].filter(
|
||||
Boolean,
|
||||
) as Array<{
|
||||
messageId?: string;
|
||||
gatewayMessageId?: string;
|
||||
}>;
|
||||
if (conditions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.prisma.smsMessageRecord.findFirst({
|
||||
where: {
|
||||
OR: conditions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async requireMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
|
||||
const message = await this.facade.findMessageByGatewayEvent(messageId, gatewayMessageId);
|
||||
if (!message) {
|
||||
throw new NotFoundException('SMS message record not found');
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { isIpAllowed } from '../common/ip-allowlist';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
|
||||
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
|
||||
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
|
||||
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
||||
|
||||
/**
|
||||
* R9 gatewaySubmit implementation. Cross-method calls return through the stable SendChainService seam.
|
||||
*/
|
||||
export class SendGatewaySubmitService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
private redis?: IORedis;
|
||||
private sendQueue?: Queue<SendJob, unknown, 'send-message'>;
|
||||
private gatewayQueue?: Queue;
|
||||
private worker?: Worker<SendJob>;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly riskReview: RiskReviewService,
|
||||
private readonly phoneFrequency: PhoneFrequencyService,
|
||||
private readonly phoneRouting: PhoneRoutingLookupService,
|
||||
private readonly facade: SendSubmissionService,
|
||||
private readonly callbacks: SendSubmissionCallbacks,
|
||||
) {}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.worker?.close();
|
||||
await this.sendQueue?.close();
|
||||
await this.gatewayQueue?.close();
|
||||
this.redis?.disconnect();
|
||||
}
|
||||
|
||||
private releaseMessageReservation(
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
return this.callbacks.releaseMessageReservation(message, remark);
|
||||
}
|
||||
|
||||
private recordCmppFailureReceipt(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
applicationId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
cmppSubmitSequenceId?: string | null;
|
||||
cmppSubmitGroupMessageId?: string | null;
|
||||
},
|
||||
errorCode: string,
|
||||
reason: string,
|
||||
) {
|
||||
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
|
||||
}
|
||||
|
||||
|
||||
async enqueueBatchTask(taskId: string) {
|
||||
const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } });
|
||||
if (!task) {
|
||||
throw new NotFoundException('SMS batch task not found');
|
||||
}
|
||||
if (task.status === 'canceled') {
|
||||
throw new BadRequestException('SMS batch task is canceled');
|
||||
}
|
||||
const messages = await this.prisma.smsMessageRecord.findMany({
|
||||
where: { batchTaskId: taskId, status: 'queued' },
|
||||
select: { id: true, queuePriority: true },
|
||||
take: 100000,
|
||||
});
|
||||
const queue = this.facade.getSendQueue();
|
||||
for (const message of messages) {
|
||||
const queuePriority = normalizeQueuePriority(message.queuePriority);
|
||||
await queue.add('send-message', { messageRecordId: message.id }, {
|
||||
jobId: message.id,
|
||||
attempts: 3,
|
||||
priority: BULLMQ_PRIORITY[queuePriority],
|
||||
});
|
||||
}
|
||||
await this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'queued' } });
|
||||
return { taskId, enqueued: messages.length };
|
||||
}
|
||||
|
||||
startWorker() {
|
||||
if (this.worker) {
|
||||
return { status: 'already_started' };
|
||||
}
|
||||
const connection = bullmqConnection();
|
||||
this.worker = new Worker<SendJob>(
|
||||
SEND_QUEUE,
|
||||
async (job) => this.facade.processSendJob(job.data),
|
||||
{ connection, concurrency: Number(process.env.API_SEND_WORKER_CONCURRENCY ?? 20) },
|
||||
);
|
||||
return { status: 'started' };
|
||||
}
|
||||
|
||||
async processSendJob(job: SendJob) {
|
||||
const message = await this.prisma.smsMessageRecord.findUnique({
|
||||
where: { id: job.messageRecordId },
|
||||
include: { batchTask: true, template: { include: { signature: true } }, signature: true },
|
||||
});
|
||||
if (!message || message.status !== 'queued') {
|
||||
return { skipped: true };
|
||||
}
|
||||
if (!message.tenantId || !message.batchTaskId) {
|
||||
return { skipped: true, reason: 'standalone channel test message' };
|
||||
}
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
try {
|
||||
const routed = await this.facade.selectChannelForMessage(businessMessage);
|
||||
return await this.facade.submitMessageToGateway(businessMessage, routed, 0);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : '无可用通道组或通道';
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { status: 'failed', errorMessage: reason },
|
||||
});
|
||||
await this.releaseMessageReservation(businessMessage, reason);
|
||||
if (message.batchTask?.sourceType === 'cmpp') {
|
||||
await this.recordCmppFailureReceipt(businessMessage, 'ROUTE', reason);
|
||||
} else {
|
||||
await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
|
||||
}
|
||||
return { submitted: false, messageRecordId: message.id, status: 'failed', reason };
|
||||
}
|
||||
}
|
||||
|
||||
async submitMessageToGateway(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
batchTaskId: string;
|
||||
applicationId?: string | null;
|
||||
templateId?: string | null;
|
||||
signatureId?: string | null;
|
||||
submitId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
queuePriority?: string | null;
|
||||
clientSrcId?: string | null;
|
||||
applicationExtension?: string | null;
|
||||
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
|
||||
signature?: { id?: string | null; name?: string | null } | null;
|
||||
},
|
||||
routed: RoutedChannel,
|
||||
attempt: number,
|
||||
retryOfSubmitRecordId?: string,
|
||||
) {
|
||||
const channel = routed.channel;
|
||||
const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension);
|
||||
await this.facade.ensureSignatureReportedForChannel(message, channel.id);
|
||||
await this.facade.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond);
|
||||
const submitId = `SUB-${randomUUID()}`;
|
||||
try {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const session = await tx.cmppSubmitSession.upsert({
|
||||
where: { sessionNo: `OPEN-${channel.id}` },
|
||||
update: { submitTotal: { increment: 1 } },
|
||||
create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 },
|
||||
});
|
||||
await tx.smsSubmitRecord.create({
|
||||
data: {
|
||||
tenantId: message.tenantId,
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
channelId: channel.id,
|
||||
channelGroupId: routed.groupId,
|
||||
channelGroupName: routed.groupName,
|
||||
sessionId: session.id,
|
||||
retryOfSubmitRecordId,
|
||||
submitId,
|
||||
submitStatus: 'queued',
|
||||
costUnitPrice: channel.unitPrice ?? 0,
|
||||
costAmountCents: moneyToNumber(channel.unitPrice) * Math.max(1, message.billingUnits ?? 1),
|
||||
},
|
||||
});
|
||||
await tx.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: {
|
||||
channelId: channel.id,
|
||||
carrier: routed.carrier,
|
||||
province: routed.province,
|
||||
submitId,
|
||||
status: 'submit_queued',
|
||||
submitStatus: 'queued',
|
||||
receiptStatus: null,
|
||||
errorCode: null,
|
||||
errorMessage: attempt > 0 ? `第 ${attempt + 1} 次提交,路由至${routed.routeScope === 'national' ? '全国' : '省网'}通道` : undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
if (retryOfSubmitRecordId) {
|
||||
this.logger.log(`sms_retry_claim_acquired ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
retryOfSubmitRecordId,
|
||||
submitId,
|
||||
channelId: channel.id,
|
||||
})}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
retryOfSubmitRecordId
|
||||
&& error instanceof Prisma.PrismaClientKnownRequestError
|
||||
&& error.code === 'P2002'
|
||||
) {
|
||||
const existingRetry = await this.prisma.smsSubmitRecord.findUnique({
|
||||
where: { retryOfSubmitRecordId },
|
||||
});
|
||||
if (existingRetry) {
|
||||
this.logger.warn(`sms_retry_claim_reused ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
retryOfSubmitRecordId,
|
||||
submitId: existingRetry.submitId,
|
||||
channelId: existingRetry.channelId,
|
||||
})}`);
|
||||
return {
|
||||
submitted: false,
|
||||
duplicateRetry: true,
|
||||
messageRecordId: message.id,
|
||||
channelId: existingRetry.channelId,
|
||||
attempt,
|
||||
submitId: existingRetry.submitId,
|
||||
};
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const command = {
|
||||
schemaVersion: 'v1',
|
||||
messageType: 'SubmitCommand',
|
||||
traceId: randomUUID(),
|
||||
messageId: message.messageId,
|
||||
channelId: channel.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId ?? 'unknown',
|
||||
taskId: message.batchTaskId,
|
||||
submitId,
|
||||
queuePriority: normalizeQueuePriority(message.queuePriority),
|
||||
phoneNumber: message.phoneNumber,
|
||||
content: message.content,
|
||||
signature: message.template?.signature?.name ?? message.signature?.name ?? 'SMS',
|
||||
templateId: message.templateId ?? 'unknown',
|
||||
billingUnits: message.billingUnits,
|
||||
route: {
|
||||
channelCode: channel.code,
|
||||
cmppAccountCode: channel.account,
|
||||
priority: attempt,
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
carrier: routed.carrier,
|
||||
province: routed.province ?? undefined,
|
||||
scope: routed.routeScope,
|
||||
groupId: routed.groupId,
|
||||
},
|
||||
cmpp: {
|
||||
serviceId: channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config
|
||||
? String(channel.config.serviceId)
|
||||
: 'SMS',
|
||||
srcId: upstreamSrcId,
|
||||
extensionDigits: getNonNegativeConfigInteger(channel.config, 'extensionDigits', 0),
|
||||
registeredDelivery: 1,
|
||||
msgFmt: 8,
|
||||
},
|
||||
upstream: {
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: channel.gatewayPort,
|
||||
account: channel.account,
|
||||
passwordCipher: channel.passwordCipher,
|
||||
cmppVersion: channel.cmppVersion,
|
||||
desiredConnections: getPositiveConfigInteger(channel.config, 'desiredConnections', 1),
|
||||
windowSize: getPositiveConfigInteger(channel.config, 'windowSize', 16),
|
||||
heartbeatIntervalSeconds: getPositiveConfigInteger(channel.config, 'heartbeatIntervalSeconds', 30),
|
||||
heartbeatMissThreshold: getPositiveConfigInteger(channel.config, 'heartbeatMissThreshold', 3),
|
||||
},
|
||||
retry: { attempt, maxAttempts: 1 },
|
||||
};
|
||||
await this.facade.getGatewayQueue().add('submit-command', command);
|
||||
await this.facade.publishGatewaySubmitCommand(command);
|
||||
await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||
return { submitted: true, messageRecordId: message.id, channelId: channel.id, attempt };
|
||||
}
|
||||
|
||||
async selectChannelForMessage(
|
||||
message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null },
|
||||
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
|
||||
): Promise<RoutedChannel> {
|
||||
if (!message.applicationId) {
|
||||
throw new BadRequestException('短信应用未配置,无法选择通道组');
|
||||
}
|
||||
const hasPersistedRouting = Boolean(message.carrier);
|
||||
const [carrier, province] = hasPersistedRouting
|
||||
? [normalizeCarrier(message.carrier), message.province ?? null]
|
||||
: await Promise.all([
|
||||
this.facade.identifyCarrier(message.phoneNumber),
|
||||
this.facade.identifyProvince(message.phoneNumber),
|
||||
]);
|
||||
if (!hasPersistedRouting) {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { carrier, province },
|
||||
});
|
||||
}
|
||||
const route = await this.facade.findApplicationRoute(message.tenantId, message.applicationId, carrier);
|
||||
const excluded = new Set(options.excludeChannelIds ?? []);
|
||||
const signatureId = await this.facade.resolveMessageSignatureId(message);
|
||||
if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道');
|
||||
const approvedTasks = await this.prisma.channelSignatureReportTask.findMany({
|
||||
where: { signatureId, reportType: 'signature', status: 'approved', channelId: { in: route.group.items.map((item) => item.channelId) } },
|
||||
select: { channelId: true },
|
||||
});
|
||||
const approvedChannelIds = new Set(approvedTasks.map((task) => task.channelId));
|
||||
const selected = selectChannelCandidate(route.group.items, {
|
||||
carrier,
|
||||
province,
|
||||
forceNational: options.forceNational,
|
||||
excludedChannelIds: excluded,
|
||||
approvedChannelIds,
|
||||
});
|
||||
if (!selected) {
|
||||
throw new NotFoundException('无已报备通过且在线的可用通道');
|
||||
}
|
||||
return {
|
||||
channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) },
|
||||
carrier,
|
||||
province,
|
||||
groupId: route.groupId,
|
||||
groupName: route.group.name,
|
||||
routeScope: isNationalChannel(selected) ? 'national' : 'province',
|
||||
};
|
||||
}
|
||||
|
||||
async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string) {
|
||||
const route = await this.prisma.channelRouteRule.findFirst({
|
||||
where: {
|
||||
status: 'active',
|
||||
tenantId,
|
||||
applicationId,
|
||||
carrier,
|
||||
channelId: null,
|
||||
province: null,
|
||||
},
|
||||
include: { group: { include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: { priority: 'asc' } } } } },
|
||||
orderBy: { priority: 'asc' },
|
||||
});
|
||||
if (!route) {
|
||||
throw new NotFoundException('企业应用未配置对应运营商通道组');
|
||||
}
|
||||
if (route.group.status !== 'active') {
|
||||
throw new BadRequestException('企业应用绑定的通道组已停用');
|
||||
}
|
||||
if (normalizeCarrier(route.group.carrier) !== carrier) {
|
||||
throw new BadRequestException('企业应用绑定的通道组运营商与路由规则不一致');
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
async identifyCarrier(phoneNumber: string) {
|
||||
return normalizeCarrier(await this.phoneRouting.identifyCarrier(phoneNumber));
|
||||
}
|
||||
|
||||
async identifyProvince(phoneNumber: string) {
|
||||
return this.phoneRouting.identifyProvince(phoneNumber);
|
||||
}
|
||||
|
||||
async ensureSignatureReportedForChannel(
|
||||
message: {
|
||||
id: string;
|
||||
templateId?: string | null;
|
||||
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
|
||||
signature?: { id?: string | null; name?: string | null } | null;
|
||||
},
|
||||
channelId: string,
|
||||
) {
|
||||
const signatureId = await this.facade.resolveMessageSignatureId(message);
|
||||
if (!signatureId) {
|
||||
throw new BadRequestException('短信签名未配置,不能提交到通道');
|
||||
}
|
||||
const reportTask = await this.prisma.channelSignatureReportTask.findFirst({
|
||||
where: { signatureId, channelId, reportType: 'signature', status: 'approved' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!reportTask) {
|
||||
throw new BadRequestException('短信签名未在最终通道报备通过');
|
||||
}
|
||||
}
|
||||
|
||||
async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
|
||||
const direct = message.signatureId ?? message.template?.signature?.id ?? message.signature?.id ?? null;
|
||||
if (direct || !message.templateId) return direct;
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: message.templateId }, include: { signature: true } });
|
||||
return template?.signature?.id ?? null;
|
||||
}
|
||||
|
||||
async waitForChannelRateLimit(channelId: string, tps: number) {
|
||||
const redis = this.facade.getRedis();
|
||||
for (;;) {
|
||||
const bucket = `rate:channel:${channelId}:${Math.floor(Date.now() / 1000)}`;
|
||||
const count = await redis.incr(bucket);
|
||||
if (count === 1) {
|
||||
await redis.expire(bucket, 2);
|
||||
}
|
||||
if (count <= Math.max(1, tps)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshTaskProgress(batchTaskId: string) {
|
||||
const groups = await this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['status'],
|
||||
where: { batchTaskId },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const count = (statuses: string[]) =>
|
||||
groups.filter((group) => statuses.includes(group.status)).reduce((sum, group) => sum + group._count._all, 0);
|
||||
const progressTotal = groups.reduce((sum, group) => sum + group._count._all, 0);
|
||||
const submittedTotal = count(['submit_queued', 'submitted', 'delivered', 'failed', 'unknown', 'timeout']);
|
||||
const successTotal = count(['delivered']);
|
||||
const failedTotal = count(['submit_failed', 'failed']);
|
||||
const unknownTotal = count(['unknown']);
|
||||
const timeoutTotal = count(['timeout']);
|
||||
const doneTotal = successTotal + failedTotal + timeoutTotal;
|
||||
const status = progressTotal > 0 && doneTotal >= progressTotal ? 'finished' : submittedTotal > 0 ? 'sending' : 'queued';
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: batchTaskId },
|
||||
data: { progressTotal, submittedTotal, successTotal, failedTotal, unknownTotal, timeoutTotal, status },
|
||||
});
|
||||
}
|
||||
|
||||
getSendQueue(): Queue<SendJob, unknown, 'send-message'> {
|
||||
if (!this.sendQueue) {
|
||||
this.sendQueue = new Queue<SendJob, unknown, 'send-message'>(SEND_QUEUE, { connection: bullmqConnection() });
|
||||
}
|
||||
return this.sendQueue;
|
||||
}
|
||||
|
||||
getGatewayQueue(): Queue {
|
||||
if (!this.gatewayQueue) {
|
||||
this.gatewayQueue = new Queue(GATEWAY_SUBMIT_QUEUE, { connection: bullmqConnection() });
|
||||
}
|
||||
return this.gatewayQueue;
|
||||
}
|
||||
|
||||
getRedis() {
|
||||
if (!this.redis) {
|
||||
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
}
|
||||
return this.redis;
|
||||
}
|
||||
|
||||
async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
|
||||
const redis = this.facade.getRedis();
|
||||
const stream = process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM;
|
||||
const payload = JSON.stringify(command);
|
||||
if (!idempotencyKey) {
|
||||
return redis.xadd(stream, '*', 'messageType', 'SubmitCommand', 'data', payload);
|
||||
}
|
||||
const result = await redis.eval(
|
||||
`local existing = redis.call('GET', KEYS[2])
|
||||
if existing then return existing end
|
||||
local streamId = redis.call('XADD', KEYS[1], '*', 'messageType', 'SubmitCommand', 'data', ARGV[1])
|
||||
redis.call('SET', KEYS[2], streamId, 'EX', ARGV[2])
|
||||
return streamId`,
|
||||
2,
|
||||
stream,
|
||||
idempotencyKey,
|
||||
payload,
|
||||
String(GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS),
|
||||
);
|
||||
return typeof result === 'string' ? result : String(result ?? '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { isIpAllowed } from '../common/ip-allowlist';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
|
||||
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
|
||||
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
|
||||
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
||||
|
||||
/**
|
||||
* R9 inboundEntry implementation. Cross-method calls return through the stable SendChainService seam.
|
||||
*/
|
||||
export class SendInboundEntryService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly riskReview: RiskReviewService,
|
||||
private readonly phoneFrequency: PhoneFrequencyService,
|
||||
private readonly phoneRouting: PhoneRoutingLookupService,
|
||||
private readonly facade: SendSubmissionService,
|
||||
private readonly callbacks: SendSubmissionCallbacks,
|
||||
) {}
|
||||
|
||||
private releaseMessageReservation(
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
return this.callbacks.releaseMessageReservation(message, remark);
|
||||
}
|
||||
|
||||
private recordCmppFailureReceipt(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
applicationId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
cmppSubmitSequenceId?: string | null;
|
||||
cmppSubmitGroupMessageId?: string | null;
|
||||
},
|
||||
errorCode: string,
|
||||
reason: string,
|
||||
) {
|
||||
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
|
||||
}
|
||||
|
||||
|
||||
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
|
||||
const application = await this.facade.findInboundApplication(data.account);
|
||||
if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') {
|
||||
throw new BadRequestException('CMPP account is invalid or disabled');
|
||||
}
|
||||
if (!application.interfaceEnabled) {
|
||||
throw new BadRequestException('CMPP interface is disabled for this application');
|
||||
}
|
||||
if (application.tenant.certificationStatus !== 'approved') {
|
||||
throw new BadRequestException('Enterprise certification is not approved');
|
||||
}
|
||||
if (!matchesApplicationSecret(data, application.secretHash)) {
|
||||
throw new BadRequestException('CMPP account or password is invalid');
|
||||
}
|
||||
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
throw new BadRequestException('CMPP source IP is not in application allowlist');
|
||||
}
|
||||
return {
|
||||
applicationId: application.id,
|
||||
tenantId: application.tenantId,
|
||||
account: application.cmppAccount,
|
||||
enterpriseCode: application.cmppEnterpriseCode,
|
||||
passwordCipher: application.secretHash,
|
||||
maxConnections: application.cmppMaxConnections,
|
||||
status: 'authenticated',
|
||||
};
|
||||
}
|
||||
|
||||
async submitInboundMessage(data: GatewayInboundSubmitDto) {
|
||||
const phoneNumbers = data.phoneNumbers?.length
|
||||
? data.phoneNumbers.map((phoneNumber) => phoneNumber.trim())
|
||||
: data.phoneNumber
|
||||
? [data.phoneNumber.trim()]
|
||||
: [];
|
||||
if (phoneNumbers.length === 0) {
|
||||
throw new BadRequestException('CMPP submit phone number is invalid');
|
||||
}
|
||||
|
||||
const application = await this.facade.findInboundApplication(data.account);
|
||||
if (!application) {
|
||||
throw new BadRequestException('CMPP account is invalid');
|
||||
}
|
||||
if (application.status !== 'active' || application.tenant.status !== 'active' || !application.interfaceEnabled) {
|
||||
throw new BadRequestException('CMPP account is disabled for new submissions');
|
||||
}
|
||||
if (data.longMessage) {
|
||||
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
throw new BadRequestException('CMPP source IP is not in application allowlist');
|
||||
}
|
||||
validateInboundApplicationSrcId(data.srcId, application);
|
||||
const collection = await this.facade.collectInboundLongMessageFragment(data, application, phoneNumbers);
|
||||
if (collection.response) {
|
||||
return collection.response;
|
||||
}
|
||||
if (!collection.complete) {
|
||||
return {
|
||||
accepted: true,
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
messageId: collection.messageId,
|
||||
status: 'fragment_pending',
|
||||
fragmentPending: true,
|
||||
receivedSegments: collection.receivedSegments,
|
||||
segmentTotal: data.longMessage.total,
|
||||
phoneCount: phoneNumbers.length,
|
||||
messages: phoneNumbers.map((phoneNumber) => ({
|
||||
phoneNumber,
|
||||
messageId: collection.messageId,
|
||||
status: 'fragment_pending',
|
||||
})),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const response = await this.facade.recoverCompletedInboundLongMessageResponse(
|
||||
collection.messageId,
|
||||
phoneNumbers,
|
||||
) ?? await this.facade.submitCompleteInboundMessage({
|
||||
...data,
|
||||
content: collection.content,
|
||||
sequenceId: collection.sequenceId,
|
||||
longMessage: undefined,
|
||||
}, phoneNumbers, application, collection.messageId);
|
||||
await this.prisma.cmppInboundLongMessage.update({
|
||||
where: { id: collection.groupId },
|
||||
data: {
|
||||
status: 'completed',
|
||||
response: JSON.parse(JSON.stringify(response)) as Prisma.InputJsonValue,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
await this.prisma.cmppInboundLongMessage.update({
|
||||
where: { id: collection.groupId },
|
||||
data: {
|
||||
status: 'rejected',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return this.facade.submitCompleteInboundMessage(data, phoneNumbers, application);
|
||||
}
|
||||
|
||||
async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) {
|
||||
const existing = await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
cmppSubmitGroupMessageId: messageId,
|
||||
phoneNumber: { in: phoneNumbers },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
applicationId: true,
|
||||
batchTaskId: true,
|
||||
messageId: true,
|
||||
phoneNumber: true,
|
||||
status: true,
|
||||
errorCode: true,
|
||||
},
|
||||
});
|
||||
const byPhone = new Map(existing.map((item) => [item.phoneNumber, item]));
|
||||
const ordered = phoneNumbers.map((phoneNumber) => byPhone.get(phoneNumber));
|
||||
if (ordered.some((item) => !item)) {
|
||||
return null;
|
||||
}
|
||||
const messages = ordered.map((item, index) => ({
|
||||
phoneNumber: phoneNumbers[index],
|
||||
messageId: item!.messageId,
|
||||
messageRecordId: item!.id,
|
||||
taskId: item!.batchTaskId ?? '',
|
||||
status: item!.status,
|
||||
}));
|
||||
const first = ordered[0]!;
|
||||
const dailyLimitRejected = ordered.every((item) => item!.errorCode === 'DAILY_LIMIT');
|
||||
return {
|
||||
accepted: !dailyLimitRejected,
|
||||
tenantId: first.tenantId ?? '',
|
||||
applicationId: first.applicationId ?? '',
|
||||
taskId: first.batchTaskId ?? '',
|
||||
messageId: first.messageId,
|
||||
messageRecordId: first.id,
|
||||
status: dailyLimitRejected ? 'rejected' : 'accepted',
|
||||
result: dailyLimitRejected ? 8 : undefined,
|
||||
phoneCount: messages.length,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
async submitCompleteInboundMessage(
|
||||
data: GatewayInboundSubmitDto,
|
||||
phoneNumbers: string[],
|
||||
application: Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>,
|
||||
requestedGroupMessageId?: string,
|
||||
) {
|
||||
if (!application) {
|
||||
throw new BadRequestException('CMPP account is invalid');
|
||||
}
|
||||
const persisted = requestedGroupMessageId
|
||||
? await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
cmppSubmitGroupMessageId: requestedGroupMessageId,
|
||||
phoneNumber: { in: phoneNumbers },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
applicationId: true,
|
||||
batchTaskId: true,
|
||||
messageId: true,
|
||||
phoneNumber: true,
|
||||
status: true,
|
||||
errorCode: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const persistedByPhone = new Map(persisted.map((item) => [item.phoneNumber, item]));
|
||||
const phoneRejections = await this.facade.classifyRejectedPhones(application.tenantId, application.id, phoneNumbers);
|
||||
const missingPhoneCount = phoneNumbers.filter((phoneNumber) => (
|
||||
!persistedByPhone.has(phoneNumber) && !phoneRejections.has(phoneNumber)
|
||||
)).length;
|
||||
const dailyQuota = missingPhoneCount > 0
|
||||
? await this.facade.tryReserveDailySendQuota(application.id, missingPhoneCount)
|
||||
: { reserved: true, dailyLimit: application.dailyLimit ?? 100000 };
|
||||
const dailyLimitRejection = dailyQuota.reserved
|
||||
? undefined
|
||||
: {
|
||||
code: 'DAILY_LIMIT',
|
||||
reason: `应用当日发送上限${dailyQuota.dailyLimit}条,本次${missingPhoneCount}条超出剩余配额`,
|
||||
};
|
||||
|
||||
const submitGroupMessageId = requestedGroupMessageId ?? `MSG-${randomUUID()}`;
|
||||
const submissions = phoneNumbers.map((phoneNumber, index) => ({
|
||||
phoneNumber,
|
||||
persisted: persistedByPhone.get(phoneNumber),
|
||||
receiptRejection: phoneRejections.get(phoneNumber),
|
||||
messageId: persistedByPhone.get(phoneNumber)?.messageId
|
||||
?? (index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`),
|
||||
}));
|
||||
const results: GatewayInboundSingleSubmitResult[] = [];
|
||||
const concurrency = 10;
|
||||
for (let offset = 0; offset < submissions.length; offset += concurrency) {
|
||||
const batch = submissions.slice(offset, offset + concurrency);
|
||||
results.push(...await Promise.all(batch.map((submission) => submission.persisted
|
||||
? Promise.resolve({
|
||||
accepted: submission.persisted.errorCode !== 'DAILY_LIMIT',
|
||||
tenantId: submission.persisted.tenantId ?? application.tenantId,
|
||||
applicationId: submission.persisted.applicationId ?? application.id,
|
||||
taskId: submission.persisted.batchTaskId ?? '',
|
||||
messageId: submission.persisted.messageId,
|
||||
messageRecordId: submission.persisted.id,
|
||||
status: submission.persisted.status,
|
||||
})
|
||||
: this.facade.submitInboundSingleMessage({
|
||||
...data,
|
||||
phoneNumber: submission.phoneNumber,
|
||||
phoneNumbers: undefined,
|
||||
}, submission.messageId, submitGroupMessageId, submission.receiptRejection ? undefined : dailyLimitRejection, submission.receiptRejection))));
|
||||
}
|
||||
const first = results[0];
|
||||
return {
|
||||
...first,
|
||||
result: dailyLimitRejection ? 8 : undefined,
|
||||
phoneCount: results.length,
|
||||
messages: results.map((result, index) => ({
|
||||
phoneNumber: phoneNumbers[index],
|
||||
messageId: result.messageId,
|
||||
messageRecordId: result.messageRecordId,
|
||||
taskId: result.taskId,
|
||||
status: result.status,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async collectInboundLongMessageFragment(
|
||||
data: GatewayInboundSubmitDto,
|
||||
application: NonNullable<Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>>,
|
||||
phoneNumbers: string[],
|
||||
) {
|
||||
const fragment = data.longMessage;
|
||||
if (!fragment || !Number.isInteger(fragment.reference) || fragment.reference < 0 || fragment.reference > 65535
|
||||
|| !Number.isInteger(fragment.total) || fragment.total < 2 || fragment.total > 255
|
||||
|| !Number.isInteger(fragment.index) || fragment.index < 1 || fragment.index > fragment.total
|
||||
|| !Number.isInteger(fragment.format) || fragment.format < 0 || fragment.format > 255) {
|
||||
throw new BadRequestException('CMPP long message fragment metadata is invalid');
|
||||
}
|
||||
const groupKey = createHash('sha256').update(JSON.stringify({
|
||||
applicationId: application.id,
|
||||
account: data.account,
|
||||
srcId: data.srcId?.trim() ?? '',
|
||||
phoneNumbers,
|
||||
reference: fragment.reference,
|
||||
total: fragment.total,
|
||||
format: fragment.format,
|
||||
})).digest('hex');
|
||||
const contentHash = createHash('sha256').update(data.content).digest('hex');
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + positiveInteger(
|
||||
process.env.CMPP_INBOUND_LONG_MESSAGE_TTL_SECONDS,
|
||||
300,
|
||||
) * 1000);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${groupKey}, 0))`;
|
||||
await tx.cmppInboundLongMessage.updateMany({
|
||||
where: {
|
||||
groupKey,
|
||||
status: { in: ['collecting', 'processing'] },
|
||||
expiresAt: { lte: now },
|
||||
},
|
||||
data: { status: 'expired', completedAt: now },
|
||||
});
|
||||
|
||||
const recent = await tx.cmppInboundLongMessage.findFirst({
|
||||
where: {
|
||||
groupKey,
|
||||
expiresAt: { gt: now },
|
||||
},
|
||||
include: { segments: { orderBy: { segmentIndex: 'asc' } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const matchingRecentSegment = recent?.segments.find((item) => item.segmentIndex === fragment.index);
|
||||
if (recent && ['completed', 'rejected'].includes(recent.status)
|
||||
&& matchingRecentSegment?.contentHash === contentHash
|
||||
&& matchingRecentSegment.sequenceId === (data.sequenceId == null ? null : String(data.sequenceId))) {
|
||||
return {
|
||||
complete: recent.status === 'completed',
|
||||
groupId: recent.id,
|
||||
messageId: recent.messageId,
|
||||
receivedSegments: recent.segments.length,
|
||||
response: recent.response as any,
|
||||
content: recent.segments.map((item) => item.content).join(''),
|
||||
sequenceId: parseOptionalSequenceId(recent.segments[0]?.sequenceId),
|
||||
};
|
||||
}
|
||||
|
||||
let group = recent && ['collecting', 'processing'].includes(recent.status) ? recent : null;
|
||||
if (!group) {
|
||||
group = await tx.cmppInboundLongMessage.create({
|
||||
data: {
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
groupKey,
|
||||
account: data.account,
|
||||
srcId: data.srcId?.trim() || null,
|
||||
phoneNumbers,
|
||||
concatReference: fragment.reference,
|
||||
segmentTotal: fragment.total,
|
||||
msgFmt: fragment.format,
|
||||
messageId: `MSG-${randomUUID()}`,
|
||||
expiresAt,
|
||||
},
|
||||
include: { segments: { orderBy: { segmentIndex: 'asc' } } },
|
||||
});
|
||||
}
|
||||
if (group.status === 'processing') {
|
||||
const processingStaleMs = positiveInteger(
|
||||
process.env.CMPP_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS,
|
||||
DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS,
|
||||
) * 1000;
|
||||
const complete = group.segments.length === fragment.total
|
||||
&& group.segments.every((item, index) => item.segmentIndex === index + 1);
|
||||
if (complete && now.getTime() - group.updatedAt.getTime() >= processingStaleMs) {
|
||||
await tx.cmppInboundLongMessage.update({
|
||||
where: { id: group.id },
|
||||
data: { status: 'processing', expiresAt },
|
||||
});
|
||||
return {
|
||||
complete: true,
|
||||
groupId: group.id,
|
||||
messageId: group.messageId,
|
||||
receivedSegments: group.segments.length,
|
||||
response: null,
|
||||
content: group.segments.map((item) => item.content).join(''),
|
||||
sequenceId: parseOptionalSequenceId(group.segments[0]?.sequenceId),
|
||||
};
|
||||
}
|
||||
return {
|
||||
complete: false,
|
||||
groupId: group.id,
|
||||
messageId: group.messageId,
|
||||
receivedSegments: group.segments.length,
|
||||
response: group.response as any,
|
||||
content: '',
|
||||
sequenceId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const existing = group.segments.find((item) => item.segmentIndex === fragment.index);
|
||||
if (existing && (existing.contentHash !== contentHash
|
||||
|| existing.sequenceId !== (data.sequenceId == null ? null : String(data.sequenceId)))) {
|
||||
throw new BadRequestException(`CMPP long message fragment ${fragment.index} conflicts with the stored fragment`);
|
||||
}
|
||||
if (!existing) {
|
||||
await tx.cmppInboundLongMessageSegment.create({
|
||||
data: {
|
||||
groupId: group.id,
|
||||
segmentIndex: fragment.index,
|
||||
sequenceId: data.sequenceId == null ? null : String(data.sequenceId),
|
||||
content: data.content,
|
||||
contentHash,
|
||||
},
|
||||
});
|
||||
}
|
||||
const segments = await tx.cmppInboundLongMessageSegment.findMany({
|
||||
where: { groupId: group.id },
|
||||
orderBy: { segmentIndex: 'asc' },
|
||||
});
|
||||
const complete = segments.length === fragment.total
|
||||
&& segments.every((item, index) => item.segmentIndex === index + 1);
|
||||
if (complete) {
|
||||
await tx.cmppInboundLongMessage.update({
|
||||
where: { id: group.id },
|
||||
data: { status: 'processing', expiresAt },
|
||||
});
|
||||
}
|
||||
return {
|
||||
complete,
|
||||
groupId: group.id,
|
||||
messageId: group.messageId,
|
||||
receivedSegments: segments.length,
|
||||
response: null,
|
||||
content: complete ? segments.map((item) => item.content).join('') : '',
|
||||
sequenceId: parseOptionalSequenceId(segments[0]?.sequenceId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async expireInboundLongMessages(now = new Date()) {
|
||||
return this.prisma.cmppInboundLongMessage.updateMany({
|
||||
where: {
|
||||
status: { in: ['collecting', 'processing'] },
|
||||
expiresAt: { lte: now },
|
||||
},
|
||||
data: {
|
||||
status: 'expired',
|
||||
completedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async submitInboundSingleMessage(
|
||||
data: GatewayInboundSubmitDto & { phoneNumber: string },
|
||||
messageId: string,
|
||||
submitGroupMessageId: string,
|
||||
synchronousRejection?: { code: string; reason: string },
|
||||
receiptRejection?: { code: string; reason: string },
|
||||
) {
|
||||
const application = await this.facade.findInboundApplication(data.account);
|
||||
if (!application) {
|
||||
throw new BadRequestException('CMPP account is invalid');
|
||||
}
|
||||
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
throw new BadRequestException('CMPP source IP is not in application allowlist');
|
||||
}
|
||||
const clientSrcId = validateInboundApplicationSrcId(data.srcId, application);
|
||||
const template = await this.facade.resolveInboundTemplateCandidate(application.id, data.content);
|
||||
const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {};
|
||||
const unitPrice = moneyToNumber(application.customerUnitPrice);
|
||||
const queuePriority = normalizeQueuePriority(application.queuePriority);
|
||||
const billing = this.billing.estimateSmsCost({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
content: data.content,
|
||||
phoneCount: 1,
|
||||
unitPrice,
|
||||
});
|
||||
const task = await this.prisma.smsBatchTask.create({
|
||||
data: {
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
templateId: template?.id,
|
||||
taskNo: `BT-${Date.now()}-${randomUUID().slice(0, 8)}`,
|
||||
sourceType: 'cmpp',
|
||||
content: data.content,
|
||||
phoneTotal: 1,
|
||||
status: synchronousRejection ? 'rejected' : 'validating',
|
||||
auditStatus: synchronousRejection ? 'rejected' : undefined,
|
||||
rejectReason: synchronousRejection?.reason,
|
||||
progressTotal: 1,
|
||||
},
|
||||
});
|
||||
await this.prisma.smsApiRequest.create({
|
||||
data: {
|
||||
tenantId: application.tenantId,
|
||||
batchTaskId: task.id,
|
||||
requestId: `REQ-${Date.now()}-${randomUUID().slice(0, 8)}`,
|
||||
sourceIp: data.remoteIp,
|
||||
userAgent: 'cmpp-gateway',
|
||||
payloadSummary: { phoneTotal: 1, contentLength: [...data.content].length, account: data.account },
|
||||
status: synchronousRejection ? 'rejected' : 'accepted',
|
||||
},
|
||||
});
|
||||
const message = await this.prisma.smsMessageRecord.create({
|
||||
data: {
|
||||
tenantId: application.tenantId,
|
||||
batchTaskId: task.id,
|
||||
applicationId: application.id,
|
||||
templateId: template?.id,
|
||||
messageId,
|
||||
phoneNumber: data.phoneNumber,
|
||||
content: data.content,
|
||||
billingUnits: billing.billingUnitsPerMessage,
|
||||
unitPrice: receiptRejection ? 0 : billing.unitPrice,
|
||||
amountCents: receiptRejection ? 0 : billing.amountCents,
|
||||
queuePriority,
|
||||
cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId),
|
||||
cmppSubmitGroupMessageId: submitGroupMessageId,
|
||||
clientSrcId,
|
||||
applicationExtension: application.cmppApplicationExtension,
|
||||
status: synchronousRejection ? 'rejected' : 'validating',
|
||||
errorCode: synchronousRejection?.code,
|
||||
errorMessage: synchronousRejection?.reason,
|
||||
},
|
||||
});
|
||||
|
||||
if (synchronousRejection) {
|
||||
return {
|
||||
accepted: false,
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
taskId: task.id,
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
status: 'rejected',
|
||||
};
|
||||
}
|
||||
|
||||
const reject = async (code: string, reason: string) => {
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'rejected', auditStatus: 'rejected', rejectReason: reason },
|
||||
});
|
||||
await this.recordCmppFailureReceipt(message, code, reason);
|
||||
};
|
||||
const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => {
|
||||
const drainage = await this.facade.resolveDrainageInfoMatch(options.signatureId, data.content);
|
||||
const drainageInfoId = drainage?.id;
|
||||
const drainageReason = drainageRejectionReason(drainage);
|
||||
if (drainageReason) {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { drainageInfoId, signatureId: options.signatureId },
|
||||
});
|
||||
await reject('DRAINAGE_NOT_APPROVED', drainageReason);
|
||||
return;
|
||||
}
|
||||
const risk = await this.facade.evaluateRiskWithPhoneFrequency({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
templateId: options.templateId,
|
||||
content: data.content,
|
||||
variables: options.templateId ? templateVariables : undefined,
|
||||
phoneNumber: data.phoneNumber,
|
||||
sourceType: 'cmpp',
|
||||
});
|
||||
if (risk.status === 'rejected') {
|
||||
await reject('RISK', risk.reason || '短信被风控拒绝');
|
||||
return;
|
||||
}
|
||||
if (risk.status === 'pending_review') {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: {
|
||||
status: 'pending_review',
|
||||
reviewTaskId: risk.task?.id,
|
||||
signatureId: options.signatureId,
|
||||
drainageInfoId,
|
||||
},
|
||||
});
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'pending_review', riskTaskId: risk.task?.id, auditStatus: 'pending', reviewReason: risk.reason },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const accountCheck = await this.billing.checkAccount({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
});
|
||||
if (!accountCheck.canSend) {
|
||||
await reject('BALANCE', '企业账户余额不足');
|
||||
return;
|
||||
}
|
||||
if (billing.amountCents > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: 'CMPP 入站短信冻结',
|
||||
});
|
||||
}
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { status: 'queued', signatureId: options.signatureId, drainageInfoId },
|
||||
});
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'ready', riskTaskId: risk.task?.id, auditStatus: 'approved' },
|
||||
});
|
||||
await this.facade.enqueueBatchTask(task.id);
|
||||
};
|
||||
if (receiptRejection) {
|
||||
await reject(receiptRejection.code, receiptRejection.reason);
|
||||
} else if (application.status !== 'active' || application.tenant.status !== 'active') {
|
||||
await reject('ACCOUNT', '企业或短信应用已停用');
|
||||
} else if (!application.interfaceEnabled) {
|
||||
await reject('INTERFACE', '短信应用 CMPP 接口已停用');
|
||||
} else if (application.tenant.certificationStatus !== 'approved') {
|
||||
await reject('CERT', '企业认证未通过');
|
||||
} else if (!template && application.templateMismatchMode === 'manual_review') {
|
||||
const signature = await this.facade.resolveInboundSignatureCandidate(application.id, data.content);
|
||||
if (!signature) {
|
||||
await reject('SIGNATURE', '短信内容未识别到已审核通过的签名');
|
||||
} else {
|
||||
const drainage = await this.facade.resolveDrainageInfoMatch(signature.id, data.content);
|
||||
const drainageReason = drainageRejectionReason(drainage);
|
||||
if (drainageReason) {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { drainageInfoId: drainage?.id, signatureId: signature.id },
|
||||
});
|
||||
await reject('DRAINAGE_NOT_APPROVED', drainageReason);
|
||||
return {
|
||||
accepted: true,
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
messageId,
|
||||
messageRecordId: message.id,
|
||||
taskId: task.id,
|
||||
status: 'rejected',
|
||||
};
|
||||
}
|
||||
const risk = await this.facade.evaluateRiskWithPhoneFrequency({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
content: data.content,
|
||||
phoneNumber: data.phoneNumber,
|
||||
sourceType: 'cmpp',
|
||||
});
|
||||
if (risk.status === 'rejected') {
|
||||
await reject('RISK', risk.reason || '短信被风控拒绝');
|
||||
} else {
|
||||
const accountCheck = await this.billing.checkAccount({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
});
|
||||
if (!accountCheck.canSend) {
|
||||
await reject('BALANCE', '企业账户余额不足');
|
||||
} else {
|
||||
if (billing.amountCents > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: 'CMPP 模板不匹配待审核短信冻结',
|
||||
});
|
||||
}
|
||||
const reviewTask = risk.status === 'pending_review' && risk.task
|
||||
? await this.facade.attachMessageToReviewTask(risk.task.id, message.id, signature.id, drainage?.id)
|
||||
: await this.riskReview.aggregateTemplateMismatch({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
account: data.account,
|
||||
messageRecordId: message.id,
|
||||
signatureId: signature.id,
|
||||
content: data.content,
|
||||
});
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
status: 'pending_review',
|
||||
riskTaskId: reviewTask?.id,
|
||||
auditStatus: 'pending',
|
||||
reviewReason: reviewTask?.reviewReason ?? '模板不匹配,等待人工审核',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!template && application.templateMismatchMode === 'direct_send') {
|
||||
const signature = await this.facade.resolveInboundSignatureCandidate(application.id, data.content);
|
||||
if (!signature) {
|
||||
await reject('SIGNATURE', '短信内容未识别到已审核通过的签名');
|
||||
} else {
|
||||
await queueAfterRiskChecks({ signatureId: signature.id });
|
||||
}
|
||||
} else if (!template) {
|
||||
await reject('TEMPLATE', '短信内容未匹配到已报备模板');
|
||||
} else if (template.auditStatus !== 'approved') {
|
||||
await reject('TEMPLATE', '短信模板尚未审核通过');
|
||||
} else if (!template.signature || template.signature.auditStatus !== 'approved') {
|
||||
await reject('SIGNATURE', '短信签名尚未审核通过');
|
||||
} else {
|
||||
await queueAfterRiskChecks({ templateId: template.id, signatureId: template.signature.id });
|
||||
}
|
||||
return {
|
||||
accepted: true,
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
taskId: task.id,
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
status: 'accepted',
|
||||
};
|
||||
}
|
||||
|
||||
async evaluateRiskWithPhoneFrequency(input: {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
templateId?: string;
|
||||
content: string;
|
||||
variables?: Record<string, unknown>;
|
||||
phoneNumber: string;
|
||||
sourceType: 'cmpp';
|
||||
}) {
|
||||
const risk = await this.riskReview.evaluateTask({
|
||||
tenantId: input.tenantId,
|
||||
applicationId: input.applicationId,
|
||||
templateId: input.templateId,
|
||||
content: input.content,
|
||||
variables: input.variables,
|
||||
phones: [input.phoneNumber],
|
||||
sourceType: input.sourceType,
|
||||
});
|
||||
if (risk.status === 'rejected') return risk;
|
||||
const frequencyRejections = await this.phoneFrequency.reserve(
|
||||
input.tenantId,
|
||||
input.applicationId,
|
||||
[input.phoneNumber],
|
||||
input.sourceType,
|
||||
);
|
||||
const rejection = frequencyRejections.get(input.phoneNumber);
|
||||
return rejection
|
||||
? { ...risk, status: 'rejected' as const, reason: rejection.reason }
|
||||
: risk;
|
||||
}
|
||||
|
||||
findInboundApplication(account: string) {
|
||||
return this.prisma.smsApplication.findFirst({
|
||||
where: { cmppAccount: account },
|
||||
include: {
|
||||
tenant: true,
|
||||
ipAllowlist: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async resolveInboundTemplateCandidate(applicationId: string, content: string) {
|
||||
const exact = await this.prisma.smsTemplate.findFirst({
|
||||
where: {
|
||||
applicationId,
|
||||
content,
|
||||
auditStatus: 'approved',
|
||||
signature: { auditStatus: 'approved' },
|
||||
},
|
||||
include: { signature: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
if (exact) return exact;
|
||||
const variableTemplates = await this.prisma.smsTemplate.findMany({
|
||||
where: {
|
||||
applicationId,
|
||||
content: { contains: '${' },
|
||||
auditStatus: 'approved',
|
||||
signature: { auditStatus: 'approved' },
|
||||
},
|
||||
include: { signature: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
return variableTemplates.find((template) => matchTemplateContent(template.content, content) !== null) ?? null;
|
||||
}
|
||||
|
||||
resolveInboundSignatureCandidate(applicationId: string, content: string) {
|
||||
const match = content.match(/^【[^】]+】/);
|
||||
if (!match?.[0]) return null;
|
||||
return this.prisma.smsSignature.findFirst({
|
||||
where: {
|
||||
applicationId,
|
||||
name: match[0],
|
||||
auditStatus: 'approved',
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) {
|
||||
if (!signatureId) return undefined;
|
||||
const candidates = await this.prisma.smsDrainageInfo.findMany({
|
||||
where: { signatureId, auditStatus: { not: 'deleted' } },
|
||||
select: { id: true, url: true, auditStatus: true, updatedAt: true },
|
||||
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
|
||||
});
|
||||
const matches = candidates
|
||||
.map((item) => ({ ...item, normalizedUrl: item.url.trim() }))
|
||||
.filter((item) => item.normalizedUrl.length > 0 && content.includes(item.normalizedUrl))
|
||||
.sort((left, right) => right.normalizedUrl.length - left.normalizedUrl.length || right.updatedAt.getTime() - left.updatedAt.getTime());
|
||||
if (matches.length === 0) return undefined;
|
||||
const longestLength = matches[0].normalizedUrl.length;
|
||||
const longestMatches = matches.filter((item) => item.normalizedUrl.length === longestLength);
|
||||
if (longestMatches.length !== 1) {
|
||||
throw new BadRequestException({
|
||||
code: 'DRAINAGE_MATCH_AMBIGUOUS',
|
||||
message: '短信内容同时匹配多条等长引流地址,无法确定报备资料',
|
||||
drainageInfoIds: longestMatches.map((item) => item.id),
|
||||
});
|
||||
}
|
||||
const matched = longestMatches[0];
|
||||
return { id: matched.id, auditStatus: matched.auditStatus };
|
||||
}
|
||||
|
||||
async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: messageRecordId },
|
||||
data: { reviewTaskId, signatureId, drainageInfoId, status: 'pending_review' },
|
||||
});
|
||||
return this.prisma.smsSendTask.findUnique({ where: { id: reviewTaskId } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
|
||||
|
||||
/**
|
||||
* R10 receipt implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendReceiptService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
private upstreamReceiptInboxScanRunning = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async intakeReceipt(data: GatewayReceiptEventDto) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({
|
||||
where: { id: data.channelId },
|
||||
select: {
|
||||
id: true,
|
||||
account: true,
|
||||
gatewayHost: true,
|
||||
gatewayPort: true,
|
||||
protocol: true,
|
||||
cmppVersion: true,
|
||||
},
|
||||
});
|
||||
if (!channel) {
|
||||
throw new NotFoundException('SMS channel not found');
|
||||
}
|
||||
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
|
||||
const receiptKey = receiptEventKey(data, data.channelId);
|
||||
const inbox = await this.prisma.upstreamReceiptInbox.upsert({
|
||||
where: { receiptKey },
|
||||
update: {
|
||||
incomingConnectionId: data.connectionId,
|
||||
},
|
||||
create: {
|
||||
receiptKey,
|
||||
incomingChannelId: data.channelId,
|
||||
incomingConnectionId: data.connectionId,
|
||||
upstreamAccount: channel.account,
|
||||
upstreamHost: channel.gatewayHost,
|
||||
upstreamPort: channel.gatewayPort,
|
||||
protocol: channel.protocol,
|
||||
protocolVersion: channel.cmppVersion,
|
||||
provisionalMessageId: data.messageId,
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
phoneNumber: data.phoneNumber?.trim() || null,
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
deliveredAt,
|
||||
status: 'pending',
|
||||
nextRetryAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (['pending', 'retrying'].includes(inbox.status)) {
|
||||
setImmediate(() => void this.facade.processUpstreamReceiptInboxRecord(inbox.id));
|
||||
}
|
||||
return { accepted: true, inboxId: inbox.id, status: inbox.status };
|
||||
}
|
||||
|
||||
async processPendingUpstreamReceiptInbox(limit = 100) {
|
||||
const now = new Date();
|
||||
const staleBefore = new Date(
|
||||
now.getTime()
|
||||
- positiveInteger(
|
||||
process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||
),
|
||||
);
|
||||
const candidates = await this.prisma.upstreamReceiptInbox.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
status: { in: ['pending', 'retrying'] },
|
||||
OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }],
|
||||
},
|
||||
{ status: 'processing', updatedAt: { lte: staleBefore } },
|
||||
],
|
||||
},
|
||||
orderBy: [{ receivedAt: 'asc' }, { id: 'asc' }],
|
||||
take: Math.min(Math.max(limit, 1), 500),
|
||||
select: { id: true },
|
||||
});
|
||||
let processed = 0;
|
||||
for (const candidate of candidates) {
|
||||
if (await this.facade.processUpstreamReceiptInboxRecord(candidate.id)) processed += 1;
|
||||
}
|
||||
return { scanned: candidates.length, processed };
|
||||
}
|
||||
|
||||
async processUpstreamReceiptInboxRecord(id: string) {
|
||||
const staleBefore = new Date(
|
||||
Date.now()
|
||||
- positiveInteger(
|
||||
process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||
),
|
||||
);
|
||||
const claimed = await this.prisma.upstreamReceiptInbox.updateMany({
|
||||
where: {
|
||||
id,
|
||||
OR: [
|
||||
{ status: { in: ['pending', 'retrying'] } },
|
||||
{ status: 'processing', updatedAt: { lte: staleBefore } },
|
||||
],
|
||||
},
|
||||
data: { status: 'processing', attemptCount: { increment: 1 }, nextRetryAt: null },
|
||||
});
|
||||
if (claimed.count !== 1) return false;
|
||||
const inbox = await this.prisma.upstreamReceiptInbox.findUnique({ where: { id } });
|
||||
if (!inbox) return false;
|
||||
try {
|
||||
const message = await this.facade.handleReceipt({
|
||||
messageId: inbox.provisionalMessageId ?? undefined,
|
||||
channelId: inbox.incomingChannelId,
|
||||
connectionId: inbox.incomingConnectionId ?? undefined,
|
||||
sequenceId: inbox.sequenceId ?? undefined,
|
||||
gatewayMessageId: inbox.gatewayMessageId,
|
||||
phoneNumber: inbox.phoneNumber ?? undefined,
|
||||
receiptStatus: normalizeReceiptStatus(inbox.receiptStatus),
|
||||
rawStatus: inbox.rawStatus,
|
||||
errorCode: inbox.errorCode ?? undefined,
|
||||
errorMessage: inbox.errorMessage ?? undefined,
|
||||
deliveredAt: inbox.deliveredAt.toISOString(),
|
||||
}, {
|
||||
account: inbox.upstreamAccount,
|
||||
gatewayHost: inbox.upstreamHost,
|
||||
gatewayPort: inbox.upstreamPort,
|
||||
protocol: inbox.protocol,
|
||||
cmppVersion: inbox.protocolVersion,
|
||||
});
|
||||
const matchedMessageRecordId = message && 'id' in message ? message.id : message?.messageRecordId;
|
||||
const matchedChannelId = message && 'channelId' in message ? message.channelId : undefined;
|
||||
await this.prisma.upstreamReceiptInbox.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'matched',
|
||||
matchedMessageRecordId: matchedMessageRecordId ?? null,
|
||||
matchedChannelId: matchedChannelId ?? null,
|
||||
lastError: null,
|
||||
processedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const maxAttempts = positiveInteger(
|
||||
process.env.UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS,
|
||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS,
|
||||
);
|
||||
const maxAgeHours = positiveInteger(
|
||||
process.env.UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS,
|
||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS,
|
||||
);
|
||||
const exhausted = inbox.attemptCount >= maxAttempts
|
||||
|| inbox.receivedAt.getTime() <= Date.now() - maxAgeHours * 60 * 60_000;
|
||||
const retryDelayMs = Math.min(30 * 60_000, 5_000 * 2 ** Math.min(Math.max(inbox.attemptCount - 1, 0), 8));
|
||||
await this.prisma.upstreamReceiptInbox.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: exhausted ? 'unmatched' : 'retrying',
|
||||
nextRetryAt: exhausted ? null : new Date(Date.now() + retryDelayMs),
|
||||
lastError: error instanceof Error ? error.message : String(error),
|
||||
processedAt: exhausted ? new Date() : null,
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async runUpstreamReceiptInboxScan() {
|
||||
if (this.upstreamReceiptInboxScanRunning) return;
|
||||
this.upstreamReceiptInboxScanRunning = true;
|
||||
try {
|
||||
const result = await this.facade.processPendingUpstreamReceiptInbox();
|
||||
if (result.processed > 0) {
|
||||
this.logger.log(`Matched ${result.processed}/${result.scanned} pending upstream receipts`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Upstream receipt inbox scan failed', error instanceof Error ? error.stack : String(error));
|
||||
} finally {
|
||||
this.upstreamReceiptInboxScanRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
async handleReceipt(
|
||||
data: GatewayReceiptEventDto,
|
||||
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
) {
|
||||
const resolved = await this.facade.resolveReceiptMessage(data, incomingIdentity);
|
||||
const logicalChannelId = resolved.channelId ?? data.channelId;
|
||||
const receiptKey = receiptEventKey(data, logicalChannelId);
|
||||
const existingReceipt = await this.prisma.smsReceiptRecord.findUnique({
|
||||
where: { receiptKey },
|
||||
include: { messageRecord: true },
|
||||
});
|
||||
if (existingReceipt?.messageRecord) {
|
||||
return existingReceipt.messageRecord;
|
||||
}
|
||||
const message = resolved.message;
|
||||
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
|
||||
if (resolved.submitRecordId) {
|
||||
await this.prisma.smsSubmitRecord.updateMany({
|
||||
where: {
|
||||
id: resolved.submitRecordId,
|
||||
gatewayMessageId: null,
|
||||
},
|
||||
data: {
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
sequenceId: data.sequenceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
try {
|
||||
await this.prisma.smsReceiptRecord.create({
|
||||
data: {
|
||||
tenantId: message.tenantId,
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
receiptKey,
|
||||
channelId: logicalChannelId,
|
||||
messageId: resolved.messageId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
phoneNumber: data.phoneNumber?.trim() || message.phoneNumber,
|
||||
sequenceId: data.sequenceId,
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
deliveredAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
const duplicate = await this.prisma.smsReceiptRecord.findUnique({
|
||||
where: { receiptKey },
|
||||
include: { messageRecord: true },
|
||||
});
|
||||
if (duplicate?.messageRecord) return duplicate.messageRecord;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const logicalReceipt = { ...data, channelId: logicalChannelId };
|
||||
await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId);
|
||||
const aggregate = await this.facade.aggregateReceiptSegments(
|
||||
message,
|
||||
logicalReceipt,
|
||||
deliveredAt,
|
||||
resolved.submitRecordId,
|
||||
resolved.submitId,
|
||||
);
|
||||
if (!aggregate.terminal) {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const status = aggregate.status;
|
||||
const isCurrentAttempt =
|
||||
(!message.channelId || message.channelId === logicalChannelId)
|
||||
&& (
|
||||
!message.gatewayMessageId
|
||||
|| message.gatewayMessageId === data.gatewayMessageId
|
||||
|| (aggregate.segmentTotal > 1 && (!message.submitId || message.submitId === resolved.submitId))
|
||||
);
|
||||
if (!isCurrentAttempt || (status === 'failed' && message.status === 'delivered')) {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId;
|
||||
if (status === 'failed' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
const retried = await this.facade.retryMessageIfAllowed(
|
||||
businessMessage,
|
||||
'回执失败补发',
|
||||
resolved.submitRecordId,
|
||||
);
|
||||
if (retried) {
|
||||
await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
|
||||
return retried;
|
||||
}
|
||||
await this.facade.refundMessage(businessMessage, '最终失败退款');
|
||||
}
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: {
|
||||
channelId: logicalChannelId,
|
||||
gatewayMessageId: message.gatewayMessageId ?? data.gatewayMessageId,
|
||||
receiptStatus: aggregate.receiptStatus,
|
||||
receiptRawStatus: aggregate.rawStatus,
|
||||
status,
|
||||
errorCode: aggregate.errorCode,
|
||||
errorMessage: aggregate.errorMessage ?? (status === 'delivered' ? null : aggregate.rawStatus),
|
||||
deliveredAt: aggregate.deliveredAt,
|
||||
},
|
||||
});
|
||||
if (!isStandaloneChannelTest && message.tenantId && message.applicationId) {
|
||||
await this.facade.queueAndTryDownstreamDelivery({
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId,
|
||||
messageRecordId: message.id,
|
||||
messageId: message.messageId,
|
||||
deliveryType: 'receipt',
|
||||
payload: {
|
||||
messageId: message.messageId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
receiptStatus: aggregate.receiptStatus,
|
||||
rawStatus: aggregate.rawStatus,
|
||||
errorCode: aggregate.errorCode,
|
||||
submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined,
|
||||
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? undefined,
|
||||
deliveredAt: aggregate.deliveredAt.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (message.batchTaskId) {
|
||||
await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||
}
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
|
||||
async recordReceiptSegment(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
channelId?: string | null;
|
||||
submitId?: string | null;
|
||||
billingUnits?: number | null;
|
||||
},
|
||||
data: GatewayReceiptEventDto,
|
||||
deliveredAt: Date,
|
||||
submitRecordId?: string,
|
||||
) {
|
||||
const segmentAudits = this.facade.smsMessageSegmentAuditDelegate();
|
||||
const updated = await segmentAudits.updateMany({
|
||||
where: {
|
||||
messageRecordId: message.id,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
},
|
||||
data: {
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode ?? null,
|
||||
deliveredAt,
|
||||
},
|
||||
});
|
||||
if (updated.count > 0) {
|
||||
return;
|
||||
}
|
||||
const submitRecord = submitRecordId
|
||||
? await this.prisma.smsSubmitRecord.findUnique({ where: { id: submitRecordId } })
|
||||
: await this.prisma.smsSubmitRecord.findFirst({
|
||||
where: { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
await segmentAudits.upsert({
|
||||
where: {
|
||||
messageRecordId_submitId_segmentIndex: {
|
||||
messageRecordId: message.id,
|
||||
submitId: submitRecord?.submitId ?? message.submitId ?? `RECEIPT-AUDIT-${data.gatewayMessageId}`,
|
||||
segmentIndex: 1,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
submitRecordId: submitRecord?.id ?? submitRecordId ?? null,
|
||||
channelId: data.channelId ?? message.channelId ?? null,
|
||||
sequenceId: data.sequenceId ?? null,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode ?? null,
|
||||
deliveredAt,
|
||||
},
|
||||
create: {
|
||||
tenantId: message.tenantId,
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
submitRecordId: submitRecord?.id ?? submitRecordId ?? null,
|
||||
channelId: data.channelId ?? message.channelId ?? null,
|
||||
submitId: submitRecord?.submitId ?? message.submitId ?? `RECEIPT-AUDIT-${data.gatewayMessageId}`,
|
||||
attempt: 0,
|
||||
segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)),
|
||||
segmentIndex: 1,
|
||||
sequenceId: data.sequenceId ?? null,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: submitRecord?.submitStatus ?? 'accepted',
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
compensationType: 'receipt_recovered',
|
||||
errorCode: data.errorCode ?? null,
|
||||
deliveredAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async aggregateReceiptSegments(
|
||||
message: {
|
||||
id: string;
|
||||
billingUnits?: number | null;
|
||||
},
|
||||
data: GatewayReceiptEventDto,
|
||||
deliveredAt: Date,
|
||||
submitRecordId?: string,
|
||||
submitId?: string,
|
||||
) {
|
||||
const audits = await this.facade.smsMessageSegmentAuditDelegate().findMany({
|
||||
where: submitRecordId
|
||||
? { messageRecordId: message.id, submitRecordId }
|
||||
: submitId
|
||||
? { messageRecordId: message.id, submitId }
|
||||
: { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId },
|
||||
orderBy: { segmentIndex: 'asc' },
|
||||
});
|
||||
return aggregateReceiptSegmentState(audits, message.billingUnits, data, deliveredAt);
|
||||
}
|
||||
|
||||
async resolveReceiptMessage(
|
||||
data: GatewayReceiptEventDto,
|
||||
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
) {
|
||||
const exactMessage = data.messageId
|
||||
? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } })
|
||||
: null;
|
||||
if (exactMessage) {
|
||||
const segmentAudit = data.gatewayMessageId
|
||||
? await this.facade.smsMessageSegmentAuditDelegate().findFirst({
|
||||
where: {
|
||||
messageRecordId: exactMessage.id,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
: null;
|
||||
if (segmentAudit) {
|
||||
return {
|
||||
message: exactMessage,
|
||||
messageId: exactMessage.messageId,
|
||||
submitRecordId: segmentAudit.submitRecordId ?? undefined,
|
||||
submitId: segmentAudit.submitId,
|
||||
channelId: segmentAudit.channelId ?? data.channelId,
|
||||
};
|
||||
}
|
||||
const submitRecord = await this.prisma.smsSubmitRecord.findFirst({
|
||||
where: {
|
||||
messageRecordId: exactMessage.id,
|
||||
channelId: data.channelId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return {
|
||||
message: exactMessage,
|
||||
messageId: exactMessage.messageId,
|
||||
submitRecordId: submitRecord?.id,
|
||||
submitId: submitRecord?.submitId,
|
||||
channelId: submitRecord?.channelId ?? data.channelId,
|
||||
};
|
||||
}
|
||||
|
||||
const phoneNumber = data.phoneNumber?.trim();
|
||||
const exactSubmits = await this.prisma.smsSubmitRecord.findMany({
|
||||
where: {
|
||||
channelId: data.channelId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
...(phoneNumber ? { messageRecord: { phoneNumber } } : {}),
|
||||
},
|
||||
include: { messageRecord: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 2,
|
||||
});
|
||||
if (exactSubmits.length === 1 && exactSubmits[0]?.messageRecord) {
|
||||
return {
|
||||
message: exactSubmits[0].messageRecord,
|
||||
messageId: exactSubmits[0].messageRecord.messageId,
|
||||
submitRecordId: exactSubmits[0].id,
|
||||
submitId: exactSubmits[0].submitId,
|
||||
channelId: exactSubmits[0].channelId,
|
||||
};
|
||||
}
|
||||
|
||||
if (!phoneNumber) {
|
||||
throw new NotFoundException('SMS message record not found');
|
||||
}
|
||||
|
||||
const incomingChannel = incomingIdentity
|
||||
?? await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
|
||||
if (!incomingChannel) {
|
||||
throw new NotFoundException('SMS message record not found');
|
||||
}
|
||||
const segmentMatches = await this.facade.smsMessageSegmentAuditDelegate().findMany({
|
||||
where: {
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
messageRecord: { phoneNumber },
|
||||
},
|
||||
include: { messageRecord: true, submitRecord: true, channel: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
const exactSegmentMatches = segmentMatches.filter((candidate) => candidate.channelId === data.channelId);
|
||||
if (exactSegmentMatches.length === 1 && exactSegmentMatches[0]?.messageRecord) {
|
||||
return {
|
||||
message: exactSegmentMatches[0].messageRecord,
|
||||
messageId: exactSegmentMatches[0].messageRecord.messageId,
|
||||
submitRecordId: exactSegmentMatches[0].submitRecordId ?? undefined,
|
||||
submitId: exactSegmentMatches[0].submitRecord?.submitId ?? exactSegmentMatches[0].submitId,
|
||||
channelId: exactSegmentMatches[0].channelId,
|
||||
};
|
||||
}
|
||||
const sameSupplierSegments = segmentMatches.filter((candidate) =>
|
||||
candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel));
|
||||
if (sameSupplierSegments.length === 1 && sameSupplierSegments[0]?.messageRecord) {
|
||||
return {
|
||||
message: sameSupplierSegments[0].messageRecord,
|
||||
messageId: sameSupplierSegments[0].messageRecord.messageId,
|
||||
submitRecordId: sameSupplierSegments[0].submitRecordId ?? undefined,
|
||||
submitId: sameSupplierSegments[0].submitRecord?.submitId ?? sameSupplierSegments[0].submitId,
|
||||
channelId: sameSupplierSegments[0].channelId,
|
||||
};
|
||||
}
|
||||
const crossConnectionSubmits = await this.prisma.smsSubmitRecord.findMany({
|
||||
where: {
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
messageRecord: { phoneNumber },
|
||||
},
|
||||
include: { messageRecord: true, channel: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
const sameSupplierSubmits = crossConnectionSubmits.filter((candidate) =>
|
||||
candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel));
|
||||
if (sameSupplierSubmits.length === 1 && sameSupplierSubmits[0]?.messageRecord) {
|
||||
return {
|
||||
message: sameSupplierSubmits[0].messageRecord,
|
||||
messageId: sameSupplierSubmits[0].messageRecord.messageId,
|
||||
submitRecordId: sameSupplierSubmits[0].id,
|
||||
submitId: sameSupplierSubmits[0].submitId,
|
||||
channelId: sameSupplierSubmits[0].channelId,
|
||||
};
|
||||
}
|
||||
|
||||
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
|
||||
const submittedAfter = new Date(deliveredAt.getTime() - 72 * 60 * 60 * 1000);
|
||||
const candidates = await this.prisma.smsSubmitRecord.findMany({
|
||||
where: {
|
||||
channelId: data.channelId,
|
||||
gatewayMessageId: null,
|
||||
submitStatus: 'timeout',
|
||||
submittedAt: {
|
||||
gte: submittedAfter,
|
||||
lte: deliveredAt,
|
||||
},
|
||||
messageRecord: {
|
||||
phoneNumber,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
messageRecord: true,
|
||||
},
|
||||
orderBy: {
|
||||
submittedAt: 'desc',
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
|
||||
if (candidates.length !== 1 || !candidates[0]?.messageRecord) {
|
||||
throw new NotFoundException('SMS message record not found');
|
||||
}
|
||||
|
||||
return {
|
||||
message: candidates[0].messageRecord,
|
||||
messageId: candidates[0].messageRecord.messageId,
|
||||
submitRecordId: candidates[0].id,
|
||||
submitId: candidates[0].submitId,
|
||||
channelId: candidates[0].channelId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
|
||||
|
||||
/**
|
||||
* R10 retry implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendRetryService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async recordGatewaySubmitDeadLetter(data: GatewaySubmitDeadLetterDto) {
|
||||
const createdAt = data.deadLetteredAt ? new Date(data.deadLetteredAt) : new Date();
|
||||
return this.prisma.gatewaySubmitDeadLetter.upsert({
|
||||
where: { streamMessageId: data.streamMessageId },
|
||||
update: {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
channelId: data.channelId,
|
||||
traceId: data.traceId,
|
||||
messageId: data.messageId,
|
||||
submitId: data.submitId,
|
||||
failureCode: data.failureCode,
|
||||
failureMessage: data.failureMessage,
|
||||
attempts: data.attempts,
|
||||
maxAttempts: data.maxAttempts,
|
||||
commandPayload: data.commandPayload as Prisma.InputJsonValue | undefined,
|
||||
rawPayload: data.rawPayload,
|
||||
},
|
||||
create: {
|
||||
streamMessageId: data.streamMessageId,
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
channelId: data.channelId,
|
||||
traceId: data.traceId,
|
||||
messageId: data.messageId,
|
||||
submitId: data.submitId,
|
||||
failureCode: data.failureCode,
|
||||
failureMessage: data.failureMessage,
|
||||
attempts: data.attempts,
|
||||
maxAttempts: data.maxAttempts,
|
||||
commandPayload: data.commandPayload as Prisma.InputJsonValue | undefined,
|
||||
rawPayload: data.rawPayload,
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async requeueGatewaySubmitDeadLetter(id: string, data: RequeueGatewaySubmitExceptionDto = {}) {
|
||||
const deadLetter = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } });
|
||||
if (!deadLetter) {
|
||||
throw new NotFoundException('Gateway提交异常记录不存在');
|
||||
}
|
||||
if (deadLetter.status !== 'pending') {
|
||||
throw new BadRequestException('该提交异常当前状态不允许重新入队');
|
||||
}
|
||||
if (!data.confirmedNotSubmitted) {
|
||||
throw new BadRequestException('请确认运营商未接收该短信后再重新入队');
|
||||
}
|
||||
const reason = String(data.reason ?? '').trim();
|
||||
if (reason.length < 5 || reason.length > 500) {
|
||||
throw new BadRequestException('请填写5至500字的重新入队原因');
|
||||
}
|
||||
if (!deadLetter.commandPayload || !isObjectRecord(deadLetter.commandPayload)) {
|
||||
throw new BadRequestException('该提交异常缺少可重新入队的SubmitCommand');
|
||||
}
|
||||
if (deadLetter.manualRetryCount >= 3) {
|
||||
throw new BadRequestException('该提交异常已达到人工重新入队次数上限');
|
||||
}
|
||||
const message = deadLetter.messageId
|
||||
? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: deadLetter.messageId } })
|
||||
: null;
|
||||
if (message && (
|
||||
message.submitStatus === 'accepted'
|
||||
|| ['submitted', 'delivered', 'unknown'].includes(message.status)
|
||||
|| ['delivered', 'unknown'].includes(message.receiptStatus ?? '')
|
||||
)) {
|
||||
throw new BadRequestException('该短信已有成功或不确定的上游结果,为避免重复发送,禁止重新入队');
|
||||
}
|
||||
const commandChannelId = String(deadLetter.commandPayload.channelId ?? deadLetter.channelId ?? '').trim();
|
||||
if (!commandChannelId) {
|
||||
throw new BadRequestException('该提交异常缺少通道信息');
|
||||
}
|
||||
const channel = await this.prisma.smsChannel.findUnique({
|
||||
where: { id: commandChannelId },
|
||||
include: { connectionStates: true },
|
||||
});
|
||||
if (!channel || channel.status !== 'active') {
|
||||
throw new BadRequestException('原通道不存在或已停用,不能重新入队');
|
||||
}
|
||||
if (!channel.connectionStates.some((state) => state.status === 'connected' && state.currentConnections > 0)) {
|
||||
throw new BadRequestException('原通道当前没有可用CMPP连接,请先恢复通道');
|
||||
}
|
||||
const claimed = await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id, status: 'pending' },
|
||||
data: { status: 'requeueing' },
|
||||
});
|
||||
if (claimed.count !== 1) {
|
||||
throw new BadRequestException('该提交异常已被其他操作处理,请刷新后重试');
|
||||
}
|
||||
const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1);
|
||||
let retryStreamMessageId: string;
|
||||
try {
|
||||
const publishedStreamMessageId = await this.facade.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey);
|
||||
if (!publishedStreamMessageId) {
|
||||
throw new Error('Gateway提交异常重新入队未返回Stream消息编号');
|
||||
}
|
||||
retryStreamMessageId = publishedStreamMessageId;
|
||||
} catch (error) {
|
||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id, status: 'requeueing' },
|
||||
data: { status: 'pending' },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const finalized = await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id, status: 'requeueing' },
|
||||
data: {
|
||||
status: 'requeued',
|
||||
manualRetryCount: { increment: 1 },
|
||||
lastRetryStreamId: retryStreamMessageId,
|
||||
lastRetriedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const updated = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } });
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Gateway提交异常记录不存在');
|
||||
}
|
||||
if (finalized.count !== 1 && updated.status !== 'resolved') {
|
||||
throw new BadRequestException('该提交异常状态已变化,请刷新后确认处理结果');
|
||||
}
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: updated.tenantId ?? undefined,
|
||||
userId: data.operatorId,
|
||||
action: 'gateway.submit_dead_letter_requeue',
|
||||
resource: 'gateway_submit_dead_letter',
|
||||
resourceId: updated.id,
|
||||
detail: {
|
||||
streamMessageId: updated.streamMessageId,
|
||||
retryStreamMessageId,
|
||||
submitId: updated.submitId,
|
||||
messageId: updated.messageId,
|
||||
reason,
|
||||
confirmedNotSubmitted: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
|
||||
const staleCutoff = new Date(now.getTime() - positiveInteger(
|
||||
process.env.GATEWAY_SUBMIT_REQUEUE_STALE_MS,
|
||||
DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS,
|
||||
));
|
||||
const stale = await this.prisma.gatewaySubmitDeadLetter.findMany({
|
||||
where: { status: 'requeueing', updatedAt: { lt: staleCutoff } },
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: 100,
|
||||
});
|
||||
let recovered = 0;
|
||||
let failed = 0;
|
||||
for (const deadLetter of stale) {
|
||||
if (!deadLetter.commandPayload || !isObjectRecord(deadLetter.commandPayload)) {
|
||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id: deadLetter.id, status: 'requeueing', updatedAt: deadLetter.updatedAt },
|
||||
data: { status: 'pending' },
|
||||
});
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
const claimed = await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id: deadLetter.id, status: 'requeueing', updatedAt: deadLetter.updatedAt },
|
||||
data: { status: 'requeue_recovering' },
|
||||
});
|
||||
if (claimed.count !== 1) continue;
|
||||
try {
|
||||
const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1);
|
||||
const retryStreamMessageId = await this.facade.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey);
|
||||
if (!retryStreamMessageId) throw new Error('Gateway提交异常恢复未返回Stream消息编号');
|
||||
const finalized = await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id: deadLetter.id, status: 'requeue_recovering' },
|
||||
data: {
|
||||
status: 'requeued',
|
||||
manualRetryCount: { increment: 1 },
|
||||
lastRetryStreamId: retryStreamMessageId,
|
||||
lastRetriedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (finalized.count === 1) {
|
||||
recovered += 1;
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: deadLetter.tenantId ?? undefined,
|
||||
action: 'gateway.submit_dead_letter_requeue_recovered',
|
||||
resource: 'gateway_submit_dead_letter',
|
||||
resourceId: deadLetter.id,
|
||||
detail: { retryStreamMessageId, requeueKey },
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id: deadLetter.id, status: 'requeue_recovering' },
|
||||
data: { status: 'requeueing' },
|
||||
});
|
||||
this.logger.error(`Gateway submit requeue recovery failed for ${deadLetter.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
return { recovered, failed };
|
||||
}
|
||||
|
||||
async retryMessageIfAllowed(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
batchTaskId: string;
|
||||
applicationId?: string | null;
|
||||
templateId?: string | null;
|
||||
signatureId?: string | null;
|
||||
submitId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
queuedAt?: Date;
|
||||
clientSrcId?: string | null;
|
||||
applicationExtension?: string | null;
|
||||
carrier?: string | null;
|
||||
province?: string | null;
|
||||
},
|
||||
reason: string,
|
||||
sourceSubmitRecordId?: string,
|
||||
) {
|
||||
const attempts = await this.prisma.smsSubmitRecord.findMany({
|
||||
where: { messageRecordId: message.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
const attemptedChannelIds = attempts.map((attempt) => attempt.channelId);
|
||||
let sourceAttempt = sourceSubmitRecordId
|
||||
? attempts.find((attempt) => attempt.id === sourceSubmitRecordId)
|
||||
: attempts.find((attempt) => attempt.submitId === message.submitId) ?? attempts[attempts.length - 1];
|
||||
if (!sourceAttempt && sourceSubmitRecordId) {
|
||||
sourceAttempt = await this.prisma.smsSubmitRecord.findUnique({
|
||||
where: { id: sourceSubmitRecordId },
|
||||
}) ?? undefined;
|
||||
}
|
||||
if (!sourceAttempt || sourceAttempt.messageRecordId !== message.id) {
|
||||
this.logger.error(`sms_retry_route_failed ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
reason,
|
||||
sourceSubmitRecordId,
|
||||
sourceMessageRecordId: sourceAttempt?.messageRecordId,
|
||||
error: sourceAttempt ? 'retry_source_submit_record_mismatch' : 'retry_source_submit_record_missing',
|
||||
})}`);
|
||||
return null;
|
||||
}
|
||||
const existingRetry = await this.prisma.smsSubmitRecord.findUnique({
|
||||
where: { retryOfSubmitRecordId: sourceAttempt.id },
|
||||
});
|
||||
if (existingRetry) {
|
||||
this.logger.warn(`sms_retry_claim_reused ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
retryOfSubmitRecordId: sourceAttempt.id,
|
||||
submitId: existingRetry.submitId,
|
||||
channelId: existingRetry.channelId,
|
||||
})}`);
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const ageMinutes = (Date.now() - new Date(message.queuedAt ?? Date.now()).getTime()) / 60_000;
|
||||
this.logger.log(`sms_retry_route_started ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
reason,
|
||||
attemptedChannelIds,
|
||||
ageMinutes: Math.round(ageMinutes * 100) / 100,
|
||||
})}`);
|
||||
if (ageMinutes >= 72 * 60) {
|
||||
this.logger.warn(`sms_retry_route_skipped ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
reason: 'maximum_message_age_exceeded',
|
||||
ageMinutes: Math.round(ageMinutes * 100) / 100,
|
||||
})}`);
|
||||
return null;
|
||||
}
|
||||
const retryCarrier = message.carrier
|
||||
? normalizeCarrier(message.carrier)
|
||||
: await this.facade.identifyCarrier(message.phoneNumber);
|
||||
const route = await this.facade.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, retryCarrier);
|
||||
const retryTimeLimitMinutes = Math.min(route.group.retryTimeLimitMinutes ?? route.group.retryTimeLimitHours * 60, 72 * 60);
|
||||
if (!route.group.retryEnabled || ageMinutes >= retryTimeLimitMinutes) {
|
||||
this.logger.warn(`sms_retry_route_skipped ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
groupId: route.groupId,
|
||||
reason: !route.group.retryEnabled ? 'group_retry_disabled' : 'group_retry_time_limit_exceeded',
|
||||
ageMinutes: Math.round(ageMinutes * 100) / 100,
|
||||
retryTimeLimitMinutes,
|
||||
})}`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const routed = await this.facade.selectChannelForMessage({ ...message, carrier: retryCarrier }, {
|
||||
forceNational: true,
|
||||
excludeChannelIds: attemptedChannelIds,
|
||||
});
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { errorMessage: reason },
|
||||
});
|
||||
const retried = await this.facade.submitMessageToGateway(
|
||||
message,
|
||||
routed,
|
||||
attempts.length,
|
||||
sourceAttempt.id,
|
||||
);
|
||||
this.logger.log(`sms_retry_route_selected ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
groupId: routed.groupId,
|
||||
channelId: routed.channel.id,
|
||||
attempt: attempts.length,
|
||||
})}`);
|
||||
return retried;
|
||||
} catch (error) {
|
||||
this.logger.error(`sms_retry_route_failed ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
reason,
|
||||
attemptedChannelIds,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { isIpAllowed } from '../common/ip-allowlist';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
|
||||
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
|
||||
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
|
||||
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
||||
|
||||
/**
|
||||
* R9 reviewContinuation implementation. Cross-method calls return through the stable SendChainService seam.
|
||||
*/
|
||||
export class SendReviewContinuationService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly riskReview: RiskReviewService,
|
||||
private readonly phoneFrequency: PhoneFrequencyService,
|
||||
private readonly phoneRouting: PhoneRoutingLookupService,
|
||||
private readonly facade: SendSubmissionService,
|
||||
private readonly callbacks: SendSubmissionCallbacks,
|
||||
) {}
|
||||
|
||||
private releaseMessageReservation(
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
return this.callbacks.releaseMessageReservation(message, remark);
|
||||
}
|
||||
|
||||
private recordCmppFailureReceipt(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
applicationId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
cmppSubmitSequenceId?: string | null;
|
||||
cmppSubmitGroupMessageId?: string | null;
|
||||
},
|
||||
errorCode: string,
|
||||
reason: string,
|
||||
) {
|
||||
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
|
||||
}
|
||||
|
||||
|
||||
async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) {
|
||||
const reviewTask = await this.prisma.smsSendTask.findUnique({
|
||||
where: { id: reviewTaskId },
|
||||
});
|
||||
if (!reviewTask) {
|
||||
return { reviewTaskId, decision, affected: 0 };
|
||||
}
|
||||
const messageRecords = await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
status: 'pending_review',
|
||||
OR: [
|
||||
{ reviewTaskId },
|
||||
{ batchTask: { riskTaskId: reviewTaskId } },
|
||||
],
|
||||
},
|
||||
include: { batchTask: true },
|
||||
});
|
||||
if (messageRecords.length === 0) {
|
||||
return { reviewTaskId, decision, affected: 0 };
|
||||
}
|
||||
const batchTaskIds = new Set<string>();
|
||||
for (const message of messageRecords) {
|
||||
if (!message.tenantId || !message.applicationId || !message.batchTaskId) continue;
|
||||
if (decision === 'approved') {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { status: 'queued', errorCode: null, errorMessage: null },
|
||||
});
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: message.batchTaskId },
|
||||
data: { status: 'ready', auditStatus: 'approved', reviewReason: reason, rejectReason: null },
|
||||
});
|
||||
batchTaskIds.add(message.batchTaskId);
|
||||
} else {
|
||||
await this.releaseMessageReservation(
|
||||
message as typeof message & { tenantId: string; batchTaskId: string },
|
||||
'模板不匹配人工审核驳回释放冻结',
|
||||
);
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: message.batchTaskId },
|
||||
data: { status: 'rejected', auditStatus: 'rejected', rejectReason: reason },
|
||||
});
|
||||
await this.recordCmppFailureReceipt(message, 'REVIEW_REJECTED', reason);
|
||||
}
|
||||
}
|
||||
for (const batchTaskId of batchTaskIds) {
|
||||
await this.facade.enqueueBatchTask(batchTaskId);
|
||||
}
|
||||
return { reviewTaskId, decision, affected: messageRecords.length };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { isIpAllowed } from '../common/ip-allowlist';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
|
||||
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
|
||||
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
|
||||
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
|
||||
|
||||
/**
|
||||
* R9 scheduledDispatch implementation. Cross-method calls return through the stable SendChainService seam.
|
||||
*/
|
||||
export class SendScheduledDispatchService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
private scheduledDispatchScanRunning = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly riskReview: RiskReviewService,
|
||||
private readonly phoneFrequency: PhoneFrequencyService,
|
||||
private readonly phoneRouting: PhoneRoutingLookupService,
|
||||
private readonly facade: SendSubmissionService,
|
||||
private readonly callbacks: SendSubmissionCallbacks,
|
||||
) {}
|
||||
|
||||
private releaseMessageReservation(
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
return this.callbacks.releaseMessageReservation(message, remark);
|
||||
}
|
||||
|
||||
private recordCmppFailureReceipt(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
applicationId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
cmppSubmitSequenceId?: string | null;
|
||||
cmppSubmitGroupMessageId?: string | null;
|
||||
},
|
||||
errorCode: string,
|
||||
reason: string,
|
||||
) {
|
||||
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
|
||||
}
|
||||
|
||||
|
||||
async dispatchDueScheduledTasks(now = new Date()) {
|
||||
const staleCutoff = new Date(now.getTime() - positiveInteger(
|
||||
process.env.SMS_SCHEDULED_DISPATCH_STALE_MS,
|
||||
DEFAULT_SCHEDULED_DISPATCH_STALE_MS,
|
||||
));
|
||||
const tasks = await this.prisma.smsBatchTask.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ status: 'scheduled', scheduledAt: { lte: now } },
|
||||
{ status: { in: ['scheduled_dispatching', 'scheduled_recovering'] }, updatedAt: { lt: staleCutoff } },
|
||||
],
|
||||
},
|
||||
orderBy: { scheduledAt: 'asc' },
|
||||
});
|
||||
const results: Array<{ taskId: string; status: string; enqueued?: number; reason?: string }> = [];
|
||||
for (const task of tasks) {
|
||||
const candidateStatus = task.status || 'scheduled';
|
||||
const claimedStatus = candidateStatus === 'scheduled_dispatching' ? 'scheduled_recovering' : 'scheduled_dispatching';
|
||||
const claimed = await this.prisma.smsBatchTask.updateMany({
|
||||
where: {
|
||||
id: task.id,
|
||||
status: candidateStatus,
|
||||
...(candidateStatus === 'scheduled' ? {} : { updatedAt: { lt: staleCutoff } }),
|
||||
},
|
||||
data: { status: claimedStatus },
|
||||
});
|
||||
if (claimed.count !== 1) continue;
|
||||
let reservationEstablished = false;
|
||||
let dispatchPrepared = false;
|
||||
try {
|
||||
await this.facade.validateSendResources(task.tenantId, task.applicationId ?? undefined, task.templateId ?? undefined);
|
||||
const messages = await this.prisma.smsMessageRecord.findMany({
|
||||
where: { batchTaskId: task.id, status: { in: ['scheduled', 'queued'] } },
|
||||
select: { id: true, amountCents: true, billingUnits: true },
|
||||
take: 100000,
|
||||
});
|
||||
const amountCents = messages.reduce((sum, message) => sum + moneyToNumber(message.amountCents), 0);
|
||||
const existingReservation = await this.prisma.accountTransaction.findFirst({
|
||||
where: { tenantId: task.tenantId, transactionType: 'frozen', relatedType: 'sms_batch_task', relatedId: task.id },
|
||||
select: { id: true },
|
||||
});
|
||||
reservationEstablished = Boolean(existingReservation);
|
||||
if (!reservationEstablished) {
|
||||
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents });
|
||||
if (!accountCheck.canSend) {
|
||||
throw new BadRequestException('定时任务到点时企业账户余额不足');
|
||||
}
|
||||
if (amountCents > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: task.tenantId,
|
||||
amountCents,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: '定时任务到点冻结',
|
||||
});
|
||||
reservationEstablished = true;
|
||||
}
|
||||
}
|
||||
dispatchPrepared = true;
|
||||
await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { batchTaskId: task.id, status: 'scheduled' },
|
||||
data: { status: 'queued' },
|
||||
});
|
||||
const enqueued = await this.facade.enqueueBatchTask(task.id);
|
||||
results.push({ taskId: task.id, status: 'queued', enqueued: enqueued.enqueued });
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : '定时任务到点执行失败';
|
||||
if (reservationEstablished || dispatchPrepared) {
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: claimedStatus, rejectReason: `调度将在超时后恢复:${reason}` },
|
||||
});
|
||||
results.push({ taskId: task.id, status: 'retrying', reason });
|
||||
continue;
|
||||
}
|
||||
await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { batchTaskId: task.id, status: 'scheduled' },
|
||||
data: { status: 'rejected', errorMessage: reason },
|
||||
});
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'failed', rejectReason: reason },
|
||||
});
|
||||
results.push({ taskId: task.id, status: 'failed', reason });
|
||||
}
|
||||
}
|
||||
return { dispatched: results.filter((result) => result.status === 'queued').length, results };
|
||||
}
|
||||
|
||||
async runScheduledDispatchScan() {
|
||||
if (this.scheduledDispatchScanRunning) return;
|
||||
this.scheduledDispatchScanRunning = true;
|
||||
try {
|
||||
await this.facade.dispatchDueScheduledTasks();
|
||||
} catch (error) {
|
||||
this.logger.error(`Scheduled SMS dispatch scan failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
this.scheduledDispatchScanRunning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { Queue } from 'bullmq';
|
||||
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
|
||||
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
|
||||
import { SendBatchEntryService } from './send-batch-entry.service';
|
||||
import { SendGatewaySubmitService } from './send-gateway-submit.service';
|
||||
import { SendInboundEntryService } from './send-inbound-entry.service';
|
||||
import { SendReviewContinuationService } from './send-review-continuation.service';
|
||||
import { SendScheduledDispatchService } from './send-scheduled-dispatch.service';
|
||||
|
||||
|
||||
export type SendSubmissionCallbacks = {
|
||||
releaseMessageReservation: (
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) => Promise<void>;
|
||||
recordCmppFailureReceipt: (
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
applicationId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
cmppSubmitSequenceId?: string | null;
|
||||
cmppSubmitGroupMessageId?: string | null;
|
||||
},
|
||||
errorCode: string,
|
||||
reason: string,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* R9 internal compatibility facade. SendChainService remains the only public NestJS provider.
|
||||
*/
|
||||
export class SendSubmissionService {
|
||||
private readonly batchEntry: SendBatchEntryService;
|
||||
private readonly inboundEntry: SendInboundEntryService;
|
||||
private readonly reviewContinuation: SendReviewContinuationService;
|
||||
private readonly scheduledDispatch: SendScheduledDispatchService;
|
||||
private readonly gatewaySubmit: SendGatewaySubmitService;
|
||||
|
||||
constructor(
|
||||
prisma: PrismaService,
|
||||
billing: BillingService,
|
||||
riskReview: RiskReviewService,
|
||||
phoneFrequency: PhoneFrequencyService,
|
||||
phoneRouting: PhoneRoutingLookupService,
|
||||
facade: SendSubmissionService,
|
||||
callbacks: SendSubmissionCallbacks,
|
||||
) {
|
||||
this.batchEntry = new SendBatchEntryService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
|
||||
this.inboundEntry = new SendInboundEntryService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
|
||||
this.reviewContinuation = new SendReviewContinuationService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
|
||||
this.scheduledDispatch = new SendScheduledDispatchService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
|
||||
this.gatewaySubmit = new SendGatewaySubmitService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
return this.gatewaySubmit.onModuleDestroy();
|
||||
}
|
||||
|
||||
async createBatchTask(data: CreateBatchTaskDto) {
|
||||
return this.batchEntry.createBatchTask(data);
|
||||
}
|
||||
|
||||
async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
|
||||
return this.batchEntry.createHttpBatchTask(data);
|
||||
}
|
||||
|
||||
async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
|
||||
return this.batchEntry.getBatchTask(taskId, tenantId, sourceType);
|
||||
}
|
||||
|
||||
async previewImport(data: ImportPreviewDto) {
|
||||
return this.batchEntry.previewImport(data);
|
||||
}
|
||||
|
||||
async confirmImport(data: ConfirmImportDto) {
|
||||
return this.batchEntry.confirmImport(data);
|
||||
}
|
||||
|
||||
async resolveUnitPrice(tenantId: string, applicationId?: string) {
|
||||
return this.batchEntry.resolveUnitPrice(tenantId, applicationId);
|
||||
}
|
||||
|
||||
async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
|
||||
return this.batchEntry.resolveQueuePriority(tenantId, applicationId);
|
||||
}
|
||||
|
||||
async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
|
||||
return this.batchEntry.resolveApplicationAccessNumber(tenantId, applicationId);
|
||||
}
|
||||
|
||||
async resolveTemplateMessageClassification(
|
||||
tenantId: string,
|
||||
applicationId: string | undefined,
|
||||
templateId: string | undefined,
|
||||
content: string,
|
||||
) {
|
||||
return this.batchEntry.resolveTemplateMessageClassification(tenantId, applicationId, templateId, content);
|
||||
}
|
||||
|
||||
async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) {
|
||||
return this.batchEntry.classifyRejectedPhones(tenantId, applicationId, phones);
|
||||
}
|
||||
|
||||
async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
|
||||
return this.batchEntry.validateSendResources(tenantId, applicationId, templateId);
|
||||
}
|
||||
|
||||
async reserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
return this.batchEntry.reserveDailySendQuota(applicationId, requestedCount);
|
||||
}
|
||||
|
||||
async tryReserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
return this.batchEntry.tryReserveDailySendQuota(applicationId, requestedCount);
|
||||
}
|
||||
|
||||
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
|
||||
return this.inboundEntry.authenticateInboundApplication(data);
|
||||
}
|
||||
|
||||
async submitInboundMessage(data: GatewayInboundSubmitDto) {
|
||||
return this.inboundEntry.submitInboundMessage(data);
|
||||
}
|
||||
|
||||
async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) {
|
||||
return this.inboundEntry.recoverCompletedInboundLongMessageResponse(messageId, phoneNumbers);
|
||||
}
|
||||
|
||||
async submitCompleteInboundMessage(
|
||||
data: GatewayInboundSubmitDto,
|
||||
phoneNumbers: string[],
|
||||
application: Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>,
|
||||
requestedGroupMessageId?: string,
|
||||
) {
|
||||
return this.inboundEntry.submitCompleteInboundMessage(data, phoneNumbers, application, requestedGroupMessageId);
|
||||
}
|
||||
|
||||
async collectInboundLongMessageFragment(
|
||||
data: GatewayInboundSubmitDto,
|
||||
application: NonNullable<Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>>,
|
||||
phoneNumbers: string[],
|
||||
) {
|
||||
return this.inboundEntry.collectInboundLongMessageFragment(data, application, phoneNumbers);
|
||||
}
|
||||
|
||||
async expireInboundLongMessages(now = new Date()) {
|
||||
return this.inboundEntry.expireInboundLongMessages(now);
|
||||
}
|
||||
|
||||
async submitInboundSingleMessage(
|
||||
data: GatewayInboundSubmitDto & { phoneNumber: string },
|
||||
messageId: string,
|
||||
submitGroupMessageId: string,
|
||||
synchronousRejection?: { code: string; reason: string },
|
||||
receiptRejection?: { code: string; reason: string },
|
||||
) {
|
||||
return this.inboundEntry.submitInboundSingleMessage(data, messageId, submitGroupMessageId, synchronousRejection, receiptRejection);
|
||||
}
|
||||
|
||||
async evaluateRiskWithPhoneFrequency(input: {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
templateId?: string;
|
||||
content: string;
|
||||
variables?: Record<string, unknown>;
|
||||
phoneNumber: string;
|
||||
sourceType: 'cmpp';
|
||||
}) {
|
||||
return this.inboundEntry.evaluateRiskWithPhoneFrequency(input);
|
||||
}
|
||||
|
||||
findInboundApplication(account: string) {
|
||||
return this.inboundEntry.findInboundApplication(account);
|
||||
}
|
||||
|
||||
async resolveInboundTemplateCandidate(applicationId: string, content: string) {
|
||||
return this.inboundEntry.resolveInboundTemplateCandidate(applicationId, content);
|
||||
}
|
||||
|
||||
resolveInboundSignatureCandidate(applicationId: string, content: string) {
|
||||
return this.inboundEntry.resolveInboundSignatureCandidate(applicationId, content);
|
||||
}
|
||||
|
||||
async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) {
|
||||
return this.inboundEntry.resolveDrainageInfoMatch(signatureId, content);
|
||||
}
|
||||
|
||||
async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) {
|
||||
return this.inboundEntry.attachMessageToReviewTask(reviewTaskId, messageRecordId, signatureId, drainageInfoId);
|
||||
}
|
||||
|
||||
async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) {
|
||||
return this.reviewContinuation.handleReviewDecision(reviewTaskId, decision, reason);
|
||||
}
|
||||
|
||||
async dispatchDueScheduledTasks(now = new Date()) {
|
||||
return this.scheduledDispatch.dispatchDueScheduledTasks(now);
|
||||
}
|
||||
|
||||
async runScheduledDispatchScan() {
|
||||
return this.scheduledDispatch.runScheduledDispatchScan();
|
||||
}
|
||||
|
||||
async enqueueBatchTask(taskId: string) {
|
||||
return this.gatewaySubmit.enqueueBatchTask(taskId);
|
||||
}
|
||||
|
||||
startWorker() {
|
||||
return this.gatewaySubmit.startWorker();
|
||||
}
|
||||
|
||||
async processSendJob(job: SendJob) {
|
||||
return this.gatewaySubmit.processSendJob(job);
|
||||
}
|
||||
|
||||
async submitMessageToGateway(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
batchTaskId: string;
|
||||
applicationId?: string | null;
|
||||
templateId?: string | null;
|
||||
signatureId?: string | null;
|
||||
submitId?: string | null;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
queuePriority?: string | null;
|
||||
clientSrcId?: string | null;
|
||||
applicationExtension?: string | null;
|
||||
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
|
||||
signature?: { id?: string | null; name?: string | null } | null;
|
||||
},
|
||||
routed: RoutedChannel,
|
||||
attempt: number,
|
||||
retryOfSubmitRecordId?: string,
|
||||
) {
|
||||
return this.gatewaySubmit.submitMessageToGateway(message, routed, attempt, retryOfSubmitRecordId);
|
||||
}
|
||||
|
||||
async selectChannelForMessage(
|
||||
message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null },
|
||||
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
|
||||
): Promise<RoutedChannel> {
|
||||
return this.gatewaySubmit.selectChannelForMessage(message, options);
|
||||
}
|
||||
|
||||
async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string) {
|
||||
return this.gatewaySubmit.findApplicationRoute(tenantId, applicationId, carrier);
|
||||
}
|
||||
|
||||
async identifyCarrier(phoneNumber: string) {
|
||||
return this.gatewaySubmit.identifyCarrier(phoneNumber);
|
||||
}
|
||||
|
||||
async identifyProvince(phoneNumber: string) {
|
||||
return this.gatewaySubmit.identifyProvince(phoneNumber);
|
||||
}
|
||||
|
||||
async ensureSignatureReportedForChannel(
|
||||
message: {
|
||||
id: string;
|
||||
templateId?: string | null;
|
||||
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
|
||||
signature?: { id?: string | null; name?: string | null } | null;
|
||||
},
|
||||
channelId: string,
|
||||
) {
|
||||
return this.gatewaySubmit.ensureSignatureReportedForChannel(message, channelId);
|
||||
}
|
||||
|
||||
async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
|
||||
return this.gatewaySubmit.resolveMessageSignatureId(message);
|
||||
}
|
||||
|
||||
async waitForChannelRateLimit(channelId: string, tps: number) {
|
||||
return this.gatewaySubmit.waitForChannelRateLimit(channelId, tps);
|
||||
}
|
||||
|
||||
async refreshTaskProgress(batchTaskId: string) {
|
||||
return this.gatewaySubmit.refreshTaskProgress(batchTaskId);
|
||||
}
|
||||
|
||||
getSendQueue(): Queue<SendJob, unknown, 'send-message'> {
|
||||
return this.gatewaySubmit.getSendQueue();
|
||||
}
|
||||
|
||||
getGatewayQueue(): Queue {
|
||||
return this.gatewaySubmit.getGatewayQueue();
|
||||
}
|
||||
|
||||
getRedis() {
|
||||
return this.gatewaySubmit.getRedis();
|
||||
}
|
||||
|
||||
async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
|
||||
return this.gatewaySubmit.publishGatewaySubmitCommand(command, idempotencyKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
|
||||
|
||||
/**
|
||||
* R10 timeout implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendTimeoutService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
private receiptTimeoutScanRunning = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async markUnknownTimeout(data: TimeoutUnknownDto) {
|
||||
const olderThanHours = data.olderThanHours ?? positiveInteger(process.env.SMS_RECEIPT_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS);
|
||||
const cutoff = new Date(Date.now() - olderThanHours * 60 * 60 * 1000);
|
||||
const candidates = await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
tenantId: { not: null },
|
||||
status: { in: ['submitted', 'unknown'] },
|
||||
submittedAt: { lte: cutoff },
|
||||
},
|
||||
select: { id: true, tenantId: true, batchTaskId: true, messageId: true, amountCents: true, billingUnits: true },
|
||||
take: 10000,
|
||||
});
|
||||
const timedOutTaskIds = new Set<string>();
|
||||
let timeout = 0;
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.tenantId) continue;
|
||||
const transitioned = await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { id: candidate.id, status: { in: ['submitted', 'unknown'] } },
|
||||
data: { status: 'timeout', timeoutAt: new Date(), errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时` },
|
||||
});
|
||||
if (transitioned.count !== 1) continue;
|
||||
timeout += 1;
|
||||
await this.facade.refundMessage(candidate as typeof candidate & { tenantId: string }, `${olderThanHours}小时未收到明确回执,自动超时退款`);
|
||||
if (candidate.batchTaskId) timedOutTaskIds.add(candidate.batchTaskId);
|
||||
}
|
||||
for (const batchTaskId of timedOutTaskIds) {
|
||||
await this.facade.refreshTaskProgress(batchTaskId);
|
||||
}
|
||||
return { timeout };
|
||||
}
|
||||
|
||||
async markExpiredDownstreamDeliveries(olderThanHours = downstreamPendingTimeoutHours()) {
|
||||
const cutoff = new Date(Date.now() - olderThanHours * 60 * 60_000);
|
||||
const expired = await this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: {
|
||||
status: 'pending',
|
||||
OR: [
|
||||
{ lastRetriedAt: null, createdAt: { lte: cutoff } },
|
||||
{ lastRetriedAt: { lte: cutoff } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
take: 500,
|
||||
});
|
||||
for (const delivery of expired) {
|
||||
await this.facade.markDownstreamDeliveryFailed(
|
||||
delivery.id,
|
||||
`下游投递排队超过 ${olderThanHours} 小时,系统自动终止重试`,
|
||||
'queue_timeout',
|
||||
);
|
||||
}
|
||||
return { failed: expired.length };
|
||||
}
|
||||
|
||||
async runReceiptTimeoutScan() {
|
||||
if (this.receiptTimeoutScanRunning) return;
|
||||
this.receiptTimeoutScanRunning = true;
|
||||
try {
|
||||
const [receiptResult, downstreamResult, requeueRecoveryResult, downstreamManualRecoveryResult] = await Promise.all([
|
||||
this.facade.markUnknownTimeout({}),
|
||||
this.facade.markExpiredDownstreamDeliveries(),
|
||||
this.facade.recoverStaleGatewaySubmitRequeues(),
|
||||
this.facade.recoverStaleDownstreamManualRequeues(),
|
||||
]);
|
||||
if (receiptResult.timeout > 0) this.logger.log(`Marked ${receiptResult.timeout} messages as receipt timeout and refunded charged messages`);
|
||||
if (downstreamResult.failed > 0) this.logger.log(`Terminated ${downstreamResult.failed} expired downstream deliveries`);
|
||||
if (requeueRecoveryResult.recovered > 0) this.logger.log(`Recovered ${requeueRecoveryResult.recovered} stale Gateway submit requeues`);
|
||||
if (downstreamManualRecoveryResult.recovered > 0) this.logger.log(`Recovered ${downstreamManualRecoveryResult.recovered} stale downstream manual requeues`);
|
||||
} catch (error) {
|
||||
this.logger.error('Receipt timeout scan failed', error instanceof Error ? error.stack : String(error));
|
||||
} finally {
|
||||
this.receiptTimeoutScanRunning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { RequireRecentAuthentication } from '../auth/require-recent-authenticati
|
||||
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';
|
||||
import { SmsConfigService } from './sms-config.service';
|
||||
import { CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
|
||||
|
||||
@ApiTags('admin-sms-config')
|
||||
@Controller('admin')
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
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';
|
||||
import { assertMoneyUnits } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { automaticDeliveryMode } from '../open-api/delivery-mode';
|
||||
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
|
||||
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
|
||||
import { SmsApplicationLifecycleService } from './application-lifecycle.service';
|
||||
|
||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||
export class SmsApplicationConfigService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly lifecycle: SmsApplicationLifecycleService) {}
|
||||
async listApplications(queryOrTenantId?: string | ApplicationListQuery) {
|
||||
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
||||
if (query.includeConnections) {
|
||||
await this.lifecycle.markTimedOutDownstreamConnections();
|
||||
}
|
||||
const applications = await this.prisma.smsApplication.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
|
||||
name: query.applicationKeyword ? { contains: query.applicationKeyword } : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: {
|
||||
tenant: true,
|
||||
ipAllowlist: true,
|
||||
httpConfig: true,
|
||||
},
|
||||
omit: {
|
||||
secretHash: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
...(query.page && query.pageSize ? {
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
} : {}),
|
||||
});
|
||||
if (!query.includeConnections) {
|
||||
return applications;
|
||||
}
|
||||
const applicationIds = applications.map((application) => application.id);
|
||||
const [connections, messageStats] = await Promise.all([
|
||||
this.prisma.cmppDownstreamConnection.findMany({
|
||||
where: { applicationId: { in: applicationIds }, status: 'connected' },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
}),
|
||||
this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['applicationId', 'status'],
|
||||
where: { applicationId: { in: applicationIds }, queuedAt: { gte: startOfToday() } },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
const disablingDetails = new Map((await Promise.all(applications
|
||||
.filter((application) => application.status === 'disabling')
|
||||
.map(async (application) => [application.id, await this.lifecycle.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);
|
||||
const todayTotal = appStats.reduce((sum, item) => sum + item._count._all, 0);
|
||||
const delivered = appStats.find((item) => item.status === 'delivered')?._count._all ?? 0;
|
||||
return {
|
||||
...application,
|
||||
cmppConnections: appConnections,
|
||||
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')
|
||||
|| left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
async listApplicationsPage(query: ApplicationListQuery) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||
const where: Prisma.SmsApplicationWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
|
||||
name: query.applicationKeyword ? { contains: query.applicationKeyword } : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.listApplications({ ...query, page, pageSize }),
|
||||
this.prisma.smsApplication.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
listApplicationOptions(tenantId?: string) {
|
||||
return this.prisma.smsApplication.findMany({
|
||||
where: { tenantId, status: { not: 'deleted' } },
|
||||
select: { id: true, tenantId: true, name: true, status: true },
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async getApplication(applicationId: string, tenantId?: string) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
include: {
|
||||
tenant: true,
|
||||
ipAllowlist: true,
|
||||
httpConfig: true,
|
||||
},
|
||||
});
|
||||
if (!application || (tenantId && application.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
return application;
|
||||
}
|
||||
|
||||
async getApplicationReportFields(applicationId?: string, reportType?: 'signature' | 'drainage') {
|
||||
if (applicationId) await this.getApplication(applicationId);
|
||||
const [commonFields, routes] = await Promise.all([
|
||||
this.prisma.commonReportField.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
reportType,
|
||||
drainageField: { status: 'active' },
|
||||
},
|
||||
include: { drainageField: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
}),
|
||||
applicationId ? this.prisma.channelRouteRule.findMany({
|
||||
where: { applicationId, status: 'active' },
|
||||
include: {
|
||||
group: {
|
||||
include: {
|
||||
items: {
|
||||
include: {
|
||||
channel: {
|
||||
include: {
|
||||
reportFields: { include: { drainageField: true }, orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { priority: 'asc' },
|
||||
}) : Promise.resolve([]),
|
||||
]);
|
||||
type MergedReportField = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
fieldType: string;
|
||||
required: boolean;
|
||||
description?: string | null;
|
||||
reportTypes: string[];
|
||||
commonReportTypes: string[];
|
||||
channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: string; source: 'common' | 'channel' | 'both' }>;
|
||||
};
|
||||
const merged = new Map<string, MergedReportField>();
|
||||
const routeChannels = new Map<string, { id: string; code: string; name: string; groupId: string; groupName: string }>();
|
||||
for (const route of routes) {
|
||||
if (!route.group) continue;
|
||||
for (const item of route.group.items) {
|
||||
if (!routeChannels.has(item.channel.id)) {
|
||||
routeChannels.set(item.channel.id, {
|
||||
id: item.channel.id,
|
||||
code: item.channel.code,
|
||||
name: item.channel.name,
|
||||
groupId: route.group.id,
|
||||
groupName: route.group.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const configured of commonFields) {
|
||||
merged.set(configured.drainageField.id, {
|
||||
id: configured.drainageField.id,
|
||||
code: configured.drainageField.code,
|
||||
name: configured.drainageField.name,
|
||||
fieldType: configured.drainageField.fieldType,
|
||||
required: configured.required,
|
||||
description: configured.drainageField.description,
|
||||
reportTypes: [configured.reportType],
|
||||
commonReportTypes: [configured.reportType],
|
||||
channels: Array.from(routeChannels.values()).map((channel) => ({
|
||||
...channel,
|
||||
required: configured.required,
|
||||
reportType: configured.reportType,
|
||||
source: 'common' as const,
|
||||
})),
|
||||
});
|
||||
}
|
||||
for (const route of routes) {
|
||||
if (!route.group) continue;
|
||||
for (const item of route.group.items) {
|
||||
for (const configured of item.channel.reportFields) {
|
||||
if (configured.status !== 'active' || !configured.drainageField || configured.drainageField.status !== 'active') continue;
|
||||
if (reportType && configured.reportType !== 'both' && configured.reportType !== reportType) continue;
|
||||
const key = configured.drainageField.id;
|
||||
const current: MergedReportField = merged.get(key) ?? {
|
||||
id: configured.drainageField.id,
|
||||
code: configured.drainageField.code,
|
||||
name: configured.drainageField.name,
|
||||
fieldType: configured.drainageField.fieldType,
|
||||
required: false,
|
||||
description: configured.drainageField.description,
|
||||
reportTypes: [],
|
||||
commonReportTypes: [],
|
||||
channels: [],
|
||||
};
|
||||
current.required = current.required || configured.required;
|
||||
if (!current.reportTypes.includes(configured.reportType)) current.reportTypes.push(configured.reportType);
|
||||
const existingChannel = current.channels.find((channel) => channel.id === item.channel.id);
|
||||
if (existingChannel) {
|
||||
existingChannel.required = existingChannel.required || configured.required;
|
||||
existingChannel.reportType = configured.reportType;
|
||||
existingChannel.source = existingChannel.source === 'common' ? 'both' : existingChannel.source;
|
||||
} else {
|
||||
current.channels.push({
|
||||
id: item.channel.id,
|
||||
code: item.channel.code,
|
||||
name: item.channel.name,
|
||||
groupId: route.group.id,
|
||||
groupName: route.group.name,
|
||||
required: configured.required,
|
||||
reportType: configured.reportType,
|
||||
source: 'channel',
|
||||
});
|
||||
}
|
||||
merged.set(key, current);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
async getClientApplicationReportFields(applicationId?: string, reportType?: 'signature' | 'drainage') {
|
||||
const fields = await this.getApplicationReportFields(applicationId, reportType);
|
||||
return fields.map(({ channels: _channels, commonReportTypes: _commonReportTypes, ...field }) => field);
|
||||
}
|
||||
|
||||
async createApplication(data: CreateSmsApplicationDto) {
|
||||
assertMoneyUnits(data.customerUnitPrice ?? 0, '客户单价');
|
||||
const secret = normalizeApplicationPassword(data.passwordCipher);
|
||||
const queuePriority = normalizeApplicationQueuePriority(data.queuePriority);
|
||||
const interfaceType = normalizeApplicationInterfaceType(data.interfaceType);
|
||||
const cmppAccount = data.cmppAccount ? await this.validateAndReserveCmppAccount(data.cmppAccount) : await this.generateCmppAccount();
|
||||
const cmppEnterpriseCode = cmppAccount;
|
||||
const accessNumber = normalizeCmppAccessNumberConfig(data);
|
||||
await this.validateClientSrcIdAvailable(accessNumber.clientSrcId);
|
||||
return this.prisma.smsApplication.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
name: data.name,
|
||||
scene: data.scene,
|
||||
callbackUrl: data.callbackUrl,
|
||||
cmppAccount,
|
||||
cmppEnterpriseCode,
|
||||
cmppApplicationExtension: accessNumber.applicationExtension,
|
||||
cmppAccessNumberFillEnabled: accessNumber.fillEnabled,
|
||||
cmppAccessNumberFillPrefix: accessNumber.fillPrefix,
|
||||
cmppClientSrcId: accessNumber.clientSrcId,
|
||||
secretHash: secret,
|
||||
interfaceEnabled: data.interfaceEnabled ?? true,
|
||||
interfaceType,
|
||||
cmppMaxConnections: getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'),
|
||||
cmppWindowSize: getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'),
|
||||
dailyLimit: getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'),
|
||||
customerUnitPrice: data.customerUnitPrice ?? 0,
|
||||
queuePriority,
|
||||
templateMismatchMode: data.templateMismatchMode ?? 'reject',
|
||||
downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled ?? true,
|
||||
downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled ?? true,
|
||||
ipAllowlist: {
|
||||
create: (data.ipAllowlist ?? []).map((ipCidr) => ({ ipCidr })),
|
||||
},
|
||||
},
|
||||
include: { ipAllowlist: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updateApplication(applicationId: string, data: UpdateSmsApplicationDto) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
include: { httpConfig: true },
|
||||
});
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
if (data.customerUnitPrice !== undefined) {
|
||||
assertMoneyUnits(data.customerUnitPrice, '客户单价');
|
||||
}
|
||||
const queuePriority = data.queuePriority === undefined
|
||||
? undefined
|
||||
: normalizeApplicationQueuePriority(data.queuePriority);
|
||||
const cmppAccount = data.cmppAccount === undefined
|
||||
? undefined
|
||||
: await this.validateAndReserveCmppAccount(data.cmppAccount, applicationId);
|
||||
const cmppEnterpriseCode = cmppAccount ?? application.cmppAccount;
|
||||
const interfaceType = data.interfaceType === undefined
|
||||
? undefined
|
||||
: normalizeApplicationInterfaceType(data.interfaceType);
|
||||
const secretHash = data.passwordCipher === undefined
|
||||
? undefined
|
||||
: normalizeApplicationPassword(data.passwordCipher);
|
||||
const accessNumberChanged = data.cmppApplicationExtension !== undefined
|
||||
|| data.cmppAccessNumberFillEnabled !== undefined
|
||||
|| data.cmppAccessNumberFillPrefix !== undefined;
|
||||
const accessNumber = accessNumberChanged
|
||||
? normalizeCmppAccessNumberConfig(data, application)
|
||||
: undefined;
|
||||
if (accessNumber?.clientSrcId && accessNumber.clientSrcId !== application.cmppClientSrcId) {
|
||||
await this.validateClientSrcIdAvailable(accessNumber.clientSrcId, applicationId);
|
||||
}
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (data.ipAllowlist) {
|
||||
await tx.smsApplicationIpAllowlist.deleteMany({ where: { applicationId } });
|
||||
}
|
||||
const updated = await tx.smsApplication.update({
|
||||
where: { id: applicationId },
|
||||
data: {
|
||||
name: data.name,
|
||||
scene: data.scene,
|
||||
callbackUrl: data.callbackUrl,
|
||||
cmppAccount,
|
||||
cmppEnterpriseCode,
|
||||
cmppApplicationExtension: accessNumber?.applicationExtension,
|
||||
cmppAccessNumberFillEnabled: accessNumber?.fillEnabled,
|
||||
cmppAccessNumberFillPrefix: accessNumber?.fillPrefix,
|
||||
cmppClientSrcId: accessNumber?.clientSrcId,
|
||||
secretHash,
|
||||
interfaceEnabled: data.interfaceEnabled,
|
||||
interfaceType,
|
||||
cmppMaxConnections: data.cmppMaxConnections === undefined ? undefined : getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'),
|
||||
cmppWindowSize: data.cmppWindowSize === undefined ? undefined : getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'),
|
||||
dailyLimit: data.dailyLimit === undefined ? undefined : getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'),
|
||||
customerUnitPrice: data.customerUnitPrice,
|
||||
queuePriority,
|
||||
templateMismatchMode: data.templateMismatchMode,
|
||||
downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled,
|
||||
downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled,
|
||||
status: data.status,
|
||||
ipAllowlist: data.ipAllowlist ? {
|
||||
create: data.ipAllowlist.map((ipCidr) => ({ ipCidr })),
|
||||
} : undefined,
|
||||
},
|
||||
include: { tenant: true, ipAllowlist: true },
|
||||
});
|
||||
if (data.interfaceEnabled !== undefined && application.httpConfig) {
|
||||
const deliveryMode = automaticDeliveryMode(data.interfaceEnabled, application.httpConfig.enabled);
|
||||
await tx.smsApplicationHttpConfig.update({
|
||||
where: { applicationId },
|
||||
data: {
|
||||
receiptDeliveryMode: deliveryMode,
|
||||
uplinkDeliveryMode: deliveryMode,
|
||||
},
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async replaceApplicationRouteRules(applicationId: string, data: ReplaceApplicationRouteRulesDto) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const routes = data.routes ?? [];
|
||||
if (routes.length === 0) {
|
||||
throw new BadRequestException('At least one carrier channel group is required');
|
||||
}
|
||||
|
||||
const carriers = new Set<string>();
|
||||
routes.forEach((route) => {
|
||||
if (!['mobile', 'unicom', 'telecom'].includes(route.carrier)) {
|
||||
throw new BadRequestException('carrier must be mobile, unicom or telecom');
|
||||
}
|
||||
if (carriers.has(route.carrier)) {
|
||||
throw new BadRequestException('Duplicate carrier route is not allowed');
|
||||
}
|
||||
carriers.add(route.carrier);
|
||||
});
|
||||
|
||||
const groups = await this.prisma.smsChannelGroup.findMany({
|
||||
where: { id: { in: routes.map((route) => route.groupId) }, status: { not: 'deleted' } },
|
||||
select: { id: true, carrier: true },
|
||||
});
|
||||
const groupMap = new Map(groups.map((group) => [group.id, group]));
|
||||
routes.forEach((route) => {
|
||||
const group = groupMap.get(route.groupId);
|
||||
if (!group) {
|
||||
throw new BadRequestException(`channel group ${route.groupId} does not exist`);
|
||||
}
|
||||
if (group.carrier !== route.carrier) {
|
||||
throw new BadRequestException('channel group carrier must match route carrier');
|
||||
}
|
||||
});
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.channelRouteRule.deleteMany({
|
||||
where: {
|
||||
applicationId,
|
||||
channelId: null,
|
||||
province: null,
|
||||
},
|
||||
});
|
||||
await tx.channelRouteRule.createMany({
|
||||
data: routes.map((route, index) => ({
|
||||
tenantId: application.tenantId,
|
||||
applicationId,
|
||||
groupId: route.groupId,
|
||||
carrier: route.carrier,
|
||||
priority: route.priority ?? (index + 1) * 10,
|
||||
status: route.status ?? 'active',
|
||||
})),
|
||||
});
|
||||
return tx.channelRouteRule.findMany({
|
||||
where: { applicationId, channelId: null, province: null, status: { not: 'deleted' } },
|
||||
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async resetApplicationSecret(applicationId: string, data: StatusChangeDto = {}) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const secret = generateApplicationPassword();
|
||||
const updated = await this.prisma.smsApplication.update({
|
||||
where: { id: applicationId },
|
||||
data: { secretHash: secret },
|
||||
});
|
||||
await this.lifecycle.writeOperationLog(application.tenantId, data.operatorId, 'sms_application.secret_reset', 'sms_application', applicationId, {
|
||||
reason: data.reason,
|
||||
});
|
||||
return { ...updated, secret };
|
||||
}
|
||||
|
||||
async getApplicationCmppParams(applicationId: string, tenantId?: string) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
include: { tenant: true },
|
||||
});
|
||||
if (!application || (tenantId && application.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
if (tenantId && !application.interfaceEnabled) {
|
||||
throw new ForbiddenException('该企业应用未开通 CMPP 接口');
|
||||
}
|
||||
return {
|
||||
applicationId: application.id,
|
||||
applicationName: application.name,
|
||||
tenantId: application.tenantId,
|
||||
tenantName: application.tenant.name,
|
||||
appCode: application.id,
|
||||
gatewayHost: process.env.CMPP_PUBLIC_HOST?.trim() || '127.0.0.1',
|
||||
gatewayPort: getPositiveIntegerEnv('CMPP_PUBLIC_PORT', 17890),
|
||||
enterpriseCode: application.cmppEnterpriseCode,
|
||||
account: application.cmppAccount,
|
||||
passwordCipher: application.secretHash,
|
||||
srcId: application.cmppClientSrcId ?? '',
|
||||
applicationExtension: application.cmppApplicationExtension,
|
||||
accessNumberFillEnabled: application.cmppAccessNumberFillEnabled,
|
||||
accessNumberFillPrefix: application.cmppAccessNumberFillPrefix,
|
||||
interfaceEnabled: application.interfaceEnabled,
|
||||
interfaceType: application.interfaceType,
|
||||
maxConnections: application.cmppMaxConnections,
|
||||
heartbeatSeconds: 30,
|
||||
windowSize: application.cmppWindowSize,
|
||||
protocolVersion: application.interfaceType === 'cmpp20' ? 'CMPP2.0' : application.interfaceType,
|
||||
};
|
||||
}
|
||||
|
||||
async validateAndReserveCmppAccount(cmppAccount: string, currentApplicationId?: string) {
|
||||
if (!/^\d{6}$/.test(cmppAccount)) {
|
||||
throw new BadRequestException('cmppAccount must be a 6-digit number');
|
||||
}
|
||||
const exists = await this.prisma.smsApplication.findUnique({ where: { cmppAccount } });
|
||||
if (exists && exists.id !== currentApplicationId) {
|
||||
throw new BadRequestException('cmppAccount already exists');
|
||||
}
|
||||
return cmppAccount;
|
||||
}
|
||||
|
||||
async validateClientSrcIdAvailable(clientSrcId: string | null, currentApplicationId?: string) {
|
||||
if (!clientSrcId) return;
|
||||
const exists = await this.prisma.smsApplication.findUnique({ where: { cmppClientSrcId: clientSrcId } });
|
||||
if (exists && exists.id !== currentApplicationId) {
|
||||
throw new BadRequestException('client CMPP Src_Id already exists');
|
||||
}
|
||||
}
|
||||
|
||||
async generateCmppAccount() {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const cmppAccount = String(randomInt(100000, 1000000));
|
||||
const exists = await this.prisma.smsApplication.findUnique({ where: { cmppAccount } });
|
||||
if (!exists) {
|
||||
return cmppAccount;
|
||||
}
|
||||
}
|
||||
throw new BadRequestException('Unable to generate unique CMPP account');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
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';
|
||||
import { assertMoneyUnits } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { automaticDeliveryMode } from '../open-api/delivery-mode';
|
||||
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
|
||||
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
|
||||
|
||||
|
||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||
export class SmsApplicationLifecycleService {
|
||||
private readonly logger = new Logger(SmsApplicationLifecycleService.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 changeApplicationStatus(applicationId: string, data: StatusChangeDto) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const status = data.status ?? 'disabled';
|
||||
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,
|
||||
},
|
||||
});
|
||||
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) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
include: { tenant: true },
|
||||
});
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
await this.markTimedOutDownstreamConnections();
|
||||
const connections = await this.prisma.cmppDownstreamConnection.findMany({
|
||||
where: { applicationId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
return {
|
||||
application,
|
||||
connections,
|
||||
summary: {
|
||||
desiredConnections: application.cmppMaxConnections,
|
||||
currentConnections: connections.filter((connection) => connection.status === 'connected').length,
|
||||
status: normalizeApplicationCmppStatus(connections, application.status),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async recordDownstreamConnectionEvent(data: GatewayDownstreamConnectionEventDto) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { cmppAccount: data.account },
|
||||
include: { ipAllowlist: true },
|
||||
});
|
||||
if (!application) {
|
||||
throw new BadRequestException('CMPP account does not reference an application');
|
||||
}
|
||||
const observedAt = parseGatewayDate(data.observedAt) ?? new Date();
|
||||
const connectedAt = parseGatewayDate(data.connectedAt) ?? observedAt;
|
||||
const existing = await this.prisma.cmppDownstreamConnection.findUnique({ where: { connectionId: data.connectionId } });
|
||||
if (data.status === 'disconnected') {
|
||||
if (existing) {
|
||||
await this.prisma.cmppDownstreamConnection.delete({ where: { id: existing.id } });
|
||||
}
|
||||
await this.writeOperationLog(application.tenantId, undefined, 'cmpp_downstream_connection.disconnected', 'cmpp_downstream_connection', data.connectionId, {
|
||||
applicationId: application.id,
|
||||
account: data.account,
|
||||
remoteIp: data.remoteIp,
|
||||
protocol: data.protocol,
|
||||
status: 'disconnected',
|
||||
errorMessage: data.errorMessage,
|
||||
});
|
||||
return { connectionId: data.connectionId, status: 'disconnected', deleted: Boolean(existing) };
|
||||
}
|
||||
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))) {
|
||||
throw new ForbiddenException('CMPP source IP is not in application allowlist');
|
||||
}
|
||||
// A Gateway process can disappear before it reports disconnect. Prune its
|
||||
// expired rows here so a restarted process can reclaim connection slots
|
||||
// without waiting for an operator to open the connection-list page.
|
||||
await this.markTimedOutDownstreamConnections(observedAt);
|
||||
const activeConnections = await this.prisma.cmppDownstreamConnection.findMany({
|
||||
where: { applicationId: application.id, status: 'connected' },
|
||||
select: { connectionId: true },
|
||||
orderBy: [{ connectedAt: 'asc' }, { connectionId: 'asc' }],
|
||||
});
|
||||
const allowedConnectionIds = activeConnections.slice(0, application.cmppMaxConnections).map((item) => item.connectionId);
|
||||
if ((!existing && activeConnections.length >= application.cmppMaxConnections)
|
||||
|| (existing && activeConnections.length > application.cmppMaxConnections && !allowedConnectionIds.includes(data.connectionId))) {
|
||||
throw new ForbiddenException(`CMPP connection limit exceeded (${application.cmppMaxConnections})`);
|
||||
}
|
||||
const payload = {
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
account: data.account,
|
||||
enterpriseCode: application.cmppEnterpriseCode,
|
||||
remoteIp: data.remoteIp,
|
||||
protocol: data.protocol,
|
||||
status: 'connected',
|
||||
connectedAt: existing?.connectedAt ?? connectedAt,
|
||||
lastHeartbeatAt: data.status === 'connected' || data.status === 'heartbeat' ? observedAt : existing?.lastHeartbeatAt,
|
||||
lastSubmitAt: data.status === 'submit' ? observedAt : existing?.lastSubmitAt,
|
||||
lastDeliverAt: data.status === 'deliver' ? observedAt : existing?.lastDeliverAt,
|
||||
disconnectedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
const connection = existing
|
||||
? await this.prisma.cmppDownstreamConnection.update({ where: { id: existing.id }, data: payload })
|
||||
: await this.prisma.cmppDownstreamConnection.create({ data: { connectionId: data.connectionId, ...payload } });
|
||||
if (data.status === 'connected') {
|
||||
await this.writeOperationLog(application.tenantId, undefined, `cmpp_downstream_connection.${data.status}`, 'cmpp_downstream_connection', data.connectionId, {
|
||||
applicationId: application.id,
|
||||
account: data.account,
|
||||
remoteIp: data.remoteIp,
|
||||
protocol: data.protocol,
|
||||
status: connection.status,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
async markTimedOutDownstreamConnections(now = new Date()) {
|
||||
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',
|
||||
OR: [
|
||||
{ lastHeartbeatAt: { lt: cutoff } },
|
||||
{ lastHeartbeatAt: null, connectedAt: { lt: cutoff } },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
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>,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async finalizeDisablingApplication(
|
||||
application: { id: string; tenantId: string; cmppAccount: string; status: string },
|
||||
abandonOutstanding: boolean,
|
||||
reason: string,
|
||||
preview: Awaited<ReturnType<SmsApplicationLifecycleService['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;
|
||||
}
|
||||
|
||||
writeOperationLog(
|
||||
tenantId: string,
|
||||
userId: string | undefined,
|
||||
action: string,
|
||||
resource: string,
|
||||
resourceId: string,
|
||||
detail: Record<string, unknown>,
|
||||
) {
|
||||
return this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId,
|
||||
userId,
|
||||
action,
|
||||
resource,
|
||||
resourceId,
|
||||
detail: detail as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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';
|
||||
import { assertMoneyUnits } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { automaticDeliveryMode } from '../open-api/delivery-mode';
|
||||
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
|
||||
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
|
||||
import { SmsApplicationLifecycleService } from './application-lifecycle.service';
|
||||
import { SmsReportValidationService } from './report-validation.service';
|
||||
|
||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||
export class SmsAuditService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly lifecycle: SmsApplicationLifecycleService, private readonly reportValidation: SmsReportValidationService) {}
|
||||
listAuditRecords(targetType?: string, targetId?: string) {
|
||||
return this.prisma.auditRecord.findMany({
|
||||
where: {
|
||||
targetType,
|
||||
targetId,
|
||||
},
|
||||
include: {
|
||||
reviewer: { select: { id: true, username: true, displayName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
approveSignature(signatureId: string, data: ReviewDto) {
|
||||
return this.reviewSignature(signatureId, 'approved', 'approve', data);
|
||||
}
|
||||
|
||||
rejectSignature(signatureId: string, data: ReviewDto) {
|
||||
return this.reviewSignature(signatureId, 'rejected', 'reject', data);
|
||||
}
|
||||
|
||||
approveTemplate(templateId: string, data: ReviewDto) {
|
||||
return this.reviewTemplate(templateId, 'approved', 'approve', data);
|
||||
}
|
||||
|
||||
rejectTemplate(templateId: string, data: ReviewDto) {
|
||||
return this.reviewTemplate(templateId, 'rejected', 'reject', data);
|
||||
}
|
||||
|
||||
async changeSignatureStatus(signatureId: string, data: StatusChangeDto, tenantId?: string) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature || (tenantId && signature.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
const status = data.status ?? 'deleted';
|
||||
const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { auditStatus: status } });
|
||||
await this.lifecycle.writeOperationLog(signature.tenantId, data.operatorId, `sms_signature.${status}`, 'sms_signature', signatureId, {
|
||||
statusBefore: signature.auditStatus,
|
||||
statusAfter: status,
|
||||
reason: data.reason,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async changeTemplateStatus(templateId: string, data: StatusChangeDto, tenantId?: string) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template || (tenantId && template.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
const status = data.status ?? 'deleted';
|
||||
const updated = await this.prisma.smsTemplate.update({ where: { id: templateId }, data: { auditStatus: status } });
|
||||
await this.lifecycle.writeOperationLog(template.tenantId, data.operatorId, `sms_template.${status}`, 'sms_template', templateId, {
|
||||
statusBefore: template.auditStatus,
|
||||
statusAfter: status,
|
||||
reason: data.reason,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async reviewSignature(signatureId: string, statusAfter: string, action: string, data: ReviewDto) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
const reviewerId = await this.resolveReviewerId(data.reviewerId);
|
||||
|
||||
const updated = await this.prisma.smsSignature.update({
|
||||
where: { id: signatureId },
|
||||
data: {
|
||||
auditStatus: statusAfter,
|
||||
rejectReason: statusAfter === 'rejected' ? data.reason : null,
|
||||
},
|
||||
});
|
||||
await this.createAuditRecord({
|
||||
tenantId: signature.tenantId,
|
||||
targetType: 'sms_signature',
|
||||
targetId: signatureId,
|
||||
action,
|
||||
statusBefore: signature.auditStatus,
|
||||
statusAfter,
|
||||
reason: data.reason,
|
||||
reviewerId,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async reviewDrainageInfo(itemId: string, statusAfter: string, action: string, data: ReviewDto) {
|
||||
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
|
||||
if (!item) throw new NotFoundException('Drainage info not found');
|
||||
if (!['pending', 'rejected'].includes(item.auditStatus)) {
|
||||
throw new BadRequestException('只有待审核或已驳回的引流信息可以审核');
|
||||
}
|
||||
if (statusAfter === 'rejected' && !data.reason?.trim()) {
|
||||
throw new BadRequestException('驳回引流信息时必须填写原因');
|
||||
}
|
||||
const reviewerId = await this.resolveReviewerId(data.reviewerId);
|
||||
const updated = await this.prisma.smsDrainageInfo.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
auditStatus: statusAfter,
|
||||
rejectReason: statusAfter === 'rejected' ? data.reason?.trim() : null,
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
include: { tenant: true, signature: true, application: true },
|
||||
});
|
||||
await this.createAuditRecord({
|
||||
tenantId: item.tenantId,
|
||||
targetType: 'sms_drainage_info',
|
||||
targetId: itemId,
|
||||
action,
|
||||
statusBefore: item.auditStatus,
|
||||
statusAfter,
|
||||
reason: data.reason,
|
||||
reviewerId,
|
||||
});
|
||||
if (statusAfter === 'approved') await this.reportValidation.activateDrainageReporting(itemId);
|
||||
else await this.reportValidation.suspendDrainageReporting(itemId, data.reason?.trim() || '引流信息运营审核驳回');
|
||||
return updated;
|
||||
}
|
||||
|
||||
async reviewTemplate(templateId: string, statusAfter: string, action: string, data: ReviewDto) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template) {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
const reviewerId = await this.resolveReviewerId(data.reviewerId);
|
||||
|
||||
const updated = await this.prisma.smsTemplate.update({
|
||||
where: { id: templateId },
|
||||
data: {
|
||||
auditStatus: statusAfter,
|
||||
rejectReason: statusAfter === 'rejected' ? data.reason : null,
|
||||
},
|
||||
});
|
||||
await this.createAuditRecord({
|
||||
tenantId: template.tenantId,
|
||||
targetType: 'sms_template',
|
||||
targetId: templateId,
|
||||
action,
|
||||
statusBefore: template.auditStatus,
|
||||
statusAfter,
|
||||
reason: data.reason,
|
||||
reviewerId,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async resolveReviewerId(reviewerId?: string) {
|
||||
if (!reviewerId) {
|
||||
return undefined;
|
||||
}
|
||||
const reviewer = await this.prisma.user.findUnique({ where: { id: reviewerId }, select: { id: true } });
|
||||
if (!reviewer) {
|
||||
throw new BadRequestException('reviewerId does not reference an existing user');
|
||||
}
|
||||
return reviewerId;
|
||||
}
|
||||
|
||||
createAuditRecord(data: Prisma.AuditRecordUncheckedCreateInput) {
|
||||
return this.prisma.auditRecord.create({ data });
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
CreateSmsSignatureDto,
|
||||
CreateSmsTemplateDto,
|
||||
StatusChangeDto,
|
||||
SmsConfigService,
|
||||
UpdateSmsTemplateDto,
|
||||
UpdateSmsDrainageInfoDto,
|
||||
UpdateSmsSignatureDto,
|
||||
} from './sms-config.service';
|
||||
} from './sms-config.contracts';
|
||||
import { SmsConfigService } from './sms-config.service';
|
||||
|
||||
@ApiTags('client-sms-config')
|
||||
@Controller('client')
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
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';
|
||||
import { assertMoneyUnits } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { automaticDeliveryMode } from '../open-api/delivery-mode';
|
||||
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
|
||||
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
|
||||
import { SmsReportValidationService } from './report-validation.service';
|
||||
import { SmsAuditService } from './audit.service';
|
||||
|
||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||
export class SmsDrainageService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService) {}
|
||||
async listClientDrainageInfos(tenantId?: string, itemId?: string) {
|
||||
return this.prisma.smsDrainageInfo.findMany({
|
||||
where: { id: itemId, tenantId, auditStatus: { not: 'deleted' } },
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
signatureId: true,
|
||||
applicationId: true,
|
||||
siteName: true,
|
||||
url: true,
|
||||
remark: true,
|
||||
reportValues: true,
|
||||
auditStatus: true,
|
||||
rejectReason: true,
|
||||
submittedAt: true,
|
||||
reviewedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
signature: { select: { id: true, name: true, auditStatus: true } },
|
||||
application: { select: { id: true, name: true, status: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getClientDrainageInfoView(itemId: string, tenantId?: string) {
|
||||
const [item] = await this.listClientDrainageInfos(tenantId, itemId);
|
||||
if (!item) throw new NotFoundException('Drainage info not found');
|
||||
return item;
|
||||
}
|
||||
|
||||
listDrainageInfos(query: DrainageInfoListQuery = {}) {
|
||||
return this.prisma.smsDrainageInfo.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
signatureId: query.signatureId,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
OR: query.keyword ? [
|
||||
{ siteName: { contains: query.keyword } },
|
||||
{ url: { contains: query.keyword } },
|
||||
{ signature: { name: { contains: query.keyword } } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { tenant: true, signature: true, application: true, reportTasks: { include: { channel: true } } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async createDrainageInfo(signatureId: string, data: CreateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature) throw new NotFoundException('Signature not found');
|
||||
if (tenantId && signature.tenantId !== tenantId) throw new NotFoundException('Signature not found');
|
||||
if (signature.auditStatus !== 'approved') throw new BadRequestException('签名审核通过后才能新增引流信息');
|
||||
if (!data.siteName?.trim() || !data.url?.trim()) throw new BadRequestException('siteName and url are required');
|
||||
await this.reportValidation.validateDrainageReportValues(signature.applicationId ?? undefined, data.reportValues);
|
||||
const auditStatus = options.initialAuditStatus ?? 'pending';
|
||||
const item = await this.prisma.smsDrainageInfo.create({
|
||||
data: {
|
||||
tenantId: signature.tenantId,
|
||||
signatureId,
|
||||
applicationId: signature.applicationId,
|
||||
siteName: data.siteName.trim(),
|
||||
url: data.url.trim(),
|
||||
remark: data.remark,
|
||||
reportValues: data.reportValues as Prisma.InputJsonValue | undefined,
|
||||
auditStatus,
|
||||
reviewedAt: auditStatus === 'approved' ? new Date() : undefined,
|
||||
},
|
||||
include: { tenant: true, signature: true, application: true },
|
||||
});
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: item.tenantId,
|
||||
targetType: 'sms_drainage_info',
|
||||
targetId: item.id,
|
||||
action: auditStatus === 'approved' ? 'admin_create_approved' : 'submit',
|
||||
statusAfter: auditStatus,
|
||||
reason: auditStatus === 'approved' ? '运营端新建引流信息自动审核通过' : undefined,
|
||||
});
|
||||
if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(item.id);
|
||||
return item;
|
||||
}
|
||||
|
||||
async updateDrainageInfo(itemId: string, data: UpdateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) {
|
||||
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } });
|
||||
if (!current) throw new NotFoundException('Drainage info not found');
|
||||
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
|
||||
if (current.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改');
|
||||
if (data.siteName !== undefined && !data.siteName.trim()) throw new BadRequestException('siteName is required');
|
||||
if (data.url !== undefined && !data.url.trim()) throw new BadRequestException('url is required');
|
||||
const applicationId = current.signature.applicationId ?? current.applicationId ?? undefined;
|
||||
await this.reportValidation.validateDrainageReportValues(applicationId, data.reportValues ?? (isRecord(current.reportValues) ? current.reportValues : {}));
|
||||
const auditStatus = options.initialAuditStatus ?? 'pending';
|
||||
const updated = await this.prisma.smsDrainageInfo.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
applicationId,
|
||||
siteName: data.siteName?.trim(),
|
||||
url: data.url?.trim(),
|
||||
remark: data.remark,
|
||||
reportValues: data.reportValues as Prisma.InputJsonValue | undefined,
|
||||
auditStatus,
|
||||
rejectReason: null,
|
||||
submittedAt: new Date(),
|
||||
reviewedAt: auditStatus === 'approved' ? new Date() : null,
|
||||
materialVersion: { increment: 1 },
|
||||
pendingReport: true,
|
||||
reportChangedAt: new Date(),
|
||||
},
|
||||
include: { tenant: true, signature: true, application: true },
|
||||
});
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: current.tenantId,
|
||||
targetType: 'sms_drainage_info',
|
||||
targetId: itemId,
|
||||
action: auditStatus === 'approved' ? 'admin_update_approved' : 'update_submit',
|
||||
statusBefore: current.auditStatus,
|
||||
statusAfter: auditStatus,
|
||||
reason: auditStatus === 'approved' ? '运营端修改引流信息并自动审核通过' : undefined,
|
||||
});
|
||||
if (auditStatus === 'approved') await this.reportValidation.activateDrainageReporting(itemId);
|
||||
else await this.reportValidation.suspendDrainageReporting(itemId, '引流信息修改后等待运营审核');
|
||||
return updated;
|
||||
}
|
||||
|
||||
approveDrainageInfo(itemId: string, data: ReviewDto) {
|
||||
return this.audit.reviewDrainageInfo(itemId, 'approved', 'approve', data);
|
||||
}
|
||||
|
||||
rejectDrainageInfo(itemId: string, data: ReviewDto) {
|
||||
return this.audit.reviewDrainageInfo(itemId, 'rejected', 'reject', data);
|
||||
}
|
||||
|
||||
async changeDrainageInfoStatus(itemId: string, data: StatusChangeDto, tenantId?: string) {
|
||||
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
|
||||
if (!current) throw new NotFoundException('Drainage info not found');
|
||||
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
|
||||
const status = data.status ?? 'deleted';
|
||||
if (tenantId && status !== 'deleted') throw new BadRequestException('客户端只能删除引流信息,不能直接修改审核状态');
|
||||
const updated = await this.prisma.smsDrainageInfo.update({ where: { id: itemId }, data: { auditStatus: status } });
|
||||
if (status === 'deleted') await this.reportValidation.suspendDrainageReporting(itemId, data.reason ?? '引流信息已删除', 'abandoned');
|
||||
await this.audit.createAuditRecord({ tenantId: current.tenantId, targetType: 'sms_drainage_info', targetId: itemId, action: status, statusBefore: current.auditStatus, statusAfter: status, reason: data.reason });
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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';
|
||||
import { assertMoneyUnits } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { automaticDeliveryMode } from '../open-api/delivery-mode';
|
||||
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
|
||||
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
|
||||
import { SmsApplicationConfigService } from './application-config.service';
|
||||
|
||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||
export class SmsReportValidationService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly applications: SmsApplicationConfigService) {}
|
||||
async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record<string, unknown>) {
|
||||
if (!drainageInfo) return drainageInfo;
|
||||
const fields = await this.applications.getApplicationReportFields(applicationId);
|
||||
return {
|
||||
...drainageInfo,
|
||||
reportRequirementSnapshot: {
|
||||
capturedAt: new Date().toISOString(),
|
||||
applicationId,
|
||||
fields: fields.map((field) => ({
|
||||
id: field.id,
|
||||
code: field.code,
|
||||
name: field.name,
|
||||
fieldType: field.fieldType,
|
||||
required: field.required,
|
||||
reportTypes: field.reportTypes,
|
||||
commonReportTypes: field.commonReportTypes,
|
||||
channels: field.channels,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async syncSignatureReportValues(signatureId: string, applicationId?: string, drainageInfo?: Record<string, unknown>) {
|
||||
if (!drainageInfo) return;
|
||||
const fields = await this.applications.getApplicationReportFields(applicationId);
|
||||
const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {};
|
||||
for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) {
|
||||
const value = reportValueParts(signatureValues[field.code]);
|
||||
for (const channel of field.channels) {
|
||||
await this.prisma.signatureReportMaterial.upsert({
|
||||
where: { signatureId_channelId_fieldCode: { signatureId, channelId: channel.id, fieldCode: field.code } },
|
||||
update: value,
|
||||
create: { signatureId, channelId: channel.id, fieldCode: field.code, ...value },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async validateSignatureReportValues(applicationId?: string, drainageInfo?: Record<string, unknown>) {
|
||||
const fields = await this.applications.getApplicationReportFields(applicationId, 'signature');
|
||||
const signatureValues = isRecord(drainageInfo?.signatureReportValues) ? drainageInfo.signatureReportValues : {};
|
||||
const missingSignature = fields
|
||||
.filter((field) => field.required && field.reportTypes.some((type) => type === 'signature' || type === 'both'))
|
||||
.filter((field) => !hasReportValue(signatureValues[field.code]));
|
||||
if (missingSignature.length > 0) {
|
||||
throw new BadRequestException(`缺少必填签名报备资料:${missingSignature.map((field) => field.name).join('、')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async validateDrainageReportValues(applicationId?: string, reportValues: Record<string, unknown> = {}) {
|
||||
const fields = await this.applications.getApplicationReportFields(applicationId, 'drainage');
|
||||
const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code]));
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async activateDrainageReporting(itemId: string) {
|
||||
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } });
|
||||
if (!item) throw new NotFoundException('Drainage info not found');
|
||||
if (item.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
|
||||
const applicationId = item.signature.applicationId ?? item.applicationId ?? undefined;
|
||||
if (!applicationId) return;
|
||||
const fields = (await this.applications.getApplicationReportFields(applicationId, 'drainage'))
|
||||
.filter((field) => field.reportTypes.some((type) => type === 'drainage' || type === 'both'));
|
||||
const channels = new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel]));
|
||||
const values = isRecord(item.reportValues) ? item.reportValues : {};
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
|
||||
for (const field of fields) {
|
||||
const value = reportValueParts(values[field.code]);
|
||||
for (const channel of field.channels) {
|
||||
await tx.drainageReportMaterial.create({
|
||||
data: { signatureId: item.signatureId, drainageItemId: item.id, channelId: channel.id, fieldCode: field.code, ...value },
|
||||
});
|
||||
}
|
||||
}
|
||||
const existingTasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } });
|
||||
const existingByChannel = new Map(existingTasks.map((task) => [task.channelId, task]));
|
||||
for (const channel of channels.values()) {
|
||||
const existing = existingByChannel.get(channel.id);
|
||||
const task = existing
|
||||
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: 'pending', reason: null } })
|
||||
: await tx.channelSignatureReportTask.create({ data: { tenantId: item.tenantId, signatureId: item.signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId: item.id, status: 'pending' } });
|
||||
await tx.channelSignatureReportRecord.create({
|
||||
data: { taskId: task.id, channelId: channel.id, action: existing ? 'audit_approved_reset' : 'audit_approved_create', statusBefore: existing?.status, statusAfter: 'pending', reason: '引流信息运营审核通过' },
|
||||
});
|
||||
}
|
||||
for (const task of existingTasks.filter((current) => !channels.has(current.channelId) && current.status !== 'abandoned')) {
|
||||
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: 'abandoned', reason: '应用当前路由已不包含此通道' } });
|
||||
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'route_removed', statusBefore: task.status, statusAfter: 'abandoned', reason: '应用当前路由已不包含此通道' } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async suspendDrainageReporting(itemId: string, reason: string, statusAfter = 'waiting_review') {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const item = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } });
|
||||
if (!item) throw new NotFoundException('Drainage info not found');
|
||||
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
|
||||
const tasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } });
|
||||
for (const task of tasks.filter((current) => current.status !== statusAfter)) {
|
||||
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: statusAfter, reason } });
|
||||
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'audit_suspended', statusBefore: task.status, statusAfter, reason } });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
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';
|
||||
import { assertMoneyUnits } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { automaticDeliveryMode } from '../open-api/delivery-mode';
|
||||
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
|
||||
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
|
||||
import { SmsReportValidationService } from './report-validation.service';
|
||||
import { SmsAuditService } from './audit.service';
|
||||
|
||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||
export class SmsSignatureService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService) {}
|
||||
async listSignatures(queryOrTenantId?: string | SignatureListQuery) {
|
||||
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
||||
const signatures = await this.prisma.smsSignature.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
|
||||
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
|
||||
name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined,
|
||||
drainageItems: query.drainageKeyword ? {
|
||||
some: {
|
||||
auditStatus: { not: 'deleted' },
|
||||
OR: [
|
||||
{ siteName: { contains: query.drainageKeyword } },
|
||||
{ url: { contains: query.drainageKeyword } },
|
||||
{ remark: { contains: query.drainageKeyword } },
|
||||
],
|
||||
},
|
||||
} : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ purpose: { contains: query.keyword } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: {
|
||||
materials: true,
|
||||
tenant: true,
|
||||
application: true,
|
||||
drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } },
|
||||
reportTasks: { include: { channel: true, drainageInfo: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
...(query.page && query.pageSize ? {
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
} : {}),
|
||||
});
|
||||
const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id));
|
||||
const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({
|
||||
where: { applicationId: { in: applicationIds }, status: 'active' },
|
||||
include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } },
|
||||
}) : [];
|
||||
const hasCommonDrainageFields = await this.prisma.commonReportField.count({
|
||||
where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } },
|
||||
}).then((count) => count > 0);
|
||||
return signatures.map((signature) => {
|
||||
const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
|
||||
const drainageLinks = signature.drainageItems.map((item) => ({
|
||||
id: item.id,
|
||||
siteName: item.siteName,
|
||||
url: item.url,
|
||||
remark: item.remark ?? '',
|
||||
reportValues: isRecord(item.reportValues) ? item.reportValues : {},
|
||||
auditStatus: item.auditStatus,
|
||||
rejectReason: item.rejectReason,
|
||||
submittedAt: item.submittedAt.toISOString(),
|
||||
reviewedAt: item.reviewedAt?.toISOString(),
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
}));
|
||||
return {
|
||||
...signature,
|
||||
name: normalizeSmsSignature(signature.name),
|
||||
drainageInfo: { ...legacyPayload, links: drainageLinks },
|
||||
reportTargets: (() => {
|
||||
const channels = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
|
||||
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task]));
|
||||
return [...new Map(channels.map((channel) => [channel.id, channel])).values()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id }));
|
||||
})(),
|
||||
drainageReportTargets: Object.fromEntries(signature.drainageItems.map((drainageItem) => {
|
||||
const drainageItemId = drainageItem.id;
|
||||
const channels = routes
|
||||
.filter((route) => route.applicationId === signature.applicationId && route.group)
|
||||
.flatMap((route) => route.group!.items.map((item) => item.channel))
|
||||
.filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType))));
|
||||
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task]));
|
||||
return [drainageItemId, [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => {
|
||||
const task = taskByChannel.get(channel.id);
|
||||
return task ? [{ channel, channelId: channel.id, status: task.status, taskId: task.id }] : [];
|
||||
})];
|
||||
})),
|
||||
drainageCarrierReportSummary: Object.fromEntries(signature.drainageItems.map((drainageItem) => {
|
||||
const drainageItemId = drainageItem.id;
|
||||
const channels = routes
|
||||
.filter((route) => route.applicationId === signature.applicationId && route.group)
|
||||
.flatMap((route) => route.group!.items.map((item) => item.channel))
|
||||
.filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType))));
|
||||
const targets = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
|
||||
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task]));
|
||||
return [drainageItemId, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
|
||||
const carrierTargets = targets.filter((channel) => channel.carrier === carrier || channel.carrier === 'all');
|
||||
const statuses = carrierTargets.flatMap((channel) => taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : []);
|
||||
const approved = statuses.filter((status) => status === 'approved').length;
|
||||
const status = !statuses.length ? 'not_applicable' : approved === statuses.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
|
||||
return [carrier, { status, approved, total: statuses.length }];
|
||||
}))];
|
||||
})),
|
||||
carrierReportSummary: Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
|
||||
const configured = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted' && (channel.carrier === carrier || channel.carrier === 'all'));
|
||||
const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()];
|
||||
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task]));
|
||||
const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
|
||||
const approved = statuses.filter((status) => status === 'approved').length;
|
||||
const status = !targets.length ? 'not_applicable' : approved === targets.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
|
||||
return [carrier, { status, approved, total: targets.length }];
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async listSignaturesPage(query: SignatureListQuery) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||
const where: Prisma.SmsSignatureWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
|
||||
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
|
||||
name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined,
|
||||
drainageItems: query.drainageKeyword ? {
|
||||
some: {
|
||||
auditStatus: { not: 'deleted' },
|
||||
OR: [
|
||||
{ siteName: { contains: query.drainageKeyword } },
|
||||
{ url: { contains: query.drainageKeyword } },
|
||||
{ remark: { contains: query.drainageKeyword } },
|
||||
],
|
||||
},
|
||||
} : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ purpose: { contains: query.keyword } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.listSignatures({ ...query, page, pageSize }),
|
||||
this.prisma.smsSignature.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
listSignatureOptions(tenantId?: string) {
|
||||
return this.prisma.smsSignature.findMany({
|
||||
where: { tenantId, auditStatus: { not: 'deleted' } },
|
||||
select: { id: true, tenantId: true, applicationId: true, name: true, auditStatus: true },
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async listClientSignatures(tenantId?: string, signatureId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}) {
|
||||
const signatures = await this.prisma.smsSignature.findMany({
|
||||
where: {
|
||||
id: signatureId,
|
||||
tenantId,
|
||||
applicationId: query.applicationId,
|
||||
auditStatus: query.status || { notIn: ['deleted', 'disabled'] },
|
||||
OR: query.keyword?.trim() ? [
|
||||
{ name: { contains: query.keyword.trim() } },
|
||||
{ purpose: { contains: query.keyword.trim() } },
|
||||
{ application: { name: { contains: query.keyword.trim() } } },
|
||||
] : undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
applicationId: true,
|
||||
name: true,
|
||||
purpose: true,
|
||||
auditStatus: true,
|
||||
reportStatus: true,
|
||||
pendingReport: true,
|
||||
reportChangedAt: true,
|
||||
rejectReason: true,
|
||||
drainageInfo: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
application: { select: { id: true, name: true, status: true } },
|
||||
materials: {
|
||||
select: { id: true, fileObjectId: true, materialType: true, title: true, description: true, createdAt: true },
|
||||
},
|
||||
drainageItems: {
|
||||
where: { auditStatus: { not: 'deleted' } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
siteName: true,
|
||||
url: true,
|
||||
remark: true,
|
||||
reportValues: true,
|
||||
auditStatus: true,
|
||||
rejectReason: true,
|
||||
submittedAt: true,
|
||||
reviewedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
_count: { select: { reportMaterials: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined,
|
||||
take: query.pageSize,
|
||||
});
|
||||
return signatures.map((signature) => {
|
||||
const stored = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
|
||||
return {
|
||||
id: signature.id,
|
||||
tenantId: signature.tenantId,
|
||||
applicationId: signature.applicationId,
|
||||
name: normalizeSmsSignature(signature.name),
|
||||
purpose: signature.purpose,
|
||||
auditStatus: signature.auditStatus,
|
||||
reportStatus: signature.reportStatus,
|
||||
pendingReport: signature.pendingReport,
|
||||
reportChangedAt: signature.reportChangedAt,
|
||||
rejectReason: signature.rejectReason,
|
||||
createdAt: signature.createdAt,
|
||||
updatedAt: signature.updatedAt,
|
||||
application: signature.application,
|
||||
materials: signature.materials,
|
||||
submittedMaterialCount: signature.materials.length + signature._count.reportMaterials,
|
||||
reportValues: isRecord(stored.signatureReportValues) ? stored.signatureReportValues : {},
|
||||
drainageInfo: {
|
||||
links: signature.drainageItems.map((item) => ({
|
||||
...item,
|
||||
reportValues: isRecord(item.reportValues) ? item.reportValues : {},
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getClientSignatureView(signatureId: string, tenantId?: string) {
|
||||
const [signature] = await this.listClientSignatures(tenantId, signatureId);
|
||||
if (!signature) throw new NotFoundException('Signature not found');
|
||||
return signature;
|
||||
}
|
||||
|
||||
async getClientSignatureWorkspace(tenantId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||
const filteredWhere: Prisma.SmsSignatureWhereInput = {
|
||||
tenantId,
|
||||
applicationId: query.applicationId,
|
||||
auditStatus: query.status || { notIn: ['deleted', 'disabled'] },
|
||||
OR: query.keyword?.trim() ? [
|
||||
{ name: { contains: query.keyword.trim() } },
|
||||
{ purpose: { contains: query.keyword.trim() } },
|
||||
{ application: { name: { contains: query.keyword.trim() } } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total, statusCounts] = await Promise.all([
|
||||
this.listClientSignatures(tenantId, undefined, { ...query, page, pageSize }),
|
||||
this.prisma.smsSignature.count({ where: filteredWhere }),
|
||||
this.prisma.smsSignature.groupBy({
|
||||
by: ['auditStatus'],
|
||||
where: { tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
const summary = { total: 0, pending: 0, approved: 0, rejected: 0, draft: 0 };
|
||||
for (const item of statusCounts) {
|
||||
const count = item._count._all;
|
||||
summary.total += count;
|
||||
if (item.auditStatus in summary && item.auditStatus !== 'total') {
|
||||
summary[item.auditStatus as keyof Omit<typeof summary, 'total'>] = count;
|
||||
}
|
||||
}
|
||||
return { items, summary, total, page, pageSize };
|
||||
}
|
||||
|
||||
async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) {
|
||||
await this.reportValidation.validateSignatureReportValues(data.applicationId, data.drainageInfo);
|
||||
const drainageInfo = await this.reportValidation.withReportRequirementSnapshot(data.applicationId, data.drainageInfo);
|
||||
const name = validateCompleteSmsSignature(data.name);
|
||||
const signature = await this.prisma.smsSignature.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
name,
|
||||
purpose: data.purpose,
|
||||
auditStatus: options.initialAuditStatus,
|
||||
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
||||
},
|
||||
});
|
||||
await this.reportValidation.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo);
|
||||
if (options.initialAuditStatus) {
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: signature.tenantId,
|
||||
targetType: 'sms_signature',
|
||||
targetId: signature.id,
|
||||
action: 'admin_create_approved',
|
||||
statusAfter: options.initialAuditStatus,
|
||||
reason: '运营端新建签名自动审核通过',
|
||||
});
|
||||
}
|
||||
return signature;
|
||||
}
|
||||
|
||||
async updateSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature || (tenantId && signature.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
await this.reportValidation.validateSignatureReportValues(data.applicationId ?? signature.applicationId ?? undefined, data.drainageInfo);
|
||||
const applicationId = data.applicationId ?? signature.applicationId ?? undefined;
|
||||
const drainageInfo = data.drainageInfo
|
||||
? await this.reportValidation.withReportRequirementSnapshot(applicationId, data.drainageInfo)
|
||||
: undefined;
|
||||
const name = data.name === undefined ? undefined : validateCompleteSmsSignature(data.name);
|
||||
const materialChanged = (data.applicationId !== undefined && data.applicationId !== signature.applicationId)
|
||||
|| (name !== undefined && 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,
|
||||
purpose: data.purpose,
|
||||
auditStatus,
|
||||
rejectReason: auditStatus === 'pending' ? null : undefined,
|
||||
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
||||
materialVersion: { increment: 1 },
|
||||
pendingReport: true,
|
||||
reportChangedAt: new Date(),
|
||||
},
|
||||
include: { materials: true, tenant: true, application: true },
|
||||
});
|
||||
await this.reportValidation.syncSignatureReportValues(signatureId, updated.applicationId ?? undefined, drainageInfo);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateClientSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) {
|
||||
const current = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Signature not found');
|
||||
if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) {
|
||||
throw new BadRequestException('当前审核状态不允许修改签名');
|
||||
}
|
||||
const updated = await this.updateSignature(signatureId, { ...data, auditStatus: 'pending' }, tenantId);
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: current.tenantId,
|
||||
targetType: 'sms_signature',
|
||||
targetId: signatureId,
|
||||
action: 'client_update_submit',
|
||||
statusBefore: current.auditStatus,
|
||||
statusAfter: 'pending',
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
createSignatureMaterial(data: CreateSignatureMaterialDto) {
|
||||
return this.prisma.signatureMaterial.create({
|
||||
data: {
|
||||
signatureId: data.signatureId,
|
||||
fileObjectId: data.fileObjectId,
|
||||
materialType: data.materialType,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async submitSignature(signatureId: string, tenantId?: string) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature || (tenantId && signature.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.smsSignature.update({
|
||||
where: { id: signatureId },
|
||||
data: { auditStatus: 'pending', rejectReason: null },
|
||||
});
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: signature.tenantId,
|
||||
targetType: 'sms_signature',
|
||||
targetId: signatureId,
|
||||
action: 'submit',
|
||||
statusBefore: signature.auditStatus,
|
||||
statusAfter: 'pending',
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/** Stable request and query contracts shared by controllers and SMS configuration domains. */
|
||||
|
||||
export interface CreateSmsApplicationDto {
|
||||
tenantId: string;
|
||||
name: string;
|
||||
scene?: string;
|
||||
callbackUrl?: string;
|
||||
cmppAccount?: string;
|
||||
cmppEnterpriseCode?: string;
|
||||
cmppApplicationExtension?: string;
|
||||
cmppAccessNumberFillEnabled?: boolean;
|
||||
cmppAccessNumberFillPrefix?: string;
|
||||
passwordCipher?: string;
|
||||
interfaceEnabled?: boolean;
|
||||
interfaceType?: string;
|
||||
cmppMaxConnections?: number;
|
||||
cmppWindowSize?: number;
|
||||
dailyLimit?: number;
|
||||
customerUnitPrice?: number;
|
||||
queuePriority?: string;
|
||||
templateMismatchMode?: string;
|
||||
downstreamReceiptRetryEnabled?: boolean;
|
||||
downstreamUplinkRetryEnabled?: boolean;
|
||||
ipAllowlist?: string[];
|
||||
}
|
||||
|
||||
export type UpdateSmsApplicationDto = Partial<Omit<CreateSmsApplicationDto, 'tenantId'>> & {
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export interface ReplaceApplicationRouteRulesDto {
|
||||
routes: Array<{
|
||||
carrier: string;
|
||||
groupId: string;
|
||||
priority?: number;
|
||||
status?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CreateSmsSignatureDto {
|
||||
tenantId: string;
|
||||
applicationId?: string;
|
||||
name: string;
|
||||
purpose?: string;
|
||||
drainageInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CreateSmsSignatureOptions {
|
||||
initialAuditStatus?: string;
|
||||
}
|
||||
|
||||
export type UpdateSmsSignatureDto = Partial<Omit<CreateSmsSignatureDto, 'tenantId'>> & {
|
||||
auditStatus?: string;
|
||||
};
|
||||
|
||||
export interface CreateSmsDrainageInfoDto {
|
||||
siteName: string;
|
||||
url: string;
|
||||
remark?: string;
|
||||
reportValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type UpdateSmsDrainageInfoDto = Partial<CreateSmsDrainageInfoDto>;
|
||||
|
||||
export interface DrainageInfoListQuery {
|
||||
tenantId?: string;
|
||||
signatureId?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export interface CreateSignatureMaterialDto {
|
||||
signatureId: string;
|
||||
fileObjectId?: string;
|
||||
materialType: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreateSmsTemplateDto {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
signatureId?: string;
|
||||
name: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
variables?: Array<{ name: string; example?: string; required?: boolean }>;
|
||||
}
|
||||
|
||||
export interface CreateSmsTemplateOptions {
|
||||
initialAuditStatus?: string;
|
||||
}
|
||||
|
||||
export type UpdateSmsTemplateDto = Partial<Omit<CreateSmsTemplateDto, 'tenantId' | 'signatureId'>> & {
|
||||
signatureId?: string | null;
|
||||
auditStatus?: string;
|
||||
};
|
||||
|
||||
export interface ReviewDto {
|
||||
reviewerId?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface StatusChangeDto {
|
||||
status?: string;
|
||||
operatorId?: string;
|
||||
reason?: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface TemplateListQuery {
|
||||
tenantId?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
enterpriseKeyword?: string;
|
||||
applicationKeyword?: string;
|
||||
nameKeyword?: string;
|
||||
contentKeyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface ApplicationListQuery {
|
||||
tenantId?: string;
|
||||
keyword?: string;
|
||||
enterpriseKeyword?: string;
|
||||
applicationKeyword?: string;
|
||||
status?: string;
|
||||
includeConnections?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface SignatureListQuery {
|
||||
tenantId?: string;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
enterpriseKeyword?: string;
|
||||
applicationKeyword?: string;
|
||||
signatureKeyword?: string;
|
||||
drainageKeyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface GatewayDownstreamConnectionEventDto {
|
||||
account: string;
|
||||
connectionId: string;
|
||||
status: 'connected' | 'heartbeat' | 'submit' | 'deliver' | 'disconnected';
|
||||
remoteIp?: string;
|
||||
protocol?: string;
|
||||
connectedAt?: string;
|
||||
observedAt?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import type { CreateSmsApplicationDto } from './sms-config.contracts';
|
||||
|
||||
/** Pure normalization and report-value helpers shared by the R3 domain services. */
|
||||
export const APPLICATION_QUEUE_PRIORITIES = ['normal', 'priority'] as const;
|
||||
export type ApplicationQueuePriority = typeof APPLICATION_QUEUE_PRIORITIES[number];
|
||||
export const APPLICATION_INTERFACE_TYPES = ['cmpp20'] as const;
|
||||
export type ApplicationInterfaceType = typeof APPLICATION_INTERFACE_TYPES[number];
|
||||
export const DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS = 90_000;
|
||||
export const APPLICATION_DISABLE_GRACE_MS = 72 * 60 * 60 * 1_000;
|
||||
export const DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS = 60_000;
|
||||
export const UNRESOLVED_DOWNSTREAM_STATUSES = ['pending', 'awaiting_ack', 'failed', 'manual_requeueing'] as const;
|
||||
|
||||
export interface TemplateVariableInput {
|
||||
name: string;
|
||||
example?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export function normalizeApplicationPassword(value: string | undefined) {
|
||||
const password = value?.trim() || generateApplicationPassword();
|
||||
if (password.length !== 16) {
|
||||
throw new BadRequestException('passwordCipher must be 16 characters');
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
export function generateApplicationPassword() {
|
||||
return randomUUID().replace(/-/g, '').slice(0, 16);
|
||||
}
|
||||
|
||||
export function estimateBillingUnits(content: string) {
|
||||
const length = [...content].length;
|
||||
if (length <= 70) {
|
||||
return 1;
|
||||
}
|
||||
return Math.ceil(length / 67);
|
||||
}
|
||||
|
||||
export function inferTemplateVariables(content: string): TemplateVariableInput[] {
|
||||
const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? [];
|
||||
return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true }));
|
||||
}
|
||||
|
||||
export function validateAndNormalizeTemplateVariables(
|
||||
content: string,
|
||||
supplied?: Array<{ name: string; example?: string; required?: boolean }>,
|
||||
): TemplateVariableInput[] {
|
||||
const names: string[] = [];
|
||||
let cursor = 0;
|
||||
while (true) {
|
||||
const start = content.indexOf('${', cursor);
|
||||
if (start < 0) break;
|
||||
const end = content.indexOf('}', start + 2);
|
||||
if (end < 0) throw new BadRequestException('模板变量未闭合');
|
||||
const name = content.slice(start + 2, end);
|
||||
if (!/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(name)) {
|
||||
throw new BadRequestException('模板变量名必须以英文字母开头,仅包含英文字母、数字和下划线,长度1至32位');
|
||||
}
|
||||
if (names.includes(name)) throw new BadRequestException(`模板变量 ${name} 重复`);
|
||||
names.push(name);
|
||||
cursor = end + 1;
|
||||
}
|
||||
if (!supplied) return names.map((name) => ({ name, required: true }));
|
||||
const suppliedNames = supplied.map((item) => item.name?.trim());
|
||||
if (suppliedNames.some((name) => !name || !/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(name))) {
|
||||
throw new BadRequestException('变量配置中包含非法变量名');
|
||||
}
|
||||
if (new Set(suppliedNames).size !== suppliedNames.length) throw new BadRequestException('变量配置中包含重复变量');
|
||||
if (suppliedNames.length !== names.length || suppliedNames.some((name) => !names.includes(name))) {
|
||||
throw new BadRequestException('变量配置必须与模板正文中的占位符完全一致');
|
||||
}
|
||||
return supplied.map((item) => ({ ...item, name: item.name.trim() }));
|
||||
}
|
||||
|
||||
export function normalizeSmsSignature(name: string) {
|
||||
const innerName = name.trim().replace(/^[【\[]+|[】\]]+$/g, '').trim();
|
||||
return innerName ? `【${innerName}】` : '';
|
||||
}
|
||||
|
||||
export function validateCompleteSmsSignature(name: string) {
|
||||
const value = name;
|
||||
if (/[\p{White_Space}\p{Cc}\p{Default_Ignorable_Code_Point}]/u.test(value)) {
|
||||
throw new BadRequestException('短信签名不能包含空格、换行或不可见字符');
|
||||
}
|
||||
const match = value.match(/^【([^【】]+)】$/);
|
||||
if (!match) {
|
||||
throw new BadRequestException('短信签名必须包含完整中文黑括号,例如:【某某科技】');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function startOfToday() {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
export function normalizeApplicationQueuePriority(value?: string): ApplicationQueuePriority {
|
||||
const queuePriority = value ?? 'normal';
|
||||
if (!APPLICATION_QUEUE_PRIORITIES.includes(queuePriority as ApplicationQueuePriority)) {
|
||||
throw new BadRequestException('queuePriority must be normal or priority');
|
||||
}
|
||||
return queuePriority as ApplicationQueuePriority;
|
||||
}
|
||||
|
||||
export function normalizeApplicationInterfaceType(value?: string): ApplicationInterfaceType {
|
||||
const interfaceType = value ?? 'cmpp20';
|
||||
if (!APPLICATION_INTERFACE_TYPES.includes(interfaceType as ApplicationInterfaceType)) {
|
||||
throw new BadRequestException('interfaceType only supports cmpp20; HTTP interface is not available yet');
|
||||
}
|
||||
return interfaceType as ApplicationInterfaceType;
|
||||
}
|
||||
|
||||
export function normalizeCmppAccessNumberConfig(
|
||||
data: Pick<CreateSmsApplicationDto, 'cmppApplicationExtension' | 'cmppAccessNumberFillEnabled' | 'cmppAccessNumberFillPrefix'>,
|
||||
current?: {
|
||||
cmppApplicationExtension?: string | null;
|
||||
cmppAccessNumberFillEnabled?: boolean | null;
|
||||
cmppAccessNumberFillPrefix?: string | null;
|
||||
},
|
||||
) {
|
||||
const applicationExtension = (
|
||||
data.cmppApplicationExtension === undefined
|
||||
? current?.cmppApplicationExtension
|
||||
: data.cmppApplicationExtension
|
||||
)?.trim() || null;
|
||||
const fillEnabled = data.cmppAccessNumberFillEnabled
|
||||
?? current?.cmppAccessNumberFillEnabled
|
||||
?? false;
|
||||
const configuredPrefix = (
|
||||
data.cmppAccessNumberFillPrefix === undefined
|
||||
? current?.cmppAccessNumberFillPrefix
|
||||
: data.cmppAccessNumberFillPrefix
|
||||
)?.trim() || null;
|
||||
|
||||
if (applicationExtension && !/^\d+$/.test(applicationExtension)) {
|
||||
throw new BadRequestException('cmppApplicationExtension must contain digits only');
|
||||
}
|
||||
if (applicationExtension && applicationExtension.length > 21) {
|
||||
throw new BadRequestException('cmppApplicationExtension must not exceed 21 digits');
|
||||
}
|
||||
if (fillEnabled && !applicationExtension) {
|
||||
throw new BadRequestException('cmppApplicationExtension is required when access number filling is enabled');
|
||||
}
|
||||
if (fillEnabled && !configuredPrefix) {
|
||||
throw new BadRequestException('cmppAccessNumberFillPrefix is required when access number filling is enabled');
|
||||
}
|
||||
if (configuredPrefix && !/^\d+$/.test(configuredPrefix)) {
|
||||
throw new BadRequestException('cmppAccessNumberFillPrefix must contain digits only');
|
||||
}
|
||||
|
||||
const fillPrefix = fillEnabled ? configuredPrefix : null;
|
||||
const clientSrcId = applicationExtension
|
||||
? `${fillPrefix ?? ''}${applicationExtension}`
|
||||
: null;
|
||||
if (clientSrcId && clientSrcId.length > 21) {
|
||||
throw new BadRequestException('client CMPP Src_Id must not exceed 21 digits');
|
||||
}
|
||||
return { applicationExtension, fillEnabled, fillPrefix, clientSrcId };
|
||||
}
|
||||
|
||||
export function getPositiveInteger(value: number | undefined, fallback: number, fieldName: string) {
|
||||
if (value === undefined || value === null) {
|
||||
return fallback;
|
||||
}
|
||||
const normalized = Number(value);
|
||||
if (!Number.isInteger(normalized) || normalized <= 0) {
|
||||
throw new BadRequestException(`${fieldName} must be a positive integer`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeApplicationCmppStatus(connections: Array<{ status: string }>, applicationStatus: string) {
|
||||
if (!['active', 'disabling'].includes(applicationStatus)) {
|
||||
return 'inactive';
|
||||
}
|
||||
if (connections.some((connection) => connection.status === 'connected')) {
|
||||
return 'connected';
|
||||
}
|
||||
if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) {
|
||||
return 'degraded';
|
||||
}
|
||||
return 'disconnected';
|
||||
}
|
||||
|
||||
export function getPositiveIntegerEnv(name: string, fallback: number) {
|
||||
const value = Number(process.env[name] ?? fallback);
|
||||
return Number.isInteger(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
export function parseGatewayDate(value?: string) {
|
||||
if (!value) return undefined;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function reportValueParts(value: unknown) {
|
||||
if (isRecord(value) && typeof value.fileObjectId === 'string') {
|
||||
return { fieldValue: typeof value.fileName === 'string' ? value.fileName : undefined, fileObjectId: value.fileObjectId };
|
||||
}
|
||||
return { fieldValue: value === undefined || value === null ? undefined : String(value), fileObjectId: undefined };
|
||||
}
|
||||
|
||||
export function hasReportValue(value: unknown) {
|
||||
if (isRecord(value)) {
|
||||
return Boolean(value.fileObjectId || value.fieldValue || value.value);
|
||||
}
|
||||
return value !== undefined && value !== null && String(value).trim().length > 0;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,215 @@
|
||||
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';
|
||||
import { assertMoneyUnits } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { automaticDeliveryMode } from '../open-api/delivery-mode';
|
||||
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
|
||||
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
|
||||
import { SmsAuditService } from './audit.service';
|
||||
|
||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||
export class SmsTemplateService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly audit: SmsAuditService) {}
|
||||
listTemplates(queryOrTenantId?: string | TemplateListQuery) {
|
||||
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
||||
return this.prisma.smsTemplate.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
|
||||
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
|
||||
name: query.nameKeyword ? { contains: query.nameKeyword } : undefined,
|
||||
content: query.contentKeyword ? { contains: query.contentKeyword } : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ content: { contains: query.keyword } },
|
||||
{ category: { contains: query.keyword } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
...(query.page && query.pageSize ? {
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async listTemplatesPage(query: TemplateListQuery) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||
const where: Prisma.SmsTemplateWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
|
||||
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
|
||||
name: query.nameKeyword ? { contains: query.nameKeyword } : undefined,
|
||||
content: query.contentKeyword ? { contains: query.contentKeyword } : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ content: { contains: query.keyword } },
|
||||
{ category: { contains: query.keyword } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.listTemplates({ ...query, page, pageSize }),
|
||||
this.prisma.smsTemplate.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
listClientTemplates(tenantId: string | undefined, includeHistory = false) {
|
||||
return this.listTemplates({ tenantId, status: includeHistory ? 'all' : 'approved' });
|
||||
}
|
||||
|
||||
async createTemplate(data: CreateSmsTemplateDto, options: CreateSmsTemplateOptions = {}) {
|
||||
const variables = validateAndNormalizeTemplateVariables(data.content, data.variables);
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } });
|
||||
if (!application || application.tenantId !== data.tenantId) {
|
||||
throw new BadRequestException('applicationId does not belong to the template tenant');
|
||||
}
|
||||
await this.validateTemplateSignature(data.signatureId, data.tenantId, data.applicationId, data.content);
|
||||
return this.prisma.smsTemplate.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
signatureId: data.signatureId,
|
||||
name: data.name,
|
||||
content: data.content,
|
||||
category: data.category,
|
||||
auditStatus: options.initialAuditStatus,
|
||||
billingUnits: estimateBillingUnits(data.content),
|
||||
variables: {
|
||||
create: variables.map((variable: TemplateVariableInput) => ({
|
||||
name: variable.name,
|
||||
example: variable.example,
|
||||
required: variable.required ?? true,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updateTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template || (tenantId && template.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
if (data.applicationId) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } });
|
||||
if (!application || application.tenantId !== template.tenantId) {
|
||||
throw new BadRequestException('applicationId does not belong to the template tenant');
|
||||
}
|
||||
}
|
||||
if (data.signatureId !== undefined || data.applicationId !== undefined || data.content !== undefined) {
|
||||
await this.validateTemplateSignature(
|
||||
data.signatureId === undefined ? template.signatureId : data.signatureId,
|
||||
template.tenantId,
|
||||
data.applicationId ?? template.applicationId,
|
||||
data.content ?? template.content,
|
||||
);
|
||||
}
|
||||
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 } });
|
||||
}
|
||||
return tx.smsTemplate.update({
|
||||
where: { id: templateId },
|
||||
data: {
|
||||
applicationId: data.applicationId,
|
||||
signatureId: data.signatureId,
|
||||
name: data.name,
|
||||
content: data.content,
|
||||
category: data.category,
|
||||
auditStatus,
|
||||
rejectReason: auditStatus === 'pending' ? null : undefined,
|
||||
billingUnits: data.content ? estimateBillingUnits(data.content) : undefined,
|
||||
variables: variables ? {
|
||||
create: variables.map((variable) => ({
|
||||
name: variable.name,
|
||||
example: variable.example,
|
||||
required: variable.required ?? true,
|
||||
})),
|
||||
} : undefined,
|
||||
},
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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.audit.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)) {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
await this.validateTemplateSignature(template.signatureId, template.tenantId, template.applicationId, template.content);
|
||||
|
||||
const updated = await this.prisma.smsTemplate.update({
|
||||
where: { id: templateId },
|
||||
data: { auditStatus: 'pending', rejectReason: null },
|
||||
});
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: template.tenantId,
|
||||
targetType: 'sms_template',
|
||||
targetId: templateId,
|
||||
action: 'submit',
|
||||
statusBefore: template.auditStatus,
|
||||
statusAfter: 'pending',
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async validateTemplateSignature(signatureId: string | null | undefined, tenantId: string, applicationId: string, content: string) {
|
||||
if (!signatureId) {
|
||||
throw new BadRequestException('短信模板必须选择短信签名');
|
||||
}
|
||||
const signature = await this.prisma.smsSignature.findUnique({
|
||||
where: { id: signatureId },
|
||||
select: { tenantId: true, applicationId: true, name: true },
|
||||
});
|
||||
if (!signature || signature.tenantId !== tenantId) {
|
||||
throw new BadRequestException('signatureId does not belong to the template tenant');
|
||||
}
|
||||
if (signature.applicationId && signature.applicationId !== applicationId) {
|
||||
throw new BadRequestException('signatureId does not belong to the template application');
|
||||
}
|
||||
const signaturePrefix = normalizeSmsSignature(signature.name);
|
||||
if (!signaturePrefix || !content.startsWith(signaturePrefix)) {
|
||||
throw new BadRequestException(`模板内容必须以所选短信签名 ${signaturePrefix || signature.name} 开头`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user