Thay đổi ARCHITEC.md cập nhật các thông tin để chuẩn bị refactor lại dự án

This commit is contained in:
2026-06-10 17:13:56 +07:00
parent ec7a9186b6
commit 3f1b31b233
17 changed files with 326 additions and 139 deletions
+45 -23
View File
@@ -4,48 +4,70 @@ Tài liệu này tổng hợp toàn bộ cấu trúc hệ thống phục vụ qu
## 1. Mô hình Dữ liệu (Database Schema - MongoDB) ## 1. Mô hình Dữ liệu (Database Schema - MongoDB)
### User (Người dùng) ### 1.1. User (Người dùng)
- `username`: String (Unique) - `_id`: ObjectId
- `email`: String (Unique) - `username`: String (Unique, bắt buộc)
- `email`: String (Unique, bắt buộc)
- `password`: String (Hashed) - `password`: String (Hashed)
- `role`: String ['admin', 'Chủ sở hữu', 'editor', 'moderator', 'Thành viên'] - `role`: String ['admin', 'moderator', 'user', 'guest'] (Chuẩn hóa mới)
- `fullName`: String - `fullName`: String
- `avatarUrl`: String - `avatarUrl`: String (Đường dẫn stream ảnh đại diện)
- `agreedToRules`: Boolean - `agreedToRules`: Boolean (Trạng thái đồng ý điều khoản)
- `storage.used`: Number (Dung lượng đã dùng - bytes)
- `storage.quota`: Number (Hạn mức dung lượng - bytes)
- `createdAt`: Date
- `updatedAt`: Date
### Asset (Tệp tin/Phương tiện) ### 1.2. Asset (Tệp tin/Phương tiện)
- `_id`: ObjectId
- `filePath`: String (Đường dẫn vật lý) - `filePath`: String (Đường dẫn vật lý)
- `fileSize`: Number (Bytes) - `fileSize`: Number (Bytes)
- `uploadedBy`: ObjectId (Ref: User) - `uploadedBy`: ObjectId (Ref: User)
- `coordinates`: Object { `lat`: Number, `lng`: Number } (GPS từ EXIF) - `coordinates`: Object { `lat`: Number, `lng`: Number } (Tọa độ GPS trích xuất từ EXIF)
- `createdAt`: Date - `createdAt`: Date
### Scene (Cảnh 360) ### 1.3. Tour (Cấu trúc Tour - Đề xuất mới)
- `name`/`title`: String - `_id`: ObjectId
- `description`: String - `name`: String (Tên của tour)
- `assetId`: ObjectId (Ref: Asset) - `description`: String (Mô tả tổng quát)
- `scene_url`: String - `location`: Object { `lat`: Number, `lng`: Number } (Vị trí trung tâm của tour)
- `gps`: Object { `lat`: Number, `lng`: Number }
- `createdBy`: ObjectId (Ref: User) - `createdBy`: ObjectId (Ref: User)
- `tourId`: ObjectId (Ref: Scene) - ID của cảnh gốc tạo nên tour - `rootSceneId`: ObjectId (Ref: Scene - Cảnh khởi đầu)
- `privacy`: String ['public', 'private', 'member', 'shared'] - `privacy`: String ['public', 'private', 'member', 'shared']
- `scenes`: Array [ObjectId (Ref: Scene)] (Danh sách các cảnh thuộc tour)
- `createdAt`: Date
- `updatedAt`: Date
### 1.4. Scene (Cảnh 360)
- `_id`: ObjectId
- `tourId`: ObjectId (Ref: Tour - Tour cha sở hữu)
- `name`: String (Tên cảnh)
- `description`: String (Mô tả chi tiết cảnh)
- `assetId`: ObjectId (Ref: Asset)
- `scene_url`: String (Đường dẫn ảnh đã xử lý)
- `gps`: Object { `lat`: Number, `lng`: Number } (Vị trí địa lý riêng của cảnh)
- `createdBy`: ObjectId (Ref: User)
- `uploadedAt`: Date (Thời gian gốc của ảnh được upload)
- `status`: String ['processing', 'completed', 'failed'] - `status`: String ['processing', 'completed', 'failed']
- `shareToken`: String (Dùng cho link truy cập nhanh) - `shareToken`: String (Dùng cho link chia sẻ)
- `shareTokenExpires`: Date - `shareTokenExpires`: Date
- `sharedWith`: Array [ObjectId (Ref: User)] - `sharedWith`: Array [ObjectId (Ref: User)]
- `sharedEmails`: Array [String] - `sharedEmails`: Array [String]
- `views`: Number - `views`: Number (Tổng lượt xem)
- `viewHistory`: Array [ { `date`: Date, `count`: Number } ] - `viewHistory`: Array [ { `date`: Date, `count`: Number } ]
- `createdAt`: Date
### Hotspot (Điểm điều hướng) ### 1.5. Hotspot / Link (Điểm điều hướng & Liên kết)
- `parent_scene_id`: ObjectId (Ref: Scene) - `_id`: ObjectId
- `target_scene_id`: ObjectId (Ref: Scene) - `parent_scene_id`: ObjectId (Ref: Scene - Cảnh chứa điểm này)
- `title`: String - `target_scene_id`: ObjectId (Ref: Scene - Cảnh đích đến)
- `target_tour_id`: ObjectId (Ref: Tour - Dùng cho liên kết sang tour khác)
- `title`: String (Tên của liên kết/hotspot)
- `description`: String - `description`: String
- `coordinates`: Object { `yaw`: Number, `pitch`: Number } - `coordinates`: Object { `yaw`: Number, `pitch`: Number }
- `is_auto_return`: Boolean (Tự động tạo link quay lại) - `is_auto_return`: Boolean (Đánh dấu link quay lại tự động)
### Setting (Cấu hình hệ thống) ### 1.6. Setting (Cấu hình hệ thống)
- `timezone`: String (Mặc định: 'Asia/Ho_Chi_Minh') - `timezone`: String (Mặc định: 'Asia/Ho_Chi_Minh')
- `language`: String (Mặc định: 'vi') - `language`: String (Mặc định: 'vi')
+10 -2
View File
@@ -51,11 +51,18 @@ router.post('/create', protect, async (req, res) => {
}); });
await hotspot.save(); await hotspot.save();
// Logic tạo liên kết quay lại tự động nếu có scene đích // [BẢO MẬT] Logic tạo liên kết quay lại tự động nếu có scene đích
if (target_scene_id) { if (target_scene_id) {
const targetScene = await Scene.findById(target_scene_id); const targetScene = await Scene.findById(target_scene_id);
if (targetScene) { if (targetScene) {
const reverseYaw = calculateReverseYaw(coordinates.yaw); // [TASK 3] BẢO VỆ tourId & CHẶN VANDALISM:
// Chỉ tự động tạo link quay lại (tức là ghi dữ liệu vào cảnh đích)
// nếu người dùng hiện tại cũng là chủ sở hữu của cảnh đích đó.
// Điều này đảm bảo:
// 1. Không thay đổi cấu trúc tour của người khác khi tạo liên kết chéo (Cross-link).
// 2. Tuyệt đối không can thiệp vào trường 'tourId' của targetScene.
if (targetScene.createdBy.toString() === req.user._id.toString()) {
const reverseYaw = calculateReverseYaw(coordinates.yaw);
const reverseHotspot = new Hotspot({ const reverseHotspot = new Hotspot({
parent_scene_id: target_scene_id, parent_scene_id: target_scene_id,
target_scene_id: parent_scene_id, target_scene_id: parent_scene_id,
@@ -64,6 +71,7 @@ router.post('/create', protect, async (req, res) => {
is_auto_return: true is_auto_return: true
}); });
await reverseHotspot.save(); await reverseHotspot.save();
}
} }
} }
+49 -16
View File
@@ -34,16 +34,43 @@ const uploadSinglePanorama = (req, res, next) => {
// @route POST /api/scenes // @route POST /api/scenes
router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) => { router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) => {
try { try {
const { title, lat, lng, privacy, sharedWithUsers, tourId } = req.body; 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' }); if (!req.file) return res.status(400).json({ message: 'Please upload a panorama image' });
// [BẢO MẬT] Làm sạch tourId từ client gửi lên // [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 !== '') ? tourId : undefined; 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
try { if (sharedWithUsers) finalSharedWith = JSON.parse(sharedWithUsers); } catch (e) {}
// [BẢO MẬT] Xác thực tourId nếu được cung cấp // [BẢO MẬT] Xác thực tourId nếu được cung cấp
if (cleanedTourId) { if (cleanedTourId) {
const tourExists = await Scene.exists({ _id: cleanedTourId }); const rootScene = await Scene.findById(cleanedTourId);
if (!tourExists) return res.status(400).json({ message: 'Tour gốc không tồn tại hoặc đã bị xóa.' }); 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');
}
} }
const latitude = Number(lat) || 0; const latitude = Number(lat) || 0;
@@ -60,21 +87,19 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) =>
}); });
await asset.save(); await asset.save();
let shareToken = privacy === 'shared' ? crypto.randomBytes(24).toString('hex') : undefined;
let parsedSharedWith = [];
try { if (sharedWithUsers) parsedSharedWith = JSON.parse(sharedWithUsers); } catch (e) {}
const scene = new Scene({ const scene = new Scene({
name: title, name: title,
assetId: asset._id, assetId: asset._id,
scene_url: tempFilePath, scene_url: tempFilePath,
gps: { lat: latitude, lng: longitude }, gps: { lat: latitude, lng: longitude },
createdBy: req.user._id, createdBy: req.user._id,
privacy: privacy || 'private', privacy: finalPrivacy,
shareToken, shareToken: finalShareToken,
sharedWith: parsedSharedWith, shareTokenExpires: finalExpires,
sharedWith: finalSharedWith,
sharedEmails: finalSharedEmails,
status: 'processing', status: 'processing',
tourId: cleanedTourId tourId: assignedTourId
}); });
// Mặc định mỗi cảnh mới khi tạo ra là cảnh gốc của chính nó // 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; if (!scene.tourId) scene.tourId = scene._id;
@@ -219,15 +244,23 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => {
await fs.promises.unlink(req.file.path).catch(() => {}); await fs.promises.unlink(req.file.path).catch(() => {});
} }
// [FIX] Đảm bảo root scene luôn có tourId để logic lan truyền hoạt động (cho cả dữ liệu cũ) // Đảm bảo tính nhất quán: Nếu không có tourId cha, scene này tự làm gốc
if (!scene.tourId) scene.tourId = scene._id; if (!scene.tourId) scene.tourId = scene._id;
await scene.save(); 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. // [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(); const isRoot = scene.tourId && scene.tourId.toString() === scene._id.toString();
if (isRoot) { if (isRoot) {
await propagateScenePrivacy(scene._id, scene, req.user._id); // [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);
} }
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 }); 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 });
+119
View File
@@ -0,0 +1,119 @@
const mongoose = require('mongoose');
const connectDB = require('../config/db');
const User = require('../models/User');
const Scene = require('../models/Scene');
/**
* Script rà soát tính toàn vẹn của tourId và quyền sở hữu.
* Mục tiêu:
* 1. Phát hiện các Scene không có tourId (Mồ côi).
* 2. Phát hiện các Scene trỏ tourId vào một cảnh không tồn tại (Link hỏng).
* 3. Phát hiện rủi ro Scenario 2: Scene của người A nhưng tourId lại trỏ vào Tour của người B.
*/
const auditTourIds = async () => {
try {
console.log('--- BẮT ĐẦU RÀ SOÁT TOUR ID ---');
await connectDB();
// Lấy tất cả scene và populate thông tin người tạo
const scenes = await Scene.find().populate('createdBy', 'username');
console.log(`Đang kiểm tra ${scenes.length} bản ghi...\n`);
const report = {
orphan: [], // Không có tourId
brokenLink: [], // tourId trỏ vào hư vô
mismatchOwner: [], // Chủ sở hữu không khớp (Rủi ro Scenario 2)
validRoots: 0,
validChildren: 0
};
for (const scene of scenes) {
const sId = scene._id.toString();
// 1. Kiểm tra tồn tại tourId
// [ROBUST CHECK] Kiểm tra cả giá trị null, undefined và chuỗi rác
const tourIdRaw = scene.tourId;
if (!tourIdRaw || tourIdRaw === "" || tourIdRaw === "null" || tourIdRaw === "undefined") {
report.orphan.push({
id: sId,
name: scene.name || scene.title,
value: JSON.stringify(tourIdRaw) // In ra giá trị thực tế để debug
});
continue;
}
const tId = scene.tourId.toString();
// Trường hợp là Root (Cảnh gốc của chính nó)
if (sId === tId) {
report.validRoots++;
continue;
}
// Trường hợp là Child -> Kiểm tra quan hệ với cha
const rootScene = await Scene.findById(scene.tourId).populate('createdBy', 'username');
if (!rootScene) {
report.brokenLink.push({ id: sId, name: scene.name || scene.title, target: tId });
continue;
}
// 2. KIỂM TRA QUAN TRỌNG: Đồng nhất chủ sở hữu (Scenario 2)
// Nếu scene con có chủ sở hữu khác với scene gốc mà nó đang trỏ tourId vào,
// nghĩa là quyền riêng tư của nó đang bị điều khiển bởi một người khác.
const sceneOwner = scene.createdBy?._id?.toString() || scene.createdBy?.toString();
const rootOwner = rootScene.createdBy?._id?.toString() || rootScene.createdBy?.toString();
if (sceneOwner !== rootOwner) {
report.mismatchOwner.push({
childId: sId,
childName: scene.name || scene.title,
childOwner: scene.createdBy?.username || 'N/A',
parentId: tId,
parentName: rootScene.name || rootScene.title,
parentOwner: rootScene.createdBy?.username || 'N/A'
});
} else {
report.validChildren++;
}
}
// --- XUẤT BÁO CÁO ---
console.log('=== KẾT QUẢ RÀ SOÁT ===');
console.log(`- Scene gốc hợp lệ: ${report.validRoots}`);
console.log(`- Scene con hợp lệ: ${report.validChildren}`);
console.log('-----------------------');
if (report.orphan.length > 0) {
console.error(`[!] LỖI: ${report.orphan.length} Scene mồ côi (thiếu tourId):`);
report.orphan.forEach(x => console.log(` - ID: ${x.id} | Tên: ${x.name}`));
}
if (report.brokenLink.length > 0) {
console.error(`[!] LỖI: ${report.brokenLink.length} Scene trỏ vào Tour không tồn tại:`);
report.brokenLink.forEach(x => console.log(` - ID: ${x.id} | Tên: ${x.name} -> Target: ${x.target}`));
}
if (report.mismatchOwner.length > 0) {
console.warn(`[!] CẢNH BÁO: ${report.mismatchOwner.length} Scene bị "lây nhiễm" tourId (Nguy cơ Scenario 2):`);
report.mismatchOwner.forEach(x => {
console.log(` - Cảnh [${x.childName}] (ID: ${x.childId}) của user [${x.childOwner}]`);
console.log(` đang bị điều khiển bởi Tour [${x.parentName}] của user [${x.parentOwner}]`);
console.log(` => GIẢI PHÁP: Cần cập nhật tourId của cảnh này về chính nó.\n`);
});
}
if (report.orphan.length === 0 && report.brokenLink.length === 0 && report.mismatchOwner.length === 0) {
console.log('[✓] Database sạch sẽ. Không phát hiện lỗi tourId hay xâm lấn quyền sở hữu.');
}
console.log('\n--- HOÀN TẤT ---');
mongoose.connection.close();
process.exit(0);
} catch (err) {
console.error('Lỗi thực thi script:', err);
process.exit(1);
}
};
auditTourIds();
+42
View File
@@ -0,0 +1,42 @@
const mongoose = require('mongoose');
const connectDB = require('../config/db');
const Scene = require('../models/Scene');
/**
* Script sửa lỗi các Scene mồ côi (không có tourId)
* Logic: Nếu một Scene không có tourId, nó sẽ được gán tourId = _id (trở thành Root)
*/
const fixOrphans = async () => {
try {
await connectDB();
// Sử dụng logic quét rộng tương tự audit script để tìm tất cả các loại "rác" tourId
const allScenes = await Scene.find({});
const orphans = allScenes.filter(s =>
!s.tourId ||
s.tourId === "" ||
s.tourId === "null" ||
s.tourId === "undefined"
);
console.log(`Tìm thấy ${orphans.length} scene mồ côi. Đang xử lý...`);
for (const scene of orphans) {
// [FIX] Sử dụng updateOne trực tiếp trên collection để bypass Schema validation
// Đảm bảo dữ liệu CHẮC CHẮN được ghi xuống Database
await Scene.collection.updateOne(
{ _id: scene._id },
{ $set: { tourId: scene._id } }
);
console.log(`- [FIXED] Scene: ${scene.name || scene.title} (ID: ${scene._id}) -> Đã trở thành Tour Gốc`);
}
console.log('\n--- HOÀN TẤT SỬA LỖI DỮ LIỆU ---');
mongoose.connection.close();
} catch (err) {
console.error('Lỗi thực thi:', err);
process.exit(1);
}
};
fixOrphans();
+4 -2
View File
@@ -68,8 +68,10 @@ const migrateTourIds = async () => {
} }
} }
// Bước 3: Xử lý các cảnh mồ côi hoặc vòng lặp kín (tự trỏ về chính mình làm gốc) // Bước 3: Xử lý các cảnh mồ côi, lỗi tourId null/rỗng hoặc vòng lặp kín
const orphanScenes = await Scene.find({ tourId: { $exists: false } }); const orphanScenes = await Scene.find({
$or: [{ tourId: { $exists: false } }, { tourId: null }, { tourId: "" }]
});
let orphanCount = 0; let orphanCount = 0;
for (const scene of orphanScenes) { for (const scene of orphanScenes) {
await Scene.updateOne({ _id: scene._id }, { $set: { tourId: scene._id } }); await Scene.updateOne({ _id: scene._id }, { $set: { tourId: scene._id } });
+27 -86
View File
@@ -16,51 +16,21 @@ const deleteSceneCascade = async (rootSceneId, performer = 'System') => {
const rootScene = await Scene.findById(rootSceneId); const rootScene = await Scene.findById(rootSceneId);
if (!rootScene) return { deletedCount: 0 }; if (!rootScene) return { deletedCount: 0 };
const tourId = rootScene.tourId ? rootScene.tourId.toString() : null; const tourId = rootScene.tourId ? rootScene.tourId.toString() : null;
const tourIdStr = tourId || rootSceneId.toString();
// [Discovery BFS] Tìm kiếm các cảnh dựa trên liên kết thực tế để xử lý dữ liệu lỗi (Broken Root) // [BIÊN GIỚI TOUR] Xác định danh sách cần xóa
let queue = [rootSceneId.toString()]; const isRoot = tourId && tourId === rootSceneId.toString();
let scenesToDelete = [rootSceneId.toString()]; let scenesToDelete = [];
const visited = new Set(scenesToDelete);
while (queue.length > 0) { if (isRoot) {
const parentId = queue.shift(); // 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 childHotspots = await Hotspot.find({ const tourScenes = await Scene.find({ tourId: rootScene.tourId }).select('_id');
parent_scene_id: parentId, scenesToDelete = tourScenes.map(s => s._id.toString());
is_auto_return: { $ne: true } } else {
}).populate('target_scene_id', 'tourId'); // Nếu xóa cảnh con lẻ: Chỉ xóa đúng nó
scenesToDelete = [rootSceneId.toString()];
for (const hs of childHotspots) {
if (hs.target_scene_id && typeof hs.target_scene_id === 'object') {
const targetScene = hs.target_scene_id;
const targetIdStr = targetScene._id.toString();
const targetTourId = targetScene.tourId ? targetScene.tourId.toString() : null;
// [Biên giới Tour] Chỉ xóa nếu:
// 1. Cùng tourId
// 2. Hoặc là Broken Root (tourId tự trỏ về chính nó nhưng lại được liên kết ở đây)
// 3. Hoặc là Orphan (không có tourId)
const isSameTour = targetTourId === tourIdStr;
const isBrokenRoot = targetTourId === targetIdStr;
const isOrphan = !targetTourId;
if (!visited.has(targetIdStr) && (isSameTour || isBrokenRoot || isOrphan)) {
visited.add(targetIdStr);
scenesToDelete.push(targetIdStr);
queue.push(targetIdStr);
}
}
}
} }
// 1. Dọn dẹp Hotspots (Cả link đi và link trỏ ĐẾN các scene sắp xóa) // 1. Thu thập Asset ID
const hotspotCleanup = await Hotspot.deleteMany({
$or: [
{ parent_scene_id: { $in: scenesToDelete } },
{ target_scene_id: { $in: scenesToDelete } }
]
});
// 2. Thu thập tất cả Asset ID liên quan // 2. Thu thập tất cả Asset ID liên quan
const scenes = await Scene.find({ _id: { $in: scenesToDelete } }); const scenes = await Scene.find({ _id: { $in: scenesToDelete } });
const assetIds = scenes.map(s => s.assetId).filter(id => id); const assetIds = scenes.map(s => s.assetId).filter(id => id);
@@ -71,20 +41,22 @@ const deleteSceneCascade = async (rootSceneId, performer = 'System') => {
if (asset.filePath) await fs.promises.unlink(asset.filePath).catch(() => {}); if (asset.filePath) await fs.promises.unlink(asset.filePath).catch(() => {});
})); }));
// 3. Xóa bản ghi trong Database // 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({
$or: [
{ parent_scene_id: { $in: scenesToDelete } },
{ target_scene_id: { $in: scenesToDelete } }
]
});
// 5. Xóa bản ghi trong Database
const assetCleanup = await Asset.deleteMany({ _id: { $in: assetIds } }); const assetCleanup = await Asset.deleteMany({ _id: { $in: assetIds } });
const sceneCleanup = await Scene.deleteMany({ _id: { $in: scenesToDelete } }); const sceneCleanup = await Scene.deleteMany({ _id: { $in: scenesToDelete } });
// Chuẩn bị nội dung thông báo cho log
const tourName = rootScene.name || rootScene.title || 'Chưa đặt tên'; const tourName = rootScene.name || rootScene.title || 'Chưa đặt tên';
const childCount = scenesToDelete.length > 0 ? scenesToDelete.length - 1 : 0; const childCount = scenesToDelete.length > 0 ? scenesToDelete.length - 1 : 0;
// Xác định xem đây là xóa cả Tour hay xóa lẻ
const isRootAction = tourIdStr === rootSceneId.toString();
// Ghi log hoạt động xóa chi tiết để dễ dàng truy vết và kiểm tra tính toàn vẹn
await logActivity('CASCADE_DELETE_SCENE', { await logActivity('CASCADE_DELETE_SCENE', {
message: isRootAction ? `Xóa trọn bộ Tour [${tourName}] và ${childCount} cảnh con` : `Xóa cảnh lẻ [${tourName}] khỏi Tour`, 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, deletedScenesCount: scenesToDelete.length,
cleanedHotspotsCount: hotspotCleanup.deletedCount cleanedHotspotsCount: hotspotCleanup.deletedCount
}, performer ? performer.toString() : 'System'); }, performer ? performer.toString() : 'System');
@@ -104,53 +76,22 @@ const propagateScenePrivacy = async (rootSceneId, privacyData, performer = 'Syst
if (!rootScene) return; if (!rootScene) return;
const tourId = rootScene.tourId || rootScene._id; const tourId = rootScene.tourId || rootScene._id;
const tourIdStr = tourId.toString();
// 1. Tìm tất cả cảnh con cần cập nhật bằng BFS để đảm bảo tính "tự chữa lành" cho tourId
let queue = [rootSceneId.toString()];
let scenesToUpdate = [rootSceneId.toString()];
const visited = new Set(scenesToUpdate);
while (queue.length > 0) {
const parentId = queue.shift();
const childHotspots = await Hotspot.find({
parent_scene_id: parentId,
is_auto_return: { $ne: true }
}).populate('target_scene_id', 'tourId');
for (const hs of childHotspots) {
if (hs.target_scene_id && typeof hs.target_scene_id === 'object') {
const targetScene = hs.target_scene_id;
const targetIdStr = targetScene._id.toString();
const targetTourId = targetScene.tourId ? targetScene.tourId.toString() : null;
// Chấp nhận cập nhật nếu là cùng tour hoặc là broken root (tự kế thừa lại tourId đúng)
const isBrokenRoot = targetTourId === targetIdStr;
const isSameTour = targetTourId === tourIdStr;
if (!visited.has(targetIdStr) && (isSameTour || isBrokenRoot || !targetTourId)) {
visited.add(targetIdStr);
scenesToUpdate.push(targetIdStr);
queue.push(targetIdStr);
}
}
}
}
const { privacy, shareToken, shareTokenExpires, sharedWith, sharedEmails } = privacyData; const { privacy, shareToken, shareTokenExpires, sharedWith, sharedEmails } = privacyData;
// 2. Chuẩn bị dữ liệu cập nhật (Luôn đồng bộ tourId để sửa lỗi dữ liệu) // 2. Chuẩn bị dữ liệu cập nhật (Chỉ cập nhật Privacy, giữ nguyên tourId)
const updateFields = { privacy, tourId }; const updateFields = { privacy };
const unsets = {}; const unsets = {};
if (privacy === 'shared') { if (shareToken) updateFields.shareToken = shareToken; else unsets.shareToken = 1; updateFields.shareTokenExpires = shareTokenExpires || undefined; updateFields.sharedWith = []; updateFields.sharedEmails = []; } else { unsets.shareToken = 1; unsets.shareTokenExpires = 1; if (privacy !== 'member') { updateFields.sharedWith = []; updateFields.sharedEmails = []; } } if (privacy === 'shared') { if (shareToken) updateFields.shareToken = shareToken; else unsets.shareToken = 1; updateFields.shareTokenExpires = shareTokenExpires || undefined; updateFields.sharedWith = []; updateFields.sharedEmails = []; } else { unsets.shareToken = 1; unsets.shareTokenExpires = 1; if (privacy !== 'member') { updateFields.sharedWith = []; updateFields.sharedEmails = []; } }
const updateQuery = { $set: updateFields }; const updateQuery = { $set: updateFields };
if (Object.keys(unsets).length > 0) updateQuery.$unset = unsets; if (Object.keys(unsets).length > 0) updateQuery.$unset = unsets;
// 3. Cập nhật dựa trên danh sách ID đã tìm được qua BFS // [BẢO MẬT TUYỆT ĐỐI] Chỉ cập nhật cho các cảnh mang đúng tourId này (Con đẻ).
await Scene.updateMany({ _id: { $in: scenesToUpdate } }, updateQuery); // Các cảnh liên kết chéo mang tourId khác nên sẽ được bảo vệ an toàn.
const result = await Scene.updateMany({ tourId: tourId }, updateQuery);
await logActivity('PROPAGATE_PRIVACY_BY_TOUR', { tourId, privacy }, performer ? performer.toString() : 'System'); await logActivity('PROPAGATE_PRIVACY_BY_TOUR', { tourId, privacy, affectedCount: result.modifiedCount }, performer ? performer.toString() : 'System');
}; };
module.exports = { deleteSceneCascade, propagateScenePrivacy }; module.exports = { deleteSceneCascade, propagateScenePrivacy };
+6
View File
@@ -529,10 +529,16 @@
<option value="">-- Chọn một cảnh để liên kết --</option> <option value="">-- Chọn một cảnh để liên kết --</option>
<!-- Sẽ được fill bằng JS --> <!-- Sẽ được fill bằng JS -->
</select> </select>
<div id="hs-existing-notice" style="font-size: 11px; margin-top: 8px; color: #aaa; line-height: 1.4;">
ℹ️ <strong>Liên kết:</strong> Cảnh này thuộc tour gốc của nó, quyền riêng tư sẽ KHÔNG thay đổi theo tour hiện tại.
</div>
</div> </div>
<!-- Lựa chọn A: Tải ảnh mới --> <!-- Lựa chọn A: Tải ảnh mới -->
<div id="hs-section-upload" class="tab-content" style="display: none;"> <div id="hs-section-upload" class="tab-content" style="display: none;">
<div id="hs-upload-notice" style="font-size: 11px; margin-bottom: 12px; color: #007bff; font-style: italic; line-height: 1.4; background: rgba(0, 123, 255, 0.05); padding: 8px; border-radius: 4px; border-left: 3px solid #007bff;">
ℹ️ <strong>Con đẻ:</strong> Cảnh này sẽ trở thành con của tour hiện tại và kế thừa quyền riêng tư từ cảnh gốc.
</div>
<label for="hs-panorama-file">Chọn ảnh Panorama 360°:</label> <label for="hs-panorama-file">Chọn ảnh Panorama 360°:</label>
<input type="file" id="hs-panorama-file" name="panorama-file" accept="image/*"> <input type="file" id="hs-panorama-file" name="panorama-file" accept="image/*">
+24 -10
View File
@@ -690,6 +690,11 @@ function openCreateSceneModal(lat, lng) {
document.getElementById('modal-lat').value = lat.toFixed(6); document.getElementById('modal-lat').value = lat.toFixed(6);
document.getElementById('modal-lng').value = lng.toFixed(6); document.getElementById('modal-lng').value = lng.toFixed(6);
// [FIX] Đảm bảo xóa tourId cũ khi tạo từ Map để Scene này trở thành Tour Gốc (Root)
const tourIdInput = document.getElementById('modal-tour-id');
if (tourIdInput) tourIdInput.value = '';
localStorage.removeItem('activeTourId');
const lang = systemSettings.language || 'vi'; const lang = systemSettings.language || 'vi';
const modalTitle = document.getElementById('create-scene-modal-title'); const modalTitle = document.getElementById('create-scene-modal-title');
if (modalTitle) modalTitle.innerText = lang === 'vi' ? "Tạo 3D scene mới" : "Create New 3D Scene"; if (modalTitle) modalTitle.innerText = lang === 'vi' ? "Tạo 3D scene mới" : "Create New 3D Scene";
@@ -1099,9 +1104,10 @@ async function openScene(sceneId, privacy, shareToken, force = false, initialPit
const scene = await sceneRes.json(); const scene = await sceneRes.json();
const hotspots = await hotspotsRes.json(); const hotspots = await hotspotsRes.json();
// [TOUR ID] Cập nhật tourId hiện tại vào localStorage để các cảnh con kế thừa đúng // [TOUR ID] Luôn cập nhật activeTourId theo Scene hiện tại để đảm bảo các cảnh con/hotspot mới
const currentTourId = scene.tourId?._id || scene.tourId || scene._id; // được gán đúng vào Tour gốc của cảnh đang xem, tránh sử dụng ID cũ/lỗi từ phiên trước.
localStorage.setItem('activeTourId', currentTourId); const openedSceneTourId = scene.tourId?._id || scene.tourId || scene._id;
localStorage.setItem('activeTourId', openedSceneTourId);
if (!sceneRes.ok) throw new Error(scene.message || 'Failed to fetch scene details'); if (!sceneRes.ok) throw new Error(scene.message || 'Failed to fetch scene details');
@@ -1162,6 +1168,7 @@ async function openScene(sceneId, privacy, shareToken, force = false, initialPit
localStorage.removeItem('activeSceneId'); localStorage.removeItem('activeSceneId');
localStorage.removeItem('activeScenePrivacy'); localStorage.removeItem('activeScenePrivacy');
localStorage.removeItem('activeSceneToken'); localStorage.removeItem('activeSceneToken');
localStorage.removeItem('activeTourId');
localStorage.removeItem('activeTourId'); localStorage.removeItem('activeTourId');
// Kiểm tra nếu đang truy cập qua link trực tiếp (URL có sceneId) mà gặp lỗi (do xóa token hoặc token không hợp lệ) // Kiểm tra nếu đang truy cập qua link trực tiếp (URL có sceneId) mà gặp lỗi (do xóa token hoặc token không hợp lệ)
@@ -1243,19 +1250,22 @@ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null
const activeTourId = localStorage.getItem('activeTourId'); const activeTourId = localStorage.getItem('activeTourId');
const targetTourId = targetScene?.tourId?._id || targetScene?.tourId; const targetTourId = targetScene?.tourId?._id || targetScene?.tourId;
const existingNotice = document.getElementById('hs-existing-notice');
let crossLinkNotice = document.getElementById('hs-crosslink-notice'); let crossLinkNotice = document.getElementById('hs-crosslink-notice');
if (!crossLinkNotice) { if (!crossLinkNotice) {
crossLinkNotice = document.createElement('div'); crossLinkNotice = document.createElement('div');
crossLinkNotice.id = 'hs-crosslink-notice'; crossLinkNotice.id = 'hs-crosslink-notice';
crossLinkNotice.style = 'font-size: 11px; margin-top: 5px; color: #ffc107; display: none;'; crossLinkNotice.style = 'font-size: 11px; margin-top: 4px; color: #ffc107; font-weight: bold; display: none;';
select.parentNode.appendChild(crossLinkNotice); select.parentNode.appendChild(crossLinkNotice);
} }
if (targetTourId && activeTourId && targetTourId !== activeTourId) { if (targetTourId && activeTourId && targetTourId !== activeTourId) {
crossLinkNotice.innerText = " Cảnh này thuộc Tour khác. Liên kết sẽ được tạo dưới dạng liên kết chéo."; crossLinkNotice.innerText = "⚠️ Chú ý: Cảnh này thuộc về một Tour khác (Liên kết chéo).";
crossLinkNotice.style.display = 'block'; crossLinkNotice.style.display = 'block';
if (existingNotice) existingNotice.style.opacity = '0.6';
} else { } else {
crossLinkNotice.style.display = 'none'; crossLinkNotice.style.display = 'none';
if (existingNotice) existingNotice.style.opacity = '1';
} }
}; };
@@ -1265,6 +1275,8 @@ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null
document.getElementById('hs-desc').value = existingHotspot.description || ''; document.getElementById('hs-desc').value = existingHotspot.description || '';
if (existingHotspot.target_scene_id) { if (existingHotspot.target_scene_id) {
select.value = existingHotspot.target_scene_id; select.value = existingHotspot.target_scene_id;
// Kích hoạt logic hiển thị thông báo ngay khi mở modal nếu đang sửa
if (typeof select.onchange === 'function') select.onchange();
} }
} }
} catch (e) { console.error("Lỗi nạp danh sách scene:", e); } } catch (e) { console.error("Lỗi nạp danh sách scene:", e); }
@@ -1303,7 +1315,9 @@ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null
sceneData.append('title', formData.get('title')); sceneData.append('title', formData.get('title'));
sceneData.append('lat', lat); // FormData sẽ convert sang string, Backend cần ép kiểu lại sceneData.append('lat', lat); // FormData sẽ convert sang string, Backend cần ép kiểu lại
sceneData.append('lng', lng); sceneData.append('lng', lng);
sceneData.append('privacy', 'public'); // [FIX] Kế thừa quyền riêng tư của scene hiện tại thay vì fix cứng public
const currentPrivacy = localStorage.getItem('activeScenePrivacy') || 'private';
sceneData.append('privacy', currentPrivacy);
// [FIX] Kế thừa tourId từ cảnh cha khi tạo cảnh mới qua hotspot upload // [FIX] Kế thừa tourId từ cảnh cha khi tạo cảnh mới qua hotspot upload
const activeTourId = localStorage.getItem('activeTourId'); const activeTourId = localStorage.getItem('activeTourId');
@@ -2061,10 +2075,10 @@ window.openEditMetadataModal = function(scene, isChildArg = null) {
sharedUsersData = scene.sharedWith || []; sharedUsersData = scene.sharedWith || [];
sharedEmailsData = scene.sharedEmails || []; sharedEmailsData = scene.sharedEmails || [];
// [TOUR ID] Cập nhật activeTourId ngay khi mở modal sửa để đảm bảo // [TOUR ID] Cập nhật activeTourId khi mở modal sửa.
// các thao tác tạo cảnh con sau đó (nếu có) luôn mang ID của Tour này. // Điều này đảm bảo ngữ cảnh tạo cảnh con mới (nếu có) luôn thuộc về Tour của cảnh đang sửa.
const currentTourId = scene.tourId?._id || scene.tourId || scene._id; const editingSceneTourId = scene.tourId?._id || scene.tourId || scene._id;
localStorage.setItem('activeTourId', currentTourId); localStorage.setItem('activeTourId', editingSceneTourId);
document.getElementById('edit-modal-scene-id').value = scene._id; document.getElementById('edit-modal-scene-id').value = scene._id;
document.getElementById('edit-modal-title').value = scene.name || scene.title || ''; document.getElementById('edit-modal-title').value = scene.name || scene.title || '';
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 MiB

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 MiB

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 MiB