feat: add application interface controls

This commit is contained in:
hectorzhao
2026-07-09 18:32:26 +08:00
parent 767f0ca9aa
commit ff0756ff9c
14 changed files with 260 additions and 26 deletions
@@ -74,7 +74,7 @@ export class AdminSmsConfigController {
@Post('enterprise-templates')
createTemplate(@Body() body: CreateSmsTemplateDto) {
return this.smsConfig.createTemplate(body);
return this.smsConfig.createTemplate(body, { initialAuditStatus: 'approved' });
}
@Put('enterprise-templates/:id')
@@ -12,6 +12,8 @@ function createPrismaMock() {
cmppEnterpriseCode: 'APP-EC',
cmppMaxConnections: 2,
cmppWindowSize: 32,
interfaceEnabled: true,
interfaceType: 'cmpp20',
queuePriority: 'normal',
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
messageRecords: [{ status: 'delivered' }, { status: 'undelivered' }],
@@ -25,6 +27,8 @@ function createPrismaMock() {
cmppEnterpriseCode: 'APP-EC',
cmppMaxConnections: 2,
cmppWindowSize: 32,
interfaceEnabled: true,
interfaceType: 'cmpp20',
queuePriority: 'normal',
secretHash: '0123456789abcdef',
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
@@ -180,8 +184,11 @@ describe('SmsConfigService', () => {
passwordCipher: '0123456789abcdef',
gatewayHost: '127.0.0.1',
gatewayPort: 17890,
interfaceEnabled: true,
interfaceType: 'cmpp20',
maxConnections: 2,
windowSize: 32,
protocolVersion: 'CMPP2.0',
}));
});
@@ -198,6 +205,8 @@ describe('SmsConfigService', () => {
passwordCipher: '1234567890abcdef',
cmppMaxConnections: 3,
cmppWindowSize: 32,
interfaceEnabled: false,
interfaceType: 'cmpp20',
queuePriority: 'priority',
ipAllowlist: ['10.0.0.1/32'],
})).resolves.toEqual(expect.objectContaining({ id: 'app-new' }));
@@ -211,6 +220,8 @@ describe('SmsConfigService', () => {
secretHash: '1234567890abcdef',
cmppMaxConnections: 3,
cmppWindowSize: 32,
interfaceEnabled: false,
interfaceType: 'cmpp20',
queuePriority: 'priority',
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
}),
@@ -230,6 +241,19 @@ describe('SmsConfigService', () => {
expect(prisma.smsApplication.create).not.toHaveBeenCalled();
});
it('rejects unavailable enterprise application interface types', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.createApplication({
tenantId: 'tenant-1',
name: 'HTTP应用',
interfaceType: 'http',
})).rejects.toThrow('interfaceType only supports cmpp20');
expect(prisma.smsApplication.create).not.toHaveBeenCalled();
});
it('rejects duplicate or invalid CMPP application accounts', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
@@ -420,6 +444,34 @@ describe('SmsConfigService', () => {
}));
});
it('creates admin enterprise templates as approved when requested', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.createTemplate({
tenantId: 'tenant-1',
applicationId: 'app-1',
signatureId: 'sig-1',
name: '运营添加模板',
content: '您的验证码为${code}',
variables: [{ name: 'code', example: '123456', required: true }],
}, { initialAuditStatus: 'approved' })).resolves.toEqual(expect.objectContaining({ id: 'tpl-new' }));
expect(prisma.smsTemplate.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
tenantId: 'tenant-1',
applicationId: 'app-1',
signatureId: 'sig-1',
name: '运营添加模板',
auditStatus: 'approved',
variables: {
create: [{ name: 'code', example: '123456', required: true }],
},
}),
include: { variables: true, application: true, tenant: true, signature: true },
}));
});
it('updates enterprise templates and rebuilds template variables', async () => {
const prisma = createPrismaMock();
const tx = {
+29 -2
View File
@@ -11,6 +11,8 @@ export interface CreateSmsApplicationDto {
cmppAccount?: string;
cmppEnterpriseCode?: string;
passwordCipher?: string;
interfaceEnabled?: boolean;
interfaceType?: string;
cmppMaxConnections?: number;
cmppWindowSize?: number;
dailyLimit?: number;
@@ -64,6 +66,10 @@ export interface CreateSmsTemplateDto {
variables?: Array<{ name: string; example?: string; required?: boolean }>;
}
export interface CreateSmsTemplateOptions {
initialAuditStatus?: string;
}
export type UpdateSmsTemplateDto = Partial<Omit<CreateSmsTemplateDto, 'tenantId'>> & {
auditStatus?: string;
};
@@ -93,6 +99,8 @@ export interface ApplicationListQuery {
const APPLICATION_QUEUE_PRIORITIES = ['normal', 'priority'] as const;
type ApplicationQueuePriority = typeof APPLICATION_QUEUE_PRIORITIES[number];
const APPLICATION_INTERFACE_TYPES = ['cmpp20'] as const;
type ApplicationInterfaceType = typeof APPLICATION_INTERFACE_TYPES[number];
@Injectable()
export class SmsConfigService {
@@ -158,6 +166,7 @@ export class SmsConfigService {
async createApplication(data: CreateSmsApplicationDto) {
const secret = normalizeApplicationPassword(data.passwordCipher);
const queuePriority = normalizeApplicationQueuePriority(data.queuePriority);
const interfaceType = normalizeApplicationInterfaceType(data.interfaceType);
const cmppAccount = data.cmppAccount ? await this.validateAndReserveCmppAccount(data.cmppAccount) : await this.generateCmppAccount();
const cmppEnterpriseCode = await this.resolveCmppEnterpriseCode(data.cmppEnterpriseCode, data.tenantId);
return this.prisma.smsApplication.create({
@@ -169,6 +178,8 @@ export class SmsConfigService {
cmppAccount,
cmppEnterpriseCode,
secretHash: secret,
interfaceEnabled: data.interfaceEnabled ?? true,
interfaceType,
cmppMaxConnections: getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'),
cmppWindowSize: getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'),
dailyLimit: data.dailyLimit,
@@ -198,6 +209,9 @@ export class SmsConfigService {
const cmppEnterpriseCode = data.cmppEnterpriseCode === undefined
? undefined
: normalizeEnterpriseCode(data.cmppEnterpriseCode);
const interfaceType = data.interfaceType === undefined
? undefined
: normalizeApplicationInterfaceType(data.interfaceType);
const secretHash = data.passwordCipher === undefined
? undefined
: normalizeApplicationPassword(data.passwordCipher);
@@ -215,6 +229,8 @@ export class SmsConfigService {
cmppAccount,
cmppEnterpriseCode,
secretHash,
interfaceEnabled: data.interfaceEnabled,
interfaceType,
cmppMaxConnections: data.cmppMaxConnections === undefined ? undefined : getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'),
cmppWindowSize: data.cmppWindowSize === undefined ? undefined : getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'),
dailyLimit: data.dailyLimit,
@@ -373,10 +389,12 @@ export class SmsConfigService {
account: application.cmppAccount,
passwordCipher: application.secretHash,
srcId: channel?.srcId ?? '',
interfaceEnabled: application.interfaceEnabled,
interfaceType: application.interfaceType,
maxConnections: application.cmppMaxConnections,
heartbeatSeconds: 30,
windowSize: application.cmppWindowSize,
protocolVersion: channel?.cmppVersion ?? '3.0',
protocolVersion: application.interfaceType === 'cmpp20' ? 'CMPP2.0' : application.interfaceType,
};
}
@@ -542,7 +560,7 @@ export class SmsConfigService {
});
}
createTemplate(data: CreateSmsTemplateDto) {
createTemplate(data: CreateSmsTemplateDto, options: CreateSmsTemplateOptions = {}) {
return this.prisma.smsTemplate.create({
data: {
tenantId: data.tenantId,
@@ -551,6 +569,7 @@ export class SmsConfigService {
name: data.name,
content: data.content,
category: data.category,
auditStatus: options.initialAuditStatus,
billingUnits: estimateBillingUnits(data.content),
variables: {
create: (data.variables ?? inferTemplateVariables(data.content)).map((variable: TemplateVariableInput) => ({
@@ -833,6 +852,14 @@ function normalizeApplicationQueuePriority(value?: string): ApplicationQueuePrio
return queuePriority as ApplicationQueuePriority;
}
function normalizeApplicationInterfaceType(value?: string): ApplicationInterfaceType {
const interfaceType = value ?? 'cmpp20';
if (!APPLICATION_INTERFACE_TYPES.includes(interfaceType as ApplicationInterfaceType)) {
throw new BadRequestException('interfaceType only supports cmpp20; HTTP interface is not available yet');
}
return interfaceType as ApplicationInterfaceType;
}
function getPositiveInteger(value: number | undefined, fallback: number, fieldName: string) {
if (value === undefined || value === null) {
return fallback;