diff --git a/.gitignore b/.gitignore index 1a530d9..6a036de 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* +logs/ .DS_Store Thumbs.db diff --git a/api/prisma/migrations/20260706123000_application_cmpp_connections/migration.sql b/api/prisma/migrations/20260706123000_application_cmpp_connections/migration.sql new file mode 100644 index 0000000..615de53 --- /dev/null +++ b/api/prisma/migrations/20260706123000_application_cmpp_connections/migration.sql @@ -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"); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 611bf93..405fafb 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -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]) } diff --git a/api/src/channels/channels.controller.ts b/api/src/channels/channels.controller.ts index 697154c..be6f205 100644 --- a/api/src/channels/channels.controller.ts +++ b/api/src/channels/channels.controller.ts @@ -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); diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index 91c919f..200b73a 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -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' }), + }); + }); }); diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index 074523a..8b10dd6 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -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; + 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) { + const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? DEFAULT_GATEWAY_CONTROL_URL).replace(/\/+$/, ''); + let response: { ok: boolean; status: number; text: () => Promise }; + 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 '复制'; } diff --git a/api/src/files/files.controller.ts b/api/src/files/files.controller.ts index 0b64d47..e8a777e 100644 --- a/api/src/files/files.controller.ts +++ b/api/src/files/files.controller.ts @@ -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); } } diff --git a/api/src/files/files.service.spec.ts b/api/src/files/files.service.spec.ts index 0e2b2c9..efaf22f 100644 --- a/api/src/files/files.service.spec.ts +++ b/api/src/files/files.service.spec.ts @@ -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'); + }); }); diff --git a/api/src/files/files.service.ts b/api/src/files/files.service.ts index fca5a87..4cc38c0 100644 --- a/api/src/files/files.service.ts +++ b/api/src/files/files.service.ts @@ -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(fileObject: T) { + return { + ...fileObject, + sizeBytes: fileObject.sizeBytes.toString(), + }; } diff --git a/api/src/files/object-storage.service.ts b/api/src/files/object-storage.service.ts index 7885491..332c4ff 100644 --- a/api/src/files/object-storage.service.ts +++ b/api/src/files/object-storage.service.ts @@ -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('MINIO_ENDPOINT') ?? 'localhost:9000'; const [endPoint, portText] = endpoint.split(':'); this.bucket = config.get('MINIO_BUCKET') ?? 'cmpp-platform'; + this.driver = config.get('OBJECT_STORAGE_DRIVER') ?? 'minio'; + this.localRoot = config.get('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; + } } diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index 8ddf32d..bc36073 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -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' }); diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index fef922a..da4e145 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -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 }], }, }, ], diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 136deab..961f577 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -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', ); } diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index c57ff6c..878f4f4 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -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({ diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index fdc1c12..093250c 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -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))) { diff --git a/api/src/tenants/tenants.controller.ts b/api/src/tenants/tenants.controller.ts index 4ba7c97..64c5cb8 100644 --- a/api/src/tenants/tenants.controller.ts +++ b/api/src/tenants/tenants.controller.ts @@ -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); diff --git a/api/src/tenants/tenants.service.spec.ts b/api/src/tenants/tenants.service.spec.ts index 0cbe8ff..ab9546a 100644 --- a/api/src/tenants/tenants.service.spec.ts +++ b/api/src/tenants/tenants.service.spec.ts @@ -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 }, + })); + }); }); diff --git a/api/src/tenants/tenants.service.ts b/api/src/tenants/tenants.service.ts index 6780521..c462fab 100644 --- a/api/src/tenants/tenants.service.ts +++ b/api/src/tenants/tenants.service.ts @@ -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 reconnecting -> online;重连次数增加;未确认消息状态明确。 | +| TC-CMPP-STATUS-005 | 模拟 TCP 断开再恢复。 | 状态 disconnected -> reconnecting -> connected;重连次数增加;未确认消息状态明确。 | | TC-CMPP-STATUS-006 | 主通道离线、备用在线且报备通过。 | 路由跳过主通道并选择备用;trace 展示备用 channelId。 | | TC-CMPP-STATUS-007 | 所有通道离线或认证失败。 | 不提交到离线连接;任务 delayed/retry/failed/pending_channel;不错误扣费。 | -| TC-CMPP-STATUS-008 | 通道业务 disabled 但连接 online。 | 不参与路由;连接状态仍可运维观察;启用后按连接状态恢复可用性。 | +| TC-CMPP-STATUS-008 | 通道业务 disabled 但连接 connected。 | 不参与路由;连接状态仍可运维观察;启用后按连接状态恢复可用性。 | | TC-CMPP-STATUS-009 | 模拟 submit resp 慢响应。 | 窗口占用、慢响应、队列积压可见;恢复后积压下降;超时可追踪。 | -| TC-CMPP-STATUS-010 | 触发 online/disconnected/reconnecting/online。 | 每次变化有状态历史、健康指标和系统日志。 | +| TC-CMPP-STATUS-010 | 触发 connected/disconnected/reconnecting/connected。 | 每次变化有状态历史、健康指标和系统日志。 | | TC-CMPP-STATUS-011 | 通道 maxConnections=4、desired=2、current=2。 | 通道详情、监控、Dashboard 连接数一致;连接列表展示 connectionId、心跳、窗口、sequence。 | | TC-CMPP-STATUS-012 | desired 1 调整为 3。 | Gateway 建立新连接至 3/3;任务可按连接/窗口分摊;日志记录调整。 | | TC-CMPP-STATUS-013 | desired 3 调整为 1。 | 多余连接优雅关闭;未确认 submit 不丢失不重复;终态 1/1。 | -| TC-CMPP-STATUS-014 | 3 条连接中断 1 条。 | 展示 degraded 或 2/3 online;异常连接数增加;重连恢复后 3/3。 | +| TC-CMPP-STATUS-014 | 3 条连接中断 1 条。 | 展示 degraded 或 2/3 connected;异常连接数增加;重连恢复后 3/3。 | | TC-CMPP-STATUS-015 | desired=0 或 current=0。 | 路由不选择该通道;无备用时任务失败或等待;原因包含无在线连接。 | | TC-CMPP-STATUS-016 | 单连接限速 100,连接数 1 和 2 分别压测。 | 理论能力随在线连接数变化;实际 TPS 不超过限速;不重复发送。 | +| TC-CMPP-STATUS-017 | 新建/启用通道后 Gateway 未回写,连接状态停留 connecting 超过 30 秒。 | API 兜底任务将连接标记为 failed,currentConnections=0,lastError 为 `Gateway connection request timed out after 30 seconds`;连接日志包含 connect_timeout;发送路由不可选择该通道。 | ### 17.9 自动化落地建议 @@ -2668,7 +2669,7 @@ npm run verify:phase8 | API Jest | 认证、字典、安全控制、通道复制/软删除、连接状态、人工充值、系统日志查询、Dashboard 聚合口径。 | | HTTP Smoke | 客户创建、认证审核、通道复制、连接状态回写、人工充值、立即发送、定时到点、trace、reconciliation。 | | Go Gateway | 连接状态回写契约、登录成功/失败、心跳超时、断线重连、窗口占满、连接数调整。 | -| 前端 Smoke | 客户端头像菜单、系统日志分页、运营模板审核搜索、企业认证详情、通道复制/链接日志、安全控制 CRUD、Dashboard 指标跳转。 | +| 前端 Smoke | 客户端头像菜单、系统日志分页、运营模板审核搜索、企业认证详情、通道复制/连接日志、安全控制 CRUD、Dashboard 指标跳转。 | | 性能 Smoke | BullMQ 500 TPS、CMPP 连接数变化后的提交能力、慢响应积压恢复。 | ### 17.9 登录和用户管理闭环 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 0a9f005..0458253 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -1,5 +1,60 @@ # 第一版系统化测试进度 +## 2026-07-06 企业管理列表字段回归 + +- 按设计锚点 `131f344a^` 恢复运营端企业管理列表字段:企业 ID、企业名称、当前余额、透支限额、今日消费、企业状态、操作。 +- 新增真实后端接口 `GET /api/admin/tenants/management-list`,由 NestJS/Prisma 聚合租户、企业账户和当天短信消息金额;前端不再用静态字段或本地假数拼出今日消费。 +- 当前余额来自 `TenantAccount.balanceCents`,透支限额来自 `TenantAccount.creditCents`,今日消费来自当天 `SmsMessageRecord.amountCents` 汇总。 +- 已执行: + - `npm --prefix api test -- tenants.service.spec.ts --runInBand` + - `npm --prefix api run build` + - `npm run build` +- 验证结果:API 单测、API build、前端 build 均通过;前端 build 仅保留既有 Vite chunk size warning。 + +## 2026-07-06 企业编辑页和上传链路修复 + +- 按设计锚点 `131f344a^` 恢复运营端企业新建/编辑页字段:企业照片、企业名称、统一社会信用代码、省/直辖市、市/区、通讯地址、联系人姓名、身份证号、手机号、电子邮箱。 +- 运营端企业新建/编辑页移除偏离锚点的企业编码、企业状态字段;后端 `POST /api/admin/tenants` 支持不传企业编码,并按信用代码/企业名生成真实唯一企业编码。 +- 修复营业执照/企业照片上传 500: + - 启动脚本在 MinIO 不可用时启用本地对象存储 `.local-data/object-storage`,文件仍通过真实 NestJS 上传接口写入对象存储目录并创建 `FileObject` 数据库记录。 + - 文件上传接口缺少 multipart 文件时返回 400。 + - `FileObject.sizeBytes` 返回前转换为字符串,避免 Prisma `BigInt` JSON 序列化 500。 +- 已执行: + - `npm --prefix api test -- tenants.service.spec.ts files.service.spec.ts --runInBand` + - `npm --prefix api run build` + - `npm run build` + - `POST http://localhost:3000/api/admin/files/upload` multipart smoke +- 验证结果:API 单测、API build、前端 build 和真实上传 smoke 均通过;前端 build 仅保留既有 Vite chunk size warning。 + +## 2026-07-06 本地 MinIO 启动脚本补充 + +- `tools/start-local.ps1` 补充本地 MinIO 启动流程:Docker Compose 优先;无 Docker 时查找 `C:\cmpp-platform-local\minio.exe`、`C:\cmpp-platform-local\minio\minio.exe` 或 PATH 中的 `minio.exe`,使用 `C:\cmpp-platform-local\minio-data` 作为数据目录,监听 `9000/9001`。 +- `package.json` 新增 `npm run start:local:minio`,用于单独启动本地 MinIO。 +- MinIO 不可用时,脚本仍会明确启用 `.local-data/object-storage` fallback;启动完成提示会区分 MinIO 是否真实运行。 +- MinIO 模式下对象存储服务会在上传/预签名前自动确认并创建 `cmpp-platform` bucket。 +- 已执行: + - `npm --prefix api run build` + - `npm run start:local -- -SkipApi -SkipWeb -SkipMigrate` +- 验证结果:API build 和启动脚本 smoke 通过;当前机器未发现 `minio.exe`,脚本按预期提示并启用本地对象存储 fallback。 + +## 2026-07-06 应用级 CMPP 连接和签名/引流表单基线 + +- CMPP 连接状态从企业/租户级聚合改为应用级独立连接: + - `CmppConnectionState` 新增 `applicationId` 并关联 `SmsApplication`。 + - 企业应用列表和连接详情只读取当前应用的 `CmppConnectionState`。 + - 运营端断开连接只操作当前应用下的连接。 + - Gateway 连接上报 `POST /api/admin/gateway/connections` 支持 `applicationId`,新连接可按应用独立记录。 +- 运营端添加/编辑短信签名页面按设计锚点 `131f344a^` 补齐字段:签名依据、短信签名、资质凭证、公司名称、统一社会信用代码、法人姓名、法人身份证号、法人身份证照片、责任人姓名、责任人手机号、责任人身份证号、责任人身份证照片、三网报备状态。 +- 运营端添加/编辑引流信息页面按设计锚点 `131f344a^` 补齐字段:引流信息、字段名称 1-10、文件上传、三网报备状态、提交时间、备注。 +- 签名和引流表单仍使用真实 `enterprise-signatures` 后端接口保存;扩展字段写入 `SmsSignature.drainageInfo` JSON,文件上传走真实 `admin/files/upload` 并保存 `FileObject` 引用。 +- 已执行: + - `npm --prefix api run prisma:generate` + - `npm --prefix api test -- sms-config.service.spec.ts channels.service.spec.ts --runInBand` + - `npm run build` + - `npm --prefix api run build` + - `npm --prefix api run prisma:migrate:deploy` +- 验证结果:Prisma Client 生成、API 针对测试、API build、前端 build 和本地 PostgreSQL migration deploy 均通过;前端 build 仅保留既有 Vite chunk size warning。 + ## 2026-07-01 ### 新增测试基础 @@ -183,7 +238,7 @@ npm run test:gateway - 通道管理补齐真实 API: - `POST /api/admin/channels/:id/copy`:复制通道配置、通道报备字段和该通道签名报备材料,写入操作日志。 - `DELETE /api/admin/channels/:id`:软删除通道,避免破坏历史发送/报备外键。 - - `GET /api/admin/channels/:id/link-logs`:基于 `OperationLog` 和 `CmppConnectionState` 查询链接日志。 + - `GET /api/admin/channels/:id/connection-logs`:基于 `OperationLog` 和 `CmppConnectionState` 查询连接日志;保留 `/link-logs` 兼容旧前端。 - 安全控制补齐真实 API:敏感词、全局黑名单、企业黑名单支持 keyword/status 查询、创建、启停/软删除,并写操作日志。 - 模板审核补齐真实查询:运营端模板列表支持 keyword/status,并返回企业、应用、签名信息;前端模板审核页已改为调用真实 API。 - 企业认证审核补齐真实查询:列表支持 keyword/status,详情返回企业信息和认证 materials;前端企业认证审核页已改为调用真实 API。 @@ -193,7 +248,7 @@ npm run test:gateway | 测试文件 | 新增覆盖 | | --- | --- | -| `api/src/channels/channels.service.spec.ts` | 通道复制、软删除、连接状态日志写入、链接日志查询。 | +| `api/src/channels/channels.service.spec.ts` | 通道复制、软删除、连接状态日志写入、连接日志查询。 | | `api/src/dictionaries/dictionaries.service.spec.ts` | 敏感词、全局黑名单、企业黑名单查询、创建、软删除和操作日志。 | ### 已执行命令 @@ -214,7 +269,7 @@ npm run build - 已将今天的客户端和运营端优化要求补入 `docs/first-version-development-requirements.md`: - 去除客户端独立账号设置菜单,改为头像下拉承载退出登录和修改密码。 - - 明确模板审核搜索、企业认证详情审核、企业应用 CMPP 连接数/连接详情/参数复制、通道复制、通道软删除、通道链接日志、安全控制 CRUD、系统日志分页等均需要真实后端 API 支撑。 + - 明确模板审核搜索、企业认证详情审核、企业应用 CMPP 连接数/连接详情/参数复制、通道复制、通道软删除、通道连接日志、安全控制 CRUD、系统日志分页等均需要真实后端 API 支撑。 - 补充客户端用户、运营端企业认证、通道、连接、字典、安全控制、系统日志等接口范围。 - 修正 Codex 执行模板,明确 mock、localStorage 或静态数据不得作为真实开发完成标准。 - 已将今天的验收点补入 `docs/system-functional-test-cases.md`: @@ -253,7 +308,7 @@ node | TC-CMPP-STATUS-UI | UI-SMOKE PASS / BACKEND GAP | 企业应用管理可展示 CMPP 状态和连接数量并打开连接详情;页面当前仍有本地初始数据路径。 | | TC-FRONTEND-CONSOLE | PASS | 关键页面无相关 console error/pageerror;仅忽略 favicon 404。 | | TC-BILLING-MANUAL-API | PASS | 人工充值无需审批:确认后账户余额、短信条数、充值单、账户流水和 Dashboard transactions 聚合同步更新。 | -| TC-CMPP-STATUS-API | PASS | 通道创建、Gateway 连接状态回写、按通道/客户查询、链接日志和 Dashboard gatewayConnections 聚合通过。 | +| TC-CMPP-STATUS-API | PASS | 通道创建、Gateway 连接状态回写、按通道/客户查询、连接日志和 Dashboard gatewayConnections 聚合通过。 | ### 发现和说明 @@ -274,7 +329,7 @@ node | BUG-DEV-001 | P1 | `npm run dev` 在 5173 被占用后切到 5174,Vite 依赖 bundling 长时间未完成,浏览器看到白屏。 | 本轮浏览器测试中 5174 HTTP 后续可达,但首次打开截图为空白;生产 build/preview 正常。 | 检查 Vite dev 依赖预构建和端口占用问题,确保开发模式可稳定渲染。 | | BUG-SEND-001 | P0 | 发送路由规则允许直接绑定单个通道,违反“规则只能绑定通道组”的业务约束。 | `SendChainService.selectChannel()` 当前存在 `route?.channel ?? route?.group...` 路径;`ChannelRouteRule` 模型也保留 `channelId` 字段。 | 路由规则只能表达应用到通道组的绑定关系;发送链路必须从企业应用绑定的运营商通道组内选路,不允许规则直接指定单个通道。 | | BUG-SEND-002 | P0 | 未命中路由规则时会 fallback 到全局第一个 `active` 通道,可能把短信发到未配置给该企业/应用的通道。 | `SendChainService.selectChannel()` 未找到 route 后执行 `smsChannel.findFirst({ where: { status: 'active' } })`。 | 企业应用没有配置对应运营商通道组或无可用通道时,短信直接 failed;不得进入 pending/delayed,不得 fallback 到其他 active 通道,需记录 trace/日志。 | -| BUG-SEND-003 | P0 | 发送选路只判断通道业务状态 `active`,不判断 CMPP 真实连接状态。 | `selectChannel()` 只检查 `SmsChannel.status`,未查询 `CmppConnectionState.status/currentConnections/lastHeartbeatAt/lastError`。 | 选路必须跳过离线、认证失败、心跳超时、重连中或 `currentConnections=0` 的通道;至少 1 条连接 online 且心跳正常才可发送,连续 3 次心跳失败进入重连且不可选。 | +| BUG-SEND-003 | P0 | 发送选路只判断通道业务状态 `active`,不判断 CMPP 真实连接状态。 | `selectChannel()` 只检查 `SmsChannel.status`,未查询 `CmppConnectionState.status/currentConnections/lastHeartbeatAt/lastError`。 | 选路必须跳过离线、认证失败、心跳超时、重连中或 `currentConnections=0` 的通道;至少 1 条连接 connected 且心跳正常才可发送,连续 3 次心跳失败进入重连且不可选。 | | BUG-SEND-004 | P0 | 通道组主通道提交失败、超时或回执失败后不会切换到下一个通道补发。 | `handleSubmitResult()` 和 `handleReceipt()` 只更新状态、释放/退款和刷新进度,没有重新选路或创建补发记录;`retry.maxAttempts` 目前未形成业务补发闭环。 | 除 unknown、超过 72 小时、超过通道组补发时间上限或通道组关闭补发外,submit rejected/timeout、连接断开、未提交成功、receipt failed 均需补发;省网失败后立即走全国通道,全国通道按优先级继续补发,最终成功只按企业应用客户费率扣一次。 | | BUG-SEND-005 | P0 | 通道组省网/全国路由没有接入真实发送链路,手机号段库也未参与归属地识别。 | `SmsChannelGroupItem` 和 `ChannelRouteRule` 虽有 `carrier/province` 字段,`PhoneSegment` 有 `prefix/carrier/province/city`,但 `SendChainService.selectChannel()` 未读取 message.phoneNumber、未查询 `phoneSegment`,只按优先级取第一个 active 通道;前端 `AdminChannelGroupFormPage` 的省网/全国配置仍为本地 `useState`。 | 发送前按可配置号码前缀正则识别运营商,识别失败走移动通道组;按手机号段库识别省份和城市,省份识别失败走对应运营商全国通道;通道需支持移动/联通/电信/三网和全国/单省发送地区,三网作为通配。 | | BUG-SEND-006 | P0 | 企业应用缺少按运营商绑定多个通道组和保存校验的真实闭环。 | 当前发送链路只按 `tenantId/applicationId` 查询单一路由规则;未体现一个应用分别绑定移动、联通、电信通道组,也未强制至少绑定一个通道组后才能保存。 | 企业应用可分别绑定移动、联通、电信通道组;一个都不绑定时 UI 不允许保存,发送时直接 failed;移动、联通、电信短信按识别结果进入对应通道组。 | @@ -285,7 +340,8 @@ node - BUG-SEND-001:后端 `createRouteRule` 禁止直接绑定单通道,路由规则只能绑定应用、运营商和通道组;发送链路不再读取 `route.channel`。 - BUG-SEND-002:发送链路未找到企业应用对应运营商通道组或无可用在线通道时,短信直接标记 `failed`,不再 fallback 到全局第一个 active 通道。 -- BUG-SEND-003:发送选路加入 CMPP 连接状态过滤,通道必须业务 `active`、连接 `online/connected`、`desiredConnections > 0` 且 `currentConnections > 0` 才可选。 +- BUG-SEND-003:发送选路加入 CMPP 连接状态过滤,通道必须业务 `active`、连接 `connected`、`desiredConnections > 0` 且 `currentConnections > 0` 才可选;`online/open` 仅作为旧 Gateway 回写兼容词入库归一化。 +- BUG-CMPP-STATUS-001:新建/启用通道后若 Gateway 连接请求长时间无回写,API 后台兜底任务会将超过 30 秒的 `connecting` 连接标记为 `failed`,写入超时原因和连接日志,避免页面长期停留“连接中”。 - BUG-SEND-004:submit rejected、submit timeout、回执 failed 等失败场景会在补发开启且未超过时间限制时,排除已尝试通道并切换到同一通道组全国通道继续提交;unknown、超过 72 小时、超过通道组补发上限或关闭补发时不补发。 - BUG-SEND-005:新增 `PhoneCarrierRule` 运营商前缀正则配置,发送前先识别运营商,识别失败默认移动;手机号段库用于识别省份,省份识别失败走对应运营商全国通道;通道新增 `sendRegion`,支持全国或单省。 - BUG-SEND-006:企业应用创建页面可分别选择移动、联通、电信通道组,一个都不选时 UI 阻止保存;创建应用成功后写入真实通道组路由规则。 @@ -601,3 +657,50 @@ git diff --check - 企业模板管理表格最小宽度 1820px,模板内容列 420px,横向滚动生效。 - 新建企业应用弹窗在 1280px 视口下未截断,未选择企业时“下一步”禁用。 - 新建短信应用页显示三网通道组卡片、已配置数量和无可用通道组提示,初始状态“创建应用”禁用。 + +## 2026-07-06 文件上传预览和下载回归 + +### 本轮修复 + +- 文件服务新增真实下载接口 `GET /api/admin/files/:id/download`,从 MinIO 或本地对象存储读取真实文件对象,支持 `inline` 预览和 `attachment` 下载。 +- 运营端企业照片、企业签名材料、引流材料、报备回执导入均在真实上传成功后显示下载入口;图片类型文件显示点击预览入口。 +- 客户端企业认证营业执照上传成功后显示下载入口,图片类型文件显示点击预览入口;提交认证时保存文件类型信息。 +- 客户端签名列表对已保存签名材料显示下载入口,图片材料按文件名或类型显示预览入口。 +- 客户端短信发送导入号码文件为前端解析文件,未生成后端文件对象;页面仅提供本地原始文件下载,不标记为真实后端归档。 + +### 已执行命令 + +```bash +npm --prefix api test -- files.service.spec.ts +npm --prefix api run build +npm run build +git diff --check +``` + +### 当前结果 + +- 文件服务单测通过:1 个 test suite、2 个测试通过。 +- API build 通过。 +- 前端 build 通过,仍存在既有 Vite chunk size warning。 +- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。 + +## 2026-07-06 企业列表人工充值入口 + +### 本轮修复 + +- 运营端企业管理列表新增“充值”按钮。 +- 点击“充值”打开企业人工充值弹窗,展示企业名称、当前余额,并支持录入充值金额、操作人和备注;企业列表入口不要求填写短信条数。 +- 提交后调用现有真实接口 `POST /api/admin/billing/manual-recharges`,成功后重新拉取企业管理列表,余额来自真实账户接口聚合结果。 +- 该入口不使用前端本地状态模拟充值入账;充值订单、账户余额、账户流水和操作日志仍由后端 `BillingService.createManualRecharge` 负责。 + +### 已执行命令 + +```bash +npm run build +git diff --check +``` + +### 当前结果 + +- 前端 build 通过,仍存在既有 Vite chunk size warning。 +- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。 diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index 9654f23..94b2ad5 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -5,6 +5,7 @@ import ( "net/http" "os" + "cmpp-platform/gateway/internal/control" "cmpp-platform/gateway/internal/health" ) @@ -14,8 +15,12 @@ func main() { addr = ":8090" } - log.Printf("cmpp gateway health server listening on %s", addr) - if err := http.ListenAndServe(addr, health.Handler()); err != nil { - log.Fatalf("gateway health server stopped: %v", err) + mux := http.NewServeMux() + mux.Handle("/health", health.Handler()) + control.Register(mux, control.Server{APIBaseURL: os.Getenv("API_BASE_URL")}) + + log.Printf("cmpp gateway control server listening on %s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("gateway control server stopped: %v", err) } } diff --git a/gateway/internal/control/server.go b/gateway/internal/control/server.go new file mode 100644 index 0000000..e0f6d27 --- /dev/null +++ b/gateway/internal/control/server.go @@ -0,0 +1,186 @@ +package control + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + cmpp "github.com/bigwhite/gocmpp" +) + +const defaultConnectTimeout = 5 * time.Second + +type DialFunc func(context.Context, ConnectChannelCommand) error + +type ConnectChannelCommand struct { + SchemaVersion string `json:"schemaVersion"` + MessageType string `json:"messageType"` + TraceID string `json:"traceId"` + ChannelID string `json:"channelId"` + ConnectionID string `json:"connectionId"` + Reason string `json:"reason"` + DesiredConnections int `json:"desiredConnections"` + Channel ChannelConfig `json:"channel"` +} + +type ChannelConfig struct { + Code string `json:"code"` + Name string `json:"name"` + GatewayHost string `json:"gatewayHost"` + GatewayPort int `json:"gatewayPort"` + Account string `json:"account"` + PasswordCipher string `json:"passwordCipher"` + SrcID string `json:"srcId"` + CMPPVersion string `json:"cmppVersion"` + RateLimitPerSecond int `json:"rateLimitPerSecond"` +} + +type ConnectionStateCallback struct { + ChannelID string `json:"channelId"` + ConnectionID string `json:"connectionId"` + Status string `json:"status"` + DesiredConnections int `json:"desiredConnections"` + CurrentConnections int `json:"currentConnections"` + LastConnectedAt string `json:"lastConnectedAt,omitempty"` + LastDisconnectedAt string `json:"lastDisconnectedAt,omitempty"` + LastHeartbeatAt string `json:"lastHeartbeatAt,omitempty"` + ReconnectCount int `json:"reconnectCount,omitempty"` + LastError string `json:"lastError,omitempty"` +} + +type Server struct { + APIBaseURL string + HTTPClient *http.Client + Dial DialFunc +} + +func Register(mux *http.ServeMux, server Server) { + if server.HTTPClient == nil { + server.HTTPClient = &http.Client{Timeout: 10 * time.Second} + } + if server.Dial == nil { + server.Dial = DialCMPP + } + mux.HandleFunc("/connections/connect", server.handleConnectChannel) +} + +func (s Server) handleConnectChannel(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var command ConnectChannelCommand + if err := json.NewDecoder(r.Body).Decode(&command); err != nil { + http.Error(w, fmt.Sprintf("invalid connect command: %v", err), http.StatusBadRequest) + return + } + if err := validateConnectChannelCommand(command); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + status := ConnectionStateCallback{ + ChannelID: command.ChannelID, + ConnectionID: command.ConnectionID, + DesiredConnections: desiredConnections(command.DesiredConnections), + } + if err := s.Dial(r.Context(), command); err != nil { + status.Status = "failed" + status.CurrentConnections = 0 + status.LastDisconnectedAt = time.Now().UTC().Format(time.RFC3339Nano) + status.LastError = err.Error() + } else { + now := time.Now().UTC().Format(time.RFC3339Nano) + status.Status = "connected" + status.CurrentConnections = status.DesiredConnections + status.LastConnectedAt = now + status.LastHeartbeatAt = now + } + + if err := s.postConnectionState(r.Context(), status); err != nil { + http.Error(w, fmt.Sprintf("failed to callback api: %v", err), http.StatusBadGateway) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(status) +} + +func DialCMPP(ctx context.Context, command ConnectChannelCommand) error { + ctx, cancel := context.WithTimeout(ctx, defaultConnectTimeout) + defer cancel() + + version := cmpp.V30 + if strings.HasPrefix(command.Channel.CMPPVersion, "2") { + version = cmpp.V20 + } + + client := cmpp.NewClient(version) + defer client.Disconnect() + + done := make(chan error, 1) + go func() { + addr := fmt.Sprintf("%s:%d", command.Channel.GatewayHost, command.Channel.GatewayPort) + done <- client.Connect(addr, command.Channel.Account, command.Channel.PasswordCipher, defaultConnectTimeout) + }() + + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-done: + return err + } +} + +func (s Server) postConnectionState(ctx context.Context, state ConnectionStateCallback) error { + apiBaseURL := strings.TrimRight(s.APIBaseURL, "/") + if apiBaseURL == "" { + apiBaseURL = "http://127.0.0.1:3000/api" + } + payload, err := json.Marshal(state) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBaseURL+"/admin/gateway/connections", bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := s.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("api returned %s", resp.Status) + } + return nil +} + +func validateConnectChannelCommand(command ConnectChannelCommand) error { + if command.MessageType != "ConnectChannel" { + return fmt.Errorf("unsupported messageType %q", command.MessageType) + } + if command.ChannelID == "" || command.ConnectionID == "" { + return fmt.Errorf("channelId and connectionId are required") + } + if command.Channel.GatewayHost == "" || command.Channel.GatewayPort <= 0 { + return fmt.Errorf("gatewayHost and gatewayPort are required") + } + if command.Channel.Account == "" || command.Channel.PasswordCipher == "" { + return fmt.Errorf("account and passwordCipher are required") + } + return nil +} + +func desiredConnections(value int) int { + if value > 0 { + return value + } + return 1 +} diff --git a/gateway/internal/control/server_test.go b/gateway/internal/control/server_test.go new file mode 100644 index 0000000..4c89300 --- /dev/null +++ b/gateway/internal/control/server_test.go @@ -0,0 +1,122 @@ +package control + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestConnectChannelCallbacksConnectedState(t *testing.T) { + var callback ConnectionStateCallback + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/admin/gateway/connections" { + t.Fatalf("unexpected callback path: %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&callback); err != nil { + t.Fatalf("decode callback: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer api.Close() + + handler := handlerWithDial(api.URL+"/api", func(context.Context, ConnectChannelCommand) error { + return nil + }) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/connections/connect", strings.NewReader(validConnectCommand())) + handler.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String()) + } + if callback.Status != "connected" || callback.CurrentConnections != 2 || callback.DesiredConnections != 2 { + t.Fatalf("unexpected callback state: %+v", callback) + } + if callback.ChannelID != "channel-1" || callback.ConnectionID != "channel-1:primary" { + t.Fatalf("unexpected callback identity: %+v", callback) + } + if callback.LastConnectedAt == "" || callback.LastHeartbeatAt == "" { + t.Fatalf("expected connection timestamps: %+v", callback) + } +} + +func TestConnectChannelCallbacksFailedState(t *testing.T) { + var callback ConnectionStateCallback + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&callback); err != nil { + t.Fatalf("decode callback: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer api.Close() + + handler := handlerWithDial(api.URL+"/api", func(context.Context, ConnectChannelCommand) error { + return errTestDial + }) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/connections/connect", strings.NewReader(validConnectCommand())) + handler.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String()) + } + if callback.Status != "failed" || callback.CurrentConnections != 0 || callback.LastError == "" { + t.Fatalf("unexpected callback state: %+v", callback) + } +} + +func TestConnectChannelRejectsInvalidCommand(t *testing.T) { + handler := handlerWithDial("", func(context.Context, ConnectChannelCommand) error { + return nil + }) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/connections/connect", strings.NewReader(`{"messageType":"SubmitCommand"}`)) + handler.ServeHTTP(resp, req) + + if resp.Code != http.StatusBadRequest { + t.Fatalf("unexpected response status: %d", resp.Code) + } +} + +type testDialError struct{} + +func (testDialError) Error() string { + return "dial failed" +} + +var errTestDial testDialError + +func handlerWithDial(apiBaseURL string, dial DialFunc) http.Handler { + mux := http.NewServeMux() + Register(mux, Server{APIBaseURL: apiBaseURL, Dial: dial}) + return mux +} + +func validConnectCommand() string { + return `{ + "schemaVersion": "v1", + "messageType": "ConnectChannel", + "traceId": "trace-1", + "channelId": "channel-1", + "connectionId": "channel-1:primary", + "reason": "channel_created", + "desiredConnections": 2, + "channel": { + "code": "CMPP-A", + "name": "主通道", + "gatewayHost": "127.0.0.1", + "gatewayPort": 17890, + "account": "sp", + "passwordCipher": "secret", + "srcId": "10690000", + "cmppVersion": "3.0", + "rateLimitPerSecond": 100 + } + }` +} diff --git a/gateway/internal/queue/messages.go b/gateway/internal/queue/messages.go index 7b2ca1a..232af3d 100644 --- a/gateway/internal/queue/messages.go +++ b/gateway/internal/queue/messages.go @@ -7,10 +7,11 @@ const SchemaVersion = "v1" type MessageType string const ( - MessageTypeSubmitCommand MessageType = "SubmitCommand" - MessageTypeSubmitResult MessageType = "SubmitResult" - MessageTypeReceiptEvent MessageType = "ReceiptEvent" - MessageTypeUplinkEvent MessageType = "UplinkEvent" + MessageTypeSubmitCommand MessageType = "SubmitCommand" + MessageTypeSubmitResult MessageType = "SubmitResult" + MessageTypeReceiptEvent MessageType = "ReceiptEvent" + MessageTypeUplinkEvent MessageType = "UplinkEvent" + MessageTypeConnectChannel MessageType = "ConnectChannel" ) type Envelope struct { @@ -88,3 +89,27 @@ type UplinkEvent struct { Content string `json:"content"` ReceivedAt time.Time `json:"receivedAt"` } + +type ConnectChannelCommand struct { + SchemaVersion string `json:"schemaVersion"` + MessageType MessageType `json:"messageType"` + TraceID string `json:"traceId"` + ChannelID string `json:"channelId"` + ConnectionID string `json:"connectionId"` + CreatedAt time.Time `json:"createdAt"` + Reason string `json:"reason"` + DesiredConnections int `json:"desiredConnections"` + Channel ConnectChannelConfig `json:"channel"` +} + +type ConnectChannelConfig struct { + Code string `json:"code"` + Name string `json:"name"` + GatewayHost string `json:"gatewayHost"` + GatewayPort int `json:"gatewayPort"` + Account string `json:"account"` + PasswordCipher string `json:"passwordCipher"` + SrcID string `json:"srcId"` + CMPPVersion string `json:"cmppVersion"` + RateLimitPerSecond int `json:"rateLimitPerSecond"` +} diff --git a/package.json b/package.json index dabc07b..3f13ffd 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "build": "tsc --noEmit && vite build", "build:api": "npm --prefix api run build", "preview": "vite preview --host 0.0.0.0", + "start:local": "powershell -NoProfile -ExecutionPolicy Bypass -File tools/start-local.ps1", + "start:local:minio": "powershell -NoProfile -ExecutionPolicy Bypass -File tools/start-local.ps1 -OnlyMinio", "prisma:generate": "npm --prefix api run prisma:generate", "spike:contracts": "node tools/spike/validate-gateway-queue-contract.mjs", "spike:gateway": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$env:Path='C:\\Program Files\\Go\\bin;'+$env:Path; Push-Location gateway; go test ./...; Pop-Location\"", diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 36600c9..b5612d6 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -39,9 +39,10 @@ export type AdminChannel = { unitPrice: number; status: string; config?: unknown; + connectionStates?: CmppConnectionState[]; }; -export type ChannelLinkLogResponse = { +export type ChannelConnectionLogResponse = { channelId: string; connectionStates: Array>; logs: Array<{ @@ -102,6 +103,11 @@ export type TenantOption = { } | null; }; +export type TenantManagementRow = TenantOption & { + account?: TenantAccount | null; + todaySpendCents: number; +}; + export type CaptchaResponse = { captchaId: string; challenge: string; @@ -388,6 +394,16 @@ export type FileObject = { createdAt: string; }; +export type FileRef = { + fileObjectId: string; + fileName: string; + contentType?: string; +}; + +export function fileDownloadUrl(fileObjectId: string, disposition: 'attachment' | 'inline' = 'attachment') { + return `/api/admin/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`; +} + export type RiskReviewTask = { id: string; tenantId: string; @@ -464,6 +480,7 @@ export type EnterpriseApplication = { export type CmppConnectionState = { id: string; tenantId?: string | null; + applicationId?: string | null; channelId: string; connectionId: string; status: string; @@ -517,8 +534,9 @@ export const adminApi = { login: (body: { login: string; password: string; captchaId: string; captchaText: string }) => request('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }), listTenants: () => request('/admin/tenants'), + listTenantManagementRows: () => request('/admin/tenants/management-list'), getTenant: (id: string) => request(`/admin/tenants/${id}`), - createTenant: (body: { name: string; code: string; status?: string; creditCode?: string; province?: string; city?: string; address?: string; contactName?: string; contactIdCard?: string; contactPhone?: string; contactEmail?: string; photoFileObjectId?: string }) => + createTenant: (body: { name: string; code?: string; status?: string; creditCode?: string; province?: string; city?: string; address?: string; contactName?: string; contactIdCard?: string; contactPhone?: string; contactEmail?: string; photoFileObjectId?: string }) => request('/admin/tenants', { method: 'POST', body: JSON.stringify(body) }), updateTenant: (id: string, body: { name?: string; code?: string; status?: string; creditCode?: string; province?: string; city?: string; address?: string; contactName?: string; contactIdCard?: string; contactPhone?: string; contactEmail?: string; photoFileObjectId?: string }) => request(`/admin/tenants/${id}`, { method: 'PUT', body: JSON.stringify(body) }), @@ -580,7 +598,7 @@ export const adminApi = { method: 'DELETE', body: JSON.stringify({ reason }), }), - listChannelLinkLogs: (id: string) => request(`/admin/channels/${id}/link-logs`), + listChannelConnectionLogs: (id: string) => request(`/admin/channels/${id}/connection-logs`), listTemplateAudits: (query: { keyword?: string; status?: string }) => { const params = new URLSearchParams(); if (query.keyword) params.set('keyword', query.keyword); @@ -630,11 +648,14 @@ export const adminApi = { request('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }), updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; items?: Array> }) => request(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + deleteChannelGroup: (id: string) => + request(`/admin/channel-groups/${id}`, { method: 'DELETE' }), addChannelGroupItem: (body: Record) => request('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }), listChannelRouteRules: () => request('/admin/channel-route-rules'), createChannelRouteRule: (body: { tenantId?: string; applicationId: string; groupId: string; carrier: string; priority?: number; status?: string }) => request('/admin/channel-route-rules', { method: 'POST', body: JSON.stringify(body) }), + listChannelConnections: (id: string) => request(`/admin/channels/${id}/connections`), replaceApplicationRouteRules: (applicationId: string, body: { routes: Array<{ carrier: 'mobile' | 'unicom' | 'telecom'; groupId: string; priority?: number; status?: string }> }) => request(`/admin/enterprise-applications/${applicationId}/route-rules`, { method: 'PUT', body: JSON.stringify(body) }), listChannelReportFields: (channelId?: string) => request(withQuery('/admin/channel-report-fields', { channelId })), diff --git a/src/apps/admin/AdminChannelGroupFormPage.tsx b/src/apps/admin/AdminChannelGroupFormPage.tsx index 2e3fdef..a38f0fc 100644 --- a/src/apps/admin/AdminChannelGroupFormPage.tsx +++ b/src/apps/admin/AdminChannelGroupFormPage.tsx @@ -1,9 +1,8 @@ import { useEffect, useMemo, useState } from 'react'; -import { Info, Plus } from 'lucide-react'; +import { Info, Pencil, Plus, Trash2 } from 'lucide-react'; import { useNavigate, useParams } from 'react-router-dom'; import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi'; -import { Breadcrumb, Button, Input, Modal, Select, Table, Tag } from '@/components/ui'; -import type { TableColumn } from '@/components/ui'; +import { Breadcrumb, Button, Input, Modal, Select, Tag } from '@/components/ui'; type Carrier = 'mobile' | 'unicom' | 'telecom'; type ChannelStatus = 'normal' | 'stopped'; @@ -50,7 +49,7 @@ const carrierLabels: Record = { }; const statusLabels: Record = { - normal: '链接正常', + normal: '通道启用', stopped: '通道停用', }; @@ -75,6 +74,38 @@ function StatusTag({ status }: { status: ChannelStatus }) { return {statusLabels[status]}; } +function RouteCard({ + title, + subtitle, + channel, + status, + onEdit, + onDelete, +}: { + title: string; + subtitle: string; + channel?: AdminChannel; + status: ChannelStatus; + onEdit: () => void; + onDelete: () => void; +}) { + return ( +
+
+ {title} + {subtitle} +
+

{channel?.name ?? '未命名通道'}

+ {channel?.sendRegion ?? '全国'} / {channel?.carrier ?? '未标记'} + +
+ + +
+
+ ); +} + function RouteConfigModal({ channels, carrier, @@ -222,40 +253,6 @@ export function AdminChannelGroupFormPage() { loadData(); }, [groupId]); - const provinceColumns = useMemo>>(() => [ - { key: 'province', title: '省份', width: '120px', render: (record) => {record.province} }, - { key: 'channel', title: '通道', render: (record) => channelById.get(record.channelId)?.name ?? record.channelId }, - { key: 'status', title: '通道状态', width: '160px', render: (record) => }, - { - key: 'actions', - title: '操作', - width: '180px', - render: (record) => ( -
- - -
- ), - }, - ], [channelById]); - - const nationalColumns = useMemo>>(() => [ - { key: 'priority', title: '优先级', width: '120px', render: (record) => {record.priority} }, - { key: 'channel', title: '通道', render: (record) => channelById.get(record.channelId)?.name ?? record.channelId }, - { key: 'status', title: '通道状态', width: '160px', render: (record) => }, - { - key: 'actions', - title: '操作', - width: '180px', - render: (record) => ( -
- - -
- ), - }, - ], [channelById]); - function saveRoute(route: ProvinceRoute | NationalRoute) { if (modal?.type === 'province') { const nextRoute = route as ProvinceRoute; @@ -359,7 +356,20 @@ export function AdminChannelGroupFormPage() {

省网分流配置

- +
+ {provinceRoutes.map((route) => ( + setProvinceRoutes((current) => current.filter((item) => item.id !== route.id))} + onEdit={() => setModal({ type: 'province', mode: 'edit', route })} + status={route.status} + subtitle="省网优先路由" + title={route.province} + /> + ))} + {provinceRoutes.length === 0 ?

暂无省网通道

: null} +
@@ -367,7 +377,20 @@ export function AdminChannelGroupFormPage() {

全国通道配置

-
+
+ {nationalRoutes.map((route) => ( + setNationalRoutes((current) => current.filter((item) => item.id !== route.id))} + onEdit={() => setModal({ type: 'national', mode: 'edit', route })} + status={route.status} + subtitle="全国补发路由" + title={`优先级 ${route.priority}`} + /> + ))} + {nationalRoutes.length === 0 ?

暂无全国通道

: null} +
diff --git a/src/apps/admin/AdminChannelGroupsPage.tsx b/src/apps/admin/AdminChannelGroupsPage.tsx index e03638e..4a035dd 100644 --- a/src/apps/admin/AdminChannelGroupsPage.tsx +++ b/src/apps/admin/AdminChannelGroupsPage.tsx @@ -1,16 +1,11 @@ import { useEffect, useMemo, useState } from 'react'; -import { Layers3, Plus, Search, UsersRound } from 'lucide-react'; +import { Layers3, Pencil, Plus, Search, Trash2, UsersRound } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; import { Breadcrumb, Button, Input, Modal, Pagination } from '@/components/ui'; import { adminApi, type ChannelGroup } from '@/api/adminApi'; type GroupCarrier = 'mobile' | 'unicom' | 'telecom'; -const carrierOptions: Array<{ label: string; value: GroupCarrier }> = [ - { label: '移动', value: 'mobile' }, - { label: '联通', value: 'unicom' }, - { label: '电信', value: 'telecom' }, -]; - const carrierLabels: Record = { mobile: '移动', unicom: '联通', @@ -18,12 +13,10 @@ const carrierLabels: Record = { }; export function AdminChannelGroupsPage() { + const navigate = useNavigate(); const [groupName, setGroupName] = useState(''); const [groups, setGroups] = useState([]); - const [modalOpen, setModalOpen] = useState(false); - const [name, setName] = useState(''); - const [code, setCode] = useState(''); - const [carrier, setCarrier] = useState('mobile'); + const [deleteTarget, setDeleteTarget] = useState(null); const [error, setError] = useState(''); function loadData() { @@ -41,16 +34,14 @@ export function AdminChannelGroupsPage() { const filteredGroups = useMemo(() => groups.filter((group) => !groupName.trim() || group.name.includes(groupName.trim())), [groupName, groups]); - function createGroup() { - adminApi.createChannelGroup({ code, name, carrier, status: 'active' }) + function deleteGroup() { + if (!deleteTarget) return; + adminApi.deleteChannelGroup(deleteTarget.id) .then(() => { - setModalOpen(false); - setName(''); - setCode(''); - setCarrier('mobile'); + setDeleteTarget(null); loadData(); }) - .catch((failure: Error) => setError(failure.message || '通道组创建失败')); + .catch((failure: Error) => setError(failure.message || '通道组删除失败')); } return ( @@ -59,7 +50,7 @@ export function AdminChannelGroupsPage() {
- @@ -92,6 +83,14 @@ export function AdminChannelGroupsPage() { })} {(group.items?.length ?? 0) === 0 ?

暂无绑定通道

: null} +
+ + +
))} @@ -101,26 +100,17 @@ export function AdminChannelGroupsPage() { - - + + )} - onClose={() => setModalOpen(false)} - open={modalOpen} - title="添加通道组" + onClose={() => setDeleteTarget(null)} + open={Boolean(deleteTarget)} + title="删除通道组" > -
- setCode(event.target.value)} value={code} /> - setName(event.target.value)} value={name} /> -
- 运营商 - {carrierOptions.map((item) => ( - - ))} -
+
+ {deleteTarget?.name} +

删除前会校验真实路由绑定;已被企业应用使用的通道组不会被删除。

diff --git a/src/apps/admin/AdminChannelsPage.tsx b/src/apps/admin/AdminChannelsPage.tsx index 30db040..2f567f2 100644 --- a/src/apps/admin/AdminChannelsPage.tsx +++ b/src/apps/admin/AdminChannelsPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Copy, Eye, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { adminApi, type AdminChannel, type ChannelLinkLogResponse } from '@/api/adminApi'; +import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type CmppConnectionState } from '@/api/adminApi'; import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui'; type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all'; @@ -41,7 +41,7 @@ type ChannelConfirmAction = { type ChannelLogState = { channel: SmsChannel; - data?: ChannelLinkLogResponse; + data?: ChannelConnectionLogResponse; }; const carrierOptions = [ @@ -54,10 +54,10 @@ const carrierOptions = [ const statusOptions = [ { label: '全部状态', value: 'all' }, - { label: '链接正常', value: 'normal' }, + { label: '连接正常', value: 'normal' }, { label: '已停用', value: 'stopped' }, - { label: '链接中', value: 'connecting' }, - { label: '链接失败', value: 'failed' }, + { label: '连接中', value: 'connecting' }, + { label: '连接失败', value: 'failed' }, ]; const protocolOptions = [ @@ -93,10 +93,10 @@ const carrierToneMap: Record }; const statusLabelMap: Record = { - normal: '链接正常', + normal: '连接正常', stopped: '已停用', - connecting: '链接中', - failed: '链接失败', + connecting: '连接中', + failed: '连接失败', }; const statusToneMap: Record = { @@ -106,21 +106,31 @@ const statusToneMap: Record = { - active: 'normal', - disabled: 'stopped', - deleted: 'stopped', - connecting: 'connecting', - failed: 'failed', - }; +function resolveChannelStatus(channel: AdminChannel, connections: CmppConnectionState[] = []): ChannelStatus { + if (channel.status !== 'active') { + return 'stopped'; + } + if (connections.some((connection) => + connection.status === 'connected' + && connection.currentConnections > 0 + && connection.desiredConnections > 0, + )) { + return 'normal'; + } + if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(connection.status) || connection.lastError)) { + return 'failed'; + } + return 'connecting'; +} + +function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[] = channel.connectionStates ?? []): SmsChannel { return { id: channel.id, name: channel.name, carrier: channel.carrier === 'unicom' || channel.carrier === 'telecom' || channel.carrier === 'all' ? channel.carrier : 'mobile', sendRegion: channel.sendRegion ?? '全国', unitPrice: channel.unitPrice, - status: statusMap[channel.status] ?? 'normal', + status: resolveChannelStatus(channel, connections), total: 0, successRate: 0, successCount: 0, @@ -353,13 +363,21 @@ export function AdminChannelsPage() { const [confirmAction, setConfirmAction] = useState(null); const [logState, setLogState] = useState(null); - useEffect(() => { + function loadChannels() { adminApi.listChannels() - .then((items) => { - setChannels(items.filter((item) => item.status !== 'deleted').map(mapApiChannel)); + .then(async (items) => { + const visibleChannels = items.filter((item) => item.status !== 'deleted'); + const connections = await Promise.all(visibleChannels.map((channel) => + adminApi.listChannelConnections(channel.id).catch(() => [] as CmppConnectionState[]), + )); + setChannels(visibleChannels.map((item, index) => mapApiChannel(item, connections[index]))); setError(''); }) .catch((failure: Error) => setError(failure.message || '通道列表加载失败')); + } + + useEffect(() => { + loadChannels(); }, []); const filteredChannels = useMemo( @@ -375,16 +393,15 @@ export function AdminChannelsPage() { async function upsertChannel(nextChannel: SmsChannel) { try { if (modal?.mode === 'edit' && modal.channel) { - const updated = await adminApi.updateChannel(modal.channel.id, buildChannelPayload(nextChannel, nextChannel.passwordCipher)); - setChannels((items) => items.map((item) => (item.id === updated.id ? mapApiChannel(updated) : item))); + await adminApi.updateChannel(modal.channel.id, buildChannelPayload(nextChannel, nextChannel.passwordCipher)); } else { - const created = await adminApi.createChannel({ + await adminApi.createChannel({ code: `CH-${Date.now()}`, ...buildChannelPayload(nextChannel, nextChannel.passwordCipher || 'secret'), status: 'active', }); - setChannels((items) => [mapApiChannel(created), ...items]); } + loadChannels(); setModal(null); setError(''); } catch (failure) { @@ -393,8 +410,8 @@ export function AdminChannelsPage() { } async function toggleChannel(channel: SmsChannel) { - const updated = await adminApi.changeChannelStatus(channel.id, mapUiStatusToApi(channel)); - setChannels((items) => items.map((item) => (item.id === channel.id ? mapApiChannel(updated) : item))); + await adminApi.changeChannelStatus(channel.id, mapUiStatusToApi(channel)); + loadChannels(); } async function deleteChannel(id: string) { @@ -403,13 +420,13 @@ export function AdminChannelsPage() { } async function copyChannel(channel: SmsChannel) { - const copied = await adminApi.copyChannel(channel.id); - setChannels((items) => [mapApiChannel(copied), ...items]); + await adminApi.copyChannel(channel.id); + loadChannels(); } async function openLinkLogs(channel: SmsChannel) { setLogState({ channel }); - const data = await adminApi.listChannelLinkLogs(channel.id); + const data = await adminApi.listChannelConnectionLogs(channel.id); setLogState({ channel, data }); } @@ -446,7 +463,7 @@ export function AdminChannelsPage() { : confirmAction?.type === 'copy' ? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。' : confirmAction?.channel.status === 'stopped' - ? '启用后通道会进入链接中状态,后续可继续观察网关连接。' + ? '启用后通道会进入连接中状态,后续可继续观察网关连接。' : '停用后该通道将不再承接新的发送任务。'; return ( @@ -491,7 +508,7 @@ export function AdminChannelsPage() {
{statusLabelMap[channel.status]}
{channel.total.toLocaleString('zh-CN')} @@ -564,7 +581,7 @@ export function AdminChannelsPage() { onClose={() => setLogState(null)} open size="xl" - title={

链接日志

{logState.channel.name}

} + title={

连接日志

{logState.channel.name}

} >
{(logState.data?.logs ?? []).map((log) => ( @@ -579,8 +596,8 @@ export function AdminChannelsPage() {
))} - {logState.data && logState.data.logs.length === 0 ?

暂无链接日志

: null} - {!logState.data ?

正在加载链接日志...

: null} + {logState.data && logState.data.logs.length === 0 ?

暂无连接日志

: null} + {!logState.data ?

正在加载连接日志...

: null}
) : null} diff --git a/src/apps/admin/AdminCustomerFormPage.tsx b/src/apps/admin/AdminCustomerFormPage.tsx index 7b3c7c9..4b7c1c8 100644 --- a/src/apps/admin/AdminCustomerFormPage.tsx +++ b/src/apps/admin/AdminCustomerFormPage.tsx @@ -1,13 +1,11 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { ImagePlus } from 'lucide-react'; -import { adminApi, type TenantOption } from '@/api/adminApi'; -import { Breadcrumb, Button, Input, Select, Textarea } from '@/components/ui'; +import { adminApi, type FileRef, type TenantOption } from '@/api/adminApi'; +import { Breadcrumb, Button, FileActions, Input, Select, Textarea } from '@/components/ui'; type EnterpriseForm = { name: string; - code: string; - status: string; creditCode: string; province: string; city: string; @@ -18,6 +16,7 @@ type EnterpriseForm = { contactEmail: string; photoFileObjectId: string; photoFileName: string; + photoContentType: string; }; type EnterpriseFormErrors = Partial>; @@ -44,8 +43,6 @@ const cityOptionsByProvince: Record { - setForm((current) => ({ ...current, photoFileObjectId: fileObject.id, photoFileName: fileObject.fileName })); + setForm((current) => ({ + ...current, + photoContentType: fileObject.contentType, + photoFileObjectId: fileObject.id, + photoFileName: fileObject.fileName, + })); setError(''); }) .catch((failure: Error) => setError(failure.message || '企业照片上传失败')) .finally(() => setUploadingPhoto(false)); } + const photoFile: FileRef | null = form.photoFileObjectId + ? { contentType: form.photoContentType, fileName: form.photoFileName || '企业照片', fileObjectId: form.photoFileObjectId } + : null; + return (
@@ -182,22 +187,22 @@ export function AdminCustomerFormPage() { type="file" /> +

{form.photoFileObjectId ? `文件对象:${form.photoFileObjectId}` : '支持 JPG、PNG、WebP,上传后随企业档案保存。'}

updateForm('name', event.target.value)} placeholder="请填写企业全称" required value={form.name} /> - updateForm('code', event.target.value)} placeholder="请填写唯一企业编码" required value={form.code} /> + updateForm('creditCode', event.target.value)} + placeholder="请填写统一社会信用代码或纳税识别号" + required + value={form.creditCode} + />
- updateForm('creditCode', event.target.value)} - placeholder="请填写统一社会信用代码或纳税识别号" - required - value={form.creditCode} - />
updateForm('contactPhone', event.target.value)} placeholder="请填写企业联系人手机号" required value={form.contactPhone} /> updateForm('contactEmail', event.target.value)} placeholder="请填写企业联系人邮箱" type="email" value={form.contactEmail} />
- - setQueryId(event.target.value)} placeholder="请输入企业ID或编码" value={queryId} /> + setQueryId(event.target.value)} placeholder="请输入企业ID" value={queryId} /> setQueryName(event.target.value)} placeholder="请输入企业名称" value={queryName} /> + + updateRechargeForm('amount', event.target.value)} prefix="¥" required type="number" value={rechargeForm.amount} /> + updateRechargeForm('operator', event.target.value)} value={rechargeForm.operator} /> +