feat: tải ảnh lên bằng tài khoản public và cho phép bình luận

This commit is contained in:
2026-06-19 22:38:16 +07:00
parent 4640526e4b
commit 6c1b77d7d2
29 changed files with 1752 additions and 165 deletions
+289 -20
View File
@@ -6,6 +6,8 @@ import 'reflect-metadata';
import * as zlib from 'zlib';
import { promisify } from 'util';
import sharp from 'sharp';
import exifr from 'exifr';
import heicConvert from 'heic-convert';
import { NestFactory } from '@nestjs/core';
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException, Injectable, UseInterceptors, UploadedFiles, Inject, HttpException, HttpStatus } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
@@ -128,7 +130,10 @@ export class TourRoleGuard implements CanActivate {
} else {
// Thử xem resourceId có phải là photoId không
const photo = await this.prisma.photo.findUnique({ where: { id: resourceId }, select: { tourId: true } });
if (photo) tourId = photo.tourId;
if (photo) {
if (!photo.tourId) return true; // Cho phép đi qua nếu ảnh không thuộc tour nào (ví dụ ảnh ẩn danh public)
tourId = photo.tourId;
}
}
}
}
@@ -317,6 +322,26 @@ class AuthController {
};
}
@Post('create-guest')
async createGuestUser() {
const user = await this.prisma.user.create({
data: {
isAnonymous: true,
name: `Lữ khách #${Math.floor(1000 + Math.random() * 9000)}`,
},
});
const payload = { sub: user.id, isAnonymous: true };
return {
access_token: this.jwtService.sign(payload),
user: {
id: user.id,
name: user.name,
isAnonymous: user.isAnonymous,
},
};
}
@Get('status')
async getStatus() {
const userCount = await this.prisma.user.count();
@@ -374,8 +399,8 @@ class AuthController {
}
@Post('signup/verify')
async signupVerify(@Body() body: { email: string; otp: string }) {
const { email, otp } = body;
async signupVerify(@Body() body: { email: string; otp: string; guestId?: string }) {
const { email, otp, guestId } = body;
const storedOtp = await this.cacheManager.get<string>(`signup_otp:${email}`);
if (!storedOtp || storedOtp !== otp) {
@@ -388,15 +413,48 @@ class AuthController {
}
const { password, name, phone, address } = JSON.parse(cachedDataStr);
const userCount = await this.prisma.user.count();
const shouldBeAdmin = userCount === 0;
const passwordHash = await bcrypt.hash(password, 10);
const user = await this.prisma.user.create({
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
});
let user;
if (guestId) {
// Tìm tài khoản khách
const guestUser = await this.prisma.user.findUnique({
where: { id: guestId }
});
if (guestUser && guestUser.isAnonymous) {
// Kiểm tra xem email này đã được sử dụng bởi một tài khoản chính thức khác chưa
const existingUser = await this.prisma.user.findFirst({
where: { email, isAnonymous: false }
});
if (existingUser) {
throw new BadRequestException('Email này đã được đăng ký bởi tài khoản khác.');
}
// Cập nhật thông tin của tài khoản khách
user = await this.prisma.user.update({
where: { id: guestId },
data: {
email,
passwordHash,
name,
phone,
address,
isAnonymous: false,
},
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
});
}
}
if (!user) {
const userCount = await this.prisma.user.count();
const shouldBeAdmin = userCount === 0;
user = await this.prisma.user.create({
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
});
}
// Xóa dữ liệu cache sau khi đăng ký thành công
await Promise.all([
@@ -1112,18 +1170,34 @@ class TourController {
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 originalExtension = path.extname(file.originalname).toLowerCase() || '.jpg';
const originalFilename = `${uniqueSuffix}${originalExtension}`;
const displayFilename = `${uniqueSuffix}.jpg`;
const originalFilePath = path.join(memberOriginalDir, filename);
const displayFilePath = path.join(tourDisplayPath, filename);
const originalFilePath = path.join(memberOriginalDir, originalFilename);
const displayFilePath = path.join(tourDisplayPath, displayFilename);
// 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)
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) {
try {
processBuffer = await heicConvert({
buffer: file.buffer,
format: 'JPEG',
quality: 1
});
console.log(`[HEIC] Converted original HEIC image to JPEG for display`);
} catch (e) {
console.warn('[HEIC] Failed to convert HEIC to JPEG, attempting to process anyway:', e.message);
}
}
// Sử dụng Sharp để resize và tối ưu dung lượng ảnh
await sharp(file.buffer)
await sharp(processBuffer)
.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
@@ -1136,8 +1210,8 @@ class TourController {
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
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',
}
});
@@ -1426,6 +1500,110 @@ class RoutingController {
class PhotoController {
constructor(private prisma: PrismaService) {}
@Post('upload-anonymous')
@UseGuards(JwtAuthGuard) // Vẫn dùng Guard để lấy user từ token
@UseInterceptors(FilesInterceptor('images', 1)) // Chỉ cho phép 1 ảnh mỗi lần
async uploadAnonymousPhoto(@UploadedFiles() files: any[], @Req() req: any) {
if (!files || files.length === 0) {
throw new BadRequestException('Vui lòng chọn một ảnh.');
}
const uploaderId = req.user.id;
const isAnonymous = req.user.isAnonymous;
// Chỉ người dùng ẩn danh mới được dùng endpoint này
if (!isAnonymous) {
throw new ForbiddenException('Chỉ người dùng khách mới có thể sử dụng tính năng này.');
}
const file = files[0];
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const originalExtension = path.extname(file.originalname).toLowerCase() || '.jpg';
const originalFilename = `${uniqueSuffix}${originalExtension}`;
const displayFilename = `${uniqueSuffix}.jpg`;
const memberOriginalDir = path.join(UPLOAD_ROOT, 'members', uploaderId, 'originals');
const displayFilePath = path.join(UPLOAD_ROOT, 'tours', displayFilename);
const originalFilePath = path.join(memberOriginalDir, originalFilename);
// Đảm bảo thư mục lưu trữ ảnh gốc tồn tại
if (!fs.existsSync(memberOriginalDir)) {
fs.mkdirSync(memberOriginalDir, { recursive: true });
}
// 1. Lưu ảnh gốc nguyên bản vào thư mục riêng của khách
await fs.promises.writeFile(originalFilePath, file.buffer);
// 2. Trích xuất GPS từ EXIF bằng exifr
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;
console.log(`[EXIF GPS] Đã tìm thấy tọa độ từ EXIF: lat=${lat}, lng=${lng}`);
}
} 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ó tọa độ, dùng tọa độ dự phòng gửi từ frontend
if (lat === undefined || lng === undefined) {
const bodyLat = req.body.latitude ? parseFloat(req.body.latitude) : undefined;
const bodyLng = req.body.longitude ? parseFloat(req.body.longitude) : undefined;
if (bodyLat !== undefined && !isNaN(bodyLat) && bodyLng !== undefined && !isNaN(bodyLng)) {
lat = bodyLat;
lng = bodyLng;
console.log(`[EXIF GPS] Sử dụng tọa độ dự phòng từ Frontend: lat=${lat}, lng=${lng}`);
}
}
// 4. Nếu vẫn không có, sử dụng vị trí mặc định (TP.HCM)
if (lat === undefined || lng === undefined) {
lat = 10.7769;
lng = 106.7009;
console.log(`[EXIF GPS] Không tìm thấy tọa độ nào, ghim tại TP.HCM mặc định: lat=${lat}, lng=${lng}`);
}
// 5. Chuyển đổi HEIC nếu cần để lưu hiển thị
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) {
try {
processBuffer = await heicConvert({
buffer: file.buffer,
format: 'JPEG',
quality: 1
});
console.log(`[HEIC] Converted anonymous HEIC image to JPEG for display`);
} catch (e) {
console.warn('[HEIC] Failed to convert HEIC to JPEG, attempting to process anyway:', e.message);
}
}
// 6. Xử lý ảnh để hiển thị (kích thước tối đa 2K: 2560px)
await sharp(processBuffer)
.resize(2560, 2560, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85 })
.toFile(displayFilePath);
return this.prisma.photo.create({
data: {
uploaderId: uploaderId,
imageUrl: `/uploads/tours/${displayFilename}`,
originalUrl: `/uploads/members/${uploaderId}/originals/${originalFilename}`,
privacy: 'PUBLIC',
metadata: {
lat: lat,
lng: lng
}
},
});
}
@Delete(':id')
async deletePhoto(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
const photo = await this.prisma.photo.findUnique({
@@ -1436,8 +1614,8 @@ class PhotoController {
throw new NotFoundException('Không tìm thấy ảnh.');
}
// Chỉ người tải lên mới có quyền xóa ảnh của họ
if (photo.uploaderId !== req.user.id) {
// Chỉ người tải lên hoặc Admin mới có quyền xóa ảnh
if (photo.uploaderId !== req.user.id && !req.user.isAdmin) {
throw new ForbiddenException('Bạn không có quyền xóa ảnh này.');
}
@@ -1577,10 +1755,21 @@ export class CommentGateway implements OnGatewayConnection {
console.log(`[WS] Client ${client.id} joined room: tour_${tourId}`);
}
@SubscribeMessage('joinPhoto')
handleJoinPhoto(client: Socket, photoId: string) {
client.join(`photo_${photoId}`);
console.log(`[WS] Client ${client.id} joined room: photo_${photoId}`);
}
notifyNewComment(tourId: string, data: any) {
// Gửi thông báo tới tất cả client trong phòng của Tour này
this.server.to(`tour_${tourId}`).emit('commentAdded', data);
}
notifyNewPhotoComment(photoId: string, data: any) {
// Gửi thông báo tới tất cả client trong phòng của Ảnh này
this.server.to(`photo_${photoId}`).emit('photoCommentAdded', data);
}
}
@Controller('locations')
@@ -1748,6 +1937,86 @@ class AdminOtpController {
}
}
@Controller('public-photos')
class PublicPhotoController {
constructor(
private prisma: PrismaService,
private commentGateway: CommentGateway
) {}
@Get()
async getPublicPhotos() {
return this.prisma.photo.findMany({
where: { privacy: 'PUBLIC' },
select: {
id: true,
imageUrl: true,
originalUrl: true,
capturedAt: true,
metadata: true,
uploader: {
select: {
id: true,
name: true,
}
}
},
orderBy: { capturedAt: 'desc' }
});
}
@Get(':photoId/comments')
async getPhotoComments(@Param('photoId', ParseUUIDPipe) photoId: string) {
return this.prisma.comment.findMany({
where: { photoId },
include: {
user: {
select: { id: true, name: true }
}
},
orderBy: { createdAt: 'asc' }
});
}
@UseGuards(JwtAuthGuard)
@Post(':photoId/comments')
async addPhotoComment(
@Param('photoId', ParseUUIDPipe) photoId: string,
@Body() body: { content: string },
@Req() req: any
) {
const { content } = body;
if (!content || content.trim() === '') {
throw new BadRequestException('Nội dung bình luận không được để trống.');
}
const photo = await this.prisma.photo.findUnique({
where: { id: photoId }
});
if (!photo) {
throw new NotFoundException('Không tìm thấy ảnh.');
}
const comment = await this.prisma.comment.create({
data: {
content: content.trim(),
photoId,
userId: req.user.id
},
include: {
user: {
select: { id: true, name: true }
}
}
});
// Phát tín hiệu WebSocket cho các client đang xem ảnh này
this.commentGateway.notifyNewPhotoComment(photoId, comment);
return comment;
}
}
@Module({
imports: [
ConfigModule.forRoot({
@@ -1771,7 +2040,7 @@ class AdminOtpController {
signOptions: { expiresIn: '1d' },
}) as any,
],
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, AdminOtpController],
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController],
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector, EmailService, ConfigService],
exports: [PrismaService]
})