diff --git a/package.json b/package.json index b40e077..763c663 100644 --- a/package.json +++ b/package.json @@ -34,11 +34,14 @@ "@nestjs/core": "^12.0.1", "@nestjs/jwt": "^12.0.2", "@nestjs/platform-express": "^12.0.1", + "@nestjs/schedule": "^12.0.2", "@nestjs/swagger": "^12.0.2", + "@nestjs/throttler": "^6.7.1", "@prisma/client": "^6.19.3", "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "helmet": "^8.3.0", "ioredis": "^6.0.0", "nestjs-pino": "^5.3.1", "pino": "^10.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efba00e..9f7a0cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,9 +178,15 @@ importers: '@nestjs/platform-express': specifier: ^12.0.1 version: 12.1.2(@nestjs/common@12.1.2(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.1.2)(supports-color@7.2.0) + '@nestjs/schedule': + specifier: ^12.0.2 + version: 12.0.2(@nestjs/common@12.1.2(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.1.2) '@nestjs/swagger': specifier: ^12.0.2 version: 12.0.2(@nestjs/common@12.1.2(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.1.2)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(typescript@6.0.3) + '@nestjs/throttler': + specifier: ^6.7.1 + version: 6.7.1(@nestjs/common@12.1.2(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.1.2)(reflect-metadata@0.2.2) '@prisma/client': specifier: ^6.19.3 version: 6.19.3(prisma@6.19.3(typescript@6.0.3))(typescript@6.0.3) @@ -193,6 +199,9 @@ importers: class-validator: specifier: ^0.15.1 version: 0.15.1 + helmet: + specifier: ^8.3.0 + version: 8.3.0 ioredis: specifier: ^6.0.0 version: 6.0.0(supports-color@7.2.0) @@ -726,6 +735,13 @@ packages: '@nestjs/common': ^12.0.0 '@nestjs/core': ^12.0.0 + '@nestjs/schedule@12.0.2': + resolution: {integrity: sha512-5iAvJEtk0njbfTrG4JQr+SXw8ZrqgDM1wJH9nUs6tVLCX5pWGd1LJ0a8b089UT1FDeDWaThRRfSLixFz9zztcA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@nestjs/common': ^11.0.0 || ^12.0.0 + '@nestjs/core': ^11.0.0 || ^12.0.0 + '@nestjs/schematics@12.0.6': resolution: {integrity: sha512-2OG0BUhGPJqVvWFhiy+7oaQa4VQUvTS9Dw8YRfvweznl4KS3YyZrdPi4cVGuRAThoEIsbdfguZzSOv1+t8U1tg==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} @@ -770,6 +786,14 @@ packages: '@nestjs/platform-express': optional: true + '@nestjs/throttler@6.7.1': + resolution: {integrity: sha512-gAyY1AkU+cSK5DCjFOB43xax9oakameH4xZa9kAgqBqSBQZgmv3DZylLV8KyZNzOGhceL3kDVtby+CF8VAdmEA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 + '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -1115,6 +1139,9 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/luxon@3.7.6': + resolution: {integrity: sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} @@ -1445,6 +1472,10 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} + date-fns@4.4.0: resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} @@ -1705,6 +1736,10 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + helmet@8.3.0: + resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==} + engines: {node: '>=18.0.0'} + help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} @@ -1929,6 +1964,10 @@ packages: resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} engines: {node: '>=18'} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2981,6 +3020,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/schedule@12.0.2(@nestjs/common@12.1.2(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.1.2)': + dependencies: + '@nestjs/common': 12.1.2(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + '@nestjs/core': 12.1.2(@nestjs/common@12.1.2(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@12.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.4.0 + '@nestjs/schematics@12.0.6(chokidar@5.0.0)(prettier@3.9.9)(typescript@6.0.3)': dependencies: '@angular-devkit/core': 22.2.0(chokidar@5.0.0) @@ -3019,6 +3064,12 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 12.1.2(@nestjs/common@12.1.2(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.1.2)(supports-color@7.2.0) + '@nestjs/throttler@6.7.1(@nestjs/common@12.1.2(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.1.2)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 12.1.2(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + '@nestjs/core': 12.1.2(@nestjs/common@12.1.2(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@12.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + '@noble/hashes@1.8.0': {} '@oxc-project/types@0.153.0': {} @@ -3245,6 +3296,8 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 24.19.1 + '@types/luxon@3.7.6': {} + '@types/methods@1.1.4': {} '@types/ms@2.1.0': {} @@ -3570,6 +3623,11 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cron@4.4.0: + dependencies: + '@types/luxon': 3.7.6 + luxon: 3.7.2 + date-fns@4.4.0: {} dateformat@4.6.3: {} @@ -3859,6 +3917,8 @@ snapshots: dependencies: function-bind: 1.1.2 + helmet@8.3.0: {} + help-me@5.0.0: {} html-escaper@2.0.2: {} @@ -4060,6 +4120,8 @@ snapshots: is-unicode-supported: 2.1.0 yoctocolors: 2.2.0 + luxon@3.7.2: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 diff --git a/src/app.module.ts b/src/app.module.ts index 99a8ded..21b29ff 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,6 +11,9 @@ import { BodyLoggingInterceptor } from './logging/body-logging.interceptor.js'; import { TransformInterceptor } from './common/interceptors/transform.interceptor.js'; import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor.js'; import { AllExceptionsFilter } from './common/filters/http-exception.filter.js'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; +import { ScheduleModule } from '@nestjs/schedule'; +import { CleanupService } from './common/cleanup.service.js'; import { TenantsModule } from './tenants/tenants.module.js'; import { UsersModule } from './users/users.module.js'; import { RolesModule } from './roles/roles.module.js'; @@ -25,6 +28,8 @@ import { AppService } from './app.service.js'; @Module({ imports: [ + ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]), + ScheduleModule.forRoot(), createLoggerModule(), PrismaModule, RedisModule, @@ -42,6 +47,8 @@ import { AppService } from './app.service.js'; controllers: [AppController], providers: [ AppService, + CleanupService, + { provide: APP_GUARD, useClass: ThrottlerGuard }, { provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: PermissionsGuard }, { provide: APP_INTERCEPTOR, useClass: RequestContextInterceptor }, diff --git a/src/common/cleanup.service.ts b/src/common/cleanup.service.ts new file mode 100644 index 0000000..9c7f01a --- /dev/null +++ b/src/common/cleanup.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { InjectPinoLogger, PinoLogger } from 'nestjs-pino'; +import { PrismaService } from '../prisma/prisma.service.js'; + +/** 定时清理:过期/吊销 refresh token 与超过保留期的审计/登录日志。 */ +@Injectable() +export class CleanupService { + constructor( + private readonly prisma: PrismaService, + @InjectPinoLogger(CleanupService.name) private readonly logger: PinoLogger, + ) {} + + /** 每天 03:07 执行。 */ + @Cron('7 3 * * *') + async cleanup(): Promise { + try { + const db = this.prisma.raw; + const tokens = await db.refreshToken.deleteMany({ + where: { OR: [{ revokedAt: { not: null } }, { expireAt: { lt: new Date() } }] }, + }); + const cutoff = new Date(Date.now() - 90 * 24 * 3600 * 1000); + const audit = await db.auditLog.deleteMany({ where: { createdAt: { lt: cutoff } } }); + const login = await db.loginLog.deleteMany({ where: { createdAt: { lt: cutoff } } }); + this.logger.info( + { refreshTokens: tokens.count, auditLogs: audit.count, loginLogs: login.count }, + '定时清理完成', + ); + } catch (err) { + this.logger.warn({ err }, '定时清理失败'); + } + } +} diff --git a/src/common/interceptors/transform.interceptor.ts b/src/common/interceptors/transform.interceptor.ts index b3c7837..5c6e2aa 100644 --- a/src/common/interceptors/transform.interceptor.ts +++ b/src/common/interceptors/transform.interceptor.ts @@ -2,12 +2,24 @@ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nes import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; +/** 递归将 Date 转为 Unix 毫秒 number(API 时间契约:统一毫秒时间戳)。 */ +function toUnixMs(value: unknown): unknown { + if (value instanceof Date) return value.getTime(); + if (Array.isArray(value)) return value.map(toUnixMs); + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) out[k] = toUnixMs(v); + return out; + } + return value; +} + /** - * 统一响应封装:{ code, message, data },成功 code = 0。 + * 统一响应封装:{ code, message, data },成功 code = 0;时间统一 Unix 毫秒。 */ @Injectable() export class TransformInterceptor implements NestInterceptor { intercept(_context: ExecutionContext, next: CallHandler): Observable { - return next.handle().pipe(map((data) => ({ code: 0, message: 'ok', data: data ?? null }))); + return next.handle().pipe(map((data) => ({ code: 0, message: 'ok', data: toUnixMs(data ?? null) }))); } } diff --git a/src/main.ts b/src/main.ts index e2dd848..3489d57 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,29 +3,33 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { Logger } from 'nestjs-pino'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import helmet from 'helmet'; import { AppModule } from './app.module.js'; async function bootstrap(): Promise { const app = await NestFactory.create(AppModule, { bufferLogs: true }); app.setGlobalPrefix('api/v1'); - app.enableCors(); + app.use(helmet()); + const corsOrigin = process.env.CORS_ORIGIN + ? process.env.CORS_ORIGIN.split(',').map((s) => s.trim()) + : ['http://localhost:5173']; + app.enableCors({ origin: corsOrigin, credentials: true }); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); app.useLogger(app.get(Logger)); - const swaggerConfig = new DocumentBuilder() - .setTitle('系统管理 API') - .setDescription('投云科技系统管理 API 文档') - .setVersion('1.0') - .addBearerAuth() - .build(); - SwaggerModule.setup( - 'docs', - app, - SwaggerModule.createDocument(app, swaggerConfig), - ); + // Swagger 文档仅非生产环境开放(生产默认关闭) + if (process.env.NODE_ENV !== 'production') { + const swaggerConfig = new DocumentBuilder() + .setTitle('系统管理 API') + .setDescription('投云科技系统管理 API 文档') + .setVersion('1.0') + .addBearerAuth() + .build(); + SwaggerModule.setup('docs', app, SwaggerModule.createDocument(app, swaggerConfig)); + } const port = Number(process.env.PORT ?? 3000); await app.listen(port); - app.get(Logger).log(`服务启动成功,端口 ${port},Swagger 文档: http://localhost:${port}/docs`); + app.get(Logger).log(`服务启动成功,端口 ${port}`); } await bootstrap();