feat: add access number routing and admin list improvements
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
ALTER TABLE "SmsApplication"
|
||||
ADD COLUMN "cmppApplicationExtension" TEXT,
|
||||
ADD COLUMN "cmppAccessNumberFillEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "cmppAccessNumberFillPrefix" TEXT,
|
||||
ADD COLUMN "cmppClientSrcId" TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX "SmsApplication_cmppClientSrcId_key"
|
||||
ON "SmsApplication"("cmppClientSrcId");
|
||||
|
||||
ALTER TABLE "SmsMessageRecord"
|
||||
ADD COLUMN "clientSrcId" TEXT,
|
||||
ADD COLUMN "applicationExtension" TEXT;
|
||||
@@ -355,6 +355,10 @@ model SmsApplication {
|
||||
callbackUrl String?
|
||||
cmppAccount String @unique
|
||||
cmppEnterpriseCode String
|
||||
cmppApplicationExtension String?
|
||||
cmppAccessNumberFillEnabled Boolean @default(false)
|
||||
cmppAccessNumberFillPrefix String?
|
||||
cmppClientSrcId String? @unique
|
||||
secretHash String
|
||||
interfaceEnabled Boolean @default(true)
|
||||
interfaceType String @default("cmpp20")
|
||||
@@ -1007,6 +1011,8 @@ model SmsMessageRecord {
|
||||
submitId String?
|
||||
gatewayMessageId String?
|
||||
cmppSubmitSequenceId String?
|
||||
clientSrcId String?
|
||||
applicationExtension String?
|
||||
status String @default("queued")
|
||||
submitStatus String?
|
||||
receiptStatus String?
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { SendChainService } from '../send-chain/send-chain.service';
|
||||
import {
|
||||
BatchReviewSmsTasksDto,
|
||||
CreateRiskRuleDto,
|
||||
ReviewSmsTaskDto,
|
||||
RiskReviewService,
|
||||
@@ -44,6 +45,16 @@ export class AdminRiskReviewController {
|
||||
return task;
|
||||
}
|
||||
|
||||
@Post('tasks/batch/reject')
|
||||
async rejectTasks(@Body() body: BatchReviewSmsTasksDto) {
|
||||
const tasks = await this.riskReview.rejectTasks(body);
|
||||
const reason = body.reason?.trim() ?? '';
|
||||
for (const task of tasks) {
|
||||
await this.sendChain.handleReviewDecision(task.id, 'rejected', reason);
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
@Post('tasks/:id/reject')
|
||||
async rejectTask(@Param('id') taskId: string, @Body() body: ReviewSmsTaskDto) {
|
||||
const task = await this.riskReview.rejectTask(taskId, body);
|
||||
|
||||
@@ -92,6 +92,34 @@ describe('RiskReviewService', () => {
|
||||
expect(prisma.smsSendTask.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('batch rejects unique tasks with one required rejection reason', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSendTask.findUnique.mockImplementation(({ where }: { where: { id: string } }) => Promise.resolve({
|
||||
id: where.id,
|
||||
status: 'pending_review',
|
||||
reviewReason: '命中风控',
|
||||
}));
|
||||
prisma.smsSendTask.update.mockImplementation(({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => Promise.resolve({
|
||||
id: where.id,
|
||||
...data,
|
||||
riskHits: [],
|
||||
}));
|
||||
const service = new RiskReviewService(prisma as never);
|
||||
|
||||
await expect(service.rejectTasks({ ids: ['task-1', 'task-2', 'task-1'], reason: '批量人工拒绝' })).resolves.toEqual([
|
||||
expect.objectContaining({ id: 'task-1', status: 'rejected', rejectReason: '批量人工拒绝' }),
|
||||
expect.objectContaining({ id: 'task-2', status: 'rejected', rejectReason: '批量人工拒绝' }),
|
||||
]);
|
||||
expect(prisma.smsSendTask.update).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('requires task ids and a reason for batch rejection', async () => {
|
||||
const service = new RiskReviewService(createPrismaMock() as never);
|
||||
|
||||
await expect(service.rejectTasks({ ids: [], reason: '拒绝' })).rejects.toThrow('At least one SMS send task id is required');
|
||||
await expect(service.rejectTasks({ ids: ['task-1'], reason: ' ' })).rejects.toThrow('Batch rejection reason is required');
|
||||
});
|
||||
|
||||
it('rejects tasks over the application max phone threshold', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', maxPhonesPerTask: 2 });
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface ReviewSmsTaskDto {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface BatchReviewSmsTasksDto extends ReviewSmsTaskDto {
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
export interface AggregateTemplateMismatchDto {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
@@ -366,6 +370,25 @@ export class RiskReviewService {
|
||||
});
|
||||
}
|
||||
|
||||
async rejectTasks(data: BatchReviewSmsTasksDto) {
|
||||
const taskIds = [...new Set((data.ids ?? []).map((id) => id.trim()).filter(Boolean))];
|
||||
if (taskIds.length === 0) {
|
||||
throw new BadRequestException('At least one SMS send task id is required');
|
||||
}
|
||||
if (taskIds.length > 100) {
|
||||
throw new BadRequestException('A maximum of 100 SMS send tasks can be rejected at once');
|
||||
}
|
||||
if (!data.reason?.trim()) {
|
||||
throw new BadRequestException('Batch rejection reason is required');
|
||||
}
|
||||
|
||||
const rejected = [];
|
||||
for (const taskId of taskIds) {
|
||||
rejected.push(await this.rejectTask(taskId, { ...data, reason: data.reason.trim() }));
|
||||
}
|
||||
return rejected;
|
||||
}
|
||||
|
||||
private async ensureDefaultRules() {
|
||||
for (const rule of DEFAULT_RULES) {
|
||||
const exists = await this.prisma.riskRule.findFirst({
|
||||
|
||||
@@ -571,6 +571,67 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts only the filled client Src_Id and snapshots the real application extension', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsApplication.findFirst.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
cmppAccount: '100001',
|
||||
cmppEnterpriseCode: 'SP0001',
|
||||
cmppApplicationExtension: '0001',
|
||||
cmppAccessNumberFillEnabled: true,
|
||||
cmppAccessNumberFillPrefix: '00',
|
||||
cmppClientSrcId: '000001',
|
||||
secretHash: 'secret-hash',
|
||||
status: 'active',
|
||||
interfaceEnabled: true,
|
||||
templateMismatchMode: 'reject',
|
||||
customerUnitPrice: 3,
|
||||
queuePriority: 'normal',
|
||||
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
||||
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
||||
});
|
||||
|
||||
await expect(service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '13800000001',
|
||||
content: 'hello',
|
||||
srcId: '000001',
|
||||
remoteIp: '127.0.0.1',
|
||||
})).resolves.toEqual(expect.objectContaining({ accepted: true }));
|
||||
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ clientSrcId: '000001', applicationExtension: '0001' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a client Src_Id that does not match the configured fill prefix and extension', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsApplication.findFirst.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
cmppApplicationExtension: '0001',
|
||||
cmppAccessNumberFillEnabled: true,
|
||||
cmppAccessNumberFillPrefix: '00',
|
||||
cmppClientSrcId: '000001',
|
||||
status: 'active',
|
||||
interfaceEnabled: true,
|
||||
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
||||
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
||||
});
|
||||
|
||||
await expect(service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '13800000001',
|
||||
content: 'hello',
|
||||
srcId: '0001',
|
||||
remoteIp: '127.0.0.1',
|
||||
})).rejects.toThrow('CMPP Src_Id must equal the access number assigned to this application: 000001');
|
||||
|
||||
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records an unreported CMPP message and returns success before delivering the template failure receipt', async () => {
|
||||
const { service, prisma, riskReview } = createService();
|
||||
prisma.smsTemplate.findFirst.mockResolvedValue(null);
|
||||
@@ -888,6 +949,26 @@ describe('SendChainService', () => {
|
||||
expect(service['postGatewayControl']).not.toHaveBeenCalledWith('/upstream/submit', expect.anything());
|
||||
});
|
||||
|
||||
it('appends the real application extension to the upstream channel base number', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const queuedMessage = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
|
||||
prisma.smsMessageRecord.findUnique.mockResolvedValue({
|
||||
...queuedMessage,
|
||||
applicationExtension: '0001',
|
||||
clientSrcId: '000001',
|
||||
});
|
||||
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
|
||||
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
||||
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
|
||||
|
||||
await service.processSendJob({ messageRecordId: 'record-1' });
|
||||
|
||||
expect(gatewayAdd).toHaveBeenCalledWith(
|
||||
'submit-command',
|
||||
expect.objectContaining({ cmpp: expect.objectContaining({ srcId: '106900000001' }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('routes a partially reported signature only through its approved backup channel', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const baseRoute = await prisma.channelRouteRule.findFirst();
|
||||
|
||||
@@ -282,9 +282,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
const phones = [...new Set(data.phones ?? [])];
|
||||
const schedule = parseSchedule(data);
|
||||
await this.validateSendResources(data.tenantId, data.applicationId, data.templateId);
|
||||
const messageClassification = await this.resolveTemplateMessageClassification(data.templateId, data.content);
|
||||
const unitPrice = await this.resolveUnitPrice(data.tenantId, data.applicationId);
|
||||
const queuePriority = await this.resolveQueuePriority(data.tenantId, data.applicationId);
|
||||
const [messageClassification, unitPrice, queuePriority, accessNumber] = await Promise.all([
|
||||
this.resolveTemplateMessageClassification(data.templateId, data.content),
|
||||
this.resolveUnitPrice(data.tenantId, data.applicationId),
|
||||
this.resolveQueuePriority(data.tenantId, data.applicationId),
|
||||
this.resolveApplicationAccessNumber(data.tenantId, data.applicationId),
|
||||
]);
|
||||
const risk = await this.riskReview.evaluateTask({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
@@ -376,6 +379,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
unitPrice: billing.unitPrice,
|
||||
amountCents: billing.billingUnitsPerMessage * billing.unitPrice,
|
||||
queuePriority,
|
||||
clientSrcId: accessNumber.clientSrcId,
|
||||
applicationExtension: accessNumber.applicationExtension,
|
||||
status: batchStatus === 'ready' ? 'queued' : batchStatus === 'scheduled' ? 'scheduled' : batchStatus,
|
||||
errorMessage: risk.status === 'rejected' ? risk.reason ?? undefined : undefined,
|
||||
})),
|
||||
@@ -1686,6 +1691,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!/^1[3-9]\d{9}$/.test(data.phoneNumber)) {
|
||||
throw new BadRequestException('CMPP submit phone number is invalid');
|
||||
}
|
||||
const clientSrcId = validateInboundApplicationSrcId(data.srcId, application);
|
||||
const template = await this.resolveInboundTemplateCandidate(application.id, data.content);
|
||||
const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {};
|
||||
const unitPrice = application.customerUnitPrice ?? 0;
|
||||
@@ -1735,6 +1741,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
amountCents: billing.amountCents,
|
||||
queuePriority,
|
||||
cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId),
|
||||
clientSrcId,
|
||||
applicationExtension: application.cmppApplicationExtension,
|
||||
status: 'validating',
|
||||
},
|
||||
});
|
||||
@@ -1966,6 +1974,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
queuePriority?: string | null;
|
||||
clientSrcId?: string | null;
|
||||
applicationExtension?: string | null;
|
||||
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
|
||||
signature?: { id?: string | null; name?: string | null } | null;
|
||||
},
|
||||
@@ -1973,6 +1983,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
attempt: number,
|
||||
) {
|
||||
const channel = routed.channel;
|
||||
const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension);
|
||||
await this.ensureSignatureReportedForChannel(message, channel.id);
|
||||
await this.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond);
|
||||
const submitId = `SUB-${randomUUID()}`;
|
||||
@@ -2039,7 +2050,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
serviceId: channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config
|
||||
? String(channel.config.serviceId)
|
||||
: 'SMS',
|
||||
srcId: channel.srcId,
|
||||
srcId: upstreamSrcId,
|
||||
extensionDigits: getNonNegativeConfigInteger(channel.config, 'extensionDigits', 0),
|
||||
registeredDelivery: 1,
|
||||
msgFmt: 8,
|
||||
@@ -2073,6 +2084,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
queuedAt?: Date;
|
||||
clientSrcId?: string | null;
|
||||
applicationExtension?: string | null;
|
||||
},
|
||||
reason: string,
|
||||
) {
|
||||
@@ -2238,6 +2251,23 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return normalizeQueuePriority(application.queuePriority);
|
||||
}
|
||||
|
||||
private async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
|
||||
if (!applicationId) {
|
||||
return { clientSrcId: null, applicationExtension: null };
|
||||
}
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
select: { tenantId: true, cmppClientSrcId: true, cmppApplicationExtension: true },
|
||||
});
|
||||
if (!application || application.tenantId !== tenantId) {
|
||||
return { clientSrcId: null, applicationExtension: null };
|
||||
}
|
||||
return {
|
||||
clientSrcId: application.cmppClientSrcId,
|
||||
applicationExtension: application.cmppApplicationExtension,
|
||||
};
|
||||
}
|
||||
|
||||
private findInboundApplication(account: string) {
|
||||
return this.prisma.smsApplication.findFirst({
|
||||
where: { cmppAccount: account },
|
||||
@@ -3096,6 +3126,39 @@ function isProvinceChannel(item: { province?: string | null; channel: { sendRegi
|
||||
return itemProvince === target || sendRegion === target;
|
||||
}
|
||||
|
||||
function validateInboundApplicationSrcId(
|
||||
srcId: string | undefined,
|
||||
application: {
|
||||
cmppApplicationExtension?: string | null;
|
||||
cmppAccessNumberFillEnabled?: boolean | null;
|
||||
cmppAccessNumberFillPrefix?: string | null;
|
||||
cmppClientSrcId?: string | null;
|
||||
},
|
||||
) {
|
||||
const submittedSrcId = srcId?.trim() ?? '';
|
||||
const applicationExtension = application.cmppApplicationExtension?.trim() ?? '';
|
||||
if (!applicationExtension) {
|
||||
return submittedSrcId || null;
|
||||
}
|
||||
|
||||
const fillPrefix = application.cmppAccessNumberFillEnabled
|
||||
? application.cmppAccessNumberFillPrefix?.trim() ?? ''
|
||||
: '';
|
||||
const expectedSrcId = application.cmppClientSrcId?.trim() || `${fillPrefix}${applicationExtension}`;
|
||||
if (!submittedSrcId || submittedSrcId !== expectedSrcId) {
|
||||
throw new BadRequestException(`CMPP Src_Id must equal the access number assigned to this application: ${expectedSrcId}`);
|
||||
}
|
||||
return submittedSrcId;
|
||||
}
|
||||
|
||||
function composeUpstreamSrcId(baseSrcId: string, applicationExtension?: string | null) {
|
||||
const upstreamSrcId = `${baseSrcId.trim()}${applicationExtension?.trim() ?? ''}`;
|
||||
if (upstreamSrcId.length > 21) {
|
||||
throw new BadRequestException('channel base access number plus application extension must not exceed 21 digits');
|
||||
}
|
||||
return upstreamSrcId;
|
||||
}
|
||||
|
||||
function positiveInteger(value: string | undefined, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
|
||||
@@ -10,6 +10,10 @@ function createPrismaMock() {
|
||||
status: 'active',
|
||||
cmppAccount: '100001',
|
||||
cmppEnterpriseCode: 'APP-EC',
|
||||
cmppApplicationExtension: '0001',
|
||||
cmppAccessNumberFillEnabled: true,
|
||||
cmppAccessNumberFillPrefix: '00',
|
||||
cmppClientSrcId: '000001',
|
||||
cmppMaxConnections: 2,
|
||||
cmppWindowSize: 32,
|
||||
interfaceEnabled: true,
|
||||
@@ -24,6 +28,10 @@ function createPrismaMock() {
|
||||
status: 'active',
|
||||
cmppAccount: '100001',
|
||||
cmppEnterpriseCode: 'APP-EC',
|
||||
cmppApplicationExtension: '0001',
|
||||
cmppAccessNumberFillEnabled: true,
|
||||
cmppAccessNumberFillPrefix: '00',
|
||||
cmppClientSrcId: '000001',
|
||||
cmppMaxConnections: 2,
|
||||
cmppWindowSize: 32,
|
||||
interfaceEnabled: true,
|
||||
@@ -225,6 +233,33 @@ describe('SmsConfigService', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('sorts enterprise applications by today send count descending with a stable name tie-breaker', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const baseApplication = {
|
||||
tenantId: 'tenant-1',
|
||||
status: 'active',
|
||||
interfaceEnabled: true,
|
||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||
ipAllowlist: [],
|
||||
};
|
||||
prisma.smsApplication.findMany.mockResolvedValue([
|
||||
{ ...baseApplication, id: 'app-1', name: '乙应用' },
|
||||
{ ...baseApplication, id: 'app-2', name: '甲应用' },
|
||||
{ ...baseApplication, id: 'app-3', name: '丙应用' },
|
||||
]);
|
||||
prisma.cmppDownstreamConnection.findMany.mockResolvedValue([]);
|
||||
prisma.smsMessageRecord.groupBy.mockResolvedValue([
|
||||
{ applicationId: 'app-1', status: 'delivered', _count: { _all: 2 } },
|
||||
{ applicationId: 'app-2', status: 'delivered', _count: { _all: 2 } },
|
||||
{ applicationId: 'app-3', status: 'delivered', _count: { _all: 5 } },
|
||||
]);
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
const applications = await service.listApplications({ includeConnections: true });
|
||||
|
||||
expect(applications.map((application) => application.id)).toEqual(['app-3', 'app-2', 'app-1']);
|
||||
});
|
||||
|
||||
it('returns CMPP params from persisted application and channel config', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
@@ -235,6 +270,10 @@ describe('SmsConfigService', () => {
|
||||
account: '100001',
|
||||
enterpriseCode: 'APP-EC',
|
||||
passwordCipher: '0123456789abcdef',
|
||||
srcId: '000001',
|
||||
applicationExtension: '0001',
|
||||
accessNumberFillEnabled: true,
|
||||
accessNumberFillPrefix: '00',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 17890,
|
||||
interfaceEnabled: true,
|
||||
@@ -283,6 +322,54 @@ describe('SmsConfigService', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('persists a filled client Src_Id separately from the real application extension', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsApplication.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(null);
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await service.createApplication({
|
||||
tenantId: 'tenant-1',
|
||||
name: '接入号应用',
|
||||
cmppAccount: '123456',
|
||||
passwordCipher: '1234567890abcdef',
|
||||
cmppApplicationExtension: '0001',
|
||||
cmppAccessNumberFillEnabled: true,
|
||||
cmppAccessNumberFillPrefix: '00',
|
||||
});
|
||||
|
||||
expect(prisma.smsApplication.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
cmppApplicationExtension: '0001',
|
||||
cmppAccessNumberFillEnabled: true,
|
||||
cmppAccessNumberFillPrefix: '00',
|
||||
cmppClientSrcId: '000001',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('rejects access number filling without a numeric prefix and application extension', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsApplication.findUnique.mockResolvedValue(null);
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.createApplication({
|
||||
tenantId: 'tenant-1',
|
||||
name: '缺少扩展码',
|
||||
cmppAccount: '123456',
|
||||
cmppAccessNumberFillEnabled: true,
|
||||
cmppAccessNumberFillPrefix: '00',
|
||||
})).rejects.toThrow('cmppApplicationExtension is required');
|
||||
|
||||
await expect(service.createApplication({
|
||||
tenantId: 'tenant-1',
|
||||
name: '错误前缀',
|
||||
cmppAccount: '123456',
|
||||
cmppApplicationExtension: '0001',
|
||||
cmppAccessNumberFillEnabled: true,
|
||||
cmppAccessNumberFillPrefix: 'AB',
|
||||
})).rejects.toThrow('cmppAccessNumberFillPrefix must contain digits only');
|
||||
});
|
||||
|
||||
it('rejects invalid enterprise application queue priority', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
@@ -10,6 +10,9 @@ export interface CreateSmsApplicationDto {
|
||||
callbackUrl?: string;
|
||||
cmppAccount?: string;
|
||||
cmppEnterpriseCode?: string;
|
||||
cmppApplicationExtension?: string;
|
||||
cmppAccessNumberFillEnabled?: boolean;
|
||||
cmppAccessNumberFillPrefix?: string;
|
||||
passwordCipher?: string;
|
||||
interfaceEnabled?: boolean;
|
||||
interfaceType?: string;
|
||||
@@ -206,7 +209,9 @@ export class SmsConfigService {
|
||||
sentToday: todayTotal,
|
||||
deliveryRate: todayTotal > 0 ? Number(((delivered / todayTotal) * 100).toFixed(1)) : 0,
|
||||
};
|
||||
});
|
||||
}).sort((left, right) => right.sentToday - left.sentToday
|
||||
|| left.name.localeCompare(right.name, 'zh-CN')
|
||||
|| left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
async getApplication(applicationId: string, tenantId?: string) {
|
||||
@@ -350,6 +355,8 @@ export class SmsConfigService {
|
||||
const interfaceType = normalizeApplicationInterfaceType(data.interfaceType);
|
||||
const cmppAccount = data.cmppAccount ? await this.validateAndReserveCmppAccount(data.cmppAccount) : await this.generateCmppAccount();
|
||||
const cmppEnterpriseCode = cmppAccount;
|
||||
const accessNumber = normalizeCmppAccessNumberConfig(data);
|
||||
await this.validateClientSrcIdAvailable(accessNumber.clientSrcId);
|
||||
return this.prisma.smsApplication.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
@@ -358,6 +365,10 @@ export class SmsConfigService {
|
||||
callbackUrl: data.callbackUrl,
|
||||
cmppAccount,
|
||||
cmppEnterpriseCode,
|
||||
cmppApplicationExtension: accessNumber.applicationExtension,
|
||||
cmppAccessNumberFillEnabled: accessNumber.fillEnabled,
|
||||
cmppAccessNumberFillPrefix: accessNumber.fillPrefix,
|
||||
cmppClientSrcId: accessNumber.clientSrcId,
|
||||
secretHash: secret,
|
||||
interfaceEnabled: data.interfaceEnabled ?? true,
|
||||
interfaceType,
|
||||
@@ -396,6 +407,15 @@ export class SmsConfigService {
|
||||
const secretHash = data.passwordCipher === undefined
|
||||
? undefined
|
||||
: normalizeApplicationPassword(data.passwordCipher);
|
||||
const accessNumberChanged = data.cmppApplicationExtension !== undefined
|
||||
|| data.cmppAccessNumberFillEnabled !== undefined
|
||||
|| data.cmppAccessNumberFillPrefix !== undefined;
|
||||
const accessNumber = accessNumberChanged
|
||||
? normalizeCmppAccessNumberConfig(data, application)
|
||||
: undefined;
|
||||
if (accessNumber?.clientSrcId && accessNumber.clientSrcId !== application.cmppClientSrcId) {
|
||||
await this.validateClientSrcIdAvailable(accessNumber.clientSrcId, applicationId);
|
||||
}
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (data.ipAllowlist) {
|
||||
@@ -409,6 +429,10 @@ export class SmsConfigService {
|
||||
callbackUrl: data.callbackUrl,
|
||||
cmppAccount,
|
||||
cmppEnterpriseCode,
|
||||
cmppApplicationExtension: accessNumber?.applicationExtension,
|
||||
cmppAccessNumberFillEnabled: accessNumber?.fillEnabled,
|
||||
cmppAccessNumberFillPrefix: accessNumber?.fillPrefix,
|
||||
cmppClientSrcId: accessNumber?.clientSrcId,
|
||||
secretHash,
|
||||
interfaceEnabled: data.interfaceEnabled,
|
||||
interfaceType,
|
||||
@@ -570,7 +594,10 @@ export class SmsConfigService {
|
||||
enterpriseCode: application.cmppEnterpriseCode,
|
||||
account: application.cmppAccount,
|
||||
passwordCipher: application.secretHash,
|
||||
srcId: channel?.srcId ?? '',
|
||||
srcId: application.cmppClientSrcId ?? '',
|
||||
applicationExtension: application.cmppApplicationExtension,
|
||||
accessNumberFillEnabled: application.cmppAccessNumberFillEnabled,
|
||||
accessNumberFillPrefix: application.cmppAccessNumberFillPrefix,
|
||||
interfaceEnabled: application.interfaceEnabled,
|
||||
interfaceType: application.interfaceType,
|
||||
maxConnections: application.cmppMaxConnections,
|
||||
@@ -591,6 +618,14 @@ export class SmsConfigService {
|
||||
return cmppAccount;
|
||||
}
|
||||
|
||||
private async validateClientSrcIdAvailable(clientSrcId: string | null, currentApplicationId?: string) {
|
||||
if (!clientSrcId) return;
|
||||
const exists = await this.prisma.smsApplication.findUnique({ where: { cmppClientSrcId: clientSrcId } });
|
||||
if (exists && exists.id !== currentApplicationId) {
|
||||
throw new BadRequestException('client CMPP Src_Id already exists');
|
||||
}
|
||||
}
|
||||
|
||||
private async generateCmppAccount() {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const cmppAccount = String(randomInt(100000, 1000000));
|
||||
@@ -1422,6 +1457,54 @@ function normalizeApplicationInterfaceType(value?: string): ApplicationInterface
|
||||
return interfaceType as ApplicationInterfaceType;
|
||||
}
|
||||
|
||||
function normalizeCmppAccessNumberConfig(
|
||||
data: Pick<CreateSmsApplicationDto, 'cmppApplicationExtension' | 'cmppAccessNumberFillEnabled' | 'cmppAccessNumberFillPrefix'>,
|
||||
current?: {
|
||||
cmppApplicationExtension?: string | null;
|
||||
cmppAccessNumberFillEnabled?: boolean | null;
|
||||
cmppAccessNumberFillPrefix?: string | null;
|
||||
},
|
||||
) {
|
||||
const applicationExtension = (
|
||||
data.cmppApplicationExtension === undefined
|
||||
? current?.cmppApplicationExtension
|
||||
: data.cmppApplicationExtension
|
||||
)?.trim() || null;
|
||||
const fillEnabled = data.cmppAccessNumberFillEnabled
|
||||
?? current?.cmppAccessNumberFillEnabled
|
||||
?? false;
|
||||
const configuredPrefix = (
|
||||
data.cmppAccessNumberFillPrefix === undefined
|
||||
? current?.cmppAccessNumberFillPrefix
|
||||
: data.cmppAccessNumberFillPrefix
|
||||
)?.trim() || null;
|
||||
|
||||
if (applicationExtension && !/^\d+$/.test(applicationExtension)) {
|
||||
throw new BadRequestException('cmppApplicationExtension must contain digits only');
|
||||
}
|
||||
if (applicationExtension && applicationExtension.length > 21) {
|
||||
throw new BadRequestException('cmppApplicationExtension must not exceed 21 digits');
|
||||
}
|
||||
if (fillEnabled && !applicationExtension) {
|
||||
throw new BadRequestException('cmppApplicationExtension is required when access number filling is enabled');
|
||||
}
|
||||
if (fillEnabled && !configuredPrefix) {
|
||||
throw new BadRequestException('cmppAccessNumberFillPrefix is required when access number filling is enabled');
|
||||
}
|
||||
if (configuredPrefix && !/^\d+$/.test(configuredPrefix)) {
|
||||
throw new BadRequestException('cmppAccessNumberFillPrefix must contain digits only');
|
||||
}
|
||||
|
||||
const fillPrefix = fillEnabled ? configuredPrefix : null;
|
||||
const clientSrcId = applicationExtension
|
||||
? `${fillPrefix ?? ''}${applicationExtension}`
|
||||
: null;
|
||||
if (clientSrcId && clientSrcId.length > 21) {
|
||||
throw new BadRequestException('client CMPP Src_Id must not exceed 21 digits');
|
||||
}
|
||||
return { applicationExtension, fillEnabled, fillPrefix, clientSrcId };
|
||||
}
|
||||
|
||||
function getPositiveInteger(value: number | undefined, fallback: number, fieldName: string) {
|
||||
if (value === undefined || value === null) {
|
||||
return fallback;
|
||||
|
||||
@@ -114,4 +114,23 @@ describe('TenantsService', () => {
|
||||
_sum: { amountCents: true },
|
||||
}));
|
||||
});
|
||||
|
||||
it('sorts management rows by today spend descending with a stable name tie-breaker', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.tenant.findMany.mockResolvedValue([
|
||||
{ id: 'tenant-1', name: '乙企业', code: 'T1', status: 'active', enterpriseCertifications: [] },
|
||||
{ id: 'tenant-2', name: '甲企业', code: 'T2', status: 'active', enterpriseCertifications: [] },
|
||||
{ id: 'tenant-3', name: '丙企业', code: 'T3', status: 'active', enterpriseCertifications: [] },
|
||||
]);
|
||||
prisma.smsMessageRecord.groupBy.mockResolvedValue([
|
||||
{ tenantId: 'tenant-1', _sum: { amountCents: 300 } },
|
||||
{ tenantId: 'tenant-2', _sum: { amountCents: 300 } },
|
||||
{ tenantId: 'tenant-3', _sum: { amountCents: 500 } },
|
||||
]);
|
||||
const service = new TenantsService(prisma as never);
|
||||
|
||||
const rows = await service.listManagementRows();
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual(['tenant-3', 'tenant-2', 'tenant-1']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +71,9 @@ export class TenantsService {
|
||||
account: accountsByTenant.get(tenant.id) ?? null,
|
||||
todaySpendCents: todaySpendByTenant.get(tenant.id) ?? 0,
|
||||
todayRefundCents: todayRefundByTenant.get(tenant.id) ?? 0,
|
||||
}));
|
||||
})).sort((left, right) => right.todaySpendCents - left.todaySpendCents
|
||||
|| left.name.localeCompare(right.name, 'zh-CN')
|
||||
|| left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
get(id: string) {
|
||||
|
||||
Reference in New Issue
Block a user