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
@@ -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;