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) {
|
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) {
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 |
Reference in New Issue
Block a user