fix: close admin channel test sms loop
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
CreateReportMaterialDto,
|
||||
CreateReportTaskDto,
|
||||
CreateRouteRuleDto,
|
||||
TestChannelDto,
|
||||
UpsertConnectionStateDto,
|
||||
UpdateChannelDto,
|
||||
UpdateChannelGroupDto,
|
||||
@@ -39,8 +40,8 @@ export class ChannelsController {
|
||||
}
|
||||
|
||||
@Post('channels/:id/test')
|
||||
testChannel(@Param('id') channelId: string) {
|
||||
return this.channels.testChannel(channelId);
|
||||
testChannel(@Param('id') channelId: string, @Body() body: TestChannelDto) {
|
||||
return this.channels.testChannel(channelId, body);
|
||||
}
|
||||
|
||||
@Post('channels/:id/status')
|
||||
|
||||
@@ -7,6 +7,8 @@ const mockFetch = jest.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
text: jest.fn().mockResolvedValue(''),
|
||||
});
|
||||
const mockRedisXadd = jest.fn().mockResolvedValue('1710000000000-0');
|
||||
const mockRedisDisconnect = jest.fn();
|
||||
|
||||
jest.mock('bullmq', () => ({
|
||||
Queue: jest.fn().mockImplementation(() => ({
|
||||
@@ -15,6 +17,11 @@ jest.mock('bullmq', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('ioredis', () => jest.fn().mockImplementation(() => ({
|
||||
xadd: mockRedisXadd,
|
||||
disconnect: mockRedisDisconnect,
|
||||
})));
|
||||
|
||||
function createPrismaMock() {
|
||||
const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'pending' };
|
||||
const channel = {
|
||||
@@ -29,12 +36,13 @@ function createPrismaMock() {
|
||||
account: 'sp',
|
||||
passwordCipher: 'secret',
|
||||
srcId: '10690000',
|
||||
cmppVersion: '3.0',
|
||||
cmppVersion: '2.0',
|
||||
rateLimitPerSecond: 100,
|
||||
unitPrice: 3,
|
||||
status: 'active',
|
||||
config: { serviceId: 'SMS' },
|
||||
sendRegion: '山东',
|
||||
connectionStates: [{ id: 'state-1', connectionId: 'conn-a', status: 'connected', currentConnections: 1 }],
|
||||
reportFields: [{ code: 'license', name: '营业执照', fieldType: 'file', required: true, description: null, sortOrder: 1, status: 'active' }],
|
||||
};
|
||||
return {
|
||||
@@ -113,6 +121,22 @@ function createPrismaMock() {
|
||||
smsApplication: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
||||
},
|
||||
tenant: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', createdAt: new Date('2026-07-09T00:00:00.000Z') }),
|
||||
},
|
||||
smsBatchTask: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'batch-1', ...data })),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'batch-1', ...data })),
|
||||
},
|
||||
cmppSubmitSession: {
|
||||
upsert: jest.fn().mockResolvedValue({ id: 'session-1', channelId: 'channel-1', sessionNo: 'OPEN-channel-1' }),
|
||||
},
|
||||
smsMessageRecord: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'message-record-1', ...data })),
|
||||
},
|
||||
smsSubmitRecord: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'submit-record-1', ...data })),
|
||||
},
|
||||
cmppConnectionState: {
|
||||
findMany: jest.fn(),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
@@ -132,6 +156,8 @@ describe('ChannelsService', () => {
|
||||
mockQueueAdd.mockClear();
|
||||
mockQueueClose.mockClear();
|
||||
mockFetch.mockClear();
|
||||
mockRedisXadd.mockClear();
|
||||
mockRedisDisconnect.mockClear();
|
||||
global.fetch = mockFetch as never;
|
||||
});
|
||||
|
||||
@@ -164,7 +190,7 @@ describe('ChannelsService', () => {
|
||||
expect(prisma.smsChannel.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
protocol: 'CMPP',
|
||||
cmppVersion: '3.0',
|
||||
cmppVersion: '2.0',
|
||||
rateLimitPerSecond: 100,
|
||||
sendRegion: '全国',
|
||||
status: 'active',
|
||||
@@ -185,6 +211,7 @@ describe('ChannelsService', () => {
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'channel-1:primary',
|
||||
reason: 'channel_created',
|
||||
channel: expect.objectContaining({ cmppVersion: '2.0' }),
|
||||
}), { jobId: 'channel-1:primary:connect' });
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
|
||||
method: 'POST',
|
||||
@@ -206,6 +233,36 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves explicit CMPP 3.0 and rejects unsupported CMPP versions', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await service.createChannel({
|
||||
code: 'CMPP-3',
|
||||
name: '3.0通道',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 17890,
|
||||
account: 'sp',
|
||||
passwordCipher: 'secret',
|
||||
srcId: '10690000',
|
||||
cmppVersion: '3.0',
|
||||
});
|
||||
|
||||
expect(prisma.smsChannel.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ cmppVersion: '3.0' }),
|
||||
});
|
||||
await expect(service.createChannel({
|
||||
code: 'BAD',
|
||||
name: '非法版本',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 17890,
|
||||
account: 'sp',
|
||||
passwordCipher: 'secret',
|
||||
srcId: '10690000',
|
||||
cmppVersion: '1.0',
|
||||
})).rejects.toThrow('cmppVersion must be 2.0 or 3.0');
|
||||
});
|
||||
|
||||
it('updates CMPP channel configuration without requiring password changes', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
@@ -525,6 +582,89 @@ describe('ChannelsService', () => {
|
||||
expect(prisma.smsChannel.update).toHaveBeenCalledWith({ where: { id: 'channel-1' }, data: { status: 'deleted' } });
|
||||
});
|
||||
|
||||
it('queues channel test SMS through real message records and gateway stream', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
const result = await service.testChannel('channel-1', {
|
||||
phoneNumber: '18821203795',
|
||||
content: '【安徽航天信息】您的验证码是070926,有效时间30分钟。',
|
||||
operatorId: 'admin-1',
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
channelId: 'channel-1',
|
||||
status: 'submit_queued',
|
||||
submitted: 1,
|
||||
batchTaskId: 'batch-1',
|
||||
}));
|
||||
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tenantId: 'tenant-1',
|
||||
sourceType: 'admin_channel_test',
|
||||
phoneTotal: 1,
|
||||
status: 'submit_queued',
|
||||
}),
|
||||
});
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: 'batch-1',
|
||||
phoneNumber: '18821203795',
|
||||
channelId: 'channel-1',
|
||||
status: 'submit_queued',
|
||||
submitStatus: 'queued',
|
||||
}),
|
||||
});
|
||||
expect(prisma.smsSubmitRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: 'batch-1',
|
||||
channelId: 'channel-1',
|
||||
sessionId: 'session-1',
|
||||
submitStatus: 'queued',
|
||||
}),
|
||||
});
|
||||
expect(mockQueueAdd).toHaveBeenCalledWith('submit-command', expect.objectContaining({
|
||||
messageType: 'SubmitCommand',
|
||||
channelId: 'channel-1',
|
||||
phoneNumber: '18821203795',
|
||||
content: '【安徽航天信息】您的验证码是070926,有效时间30分钟。',
|
||||
upstream: expect.objectContaining({ cmppVersion: '2.0', gatewayHost: '127.0.0.1' }),
|
||||
}));
|
||||
expect(mockRedisXadd).toHaveBeenCalledWith(
|
||||
'gateway.submit.commands',
|
||||
'*',
|
||||
'messageType',
|
||||
'SubmitCommand',
|
||||
'data',
|
||||
expect.stringContaining('"phoneNumber":"18821203795"'),
|
||||
);
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: 'sms_channel.test_submit',
|
||||
resource: 'sms_channel',
|
||||
resourceId: 'channel-1',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects channel test SMS when no CMPP connection is online', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsChannel.findUnique.mockResolvedValueOnce({
|
||||
...(await prisma.smsChannel.findUnique()),
|
||||
connectionStates: [{ connectionId: 'conn-a', status: 'failed', currentConnections: 0 }],
|
||||
});
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await expect(service.testChannel('channel-1', {
|
||||
phoneNumber: '18821203795',
|
||||
content: '测试短信',
|
||||
})).rejects.toThrow('通道当前没有可用 CMPP 连接');
|
||||
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
|
||||
expect(mockRedisXadd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upserts and lists CMPP connection states', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
@@ -546,7 +686,7 @@ describe('ChannelsService', () => {
|
||||
where: { applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a' },
|
||||
});
|
||||
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', status: 'connected' }),
|
||||
data: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', status: 'connected', lastError: null }),
|
||||
});
|
||||
expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({
|
||||
where: { channelId: 'channel-1' },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -144,17 +145,30 @@ export interface CopyChannelDto {
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface TestChannelDto {
|
||||
phoneNumber?: string;
|
||||
phones?: string[] | string;
|
||||
content?: string;
|
||||
accessNo?: string;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.commands';
|
||||
const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue';
|
||||
const GATEWAY_SUBMIT_STREAM = 'gateway.submit.commands';
|
||||
const DEFAULT_GATEWAY_CONTROL_URL = 'http://127.0.0.1:8090';
|
||||
const DEFAULT_CHANNEL_CONNECTION_ID = 'primary';
|
||||
const DEFAULT_CONNECTING_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_CONNECTING_TIMEOUT_SCAN_MS = 5_000;
|
||||
const CONNECTING_TIMEOUT_ERROR = 'Gateway connection request timed out';
|
||||
const DEFAULT_CMPP_VERSION = '2.0';
|
||||
|
||||
@Injectable()
|
||||
export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(ChannelsService.name);
|
||||
private gatewayConnectionQueue?: Queue;
|
||||
private gatewaySubmitQueue?: Queue;
|
||||
private redis?: IORedis;
|
||||
private connectionTimeoutTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -176,6 +190,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
clearInterval(this.connectionTimeoutTimer);
|
||||
}
|
||||
await this.gatewayConnectionQueue?.close();
|
||||
await this.gatewaySubmitQueue?.close();
|
||||
this.redis?.disconnect();
|
||||
}
|
||||
|
||||
listChannels() {
|
||||
@@ -198,6 +214,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535) {
|
||||
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
|
||||
}
|
||||
const cmppVersion = normalizeCmppVersion(data.cmppVersion);
|
||||
const config = normalizeChannelRuntimeConfig(data.config, data.desiredConnections, data.windowSize);
|
||||
const channel = await this.prisma.smsChannel.create({
|
||||
data: {
|
||||
@@ -212,7 +229,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
account: data.account,
|
||||
passwordCipher: data.passwordCipher,
|
||||
srcId: data.srcId,
|
||||
cmppVersion: data.cmppVersion ?? '3.0',
|
||||
cmppVersion,
|
||||
rateLimitPerSecond: data.rateLimitPerSecond ?? 100,
|
||||
unitPrice: data.unitPrice ?? 0,
|
||||
status: data.status ?? 'active',
|
||||
@@ -234,6 +251,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
if (gatewayPort !== undefined && (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535)) {
|
||||
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
|
||||
}
|
||||
const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion);
|
||||
const config = data.config !== undefined || data.desiredConnections !== undefined || data.windowSize !== undefined
|
||||
? normalizeChannelRuntimeConfig(channel.config, data.desiredConnections, data.windowSize)
|
||||
: undefined;
|
||||
@@ -251,7 +269,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
account: data.account,
|
||||
passwordCipher: data.passwordCipher,
|
||||
srcId: data.srcId,
|
||||
cmppVersion: data.cmppVersion,
|
||||
cmppVersion,
|
||||
rateLimitPerSecond: data.rateLimitPerSecond,
|
||||
unitPrice: data.unitPrice,
|
||||
status: data.status,
|
||||
@@ -394,11 +412,135 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.changeChannelStatus(channelId, { ...data, status: 'deleted' });
|
||||
}
|
||||
|
||||
testChannel(channelId: string) {
|
||||
async testChannel(channelId: string, data: TestChannelDto = {}) {
|
||||
const phoneNumbers = normalizeTestPhones(data);
|
||||
const content = normalizeTestContent(data.content);
|
||||
const channel = await this.prisma.smsChannel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: { connectionStates: true },
|
||||
});
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
if (channel.status !== 'active') {
|
||||
throw new BadRequestException('通道未启用,不能发送测试短信');
|
||||
}
|
||||
const connectedState = channel.connectionStates.find((state) =>
|
||||
normalizeGatewayConnectionStatus(state.status) === 'connected' && (state.currentConnections ?? 0) > 0,
|
||||
);
|
||||
if (!connectedState) {
|
||||
throw new BadRequestException('通道当前没有可用 CMPP 连接,请先连接成功后再测试发送');
|
||||
}
|
||||
|
||||
const tenant = await this.prisma.tenant.findFirst({ orderBy: { createdAt: 'asc' } });
|
||||
if (!tenant) {
|
||||
throw new BadRequestException('未找到可归属测试短信的客户租户');
|
||||
}
|
||||
|
||||
const createdAt = new Date();
|
||||
const taskNo = `CHTEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const batchTask = await this.prisma.smsBatchTask.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
taskNo,
|
||||
sourceType: 'admin_channel_test',
|
||||
content,
|
||||
category: 'channel_test',
|
||||
phoneTotal: phoneNumbers.length,
|
||||
status: 'submit_queued',
|
||||
auditStatus: 'approved',
|
||||
progressTotal: phoneNumbers.length,
|
||||
submittedTotal: 0,
|
||||
createdById: data.operatorId,
|
||||
},
|
||||
});
|
||||
|
||||
const results = [];
|
||||
for (const [index, phoneNumber] of phoneNumbers.entries()) {
|
||||
const messageId = `MSG-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const submitId = `SUB-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const session = await this.prisma.cmppSubmitSession.upsert({
|
||||
where: { sessionNo: `OPEN-${channel.id}` },
|
||||
update: { submitTotal: { increment: 1 } },
|
||||
create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 },
|
||||
});
|
||||
const messageRecord = await this.prisma.smsMessageRecord.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
batchTaskId: batchTask.id,
|
||||
messageId,
|
||||
phoneNumber,
|
||||
content,
|
||||
billingUnits: calculateBillingUnits(content),
|
||||
unitPrice: channel.unitPrice,
|
||||
amountCents: channel.unitPrice * calculateBillingUnits(content),
|
||||
queuePriority: 'normal',
|
||||
channelId: channel.id,
|
||||
submitId,
|
||||
status: 'submit_queued',
|
||||
submitStatus: 'queued',
|
||||
errorMessage: '运营端通道测试短信',
|
||||
},
|
||||
});
|
||||
await this.prisma.smsSubmitRecord.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
batchTaskId: batchTask.id,
|
||||
messageRecordId: messageRecord.id,
|
||||
channelId: channel.id,
|
||||
sessionId: session.id,
|
||||
submitId,
|
||||
submitStatus: 'queued',
|
||||
},
|
||||
});
|
||||
const command = buildChannelTestSubmitCommand({
|
||||
channel,
|
||||
content,
|
||||
phoneNumber,
|
||||
messageId,
|
||||
submitId,
|
||||
batchTaskId: batchTask.id,
|
||||
tenantId: tenant.id,
|
||||
attempt: index,
|
||||
accessNo: data.accessNo,
|
||||
});
|
||||
await this.getGatewaySubmitQueue().add('submit-command', command);
|
||||
const streamMessageId = await this.publishGatewaySubmitCommand(command);
|
||||
results.push({
|
||||
phoneNumber,
|
||||
messageRecordId: messageRecord.id,
|
||||
submitId,
|
||||
streamMessageId,
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: batchTask.id },
|
||||
data: { submittedTotal: phoneNumbers.length },
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
userId: data.operatorId,
|
||||
action: 'sms_channel.test_submit',
|
||||
resource: 'sms_channel',
|
||||
resourceId: channel.id,
|
||||
detail: {
|
||||
batchTaskId: batchTask.id,
|
||||
phoneTotal: phoneNumbers.length,
|
||||
messageRecordIds: results.map((item) => item.messageRecordId),
|
||||
connectionId: connectedState.connectionId,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
channelId,
|
||||
status: 'queued',
|
||||
message: 'Channel test request accepted as a phase-4 placeholder.',
|
||||
status: 'submit_queued',
|
||||
batchTaskId: batchTask.id,
|
||||
taskNo: batchTask.taskNo,
|
||||
submitted: results.length,
|
||||
messages: results,
|
||||
queuedAt: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -485,7 +627,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
lastDisconnectedAt: data.lastDisconnectedAt ? new Date(data.lastDisconnectedAt) : undefined,
|
||||
lastHeartbeatAt: data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : undefined,
|
||||
reconnectCount: data.reconnectCount ?? 0,
|
||||
lastError: data.lastError,
|
||||
lastError: status === 'connected' ? null : data.lastError,
|
||||
};
|
||||
const existing = await this.prisma.cmppConnectionState.findFirst({
|
||||
where: {
|
||||
@@ -1038,6 +1180,31 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.gatewayConnectionQueue;
|
||||
}
|
||||
|
||||
private getGatewaySubmitQueue() {
|
||||
this.gatewaySubmitQueue ??= new Queue(GATEWAY_SUBMIT_QUEUE, { connection: bullmqConnection() });
|
||||
return this.gatewaySubmitQueue;
|
||||
}
|
||||
|
||||
private getRedis() {
|
||||
if (!this.redis) {
|
||||
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
}
|
||||
return this.redis;
|
||||
}
|
||||
|
||||
private async publishGatewaySubmitCommand(command: unknown) {
|
||||
return this.getRedis().xadd(
|
||||
process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM,
|
||||
'*',
|
||||
'messageType',
|
||||
'SubmitCommand',
|
||||
'data',
|
||||
JSON.stringify(command),
|
||||
);
|
||||
}
|
||||
|
||||
private async notifyGatewayConnect(command: Record<string, unknown>) {
|
||||
const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? DEFAULT_GATEWAY_CONTROL_URL).replace(/\/+$/, '');
|
||||
let response: { ok: boolean; status: number; text: () => Promise<string> };
|
||||
@@ -1057,6 +1224,131 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTestPhones(data: TestChannelDto) {
|
||||
const rawPhones = Array.isArray(data.phones)
|
||||
? data.phones
|
||||
: String(data.phoneNumber ?? data.phones ?? '').split(/[,\n,\s]+/u);
|
||||
const phones = rawPhones.map((phone) => String(phone).trim()).filter(Boolean);
|
||||
const uniquePhones = Array.from(new Set(phones));
|
||||
if (uniquePhones.length === 0) {
|
||||
throw new BadRequestException('请填写测试手机号');
|
||||
}
|
||||
if (uniquePhones.length > 10) {
|
||||
throw new BadRequestException('测试手机号最多允许 10 个');
|
||||
}
|
||||
for (const phone of uniquePhones) {
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
throw new BadRequestException(`手机号格式不正确:${phone}`);
|
||||
}
|
||||
}
|
||||
return uniquePhones;
|
||||
}
|
||||
|
||||
function normalizeTestContent(content?: string) {
|
||||
const normalized = (content ?? '').trim();
|
||||
if (!normalized) {
|
||||
throw new BadRequestException('请填写测试短信内容');
|
||||
}
|
||||
if (normalized.length > 1000) {
|
||||
throw new BadRequestException('测试短信内容不能超过 1000 字符');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function calculateBillingUnits(content: string) {
|
||||
return Math.max(1, Math.ceil([...content].length / 67));
|
||||
}
|
||||
|
||||
function buildChannelTestSubmitCommand({
|
||||
channel,
|
||||
content,
|
||||
phoneNumber,
|
||||
messageId,
|
||||
submitId,
|
||||
batchTaskId,
|
||||
tenantId,
|
||||
attempt,
|
||||
accessNo,
|
||||
}: {
|
||||
channel: {
|
||||
id: string;
|
||||
code: string;
|
||||
gatewayHost: string;
|
||||
gatewayPort: number;
|
||||
account: string;
|
||||
passwordCipher: string;
|
||||
srcId: string;
|
||||
cmppVersion: string;
|
||||
rateLimitPerSecond: number;
|
||||
config?: Prisma.JsonValue | null;
|
||||
};
|
||||
content: string;
|
||||
phoneNumber: string;
|
||||
messageId: string;
|
||||
submitId: string;
|
||||
batchTaskId: string;
|
||||
tenantId: string;
|
||||
attempt: number;
|
||||
accessNo?: string;
|
||||
}) {
|
||||
const srcId = accessNo?.trim() ? `${channel.srcId}${accessNo.trim()}` : channel.srcId;
|
||||
return {
|
||||
schemaVersion: 'v1',
|
||||
messageType: 'SubmitCommand',
|
||||
traceId: randomUUID(),
|
||||
messageId,
|
||||
channelId: channel.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
tenantId,
|
||||
applicationId: 'admin-channel-test',
|
||||
taskId: batchTaskId,
|
||||
submitId,
|
||||
queuePriority: 'normal',
|
||||
phoneNumber,
|
||||
content,
|
||||
signature: 'CHANNEL_TEST',
|
||||
templateId: 'admin-channel-test',
|
||||
billingUnits: calculateBillingUnits(content),
|
||||
route: {
|
||||
channelCode: channel.code,
|
||||
cmppAccountCode: channel.account,
|
||||
priority: attempt,
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
},
|
||||
cmpp: {
|
||||
serviceId: getStringConfigValue(channel.config, 'serviceId', 'SMS'),
|
||||
srcId,
|
||||
registeredDelivery: 1,
|
||||
msgFmt: 8,
|
||||
},
|
||||
upstream: {
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: channel.gatewayPort,
|
||||
account: channel.account,
|
||||
passwordCipher: channel.passwordCipher,
|
||||
cmppVersion: channel.cmppVersion,
|
||||
desiredConnections: getPositiveRuntimeInteger(getConfigValue(channel.config, 'desiredConnections'), 1, 'desiredConnections'),
|
||||
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
|
||||
},
|
||||
retry: { attempt: 0, maxAttempts: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
function getConfigValue(config: Prisma.JsonValue | null | undefined, key: string) {
|
||||
if (config && typeof config === 'object' && !Array.isArray(config) && key in config) {
|
||||
return config[key as keyof typeof config];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getStringConfigValue(config: Prisma.JsonValue | null | undefined, key: string, fallback: string) {
|
||||
const value = getConfigValue(config, key);
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function normalizeConnectionAction(status: string) {
|
||||
const normalized = status.toLowerCase();
|
||||
if (normalized === 'connected') {
|
||||
@@ -1077,6 +1369,14 @@ function normalizeConnectionAction(status: string) {
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
function normalizeCmppVersion(version?: string) {
|
||||
const normalized = (version ?? DEFAULT_CMPP_VERSION).trim();
|
||||
if (normalized === '2.0' || normalized === '3.0') {
|
||||
return normalized;
|
||||
}
|
||||
throw new BadRequestException('cmppVersion must be 2.0 or 3.0');
|
||||
}
|
||||
|
||||
function normalizeGatewayConnectionStatus(status: string) {
|
||||
const normalized = status.toLowerCase();
|
||||
if (['online', 'open', 'connected'].includes(normalized)) {
|
||||
|
||||
@@ -554,6 +554,36 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updates admin channel test message status without business retry routing', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.smsBatchTask.findUnique.mockResolvedValue({ id: 'task-1', tenantId: 'tenant-1', sourceType: 'admin_channel_test' });
|
||||
|
||||
await service.handleSubmitResult({
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
submitId: 'SUB-1',
|
||||
sequenceId: 7,
|
||||
gatewayMessageId: 'GW-1',
|
||||
submitStatus: 'timeout',
|
||||
errorCode: 'SUBMIT_TIMEOUT',
|
||||
errorMessage: 'context deadline exceeded',
|
||||
submittedAt: '2026-07-09T03:44:15.445Z',
|
||||
});
|
||||
|
||||
expect(prisma.channelRouteRule.findFirst).not.toHaveBeenCalled();
|
||||
expect(billing.release).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
|
||||
where: { id: 'record-1' },
|
||||
data: expect.objectContaining({
|
||||
gatewayMessageId: 'GW-1',
|
||||
submitStatus: 'timeout',
|
||||
status: 'timeout',
|
||||
errorCode: 'SUBMIT_TIMEOUT',
|
||||
errorMessage: 'context deadline exceeded',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('releases reservation for rejected submit result and refunds failed receipts', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.smsBillingRecord.findFirst
|
||||
|
||||
@@ -619,13 +619,18 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
},
|
||||
});
|
||||
await this.recordSubmitSegments(message, data, submittedAt);
|
||||
const batchTask = await this.prisma.smsBatchTask.findUnique({
|
||||
where: { id: message.batchTaskId },
|
||||
select: { sourceType: true },
|
||||
});
|
||||
const isAdminChannelTest = batchTask?.sourceType === 'admin_channel_test';
|
||||
if (data.submitId && message.submitId && data.submitId !== message.submitId) {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const status = data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed';
|
||||
if (data.submitStatus === 'accepted') {
|
||||
await this.chargeAcceptedMessage(message);
|
||||
} else {
|
||||
} else if (!isAdminChannelTest) {
|
||||
const retried = await this.retryMessageIfAllowed(message, data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发');
|
||||
if (retried) {
|
||||
await this.refreshTaskProgress(message.batchTaskId);
|
||||
|
||||
Reference in New Issue
Block a user