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 };
+4
View File
@@ -10,6 +10,10 @@ const hotspotSchema = new mongoose.Schema({
type: mongoose.Schema.Types.ObjectId,
ref: 'Scene'
},
target_tour_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Tour'
},
title: {
type: String,
trim: true
+41 -35
View File
@@ -1,55 +1,61 @@
const mongoose = require('mongoose');
const sceneSchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true
tourId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Tour',
required: true
},
scene_url: {
type: String
name: {
type: String,
required: true,
trim: true
},
description: {
type: String,
trim: true
},
assetId: {
type: mongoose.Schema.Types.ObjectId,
assetId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Asset',
required: true
},
scene_url: String,
gps: {
lat: { type: Number, required: true },
lng: { type: Number, required: true }
lat: Number,
lng: Number
},
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
privacy: {
uploadedAt: {
type: Date,
default: Date.now
},
status: {
type: String,
enum: ['public', 'private', 'shared', 'member'],
default: 'private'
enum: ['processing', 'completed', 'failed'],
default: 'processing'
},
shareToken: {
type: String,
unique: true,
sparse: true
},
shareTokenExpires: {
type: Date
},
sharedWith: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
shareToken: String,
shareTokenExpires: Date,
sharedWith: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}],
sharedEmails: [{
type: String,
trim: true
}],
}, {
timestamps: true
sharedEmails: [String],
views: {
type: Number,
default: 0
},
viewHistory: [{
date: Date,
count: Number
}]
}, {
timestamps: true
});
module.exports = mongoose.model('Scene', sceneSchema);
module.exports = mongoose.model('Scene', sceneSchema);
+46
View File
@@ -0,0 +1,46 @@
const mongoose = require('mongoose');
const tourSchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true
},
description: {
type: String,
trim: true
},
location: {
lat: Number,
lng: Number
},
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
rootSceneId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Scene'
},
privacy: {
type: String,
enum: ['public', 'private', 'member', 'shared'],
default: 'private'
},
shareToken: String,
shareTokenExpires: Date,
sharedWith: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}],
sharedEmails: [String],
scenes: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Scene'
}]
}, {
timestamps: true
});
module.exports = mongoose.model('Tour', tourSchema);
+15 -1
View File
@@ -26,12 +26,26 @@ const userSchema = new mongoose.Schema({
},
role: {
type: String,
enum: ['admin', 'moderator', 'editor', 'user'],
enum: ['admin', 'moderator', 'user'],
default: 'user'
},
avatarUrl: {
type: String,
default: ''
},
agreedToRules: {
type: Boolean,
required: true
},
storage: {
used: {
type: Number,
default: 0
},
quota: {
type: Number,
default: 5368709120 // Mặc định 5GB (bytes)
}
}
}, {
timestamps: true
+5 -1
View File
@@ -13,13 +13,17 @@ if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
// Import các sub-routers
const adminRoutes = require('./adminRoutes');
const sceneRoutes = require('./sceneRoutes');
const userRoutes = require('./userRoutes');
const tourRoutes = require('../middlewares/TourController'); // Đường dẫn thực tế hiện tại
const authRoutes = require('./authRoutes');
const userRoutes = require('./userRoutes');
const hotspotRoutes = require('./hotspotRoutes');
const assetRoutes = require('./assetRoutes');
// Các module chưa tách hết (có thể tách tiếp ở Giai đoạn sau)
// Ở đây tôi gắn các route còn lại trực tiếp để không làm gián đoạn hệ thống
router.use('/admin', adminRoutes);
router.use('/auth', authRoutes); // Tích hợp API Đăng ký/Đăng nhập
router.use('/tours', tourRoutes); // Thêm các route cho Tour
router.use('/scenes', sceneRoutes);
router.use('/users', userRoutes);
router.use('/me', userRoutes); // Frontend gọi /api/me/profile, sẽ trỏ vào userRoutes
+41 -9
View File
@@ -3,6 +3,7 @@ const router = express.Router();
const path = require('path');
const fs = require('fs');
const sharp = require('sharp');
const jwt = require('jsonwebtoken');
const Asset = require('../models/Asset');
const Scene = require('../models/Scene');
@@ -26,20 +27,51 @@ router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res
const asset = await Asset.findById(req.params.assetId);
if (!asset) return res.status(404).json({ message: 'Asset not found' });
// [FIX] Luôn kiểm tra JWT từ query string ngay cả khi optionalAuth đã chạy
let user = req.user;
const isGuest = !user || user.role === 'guest';
if (isGuest && req.query.token) {
try {
const decoded = jwt.verify(req.query.token, process.env.JWT_SECRET || 'your_jwt_secret');
if (decoded && decoded.id) {
const User = require('../models/User');
const authenticatedUser = await User.findById(decoded.id);
if (authenticatedUser) user = authenticatedUser;
}
} catch (e) {
}
}
const isAdmin = user && (user.role === 'admin' || user.role === 'moderator');
const userIdStr = user && user._id ? user._id.toString() : null;
const userEmail = user ? user.email : null;
// Kiểm tra quyền truy cập dựa trên Privacy của Scene liên kết
const scene = await Scene.findOne({ assetId: asset._id });
const scene = await Scene.findOne({ assetId: asset._id }).populate('tourId');
if (!scene) {
// Asset mồ côi, chỉ chủ sở hữu được xem
if (!req.user || req.user._id.toString() !== asset.uploadedBy.toString()) {
if (!isAdmin && (!userIdStr || userIdStr !== asset.uploadedBy.toString())) {
return res.status(403).json({ message: 'Bạn không được phép di chuyển đến cảnh này' });
}
} else {
const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
const userEmail = req.user ? req.user.email : null;
let hasAccess = scene.privacy === 'public' ||
(scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) ||
(req.user && scene.createdBy.toString() === req.user._id.toString()) ||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid);
const isSceneTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
const tour = scene.tourId;
const isTourTokenValid = tour && tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
// Chuẩn hóa ID người tạo để so sánh
const sceneOwnerId = scene.createdBy?._id || scene.createdBy || scene.owner?._id || scene.owner;
const isOwner = userIdStr && sceneOwnerId && sceneOwnerId.toString() === userIdStr;
let hasAccess = isAdmin ||
scene.privacy === 'public' ||
(scene.privacy === 'member' && userIdStr && (scene.sharedWith.some(id => id.toString() === userIdStr) || (userEmail && scene.sharedEmails.includes(userEmail)))) ||
isOwner ||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isSceneTokenValid) ||
(tour && tour.privacy === 'shared' && req.query.token === tour.shareToken && isTourTokenValid);
if (scene.status === 'processing' && !hasAccess) {
return res.status(403).json({ message: 'Ảnh đang được xử lý và bạn không có quyền xem tạm thời' });
}
// [BRIDGE ACCESS LOGIC]
// Áp dụng tương tự cho Asset để đảm bảo hiển thị được ảnh khi di chuyển liên kết chéo
@@ -131,7 +163,7 @@ router.get('/assets/view_avatar/:filename', async (req, res) => {
*/
router.get('/me/assets', protect, async (req, res) => {
try {
const query = (req.user.role === 'admin' || req.user.role === 'Chủ sở hữu') ? {} : { uploadedBy: req.user._id };
const query = (req.user.role === 'admin') ? {} : { uploadedBy: req.user._id };
const assets = await Asset.aggregate([
{ $match: query },
+25 -1
View File
@@ -38,9 +38,19 @@ router.post('/create', protect, async (req, res) => {
return res.status(403).json({ message: 'Không có quyền tạo hotspot cho scene này' });
}
// [NEW LOGIC] Xử lý liên kết chéo giữa các Tour
let target_tour_id = undefined;
if (target_scene_id) {
const targetScene = await Scene.findById(target_scene_id);
if (targetScene && targetScene.tourId && parentScene.tourId && targetScene.tourId.toString() !== parentScene.tourId.toString()) {
target_tour_id = targetScene.tourId;
}
}
const hotspot = new Hotspot({
parent_scene_id,
target_scene_id,
target_tour_id,
title,
description,
coordinates: {
@@ -87,7 +97,7 @@ router.post('/create', protect, async (req, res) => {
*/
router.put('/update/:id', protect, async (req, res) => {
try {
const { title, description, coordinates } = req.body;
const { title, description, coordinates, target_scene_id } = req.body;
const hotspot = await Hotspot.findById(req.params.id);
if (!hotspot) return res.status(404).json({ message: 'Hotspot không tồn tại' });
@@ -96,6 +106,20 @@ router.put('/update/:id', protect, async (req, res) => {
return res.status(403).json({ message: 'Không có quyền cập nhật' });
}
// Cập nhật target_scene_id và tính toán lại target_tour_id nếu có thay đổi
if (target_scene_id) {
const targetScene = await Scene.findById(target_scene_id);
if (targetScene) {
hotspot.target_scene_id = target_scene_id;
// Kiểm tra liên kết chéo
if (targetScene.tourId && parentScene.tourId && targetScene.tourId.toString() !== parentScene.tourId.toString()) {
hotspot.target_tour_id = targetScene.tourId;
} else {
hotspot.target_tour_id = undefined;
}
}
}
if (title) hotspot.title = title;
if (description) hotspot.description = description;
if (coordinates) hotspot.coordinates = coordinates;
+15 -4
View File
@@ -23,14 +23,25 @@ const imageWorker = new Worker('image-processing', async (job) => {
// 3. Chèn GPS Metadata
await injectGPSCoordinates(processedFilePath, latitude, longitude);
// 4. Cập nhật đường dẫn file thực tế vào Database
// Lúc này ảnh đã sẵn sàng để phục vụ (8K)
await Asset.findByIdAndUpdate(assetId, { filePath: processedFilePath });
await Scene.findByIdAndUpdate(sceneId, {
// 4. Cập nhật đng thời cả Asset và Scene
await Asset.findByIdAndUpdate(assetId, {
filePath: processedFilePath
}, { returnDocument: 'after' });
const scene = await Scene.findByIdAndUpdate(sceneId, {
scene_url: processedFilePath,
status: 'completed' // Xử lý xong
}, {
// Thay thế 'new: true' bằng 'returnDocument: after' để tránh cảnh báo deprecation
returnDocument: 'after'
});
// 4.1 Tự động tính toán lại vị trí trung tâm của Tour sau khi ảnh đã được xử lý và chèn GPS thành công
if (scene && scene.tourId) {
const tourController = require('../middlewares/TourController');
if (tourController.updateTourCenter) await tourController.updateTourCenter(scene.tourId);
}
// 5. Dọn dẹp file tạm
await fs.promises.unlink(tempFilePath).catch(() => {});
+121 -69
View File
@@ -6,6 +6,7 @@ const crypto = require('crypto');
const multer = require('multer');
const Scene = require('../models/Scene');
const Tour = require('../models/Tour');
const Asset = require('../models/Asset');
const Hotspot = require('../models/Hotspot');
const { protect, optionalAuth } = require('../middlewares/authMiddleware');
@@ -37,40 +38,17 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) =>
const { title, lat, lng, privacy, sharedWithUsers, sharedEmails, shareExpireDays, tourId } = req.body;
if (!req.file) return res.status(400).json({ message: 'Please upload a panorama image' });
// [BẢO MẬT] Xác định quan hệ: Nếu có tourId thì là "Con đẻ", nếu không là "Gốc"
const cleanedTourId = (tourId && tourId !== 'null' && tourId !== 'undefined' && tourId !== '') ? tourId : undefined;
let finalPrivacy = privacy || 'private';
let finalSharedWith = [];
let finalSharedEmails = [];
let finalShareToken = undefined;
let finalExpires = undefined;
let assignedTourId = cleanedTourId; // Biến tạm để lưu tourId cuối cùng được gán
// [QUY TRÌNH MỚI] Bắt buộc tourId và kế thừa từ Tour model
if (!tourId || tourId === 'null' || tourId === 'undefined') {
return res.status(400).json({ message: 'tourId là bắt buộc khi tạo cảnh mới.' });
}
try { if (sharedWithUsers) finalSharedWith = JSON.parse(sharedWithUsers); } catch (e) {}
const tour = await Tour.findById(tourId);
if (!tour) return res.status(404).json({ message: 'Tour không tồn tại hoặc đã bị xóa.' });
// [BẢO MẬT] Xác thực tourId nếu được cung cấp
if (cleanedTourId) {
const rootScene = await Scene.findById(cleanedTourId);
if (!rootScene) return res.status(400).json({ message: 'Tour gốc không tồn tại hoặc đã bị xóa.' });
// [SECURITY] Chỉ cho phép gán tourId nếu người dùng hiện tại là chủ sở hữu của cảnh gốc đó
if (rootScene.createdBy.toString() !== req.user._id.toString()) {
// Nếu không phải chủ sở hữu, cảnh mới này sẽ tự làm gốc của chính nó
assignedTourId = undefined;
} else {
// [ENFORCE INHERITANCE] Cảnh con bắt buộc kế thừa toàn bộ cấu hình từ cảnh gốc
finalPrivacy = rootScene.privacy;
finalSharedWith = rootScene.sharedWith;
finalSharedEmails = rootScene.sharedEmails;
finalShareToken = rootScene.shareToken;
finalExpires = rootScene.shareTokenExpires;
}
} else {
// Nếu là cảnh gốc mới, tạo token nếu chế độ là shared
if (finalPrivacy === 'shared') {
finalShareToken = crypto.randomBytes(24).toString('hex');
}
// [SECURITY] Chỉ chủ sở hữu Tour hoặc Admin mới được thêm cảnh
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 thêm cảnh vào Tour này.' });
}
const latitude = Number(lat) || 0;
@@ -93,18 +71,30 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) =>
scene_url: tempFilePath,
gps: { lat: latitude, lng: longitude },
createdBy: req.user._id,
privacy: finalPrivacy,
shareToken: finalShareToken,
shareTokenExpires: finalExpires,
sharedWith: finalSharedWith,
sharedEmails: finalSharedEmails,
privacy: tour.privacy || 'private',
status: 'processing',
tourId: assignedTourId
tourId: tour._id,
shareToken: tour.shareToken,
shareTokenExpires: tour.shareTokenExpires,
sharedWith: tour.sharedWith,
sharedEmails: tour.sharedEmails
});
// Mặc định mỗi cảnh mới khi tạo ra là cảnh gốc của chính nó
if (!scene.tourId) scene.tourId = scene._id;
await scene.save();
// Cập nhật Tour: Thêm scene vào danh sách và gán rootSceneId nếu là cảnh đầu tiên
tour.scenes.push(scene._id);
if (!tour.rootSceneId) {
tour.rootSceneId = scene._id;
}
await tour.save();
// Tự động tính toán lại vị trí trung tâm của Tour khi thêm cảnh mới
if (latitude !== 0 || longitude !== 0) {
const tourController = require('../middlewares/TourController');
if (tourController.updateTourCenter) await tourController.updateTourCenter(tour._id);
}
await imageQueue.add('process-panorama', {
tempFilePath, processedFilePath, latitude, longitude, assetId: asset._id, sceneId: scene._id
});
@@ -119,11 +109,28 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) =>
// @route GET /api/scenes
router.get('/', optionalAuth, async (req, res) => {
try {
let query = req.user
const { token } = req.query;
// Quyền cơ bản: Công khai hoặc là chủ sở hữu/thành viên được chia sẻ
let baseQuery = req.user && req.user.role !== 'guest'
? { $or: [{ privacy: 'public' }, { createdBy: req.user._id }, { sharedWith: req.user._id }, { sharedEmails: req.user.email }] }
: { privacy: 'public' };
const scenes = await Scene.find(query).populate('createdBy', 'username').lean();
let finalQuery = baseQuery;
// Nếu có token từ URL (Guest truy cập link shared), cho phép lấy các scene thuộc Tour/Scene mang token đó
if (token) {
const tourWithToken = await Tour.findOne({ shareToken: token }).select('_id');
finalQuery = {
$or: [
baseQuery,
{ shareToken: token },
{ tourId: tourWithToken ? tourWithToken._id : null }
]
};
}
const scenes = await Scene.find(finalQuery).populate('createdBy', 'username').lean();
res.json(scenes);
} catch (error) {
res.status(500).json({ message: error.message });
@@ -133,15 +140,30 @@ router.get('/', optionalAuth, async (req, res) => {
// @route GET /api/scenes/:id
router.get('/:id', optionalAuth, async (req, res) => {
try {
const scene = await Scene.findById(req.params.id).populate('createdBy', 'username').populate('assetId');
const scene = await Scene.findById(req.params.id)
.populate('createdBy', 'username')
.populate('assetId')
.populate('tourId');
if (!scene) return res.status(404).json({ message: 'Scene not found' });
const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
const tour = scene.tourId; // tourId is populated
if (!tour) return res.status(404).json({ message: 'Tour liên kết không tồn tại.' });
const isOwner = req.user && tour.createdBy?.toString() === req.user._id.toString();
const isAdmin = req.user && req.user.role === 'admin';
const isSceneTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
const isTourTokenValid = tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
const userEmail = req.user ? req.user.email : null;
let hasAccess = scene.privacy === 'public' ||
(scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) ||
(req.user && scene.createdBy._id.toString() === req.user._id.toString()) ||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid);
let hasAccess = tour.privacy === 'public' || isOwner || isAdmin ||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isSceneTokenValid) || // Access via scene's token
(tour.privacy === 'shared' && req.query.token === tour.shareToken && isTourTokenValid) || // Access via tour's token
(tour.privacy === 'member' && req.user && ( // Access for members
tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
(userEmail && tour.sharedEmails.includes(userEmail))
));
// [BRIDGE ACCESS LOGIC]
// Nếu chưa có quyền, kiểm tra xem người dùng có đến từ một cảnh hợp lệ thuộc Tour khác không
@@ -161,9 +183,20 @@ router.get('/:id', optionalAuth, async (req, res) => {
if (!hasAccess) return res.status(403).json({ message: 'Bạn không được phép di chuyển đến cảnh này' });
// [BẢO MẬT] Một cảnh là cảnh con nếu nó thuộc về một tour và tourId khác với ID chính nó
const isChild = scene.tourId && scene.tourId.toString() !== scene._id.toString();
res.json({ ...scene.toObject(), isChildScene: !!isChild });
// Increment view count if not owner/admin and not a bot
if (!isOwner && !isAdmin && !req.headers['user-agent']?.match(/bot|crawl|spider/i)) {
scene.views = (scene.views || 0) + 1;
const today = new Date();
today.setHours(0, 0, 0, 0);
const viewEntry = scene.viewHistory.find(entry => new Date(entry.date).setHours(0,0,0,0) === today.getTime());
if (viewEntry) {
viewEntry.count++;
} else {
scene.viewHistory.push({ date: today, count: 1 });
}
await scene.save();
}
res.json(scene);
} catch (error) {
res.status(500).json({ message: error.message });
}
@@ -179,12 +212,10 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => {
return res.status(403).json({ message: 'Not authorized' });
}
// [BẢO MẬT] Kiểm tra nếu là cảnh con thì chặn thay đổi Privacy
// Dựa vào tourId để xác định quan hệ cha-con chính xác, tránh bị nhầm bởi liên kết chéo (cross-link)
const isChild = scene.tourId && scene.tourId.toString() !== scene._id.toString();
if (isChild && privacy && privacy !== scene.privacy) {
// [BẢO MẬT] Chặn thay đổi Privacy trực tiếp trên Scene. Phải thông qua Tour.
if (privacy && privacy !== scene.privacy) {
return res.status(403).json({
message: "Cảnh này thuộc một tour. Vui lòng thay đổi quyền riêng tư tại Cảnh gốc để đồng bộ."
message: "Quyền riêng tư phải được quản lý tập trung tại cấp độ Tour."
});
}
@@ -248,19 +279,10 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => {
if (!scene.tourId) scene.tourId = scene._id;
await scene.save();
// [BẢO MẬT] Lan truyền Privacy xuống các cảnh con nếu đây là cảnh gốc của Tour.
const isRoot = scene.tourId && scene.tourId.toString() === scene._id.toString();
if (isRoot) {
// [TASK 2] Chuẩn hóa dữ liệu truyền vào helper
// Chuyển đổi sharedWith thành mảng string ID thuần túy để tránh lỗi Mongoose
await propagateScenePrivacy(scene._id, {
privacy: scene.privacy,
shareToken: scene.shareToken,
shareTokenExpires: scene.shareTokenExpires,
sharedWith: scene.sharedWith.map(id => id.toString ? id.toString() : id),
sharedEmails: scene.sharedEmails
}, req.user._id);
// Cập nhật lại vị trí trung tâm của Tour nếu tọa độ của Scene này thay đổi
if (lat || lng) {
const tourController = require('../middlewares/TourController');
if (tourController.updateTourCenter) await tourController.updateTourCenter(scene.tourId);
}
res.json({ message: 'Cập nhật thành công và đã đồng bộ quyền riêng tư cho các cảnh liên quan.', scene });
@@ -280,8 +302,38 @@ router.delete('/:id', protect, async (req, res) => {
return res.status(403).json({ message: 'Forbidden' });
}
let tourId = rootScene.tourId;
const { deletedCount } = await deleteSceneCascade(rootSceneId, req.user._id);
// --- NEW LOGIC TO UPDATE PARENT TOUR ---
if (tourId) {
const tour = await Tour.findById(tourId);
if (tour) {
// Remove the deleted scene from the tour's scenes array
tour.scenes = tour.scenes.filter(sId => sId.toString() !== rootSceneId.toString());
// If the deleted scene was the rootSceneId, find a new root or set to null
if (tour.rootSceneId && tour.rootSceneId.toString() === rootSceneId.toString()) {
tour.rootSceneId = tour.scenes.length > 0 ? tour.scenes[0] : null;
}
// [KIỂM TRA CHÍNH XÁC] Đếm số lượng scene thực tế còn lại trong database của Tour này
const actualRemainingScenes = await Scene.countDocuments({ tourId: tour._id });
if (actualRemainingScenes === 0) {
await Tour.findByIdAndDelete(tour._id);
return res.json({
message: `Đã xóa Tour "${tour.name}" vì không còn cảnh nào bên trong.`,
tourDeleted: true
});
} else {
await tour.save();
}
}
}
// --- END NEW LOGIC ---
res.json({
message: deletedCount > 1
? `Đã xóa scene cha và ${deletedCount - 1} scene con liên quan.`
+83
View File
@@ -0,0 +1,83 @@
const mongoose = require('mongoose');
const connectDB = require('../config/db');
const Scene = require('../models/Scene');
const Tour = require('../models/Tour');
/**
* Script migration Giai đoạn 3:
* 1. Tạo các bản ghi Tour tương ứng cho mỗi "Cảnh gốc" (Dựa trên tourId cũ).
* 2. Gán lại tourId của tất cả các cảnh con vào bản ghi Tour mới (Ref: Tour).
* 3. Chuyển thông tin chia sẻ từ Scene gốc sang Tour làm nguồn dữ liệu chính.
*/
const migrateToTours = async () => {
try {
console.log('--- Bắt đầu quy trình migration sang cấu trúc Tour-centric ---');
await connectDB();
// Lấy danh sách tất cả các tourId hiện có (trước đây trỏ đến Scene ID)
// Sử dụng distinct để lọc ra các nhóm Tour độc lập
const oldTourIds = await Scene.distinct('tourId');
console.log(`Tìm thấy ${oldTourIds.length} nhóm cảnh cần chuyển đổi sang Tour.`);
for (const oldId of oldTourIds) {
if (!oldId) continue;
// Tìm "Cảnh gốc" của tour này (Cảnh có ID trùng với tourId cũ)
// Nếu không tìm thấy (do dữ liệu cũ lỗi), lấy cảnh đầu tiên trong nhóm làm gốc
let rootScene = await Scene.findById(oldId).lean();
if (!rootScene) {
rootScene = await Scene.findOne({ tourId: oldId }).lean();
}
if (!rootScene) {
console.warn(`[!] Không tìm thấy dữ liệu cảnh cho tourId cũ: ${oldId}. Bỏ qua.`);
continue;
}
console.log(`Đang tạo Tour cho: ${rootScene.name || rootScene.title} (${rootScene._id})`);
// 1. Khởi tạo Tour mới và sao chép thông tin chia sẻ từ Scene gốc
const newTour = new Tour({
name: rootScene.name || rootScene.title || "Tour mới",
description: rootScene.description || "",
location: {
lat: rootScene.gps?.lat || 0,
lng: rootScene.gps?.lng || 0
},
createdBy: rootScene.createdBy,
rootSceneId: rootScene._id,
privacy: rootScene.privacy || 'private',
shareToken: rootScene.shareToken,
shareTokenExpires: rootScene.shareTokenExpires,
sharedWith: rootScene.sharedWith || [],
sharedEmails: rootScene.sharedEmails || [],
scenes: [] // Sẽ được cập nhật danh sách ID cảnh con bên dưới
});
// 2. Thu thập tất cả các cảnh con thuộc tour này
const memberScenes = await Scene.find({ tourId: oldId });
newTour.scenes = memberScenes.map(s => s._id);
await newTour.save();
// 3. Cập nhật tourId của tất cả cảnh trỏ về bản ghi Tour (ObjectId) mới tạo
// Việc này chuyển đổi từ quan hệ Scene -> Scene sang Scene -> Tour
await Scene.updateMany(
{ tourId: oldId },
{ $set: { tourId: newTour._id } }
);
console.log(` -> Thành công: Tour [${newTour._id}] đã nhận ${memberScenes.length} cảnh.`);
}
console.log('--- Hoàn tất migration sang cấu trúc Tour! ---');
mongoose.connection.close();
process.exit(0);
} catch (error) {
console.error('Lỗi Migration:', error.message);
if (mongoose.connection) mongoose.connection.close();
process.exit(1);
}
};
migrateToTours();
+69
View File
@@ -0,0 +1,69 @@
const mongoose = require('mongoose');
const connectDB = require('../config/db');
const User = require('../models/User');
const Asset = require('../models/Asset');
/**
* Script migration để chuẩn hóa thông tin người dùng:
* 1. Chuyển đổi các Role cũ (Chủ sở hữu, editor, Thành viên) sang enum mới.
* 2. Khởi tạo/Cập nhật object storage (used/quota) dựa trên dữ liệu thực tế từ Asset.
*/
const migrateUsers = async () => {
try {
console.log('--- Bắt đầu quy trình migration User ---');
await connectDB();
const users = await User.find({});
console.log(`Tìm thấy ${users.length} người dùng cần rà soát.`);
for (const user of users) {
console.log(`Đang xử lý user: ${user.username} (${user._id})`);
// 1. Chuẩn hóa Role
// Bản cũ có thể có: 'admin', 'Chủ sở hữu', 'editor', 'moderator', 'Thành viên'
let oldRole = user.role;
if (oldRole === 'Chủ sở hữu') user.role = 'admin';
else if (oldRole === 'editor' || oldRole === 'Thành viên') user.role = 'user';
const validRoles = ['admin', 'moderator', 'user'];
if (!validRoles.includes(user.role)) {
user.role = 'user';
}
// 1.1. Đảm bảo trường agreedToRules tồn tại và có giá trị
if (user.agreedToRules === undefined || user.agreedToRules === null) {
user.agreedToRules = true; // Giả định người dùng cũ đã đồng ý
}
// 2. Tính toán dung lượng đã sử dụng từ Asset thực tế
const usage = await Asset.aggregate([
{ $match: { uploadedBy: user._id } },
{ $group: { _id: null, total: { $sum: "$fileSize" } } }
]);
const usedBytes = usage.length > 0 ? usage[0].total : 0;
// 3. Cập nhật cấu trúc storage
// Nếu user đã có quota riêng thì giữ lại, nếu không dùng mặc định 5GB (5368709120 bytes)
const currentQuota = user.storage && user.storage.quota ? user.storage.quota : 5368709120;
user.storage = {
used: usedBytes,
quota: currentQuota
};
// Lưu thay đổi (Middleware hash password sẽ không chạy vì password không bị sửa)
await user.save();
console.log(` -> Cập nhật: Role [${oldRole} -> ${user.role}] | Storage: ${(usedBytes / (1024*1024)).toFixed(2)} MB / ${(currentQuota / (1024*1024*1024)).toFixed(0)} GB`);
}
console.log('--- Hoàn tất migration User! ---');
mongoose.connection.close();
process.exit(0);
} catch (error) {
console.error('Lỗi Migration:', error.message);
if (mongoose.connection) mongoose.connection.close();
process.exit(1);
}
};
migrateUsers();
+29 -89
View File
@@ -1,110 +1,50 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
const dotenv = require('dotenv');
const connectDB = require('./config/db');
const authRoutes = require('./routes/authRoutes');
const apiRoutes = require('./routes/apiRoutes');
// Khởi động Image Processing Worker
require('./routes/imageWorker');
// Cấu hình môi trường
dotenv.config();
// Connect to Database
// Kiểm tra các biến môi trường bắt buộc
const requiredEnvs = ['MONGODB_URI', 'JWT_SECRET'];
requiredEnvs.forEach(env => {
if (!process.env[env]) {
console.error(`[CRITICAL] Thiếu biến môi trường bắt buộc: ${env}`);
process.exit(1);
}
});
// Kết nối cơ sở dữ liệu MongoDB
connectDB();
const app = express();
// Standard middlewares
// Chuẩn bị danh sách các origin được phép cho CORS
const primarySystemHost = process.env.SYSTEM_HOST || 'http://localhost:5000';
let configuredAllowedOrigins = [];
// Thêm SYSTEM_HOST chính
try {
configuredAllowedOrigins.push(new URL(primarySystemHost).origin);
} catch (e) {
console.warn(`[CORS Config Warning] Malformed SYSTEM_HOST: ${primarySystemHost}. Using as-is.`);
configuredAllowedOrigins.push(primarySystemHost);
}
// Thêm các origin bổ sung từ biến môi trường ADDITIONAL_ALLOWED_ORIGINS (cách nhau bởi dấu phẩy)
if (process.env.ADDITIONAL_ALLOWED_ORIGINS) {
process.env.ADDITIONAL_ALLOWED_ORIGINS.split(',').forEach(originStr => {
try {
configuredAllowedOrigins.push(new URL(originStr.trim()).origin);
} catch (e) {
console.warn(`[CORS Config Warning] Malformed origin in ADDITIONAL_ALLOWED_ORIGINS: ${originStr.trim()}. Skipping.`);
}
});
}
const corsOptions = {
origin: function (origin, callback) {
// Cho phép các request không có origin (như Postman hoặc khi render phía server)
if (!origin) return callback(null, true);
let incomingOrigin;
try {
incomingOrigin = new URL(origin).origin;
} catch (e) {
incomingOrigin = origin;
}
// Kiểm tra nếu incomingOrigin nằm trong danh sách các origin được cấu hình
if (configuredAllowedOrigins.includes(incomingOrigin)) return callback(null, true);
// Trong môi trường dev, cho phép các biến thể localhost
const isLocal = incomingOrigin.includes('localhost') || incomingOrigin.includes('127.0.0.1') || incomingOrigin.includes('::1');
if (process.env.NODE_ENV !== 'production' && isLocal) {
return callback(null, true);
}
console.warn(`[CORS Blocked]: Origin ${origin} is not allowed by configuration.`);
callback(new Error('Not allowed by CORS'));
},
credentials: true,
maxAge: 86400 // Cho phép trình duyệt cache kết quả preflight OPTIONS trong 24 giờ
};
app.use(cors(corsOptions));
// Middlewares cơ bản
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Request Logger Middleware
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`);
});
next();
});
// Khởi tạo Worker xử lý ảnh (BullMQ + Redis)
// Việc import này sẽ kích hoạt imageWorker.js lắng nghe hàng đợi 'image-processing'
require('./routes/imageWorker');
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api', apiRoutes);
// Đăng ký các API Routes tập trung
app.use('/api', require('./routes/apiRoutes'));
// Serve Frontend static assets from the parent/frontend directory
// Phục vụ các tệp tĩnh từ thư mục frontend
app.use(express.static(path.join(__dirname, '../frontend')));
// Fallback to index.html for single-page style behaviors
app.use((req, res) => {
// Hỗ trợ Single Page Application (SPA)
// Mọi request không khớp với API hoặc File tĩnh sẽ trả về index.html
app.get(/.*/, (req, res) => {
res.sendFile(path.join(__dirname, '../frontend/index.html'));
});
// Centralized JSON Error Handler (Ngăn chặn lỗi trả về HTML làm hỏng Frontend)
app.use((err, req, res, next) => {
console.error(`[Error Handler]: ${err.message}`);
res.status(err.status || 500).json({
message: err.message || 'Internal Server Error'
});
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server is running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
console.log(`CORS Allowed Origins: ${configuredAllowedOrigins.join(', ')}`);
});
// ... cuối file server.js
module.exports = app;
console.log(`================================================`);
console.log(`🚀 Server 3D Tours đang chạy tại port: ${PORT}`);
console.log(`🔧 Chế độ: ${process.env.NODE_ENV || 'development'}`);
console.log(`================================================`);
});
+101
View File
@@ -0,0 +1,101 @@
const { updateTourCenter } = require('../middlewares/TourController');
const Scene = require('../models/Scene');
const Tour = require('../models/Tour');
// Mocking Mongoose models
jest.mock('../models/Scene');
jest.mock('../models/Tour');
describe('TourController - updateTourCenter', () => {
const tourId = '507f1f77bcf86cd799439011';
beforeEach(() => {
jest.clearAllMocks();
// Mock console.error to keep test output clean
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
console.error.mockRestore();
});
test('nên tính toán trung bình GPS chính xác từ nhiều cảnh hợp lệ', async () => {
const mockScenes = [
{ gps: { lat: 10.0, lng: 20.0 } },
{ gps: { lat: 20.0, lng: 40.0 } },
{ gps: { lat: 30.0, lng: 60.0 } }
];
Scene.find.mockReturnValue({
select: jest.fn().mockResolvedValue(mockScenes)
});
await updateTourCenter(tourId);
// Trung bình: lat (10+20+30)/3 = 20, lng (20+40+60)/3 = 40
expect(Tour.findByIdAndUpdate).toHaveBeenCalledWith(tourId, {
location: { lat: 20.0, lng: 40.0 }
});
});
test('nên bỏ qua các cảnh có tọa độ (0,0), null hoặc không phải là số', async () => {
const mockScenes = [
{ gps: { lat: 10.0, lng: 20.0 } },
{ gps: { lat: 0, lng: 0 } }, // Bỏ qua (0,0)
{ gps: null }, // Bỏ qua null
{ gps: { lat: 'invalid', lng: 30 } }, // Bỏ qua vì không phải số
{ gps: { lat: 20.0, lng: 40.0 } }
];
Scene.find.mockReturnValue({
select: jest.fn().mockResolvedValue(mockScenes)
});
await updateTourCenter(tourId);
// Chỉ tính 2 cảnh hợp lệ: lat (10+20)/2 = 15, lng (20+40)/2 = 30
expect(Tour.findByIdAndUpdate).toHaveBeenCalledWith(tourId, {
location: { lat: 15.0, lng: 30.0 }
});
});
test('không nên cập nhật Tour nếu không tìm thấy cảnh nào', async () => {
Scene.find.mockReturnValue({
select: jest.fn().mockResolvedValue([])
});
await updateTourCenter(tourId);
expect(Tour.findByIdAndUpdate).not.toHaveBeenCalled();
});
test('không nên cập nhật Tour nếu không có cảnh nào mang GPS hợp lệ', async () => {
const mockScenes = [
{ gps: { lat: 0, lng: 0 } },
{ gps: null }
];
Scene.find.mockReturnValue({
select: jest.fn().mockResolvedValue(mockScenes)
});
await updateTourCenter(tourId);
expect(Tour.findByIdAndUpdate).not.toHaveBeenCalled();
});
test('nên log lỗi ra console nếu truy vấn Database thất bại', async () => {
const errorMessage = 'Database connection lost';
Scene.find.mockImplementation(() => {
throw new Error(errorMessage);
});
await updateTourCenter(tourId);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining(`Error updating center for tour ${tourId}`),
errorMessage
);
expect(Tour.findByIdAndUpdate).not.toHaveBeenCalled();
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 MiB

+43 -42
View File
@@ -7,76 +7,77 @@ const { logActivity } = require('./logger');
/**
* Xóa dây chuyền một Scene và tất cả các Scene con liên quan (BFS).
* Tuân thủ logic: Xóa cha thì xóa con, xóa con không xóa cha.
* @param {string} rootSceneId - ID của Scene gốc cần xóa
* @param {string} rootSceneId - ID của Scene cần xóa
* @param {string} performer - Tên người thực hiện thao tác
* @returns {Promise<{deletedCount: number}>} Số lượng scene đã xóa
*/
const deleteSceneCascade = async (rootSceneId, performer = 'System') => {
// 0. Xác định tourId của scene gốc để thiết lập biên giới xóa
const rootScene = await Scene.findById(rootSceneId);
if (!rootScene) return { deletedCount: 0 };
const tourId = rootScene.tourId ? rootScene.tourId.toString() : null;
const scene = await Scene.findById(rootSceneId);
if (!scene) return { deletedCount: 0 };
// [BIÊN GIỚI TOUR] Xác định danh sách cần xóa
const isRoot = tourId && tourId === rootSceneId.toString();
let scenesToDelete = [];
if (isRoot) {
// Nếu xóa gốc: Xóa mọi thứ thuộc tourId này (Bao gồm con đẻ, loại trừ liên kết)
const tourScenes = await Scene.find({ tourId: rootScene.tourId }).select('_id');
scenesToDelete = tourScenes.map(s => s._id.toString());
} else {
// Nếu xóa cảnh con lẻ: Chỉ xóa đúng nó
scenesToDelete = [rootSceneId.toString()];
}
const sceneId = rootSceneId.toString();
const scenesToDelete = [sceneId];
// 1. Thu thập Asset ID
// 2. Thu thập tất cả Asset ID liên quan
const scenes = await Scene.find({ _id: { $in: scenesToDelete } });
const assetIds = scenes.map(s => s.assetId).filter(id => id);
const assetIds = [scene.assetId].filter(id => id);
const assets = await Asset.find({ _id: { $in: assetIds } });
// 3. Xóa tệp tin vật lý trên đĩa (Bất đồng bộ)
// 2. Xóa tệp tin vật lý trên đĩa (Bất đồng bộ)
await Promise.all(assets.map(async asset => {
if (asset.filePath) await fs.promises.unlink(asset.filePath).catch(() => {});
}));
// 4. Dọn dẹp Hotspots (Cả link đi từ cảnh bị xóa link từ các tour khác trỏ ĐẾN cảnh này)
const hotspotCleanup = await Hotspot.deleteMany({
// 3. Dọn dẹp Hotspots (Link đi và Link đến cảnh này)
const hotspotCleanup = await Hotspot.deleteMany({
$or: [
{ parent_scene_id: { $in: scenesToDelete } },
{ target_scene_id: { $in: scenesToDelete } }
]
{ parent_scene_id: sceneId },
{ target_scene_id: sceneId }
]
});
// 5. Xóa bản ghi trong Database
const assetCleanup = await Asset.deleteMany({ _id: { $in: assetIds } });
const sceneCleanup = await Scene.deleteMany({ _id: { $in: scenesToDelete } });
// 4. Cập nhật Tour cha (Gỡ bỏ reference và cập nhật rootSceneId)
if (scene.tourId) {
const Tour = require('../models/Tour'); // Tránh dependency vòng
const tour = await Tour.findById(scene.tourId);
if (tour) {
tour.scenes = tour.scenes.filter(id => id.toString() !== sceneId);
// Nếu cảnh bị xóa là cảnh khởi đầu, gán lại cảnh đầu tiên còn lại hoặc null
if (tour.rootSceneId && tour.rootSceneId.toString() === sceneId) {
tour.rootSceneId = tour.scenes.length > 0 ? tour.scenes[0] : null;
}
await tour.save();
const tourName = rootScene.name || rootScene.title || 'Chưa đặt tên';
const childCount = scenesToDelete.length > 0 ? scenesToDelete.length - 1 : 0;
await logActivity('CASCADE_DELETE_SCENE', {
message: isRoot ? `Xóa trọn bộ Tour [${tourName}] và ${childCount} cảnh con` : `Xóa cảnh lẻ [${tourName}] khỏi Tour`,
deletedScenesCount: scenesToDelete.length,
// Cập nhật lại vị trí trung tâm của Tour sau khi một cảnh bị xóa khỏi danh sách
const tourController = require('../middlewares/TourController');
if (tourController && tourController.updateTourCenter) {
await tourController.updateTourCenter(tour._id);
}
}
}
// 5. Xóa bản ghi trong Database
await Asset.deleteMany({ _id: { $in: assetIds } });
await Scene.deleteOne({ _id: sceneId });
const sceneName = scene.name || scene.title || 'Chưa đặt tên';
await logActivity('DELETE_SCENE', {
message: `Xóa cảnh [${sceneName}] và các tài nguyên liên quan`,
sceneId: sceneId,
cleanedHotspotsCount: hotspotCleanup.deletedCount
}, performer ? performer.toString() : 'System');
return { deletedCount: scenesToDelete.length };
return { deletedCount: 1 };
};
/**
* Lan truyền thiết lập quyền riêng tư cho toàn bộ Tour dựa trên tourId.
* Đảm bảo tính nhất quán của toàn bộ Tour khi thay đổi quyền truy cập.
* @param {string} rootSceneId - ID của cảnh gốc thực hiện thay đổi
* @param {string} tourId - ID của Tour thực hiện thay đổi
* @param {Object} privacyData - Dữ liệu quyền riêng tư mới
* @param {string} performer - ID người thực hiện (mặc định là System)
*/
const propagateScenePrivacy = async (rootSceneId, privacyData, performer = 'System') => {
const rootScene = await Scene.findById(rootSceneId);
if (!rootScene) return;
const tourId = rootScene.tourId || rootScene._id;
const propagateScenePrivacy = async (tourId, privacyData, performer = 'System') => {
const { privacy, shareToken, shareTokenExpires, sharedWith, sharedEmails } = privacyData;
// 2. Chuẩn bị dữ liệu cập nhật (Chỉ cập nhật Privacy, giữ nguyên tourId)