fix: harden real backend workflows and channel connections

This commit is contained in:
hectorzhao
2026-07-06 17:54:53 +08:00
parent 8cca361441
commit b5132d7f4e
47 changed files with 2530 additions and 314 deletions
+1
View File
@@ -10,6 +10,7 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
logs/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
@@ -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");
+5 -1
View File
@@ -352,6 +352,7 @@ model SmsApplication {
sendTasks SmsSendTask[] sendTasks SmsSendTask[]
batchTasks SmsBatchTask[] batchTasks SmsBatchTask[]
messageRecords SmsMessageRecord[] messageRecords SmsMessageRecord[]
connectionStates CmppConnectionState[]
@@index([tenantId, status]) @@index([tenantId, status])
} }
@@ -502,6 +503,7 @@ model SmsChannel {
model CmppConnectionState { model CmppConnectionState {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String? tenantId String?
applicationId String?
channelId String channelId String
connectionId String connectionId String
status String @default("disconnected") status String @default("disconnected")
@@ -516,10 +518,12 @@ model CmppConnectionState {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
tenant Tenant? @relation(fields: [tenantId], references: [id]) tenant Tenant? @relation(fields: [tenantId], references: [id])
application SmsApplication? @relation(fields: [applicationId], references: [id])
channel SmsChannel @relation(fields: [channelId], references: [id]) channel SmsChannel @relation(fields: [channelId], references: [id])
@@unique([channelId, connectionId]) @@unique([applicationId, channelId, connectionId])
@@index([tenantId, status]) @@index([tenantId, status])
@@index([applicationId, status])
@@index([channelId, status]) @@index([channelId, status])
} }
+12 -2
View File
@@ -64,8 +64,13 @@ export class ChannelsController {
} }
@Get('channels/:id/link-logs') @Get('channels/:id/link-logs')
listChannelLinkLogs(@Param('id') channelId: string) { listLegacyChannelConnectionLogs(@Param('id') channelId: string) {
return this.channels.listChannelLinkLogs(channelId); return this.channels.listChannelConnectionLogs(channelId);
}
@Get('channels/:id/connection-logs')
listChannelConnectionLogs(@Param('id') channelId: string) {
return this.channels.listChannelConnectionLogs(channelId);
} }
@Get('channels/:id/connections') @Get('channels/:id/connections')
@@ -98,6 +103,11 @@ export class ChannelsController {
return this.channels.updateGroup(groupId, body); return this.channels.updateGroup(groupId, body);
} }
@Delete('channel-groups/:id')
deleteGroup(@Param('id') groupId: string) {
return this.channels.deleteGroup(groupId);
}
@Post('channel-groups/items') @Post('channel-groups/items')
addGroupItem(@Body() body: CreateChannelGroupItemDto) { addGroupItem(@Body() body: CreateChannelGroupItemDto) {
return this.channels.addGroupItem(body); return this.channels.addGroupItem(body);
+170 -7
View File
@@ -1,5 +1,20 @@
import { ChannelsService } from './channels.service'; 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() { function createPrismaMock() {
const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'pending' }; const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'pending' };
const channel = { const channel = {
@@ -54,6 +69,7 @@ function createPrismaMock() {
findMany: jest.fn(), findMany: jest.fn(),
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72 }), 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 })), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }),
}, },
smsChannelGroupItem: { smsChannelGroupItem: {
deleteMany: jest.fn(), deleteMany: jest.fn(),
@@ -63,6 +79,7 @@ function createPrismaMock() {
}, },
channelRouteRule: { channelRouteRule: {
findMany: jest.fn(), findMany: jest.fn(),
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'route-1', ...data })), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'route-1', ...data })),
}, },
channelReportField: { channelReportField: {
@@ -93,9 +110,15 @@ function createPrismaMock() {
smsSignature: { smsSignature: {
update: jest.fn(), update: jest.fn(),
}, },
smsApplication: {
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
},
cmppConnectionState: { cmppConnectionState: {
findMany: jest.fn(), 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: { operationLog: {
create: jest.fn(), create: jest.fn(),
@@ -105,11 +128,18 @@ function createPrismaMock() {
} }
describe('ChannelsService', () => { 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 () => { it('rejects incomplete channel creation input with readable 400 errors', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never); 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(); expect(prisma.smsChannel.create).not.toHaveBeenCalled();
}); });
@@ -138,6 +168,25 @@ describe('ChannelsService', () => {
status: 'active', 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({ expect(prisma.smsChannelGroup.create).toHaveBeenCalledWith({
data: expect.objectContaining({ carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 }), data: expect.objectContaining({ carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 }),
}); });
@@ -309,6 +358,18 @@ describe('ChannelsService', () => {
expect(prisma.channelRouteRule.create).not.toHaveBeenCalled(); 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 () => { it('upserts signature report material per channel field', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never); const service = new ChannelsService(prisma as never);
@@ -405,6 +466,24 @@ describe('ChannelsService', () => {
resourceId: 'channel-1', 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 () => { it('copies channels with report field configuration and report materials', async () => {
@@ -432,6 +511,7 @@ describe('ChannelsService', () => {
await service.upsertConnectionState({ await service.upsertConnectionState({
tenantId: 'tenant-1', tenantId: 'tenant-1',
applicationId: 'app-1',
channelId: 'channel-1', channelId: 'channel-1',
connectionId: 'conn-a', connectionId: 'conn-a',
status: 'online', status: 'online',
@@ -440,12 +520,13 @@ describe('ChannelsService', () => {
}); });
await service.listChannelConnections('channel-1'); await service.listChannelConnections('channel-1');
await service.listTenantConnections('tenant-1'); await service.listTenantConnections('tenant-1');
await service.listChannelLinkLogs('channel-1'); await service.listChannelConnectionLogs('channel-1');
expect(prisma.cmppConnectionState.upsert).toHaveBeenCalledWith({ expect(prisma.cmppConnectionState.findFirst).toHaveBeenCalledWith({
where: { channelId_connectionId: { channelId: 'channel-1', connectionId: 'conn-a' } }, where: { applicationId: 'app-1', 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.create).toHaveBeenCalledWith({
data: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', status: 'connected' }),
}); });
expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({ expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({
where: { channelId: 'channel-1' }, where: { channelId: 'channel-1' },
@@ -467,4 +548,86 @@ describe('ChannelsService', () => {
}); });
expect(prisma.operationLog.findMany).toHaveBeenCalled(); 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' }),
});
});
}); });
+325 -15
View File
@@ -1,5 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Queue } from 'bullmq';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
export interface CreateChannelDto { export interface CreateChannelDto {
@@ -113,6 +115,7 @@ export interface CreateReceiptImportDto {
export interface UpsertConnectionStateDto { export interface UpsertConnectionStateDto {
tenantId?: string; tenantId?: string;
applicationId?: string;
channelId: string; channelId: string;
connectionId: string; connectionId: string;
status: string; status: string;
@@ -137,15 +140,45 @@ export interface CopyChannelDto {
operatorId?: string; 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() @Injectable()
export class ChannelsService { export class ChannelsService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(ChannelsService.name);
private gatewayConnectionQueue?: Queue;
private connectionTimeoutTimer?: ReturnType<typeof setInterval>;
constructor(private readonly prisma: PrismaService) {} 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() { listChannels() {
return this.prisma.smsChannel.findMany({ orderBy: { createdAt: 'desc' }, take: 100 }); 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 missingFields = ['code', 'name', 'gatewayHost', 'gatewayPort', 'account', 'passwordCipher', 'srcId'].filter((field) => {
const value = data[field as keyof CreateChannelDto]; const value = data[field as keyof CreateChannelDto];
return value === undefined || value === null || value === ''; return value === undefined || value === null || value === '';
@@ -157,7 +190,7 @@ export class ChannelsService {
if (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535) { if (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535) {
throw new BadRequestException('gatewayPort must be an integer between 1 and 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: { data: {
code: data.code, code: data.code,
name: data.name, name: data.name,
@@ -177,6 +210,10 @@ export class ChannelsService {
config: data.config as Prisma.InputJsonValue | undefined, 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) { async updateChannel(channelId: string, data: UpdateChannelDto) {
@@ -253,6 +290,9 @@ export class ChannelsService {
} as Prisma.InputJsonValue, } as Prisma.InputJsonValue,
}, },
}); });
if (data.status === 'active') {
await this.requestChannelConnection(updated, 'channel_enabled', data.operatorId);
}
return updated; 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 } }); const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId }, select: { id: true } });
if (!channel) { if (!channel) {
throw new NotFoundException('Channel not found'); throw new NotFoundException('Channel not found');
@@ -412,21 +452,40 @@ export class ChannelsService {
} }
async upsertConnectionState(data: UpsertConnectionStateDto) { 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 = { const payload = {
tenantId: data.tenantId, tenantId: data.tenantId,
status: data.status, applicationId: data.applicationId,
status,
desiredConnections: data.desiredConnections ?? 1, 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, lastConnectedAt: data.lastConnectedAt ? new Date(data.lastConnectedAt) : undefined,
lastDisconnectedAt: data.lastDisconnectedAt ? new Date(data.lastDisconnectedAt) : undefined, lastDisconnectedAt: data.lastDisconnectedAt ? new Date(data.lastDisconnectedAt) : undefined,
lastHeartbeatAt: data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : undefined, lastHeartbeatAt: data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : undefined,
reconnectCount: data.reconnectCount ?? 0, reconnectCount: data.reconnectCount ?? 0,
lastError: data.lastError, lastError: data.lastError,
}; };
const state = await this.prisma.cmppConnectionState.upsert({ const existing = await this.prisma.cmppConnectionState.findFirst({
where: { channelId_connectionId: { channelId: data.channelId, connectionId: data.connectionId } }, where: {
update: payload, applicationId: data.applicationId ?? null,
create: { channelId: data.channelId,
connectionId: data.connectionId,
},
});
const state = existing
? await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data: payload })
: await this.prisma.cmppConnectionState.create({
data: {
channelId: data.channelId, channelId: data.channelId,
connectionId: data.connectionId, connectionId: data.connectionId,
...payload, ...payload,
@@ -435,11 +494,12 @@ export class ChannelsService {
await this.prisma.operationLog.create({ await this.prisma.operationLog.create({
data: { data: {
tenantId: data.tenantId, tenantId: data.tenantId,
action: `cmpp_connection.${normalizeConnectionAction(data.status)}`, action: `cmpp_connection.${normalizeConnectionAction(status)}`,
resource: 'cmpp_connection', resource: 'cmpp_connection',
resourceId: `${data.channelId}:${data.connectionId}`, resourceId: `${data.channelId}:${data.connectionId}`,
detail: { detail: {
status: data.status, status,
applicationId: state.applicationId,
desiredConnections: state.desiredConnections, desiredConnections: state.desiredConnections,
currentConnections: state.currentConnections, currentConnections: state.currentConnections,
lastError: state.lastError, lastError: state.lastError,
@@ -449,6 +509,69 @@ export class ChannelsService {
return state; 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() { listGroups() {
return this.prisma.smsChannelGroup.findMany({ return this.prisma.smsChannelGroup.findMany({
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } }, 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() { listRouteRules() {
return this.prisma.channelRouteRule.findMany({ return this.prisma.channelRouteRule.findMany({
include: { group: true, channel: true }, include: { group: true, channel: true },
@@ -796,11 +938,116 @@ export class ChannelsService {
}, },
}); });
} }
private async requestChannelConnection(
channel: {
id: string;
code: string;
name: string;
gatewayHost: string;
gatewayPort: number;
account: string;
passwordCipher: string;
srcId: string;
cmppVersion: string;
rateLimitPerSecond: number;
config?: Prisma.JsonValue | null;
},
reason: 'channel_created' | 'channel_enabled',
operatorId?: string,
) {
const desiredConnections = getDesiredConnections(channel.config);
const connectionId = defaultChannelConnectionId(channel.id);
const existing = await this.prisma.cmppConnectionState.findFirst({
where: {
applicationId: null,
channelId: channel.id,
connectionId,
},
});
const data = {
applicationId: null,
status: 'connecting',
desiredConnections,
currentConnections: 0,
lastError: null,
};
const state = existing
? await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data })
: await this.prisma.cmppConnectionState.create({
data: {
channelId: channel.id,
connectionId,
...data,
},
});
await this.prisma.operationLog.create({
data: {
userId: operatorId,
action: 'cmpp_connection.connect_requested',
resource: 'cmpp_connection',
resourceId: `${channel.id}:${connectionId}`,
detail: {
reason,
status: state.status,
desiredConnections: state.desiredConnections,
currentConnections: state.currentConnections,
} as Prisma.InputJsonValue,
},
});
const command = {
schemaVersion: 'v1',
messageType: 'ConnectChannel',
traceId: randomUUID(),
channelId: channel.id,
connectionId,
createdAt: new Date().toISOString(),
reason,
desiredConnections,
channel: {
code: channel.code,
name: channel.name,
gatewayHost: channel.gatewayHost,
gatewayPort: channel.gatewayPort,
account: channel.account,
passwordCipher: channel.passwordCipher,
srcId: channel.srcId,
cmppVersion: channel.cmppVersion,
rateLimitPerSecond: channel.rateLimitPerSecond,
},
};
await this.getGatewayConnectionQueue().add('connect-channel', command, { jobId: `${connectionId}:connect` });
await this.notifyGatewayConnect(command);
return state;
}
private getGatewayConnectionQueue() {
this.gatewayConnectionQueue ??= new Queue(GATEWAY_CONNECTION_QUEUE, { connection: bullmqConnection() });
return this.gatewayConnectionQueue;
}
private async notifyGatewayConnect(command: Record<string, unknown>) {
const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? DEFAULT_GATEWAY_CONTROL_URL).replace(/\/+$/, '');
let response: { ok: boolean; status: number; text: () => Promise<string> };
try {
response = await fetch(`${baseUrl}/connections/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(command),
});
} catch (error) {
throw new BadRequestException(`Gateway connect request failed: ${error instanceof Error ? error.message : String(error)}`);
}
if (!response.ok) {
const responseText = await response.text();
throw new BadRequestException(`Gateway connect request failed: ${response.status} ${responseText}`);
}
}
} }
function normalizeConnectionAction(status: string) { function normalizeConnectionAction(status: string) {
const normalized = status.toLowerCase(); const normalized = status.toLowerCase();
if (['online', 'connected', 'open'].includes(normalized)) { if (normalized === 'connected') {
return 'connected'; return 'connected';
} }
if (['heartbeat', 'active_test'].includes(normalized)) { if (['heartbeat', 'active_test'].includes(normalized)) {
@@ -812,9 +1059,66 @@ function normalizeConnectionAction(status: string) {
if (['offline', 'closed', 'disconnected'].includes(normalized)) { if (['offline', 'closed', 'disconnected'].includes(normalized)) {
return 'disconnected'; return 'disconnected';
} }
if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) {
return 'failed';
}
return 'updated'; 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') { function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean); const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
if (lines.length === 0) { if (lines.length === 0) {
@@ -990,8 +1294,11 @@ function validateGroupItems(
} }
function normalizeLinkEvent(action: string) { function normalizeLinkEvent(action: string) {
if (action.includes('connect_requested')) {
return '连接请求';
}
if (action.includes('connected')) { if (action.includes('connected')) {
return '新建'; return '连接成功';
} }
if (action.includes('heartbeat')) { if (action.includes('heartbeat')) {
return '心跳'; return '心跳';
@@ -1002,6 +1309,9 @@ function normalizeLinkEvent(action: string) {
if (action.includes('disconnected')) { if (action.includes('disconnected')) {
return '断开'; return '断开';
} }
if (action.includes('failed')) {
return '连接失败';
}
if (action.includes('copy')) { if (action.includes('copy')) {
return '复制'; return '复制';
} }
+20 -1
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Post, UploadedFile, UseInterceptors } from '@nestjs/common'; import { BadRequestException, Body, Controller, Get, Param, Post, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator'; import { TenantId } from '../common/tenant-id.decorator';
@@ -11,6 +11,11 @@ type UploadedMultipartFile = {
buffer: Buffer; buffer: Buffer;
}; };
type DownloadResponse = {
setHeader(name: string, value: number | string): void;
send(content: Buffer): void;
};
@ApiTags('files') @ApiTags('files')
@Controller('admin/files') @Controller('admin/files')
export class FilesController { export class FilesController {
@@ -21,6 +26,17 @@ export class FilesController {
return this.files.list(tenantId); 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() @Post()
create(@Body() body: CreateFileObjectDto) { create(@Body() body: CreateFileObjectDto) {
return this.files.create(body); return this.files.create(body);
@@ -34,6 +50,9 @@ export class FilesController {
@Post('upload') @Post('upload')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 20 * 1024 * 1024 } })) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 20 * 1024 * 1024 } }))
upload(@UploadedFile() file: UploadedMultipartFile, @Body('purpose') purpose: string, @Body('prefix') prefix?: string, @TenantId() tenantId?: string) { 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); return this.files.upload({ tenantId, purpose: purpose || 'general', prefix }, file);
} }
} }
+36
View File
@@ -27,6 +27,7 @@ describe('FilesService', () => {
bucket: 'cmpp-platform', bucket: 'cmpp-platform',
fileName: '营业执照.png', fileName: '营业执照.png',
contentType: 'image/png', contentType: 'image/png',
sizeBytes: '12',
purpose: 'signature_material', purpose: 'signature_material',
})); }));
@@ -47,4 +48,39 @@ describe('FilesService', () => {
}), }),
}); });
}); });
it('downloads file content from object storage by FileObject id', async () => {
const fileObject = {
id: 'file-1',
tenantId: 'tenant-1',
bucket: 'cmpp-platform',
objectKey: 'signature-materials/sig-1/file.png',
fileName: 'file.png',
contentType: 'image/png',
sizeBytes: BigInt(12),
checksum: null,
purpose: 'signature_material',
createdAt: new Date('2026-07-06T00:00:00.000Z'),
};
const prisma = {
fileObject: {
findUnique: jest.fn().mockResolvedValue(fileObject),
},
};
const objectStorage = {
getObject: jest.fn().mockResolvedValue(Buffer.from('file-content')),
};
const service = new FilesService(prisma as never, objectStorage as never);
await expect(service.getDownload('file-1')).resolves.toEqual({
fileObject: expect.objectContaining({
id: 'file-1',
fileName: 'file.png',
sizeBytes: '12',
}),
content: Buffer.from('file-content'),
});
expect(prisma.fileObject.findUnique).toHaveBeenCalledWith({ where: { id: 'file-1' } });
expect(objectStorage.getObject).toHaveBeenCalledWith('signature-materials/sig-1/file.png');
});
}); });
+22 -3
View File
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -38,7 +38,7 @@ export class FilesService {
where: tenantId ? { tenantId } : undefined, where: tenantId ? { tenantId } : undefined,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 100, take: 100,
}); }).then((items) => items.map(serializeFileObject));
} }
create(data: CreateFileObjectDto) { create(data: CreateFileObjectDto) {
@@ -52,7 +52,7 @@ export class FilesService {
checksum: data.checksum, checksum: data.checksum,
purpose: data.purpose, purpose: data.purpose,
}; };
return this.prisma.fileObject.create({ data: createData }); return this.prisma.fileObject.create({ data: createData }).then(serializeFileObject);
} }
async createPresignedUpload(data: CreatePresignedUploadDto) { async createPresignedUpload(data: CreatePresignedUploadDto) {
@@ -79,4 +79,23 @@ export class FilesService {
purpose: data.purpose, purpose: data.purpose,
}); });
} }
async getDownload(id: string) {
const fileObject = await this.prisma.fileObject.findUnique({ where: { id } });
if (!fileObject) {
throw new NotFoundException('File object not found');
}
const content = await this.objectStorage.getObject(fileObject.objectKey);
return {
fileObject: serializeFileObject(fileObject),
content,
};
}
}
function serializeFileObject<T extends { sizeBytes: bigint | number | string }>(fileObject: T) {
return {
...fileObject,
sizeBytes: fileObject.sizeBytes.toString(),
};
} }
+44 -2
View File
@@ -1,16 +1,23 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Client } from 'minio'; import { Client } from 'minio';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
@Injectable() @Injectable()
export class ObjectStorageService { export class ObjectStorageService {
private readonly client: Client; private readonly client: Client;
private readonly bucket: string; private readonly bucket: string;
private readonly driver: string;
private readonly localRoot: string;
private bucketReady = false;
constructor(config: ConfigService) { constructor(config: ConfigService) {
const endpoint = config.get<string>('MINIO_ENDPOINT') ?? 'localhost:9000'; const endpoint = config.get<string>('MINIO_ENDPOINT') ?? 'localhost:9000';
const [endPoint, portText] = endpoint.split(':'); const [endPoint, portText] = endpoint.split(':');
this.bucket = config.get<string>('MINIO_BUCKET') ?? 'cmpp-platform'; this.bucket = config.get<string>('MINIO_BUCKET') ?? 'cmpp-platform';
this.driver = config.get<string>('OBJECT_STORAGE_DRIVER') ?? 'minio';
this.localRoot = config.get<string>('OBJECT_STORAGE_LOCAL_ROOT') ?? join(process.cwd(), '..', '.local-data', 'object-storage');
this.client = new Client({ this.client = new Client({
endPoint, endPoint,
port: Number(portText ?? 9000), 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); 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, { return this.client.putObject(this.bucket, objectKey, content, sizeBytes, {
'Content-Type': contentType, '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() { getBucket() {
return this.bucket; return this.bucket;
} }
private async ensureBucket() {
if (this.bucketReady) {
return;
}
const exists = await this.client.bucketExists(this.bucket);
if (!exists) {
await this.client.makeBucket(this.bucket, '');
}
this.bucketReady = true;
}
} }
@@ -41,7 +41,7 @@ function createPrismaMock() {
count: jest.fn().mockResolvedValue(1), count: jest.fn().mockResolvedValue(1),
}, },
cmppConnectionState: { 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: { operationLog: {
findMany: jest.fn().mockResolvedValue([{ findMany: jest.fn().mockResolvedValue([{
@@ -115,7 +115,7 @@ describe('OperationsService', () => {
taskCount: 3, taskCount: 3,
uplinkCount: 1, uplinkCount: 1,
pendingAuditCount: 6, 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' }); await service.statistics({ tenantId: 'tenant-1', groupBy: 'application' });
@@ -33,7 +33,7 @@ function createPrismaMock() {
carrier: 'mobile', carrier: 'mobile',
sendRegion: '全国', sendRegion: '全国',
config: { serviceId: 'SMS' }, config: { serviceId: 'SMS' },
connectionStates: [{ status: 'online', currentConnections: 1, desiredConnections: 1 }], connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
}; };
const route = { const route = {
id: 'route-1', id: 'route-1',
@@ -485,7 +485,7 @@ describe('SendChainService', () => {
status: 'active', status: 'active',
carrier: 'all', carrier: 'all',
sendRegion: '全国', sendRegion: '全国',
connectionStates: [{ status: 'online', currentConnections: 1, desiredConnections: 1 }], connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
}, },
}, },
{ {
@@ -505,7 +505,7 @@ describe('SendChainService', () => {
status: 'active', status: 'active',
carrier: 'all', carrier: 'all',
sendRegion: '全国', sendRegion: '全国',
connectionStates: [{ status: 'online', currentConnections: 1, desiredConnections: 1 }], connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
}, },
}, },
], ],
+1 -1
View File
@@ -863,7 +863,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return false; return false;
} }
return (channel.connectionStates ?? []).some((connection) => return (channel.connectionStates ?? []).some((connection) =>
connection.desiredConnections > 0 && connection.currentConnections > 0 && ['online', 'connected'].includes(connection.status), connection.desiredConnections > 0 && connection.currentConnections > 0 && connection.status === 'connected',
); );
} }
@@ -81,8 +81,8 @@ function createPrismaMock() {
findUnique: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue(null),
}, },
cmppConnectionState: { cmppConnectionState: {
findMany: jest.fn().mockResolvedValue([{ channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1', status: 'online', currentConnections: 1, desiredConnections: 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({ channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-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 })), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-state-1', ...data })),
}, },
smsChannel: { smsChannel: {
@@ -256,7 +256,7 @@ describe('SmsConfigService', () => {
await service.disconnectApplicationConnection('app-1', 'conn-a', { status: 'disconnected', reason: 'manual' }); await service.disconnectApplicationConnection('app-1', 'conn-a', { status: 'disconnected', reason: 'manual' });
expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({ 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' }), data: expect.objectContaining({ status: 'disconnected', currentConnections: 0, lastError: 'manual' }),
}); });
expect(prisma.operationLog.create).toHaveBeenCalledWith({ expect(prisma.operationLog.create).toHaveBeenCalledWith({
+7 -7
View File
@@ -110,15 +110,15 @@ export class SmsConfigService {
if (!query.includeConnections) { if (!query.includeConnections) {
return applications; 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({ const connections = await this.prisma.cmppConnectionState.findMany({
where: { tenantId: { in: tenantIds } }, where: { applicationId: { in: applicationIds } },
include: { channel: true }, include: { channel: true },
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
take: 500, take: 500,
}); });
return applications.map((application) => { 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 todayTotal = application.messageRecords.length;
const delivered = application.messageRecords.filter((message) => message.status === 'delivered').length; const delivered = application.messageRecords.filter((message) => message.status === 'delivered').length;
return { return {
@@ -298,7 +298,7 @@ export class SmsConfigService {
throw new NotFoundException('Application not found'); throw new NotFoundException('Application not found');
} }
const connections = await this.prisma.cmppConnectionState.findMany({ const connections = await this.prisma.cmppConnectionState.findMany({
where: { tenantId: application.tenantId }, where: { applicationId },
include: { channel: true }, include: { channel: true },
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
take: 100, take: 100,
@@ -351,13 +351,13 @@ export class SmsConfigService {
throw new NotFoundException('Application not found'); throw new NotFoundException('Application not found');
} }
const connection = await this.prisma.cmppConnectionState.findFirst({ const connection = await this.prisma.cmppConnectionState.findFirst({
where: { tenantId: application.tenantId, connectionId }, where: { applicationId, connectionId },
}); });
if (!connection) { if (!connection) {
throw new NotFoundException('Connection not found'); throw new NotFoundException('Connection not found');
} }
const updated = await this.prisma.cmppConnectionState.update({ const updated = await this.prisma.cmppConnectionState.update({
where: { channelId_connectionId: { channelId: connection.channelId, connectionId } }, where: { id: connection.id },
data: { data: {
status: 'disconnected', status: 'disconnected',
currentConnections: 0, currentConnections: 0,
@@ -742,7 +742,7 @@ function normalizeApplicationCmppStatus(connections: Array<{ status: string; cur
if (applicationStatus !== 'active') { if (applicationStatus !== 'active') {
return 'inactive'; 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'; return 'connected';
} }
if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) { if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) {
+5
View File
@@ -12,6 +12,11 @@ export class TenantsController {
return this.tenants.list(); return this.tenants.list();
} }
@Get('management-list')
listManagementRows() {
return this.tenants.listManagementRows();
}
@Get(':id') @Get(':id')
get(@Param('id') id: string) { get(@Param('id') id: string) {
return this.tenants.get(id); return this.tenants.get(id);
+27
View File
@@ -22,6 +22,12 @@ function createPrismaMock() {
create: jest.fn().mockResolvedValue({ id: 'cert-1' }), create: jest.fn().mockResolvedValue({ id: 'cert-1' }),
update: 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); await expect(service.delete('missing')).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.tenant.update).not.toHaveBeenCalled(); expect(prisma.tenant.update).not.toHaveBeenCalled();
}); });
it('lists management rows with real account and today spend fields', async () => {
const prisma = createPrismaMock();
const service = new TenantsService(prisma as never);
await expect(service.listManagementRows()).resolves.toEqual([
expect.objectContaining({
id: 'tenant-1',
name: '测试企业',
account: expect.objectContaining({ balanceCents: 12000, creditCents: 5000 }),
todaySpendCents: 350,
}),
]);
expect(prisma.tenant.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { status: { not: 'deleted' } },
}));
expect(prisma.smsMessageRecord.groupBy).toHaveBeenCalledWith(expect.objectContaining({
by: ['tenantId'],
_sum: { amountCents: true },
}));
});
}); });
+41 -2
View File
@@ -4,7 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
export interface CreateTenantDto { export interface CreateTenantDto {
name: string; name: string;
code: string; code?: string;
status?: string; status?: string;
creditCode?: string; creditCode?: string;
province?: string; province?: string;
@@ -44,6 +44,31 @@ export class TenantsService {
}).then((items) => items.map(withEnterpriseProfile)); }).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) { get(id: string) {
return this.prisma.tenant.findUnique({ return this.prisma.tenant.findUnique({
where: { id }, where: { id },
@@ -57,8 +82,9 @@ export class TenantsService {
} }
async create(data: CreateTenantDto) { async create(data: CreateTenantDto) {
const code = data.code?.trim() || generateTenantCode(data);
const tenant = await this.prisma.tenant.create({ 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); await this.upsertProfile(tenant.id, data);
return this.get(tenant.id); return this.get(tenant.id);
@@ -163,3 +189,16 @@ function withEnterpriseProfile<T extends { enterpriseCertifications?: Array<{ li
} : null, } : null,
}; };
} }
function startOfToday() {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}
function generateTenantCode(data: CreateTenantDto) {
const source = data.creditCode?.trim() || data.name.trim();
const normalized = source.replace(/[^\da-zA-Z]/g, '').toLowerCase();
const suffix = Date.now().toString(36).slice(-6);
return `ent-${(normalized || 'tenant').slice(0, 18)}-${suffix}`;
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -217,7 +217,7 @@
问题: 问题:
- 创建、复制、启停、删除、接日志已经接真实 API。 - 创建、复制、启停、删除、接日志已经接真实 API。
- 旧实现编辑通道时直接提示“短信通道编辑接口待补,当前不做本地模拟保存”。 - 旧实现编辑通道时直接提示“短信通道编辑接口待补,当前不做本地模拟保存”。
影响: 影响:
@@ -149,12 +149,13 @@
14. 通道组明细 `carrier` 必须保留并参与发送逻辑,且必须等于通道组运营商。三网只允许作为通道本体能力 `SmsChannel.carrier=all`,表示该通道可被运营分配到移动、联通或电信通道组;一旦放入某个通道组,只能服务该通道组所属运营商。 14. 通道组明细 `carrier` 必须保留并参与发送逻辑,且必须等于通道组运营商。三网只允许作为通道本体能力 `SmsChannel.carrier=all`,表示该通道可被运营分配到移动、联通或电信通道组;一旦放入某个通道组,只能服务该通道组所属运营商。
15. 通道组明细引用通道时必须校验通道能力:移动组只能引用移动通道或三网通道,联通组只能引用联通通道或三网通道,电信组只能引用电信通道或三网通道。 15. 通道组明细引用通道时必须校验通道能力:移动组只能引用移动通道或三网通道,联通组只能引用联通通道或三网通道,电信组只能引用电信通道或三网通道。
16. 未命中企业应用通道组或无可用通道时不得 fallback 到全局第一个 active 通道,应将该短信标记为 failed,并记录可读失败原因、trace 和系统日志;客户端本期不展示通道细节。 16. 未命中企业应用通道组或无可用通道时不得 fallback 到全局第一个 active 通道,应将该短信标记为 failed,并记录可读失败原因、trace 和系统日志;客户端本期不展示通道细节。
17. 可发送通道必须同时满足:通道业务状态 active、CMPP 连接状态 online、当前连接数大于 0、心跳未失败;auth_failed、heartbeat_timeout、reconnecting、disconnected 或当前连接数为 0 的通道均不可被选中。 17. 可发送通道必须同时满足:通道业务状态 active、CMPP 连接状态 connected、期望连接数大于 0、当前连接数大于 0、心跳未失败;auth_failed、heartbeat_timeout、reconnecting、disconnected、failed 或当前连接数为 0 的通道均不可被选中。Gateway 旧回写词 online/open 仅允许在 API 入库时兼容归一化为 connected,不作为发送判断状态。
18. 多连接通道只要至少 1 条连接 online 且可用即可参与路由;心跳失败应及时更新连接状态,连续 3 次心跳失败后进入重连,重连成功前不可发送。 18. 多连接通道只要至少 1 条连接 connected 且可用即可参与路由;心跳失败应及时更新连接状态,连续 3 次心跳失败后进入重连,重连成功前不可发送。
19. 通道异常时应支持熔断、降级、切换备用通道和失败重试;失败补发必须在同一企业应用授权的通道组范围内执行,并使用触发补发时的当前通道组配置,保证计费、退款、幂等和 trace 可追踪 19. 新建或启用通道后,系统应立即创建/更新默认 CMPP 连接状态为 connecting 并通知 Go Gateway 发起连接;如果 connecting 超过 30 秒仍未收到 Gateway 回写 connected 或 failed,API 后台兜底任务必须将该连接标记为 failed,写入 `lastError=Gateway connection request timed out after 30 seconds` 和连接日志。默认扫描间隔 5 秒,可通过环境变量调整
20. 失败补发除以下情况外均应触发:短信状态为 unknown;距离客户提交时间超过 72 小时;距离客户提交时间超过通道组配置的补发时间上限;通道组关闭失败补发 20. 通道异常时应支持熔断、降级、切换备用通道和失败重试;失败补发必须在同一企业应用授权的通道组范围内执行,并使用触发补发时的当前通道组配置,保证计费、退款、幂等和 trace 可追踪
21. 通道组补发时间上限由运营端配置,最大不得超过 72 小时;本期不配置最大补发次数、补发间隔、失败类型白名单或人工重发能力,进入最终 failed/timeout 后不再人工重发。 21. 失败补发除以下情况外均应触发:短信状态为 unknown;距离客户提交时间超过 72 小时;距离客户提交时间超过通道组配置的补发时间上限;通道组关闭失败补发。
22. 提交 accepted 后立即按企业应用配置的客户费率扣费;补发过程中最终成功只扣一次,submit failed 未真正发出时释放冻结且不扣费;failed receipt 导致最终全失败时退款;本期客户计费不使用通道成本价 22. 通道组补发时间上限由运营端配置,最大不得超过 72 小时;本期不配置最大补发次数、补发间隔、失败类型白名单或人工重发能力,进入最终 failed/timeout 后不再人工重发
23. 提交 accepted 后立即按企业应用配置的客户费率扣费;补发过程中最终成功只扣一次,submit failed 未真正发出时释放冻结且不扣费;failed receipt 导致最终全失败时退款;本期客户计费不使用通道成本价。
### 4.7 通道签名报备 ### 4.7 通道签名报备
@@ -297,7 +298,7 @@
- 支持查看通道成功率、未知率、失败率、累计发送量。 - 支持查看通道成功率、未知率、失败率、累计发送量。
- 支持进入通道报备详情。 - 支持进入通道报备详情。
- 通道报备详情页中,签名下的引流信息默认收起,用户点击后展开;展开/收起只影响页面展示,不改变报备数据。 - 通道报备详情页中,签名下的引流信息默认收起,用户点击后展开;展开/收起只影响页面展示,不改变报备数据。
- 通道列表状态区域展示“接日志”入口;点击后弹窗展示真实连接日志,包括新建连接、断开、心跳、重连、异常等事件,日志来源于 Gateway 回写或 OperationLog。 - 通道列表状态区域展示“接日志”入口;点击后弹窗展示真实连接日志,包括连接请求、连接成功、断开、心跳、重连、异常等事件,日志来源于 Gateway 回写或 OperationLog。
- 通道操作按钮应保持一致的两列布局,报备详情、编辑、复制、发送测试、启停、删除等操作文案清晰。 - 通道操作按钮应保持一致的两列布局,报备详情、编辑、复制、发送测试、启停、删除等操作文案清晰。
### 5.14 运营端通道组管理 ### 5.14 运营端通道组管理
+14 -13
View File
@@ -467,17 +467,17 @@
- 删除连接调用真实后端或 Gateway 接口,连接状态刷新并写系统日志。 - 删除连接调用真实后端或 Gateway 接口,连接状态刷新并写系统日志。
- CMPP 参数来源于真实应用/通道配置,一键复制内容与 API 返回一致。 - CMPP 参数来源于真实应用/通道配置,一键复制内容与 API 返回一致。
### TC-ADMIN-019 通道接日志展示 ### TC-ADMIN-019 通道接日志展示
- 优先级:P1 - 优先级:P1
- 前置条件:Gateway 或测试替身已产生新建连接、心跳、断开、重连、异常事件。 - 前置条件:Gateway 或测试替身已产生连接请求、连接成功、心跳、断开、重连、异常事件。
- 步骤: - 步骤:
1. 打开短信通道管理页面。 1. 打开短信通道管理页面。
2. 在状态区域点击接日志。 2. 在状态区域点击接日志。
3. 查看日志时间、事件类型、连接 id、详情。 3. 查看日志时间、事件类型、连接 id、详情。
- 预期结果: - 预期结果:
- 接日志由真实后端 API 返回,不使用前端硬编码数据。 - 接日志由真实后端 API 返回,不使用前端硬编码数据。
- 日志包含新建连接、断开、心跳、重连、异常等事件。 - 日志包含连接请求、连接成功、断开、心跳、重连、异常等事件。
- 日志按时间倒序展示,并可定位到对应通道或连接。 - 日志按时间倒序展示,并可定位到对应通道或连接。
### TC-ADMIN-020 安全控制搜索、添加、启停与删除 ### TC-ADMIN-020 安全控制搜索、添加、启停与删除
@@ -2585,7 +2585,7 @@ npm run verify:phase8
| TC-ADMIN-016 | 复制通道,随后查询新通道详情、报备字段、签名报备材料。 | 新通道 code/id 唯一;CMPP 参数、限速、报备字段、材料被复制;源通道不受影响;复制日志包含 sourceChannelId 和 newChannelId。 | | TC-ADMIN-016 | 复制通道,随后查询新通道详情、报备字段、签名报备材料。 | 新通道 code/id 唯一;CMPP 参数、限速、报备字段、材料被复制;源通道不受影响;复制日志包含 sourceChannelId 和 newChannelId。 |
| TC-ADMIN-017 | 对有关联历史的通道执行停用、启用、删除。 | 取消确认无请求或无状态变化;停用后不参与路由;删除为软删除/归档;历史发送、报备、日志仍可查。 | | TC-ADMIN-017 | 对有关联历史的通道执行停用、启用、删除。 | 取消确认无请求或无状态变化;停用后不参与路由;删除为软删除/归档;历史发送、报备、日志仍可查。 |
| TC-ADMIN-018 | 企业应用列表展示 CMPP 连接数,打开连接详情,删除连接,复制 CMPP 参数。 | 连接数来自连接状态 API;详情含 connectionId/status/heartbeat/window/lastSubmitAt;删除连接调用真实接口;复制文本与 API 返回一致。 | | TC-ADMIN-018 | 企业应用列表展示 CMPP 连接数,打开连接详情,删除连接,复制 CMPP 参数。 | 连接数来自连接状态 API;详情含 connectionId/status/heartbeat/window/lastSubmitAt;删除连接调用真实接口;复制文本与 API 返回一致。 |
| TC-ADMIN-019 | 打开通道接日志,按事件类型和时间查看。 | 日志包含 connect、active_test、disconnect、reconnect、auth_failed、slow_response;按时间倒序;可定位 channelId/connectionId。 | | TC-ADMIN-019 | 打开通道接日志,按事件类型和时间查看。 | 日志包含 connect、active_test、disconnect、reconnect、auth_failed、slow_response;按时间倒序;可定位 channelId/connectionId。 |
| TC-ADMIN-020 | 企业黑名单、全局黑名单、敏感词分别执行搜索、新增、停用、删除。 | 搜索由 API 处理;停用/删除后发送前风控只使用 active 数据;删除不影响历史命中记录;所有动作写日志。 | | TC-ADMIN-020 | 企业黑名单、全局黑名单、敏感词分别执行搜索、新增、停用、删除。 | 搜索由 API 处理;停用/删除后发送前风控只使用 active 数据;删除不影响历史命中记录;所有动作写日志。 |
| TC-ADMIN-021 | 创建待审核企业认证、签名、模板、短信审核任务,检查铃铛总数和分类数。 | 总数等于分类汇总;点击分类跳转并带入筛选;审核完成后数量刷新;新增待办触发站内提醒或浏览器通知。 | | TC-ADMIN-021 | 创建待审核企业认证、签名、模板、短信审核任务,检查铃铛总数和分类数。 | 总数等于分类汇总;点击分类跳转并带入筛选;审核完成后数量刷新;新增待办触发站内提醒或浏览器通知。 |
| TC-ADMIN-022 | 运营日志按客户、操作者、动作、资源、时间搜索,查看长详情。 | 后端分页和搜索准确;详情不截断;可查到通道复制、启停、删除、连接状态变化、安全控制变更、充值等日志。 | | TC-ADMIN-022 | 运营日志按客户、操作者、动作、资源、时间搜索,查看长详情。 | 后端分页和搜索准确;详情不截断;可查到通道复制、启停、删除、连接状态变化、安全控制变更、充值等日志。 |
@@ -2636,7 +2636,7 @@ npm run verify:phase8
| TC-CUSTOMER-008 | 删除/归档有历史数据的客户。 | 不允许硬删除或执行归档;新发送和未执行 scheduled 阻断;历史 trace/对账可查。 | | TC-CUSTOMER-008 | 删除/归档有历史数据的客户。 | 不允许硬删除或执行归档;新发送和未执行 scheduled 阻断;历史 trace/对账可查。 |
| TC-CUSTOMER-009 | 客户详情总览应用、签名、模板、今日发送、余额。 | 各指标与明细列表聚合一致;跳转带客户筛选;异常状态有标识。 | | TC-CUSTOMER-009 | 客户详情总览应用、签名、模板、今日发送、余额。 | 各指标与明细列表聚合一致;跳转带客户筛选;异常状态有标识。 |
| TC-CUSTOMER-010 | 客户绑定通道组,主通道 online、备通道 disconnected。 | 客户详情展示通道组和连接状态;发送路由选择 online 且报备通过通道;trace channelId 一致。 | | TC-CUSTOMER-010 | 客户绑定通道组,主通道 online、备通道 disconnected。 | 客户详情展示通道组和连接状态;发送路由选择 online 且报备通过通道;trace channelId 一致。 |
| TC-CUSTOMER-011 | 客户 A/B 不同连接配置和在线数。 | 客户列表摘要如 `2/2 online``1/3 degraded`;详情和通道监控一致。 | | TC-CUSTOMER-011 | 客户 A/B 不同连接配置和在线数。 | 客户列表摘要如 `2/2 connected``1/3 degraded`;详情和通道监控一致。 |
| TC-CUSTOMER-012 | 调整客户通道连接数并触发 Gateway 重载。 | desired/current 连接数最终一致;发送能力或窗口容量随配置变化;日志记录变更。 | | TC-CUSTOMER-012 | 调整客户通道连接数并触发 Gateway 重载。 | desired/current 连接数最终一致;发送能力或窗口容量随配置变化;日志记录变更。 |
| TC-CUSTOMER-013 | 超过通道最大连接数分配。 | 保存失败;提示最大连接数、已分配数和可用数;不影响已有连接;失败日志存在。 | | TC-CUSTOMER-013 | 超过通道最大连接数分配。 | 保存失败;提示最大连接数、已分配数和可用数;不影响已有连接;失败日志存在。 |
@@ -2645,21 +2645,22 @@ npm run verify:phase8
| 用例 | 模拟方式 | 必查断言 | | 用例 | 模拟方式 | 必查断言 |
| --- | --- | --- | | --- | --- | --- |
| TC-CMPP-STATUS-001 | Gateway 未启动或未回写。 | 通道业务 active 与连接 disconnected/unknown 分开展示;不可误判为可提交。 | | TC-CMPP-STATUS-001 | Gateway 未启动或未回写。 | 通道业务 active 与连接 disconnected/unknown 分开展示;不可误判为可提交。 |
| TC-CMPP-STATUS-002 | 模拟 SMSC 登录成功。 | 状态 online;连接建立时间、最近心跳、窗口可用;发送可路由到该通道。 | | TC-CMPP-STATUS-002 | 模拟 SMSC 登录成功。 | 状态 connected;连接建立时间、最近心跳、窗口可用;发送可路由到该通道。 |
| TC-CMPP-STATUS-003 | 模拟登录认证失败。 | 状态 auth_failed;错误码/原因展示;路由跳过;日志/告警记录。 | | TC-CMPP-STATUS-003 | 模拟登录认证失败。 | 状态 auth_failed;错误码/原因展示;路由跳过;日志/告警记录。 |
| TC-CMPP-STATUS-004 | 模拟 active test 超时。 | 状态 heartbeat_timeout/disconnected;进入重连;新发送走备用或等待失败。 | | TC-CMPP-STATUS-004 | 模拟 active test 超时。 | 状态 heartbeat_timeout/disconnected;进入重连;新发送走备用或等待失败。 |
| TC-CMPP-STATUS-005 | 模拟 TCP 断开再恢复。 | 状态 disconnected -> reconnecting -> online;重连次数增加;未确认消息状态明确。 | | TC-CMPP-STATUS-005 | 模拟 TCP 断开再恢复。 | 状态 disconnected -> reconnecting -> connected;重连次数增加;未确认消息状态明确。 |
| TC-CMPP-STATUS-006 | 主通道离线、备用在线且报备通过。 | 路由跳过主通道并选择备用;trace 展示备用 channelId。 | | TC-CMPP-STATUS-006 | 主通道离线、备用在线且报备通过。 | 路由跳过主通道并选择备用;trace 展示备用 channelId。 |
| TC-CMPP-STATUS-007 | 所有通道离线或认证失败。 | 不提交到离线连接;任务 delayed/retry/failed/pending_channel;不错误扣费。 | | 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-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-011 | 通道 maxConnections=4、desired=2、current=2。 | 通道详情、监控、Dashboard 连接数一致;连接列表展示 connectionId、心跳、窗口、sequence。 |
| TC-CMPP-STATUS-012 | desired 1 调整为 3。 | Gateway 建立新连接至 3/3;任务可按连接/窗口分摊;日志记录调整。 | | TC-CMPP-STATUS-012 | desired 1 调整为 3。 | Gateway 建立新连接至 3/3;任务可按连接/窗口分摊;日志记录调整。 |
| TC-CMPP-STATUS-013 | desired 3 调整为 1。 | 多余连接优雅关闭;未确认 submit 不丢失不重复;终态 1/1。 | | 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-015 | desired=0 或 current=0。 | 路由不选择该通道;无备用时任务失败或等待;原因包含无在线连接。 |
| TC-CMPP-STATUS-016 | 单连接限速 100,连接数 1 和 2 分别压测。 | 理论能力随在线连接数变化;实际 TPS 不超过限速;不重复发送。 | | TC-CMPP-STATUS-016 | 单连接限速 100,连接数 1 和 2 分别压测。 | 理论能力随在线连接数变化;实际 TPS 不超过限速;不重复发送。 |
| TC-CMPP-STATUS-017 | 新建/启用通道后 Gateway 未回写,连接状态停留 connecting 超过 30 秒。 | API 兜底任务将连接标记为 failedcurrentConnections=0lastError 为 `Gateway connection request timed out after 30 seconds`;连接日志包含 connect_timeout;发送路由不可选择该通道。 |
### 17.9 自动化落地建议 ### 17.9 自动化落地建议
@@ -2668,7 +2669,7 @@ npm run verify:phase8
| API Jest | 认证、字典、安全控制、通道复制/软删除、连接状态、人工充值、系统日志查询、Dashboard 聚合口径。 | | API Jest | 认证、字典、安全控制、通道复制/软删除、连接状态、人工充值、系统日志查询、Dashboard 聚合口径。 |
| HTTP Smoke | 客户创建、认证审核、通道复制、连接状态回写、人工充值、立即发送、定时到点、trace、reconciliation。 | | HTTP Smoke | 客户创建、认证审核、通道复制、连接状态回写、人工充值、立即发送、定时到点、trace、reconciliation。 |
| Go Gateway | 连接状态回写契约、登录成功/失败、心跳超时、断线重连、窗口占满、连接数调整。 | | Go Gateway | 连接状态回写契约、登录成功/失败、心跳超时、断线重连、窗口占满、连接数调整。 |
| 前端 Smoke | 客户端头像菜单、系统日志分页、运营模板审核搜索、企业认证详情、通道复制/接日志、安全控制 CRUD、Dashboard 指标跳转。 | | 前端 Smoke | 客户端头像菜单、系统日志分页、运营模板审核搜索、企业认证详情、通道复制/接日志、安全控制 CRUD、Dashboard 指标跳转。 |
| 性能 Smoke | BullMQ 500 TPS、CMPP 连接数变化后的提交能力、慢响应积压恢复。 | | 性能 Smoke | BullMQ 500 TPS、CMPP 连接数变化后的提交能力、慢响应积压恢复。 |
### 17.9 登录和用户管理闭环 ### 17.9 登录和用户管理闭环
+109 -6
View File
@@ -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 ## 2026-07-01
### 新增测试基础 ### 新增测试基础
@@ -183,7 +238,7 @@ npm run test:gateway
- 通道管理补齐真实 API - 通道管理补齐真实 API
- `POST /api/admin/channels/:id/copy`:复制通道配置、通道报备字段和该通道签名报备材料,写入操作日志。 - `POST /api/admin/channels/:id/copy`:复制通道配置、通道报备字段和该通道签名报备材料,写入操作日志。
- `DELETE /api/admin/channels/:id`:软删除通道,避免破坏历史发送/报备外键。 - `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 查询、创建、启停/软删除,并写操作日志。 - 安全控制补齐真实 API:敏感词、全局黑名单、企业黑名单支持 keyword/status 查询、创建、启停/软删除,并写操作日志。
- 模板审核补齐真实查询:运营端模板列表支持 keyword/status,并返回企业、应用、签名信息;前端模板审核页已改为调用真实 API。 - 模板审核补齐真实查询:运营端模板列表支持 keyword/status,并返回企业、应用、签名信息;前端模板审核页已改为调用真实 API。
- 企业认证审核补齐真实查询:列表支持 keyword/status,详情返回企业信息和认证 materials;前端企业认证审核页已改为调用真实 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` | 敏感词、全局黑名单、企业黑名单查询、创建、软删除和操作日志。 | | `api/src/dictionaries/dictionaries.service.spec.ts` | 敏感词、全局黑名单、企业黑名单查询、创建、软删除和操作日志。 |
### 已执行命令 ### 已执行命令
@@ -214,7 +269,7 @@ npm run build
- 已将今天的客户端和运营端优化要求补入 `docs/first-version-development-requirements.md` - 已将今天的客户端和运营端优化要求补入 `docs/first-version-development-requirements.md`
- 去除客户端独立账号设置菜单,改为头像下拉承载退出登录和修改密码。 - 去除客户端独立账号设置菜单,改为头像下拉承载退出登录和修改密码。
- 明确模板审核搜索、企业认证详情审核、企业应用 CMPP 连接数/连接详情/参数复制、通道复制、通道软删除、通道接日志、安全控制 CRUD、系统日志分页等均需要真实后端 API 支撑。 - 明确模板审核搜索、企业认证详情审核、企业应用 CMPP 连接数/连接详情/参数复制、通道复制、通道软删除、通道接日志、安全控制 CRUD、系统日志分页等均需要真实后端 API 支撑。
- 补充客户端用户、运营端企业认证、通道、连接、字典、安全控制、系统日志等接口范围。 - 补充客户端用户、运营端企业认证、通道、连接、字典、安全控制、系统日志等接口范围。
- 修正 Codex 执行模板,明确 mock、localStorage 或静态数据不得作为真实开发完成标准。 - 修正 Codex 执行模板,明确 mock、localStorage 或静态数据不得作为真实开发完成标准。
- 已将今天的验收点补入 `docs/system-functional-test-cases.md` - 已将今天的验收点补入 `docs/system-functional-test-cases.md`
@@ -253,7 +308,7 @@ node <browser-and-api-smoke>
| TC-CMPP-STATUS-UI | UI-SMOKE PASS / BACKEND GAP | 企业应用管理可展示 CMPP 状态和连接数量并打开连接详情;页面当前仍有本地初始数据路径。 | | TC-CMPP-STATUS-UI | UI-SMOKE PASS / BACKEND GAP | 企业应用管理可展示 CMPP 状态和连接数量并打开连接详情;页面当前仍有本地初始数据路径。 |
| TC-FRONTEND-CONSOLE | PASS | 关键页面无相关 console error/pageerror;仅忽略 favicon 404。 | | TC-FRONTEND-CONSOLE | PASS | 关键页面无相关 console error/pageerror;仅忽略 favicon 404。 |
| TC-BILLING-MANUAL-API | PASS | 人工充值无需审批:确认后账户余额、短信条数、充值单、账户流水和 Dashboard transactions 聚合同步更新。 | | 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 <browser-and-api-smoke>
| BUG-DEV-001 | P1 | `npm run dev` 在 5173 被占用后切到 5174Vite 依赖 bundling 长时间未完成,浏览器看到白屏。 | 本轮浏览器测试中 5174 HTTP 后续可达,但首次打开截图为空白;生产 build/preview 正常。 | 检查 Vite dev 依赖预构建和端口占用问题,确保开发模式可稳定渲染。 | | BUG-DEV-001 | P1 | `npm run dev` 在 5173 被占用后切到 5174Vite 依赖 bundling 长时间未完成,浏览器看到白屏。 | 本轮浏览器测试中 5174 HTTP 后续可达,但首次打开截图为空白;生产 build/preview 正常。 | 检查 Vite dev 依赖预构建和端口占用问题,确保开发模式可稳定渲染。 |
| BUG-SEND-001 | P0 | 发送路由规则允许直接绑定单个通道,违反“规则只能绑定通道组”的业务约束。 | `SendChainService.selectChannel()` 当前存在 `route?.channel ?? route?.group...` 路径;`ChannelRouteRule` 模型也保留 `channelId` 字段。 | 路由规则只能表达应用到通道组的绑定关系;发送链路必须从企业应用绑定的运营商通道组内选路,不允许规则直接指定单个通道。 | | 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-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-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-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;移动、联通、电信短信按识别结果进入对应通道组。 | | BUG-SEND-006 | P0 | 企业应用缺少按运营商绑定多个通道组和保存校验的真实闭环。 | 当前发送链路只按 `tenantId/applicationId` 查询单一路由规则;未体现一个应用分别绑定移动、联通、电信通道组,也未强制至少绑定一个通道组后才能保存。 | 企业应用可分别绑定移动、联通、电信通道组;一个都不绑定时 UI 不允许保存,发送时直接 failed;移动、联通、电信短信按识别结果进入对应通道组。 |
@@ -285,7 +340,8 @@ node <browser-and-api-smoke>
- BUG-SEND-001:后端 `createRouteRule` 禁止直接绑定单通道,路由规则只能绑定应用、运营商和通道组;发送链路不再读取 `route.channel` - BUG-SEND-001:后端 `createRouteRule` 禁止直接绑定单通道,路由规则只能绑定应用、运营商和通道组;发送链路不再读取 `route.channel`
- BUG-SEND-002:发送链路未找到企业应用对应运营商通道组或无可用在线通道时,短信直接标记 `failed`,不再 fallback 到全局第一个 active 通道。 - 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-004submit rejected、submit timeout、回执 failed 等失败场景会在补发开启且未超过时间限制时,排除已尝试通道并切换到同一通道组全国通道继续提交;unknown、超过 72 小时、超过通道组补发上限或关闭补发时不补发。 - BUG-SEND-004submit rejected、submit timeout、回执 failed 等失败场景会在补发开启且未超过时间限制时,排除已尝试通道并切换到同一通道组全国通道继续提交;unknown、超过 72 小时、超过通道组补发上限或关闭补发时不补发。
- BUG-SEND-005:新增 `PhoneCarrierRule` 运营商前缀正则配置,发送前先识别运营商,识别失败默认移动;手机号段库用于识别省份,省份识别失败走对应运营商全国通道;通道新增 `sendRegion`,支持全国或单省。 - BUG-SEND-005:新增 `PhoneCarrierRule` 运营商前缀正则配置,发送前先识别运营商,识别失败默认移动;手机号段库用于识别省份,省份识别失败走对应运营商全国通道;通道新增 `sendRegion`,支持全国或单省。
- BUG-SEND-006:企业应用创建页面可分别选择移动、联通、电信通道组,一个都不选时 UI 阻止保存;创建应用成功后写入真实通道组路由规则。 - BUG-SEND-006:企业应用创建页面可分别选择移动、联通、电信通道组,一个都不选时 UI 阻止保存;创建应用成功后写入真实通道组路由规则。
@@ -601,3 +657,50 @@ git diff --check
- 企业模板管理表格最小宽度 1820px,模板内容列 420px,横向滚动生效。 - 企业模板管理表格最小宽度 1820px,模板内容列 420px,横向滚动生效。
- 新建企业应用弹窗在 1280px 视口下未截断,未选择企业时“下一步”禁用。 - 新建企业应用弹窗在 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 提示。
+8 -3
View File
@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"os" "os"
"cmpp-platform/gateway/internal/control"
"cmpp-platform/gateway/internal/health" "cmpp-platform/gateway/internal/health"
) )
@@ -14,8 +15,12 @@ func main() {
addr = ":8090" addr = ":8090"
} }
log.Printf("cmpp gateway health server listening on %s", addr) mux := http.NewServeMux()
if err := http.ListenAndServe(addr, health.Handler()); err != nil { mux.Handle("/health", health.Handler())
log.Fatalf("gateway health server stopped: %v", err) 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)
} }
} }
+186
View File
@@ -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
}
+122
View File
@@ -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
}
}`
}
+25
View File
@@ -11,6 +11,7 @@ const (
MessageTypeSubmitResult MessageType = "SubmitResult" MessageTypeSubmitResult MessageType = "SubmitResult"
MessageTypeReceiptEvent MessageType = "ReceiptEvent" MessageTypeReceiptEvent MessageType = "ReceiptEvent"
MessageTypeUplinkEvent MessageType = "UplinkEvent" MessageTypeUplinkEvent MessageType = "UplinkEvent"
MessageTypeConnectChannel MessageType = "ConnectChannel"
) )
type Envelope struct { type Envelope struct {
@@ -88,3 +89,27 @@ type UplinkEvent struct {
Content string `json:"content"` Content string `json:"content"`
ReceivedAt time.Time `json:"receivedAt"` 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"`
}
+2
View File
@@ -8,6 +8,8 @@
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"build:api": "npm --prefix api run build", "build:api": "npm --prefix api run build",
"preview": "vite preview --host 0.0.0.0", "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", "prisma:generate": "npm --prefix api run prisma:generate",
"spike:contracts": "node tools/spike/validate-gateway-queue-contract.mjs", "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\"", "spike:gateway": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$env:Path='C:\\Program Files\\Go\\bin;'+$env:Path; Push-Location gateway; go test ./...; Pop-Location\"",
+24 -3
View File
@@ -39,9 +39,10 @@ export type AdminChannel = {
unitPrice: number; unitPrice: number;
status: string; status: string;
config?: unknown; config?: unknown;
connectionStates?: CmppConnectionState[];
}; };
export type ChannelLinkLogResponse = { export type ChannelConnectionLogResponse = {
channelId: string; channelId: string;
connectionStates: Array<Record<string, unknown>>; connectionStates: Array<Record<string, unknown>>;
logs: Array<{ logs: Array<{
@@ -102,6 +103,11 @@ export type TenantOption = {
} | null; } | null;
}; };
export type TenantManagementRow = TenantOption & {
account?: TenantAccount | null;
todaySpendCents: number;
};
export type CaptchaResponse = { export type CaptchaResponse = {
captchaId: string; captchaId: string;
challenge: string; challenge: string;
@@ -388,6 +394,16 @@ export type FileObject = {
createdAt: string; 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 = { export type RiskReviewTask = {
id: string; id: string;
tenantId: string; tenantId: string;
@@ -464,6 +480,7 @@ export type EnterpriseApplication = {
export type CmppConnectionState = { export type CmppConnectionState = {
id: string; id: string;
tenantId?: string | null; tenantId?: string | null;
applicationId?: string | null;
channelId: string; channelId: string;
connectionId: string; connectionId: string;
status: string; status: string;
@@ -517,8 +534,9 @@ export const adminApi = {
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) => login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
request<LoginSession>('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }), request<LoginSession>('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }),
listTenants: () => request<TenantOption[]>('/admin/tenants'), listTenants: () => request<TenantOption[]>('/admin/tenants'),
listTenantManagementRows: () => request<TenantManagementRow[]>('/admin/tenants/management-list'),
getTenant: (id: string) => request<TenantOption>(`/admin/tenants/${id}`), getTenant: (id: string) => request<TenantOption>(`/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<TenantOption>('/admin/tenants', { method: 'POST', body: JSON.stringify(body) }), request<TenantOption>('/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 }) => 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<TenantOption>(`/admin/tenants/${id}`, { method: 'PUT', body: JSON.stringify(body) }), request<TenantOption>(`/admin/tenants/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
@@ -580,7 +598,7 @@ export const adminApi = {
method: 'DELETE', method: 'DELETE',
body: JSON.stringify({ reason }), body: JSON.stringify({ reason }),
}), }),
listChannelLinkLogs: (id: string) => request<ChannelLinkLogResponse>(`/admin/channels/${id}/link-logs`), listChannelConnectionLogs: (id: string) => request<ChannelConnectionLogResponse>(`/admin/channels/${id}/connection-logs`),
listTemplateAudits: (query: { keyword?: string; status?: string }) => { listTemplateAudits: (query: { keyword?: string; status?: string }) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (query.keyword) params.set('keyword', query.keyword); if (query.keyword) params.set('keyword', query.keyword);
@@ -630,11 +648,14 @@ export const adminApi = {
request<ChannelGroup>('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }), request<ChannelGroup>('/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<Record<string, unknown>> }) => updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; items?: Array<Record<string, unknown>> }) =>
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }), request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteChannelGroup: (id: string) =>
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'DELETE' }),
addChannelGroupItem: (body: Record<string, unknown>) => addChannelGroupItem: (body: Record<string, unknown>) =>
request<DictionaryItem>('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }), request<DictionaryItem>('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }),
listChannelRouteRules: () => request<DictionaryItem[]>('/admin/channel-route-rules'), listChannelRouteRules: () => request<DictionaryItem[]>('/admin/channel-route-rules'),
createChannelRouteRule: (body: { tenantId?: string; applicationId: string; groupId: string; carrier: string; priority?: number; status?: string }) => createChannelRouteRule: (body: { tenantId?: string; applicationId: string; groupId: string; carrier: string; priority?: number; status?: string }) =>
request<DictionaryItem>('/admin/channel-route-rules', { method: 'POST', body: JSON.stringify(body) }), request<DictionaryItem>('/admin/channel-route-rules', { method: 'POST', body: JSON.stringify(body) }),
listChannelConnections: (id: string) => request<CmppConnectionState[]>(`/admin/channels/${id}/connections`),
replaceApplicationRouteRules: (applicationId: string, body: { routes: Array<{ carrier: 'mobile' | 'unicom' | 'telecom'; groupId: string; priority?: number; status?: string }> }) => replaceApplicationRouteRules: (applicationId: string, body: { routes: Array<{ carrier: 'mobile' | 'unicom' | 'telecom'; groupId: string; priority?: number; status?: string }> }) =>
request<DictionaryItem[]>(`/admin/enterprise-applications/${applicationId}/route-rules`, { method: 'PUT', body: JSON.stringify(body) }), request<DictionaryItem[]>(`/admin/enterprise-applications/${applicationId}/route-rules`, { method: 'PUT', body: JSON.stringify(body) }),
listChannelReportFields: (channelId?: string) => request<ChannelReportField[]>(withQuery('/admin/channel-report-fields', { channelId })), listChannelReportFields: (channelId?: string) => request<ChannelReportField[]>(withQuery('/admin/channel-report-fields', { channelId })),
+63 -40
View File
@@ -1,9 +1,8 @@
import { useEffect, useMemo, useState } from 'react'; 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 { useNavigate, useParams } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi'; import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag } from '@/components/ui'; import { Breadcrumb, Button, Input, Modal, Select, Tag } from '@/components/ui';
import type { TableColumn } from '@/components/ui';
type Carrier = 'mobile' | 'unicom' | 'telecom'; type Carrier = 'mobile' | 'unicom' | 'telecom';
type ChannelStatus = 'normal' | 'stopped'; type ChannelStatus = 'normal' | 'stopped';
@@ -50,7 +49,7 @@ const carrierLabels: Record<Carrier, string> = {
}; };
const statusLabels: Record<ChannelStatus, string> = { const statusLabels: Record<ChannelStatus, string> = {
normal: '链接正常', normal: '通道启用',
stopped: '通道停用', stopped: '通道停用',
}; };
@@ -75,6 +74,38 @@ function StatusTag({ status }: { status: ChannelStatus }) {
return <Tag tone={statusTones[status]}>{statusLabels[status]}</Tag>; return <Tag tone={statusTones[status]}>{statusLabels[status]}</Tag>;
} }
function RouteCard({
title,
subtitle,
channel,
status,
onEdit,
onDelete,
}: {
title: string;
subtitle: string;
channel?: AdminChannel;
status: ChannelStatus;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<article className="channel-route-card">
<div>
<strong>{title}</strong>
<span>{subtitle}</span>
</div>
<p>{channel?.name ?? '未命名通道'}</p>
<small>{channel?.sendRegion ?? '全国'} / {channel?.carrier ?? '未标记'}</small>
<StatusTag status={status} />
<footer>
<button onClick={onEdit} type="button"><Pencil size={15} /></button>
<button className="is-danger" onClick={onDelete} type="button"><Trash2 size={15} /></button>
</footer>
</article>
);
}
function RouteConfigModal({ function RouteConfigModal({
channels, channels,
carrier, carrier,
@@ -222,40 +253,6 @@ export function AdminChannelGroupFormPage() {
loadData(); loadData();
}, [groupId]); }, [groupId]);
const provinceColumns = useMemo<Array<TableColumn<ProvinceRoute>>>(() => [
{ key: 'province', title: '省份', width: '120px', render: (record) => <strong>{record.province}</strong> },
{ key: 'channel', title: '通道', render: (record) => channelById.get(record.channelId)?.name ?? record.channelId },
{ key: 'status', title: '通道状态', width: '160px', render: (record) => <StatusTag status={record.status} /> },
{
key: 'actions',
title: '操作',
width: '180px',
render: (record) => (
<div className="channel-group-row-actions">
<button onClick={() => setModal({ type: 'province', mode: 'edit', route: record })} type="button"></button>
<button className="is-danger" onClick={() => setProvinceRoutes((current) => current.filter((item) => item.id !== record.id))} type="button"></button>
</div>
),
},
], [channelById]);
const nationalColumns = useMemo<Array<TableColumn<NationalRoute>>>(() => [
{ key: 'priority', title: '优先级', width: '120px', render: (record) => <strong>{record.priority}</strong> },
{ key: 'channel', title: '通道', render: (record) => channelById.get(record.channelId)?.name ?? record.channelId },
{ key: 'status', title: '通道状态', width: '160px', render: (record) => <StatusTag status={record.status} /> },
{
key: 'actions',
title: '操作',
width: '180px',
render: (record) => (
<div className="channel-group-row-actions">
<button onClick={() => setModal({ type: 'national', mode: 'edit', route: record })} type="button"></button>
<button className="is-danger" onClick={() => setNationalRoutes((current) => current.filter((item) => item.id !== record.id))} type="button"></button>
</div>
),
},
], [channelById]);
function saveRoute(route: ProvinceRoute | NationalRoute) { function saveRoute(route: ProvinceRoute | NationalRoute) {
if (modal?.type === 'province') { if (modal?.type === 'province') {
const nextRoute = route as ProvinceRoute; const nextRoute = route as ProvinceRoute;
@@ -359,7 +356,20 @@ export function AdminChannelGroupFormPage() {
<section className="surface channel-group-form-section"> <section className="surface channel-group-form-section">
<h2></h2> <h2></h2>
<Table columns={provinceColumns} data={provinceRoutes} emptyText="暂无省网通道" rowKey="id" /> <div className="channel-route-card-grid">
{provinceRoutes.map((route) => (
<RouteCard
key={route.id}
channel={channelById.get(route.channelId)}
onDelete={() => 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 ? <p className="channel-route-empty"></p> : null}
</div>
<Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'province', mode: 'create' })} variant="ghost"> <Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'province', mode: 'create' })} variant="ghost">
</Button> </Button>
@@ -367,7 +377,20 @@ export function AdminChannelGroupFormPage() {
<section className="surface channel-group-form-section"> <section className="surface channel-group-form-section">
<h2></h2> <h2></h2>
<Table columns={nationalColumns} data={nationalRoutes} emptyText="暂无全国通道" rowKey="id" /> <div className="channel-route-card-grid">
{nationalRoutes.map((route) => (
<RouteCard
key={route.id}
channel={channelById.get(route.channelId)}
onDelete={() => 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 ? <p className="channel-route-empty"></p> : null}
</div>
<Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'national', mode: 'create' })} variant="ghost"> <Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'national', mode: 'create' })} variant="ghost">
</Button> </Button>
+26 -36
View File
@@ -1,16 +1,11 @@
import { useEffect, useMemo, useState } from 'react'; 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 { Breadcrumb, Button, Input, Modal, Pagination } from '@/components/ui';
import { adminApi, type ChannelGroup } from '@/api/adminApi'; import { adminApi, type ChannelGroup } from '@/api/adminApi';
type GroupCarrier = 'mobile' | 'unicom' | 'telecom'; type GroupCarrier = 'mobile' | 'unicom' | 'telecom';
const carrierOptions: Array<{ label: string; value: GroupCarrier }> = [
{ label: '移动', value: 'mobile' },
{ label: '联通', value: 'unicom' },
{ label: '电信', value: 'telecom' },
];
const carrierLabels: Record<GroupCarrier, string> = { const carrierLabels: Record<GroupCarrier, string> = {
mobile: '移动', mobile: '移动',
unicom: '联通', unicom: '联通',
@@ -18,12 +13,10 @@ const carrierLabels: Record<GroupCarrier, string> = {
}; };
export function AdminChannelGroupsPage() { export function AdminChannelGroupsPage() {
const navigate = useNavigate();
const [groupName, setGroupName] = useState(''); const [groupName, setGroupName] = useState('');
const [groups, setGroups] = useState<ChannelGroup[]>([]); const [groups, setGroups] = useState<ChannelGroup[]>([]);
const [modalOpen, setModalOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState<ChannelGroup | null>(null);
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [carrier, setCarrier] = useState<GroupCarrier>('mobile');
const [error, setError] = useState(''); const [error, setError] = useState('');
function loadData() { function loadData() {
@@ -41,16 +34,14 @@ export function AdminChannelGroupsPage() {
const filteredGroups = useMemo(() => groups.filter((group) => !groupName.trim() || group.name.includes(groupName.trim())), [groupName, groups]); const filteredGroups = useMemo(() => groups.filter((group) => !groupName.trim() || group.name.includes(groupName.trim())), [groupName, groups]);
function createGroup() { function deleteGroup() {
adminApi.createChannelGroup({ code, name, carrier, status: 'active' }) if (!deleteTarget) return;
adminApi.deleteChannelGroup(deleteTarget.id)
.then(() => { .then(() => {
setModalOpen(false); setDeleteTarget(null);
setName('');
setCode('');
setCarrier('mobile');
loadData(); loadData();
}) })
.catch((failure: Error) => setError(failure.message || '通道组创建失败')); .catch((failure: Error) => setError(failure.message || '通道组删除失败'));
} }
return ( return (
@@ -59,7 +50,7 @@ export function AdminChannelGroupsPage() {
<div> <div>
<Breadcrumb items={['短信通道组管理']} /> <Breadcrumb items={['短信通道组管理']} />
</div> </div>
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}> <Button icon={<Plus size={16} />} onClick={() => navigate('/admin/channel-groups/new')}>
</Button> </Button>
</div> </div>
@@ -92,6 +83,14 @@ export function AdminChannelGroupsPage() {
})} })}
{(group.items?.length ?? 0) === 0 ? <p className="muted"></p> : null} {(group.items?.length ?? 0) === 0 ? <p className="muted"></p> : null}
</div> </div>
<footer>
<button onClick={() => navigate(`/admin/channel-groups/${group.id}/edit`)} type="button">
<Pencil size={15} />
</button>
<button className="is-danger" onClick={() => setDeleteTarget(group)} type="button">
<Trash2 size={15} />
</button>
</footer>
</article> </article>
))} ))}
</div> </div>
@@ -101,26 +100,17 @@ export function AdminChannelGroupsPage() {
<Modal <Modal
footer={( footer={(
<> <>
<Button onClick={() => setModalOpen(false)} variant="ghost"></Button> <Button onClick={() => setDeleteTarget(null)} variant="ghost"></Button>
<Button disabled={!name || !code} onClick={createGroup}></Button> <Button onClick={deleteGroup} variant="danger"></Button>
</> </>
)} )}
onClose={() => setModalOpen(false)} onClose={() => setDeleteTarget(null)}
open={modalOpen} open={Boolean(deleteTarget)}
title="添加通道组" title="删除通道组"
> >
<div className="admin-system-modal-form"> <div className="channel-confirm">
<Input label="通道组编码" onChange={(event) => setCode(event.target.value)} value={code} /> <strong>{deleteTarget?.name}</strong>
<Input label="通道组名称" onChange={(event) => setName(event.target.value)} value={name} /> <p>使</p>
<div className="channel-group-radio-row">
<span></span>
{carrierOptions.map((item) => (
<label key={item.value}>
<input checked={carrier === item.value} onChange={() => setCarrier(item.value)} type="radio" />
{item.label}
</label>
))}
</div>
</div> </div>
</Modal> </Modal>
</div> </div>
+51 -34
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Copy, Eye, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react'; import { Copy, Eye, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; 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'; import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all'; type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
@@ -41,7 +41,7 @@ type ChannelConfirmAction = {
type ChannelLogState = { type ChannelLogState = {
channel: SmsChannel; channel: SmsChannel;
data?: ChannelLinkLogResponse; data?: ChannelConnectionLogResponse;
}; };
const carrierOptions = [ const carrierOptions = [
@@ -54,10 +54,10 @@ const carrierOptions = [
const statusOptions = [ const statusOptions = [
{ label: '全部状态', value: 'all' }, { label: '全部状态', value: 'all' },
{ label: '接正常', value: 'normal' }, { label: '接正常', value: 'normal' },
{ label: '已停用', value: 'stopped' }, { label: '已停用', value: 'stopped' },
{ label: '接中', value: 'connecting' }, { label: '接中', value: 'connecting' },
{ label: '接失败', value: 'failed' }, { label: '接失败', value: 'failed' },
]; ];
const protocolOptions = [ const protocolOptions = [
@@ -93,10 +93,10 @@ const carrierToneMap: Record<Carrier, 'info' | 'danger' | 'success' | 'neutral'>
}; };
const statusLabelMap: Record<ChannelStatus, string> = { const statusLabelMap: Record<ChannelStatus, string> = {
normal: '接正常', normal: '接正常',
stopped: '已停用', stopped: '已停用',
connecting: '接中', connecting: '接中',
failed: '接失败', failed: '接失败',
}; };
const statusToneMap: Record<ChannelStatus, 'success' | 'neutral' | 'info' | 'danger'> = { const statusToneMap: Record<ChannelStatus, 'success' | 'neutral' | 'info' | 'danger'> = {
@@ -106,21 +106,31 @@ const statusToneMap: Record<ChannelStatus, 'success' | 'neutral' | 'info' | 'dan
failed: 'danger', failed: 'danger',
}; };
function mapApiChannel(channel: AdminChannel): SmsChannel { function resolveChannelStatus(channel: AdminChannel, connections: CmppConnectionState[] = []): ChannelStatus {
const statusMap: Record<string, ChannelStatus> = { if (channel.status !== 'active') {
active: 'normal', return 'stopped';
disabled: 'stopped', }
deleted: 'stopped', if (connections.some((connection) =>
connecting: 'connecting', connection.status === 'connected'
failed: 'failed', && 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 { return {
id: channel.id, id: channel.id,
name: channel.name, name: channel.name,
carrier: channel.carrier === 'unicom' || channel.carrier === 'telecom' || channel.carrier === 'all' ? channel.carrier : 'mobile', carrier: channel.carrier === 'unicom' || channel.carrier === 'telecom' || channel.carrier === 'all' ? channel.carrier : 'mobile',
sendRegion: channel.sendRegion ?? '全国', sendRegion: channel.sendRegion ?? '全国',
unitPrice: channel.unitPrice, unitPrice: channel.unitPrice,
status: statusMap[channel.status] ?? 'normal', status: resolveChannelStatus(channel, connections),
total: 0, total: 0,
successRate: 0, successRate: 0,
successCount: 0, successCount: 0,
@@ -353,13 +363,21 @@ export function AdminChannelsPage() {
const [confirmAction, setConfirmAction] = useState<ChannelConfirmAction | null>(null); const [confirmAction, setConfirmAction] = useState<ChannelConfirmAction | null>(null);
const [logState, setLogState] = useState<ChannelLogState | null>(null); const [logState, setLogState] = useState<ChannelLogState | null>(null);
useEffect(() => { function loadChannels() {
adminApi.listChannels() adminApi.listChannels()
.then((items) => { .then(async (items) => {
setChannels(items.filter((item) => item.status !== 'deleted').map(mapApiChannel)); 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(''); setError('');
}) })
.catch((failure: Error) => setError(failure.message || '通道列表加载失败')); .catch((failure: Error) => setError(failure.message || '通道列表加载失败'));
}
useEffect(() => {
loadChannels();
}, []); }, []);
const filteredChannels = useMemo( const filteredChannels = useMemo(
@@ -375,16 +393,15 @@ export function AdminChannelsPage() {
async function upsertChannel(nextChannel: SmsChannel) { async function upsertChannel(nextChannel: SmsChannel) {
try { try {
if (modal?.mode === 'edit' && modal.channel) { if (modal?.mode === 'edit' && modal.channel) {
const updated = await adminApi.updateChannel(modal.channel.id, buildChannelPayload(nextChannel, nextChannel.passwordCipher)); await adminApi.updateChannel(modal.channel.id, buildChannelPayload(nextChannel, nextChannel.passwordCipher));
setChannels((items) => items.map((item) => (item.id === updated.id ? mapApiChannel(updated) : item)));
} else { } else {
const created = await adminApi.createChannel({ await adminApi.createChannel({
code: `CH-${Date.now()}`, code: `CH-${Date.now()}`,
...buildChannelPayload(nextChannel, nextChannel.passwordCipher || 'secret'), ...buildChannelPayload(nextChannel, nextChannel.passwordCipher || 'secret'),
status: 'active', status: 'active',
}); });
setChannels((items) => [mapApiChannel(created), ...items]);
} }
loadChannels();
setModal(null); setModal(null);
setError(''); setError('');
} catch (failure) { } catch (failure) {
@@ -393,8 +410,8 @@ export function AdminChannelsPage() {
} }
async function toggleChannel(channel: SmsChannel) { async function toggleChannel(channel: SmsChannel) {
const updated = await adminApi.changeChannelStatus(channel.id, mapUiStatusToApi(channel)); await adminApi.changeChannelStatus(channel.id, mapUiStatusToApi(channel));
setChannels((items) => items.map((item) => (item.id === channel.id ? mapApiChannel(updated) : item))); loadChannels();
} }
async function deleteChannel(id: string) { async function deleteChannel(id: string) {
@@ -403,13 +420,13 @@ export function AdminChannelsPage() {
} }
async function copyChannel(channel: SmsChannel) { async function copyChannel(channel: SmsChannel) {
const copied = await adminApi.copyChannel(channel.id); await adminApi.copyChannel(channel.id);
setChannels((items) => [mapApiChannel(copied), ...items]); loadChannels();
} }
async function openLinkLogs(channel: SmsChannel) { async function openLinkLogs(channel: SmsChannel) {
setLogState({ channel }); setLogState({ channel });
const data = await adminApi.listChannelLinkLogs(channel.id); const data = await adminApi.listChannelConnectionLogs(channel.id);
setLogState({ channel, data }); setLogState({ channel, data });
} }
@@ -446,7 +463,7 @@ export function AdminChannelsPage() {
: confirmAction?.type === 'copy' : confirmAction?.type === 'copy'
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。' ? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
: confirmAction?.channel.status === 'stopped' : confirmAction?.channel.status === 'stopped'
? '启用后通道会进入接中状态,后续可继续观察网关连接。' ? '启用后通道会进入接中状态,后续可继续观察网关连接。'
: '停用后该通道将不再承接新的发送任务。'; : '停用后该通道将不再承接新的发送任务。';
return ( return (
@@ -491,7 +508,7 @@ export function AdminChannelsPage() {
<div className="sms-channel-status-cell"> <div className="sms-channel-status-cell">
<Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag> <Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag>
<button onClick={() => void openLinkLogs(channel)} type="button"> <button onClick={() => void openLinkLogs(channel)} type="button">
<FileText size={14} /> <FileText size={14} />
</button> </button>
</div> </div>
<strong className="sms-channel-total">{channel.total.toLocaleString('zh-CN')}</strong> <strong className="sms-channel-total">{channel.total.toLocaleString('zh-CN')}</strong>
@@ -564,7 +581,7 @@ export function AdminChannelsPage() {
onClose={() => setLogState(null)} onClose={() => setLogState(null)}
open open
size="xl" size="xl"
title={<div className="template-modal-title"><h2></h2><p>{logState.channel.name}</p></div>} title={<div className="template-modal-title"><h2></h2><p>{logState.channel.name}</p></div>}
> >
<div className="channel-log-list"> <div className="channel-log-list">
{(logState.data?.logs ?? []).map((log) => ( {(logState.data?.logs ?? []).map((log) => (
@@ -579,8 +596,8 @@ export function AdminChannelsPage() {
</div> </div>
</article> </article>
))} ))}
{logState.data && logState.data.logs.length === 0 ? <p className="muted"></p> : null} {logState.data && logState.data.logs.length === 0 ? <p className="muted"></p> : null}
{!logState.data ? <p className="muted">...</p> : null} {!logState.data ? <p className="muted">...</p> : null}
</div> </div>
</Modal> </Modal>
) : null} ) : null}
+18 -20
View File
@@ -1,13 +1,11 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { ImagePlus } from 'lucide-react'; import { ImagePlus } from 'lucide-react';
import { adminApi, type TenantOption } from '@/api/adminApi'; import { adminApi, type FileRef, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select, Textarea } from '@/components/ui'; import { Breadcrumb, Button, FileActions, Input, Select, Textarea } from '@/components/ui';
type EnterpriseForm = { type EnterpriseForm = {
name: string; name: string;
code: string;
status: string;
creditCode: string; creditCode: string;
province: string; province: string;
city: string; city: string;
@@ -18,6 +16,7 @@ type EnterpriseForm = {
contactEmail: string; contactEmail: string;
photoFileObjectId: string; photoFileObjectId: string;
photoFileName: string; photoFileName: string;
photoContentType: string;
}; };
type EnterpriseFormErrors = Partial<Record<keyof EnterpriseForm, string>>; type EnterpriseFormErrors = Partial<Record<keyof EnterpriseForm, string>>;
@@ -44,8 +43,6 @@ const cityOptionsByProvince: Record<string, Array<{ label: string; value: string
const emptyForm: EnterpriseForm = { const emptyForm: EnterpriseForm = {
name: '', name: '',
code: '',
status: 'active',
creditCode: '', creditCode: '',
province: '', province: '',
city: '', city: '',
@@ -56,14 +53,13 @@ const emptyForm: EnterpriseForm = {
contactEmail: '', contactEmail: '',
photoFileObjectId: '', photoFileObjectId: '',
photoFileName: '', photoFileName: '',
photoContentType: '',
}; };
function formFromTenant(tenant: TenantOption): EnterpriseForm { function formFromTenant(tenant: TenantOption): EnterpriseForm {
const profile = tenant.enterpriseProfile; const profile = tenant.enterpriseProfile;
return { return {
name: tenant.name, name: tenant.name,
code: tenant.code,
status: tenant.status,
creditCode: profile?.creditCode ?? '', creditCode: profile?.creditCode ?? '',
province: profile?.province ?? '', province: profile?.province ?? '',
city: profile?.city ?? '', city: profile?.city ?? '',
@@ -74,6 +70,7 @@ function formFromTenant(tenant: TenantOption): EnterpriseForm {
contactEmail: profile?.contactEmail ?? '', contactEmail: profile?.contactEmail ?? '',
photoFileObjectId: profile?.photoFileObjectId ?? '', photoFileObjectId: profile?.photoFileObjectId ?? '',
photoFileName: profile?.photoFileObjectId ? '已上传企业照片' : '', photoFileName: profile?.photoFileObjectId ? '已上传企业照片' : '',
photoContentType: '',
}; };
} }
@@ -117,7 +114,6 @@ export function AdminCustomerFormPage() {
function validateForm() { function validateForm() {
const nextErrors: EnterpriseFormErrors = {}; const nextErrors: EnterpriseFormErrors = {};
if (!form.name.trim()) nextErrors.name = '请填写企业名称'; if (!form.name.trim()) nextErrors.name = '请填写企业名称';
if (!form.code.trim()) nextErrors.code = '请填写企业编码';
if (!form.creditCode.trim()) nextErrors.creditCode = '请填写统一社会信用代码'; if (!form.creditCode.trim()) nextErrors.creditCode = '请填写统一社会信用代码';
if (!form.contactName.trim()) nextErrors.contactName = '请填写联系人姓名'; if (!form.contactName.trim()) nextErrors.contactName = '请填写联系人姓名';
if (!form.contactPhone.trim()) nextErrors.contactPhone = '请填写手机号'; if (!form.contactPhone.trim()) nextErrors.contactPhone = '请填写手机号';
@@ -128,7 +124,7 @@ export function AdminCustomerFormPage() {
function submitForm() { function submitForm() {
if (!validateForm()) return; if (!validateForm()) return;
setSaving(true); setSaving(true);
const { photoFileName, ...payload } = form; const { photoContentType, photoFileName, ...payload } = form;
const request = isEdit && enterpriseId const request = isEdit && enterpriseId
? adminApi.updateTenant(enterpriseId, payload) ? adminApi.updateTenant(enterpriseId, payload)
: adminApi.createTenant(payload); : adminApi.createTenant(payload);
@@ -143,13 +139,22 @@ export function AdminCustomerFormPage() {
setUploadingPhoto(true); setUploadingPhoto(true);
adminApi.uploadFileObject(file, { purpose: 'enterprise_photo', prefix: 'enterprise-photos' }) adminApi.uploadFileObject(file, { purpose: 'enterprise_photo', prefix: 'enterprise-photos' })
.then((fileObject) => { .then((fileObject) => {
setForm((current) => ({ ...current, photoFileObjectId: fileObject.id, photoFileName: fileObject.fileName })); setForm((current) => ({
...current,
photoContentType: fileObject.contentType,
photoFileObjectId: fileObject.id,
photoFileName: fileObject.fileName,
}));
setError(''); setError('');
}) })
.catch((failure: Error) => setError(failure.message || '企业照片上传失败')) .catch((failure: Error) => setError(failure.message || '企业照片上传失败'))
.finally(() => setUploadingPhoto(false)); .finally(() => setUploadingPhoto(false));
} }
const photoFile: FileRef | null = form.photoFileObjectId
? { contentType: form.photoContentType, fileName: form.photoFileName || '企业照片', fileObjectId: form.photoFileObjectId }
: null;
return ( return (
<section className="page-stack enterprise-form-page"> <section className="page-stack enterprise-form-page">
<div className="page-heading"> <div className="page-heading">
@@ -182,13 +187,12 @@ export function AdminCustomerFormPage() {
type="file" type="file"
/> />
</label> </label>
<FileActions file={photoFile} />
<p>{form.photoFileObjectId ? `文件对象:${form.photoFileObjectId}` : '支持 JPG、PNG、WebP,上传后随企业档案保存。'}</p> <p>{form.photoFileObjectId ? `文件对象:${form.photoFileObjectId}` : '支持 JPG、PNG、WebP,上传后随企业档案保存。'}</p>
</div> </div>
<div className="form-grid form-grid--two"> <div className="form-grid form-grid--two">
<Input error={errors.name} label="企业名称" onChange={(event) => updateForm('name', event.target.value)} placeholder="请填写企业全称" required value={form.name} /> <Input error={errors.name} label="企业名称" onChange={(event) => updateForm('name', event.target.value)} placeholder="请填写企业全称" required value={form.name} />
<Input error={errors.code} label="企业编码" onChange={(event) => updateForm('code', event.target.value)} placeholder="请填写唯一企业编码" required value={form.code} />
</div>
<Input <Input
error={errors.creditCode} error={errors.creditCode}
hint="修改此项将同步更新该企业档案。" hint="修改此项将同步更新该企业档案。"
@@ -198,6 +202,7 @@ export function AdminCustomerFormPage() {
required required
value={form.creditCode} value={form.creditCode}
/> />
</div>
<div className="form-grid form-grid--two"> <div className="form-grid form-grid--two">
<Select label="省/直辖市" onChange={(event) => updateForm('province', event.target.value)} options={provinceOptions} value={form.province} /> <Select label="省/直辖市" onChange={(event) => updateForm('province', event.target.value)} options={provinceOptions} value={form.province} />
@@ -235,13 +240,6 @@ export function AdminCustomerFormPage() {
<Input error={errors.contactPhone} label="手机号" onChange={(event) => updateForm('contactPhone', event.target.value)} placeholder="请填写企业联系人手机号" required value={form.contactPhone} /> <Input error={errors.contactPhone} label="手机号" onChange={(event) => updateForm('contactPhone', event.target.value)} placeholder="请填写企业联系人手机号" required value={form.contactPhone} />
<Input label="电子邮箱" onChange={(event) => updateForm('contactEmail', event.target.value)} placeholder="请填写企业联系人邮箱" type="email" value={form.contactEmail} /> <Input label="电子邮箱" onChange={(event) => updateForm('contactEmail', event.target.value)} placeholder="请填写企业联系人邮箱" type="email" value={form.contactEmail} />
</div> </div>
<Select
label="企业状态"
onChange={(event) => updateForm('status', event.target.value)}
options={[{ label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]}
value={form.status}
/>
</section> </section>
<div className="enterprise-form-footer"> <div className="enterprise-form-footer">
+109 -22
View File
@@ -1,17 +1,25 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Building2, DollarSign, Plus, Trash2, TrendingDown, TrendingUp } from 'lucide-react'; import { Building2, DollarSign, Plus, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
import { adminApi, type TenantAccount, type TenantOption } from '@/api/adminApi'; import { adminApi, type TenantManagementRow } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui'; import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
type AdminCustomersPageProps = { type AdminCustomersPageProps = {
basePath?: string; basePath?: string;
}; };
type CustomerRow = TenantOption & { type CustomerRow = TenantManagementRow;
account?: TenantAccount;
type RechargeForm = {
amount: string;
operator: string;
remark: string;
}; };
function formatCurrency(cents: number) {
return (cents / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) { function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
return ( return (
<Modal footer={<><Button onClick={onCancel} variant="ghost"></Button><Button onClick={onConfirm}></Button></>} onClose={onCancel} open title="操作确认"> <Modal footer={<><Button onClick={onCancel} variant="ghost"></Button><Button onClick={onConfirm}></Button></>} onClose={onCancel} open title="操作确认">
@@ -20,6 +28,14 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan
); );
} }
function emptyRechargeForm(): RechargeForm {
return {
amount: '',
operator: '运营',
remark: '',
};
}
export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCustomersPageProps) { export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCustomersPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [records, setRecords] = useState<CustomerRow[]>([]); const [records, setRecords] = useState<CustomerRow[]>([]);
@@ -28,15 +44,16 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
const [queryStatus, setQueryStatus] = useState('all'); const [queryStatus, setQueryStatus] = useState('all');
const [filters, setFilters] = useState({ id: '', name: '', status: 'all' }); const [filters, setFilters] = useState({ id: '', name: '', status: 'all' });
const [confirmAction, setConfirmAction] = useState<{ type: 'toggle' | 'delete'; record: CustomerRow } | null>(null); const [confirmAction, setConfirmAction] = useState<{ type: 'toggle' | 'delete'; record: CustomerRow } | null>(null);
const [rechargeTarget, setRechargeTarget] = useState<CustomerRow | null>(null);
const [rechargeForm, setRechargeForm] = useState<RechargeForm>(emptyRechargeForm);
const [rechargeError, setRechargeError] = useState('');
const [recharging, setRecharging] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
function loadData() { function loadData() {
Promise.all([adminApi.listTenants(), adminApi.listAccounts()]) adminApi.listTenantManagementRows()
.then(([tenants, accounts]) => { .then((items) => {
setRecords(tenants.filter((tenant) => tenant.status !== 'deleted').map((tenant) => ({ setRecords(items);
...tenant,
account: accounts.find((account) => account.tenantId === tenant.id),
})));
setError(''); setError('');
}) })
.catch((failure: Error) => setError(failure.message || '企业列表加载失败')); .catch((failure: Error) => setError(failure.message || '企业列表加载失败'));
@@ -47,7 +64,7 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
}, []); }, []);
const filteredRecords = useMemo(() => records.filter((record) => { const filteredRecords = useMemo(() => records.filter((record) => {
const matchId = filters.id ? record.id.includes(filters.id) || record.code.includes(filters.id) : true; const matchId = filters.id ? record.id.includes(filters.id) : true;
const matchName = filters.name ? record.name.includes(filters.name) : true; const matchName = filters.name ? record.name.includes(filters.name) : true;
const matchStatus = filters.status === 'all' ? true : record.status === filters.status; const matchStatus = filters.status === 'all' ? true : record.status === filters.status;
return matchId && matchName && matchStatus; return matchId && matchName && matchStatus;
@@ -58,23 +75,34 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
const totalBalance = records.reduce((sum, record) => sum + (record.account?.balanceCents ?? 0), 0); const totalBalance = records.reduce((sum, record) => sum + (record.account?.balanceCents ?? 0), 0);
const columns: Array<TableColumn<CustomerRow>> = [ const columns: Array<TableColumn<CustomerRow>> = [
{ key: 'id', title: '企业ID', width: '240px', render: (record) => <span className="table-mono-id">{record.id}</span> }, { key: 'id', title: '企业ID', width: '160px', render: (record) => <span className="table-mono-id">{record.id}</span> },
{ key: 'name', title: '企业名称', width: '260px', render: (record) => <strong className="table-strong-text">{record.name}</strong> }, { key: 'name', title: '企业名称', width: '260px', render: (record) => <strong className="table-strong-text">{record.name}</strong> },
{ key: 'code', title: '企业编码', width: '180px', render: (record) => <span className="table-mono-id">{record.code}</span> }, {
{ key: 'creditCode', title: '统一社会信用代码', width: '220px', render: (record) => record.enterpriseProfile?.creditCode || '-' }, key: 'balance',
{ key: 'contact', title: '联系人', width: '160px', render: (record) => record.enterpriseProfile?.contactName || '-' }, title: '当前余额',
{ key: 'phone', title: '联系电话', width: '150px', render: (record) => record.enterpriseProfile?.contactPhone || '-' }, width: '150px',
{ key: 'balance', title: '现金余额', width: '150px', align: 'right', render: (record) => `¥${((record.account?.balanceCents ?? 0) / 100).toLocaleString('zh-CN')}` }, align: 'right',
{ key: 'smsUnits', title: '短信余量', width: '150px', align: 'right', render: (record) => `${(record.account?.smsUnits ?? 0).toLocaleString('zh-CN')}` }, render: (record) => {
const balance = record.account?.balanceCents ?? 0;
return (
<span className={balance < 0 ? 'status-danger' : ''}>
¥{formatCurrency(balance)}
{balance < 0 ? <Tag tone="danger" className="enterprise-inline-tag"></Tag> : null}
</span>
);
},
},
{ key: 'overdraftLimit', title: '透支限额', width: '150px', align: 'right', render: (record) => `¥${formatCurrency(record.account?.creditCents ?? 0)}` },
{ key: 'todaySpend', title: '今日消费', width: '150px', align: 'right', render: (record) => `¥${formatCurrency(record.todaySpendCents)}` },
{ key: 'status', title: '企业状态', width: '130px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'warning'}>{record.status === 'active' ? '正常' : '已禁用'}</Tag> }, { key: 'status', title: '企业状态', width: '130px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'warning'}>{record.status === 'active' ? '正常' : '已禁用'}</Tag> },
{ {
key: 'actions', key: 'actions',
title: '操作', title: '操作',
align: 'right', align: 'right',
width: '280px', width: '300px',
render: (record) => ( render: (record) => (
<div className="table-actions"> <div className="table-actions">
<Button onClick={() => navigate(`${basePath}/${record.id}`)} size="sm" variant="ghost"></Button> <Button icon={<DollarSign size={15} />} onClick={() => openRechargeModal(record)} size="sm" variant="ghost"></Button>
<Button onClick={() => navigate(`${basePath}/${record.id}/edit`)} size="sm" variant="ghost"></Button> <Button onClick={() => navigate(`${basePath}/${record.id}/edit`)} size="sm" variant="ghost"></Button>
<Button onClick={() => setConfirmAction({ type: 'toggle', record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}> <Button onClick={() => setConfirmAction({ type: 'toggle', record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>
{record.status === 'active' ? '禁用' : '启用'} {record.status === 'active' ? '禁用' : '启用'}
@@ -85,6 +113,42 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
}, },
]; ];
function openRechargeModal(record: CustomerRow) {
setRechargeTarget(record);
setRechargeForm(emptyRechargeForm());
setRechargeError('');
}
function updateRechargeForm<K extends keyof RechargeForm>(key: K, value: RechargeForm[K]) {
setRechargeForm((current) => ({ ...current, [key]: value }));
setRechargeError('');
}
async function submitRecharge() {
if (!rechargeTarget) return;
const amount = Number(rechargeForm.amount);
if (!Number.isFinite(amount) || amount <= 0) {
setRechargeError('请填写大于 0 的充值金额');
return;
}
setRecharging(true);
try {
await adminApi.createManualRecharge({
tenantId: rechargeTarget.id,
amountCents: Math.round(amount * 100),
smsUnits: 0,
remark: [rechargeForm.operator, rechargeForm.remark].filter(Boolean).join(' / '),
});
setRechargeTarget(null);
setRechargeForm(emptyRechargeForm());
await loadData();
} catch (failure) {
setRechargeError(failure instanceof Error ? failure.message : '企业充值失败');
} finally {
setRecharging(false);
}
}
function submitConfirmAction() { function submitConfirmAction() {
if (!confirmAction) return; if (!confirmAction) return;
const action = confirmAction.type === 'delete' const action = confirmAction.type === 'delete'
@@ -108,13 +172,13 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
<div className="surface mini-status-card"><Building2 size={22} /><div><span></span><strong>{records.length}</strong><small></small></div></div> <div className="surface mini-status-card"><Building2 size={22} /><div><span></span><strong>{records.length}</strong><small></small></div></div>
<div className="surface mini-status-card"><TrendingUp size={22} /><div><span></span><strong>{activeCount}</strong><small></small></div></div> <div className="surface mini-status-card"><TrendingUp size={22} /><div><span></span><strong>{activeCount}</strong><small></small></div></div>
<div className="surface mini-status-card"><TrendingDown size={22} /><div><span></span><strong>{disabledCount}</strong><small></small></div></div> <div className="surface mini-status-card"><TrendingDown size={22} /><div><span></span><strong>{disabledCount}</strong><small></small></div></div>
<div className="surface mini-status-card"><DollarSign size={22} /><div><span></span><strong>¥{(totalBalance / 100).toLocaleString('zh-CN')}</strong><small></small></div></div> <div className="surface mini-status-card"><DollarSign size={22} /><div><span></span><strong>¥{formatCurrency(totalBalance)}</strong><small></small></div></div>
</div> </div>
<div className="surface ui-query-panel"> <div className="surface ui-query-panel">
<h2></h2> <h2></h2>
<div className="ui-query-panel__grid enterprise-query-grid"> <div className="ui-query-panel__grid enterprise-query-grid">
<Input label="企业ID/编码" onChange={(event) => setQueryId(event.target.value)} placeholder="请输入企业ID或编码" value={queryId} /> <Input label="企业ID" onChange={(event) => setQueryId(event.target.value)} placeholder="请输入企业ID" value={queryId} />
<Input label="企业名称" onChange={(event) => setQueryName(event.target.value)} placeholder="请输入企业名称" value={queryName} /> <Input label="企业名称" onChange={(event) => setQueryName(event.target.value)} placeholder="请输入企业名称" value={queryName} />
<Select label="企业状态" onChange={(event) => setQueryStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={queryStatus} /> <Select label="企业状态" onChange={(event) => setQueryStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={queryStatus} />
<div className="enterprise-query-actions"> <div className="enterprise-query-actions">
@@ -136,6 +200,29 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
onConfirm={submitConfirmAction} onConfirm={submitConfirmAction}
/> />
) : null} ) : null}
{rechargeTarget ? (
<Modal
footer={(
<>
<Button disabled={recharging} onClick={() => setRechargeTarget(null)} variant="ghost"></Button>
<Button disabled={recharging} onClick={() => { void submitRecharge(); }}>{recharging ? '充值中...' : '确认充值'}</Button>
</>
)}
onClose={() => setRechargeTarget(null)}
open
size="md"
title="企业人工充值"
>
<div className="admin-system-modal-form">
<Input disabled label="企业名称" value={rechargeTarget.name} />
<Input disabled label="当前余额" prefix="¥" value={formatCurrency(rechargeTarget.account?.balanceCents ?? 0)} />
<Input label="充值金额" onChange={(event) => updateRechargeForm('amount', event.target.value)} prefix="¥" required type="number" value={rechargeForm.amount} />
<Input label="操作人" onChange={(event) => updateRechargeForm('operator', event.target.value)} value={rechargeForm.operator} />
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateRechargeForm('remark', event.target.value)} rows={4} value={rechargeForm.remark} />
</div>
{rechargeError ? <p className="form-error">{rechargeError}</p> : null}
</Modal>
) : null}
</section> </section>
); );
} }
@@ -462,7 +462,7 @@ function mapApplication(application: EnterpriseApplication): SmsApp {
} }
function mapConnection(connection: CmppConnectionState): CmppConnection { function mapConnection(connection: CmppConnectionState): CmppConnection {
const isOpen = ['online', 'connected', 'open'].includes(connection.status) && connection.currentConnections > 0; const isOpen = connection.status === 'connected' && connection.currentConnections > 0;
return { return {
id: connection.connectionId, id: connection.connectionId,
state: isOpen ? 'open' : connection.status === 'reconnecting' ? 'reconnecting' : 'closed', state: isOpen ? 'open' : connection.status === 'reconnecting' ? 'reconnecting' : 'closed',
+236 -17
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Edit3, FileText, Plus, Search, Trash2 } from 'lucide-react'; import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type TenantOption } from '@/api/adminApi'; import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Tabs, Tag, Textarea } from '@/components/ui'; import { Breadcrumb, Button, FileActions, Input, Modal, Select, Tabs, Tag, Textarea } from '@/components/ui';
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing'; type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
@@ -9,6 +9,16 @@ type DrainageInfo = {
id: string; id: string;
siteName: string; siteName: string;
url: string; url: string;
field1File?: UploadedFileRef | null;
field2?: string;
field3?: string;
field4?: string;
field5?: string;
field6?: string;
field7File?: UploadedFileRef | null;
field8?: string;
field9?: string;
field10?: string;
mobile: CarrierStatus; mobile: CarrierStatus;
unicom: CarrierStatus; unicom: CarrierStatus;
telecom: CarrierStatus; telecom: CarrierStatus;
@@ -16,11 +26,30 @@ type DrainageInfo = {
remark: string; remark: string;
}; };
type UploadedFileRef = FileRef;
type SignatureProfile = {
basis: string;
companyName: string;
creditCode: string;
legalPersonName: string;
legalPersonIdCard: string;
responsibleName: string;
responsiblePhone: string;
responsibleIdCard: string;
credentialFile?: UploadedFileRef | null;
legalFrontFile?: UploadedFileRef | null;
legalBackFile?: UploadedFileRef | null;
responsibleFrontFile?: UploadedFileRef | null;
responsibleBackFile?: UploadedFileRef | null;
};
type SignatureFormState = { type SignatureFormState = {
tenantId: string; tenantId: string;
applicationId: string; applicationId: string;
name: string; name: string;
purpose: string; purpose: string;
profile: SignatureProfile;
mobile: CarrierStatus; mobile: CarrierStatus;
unicom: CarrierStatus; unicom: CarrierStatus;
telecom: CarrierStatus; telecom: CarrierStatus;
@@ -54,6 +83,7 @@ function StatusTag({ status }: { status: CarrierStatus }) {
function readDrainagePayload(signature: ClientSmsSignature) { function readDrainagePayload(signature: ClientSmsSignature) {
const payload = signature.drainageInfo && typeof signature.drainageInfo === 'object' ? signature.drainageInfo : {}; const payload = signature.drainageInfo && typeof signature.drainageInfo === 'object' ? signature.drainageInfo : {};
const carrierStatus = typeof payload.carrierStatus === 'object' && payload.carrierStatus ? payload.carrierStatus as Record<string, unknown> : {}; const carrierStatus = typeof payload.carrierStatus === 'object' && payload.carrierStatus ? payload.carrierStatus as Record<string, unknown> : {};
const profile = typeof payload.signatureProfile === 'object' && payload.signatureProfile ? payload.signatureProfile as Record<string, unknown> : {};
const links = Array.isArray(payload.links) ? payload.links as Array<Record<string, unknown>> : []; const links = Array.isArray(payload.links) ? payload.links as Array<Record<string, unknown>> : [];
const fallbackStatus = normalizeCarrierStatus(signature.auditStatus); const fallbackStatus = normalizeCarrierStatus(signature.auditStatus);
return { return {
@@ -62,10 +92,21 @@ function readDrainagePayload(signature: ClientSmsSignature) {
unicom: normalizeCarrierStatus(carrierStatus.unicom, fallbackStatus), unicom: normalizeCarrierStatus(carrierStatus.unicom, fallbackStatus),
telecom: normalizeCarrierStatus(carrierStatus.telecom, fallbackStatus), telecom: normalizeCarrierStatus(carrierStatus.telecom, fallbackStatus),
}, },
signatureProfile: normalizeSignatureProfile(profile, signature),
links: links.map((item) => ({ links: links.map((item) => ({
id: String(item.id ?? `drain-${Date.now()}`), id: String(item.id ?? `drain-${Date.now()}`),
siteName: String(item.siteName ?? ''), siteName: String(item.siteName ?? ''),
url: String(item.url ?? ''), url: String(item.url ?? ''),
field1File: normalizeUploadedFile(item.field1File),
field2: String(item.field2 ?? ''),
field3: String(item.field3 ?? ''),
field4: String(item.field4 ?? ''),
field5: String(item.field5 ?? ''),
field6: String(item.field6 ?? ''),
field7File: normalizeUploadedFile(item.field7File),
field8: String(item.field8 ?? ''),
field9: String(item.field9 ?? ''),
field10: String(item.field10 ?? ''),
mobile: normalizeCarrierStatus(item.mobile, 'filing'), mobile: normalizeCarrierStatus(item.mobile, 'filing'),
unicom: normalizeCarrierStatus(item.unicom, 'filing'), unicom: normalizeCarrierStatus(item.unicom, 'filing'),
telecom: normalizeCarrierStatus(item.telecom, 'filing'), telecom: normalizeCarrierStatus(item.telecom, 'filing'),
@@ -75,8 +116,54 @@ function readDrainagePayload(signature: ClientSmsSignature) {
}; };
} }
function buildDrainagePayload(carrierStatus: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }, links: DrainageInfo[]) { function buildDrainagePayload(carrierStatus: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }, links: DrainageInfo[], signatureProfile?: SignatureProfile) {
return { carrierStatus, links }; return { carrierStatus, links, signatureProfile };
}
function normalizeUploadedFile(value: unknown): UploadedFileRef | null {
if (!value || typeof value !== 'object') return null;
const item = value as Record<string, unknown>;
const fileObjectId = String(item.fileObjectId ?? '');
const fileName = String(item.fileName ?? '');
const contentType = typeof item.contentType === 'string' ? item.contentType : undefined;
return fileObjectId || fileName ? { contentType, fileObjectId, fileName } : null;
}
function emptySignatureProfile(signature?: ClientSmsSignature): SignatureProfile {
return {
basis: '',
companyName: signature?.tenant?.name ?? '',
creditCode: '',
legalPersonName: '',
legalPersonIdCard: '',
responsibleName: '',
responsiblePhone: '',
responsibleIdCard: '',
credentialFile: null,
legalFrontFile: null,
legalBackFile: null,
responsibleFrontFile: null,
responsibleBackFile: null,
};
}
function normalizeSignatureProfile(value: Record<string, unknown>, signature?: ClientSmsSignature): SignatureProfile {
return {
...emptySignatureProfile(signature),
basis: String(value.basis ?? ''),
companyName: String(value.companyName ?? signature?.tenant?.name ?? ''),
creditCode: String(value.creditCode ?? ''),
legalPersonName: String(value.legalPersonName ?? ''),
legalPersonIdCard: String(value.legalPersonIdCard ?? ''),
responsibleName: String(value.responsibleName ?? ''),
responsiblePhone: String(value.responsiblePhone ?? ''),
responsibleIdCard: String(value.responsibleIdCard ?? ''),
credentialFile: normalizeUploadedFile(value.credentialFile),
legalFrontFile: normalizeUploadedFile(value.legalFrontFile),
legalBackFile: normalizeUploadedFile(value.legalBackFile),
responsibleFrontFile: normalizeUploadedFile(value.responsibleFrontFile),
responsibleBackFile: normalizeUploadedFile(value.responsibleBackFile),
};
} }
function normalizeCarrierStatus(value: unknown, fallback: CarrierStatus = 'filing'): CarrierStatus { function normalizeCarrierStatus(value: unknown, fallback: CarrierStatus = 'filing'): CarrierStatus {
@@ -91,6 +178,53 @@ function formatDate(value?: string) {
return value ? new Date(value).toLocaleString('zh-CN') : '-'; return value ? new Date(value).toLocaleString('zh-CN') : '-';
} }
function SignatureUploadBox({
compact = false,
file,
label,
onUploaded,
}: {
compact?: boolean;
file?: UploadedFileRef | null;
label: string;
onUploaded: (file: UploadedFileRef) => void;
}) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
async function uploadFile(fileInput: File | undefined) {
if (!fileInput) return;
setUploading(true);
setError('');
try {
const fileObject = await adminApi.uploadFileObject(fileInput, { purpose: 'signature_report_material', prefix: 'signature-report-materials' });
onUploaded({ contentType: fileObject.contentType, fileObjectId: fileObject.id, fileName: fileObject.fileName });
} catch (failure) {
setError(failure instanceof Error ? failure.message : '文件上传失败');
} finally {
setUploading(false);
}
}
return (
<label className={compact ? 'signature-upload signature-upload--compact' : 'signature-upload'}>
<span>{label}</span>
<Upload size={compact ? 30 : 42} />
<strong>{uploading ? '上传中...' : file?.fileName || (compact ? '上传文件' : '点击上传 或拖拽文件到此处')}</strong>
<FileActions file={file} />
{!compact ? <small> PNGJPGJPEGPDF 10M</small> : null}
{error ? <small className="form-error">{error}</small> : null}
<input
accept="image/png,image/jpeg,application/pdf"
disabled={uploading}
onChange={(event) => { void uploadFile(event.target.files?.[0]); }}
style={{ display: 'none' }}
type="file"
/>
</label>
);
}
function SignatureFormModal({ function SignatureFormModal({
applications, applications,
item, item,
@@ -110,6 +244,7 @@ function SignatureFormModal({
applicationId: item?.applicationId ?? '', applicationId: item?.applicationId ?? '',
name: item?.name ?? '', name: item?.name ?? '',
purpose: item?.purpose ?? '', purpose: item?.purpose ?? '',
profile: payload?.signatureProfile ?? emptySignatureProfile(item),
mobile: payload?.carrierStatus.mobile ?? 'filing', mobile: payload?.carrierStatus.mobile ?? 'filing',
unicom: payload?.carrierStatus.unicom ?? 'filing', unicom: payload?.carrierStatus.unicom ?? 'filing',
telecom: payload?.carrierStatus.telecom ?? 'filing', telecom: payload?.carrierStatus.telecom ?? 'filing',
@@ -120,6 +255,10 @@ function SignatureFormModal({
setForm((current) => ({ ...current, [key]: value })); setForm((current) => ({ ...current, [key]: value }));
} }
function updateProfile<Key extends keyof SignatureProfile>(key: Key, value: SignatureProfile[Key]) {
setForm((current) => ({ ...current, profile: { ...current.profile, [key]: value } }));
}
return ( return (
<Modal <Modal
footer={( footer={(
@@ -131,11 +270,20 @@ function SignatureFormModal({
onClose={onClose} onClose={onClose}
open open
size="xl" size="xl"
title={item ? '编辑短信签名' : '添加短信签名'} title={(
<div className="signature-modal-title">
<h2>{item ? '编辑签名' : '添加签名'}</h2>
<p>{item ? '修改短信签名的相关信息' : '新增短信签名的相关信息'}</p>
</div>
)}
> >
<div className="signature-form"> <div className="signature-form">
<section> <section>
<h3></h3> <h3></h3>
<div className="signature-alert">
<Info size={18} />
<span>使 PNGJPGJPEG PDF </span>
</div>
<div className="signature-form-grid"> <div className="signature-form-grid">
<Select <Select
disabled={Boolean(item)} disabled={Boolean(item)}
@@ -149,7 +297,7 @@ function SignatureFormModal({
value={form.tenantId} value={form.tenantId}
/> />
<Select <Select
label="所属应用" label="* 应用名称"
onChange={(event) => update('applicationId', event.target.value)} onChange={(event) => update('applicationId', event.target.value)}
options={[ options={[
{ label: '不绑定应用', value: '' }, { label: '不绑定应用', value: '' },
@@ -157,8 +305,46 @@ function SignatureFormModal({
]} ]}
value={form.applicationId} value={form.applicationId}
/> />
<Input label="短信签名" onChange={(event) => update('name', event.target.value)} placeholder="例如【某某科技】" required value={form.name} /> <Select
<Input label="签名用途" onChange={(event) => update('purpose', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.purpose} /> label="* 签名依据"
onChange={(event) => updateProfile('basis', event.target.value)}
options={[
{ label: '请选择签名依据', value: '' },
{ label: '企事业单位证明', value: 'company' },
{ label: '商标注册证', value: 'trademark' },
{ label: '授权委托书', value: 'authorization' },
]}
value={form.profile.basis}
/>
<Input label="* 短信签名" onChange={(event) => update('name', event.target.value)} placeholder="请输入短信签名,如【XXXX公司】" required value={form.name} />
</div>
<SignatureUploadBox
file={form.profile.credentialFile}
label="* 资质凭证"
onUploaded={(file) => updateProfile('credentialFile', file)}
/>
</section>
<section>
<h3></h3>
<div className="signature-form-grid">
<Input label="* 公司名称" onChange={(event) => updateProfile('companyName', event.target.value)} placeholder="请输入公司名称" value={form.profile.companyName} />
<Input label="* 统一社会信用代码" onChange={(event) => updateProfile('creditCode', event.target.value)} placeholder="请输入统一社会信用代码" value={form.profile.creditCode} />
<Input label="* 法人姓名" onChange={(event) => updateProfile('legalPersonName', event.target.value)} placeholder="请输入法人姓名" value={form.profile.legalPersonName} />
<Input label="法人身份证号" onChange={(event) => updateProfile('legalPersonIdCard', event.target.value)} placeholder="请输入法人身份证号" value={form.profile.legalPersonIdCard} />
<SignatureUploadBox compact file={form.profile.legalFrontFile} label="法人身份证照片-人像面" onUploaded={(file) => updateProfile('legalFrontFile', file)} />
<SignatureUploadBox compact file={form.profile.legalBackFile} label="法人身份证照片-国徽面" onUploaded={(file) => updateProfile('legalBackFile', file)} />
</div>
</section>
<section>
<h3></h3>
<div className="signature-form-grid">
<Input label="* 责任人姓名" onChange={(event) => updateProfile('responsibleName', event.target.value)} placeholder="请输入责任人姓名" value={form.profile.responsibleName} />
<Input label="* 责任人手机号" onChange={(event) => updateProfile('responsiblePhone', event.target.value)} placeholder="请输入责任人手机号" value={form.profile.responsiblePhone} />
<Input className="signature-form-grid__wide" label="* 责任人身份证号" onChange={(event) => updateProfile('responsibleIdCard', event.target.value)} placeholder="请输入责任人身份证号" value={form.profile.responsibleIdCard} />
<SignatureUploadBox compact file={form.profile.responsibleFrontFile} label="责任人身份证照片-人像面" onUploaded={(file) => updateProfile('responsibleFrontFile', file)} />
<SignatureUploadBox compact file={form.profile.responsibleBackFile} label="责任人身份证照片-国徽面" onUploaded={(file) => updateProfile('responsibleBackFile', file)} />
</div> </div>
</section> </section>
@@ -180,6 +366,16 @@ function DrainageFormModal({ item, onClose, onSubmit }: { item?: DrainageInfo; o
id: `drain-${Date.now()}`, id: `drain-${Date.now()}`,
siteName: '', siteName: '',
url: '', url: '',
field1File: null,
field2: '',
field3: '',
field4: '',
field5: '',
field6: '',
field7File: null,
field8: '',
field9: '',
field10: '',
mobile: 'filing', mobile: 'filing',
unicom: 'filing', unicom: 'filing',
telecom: 'filing', telecom: 'filing',
@@ -196,20 +392,43 @@ function DrainageFormModal({ item, onClose, onSubmit }: { item?: DrainageInfo; o
footer={( footer={(
<> <>
<Button onClick={onClose} variant="ghost"></Button> <Button onClick={onClose} variant="ghost"></Button>
<Button disabled={!form.siteName || !form.url} onClick={() => onSubmit(form)}></Button> <Button disabled={!form.url} onClick={() => onSubmit(form)}></Button>
</> </>
)} )}
onClose={onClose} onClose={onClose}
open open
size="xl" size="xl"
title={item ? '编辑引流链接' : '添加引流链接'} title={item ? '编辑引流信息' : '添加引流信息'}
> >
<div className="signature-form drainage-edit-form"> <div className="signature-form drainage-edit-form">
<section> <section>
<h3></h3> <h3></h3>
<Input
label="* 引流信息"
onChange={(event) => update('url', event.target.value)}
placeholder="请输入引流网址"
required
value={form.url}
/>
<div className="signature-alert drainage-form-note">
<Info size={18} />
<ol>
<li></li>
<li> PNGJPG JPEG 3M</li>
<li> PDF 10M</li>
</ol>
</div>
<div className="signature-form-grid"> <div className="signature-form-grid">
<Input label="名称" onChange={(event) => update('siteName', event.target.value)} placeholder="请输入站点名称" required value={form.siteName} /> <SignatureUploadBox compact file={form.field1File} label="* 字段名称1" onUploaded={(file) => update('field1File', file)} />
<Input label="网站链接" onChange={(event) => update('url', event.target.value)} placeholder="https://example.com" required value={form.url} /> <Input label="* 字段名称2" onChange={(event) => update('field2', event.target.value)} placeholder="请输入字段2内容" value={form.field2 ?? ''} />
<Input label="* 字段名称3" onChange={(event) => { update('field3', event.target.value); update('siteName', event.target.value); }} placeholder="请输入公司名称" value={form.field3 ?? form.siteName} />
<Input label="字段名称4" onChange={(event) => update('field4', event.target.value)} placeholder="请输入统一社会信用代码" value={form.field4 ?? ''} />
<Input label="* 字段名称5" onChange={(event) => update('field5', event.target.value)} placeholder="请输入法人姓名" value={form.field5 ?? ''} />
<Input label="字段名称6" onChange={(event) => update('field6', event.target.value)} placeholder="请输入法人身份证号" value={form.field6 ?? ''} />
<SignatureUploadBox compact file={form.field7File} label="字段名称7" onUploaded={(file) => update('field7File', file)} />
<Input label="* 字段名称8" onChange={(event) => update('field8', event.target.value)} placeholder="请输入责任人身份证号" value={form.field8 ?? ''} />
<Input label="* 字段名称9" onChange={(event) => update('field9', event.target.value)} placeholder="请输入责任人姓名" value={form.field9 ?? ''} />
<Input label="* 字段名称10" onChange={(event) => update('field10', event.target.value)} placeholder="请输入责任人手机号" value={form.field10 ?? ''} />
<Select label="移动状态" onChange={(event) => update('mobile', event.target.value as CarrierStatus)} options={statusOptions} value={form.mobile} /> <Select label="移动状态" onChange={(event) => update('mobile', event.target.value as CarrierStatus)} options={statusOptions} value={form.mobile} />
<Select label="联通状态" onChange={(event) => update('unicom', event.target.value as CarrierStatus)} options={statusOptions} value={form.unicom} /> <Select label="联通状态" onChange={(event) => update('unicom', event.target.value as CarrierStatus)} options={statusOptions} value={form.unicom} />
<Select label="电信状态" onChange={(event) => update('telecom', event.target.value as CarrierStatus)} options={statusOptions} value={form.telecom} /> <Select label="电信状态" onChange={(event) => update('telecom', event.target.value as CarrierStatus)} options={statusOptions} value={form.telecom} />
@@ -326,7 +545,7 @@ export function AdminEnterpriseSignaturesPage() {
mobile: state.mobile, mobile: state.mobile,
unicom: state.unicom, unicom: state.unicom,
telecom: state.telecom, telecom: state.telecom,
}, existingPayload.links); }, existingPayload.links, state.profile);
try { try {
if (existing) { if (existing) {
await adminApi.updateEnterpriseSignature(existing.id, { await adminApi.updateEnterpriseSignature(existing.id, {
@@ -362,7 +581,7 @@ export function AdminEnterpriseSignaturesPage() {
? payload.links.map((current) => current.id === item.id ? item : current) ? payload.links.map((current) => current.id === item.id ? item : current)
: [item, ...payload.links]; : [item, ...payload.links];
await adminApi.updateEnterpriseSignature(signatureId, { await adminApi.updateEnterpriseSignature(signatureId, {
drainageInfo: buildDrainagePayload(payload.carrierStatus, links), drainageInfo: buildDrainagePayload(payload.carrierStatus, links, payload.signatureProfile),
}); });
setDrainageModal(null); setDrainageModal(null);
setExpandedSignatureId(signatureId); setExpandedSignatureId(signatureId);
@@ -380,7 +599,7 @@ export function AdminEnterpriseSignaturesPage() {
if (signature) { if (signature) {
const payload = readDrainagePayload(signature); const payload = readDrainagePayload(signature);
await adminApi.updateEnterpriseSignature(signature.id, { await adminApi.updateEnterpriseSignature(signature.id, {
drainageInfo: buildDrainagePayload(payload.carrierStatus, payload.links.filter((item) => item.id !== deleteTarget.id)), drainageInfo: buildDrainagePayload(payload.carrierStatus, payload.links.filter((item) => item.id !== deleteTarget.id), payload.signatureProfile),
}); });
} }
} }
+44 -7
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Plus, Search } from 'lucide-react'; import { Plus, RadioTower, Search, Smartphone } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Table, type TableColumn } from '@/components/ui'; import { Breadcrumb, Button, Input, Modal, Select, Table, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem } from '@/api/adminApi'; import { adminApi, type DictionaryItem } from '@/api/adminApi';
@@ -53,6 +53,11 @@ export function AdminPhoneSegmentsPage() {
[keyword, segments], [keyword, segments],
); );
const filteredRules = useMemo(
() => rules.filter((rule) => [rule.carrier, rule.pattern, rule.remark].some((value) => String(value ?? '').includes(keyword))),
[keyword, rules],
);
function createSegment() { function createSegment() {
adminApi.createPhoneSegment({ prefix, carrier, province, city }) adminApi.createPhoneSegment({ prefix, carrier, province, city })
.then(() => { .then(() => {
@@ -101,12 +106,44 @@ export function AdminPhoneSegmentsPage() {
</div> </div>
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-system-toolbar"> <div className="phone-segment-overview">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索手机号段、运营商、省份或城市" prefix={<Search size={16} />} value={keyword} /> <section>
<div className="segmented-control"> <span><Smartphone size={20} /></span>
<button className={activeTab === 'segments' ? 'is-active' : ''} onClick={() => setActiveTab('segments')} type="button"></button> <div>
<button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setActiveTab('rules')} type="button"></button> <strong>{segments.length}</strong>
<p></p>
</div> </div>
</section>
<section>
<span><RadioTower size={20} /></span>
<div>
<strong>{rules.length}</strong>
<p></p>
</div>
</section>
</div>
<div className="surface phone-segment-tabs">
<button className={activeTab === 'segments' ? 'is-active' : ''} onClick={() => setActiveTab('segments')} type="button">
<Smartphone size={18} />
<span>
<strong></strong>
<small> 7 </small>
</span>
<em>{segments.length}</em>
</button>
<button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setActiveTab('rules')} type="button">
<RadioTower size={18} />
<span>
<strong></strong>
<small></small>
</span>
<em>{rules.length}</em>
</button>
</div>
<div className="surface admin-system-toolbar phone-segment-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '搜索手机号段、运营商、省份或城市' : '搜索运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} />
<Button icon={<Plus size={16} />} onClick={() => activeTab === 'segments' ? setCreating(true) : setCreatingRule(true)}> <Button icon={<Plus size={16} />} onClick={() => activeTab === 'segments' ? setCreating(true) : setCreatingRule(true)}>
{activeTab === 'segments' ? '新增号段' : '新增规则'} {activeTab === 'segments' ? '新增号段' : '新增规则'}
</Button> </Button>
@@ -115,7 +152,7 @@ export function AdminPhoneSegmentsPage() {
<div className="surface admin-system-table-card"> <div className="surface admin-system-table-card">
{activeTab === 'segments' {activeTab === 'segments'
? <Table columns={columns} data={filteredSegments} emptyText="暂无手机号段" rowKey="id" /> ? <Table columns={columns} data={filteredSegments} emptyText="暂无手机号段" rowKey="id" />
: <Table columns={ruleColumns} data={rules} emptyText="暂无运营商区分规则" rowKey="id" />} : <Table columns={ruleColumns} data={filteredRules} emptyText="暂无运营商区分规则" rowKey="id" />}
</div> </div>
<Modal <Modal
+63 -19
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Download, Eye, FileUp, Search } from 'lucide-react'; import { Download, Eye, FileUp, Search } from 'lucide-react';
import { adminApi, type ReportTask } from '@/api/adminApi'; import { adminApi, type FileObject, type FileRef, type ReportTask } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui'; import { Breadcrumb, Button, DateRangeInput, FileActions, Input, Modal, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger' }> = { const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger' }> = {
pending: { label: '待处理', tone: 'neutral' }, pending: { label: '待处理', tone: 'neutral' },
@@ -13,12 +13,59 @@ const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'su
failed: { label: '有失败', tone: 'danger' }, failed: { label: '有失败', tone: 'danger' },
}; };
function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubmit: (file: File, remark: string) => void }) { type ReceiptImportPayload = {
delimiter: ',' | '\t';
fileContent: string;
fileName: string;
fileObjectId: string;
remark: string;
};
function ReceiptImportModal({ onClose, onSubmit, task }: { onClose: () => void; onSubmit: (payload: ReceiptImportPayload) => void; task: ReportTask }) {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [fileObject, setFileObject] = useState<FileObject | null>(null);
const [uploading, setUploading] = useState(false);
const [remark, setRemark] = useState(''); const [remark, setRemark] = useState('');
const [error, setError] = useState('');
const fileRef: FileRef | null = fileObject
? { contentType: fileObject.contentType, fileName: fileObject.fileName, fileObjectId: fileObject.id }
: null;
async function uploadReceiptFile(nextFile: File | undefined) {
setFile(nextFile ?? null);
setFileObject(null);
setError('');
if (!nextFile) {
return;
}
setUploading(true);
try {
const uploaded = await adminApi.uploadFileObject(nextFile, { purpose: 'report_receipt', prefix: `report-receipts/${task.id}` });
setFileObject(uploaded);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备回执上传失败');
} finally {
setUploading(false);
}
}
async function submitImport() {
if (!file || !fileObject) {
return;
}
const delimiter: ',' | '\t' = file.name.toLowerCase().endsWith('.tsv') ? '\t' : ',';
onSubmit({
delimiter,
fileContent: await file.text(),
fileName: file.name,
fileObjectId: fileObject.id,
remark,
});
}
return ( return (
<Modal <Modal
footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!file} onClick={() => file && onSubmit(file, remark)}></Button></>} footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!fileObject || uploading} onClick={() => { void submitImport(); }}>{uploading ? '上传中...' : '确认导入'}</Button></>}
onClose={onClose} onClose={onClose}
open open
size="xl" size="xl"
@@ -29,13 +76,15 @@ function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubm
<FileUp size={38} /> <FileUp size={38} />
<strong>{file?.name || '选择回执文件'}</strong> <strong>{file?.name || '选择回执文件'}</strong>
<span> CSVTSVTXT /</span> <span> CSVTSVTXT /</span>
<FileActions file={fileRef} />
<input <input
accept=".csv,.tsv,.txt,text/csv,text/plain" accept=".csv,.tsv,.txt,text/csv,text/plain"
onChange={(event) => setFile(event.target.files?.[0] ?? null)} onChange={(event) => { void uploadReceiptFile(event.target.files?.[0]); }}
style={{ display: 'none' }} style={{ display: 'none' }}
type="file" type="file"
/> />
</label> </label>
{error ? <p className="form-error">{error}</p> : null}
<Textarea label="导入备注" onChange={(event) => setRemark(event.target.value)} placeholder="记录回执来源、运营商工单号或人工处理说明" rows={4} value={remark} /> <Textarea label="导入备注" onChange={(event) => setRemark(event.target.value)} placeholder="记录回执来源、运营商工单号或人工处理说明" rows={4} value={remark} />
</div> </div>
</Modal> </Modal>
@@ -92,20 +141,15 @@ export function AdminReportTasksPage() {
.catch((failure: Error) => setError(failure.message || '报备任务导出失败')); .catch((failure: Error) => setError(failure.message || '报备任务导出失败'));
} }
function importReceipt(file: File, remark: string) { function importReceipt(payload: ReceiptImportPayload) {
if (!receiptTask) return; if (!receiptTask) return;
const delimiter = file.name.toLowerCase().endsWith('.tsv') ? '\t' : ','; adminApi.importReportReceipt(receiptTask.id, {
Promise.all([ delimiter: payload.delimiter,
adminApi.uploadFileObject(file, { purpose: 'report_receipt', prefix: `report-receipts/${receiptTask.id}` }), fileContent: payload.fileContent,
file.text(), fileName: payload.fileName,
]) fileObjectId: payload.fileObjectId,
.then(([fileObject, fileContent]) => adminApi.importReportReceipt(receiptTask.id, { reason: payload.remark,
fileObjectId: fileObject.id, })
fileName: file.name,
fileContent,
delimiter,
reason: remark,
}))
.then(() => { .then(() => {
setReceiptTask(null); setReceiptTask(null);
loadData(); loadData();
@@ -156,7 +200,7 @@ export function AdminReportTasksPage() {
<Table columns={columns} data={filteredTasks} emptyText="暂无报备任务" rowKey="id" /> <Table columns={columns} data={filteredTasks} emptyText="暂无报备任务" rowKey="id" />
</div> </div>
{receiptTask ? <ReceiptImportModal onClose={() => setReceiptTask(null)} onSubmit={importReceipt} /> : null} {receiptTask ? <ReceiptImportModal onClose={() => setReceiptTask(null)} onSubmit={importReceipt} task={receiptTask} /> : null}
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null} {detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
</section> </section>
); );
+1 -1
View File
@@ -146,7 +146,7 @@ export function ClientApplicationsPage() {
<dd>{formatPrice(application.customerUnitPrice)}</dd> <dd>{formatPrice(application.customerUnitPrice)}</dd>
</div> </div>
<div> <div>
<dt>CMPP接状态</dt> <dt>CMPP接状态</dt>
<dd><Tag tone={statusToneMap[linkStatus]}>{statusLabelMap[linkStatus]}</Tag></dd> <dd><Tag tone={statusToneMap[linkStatus]}>{statusLabelMap[linkStatus]}</Tag></dd>
</div> </div>
</dl> </dl>
+8 -2
View File
@@ -8,8 +8,8 @@ import {
Upload, Upload,
UserCheck, UserCheck,
} from 'lucide-react'; } from 'lucide-react';
import { Button, Input, Select, Textarea } from '@/components/ui'; import { Button, FileActions, Input, Select, Textarea } from '@/components/ui';
import { clientApi, type EnterpriseCertification, type FileObject } from '@/api/adminApi'; import { clientApi, type EnterpriseCertification, type FileObject, type FileRef } from '@/api/adminApi';
type AuthStep = 'overview' | 'profile' | 'method' | 'recharge' | 'face' | 'faceScan' | 'pending' | 'success' | 'failed'; type AuthStep = 'overview' | 'profile' | 'method' | 'recharge' | 'face' | 'faceScan' | 'pending' | 'success' | 'failed';
type AuthMethod = 'face' | 'recharge'; type AuthMethod = 'face' | 'recharge';
@@ -52,10 +52,15 @@ const emptyCertificationForm: CertificationForm = {
}; };
function UploadPanel({ file, uploading, onFile }: { file: FileObject | null; uploading: boolean; onFile: (file: File | undefined) => void }) { function UploadPanel({ file, uploading, onFile }: { file: FileObject | null; uploading: boolean; onFile: (file: File | undefined) => void }) {
const fileRef: FileRef | null = file
? { contentType: file.contentType, fileName: file.fileName, fileObjectId: file.id }
: null;
return ( return (
<label className="enterprise-upload"> <label className="enterprise-upload">
<Upload size={38} /> <Upload size={38} />
<strong>{uploading ? '上传中...' : file?.fileName ?? '点击上传'}</strong> <strong>{uploading ? '上传中...' : file?.fileName ?? '点击上传'}</strong>
<FileActions file={fileRef} />
<input <input
accept="image/png,image/jpeg,image/webp,application/pdf" accept="image/png,image/jpeg,image/webp,application/pdf"
disabled={uploading} disabled={uploading}
@@ -203,6 +208,7 @@ export function ClientEnterpriseAuthPage() {
materials: { materials: {
licenseFileObjectId: licenseFile?.id, licenseFileObjectId: licenseFile?.id,
licenseFileName: licenseFile?.fileName, licenseFileName: licenseFile?.fileName,
licenseFileContentType: licenseFile?.contentType,
province: form.province, province: form.province,
city: form.city, city: form.city,
address: form.address, address: form.address,
+31 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Check, FileText, Plus, Search, Send, Trash2 } from 'lucide-react'; import { Check, Download, FileText, Plus, Search, Send, Trash2 } from 'lucide-react';
import { Button, DateTimeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui'; import { Button, DateTimeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type ImportPreviewResponse, type SmsBatchTask } from '@/api/adminApi'; import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type ImportPreviewResponse, type SmsBatchTask } from '@/api/adminApi';
@@ -29,6 +29,7 @@ export function ClientSendPage() {
const [recipients, setRecipients] = useState<Recipient[]>([{ id: '1', phone: '' }]); const [recipients, setRecipients] = useState<Recipient[]>([{ id: '1', phone: '' }]);
const [importContent, setImportContent] = useState(''); const [importContent, setImportContent] = useState('');
const [importFileName, setImportFileName] = useState(''); const [importFileName, setImportFileName] = useState('');
const [importFileUrl, setImportFileUrl] = useState('');
const [importPreview, setImportPreview] = useState<ImportPreviewResponse | null>(null); const [importPreview, setImportPreview] = useState<ImportPreviewResponse | null>(null);
const [importLoading, setImportLoading] = useState(false); const [importLoading, setImportLoading] = useState(false);
const [submittedRecord, setSubmittedRecord] = useState<SmsBatchTask | null>(null); const [submittedRecord, setSubmittedRecord] = useState<SmsBatchTask | null>(null);
@@ -44,6 +45,12 @@ export function ClientSendPage() {
.catch((reason: Error) => setError(reason.message || '短信发送基础数据加载失败')); .catch((reason: Error) => setError(reason.message || '短信发送基础数据加载失败'));
}, []); }, []);
useEffect(() => () => {
if (importFileUrl) {
URL.revokeObjectURL(importFileUrl);
}
}, [importFileUrl]);
const selectedSignature = useMemo( const selectedSignature = useMemo(
() => signatures.find((item) => item.id === signatureId), () => signatures.find((item) => item.id === signatureId),
[signatures, signatureId], [signatures, signatureId],
@@ -133,10 +140,22 @@ export function ClientSendPage() {
}); });
setImportContent(content); setImportContent(content);
setImportFileName(file.name); setImportFileName(file.name);
setImportFileUrl((current) => {
if (current) {
URL.revokeObjectURL(current);
}
return URL.createObjectURL(file);
});
setImportPreview(preview); setImportPreview(preview);
} catch (reason) { } catch (reason) {
setImportContent(''); setImportContent('');
setImportFileName(''); setImportFileName('');
setImportFileUrl((current) => {
if (current) {
URL.revokeObjectURL(current);
}
return '';
});
setImportPreview(null); setImportPreview(null);
setError(reason instanceof Error ? reason.message : '导入预览失败'); setError(reason instanceof Error ? reason.message : '导入预览失败');
} finally { } finally {
@@ -294,7 +313,17 @@ export function ClientSendPage() {
<Button disabled={importLoading || !templateId} onClick={() => document.getElementById('sms-import-file')?.click()} variant="ghost"> <Button disabled={importLoading || !templateId} onClick={() => document.getElementById('sms-import-file')?.click()} variant="ghost">
{importLoading ? '解析中...' : '选择文件'} {importLoading ? '解析中...' : '选择文件'}
</Button> </Button>
{importFileName ? <span>{importFileName}</span> : null} {importFileName ? (
<span>
{importFileName}
{importFileUrl ? (
<a className="file-action-link" download={importFileName} href={importFileUrl}>
<Download size={14} />
</a>
) : null}
</span>
) : null}
{importPreview ? ( {importPreview ? (
<div className="detail-grid"> <div className="detail-grid">
<div><span></span><strong>{importPreview.totalRows}</strong></div> <div><span></span><strong>{importPreview.totalRows}</strong></div>
+13 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { FilePenLine, Plus, Search, Trash2, Upload } from 'lucide-react'; import { FilePenLine, Plus, Search, Trash2, Upload } from 'lucide-react';
import { Button, Input, Modal, Select, Tag } from '@/components/ui'; import { Button, FileActions, Input, Modal, Select, Tag } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature } from '@/api/adminApi'; import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = { const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
approved: 'success', approved: 'success',
@@ -18,6 +18,14 @@ const statusLabel: Record<string, string> = {
disabled: '已禁用', disabled: '已禁用',
}; };
function materialToFileRef(material: Record<string, unknown>): FileRef | null {
const fileObjectId = String(material.fileObjectId ?? '');
if (!fileObjectId) return null;
const fileName = String(material.title ?? material.fileName ?? '签名材料');
const contentType = typeof material.contentType === 'string' ? material.contentType : undefined;
return { contentType, fileName, fileObjectId };
}
export function ClientSignaturesPage() { export function ClientSignaturesPage() {
const [applications, setApplications] = useState<ClientSmsApplication[]>([]); const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]); const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
@@ -124,6 +132,9 @@ export function ClientSignaturesPage() {
<div> <div>
<span></span> <span></span>
<strong>{signature.materials?.length ?? 0} </strong> <strong>{signature.materials?.length ?? 0} </strong>
{signature.materials?.map((material) => (
<FileActions file={materialToFileRef(material)} key={String(material.id ?? material.fileObjectId)} />
))}
</div> </div>
<div className="signature-actions"> <div className="signature-actions">
<Button icon={<Trash2 size={16} />} onClick={() => disableSignature(signature.id)} size="sm" variant="danger"></Button> <Button icon={<Trash2 size={16} />} onClick={() => disableSignature(signature.id)} size="sm" variant="danger"></Button>
+34
View File
@@ -0,0 +1,34 @@
import { Download, Eye } from 'lucide-react';
import { fileDownloadUrl, type FileRef } from '@/api/adminApi';
import { Button } from './Button';
type FileActionsProps = {
file?: FileRef | null;
};
function isImageFile(file: FileRef) {
const contentType = file.contentType?.toLowerCase() ?? '';
const name = file.fileName.toLowerCase();
return contentType.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(name);
}
export function FileActions({ file }: FileActionsProps) {
if (!file?.fileObjectId) {
return null;
}
const previewUrl = fileDownloadUrl(file.fileObjectId, 'inline');
const downloadUrl = fileDownloadUrl(file.fileObjectId, 'attachment');
return (
<span className="file-actions" onClick={(event) => event.stopPropagation()}>
{isImageFile(file) ? (
<Button icon={<Eye size={14} />} onClick={() => window.open(previewUrl, '_blank', 'noopener,noreferrer')} size="sm" variant="ghost">
</Button>
) : null}
<a className="file-action-link" href={downloadUrl}>
<Download size={14} />
</a>
</span>
);
}
+1
View File
@@ -4,6 +4,7 @@ export { Chart } from './Chart';
export { DateRangeInput } from './DateRangeInput'; export { DateRangeInput } from './DateRangeInput';
export { DateTimeInput } from './DateTimeInput'; export { DateTimeInput } from './DateTimeInput';
export { DetailInfoGrid, DetailProgressStats, DetailSection, DetailTitle, getRateTone, ProgressBar, RateCard, RateOverview } from './Detail'; export { DetailInfoGrid, DetailProgressStats, DetailSection, DetailTitle, getRateTone, ProgressBar, RateCard, RateOverview } from './Detail';
export { FileActions } from './FileActions';
export { Input } from './Input'; export { Input } from './Input';
export { Modal } from './Modal'; export { Modal } from './Modal';
export { InlineTextPreview, Pagination, QueryPanel } from './PagePrimitives'; export { InlineTextPreview, Pagination, QueryPanel } from './PagePrimitives';
+241
View File
@@ -6904,6 +6904,29 @@ h3 {
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
} }
.channel-group-card footer button {
align-items: center;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-selected);
cursor: pointer;
display: inline-flex;
font-weight: var(--font-weight-semibold);
gap: var(--space-1);
min-height: 32px;
padding: 0 var(--space-3);
}
.channel-group-card footer button.is-danger {
color: var(--color-danger);
}
.channel-group-card footer button:hover {
background: var(--color-accent-soft);
border-color: currentColor;
}
.channel-group-form-page { .channel-group-form-page {
min-width: 1040px; min-width: 1040px;
} }
@@ -6999,6 +7022,85 @@ h3 {
color: var(--color-danger); color: var(--color-danger);
} }
.channel-route-card-grid {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(3, minmax(220px, 1fr));
}
.channel-route-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-3);
min-height: 188px;
padding: var(--space-4);
}
.channel-route-card > div {
align-items: flex-start;
display: flex;
gap: var(--space-3);
justify-content: space-between;
}
.channel-route-card strong {
color: var(--color-text-strong);
font-size: var(--font-size-lg);
}
.channel-route-card span,
.channel-route-card small {
color: var(--color-text-muted);
}
.channel-route-card p {
color: var(--color-text);
font-weight: var(--font-weight-semibold);
line-height: 1.5;
margin: 0;
overflow-wrap: anywhere;
}
.channel-route-card footer {
align-items: center;
border-top: 1px solid var(--color-border);
display: flex;
gap: var(--space-3);
justify-content: flex-end;
margin-top: var(--space-1);
padding-top: var(--space-3);
}
.channel-route-card footer button {
align-items: center;
background: transparent;
border: 0;
color: var(--color-selected);
cursor: pointer;
display: inline-flex;
font-weight: var(--font-weight-semibold);
gap: var(--space-1);
padding: 0;
}
.channel-route-card footer button.is-danger {
color: var(--color-danger);
}
.channel-route-empty {
align-items: center;
background: var(--color-surface-subtle);
border: 1px dashed var(--color-border-strong);
border-radius: var(--radius-md);
color: var(--color-text-muted);
display: flex;
justify-content: center;
min-height: 120px;
padding: var(--space-5);
}
.channel-route-modal { .channel-route-modal {
display: grid; display: grid;
gap: var(--space-5); gap: var(--space-5);
@@ -8285,6 +8387,114 @@ h3 {
grid-template-columns: minmax(360px, 1fr) auto; grid-template-columns: minmax(360px, 1fr) auto;
} }
.phone-segment-overview {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.phone-segment-overview section {
align-items: center;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: flex;
gap: var(--space-4);
min-height: 92px;
padding: var(--space-5);
}
.phone-segment-overview span {
align-items: center;
background: var(--color-selected-soft);
border-radius: var(--radius-md);
color: var(--color-selected);
display: inline-flex;
height: 44px;
justify-content: center;
width: 44px;
}
.phone-segment-overview strong {
color: var(--color-text-strong);
font-size: 26px;
line-height: 1;
}
.phone-segment-overview p {
color: var(--color-text-muted);
margin: var(--space-1) 0 0;
}
.phone-segment-tabs {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: var(--space-4);
}
.phone-segment-tabs button {
align-items: center;
background: var(--color-surface-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
cursor: pointer;
display: grid;
gap: var(--space-3);
grid-template-columns: 36px minmax(0, 1fr) auto;
min-height: 78px;
padding: var(--space-4);
text-align: left;
}
.phone-segment-tabs button > svg {
color: var(--color-selected);
}
.phone-segment-tabs button span {
display: grid;
gap: 4px;
min-width: 0;
}
.phone-segment-tabs button strong {
color: var(--color-text-strong);
font-size: var(--font-size-base);
}
.phone-segment-tabs button small {
color: var(--color-text-muted);
line-height: 1.45;
}
.phone-segment-tabs button em {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 999px;
color: var(--color-text-strong);
font-style: normal;
font-weight: var(--font-weight-semibold);
min-width: 40px;
padding: 5px 10px;
text-align: center;
}
.phone-segment-tabs button.is-active {
background: var(--color-selected-soft);
border-color: #bfdbfe;
}
.phone-segment-tabs button.is-active em {
background: var(--color-selected);
border-color: var(--color-selected);
color: #ffffff;
}
.phone-segment-toolbar {
align-items: center;
}
.admin-system-table-card { .admin-system-table-card {
overflow: hidden; overflow: hidden;
padding: 0; padding: 0;
@@ -8367,6 +8577,34 @@ h3 {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.file-actions {
align-items: center;
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.file-action-link {
align-items: center;
background: #fff;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
display: inline-flex;
font-size: 13px;
font-weight: 600;
gap: 4px;
min-height: 30px;
padding: 0 10px;
text-decoration: none;
}
.file-action-link:hover {
border-color: var(--primary);
color: var(--primary);
}
@media (max-width: 780px) { @media (max-width: 780px) {
.app-shell { .app-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -8534,8 +8772,11 @@ h3 {
.admin-recharge-filter, .admin-recharge-filter,
.admin-recharge-filter__actions, .admin-recharge-filter__actions,
.admin-system-toolbar, .admin-system-toolbar,
.phone-segment-overview,
.phone-segment-tabs,
.admin-system-modal-form, .admin-system-modal-form,
.admin-drainage-toolbar, .admin-drainage-toolbar,
.channel-route-card-grid,
.admin-split-filter, .admin-split-filter,
.detail-grid, .detail-grid,
.admin-task-detail-grid, .admin-task-detail-grid,
+5 -3
View File
@@ -147,10 +147,11 @@ async function ensureSmokeData() {
} }
await prisma.cmppConnectionState.upsert({ await prisma.cmppConnectionState.upsert({
where: { channelId_connectionId: { channelId: channel.id, connectionId: 'smoke-conn-1' } }, where: { applicationId_channelId_connectionId: { applicationId: application.id, channelId: channel.id, connectionId: 'smoke-conn-1' } },
update: { update: {
tenantId: tenant.id, tenantId: tenant.id,
status: 'online', applicationId: application.id,
status: 'connected',
desiredConnections: 1, desiredConnections: 1,
currentConnections: 1, currentConnections: 1,
lastHeartbeatAt: new Date(), lastHeartbeatAt: new Date(),
@@ -158,9 +159,10 @@ async function ensureSmokeData() {
}, },
create: { create: {
tenantId: tenant.id, tenantId: tenant.id,
applicationId: application.id,
channelId: channel.id, channelId: channel.id,
connectionId: 'smoke-conn-1', connectionId: 'smoke-conn-1',
status: 'online', status: 'connected',
desiredConnections: 1, desiredConnections: 1,
currentConnections: 1, currentConnections: 1,
lastConnectedAt: new Date(), lastConnectedAt: new Date(),
+325
View File
@@ -0,0 +1,325 @@
param(
[switch]$SkipInfra,
[switch]$SkipMigrate,
[switch]$SkipApi,
[switch]$SkipWeb,
[switch]$WithGateway,
[switch]$OnlyMinio
)
$ErrorActionPreference = 'Stop'
$root = Resolve-Path (Join-Path $PSScriptRoot '..')
$logDir = Join-Path $root 'logs'
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
function Test-CommandAvailable {
param([string]$Name)
return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
}
function Test-PortOpen {
param([int]$Port)
return [bool](Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
}
function Test-TcpPort {
param(
[string]$HostName,
[int]$Port
)
try {
$client = [System.Net.Sockets.TcpClient]::new()
$connect = $client.BeginConnect($HostName, $Port, $null, $null)
if (-not $connect.AsyncWaitHandle.WaitOne(1000)) {
$client.Close()
return $false
}
$client.EndConnect($connect)
$client.Close()
return $true
} catch {
return $false
}
}
function Start-LoggedProcess {
param(
[string]$Name,
[string]$FilePath,
[string[]]$ArgumentList,
[string]$WorkingDirectory,
[string]$OutLog,
[string]$ErrLog
)
Write-Host "Starting $Name..."
Start-Process `
-FilePath $FilePath `
-ArgumentList $ArgumentList `
-WorkingDirectory $WorkingDirectory `
-RedirectStandardOutput $OutLog `
-RedirectStandardError $ErrLog `
-WindowStyle Hidden | Out-Null
}
function Wait-ForTcpPort {
param(
[string]$Name,
[string]$HostName,
[int]$Port,
[int]$TimeoutSeconds = 20
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
if (Test-TcpPort -HostName $HostName -Port $Port) {
Write-Host "$Name is listening on ${HostName}:$Port."
return $true
}
Start-Sleep -Milliseconds 500
}
Write-Host "$Name is not listening on ${HostName}:$Port after ${TimeoutSeconds}s."
return $false
}
function Find-LocalPostgresBin {
$candidates = @(
'C:\cmpp-platform-local\pgsql\bin',
'C:\cmpp-platform-local\PostgreSQL16\bin',
'C:\Program Files\PostgreSQL\16\bin'
)
foreach ($candidate in $candidates) {
if ((Test-Path (Join-Path $candidate 'pg_ctl.exe')) -and (Test-Path (Join-Path $candidate 'pg_isready.exe'))) {
return $candidate
}
}
$pgCtl = Get-Command 'pg_ctl.exe' -ErrorAction SilentlyContinue
if ($pgCtl) {
return Split-Path $pgCtl.Source -Parent
}
return $null
}
function Start-LocalPostgres {
if (Test-TcpPort -HostName '127.0.0.1' -Port 5432) {
Write-Host 'PostgreSQL is already reachable on 127.0.0.1:5432.'
return $true
}
$pgBin = Find-LocalPostgresBin
$pgData = 'C:\cmpp-platform-local\postgres-data'
if (-not $pgBin -or -not (Test-Path (Join-Path $pgData 'PG_VERSION'))) {
Write-Host 'PostgreSQL is not reachable and no local PostgreSQL data directory was found.'
Write-Host 'Expected Docker Compose or local data at C:\cmpp-platform-local\postgres-data.'
return $false
}
Write-Host "Starting local PostgreSQL from $pgData..."
& (Join-Path $pgBin 'pg_ctl.exe') -D $pgData -l (Join-Path $logDir 'postgres.log') start | Out-Host
Start-Sleep -Seconds 2
if (Test-Path (Join-Path $pgBin 'pg_isready.exe')) {
& (Join-Path $pgBin 'pg_isready.exe') -h 127.0.0.1 -p 5432 -U cmpp -d cmpp_platform | Out-Host
}
return (Wait-ForTcpPort -Name 'PostgreSQL' -HostName '127.0.0.1' -Port 5432 -TimeoutSeconds 30)
}
function Start-LocalRedis {
if (Test-TcpPort -HostName '127.0.0.1' -Port 6379) {
Write-Host 'Redis is already reachable on 127.0.0.1:6379.'
return $true
}
$redisServer = Get-Command 'redis-server' -ErrorAction SilentlyContinue
if (-not $redisServer) {
Write-Host 'Redis is not reachable and redis-server was not found in PATH.'
return $false
}
$redisDir = Join-Path $root '.local-data\redis'
New-Item -ItemType Directory -Force -Path $redisDir | Out-Null
Start-LoggedProcess `
-Name 'Redis' `
-FilePath $redisServer.Source `
-ArgumentList @('--port', '6379', '--dir', $redisDir, '--dbfilename', 'dump.rdb') `
-WorkingDirectory $root `
-OutLog (Join-Path $logDir 'redis.out.log') `
-ErrLog (Join-Path $logDir 'redis.err.log')
return (Wait-ForTcpPort -Name 'Redis' -HostName '127.0.0.1' -Port 6379 -TimeoutSeconds 15)
}
function Find-LocalMinio {
$candidates = @(
'C:\cmpp-platform-local\minio.exe',
'C:\cmpp-platform-local\minio\minio.exe'
)
foreach ($candidate in $candidates) {
if (Test-Path $candidate) {
return $candidate
}
}
$minio = Get-Command 'minio.exe' -ErrorAction SilentlyContinue
if ($minio) {
return $minio.Source
}
return $null
}
function Start-LocalMinio {
if ((Test-TcpPort -HostName '127.0.0.1' -Port 9000) -and (Test-TcpPort -HostName '127.0.0.1' -Port 9001)) {
Write-Host 'MinIO is already reachable on 127.0.0.1:9000/9001.'
return $true
}
$minio = Find-LocalMinio
if (-not $minio) {
Write-Host 'MinIO is not reachable and minio.exe was not found.'
Write-Host 'Expected minio.exe at C:\cmpp-platform-local\minio.exe or in PATH.'
return $false
}
$minioData = 'C:\cmpp-platform-local\minio-data'
New-Item -ItemType Directory -Force -Path $minioData | Out-Null
if (-not $env:MINIO_ROOT_USER) {
$env:MINIO_ROOT_USER = 'cmpp_minio'
}
if (-not $env:MINIO_ROOT_PASSWORD) {
$env:MINIO_ROOT_PASSWORD = 'cmpp_minio_password'
}
Start-LoggedProcess `
-Name 'MinIO' `
-FilePath $minio `
-ArgumentList @('server', $minioData, '--address', ':9000', '--console-address', ':9001') `
-WorkingDirectory (Split-Path $minio -Parent) `
-OutLog (Join-Path $logDir 'minio.out.log') `
-ErrLog (Join-Path $logDir 'minio.err.log')
$apiReady = Wait-ForTcpPort -Name 'MinIO API' -HostName '127.0.0.1' -Port 9000 -TimeoutSeconds 20
$consoleReady = Wait-ForTcpPort -Name 'MinIO Console' -HostName '127.0.0.1' -Port 9001 -TimeoutSeconds 20
return ($apiReady -and $consoleReady)
}
function Test-Minio {
if ((Test-TcpPort -HostName '127.0.0.1' -Port 9000) -and (Test-TcpPort -HostName '127.0.0.1' -Port 9001)) {
Write-Host 'MinIO ports 9000/9001 are reachable.'
return $true
}
Write-Host 'MinIO is not fully reachable on 9000/9001. File upload smoke may be blocked.'
return $false
}
Write-Host "CMPP local service startup"
Write-Host "Workspace: $root"
$minioReady = $false
if ($OnlyMinio) {
$minioReady = Start-LocalMinio
if (-not $minioReady) {
exit 1
}
Write-Host ''
Write-Host 'MinIO startup command issued. Useful URLs:'
Write-Host ' MinIO API: http://localhost:9000'
Write-Host ' MinIO Console: http://localhost:9001'
Write-Host ''
Write-Host "Logs: $logDir"
return
}
if (-not $SkipInfra) {
if (Test-CommandAvailable 'docker') {
Write-Host "Starting PostgreSQL, Redis and MinIO via Docker Compose..."
& docker compose -f (Join-Path $root 'infra/docker-compose.yml') up -d
Wait-ForTcpPort -Name 'PostgreSQL' -HostName '127.0.0.1' -Port 5432 -TimeoutSeconds 30 | Out-Null
Wait-ForTcpPort -Name 'Redis' -HostName '127.0.0.1' -Port 6379 -TimeoutSeconds 20 | Out-Null
$minioReady = Test-Minio
} else {
Write-Host "Docker CLI is not available. Trying local PostgreSQL and Redis fallback..."
Start-LocalPostgres | Out-Null
Start-LocalRedis | Out-Null
$minioReady = Start-LocalMinio
}
} else {
$minioReady = Test-Minio
}
if (-not $minioReady) {
$localObjectRoot = Join-Path $root '.local-data\object-storage'
New-Item -ItemType Directory -Force -Path $localObjectRoot | Out-Null
$env:OBJECT_STORAGE_DRIVER = 'local'
$env:OBJECT_STORAGE_LOCAL_ROOT = $localObjectRoot
Write-Host "Object storage fallback enabled: $localObjectRoot"
}
if (-not $SkipInfra -and -not $SkipMigrate -and (Test-TcpPort -HostName '127.0.0.1' -Port 5432)) {
Write-Host 'Applying Prisma migrations...'
& npm.cmd --prefix api run prisma:migrate:deploy
}
if (-not $SkipApi) {
if (Test-PortOpen 3000) {
Write-Host "API port 3000 is already listening; leaving the existing API process untouched."
} else {
Start-LoggedProcess `
-Name 'NestJS API' `
-FilePath 'npm.cmd' `
-ArgumentList @('--prefix', 'api', 'run', 'start:dev') `
-WorkingDirectory $root `
-OutLog (Join-Path $logDir 'api-dev.out.log') `
-ErrLog (Join-Path $logDir 'api-dev.err.log')
}
}
if (-not $SkipWeb) {
if ((Test-PortOpen 5173) -or (Test-PortOpen 4173) -or (Test-PortOpen 4174)) {
Write-Host "A frontend port is already listening on 5173/4173/4174; leaving the existing web process untouched."
} else {
Start-LoggedProcess `
-Name 'frontend preview' `
-FilePath 'npm.cmd' `
-ArgumentList @('run', 'dev') `
-WorkingDirectory $root `
-OutLog (Join-Path $logDir 'frontend-dev.out.log') `
-ErrLog (Join-Path $logDir 'frontend-dev.err.log')
}
}
if ($WithGateway) {
if (-not (Test-CommandAvailable 'go')) {
Write-Host "Go CLI is not available. Skipping gateway startup."
} elseif (Test-PortOpen 8090) {
Write-Host "Gateway health port 8090 is already listening; leaving the existing gateway process untouched."
} else {
Start-LoggedProcess `
-Name 'Go gateway' `
-FilePath 'go' `
-ArgumentList @('run', './cmd/gateway') `
-WorkingDirectory (Join-Path $root 'gateway') `
-OutLog (Join-Path $logDir 'gateway.out.log') `
-ErrLog (Join-Path $logDir 'gateway.err.log')
}
}
Write-Host ''
Write-Host 'Startup command issued. Useful URLs:'
Write-Host ' API: http://localhost:3000/api'
Write-Host ' Frontend: http://localhost:5173 or http://localhost:4173'
if ($minioReady) {
Write-Host ' MinIO: http://localhost:9001'
} else {
Write-Host ' MinIO: not running; using local object storage fallback when API is started by this script'
}
Write-Host ''
Write-Host "Logs: $logDir"