Files
lislgosms/api/src/sms-config/application-config.service.ts
T

514 lines
24 KiB
TypeScript

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');
}
}