fix: ảnh phải giữ nguyên hướng chụp
This commit is contained in:
+231
-3
@@ -1168,6 +1168,10 @@ class TourController {
|
||||
if (!fs.existsSync(memberOriginalDir)) fs.mkdirSync(memberOriginalDir, { recursive: true });
|
||||
if (!fs.existsSync(tourDisplayPath)) fs.mkdirSync(tourDisplayPath, { recursive: true });
|
||||
|
||||
// Trích xuất tọa độ GPS dự phòng từ request body gửi từ frontend
|
||||
const bodyLat = req.body.latitude ? parseFloat(req.body.latitude) : undefined;
|
||||
const bodyLng = req.body.longitude ? parseFloat(req.body.longitude) : undefined;
|
||||
|
||||
return Promise.all(files.map(async (file) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const originalExtension = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
@@ -1180,7 +1184,34 @@ class TourController {
|
||||
// 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)
|
||||
// 2. Trích xuất GPS từ EXIF
|
||||
let lat: number | undefined;
|
||||
let lng: number | undefined;
|
||||
try {
|
||||
const gps = await exifr.gps(file.buffer);
|
||||
if (gps && typeof gps.latitude === 'number' && typeof gps.longitude === 'number') {
|
||||
lat = gps.latitude;
|
||||
lng = gps.longitude;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[EXIF GPS] Không thể giải nén GPS từ EXIF ảnh:', e.message);
|
||||
}
|
||||
|
||||
// 3. Nếu EXIF không có GPS, dùng GPS dự phòng của thiết bị gửi từ Frontend
|
||||
if (lat === undefined || lng === undefined) {
|
||||
if (bodyLat !== undefined && !isNaN(bodyLat) && bodyLng !== undefined && !isNaN(bodyLng)) {
|
||||
lat = bodyLat;
|
||||
lng = bodyLng;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Nếu vẫn không có vị trí nào, ghim tại vị trí mặc định [10.7769, 106.7009]
|
||||
if (lat === undefined || lng === undefined) {
|
||||
lat = 10.7769;
|
||||
lng = 106.7009;
|
||||
}
|
||||
|
||||
// 5. Xử lý ảnh để hiển thị (Độ phân giải 2K: tối đa 2560px)
|
||||
let processBuffer = file.buffer;
|
||||
const isHeic = file.originalname.toLowerCase().endsWith('.heic') || file.originalname.toLowerCase().endsWith('.heif') || file.mimetype === 'image/heic' || file.mimetype === 'image/heif';
|
||||
if (isHeic) {
|
||||
@@ -1198,6 +1229,7 @@ class TourController {
|
||||
|
||||
// Sử dụng Sharp để resize và tối ưu dung lượng ảnh
|
||||
await sharp(processBuffer)
|
||||
.rotate()
|
||||
.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
|
||||
@@ -1205,7 +1237,7 @@ class TourController {
|
||||
.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)
|
||||
// 6. Lưu thông tin vào Database (Lưu cả 2 đường dẫn và metadata GPS)
|
||||
return this.prisma.photo.create({
|
||||
data: {
|
||||
tourId: tourId,
|
||||
@@ -1213,6 +1245,10 @@ class TourController {
|
||||
imageUrl: `/uploads/tours/${displayFilename}`, // URL ảnh 2K dùng để render (luôn là .jpg)
|
||||
originalUrl: `/uploads/members/${uploaderId}/originals/${originalFilename}`, // URL ảnh gốc để tải xuống
|
||||
privacy: 'TOUR_ONLY',
|
||||
metadata: {
|
||||
lat: lat,
|
||||
lng: lng
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
@@ -1586,6 +1622,7 @@ class PhotoController {
|
||||
|
||||
// 6. Xử lý ảnh để hiển thị (kích thước tối đa 2K: 2560px)
|
||||
await sharp(processBuffer)
|
||||
.rotate()
|
||||
.resize(2560, 2560, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 85 })
|
||||
.toFile(displayFilePath);
|
||||
@@ -1972,6 +2009,197 @@ class AdminOtpController {
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/trash-photos')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
class AdminController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
async getTrashPhotos() {
|
||||
// 1. Lấy tất cả ảnh trong DB
|
||||
const dbPhotos = await this.prisma.photo.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
imageUrl: true,
|
||||
originalUrl: true,
|
||||
privacy: true,
|
||||
tourId: true,
|
||||
}
|
||||
});
|
||||
|
||||
const dbImageUrls = new Set(
|
||||
dbPhotos.map(p => p.imageUrl).filter(Boolean)
|
||||
);
|
||||
const dbOriginalUrls = new Set(
|
||||
dbPhotos.map(p => p.originalUrl).filter(Boolean)
|
||||
);
|
||||
|
||||
// 2. Tìm tất cả tệp tin vật lý trên đĩa
|
||||
const allDiskFiles: string[] = [];
|
||||
const getFilesRecursively = (dir: string) => {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
const list = fs.readdirSync(dir);
|
||||
for (const file of list) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
getFilesRecursively(fullPath);
|
||||
} else {
|
||||
allDiskFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getFilesRecursively(UPLOAD_ROOT);
|
||||
|
||||
const trashPhotos: any[] = [];
|
||||
|
||||
// 3. Kiểm tra các tệp tin trên đĩa xem có trong DB không
|
||||
for (const filePath of allDiskFiles) {
|
||||
const relativePath = '/uploads' + filePath.substring(UPLOAD_ROOT.length).replace(/\\/g, '/');
|
||||
if (path.basename(filePath).startsWith('.')) continue;
|
||||
|
||||
const isUsed = dbImageUrls.has(relativePath) || dbOriginalUrls.has(relativePath);
|
||||
|
||||
if (!isUsed) {
|
||||
let size = 0;
|
||||
try {
|
||||
size = fs.statSync(filePath).size;
|
||||
} catch (e) {}
|
||||
|
||||
trashPhotos.push({
|
||||
filePath: filePath,
|
||||
url: relativePath,
|
||||
size: size,
|
||||
reason: 'Tệp tin không tồn tại trong cơ sở dữ liệu (ảnh mồ côi)',
|
||||
type: 'file_only'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Kiểm tra các bản ghi DB mồ côi (không có tourId và privacy không phải PUBLIC)
|
||||
const orphanedDbPhotos = dbPhotos.filter(p => !p.tourId && p.privacy !== 'PUBLIC');
|
||||
for (const dbPhoto of orphanedDbPhotos) {
|
||||
let size = 0;
|
||||
if (dbPhoto.imageUrl) {
|
||||
const fullPath = path.join(process.cwd(), dbPhoto.imageUrl.replace(/^\//, ''));
|
||||
try {
|
||||
if (fs.existsSync(fullPath)) {
|
||||
size += fs.statSync(fullPath).size;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
if (dbPhoto.originalUrl) {
|
||||
const fullPath = path.join(process.cwd(), dbPhoto.originalUrl.replace(/^\//, ''));
|
||||
try {
|
||||
if (fs.existsSync(fullPath)) {
|
||||
size += fs.statSync(fullPath).size;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
trashPhotos.push({
|
||||
id: dbPhoto.id,
|
||||
url: dbPhoto.imageUrl,
|
||||
size: size,
|
||||
reason: 'Bản ghi ảnh riêng tư không gắn với chuyến đi nào',
|
||||
type: 'db_orphaned'
|
||||
});
|
||||
}
|
||||
|
||||
return trashPhotos;
|
||||
}
|
||||
|
||||
@Delete('clean')
|
||||
async cleanTrashPhotos() {
|
||||
const trashList = await this.getTrashPhotos();
|
||||
let deletedCount = 0;
|
||||
let freedSpace = 0;
|
||||
|
||||
for (const item of trashList) {
|
||||
if (item.type === 'file_only') {
|
||||
if (item.filePath && fs.existsSync(item.filePath)) {
|
||||
try {
|
||||
fs.unlinkSync(item.filePath);
|
||||
deletedCount++;
|
||||
freedSpace += item.size;
|
||||
} catch (e) {}
|
||||
}
|
||||
} else if (item.type === 'db_orphaned') {
|
||||
const photo = await this.prisma.photo.findUnique({ where: { id: item.id } });
|
||||
if (photo) {
|
||||
if (photo.imageUrl) {
|
||||
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
|
||||
if (fs.existsSync(displayFilePath)) {
|
||||
try { fs.unlinkSync(displayFilePath); } catch (e) {}
|
||||
}
|
||||
}
|
||||
if (photo.originalUrl) {
|
||||
const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, ''));
|
||||
if (fs.existsSync(originalFilePath)) {
|
||||
try { fs.unlinkSync(originalFilePath); } catch (e) {}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.prisma.photo.delete({ where: { id: item.id } });
|
||||
deletedCount++;
|
||||
freedSpace += item.size;
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Đã dọn dẹp thành công. Đã xóa ${deletedCount} mục, giải phóng ${(freedSpace / (1024 * 1024)).toFixed(2)} MB.`,
|
||||
deletedCount,
|
||||
freedSpace
|
||||
};
|
||||
}
|
||||
|
||||
@Delete('delete-single')
|
||||
async deleteSingleTrash(@Body() body: { type: 'file_only' | 'db_orphaned'; filePath?: string; id?: string }) {
|
||||
const { type, filePath, id } = body;
|
||||
|
||||
if (type === 'file_only') {
|
||||
if (!filePath) throw new BadRequestException('Đường dẫn tệp tin không hợp lệ');
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
if (!resolvedPath.startsWith(UPLOAD_ROOT)) {
|
||||
throw new ForbiddenException('Không được phép xóa tệp tin ngoài thư mục uploads');
|
||||
}
|
||||
|
||||
if (fs.existsSync(resolvedPath)) {
|
||||
fs.unlinkSync(resolvedPath);
|
||||
return { success: true, message: 'Đã xóa tệp tin thành công' };
|
||||
} else {
|
||||
throw new NotFoundException('Tệp tin không tồn tại');
|
||||
}
|
||||
} else if (type === 'db_orphaned') {
|
||||
if (!id) throw new BadRequestException('ID bản ghi không hợp lệ');
|
||||
const photo = await this.prisma.photo.findUnique({ where: { id } });
|
||||
if (!photo) throw new NotFoundException('Bản ghi không tồn tại trong cơ sở dữ liệu');
|
||||
|
||||
if (photo.imageUrl) {
|
||||
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
|
||||
if (fs.existsSync(displayFilePath)) {
|
||||
try { fs.unlinkSync(displayFilePath); } catch (e) {}
|
||||
}
|
||||
}
|
||||
if (photo.originalUrl) {
|
||||
const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, ''));
|
||||
if (fs.existsSync(originalFilePath)) {
|
||||
try { fs.unlinkSync(originalFilePath); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.photo.delete({ where: { id } });
|
||||
return { success: true, message: 'Đã xóa bản ghi và các tệp liên quan thành công' };
|
||||
}
|
||||
|
||||
throw new BadRequestException('Yêu cầu không hợp lệ');
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('public-photos')
|
||||
class PublicPhotoController {
|
||||
constructor(
|
||||
@@ -2075,7 +2303,7 @@ class PublicPhotoController {
|
||||
signOptions: { expiresIn: '1d' },
|
||||
}) as any,
|
||||
],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController, AdminController],
|
||||
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector, EmailService, ConfigService],
|
||||
exports: [PrismaService]
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user