feat: add phone frequency controls and modularize codebase
This commit is contained in:
@@ -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