Refactor giai đoạn 1: test các tính năng vừa thay đổi như tour, scene...

This commit is contained in:
2026-06-10 21:58:45 +07:00
parent 3f1b31b233
commit 358a98b21b
31 changed files with 1391 additions and 638 deletions
+292
View File
@@ -0,0 +1,292 @@
const express = require('express');
const router = express.Router();
const Tour = require('../models/Tour');
const Scene = require('../models/Scene');
const { protect, optionalAuth } = require('../middlewares/authMiddleware');
const { propagateScenePrivacy } = require('../utils/sceneHelper');
const crypto = require('crypto');
// @route POST /api/tours
// @desc Tạo một Tour mới (bước đầu tiên trước khi upload ảnh)
// @access Private
router.post('/', protect, async (req, res) => {
try {
const { name, description, lat, lng, privacy } = req.body;
if (!name) {
return res.status(400).json({ message: 'Tên Tour là bắt buộc.' });
}
const newTour = new Tour({
name,
description,
location: { lat: Number(lat) || 0, lng: Number(lng) || 0 },
createdBy: req.user._id,
privacy: privacy || 'private',
scenes: [],
shareToken: (privacy === 'shared') ? crypto.randomBytes(24).toString('hex') : undefined
});
if (newTour.privacy === 'shared') {
// Thiết lập hạn mặc định 7 ngày nếu không chỉ định
const expires = new Date();
expires.setDate(expires.getDate() + 7);
newTour.shareTokenExpires = expires;
}
await newTour.save();
res.status(201).json({ message: 'Tour đã được tạo thành công.', tour: newTour });
} catch (error) {
res.status(500).json({ message: error.message });
}
});
// @route PUT /api/tours/:id
// @desc Cập nhật Tour và lan truyền quyền riêng tư xuống các cảnh con
// @access Private (Chủ sở hữu hoặc Admin)
router.put('/:id', protect, async (req, res) => {
try {
const {
name,
description,
lat,
lng,
privacy,
sharedWithUsers,
sharedEmails,
shareExpireDays
} = req.body;
const tour = await Tour.findById(req.params.id);
if (!tour) return res.status(404).json({ message: 'Tour không tồn tại.' });
if (tour.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') {
return res.status(403).json({ message: 'Bạn không có quyền chỉnh sửa Tour này.' });
}
tour.name = name || tour.name;
tour.description = description !== undefined ? description : tour.description;
if (lat !== undefined) tour.location.lat = Number(lat);
if (lng !== undefined) tour.location.lng = Number(lng);
if (privacy) tour.privacy = privacy;
// Xử lý logic Token cho chế độ 'shared' (Link-based)
if (tour.privacy === 'shared') {
if (!tour.shareToken) tour.shareToken = crypto.randomBytes(24).toString('hex');
if (shareExpireDays && shareExpireDays !== 'never') {
const expires = new Date();
expires.setDate(expires.getDate() + parseInt(shareExpireDays));
tour.shareTokenExpires = expires;
} else if (shareExpireDays === 'never') {
tour.shareTokenExpires = null;
}
} else {
tour.shareToken = undefined;
tour.shareTokenExpires = undefined;
}
// Cập nhật danh sách thành viên được chia sẻ
if (tour.privacy === 'member' || tour.privacy === 'shared') {
if (sharedWithUsers) {
try { tour.sharedWith = JSON.parse(sharedWithUsers); } catch (e) { }
}
if (sharedEmails) {
try { tour.sharedEmails = JSON.parse(sharedEmails); } catch (e) { }
}
} else if (tour.privacy === 'private') {
tour.sharedWith = [];
tour.sharedEmails = [];
}
await tour.save();
// [CORE LOGIC] Lan truyền thiết lập mới xuống toàn bộ các Scene con trong Tour
await propagateScenePrivacy(tour._id, {
privacy: tour.privacy,
shareToken: tour.shareToken,
shareTokenExpires: tour.shareTokenExpires,
sharedWith: tour.sharedWith,
sharedEmails: tour.sharedEmails
}, req.user._id);
res.json({ message: 'Tour đã được cập nhật thành công.', tour });
} catch (error) {
res.status(500).json({ message: error.message });
}
});
// @route GET /api/tours/:id
// @desc Lấy chi tiết Tour và danh sách các cảnh (Kiểm tra quyền truy cập)
// @access Public (Xác thực thông qua Privacy/Token)
router.get('/:id', optionalAuth, async (req, res) => {
try {
const tour = await Tour.findById(req.params.id)
.populate('createdBy', 'username')
.populate({
path: 'rootSceneId',
select: 'assetId', // Chỉ lấy assetId của rootScene
populate: { path: 'assetId', select: '_id' } // Populate assetId để lấy _id của Asset
})
.populate({
path: 'scenes',
select: 'name description assetId gps status privacy shareToken shareTokenExpires sharedWith sharedEmails createdBy',
populate: { path: 'assetId', select: '_id' }
})
.lean();
if (!tour) return res.status(404).json({ message: 'Tour không tồn tại.' });
const isOwner = req.user && tour.createdBy._id.toString() === req.user._id.toString();
const isAdmin = req.user && req.user.role === 'admin';
const isTokenValid = tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
const userEmail = req.user ? req.user.email : null;
let hasAccess = tour.privacy === 'public' || isOwner || isAdmin ||
(tour.privacy === 'shared' && req.query.token === tour.shareToken && isTokenValid) ||
(tour.privacy === 'member' && req.user && (
tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
(userEmail && tour.sharedEmails.includes(userEmail))
));
if (!hasAccess) return res.status(403).json({ message: 'Bạn không có quyền truy cập Tour này.' });
res.json(tour);
} catch (error) {
res.status(500).json({ message: error.message });
}
});
// @route GET /api/tours
// @desc Lấy danh sách Tour công khai hoặc của chính mình
// @access Public/Private
router.get('/', optionalAuth, async (req, res) => {
try {
let query = { privacy: 'public' };
if (req.user && req.user.role !== 'guest') {
query = {
$or: [
{ privacy: 'public' },
{ createdBy: req.user._id },
{ privacy: 'member', sharedWith: req.user._id },
{ privacy: 'member', sharedEmails: req.user.email }
]
};
}
const tours = await Tour.find(query)
.populate('createdBy', 'username')
.populate({
path: 'rootSceneId',
select: 'assetId', // Chỉ lấy assetId của rootScene
populate: { path: 'assetId', select: '_id' } // Populate assetId để lấy _id của Asset
})
.sort({ createdAt: -1 })
.lean();
res.json(tours);
} catch (error) {
res.status(500).json({ message: error.message });
}
});
// @route DELETE /api/tours/:id
// @desc Xóa Tour và xóa dây chuyền toàn bộ Scene/Asset bên trong
// @access Private (Chủ sở hữu hoặc Admin)
router.delete('/:id', protect, async (req, res) => {
try {
const tour = await Tour.findById(req.params.id);
if (!tour) return res.status(404).json({ message: 'Tour không tồn tại.' });
if (tour.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') {
return res.status(403).json({ message: 'Bạn không có quyền xóa Tour này.' });
}
const { deleteSceneCascade } = require('../utils/sceneHelper');
const scenesInTour = await Scene.find({ tourId: tour._id });
for (const scene of scenesInTour) {
await deleteSceneCascade(scene._id, req.user._id);
}
await Tour.findByIdAndDelete(req.params.id);
res.json({ message: `Tour "${tour.name}" đã được xóa thành công.` });
} catch (error) {
res.status(500).json({ message: error.message });
}
});
/**
* Tính toán và cập nhật tọa độ trung tâm (location) của Tour
* dựa trên giá trị trung bình tọa độ GPS của tất cả các cảnh con hiện có.
* @param {string} tourId - ID của Tour cần cập nhật
*/
const updateTourCenter = async (tourId) => {
try {
const scenes = await Scene.find({ tourId }).select('gps');
if (!scenes || scenes.length === 0) return;
let totalLat = 0;
let totalLng = 0;
let validCount = 0;
scenes.forEach(scene => {
// Chỉ tính toán dựa trên các cảnh có tọa độ GPS hợp lệ (khác 0,0)
if (scene.gps &&
typeof scene.gps.lat === 'number' &&
typeof scene.gps.lng === 'number' &&
(scene.gps.lat !== 0 || scene.gps.lng !== 0)) {
totalLat += scene.gps.lat;
totalLng += scene.gps.lng;
validCount++;
}
});
if (validCount > 0) {
await Tour.findByIdAndUpdate(tourId, {
location: {
lat: totalLat / validCount,
lng: totalLng / validCount
}
}, {
// Thay thế cho 'new: true' để lấy dữ liệu sau khi cập nhật
returnDocument: 'after'
});
}
} catch (error) {
console.error(`[TourController] Error updating center for tour ${tourId}:`, error.message);
}
};
// @route POST /api/tours/recalculate-all
// @desc Admin: Tính toán lại trung tâm cho toàn bộ Tour trong hệ thống
// @access Private (Admin only)
router.post('/recalculate-all', protect, async (req, res) => {
if (req.user.role !== 'admin') {
return res.status(403).json({ message: 'Tính năng này chỉ dành cho Quản trị viên.' });
}
try {
const tours = await Tour.find({});
let processedCount = 0;
// Thực hiện tuần tự để tránh gây áp lực quá lớn lên cơ sở dữ liệu
for (const tour of tours) {
await updateTourCenter(tour._id);
processedCount++;
}
res.json({
message: `Đã hoàn thành tính toán lại trung tâm cho ${processedCount} Tour.`,
processedCount
});
} catch (error) {
res.status(500).json({ message: error.message });
}
});
router.updateTourCenter = updateTourCenter;
module.exports = router;
+29 -26
View File
@@ -2,55 +2,58 @@ const jwt = require('jsonwebtoken');
const User = require('../models/User');
/**
* Strict authentication middleware. Rejects requests without a valid JWT.
* Middleware bảo vệ các route yêu cầu đăng nhập.
* Chặn quyền 'guest' (người dùng chưa đăng nhập).
*/
const protect = async (req, res, next) => {
let token;
if (
(req.headers.authorization && req.headers.authorization.startsWith('Bearer')) ||
req.query.token
) {
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
try {
token = req.headers.authorization
? req.headers.authorization.split(' ')[1]
: req.query.token;
token = req.headers.authorization.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = await User.findById(decoded.id).select('-password');
if (!req.user) {
return res.status(401).json({ message: 'User not found' });
return res.status(401).json({ message: 'Tài khoản không tồn tại' });
}
return next();
} catch (error) {
return res.status(401).json({ message: 'Not authorized, token failed' });
return res.status(401).json({ message: 'Phiên làm việc hết hạn, vui lòng đăng nhập lại' });
}
}
return res.status(401).json({ message: 'Not authorized, no token provided' });
return res.status(401).json({ message: 'Vui lòng đăng nhập để sử dụng tính năng này' });
};
/**
* Optional authentication middleware. Populates req.user if a valid token is present,
* but allows the request to proceed as a guest if no token is found.
* Middleware xác thực tùy chọn.
* Nếu không có token hoặc token không hợp lệ, gán role 'guest' cho req.user.
*/
const optionalAuth = async (req, res, next) => {
if (
(req.headers.authorization && req.headers.authorization.startsWith('Bearer')) ||
req.query.token
) {
let token;
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
try {
const token = req.headers.authorization
? req.headers.authorization.split(' ')[1]
: req.query.token;
token = req.headers.authorization.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = await User.findById(decoded.id).select('-password');
} catch (error) {
// Ignore error and continue as guest
// Token lỗi, gán guest ở dưới
}
}
// Logic gán Guest Role: Không lưu trong DB, chỉ tồn tại trong vòng đời Request
if (!req.user) {
req.user = {
role: 'guest',
username: 'Guest'
};
}
next();
};
module.exports = {
protect,
optionalAuth
};
module.exports = { protect, optionalAuth };
+27 -43
View File
@@ -1,57 +1,41 @@
const Asset = require('../models/Asset');
const fs = require('fs');
const fs = require('fs').promises;
const path = require('path');
// Cấu hình Quota cho từng nhóm người dùng (đơn vị: Bytes)
const ROLE_QUOTAS = {
'Thành viên': 2 * 1024 * 1024 * 1024, // 2GB
'editor': 10 * 1024 * 1024 * 1024, // 10GB
'admin': 100 * 1024 * 1024 * 1024, // 100GB (hoặc Infinity)
'Chủ sở hữu': Infinity // Không giới hạn
};
/**
* Middleware kiểm tra giới hạn lưu trữ của người dùng
* Dựa trên cấu trúc storage (used/quota) và role mới.
*/
const checkQuota = async (req, res, next) => {
if (!req.user) return res.status(401).json({ message: 'Unauthorized' });
if (!req.user) return res.status(401).json({ message: 'Vui lòng đăng nhập' });
const userRole = req.user.role || 'Thành viên';
const quota = ROLE_QUOTAS[userRole] || ROLE_QUOTAS['Thành viên'];
// Admin được miễn trừ kiểm tra dung lượng
if (req.user.role === 'admin') return next();
// Nếu không giới hạn thì đi tiếp
if (quota === Infinity) return next();
// Lấy dữ liệu từ req.user (đã được authMiddleware nạp từ DB)
const used = req.user.storage?.used || 0;
const quota = req.user.storage?.quota || 0;
const newFileSize = req.file ? req.file.size : 0;
try {
// Sử dụng MongoDB Aggregation để tính tổng dung lượng ngay trên database
const usageResult = await Asset.aggregate([
{ $match: { uploadedBy: req.user._id } },
{
$group: {
_id: null,
totalUsage: { $sum: { $ifNull: ["$fileSize", 0] } }
}
}
]);
const currentUsage = usageResult.length > 0 ? usageResult[0].totalUsage : 0;
const newFileSize = req.file ? req.file.size : 0;
if (currentUsage + newFileSize > quota) {
// Xóa file tạm vừa upload lên nếu vượt định mức
if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
return res.status(403).json({
message: `Vượt quá giới hạn lưu trữ. Định mức của bạn là ${(quota / (1024**3)).toFixed(1)}GB. Bạn đã sử dụng ${(currentUsage / (1024**3)).toFixed(2)}GB.`
});
// Kiểm tra nếu tổng dung lượng sau khi upload vượt quá hạn mức
if (used + newFileSize > quota) {
// Xóa ngay file tạm vừa được multer lưu vào disk để giải phóng tài nguyên server
if (req.file && req.file.path) {
await fs.unlink(req.file.path).catch(err =>
console.error('[Quota Middleware] Lỗi xóa file tạm:', err.message)
);
}
next();
} catch (error) {
console.error('[Quota Check Error]:', error);
next(); // Cho phép đi tiếp nếu lỗi logic kiểm tra để tránh chặn người dùng oan
return res.status(403).json({
message: 'Dung lượng lưu trữ của bạn đã hết hoặc không đủ để thực hiện thao tác này.',
storage: {
used: `${(used / (1024 * 1024)).toFixed(2)} MB`,
quota: `${(quota / (1024 * 1024)).toFixed(2)} MB`,
required: `${(newFileSize / (1024 * 1024)).toFixed(2)} MB`
}
});
}
next();
};
module.exports = { checkQuota, ROLE_QUOTAS };
module.exports = { checkQuota };