fix: ảnh phải giữ nguyên hướng chụp

This commit is contained in:
2026-06-20 07:28:28 +07:00
parent def0e0d0f9
commit 4373ed684a
26 changed files with 665 additions and 18 deletions
+239 -1
View File
@@ -1052,6 +1052,8 @@ let TourController = class TourController {
fs.mkdirSync(memberOriginalDir, { recursive: true });
if (!fs.existsSync(tourDisplayPath))
fs.mkdirSync(tourDisplayPath, { recursive: true });
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';
@@ -1060,6 +1062,28 @@ let TourController = class TourController {
const originalFilePath = path.join(memberOriginalDir, originalFilename);
const displayFilePath = path.join(tourDisplayPath, displayFilename);
await fs.promises.writeFile(originalFilePath, file.buffer);
let lat;
let lng;
try {
const gps = await exifr_1.default.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);
}
if (lat === undefined || lng === undefined) {
if (bodyLat !== undefined && !isNaN(bodyLat) && bodyLng !== undefined && !isNaN(bodyLng)) {
lat = bodyLat;
lng = bodyLng;
}
}
if (lat === undefined || lng === undefined) {
lat = 10.7769;
lng = 106.7009;
}
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) {
@@ -1076,6 +1100,7 @@ let TourController = class TourController {
}
}
await (0, sharp_1.default)(processBuffer)
.rotate()
.resize(2560, 2560, {
fit: 'inside',
withoutEnlargement: true
@@ -1089,6 +1114,10 @@ let TourController = class TourController {
imageUrl: `/uploads/tours/${displayFilename}`,
originalUrl: `/uploads/members/${uploaderId}/originals/${originalFilename}`,
privacy: 'TOUR_ONLY',
metadata: {
lat: lat,
lng: lng
}
}
});
}));
@@ -1624,6 +1653,7 @@ let PhotoController = class PhotoController {
}
}
await (0, sharp_1.default)(processBuffer)
.rotate()
.resize(2560, 2560, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85 })
.toFile(displayFilePath);
@@ -2041,6 +2071,214 @@ AdminOtpController = __decorate([
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
EmailService, Object])
], AdminOtpController);
let AdminController = class AdminController {
constructor(prisma) {
this.prisma = prisma;
}
async getTrashPhotos() {
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));
const allDiskFiles = [];
const getFilesRecursively = (dir) => {
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 = [];
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'
});
}
}
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;
}
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
};
}
async deleteSingleTrash(body) {
const { type, filePath, id } = body;
if (type === 'file_only') {
if (!filePath)
throw new common_1.BadRequestException('Đường dẫn tệp tin không hợp lệ');
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(UPLOAD_ROOT)) {
throw new common_1.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 common_1.NotFoundException('Tệp tin không tồn tại');
}
}
else if (type === 'db_orphaned') {
if (!id)
throw new common_1.BadRequestException('ID bản ghi không hợp lệ');
const photo = await this.prisma.photo.findUnique({ where: { id } });
if (!photo)
throw new common_1.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 common_1.BadRequestException('Yêu cầu không hợp lệ');
}
};
__decorate([
(0, common_1.Get)(),
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:returntype", Promise)
], AdminController.prototype, "getTrashPhotos", null);
__decorate([
(0, common_1.Delete)('clean'),
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:returntype", Promise)
], AdminController.prototype, "cleanTrashPhotos", null);
__decorate([
(0, common_1.Delete)('delete-single'),
__param(0, (0, common_1.Body)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Object]),
__metadata("design:returntype", Promise)
], AdminController.prototype, "deleteSingleTrash", null);
AdminController = __decorate([
(0, common_1.Controller)('admin/trash-photos'),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], AdminController);
let PublicPhotoController = class PublicPhotoController {
constructor(prisma, commentGateway) {
this.prisma = prisma;
@@ -2155,7 +2393,7 @@ AppModule = __decorate([
signOptions: { expiresIn: '1d' },
}),
],
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: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard, CommentGateway, core_2.Reflector, EmailService, config_1.ConfigService],
exports: [prisma_service_1.PrismaService]
})
+1 -1
View File
File diff suppressed because one or more lines are too long
+231 -3
View File
@@ -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]
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB