From 0bfeb0839ee6b67611e13796f46c19fc05b88de9 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Fri, 24 Jul 2026 10:47:47 +0800 Subject: [PATCH] feat: add protocol interaction observability --- .../migration.sql | 34 ++++ api/prisma/schema.prisma | 33 +++ api/src/app.module.ts | 2 + api/src/open-api/open-api.service.ts | 52 ++++- .../operations/admin-operations.controller.ts | 20 +- api/src/protocol-logs/protocol-logs.module.ts | 9 + .../protocol-logs.service.spec.ts | 68 +++++++ .../protocol-logs/protocol-logs.service.ts | 189 ++++++++++++++++++ .../send-chain/gateway-events.controller.ts | 54 ++++- .../first-version-development-requirements.md | 8 + docs/system-functional-test-cases.md | 9 + docs/testing-progress.md | 12 ++ gateway/internal/upstream/manager.go | 16 +- src/api/adminApi.ts | 33 +++ src/apps/admin/AdminSystemLogsPage.tsx | 161 ++++++++++++--- src/styles/global.css | 76 +++++++ vite.config.ts | 6 +- 17 files changed, 740 insertions(+), 42 deletions(-) create mode 100644 api/prisma/migrations/20260724113000_add_protocol_interaction_logs/migration.sql create mode 100644 api/src/protocol-logs/protocol-logs.module.ts create mode 100644 api/src/protocol-logs/protocol-logs.service.spec.ts create mode 100644 api/src/protocol-logs/protocol-logs.service.ts diff --git a/api/prisma/migrations/20260724113000_add_protocol_interaction_logs/migration.sql b/api/prisma/migrations/20260724113000_add_protocol_interaction_logs/migration.sql new file mode 100644 index 0000000..f644845 --- /dev/null +++ b/api/prisma/migrations/20260724113000_add_protocol_interaction_logs/migration.sql @@ -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"); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index fbec9b0..aade56b 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -182,6 +182,39 @@ model OperationLogArchive { @@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 { id String @id @default(cuid()) tenantId String? diff --git a/api/src/app.module.ts b/api/src/app.module.ts index ea41adb..4d65d58 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -15,6 +15,7 @@ import { HealthController } from './health.controller'; import { OperationsModule } from './operations/operations.module'; import { OpenApiModule } from './open-api/open-api.module'; import { PrismaModule } from './prisma/prisma.module'; +import { ProtocolLogsModule } from './protocol-logs/protocol-logs.module'; import { RiskReviewModule } from './risk-review/risk-review.module'; import { ReportsModule } from './reports/reports.module'; import { ReportMaterialsModule } from './report-materials/report-materials.module'; @@ -30,6 +31,7 @@ import { UsersModule } from './users/users.module'; envFilePath: ['.env.local', '.env'], }), PrismaModule, + ProtocolLogsModule, AuthModule, TenantsModule, UsersModule, diff --git a/api/src/open-api/open-api.service.ts b/api/src/open-api/open-api.service.ts index c985c56..79ac516 100644 --- a/api/src/open-api/open-api.service.ts +++ b/api/src/open-api/open-api.service.ts @@ -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 { Queue, Worker } from 'bullmq'; 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 { decryptSecret, encryptSecret } from './open-api.crypto'; import type { OpenApiAuthContext } from './open-api.types'; +import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service'; const WEBHOOK_QUEUE = 'http-webhook-delivery'; 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 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() { 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() }; 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; } catch (error) { let outwardError = error; @@ -213,6 +233,19 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy { } 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() } }); + 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; } } @@ -326,6 +359,21 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy { const success = responseStatus !== undefined && responseStatus >= 200 && responseStatus < 300; 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=***' } } }); + 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) { await this.prisma.httpWebhookDelivery.update({ where: { id: deliveryId }, data: { status: 'delivered', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: null, deliveredAt: new Date(), nextRetryAt: null } }); return; diff --git a/api/src/operations/admin-operations.controller.ts b/api/src/operations/admin-operations.controller.ts index 81bef58..18cf3e1 100644 --- a/api/src/operations/admin-operations.controller.ts +++ b/api/src/operations/admin-operations.controller.ts @@ -4,6 +4,7 @@ import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; import { SendChainService } from '../send-chain/send-chain.service'; import { OperationsService } from './operations.service'; +import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service'; type DownloadResponse = { setHeader(name: string, value: number | string): void; @@ -259,7 +260,24 @@ export class AdminOperationsController { @ApiTags('admin-system-logs') @Controller('admin/system-logs') 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() list( diff --git a/api/src/protocol-logs/protocol-logs.module.ts b/api/src/protocol-logs/protocol-logs.module.ts new file mode 100644 index 0000000..0dff665 --- /dev/null +++ b/api/src/protocol-logs/protocol-logs.module.ts @@ -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 {} diff --git a/api/src/protocol-logs/protocol-logs.service.spec.ts b/api/src/protocol-logs/protocol-logs.service.spec.ts new file mode 100644 index 0000000..2ed407d --- /dev/null +++ b/api/src/protocol-logs/protocol-logs.service.spec.ts @@ -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' } }]), + }), + })); + }); +}); diff --git a/api/src/protocol-logs/protocol-logs.service.ts b/api/src/protocol-logs/protocol-logs.service.ts new file mode 100644 index 0000000..64d82e7 --- /dev/null +++ b/api/src/protocol-logs/protocol-logs.service.ts @@ -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 | 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; + private retentionTimer?: ReturnType; + 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 | 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; +} diff --git a/api/src/send-chain/gateway-events.controller.ts b/api/src/send-chain/gateway-events.controller.ts index baedbf6..deb8f52 100644 --- a/api/src/send-chain/gateway-events.controller.ts +++ b/api/src/send-chain/gateway-events.controller.ts @@ -15,6 +15,7 @@ import { SendChainService, } from './send-chain.service'; import { GatewayDownstreamConnectionEventDto, SmsConfigService } from '../sms-config/sms-config.service'; +import { ProtocolLogsService, type ProtocolLogInput } from '../protocol-logs/protocol-logs.service'; @ApiTags('gateway-events') @Controller('gateway/events') @@ -22,21 +23,22 @@ export class GatewayEventsController { constructor( private readonly sendChain: SendChainService, private readonly smsConfig: SmsConfigService, + private readonly protocolLogs: ProtocolLogsService, ) {} @Post('submit-result') submitResult(@Body() body: GatewaySubmitResultDto) { - return this.sendChain.handleSubmitResult(body); + return this.trackGatewayEvent('submit_resp', body, () => this.sendChain.handleSubmitResult(body)); } @Post('receipt') receipt(@Body() body: GatewayReceiptEventDto) { - return this.sendChain.handleReceipt(body); + return this.trackGatewayEvent('deliver_receipt', body, () => this.sendChain.handleReceipt(body)); } @Post('uplink') uplink(@Body() body: GatewayUplinkEventDto) { - return this.sendChain.handleUplink(body); + return this.trackGatewayEvent('deliver_uplink', body, () => this.sendChain.handleUplink(body)); } @Post('dead-letter') @@ -46,12 +48,12 @@ export class GatewayEventsController { @Post('inbound/authenticate') authenticateInbound(@Body() body: GatewayInboundAuthDto) { - return this.sendChain.authenticateInboundApplication(body); + return this.trackGatewayEvent('connect', body, () => this.sendChain.authenticateInboundApplication(body), 'client_to_platform'); } @Post('inbound/submit') submitInbound(@Body() body: GatewayInboundSubmitDto) { - return this.sendChain.submitInboundMessage(body); + return this.trackGatewayEvent('submit', body, () => this.sendChain.submitInboundMessage(body), 'client_to_platform'); } @Post('inbound/connection') @@ -88,4 +90,46 @@ export class GatewayEventsController { downstreamRecoveryStatus(@Body() body: GatewayDownstreamRecoveryStatusDto) { return this.sendChain.recordGatewayDownstreamRecoveryStatus(body); } + + private async trackGatewayEvent( + eventType: string, + body: object, + action: () => Promise | T, + direction: ProtocolLogInput['direction'] = 'channel_to_platform', + ) { + const startedAt = Date.now(); + const value = body as Record; + const common: Omit = { + 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; + } + } } diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 6de82a3..a814ad2 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -1662,3 +1662,11 @@ - 所有日报表按 `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天,保留期允许通过环境变量配置。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 95614d1..1512ac7 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -3783,3 +3783,12 @@ npm run verify:phase8 - `TC-REPORT-SEGMENT-005`:构造含长短信分片、平台拦截、成功、失败和无终态记录的日报,验证 `提交=全部 billingUnits`、`发送=提交-平台拦截`、`发送=未知+成功+失败`,并验证三类 CSV 导出字段一致。 - `TC-UI-DETAIL-006`:短信详情展示发送号码,分片审计使用无需横向滚动的响应式卡片;短信记录桌面行密度提升且长内容两行截断。 - `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。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 70b4d1e..1cd27b3 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -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 网络。 - 既往 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、真实供应商持续能力仍需协议测试环境或供应商配合压测”理解。本次未发送真实短信、未修改生产业务数据。 + +## 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重启及发布后真实回执链路观察。 diff --git a/gateway/internal/upstream/manager.go b/gateway/internal/upstream/manager.go index c2110a1..15a78f6 100644 --- a/gateway/internal/upstream/manager.go +++ b/gateway/internal/upstream/manager.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "net" "net/http" "strings" @@ -943,8 +944,10 @@ func (c *connection) handleDeliver(pkt deliverPacket) { if pkt.registerDelivery == 1 { var receipt cmpp.CmppReceiptPkt 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 } + 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) if !ok { cmd, ok = c.commandFor(pkt.msgID) @@ -973,12 +976,17 @@ func (c *connection) handleDeliver(pkt deliverPacket) { RawStatus: strings.TrimSpace(receipt.Stat), 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 } content, complete, err := c.decodeUplinkContent(pkt) 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 } if !complete { @@ -1000,7 +1008,11 @@ func (c *connection) handleDeliver(pkt deliverPacket) { Content: content, 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) { diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 0119dac..b935caa 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -959,6 +959,37 @@ export type OperationLogResponse = { 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 | null; + createdAt: string; +}; + +export type ProtocolInteractionLogResponse = { + items: ProtocolInteractionLogItem[]; + total: number; + page: number; + pageSize: number; + eventTypes: string[]; +}; + export type SystemLogExportResult = { operationId: string; status: 'completed'; @@ -1366,6 +1397,8 @@ export const adminApi = { getDashboard: (tenantId?: string) => request(withQuery('/admin/operations/dashboard/statistics', { tenantId })), listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) => request(withQuery('/admin/system-logs', query)), + listProtocolInteractionLogs: (query: { protocol?: string; direction?: string; eventType?: string; status?: string; keyword?: string; range?: string; page?: number; pageSize?: number }) => + request(withQuery('/admin/system-logs/protocol-interactions', query)), exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string }) => request('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }), listAccounts: () => request('/admin/billing/accounts'), diff --git a/src/apps/admin/AdminSystemLogsPage.tsx b/src/apps/admin/AdminSystemLogsPage.tsx index 720d856..b0de47a 100644 --- a/src/apps/admin/AdminSystemLogsPage.tsx +++ b/src/apps/admin/AdminSystemLogsPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { CalendarDays, FileText, Search } from 'lucide-react'; -import { Button, Input, Pagination, Select, SystemLogExport, Table, Tag, type TableColumn } from '@/components/ui'; -import { adminApi, type OperationLogItem } from '@/api/adminApi'; +import { Button, Input, Modal, Pagination, Select, SystemLogExport, Table, Tabs, Tag, type TableColumn } from '@/components/ui'; +import { adminApi, type OperationLogItem, type ProtocolInteractionLogItem } from '@/api/adminApi'; import { formatDateTime } from '@/utils/dateTime'; type LogLevel = 'info' | 'success' | 'warning' | 'error'; @@ -21,6 +21,7 @@ const levelToneMap: Record }; export function AdminSystemLogsPage() { + const [activeTab, setActiveTab] = useState('operations'); const [keyword, setKeyword] = useState(''); const [level, setLevel] = useState('all'); const [module, setModule] = useState('all'); @@ -92,16 +93,8 @@ export function AdminSystemLogsPage() { { key: 'ip', title: 'IP', width: '120px', render: (record) => {record.ip} }, ], []); - return ( -
-
-
- -

