feat: add protocol interaction observability
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
CREATE TABLE "ProtocolInteractionLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"protocol" TEXT NOT NULL,
|
||||||
|
"direction" TEXT NOT NULL,
|
||||||
|
"eventType" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT,
|
||||||
|
"applicationId" TEXT,
|
||||||
|
"channelId" TEXT,
|
||||||
|
"account" TEXT,
|
||||||
|
"messageId" TEXT,
|
||||||
|
"gatewayMessageId" TEXT,
|
||||||
|
"traceId" TEXT,
|
||||||
|
"requestId" TEXT,
|
||||||
|
"phoneMasked" TEXT,
|
||||||
|
"resultCode" TEXT,
|
||||||
|
"durationMs" INTEGER,
|
||||||
|
"payloadBytes" INTEGER,
|
||||||
|
"retryCount" INTEGER,
|
||||||
|
"detail" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ProtocolInteractionLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "ProtocolInteractionLog_createdAt_idx" ON "ProtocolInteractionLog"("createdAt");
|
||||||
|
CREATE INDEX "ProtocolInteractionLog_protocol_direction_createdAt_idx" ON "ProtocolInteractionLog"("protocol", "direction", "createdAt");
|
||||||
|
CREATE INDEX "ProtocolInteractionLog_eventType_createdAt_idx" ON "ProtocolInteractionLog"("eventType", "createdAt");
|
||||||
|
CREATE INDEX "ProtocolInteractionLog_status_createdAt_idx" ON "ProtocolInteractionLog"("status", "createdAt");
|
||||||
|
CREATE INDEX "ProtocolInteractionLog_channelId_createdAt_idx" ON "ProtocolInteractionLog"("channelId", "createdAt");
|
||||||
|
CREATE INDEX "ProtocolInteractionLog_applicationId_createdAt_idx" ON "ProtocolInteractionLog"("applicationId", "createdAt");
|
||||||
|
CREATE INDEX "ProtocolInteractionLog_messageId_idx" ON "ProtocolInteractionLog"("messageId");
|
||||||
|
CREATE INDEX "ProtocolInteractionLog_gatewayMessageId_idx" ON "ProtocolInteractionLog"("gatewayMessageId");
|
||||||
|
CREATE INDEX "ProtocolInteractionLog_requestId_idx" ON "ProtocolInteractionLog"("requestId");
|
||||||
@@ -182,6 +182,39 @@ model OperationLogArchive {
|
|||||||
@@index([archiveMonth])
|
@@index([archiveMonth])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ProtocolInteractionLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
protocol String
|
||||||
|
direction String
|
||||||
|
eventType String
|
||||||
|
status String
|
||||||
|
tenantId String?
|
||||||
|
applicationId String?
|
||||||
|
channelId String?
|
||||||
|
account String?
|
||||||
|
messageId String?
|
||||||
|
gatewayMessageId String?
|
||||||
|
traceId String?
|
||||||
|
requestId String?
|
||||||
|
phoneMasked String?
|
||||||
|
resultCode String?
|
||||||
|
durationMs Int?
|
||||||
|
payloadBytes Int?
|
||||||
|
retryCount Int?
|
||||||
|
detail Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([protocol, direction, createdAt])
|
||||||
|
@@index([eventType, createdAt])
|
||||||
|
@@index([status, createdAt])
|
||||||
|
@@index([channelId, createdAt])
|
||||||
|
@@index([applicationId, createdAt])
|
||||||
|
@@index([messageId])
|
||||||
|
@@index([gatewayMessageId])
|
||||||
|
@@index([requestId])
|
||||||
|
}
|
||||||
|
|
||||||
model FileObject {
|
model FileObject {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String?
|
tenantId String?
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { HealthController } from './health.controller';
|
|||||||
import { OperationsModule } from './operations/operations.module';
|
import { OperationsModule } from './operations/operations.module';
|
||||||
import { OpenApiModule } from './open-api/open-api.module';
|
import { OpenApiModule } from './open-api/open-api.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { ProtocolLogsModule } from './protocol-logs/protocol-logs.module';
|
||||||
import { RiskReviewModule } from './risk-review/risk-review.module';
|
import { RiskReviewModule } from './risk-review/risk-review.module';
|
||||||
import { ReportsModule } from './reports/reports.module';
|
import { ReportsModule } from './reports/reports.module';
|
||||||
import { ReportMaterialsModule } from './report-materials/report-materials.module';
|
import { ReportMaterialsModule } from './report-materials/report-materials.module';
|
||||||
@@ -30,6 +31,7 @@ import { UsersModule } from './users/users.module';
|
|||||||
envFilePath: ['.env.local', '.env'],
|
envFilePath: ['.env.local', '.env'],
|
||||||
}),
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
ProtocolLogsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
TenantsModule,
|
TenantsModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BadRequestException, ConflictException, ForbiddenException, forwardRef, HttpException, Inject, Injectable, NotFoundException, OnModuleDestroy, OnModuleInit, UnprocessableEntityException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, ForbiddenException, forwardRef, HttpException, Inject, Injectable, NotFoundException, OnModuleDestroy, OnModuleInit, Optional, UnprocessableEntityException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { Queue, Worker } from 'bullmq';
|
import { Queue, Worker } from 'bullmq';
|
||||||
import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto';
|
import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto';
|
||||||
@@ -10,6 +10,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { SendChainService } from '../send-chain/send-chain.service';
|
import { SendChainService } from '../send-chain/send-chain.service';
|
||||||
import { decryptSecret, encryptSecret } from './open-api.crypto';
|
import { decryptSecret, encryptSecret } from './open-api.crypto';
|
||||||
import type { OpenApiAuthContext } from './open-api.types';
|
import type { OpenApiAuthContext } from './open-api.types';
|
||||||
|
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
|
||||||
|
|
||||||
const WEBHOOK_QUEUE = 'http-webhook-delivery';
|
const WEBHOOK_QUEUE = 'http-webhook-delivery';
|
||||||
const DELIVERY_MODES = ['cmpp', 'http', 'both', 'none'] as const;
|
const DELIVERY_MODES = ['cmpp', 'http', 'both', 'none'] as const;
|
||||||
@@ -45,7 +46,11 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private queue?: Queue<{ deliveryId: string }>;
|
private queue?: Queue<{ deliveryId: string }>;
|
||||||
private worker?: Worker<{ deliveryId: string }>;
|
private worker?: Worker<{ deliveryId: string }>;
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService, @Inject(forwardRef(() => SendChainService)) private readonly sendChain: SendChainService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
@Inject(forwardRef(() => SendChainService)) private readonly sendChain: SendChainService,
|
||||||
|
@Optional() private readonly protocolLogs?: ProtocolLogsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
const connection = bullmqConnection();
|
const connection = bullmqConnection();
|
||||||
@@ -203,6 +208,21 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
const response = { code: 'ACCEPTED', requestId, messageId: message?.messageId, clientMessageId: input.clientMessageId ?? null, status: message?.status ?? task.status, acceptedAt: new Date().toISOString() };
|
const response = { code: 'ACCEPTED', requestId, messageId: message?.messageId, clientMessageId: input.clientMessageId ?? null, status: message?.status ?? task.status, acceptedAt: new Date().toISOString() };
|
||||||
await this.prisma.openApiRequest.update({ where: { id: request.id }, data: { status: 'completed', httpStatus: 202, businessCode: 'ACCEPTED', responseBody: response, messageRecordId: message?.id, durationMs: Date.now() - startedAt, completedAt: new Date() } });
|
await this.prisma.openApiRequest.update({ where: { id: request.id }, data: { status: 'completed', httpStatus: 202, businessCode: 'ACCEPTED', responseBody: response, messageRecordId: message?.id, durationMs: Date.now() - startedAt, completedAt: new Date() } });
|
||||||
|
this.protocolLogs?.record({
|
||||||
|
protocol: 'http',
|
||||||
|
direction: 'client_to_platform',
|
||||||
|
eventType: 'send_request',
|
||||||
|
status: 'accepted',
|
||||||
|
tenantId: auth.application.tenantId,
|
||||||
|
applicationId: auth.application.id,
|
||||||
|
messageId: message?.messageId,
|
||||||
|
requestId,
|
||||||
|
phone: mobile,
|
||||||
|
resultCode: 'ACCEPTED',
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
payloadBytes: Buffer.byteLength(content, 'utf8'),
|
||||||
|
detail: { clientMessageId: input.clientMessageId },
|
||||||
|
});
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let outwardError = error;
|
let outwardError = error;
|
||||||
@@ -213,6 +233,19 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
const failure = normalizeOpenApiFailure(outwardError);
|
const failure = normalizeOpenApiFailure(outwardError);
|
||||||
await this.prisma.openApiRequest.update({ where: { id: request.id }, data: { status: 'failed', httpStatus: failure.httpStatus, businessCode: failure.code, responseBody: failure.responseBody, durationMs: Date.now() - startedAt, completedAt: new Date() } });
|
await this.prisma.openApiRequest.update({ where: { id: request.id }, data: { status: 'failed', httpStatus: failure.httpStatus, businessCode: failure.code, responseBody: failure.responseBody, durationMs: Date.now() - startedAt, completedAt: new Date() } });
|
||||||
|
this.protocolLogs?.record({
|
||||||
|
protocol: 'http',
|
||||||
|
direction: 'client_to_platform',
|
||||||
|
eventType: 'send_request',
|
||||||
|
status: 'failed',
|
||||||
|
tenantId: auth.application.tenantId,
|
||||||
|
applicationId: auth.application.id,
|
||||||
|
requestId,
|
||||||
|
phone: mobile,
|
||||||
|
resultCode: failure.code,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
payloadBytes: Buffer.byteLength(content, 'utf8'),
|
||||||
|
});
|
||||||
throw outwardError;
|
throw outwardError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,6 +359,21 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const success = responseStatus !== undefined && responseStatus >= 200 && responseStatus < 300;
|
const success = responseStatus !== undefined && responseStatus >= 200 && responseStatus < 300;
|
||||||
const retryable = errorMessage !== undefined || responseStatus === 408 || responseStatus === 429 || (responseStatus !== undefined && responseStatus >= 500);
|
const retryable = errorMessage !== undefined || responseStatus === 408 || responseStatus === 429 || (responseStatus !== undefined && responseStatus >= 500);
|
||||||
await this.prisma.httpWebhookAttempt.create({ data: { deliveryId, attemptNo, responseStatus, responseSummary, errorMessage, durationMs: Date.now() - startedAt, requestHeaders: { 'x-event-id': delivery.event.eventId, 'x-event-type': delivery.event.eventType, 'x-timestamp': timestamp, 'x-signature': 'sha256=***' } } });
|
await this.prisma.httpWebhookAttempt.create({ data: { deliveryId, attemptNo, responseStatus, responseSummary, errorMessage, durationMs: Date.now() - startedAt, requestHeaders: { 'x-event-id': delivery.event.eventId, 'x-event-type': delivery.event.eventType, 'x-timestamp': timestamp, 'x-signature': 'sha256=***' } } });
|
||||||
|
this.protocolLogs?.record({
|
||||||
|
protocol: 'http',
|
||||||
|
direction: 'platform_to_client',
|
||||||
|
eventType: `${delivery.event.eventType}_webhook`,
|
||||||
|
status: success ? 'success' : retryable ? 'retrying' : 'failed',
|
||||||
|
tenantId: delivery.event.tenantId,
|
||||||
|
applicationId: delivery.event.applicationId,
|
||||||
|
messageId: delivery.event.messageId,
|
||||||
|
requestId: delivery.event.eventId,
|
||||||
|
resultCode: responseStatus ?? 'NETWORK_ERROR',
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
payloadBytes: Buffer.byteLength(body, 'utf8'),
|
||||||
|
retryCount: attemptNo - 1,
|
||||||
|
detail: { deliveryId, attemptNo, error: errorMessage },
|
||||||
|
});
|
||||||
if (success) {
|
if (success) {
|
||||||
await this.prisma.httpWebhookDelivery.update({ where: { id: deliveryId }, data: { status: 'delivered', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: null, deliveredAt: new Date(), nextRetryAt: null } });
|
await this.prisma.httpWebhookDelivery.update({ where: { id: deliveryId }, data: { status: 'delivered', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: null, deliveredAt: new Date(), nextRetryAt: null } });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
|||||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||||
import { SendChainService } from '../send-chain/send-chain.service';
|
import { SendChainService } from '../send-chain/send-chain.service';
|
||||||
import { OperationsService } from './operations.service';
|
import { OperationsService } from './operations.service';
|
||||||
|
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
|
||||||
|
|
||||||
type DownloadResponse = {
|
type DownloadResponse = {
|
||||||
setHeader(name: string, value: number | string): void;
|
setHeader(name: string, value: number | string): void;
|
||||||
@@ -259,7 +260,24 @@ export class AdminOperationsController {
|
|||||||
@ApiTags('admin-system-logs')
|
@ApiTags('admin-system-logs')
|
||||||
@Controller('admin/system-logs')
|
@Controller('admin/system-logs')
|
||||||
export class AdminSystemLogsController {
|
export class AdminSystemLogsController {
|
||||||
constructor(private readonly operations: OperationsService) {}
|
constructor(
|
||||||
|
private readonly operations: OperationsService,
|
||||||
|
private readonly protocolLogs: ProtocolLogsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('protocol-interactions')
|
||||||
|
protocolInteractions(
|
||||||
|
@Query('protocol') protocol?: string,
|
||||||
|
@Query('direction') direction?: string,
|
||||||
|
@Query('eventType') eventType?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('range') range?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.protocolLogs.list({ protocol, direction, eventType, status, keyword, range, page: Number(page), pageSize: Number(pageSize) });
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(
|
list(
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ProtocolLogsService } from './protocol-logs.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [ProtocolLogsService],
|
||||||
|
exports: [ProtocolLogsService],
|
||||||
|
})
|
||||||
|
export class ProtocolLogsModule {}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { ProtocolLogsService } from './protocol-logs.service';
|
||||||
|
|
||||||
|
describe('ProtocolLogsService', () => {
|
||||||
|
const prisma = {
|
||||||
|
protocolInteractionLog: {
|
||||||
|
createMany: jest.fn(),
|
||||||
|
deleteMany: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
groupBy: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
prisma.protocolInteractionLog.createMany.mockResolvedValue({ count: 1 });
|
||||||
|
prisma.protocolInteractionLog.deleteMany.mockResolvedValue({ count: 0 });
|
||||||
|
prisma.protocolInteractionLog.findMany.mockResolvedValue([]);
|
||||||
|
prisma.protocolInteractionLog.count.mockResolvedValue(0);
|
||||||
|
prisma.protocolInteractionLog.groupBy.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buffers a masked and secret-free business event', async () => {
|
||||||
|
const service = new ProtocolLogsService(prisma as never);
|
||||||
|
service.record({
|
||||||
|
protocol: 'cmpp',
|
||||||
|
direction: 'channel_to_platform',
|
||||||
|
eventType: 'deliver_receipt',
|
||||||
|
status: 'received',
|
||||||
|
phone: '18821203795',
|
||||||
|
gatewayMessageId: 123n,
|
||||||
|
detail: { sequenceId: 7, content: 'must not persist', authorization: 'secret' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.onModuleDestroy();
|
||||||
|
|
||||||
|
expect(prisma.protocolInteractionLog.createMany).toHaveBeenCalledWith({
|
||||||
|
data: [expect.objectContaining({
|
||||||
|
phoneMasked: '188****3795',
|
||||||
|
gatewayMessageId: '123',
|
||||||
|
detail: { sequenceId: 7 },
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies protocol, direction, keyword and time filters', async () => {
|
||||||
|
const service = new ProtocolLogsService(prisma as never);
|
||||||
|
await service.list({
|
||||||
|
protocol: 'cmpp',
|
||||||
|
direction: 'channel_to_platform',
|
||||||
|
keyword: 'MSG-1',
|
||||||
|
range: '7d',
|
||||||
|
page: 2,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prisma.protocolInteractionLog.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
skip: 20,
|
||||||
|
take: 20,
|
||||||
|
where: expect.objectContaining({
|
||||||
|
protocol: 'cmpp',
|
||||||
|
direction: 'channel_to_platform',
|
||||||
|
createdAt: { gte: expect.any(Date) },
|
||||||
|
OR: expect.arrayContaining([{ messageId: { contains: 'MSG-1' } }]),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
export type ProtocolLogInput = {
|
||||||
|
protocol: 'cmpp' | 'http';
|
||||||
|
direction: 'client_to_platform' | 'platform_to_channel' | 'channel_to_platform' | 'platform_to_client';
|
||||||
|
eventType: string;
|
||||||
|
status: 'received' | 'accepted' | 'success' | 'failed' | 'retrying';
|
||||||
|
tenantId?: string | null;
|
||||||
|
applicationId?: string | null;
|
||||||
|
channelId?: string | null;
|
||||||
|
account?: string | null;
|
||||||
|
messageId?: string | null;
|
||||||
|
gatewayMessageId?: string | number | bigint | null;
|
||||||
|
traceId?: string | null;
|
||||||
|
requestId?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
resultCode?: string | number | null;
|
||||||
|
durationMs?: number | null;
|
||||||
|
payloadBytes?: number | null;
|
||||||
|
retryCount?: number | null;
|
||||||
|
detail?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProtocolLogQuery = {
|
||||||
|
protocol?: string;
|
||||||
|
direction?: string;
|
||||||
|
eventType?: string;
|
||||||
|
status?: string;
|
||||||
|
keyword?: string;
|
||||||
|
range?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProtocolLogsService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(ProtocolLogsService.name);
|
||||||
|
private readonly buffer: Prisma.ProtocolInteractionLogCreateManyInput[] = [];
|
||||||
|
private flushTimer?: ReturnType<typeof setInterval>;
|
||||||
|
private retentionTimer?: ReturnType<typeof setInterval>;
|
||||||
|
private flushing = false;
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.flushTimer = setInterval(() => void this.flush(), positiveEnv('PROTOCOL_LOG_FLUSH_INTERVAL_MS', 500));
|
||||||
|
this.flushTimer.unref?.();
|
||||||
|
this.retentionTimer = setInterval(() => void this.purgeExpired(), positiveEnv('PROTOCOL_LOG_RETENTION_INTERVAL_MS', 86_400_000));
|
||||||
|
this.retentionTimer.unref?.();
|
||||||
|
setTimeout(() => void this.purgeExpired(), 30_000).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
if (this.flushTimer) clearInterval(this.flushTimer);
|
||||||
|
if (this.retentionTimer) clearInterval(this.retentionTimer);
|
||||||
|
await this.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
record(input: ProtocolLogInput) {
|
||||||
|
const maxQueue = positiveEnv('PROTOCOL_LOG_MAX_BUFFER', 10_000);
|
||||||
|
if (this.buffer.length >= maxQueue) {
|
||||||
|
this.logger.warn(`Protocol log buffer full; dropping event ${input.protocol}/${input.eventType}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.buffer.push({
|
||||||
|
protocol: input.protocol,
|
||||||
|
direction: input.direction,
|
||||||
|
eventType: clean(input.eventType, 64) ?? 'unknown',
|
||||||
|
status: input.status,
|
||||||
|
tenantId: clean(input.tenantId),
|
||||||
|
applicationId: clean(input.applicationId),
|
||||||
|
channelId: clean(input.channelId),
|
||||||
|
account: clean(input.account, 128),
|
||||||
|
messageId: clean(input.messageId, 128),
|
||||||
|
gatewayMessageId: input.gatewayMessageId == null ? null : String(input.gatewayMessageId).slice(0, 128),
|
||||||
|
traceId: clean(input.traceId, 128),
|
||||||
|
requestId: clean(input.requestId, 128),
|
||||||
|
phoneMasked: maskPhone(input.phone),
|
||||||
|
resultCode: input.resultCode == null ? null : String(input.resultCode).slice(0, 64),
|
||||||
|
durationMs: safeInteger(input.durationMs),
|
||||||
|
payloadBytes: safeInteger(input.payloadBytes),
|
||||||
|
retryCount: safeInteger(input.retryCount),
|
||||||
|
detail: sanitizeDetail(input.detail),
|
||||||
|
});
|
||||||
|
if (this.buffer.length >= positiveEnv('PROTOCOL_LOG_BATCH_SIZE', 100)) void this.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: ProtocolLogQuery) {
|
||||||
|
const page = Math.max(1, Number(query.page) || 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize) || 20));
|
||||||
|
const where: Prisma.ProtocolInteractionLogWhereInput = {
|
||||||
|
protocol: selected(query.protocol),
|
||||||
|
direction: selected(query.direction),
|
||||||
|
eventType: selected(query.eventType),
|
||||||
|
status: selected(query.status),
|
||||||
|
createdAt: rangeWhere(query.range),
|
||||||
|
OR: query.keyword ? [
|
||||||
|
{ messageId: { contains: query.keyword } },
|
||||||
|
{ gatewayMessageId: { contains: query.keyword } },
|
||||||
|
{ requestId: { contains: query.keyword } },
|
||||||
|
{ traceId: { contains: query.keyword } },
|
||||||
|
{ account: { contains: query.keyword } },
|
||||||
|
{ phoneMasked: { contains: query.keyword } },
|
||||||
|
{ resultCode: { contains: query.keyword } },
|
||||||
|
] : undefined,
|
||||||
|
};
|
||||||
|
const [items, total, eventTypes] = await Promise.all([
|
||||||
|
this.prisma.protocolInteractionLog.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.protocolInteractionLog.count({ where }),
|
||||||
|
this.prisma.protocolInteractionLog.groupBy({ by: ['eventType'], orderBy: { eventType: 'asc' } }),
|
||||||
|
]);
|
||||||
|
return { items, total, page, pageSize, eventTypes: eventTypes.map((item) => item.eventType) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flush() {
|
||||||
|
if (this.flushing || this.buffer.length === 0) return;
|
||||||
|
this.flushing = true;
|
||||||
|
const batch = this.buffer.splice(0, positiveEnv('PROTOCOL_LOG_BATCH_SIZE', 100));
|
||||||
|
try {
|
||||||
|
await this.prisma.protocolInteractionLog.createMany({ data: batch });
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Protocol log batch write failed (${batch.length} events)`, error instanceof Error ? error.stack : String(error));
|
||||||
|
} finally {
|
||||||
|
this.flushing = false;
|
||||||
|
if (this.buffer.length > 0) setImmediate(() => void this.flush());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async purgeExpired() {
|
||||||
|
const retentionDays = positiveEnv('PROTOCOL_LOG_RETENTION_DAYS', 30);
|
||||||
|
try {
|
||||||
|
const result = await this.prisma.protocolInteractionLog.deleteMany({
|
||||||
|
where: { createdAt: { lt: new Date(Date.now() - retentionDays * 86_400_000) } },
|
||||||
|
});
|
||||||
|
if (result.count > 0) this.logger.log(`Purged ${result.count} protocol logs older than ${retentionDays} days`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Protocol log retention failed', error instanceof Error ? error.stack : String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean(value: unknown, max = 191) {
|
||||||
|
const text = String(value ?? '').trim();
|
||||||
|
return text ? text.slice(0, max) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskPhone(value: unknown) {
|
||||||
|
const text = String(value ?? '').replace(/\D/g, '');
|
||||||
|
if (!text) return null;
|
||||||
|
return text.length >= 7 ? `${text.slice(0, 3)}****${text.slice(-4)}` : `***${text.slice(-2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeInteger(value: unknown) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isSafeInteger(number) && number >= 0 ? number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selected(value?: string) {
|
||||||
|
return value && value !== 'all' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveEnv(name: string, fallback: number) {
|
||||||
|
const value = Number(process.env[name]);
|
||||||
|
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeWhere(range?: string): Prisma.DateTimeFilter | undefined {
|
||||||
|
const now = new Date();
|
||||||
|
if (range === 'today') return { gte: new Date(now.getFullYear(), now.getMonth(), now.getDate()) };
|
||||||
|
if (range === '7d') return { gte: new Date(now.getTime() - 7 * 86_400_000) };
|
||||||
|
if (range === '30d') return { gte: new Date(now.getTime() - 30 * 86_400_000) };
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeDetail(detail?: Record<string, unknown> | null): Prisma.InputJsonValue | undefined {
|
||||||
|
if (!detail) return undefined;
|
||||||
|
const blocked = /password|secret|token|signature|authorization|content|raw|body/i;
|
||||||
|
return Object.fromEntries(Object.entries(detail).filter(([key, value]) => !blocked.test(key) && value !== undefined).map(([key, value]) => [
|
||||||
|
key.slice(0, 64),
|
||||||
|
typeof value === 'string' ? value.slice(0, 500) : typeof value === 'number' || typeof value === 'boolean' || value === null ? value : String(value).slice(0, 500),
|
||||||
|
])) as Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
SendChainService,
|
SendChainService,
|
||||||
} from './send-chain.service';
|
} from './send-chain.service';
|
||||||
import { GatewayDownstreamConnectionEventDto, SmsConfigService } from '../sms-config/sms-config.service';
|
import { GatewayDownstreamConnectionEventDto, SmsConfigService } from '../sms-config/sms-config.service';
|
||||||
|
import { ProtocolLogsService, type ProtocolLogInput } from '../protocol-logs/protocol-logs.service';
|
||||||
|
|
||||||
@ApiTags('gateway-events')
|
@ApiTags('gateway-events')
|
||||||
@Controller('gateway/events')
|
@Controller('gateway/events')
|
||||||
@@ -22,21 +23,22 @@ export class GatewayEventsController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly sendChain: SendChainService,
|
private readonly sendChain: SendChainService,
|
||||||
private readonly smsConfig: SmsConfigService,
|
private readonly smsConfig: SmsConfigService,
|
||||||
|
private readonly protocolLogs: ProtocolLogsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('submit-result')
|
@Post('submit-result')
|
||||||
submitResult(@Body() body: GatewaySubmitResultDto) {
|
submitResult(@Body() body: GatewaySubmitResultDto) {
|
||||||
return this.sendChain.handleSubmitResult(body);
|
return this.trackGatewayEvent('submit_resp', body, () => this.sendChain.handleSubmitResult(body));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('receipt')
|
@Post('receipt')
|
||||||
receipt(@Body() body: GatewayReceiptEventDto) {
|
receipt(@Body() body: GatewayReceiptEventDto) {
|
||||||
return this.sendChain.handleReceipt(body);
|
return this.trackGatewayEvent('deliver_receipt', body, () => this.sendChain.handleReceipt(body));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('uplink')
|
@Post('uplink')
|
||||||
uplink(@Body() body: GatewayUplinkEventDto) {
|
uplink(@Body() body: GatewayUplinkEventDto) {
|
||||||
return this.sendChain.handleUplink(body);
|
return this.trackGatewayEvent('deliver_uplink', body, () => this.sendChain.handleUplink(body));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('dead-letter')
|
@Post('dead-letter')
|
||||||
@@ -46,12 +48,12 @@ export class GatewayEventsController {
|
|||||||
|
|
||||||
@Post('inbound/authenticate')
|
@Post('inbound/authenticate')
|
||||||
authenticateInbound(@Body() body: GatewayInboundAuthDto) {
|
authenticateInbound(@Body() body: GatewayInboundAuthDto) {
|
||||||
return this.sendChain.authenticateInboundApplication(body);
|
return this.trackGatewayEvent('connect', body, () => this.sendChain.authenticateInboundApplication(body), 'client_to_platform');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('inbound/submit')
|
@Post('inbound/submit')
|
||||||
submitInbound(@Body() body: GatewayInboundSubmitDto) {
|
submitInbound(@Body() body: GatewayInboundSubmitDto) {
|
||||||
return this.sendChain.submitInboundMessage(body);
|
return this.trackGatewayEvent('submit', body, () => this.sendChain.submitInboundMessage(body), 'client_to_platform');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('inbound/connection')
|
@Post('inbound/connection')
|
||||||
@@ -88,4 +90,46 @@ export class GatewayEventsController {
|
|||||||
downstreamRecoveryStatus(@Body() body: GatewayDownstreamRecoveryStatusDto) {
|
downstreamRecoveryStatus(@Body() body: GatewayDownstreamRecoveryStatusDto) {
|
||||||
return this.sendChain.recordGatewayDownstreamRecoveryStatus(body);
|
return this.sendChain.recordGatewayDownstreamRecoveryStatus(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async trackGatewayEvent<T>(
|
||||||
|
eventType: string,
|
||||||
|
body: object,
|
||||||
|
action: () => Promise<T> | T,
|
||||||
|
direction: ProtocolLogInput['direction'] = 'channel_to_platform',
|
||||||
|
) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const value = body as Record<string, unknown>;
|
||||||
|
const common: Omit<ProtocolLogInput, 'status'> = {
|
||||||
|
protocol: 'cmpp',
|
||||||
|
direction,
|
||||||
|
eventType,
|
||||||
|
tenantId: value.tenantId as string,
|
||||||
|
applicationId: value.applicationId as string,
|
||||||
|
channelId: value.channelId as string,
|
||||||
|
account: (value.account ?? value.loginAccount) as string,
|
||||||
|
messageId: (value.messageId ?? value.platformMessageId) as string,
|
||||||
|
gatewayMessageId: (value.gatewayMessageId ?? value.msgId ?? value.upstreamMessageId) as string,
|
||||||
|
phone: (value.phoneNumber ?? value.srcTerminalId ?? value.destinationId) as string,
|
||||||
|
resultCode: (value.result ?? value.status ?? value.stat) as string,
|
||||||
|
detail: { sequenceId: value.sequenceId, connectionId: value.connectionId },
|
||||||
|
};
|
||||||
|
this.protocolLogs.record({ ...common, status: 'received', durationMs: 0 });
|
||||||
|
try {
|
||||||
|
const result = await action();
|
||||||
|
this.protocolLogs.record({
|
||||||
|
...common,
|
||||||
|
status: 'success',
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
this.protocolLogs.record({
|
||||||
|
...common,
|
||||||
|
status: 'failed',
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
detail: { error: error instanceof Error ? error.message : 'unknown error' },
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1662,3 +1662,11 @@
|
|||||||
- 所有日报表按 `SmsMessageRecord.billingUnits` 统计长短信分片条数,输出提交、发送、未知、成功、失败五项;平台拦截(`status=rejected`)计入提交但不计入发送,并保证 `发送=未知+成功+失败`。
|
- 所有日报表按 `SmsMessageRecord.billingUnits` 统计长短信分片条数,输出提交、发送、未知、成功、失败五项;平台拦截(`status=rejected`)计入提交但不计入发送,并保证 `发送=未知+成功+失败`。
|
||||||
- 通道维度只存在已经路由到通道的记录,因此该维度的提交数等于发送数;应用、签名、引流信息和对账维度的提交数包含平台拦截。
|
- 通道维度只存在已经路由到通道的记录,因此该维度的提交数等于发送数;应用、签名、引流信息和对账维度的提交数包含平台拦截。
|
||||||
- 二级添加、编辑页面必须继续高亮其所属侧边菜单。
|
- 二级添加、编辑页面必须继续高亮其所属侧边菜单。
|
||||||
|
|
||||||
|
## 2026-07-24 CMPP/HTTP 通讯交互日志要求
|
||||||
|
|
||||||
|
1. 运营端“系统日志”必须将人员操作审计与协议通讯日志分成两个独立页签。通讯日志至少支持协议、交互方向、事件类型、结果、关键字和时间范围过滤,并展示平台消息号、上游消息号或HTTP请求号、脱敏对象、结果码、耗时和安全详情。
|
||||||
|
2. CMPP应覆盖客户登录/Submit、供应商SubmitResp、状态报告Deliver、上行Deliver及平台下游投递;HTTP应覆盖客户发送请求和平台回执/上行Webhook。日志状态至少区分已收到、已受理、成功、重试和失败,不能只记录最终成功。
|
||||||
|
3. Gateway收到状态报告或上行后,必须对解包/解码失败及转发NestJS失败输出结构化安全日志;NestJS入口应分别记录到达和业务处理结果,以便区分“上游未发”“Gateway未收到”“Gateway转发失败”和“API落库失败”。
|
||||||
|
4. 通讯日志不得保存短信正文、密码、密钥、Token、签名鉴权值或完整HTTP请求体;手机号只保存脱敏值。CMPP心跳不得逐包写入数据库,连接健康仍使用连接状态和聚合指标。
|
||||||
|
5. 通讯日志写入不能阻塞短信主链路,默认批量异步写入,缓冲区应有上限和溢出告警;热数据默认保留30天,保留期允许通过环境变量配置。
|
||||||
|
|||||||
@@ -3783,3 +3783,12 @@ npm run verify:phase8
|
|||||||
- `TC-REPORT-SEGMENT-005`:构造含长短信分片、平台拦截、成功、失败和无终态记录的日报,验证 `提交=全部 billingUnits`、`发送=提交-平台拦截`、`发送=未知+成功+失败`,并验证三类 CSV 导出字段一致。
|
- `TC-REPORT-SEGMENT-005`:构造含长短信分片、平台拦截、成功、失败和无终态记录的日报,验证 `提交=全部 billingUnits`、`发送=提交-平台拦截`、`发送=未知+成功+失败`,并验证三类 CSV 导出字段一致。
|
||||||
- `TC-UI-DETAIL-006`:短信详情展示发送号码,分片审计使用无需横向滚动的响应式卡片;短信记录桌面行密度提升且长内容两行截断。
|
- `TC-UI-DETAIL-006`:短信详情展示发送号码,分片审计使用无需横向滚动的响应式卡片;短信记录桌面行密度提升且长内容两行截断。
|
||||||
- `TC-UI-NAV-007`:从企业应用、企业管理、通道组等列表进入新增/编辑页后,所属二级菜单保持 `aria-current=page` 和选中样式。
|
- `TC-UI-NAV-007`:从企业应用、企业管理、通道组等列表进入新增/编辑页后,所属二级菜单保持 `aria-current=page` 和选中样式。
|
||||||
|
## 2026-07-24 CMPP/HTTP 通讯交互日志用例
|
||||||
|
|
||||||
|
- `TC-PROTOCOL-LOG-001`:向Gateway客户认证入口提交不存在的CMPP账号;真实接口返回业务4xx,通讯日志分别出现`received`和`failed`事件,账号可检索、耗时和安全错误可见,数据库无短信业务记录。
|
||||||
|
- `TC-PROTOCOL-LOG-002`:真实CMPP Submit获得供应商SubmitResp;通讯日志可按CMPP、通道到平台、SubmitResp及平台消息号筛选,展示上游消息号和结果码,不包含短信正文或通道密码。
|
||||||
|
- `TC-PROTOCOL-LOG-003`:供应商发送DELIVER状态报告;Gateway结构化日志出现收到事件,NestJS通讯日志依次出现入口收到和落库成功。构造解包失败或API拒绝时必须出现对应失败证据,不能静默返回。
|
||||||
|
- `TC-PROTOCOL-LOG-004`:通过公开HTTP API提交合法和非法请求;通讯日志展示客户到平台的受理或失败状态、请求号、脱敏手机号、业务码和耗时,鉴权头、密钥和正文不得入库。
|
||||||
|
- `TC-PROTOCOL-LOG-005`:平台向客户投递回执或上行Webhook并触发成功、网络失败和重试;通讯日志展示事件ID、HTTP状态或网络错误、耗时、尝试次数及最终状态,真实`HttpWebhookAttempt`状态一致。
|
||||||
|
- `TC-PROTOCOL-LOG-006`:连续运行CMPP心跳;`ProtocolInteractionLog`行数不随每个ACTIVE_TEST增长,连接状态中的最近心跳仍更新。超过配置保留期的数据被清理,业务表及操作审计不受影响。
|
||||||
|
- `TC-PROTOCOL-LOG-007`:运营端真实登录后打开系统日志,键盘切换“系统与操作日志/通讯交互日志”,筛选、分页、详情及固定操作列可用;桌面和平板/手机不产生页面级横向溢出,宽表允许容器内滚动,控制台无error/warn。
|
||||||
|
|||||||
@@ -2317,3 +2317,15 @@ git diff --check
|
|||||||
- 预发布在真实 API/Gateway 保持运行的条件下,使用不触碰业务 Stream、不访问供应商且最终清理的专用 BullMQ 队列连续执行 3 轮 15000 条测试:完整提交结果与回执闭环分别为 3570.33、3650.92、3587.31 TPS,平均 3602.85 TPS;入队平均 18781.28 TPS。该指标只表示 Redis/BullMQ 与 Node Worker 的内部队列能力,不包含 Prisma 业务事务、计费、路由或真实 CMPP 网络。
|
- 预发布在真实 API/Gateway 保持运行的条件下,使用不触碰业务 Stream、不访问供应商且最终清理的专用 BullMQ 队列连续执行 3 轮 15000 条测试:完整提交结果与回执闭环分别为 3570.33、3650.92、3587.31 TPS,平均 3602.85 TPS;入队平均 18781.28 TPS。该指标只表示 Redis/BullMQ 与 Node Worker 的内部队列能力,不包含 Prisma 业务事务、计费、路由或真实 CMPP 网络。
|
||||||
- 既往 284.16、376.52、438.93 TPS 的下降主要是测试环境争用而非已证实的代码回归:本机 3000 端口有另一会话从 2026-07-23 23:02 起运行的 API,压测与其争用 Redis 和 CPU;此前停止 API 后曾恢复至约 872—910 TPS。本轮未终止其他会话进程,改用独立 Redis 后完整 Phase 8 为 549.52 TPS并通过。预发布同一代码三轮约 3603 TPS且方差很小,进一步说明旧低值不能作为平台容量结论。
|
- 既往 284.16、376.52、438.93 TPS 的下降主要是测试环境争用而非已证实的代码回归:本机 3000 端口有另一会话从 2026-07-23 23:02 起运行的 API,压测与其争用 Redis 和 CPU;此前停止 API 后曾恢复至约 872—910 TPS。本轮未终止其他会话进程,改用独立 Redis 后完整 Phase 8 为 549.52 TPS并通过。预发布同一代码三轮约 3603 TPS且方差很小,进一步说明旧低值不能作为平台容量结论。
|
||||||
- 当前真实发送配置的上限不是 3603 TPS:5 条 active 通道各配置 100 TPS,Gateway 总配置上限为 500 TPS;每条通道 1 个连接、窗口 16,API Send Worker 并发 50。真实持续吞吐还受供应商授权 TPS、网络往返、回执速度、数据库和计费事务影响,因此当前预发布应按“内部队列约 3600 TPS、配置发送上限 500 TPS、真实供应商持续能力仍需协议测试环境或供应商配合压测”理解。本次未发送真实短信、未修改生产业务数据。
|
- 当前真实发送配置的上限不是 3603 TPS:5 条 active 通道各配置 100 TPS,Gateway 总配置上限为 500 TPS;每条通道 1 个连接、窗口 16,API Send Worker 并发 50。真实持续吞吐还受供应商授权 TPS、网络往返、回执速度、数据库和计费事务影响,因此当前预发布应按“内部队列约 3600 TPS、配置发送上限 500 TPS、真实供应商持续能力仍需协议测试环境或供应商配合压测”理解。本次未发送真实短信、未修改生产业务数据。
|
||||||
|
|
||||||
|
## 2026-07-24 回执缺失复核与通讯交互日志(本地未提交、未部署)
|
||||||
|
|
||||||
|
- 预发布只读复核确认当前仍部署`afd3c960709b1660c18d028499c811321983cb22`。`13127620092`今日两次提交经“赛邮行业-王斯评中转”获得上游Msg_Id后停留submitted;该通道最后一条回执为2026-07-21 17:39:58(北京时间)。`18821203795`今日09:26“富泷物业-移动”测试提交上游Msg_Id `736025035345047554`,5分32秒抓包期间只有SubmitResp和心跳,没有供应商DELIVER。
|
||||||
|
- 回执链路并非全局失效:同一“富泷物业-移动”通道在7月23 17:02收到DELIVRD,在本次`afd3c960`部署并重连后仍于7月24 08:49收到`UT:0010`失败回执;其他通道7月23、24也有回执落库。因此“7月24心跳/重连代码导致所有回执不能处理”与事实不符。Gateway的`handleDeliver`核心解析、匹配和API转发自7月8/9以来未改;最强证据仍指向特定提交或特定供应商账号未下发DELIVER,赛邮账号则需重点核查重启后的回执会话绑定。
|
||||||
|
- 新增独立`ProtocolInteractionLog`及migration `20260724113000_add_protocol_interaction_logs`,记录CMPP/HTTP业务交互的协议、方向、事件、状态、消息/请求标识、脱敏手机号、结果码、耗时和安全详情。默认500ms/100条批量异步写入、10000条缓冲上限和30天保留;不逐包记录心跳,不保存正文、密码、密钥、Token、鉴权签名或完整请求体。
|
||||||
|
- NestJS Gateway事件入口记录收到、成功和失败;公开HTTP发送记录受理/失败;HTTP Webhook记录成功、重试或失败。Go Gateway对DELIVER回执解包失败、上行解码失败、收到事件及转发API结果输出结构化日志,消除原先静默丢失盲点。
|
||||||
|
- 运营端系统日志新增“通讯交互日志”页签,支持协议、方向、事件、结果、关键字和时间筛选,分页展示并提供安全详情弹窗;手机号脱敏说明可见,详情操作列固定。
|
||||||
|
- 本地真实PostgreSQL已应用67条migration并为最新。使用新建的本地平台管理员、真实算术验证码和真实NestJS API提交无效CMPP认证,接口按预期返回400,查询API从PostgreSQL读到同一`connect`事件的`received`和`failed`两条记录,失败耗时17ms;未发送短信。
|
||||||
|
- 自动化验证已通过:API全量25 suites / 306 tests(含通讯日志脱敏/过滤和公开HTTP回归)、Prisma generate/validate、API TypeScript build、前端TypeScript/Vite build、Gateway `go test ./...`。全量Jest使用`--forceExit`结束并保留既有异步句柄提示;前端仍有既有约1.93MB单chunk/578.69KB gzip警告。
|
||||||
|
- 浏览器使用真实本地账号和验证码登录运营端,两个日志页签切换、真实数据表、筛选控件、分页和详情入口可见;桌面控制台0条error/warn,截图确认页面正常。最终构建在390×844和375×667复验均无页面级横向溢出,详情操作区分别完整位于`x=18..372`和`x=18..357`,控制台均为0条error/warn;截图保存在`outputs/protocol-interaction-logs/`。
|
||||||
|
- 当前改动保持未提交、未推送、未部署。预发布尚无新通讯日志表和页面,后续发布前必须执行备份、migration、Gateway先于API重启及发布后真实回执链路观察。
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -943,8 +944,10 @@ func (c *connection) handleDeliver(pkt deliverPacket) {
|
|||||||
if pkt.registerDelivery == 1 {
|
if pkt.registerDelivery == 1 {
|
||||||
var receipt cmpp.CmppReceiptPkt
|
var receipt cmpp.CmppReceiptPkt
|
||||||
if err := receipt.Unpack([]byte(pkt.msgContent)); err != nil {
|
if err := receipt.Unpack([]byte(pkt.msgContent)); err != nil {
|
||||||
|
log.Printf("protocol_event protocol=cmpp direction=channel_to_platform event=deliver_receipt status=parse_failed channel_id=%s sequence_id=%d packet_msg_id=%d error=%q", c.channelID, pkt.seqID, pkt.msgID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("protocol_event protocol=cmpp direction=channel_to_platform event=deliver_receipt status=received channel_id=%s sequence_id=%d gateway_message_id=%d raw_status=%s", c.channelID, pkt.seqID, receipt.MsgId, strings.TrimSpace(receipt.Stat))
|
||||||
cmd, ok := c.commandFor(receipt.MsgId)
|
cmd, ok := c.commandFor(receipt.MsgId)
|
||||||
if !ok {
|
if !ok {
|
||||||
cmd, ok = c.commandFor(pkt.msgID)
|
cmd, ok = c.commandFor(pkt.msgID)
|
||||||
@@ -973,12 +976,17 @@ func (c *connection) handleDeliver(pkt deliverPacket) {
|
|||||||
RawStatus: strings.TrimSpace(receipt.Stat),
|
RawStatus: strings.TrimSpace(receipt.Stat),
|
||||||
DeliveredAt: time.Now().UTC(),
|
DeliveredAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
_ = postJSON(context.Background(), c.httpClient, c.apiBaseURL, "/gateway/events/receipt", event)
|
if err := postJSON(context.Background(), c.httpClient, c.apiBaseURL, "/gateway/events/receipt", event); err != nil {
|
||||||
|
log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_receipt status=forward_failed channel_id=%s sequence_id=%d gateway_message_id=%d error=%q", c.channelID, pkt.seqID, receipt.MsgId, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_receipt status=forwarded channel_id=%s sequence_id=%d gateway_message_id=%d", c.channelID, pkt.seqID, receipt.MsgId)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
content, complete, err := c.decodeUplinkContent(pkt)
|
content, complete, err := c.decodeUplinkContent(pkt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("protocol_event protocol=cmpp direction=channel_to_platform event=deliver_uplink status=decode_failed channel_id=%s sequence_id=%d packet_msg_id=%d error=%q", c.channelID, pkt.seqID, pkt.msgID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !complete {
|
if !complete {
|
||||||
@@ -1000,7 +1008,11 @@ func (c *connection) handleDeliver(pkt deliverPacket) {
|
|||||||
Content: content,
|
Content: content,
|
||||||
ReceivedAt: time.Now().UTC(),
|
ReceivedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
_ = postJSON(context.Background(), c.httpClient, c.apiBaseURL, "/gateway/events/uplink", event)
|
if err := postJSON(context.Background(), c.httpClient, c.apiBaseURL, "/gateway/events/uplink", event); err != nil {
|
||||||
|
log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_uplink status=forward_failed channel_id=%s sequence_id=%d packet_msg_id=%d error=%q", c.channelID, pkt.seqID, pkt.msgID, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("protocol_event protocol=cmpp direction=gateway_to_api event=deliver_uplink status=forwarded channel_id=%s sequence_id=%d packet_msg_id=%d", c.channelID, pkt.seqID, pkt.msgID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *connection) decodeUplinkContent(pkt deliverPacket) (string, bool, error) {
|
func (c *connection) decodeUplinkContent(pkt deliverPacket) (string, bool, error) {
|
||||||
|
|||||||
@@ -959,6 +959,37 @@ export type OperationLogResponse = {
|
|||||||
modules: string[];
|
modules: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProtocolInteractionLogItem = {
|
||||||
|
id: string;
|
||||||
|
protocol: 'cmpp' | 'http';
|
||||||
|
direction: 'client_to_platform' | 'platform_to_channel' | 'channel_to_platform' | 'platform_to_client';
|
||||||
|
eventType: string;
|
||||||
|
status: 'received' | 'accepted' | 'success' | 'failed' | 'retrying';
|
||||||
|
tenantId?: string | null;
|
||||||
|
applicationId?: string | null;
|
||||||
|
channelId?: string | null;
|
||||||
|
account?: string | null;
|
||||||
|
messageId?: string | null;
|
||||||
|
gatewayMessageId?: string | null;
|
||||||
|
traceId?: string | null;
|
||||||
|
requestId?: string | null;
|
||||||
|
phoneMasked?: string | null;
|
||||||
|
resultCode?: string | null;
|
||||||
|
durationMs?: number | null;
|
||||||
|
payloadBytes?: number | null;
|
||||||
|
retryCount?: number | null;
|
||||||
|
detail?: Record<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProtocolInteractionLogResponse = {
|
||||||
|
items: ProtocolInteractionLogItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
eventTypes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type SystemLogExportResult = {
|
export type SystemLogExportResult = {
|
||||||
operationId: string;
|
operationId: string;
|
||||||
status: 'completed';
|
status: 'completed';
|
||||||
@@ -1366,6 +1397,8 @@ export const adminApi = {
|
|||||||
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
||||||
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
|
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
|
||||||
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
|
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
|
||||||
|
listProtocolInteractionLogs: (query: { protocol?: string; direction?: string; eventType?: string; status?: string; keyword?: string; range?: string; page?: number; pageSize?: number }) =>
|
||||||
|
request<ProtocolInteractionLogResponse>(withQuery('/admin/system-logs/protocol-interactions', query)),
|
||||||
exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string }) =>
|
exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string }) =>
|
||||||
request<SystemLogExportResult>('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
|
request<SystemLogExportResult>('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
|
||||||
listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'),
|
listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { CalendarDays, FileText, Search } from 'lucide-react';
|
import { CalendarDays, FileText, Search } from 'lucide-react';
|
||||||
import { Button, Input, Pagination, Select, SystemLogExport, Table, Tag, type TableColumn } from '@/components/ui';
|
import { Button, Input, Modal, Pagination, Select, SystemLogExport, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
|
||||||
import { adminApi, type OperationLogItem } from '@/api/adminApi';
|
import { adminApi, type OperationLogItem, type ProtocolInteractionLogItem } from '@/api/adminApi';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|
||||||
type LogLevel = 'info' | 'success' | 'warning' | 'error';
|
type LogLevel = 'info' | 'success' | 'warning' | 'error';
|
||||||
@@ -21,6 +21,7 @@ const levelToneMap: Record<LogLevel, 'info' | 'success' | 'warning' | 'danger'>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function AdminSystemLogsPage() {
|
export function AdminSystemLogsPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState('operations');
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [level, setLevel] = useState('all');
|
const [level, setLevel] = useState('all');
|
||||||
const [module, setModule] = useState('all');
|
const [module, setModule] = useState('all');
|
||||||
@@ -92,16 +93,8 @@ export function AdminSystemLogsPage() {
|
|||||||
{ key: 'ip', title: 'IP', width: '120px', render: (record) => <span className="muted">{record.ip}</span> },
|
{ key: 'ip', title: 'IP', width: '120px', render: (record) => <span className="muted">{record.ip}</span> },
|
||||||
], []);
|
], []);
|
||||||
|
|
||||||
return (
|
const operationContent = (
|
||||||
<section className="page-stack system-page">
|
<div className="page-stack">
|
||||||
<div className="system-page-toolbar">
|
|
||||||
<div className="sms-send-title">
|
|
||||||
<span className="sms-send-title__icon"><FileText size={22} /></span>
|
|
||||||
<h1>系统日志</h1>
|
|
||||||
</div>
|
|
||||||
<SystemLogExport exportLogs={adminApi.exportSystemLogs} filters={filters} portal="admin" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="system-log-filters">
|
<div className="system-log-filters">
|
||||||
<Input
|
<Input
|
||||||
onChange={(event) => setKeyword(event.target.value)}
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
@@ -127,24 +120,7 @@ export function AdminSystemLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="system-log-range">
|
<LogRange range={range} onChange={setRange} />
|
||||||
<span><CalendarDays size={18} /> 时间范围:</span>
|
|
||||||
{[
|
|
||||||
{ label: '今天', value: 'today' },
|
|
||||||
{ label: '近7天', value: '7d' },
|
|
||||||
{ label: '近30天', value: '30d' },
|
|
||||||
{ label: '全部', value: 'all' },
|
|
||||||
].map((item) => (
|
|
||||||
<Button
|
|
||||||
key={item.value}
|
|
||||||
onClick={() => setRange(item.value)}
|
|
||||||
size="sm"
|
|
||||||
variant={range === item.value ? 'primary' : 'secondary'}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="surface system-table-card">
|
<div className="surface system-table-card">
|
||||||
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} pagination={false} rowKey="id" />
|
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} pagination={false} rowKey="id" />
|
||||||
@@ -159,6 +135,131 @@ export function AdminSystemLogsPage() {
|
|||||||
total={total}
|
total={total}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page-stack system-page">
|
||||||
|
<div className="system-page-toolbar">
|
||||||
|
<div className="sms-send-title">
|
||||||
|
<span className="sms-send-title__icon"><FileText size={22} /></span>
|
||||||
|
<h1>系统日志</h1>
|
||||||
|
</div>
|
||||||
|
{activeTab === 'operations' ? <SystemLogExport exportLogs={adminApi.exportSystemLogs} filters={filters} portal="admin" /> : null}
|
||||||
|
</div>
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{ label: '系统与操作日志', value: 'operations', content: operationContent },
|
||||||
|
{ label: '通讯交互日志', value: 'protocol', content: <ProtocolInteractionPanel active={activeTab === 'protocol'} /> },
|
||||||
|
]}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
value={activeTab}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const directionLabels: Record<ProtocolInteractionLogItem['direction'], string> = {
|
||||||
|
client_to_platform: '客户 → 平台',
|
||||||
|
platform_to_channel: '平台 → 通道',
|
||||||
|
channel_to_platform: '通道 → 平台',
|
||||||
|
platform_to_client: '平台 → 客户',
|
||||||
|
};
|
||||||
|
|
||||||
|
const protocolStatusTone: Record<ProtocolInteractionLogItem['status'], 'info' | 'success' | 'warning' | 'danger'> = {
|
||||||
|
received: 'info',
|
||||||
|
accepted: 'success',
|
||||||
|
success: 'success',
|
||||||
|
retrying: 'warning',
|
||||||
|
failed: 'danger',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ProtocolInteractionPanel({ active }: { active: boolean }) {
|
||||||
|
const [inputs, setInputs] = useState({ keyword: '', protocol: 'all', direction: 'all', eventType: 'all', status: 'all', range: 'today' });
|
||||||
|
const [filters, setFilters] = useState(inputs);
|
||||||
|
const [items, setItems] = useState<ProtocolInteractionLogItem[]>([]);
|
||||||
|
const [eventTypes, setEventTypes] = useState<string[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [detail, setDetail] = useState<ProtocolInteractionLogItem | null>(null);
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
let cancelled = false;
|
||||||
|
adminApi.listProtocolInteractionLogs({ ...filters, page, pageSize })
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setItems(data.items);
|
||||||
|
setEventTypes(data.eventTypes);
|
||||||
|
setTotal(data.total);
|
||||||
|
setError('');
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setItems([]);
|
||||||
|
setTotal(0);
|
||||||
|
setError(reason instanceof Error ? reason.message : '通讯交互日志加载失败');
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [active, filters, page]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const columns = useMemo<Array<TableColumn<ProtocolInteractionLogItem>>>(() => [
|
||||||
|
{ key: 'createdAt', title: '时间', width: '170px', render: (record) => <span className="muted">{formatDateTime(record.createdAt)}</span> },
|
||||||
|
{ key: 'protocol', title: '协议', width: '90px', render: (record) => <Tag tone={record.protocol === 'cmpp' ? 'info' : 'success'}>{record.protocol.toUpperCase()}</Tag> },
|
||||||
|
{ key: 'direction', title: '方向', width: '150px', render: (record) => directionLabels[record.direction] },
|
||||||
|
{ key: 'eventType', title: '事件', width: '150px', render: (record) => <strong>{record.eventType}</strong> },
|
||||||
|
{ key: 'messageId', title: '消息标识', width: '220px', render: (record) => <div className="protocol-log-identifiers"><span>{record.messageId || '-'}</span><small>{record.gatewayMessageId || record.requestId || ''}</small></div> },
|
||||||
|
{ key: 'target', title: '对象', width: '160px', render: (record) => <div className="protocol-log-identifiers"><span>{record.phoneMasked || record.account || '-'}</span><small>{record.channelId || record.applicationId || ''}</small></div> },
|
||||||
|
{ key: 'status', title: '结果', width: '130px', render: (record) => <div className="protocol-log-result"><Tag tone={protocolStatusTone[record.status]}>{record.status}</Tag><small>{record.resultCode || ''}</small></div> },
|
||||||
|
{ key: 'durationMs', title: '耗时', width: '90px', render: (record) => record.durationMs == null ? '-' : `${record.durationMs} ms` },
|
||||||
|
{ key: 'detail', title: '详情', width: '90px', render: (record) => <Button onClick={() => setDetail(record)} size="sm" variant="ghost">查看</Button> },
|
||||||
|
], []);
|
||||||
|
|
||||||
|
function query() {
|
||||||
|
setPage(1);
|
||||||
|
setFilters({ ...inputs, keyword: inputs.keyword.trim() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
const next = { keyword: '', protocol: 'all', direction: 'all', eventType: 'all', status: 'all', range: 'today' };
|
||||||
|
setInputs(next);
|
||||||
|
setFilters(next);
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack protocol-log-panel">
|
||||||
|
<div className="protocol-log-hint">仅记录业务交互和异常,不逐包记录 CMPP 心跳;手机号已脱敏,短信内容、密钥和鉴权头不会入库。</div>
|
||||||
|
<div className="system-log-filters protocol-log-filters">
|
||||||
|
<Input onChange={(event) => setInputs((value) => ({ ...value, keyword: event.target.value }))} placeholder="消息ID、请求ID、账号、脱敏手机号或结果码" prefix={<Search size={16} />} value={inputs.keyword} />
|
||||||
|
<Select onChange={(event) => setInputs((value) => ({ ...value, protocol: event.target.value }))} options={[{ label: '全部协议', value: 'all' }, { label: 'CMPP', value: 'cmpp' }, { label: 'HTTP', value: 'http' }]} value={inputs.protocol} />
|
||||||
|
<Select onChange={(event) => setInputs((value) => ({ ...value, direction: event.target.value }))} options={[{ label: '全部方向', value: 'all' }, ...Object.entries(directionLabels).map(([value, label]) => ({ value, label }))]} value={inputs.direction} />
|
||||||
|
<Select onChange={(event) => setInputs((value) => ({ ...value, eventType: event.target.value }))} options={[{ label: '全部事件', value: 'all' }, ...eventTypes.map((value) => ({ label: value, value }))]} value={inputs.eventType} />
|
||||||
|
<Select onChange={(event) => setInputs((value) => ({ ...value, status: event.target.value }))} options={[{ label: '全部结果', value: 'all' }, { label: '已接收', value: 'received' }, { label: '已受理', value: 'accepted' }, { label: '成功', value: 'success' }, { label: '重试中', value: 'retrying' }, { label: '失败', value: 'failed' }]} value={inputs.status} />
|
||||||
|
<div className="system-log-filters__actions"><Button icon={<Search size={16} />} onClick={query}>查询</Button><Button onClick={reset} variant="ghost">重置</Button></div>
|
||||||
|
</div>
|
||||||
|
<LogRange range={inputs.range} onChange={(range) => setInputs((value) => ({ ...value, range }))} />
|
||||||
|
<div className="surface system-table-card protocol-log-table">
|
||||||
|
<Table columns={columns} data={items} emptyText={error || '暂无通讯交互日志'} pagination={false} rowKey="id" />
|
||||||
|
<Pagination nextDisabled={page >= totalPages} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} onPrevious={() => setPage((value) => Math.max(1, value - 1))} page={Math.min(page, totalPages)} totalPages={totalPages} onPageChange={setPage} previousDisabled={page <= 1} total={total} />
|
||||||
|
</div>
|
||||||
|
<Modal footer={<Button onClick={() => setDetail(null)}>关闭</Button>} onClose={() => setDetail(null)} open={Boolean(detail)} title="通讯交互详情">
|
||||||
|
{detail ? <dl className="protocol-log-detail">{Object.entries(detail).filter(([, value]) => value !== null && value !== undefined && value !== '').map(([key, value]) => <div key={key}><dt>{key}</dt><dd>{typeof value === 'object' ? JSON.stringify(value) : String(value)}</dd></div>)}</dl> : null}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogRange({ range, onChange }: { range: string; onChange: (value: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="system-log-range">
|
||||||
|
<span><CalendarDays size={18} /> 时间范围:</span>
|
||||||
|
{[{ label: '今天', value: 'today' }, { label: '近7天', value: '7d' }, { label: '近30天', value: '30d' }, { label: '全部', value: 'all' }].map((item) => (
|
||||||
|
<Button key={item.value} onClick={() => onChange(item.value)} size="sm" variant={range === item.value ? 'primary' : 'secondary'}>{item.label}</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4783,6 +4783,82 @@ h3 {
|
|||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.protocol-log-hint {
|
||||||
|
background: var(--color-info-soft);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-info) 24%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-log-filters {
|
||||||
|
grid-template-columns: minmax(260px, 1.4fr) repeat(4, minmax(135px, .55fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-log-table .ui-table {
|
||||||
|
min-width: 1320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-log-table .ui-table th:last-child,
|
||||||
|
.protocol-log-table .ui-table td:last-child {
|
||||||
|
background: var(--color-surface);
|
||||||
|
box-shadow: -10px 0 18px -18px rgba(15, 23, 42, .6);
|
||||||
|
position: sticky;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-log-identifiers,
|
||||||
|
.protocol-log-result {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-log-identifiers small,
|
||||||
|
.protocol-log-result small {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-log-detail {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-log-detail > div {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
grid-template-columns: minmax(130px, .35fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-log-detail dt {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-log-detail dd {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.protocol-log-filters {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.protocol-log-filters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-log-detail > div {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.enterprise-page {
|
.enterprise-page {
|
||||||
gap: 28px;
|
gap: 28px;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -2,6 +2,8 @@ import { defineConfig } from 'vite';
|
|||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
const apiProxyTarget = process.env.API_PROXY_TARGET ?? 'http://localhost:3000';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
cacheDir: 'node_modules/.vite-cmpp',
|
cacheDir: 'node_modules/.vite-cmpp',
|
||||||
@@ -15,7 +17,7 @@ export default defineConfig({
|
|||||||
strictPort: false,
|
strictPort: false,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3000',
|
target: apiProxyTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -27,7 +29,7 @@ export default defineConfig({
|
|||||||
preview: {
|
preview: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3000',
|
target: apiProxyTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user