feat(signature): Signaturen beim Report-Upload behalten + Cron-Cleanup nach Frist
Bisher loeschte die Report-Pipeline die Unterschriften nach erfolgreichem DOCUframe-Upload. Wir brauchen die Signatur-Dateien aber weiterhin, daher: - ProcessDeliveryReportUseCase: Signatur-Loeschung (delete_for_delivery) aus dem Cleanup entfernt + SignatureStorage-Dependency raus (Report-PDF/Bild-Notiz- Cleanup bleibt). - SignatureStorage: neue Methode delete_older_than(max_age) -> Anzahl; lokaler Adapter loescht PNGs aelter als die Frist (per mtime). - Config [signature]: retention_days (Default 90, 0 = aus) + cleanup_cron (Default taeglich 04:00). - main.rs: Signatur-Cleanup-Scheduler (gated retention_days > 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -69,6 +69,12 @@ retry_cron = "0 */5 * * * *"
|
|||||||
# --- Lokale Speicher (Signaturen / Bild-Notizen) --------------------------
|
# --- Lokale Speicher (Signaturen / Bild-Notizen) --------------------------
|
||||||
[signature]
|
[signature]
|
||||||
storage_dir = "./data/signatures"
|
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]
|
[attachment]
|
||||||
storage_dir = "./data/attachments"
|
storage_dir = "./data/attachments"
|
||||||
|
|||||||
@ -102,7 +102,12 @@ impl Config {
|
|||||||
app_names = ?self.gsd.app_names,
|
app_names = ?self.gsd.app_names,
|
||||||
"cfg.gsd"
|
"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.attachment.storage_dir, "cfg.attachment (Bild-Notizen lokal)");
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
storage_dir = %self.report.storage_dir,
|
storage_dir = %self.report.storage_dir,
|
||||||
@ -265,12 +270,22 @@ pub struct SignatureConfig {
|
|||||||
/// Basis-Verzeichnis für die PNG-Dateien (wird beim Start angelegt).
|
/// Basis-Verzeichnis für die PNG-Dateien (wird beim Start angelegt).
|
||||||
#[serde(default = "default_signature_dir")]
|
#[serde(default = "default_signature_dir")]
|
||||||
pub storage_dir: String,
|
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 {
|
impl Default for SignatureConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
storage_dir: default_signature_dir(),
|
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()
|
"./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
|
/// Lokaler Speicher für hochgeladene Bild-Notizen. Statt DOCUframe landen die
|
||||||
/// Bilder pro Belegnummer in einem Unterordner (`<dir>/<Belegnummer>/…`).
|
/// Bilder pro Belegnummer in einem Unterordner (`<dir>/<Belegnummer>/…`).
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
|||||||
@ -46,6 +46,7 @@ use holzleitner_application::usecases::{
|
|||||||
UpdateServiceUseCase, UploadDeliveryNoteImageUseCase,
|
UpdateServiceUseCase, UploadDeliveryNoteImageUseCase,
|
||||||
};
|
};
|
||||||
use holzleitner_application::ports::DeliveryReportJobRepository;
|
use holzleitner_application::ports::DeliveryReportJobRepository;
|
||||||
|
use holzleitner_application::ports::SignatureStorage;
|
||||||
use holzleitner_application::ports::{SyncRunRepository, SyncTrigger};
|
use holzleitner_application::ports::{SyncRunRepository, SyncTrigger};
|
||||||
use holzleitner_infrastructure::auth::{
|
use holzleitner_infrastructure::auth::{
|
||||||
KeycloakAdapterConfig, KeycloakAdminClient, KeycloakAdminConfig, KeycloakAuthService,
|
KeycloakAdapterConfig, KeycloakAdminClient, KeycloakAdminConfig, KeycloakAuthService,
|
||||||
@ -271,7 +272,6 @@ pub(crate) async fn run_app(
|
|||||||
gsd_service.clone(),
|
gsd_service.clone(),
|
||||||
attachment_repository.clone(),
|
attachment_repository.clone(),
|
||||||
attachment_storage.clone(),
|
attachment_storage.clone(),
|
||||||
signature_storage.clone(),
|
|
||||||
report_sink.clone(),
|
report_sink.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
@ -368,7 +368,7 @@ pub(crate) async fn run_app(
|
|||||||
Arc::new(ApplyDeliveryActionUseCase::new(delivery_repository.clone()));
|
Arc::new(ApplyDeliveryActionUseCase::new(delivery_repository.clone()));
|
||||||
let complete_delivery = Arc::new(CompleteDeliveryUseCase::new(
|
let complete_delivery = Arc::new(CompleteDeliveryUseCase::new(
|
||||||
delivery_completion_repository,
|
delivery_completion_repository,
|
||||||
signature_storage,
|
signature_storage.clone(),
|
||||||
car_repository.clone(),
|
car_repository.clone(),
|
||||||
if cfg.erp.writeback_enabled {
|
if cfg.erp.writeback_enabled {
|
||||||
Some(push_completion_to_erp.clone())
|
Some(push_completion_to_erp.clone())
|
||||||
@ -631,6 +631,51 @@ pub(crate) async fn run_app(
|
|||||||
None
|
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)
|
let addr: SocketAddr = format!("{}:{}", cfg.server.host, cfg.server.port)
|
||||||
.parse()
|
.parse()
|
||||||
.with_context(|| format!("ungültige Adresse {}:{}", cfg.server.host, cfg.server.port))?;
|
.with_context(|| format!("ungültige Adresse {}:{}", cfg.server.host, cfg.server.port))?;
|
||||||
|
|||||||
@ -8,6 +8,8 @@
|
|||||||
//! `holzleitner-infrastructure`. Der Use Case erhält eine relative
|
//! `holzleitner-infrastructure`. Der Use Case erhält eine relative
|
||||||
//! Referenz zurück, die in `delivery_completions` persistiert wird.
|
//! Referenz zurück, die in `delivery_completions` persistiert wird.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -47,8 +49,14 @@ pub trait SignatureStorage: Send + Sync {
|
|||||||
/// Einbetten in den PDF-Report. `None`, wenn die Datei fehlt.
|
/// Einbetten in den PDF-Report. `None`, wenn die Datei fehlt.
|
||||||
async fn load(&self, reference: &str) -> Result<Option<Vec<u8>>, ApplicationError>;
|
async fn load(&self, reference: &str) -> Result<Option<Vec<u8>>, ApplicationError>;
|
||||||
|
|
||||||
/// Löscht beide Unterschriften (Kunde + Fahrer) einer Lieferung — Aufräumen
|
/// Löscht beide Unterschriften (Kunde + Fahrer) einer Lieferung. Idempotent
|
||||||
/// nach erfolgreichem Report-Upload (die Unterschriften stecken dann
|
/// (fehlende Datei = ok). Hinweis: Wird NICHT mehr beim Report-Upload
|
||||||
/// eingebettet im PDF in DOCUframe). Idempotent (fehlende Datei = ok).
|
/// 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>;
|
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<u64, ApplicationError>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ use uuid::Uuid;
|
|||||||
use crate::error::ApplicationError;
|
use crate::error::ApplicationError;
|
||||||
use crate::ports::{
|
use crate::ports::{
|
||||||
AttachmentRepository, AttachmentStorage, DeliveryReportJobRepository, DeliveryReportSink,
|
AttachmentRepository, AttachmentStorage, DeliveryReportJobRepository, DeliveryReportSink,
|
||||||
DocuframeReportGateway, ReportJobStatus, SignatureStorage,
|
DocuframeReportGateway, ReportJobStatus,
|
||||||
};
|
};
|
||||||
use crate::usecases::GenerateDeliveryReportUseCase;
|
use crate::usecases::GenerateDeliveryReportUseCase;
|
||||||
|
|
||||||
@ -33,7 +33,6 @@ pub struct ProcessDeliveryReportUseCase {
|
|||||||
gateway: Arc<dyn DocuframeReportGateway>,
|
gateway: Arc<dyn DocuframeReportGateway>,
|
||||||
attachment_repo: Arc<dyn AttachmentRepository>,
|
attachment_repo: Arc<dyn AttachmentRepository>,
|
||||||
attachment_storage: Arc<dyn AttachmentStorage>,
|
attachment_storage: Arc<dyn AttachmentStorage>,
|
||||||
signatures: Arc<dyn SignatureStorage>,
|
|
||||||
report_sink: Arc<dyn DeliveryReportSink>,
|
report_sink: Arc<dyn DeliveryReportSink>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,7 +44,6 @@ impl ProcessDeliveryReportUseCase {
|
|||||||
gateway: Arc<dyn DocuframeReportGateway>,
|
gateway: Arc<dyn DocuframeReportGateway>,
|
||||||
attachment_repo: Arc<dyn AttachmentRepository>,
|
attachment_repo: Arc<dyn AttachmentRepository>,
|
||||||
attachment_storage: Arc<dyn AttachmentStorage>,
|
attachment_storage: Arc<dyn AttachmentStorage>,
|
||||||
signatures: Arc<dyn SignatureStorage>,
|
|
||||||
report_sink: Arc<dyn DeliveryReportSink>,
|
report_sink: Arc<dyn DeliveryReportSink>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@ -54,7 +52,6 @@ impl ProcessDeliveryReportUseCase {
|
|||||||
gateway,
|
gateway,
|
||||||
attachment_repo,
|
attachment_repo,
|
||||||
attachment_storage,
|
attachment_storage,
|
||||||
signatures,
|
|
||||||
report_sink,
|
report_sink,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -107,11 +104,14 @@ impl ProcessDeliveryReportUseCase {
|
|||||||
/// Aufräumen nach erfolgreichem Upload — best-effort (Fehler werden
|
/// Aufräumen nach erfolgreichem Upload — best-effort (Fehler werden
|
||||||
/// geschluckt; der Report liegt bereits sicher in DOCUframe):
|
/// geschluckt; der Report liegt bereits sicher in DOCUframe):
|
||||||
/// * lokale Report-PDFs
|
/// * lokale Report-PDFs
|
||||||
/// * Unterschriften (Kunde + Fahrer)
|
|
||||||
/// * Bild-Notizen (Datei löschen + `deleted_at` setzen, Metadaten bleiben)
|
/// * 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) {
|
async fn cleanup_local(&self, delivery_id: Uuid, belegnummer: &str) {
|
||||||
let _ = self.report_sink.delete(belegnummer).await;
|
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 {
|
if let Ok(refs) = self.attachment_repo.list_active_for_delivery(delivery_id).await {
|
||||||
for r in refs {
|
for r in refs {
|
||||||
let _ = self.attachment_storage.delete(&r.reference).await;
|
let _ = self.attachment_storage.delete(&r.reference).await;
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
//! Pfad — so bleibt ein Umzug des Verzeichnisses unkompliziert.
|
//! Pfad — so bleibt ein Umzug des Verzeichnisses unkompliziert.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -96,4 +97,48 @@ impl SignatureStorage for LocalSignatureStorage {
|
|||||||
ApplicationError::Repository(format!("signatur löschen fehlgeschlagen: {e}"))
|
ApplicationError::Repository(format!("signatur löschen fehlgeschlagen: {e}"))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn delete_older_than(&self, max_age: Duration) -> Result<u64, ApplicationError> {
|
||||||
|
let base = self.base_dir.clone();
|
||||||
|
tokio::task::spawn_blocking(move || -> std::io::Result<u64> {
|
||||||
|
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}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user