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") interfaceType String @default("cmpp20")
cmppMaxConnections Int @default(1) cmppMaxConnections Int @default(1)
cmppWindowSize Int @default(16) cmppWindowSize Int @default(16)
dailyLimit Int? dailyLimit Int @default(100000)
customerUnitPrice BigInt @default(0) customerUnitPrice BigInt @default(0)
queuePriority String @default("normal") queuePriority String @default("normal")
maxPhonesPerTask Int @default(1000000) maxPhonesPerTask Int @default(1000000)
@@ -399,6 +399,7 @@ model SmsApplication {
openApiRequests OpenApiRequest[] openApiRequests OpenApiRequest[]
httpWebhookEndpoints HttpWebhookEndpoint[] httpWebhookEndpoints HttpWebhookEndpoint[]
httpWebhookEvents HttpWebhookEvent[] httpWebhookEvents HttpWebhookEvent[]
dailyUsages SmsApplicationDailyUsage[]
@@index([tenantId, status]) @@index([tenantId, status])
} }
@@ -419,20 +420,20 @@ model SmsApplicationHttpConfig {
id String @id @default(cuid()) id String @id @default(cuid())
applicationId String @unique applicationId String @unique
enabled Boolean @default(false) enabled Boolean @default(false)
sendEnabled Boolean @default(false) sendEnabled Boolean @default(true)
messageQueryEnabled Boolean @default(false) messageQueryEnabled Boolean @default(true)
receiptWebhookEnabled Boolean @default(false) receiptWebhookEnabled Boolean @default(true)
uplinkWebhookEnabled Boolean @default(false) uplinkWebhookEnabled Boolean @default(true)
uplinkQueryEnabled Boolean @default(false) uplinkQueryEnabled Boolean @default(true)
credentialSelfServiceEnabled Boolean @default(false) credentialSelfServiceEnabled Boolean @default(true)
qpsLimit Int @default(10) qpsLimit Int @default(10)
timestampToleranceSeconds Int @default(300) timestampToleranceSeconds Int @default(300)
maxCredentialCount Int @default(2) maxCredentialCount Int @default(2)
uplinkRetentionDays Int @default(90) uplinkRetentionDays Int @default(90)
maxQueryRangeDays Int @default(31) maxQueryRangeDays Int @default(31)
maxPageSize Int @default(100) maxPageSize Int @default(100)
receiptDeliveryMode String @default("cmpp") receiptDeliveryMode String @default("http")
uplinkDeliveryMode String @default("cmpp") uplinkDeliveryMode String @default("http")
webhookRetryEnabled Boolean @default(true) webhookRetryEnabled Boolean @default(true)
webhookMaxAttempts Int @default(7) webhookMaxAttempts Int @default(7)
webhookTimeoutSeconds Int @default(10) webhookTimeoutSeconds Int @default(10)
@@ -445,6 +446,20 @@ model SmsApplicationHttpConfig {
application SmsApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) 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 { model SmsApplicationHttpIpAllowlist {
id String @id @default(cuid()) id String @id @default(cuid())
applicationId String applicationId String
+26
View File
@@ -73,6 +73,32 @@ describe('OpenApiService', () => {
expect(prisma.httpWebhookEvent.create).toHaveBeenCalled(); expect(prisma.httpWebhookEvent.create).toHaveBeenCalled();
expect(prisma.httpWebhookDelivery.create).toHaveBeenCalledWith({ data: { eventId: 'event-row-1', endpointId: 'endpoint-1' } }); 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() { function auth() {
+37 -25
View File
@@ -69,8 +69,8 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
} }
async updateConfig(applicationId: string, input: HttpConfigInput, tenantId?: string) { async updateConfig(applicationId: string, input: HttpConfigInput, tenantId?: string) {
await this.requireApplication(applicationId, tenantId); const application = await this.requireApplication(applicationId, tenantId);
const data = normalizeConfig(input); const data = normalizeConfig(input, application.httpConfig);
const ipAllowlist = normalizeIpAllowlist(input.ipAllowlist); const ipAllowlist = normalizeIpAllowlist(input.ipAllowlist);
const [config] = await this.prisma.$transaction([ const [config] = await this.prisma.$transaction([
this.prisma.smsApplicationHttpConfig.upsert({ 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 }; return { httpStatus: 500, code: 'INTERNAL_ERROR', responseBody: { code: 'INTERNAL_ERROR', message: 'Internal server error' } as Prisma.InputJsonValue };
} }
function normalizeConfig(input: HttpConfigInput) { function normalizeConfig(input: HttpConfigInput, existing?: { enabled?: boolean } | null) {
for (const mode of [input.receiptDeliveryMode, input.uplinkDeliveryMode]) { 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'); if (mode !== undefined && !DELIVERY_MODES.includes(mode as typeof DELIVERY_MODES[number])) throw new BadRequestException('投递模式仅支持 cmpp、http、both、none');
} }
return { return {
enabled: input.enabled, enabled: effective.enabled,
sendEnabled: input.sendEnabled, sendEnabled: effective.sendEnabled,
messageQueryEnabled: input.messageQueryEnabled, messageQueryEnabled: effective.messageQueryEnabled,
receiptWebhookEnabled: input.receiptWebhookEnabled, receiptWebhookEnabled: effective.receiptWebhookEnabled,
uplinkWebhookEnabled: input.uplinkWebhookEnabled, uplinkWebhookEnabled: effective.uplinkWebhookEnabled,
uplinkQueryEnabled: input.uplinkQueryEnabled, uplinkQueryEnabled: effective.uplinkQueryEnabled,
credentialSelfServiceEnabled: input.credentialSelfServiceEnabled, credentialSelfServiceEnabled: effective.credentialSelfServiceEnabled,
qpsLimit: bounded(input.qpsLimit, 1, 1000, 'QPS'), qpsLimit: bounded(effective.qpsLimit, 1, 1000, 'QPS'),
timestampToleranceSeconds: bounded(input.timestampToleranceSeconds, 60, 900, '时间戳容差'), timestampToleranceSeconds: bounded(effective.timestampToleranceSeconds, 60, 900, '时间戳容差'),
maxCredentialCount: bounded(input.maxCredentialCount, 1, 10, '凭据数'), maxCredentialCount: bounded(effective.maxCredentialCount, 1, 10, '凭据数'),
uplinkRetentionDays: bounded(input.uplinkRetentionDays, 1, 365, '上行保留天数'), uplinkRetentionDays: bounded(effective.uplinkRetentionDays, 1, 365, '上行保留天数'),
maxQueryRangeDays: bounded(input.maxQueryRangeDays, 1, 90, '查询跨度'), maxQueryRangeDays: bounded(effective.maxQueryRangeDays, 1, 90, '查询跨度'),
maxPageSize: bounded(input.maxPageSize, 10, 500, '分页上限'), maxPageSize: bounded(effective.maxPageSize, 10, 500, '分页上限'),
receiptDeliveryMode: input.receiptDeliveryMode, receiptDeliveryMode: effective.receiptDeliveryMode,
uplinkDeliveryMode: input.uplinkDeliveryMode, uplinkDeliveryMode: effective.uplinkDeliveryMode,
webhookRetryEnabled: input.webhookRetryEnabled, webhookRetryEnabled: effective.webhookRetryEnabled,
webhookMaxAttempts: bounded(input.webhookMaxAttempts, 1, 7, '回调重试次数'), webhookMaxAttempts: bounded(effective.webhookMaxAttempts, 1, 7, '回调重试次数'),
webhookTimeoutSeconds: bounded(input.webhookTimeoutSeconds, 1, 30, '回调超时'), webhookTimeoutSeconds: bounded(effective.webhookTimeoutSeconds, 1, 30, '回调超时'),
requireHttps: input.requireHttps, requireHttps: effective.requireHttps,
allowClientManualRetry: input.allowClientManualRetry, allowClientManualRetry: effective.allowClientManualRetry,
allowClientTest: input.allowClientTest, allowClientTest: effective.allowClientTest,
}; };
} }
@@ -300,6 +300,7 @@ function createPrismaMock() {
globalBlacklist: { globalBlacklist: {
findMany: jest.fn().mockResolvedValue([]), findMany: jest.fn().mockResolvedValue([]),
}, },
$queryRaw: jest.fn().mockResolvedValue([{ dailyLimit: 100000, usedCount: 2 }]),
$transaction: jest.fn((operations) => Promise.all(operations)), $transaction: jest.fn((operations) => Promise.all(operations)),
}; };
} }
@@ -364,6 +365,23 @@ describe('SendChainService', () => {
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1'); 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 () => { it('recognizes an approved template for public HTTP content and reads back the api task', async () => {
const { service, prisma, riskReview } = createService(); const { service, prisma, riskReview } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
@@ -832,6 +850,31 @@ describe('SendChainService', () => {
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2); 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 () => { it('rejects a multi-destination CMPP Submit before persistence when any destination is invalid', async () => {
const { service, prisma } = createService(); 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 { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq'; import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis'; import IORedis from 'ioredis';
@@ -366,6 +366,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
throw new BadRequestException('企业账户余额不足'); throw new BadRequestException('企业账户余额不足');
} }
} }
if (data.applicationId && risk.status !== 'rejected') {
await this.reserveDailySendQuota(data.applicationId, phones.length);
}
const task = await this.prisma.smsBatchTask.create({ const task = await this.prisma.smsBatchTask.create({
data: { data: {
tenantId: data.tenantId, tenantId: data.tenantId,
@@ -2020,6 +2023,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
throw new BadRequestException('CMPP submit phone number is invalid'); 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 submitGroupMessageId = `MSG-${randomUUID()}`;
const submissions = phoneNumbers.map((phoneNumber, index) => ({ const submissions = phoneNumbers.map((phoneNumber, index) => ({
phoneNumber, phoneNumber,
@@ -2033,7 +2045,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
...data, ...data,
phoneNumber: submission.phoneNumber, phoneNumber: submission.phoneNumber,
phoneNumbers: undefined, phoneNumbers: undefined,
}, submission.messageId, submitGroupMessageId)))); }, submission.messageId, submitGroupMessageId, dailyLimitFailure))));
} }
const first = results[0]; const first = results[0];
return { return {
@@ -2053,6 +2065,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
data: GatewayInboundSubmitDto & { phoneNumber: string }, data: GatewayInboundSubmitDto & { phoneNumber: string },
messageId: string, messageId: string,
submitGroupMessageId: string, submitGroupMessageId: string,
dailyLimitFailure?: string,
) { ) {
const application = await this.findInboundApplication(data.account); const application = await this.findInboundApplication(data.account);
if (!application) { if (!application) {
@@ -2190,7 +2203,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}); });
await this.enqueueBatchTask(task.id); 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', '企业或短信应用已停用'); await reject('ACCOUNT', '企业或短信应用已停用');
} else if (!application.interfaceEnabled) { } else if (!application.interfaceEnabled) {
await reject('INTERFACE', '短信应用 CMPP 接口已停用'); 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: { private async chargeAcceptedMessage(message: {
tenantId: string; tenantId: string;
applicationId?: string | null; applicationId?: string | null;
@@ -3697,6 +3764,17 @@ function positiveInteger(value: string | undefined, fallback: number) {
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; 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() { function bullmqConnection() {
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379'); const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
return { return {
+27 -5
View File
@@ -323,6 +323,7 @@ describe('SmsConfigService', () => {
interfaceEnabled: false, interfaceEnabled: false,
interfaceType: 'cmpp20', interfaceType: 'cmpp20',
queuePriority: 'priority', queuePriority: 'priority',
dailyLimit: 100000,
downstreamReceiptRetryEnabled: true, downstreamReceiptRetryEnabled: true,
downstreamUplinkRetryEnabled: true, downstreamUplinkRetryEnabled: true,
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] }, ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
@@ -955,20 +956,41 @@ describe('SmsConfigService', () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never); 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({ 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' }) }); 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 () => { it('updates enterprise signature drainage info through the admin API path', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never); const service = new SmsConfigService(prisma as never);
await expect(service.updateSignature('sig-1', { await expect(service.updateSignature('sig-1', {
name: '签名B', name: '签名B',
auditStatus: 'approved', auditStatus: 'approved',
drainageInfo: { drainageInfo: {
carrierStatus: { mobile: 'approved', unicom: 'approved', telecom: 'approved' }, carrierStatus: { mobile: 'approved', unicom: 'approved', telecom: 'approved' },
@@ -976,14 +998,14 @@ describe('SmsConfigService', () => {
}, },
})).resolves.toEqual(expect.objectContaining({ })).resolves.toEqual(expect.objectContaining({
id: 'sig-1', id: 'sig-1',
name: '签名B', name: '签名B',
auditStatus: 'approved', auditStatus: 'approved',
})); }));
expect(prisma.smsSignature.update).toHaveBeenCalledWith({ expect(prisma.smsSignature.update).toHaveBeenCalledWith({
where: { id: 'sig-1' }, where: { id: 'sig-1' },
data: expect.objectContaining({ data: expect.objectContaining({
name: '签名B', name: '签名B',
auditStatus: 'approved', auditStatus: 'approved',
drainageInfo: expect.objectContaining({ drainageInfo: expect.objectContaining({
carrierStatus: expect.objectContaining({ mobile: 'approved' }), carrierStatus: expect.objectContaining({ mobile: 'approved' }),
+18 -6
View File
@@ -385,7 +385,7 @@ export class SmsConfigService {
interfaceType, interfaceType,
cmppMaxConnections: getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'), cmppMaxConnections: getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'),
cmppWindowSize: getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'), cmppWindowSize: getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'),
dailyLimit: data.dailyLimit, dailyLimit: getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'),
customerUnitPrice: data.customerUnitPrice ?? 0, customerUnitPrice: data.customerUnitPrice ?? 0,
queuePriority, queuePriority,
maxPhonesPerTask: data.maxPhonesPerTask ?? 1000000, maxPhonesPerTask: data.maxPhonesPerTask ?? 1000000,
@@ -452,7 +452,7 @@ export class SmsConfigService {
interfaceType, interfaceType,
cmppMaxConnections: data.cmppMaxConnections === undefined ? undefined : getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'), cmppMaxConnections: data.cmppMaxConnections === undefined ? undefined : getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'),
cmppWindowSize: data.cmppWindowSize === undefined ? undefined : getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'), 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, customerUnitPrice: data.customerUnitPrice,
queuePriority, queuePriority,
maxPhonesPerTask: data.maxPhonesPerTask, maxPhonesPerTask: data.maxPhonesPerTask,
@@ -799,6 +799,7 @@ export class SmsConfigService {
})); }));
return { return {
...signature, ...signature,
name: normalizeSmsSignature(signature.name),
drainageInfo: { ...legacyPayload, links: drainageLinks }, drainageInfo: { ...legacyPayload, links: drainageLinks },
reportTargets: (() => { 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 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, id: signature.id,
tenantId: signature.tenantId, tenantId: signature.tenantId,
applicationId: signature.applicationId, applicationId: signature.applicationId,
name: signature.name, name: normalizeSmsSignature(signature.name),
purpose: signature.purpose, purpose: signature.purpose,
auditStatus: signature.auditStatus, auditStatus: signature.auditStatus,
reportStatus: signature.reportStatus, reportStatus: signature.reportStatus,
@@ -977,11 +978,12 @@ export class SmsConfigService {
async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) { async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) {
await this.validateSignatureReportValues(data.applicationId, data.drainageInfo); await this.validateSignatureReportValues(data.applicationId, data.drainageInfo);
const drainageInfo = await this.withReportRequirementSnapshot(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({ const signature = await this.prisma.smsSignature.create({
data: { data: {
tenantId: data.tenantId, tenantId: data.tenantId,
applicationId: data.applicationId, applicationId: data.applicationId,
name: data.name, name,
purpose: data.purpose, purpose: data.purpose,
auditStatus: options.initialAuditStatus, auditStatus: options.initialAuditStatus,
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
@@ -1011,8 +1013,9 @@ export class SmsConfigService {
const drainageInfo = data.drainageInfo const drainageInfo = data.drainageInfo
? await this.withReportRequirementSnapshot(applicationId, data.drainageInfo) ? await this.withReportRequirementSnapshot(applicationId, data.drainageInfo)
: undefined; : undefined;
const name = data.name === undefined ? undefined : validateCompleteSmsSignature(data.name);
const materialChanged = (data.applicationId !== undefined && data.applicationId !== signature.applicationId) 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.purpose !== undefined && data.purpose !== signature.purpose)
|| (data.drainageInfo !== undefined && JSON.stringify(data.drainageInfo) !== JSON.stringify(signature.drainageInfo ?? null)); || (data.drainageInfo !== undefined && JSON.stringify(data.drainageInfo) !== JSON.stringify(signature.drainageInfo ?? null));
const auditStatus = materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus; const auditStatus = materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus;
@@ -1020,7 +1023,7 @@ export class SmsConfigService {
where: { id: signatureId }, where: { id: signatureId },
data: { data: {
applicationId: data.applicationId, applicationId: data.applicationId,
name: data.name, name,
purpose: data.purpose, purpose: data.purpose,
auditStatus, auditStatus,
rejectReason: auditStatus === 'pending' ? null : undefined, rejectReason: auditStatus === 'pending' ? null : undefined,
@@ -1728,6 +1731,15 @@ function normalizeSmsSignature(name: string) {
return innerName ? `${innerName}` : ''; 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() { function startOfToday() {
const date = new Date(); const date = new Date();
date.setHours(0, 0, 0, 0); date.setHours(0, 0, 0, 0);
+1 -1
View File
@@ -125,7 +125,7 @@
- 运营端签名页恢复短信/彩信 Tab,其中彩信签名显示待开发空态,不展示演示数据。 - 运营端签名页恢复短信/彩信 Tab,其中彩信签名显示待开发空态,不展示演示数据。
- 短信签名列表使用真实 `GET /api/admin/enterprise-signatures`,返回企业、应用、材料和 `drainageInfo` - 短信签名列表使用真实 `GET /api/admin/enterprise-signatures`,返回企业、应用、材料和 `drainageInfo`
- 已补运营端 `POST /api/admin/enterprise-signatures``PUT /api/admin/enterprise-signatures/{id}` - 已补运营端 `POST /api/admin/enterprise-signatures``PUT /api/admin/enterprise-signatures/{id}`
- 添加/编辑签名可选择真实企业和应用三网报备状态保存到 `SmsSignature.drainageInfo.carrierStatus` - 添加/编辑签名可选择真实企业和应用;签名名称必须填写完整中文黑括号格式 `【签名】`,后端以相同格式校验和保存,所有页面只展示一层括号。三网报备状态保存到 `SmsSignature.drainageInfo.carrierStatus`
- 引流信息列表、添加、编辑、删除保存到 `SmsSignature.drainageInfo.links` - 引流信息列表、添加、编辑、删除保存到 `SmsSignature.drainageInfo.links`
- 删除签名调用真实状态变更接口写 `auditStatus=deleted`,列表默认排除 deleted。 - 删除签名调用真实状态变更接口写 `auditStatus=deleted`,列表默认排除 deleted。
+10 -3
View File
@@ -129,10 +129,11 @@
### 4.3 签名与引流信息 ### 4.3 签名与引流信息
1. 客户端创建短信签名,提交签名名称、用途、证明材料、引流信息 1. 客户端和运营端新增、编辑短信签名,签名名称必须填写完整中文黑括号格式,例如 `【某某科技】`;缺少括号、英文方括号、重复括号、空括号或括号外附加文本均不得提交。NestJS API 必须执行同样校验并以完整格式写入 PostgreSQL,不能只依赖前端按钮状态
2. 运营端企业签名管理查看签名资料。 2. 运营端企业签名管理查看签名资料。
3. 签名需完成企业内部审核和通道报备,状态包括草稿、待审核、已通过、已驳回、报备中、报备通过、报备失败。 3. 签名需完成企业内部审核和通道报备,状态包括草稿、待审核、已通过、已驳回、报备中、报备通过、报备失败。
4. 已通过且报备通过的签名才允许发送。 4. 已通过且报备通过的签名才允许发送。
5. 签名列表、详情、审核、报备、导入导出、模板选择和短信预览统一展示恰好一层完整黑括号;历史非规范签名通过 migration 规范化,任何页面不得再次拼成 `【【签名】】`
### 4.4 模板管理与审核 ### 4.4 模板管理与审核
@@ -1489,7 +1490,7 @@
### 2026-07-15 签名与引流资料批量导入、通道映射及统一报备 ### 2026-07-15 签名与引流资料批量导入、通道映射及统一报备
1. 运营端在“报备任务”下提供“待报备资料”工作台。WPS 在线表格须先由用户另存为 `.xlsx`,系统读取真实工作簿、工作表、表头、单元格和内嵌图片;原始文件及拆出的图片写入 MinIO,导入批次、映射和业务资料写入 PostgreSQL,不支持用 CSV、前端静态数组或浏览器本地存储冒充图片导入。 1. 运营端在“报备任务”下提供“待报备资料”工作台。WPS 在线表格须先由用户另存为 `.xlsx`,系统读取真实工作簿、工作表、表头、单元格和内嵌图片;原始文件及拆出的图片写入 MinIO,导入批次、映射和业务资料写入 PostgreSQL,不支持用 CSV、前端静态数组或浏览器本地存储冒充图片导入。
2. 导入分为“解析预览”和“确认入库”两步。用户可指定企业、企业应用、资料类型、表头行数、数据起始行并复用映射方案;每个源列可映射到签名名称、用途、所属签名、站点名称、URL、备注或报备字段库中的动态字段,同时配置文本/图片/文件、必填和转换规则。源文件字段名称和顺序不固定,映射方案必须可持久化复用。 2. 导入分为“解析预览”和“确认入库”两步。用户可指定企业、企业应用、资料类型、表头行数、数据起始行并复用映射方案;每个源列可映射到签名名称、用途、所属签名、站点名称、URL、备注或报备字段库中的动态字段,同时配置文本/图片/文件、必填和转换规则。源文件中的签名名称同样必须使用完整中文黑括号格式 `【签名】`,不得通过导入绕过页面/API签名校验;源文件字段名称和顺序不固定,映射方案必须可持久化复用。
3. 导入和业务页面的新建/修改只将已审核签名或引流信息标记为待报备,并递增材料版本;不得在每次导入后自动创建通道报备任务。运营人员可跨签名、跨引流信息勾选资料,一次创建统一报备批次。 3. 导入和业务页面的新建/修改只将已审核签名或引流信息标记为待报备,并递增材料版本;不得在每次导入后自动创建通道报备任务。运营人员可跨签名、跨引流信息勾选资料,一次创建统一报备批次。
4. 创建批次时按每条资料所属企业应用的当前生效路由规则展开所有通道;一个签名走多个通道时,必须为每个通道创建或重置独立报备任务并生成一份该通道的 `.xlsx`。无生效路由、通道未配置字段或缺少通道必填资料时,该资料继续保留在待报备池,任务进入“资料待补充”,不得伪装为已完成。 4. 创建批次时按每条资料所属企业应用的当前生效路由规则展开所有通道;一个签名走多个通道时,必须为每个通道创建或重置独立报备任务并生成一份该通道的 `.xlsx`。无生效路由、通道未配置字段或缺少通道必填资料时,该资料继续保留在待报备池,任务进入“资料待补充”,不得伪装为已完成。
5. 通道“配置签名报备字段”和“配置引流信息字段”弹窗使用字段池,按资料类型分别配置。每列包含标准字段、通道导出表头、列顺序、必填、说明、列宽、文本转换、缺省值以及图片宽高;导出表头和列顺序必须严格使用通道配置,不受导入表格原始名称和顺序影响。 5. 通道“配置签名报备字段”和“配置引流信息字段”弹窗使用字段池,按资料类型分别配置。每列包含标准字段、通道导出表头、列顺序、必填、说明、列宽、文本转换、缺省值以及图片宽高;导出表头和列顺序必须严格使用通道配置,不受导入表格原始名称和顺序影响。
@@ -1543,7 +1544,7 @@
1. 运营端企业应用列表同时提供 CMPP 参数和 HTTP 参数复制;客户端应用列表提供 CMPP 参数复制,客户端“接口对接”页提供 HTTP 参数复制。复制内容必须来自真实应用和 HTTP 配置 API,不得用静态数组、localStorage 或页面默认值冒充。 1. 运营端企业应用列表同时提供 CMPP 参数和 HTTP 参数复制;客户端应用列表提供 CMPP 参数复制,客户端“接口对接”页提供 HTTP 参数复制。复制内容必须来自真实应用和 HTTP 配置 API,不得用静态数组、localStorage 或页面默认值冒充。
2. 客户端仅在应用已开通对应协议时允许复制参数。未开通 CMPP 时按钮不可操作,且客户端直接请求 CMPP 参数 API 必须返回 403;未开通 HTTP 时同样不得复制 HTTP 参数。 2. 客户端仅在应用已开通对应协议时允许复制参数。未开通 CMPP 时按钮不可操作,且客户端直接请求 CMPP 参数 API 必须返回 403;未开通 HTTP 时同样不得复制 HTTP 参数。
3. 客户侧 CMPP 网关地址和端口是平台对外公布的下游接入地址,分别由 `CMPP_PUBLIC_HOST``CMPP_PUBLIC_PORT` 配置,不得读取任一上游短信通道的网关地址。生产默认值为 `8.160.169.106:17890` 3. 客户侧 CMPP 网关地址和端口是平台对外公布的下游接入地址,分别由 `CMPP_PUBLIC_HOST``CMPP_PUBLIC_PORT` 配置,不得读取任一上游短信通道的网关地址。当前预发布环境默认值为 `8.160.169.106:17890`;正式生产必须使用独立正式地址和配置
4. 参数复制必须兼容平台当前 HTTP 页面:优先使用 Clipboard API;浏览器因非安全上下文或权限拒绝时,使用受控 textarea 复制降级,并向用户明确反馈成功或失败,不得无提示失败。 4. 参数复制必须兼容平台当前 HTTP 页面:优先使用 Clipboard API;浏览器因非安全上下文或权限拒绝时,使用受控 textarea 复制降级,并向用户明确反馈成功或失败,不得无提示失败。
5. `cmppMaxConnections` 必须在 Gateway 登录时按应用和活动 TCP 会话真实计数并限制,同时由 API 的连接事件校验兜底。连接关闭或异常断开后必须及时释放连接名额并回写断开事件。 5. `cmppMaxConnections` 必须在 Gateway 登录时按应用和活动 TCP 会话真实计数并限制,同时由 API 的连接事件校验兜底。连接关闭或异常断开后必须及时释放连接名额并回写断开事件。
6. CMPP IP/CIDR 白名单必须在登录和连接事件中校验;运营端修改白名单、关闭接口、停用应用或降低最大连接数后,Gateway 应在下一次心跳校验时关闭不再符合条件的存量连接,不能只限制后续 Submit。 6. CMPP IP/CIDR 白名单必须在登录和连接事件中校验;运营端修改白名单、关闭接口、停用应用或降低最大连接数后,Gateway 应在下一次心跳校验时关闭不再符合条件的存量连接,不能只限制后续 Submit。
@@ -1630,3 +1631,9 @@
- 企业认证上传区在手机端必须完整显示长文件名;步骤条允许安全横向浏览且默认展示第一步,省市选择不得因固定宽度被裁切。 - 企业认证上传区在手机端必须完整显示长文件名;步骤条允许安全横向浏览且默认展示第一步,省市选择不得因固定宽度被裁切。
- 客户端日志导出必须显示提交中、完成数量、操作单号、下载和失败重试;客户端CSV仅允许时间、级别、模块、操作人、动作、资源ID六列,不得包含详情、IP或嵌套内部字段。 - 客户端日志导出必须显示提交中、完成数量、操作单号、下载和失败重试;客户端CSV仅允许时间、级别、模块、操作人、动作、资源ID六列,不得包含详情、IP或嵌套内部字段。
- 签名和模板审核通过/驳回必须共用资格预检、状态版本、幂等键、事务审计和结构化结果协议。页面必须在最终决定前显示对象唯一标识、资格和影响范围;取消确认不得改变状态或写审计。 - 签名和模板审核通过/驳回必须共用资格预检、状态版本、幂等键、事务审计和结构化结果协议。页面必须在最终决定前显示对象唯一标识、资格和影响范围;取消确认不得改变状态或写审计。
## 2026-07-22 应用日发送上限与HTTP参数默认值补充
- 每个短信应用的日发送上限默认为100000条,按北京时间自然日和去重后目标号码数计数。客户端、公开HTTP和下游CMPP入站必须共用PostgreSQL原子配额计数,多API实例并发不得突破上限。
- 客户端/HTTP整批超限时不创建任务、短信记录或账务冻结,HTTP返回429及`DAILY_SEND_LIMIT_EXCEEDED`。CMPP合法Submit超限时仍保留每个号码的可审计失败主记录,不冻结/扣费,并以`DAILY_LIMIT`失败回执通知客户。
- 首次开通HTTP接口时,后端默认开启单条发送、状态查询、回执回调、上行查询、上行回调和客户端自助密钥六项能力,回执/上行投递默认为HTTP Webhook。参数复制必须包含应用名称、AppID、六项能力、基础地址、文档、QPS、白名单和真实投递方式。
+1
View File
@@ -22,6 +22,7 @@
3. 规则约束 3. 规则约束
- 应用、签名、模板均按 `tenantId` 隔离。 - 应用、签名、模板均按 `tenantId` 隔离。
- 新增和编辑签名必须填写完整中文黑括号名称 `【签名】`;前后端共同拒绝缺少括号、英文方括号、重复括号和空括号,数据库及所有业务展示统一保留恰好一层黑括号。
- 已审核模板主体第一版不允许直接修改;阶段 3 先不实现编辑接口,后续如需要编辑只允许草稿/驳回状态。 - 已审核模板主体第一版不允许直接修改;阶段 3 先不实现编辑接口,后续如需要编辑只允许草稿/驳回状态。
- 签名和模板提交审核时写入 `AuditRecord` - 签名和模板提交审核时写入 `AuditRecord`
+4 -4
View File
@@ -1,6 +1,6 @@
# CMPP 平台生产部署手册 # CMPP 平台部署手册(当前实例为预发布环境)
## 本次生产端口 ## 当前预发布环境端口
- 运营端、客户端页面:`http://8.160.169.106:12026` - 运营端、客户端页面:`http://8.160.169.106:12026`
- API:仅本机 `127.0.0.1:3000`,由 Nginx `/api/` 反向代理。 - API:仅本机 `127.0.0.1:3000`,由 Nginx `/api/` 反向代理。
@@ -60,7 +60,7 @@ PROD_ADMIN_USERNAME=prod_admin
PROD_ADMIN_PASSWORD='change-me' PROD_ADMIN_PASSWORD='change-me'
``` ```
安全会话使用 HttpOnly Cookie,正式生产必须先为页面和 `/api` 配置 HTTPS,并保持 `SESSION_COOKIE_SECURE=true`仅在用户明确授权的 HTTP 生产验证环境中,允许临时显式设置 `SESSION_COOKIE_SECURE=false` 维持验证可用性;该例外必须记录在发布验收中,不能替代正式环境 TLS。 安全会话使用 HttpOnly Cookie,正式生产必须先为页面和 `/api` 配置 HTTPS,并保持 `SESSION_COOKIE_SECURE=true`当前 HTTP 预发布环境经明确授权可临时显式设置 `SESSION_COOKIE_SECURE=false` 维持验证可用性;该例外必须记录在发布验收中,不能替代正式环境 TLS。
系统操作日志默认在线保留 180 天。API 每日以最多 20 个、每批 1000 条的小事务将过期记录搬入 `OperationLogArchive`,并用 `archiveMonth=YYYY-MM` 标记归档月份;归档记录不会自动删除。调整保留期或批量参数前,应先评估数据库、备份窗口和审计要求。归档表达到千万级或清理窗口不能满足要求时,再实施按 `createdAt` 的月度 PostgreSQL 分区,不在当前数据规模下提前改造主表分区。 系统操作日志默认在线保留 180 天。API 每日以最多 20 个、每批 1000 条的小事务将过期记录搬入 `OperationLogArchive`,并用 `archiveMonth=YYYY-MM` 标记归档月份;归档记录不会自动删除。调整保留期或批量参数前,应先评估数据库、备份窗口和审计要求。归档表达到千万级或清理窗口不能满足要求时,再实施按 `createdAt` 的月度 PostgreSQL 分区,不在当前数据规模下提前改造主表分区。
@@ -74,7 +74,7 @@ Gateway 的最终 TPS 防线依赖与 API 相同的 Redis。通道连接时会
日报任务默认启用,并由 `REPORT_REFRESH_INTERVAL_MS` 每小时检查一次北京时间业务日是否变化;每个业务日只执行一次 T-4 至 T-1 重算。服务重启后也会自动补跑最近四个完整自然日,确保 72 小时回执更新反映到对账和利润报表。 日报任务默认启用,并由 `REPORT_REFRESH_INTERVAL_MS` 每小时检查一次北京时间业务日是否变化;每个业务日只执行一次 T-4 至 T-1 重算。服务重启后也会自动补跑最近四个完整自然日,确保 72 小时回执更新反映到对账和利润报表。
生产验证服务器临时无法稳定下载 MinIO,可显式传入 `OBJECT_STORAGE_DRIVER=local`,文件会通过真实 API 保存到服务器本地目录 `OBJECT_STORAGE_LOCAL_ROOT``cmpp-minio` 服务会跳过安装和启动。该模式只建议用于验证环境;正式生产建议恢复 `OBJECT_STORAGE_DRIVER=minio` 预发布服务器临时无法稳定下载 MinIO,可显式传入 `OBJECT_STORAGE_DRIVER=local`,文件会通过真实 API 保存到服务器本地目录 `OBJECT_STORAGE_LOCAL_ROOT``cmpp-minio` 服务会跳过安装和启动。该模式只建议用于验证环境;正式生产建议恢复 `OBJECT_STORAGE_DRIVER=minio`
## 后续发布 ## 后续发布
+27 -1
View File
@@ -95,7 +95,7 @@
- 优先级:P0 - 优先级:P0
- 前置条件:MinIO 或等价对象存储测试服务可用;如不可用,本用例标记为阻塞或未执行,不得用文件服务 mock 作为验收通过依据。 - 前置条件:MinIO 或等价对象存储测试服务可用;如不可用,本用例标记为阻塞或未执行,不得用文件服务 mock 作为验收通过依据。
- 步骤: - 步骤:
1. 创建短信签名,填写名称、用途、引流信息。 1. 创建短信签名,填写完整中文黑括号名称 `【测试签名】`、用途、引流信息。
2. 上传或登记签名证明材料。 2. 上传或登记签名证明材料。
3. 提交审核。 3. 提交审核。
4. 打开签名列表和详情。 4. 打开签名列表和详情。
@@ -104,6 +104,21 @@
- 材料与签名关联成功。 - 材料与签名关联成功。
- 提交后签名状态变为 pending。 - 提交后签名状态变为 pending。
- 生成审核记录。 - 生成审核记录。
- PostgreSQL保存完整名称`【测试签名】`,客户端和运营端新增、编辑输入框及列表、详情均显示恰好一层黑括号。
### TC-CLIENT-003B 签名黑括号格式前后端强制校验
- 优先级:P0
- 前置条件:存在可创建签名的企业和应用。
- 步骤:
1. 分别在客户端和运营端新增、编辑签名,输入`测试签名``[测试签名]``【【测试签名】】``【 】``【测试签名】附加文本`
2. 绕过页面直接调用真实新增、编辑API提交相同非法名称。
3. 输入合法完整名称`【测试签名】`并保存,刷新列表、详情、审核和报备页面。
4. 选择该签名生成模板内容和发送预览。
- 预期结果:
- 所有非法格式在前端不可提交,直接调用API也返回400及可行动错误,不写入`SmsSignature`
- 合法名称以完整格式写入PostgreSQL;新增、编辑和所有展示位置均为`【测试签名】`
- 模板与发送预览只包含一层签名,不出现`【【测试签名】】`
### TC-CLIENT-003A 签名与引流信息工作台及通道信息隔离 ### TC-CLIENT-003A 签名与引流信息工作台及通道信息隔离
@@ -3691,3 +3706,14 @@ npm run verify:phase8
| A23-REVIEW-001 | 签名/模板点击通过 | 首先显示对象名、唯一ID、企业、应用、资格检查和影响;没有最终确认不得改变pending状态或写AuditRecord | | A23-REVIEW-001 | 签名/模板点击通过 | 首先显示对象名、唯一ID、企业、应用、资格检查和影响;没有最终确认不得改变pending状态或写AuditRecord |
| A23-REVIEW-002 | 审核并发与幂等 | 使用pending+updatedAt条件更新;同键重放返回同一操作单且仅一条审计;版本变化返回冲突 | | A23-REVIEW-002 | 审核并发与幂等 | 使用pending+updatedAt条件更新;同键重放返回同一操作单且仅一条审计;版本变化返回冲突 |
| A23-REVIEW-003 | 审核确认层五视口 | 签名确认层截图覆盖五视口;模板覆盖桌面截图和390px DOM尺寸测量;内容与按钮可达、无横向溢出、console无业务error/warn | | A23-REVIEW-003 | 审核确认层五视口 | 签名确认层截图覆盖五视口;模板覆盖桌面截图和390px DOM尺寸测量;内容与按钮可达、无横向溢出、console无业务error/warn |
## 2026-07-22 日发送配额与HTTP参数回归用例
| 用例ID | 场景 | 验收标准 |
| --- | --- | --- |
| TC-SEND-DAILY-001 | 新建应用不传dailyLimit | PostgreSQL保存100000,返回值与页面均显示100000 |
| TC-SEND-DAILY-002 | 当日剩余1条时,客户端或HTTP同时发2个号码 | 整批返回429/DAILY_SEND_LIMIT_EXCEEDED,不新建任务、短信记录、冻结或队列作业 |
| TC-SEND-DAILY-003 | 两个API实例并发争抢最后配额 | 依赖`applicationId+usageDate`唯一索引与条件upsert,只有不突破上限的请求成功 |
| TC-SEND-DAILY-004 | 多号码CMPP Submit整包超限 | 仅一个SubmitResp;每个号码均有rejected主记录和DAILY_LIMIT回执,无冻结和扣费 |
| TC-HTTP-PARAM-002 | 首次开通HTTP后查看并复制参数 | 六项能力默认开启,回执/上行为HTTP Webhook;复制文本含AppID和“客户端自助密钥”,与真实API/DB一致 |
| TC-HTTP-PARAM-003 | 升级前已开通HTTP且Webhook能力开启,投递模式仍为cmpp | migration将对应回执/上行模式回填为http,参数复制不再显示CMPP长连接 |
+47 -16
View File
@@ -1,5 +1,7 @@
# 第一版系统化测试进度 # 第一版系统化测试进度
> 环境命名:当前 `8.160.169.106:12026`Web/API)和 `8.160.169.106:17890`(CMPP 入站)实例统一定义为“预发布环境”。历史记录中涉及该实例的验证、部署和业务页面均按预发布环境理解;`production-deploy.sh``NODE_ENV=production` 及正式生产安全/备份规范保留原有技术语义,不代表该实例为正式生产。
## 2026-07-16 客户端签名与引流信息页面重做(已提交、已部署) ## 2026-07-16 客户端签名与引流信息页面重做(已提交、已部署)
- 客户端“签名与引流信息”按运营端信息结构重做为签名父级、引流信息子级的可展开工作台,增加真实后端状态统计、关键字/应用/状态筛选、已交资料数、修改说明、新增/修改/删除确认;客户端文案不再出现通道和内部报备概念。 - 客户端“签名与引流信息”按运营端信息结构重做为签名父级、引流信息子级的可展开工作台,增加真实后端状态统计、关键字/应用/状态筛选、已交资料数、修改说明、新增/修改/删除确认;客户端文案不再出现通道和内部报备概念。
@@ -690,7 +692,7 @@ npm run build
- API build:通过。 - API build:通过。
- 前端 build:通过,仍有既有大 chunk warning。 - 前端 build:通过,仍有既有大 chunk warning。
- `tools/smoke/real-env-smoke.mjs` 已同步企业管理员邮箱/手机号、角色 seed、验证码登录和 CMPP 端口 `17890` - `tools/smoke/real-env-smoke.mjs` 已同步企业管理员邮箱/手机号、角色 seed、验证码登录和 CMPP 端口 `17890`
- 真实数据库迁移、浏览器端登录 smoke 需要在生产验证环境执行 `prisma migrate deploy` 后补充记录。 - 真实数据库迁移、浏览器端登录 smoke 需要在预发布环境执行 `prisma migrate deploy` 后补充记录。
## 2026-07-02 非彩信纯 mock 菜单真实化 ## 2026-07-02 非彩信纯 mock 菜单真实化
@@ -726,7 +728,7 @@ npm run verify:phase8
### 待复测 ### 待复测
- 浏览器 smoke 和真实文件上传 smoke 需要在生产验证环境补跑,重点复测客户端发送、签名材料上传、短信审核、短信记录、客户管理和报备任务。 - 浏览器 smoke 和真实文件上传 smoke 需要在预发布环境补跑,重点复测客户端发送、签名材料上传、短信审核、短信记录、客户管理和报备任务。
## 2026-07-03 单运营商通道组、应用级费率和回执幂等 ## 2026-07-03 单运营商通道组、应用级费率和回执幂等
@@ -841,7 +843,7 @@ npm run verify:phase8
- `go build ./cmd/gateway`:通过。 - `go build ./cmd/gateway`:通过。
- 真实 TCP 非法包用例:向入站端口写入非法 `total_length`,确认业务 handler 未执行时仍产生 `read/unpack packet failed` 日志。 - 真实 TCP 非法包用例:向入站端口写入非法 `total_length`,确认业务 handler 未执行时仍产生 `read/unpack packet failed` 日志。
- CMPP2.0 真实集成用例:客户使用与登录账号不同的 `MsgSrc=SP0001`,完成 V20 ConnectResp、Cmpp2SubmitReq/Resp 和 Cmpp2Deliver ReceiptNestJS 收到的 account 仍为 bind 账号:通过。 - CMPP2.0 真实集成用例:客户使用与登录账号不同的 `MsgSrc=SP0001`,完成 V20 ConnectResp、Cmpp2SubmitReq/Resp 和 Cmpp2Deliver ReceiptNestJS 收到的 account 仍为 bind 账号:通过。
- 已将合并后提交 `bb4992f0` 部署到生产验证环境;Prisma 无待执行迁移,前端/API/Gateway 构建和标准健康检查通过,12026/3000/8090/17890 监听正常。生产账号 `910887` 重连日志确认 `requested_version=0x20 response_version=0x20`;该测试应用的 `cmppEnterpriseCode` 已通过真实运营 API 同步为 `910887` - 已将合并后提交 `bb4992f0` 部署到预发布环境;Prisma 无待执行迁移,前端/API/Gateway 构建和标准健康检查通过,12026/3000/8090/17890 监听正常。生产账号 `910887` 重连日志确认 `requested_version=0x20 response_version=0x20`;该测试应用的 `cmppEnterpriseCode` 已通过真实运营 API 同步为 `910887`
## 2026-07-07 Gateway 上游提交与下游 Deliver 闭环补齐 ## 2026-07-07 Gateway 上游提交与下游 Deliver 闭环补齐
@@ -1584,7 +1586,7 @@ git diff --check
## 2026-07-10 Batch 0 飞书瑕疵台账与分批策略 ## 2026-07-10 Batch 0 飞书瑕疵台账与分批策略
来源:飞书《短信平台第一版瑕疵》。本表仅记录问题路由和验收边界;除 Batch 1 外,其他项目仍须先在生产验证环境只读复现并核对真实代码、API、PostgreSQL、Redis、MinIO 或 Gateway 状态,不能根据页面现象直接修改。 来源:飞书《短信平台第一版瑕疵》。本表仅记录问题路由和验收边界;除 Batch 1 外,其他项目仍须先在预发布环境只读复现并核对真实代码、API、PostgreSQL、Redis、MinIO 或 Gateway 状态,不能根据页面现象直接修改。
| 飞书项 | 初步分类 | 真实链路/风险 | 计划批次 | 当前状态 | | 飞书项 | 初步分类 | 真实链路/风险 | 计划批次 | 当前状态 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
@@ -1653,7 +1655,7 @@ git diff --check
- API 全量单测通过:12 个 test suites、113 个测试通过;新增 BillingService 覆盖两笔人工充值分别返回其历史余额。 - API 全量单测通过:12 个 test suites、113 个测试通过;新增 BillingService 覆盖两笔人工充值分别返回其历史余额。
- API build 和前端 build 通过;前端仍有既有 Vite chunk size warning。 - API build 和前端 build 通过;前端仍有既有 Vite chunk size warning。
- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。 - `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。
- 已按生产标准脚本部署到 `8.160.169.106`Prisma migration deploy 无待执行迁移,`cmpp-api``cmpp-gateway`、Nginx、MinIO 均为 activeAPI/Gateway health、Redis 均通过。 - 已按正式发布标准脚本部署到预发布服务器 `8.160.169.106`Prisma migration deploy 无待执行迁移,`cmpp-api``cmpp-gateway`、Nginx、MinIO 均为 activeAPI/Gateway health、Redis 均通过。
- 生产管理员真实登录后只读调用 `GET /api/admin/billing/manual-recharges` 成功返回 2 条记录,响应包含真实 `balanceAfterCents`10000、1000)。 - 生产管理员真实登录后只读调用 `GET /api/admin/billing/manual-recharges` 成功返回 2 条记录,响应包含真实 `balanceAfterCents`10000、1000)。
## 2026-07-10 Batch 2 通道配置真实链路 ## 2026-07-10 Batch 2 通道配置真实链路
@@ -1677,7 +1679,7 @@ git diff --check
- ChannelsService 和 SendChainService 定向测试通过:2 个 test suites、55 个测试通过;ChannelsService 单独测试 23 项,覆盖流速、扩展位数持久化和非法配置拒绝。 - ChannelsService 和 SendChainService 定向测试通过:2 个 test suites、55 个测试通过;ChannelsService 单独测试 23 项,覆盖流速、扩展位数持久化和非法配置拒绝。
- API build、前端 build、Gateway queue/upstream 测试通过;前端仍有既有 Vite chunk size warning。 - API build、前端 build、Gateway queue/upstream 测试通过;前端仍有既有 Vite chunk size warning。
- 已重新部署生产验证环境;Prisma migration deploy 无待执行迁移,`cmpp-api``cmpp-gateway`、Nginx、MinIO 均为 activeAPI/Gateway health 正常。生产运行源码已确认包含流速校验、扩展位数持久化及 Gateway 队列字段。 - 已重新部署预发布环境;Prisma migration deploy 无待执行迁移,`cmpp-api``cmpp-gateway`、Nginx、MinIO 均为 activeAPI/Gateway health 正常。生产运行源码已确认包含流速校验、扩展位数持久化及 Gateway 队列字段。
- 通道组名称为空时已有前端提示“请输入通道组名称”,保存会在调用真实创建/更新 API 前中断;本轮复核后不重复改动。 - 通道组名称为空时已有前端提示“请输入通道组名称”,保存会在调用真实创建/更新 API 前中断;本轮复核后不重复改动。
- 通道编辑密码保持掩码且不回显:编辑时明确提示“留空保持不变,填写新密码才更新”;新建通道仍要求填写密码。 - 通道编辑密码保持掩码且不回显:编辑时明确提示“留空保持不变,填写新密码才更新”;新建通道仍要求填写密码。
- 上述密码交互调整已于 2026-07-10 生产验证部署后再次核验:`cmpp-api``cmpp-gateway`、Nginx、MinIO 均为 active,内外部 health/HTTP 检查通过。 - 上述密码交互调整已于 2026-07-10 生产验证部署后再次核验:`cmpp-api``cmpp-gateway`、Nginx、MinIO 均为 active,内外部 health/HTTP 检查通过。
@@ -1884,7 +1886,7 @@ git diff --check
## 2026-07-14 服务端安全会话与自动锁定 ## 2026-07-14 服务端安全会话与自动锁定
- 将可预测的 `dev-token:userId:sessionVersion` 和 localStorage 访问令牌替换为 256 位随机会话标识;浏览器只通过 HttpOnly、SameSite Cookie 携带,Redis 使用会话标识 SHA-256 键保存真实状态。生产模式 Cookie 默认 `Secure`;本次按用户要求部署到现有 HTTP 生产验证环境时显式配置 `SESSION_COOKIE_SECURE=false`,正式生产切换 HTTPS 后必须恢复为 `true` - 将可预测的 `dev-token:userId:sessionVersion` 和 localStorage 访问令牌替换为 256 位随机会话标识;浏览器只通过 HttpOnly、SameSite Cookie 携带,Redis 使用会话标识 SHA-256 键保存真实状态。生产模式 Cookie 默认 `Secure`;本次按用户要求部署到现有 HTTP 预发布环境时显式配置 `SESSION_COOKIE_SECURE=false`,正式生产切换 HTTPS 后必须恢复为 `true`
- 运营端/客户端无操作阈值分别为 60/120 分钟,提前 5 分钟提醒;超时进入密码锁屏,4 小时内可用当前密码解锁并轮换会话标识,超过后完整登录。绝对会话时长 12 小时不可滑动续期;敏感操作最近密码认证窗口为 30 分钟。 - 运营端/客户端无操作阈值分别为 60/120 分钟,提前 5 分钟提醒;超时进入密码锁屏,4 小时内可用当前密码解锁并轮换会话标识,超过后完整登录。绝对会话时长 12 小时不可滑动续期;敏感操作最近密码认证窗口为 30 分钟。
- NestJS 中间件对运营端和客户端受保护 API 强制要求 Redis 会话,逐次校验用户状态和 `sessionVersion`Gateway 回调和 health 保持原内部链路,不被浏览器会话门禁拦截。自动轮询只有检测到近期真实浏览器操作时才携带活动标识,不能长期保活无人值守会话。 - NestJS 中间件对运营端和客户端受保护 API 强制要求 Redis 会话,逐次校验用户状态和 `sessionVersion`Gateway 回调和 health 保持原内部链路,不被浏览器会话门禁拦截。自动轮询只有检测到近期真实浏览器操作时才携带活动标识,不能长期保活无人值守会话。
@@ -2007,7 +2009,7 @@ git diff --check
- 生产首轮发布复核发现 Gateway 进程重启时无法保证回写旧 TCP 会话断开,数据库陈旧连接可能占用应用连接名额,而原超时清理只在查询连接列表时触发。现将 90 秒陈旧连接清理前置到每次非断开连接事件校验,确保新 Gateway 无需等待运营人员打开页面即可自动恢复连接名额。 - 生产首轮发布复核发现 Gateway 进程重启时无法保证回写旧 TCP 会话断开,数据库陈旧连接可能占用应用连接名额,而原超时清理只在查询连接列表时触发。现将 90 秒陈旧连接清理前置到每次非断开连接事件校验,确保新 Gateway 无需等待运营人员打开页面即可自动恢复连接名额。
- 工作区完整功能提交 `faa716b8d07ea77fae3ec41c858b52f6a341e6b9` 和重启恢复修正提交 `23a1f6fa15445dbe6d2e4738b10b545b7c657472` 已 push。两次发布前备份分别位于 `/opt/cmpp-platform/backups/releases/20260716-175500``/opt/cmpp-platform/backups/releases/20260716-180244`,PostgreSQL、运行源码和环境配置均通过 gzip/tar 完整性及 SHA-256 校验;最终发布包本地与服务器 SHA-256 均为 `c34c0fb627b77aa0c1a3d08169b3aff8d04587544ed9c1d83d8c0cd41b007272` - 工作区完整功能提交 `faa716b8d07ea77fae3ec41c858b52f6a341e6b9` 和重启恢复修正提交 `23a1f6fa15445dbe6d2e4738b10b545b7c657472` 已 push。两次发布前备份分别位于 `/opt/cmpp-platform/backups/releases/20260716-175500``/opt/cmpp-platform/backups/releases/20260716-180244`,PostgreSQL、运行源码和环境配置均通过 gzip/tar 完整性及 SHA-256 校验;最终发布包本地与服务器 SHA-256 均为 `c34c0fb627b77aa0c1a3d08169b3aff8d04587544ed9c1d83d8c0cd41b007272`
- 生产 migration `20260716150000_expand_money_precision_to_four_decimals` 已应用,53 条 migration 齐全,`SmsApplication.customerUnitPrice` 等金额列已为 `BIGINT`。生产 API/Gateway health、PostgreSQL、Redis PONG、MinIO、Nginx 及 `12026/17890/8090/3000/9000` 监听均正常;两个真实通道 TPS key 均为 100`gateway.submit.commands``pending=0、lag=0`,部署后 API/Gateway 无 error 级日志。 - 生产 migration `20260716150000_expand_money_precision_to_four_decimals` 已应用,53 条 migration 齐全,`SmsApplication.customerUnitPrice` 等金额列已为 `BIGINT`。生产 API/Gateway health、PostgreSQL、Redis PONG、MinIO、Nginx 及 `12026/17890/8090/3000/9000` 监听均正常;两个真实通道 TPS key 均为 100`gateway.submit.commands``pending=0、lag=0`,部署后 API/Gateway 无 error 级日志。
- 生产公网 CMPP 参数已配置为 `8.160.169.106:17890`。不符合白名单的 `715011 / 183.194.97.158` 陈旧连接在超时窗口后自动清除,合法账号 `910887` 由新 Gateway 建立新连接并持续更新心跳,证明白名单和重启后连接名额恢复逻辑真实生效。Chrome/Playwright 复核运营登录、390px 客户端登录和客户 Swagger 文档均为 200、无横向溢出、无 console/page error;未绕过验证码、未修改账号、未发送短信。 - 预发布公网 CMPP 参数已配置为 `8.160.169.106:17890`。不符合白名单的 `715011 / 183.194.97.158` 陈旧连接在超时窗口后自动清除,合法账号 `910887` 由新 Gateway 建立新连接并持续更新心跳,证明白名单和重启后连接名额恢复逻辑真实生效。Chrome/Playwright 复核运营登录、390px 客户端登录和客户 Swagger 文档均为 200、无横向溢出、无 console/page error;未绕过验证码、未修改账号、未发送短信。
## 2026-07-18 CMPP 多号码 Submit 首号码静默丢弃修复(未提交、未部署) ## 2026-07-18 CMPP 多号码 Submit 首号码静默丢弃修复(未提交、未部署)
@@ -2057,7 +2059,7 @@ git diff --check
- BullMQ 15000条/500并发门槛在本机共享Redis复测为429.50和404.43 TPS,临时独立Redis复测为379.39 TPS,未达到500 TPS;同一代码此前隔离复测达到907.93 TPS。本次不修改门槛、不伪造结果,按共享主机瞬时负载风险继续记录,后续应在固定规格、空载环境建立稳定基线。 - BullMQ 15000条/500并发门槛在本机共享Redis复测为429.50和404.43 TPS,临时独立Redis复测为379.39 TPS,未达到500 TPS;同一代码此前隔离复测达到907.93 TPS。本次不修改门槛、不伪造结果,按共享主机瞬时负载风险继续记录,后续应在固定规格、空载环境建立稳定基线。
- 部署前生产PostgreSQL、运行源码和环境配置备份至`/opt/cmpp-platform/backups/releases/20260720-180906`。三份备份均非空并通过gzip/tar完整性及SHA-256校验:数据库`824be663552da2d2ce99184ec464a6e72d2f25c0c16dee234bfc47c7d371f433`、源码`2598a9b6dd9b6a40002b877316cf23aef48447678915450381f76dcb99d14b54`、环境`189f4f67e9d52f3c1ce751d1005c08667b1b4efcd0c576aaa5ad3249c822e4e7`。发布包本地和服务器SHA-256均为`a6582da4de5c7161b480f4b5695099accb247071ec825e0b697c9f8d60762342` - 部署前生产PostgreSQL、运行源码和环境配置备份至`/opt/cmpp-platform/backups/releases/20260720-180906`。三份备份均非空并通过gzip/tar完整性及SHA-256校验:数据库`824be663552da2d2ce99184ec464a6e72d2f25c0c16dee234bfc47c7d371f433`、源码`2598a9b6dd9b6a40002b877316cf23aef48447678915450381f76dcb99d14b54`、环境`189f4f67e9d52f3c1ce751d1005c08667b1b4efcd0c576aaa5ad3249c822e4e7`。发布包本地和服务器SHA-256均为`a6582da4de5c7161b480f4b5695099accb247071ec825e0b697c9f8d60762342`
- 使用`tools/deploy/production-deploy.sh`完成部署,成功应用`20260718130000_add_cmpp_submit_group_message_id``20260720110000_add_receipt_identity``20260720113000_add_report_business_metrics``20260720114500_add_receipt_phone_number`,生产57条migration齐全且schema最新;CMPP多号码分组、回执唯一身份/目的号码、报表失败退款指标等目标列均已存在。 - 使用`tools/deploy/production-deploy.sh`完成部署,成功应用`20260718130000_add_cmpp_submit_group_message_id``20260720110000_add_receipt_identity``20260720113000_add_report_business_metrics``20260720114500_add_receipt_phone_number`,生产57条migration齐全且schema最新;CMPP多号码分组、回执唯一身份/目的号码、报表失败退款指标等目标列均已存在。
- 生产运行提交为`f02c33cbb7248410c189f75502d6e497fff7b355``cmpp-gateway``cmpp-api`、Nginx、PostgreSQL、Redis和MinIO均active`12026/17890/8090/3000/9000`监听;API/Gateway health、Redis PONG、PostgreSQL readiness、外部首页、运营登录、客户端登录、API health和Swagger JSON均通过,公网CMPP `8.160.169.106:17890`可连接。根目录/API生产依赖audit均为0漏洞。 - 预发布运行提交为`f02c33cbb7248410c189f75502d6e497fff7b355``cmpp-gateway``cmpp-api`、Nginx、PostgreSQL、Redis和MinIO均active`12026/17890/8090/3000/9000`监听;API/Gateway health、Redis PONG、PostgreSQL readiness、外部首页、运营登录、客户端登录、API health和Swagger JSON均通过,公网CMPP `8.160.169.106:17890`可连接。根目录/API运行依赖audit均为0漏洞。
- 两个active上游通道均为`connected/currentConnections=1`,权威TPS配置已恢复;Redis Stream `gateway.submit.commands` consumer group为`pending=0、lag=0`。部署后API/Gateway error级日志均为0。未发送或重投真实短信,未执行充值、审核、删除或生产业务数据修改。 - 两个active上游通道均为`connected/currentConnections=1`,权威TPS配置已恢复;Redis Stream `gateway.submit.commands` consumer group为`pending=0、lag=0`。部署后API/Gateway error级日志均为0。未发送或重投真实短信,未执行充值、审核、删除或生产业务数据修改。
- 本地Browser五视口LG2-P0-01证据已通过;生产浏览器烟测被企业网络策略禁止访问该公网HTTP地址,未使用其他浏览器或自动化方式绕过。生产外部HTTP/TCP、真实服务、数据库、Redis和运行产物均已核验,但本次不把生产浏览器交互标记为通过。 - 本地Browser五视口LG2-P0-01证据已通过;生产浏览器烟测被企业网络策略禁止访问该公网HTTP地址,未使用其他浏览器或自动化方式绕过。生产外部HTTP/TCP、真实服务、数据库、Redis和运行产物均已核验,但本次不把生产浏览器交互标记为通过。
@@ -2163,16 +2165,45 @@ git diff --check
- `LG2-P1-14`在运营签名和模板审核页分别打开通过确认层,仅取消时数据库状态仍为pending且审计为0。真实API随后首次批准签名并用相同幂等键重放,两次返回同一操作单`cmrvlgtrq000gakyumhm1iuiv`,重放标志为true且只写一次审核。 - `LG2-P1-14`在运营签名和模板审核页分别打开通过确认层,仅取消时数据库状态仍为pending且审计为0。真实API随后首次批准签名并用相同幂等键重放,两次返回同一操作单`cmrvlgtrq000gakyumhm1iuiv`,重放标志为true且只写一次审核。
- 浏览器证据覆盖上传页和日志导出页五视口、签名确认层五视口截图,以及模板确认层1440×900截图和390×844 DOM尺寸测量;两端console error/warn为空。证据位于测试项目`平台LG_UIUX二轮走查证据/整改_A2_A3收口_20260722/` - 浏览器证据覆盖上传页和日志导出页五视口、签名确认层五视口截图,以及模板确认层1440×900截图和390×844 DOM尺寸测量;两端console error/warn为空。证据位于测试项目`平台LG_UIUX二轮走查证据/整改_A2_A3收口_20260722/`
- 自动化通过:files、operations、review-governance 3 suites / 31 tests;前端build、API build、Prisma validate/status58条、schema最新)和`git diff --check`。前端仍有约1.92MB单chunk告警,项目无独立前端lint/组件/axe脚本。 - 自动化通过:files、operations、review-governance 3 suites / 31 tests;前端build、API build、Prisma validate/status58条、schema最新)和`git diff --check`。前端仍有约1.92MB单chunk告警,项目无独立前端lint/组件/axe脚本。
- 客户端日志列表仍展示内部详情/IP,继续归`LG2-P1-07`,未因导出安全而标记完成。临时数据、MinIO对象和Redis会话已清理;未触碰生产,未提交、未push、未部署。 - 客户端日志列表仍展示内部详情/IP,继续归`LG2-P1-07`,未因导出安全而标记完成。临时数据、MinIO对象和Redis会话已清理;未触碰预发布环境,未提交、未push、未部署。
## 2026-07-22 工作区汇总提交与生产发布(`0f223f7f` ## 2026-07-22 工作区汇总提交与预发布发布(`0f223f7f`
- 按用户授权汇总提交当前全部有效平台源码、migration、测试、依赖锁文件和项目文档,共80个文件、4959行新增和765行删除;功能提交为`0f223f7f91d1bd24e7a2cc0ce6ce9ae3e3258b10``feat: harden platform workflows and UI governance`),已push至`origin/main`。按仓库约束未提交`api/tsconfig.build.tsbuildinfo`和任何`.log`;独立的`outputs/phone-prefix-area-code-20260721/`为号码地区表生成交付物而非平台运行源码,未纳入生产提交。 - 按用户授权汇总提交当前全部有效平台源码、migration、测试、依赖锁文件和项目文档,共80个文件、4959行新增和765行删除;功能提交为`0f223f7f91d1bd24e7a2cc0ce6ce9ae3e3258b10``feat: harden platform workflows and UI governance`),已push至`origin/main`。按仓库约束未提交`api/tsconfig.build.tsbuildinfo`和任何`.log`;独立的`outputs/phone-prefix-area-code-20260721/`为号码地区表生成交付物而非平台运行源码,未纳入生产提交。
- 发布前发现API锁文件中的Prisma 7.8开发依赖链产生6个审计漏洞,且`api/package.json`存在重复`overrides`键。已将`@prisma/client``@prisma/adapter-pg`和Prisma CLI同步升级至7.9.0,移除过时Hono强制版本并合并override;干净`npm ci --include=dev`后根目录和API的`npm audit --audit-level=low`均为0漏洞。Prisma Client 7.9.0生成、schema validate及本地58条migration status均通过。 - 发布前发现API锁文件中的Prisma 7.8开发依赖链产生6个审计漏洞,且`api/package.json`存在重复`overrides`键。已将`@prisma/client``@prisma/adapter-pg`和Prisma CLI同步升级至7.9.0,移除过时Hono强制版本并合并override;干净`npm ci --include=dev`后根目录和API的`npm audit --audit-level=low`均为0漏洞。Prisma Client 7.9.0生成、schema validate及本地58条migration status均通过。
- 本地发布门禁:API全量24 suites / 282 tests通过,API TypeScript build、前端TypeScript/Vite build、Gateway `go test ./...`、Prisma generate/validate/migrate status和`git diff --check`通过。Jest断言完成后仍需`--forceExit`结束既有异步句柄;前端仍有约1.92MB单chunk警告,继续归入后续性能整改,不虚报解决。 - 本地发布门禁:API全量24 suites / 282 tests通过,API TypeScript build、前端TypeScript/Vite build、Gateway `go test ./...`、Prisma generate/validate/migrate status和`git diff --check`通过。Jest断言完成后仍需`--forceExit`结束既有异步句柄;前端仍有约1.92MB单chunk警告,继续归入后续性能整改,不虚报解决。
- 部署前生产PostgreSQL、运行源码和环境配置备份至`/opt/cmpp-platform/backups/releases/20260722-142102`。数据库备份`postgresql.sql.gz`为4396893字节、SHA-256 `598e0b624b69c85f707a5c8f769a00fe2118f44e1491f872aa62e1f72d43890f`;源码备份`runtime-source.tar.gz`为28537401字节、SHA-256 `552633474c7888eb2c1a17902430fa385054ef7f5b5e654faf3e2934e94caec4`;环境备份`cmpp-platform.env`为850字节、SHA-256 `189f4f67e9d52f3c1ce751d1005c08667b1b4efcd0c576aaa5ad3249c822e4e7`。三份文件均非空、权限600,数据库gzip和源码tar完整性校验通过。 - 部署前预发布PostgreSQL、运行源码和环境配置备份至`/opt/cmpp-platform/backups/releases/20260722-142102`。数据库备份`postgresql.sql.gz`为4396893字节、SHA-256 `598e0b624b69c85f707a5c8f769a00fe2118f44e1491f872aa62e1f72d43890f`;源码备份`runtime-source.tar.gz`为28537401字节、SHA-256 `552633474c7888eb2c1a17902430fa385054ef7f5b5e654faf3e2934e94caec4`;环境备份`cmpp-platform.env`为850字节、SHA-256 `189f4f67e9d52f3c1ce751d1005c08667b1b4efcd0c576aaa5ad3249c822e4e7`。三份文件均非空、权限600,数据库gzip和源码tar完整性校验通过。
- 发布包由提交快照生成,本地与服务器SHA-256均为`4aa2188b943885d73558b8e04014fd559d421981071cbcddca72ba379780478e`。使用`tools/deploy/production-deploy.sh`完成部署,成功应用`20260721150000_backfill_misattributed_delivery_receipts`生产58条migration齐全且schema最新;脚本按Gateway在前、API在后的顺序重启并恢复运行状态。生产`.deployed-commit=0f223f7f91d1bd24e7a2cc0ce6ce9ae3e3258b10` - 发布包由提交快照生成,本地与服务器SHA-256均为`4aa2188b943885d73558b8e04014fd559d421981071cbcddca72ba379780478e`。使用`tools/deploy/production-deploy.sh`完成部署,成功应用`20260721150000_backfill_misattributed_delivery_receipts`预发布58条migration齐全且schema最新;脚本按Gateway在前、API在后的顺序重启并恢复运行状态。预发布`.deployed-commit=0f223f7f91d1bd24e7a2cc0ce6ce9ae3e3258b10`
- 发布后`cmpp-gateway``cmpp-api`、Nginx、PostgreSQL、Redis(实际unit为`redis`)和MinIO均active`12026/17890/8090/3000/9000/6379/5432`监听;API/Gateway health、Redis PONG、PostgreSQL readiness均通过。两个active上游通道`CH-1783566107506``CH-1783566107506-COPY-MRCXAK2W`均恢复为`connected/currentConnections=1`,Redis中7个通道权威TPS配置存在;`gateway.submit.commands``cmpp-gateway` consumer group为`pending=0、lag=0` - 发布后`cmpp-gateway``cmpp-api`、Nginx、PostgreSQL、Redis(实际unit为`redis`)和MinIO均active`12026/17890/8090/3000/9000/6379/5432`监听;API/Gateway health、Redis PONG、PostgreSQL readiness均通过。两个active上游通道`CH-1783566107506``CH-1783566107506-COPY-MRCXAK2W`均恢复为`connected/currentConnections=1`,Redis中7个通道权威TPS配置存在;`gateway.submit.commands``cmpp-gateway` consumer group为`pending=0、lag=0`
- 服务器本机及外部访问首页、运营登录、客户端登录和API health均返回HTTP 200,公网CMPP `8.160.169.106:17890` TCP连接成功。生产根目录/API依赖audit均为0;部署后20分钟内API和Gateway journal error均为0API近期stderr错误匹配为0。 - 服务器本机及外部访问首页、运营登录、客户端登录和API health均返回HTTP 200,公网CMPP `8.160.169.106:17890` TCP连接成功。预发布根目录/API依赖audit均为0;部署后20分钟内API和Gateway journal error均为0API近期stderr错误匹配为0。
- Browser真实打开生产客户端登录页`/#/client/login`,页面标题为“聆界短信管理平台”,Logo、用户名、密码、图形验证码和登录按钮完整渲染,DOM非空、无框架错误层,页面console error/warn为0并取得截图。空表单登录按钮交互在Browser控制层两次超时并触发连接重置,因此本次仅将生产页面渲染烟测记为通过,不虚报登录交互通过;未输入或传输账号密码。 - Browser真实打开预发布客户端登录页`/#/client/login`,页面标题为“聆界短信管理平台”,Logo、用户名、密码、图形验证码和登录按钮完整渲染,DOM非空、无框架错误层,页面console error/warn为0并取得截图。空表单登录按钮交互在Browser控制层两次超时并触发连接重置,因此本次仅将预发布页面渲染烟测记为通过,不虚报登录交互通过;未输入或传输账号密码。
- 本次未发送或重投真实短信,未充值、审核、删除、禁用账号、改密或修改真实通道配置。生产业务数据变更仅来自已审查并随发布执行的历史回执确定性回填migration。 - 本次未发送或重投真实短信,未充值、审核、删除、禁用账号、改密或修改真实通道配置。预发布业务数据变更仅来自已审查并随发布执行的历史回执确定性回填migration。
## 2026-07-22 预发布发布中断窗口复盘
- 本次约1小时19分钟为端到端发布作业时长,并非业务持续中断时长;旧服务在依赖安装、构建、备份和上传期间持续运行,实际服务切换集中在`14:27:53`附近。
- systemd日志显示:Gateway从开始停止到重新启动约43毫秒,Nginx约126毫秒;API在`14:27:53.283`开始停止,Nest于`14:27:54`记录启动成功,API不可用窗口约1秒,按日志秒级精度保守上界小于1.72秒。
- Nginx访问日志在`14:20—14:32`共记录8次请求且均为HTTP 200、无5xx;但`14:27:45—14:28:10`没有请求样本,因此只能确认“未观察到HTTP失败”,不能据此声称HTTP业务零影响。
- Gateway重启会断开全部既有CMPP TCP会话。账号`910887`约3秒后重连;账号`991405`受客户端重试周期和连接名额暂未释放影响,首条可用连接约78秒后恢复,两条连接全部恢复约108秒后完成。若同样方式用于正式生产,应按受影响CMPP客户最长约1分48秒的业务中断评估,而不能只按Gateway监听端口的43毫秒计算。
- 该方式不是零停机发布。正式生产应采用API/前端蓝绿或滚动切换、Nginx reload、Gateway双实例与连接排空、受控关闭时及时释放连接名额,以及持续探针和健康门禁后再切流。
## 2026-07-22 应用日发送上限与HTTP参数复制修复(未提交、未部署)
- 根因:`SmsApplication.dailyLimit`原先仅保存/展示,发送链没有任何读取或拦截;HTTP开通默认值只改了前端表单,Prisma/数据库仍以`cmpp`为回执/上行默认投递模式,已有HTTP应用未回填;复制文本还漏了AppID和第六项“客户端自助密钥”。生产只读核查唯一已开通HTTP应用六项能力均为true,但两个投递模式均为`cmpp`,与现象一致。
- 新增`SmsApplicationDailyUsage(applicationId, usageDate, usedCount)`及唯一索引,按北京时间自然日使用PostgreSQL条件upsert原子抢占去重后号码配额。新建应用和历史NULL默认100000;迁移同时回填发布当日已创建的短信数,避免午间升级后额外获得一整份配额。
- 客户端/HTTP超限整批返回429和`DAILY_SEND_LIMIT_EXCEEDED`,不创建任务/记录/冻结;CMPP整包预留失败后每个号码仍创建可审计rejected记录与`DAILY_LIMIT`失败回执,不冻结或扣费。
- HTTP Schema和后端首次开通均默认六项能力为true、两个投递模式为`http`;迁移仅将“HTTP已开通+对应Webhook已开启+旧模式cmpp”的历史行定向回填为http。复制文本新增AppID和客户端自助密钥能力。
- 回归:目标SendChain/SmsConfig/OpenAPI 3 suites / 138 tests通过;API全量24 suites / 289 tests通过;API build、前端build、Gateway `go test ./...`、Prisma generate/validate通过。前端仍有既有1.92MB chunk警告;Jest断言完成后仍需`--forceExit`结束异步句柄。
- 独立临时PostgreSQL数据库从零应用60条migration并通过status;实链并发验证上限3条时两个2条请求仅1个成功,`usedCount=2`;新HTTP配置六项均true且投递模式为http。临时数据库已删除,共享本地库未应用本轮migration,避免触碰另一会话同时新增的签名迁移。
- `npm run verify:phase8`未通过:契约和Gateway通过,BullMQ完成15000条真实Redis消息,但本机端到端336.82 TPS低于脚本500 TPS门槛,命令链在此中止;未将环境性能不达标写成通过。
- Browser本地页面验收未完成:应用内Browser对`127.0.0.1`和工作站LAN地址均连接被拒,随后浏览器连接超时重置。未使用mock、生产旧页面或伪造截图冒充修复后交互通过。
## 2026-07-22 短信签名完整黑括号口径统一(未提交、未部署)
- 产品口径统一为:客户端和运营端新增、编辑签名时均填写完整中文黑括号名称,例如`【某某科技】`;缺少括号、英文方括号、重复括号、空括号或括号外文本均不得提交。所有列表、详情、审核、报备、模板和发送预览只显示一层完整括号。
- 客户端移除“无需填写【】”提示,两端表单按完整格式控制提交;NestJS新增、编辑API执行相同强制校验并保存完整名称,防止绕过前端。新增migration统一修复历史非规范名称,签名API读取时也按单层格式输出。
- 客户端发送预览改用共享签名替换函数,不再在已经带括号的数据库名称外再次拼接,避免`【【签名】】`。签名资料导入继续走同一后端校验,不能成为绕过入口。
- 验证结果:SmsConfig定向1 suite / 53项、报备资料导入1 suite / 7项、API全量24 suites / 290项通过;API TypeScript build、前端TypeScript/Vite build、Prisma validate通过。本地PostgreSQL已应用第59、60条migration且schema最新;第60条签名migration另在真实PostgreSQL事务内验证`测试``[英文]``【【重复】】`分别规范化为`【测试】``【英文】``【重复】`,随后回滚临时数据。Jest仍需`--forceExit`结束既有异步句柄,前端仍有既有约1.92MB单chunk警告。
- 应用内浏览器使用本地真实PostgreSQL临时企业、平台管理员和企业管理员,分别读取并计算真实算术验证码登录客户端`/#/client/signatures`和运营端`/#/admin/enterprise-signatures`。两端新增表单输入`某某科技`时均出现“必须填写完整中文黑括号签名”错误且提交按钮禁用,改为`【某某科技】`后按钮启用;未点击保存。另插入本地草稿`【编辑验收签名】`,客户端“修改”和运营端“编辑”弹窗均原样显示完整一层括号且提交可用,两端console error/warn均为0,并取得可见截图。
- 浏览器验收结束后已精确删除1条临时签名、2个临时用户、1个临时企业、4条登录操作日志和2个Redis会话;复核企业、用户、签名计数均为0。本会话启动的本地API和前端预览进程已停止,未留下测试业务数据或后台进程。
- 本轮未提交、未push、未部署;未写入预发布业务数据。工作区同时存在其他会话的应用日限额/HTTP默认配置修改,本轮仅在同一文件的签名逻辑区域增量修改并完整保留其改动。
@@ -4,6 +4,7 @@ import { adminApi, type ApplicationReportField, type ClientSmsApplication, type
import { Breadcrumb, Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui'; import { Breadcrumb, Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
import { displayFileName } from '@/utils/fileName'; import { displayFileName } from '@/utils/fileName';
import { formatDateTime } from '@/utils/dateTime'; import { formatDateTime } from '@/utils/dateTime';
import { isCompleteSmsSignature } from '@/utils/smsSignature';
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing'; type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
@@ -319,7 +320,7 @@ function SignatureFormModal({
footer={( footer={(
<> <>
<Button onClick={onClose} variant="ghost"></Button> <Button onClick={onClose} variant="ghost"></Button>
<Button disabled={!form.tenantId || !form.name || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}></Button> <Button disabled={!form.tenantId || !isCompleteSmsSignature(form.name) || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}></Button>
</> </>
)} )}
onClose={onClose} onClose={onClose}
@@ -337,7 +338,7 @@ function SignatureFormModal({
<h3></h3> <h3></h3>
<div className="signature-alert"> <div className="signature-alert">
<Info size={18} /> <Info size={18} />
<span>使 PNGJPGJPEG PDF </span> <span>使</span>
</div> </div>
<div className="signature-form-grid"> <div className="signature-form-grid">
<Select <Select
@@ -360,7 +361,15 @@ function SignatureFormModal({
]} ]}
value={form.applicationId} value={form.applicationId}
/> />
<Input label="* 短信签名" onChange={(event) => update('name', event.target.value)} placeholder="请输入短信签名,如【XXXX公司】" required value={form.name} /> <Input
error={form.name.trim() && !isCompleteSmsSignature(form.name) ? '必须填写完整中文黑括号签名,例如:【某某科技】' : undefined}
hint="新增和编辑时都必须保留完整的【】"
label="短信签名"
onChange={(event) => update('name', event.target.value)}
placeholder="请输入完整签名,例如:【某某科技】"
required
value={form.name}
/>
</div> </div>
</section> </section>
+2 -1
View File
@@ -3,6 +3,7 @@ import { Check, Download, FileText, Plus, Search, Send, Trash2 } from 'lucide-re
import { Button, DateTimeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui'; import { Button, DateTimeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignatureView, type ClientSmsTemplate, type ImportPreviewResponse, type SmsBatchTask } from '@/api/adminApi'; import { clientApi, type ClientSmsApplication, type ClientSmsSignatureView, type ClientSmsTemplate, type ImportPreviewResponse, type SmsBatchTask } from '@/api/adminApi';
import { formatCents } from '@/utils/currency'; import { formatCents } from '@/utils/currency';
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
type Recipient = { type Recipient = {
id: string; id: string;
@@ -71,7 +72,7 @@ export function ClientSendPage() {
const importedValidCount = importPreview?.validCount ?? 0; const importedValidCount = importPreview?.validCount ?? 0;
const receiverCount = receiverMode === 'manual' ? validRecipients.length : importedValidCount; const receiverCount = receiverMode === 'manual' ? validRecipients.length : importedValidCount;
const previewText = selectedSignature && messageContent const previewText = selectedSignature && messageContent
? `${selectedSignature.name}${messageContent}` ? replaceLeadingSmsSignature(messageContent, selectedSignature.name)
: messageContent; : messageContent;
const wordCount = previewText.length; const wordCount = previewText.length;
const smsParts = wordCount > 0 ? Math.max(1, Math.ceil(wordCount / 70)) : 0; const smsParts = wordCount > 0 ? Math.max(1, Math.ceil(wordCount / 70)) : 0;
+16 -2
View File
@@ -9,6 +9,7 @@ import {
type ClientSmsSignatureView, type ClientSmsSignatureView,
type FileRef, type FileRef,
} from '@/api/adminApi'; } from '@/api/adminApi';
import { isCompleteSmsSignature } from '@/utils/smsSignature';
const EMPTY_WORKSPACE: ClientSignatureWorkspace = { const EMPTY_WORKSPACE: ClientSignatureWorkspace = {
items: [], items: [],
@@ -120,6 +121,10 @@ function SignatureModal({
} }
async function save() { async function save() {
if (!isCompleteSmsSignature(name)) {
setError('短信签名必须包含完整中文黑括号,例如:【某某科技】');
return;
}
setSaving(true); setSaving(true);
setError(''); setError('');
try { try {
@@ -138,8 +143,9 @@ function SignatureModal({
} }
const missingRequired = fields.some((field) => field.required && !values[field.code]); const missingRequired = fields.some((field) => field.required && !values[field.code]);
const signatureNameValid = isCompleteSmsSignature(name);
return <Modal return <Modal
footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!name.trim() || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>} footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!signatureNameValid || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>}
onClose={onClose} onClose={onClose}
open open
size="xl" size="xl"
@@ -153,7 +159,15 @@ function SignatureModal({
options={[{ label: '不绑定应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]} options={[{ label: '不绑定应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]}
value={applicationId} value={applicationId}
/> />
<Input label="短信签名" onChange={(event) => setName(event.target.value)} placeholder="例如:某某科技(无需填写【】)" required value={name} /> <Input
error={name.trim() && !signatureNameValid ? '必须填写完整中文黑括号签名,例如:【某某科技】' : undefined}
hint="新增和编辑时都必须保留完整的【】"
label="短信签名"
onChange={(event) => setName(event.target.value)}
placeholder="请输入完整签名,例如:【某某科技】"
required
value={name}
/>
<Input label="使用场景" onChange={(event) => setPurpose(event.target.value)} placeholder="例如:验证码、订单通知" value={purpose ?? ''} /> <Input label="使用场景" onChange={(event) => setPurpose(event.target.value)} placeholder="例如:验证码、订单通知" value={purpose ?? ''} />
<section className="client-signature-form-section"> <section className="client-signature-form-section">
<div><h3></h3><p></p></div> <div><h3></h3><p></p></div>
+2
View File
@@ -6,6 +6,7 @@ const capabilityLabels = [
['uplinkQueryEnabled', '上行查询'], ['uplinkQueryEnabled', '上行查询'],
['receiptWebhookEnabled', '回执回调'], ['receiptWebhookEnabled', '回执回调'],
['uplinkWebhookEnabled', '上行回调'], ['uplinkWebhookEnabled', '上行回调'],
['credentialSelfServiceEnabled', '客户端自助密钥'],
] as const; ] as const;
const deliveryModeLabels: Record<string, string> = { const deliveryModeLabels: Record<string, string> = {
@@ -20,6 +21,7 @@ export function formatHttpApiParams(response: HttpApiConfigResponse, origin: str
const baseUrl = `${origin.replace(/\/$/, '')}/api/openapi/v1`; const baseUrl = `${origin.replace(/\/$/, '')}/api/openapi/v1`;
return [ return [
`应用名称: ${response.applicationName ?? response.applicationId}`, `应用名称: ${response.applicationName ?? response.applicationId}`,
`AppID: ${response.applicationId}`,
`HTTP接口: ${config?.enabled ? '开通' : '关闭'}`, `HTTP接口: ${config?.enabled ? '开通' : '关闭'}`,
`基础地址: ${baseUrl}`, `基础地址: ${baseUrl}`,
`接口文档: ${origin.replace(/\/$/, '')}/api/client-docs`, `接口文档: ${origin.replace(/\/$/, '')}/api/client-docs`,
+6
View File
@@ -5,6 +5,12 @@ export function formatSmsSignature(name?: string | null) {
return innerName ? `${innerName}` : ''; return innerName ? `${innerName}` : '';
} }
export function isCompleteSmsSignature(name?: string | null) {
const value = (name ?? '').trim();
const match = value.match(/^【([^【】]+)】$/);
return Boolean(match && match[1] === match[1].trim());
}
export function replaceLeadingSmsSignature(content: string, signatureName?: string | null) { export function replaceLeadingSmsSignature(content: string, signatureName?: string | null) {
const body = content.replace(LEADING_SMS_SIGNATURE, ''); const body = content.replace(LEADING_SMS_SIGNATURE, '');
return `${formatSmsSignature(signatureName)}${body}`; return `${formatSmsSignature(signatureName)}${body}`;