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
+74
View File
@@ -44,6 +44,9 @@ var __metadata = (this && this.__metadata) || function (k, v) {
var __param = (this && this.__param) || function (paramIndex, decorator) { var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); } return function (target, key) { decorator(target, key, paramIndex); }
}; };
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.CommentGateway = void 0; exports.CommentGateway = void 0;
const dotenv = __importStar(require("dotenv")); const dotenv = __importStar(require("dotenv"));
@@ -51,8 +54,11 @@ const path = __importStar(require("path"));
const envPath = path.resolve(process.cwd(), '..', '.env'); const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath }); dotenv.config({ path: envPath });
require("reflect-metadata"); require("reflect-metadata");
const fs = __importStar(require("fs"));
const sharp_1 = __importDefault(require("sharp"));
const core_1 = require("@nestjs/core"); const core_1 = require("@nestjs/core");
const common_1 = require("@nestjs/common"); const common_1 = require("@nestjs/common");
const platform_express_1 = require("@nestjs/platform-express");
const websockets_1 = require("@nestjs/websockets"); const websockets_1 = require("@nestjs/websockets");
const socket_io_1 = require("socket.io"); const socket_io_1 = require("socket.io");
const prisma_service_1 = require("../prisma/prisma.service"); const prisma_service_1 = require("../prisma/prisma.service");
@@ -62,6 +68,7 @@ const jwt_1 = require("@nestjs/jwt");
const jwt_auth_guard_1 = require("./auth/jwt-auth.guard"); const jwt_auth_guard_1 = require("./auth/jwt-auth.guard");
const jwt_strategy_1 = require("./auth/jwt.strategy"); const jwt_strategy_1 = require("./auth/jwt.strategy");
const rbac_middleware_1 = require("./common/rbac.middleware"); const rbac_middleware_1 = require("./common/rbac.middleware");
const UPLOAD_ROOT = path.join(process.cwd(), 'uploads');
async function bootstrap() { async function bootstrap() {
if (!process.env.DATABASE_URL) { 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); 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);
@@ -72,6 +79,12 @@ async function bootstrap() {
const app = await core_1.NestFactory.create(AppModule); const app = await core_1.NestFactory.create(AppModule);
app.setGlobalPrefix('api/v1'); app.setGlobalPrefix('api/v1');
app.enableCors(); app.enableCors();
if (!fs.existsSync(UPLOAD_ROOT)) {
fs.mkdirSync(UPLOAD_ROOT, { recursive: true });
}
app.useStaticAssets(UPLOAD_ROOT, {
prefix: '/uploads/',
});
await app.listen(3001); await app.listen(3001);
console.log(`🚀 Server is running on: http://localhost:3001`); console.log(`🚀 Server is running on: http://localhost:3001`);
} }
@@ -395,6 +408,15 @@ let TourController = class TourController {
}); });
} }
async deleteTour(id) { async deleteTour(id) {
const photos = await this.prisma.photo.findMany({
where: { tourId: id }
});
for (const photo of photos) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) {
fs.unlinkSync(displayFilePath);
}
}
await this.prisma.tour.delete({ await this.prisma.tour.delete({
where: { id }, where: { id },
}); });
@@ -625,6 +647,42 @@ let TourController = class TourController {
}); });
return { message: 'Đã xóa thành viên khỏi tour' }; return { message: 'Đã xóa thành viên khỏi tour' };
} }
async uploadPhotos(tourId, files, req) {
if (!files || files.length === 0) {
throw new common_1.BadRequestException('Vui lòng chọn ít nhất một ảnh');
}
const uploaderId = req.user.id;
const memberOriginalDir = path.join(UPLOAD_ROOT, 'members', uploaderId, 'originals');
const tourDisplayPath = path.join(UPLOAD_ROOT, 'tours');
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);
await fs.promises.writeFile(originalFilePath, file.buffer);
await (0, sharp_1.default)(file.buffer)
.resize(2560, 2560, {
fit: 'inside',
withoutEnlargement: true
})
.jpeg({ quality: 85 })
.toFile(displayFilePath);
return this.prisma.photo.create({
data: {
tourId: tourId,
uploaderId: uploaderId,
imageUrl: `/uploads/tours/${filename}`,
originalUrl: `/uploads/members/${uploaderId}/originals/${filename}`,
privacy: 'TOUR_ONLY',
}
});
}));
}
}; };
__decorate([ __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
@@ -774,6 +832,17 @@ __decorate([
__metadata("design:paramtypes", [String, String]), __metadata("design:paramtypes", [String, String]),
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourController.prototype, "removeMember", null); ], TourController.prototype, "removeMember", null);
__decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, rbac_middleware_1.TourRoleGuard),
(0, common_1.Post)(':tourId/photos'),
(0, common_1.UseInterceptors)((0, platform_express_1.FilesInterceptor)('images', 10)),
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.UploadedFiles)()),
__param(2, (0, common_1.Req)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String, Array, Object]),
__metadata("design:returntype", Promise)
], TourController.prototype, "uploadPhotos", null);
TourController = __decorate([ TourController = __decorate([
(0, common_1.Controller)('tours'), (0, common_1.Controller)('tours'),
__metadata("design:paramtypes", [prisma_service_1.PrismaService]) __metadata("design:paramtypes", [prisma_service_1.PrismaService])
@@ -1035,8 +1104,13 @@ let UserController = class UserController {
if (adminCount <= 1) if (adminCount <= 1)
throw new common_1.BadRequestException('Không thể xóa Quản trị viên cuối cùng'); throw new common_1.BadRequestException('Không thể xóa Quản trị viên cuối cùng');
} }
const memberDir = path.join(UPLOAD_ROOT, 'members', id);
await this.prisma.photo.deleteMany({ where: { uploaderId: id } });
await this.prisma.tourParticipant.deleteMany({ where: { userId: id } }); await this.prisma.tourParticipant.deleteMany({ where: { userId: id } });
await this.prisma.user.delete({ where: { id } }); await this.prisma.user.delete({ where: { id } });
if (fs.existsSync(memberDir)) {
fs.rmSync(memberDir, { recursive: true, force: true });
}
return { message: 'Đã xóa người dùng' }; return { message: 'Đã xóa người dùng' };
} }
async toggleBlock(id) { async toggleBlock(id) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -35,6 +35,7 @@
"pg": "^8.12.0", "pg": "^8.12.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"sharp": "^0.35.1",
"socket.io": "^4.8.3" "socket.io": "^4.8.3"
} }
} }
+1
View File
@@ -184,6 +184,7 @@ model Photo {
locationId String? locationId String?
uploaderId String uploaderId String
imageUrl String imageUrl String
originalUrl String?
capturedAt DateTime @default(now()) capturedAt DateTime @default(now())
metadata Json? metadata Json?
privacy PrivacyLevel @default(TOUR_ONLY) privacy PrivacyLevel @default(TOUR_ONLY)
+98 -2
View File
@@ -6,8 +6,13 @@ const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath }); dotenv.config({ path: envPath });
import 'reflect-metadata'; import 'reflect-metadata';
import * as fs from 'fs';
import sharp from 'sharp';
import { NestFactory } from '@nestjs/core'; 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 { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -18,6 +23,9 @@ import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { JwtStrategy } from './auth/jwt.strategy'; import { JwtStrategy } from './auth/jwt.strategy';
import { TourRoleGuard } from './common/rbac.middleware'; 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() { async function bootstrap() {
if (!process.env.DATABASE_URL) { 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); 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('DATABASE_URL:', process.env.DATABASE_URL);
console.log('===================================='); 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'); app.setGlobalPrefix('api/v1');
// Bật CORS để cho phép Frontend kết nối API không bị chặn // Bật CORS để cho phép Frontend kết nối API không bị chặn
app.enableCors(); 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); await app.listen(3001);
console.log(`🚀 Server is running on: http://localhost:3001`); console.log(`🚀 Server is running on: http://localhost:3001`);
} }
@@ -382,6 +401,19 @@ class TourController {
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Delete(':id') @Delete(':id')
async deleteTour(@Param('id', ParseUUIDPipe) id: string) { 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 // Xóa tour sẽ xóa cascade các Leg, Location, Expense nhờ config onDelete: Cascade trong schema
await this.prisma.tour.delete({ await this.prisma.tour.delete({
where: { id }, where: { id },
@@ -653,6 +685,58 @@ class TourController {
}); });
return { message: 'Đã xóa thành viên khỏi tour' }; 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') @Controller('locations')
@@ -914,8 +998,20 @@ class UserController {
const adminCount = await this.prisma.user.count({ where: { isAdmin: true } }); 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'); 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.tourParticipant.deleteMany({ where: { userId: id } });
await this.prisma.user.delete({ where: { 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' }; return { message: 'Đã xóa người dùng' };
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB