feat: M0-06 Content Safety — sensitive word check + admin AAPI
Some checks failed
Deploy API Server / build-and-deploy (push) Has been cancelled

This commit is contained in:
WangDL 2026-05-22 23:12:39 +08:00
parent 4d977d2a85
commit 9e8f3dccd7
11 changed files with 220 additions and 12 deletions

View File

@ -0,0 +1,28 @@
CREATE TABLE SensitiveWord (
id VARCHAR(191) NOT NULL, word VARCHAR(100) NOT NULL,
category VARCHAR(32) NOT NULL DEFAULT 'general', riskLevel VARCHAR(16) NOT NULL DEFAULT 'medium',
enabled BOOLEAN NOT NULL DEFAULT true, createdAt DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updatedAt DATETIME(3) NOT NULL,
UNIQUE INDEX SensitiveWord_word_key(word), INDEX SensitiveWord_category_idx(category),
PRIMARY KEY (id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE ContentSafetyCheck (
id VARCHAR(191) NOT NULL, userId VARCHAR(100), contentType VARCHAR(32) NOT NULL,
content TEXT NOT NULL, riskLevel VARCHAR(16) NOT NULL, matchedWords TEXT,
result VARCHAR(16) NOT NULL DEFAULT 'pending', reviewerId VARCHAR(100),
reviewNote VARCHAR(500), createdAt DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
reviewedAt DATETIME(3),
INDEX ContentSafetyCheck_userId_idx(userId), INDEX ContentSafetyCheck_result_idx(result),
INDEX ContentSafetyCheck_createdAt_idx(createdAt), PRIMARY KEY (id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE ContentReport (
id VARCHAR(191) NOT NULL, reporterId VARCHAR(191) NOT NULL,
targetType VARCHAR(32) NOT NULL, targetId VARCHAR(100) NOT NULL,
reason VARCHAR(500) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'pending',
handledBy VARCHAR(100), handleNote VARCHAR(500),
createdAt DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), handledAt DATETIME(3),
INDEX ContentReport_status_idx(status), INDEX ContentReport_createdAt_idx(createdAt),
PRIMARY KEY (id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -901,3 +901,50 @@ model SecurityEvent {
@@index([eventType]) @@index([eventType])
@@index([createdAt]) @@index([createdAt])
} }
model SensitiveWord {
id String @id @default(cuid())
word String @unique @db.VarChar(100)
category String @default("general") @db.VarChar(32)
riskLevel String @default("medium") @db.VarChar(16)
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([word])
@@index([category])
}
model ContentSafetyCheck {
id String @id @default(cuid())
userId String? @db.VarChar(100)
contentType String @db.VarChar(32)
content String @db.Text
riskLevel String @db.VarChar(16)
matchedWords String? @db.Text
result String @default("pending") @db.VarChar(16)
reviewerId String? @db.VarChar(100)
reviewNote String? @db.VarChar(500)
createdAt DateTime @default(now())
reviewedAt DateTime?
@@index([userId])
@@index([result])
@@index([createdAt])
}
model ContentReport {
id String @id @default(cuid())
reporterId String
targetType String @db.VarChar(32)
targetId String @db.VarChar(100)
reason String @db.VarChar(500)
status String @default("pending") @db.VarChar(16)
handledBy String? @db.VarChar(100)
handleNote String? @db.VarChar(500)
createdAt DateTime @default(now())
handledAt DateTime?
@@index([status])
@@index([createdAt])
}

View File

@ -16,6 +16,8 @@ import { AuthModule } from './modules/auth/auth.module';
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module'; import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
import { AdminDashboardModule } from './modules/admin-dashboard/admin-dashboard.module'; import { AdminDashboardModule } from './modules/admin-dashboard/admin-dashboard.module';
import { AdminUsersModule } from './modules/admin-users/admin-users.module'; import { AdminUsersModule } from './modules/admin-users/admin-users.module';
import { ContentSafetyModule } from './modules/content-safety/content-safety.module';
import { AdminThrottleModule } from './modules/admin-throttle/admin-throttle.module';
import { AdminThrottleModule } from './modules/admin-throttle/admin-throttle.module'; import { AdminThrottleModule } from './modules/admin-throttle/admin-throttle.module';
import { AppConfigModule } from './modules/config/config.module'; import { AppConfigModule } from './modules/config/config.module';
import { AdminEventsModule } from './modules/admin-events/admin-events.module'; import { AdminEventsModule } from './modules/admin-events/admin-events.module';
@ -53,6 +55,8 @@ import { ResponseInterceptor } from './common/interceptors/response.interceptor'
import { TraceIdInterceptor } from './common/interceptors/trace-id.interceptor'; import { TraceIdInterceptor } from './common/interceptors/trace-id.interceptor';
import { TimeoutInterceptor } from './common/interceptors/timeout.interceptor'; import { TimeoutInterceptor } from './common/interceptors/timeout.interceptor';
import { AppThrottleModule } from './common/throttle/throttle.module'; import { AppThrottleModule } from './common/throttle/throttle.module';
import { TimeoutInterceptor } from './common/interceptors/timeout.interceptor';
import { AppThrottleModule } from './common/throttle/throttle.module';
import { AiAnalysisWorker } from './workers/ai-analysis.worker'; import { AiAnalysisWorker } from './workers/ai-analysis.worker';
import { DocumentImportWorker } from './workers/document-import.worker'; import { DocumentImportWorker } from './workers/document-import.worker';
@ -92,6 +96,7 @@ import appleConfig from './config/apple.config';
PrismaModule, PrismaModule,
RedisModule, RedisModule,
AppThrottleModule, AppThrottleModule,
AppThrottleModule,
EventBusModule, EventBusModule,
QueueModule, QueueModule,
AiModule, AiModule,
@ -102,6 +107,8 @@ import appleConfig from './config/apple.config';
AdminAuthModule, AdminAuthModule,
AdminDashboardModule, AdminDashboardModule,
AdminUsersModule, AdminUsersModule,
ContentSafetyModule,
AdminThrottleModule,
AdminThrottleModule, AdminThrottleModule,
AppConfigModule, AppConfigModule,
AdminEventsModule, AdminEventsModule,
@ -138,6 +145,7 @@ import appleConfig from './config/apple.config';
{ provide: APP_PIPE, useClass: StrictValidationPipe }, { provide: APP_PIPE, useClass: StrictValidationPipe },
{ provide: APP_INTERCEPTOR, useClass: TraceIdInterceptor }, { provide: APP_INTERCEPTOR, useClass: TraceIdInterceptor },
{ provide: APP_INTERCEPTOR, useClass: TimeoutInterceptor }, { provide: APP_INTERCEPTOR, useClass: TimeoutInterceptor },
{ provide: APP_INTERCEPTOR, useClass: TimeoutInterceptor },
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor }, { provide: APP_INTERCEPTOR, useClass: ResponseInterceptor },
AiAnalysisWorker, AiAnalysisWorker,
DocumentImportWorker, DocumentImportWorker,

View File

@ -5,6 +5,14 @@ import { catchError, timeout } from 'rxjs/operators';
@Injectable() @Injectable()
export class TimeoutInterceptor implements NestInterceptor { export class TimeoutInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<any> { intercept(_context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(timeout(30000), catchError(err => err instanceof TimeoutError ? throwError(() => new RequestTimeoutException('Request timeout')) : throwError(() => err))); return next.handle().pipe(
timeout(30000),
catchError(err => {
if (err instanceof TimeoutError) {
return throwError(() => new RequestTimeoutException('Request timeout'));
}
return throwError(() => err);
}),
);
} }
} }

View File

@ -1,22 +1,19 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ThrottlerStorage } from '@nestjs/throttler'; import { ThrottlerStorage } from '@nestjs/throttler';
import { ThrottlerStorageRecord } from '@nestjs/throttler/dist/throttler-storage-record.interface';
import { RedisService } from '../../infrastructure/redis/redis.service'; import { RedisService } from '../../infrastructure/redis/redis.service';
@Injectable() @Injectable()
export class RedisThrottlerStorage implements ThrottlerStorage { export class RedisThrottlerStorage implements ThrottlerStorage {
constructor(private readonly redis: RedisService) {} constructor(private readonly redis: RedisService) {}
async increment(key: string, ttl: number, limit: number, blockDuration: number, throttlerName: string): Promise<ThrottlerStorageRecord> { async increment(key: string, ttl: number): Promise<{ totalHits: number; timeToExpire: number }> {
const redisKey = `throttle:${throttlerName}:${key}`; const redisKey = `throttle:${key}`;
try { try {
const hits = await this.redis.incr(redisKey); const result = await this.redis.incr(redisKey);
await this.redis.expire(redisKey, Math.ceil(ttl / 1000)); await this.redis.expire(redisKey, Math.ceil(ttl / 1000));
const isBlocked = hits > limit; return { totalHits: result, timeToExpire: ttl };
const timeToBlockExpire = isBlocked ? blockDuration : 0;
return { totalHits: hits, timeToExpire: ttl, isBlocked, timeToBlockExpire };
} catch { } catch {
return { totalHits: 1, timeToExpire: ttl, isBlocked: false, timeToBlockExpire: 0 }; return { totalHits: 1, timeToExpire: ttl };
} }
} }
} }

View File

@ -1,5 +1,5 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerModule, ThrottlerStorage } from '@nestjs/throttler';
import { RedisThrottlerStorage } from './redis-throttler.storage'; import { RedisThrottlerStorage } from './redis-throttler.storage';
import { RedisService } from '../../infrastructure/redis/redis.service'; import { RedisService } from '../../infrastructure/redis/redis.service';
@ -19,6 +19,7 @@ import { RedisService } from '../../infrastructure/redis/redis.service';
inject: [RedisService], inject: [RedisService],
}), }),
], ],
providers: [RedisThrottlerStorage],
exports: [ThrottlerModule], exports: [ThrottlerModule],
}) })
export class AppThrottleModule {} export class AppThrottleModule {}

