diff --git a/ARCHITEC.md b/ARCHITEC.md new file mode 100644 index 0000000..8a8bff4 --- /dev/null +++ b/ARCHITEC.md @@ -0,0 +1,136 @@ +# Tài liệu Kiến trúc Hệ thống 3D Tours + +Tài liệu này tổng hợp toàn bộ cấu trúc hệ thống phục vụ quá trình refactor dự án. + +## 1. Mô hình Dữ liệu (Database Schema - MongoDB) + +### User (Người dùng) +- `username`: String (Unique) +- `email`: String (Unique) +- `password`: String (Hashed) +- `role`: String ['admin', 'Chủ sở hữu', 'editor', 'moderator', 'Thành viên'] +- `fullName`: String +- `avatarUrl`: String +- `agreedToRules`: Boolean + +### Asset (Tệp tin/Phương tiện) +- `filePath`: String (Đường dẫn vật lý) +- `fileSize`: Number (Bytes) +- `uploadedBy`: ObjectId (Ref: User) +- `coordinates`: Object { `lat`: Number, `lng`: Number } (GPS 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 } +- `createdBy`: ObjectId (Ref: User) +- `privacy`: String ['public', 'private', 'member', 'shared'] +- `status`: String ['processing', 'completed', 'failed'] +- `shareToken`: String (Dùng cho link truy cập nhanh) +- `shareTokenExpires`: Date +- `sharedWith`: Array [ObjectId (Ref: User)] +- `sharedEmails`: Array [String] +- `views`: Number +- `viewHistory`: Array [ { `date`: Date, `count`: Number } ] + +### Hotspot (Điểm điều hướng) +- `parent_scene_id`: ObjectId (Ref: Scene) +- `target_scene_id`: ObjectId (Ref: Scene) +- `title`: String +- `description`: String +- `coordinates`: Object { `yaw`: Number, `pitch`: Number } +- `is_auto_return`: Boolean (Tự động tạo link quay lại) + +### Setting (Cấu hình hệ thống) +- `timezone`: String (Mặc định: 'Asia/Ho_Chi_Minh') +- `language`: String (Mặc định: 'vi') + +--- + +## 2. Danh sách API Endpoints + +### Quản trị hệ thống (Admin Only) +- `POST /api/admin/backup`: Xuất toàn bộ DB và Uploads (Zip) +- `POST /api/admin/restore`: Khôi phục hệ thống từ file Zip +- `GET /api/admin/maintenance/stray-files`: Tìm file rác (không có trong DB) +- `POST /api/admin/maintenance/cleanup`: Dọn dẹp dữ liệu mồ côi +- `GET /api/admin/users`: Quản lý danh sách người dùng (Phân trang) +- `PUT /api/admin/users/:id`: Cập nhật User (Quyền, Password...) +- `DELETE /api/admin/users/:id`: Xóa User và dọn dẹp data liên quan + +### Cảnh 3D (Scenes) +- `POST /api/scenes`: Tạo mới (Upload ảnh, Resize 8K, Inject GPS) +- `GET /api/scenes`: Lấy danh sách hiển thị trên bản đồ (Theo quyền truy cập) +- `GET /api/scenes/:id`: Chi tiết một cảnh +- `PUT /api/scenes/:id`: Cập nhật Metadata, Privacy hoặc thay thế ảnh +- `DELETE /api/scenes/:id`: Xóa Scene và các scene con liên kết (BFS) +- `GET /api/share/:sceneId`: Trang trung gian hỗ trợ Open Graph (FB/Zalo) + +### Điểm điều hướng (Hotspots) +- `GET /api/hotspots/:scene_id`: Lấy danh sách điểm tương tác của cảnh +- `POST /api/hotspots/create`: Tạo mới (Hỗ trợ tự động tạo link ngược) +- `PUT /api/hotspots/update/:id`: Cập nhật vị trí/tiêu đề +- `DELETE /api/hotspots/delete/:id`: Xóa hotspot + +### Tài sản & Media (Assets) +- `GET /api/assets/view/:assetId`: Stream ảnh panorama (Có Referer & Token Verification) +- `GET /api/assets/view_avatar/:filename`: Stream ảnh đại diện +- `GET /api/me/assets`: Kho ảnh của tôi +- `DELETE /api/assets/:id`: Xóa file vật lý và bản ghi +- `GET /api/me/assets/top-large`: Thống kê file chiếm dung lượng lớn + +### Người dùng & Hồ sơ (User Profile) +- `POST /api/auth/register`: Đăng ký tài khoản +- `POST /api/auth/login`: Đăng nhập (Trả về JWT) +- `GET /api/me/profile`: Thông tin cá nhân & Quota lưu trữ +- `PUT /api/me/profile`: Cập nhật hồ sơ & Avatar +- `GET /api/users/search`: Tìm kiếm người dùng để chia sẻ + +### Hệ thống (System) +- `GET /api/system/settings`: Lấy cấu hình (Timezone, Lang) +- `PUT /api/system/settings`: Cập nhật cấu hình hệ thống +- `POST /api/maintenance/reset-all`: Xóa sạch dữ liệu (Dev only) + +--- + +## 3. Trung tâm Xử lý Hình ảnh (Backend Worker) +- **Công nghệ**: BullMQ + Redis + Sharp. +- **Hàng đợi**: `image-processing`. +- **Công việc**: + 1. Resize ảnh Equirectangular sang 8K. + 2. Inject tọa độ GPS vào metadata (Exiftool/ExifHelper). + 3. Cập nhật trạng thái `Scene` từ `processing` -> `completed`. + +--- + +## 4. Kiến trúc Frontend + +### Thành phần chính: +- **Bản đồ**: Leaflet.js + MarkerCluster. +- **Trình xem 360**: Pannellum. +- **Quản lý trạng thái**: LocalStorage (Lưu JWT, vị trí map, scene đang xem). + +### Luồng logic đặc biệt: +1. **Bảo mật ảnh**: Ảnh được stream qua API thay vì link trực tiếp. API yêu cầu xác thực `Referer` (chống hotlinking) và `JWT` hoặc `ShareToken`. +2. **Chia sẻ (Privacy Cascading)**: Khi đổi Privacy của Scene mẹ, các Scene con trong chuỗi Tour sẽ được đồng bộ quyền truy cập và Token. +3. **Open Graph**: Route `/api/share/:id` chèn Meta Tags động và Watermark 360 badge vào ảnh thumbnail để hiển thị đẹp trên mạng xã hội. + +--- + +## 5. Các Middleware Bảo mật +- `authMiddleware.js`: Xác thực JWT (`protect`, `optionalAuth`). +- `securityMiddleware.js`: + - `verifyReferer`: Chặn truy cập trực tiếp từ trình duyệt/site khác. + - `setNoCacheHeaders`: Chặn lưu cache các tài sản nhạy cảm. +- `quotaMiddleware.js`: Kiểm tra dung lượng lưu trữ dựa trên Role người dùng. + +--- + +## 6. Ghi chú cho Refactor +- Cần chuẩn hóa các đường dẫn tuyệt đối (hiện đang fix cứng `/home/locpham/...`). +- Chuyển đổi các logic xử lý file đồng bộ (`fs.unlinkSync`) sang bất đồng bộ để tối ưu I/O. +- Tách nhỏ `apiRoutes.js` thành các route con (admin, scenes, users, assets). +- Bổ sung Unit Test cho logic tính toán tọa độ Hotspot ngược. \ No newline at end of file diff --git a/backend/routes/apiRoutes.js b/backend/routes/apiRoutes.js index e4b1a17..ed2d0af 100644 --- a/backend/routes/apiRoutes.js +++ b/backend/routes/apiRoutes.js @@ -856,34 +856,33 @@ router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res, next) if (targetSceneIds.length > 0) { for (const targetId of targetSceneIds) { - const updateData = { privacy: privacy }; - let newShareToken = null; + let updateOperation = { $set: { privacy: privacy } }; // Nếu chuyển sang 'shared', đảm bảo scene con cũng có token riêng if (privacy === 'shared') { const target = await Scene.findById(targetId); if (target && !target.shareToken) { - newShareToken = crypto.randomBytes(24).toString('hex'); - updateData.shareToken = newShareToken; + updateOperation.$set.shareToken = crypto.randomBytes(24).toString('hex'); // Đặt thời hạn token của scene con giống scene cha nếu có if (scene.shareTokenExpires) { - updateData.shareTokenExpires = scene.shareTokenExpires; + updateOperation.$set.shareTokenExpires = scene.shareTokenExpires; } } else if (target && target.shareToken) { // Nếu scene con đã có token, giữ nguyên - updateData.shareToken = target.shareToken; + updateOperation.$set.shareToken = target.shareToken; if (scene.shareTokenExpires) { - updateData.shareTokenExpires = scene.shareTokenExpires; + updateOperation.$set.shareTokenExpires = scene.shareTokenExpires; } else { - updateData.shareTokenExpires = null; + updateOperation.$set.shareTokenExpires = null; } } } else { // Nếu không phải 'shared', xóa token và thời hạn của scene con - updateData.shareToken = null; - updateData.shareTokenExpires = null; + // Sử dụng $unset để loại bỏ trường thay vì đặt thành null, + // điều này giúp tránh lỗi duplicate key nếu index không phải là sparse. + updateOperation.$unset = { shareToken: "", shareTokenExpires: "" }; } - await Scene.updateOne({ _id: targetId }, { $set: updateData }); + await Scene.updateOne({ _id: targetId }, updateOperation); } console.log(`[Privacy Sync] Cascaded privacy status to ${targetSceneIds.length} linked scenes.`); } diff --git a/backend/uploads/processed_1781002771830_4f231324.jpg.jpg b/backend/uploads/processed_1781002771830_4f231324.jpg.jpg new file mode 100644 index 0000000..cfc02d4 Binary files /dev/null and b/backend/uploads/processed_1781002771830_4f231324.jpg.jpg differ diff --git a/backend/uploads/processed_1781002790436_f674c83c.jpg.jpg b/backend/uploads/processed_1781002790436_f674c83c.jpg.jpg new file mode 100644 index 0000000..c16333a Binary files /dev/null and b/backend/uploads/processed_1781002790436_f674c83c.jpg.jpg differ diff --git a/backend/uploads/processed_1781002862946_4d78a88e.jpg.jpg b/backend/uploads/processed_1781002862946_4d78a88e.jpg.jpg new file mode 100644 index 0000000..fd463d5 Binary files /dev/null and b/backend/uploads/processed_1781002862946_4d78a88e.jpg.jpg differ diff --git a/frontend/js/main_map.js b/frontend/js/main_map.js index c4e5012..41ddff2 100644 --- a/frontend/js/main_map.js +++ b/frontend/js/main_map.js @@ -945,15 +945,15 @@ async function handleEditDeleteScene(scene) { editPrivacyBtn.onclick = () => { returnToDashboardAfterEdit = false; closeActionModal(); - // Mở modal metadata, false vì ảnh trên map luôn là ảnh mẹ (không phải child) - openEditMetadataModal(scene, false); + // Sử dụng thuộc tính isChildScene từ backend để quyết định quyền chỉnh sửa + openEditMetadataModal(scene, scene.isChildScene); }; // Gán sự kiện cho nút Sửa editBtn.onclick = () => { returnToDashboardAfterEdit = false; closeActionModal(); - openEditMetadataModal(scene, false); + openEditMetadataModal(scene, scene.isChildScene); }; // Gán sự kiện cho nút Xóa @@ -1546,8 +1546,7 @@ async function loadMyScenes() { dashboardReturnTab = 'my-scenes'; returnToDashboardAfterEdit = true; closeDashboard(); - // Mặc định truyền false cho isChild, logic backend sẽ xử lý cascade privacy sau - openEditMetadataModal(scene, false); + openEditMetadataModal(scene, scene.isChildScene); }; // Xử lý nút Xóa (Sẽ được hoàn thiện ở Bước 4) @@ -1966,7 +1965,10 @@ window.openEditFromMedia = function(scene, isChild = false) { /** * Mở Modal sửa thông tin Metadata chuyên biệt */ -window.openEditMetadataModal = function(scene, isChild = false) { +window.openEditMetadataModal = function(scene, isChildArg = null) { + // Ưu tiên isChildScene từ object scene, hoặc giá trị truyền vào thủ công + const isChild = isChildArg !== null ? isChildArg : (!!scene.isChildScene); + currentEditingScene = scene; // Lưu lại để dùng cho chia sẻ // Load dữ liệu chia sẻ hiện tại sharedUsersData = scene.sharedWith || []; diff --git a/frontend/js/viewer360.js b/frontend/js/viewer360.js index e735384..fa650b5 100644 --- a/frontend/js/viewer360.js +++ b/frontend/js/viewer360.js @@ -43,6 +43,17 @@ function renderCustomHotspot(hotSpotDiv, args) { callout.appendChild(title); hotSpotDiv.appendChild(callout); + + // Gắn sự kiện chuột phải trực tiếp vào bong bóng callout + callout.addEventListener('contextmenu', (e) => { + e.preventDefault(); + e.stopPropagation(); // Ngăn chặn sự kiện lan truyền lên viewer + + // Kiểm tra quyền và mở menu chỉnh sửa hotspot + if (typeof window.openHotspotMenu === 'function') { + window.openHotspotMenu(args.hotspotData); // Truyền dữ liệu hotspot đầy đủ + } + }); } /** @@ -88,7 +99,8 @@ function initPanoramaViewer(imageUrl, hotspots = [], ownerId = null, initialPitc createTooltipFunc: renderCustomHotspot, createTooltipArgs: { title: h.title || target?.name || target?.title || "Điểm điều hướng", - thumbUrl: thumbUrl + thumbUrl: thumbUrl, + hotspotData: h // Truyền toàn bộ dữ liệu hotspot vào args để sử dụng trong renderCustomHotspot }, id: h._id, clickHandlerFunc: () => { @@ -158,6 +170,7 @@ function applyViewerSecurity() { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); + return false; // Ngăn chặn menu chuột phải mặc định của trình duyệt // Nếu viewer đang hoạt động, lấy tọa độ Pitch/Yaw tại điểm click if (activeViewer) { @@ -177,25 +190,12 @@ function applyViewerSecurity() { const pitch = coords[0]; const yaw = coords[1]; - // Kiểm tra xem có hotspot nào gần điểm click không (ngưỡng 2 độ) - const existing = currentHotspots.find(h => - Math.abs((h.coordinates?.pitch || h.pitch) - pitch) < 2 && - Math.abs((h.coordinates?.yaw || h.yaw) - yaw) < 2 - ); - // Nếu không được phép, dừng xử lý và chặn menu mặc định if (!isAuthorized) return false; - if (existing) { - // ĐÃ CÓ Hotspot -> Hiện Menu: [Sửa Hotspot] / [Xóa Hotspot] - if (typeof window.openHotspotMenu === 'function') { - window.openHotspotMenu(existing); - } - } else { - // CHƯA CÓ Hotspot -> Hiện Form: [Tạo mới Hotspot] - if (typeof window.handleHotspotCreation === 'function') { - window.handleHotspotCreation(pitch, yaw, null); - } + // Nếu click chuột phải vào vùng trống, mở form tạo hotspot mới + if (typeof window.handleHotspotCreation === 'function') { + window.handleHotspotCreation(pitch, yaw, null); } console.log(`Coordinates captured: Pitch ${pitch}, Yaw ${yaw}`); } @@ -203,8 +203,8 @@ function applyViewerSecurity() { }; // Sử dụng capture phase (true) để bắt sự kiện trước khi nó chạm đến Pannellum - container.addEventListener('contextmenu', handleContextMenu, true); - panoramaViewer.addEventListener('contextmenu', handleContextMenu, true); + // container.addEventListener('contextmenu', handleContextMenu, true); // Bỏ gắn sự kiện này + // panoramaViewer.addEventListener('contextmenu', handleContextMenu, true); // Bỏ gắn sự kiện này // Block drag and drop container.addEventListener('dragstart', (e) => {