feat: M0-06 Content Safety — sensitive word check + admin AAPI
Some checks failed
Deploy API Server / build-and-deploy (push) Has been cancelled
Some checks failed
Deploy API Server / build-and-deploy (push) Has been cancelled
This commit is contained in:
parent
4d977d2a85
commit
9e8f3dccd7
@ -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;
|
||||||
@ -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])
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
@ -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: [
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
20
src/modules/content-safety/content-safety.controller.ts
Normal file
20
src/modules/content-safety/content-safety.controller.ts
Normal 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() }
|
||||||
|
}
|
||||||
15
src/modules/content-safety/content-safety.module.ts
Normal file
15
src/modules/content-safety/content-safety.module.ts
Normal 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 {}
|
||||||
80
src/modules/content-safety/content-safety.service.ts
Normal file
80
src/modules/content-safety/content-safety.service.ts
Normal 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 }) }
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user