feat: add application queue priority routing
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "SmsApplication" ADD COLUMN "queuePriority" TEXT NOT NULL DEFAULT 'normal';
|
||||||
|
ALTER TABLE "SmsMessageRecord" ADD COLUMN "queuePriority" TEXT NOT NULL DEFAULT 'normal';
|
||||||
@@ -339,6 +339,7 @@ model SmsApplication {
|
|||||||
secretHash String
|
secretHash String
|
||||||
dailyLimit Int?
|
dailyLimit Int?
|
||||||
customerUnitPrice Int @default(0)
|
customerUnitPrice Int @default(0)
|
||||||
|
queuePriority String @default("normal")
|
||||||
maxPhonesPerTask Int @default(1000000)
|
maxPhonesPerTask Int @default(1000000)
|
||||||
templateMismatchMode String @default("reject")
|
templateMismatchMode String @default("reject")
|
||||||
status String @default("active")
|
status String @default("active")
|
||||||
@@ -852,6 +853,7 @@ model SmsMessageRecord {
|
|||||||
billingUnits Int @default(1)
|
billingUnits Int @default(1)
|
||||||
unitPrice Int @default(0)
|
unitPrice Int @default(0)
|
||||||
amountCents Int @default(0)
|
amountCents Int @default(0)
|
||||||
|
queuePriority String @default("normal")
|
||||||
channelId String?
|
channelId String?
|
||||||
submitId String?
|
submitId String?
|
||||||
gatewayMessageId String?
|
gatewayMessageId String?
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function createPrismaMock() {
|
|||||||
unitPrice: 3,
|
unitPrice: 3,
|
||||||
amountCents: 3,
|
amountCents: 3,
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
|
queuePriority: 'normal',
|
||||||
submitId: 'SUB-1',
|
submitId: 'SUB-1',
|
||||||
gatewayMessageId: 'GW-1',
|
gatewayMessageId: 'GW-1',
|
||||||
channelId: 'channel-1',
|
channelId: 'channel-1',
|
||||||
@@ -58,7 +59,7 @@ function createPrismaMock() {
|
|||||||
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
|
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
|
||||||
},
|
},
|
||||||
smsApplication: {
|
smsApplication: {
|
||||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', status: 'active', customerUnitPrice: 3 }),
|
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', status: 'active', customerUnitPrice: 3, queuePriority: 'normal' }),
|
||||||
},
|
},
|
||||||
smsTemplate: {
|
smsTemplate: {
|
||||||
findUnique: jest.fn().mockResolvedValue({
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
@@ -183,8 +184,8 @@ describe('SendChainService', () => {
|
|||||||
});
|
});
|
||||||
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
||||||
data: expect.arrayContaining([
|
data: expect.arrayContaining([
|
||||||
expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', billingUnits: 1, amountCents: 3 }),
|
expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', billingUnits: 1, amountCents: 3, queuePriority: 'normal' }),
|
||||||
expect.objectContaining({ phoneNumber: '13800000002', status: 'queued', billingUnits: 1, amountCents: 3 }),
|
expect.objectContaining({ phoneNumber: '13800000002', status: 'queued', billingUnits: 1, amountCents: 3, queuePriority: 'normal' }),
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 6, smsUnits: 2, relatedId: 'task-1' }));
|
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 6, smsUnits: 2, relatedId: 'task-1' }));
|
||||||
@@ -309,10 +310,25 @@ describe('SendChainService', () => {
|
|||||||
service['getSendQueue'] = jest.fn().mockReturnValue({ add });
|
service['getSendQueue'] = jest.fn().mockReturnValue({ add });
|
||||||
|
|
||||||
await expect(service.enqueueBatchTask('task-1')).resolves.toEqual({ taskId: 'task-1', enqueued: 1 });
|
await expect(service.enqueueBatchTask('task-1')).resolves.toEqual({ taskId: 'task-1', enqueued: 1 });
|
||||||
expect(add).toHaveBeenCalledWith('send-message', { messageRecordId: 'record-1' }, { jobId: 'record-1', attempts: 3 });
|
expect(add).toHaveBeenCalledWith('send-message', { messageRecordId: 'record-1' }, { jobId: 'record-1', attempts: 3, priority: 100 });
|
||||||
expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({ where: { id: 'task-1' }, data: { status: 'queued' } });
|
expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({ where: { id: 'task-1' }, data: { status: 'queued' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds priority message jobs ahead of normal message jobs', async () => {
|
||||||
|
const { service, prisma } = createService();
|
||||||
|
const add = jest.fn().mockResolvedValue(undefined);
|
||||||
|
prisma.smsMessageRecord.findMany.mockResolvedValue([
|
||||||
|
{ id: 'record-priority', batchTaskId: 'task-1', queuePriority: 'priority' },
|
||||||
|
{ id: 'record-normal', batchTaskId: 'task-1', queuePriority: 'normal' },
|
||||||
|
]);
|
||||||
|
service['getSendQueue'] = jest.fn().mockReturnValue({ add });
|
||||||
|
|
||||||
|
await expect(service.enqueueBatchTask('task-1')).resolves.toEqual({ taskId: 'task-1', enqueued: 2 });
|
||||||
|
|
||||||
|
expect(add).toHaveBeenCalledWith('send-message', { messageRecordId: 'record-priority' }, { jobId: 'record-priority', attempts: 3, priority: 1 });
|
||||||
|
expect(add).toHaveBeenCalledWith('send-message', { messageRecordId: 'record-normal' }, { jobId: 'record-normal', attempts: 3, priority: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
it('routes queued messages to gateway submit commands', async () => {
|
it('routes queued messages to gateway submit commands', async () => {
|
||||||
const { service, prisma } = createService();
|
const { service, prisma } = createService();
|
||||||
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
|
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
|
||||||
@@ -333,6 +349,7 @@ describe('SendChainService', () => {
|
|||||||
messageType: 'SubmitCommand',
|
messageType: 'SubmitCommand',
|
||||||
messageId: 'MSG-1',
|
messageId: 'MSG-1',
|
||||||
channelId: 'channel-1',
|
channelId: 'channel-1',
|
||||||
|
queuePriority: 'normal',
|
||||||
phoneNumber: '13800000001',
|
phoneNumber: '13800000001',
|
||||||
route: expect.objectContaining({ channelCode: 'CMPP-A', rateLimitPerSecond: 100 }),
|
route: expect.objectContaining({ channelCode: 'CMPP-A', rateLimitPerSecond: 100 }),
|
||||||
cmpp: expect.objectContaining({ serviceId: 'SMS', srcId: '10690000' }),
|
cmpp: expect.objectContaining({ serviceId: 'SMS', srcId: '10690000' }),
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ interface SendJob {
|
|||||||
messageRecordId: string;
|
messageRecordId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QueuePriority = 'normal' | 'priority';
|
||||||
|
|
||||||
type RoutedChannel = {
|
type RoutedChannel = {
|
||||||
channel: {
|
channel: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -101,6 +103,10 @@ type RoutedChannel = {
|
|||||||
|
|
||||||
const SEND_QUEUE = 'sms.send.queue';
|
const SEND_QUEUE = 'sms.send.queue';
|
||||||
const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue';
|
const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue';
|
||||||
|
const BULLMQ_PRIORITY: Record<QueuePriority, number> = {
|
||||||
|
priority: 1,
|
||||||
|
normal: 100,
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||||
@@ -133,6 +139,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const schedule = parseSchedule(data);
|
const schedule = parseSchedule(data);
|
||||||
await this.validateSendResources(data.tenantId, data.applicationId, data.templateId);
|
await this.validateSendResources(data.tenantId, data.applicationId, data.templateId);
|
||||||
const unitPrice = await this.resolveUnitPrice(data.tenantId, data.applicationId);
|
const unitPrice = await this.resolveUnitPrice(data.tenantId, data.applicationId);
|
||||||
|
const queuePriority = await this.resolveQueuePriority(data.tenantId, data.applicationId);
|
||||||
const risk = await this.riskReview.evaluateTask({
|
const risk = await this.riskReview.evaluateTask({
|
||||||
tenantId: data.tenantId,
|
tenantId: data.tenantId,
|
||||||
applicationId: data.applicationId,
|
applicationId: data.applicationId,
|
||||||
@@ -223,6 +230,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
billingUnits: billing.billingUnitsPerMessage,
|
billingUnits: billing.billingUnitsPerMessage,
|
||||||
unitPrice: billing.unitPrice,
|
unitPrice: billing.unitPrice,
|
||||||
amountCents: billing.billingUnitsPerMessage * billing.unitPrice,
|
amountCents: billing.billingUnitsPerMessage * billing.unitPrice,
|
||||||
|
queuePriority,
|
||||||
status: batchStatus === 'ready' ? 'queued' : batchStatus === 'scheduled' ? 'scheduled' : batchStatus,
|
status: batchStatus === 'ready' ? 'queued' : batchStatus === 'scheduled' ? 'scheduled' : batchStatus,
|
||||||
errorMessage: risk.status === 'rejected' ? risk.reason ?? undefined : undefined,
|
errorMessage: risk.status === 'rejected' ? risk.reason ?? undefined : undefined,
|
||||||
})),
|
})),
|
||||||
@@ -365,12 +373,17 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
const messages = await this.prisma.smsMessageRecord.findMany({
|
const messages = await this.prisma.smsMessageRecord.findMany({
|
||||||
where: { batchTaskId: taskId, status: 'queued' },
|
where: { batchTaskId: taskId, status: 'queued' },
|
||||||
select: { id: true },
|
select: { id: true, queuePriority: true },
|
||||||
take: 100000,
|
take: 100000,
|
||||||
});
|
});
|
||||||
const queue = this.getSendQueue();
|
const queue = this.getSendQueue();
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
await queue.add('send-message', { messageRecordId: message.id }, { jobId: message.id, attempts: 3 });
|
const queuePriority = normalizeQueuePriority(message.queuePriority);
|
||||||
|
await queue.add('send-message', { messageRecordId: message.id }, {
|
||||||
|
jobId: message.id,
|
||||||
|
attempts: 3,
|
||||||
|
priority: BULLMQ_PRIORITY[queuePriority],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'queued' } });
|
await this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'queued' } });
|
||||||
return { taskId, enqueued: messages.length };
|
return { taskId, enqueued: messages.length };
|
||||||
@@ -653,6 +666,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
content: string;
|
content: string;
|
||||||
billingUnits: number;
|
billingUnits: number;
|
||||||
|
queuePriority?: string | null;
|
||||||
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
|
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
|
||||||
},
|
},
|
||||||
routed: RoutedChannel,
|
routed: RoutedChannel,
|
||||||
@@ -701,6 +715,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
applicationId: message.applicationId ?? 'unknown',
|
applicationId: message.applicationId ?? 'unknown',
|
||||||
taskId: message.batchTaskId,
|
taskId: message.batchTaskId,
|
||||||
submitId,
|
submitId,
|
||||||
|
queuePriority: normalizeQueuePriority(message.queuePriority),
|
||||||
phoneNumber: message.phoneNumber,
|
phoneNumber: message.phoneNumber,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
signature: message.template?.signature?.name ?? 'SMS',
|
signature: message.template?.signature?.name ?? 'SMS',
|
||||||
@@ -882,6 +897,20 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return application.customerUnitPrice ?? 0;
|
return application.customerUnitPrice ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
|
||||||
|
if (!applicationId) {
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
const application = await this.prisma.smsApplication.findUnique({
|
||||||
|
where: { id: applicationId },
|
||||||
|
select: { tenantId: true, queuePriority: true },
|
||||||
|
});
|
||||||
|
if (!application || application.tenantId !== tenantId) {
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
return normalizeQueuePriority(application.queuePriority);
|
||||||
|
}
|
||||||
|
|
||||||
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
|
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
|
||||||
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
|
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
|
||||||
if (!tenant || tenant.status !== 'active') {
|
if (!tenant || tenant.status !== 'active') {
|
||||||
@@ -1202,6 +1231,10 @@ function normalizeCarrier(carrier?: string | null) {
|
|||||||
return value || 'mobile';
|
return value || 'mobile';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeQueuePriority(queuePriority?: string | null): QueuePriority {
|
||||||
|
return queuePriority === 'priority' ? 'priority' : 'normal';
|
||||||
|
}
|
||||||
|
|
||||||
function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string) {
|
function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string) {
|
||||||
const normalized = normalizeCarrier(channelCarrier);
|
const normalized = normalizeCarrier(channelCarrier);
|
||||||
return normalized === 'all' || normalized === targetCarrier;
|
return normalized === 'all' || normalized === targetCarrier;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ function createPrismaMock() {
|
|||||||
tenantId: 'tenant-1',
|
tenantId: 'tenant-1',
|
||||||
name: '应用A',
|
name: '应用A',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
queuePriority: 'normal',
|
||||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||||
messageRecords: [{ status: 'delivered' }, { status: 'undelivered' }],
|
messageRecords: [{ status: 'delivered' }, { status: 'undelivered' }],
|
||||||
}]),
|
}]),
|
||||||
@@ -16,6 +17,7 @@ function createPrismaMock() {
|
|||||||
tenantId: 'tenant-1',
|
tenantId: 'tenant-1',
|
||||||
name: '应用A',
|
name: '应用A',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
queuePriority: 'normal',
|
||||||
secretHash: 'secret-hash',
|
secretHash: 'secret-hash',
|
||||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||||
}),
|
}),
|
||||||
@@ -147,6 +149,7 @@ describe('SmsConfigService', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 'app-1',
|
id: 'app-1',
|
||||||
cmppStatus: 'connected',
|
cmppStatus: 'connected',
|
||||||
|
queuePriority: 'normal',
|
||||||
sentToday: 2,
|
sentToday: 2,
|
||||||
deliveryRate: 50,
|
deliveryRate: 50,
|
||||||
cmppConnections: [expect.objectContaining({ connectionId: 'conn-a' })],
|
cmppConnections: [expect.objectContaining({ connectionId: 'conn-a' })],
|
||||||
@@ -167,6 +170,40 @@ describe('SmsConfigService', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates enterprise applications with persisted queue priority', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const service = new SmsConfigService(prisma as never);
|
||||||
|
|
||||||
|
await expect(service.createApplication({
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
name: '优先应用',
|
||||||
|
queuePriority: 'priority',
|
||||||
|
ipAllowlist: ['10.0.0.1/32'],
|
||||||
|
})).resolves.toEqual(expect.objectContaining({ id: 'app-new' }));
|
||||||
|
|
||||||
|
expect(prisma.smsApplication.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
name: '优先应用',
|
||||||
|
queuePriority: 'priority',
|
||||||
|
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid enterprise application queue priority', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const service = new SmsConfigService(prisma as never);
|
||||||
|
|
||||||
|
expect(() => service.createApplication({
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
name: '异常应用',
|
||||||
|
queuePriority: 'urgent',
|
||||||
|
})).toThrow('queuePriority must be normal or priority');
|
||||||
|
|
||||||
|
expect(prisma.smsApplication.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('updates enterprise application profile and allowlist through a transaction', async () => {
|
it('updates enterprise application profile and allowlist through a transaction', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const tx = {
|
const tx = {
|
||||||
@@ -181,7 +218,7 @@ describe('SmsConfigService', () => {
|
|||||||
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
|
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
|
||||||
const service = new SmsConfigService(prisma as never);
|
const service = new SmsConfigService(prisma as never);
|
||||||
|
|
||||||
await expect(service.updateApplication('app-1', { name: '新应用', customerUnitPrice: 300, ipAllowlist: ['10.0.0.1/32'] }))
|
await expect(service.updateApplication('app-1', { name: '新应用', customerUnitPrice: 300, queuePriority: 'priority', ipAllowlist: ['10.0.0.1/32'] }))
|
||||||
.resolves.toEqual(expect.objectContaining({ id: 'app-1', name: '新应用' }));
|
.resolves.toEqual(expect.objectContaining({ id: 'app-1', name: '新应用' }));
|
||||||
|
|
||||||
expect(tx.smsApplicationIpAllowlist.deleteMany).toHaveBeenCalledWith({ where: { applicationId: 'app-1' } });
|
expect(tx.smsApplicationIpAllowlist.deleteMany).toHaveBeenCalledWith({ where: { applicationId: 'app-1' } });
|
||||||
@@ -190,6 +227,7 @@ describe('SmsConfigService', () => {
|
|||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
name: '新应用',
|
name: '新应用',
|
||||||
customerUnitPrice: 300,
|
customerUnitPrice: 300,
|
||||||
|
queuePriority: 'priority',
|
||||||
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
|
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface CreateSmsApplicationDto {
|
|||||||
callbackUrl?: string;
|
callbackUrl?: string;
|
||||||
dailyLimit?: number;
|
dailyLimit?: number;
|
||||||
customerUnitPrice?: number;
|
customerUnitPrice?: number;
|
||||||
|
queuePriority?: string;
|
||||||
maxPhonesPerTask?: number;
|
maxPhonesPerTask?: number;
|
||||||
templateMismatchMode?: string;
|
templateMismatchMode?: string;
|
||||||
ipAllowlist?: string[];
|
ipAllowlist?: string[];
|
||||||
@@ -85,6 +86,9 @@ export interface ApplicationListQuery {
|
|||||||
includeConnections?: boolean;
|
includeConnections?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const APPLICATION_QUEUE_PRIORITIES = ['normal', 'priority'] as const;
|
||||||
|
type ApplicationQueuePriority = typeof APPLICATION_QUEUE_PRIORITIES[number];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsConfigService {
|
export class SmsConfigService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -148,6 +152,7 @@ export class SmsConfigService {
|
|||||||
|
|
||||||
createApplication(data: CreateSmsApplicationDto) {
|
createApplication(data: CreateSmsApplicationDto) {
|
||||||
const secret = randomBytes(24).toString('hex');
|
const secret = randomBytes(24).toString('hex');
|
||||||
|
const queuePriority = normalizeApplicationQueuePriority(data.queuePriority);
|
||||||
return this.prisma.smsApplication.create({
|
return this.prisma.smsApplication.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId: data.tenantId,
|
tenantId: data.tenantId,
|
||||||
@@ -157,6 +162,7 @@ export class SmsConfigService {
|
|||||||
secretHash: hashSecret(secret),
|
secretHash: hashSecret(secret),
|
||||||
dailyLimit: data.dailyLimit,
|
dailyLimit: data.dailyLimit,
|
||||||
customerUnitPrice: data.customerUnitPrice ?? 0,
|
customerUnitPrice: data.customerUnitPrice ?? 0,
|
||||||
|
queuePriority,
|
||||||
maxPhonesPerTask: data.maxPhonesPerTask ?? 1000000,
|
maxPhonesPerTask: data.maxPhonesPerTask ?? 1000000,
|
||||||
templateMismatchMode: data.templateMismatchMode ?? 'reject',
|
templateMismatchMode: data.templateMismatchMode ?? 'reject',
|
||||||
ipAllowlist: {
|
ipAllowlist: {
|
||||||
@@ -172,6 +178,9 @@ export class SmsConfigService {
|
|||||||
if (!application) {
|
if (!application) {
|
||||||
throw new NotFoundException('Application not found');
|
throw new NotFoundException('Application not found');
|
||||||
}
|
}
|
||||||
|
const queuePriority = data.queuePriority === undefined
|
||||||
|
? undefined
|
||||||
|
: normalizeApplicationQueuePriority(data.queuePriority);
|
||||||
|
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
if (data.ipAllowlist) {
|
if (data.ipAllowlist) {
|
||||||
@@ -185,6 +194,7 @@ export class SmsConfigService {
|
|||||||
callbackUrl: data.callbackUrl,
|
callbackUrl: data.callbackUrl,
|
||||||
dailyLimit: data.dailyLimit,
|
dailyLimit: data.dailyLimit,
|
||||||
customerUnitPrice: data.customerUnitPrice,
|
customerUnitPrice: data.customerUnitPrice,
|
||||||
|
queuePriority,
|
||||||
maxPhonesPerTask: data.maxPhonesPerTask,
|
maxPhonesPerTask: data.maxPhonesPerTask,
|
||||||
templateMismatchMode: data.templateMismatchMode,
|
templateMismatchMode: data.templateMismatchMode,
|
||||||
status: data.status,
|
status: data.status,
|
||||||
@@ -738,6 +748,14 @@ function startOfToday() {
|
|||||||
return date;
|
return date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeApplicationQueuePriority(value?: string): ApplicationQueuePriority {
|
||||||
|
const queuePriority = value ?? 'normal';
|
||||||
|
if (!APPLICATION_QUEUE_PRIORITIES.includes(queuePriority as ApplicationQueuePriority)) {
|
||||||
|
throw new BadRequestException('queuePriority must be normal or priority');
|
||||||
|
}
|
||||||
|
return queuePriority as ApplicationQueuePriority;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeApplicationCmppStatus(connections: Array<{ status: string; currentConnections: number }>, applicationStatus: string) {
|
function normalizeApplicationCmppStatus(connections: Array<{ status: string; currentConnections: number }>, applicationStatus: string) {
|
||||||
if (applicationStatus !== 'active') {
|
if (applicationStatus !== 'active') {
|
||||||
return 'inactive';
|
return 'inactive';
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"signature": "测试平台",
|
"signature": "测试平台",
|
||||||
"templateId": "tpl-demo-code",
|
"templateId": "tpl-demo-code",
|
||||||
"billingUnits": 1,
|
"billingUnits": 1,
|
||||||
|
"queuePriority": "priority",
|
||||||
"route": {
|
"route": {
|
||||||
"channelCode": "CMCC-CMPP-DEMO",
|
"channelCode": "CMCC-CMPP-DEMO",
|
||||||
"cmppAccountCode": "cmpp-account-demo",
|
"cmppAccountCode": "cmpp-account-demo",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"signature",
|
"signature",
|
||||||
"templateId",
|
"templateId",
|
||||||
"billingUnits",
|
"billingUnits",
|
||||||
|
"queuePriority",
|
||||||
"route",
|
"route",
|
||||||
"cmpp",
|
"cmpp",
|
||||||
"retry"
|
"retry"
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
"signature": { "type": "string", "minLength": 1 },
|
"signature": { "type": "string", "minLength": 1 },
|
||||||
"templateId": { "type": "string", "minLength": 1 },
|
"templateId": { "type": "string", "minLength": 1 },
|
||||||
"billingUnits": { "type": "integer", "minimum": 1 },
|
"billingUnits": { "type": "integer", "minimum": 1 },
|
||||||
|
"queuePriority": { "enum": ["normal", "priority"] },
|
||||||
"route": {
|
"route": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["channelCode", "cmppAccountCode", "priority"],
|
"required": ["channelCode", "cmppAccountCode", "priority"],
|
||||||
|
|||||||
@@ -102,6 +102,9 @@
|
|||||||
2. 运营端可查看企业应用,并支持按企业名称、应用名称、状态搜索。
|
2. 运营端可查看企业应用,并支持按企业名称、应用名称、状态搜索。
|
||||||
3. 运营端可启用、停用、编辑应用。
|
3. 运营端可启用、停用、编辑应用。
|
||||||
4. 应用必须归属企业,并关联后续签名、模板和发送任务。
|
4. 应用必须归属企业,并关联后续签名、模板和发送任务。
|
||||||
|
5. 短信应用必须配置发送队列等级:普通队列或优先队列。未配置时默认普通队列;优先队列用于验证码、登录确认、交易通知等高时效短信,普通队列用于营销、通知等常规短信。
|
||||||
|
6. 发送队列等级属于真实业务配置,必须保存到后端数据库,并在客户端、运营端创建/编辑应用时展示和可修改;不得只作为前端展示字段。
|
||||||
|
7. 运营端代企业新增短信应用时,第一步选择企业必须使用项目通用 Select/下拉控件,选项来自真实企业 API,支持加载中、空数据、错误态,不允许写死企业列表。
|
||||||
|
|
||||||
### 4.3 签名与引流信息
|
### 4.3 签名与引流信息
|
||||||
|
|
||||||
@@ -130,6 +133,9 @@
|
|||||||
9. 平台批量任务只记录客户端创建的发送任务;API 调用和 CMPP 对接发送不进入批量任务。
|
9. 平台批量任务只记录客户端创建的发送任务;API 调用和 CMPP 对接发送不进入批量任务。
|
||||||
10. 所有来源的短信,包括平台批量任务、API 调用、CMPP 对接发送,全部按手机号维度进入短信记录。
|
10. 所有来源的短信,包括平台批量任务、API 调用、CMPP 对接发送,全部按手机号维度进入短信记录。
|
||||||
11. 任务进度、发送详情和短信记录实时或准实时更新。
|
11. 任务进度、发送详情和短信记录实时或准实时更新。
|
||||||
|
12. 发送入队必须按短信应用的队列等级分流到优先队列或普通队列;同等条件下优先队列消息必须先于普通队列消息被 Send Worker 消费并提交 Gateway。
|
||||||
|
13. 优先队列只能改变待发送消息的调度顺序,不得绕过企业/应用状态、签名模板审核、通道报备、余额/授信、黑名单、风控、通道组路由、通道限速和 Gateway 连接可用性校验。
|
||||||
|
14. 同一队列内部按创建时间、任务顺序和手机号拆分顺序保持 FIFO 或可解释的稳定排序;优先队列插队时必须可在 trace 或任务日志中追踪队列等级和入队时间。
|
||||||
|
|
||||||
### 4.6 通道配置与路由
|
### 4.6 通道配置与路由
|
||||||
|
|
||||||
@@ -169,7 +175,65 @@
|
|||||||
8. 报备记录保留每次导出、导入、状态变更和操作人。
|
8. 报备记录保留每次导出、导入、状态变更和操作人。
|
||||||
9. 发送前必须校验最终选中通道上的签名报备任务为 approved;补发切换到新通道时必须重新按新通道校验报备状态,未通过则该次发送失败。
|
9. 发送前必须校验最终选中通道上的签名报备任务为 approved;补发切换到新通道时必须重新按新通道校验报备状态,未通过则该次发送失败。
|
||||||
|
|
||||||
### 4.8 回执与上行
|
### 4.8 CMPP Gateway 与外部接入
|
||||||
|
|
||||||
|
第一版发送链路必须区分两类 CMPP 连接,不允许用页面状态、HTTP 占位或模拟器结果替代真实协议能力:
|
||||||
|
|
||||||
|
- 上游通道连接:平台作为 SP 客户端,连接运营商或供应商 SMSC,将平台已路由的短信提交到目标通道。
|
||||||
|
- 下游客户接入:企业客户作为外部 SP 客户端,连接平台暴露的 CMPP 监听端口(生产端口 `17890`),通过 CMPP 协议向平台提交短信。
|
||||||
|
|
||||||
|
#### 4.8.1 上游通道连接能力
|
||||||
|
|
||||||
|
1. Gateway 必须按运营端通道配置连接上游 SMSC,使用通道的 `gatewayHost/gatewayPort/account/passwordCipher/srcId/cmppVersion` 完成 CMPP 2.0/3.0 connect/login。
|
||||||
|
2. Gateway 必须校验上游 connect/login 返回码,区分 connected、auth_failed、connect_timeout、network_error、protocol_error 等状态,并回写 NestJS 真实连接状态。
|
||||||
|
3. Gateway 必须支持每个通道配置期望连接数,建立多条长连接,并按连接维度维护 currentConnections、lastConnectedAt、lastHeartbeatAt、lastError、reconnectCount。
|
||||||
|
4. Gateway 必须实现 ActiveTest 心跳与超时检测;连续心跳失败后连接进入 heartbeat_timeout/reconnecting,重连成功前该连接不可参与发送。
|
||||||
|
5. Gateway 必须支持断线自动重连、指数退避或固定退避、最大重试间隔、重连日志和状态回写。
|
||||||
|
6. Gateway 必须维护 CMPP sequenceId 与平台 messageId、submitId、channelId 的映射,submit resp 和 deliver 回执必须能追溯到原短信记录和提交尝试。
|
||||||
|
7. Gateway 必须实现真实 CMPP Submit,包括短信内容编码、长短信拆分、RegisteredDelivery、serviceId、srcId、destTerminalId、msgFmt、feeType/feeCode 等字段映射。
|
||||||
|
8. Gateway 必须消费 NestJS 投递的 `SubmitCommand` 队列或等价内部接口;提交成功、提交失败、超时均必须回传 `SubmitResult`,不得只停留在 API 侧入队。
|
||||||
|
9. Gateway 必须按通道连接和窗口容量控制并发,处理窗口满、SMSC 慢响应、sequence 回绕、连接断开时的在途消息状态。
|
||||||
|
10. Gateway 不承担业务审核、计费、签名报备、通道组路由、黑名单或敏感词判断;这些由 NestJS 完成,Gateway 只执行已授权通道提交与协议事件回传。
|
||||||
|
|
||||||
|
#### 4.8.2 下游客户 CMPP 接入能力
|
||||||
|
|
||||||
|
1. Gateway 必须监听生产 CMPP 端口 `17890`,作为平台侧 CMPP Server 接收企业客户系统连接;该端口不是 HTTP 健康检查或控制接口。
|
||||||
|
2. Gateway 必须按企业应用生成的 CMPP 接入参数校验客户 connect/login,包括企业代码、账号、密码、CMPP 版本、源 IP 白名单、应用状态、企业状态、连接数上限。
|
||||||
|
3. 客户端应用的 IP 白名单必须对 CMPP 下游连接生效;未命中白名单、应用停用、企业停用、密码错误、超过连接数上限时必须拒绝连接并记录系统日志。
|
||||||
|
4. Gateway 必须维护应用级下游连接状态,回写 applicationId、tenantId、connectionId、currentConnections、desiredConnections、lastHeartbeatAt、lastError,运营端企业应用列表和连接详情必须来自这些真实状态。
|
||||||
|
5. Gateway 必须实现下游 ActiveTest、Terminate、异常断开处理;断开后连接数和状态必须及时回写。
|
||||||
|
6. Gateway 必须处理客户提交的 CMPP Submit,将手机号、内容、源地址、企业应用、客户消息序号等转换为平台发送请求。
|
||||||
|
7. 下游 CMPP Submit 进入平台后,不创建客户端批量任务,但必须按手机号维度创建 `sms_message_record`,source 标记为 `cmpp`,并保留客户侧 sequence/msgId 映射。
|
||||||
|
8. 下游 CMPP Submit 必须复用 NestJS 发送前校验:企业/应用状态、IP 白名单、签名/模板报备、模板匹配策略、风控、黑名单、余额/授信、运营商识别、通道组路由。
|
||||||
|
9. 对客户 Submit 的响应必须符合 CMPP 协议:参数错误、鉴权失败、余额不足、模板或签名未通过、无可用通道、风控拒绝等应映射为明确失败状态;已接收进入平台发送链路时返回成功并生成可追踪平台 messageId。
|
||||||
|
10. Gateway 必须支持平台最终回执向下游客户连接投递 Deliver Receipt;若客户连接已断开,应按策略缓存、重试或记录投递失败,不能丢失平台最终状态。
|
||||||
|
11. Gateway 必须支持下游客户上行接入场景:收到运营商上行后,按接入号、手机号、应用、时间窗口匹配并向客户连接推送 Deliver,上行同时入库。
|
||||||
|
12. 下游客户连接与上游通道连接必须隔离管理:客户侧账号密码不能用于连接上游通道,上游通道账号密码也不能作为客户接入凭据。
|
||||||
|
|
||||||
|
#### 4.8.3 回执、上行与幂等
|
||||||
|
|
||||||
|
1. Gateway 必须解析上游 deliver receipt,将 DELIVRD、UNDELIV、EXPIRED、REJECTD 等供应商状态归一化为平台 delivered、failed、unknown、timeout 等状态。
|
||||||
|
2. Gateway 必须解析上游普通 deliver 上行短信,携带手机号、接入号、内容、接收时间、通道和原始报文摘要回传 NestJS。
|
||||||
|
3. Gateway 回传 `SubmitResult`、`ReceiptEvent`、`UplinkEvent` 时必须包含 traceId、messageId、submitId 或可映射字段、channelId、gatewayMessageId、sequenceId,保证短信详情能展示完整历史尝试。
|
||||||
|
4. 重复 submit resp、重复 receipt、迟到旧通道 receipt 必须交由 NestJS 幂等处理;Gateway 不得因本地缓存丢失而生成无法追踪的重复业务事件。
|
||||||
|
5. Gateway 需要保留最小运行日志和指标:连接数、登录失败次数、心跳失败次数、submit TPS、submit resp 延迟、receipt 延迟、队列积压、重连次数、协议错误。
|
||||||
|
6. Gateway 控制服务 `/health` 只能表示进程存活;真实验收必须检查上下游连接状态、队列消费、submit/receipt/uplink 事件闭环。
|
||||||
|
|
||||||
|
#### 4.8.4 当前实现缺口标记
|
||||||
|
|
||||||
|
截至当前版本,Go Gateway 已有 HTTP 控制服务、健康检查、连接上游 SMSC 的 `ConnectChannel` 控制入口、gocmpp 协议 spike 和队列消息结构;但仍缺少生产验收所需的完整能力:
|
||||||
|
|
||||||
|
- 未实现 `17890` 入站 CMPP Server 监听。
|
||||||
|
- 未实现下游客户 connect/login 鉴权、IP 白名单、应用级连接数限制和连接状态回写。
|
||||||
|
- 未实现下游 CMPP Submit 到平台发送请求的转换。
|
||||||
|
- 未实现客户侧 SubmitResp、最终 Deliver Receipt 和上行 Deliver 投递。
|
||||||
|
- 未实现 Gateway 消费 `SubmitCommand` 并真实 submit 到上游通道的 worker。
|
||||||
|
- 未实现上游 deliver receipt 和普通上行 deliver 的生产解析与事件回传闭环。
|
||||||
|
- 未实现多连接窗口管理、在途消息恢复、断线重连后的消息状态处理。
|
||||||
|
|
||||||
|
这些缺口未补齐前,不能把 CMPP 对接发送、17890 端口联调、客户账号密码鉴权、客户 IP 白名单或真实网关 submit/receipt/uplink 作为“生产已验收通过”。
|
||||||
|
|
||||||
|
### 4.9 回执与上行
|
||||||
|
|
||||||
1. 通道回执接入后更新发送记录状态。
|
1. 通道回执接入后更新发送记录状态。
|
||||||
2. 回执状态至少包括提交成功、提交失败、发送成功、发送失败、未知、超时。
|
2. 回执状态至少包括提交成功、提交失败、发送成功、发送失败、未知、超时。
|
||||||
@@ -182,7 +246,7 @@
|
|||||||
9. 完全匹配不到的上行短信仍需入库,并展示为未匹配。
|
9. 完全匹配不到的上行短信仍需入库,并展示为未匹配。
|
||||||
10. 客户端可查看本企业上行短信,运营端可查看全平台上行短信。
|
10. 客户端可查看本企业上行短信,运营端可查看全平台上行短信。
|
||||||
|
|
||||||
### 4.9 账户计费
|
### 4.10 账户计费
|
||||||
|
|
||||||
1. 客户端可查看充值套餐、购买或申请充值套餐、查看账单流水。
|
1. 客户端可查看充值套餐、购买或申请充值套餐、查看账单流水。
|
||||||
2. 发送创建时按短信内容计费条数、企业应用客户单价或套餐规则生成预估费用,计费条数只按 70/67 字规则拆分;不按移动、联通、电信配置不同客户价。
|
2. 发送创建时按短信内容计费条数、企业应用客户单价或套餐规则生成预估费用,计费条数只按 70/67 字规则拆分;不按移动、联通、电信配置不同客户价。
|
||||||
@@ -236,6 +300,7 @@
|
|||||||
|
|
||||||
- 支持新增、编辑、启用、停用应用。
|
- 支持新增、编辑、启用、停用应用。
|
||||||
- 支持配置回调地址、IP 白名单、应用密钥、日发送限额。
|
- 支持配置回调地址、IP 白名单、应用密钥、日发送限额。
|
||||||
|
- 支持配置发送队列等级:普通队列、优先队列;默认普通队列,变更后只影响新创建或新入队的发送消息。
|
||||||
- 支持配置“不符合模板的短信”处理策略:拒绝发送、人工审核、直接发送。
|
- 支持配置“不符合模板的短信”处理策略:拒绝发送、人工审核、直接发送。
|
||||||
- 支持配置应用级风控阈值:单任务最大号码数、重复号码比例、非法号码比例、黑名单命中比例、非工作时间大批量发送策略、短时间任务创建频控。
|
- 支持配置应用级风控阈值:单任务最大号码数、重复号码比例、非法号码比例、黑名单命中比例、非工作时间大批量发送策略、短时间任务创建频控。
|
||||||
- 默认阈值:重复号码比例 30%、非法号码比例 10%、黑名单命中比例 5%、同一企业/应用 10 分钟内最多创建 10 次任务。
|
- 默认阈值:重复号码比例 30%、非法号码比例 10%、黑名单命中比例 5%、同一企业/应用 10 分钟内最多创建 10 次任务。
|
||||||
@@ -279,6 +344,8 @@
|
|||||||
- 企业应用列表展示 CMPP 连接数;点击连接数打开连接详情弹窗,内容包含连接 id、状态、连接建立时间、最近心跳时间、上次提交时间、窗口占用等。
|
- 企业应用列表展示 CMPP 连接数;点击连接数打开连接详情弹窗,内容包含连接 id、状态、连接建立时间、最近心跳时间、上次提交时间、窗口占用等。
|
||||||
- 企业应用连接详情支持删除连接;删除连接必须调用真实后端接口或 Gateway 回写接口,并写入系统日志。
|
- 企业应用连接详情支持删除连接;删除连接必须调用真实后端接口或 Gateway 回写接口,并写入系统日志。
|
||||||
- 企业应用列表提供 CMPP 连接参数查看与一键复制能力,参数来源于真实应用/通道配置,不允许只在前端拼接假数据。
|
- 企业应用列表提供 CMPP 连接参数查看与一键复制能力,参数来源于真实应用/通道配置,不允许只在前端拼接假数据。
|
||||||
|
- 企业应用新增/编辑必须展示发送队列等级,并真实保存普通队列或优先队列配置;列表或详情应能看到该配置,便于运营核对高优先级应用。
|
||||||
|
- 企业应用新增时选择企业必须使用通用下拉控件和真实企业接口;下拉控件的视觉、尺寸、禁用态、错误态应与系统内其他 Select 保持一致。
|
||||||
|
|
||||||
### 5.12 运营端审核
|
### 5.12 运营端审核
|
||||||
|
|
||||||
@@ -432,6 +499,7 @@
|
|||||||
- Go CMPP Gateway 作为独立服务部署,不和 NestJS 业务后台耦合。
|
- Go CMPP Gateway 作为独立服务部署,不和 NestJS 业务后台耦合。
|
||||||
- 第一版支持真实 CMPP 2.0/3.0 长连接能力,后续可扩展 SGIP、SMGP、HTTP 通道。
|
- 第一版支持真实 CMPP 2.0/3.0 长连接能力,后续可扩展 SGIP、SMGP、HTTP 通道。
|
||||||
- 网关负责通道连接、登录认证、长连接保活、submit、deliver、active test、重连、滑动窗口、sequence 管理、回执与上行接收。
|
- 网关负责通道连接、登录认证、长连接保活、submit、deliver、active test、重连、滑动窗口、sequence 管理、回执与上行接收。
|
||||||
|
- 网关必须同时覆盖上游通道客户端能力和下游客户服务端能力;两类连接的账号、密码、IP 白名单、连接数、状态回写和消息映射必须隔离管理。
|
||||||
- NestJS 业务后台负责企业、应用、模板、签名、报备、审核、计费、风控、路由决策和发送任务编排。
|
- NestJS 业务后台负责企业、应用、模板、签名、报备、审核、计费、风控、路由决策和发送任务编排。
|
||||||
- NestJS 与 Go Gateway 之间通过 Redis/BullMQ 队列或内部 gRPC/HTTP 通信;第一版建议使用队列提交发送指令、队列回传提交结果和回执事件。
|
- NestJS 与 Go Gateway 之间通过 Redis/BullMQ 队列或内部 gRPC/HTTP 通信;第一版建议使用队列提交发送指令、队列回传提交结果和回执事件。
|
||||||
- Go Gateway 不直接承担业务审核、账务扣费、签名报备判断,只执行已被业务后台路由后的通道提交。
|
- Go Gateway 不直接承担业务审核、账务扣费、签名报备判断,只执行已被业务后台路由后的通道提交。
|
||||||
@@ -483,24 +551,27 @@
|
|||||||
|
|
||||||
1. API 创建批量任务。
|
1. API 创建批量任务。
|
||||||
2. 拆分号码和变量,生成 message_record。
|
2. 拆分号码和变量,生成 message_record。
|
||||||
3. 写入数据库并投递 send.queue。
|
3. 写入数据库并准备投递发送队列。
|
||||||
4. Send Worker 消费消息,校验黑名单、敏感词、限额。
|
4. 根据短信应用的队列等级写入 priority_send.queue 或 normal_send.queue,或在同一 BullMQ 队列中写入可验证的 priority 值;数据库中的 message_record/submit trace 必须保留队列等级。
|
||||||
5. 使用可配置号码前缀正则识别运营商;识别不出时按移动处理。
|
5. Send Worker 优先消费优先队列,再消费普通队列;多实例部署时必须避免普通队列持续抢占导致优先队列失效。
|
||||||
6. 使用手机号段库识别号码省份和城市;识别不出省份时按全国路由处理。
|
6. Send Worker 消费消息,校验黑名单、敏感词、限额。
|
||||||
7. 按企业应用绑定的对应运营商通道组执行路由:通道组只能是移动、联通、电信之一,发送时必须同时满足路由规则运营商、通道组运营商、通道组明细 carrier 与号码识别运营商一致;再校验通道本体 carrier 为对应运营商或三网;最后先匹配省网通道,再匹配全国通道,不得直接绑定或 fallback 到非授权单通道。
|
7. 使用可配置号码前缀正则识别运营商;识别不出时按移动处理。
|
||||||
8. 过滤业务 disabled、连接离线、认证失败、心跳超时或无可用连接数的通道。
|
8. 使用手机号段库识别号码省份和城市;识别不出省份时按全国路由处理。
|
||||||
9. 通过通道限速器控制 TPS。
|
9. 按企业应用绑定的对应运营商通道组执行路由:通道组只能是移动、联通、电信之一,发送时必须同时满足路由规则运营商、通道组运营商、通道组明细 carrier 与号码识别运营商一致;再校验通道本体 carrier 为对应运营商或三网;最后先匹配省网通道,再匹配全国通道,不得直接绑定或 fallback 到非授权单通道。
|
||||||
10. 调用 Gateway Adapter 提交短信。
|
10. 过滤业务 disabled、连接离线、认证失败、心跳超时或无可用连接数的通道。
|
||||||
11. 写入 submit 状态。
|
11. 通过通道限速器控制 TPS;优先队列不得突破通道配置的供应商 TPS 和连接窗口上限。
|
||||||
12. Submit rejected、submit timeout、Gateway 连接断开或未提交成功、receipt failed 等场景按通道组策略补发到下一可用全国通道;unknown、超过 72 小时、超过通道组补发时间上限或关闭补发时不再补发。
|
12. 调用 Gateway Adapter 提交短信。
|
||||||
13. Receipt Worker 接收回执并更新最终状态。
|
13. 写入 submit 状态。
|
||||||
14. 补发过程必须保持幂等、计费一致和 trace 可查,最终成功只扣一次客户费率。
|
14. Submit rejected、submit timeout、Gateway 连接断开或未提交成功、receipt failed 等场景按通道组策略补发到下一可用全国通道;unknown、超过 72 小时、超过通道组补发时间上限或关闭补发时不再补发。
|
||||||
15. 统计任务进度。
|
15. Receipt Worker 接收回执并更新最终状态。
|
||||||
|
16. 补发过程必须保持幂等、计费一致和 trace 可查,最终成功只扣一次客户费率。
|
||||||
|
17. 统计任务进度。
|
||||||
|
|
||||||
### 8.3 500 条/秒实现建议
|
### 8.3 500 条/秒实现建议
|
||||||
|
|
||||||
- 创建任务与发送解耦,提交接口只负责入库和投递队列。
|
- 创建任务与发送解耦,提交接口只负责入库和投递队列。
|
||||||
- Send Worker 多实例部署,每实例处理固定通道分片或队列分片。
|
- Send Worker 多实例部署,每实例处理固定通道分片或队列分片。
|
||||||
|
- 发送调度必须支持优先队列和普通队列:优先队列消息在同等通道和限速条件下先被消费;普通队列不能被永久饿死,应通过批次配额、老化策略或可配置调度比例保证恢复。
|
||||||
- 使用 Redis Token Bucket 或本地令牌桶加 Redis 协调实现通道限速。
|
- 使用 Redis Token Bucket 或本地令牌桶加 Redis 协调实现通道限速。
|
||||||
- 单条发送记录以 message_id 全局唯一,所有提交和回执处理幂等。
|
- 单条发送记录以 message_id 全局唯一,所有提交和回执处理幂等。
|
||||||
- 批量插入 message_record,避免逐条事务。
|
- 批量插入 message_record,避免逐条事务。
|
||||||
@@ -520,6 +591,7 @@
|
|||||||
### 9.2 短信配置
|
### 9.2 短信配置
|
||||||
|
|
||||||
- sms_application:短信应用。
|
- sms_application:短信应用。
|
||||||
|
- sms_application.queue_priority 或等价字段:应用发送队列等级,取值 normal/priority,默认 normal。
|
||||||
- sms_signature:短信签名。
|
- sms_signature:短信签名。
|
||||||
- signature_material:签名证明材料。
|
- signature_material:签名证明材料。
|
||||||
- sms_template:短信模板。
|
- sms_template:短信模板。
|
||||||
@@ -1098,18 +1170,27 @@
|
|||||||
4. Send Worker。
|
4. Send Worker。
|
||||||
5. 通道路由。
|
5. 通道路由。
|
||||||
6. Redis 限速。
|
6. Redis 限速。
|
||||||
7. Go Gateway 提交 CMPP。
|
7. 上游通道 CMPP connect/login、ActiveTest、断线重连和连接状态回写。
|
||||||
8. Submit Resp 处理。
|
8. Gateway 消费 SubmitCommand 并真实 submit 到上游通道。
|
||||||
9. 回执处理。
|
9. Submit Resp 解析、sequence/messageId 映射和提交状态回传。
|
||||||
10. 上行短信处理。
|
10. Deliver Receipt 解析、重复/迟到回执幂等回传。
|
||||||
11. 72 小时未知转超时。
|
11. 普通 Deliver 上行解析和上行入库。
|
||||||
12. 任务进度统计。
|
12. 下游客户 CMPP Server 监听 `17890`。
|
||||||
|
13. 下游客户 connect/login 鉴权、IP 白名单、应用状态和连接数校验。
|
||||||
|
14. 下游客户 CMPP Submit 转平台发送请求,source=cmpp,不创建客户端批量任务。
|
||||||
|
15. 平台最终回执和上行向下游客户连接投递。
|
||||||
|
16. 72 小时未知转超时。
|
||||||
|
17. 任务进度统计。
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
- 平台创建的批量任务只记录客户端任务。
|
- 平台创建的批量任务只记录客户端任务。
|
||||||
- 平台任务、API 调用、CMPP 对接发送全部按手机号维度进入短信记录。
|
- 平台任务、API 调用、CMPP 对接发送全部按手机号维度进入短信记录。
|
||||||
- 支持 submit resp、deliver 回执、上行短信、超时补偿。
|
- 支持 submit resp、deliver 回执、上行短信、超时补偿。
|
||||||
|
- Gateway `/health` 只表示进程存活,不等于 CMPP 发送链路验收通过。
|
||||||
|
- `17890` 必须是真实 CMPP Server 监听,能完成客户 connect/login 鉴权、IP 白名单校验和 submit 接入。
|
||||||
|
- Gateway 必须真实消费 SubmitCommand 并向上游通道 submit,submit resp、receipt、uplink 事件必须进入 NestJS 后端闭环。
|
||||||
|
- 客户 CMPP 接入发送不创建客户端批量任务,但所有号码必须进入短信记录并可追踪客户侧 sequence/msgId 与平台 messageId。
|
||||||
|
|
||||||
### 阶段 8:查询、统计、验收
|
### 阶段 8:查询、统计、验收
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 租户 | `tenant-a` 正常企业,`tenant-b` 用于租户隔离验证。 |
|
| 租户 | `tenant-a` 正常企业,`tenant-b` 用于租户隔离验证。 |
|
||||||
| 用户 | 客户端企业管理员、客户端普通用户、运营管理员、运营审核员。 |
|
| 用户 | 客户端企业管理员、客户端普通用户、运营管理员、运营审核员。 |
|
||||||
| 短信应用 | `app-a`,状态 active,日限额开启,模板不匹配策略分别准备 reject/manual_review/allow 三组。 |
|
| 短信应用 | `app-a`,状态 active,日限额开启,模板不匹配策略分别准备 reject/manual_review/allow 三组;另准备普通队列应用 `app-normal` 和优先队列应用 `app-priority`。 |
|
||||||
| 签名 | `【测试签名】`,审核通过且报备通过;另准备待审核、驳回、报备失败签名。 |
|
| 签名 | `【测试签名】`,审核通过且报备通过;另准备待审核、驳回、报备失败签名。 |
|
||||||
| 模板 | 验证码模板 `验证码为 ${code}`,营销模板 `尊敬的${name},优惠活动开始`,分别准备草稿、待审核、通过、驳回。 |
|
| 模板 | 验证码模板 `验证码为 ${code}`,营销模板 `尊敬的${name},优惠活动开始`,分别准备草稿、待审核、通过、驳回。 |
|
||||||
| 通道 | active CMPP 通道、disabled 通道、备用通道;通道组包含主备优先级。 |
|
| 通道 | active CMPP 通道、disabled 通道、备用通道;通道组包含主备优先级。 |
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
| 企业认证闭环 | 客户提交资料、运营审核、客户查看状态、驳回重提、通过后才允许使用受限发送能力、日志可追溯。 |
|
| 企业认证闭环 | 客户提交资料、运营审核、客户查看状态、驳回重提、通过后才允许使用受限发送能力、日志可追溯。 |
|
||||||
| 客户管理闭环 | 客户创建、编辑、启停、认证、额度/余额、客户下应用/签名/模板/发送记录联动、租户隔离、日志可追溯。 |
|
| 客户管理闭环 | 客户创建、编辑、启停、认证、额度/余额、客户下应用/签名/模板/发送记录联动、租户隔离、日志可追溯。 |
|
||||||
| 短信应用闭环 | 创建、启停、删除、密钥重置、IP 白名单、模板不匹配策略、变更后对发送实时生效、日志可追溯。 |
|
| 短信应用闭环 | 创建、启停、删除、密钥重置、IP 白名单、模板不匹配策略、变更后对发送实时生效、日志可追溯。 |
|
||||||
|
| 应用队列等级闭环 | 客户端和运营端创建/编辑应用时保存普通/优先队列配置;发送入队按应用队列等级分流;优先队列在不绕过业务校验和通道限速的前提下插队消费。 |
|
||||||
| 签名闭环 | 创建、材料、审核、删除、报备状态变化、发送可用性校验、历史任务不受错误覆盖、日志可追溯。 |
|
| 签名闭环 | 创建、材料、审核、删除、报备状态变化、发送可用性校验、历史任务不受错误覆盖、日志可追溯。 |
|
||||||
| 引流信息闭环 | 字段配置、客户填写、删除字段或删除客户引流信息、报备材料影响、发送阻断或审核原因可见。 |
|
| 引流信息闭环 | 字段配置、客户填写、删除字段或删除客户引流信息、报备材料影响、发送阻断或审核原因可见。 |
|
||||||
| 模板闭环 | 单变量、多变量、审核、删除、变量缺失/多传/格式异常、计费预估、发送可用性校验。 |
|
| 模板闭环 | 单变量、多变量、审核、删除、变量缺失/多传/格式异常、计费预估、发送可用性校验。 |
|
||||||
@@ -65,12 +66,12 @@
|
|||||||
- 优先级:P1
|
- 优先级:P1
|
||||||
- 前置条件:企业管理员已登录。
|
- 前置条件:企业管理员已登录。
|
||||||
- 步骤:
|
- 步骤:
|
||||||
1. 创建短信应用,填写名称、场景、回调地址、IP 白名单、日限额。
|
1. 创建短信应用,填写名称、场景、回调地址、IP 白名单、日限额,并选择发送队列等级。
|
||||||
2. 查询应用列表和详情。
|
2. 查询应用列表和详情。
|
||||||
3. 执行应用密钥重置。
|
3. 执行应用密钥重置。
|
||||||
- 预期结果:
|
- 预期结果:
|
||||||
- 应用创建成功,状态为 active。
|
- 应用创建成功,状态为 active。
|
||||||
- IP 白名单、日限额、模板不匹配策略保存正确。
|
- IP 白名单、日限额、模板不匹配策略和发送队列等级保存正确,刷新后仍来自真实 API/数据库。
|
||||||
- 密钥重置后旧密钥不可见,新密钥或密钥摘要更新。
|
- 密钥重置后旧密钥不可见,新密钥或密钥摘要更新。
|
||||||
- 生成操作日志。
|
- 生成操作日志。
|
||||||
|
|
||||||
@@ -470,6 +471,23 @@
|
|||||||
- 删除连接调用真实后端或 Gateway 接口,连接状态刷新并写系统日志。
|
- 删除连接调用真实后端或 Gateway 接口,连接状态刷新并写系统日志。
|
||||||
- CMPP 参数来源于真实应用/通道配置,一键复制内容与 API 返回一致。
|
- CMPP 参数来源于真实应用/通道配置,一键复制内容与 API 返回一致。
|
||||||
|
|
||||||
|
### TC-ADMIN-018A 企业应用新增表单企业选择与队列等级
|
||||||
|
|
||||||
|
- 优先级:P0
|
||||||
|
- 前置条件:存在至少两个真实企业,运营管理员已登录,通道组基础数据可用。
|
||||||
|
- 步骤:
|
||||||
|
1. 打开运营端企业应用管理,点击新增短信应用。
|
||||||
|
2. 在第一步选择企业下拉框中查看企业选项、加载态和空态。
|
||||||
|
3. 选择企业后进入应用参数表单。
|
||||||
|
4. 配置应用名称、客户单价、IP 白名单、发送队列等级、移动/联通/电信通道组后保存。
|
||||||
|
5. 刷新列表并打开编辑页。
|
||||||
|
- 预期结果:
|
||||||
|
- 企业选择使用项目通用 Select/下拉控件,样式、禁用态、错误态与系统其他下拉一致。
|
||||||
|
- 企业选项来自真实企业 API,不使用静态数组、mock 或 localStorage。
|
||||||
|
- 请求体包含 tenantId、queuePriority、客户单价、IP 白名单和通道组绑定。
|
||||||
|
- 后端真实保存应用队列等级,刷新列表和编辑页后仍显示正确。
|
||||||
|
- 不选择任何通道组或缺少必填字段时不能保存,并显示可读提示。
|
||||||
|
|
||||||
### TC-ADMIN-019 通道连接日志展示
|
### TC-ADMIN-019 通道连接日志展示
|
||||||
|
|
||||||
- 优先级:P1
|
- 优先级:P1
|
||||||
@@ -960,6 +978,22 @@
|
|||||||
- 第二条记录历史或被幂等处理。
|
- 第二条记录历史或被幂等处理。
|
||||||
- 不重复扣费或重复变更最终状态。
|
- 不重复扣费或重复变更最终状态。
|
||||||
|
|
||||||
|
### TC-SEND-021 优先队列插队发送
|
||||||
|
|
||||||
|
- 优先级:P0
|
||||||
|
- 前置条件:存在普通队列应用 `app-normal` 和优先队列应用 `app-priority`;两者企业状态、应用状态、签名、模板、报备、余额、通道组、通道连接均可用;Redis/BullMQ 使用真实本地服务。
|
||||||
|
- 步骤:
|
||||||
|
1. 使用 `app-normal` 连续提交一批普通队列短信,使普通队列形成可观察积压。
|
||||||
|
2. 在普通队列积压未清空时,使用 `app-priority` 提交少量优先队列短信。
|
||||||
|
3. 观察数据库 message_record、BullMQ job、Send Worker 日志、submit trace 和 Gateway SubmitCommand 顺序。
|
||||||
|
4. 等待两类消息处理完成。
|
||||||
|
- 预期结果:
|
||||||
|
- 普通队列和优先队列消息均写入真实数据库,记录包含 queuePriority 或等价可追踪字段。
|
||||||
|
- 优先队列消息在普通队列已有积压时更早被 Send Worker 消费并提交 Gateway。
|
||||||
|
- 优先队列不绕过模板/签名/报备/余额/风控/通道组路由/通道限速/Gateway 连接校验。
|
||||||
|
- 普通队列后续能继续恢复消费,不出现永久积压或重复提交。
|
||||||
|
- trace 中可区分队列等级、入队时间、消费时间和 submit 顺序。
|
||||||
|
|
||||||
## 10. 契约与队列用例
|
## 10. 契约与队列用例
|
||||||
|
|
||||||
### TC-CONTRACT-001 SubmitCommand Schema 校验
|
### TC-CONTRACT-001 SubmitCommand Schema 校验
|
||||||
@@ -986,6 +1020,16 @@
|
|||||||
- 步骤:运行 `npm run spike:contracts`。
|
- 步骤:运行 `npm run spike:contracts`。
|
||||||
- 预期结果:`uplink-event.json` 通过 schema 校验。
|
- 预期结果:`uplink-event.json` 通过 schema 校验。
|
||||||
|
|
||||||
|
### TC-CONTRACT-005 SubmitCommand 队列等级字段校验
|
||||||
|
|
||||||
|
- 优先级:P0
|
||||||
|
- 前置条件:SubmitCommand 契约已扩展 queuePriority、priority 或 queueName 字段。
|
||||||
|
- 步骤:运行 `npm run spike:contracts`。
|
||||||
|
- 预期结果:
|
||||||
|
- SubmitCommand 示例覆盖普通队列和优先队列。
|
||||||
|
- schema 明确允许的队列等级取值,不接受未知等级。
|
||||||
|
- NestJS 生产者和 Gateway/Worker 消费者使用同一字段语义。
|
||||||
|
|
||||||
## 11. 端到端 Smoke 用例
|
## 11. 端到端 Smoke 用例
|
||||||
|
|
||||||
### TC-E2E-001 核心发送闭环
|
### TC-E2E-001 核心发送闭环
|
||||||
@@ -1083,6 +1127,19 @@
|
|||||||
- 无重复最终状态。
|
- 无重复最终状态。
|
||||||
- 错误率满足阶段 0/8 性能报告要求。
|
- 错误率满足阶段 0/8 性能报告要求。
|
||||||
|
|
||||||
|
### TC-PERF-003 混合优先级队列 Smoke
|
||||||
|
|
||||||
|
- 优先级:P0
|
||||||
|
- 前置条件:Redis/BullMQ 可用,准备普通队列和优先队列应用。
|
||||||
|
- 步骤:
|
||||||
|
1. 先提交一批普通队列消息制造积压。
|
||||||
|
2. 再提交优先队列消息。
|
||||||
|
3. 持续提交少量优先队列消息,同时观察普通队列恢复。
|
||||||
|
- 预期结果:
|
||||||
|
- 优先队列消息消费延迟明显低于已有普通队列积压。
|
||||||
|
- 普通队列不会被永久饿死。
|
||||||
|
- 端到端吞吐仍满足第一版性能 smoke 口径。
|
||||||
|
|
||||||
## 14. 业务闭环补充用例
|
## 14. 业务闭环补充用例
|
||||||
|
|
||||||
### TC-CERT-001 企业认证资料提交
|
### TC-CERT-001 企业认证资料提交
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
- 当前重点:
|
- 当前重点:
|
||||||
- 风控规则评估:最大号码数、重复率、非法号码率、黑名单率、模板变量异常、直接拒绝、进入人工审核。
|
- 风控规则评估:最大号码数、重复率、非法号码率、黑名单率、模板变量异常、直接拒绝、进入人工审核。
|
||||||
- 计费:费用预估、余额检查、冻结、提交 accepted 扣费、失败回执退款、短信计费记录、应用级客户费率、重复回调幂等。
|
- 计费:费用预估、余额检查、冻结、提交 accepted 扣费、失败回执退款、短信计费记录、应用级客户费率、重复回调幂等。
|
||||||
- 发送链路:批量任务创建、手机号拆分、发送入队、运营商前缀正则分流、手机号段归属地识别、应用运营商通道组路由、省网/全国路由、submit result 更新、receipt 更新、迟到旧回执不覆盖最终成功、失败补发、补发停止条件、uplink 记录、72 小时未知转超时。
|
- 发送链路:批量任务创建、手机号拆分、按应用队列等级分流普通/优先队列、优先队列插队消费、普通队列防饿死、运营商前缀正则分流、手机号段归属地识别、应用运营商通道组路由、省网/全国路由、submit result 更新、receipt 更新、迟到旧回执不覆盖最终成功、失败补发、补发停止条件、uplink 记录、72 小时未知转超时。
|
||||||
- 通道与报备:通道创建、通道发送地区、三网通道通配、单运营商通道组、通道组明细 carrier 参与发送、省份与通道发送地区一致性、全国通道优先级唯一、企业应用通道组保存校验、禁止单通道发送规则、最终选中通道签名报备校验、签名报备任务、导出、回执导入、签名状态同步。
|
- 通道与报备:通道创建、通道发送地区、三网通道通配、单运营商通道组、通道组明细 carrier 参与发送、省份与通道发送地区一致性、全国通道优先级唯一、企业应用通道组保存校验、禁止单通道发送规则、最终选中通道签名报备校验、签名报备任务、导出、回执导入、签名状态同步。
|
||||||
- 查询统计:发送链路 trace、对账 reconciliation、dashboard/statistics。
|
- 查询统计:发送链路 trace、对账 reconciliation、dashboard/statistics。
|
||||||
- Gateway:SEQID/MSGID 追踪、重连、health、gocmpp submit/resp 模拟器。
|
- Gateway:SEQID/MSGID 追踪、重连、health、gocmpp submit/resp 模拟器。
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
- 单元/轻集成测试可以使用 mock Prisma/BullMQ/Redis 验证 Service 编排和异常分支,但这只代表代码级测试通过。
|
- 单元/轻集成测试可以使用 mock Prisma/BullMQ/Redis 验证 Service 编排和异常分支,但这只代表代码级测试通过。
|
||||||
- 系统功能集成测试必须补充真实服务闭环:
|
- 系统功能集成测试必须补充真实服务闭环:
|
||||||
- Prisma/PostgreSQL test database 集成测试。
|
- Prisma/PostgreSQL test database 集成测试。
|
||||||
- BullMQ + Redis 队列消费集成测试。
|
- BullMQ + Redis 队列消费集成测试,覆盖普通队列、优先队列、混合积压、优先队列插队和普通队列恢复。
|
||||||
- MinIO 预签名上传集成测试。
|
- MinIO 预签名上传集成测试。
|
||||||
- 前端页面调用真实 API 的浏览器 smoke。
|
- 前端页面调用真实 API 的浏览器 smoke。
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
- 继续复用 `docs/contracts/gateway-queue-messages.schema.json`。
|
- 继续复用 `docs/contracts/gateway-queue-messages.schema.json`。
|
||||||
- 继续使用 `tools/spike/validate-gateway-queue-contract.mjs` 校验 SubmitCommand、SubmitResult、ReceiptEvent、UplinkEvent 示例。
|
- 继续使用 `tools/spike/validate-gateway-queue-contract.mjs` 校验 SubmitCommand、SubmitResult、ReceiptEvent、UplinkEvent 示例。
|
||||||
- 队列消息变更必须先改 schema 和示例,再改 NestJS/Gateway 实现。
|
- 队列消息变更必须先改 schema 和示例,再改 NestJS/Gateway 实现。
|
||||||
|
- 若队列消息新增 queuePriority、priority 或队列名称字段,必须同步更新契约 schema、示例、NestJS 生产者、Send Worker 消费者和 Gateway 消费入口。
|
||||||
|
|
||||||
### 2.4 端到端 Smoke
|
### 2.4 端到端 Smoke
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
|
|
||||||
- 继续复用 `npm run spike:bullmq`。
|
- 继续复用 `npm run spike:bullmq`。
|
||||||
- 验证 15000 条消息、并发 500 的入队和端到端队列链路吞吐。
|
- 验证 15000 条消息、并发 500 的入队和端到端队列链路吞吐。
|
||||||
|
- 混合优先级压测必须额外验证:普通队列已有积压时,新进入的优先队列消息能更早被消费;优先队列持续进入时,普通队列仍能按配置恢复消费,不出现永久饥饿。
|
||||||
- 性能 smoke 只验证第一版“可稳定入队并调度 500 条短信/秒”的链路能力,不替代生产压测。
|
- 性能 smoke 只验证第一版“可稳定入队并调度 500 条短信/秒”的链路能力,不替代生产压测。
|
||||||
|
|
||||||
## 3. 执行命令
|
## 3. 执行命令
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
# 第一版系统化测试进度
|
# 第一版系统化测试进度
|
||||||
|
|
||||||
|
## 2026-07-07 企业应用通用下拉与优先队列需求补充
|
||||||
|
|
||||||
|
- 已补充需求文档,明确运营端企业应用新增时选择企业必须使用通用 Select/下拉控件,企业选项来自真实企业 API,支持加载、空态和错误态,不允许静态数组或 localStorage 兜底。
|
||||||
|
- 已补充应用发送队列等级需求:短信应用必须保存普通队列/优先队列配置,默认普通队列;优先队列用于验证码、登录确认、交易通知等高时效短信。
|
||||||
|
- 已补充发送链路要求:发送入队必须按应用队列等级分流,优先队列在同等业务校验、通道组路由、通道限速和 Gateway 连接条件下插队消费;普通队列不能永久饿死。
|
||||||
|
- 已补充测试计划和系统功能用例:
|
||||||
|
- 企业应用新增表单企业选择与队列等级真实保存。
|
||||||
|
- 优先队列插队发送。
|
||||||
|
- SubmitCommand 队列等级契约校验。
|
||||||
|
- 混合优先级队列性能 smoke。
|
||||||
|
- 当前状态:仅完成需求和测试口径补充,前后端、Prisma、BullMQ/Send Worker、Gateway 队列契约尚未实现,不能记为功能验收通过。
|
||||||
|
- 2026-07-07 追加:Prisma 与 NestJS 企业应用 API 已增加 `queuePriority` 持久化字段,创建/编辑支持 normal/priority 校验,列表/详情随真实应用数据返回;前端表单、发送入队、Send Worker 调度和 Gateway 队列契约仍待后续步骤实现。
|
||||||
|
- 2026-07-07 追加:运营端企业应用新增第一步企业选择已改为通用 `Select`;企业应用新增/编辑表单已按设计锚点恢复“发送队列”单选项,并在保存时真实提交 `queuePriority`。发送入队、Send Worker 调度和 Gateway 队列契约仍待后续步骤实现。
|
||||||
|
- 2026-07-07 追加:发送链路已将应用 `queuePriority` 固化到 `SmsMessageRecord`,BullMQ 入队按 priority/normal 写入不同 job priority,SubmitCommand 契约、示例和 Go Gateway 队列结构已增加 `queuePriority`;当前实现覆盖优先队列插队的基础能力,持续高优先级流量下普通队列防饥饿策略仍需后续压测和调度增强。
|
||||||
|
- 2026-07-07 追加:Gateway 队列契约第 8 步已独立校验,`SubmitCommand` schema/example 要求 `queuePriority`,Go Gateway `SubmitCommand` 结构可反序列化该字段,并通过 `npm run spike:contracts` 与 `npm run spike:gateway`。
|
||||||
|
- 2026-07-07 追加:第 9 步收口验证通过:`npm --prefix api run prisma:generate`、`npm --prefix api test -- sms-config.service.spec.ts send-chain.service.spec.ts --runInBand`、`npm run spike:contracts`、`npm run spike:gateway`、`npm --prefix api run build`、`npm run build` 均通过;前端 build 仅保留既有 Vite chunk size warning。
|
||||||
|
|
||||||
## 2026-07-06 企业管理列表字段回归
|
## 2026-07-06 企业管理列表字段回归
|
||||||
|
|
||||||
- 按设计锚点 `131f344a^` 恢复运营端企业管理列表字段:企业 ID、企业名称、当前余额、透支限额、今日消费、企业状态、操作。
|
- 按设计锚点 `131f344a^` 恢复运营端企业管理列表字段:企业 ID、企业名称、当前余额、透支限额、今日消费、企业状态、操作。
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type SubmitCommand struct {
|
|||||||
Signature string `json:"signature"`
|
Signature string `json:"signature"`
|
||||||
TemplateID string `json:"templateId"`
|
TemplateID string `json:"templateId"`
|
||||||
BillingUnits int `json:"billingUnits"`
|
BillingUnits int `json:"billingUnits"`
|
||||||
|
QueuePriority string `json:"queuePriority"`
|
||||||
Route Route `json:"route"`
|
Route Route `json:"route"`
|
||||||
CMPP CMPP `json:"cmpp"`
|
CMPP CMPP `json:"cmpp"`
|
||||||
Retry Retry `json:"retry"`
|
Retry Retry `json:"retry"`
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package queue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSubmitCommandUnmarshalsQueuePriority(t *testing.T) {
|
||||||
|
payload := []byte(`{
|
||||||
|
"schemaVersion": "v1",
|
||||||
|
"messageType": "SubmitCommand",
|
||||||
|
"traceId": "trace-queue-0001",
|
||||||
|
"messageId": "msg-queue-0001",
|
||||||
|
"channelId": "channel-1",
|
||||||
|
"createdAt": "2026-07-07T10:00:00Z",
|
||||||
|
"tenantId": "tenant-1",
|
||||||
|
"applicationId": "app-1",
|
||||||
|
"submitId": "submit-1",
|
||||||
|
"phoneNumber": "13800138000",
|
||||||
|
"content": "hello",
|
||||||
|
"signature": "测试",
|
||||||
|
"templateId": "tpl-1",
|
||||||
|
"billingUnits": 1,
|
||||||
|
"queuePriority": "priority",
|
||||||
|
"route": {
|
||||||
|
"channelCode": "CMPP-A",
|
||||||
|
"cmppAccountCode": "account-a",
|
||||||
|
"priority": 1,
|
||||||
|
"rateLimitPerSecond": 100
|
||||||
|
},
|
||||||
|
"cmpp": {
|
||||||
|
"serviceId": "SMS",
|
||||||
|
"srcId": "10690000",
|
||||||
|
"registeredDelivery": 1,
|
||||||
|
"msgFmt": 8
|
||||||
|
},
|
||||||
|
"retry": {
|
||||||
|
"attempt": 0,
|
||||||
|
"maxAttempts": 3
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
var command SubmitCommand
|
||||||
|
if err := json.Unmarshal(payload, &command); err != nil {
|
||||||
|
t.Fatalf("unmarshal submit command: %v", err)
|
||||||
|
}
|
||||||
|
if command.QueuePriority != "priority" {
|
||||||
|
t.Fatalf("QueuePriority = %q, want priority", command.QueuePriority)
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-2
@@ -187,6 +187,7 @@ export type ClientSmsApplication = {
|
|||||||
name: string;
|
name: string;
|
||||||
scene?: string | null;
|
scene?: string | null;
|
||||||
customerUnitPrice?: number | null;
|
customerUnitPrice?: number | null;
|
||||||
|
queuePriority?: 'normal' | 'priority' | string | null;
|
||||||
status: string;
|
status: string;
|
||||||
dailyLimit?: number | null;
|
dailyLimit?: number | null;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
@@ -455,6 +456,7 @@ export type EnterpriseApplication = {
|
|||||||
status: string;
|
status: string;
|
||||||
dailyLimit?: number | null;
|
dailyLimit?: number | null;
|
||||||
customerUnitPrice?: number | null;
|
customerUnitPrice?: number | null;
|
||||||
|
queuePriority?: 'normal' | 'priority' | string | null;
|
||||||
maxPhonesPerTask?: number | null;
|
maxPhonesPerTask?: number | null;
|
||||||
templateMismatchMode?: string | null;
|
templateMismatchMode?: string | null;
|
||||||
ipAllowlist?: Array<{ id: string; ipCidr: string; remark?: string | null }>;
|
ipAllowlist?: Array<{ id: string; ipCidr: string; remark?: string | null }>;
|
||||||
@@ -550,9 +552,9 @@ export const adminApi = {
|
|||||||
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
|
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
|
||||||
getEnterpriseApplication: (id: string) =>
|
getEnterpriseApplication: (id: string) =>
|
||||||
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`),
|
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`),
|
||||||
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[] }) =>
|
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[] }) =>
|
||||||
request<EnterpriseApplication>('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }),
|
request<EnterpriseApplication>('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[] }) =>
|
updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[] }) =>
|
||||||
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
changeApplicationStatus: (id: string, status: string, reason?: string) =>
|
changeApplicationStatus: (id: string, status: string, reason?: string) =>
|
||||||
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}/status`, {
|
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}/status`, {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react';
|
import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Breadcrumb, Button, Input, Modal, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
|
||||||
import { adminApi, type ApplicationCmppParams, type CmppConnectionState, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
|
import { adminApi, type ApplicationCmppParams, type CmppConnectionState, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
|
||||||
|
|
||||||
type SmsApp = {
|
type SmsApp = {
|
||||||
@@ -94,15 +94,17 @@ function AddApplicationModal({
|
|||||||
title={<div className="template-modal-title"><h2>新建企业应用</h2><p>先选择真实企业,再配置短信应用和三网通道组。</p></div>}
|
title={<div className="template-modal-title"><h2>新建企业应用</h2><p>先选择真实企业,再配置短信应用和三网通道组。</p></div>}
|
||||||
>
|
>
|
||||||
<div className="form-grid app-create-modal">
|
<div className="form-grid app-create-modal">
|
||||||
<label className="field">
|
<Select
|
||||||
<span>所属企业</span>
|
disabled={loading || tenants.length === 0}
|
||||||
<select disabled={loading} onChange={(event) => onChange(event.target.value)} value={selectedTenantId}>
|
hint={!loading && tenants.length === 0 ? '暂无可选择企业,请先创建真实企业。' : undefined}
|
||||||
<option value="">{loading ? '企业加载中...' : '请选择真实企业'}</option>
|
label="所属企业"
|
||||||
{tenants.map((tenant) => (
|
onChange={(event) => onChange(event.target.value)}
|
||||||
<option key={tenant.id} value={tenant.id}>{tenant.name}({tenant.code})</option>
|
options={[
|
||||||
))}
|
{ label: loading ? '企业加载中...' : '请选择真实企业', value: '' },
|
||||||
</select>
|
...tenants.map((tenant) => ({ label: `${tenant.name}(${tenant.code})`, value: tenant.id })),
|
||||||
</label>
|
]}
|
||||||
|
value={selectedTenantId}
|
||||||
|
/>
|
||||||
<div className="app-create-modal__hint">
|
<div className="app-create-modal__hint">
|
||||||
<strong>{selectedTenantId ? tenants.find((tenant) => tenant.id === selectedTenantId)?.name : '请选择要开通短信应用的企业'}</strong>
|
<strong>{selectedTenantId ? tenants.find((tenant) => tenant.id === selectedTenantId)?.name : '请选择要开通短信应用的企业'}</strong>
|
||||||
<span>下一步会进入应用参数、客户单价、IP 白名单和运营商通道组配置。</span>
|
<span>下一步会进入应用参数、客户单价、IP 白名单和运营商通道组配置。</span>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { ArrowLeft, RadioTower } from 'lucide-react';
|
import { ArrowLeft, Info, RadioTower } from 'lucide-react';
|
||||||
import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication } from '@/api/adminApi';
|
import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
|
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
|
||||||
|
|
||||||
type Carrier = 'mobile' | 'unicom' | 'telecom';
|
type Carrier = 'mobile' | 'unicom' | 'telecom';
|
||||||
|
type QueuePriority = 'normal' | 'priority';
|
||||||
|
|
||||||
const carrierMeta: Record<Carrier, { label: string; description: string }> = {
|
const carrierMeta: Record<Carrier, { label: string; description: string }> = {
|
||||||
mobile: { label: '移动', description: '移动号码只会进入移动通道组' },
|
mobile: { label: '移动', description: '移动号码只会进入移动通道组' },
|
||||||
@@ -20,6 +21,7 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
const [scene, setScene] = useState('行业通知');
|
const [scene, setScene] = useState('行业通知');
|
||||||
const [dailyLimit, setDailyLimit] = useState('100000');
|
const [dailyLimit, setDailyLimit] = useState('100000');
|
||||||
const [customerUnitPrice, setCustomerUnitPrice] = useState('0.0300');
|
const [customerUnitPrice, setCustomerUnitPrice] = useState('0.0300');
|
||||||
|
const [queuePriority, setQueuePriority] = useState<QueuePriority>('normal');
|
||||||
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
|
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
|
||||||
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
|
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
|
||||||
const [ipAddress, setIpAddress] = useState('');
|
const [ipAddress, setIpAddress] = useState('');
|
||||||
@@ -72,6 +74,7 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
setScene(application.scene ?? '');
|
setScene(application.scene ?? '');
|
||||||
setDailyLimit(application.dailyLimit ? String(application.dailyLimit) : '');
|
setDailyLimit(application.dailyLimit ? String(application.dailyLimit) : '');
|
||||||
setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(4));
|
setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(4));
|
||||||
|
setQueuePriority(application.queuePriority === 'priority' ? 'priority' : 'normal');
|
||||||
setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : '');
|
setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : '');
|
||||||
setMismatchPolicy(application.templateMismatchMode ?? 'reject');
|
setMismatchPolicy(application.templateMismatchMode ?? 'reject');
|
||||||
setIpAddress(application.ipAllowlist?.map((item) => item.ipCidr).join('\n') ?? '');
|
setIpAddress(application.ipAllowlist?.map((item) => item.ipCidr).join('\n') ?? '');
|
||||||
@@ -106,6 +109,7 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
scene,
|
scene,
|
||||||
dailyLimit: Number(dailyLimit) || undefined,
|
dailyLimit: Number(dailyLimit) || undefined,
|
||||||
customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100),
|
customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100),
|
||||||
|
queuePriority,
|
||||||
maxPhonesPerTask: Number(phoneDailyLimit) || undefined,
|
maxPhonesPerTask: Number(phoneDailyLimit) || undefined,
|
||||||
templateMismatchMode: mismatchPolicy,
|
templateMismatchMode: mismatchPolicy,
|
||||||
ipAllowlist: parseIpAllowlist(ipAddress),
|
ipAllowlist: parseIpAllowlist(ipAddress),
|
||||||
@@ -163,6 +167,23 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
<Input label="应用场景" onChange={(event) => setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} />
|
<Input label="应用场景" onChange={(event) => setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} />
|
||||||
<Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} />
|
<Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} />
|
||||||
<Input label="客户单价(元/条)" onChange={(event) => setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required value={customerUnitPrice} />
|
<Input label="客户单价(元/条)" onChange={(event) => setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required value={customerUnitPrice} />
|
||||||
|
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||||
|
<span>发送队列</span>
|
||||||
|
<div className="radio-row">
|
||||||
|
<label>
|
||||||
|
<input checked={queuePriority === 'priority'} onChange={() => setQueuePriority('priority')} type="radio" />
|
||||||
|
优先队列(行业短信)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input checked={queuePriority === 'normal'} onChange={() => setQueuePriority('normal')} type="radio" />
|
||||||
|
普通队列(会员营销)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="admin-app-form-tip">
|
||||||
|
<Info size={17} />
|
||||||
|
<span>优先队列会在发送调度中插队处理,但仍必须经过模板、签名、余额、通道组和通道限速校验。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Input label="每任务最大号码数" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} />
|
<Input label="每任务最大号码数" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} />
|
||||||
<Select
|
<Select
|
||||||
label="不符合模板的短信"
|
label="不符合模板的短信"
|
||||||
|
|||||||
Reference in New Issue
Block a user