feat: add access number routing and admin list improvements

This commit is contained in:
hectorzhao
2026-07-15 15:45:27 +08:00
parent 27c464246b
commit 3e114ee729
19 changed files with 547 additions and 508 deletions
@@ -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;
+6
View File
@@ -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();
+67 -4
View File
@@ -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);
+85 -2
View File
@@ -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;
+19
View File
@@ -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']);
});
});
+3 -1
View File
@@ -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) {