Refactor giai đoạn 1: test các tính năng vừa thay đổi như tour, scene...
This commit is contained in:
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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);
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.`
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -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(`================================================`);
|
||||
});
|
||||
@@ -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 |
@@ -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 và 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)
|
||||
|
||||
Reference in New Issue
Block a user