feat: improve channel resilience and operations
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { ChannelsService } from './channels.service';
|
||||
|
||||
const mockQueueAdd = jest.fn().mockResolvedValue(undefined);
|
||||
const mockJobRemove = jest.fn().mockResolvedValue(undefined);
|
||||
const mockQueueAdd = jest.fn().mockImplementation(async () => ({ id: 'job-1', remove: mockJobRemove }));
|
||||
const mockQueueClose = jest.fn().mockResolvedValue(undefined);
|
||||
const mockFetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -8,6 +9,8 @@ const mockFetch = jest.fn().mockResolvedValue({
|
||||
text: jest.fn().mockResolvedValue(''),
|
||||
});
|
||||
const mockRedisXadd = jest.fn().mockResolvedValue('1710000000000-0');
|
||||
const mockRedisSet = jest.fn().mockResolvedValue('OK');
|
||||
const mockRedisEval = jest.fn().mockResolvedValue(1);
|
||||
const mockRedisDisconnect = jest.fn();
|
||||
|
||||
jest.mock('bullmq', () => ({
|
||||
@@ -19,6 +22,8 @@ jest.mock('bullmq', () => ({
|
||||
|
||||
jest.mock('ioredis', () => jest.fn().mockImplementation(() => ({
|
||||
xadd: mockRedisXadd,
|
||||
set: mockRedisSet,
|
||||
eval: mockRedisEval,
|
||||
disconnect: mockRedisDisconnect,
|
||||
})));
|
||||
|
||||
@@ -70,7 +75,7 @@ function createPrismaMock() {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
|
||||
findUnique: jest.fn().mockResolvedValue(channel),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...channel, ...data })),
|
||||
},
|
||||
channelHealthMetric: { findMany: jest.fn() },
|
||||
smsChannelGroup: {
|
||||
@@ -272,8 +277,11 @@ describe('ChannelsService', () => {
|
||||
beforeEach(() => {
|
||||
mockQueueAdd.mockClear();
|
||||
mockQueueClose.mockClear();
|
||||
mockJobRemove.mockClear();
|
||||
mockFetch.mockClear();
|
||||
mockRedisXadd.mockClear();
|
||||
mockRedisSet.mockClear();
|
||||
mockRedisEval.mockClear();
|
||||
mockRedisDisconnect.mockClear();
|
||||
global.fetch = mockFetch as never;
|
||||
});
|
||||
@@ -300,12 +308,60 @@ describe('ChannelsService', () => {
|
||||
channelId: 'channel-1',
|
||||
reason: 'gateway_restarted',
|
||||
channel: expect.objectContaining({ rateLimitPerSecond: 100 }),
|
||||
}), { jobId: 'channel-1:primary:connect' });
|
||||
}), expect.objectContaining({
|
||||
jobId: expect.stringMatching(/^gateway-connect-channel-1-/),
|
||||
removeOnComplete: 1000,
|
||||
removeOnFail: 1000,
|
||||
}));
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
|
||||
body: expect.stringContaining('"reason":"gateway_restarted"'),
|
||||
}));
|
||||
});
|
||||
|
||||
it('uses the direct Gateway control path when the Redis marker queue is unavailable', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
mockQueueAdd.mockRejectedValueOnce(new Error('redis unavailable'));
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await expect(service.createChannel({
|
||||
code: 'CMPP-DIRECT',
|
||||
name: '直连控制测试',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 17890,
|
||||
account: 'sp',
|
||||
passwordCipher: 'secret',
|
||||
srcId: '10690000',
|
||||
status: 'active',
|
||||
})).resolves.toEqual(expect.objectContaining({ id: 'channel-1' }));
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
|
||||
method: 'POST',
|
||||
}));
|
||||
});
|
||||
|
||||
it('reuses a supplier state created concurrently by another API instance', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.cmppConnectionState.findFirst
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: 'state-concurrent', applicationId: null });
|
||||
prisma.cmppConnectionState.create.mockRejectedValueOnce({ code: 'P2002' });
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await expect(service.createChannel({
|
||||
code: 'CMPP-CONCURRENT',
|
||||
name: '并发状态测试',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 17890,
|
||||
account: 'sp',
|
||||
passwordCipher: 'secret',
|
||||
srcId: '10690000',
|
||||
status: 'active',
|
||||
})).resolves.toEqual(expect.objectContaining({ id: 'channel-1' }));
|
||||
expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({
|
||||
where: { id: 'state-concurrent' },
|
||||
data: expect.objectContaining({ status: 'connecting' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('creates CMPP channels and route rules with first-version defaults', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
@@ -333,7 +389,7 @@ describe('ChannelsService', () => {
|
||||
rateLimitPerSecond: 750,
|
||||
sendRegion: '全国',
|
||||
status: 'active',
|
||||
config: expect.objectContaining({ desiredConnections: 2, windowSize: 32, extensionDigits: 4 }),
|
||||
config: expect.objectContaining({ desiredConnections: 2, windowSize: 32, extensionDigits: 4, serviceId: 'SMS' }),
|
||||
}),
|
||||
});
|
||||
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
|
||||
@@ -351,7 +407,11 @@ describe('ChannelsService', () => {
|
||||
connectionId: 'channel-1:primary',
|
||||
reason: 'channel_created',
|
||||
channel: expect.objectContaining({ cmppVersion: '2.0' }),
|
||||
}), { jobId: 'channel-1:primary:connect' });
|
||||
}), expect.objectContaining({
|
||||
jobId: expect.stringMatching(/^gateway-connect-channel-1-/),
|
||||
removeOnComplete: 1000,
|
||||
removeOnFail: 1000,
|
||||
}));
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('"messageType":"ConnectChannel"'),
|
||||
@@ -372,6 +432,29 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults new channels to port 7890, CMPP and SMS while rejecting protocol overrides', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await service.createChannel({
|
||||
code: 'CMPP-DEFAULT',
|
||||
name: '默认通道',
|
||||
gatewayHost: '127.0.0.1',
|
||||
account: 'sp',
|
||||
passwordCipher: 'secret',
|
||||
srcId: '10690000',
|
||||
protocol: 'HTTP',
|
||||
});
|
||||
|
||||
expect(prisma.smsChannel.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
gatewayPort: 7890,
|
||||
protocol: 'CMPP',
|
||||
config: expect.objectContaining({ serviceId: 'SMS' }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves explicit CMPP 3.0 and rejects unsupported CMPP versions', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
@@ -417,6 +500,7 @@ describe('ChannelsService', () => {
|
||||
|
||||
await expect(service.createChannel({ ...channel, rateLimitPerSecond: 2001 })).rejects.toThrow('rateLimitPerSecond must be between 1 and 2000');
|
||||
await expect(service.createChannel({ ...channel, config: { extensionDigits: 21 } })).rejects.toThrow('extensionDigits must be an integer between 0 and 20');
|
||||
await expect(service.createChannel({ ...channel, config: { serviceId: '业务代码' } })).rejects.toThrow('serviceId must contain 1 to 10 ASCII characters');
|
||||
});
|
||||
|
||||
it('updates CMPP channel configuration without requiring password changes', async () => {
|
||||
@@ -730,11 +814,155 @@ describe('ChannelsService', () => {
|
||||
messageType: 'ConnectChannel',
|
||||
channelId: 'channel-1',
|
||||
reason: 'channel_enabled',
|
||||
}), { jobId: 'channel-1:primary:connect' });
|
||||
}), expect.objectContaining({
|
||||
jobId: expect.stringMatching(/^gateway-connect-channel-1-/),
|
||||
removeOnComplete: 1000,
|
||||
removeOnFail: 1000,
|
||||
}));
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('"reason":"channel_enabled"'),
|
||||
}));
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/disconnect', expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('"reason":"channel_disabled"'),
|
||||
}));
|
||||
});
|
||||
|
||||
it('reconnects active failed channels and disconnects inactive live channels during reconciliation', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsChannel.findMany.mockResolvedValue([
|
||||
{
|
||||
...(await prisma.smsChannel.findUnique()),
|
||||
id: 'channel-active',
|
||||
status: 'active',
|
||||
connectionStates: [{
|
||||
id: 'state-active',
|
||||
connectionId: 'channel-active:primary',
|
||||
status: 'failed',
|
||||
currentConnections: 0,
|
||||
desiredConnections: 1,
|
||||
lastHeartbeatAt: null,
|
||||
nextReconnectAt: new Date('2026-07-23T10:00:00.000Z'),
|
||||
}],
|
||||
},
|
||||
{
|
||||
...(await prisma.smsChannel.findUnique()),
|
||||
id: 'channel-disabled',
|
||||
status: 'disabled',
|
||||
connectionStates: [{
|
||||
id: 'state-disabled',
|
||||
connectionId: 'channel-disabled:primary',
|
||||
status: 'connected',
|
||||
currentConnections: 1,
|
||||
desiredConnections: 1,
|
||||
lastHeartbeatAt: new Date('2026-07-23T10:59:55.000Z'),
|
||||
nextReconnectAt: null,
|
||||
}],
|
||||
},
|
||||
]);
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
const result = await service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z'));
|
||||
|
||||
expect(result).toEqual({ scanned: 2, reconnectRequested: 1, disconnectRequested: 1 });
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
|
||||
body: expect.stringContaining('"reason":"automatic_reconnect"'),
|
||||
}));
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/disconnect', expect.objectContaining({
|
||||
body: expect.stringContaining('"reason":"inactive_channel_reconcile"'),
|
||||
}));
|
||||
expect(mockRedisSet).toHaveBeenCalledTimes(2);
|
||||
expect(mockRedisEval).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not reconnect a fresh healthy supplier connection', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsChannel.findMany.mockResolvedValue([{
|
||||
...(await prisma.smsChannel.findUnique()),
|
||||
status: 'active',
|
||||
config: { desiredConnections: 1, heartbeatIntervalSeconds: 30, heartbeatMissThreshold: 3 },
|
||||
connectionStates: [{
|
||||
connectionId: 'channel-1:primary',
|
||||
status: 'connected',
|
||||
currentConnections: 1,
|
||||
desiredConnections: 1,
|
||||
lastHeartbeatAt: new Date('2026-07-23T10:59:55.000Z'),
|
||||
nextReconnectAt: null,
|
||||
}],
|
||||
}]);
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await expect(service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z')))
|
||||
.resolves.toEqual({ scanned: 1, reconnectRequested: 0, disconnectRequested: 0 });
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(mockRedisSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips duplicate reconciliation when another API instance owns the Redis lease', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsChannel.findMany.mockResolvedValue([{
|
||||
...(await prisma.smsChannel.findUnique()),
|
||||
status: 'active',
|
||||
connectionStates: [],
|
||||
}]);
|
||||
mockRedisSet.mockResolvedValueOnce(null);
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await expect(service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z')))
|
||||
.resolves.toEqual({ scanned: 1, reconnectRequested: 0, disconnectRequested: 0 });
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(mockRedisEval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores supplier heartbeat as a connected state and heartbeat audit event', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await service.upsertConnectionState({
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'channel-1:primary',
|
||||
status: 'heartbeat',
|
||||
currentConnections: 1,
|
||||
lastHeartbeatAt: '2026-07-23T11:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
status: 'connected',
|
||||
lastHeartbeatAt: new Date('2026-07-23T11:00:00.000Z'),
|
||||
}),
|
||||
});
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: 'cmpp_connection.heartbeat' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('updates every supplier heartbeat without flooding operation logs', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.cmppConnectionState.findFirst.mockResolvedValue({
|
||||
id: 'state-1',
|
||||
applicationId: null,
|
||||
lastHeartbeatAt: new Date('2026-07-23T10:59:30.000Z'),
|
||||
});
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await service.upsertConnectionState({
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'channel-1:primary',
|
||||
status: 'heartbeat',
|
||||
currentConnections: 1,
|
||||
lastHeartbeatAt: '2026-07-23T11:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({
|
||||
where: { id: 'state-1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'connected',
|
||||
lastHeartbeatAt: new Date('2026-07-23T11:00:00.000Z'),
|
||||
}),
|
||||
});
|
||||
expect(prisma.operationLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('copies channels with report field configuration and report materials', async () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface CreateChannelDto {
|
||||
sendRegion?: string;
|
||||
protocol?: string;
|
||||
gatewayHost: string;
|
||||
gatewayPort: number;
|
||||
gatewayPort?: number;
|
||||
enterpriseCode?: string;
|
||||
account: string;
|
||||
passwordCipher: string;
|
||||
@@ -24,6 +24,8 @@ export interface CreateChannelDto {
|
||||
status?: string;
|
||||
desiredConnections?: number;
|
||||
windowSize?: number;
|
||||
heartbeatIntervalSeconds?: number;
|
||||
heartbeatMissThreshold?: number;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -151,6 +153,9 @@ export interface UpsertConnectionStateDto {
|
||||
lastDisconnectedAt?: string;
|
||||
lastHeartbeatAt?: string;
|
||||
reconnectCount?: number;
|
||||
lastReconnectAttemptAt?: string;
|
||||
nextReconnectAt?: string;
|
||||
lastErrorCategory?: string;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
@@ -182,6 +187,11 @@ const DEFAULT_CHANNEL_CONNECTION_ID = 'primary';
|
||||
const DEFAULT_CONNECTING_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_CONNECTING_TIMEOUT_SCAN_MS = 5_000;
|
||||
const DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS = 1_000;
|
||||
const DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS = 30_000;
|
||||
const DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 30;
|
||||
const DEFAULT_HEARTBEAT_MISS_THRESHOLD = 3;
|
||||
const HEARTBEAT_AUDIT_INTERVAL_MS = 5 * 60_000;
|
||||
const CONNECTING_TIMEOUT_ERROR = 'Gateway connection request timed out';
|
||||
const DEFAULT_CMPP_VERSION = '2.0';
|
||||
|
||||
@@ -193,6 +203,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
private redis?: IORedis;
|
||||
private connectionTimeoutTimer?: ReturnType<typeof setInterval>;
|
||||
private gatewayStartupReconnectTimer?: ReturnType<typeof setTimeout>;
|
||||
private gatewayReconcileTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@@ -209,6 +220,14 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
void this.reconnectActiveChannelsAfterGatewayRestart();
|
||||
}, getPositiveIntegerEnv('GATEWAY_STARTUP_RECONNECT_DELAY_MS', DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS));
|
||||
this.gatewayStartupReconnectTimer.unref?.();
|
||||
if (process.env.GATEWAY_CONNECTION_RECONCILER_DISABLED !== 'true') {
|
||||
this.gatewayReconcileTimer = setInterval(() => {
|
||||
void this.reconcileGatewayConnections().catch((error) => {
|
||||
this.logger.error(`Failed to reconcile supplier connections: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}, getPositiveIntegerEnv('GATEWAY_CONNECTION_RECONCILE_INTERVAL_MS', DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS));
|
||||
this.gatewayReconcileTimer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
@@ -218,6 +237,9 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
if (this.gatewayStartupReconnectTimer) {
|
||||
clearTimeout(this.gatewayStartupReconnectTimer);
|
||||
}
|
||||
if (this.gatewayReconcileTimer) {
|
||||
clearInterval(this.gatewayReconcileTimer);
|
||||
}
|
||||
await this.gatewayConnectionQueue?.close();
|
||||
await this.gatewaySubmitQueue?.close();
|
||||
this.redis?.disconnect();
|
||||
@@ -232,19 +254,26 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async createChannel(data: CreateChannelDto) {
|
||||
assertMoneyUnits(data.unitPrice ?? 0, '通道单价');
|
||||
const missingFields = ['code', 'name', 'gatewayHost', 'gatewayPort', 'account', 'passwordCipher', 'srcId'].filter((field) => {
|
||||
const missingFields = ['code', 'name', 'gatewayHost', 'account', 'passwordCipher', 'srcId'].filter((field) => {
|
||||
const value = data[field as keyof CreateChannelDto];
|
||||
return value === undefined || value === null || value === '';
|
||||
});
|
||||
if (missingFields.length > 0) {
|
||||
throw new BadRequestException(`Missing required channel fields: ${missingFields.join(', ')}`);
|
||||
}
|
||||
const gatewayPort = Number(data.gatewayPort);
|
||||
const gatewayPort = Number(data.gatewayPort ?? 7890);
|
||||
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(undefined, data.config, data.desiredConnections, data.windowSize);
|
||||
const config = normalizeChannelRuntimeConfig(
|
||||
undefined,
|
||||
data.config,
|
||||
data.desiredConnections,
|
||||
data.windowSize,
|
||||
data.heartbeatIntervalSeconds,
|
||||
data.heartbeatMissThreshold,
|
||||
);
|
||||
const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond);
|
||||
const channel = await this.prisma.smsChannel.create({
|
||||
data: {
|
||||
@@ -252,7 +281,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
name: data.name,
|
||||
carrier: data.carrier,
|
||||
sendRegion: data.sendRegion ?? '全国',
|
||||
protocol: data.protocol ?? 'CMPP',
|
||||
protocol: 'CMPP',
|
||||
gatewayHost: data.gatewayHost,
|
||||
gatewayPort,
|
||||
enterpriseCode: data.enterpriseCode,
|
||||
@@ -285,8 +314,19 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
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.config, data.desiredConnections, data.windowSize)
|
||||
const config = data.config !== undefined
|
||||
|| data.desiredConnections !== undefined
|
||||
|| data.windowSize !== undefined
|
||||
|| data.heartbeatIntervalSeconds !== undefined
|
||||
|| data.heartbeatMissThreshold !== undefined
|
||||
? normalizeChannelRuntimeConfig(
|
||||
channel.config,
|
||||
data.config,
|
||||
data.desiredConnections,
|
||||
data.windowSize,
|
||||
data.heartbeatIntervalSeconds,
|
||||
data.heartbeatMissThreshold,
|
||||
)
|
||||
: undefined;
|
||||
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
|
||||
? undefined
|
||||
@@ -298,7 +338,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
name: data.name,
|
||||
carrier: data.carrier,
|
||||
sendRegion: data.sendRegion,
|
||||
protocol: data.protocol,
|
||||
protocol: 'CMPP',
|
||||
gatewayHost: data.gatewayHost,
|
||||
gatewayPort,
|
||||
enterpriseCode: data.enterpriseCode,
|
||||
@@ -334,6 +374,26 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
const connectionConfigChanged = [
|
||||
'gatewayHost',
|
||||
'gatewayPort',
|
||||
'account',
|
||||
'passwordCipher',
|
||||
'cmppVersion',
|
||||
'rateLimitPerSecond',
|
||||
'desiredConnections',
|
||||
'windowSize',
|
||||
'heartbeatIntervalSeconds',
|
||||
'heartbeatMissThreshold',
|
||||
].some((key) => data[key as keyof UpdateChannelDto] !== undefined)
|
||||
|| Boolean(data.config && ['desiredConnections', 'windowSize', 'heartbeatIntervalSeconds', 'heartbeatMissThreshold']
|
||||
.some((key) => key in data.config!));
|
||||
const updatedStatus = data.status ?? channel.status;
|
||||
if (updatedStatus === 'active' && (connectionConfigChanged || channel.status !== 'active')) {
|
||||
await this.requestChannelConnection(updated, 'channel_updated');
|
||||
} else if (updatedStatus !== 'active' && channel.status === 'active') {
|
||||
await this.requestChannelDisconnection(updated, 'channel_disabled');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -358,6 +418,12 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
if (data.status === 'active') {
|
||||
await this.requestChannelConnection(updated, 'channel_enabled', data.operatorId);
|
||||
} else if (channel.status === 'active' || data.status === 'deleted') {
|
||||
await this.requestChannelDisconnection(
|
||||
updated,
|
||||
data.status === 'deleted' ? 'channel_deleted' : 'channel_disabled',
|
||||
data.operatorId,
|
||||
);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
@@ -615,7 +681,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
async upsertConnectionState(data: UpsertConnectionStateDto) {
|
||||
const status = normalizeGatewayConnectionStatus(data.status);
|
||||
const rawStatus = data.status;
|
||||
const status = normalizeGatewayConnectionStatus(rawStatus);
|
||||
if (data.applicationId) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } });
|
||||
if (!application) {
|
||||
@@ -636,6 +703,9 @@ 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,
|
||||
lastReconnectAttemptAt: data.lastReconnectAttemptAt ? new Date(data.lastReconnectAttemptAt) : undefined,
|
||||
nextReconnectAt: data.nextReconnectAt ? new Date(data.nextReconnectAt) : status === 'connected' ? null : undefined,
|
||||
lastErrorCategory: status === 'connected' ? null : data.lastErrorCategory,
|
||||
lastError: status === 'connected' ? null : data.lastError,
|
||||
};
|
||||
const existing = await this.prisma.cmppConnectionState.findFirst({
|
||||
@@ -645,30 +715,59 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
connectionId: data.connectionId,
|
||||
},
|
||||
});
|
||||
const state = existing
|
||||
? await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data: payload })
|
||||
: await this.prisma.cmppConnectionState.create({
|
||||
let state;
|
||||
if (existing) {
|
||||
state = await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data: payload });
|
||||
} else {
|
||||
try {
|
||||
state = await this.prisma.cmppConnectionState.create({
|
||||
data: {
|
||||
channelId: data.channelId,
|
||||
connectionId: data.connectionId,
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code !== 'P2002') {
|
||||
throw error;
|
||||
}
|
||||
const concurrent = await this.prisma.cmppConnectionState.findFirst({
|
||||
where: {
|
||||
applicationId: data.applicationId ?? null,
|
||||
channelId: data.channelId,
|
||||
connectionId: data.connectionId,
|
||||
},
|
||||
});
|
||||
if (!concurrent) {
|
||||
throw error;
|
||||
}
|
||||
state = await this.prisma.cmppConnectionState.update({ where: { id: concurrent.id }, data: payload });
|
||||
}
|
||||
}
|
||||
const action = normalizeConnectionAction(
|
||||
['heartbeat', 'active_test'].includes(rawStatus.toLowerCase()) ? rawStatus : status,
|
||||
);
|
||||
const heartbeatObservedAt = data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : new Date();
|
||||
const shouldWriteAudit = action !== 'heartbeat'
|
||||
|| !existing?.lastHeartbeatAt
|
||||
|| heartbeatObservedAt.getTime() - existing.lastHeartbeatAt.getTime() >= HEARTBEAT_AUDIT_INTERVAL_MS;
|
||||
if (shouldWriteAudit) {
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
channelId: data.channelId,
|
||||
connectionId: data.connectionId,
|
||||
...payload,
|
||||
tenantId: data.tenantId,
|
||||
action: `cmpp_connection.${action}`,
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${data.channelId}:${data.connectionId}`,
|
||||
detail: {
|
||||
status,
|
||||
applicationId: state.applicationId,
|
||||
desiredConnections: state.desiredConnections,
|
||||
currentConnections: state.currentConnections,
|
||||
lastError: state.lastError,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
action: `cmpp_connection.${normalizeConnectionAction(status)}`,
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${data.channelId}:${data.connectionId}`,
|
||||
detail: {
|
||||
status,
|
||||
applicationId: state.applicationId,
|
||||
desiredConnections: state.desiredConnections,
|
||||
currentConnections: state.currentConnections,
|
||||
lastError: state.lastError,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -705,6 +804,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
status: 'failed',
|
||||
currentConnections: 0,
|
||||
lastDisconnectedAt: now,
|
||||
nextReconnectAt: now,
|
||||
lastErrorCategory: 'timeout',
|
||||
lastError,
|
||||
},
|
||||
});
|
||||
@@ -1245,7 +1346,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
rateLimitPerSecond: number;
|
||||
config?: Prisma.JsonValue | null;
|
||||
},
|
||||
reason: 'channel_created' | 'channel_enabled' | 'gateway_restarted',
|
||||
reason: 'channel_created' | 'channel_enabled' | 'channel_updated' | 'gateway_restarted' | 'automatic_reconnect',
|
||||
operatorId?: string,
|
||||
) {
|
||||
const desiredConnections = getDesiredConnections(channel.config);
|
||||
@@ -1263,16 +1364,34 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
desiredConnections,
|
||||
currentConnections: 0,
|
||||
lastError: null,
|
||||
lastReconnectAttemptAt: new Date(),
|
||||
nextReconnectAt: new Date(Date.now() + getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_MS', DEFAULT_CONNECTING_TIMEOUT_MS)),
|
||||
};
|
||||
const state = existing
|
||||
? await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data })
|
||||
: await this.prisma.cmppConnectionState.create({
|
||||
data: {
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
let state;
|
||||
if (existing) {
|
||||
state = await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data });
|
||||
} else {
|
||||
try {
|
||||
state = await this.prisma.cmppConnectionState.create({
|
||||
data: {
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code !== 'P2002') {
|
||||
throw error;
|
||||
}
|
||||
const concurrent = await this.prisma.cmppConnectionState.findFirst({
|
||||
where: { applicationId: null, channelId: channel.id, connectionId },
|
||||
});
|
||||
if (!concurrent) {
|
||||
throw error;
|
||||
}
|
||||
state = await this.prisma.cmppConnectionState.update({ where: { id: concurrent.id }, data });
|
||||
}
|
||||
}
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
userId: operatorId,
|
||||
@@ -1306,10 +1425,36 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
srcId: channel.srcId,
|
||||
cmppVersion: channel.cmppVersion,
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
|
||||
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||
'heartbeatIntervalSeconds',
|
||||
),
|
||||
heartbeatMissThreshold: getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatMissThreshold'),
|
||||
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
|
||||
'heartbeatMissThreshold',
|
||||
),
|
||||
},
|
||||
};
|
||||
await this.getGatewayConnectionQueue().add('connect-channel', command, { jobId: `${connectionId}:connect` });
|
||||
await this.notifyGatewayConnect(command);
|
||||
const queuedJob = await this.getGatewayConnectionQueue().add('connect-channel', command, {
|
||||
jobId: `gateway-connect-${channel.id}-${command.traceId}`,
|
||||
removeOnComplete: 1000,
|
||||
removeOnFail: 1000,
|
||||
}).catch((error) => {
|
||||
this.logger.warn(`Gateway connect marker enqueue failed; continuing with direct control request: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return undefined;
|
||||
});
|
||||
try {
|
||||
await this.notifyGatewayConnect(command);
|
||||
} finally {
|
||||
if (queuedJob) {
|
||||
await queuedJob.remove().catch((error) => {
|
||||
this.logger.warn(`Failed to remove delivered Gateway connect marker ${queuedJob.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -1328,6 +1473,134 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
async reconcileGatewayConnections(now = new Date()) {
|
||||
const channels = await this.prisma.smsChannel.findMany({
|
||||
where: { status: { in: ['active', 'disabled', 'deleted'] } },
|
||||
include: {
|
||||
connectionStates: {
|
||||
where: { applicationId: null },
|
||||
},
|
||||
},
|
||||
take: 200,
|
||||
});
|
||||
let reconnectRequested = 0;
|
||||
let disconnectRequested = 0;
|
||||
for (const channel of channels) {
|
||||
const state = channel.connectionStates.find((item) => item.connectionId === defaultChannelConnectionId(channel.id));
|
||||
if (channel.status !== 'active') {
|
||||
if (state && (state.currentConnections > 0 || ['connected', 'connecting', 'reconnecting'].includes(state.status))) {
|
||||
await this.withGatewayReconcileLock(channel.id, async () => {
|
||||
await this.requestChannelDisconnection(channel, 'inactive_channel_reconcile');
|
||||
disconnectRequested++;
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const desiredConnections = getDesiredConnections(channel.config);
|
||||
const heartbeatIntervalSeconds = getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||
'heartbeatIntervalSeconds',
|
||||
);
|
||||
const heartbeatMissThreshold = getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatMissThreshold'),
|
||||
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
|
||||
'heartbeatMissThreshold',
|
||||
);
|
||||
const heartbeatCutoff = new Date(now.getTime() - heartbeatIntervalSeconds * (heartbeatMissThreshold + 1) * 1000);
|
||||
const connectedAndFresh = state?.status === 'connected'
|
||||
&& state.currentConnections >= desiredConnections
|
||||
&& Boolean(state.lastHeartbeatAt && state.lastHeartbeatAt > heartbeatCutoff);
|
||||
const retryDue = !state?.nextReconnectAt || state.nextReconnectAt <= now;
|
||||
if (!connectedAndFresh && retryDue) {
|
||||
await this.withGatewayReconcileLock(channel.id, async () => {
|
||||
await this.requestChannelConnection(channel, 'automatic_reconnect');
|
||||
reconnectRequested++;
|
||||
});
|
||||
}
|
||||
}
|
||||
return { scanned: channels.length, reconnectRequested, disconnectRequested };
|
||||
}
|
||||
|
||||
private async withGatewayReconcileLock(channelId: string, action: () => Promise<void>) {
|
||||
const redis = this.getRedis();
|
||||
const key = `cmpp:gateway:reconcile:${channelId}`;
|
||||
const token = randomUUID();
|
||||
const acquired = await redis.set(key, token, 'PX', DEFAULT_CONNECTING_TIMEOUT_MS, 'NX');
|
||||
if (acquired !== 'OK') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
await redis.eval(
|
||||
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end',
|
||||
1,
|
||||
key,
|
||||
token,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async requestChannelDisconnection(
|
||||
channel: { id: string },
|
||||
reason: 'channel_disabled' | 'channel_deleted' | 'inactive_channel_reconcile',
|
||||
operatorId?: string,
|
||||
) {
|
||||
const connectionId = defaultChannelConnectionId(channel.id);
|
||||
const now = new Date();
|
||||
await this.prisma.cmppConnectionState.updateMany({
|
||||
where: {
|
||||
applicationId: null,
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
},
|
||||
data: {
|
||||
status: 'disconnected',
|
||||
currentConnections: 0,
|
||||
lastDisconnectedAt: now,
|
||||
nextReconnectAt: null,
|
||||
lastErrorCategory: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
userId: operatorId,
|
||||
action: 'cmpp_connection.disconnect_requested',
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${channel.id}:${connectionId}`,
|
||||
detail: { reason } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
const command = {
|
||||
schemaVersion: 'v1',
|
||||
messageType: 'DisconnectChannel',
|
||||
traceId: randomUUID(),
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
createdAt: now.toISOString(),
|
||||
reason,
|
||||
};
|
||||
const queuedJob = await this.getGatewayConnectionQueue().add('disconnect-channel', command, {
|
||||
jobId: `gateway-disconnect-${channel.id}-${command.traceId}`,
|
||||
removeOnComplete: 1000,
|
||||
removeOnFail: 1000,
|
||||
}).catch((error) => {
|
||||
this.logger.warn(`Gateway disconnect marker enqueue failed; continuing with direct control request: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return undefined;
|
||||
});
|
||||
try {
|
||||
await this.notifyGatewayDisconnect(command);
|
||||
} finally {
|
||||
if (queuedJob) {
|
||||
await queuedJob.remove().catch((error) => {
|
||||
this.logger.warn(`Failed to remove delivered Gateway disconnect marker ${queuedJob.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getGatewayConnectionQueue() {
|
||||
this.gatewayConnectionQueue ??= new Queue(GATEWAY_CONNECTION_QUEUE, { connection: bullmqConnection() });
|
||||
return this.gatewayConnectionQueue;
|
||||
@@ -1366,6 +1639,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(command),
|
||||
signal: AbortSignal.timeout(getPositiveIntegerEnv('GATEWAY_CONTROL_TIMEOUT_MS', DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS)),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Gateway connect request failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
@@ -1375,6 +1649,25 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
throw new BadRequestException(`Gateway connect request failed: ${response.status} ${responseText}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyGatewayDisconnect(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> };
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/connections/disconnect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(command),
|
||||
signal: AbortSignal.timeout(getPositiveIntegerEnv('GATEWAY_CONTROL_TIMEOUT_MS', DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS)),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Gateway disconnect request failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new BadRequestException(`Gateway disconnect request failed: ${response.status} ${responseText}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTestPhones(data: TestChannelDto) {
|
||||
@@ -1481,6 +1774,16 @@ function buildChannelTestSubmitCommand({
|
||||
cmppVersion: channel.cmppVersion,
|
||||
desiredConnections: getPositiveRuntimeInteger(getConfigValue(channel.config, 'desiredConnections'), 1, 'desiredConnections'),
|
||||
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
|
||||
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||
'heartbeatIntervalSeconds',
|
||||
),
|
||||
heartbeatMissThreshold: getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatMissThreshold'),
|
||||
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
|
||||
'heartbeatMissThreshold',
|
||||
),
|
||||
},
|
||||
retry: { attempt: 0, maxAttempts: 1 },
|
||||
};
|
||||
@@ -1531,7 +1834,7 @@ function normalizeCmppVersion(version?: string) {
|
||||
|
||||
function normalizeGatewayConnectionStatus(status: string) {
|
||||
const normalized = status.toLowerCase();
|
||||
if (['online', 'open', 'connected'].includes(normalized)) {
|
||||
if (['online', 'open', 'connected', 'heartbeat', 'active_test'].includes(normalized)) {
|
||||
return 'connected';
|
||||
}
|
||||
if (['connecting', 'connect_requested'].includes(normalized)) {
|
||||
@@ -1568,6 +1871,8 @@ function normalizeChannelRuntimeConfig(
|
||||
incomingConfig?: Record<string, unknown> | null,
|
||||
desiredConnections?: number,
|
||||
windowSize?: number,
|
||||
heartbeatIntervalSeconds?: number,
|
||||
heartbeatMissThreshold?: number,
|
||||
) {
|
||||
const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
|
||||
? existingConfig as Record<string, unknown>
|
||||
@@ -1578,10 +1883,29 @@ function normalizeChannelRuntimeConfig(
|
||||
const base = { ...existing, ...incoming };
|
||||
base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections');
|
||||
base.windowSize = getPositiveRuntimeInteger(windowSize ?? base.windowSize, 16, 'windowSize');
|
||||
base.heartbeatIntervalSeconds = getPositiveRuntimeInteger(
|
||||
heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds,
|
||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||
'heartbeatIntervalSeconds',
|
||||
);
|
||||
base.heartbeatMissThreshold = getPositiveRuntimeInteger(
|
||||
heartbeatMissThreshold ?? base.heartbeatMissThreshold,
|
||||
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
|
||||
'heartbeatMissThreshold',
|
||||
);
|
||||
base.extensionDigits = normalizeExtensionDigits(base.extensionDigits);
|
||||
base.serviceId = normalizeCmppServiceId(base.serviceId);
|
||||
return base;
|
||||
}
|
||||
|
||||
function normalizeCmppServiceId(value: unknown) {
|
||||
const normalized = String(value ?? 'SMS').trim() || 'SMS';
|
||||
if (!/^[\x20-\x7E]{1,10}$/.test(normalized)) {
|
||||
throw new BadRequestException('serviceId must contain 1 to 10 ASCII characters');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeChannelRateLimit(value: unknown) {
|
||||
const normalized = getPositiveRuntimeInteger(value, 100, 'rateLimitPerSecond');
|
||||
if (normalized > 2000) {
|
||||
|
||||
@@ -145,6 +145,25 @@ describe('DictionariesService', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('excludes soft-deleted security entries even when no status or deleted is requested', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new DictionariesService(prisma as never);
|
||||
|
||||
await service.listSensitiveWords();
|
||||
await service.listGlobalBlacklist({ status: 'deleted' });
|
||||
await service.listEnterpriseBlacklist();
|
||||
|
||||
expect(prisma.sensitiveWord.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ status: { not: 'deleted' } }),
|
||||
}));
|
||||
expect(prisma.globalBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ status: { not: 'deleted' } }),
|
||||
}));
|
||||
expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ status: { not: 'deleted' } }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('paginates carrier rules with a real database count', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.phoneCarrierRule.findMany.mockResolvedValue([{ id: 'rule-1', carrier: 'mobile', pattern: '^13' }]);
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface CreatePhoneSegmentDto {
|
||||
city?: string;
|
||||
}
|
||||
|
||||
function visibleDictionaryStatus(status?: string): string | Prisma.StringFilter {
|
||||
return status && status !== 'all' && status !== 'deleted' ? status : { not: 'deleted' };
|
||||
}
|
||||
|
||||
export interface PhoneSegmentListQuery {
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
@@ -152,7 +156,7 @@ export class DictionariesService {
|
||||
listSensitiveWords(query: DictionaryListQuery = {}) {
|
||||
return this.prisma.sensitiveWord.findMany({
|
||||
where: {
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
status: visibleDictionaryStatus(query.status),
|
||||
OR: query.keyword ? [
|
||||
{ word: { contains: query.keyword } },
|
||||
{ level: { contains: query.keyword } },
|
||||
@@ -184,7 +188,7 @@ export class DictionariesService {
|
||||
listGlobalBlacklist(query: DictionaryListQuery = {}) {
|
||||
return this.prisma.globalBlacklist.findMany({
|
||||
where: {
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
status: visibleDictionaryStatus(query.status),
|
||||
OR: query.keyword ? [
|
||||
{ phoneNumber: { contains: query.keyword } },
|
||||
{ reason: { contains: query.keyword } },
|
||||
@@ -221,7 +225,7 @@ export class DictionariesService {
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
status: visibleDictionaryStatus(query.status),
|
||||
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
|
||||
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
|
||||
phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined,
|
||||
|
||||
@@ -82,11 +82,12 @@ describe('ReportsService', () => {
|
||||
it('exports complete filtered report data as escaped CSV instead of the current page', async () => {
|
||||
prisma.dailyReconciliationReport.findMany.mockResolvedValueOnce([{
|
||||
id: 'recon-export', reportDate: new Date('2026-07-14'), tenantName: '示例,企业', applicationName: '应用A',
|
||||
sentUnits: 12, successUnits: 10, generatedAt: new Date('2026-07-15T00:00:00Z'),
|
||||
submittedUnits: 14, sentUnits: 12, unknownUnits: 1, successUnits: 10, failedUnits: 1, generatedAt: new Date('2026-07-15T00:00:00Z'),
|
||||
}]);
|
||||
const exported = await service.exportReconciliation({ tenantId: 'tenant-1', dateFrom: '2026-07-01', dateTo: '2026-07-14' });
|
||||
expect(exported.fileName).toContain('对账单-');
|
||||
expect(exported.content).toContain('"示例,企业"');
|
||||
expect(exported.content).toContain('提交条数');
|
||||
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ tenantId: 'tenant-1' }),
|
||||
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
|
||||
|
||||
@@ -74,19 +74,19 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async exportReconciliation(query: ReportListQuery) {
|
||||
const items = await this.prisma.dailyReconciliationReport.findMany({ where: reconciliationWhere(query), orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }] });
|
||||
return csvExport('对账单', ['发送日期', '企业', '企业应用', '发送条数', '成功条数', '失败条数', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.tenantName, item.applicationName, item.sentUnits, item.successUnits, item.failedUnits, formatCsvDate(item.generatedAt)]));
|
||||
return csvExport('对账单', ['发送日期', '企业', '企业应用', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.tenantName, item.applicationName, item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, formatCsvDate(item.generatedAt)]));
|
||||
}
|
||||
|
||||
async exportProfit(query: ReportListQuery) {
|
||||
const { dimensionType, where } = profitWhere(query);
|
||||
const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] });
|
||||
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '发送条数', '成功条数', '失败条数', '净消费金额(元)', '返还金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.sentUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.refundCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
|
||||
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '净消费金额(元)', '返还金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.refundCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
|
||||
}
|
||||
|
||||
async exportQuality(query: ReportListQuery) {
|
||||
const { dimensionType, where } = qualityWhere(query);
|
||||
const items = await this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }] });
|
||||
return csvExport(`发送质量报表-${dimensionType}`, ['发送日期', '统计对象', '企业', '发送条数', '成功条数', '失败条数', '成功率(%)', '平均到达时长(毫秒)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.sentUnits, item.successUnits, item.failedUnits, (item.successRateBps / 100).toFixed(2), item.avgArrivalMs ?? '', formatCsvDate(item.generatedAt)]));
|
||||
return csvExport(`发送质量报表-${dimensionType}`, ['发送日期', '统计对象', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '成功率(%)', '平均到达时长(毫秒)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, (item.successRateBps / 100).toFixed(2), item.avgArrivalMs ?? '', formatCsvDate(item.generatedAt)]));
|
||||
}
|
||||
|
||||
async refreshRollingWindow(now = new Date()) {
|
||||
@@ -119,7 +119,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
INSERT INTO "DailyReconciliationReport" (
|
||||
"id", "reportDate", "tenantId", "tenantName", "applicationId", "applicationName",
|
||||
"sentUnits", "successUnits", "failedUnits", "generatedAt", "updatedAt"
|
||||
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "generatedAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
CONCAT('recon-', MD5(${day.key} || ':' || tenant.id || ':' || application.id)),
|
||||
@@ -129,8 +129,15 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
application.id,
|
||||
application.name,
|
||||
COALESCE(SUM(message."billingUnits"), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(message.status, '') <> 'rejected' THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(message.status, '') <> 'rejected'
|
||||
AND NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
|
||||
AND NOT (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
|
||||
THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN message.status IN ('submit_failed', 'failed', 'timeout') OR message."receiptStatus" = 'undelivered' THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
|
||||
AND (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
|
||||
THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM "SmsMessageRecord" message
|
||||
@@ -157,7 +164,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
INSERT INTO "DailyProfitReport" (
|
||||
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
|
||||
"tenantId", "tenantName", "applicationId", "channelId",
|
||||
"sentUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps",
|
||||
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps",
|
||||
"generatedAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
@@ -171,8 +178,15 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
application.id,
|
||||
NULL,
|
||||
COALESCE(SUM(message."billingUnits"), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(message.status, '') <> 'rejected' THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(message.status, '') <> 'rejected'
|
||||
AND NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
|
||||
AND NOT (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
|
||||
THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN message.status IN ('submit_failed', 'failed', 'timeout') OR message."receiptStatus" = 'undelivered' THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
|
||||
AND (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
|
||||
THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(billing.revenue), 0)::bigint,
|
||||
COALESCE(SUM(billing.refund), 0)::bigint,
|
||||
COALESCE(SUM(costs.cost), 0)::bigint,
|
||||
@@ -202,7 +216,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
INSERT INTO "DailyProfitReport" (
|
||||
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
|
||||
"tenantId", "tenantName", "applicationId", "channelId",
|
||||
"sentUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps",
|
||||
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps",
|
||||
"generatedAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
@@ -216,18 +230,30 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
NULL,
|
||||
channel.id,
|
||||
COALESCE(SUM(message."billingUnits"), 0)::integer,
|
||||
COALESCE(SUM(message."billingUnits"), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN NOT EXISTS (
|
||||
SELECT 1 FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" IN ('delivered', 'undelivered')
|
||||
) AND submit."submitStatus" NOT IN ('rejected', 'timeout') THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN EXISTS (
|
||||
SELECT 1 FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN EXISTS (
|
||||
COALESCE(SUM(CASE WHEN NOT EXISTS (
|
||||
SELECT 1 FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) AND (EXISTS (
|
||||
SELECT 1 FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'undelivered'
|
||||
) OR submit."submitStatus" IN ('rejected', 'timeout') THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
) OR submit."submitStatus" IN ('rejected', 'timeout')) THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0)::bigint,
|
||||
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.refund ELSE 0 END), 0)::bigint,
|
||||
COALESCE(SUM(submit."costAmountCents"), 0)::bigint,
|
||||
@@ -280,8 +306,15 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat
|
||||
tenant.name AS tenant_name,
|
||||
application.id AS application_id,
|
||||
message."billingUnits" AS billing_units,
|
||||
CASE WHEN COALESCE(message.status, '') <> 'rejected' THEN message."billingUnits" ELSE 0 END AS sent_units,
|
||||
CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END AS success_units,
|
||||
CASE WHEN message.status IN ('submit_failed', 'failed', 'timeout') OR message."receiptStatus" = 'undelivered' THEN message."billingUnits" ELSE 0 END AS failed_units,
|
||||
CASE WHEN NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
|
||||
AND (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
|
||||
THEN message."billingUnits" ELSE 0 END AS failed_units,
|
||||
CASE WHEN COALESCE(message.status, '') <> 'rejected'
|
||||
AND NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
|
||||
AND NOT (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
|
||||
THEN message."billingUnits" ELSE 0 END AS unknown_units,
|
||||
CASE WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||
AND message."submittedAt" IS NOT NULL AND message."deliveredAt" >= message."submittedAt"
|
||||
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000 END AS arrival_ms
|
||||
@@ -299,7 +332,7 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat
|
||||
INSERT INTO "DailyQualityReport" (
|
||||
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
|
||||
"tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId",
|
||||
"sentUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt"
|
||||
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
CONCAT('quality-', ${dimensionTypeSql}, '-', MD5(${day.key} || ':' || base.dimension_id)),
|
||||
@@ -314,9 +347,11 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat
|
||||
CASE WHEN ${dimensionTypeSql} = 'signature' AND base.dimension_id NOT LIKE 'unmatched:%' THEN base.dimension_id ELSE NULL END,
|
||||
CASE WHEN ${dimensionTypeSql} = 'drainage' AND base.dimension_id NOT LIKE 'unmatched:%' THEN base.dimension_id ELSE NULL END,
|
||||
SUM(base.billing_units)::integer,
|
||||
SUM(base.sent_units)::integer,
|
||||
SUM(base.unknown_units)::integer,
|
||||
SUM(base.success_units)::integer,
|
||||
SUM(base.failed_units)::integer,
|
||||
CASE WHEN SUM(base.billing_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.billing_units))::integer END,
|
||||
CASE WHEN SUM(base.sent_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.sent_units))::integer END,
|
||||
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL AND base.arrival_ms <= thresholds.p95_ms))::integer,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
@@ -334,7 +369,8 @@ function qualityByChannelSql(day: BusinessDay) {
|
||||
channel.name AS dimension_name,
|
||||
message."billingUnits" AS billing_units,
|
||||
CASE WHEN receipt."deliveredAt" IS NOT NULL THEN message."billingUnits" ELSE 0 END AS success_units,
|
||||
CASE WHEN failed_receipt."failedAt" IS NOT NULL THEN message."billingUnits" ELSE 0 END AS failed_units,
|
||||
CASE WHEN receipt."deliveredAt" IS NULL AND failed_receipt."failedAt" IS NOT NULL THEN message."billingUnits" ELSE 0 END AS failed_units,
|
||||
CASE WHEN receipt."deliveredAt" IS NULL AND failed_receipt."failedAt" IS NULL THEN message."billingUnits" ELSE 0 END AS unknown_units,
|
||||
CASE WHEN receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt")
|
||||
THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 END AS arrival_ms
|
||||
FROM "SmsSubmitRecord" submit
|
||||
@@ -364,7 +400,7 @@ function qualityByChannelSql(day: BusinessDay) {
|
||||
INSERT INTO "DailyQualityReport" (
|
||||
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
|
||||
"tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId",
|
||||
"sentUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt"
|
||||
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
CONCAT('quality-channel-', MD5(${day.key} || ':' || base.dimension_id)),
|
||||
@@ -374,6 +410,8 @@ function qualityByChannelSql(day: BusinessDay) {
|
||||
MAX(base.dimension_name),
|
||||
NULL, NULL, NULL, base.dimension_id, NULL, NULL,
|
||||
SUM(base.billing_units)::integer,
|
||||
SUM(base.billing_units)::integer,
|
||||
SUM(base.unknown_units)::integer,
|
||||
SUM(base.success_units)::integer,
|
||||
SUM(base.failed_units)::integer,
|
||||
CASE WHEN SUM(base.billing_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.billing_units))::integer END,
|
||||
|
||||
@@ -2847,6 +2847,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
cmppVersion: channel.cmppVersion,
|
||||
desiredConnections: getPositiveConfigInteger(channel.config, 'desiredConnections', 1),
|
||||
windowSize: getPositiveConfigInteger(channel.config, 'windowSize', 16),
|
||||
heartbeatIntervalSeconds: getPositiveConfigInteger(channel.config, 'heartbeatIntervalSeconds', 30),
|
||||
heartbeatMissThreshold: getPositiveConfigInteger(channel.config, 'heartbeatMissThreshold', 3),
|
||||
},
|
||||
retry: { attempt, maxAttempts: 1 },
|
||||
};
|
||||
|
||||
@@ -324,6 +324,7 @@ describe('SmsConfigService', () => {
|
||||
interfaceType: 'cmpp20',
|
||||
queuePriority: 'priority',
|
||||
dailyLimit: 100000,
|
||||
maxPhonesPerTask: 10000,
|
||||
downstreamReceiptRetryEnabled: true,
|
||||
downstreamUplinkRetryEnabled: true,
|
||||
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
|
||||
|
||||
@@ -388,7 +388,7 @@ export class SmsConfigService {
|
||||
dailyLimit: getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'),
|
||||
customerUnitPrice: data.customerUnitPrice ?? 0,
|
||||
queuePriority,
|
||||
maxPhonesPerTask: data.maxPhonesPerTask ?? 1000000,
|
||||
maxPhonesPerTask: data.maxPhonesPerTask ?? 10000,
|
||||
templateMismatchMode: data.templateMismatchMode ?? 'reject',
|
||||
downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled ?? true,
|
||||
downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled ?? true,
|
||||
|
||||
Reference in New Issue
Block a user