系统日志

-
- -
- + const operationContent = ( +
setKeyword(event.target.value)} @@ -127,24 +120,7 @@ export function AdminSystemLogsPage() {
-
- 时间范围: - {[ - { label: '今天', value: 'today' }, - { label: '近7天', value: '7d' }, - { label: '近30天', value: '30d' }, - { label: '全部', value: 'all' }, - ].map((item) => ( - - ))} -
+
@@ -159,6 +135,131 @@ export function AdminSystemLogsPage() { total={total} /> + + ); + + return ( +
+
+
+ +

系统日志

+
+ {activeTab === 'operations' ? : null} +
+ }, + ]} + onChange={setActiveTab} + value={activeTab} + />
); } + +const directionLabels: Record = { + client_to_platform: '客户 → 平台', + platform_to_channel: '平台 → 通道', + channel_to_platform: '通道 → 平台', + platform_to_client: '平台 → 客户', +}; + +const protocolStatusTone: Record = { + 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([]); + const [eventTypes, setEventTypes] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [error, setError] = useState(''); + const [detail, setDetail] = useState(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>>(() => [ + { key: 'createdAt', title: '时间', width: '170px', render: (record) => {formatDateTime(record.createdAt)} }, + { key: 'protocol', title: '协议', width: '90px', render: (record) => {record.protocol.toUpperCase()} }, + { key: 'direction', title: '方向', width: '150px', render: (record) => directionLabels[record.direction] }, + { key: 'eventType', title: '事件', width: '150px', render: (record) => {record.eventType} }, + { key: 'messageId', title: '消息标识', width: '220px', render: (record) =>
{record.messageId || '-'}{record.gatewayMessageId || record.requestId || ''}
}, + { key: 'target', title: '对象', width: '160px', render: (record) =>
{record.phoneMasked || record.account || '-'}{record.channelId || record.applicationId || ''}
}, + { key: 'status', title: '结果', width: '130px', render: (record) =>
{record.status}{record.resultCode || ''}
}, + { key: 'durationMs', title: '耗时', width: '90px', render: (record) => record.durationMs == null ? '-' : `${record.durationMs} ms` }, + { key: 'detail', title: '详情', width: '90px', render: (record) => }, + ], []); + + 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 ( +
+
仅记录业务交互和异常,不逐包记录 CMPP 心跳;手机号已脱敏,短信内容、密钥和鉴权头不会入库。
+
+ setInputs((value) => ({ ...value, keyword: event.target.value }))} placeholder="消息ID、请求ID、账号、脱敏手机号或结果码" prefix={} value={inputs.keyword} /> + setInputs((value) => ({ ...value, direction: event.target.value }))} options={[{ label: '全部方向', value: 'all' }, ...Object.entries(directionLabels).map(([value, label]) => ({ value, label }))]} value={inputs.direction} /> + 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} /> +
+
+ setInputs((value) => ({ ...value, range }))} /> +
+
+ = 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} /> + + setDetail(null)}>关闭} onClose={() => setDetail(null)} open={Boolean(detail)} title="通讯交互详情"> + {detail ?
{Object.entries(detail).filter(([, value]) => value !== null && value !== undefined && value !== '').map(([key, value]) =>
{key}
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
)}
: null} +
+ + ); +} + +function LogRange({ range, onChange }: { range: string; onChange: (value: string) => void }) { + return ( +
+ 时间范围: + {[{ label: '今天', value: 'today' }, { label: '近7天', value: '7d' }, { label: '近30天', value: '30d' }, { label: '全部', value: 'all' }].map((item) => ( + + ))} +
+ ); +} diff --git a/src/styles/global.css b/src/styles/global.css index 49fe72a..9b4c14d 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -4783,6 +4783,82 @@ h3 { 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 { gap: 28px; } diff --git a/vite.config.ts b/vite.config.ts index 54b3608..6cb3cc8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; +const apiProxyTarget = process.env.API_PROXY_TARGET ?? 'http://localhost:3000'; + export default defineConfig({ plugins: [react()], cacheDir: 'node_modules/.vite-cmpp', @@ -15,7 +17,7 @@ export default defineConfig({ strictPort: false, proxy: { '/api': { - target: 'http://localhost:3000', + target: apiProxyTarget, changeOrigin: true, }, }, @@ -27,7 +29,7 @@ export default defineConfig({ preview: { proxy: { '/api': { - target: 'http://localhost:3000', + target: apiProxyTarget, changeOrigin: true, }, },