fix: enforce application limits and signature format

This commit is contained in:
hectorzhao
2026-07-22 16:29:22 +08:00
parent cd085d2712
commit a09036c67b
20 changed files with 479 additions and 79 deletions
@@ -0,0 +1,75 @@
-- Enforce a real per-application daily send quota and make 100,000 the platform default.
UPDATE "SmsApplication"
SET "dailyLimit" = 100000
WHERE "dailyLimit" IS NULL;
ALTER TABLE "SmsApplication"
ALTER COLUMN "dailyLimit" SET DEFAULT 100000,
ALTER COLUMN "dailyLimit" SET NOT NULL;
CREATE TABLE "SmsApplicationDailyUsage" (
"id" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"usageDate" DATE NOT NULL,
"usedCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SmsApplicationDailyUsage_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "SmsApplicationDailyUsage_applicationId_usageDate_key"
ON "SmsApplicationDailyUsage"("applicationId", "usageDate");
CREATE INDEX "SmsApplicationDailyUsage_usageDate_idx"
ON "SmsApplicationDailyUsage"("usageDate");
ALTER TABLE "SmsApplicationDailyUsage"
ADD CONSTRAINT "SmsApplicationDailyUsage_applicationId_fkey"
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- Preserve sends already accepted earlier on the deployment day, so deploying at
-- midday cannot grant an extra full quota. The business day is Asia/Shanghai.
INSERT INTO "SmsApplicationDailyUsage" (
"id", "applicationId", "usageDate", "usedCount", "createdAt", "updatedAt"
)
SELECT
md5(message."applicationId" || ':' || ((CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Shanghai')::date)::text),
message."applicationId",
(CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Shanghai')::date,
COUNT(*)::integer,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "SmsMessageRecord" message
WHERE message."applicationId" IS NOT NULL
AND message."queuedAt" >= ((CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Shanghai')::date::timestamp AT TIME ZONE 'Asia/Shanghai')
AND message."queuedAt" < (((CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Shanghai')::date + 1)::timestamp AT TIME ZONE 'Asia/Shanghai')
GROUP BY message."applicationId";
-- HTTP applications created before the UI default fix retained CMPP delivery modes.
-- Existing enabled webhook capabilities are migrated to the HTTP transport they expose.
UPDATE "SmsApplicationHttpConfig"
SET "receiptDeliveryMode" = 'http'
WHERE "enabled" = TRUE
AND "receiptWebhookEnabled" = TRUE
AND "receiptDeliveryMode" = 'cmpp';
UPDATE "SmsApplicationHttpConfig"
SET "uplinkDeliveryMode" = 'http'
WHERE "enabled" = TRUE
AND "uplinkWebhookEnabled" = TRUE
AND "uplinkDeliveryMode" = 'cmpp';
ALTER TABLE "SmsApplicationHttpConfig"
ALTER COLUMN "sendEnabled" SET DEFAULT TRUE,
ALTER COLUMN "messageQueryEnabled" SET DEFAULT TRUE,
ALTER COLUMN "receiptWebhookEnabled" SET DEFAULT TRUE,
ALTER COLUMN "uplinkWebhookEnabled" SET DEFAULT TRUE,
ALTER COLUMN "uplinkQueryEnabled" SET DEFAULT TRUE,
ALTER COLUMN "credentialSelfServiceEnabled" SET DEFAULT TRUE,
ALTER COLUMN "receiptDeliveryMode" SET DEFAULT 'http',
ALTER COLUMN "uplinkDeliveryMode" SET DEFAULT 'http';
-- Rollback guidance:
-- Drop SmsApplicationDailyUsage and its FK/indexes, restore SmsApplication.dailyLimit
-- to nullable/no-default, and restore HTTP column defaults to FALSE/'cmpp'. Data migrated
-- from cmpp to http is intentionally not auto-reversed because explicit edits after upgrade
-- cannot be distinguished safely.
@@ -0,0 +1,20 @@
-- SMS signature names are canonical business identifiers and must contain
-- exactly one complete pair of Chinese black brackets: 【signature】.
WITH normalized AS (
SELECT
id,
btrim(
regexp_replace(
regexp_replace(btrim(name), '^[【\[]+', ''),
'[】\]]+$',
''
)
) AS inner_name
FROM "SmsSignature"
)
UPDATE "SmsSignature" AS signature
SET name = '' || normalized.inner_name || ''
FROM normalized
WHERE signature.id = normalized.id
AND normalized.inner_name <> ''
AND signature.name IS DISTINCT FROM '' || normalized.inner_name || '';
+24 -9
View File
@@ -366,7 +366,7 @@ model SmsApplication {
interfaceType String @default("cmpp20")
cmppMaxConnections Int @default(1)
cmppWindowSize Int @default(16)
dailyLimit Int?
dailyLimit Int @default(100000)
customerUnitPrice BigInt @default(0)
queuePriority String @default("normal")
maxPhonesPerTask Int @default(1000000)
@@ -399,6 +399,7 @@ model SmsApplication {
openApiRequests OpenApiRequest[]
httpWebhookEndpoints HttpWebhookEndpoint[]
httpWebhookEvents HttpWebhookEvent[]
dailyUsages SmsApplicationDailyUsage[]
@@index([tenantId, status])
}
@@ -419,20 +420,20 @@ model SmsApplicationHttpConfig {
id String @id @default(cuid())
applicationId String @unique
enabled Boolean @default(false)
sendEnabled Boolean @default(false)
messageQueryEnabled Boolean @default(false)
receiptWebhookEnabled Boolean @default(false)
uplinkWebhookEnabled Boolean @default(false)
uplinkQueryEnabled Boolean @default(false)
credentialSelfServiceEnabled Boolean @default(false)
sendEnabled Boolean @default(true)
messageQueryEnabled Boolean @default(true)
receiptWebhookEnabled Boolean @default(true)
uplinkWebhookEnabled Boolean @default(true)
uplinkQueryEnabled Boolean @default(true)
credentialSelfServiceEnabled Boolean @default(true)
qpsLimit Int @default(10)
timestampToleranceSeconds Int @default(300)
maxCredentialCount Int @default(2)
uplinkRetentionDays Int @default(90)
maxQueryRangeDays Int @default(31)
maxPageSize Int @default(100)
receiptDeliveryMode String @default("cmpp")
uplinkDeliveryMode String @default("cmpp")
receiptDeliveryMode String @default("http")
uplinkDeliveryMode String @default("http")
webhookRetryEnabled Boolean @default(true)
webhookMaxAttempts Int @default(7)
webhookTimeoutSeconds Int @default(10)
@@ -445,6 +446,20 @@ model SmsApplicationHttpConfig {
application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade)
}
model SmsApplicationDailyUsage {
id String @id @default(cuid())
applicationId String
usageDate DateTime @db.Date
usedCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade)
@@unique([applicationId, usageDate])
@@index([usageDate])
}
model SmsApplicationHttpIpAllowlist {
id String @id @default(cuid())
applicationId String
+26
View File
@@ -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() {
+37 -25
View File
@@ -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();
+81 -3
View File
@@ -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 {
+27 -5
View File
@@ -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' }),
+18 -6
View File
@@ -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);