View File

@ -12,7 +12,7 @@ import type { AdminRole } from '../../common/types/admin-role.enum';
export class AdminThrottleController { export class AdminThrottleController {
@Get('status') @Get('status')
@AdminRoles('SUPER_ADMIN' as AdminRole) @AdminRoles('SUPER_ADMIN' as AdminRole)
@ApiOperation({ summary: '限流规则状态' }) @ApiOperation({ summary: '限流状态' })
async status() { async status() {
return { return {
rules: [ rules: [

View File

@ -2,5 +2,9 @@ import { Module } from '@nestjs/common';
import { AdminThrottleController } from './admin-throttle.controller'; import { AdminThrottleController } from './admin-throttle.controller';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
import { AdminRolesGuard } from '../../common/guards/admin-roles.guard'; import { AdminRolesGuard } from '../../common/guards/admin-roles.guard';
@Module({ controllers: [AdminThrottleController], providers: [AdminAuthGuard, AdminRolesGuard] })
@Module({
controllers: [AdminThrottleController],
providers: [AdminAuthGuard, AdminRolesGuard],
})
export class AdminThrottleModule {} export class AdminThrottleModule {}

View File

@ -0,0 +1,20 @@
import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { ContentSafetyService } from './content-safety.service';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
import { AdminRolesGuard } from '../../common/guards/admin-roles.guard';
import { AdminRoles } from '../../common/decorators/admin-roles.decorator';
import type { AdminRole } from '../../common/types/admin-role.enum';
@ApiTags('admin-content-safety')
@Controller('admin-api/content-safety')
@UseGuards(AdminAuthGuard, AdminRolesGuard)
@ApiBearerAuth()
export class ContentSafetyController {
constructor(private readonly svc: ContentSafetyService) {}
@Get('words') @AdminRoles('SUPER_ADMIN' as AdminRole) async words() { return this.svc.getAllWords() }
@Post('words') @AdminRoles('SUPER_ADMIN' as AdminRole) async addWord(@Body() d: { word: string; category: string; riskLevel: string }) { return this.svc.addWord(d.word, d.category, d.riskLevel) }
@Delete('words/:id') @AdminRoles('SUPER_ADMIN' as AdminRole) async removeWord(@Param('id') id: string) { await this.svc.removeWord(id); return { success: true } }
@Get('checks') @AdminRoles('SUPER_ADMIN' as AdminRole) async checks() { return this.svc.getChecks() }
}

View File

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { ContentSafetyController } from './content-safety.controller';
import { ContentSafetyService } from './content-safety.service';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { RedisService } from '../../infrastructure/redis/redis.service';
import { QueueService } from '../../infrastructure/queue/queue.service';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
import { AdminRolesGuard } from '../../common/guards/admin-roles.guard';
@Module({
controllers: [ContentSafetyController],
providers: [ContentSafetyService, PrismaService, RedisService, QueueService, AdminAuthGuard, AdminRolesGuard],
exports: [ContentSafetyService],
})
export class ContentSafetyModule {}

View File

@ -0,0 +1,80 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { RedisService } from '../../infrastructure/redis/redis.service';
import { QueueService } from '../../infrastructure/queue/queue.service';
const SW_CACHE_KEY = 'safety:words';
const SW_CACHE_TTL = 300;
@Injectable()
export class ContentSafetyService {
private readonly logger = new Logger(ContentSafetyService.name);
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly queue: QueueService,
) {}
async check(text: string, context: { userId?: string; contentType: string }): Promise<{ safe: boolean; riskLevel: string; matchedWords: string[] }> {
if (!text) return { safe: true, riskLevel: 'low', matchedWords: [] };
const words = await this.getSensitiveWords();
const matched: string[] = [];
let highestRisk = 'low';
for (const sw of words) {
if (text.includes(sw.word)) {
matched.push(sw.word);
if (sw.riskLevel === 'critical') highestRisk = 'critical';
else if (sw.riskLevel === 'high' && highestRisk !== 'critical') highestRisk = 'high';
else if (sw.riskLevel === 'medium' && highestRisk === 'low') highestRisk = 'medium';
}
}
const result = highestRisk === 'critical' || highestRisk === 'high' ? 'blocked' : highestRisk === 'medium' ? 'flagged' : 'passed';
// Save check record
await this.prisma.contentSafetyCheck.create({
data: {
userId: context.userId || null,
contentType: context.contentType,
content: text.slice(0, 1000),
riskLevel: highestRisk,
matchedWords: matched.join(',') || null,
result,
},
});
return { safe: result !== 'blocked', riskLevel: highestRisk, matchedWords: matched };
}
private async getSensitiveWords(): Promise<{ word: string; riskLevel: string }[]> {
try {
const cached = await this.redis.get(SW_CACHE_KEY);
if (cached) return JSON.parse(cached);
} catch {}
const words = await this.prisma.sensitiveWord.findMany({
where: { enabled: true },
select: { word: true, riskLevel: true },
});
try { await this.redis.set(SW_CACHE_KEY, JSON.stringify(words), SW_CACHE_TTL); } catch {}
return words;
}
async addWord(word: string, category: string, riskLevel: string) {
await this.prisma.sensitiveWord.create({ data: { word, category, riskLevel } });
try { await this.redis.del(SW_CACHE_KEY); } catch {}
}
async removeWord(id: string) {
await this.prisma.sensitiveWord.delete({ where: { id } });
try { await this.redis.del(SW_CACHE_KEY); } catch {}
}
async getAllWords() { return this.prisma.sensitiveWord.findMany({ orderBy: { createdAt: 'desc' } }) }
async getChecks(limit = 50) { return this.prisma.contentSafetyCheck.findMany({ orderBy: { createdAt: 'desc' }, take: limit }) }
}