fix: harden real backend workflows and channel connections
This commit is contained in:
@@ -64,8 +64,13 @@ export class ChannelsController {
|
||||
}
|
||||
|
||||
@Get('channels/:id/link-logs')
|
||||
listChannelLinkLogs(@Param('id') channelId: string) {
|
||||
return this.channels.listChannelLinkLogs(channelId);
|
||||
listLegacyChannelConnectionLogs(@Param('id') channelId: string) {
|
||||
return this.channels.listChannelConnectionLogs(channelId);
|
||||
}
|
||||
|
||||
@Get('channels/:id/connection-logs')
|
||||
listChannelConnectionLogs(@Param('id') channelId: string) {
|
||||
return this.channels.listChannelConnectionLogs(channelId);
|
||||
}
|
||||
|
||||
@Get('channels/:id/connections')
|
||||
@@ -98,6 +103,11 @@ export class ChannelsController {
|
||||
return this.channels.updateGroup(groupId, body);
|
||||
}
|
||||
|
||||
@Delete('channel-groups/:id')
|
||||
deleteGroup(@Param('id') groupId: string) {
|
||||
return this.channels.deleteGroup(groupId);
|
||||
}
|
||||
|
||||
@Post('channel-groups/items')
|
||||
addGroupItem(@Body() body: CreateChannelGroupItemDto) {
|
||||
return this.channels.addGroupItem(body);
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { ChannelsService } from './channels.service';
|
||||
|
||||
const mockQueueAdd = jest.fn().mockResolvedValue(undefined);
|
||||
const mockQueueClose = jest.fn().mockResolvedValue(undefined);
|
||||
const mockFetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: jest.fn().mockResolvedValue(''),
|
||||
});
|
||||
|
||||
jest.mock('bullmq', () => ({
|
||||
Queue: jest.fn().mockImplementation(() => ({
|
||||
add: mockQueueAdd,
|
||||
close: mockQueueClose,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createPrismaMock() {
|
||||
const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'pending' };
|
||||
const channel = {
|
||||
@@ -54,6 +69,7 @@ function createPrismaMock() {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72 }),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
|
||||
delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }),
|
||||
},
|
||||
smsChannelGroupItem: {
|
||||
deleteMany: jest.fn(),
|
||||
@@ -63,6 +79,7 @@ function createPrismaMock() {
|
||||
},
|
||||
channelRouteRule: {
|
||||
findMany: jest.fn(),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'route-1', ...data })),
|
||||
},
|
||||
channelReportField: {
|
||||
@@ -93,9 +110,15 @@ function createPrismaMock() {
|
||||
smsSignature: {
|
||||
update: jest.fn(),
|
||||
},
|
||||
smsApplication: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
||||
},
|
||||
cmppConnectionState: {
|
||||
findMany: jest.fn(),
|
||||
upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({ id: 'conn-1', ...create })),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-1', ...data })),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-1', ...data })),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
@@ -105,11 +128,18 @@ function createPrismaMock() {
|
||||
}
|
||||
|
||||
describe('ChannelsService', () => {
|
||||
beforeEach(() => {
|
||||
mockQueueAdd.mockClear();
|
||||
mockQueueClose.mockClear();
|
||||
mockFetch.mockClear();
|
||||
global.fetch = mockFetch as never;
|
||||
});
|
||||
|
||||
it('rejects incomplete channel creation input with readable 400 errors', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
expect(() => service.createChannel({ name: '缺字段通道' } as never)).toThrow('Missing required channel fields');
|
||||
await expect(service.createChannel({ name: '缺字段通道' } as never)).rejects.toThrow('Missing required channel fields');
|
||||
expect(prisma.smsChannel.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -138,6 +168,25 @@ describe('ChannelsService', () => {
|
||||
status: 'active',
|
||||
}),
|
||||
});
|
||||
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'channel-1:primary',
|
||||
status: 'connecting',
|
||||
desiredConnections: 1,
|
||||
currentConnections: 0,
|
||||
}),
|
||||
});
|
||||
expect(mockQueueAdd).toHaveBeenCalledWith('connect-channel', expect.objectContaining({
|
||||
messageType: 'ConnectChannel',
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'channel-1:primary',
|
||||
reason: 'channel_created',
|
||||
}), { jobId: 'channel-1:primary:connect' });
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('"messageType":"ConnectChannel"'),
|
||||
}));
|
||||
expect(prisma.smsChannelGroup.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 }),
|
||||
});
|
||||
@@ -309,6 +358,18 @@ describe('ChannelsService', () => {
|
||||
expect(prisma.channelRouteRule.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes channel groups only when no active route rule is bound', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await service.deleteGroup('group-1');
|
||||
expect(prisma.smsChannelGroupItem.deleteMany).toHaveBeenCalledWith({ where: { groupId: 'group-1' } });
|
||||
expect(prisma.smsChannelGroup.delete).toHaveBeenCalledWith({ where: { id: 'group-1' } });
|
||||
|
||||
prisma.channelRouteRule.findFirst.mockResolvedValueOnce({ id: 'route-1' });
|
||||
await expect(service.deleteGroup('group-1')).rejects.toThrow('Channel group is used by application route rules');
|
||||
});
|
||||
|
||||
it('upserts signature report material per channel field', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
@@ -405,6 +466,24 @@ describe('ChannelsService', () => {
|
||||
resourceId: 'channel-1',
|
||||
}),
|
||||
});
|
||||
|
||||
await service.changeChannelStatus('channel-1', { status: 'active', operatorId: 'admin-1', reason: 'resume' });
|
||||
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'channel-1:primary',
|
||||
status: 'connecting',
|
||||
}),
|
||||
});
|
||||
expect(mockQueueAdd).toHaveBeenCalledWith('connect-channel', expect.objectContaining({
|
||||
messageType: 'ConnectChannel',
|
||||
channelId: 'channel-1',
|
||||
reason: 'channel_enabled',
|
||||
}), { jobId: 'channel-1:primary:connect' });
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('"reason":"channel_enabled"'),
|
||||
}));
|
||||
});
|
||||
|
||||
it('copies channels with report field configuration and report materials', async () => {
|
||||
@@ -432,6 +511,7 @@ describe('ChannelsService', () => {
|
||||
|
||||
await service.upsertConnectionState({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'conn-a',
|
||||
status: 'online',
|
||||
@@ -440,12 +520,13 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
await service.listChannelConnections('channel-1');
|
||||
await service.listTenantConnections('tenant-1');
|
||||
await service.listChannelLinkLogs('channel-1');
|
||||
await service.listChannelConnectionLogs('channel-1');
|
||||
|
||||
expect(prisma.cmppConnectionState.upsert).toHaveBeenCalledWith({
|
||||
where: { channelId_connectionId: { channelId: 'channel-1', connectionId: 'conn-a' } },
|
||||
update: expect.objectContaining({ tenantId: 'tenant-1', status: 'online', desiredConnections: 2, currentConnections: 1 }),
|
||||
create: expect.objectContaining({ channelId: 'channel-1', connectionId: 'conn-a', status: 'online' }),
|
||||
expect(prisma.cmppConnectionState.findFirst).toHaveBeenCalledWith({
|
||||
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' }),
|
||||
});
|
||||
expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({
|
||||
where: { channelId: 'channel-1' },
|
||||
@@ -467,4 +548,86 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
expect(prisma.operationLog.findMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks stale connecting CMPP connections as failed with operation logs', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
const now = new Date('2026-07-06T10:00:45.000Z');
|
||||
prisma.cmppConnectionState.findMany.mockResolvedValueOnce([{
|
||||
id: 'conn-state-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: null,
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'channel-1:primary',
|
||||
status: 'connecting',
|
||||
desiredConnections: 1,
|
||||
currentConnections: 0,
|
||||
updatedAt: new Date('2026-07-06T10:00:00.000Z'),
|
||||
}]);
|
||||
|
||||
await expect(service.markTimedOutConnectingChannels(now)).resolves.toEqual({ checked: 1, failed: 1 });
|
||||
|
||||
expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
status: 'connecting',
|
||||
updatedAt: { lte: new Date('2026-07-06T10:00:15.000Z') },
|
||||
},
|
||||
select: expect.objectContaining({
|
||||
id: true,
|
||||
channelId: true,
|
||||
connectionId: true,
|
||||
updatedAt: true,
|
||||
}),
|
||||
take: 100,
|
||||
});
|
||||
expect(prisma.cmppConnectionState.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: 'conn-state-1',
|
||||
status: 'connecting',
|
||||
updatedAt: { lte: new Date('2026-07-06T10:00:15.000Z') },
|
||||
},
|
||||
data: expect.objectContaining({
|
||||
status: 'failed',
|
||||
currentConnections: 0,
|
||||
lastDisconnectedAt: now,
|
||||
lastError: 'Gateway connection request timed out after 30 seconds',
|
||||
}),
|
||||
});
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tenantId: 'tenant-1',
|
||||
action: 'cmpp_connection.failed',
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: 'channel-1:channel-1:primary',
|
||||
detail: expect.objectContaining({
|
||||
reason: 'connect_timeout',
|
||||
timeoutMs: 30000,
|
||||
status: 'failed',
|
||||
previousStatus: 'connecting',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write timeout logs when a connecting state is already changed by gateway callback', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.cmppConnectionState.updateMany.mockResolvedValueOnce({ count: 0 });
|
||||
prisma.cmppConnectionState.findMany.mockResolvedValueOnce([{
|
||||
id: 'conn-state-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: null,
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'channel-1:primary',
|
||||
desiredConnections: 1,
|
||||
currentConnections: 0,
|
||||
updatedAt: new Date('2026-07-06T10:00:00.000Z'),
|
||||
}]);
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await expect(service.markTimedOutConnectingChannels(new Date('2026-07-06T10:00:45.000Z'))).resolves.toEqual({ checked: 1, failed: 0 });
|
||||
|
||||
expect(prisma.operationLog.create).not.toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: 'cmpp_connection.failed' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface CreateChannelDto {
|
||||
@@ -113,6 +115,7 @@ export interface CreateReceiptImportDto {
|
||||
|
||||
export interface UpsertConnectionStateDto {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId: string;
|
||||
connectionId: string;
|
||||
status: string;
|
||||
@@ -137,15 +140,45 @@ export interface CopyChannelDto {
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.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';
|
||||
|
||||
@Injectable()
|
||||
export class ChannelsService {
|
||||
export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(ChannelsService.name);
|
||||
private gatewayConnectionQueue?: Queue;
|
||||
private connectionTimeoutTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (process.env.GATEWAY_CONNECTING_TIMEOUT_SCANNER_DISABLED === 'true') {
|
||||
return;
|
||||
}
|
||||
this.connectionTimeoutTimer = setInterval(() => {
|
||||
void this.markTimedOutConnectingChannels().catch((error) => {
|
||||
this.logger.error(`Failed to mark timed-out CMPP connections: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}, getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_SCAN_MS', DEFAULT_CONNECTING_TIMEOUT_SCAN_MS));
|
||||
this.connectionTimeoutTimer.unref?.();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.connectionTimeoutTimer) {
|
||||
clearInterval(this.connectionTimeoutTimer);
|
||||
}
|
||||
await this.gatewayConnectionQueue?.close();
|
||||
}
|
||||
|
||||
listChannels() {
|
||||
return this.prisma.smsChannel.findMany({ orderBy: { createdAt: 'desc' }, take: 100 });
|
||||
}
|
||||
|
||||
createChannel(data: CreateChannelDto) {
|
||||
async createChannel(data: CreateChannelDto) {
|
||||
const missingFields = ['code', 'name', 'gatewayHost', 'gatewayPort', 'account', 'passwordCipher', 'srcId'].filter((field) => {
|
||||
const value = data[field as keyof CreateChannelDto];
|
||||
return value === undefined || value === null || value === '';
|
||||
@@ -157,7 +190,7 @@ export class ChannelsService {
|
||||
if (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535) {
|
||||
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
|
||||
}
|
||||
return this.prisma.smsChannel.create({
|
||||
const channel = await this.prisma.smsChannel.create({
|
||||
data: {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
@@ -177,6 +210,10 @@ export class ChannelsService {
|
||||
config: data.config as Prisma.InputJsonValue | undefined,
|
||||
},
|
||||
});
|
||||
if (channel.status === 'active') {
|
||||
await this.requestChannelConnection(channel, 'channel_created');
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
async updateChannel(channelId: string, data: UpdateChannelDto) {
|
||||
@@ -253,6 +290,9 @@ export class ChannelsService {
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
if (data.status === 'active') {
|
||||
await this.requestChannelConnection(updated, 'channel_enabled', data.operatorId);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -366,7 +406,7 @@ export class ChannelsService {
|
||||
});
|
||||
}
|
||||
|
||||
async listChannelLinkLogs(channelId: string) {
|
||||
async listChannelConnectionLogs(channelId: string) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId }, select: { id: true } });
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
@@ -412,34 +452,54 @@ export class ChannelsService {
|
||||
}
|
||||
|
||||
async upsertConnectionState(data: UpsertConnectionStateDto) {
|
||||
const status = normalizeGatewayConnectionStatus(data.status);
|
||||
if (data.applicationId) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } });
|
||||
if (!application) {
|
||||
throw new BadRequestException('applicationId does not reference an existing application');
|
||||
}
|
||||
if (data.tenantId && data.tenantId !== application.tenantId) {
|
||||
throw new BadRequestException('applicationId does not belong to tenantId');
|
||||
}
|
||||
data.tenantId = application.tenantId;
|
||||
}
|
||||
const payload = {
|
||||
tenantId: data.tenantId,
|
||||
status: data.status,
|
||||
applicationId: data.applicationId,
|
||||
status,
|
||||
desiredConnections: data.desiredConnections ?? 1,
|
||||
currentConnections: data.currentConnections ?? (data.status === 'online' || data.status === 'connected' ? 1 : 0),
|
||||
currentConnections: data.currentConnections ?? (status === 'connected' ? 1 : 0),
|
||||
lastConnectedAt: data.lastConnectedAt ? new Date(data.lastConnectedAt) : undefined,
|
||||
lastDisconnectedAt: data.lastDisconnectedAt ? new Date(data.lastDisconnectedAt) : undefined,
|
||||
lastHeartbeatAt: data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : undefined,
|
||||
reconnectCount: data.reconnectCount ?? 0,
|
||||
lastError: data.lastError,
|
||||
};
|
||||
const state = await this.prisma.cmppConnectionState.upsert({
|
||||
where: { channelId_connectionId: { channelId: data.channelId, connectionId: data.connectionId } },
|
||||
update: payload,
|
||||
create: {
|
||||
const existing = await this.prisma.cmppConnectionState.findFirst({
|
||||
where: {
|
||||
applicationId: data.applicationId ?? null,
|
||||
channelId: data.channelId,
|
||||
connectionId: data.connectionId,
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
const state = existing
|
||||
? await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data: payload })
|
||||
: await this.prisma.cmppConnectionState.create({
|
||||
data: {
|
||||
channelId: data.channelId,
|
||||
connectionId: data.connectionId,
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
action: `cmpp_connection.${normalizeConnectionAction(data.status)}`,
|
||||
action: `cmpp_connection.${normalizeConnectionAction(status)}`,
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${data.channelId}:${data.connectionId}`,
|
||||
detail: {
|
||||
status: data.status,
|
||||
status,
|
||||
applicationId: state.applicationId,
|
||||
desiredConnections: state.desiredConnections,
|
||||
currentConnections: state.currentConnections,
|
||||
lastError: state.lastError,
|
||||
@@ -449,6 +509,69 @@ export class ChannelsService {
|
||||
return state;
|
||||
}
|
||||
|
||||
async markTimedOutConnectingChannels(now = new Date()) {
|
||||
const timeoutMs = getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_MS', DEFAULT_CONNECTING_TIMEOUT_MS);
|
||||
const cutoff = new Date(now.getTime() - timeoutMs);
|
||||
const lastError = `${CONNECTING_TIMEOUT_ERROR} after ${Math.round(timeoutMs / 1000)} seconds`;
|
||||
const states = await this.prisma.cmppConnectionState.findMany({
|
||||
where: {
|
||||
status: 'connecting',
|
||||
updatedAt: { lte: cutoff },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
applicationId: true,
|
||||
channelId: true,
|
||||
connectionId: true,
|
||||
desiredConnections: true,
|
||||
currentConnections: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
let failed = 0;
|
||||
for (const state of states) {
|
||||
const result = await this.prisma.cmppConnectionState.updateMany({
|
||||
where: {
|
||||
id: state.id,
|
||||
status: 'connecting',
|
||||
updatedAt: { lte: cutoff },
|
||||
},
|
||||
data: {
|
||||
status: 'failed',
|
||||
currentConnections: 0,
|
||||
lastDisconnectedAt: now,
|
||||
lastError,
|
||||
},
|
||||
});
|
||||
if (result.count === 0) {
|
||||
continue;
|
||||
}
|
||||
failed += result.count;
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: state.tenantId,
|
||||
action: 'cmpp_connection.failed',
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${state.channelId}:${state.connectionId}`,
|
||||
detail: {
|
||||
reason: 'connect_timeout',
|
||||
applicationId: state.applicationId,
|
||||
status: 'failed',
|
||||
previousStatus: 'connecting',
|
||||
desiredConnections: state.desiredConnections,
|
||||
currentConnectionsBefore: state.currentConnections,
|
||||
currentConnections: 0,
|
||||
timeoutMs,
|
||||
lastError,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { checked: states.length, failed };
|
||||
}
|
||||
|
||||
listGroups() {
|
||||
return this.prisma.smsChannelGroup.findMany({
|
||||
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||
@@ -582,6 +705,25 @@ export class ChannelsService {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteGroup(groupId: string) {
|
||||
const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } });
|
||||
if (!group) {
|
||||
throw new NotFoundException('Channel group not found');
|
||||
}
|
||||
const boundRoute = await this.prisma.channelRouteRule.findFirst({
|
||||
where: {
|
||||
groupId,
|
||||
status: 'active',
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (boundRoute) {
|
||||
throw new BadRequestException('Channel group is used by application route rules and cannot be deleted');
|
||||
}
|
||||
await this.prisma.smsChannelGroupItem.deleteMany({ where: { groupId } });
|
||||
return this.prisma.smsChannelGroup.delete({ where: { id: groupId } });
|
||||
}
|
||||
|
||||
listRouteRules() {
|
||||
return this.prisma.channelRouteRule.findMany({
|
||||
include: { group: true, channel: true },
|
||||
@@ -796,11 +938,116 @@ export class ChannelsService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async requestChannelConnection(
|
||||
channel: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
gatewayHost: string;
|
||||
gatewayPort: number;
|
||||
account: string;
|
||||
passwordCipher: string;
|
||||
srcId: string;
|
||||
cmppVersion: string;
|
||||
rateLimitPerSecond: number;
|
||||
config?: Prisma.JsonValue | null;
|
||||
},
|
||||
reason: 'channel_created' | 'channel_enabled',
|
||||
operatorId?: string,
|
||||
) {
|
||||
const desiredConnections = getDesiredConnections(channel.config);
|
||||
const connectionId = defaultChannelConnectionId(channel.id);
|
||||
const existing = await this.prisma.cmppConnectionState.findFirst({
|
||||
where: {
|
||||
applicationId: null,
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
},
|
||||
});
|
||||
const data = {
|
||||
applicationId: null,
|
||||
status: 'connecting',
|
||||
desiredConnections,
|
||||
currentConnections: 0,
|
||||
lastError: null,
|
||||
};
|
||||
const state = existing
|
||||
? await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data })
|
||||
: await this.prisma.cmppConnectionState.create({
|
||||
data: {
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
userId: operatorId,
|
||||
action: 'cmpp_connection.connect_requested',
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${channel.id}:${connectionId}`,
|
||||
detail: {
|
||||
reason,
|
||||
status: state.status,
|
||||
desiredConnections: state.desiredConnections,
|
||||
currentConnections: state.currentConnections,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
const command = {
|
||||
schemaVersion: 'v1',
|
||||
messageType: 'ConnectChannel',
|
||||
traceId: randomUUID(),
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
createdAt: new Date().toISOString(),
|
||||
reason,
|
||||
desiredConnections,
|
||||
channel: {
|
||||
code: channel.code,
|
||||
name: channel.name,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: channel.gatewayPort,
|
||||
account: channel.account,
|
||||
passwordCipher: channel.passwordCipher,
|
||||
srcId: channel.srcId,
|
||||
cmppVersion: channel.cmppVersion,
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
},
|
||||
};
|
||||
await this.getGatewayConnectionQueue().add('connect-channel', command, { jobId: `${connectionId}:connect` });
|
||||
await this.notifyGatewayConnect(command);
|
||||
return state;
|
||||
}
|
||||
|
||||
private getGatewayConnectionQueue() {
|
||||
this.gatewayConnectionQueue ??= new Queue(GATEWAY_CONNECTION_QUEUE, { connection: bullmqConnection() });
|
||||
return this.gatewayConnectionQueue;
|
||||
}
|
||||
|
||||
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> };
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/connections/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(command),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Gateway connect request failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new BadRequestException(`Gateway connect request failed: ${response.status} ${responseText}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConnectionAction(status: string) {
|
||||
const normalized = status.toLowerCase();
|
||||
if (['online', 'connected', 'open'].includes(normalized)) {
|
||||
if (normalized === 'connected') {
|
||||
return 'connected';
|
||||
}
|
||||
if (['heartbeat', 'active_test'].includes(normalized)) {
|
||||
@@ -812,9 +1059,66 @@ function normalizeConnectionAction(status: string) {
|
||||
if (['offline', 'closed', 'disconnected'].includes(normalized)) {
|
||||
return 'disconnected';
|
||||
}
|
||||
if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) {
|
||||
return 'failed';
|
||||
}
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
function normalizeGatewayConnectionStatus(status: string) {
|
||||
const normalized = status.toLowerCase();
|
||||
if (['online', 'open', 'connected'].includes(normalized)) {
|
||||
return 'connected';
|
||||
}
|
||||
if (['connecting', 'connect_requested'].includes(normalized)) {
|
||||
return 'connecting';
|
||||
}
|
||||
if (['reconnecting', 'reconnect'].includes(normalized)) {
|
||||
return 'reconnecting';
|
||||
}
|
||||
if (['offline', 'closed', 'disconnected'].includes(normalized)) {
|
||||
return 'disconnected';
|
||||
}
|
||||
if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) {
|
||||
return 'failed';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function defaultChannelConnectionId(channelId: string) {
|
||||
return `${channelId}:${DEFAULT_CHANNEL_CONNECTION_ID}`;
|
||||
}
|
||||
|
||||
function getDesiredConnections(config?: Prisma.JsonValue | null) {
|
||||
if (config && typeof config === 'object' && !Array.isArray(config) && 'desiredConnections' in config) {
|
||||
const value = Number(config.desiredConnections);
|
||||
if (Number.isInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function bullmqConnection() {
|
||||
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
|
||||
return {
|
||||
host: redisUrl.hostname,
|
||||
port: Number(redisUrl.port || 6379),
|
||||
username: redisUrl.username || undefined,
|
||||
password: redisUrl.password || undefined,
|
||||
maxRetriesPerRequest: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getPositiveIntegerEnv(name: string, fallback: number) {
|
||||
const value = Number(process.env[name]);
|
||||
if (Number.isInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
|
||||
function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
|
||||
const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
@@ -990,8 +1294,11 @@ function validateGroupItems(
|
||||
}
|
||||
|
||||
function normalizeLinkEvent(action: string) {
|
||||
if (action.includes('connect_requested')) {
|
||||
return '连接请求';
|
||||
}
|
||||
if (action.includes('connected')) {
|
||||
return '新建';
|
||||
return '连接成功';
|
||||
}
|
||||
if (action.includes('heartbeat')) {
|
||||
return '心跳';
|
||||
@@ -1002,6 +1309,9 @@ function normalizeLinkEvent(action: string) {
|
||||
if (action.includes('disconnected')) {
|
||||
return '断开';
|
||||
}
|
||||
if (action.includes('failed')) {
|
||||
return '连接失败';
|
||||
}
|
||||
if (action.includes('copy')) {
|
||||
return '复制';
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { BadRequestException, Body, Controller, Get, Param, Post, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
@@ -11,6 +11,11 @@ type UploadedMultipartFile = {
|
||||
buffer: Buffer;
|
||||
};
|
||||
|
||||
type DownloadResponse = {
|
||||
setHeader(name: string, value: number | string): void;
|
||||
send(content: Buffer): void;
|
||||
};
|
||||
|
||||
@ApiTags('files')
|
||||
@Controller('admin/files')
|
||||
export class FilesController {
|
||||
@@ -21,6 +26,17 @@ export class FilesController {
|
||||
return this.files.list(tenantId);
|
||||
}
|
||||
|
||||
@Get(':id/download')
|
||||
async download(@Param('id') id: string, @Query('disposition') disposition: string | undefined, @Res() response: DownloadResponse) {
|
||||
const { fileObject, content } = await this.files.getDownload(id);
|
||||
const mode = disposition === 'inline' ? 'inline' : 'attachment';
|
||||
const encodedName = encodeURIComponent(fileObject.fileName);
|
||||
response.setHeader('Content-Type', fileObject.contentType || 'application/octet-stream');
|
||||
response.setHeader('Content-Length', content.length);
|
||||
response.setHeader('Content-Disposition', `${mode}; filename*=UTF-8''${encodedName}`);
|
||||
response.send(content);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() body: CreateFileObjectDto) {
|
||||
return this.files.create(body);
|
||||
@@ -34,6 +50,9 @@ export class FilesController {
|
||||
@Post('upload')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 20 * 1024 * 1024 } }))
|
||||
upload(@UploadedFile() file: UploadedMultipartFile, @Body('purpose') purpose: string, @Body('prefix') prefix?: string, @TenantId() tenantId?: string) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('Upload file is required');
|
||||
}
|
||||
return this.files.upload({ tenantId, purpose: purpose || 'general', prefix }, file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('FilesService', () => {
|
||||
bucket: 'cmpp-platform',
|
||||
fileName: '营业执照.png',
|
||||
contentType: 'image/png',
|
||||
sizeBytes: '12',
|
||||
purpose: 'signature_material',
|
||||
}));
|
||||
|
||||
@@ -47,4 +48,39 @@ describe('FilesService', () => {
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('downloads file content from object storage by FileObject id', async () => {
|
||||
const fileObject = {
|
||||
id: 'file-1',
|
||||
tenantId: 'tenant-1',
|
||||
bucket: 'cmpp-platform',
|
||||
objectKey: 'signature-materials/sig-1/file.png',
|
||||
fileName: 'file.png',
|
||||
contentType: 'image/png',
|
||||
sizeBytes: BigInt(12),
|
||||
checksum: null,
|
||||
purpose: 'signature_material',
|
||||
createdAt: new Date('2026-07-06T00:00:00.000Z'),
|
||||
};
|
||||
const prisma = {
|
||||
fileObject: {
|
||||
findUnique: jest.fn().mockResolvedValue(fileObject),
|
||||
},
|
||||
};
|
||||
const objectStorage = {
|
||||
getObject: jest.fn().mockResolvedValue(Buffer.from('file-content')),
|
||||
};
|
||||
const service = new FilesService(prisma as never, objectStorage as never);
|
||||
|
||||
await expect(service.getDownload('file-1')).resolves.toEqual({
|
||||
fileObject: expect.objectContaining({
|
||||
id: 'file-1',
|
||||
fileName: 'file.png',
|
||||
sizeBytes: '12',
|
||||
}),
|
||||
content: Buffer.from('file-content'),
|
||||
});
|
||||
expect(prisma.fileObject.findUnique).toHaveBeenCalledWith({ where: { id: 'file-1' } });
|
||||
expect(objectStorage.getObject).toHaveBeenCalledWith('signature-materials/sig-1/file.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -38,7 +38,7 @@ export class FilesService {
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}).then((items) => items.map(serializeFileObject));
|
||||
}
|
||||
|
||||
create(data: CreateFileObjectDto) {
|
||||
@@ -52,7 +52,7 @@ export class FilesService {
|
||||
checksum: data.checksum,
|
||||
purpose: data.purpose,
|
||||
};
|
||||
return this.prisma.fileObject.create({ data: createData });
|
||||
return this.prisma.fileObject.create({ data: createData }).then(serializeFileObject);
|
||||
}
|
||||
|
||||
async createPresignedUpload(data: CreatePresignedUploadDto) {
|
||||
@@ -79,4 +79,23 @@ export class FilesService {
|
||||
purpose: data.purpose,
|
||||
});
|
||||
}
|
||||
|
||||
async getDownload(id: string) {
|
||||
const fileObject = await this.prisma.fileObject.findUnique({ where: { id } });
|
||||
if (!fileObject) {
|
||||
throw new NotFoundException('File object not found');
|
||||
}
|
||||
const content = await this.objectStorage.getObject(fileObject.objectKey);
|
||||
return {
|
||||
fileObject: serializeFileObject(fileObject),
|
||||
content,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function serializeFileObject<T extends { sizeBytes: bigint | number | string }>(fileObject: T) {
|
||||
return {
|
||||
...fileObject,
|
||||
sizeBytes: fileObject.sizeBytes.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Client } from 'minio';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
@Injectable()
|
||||
export class ObjectStorageService {
|
||||
private readonly client: Client;
|
||||
private readonly bucket: string;
|
||||
private readonly driver: string;
|
||||
private readonly localRoot: string;
|
||||
private bucketReady = false;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
const endpoint = config.get<string>('MINIO_ENDPOINT') ?? 'localhost:9000';
|
||||
const [endPoint, portText] = endpoint.split(':');
|
||||
this.bucket = config.get<string>('MINIO_BUCKET') ?? 'cmpp-platform';
|
||||
this.driver = config.get<string>('OBJECT_STORAGE_DRIVER') ?? 'minio';
|
||||
this.localRoot = config.get<string>('OBJECT_STORAGE_LOCAL_ROOT') ?? join(process.cwd(), '..', '.local-data', 'object-storage');
|
||||
this.client = new Client({
|
||||
endPoint,
|
||||
port: Number(portText ?? 9000),
|
||||
@@ -20,17 +27,52 @@ export class ObjectStorageService {
|
||||
});
|
||||
}
|
||||
|
||||
presignedPutObject(objectKey: string, expirySeconds = 3600) {
|
||||
async presignedPutObject(objectKey: string, expirySeconds = 3600) {
|
||||
if (this.driver === 'local') {
|
||||
return Promise.resolve(`local://${this.bucket}/${objectKey}?expires=${expirySeconds}`);
|
||||
}
|
||||
await this.ensureBucket();
|
||||
return this.client.presignedPutObject(this.bucket, objectKey, expirySeconds);
|
||||
}
|
||||
|
||||
putObject(objectKey: string, content: Buffer, sizeBytes: number, contentType: string) {
|
||||
async putObject(objectKey: string, content: Buffer, sizeBytes: number, contentType: string) {
|
||||
if (this.driver === 'local') {
|
||||
const filePath = join(this.localRoot, this.bucket, ...objectKey.split('/'));
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, content);
|
||||
return { etag: `local-${sizeBytes}-${contentType}` };
|
||||
}
|
||||
await this.ensureBucket();
|
||||
return this.client.putObject(this.bucket, objectKey, content, sizeBytes, {
|
||||
'Content-Type': contentType,
|
||||
});
|
||||
}
|
||||
|
||||
async getObject(objectKey: string) {
|
||||
if (this.driver === 'local') {
|
||||
return readFile(join(this.localRoot, this.bucket, ...objectKey.split('/')));
|
||||
}
|
||||
await this.ensureBucket();
|
||||
const stream = await this.client.getObject(this.bucket, objectKey);
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
getBucket() {
|
||||
return this.bucket;
|
||||
}
|
||||
|
||||
private async ensureBucket() {
|
||||
if (this.bucketReady) {
|
||||
return;
|
||||
}
|
||||
const exists = await this.client.bucketExists(this.bucket);
|
||||
if (!exists) {
|
||||
await this.client.makeBucket(this.bucket, '');
|
||||
}
|
||||
this.bucketReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ function createPrismaMock() {
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
},
|
||||
cmppConnectionState: {
|
||||
groupBy: jest.fn().mockResolvedValue([{ status: 'online', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }]),
|
||||
groupBy: jest.fn().mockResolvedValue([{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }]),
|
||||
},
|
||||
operationLog: {
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
@@ -115,7 +115,7 @@ describe('OperationsService', () => {
|
||||
taskCount: 3,
|
||||
uplinkCount: 1,
|
||||
pendingAuditCount: 6,
|
||||
gatewayConnections: [{ status: 'online', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }],
|
||||
gatewayConnections: [{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }],
|
||||
}),
|
||||
);
|
||||
await service.statistics({ tenantId: 'tenant-1', groupBy: 'application' });
|
||||
|
||||
@@ -33,7 +33,7 @@ function createPrismaMock() {
|
||||
carrier: 'mobile',
|
||||
sendRegion: '全国',
|
||||
config: { serviceId: 'SMS' },
|
||||
connectionStates: [{ status: 'online', currentConnections: 1, desiredConnections: 1 }],
|
||||
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
|
||||
};
|
||||
const route = {
|
||||
id: 'route-1',
|
||||
@@ -485,7 +485,7 @@ describe('SendChainService', () => {
|
||||
status: 'active',
|
||||
carrier: 'all',
|
||||
sendRegion: '全国',
|
||||
connectionStates: [{ status: 'online', currentConnections: 1, desiredConnections: 1 }],
|
||||
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -505,7 +505,7 @@ describe('SendChainService', () => {
|
||||
status: 'active',
|
||||
carrier: 'all',
|
||||
sendRegion: '全国',
|
||||
connectionStates: [{ status: 'online', currentConnections: 1, desiredConnections: 1 }],
|
||||
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -863,7 +863,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return false;
|
||||
}
|
||||
return (channel.connectionStates ?? []).some((connection) =>
|
||||
connection.desiredConnections > 0 && connection.currentConnections > 0 && ['online', 'connected'].includes(connection.status),
|
||||
connection.desiredConnections > 0 && connection.currentConnections > 0 && connection.status === 'connected',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -81,8 +81,8 @@ function createPrismaMock() {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
cmppConnectionState: {
|
||||
findMany: jest.fn().mockResolvedValue([{ channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1', status: 'online', currentConnections: 1, desiredConnections: 1 }]),
|
||||
findFirst: jest.fn().mockResolvedValue({ channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1' }),
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'conn-state-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1', status: 'connected', currentConnections: 1, desiredConnections: 1 }]),
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'conn-state-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1' }),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-state-1', ...data })),
|
||||
},
|
||||
smsChannel: {
|
||||
@@ -256,7 +256,7 @@ describe('SmsConfigService', () => {
|
||||
await service.disconnectApplicationConnection('app-1', 'conn-a', { status: 'disconnected', reason: 'manual' });
|
||||
|
||||
expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({
|
||||
where: { channelId_connectionId: { channelId: 'channel-1', connectionId: 'conn-a' } },
|
||||
where: { id: 'conn-state-1' },
|
||||
data: expect.objectContaining({ status: 'disconnected', currentConnections: 0, lastError: 'manual' }),
|
||||
});
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
|
||||
@@ -110,15 +110,15 @@ export class SmsConfigService {
|
||||
if (!query.includeConnections) {
|
||||
return applications;
|
||||
}
|
||||
const tenantIds = [...new Set(applications.map((application) => application.tenantId))];
|
||||
const applicationIds = applications.map((application) => application.id);
|
||||
const connections = await this.prisma.cmppConnectionState.findMany({
|
||||
where: { tenantId: { in: tenantIds } },
|
||||
where: { applicationId: { in: applicationIds } },
|
||||
include: { channel: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
return applications.map((application) => {
|
||||
const appConnections = connections.filter((connection) => connection.tenantId === application.tenantId);
|
||||
const appConnections = connections.filter((connection) => connection.applicationId === application.id);
|
||||
const todayTotal = application.messageRecords.length;
|
||||
const delivered = application.messageRecords.filter((message) => message.status === 'delivered').length;
|
||||
return {
|
||||
@@ -298,7 +298,7 @@ export class SmsConfigService {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const connections = await this.prisma.cmppConnectionState.findMany({
|
||||
where: { tenantId: application.tenantId },
|
||||
where: { applicationId },
|
||||
include: { channel: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 100,
|
||||
@@ -351,13 +351,13 @@ export class SmsConfigService {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const connection = await this.prisma.cmppConnectionState.findFirst({
|
||||
where: { tenantId: application.tenantId, connectionId },
|
||||
where: { applicationId, connectionId },
|
||||
});
|
||||
if (!connection) {
|
||||
throw new NotFoundException('Connection not found');
|
||||
}
|
||||
const updated = await this.prisma.cmppConnectionState.update({
|
||||
where: { channelId_connectionId: { channelId: connection.channelId, connectionId } },
|
||||
where: { id: connection.id },
|
||||
data: {
|
||||
status: 'disconnected',
|
||||
currentConnections: 0,
|
||||
@@ -742,7 +742,7 @@ function normalizeApplicationCmppStatus(connections: Array<{ status: string; cur
|
||||
if (applicationStatus !== 'active') {
|
||||
return 'inactive';
|
||||
}
|
||||
if (connections.some((connection) => ['online', 'connected', 'open'].includes(connection.status) && connection.currentConnections > 0)) {
|
||||
if (connections.some((connection) => connection.status === 'connected' && connection.currentConnections > 0)) {
|
||||
return 'connected';
|
||||
}
|
||||
if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) {
|
||||
|
||||
@@ -12,6 +12,11 @@ export class TenantsController {
|
||||
return this.tenants.list();
|
||||
}
|
||||
|
||||
@Get('management-list')
|
||||
listManagementRows() {
|
||||
return this.tenants.listManagementRows();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
return this.tenants.get(id);
|
||||
|
||||
@@ -22,6 +22,12 @@ function createPrismaMock() {
|
||||
create: jest.fn().mockResolvedValue({ id: 'cert-1' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'cert-1' }),
|
||||
},
|
||||
tenantAccount: {
|
||||
findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 12000, smsUnits: 300, creditCents: 5000, status: 'active' }]),
|
||||
},
|
||||
smsMessageRecord: {
|
||||
groupBy: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', _sum: { amountCents: 350 } }]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,4 +77,25 @@ describe('TenantsService', () => {
|
||||
await expect(service.delete('missing')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(prisma.tenant.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists management rows with real account and today spend fields', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new TenantsService(prisma as never);
|
||||
|
||||
await expect(service.listManagementRows()).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'tenant-1',
|
||||
name: '测试企业',
|
||||
account: expect.objectContaining({ balanceCents: 12000, creditCents: 5000 }),
|
||||
todaySpendCents: 350,
|
||||
}),
|
||||
]);
|
||||
expect(prisma.tenant.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { status: { not: 'deleted' } },
|
||||
}));
|
||||
expect(prisma.smsMessageRecord.groupBy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
by: ['tenantId'],
|
||||
_sum: { amountCents: true },
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface CreateTenantDto {
|
||||
name: string;
|
||||
code: string;
|
||||
code?: string;
|
||||
status?: string;
|
||||
creditCode?: string;
|
||||
province?: string;
|
||||
@@ -44,6 +44,31 @@ export class TenantsService {
|
||||
}).then((items) => items.map(withEnterpriseProfile));
|
||||
}
|
||||
|
||||
async listManagementRows() {
|
||||
const sinceToday = startOfToday();
|
||||
const [tenants, accounts, todaySpendGroups] = await Promise.all([
|
||||
this.prisma.tenant.findMany({
|
||||
where: { status: { not: 'deleted' } },
|
||||
include: { enterpriseCertifications: { orderBy: { submittedAt: 'desc' }, take: 1 } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
}),
|
||||
this.prisma.tenantAccount.findMany({ take: 200 }),
|
||||
this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['tenantId'],
|
||||
where: { queuedAt: { gte: sinceToday } },
|
||||
_sum: { amountCents: true },
|
||||
}),
|
||||
]);
|
||||
const accountsByTenant = new Map(accounts.map((account) => [account.tenantId, account]));
|
||||
const todaySpendByTenant = new Map(todaySpendGroups.map((group) => [group.tenantId, group._sum.amountCents ?? 0]));
|
||||
return tenants.map((tenant) => ({
|
||||
...withEnterpriseProfile(tenant),
|
||||
account: accountsByTenant.get(tenant.id) ?? null,
|
||||
todaySpendCents: todaySpendByTenant.get(tenant.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
get(id: string) {
|
||||
return this.prisma.tenant.findUnique({
|
||||
where: { id },
|
||||
@@ -57,8 +82,9 @@ export class TenantsService {
|
||||
}
|
||||
|
||||
async create(data: CreateTenantDto) {
|
||||
const code = data.code?.trim() || generateTenantCode(data);
|
||||
const tenant = await this.prisma.tenant.create({
|
||||
data: { name: data.name, code: data.code, status: data.status ?? 'active' },
|
||||
data: { name: data.name, code, status: data.status ?? 'active' },
|
||||
});
|
||||
await this.upsertProfile(tenant.id, data);
|
||||
return this.get(tenant.id);
|
||||
@@ -163,3 +189,16 @@ function withEnterpriseProfile<T extends { enterpriseCertifications?: Array<{ li
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
function startOfToday() {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
function generateTenantCode(data: CreateTenantDto) {
|
||||
const source = data.creditCode?.trim() || data.name.trim();
|
||||
const normalized = source.replace(/[^\da-zA-Z]/g, '').toLowerCase();
|
||||
const suffix = Date.now().toString(36).slice(-6);
|
||||
return `ent-${(normalized || 'tenant').slice(0, 18)}-${suffix}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user