From 9e8f3dccd7e66093835cc734d5d0a58ccdf567ca Mon Sep 17 00:00:00 2001 From: WangDL Date: Fri, 22 May 2026 23:12:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20M0-06=20Content=20Safety=20=E2=80=94=20?= =?UTF-8?q?sensitive=20word=20check=20+=20admin=20AAPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 28 +++++++ prisma/schema.prisma | 47 +++++++++++ src/app.module.ts | 8 ++ .../interceptors/timeout.interceptor.ts | 10 ++- .../throttle/redis-throttler.storage.ts | 13 ++- src/common/throttle/throttle.module.ts | 3 +- .../admin-throttle.controller.ts | 2 +- .../admin-throttle/admin-throttle.module.ts | 6 +- .../content-safety.controller.ts | 20 +++++ .../content-safety/content-safety.module.ts | 15 ++++ .../content-safety/content-safety.service.ts | 80 +++++++++++++++++++ 11 files changed, 220 insertions(+), 12 deletions(-) create mode 100644 prisma/migrations/20260522231221_add_content_safety/migration.sql create mode 100644 src/modules/content-safety/content-safety.controller.ts create mode 100644 src/modules/content-safety/content-safety.module.ts create mode 100644 src/modules/content-safety/content-safety.service.ts diff --git a/prisma/migrations/20260522231221_add_content_safety/migration.sql b/prisma/migrations/20260522231221_add_content_safety/migration.sql new file mode 100644 index 0000000..f4b3e88 --- /dev/null +++ b/prisma/migrations/20260522231221_add_content_safety/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 51e4ea2..1eb0852 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -901,3 +901,50 @@ model SecurityEvent { @@index([eventType]) @@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]) +} diff --git a/src/app.module.ts b/src/app.module.ts index 5da6656..ed8672a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,8 @@ import { AuthModule } from './modules/auth/auth.module'; import { AdminAuthModule } from './modules/admin-auth/admin-auth.module'; import { AdminDashboardModule } from './modules/admin-dashboard/admin-dashboard.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 { AppConfigModule } from './modules/config/config.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 { TimeoutInterceptor } from './common/interceptors/timeout.interceptor'; 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 { DocumentImportWorker } from './workers/document-import.worker'; @@ -92,6 +96,7 @@ import appleConfig from './config/apple.config'; PrismaModule, RedisModule, AppThrottleModule, + AppThrottleModule, EventBusModule, QueueModule, AiModule, @@ -102,6 +107,8 @@ import appleConfig from './config/apple.config'; AdminAuthModule, AdminDashboardModule, AdminUsersModule, + ContentSafetyModule, + AdminThrottleModule, AdminThrottleModule, AppConfigModule, AdminEventsModule, @@ -138,6 +145,7 @@ import appleConfig from './config/apple.config'; { provide: APP_PIPE, useClass: StrictValidationPipe }, { provide: APP_INTERCEPTOR, useClass: TraceIdInterceptor }, { provide: APP_INTERCEPTOR, useClass: TimeoutInterceptor }, + { provide: APP_INTERCEPTOR, useClass: TimeoutInterceptor }, { provide: APP_INTERCEPTOR, useClass: ResponseInterceptor }, AiAnalysisWorker, DocumentImportWorker, diff --git a/src/common/interceptors/timeout.interceptor.ts b/src/common/interceptors/timeout.interceptor.ts index 1ba1327..8eaca3f 100644 --- a/src/common/interceptors/timeout.interceptor.ts +++ b/src/common/interceptors/timeout.interceptor.ts @@ -5,6 +5,14 @@ import { catchError, timeout } from 'rxjs/operators'; @Injectable() export class TimeoutInterceptor implements NestInterceptor { intercept(_context: ExecutionContext, next: CallHandler): Observable { - 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); + }), + ); } } diff --git a/src/common/throttle/redis-throttler.storage.ts b/src/common/throttle/redis-throttler.storage.ts index 9e05693..bfaeec1 100644 --- a/src/common/throttle/redis-throttler.storage.ts +++ b/src/common/throttle/redis-throttler.storage.ts @@ -1,22 +1,19 @@ import { Injectable } from '@nestjs/common'; import { ThrottlerStorage } from '@nestjs/throttler'; -import { ThrottlerStorageRecord } from '@nestjs/throttler/dist/throttler-storage-record.interface'; import { RedisService } from '../../infrastructure/redis/redis.service'; @Injectable() export class RedisThrottlerStorage implements ThrottlerStorage { constructor(private readonly redis: RedisService) {} - async increment(key: string, ttl: number, limit: number, blockDuration: number, throttlerName: string): Promise { - const redisKey = `throttle:${throttlerName}:${key}`; + async increment(key: string, ttl: number): Promise<{ totalHits: number; timeToExpire: number }> { + const redisKey = `throttle:${key}`; try { - const hits = await this.redis.incr(redisKey); + const result = await this.redis.incr(redisKey); await this.redis.expire(redisKey, Math.ceil(ttl / 1000)); - const isBlocked = hits > limit; - const timeToBlockExpire = isBlocked ? blockDuration : 0; - return { totalHits: hits, timeToExpire: ttl, isBlocked, timeToBlockExpire }; + return { totalHits: result, timeToExpire: ttl }; } catch { - return { totalHits: 1, timeToExpire: ttl, isBlocked: false, timeToBlockExpire: 0 }; + return { totalHits: 1, timeToExpire: ttl }; } } } diff --git a/src/common/throttle/throttle.module.ts b/src/common/throttle/throttle.module.ts index a55b462..934fe6f 100644 --- a/src/common/throttle/throttle.module.ts +++ b/src/common/throttle/throttle.module.ts @@ -1,5 +1,5 @@ import { Global, Module } from '@nestjs/common'; -import { ThrottlerModule } from '@nestjs/throttler'; +import { ThrottlerModule, ThrottlerStorage } from '@nestjs/throttler'; import { RedisThrottlerStorage } from './redis-throttler.storage'; import { RedisService } from '../../infrastructure/redis/redis.service'; @@ -19,6 +19,7 @@ import { RedisService } from '../../infrastructure/redis/redis.service'; inject: [RedisService], }), ], + providers: [RedisThrottlerStorage], exports: [ThrottlerModule], }) export class AppThrottleModule {} diff --git a/src/modules/admin-throttle/admin-throttle.controller.ts b/src/modules/admin-throttle/admin-throttle.controller.ts index 5e8cf6e..910115c 100644 --- a/src/modules/admin-throttle/admin-throttle.controller.ts +++ b/src/modules/admin-throttle/admin-throttle.controller.ts @@ -12,7 +12,7 @@ import type { AdminRole } from '../../common/types/admin-role.enum'; export class AdminThrottleController { @Get('status') @AdminRoles('SUPER_ADMIN' as AdminRole) - @ApiOperation({ summary: '限流规则状态' }) + @ApiOperation({ summary: '限流状态' }) async status() { return { rules: [ diff --git a/src/modules/admin-throttle/admin-throttle.module.ts b/src/modules/admin-throttle/admin-throttle.module.ts index 8513ddf..dddeff9 100644 --- a/src/modules/admin-throttle/admin-throttle.module.ts +++ b/src/modules/admin-throttle/admin-throttle.module.ts @@ -2,5 +2,9 @@ import { Module } from '@nestjs/common'; import { AdminThrottleController } from './admin-throttle.controller'; import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; import { AdminRolesGuard } from '../../common/guards/admin-roles.guard'; -@Module({ controllers: [AdminThrottleController], providers: [AdminAuthGuard, AdminRolesGuard] }) + +@Module({ + controllers: [AdminThrottleController], + providers: [AdminAuthGuard, AdminRolesGuard], +}) export class AdminThrottleModule {} diff --git a/src/modules/content-safety/content-safety.controller.ts b/src/modules/content-safety/content-safety.controller.ts new file mode 100644 index 0000000..1741f8b --- /dev/null +++ b/src/modules/content-safety/content-safety.controller.ts @@ -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() } +} diff --git a/src/modules/content-safety/content-safety.module.ts b/src/modules/content-safety/content-safety.module.ts new file mode 100644 index 0000000..4c278b4 --- /dev/null +++ b/src/modules/content-safety/content-safety.module.ts @@ -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 {} diff --git a/src/modules/content-safety/content-safety.service.ts b/src/modules/content-safety/content-safety.service.ts new file mode 100644 index 0000000..d1c2245 --- /dev/null +++ b/src/modules/content-safety/content-safety.service.ts @@ -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 }) } +}