diff --git a/backend/routes/apiRoutes.js b/backend/routes/apiRoutes.js index ab731da..9397aaf 100644 --- a/backend/routes/apiRoutes.js +++ b/backend/routes/apiRoutes.js @@ -641,7 +641,7 @@ router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => { const processedFilePath = path.join(uploadDir, processedFileName); await resizeTo8K(req.file.path, processedFilePath); - await injectGPSCoordinates(processedFilePath, scene.lat, scene.lng); + await injectGPSCoordinates(processedFilePath, scene.gps.lat, scene.gps.lng); const asset = new Asset({ filePath: processedFilePath, diff --git a/backend/routes/imageQueue.js b/backend/routes/imageQueue.js index 614d8f9..6a078f9 100644 --- a/backend/routes/imageQueue.js +++ b/backend/routes/imageQueue.js @@ -16,12 +16,12 @@ const imageQueue = new Queue('image-processing', { backoff: { type: 'exponential', delay: 5000 }, // Tự động dọn dẹp Job để tối ưu bộ nhớ Redis removeOnComplete: { - age: 3600, // Xóa các job hoàn thành sau 1 giờ (3600 giây) - count: 100 // Hoặc giữ tối đa 100 job hoàn thành gần nhất + age: 1800, // Xóa các job hoàn thành sau 30 phút (1800 giây) để giải phóng RAM nhanh hơn + count: 50 // Chỉ giữ 50 job gần nhất (đủ để xem log gần đây) }, removeOnFail: { - age: 24 * 3600, // Giữ lại job lỗi trong 24 giờ để admin kiểm tra - count: 500 // Giữ tối đa 500 job lỗi + age: 12 * 3600, // Giữ lại job lỗi trong 12 giờ để admin kiểm tra + count: 100 // Giữ tối đa 100 job lỗi } } }); diff --git a/backend/routes/imageWorker.js b/backend/routes/imageWorker.js index 170e069..c0e4445 100644 --- a/backend/routes/imageWorker.js +++ b/backend/routes/imageWorker.js @@ -1,7 +1,7 @@ const { Worker } = require('bullmq'); const fs = require('fs'); const path = require('path'); -const { connection } = require('./imageQueue'); +const { imageQueue, connection } = require('./imageQueue'); const { resizeTo8K } = require('../utils/imageHelper'); const { injectGPSCoordinates } = require('../utils/exifHelper'); const Asset = require('../models/Asset'); @@ -51,4 +51,20 @@ imageWorker.on('failed', (job, err) => { console.error(`Job ${job.id} thất bại sau nhiều lần thử: ${err.message}`); }); +// Khi khởi động Worker, thực hiện dọn dẹp các job "stalled" hoặc dữ liệu rác +// để đảm bảo Redis luôn sạch sẽ khi hệ thống khởi động lại. +imageWorker.on('ready', async () => { + console.log('[Worker] Sẵn sàng xử lý hàng đợi.'); + try { + // Xóa các job hoàn thành quá 1 giờ và job thất bại quá 24 giờ + // (Bổ trợ thêm cho cơ chế tự động dọn dẹp của Queue) + await imageQueue.clean(3600000, 1000, 'completed'); + await imageQueue.clean(86400000, 1000, 'failed'); + + console.log('[Worker] Đã dọn dẹp các job cũ không cần thiết trong Redis.'); + } catch (err) { + console.error('[Worker Cleanup Error]:', err.message); + } +}); + module.exports = imageWorker; \ No newline at end of file diff --git a/backend/scripts/checkIntegrity.js b/backend/scripts/checkIntegrity.js new file mode 100644 index 0000000..ea9dc38 --- /dev/null +++ b/backend/scripts/checkIntegrity.js @@ -0,0 +1,97 @@ +const mongoose = require('mongoose'); +const fs = require('fs'); +const path = require('path'); +const connectDB = require('../config/db'); +const Asset = require('../models/Asset'); +const Scene = require('../models/Scene'); + +/** + * Script kiểm tra tính toàn vẹn dữ liệu + * So sánh sự đồng bộ giữa Database (MongoDB) và Filesystem (thư mục uploads) + */ +const checkIntegrity = async () => { + try { + console.log('=== KHỞI CHẠY KIỂM TRA TÍNH TOÀN VẸN HỆ THỐNG ==='); + await connectDB(); + + const assets = await Asset.find().lean(); + const scenes = await Scene.find().lean(); + const uploadDir = path.resolve(__dirname, '../uploads'); + const tempDir = path.resolve(uploadDir, 'temp'); + + const dbFilePaths = new Set(); + const brokenAssets = []; + const assetIdsInDB = new Set(assets.map(a => a._id.toString())); + + console.log(`\n1. Kiểm tra Database -> Filesystem (${assets.length} Assets):`); + for (const asset of assets) { + if (asset.filePath) { + const absolutePath = path.resolve(asset.filePath); + dbFilePaths.add(absolutePath); + + if (!fs.existsSync(absolutePath)) { + brokenAssets.push({ + id: asset._id, + path: asset.filePath, + user: asset.uploadedBy + }); + } + } + } + + if (brokenAssets.length > 0) { + console.error(` [!] CẢNH BÁO: Tìm thấy ${brokenAssets.length} bản ghi mất file vật lý:`); + brokenAssets.forEach(ba => console.error(` - Asset ID: ${ba.id} | Path: ${ba.path}`)); + } else { + console.log(' [✓] Tất cả bản ghi Asset đều có file vật lý tương ứng.'); + } + + console.log(`\n2. Kiểm tra Filesystem -> Database:`); + let strayFilesCount = 0; + if (fs.existsSync(uploadDir)) { + const files = fs.readdirSync(uploadDir); + files.forEach(file => { + const fullPath = path.join(uploadDir, file); + // Bỏ qua thư mục temp và các thư mục con khác + if (fs.lstatSync(fullPath).isFile() && !file.startsWith('.')) { + if (!dbFilePaths.has(path.resolve(fullPath))) { + console.warn(` [?] File không có bản ghi DB: ${file}`); + strayFilesCount++; + } + } + }); + } + console.log(` [i] Tìm thấy ${strayFilesCount} file mồ côi (không được quản lý bởi Asset).`); + + console.log(`\n3. Kiểm tra Liên kết Scene -> Asset (${scenes.length} Scenes):`); + let brokenScenesCount = 0; + for (const scene of scenes) { + const assetId = scene.assetId?.toString(); + if (assetId && !assetIdsInDB.has(assetId)) { + console.error(` [!] Scene "${scene.name || scene.title}" (ID: ${scene._id}) trỏ tới Asset ID không tồn tại: ${assetId}`); + brokenScenesCount++; + } + } + + if (brokenScenesCount === 0) { + console.log(' [✓] Tất cả các Scene đều liên kết với Asset hợp lệ.'); + } else { + console.error(` [!] Tìm thấy ${brokenScenesCount} Scene bị hỏng liên kết.`); + } + + console.log('\n=== TỔNG KẾT ==='); + console.log(`- Bản ghi Asset lỗi: ${brokenAssets.length}`); + console.log(`- File mồ côi: ${strayFilesCount}`); + console.log(`- Scene hỏng liên kết: ${brokenScenesCount}`); + console.log('================================================'); + + mongoose.connection.close(); + process.exit(0); + } catch (err) { + console.error('Lỗi nghiêm trọng khi kiểm tra:', err.message); + if (mongoose.connection) mongoose.connection.close(); + process.exit(1); + } +}; + +checkIntegrity(); \ No newline at end of file diff --git a/backend/scripts/cleanupOrphanedData.js b/backend/scripts/cleanupOrphanedData.js new file mode 100644 index 0000000..bb3b123 --- /dev/null +++ b/backend/scripts/cleanupOrphanedData.js @@ -0,0 +1,91 @@ +const mongoose = require('mongoose'); +const fs = require('fs'); +const path = require('path'); +const connectDB = require('../config/db'); +const User = require('../models/User'); +const Scene = require('../models/Scene'); +const Asset = require('../models/Asset'); +const Hotspot = require('../models/Hotspot'); + +/** + * Script dọn dẹp dữ liệu rác (orphaned data) + * Xóa các Scene, Asset và File vật lý của những người dùng đã bị xóa khỏi hệ thống. + * Đồng thời dọn dẹp các Asset "mồ côi" không còn được gắn vào bất kỳ Scene nào. + */ +const cleanup = async () => { + try { + console.log('--- Bắt đầu quy trình dọn dẹp dữ liệu rác ---'); + await connectDB(); + + // 1. Lấy danh sách tất cả ID người dùng hiện đang tồn tại + const validUserIds = await User.distinct('_id'); + console.log(`- Tìm thấy ${validUserIds.length} người dùng hợp lệ.`); + + // 2. Tìm các Scene mồ côi (Người tạo không còn tồn tại) + const orphanedScenes = await Scene.find({ createdBy: { $nin: validUserIds } }); + const orphanedSceneIds = orphanedScenes.map(s => s._id); + console.log(`- Tìm thấy ${orphanedSceneIds.length} cảnh (Scene) mồ côi.`); + + // 3. Xóa các Hotspot liên quan đến các Scene mồ côi + // Xóa cả hotspot xuất phát từ và trỏ đến các scene bị xóa + const hotspotResult = await Hotspot.deleteMany({ + $or: [ + { parent_scene_id: { $in: orphanedSceneIds } }, + { target_scene_id: { $in: orphanedSceneIds } } + ] + }); + console.log(`- Đã xóa ${hotspotResult.deletedCount} liên kết Hotspot liên quan.`); + + // 4. Xóa các Scene mồ côi trong DB + const sceneResult = await Scene.deleteMany({ _id: { $in: orphanedSceneIds } }); + console.log(`- Đã xóa ${sceneResult.deletedCount} bản ghi Scene trong Database.`); + + // 5. Tìm các Asset mồ côi (Người upload không tồn tại HOẶC không có Scene nào liên kết) + // Chúng ta lấy danh sách assetId đang được sử dụng bởi các Scene còn lại + const usedAssetIds = await Scene.distinct('assetId'); + + // Để an toàn, chỉ xóa các Asset không liên kết nếu chúng đã tồn tại hơn 2 giờ + // (Tránh xóa nhầm ảnh đang trong hàng đợi xử lý của Worker) + const safeDate = new Date(Date.now() - 2 * 3600 * 1000); + + const orphanedAssets = await Asset.find({ + $or: [ + { uploadedBy: { $nin: validUserIds } }, // User đã bị xóa + { + $and: [ + { _id: { $nin: usedAssetIds } }, // Không có scene nào trỏ tới + { createdAt: { $lt: safeDate } } // Đã quá 2 giờ + ] + } + ] + }); + console.log(`- Tìm thấy ${orphanedAssets.length} ảnh (Asset) mồ côi hoặc không liên kết.`); + + // 6. Xóa tệp tin vật lý và bản ghi Asset + let filesDeleted = 0; + for (const asset of orphanedAssets) { + if (asset.filePath && fs.existsSync(asset.filePath)) { + try { + fs.unlinkSync(asset.filePath); + filesDeleted++; + } catch (e) { + console.error(` [Lỗi] Không thể xóa file: ${asset.filePath}`); + } + } + await Asset.findByIdAndDelete(asset._id); + } + console.log(`- Đã dọn dẹp ${filesDeleted} tệp tin vật lý.`); + console.log(`- Đã xóa ${orphanedAssets.length} bản ghi Asset trong Database.`); + + console.log('--- Hoàn tất dọn dẹp! Hệ thống đã sạch sẽ. ---'); + + mongoose.connection.close(); + process.exit(0); + } catch (err) { + console.error('Lỗi nghiêm trọng khi dọn dẹp:', err.message); + if (mongoose.connection) mongoose.connection.close(); + process.exit(1); + } +}; + +cleanup(); \ No newline at end of file diff --git a/backend/scripts/promoteAdmin.js b/backend/scripts/promoteAdmin.js new file mode 100644 index 0000000..9da086d --- /dev/null +++ b/backend/scripts/promoteAdmin.js @@ -0,0 +1,36 @@ +const mongoose = require('mongoose'); +const connectDB = require('../config/db'); +const User = require('../models/User'); + +const promote = async () => { + const username = process.argv[2]; // Lấy username từ câu lệnh: node promoteAdmin.js + + if (!username) { + console.error('Lỗi: Vui lòng cung cấp username. Ví dụ: node promoteAdmin.js locpham'); + process.exit(1); + } + + try { + await connectDB(); + + const user = await User.findOneAndUpdate( + { username: username }, + { $set: { role: 'admin' } }, + { new: true } + ); + + if (!user) { + console.error(`Không tìm thấy người dùng có tên: ${username}`); + } else { + console.log(`--- THÀNH CÔNG ---`); + console.log(`Người dùng ${user.username} đã được nâng cấp lên quyền: ${user.role}`); + } + + mongoose.connection.close(); + } catch (err) { + console.error('Lỗi:', err.message); + process.exit(1); + } +}; + +promote(); \ No newline at end of file diff --git a/backend/scripts/storageStats.js b/backend/scripts/storageStats.js new file mode 100644 index 0000000..4ec8563 --- /dev/null +++ b/backend/scripts/storageStats.js @@ -0,0 +1,86 @@ +const mongoose = require('mongoose'); +const fs = require('fs'); +const path = require('path'); +const connectDB = require('../config/db'); +const User = require('../models/User'); +const Asset = require('../models/Asset'); + +/** + * Hàm định dạng dung lượng file + */ +function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} + +/** + * Script thống kê dung lượng lưu trữ theo từng người dùng + */ +const getStorageStats = async () => { + try { + console.log('=== ĐANG TRUY XUẤT THỐNG KÊ DUNG LƯỢNG LƯU TRỮ ==='); + await connectDB(); + + // 1. Lấy tất cả user để map tên + const users = await User.find().select('username email').lean(); + const userMap = {}; + const stats = {}; + + users.forEach(u => { + userMap[u._id.toString()] = u.username; + stats[u._id.toString()] = { + username: u.username, + email: u.email, + fileCount: 0, + totalBytes: 0 + }; + }); + + // Thêm mục cho các asset không có chủ sở hữu (nếu có) + stats['unknown'] = { username: 'Không xác định', email: 'N/A', fileCount: 0, totalBytes: 0 }; + + // 2. Lấy tất cả Asset + const assets = await Asset.find().lean(); + console.log(`- Đang kiểm tra ${assets.length} tệp tin trong hệ thống...`); + + for (const asset of assets) { + const userId = asset.uploadedBy ? asset.uploadedBy.toString() : 'unknown'; + + if (asset.filePath && fs.existsSync(asset.filePath)) { + const fileStat = fs.statSync(asset.filePath); + if (stats[userId]) { + stats[userId].totalBytes += fileStat.size; + stats[userId].fileCount += 1; + } else { + stats['unknown'].totalBytes += fileStat.size; + stats['unknown'].fileCount += 1; + } + } + } + + // 3. Hiển thị kết quả + console.log('\nKết quả thống kê:'); + console.log(''.padEnd(60, '-')); + console.log(`${'Username'.padEnd(20)} | ${'Số file'.padEnd(10)} | ${'Dung lượng'}`); + console.log(''.padEnd(60, '-')); + + Object.values(stats).forEach(userStat => { + if (userStat.fileCount > 0) { + console.log(`${userStat.username.padEnd(20)} | ${userStat.fileCount.toString().padEnd(10)} | ${formatBytes(userStat.totalBytes)}`); + } + }); + console.log(''.padEnd(60, '-')); + + mongoose.connection.close(); + } catch (err) { + console.error('Lỗi khi thống kê:', err.message); + if (mongoose.connection) mongoose.connection.close(); + process.exit(1); + } +}; + +getStorageStats(); \ No newline at end of file diff --git a/frontend/css/style.css b/frontend/css/style.css index f370929..c7bb398 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -192,11 +192,11 @@ html, body { background: rgba(30, 30, 30, 0.98); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); border-radius: 8px; - padding: 10px 0; /* Remove horizontal padding to let hover cover full width */ + padding: 15px 0; /* Tăng padding dọc để thoáng hơn */ width: auto; /* Tự động điều chỉnh theo nội dung */ z-index: 1600; /* Above top-bar */ border: 1px solid rgba(255, 255, 255, 0.1); - min-width: 180px; /* Đảm bảo không quá nhỏ */ + min-width: 320px; /* Mở rộng box để tránh ngắt dòng nội dung */ max-height: 550px; overflow-y: auto; } @@ -247,17 +247,37 @@ html, body { width: calc(100% - 40px) !important; background: #007bff !important; color: white !important; - padding: 10px !important; + padding: 12px !important; /* Tăng padding để nút nhìn đầy đặn hơn */ + display: flex !important; /* Kích hoạt flexbox để căn giữa icon và text */ + align-items: center; + justify-content: center; + gap: 10px; /* Khoảng cách giữa icon và chữ */ + border-radius: 6px !important; + font-weight: 600 !important; +} + +.auth-submit-btn::before { + content: '\1F464'; /* Mã Unicode cho icon người dùng (👤) */ + font-size: 16px; } .rules-checkbox { display: flex; - align-items: center; + align-items: flex-start; /* Căn đỉnh để khi text dài vẫn nhìn hợp lý */ + justify-content: flex-start; /* Căn lề trái */ gap: 8px; color: #ccc; font-size: 12px; - margin: 5px 20px 15px; + margin: 5px 20px 15px 20px; cursor: pointer; + text-align: left; +} + +/* Ghi đè độ rộng 100% của input để checkbox không chiếm toàn bộ dòng */ +.rules-checkbox input[type="checkbox"] { + width: auto !important; + margin: 2px 0 0 0 !important; + flex-shrink: 0; /* Ngăn checkbox bị bóp méo */ } #user-dropdown input { diff --git a/frontend/js/main_map.js b/frontend/js/main_map.js index 994d0b6..2424509 100644 --- a/frontend/js/main_map.js +++ b/frontend/js/main_map.js @@ -790,27 +790,6 @@ function closeActionModal() { document.getElementById('action-choice-modal').style.display = 'none'; } -/** - * Opens the modal in Edit mode - */ -function openEditSceneModal(scene) { - document.getElementById('modal-scene-id').value = scene._id; - // Cập nhật để hỗ trợ cả cấu trúc cũ và mới (gps.lat/name) - document.getElementById('modal-lat').value = scene.gps?.lat || scene.lat; - document.getElementById('modal-lng').value = scene.gps?.lng || scene.lng; - const sceneName = scene.name || scene.title; - document.getElementById('modal-title').value = sceneName; - document.getElementById('modal-privacy').value = scene.privacy; - document.getElementById('modal-panorama').required = false; // Photo update is optional - - const lang = systemSettings.language || 'vi'; - const modalTitle = document.getElementById('create-scene-modal-title'); - if (modalTitle) modalTitle.innerText = lang === 'vi' ? "Sửa 3D scene" : "Edit 3D scene"; - - toggleSharedUsers(); - document.getElementById('create-scene-modal').style.display = 'flex'; -} - /** * Mở modal xác nhận xóa scene */