251124:1700 Ready to Phase 7
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// File: src/common/auth/auth.controller.ts
|
||||
// บันทึกการแก้ไข: เพิ่ม Endpoints ให้ครบตามแผน T1.2 (Refresh, Logout, Profile)
|
||||
// บันทึกการแก้ไข: เพิ่ม Type ให้ req และแก้ไข Import (Fix TS7006)
|
||||
|
||||
import {
|
||||
Controller,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Get,
|
||||
UseGuards,
|
||||
UnauthorizedException,
|
||||
Request,
|
||||
Req,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
@@ -16,9 +16,15 @@ import { Throttle } from '@nestjs/throttler';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { LoginDto } from './dto/login.dto.js';
|
||||
import { RegisterDto } from './dto/register.dto.js';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard.js';
|
||||
import { JwtRefreshGuard } from './guards/jwt-refresh.guard.js'; // ต้องสร้าง Guard นี้
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; // (ถ้าใช้ Swagger)
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard.js';
|
||||
import { JwtRefreshGuard } from '../guards/jwt-refresh.guard.js';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Request } from 'express'; // ✅ Import Request
|
||||
|
||||
// สร้าง Interface สำหรับ Request ที่มี User (เพื่อให้ TS รู้จัก req.user)
|
||||
interface RequestWithUser extends Request {
|
||||
user: any;
|
||||
}
|
||||
|
||||
@ApiTags('Authentication')
|
||||
@Controller('auth')
|
||||
@@ -26,7 +32,7 @@ export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } }) // เข้มงวด: 5 ครั้ง/นาที
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'เข้าสู่ระบบเพื่อรับ Access & Refresh Token' })
|
||||
async login(@Body() loginDto: LoginDto) {
|
||||
@@ -43,7 +49,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post('register-admin')
|
||||
@UseGuards(JwtAuthGuard) // ควรป้องกัน Route นี้ให้เฉพาะ Superadmin
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'สร้างบัญชีผู้ใช้ใหม่ (Admin Only)' })
|
||||
async register(@Body() registerDto: RegisterDto) {
|
||||
@@ -54,8 +60,8 @@ export class AuthController {
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'ขอ Access Token ใหม่ด้วย Refresh Token' })
|
||||
async refresh(@Request() req) {
|
||||
// req.user จะมาจาก JwtRefreshStrategy
|
||||
async refresh(@Req() req: RequestWithUser) {
|
||||
// ✅ ระบุ Type ชัดเจน
|
||||
return this.authService.refreshToken(req.user.sub, req.user.refreshToken);
|
||||
}
|
||||
|
||||
@@ -64,9 +70,13 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'ออกจากระบบ (Revoke Token)' })
|
||||
async logout(@Request() req) {
|
||||
// ดึง Token จาก Header Authorization: Bearer <token>
|
||||
async logout(@Req() req: RequestWithUser) {
|
||||
// ✅ ระบุ Type ชัดเจน
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
// ต้องเช็คว่ามี token หรือไม่ เพื่อป้องกัน runtime error
|
||||
if (!token) {
|
||||
return { message: 'No token provided' };
|
||||
}
|
||||
return this.authService.logout(req.user.sub, token);
|
||||
}
|
||||
|
||||
@@ -74,7 +84,8 @@ export class AuthController {
|
||||
@Get('profile')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'ดึงข้อมูลผู้ใช้ปัจจุบัน' })
|
||||
getProfile(@Request() req) {
|
||||
getProfile(@Req() req: RequestWithUser) {
|
||||
// ✅ ระบุ Type ชัดเจน
|
||||
return req.user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// File: src/common/auth/auth.module.ts
|
||||
// บันทึกการแก้ไข: ลงทะเบียน Refresh Strategy และแก้ไข Config
|
||||
// บันทึกการแก้ไข: แก้ไข Type Mismatch ของ expiresIn (Fix TS2322)
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
@@ -21,17 +21,14 @@ import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy.js';
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
// ใช้ Template String หรือค่า Default ที่ปลอดภัย
|
||||
expiresIn: configService.get<string>('JWT_EXPIRATION') || '15m',
|
||||
// ✅ Fix: Cast เป็น any เพื่อแก้ปัญหา Type ไม่ตรงกับ Library (StringValue vs string)
|
||||
expiresIn: (configService.get<string>('JWT_EXPIRATION') ||
|
||||
'15m') as any,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
JwtRefreshStrategy, // ✅ เพิ่ม Strategy สำหรับ Refresh Token
|
||||
],
|
||||
providers: [AuthService, JwtStrategy, JwtRefreshStrategy],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// File: src/common/auth/auth.service.ts
|
||||
// บันทึกการแก้ไข: เพิ่ม Refresh Token, Logout (Redis Blacklist) และ Profile ตาม T1.2
|
||||
// บันทึกการแก้ไข: แก้ไข Type Mismatch ใน signAsync (Fix TS2769)
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
@@ -10,11 +10,10 @@ import {
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Cache } from 'cache-manager';
|
||||
import type { Cache } from 'cache-manager';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { UserService } from '../../modules/user/user.service.js';
|
||||
import { RegisterDto } from './dto/register.dto.js';
|
||||
import { User } from '../../modules/user/entities/user.entity.js';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -22,7 +21,7 @@ export class AuthService {
|
||||
private userService: UserService,
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache, // ใช้ Redis สำหรับ Blacklist
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||
) {}
|
||||
|
||||
// 1. ตรวจสอบ Username/Password
|
||||
@@ -41,31 +40,33 @@ export class AuthService {
|
||||
const payload = {
|
||||
username: user.username,
|
||||
sub: user.user_id,
|
||||
scope: 'Global', // ตัวอย่าง: ใส่ Scope เริ่มต้น หรือดึงจาก Role
|
||||
scope: 'Global',
|
||||
};
|
||||
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get<string>('JWT_SECRET'),
|
||||
expiresIn: this.configService.get<string>('JWT_EXPIRATION') || '15m',
|
||||
// ✅ Fix: Cast as any
|
||||
expiresIn: (this.configService.get<string>('JWT_EXPIRATION') ||
|
||||
'15m') as any,
|
||||
}),
|
||||
this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
|
||||
expiresIn:
|
||||
this.configService.get<string>('JWT_REFRESH_EXPIRATION') || '7d',
|
||||
// ✅ Fix: Cast as any
|
||||
expiresIn: (this.configService.get<string>('JWT_REFRESH_EXPIRATION') ||
|
||||
'7d') as any,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
user: user, // ส่งข้อมูล user กลับไปให้ Frontend ใช้แสดงผลเบื้องต้น
|
||||
user: user,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Register (สำหรับ Admin)
|
||||
async register(userDto: RegisterDto) {
|
||||
// ตรวจสอบว่ามี user อยู่แล้วหรือไม่
|
||||
const existingUser = await this.userService.findOneByUsername(
|
||||
userDto.username,
|
||||
);
|
||||
@@ -84,33 +85,30 @@ export class AuthService {
|
||||
|
||||
// 4. Refresh Token: ออก Token ใหม่
|
||||
async refreshToken(userId: number, refreshToken: string) {
|
||||
// ตรวจสอบความถูกต้องของ Refresh Token (ถ้าใช้ DB เก็บ Refresh Token ก็เช็คตรงนี้)
|
||||
// ในที่นี้เราเชื่อใจ Signature ของ JWT Refresh Secret
|
||||
const user = await this.userService.findOne(userId);
|
||||
if (!user) throw new UnauthorizedException('User not found');
|
||||
|
||||
// สร้าง Access Token ใหม่
|
||||
const payload = { username: user.username, sub: user.user_id };
|
||||
|
||||
const accessToken = await this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get<string>('JWT_SECRET'),
|
||||
expiresIn: this.configService.get<string>('JWT_EXPIRATION') || '15m',
|
||||
// ✅ Fix: Cast as any
|
||||
expiresIn: (this.configService.get<string>('JWT_EXPIRATION') ||
|
||||
'15m') as any,
|
||||
});
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
// refresh_token: refreshToken, // จะส่งเดิมกลับ หรือ Rotate ใหม่ก็ได้ (แนะนำ Rotate เพื่อความปลอดภัยสูงสุด)
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Logout: นำ Token เข้า Blacklist ใน Redis
|
||||
async logout(userId: number, accessToken: string) {
|
||||
// หาเวลาที่เหลือของ Token เพื่อตั้ง TTL ใน Redis
|
||||
try {
|
||||
const decoded = this.jwtService.decode(accessToken);
|
||||
if (decoded && decoded.exp) {
|
||||
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
|
||||
if (ttl > 0) {
|
||||
// Key pattern: blacklist:token:{token_string}
|
||||
await this.cacheManager.set(
|
||||
`blacklist:token:${accessToken}`,
|
||||
true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// File: src/common/auth/strategies/jwt-refresh.strategy.ts
|
||||
// บันทึกการแก้ไข: Strategy สำหรับ Refresh Token (T1.2)
|
||||
// บันทึกการแก้ไข: แก้ไข TS2345 โดยยืนยันค่า secretOrKey ด้วย ! (Non-null assertion)
|
||||
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
@@ -16,8 +17,8 @@ export class JwtRefreshStrategy extends PassportStrategy(
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
// ใช้ Secret แยกต่างหากสำหรับ Refresh Token
|
||||
secretOrKey: configService.get<string>('JWT_REFRESH_SECRET'),
|
||||
// ✅ Fix: ใส่ ! เพื่อบอก TS ว่าค่านี้มีอยู่จริง (จาก env validation)
|
||||
secretOrKey: configService.get<string>('JWT_REFRESH_SECRET')!,
|
||||
passReqToCallback: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// File: src/common/auth/strategies/jwt.strategy.ts
|
||||
// บันทึกการแก้ไข: ปรับปรุง JwtStrategy ให้ตรวจสอบ Blacklist (Redis) และสถานะ User (T1.2)
|
||||
// บันทึกการแก้ไข: แก้ไข TS2345 (secretOrKey type) และ TS2551 (user.isActive property name)
|
||||
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, UnauthorizedException, Inject } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager'; // ✅ ใช้สำหรับ Blacklist
|
||||
import { Cache } from 'cache-manager';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import type { Cache } from 'cache-manager';
|
||||
import { Request } from 'express';
|
||||
import { UserService } from '../../../modules/user/user.service.js';
|
||||
|
||||
@@ -14,7 +13,7 @@ import { UserService } from '../../../modules/user/user.service.js';
|
||||
export interface JwtPayload {
|
||||
sub: number;
|
||||
username: string;
|
||||
scope?: string; // เพิ่ม Scope ถ้ามีการใช้
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -22,13 +21,14 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private userService: UserService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache, // ✅ Inject Redis Cache
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_SECRET'),
|
||||
passReqToCallback: true, // ✅ จำเป็นต้องใช้ เพื่อดึง Raw Token มาเช็ค Blacklist
|
||||
// ✅ Fix TS2345: ใส่ ! เพื่อยืนยันว่า Secret Key มีค่าแน่นอน
|
||||
secretOrKey: configService.get<string>('JWT_SECRET')!,
|
||||
passReqToCallback: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
// 1. ดึง Token ออกมาเพื่อตรวจสอบใน Blacklist
|
||||
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
|
||||
|
||||
// 2. ตรวจสอบว่า Token นี้อยู่ใน Redis Blacklist หรือไม่ (กรณี Logout ไปแล้ว)
|
||||
// 2. ตรวจสอบว่า Token นี้อยู่ใน Redis Blacklist หรือไม่
|
||||
const isBlacklisted = await this.cacheManager.get(
|
||||
`blacklist:token:${token}`,
|
||||
);
|
||||
@@ -53,11 +53,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
}
|
||||
|
||||
// 5. (Optional) ตรวจสอบว่า User ยัง Active อยู่หรือไม่
|
||||
if (user.is_active === false || user.is_active === 0) {
|
||||
// ✅ Fix TS2551: แก้ไขชื่อ Property จาก is_active เป็น isActive ตาม Entity Definition
|
||||
if (user.isActive === false) {
|
||||
throw new UnauthorizedException('User account is inactive');
|
||||
}
|
||||
|
||||
// คืนค่า User เพื่อนำไปใส่ใน req.user
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// File: src/common/config/redis.config.ts
|
||||
// บันทึกการแก้ไข: สร้าง Config สำหรับ Redis (T0.2)
|
||||
// บันทึกการแก้ไข: แก้ไข TS2345 โดยการจัดการค่า undefined ของ process.env ก่อน parseInt
|
||||
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('redis', () => ({
|
||||
host: process.env.REDIS_HOST || 'cache', // Default เป็นชื่อ Service ใน Docker
|
||||
port: parseInt(process.env.REDIS_PORT, 10) || 6379,
|
||||
ttl: parseInt(process.env.REDIS_TTL, 10) || 3600, // Default TTL 1 ชั่วโมง
|
||||
// password: process.env.REDIS_PASSWORD, // เปิดใช้ถ้ามี Password
|
||||
// ใช้ค่า Default 'cache' ถ้าหาไม่เจอ
|
||||
host: process.env.REDIS_HOST || 'cache',
|
||||
// ✅ Fix: ใช้ || '6379' เพื่อให้มั่นใจว่าเป็น string ก่อนเข้า parseInt
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
// ✅ Fix: ใช้ || '3600' เพื่อให้มั่นใจว่าเป็น string
|
||||
ttl: parseInt(process.env.REDIS_TTL || '3600', 10),
|
||||
// password: process.env.REDIS_PASSWORD,
|
||||
}));
|
||||
|
||||
49
backend/src/common/decorators/circuit-breaker.decorator.ts
Normal file
49
backend/src/common/decorators/circuit-breaker.decorator.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// File: src/common/resilience/decorators/circuit-breaker.decorator.ts
|
||||
import CircuitBreaker from 'opossum'; // ✅ เปลี่ยนเป็น Default Import (ถ้าลง @types/opossum แล้วจะผ่าน)
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
export interface CircuitBreakerOptions {
|
||||
timeout?: number;
|
||||
errorThresholdPercentage?: number;
|
||||
resetTimeout?: number;
|
||||
fallback?: (...args: any[]) => any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator สำหรับ Circuit Breaker
|
||||
* ใช้ป้องกัน System Overload เมื่อ External Service ล่ม
|
||||
*/
|
||||
export function UseCircuitBreaker(options: CircuitBreakerOptions = {}) {
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string,
|
||||
descriptor: PropertyDescriptor,
|
||||
) {
|
||||
const originalMethod = descriptor.value;
|
||||
const logger = new Logger('CircuitBreakerDecorator');
|
||||
|
||||
// สร้าง Opossum Circuit Breaker Instance
|
||||
const breaker = new CircuitBreaker(originalMethod, {
|
||||
timeout: options.timeout || 3000,
|
||||
errorThresholdPercentage: options.errorThresholdPercentage || 50,
|
||||
resetTimeout: options.resetTimeout || 10000,
|
||||
});
|
||||
|
||||
breaker.on('open', () => logger.warn(`Circuit OPEN for ${propertyKey}`));
|
||||
breaker.on('halfOpen', () =>
|
||||
logger.log(`Circuit HALF-OPEN for ${propertyKey}`),
|
||||
);
|
||||
breaker.on('close', () => logger.log(`Circuit CLOSED for ${propertyKey}`));
|
||||
|
||||
if (options.fallback) {
|
||||
breaker.fallback(options.fallback);
|
||||
}
|
||||
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
// ✅ ใช้ .fire โดยส่ง this context ให้ถูกต้อง
|
||||
return breaker.fire.apply(breaker, [this, ...args]);
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
60
backend/src/common/decorators/retry.decorator.ts
Normal file
60
backend/src/common/decorators/retry.decorator.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// File: src/common/resilience/decorators/retry.decorator.ts
|
||||
import retry from 'async-retry'; // ✅ แก้ Import: เปลี่ยนจาก * as retry เป็น default import
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
export interface RetryOptions {
|
||||
retries?: number;
|
||||
factor?: number;
|
||||
minTimeout?: number;
|
||||
maxTimeout?: number;
|
||||
onRetry?: (e: Error, attempt: number) => any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator สำหรับการ Retry Function เมื่อเกิด Error
|
||||
* ใช้สำหรับ External Call ที่อาจมีปัญหา Network ชั่วคราว
|
||||
*/
|
||||
export function Retry(options: RetryOptions = {}) {
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string,
|
||||
descriptor: PropertyDescriptor,
|
||||
) {
|
||||
const originalMethod = descriptor.value;
|
||||
const logger = new Logger('RetryDecorator');
|
||||
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
return retry(
|
||||
// ✅ ระบุ Type ให้กับ bail และ attempt เพื่อแก้ Implicit any
|
||||
async (bail: (e: Error) => void, attempt: number) => {
|
||||
try {
|
||||
return await originalMethod.apply(this, args);
|
||||
} catch (error) {
|
||||
// ✅ Cast error เป็น Error Object เพื่อแก้ปัญหา 'unknown'
|
||||
const err = error as Error;
|
||||
|
||||
if (options.onRetry) {
|
||||
options.onRetry(err, attempt);
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Attempt ${attempt} failed for ${propertyKey}. Error: ${err.message}`, // ✅ ใช้ err.message
|
||||
);
|
||||
|
||||
// ถ้าต้องการให้หยุด Retry ทันทีในบางเงื่อนไข สามารถเรียก bail(err) ได้ที่นี่
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
retries: options.retries || 3,
|
||||
factor: options.factor || 2,
|
||||
minTimeout: options.minTimeout || 1000,
|
||||
maxTimeout: options.maxTimeout || 5000,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
70
backend/src/common/file-storage/file-cleanup.service.ts
Normal file
70
backend/src/common/file-storage/file-cleanup.service.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// File: src/common/file-storage/file-cleanup.service.ts
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, LessThan } from 'typeorm';
|
||||
import * as fs from 'fs-extra';
|
||||
import { Attachment } from './entities/attachment.entity';
|
||||
|
||||
@Injectable()
|
||||
export class FileCleanupService {
|
||||
private readonly logger = new Logger(FileCleanupService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Attachment)
|
||||
private attachmentRepository: Repository<Attachment>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* รันทุกวันเวลาเที่ยงคืน (00:00)
|
||||
* ลบไฟล์ชั่วคราว (isTemporary = true) ที่หมดอายุแล้ว (expiresAt < now)
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async handleCleanup() {
|
||||
this.logger.log('Running temporary file cleanup job...');
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// 1. ค้นหาไฟล์ที่หมดอายุ
|
||||
const expiredAttachments = await this.attachmentRepository.find({
|
||||
where: {
|
||||
isTemporary: true,
|
||||
expiresAt: LessThan(now),
|
||||
},
|
||||
});
|
||||
|
||||
if (expiredAttachments.length === 0) {
|
||||
this.logger.log('No expired files found.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Found ${expiredAttachments.length} expired files. Deleting...`,
|
||||
);
|
||||
|
||||
let deletedCount = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const att of expiredAttachments) {
|
||||
try {
|
||||
// 2. ลบไฟล์จริงออกจาก Disk
|
||||
if (await fs.pathExists(att.filePath)) {
|
||||
await fs.remove(att.filePath);
|
||||
}
|
||||
|
||||
// 3. ลบ Record ออกจาก Database
|
||||
await this.attachmentRepository.remove(att);
|
||||
deletedCount++;
|
||||
} catch (error) {
|
||||
// ✅ แก้ไข: Cast error เป็น Error object เพื่อเข้าถึง .message
|
||||
const errMessage = (error as Error).message;
|
||||
this.logger.error(`Failed to delete file ID ${att.id}: ${errMessage}`);
|
||||
errors.push(att.id);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Cleanup complete. Deleted: ${deletedCount}, Failed: ${errors.length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
// File: src/common/file-storage/file-storage.controller.ts
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Delete, // ✅ Import Delete
|
||||
Param,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
@@ -8,12 +12,16 @@ import {
|
||||
ParseFilePipe,
|
||||
MaxFileSizeValidator,
|
||||
FileTypeValidator,
|
||||
Res,
|
||||
StreamableFile,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { FileStorageService } from './file-storage.service.js';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard.js';
|
||||
|
||||
// ✅ 1. สร้าง Interface เพื่อระบุ Type ของ Request
|
||||
// Interface เพื่อระบุ Type ของ Request ที่ผ่าน JwtAuthGuard มาแล้ว
|
||||
interface RequestWithUser {
|
||||
user: {
|
||||
userId: number;
|
||||
@@ -33,17 +41,56 @@ export class FileStorageController {
|
||||
new ParseFilePipe({
|
||||
validators: [
|
||||
new MaxFileSizeValidator({ maxSize: 50 * 1024 * 1024 }), // 50MB
|
||||
// ตรวจสอบประเภทไฟล์ (Regex)
|
||||
// ตรวจสอบประเภทไฟล์ (Regex) - รวม image, pdf, docs, zip
|
||||
new FileTypeValidator({
|
||||
fileType: /(pdf|msword|openxmlformats|zip|octet-stream)/,
|
||||
fileType:
|
||||
/(pdf|msword|openxmlformats|zip|octet-stream|image|jpeg|png)/,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
file: Express.Multer.File,
|
||||
@Request() req: RequestWithUser, // ✅ 2. ระบุ Type ตรงนี้แทน any
|
||||
@Request() req: RequestWithUser,
|
||||
) {
|
||||
// ส่ง userId จาก Token ไปด้วย
|
||||
return this.fileStorageService.upload(file, req.user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint สำหรับดาวน์โหลดไฟล์
|
||||
* GET /files/:id/download
|
||||
*/
|
||||
@Get(':id/download')
|
||||
async downloadFile(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<StreamableFile> {
|
||||
const { stream, attachment } = await this.fileStorageService.download(id);
|
||||
|
||||
// Encode ชื่อไฟล์เพื่อรองรับภาษาไทยและตัวอักษรพิเศษใน Header
|
||||
const encodedFilename = encodeURIComponent(attachment.originalFilename);
|
||||
|
||||
res.set({
|
||||
'Content-Type': attachment.mimeType,
|
||||
// บังคับให้ browser ดาวน์โหลดไฟล์ แทนการ preview
|
||||
'Content-Disposition': `attachment; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`,
|
||||
'Content-Length': attachment.fileSize,
|
||||
});
|
||||
|
||||
return new StreamableFile(stream);
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ NEW: Delete Endpoint
|
||||
* DELETE /files/:id
|
||||
*/
|
||||
@Delete(':id')
|
||||
async deleteFile(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Request() req: RequestWithUser,
|
||||
) {
|
||||
// ส่ง userId ไปด้วยเพื่อตรวจสอบความเป็นเจ้าของ
|
||||
await this.fileStorageService.delete(id, req.user.userId);
|
||||
return { message: 'File deleted successfully', id };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ScheduleModule } from '@nestjs/schedule'; // ✅ Import
|
||||
import { FileStorageService } from './file-storage.service.js';
|
||||
import { FileStorageController } from './file-storage.controller.js';
|
||||
import { FileCleanupService } from './file-cleanup.service.js'; // ✅ Import
|
||||
import { Attachment } from './entities/attachment.entity.js';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Attachment])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Attachment]),
|
||||
ScheduleModule.forRoot(), // ✅ เปิดใช้งาน Cron Job],
|
||||
],
|
||||
controllers: [FileStorageController],
|
||||
providers: [FileStorageService],
|
||||
providers: [
|
||||
FileStorageService,
|
||||
FileCleanupService, // ✅ Register Provider
|
||||
],
|
||||
exports: [FileStorageService], // Export ให้ Module อื่น (เช่น Correspondence) เรียกใช้ตอน Commit
|
||||
})
|
||||
export class FileStorageModule {}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// File: src/common/file-storage/file-storage.service.ts
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -12,6 +13,7 @@ import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Attachment } from './entities/attachment.entity.js';
|
||||
import { ForbiddenException } from '@nestjs/common'; // ✅ Import เพิ่ม
|
||||
|
||||
@Injectable()
|
||||
export class FileStorageService {
|
||||
@@ -29,7 +31,7 @@ export class FileStorageService {
|
||||
? '/share/dms-data'
|
||||
: path.join(process.cwd(), 'uploads');
|
||||
|
||||
// สร้างโฟลเดอร์รอไว้เลยถ้ายังไม่มี
|
||||
// สร้างโฟลเดอร์ temp รอไว้เลยถ้ายังไม่มี
|
||||
fs.ensureDirSync(path.join(this.uploadRoot, 'temp'));
|
||||
}
|
||||
|
||||
@@ -75,11 +77,20 @@ export class FileStorageService {
|
||||
* เมธอดนี้จะถูกเรียกโดย Service อื่น (เช่น CorrespondenceService) เมื่อกด Save
|
||||
*/
|
||||
async commit(tempIds: string[]): Promise<Attachment[]> {
|
||||
if (!tempIds || tempIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const attachments = await this.attachmentRepository.find({
|
||||
where: { tempId: In(tempIds), isTemporary: true },
|
||||
});
|
||||
|
||||
if (attachments.length !== tempIds.length) {
|
||||
// แจ้งเตือนแต่อาจจะไม่ throw ถ้าต้องการให้ process ต่อไปได้บางส่วน (ขึ้นอยู่กับ business logic)
|
||||
// แต่เพื่อความปลอดภัยควรแจ้งว่าไฟล์ไม่ครบ
|
||||
this.logger.warn(
|
||||
`Expected ${tempIds.length} files to commit, but found ${attachments.length}`,
|
||||
);
|
||||
throw new NotFoundException('Some files not found or already committed');
|
||||
}
|
||||
|
||||
@@ -98,21 +109,27 @@ export class FileStorageService {
|
||||
|
||||
try {
|
||||
// ย้ายไฟล์
|
||||
await fs.move(oldPath, newPath, { overwrite: true });
|
||||
if (await fs.pathExists(oldPath)) {
|
||||
await fs.move(oldPath, newPath, { overwrite: true });
|
||||
|
||||
// อัปเดตข้อมูลใน DB
|
||||
att.filePath = newPath;
|
||||
att.isTemporary = false;
|
||||
att.tempId = undefined; // เคลียร์ tempId
|
||||
att.expiresAt = undefined; // เคลียร์วันหมดอายุ
|
||||
// อัปเดตข้อมูลใน DB
|
||||
att.filePath = newPath;
|
||||
att.isTemporary = false;
|
||||
att.tempId = null as any; // เคลียร์ tempId (TypeORM อาจต้องการ null แทน undefined สำหรับ nullable)
|
||||
att.expiresAt = null as any; // เคลียร์วันหมดอายุ
|
||||
|
||||
committedAttachments.push(await this.attachmentRepository.save(att));
|
||||
committedAttachments.push(await this.attachmentRepository.save(att));
|
||||
} else {
|
||||
this.logger.error(`File missing during commit: ${oldPath}`);
|
||||
throw new NotFoundException(
|
||||
`File not found on disk: ${att.originalFilename}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to move file from ${oldPath} to ${newPath}`,
|
||||
error,
|
||||
);
|
||||
// ถ้า error ตัวนึง ควรจะ rollback หรือ throw error (ในที่นี้ throw เพื่อให้ Transaction ของผู้เรียกจัดการ)
|
||||
throw new BadRequestException(
|
||||
`Failed to commit file: ${att.originalFilename}`,
|
||||
);
|
||||
@@ -122,7 +139,83 @@ export class FileStorageService {
|
||||
return committedAttachments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download File
|
||||
* ดึงไฟล์มาเป็น Stream เพื่อส่งกลับไปให้ Controller
|
||||
*/
|
||||
async download(
|
||||
id: number,
|
||||
): Promise<{ stream: fs.ReadStream; attachment: Attachment }> {
|
||||
// 1. ค้นหาข้อมูลไฟล์จาก DB
|
||||
const attachment = await this.attachmentRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
throw new NotFoundException(`Attachment #${id} not found`);
|
||||
}
|
||||
|
||||
// 2. ตรวจสอบว่าไฟล์มีอยู่จริงบน Disk หรือไม่
|
||||
const filePath = attachment.filePath;
|
||||
if (!fs.existsSync(filePath)) {
|
||||
this.logger.error(`File missing on disk: ${filePath}`);
|
||||
throw new NotFoundException('File not found on server storage');
|
||||
}
|
||||
|
||||
// 3. สร้าง Read Stream (มีประสิทธิภาพกว่าการโหลดทั้งไฟล์เข้า Memory)
|
||||
const stream = fs.createReadStream(filePath);
|
||||
|
||||
return { stream, attachment };
|
||||
}
|
||||
|
||||
private calculateChecksum(buffer: Buffer): string {
|
||||
return crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ NEW: Delete File
|
||||
* ลบไฟล์ออกจาก Disk และ Database
|
||||
*/
|
||||
async delete(id: number, userId: number): Promise<void> {
|
||||
// 1. ค้นหาไฟล์
|
||||
const attachment = await this.attachmentRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
throw new NotFoundException(`Attachment #${id} not found`);
|
||||
}
|
||||
|
||||
// 2. ตรวจสอบความเป็นเจ้าของ (Security Check)
|
||||
// อนุญาตให้ลบถ้าเป็นคนอัปโหลดเอง
|
||||
// (ในอนาคตอาจเพิ่มเงื่อนไข OR User เป็น Admin/Document Control)
|
||||
if (attachment.uploadedByUserId !== userId) {
|
||||
this.logger.warn(
|
||||
`User ${userId} tried to delete file ${id} owned by ${attachment.uploadedByUserId}`,
|
||||
);
|
||||
throw new ForbiddenException('You are not allowed to delete this file');
|
||||
}
|
||||
|
||||
// 3. ลบไฟล์ออกจาก Disk
|
||||
try {
|
||||
if (await fs.pathExists(attachment.filePath)) {
|
||||
await fs.remove(attachment.filePath);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`File not found on disk during deletion: ${attachment.filePath}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to delete file from disk: ${attachment.filePath}`,
|
||||
error,
|
||||
);
|
||||
throw new BadRequestException('Failed to delete file from storage');
|
||||
}
|
||||
|
||||
// 4. ลบ Record ออกจาก Database
|
||||
await this.attachmentRepository.remove(attachment);
|
||||
|
||||
this.logger.log(`File deleted: ${id} by user ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Cache } from 'cache-manager';
|
||||
import type { Cache } from 'cache-manager';
|
||||
import { BYPASS_MAINTENANCE_KEY } from '../decorators/bypass-maintenance.decorator';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// File: src/common/interceptors/idempotency.interceptor.ts
|
||||
// บันทึกการแก้ไข: สร้าง IdempotencyInterceptor เพื่อป้องกันการทำรายการซ้ำ (T1.1)
|
||||
// บันทึกการแก้ไข: แก้ไข TS18046 โดยการตรวจสอบ Type ของ err ใน catch block
|
||||
|
||||
import {
|
||||
CallHandler,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Cache } from 'cache-manager';
|
||||
import type { Cache } from 'cache-manager';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { Request } from 'express';
|
||||
@@ -29,43 +30,37 @@ export class IdempotencyInterceptor implements NestInterceptor {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const method = request.method;
|
||||
|
||||
// 1. ตรวจสอบว่าควรใช้ Idempotency หรือไม่ (เฉพาะ POST, PUT, DELETE)
|
||||
if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
// 2. ดึง Idempotency-Key จาก Header
|
||||
const idempotencyKey = request.headers['idempotency-key'] as string;
|
||||
|
||||
// ถ้าไม่มี Key ส่งมา ให้ทำงานปกติ (หรือจะบังคับให้ Error ก็ได้ ตาม Policy)
|
||||
if (!idempotencyKey) {
|
||||
// หมายเหตุ: ในระบบที่ Strict อาจจะ throw BadRequestException ถ้าไม่มี Key สำหรับ Transaction สำคัญ
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const cacheKey = `idempotency:${idempotencyKey}`;
|
||||
|
||||
// 3. ตรวจสอบใน Redis ว่า Key นี้เคยถูกประมวลผลหรือยัง
|
||||
const cachedResponse = await this.cacheManager.get(cacheKey);
|
||||
|
||||
if (cachedResponse) {
|
||||
this.logger.warn(
|
||||
`Idempotency key detected: ${idempotencyKey}. Returning cached response.`,
|
||||
);
|
||||
// ถ้ามี ให้คืนค่าเดิมกลับไปเลย (เสมือนว่าทำรายการสำเร็จแล้ว)
|
||||
return of(cachedResponse);
|
||||
}
|
||||
|
||||
// 4. ถ้ายังไม่มี ให้ประมวลผลต่อ และบันทึกผลลัพธ์ลง Redis
|
||||
return next.handle().pipe(
|
||||
tap(async (response) => {
|
||||
try {
|
||||
// บันทึก Response ลง Cache (TTL 24 ชั่วโมง หรือตามความเหมาะสม)
|
||||
await this.cacheManager.set(cacheKey, response, 86400 * 1000);
|
||||
} catch (err) {
|
||||
// ✅ Fix: ตรวจสอบว่า err เป็น Error Object หรือไม่ ก่อนเรียก .stack
|
||||
const errorMessage = err instanceof Error ? err.stack : String(err);
|
||||
this.logger.error(
|
||||
`Failed to cache idempotency key ${idempotencyKey}`,
|
||||
err.stack,
|
||||
errorMessage,
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
90
backend/src/common/interceptors/performance.interceptor.ts
Normal file
90
backend/src/common/interceptors/performance.interceptor.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// File: src/modules/monitoring/interceptors/performance.interceptor.ts
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { MetricsService } from '../../modules/monitoring/services/metrics.service';
|
||||
|
||||
@Injectable()
|
||||
export class PerformanceInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(PerformanceInterceptor.name);
|
||||
|
||||
constructor(private readonly metricsService: MetricsService) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
// ข้ามการวัดผลสำหรับ Endpoint /metrics และ /health เพื่อลด Noise
|
||||
const req = context.switchToHttp().getRequest();
|
||||
if (req.url === '/metrics' || req.url === '/health') {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const method = req.method;
|
||||
const url = req.route ? req.route.path : req.url; // ใช้ Route path แทน Full URL เพื่อลด Cardinality
|
||||
const startTime = process.hrtime();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: (data) => {
|
||||
this.recordMetrics(context, method, url, startTime, 200); // สมมติ 200 หรือดึงจาก Response จริง
|
||||
},
|
||||
error: (err) => {
|
||||
const status = err.status || 500;
|
||||
this.recordMetrics(context, method, url, startTime, status);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* บันทึกข้อมูลลง Metrics Service และ Logger
|
||||
*/
|
||||
private recordMetrics(
|
||||
context: ExecutionContext,
|
||||
method: string,
|
||||
route: string,
|
||||
startTime: [number, number],
|
||||
statusCode: number,
|
||||
) {
|
||||
const res = context.switchToHttp().getResponse();
|
||||
const finalStatus = res.statusCode || statusCode;
|
||||
|
||||
// คำนวณระยะเวลา (Seconds)
|
||||
const diff = process.hrtime(startTime);
|
||||
const durationInSeconds = diff[0] + diff[1] / 1e9;
|
||||
const durationInMs = durationInSeconds * 1000;
|
||||
|
||||
// 1. บันทึก Metrics (Prometheus)
|
||||
this.metricsService.httpRequestsTotal.inc({
|
||||
method,
|
||||
route,
|
||||
status_code: finalStatus.toString(),
|
||||
});
|
||||
|
||||
this.metricsService.httpRequestDuration.observe(
|
||||
{
|
||||
method,
|
||||
route,
|
||||
status_code: finalStatus.toString(),
|
||||
},
|
||||
durationInSeconds,
|
||||
);
|
||||
|
||||
// 2. บันทึก Log (Winston JSON) - เฉพาะ Request ที่ช้าเกิน 200ms หรือ Error
|
||||
// ตาม Req 6.5.1 API Response Time Target < 200ms
|
||||
if (durationInMs > 200 || finalStatus >= 400) {
|
||||
this.logger.log({
|
||||
message: 'HTTP Request Performance',
|
||||
method,
|
||||
route,
|
||||
statusCode: finalStatus,
|
||||
durationMs: durationInMs,
|
||||
level: finalStatus >= 500 ? 'error' : 'warn',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
9
backend/src/common/resilience/resilience.module.ts
Normal file
9
backend/src/common/resilience/resilience.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// File: src/common/resilience/resilience.module.ts
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [],
|
||||
exports: [],
|
||||
})
|
||||
export class ResilienceModule {}
|
||||
Reference in New Issue
Block a user