fix: harden real backend workflows and channel connections

This commit is contained in:
hectorzhao
2026-07-06 17:54:53 +08:00
parent 8cca361441
commit b5132d7f4e
47 changed files with 2530 additions and 314 deletions
@@ -0,0 +1,11 @@
-- Make CMPP connection states application-scoped for enterprise application status.
ALTER TABLE "CmppConnectionState" ADD COLUMN "applicationId" TEXT;
ALTER TABLE "CmppConnectionState" ADD CONSTRAINT "CmppConnectionState_applicationId_fkey"
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE SET NULL ON UPDATE CASCADE;
DROP INDEX IF EXISTS "CmppConnectionState_channelId_connectionId_key";
CREATE UNIQUE INDEX "CmppConnectionState_applicationId_channelId_connectionId_key"
ON "CmppConnectionState"("applicationId", "channelId", "connectionId");
CREATE INDEX "CmppConnectionState_applicationId_status_idx"
ON "CmppConnectionState"("applicationId", "status");
+7 -3
View File
@@ -352,6 +352,7 @@ model SmsApplication {
sendTasks SmsSendTask[]
batchTasks SmsBatchTask[]
messageRecords SmsMessageRecord[]
connectionStates CmppConnectionState[]
@@index([tenantId, status])
}
@@ -502,6 +503,7 @@ model SmsChannel {
model CmppConnectionState {
id String @id @default(cuid())
tenantId String?
applicationId String?
channelId String
connectionId String
status String @default("disconnected")
@@ -515,11 +517,13 @@ model CmppConnectionState {
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
tenant Tenant? @relation(fields: [tenantId], references: [id])
channel SmsChannel @relation(fields: [channelId], references: [id])
tenant Tenant? @relation(fields: [tenantId], references: [id])
application SmsApplication? @relation(fields: [applicationId], references: [id])
channel SmsChannel @relation(fields: [channelId], references: [id])
@@unique([channelId, connectionId])
@@unique([applicationId, channelId, connectionId])
@@index([tenantId, status])
@@index([applicationId, status])
@@index([channelId, status])
}
+12 -2
View File
@@ -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);
+170 -7
View File
@@ -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' }),
});
});
});
+326 -16
View File
@@ -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 '复制';
}
+20 -1
View File
@@ -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);
}
}
+36
View 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');
});
});
+22 -3
View File
@@ -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(),
};
}
+44 -2
View File
@@ -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 }],
},
},
],
+1 -1
View File
@@ -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({
+7 -7
View File
@@ -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))) {
+5
View File
@@ -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);
+27
View File
@@ -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 },
}));
});
});
+41 -2
View File
@@ -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}`;
}
File diff suppressed because one or more lines are too long