Thay đổi ngày 20260609

This commit is contained in:
2026-06-09 19:48:56 +07:00
parent d243c67718
commit d39d3b3d53
7 changed files with 173 additions and 36 deletions
+136
View File
@@ -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.
+10 -11
View File
@@ -856,34 +856,33 @@ router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res, next)
if (targetSceneIds.length > 0) { if (targetSceneIds.length > 0) {
for (const targetId of targetSceneIds) { for (const targetId of targetSceneIds) {
const updateData = { privacy: privacy }; let updateOperation = { $set: { privacy: privacy } };
let newShareToken = null;
// Nếu chuyển sang 'shared', đảm bảo scene con cũng có token riêng // Nếu chuyển sang 'shared', đảm bảo scene con cũng có token riêng
if (privacy === 'shared') { if (privacy === 'shared') {
const target = await Scene.findById(targetId); const target = await Scene.findById(targetId);
if (target && !target.shareToken) { if (target && !target.shareToken) {
newShareToken = crypto.randomBytes(24).toString('hex'); updateOperation.$set.shareToken = crypto.randomBytes(24).toString('hex');
updateData.shareToken = newShareToken;
// Đặt thời hạn token của scene con giống scene cha nếu có // Đặt thời hạn token của scene con giống scene cha nếu có
if (scene.shareTokenExpires) { if (scene.shareTokenExpires) {
updateData.shareTokenExpires = scene.shareTokenExpires; updateOperation.$set.shareTokenExpires = scene.shareTokenExpires;
} }
} else if (target && target.shareToken) { } else if (target && target.shareToken) {
// Nếu scene con đã có token, giữ nguyên // Nếu scene con đã có token, giữ nguyên
updateData.shareToken = target.shareToken; updateOperation.$set.shareToken = target.shareToken;
if (scene.shareTokenExpires) { if (scene.shareTokenExpires) {
updateData.shareTokenExpires = scene.shareTokenExpires; updateOperation.$set.shareTokenExpires = scene.shareTokenExpires;
} else { } else {
updateData.shareTokenExpires = null; updateOperation.$set.shareTokenExpires = null;
} }
} }
} else { } else {
// Nếu không phải 'shared', xóa token và thời hạn của scene con // Nếu không phải 'shared', xóa token và thời hạn của scene con
updateData.shareToken = null; // Sử dụng $unset để loại bỏ trường thay vì đặt thành null,
updateData.shareTokenExpires = 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.`); console.log(`[Privacy Sync] Cascaded privacy status to ${targetSceneIds.length} linked scenes.`);
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

+8 -6
View File
@@ -945,15 +945,15 @@ async function handleEditDeleteScene(scene) {
editPrivacyBtn.onclick = () => { editPrivacyBtn.onclick = () => {
returnToDashboardAfterEdit = false; returnToDashboardAfterEdit = false;
closeActionModal(); closeActionModal();
// Mở modal metadata, false vì ảnh trên map luôn là ảnh mẹ (không phải child) // Sử dụng thuộc tính isChildScene từ backend để quyết định quyền chỉnh sửa
openEditMetadataModal(scene, false); openEditMetadataModal(scene, scene.isChildScene);
}; };
// Gán sự kiện cho nút Sửa // Gán sự kiện cho nút Sửa
editBtn.onclick = () => { editBtn.onclick = () => {
returnToDashboardAfterEdit = false; returnToDashboardAfterEdit = false;
closeActionModal(); closeActionModal();
openEditMetadataModal(scene, false); openEditMetadataModal(scene, scene.isChildScene);
}; };
// Gán sự kiện cho nút Xóa // Gán sự kiện cho nút Xóa
@@ -1546,8 +1546,7 @@ async function loadMyScenes() {
dashboardReturnTab = 'my-scenes'; dashboardReturnTab = 'my-scenes';
returnToDashboardAfterEdit = true; returnToDashboardAfterEdit = true;
closeDashboard(); closeDashboard();
// Mặc định truyền false cho isChild, logic backend sẽ xử lý cascade privacy sau openEditMetadataModal(scene, scene.isChildScene);
openEditMetadataModal(scene, false);
}; };
// Xử lý nút Xóa (Sẽ được hoàn thiện ở Bước 4) // 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 * 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ẻ currentEditingScene = scene; // Lưu lại để dùng cho chia sẻ
// Load dữ liệu chia sẻ hiện tại // Load dữ liệu chia sẻ hiện tại
sharedUsersData = scene.sharedWith || []; sharedUsersData = scene.sharedWith || [];
+19 -19
View File
@@ -43,6 +43,17 @@ function renderCustomHotspot(hotSpotDiv, args) {
callout.appendChild(title); callout.appendChild(title);
hotSpotDiv.appendChild(callout); 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, createTooltipFunc: renderCustomHotspot,
createTooltipArgs: { createTooltipArgs: {
title: h.title || target?.name || target?.title || "Điểm điều hướng", 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, id: h._id,
clickHandlerFunc: () => { clickHandlerFunc: () => {
@@ -158,6 +170,7 @@ function applyViewerSecurity() {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
e.stopImmediatePropagation(); 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 // Nếu viewer đang hoạt động, lấy tọa độ Pitch/Yaw tại điểm click
if (activeViewer) { if (activeViewer) {
@@ -177,25 +190,12 @@ function applyViewerSecurity() {
const pitch = coords[0]; const pitch = coords[0];
const yaw = coords[1]; 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 // Nếu không được phép, dừng xử lý và chặn menu mặc định
if (!isAuthorized) return false; if (!isAuthorized) return false;
if (existing) { // Nếu click chuột phải vào vùng trống, mở form tạo hotspot mới
// ĐÃ CÓ Hotspot -> Hiện Menu: [Sửa Hotspot] / [Xóa Hotspot] if (typeof window.handleHotspotCreation === 'function') {
if (typeof window.openHotspotMenu === 'function') { window.handleHotspotCreation(pitch, yaw, null);
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);
}
} }
console.log(`Coordinates captured: Pitch ${pitch}, Yaw ${yaw}`); 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 // Sử dụng capture phase (true) để bắt sự kiện trước khi nó chạm đến Pannellum
container.addEventListener('contextmenu', handleContextMenu, true); // container.addEventListener('contextmenu', handleContextMenu, true); // Bỏ gắn sự kiện này
panoramaViewer.addEventListener('contextmenu', handleContextMenu, true); // panoramaViewer.addEventListener('contextmenu', handleContextMenu, true); // Bỏ gắn sự kiện này
// Block drag and drop // Block drag and drop
container.addEventListener('dragstart', (e) => { container.addEventListener('dragstart', (e) => {