diff --git a/config.example.toml b/config.example.toml index 3cc4a2a..2534cd0 100644 --- a/config.example.toml +++ b/config.example.toml @@ -69,6 +69,12 @@ retry_cron = "0 */5 * * * *" # --- Lokale Speicher (Signaturen / Bild-Notizen) -------------------------- [signature] storage_dir = "./data/signatures" +# Aufbewahrungsfrist der Unterschrifts-Dateien in Tagen. Ein Cron löscht +# ältere Dateien (Unterschriften werden NICHT mehr beim Report-Upload gelöscht). +# 0 = nie automatisch löschen. +retention_days = 90 +# Cron (6-stellig, inkl. Sekunden) für den Signatur-Cleanup (täglich 04:00). +cleanup_cron = "0 0 4 * * *" [attachment] storage_dir = "./data/attachments" diff --git a/crates/api/src/config.rs b/crates/api/src/config.rs index 00f6f4e..a50c1a9 100644 --- a/crates/api/src/config.rs +++ b/crates/api/src/config.rs @@ -102,7 +102,12 @@ impl Config { app_names = ?self.gsd.app_names, "cfg.gsd" ); - tracing::info!(storage_dir = %self.signature.storage_dir, "cfg.signature"); + tracing::info!( + storage_dir = %self.signature.storage_dir, + retention_days = self.signature.retention_days, + cleanup_cron = %self.signature.cleanup_cron, + "cfg.signature" + ); tracing::info!(storage_dir = %self.attachment.storage_dir, "cfg.attachment (Bild-Notizen lokal)"); tracing::info!( storage_dir = %self.report.storage_dir, @@ -265,12 +270,22 @@ pub struct SignatureConfig { /// Basis-Verzeichnis für die PNG-Dateien (wird beim Start angelegt). #[serde(default = "default_signature_dir")] pub storage_dir: String, + /// Aufbewahrungsfrist der lokalen Unterschrifts-Dateien in Tagen. Ein + /// Cron-Job (`cleanup_cron`) löscht Dateien, die älter sind. `0` = Cleanup + /// AUS (Unterschriften werden nie automatisch gelöscht). + #[serde(default = "default_signature_retention_days")] + pub retention_days: u32, + /// Cron (6-stellig, inkl. Sekunden) für den Signatur-Cleanup. + #[serde(default = "default_signature_cleanup_cron")] + pub cleanup_cron: String, } impl Default for SignatureConfig { fn default() -> Self { Self { storage_dir: default_signature_dir(), + retention_days: default_signature_retention_days(), + cleanup_cron: default_signature_cleanup_cron(), } } } @@ -279,6 +294,15 @@ fn default_signature_dir() -> String { "./data/signatures".to_string() } +fn default_signature_retention_days() -> u32 { + 90 +} + +fn default_signature_cleanup_cron() -> String { + // täglich 04:00 + "0 0 4 * * *".to_string() +} + /// Lokaler Speicher für hochgeladene Bild-Notizen. Statt DOCUframe landen die /// Bilder pro Belegnummer in einem Unterordner (`//…`). #[derive(Debug, Clone, Deserialize)] diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index 4267016..b434e15 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -46,6 +46,7 @@ use holzleitner_application::usecases::{ UpdateServiceUseCase, UploadDeliveryNoteImageUseCase, }; use holzleitner_application::ports::DeliveryReportJobRepository; +use holzleitner_application::ports::SignatureStorage; use holzleitner_application::ports::{SyncRunRepository, SyncTrigger}; use holzleitner_infrastructure::auth::{ KeycloakAdapterConfig, KeycloakAdminClient, KeycloakAdminConfig, KeycloakAuthService, @@ -271,7 +272,6 @@ pub(crate) async fn run_app( gsd_service.clone(), attachment_repository.clone(), attachment_storage.clone(), - signature_storage.clone(), report_sink.clone(), )); @@ -368,7 +368,7 @@ pub(crate) async fn run_app( Arc::new(ApplyDeliveryActionUseCase::new(delivery_repository.clone())); let complete_delivery = Arc::new(CompleteDeliveryUseCase::new( delivery_completion_repository, - signature_storage, + signature_storage.clone(), car_repository.clone(), if cfg.erp.writeback_enabled { Some(push_completion_to_erp.clone()) @@ -631,6 +631,51 @@ pub(crate) async fn run_app( None }; + // --- Signatur-Cleanup-Scheduler ------------------------------------ + // Unterschriften bleiben nach dem Report-Upload bewusst erhalten (wir + // brauchen die Dateien) und werden hier periodisch gelöscht, sobald sie + // älter als die Aufbewahrungsfrist sind. retention_days = 0 ⇒ deaktiviert. + let _signature_cleanup_scheduler = if cfg.signature.retention_days > 0 { + let scheduler = JobScheduler::new() + .await + .context("Signatur-Cleanup-JobScheduler konnte nicht erstellt werden")?; + let signatures = signature_storage.clone(); + let max_age = Duration::from_secs(cfg.signature.retention_days as u64 * 86_400); + let job = Job::new_async(cfg.signature.cleanup_cron.as_str(), move |_uuid, _lock| { + let signatures = signatures.clone(); + Box::pin(async move { + match signatures.delete_older_than(max_age).await { + Ok(0) => {} + Ok(n) => tracing::info!( + deleted = n, + "signature_cleanup: abgelaufene Unterschriften gelöscht" + ), + Err(e) => tracing::error!(error = %e, "signature_cleanup fehlgeschlagen"), + } + }) + }) + .context("Signatur-Cleanup-Job konnte nicht erstellt werden")?; + scheduler + .add(job) + .await + .context("Signatur-Cleanup-Job add fehlgeschlagen")?; + scheduler + .start() + .await + .context("Signatur-Cleanup-Scheduler-Start fehlgeschlagen")?; + tracing::info!( + cron = %cfg.signature.cleanup_cron, + retention_days = cfg.signature.retention_days, + "signature_cleanup scheduler gestartet" + ); + Some(scheduler) + } else { + tracing::info!( + "signature_cleanup deaktiviert (signature.retention_days = 0) — Unterschriften werden nie automatisch gelöscht" + ); + None + }; + let addr: SocketAddr = format!("{}:{}", cfg.server.host, cfg.server.port) .parse() .with_context(|| format!("ungültige Adresse {}:{}", cfg.server.host, cfg.server.port))?; diff --git a/crates/application/src/ports/signature_storage.rs b/crates/application/src/ports/signature_storage.rs index 770c863..469478c 100644 --- a/crates/application/src/ports/signature_storage.rs +++ b/crates/application/src/ports/signature_storage.rs @@ -8,6 +8,8 @@ //! `holzleitner-infrastructure`. Der Use Case erhält eine relative //! Referenz zurück, die in `delivery_completions` persistiert wird. +use std::time::Duration; + use async_trait::async_trait; use uuid::Uuid; @@ -47,8 +49,14 @@ pub trait SignatureStorage: Send + Sync { /// Einbetten in den PDF-Report. `None`, wenn die Datei fehlt. async fn load(&self, reference: &str) -> Result>, ApplicationError>; - /// Löscht beide Unterschriften (Kunde + Fahrer) einer Lieferung — Aufräumen - /// nach erfolgreichem Report-Upload (die Unterschriften stecken dann - /// eingebettet im PDF in DOCUframe). Idempotent (fehlende Datei = ok). + /// Löscht beide Unterschriften (Kunde + Fahrer) einer Lieferung. Idempotent + /// (fehlende Datei = ok). Hinweis: Wird NICHT mehr beim Report-Upload + /// aufgerufen — Unterschriften bleiben erhalten und werden per Cron über + /// [`delete_older_than`](Self::delete_older_than) nach Frist entfernt. async fn delete_for_delivery(&self, delivery_id: Uuid) -> Result<(), ApplicationError>; + + /// Löscht alle Unterschrifts-Dateien, deren letzte Änderung länger als + /// `max_age` zurückliegt (Aufbewahrungsfrist). Für den periodischen + /// Cron-Cleanup. Liefert die Anzahl gelöschter Dateien. + async fn delete_older_than(&self, max_age: Duration) -> Result; } diff --git a/crates/application/src/usecases/process_delivery_report.rs b/crates/application/src/usecases/process_delivery_report.rs index 37d6d1f..63472ba 100644 --- a/crates/application/src/usecases/process_delivery_report.rs +++ b/crates/application/src/usecases/process_delivery_report.rs @@ -23,7 +23,7 @@ use uuid::Uuid; use crate::error::ApplicationError; use crate::ports::{ AttachmentRepository, AttachmentStorage, DeliveryReportJobRepository, DeliveryReportSink, - DocuframeReportGateway, ReportJobStatus, SignatureStorage, + DocuframeReportGateway, ReportJobStatus, }; use crate::usecases::GenerateDeliveryReportUseCase; @@ -33,7 +33,6 @@ pub struct ProcessDeliveryReportUseCase { gateway: Arc, attachment_repo: Arc, attachment_storage: Arc, - signatures: Arc, report_sink: Arc, } @@ -45,7 +44,6 @@ impl ProcessDeliveryReportUseCase { gateway: Arc, attachment_repo: Arc, attachment_storage: Arc, - signatures: Arc, report_sink: Arc, ) -> Self { Self { @@ -54,7 +52,6 @@ impl ProcessDeliveryReportUseCase { gateway, attachment_repo, attachment_storage, - signatures, report_sink, } } @@ -107,11 +104,14 @@ impl ProcessDeliveryReportUseCase { /// Aufräumen nach erfolgreichem Upload — best-effort (Fehler werden /// geschluckt; der Report liegt bereits sicher in DOCUframe): /// * lokale Report-PDFs - /// * Unterschriften (Kunde + Fahrer) /// * Bild-Notizen (Datei löschen + `deleted_at` setzen, Metadaten bleiben) + /// + /// **Unterschriften werden hier bewusst NICHT gelöscht.** Die Signatur- + /// Dateien bleiben im Backend erhalten (wir brauchen sie weiterhin) und + /// werden separat per Cron-Job gelöscht, sobald sie älter als die + /// Aufbewahrungsfrist (`signature.retention_days`) sind. async fn cleanup_local(&self, delivery_id: Uuid, belegnummer: &str) { let _ = self.report_sink.delete(belegnummer).await; - let _ = self.signatures.delete_for_delivery(delivery_id).await; if let Ok(refs) = self.attachment_repo.list_active_for_delivery(delivery_id).await { for r in refs { let _ = self.attachment_storage.delete(&r.reference).await; diff --git a/crates/infrastructure/src/storage/signature_storage.rs b/crates/infrastructure/src/storage/signature_storage.rs index 24741c9..54abcc3 100644 --- a/crates/infrastructure/src/storage/signature_storage.rs +++ b/crates/infrastructure/src/storage/signature_storage.rs @@ -7,6 +7,7 @@ //! Pfad — so bleibt ein Umzug des Verzeichnisses unkompliziert. use std::path::PathBuf; +use std::time::{Duration, SystemTime}; use async_trait::async_trait; use uuid::Uuid; @@ -96,4 +97,48 @@ impl SignatureStorage for LocalSignatureStorage { ApplicationError::Repository(format!("signatur löschen fehlgeschlagen: {e}")) }) } + + async fn delete_older_than(&self, max_age: Duration) -> Result { + let base = self.base_dir.clone(); + tokio::task::spawn_blocking(move || -> std::io::Result { + let now = SystemTime::now(); + let entries = match std::fs::read_dir(&base) { + Ok(e) => e, + // Verzeichnis (noch) nicht vorhanden → nichts zu löschen. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(e) => return Err(e), + }; + let mut deleted = 0u64; + for entry in entries { + let entry = entry?; + let meta = match entry.metadata() { + Ok(m) => m, + Err(_) => continue, + }; + if !meta.is_file() { + continue; + } + // Alter über die letzte Änderung (mtime) bestimmen. Lässt sich + // die mtime nicht lesen, Datei vorsichtshalber NICHT löschen. + let modified = match meta.modified() { + Ok(t) => t, + Err(_) => continue, + }; + let age = now.duration_since(modified).unwrap_or(Duration::ZERO); + if age > max_age { + match std::fs::remove_file(entry.path()) { + Ok(()) => deleted += 1, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + } + } + Ok(deleted) + }) + .await + .map_err(|e| ApplicationError::Repository(format!("join error: {e}")))? + .map_err(|e| { + ApplicationError::Repository(format!("signatur-cleanup fehlgeschlagen: {e}")) + }) + } }