fix: enforce application limits and signature format
This commit is contained in:
@@ -73,6 +73,32 @@ describe('OpenApiService', () => {
|
||||
expect(prisma.httpWebhookEvent.create).toHaveBeenCalled();
|
||||
expect(prisma.httpWebhookDelivery.create).toHaveBeenCalledWith({ data: { eventId: 'event-row-1', endpointId: 'endpoint-1' } });
|
||||
});
|
||||
|
||||
it('defaults a newly enabled HTTP interface to all six capabilities and HTTP webhook delivery', async () => {
|
||||
const prisma = {
|
||||
smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }) },
|
||||
smsApplicationHttpConfig: { upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve(create)) },
|
||||
smsApplicationHttpIpAllowlist: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: jest.fn() },
|
||||
$transaction: jest.fn((operations) => Promise.all(operations)),
|
||||
};
|
||||
const service = new OpenApiService(prisma as never, {} as never);
|
||||
|
||||
await service.updateConfig('app-1', { enabled: true });
|
||||
|
||||
expect(prisma.smsApplicationHttpConfig.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
create: expect.objectContaining({
|
||||
enabled: true,
|
||||
sendEnabled: true,
|
||||
messageQueryEnabled: true,
|
||||
receiptWebhookEnabled: true,
|
||||
uplinkWebhookEnabled: true,
|
||||
uplinkQueryEnabled: true,
|
||||
credentialSelfServiceEnabled: true,
|
||||
receiptDeliveryMode: 'http',
|
||||
uplinkDeliveryMode: 'http',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
function auth() {
|
||||
|
||||
@@ -69,8 +69,8 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
async updateConfig(applicationId: string, input: HttpConfigInput, tenantId?: string) {
|
||||
await this.requireApplication(applicationId, tenantId);
|
||||
const data = normalizeConfig(input);
|
||||
const application = await this.requireApplication(applicationId, tenantId);
|
||||
const data = normalizeConfig(input, application.httpConfig);
|
||||
const ipAllowlist = normalizeIpAllowlist(input.ipAllowlist);
|
||||
const [config] = await this.prisma.$transaction([
|
||||
this.prisma.smsApplicationHttpConfig.upsert({
|
||||
@@ -362,32 +362,44 @@ function normalizeOpenApiFailure(error: unknown) {
|
||||
return { httpStatus: 500, code: 'INTERNAL_ERROR', responseBody: { code: 'INTERNAL_ERROR', message: 'Internal server error' } as Prisma.InputJsonValue };
|
||||
}
|
||||
|
||||
function normalizeConfig(input: HttpConfigInput) {
|
||||
for (const mode of [input.receiptDeliveryMode, input.uplinkDeliveryMode]) {
|
||||
function normalizeConfig(input: HttpConfigInput, existing?: { enabled?: boolean } | null) {
|
||||
const enabling = input.enabled === true && existing?.enabled !== true;
|
||||
const effective = enabling ? {
|
||||
sendEnabled: true,
|
||||
messageQueryEnabled: true,
|
||||
receiptWebhookEnabled: true,
|
||||
uplinkWebhookEnabled: true,
|
||||
uplinkQueryEnabled: true,
|
||||
credentialSelfServiceEnabled: true,
|
||||
receiptDeliveryMode: 'http',
|
||||
uplinkDeliveryMode: 'http',
|
||||
...input,
|
||||
} : input;
|
||||
for (const mode of [effective.receiptDeliveryMode, effective.uplinkDeliveryMode]) {
|
||||
if (mode !== undefined && !DELIVERY_MODES.includes(mode as typeof DELIVERY_MODES[number])) throw new BadRequestException('投递模式仅支持 cmpp、http、both、none');
|
||||
}
|
||||
return {
|
||||
enabled: input.enabled,
|
||||
sendEnabled: input.sendEnabled,
|
||||
messageQueryEnabled: input.messageQueryEnabled,
|
||||
receiptWebhookEnabled: input.receiptWebhookEnabled,
|
||||
uplinkWebhookEnabled: input.uplinkWebhookEnabled,
|
||||
uplinkQueryEnabled: input.uplinkQueryEnabled,
|
||||
credentialSelfServiceEnabled: input.credentialSelfServiceEnabled,
|
||||
qpsLimit: bounded(input.qpsLimit, 1, 1000, 'QPS'),
|
||||
timestampToleranceSeconds: bounded(input.timestampToleranceSeconds, 60, 900, '时间戳容差'),
|
||||
maxCredentialCount: bounded(input.maxCredentialCount, 1, 10, '凭据数'),
|
||||
uplinkRetentionDays: bounded(input.uplinkRetentionDays, 1, 365, '上行保留天数'),
|
||||
maxQueryRangeDays: bounded(input.maxQueryRangeDays, 1, 90, '查询跨度'),
|
||||
maxPageSize: bounded(input.maxPageSize, 10, 500, '分页上限'),
|
||||
receiptDeliveryMode: input.receiptDeliveryMode,
|
||||
uplinkDeliveryMode: input.uplinkDeliveryMode,
|
||||
webhookRetryEnabled: input.webhookRetryEnabled,
|
||||
webhookMaxAttempts: bounded(input.webhookMaxAttempts, 1, 7, '回调重试次数'),
|
||||
webhookTimeoutSeconds: bounded(input.webhookTimeoutSeconds, 1, 30, '回调超时'),
|
||||
requireHttps: input.requireHttps,
|
||||
allowClientManualRetry: input.allowClientManualRetry,
|
||||
allowClientTest: input.allowClientTest,
|
||||
enabled: effective.enabled,
|
||||
sendEnabled: effective.sendEnabled,
|
||||
messageQueryEnabled: effective.messageQueryEnabled,
|
||||
receiptWebhookEnabled: effective.receiptWebhookEnabled,
|
||||
uplinkWebhookEnabled: effective.uplinkWebhookEnabled,
|
||||
uplinkQueryEnabled: effective.uplinkQueryEnabled,
|
||||
credentialSelfServiceEnabled: effective.credentialSelfServiceEnabled,
|
||||
qpsLimit: bounded(effective.qpsLimit, 1, 1000, 'QPS'),
|
||||
timestampToleranceSeconds: bounded(effective.timestampToleranceSeconds, 60, 900, '时间戳容差'),
|
||||
maxCredentialCount: bounded(effective.maxCredentialCount, 1, 10, '凭据数'),
|
||||
uplinkRetentionDays: bounded(effective.uplinkRetentionDays, 1, 365, '上行保留天数'),
|
||||
maxQueryRangeDays: bounded(effective.maxQueryRangeDays, 1, 90, '查询跨度'),
|
||||
maxPageSize: bounded(effective.maxPageSize, 10, 500, '分页上限'),
|
||||
receiptDeliveryMode: effective.receiptDeliveryMode,
|
||||
uplinkDeliveryMode: effective.uplinkDeliveryMode,
|
||||
webhookRetryEnabled: effective.webhookRetryEnabled,
|
||||
webhookMaxAttempts: bounded(effective.webhookMaxAttempts, 1, 7, '回调重试次数'),
|
||||
webhookTimeoutSeconds: bounded(effective.webhookTimeoutSeconds, 1, 30, '回调超时'),
|
||||
requireHttps: effective.requireHttps,
|
||||
allowClientManualRetry: effective.allowClientManualRetry,
|
||||
allowClientTest: effective.allowClientTest,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -300,6 +300,7 @@ function createPrismaMock() {
|
||||
globalBlacklist: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ dailyLimit: 100000, usedCount: 2 }]),
|
||||
$transaction: jest.fn((operations) => Promise.all(operations)),
|
||||
};
|
||||
}
|
||||
@@ -364,6 +365,23 @@ describe('SendChainService', () => {
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
it('rejects the whole batch atomically when the application daily send limit would be exceeded', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.$queryRaw.mockResolvedValueOnce([{ dailyLimit: 1, usedCount: null }]);
|
||||
|
||||
await expect(service.createBatchTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001', '13800000002'],
|
||||
})).rejects.toThrow('应用当日发送上限1条');
|
||||
|
||||
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.createMany).not.toHaveBeenCalled();
|
||||
expect(billing.freeze).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recognizes an approved template for public HTTP content and reads back the api task', async () => {
|
||||
const { service, prisma, riskReview } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
@@ -832,6 +850,31 @@ describe('SendChainService', () => {
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects every destination in one CMPP Submit with auditable receipts when the daily limit is exceeded', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.$queryRaw.mockResolvedValueOnce([{ dailyLimit: 1, usedCount: null }]);
|
||||
let taskIndex = 0;
|
||||
prisma.smsBatchTask.create.mockImplementation(({ data }) => Promise.resolve({ id: `task-${++taskIndex}`, ...data }));
|
||||
let messageIndex = 0;
|
||||
prisma.smsMessageRecord.create.mockImplementation(({ data }) => Promise.resolve({ id: `record-${++messageIndex}`, ...data }));
|
||||
|
||||
const result = await service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumbers: ['13800000001', '13900000002'],
|
||||
content: 'hello',
|
||||
sequenceId: 88,
|
||||
remoteIp: '127.0.0.1',
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ accepted: true, phoneCount: 2 }));
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ errorCode: 'DAILY_LIMIT', receiptStatus: 'undelivered' }),
|
||||
});
|
||||
expect(billing.freeze).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a multi-destination CMPP Submit before persistence when any destination is invalid', async () => {
|
||||
const { service, prisma } = createService();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, forwardRef, Inject, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit, Optional } from '@nestjs/common';
|
||||
import { BadRequestException, forwardRef, HttpException, HttpStatus, Inject, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit, Optional } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
@@ -366,6 +366,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
throw new BadRequestException('企业账户余额不足');
|
||||
}
|
||||
}
|
||||
if (data.applicationId && risk.status !== 'rejected') {
|
||||
await this.reserveDailySendQuota(data.applicationId, phones.length);
|
||||
}
|
||||
const task = await this.prisma.smsBatchTask.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
@@ -2020,6 +2023,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
throw new BadRequestException('CMPP submit phone number is invalid');
|
||||
}
|
||||
|
||||
const application = await this.findInboundApplication(data.account);
|
||||
if (!application) {
|
||||
throw new BadRequestException('CMPP account is invalid');
|
||||
}
|
||||
const dailyQuota = await this.tryReserveDailySendQuota(application.id, phoneNumbers.length);
|
||||
const dailyLimitFailure = dailyQuota.reserved
|
||||
? undefined
|
||||
: `应用当日发送上限${dailyQuota.dailyLimit}条,本次${phoneNumbers.length}条超出剩余配额`;
|
||||
|
||||
const submitGroupMessageId = `MSG-${randomUUID()}`;
|
||||
const submissions = phoneNumbers.map((phoneNumber, index) => ({
|
||||
phoneNumber,
|
||||
@@ -2033,7 +2045,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
...data,
|
||||
phoneNumber: submission.phoneNumber,
|
||||
phoneNumbers: undefined,
|
||||
}, submission.messageId, submitGroupMessageId))));
|
||||
}, submission.messageId, submitGroupMessageId, dailyLimitFailure))));
|
||||
}
|
||||
const first = results[0];
|
||||
return {
|
||||
@@ -2053,6 +2065,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
data: GatewayInboundSubmitDto & { phoneNumber: string },
|
||||
messageId: string,
|
||||
submitGroupMessageId: string,
|
||||
dailyLimitFailure?: string,
|
||||
) {
|
||||
const application = await this.findInboundApplication(data.account);
|
||||
if (!application) {
|
||||
@@ -2190,7 +2203,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
await this.enqueueBatchTask(task.id);
|
||||
};
|
||||
if (application.status !== 'active' || application.tenant.status !== 'active') {
|
||||
if (dailyLimitFailure) {
|
||||
await reject('DAILY_LIMIT', dailyLimitFailure);
|
||||
} else if (application.status !== 'active' || application.tenant.status !== 'active') {
|
||||
await reject('ACCOUNT', '企业或短信应用已停用');
|
||||
} else if (!application.interfaceEnabled) {
|
||||
await reject('INTERFACE', '短信应用 CMPP 接口已停用');
|
||||
@@ -2908,6 +2923,58 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private async reserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
const result = await this.tryReserveDailySendQuota(applicationId, requestedCount);
|
||||
if (!result.reserved) {
|
||||
throw new HttpException({
|
||||
code: 'DAILY_SEND_LIMIT_EXCEEDED',
|
||||
message: `应用当日发送上限${result.dailyLimit}条,本次${requestedCount}条超出剩余配额`,
|
||||
dailyLimit: result.dailyLimit,
|
||||
requestedCount,
|
||||
}, HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async tryReserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
if (!Number.isInteger(requestedCount) || requestedCount <= 0) {
|
||||
throw new BadRequestException('发送号码数量必须为正整数');
|
||||
}
|
||||
const usageDate = shanghaiDateKey();
|
||||
const reservationId = randomUUID();
|
||||
const rows = await this.prisma.$queryRaw<Array<{ dailyLimit: number; usedCount: number | null }>>(Prisma.sql`
|
||||
WITH application_limit AS (
|
||||
SELECT id, COALESCE("dailyLimit", 100000)::integer AS "dailyLimit"
|
||||
FROM "SmsApplication"
|
||||
WHERE id = ${applicationId}
|
||||
), reservation AS (
|
||||
INSERT INTO "SmsApplicationDailyUsage" (
|
||||
id, "applicationId", "usageDate", "usedCount", "createdAt", "updatedAt"
|
||||
)
|
||||
SELECT ${reservationId}, id, ${usageDate}::date, ${requestedCount}, NOW(), NOW()
|
||||
FROM application_limit
|
||||
WHERE ${requestedCount} <= "dailyLimit"
|
||||
ON CONFLICT ("applicationId", "usageDate") DO UPDATE
|
||||
SET "usedCount" = "SmsApplicationDailyUsage"."usedCount" + EXCLUDED."usedCount",
|
||||
"updatedAt" = NOW()
|
||||
WHERE "SmsApplicationDailyUsage"."usedCount" + EXCLUDED."usedCount"
|
||||
<= (SELECT "dailyLimit" FROM application_limit)
|
||||
RETURNING "usedCount"
|
||||
)
|
||||
SELECT application_limit."dailyLimit", reservation."usedCount"
|
||||
FROM application_limit
|
||||
LEFT JOIN reservation ON TRUE
|
||||
`);
|
||||
if (rows.length === 0) {
|
||||
throw new NotFoundException('短信应用不存在');
|
||||
}
|
||||
return {
|
||||
dailyLimit: Number(rows[0].dailyLimit),
|
||||
usedCount: rows[0].usedCount == null ? null : Number(rows[0].usedCount),
|
||||
reserved: rows[0].usedCount != null,
|
||||
};
|
||||
}
|
||||
|
||||
private async chargeAcceptedMessage(message: {
|
||||
tenantId: string;
|
||||
applicationId?: string | null;
|
||||
@@ -3697,6 +3764,17 @@ function positiveInteger(value: string | undefined, fallback: number) {
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function shanghaiDateKey(now = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(now);
|
||||
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||
return `${values.year}-${values.month}-${values.day}`;
|
||||
}
|
||||
|
||||
function bullmqConnection() {
|
||||
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
|
||||
return {
|
||||
|
||||
@@ -323,6 +323,7 @@ describe('SmsConfigService', () => {
|
||||
interfaceEnabled: false,
|
||||
interfaceType: 'cmpp20',
|
||||
queuePriority: 'priority',
|
||||
dailyLimit: 100000,
|
||||
downstreamReceiptRetryEnabled: true,
|
||||
downstreamUplinkRetryEnabled: true,
|
||||
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
|
||||
@@ -955,20 +956,41 @@ describe('SmsConfigService', () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await service.createSignature({ tenantId: 'tenant-1', name: '运营新建签名' }, { initialAuditStatus: 'approved' });
|
||||
await service.createSignature({ tenantId: 'tenant-1', name: '【运营新建签名】' }, { initialAuditStatus: 'approved' });
|
||||
|
||||
expect(prisma.smsSignature.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ auditStatus: 'approved', name: '运营新建签名' }),
|
||||
data: expect.objectContaining({ auditStatus: 'approved', name: '【运营新建签名】' }),
|
||||
});
|
||||
expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'admin_create_approved', statusAfter: 'approved' }) });
|
||||
});
|
||||
|
||||
it.each(['未带括号', '[英文括号]', '【【重复括号】】', '【 】'])(
|
||||
'rejects a signature name without exactly one complete Chinese black bracket pair: %s',
|
||||
async (name) => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.createSignature({ tenantId: 'tenant-1', name }))
|
||||
.rejects.toThrow('短信签名必须包含完整中文黑括号,例如:【某某科技】');
|
||||
expect(prisma.smsSignature.create).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects editing a signature to a name without complete Chinese black brackets', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.updateSignature('sig-1', { name: '编辑无括号' }))
|
||||
.rejects.toThrow('短信签名必须包含完整中文黑括号,例如:【某某科技】');
|
||||
expect(prisma.smsSignature.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates enterprise signature drainage info through the admin API path', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.updateSignature('sig-1', {
|
||||
name: '签名B',
|
||||
name: '【签名B】',
|
||||
auditStatus: 'approved',
|
||||
drainageInfo: {
|
||||
carrierStatus: { mobile: 'approved', unicom: 'approved', telecom: 'approved' },
|
||||
@@ -976,14 +998,14 @@ describe('SmsConfigService', () => {
|
||||
},
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
id: 'sig-1',
|
||||
name: '签名B',
|
||||
name: '【签名B】',
|
||||
auditStatus: 'approved',
|
||||
}));
|
||||
|
||||
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
|
||||
where: { id: 'sig-1' },
|
||||
data: expect.objectContaining({
|
||||
name: '签名B',
|
||||
name: '【签名B】',
|
||||
auditStatus: 'approved',
|
||||
drainageInfo: expect.objectContaining({
|
||||
carrierStatus: expect.objectContaining({ mobile: 'approved' }),
|
||||
|
||||
@@ -385,7 +385,7 @@ export class SmsConfigService {
|
||||
interfaceType,
|
||||
cmppMaxConnections: getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'),
|
||||
cmppWindowSize: getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'),
|
||||
dailyLimit: data.dailyLimit,
|
||||
dailyLimit: getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'),
|
||||
customerUnitPrice: data.customerUnitPrice ?? 0,
|
||||
queuePriority,
|
||||
maxPhonesPerTask: data.maxPhonesPerTask ?? 1000000,
|
||||
@@ -452,7 +452,7 @@ export class SmsConfigService {
|
||||
interfaceType,
|
||||
cmppMaxConnections: data.cmppMaxConnections === undefined ? undefined : getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'),
|
||||
cmppWindowSize: data.cmppWindowSize === undefined ? undefined : getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'),
|
||||
dailyLimit: data.dailyLimit,
|
||||
dailyLimit: data.dailyLimit === undefined ? undefined : getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'),
|
||||
customerUnitPrice: data.customerUnitPrice,
|
||||
queuePriority,
|
||||
maxPhonesPerTask: data.maxPhonesPerTask,
|
||||
@@ -799,6 +799,7 @@ export class SmsConfigService {
|
||||
}));
|
||||
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');
|
||||
@@ -894,7 +895,7 @@ export class SmsConfigService {
|
||||
id: signature.id,
|
||||
tenantId: signature.tenantId,
|
||||
applicationId: signature.applicationId,
|
||||
name: signature.name,
|
||||
name: normalizeSmsSignature(signature.name),
|
||||
purpose: signature.purpose,
|
||||
auditStatus: signature.auditStatus,
|
||||
reportStatus: signature.reportStatus,
|
||||
@@ -977,11 +978,12 @@ export class SmsConfigService {
|
||||
async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) {
|
||||
await this.validateSignatureReportValues(data.applicationId, data.drainageInfo);
|
||||
const drainageInfo = await this.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: data.name,
|
||||
name,
|
||||
purpose: data.purpose,
|
||||
auditStatus: options.initialAuditStatus,
|
||||
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
||||
@@ -1011,8 +1013,9 @@ export class SmsConfigService {
|
||||
const drainageInfo = data.drainageInfo
|
||||
? await this.withReportRequirementSnapshot(applicationId, data.drainageInfo)
|
||||
: undefined;
|
||||
const name = data.name === undefined ? undefined : validateCompleteSmsSignature(data.name);
|
||||
const materialChanged = (data.applicationId !== undefined && data.applicationId !== signature.applicationId)
|
||||
|| (data.name !== undefined && normalizeSmsSignature(data.name) !== normalizeSmsSignature(signature.name))
|
||||
|| (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;
|
||||
@@ -1020,7 +1023,7 @@ export class SmsConfigService {
|
||||
where: { id: signatureId },
|
||||
data: {
|
||||
applicationId: data.applicationId,
|
||||
name: data.name,
|
||||
name,
|
||||
purpose: data.purpose,
|
||||
auditStatus,
|
||||
rejectReason: auditStatus === 'pending' ? null : undefined,
|
||||
@@ -1728,6 +1731,15 @@ function normalizeSmsSignature(name: string) {
|
||||
return innerName ? `【${innerName}】` : '';
|
||||
}
|
||||
|
||||
function validateCompleteSmsSignature(name: string) {
|
||||
const value = name.trim();
|
||||
const match = value.match(/^【([^【】]+)】$/);
|
||||
if (!match || match[1] !== match[1].trim()) {
|
||||
throw new BadRequestException('短信签名必须包含完整中文黑括号,例如:【某某科技】');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function startOfToday() {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
|
||||
Reference in New Issue
Block a user