diff --git a/.gitignore b/.gitignore index 9d58dd3..81213a4 100644 --- a/.gitignore +++ b/.gitignore @@ -260,3 +260,10 @@ database/BSK.db-wal *.db-wal database/*.db *.old +*.jar +*.db +*.db-shm +*.db-wal +database/*.db +database/*.db-shm +database/*.db-wal \ No newline at end of file diff --git a/DATABASE_BACKUP_GUIDE.md b/DATABASE_BACKUP_GUIDE.md deleted file mode 100644 index ab44268..0000000 --- a/DATABASE_BACKUP_GUIDE.md +++ /dev/null @@ -1,304 +0,0 @@ -# 🗄️ BSK Clinic Database Backup Guide - -## ⚠️ **CRITICAL: Understanding SQLite WAL Mode** - -Your database uses **WAL (Write-Ahead Logging)** mode for better concurrent access. This creates **3 files**: - -``` -database/ -├── BSK.db ← Main database (updated periodically via "checkpoint") -├── BSK.db-wal ← Write-Ahead Log (contains RECENT uncommitted data) -└── BSK.db-shm ← Shared Memory (coordination file) -``` - -### **The Problem You Experienced:** - -``` -❌ WRONG - What Happened: -1. You copied ONLY BSK.db (old data from last checkpoint) -2. You LOST BSK.db-wal (contained checkups 184-1978) -3. Result: 1795 checkups disappeared! - -✅ CORRECT - What You Should Do: -1. Stop the server OR checkpoint first -2. Copy the main BSK.db file (now contains all data) -3. Restore it -``` - ---- - -## 📋 **Backup Methods** - -### **Method 1: Using Built-in Google Drive Backup (RECOMMENDED) ✅** - -This is now **COMPLETELY SAFE** - backs up ALL 3 database files! - -**Steps:** -1. Open **Server Dashboard** -2. Click **"Sao lưu CSDL lên Drive"** button -3. Watch the progress: - - "Đang kiểm tra các tệp cơ sở dữ liệu..." - - Shows size of each file (BSK.db, BSK.db-wal, BSK.db-shm) - - "Đang tải lên tất cả các tệp database..." - - "✅ Sao lưu hoàn tất!" -4. ✅ Done! All files backed up to a timestamped folder. - -**What happens behind the scenes:** -```java -// server.jar automatically does this: -1. Checks all 3 files: BSK.db, BSK.db-wal, BSK.db-shm -2. Creates timestamped folder: "_Database_Backups/Backup_2025-01-15_14-30-00/" -3. Uploads ALL files to that folder -4. No data loss possible! -``` - -**Google Drive Structure:** -``` -BSK_Clinic_Patient_Files/ -└── _Database_Backups/ - ├── Backup_2025-01-15_14-30-00/ - │ ├── BSK.db (main database) - │ ├── BSK.db-wal (uncommitted changes) - │ └── BSK.db-shm (shared memory) - └── Backup_2025-01-15_09-15-00/ - └── ... (older backup) -``` - ---- - -### **Method 2: Manual Backup with PowerShell Script** - -**For manual backups when server is running:** - -```powershell -# Step 1: Checkpoint the database -.\checkpoint_database.ps1 - -# Step 2: Copy the database -Copy-Item database\BSK.db "D:\Backups\BSK_backup_$(Get-Date -Format 'yyyy-MM-dd_HH-mm-ss').db" -``` - -**For manual backups when server is stopped:** - -```powershell -# Just copy all 3 files -Copy-Item database\BSK.db* "D:\Backups\" -``` - ---- - -### **Method 3: Using SQLite Command Line** - -```bash -# Option A: Checkpoint first (if server is running) -sqlite3 database/BSK.db "PRAGMA wal_checkpoint(TRUNCATE);" - -# Option B: Create a proper backup -sqlite3 database/BSK.db ".backup 'backup.db'" -``` - ---- - -## 🔄 **Restoring from Backup** - -### **Method 1: Restore Complete Backup from Google Drive** - -**Steps:** -1. **Stop the server** completely -2. Download the **entire backup folder** from Google Drive -3. Navigate to `_Database_Backups/Backup_YYYY-MM-DD_HH-MM-SS/` -4. Download all 3 files: - - `BSK.db` - - `BSK.db-wal` (if present) - - `BSK.db-shm` (if present) -5. **Delete or backup your current database files**: - ```powershell - # Backup current database - Move-Item database\BSK.db* backup_old\ - ``` -6. **Copy downloaded files** to `database/` folder -7. **Restart the server** -8. ✅ **Done!** All data restored including uncommitted transactions - -### **Method 2: Restore Only Main DB (Data Loss Risk!)** - -⚠️ **WARNING:** Only use this if no WAL file exists in the backup! - -**Steps:** -1. Stop the server -2. Download only `BSK.db` from backup -3. Copy to `database/` folder -4. Restart server -5. ⚠️ Any data in the WAL file at backup time is **lost** - ---- - -## 🔧 **When to Checkpoint** - -SQLite automatically checkpoints when: -- WAL file reaches **1000 pages** (~4MB) -- Database is closed gracefully -- Connection pool is shut down - -**Manual checkpoint is needed when:** -- ⚠️ Server has been running for days without restart -- ⚠️ You need to backup while server is running -- ⚠️ WAL file is very large (>10MB) - -**Check WAL status:** -```powershell -.\checkpoint_database.ps1 -CheckOnly -``` - ---- - -## 🚨 **Emergency: Recovering from Bad Backup** - -If you already uploaded a backup that's missing data: - -### **Option 1: Check if WAL files still exist at hospital** - -```powershell -# At the hospital server, check: -Get-ChildItem database\BSK.db* - -# If BSK.db-wal exists: -sqlite3 database\BSK.db "PRAGMA wal_checkpoint(TRUNCATE);" -# Now backup this file - it has all data! -``` - -### **Option 2: Restore from older backup + replay transactions** - -If WAL is gone, data is **permanently lost**. You'll need: -1. Use the latest backup before modification -2. Manually re-enter lost data -3. Use filesystem folder IDs to identify missing checkups: - -```powershell -# Find checkup folders -Get-ChildItem image\checkup_media -Directory | - Where-Object { $_.Name -match '^\d+$' } | - Sort-Object { [int]$_.Name } - -# Compare with database -sqlite3 database\BSK.db "SELECT checkup_id FROM Checkup ORDER BY checkup_id" -``` - ---- - -## 📊 **Best Practices** - -### ✅ **DO:** -- ✅ Use the built-in Google Drive backup button -- ✅ Stop the server before manual file copies -- ✅ Test your backups regularly -- ✅ Keep multiple backup versions -- ✅ Backup before schema changes -- ✅ Document any manual database modifications - -### ❌ **DON'T:** -- ❌ Copy only BSK.db while server is running -- ❌ Modify the database on your machine while hospital uses it -- ❌ Ignore WAL files during backup -- ❌ Restore old backups without checking timestamps -- ❌ Edit production database without testing first - ---- - -## 🛠️ **Schema Change Workflow** - -When you need to add columns or modify the database: - -``` -CORRECT Process: -────────────────────────────────────────────────── -1. STOP the hospital server completely -2. Backup using: .\checkpoint_database.ps1 -3. Download the backup -4. Test changes on your machine -5. Create migration script (ALTER TABLE commands) -6. Send ONLY the migration script (not the whole DB) -7. Run migration script at hospital -8. Restart server -9. Verify everything works - -NEVER: -────────────────────────────────────────────────── -❌ Download → Modify → Upload entire database - (This WILL cause data loss!) -``` - -### **Migration Script Example:** - -```sql --- migration_add_doctor_columns.sql -BEGIN TRANSACTION; - --- Add new column -ALTER TABLE Doctor ADD COLUMN specialty TEXT; - --- Update existing data if needed -UPDATE Doctor SET specialty = 'General' WHERE specialty IS NULL; - -COMMIT; -``` - -**Apply migration:** -```bash -sqlite3 database/BSK.db < migration_add_doctor_columns.sql -``` - ---- - -## 🔍 **Troubleshooting** - -### **Problem: WAL file is huge (>100MB)** - -```powershell -# Force checkpoint -sqlite3 database\BSK.db "PRAGMA wal_checkpoint(TRUNCATE);" - -# If it fails, connections are open: -# Stop the server, then retry -``` - -### **Problem: "database is locked"** - -- Server is running → stop it first -- Old connections → restart server -- Check: `database\BSK.db-shm` (if exists, connections are active) - -### **Problem: Backup seems too small** - -```powershell -# Check WAL size -Get-Item database\BSK.db-wal - -# If WAL is large, you forgot to checkpoint! -.\checkpoint_database.ps1 -``` - ---- - -## 📞 **Support** - -If you encounter data loss: -1. **DON'T PANIC** -2. **DON'T overwrite any files** -3. Check if old backups exist on Google Drive -4. Contact: [Your Support Contact] - ---- - -## 📚 **Further Reading** - -- [SQLite WAL Mode Documentation](https://www.sqlite.org/wal.html) -- [SQLite Backup Documentation](https://www.sqlite.org/backup.html) -- [Understanding WAL Checkpoints](https://www.sqlite.org/wal.html#ckpt) - ---- - -**Last Updated:** $(Get-Date -Format 'yyyy-MM-dd') -**Version:** 1.0 -**Author:** BSK Development Team - diff --git a/image/checkup_media/2178/IMG_2178_20251205_144534_212.jpg b/image/checkup_media/2178/IMG_2178_20251205_144534_212.jpg new file mode 100644 index 0000000..6a40bd6 Binary files /dev/null and b/image/checkup_media/2178/IMG_2178_20251205_144534_212.jpg differ diff --git a/image/checkup_media/2182/IMG_2182_20251205_144843_092.jpg b/image/checkup_media/2182/IMG_2182_20251205_144843_092.jpg new file mode 100644 index 0000000..6a40bd6 Binary files /dev/null and b/image/checkup_media/2182/IMG_2182_20251205_144843_092.jpg differ diff --git a/image/checkup_media/2189/IMG_2189_20251205_145706_918.jpg b/image/checkup_media/2189/IMG_2189_20251205_145706_918.jpg new file mode 100644 index 0000000..6a40bd6 Binary files /dev/null and b/image/checkup_media/2189/IMG_2189_20251205_145706_918.jpg differ diff --git a/src/main/java/BsK/client/ui/component/CheckUpPage/CheckUpPage.java b/src/main/java/BsK/client/ui/component/CheckUpPage/CheckUpPage.java index f333199..e3df72c 100644 --- a/src/main/java/BsK/client/ui/component/CheckUpPage/CheckUpPage.java +++ b/src/main/java/BsK/client/ui/component/CheckUpPage/CheckUpPage.java @@ -3379,20 +3379,37 @@ public class CheckUpPage extends JPanel { private void handleUltrasoundImageDetected(Path imagePath, String patientId) { log.info("=== HANDLING ULTRASOUND IMAGE: {} ===", imagePath.toAbsolutePath()); log.info("Patient ID for this operation: {}", patientId); - log.info("Current media path: {}", currentCheckupMediaPath); - // Snapshot the current media state atomically to avoid races - final String capturedPatientId; - final Path capturedMediaPath; - synchronized (mediaStateLock) { - capturedPatientId = (patientId == null) ? currentCheckupIdForMedia : patientId; - capturedMediaPath = currentCheckupMediaPath; - } - if (capturedPatientId == null || capturedPatientId.trim().isEmpty() || capturedMediaPath == null) { - log.warn("Media state not ready (patientId or mediaPath null). Skipping: {}", imagePath); + // CRITICAL FIX: Use the patient ID passed as parameter (from folder scan) + // Do NOT fall back to currentCheckupIdForMedia as it may have changed + if (patientId == null || patientId.trim().isEmpty()) { + log.error("CRITICAL: Patient ID is null when processing ultrasound image. This should never happen. Aborting to prevent data corruption."); return; } + // Snapshot the current media state atomically to avoid races + final String capturedPatientId = patientId; // Always use the parameter, never currentCheckupIdForMedia + final Path capturedMediaPath; + synchronized (mediaStateLock) { + // Only read the path from shared state, but use the patient ID from parameter + capturedMediaPath = currentCheckupMediaPath; + } + + // Validate we have the necessary state + if (capturedMediaPath == null) { + log.warn("Media path is null. This may happen if no patient is currently selected. Skipping: {}", imagePath); + return; + } + + // Additional safety check: Ensure the patient ID in the path matches the captured patient ID + String pathPatientId = capturedMediaPath.getFileName().toString(); + if (!capturedPatientId.equals(pathPatientId)) { + log.error("CRITICAL RACE CONDITION DETECTED: Patient ID mismatch! Folder scan ID: {}, Current media path ID: {}. Aborting to prevent data corruption.", capturedPatientId, pathPatientId); + return; + } + + log.info("Validated patient ID: {} matches current media path", capturedPatientId); + try { // Final safety check - ensure file still exists before processing if (!Files.exists(imagePath)) { @@ -3586,7 +3603,15 @@ public class CheckUpPage extends JPanel { } private void handleTakePicture() { - if (currentCheckupMediaPath == null) { + // Atomically capture the media state to prevent race conditions + final String capturedCheckupId; + final Path capturedMediaPath; + synchronized (mediaStateLock) { + capturedCheckupId = currentCheckupIdForMedia; + capturedMediaPath = currentCheckupMediaPath; + } + + if (capturedMediaPath == null || capturedCheckupId == null || capturedCheckupId.trim().isEmpty()) { JOptionPane.showMessageDialog(this, "Vui lòng chọn một lượt khám để lưu ảnh.", "Chưa chọn lượt khám", JOptionPane.WARNING_MESSAGE); return; } @@ -3598,8 +3623,10 @@ public class CheckUpPage extends JPanel { } String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss_SSS").format(new Date()); - String fileName = "IMG_" + currentCheckupIdForMedia + "_" + timestamp + ".jpg"; - Path filePath = currentCheckupMediaPath.resolve(fileName); + String fileName = "IMG_" + capturedCheckupId + "_" + timestamp + ".jpg"; + Path filePath = capturedMediaPath.resolve(fileName); + + log.info("Taking picture for checkup ID: {} with filename: {}", capturedCheckupId, fileName); try { BufferedImage image = selectedWebcam.getImage(); @@ -3608,11 +3635,12 @@ public class CheckUpPage extends JPanel { ImageIO.write(image, "JPG", filePath.toFile()); log.info("Picture taken and saved locally as JPG at: {}", filePath); - // Refresh UI to show the new image immediately - loadAndDisplayImages(currentCheckupMediaPath); + // Refresh UI to show the new image immediately (use captured path, not currentCheckupMediaPath) + loadAndDisplayImages(capturedMediaPath); - // Now, upload in the background (server will convert to PNG for archival) - uploadImageInBackground(filePath.getFileName().toString(), image); + // Now, upload in the background + // The filename already contains the checkup ID, so uploadImageInBackground will extract it safely + uploadImageInBackground(fileName, image); } else { throw new IOException("Không thể lấy ảnh từ webcam"); @@ -3632,6 +3660,16 @@ public class CheckUpPage extends JPanel { private void uploadImageInBackground(String fileName, BufferedImage image) { imageUploadExecutor.submit(() -> { try { + // CRITICAL FIX: Extract checkup ID from filename instead of using volatile field + // This prevents race conditions when user switches patients during upload + String checkupId = extractCheckupIdFromFilename(fileName); + if (checkupId == null || checkupId.isEmpty()) { + log.error("CRITICAL: Failed to extract checkup ID from filename: {}. Upload aborted to prevent data corruption.", fileName); + return; + } + + log.info("Uploading image {} for checkup ID: {}", fileName, checkupId); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); // CHANGE 1: Write the image as a JPG instead of PNG. @@ -3641,14 +3679,12 @@ public class CheckUpPage extends JPanel { byte[] imageData = baos.toByteArray(); baos.close(); - String checkupId = currentCheckupIdForMedia; - // CHANGE 2: Use the original fileName (which should already be .jpg). // No need to rename it to .png anymore. UploadCheckupImageRequest request = new UploadCheckupImageRequest(checkupId, imageData, fileName); NetworkUtil.sendPacket(ClientHandler.ctx.channel(), request); - log.info("Sent UploadCheckupImageRequest for {}", fileName); + log.info("Sent UploadCheckupImageRequest for {} (checkupId: {})", fileName, checkupId); } catch (IOException e) { log.error("Failed to convert image to JPG for upload: {}", fileName, e); @@ -3656,6 +3692,39 @@ public class CheckUpPage extends JPanel { }); } + /** + * Extracts checkup ID from filename format: IMG_{checkupId}_{timestamp}.jpg + * This prevents race conditions where currentCheckupIdForMedia changes while processing. + * + * @param fileName The image filename + * @return The checkup ID, or null if extraction fails + */ + private String extractCheckupIdFromFilename(String fileName) { + if (fileName == null || fileName.isEmpty()) { + return null; + } + + // Expected format: IMG_{checkupId}_{timestamp}.jpg or ultrasound_{checkupId}_{timestamp}.jpg + try { + String[] parts = fileName.split("_"); + if (parts.length >= 2) { + // The checkup ID is always the second part (index 1) + String checkupId = parts[1]; + + // Validate it's a number + Integer.parseInt(checkupId); + + log.debug("Extracted checkup ID '{}' from filename '{}'", checkupId, fileName); + return checkupId; + } + } catch (NumberFormatException e) { + log.warn("Invalid checkup ID format in filename: {}", fileName); + } + + log.error("Failed to extract checkup ID from filename: {}", fileName); + return null; + } + private void deleteImage(File imageFile) { if (imageFile == null || !imageFile.exists()) { log.warn("Cannot delete image - file does not exist or is null"); @@ -3663,17 +3732,22 @@ public class CheckUpPage extends JPanel { } String fileName = imageFile.getName(); - String checkupId = currentCheckupIdForMedia; + + // CRITICAL FIX: Extract checkup ID from filename instead of using volatile field + // This prevents race conditions when user switches patients during deletion + String checkupId = extractCheckupIdFromFilename(fileName); if (checkupId == null || checkupId.trim().isEmpty()) { - log.error("Cannot delete image - no checkup ID available"); + log.error("Cannot delete image - failed to extract checkup ID from filename: {}", fileName); JOptionPane.showMessageDialog(this, - "Không thể xóa ảnh - không có mã khám.", + "Không thể xóa ảnh - tên file không hợp lệ: " + fileName, "Lỗi", JOptionPane.ERROR_MESSAGE); return; } + log.info("Deleting image {} for checkup ID: {}", fileName, checkupId); + // Send delete request to server DeleteCheckupImageRequest request = new DeleteCheckupImageRequest(checkupId, fileName); NetworkUtil.sendPacket(ClientHandler.ctx.channel(), request); @@ -3850,8 +3924,15 @@ public class CheckUpPage extends JPanel { log.debug("Saved synced image as JPG: {}", imageFile.getAbsolutePath()); // If this image belongs to the currently selected patient, refresh the gallery to show it. - if (response.getCheckupId().equals(currentCheckupIdForMedia)) { - loadAndDisplayImages(currentCheckupMediaPath); + // Use atomic read to prevent race conditions + String currentId; + Path currentPath; + synchronized (mediaStateLock) { + currentId = currentCheckupIdForMedia; + currentPath = currentCheckupMediaPath; + } + if (currentId != null && response.getCheckupId().equals(currentId) && currentPath != null) { + loadAndDisplayImages(currentPath); } } catch (IOException e) { @@ -3866,7 +3947,15 @@ public class CheckUpPage extends JPanel { private void handleRecordVideo() { - if (currentCheckupMediaPath == null) { + // Atomically capture the media state to prevent race conditions + final String capturedCheckupId; + final Path capturedMediaPath; + synchronized (mediaStateLock) { + capturedCheckupId = currentCheckupIdForMedia; + capturedMediaPath = currentCheckupMediaPath; + } + + if (capturedMediaPath == null || capturedCheckupId == null || capturedCheckupId.trim().isEmpty()) { JOptionPane.showMessageDialog(this, "Vui lòng chọn một lượt khám để lưu video.", "Chưa chọn lượt khám", JOptionPane.WARNING_MESSAGE); return; } @@ -3880,8 +3969,10 @@ public class CheckUpPage extends JPanel { if (!isRecording) { // Start recording String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()); - String fileName = "VID_" + currentCheckupIdForMedia + "_" + timestamp + ".mp4"; - Path filePath = currentCheckupMediaPath.resolve(fileName); + String fileName = "VID_" + capturedCheckupId + "_" + timestamp + ".mp4"; + Path filePath = capturedMediaPath.resolve(fileName); + + log.info("Starting video recording for checkup ID: {} with filename: {}", capturedCheckupId, fileName); try { // Check if JavaCV classes are available diff --git a/src/main/java/BsK/server/network/handler/ServerHandler.java b/src/main/java/BsK/server/network/handler/ServerHandler.java index 9107a22..c3dcf94 100644 --- a/src/main/java/BsK/server/network/handler/ServerHandler.java +++ b/src/main/java/BsK/server/network/handler/ServerHandler.java @@ -1266,6 +1266,30 @@ public class ServerHandler extends SimpleChannelInboundHandler= 2) { + // The checkup ID is always the second part (index 1) + String checkupId = parts[1]; + + // Validate it's a valid integer (checkup IDs are numeric) + Integer.parseInt(checkupId); + + log.debug("Extracted checkup ID '{}' from filename '{}'", checkupId, fileName); + return checkupId; + } + } catch (NumberFormatException e) { + // Filename has underscores but second part is not a number + log.debug("Filename '{}' does not follow expected format (second part is not a valid checkup ID)", fileName); + } + + // Return null if filename doesn't follow expected format + // This is OK - allows legacy files or manually uploaded files without checkup ID + return null; + } + /** * Checks if a character is a Vietnamese vowel or 'd/đ' that can have accents. * Vietnamese vowels: a, ă, â, e, ê, i, o, ô, ơ, u, ư, y