fix: harden real backend workflows and channel connections
This commit is contained in:
@@ -10,6 +10,7 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
logs/
|
||||
|
||||
.DS_Store
|
||||
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");
|
||||
@@ -352,6 +352,7 @@ model SmsApplication {
|
||||
sendTasks SmsSendTask[]
|
||||
batchTasks SmsBatchTask[]
|
||||
messageRecords SmsMessageRecord[]
|
||||
connectionStates CmppConnectionState[]
|
||||
|
||||
@@index([tenantId, status])
|
||||
}
|
||||
@@ -502,6 +503,7 @@ model SmsChannel {
|
||||
model CmppConnectionState {
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
applicationId String?
|
||||
channelId String
|
||||
connectionId String
|
||||
status String @default("disconnected")
|
||||
@@ -516,10 +518,12 @@ model CmppConnectionState {
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
application SmsApplication? @relation(fields: [applicationId], references: [id])
|
||||
channel SmsChannel @relation(fields: [channelId], references: [id])
|
||||
|
||||
@@unique([channelId, connectionId])
|
||||
@@unique([applicationId, channelId, connectionId])
|
||||
@@index([tenantId, status])
|
||||
@@index([applicationId, status])
|
||||
@@index([channelId, status])
|
||||
}
|
||||
|
||||
|
||||
@@ -64,8 +64,13 @@ export class ChannelsController {
|
||||
}
|
||||
|
||||
@Get('channels/:id/link-logs')
|
||||
listChannelLinkLogs(@Param('id') channelId: string) {
|
||||
return this.channels.listChannelLinkLogs(channelId);
|
||||
listLegacyChannelConnectionLogs(@Param('id') channelId: string) {
|
||||
return this.channels.listChannelConnectionLogs(channelId);
|
||||
}
|
||||
|
||||
@Get('channels/:id/connection-logs')
|
||||
listChannelConnectionLogs(@Param('id') channelId: string) {
|
||||
return this.channels.listChannelConnectionLogs(channelId);
|
||||
}
|
||||
|
||||
@Get('channels/:id/connections')
|
||||
@@ -98,6 +103,11 @@ export class ChannelsController {
|
||||
return this.channels.updateGroup(groupId, body);
|
||||
}
|
||||
|
||||
@Delete('channel-groups/:id')
|
||||
deleteGroup(@Param('id') groupId: string) {
|
||||
return this.channels.deleteGroup(groupId);
|
||||
}
|
||||
|
||||
@Post('channel-groups/items')
|
||||
addGroupItem(@Body() body: CreateChannelGroupItemDto) {
|
||||
return this.channels.addGroupItem(body);
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { ChannelsService } from './channels.service';
|
||||
|
||||
const mockQueueAdd = jest.fn().mockResolvedValue(undefined);
|
||||
const mockQueueClose = jest.fn().mockResolvedValue(undefined);
|
||||
const mockFetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: jest.fn().mockResolvedValue(''),
|
||||
});
|
||||
|
||||
jest.mock('bullmq', () => ({
|
||||
Queue: jest.fn().mockImplementation(() => ({
|
||||
add: mockQueueAdd,
|
||||
close: mockQueueClose,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createPrismaMock() {
|
||||
const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'pending' };
|
||||
const channel = {
|
||||
@@ -54,6 +69,7 @@ function createPrismaMock() {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72 }),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
|
||||
delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }),
|
||||
},
|
||||
smsChannelGroupItem: {
|
||||
deleteMany: jest.fn(),
|
||||
@@ -63,6 +79,7 @@ function createPrismaMock() {
|
||||
},
|
||||
channelRouteRule: {
|
||||
findMany: jest.fn(),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'route-1', ...data })),
|
||||
},
|
||||
channelReportField: {
|
||||
@@ -93,9 +110,15 @@ function createPrismaMock() {
|
||||
smsSignature: {
|
||||
update: jest.fn(),
|
||||
},
|
||||
smsApplication: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
||||
},
|
||||
cmppConnectionState: {
|
||||
findMany: jest.fn(),
|
||||
upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({ id: 'conn-1', ...create })),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-1', ...data })),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-1', ...data })),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
@@ -105,11 +128,18 @@ function createPrismaMock() {
|
||||
}
|
||||
|
||||
describe('ChannelsService', () => {
|
||||
beforeEach(() => {
|
||||
mockQueueAdd.mockClear();
|
||||
mockQueueClose.mockClear();
|
||||
mockFetch.mockClear();
|
||||
global.fetch = mockFetch as never;
|
||||
});
|
||||
|
||||
it('rejects incomplete channel creation input with readable 400 errors', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
expect(() => service.createChannel({ name: '缺字段通道' } as never)).toThrow('Missing required channel fields');
|
||||
await expect(service.createChannel({ name: '缺字段通道' } as never)).rejects.toThrow('Missing required channel fields');
|
||||
expect(prisma.smsChannel.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -138,6 +168,25 @@ describe('ChannelsService', () => {
|
||||
status: 'active',
|
||||
}),
|
||||
});
|
||||
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'channel-1:primary',
|
||||
status: 'connecting',
|
||||
desiredConnections: 1,
|
||||
currentConnections: 0,
|
||||
}),
|
||||
});
|
||||
expect(mockQueueAdd).toHaveBeenCalledWith('connect-channel', expect.objectContaining({
|
||||
messageType: 'ConnectChannel',
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'channel-1:primary',
|
||||
reason: 'channel_created',
|
||||
}), { jobId: 'channel-1:primary:connect' });
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('"messageType":"ConnectChannel"'),
|
||||
}));
|
||||
expect(prisma.smsChannelGroup.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 }),
|
||||
});
|
||||
@@ -309,6 +358,18 @@ describe('ChannelsService', () => {
|
||||
expect(prisma.channelRouteRule.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes channel groups only when no active route rule is bound', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await service.deleteGroup('group-1');
|
||||
expect(prisma.smsChannelGroupItem.deleteMany).toHaveBeenCalledWith({ where: { groupId: 'group-1' } });
|
||||
expect(prisma.smsChannelGroup.delete).toHaveBeenCalledWith({ where: { id: 'group-1' } });
|
||||
|
||||
prisma.channelRouteRule.findFirst.mockResolvedValueOnce({ id: 'route-1' });
|
||||
await expect(service.deleteGroup('group-1')).rejects.toThrow('Channel group is used by application route rules');
|
||||
});
|
||||
|
||||
it('upserts signature report material per channel field', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
@@ -405,6 +466,24 @@ describe('ChannelsService', () => {
|
||||
resourceId: 'channel-1',
|
||||
}),
|
||||
});
|
||||
|
||||
await service.changeChannelStatus('channel-1', { status: 'active', operatorId: 'admin-1', reason: 'resume' });
|
||||
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'channel-1:primary',
|
||||
status: 'connecting',
|
||||
}),
|
||||
});
|
||||
expect(mockQueueAdd).toHaveBeenCalledWith('connect-channel', expect.objectContaining({
|
||||
messageType: 'ConnectChannel',
|
||||
channelId: 'channel-1',
|
||||
reason: 'channel_enabled',
|
||||
}), { jobId: 'channel-1:primary:connect' });
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('"reason":"channel_enabled"'),
|
||||
}));
|
||||
});
|
||||
|
||||
it('copies channels with report field configuration and report materials', async () => {
|
||||
@@ -432,6 +511,7 @@ describe('ChannelsService', () => {
|
||||
|
||||
await service.upsertConnectionState({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'conn-a',
|
||||
status: 'online',
|
||||
@@ -440,12 +520,13 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
await service.listChannelConnections('channel-1');
|
||||
await service.listTenantConnections('tenant-1');
|
||||
await service.listChannelLinkLogs('channel-1');
|
||||
await service.listChannelConnectionLogs('channel-1');
|
||||
|
||||
expect(prisma.cmppConnectionState.upsert).toHaveBeenCalledWith({
|
||||
where: { channelId_connectionId: { channelId: 'channel-1', connectionId: 'conn-a' } },
|
||||
update: expect.objectContaining({ tenantId: 'tenant-1', status: 'online', desiredConnections: 2, currentConnections: 1 }),
|
||||
create: expect.objectContaining({ channelId: 'channel-1', connectionId: 'conn-a', status: 'online' }),
|
||||
expect(prisma.cmppConnectionState.findFirst).toHaveBeenCalledWith({
|
||||
where: { applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a' },
|
||||
});
|
||||
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', status: 'connected' }),
|
||||
});
|
||||
expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({
|
||||
where: { channelId: 'channel-1' },
|
||||
@@ -467,4 +548,86 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
expect(prisma.operationLog.findMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks stale connecting CMPP connections as failed with operation logs', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
const now = new Date('2026-07-06T10:00:45.000Z');
|
||||
prisma.cmppConnectionState.findMany.mockResolvedValueOnce([{
|
||||
id: 'conn-state-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: null,
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'channel-1:primary',
|
||||
status: 'connecting',
|
||||
desiredConnections: 1,
|
||||
currentConnections: 0,
|
||||
updatedAt: new Date('2026-07-06T10:00:00.000Z'),
|
||||
}]);
|
||||
|
||||
await expect(service.markTimedOutConnectingChannels(now)).resolves.toEqual({ checked: 1, failed: 1 });
|
||||
|
||||
expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
status: 'connecting',
|
||||
updatedAt: { lte: new Date('2026-07-06T10:00:15.000Z') },
|
||||
},
|
||||
select: expect.objectContaining({
|
||||
id: true,
|
||||
channelId: true,
|
||||
connectionId: true,
|
||||
updatedAt: true,
|
||||
}),
|
||||
take: 100,
|
||||
});
|
||||
expect(prisma.cmppConnectionState.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: 'conn-state-1',
|
||||
status: 'connecting',
|
||||
updatedAt: { lte: new Date('2026-07-06T10:00:15.000Z') },
|
||||
},
|
||||
data: expect.objectContaining({
|
||||
status: 'failed',
|
||||
currentConnections: 0,
|
||||
lastDisconnectedAt: now,
|
||||
lastError: 'Gateway connection request timed out after 30 seconds',
|
||||
}),
|
||||
});
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tenantId: 'tenant-1',
|
||||
action: 'cmpp_connection.failed',
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: 'channel-1:channel-1:primary',
|
||||
detail: expect.objectContaining({
|
||||
reason: 'connect_timeout',
|
||||
timeoutMs: 30000,
|
||||
status: 'failed',
|
||||
previousStatus: 'connecting',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write timeout logs when a connecting state is already changed by gateway callback', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.cmppConnectionState.updateMany.mockResolvedValueOnce({ count: 0 });
|
||||
prisma.cmppConnectionState.findMany.mockResolvedValueOnce([{
|
||||
id: 'conn-state-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: null,
|
||||
channelId: 'channel-1',
|
||||
connectionId: 'channel-1:primary',
|
||||
desiredConnections: 1,
|
||||
currentConnections: 0,
|
||||
updatedAt: new Date('2026-07-06T10:00:00.000Z'),
|
||||
}]);
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await expect(service.markTimedOutConnectingChannels(new Date('2026-07-06T10:00:45.000Z'))).resolves.toEqual({ checked: 1, failed: 0 });
|
||||
|
||||
expect(prisma.operationLog.create).not.toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: 'cmpp_connection.failed' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface CreateChannelDto {
|
||||
@@ -113,6 +115,7 @@ export interface CreateReceiptImportDto {
|
||||
|
||||
export interface UpsertConnectionStateDto {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId: string;
|
||||
connectionId: string;
|
||||
status: string;
|
||||
@@ -137,15 +140,45 @@ export interface CopyChannelDto {
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.commands';
|
||||
const DEFAULT_GATEWAY_CONTROL_URL = 'http://127.0.0.1:8090';
|
||||
const DEFAULT_CHANNEL_CONNECTION_ID = 'primary';
|
||||
const DEFAULT_CONNECTING_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_CONNECTING_TIMEOUT_SCAN_MS = 5_000;
|
||||
const CONNECTING_TIMEOUT_ERROR = 'Gateway connection request timed out';
|
||||
|
||||
@Injectable()
|
||||
export class ChannelsService {
|
||||
export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(ChannelsService.name);
|
||||
private gatewayConnectionQueue?: Queue;
|
||||
private connectionTimeoutTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (process.env.GATEWAY_CONNECTING_TIMEOUT_SCANNER_DISABLED === 'true') {
|
||||
return;
|
||||
}
|
||||
this.connectionTimeoutTimer = setInterval(() => {
|
||||
void this.markTimedOutConnectingChannels().catch((error) => {
|
||||
this.logger.error(`Failed to mark timed-out CMPP connections: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}, getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_SCAN_MS', DEFAULT_CONNECTING_TIMEOUT_SCAN_MS));
|
||||
this.connectionTimeoutTimer.unref?.();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.connectionTimeoutTimer) {
|
||||
clearInterval(this.connectionTimeoutTimer);
|
||||
}
|
||||
await this.gatewayConnectionQueue?.close();
|
||||
}
|
||||
|
||||
listChannels() {
|
||||
return this.prisma.smsChannel.findMany({ orderBy: { createdAt: 'desc' }, take: 100 });
|
||||
}
|
||||
|
||||
createChannel(data: CreateChannelDto) {
|
||||
async createChannel(data: CreateChannelDto) {
|
||||
const missingFields = ['code', 'name', 'gatewayHost', 'gatewayPort', 'account', 'passwordCipher', 'srcId'].filter((field) => {
|
||||
const value = data[field as keyof CreateChannelDto];
|
||||
return value === undefined || value === null || value === '';
|
||||
@@ -157,7 +190,7 @@ export class ChannelsService {
|
||||
if (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535) {
|
||||
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
|
||||
}
|
||||
return this.prisma.smsChannel.create({
|
||||
const channel = await this.prisma.smsChannel.create({
|
||||
data: {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
@@ -177,6 +210,10 @@ export class ChannelsService {
|
||||
config: data.config as Prisma.InputJsonValue | undefined,
|
||||
},
|
||||
});
|
||||
if (channel.status === 'active') {
|
||||
await this.requestChannelConnection(channel, 'channel_created');
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
async updateChannel(channelId: string, data: UpdateChannelDto) {
|
||||
@@ -253,6 +290,9 @@ export class ChannelsService {
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
if (data.status === 'active') {
|
||||
await this.requestChannelConnection(updated, 'channel_enabled', data.operatorId);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -366,7 +406,7 @@ export class ChannelsService {
|
||||
});
|
||||
}
|
||||
|
||||
async listChannelLinkLogs(channelId: string) {
|
||||
async listChannelConnectionLogs(channelId: string) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId }, select: { id: true } });
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
@@ -412,21 +452,40 @@ export class ChannelsService {
|
||||
}
|
||||
|
||||
async upsertConnectionState(data: UpsertConnectionStateDto) {
|
||||
const status = normalizeGatewayConnectionStatus(data.status);
|
||||
if (data.applicationId) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } });
|
||||
if (!application) {
|
||||
throw new BadRequestException('applicationId does not reference an existing application');
|
||||
}
|
||||
if (data.tenantId && data.tenantId !== application.tenantId) {
|
||||
throw new BadRequestException('applicationId does not belong to tenantId');
|
||||
}
|
||||
data.tenantId = application.tenantId;
|
||||
}
|
||||
const payload = {
|
||||
tenantId: data.tenantId,
|
||||
status: data.status,
|
||||
applicationId: data.applicationId,
|
||||
status,
|
||||
desiredConnections: data.desiredConnections ?? 1,
|
||||
currentConnections: data.currentConnections ?? (data.status === 'online' || data.status === 'connected' ? 1 : 0),
|
||||
currentConnections: data.currentConnections ?? (status === 'connected' ? 1 : 0),
|
||||
lastConnectedAt: data.lastConnectedAt ? new Date(data.lastConnectedAt) : undefined,
|
||||
lastDisconnectedAt: data.lastDisconnectedAt ? new Date(data.lastDisconnectedAt) : undefined,
|
||||
lastHeartbeatAt: data.lastHeartbeatAt ? new Date(data.lastHeartbeatAt) : undefined,
|
||||
reconnectCount: data.reconnectCount ?? 0,
|
||||
lastError: data.lastError,
|
||||
};
|
||||
const state = await this.prisma.cmppConnectionState.upsert({
|
||||
where: { channelId_connectionId: { channelId: data.channelId, connectionId: data.connectionId } },
|
||||
update: payload,
|
||||
create: {
|
||||
const existing = await this.prisma.cmppConnectionState.findFirst({
|
||||
where: {
|
||||
applicationId: data.applicationId ?? null,
|
||||
channelId: data.channelId,
|
||||
connectionId: data.connectionId,
|
||||
},
|
||||
});
|
||||
const state = existing
|
||||
? await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data: payload })
|
||||
: await this.prisma.cmppConnectionState.create({
|
||||
data: {
|
||||
channelId: data.channelId,
|
||||
connectionId: data.connectionId,
|
||||
...payload,
|
||||
@@ -435,11 +494,12 @@ export class ChannelsService {
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
action: `cmpp_connection.${normalizeConnectionAction(data.status)}`,
|
||||
action: `cmpp_connection.${normalizeConnectionAction(status)}`,
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${data.channelId}:${data.connectionId}`,
|
||||
detail: {
|
||||
status: data.status,
|
||||
status,
|
||||
applicationId: state.applicationId,
|
||||
desiredConnections: state.desiredConnections,
|
||||
currentConnections: state.currentConnections,
|
||||
lastError: state.lastError,
|
||||
@@ -449,6 +509,69 @@ export class ChannelsService {
|
||||
return state;
|
||||
}
|
||||
|
||||
async markTimedOutConnectingChannels(now = new Date()) {
|
||||
const timeoutMs = getPositiveIntegerEnv('GATEWAY_CONNECTING_TIMEOUT_MS', DEFAULT_CONNECTING_TIMEOUT_MS);
|
||||
const cutoff = new Date(now.getTime() - timeoutMs);
|
||||
const lastError = `${CONNECTING_TIMEOUT_ERROR} after ${Math.round(timeoutMs / 1000)} seconds`;
|
||||
const states = await this.prisma.cmppConnectionState.findMany({
|
||||
where: {
|
||||
status: 'connecting',
|
||||
updatedAt: { lte: cutoff },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
applicationId: true,
|
||||
channelId: true,
|
||||
connectionId: true,
|
||||
desiredConnections: true,
|
||||
currentConnections: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
let failed = 0;
|
||||
for (const state of states) {
|
||||
const result = await this.prisma.cmppConnectionState.updateMany({
|
||||
where: {
|
||||
id: state.id,
|
||||
status: 'connecting',
|
||||
updatedAt: { lte: cutoff },
|
||||
},
|
||||
data: {
|
||||
status: 'failed',
|
||||
currentConnections: 0,
|
||||
lastDisconnectedAt: now,
|
||||
lastError,
|
||||
},
|
||||
});
|
||||
if (result.count === 0) {
|
||||
continue;
|
||||
}
|
||||
failed += result.count;
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: state.tenantId,
|
||||
action: 'cmpp_connection.failed',
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${state.channelId}:${state.connectionId}`,
|
||||
detail: {
|
||||
reason: 'connect_timeout',
|
||||
applicationId: state.applicationId,
|
||||
status: 'failed',
|
||||
previousStatus: 'connecting',
|
||||
desiredConnections: state.desiredConnections,
|
||||
currentConnectionsBefore: state.currentConnections,
|
||||
currentConnections: 0,
|
||||
timeoutMs,
|
||||
lastError,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { checked: states.length, failed };
|
||||
}
|
||||
|
||||
listGroups() {
|
||||
return this.prisma.smsChannelGroup.findMany({
|
||||
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||
@@ -582,6 +705,25 @@ export class ChannelsService {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteGroup(groupId: string) {
|
||||
const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } });
|
||||
if (!group) {
|
||||
throw new NotFoundException('Channel group not found');
|
||||
}
|
||||
const boundRoute = await this.prisma.channelRouteRule.findFirst({
|
||||
where: {
|
||||
groupId,
|
||||
status: 'active',
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (boundRoute) {
|
||||
throw new BadRequestException('Channel group is used by application route rules and cannot be deleted');
|
||||
}
|
||||
await this.prisma.smsChannelGroupItem.deleteMany({ where: { groupId } });
|
||||
return this.prisma.smsChannelGroup.delete({ where: { id: groupId } });
|
||||
}
|
||||
|
||||
listRouteRules() {
|
||||
return this.prisma.channelRouteRule.findMany({
|
||||
include: { group: true, channel: true },
|
||||
@@ -796,11 +938,116 @@ export class ChannelsService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async requestChannelConnection(
|
||||
channel: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
gatewayHost: string;
|
||||
gatewayPort: number;
|
||||
account: string;
|
||||
passwordCipher: string;
|
||||
srcId: string;
|
||||
cmppVersion: string;
|
||||
rateLimitPerSecond: number;
|
||||
config?: Prisma.JsonValue | null;
|
||||
},
|
||||
reason: 'channel_created' | 'channel_enabled',
|
||||
operatorId?: string,
|
||||
) {
|
||||
const desiredConnections = getDesiredConnections(channel.config);
|
||||
const connectionId = defaultChannelConnectionId(channel.id);
|
||||
const existing = await this.prisma.cmppConnectionState.findFirst({
|
||||
where: {
|
||||
applicationId: null,
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
},
|
||||
});
|
||||
const data = {
|
||||
applicationId: null,
|
||||
status: 'connecting',
|
||||
desiredConnections,
|
||||
currentConnections: 0,
|
||||
lastError: null,
|
||||
};
|
||||
const state = existing
|
||||
? await this.prisma.cmppConnectionState.update({ where: { id: existing.id }, data })
|
||||
: await this.prisma.cmppConnectionState.create({
|
||||
data: {
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
userId: operatorId,
|
||||
action: 'cmpp_connection.connect_requested',
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${channel.id}:${connectionId}`,
|
||||
detail: {
|
||||
reason,
|
||||
status: state.status,
|
||||
desiredConnections: state.desiredConnections,
|
||||
currentConnections: state.currentConnections,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
const command = {
|
||||
schemaVersion: 'v1',
|
||||
messageType: 'ConnectChannel',
|
||||
traceId: randomUUID(),
|
||||
channelId: channel.id,
|
||||
connectionId,
|
||||
createdAt: new Date().toISOString(),
|
||||
reason,
|
||||
desiredConnections,
|
||||
channel: {
|
||||
code: channel.code,
|
||||
name: channel.name,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: channel.gatewayPort,
|
||||
account: channel.account,
|
||||
passwordCipher: channel.passwordCipher,
|
||||
srcId: channel.srcId,
|
||||
cmppVersion: channel.cmppVersion,
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
},
|
||||
};
|
||||
await this.getGatewayConnectionQueue().add('connect-channel', command, { jobId: `${connectionId}:connect` });
|
||||
await this.notifyGatewayConnect(command);
|
||||
return state;
|
||||
}
|
||||
|
||||
private getGatewayConnectionQueue() {
|
||||
this.gatewayConnectionQueue ??= new Queue(GATEWAY_CONNECTION_QUEUE, { connection: bullmqConnection() });
|
||||
return this.gatewayConnectionQueue;
|
||||
}
|
||||
|
||||
private async notifyGatewayConnect(command: Record<string, unknown>) {
|
||||
const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? DEFAULT_GATEWAY_CONTROL_URL).replace(/\/+$/, '');
|
||||
let response: { ok: boolean; status: number; text: () => Promise<string> };
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/connections/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(command),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Gateway connect request failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new BadRequestException(`Gateway connect request failed: ${response.status} ${responseText}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConnectionAction(status: string) {
|
||||
const normalized = status.toLowerCase();
|
||||
if (['online', 'connected', 'open'].includes(normalized)) {
|
||||
if (normalized === 'connected') {
|
||||
return 'connected';
|
||||
}
|
||||
if (['heartbeat', 'active_test'].includes(normalized)) {
|
||||
@@ -812,9 +1059,66 @@ function normalizeConnectionAction(status: string) {
|
||||
if (['offline', 'closed', 'disconnected'].includes(normalized)) {
|
||||
return 'disconnected';
|
||||
}
|
||||
if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) {
|
||||
return 'failed';
|
||||
}
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
function normalizeGatewayConnectionStatus(status: string) {
|
||||
const normalized = status.toLowerCase();
|
||||
if (['online', 'open', 'connected'].includes(normalized)) {
|
||||
return 'connected';
|
||||
}
|
||||
if (['connecting', 'connect_requested'].includes(normalized)) {
|
||||
return 'connecting';
|
||||
}
|
||||
if (['reconnecting', 'reconnect'].includes(normalized)) {
|
||||
return 'reconnecting';
|
||||
}
|
||||
if (['offline', 'closed', 'disconnected'].includes(normalized)) {
|
||||
return 'disconnected';
|
||||
}
|
||||
if (['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(normalized)) {
|
||||
return 'failed';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function defaultChannelConnectionId(channelId: string) {
|
||||
return `${channelId}:${DEFAULT_CHANNEL_CONNECTION_ID}`;
|
||||
}
|
||||
|
||||
function getDesiredConnections(config?: Prisma.JsonValue | null) {
|
||||
if (config && typeof config === 'object' && !Array.isArray(config) && 'desiredConnections' in config) {
|
||||
const value = Number(config.desiredConnections);
|
||||
if (Number.isInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function bullmqConnection() {
|
||||
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
|
||||
return {
|
||||
host: redisUrl.hostname,
|
||||
port: Number(redisUrl.port || 6379),
|
||||
username: redisUrl.username || undefined,
|
||||
password: redisUrl.password || undefined,
|
||||
maxRetriesPerRequest: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getPositiveIntegerEnv(name: string, fallback: number) {
|
||||
const value = Number(process.env[name]);
|
||||
if (Number.isInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
|
||||
function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
|
||||
const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
@@ -990,8 +1294,11 @@ function validateGroupItems(
|
||||
}
|
||||
|
||||
function normalizeLinkEvent(action: string) {
|
||||
if (action.includes('connect_requested')) {
|
||||
return '连接请求';
|
||||
}
|
||||
if (action.includes('connected')) {
|
||||
return '新建';
|
||||
return '连接成功';
|
||||
}
|
||||
if (action.includes('heartbeat')) {
|
||||
return '心跳';
|
||||
@@ -1002,6 +1309,9 @@ function normalizeLinkEvent(action: string) {
|
||||
if (action.includes('disconnected')) {
|
||||
return '断开';
|
||||
}
|
||||
if (action.includes('failed')) {
|
||||
return '连接失败';
|
||||
}
|
||||
if (action.includes('copy')) {
|
||||
return '复制';
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { BadRequestException, Body, Controller, Get, Param, Post, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
@@ -11,6 +11,11 @@ type UploadedMultipartFile = {
|
||||
buffer: Buffer;
|
||||
};
|
||||
|
||||
type DownloadResponse = {
|
||||
setHeader(name: string, value: number | string): void;
|
||||
send(content: Buffer): void;
|
||||
};
|
||||
|
||||
@ApiTags('files')
|
||||
@Controller('admin/files')
|
||||
export class FilesController {
|
||||
@@ -21,6 +26,17 @@ export class FilesController {
|
||||
return this.files.list(tenantId);
|
||||
}
|
||||
|
||||
@Get(':id/download')
|
||||
async download(@Param('id') id: string, @Query('disposition') disposition: string | undefined, @Res() response: DownloadResponse) {
|
||||
const { fileObject, content } = await this.files.getDownload(id);
|
||||
const mode = disposition === 'inline' ? 'inline' : 'attachment';
|
||||
const encodedName = encodeURIComponent(fileObject.fileName);
|
||||
response.setHeader('Content-Type', fileObject.contentType || 'application/octet-stream');
|
||||
response.setHeader('Content-Length', content.length);
|
||||
response.setHeader('Content-Disposition', `${mode}; filename*=UTF-8''${encodedName}`);
|
||||
response.send(content);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() body: CreateFileObjectDto) {
|
||||
return this.files.create(body);
|
||||
@@ -34,6 +50,9 @@ export class FilesController {
|
||||
@Post('upload')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 20 * 1024 * 1024 } }))
|
||||
upload(@UploadedFile() file: UploadedMultipartFile, @Body('purpose') purpose: string, @Body('prefix') prefix?: string, @TenantId() tenantId?: string) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('Upload file is required');
|
||||
}
|
||||
return this.files.upload({ tenantId, purpose: purpose || 'general', prefix }, file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('FilesService', () => {
|
||||
bucket: 'cmpp-platform',
|
||||
fileName: '营业执照.png',
|
||||
contentType: 'image/png',
|
||||
sizeBytes: '12',
|
||||
purpose: 'signature_material',
|
||||
}));
|
||||
|
||||
@@ -47,4 +48,39 @@ describe('FilesService', () => {
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('downloads file content from object storage by FileObject id', async () => {
|
||||
const fileObject = {
|
||||
id: 'file-1',
|
||||
tenantId: 'tenant-1',
|
||||
bucket: 'cmpp-platform',
|
||||
objectKey: 'signature-materials/sig-1/file.png',
|
||||
fileName: 'file.png',
|
||||
contentType: 'image/png',
|
||||
sizeBytes: BigInt(12),
|
||||
checksum: null,
|
||||
purpose: 'signature_material',
|
||||
createdAt: new Date('2026-07-06T00:00:00.000Z'),
|
||||
};
|
||||
const prisma = {
|
||||
fileObject: {
|
||||
findUnique: jest.fn().mockResolvedValue(fileObject),
|
||||
},
|
||||
};
|
||||
const objectStorage = {
|
||||
getObject: jest.fn().mockResolvedValue(Buffer.from('file-content')),
|
||||
};
|
||||
const service = new FilesService(prisma as never, objectStorage as never);
|
||||
|
||||
await expect(service.getDownload('file-1')).resolves.toEqual({
|
||||
fileObject: expect.objectContaining({
|
||||
id: 'file-1',
|
||||
fileName: 'file.png',
|
||||
sizeBytes: '12',
|
||||
}),
|
||||
content: Buffer.from('file-content'),
|
||||
});
|
||||
expect(prisma.fileObject.findUnique).toHaveBeenCalledWith({ where: { id: 'file-1' } });
|
||||
expect(objectStorage.getObject).toHaveBeenCalledWith('signature-materials/sig-1/file.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -38,7 +38,7 @@ export class FilesService {
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}).then((items) => items.map(serializeFileObject));
|
||||
}
|
||||
|
||||
create(data: CreateFileObjectDto) {
|
||||
@@ -52,7 +52,7 @@ export class FilesService {
|
||||
checksum: data.checksum,
|
||||
purpose: data.purpose,
|
||||
};
|
||||
return this.prisma.fileObject.create({ data: createData });
|
||||
return this.prisma.fileObject.create({ data: createData }).then(serializeFileObject);
|
||||
}
|
||||
|
||||
async createPresignedUpload(data: CreatePresignedUploadDto) {
|
||||
@@ -79,4 +79,23 @@ export class FilesService {
|
||||
purpose: data.purpose,
|
||||
});
|
||||
}
|
||||
|
||||
async getDownload(id: string) {
|
||||
const fileObject = await this.prisma.fileObject.findUnique({ where: { id } });
|
||||
if (!fileObject) {
|
||||
throw new NotFoundException('File object not found');
|
||||
}
|
||||
const content = await this.objectStorage.getObject(fileObject.objectKey);
|
||||
return {
|
||||
fileObject: serializeFileObject(fileObject),
|
||||
content,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function serializeFileObject<T extends { sizeBytes: bigint | number | string }>(fileObject: T) {
|
||||
return {
|
||||
...fileObject,
|
||||
sizeBytes: fileObject.sizeBytes.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Client } from 'minio';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
@Injectable()
|
||||
export class ObjectStorageService {
|
||||
private readonly client: Client;
|
||||
private readonly bucket: string;
|
||||
private readonly driver: string;
|
||||
private readonly localRoot: string;
|
||||
private bucketReady = false;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
const endpoint = config.get<string>('MINIO_ENDPOINT') ?? 'localhost:9000';
|
||||
const [endPoint, portText] = endpoint.split(':');
|
||||
this.bucket = config.get<string>('MINIO_BUCKET') ?? 'cmpp-platform';
|
||||
this.driver = config.get<string>('OBJECT_STORAGE_DRIVER') ?? 'minio';
|
||||
this.localRoot = config.get<string>('OBJECT_STORAGE_LOCAL_ROOT') ?? join(process.cwd(), '..', '.local-data', 'object-storage');
|
||||
this.client = new Client({
|
||||
endPoint,
|
||||
port: Number(portText ?? 9000),
|
||||
@@ -20,17 +27,52 @@ export class ObjectStorageService {
|
||||
});
|
||||
}
|
||||
|
||||
presignedPutObject(objectKey: string, expirySeconds = 3600) {
|
||||
async presignedPutObject(objectKey: string, expirySeconds = 3600) {
|
||||
if (this.driver === 'local') {
|
||||
return Promise.resolve(`local://${this.bucket}/${objectKey}?expires=${expirySeconds}`);
|
||||
}
|
||||
await this.ensureBucket();
|
||||
return this.client.presignedPutObject(this.bucket, objectKey, expirySeconds);
|
||||
}
|
||||
|
||||
putObject(objectKey: string, content: Buffer, sizeBytes: number, contentType: string) {
|
||||
async putObject(objectKey: string, content: Buffer, sizeBytes: number, contentType: string) {
|
||||
if (this.driver === 'local') {
|
||||
const filePath = join(this.localRoot, this.bucket, ...objectKey.split('/'));
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, content);
|
||||
return { etag: `local-${sizeBytes}-${contentType}` };
|
||||
}
|
||||
await this.ensureBucket();
|
||||
return this.client.putObject(this.bucket, objectKey, content, sizeBytes, {
|
||||
'Content-Type': contentType,
|
||||
});
|
||||
}
|
||||
|
||||
async getObject(objectKey: string) {
|
||||
if (this.driver === 'local') {
|
||||
return readFile(join(this.localRoot, this.bucket, ...objectKey.split('/')));
|
||||
}
|
||||
await this.ensureBucket();
|
||||
const stream = await this.client.getObject(this.bucket, objectKey);
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
getBucket() {
|
||||
return this.bucket;
|
||||
}
|
||||
|
||||
private async ensureBucket() {
|
||||
if (this.bucketReady) {
|
||||
return;
|
||||
}
|
||||
const exists = await this.client.bucketExists(this.bucket);
|
||||
if (!exists) {
|
||||
await this.client.makeBucket(this.bucket, '');
|
||||
}
|
||||
this.bucketReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ function createPrismaMock() {
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
},
|
||||
cmppConnectionState: {
|
||||
groupBy: jest.fn().mockResolvedValue([{ status: 'online', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }]),
|
||||
groupBy: jest.fn().mockResolvedValue([{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }]),
|
||||
},
|
||||
operationLog: {
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
@@ -115,7 +115,7 @@ describe('OperationsService', () => {
|
||||
taskCount: 3,
|
||||
uplinkCount: 1,
|
||||
pendingAuditCount: 6,
|
||||
gatewayConnections: [{ status: 'online', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }],
|
||||
gatewayConnections: [{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }],
|
||||
}),
|
||||
);
|
||||
await service.statistics({ tenantId: 'tenant-1', groupBy: 'application' });
|
||||
|
||||
@@ -33,7 +33,7 @@ function createPrismaMock() {
|
||||
carrier: 'mobile',
|
||||
sendRegion: '全国',
|
||||
config: { serviceId: 'SMS' },
|
||||
connectionStates: [{ status: 'online', currentConnections: 1, desiredConnections: 1 }],
|
||||
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
|
||||
};
|
||||
const route = {
|
||||
id: 'route-1',
|
||||
@@ -485,7 +485,7 @@ describe('SendChainService', () => {
|
||||
status: 'active',
|
||||
carrier: 'all',
|
||||
sendRegion: '全国',
|
||||
connectionStates: [{ status: 'online', currentConnections: 1, desiredConnections: 1 }],
|
||||
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -505,7 +505,7 @@ describe('SendChainService', () => {
|
||||
status: 'active',
|
||||
carrier: 'all',
|
||||
sendRegion: '全国',
|
||||
connectionStates: [{ status: 'online', currentConnections: 1, desiredConnections: 1 }],
|
||||
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -863,7 +863,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return false;
|
||||
}
|
||||
return (channel.connectionStates ?? []).some((connection) =>
|
||||
connection.desiredConnections > 0 && connection.currentConnections > 0 && ['online', 'connected'].includes(connection.status),
|
||||
connection.desiredConnections > 0 && connection.currentConnections > 0 && connection.status === 'connected',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -81,8 +81,8 @@ function createPrismaMock() {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
cmppConnectionState: {
|
||||
findMany: jest.fn().mockResolvedValue([{ channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1', status: 'online', currentConnections: 1, desiredConnections: 1 }]),
|
||||
findFirst: jest.fn().mockResolvedValue({ channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1' }),
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'conn-state-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1', status: 'connected', currentConnections: 1, desiredConnections: 1 }]),
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'conn-state-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1' }),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-state-1', ...data })),
|
||||
},
|
||||
smsChannel: {
|
||||
@@ -256,7 +256,7 @@ describe('SmsConfigService', () => {
|
||||
await service.disconnectApplicationConnection('app-1', 'conn-a', { status: 'disconnected', reason: 'manual' });
|
||||
|
||||
expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({
|
||||
where: { channelId_connectionId: { channelId: 'channel-1', connectionId: 'conn-a' } },
|
||||
where: { id: 'conn-state-1' },
|
||||
data: expect.objectContaining({ status: 'disconnected', currentConnections: 0, lastError: 'manual' }),
|
||||
});
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
|
||||
@@ -110,15 +110,15 @@ export class SmsConfigService {
|
||||
if (!query.includeConnections) {
|
||||
return applications;
|
||||
}
|
||||
const tenantIds = [...new Set(applications.map((application) => application.tenantId))];
|
||||
const applicationIds = applications.map((application) => application.id);
|
||||
const connections = await this.prisma.cmppConnectionState.findMany({
|
||||
where: { tenantId: { in: tenantIds } },
|
||||
where: { applicationId: { in: applicationIds } },
|
||||
include: { channel: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
return applications.map((application) => {
|
||||
const appConnections = connections.filter((connection) => connection.tenantId === application.tenantId);
|
||||
const appConnections = connections.filter((connection) => connection.applicationId === application.id);
|
||||
const todayTotal = application.messageRecords.length;
|
||||
const delivered = application.messageRecords.filter((message) => message.status === 'delivered').length;
|
||||
return {
|
||||
@@ -298,7 +298,7 @@ export class SmsConfigService {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const connections = await this.prisma.cmppConnectionState.findMany({
|
||||
where: { tenantId: application.tenantId },
|
||||
where: { applicationId },
|
||||
include: { channel: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 100,
|
||||
@@ -351,13 +351,13 @@ export class SmsConfigService {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const connection = await this.prisma.cmppConnectionState.findFirst({
|
||||
where: { tenantId: application.tenantId, connectionId },
|
||||
where: { applicationId, connectionId },
|
||||
});
|
||||
if (!connection) {
|
||||
throw new NotFoundException('Connection not found');
|
||||
}
|
||||
const updated = await this.prisma.cmppConnectionState.update({
|
||||
where: { channelId_connectionId: { channelId: connection.channelId, connectionId } },
|
||||
where: { id: connection.id },
|
||||
data: {
|
||||
status: 'disconnected',
|
||||
currentConnections: 0,
|
||||
@@ -742,7 +742,7 @@ function normalizeApplicationCmppStatus(connections: Array<{ status: string; cur
|
||||
if (applicationStatus !== 'active') {
|
||||
return 'inactive';
|
||||
}
|
||||
if (connections.some((connection) => ['online', 'connected', 'open'].includes(connection.status) && connection.currentConnections > 0)) {
|
||||
if (connections.some((connection) => connection.status === 'connected' && connection.currentConnections > 0)) {
|
||||
return 'connected';
|
||||
}
|
||||
if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) {
|
||||
|
||||
@@ -12,6 +12,11 @@ export class TenantsController {
|
||||
return this.tenants.list();
|
||||
}
|
||||
|
||||
@Get('management-list')
|
||||
listManagementRows() {
|
||||
return this.tenants.listManagementRows();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
return this.tenants.get(id);
|
||||
|
||||
@@ -22,6 +22,12 @@ function createPrismaMock() {
|
||||
create: jest.fn().mockResolvedValue({ id: 'cert-1' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'cert-1' }),
|
||||
},
|
||||
tenantAccount: {
|
||||
findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 12000, smsUnits: 300, creditCents: 5000, status: 'active' }]),
|
||||
},
|
||||
smsMessageRecord: {
|
||||
groupBy: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', _sum: { amountCents: 350 } }]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,4 +77,25 @@ describe('TenantsService', () => {
|
||||
await expect(service.delete('missing')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(prisma.tenant.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists management rows with real account and today spend fields', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new TenantsService(prisma as never);
|
||||
|
||||
await expect(service.listManagementRows()).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'tenant-1',
|
||||
name: '测试企业',
|
||||
account: expect.objectContaining({ balanceCents: 12000, creditCents: 5000 }),
|
||||
todaySpendCents: 350,
|
||||
}),
|
||||
]);
|
||||
expect(prisma.tenant.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { status: { not: 'deleted' } },
|
||||
}));
|
||||
expect(prisma.smsMessageRecord.groupBy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
by: ['tenantId'],
|
||||
_sum: { amountCents: true },
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface CreateTenantDto {
|
||||
name: string;
|
||||
code: string;
|
||||
code?: string;
|
||||
status?: string;
|
||||
creditCode?: string;
|
||||
province?: string;
|
||||
@@ -44,6 +44,31 @@ export class TenantsService {
|
||||
}).then((items) => items.map(withEnterpriseProfile));
|
||||
}
|
||||
|
||||
async listManagementRows() {
|
||||
const sinceToday = startOfToday();
|
||||
const [tenants, accounts, todaySpendGroups] = await Promise.all([
|
||||
this.prisma.tenant.findMany({
|
||||
where: { status: { not: 'deleted' } },
|
||||
include: { enterpriseCertifications: { orderBy: { submittedAt: 'desc' }, take: 1 } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
}),
|
||||
this.prisma.tenantAccount.findMany({ take: 200 }),
|
||||
this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['tenantId'],
|
||||
where: { queuedAt: { gte: sinceToday } },
|
||||
_sum: { amountCents: true },
|
||||
}),
|
||||
]);
|
||||
const accountsByTenant = new Map(accounts.map((account) => [account.tenantId, account]));
|
||||
const todaySpendByTenant = new Map(todaySpendGroups.map((group) => [group.tenantId, group._sum.amountCents ?? 0]));
|
||||
return tenants.map((tenant) => ({
|
||||
...withEnterpriseProfile(tenant),
|
||||
account: accountsByTenant.get(tenant.id) ?? null,
|
||||
todaySpendCents: todaySpendByTenant.get(tenant.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
get(id: string) {
|
||||
return this.prisma.tenant.findUnique({
|
||||
where: { id },
|
||||
@@ -57,8 +82,9 @@ export class TenantsService {
|
||||
}
|
||||
|
||||
async create(data: CreateTenantDto) {
|
||||
const code = data.code?.trim() || generateTenantCode(data);
|
||||
const tenant = await this.prisma.tenant.create({
|
||||
data: { name: data.name, code: data.code, status: data.status ?? 'active' },
|
||||
data: { name: data.name, code, status: data.status ?? 'active' },
|
||||
});
|
||||
await this.upsertProfile(tenant.id, data);
|
||||
return this.get(tenant.id);
|
||||
@@ -163,3 +189,16 @@ function withEnterpriseProfile<T extends { enterpriseCertifications?: Array<{ li
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
function startOfToday() {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
function generateTenantCode(data: CreateTenantDto) {
|
||||
const source = data.creditCode?.trim() || data.name.trim();
|
||||
const normalized = source.replace(/[^\da-zA-Z]/g, '').toLowerCase();
|
||||
const suffix = Date.now().toString(36).slice(-6);
|
||||
return `ent-${(normalized || 'tenant').slice(0, 18)}-${suffix}`;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -217,7 +217,7 @@
|
||||
|
||||
问题:
|
||||
|
||||
- 创建、复制、启停、删除、链接日志已经接真实 API。
|
||||
- 创建、复制、启停、删除、连接日志已经接真实 API。
|
||||
- 旧实现编辑通道时直接提示“短信通道编辑接口待补,当前不做本地模拟保存”。
|
||||
|
||||
影响:
|
||||
|
||||
@@ -149,12 +149,13 @@
|
||||
14. 通道组明细 `carrier` 必须保留并参与发送逻辑,且必须等于通道组运营商。三网只允许作为通道本体能力 `SmsChannel.carrier=all`,表示该通道可被运营分配到移动、联通或电信通道组;一旦放入某个通道组,只能服务该通道组所属运营商。
|
||||
15. 通道组明细引用通道时必须校验通道能力:移动组只能引用移动通道或三网通道,联通组只能引用联通通道或三网通道,电信组只能引用电信通道或三网通道。
|
||||
16. 未命中企业应用通道组或无可用通道时不得 fallback 到全局第一个 active 通道,应将该短信标记为 failed,并记录可读失败原因、trace 和系统日志;客户端本期不展示通道细节。
|
||||
17. 可发送通道必须同时满足:通道业务状态 active、CMPP 连接状态 online、当前连接数大于 0、心跳未失败;auth_failed、heartbeat_timeout、reconnecting、disconnected 或当前连接数为 0 的通道均不可被选中。
|
||||
18. 多连接通道只要至少 1 条连接 online 且可用即可参与路由;心跳失败应及时更新连接状态,连续 3 次心跳失败后进入重连,重连成功前不可发送。
|
||||
19. 通道异常时应支持熔断、降级、切换备用通道和失败重试;失败补发必须在同一企业应用授权的通道组范围内执行,并使用触发补发时的当前通道组配置,保证计费、退款、幂等和 trace 可追踪。
|
||||
20. 失败补发除以下情况外均应触发:短信状态为 unknown;距离客户提交时间超过 72 小时;距离客户提交时间超过通道组配置的补发时间上限;通道组关闭失败补发。
|
||||
21. 通道组补发时间上限由运营端配置,最大不得超过 72 小时;本期不配置最大补发次数、补发间隔、失败类型白名单或人工重发能力,进入最终 failed/timeout 后不再人工重发。
|
||||
22. 提交 accepted 后立即按企业应用配置的客户费率扣费;补发过程中最终成功只扣一次,submit failed 未真正发出时释放冻结且不扣费;failed receipt 导致最终全失败时退款;本期客户计费不使用通道成本价。
|
||||
17. 可发送通道必须同时满足:通道业务状态 active、CMPP 连接状态 connected、期望连接数大于 0、当前连接数大于 0、心跳未失败;auth_failed、heartbeat_timeout、reconnecting、disconnected、failed 或当前连接数为 0 的通道均不可被选中。Gateway 旧回写词 online/open 仅允许在 API 入库时兼容归一化为 connected,不作为发送判断状态。
|
||||
18. 多连接通道只要至少 1 条连接 connected 且可用即可参与路由;心跳失败应及时更新连接状态,连续 3 次心跳失败后进入重连,重连成功前不可发送。
|
||||
19. 新建或启用通道后,系统应立即创建/更新默认 CMPP 连接状态为 connecting 并通知 Go Gateway 发起连接;如果 connecting 超过 30 秒仍未收到 Gateway 回写 connected 或 failed,API 后台兜底任务必须将该连接标记为 failed,写入 `lastError=Gateway connection request timed out after 30 seconds` 和连接日志。默认扫描间隔 5 秒,可通过环境变量调整。
|
||||
20. 通道异常时应支持熔断、降级、切换备用通道和失败重试;失败补发必须在同一企业应用授权的通道组范围内执行,并使用触发补发时的当前通道组配置,保证计费、退款、幂等和 trace 可追踪。
|
||||
21. 失败补发除以下情况外均应触发:短信状态为 unknown;距离客户提交时间超过 72 小时;距离客户提交时间超过通道组配置的补发时间上限;通道组关闭失败补发。
|
||||
22. 通道组补发时间上限由运营端配置,最大不得超过 72 小时;本期不配置最大补发次数、补发间隔、失败类型白名单或人工重发能力,进入最终 failed/timeout 后不再人工重发。
|
||||
23. 提交 accepted 后立即按企业应用配置的客户费率扣费;补发过程中最终成功只扣一次,submit failed 未真正发出时释放冻结且不扣费;failed receipt 导致最终全失败时退款;本期客户计费不使用通道成本价。
|
||||
|
||||
### 4.7 通道签名报备
|
||||
|
||||
@@ -297,7 +298,7 @@
|
||||
- 支持查看通道成功率、未知率、失败率、累计发送量。
|
||||
- 支持进入通道报备详情。
|
||||
- 通道报备详情页中,签名下的引流信息默认收起,用户点击后展开;展开/收起只影响页面展示,不改变报备数据。
|
||||
- 通道列表状态区域展示“链接日志”入口;点击后弹窗展示真实连接日志,包括新建连接、断开、心跳、重连、异常等事件,日志来源于 Gateway 回写或 OperationLog。
|
||||
- 通道列表状态区域展示“连接日志”入口;点击后弹窗展示真实连接日志,包括连接请求、连接成功、断开、心跳、重连、异常等事件,日志来源于 Gateway 回写或 OperationLog。
|
||||
- 通道操作按钮应保持一致的两列布局,报备详情、编辑、复制、发送测试、启停、删除等操作文案清晰。
|
||||
|
||||
### 5.14 运营端通道组管理
|
||||
|
||||
@@ -467,17 +467,17 @@
|
||||
- 删除连接调用真实后端或 Gateway 接口,连接状态刷新并写系统日志。
|
||||
- CMPP 参数来源于真实应用/通道配置,一键复制内容与 API 返回一致。
|
||||
|
||||
### TC-ADMIN-019 通道链接日志展示
|
||||
### TC-ADMIN-019 通道连接日志展示
|
||||
|
||||
- 优先级:P1
|
||||
- 前置条件:Gateway 或测试替身已产生新建连接、心跳、断开、重连、异常事件。
|
||||
- 前置条件:Gateway 或测试替身已产生连接请求、连接成功、心跳、断开、重连、异常事件。
|
||||
- 步骤:
|
||||
1. 打开短信通道管理页面。
|
||||
2. 在状态区域点击链接日志。
|
||||
2. 在状态区域点击连接日志。
|
||||
3. 查看日志时间、事件类型、连接 id、详情。
|
||||
- 预期结果:
|
||||
- 链接日志由真实后端 API 返回,不使用前端硬编码数据。
|
||||
- 日志包含新建连接、断开、心跳、重连、异常等事件。
|
||||
- 连接日志由真实后端 API 返回,不使用前端硬编码数据。
|
||||
- 日志包含连接请求、连接成功、断开、心跳、重连、异常等事件。
|
||||
- 日志按时间倒序展示,并可定位到对应通道或连接。
|
||||
|
||||
### TC-ADMIN-020 安全控制搜索、添加、启停与删除
|
||||
@@ -2585,7 +2585,7 @@ npm run verify:phase8
|
||||
| TC-ADMIN-016 | 复制通道,随后查询新通道详情、报备字段、签名报备材料。 | 新通道 code/id 唯一;CMPP 参数、限速、报备字段、材料被复制;源通道不受影响;复制日志包含 sourceChannelId 和 newChannelId。 |
|
||||
| TC-ADMIN-017 | 对有关联历史的通道执行停用、启用、删除。 | 取消确认无请求或无状态变化;停用后不参与路由;删除为软删除/归档;历史发送、报备、日志仍可查。 |
|
||||
| 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-021 | 创建待审核企业认证、签名、模板、短信审核任务,检查铃铛总数和分类数。 | 总数等于分类汇总;点击分类跳转并带入筛选;审核完成后数量刷新;新增待办触发站内提醒或浏览器通知。 |
|
||||
| TC-ADMIN-022 | 运营日志按客户、操作者、动作、资源、时间搜索,查看长详情。 | 后端分页和搜索准确;详情不截断;可查到通道复制、启停、删除、连接状态变化、安全控制变更、充值等日志。 |
|
||||
@@ -2636,7 +2636,7 @@ npm run verify:phase8
|
||||
| TC-CUSTOMER-008 | 删除/归档有历史数据的客户。 | 不允许硬删除或执行归档;新发送和未执行 scheduled 阻断;历史 trace/对账可查。 |
|
||||
| TC-CUSTOMER-009 | 客户详情总览应用、签名、模板、今日发送、余额。 | 各指标与明细列表聚合一致;跳转带客户筛选;异常状态有标识。 |
|
||||
| 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-013 | 超过通道最大连接数分配。 | 保存失败;提示最大连接数、已分配数和可用数;不影响已有连接;失败日志存在。 |
|
||||
|
||||
@@ -2645,21 +2645,22 @@ npm run verify:phase8
|
||||
| 用例 | 模拟方式 | 必查断言 |
|
||||
| --- | --- | --- |
|
||||
| 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-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-007 | 所有通道离线或认证失败。 | 不提交到离线连接;任务 delayed/retry/failed/pending_channel;不错误扣费。 |
|
||||
| TC-CMPP-STATUS-008 | 通道业务 disabled 但连接 online。 | 不参与路由;连接状态仍可运维观察;启用后按连接状态恢复可用性。 |
|
||||
| TC-CMPP-STATUS-008 | 通道业务 disabled 但连接 connected。 | 不参与路由;连接状态仍可运维观察;启用后按连接状态恢复可用性。 |
|
||||
| TC-CMPP-STATUS-009 | 模拟 submit resp 慢响应。 | 窗口占用、慢响应、队列积压可见;恢复后积压下降;超时可追踪。 |
|
||||
| TC-CMPP-STATUS-010 | 触发 online/disconnected/reconnecting/online。 | 每次变化有状态历史、健康指标和系统日志。 |
|
||||
| TC-CMPP-STATUS-010 | 触发 connected/disconnected/reconnecting/connected。 | 每次变化有状态历史、健康指标和系统日志。 |
|
||||
| TC-CMPP-STATUS-011 | 通道 maxConnections=4、desired=2、current=2。 | 通道详情、监控、Dashboard 连接数一致;连接列表展示 connectionId、心跳、窗口、sequence。 |
|
||||
| TC-CMPP-STATUS-012 | desired 1 调整为 3。 | Gateway 建立新连接至 3/3;任务可按连接/窗口分摊;日志记录调整。 |
|
||||
| TC-CMPP-STATUS-013 | desired 3 调整为 1。 | 多余连接优雅关闭;未确认 submit 不丢失不重复;终态 1/1。 |
|
||||
| TC-CMPP-STATUS-014 | 3 条连接中断 1 条。 | 展示 degraded 或 2/3 online;异常连接数增加;重连恢复后 3/3。 |
|
||||
| TC-CMPP-STATUS-014 | 3 条连接中断 1 条。 | 展示 degraded 或 2/3 connected;异常连接数增加;重连恢复后 3/3。 |
|
||||
| TC-CMPP-STATUS-015 | desired=0 或 current=0。 | 路由不选择该通道;无备用时任务失败或等待;原因包含无在线连接。 |
|
||||
| TC-CMPP-STATUS-016 | 单连接限速 100,连接数 1 和 2 分别压测。 | 理论能力随在线连接数变化;实际 TPS 不超过限速;不重复发送。 |
|
||||
| TC-CMPP-STATUS-017 | 新建/启用通道后 Gateway 未回写,连接状态停留 connecting 超过 30 秒。 | API 兜底任务将连接标记为 failed,currentConnections=0,lastError 为 `Gateway connection request timed out after 30 seconds`;连接日志包含 connect_timeout;发送路由不可选择该通道。 |
|
||||
|
||||
### 17.9 自动化落地建议
|
||||
|
||||
@@ -2668,7 +2669,7 @@ npm run verify:phase8
|
||||
| API Jest | 认证、字典、安全控制、通道复制/软删除、连接状态、人工充值、系统日志查询、Dashboard 聚合口径。 |
|
||||
| HTTP Smoke | 客户创建、认证审核、通道复制、连接状态回写、人工充值、立即发送、定时到点、trace、reconciliation。 |
|
||||
| Go Gateway | 连接状态回写契约、登录成功/失败、心跳超时、断线重连、窗口占满、连接数调整。 |
|
||||
| 前端 Smoke | 客户端头像菜单、系统日志分页、运营模板审核搜索、企业认证详情、通道复制/链接日志、安全控制 CRUD、Dashboard 指标跳转。 |
|
||||
| 前端 Smoke | 客户端头像菜单、系统日志分页、运营模板审核搜索、企业认证详情、通道复制/连接日志、安全控制 CRUD、Dashboard 指标跳转。 |
|
||||
| 性能 Smoke | BullMQ 500 TPS、CMPP 连接数变化后的提交能力、慢响应积压恢复。 |
|
||||
|
||||
### 17.9 登录和用户管理闭环
|
||||
|
||||
+109
-6
@@ -1,5 +1,60 @@
|
||||
# 第一版系统化测试进度
|
||||
|
||||
## 2026-07-06 企业管理列表字段回归
|
||||
|
||||
- 按设计锚点 `131f344a^` 恢复运营端企业管理列表字段:企业 ID、企业名称、当前余额、透支限额、今日消费、企业状态、操作。
|
||||
- 新增真实后端接口 `GET /api/admin/tenants/management-list`,由 NestJS/Prisma 聚合租户、企业账户和当天短信消息金额;前端不再用静态字段或本地假数拼出今日消费。
|
||||
- 当前余额来自 `TenantAccount.balanceCents`,透支限额来自 `TenantAccount.creditCents`,今日消费来自当天 `SmsMessageRecord.amountCents` 汇总。
|
||||
- 已执行:
|
||||
- `npm --prefix api test -- tenants.service.spec.ts --runInBand`
|
||||
- `npm --prefix api run build`
|
||||
- `npm run build`
|
||||
- 验证结果:API 单测、API build、前端 build 均通过;前端 build 仅保留既有 Vite chunk size warning。
|
||||
|
||||
## 2026-07-06 企业编辑页和上传链路修复
|
||||
|
||||
- 按设计锚点 `131f344a^` 恢复运营端企业新建/编辑页字段:企业照片、企业名称、统一社会信用代码、省/直辖市、市/区、通讯地址、联系人姓名、身份证号、手机号、电子邮箱。
|
||||
- 运营端企业新建/编辑页移除偏离锚点的企业编码、企业状态字段;后端 `POST /api/admin/tenants` 支持不传企业编码,并按信用代码/企业名生成真实唯一企业编码。
|
||||
- 修复营业执照/企业照片上传 500:
|
||||
- 启动脚本在 MinIO 不可用时启用本地对象存储 `.local-data/object-storage`,文件仍通过真实 NestJS 上传接口写入对象存储目录并创建 `FileObject` 数据库记录。
|
||||
- 文件上传接口缺少 multipart 文件时返回 400。
|
||||
- `FileObject.sizeBytes` 返回前转换为字符串,避免 Prisma `BigInt` JSON 序列化 500。
|
||||
- 已执行:
|
||||
- `npm --prefix api test -- tenants.service.spec.ts files.service.spec.ts --runInBand`
|
||||
- `npm --prefix api run build`
|
||||
- `npm run build`
|
||||
- `POST http://localhost:3000/api/admin/files/upload` multipart smoke
|
||||
- 验证结果:API 单测、API build、前端 build 和真实上传 smoke 均通过;前端 build 仅保留既有 Vite chunk size warning。
|
||||
|
||||
## 2026-07-06 本地 MinIO 启动脚本补充
|
||||
|
||||
- `tools/start-local.ps1` 补充本地 MinIO 启动流程:Docker Compose 优先;无 Docker 时查找 `C:\cmpp-platform-local\minio.exe`、`C:\cmpp-platform-local\minio\minio.exe` 或 PATH 中的 `minio.exe`,使用 `C:\cmpp-platform-local\minio-data` 作为数据目录,监听 `9000/9001`。
|
||||
- `package.json` 新增 `npm run start:local:minio`,用于单独启动本地 MinIO。
|
||||
- MinIO 不可用时,脚本仍会明确启用 `.local-data/object-storage` fallback;启动完成提示会区分 MinIO 是否真实运行。
|
||||
- MinIO 模式下对象存储服务会在上传/预签名前自动确认并创建 `cmpp-platform` bucket。
|
||||
- 已执行:
|
||||
- `npm --prefix api run build`
|
||||
- `npm run start:local -- -SkipApi -SkipWeb -SkipMigrate`
|
||||
- 验证结果:API build 和启动脚本 smoke 通过;当前机器未发现 `minio.exe`,脚本按预期提示并启用本地对象存储 fallback。
|
||||
|
||||
## 2026-07-06 应用级 CMPP 连接和签名/引流表单基线
|
||||
|
||||
- CMPP 连接状态从企业/租户级聚合改为应用级独立连接:
|
||||
- `CmppConnectionState` 新增 `applicationId` 并关联 `SmsApplication`。
|
||||
- 企业应用列表和连接详情只读取当前应用的 `CmppConnectionState`。
|
||||
- 运营端断开连接只操作当前应用下的连接。
|
||||
- Gateway 连接上报 `POST /api/admin/gateway/connections` 支持 `applicationId`,新连接可按应用独立记录。
|
||||
- 运营端添加/编辑短信签名页面按设计锚点 `131f344a^` 补齐字段:签名依据、短信签名、资质凭证、公司名称、统一社会信用代码、法人姓名、法人身份证号、法人身份证照片、责任人姓名、责任人手机号、责任人身份证号、责任人身份证照片、三网报备状态。
|
||||
- 运营端添加/编辑引流信息页面按设计锚点 `131f344a^` 补齐字段:引流信息、字段名称 1-10、文件上传、三网报备状态、提交时间、备注。
|
||||
- 签名和引流表单仍使用真实 `enterprise-signatures` 后端接口保存;扩展字段写入 `SmsSignature.drainageInfo` JSON,文件上传走真实 `admin/files/upload` 并保存 `FileObject` 引用。
|
||||
- 已执行:
|
||||
- `npm --prefix api run prisma:generate`
|
||||
- `npm --prefix api test -- sms-config.service.spec.ts channels.service.spec.ts --runInBand`
|
||||
- `npm run build`
|
||||
- `npm --prefix api run build`
|
||||
- `npm --prefix api run prisma:migrate:deploy`
|
||||
- 验证结果:Prisma Client 生成、API 针对测试、API build、前端 build 和本地 PostgreSQL migration deploy 均通过;前端 build 仅保留既有 Vite chunk size warning。
|
||||
|
||||
## 2026-07-01
|
||||
|
||||
### 新增测试基础
|
||||
@@ -183,7 +238,7 @@ npm run test:gateway
|
||||
- 通道管理补齐真实 API:
|
||||
- `POST /api/admin/channels/:id/copy`:复制通道配置、通道报备字段和该通道签名报备材料,写入操作日志。
|
||||
- `DELETE /api/admin/channels/:id`:软删除通道,避免破坏历史发送/报备外键。
|
||||
- `GET /api/admin/channels/:id/link-logs`:基于 `OperationLog` 和 `CmppConnectionState` 查询链接日志。
|
||||
- `GET /api/admin/channels/:id/connection-logs`:基于 `OperationLog` 和 `CmppConnectionState` 查询连接日志;保留 `/link-logs` 兼容旧前端。
|
||||
- 安全控制补齐真实 API:敏感词、全局黑名单、企业黑名单支持 keyword/status 查询、创建、启停/软删除,并写操作日志。
|
||||
- 模板审核补齐真实查询:运营端模板列表支持 keyword/status,并返回企业、应用、签名信息;前端模板审核页已改为调用真实 API。
|
||||
- 企业认证审核补齐真实查询:列表支持 keyword/status,详情返回企业信息和认证 materials;前端企业认证审核页已改为调用真实 API。
|
||||
@@ -193,7 +248,7 @@ npm run test:gateway
|
||||
|
||||
| 测试文件 | 新增覆盖 |
|
||||
| --- | --- |
|
||||
| `api/src/channels/channels.service.spec.ts` | 通道复制、软删除、连接状态日志写入、链接日志查询。 |
|
||||
| `api/src/channels/channels.service.spec.ts` | 通道复制、软删除、连接状态日志写入、连接日志查询。 |
|
||||
| `api/src/dictionaries/dictionaries.service.spec.ts` | 敏感词、全局黑名单、企业黑名单查询、创建、软删除和操作日志。 |
|
||||
|
||||
### 已执行命令
|
||||
@@ -214,7 +269,7 @@ npm run build
|
||||
|
||||
- 已将今天的客户端和运营端优化要求补入 `docs/first-version-development-requirements.md`:
|
||||
- 去除客户端独立账号设置菜单,改为头像下拉承载退出登录和修改密码。
|
||||
- 明确模板审核搜索、企业认证详情审核、企业应用 CMPP 连接数/连接详情/参数复制、通道复制、通道软删除、通道链接日志、安全控制 CRUD、系统日志分页等均需要真实后端 API 支撑。
|
||||
- 明确模板审核搜索、企业认证详情审核、企业应用 CMPP 连接数/连接详情/参数复制、通道复制、通道软删除、通道连接日志、安全控制 CRUD、系统日志分页等均需要真实后端 API 支撑。
|
||||
- 补充客户端用户、运营端企业认证、通道、连接、字典、安全控制、系统日志等接口范围。
|
||||
- 修正 Codex 执行模板,明确 mock、localStorage 或静态数据不得作为真实开发完成标准。
|
||||
- 已将今天的验收点补入 `docs/system-functional-test-cases.md`:
|
||||
@@ -253,7 +308,7 @@ node <browser-and-api-smoke>
|
||||
| TC-CMPP-STATUS-UI | UI-SMOKE PASS / BACKEND GAP | 企业应用管理可展示 CMPP 状态和连接数量并打开连接详情;页面当前仍有本地初始数据路径。 |
|
||||
| TC-FRONTEND-CONSOLE | PASS | 关键页面无相关 console error/pageerror;仅忽略 favicon 404。 |
|
||||
| TC-BILLING-MANUAL-API | PASS | 人工充值无需审批:确认后账户余额、短信条数、充值单、账户流水和 Dashboard transactions 聚合同步更新。 |
|
||||
| TC-CMPP-STATUS-API | PASS | 通道创建、Gateway 连接状态回写、按通道/客户查询、链接日志和 Dashboard gatewayConnections 聚合通过。 |
|
||||
| TC-CMPP-STATUS-API | PASS | 通道创建、Gateway 连接状态回写、按通道/客户查询、连接日志和 Dashboard gatewayConnections 聚合通过。 |
|
||||
|
||||
### 发现和说明
|
||||
|
||||
@@ -274,7 +329,7 @@ node <browser-and-api-smoke>
|
||||
| BUG-DEV-001 | P1 | `npm run dev` 在 5173 被占用后切到 5174,Vite 依赖 bundling 长时间未完成,浏览器看到白屏。 | 本轮浏览器测试中 5174 HTTP 后续可达,但首次打开截图为空白;生产 build/preview 正常。 | 检查 Vite dev 依赖预构建和端口占用问题,确保开发模式可稳定渲染。 |
|
||||
| BUG-SEND-001 | P0 | 发送路由规则允许直接绑定单个通道,违反“规则只能绑定通道组”的业务约束。 | `SendChainService.selectChannel()` 当前存在 `route?.channel ?? route?.group...` 路径;`ChannelRouteRule` 模型也保留 `channelId` 字段。 | 路由规则只能表达应用到通道组的绑定关系;发送链路必须从企业应用绑定的运营商通道组内选路,不允许规则直接指定单个通道。 |
|
||||
| BUG-SEND-002 | P0 | 未命中路由规则时会 fallback 到全局第一个 `active` 通道,可能把短信发到未配置给该企业/应用的通道。 | `SendChainService.selectChannel()` 未找到 route 后执行 `smsChannel.findFirst({ where: { status: 'active' } })`。 | 企业应用没有配置对应运营商通道组或无可用通道时,短信直接 failed;不得进入 pending/delayed,不得 fallback 到其他 active 通道,需记录 trace/日志。 |
|
||||
| BUG-SEND-003 | P0 | 发送选路只判断通道业务状态 `active`,不判断 CMPP 真实连接状态。 | `selectChannel()` 只检查 `SmsChannel.status`,未查询 `CmppConnectionState.status/currentConnections/lastHeartbeatAt/lastError`。 | 选路必须跳过离线、认证失败、心跳超时、重连中或 `currentConnections=0` 的通道;至少 1 条连接 online 且心跳正常才可发送,连续 3 次心跳失败进入重连且不可选。 |
|
||||
| BUG-SEND-003 | P0 | 发送选路只判断通道业务状态 `active`,不判断 CMPP 真实连接状态。 | `selectChannel()` 只检查 `SmsChannel.status`,未查询 `CmppConnectionState.status/currentConnections/lastHeartbeatAt/lastError`。 | 选路必须跳过离线、认证失败、心跳超时、重连中或 `currentConnections=0` 的通道;至少 1 条连接 connected 且心跳正常才可发送,连续 3 次心跳失败进入重连且不可选。 |
|
||||
| BUG-SEND-004 | P0 | 通道组主通道提交失败、超时或回执失败后不会切换到下一个通道补发。 | `handleSubmitResult()` 和 `handleReceipt()` 只更新状态、释放/退款和刷新进度,没有重新选路或创建补发记录;`retry.maxAttempts` 目前未形成业务补发闭环。 | 除 unknown、超过 72 小时、超过通道组补发时间上限或通道组关闭补发外,submit rejected/timeout、连接断开、未提交成功、receipt failed 均需补发;省网失败后立即走全国通道,全国通道按优先级继续补发,最终成功只按企业应用客户费率扣一次。 |
|
||||
| BUG-SEND-005 | P0 | 通道组省网/全国路由没有接入真实发送链路,手机号段库也未参与归属地识别。 | `SmsChannelGroupItem` 和 `ChannelRouteRule` 虽有 `carrier/province` 字段,`PhoneSegment` 有 `prefix/carrier/province/city`,但 `SendChainService.selectChannel()` 未读取 message.phoneNumber、未查询 `phoneSegment`,只按优先级取第一个 active 通道;前端 `AdminChannelGroupFormPage` 的省网/全国配置仍为本地 `useState`。 | 发送前按可配置号码前缀正则识别运营商,识别失败走移动通道组;按手机号段库识别省份和城市,省份识别失败走对应运营商全国通道;通道需支持移动/联通/电信/三网和全国/单省发送地区,三网作为通配。 |
|
||||
| BUG-SEND-006 | P0 | 企业应用缺少按运营商绑定多个通道组和保存校验的真实闭环。 | 当前发送链路只按 `tenantId/applicationId` 查询单一路由规则;未体现一个应用分别绑定移动、联通、电信通道组,也未强制至少绑定一个通道组后才能保存。 | 企业应用可分别绑定移动、联通、电信通道组;一个都不绑定时 UI 不允许保存,发送时直接 failed;移动、联通、电信短信按识别结果进入对应通道组。 |
|
||||
@@ -285,7 +340,8 @@ node <browser-and-api-smoke>
|
||||
|
||||
- BUG-SEND-001:后端 `createRouteRule` 禁止直接绑定单通道,路由规则只能绑定应用、运营商和通道组;发送链路不再读取 `route.channel`。
|
||||
- BUG-SEND-002:发送链路未找到企业应用对应运营商通道组或无可用在线通道时,短信直接标记 `failed`,不再 fallback 到全局第一个 active 通道。
|
||||
- BUG-SEND-003:发送选路加入 CMPP 连接状态过滤,通道必须业务 `active`、连接 `online/connected`、`desiredConnections > 0` 且 `currentConnections > 0` 才可选。
|
||||
- BUG-SEND-003:发送选路加入 CMPP 连接状态过滤,通道必须业务 `active`、连接 `connected`、`desiredConnections > 0` 且 `currentConnections > 0` 才可选;`online/open` 仅作为旧 Gateway 回写兼容词入库归一化。
|
||||
- BUG-CMPP-STATUS-001:新建/启用通道后若 Gateway 连接请求长时间无回写,API 后台兜底任务会将超过 30 秒的 `connecting` 连接标记为 `failed`,写入超时原因和连接日志,避免页面长期停留“连接中”。
|
||||
- BUG-SEND-004:submit rejected、submit timeout、回执 failed 等失败场景会在补发开启且未超过时间限制时,排除已尝试通道并切换到同一通道组全国通道继续提交;unknown、超过 72 小时、超过通道组补发上限或关闭补发时不补发。
|
||||
- BUG-SEND-005:新增 `PhoneCarrierRule` 运营商前缀正则配置,发送前先识别运营商,识别失败默认移动;手机号段库用于识别省份,省份识别失败走对应运营商全国通道;通道新增 `sendRegion`,支持全国或单省。
|
||||
- BUG-SEND-006:企业应用创建页面可分别选择移动、联通、电信通道组,一个都不选时 UI 阻止保存;创建应用成功后写入真实通道组路由规则。
|
||||
@@ -601,3 +657,50 @@ git diff --check
|
||||
- 企业模板管理表格最小宽度 1820px,模板内容列 420px,横向滚动生效。
|
||||
- 新建企业应用弹窗在 1280px 视口下未截断,未选择企业时“下一步”禁用。
|
||||
- 新建短信应用页显示三网通道组卡片、已配置数量和无可用通道组提示,初始状态“创建应用”禁用。
|
||||
|
||||
## 2026-07-06 文件上传预览和下载回归
|
||||
|
||||
### 本轮修复
|
||||
|
||||
- 文件服务新增真实下载接口 `GET /api/admin/files/:id/download`,从 MinIO 或本地对象存储读取真实文件对象,支持 `inline` 预览和 `attachment` 下载。
|
||||
- 运营端企业照片、企业签名材料、引流材料、报备回执导入均在真实上传成功后显示下载入口;图片类型文件显示点击预览入口。
|
||||
- 客户端企业认证营业执照上传成功后显示下载入口,图片类型文件显示点击预览入口;提交认证时保存文件类型信息。
|
||||
- 客户端签名列表对已保存签名材料显示下载入口,图片材料按文件名或类型显示预览入口。
|
||||
- 客户端短信发送导入号码文件为前端解析文件,未生成后端文件对象;页面仅提供本地原始文件下载,不标记为真实后端归档。
|
||||
|
||||
### 已执行命令
|
||||
|
||||
```bash
|
||||
npm --prefix api test -- files.service.spec.ts
|
||||
npm --prefix api run build
|
||||
npm run build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
### 当前结果
|
||||
|
||||
- 文件服务单测通过:1 个 test suite、2 个测试通过。
|
||||
- API build 通过。
|
||||
- 前端 build 通过,仍存在既有 Vite chunk size warning。
|
||||
- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。
|
||||
|
||||
## 2026-07-06 企业列表人工充值入口
|
||||
|
||||
### 本轮修复
|
||||
|
||||
- 运营端企业管理列表新增“充值”按钮。
|
||||
- 点击“充值”打开企业人工充值弹窗,展示企业名称、当前余额,并支持录入充值金额、操作人和备注;企业列表入口不要求填写短信条数。
|
||||
- 提交后调用现有真实接口 `POST /api/admin/billing/manual-recharges`,成功后重新拉取企业管理列表,余额来自真实账户接口聚合结果。
|
||||
- 该入口不使用前端本地状态模拟充值入账;充值订单、账户余额、账户流水和操作日志仍由后端 `BillingService.createManualRecharge` 负责。
|
||||
|
||||
### 已执行命令
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
### 当前结果
|
||||
|
||||
- 前端 build 通过,仍存在既有 Vite chunk size warning。
|
||||
- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"cmpp-platform/gateway/internal/control"
|
||||
"cmpp-platform/gateway/internal/health"
|
||||
)
|
||||
|
||||
@@ -14,8 +15,12 @@ func main() {
|
||||
addr = ":8090"
|
||||
}
|
||||
|
||||
log.Printf("cmpp gateway health server listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, health.Handler()); err != nil {
|
||||
log.Fatalf("gateway health server stopped: %v", err)
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/health", health.Handler())
|
||||
control.Register(mux, control.Server{APIBaseURL: os.Getenv("API_BASE_URL")})
|
||||
|
||||
log.Printf("cmpp gateway control server listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatalf("gateway control server stopped: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}`
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const (
|
||||
MessageTypeSubmitResult MessageType = "SubmitResult"
|
||||
MessageTypeReceiptEvent MessageType = "ReceiptEvent"
|
||||
MessageTypeUplinkEvent MessageType = "UplinkEvent"
|
||||
MessageTypeConnectChannel MessageType = "ConnectChannel"
|
||||
)
|
||||
|
||||
type Envelope struct {
|
||||
@@ -88,3 +89,27 @@ type UplinkEvent struct {
|
||||
Content string `json:"content"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
}
|
||||
|
||||
type ConnectChannelCommand struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
MessageType MessageType `json:"messageType"`
|
||||
TraceID string `json:"traceId"`
|
||||
ChannelID string `json:"channelId"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Reason string `json:"reason"`
|
||||
DesiredConnections int `json:"desiredConnections"`
|
||||
Channel ConnectChannelConfig `json:"channel"`
|
||||
}
|
||||
|
||||
type ConnectChannelConfig struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
GatewayHost string `json:"gatewayHost"`
|
||||
GatewayPort int `json:"gatewayPort"`
|
||||
Account string `json:"account"`
|
||||
PasswordCipher string `json:"passwordCipher"`
|
||||
SrcID string `json:"srcId"`
|
||||
CMPPVersion string `json:"cmppVersion"`
|
||||
RateLimitPerSecond int `json:"rateLimitPerSecond"`
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"build:api": "npm --prefix api run build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"start:local": "powershell -NoProfile -ExecutionPolicy Bypass -File tools/start-local.ps1",
|
||||
"start:local:minio": "powershell -NoProfile -ExecutionPolicy Bypass -File tools/start-local.ps1 -OnlyMinio",
|
||||
"prisma:generate": "npm --prefix api run prisma:generate",
|
||||
"spike:contracts": "node tools/spike/validate-gateway-queue-contract.mjs",
|
||||
"spike:gateway": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$env:Path='C:\\Program Files\\Go\\bin;'+$env:Path; Push-Location gateway; go test ./...; Pop-Location\"",
|
||||
|
||||
+24
-3
@@ -39,9 +39,10 @@ export type AdminChannel = {
|
||||
unitPrice: number;
|
||||
status: string;
|
||||
config?: unknown;
|
||||
connectionStates?: CmppConnectionState[];
|
||||
};
|
||||
|
||||
export type ChannelLinkLogResponse = {
|
||||
export type ChannelConnectionLogResponse = {
|
||||
channelId: string;
|
||||
connectionStates: Array<Record<string, unknown>>;
|
||||
logs: Array<{
|
||||
@@ -102,6 +103,11 @@ export type TenantOption = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type TenantManagementRow = TenantOption & {
|
||||
account?: TenantAccount | null;
|
||||
todaySpendCents: number;
|
||||
};
|
||||
|
||||
export type CaptchaResponse = {
|
||||
captchaId: string;
|
||||
challenge: string;
|
||||
@@ -388,6 +394,16 @@ export type FileObject = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type FileRef = {
|
||||
fileObjectId: string;
|
||||
fileName: string;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export function fileDownloadUrl(fileObjectId: string, disposition: 'attachment' | 'inline' = 'attachment') {
|
||||
return `/api/admin/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`;
|
||||
}
|
||||
|
||||
export type RiskReviewTask = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
@@ -464,6 +480,7 @@ export type EnterpriseApplication = {
|
||||
export type CmppConnectionState = {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
applicationId?: string | null;
|
||||
channelId: string;
|
||||
connectionId: string;
|
||||
status: string;
|
||||
@@ -517,8 +534,9 @@ export const adminApi = {
|
||||
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
|
||||
request<LoginSession>('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listTenants: () => request<TenantOption[]>('/admin/tenants'),
|
||||
listTenantManagementRows: () => request<TenantManagementRow[]>('/admin/tenants/management-list'),
|
||||
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) }),
|
||||
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) }),
|
||||
@@ -580,7 +598,7 @@ export const adminApi = {
|
||||
method: 'DELETE',
|
||||
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 }) => {
|
||||
const params = new URLSearchParams();
|
||||
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) }),
|
||||
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) }),
|
||||
deleteChannelGroup: (id: string) =>
|
||||
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'DELETE' }),
|
||||
addChannelGroupItem: (body: Record<string, unknown>) =>
|
||||
request<DictionaryItem>('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listChannelRouteRules: () => request<DictionaryItem[]>('/admin/channel-route-rules'),
|
||||
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) }),
|
||||
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 }> }) =>
|
||||
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 })),
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Info, Plus } from 'lucide-react';
|
||||
import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag } from '@/components/ui';
|
||||
import type { TableColumn } from '@/components/ui';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Tag } from '@/components/ui';
|
||||
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom';
|
||||
type ChannelStatus = 'normal' | 'stopped';
|
||||
@@ -50,7 +49,7 @@ const carrierLabels: Record<Carrier, string> = {
|
||||
};
|
||||
|
||||
const statusLabels: Record<ChannelStatus, string> = {
|
||||
normal: '链接正常',
|
||||
normal: '通道启用',
|
||||
stopped: '通道停用',
|
||||
};
|
||||
|
||||
@@ -75,6 +74,38 @@ function StatusTag({ status }: { status: ChannelStatus }) {
|
||||
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({
|
||||
channels,
|
||||
carrier,
|
||||
@@ -222,40 +253,6 @@ export function AdminChannelGroupFormPage() {
|
||||
loadData();
|
||||
}, [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) {
|
||||
if (modal?.type === 'province') {
|
||||
const nextRoute = route as ProvinceRoute;
|
||||
@@ -359,7 +356,20 @@ export function AdminChannelGroupFormPage() {
|
||||
|
||||
<section className="surface channel-group-form-section">
|
||||
<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>
|
||||
@@ -367,7 +377,20 @@ export function AdminChannelGroupFormPage() {
|
||||
|
||||
<section className="surface channel-group-form-section">
|
||||
<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>
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Layers3, Plus, Search, UsersRound } from 'lucide-react';
|
||||
import { Layers3, Pencil, Plus, Search, Trash2, UsersRound } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination } from '@/components/ui';
|
||||
import { adminApi, type ChannelGroup } from '@/api/adminApi';
|
||||
|
||||
type GroupCarrier = 'mobile' | 'unicom' | 'telecom';
|
||||
|
||||
const carrierOptions: Array<{ label: string; value: GroupCarrier }> = [
|
||||
{ label: '移动', value: 'mobile' },
|
||||
{ label: '联通', value: 'unicom' },
|
||||
{ label: '电信', value: 'telecom' },
|
||||
];
|
||||
|
||||
const carrierLabels: Record<GroupCarrier, string> = {
|
||||
mobile: '移动',
|
||||
unicom: '联通',
|
||||
@@ -18,12 +13,10 @@ const carrierLabels: Record<GroupCarrier, string> = {
|
||||
};
|
||||
|
||||
export function AdminChannelGroupsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [groups, setGroups] = useState<ChannelGroup[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [carrier, setCarrier] = useState<GroupCarrier>('mobile');
|
||||
const [deleteTarget, setDeleteTarget] = useState<ChannelGroup | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
@@ -41,16 +34,14 @@ export function AdminChannelGroupsPage() {
|
||||
|
||||
const filteredGroups = useMemo(() => groups.filter((group) => !groupName.trim() || group.name.includes(groupName.trim())), [groupName, groups]);
|
||||
|
||||
function createGroup() {
|
||||
adminApi.createChannelGroup({ code, name, carrier, status: 'active' })
|
||||
function deleteGroup() {
|
||||
if (!deleteTarget) return;
|
||||
adminApi.deleteChannelGroup(deleteTarget.id)
|
||||
.then(() => {
|
||||
setModalOpen(false);
|
||||
setName('');
|
||||
setCode('');
|
||||
setCarrier('mobile');
|
||||
setDeleteTarget(null);
|
||||
loadData();
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '通道组创建失败'));
|
||||
.catch((failure: Error) => setError(failure.message || '通道组删除失败'));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -59,7 +50,7 @@ export function AdminChannelGroupsPage() {
|
||||
<div>
|
||||
<Breadcrumb items={['短信通道组管理']} />
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}>
|
||||
<Button icon={<Plus size={16} />} onClick={() => navigate('/admin/channel-groups/new')}>
|
||||
添加通道组
|
||||
</Button>
|
||||
</div>
|
||||
@@ -92,6 +83,14 @@ export function AdminChannelGroupsPage() {
|
||||
})}
|
||||
{(group.items?.length ?? 0) === 0 ? <p className="muted">暂无绑定通道</p> : null}
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
@@ -101,26 +100,17 @@ export function AdminChannelGroupsPage() {
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button>
|
||||
<Button disabled={!name || !code} onClick={createGroup}>保存</Button>
|
||||
<Button onClick={() => setDeleteTarget(null)} variant="ghost">取消</Button>
|
||||
<Button onClick={deleteGroup} variant="danger">确认删除</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setModalOpen(false)}
|
||||
open={modalOpen}
|
||||
title="添加通道组"
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
open={Boolean(deleteTarget)}
|
||||
title="删除通道组"
|
||||
>
|
||||
<div className="admin-system-modal-form">
|
||||
<Input label="通道组编码" onChange={(event) => setCode(event.target.value)} value={code} />
|
||||
<Input label="通道组名称" onChange={(event) => setName(event.target.value)} value={name} />
|
||||
<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 className="channel-confirm">
|
||||
<strong>{deleteTarget?.name}</strong>
|
||||
<p>删除前会校验真实路由绑定;已被企业应用使用的通道组不会被删除。</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Copy, Eye, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelLinkLogResponse } from '@/api/adminApi';
|
||||
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type CmppConnectionState } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
|
||||
@@ -41,7 +41,7 @@ type ChannelConfirmAction = {
|
||||
|
||||
type ChannelLogState = {
|
||||
channel: SmsChannel;
|
||||
data?: ChannelLinkLogResponse;
|
||||
data?: ChannelConnectionLogResponse;
|
||||
};
|
||||
|
||||
const carrierOptions = [
|
||||
@@ -54,10 +54,10 @@ const carrierOptions = [
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '链接正常', value: 'normal' },
|
||||
{ label: '连接正常', value: 'normal' },
|
||||
{ label: '已停用', value: 'stopped' },
|
||||
{ label: '链接中', value: 'connecting' },
|
||||
{ label: '链接失败', value: 'failed' },
|
||||
{ label: '连接中', value: 'connecting' },
|
||||
{ label: '连接失败', value: 'failed' },
|
||||
];
|
||||
|
||||
const protocolOptions = [
|
||||
@@ -93,10 +93,10 @@ const carrierToneMap: Record<Carrier, 'info' | 'danger' | 'success' | 'neutral'>
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<ChannelStatus, string> = {
|
||||
normal: '链接正常',
|
||||
normal: '连接正常',
|
||||
stopped: '已停用',
|
||||
connecting: '链接中',
|
||||
failed: '链接失败',
|
||||
connecting: '连接中',
|
||||
failed: '连接失败',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<ChannelStatus, 'success' | 'neutral' | 'info' | 'danger'> = {
|
||||
@@ -106,21 +106,31 @@ const statusToneMap: Record<ChannelStatus, 'success' | 'neutral' | 'info' | 'dan
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
function mapApiChannel(channel: AdminChannel): SmsChannel {
|
||||
const statusMap: Record<string, ChannelStatus> = {
|
||||
active: 'normal',
|
||||
disabled: 'stopped',
|
||||
deleted: 'stopped',
|
||||
connecting: 'connecting',
|
||||
failed: 'failed',
|
||||
};
|
||||
function resolveChannelStatus(channel: AdminChannel, connections: CmppConnectionState[] = []): ChannelStatus {
|
||||
if (channel.status !== 'active') {
|
||||
return 'stopped';
|
||||
}
|
||||
if (connections.some((connection) =>
|
||||
connection.status === 'connected'
|
||||
&& connection.currentConnections > 0
|
||||
&& connection.desiredConnections > 0,
|
||||
)) {
|
||||
return 'normal';
|
||||
}
|
||||
if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'failed', 'error'].includes(connection.status) || connection.lastError)) {
|
||||
return 'failed';
|
||||
}
|
||||
return 'connecting';
|
||||
}
|
||||
|
||||
function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[] = channel.connectionStates ?? []): SmsChannel {
|
||||
return {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
carrier: channel.carrier === 'unicom' || channel.carrier === 'telecom' || channel.carrier === 'all' ? channel.carrier : 'mobile',
|
||||
sendRegion: channel.sendRegion ?? '全国',
|
||||
unitPrice: channel.unitPrice,
|
||||
status: statusMap[channel.status] ?? 'normal',
|
||||
status: resolveChannelStatus(channel, connections),
|
||||
total: 0,
|
||||
successRate: 0,
|
||||
successCount: 0,
|
||||
@@ -353,13 +363,21 @@ export function AdminChannelsPage() {
|
||||
const [confirmAction, setConfirmAction] = useState<ChannelConfirmAction | null>(null);
|
||||
const [logState, setLogState] = useState<ChannelLogState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function loadChannels() {
|
||||
adminApi.listChannels()
|
||||
.then((items) => {
|
||||
setChannels(items.filter((item) => item.status !== 'deleted').map(mapApiChannel));
|
||||
.then(async (items) => {
|
||||
const visibleChannels = items.filter((item) => item.status !== 'deleted');
|
||||
const connections = await Promise.all(visibleChannels.map((channel) =>
|
||||
adminApi.listChannelConnections(channel.id).catch(() => [] as CmppConnectionState[]),
|
||||
));
|
||||
setChannels(visibleChannels.map((item, index) => mapApiChannel(item, connections[index])));
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '通道列表加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadChannels();
|
||||
}, []);
|
||||
|
||||
const filteredChannels = useMemo(
|
||||
@@ -375,16 +393,15 @@ export function AdminChannelsPage() {
|
||||
async function upsertChannel(nextChannel: SmsChannel) {
|
||||
try {
|
||||
if (modal?.mode === 'edit' && modal.channel) {
|
||||
const updated = await adminApi.updateChannel(modal.channel.id, buildChannelPayload(nextChannel, nextChannel.passwordCipher));
|
||||
setChannels((items) => items.map((item) => (item.id === updated.id ? mapApiChannel(updated) : item)));
|
||||
await adminApi.updateChannel(modal.channel.id, buildChannelPayload(nextChannel, nextChannel.passwordCipher));
|
||||
} else {
|
||||
const created = await adminApi.createChannel({
|
||||
await adminApi.createChannel({
|
||||
code: `CH-${Date.now()}`,
|
||||
...buildChannelPayload(nextChannel, nextChannel.passwordCipher || 'secret'),
|
||||
status: 'active',
|
||||
});
|
||||
setChannels((items) => [mapApiChannel(created), ...items]);
|
||||
}
|
||||
loadChannels();
|
||||
setModal(null);
|
||||
setError('');
|
||||
} catch (failure) {
|
||||
@@ -393,8 +410,8 @@ export function AdminChannelsPage() {
|
||||
}
|
||||
|
||||
async function toggleChannel(channel: SmsChannel) {
|
||||
const updated = await adminApi.changeChannelStatus(channel.id, mapUiStatusToApi(channel));
|
||||
setChannels((items) => items.map((item) => (item.id === channel.id ? mapApiChannel(updated) : item)));
|
||||
await adminApi.changeChannelStatus(channel.id, mapUiStatusToApi(channel));
|
||||
loadChannels();
|
||||
}
|
||||
|
||||
async function deleteChannel(id: string) {
|
||||
@@ -403,13 +420,13 @@ export function AdminChannelsPage() {
|
||||
}
|
||||
|
||||
async function copyChannel(channel: SmsChannel) {
|
||||
const copied = await adminApi.copyChannel(channel.id);
|
||||
setChannels((items) => [mapApiChannel(copied), ...items]);
|
||||
await adminApi.copyChannel(channel.id);
|
||||
loadChannels();
|
||||
}
|
||||
|
||||
async function openLinkLogs(channel: SmsChannel) {
|
||||
setLogState({ channel });
|
||||
const data = await adminApi.listChannelLinkLogs(channel.id);
|
||||
const data = await adminApi.listChannelConnectionLogs(channel.id);
|
||||
setLogState({ channel, data });
|
||||
}
|
||||
|
||||
@@ -446,7 +463,7 @@ export function AdminChannelsPage() {
|
||||
: confirmAction?.type === 'copy'
|
||||
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '启用后通道会进入链接中状态,后续可继续观察网关连接。'
|
||||
? '启用后通道会进入连接中状态,后续可继续观察网关连接。'
|
||||
: '停用后该通道将不再承接新的发送任务。';
|
||||
|
||||
return (
|
||||
@@ -491,7 +508,7 @@ export function AdminChannelsPage() {
|
||||
<div className="sms-channel-status-cell">
|
||||
<Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag>
|
||||
<button onClick={() => void openLinkLogs(channel)} type="button">
|
||||
<FileText size={14} />链接日志
|
||||
<FileText size={14} />连接日志
|
||||
</button>
|
||||
</div>
|
||||
<strong className="sms-channel-total">{channel.total.toLocaleString('zh-CN')}</strong>
|
||||
@@ -564,7 +581,7 @@ export function AdminChannelsPage() {
|
||||
onClose={() => setLogState(null)}
|
||||
open
|
||||
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">
|
||||
{(logState.data?.logs ?? []).map((log) => (
|
||||
@@ -579,8 +596,8 @@ export function AdminChannelsPage() {
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{logState.data && logState.data.logs.length === 0 ? <p className="muted">暂无链接日志</p> : null}
|
||||
{!logState.data ? <p className="muted">正在加载链接日志...</p> : null}
|
||||
{logState.data && logState.data.logs.length === 0 ? <p className="muted">暂无连接日志</p> : null}
|
||||
{!logState.data ? <p className="muted">正在加载连接日志...</p> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ImagePlus } from 'lucide-react';
|
||||
import { adminApi, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Select, Textarea } from '@/components/ui';
|
||||
import { adminApi, type FileRef, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, FileActions, Input, Select, Textarea } from '@/components/ui';
|
||||
|
||||
type EnterpriseForm = {
|
||||
name: string;
|
||||
code: string;
|
||||
status: string;
|
||||
creditCode: string;
|
||||
province: string;
|
||||
city: string;
|
||||
@@ -18,6 +16,7 @@ type EnterpriseForm = {
|
||||
contactEmail: string;
|
||||
photoFileObjectId: string;
|
||||
photoFileName: string;
|
||||
photoContentType: string;
|
||||
};
|
||||
|
||||
type EnterpriseFormErrors = Partial<Record<keyof EnterpriseForm, string>>;
|
||||
@@ -44,8 +43,6 @@ const cityOptionsByProvince: Record<string, Array<{ label: string; value: string
|
||||
|
||||
const emptyForm: EnterpriseForm = {
|
||||
name: '',
|
||||
code: '',
|
||||
status: 'active',
|
||||
creditCode: '',
|
||||
province: '',
|
||||
city: '',
|
||||
@@ -56,14 +53,13 @@ const emptyForm: EnterpriseForm = {
|
||||
contactEmail: '',
|
||||
photoFileObjectId: '',
|
||||
photoFileName: '',
|
||||
photoContentType: '',
|
||||
};
|
||||
|
||||
function formFromTenant(tenant: TenantOption): EnterpriseForm {
|
||||
const profile = tenant.enterpriseProfile;
|
||||
return {
|
||||
name: tenant.name,
|
||||
code: tenant.code,
|
||||
status: tenant.status,
|
||||
creditCode: profile?.creditCode ?? '',
|
||||
province: profile?.province ?? '',
|
||||
city: profile?.city ?? '',
|
||||
@@ -74,6 +70,7 @@ function formFromTenant(tenant: TenantOption): EnterpriseForm {
|
||||
contactEmail: profile?.contactEmail ?? '',
|
||||
photoFileObjectId: profile?.photoFileObjectId ?? '',
|
||||
photoFileName: profile?.photoFileObjectId ? '已上传企业照片' : '',
|
||||
photoContentType: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,7 +114,6 @@ export function AdminCustomerFormPage() {
|
||||
function validateForm() {
|
||||
const nextErrors: EnterpriseFormErrors = {};
|
||||
if (!form.name.trim()) nextErrors.name = '请填写企业名称';
|
||||
if (!form.code.trim()) nextErrors.code = '请填写企业编码';
|
||||
if (!form.creditCode.trim()) nextErrors.creditCode = '请填写统一社会信用代码';
|
||||
if (!form.contactName.trim()) nextErrors.contactName = '请填写联系人姓名';
|
||||
if (!form.contactPhone.trim()) nextErrors.contactPhone = '请填写手机号';
|
||||
@@ -128,7 +124,7 @@ export function AdminCustomerFormPage() {
|
||||
function submitForm() {
|
||||
if (!validateForm()) return;
|
||||
setSaving(true);
|
||||
const { photoFileName, ...payload } = form;
|
||||
const { photoContentType, photoFileName, ...payload } = form;
|
||||
const request = isEdit && enterpriseId
|
||||
? adminApi.updateTenant(enterpriseId, payload)
|
||||
: adminApi.createTenant(payload);
|
||||
@@ -143,13 +139,22 @@ export function AdminCustomerFormPage() {
|
||||
setUploadingPhoto(true);
|
||||
adminApi.uploadFileObject(file, { purpose: 'enterprise_photo', prefix: 'enterprise-photos' })
|
||||
.then((fileObject) => {
|
||||
setForm((current) => ({ ...current, photoFileObjectId: fileObject.id, photoFileName: fileObject.fileName }));
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
photoContentType: fileObject.contentType,
|
||||
photoFileObjectId: fileObject.id,
|
||||
photoFileName: fileObject.fileName,
|
||||
}));
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '企业照片上传失败'))
|
||||
.finally(() => setUploadingPhoto(false));
|
||||
}
|
||||
|
||||
const photoFile: FileRef | null = form.photoFileObjectId
|
||||
? { contentType: form.photoContentType, fileName: form.photoFileName || '企业照片', fileObjectId: form.photoFileObjectId }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="page-stack enterprise-form-page">
|
||||
<div className="page-heading">
|
||||
@@ -182,13 +187,12 @@ export function AdminCustomerFormPage() {
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
<FileActions file={photoFile} />
|
||||
<p>{form.photoFileObjectId ? `文件对象:${form.photoFileObjectId}` : '支持 JPG、PNG、WebP,上传后随企业档案保存。'}</p>
|
||||
</div>
|
||||
|
||||
<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.code} label="企业编码" onChange={(event) => updateForm('code', event.target.value)} placeholder="请填写唯一企业编码" required value={form.code} />
|
||||
</div>
|
||||
<Input
|
||||
error={errors.creditCode}
|
||||
hint="修改此项将同步更新该企业档案。"
|
||||
@@ -198,6 +202,7 @@ export function AdminCustomerFormPage() {
|
||||
required
|
||||
value={form.creditCode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-grid form-grid--two">
|
||||
<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 label="电子邮箱" onChange={(event) => updateForm('contactEmail', event.target.value)} placeholder="请填写企业联系人邮箱" type="email" value={form.contactEmail} />
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="企业状态"
|
||||
onChange={(event) => updateForm('status', event.target.value)}
|
||||
options={[{ label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]}
|
||||
value={form.status}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="enterprise-form-footer">
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Building2, DollarSign, Plus, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import { adminApi, type TenantAccount, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type TenantManagementRow } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
|
||||
type AdminCustomersPageProps = {
|
||||
basePath?: string;
|
||||
};
|
||||
|
||||
type CustomerRow = TenantOption & {
|
||||
account?: TenantAccount;
|
||||
type CustomerRow = TenantManagementRow;
|
||||
|
||||
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 }) {
|
||||
return (
|
||||
<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) {
|
||||
const navigate = useNavigate();
|
||||
const [records, setRecords] = useState<CustomerRow[]>([]);
|
||||
@@ -28,15 +44,16 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
const [queryStatus, setQueryStatus] = useState('all');
|
||||
const [filters, setFilters] = useState({ id: '', name: '', status: 'all' });
|
||||
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('');
|
||||
|
||||
function loadData() {
|
||||
Promise.all([adminApi.listTenants(), adminApi.listAccounts()])
|
||||
.then(([tenants, accounts]) => {
|
||||
setRecords(tenants.filter((tenant) => tenant.status !== 'deleted').map((tenant) => ({
|
||||
...tenant,
|
||||
account: accounts.find((account) => account.tenantId === tenant.id),
|
||||
})));
|
||||
adminApi.listTenantManagementRows()
|
||||
.then((items) => {
|
||||
setRecords(items);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '企业列表加载失败'));
|
||||
@@ -47,7 +64,7 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
}, []);
|
||||
|
||||
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 matchStatus = filters.status === 'all' ? true : record.status === filters.status;
|
||||
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 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: '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: 'contact', title: '联系人', width: '160px', render: (record) => record.enterpriseProfile?.contactName || '-' },
|
||||
{ key: 'phone', title: '联系电话', width: '150px', render: (record) => record.enterpriseProfile?.contactPhone || '-' },
|
||||
{ key: 'balance', title: '现金余额', width: '150px', align: 'right', render: (record) => `¥${((record.account?.balanceCents ?? 0) / 100).toLocaleString('zh-CN')}` },
|
||||
{ key: 'smsUnits', title: '短信余量', width: '150px', align: 'right', render: (record) => `${(record.account?.smsUnits ?? 0).toLocaleString('zh-CN')} 条` },
|
||||
{
|
||||
key: 'balance',
|
||||
title: '当前余额',
|
||||
width: '150px',
|
||||
align: 'right',
|
||||
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: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '280px',
|
||||
width: '300px',
|
||||
render: (record) => (
|
||||
<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={() => setConfirmAction({ type: 'toggle', record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>
|
||||
{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() {
|
||||
if (!confirmAction) return;
|
||||
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"><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"><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 className="surface ui-query-panel">
|
||||
<h2>查询条件</h2>
|
||||
<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} />
|
||||
<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">
|
||||
@@ -136,6 +200,29 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
onConfirm={submitConfirmAction}
|
||||
/>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -462,7 +462,7 @@ function mapApplication(application: EnterpriseApplication): SmsApp {
|
||||
}
|
||||
|
||||
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 {
|
||||
id: connection.connectionId,
|
||||
state: isOpen ? 'open' : connection.status === 'reconnecting' ? 'reconnecting' : 'closed',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Edit3, FileText, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||
import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, FileActions, Input, Modal, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||
|
||||
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
|
||||
|
||||
@@ -9,6 +9,16 @@ type DrainageInfo = {
|
||||
id: string;
|
||||
siteName: 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;
|
||||
unicom: CarrierStatus;
|
||||
telecom: CarrierStatus;
|
||||
@@ -16,11 +26,30 @@ type DrainageInfo = {
|
||||
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 = {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
name: string;
|
||||
purpose: string;
|
||||
profile: SignatureProfile;
|
||||
mobile: CarrierStatus;
|
||||
unicom: CarrierStatus;
|
||||
telecom: CarrierStatus;
|
||||
@@ -54,6 +83,7 @@ function StatusTag({ status }: { status: CarrierStatus }) {
|
||||
function readDrainagePayload(signature: ClientSmsSignature) {
|
||||
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 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 fallbackStatus = normalizeCarrierStatus(signature.auditStatus);
|
||||
return {
|
||||
@@ -62,10 +92,21 @@ function readDrainagePayload(signature: ClientSmsSignature) {
|
||||
unicom: normalizeCarrierStatus(carrierStatus.unicom, fallbackStatus),
|
||||
telecom: normalizeCarrierStatus(carrierStatus.telecom, fallbackStatus),
|
||||
},
|
||||
signatureProfile: normalizeSignatureProfile(profile, signature),
|
||||
links: links.map((item) => ({
|
||||
id: String(item.id ?? `drain-${Date.now()}`),
|
||||
siteName: String(item.siteName ?? ''),
|
||||
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'),
|
||||
unicom: normalizeCarrierStatus(item.unicom, '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[]) {
|
||||
return { carrierStatus, links };
|
||||
function buildDrainagePayload(carrierStatus: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }, links: DrainageInfo[], signatureProfile?: SignatureProfile) {
|
||||
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 {
|
||||
@@ -91,6 +178,53 @@ function formatDate(value?: string) {
|
||||
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>支持 PNG、JPG、JPEG、PDF,文件大小不超过 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({
|
||||
applications,
|
||||
item,
|
||||
@@ -110,6 +244,7 @@ function SignatureFormModal({
|
||||
applicationId: item?.applicationId ?? '',
|
||||
name: item?.name ?? '',
|
||||
purpose: item?.purpose ?? '',
|
||||
profile: payload?.signatureProfile ?? emptySignatureProfile(item),
|
||||
mobile: payload?.carrierStatus.mobile ?? 'filing',
|
||||
unicom: payload?.carrierStatus.unicom ?? 'filing',
|
||||
telecom: payload?.carrierStatus.telecom ?? 'filing',
|
||||
@@ -120,6 +255,10 @@ function SignatureFormModal({
|
||||
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 (
|
||||
<Modal
|
||||
footer={(
|
||||
@@ -131,11 +270,20 @@ function SignatureFormModal({
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={item ? '编辑短信签名' : '添加短信签名'}
|
||||
title={(
|
||||
<div className="signature-modal-title">
|
||||
<h2>{item ? '编辑签名' : '添加签名'}</h2>
|
||||
<p>{item ? '修改短信签名的相关信息' : '新增短信签名的相关信息'}</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="signature-form">
|
||||
<section>
|
||||
<h3>基本信息</h3>
|
||||
<div className="signature-alert">
|
||||
<Info size={18} />
|
||||
<span>签名需履行报备,并遵照管理部门审核结果方可使用。请用 PNG、JPG、JPEG 或 PDF 格式上传真实材料。</span>
|
||||
</div>
|
||||
<div className="signature-form-grid">
|
||||
<Select
|
||||
disabled={Boolean(item)}
|
||||
@@ -149,7 +297,7 @@ function SignatureFormModal({
|
||||
value={form.tenantId}
|
||||
/>
|
||||
<Select
|
||||
label="所属应用"
|
||||
label="* 应用名称"
|
||||
onChange={(event) => update('applicationId', event.target.value)}
|
||||
options={[
|
||||
{ label: '不绑定应用', value: '' },
|
||||
@@ -157,8 +305,46 @@ function SignatureFormModal({
|
||||
]}
|
||||
value={form.applicationId}
|
||||
/>
|
||||
<Input label="短信签名" onChange={(event) => update('name', event.target.value)} placeholder="例如【某某科技】" required value={form.name} />
|
||||
<Input label="签名用途" onChange={(event) => update('purpose', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.purpose} />
|
||||
<Select
|
||||
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>
|
||||
</section>
|
||||
|
||||
@@ -180,6 +366,16 @@ function DrainageFormModal({ item, onClose, onSubmit }: { item?: DrainageInfo; o
|
||||
id: `drain-${Date.now()}`,
|
||||
siteName: '',
|
||||
url: '',
|
||||
field1File: null,
|
||||
field2: '',
|
||||
field3: '',
|
||||
field4: '',
|
||||
field5: '',
|
||||
field6: '',
|
||||
field7File: null,
|
||||
field8: '',
|
||||
field9: '',
|
||||
field10: '',
|
||||
mobile: 'filing',
|
||||
unicom: 'filing',
|
||||
telecom: 'filing',
|
||||
@@ -196,20 +392,43 @@ function DrainageFormModal({ item, onClose, onSubmit }: { item?: DrainageInfo; o
|
||||
footer={(
|
||||
<>
|
||||
<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}
|
||||
open
|
||||
size="xl"
|
||||
title={item ? '编辑引流链接' : '添加引流链接'}
|
||||
title={item ? '编辑引流信息' : '添加引流信息'}
|
||||
>
|
||||
<div className="signature-form drainage-edit-form">
|
||||
<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>图片仅支持 PNG、JPG 或 JPEG 格式的正版文件,且大小不超过 3M;</li>
|
||||
<li>文件格式支持 PDF 格式或者图片,且大小不超过 10M。</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="signature-form-grid">
|
||||
<Input label="站名称" onChange={(event) => update('siteName', event.target.value)} placeholder="请输入站点名称" required value={form.siteName} />
|
||||
<Input label="网站链接" onChange={(event) => update('url', event.target.value)} placeholder="https://example.com" required value={form.url} />
|
||||
<SignatureUploadBox compact file={form.field1File} label="* 字段名称1" onUploaded={(file) => update('field1File', file)} />
|
||||
<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('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} />
|
||||
@@ -326,7 +545,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
mobile: state.mobile,
|
||||
unicom: state.unicom,
|
||||
telecom: state.telecom,
|
||||
}, existingPayload.links);
|
||||
}, existingPayload.links, state.profile);
|
||||
try {
|
||||
if (existing) {
|
||||
await adminApi.updateEnterpriseSignature(existing.id, {
|
||||
@@ -362,7 +581,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
? payload.links.map((current) => current.id === item.id ? item : current)
|
||||
: [item, ...payload.links];
|
||||
await adminApi.updateEnterpriseSignature(signatureId, {
|
||||
drainageInfo: buildDrainagePayload(payload.carrierStatus, links),
|
||||
drainageInfo: buildDrainagePayload(payload.carrierStatus, links, payload.signatureProfile),
|
||||
});
|
||||
setDrainageModal(null);
|
||||
setExpandedSignatureId(signatureId);
|
||||
@@ -380,7 +599,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
if (signature) {
|
||||
const payload = readDrainagePayload(signature);
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { adminApi, type DictionaryItem } from '@/api/adminApi';
|
||||
|
||||
@@ -53,6 +53,11 @@ export function AdminPhoneSegmentsPage() {
|
||||
[keyword, segments],
|
||||
);
|
||||
|
||||
const filteredRules = useMemo(
|
||||
() => rules.filter((rule) => [rule.carrier, rule.pattern, rule.remark].some((value) => String(value ?? '').includes(keyword))),
|
||||
[keyword, rules],
|
||||
);
|
||||
|
||||
function createSegment() {
|
||||
adminApi.createPhoneSegment({ prefix, carrier, province, city })
|
||||
.then(() => {
|
||||
@@ -101,12 +106,44 @@ export function AdminPhoneSegmentsPage() {
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="surface admin-system-toolbar">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索手机号段、运营商、省份或城市" prefix={<Search size={16} />} value={keyword} />
|
||||
<div className="segmented-control">
|
||||
<button className={activeTab === 'segments' ? 'is-active' : ''} onClick={() => setActiveTab('segments')} type="button">手机号段</button>
|
||||
<button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setActiveTab('rules')} type="button">运营商区分规则</button>
|
||||
<div className="phone-segment-overview">
|
||||
<section>
|
||||
<span><Smartphone size={20} /></span>
|
||||
<div>
|
||||
<strong>{segments.length}</strong>
|
||||
<p>手机号段记录</p>
|
||||
</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)}>
|
||||
{activeTab === 'segments' ? '新增号段' : '新增规则'}
|
||||
</Button>
|
||||
@@ -115,7 +152,7 @@ export function AdminPhoneSegmentsPage() {
|
||||
<div className="surface admin-system-table-card">
|
||||
{activeTab === 'segments'
|
||||
? <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>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Download, Eye, FileUp, Search } from 'lucide-react';
|
||||
import { adminApi, type ReportTask } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type FileObject, type FileRef, type ReportTask } from '@/api/adminApi';
|
||||
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' }> = {
|
||||
pending: { label: '待处理', tone: 'neutral' },
|
||||
@@ -13,12 +13,59 @@ const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'su
|
||||
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 [fileObject, setFileObject] = useState<FileObject | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
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 (
|
||||
<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}
|
||||
open
|
||||
size="xl"
|
||||
@@ -29,13 +76,15 @@ function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubm
|
||||
<FileUp size={38} />
|
||||
<strong>{file?.name || '选择回执文件'}</strong>
|
||||
<span>支持 CSV、TSV、TXT 文本回执,需包含状态/结果列。</span>
|
||||
<FileActions file={fileRef} />
|
||||
<input
|
||||
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' }}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<Textarea label="导入备注" onChange={(event) => setRemark(event.target.value)} placeholder="记录回执来源、运营商工单号或人工处理说明" rows={4} value={remark} />
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -92,20 +141,15 @@ export function AdminReportTasksPage() {
|
||||
.catch((failure: Error) => setError(failure.message || '报备任务导出失败'));
|
||||
}
|
||||
|
||||
function importReceipt(file: File, remark: string) {
|
||||
function importReceipt(payload: ReceiptImportPayload) {
|
||||
if (!receiptTask) return;
|
||||
const delimiter = file.name.toLowerCase().endsWith('.tsv') ? '\t' : ',';
|
||||
Promise.all([
|
||||
adminApi.uploadFileObject(file, { purpose: 'report_receipt', prefix: `report-receipts/${receiptTask.id}` }),
|
||||
file.text(),
|
||||
])
|
||||
.then(([fileObject, fileContent]) => adminApi.importReportReceipt(receiptTask.id, {
|
||||
fileObjectId: fileObject.id,
|
||||
fileName: file.name,
|
||||
fileContent,
|
||||
delimiter,
|
||||
reason: remark,
|
||||
}))
|
||||
adminApi.importReportReceipt(receiptTask.id, {
|
||||
delimiter: payload.delimiter,
|
||||
fileContent: payload.fileContent,
|
||||
fileName: payload.fileName,
|
||||
fileObjectId: payload.fileObjectId,
|
||||
reason: payload.remark,
|
||||
})
|
||||
.then(() => {
|
||||
setReceiptTask(null);
|
||||
loadData();
|
||||
@@ -156,7 +200,7 @@ export function AdminReportTasksPage() {
|
||||
<Table columns={columns} data={filteredTasks} emptyText="暂无报备任务" rowKey="id" />
|
||||
</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}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -146,7 +146,7 @@ export function ClientApplicationsPage() {
|
||||
<dd>{formatPrice(application.customerUnitPrice)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>CMPP链接状态</dt>
|
||||
<dt>CMPP连接状态</dt>
|
||||
<dd><Tag tone={statusToneMap[linkStatus]}>{statusLabelMap[linkStatus]}</Tag></dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
Upload,
|
||||
UserCheck,
|
||||
} from 'lucide-react';
|
||||
import { Button, Input, Select, Textarea } from '@/components/ui';
|
||||
import { clientApi, type EnterpriseCertification, type FileObject } from '@/api/adminApi';
|
||||
import { Button, FileActions, Input, Select, Textarea } from '@/components/ui';
|
||||
import { clientApi, type EnterpriseCertification, type FileObject, type FileRef } from '@/api/adminApi';
|
||||
|
||||
type AuthStep = 'overview' | 'profile' | 'method' | 'recharge' | 'face' | 'faceScan' | 'pending' | 'success' | 'failed';
|
||||
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 }) {
|
||||
const fileRef: FileRef | null = file
|
||||
? { contentType: file.contentType, fileName: file.fileName, fileObjectId: file.id }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<label className="enterprise-upload">
|
||||
<Upload size={38} />
|
||||
<strong>{uploading ? '上传中...' : file?.fileName ?? '点击上传'}</strong>
|
||||
<FileActions file={fileRef} />
|
||||
<input
|
||||
accept="image/png,image/jpeg,image/webp,application/pdf"
|
||||
disabled={uploading}
|
||||
@@ -203,6 +208,7 @@ export function ClientEnterpriseAuthPage() {
|
||||
materials: {
|
||||
licenseFileObjectId: licenseFile?.id,
|
||||
licenseFileName: licenseFile?.fileName,
|
||||
licenseFileContentType: licenseFile?.contentType,
|
||||
province: form.province,
|
||||
city: form.city,
|
||||
address: form.address,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { 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 [importContent, setImportContent] = useState('');
|
||||
const [importFileName, setImportFileName] = useState('');
|
||||
const [importFileUrl, setImportFileUrl] = useState('');
|
||||
const [importPreview, setImportPreview] = useState<ImportPreviewResponse | null>(null);
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
const [submittedRecord, setSubmittedRecord] = useState<SmsBatchTask | null>(null);
|
||||
@@ -44,6 +45,12 @@ export function ClientSendPage() {
|
||||
.catch((reason: Error) => setError(reason.message || '短信发送基础数据加载失败'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (importFileUrl) {
|
||||
URL.revokeObjectURL(importFileUrl);
|
||||
}
|
||||
}, [importFileUrl]);
|
||||
|
||||
const selectedSignature = useMemo(
|
||||
() => signatures.find((item) => item.id === signatureId),
|
||||
[signatures, signatureId],
|
||||
@@ -133,10 +140,22 @@ export function ClientSendPage() {
|
||||
});
|
||||
setImportContent(content);
|
||||
setImportFileName(file.name);
|
||||
setImportFileUrl((current) => {
|
||||
if (current) {
|
||||
URL.revokeObjectURL(current);
|
||||
}
|
||||
return URL.createObjectURL(file);
|
||||
});
|
||||
setImportPreview(preview);
|
||||
} catch (reason) {
|
||||
setImportContent('');
|
||||
setImportFileName('');
|
||||
setImportFileUrl((current) => {
|
||||
if (current) {
|
||||
URL.revokeObjectURL(current);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
setImportPreview(null);
|
||||
setError(reason instanceof Error ? reason.message : '导入预览失败');
|
||||
} finally {
|
||||
@@ -294,7 +313,17 @@ export function ClientSendPage() {
|
||||
<Button disabled={importLoading || !templateId} onClick={() => document.getElementById('sms-import-file')?.click()} variant="ghost">
|
||||
{importLoading ? '解析中...' : '选择文件'}
|
||||
</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 ? (
|
||||
<div className="detail-grid">
|
||||
<div><span>总行数</span><strong>{importPreview.totalRows}</strong></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FilePenLine, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Tag } from '@/components/ui';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignature } from '@/api/adminApi';
|
||||
import { Button, FileActions, Input, Modal, Select, Tag } from '@/components/ui';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
|
||||
|
||||
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
|
||||
approved: 'success',
|
||||
@@ -18,6 +18,14 @@ const statusLabel: Record<string, string> = {
|
||||
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() {
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
|
||||
@@ -124,6 +132,9 @@ export function ClientSignaturesPage() {
|
||||
<div>
|
||||
<span>材料</span>
|
||||
<strong>{signature.materials?.length ?? 0} 份</strong>
|
||||
{signature.materials?.map((material) => (
|
||||
<FileActions file={materialToFileRef(material)} key={String(material.id ?? material.fileObjectId)} />
|
||||
))}
|
||||
</div>
|
||||
<div className="signature-actions">
|
||||
<Button icon={<Trash2 size={16} />} onClick={() => disableSignature(signature.id)} size="sm" variant="danger">删除</Button>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export { Chart } from './Chart';
|
||||
export { DateRangeInput } from './DateRangeInput';
|
||||
export { DateTimeInput } from './DateTimeInput';
|
||||
export { DetailInfoGrid, DetailProgressStats, DetailSection, DetailTitle, getRateTone, ProgressBar, RateCard, RateOverview } from './Detail';
|
||||
export { FileActions } from './FileActions';
|
||||
export { Input } from './Input';
|
||||
export { Modal } from './Modal';
|
||||
export { InlineTextPreview, Pagination, QueryPanel } from './PagePrimitives';
|
||||
|
||||
+246
-5
@@ -6904,6 +6904,29 @@ h3 {
|
||||
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 {
|
||||
min-width: 1040px;
|
||||
}
|
||||
@@ -6999,6 +7022,85 @@ h3 {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
@@ -8285,6 +8387,114 @@ h3 {
|
||||
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 {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
@@ -8367,6 +8577,34 @@ h3 {
|
||||
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) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -8534,9 +8772,12 @@ h3 {
|
||||
.admin-recharge-filter,
|
||||
.admin-recharge-filter__actions,
|
||||
.admin-system-toolbar,
|
||||
.admin-system-modal-form,
|
||||
.admin-drainage-toolbar,
|
||||
.admin-split-filter,
|
||||
.phone-segment-overview,
|
||||
.phone-segment-tabs,
|
||||
.admin-system-modal-form,
|
||||
.admin-drainage-toolbar,
|
||||
.channel-route-card-grid,
|
||||
.admin-split-filter,
|
||||
.detail-grid,
|
||||
.admin-task-detail-grid,
|
||||
.admin-task-metrics,
|
||||
@@ -8546,9 +8787,9 @@ h3 {
|
||||
.admin-uplink-info-grid,
|
||||
.admin-uplink-match-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-recharge-pagination {
|
||||
.admin-recharge-pagination {
|
||||
align-items: stretch;
|
||||
display: grid;
|
||||
justify-content: stretch;
|
||||
|
||||
@@ -147,10 +147,11 @@ async function ensureSmokeData() {
|
||||
}
|
||||
|
||||
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: {
|
||||
tenantId: tenant.id,
|
||||
status: 'online',
|
||||
applicationId: application.id,
|
||||
status: 'connected',
|
||||
desiredConnections: 1,
|
||||
currentConnections: 1,
|
||||
lastHeartbeatAt: new Date(),
|
||||
@@ -158,9 +159,10 @@ async function ensureSmokeData() {
|
||||
},
|
||||
create: {
|
||||
tenantId: tenant.id,
|
||||
applicationId: application.id,
|
||||
channelId: channel.id,
|
||||
connectionId: 'smoke-conn-1',
|
||||
status: 'online',
|
||||
status: 'connected',
|
||||
desiredConnections: 1,
|
||||
currentConnections: 1,
|
||||
lastConnectedAt: new Date(),
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user