feat: improve application access and money precision

This commit is contained in:
hectorzhao
2026-07-16 17:54:05 +08:00
parent 9d5c507007
commit faa716b8d0
49 changed files with 1699 additions and 489 deletions
+49 -5
View File
@@ -38,6 +38,7 @@ function createPrismaMock() {
interfaceType: 'cmpp20',
queuePriority: 'normal',
secretHash: '0123456789abcdef',
ipAllowlist: [],
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
}),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })),
@@ -261,9 +262,11 @@ describe('SmsConfigService', () => {
expect(applications.map((application) => application.id)).toEqual(['app-3', 'app-2', 'app-1']);
});
it('returns CMPP params from persisted application and channel config', async () => {
it('returns CMPP params from the public inbound Gateway config instead of an upstream channel', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
process.env.CMPP_PUBLIC_HOST = 'cmpp.example.com';
process.env.CMPP_PUBLIC_PORT = '17891';
await expect(service.getApplicationCmppParams('app-1')).resolves.toEqual(expect.objectContaining({
applicationId: 'app-1',
@@ -275,14 +278,17 @@ describe('SmsConfigService', () => {
applicationExtension: '0001',
accessNumberFillEnabled: true,
accessNumberFillPrefix: '00',
gatewayHost: '127.0.0.1',
gatewayPort: 17890,
gatewayHost: 'cmpp.example.com',
gatewayPort: 17891,
interfaceEnabled: true,
interfaceType: 'cmpp20',
maxConnections: 2,
windowSize: 32,
protocolVersion: 'CMPP2.0',
}));
expect(prisma.smsChannel.findFirst).not.toHaveBeenCalled();
delete process.env.CMPP_PUBLIC_HOST;
delete process.env.CMPP_PUBLIC_PORT;
});
it('creates enterprise applications with persisted queue priority', async () => {
@@ -428,7 +434,9 @@ describe('SmsConfigService', () => {
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
const service = new SmsConfigService(prisma as never);
await expect(service.updateApplication('app-1', { name: '新应用', customerUnitPrice: 300, queuePriority: 'priority', ipAllowlist: ['10.0.0.1/32'] }))
await expect(service.updateApplication('app-1', { customerUnitPrice: 325.5 }))
.rejects.toThrow('客户单价最多支持人民币小数点后 4 位');
await expect(service.updateApplication('app-1', { name: '新应用', customerUnitPrice: 325, queuePriority: 'priority', ipAllowlist: ['10.0.0.1/32'] }))
.resolves.toEqual(expect.objectContaining({ id: 'app-1', name: '新应用' }));
expect(tx.smsApplicationIpAllowlist.deleteMany).toHaveBeenCalledWith({ where: { applicationId: 'app-1' } });
@@ -437,7 +445,7 @@ describe('SmsConfigService', () => {
data: expect.objectContaining({
name: '新应用',
cmppEnterpriseCode: '100001',
customerUnitPrice: 300,
customerUnitPrice: 325,
queuePriority: 'priority',
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
}),
@@ -498,6 +506,17 @@ describe('SmsConfigService', () => {
await expect(service.getApplicationCmppParams('app-1', 'tenant-2')).rejects.toThrow('Application not found');
});
it('forbids client CMPP parameter access when the interface is not enabled', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1', tenantId: 'tenant-1', interfaceEnabled: false,
tenant: { id: 'tenant-1', name: '租户A' },
});
const service = new SmsConfigService(prisma as never);
await expect(service.getApplicationCmppParams('app-1', 'tenant-1')).rejects.toThrow('该企业应用未开通 CMPP 接口');
});
it('records Gateway downstream CMPP connection and heartbeat events against the real application account', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
@@ -531,6 +550,31 @@ describe('SmsConfigService', () => {
});
});
it('rejects connection heartbeats after an IP allowlist change or connection-limit reduction', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1', tenantId: 'tenant-1', cmppEnterpriseCode: 'APP-EC', cmppMaxConnections: 1,
interfaceEnabled: true, status: 'active', ipAllowlist: [{ ipCidr: '10.0.0.0/24' }],
});
prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({ id: 'downstream-2', connectedAt: new Date() });
const service = new SmsConfigService(prisma as never);
await expect(service.recordDownstreamConnectionEvent({
account: '100001', connectionId: 'gateway-1-2', status: 'heartbeat', remoteIp: '127.0.0.1',
})).rejects.toThrow('CMPP source IP is not in application allowlist');
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1', tenantId: 'tenant-1', cmppEnterpriseCode: 'APP-EC', cmppMaxConnections: 1,
interfaceEnabled: true, status: 'active', ipAllowlist: [],
});
prisma.cmppDownstreamConnection.findMany.mockResolvedValue([
{ connectionId: 'gateway-1-1' }, { connectionId: 'gateway-1-2' },
]);
await expect(service.recordDownstreamConnectionEvent({
account: '100001', connectionId: 'gateway-1-2', status: 'heartbeat', remoteIp: '127.0.0.1',
})).rejects.toThrow('CMPP connection limit exceeded (1)');
});
it.each([
['heartbeat', 'lastHeartbeatAt'],
['submit', 'lastSubmitAt'],
+29 -8
View File
@@ -1,6 +1,8 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomInt, randomUUID } from 'node:crypto';
import { isIpAllowed } from '../common/ip-allowlist';
import { assertMoneyUnits } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
export interface CreateSmsApplicationDto {
@@ -358,6 +360,7 @@ export class SmsConfigService {
}
async createApplication(data: CreateSmsApplicationDto) {
assertMoneyUnits(data.customerUnitPrice ?? 0, '客户单价');
const secret = normalizeApplicationPassword(data.passwordCipher);
const queuePriority = normalizeApplicationQueuePriority(data.queuePriority);
const interfaceType = normalizeApplicationInterfaceType(data.interfaceType);
@@ -402,6 +405,9 @@ export class SmsConfigService {
if (!application) {
throw new NotFoundException('Application not found');
}
if (data.customerUnitPrice !== undefined) {
assertMoneyUnits(data.customerUnitPrice, '客户单价');
}
const queuePriority = data.queuePriority === undefined
? undefined
: normalizeApplicationQueuePriority(data.queuePriority);
@@ -587,18 +593,17 @@ export class SmsConfigService {
if (!application || (tenantId && application.tenantId !== tenantId)) {
throw new NotFoundException('Application not found');
}
const channel = await this.prisma.smsChannel.findFirst({
where: { status: { not: 'deleted' } },
orderBy: { createdAt: 'desc' },
});
if (tenantId && !application.interfaceEnabled) {
throw new ForbiddenException('该企业应用未开通 CMPP 接口');
}
return {
applicationId: application.id,
applicationName: application.name,
tenantId: application.tenantId,
tenantName: application.tenant.name,
appCode: application.id,
gatewayHost: channel?.gatewayHost ?? '',
gatewayPort: channel?.gatewayPort ?? 0,
gatewayHost: process.env.CMPP_PUBLIC_HOST?.trim() || '127.0.0.1',
gatewayPort: getPositiveIntegerEnv('CMPP_PUBLIC_PORT', 17890),
enterpriseCode: application.cmppEnterpriseCode,
account: application.cmppAccount,
passwordCipher: application.secretHash,
@@ -648,7 +653,7 @@ export class SmsConfigService {
async recordDownstreamConnectionEvent(data: GatewayDownstreamConnectionEventDto) {
const application = await this.prisma.smsApplication.findUnique({
where: { cmppAccount: data.account },
select: { id: true, tenantId: true, cmppEnterpriseCode: true },
include: { ipAllowlist: true },
});
if (!application) {
throw new BadRequestException('CMPP account does not reference an application');
@@ -670,6 +675,22 @@ export class SmsConfigService {
});
return { connectionId: data.connectionId, status: 'disconnected', deleted: Boolean(existing) };
}
if (!application.interfaceEnabled || application.status !== 'active') {
throw new ForbiddenException('CMPP interface is disabled for this application');
}
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new ForbiddenException('CMPP source IP is not in application allowlist');
}
const activeConnections = await this.prisma.cmppDownstreamConnection.findMany({
where: { applicationId: application.id, status: 'connected' },
select: { connectionId: true },
orderBy: [{ connectedAt: 'asc' }, { connectionId: 'asc' }],
});
const allowedConnectionIds = activeConnections.slice(0, application.cmppMaxConnections).map((item) => item.connectionId);
if ((!existing && activeConnections.length >= application.cmppMaxConnections)
|| (existing && activeConnections.length > application.cmppMaxConnections && !allowedConnectionIds.includes(data.connectionId))) {
throw new ForbiddenException(`CMPP connection limit exceeded (${application.cmppMaxConnections})`);
}
const payload = {
tenantId: application.tenantId,
applicationId: application.id,