Cập nhật tính năng quản lí dữ liệu mồ côi
This commit is contained in:
@@ -27,6 +27,65 @@ const tempDir = path.join(uploadDir, 'temp');
|
|||||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
|
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hàm bổ trợ: Dọn dẹp dữ liệu mồ côi
|
||||||
|
* Được gọi tự động khi xóa user hoặc gọi thủ công từ Admin Dashboard
|
||||||
|
*/
|
||||||
|
const runOrphanedCleanup = async () => {
|
||||||
|
const validUserIds = await User.distinct('_id');
|
||||||
|
|
||||||
|
// 1. Xử lý Scenes mồ côi
|
||||||
|
const orphanedScenes = await Scene.find({ createdBy: { $nin: validUserIds } });
|
||||||
|
const orphanedSceneIds = orphanedScenes.map(s => s._id);
|
||||||
|
|
||||||
|
if (orphanedSceneIds.length > 0) {
|
||||||
|
// Xóa Hotspots liên quan
|
||||||
|
await Hotspot.deleteMany({
|
||||||
|
$or: [
|
||||||
|
{ parent_scene_id: { $in: orphanedSceneIds } },
|
||||||
|
{ target_scene_id: { $in: orphanedSceneIds } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
// Xóa Scenes
|
||||||
|
await Scene.deleteMany({ _id: { $in: orphanedSceneIds } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Xử lý Assets mồ côi (Không có owner hoặc không gắn vào Scene nào quá 2h)
|
||||||
|
const usedAssetIds = await Scene.distinct('assetId');
|
||||||
|
const safeDate = new Date(Date.now() - 2 * 3600 * 1000);
|
||||||
|
|
||||||
|
const orphanedAssets = await Asset.find({
|
||||||
|
$or: [
|
||||||
|
{ uploadedBy: { $nin: validUserIds } },
|
||||||
|
{
|
||||||
|
$and: [
|
||||||
|
{ _id: { $nin: usedAssetIds } },
|
||||||
|
{ createdAt: { $lt: safeDate } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let deletedFilesCount = 0;
|
||||||
|
for (const asset of orphanedAssets) {
|
||||||
|
if (asset.filePath && fs.existsSync(asset.filePath)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(asset.filePath);
|
||||||
|
deletedFilesCount++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Cleanup Error] File: ${asset.filePath}`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Asset.findByIdAndDelete(asset._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scenesDeleted: orphanedSceneIds.length,
|
||||||
|
assetsDeleted: orphanedAssets.length,
|
||||||
|
filesRemoved: deletedFilesCount
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Configure Multer for temp uploads
|
// Configure Multer for temp uploads
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
destination: (req, file, cb) => {
|
||||||
@@ -91,6 +150,68 @@ router.post('/admin/backup', protect, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/admin/maintenance/stray-files
|
||||||
|
* @desc Kiểm tra các file trong thư mục uploads không có bản ghi DB trỏ tới
|
||||||
|
* @access Private (Admin)
|
||||||
|
*/
|
||||||
|
router.get('/admin/maintenance/stray-files', protect, async (req, res) => {
|
||||||
|
if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') {
|
||||||
|
return res.status(403).json({ message: 'Bạn không có quyền quản trị' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Đọc danh sách file thực tế (bỏ qua thư mục temp và file ẩn)
|
||||||
|
const filesOnDisk = fs.readdirSync(uploadDir).filter(file => {
|
||||||
|
const fullPath = path.join(uploadDir, file);
|
||||||
|
return fs.lstatSync(fullPath).isFile() && !file.startsWith('.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lấy danh sách file trong DB
|
||||||
|
const assets = await Asset.find().select('filePath').lean();
|
||||||
|
const dbFileNames = new Set(assets.map(a => path.basename(a.filePath)));
|
||||||
|
|
||||||
|
// Lọc ra các file mồ côi trên đĩa
|
||||||
|
const strayFiles = filesOnDisk.filter(file => !dbFileNames.has(file));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
count: strayFiles.length,
|
||||||
|
files: strayFiles
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/admin/maintenance/cleanup
|
||||||
|
* @desc Kích hoạt dọn dẹp dữ liệu mồ côi thủ công
|
||||||
|
* @access Private (Admin)
|
||||||
|
*/
|
||||||
|
router.post('/admin/maintenance/cleanup', protect, async (req, res) => {
|
||||||
|
if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') {
|
||||||
|
return res.status(403).json({ message: 'Forbidden' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Nếu có tham số deleteStray=true, xóa luôn các file không có trong DB
|
||||||
|
if (req.query.deleteStray === 'true') {
|
||||||
|
const assets = await Asset.find().select('filePath').lean();
|
||||||
|
const dbFileNames = new Set(assets.map(a => path.basename(a.filePath)));
|
||||||
|
const filesOnDisk = fs.readdirSync(uploadDir).filter(f => fs.lstatSync(path.join(uploadDir, f)).isFile() && !f.startsWith('.'));
|
||||||
|
|
||||||
|
filesOnDisk.forEach(file => {
|
||||||
|
if (!dbFileNames.has(file)) {
|
||||||
|
try { fs.unlinkSync(path.join(uploadDir, file)); } catch (e) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await runOrphanedCleanup();
|
||||||
|
res.json({ message: 'Quy trình dọn dẹp hoàn tất', report });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route POST /api/admin/restore
|
* @route POST /api/admin/restore
|
||||||
* @desc Khôi phục hệ thống từ file backup.zip
|
* @desc Khôi phục hệ thống từ file backup.zip
|
||||||
@@ -1140,6 +1261,10 @@ router.delete('/admin/users/:id', protect, async (req, res) => {
|
|||||||
|
|
||||||
// Lưu ý: Trong thực tế bạn có thể muốn xóa cả các Scene của user này
|
// Lưu ý: Trong thực tế bạn có thể muốn xóa cả các Scene của user này
|
||||||
await User.findByIdAndDelete(req.params.id);
|
await User.findByIdAndDelete(req.params.id);
|
||||||
|
|
||||||
|
// Tự động dọn dẹp dữ liệu liên quan để tránh rác hệ thống
|
||||||
|
await runOrphanedCleanup();
|
||||||
|
|
||||||
res.json({ message: 'Đã xóa người dùng vĩnh viễn' });
|
res.json({ message: 'Đã xóa người dùng vĩnh viễn' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ message: error.message });
|
res.status(500).json({ message: error.message });
|
||||||
|
|||||||
@@ -138,6 +138,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="tab-user-management" class="dashboard-tab-pane admin-only">
|
<div id="tab-user-management" class="dashboard-tab-pane admin-only">
|
||||||
<h3>Quản lí người dùng</h3>
|
<h3>Quản lí người dùng</h3>
|
||||||
|
<div class="admin-maintenance-header" style="margin-bottom: 20px;">
|
||||||
|
<button class="edit-btn-large" onclick="openManualCleanupConfirm()" style="background: #17a2b8; width: auto; padding: 10px 20px; font-size: 14px;">
|
||||||
|
🧹 Dọn dẹp dữ liệu mồ côi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-search-container">
|
||||||
|
<input type="text" id="admin-user-search-input" placeholder="Tìm kiếm theo tên, email, username..." onkeydown="if(event.key === 'Enter') loadAdminUsers(1)">
|
||||||
|
<button onclick="loadAdminUsers(1)" class="admin-search-btn">Tìm kiếm</button>
|
||||||
|
</div>
|
||||||
<div id="admin-users-list" class="dashboard-list"></div>
|
<div id="admin-users-list" class="dashboard-list"></div>
|
||||||
<div id="admin-users-pagination" class="pagination-container"></div>
|
<div id="admin-users-pagination" class="pagination-container"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -423,6 +432,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Maintenance Confirmation Modal -->
|
||||||
|
<div id="maintenance-confirm-modal" class="modal-overlay">
|
||||||
|
<div class="modal-content action-modal-content logout-modal-dark">
|
||||||
|
<div id="maintenance-confirm-icon" style="font-size: 40px; color: #ffc107; margin-bottom: 10px;">⚙️</div>
|
||||||
|
<h2 style="color: #fff; margin-bottom: 10px;" id="maintenance-confirm-title">Xác nhận bảo trì</h2>
|
||||||
|
<p style="color: #ccc; margin-bottom: 25px;" id="maintenance-confirm-desc">Nội dung xác nhận...</p>
|
||||||
|
<input type="hidden" id="maintenance-action-type">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button id="maintenance-confirm-btn" class="edit-btn-large" style="background: #28a745;">Xác nhận thực hiện</button>
|
||||||
|
<button onclick="closeMaintenanceConfirm()" class="edit-btn-large" style="background: #6c757d;">Hủy bỏ</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Logout Confirmation Modal -->
|
<!-- Logout Confirmation Modal -->
|
||||||
<div id="logout-confirm-modal" class="modal-overlay">
|
<div id="logout-confirm-modal" class="modal-overlay">
|
||||||
<div class="modal-content action-modal-content logout-modal-dark">
|
<div class="modal-content action-modal-content logout-modal-dark">
|
||||||
|
|||||||
+106
-1
@@ -1666,11 +1666,87 @@ window.deleteUserByAdmin = async function(userId) {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.message);
|
if (!res.ok) throw new Error(data.message);
|
||||||
showNotification(data.message, 'success');
|
|
||||||
|
// Hiển thị thông báo thành công kèm báo cáo dọn dẹp nếu có
|
||||||
|
let successMsg = data.message;
|
||||||
|
if (data.report) {
|
||||||
|
successMsg += `\n(Đã dọn dẹp: ${data.report.scenesDeleted} Scenes, ${data.report.filesRemoved} Files)`;
|
||||||
|
}
|
||||||
|
showNotification(successMsg, 'success');
|
||||||
loadAdminUsers();
|
loadAdminUsers();
|
||||||
} catch (e) { showNotification(e.message, 'error'); }
|
} catch (e) { showNotification(e.message, 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Giai đoạn 2 & 3: Các hàm xử lý bảo trì cho Admin tối cao
|
||||||
|
*/
|
||||||
|
window.openManualCleanupConfirm = function() {
|
||||||
|
const modal = document.getElementById('maintenance-confirm-modal');
|
||||||
|
document.getElementById('maintenance-confirm-title').innerText = "Dọn dẹp hệ thống";
|
||||||
|
document.getElementById('maintenance-confirm-desc').innerText = "Hệ thống sẽ quét và xóa bỏ tất cả các Scene, Hotspot và Asset không còn liên kết hợp lệ. Thao tác này không thể hoàn tác.";
|
||||||
|
document.getElementById('maintenance-action-type').value = 'cleanup';
|
||||||
|
document.getElementById('maintenance-confirm-btn').onclick = runManualCleanup;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.closeMaintenanceConfirm = () => {
|
||||||
|
document.getElementById('maintenance-confirm-modal').style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
async function runManualCleanup() {
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
closeMaintenanceConfirm();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/admin/maintenance/cleanup`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.message);
|
||||||
|
|
||||||
|
const report = data.report;
|
||||||
|
showSuccessModal(`Dọn dẹp hoàn tất!\n- Scenes xóa: ${report.scenesDeleted}\n- Files xóa: ${report.filesRemoved}`);
|
||||||
|
} catch (e) { showNotification(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
window.checkStrayFiles = async function() {
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/admin/maintenance/stray-files`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.message);
|
||||||
|
|
||||||
|
if (data.count === 0) {
|
||||||
|
showSuccessModal("Tuyệt vời! Không tìm thấy tệp tin rác nào trong hệ thống.", '✨');
|
||||||
|
} else {
|
||||||
|
const modal = document.getElementById('maintenance-confirm-modal');
|
||||||
|
document.getElementById('maintenance-confirm-title').innerText = "Phát hiện tệp tin rác";
|
||||||
|
document.getElementById('maintenance-confirm-desc').innerText = `Tìm thấy ${data.count} file trong thư mục uploads không có bản ghi trong Database. Bạn có muốn xóa chúng để tiết kiệm dung lượng?`;
|
||||||
|
document.getElementById('maintenance-action-type').value = 'stray';
|
||||||
|
document.getElementById('maintenance-confirm-btn').onclick = deleteStrayFiles;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
} catch (e) { showNotification(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
async function deleteStrayFiles() {
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
closeMaintenanceConfirm();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/admin/maintenance/cleanup?deleteStray=true`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.message);
|
||||||
|
|
||||||
|
showSuccessModal(`Đã dọn dẹp sạch sẽ ${data.report.filesRemoved} tệp tin rác khỏi máy chủ.`);
|
||||||
|
loadMediaStats(); // Cập nhật lại dung lượng hiển thị
|
||||||
|
} catch (e) { showNotification(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tải và hiển thị kho ảnh/media của người dùng
|
* Tải và hiển thị kho ảnh/media của người dùng
|
||||||
*/
|
*/
|
||||||
@@ -2171,6 +2247,35 @@ async function handleRestore(input) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cập nhật cấu hình hệ thống
|
||||||
|
*/
|
||||||
|
async function updateSystemSettings(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
const timezone = document.getElementById('sys-timezone').value;
|
||||||
|
const language = document.getElementById('sys-language').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/system/settings`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ timezone, language })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showNotification("Cấu hình hệ thống đã được lưu!", "success");
|
||||||
|
// Tải lại cài đặt để áp dụng ngôn ngữ mới ngay lập tức
|
||||||
|
fetchSystemSettings();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showNotification("Lỗi lưu cấu hình: " + err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens a specific tab within the dashboard.
|
* Opens a specific tab within the dashboard.
|
||||||
* @param {string} tabName - The ID of the tab pane to open (e.g., 'profile', 'my-scenes').
|
* @param {string} tabName - The ID of the tab pane to open (e.g., 'profile', 'my-scenes').
|
||||||
|
|||||||
Reference in New Issue
Block a user