feat: add cmpp inbound gateway listener
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
GatewayInboundAuthDto,
|
||||
GatewayInboundSubmitDto,
|
||||
GatewayReceiptEventDto,
|
||||
GatewaySubmitResultDto,
|
||||
GatewayUplinkEventDto,
|
||||
@@ -26,5 +28,14 @@ export class GatewayEventsController {
|
||||
uplink(@Body() body: GatewayUplinkEventDto) {
|
||||
return this.sendChain.handleUplink(body);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('inbound/authenticate')
|
||||
authenticateInbound(@Body() body: GatewayInboundAuthDto) {
|
||||
return this.sendChain.authenticateInboundApplication(body);
|
||||
}
|
||||
|
||||
@Post('inbound/submit')
|
||||
submitInbound(@Body() body: GatewayInboundSubmitDto) {
|
||||
return this.sendChain.submitInboundMessage(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,17 @@ function createPrismaMock() {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
|
||||
},
|
||||
smsApplication: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', status: 'active', customerUnitPrice: 3, queuePriority: 'normal' }),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', status: 'active', customerUnitPrice: 3, queuePriority: 'normal' }),
|
||||
findFirst: jest.fn().mockResolvedValue({
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
cmppAccount: '100001',
|
||||
secretHash: 'secret-hash',
|
||||
status: 'active',
|
||||
queuePriority: 'normal',
|
||||
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
||||
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
||||
}),
|
||||
},
|
||||
smsTemplate: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
@@ -69,6 +79,14 @@ function createPrismaMock() {
|
||||
auditStatus: 'approved',
|
||||
signature: { auditStatus: 'approved', reportStatus: 'approved' },
|
||||
}),
|
||||
findFirst: jest.fn().mockResolvedValue({
|
||||
id: 'tpl-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
content: 'hello',
|
||||
auditStatus: 'approved',
|
||||
signature: { auditStatus: 'approved', reportStatus: 'approved' },
|
||||
}),
|
||||
},
|
||||
smsBatchTask: {
|
||||
create: jest.fn().mockResolvedValue(task),
|
||||
|
||||
@@ -2,6 +2,8 @@ import { BadRequestException, Injectable, NotFoundException, OnModuleDestroy, On
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { isIP } from 'node:net';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -20,6 +22,25 @@ export interface CreateBatchTaskDto {
|
||||
createdById?: string;
|
||||
sourceIp?: string;
|
||||
userAgent?: string;
|
||||
sourceType?: 'client' | 'api' | 'cmpp';
|
||||
}
|
||||
|
||||
export interface GatewayInboundAuthDto {
|
||||
account: string;
|
||||
password?: string;
|
||||
authSource?: string;
|
||||
timestamp?: number;
|
||||
remoteIp?: string;
|
||||
}
|
||||
|
||||
export interface GatewayInboundSubmitDto {
|
||||
account: string;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
srcId?: string;
|
||||
destId?: string;
|
||||
sequenceId?: number;
|
||||
remoteIp?: string;
|
||||
}
|
||||
|
||||
export interface GatewaySubmitResultDto {
|
||||
@@ -176,7 +197,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
applicationId: data.applicationId,
|
||||
templateId: data.templateId,
|
||||
taskNo: `BT-${Date.now()}-${randomUUID().slice(0, 8)}`,
|
||||
sourceType: 'client',
|
||||
sourceType: data.sourceType ?? 'client',
|
||||
content: data.content,
|
||||
category: data.category,
|
||||
phoneTotal: phones.length,
|
||||
@@ -628,6 +649,63 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
|
||||
const application = await this.findInboundApplication(data.account);
|
||||
if (!application || application.status !== 'active' || application.tenant.status !== 'active') {
|
||||
throw new BadRequestException('CMPP account is invalid or disabled');
|
||||
}
|
||||
if (application.tenant.certificationStatus !== 'approved') {
|
||||
throw new BadRequestException('Enterprise certification is not approved');
|
||||
}
|
||||
if (!matchesApplicationSecret(data, application.secretHash)) {
|
||||
throw new BadRequestException('CMPP account or password is invalid');
|
||||
}
|
||||
if (data.remoteIp && !isApplicationIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
throw new BadRequestException('CMPP source IP is not in application allowlist');
|
||||
}
|
||||
return {
|
||||
applicationId: application.id,
|
||||
tenantId: application.tenantId,
|
||||
account: application.cmppAccount,
|
||||
passwordCipher: application.secretHash,
|
||||
status: 'authenticated',
|
||||
};
|
||||
}
|
||||
|
||||
async submitInboundMessage(data: GatewayInboundSubmitDto) {
|
||||
const application = await this.findInboundApplication(data.account);
|
||||
if (!application || application.status !== 'active' || application.tenant.status !== 'active') {
|
||||
throw new BadRequestException('CMPP account is invalid or disabled');
|
||||
}
|
||||
if (data.remoteIp && !isApplicationIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
throw new BadRequestException('CMPP source IP is not in application allowlist');
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(data.phoneNumber)) {
|
||||
throw new BadRequestException('CMPP submit phone number is invalid');
|
||||
}
|
||||
const template = await this.resolveInboundTemplate(application.id, data.content);
|
||||
const task = await this.createBatchTask({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
templateId: template.id,
|
||||
content: data.content,
|
||||
phones: [data.phoneNumber],
|
||||
sourceType: 'cmpp',
|
||||
sourceIp: data.remoteIp,
|
||||
userAgent: 'cmpp-gateway',
|
||||
});
|
||||
const message = task?.messages?.[0];
|
||||
return {
|
||||
accepted: true,
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
taskId: task?.id,
|
||||
messageId: message?.messageId,
|
||||
messageRecordId: message?.id,
|
||||
status: message?.status ?? task?.status,
|
||||
};
|
||||
}
|
||||
|
||||
async markUnknownTimeout(data: TimeoutUnknownDto) {
|
||||
const olderThanHours = data.olderThanHours ?? 72;
|
||||
const cutoff = new Date(Date.now() - olderThanHours * 60 * 60 * 1000);
|
||||
@@ -911,6 +989,36 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return normalizeQueuePriority(application.queuePriority);
|
||||
}
|
||||
|
||||
private findInboundApplication(account: string) {
|
||||
return this.prisma.smsApplication.findFirst({
|
||||
where: { cmppAccount: account },
|
||||
include: {
|
||||
tenant: true,
|
||||
ipAllowlist: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveInboundTemplate(applicationId: string, content: string) {
|
||||
const template = await this.prisma.smsTemplate.findFirst({
|
||||
where: {
|
||||
applicationId,
|
||||
content,
|
||||
auditStatus: 'approved',
|
||||
signature: {
|
||||
auditStatus: 'approved',
|
||||
reportStatus: 'approved',
|
||||
},
|
||||
},
|
||||
include: { signature: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
if (!template) {
|
||||
throw new BadRequestException('CMPP submit content does not match an approved template and signature');
|
||||
}
|
||||
return template;
|
||||
}
|
||||
|
||||
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
|
||||
if (!tenant || tenant.status !== 'active') {
|
||||
@@ -1270,3 +1378,64 @@ function bullmqConnection() {
|
||||
maxRetriesPerRequest: null,
|
||||
};
|
||||
}
|
||||
|
||||
function matchesApplicationSecret(data: GatewayInboundAuthDto, secretHash: string) {
|
||||
if (data.authSource && data.timestamp !== undefined) {
|
||||
const expected = createHash('md5')
|
||||
.update(Buffer.concat([
|
||||
Buffer.from(octetString(data.account, 6), 'binary'),
|
||||
Buffer.alloc(9),
|
||||
Buffer.from(secretHash),
|
||||
Buffer.from(String(data.timestamp).padStart(10, '0')),
|
||||
]))
|
||||
.digest('base64');
|
||||
return expected === data.authSource;
|
||||
}
|
||||
if (!data.password) {
|
||||
return false;
|
||||
}
|
||||
return data.password === secretHash || createHash('sha256').update(data.password).digest('hex') === secretHash;
|
||||
}
|
||||
|
||||
function octetString(value: string, fixedLength: number) {
|
||||
if (value.length === fixedLength) {
|
||||
return value;
|
||||
}
|
||||
if (value.length > fixedLength) {
|
||||
return value.slice(value.length - fixedLength);
|
||||
}
|
||||
return value + '\0'.repeat(fixedLength - value.length);
|
||||
}
|
||||
|
||||
function isApplicationIpAllowed(remoteIp: string, allowlist: string[]) {
|
||||
const normalizedRemoteIp = normalizeIp(remoteIp);
|
||||
if (allowlist.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return allowlist.some((rule) => ipMatchesRule(normalizedRemoteIp, rule));
|
||||
}
|
||||
|
||||
function ipMatchesRule(remoteIp: string, rule: string) {
|
||||
const normalizedRule = normalizeIp(rule.trim());
|
||||
if (!normalizedRule) {
|
||||
return false;
|
||||
}
|
||||
if (!normalizedRule.includes('/')) {
|
||||
return remoteIp === normalizedRule;
|
||||
}
|
||||
const [network, prefixText] = normalizedRule.split('/');
|
||||
const prefix = Number(prefixText);
|
||||
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32 || isIP(remoteIp) !== 4 || isIP(network) !== 4) {
|
||||
return false;
|
||||
}
|
||||
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
||||
return (ipv4ToInt(remoteIp) & mask) === (ipv4ToInt(network) & mask);
|
||||
}
|
||||
|
||||
function normalizeIp(value: string) {
|
||||
return value.replace(/^::ffff:/, '').trim();
|
||||
}
|
||||
|
||||
function ipv4ToInt(value: string) {
|
||||
return value.split('.').reduce((result, part) => ((result << 8) + Number(part)) >>> 0, 0);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ function createPrismaMock() {
|
||||
tenantId: 'tenant-1',
|
||||
name: '应用A',
|
||||
status: 'active',
|
||||
cmppAccount: '100001',
|
||||
queuePriority: 'normal',
|
||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||
messageRecords: [{ status: 'delivered' }, { status: 'undelivered' }],
|
||||
@@ -17,6 +18,7 @@ function createPrismaMock() {
|
||||
tenantId: 'tenant-1',
|
||||
name: '应用A',
|
||||
status: 'active',
|
||||
cmppAccount: '100001',
|
||||
queuePriority: 'normal',
|
||||
secretHash: 'secret-hash',
|
||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||
@@ -164,6 +166,7 @@ describe('SmsConfigService', () => {
|
||||
await expect(service.getApplicationCmppParams('app-1')).resolves.toEqual(expect.objectContaining({
|
||||
applicationId: 'app-1',
|
||||
tenantName: '租户A',
|
||||
account: '100001',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 17890,
|
||||
maxConnections: 2,
|
||||
@@ -172,6 +175,7 @@ describe('SmsConfigService', () => {
|
||||
|
||||
it('creates enterprise applications with persisted queue priority', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsApplication.findUnique.mockResolvedValueOnce(null);
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.createApplication({
|
||||
@@ -185,6 +189,7 @@ describe('SmsConfigService', () => {
|
||||
data: expect.objectContaining({
|
||||
tenantId: 'tenant-1',
|
||||
name: '优先应用',
|
||||
cmppAccount: expect.stringMatching(/^\d{6}$/),
|
||||
queuePriority: 'priority',
|
||||
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
|
||||
}),
|
||||
@@ -195,11 +200,11 @@ describe('SmsConfigService', () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
expect(() => service.createApplication({
|
||||
await expect(service.createApplication({
|
||||
tenantId: 'tenant-1',
|
||||
name: '异常应用',
|
||||
queuePriority: 'urgent',
|
||||
})).toThrow('queuePriority must be normal or priority');
|
||||
})).rejects.toThrow('queuePriority must be normal or priority');
|
||||
|
||||
expect(prisma.smsApplication.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { randomBytes, randomInt, createHash } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface CreateSmsApplicationDto {
|
||||
@@ -150,15 +150,17 @@ export class SmsConfigService {
|
||||
return application;
|
||||
}
|
||||
|
||||
createApplication(data: CreateSmsApplicationDto) {
|
||||
async createApplication(data: CreateSmsApplicationDto) {
|
||||
const secret = randomBytes(24).toString('hex');
|
||||
const queuePriority = normalizeApplicationQueuePriority(data.queuePriority);
|
||||
const cmppAccount = await this.generateCmppAccount();
|
||||
return this.prisma.smsApplication.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
name: data.name,
|
||||
scene: data.scene,
|
||||
callbackUrl: data.callbackUrl,
|
||||
cmppAccount,
|
||||
secretHash: hashSecret(secret),
|
||||
dailyLimit: data.dailyLimit,
|
||||
customerUnitPrice: data.customerUnitPrice ?? 0,
|
||||
@@ -345,8 +347,8 @@ export class SmsConfigService {
|
||||
gatewayHost: channel?.gatewayHost ?? '',
|
||||
gatewayPort: channel?.gatewayPort ?? 0,
|
||||
enterpriseCode: channel?.enterpriseCode ?? application.tenant.code,
|
||||
account: channel?.account ?? application.tenant.code,
|
||||
passwordCipher: channel?.passwordCipher ?? application.secretHash,
|
||||
account: application.cmppAccount,
|
||||
passwordCipher: application.secretHash,
|
||||
srcId: channel?.srcId ?? '',
|
||||
maxConnections: channel?.config && typeof channel.config === 'object' && 'maxConnections' in channel.config ? Number(channel.config.maxConnections) : 1,
|
||||
heartbeatSeconds: 30,
|
||||
@@ -355,6 +357,17 @@ export class SmsConfigService {
|
||||
};
|
||||
}
|
||||
|
||||
private async generateCmppAccount() {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const cmppAccount = String(randomInt(100000, 1000000));
|
||||
const exists = await this.prisma.smsApplication.findUnique({ where: { cmppAccount } });
|
||||
if (!exists) {
|
||||
return cmppAccount;
|
||||
}
|
||||
}
|
||||
throw new BadRequestException('Unable to generate unique CMPP account');
|
||||
}
|
||||
|
||||
async disconnectApplicationConnection(applicationId: string, connectionId: string, data: StatusChangeDto = { status: 'disconnected' }) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
||||
if (!application) {
|
||||
|
||||
Reference in New Issue
Block a user