feat: add protocol interaction observability

This commit is contained in:
hectorzhao
2026-07-24 10:47:47 +08:00
parent 4f4fa5fcb7
commit 0bfeb0839e
17 changed files with 740 additions and 42 deletions
@@ -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");
+33
View File
@@ -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?
+2
View File
@@ -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,
+50 -2
View File
@@ -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;
@@ -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(
@@ -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,
} 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<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`)计入提交但不计入发送,并保证 `发送=未知+成功+失败`
- 通道维度只存在已经路由到通道的记录,因此该维度的提交数等于发送数;应用、签名、引流信息和对账维度的提交数包含平台拦截。
- 二级添加、编辑页面必须继续高亮其所属侧边菜单。
## 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天,保留期允许通过环境变量配置。
+9
View File
@@ -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。
+12
View File
@@ -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 TPSGateway 总配置上限为 500 TPS;每条通道 1 个连接、窗口 16API 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重启及发布后真实回执链路观察。
+14 -2
View File
@@ -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) {
+33
View File
@@ -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<string, unknown> | 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<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
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 }) =>
request<SystemLogExportResult>('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'),
+131 -30
View File
@@ -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<LogLevel, 'info' | 'success' | 'warning' | 'danger'>
};
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) => <span className="muted">{record.ip}</span> },
], []);
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>
<SystemLogExport exportLogs={adminApi.exportSystemLogs} filters={filters} portal="admin" />
</div>
const operationContent = (
<div className="page-stack">
<div className="system-log-filters">
<Input
onChange={(event) => setKeyword(event.target.value)}
@@ -127,24 +120,7 @@ export function AdminSystemLogsPage() {
</div>
</div>
<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={() => setRange(item.value)}
size="sm"
variant={range === item.value ? 'primary' : 'secondary'}
>
{item.label}
</Button>
))}
</div>
<LogRange range={range} onChange={setRange} />
<div className="surface system-table-card">
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} pagination={false} rowKey="id" />
@@ -159,6 +135,131 @@ export function AdminSystemLogsPage() {
total={total}
/>
</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>
);
}
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>
);
}
+76
View File
@@ -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;
}
+4 -2
View File
@@ -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,
},
},