Thay đổi ARCHITEC.md cập nhật các thông tin để chuẩn bị refactor lại dự án
@@ -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)
|
||||
|
||||
### User (Người dùng)
|
||||
- `username`: String (Unique)
|
||||
- `email`: String (Unique)
|
||||
### 1.1. User (Người dùng)
|
||||
- `_id`: ObjectId
|
||||
- `username`: String (Unique, bắt buộc)
|
||||
- `email`: String (Unique, bắt buộc)
|
||||
- `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
|
||||
- `avatarUrl`: String
|
||||
- `agreedToRules`: Boolean
|
||||
- `avatarUrl`: String (Đường dẫn stream ảnh đại diện)
|
||||
- `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ý)
|
||||
- `fileSize`: Number (Bytes)
|
||||
- `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
|
||||
|
||||
### Scene (Cảnh 360)
|
||||
- `name`/`title`: String
|
||||
- `description`: String
|
||||
- `assetId`: ObjectId (Ref: Asset)
|
||||
- `scene_url`: String
|
||||
- `gps`: Object { `lat`: Number, `lng`: Number }
|
||||
### 1.3. Tour (Cấu trúc Tour - Đề xuất mới)
|
||||
- `_id`: ObjectId
|
||||
- `name`: String (Tên của tour)
|
||||
- `description`: String (Mô tả tổng quát)
|
||||
- `location`: Object { `lat`: Number, `lng`: Number } (Vị trí trung tâm của tour)
|
||||
- `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']
|
||||
- `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']
|
||||
- `shareToken`: String (Dùng cho link truy cập nhanh)
|
||||
- `shareToken`: String (Dùng cho link chia sẻ)
|
||||
- `shareTokenExpires`: Date
|
||||
- `sharedWith`: Array [ObjectId (Ref: User)]
|
||||
- `sharedEmails`: Array [String]
|
||||
- `views`: Number
|
||||
- `views`: Number (Tổng lượt xem)
|
||||
- `viewHistory`: Array [ { `date`: Date, `count`: Number } ]
|
||||
- `createdAt`: Date
|
||||
|
||||
### Hotspot (Điểm điều hướng)
|
||||
- `parent_scene_id`: ObjectId (Ref: Scene)
|
||||
- `target_scene_id`: ObjectId (Ref: Scene)
|
||||
- `title`: String
|
||||
### 1.5. Hotspot / Link (Điểm điều hướng & Liên kết)
|
||||
- `_id`: ObjectId
|
||||
- `parent_scene_id`: ObjectId (Ref: Scene - Cảnh chứa điểm này)
|
||||
- `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
|
||||
- `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')
|
||||
- `language`: String (Mặc định: 'vi')
|
||||
|
||||
|
||||
@@ -51,10 +51,17 @@ router.post('/create', protect, async (req, res) => {
|
||||
});
|
||||
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) {
|
||||
const targetScene = await Scene.findById(target_scene_id);
|
||||
if (targetScene) {
|
||||
// [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({
|
||||
parent_scene_id: target_scene_id,
|
||||
@@ -66,6 +73,7 @@ router.post('/create', protect, async (req, res) => {
|
||||
await reverseHotspot.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json(hotspot);
|
||||
} catch (error) {
|
||||
|
||||
@@ -34,16 +34,43 @@ const uploadSinglePanorama = (req, res, next) => {
|
||||
// @route POST /api/scenes
|
||||
router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) => {
|
||||
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' });
|
||||
|
||||
// [BẢO MẬT] Làm sạch tourId từ client gửi lên
|
||||
const cleanedTourId = (tourId && tourId !== 'null' && tourId !== '') ? tourId : undefined;
|
||||
// [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
|
||||
|
||||
try { if (sharedWithUsers) finalSharedWith = JSON.parse(sharedWithUsers); } catch (e) {}
|
||||
|
||||
// [BẢO MẬT] Xác thực tourId nếu được cung cấp
|
||||
if (cleanedTourId) {
|
||||
const tourExists = await Scene.exists({ _id: cleanedTourId });
|
||||
if (!tourExists) return res.status(400).json({ message: 'Tour gốc không tồn tại hoặc đã bị xóa.' });
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
const latitude = Number(lat) || 0;
|
||||
@@ -60,21 +87,19 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) =>
|
||||
});
|
||||
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({
|
||||
name: title,
|
||||
assetId: asset._id,
|
||||
scene_url: tempFilePath,
|
||||
gps: { lat: latitude, lng: longitude },
|
||||
createdBy: req.user._id,
|
||||
privacy: privacy || 'private',
|
||||
shareToken,
|
||||
sharedWith: parsedSharedWith,
|
||||
privacy: finalPrivacy,
|
||||
shareToken: finalShareToken,
|
||||
shareTokenExpires: finalExpires,
|
||||
sharedWith: finalSharedWith,
|
||||
sharedEmails: finalSharedEmails,
|
||||
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ó
|
||||
if (!scene.tourId) scene.tourId = scene._id;
|
||||
@@ -219,7 +244,7 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => {
|
||||
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;
|
||||
await scene.save();
|
||||
|
||||
@@ -227,7 +252,15 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => {
|
||||
const isRoot = scene.tourId && scene.tourId.toString() === scene._id.toString();
|
||||
|
||||
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 });
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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)
|
||||
const orphanScenes = await Scene.find({ tourId: { $exists: false } });
|
||||
// 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({
|
||||
$or: [{ tourId: { $exists: false } }, { tourId: null }, { tourId: "" }]
|
||||
});
|
||||
let orphanCount = 0;
|
||||
for (const scene of orphanScenes) {
|
||||
await Scene.updateOne({ _id: scene._id }, { $set: { tourId: scene._id } });
|
||||
|
||||
@@ -16,51 +16,21 @@ const deleteSceneCascade = async (rootSceneId, performer = 'System') => {
|
||||
const rootScene = await Scene.findById(rootSceneId);
|
||||
if (!rootScene) return { deletedCount: 0 };
|
||||
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)
|
||||
let queue = [rootSceneId.toString()];
|
||||
let scenesToDelete = [rootSceneId.toString()];
|
||||
const visited = new Set(scenesToDelete);
|
||||
// [BIÊN GIỚI TOUR] Xác định danh sách cần xóa
|
||||
const isRoot = tourId && tourId === rootSceneId.toString();
|
||||
let scenesToDelete = [];
|
||||
|
||||
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;
|
||||
|
||||
// [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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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()];
|
||||
}
|
||||
|
||||
// 1. Dọn dẹp Hotspots (Cả link đi và link trỏ ĐẾN các scene sắp xóa)
|
||||
const hotspotCleanup = await Hotspot.deleteMany({
|
||||
$or: [
|
||||
{ parent_scene_id: { $in: scenesToDelete } },
|
||||
{ target_scene_id: { $in: scenesToDelete } }
|
||||
]
|
||||
});
|
||||
|
||||
// 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);
|
||||
@@ -71,20 +41,22 @@ const deleteSceneCascade = async (rootSceneId, performer = 'System') => {
|
||||
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 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 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', {
|
||||
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,
|
||||
cleanedHotspotsCount: hotspotCleanup.deletedCount
|
||||
}, performer ? performer.toString() : 'System');
|
||||
@@ -104,53 +76,22 @@ const propagateScenePrivacy = async (rootSceneId, privacyData, performer = 'Syst
|
||||
if (!rootScene) return;
|
||||
|
||||
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;
|
||||
|
||||
// 2. Chuẩn bị dữ liệu cập nhật (Luôn đồng bộ tourId để sửa lỗi dữ liệu)
|
||||
const updateFields = { privacy, tourId };
|
||||
// 2. Chuẩn bị dữ liệu cập nhật (Chỉ cập nhật Privacy, giữ nguyên tourId)
|
||||
const updateFields = { privacy };
|
||||
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 = []; } }
|
||||
|
||||
const updateQuery = { $set: updateFields };
|
||||
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
|
||||
await Scene.updateMany({ _id: { $in: scenesToUpdate } }, updateQuery);
|
||||
// [BẢO MẬT TUYỆT ĐỐI] Chỉ cập nhật cho các cảnh mang đúng tourId này (Con đẻ).
|
||||
// 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 };
|
||||
@@ -529,10 +529,16 @@
|
||||
<option value="">-- Chọn một cảnh để liên kết --</option>
|
||||
<!-- Sẽ được fill bằng JS -->
|
||||
</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>
|
||||
|
||||
<!-- Lựa chọn A: Tải ảnh mới -->
|
||||
<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>
|
||||
<input type="file" id="hs-panorama-file" name="panorama-file" accept="image/*">
|
||||
|
||||
|
||||
@@ -690,6 +690,11 @@ function openCreateSceneModal(lat, lng) {
|
||||
document.getElementById('modal-lat').value = lat.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 modalTitle = document.getElementById('create-scene-modal-title');
|
||||
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 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
|
||||
const currentTourId = scene.tourId?._id || scene.tourId || scene._id;
|
||||
localStorage.setItem('activeTourId', currentTourId);
|
||||
// [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
|
||||
// đượ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.
|
||||
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');
|
||||
|
||||
@@ -1162,6 +1168,7 @@ async function openScene(sceneId, privacy, shareToken, force = false, initialPit
|
||||
localStorage.removeItem('activeSceneId');
|
||||
localStorage.removeItem('activeScenePrivacy');
|
||||
localStorage.removeItem('activeSceneToken');
|
||||
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ệ)
|
||||
@@ -1243,19 +1250,22 @@ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null
|
||||
const activeTourId = localStorage.getItem('activeTourId');
|
||||
const targetTourId = targetScene?.tourId?._id || targetScene?.tourId;
|
||||
|
||||
const existingNotice = document.getElementById('hs-existing-notice');
|
||||
let crossLinkNotice = document.getElementById('hs-crosslink-notice');
|
||||
if (!crossLinkNotice) {
|
||||
crossLinkNotice = document.createElement('div');
|
||||
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);
|
||||
}
|
||||
|
||||
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';
|
||||
if (existingNotice) existingNotice.style.opacity = '0.6';
|
||||
} else {
|
||||
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 || '';
|
||||
if (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); }
|
||||
@@ -1303,7 +1315,9 @@ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null
|
||||
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('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
|
||||
const activeTourId = localStorage.getItem('activeTourId');
|
||||
@@ -2061,10 +2075,10 @@ window.openEditMetadataModal = function(scene, isChildArg = null) {
|
||||
sharedUsersData = scene.sharedWith || [];
|
||||
sharedEmailsData = scene.sharedEmails || [];
|
||||
|
||||
// [TOUR ID] Cập nhật activeTourId ngay khi mở modal sửa để đảm bảo
|
||||
// các thao tác tạo cảnh con sau đó (nếu có) luôn mang ID của Tour này.
|
||||
const currentTourId = scene.tourId?._id || scene.tourId || scene._id;
|
||||
localStorage.setItem('activeTourId', currentTourId);
|
||||
// [TOUR ID] Cập nhật activeTourId khi mở modal sửa.
|
||||
// Đ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 editingSceneTourId = scene.tourId?._id || scene.tourId || scene._id;
|
||||
localStorage.setItem('activeTourId', editingSceneTourId);
|
||||
|
||||
document.getElementById('edit-modal-scene-id').value = scene._id;
|
||||
document.getElementById('edit-modal-title').value = scene.name || scene.title || '';
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 6.9 MiB After Width: | Height: | Size: 6.9 MiB |
|
Before Width: | Height: | Size: 5.3 MiB After Width: | Height: | Size: 5.3 MiB |
|
After Width: | Height: | Size: 5.7 MiB |
|
After Width: | Height: | Size: 36 MiB |
|
After Width: | Height: | Size: 36 MiB |
|
After Width: | Height: | Size: 36 MiB |
|
After Width: | Height: | Size: 36 MiB |