Chỉnh sửa và tối ưu form đăng nhập và đăng ký

This commit is contained in:
2026-06-09 12:22:29 +07:00
parent 2fba77d50c
commit 9f9c38e6e7
9 changed files with 357 additions and 32 deletions
+1 -1
View File
@@ -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,
+4 -4
View File
@@ -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
}
}
});
+17 -1
View File
@@ -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;
+97
View File
@@ -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();
+91
View File
@@ -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();
+36
View File
@@ -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 <username>
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();
+86
View File
@@ -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();
+25 -5
View File
@@ -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 {
-21
View File
@@ -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
*/