feat: add application interface controls
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "SmsApplication"
|
||||
ADD COLUMN "interfaceEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "interfaceType" TEXT NOT NULL DEFAULT 'cmpp20';
|
||||
@@ -344,6 +344,8 @@ model SmsApplication {
|
||||
cmppAccount String @unique
|
||||
cmppEnterpriseCode String
|
||||
secretHash String
|
||||
interfaceEnabled Boolean @default(true)
|
||||
interfaceType String @default("cmpp20")
|
||||
cmppMaxConnections Int @default(1)
|
||||
cmppWindowSize Int @default(16)
|
||||
dailyLimit Int?
|
||||
|
||||
@@ -63,7 +63,7 @@ function createPrismaMock() {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
|
||||
},
|
||||
smsApplication: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', status: 'active', customerUnitPrice: 3, queuePriority: 'normal' }),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', status: 'active', interfaceEnabled: true, customerUnitPrice: 3, queuePriority: 'normal' }),
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'app-1', tenantId: 'tenant-1', name: '应用A' }]),
|
||||
findFirst: jest.fn().mockResolvedValue({
|
||||
id: 'app-1',
|
||||
@@ -71,6 +71,7 @@ function createPrismaMock() {
|
||||
cmppAccount: '100001',
|
||||
secretHash: 'secret-hash',
|
||||
status: 'active',
|
||||
interfaceEnabled: true,
|
||||
queuePriority: 'normal',
|
||||
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
||||
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
||||
@@ -414,6 +415,74 @@ describe('SendChainService', () => {
|
||||
).rejects.toThrow('企业认证未通过,不能发送短信');
|
||||
});
|
||||
|
||||
it('blocks sending when application interface is disabled', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsApplication.findUnique.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
status: 'active',
|
||||
interfaceEnabled: false,
|
||||
customerUnitPrice: 3,
|
||||
queuePriority: 'normal',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createBatchTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001'],
|
||||
}),
|
||||
).rejects.toThrow('短信应用接口未开通,不能发送短信');
|
||||
|
||||
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects Gateway authentication when application interface is disabled', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsApplication.findFirst.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
cmppAccount: '100001',
|
||||
secretHash: 'secret-hash',
|
||||
status: 'active',
|
||||
interfaceEnabled: false,
|
||||
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
||||
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
||||
});
|
||||
|
||||
await expect(service.authenticateInboundApplication({
|
||||
account: '100001',
|
||||
password: 'secret-hash',
|
||||
remoteIp: '127.0.0.1',
|
||||
})).rejects.toThrow('CMPP interface is disabled for this application');
|
||||
});
|
||||
|
||||
it('rejects Gateway submit when application interface is disabled', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsApplication.findFirst.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
cmppAccount: '100001',
|
||||
secretHash: 'secret-hash',
|
||||
status: 'active',
|
||||
interfaceEnabled: false,
|
||||
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',
|
||||
remoteIp: '127.0.0.1',
|
||||
})).rejects.toThrow('CMPP interface is disabled for this application');
|
||||
|
||||
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('previews imported phone files with duplicate, invalid, blacklist, and variable errors', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.enterpriseBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000003' }]);
|
||||
|
||||
@@ -1396,6 +1396,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!application || application.status !== 'active' || application.tenant.status !== 'active') {
|
||||
throw new BadRequestException('CMPP account is invalid or disabled');
|
||||
}
|
||||
if (!application.interfaceEnabled) {
|
||||
throw new BadRequestException('CMPP interface is disabled for this application');
|
||||
}
|
||||
if (application.tenant.certificationStatus !== 'approved') {
|
||||
throw new BadRequestException('Enterprise certification is not approved');
|
||||
}
|
||||
@@ -1419,6 +1422,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!application || application.status !== 'active' || application.tenant.status !== 'active') {
|
||||
throw new BadRequestException('CMPP account is invalid or disabled');
|
||||
}
|
||||
if (!application.interfaceEnabled) {
|
||||
throw new BadRequestException('CMPP interface is disabled for this application');
|
||||
}
|
||||
if (data.remoteIp && !isApplicationIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
throw new BadRequestException('CMPP source IP is not in application allowlist');
|
||||
}
|
||||
@@ -1787,6 +1793,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!application || application.tenantId !== tenantId || application.status !== 'active') {
|
||||
throw new BadRequestException('短信应用不存在或已停用');
|
||||
}
|
||||
if (!application.interfaceEnabled) {
|
||||
throw new BadRequestException('短信应用接口未开通,不能发送短信');
|
||||
}
|
||||
if (!templateId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user