feat: M0-04 Audit — async BullMQ writes + riskLevel + reason + SecurityEvent
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 39s

This commit is contained in:
WangDL 2026-05-22 23:03:32 +08:00
parent a1ac07bf88
commit b5a983dc6b
7 changed files with 89 additions and 16 deletions

View File

@ -0,0 +1,11 @@
ALTER TABLE AdminAuditLog ADD COLUMN riskLevel VARCHAR(16) NULL;
ALTER TABLE AdminAuditLog ADD COLUMN reason VARCHAR(500) NULL;
CREATE TABLE SecurityEvent (
id VARCHAR(191) NOT NULL, userId VARCHAR(191), adminUserId VARCHAR(191),
eventType VARCHAR(64) NOT NULL, severity VARCHAR(16) NOT NULL DEFAULT 'low',
ip VARCHAR(45), userAgent VARCHAR(500), detail JSON,
handled BOOLEAN NOT NULL DEFAULT false, handledBy VARCHAR(100),
createdAt DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX SecurityEvent_userId_idx(userId), INDEX SecurityEvent_eventType_idx(eventType),
INDEX SecurityEvent_createdAt_idx(createdAt), PRIMARY KEY (id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -766,6 +766,8 @@ model AdminAuditLog {
afterJson Json?
ip String? @db.VarChar(45)
userAgent String? @db.VarChar(500)
riskLevel String? @db.VarChar(16)
reason String? @db.VarChar(500)
createdAt DateTime @default(now())
adminUser AdminUser @relation(fields: [adminUserId], references: [id])
@ -881,3 +883,21 @@ model ConfigChangeLog {
@@index([entityType, entityId])
@@index([createdAt])
}
model SecurityEvent {
id String @id @default(cuid())
userId String?
adminUserId String?
eventType String @db.VarChar(64)
severity String @default("low") @db.VarChar(16)
ip String? @db.VarChar(45)
userAgent String? @db.VarChar(500)
detail Json?
handled Boolean @default(false)
handledBy String? @db.VarChar(100)
createdAt DateTime @default(now())
@@index([userId])
@@index([eventType])
@@index([createdAt])
}

View File

@ -1,7 +1,7 @@
import { Global, Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { ConfigService } from '@nestjs/config';
import { QueueService, QUEUE_AI_ANALYSIS, QUEUE_DOCUMENT_IMPORT, QUEUE_NOTIFICATION } from './queue.service';
import { QueueService, QUEUE_AI_ANALYSIS, QUEUE_AUDIT_LOG, QUEUE_DOCUMENT_IMPORT, QUEUE_NOTIFICATION } from './queue.service';
@Global()
@Module({
@ -27,6 +27,7 @@ import { QueueService, QUEUE_AI_ANALYSIS, QUEUE_DOCUMENT_IMPORT, QUEUE_NOTIFICAT
{ name: QUEUE_AI_ANALYSIS },
{ name: QUEUE_DOCUMENT_IMPORT },
{ name: QUEUE_NOTIFICATION },
{ name: QUEUE_AUDIT_LOG },
),
],
providers: [QueueService],

View File

@ -5,6 +5,7 @@ import { Queue } from 'bullmq';
export const QUEUE_AI_ANALYSIS = 'ai-analysis';
export const QUEUE_DOCUMENT_IMPORT = 'document-import';
export const QUEUE_NOTIFICATION = 'notification';
export const QUEUE_AUDIT_LOG = 'audit-logs';
@Injectable()
export class QueueService {

View File

@ -1,11 +1,15 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { AuditLogProcessor } from './audit-log.processor';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { QUEUE_AUDIT_LOG } from '../../infrastructure/queue/queue.service';
import { AdminAuditLogController } from './admin-audit-log.controller';
import { AdminAuditLogService } from './admin-audit-log.service';
import { AdminAuthModule } from '../admin-auth/admin-auth.module';
@Module({
imports: [AdminAuthModule],
imports: [AdminAuthModule, BullModule.registerQueue({ name: QUEUE_AUDIT_LOG })],
controllers: [AdminAuditLogController],
providers: [AdminAuditLogService],
providers: [AuditLogProcessor, PrismaService,AdminAuditLogService],
})
export class AdminAuditLogModule {}

View File

@ -0,0 +1,26 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { QUEUE_AUDIT_LOG } from '../../infrastructure/queue/queue.service';
@Processor(QUEUE_AUDIT_LOG)
export class AuditLogProcessor extends WorkerHost {
constructor(private readonly prisma: PrismaService) { super(); }
async process(job: Job) {
await this.prisma.adminAuditLog.create({
data: {
adminUserId: job.data.adminUserId,
action: job.data.action,
resourceType: job.data.resourceType,
resourceId: job.data.resourceId,
beforeJson: job.data.beforeJson,
afterJson: job.data.afterJson,
ip: job.data.ip,
userAgent: job.data.userAgent,
riskLevel: job.data.riskLevel,
reason: job.data.reason,
},
});
}
}

View File

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { QueueService } from '../../infrastructure/queue/queue.service';
export interface AuditLogInput {
adminUserId: string;
@ -10,24 +10,34 @@ export interface AuditLogInput {
afterJson?: any;
ip?: string;
userAgent?: string;
riskLevel?: 'low' | 'medium' | 'high' | 'critical';
reason?: string;
}
@Injectable()
export class AdminAuditService {
constructor(private readonly prisma: PrismaService) {}
constructor(private readonly queue: QueueService) {}
/** Async audit log via BullMQ — never blocks the main operation */
async log(input: AuditLogInput) {
return this.prisma.adminAuditLog.create({
data: {
adminUserId: input.adminUserId,
action: input.action,
resourceType: input.resourceType ?? null,
resourceId: input.resourceId ?? null,
beforeJson: input.beforeJson ?? null,
afterJson: input.afterJson ?? null,
ip: input.ip ?? null,
userAgent: input.userAgent ?? null,
},
await this.queue.add('audit-logs', {
adminUserId: input.adminUserId,
action: input.action,
resourceType: input.resourceType ?? null,
resourceId: input.resourceId ?? null,
beforeJson: input.beforeJson ?? null,
afterJson: input.afterJson ?? null,
ip: input.ip ?? null,
userAgent: input.userAgent ?? null,
riskLevel: input.riskLevel ?? this.defaultRisk(input.action),
reason: input.reason ?? null,
});
}
private defaultRisk(action: string): string {
if (/delete|remove|purge|revoke|reset|transfer/i.test(action)) return 'critical';
if (/update|edit|disable|block|freeze/i.test(action)) return 'high';
if (/create|add|import|upload/i.test(action)) return 'medium';
return 'low';
}
}