feat: thêm chức năng upload ảnh

This commit is contained in:
2026-06-16 17:14:22 +07:00
parent 989d60643b
commit 88b2182789
6 changed files with 175 additions and 3 deletions
+98 -2
View File
@@ -6,8 +6,13 @@ const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath });
import 'reflect-metadata';
import * as fs from 'fs';
import sharp from 'sharp';
import { NestFactory } from '@nestjs/core';
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException, Injectable } from '@nestjs/common';
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException, Injectable, UseInterceptors, UploadedFiles } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import { FilesInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { PrismaService } from '../prisma/prisma.service';
@@ -18,6 +23,9 @@ import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { JwtStrategy } from './auth/jwt.strategy';
import { TourRoleGuard } from './common/rbac.middleware';
// Khai báo vị trí thư mục upload cụ thể
const UPLOAD_ROOT = path.join(process.cwd(), 'uploads');
async function bootstrap() {
if (!process.env.DATABASE_URL) {
throw new Error('❌ DATABASE_URL không tồn tại trong biến môi trường! Kiểm tra file .env tại: ' + envPath);
@@ -28,11 +36,22 @@ async function bootstrap() {
console.log('DATABASE_URL:', process.env.DATABASE_URL);
console.log('====================================');
const app = await NestFactory.create(AppModule);
// Chuyển sang dùng NestExpressApplication để cấu hình static assets
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.setGlobalPrefix('api/v1');
// Bật CORS để cho phép Frontend kết nối API không bị chặn
app.enableCors();
// Tự động tạo thư mục upload nếu chưa tồn tại
if (!fs.existsSync(UPLOAD_ROOT)) {
fs.mkdirSync(UPLOAD_ROOT, { recursive: true });
}
// Khai báo vị trí để ảnh upload có thể truy cập được từ bên ngoài qua URL
app.useStaticAssets(UPLOAD_ROOT, {
prefix: '/uploads/',
});
await app.listen(3001);
console.log(`🚀 Server is running on: http://localhost:3001`);
}
@@ -382,6 +401,19 @@ class TourController {
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Delete(':id')
async deleteTour(@Param('id', ParseUUIDPipe) id: string) {
// 1. Lấy danh sách ảnh thuộc tour để có đường dẫn file 2K
const photos = await this.prisma.photo.findMany({
where: { tourId: id }
});
// 2. Xóa các file vật lý (ảnh 2K) trong thư mục uploads/tours
for (const photo of photos) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) {
fs.unlinkSync(displayFilePath);
}
}
// Xóa tour sẽ xóa cascade các Leg, Location, Expense nhờ config onDelete: Cascade trong schema
await this.prisma.tour.delete({
where: { id },
@@ -653,6 +685,58 @@ class TourController {
});
return { message: 'Đã xóa thành viên khỏi tour' };
}
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/photos')
@UseInterceptors(FilesInterceptor('images', 10)) // Chuyển sang memory storage để xử lý ảnh trước khi lưu
async uploadPhotos(@Param('tourId', ParseUUIDPipe) tourId: string, @UploadedFiles() files: any[], @Req() req: any) {
if (!files || files.length === 0) {
throw new BadRequestException('Vui lòng chọn ít nhất một ảnh');
}
const uploaderId = req.user.id;
// Đường dẫn ảnh gốc cho từng thành viên
const memberOriginalDir = path.join(UPLOAD_ROOT, 'members', uploaderId, 'originals');
// Đường dẫn ảnh hiển thị chung của Tour
const tourDisplayPath = path.join(UPLOAD_ROOT, 'tours');
// Đảm bảo các thư mục tồn tại
if (!fs.existsSync(memberOriginalDir)) fs.mkdirSync(memberOriginalDir, { recursive: true });
if (!fs.existsSync(tourDisplayPath)) fs.mkdirSync(tourDisplayPath, { recursive: true });
return Promise.all(files.map(async (file) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const extension = path.extname(file.originalname).toLowerCase() || '.jpg';
const filename = `${uniqueSuffix}${extension}`;
const originalFilePath = path.join(memberOriginalDir, filename);
const displayFilePath = path.join(tourDisplayPath, filename);
// 1. Lưu ảnh gốc nguyên bản vào thư mục riêng của thành viên
await fs.promises.writeFile(originalFilePath, file.buffer);
// 2. Xử lý ảnh để hiển thị (Độ phân giải 2K: tối đa 2560px)
// Sử dụng Sharp để resize và tối ưu dung lượng ảnh
await sharp(file.buffer)
.resize(2560, 2560, {
fit: 'inside', // Giữ nguyên tỷ lệ, không vượt quá khung 2K
withoutEnlargement: true // Nếu ảnh nhỏ hơn 2K thì giữ nguyên, không làm vỡ ảnh
})
.jpeg({ quality: 85 }) // Tối ưu chất lượng/dung lượng
.toFile(displayFilePath);
// 3. Lưu thông tin vào Database (Lưu cả 2 đường dẫn)
return this.prisma.photo.create({
data: {
tourId: tourId,
uploaderId: uploaderId,
imageUrl: `/uploads/tours/${filename}`, // URL ảnh 2K dùng để render
originalUrl: `/uploads/members/${uploaderId}/originals/${filename}`, // URL ảnh gốc để tải xuống
privacy: 'TOUR_ONLY',
}
});
}));
}
}
@Controller('locations')
@@ -914,8 +998,20 @@ class UserController {
const adminCount = await this.prisma.user.count({ where: { isAdmin: true } });
if (adminCount <= 1) throw new BadRequestException('Không thể xóa Quản trị viên cuối cùng');
}
// 1. Xác định thư mục chứa ảnh gốc của thành viên
const memberDir = path.join(UPLOAD_ROOT, 'members', id);
// 2. Xóa các ràng buộc và dữ liệu trong DB
await this.prisma.photo.deleteMany({ where: { uploaderId: id } });
await this.prisma.tourParticipant.deleteMany({ where: { userId: id } });
await this.prisma.user.delete({ where: { id } });
// 3. Xóa vật lý toàn bộ thư mục ảnh gốc
if (fs.existsSync(memberDir)) {
fs.rmSync(memberDir, { recursive: true, force: true });
}
return { message: 'Đã xóa người dùng' };
}