feat: thêm chức năng upload ảnh
This commit is contained in:
Vendored
+74
@@ -44,6 +44,9 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
||||
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 });
|
||||
exports.CommentGateway = void 0;
|
||||
const dotenv = __importStar(require("dotenv"));
|
||||
@@ -51,8 +54,11 @@ const path = __importStar(require("path"));
|
||||
const envPath = path.resolve(process.cwd(), '..', '.env');
|
||||
dotenv.config({ path: envPath });
|
||||
require("reflect-metadata");
|
||||
const fs = __importStar(require("fs"));
|
||||
const sharp_1 = __importDefault(require("sharp"));
|
||||
const core_1 = require("@nestjs/core");
|
||||
const common_1 = require("@nestjs/common");
|
||||
const platform_express_1 = require("@nestjs/platform-express");
|
||||
const websockets_1 = require("@nestjs/websockets");
|
||||
const socket_io_1 = require("socket.io");
|
||||
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_strategy_1 = require("./auth/jwt.strategy");
|
||||
const rbac_middleware_1 = require("./common/rbac.middleware");
|
||||
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);
|
||||
@@ -72,6 +79,12 @@ async function bootstrap() {
|
||||
const app = await core_1.NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.enableCors();
|
||||
if (!fs.existsSync(UPLOAD_ROOT)) {
|
||||
fs.mkdirSync(UPLOAD_ROOT, { recursive: true });
|
||||
}
|
||||
app.useStaticAssets(UPLOAD_ROOT, {
|
||||
prefix: '/uploads/',
|
||||
});
|
||||
await app.listen(3001);
|
||||
console.log(`🚀 Server is running on: http://localhost:3001`);
|
||||
}
|
||||
@@ -395,6 +408,15 @@ let TourController = class TourController {
|
||||
});
|
||||
}
|
||||
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({
|
||||
where: { id },
|
||||
});
|
||||
@@ -625,6 +647,42 @@ let TourController = class TourController {
|
||||
});
|
||||
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([
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||
@@ -774,6 +832,17 @@ __decorate([
|
||||
__metadata("design:paramtypes", [String, String]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], 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([
|
||||
(0, common_1.Controller)('tours'),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||
@@ -1035,8 +1104,13 @@ let UserController = class UserController {
|
||||
if (adminCount <= 1)
|
||||
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.user.delete({ where: { id } });
|
||||
if (fs.existsSync(memberDir)) {
|
||||
fs.rmSync(memberDir, { recursive: true, force: true });
|
||||
}
|
||||
return { message: 'Đã xóa người dùng' };
|
||||
}
|
||||
async toggleBlock(id) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -35,6 +35,7 @@
|
||||
"pg": "^8.12.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"sharp": "^0.35.1",
|
||||
"socket.io": "^4.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +184,7 @@ model Photo {
|
||||
locationId String?
|
||||
uploaderId String
|
||||
imageUrl String
|
||||
originalUrl String?
|
||||
capturedAt DateTime @default(now())
|
||||
metadata Json?
|
||||
privacy PrivacyLevel @default(TOUR_ONLY)
|
||||
|
||||
+98
-2
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Reference in New Issue
Block a user